diff --git a/README.md b/README.md
index 268955ab8..b7d141564 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,14 @@ Destiny 2 Offline Preservation Mod
- [Documentation](https://projectsunrise.dev/docs/)
- [Discord](https://discord.gg/22JS6et5k9)
+## Opening the overlay
+
+After launching Destiny 2, press Insert to open or close the Sunrise overlay
+
+Insert is the default key and can be changed in `bin\x64\Sunrise\settings.json`
+
+See the [FAQ](https://projectsunrise.dev/faq/) for more information
+
## Features
- Load into any Destination (matchmade activities are currently broken)
- Script Missions
diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj
index 6cd0414ae..a180603f2 100644
--- a/Sunrise/Sunrise.vcxproj
+++ b/Sunrise/Sunrise.vcxproj
@@ -167,6 +167,7 @@
+
@@ -1293,6 +1294,7 @@
+
diff --git a/Sunrise/src/core/settings/client/client_ui_settings_parser.cpp b/Sunrise/src/core/settings/client/client_ui_settings_parser.cpp
index 9892275b8..629a83b7f 100644
--- a/Sunrise/src/core/settings/client/client_ui_settings_parser.cpp
+++ b/Sunrise/src/core/settings/client/client_ui_settings_parser.cpp
@@ -46,6 +46,16 @@ bool Parser::ui_toggle_key_value(std::string_view name, UINT& output) noexcept {
return false;
}
+/** Names one Windows SDK virtual key with the setting that selects it. */
+std::string_view Parser::ui_toggle_key_name(UINT virtualKey) noexcept {
+ for (const KeyName& entry : kToggleKeys) {
+ if (entry.virtualKey == virtualKey) {
+ return entry.name;
+ }
+ }
+ return {};
+}
+
/** Parses the in-game UI boot and input policy. */
bool Parser::client_ui_settings(ui::runtime::Settings& output) noexcept {
if (!consume('{')) {
diff --git a/Sunrise/src/core/settings/parser.h b/Sunrise/src/core/settings/parser.h
index 37d3a57a6..7b306d386 100644
--- a/Sunrise/src/core/settings/parser.h
+++ b/Sunrise/src/core/settings/parser.h
@@ -27,6 +27,9 @@ class Parser {
/** Reads only the root version; a missing version is zero. */
[[nodiscard]] bool parse_version(std::uint32_t& output) noexcept;
+ /** @return The settings name of a supported menu toggle key, or empty when it is not one. */
+ [[nodiscard]] static std::string_view ui_toggle_key_name(UINT virtualKey) noexcept;
+
private:
/** Parses the Core settings object. */
[[nodiscard]] bool core(Settings& output) noexcept;
diff --git a/Sunrise/src/core/ui/hud/overlay.h b/Sunrise/src/core/ui/hud/overlay.h
index 9e8382fa3..57fe2f768 100644
--- a/Sunrise/src/core/ui/hud/overlay.h
+++ b/Sunrise/src/core/ui/hud/overlay.h
@@ -8,6 +8,8 @@ namespace sunrise::core::ui::hud {
enum class Overlay : std::uint8_t {
/** The Sunrise name, version and animated logo. */
logoCard,
+ /** How to open the menu, shown once beside the card shortly after start. */
+ startupHint,
/** Where the player is: activity, bubble, slice set and closest spawn. */
currentStatus,
/** The instances of the session the player is in. */
diff --git a/Sunrise/src/core/ui/hud/overlays/ui_hud_startup_hint_overlay.cpp b/Sunrise/src/core/ui/hud/overlays/ui_hud_startup_hint_overlay.cpp
new file mode 100644
index 000000000..e685916fc
--- /dev/null
+++ b/Sunrise/src/core/ui/hud/overlays/ui_hud_startup_hint_overlay.cpp
@@ -0,0 +1,82 @@
+#include "ui_hud_startup_hint_overlay.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "../../../settings/parser.h"
+#include "../../runtime/ui_visibility_runtime.h"
+
+namespace sunrise::core::ui::hud::overlays::startup_hint {
+namespace {
+
+/** 2 seconds let the game settle before the hint asks for attention. */
+constexpr float kDelaySeconds = 2.0F;
+/** 30 seconds is long enough to notice the hint and short enough to clear itself. */
+constexpr float kVisibleSeconds = 30.0F;
+/** Half a second to fade in or out, slower than the menus so the hint stays subtle. */
+constexpr float kFadeSeconds = 0.5F;
+/** A hidden hint has finished fading out and draws nothing. */
+constexpr float kHiddenOpacity = 0.0F;
+/** A shown hint has finished fading in. */
+constexpr float kShownOpacity = 1.0F;
+/** Longest supported toggle key name, plus the null. */
+constexpr std::size_t kKeyNameCapacity = 16;
+
+/** Seconds since the first HUD frame, counted until the hint is dismissed. */
+float g_elapsedSeconds = 0.0F;
+/** Set once the surface opened or the stay ran out. The hint never returns in this run. */
+bool g_dismissed = false;
+/** The hint's own fade, so it needs no shared animation key. */
+float g_opacity = kHiddenOpacity;
+
+/**
+ * Names the configured toggle key in upper case, so it stands out from the muted text around it.
+ * The key always comes from the parser's table, because the settings reject any other one.
+ * @return Null-terminated key name.
+ */
+[[nodiscard]] std::array key_label() noexcept {
+ std::array label{};
+ const std::string_view name =
+ settings::parser::Parser::ui_toggle_key_name(runtime::snapshot().toggleVirtualKey);
+ const std::size_t length = (std::min)(name.size(), label.size() - 1);
+ for (std::size_t index = 0; index < length; ++index) {
+ label[index] = static_cast(std::toupper(static_cast(name[index])));
+ }
+ return label;
+}
+
+} // namespace
+
+/** Advances the hint's one-time lifetime by this frame. */
+float progress() noexcept {
+ const float deltaSeconds = ImGui::GetIO().DeltaTime;
+ if (!g_dismissed) {
+ g_elapsedSeconds += deltaSeconds;
+ // An open surface has taught the key, and a hint that came back later would nag.
+ if (runtime::snapshot().visible || g_elapsedSeconds >= kDelaySeconds + kVisibleSeconds) {
+ g_dismissed = true;
+ }
+ }
+ const bool shown = !g_dismissed && g_elapsedSeconds >= kDelaySeconds;
+ const float step = deltaSeconds / kFadeSeconds;
+ g_opacity = shown ? (std::min)(g_opacity + step, kShownOpacity)
+ : (std::max)(g_opacity - step, kHiddenOpacity);
+ return g_opacity;
+}
+
+/** Draws the startup hint inside the overlay window the stack has already started. */
+void draw() noexcept {
+ const std::array key = key_label();
+ // The sentence is muted like the card's version line; the key is white like its wordmark.
+ ImGui::TextDisabled("Press");
+ ImGui::SameLine();
+ ImGui::TextUnformatted(key.data());
+ ImGui::SameLine();
+ ImGui::TextDisabled("to open the Sunrise overlay");
+}
+
+} // namespace sunrise::core::ui::hud::overlays::startup_hint
diff --git a/Sunrise/src/core/ui/hud/overlays/ui_hud_startup_hint_overlay.h b/Sunrise/src/core/ui/hud/overlays/ui_hud_startup_hint_overlay.h
new file mode 100644
index 000000000..dbf2e4a5c
--- /dev/null
+++ b/Sunrise/src/core/ui/hud/overlays/ui_hud_startup_hint_overlay.h
@@ -0,0 +1,15 @@
+#pragma once
+
+namespace sunrise::core::ui::hud::overlays::startup_hint {
+
+/**
+ * Advances the hint's one-time lifetime by this frame: a short wait after the first HUD frame, a
+ * bounded stay, and an early end once the main surface opens. It never comes back in the same run.
+ * @return Current fade, from 0 (nothing to draw) to 1 (fully shown).
+ */
+[[nodiscard]] float progress() noexcept;
+
+/** Draws the startup hint inside the overlay window the stack has already started. */
+void draw() noexcept;
+
+} // namespace sunrise::core::ui::hud::overlays::startup_hint
diff --git a/Sunrise/src/core/ui/hud/ui_hud_overlay_stack.cpp b/Sunrise/src/core/ui/hud/ui_hud_overlay_stack.cpp
index f5fbae483..a6eb2c31d 100644
--- a/Sunrise/src/core/ui/hud/ui_hud_overlay_stack.cpp
+++ b/Sunrise/src/core/ui/hud/ui_hud_overlay_stack.cpp
@@ -13,6 +13,7 @@
#include "overlays/ui_hud_mission_script_overlay.h"
#include "overlays/ui_hud_sensor_events_overlay.h"
#include "overlays/ui_hud_session_overlay.h"
+#include "overlays/ui_hud_startup_hint_overlay.h"
#include "overlays/ui_hud_status_overlay.h"
#include "store/hud_settings_store.h"
@@ -48,6 +49,11 @@ constexpr std::size_t kSwitchCount = kOverlayCount + kStatusLineCount;
/** Every overlay, in Overlay order. The menu lists them and the corner stacks them in it. */
constexpr std::array kOverlays{
Entry{"Sunrise Card", "sunrise_card", "##sunrise_hud_card", &overlays::logo::draw, true},
+ Entry{"Startup Hint",
+ "startup_hint",
+ "##sunrise_hud_startup_hint",
+ &overlays::startup_hint::draw,
+ true},
// Diagnostic overlays start off because an ordinary run does not need them on screen.
Entry{
"Current Status", "current_status", "##sunrise_hud_status", &overlays::status::draw, false},
@@ -136,18 +142,18 @@ void save_switches() noexcept {
* Draws one overlay at a fixed position.
* @param entry Overlay to draw.
* @param position Top-left corner, in final framebuffer pixels.
- * @return Height the window took, which is known only after its content is submitted.
+ * @return Size the window took, which is known only after its content is submitted.
*/
-[[nodiscard]] float draw_overlay(const Entry& entry, const ImVec2& position) noexcept {
+[[nodiscard]] ImVec2 draw_overlay(const Entry& entry, const ImVec2& position) noexcept {
ImGui::SetNextWindowPos(position, ImGuiCond_Always);
const bool submitContents = ImGui::Begin(entry.windowId, nullptr, kOverlayFlags);
if (submitContents) {
entry.draw();
}
// Read inside the window, because the size belongs to it and not to the caller's window.
- const float height = ImGui::GetWindowSize().y;
+ const ImVec2 size = ImGui::GetWindowSize();
ImGui::End();
- return height;
+ return size;
}
} // namespace
@@ -217,14 +223,33 @@ bool draw(bool interfaceEnabled) noexcept {
const float margin = scaling::dpi::pixels(kViewportMargin);
const float gap = scaling::dpi::pixels(kOverlayGap);
ImVec2 position{viewport->WorkPos.x + margin, viewport->WorkPos.y + margin};
+ // The startup hint sits beside whichever overlay owns the top row, or on the corner when
+ // none does. It is drawn after the stack, once that row's width is known, and never moves
+ // the overlays stacked below.
+ ImVec2 hintPosition = position;
bool drawn = false;
for (std::size_t index = 0; index < kOverlayCount; ++index) {
- if (!g_enabled[index]) {
+ if (!g_enabled[index] || static_cast(index) == Overlay::startupHint) {
continue;
}
- position.y += draw_overlay(kOverlays[index], position) + gap;
+ const ImVec2 size = draw_overlay(kOverlays[index], position);
+ if (!drawn) {
+ hintPosition.x += size.x + gap;
+ }
+ position.y += size.y + gap;
drawn = true;
}
+ constexpr auto hintIndex = static_cast(Overlay::startupHint);
+ if (g_enabled[hintIndex]) {
+ const float progress = overlays::startup_hint::progress();
+ if (progress > 0.0F) {
+ // One style alpha fades the hint and everything drawn inside it together.
+ ImGui::PushStyleVar(ImGuiStyleVar_Alpha, progress);
+ (void)draw_overlay(kOverlays[hintIndex], hintPosition);
+ ImGui::PopStyleVar();
+ drawn = true;
+ }
+ }
return drawn;
}
diff --git a/Sunrise/src/core/ui/layout/ui_layout_render.cpp b/Sunrise/src/core/ui/layout/ui_layout_render.cpp
index 7ec406ced..4096e6c61 100644
--- a/Sunrise/src/core/ui/layout/ui_layout_render.cpp
+++ b/Sunrise/src/core/ui/layout/ui_layout_render.cpp
@@ -9,6 +9,7 @@
#include "../components/logo/ui_logo_component.h"
#include "../components/section/ui_section_component.h"
#include "../modules/registry/ui_module_registry.h"
+#include "../runtime/ui_visibility_runtime.h"
#include "../scaling/dpi/ui_dpi_scaling.h"
#include "navigation/ui_layout_navigation.h"
#include "ui_layout_lifecycle.h"
@@ -58,6 +59,12 @@ constexpr float kTitleTextRatio = 1.5F;
constexpr float kHalfExtent = 2.0F;
/** The surface names the tool with the same wordmark the HUD card carries. */
constexpr char kTitle[] = "SUNRISE";
+/** The hidden close action label is scoped by the main window. */
+constexpr char kCloseWidgetId[] = "##close";
+/** The close cross spans this fraction of its button, so it reads as a glyph and not a frame. */
+constexpr float kCloseCrossRatio = 0.5F;
+/** One pixel keeps the close cross as thin as the body text. */
+constexpr float kCloseCrossThickness = 1.0F;
/**
* Copies one display name into null-terminated component storage.
@@ -119,8 +126,15 @@ void draw_companion_windows() noexcept {
}
}
-/** Draws the animated logo, then the name and version, on one title row. */
-void draw_title() noexcept {
+/**
+ * Draws the animated logo, then the name and version, on one title row, with a close action
+ * at its far end.
+ * @return True when the close action was pressed.
+ */
+[[nodiscard]] bool draw_title() noexcept {
+ // The row edges are taken before it opens, so the close action can sit at its far end.
+ const ImVec2 rowOrigin = ImGui::GetCursorScreenPos();
+ const float rowRight = rowOrigin.x + ImGui::GetContentRegionAvail().x;
const float extent = scaling::dpi::pixels(kTitleLogoExtent);
const bool logoDrawn = components::logo::draw(extent);
if (logoDrawn) {
@@ -144,6 +158,31 @@ void draw_title() noexcept {
ImGui::SetCursorPosY(
titleY + ((std::max)(titleHeight - ImGui::GetTextLineHeight(), 0.0F) / kHalfExtent));
ImGui::TextDisabled(SUNRISE_VER_STRING);
+
+ // A cross at the right edge of the title row, centered on it. It stays on the row the logo
+ // opened, like the version, so the row and the separator below keep their places.
+ ImGui::SameLine();
+ const float buttonExtent = ImGui::GetFontSize();
+ const float rowHeight = logoDrawn ? extent : titleHeight;
+ const ImVec2 buttonPosition{
+ rowRight - buttonExtent,
+ rowOrigin.y + ((std::max)(rowHeight - buttonExtent, 0.0F) / kHalfExtent)};
+ ImGui::SetCursorScreenPos(buttonPosition);
+ const bool pressed = ImGui::InvisibleButton(kCloseWidgetId, {buttonExtent, buttonExtent});
+
+ // Muted like the version until hovered, then white like the title.
+ const ImU32 color =
+ ImGui::GetColorU32(ImGui::IsItemHovered() ? ImGuiCol_Text : ImGuiCol_TextDisabled);
+ const float half = buttonExtent * kCloseCrossRatio / kHalfExtent;
+ const ImVec2 center{buttonPosition.x + (buttonExtent / kHalfExtent),
+ buttonPosition.y + (buttonExtent / kHalfExtent)};
+ ImDrawList* drawList = ImGui::GetWindowDrawList();
+ const float thickness = scaling::dpi::pixels(kCloseCrossThickness);
+ drawList->AddLine(
+ {center.x - half, center.y - half}, {center.x + half, center.y + half}, color, thickness);
+ drawList->AddLine(
+ {center.x + half, center.y - half}, {center.x - half, center.y + half}, color, thickness);
+ return pressed;
}
} // namespace
@@ -186,7 +225,10 @@ bool render(bool visible) noexcept {
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, progress);
const bool submitContents = ImGui::Begin("Sunrise", nullptr, kMainWindowFlags);
if (submitContents) {
- draw_title();
+ // The close action only ever hides, so a surface already closing stays closed.
+ if (draw_title()) {
+ runtime::hide();
+ }
ImGui::Separator();
const StateSnapshot state = snapshot();
diff --git a/Sunrise/src/core/ui/runtime/ui_visibility_runtime.cpp b/Sunrise/src/core/ui/runtime/ui_visibility_runtime.cpp
index d7129db47..bb52796f1 100644
--- a/Sunrise/src/core/ui/runtime/ui_visibility_runtime.cpp
+++ b/Sunrise/src/core/ui/runtime/ui_visibility_runtime.cpp
@@ -63,4 +63,11 @@ bool toggle_for_key(UINT virtualKey) noexcept {
return handled;
}
+/** Closes the menu. A menu that is already closed or closing stays that way. */
+void hide() noexcept {
+ AcquireSRWLockExclusive(&g_visibilityLock);
+ g_state.visible = false;
+ ReleaseSRWLockExclusive(&g_visibilityLock);
+}
+
} // namespace sunrise::core::ui::runtime
diff --git a/Sunrise/src/core/ui/runtime/ui_visibility_runtime.h b/Sunrise/src/core/ui/runtime/ui_visibility_runtime.h
index 49d371b6b..8f1cd1c8b 100644
--- a/Sunrise/src/core/ui/runtime/ui_visibility_runtime.h
+++ b/Sunrise/src/core/ui/runtime/ui_visibility_runtime.h
@@ -35,4 +35,7 @@ void shutdown() noexcept;
*/
[[nodiscard]] bool toggle_for_key(UINT virtualKey) noexcept;
+/** Closes the menu. A menu that is already closed or closing stays that way. */
+void hide() noexcept;
+
} // namespace sunrise::core::ui::runtime