From a94bcba9c80c82512350d2908fd13609b55095d8 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sat, 10 Oct 2026 00:45:22 -0500 Subject: [PATCH] Pause menu The Multiplayer and Custom Stuff pages of the game's pause menu were built from the game's own menu widgets: a button, a text box and a line of text, which cannot make a list with columns. The game now keeps only the frame around each page (top bar, title, Back prompt) and ReSkate draws everything in it, tabs included. The native page is one empty body. Multiplayer - Server browser: a list with name, map and skaters columns that sort, search, filters (all, servers, lobbies, friends, hide full, my map), and a panel for the session pointed at. Pressing a row joins it; the row says JOIN while pointed at. Join by code is in the panel, and its own tab is gone. - Host lobby, as before. - Current session (only in one): who is here, leave, map, join code, party. - Voice chat (only in a session). - Session settings (only for a lobby's host or a server's admin). They are sent as explicit values, which a dedicated server needs. Custom Stuff - Official maps and Custom maps: pressing a map loads it. - World: traffic, pedestrians, time of day. - Parks: a card for each lot with its style, layout and load. - Player: noclip, no bail, Hall of Meat, board wear, the camera views, flight speed and the boosts. - Online: player and nametag distances, nametags, chat, chat bubbles, Discord status, and showing your tag and items. - Visuals: camera effects, challenges, shuffle playlists. - Binds: record or clear the button for each action. - The player card name is no longer on the page (cardname in the console still sets it). Input: the game keeps the mouse and controller while its menu is up, so the pages only watch them. They have their own focus, moved with the D-pad or arrow keys and pressed with A or Enter, with LT/RT or Z/C for tabs; lists remember the row the focus left. Typing in a text box or recording a bind takes the keyboard and controller for that long, as chat does. Nothing is taken while the game is not the window in front, and the overlay's own menu draws on top. Layout follows the game's menu at any window shape: scaled to fit whole, 192 units clear at the sides, centred past 4704 units wide. Sounds: the pages raise the game's own UI one-shot event for moving the focus, pressing and changing tab. --- Extension/UI/NativeMenu/native_menu.cpp | 112 +- Extension/UI/NativeMenu/native_menu.h | 6 +- .../UI/NativeMenu/native_menu_multiplayer.cpp | 420 +---- Extension/UI/NativeMenu/ui_sound.cpp | 145 ++ Extension/UI/NativeMenu/ui_sound.h | 17 + Extension/UI/Overlay/cursor.cpp | 9 +- Extension/UI/Overlay/hub_page.cpp | 1462 +++++++++++++++++ Extension/UI/Overlay/multiplayer_menu.cpp | 5 + Extension/UI/Overlay/overlay.cpp | 4 +- Extension/UI/Overlay/overlay.h | 19 + Extension/UI/Overlay/overlay_input.cpp | 4 +- Extension/UI/Overlay/overlay_internal.h | 9 + Extension/UI/Overlay/overlay_render.cpp | 8 +- Runtime/runtime_init.cpp | 4 + cmake/Runtime.cmake | 1 + cmake/UI.cmake | 1 + 16 files changed, 1745 insertions(+), 481 deletions(-) create mode 100644 Extension/UI/NativeMenu/ui_sound.cpp create mode 100644 Extension/UI/NativeMenu/ui_sound.h create mode 100644 Extension/UI/Overlay/hub_page.cpp diff --git a/Extension/UI/NativeMenu/native_menu.cpp b/Extension/UI/NativeMenu/native_menu.cpp index bf35f60d..3998e664 100644 --- a/Extension/UI/NativeMenu/native_menu.cpp +++ b/Extension/UI/NativeMenu/native_menu.cpp @@ -1,7 +1,9 @@ #include "native_menu.h" #include "native_menu_internal.h" #include "native_menu_dump.h" +#include "ui_sound.h" #include "Extension/Multiplayer/Session/session.h" +#include "Extension/Multiplayer/Hud/game_ui_state.h" #include "Engine/Core/Log/logging.h" #include "Engine/Core/Platform/launcher_support.h" #include "Extension/Profile/local_profile_runtime.h" @@ -9,6 +11,7 @@ #include "Engine/Game/Build/20260929/engine.h" #include "Engine/Game/Build/20260929/native_menu.h" #include +#include #include namespace dingosdk::multiplayer { @@ -117,51 +120,10 @@ namespace { void render(const Context& context, const MultiplayerModel& model) { auto& s = state(); ++s.pass; - const auto publish = [&](const std::vector& rows, Value list, unsigned slot, float width) { - s.row_width = width; - std::vector visible; - for (const auto& entry : rows) { - if (entry.input) add_input(context, visible, entry.id, entry.title, entry.argument); - else if (entry.button) add_button(context, visible, entry.id, entry.title, entry.command, entry.argument, entry.primary); - else add_text(context, visible, entry.id, entry.title); - } - publish_rows(context, list, slot, visible); - }; - if (s.slot == tools_page) { - const auto selected = read(context.address(context.path(s.page, {content, 0x18f8355b}))); - if (selected >= 0 && selected < static_cast(section_count)) s.section = static_cast
(selected); - for (unsigned i = 0; i < native_tools::sections.size(); ++i) { - const auto view = native_tools::render(s.tools, s.tools_model, s.tools_callbacks, i); - publish(view.main, s.lists[i], i * 2, main_width); - publish(view.side, s.side_lists[i], i * 2 + 1, side_width); - } - return; - } - if (model.active != s.was_active) { - s.section = model.active ? Section::session : Section::browser; - s.selected_player.clear(); - s.protected_lobby.clear(); - s.feedback.clear(); s.feedback_until = 0; - // Republish native widget lists when membership ends, even if a list - // happens to have the same row IDs as the previous offline page. - for (auto& displayed : s.displayed) displayed.clear(); - context.set(context.path(s.page, {content, 0x18f8355b}), static_cast(s.section)); - } else { - const auto selected = read(context.address(context.path(s.page, {content, 0x18f8355b}))); - if (selected >= 0 && selected < static_cast(section_count)) s.section = static_cast
(selected); - } - s.was_active = model.active; - // The browser coming into view (the game menu opening, or switching to it) - // fetches the latest lobbies. - const bool browsing = s.section == Section::browser && !model.active && !model.lobby_joining; - if (browsing && !s.browsing && !model.lobby_searching) queue_command("browse", "", {}); - s.browsing = browsing; - if (s.feedback_until && (GetTickCount64() >= s.feedback_until || model.status != s.feedback_status)) { - s.feedback.clear(); s.feedback_until = 0; - } - // Populate every section before it can be shown; tab switches never wait for - // the next model polling interval to create their controls. - for (unsigned i = 0; i < section_count; ++i) render_section(context, model, static_cast
(i)); + // Both pages are drawn by the overlay (Extension/UI/Overlay/hub_page.cpp), their tabs too: + // here each is one body with nothing in it. + s.section = Section::browser; + render_section(context, model, Section::browser); } void ensure_body(const Context& context) { auto& s = state(); @@ -215,7 +177,13 @@ void hide_top_bar_currency(const Context& context, Value menu) { } // namespace void update_hub(const Context& context); namespace { -void restore_last_tab(const Context& context, Value menu) { +// Which of ReSkate's pages is on screen, for the overlay (native_menu_page): when one was last +// seen selected with the pause menu up (GetTickCount64; 0: not now), and which. +std::atomic page_seen{}; +std::atomic page_shown{}; // 0: Multiplayer, 1: Custom Stuff +std::atomic game_in_menu{}; +// Returns the key of the tab the pause menu has selected (0: none of ours can be told). +std::uint32_t restore_last_tab(const Context& context, Value menu) { static std::uint32_t remembered{}; static bool reopening{}; static int previous = -2; @@ -223,7 +191,7 @@ void restore_last_tab(const Context& context, Value menu) { const auto target = context.path(menu, {content, 0x18f8355b}); unsigned count{}, stride{}; const auto values = context.array(list, 16, count, stride); - if (stride != stack_item.size) return; + if (stride != stack_item.size) return 0; const auto key_at = [&](int index) { std::uint32_t key{}; if (index >= 0 && static_cast(index) < count) std::memcpy(&key, values.data() + index * stride + 0x568, 4); @@ -234,7 +202,7 @@ void restore_last_tab(const Context& context, Value menu) { }; bool present{}; for (unsigned i = 0; i < count; ++i) present |= owned(key_at(static_cast(i))); - if (!present) { reopening = true; previous = -2; return; } + if (!present) { reopening = true; previous = -2; return 0; } const auto selected = read(context.address(target)); if (reopening) { reopening = false; @@ -246,12 +214,14 @@ void restore_last_tab(const Context& context, Value menu) { logging::printf(logging::Level::info, logging::Channel::ui, "Pause menu reopened on ReSkate tab %u.", i); } previous = static_cast(i); - return; + return key_at(static_cast(i)); } } - if (selected == previous) return; - previous = selected; - if (selected >= 0) remembered = owned(key_at(selected)) ? key_at(selected) : 0; + if (selected != previous) { + previous = selected; + if (selected >= 0) remembered = owned(key_at(selected)) ? key_at(selected) : 0; + } + return key_at(selected); } // Page Back is a button action: input UI_Back, navigation "ToggleMenu", no // delegate, handled by the shared navigation handler. An active action with @@ -468,8 +438,8 @@ void initialize(const Context& context, Root root, const MultiplayerModel& model context.text(context.path(s.page, {0x3781b603, text_field}), titles[s.slot]); context.set(context.field(s.page, 0xe1d821a3), true); context.set(context.field(s.page, 0x616cb924), false); - const auto sections = s.slot == tools_page ? std::vector(native_tools::sections.begin(), native_tools::sections.end()) : - std::vector{"Server Browser", "Host Lobby", "Join by Code", "Current Session", "Voice Chat"}; + // One body for either page: the overlay draws the page and its tabs. + const std::vector sections{" "}; s.section_total = static_cast(sections.size()); std::vector body_bytes(stack_item.size * sections.size()); for (unsigned i = 0; i < sections.size(); ++i) { @@ -551,20 +521,6 @@ void tick_page(std::uintptr_t base, bool loading) noexcept { std::string card_request; std::optional card_name; try { - if (s.slot == tools_page) { - { std::lock_guard callbacks_lock(callbacks_mutex); s.tools_callbacks = tools_callbacks; } - if (!s.tools_callbacks.read_model) return; - std::deque requests; - { std::lock_guard pending_lock(s.mutex); requests.swap(s.pending); } - if (!requests.empty() || now >= s.next_render) - s.tools_callbacks.read_model(s.tools_callbacks.user, s.tools_model); - for (const auto& request : requests) if (request.generation == s.generation.load()) { - // The card name field can only be read under the model lock below. - if (request.command == "card-name" || request.command == "card-name-reset") card_request = request.command; - else native_tools::activate(s.tools, s.tools_model, s.tools_callbacks, request.command, request.argument); - s.next_render = 0; - } - } const auto ui = read
(base + addr::engine::ui_manager); if (!ui) return; const auto manager = read
(ui + 0x140); @@ -603,9 +559,17 @@ void tick_page(std::uintptr_t base, bool loading) noexcept { if (!active.model.handle) return; trim_store(context, active.model); if (!s.slot) { - restore_last_tab(context, active.model); + const auto shown_tab = restore_last_tab(context, active.model); hide_top_bar_currency(context, active.model); update_hub(context); + // Tell the overlay while this page is the one on screen: it draws the body. + const bool ours = shown_tab == our_key || shown_tab == our_key + tools_page * 0x100; + const auto& shown_page = page_state(shown_tab == our_key ? 0 : tools_page); + const bool on_screen = ours && game_in_menu.load(std::memory_order_relaxed) && shown_page.page.handle && + context.type_of(shown_page.page.handle) == shown_page.page.type; + // (Said at once when another page takes its place, not left to go stale.) + page_shown.store(shown_tab == our_key ? 0 : 1, std::memory_order_relaxed); + page_seen.store(on_screen ? now : 0, std::memory_order_relaxed); } bool pending{}; { std::lock_guard pending_lock(s.mutex); pending = !s.pending.empty(); } @@ -729,8 +693,18 @@ void native_menu_before_level_transition(std::uintptr_t base, unsigned next) noe "Native ReSkate menu cleanup could not finish before state %u.", next); }); } +overlay::HubPage native_menu_page() noexcept { + // Seen every 50 ms while it is up; a little longer than that and it has gone. + const auto seen = page_seen.load(std::memory_order_relaxed); + if (!seen || GetTickCount64() - seen > 200) return {}; + return {true, page_shown.load(std::memory_order_relaxed)}; +} void tick_native_menu(std::uintptr_t base, bool loading) noexcept { run_pending_ui_dump(base); + // The menu sounds the overlay's pages asked for (ui_sound.h). + tick_ui_sounds(base, loading); + // (Read here: it takes the UI model's lock, which a page's tick then holds.) + game_in_menu.store(!loading && sample_game_ui_state(base).in_menu, std::memory_order_relaxed); if (lifetime.blocked()) return; for (unsigned slot = 0; slot < page_count; ++slot) { current_state = &page_state(slot); diff --git a/Extension/UI/NativeMenu/native_menu.h b/Extension/UI/NativeMenu/native_menu.h index 33d937f6..13c30954 100644 --- a/Extension/UI/NativeMenu/native_menu.h +++ b/Extension/UI/NativeMenu/native_menu.h @@ -2,7 +2,7 @@ #include #include -namespace dingosdk::overlay { struct CallbacksV3; } +namespace dingosdk::overlay { struct CallbacksV3; struct HubPage; } namespace dingosdk::multiplayer { // Call after the session tick, including while disconnected. All UI model @@ -17,4 +17,8 @@ void native_menu_before_level_transition(std::uintptr_t base, unsigned next) noe // Uses the same validated runtime queues as the overlay. The reader must be a // snapshot reader; it must not consume the overlay's console log cursor. void set_native_menu_callbacks(const overlay::CallbacksV3& callbacks); +// Whether one of ReSkate's pages (Multiplayer, Custom Stuff) is the pause menu's page on +// screen right now, and which. Any thread: the overlay draws the page from it +// (overlay::HubPageFeed). +overlay::HubPage native_menu_page() noexcept; } // namespace dingosdk::multiplayer diff --git a/Extension/UI/NativeMenu/native_menu_multiplayer.cpp b/Extension/UI/NativeMenu/native_menu_multiplayer.cpp index 8db8a946..0fa06e54 100644 --- a/Extension/UI/NativeMenu/native_menu_multiplayer.cpp +++ b/Extension/UI/NativeMenu/native_menu_multiplayer.cpp @@ -1,420 +1,30 @@ #include "native_menu_internal.h" -#include "Extension/Multiplayer/Session/session.h" -#include "Engine/Game/Input/voice_input.h" -#include -#include +// The pause menu's Multiplayer and Custom Stuff pages. The overlay draws everything in them +// (their tabs and what each tab holds: Extension/UI/Overlay/hub_page.cpp), because the game's +// menu widgets are a button, a text box and a line of text and cannot make a list with +// columns. What is left here is the page the game needs for each hub tab: one body, empty. namespace dingosdk::multiplayer { using namespace menu_data; using namespace native_menu_detail; -namespace { -bool copy_code_to_clipboard(const std::string& code) noexcept { - if (code.empty()) return false; - const auto window = GetForegroundWindow(); - DWORD process{}; - if (!window || !GetWindowThreadProcessId(window, &process) || process != GetCurrentProcessId()) return false; - const auto memory = GlobalAlloc(GMEM_MOVEABLE, (code.size() + 1) * sizeof(wchar_t)); - if (!memory) return false; - auto* text = static_cast(GlobalLock(memory)); - if (!text) { GlobalFree(memory); return false; } - // Join codes use ASCII, stored as Unicode text for other Windows apps. - std::copy(code.begin(), code.end(), text); - text[code.size()] = L'\0'; - GlobalUnlock(memory); - if (!OpenClipboard(window)) { GlobalFree(memory); return false; } - const bool copied = EmptyClipboard() && SetClipboardData(CF_UNICODETEXT, memory) != nullptr; - CloseClipboard(); - if (!copied) GlobalFree(memory); // Windows owns the allocation after success. - return copied; -} -// Voice buttons cycle through these steps, wrapping after the last. -constexpr std::array voice_distances{10.f, 20.f, 35.f, 50.f, 75.f, 100.f, 150.f, 300.f}; -constexpr std::array voice_volumes{0.f, .25f, .5f, .75f, 1.f, 1.5f, 2.f, 3.f, 5.f}; -template float next_step(float current, const std::array& steps) { - const auto found = std::upper_bound(steps.begin(), steps.end(), current + 0.001f); - return found == steps.end() ? steps.front() : *found; -} -std::string volume_label(float value) { - auto text = std::to_string(value); - return text.substr(0, text.find('.') + 3) + "x"; -} -} // namespace namespace native_menu_detail { -void process_clipboard() noexcept { +void process_clipboard() noexcept {} +void process_actions(const Context&, const MultiplayerModel&) { + // Nothing on the page has an action; anything queued is from a page since replaced. auto& s = state(); - if (!s.copy_code_requested) return; - s.copy_code_requested = false; - try { - const auto model = multiplayer::model(); - s.feedback = !model.active || model.invite.empty() ? "No active join code to copy." : - copy_code_to_clipboard(model.invite) ? "Join code copied." : "Couldn't copy the join code. Try again."; - s.feedback_status = model.status; - s.feedback_until = GetTickCount64() + 4000; - s.next_render = 0; - } catch (...) { } + std::lock_guard lock(s.mutex); + s.pending.clear(); } -void process_actions(const Context& context, const MultiplayerModel& model) { +void render_section(const Context& context, const MultiplayerModel&, Section section) { auto& s = state(); - if (!s.host_seeded && model.saved_host.loaded) { - // Start from the host settings used last time. - s.host_seeded = true; - s.public_lobby = model.saved_host.public_lobby; - s.capacity = static_cast(model.saved_host.capacity); - s.tps = model.saved_host.tps; - } - std::deque requests; - { std::lock_guard lock(s.mutex); requests.swap(s.pending); } - for (const auto& request : requests) { - if (request.generation != s.generation.load()) continue; - s.feedback_until = 0; - const auto& cmd = request.command; - if (cmd == "section") { - s.section = request.argument == "join" ? Section::join : request.argument == "session" ? Section::session : - request.argument == "host" ? Section::host : Section::browser; - context.set(context.path(s.page, {content, 0x18f8355b}), static_cast(s.section)); - s.feedback.clear(); - } else if (cmd == "filter-map") { - s.browser.same_map = !s.browser.same_map; - } else if (cmd == "sort") { - s.browser.sort = static_cast((static_cast(s.browser.sort) + 1) % 3); - } else if (cmd == "select-player") { - s.selected_player = menu_view::selected_player(model, request.argument) ? request.argument : ""; - s.feedback.clear(); - } else if (cmd == "session-details") { - s.selected_player.clear(); - s.feedback.clear(); - } else if (cmd == "copy-code") { - s.copy_code_requested = true; - } else if (cmd == "code-mode") { - s.protected_lobby.clear(); clear_input(context, "join-password"); - } else if (cmd == "visibility") { - if (!model.active && !model.lobby_joining) s.public_lobby = !s.public_lobby; - } else if (cmd == "capacity") { - if (!model.active && !model.lobby_joining) { - constexpr std::array limits{2, 4, 8, 16, multiplayer_lobby_player_limit}; - const auto next = std::upper_bound(limits.begin(), limits.end(), s.capacity); - s.capacity = next == limits.end() ? limits.front() : *next; - } - } else if (cmd == "voice-set") { - if (s.voice_pending && GetTickCount64() >= s.voice_pending_until) s.voice_pending.reset(); - auto value = s.voice_pending.value_or(model.voice.settings); - const auto& what = request.argument; - if (what == "enabled") value.enabled = !value.enabled; - else if (what == "open-mic") value.open_mic = !value.open_mic; - else if (what == "proximity") value.proximity = !value.proximity; - else if (what == "distance") value.distance = next_step(value.distance, voice_distances); - else if (what == "volume") value.volume = next_step(value.volume, voice_volumes); - else if (what == "microphone") value.microphone = next_step(value.microphone, voice_volumes); - else continue; - const auto argument = std::to_string(value.enabled) + " " + std::to_string(value.proximity) + " " + - std::to_string(value.push_to_talk) + " " + std::to_string(value.distance) + " " + std::to_string(value.volume) + " " + - std::to_string(value.open_mic) + " " + std::to_string(value.controller_combo) + " " + std::to_string(value.microphone); - if (queue_command("voice", argument, {})) { - s.voice_pending = value; - s.voice_pending_until = GetTickCount64() + 2000; - s.feedback.clear(); - } else s.feedback = "Couldn't apply that change. Try again."; - } else if (cmd == "voice-player-volume") { - const auto player = std::find_if(model.voice.players.begin(), model.voice.players.end(), - [&](const auto& entry) { return std::to_string(entry.id) == request.argument; }); - const auto volume = next_step(player == model.voice.players.end() ? 1.f : player->volume, voice_volumes); - s.feedback = queue_command("voice-volume", request.argument + " " + std::to_string(volume), {}) ? "" : - "Couldn't apply that change. Try again."; - } else if (cmd == "tps") { - if (!model.active && !model.lobby_joining) { - const auto next = std::upper_bound(multiplayer_tick_rates.begin(), multiplayer_tick_rates.end(), s.tps); - s.tps = next == multiplayer_tick_rates.end() ? multiplayer_tick_rates.front() : *next; - } - } else { - std::string command = cmd, argument = request.argument, password; - if (cmd == "host" || cmd == "join" || cmd == "join-lobby") { - // From a session they joined a player can hop to a server in the list (can_join). - const bool hop = cmd == "join-lobby" && model.active && !model.hosting && !model.echo; - if ((model.active && !hop) || model.lobby_joining) { s.feedback = "Leave the current session before connecting."; continue; } - if (cmd == "host") { - if (!model.local_ready) { s.feedback = "Load a map before hosting."; continue; } - const auto name = input_text(context, "host-name"); - if (name.size() > 128) { s.feedback = "Lobby name is too long. Use a shorter name."; continue; } - command = "host-config"; - argument = std::string(s.public_lobby ? "public " : "code ") + std::to_string(s.capacity) + - " " + std::to_string(s.tps) + " " + name; - password = input_text(context, "host-password"); - } else { - if (cmd == "join") password = input_text(context, "join-password"); - if (cmd == "join") { - argument = input_text(context, "join-code"); - if (!s.protected_lobby.empty()) { command = "join-lobby"; argument = s.protected_lobby; } - } - if (command == "join-lobby") { - const auto lobby = std::find_if(model.lobbies.begin(), model.lobbies.end(), [&](const auto& entry) { - return std::to_string(entry.id) == argument; - }); - if (lobby == model.lobbies.end() || !menu_view::can_join(model, *lobby)) { - SecureZeroMemory(password.data(), password.size()); - s.feedback = "That lobby is unavailable. Refresh the browser."; continue; - } - if (cmd == "join-lobby" && lobby->password_required) { - SecureZeroMemory(password.data(), password.size()); - s.protected_lobby = argument; - clear_input(context, "join-password"); - s.section = Section::join; - context.set(context.path(s.page, {content, 0x18f8355b}), static_cast(s.section)); - continue; - } - } - } - } - if (password.size() > 64) { - SecureZeroMemory(password.data(), password.size()); - s.feedback = "Password is too long. Use a shorter password."; continue; - } - if ((cmd == "kick" || cmd == "object-placement" || cmd == "object-limit" || cmd == "clear-objects" || cmd == "world-layer-sync" || - cmd == "voice-allow" || cmd == "noclip-allow" || cmd == "nobail-allow" || cmd == "boosts-allow" || - cmd == "tuning-enforce") && - !model.hosting) { - s.feedback = "Only the host can change this setting."; continue; - } - const bool accepted = queue_command(command, argument, password); - if (!password.empty()) SecureZeroMemory(password.data(), password.size()); - s.feedback = accepted ? "" : "Couldn't apply that change. Try again."; - s.feedback_status = model.status; - s.feedback_until = GetTickCount64() + 4000; - if (accepted && cmd == "host") clear_input(context, "host-password"); - if (accepted && (cmd == "join" || cmd == "join-lobby")) clear_input(context, "join-password"); - } - } -} -void render_section(const Context& context, const MultiplayerModel& model, Section section) { - auto& s = state(); - // Reuse the ReSkate page's catalog snapshot; no extra model reads or scans. - const auto& levels = page_state(1).tools_model.levels; - const bool idle = !model.active && !model.lobby_joining; const auto index = static_cast(section); + // One empty line in each column: a body with no rows at all is not one the game lays out, + // and a line of text is nothing the game can put its focus on. std::vector main, side; s.row_width = main_width; - if (section == Section::browser) { - auto query = input_text(context, "browser-search"); - if (query != s.browser.query) s.browser.query = std::move(query); - const auto result = menu_view::browse(model, s.browser, levels); - add_text(context, main, "browser-heading", "SERVERS"); - add_text(context, main, "browser-count", std::to_string(result.total) + " servers and public lobbies"); - if (result.lobbies.empty()) { - add_text(context, main, "browser-empty", model.lobby_searching ? "Finding skaters..." : - !model.lobby_searched ? "Find your next session." : model.lobbies.empty() ? - "No public lobbies right now." : "No lobbies match your filters."); - add_text(context, main, "browser-help", !model.lobby_searched ? "Choose Refresh lobbies to see who is skating." : - model.lobbies.empty() ? "Host a public lobby and invite other skaters." : "Try a different name or map."); - } - for (const auto* lobby : result.lobbies) { - const auto id = std::to_string(lobby->id); - const bool available = menu_view::can_join(model, *lobby); - const auto title = menu_view::caption(lobby->name) + (lobby->official ? " [OFFICIAL]" : lobby->dedicated ? " [SERVER]" : "") + "\n" + menu_view::caption(menu_view::map_name(lobby->map, levels), 32) + - " / " + std::to_string(lobby->players) + "/" + std::to_string(lobby->capacity) + - " skaters / " + (lobby->players >= lobby->capacity ? "FULL" : lobby->password_required ? "PASSWORD" : "OPEN") + - (lobby->friends.empty() ? std::string{} : " / WITH " + menu_view::caption(lobby_friends_text(*lobby), 40)); - add_button(context, main, "lobby-" + id, title, available ? "join-lobby" : "", id); - } - s.row_width = side_width; - add_text(context, side, "browser-tools", "FIND A SESSION"); - add_input(context, side, "browser-search", "Search lobby or map"); - add_button(context, side, "browser-map", s.browser.same_map ? "Map: Current map" : "Map: All maps", "filter-map"); - constexpr std::array sorts{"Lobby name", "Most players", "Map"}; - add_button(context, side, "browser-sort", std::string("Sort: ") + sorts[static_cast(s.browser.sort)], "sort"); - add_button(context, side, "refresh", model.lobby_searching ? "Searching..." : "Refresh lobbies", - model.lobby_searching ? "" : "browse", "", true); - add_text(context, side, "browser-status", s.feedback.empty() ? model.browser_status : s.feedback); - if (!idle) add_text(context, side, "browser-connected", model.active && !model.hosting && !model.echo - ? "Pick another server to leave this one and join it." : "End your session before joining another."); - } else if (section == Section::host) { - add_text(context, main, "host-heading", "MAKE IT YOUR SESSION"); - add_text(context, main, "host-name-label", "LOBBY NAME"); - add_input(context, main, "host-name", "Your Steam name", model.local_name); - add_text(context, main, "host-password-label", "PASSWORD (OPTIONAL)"); - add_input(context, main, "host-password", "Leave blank for an open lobby"); - add_text(context, main, "host-map", "MAP / " + menu_view::map_name(model.map, levels)); - s.row_width = side_width; - add_text(context, side, "host-options", "LOBBY SETTINGS"); - add_button(context, side, "visibility", (model.hosting ? model.public_host : s.public_lobby) ? - "Visibility: Public" : "Visibility: Join code", idle ? "visibility" : ""); - add_button(context, side, "capacity", "Skaters: " + std::to_string(model.hosting ? model.capacity : s.capacity), idle ? "capacity" : ""); - add_button(context, side, "tps", "Tick rate: " + std::to_string(model.hosting ? model.tps : s.tps), idle ? "tps" : ""); - add_text(context, side, "visibility-help", s.public_lobby ? "Listed in the public server browser." : "Only skaters with your code can join."); - add_button(context, side, "host", !idle ? "Session already active" : model.local_ready ? "Host lobby" : "Load a map to host", - idle && model.local_ready ? "host" : "", "", true); - add_text(context, side, "host-status", s.feedback.empty() ? model.status : s.feedback); - } else if (section == Section::join) { - const auto lobby = std::find_if(model.lobbies.begin(), model.lobbies.end(), [&](const auto& entry) { - return std::to_string(entry.id) == s.protected_lobby; - }); - const bool protected_join = !s.protected_lobby.empty(); - add_text(context, main, "join-heading", protected_join ? "PASSWORD REQUIRED" : "SKATE WITH YOUR FRIENDS"); - if (protected_join) { - add_text(context, main, "join-target", lobby != model.lobbies.end() ? menu_view::caption(lobby->name) : "Lobby is no longer listed. Refresh the browser."); - } else { - add_text(context, main, "join-code-label", "JOIN CODE"); - add_input(context, main, "join-code", "Paste a ReSkate join code"); - } - add_text(context, main, "join-password-label", "LOBBY PASSWORD"); - add_input(context, main, "join-password", protected_join ? "Enter the lobby password" : "Only needed for protected lobbies"); - s.row_width = side_width; - add_text(context, side, "join-info", protected_join ? "JOIN THIS LOBBY" : "GOT A CODE?"); - add_text(context, side, "join-help", protected_join ? "Enter the password shared by the host." : "Ask the host for their session code."); - add_text(context, side, "join-travel", "You will travel to the host's map."); - add_button(context, side, "join", model.lobby_joining ? "Joining..." : "Join lobby", - idle && (!protected_join || lobby != model.lobbies.end()) ? "join" : "", "", true); - if (protected_join) add_button(context, side, "join-code-mode", "Use a join code instead", "code-mode"); - add_text(context, side, "join-status", s.feedback.empty() ? model.status : s.feedback); - } else if (section == Section::voice) { - if (s.voice_pending && (*s.voice_pending == model.voice.settings || GetTickCount64() >= s.voice_pending_until)) - s.voice_pending.reset(); - const auto value = s.voice_pending.value_or(model.voice.settings); - add_text(context, main, "voice-heading", "SKATERS IN YOUR SESSION"); - bool anyone{}; - for (const auto& player : model.roster) { - if (player.id == model.local_id || !player.connected) continue; - anyone = true; - const auto id = std::to_string(player.id); - const auto found = std::find_if(model.voice.players.begin(), model.voice.players.end(), - [&](const auto& entry) { return entry.id == player.id; }); - const auto voice = found == model.voice.players.end() ? VoicePlayer{player.id} : *found; - add_button(context, main, "voice-mute-" + id, menu_view::caption(player.name, 32) + " / " + - (voice.muted ? "MUTED" : model.voice.allowed && voice.speaking ? "SPEAKING" : "Mute"), - "voice-mute", id + (voice.muted ? " off" : " on"), false, 112.f); - add_button(context, main, "voice-volume-" + id, "Volume: " + volume_label(voice.volume), - "voice-player-volume", id, false, 112.f); - } - if (!anyone) { - add_text(context, main, "voice-alone", model.active ? "No other skaters in the session yet." : "You are not in a session yet."); - add_text(context, main, "voice-alone-help", "These voice settings apply to every session you join."); - } - s.row_width = side_width; - add_text(context, side, "voice-settings", "VOICE SETTINGS", 48.f); - add_button(context, side, "voice-enabled", std::string("Voice chat: ") + (value.enabled ? "On" : "Off"), - "voice-set", "enabled", false, 136.f); - add_button(context, side, "voice-mode", std::string("Transmit: ") + (value.open_mic ? "Open mic" : "Push to talk"), - "voice-set", "open-mic", false, 136.f); - if (!value.open_mic) { - std::string binds = "Talk key: " + voice_key_name(value.push_to_talk); - if (value.controller_combo) binds += " / Pad: " + controller_combo_label(value.controller_combo); - add_text(context, side, "voice-binds", binds, 48.f); - } - add_button(context, side, "voice-proximity", std::string("Proximity voice: ") + (value.proximity ? "On" : "Off"), - "voice-set", "proximity", false, 136.f); - add_button(context, side, "voice-distance", "Voice distance: " + std::to_string(static_cast(value.distance + .5f)) + " m", - value.proximity ? "voice-set" : "", "distance", false, 136.f); - add_button(context, side, "voice-volume", "Listening volume: " + volume_label(value.volume), "voice-set", "volume", false, 136.f); - add_button(context, side, "voice-microphone", "Microphone volume: " + volume_label(value.microphone), - "voice-set", "microphone", false, 136.f); - if (model.hosting) - add_button(context, side, "voice-allow", std::string("Lobby voice: ") + (model.voice.allowed ? "Allowed" : "Disabled"), - "voice-allow", model.voice.allowed ? "off" : "on", false, 136.f); - else if (model.active && !model.voice.allowed) - add_text(context, side, "voice-disallowed", "The host has disabled voice chat for this lobby.", 48.f); - add_text(context, side, "voice-status", !s.feedback.empty() ? s.feedback : - model.voice.transmitting ? "MICROPHONE TRANSMITTING" : model.voice.status, 48.f); - } else { - add_text(context, main, "session-heading", model.active ? menu_view::caption(model.lobby_name.empty() ? "CURRENT SESSION" : model.lobby_name) : "YOUR SESSION"); - if (!model.active) { - add_text(context, main, "no-session", "You are not in a session yet."); - add_text(context, main, "session-help", "Find a public lobby, host your own, or join with a code."); - add_button(context, main, "session-browse", "Find a public lobby", "section", "browser", true); - } else { - add_text(context, main, "session-roster", "SKATERS / " + std::to_string(model.players) + "/" + std::to_string(model.capacity)); - add_button(context, main, "local-player", menu_view::caption(model.local_name) + - (model.hosting ? " / YOU - HOST" : model.parties && model.party_leader ? " / YOU - PARTY LEADER" : " / YOU"), - {}, {}, false, 112.f); - for (const auto& player : model.roster) { - if (player.id == model.local_id || !player.connected) continue; - const auto id = std::to_string(player.id); - // Your party members are marked. - const std::string party = !model.parties || !player.party_member ? "" - : player.party_leader ? " / PARTY LEADER" : " / PARTY"; - add_button(context, main, "player-" + id, menu_view::caption(player.name) + - (player.id == model.host_id ? " / HOST" : party), "select-player", menu_view::player_identity(player), false, 112.f); - } - } - s.row_width = side_width; - const auto* selected = menu_view::selected_player(model, s.selected_player); - if (!selected) s.selected_player.clear(); - if (selected) { - add_text(context, side, "player-options", "PLAYER OPTIONS"); - add_text(context, side, "selected-player-name", menu_view::caption(selected->name, 32)); - add_text(context, side, "selected-player-role", selected->id == model.host_id ? "Lobby host" - : model.parties && selected->party_member ? (selected->party_leader ? "Your party's leader" : "In your party") - : model.parties && selected->party ? "In another party" : "Connected skater"); - if (model.hosting && selected->id != model.host_id) - add_button(context, side, "remove-player", "Remove from session", "kick", menu_view::player_identity(*selected)); - if (model.parties) { - const auto id = std::to_string(selected->id); - if (!selected->party_member) { - add_button(context, side, "party-invite", "Invite to party", "party", "invite " + id); - if (selected->party) add_button(context, side, "party-join", "Join their party", "party", "join " + id); - } else if (model.party_leader) { - add_button(context, side, "party-kick", "Remove from party", "party", "kick " + id); - add_button(context, side, "party-promote", "Make party leader", "party", "promote " + id); - } - } - add_button(context, side, "back-to-session", "Session details", "session-details"); - } else if (model.active) { - add_text(context, side, "session-details", "SESSION DETAILS", 48.f); - add_text(context, side, "map", menu_view::map_name(model.map, levels), 48.f); - add_text(context, side, "invite-label", "JOIN CODE", 48.f); - add_text(context, side, "invite", model.invite, 80.f); - add_text(context, side, "session-tps", "Tick rate: " + std::to_string(model.tps), 48.f); - if (model.parties) { - // Parties: yours, and the invites waiting for an answer. - for (const auto& invite : model.party_invites) { - const auto from = std::to_string(invite.from); - add_text(context, side, "party-invite-" + from, menu_view::caption(invite.name, 32) + " invited you to their party", 48.f); - add_button(context, side, "party-accept-" + from, "Accept party invite", "party", "accept " + from, false, 136.f); - add_button(context, side, "party-decline-" + from, "Decline", "party", "decline " + from, false, 136.f); - } - if (model.party) { - if (model.party_leader) - add_button(context, side, "party-privacy", std::string("Party: ") + (model.party_open ? "Anyone can join" : "Invite only"), - "party", model.party_open ? "close" : "open", false, 136.f); - add_button(context, side, "party-leave", "Leave party", "party", "leave", false, 136.f); - } else { - add_text(context, side, "party-none", "Not in a party: select a skater to invite them.", 48.f); - } - } - const auto placement = model.dedicated && model.object_placement == ObjectPlacement::host_only - ? std::string("Admins only") : std::string(object_placement_name(model.object_placement)); - add_button(context, side, "editor", "Object placement: " + placement, - model.hosting ? "object-placement" : "", "next", false, 136.f); - { - // The same round numbers as the overlay's list, one press each. - static constexpr std::array limits{0, 10, 25, 50, 100, 250, 500}; - const auto found = std::find(limits.begin(), limits.end(), model.object_limit); - const auto next = limits[found == limits.end() ? 0 : static_cast(found - limits.begin() + 1) % limits.size()]; - add_button(context, side, "object-limit", - "Objects per player: " + (model.object_limit ? std::to_string(model.object_limit) : std::string("No limit")), - model.hosting ? "object-limit" : "", next ? std::to_string(next) : std::string("off"), false, 136.f); - } - if (model.hosting) - add_button(context, side, "clear-objects", "Delete all guest objects", "clear-objects", {}, false, 136.f); - add_button(context, side, "guest-noclip", std::string("Guest noclip: ") + (model.guest_noclip ? "Allowed" : "Off"), - model.hosting ? "noclip-allow" : "", "toggle", false, 136.f); - add_button(context, side, "guest-nobail", std::string("Guest No Bail: ") + (model.guest_no_bail ? "Allowed" : "Off"), - model.hosting ? "nobail-allow" : "", "toggle", false, 136.f); - add_button(context, side, "guest-boosts", std::string("Guest boosts: ") + (model.guest_boosts ? "Allowed" : "Off"), - model.hosting ? "boosts-allow" : "", "toggle", false, 136.f); - add_button(context, side, "tuning", std::string("Physics tuning: ") + - (model.enforce_tuning ? (model.dedicated ? "Game's" : "Host's") : "Everyone's own"), - model.hosting ? "tuning-enforce" : "", "toggle", false, 136.f); - add_button(context, side, "layers", std::string("World layer sync: ") + (model.force_world_layers ? "On" : "Off"), - model.hosting ? "world-layer-sync" : "", "toggle", false, 136.f); - add_button(context, side, "nametags", std::string("Nametags: ") + (model.nametags ? "On" : "Off"), - "nametags", "toggle", false, 136.f); - add_button(context, side, "chat-bubbles", std::string("Chat bubbles: ") + (model.chat_bubbles ? "On" : "Off"), - "chat-bubbles", "toggle", false, 136.f); - add_button(context, side, "chat-bubbles-own", std::string("Own chat bubbles: ") + (model.chat_bubbles_own ? "On" : "Off"), - model.chat_bubbles ? "chat-bubbles-own" : "", "toggle", false, 136.f); - add_button(context, side, "copy-code", "Copy join code", model.invite.empty() ? "" : "copy-code", {}, false, 136.f); - add_button(context, side, "leave", model.hosting ? "End session" : "Leave session", "stop", {}, false, 136.f); - } - add_text(context, side, "session-status", s.feedback.empty() ? model.status : s.feedback, 48.f); - } + add_text(context, main, "blank", " "); + s.row_width = side_width; + add_text(context, side, "blank-side", " "); publish_rows(context, s.lists[index], index * 2, main); publish_rows(context, s.side_lists[index], index * 2 + 1, side); } diff --git a/Extension/UI/NativeMenu/ui_sound.cpp b/Extension/UI/NativeMenu/ui_sound.cpp new file mode 100644 index 00000000..c4b44872 --- /dev/null +++ b/Extension/UI/NativeMenu/ui_sound.cpp @@ -0,0 +1,145 @@ +#include "ui_sound.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/supported_build.h" +#include "Engine/Game/Build/20260929/native_party.h" +#include "Engine/Game/Build/20260929/native_throwdowns.h" +#include "Extension/Throwdowns/native_type_scan.h" +#include +#include +#include +#include +#include + +namespace dingosdk::multiplayer { +namespace { +namespace throwdowns = addr::native_throwdowns; +namespace party = addr::native_party; +// EBX TypeNameHash of the event, and the NameHash of its UiAudioEnum field. +constexpr std::uint32_t play_one_shot = 1415301872U, sound_field = 1903611779U, none = ~0U; + +std::atomic wanted{none}; +// The event's type object. Its type is defined by data a level's bundles carry: it is made +// again, somewhere else, by each level, so it is found on the heap (off this thread) and +// checked before every use. +std::atomic type_object{}; +std::atomic scanning{}, off{}; +std::atomic scan_after{}; + +template bool matches(std::uintptr_t address, const std::array &prefix) { + std::array bytes{}; + return memory::read_bytes(address, bytes.data(), bytes.size()) && bytes == prefix; +} +// The five game functions used here are the ones this build's party and throwdown code calls. +bool supported(std::uintptr_t base) { + static const bool ok = [base] { + if (!matches(base + throwdowns::type_construct, throwdowns::type_construct_prefix) || + !matches(base + throwdowns::type_destroy, throwdowns::type_destroy_prefix)) + return false; + for (const auto rva : {party::current_context, party::event_dispatcher, party::event_post}) { + bool found{}; + for (const auto &contract : party::party_request_contracts) + if (contract.rva == rva) found = matches(base + rva, contract.bytes); + if (!found) return false; + } + return true; + }(); + return ok; +} +// Whether `object` is still the event's type: its record names the event and constructs +// through code (the same test the throwdown code makes before it constructs one). +bool type_alive(std::uintptr_t base, std::uintptr_t object) { + std::uintptr_t record{}, construct{}; + std::uint32_t name{}; + if (!object || !memory::peek(object, record) || !memory::peek(record, name) || name != play_one_shot || + !memory::peek(record + 0x30, construct)) + return false; + if (construct >= base && construct - base < supported_build::game_image_size) return true; + MEMORY_BASIC_INFORMATION info{}; + return VirtualQuery(reinterpret_cast(construct), &info, sizeof(info)) && info.State == MEM_COMMIT && + !(info.Protect & PAGE_GUARD) && + (info.Protect & (PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY)); +} +// Where the sound's number goes in an instance of the event: a 4-byte field. +std::optional sound_offset(std::uintptr_t type) { + std::uintptr_t record{}, fields{}; + std::uint16_t count{}; + if (!memory::peek(type, record) || !memory::peek(record + 0x2a, count) || !memory::peek(record + 0x60, fields) || !fields || count > 16) + return std::nullopt; + for (unsigned i = 0; i < count; ++i) { + const auto entry = fields + i * 0x18; + std::uint32_t name{}; + std::uint16_t offset{}, size{}; + std::uintptr_t field_type{}, field_record{}; + if (!memory::peek(entry, name) || name != sound_field) continue; + if (!memory::peek(entry + 8, offset) || !memory::peek(entry + 0x10, field_type) || !memory::peek(field_type, field_record) || + !memory::peek(field_record + 6, size) || size != 4) + return std::nullopt; + return offset; + } + return std::nullopt; +} +// Makes an instance of the event, sets the sound and raises it as the game raises its own +// events (native_party.h: the dispatcher copies the instance, which is then destroyed). +// False when a step faulted: the sounds are switched off then. +bool raise(std::uintptr_t base, std::uintptr_t type, std::uint16_t offset, std::uint32_t sound) noexcept { + __try { + const auto allocator = *reinterpret_cast(base + throwdowns::event_allocator); + const auto value = reinterpret_cast(base + throwdowns::type_construct)(type, allocator); + if (!value) return true; + *reinterpret_cast(value + offset) = sound; + alignas(16) unsigned char context[16]{}; + reinterpret_cast(base + party::current_context)(context); + const auto dispatcher = reinterpret_cast(base + party::event_dispatcher)(context); + if (dispatcher && *reinterpret_cast(dispatcher + 0x28)) { + struct Options { float delay; std::uint32_t count; std::uint32_t flags; } options{0.f, 1, 0}; + reinterpret_cast(base + party::event_post)( + dispatcher, type, reinterpret_cast(value), &options, 0); + } + reinterpret_cast(base + throwdowns::type_destroy)(type, allocator, value); + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} +} // namespace + +void queue_ui_sound(std::uint32_t sound) noexcept { wanted.store(sound, std::memory_order_relaxed); } + +void tick_ui_sounds(std::uintptr_t base, bool loading) noexcept { + const auto sound = wanted.exchange(none, std::memory_order_relaxed); + if (sound == none || loading || !base || off.load(std::memory_order_relaxed)) return; + try { + if (!supported(base)) { + off = true; + logging::log(logging::Level::info, logging::Channel::ui, "Menu sounds: this game build differs from the supported one; ReSkate's pages stay silent."); + return; + } + const auto type = type_object.load(std::memory_order_acquire); + if (!type_alive(base, type)) { + // Not found yet, or left behind with the last level: look for it off this thread (the + // scan takes a few hundred milliseconds). This sound is skipped; the next ones play. + type_object.store(0, std::memory_order_release); + if (GetTickCount64() < scan_after.load(std::memory_order_relaxed) || scanning.exchange(true)) return; + std::thread([] { + NativeTypeQuery query{play_one_shot, 0}; + try { + find_native_types(std::span(&query, 1), true); + } catch (...) {} + if (query.object) type_object.store(query.object, std::memory_order_release); + else scan_after.store(GetTickCount64() + 5000, std::memory_order_relaxed); + scanning.store(false); + }).detach(); + return; + } + const auto offset = sound_offset(type); + if (!offset || !raise(base, type, *offset, sound)) { + off = true; + logging::log(logging::Level::warning, logging::Channel::ui, "Menu sounds: the game's sound event could not be raised; ReSkate's pages stay silent."); + } + } catch (...) { + scanning.store(false); + } +} +} // namespace dingosdk::multiplayer diff --git a/Extension/UI/NativeMenu/ui_sound.h b/Extension/UI/NativeMenu/ui_sound.h new file mode 100644 index 00000000..d6289a69 --- /dev/null +++ b/Extension/UI/NativeMenu/ui_sound.h @@ -0,0 +1,17 @@ +#pragma once +#include + +// The game's own menu sounds, for the pages ReSkate draws in its pause menu. The game plays +// them through its UI audio manager, which listens for one event +// (Audio/UI/Manager/Events/DGO_UI_PlayOneShot_Event) carrying which sound +// (Audio/_Systems/EnumTypeInfoAssets/DGO_Audio_UI_OneShot_Enum). This raises that event the +// way the game's own code raises events, so the sounds are the game's, at the player's own +// volume settings. +namespace dingosdk::multiplayer { +// Values of the game's enum. +enum class UiSound : std::uint32_t { cancel = 0, navigate = 3, toggle = 5, select = 7, tab = 56 }; +// Any thread: the sound is played at the next client tick (the newest asked for wins). +void queue_ui_sound(std::uint32_t sound) noexcept; +// Client thread, each tick while a world is up. +void tick_ui_sounds(std::uintptr_t base, bool loading) noexcept; +} // namespace dingosdk::multiplayer diff --git a/Extension/UI/Overlay/cursor.cpp b/Extension/UI/Overlay/cursor.cpp index 9f6b7414..57dc0e30 100644 --- a/Extension/UI/Overlay/cursor.cpp +++ b/Extension/UI/Overlay/cursor.cpp @@ -137,13 +137,18 @@ void update_menu_pointer() { auto& s = state(); auto& io = ImGui::GetIO(); const bool active = owns_menu_cursor(s); - io.AddFocusEvent(active); + // The pause menu's server browser watches the pointer while the game keeps it: the same + // reads, with the game's own cursor on screen and nothing taken from the game. + const bool watching = !active && s.hub_pointer.load() && s.input_attached && !s.stop && !s.failed && + game_window_foreground(s.window); + io.AddFocusEvent(active || watching); io.MouseDrawCursor = active; - if (!active) { + if (!active && !watching) { io.ClearInputKeys(); io.ClearInputMouse(); s.raw_mouse_buttons.store(0); return; } + if (watching) io.ClearInputKeys(); OverlayInputAccess access; POINT point{}; // The Win32 backend only polls when it thinks WM_MOUSEMOVE tracking ended. diff --git a/Extension/UI/Overlay/hub_page.cpp b/Extension/UI/Overlay/hub_page.cpp new file mode 100644 index 00000000..f80d6f39 --- /dev/null +++ b/Extension/UI/Overlay/hub_page.cpp @@ -0,0 +1,1462 @@ +#include "overlay_internal.h" +#include "skate_style.h" +#include "cursor.h" +#include "input_capture.h" +#include "Extension/UI/NativeMenu/native_menu_view.h" +#include "Extension/UI/NativeMenu/native_tools_view.h" +#include "Extension/Boot/discord_presence.h" +#include "Extension/Music/local_music_playback.h" +#include "Extension/Profile/local_profile_runtime.h" +#include "Engine/Game/Input/controller_bindings.h" +#include "Engine/Game/Input/voice_input.h" +#include "Engine/Game/Multiplayer/object_placement.h" +#include "Engine/Game/Multiplayer/session_limits.h" +#include "Engine/Game/Multiplayer/tick_settings.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ReSkate's two pages of the game's own pause menu, Multiplayer and Custom Stuff, drawn by +// ReSkate: the game's menu widgets are a button, a text box and a line of text, which cannot +// make a list with columns, so the game keeps the frame around a page (its top bar, title and +// Back prompt) and this draws everything in it: the page's tabs and whichever is open (the +// server browser, hosting, the session, voice chat; travel, parks, the player and visuals). +// +// The game still owns the mouse and the controller while its menu is up, so this layer only +// watches them (State::hub_pointer). It keeps its own focus, moved with the D-pad or the arrow +// keys and pressed with A or Enter, so every control is reachable without a mouse. Typing in a +// text box is the exception: for that long the overlay takes the keyboard, as the chat box +// does (State::hub_typing). + +namespace dingosdk::overlay { +namespace { +std::atomic page_feed{}; +std::atomic ui_sound{}; +std::mutex hub_callbacks_mutex; +CallbacksV3 hub_callbacks; // under the mutex +} // namespace +void set_hub_page_feed(HubPageFeed feed) noexcept { page_feed.store(feed); } +void set_ui_sound(UiSound play) noexcept { ui_sound.store(play); } +void set_hub_callbacks(const CallbacksV3 &callbacks) noexcept { + std::lock_guard lock(hub_callbacks_mutex); + hub_callbacks = callbacks; +} +} // namespace dingosdk::overlay + +namespace dingosdk::overlay::detail { +namespace { +namespace view = dingosdk::multiplayer::menu_view; +using Clock = std::chrono::steady_clock; +using namespace std::chrono_literals; + +// The game's menu is laid out on a screen of 3840 by 2160 units, scaled to fit the window +// whole (so by its width in a tall window and its height in a wide one). What does not fit +// the 16:9 of that is given to the menu too: up to 4704 units across, centred in anything +// wider, and all of a taller window's height, where the top of the page moves down and the +// Back prompt up by a twentieth of the extra. Its pages keep 192 units clear at the sides. +// In those units, on a 16:9 screen: the page's tabs sit under its title at 572, its body +// starts at 770, and its Back prompt sits under 1905. (Measured on the game's own pages at +// 16:9, 21:9, 32:9, 11:7 and a window taller than wide.) +constexpr float layout_width = 3840.f, layout_height = 2160.f, layout_widest = 4704.f, side_margin = 192.f, tabs_top = 572.f, + tabs_height = 84.f, body_top = 770.f, body_bottom = 1905.f, body_most = 4400.f; +constexpr ImU32 sticky = IM_COL32(236, 215, 108, 255), sticky_hover = IM_COL32(246, 228, 132, 255), + sticky_off = IM_COL32(120, 114, 80, 255), row_hover = IM_COL32(52, 53, 57, 240), + hint_ink = IM_COL32(110, 110, 110, 255), soft = IM_COL32(210, 210, 214, 255); + +enum class Tab { browser, host, session, voice, settings }; +// The game's own menu sounds, by their numbers in its list (UI/NativeMenu/ui_sound.h). +constexpr std::uint32_t sound_navigate = 3, sound_select = 7, sound_tab = 56; +enum class Kind { all, servers, lobbies }; +enum class Look { tile, sticky, plain, chosen }; + +constexpr std::uint32_t hash(std::string_view text, std::uint64_t salt = 0) { + std::uint32_t value = 2166136261U ^ static_cast(salt * 2654435761ULL); + for (const char c : text) value = (value ^ static_cast(c)) * 16777619U; + return value ? value : 1; +} + +struct Row { + const MultiplayerLobby *lobby{}; + std::string map; + bool joinable{}, here{}, full{}, own{}; +}; +struct Item { + std::uint32_t id{}; + ImVec2 a, b; + std::uint32_t list{}; // the scrolling list it is a row of, or 0 +}; + +struct Hub { + HubPage page; + Clock::time_point next_page{}, next_model{}; + Model full; // the game as the menus show it, read a few times a second + MultiplayerModel model; // its multiplayer part + CallbacksV3 callbacks; + bool shown{}, dismissed{}, was_active{}, searched{}; + // Custom Stuff: the same rows and actions the page has always had (native_tools_view.h). + native_tools::State tools; + int tools_tab{}; + // Recording a bind (the BINDS tab): which action (0: none), what has been pressed so far, + // and when it gives up (GetTickCount64). + int recording_bind{}; + ControllerComboCapture bind_capture; + ULONGLONG recording_until{}; + Tab tab{Tab::browser}; + // The browser. + std::vector rows; + std::size_t listed{}; + int skaters{}; + bool rows_stale{true}; + std::string search, password, code, code_password; + Kind kind{Kind::all}; + bool friends_only{}, hide_full{}, same_map{}, code_mode{}; + view::Sort sort{view::Sort::players}; + std::uint64_t selected{}; + // Hosting. + std::string host_name, host_password; + bool host_seeded{}, host_named{}, public_lobby{true}; + unsigned capacity = static_cast(multiplayer_lobby_player_limit), tps = multiplayer_default_tps; + // The session. + std::string selected_player; + // Voice: a change sent but not yet in the model, so a second press builds on the first. + std::optional voice_pending; + ULONGLONG voice_pending_until{}; + // Focus, the keyboard and scrolling. + std::uint32_t focus{}, fire{}, typing{}; + std::uint32_t focus_next{}; // a control that is only drawn from the next frame on, to focus then + // The game's menu sound this frame calls for (its number; ~0: none), and the focus it was + // last played for moving to. + std::uint32_t sound = ~0U, focus_heard{}; + ULONGLONG sound_at{}; + bool ring{}, focus_moved{}; + std::vector items, last; + std::map scrolls; + // The row of each list the focus was last on: where it goes back to when it returns to the + // list, in place of the row that happens to be level with where it came from. + std::map list_focus; + std::uint32_t pad_before{}; + std::array keys_before{}; + ULONGLONG repeat_at{}; + std::string notice; + ULONGLONG notice_until{}; +}; +Hub &hub() { + static Hub value; + return value; +} + +// What one frame draws with. +struct Ui { + Hub &h; + ImDrawList *draw; + ImFont *bold, *body, *heading; + float u; + ImVec2 mouse; + bool input, clicked, doubled, moved; + float wheel; + const MultiplayerModel ∓ + ControllerInput pad; +}; + +void set_typing(Hub &h, std::uint32_t id) { + auto &s = state(); + if (h.typing == id) return; + const bool was = h.typing != 0; + h.typing = id; + if (was == (id != 0)) return; + s.hub_typing.store(id != 0); + // The keyboard and the cursor change hands, as when the chat box opens and closes. + sync_menu_cursor(); + std::lock_guard lock(s.input_mutex); + s.input.clear(); +} +void say(Hub &h, std::string text) { + h.notice = std::move(text); + h.notice_until = GetTickCount64() + 5000; +} +bool send(Hub &h, const char *action, const std::string &argument = {}, const std::string &password = {}) { + if (queue_multiplayer_action(action, argument, password)) return true; + say(h, "Couldn't do that just now. Try again."); + return false; +} +void wipe(std::string &secret) { + SecureZeroMemory(secret.data(), secret.size()); + secret.clear(); +} + +bool inside(ImVec2 p, ImVec2 a, ImVec2 b) { return p.x >= a.x && p.x < b.x && p.y >= a.y && p.y < b.y; } +float width_of(ImFont *font, float size, const std::string &text) { return font->CalcTextSizeA(size, FLT_MAX, 0, text.c_str()).x; } +// One line of text inside a box, cut off at its right edge. +void label(ImDrawList *draw, ImFont *font, float size, ImVec2 at, float right, ImU32 colour, const std::string &text) { + if (right <= at.x) return; + draw->PushClipRect(ImVec2(at.x, at.y - size), ImVec2(right, at.y + size * 2), true); + draw->AddText(font, size, at, colour, text.c_str()); + draw->PopClipRect(); +} + +// A control: takes its place in this frame's focus order and says what is happening to it. +// `clip`: the part of the screen it can be pointed at in (a row of a scrolling list). +struct Hit { + bool hover{}, focus{}, press{}; +}; +Hit item(Ui &ui, std::uint32_t id, ImVec2 a, ImVec2 b, const Item *clip = nullptr) { + auto &h = ui.h; + h.items.push_back({id, a, b, clip ? clip->id : 0}); + if (clip && h.focus == id) h.list_focus[clip->id] = id; + Hit hit; + hit.hover = ui.input && inside(ui.mouse, a, b) && (!clip || inside(ui.mouse, clip->a, clip->b)); + if (hit.hover && ui.moved && !h.typing) h.focus = id, h.ring = false; + hit.focus = h.focus == id && (h.ring || hit.hover); + hit.press = ui.input && ((hit.hover && ui.clicked) || h.fire == id); + if (hit.press) h.focus = id, h.sound = sound_select; + return hit; +} + +// A button in one of the game's looks. `enabled` off: drawn dim, and not a control at all. +bool button(Ui &ui, std::uint32_t id, ImVec2 a, ImVec2 b, const std::string &text, Look look = Look::tile, bool enabled = true, + const std::string &value = {}, const Item *clip = nullptr) { + const float u = ui.u, height = b.y - a.y, size = std::min(40 * u, height * .42f); + const Hit hit = enabled ? item(ui, id, a, b, clip) : Hit{}; + const bool lit = hit.focus || hit.hover; + ImU32 fill = skate_theme::tile, ink = enabled ? theme::paper : theme::muted; + if (look == Look::sticky) fill = !enabled ? sticky_off : lit ? sticky_hover : sticky, ink = skate_theme::black; + else if (lit) fill = theme::blue; + else if (look == Look::plain) fill = skate_theme::tile_light; + else if (look == Look::chosen) fill = theme::paper, ink = skate_theme::black; + skate_theme::rough_rect(ui.draw, a, b, fill, id & 0xfff, u * 2); + if (look == Look::sticky && hit.focus) ui.draw->AddRect(a, b, theme::blue, 0, 0, 6 * u); + if (value.empty()) { + ui.draw->AddText(ui.bold, size, ImVec2(a.x + (b.x - a.x - width_of(ui.bold, size, text)) * .5f, a.y + (height - size) * .5f), ink, text.c_str()); + } else { + // A setting: its name on the left, what it is now on the right. + const float w = width_of(ui.bold, size, value); + label(ui.draw, ui.body, size, ImVec2(a.x + 30 * u, a.y + (height - size) * .5f), b.x - w - 50 * u, ink, text); + ui.draw->AddText(ui.bold, size, ImVec2(b.x - w - 30 * u, a.y + (height - size) * .5f), enabled && !lit && look != Look::chosen ? theme::blue : ink, value.c_str()); + } + return hit.press; +} + +// What was typed this frame goes into `text` (the overlay has the keyboard while typing). +void take_typing(std::string &text, std::size_t limit) { + auto &io = ImGui::GetIO(); + for (const ImWchar c : io.InputQueueCharacters) { + if (c < 32 || c == 127 || text.size() + 3 > limit) continue; + if (c < 0x80) text += static_cast(c); + else if (c < 0x800) text += static_cast(0xc0 | (c >> 6)), text += static_cast(0x80 | (c & 0x3f)); + else text += static_cast(0xe0 | (c >> 12)), text += static_cast(0x80 | ((c >> 6) & 0x3f)), text += static_cast(0x80 | (c & 0x3f)); + } + if (ImGui::IsKeyPressed(ImGuiKey_Backspace, true) && !text.empty()) { + text.pop_back(); + while (!text.empty() && (static_cast(text.back()) & 0xc0) == 0x80) text.pop_back(); + if (!text.empty() && static_cast(text.back()) >= 0xc0) text.pop_back(); + } + if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_V, false)) + if (const char *pasted = ImGui::GetClipboardText()) + for (; *pasted && text.size() < limit; ++pasted) + if (static_cast(*pasted) >= 32) text += *pasted; +} +// A text box in the game's white. Pressing it gives it the keyboard; Enter (returns true) or +// Escape gives the keyboard back, and so does a click anywhere else. +bool field(Ui &ui, std::uint32_t id, ImVec2 a, ImVec2 b, std::string &text, const char *hint, bool secret = false, std::size_t limit = 64) { + auto &h = ui.h; + const float u = ui.u, size = 40 * u; + const Hit hit = item(ui, id, a, b); + bool typing = h.typing == id, entered{}; + if (hit.press && !typing) set_typing(h, id), typing = true; + else if (typing && ui.clicked && !hit.hover) set_typing(h, 0), typing = false; + if (typing) { + take_typing(text, limit); + if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) set_typing(h, 0), typing = false; + else if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) set_typing(h, 0), typing = false, entered = true; + } + skate_theme::rough_rect(ui.draw, a, b, theme::paper, id & 0xfff, u * 2); + if (typing || hit.focus) ui.draw->AddRect(a, b, theme::blue, 0, 0, 6 * u); + const bool empty = text.empty() && !typing; + std::string shown = empty ? std::string(hint) : secret ? std::string(text.size(), '*') : text; + if (typing && std::fmod(ImGui::GetTime(), 1.0) < .5) shown += '|'; + label(ui.draw, ui.body, size, ImVec2(a.x + 28 * u, a.y + (b.y - a.y - size) * .5f), b.x - 16 * u, empty ? hint_ink : skate_theme::black, shown); + return entered; +} + +// A column that scrolls: rows are placed with next(), which gives where the next one goes. +struct Scroller { + Ui &ui; + Item box; + float &offset; + float y; + Scroller(Ui &owner, std::uint32_t id, ImVec2 a, ImVec2 b) : ui(owner), box{id, a, b}, offset(owner.h.scrolls[id]), y(a.y - owner.h.scrolls[id]) { + ui.draw->PushClipRect(a, b, true); + } + // The top of a row `height` tall; `drawn` says whether any of it is in view. + float next(float height, float gap, bool &drawn) { + const float top = y; + y += height + gap; + drawn = top + height >= box.a.y && top <= box.b.y; + return top; + } + // Keeps the focused row in view once the focus has moved to it. + void reveal(std::uint32_t id, float top, float height) { + if (ui.h.focus != id || !ui.h.focus_moved) return; + if (top < box.a.y) offset -= box.a.y - top; + else if (top + height > box.b.y) offset += top + height - box.b.y; + } + void end() { + ui.draw->PopClipRect(); + const float view_height = box.b.y - box.a.y, content = y + offset - box.a.y, most = std::max(0.f, content - view_height); + if (ui.input && inside(ui.mouse, box.a, box.b) && ui.wheel != 0) offset -= ui.wheel * 170 * ui.u; + offset = std::clamp(offset, 0.f, most); + if (most > 0) { + const float thumb = std::max(60 * ui.u, view_height * view_height / content); + const float at = box.a.y + (view_height - thumb) * (offset / most); + ui.draw->AddRectFilled(ImVec2(box.b.x - 10 * ui.u, at), ImVec2(box.b.x - 2 * ui.u, at + thumb), theme::muted, 4 * ui.u); + } + } +}; + +// ---------------------------------------------------------------- the server browser + +void rebuild(Hub &h) { + h.rows_stale = false; + h.rows.clear(); + const auto &mp = h.model; + const std::span levels = h.full.levels; + const auto found = view::browse(mp, {h.search, h.same_map, h.sort}, levels); + h.listed = mp.lobbies.size(); + h.skaters = 0; + for (const auto &lobby : mp.lobbies) h.skaters += std::max(0, lobby.players); + for (const auto *lobby : found.lobbies) { + const bool full = lobby->players >= lobby->capacity; + if ((h.kind == Kind::servers && !lobby->dedicated) || (h.kind == Kind::lobbies && lobby->dedicated) || + (h.friends_only && lobby->friends.empty()) || (h.hide_full && full)) + continue; + h.rows.push_back({lobby, view::caption(view::map_name(lobby->map, levels), 40), view::can_join(mp, *lobby), + mp.active && lobby->id == mp.public_lobby, full, lobby->owner == mp.local_id}); + } + if (std::none_of(h.rows.begin(), h.rows.end(), [&](const Row &row) { return row.lobby->id == h.selected; })) + h.selected = h.rows.empty() ? 0 : h.rows.front().lobby->id; +} +const Row *selected_row(const Hub &h) { + for (const auto &row : h.rows) + if (row.lobby->id == h.selected) return &row; + return nullptr; +} +void join(Hub &h, const Row &row) { + if (!row.joinable) return; + if (row.lobby->password_required && h.password.empty()) { + h.focus_next = hash("password"); + return say(h, "Enter the password, then join."); + } + if (send(h, "join-lobby", std::to_string(row.lobby->id), row.lobby->password_required ? h.password : std::string())) { + say(h, "Joining " + view::caption(row.lobby->name, 40) + "..."); + wipe(h.password); + } +} + +void browser_page(Ui &ui, ImVec2 a, ImVec2 z) { + auto &h = ui.h; + const auto &mp = ui.mp; + const float u = ui.u, gap = 28 * u, bar = 96 * u, list_width = (z.x - a.x) * .62f; + auto *draw = ui.draw; + if (!h.searched && !mp.lobby_searching) h.searched = true, queue_multiplayer_action("browse", ""); + bool changed{}; + // ---- the bar: search, filters, refresh + { + const float search_width = std::min(1000 * u, list_width * .4f); + const auto before = h.search; + field(ui, hash("search"), a, ImVec2(a.x + search_width, a.y + bar), h.search, "Search name or map"); + changed |= before != h.search; + float x = a.x + search_width + gap; + const auto chip = [&](const char *text, bool on) { + const float size = 34 * u, w = width_of(ui.bold, size, text) + 44 * u; + const ImVec2 c(x, a.y + 10 * u), d(x + w, a.y + bar - 10 * u); + const Hit hit = item(ui, hash(text, 7), c, d); + const bool lit = hit.focus || hit.hover; + skate_theme::rough_rect(draw, c, d, lit ? theme::blue : on ? theme::paper : skate_theme::tile, hash(text) & 0xfff, u * 2); + draw->AddText(ui.bold, size, ImVec2(c.x + 22 * u, c.y + (d.y - c.y - size) * .5f), on && !lit ? skate_theme::black : theme::paper, text); + x = d.x + 12 * u; + return hit.press; + }; + if (chip("ALL", h.kind == Kind::all)) h.kind = Kind::all, changed = true; + if (chip("SERVERS", h.kind == Kind::servers)) h.kind = Kind::servers, changed = true; + if (chip("LOBBIES", h.kind == Kind::lobbies)) h.kind = Kind::lobbies, changed = true; + x += 16 * u; + if (chip("FRIENDS", h.friends_only)) h.friends_only = !h.friends_only, changed = true; + if (chip("HIDE FULL", h.hide_full)) h.hide_full = !h.hide_full, changed = true; + if (chip("MY MAP", h.same_map)) h.same_map = !h.same_map, changed = true; + if (button(ui, hash("refresh"), ImVec2(z.x - 420 * u, a.y), ImVec2(z.x, a.y + bar), mp.lobby_searching ? "SEARCHING..." : "REFRESH", + Look::sticky, !mp.lobby_searching)) + queue_multiplayer_action("browse", ""); + } + if (ui.input && !h.typing && (ui.pad.buttons & ~h.pad_before & 0x4000) && !mp.lobby_searching) queue_multiplayer_action("browse", ""); // X / Square + if (ui.input && !h.typing && (ui.pad.buttons & ~h.pad_before & 0x8000)) h.sort = static_cast((static_cast(h.sort) + 1) % 3), changed = true; // Y / Triangle + + // ---- the list + const ImVec2 la(a.x, a.y + bar + gap), lz(a.x + list_width, z.y - 60 * u); + const float header = 52 * u, row_height = 104 * u, row_gap = 8 * u, pad_x = 28 * u; + const float map_x = la.x + (lz.x - la.x) * .56f, count_right = lz.x - pad_x - 16 * u; + { + const float size = 28 * u; + const auto column = [&](const char *text, float x, view::Sort sort, bool right_aligned) { + const float w = width_of(ui.bold, size, text); + const ImVec2 c(right_aligned ? x - w : x, la.y), d(c.x + w + 30 * u, la.y + header); + const bool on = h.sort == sort; + const Hit hit = item(ui, hash(text, 9), c, d); + draw->AddText(ui.bold, size, ImVec2(c.x, c.y + 8 * u), on || hit.focus || hit.hover ? theme::blue : theme::muted, text); + if (on) draw->AddTriangleFilled(ImVec2(c.x + w + 10 * u, c.y + 16 * u), ImVec2(c.x + w + 26 * u, c.y + 16 * u), ImVec2(c.x + w + 18 * u, c.y + 28 * u), theme::blue); + if (hit.press && !on) h.sort = sort, changed = true; + }; + column("NAME", la.x + pad_x, view::Sort::name, false); + column("MAP", map_x, view::Sort::map, false); + column("SKATERS", count_right - 26 * u, view::Sort::players, true); + } + if (changed) rebuild(h), h.scrolls[hash("servers")] = 0; + const Row *chosen{}; + { + Scroller list(ui, hash("servers"), ImVec2(la.x, la.y + header), lz); + for (const auto &row : h.rows) { + const auto &lobby = *row.lobby; + const auto id = hash("server", lobby.id); + bool drawn{}; + const float top = list.next(row_height, row_gap, drawn); + const ImVec2 p(la.x, top), q(lz.x - 20 * u, top + row_height); + const Hit hit = item(ui, id, p, q, &list.box); + list.reveal(id, top, row_height); + // Pointing at a row (or moving the focus onto it) is choosing it: the panel beside the + // list shows it. Pressing it joins. + if (hit.focus && (h.ring || ui.moved || ui.clicked)) { + if (h.selected != lobby.id) wipe(h.password), h.code_mode = false; + h.selected = lobby.id; + } + if (hit.press) chosen = &row; + if (!drawn) continue; + // Blue is where the pointer or the focus is; the row the panel shows keeps a mark. + const bool lit = hit.focus || hit.hover, shown = h.selected == lobby.id; + const ImU32 tile = lit ? theme::blue : hit.hover ? row_hover : lobby.official ? skate_theme::official_tile + : !lobby.friends.empty() ? skate_theme::friends_tile : skate_theme::tile; + skate_theme::rough_rect(draw, p, q, tile, 0x900 + static_cast(lobby.id & 0xff), u * 2); + if (shown && !lit) draw->AddRectFilled(ImVec2(p.x, p.y + 6 * u), ImVec2(p.x + 10 * u, q.y - 6 * u), theme::blue); + const ImU32 ink = row.full && !lit ? theme::muted : theme::paper; + const float name_size = 42 * u; + float x = p.x + pad_x; + const char *plate = lobby.official ? "OFFICIAL" : lobby.dedicated ? "SERVER" : "LOBBY"; + const float plate_width = width_of(ui.bold, 24 * u, plate) + 20 * u, plate_top = top + (row_height - 38 * u) * .5f; + draw->AddRectFilled(ImVec2(x, plate_top), ImVec2(x + plate_width, plate_top + 38 * u), + lit ? IM_COL32(0, 0, 0, 90) : lobby.official ? skate_theme::official : skate_theme::tile_light, 4 * u); + draw->AddText(ui.bold, 24 * u, ImVec2(x + 10 * u, plate_top + 7 * u), lit || lobby.official ? theme::paper : theme::muted, plate); + x += plate_width + 18 * u; + const bool friends = !lobby.friends.empty(); + label(draw, ui.bold, name_size, ImVec2(x, top + (friends ? 14 * u : (row_height - name_size) * .5f)), map_x - 24 * u, ink, view::caption(lobby.name, 64)); + if (friends) label(draw, ui.body, 28 * u, ImVec2(x, top + 62 * u), map_x - 24 * u, lit ? theme::paper : skate_theme::friends_text, "with " + lobby_friends_text(lobby)); + const auto count = row.full ? std::string("FULL") : std::to_string(lobby.players) + " / " + std::to_string(lobby.capacity); + const float count_x = count_right - width_of(ui.bold, name_size, count); + float map_right = count_x - 24 * u; + if (lit) { + // What pressing it does, on the game's yellow: there is no other join button. + const char *does = mp.lobby_joining ? "JOINING" : row.here ? "YOU ARE HERE" : row.own ? "YOURS" : row.full ? nullptr + : mp.active && mp.hosting ? "END YOUR SESSION FIRST" : "JOIN"; + if (does) { + const float w = width_of(ui.bold, 30 * u, does) + 40 * u; + map_right -= w + 20 * u; + const ImVec2 c(map_right + 8 * u, top + 22 * u), d(c.x + w, top + row_height - 22 * u); + skate_theme::rough_rect(draw, c, d, row.joinable ? sticky : sticky_off, 0x51, u * 2); + draw->AddText(ui.bold, 30 * u, ImVec2(c.x + 20 * u, c.y + (d.y - c.y - 30 * u) * .5f), skate_theme::black, does); + } + } + if (lobby.password_required) { + map_right -= width_of(ui.bold, 24 * u, "PASSWORD") + 24 * u; + draw->AddText(ui.bold, 24 * u, ImVec2(map_right + 8 * u, top + (row_height - 24 * u) * .5f), lit ? theme::paper : skate_theme::warning, "PASSWORD"); + } + label(draw, ui.body, 36 * u, ImVec2(map_x, top + (row_height - 36 * u) * .5f), map_right, lit ? theme::paper : row.full ? theme::muted : soft, row.map); + draw->AddText(ui.bold, name_size, ImVec2(count_x, top + (row_height - name_size) * .5f), row.full && !lit ? skate_theme::danger : ink, count.c_str()); + } + list.end(); + } + if (h.rows.empty()) { + const char *line = mp.lobby_searching ? "Finding skaters..." : !mp.lobby_searched ? "Looking for sessions..." + : h.listed ? "Nothing matches these filters." : "Nobody is hosting right now. Host a lobby and invite some skaters."; + draw->AddText(ui.body, 40 * u, ImVec2(la.x + pad_x, la.y + header + 40 * u), theme::muted, line); + } + { + const auto total = std::to_string(h.rows.size()) + (h.rows.size() == h.listed ? "" : " of " + std::to_string(h.listed)) + + (h.listed == 1 ? " session, " : " sessions, ") + std::to_string(h.skaters) + " skating"; + draw->AddText(ui.body, 30 * u, ImVec2(la.x + pad_x, z.y - 44 * u), theme::muted, total.c_str()); + float x = la.x + pad_x + width_of(ui.body, 30 * u, total) + 60 * u; + const auto hint = [&](std::uint32_t pad_button, const char *key, const char *what) { + const auto name = ui.pad.available ? controller_combo_label(pad_button, ui.pad.style) : std::string(key); + if (!name.empty()) x += theme::keycap(draw, ui.bold, u * 2, ImVec2(x, z.y - 46 * u), name.c_str(), what) + 36 * u; + }; + hint(0x1000, "Enter", "Join"); + if (ui.pad.available) hint(0x4000, "", "Refresh"), hint(0x8000, "", "Sort"); + } + + // ---- beside the list: the chosen session, or joining with a code + const ImVec2 da(lz.x + gap, la.y), dz(z.x, z.y); + skate_theme::rough_rect(draw, da, dz, skate_theme::tile, 0x77, u * 2); + const float inset = 40 * u, left = da.x + inset, edge = dz.x - inset, tall = 110 * u; + const ImVec2 foot(left, dz.y - inset - 84 * u); + if (h.code_mode) { + float y = da.y + inset; + draw->AddText(ui.heading, 56 * u, ImVec2(left, y), theme::paper, "JOIN BY CODE"); + y += 84 * u; + label(draw, ui.body, 32 * u, ImVec2(left, y), edge, theme::muted, "Ask the host for their session code. You will travel to their map."); + y += 76 * u; + draw->AddText(ui.bold, 28 * u, ImVec2(left, y), theme::muted, "JOIN CODE"); + y += 44 * u; + bool go = field(ui, hash("code"), ImVec2(left, y), ImVec2(edge, y + bar), h.code, "Paste a ReSkate join code", false, 120); + y += bar + 36 * u; + draw->AddText(ui.bold, 28 * u, ImVec2(left, y), theme::muted, "PASSWORD"); + y += 44 * u; + go |= field(ui, hash("code-password"), ImVec2(left, y), ImVec2(edge, y + bar), h.code_password, "Only if it has one", true); + y += bar + 40 * u; + const bool idle = !mp.active && !mp.lobby_joining; + go |= button(ui, hash("join-code"), ImVec2(left, y), ImVec2(edge, y + tall), mp.lobby_joining ? "JOINING..." : idle ? "JOIN" : "LEAVE YOUR SESSION FIRST", + Look::sticky, idle && !h.code.empty()); + if (go && idle && !h.code.empty() && send(h, "join", h.code, h.code_password)) say(h, "Joining..."), wipe(h.code_password); + y += tall + 20 * u; + label(draw, ui.body, 30 * u, ImVec2(left, y), edge, theme::muted, view::caption(!h.notice.empty() ? h.notice : mp.status, 96)); + if (button(ui, hash("code-back"), foot, ImVec2(edge, foot.y + 84 * u), "BACK TO THE LIST", Look::plain)) h.code_mode = false, h.focus_next = hash("code-open"); + return; + } + if (const auto *row = selected_row(h)) { + const auto &lobby = *row->lobby; + float y = da.y + inset; + label(draw, ui.heading, 56 * u, ImVec2(left, y), edge, theme::paper, view::caption(lobby.name, 64)); + y += 72 * u; + label(draw, ui.body, 32 * u, ImVec2(left, y), edge, lobby.official ? skate_theme::official_text : theme::muted, + std::string(lobby.official ? "Official server" : lobby.dedicated ? "Dedicated server" : "Player lobby") + (lobby.password_required ? ", password needed" : "")); + y += 66 * u; + const auto fact = [&](const char *name, const std::string &value, ImU32 colour = theme::paper) { + draw->AddText(ui.body, 34 * u, ImVec2(left, y), theme::muted, name); + const float w = std::min(width_of(ui.bold, 34 * u, value), edge - left - 260 * u); + label(draw, ui.bold, 34 * u, ImVec2(edge - w, y), edge, colour, value); + y += 54 * u; + }; + fact("Map", row->map); + fact("Skaters", std::to_string(lobby.players) + " / " + std::to_string(lobby.capacity), row->full ? skate_theme::danger : theme::paper); + if (lobby.ping >= 0) fact("Ping", std::to_string(lobby.ping) + " ms"); + if (!lobby.friends.empty()) fact("Friends here", lobby_friends_text(lobby), skate_theme::friends_text); + const float fill = lobby.capacity > 0 ? std::clamp(static_cast(lobby.players) / static_cast(lobby.capacity), 0.f, 1.f) : 0.f; + draw->AddRectFilled(ImVec2(left, y + 4 * u), ImVec2(edge, y + 18 * u), skate_theme::tile_light, 4 * u); + draw->AddRectFilled(ImVec2(left, y + 4 * u), ImVec2(left + (edge - left) * fill, y + 18 * u), row->full ? skate_theme::danger : theme::blue, 4 * u); + y += 56 * u; + bool go = chosen == row; + if (lobby.password_required && row->joinable) { + draw->AddText(ui.bold, 28 * u, ImVec2(left, y), theme::muted, "PASSWORD"); + y += 42 * u; + go |= field(ui, hash("password"), ImVec2(left, y), ImVec2(edge, y + bar), h.password, "Type it here", true); + y += bar + 24 * u; + label(draw, ui.body, 28 * u, ImVec2(left, y), edge, theme::muted, "Type it, then press Enter or the session to join."); + y += 52 * u; + } + if (go) join(h, *row); + const auto &status = !h.notice.empty() ? h.notice : mp.active && !mp.hosting && !mp.echo && row->joinable + ? std::string("Joining leaves the session you are in.") : mp.browser_status; + label(draw, ui.body, 30 * u, ImVec2(left, y), edge, theme::muted, view::caption(status, 96)); + } else { + draw->AddText(ui.body, 36 * u, ImVec2(left, da.y + inset), theme::muted, "Pick a session to see it here."); + label(draw, ui.body, 30 * u, ImVec2(left, da.y + inset + 60 * u), edge, theme::muted, view::caption(!h.notice.empty() ? h.notice : mp.browser_status, 96)); + } + if (button(ui, hash("code-open"), foot, ImVec2(edge, foot.y + 84 * u), "JOIN BY CODE", Look::plain)) h.code_mode = true, h.focus_next = hash("code"); +} + +// ---------------------------------------------------------------- hosting + +void host_page(Ui &ui, ImVec2 a, ImVec2 z) { + auto &h = ui.h; + const auto &mp = ui.mp; + const float u = ui.u, bar = 96 * u, row = 104 * u, gap = 12 * u; + auto *draw = ui.draw; + if (!h.host_seeded && mp.saved_host.loaded) { + // Start from the host settings used last time. + h.host_seeded = true; + h.public_lobby = mp.saved_host.public_lobby; + h.capacity = static_cast(mp.saved_host.capacity); + h.tps = mp.saved_host.tps; + if (!mp.saved_host.lobby_name.empty()) h.host_name = mp.saved_host.lobby_name, h.host_named = true; + } + if (!h.host_named && !mp.local_name.empty()) h.host_name = mp.local_name, h.host_named = true; + const bool idle = !mp.active && !mp.lobby_joining; + const float left_width = std::min((z.x - a.x) * .5f, 1800 * u); + float y = a.y; + draw->AddText(ui.heading, 52 * u, ImVec2(a.x, y), theme::paper, "MAKE IT YOUR SESSION"); + y += 84 * u; + draw->AddText(ui.bold, 28 * u, ImVec2(a.x, y), theme::muted, "LOBBY NAME"); + y += 44 * u; + field(ui, hash("host-name"), ImVec2(a.x, y), ImVec2(a.x + left_width, y + bar), h.host_name, "Your Steam name", false, 120); + y += bar + 40 * u; + draw->AddText(ui.bold, 28 * u, ImVec2(a.x, y), theme::muted, "PASSWORD (OPTIONAL)"); + y += 44 * u; + field(ui, hash("host-password"), ImVec2(a.x, y), ImVec2(a.x + left_width, y + bar), h.host_password, "Leave blank for an open lobby", true); + y += bar + 48 * u; + draw->AddText(ui.bold, 28 * u, ImVec2(a.x, y), theme::muted, "MAP"); + label(draw, ui.bold, 40 * u, ImVec2(a.x, y + 44 * u), a.x + left_width, theme::paper, view::map_name(mp.map, ui.h.full.levels)); + + const ImVec2 sa(a.x + left_width + 60 * u, a.y); + const float right = std::min(z.x, sa.x + 1500 * u); + y = sa.y; + draw->AddText(ui.heading, 52 * u, ImVec2(sa.x, y), theme::paper, "LOBBY SETTINGS"); + y += 84 * u; + const auto setting = [&](const char *name, const std::string &value) { + const bool pressed = button(ui, hash(name, 3), ImVec2(sa.x, y), ImVec2(right, y + row), name, Look::tile, idle, value); + y += row + gap; + return pressed; + }; + if (setting("Visibility", (mp.hosting ? mp.public_host : h.public_lobby) ? "Public" : "Join code")) h.public_lobby = !h.public_lobby; + if (setting("Skaters", std::to_string(mp.hosting ? static_cast(mp.capacity) : h.capacity))) { + constexpr std::array limits{2, 4, 8, 16, static_cast(multiplayer_lobby_player_limit)}; + const auto next = std::upper_bound(limits.begin(), limits.end(), h.capacity); + h.capacity = next == limits.end() ? limits.front() : *next; + } + if (setting("Tick rate", std::to_string(mp.hosting ? mp.tps : h.tps))) { + const auto next = std::upper_bound(multiplayer_tick_rates.begin(), multiplayer_tick_rates.end(), h.tps); + h.tps = next == multiplayer_tick_rates.end() ? multiplayer_tick_rates.front() : *next; + } + label(draw, ui.body, 30 * u, ImVec2(sa.x, y + 4 * u), right, theme::muted, + h.public_lobby ? "Listed in the server browser for anyone to join." : "Only skaters you give your code to can join."); + y += 64 * u; + const bool ready = idle && mp.local_ready; + if (button(ui, hash("host"), ImVec2(sa.x, y), ImVec2(right, y + 120 * u), !idle ? "SESSION ALREADY ACTIVE" : mp.local_ready ? "HOST LOBBY" : "LOAD A MAP TO HOST", + Look::sticky, ready)) { + if (h.host_name.size() > 128) say(h, "Lobby name is too long. Use a shorter name."); + else if (send(h, "host-config", std::string(h.public_lobby ? "public " : "code ") + std::to_string(h.capacity) + " " + std::to_string(h.tps) + " " + h.host_name, + h.host_password)) + wipe(h.host_password); + } + y += 140 * u; + label(draw, ui.body, 30 * u, ImVec2(sa.x, y), right, theme::muted, view::caption(!h.notice.empty() ? h.notice : mp.status, 110)); +} + +// ---------------------------------------------------------------- the session + +void session_page(Ui &ui, ImVec2 a, ImVec2 z) { + auto &h = ui.h; + const auto &mp = ui.mp; + const float u = ui.u, gap = 28 * u, row = 96 * u, row_gap = 8 * u, list_width = (z.x - a.x) * .5f; + auto *draw = ui.draw; + label(draw, ui.heading, 52 * u, a, a.x + list_width, theme::paper, view::caption(mp.lobby_name.empty() ? "CURRENT SESSION" : mp.lobby_name, 64)); + draw->AddText(ui.bold, 28 * u, ImVec2(a.x, a.y + 76 * u), theme::muted, ("SKATERS " + std::to_string(mp.players) + " / " + std::to_string(mp.capacity)).c_str()); + { + // Who is here: a list to read through. The rows take the focus only so that the D-pad + // can scroll it. + Scroller list(ui, hash("players"), ImVec2(a.x, a.y + 128 * u), ImVec2(a.x + list_width, z.y)); + const auto person = [&](std::uint64_t id, const std::string &name, const std::string &role) { + const auto key = hash("player", id); + bool drawn{}; + const float top = list.next(row, row_gap, drawn); + const ImVec2 p(a.x, top), q(a.x + list_width - 20 * u, top + row); + const Hit hit = item(ui, key, p, q, &list.box); + list.reveal(key, top, row); + if (!drawn) return; + skate_theme::rough_rect(draw, p, q, hit.focus && h.ring ? row_hover : skate_theme::tile, key & 0xfff, u * 2); + const float w = role.empty() ? 0 : width_of(ui.bold, 28 * u, role) + 30 * u; + label(draw, ui.bold, 40 * u, ImVec2(p.x + 30 * u, top + (row - 40 * u) * .5f), q.x - w - 30 * u, theme::paper, view::caption(name, 48)); + if (!role.empty()) draw->AddText(ui.bold, 28 * u, ImVec2(q.x - w, top + (row - 28 * u) * .5f), theme::muted, role.c_str()); + }; + person(mp.local_id, mp.local_name, mp.hosting ? "YOU, HOST" : mp.parties && mp.party_leader ? "YOU, PARTY LEADER" : "YOU"); + for (const auto &player : mp.roster) { + if (player.id == mp.local_id || !player.connected) continue; + person(player.id, player.name, player.id == mp.host_id ? "HOST" : !mp.parties || !player.party_member ? "" : player.party_leader ? "PARTY LEADER" : "PARTY"); + } + list.end(); + } + + // Beside the list: the session and its settings. + const ImVec2 da(a.x + list_width + gap, a.y), dz(z.x, z.y); + skate_theme::rough_rect(draw, da, dz, skate_theme::tile, 0x7a, u * 2); + const float inset = 36 * u, left = da.x + inset, edge = dz.x - inset - 14 * u, opt = 84 * u, opt_gap = 10 * u; + Scroller side(ui, hash("session-side"), ImVec2(da.x, da.y + inset), ImVec2(dz.x, dz.y - inset)); + const auto text = [&](ImFont *font, float size, ImU32 colour, const std::string &line, float below) { + bool drawn{}; + const float top = side.next(size, below, drawn); + if (drawn) label(draw, font, size, ImVec2(left, top), edge, colour, line); + }; + // A row of the panel: a setting (with `value`) or a plain action. Returns whether it was pressed. + const auto option = [&](const std::string &name, const std::string &value, bool enabled, Look look = Look::plain) { + const auto id = hash(name, 5); + bool drawn{}; + const float top = side.next(opt, opt_gap, drawn); + side.reveal(id, top, opt); + // (Drawn or not, it keeps its place in the focus order, so the D-pad can reach it.) + return button(ui, id, ImVec2(left, top), ImVec2(edge, top + opt), name, look, enabled, value, &side.box); + }; + { + // Leaving comes first: it is what most visits to this page are for. + if (option(mp.hosting ? "End session" : "Leave server", {}, true, Look::sticky)) send(h, "stop"); + bool drawn{}; + side.next(10 * u, 10 * u, drawn); + text(ui.bold, 28 * u, theme::muted, "MAP", 10 * u); + text(ui.bold, 40 * u, theme::paper, view::map_name(mp.map, ui.h.full.levels), 30 * u); + if (!mp.invite.empty()) { + text(ui.bold, 28 * u, theme::muted, "JOIN CODE", 10 * u); + text(ui.bold, 36 * u, theme::paper, mp.invite, 16 * u); + if (option("Copy join code", {}, true)) ImGui::SetClipboardText(mp.invite.c_str()), say(h, "Join code copied."); + } + if (mp.parties) { + for (const auto &invite : mp.party_invites) { + const auto from = std::to_string(invite.from); + text(ui.body, 32 * u, theme::paper, view::caption(invite.name, 32) + " invited you to their party", 12 * u); + if (option("Accept invite from " + view::caption(invite.name, 20), {}, true)) send(h, "party", "accept " + from); + if (option("Decline " + view::caption(invite.name, 20), {}, true)) send(h, "party", "decline " + from); + } + if (mp.party) { + if (mp.party_leader && option("Party", mp.party_open ? "Anyone can join" : "Invite only", true)) send(h, "party", mp.party_open ? "close" : "open"); + if (option("Leave party", {}, true)) send(h, "party", "leave"); + } + } + text(ui.body, 30 * u, theme::muted, "Tick rate: " + std::to_string(mp.tps), 16 * u); + } + text(ui.body, 30 * u, theme::muted, view::caption(!h.notice.empty() ? h.notice : mp.status, 110), 0); + side.end(); +} + +// ---------------------------------------------------------------- voice chat + +constexpr std::array voice_distances{10.f, 20.f, 35.f, 50.f, 75.f, 100.f, 150.f, 300.f}; +constexpr std::array voice_volumes{0.f, .25f, .5f, .75f, 1.f, 1.5f, 2.f, 3.f, 5.f}; +template float next_step(float current, const std::array &steps) { + const auto found = std::upper_bound(steps.begin(), steps.end(), current + 0.001f); + return found == steps.end() ? steps.front() : *found; +} +std::string volume_label(float value) { + auto text = std::to_string(value); + return text.substr(0, text.find('.') + 3) + "x"; +} + +void voice_page(Ui &ui, ImVec2 a, ImVec2 z) { + auto &h = ui.h; + const auto &mp = ui.mp; + const float u = ui.u, gap = 28 * u, row = 96 * u, row_gap = 8 * u, list_width = (z.x - a.x) * .5f; + auto *draw = ui.draw; + if (h.voice_pending && (*h.voice_pending == mp.voice.settings || GetTickCount64() >= h.voice_pending_until)) h.voice_pending.reset(); + auto value = h.voice_pending.value_or(mp.voice.settings); + draw->AddText(ui.heading, 52 * u, a, theme::paper, "SKATERS IN YOUR SESSION"); + { + Scroller list(ui, hash("voices"), ImVec2(a.x, a.y + 96 * u), ImVec2(a.x + list_width, z.y)); + bool anyone{}; + for (const auto &player : mp.roster) { + if (player.id == mp.local_id || !player.connected) continue; + anyone = true; + const auto id = std::to_string(player.id); + const auto found = std::find_if(mp.voice.players.begin(), mp.voice.players.end(), [&](const auto &entry) { return entry.id == player.id; }); + const auto voice = found == mp.voice.players.end() ? VoicePlayer{player.id} : *found; + bool drawn{}; + const float top = list.next(row, row_gap, drawn); + const float right = a.x + list_width - 20 * u, volume_x = right - 330 * u, mute_x = volume_x - 12 * u - 260 * u; + const auto mute = hash("mute", player.id), volume = hash("volume", player.id); + list.reveal(mute, top, row), list.reveal(volume, top, row); + if (drawn) { + skate_theme::rough_rect(draw, ImVec2(a.x, top), ImVec2(mute_x - 12 * u, top + row), skate_theme::tile, mute & 0xfff, u * 2); + label(draw, ui.bold, 40 * u, ImVec2(a.x + 30 * u, top + (row - 40 * u) * .5f), mute_x - 40 * u, + mp.voice.allowed && voice.speaking && !voice.muted ? skate_theme::good : theme::paper, view::caption(player.name, 40)); + } + if (button(ui, mute, ImVec2(mute_x, top), ImVec2(volume_x - 12 * u, top + row), voice.muted ? "MUTED" : "MUTE", Look::plain, true, {}, &list.box)) + send(h, "voice-mute", id + (voice.muted ? " off" : " on")); + if (button(ui, volume, ImVec2(volume_x, top), ImVec2(right, top + row), "Volume", Look::plain, true, volume_label(voice.volume), &list.box)) + send(h, "voice-volume", id + " " + std::to_string(next_step(voice.volume, voice_volumes))); + } + list.end(); + if (!anyone) { + draw->AddText(ui.body, 38 * u, ImVec2(a.x, a.y + 110 * u), theme::muted, mp.active ? "No other skaters in the session yet." : "You are not in a session yet."); + draw->AddText(ui.body, 32 * u, ImVec2(a.x, a.y + 164 * u), theme::muted, "These voice settings apply to every session you join."); + } + } + const ImVec2 sa(a.x + list_width + gap, a.y); + const float right = std::min(z.x, sa.x + 1500 * u), opt = 92 * u; + float y = sa.y; + draw->AddText(ui.heading, 52 * u, ImVec2(sa.x, y), theme::paper, "VOICE SETTINGS"); + y += 84 * u; + bool changed{}; + const auto setting = [&](const char *name, const std::string &shown, bool enabled = true) { + const bool pressed = button(ui, hash(name, 4), ImVec2(sa.x, y), ImVec2(right, y + opt), name, Look::tile, enabled, shown); + y += opt + 10 * u; + return pressed; + }; + if (setting("Voice chat", value.enabled ? "On" : "Off")) value.enabled = !value.enabled, changed = true; + if (setting("Transmit", value.open_mic ? "Open mic" : "Push to talk")) value.open_mic = !value.open_mic, changed = true; + if (!value.open_mic) { + std::string binds = "Talk key: " + voice_key_name(value.push_to_talk); + if (value.controller_combo) binds += " Pad: " + controller_combo_label(value.controller_combo, ui.pad.style); + label(draw, ui.body, 30 * u, ImVec2(sa.x + 30 * u, y), right, theme::muted, binds); + y += 50 * u; + } + if (setting("Proximity voice", value.proximity ? "On" : "Off")) value.proximity = !value.proximity, changed = true; + if (setting("Voice distance", std::to_string(static_cast(value.distance + .5f)) + " m", value.proximity)) value.distance = next_step(value.distance, voice_distances), changed = true; + if (setting("Listening volume", volume_label(value.volume))) value.volume = next_step(value.volume, voice_volumes), changed = true; + if (setting("Microphone volume", volume_label(value.microphone))) value.microphone = next_step(value.microphone, voice_volumes), changed = true; + if (changed) { + const auto argument = std::to_string(value.enabled) + " " + std::to_string(value.proximity) + " " + std::to_string(value.push_to_talk) + " " + + std::to_string(value.distance) + " " + std::to_string(value.volume) + " " + std::to_string(value.open_mic) + " " + + std::to_string(value.controller_combo) + " " + std::to_string(value.microphone); + if (send(h, "voice", argument)) h.voice_pending = value, h.voice_pending_until = GetTickCount64() + 2000; + } + if (mp.hosting) { + if (setting("Lobby voice", mp.voice.allowed ? "Allowed" : "Disabled")) send(h, "voice-allow", mp.voice.allowed ? "off" : "on"); + } else if (mp.active && !mp.voice.allowed) { + label(draw, ui.body, 30 * u, ImVec2(sa.x + 30 * u, y), right, skate_theme::warning, "The host has disabled voice chat for this lobby."); + y += 50 * u; + } + label(draw, ui.body, 30 * u, ImVec2(sa.x + 30 * u, y + 8 * u), right, mp.voice.transmitting ? skate_theme::good : theme::muted, + !h.notice.empty() ? h.notice : mp.voice.transmitting ? std::string("MICROPHONE TRANSMITTING") : mp.voice.status); +} + +// ---------------------------------------------------------------- Custom Stuff + +enum ToolsTab : int { official_tab, custom_tab, world_tab, parks_tab, player_tab, online_tab, visuals_tab, binds_tab }; + +// The next (or the one before) of a setting's steps from where it is now; it stops at the ends. +template float stepped(float current, const std::array &steps, int direction) { + if (direction > 0) { + for (const float step : steps) + if (step > current + .001f) return step; + return steps.back(); + } + for (std::size_t i = steps.size(); i-- > 0;) + if (steps[i] < current - .001f) return steps[i]; + return steps.front(); +} +std::string whole(float value, const char *unit = "") { return std::to_string(static_cast(value + .5f)) + unit; } + +// A column of settings that scrolls: each a row with its name and what it is now. +struct Column { + Ui &ui; + Scroller list; + float left, right, tall, between; + Column(Ui &owner, std::uint32_t id, ImVec2 a, ImVec2 b) + : ui(owner), list(owner, id, a, b), left(a.x), right(b.x - 26 * owner.u), tall(96 * owner.u), between(10 * owner.u) {} + // A row that is pressed: a switch, a choice that goes round, or an action (no value). + bool setting(const std::string &name, const std::string &value, bool enabled, Look look = Look::tile) { + const auto id = hash(name, 21); + bool drawn{}; + const float top = list.next(tall, between, drawn); + list.reveal(id, top, tall); + return button(ui, id, ImVec2(left, top), ImVec2(right, top + tall), name, look, enabled, value, &list.box); + } + // A row with a number: less and more at its right end. Returns -1, 0 or 1. + int adjust(const std::string &name, const std::string &value, bool enabled) { + const float u = ui.u, side = 96 * u, readout = 300 * u; + const auto less = hash(name, 22), more = hash(name, 23); + bool drawn{}; + const float top = list.next(tall, between, drawn); + list.reveal(less, top, tall), list.reveal(more, top, tall); + const float more_x = right - side, less_x = more_x - readout - side; + if (drawn) { + skate_theme::rough_rect(ui.draw, ImVec2(left, top), ImVec2(less_x - 10 * u, top + tall), skate_theme::tile, less & 0xfff, u * 2); + label(ui.draw, ui.body, 40 * u, ImVec2(left + 30 * u, top + (tall - 40 * u) * .5f), less_x - 30 * u, enabled ? theme::paper : theme::muted, name); + skate_theme::rough_rect(ui.draw, ImVec2(less_x + side + 6 * u, top), ImVec2(more_x - 6 * u, top + tall), skate_theme::tile, more & 0xfff, u * 2); + const float w = width_of(ui.bold, 40 * u, value); + ui.draw->AddText(ui.bold, 40 * u, ImVec2(less_x + side + (readout - w) * .5f, top + (tall - 40 * u) * .5f), enabled ? theme::blue : theme::muted, value.c_str()); + } + int result{}; + if (button(ui, less, ImVec2(less_x, top), ImVec2(less_x + side, top + tall), "-", Look::plain, enabled, {}, &list.box)) result = -1; + if (button(ui, more, ImVec2(more_x, top), ImVec2(right, top + tall), "+", Look::plain, enabled, {}, &list.box)) result = 1; + return result; + } + void heading(const char *text) { + bool drawn{}; + const float top = list.next(52 * ui.u, 30 * ui.u, drawn); + if (drawn) ui.draw->AddText(ui.heading, 52 * ui.u, ImVec2(left, top), theme::paper, text); + } + void title(const char *text) { + bool drawn{}; + const float top = list.next(54 * ui.u, 12 * ui.u, drawn); + if (drawn) ui.draw->AddText(ui.bold, 28 * ui.u, ImVec2(left, top + 26 * ui.u), theme::muted, text); + } + void note(const std::string &text, ImU32 colour = theme::muted) { + if (text.empty()) return; + const float size = 30 * ui.u, wrap = right - left; + bool drawn{}; + const float top = list.next(ui.body->CalcTextSizeA(size, FLT_MAX, wrap, text.c_str()).y + 6 * ui.u, 18 * ui.u, drawn); + if (drawn) ui.draw->AddText(ui.body, size, ImVec2(left, top + 6 * ui.u), colour, text.c_str(), nullptr, wrap); + } + void end() { list.end(); } +}; + +// The page's tabs. What a control does, and when it can, is the same as on ReSkate's own menu +// (native_tools_view.h for travel, parks and the world; the same requests and commands that +// menu sends for the rest); how it is laid out is here. +void tools_page(Ui &ui, ImVec2 a, ImVec2 z) { + auto &h = ui.h; + const auto &m = h.full; + const auto &mp = m.multiplayer; + const auto &cb = h.callbacks; + const float u = ui.u, gap = 28 * u, tall = 96 * u, between = 10 * u; + auto *draw = ui.draw; + native_tools::sync(h.tools, m); + const auto changed = [&] { h.next_model = {}; }; // what it changed shows at once + const auto act = [&](std::string_view command, const std::string &argument = {}) { + native_tools::activate(h.tools, m, cb, command, argument); + if (!h.tools.feedback.empty()) say(h, std::exchange(h.tools.feedback, {})); + changed(); + }; + const bool console = cb.queue_console_command != nullptr, debugging = cb.queue_debug != nullptr; + const auto run = [&](const std::string &line) { + std::array result{}; + if (!console || !cb.queue_console_command(cb.user, line.c_str(), result.data(), result.size())) say(h, "Couldn't apply that change. Try again when the game is ready."); + changed(); + }; + const auto debug = [&](DebugRequest request) { + std::array result{}; + if (!debugging || !cb.queue_debug(cb.user, request, result.data(), result.size())) say(h, "Couldn't apply that change. Try again when the game is ready."); + changed(); + }; + const auto online = [&](const char *action, const std::string &argument) { + send(h, action, argument); + changed(); + }; + const auto on_off = [](bool on) { return std::string(on ? "On" : "Off"); }; + const float list_width = std::min((z.x - a.x) * .56f, 2300 * u), column_width = std::min(z.x - a.x, 2100 * u); + // A list of levels: the one pointed at says LOAD, and pressing it travels there. + const auto levels = [&](bool custom, const char *title, const char *empty, const char *empty_help) { + draw->AddText(ui.heading, 52 * u, a, theme::paper, title); + std::vector listed; + for (const auto &entry : m.levels) + if (native_tools::listed(entry) && entry.custom == custom) listed.push_back(&entry); + std::sort(listed.begin(), listed.end(), [](const auto *x, const auto *y) { return native_tools::level_name(*x) < native_tools::level_name(*y); }); + const auto *root = native_tools::level(m, native_tools::root_asset); + const bool travel = root && root->native_registered && root->can_load && m.can_queue_load && !multiplayer_controls_level(mp) && cb.queue_load; + Scroller list(ui, hash(title, 30), ImVec2(a.x, a.y + 96 * u), ImVec2(a.x + list_width, z.y)); + for (const auto *entry : listed) { + const auto id = hash("level", hash(entry->asset)); + const bool can = travel && entry->can_load && entry != root; + bool drawn{}; + const float top = list.next(tall, between, drawn); + const ImVec2 p(a.x, top), q(a.x + list_width - 20 * u, top + tall); + const Hit hit = item(ui, id, p, q, &list.box); + list.reveal(id, top, tall); + if (hit.press && can) h.tools.destination = entry->asset, act("load-level"); + if (!drawn) continue; + const bool lit = hit.focus || hit.hover; + skate_theme::rough_rect(draw, p, q, lit ? theme::blue : skate_theme::tile, id & 0xfff, u * 2); + float name_right = q.x - 30 * u; + if (lit) { + const char *does = can ? "LOAD" : multiplayer_controls_level(mp) ? "HOST'S CHOICE" : "NOT NOW"; + const float w = width_of(ui.bold, 30 * u, does) + 40 * u; + const ImVec2 c(q.x - w - 24 * u, top + 20 * u), d(q.x - 24 * u, top + tall - 20 * u); + skate_theme::rough_rect(draw, c, d, can ? sticky : sticky_off, 0x52, u * 2); + draw->AddText(ui.bold, 30 * u, ImVec2(c.x + 20 * u, c.y + (d.y - c.y - 30 * u) * .5f), skate_theme::black, does); + name_right = c.x - 20 * u; + } + label(draw, ui.bold, 42 * u, ImVec2(p.x + 30 * u, top + (tall - 42 * u) * .5f), name_right, can || lit ? theme::paper : theme::muted, + native_tools::level_name(*entry)); + } + list.end(); + if (listed.empty()) { + draw->AddText(ui.body, 38 * u, ImVec2(a.x, a.y + 110 * u), theme::muted, empty); + if (*empty_help) draw->AddText(ui.body, 32 * u, ImVec2(a.x, a.y + 164 * u), theme::muted, empty_help); + } + const std::string note = multiplayer_controls_level(mp) ? "Only the lobby host can change levels." + : !m.can_queue_load ? "Travel is unavailable right now. Wait for the game to finish loading." : h.notice; + if (!note.empty()) + draw->AddText(ui.body, 30 * u, ImVec2(a.x + list_width + gap, a.y + 110 * u), theme::muted, note.c_str(), nullptr, std::min(z.x - a.x - list_width - gap, 1500 * u)); + }; + + switch (h.tools_tab) { + case official_tab: levels(false, "FIND YOUR SPOT", "Waiting for available levels...", ""); break; + case custom_tab: levels(true, "CUSTOM MAPS", "No custom maps installed.", "Custom maps from the Mods folder appear here."); break; + case world_tab: { + Column list(ui, hash("world"), a, ImVec2(a.x + column_width, z.y)); + list.heading("THIS WORLD"); + const bool population = m.world.ready && m.world_controls.population_available && console; + if (list.setting("Traffic", population_labels[std::clamp(m.world_controls.choices.traffic, -1, 3) + 1], population)) act("traffic"); + if (list.setting("Pedestrians", population_labels[std::clamp(m.world_controls.choices.pedestrians, -1, 3) + 1], population)) act("pedestrians"); + if (list.setting("Time of day", native_tools::time_of_day_labels[native_tools::time_of_day(m)], native_tools::time_of_day_available(m, cb))) act("time-of-day"); + if (m.world.controlled_by_host) + list.note(mp.server_admin ? "You are an admin: time of day changes for the whole server." : "The lobby host controls world layers, including time of day."); + list.note(h.notice); + list.end(); + break; + } + case parks_tab: { + // One card for each of the three lots: its style, its layout, and loading it. + draw->AddText(ui.heading, 52 * u, a, theme::paper, "PARK LAYOUTS"); + const bool available = native_tools::can_park(m, cb); + const float cards = static_cast(park_lots.size()); + const float width = std::min(((z.x - a.x) - gap * (cards - 1)) / cards, 1300 * u), top = a.y + 96 * u; + const float card_height = 36 * u + 60 * u + (tall + between) * 2 + 16 * u + 110 * u + 36 * u; + for (unsigned lot = 0; lot < park_lots.size(); ++lot) { + const ImVec2 p(a.x + static_cast(lot) * (width + gap), top), q(p.x + width, top + card_height); + skate_theme::rough_rect(draw, p, q, skate_theme::tile, 0x60 + lot, u * 2); + const float left = p.x + 36 * u, right = q.x - 36 * u; + float y = p.y + 36 * u; + label(draw, ui.bold, 40 * u, ImVec2(left, y), right, theme::paper, std::string(park_lots[lot].label)); + y += 60 * u; + std::string family = "Empty lot"; + for (unsigned i = 0; i < park_families.size(); ++i) + if (h.tools.parks[lot].starts_with(park_families[i])) family = park_family_labels[i]; + const auto row = [&](const char *name, const std::string &value, std::string_view command) { + if (button(ui, hash(name, 40 + lot), ImVec2(left, y), ImVec2(right, y + tall), name, Look::plain, available, value)) h.tools.lot = lot, act(command); + y += tall + between; + }; + row("Style", family, "park-family"); + row("Layout", park_label(h.tools.parks[lot]), "park-layout"); + y += 16 * u; + if (button(ui, hash("load-park", lot), ImVec2(left, y), ImVec2(right, y + 110 * u), "LOAD THIS LAYOUT", Look::sticky, + available && valid_park(lot, h.tools.parks[lot]))) + h.tools.lot = lot, act("load-park"); + } + Column all(ui, hash("parks"), ImVec2(a.x, top + card_height + 30 * u), ImVec2(a.x + std::min(z.x - a.x, 1500 * u), z.y)); + all.title("ALL THREE LOTS"); + if (all.setting("Load random parks", {}, available)) act("load-random-parks"); + if (all.setting("Randomize on launch", on_off(m.parks.randomize_on_launch), m.parks.available && console)) act("park-random-on-launch"); + all.note(m.world.map != WorldMap::bam ? std::string("Load San Vansterdam to change parks.") + : native_tools::parks_locked(m) ? std::string("The lobby host controls park layouts.") + : m.parks.controlled_by_host ? std::string("You are an admin: layouts you load change for the whole server.") : h.notice); + all.end(); + break; + } + case player_tab: { + Column list(ui, hash("player"), a, ImVec2(a.x + column_width, z.y)); + const auto &d = m.debug; + list.heading("SKATE YOUR WAY"); + if (list.setting("Noclip", on_off(d.noclip), (d.noclip_available || d.noclip) && debugging)) debug({DebugAction::set_noclip, !d.noclip}); + if (list.setting("No bail", on_off(d.no_bail), (d.no_bail_available || d.no_bail) && debugging)) debug({DebugAction::set_no_bail, !d.no_bail}); + if (list.setting("Hall of Meat", on_off(m.hall_of_meat.enabled), m.hall_of_meat.available && console)) act("hall-of-meat"); + const auto &wear = m.offline.board_wear; + if (list.setting("Board wear", on_off(wear.effective), wear.available && cb.queue_offline_feature)) act("board-wear"); + if (list.setting("Reset board wear", {}, wear.available && wear.effective && console)) act("board-wear-reset"); + list.title("CAMERA"); + const bool camera = d.available && d.camera_available && debugging, edit = d.available && debugging; + if (list.setting("First person", on_off(d.first_person), camera)) debug({DebugAction::set_first_person, !d.first_person}); + if (list.setting("True first person", on_off(d.first_person_arm.stabilize), edit)) debug({DebugAction::set_first_person_stabilize, !d.first_person_arm.stabilize}); + if (list.setting("Third person on foot", on_off(d.first_person_arm.board_only), edit)) debug({DebugAction::set_first_person_board_only, !d.first_person_arm.board_only}); + if (list.setting("Freecam", on_off(d.free_camera), camera)) debug({DebugAction::set_free_camera, !d.free_camera}); + { + constexpr std::array speeds{.6f, 3.f, 5.f, 15.f, 60.f, 300.f, 1500.f}; + const auto text = std::to_string(d.camera_speed); + if (const int way = list.adjust("Flight speed", text.substr(0, text.find('.') + 2), (d.free_camera || d.noclip) && d.camera_available && debugging)) + debug({DebugAction::set_camera_speed, false, stepped(d.camera_speed, speeds, way)}); + } + list.title("BOOSTS"); + { + constexpr std::array forward{1.f, 5.f, 10.f, 25.f, 50.f, 100.f, 150.f, 200.f, 300.f}; + constexpr std::array up{1.f, 2.f, 3.f, 5.f, 8.f, 12.f, 18.f, 25.f}; + if (const int way = list.adjust("Forward boost", "+" + whole(d.forward_velocity_speed), debugging)) + debug({DebugAction::set_forward_velocity_speed, false, stepped(d.forward_velocity_speed, forward, way)}); + if (const int way = list.adjust("Up boost", "+" + whole(d.up_velocity_speed), debugging)) + debug({DebugAction::set_up_velocity_speed, false, stepped(d.up_velocity_speed, up, way)}); + if (const int way = list.adjust("Off-board up boost", "+" + whole(d.offboard_up_velocity_speed), debugging)) + debug({DebugAction::set_offboard_up_velocity_speed, false, stepped(d.offboard_up_velocity_speed, up, way)}); + } + list.note(!h.notice.empty() ? h.notice : std::string("The buttons for the boosts are on the BINDS tab. Flight speed is for noclip and freecam; close the menu to fly.")); + list.end(); + break; + } + case online_tab: { + Column list(ui, hash("online"), a, ImVec2(a.x + column_width, z.y)); + list.heading("OTHER SKATERS"); + if (m.steam_offline) { + list.note("Multiplayer is off in offline mode."); + } else { + constexpr std::array shown{50.f, 75.f, 100.f, 150.f, 200.f, 300.f, 500.f, 1000.f}; + constexpr std::array tags{10.f, 25.f, 50.f, 75.f, 120.f, 200.f, 300.f, 500.f}; + constexpr std::array reach{5.f, 10.f, 20.f, 40.f, 75.f, 120.f, 200.f}; + constexpr std::array seconds{1.f, 3.f, 5.f, 8.f, 15.f, 30.f}; + constexpr std::array lines{1.f, 2.f, 3.f, 4.f, 5.f, 6.f, 7.f, 8.f}; + if (const int way = list.adjust("Player distance", mp.player_distance >= player_distance_unlimited ? std::string("Everyone") : whole(mp.player_distance, " m"), true)) + online("player-distance", whole(stepped(mp.player_distance, shown, way))); + if (list.setting("Direct connections", on_off(mp.prefer_direct), true)) online("direct-connections", mp.prefer_direct ? "off" : "on"); + list.title("NAMETAGS"); + if (list.setting("Nametags", on_off(mp.nametags), true)) online("nametags", mp.nametags ? "off" : "on"); + if (const int way = list.adjust("Nametag distance", whole(mp.nametag_distance, " m"), true)) online("nametag-distance", whole(stepped(mp.nametag_distance, tags, way))); + if (list.setting("Nametag dots", on_off(mp.nametag_dots), true)) online("nametag-dots", mp.nametag_dots ? "off" : "on"); + if (list.setting("Friends' nametags only", on_off(mp.nametags_friends), true)) online("nametags-friends", mp.nametags_friends ? "off" : "on"); + list.title("CHAT"); + if (list.setting("Text chat", on_off(mp.chat_visible), true)) online("chat-visible", mp.chat_visible ? "off" : "on"); + if (list.setting("Chat bubbles", on_off(mp.chat_bubbles), true)) online("chat-bubbles", mp.chat_bubbles ? "off" : "on"); + if (list.setting("Own chat bubbles", on_off(mp.chat_bubbles_own), mp.chat_bubbles)) online("chat-bubbles-own", mp.chat_bubbles_own ? "off" : "on"); + if (const int way = list.adjust("Bubble distance", whole(mp.chat_bubbles_distance, " m"), mp.chat_bubbles)) + online("chat-bubbles-distance", whole(stepped(mp.chat_bubbles_distance, reach, way))); + if (const int way = list.adjust("Bubble time", whole(mp.chat_bubbles_duration, " s"), mp.chat_bubbles)) + online("chat-bubbles-duration", whole(stepped(mp.chat_bubbles_duration, seconds, way))); + if (const int way = list.adjust("Bubble lines", whole(static_cast(mp.chat_bubbles_history)), mp.chat_bubbles)) + online("chat-bubbles-history", whole(stepped(static_cast(mp.chat_bubbles_history), lines, way))); + if (dingosdk::discord_presence::available()) { + list.title("DISCORD"); + if (list.setting("Discord status", on_off(dingosdk::discord_presence::enabled()), true)) dingosdk::discord_presence::set_enabled(!dingosdk::discord_presence::enabled()); + } + if (!mp.identity_tag.empty()) { + // Only for players the ReSkate team has given a tag. + list.title("YOUR TAG"); + if (list.setting("Show my tag", on_off(mp.identity_tag_shown), true)) online("mark-tag", mp.identity_tag_shown ? "off" : "on"); + if (list.setting("Show my items", on_off(mp.identity_items_shown), true)) online("mark-items", mp.identity_items_shown ? "off" : "on"); + } + list.note(h.notice); + } + list.end(); + break; + } + case visuals_tab: { + Column list(ui, hash("visuals"), a, ImVec2(a.x + column_width, z.y)); + list.heading("WHAT YOU SEE AND HEAR"); + constexpr std::array names{"Film grain", "Vignette", "Chromatic aberration"}; + for (unsigned i = 0; i < names.size(); ++i) { + const auto choice = m.graphics.choices.effects[i]; + const bool on = choice < 0 ? !m.graphics.ready[i] || m.graphics.enabled[i] : choice != 0; + if (list.setting(names[i], on_off(on) + (choice < 0 ? " (default)" : ""), m.graphics.available && console)) act("graphics", std::string(graphics_keys[i])); + } + if (list.setting("Restore default effects", {}, m.graphics.available && console)) act("graphics-reset"); + if (list.setting("Challenges", m.progression.challenges_hidden ? "Hidden" : "Shown", m.progression.challenges_enabled && console)) act("challenges"); + { + const bool shuffle = dingosdk::profile_runtime::music_shuffle_enabled(); + if (list.setting("Shuffle playlists", on_off(shuffle), true)) { + dingosdk::profile_runtime::set_music_shuffle_enabled(!shuffle); + dingosdk::profile_runtime::set_local_preference("MusicShuffle", !shuffle); + } + } + list.note(!h.notice.empty() ? h.notice : std::string("Hiding challenges keeps your progress.")); + list.end(); + break; + } + default: { + // Buttons for ReSkate's own actions. Pressing a row records the next button, combination + // of buttons or key; for that long the game does not see them. + const auto &binds = m.bindings; + const bool available = binds.available && console; + const auto recording = hash("bind-recording"); + const auto save = [&](int action, std::uint32_t combo) { + run("bind " + (action >= 10 ? std::string(action_binds[static_cast(action - 10)].name) : std::string(action == 1 ? "freecamcontroller" : action == 2 ? "freecam" + : action == 3 ? "noclip" : action == 4 ? "forwardvelocity" : action == 5 ? "upvelocity" : action == 6 ? "tptofreecam" : action == 8 ? "voteyes" + : action == 9 ? "voteno" : "offboardupvelocity")) + " " + std::to_string(combo)); + }; + if (h.recording_bind) { + ControllerInput controller; + DingoSDKOverlayReadControllerInput(&controller, true); + if (!available || GetTickCount64() >= h.recording_until || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + h.recording_bind = 0, set_typing(h, 0); + say(h, "Recording cancelled. Your binding is unchanged."); + } else if (const auto combo = h.bind_capture.update(controller, true)) { + const int action = std::exchange(h.recording_bind, 0); + set_typing(h, 0); + save(action, *combo); + } + } + Column list(ui, hash("binds"), a, ImVec2(a.x + column_width, z.y)); + list.heading("ACTION BINDS"); + list.note(h.recording_bind ? std::string("Press the button, combination or key to use. Escape cancels.") + : !h.notice.empty() ? h.notice : std::string("Pick an action, then press what should do it. CLEAR takes its button away.")); + const auto row = [&](int action, const std::string &name, std::uint32_t combo) { + const float u2 = ui.u, clear_width = 260 * u2; + const auto id = hash("bind", static_cast(action)), clear = hash("bind-clear", static_cast(action)); + bool drawn{}; + const float top = list.list.next(list.tall, list.between, drawn); + list.list.reveal(id, top, list.tall), list.list.reveal(clear, top, list.tall); + const bool mine = h.recording_bind == action, idle = available && !h.recording_bind; + if (button(ui, id, ImVec2(list.left, top), ImVec2(list.right - clear_width - 12 * u2, top + list.tall), name, mine ? Look::chosen : Look::tile, idle, + mine ? std::string("Recording...") : controller_combo_label(combo, ui.pad.style), &list.list.box)) { + h.recording_bind = action; + h.bind_capture = {}; + h.recording_until = GetTickCount64() + 30000; + h.notice.clear(); + set_typing(h, recording); + } + if (button(ui, clear, ImVec2(list.right - clear_width, top), ImVec2(list.right, top + list.tall), "CLEAR", Look::plain, idle && combo != 0, {}, &list.list.box)) + save(action, 0); + }; + row(1, "Freecam controller", binds.freecam_controller_combo); + row(2, "Freecam", binds.freecam_combo); + row(3, "Noclip", binds.noclip_combo); + row(4, "Forward boost", binds.forward_velocity_combo); + row(5, "Up boost", binds.up_velocity_combo); + row(7, "Off-board up boost", binds.offboard_up_velocity_combo); + row(6, "Teleport to freecam", binds.tp_to_freecam_combo); + row(8, "Vote yes", binds.vote_yes_combo); + row(9, "Vote no", binds.vote_no_combo); + for (std::size_t i = 0; i < action_binds.size(); ++i) row(10 + static_cast(i), std::string(action_binds[i].label), binds.action_combos[i]); + list.end(); + break; + } + } +} + +// ---------------------------------------------------------------- session settings + +// What the session allows, for whoever runs it: the host of a lobby, or an admin of a +// dedicated server (whose changes go to the server). Nobody else has the tab. +void settings_page(Ui &ui, ImVec2 a, ImVec2 z) { + auto &h = ui.h; + const auto &mp = ui.mp; + Column list(ui, hash("session-settings"), a, ImVec2(a.x + std::min(z.x - a.x, 2100 * ui.u), z.y)); + list.heading("SESSION SETTINGS"); + // (Each is sent as the value it should have, the way a server's own console takes it.) + list.note(mp.dedicated ? "You are an admin: these change for the whole server." : "You are the host: these change for everyone in your lobby."); + const auto allowed = [](bool on) { return std::string(on ? "Allowed" : "Off"); }; + list.title("BUILDING"); + if (list.setting("Object placement", mp.dedicated && mp.object_placement == ObjectPlacement::host_only ? std::string("Admins only") + : std::string(object_placement_name(mp.object_placement)), true)) + send(h, "object-placement", mp.object_placement == ObjectPlacement::everyone ? "host" : mp.object_placement == ObjectPlacement::host_only ? "nobody" : "everyone"); + { + static constexpr std::array limits{0, 10, 25, 50, 100, 250, 500}; + const auto found = std::find(limits.begin(), limits.end(), mp.object_limit); + const auto next = limits[found == limits.end() ? 0 : static_cast(found - limits.begin() + 1) % limits.size()]; + if (list.setting("Objects per player", mp.object_limit ? std::to_string(mp.object_limit) : std::string("No limit"), true)) + send(h, "object-limit", next ? std::to_string(next) : std::string("off")); + } + if (list.setting("Delete all guest objects", {}, true)) send(h, "clear-objects"); + list.title("WHAT GUESTS MAY USE"); + if (list.setting("Guest noclip", allowed(mp.guest_noclip), true)) send(h, "noclip-allow", mp.guest_noclip ? "off" : "on"); + if (list.setting("Guest No Bail", allowed(mp.guest_no_bail), true)) send(h, "nobail-allow", mp.guest_no_bail ? "off" : "on"); + if (list.setting("Guest boosts", allowed(mp.guest_boosts), true)) send(h, "boosts-allow", mp.guest_boosts ? "off" : "on"); + list.title("THE WORLD"); + if (list.setting("Physics tuning", mp.enforce_tuning ? (mp.dedicated ? "Game's" : "Host's") : "Everyone's own", true)) send(h, "tuning-enforce", mp.enforce_tuning ? "off" : "on"); + if (list.setting("World layer sync", mp.force_world_layers ? "On" : "Off", true)) send(h, "world-layer-sync", mp.force_world_layers ? "off" : "on"); + list.note(!h.notice.empty() ? h.notice : mp.status); + list.end(); +} + +// ---------------------------------------------------------------- focus + +// Moves the focus one control over, by where the controls were drawn last frame. +void move_focus(Hub &h, int dx, int dy) { + const Item *from{}; + for (const auto &entry : h.last) + if (entry.id == h.focus) from = &entry; + if (!from) { + if (!h.last.empty()) h.focus = h.last.front().id; + return; + } + const ImVec2 centre((from->a.x + from->b.x) * .5f, (from->a.y + from->b.y) * .5f); + const Item *best{}; + float best_score = FLT_MAX; + for (const auto &entry : h.last) { + if (entry.id == from->id) continue; + // Measured from the nearest point of the other control, so a wide row is as close as it looks. + const ImVec2 closest(std::clamp(centre.x, entry.a.x, entry.b.x), std::clamp(centre.y, entry.a.y, entry.b.y)); + const float along = dx ? (closest.x - centre.x) * static_cast(dx) : (closest.y - centre.y) * static_cast(dy); + const float across = dx ? std::abs(closest.y - centre.y) : std::abs(closest.x - centre.x); + const float edge = dx ? (dx > 0 ? entry.a.x - from->b.x : from->a.x - entry.b.x) : (dy > 0 ? entry.a.y - from->b.y : from->a.y - entry.b.y); + if (along <= 0 || edge < -2) continue; + const float score = along + across * 2.5f; + if (score < best_score) best_score = score, best = &entry; + } + if (!best) return; + h.focus = best->id; + // Into a list from outside it: back to the row it was left on, if that row is still there. + if (best->list && best->list != from->list) + if (const auto kept = h.list_focus.find(best->list); kept != h.list_focus.end()) + for (const auto &entry : h.last) + if (entry.id == kept->second && entry.list == best->list) h.focus = entry.id; +} +} // namespace + +bool hub_page_pending() { + auto &h = hub(); + auto &s = state(); + const auto now = Clock::now(); + if (now >= h.next_page) { + h.next_page = now + 40ms; + h.page = {}; + if (const auto feed = page_feed.load()) { + try { h.page = feed(); } catch (...) {} + } + if (!h.page.visible) h.dismissed = false; + } + const bool shown = h.page.visible && !h.dismissed; + if (shown != h.shown) { + h.shown = shown; + h.next_model = {}; + h.searched = false; + h.last.clear(); + if (!shown) { + h.recording_bind = 0; + set_typing(h, 0); + wipe(h.password), wipe(h.code_password), wipe(h.host_password); + h.notice.clear(); + } + } + // With the overlay's own menu or console open the page stays where it is, behind them, and + // takes no input: they have the pointer then. + const bool others = s.visible.load() || s.console_visible.load() || s.editor_visible.load() || s.chat_visible.load(); + s.hub_pointer.store(shown && !others); + if (shown && now >= h.next_model) { + h.next_model = now + 200ms; + { + std::lock_guard lock(hub_callbacks_mutex); + h.callbacks = hub_callbacks; + } + if (h.callbacks.read_model) { + try { + h.callbacks.read_model(h.callbacks.user, h.full); + h.model = h.full.multiplayer; + } catch (...) { h.full = {}, h.model = {}; } + h.rows_stale = true; + } + } + return shown; +} + +void draw_hub_page() { + auto &h = hub(); + if (!h.shown) return; + auto &s = state(); + auto &io = ImGui::GetIO(); + const auto display = io.DisplaySize; + // One unit of the game's menu in pixels, and where its page sits in this window. + const float u = std::min(display.x / layout_width, display.y / layout_height); + const float across = display.x / u, down = display.y / u, canvas = std::min(across, layout_widest); + const float page_left = ((across - canvas) * .5f + side_margin) * u, page_right = ((across + canvas) * .5f - side_margin) * u; + const float lower = (down - layout_height) * .05f; // how far a taller window moves the page down + const bool others = s.visible.load() || s.console_visible.load() || s.editor_visible.load() || s.chat_visible.load(); + Ui ui{h, ImGui::GetBackgroundDrawList(), s.menu.bold ? s.menu.bold : ImGui::GetFont(), s.menu.body ? s.menu.body : ImGui::GetFont(), nullptr, u, io.MousePos, + !others && game_window_foreground(s.window.load()), false, false, false, 0, h.model, {}}; + ui.heading = s.menu.heading ? s.menu.heading : ui.bold; + ui.clicked = ui.input && ImGui::IsMouseClicked(ImGuiMouseButton_Left); + ui.doubled = ui.input && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left); + ui.moved = io.MouseDelta.x != 0 || io.MouseDelta.y != 0; + ui.wheel = ui.input ? io.MouseWheel : 0; + const auto &mp = h.model; + if (h.rows_stale) rebuild(h); + if (h.notice_until && GetTickCount64() >= h.notice_until) h.notice.clear(), h.notice_until = 0; + // Going into a session shows it; leaving one goes back to the browser. + if (mp.active != h.was_active) { + h.was_active = mp.active; + h.tab = mp.active ? Tab::session : Tab::browser; + h.selected_player.clear(); + } + if ((h.tab == Tab::session || h.tab == Tab::voice) && !mp.active) h.tab = Tab::browser; + const bool runs_session = mp.active && (mp.hosting || mp.server_admin); + if (h.tab == Tab::settings && !runs_session) h.tab = mp.active ? Tab::session : Tab::browser; + + // ---- the controller and the keys, watched while the game has them + if (ui.input) DingoSDKOverlayReadControllerInput(&ui.pad, true); + if (!ui.pad.available) ui.pad.buttons = 0; + const std::uint32_t pressed = ui.pad.buttons & ~h.pad_before; + std::array keys{}; + if (ui.input && !h.typing) { + OverlayInputAccess access; + constexpr std::array codes{VK_UP, VK_DOWN, VK_LEFT, VK_RIGHT, VK_RETURN, 'Z', 'C', VK_ESCAPE}; + for (std::size_t i = 0; i < codes.size(); ++i) keys[i] = (GetAsyncKeyState(codes[i]) & 0x8000) != 0; + } + const auto key_new = [&](std::size_t i) { return keys[i] && !h.keys_before[i]; }; + const bool typing_before = h.typing != 0; + h.fire = 0; + // Moving the mouse puts the pointer in charge: the D-pad's highlight goes, and comes back + // with the D-pad. + if (ui.moved && !h.typing) h.ring = false; + h.focus_moved = false; + int previous_tab{}, next_tab{}; + if (ui.input && !typing_before) { + // Back closes the game's menu, which takes a moment to say so: this goes at once. + if (key_new(7) || (pressed & 0x2000)) h.dismissed = true; + const std::array held{keys[0] || (ui.pad.buttons & 1), keys[1] || (ui.pad.buttons & 2), keys[2] || (ui.pad.buttons & 4), keys[3] || (ui.pad.buttons & 8)}; + const std::array fresh{key_new(0) || (pressed & 1), key_new(1) || (pressed & 2), key_new(2) || (pressed & 4), key_new(3) || (pressed & 8)}; + const auto tick = GetTickCount64(); + int direction = -1; + for (int i = 0; i < 4; ++i) + if (fresh[static_cast(i)]) direction = i, h.repeat_at = tick + 380; + if (direction < 0 && tick >= h.repeat_at) + for (int i = 0; i < 4; ++i) + if (held[static_cast(i)]) direction = i, h.repeat_at = tick + 70; + if (direction >= 0) { + // The first press only shows where the focus is. + if (h.ring || !h.focus) move_focus(h, direction == 2 ? -1 : direction == 3 ? 1 : 0, direction == 0 ? -1 : direction == 1 ? 1 : 0); + h.ring = true; + h.focus_moved = true; + } + if (key_new(4) || (pressed & 0x1000)) { + if (h.focus && std::any_of(h.last.begin(), h.last.end(), [&](const Item &entry) { return entry.id == h.focus; })) h.fire = h.focus, h.ring = true; + } + previous_tab = key_new(5) || (pressed & 0x10000); + next_tab = key_new(6) || (pressed & 0x20000); + } + h.keys_before = keys; + // A key that ended the typing (Enter, Escape) is still down: it is not a fresh press of anything. + // And so is one held while the game was not the window in front. + if (typing_before || !ui.input) h.keys_before.fill(true); + if (!ui.input) h.pad_before = ~0U; + h.items.clear(); + + // ---- the page's tabs + const ImVec2 a(page_left, (body_top + lower) * u); + const ImVec2 z(std::min(page_right, a.x + body_most * u), (down - (layout_height - body_bottom) - lower) * u); + const bool tools = h.page.section == 1; + { + // (Each page keeps the tab it was left on.) + std::vector> tabs; + int multiplayer_tab = static_cast(h.tab); + int &open = tools ? h.tools_tab : multiplayer_tab; + if (tools) { + tabs = {{official_tab, "OFFICIAL MAPS"}, {custom_tab, "CUSTOM MAPS"}, {world_tab, "WORLD"}, {parks_tab, "PARKS"}, + {player_tab, "PLAYER"}, {online_tab, "ONLINE"}, {visuals_tab, "VISUALS"}, {binds_tab, "BINDS"}}; + } else { + tabs = {{static_cast(Tab::browser), "SERVER BROWSER"}, {static_cast(Tab::host), "HOST LOBBY"}}; + // The session and its voice chat are only there in one. + if (mp.active) tabs.emplace_back(static_cast(Tab::session), "CURRENT SESSION"), tabs.emplace_back(static_cast(Tab::voice), "VOICE CHAT"); + // And its settings only for whoever runs it. + if (runs_session) tabs.emplace_back(static_cast(Tab::settings), "SESSION SETTINGS"); + } + if (previous_tab || next_tab) { + std::size_t at = 0; + for (std::size_t i = 0; i < tabs.size(); ++i) + if (tabs[i].first == open) at = i; + at = (at + tabs.size() + (next_tab ? 1 : 0) - (previous_tab ? 1 : 0)) % tabs.size(); + open = tabs[at].first; + h.focus = 0; + h.sound = sound_tab; + set_typing(h, 0); + } + const float top = (tabs_top + lower) * u, bottom = top + tabs_height * u, size = 44 * u; + float x = a.x; + const auto cap = [&](std::uint32_t pad_button, const char *key) { + const auto name = ui.pad.available ? controller_combo_label(pad_button, ui.pad.style) : std::string(key); + x += theme::keycap(ui.draw, ui.bold, u * 2.2f, ImVec2(x, top + 20 * u), name.c_str(), "") + 4 * u; + }; + cap(0x10000, "Z"); + for (const auto &[tab, name] : tabs) { + const float w = width_of(ui.bold, size, name) + 56 * u; + const ImVec2 p(x, top), q(x + w, bottom); + const Hit hit = item(ui, hash(name, 1), p, q); + const bool on = open == tab, lit = hit.focus || hit.hover; + skate_theme::rough_rect(ui.draw, p, q, lit && !on ? theme::blue : on ? theme::paper : IM_COL32(26, 26, 26, 200), hash(name) & 0xfff, u * 2); + ui.draw->AddText(ui.bold, size, ImVec2(p.x + 28 * u, top + (bottom - top - size) * .5f), on ? skate_theme::black : theme::paper, name); + // The game's brushed blue line under the tab that is open. + if (on) ui.draw->AddRectFilled(ImVec2(p.x + 6 * u, bottom + 4 * u), ImVec2(q.x - 6 * u, bottom + 14 * u), theme::blue, 5 * u); + if (hit.press && !on) open = tab, h.sound = sound_tab, set_typing(h, 0); + x = q.x + 20 * u; + } + cap(0x20000, "C"); + if (!tools) h.tab = static_cast(multiplayer_tab); + } + + if (tools) tools_page(ui, a, z); + else switch (h.tab) { + case Tab::browser: browser_page(ui, a, z); break; + case Tab::host: host_page(ui, a, z); break; + case Tab::session: session_page(ui, a, z); break; + case Tab::voice: voice_page(ui, a, z); break; + case Tab::settings: settings_page(ui, a, z); break; + } + if (ui.input) h.pad_before = ui.pad.buttons; + // A press that found nothing to land on is forgotten; the focus stays on a control that is + // still there, or goes to the first one. + if (h.focus_next) { + // Asked for this frame, drawn from the next: not there to find yet. + h.focus = std::exchange(h.focus_next, 0); + h.ring = h.focus_moved = true; + h.last = h.items; + return; + } + if (h.focus && std::none_of(h.items.begin(), h.items.end(), [&](const Item &entry) { return entry.id == h.focus; })) h.focus = 0; + if (!h.focus && h.ring && !h.items.empty()) { + // Past the tabs: into the page itself. + const auto first = std::find_if(h.items.begin(), h.items.end(), [&](const Item &entry) { return entry.a.y >= a.y - 1; }); + h.focus = first != h.items.end() ? first->id : h.items.front().id; + } + h.last = h.items; + // The game's sounds, as its own menus make them: moving the focus, pressing, changing tab. + if (h.sound == ~0U && h.focus && h.focus != h.focus_heard && ui.input) h.sound = sound_navigate; + h.focus_heard = h.focus; + if (const auto wanted = std::exchange(h.sound, ~0U); wanted != ~0U && ui.input) { + const auto tick = GetTickCount64(); + // (Sweeping the pointer down a list is not a sound for every row.) + if (const auto play = ui_sound.load(); play && (wanted != sound_navigate || tick - h.sound_at >= 45)) play(wanted), h.sound_at = tick; + } +} +} // namespace dingosdk::overlay::detail diff --git a/Extension/UI/Overlay/multiplayer_menu.cpp b/Extension/UI/Overlay/multiplayer_menu.cpp index 9a5ba035..28c72c06 100644 --- a/Extension/UI/Overlay/multiplayer_menu.cpp +++ b/Extension/UI/Overlay/multiplayer_menu.cpp @@ -23,6 +23,11 @@ bool queue_multiplayer_action(const char* action, const std::string& argument) { std::array result{}; return callback && callback(action, argument.c_str(), "", result.data(), result.size()); } +bool queue_multiplayer_action(const char* action, const std::string& argument, const std::string& password) { + const auto callback = private_queue.load(); + std::array result{}; + return callback && callback(action, argument.c_str(), password.c_str(), result.data(), result.size()); +} } // namespace dingosdk::overlay::detail namespace dingosdk::overlay::menu::multiplayer_detail { diff --git a/Extension/UI/Overlay/overlay.cpp b/Extension/UI/Overlay/overlay.cpp index 0b9d221e..51bac695 100644 --- a/Extension/UI/Overlay/overlay.cpp +++ b/Extension/UI/Overlay/overlay.cpp @@ -43,7 +43,8 @@ bool is_input(UINT message) { } bool interactive_visible(const State& s) { - return s.visible.load() || s.console_visible.load() || s.editor_visible.load() || s.chat_visible.load(); + return s.visible.load() || s.console_visible.load() || s.editor_visible.load() || s.chat_visible.load() || + s.hub_typing.load(); } void restore_input(bool hide_menu) { @@ -52,6 +53,7 @@ void restore_input(bool hide_menu) { s.visible.store(false); s.console_visible.store(false); s.chat_visible.store(false); + s.hub_typing.store(false); s.editor_visible.store(false); s.editor_flight.store(false); } diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 3e4b6892..a15e6a0c 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -280,6 +280,16 @@ void set_multiplayer_queue(MultiplayerQueue) noexcept; // The session's text chat, read by the chat panel every frame (thread-safe, cheap). using ChatFeed = MultiplayerChat (*)(); void set_chat_feed(ChatFeed) noexcept; +// The game's pause menu, as far as ReSkate's own pages in it go: whether one of them is the +// page on screen, and which (`section` 0: Multiplayer, 1: Custom Stuff). The overlay draws +// everything in such a page itself, its tabs too (hub_page.cpp). Read every presented frame +// (thread-safe, cheap). +struct HubPage { + bool visible{}; + int section{}; +}; +using HubPageFeed = HubPage (*)(); +void set_hub_page_feed(HubPageFeed) noexcept; // Text the game draws with its debug text natives, where retail draws nothing (the // S.K.A.T.E. throwdown HUD). One frame of lines, positioned in a width x height screen; // color is R, G, B, A bytes (ImGui's IM_COL32 layout). Read every presented frame @@ -373,6 +383,15 @@ struct CallbacksV3 { char* result, std::size_t result_size) = nullptr; }; +// Plays one of the game's own menu sounds, by its number in the game's list (ui_sound.h: 3 +// moving the focus, 7 pressing something, 56 changing tab). Any thread. +using UiSound = void (*)(std::uint32_t sound) noexcept; +void set_ui_sound(UiSound) noexcept; +// What those pages read the game with and act through: callbacks as the overlay's own, with a +// read_model that takes a snapshot and leaves the console log alone (it is asked a few times a +// second while a page is drawn). +void set_hub_callbacks(const CallbacksV3 &callbacks) noexcept; + struct Status { bool started = false; bool bound = false; diff --git a/Extension/UI/Overlay/overlay_input.cpp b/Extension/UI/Overlay/overlay_input.cpp index b703e847..ef128b24 100644 --- a/Extension/UI/Overlay/overlay_input.cpp +++ b/Extension/UI/Overlay/overlay_input.cpp @@ -228,7 +228,9 @@ LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { if (message == WM_NCDESTROY) s.selected_window_destroyed.store(true); if (message == WM_KILLFOCUS || message == WM_SETFOCUS || message == WM_NCDESTROY) sync_menu_cursor(); // ImGui's Win32 backend ignores raw input, so WM_INPUT is not queued. - if ((interactive_visible(s) || message == WM_KILLFOCUS || message == WM_SETFOCUS) && message != WM_INPUT) { + // (The pause menu's server browser scrolls with the wheel, which only arrives this way.) + if ((interactive_visible(s) || message == WM_KILLFOCUS || message == WM_SETFOCUS || + (message == WM_MOUSEWHEEL && s.hub_pointer.load())) && message != WM_INPUT) { std::lock_guard lock(s.input_mutex); if (message == WM_MOUSEMOVE && !s.input.empty() && s.input.back().message == WM_MOUSEMOVE && s.input.back().window == window) { diff --git a/Extension/UI/Overlay/overlay_internal.h b/Extension/UI/Overlay/overlay_internal.h index 4ea2ff01..6b85f96e 100644 --- a/Extension/UI/Overlay/overlay_internal.h +++ b/Extension/UI/Overlay/overlay_internal.h @@ -126,6 +126,11 @@ struct State { std::atomic chat_visible{false}, chat_available{false}; std::atomic chat_character_pending{false}, chat_escape_pending{false}, chat_focus_requested{false}; std::atomic chat_command_requested{false}; // opened with "/": the box starts with it + // The Multiplayer page drawn in the game's pause menu (hub_page.cpp). hub_pointer: it is + // on screen, so the overlay watches the mouse (position, buttons, wheel) while the game + // keeps it. hub_typing: one of its text boxes has the keyboard, which the overlay takes + // for that long, as for chat. + std::atomic hub_pointer{false}, hub_typing{false}; std::atomic input_attached{false}; std::atomic stop{false}; std::atomic window{nullptr}; @@ -281,4 +286,8 @@ void draw_perf_hud(); void draw_perf_window(); // Queues a private multiplayer action from outside the menu (multiplayer_menu.cpp). bool queue_multiplayer_action(const char* action, const std::string& argument); +bool queue_multiplayer_action(const char* action, const std::string& argument, const std::string& password); +// The pause menu's Multiplayer page (hub_page.cpp): polled every presented frame. +bool hub_page_pending(); +void draw_hub_page(); } diff --git a/Extension/UI/Overlay/overlay_render.cpp b/Extension/UI/Overlay/overlay_render.cpp index a1f05844..051c24b7 100644 --- a/Extension/UI/Overlay/overlay_render.cpp +++ b/Extension/UI/Overlay/overlay_render.cpp @@ -482,6 +482,7 @@ void render(IDXGISwapChain* presented, UINT flags) { const bool nametag_frame = nametags_pending(); const bool meat_frame = hall_of_meat_pending(); const bool perf_frame = perf_hud_pending() || trainer_hud_pending(); + const bool hub_frame = hub_page_pending(); if (trainer_open_requested()) s.visible.store(true); const bool menu_frame = interactive_visible(s); if (!menu_frame) { @@ -499,7 +500,7 @@ void render(IDXGISwapChain* presented, UINT flags) { } // Hidden, the overlay still draws while a notice or chat line is on screen. if (s.loaded_notice_posted && !notices_pending() && !chat_frame && !game_text_frame && !skate_hud_frame && - !nametag_frame && !meat_frame && !perf_frame) return; + !nametag_frame && !meat_frame && !perf_frame && !hub_frame) return; } else if (!s.ui_was_interactive) { s.ui_was_interactive = true; s.last_model = {}; // Reopening immediately reads fresh state. @@ -534,7 +535,9 @@ void render(IDXGISwapChain* presented, UINT flags) { } const bool interactive = interactive_visible(s); sync_menu_cursor(); - if (!interactive) { ImGui::GetIO().ClearInputKeys(); ImGui::GetIO().ClearInputMouse(); } + // (Watching the pointer for the pause menu's browser keeps the mouse state between frames, + // or every frame would look like a fresh click.) + if (!interactive) { ImGui::GetIO().ClearInputKeys(); if (!hub_frame) ImGui::GetIO().ClearInputMouse(); } ImGui::GetIO().MouseDrawCursor = owns_menu_cursor(s); ImGui_ImplDX12_NewFrame(); { OverlayInputAccess access; ImGui_ImplWin32_NewFrame(); } @@ -546,6 +549,7 @@ void render(IDXGISwapChain* presented, UINT flags) { draw_console(); draw_perf_window(); } + draw_hub_page(); draw_hall_of_meat(); draw_nametags(); draw_game_text(); diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index 500a5caa..34a0fca8 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -15,6 +15,7 @@ #include "Extension/Progression/entitlement_request_hook.h" #include "Extension/Skater/skater_observer.h" #include "Extension/UI/NativeMenu/native_menu.h" +#include "Extension/UI/NativeMenu/ui_sound.h" #include "Extension/Throwdowns/throwdown_lab.h" #include "Extension/Multiplayer/Session/session.h" #include "Extension/Throwdowns/throwdown_debug_text.h" @@ -188,6 +189,9 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { dingosdk::multiplayer::set_native_menu_callbacks(native_callbacks); dingosdk::overlay::set_multiplayer_queue(queue_multiplayer_command); dingosdk::overlay::set_chat_feed(dingosdk::multiplayer::chat); + dingosdk::overlay::set_hub_page_feed(dingosdk::multiplayer::native_menu_page); + dingosdk::overlay::set_hub_callbacks(native_callbacks); + dingosdk::overlay::set_ui_sound(dingosdk::multiplayer::queue_ui_sound); dingosdk::overlay::set_game_text_feed(dingosdk::multiplayer::skate_debug_text); dingosdk::overlay::set_skate_hud_feed(dingosdk::multiplayer::skate_hud); dingosdk::overlay::set_nametag_feed(dingosdk::multiplayer::custom_nametags); diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 0e3be092..31b4081a 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -23,6 +23,7 @@ add_library(dingosdk_runtime SHARED Extension/UI/NativeMenu/native_menu.cpp Extension/UI/NativeMenu/native_menu_rows.cpp Extension/UI/NativeMenu/native_menu_multiplayer.cpp + Extension/UI/NativeMenu/ui_sound.cpp Extension/UI/NativeMenu/native_menu_dump.cpp Extension/UI/NativeMenu/native_hub.cpp Extension/UI/NativeMenu/native_menu_data.cpp diff --git a/cmake/UI.cmake b/cmake/UI.cmake index ad8d677a..9afc4552 100644 --- a/cmake/UI.cmake +++ b/cmake/UI.cmake @@ -10,6 +10,7 @@ add_library(dingosdk_overlay STATIC Extension/UI/Overlay/overlay_input.cpp Extension/UI/Overlay/overlay_notices.cpp Extension/UI/Overlay/chat_overlay.cpp + Extension/UI/Overlay/hub_page.cpp Extension/UI/Overlay/game_text_overlay.cpp Extension/UI/Overlay/perf_overlay.cpp Extension/UI/Overlay/skate_hud_overlay.cpp