From 4c07f2493bccf6b7f42c4cff17f9bd7e6efebeb8 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Fri, 16 Oct 2026 23:37:33 -0500 Subject: [PATCH] Menu: a full screen layout for controllers beside the window, and the pages regrouped The ReSkate menu now has two styles, switched with View / F11 or the buttons in its top bar and saved with the profile (`ui fullscreen`): - Window: the mouse menu as before, with a side rail of pages in groups. - Full screen: made for a controller, in the pause menu's look. Pages across the top (LB / RB), each page's sections as the ONLINE page's tabs (LT / RT), button prompts at the bottom, the pause menu's sounds, and the pause menu's own Multiplayer pages as ONLINE. The pages are regrouped: HOME (quick switches and a tile for each page), MAP, BUILD, SKATER (freecam, first person, cheats), SANDBOX (once Trainer), ADDONS (drone, Board Break, Road Rash, Hall of Meat, grind exits, music), ONLINE, PROGRESS, MODS, SETTINGS, DEVELOPER. Full screen, every shared control is drawn for a pad: switches and option rows changed with left / right one step a press, sliders as steppers that repeat when held, rows of buttons, and pages that scroll with the right stick. Search boxes and typed values stay in the window. A pad press takes the menu's focus back and hides the pointer until the mouse moves. Also: map pictures in the map picker (official loading screens, a mod's icon) with a search box in the window, Splash and Tutorial Island left out of it; number boxes beside the window's sliders; the binds in groups, with an action bind to open the menu (`menu`); a menu size for each style (`ui scalefull`); the time of day stepping from the one last asked for. --- Engine/Game/Input/controller_bindings.h | 3 +- Extension/Assets/map_icons.cpp | 49 + Extension/Music/music_page.cpp | 83 +- .../Profile/local_profile_preferences.cpp | 47 +- Extension/Profile/local_profile_runtime.h | 8 +- Extension/Settings/console_commands.cpp | 35 + Extension/Skater/console_commands.cpp | 15 +- Extension/Skater/drone_flight.h | 2 +- Extension/Trainer/trainer_commands.cpp | 2 +- Extension/Trainer/trainer_page.cpp | 187 ++- Extension/UI/Overlay/atmosphere_menu.cpp | 113 ++ Extension/UI/Overlay/cursor.cpp | 24 +- Extension/UI/Overlay/drone_page.cpp | 26 +- Extension/UI/Overlay/hub_page.cpp | 84 +- Extension/UI/Overlay/map_download_card.cpp | 1 + Extension/UI/Overlay/menu_map_pictures.cpp | 144 ++ Extension/UI/Overlay/modding_menu.cpp | 30 +- Extension/UI/Overlay/multiplayer_lobbies.cpp | 2 +- Extension/UI/Overlay/multiplayer_menu.cpp | 32 +- Extension/UI/Overlay/multiplayer_session.cpp | 18 +- Extension/UI/Overlay/overlay.h | 36 + Extension/UI/Overlay/overlay_internal.h | 8 + Extension/UI/Overlay/overlay_render.cpp | 119 +- Extension/UI/Overlay/progression_menu.cpp | 8 +- Extension/UI/Overlay/skate_menu.cpp | 1177 +++++++++++++++-- Extension/UI/Overlay/skate_menu.h | 19 +- Extension/UI/Overlay/skate_menu_addons.cpp | 293 ++++ Extension/UI/Overlay/skate_menu_internal.h | 82 +- Extension/UI/Overlay/skate_menu_settings.cpp | 235 +++- Extension/UI/Overlay/skate_menu_skater.cpp | 126 +- Extension/UI/Overlay/skate_menu_world.cpp | 295 ++++- Runtime/overlay_callbacks.cpp | 2 + cmake/UI.cmake | 2 + 33 files changed, 2843 insertions(+), 464 deletions(-) create mode 100644 Extension/UI/Overlay/menu_map_pictures.cpp create mode 100644 Extension/UI/Overlay/skate_menu_addons.cpp diff --git a/Engine/Game/Input/controller_bindings.h b/Engine/Game/Input/controller_bindings.h index 639ed1e3..09067553 100644 --- a/Engine/Game/Input/controller_bindings.h +++ b/Engine/Game/Input/controller_bindings.h @@ -89,7 +89,8 @@ struct ControllerInput { struct ActionBind { std::string_view key, name, label, command; }; -inline constexpr std::array action_binds{{ +inline constexpr std::array action_binds{{ + {"reskate_menu", "reskatemenu", "Open the ReSkate menu", "menu"}, {"first_person", "firstperson", "First person", "firstperson toggle"}, {"hide_hud", "hidehud", "Hide HUD", "hideui toggle"}, {"voice_chat", "voicechat", "Voice chat on / off", "mp voice-chat toggle"}, diff --git a/Extension/Assets/map_icons.cpp b/Extension/Assets/map_icons.cpp index 62f6aa82..2a168dfa 100644 --- a/Extension/Assets/map_icons.cpp +++ b/Extension/Assets/map_icons.cpp @@ -9,7 +9,9 @@ #include "Engine/Vfs/thunderstore_package.h" #include "Extension/Multiplayer/Session/session.h" #include "Extension/UI/Overlay/overlay.h" +#include #include +#include #include #include #include @@ -136,9 +138,56 @@ void fetch(unsigned serial, std::vector maps) { } fetching.store(false); } + +// The menu's map grid: each level's picture, a custom map's mod icon (its path found on the client +// thread) or an official map's loading screen. +std::atomic menu_fetching{}; +void fetch_menu(std::vector> maps) { + std::vector icons; + for (auto& [map, icon_path] : maps) { + overlay::MapVoteIcon icon; + if (!icon_path.empty()) icon.png = read_bytes(icon_path); + if (icon.png.empty()) { + icon.rgba = game_picture(map.name); + if (!icon.rgba.empty()) icon.side = picture_side; + } + if (icon.png.empty() && icon.rgba.empty()) continue; + icon.name = std::move(map.asset); + icons.push_back(std::move(icon)); + } + overlay::set_menu_map_pictures(icons); + try { + logging::log(logging::Level::info, logging::Channel::assets, "Menu map grid: {} of {} map(s) with a picture.", icons.size(), maps.size()); + } catch (...) {} + menu_fetching.store(false); +} +bool same_asset(std::string_view a, std::string_view b) { + return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) { + return std::tolower(static_cast(x)) == std::tolower(static_cast(y)); + }); +} +void menu_tick() { + if (menu_fetching.load()) return; + auto wanted = overlay::take_menu_map_request(); + if (wanted.empty()) return; + std::vector> maps; + const auto& catalog = mods::catalog(); + for (auto& map : wanted) { + fs::path icon; + for (const auto& mod : catalog.mods) + if (std::ranges::any_of(mod.levels, [&](const std::string& level) { return same_asset(level, map.asset); })) { + icon = mod.directory / L"icon.png"; + break; + } + maps.emplace_back(std::move(map), std::move(icon)); + } + menu_fetching.store(true); + std::thread(fetch_menu, std::move(maps)).detach(); +} } // namespace void tick() { + menu_tick(); const auto serial = multiplayer::next_maps_serial(); if (serial == wanted_serial.load() || fetching.load()) return; wanted_serial.store(serial); diff --git a/Extension/Music/music_page.cpp b/Extension/Music/music_page.cpp index 65dcfd80..52fb77f7 100644 --- a/Extension/Music/music_page.cpp +++ b/Extension/Music/music_page.cpp @@ -95,9 +95,15 @@ void player_tab(SkateMenu& menu, Page& p) { } } ImGui::Dummy(ImVec2(0, px(4))); + using external_player::Command; + if (console_style()) { + const int pressed = button_row(menu, "music-buttons", {{"PREVIOUS", p.station}, {p.playing ? "PAUSE" : "PLAY", p.station, true}, {"NEXT", p.station}}); + if (pressed == 0) external_player::send(Command::previous); + if (pressed == 1) external_player::send(Command::toggle), p.read_at = -1; + if (pressed == 2) external_player::send(Command::next); + } else { const float gap = px(6), width = (ImGui::GetContentRegionAvail().x - gap * 2) / 3; ImGui::BeginDisabled(!p.station); - using external_player::Command; if (ImGui::Button("PREVIOUS", ImVec2(width, px(36)))) external_player::send(Command::previous); ImGui::SameLine(0, gap); skate_theme::push_primary_button(); @@ -109,6 +115,7 @@ void player_tab(SkateMenu& menu, Page& p) { ImGui::SameLine(0, gap); if (ImGui::Button("NEXT", ImVec2(width, px(36)))) external_player::send(Command::next); ImGui::EndDisabled(); + } ImGui::Dummy(ImVec2(0, px(4))); bool notices = youtube_music::notices(); if (toggle_row(menu, "Show \"Loading song\" in the corner", "The notice in the top left while a YouTube song or a radio station is on its way.", notices)) { @@ -121,11 +128,23 @@ void player_tab(SkateMenu& menu, Page& p) { const auto& s = p.streams; const bool lined_up = s.playing && s.playing_list < s.playlists.size(); begin_card(menu, "music-queue", "QUEUE", lined_up ? s.playlists[s.playing_list].name.c_str() : !p.spotify.empty() ? "Up next on Spotify" : nullptr); - if (lined_up) { + if (lined_up && console_style()) { + const auto& songs = s.playlists[s.playing_list].songs; + const bool held = s.loading || (p.pressed >= 0 && ImGui::GetTime() - p.pressed < 1.5); + for (std::size_t i = 0; i < songs.size(); ++i) { + const bool current = i == s.playing_song; + if (list_row(menu, std::to_string(i).c_str(), std::format("{:>3} {}", i + 1, songs[i].title), songs[i].artist, current, !held) && !current) { + youtube_music::play_at(s.playing_list, i); + p.pressed = ImGui::GetTime(); + p.read_at = -1; + } + if (ImGui::IsItemFocused()) set_console_hint(current ? "Playing now." : "A plays this song."); + } + } else if (lined_up) { const auto& songs = s.playlists[s.playing_list].songs; ImGui::SetNextItemWidth(-FLT_MIN); ImGui::InputTextWithHint("##music-find", "Find a song or artist", p.find.data(), p.find.size()); - ImGui::BeginChild("music-songs", ImVec2(0, std::max(px(160), ImGui::GetContentRegionAvail().y - px(330))), ImGuiChildFlags_None); + ImGui::BeginChild("music-songs", ImVec2(0, std::max(px(160), ImGui::GetContentRegionAvail().y - px(330))), ImGuiChildFlags_NavFlattened); // (One press at a time: a song that is loading is not begun again under more presses.) const bool held = s.loading || (p.pressed >= 0 && ImGui::GetTime() - p.pressed < 1.5); for (std::size_t i = 0; i < songs.size(); ++i) { @@ -147,7 +166,7 @@ void player_tab(SkateMenu& menu, Page& p) { } ImGui::EndChild(); } else if (!p.spotify.empty()) { - ImGui::BeginChild("music-spotify", ImVec2(0, std::max(px(160), ImGui::GetContentRegionAvail().y - px(330))), ImGuiChildFlags_None); + ImGui::BeginChild("music-spotify", ImVec2(0, std::max(px(160), ImGui::GetContentRegionAvail().y - px(330))), ImGuiChildFlags_NavFlattened); for (std::size_t i = 0; i < p.spotify.size(); ++i) { ImGui::PushStyleColor(ImGuiCol_Text, i ? skate_theme::white : skate_theme::blue_hover); ImGui::TextUnformatted(std::format("{:>3} {}", i + 1, p.spotify[i].title).c_str()); @@ -168,7 +187,7 @@ void sound_tab(SkateMenu& menu, Page& p) { bool editing{}, changed{}; begin_card(menu, "music-loudness", "LOUDNESS"); field(menu, "Music loudness", "How loud the music you play through ReSkate is in the game."); - if (ImGui::SliderFloat("##music-gain", &p.gain, 0.0f, 400.0f, "%.0f%%", ImGuiSliderFlags_AlwaysClamp)) music_inject::set_gain(p.gain / 100.0f); + if (::dingosdk::overlay::menu::slider_float("##music-gain", &p.gain, 0.0f, 400.0f, "%.0f%%", ImGuiSliderFlags_AlwaysClamp)) music_inject::set_gain(p.gain / 100.0f); editing |= ImGui::IsItemActive(); changed |= ImGui::IsItemDeactivatedAfterEdit(); bool levelling = audio_dsp::levelling(); @@ -185,6 +204,38 @@ void sound_tab(SkateMenu& menu, Page& p) { begin_card(menu, "music-equaliser", "EQUALISER", "Up for more of that part of the sound, down for less."); static constexpr std::array names{"BASS", "LOW MID", "MID", "HIGH MID", "TREBLE"}, where{"60 Hz", "250 Hz", "1 kHz", "4 kHz", "12 kHz"}; + struct Preset { + const char* name; + std::array bands; + }; + static constexpr std::array presets{{{"FLAT", {0, 0, 0, 0, 0}}, {"BASS BOOST", {6, 3, 0, 0, 0}}, {"LOUD", {5, 2, -1, 2, 4}}, {"VOCAL", {-2, 0, 3, 3, 0}}, {"BRIGHT", {0, 0, 0, 3, 5}}}}; + if (console_style()) { + for (int i = 0; i < audio_dsp::bands; ++i) { + const auto at = static_cast(i); + field(menu, std::format("{} ({})", names[at], where[at]).c_str(), "Left / right a decibel at a time."); + ImGui::PushID(i); + set_console_step(1.0); + if (slider_float("##band", &p.bands[at], -audio_dsp::most_decibels, audio_dsp::most_decibels, "%+.0f dB", ImGuiSliderFlags_AlwaysClamp)) { + p.bands[at] = std::round(p.bands[at]); + audio_dsp::set_band(i, p.bands[at]); + } + editing |= ImGui::IsItemActive(); + changed |= ImGui::IsItemDeactivatedAfterEdit(); + ImGui::PopID(); + } + const int pressed = button_row(menu, "eq-presets", {{presets[0].name, true, presets[0].bands == p.bands}, {presets[1].name, true, presets[1].bands == p.bands}, + {presets[2].name, true, presets[2].bands == p.bands}, {presets[3].name, true, presets[3].bands == p.bands}, {presets[4].name, true, presets[4].bands == p.bands}}); + if (pressed >= 0) { + p.bands = presets[static_cast(pressed)].bands; + for (int band = 0; band < audio_dsp::bands; ++band) audio_dsp::set_band(band, p.bands[static_cast(band)]); + changed = true; + } + note("The loudness, levelling and equaliser only change music played through ReSkate, not the game's own."); + end_card(); + p.editing = editing; + if (changed) spotify_dashboard::save_settings(); + return; + } // Each band is a column: how much it is set to, a narrow track with a line where 0 dB is // and a filled bar from that line to the handle, then its name and where it sits. const float gap = px(10), column = std::min(px(96), (ImGui::GetContentRegionAvail().x - gap * (audio_dsp::bands - 1)) / audio_dsp::bands); @@ -232,11 +283,6 @@ void sound_tab(SkateMenu& menu, Page& p) { ImGui::SetCursorScreenPos(origin); ImGui::Dummy(ImVec2((column + gap) * audio_dsp::bands - gap, px(24) + tall + px(46))); ImGui::Dummy(ImVec2(0, px(6))); - struct Preset { - const char* name; - std::array bands; - }; - static constexpr std::array presets{{{"FLAT", {0, 0, 0, 0, 0}}, {"BASS BOOST", {6, 3, 0, 0, 0}}, {"LOUD", {5, 2, -1, 2, 4}}, {"VOCAL", {-2, 0, 3, 3, 0}}, {"BRIGHT", {0, 0, 0, 3, 5}}}}; for (std::size_t i = 0; i < presets.size(); ++i) { if (i) ImGui::SameLine(0, px(6)); const bool on = presets[i].bands == p.bands; @@ -278,6 +324,16 @@ void sources_tab(SkateMenu& menu, Page& p) { const auto& playlist = p.streams.playlists[i]; if (playlist.kind != Kind::files) continue; ImGui::PushID(static_cast(i)); + if (console_style()) { + const auto label = std::format("RESCAN {} ({} songs)", playlist.name, playlist.songs.size()); + if (button_row(menu, "rescan", {{label.c_str(), !p.streams.busy}}) == 0) { + youtube_music::add_folder(playlist.url); + feedback(menu, "Reading the folder again."); + p.read_at = -1; + } + ImGui::PopID(); + continue; + } ImGui::BeginDisabled(p.streams.busy); if (ImGui::Button("RESCAN")) { youtube_music::add_folder(playlist.url); @@ -300,8 +356,11 @@ void music_page(SkateMenu& menu, const Model&, const CallbacksV3&) { read(p); category_tabs(menu, p.tab, {"PLAYER", "SOUND", "SOURCES"}, "music-tabs"); ImGui::PushID(p.tab); - ImGui::BeginChild("music-tab", ImVec2(0, page_body_height(menu))); - if (p.tab == 0) player_tab(menu, p); + ImGui::BeginChild("music-tab", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); + if (p.tab == 0) { + player_tab(menu, p); + music_playback_card(menu); + } else if (p.tab == 1) sound_tab(menu, p); else sources_tab(menu, p); ImGui::EndChild(); diff --git a/Extension/Profile/local_profile_preferences.cpp b/Extension/Profile/local_profile_preferences.cpp index bb865077..5aa3632c 100644 --- a/Extension/Profile/local_profile_preferences.cpp +++ b/Extension/Profile/local_profile_preferences.cpp @@ -274,29 +274,58 @@ void set_local_values(const std::vector>& values) n } catch (...) { /* Saving is best effort; the runtime values already applied. */ } } -// Shared with the overlay through the model; clamped to a legible range. -std::atomic& menu_scale_cache() noexcept { static std::atomic value{0}; return value; } +// Shared with the overlay through the model; clamped to a legible range. The window and the full +// screen menu each keep their own (ReSkate.MenuScale, ReSkate.MenuScaleFullscreen). +std::atomic& menu_scale_cache(bool fullscreen) noexcept { + static std::atomic window{0}, full{0}; + return fullscreen ? full : window; +} +const char* menu_scale_key(bool fullscreen) noexcept { return fullscreen ? "ReSkate.MenuScaleFullscreen" : "ReSkate.MenuScale"; } -float local_menu_scale() noexcept { - auto& cache = menu_scale_cache(); +float local_menu_scale(bool fullscreen) noexcept { + auto& cache = menu_scale_cache(fullscreen); if (const auto value = cache.load(std::memory_order_relaxed); value > 0) return value; float value = default_menu_scale; try { auto& s = local_runtime(); if (s.store) - if (const auto saved = s.store->user_value("ReSkate.MenuScale"); saved && saved->is_number()) + if (const auto saved = s.store->user_value(menu_scale_key(fullscreen)); saved && saved->is_number()) value = std::clamp(static_cast(saved->get()), min_menu_scale, max_menu_scale); } catch (...) { /* An unreadable preference just means the default scale. */ } cache.store(value, std::memory_order_relaxed); return value; } -bool set_local_menu_scale(float value) noexcept { - if (!(value >= min_menu_scale && value <= max_menu_scale)) return false; - menu_scale_cache().store(value, std::memory_order_relaxed); +std::atomic& menu_fullscreen_cache() noexcept { static std::atomic value{-1}; return value; } + +bool local_menu_fullscreen() noexcept { + auto& cache = menu_fullscreen_cache(); + if (const auto value = cache.load(std::memory_order_relaxed); value >= 0) return value != 0; + bool value = false; try { auto& s = local_runtime(); - if (s.store) s.store->set_user_value("ReSkate.MenuScale", static_cast(value)); + if (s.store) + if (const auto saved = s.store->user_value("ReSkate.MenuFullscreen"); saved && saved->is_boolean()) + value = saved->get(); + } catch (...) { /* An unreadable preference just means a window. */ } + cache.store(value ? 1 : 0, std::memory_order_relaxed); + return value; +} + +void set_local_menu_fullscreen(bool fullscreen) noexcept { + menu_fullscreen_cache().store(fullscreen ? 1 : 0, std::memory_order_relaxed); + try { + auto& s = local_runtime(); + if (s.store) s.store->set_user_value("ReSkate.MenuFullscreen", fullscreen); + } catch (...) { /* Saving is best effort; the runtime value already applied. */ } +} + +bool set_local_menu_scale(float value, bool fullscreen) noexcept { + if (!(value >= min_menu_scale && value <= max_menu_scale)) return false; + menu_scale_cache(fullscreen).store(value, std::memory_order_relaxed); + try { + auto& s = local_runtime(); + if (s.store) s.store->set_user_value(menu_scale_key(fullscreen), static_cast(value)); } catch (...) { /* Saving is best effort; the runtime value already applied. */ } return true; } diff --git a/Extension/Profile/local_profile_runtime.h b/Extension/Profile/local_profile_runtime.h index f315da01..a4ebd5b3 100644 --- a/Extension/Profile/local_profile_runtime.h +++ b/Extension/Profile/local_profile_runtime.h @@ -102,8 +102,12 @@ std::optional local_value(std::string_view key) noexcept; void set_local_values(const std::vector>& values) noexcept; // ReSkate menu scale, clamped to a legible range. Cached so the overlay can // read it every frame without touching the database. -float local_menu_scale() noexcept; -bool set_local_menu_scale(float value) noexcept; +// The ReSkate menu's size: the window's, or (`fullscreen`) the full screen menu's. +float local_menu_scale(bool fullscreen = false) noexcept; +bool set_local_menu_scale(float value, bool fullscreen = false) noexcept; +// Whether the ReSkate menu fills the screen (made for a controller) or is a window. Cached likewise. +bool local_menu_fullscreen() noexcept; +void set_local_menu_fullscreen(bool fullscreen) noexcept; // The game's camera height option (UseHighCam, true is the high camera) as the game writes it, // which its camera height button does: the listener returns the height to keep instead. Runs on // the thread that writes the option. diff --git a/Extension/Settings/console_commands.cpp b/Extension/Settings/console_commands.cpp index 7ba64f58..c8d1f3b9 100644 --- a/Extension/Settings/console_commands.cpp +++ b/Extension/Settings/console_commands.cpp @@ -121,5 +121,40 @@ void register_settings_commands(Commands ®istry) { out("Menu scale reset."); }; registry.add(std::move(menu)); + + auto full_scale = argument("scale", Type::number); + full_scale.minimum = min_menu_scale; + full_scale.maximum = max_menu_scale; + auto menu_full = variable("ui scalefull", "Scale the full screen ReSkate menu and its text", Group::console, std::move(full_scale)); + menu_full.execution = Execution::local; + menu_full.inspect = [](const Model &m) { + return State{true, std::format("{:.2f}", m.menu_scale_fullscreen), {}, "Saved with your profile.", false}; + }; + menu_full.run = [](const Model &, const Values &args, const Output &out) { + const auto value = static_cast(std::get(args[0])); + out(profile_runtime::set_local_menu_scale(value, true) + ? std::format("Full screen menu scale set to {:.2f}.", value) + : std::format("error: Choose a scale from {:.2f} to {:.2f}.", min_menu_scale, max_menu_scale)); + }; + menu_full.reset = [](const Model &, const Output &out) { + profile_runtime::set_local_menu_scale(default_menu_scale, true); + out("Full screen menu scale reset."); + }; + registry.add(std::move(menu_full)); + + auto fullscreen = variable("ui fullscreen", "Show the ReSkate menu full screen, made for a controller (0: a window)", + Group::console, argument("0|1", Type::boolean)); + fullscreen.execution = Execution::local; + fullscreen.inspect = [](const Model &m) { return boolean_state(true, m.menu_fullscreen, {}); }; + fullscreen.run = [](const Model &, const Values &args, const Output &out) { + const bool on = std::get(args[0]); + profile_runtime::set_local_menu_fullscreen(on); + out(on ? "The ReSkate menu fills the screen." : "The ReSkate menu is a window."); + }; + fullscreen.reset = [](const Model &, const Output &out) { + profile_runtime::set_local_menu_fullscreen(false); + out("The ReSkate menu is a window."); + }; + registry.add(std::move(fullscreen)); } } // namespace dingosdk::console diff --git a/Extension/Skater/console_commands.cpp b/Extension/Skater/console_commands.cpp index 4ad76178..856ffd8f 100644 --- a/Extension/Skater/console_commands.cpp +++ b/Extension/Skater/console_commands.cpp @@ -66,7 +66,7 @@ void register_movement_commands(Commands ®istry) { registry.add(std::move(entry)); } auto drone_mode = variable("dronemode", - "Fly the free camera as an FPV drone. Easiest from the DRONE page of the ReSkate menu (Insert): Start flying", + "Fly the free camera as an FPV drone. Easiest from Addons > Drone in the ReSkate menu (Insert): Start flying", Group::movement, argument("0|1", Type::boolean)); drone_mode.inspect = [](const Model &m) { return boolean_state(m.debug.available, drone::is_enabled(), "Waiting for local player controls.", @@ -130,13 +130,20 @@ void register_movement_commands(Commands ®istry) { : std::format("{} = {:g} (the closest allowed value)", name, value)); }; registry.add(std::move(tune)); - auto menu_command = action("dronemenu", "Open the ReSkate menu on its DRONE page", Group::movement); + auto menu_command = action("dronemenu", "Open the ReSkate menu on Addons > Drone", Group::movement); menu_command.inspect = [](const Model &) { return State{true, {}, {}, {}, false}; }; menu_command.run = [](const Model &, const Values &, const Output &out) { drone::menu_flag().store(true); - out("Opening the DRONE page."); + out("Opening Addons > Drone."); }; registry.add(std::move(menu_command)); + auto open_menu = action("menu", "Open the ReSkate menu (bind it to a controller combo to open it with a pad)", Group::console); + open_menu.inspect = [](const Model &) { return State{true, {}, {}, {}, false}; }; + open_menu.run = [](const Model &, const Values &, const Output &out) { + overlay::menu_open_flag().store(true); + out("Opening the ReSkate menu."); + }; + registry.add(std::move(open_menu)); auto arm_command = action("dronearm", "Arm the drone: motors on, ready to fly", Group::movement); arm_command.inspect = [](const Model &) { return State{true, {}, {}, {}, false}; }; arm_command.run = [](const Model &, const Values &, const Output &out) { @@ -165,7 +172,7 @@ void register_movement_commands(Commands ®istry) { Group::movement, argument("0|1", Type::boolean)); radio_mode.inspect = [](const Model &m) { return boolean_state(m.debug.available, drone::radio::config().enabled.load(), "Waiting for local player controls.", - "Set it up on the DRONE page (dronemenu), CONTROLLER tab"); + "Set it up in Addons > Drone (dronemenu), CONTROLLER tab"); }; radio_mode.run = [](const Model &, const Values &args, const Output &out) { const bool on = std::get(args[0]); diff --git a/Extension/Skater/drone_flight.h b/Extension/Skater/drone_flight.h index 18e20c5b..e5a751d7 100644 --- a/Extension/Skater/drone_flight.h +++ b/Extension/Skater/drone_flight.h @@ -189,7 +189,7 @@ inline constexpr KnobInfo knob_info[] = { {"motorpitch", "Motor sound pitch. Higher sounds like a smaller drone", 0.4f, 2.5f, 1.0f}, {"throttlecentre", "Your throttle stick springs back to the middle (gamepads): the middle hovers, up climbs, down descends", 0.0f, 1.0f, 0.0f}, {"deadzone", "How far a stick must move before the drone reacts (0-0.3). Raise it if the drone drifts on its own", 0.0f, 0.3f, 0.03f}, - {"goggles", "What your skater wears over their eyes while you fly: 0 = nothing, 1-8 = one of the eyewear choices on the DRONE page", 0.0f, 8.0f, 1.0f}, + {"goggles", "What your skater wears over their eyes while you fly: 0 = nothing, 1-8 = one of the eyewear choices in Addons > Drone", 0.0f, 8.0f, 1.0f}, {"hideui", "Hide the game's own interface while you fly or the drone follows you (Start flying or Follow me hides it, stopping brings it back)", 0.0f, 1.0f, 1.0f}, {"gamepad", "Fly with a gamepad (Xbox or PlayStation) instead of an RC radio: left stick throttle and yaw, right stick pitch and roll, with gentler sticks. A arms, Menu / Options puts the drone back", 0.0f, 1.0f, 1.0f}, {"padrate", "Gamepad: how much of your rates the sticks reach at full tilt (0.2-1). Lower is calmer. In angle mode it slows turning only: full stick still leans all the way", 0.2f, 1.0f, 0.6f}, diff --git a/Extension/Trainer/trainer_commands.cpp b/Extension/Trainer/trainer_commands.cpp index de4b74c9..a7b4a3f7 100644 --- a/Extension/Trainer/trainer_commands.cpp +++ b/Extension/Trainer/trainer_commands.cpp @@ -12,7 +12,7 @@ void register_trainer_commands(Commands ®istry) { }; const Verb verbs[]{ {"status", "What the trainer is doing", nullptr, false}, - {"open", "Open the menu on the trainer page", "[tune|presets|practice|map|realistic|fun|everything]", false}, + {"open", "Open the menu on the Sandbox page", "[tune|presets|practice|map|realistic|fun|everything]", false}, {"set", "Set a physics tuning value live", " ", true}, {"freeze", "Keep a value through presets and resets", " 0|1", true}, {"reset", "Put a value (or all) back to stock", "|all", false}, diff --git a/Extension/Trainer/trainer_page.cpp b/Extension/Trainer/trainer_page.cpp index 8153785b..be4c0584 100644 --- a/Extension/Trainer/trainer_page.cpp +++ b/Extension/Trainer/trainer_page.cpp @@ -96,6 +96,31 @@ void filter_rows(Page &p, const trainer::View &view) { void value_row(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::View &view, const trainer::Row &row, bool with_group, bool friendly) { + if (console_style()) { + // Full screen: a switch, or the value nudged a step at a time (a fiftieth of its size). + ImGui::PushID(row.id.c_str()); + const auto label = friendly && !row.friendly.empty() ? row.friendly : with_group ? row.group + " / " + row.label : row.label; + const auto hint = std::format("{}. The game's own is {}.{}", row.id, number(row.stock), + row.used ? "" : " The game was not found reading it, so changing it may do nothing."); + const bool can = view.editable && callbacks.queue_console_command; + if (row.kind == trainer::Kind::flag) { + bool on = row.value != 0; + if (toggle_row(menu, label.c_str(), hint.c_str(), on, can)) // (a label that changes would lose the focus) + trainer_command(menu, callbacks, std::format("set {} {}", row.id, on ? 1 : 0)); + } else { + field(menu, (row.touched ? label + " *" : label).c_str(), hint.c_str()); + const bool scale = row.kind == trainer::Kind::curve || row.kind == trainer::Kind::graph; + const bool whole = row.kind == trainer::Kind::integer; + double value = row.value; + set_console_step(whole ? 1.0 : scale ? 0.05 : std::max({std::abs(row.stock), std::abs(value), 0.01}) * 0.02); + ImGui::BeginDisabled(!can); + if (slider_double("##value", &value, scale ? 0.0 : -1.0e6, 1.0e6, whole ? "%.0f" : scale ? "x %.2f" : "%.4g", ImGuiSliderFlags_AlwaysClamp)) + trainer_command(menu, callbacks, std::format("set {} {}", row.id, number(value))); + ImGui::EndDisabled(); + } + ImGui::PopID(); + return; + } ImGui::PushID(row.id.c_str()); bool frozen = row.frozen; if (ImGui::Checkbox("##freeze", &frozen)) trainer_command(menu, callbacks, std::format("freeze {} {}", row.id, frozen ? 1 : 0)); @@ -157,6 +182,18 @@ void preset_switch(SkateMenu &menu, const CallbacksV3 &callbacks, const trainer: } // A built-in preset as a dial: one multiplier that moves everything the preset moves. void dial_row(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::PresetRow &preset) { + if (console_style()) { + ImGui::PushID(preset.name.c_str()); + const auto hint = std::format("{} 1 is the game's own; \"{}\" is x {}.", preset.note, preset.name, number(preset.amount)); + field(menu, std::abs(preset.factor - 1.0) > 1e-4 ? (preset.title + " *").c_str() : preset.title.c_str(), hint.c_str()); + double value = preset.factor; + const double low = preset.amount < 1 ? 0.05 : 0.2, high = preset.amount < 1 ? 3.0 : 10.0; + if (slider_double("##dial", &value, low, high, "x %.2f", ImGuiSliderFlags_Logarithmic) && value > 0) + trainer_command(menu, callbacks, std::format("dial {} {}", number(value), preset.name)); + ImGui::PopID(); + (void)p; + return; + } ImGui::PushID(preset.name.c_str()); const float column = ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x * 0.40f; const bool moved = std::abs(preset.factor - 1.0) > 1e-4; @@ -216,7 +253,10 @@ void tune_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, } if (!view.editable) warn(view.blocked.c_str()); // Two short lists and the whole table. - { + if (console_style()) { + static const std::vector modes{"Realistic: tone it down", "Fun: turn it up", "Everything"}; + option_row(menu, "Showing", "Left / right: a short list to tone the game down, one to turn it up, or every value.", p.mode, modes); + } else { static constexpr const char *modes[]{"REALISTIC", "FUN", "EVERYTHING"}; const float width = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 2) / 3; for (int i = 0; i < 3; ++i) { @@ -237,6 +277,17 @@ void tune_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, if (preset.builtin && preset.dial && (p.mode == 2 || (preset.modes & list))) dial_row(menu, callbacks, p, preset); // The presets that are plain switches, and the ones that set several dials at once. bool first = true; + if (console_style()) + for (const auto &preset : view.presets) { + // Full screen, every preset as a switch: on while all it sets holds. + if (!preset.builtin || preset.name == "Stock" || !(p.mode == 2 || (preset.modes & list))) continue; + bool on = preset.active; + ImGui::PushID(preset.name.c_str()); + if (toggle_row(menu, preset.name.c_str(), preset.note.c_str(), on, can_edit)) + trainer_command(menu, callbacks, std::string(on ? "preset apply " : "preset remove ") + preset.name); + ImGui::PopID(); + } + else for (const auto &preset : view.presets) { if (!preset.builtin || preset.dial || preset.name == "Stock" || !(p.mode == 2 || (preset.modes & list))) continue; if (!first && ImGui::GetItemRectMax().x + ImGui::CalcTextSize(preset.name.c_str()).x + ImGui::GetStyle().FramePadding.x * 4 < @@ -247,13 +298,23 @@ void tune_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, } ImGui::EndDisabled(); if (p.mode != 0) { - // The game's own bail protection, the same switch as Skater > Movement. + // The game's own bail protection, the same switch as Skater > Cheats. const auto &debug = model.debug; bool no_bail = debug.no_bail; if (toggle_row(menu, "Never bail", "No wipeouts at all. Recover from a bail before switching it on.", no_bail, (debug.no_bail_available || debug.no_bail) && callbacks.queue_debug != nullptr)) debug_request(menu, callbacks, {DebugAction::set_no_bail, no_bail}); } + if (console_style()) { + const auto changed = std::format("{} values changed. Every one back to the game's own; locked values stay.", view.touched); + const int pressed = button_row(menu, "tune-reset", {{"RESET VALUES", can_edit && view.touched > 0, false, changed.c_str()}, + {"RESET EVERYTHING", callbacks.queue_console_command != nullptr, false, "The game as it shipped: every value, lock, preset and trick slider."}}); + if (pressed == 0) trainer_command(menu, callbacks, "reset all"); + if (pressed == 1) { + p.hippy_editing = p.nocomply_editing = p.boneless_editing = p.offboard_editing = p.flip_editing = false; + trainer_command(menu, callbacks, "reset everything"); + } + } else { ImGui::BeginDisabled(!can_edit || !view.touched); if (ImGui::Button("Reset values")) trainer_command(menu, callbacks, "reset all"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Every tuning value back to the game's own. Locked values stay."); @@ -261,6 +322,7 @@ void tune_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, ImGui::SameLine(); ImGui::AlignTextToFramePadding(); ImGui::TextDisabled("%zu values changed", view.touched); + } end_card(); ImGui::BeginDisabled(!can_edit); @@ -269,6 +331,25 @@ void tune_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, if (p.mode == 2) { begin_card(menu, "preset-save", "YOUR PRESETS", "Save what is changed right now"); + if (console_style()) { + for (const auto &preset : view.presets) { + if (preset.builtin) continue; + const int pressed = button_row(menu, preset.name.c_str(), { + {preset.name.c_str(), can_edit, preset.active, preset.active ? "On: A turns it off." : "A turns it on."}, + {"USE ON THIS MAP", can_edit, view.profile_preset == preset.name, "Apply it every time this map loads."}, + {"DELETE", can_edit, false, "Delete this preset."}}); + if (pressed == 0) trainer_command(menu, callbacks, std::string(preset.active ? "preset remove " : "preset apply ") + preset.name); + if (pressed == 1) trainer_command(menu, callbacks, "profile set " + preset.name); + if (pressed == 2) trainer_command(menu, callbacks, "preset delete " + preset.name); + } + if (!view.map.empty()) { + info(menu, "This map applies", view.profile_preset.empty() ? "nothing" : view.profile_preset); + if (!view.profile_preset.empty() && button_row(menu, "profile-clear", {{"STOP APPLYING IT ON THIS MAP"}}) == 0) + trainer_command(menu, callbacks, "profile clear"); + } + note("Name and save a preset in the window menu (View): it needs a keyboard."); + end_card(); + } else { ImGui::BeginDisabled(!can_edit); const float button = ImGui::CalcTextSize("Turn off").x + ImGui::GetStyle().FramePadding.x * 2; for (const auto &preset : view.presets) { @@ -307,12 +388,24 @@ void tune_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, note("\"This map\" beside one of your presets applies it every time this map loads."); } end_card(); + } } // The values themselves: the short list's, or all of them. ImGui::Spacing(); ImGui::SeparatorText(p.mode == 2 ? "EVERY VALUE" : "FINE TUNING"); - if (p.mode == 2) { + if (p.mode == 2 && console_style()) { + trainer::note_class_list_shown(); + p.search[0] = 0; // (searching wants a keyboard: the window menu has it) + std::vector groups{"Every group"}; + for (const auto &group : view.groups) groups.push_back(group); + int group = std::max(p.group, 1) - 1; + if (option_row(menu, "Group", "Left / right for one group of values.", group, groups)) p.group = group + 1; + toggle_row(menu, "Only what I changed", "Only the values you changed or locked.", p.only_changed); + toggle_row(menu, "Graph points", "Every point of the tuning graphs, not just one multiplier per graph.", p.graph_points); + toggle_row(menu, "Values with no use found", "Also the values the game was never found reading. Changing those will probably do nothing.", + p.show_unused); + } else if (p.mode == 2) { // The list includes the game's tuning classes, which the trainer has to find in memory // before an edit to one does anything: with the list open, it looks now. trainer::note_class_list_shown(); @@ -338,10 +431,11 @@ void tune_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, if (ImGui::IsItemHovered()) ImGui::SetTooltip("Also list the tuning values that the game was never found or seen reading.\nChanging those will probably do nothing."); } else { - note("The same values the dials move, one at a time. The box on the left locks a value against presets, dials and Reset values."); + note(console_style() ? "The same values the dials move, one at a time. A * marks one you changed." + : "The same values the dials move, one at a time. The box on the left locks a value against presets, dials and Reset values."); } filter_rows(p, view); - if (p.mode == 2) { + if (p.mode == 2 && !console_style()) { ImGui::SameLine(); if (p.hidden_unused) ImGui::TextDisabled("%zu shown, %zu changed, %zu hidden", p.shown.size(), view.touched, p.hidden_unused); else ImGui::TextDisabled("%zu shown, %zu changed", p.shown.size(), view.touched); @@ -362,6 +456,17 @@ void trick_heights(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const const auto slider = [&](const char *label, const char *option, float value, float &edit, bool &editing, float high = 50.0f, bool limited = false) { field(menu, label); ImGui::PushID(option); + if (console_style()) { + float shown = editing ? edit : value; + if (slider_float("##height", &shown, 0.1f, high, "x %.2f", (limited ? ImGuiSliderFlags_AlwaysClamp : 0) | ImGuiSliderFlags_Logarithmic)) { + edit = shown; + editing = true; + } + if (ImGui::IsItemDeactivatedAfterEdit()) trainer_command(menu, callbacks, std::format("option {} {:.2f}", option, edit)); + if (!ImGui::IsItemActive() && editing && std::abs(value - edit) < 0.005f * std::max(1.0f, std::abs(edit))) editing = false; + ImGui::PopID(); + return; + } float shown = editing ? edit : value; const float box = px(64); ImGui::SetNextItemWidth(std::max(px(70), ImGui::GetContentRegionAvail().x - box - ImGui::GetStyle().ItemSpacing.x)); @@ -388,7 +493,7 @@ void trick_heights(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const slider("Boneless height", "boneless_height", view.boneless_height, p.boneless_edit, p.boneless_editing); slider("Hippy jump height", "hippy_height", view.hippy_height, p.hippy_edit, p.hippy_editing); slider("Off-board jump height", "offboard_height", view.offboard_height, p.offboard_edit, p.offboard_editing); - if (ImGui::Button("Reset tricks")) { + if (button_row(menu, "tricks-reset", {{"RESET TRICKS"}}) == 0) { p.hippy_editing = p.nocomply_editing = p.boneless_editing = p.offboard_editing = p.flip_editing = false; trainer_command(menu, callbacks, "reset tricks"); } @@ -429,13 +534,13 @@ void practice_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callba }; field(menu, "Speed"); auto value = static_cast(current); - if (ImGui::SliderFloat("##game-speed", &value, 0.05f, 2.0f, current == 0.0 ? "paused" : "%.2fx", ImGuiSliderFlags_AlwaysClamp)) + if (::dingosdk::overlay::menu::slider_float("##game-speed", &value, 0.05f, 2.0f, current == 0.0 ? "paused" : "%.2fx", ImGuiSliderFlags_AlwaysClamp)) set(value); - if (ImGui::Button(current == 0.0 ? "Resume" : "Pause")) set(current == 0.0 ? 1.0 : 0.0); - for (const auto quick : {0.1, 0.25, 0.5, 0.75, 1.0}) { - ImGui::SameLine(); - if (ImGui::Button(std::format("{}x", number(quick)).c_str())) set(quick); - } + const int pressed = button_row(menu, "speeds", {{current == 0.0 ? "RESUME" : "PAUSE", true, current == 0.0}, {"0.1x", true, current == 0.1}, + {"0.25x", true, current == 0.25}, {"0.5x", true, current == 0.5}, {"0.75x", true, current == 0.75}, {"1x", true, current == 1.0}}); + constexpr std::array quick{0.1, 0.25, 0.5, 0.75, 1.0}; + if (pressed == 0) set(current == 0.0 ? 1.0 : 0.0); + else if (pressed > 0) set(quick[static_cast(pressed - 1)]); note("The jump read-out measures real time, so it reads low while the game is slowed."); } } @@ -446,23 +551,20 @@ void practice_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callba if (choice(menu, "marker-slot", slot, {"1", "2", "3", "4", "5"})) trainer_command(menu, callbacks, std::format("slot {}", slot + 1)); const auto &marker = view.markers[static_cast(std::clamp(view.slot, 0, static_cast(trainer::marker_slots) - 1))]; info(menu, "Selected", marker.set ? std::format("{:.1f}, {:.1f}, {:.1f}", marker.position[0], marker.position[1], marker.position[2]) : "empty"); - ImGui::BeginDisabled(view.map.empty()); - ImGui::BeginDisabled(!telemetry.skater); - if (ImGui::Button("Save here")) trainer_command(menu, callbacks, "marker save"); - ImGui::EndDisabled(); - ImGui::SameLine(); - ImGui::BeginDisabled(!marker.set); - if (ImGui::Button("Go")) trainer_command(menu, callbacks, "marker go"); - ImGui::SameLine(); - if (ImGui::Button("Clear")) trainer_command(menu, callbacks, "marker clear"); - ImGui::EndDisabled(); - ImGui::EndDisabled(); + { + const bool map = !view.map.empty(); + const int pressed = button_row(menu, "marker-actions", {{"SAVE HERE", map && telemetry.skater, false, "Put the selected marker where you are."}, + {"GO", map && marker.set, false, "Go to the selected marker."}, {"CLEAR", map && marker.set, false, "Empty the selected marker."}}); + if (pressed == 0) trainer_command(menu, callbacks, "marker save"); + if (pressed == 1) trainer_command(menu, callbacks, "marker go"); + if (pressed == 2) trainer_command(menu, callbacks, "marker clear"); + } bool auto_return = view.auto_return; if (toggle_row(menu, "Return after a bail", "Go back to the selected marker when the skater wipes out.", auto_return)) trainer_command(menu, callbacks, std::format("option auto_return {}", auto_return ? 1 : 0)); field(menu, "Return delay"); float delay = view.return_delay; - ImGui::SliderFloat("##return-delay", &delay, 0.0f, 10.0f, "%.1f s", ImGuiSliderFlags_AlwaysClamp); + ::dingosdk::overlay::menu::slider_float("##return-delay", &delay, 0.0f, 10.0f, "%.1f s", ImGuiSliderFlags_AlwaysClamp); if (ImGui::IsItemDeactivatedAfterEdit()) trainer_command(menu, callbacks, std::format("option return_delay {:.1f}", delay)); bool pad = view.pad_shortcuts; if (toggle_row(menu, "Controller shortcuts", "Hold LB + RB, then D-pad: up saves, down goes, left / right pick the slot.", pad)) @@ -474,11 +576,20 @@ void practice_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callba trainer::note_teleport_card_shown(); info(menu, "Map waypoint", view.waypoint_set ? std::format("{:.1f}, {:.1f}, {:.1f}", view.waypoint[0], view.waypoint[1], view.waypoint[2]) : "none - place one on the pause map"); - ImGui::BeginDisabled(!view.waypoint_set || !telemetry.skater); - if (ImGui::Button("Go to map waypoint")) trainer_command(menu, callbacks, "waypoint"); - ImGui::EndDisabled(); + if (button_row(menu, "waypoint", {{"GO TO MAP WAYPOINT", view.waypoint_set && telemetry.skater, false, "The waypoint you placed on the pause map."}}) == 0) + trainer_command(menu, callbacks, "waypoint"); if (telemetry.skater) info(menu, "You are at", std::format("{:.2f}, {:.2f}, {:.2f}", telemetry.position[0], telemetry.position[1], telemetry.position[2])); + if (console_style()) { + // Typing a position wants a keyboard: full screen keeps one, set from where you are. + info(menu, "Saved spot", std::format("{:.2f}, {:.2f}, {:.2f}", p.teleport[0], p.teleport[1], p.teleport[2])); + const int pressed = button_row(menu, "teleport-actions", {{"SET IT TO HERE", telemetry.skater, false, "Keep where you are now."}, + {"GO THERE", telemetry.skater, false, "Teleport to the saved spot."}}); + if (pressed == 0) p.teleport = telemetry.position; + if (pressed == 1) trainer_command(menu, callbacks, std::format("tp {:.3f} {:.3f} {:.3f}", p.teleport[0], p.teleport[1], p.teleport[2])); + end_card(); + return; + } field(menu, "X, Y, Z"); ImGui::InputFloat3("##teleport", p.teleport.data(), "%.2f"); ImGui::BeginDisabled(!telemetry.skater); @@ -543,16 +654,18 @@ void map_tab(SkateMenu &menu, const CallbacksV3 &callbacks, const trainer::View if (!view.map_note.empty()) note(view.map_note.c_str()); if (!view.map_preset.empty()) { info(menu, "Author's preset", view.map_preset); - ImGui::BeginDisabled(!view.editable); - if (ImGui::Button("Apply the author's preset")) trainer_command(menu, callbacks, "preset apply map"); - ImGui::EndDisabled(); + if (button_row(menu, "author-preset", {{"APPLY THE AUTHOR'S PRESET", view.editable}}) == 0) trainer_command(menu, callbacks, "preset apply map"); } for (std::size_t i = 0; i < view.spots.size(); ++i) { ImGui::PushID(static_cast(i)); - if (ImGui::Button("Go")) trainer_command(menu, callbacks, std::format("spot {}", i + 1)); - ImGui::SameLine(); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(view.spots[i].name.c_str()); + if (console_style()) { + if (button_row(menu, "spot", {{("GO TO " + view.spots[i].name).c_str()}}) == 0) trainer_command(menu, callbacks, std::format("spot {}", i + 1)); + } else { + if (ImGui::Button("Go")) trainer_command(menu, callbacks, std::format("spot {}", i + 1)); + ImGui::SameLine(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(view.spots[i].name.c_str()); + } ImGui::PopID(); } if (view.map_note.empty() && view.map_preset.empty() && view.spots.empty()) @@ -581,6 +694,7 @@ bool trainer_page_wanted() { void trainer_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks) { auto &p = page(); const auto view = trainer::view(); + if (!console_style()) { ImGui::BeginDisabled(!callbacks.queue_console_command); if (ImGui::Button("RESET EVERYTHING")) { p.hippy_editing = p.nocomply_editing = p.boneless_editing = p.offboard_editing = p.flip_editing = false; @@ -590,9 +704,10 @@ void trainer_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callba ImGui::SameLine(); ImGui::AlignTextToFramePadding(); ImGui::TextDisabled("The game as it shipped: every value, lock, preset and trick slider."); - category_tabs(menu, p.tab, {"TUNE", "PRACTICE", "MAP & HUD"}, "trainer-tabs"); + } + category_tabs(menu, p.tab, {"TUNE", "PRACTICE", "MAP & HUD"}, "trainer-tabs"); // (the cheats are on Skater > Cheats) ImGui::PushID(p.tab); - ImGui::BeginChild("trainer-tab", ImVec2(0, page_body_height(menu))); + ImGui::BeginChild("trainer-tab", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); switch (p.tab) { case 0: tune_tab(menu, model, callbacks, p, *view); break; case 1: practice_tab(menu, model, callbacks, p, *view); break; diff --git a/Extension/UI/Overlay/atmosphere_menu.cpp b/Extension/UI/Overlay/atmosphere_menu.cpp index 20c00c0b..f3f20171 100644 --- a/Extension/UI/Overlay/atmosphere_menu.cpp +++ b/Extension/UI/Overlay/atmosphere_menu.cpp @@ -2,6 +2,7 @@ #include "atmosphere_color.h" #include #include +#include #include namespace dingosdk::overlay::menu { @@ -43,6 +44,77 @@ void control_row(SkateMenu& menu,const WorldControlsModel& model,const Callbacks state.waiting=true;state.pending=std::move(v);state.deadline=ImGui::GetTime()+2.0; } }; + if (console_style()) { + // Full screen: a row for the control (a lane of a vector or colour each), changed with + // left / right; saved once a run of steps ends, as a slider is once let go. + ImGui::PushID(c.key); + std::string hint=c.key; + if (reading.varies) hint+=". Values vary across this level's environments"; + if (saved && !reading.applied) hint+=". The saved value is waiting to apply"; + hint+="."; + bool immediate=false,changed=false,editing=false,deactivated=false; + if (c.type==AtmosphereType::texture) { + const auto& names=model.textures[i]; + int at=-1; + for (std::size_t j=0;j(j); + if (option_row(menu,c.label,names.empty() ? "No authored texture for this slot is loaded in this level." : hint.c_str(),at,names,!names.empty()) && at>=0) { + state.value.texture=names[static_cast(at)];changed=immediate=true; + } + } else if (c.type==AtmosphereType::toggle) { + bool enabled=state.value.number[0]!=0; + if (toggle_row(menu,c.label,hint.c_str(),enabled)) { state.value.number[0]=enabled ? 1 : 0;changed=immediate=true; } + } else if (c.type==AtmosphereType::enumeration) { + std::vector names; + int at=-1; + for (unsigned j=0;j(j); + } + if (option_row(menu,c.label,hint.c_str(),at,names) && at>=0) { + state.value.number[0]=atmosphere_options[c.option_start+static_cast(at)].value;changed=immediate=true; + } + } else { + ImGui::BeginGroup(); + if (c.type==AtmosphereType::color) { + auto color=atmosphere_color_to_ui(state.value); + static constexpr const char* channels[]{" red"," green"," blue"}; + for (int k=0;k<3;++k) { + field(menu,(std::string(c.label)+channels[k]).c_str(),hint.c_str()); + ImGui::PushID(k); + set_console_step(0.02); + if (slider_float("##lane",&color[static_cast(k)],0.0f,1.0f,"%.2f",ImGuiSliderFlags_AlwaysClamp)) { + state.value=atmosphere_color_from_ui(color,state.value);changed=true; + } + ImGui::PopID(); + } + } else { + static constexpr const char* axes[]{" X"," Y"," Z"," W"}; + for (unsigned lane=0;lane1 ? axes[std::min(lane,3u)] : "")).c_str(),hint.c_str()); + ImGui::PushID(static_cast(lane)); + double value=state.value.number[lane]; + const double range=c.maximum-c.minimum; + set_console_step(c.type==AtmosphereType::integer ? 1.0 : range>0 && range<=1e4 ? range/100 : std::max(std::abs(value)*0.05,0.01)); + if (slider_double("##lane",&value,c.minimum,c.maximum,c.type==AtmosphereType::integer ? "%.0f" : "%.4g",ImGuiSliderFlags_AlwaysClamp)) { + state.value.number[lane]=value;changed=true; + } + ImGui::PopID(); + } + } + ImGui::EndGroup(); + editing=ImGui::IsItemActive(); + deactivated=ImGui::IsItemDeactivatedAfterEdit(); + } + state.dirty |= changed; + state.editing=editing; + if (state.dirty && (immediate || deactivated || !state.editing)) { + submit(state.value); + state.dirty=false; + } + ImGui::PopID(); + return; + } ImGui::PushID(c.key); ImGui::TableNextRow();ImGui::TableNextColumn(); ImGui::AlignTextToFramePadding();ImGui::TextWrapped("%s",c.label); @@ -116,6 +188,47 @@ void control_row(SkateMenu& menu,const WorldControlsModel& model,const Callbacks } void atmosphere_menu(SkateMenu& menu,const Model& model,const CallbacksV3& callbacks) { const auto& controls=model.world_controls; + if (console_style()) { + // Full screen: one group at a time (fog, sky or wind), its controls as rows under their + // section names; no search box (that wants a keyboard). + static int kind=0; + static const std::vector groups{atmosphere_groups.begin(),atmosphere_groups.end()}; + const bool can=controls.environment_available && callbacks.queue_console_command; + begin_card(menu,"atmosphere","FOG, SKY AND WIND","Changes apply at once and save by themselves"); + option_row(menu,"Showing","Left / right: the fog, the sky or the wind.",kind,groups); + const auto group=static_cast(std::clamp(kind,0,2)); + std::vector rows; + std::vector sections; + for (unsigned i=0;i=0 || choices.fog_distance>=0 : group==1 ? choices.sky_brightness>=0 || choices.clouds>=0 + : choices.wind_strength>=0 || choices.wind_direction>=0) + warn("Console modifiers are active. Restore defaults clears these too."); + ImGui::BeginDisabled(!can); + for (const auto section_name:sections) { + if (section_name!=atmosphere_groups[group]) section(menu,std::string(section_name).c_str()); + if (group==1 && (section_name=="Physical atmosphere" || section_name=="Primary light" || section_name=="Secondary light")) + note("Used when Sky Type is Physical."); + for (const auto i:rows) if (dingosdk::atmosphere_controls[i].section==section_name) control_row(menu,controls,callbacks,i); + } + ImGui::EndDisabled(); + if (!controls.status.empty()) note(controls.status.c_str()); + end_card(); + return; + } ImGui::Spacing(); ImGui::SetNextItemWidth(-1); ImGui::InputTextWithHint("##atmosphere-search","Search fog, sky, and wind controls...",menu.atmosphere_search.data(),menu.atmosphere_search.size()); diff --git a/Extension/UI/Overlay/cursor.cpp b/Extension/UI/Overlay/cursor.cpp index a991e0f7..d480c64b 100644 --- a/Extension/UI/Overlay/cursor.cpp +++ b/Extension/UI/Overlay/cursor.cpp @@ -3,6 +3,9 @@ #include "input_capture.h" #include "Engine/Core/Log/logging.h" #include +#include +#include +#include #include #include @@ -230,7 +233,23 @@ void update_menu_pointer() { 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; + // The controller in charge of the full screen menu: no pointer until the mouse moves or a button + // of it is pressed. + // (The real pointer and buttons: these reads are the game's, faked, outside OverlayInputAccess.) + static POINT last{LONG_MIN, LONG_MIN}; + POINT now{}; + bool have_now{}, clicked{}; + { + OverlayInputAccess real; + have_now = GetCursorPos(&now) != 0; + clicked = ((GetAsyncKeyState(VK_LBUTTON) | GetAsyncKeyState(VK_RBUTTON) | GetAsyncKeyState(VK_MBUTTON)) & 0x8000) != 0; + } + const bool mouse_moved = have_now && last.x != LONG_MIN && (std::abs(now.x - last.x) > 2 || std::abs(now.y - last.y) > 2); + if (have_now) last = now; + auto& pad_in_charge = dingosdk::overlay::menu_pad_in_charge(); + if (mouse_moved || clicked || !dingosdk::overlay::menu_fullscreen_shown().load()) pad_in_charge.store(false); + const bool hidden = active && pad_in_charge.load(); + io.MouseDrawCursor = active && !hidden; if (!active && !watching) { io.ClearInputKeys(); io.ClearInputMouse(); s.raw_mouse_buttons.store(0); @@ -241,7 +260,8 @@ void update_menu_pointer() { POINT point{}; // The Win32 backend only polls when it thinks WM_MOUSEMOVE tracking ended. // Skate can stop legacy messages during gameplay without WM_MOUSELEAVE. - if (GetCursorPos(&point) && ScreenToClient(s.window.load(), &point)) + if (hidden) io.AddMousePosEvent(-FLT_MAX, -FLT_MAX); // (pointing at nothing: no tile lights up under it) + else if (GetCursorPos(&point) && ScreenToClient(s.window.load(), &point)) io.AddMousePosEvent(static_cast(point.x), static_cast(point.y)); const bool swapped = GetSystemMetrics(SM_SWAPBUTTON) != 0; const std::array keys{swapped ? VK_RBUTTON : VK_LBUTTON, swapped ? VK_LBUTTON : VK_RBUTTON, diff --git a/Extension/UI/Overlay/drone_page.cpp b/Extension/UI/Overlay/drone_page.cpp index 36311f63..9a4ce7e6 100644 --- a/Extension/UI/Overlay/drone_page.cpp +++ b/Extension/UI/Overlay/drone_page.cpp @@ -39,7 +39,7 @@ void slider(SkateMenu& menu, const CallbacksV3& callbacks, Knob k, const char* l field(menu, label, help.c_str()); float v = get(k); ImGui::PushID(static_cast(k)); - if (ImGui::SliderFloat("##value", &v, drone::knob_info[k].lo, drone::knob_info[k].hi, format)) set(k, v); + if (::dingosdk::overlay::menu::slider_float("##value", &v, drone::knob_info[k].lo, drone::knob_info[k].hi, format)) set(k, v); if (ImGui::IsItemDeactivatedAfterEdit()) save(callbacks, k); ImGui::PopID(); } @@ -48,7 +48,7 @@ void integer(SkateMenu& menu, const CallbacksV3& callbacks, Knob k, const char* field(menu, label, help.c_str()); int v = static_cast(std::lround(get(k))); ImGui::PushID(static_cast(k)); - if (ImGui::SliderInt("##value", &v, static_cast(drone::knob_info[k].lo), static_cast(drone::knob_info[k].hi))) + if (::dingosdk::overlay::menu::slider_int("##value", &v, static_cast(drone::knob_info[k].lo), static_cast(drone::knob_info[k].hi))) set(k, static_cast(v)); if (ImGui::IsItemDeactivatedAfterEdit()) save(callbacks, k); ImGui::PopID(); @@ -172,7 +172,7 @@ void fly_tab(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) (sent_at > 0.0 && ImGui::GetTime() - sent_at > 1.0))) { pending = -1.0f; sent_at = 0.0; } float top = pending >= 0.0f ? pending : model.debug.camera_speed; field(menu, "Top speed", "The same setting as freecam's flight speed.\n\nConsole: flyspeed"); - if (ImGui::SliderFloat("##top-speed", &top, 0.6f, 300.0f, "%.1f", ImGuiSliderFlags_Logarithmic)) pending = top; + if (::dingosdk::overlay::menu::slider_float("##top-speed", &top, 0.6f, 300.0f, "%.1f", ImGuiSliderFlags_Logarithmic)) pending = top; if (ImGui::IsItemDeactivatedAfterEdit()) { run(callbacks, std::format("flyspeed {}", top)); sent_at = ImGui::GetTime(); } // The flight model's drag is set so full throttle flat out settles near four times the fly speed (m/s). const auto speed = std::format("Flat out at about {:.0f} km/h (a little faster with boost).", top * 4.0f * 3.6f); @@ -260,7 +260,7 @@ void handling_tab(SkateMenu& menu, const CallbacksV3& callbacks) { if (limited) { field(menu, "Range"); float metres = get(Knob::kRange); - if (ImGui::SliderFloat("##range", &metres, 50.0f, 5000.0f, "%.0f m", ImGuiSliderFlags_Logarithmic)) set(Knob::kRange, metres); + if (::dingosdk::overlay::menu::slider_float("##range", &metres, 50.0f, 5000.0f, "%.0f m", ImGuiSliderFlags_Logarithmic)) set(Knob::kRange, metres); if (ImGui::IsItemDeactivatedAfterEdit()) save(callbacks, Knob::kRange); const auto explained = std::format( "Measured from where you took off. The picture starts to break up at {:.0f} m and is lost at {:.0f} m. Stay without a " @@ -552,16 +552,20 @@ void lobby_tab(SkateMenu& menu, const CallbacksV3& callbacks) { } // namespace void drone_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - category_tabs(menu, menu.drone_tab, {"FLY", "HANDLING", "RATES", "DISPLAY", "CONTROLLER", "LOBBY"}, "drone-tabs"); + category_tabs(menu, menu.drone_tab, {"FLY", "FLIGHT", "DISPLAY", "CONTROLLER"}, "drone-tabs"); ImGui::PushID(menu.drone_tab); - ImGui::BeginChild("drone-tab", ImVec2(0, page_body_height(menu))); + ImGui::BeginChild("drone-tab", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); switch (menu.drone_tab) { case 0: fly_tab(menu, model, callbacks); break; - case 1: handling_tab(menu, callbacks); break; - case 2: rates_tab(menu, callbacks); break; - case 3: display_tab(menu, callbacks); break; - case 4: controller_tab(menu, callbacks); break; - default: lobby_tab(menu, callbacks); break; + case 1: + handling_tab(menu, callbacks); + rates_tab(menu, callbacks); + break; + case 2: + display_tab(menu, callbacks); + lobby_tab(menu, callbacks); + break; + default: controller_tab(menu, callbacks); break; } ImGui::EndChild(); ImGui::PopID(); diff --git a/Extension/UI/Overlay/hub_page.cpp b/Extension/UI/Overlay/hub_page.cpp index 6aefeebb..1b83db28 100644 --- a/Extension/UI/Overlay/hub_page.cpp +++ b/Extension/UI/Overlay/hub_page.cpp @@ -46,6 +46,9 @@ 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 play_ui_sound(std::uint32_t sound) noexcept { + if (const auto play = ui_sound.load()) play(sound); +} void set_hub_callbacks(const CallbacksV3 &callbacks) noexcept { std::lock_guard lock(hub_callbacks_mutex); hub_callbacks = callbacks; @@ -157,6 +160,8 @@ struct Hub { ULONGLONG join_asked{}; std::string failed, failed_name; // why the last join did not get in, and where to; empty: nothing to say ULONGLONG notice_until{}; + // The frame the ReSkate menu last drew these pages itself (draw_online_embedded). + int embedded_frame{-10}; }; Hub &hub() { static Hub value; @@ -1454,22 +1459,14 @@ bool hub_page_pending() { return shown; } -void draw_hub_page() { - auto &h = hub(); - if (!h.shown) return; +// One frame of the pages, in `a`..`z` with their tabs at `tabs_y`: in the game's pause menu +// (draw_hub_page), or on the ReSkate menu's full screen ONLINE page (draw_online_embedded), which +// keeps B for itself. +void hub_frame(Hub &h, ImDrawList *draw, float u, ImVec2 a, ImVec2 z, float tabs_y, bool input, bool embedded) { 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, - // (A card that asks something over the page has the input while it asks.) - !others && !s.prompt_input_active.load(std::memory_order_relaxed) && game_window_foreground(s.window.load()), - false, false, false, 0, h.model, {}}; + Ui ui{h, draw, s.menu.bold ? s.menu.bold : ImGui::GetFont(), s.menu.body ? s.menu.body : ImGui::GetFont(), nullptr, u, io.MousePos, + input, 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); @@ -1537,16 +1534,16 @@ void draw_hub_page() { 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; + if (!embedded && (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 (fresh[static_cast(i)]) direction = i, h.repeat_at = tick + (embedded ? 420 : 380); // (the full screen menu's pace inside it) 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 (held[static_cast(i)]) direction = i, h.repeat_at = tick + (embedded ? 160 : 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); @@ -1567,9 +1564,7 @@ void draw_hub_page() { 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; + const bool tools = !embedded && h.page.section == 1; { // (Each page keeps the tab it was left on.) std::vector> tabs; @@ -1595,7 +1590,7 @@ void draw_hub_page() { h.sound = sound_tab; set_typing(h, 0); } - const float top = (tabs_top + lower) * u, bottom = top + tabs_height * u, size = 44 * u; + const float top = tabs_y, 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); @@ -1662,4 +1657,51 @@ void draw_hub_page() { if (const auto play = ui_sound.load(); play && (wanted != sound_navigate || tick - h.sound_at >= 45)) play(wanted), h.sound_at = tick; } } + +void draw_hub_page() { + auto &h = hub(); + if (!h.shown) return; + // (The ReSkate menu drew these pages over this one this frame: once is enough.) + if (h.embedded_frame == ImGui::GetFrameCount()) return; + auto &s = state(); + const auto display = ImGui::GetIO().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(); + 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); + // (A card that asks something over the page has the input while it asks.) + hub_frame(h, ImGui::GetBackgroundDrawList(), u, a, z, (tabs_top + lower) * u, + !others && !s.prompt_input_active.load(std::memory_order_relaxed) && game_window_foreground(s.window.load()), false); +} + +void draw_online_embedded(ImDrawList *draw, ImVec2 a, ImVec2 z, const Model &model, const CallbacksV3 &callbacks, bool input) { + auto &h = hub(); + const int frame = ImGui::GetFrameCount(); + const bool fresh = h.embedded_frame != frame - 1; + h.embedded_frame = frame; + const auto now = Clock::now(); + if (fresh) { + // What is held as the page comes up is not a press on it. + h.last.clear(); + h.focus = 0; + h.searched = false; + h.keys_before.fill(true); + h.pad_before = ~0U; + } + if (fresh || now >= h.next_model) { + h.next_model = now + 200ms; + h.full = model; + h.model = model.multiplayer; + h.callbacks = callbacks; + h.rows_stale = true; + } + const auto display = ImGui::GetIO().DisplaySize; + const float u = std::min(display.x / layout_width, display.y / layout_height); + hub_frame(h, draw, u, ImVec2(a.x, a.y + (tabs_height + 64) * u), z, a.y, input, true); +} +bool online_embedded_typing() { return hub().typing != 0; } } // namespace dingosdk::overlay::detail diff --git a/Extension/UI/Overlay/map_download_card.cpp b/Extension/UI/Overlay/map_download_card.cpp index 5a3b4a1e..040f4742 100644 --- a/Extension/UI/Overlay/map_download_card.cpp +++ b/Extension/UI/Overlay/map_download_card.cpp @@ -92,6 +92,7 @@ void set_map_download_icon(const std::string& png) noexcept { } // namespace dingosdk::overlay namespace dingosdk::overlay::detail { +bool decode_picture(const std::string& png, CardPixels& out, UINT side) { return decode(png, out, side); } bool take_card_pixels(CardPixels& out) { std::lock_guard lock(image_mutex); if (!waiting) return false; diff --git a/Extension/UI/Overlay/menu_map_pictures.cpp b/Extension/UI/Overlay/menu_map_pictures.cpp new file mode 100644 index 00000000..2984f586 --- /dev/null +++ b/Extension/UI/Overlay/menu_map_pictures.cpp @@ -0,0 +1,144 @@ +#include "overlay_internal.h" +#include "skate_menu_internal.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +// The pictures on the menu's map grid (Map > Travel). The grid asks with the levels it lists; +// Assets/map_icons.cpp answers off the game's thread (an official map's loading screen, a custom +// map's mod icon), and the pictures go into one texture, a square each, in the SRV heap's third +// slot (overlay_render.cpp, upload_menu_map_image). +namespace dingosdk::overlay { +namespace { +constexpr UINT cell = 256, columns = 8; +constexpr std::size_t most = 64; // 4 MB of pixels at most + +std::string folded(std::string_view text) { + std::string result(text); + for (auto& c : result) c = static_cast(std::tolower(static_cast(c))); + return result; +} + +struct Pictures { + std::mutex mutex; + std::string asked; // the levels last asked for, one asset a line + std::vector request; // not yet taken by map_icons + // The texture: the pixels kept to upload again after a device reset, and where each level's + // picture is in them (by folded asset). `ready` are those of the pixels on the GPU now. + detail::CardPixels current, waiting_pixels; + std::map> waiting_cells, ready_cells; + UINT width{}, height{}, ready_width{}, ready_height{}; + bool waiting{}; + unsigned serial{}; + ImTextureID texture{}; +}; +Pictures& pictures() { static auto* value = new Pictures; return *value; } // process lifetime +} + +void want_menu_map_pictures(std::vector levels) { + std::string key; + for (const auto& level : levels) key += folded(level.asset) + '\n'; + auto& p = pictures(); + std::lock_guard lock(p.mutex); + if (key == p.asked) return; + p.asked = std::move(key); + if (levels.size() > most) levels.resize(most); + p.request = std::move(levels); +} + +std::vector take_menu_map_request() noexcept { + auto& p = pictures(); + std::lock_guard lock(p.mutex); + return std::exchange(p.request, {}); +} + +void set_menu_map_pictures(const std::vector& found) noexcept { + try { + std::vector> squares; + for (const auto& icon : found) { + if (squares.size() >= most) break; + detail::CardPixels one; + bool ok{}; + if (icon.side == cell && icon.rgba.size() == std::size_t{cell} * cell * 4) { + one.rgba = icon.rgba; + one.width = one.height = cell; + ok = true; + } else if (!icon.png.empty() && icon.png.size() <= 4 * 1024 * 1024) { + ok = detail::decode_picture(icon.png, one, cell) && one.width == cell && one.height == cell; + } + if (ok) squares.emplace_back(folded(icon.name), std::move(one)); + } + auto& p = pictures(); + std::lock_guard lock(p.mutex); + if (squares.empty()) return; + const auto count = static_cast(squares.size()); + detail::CardPixels atlas; + atlas.width = cell * std::min(count, columns); + atlas.height = cell * ((count + columns - 1) / columns); + atlas.rgba.assign(std::size_t{atlas.width} * atlas.height * 4, 0); + std::map> cells; + for (UINT i = 0; i < count; ++i) { + const UINT x = (i % columns) * cell, y = (i / columns) * cell; + for (UINT row = 0; row < cell; ++row) + std::memcpy(atlas.rgba.data() + (std::size_t{y + row} * atlas.width + x) * 4, + squares[i].second.rgba.data() + std::size_t{row} * cell * 4, std::size_t{cell} * 4); + cells[squares[i].first] = i; + } + atlas.serial = ++p.serial; + p.width = atlas.width; + p.height = atlas.height; + p.current = atlas; + p.waiting_pixels = std::move(atlas); + p.waiting_cells = std::move(cells); + p.waiting = true; + } catch (...) { /* No pictures: the grid shows each map's name on a plain tile. */ } +} + +bool menu_map_picture(const std::string& asset, ImTextureID& texture, ImVec2& uv0, ImVec2& uv1) { + auto& p = pictures(); + std::lock_guard lock(p.mutex); + if (!p.texture || !p.ready_width || !p.ready_height) return false; + const auto found = p.ready_cells.find(folded(asset)); + if (found == p.ready_cells.end()) return false; + const float x = static_cast((found->second % columns) * cell), y = static_cast((found->second / columns) * cell); + texture = p.texture; + uv0 = ImVec2(x / static_cast(p.ready_width), y / static_cast(p.ready_height)); + uv1 = ImVec2((x + cell) / static_cast(p.ready_width), (y + cell) / static_cast(p.ready_height)); + return true; +} + +namespace detail { +bool take_menu_map_pixels(CardPixels& out) { + auto& p = pictures(); + std::lock_guard lock(p.mutex); + if (!p.waiting) return false; + p.waiting = false; + out = std::exchange(p.waiting_pixels, {}); + return true; +} +void menu_map_pixels_uploaded(ImTextureID texture, unsigned serial) { + auto& p = pictures(); + std::lock_guard lock(p.mutex); + if (serial != p.serial) return; + p.texture = texture; + p.ready_cells = p.waiting_cells; + p.ready_width = p.width; + p.ready_height = p.height; +} +void menu_map_pixels_lost() { + auto& p = pictures(); + std::lock_guard lock(p.mutex); + p.texture = {}; + if (!p.current.rgba.empty()) { + p.waiting_pixels = p.current; + p.waiting = true; + } +} +} +} diff --git a/Extension/UI/Overlay/modding_menu.cpp b/Extension/UI/Overlay/modding_menu.cpp index 016cb15d..a2b1bbf9 100644 --- a/Extension/UI/Overlay/modding_menu.cpp +++ b/Extension/UI/Overlay/modding_menu.cpp @@ -111,6 +111,28 @@ void board_break_deck_card(SkateMenu& menu, const Model& model, const CallbacksV const auto in_play = mods::board_break_in_play(); begin_card(menu, "board-break-decks", "BOARD BREAK DECK", in_play.empty() ? "none in the game" : label(in_play).c_str()); const bool busy = live_mods::busy(); + if (menu::console_style()) { + // Full screen: one row, left / right to a deck and A to put it in the game. + static int staged = -1; + static std::string staged_from; + std::vector names; + int playing = -1; + for (std::size_t i = 0; i < decks.size(); ++i) { + names.push_back(label(decks[i]) + (there(decks[i]) ? "" : " (not installed)")); + if (decks[i] == in_play) playing = static_cast(i); + } + if (staged < 0 || staged >= static_cast(decks.size()) || staged_from != in_play) staged = playing, staged_from = in_play; + bool load = false; + const bool waiting = staged >= 0 && decks[static_cast(staged)] != in_play; + menu::option_row(menu, waiting ? "Deck - A puts it in###deck" : "Deck###deck", + waiting ? "Press A to put this deck in the game (the level loads again)." : "Left / right to pick a deck, then A to put it in the game.", + staged, names, !busy && callbacks.queue_console_command, &load); + if (load && staged >= 0) { + const auto& deck = decks[static_cast(staged)]; + if (!there(deck)) menu::feedback(menu, "That deck is not installed."); + else if (deck != in_play) send_console(menu, callbacks, "boardbreak deck " + deck); + } + } else { const float gap = ImGui::GetStyle().ItemSpacing.x; const float width = (ImGui::GetContentRegionAvail().x - gap * 3) / 4; for (std::size_t i = 0; i < decks.size(); ++i) { @@ -123,6 +145,7 @@ void board_break_deck_card(SkateMenu& menu, const Model& model, const CallbacksV ImGui::EndDisabled(); ImGui::PopID(); } + } if (busy) note("Applying mods..."); else if (!in_play.empty() && chosen != in_play && there(chosen)) note((label(chosen) + " is chosen and shows once the level has loaded again.").c_str()); @@ -428,7 +451,7 @@ void draw_custom_scripts(SkateMenu& menu) { menu::end_card(); } } -void draw_modding_menu(SkateMenu& menu, int tab, const Model& model, const CallbacksV3& callbacks) { +void draw_modding_menu(SkateMenu& menu, int tab, const Model&, const CallbacksV3&) { // Loose files are switched in the launcher; custom scripts only need to know // whether they are on. if (!menu.loose_files_settings_loaded) { @@ -437,9 +460,6 @@ void draw_modding_menu(SkateMenu& menu, int tab, const Model& model, const Callb catch (const std::exception&) {} } if (tab == 1) draw_custom_scripts(menu); - else { - menu::board_break_deck_card(menu, model, callbacks); - draw_installed_mods(menu); - } + else draw_installed_mods(menu); } } diff --git a/Extension/UI/Overlay/multiplayer_lobbies.cpp b/Extension/UI/Overlay/multiplayer_lobbies.cpp index 2ee2c024..0e1fc0fb 100644 --- a/Extension/UI/Overlay/multiplayer_lobbies.cpp +++ b/Extension/UI/Overlay/multiplayer_lobbies.cpp @@ -319,7 +319,7 @@ void join_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks // The card's real height from the last frame; the estimate only covers the first. const float code_space = menu.multiplayer_code_height > 0 ? menu.multiplayer_code_height : direct_join_height() + px(8); const float height = std::max(px(120), ImGui::GetContentRegionAvail().y - code_space); - ImGui::BeginChild("lobby-list", ImVec2(0, height)); + ImGui::BeginChild("lobby-list", ImVec2(0, height), ImGuiChildFlags_NavFlattened); auto *draw = ImGui::GetWindowDrawList(); const float tile_height = px(66), pad = px(14), gap = px(6); const float join_width = px(110); diff --git a/Extension/UI/Overlay/multiplayer_menu.cpp b/Extension/UI/Overlay/multiplayer_menu.cpp index 28c72c06..3faff0c5 100644 --- a/Extension/UI/Overlay/multiplayer_menu.cpp +++ b/Extension/UI/Overlay/multiplayer_menu.cpp @@ -107,7 +107,7 @@ void host_page(SkateMenu &menu, const Model &model, const CallbacksV3 &) { ImGui::InputTextWithHint("##lobby-name", name_hint.c_str(), menu.multiplayer_lobby_name.data(), menu.multiplayer_lobby_name.size()); field(menu, "Player limit", "Includes you."); - ImGui::SliderInt("##player-limit", &capacity, 2, multiplayer_lobby_player_limit, "%d players"); + ::dingosdk::overlay::menu::slider_int("##player-limit", &capacity, 2, multiplayer_lobby_player_limit, "%d players"); menu.multiplayer_capacity = capacity; field(menu, "Update rate", "How often nearby skaters update. Higher is smoother but uses more upload bandwidth."); @@ -251,7 +251,7 @@ void multiplayer_display_settings(SkateMenu &menu, const Model &model) { field(menu, "Player distance", "How far away other players are still shown as skaters. Past it they keep their nametag " "or dot. Lower it on a busy server for more frames and less memory; all the way up shows everyone."); float shown = menu.player_distance_pending.value_or(mp.player_distance); - ImGui::SliderFloat("##player-distance", &shown, player_distance_least, player_distance_unlimited, + ::dingosdk::overlay::menu::slider_float("##player-distance", &shown, player_distance_least, player_distance_unlimited, shown >= player_distance_unlimited ? "Everyone" : "%.0f m", ImGuiSliderFlags_AlwaysClamp); if (ImGui::IsItemActive()) menu.player_distance_pending = shown; if (ImGui::IsItemDeactivatedAfterEdit()) { @@ -283,7 +283,7 @@ void multiplayer_display_settings(SkateMenu &menu, const Model &model) { std::array unused{}; field(menu, "Nametag distance", "How far away a player's name still shows. Past it they are a dot."); float distance = menu.nametag_distance_pending.value_or(mp.nametag_distance); - ImGui::SliderFloat("##nametag-distance", &distance, 10.f, 500.f, "%.0f m", ImGuiSliderFlags_AlwaysClamp); + ::dingosdk::overlay::menu::slider_float("##nametag-distance", &distance, 10.f, 500.f, "%.0f m", ImGuiSliderFlags_AlwaysClamp); if (ImGui::IsItemActive()) menu.nametag_distance_pending = distance; if (ImGui::IsItemDeactivatedAfterEdit()) { send_private(menu, "nametag-distance", std::to_string(static_cast(distance)), unused, false); @@ -338,7 +338,7 @@ void multiplayer_display_settings(SkateMenu &menu, const Model &model) { std::array unused{}; field(menu, "Bubble distance", "How far away a player can be and still show a chat bubble."); float distance = menu.chat_bubbles_distance_pending.value_or(mp.chat_bubbles_distance); - ImGui::SliderFloat("##bubble-distance", &distance, 5.f, 200.f, "%.0f m", ImGuiSliderFlags_AlwaysClamp); + ::dingosdk::overlay::menu::slider_float("##bubble-distance", &distance, 5.f, 200.f, "%.0f m", ImGuiSliderFlags_AlwaysClamp); if (ImGui::IsItemActive()) menu.chat_bubbles_distance_pending = distance; if (ImGui::IsItemDeactivatedAfterEdit()) { send_private(menu, "chat-bubbles-distance", std::to_string(static_cast(distance)), unused, false); @@ -350,7 +350,7 @@ void multiplayer_display_settings(SkateMenu &menu, const Model &model) { menu.chat_bubbles_distance_pending.reset(); field(menu, "Bubble duration", "How many seconds a chat bubble stays before it fades."); float duration = menu.chat_bubbles_duration_pending.value_or(mp.chat_bubbles_duration); - ImGui::SliderFloat("##bubble-duration", &duration, 1.f, 30.f, "%.0f s", ImGuiSliderFlags_AlwaysClamp); + ::dingosdk::overlay::menu::slider_float("##bubble-duration", &duration, 1.f, 30.f, "%.0f s", ImGuiSliderFlags_AlwaysClamp); if (ImGui::IsItemActive()) menu.chat_bubbles_duration_pending = duration; if (ImGui::IsItemDeactivatedAfterEdit()) { send_private(menu, "chat-bubbles-duration", std::to_string(static_cast(duration)), unused, false); @@ -362,7 +362,7 @@ void multiplayer_display_settings(SkateMenu &menu, const Model &model) { menu.chat_bubbles_duration_pending.reset(); field(menu, "Bubble history", "How many recent messages stack above each skater (1-8)."); int history = menu.chat_bubbles_history_pending.value_or(mp.chat_bubbles_history); - ImGui::SliderInt("##bubble-history", &history, 1, 8, "%d lines", ImGuiSliderFlags_AlwaysClamp); + ::dingosdk::overlay::menu::slider_int("##bubble-history", &history, 1, 8, "%d lines", ImGuiSliderFlags_AlwaysClamp); if (ImGui::IsItemActive()) menu.chat_bubbles_history_pending = history; if (ImGui::IsItemDeactivatedAfterEdit()) { send_private(menu, "chat-bubbles-history", std::to_string(history), unused, false); @@ -385,7 +385,7 @@ void multiplayer_network_page(SkateMenu &menu, const Model &model, const Callbac void special_page(SkateMenu &menu, const Model &model, const CallbacksV3 &) { const auto &mp = model.multiplayer; std::array unused{}; - ImGui::BeginChild("special", ImVec2(0, page_body_height(menu))); + ImGui::BeginChild("special", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); begin_card(menu, "special-tag", "YOUR TAG"); { // Drawn the way chat and nametags draw it (role_badge.h), and faint while it is off. @@ -517,11 +517,17 @@ void multiplayer_page(SkateMenu &menu, const Model &model, const CallbacksV3 &ca const bool has_session = mp.active || mp.lobby_joining; if (has_session && !menu.multiplayer_session_seen) menu.multiplayer_tab = multiplayer_session_tab; menu.multiplayer_session_seen = has_session; - category_tabs(menu, menu.multiplayer_tab, {"SERVERS", has_session ? "SESSION" : "HOST", "VOICE", "BANS"}, - "multiplayer-categories"); + // YOUR TAG only for a player the backend lists (it was the SPECIAL page). + if (mp.identity_tag.empty()) + category_tabs(menu, menu.multiplayer_tab, {"SERVERS", has_session ? "SESSION" : "HOST", "VOICE", "BANS", "PLAYERS AND CHAT"}, + "multiplayer-categories"); + else + category_tabs(menu, menu.multiplayer_tab, + {"SERVERS", has_session ? "SESSION" : "HOST", "VOICE", "BANS", "PLAYERS AND CHAT", "YOUR TAG"}, + "multiplayer-categories"); if (menu.multiplayer_tab != multiplayer_voice_tab) menu.voice_bind_capture = 0; ImGui::PushID(menu.multiplayer_tab); - ImGui::BeginChild("multiplayer-category", ImVec2(0, page_body_height(menu))); + ImGui::BeginChild("multiplayer-category", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); switch (menu.multiplayer_tab) { case 0: join_page(menu, model, callbacks); @@ -537,6 +543,12 @@ void multiplayer_page(SkateMenu &menu, const Model &model, const CallbacksV3 &ca case multiplayer_bans_tab: bans_page(menu, model); break; + case multiplayer_chat_tab: + menu::multiplayer_display_settings(menu, model); + break; + default: + menu::special_page(menu, model, callbacks); + break; } ImGui::EndChild(); ImGui::PopID(); diff --git a/Extension/UI/Overlay/multiplayer_session.cpp b/Extension/UI/Overlay/multiplayer_session.cpp index 2139796e..fed71f1d 100644 --- a/Extension/UI/Overlay/multiplayer_session.cpp +++ b/Extension/UI/Overlay/multiplayer_session.cpp @@ -168,7 +168,7 @@ void voice_controls(SkateMenu &menu, const MultiplayerModel &mp) { field(menu, "Voice range", "How far apart players can be and still hear each other. Past this the host " "stops sending their voice."); float range = menu.voice_range_pending.value_or(mp.voice_range); - ImGui::SliderFloat("##voice-range", &range, min_voice_range, max_voice_range, "%.0f m", + ::dingosdk::overlay::menu::slider_float("##voice-range", &range, min_voice_range, max_voice_range, "%.0f m", ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_Logarithmic); if (ImGui::IsItemActive()) menu.voice_range_pending = range; if (ImGui::IsItemDeactivatedAfterEdit()) { @@ -258,7 +258,7 @@ void voice_controls(SkateMenu &menu, const MultiplayerModel &mp) { if (ImGui::Button("Choose in Steam voice settings", ImVec2(-FLT_MIN, 0))) ShellExecuteW(nullptr, L"open", L"steam://settings/voice", nullptr, nullptr, SW_SHOWNORMAL); field(menu, "Your microphone", "How loud other players hear you."); - changed |= ImGui::SliderFloat("##microphone", &value.microphone, 0.f, max_voice_volume, "%.2fx", ImGuiSliderFlags_AlwaysClamp); + changed |= ::dingosdk::overlay::menu::slider_float("##microphone", &value.microphone, 0.f, max_voice_volume, "%.2fx", ImGuiSliderFlags_AlwaysClamp); if (mp.voice.transmitting) { tag(menu, "TRANSMITTING", skate_theme::good); ImGui::SameLine(); @@ -269,13 +269,13 @@ void voice_controls(SkateMenu &menu, const MultiplayerModel &mp) { begin_card(menu, "hearing", "HEARING"); field(menu, "Voices volume", "How loud you hear everyone. Adjust single players below."); - changed |= ImGui::SliderFloat("##listening", &value.volume, 0.f, max_voice_volume, "%.2fx", ImGuiSliderFlags_AlwaysClamp); + changed |= ::dingosdk::overlay::menu::slider_float("##listening", &value.volume, 0.f, max_voice_volume, "%.2fx", ImGuiSliderFlags_AlwaysClamp); changed |= toggle_row(menu, "Proximity voice", "Hear players quieter the further away they are. Off: everyone at full volume.", value.proximity); ImGui::BeginDisabled(!value.proximity); field(menu, "Hearing distance", "How far away you hear nearby players. Voices get quieter with distance and " "fade out towards this edge."); - changed |= ImGui::SliderFloat("##distance", &value.distance, min_hearing_distance, max_hearing_distance, "%.0f m", + changed |= ::dingosdk::overlay::menu::slider_float("##distance", &value.distance, min_hearing_distance, max_hearing_distance, "%.0f m", ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_Logarithmic); ImGui::EndDisabled(); end_card(); @@ -314,7 +314,7 @@ void voice_controls(SkateMenu &menu, const MultiplayerModel &mp) { ImGui::TableNextRow(); ImGui::TableNextColumn(); ImGui::AlignTextToFramePadding(); cell_text(peer.name); ImGui::TableNextColumn(); ImGui::SetNextItemWidth(-1); - if (ImGui::SliderFloat("##volume", &gain, 0.f, max_voice_volume, "%.2fx", ImGuiSliderFlags_AlwaysClamp) && + if (::dingosdk::overlay::menu::slider_float("##volume", &gain, 0.f, max_voice_volume, "%.2fx", ImGuiSliderFlags_AlwaysClamp) && send_private(menu, "voice-volume", std::to_string(peer.id) + " " + std::to_string(gain), unused, false)) menu.voice_volume_pending[peer.id] = {gain, ImGui::GetTime() + 2}; ImGui::TableNextColumn(); @@ -348,9 +348,9 @@ void ban_popup(SkateMenu &menu) { : menu.ban_confirm_name.c_str()); ImGui::PopFont(); const bool server = menu.ban_confirm_on_server; - note(server ? "They are removed now and can never join this server again. Unban them any time in Multiplayer > Bans." + note(server ? "They are removed now and can never join this server again. Unban them any time in Online > Bans." : "They are removed now and can never join a lobby you host, even with your join code. " - "Unban them any time in Multiplayer > Bans."); + "Unban them any time in Online > Bans."); ImGui::Dummy(ImVec2(0, px(4))); const float half = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * .5f; skate_theme::push_primary_button(); @@ -702,11 +702,11 @@ void session_page(SkateMenu &menu, const Model &model) { if (!mp.tuning_status.empty()) note(mp.tuning_status.c_str()); ImGui::Dummy(ImVec2(0, px(2))); bool force_layers = mp.force_world_layers; - if (toggle_row(menu, "Sync world layers", "Guests follow your World > Layers choices and cannot change their own.", + if (toggle_row(menu, "Sync world layers", "Guests follow your Map > Layers and crowds choices and cannot change their own.", force_layers, controls(mp, "layer-sync"), force_layers ? "ON" : "OFF")) send_private(menu, "world-layer-sync", force_layers ? "on" : "off", unused, false); if (mp.dedicated) - note("The server's layers are set with server commands: layer on|off (keys are listed in World > Layers)."); + note("The server's layers are set with server commands: layer on|off (keys are listed in Map > Layers and crowds)."); else note(force_layers ? "Guests follow the host's world layers; their layer controls are locked." : "Everyone picks their own world layers. Turning sync off restores each guest's own choices."); end_card(); diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 788e1c43..393574f6 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -16,6 +16,7 @@ #include "Engine/Core/Console/console_entry.h" #include +#include #include #include #include @@ -275,6 +276,9 @@ struct Model { ObjectPersistenceModel object_persistence; ParkEditorModel editor; float menu_scale = default_menu_scale; + float menu_scale_fullscreen = default_menu_scale; // the full screen menu's own size (`ui scalefull`) + // The ReSkate menu fills the screen, made for a controller, instead of a window (`ui fullscreen`). + bool menu_fullscreen = false; // Offline mode: no Steam, so every multiplayer page and control is hidden. bool steam_offline = false; ControllerBindingsModel bindings; @@ -348,6 +352,13 @@ struct MapVoteIcon { unsigned side{}; }; void set_map_vote_icons(const std::vector& icons) noexcept; +// The menu's map grid (Map > Travel): the levels it shows ask for their pictures, which +// Assets/map_icons.cpp finds off the game's thread (MapVoteIcon::name is then the level's asset). +struct MenuMapWanted { + std::string asset, name; +}; +std::vector take_menu_map_request() noexcept; +void set_menu_map_pictures(const std::vector& pictures) noexcept; // That vote's popup (map_download_card.cpp), for the runtime, which reads the controller and // holds the game's input while it is up: whether it is, moving the pick, voting for the pick // (the popup then closes), and closing it without a vote. Any thread. @@ -491,6 +502,9 @@ struct CallbacksV3 { // 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; +// One of the game's menu sounds, by its number (UI/NativeMenu/ui_sound.h): 3 moving the focus, +// 7 pressing, 56 changing tab, as the pause menu's own pages make them. +void play_ui_sound(std::uint32_t sound) 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). @@ -537,6 +551,28 @@ struct Gamepad { unsigned buttons = 0; }; Gamepad read_gamepad(); +// The full screen menu's page draws its own controls with their own focus (ONLINE: the pause +// menu's pages): the controller then drives that, not ImGui's navigation. +inline std::atomic& menu_page_owns_pad() { + static std::atomic value{false}; + return value; +} +// The controller is in charge of the full screen menu: the pointer is hidden (and points at +// nothing) until the mouse moves or clicks (overlay_render.cpp sets it, cursor.cpp clears it). +inline std::atomic& menu_pad_in_charge() { + static std::atomic value{false}; + return value; +} +// The menu was last drawn full screen (its controller pace, overlay_render.cpp). +inline std::atomic& menu_fullscreen_shown() { + static std::atomic value{false}; + return value; +} +// `menu` (and the controller bind for it) asks for the ReSkate menu to open; the overlay takes it. +inline std::atomic& menu_open_flag() { + static std::atomic value{false}; + return value; +} // Reads of a controller between these two calls see the real device even while ReSkate keeps // the game from reading it (freecam or drone controller capture). Same thread, paired. // The FPV drone reads its controller this way (Skater/drone_radio.h). diff --git a/Extension/UI/Overlay/overlay_internal.h b/Extension/UI/Overlay/overlay_internal.h index 0bede0a5..34acfb5e 100644 --- a/Extension/UI/Overlay/overlay_internal.h +++ b/Extension/UI/Overlay/overlay_internal.h @@ -179,6 +179,8 @@ struct State { // The map download card's icon (map_download_card.cpp), in the SRV heap's second slot, and // the buffer it was uploaded from, which lives until the next one replaces it. ComPtr card_texture, card_upload; + // The menu's map pictures (menu_map_pictures.cpp) in the third slot, likewise. + ComPtr menu_map_texture, menu_map_upload; ComPtr commands; ComPtr fence; HANDLE fence_event = nullptr; @@ -304,6 +306,12 @@ struct CardPixels { bool take_card_pixels(CardPixels& pixels); void card_image_uploaded(ImTextureID texture, unsigned serial); void card_image_lost(); +// Decodes a PNG to RGBA; with `side`, scaled to a square of that many pixels. +bool decode_picture(const std::string& png, CardPixels& out, UINT side); +// The menu's map pictures (menu_map_pictures.cpp), the same way, in the SRV heap's third slot. +bool take_menu_map_pixels(CardPixels& pixels); +void menu_map_pixels_uploaded(ImTextureID texture, unsigned serial); +void menu_map_pixels_lost(); // Game debug text (game_text_overlay.cpp): polled every presented frame. bool game_text_pending(); void draw_game_text(); diff --git a/Extension/UI/Overlay/overlay_render.cpp b/Extension/UI/Overlay/overlay_render.cpp index d1029aeb..9b6957c7 100644 --- a/Extension/UI/Overlay/overlay_render.cpp +++ b/Extension/UI/Overlay/overlay_render.cpp @@ -77,6 +77,8 @@ void destroy_graphics() { s.frames.clear(); s.card_texture.Reset(); s.card_upload.Reset(); card_image_lost(); + s.menu_map_texture.Reset(); s.menu_map_upload.Reset(); + menu_map_pixels_lost(); s.commands.Reset(); s.rtvs.Reset(); s.srvs.Reset(); s.fence.Reset(); s.device.Reset(); if (s.fence_event) CloseHandle(s.fence_event); s.fence_event = nullptr; @@ -284,7 +286,7 @@ bool setup_graphics() { return false; } heap.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV; - heap.NumDescriptors = 2; // ImGui's atlas, then the map download card's icon (upload_card_image) + heap.NumDescriptors = 3; // ImGui's atlas, the map download card's icon, the menu's map pictures (upload_image) heap.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE; graphics_result = s.device->CreateDescriptorHeap(&heap, IID_PPV_ARGS(&s.srvs)); if (FAILED(graphics_result)) { @@ -524,17 +526,76 @@ void draw_menu() { if (!visible) s.visible.store(false); } -// The map download card's icon (map_download_card.cpp), when one is waiting: made into a -// texture in the SRV heap's second slot. Recorded on the frame's command list ahead of ImGui's -// draws, so the copy is done before anything samples it. Nothing here is fatal: without the -// icon the card shows a plain tile. -void upload_card_image(State& s) { - CardPixels pixels; - if (!take_card_pixels(pixels) || pixels.rgba.empty() || !s.device || !s.srvs) return; - // Frames still on the GPU may sample the icon this one replaces. - if (s.card_texture && s.next_fence && !completed(s.next_fence, render_fence_timeout_ms)) return; - s.card_texture.Reset(); - s.card_upload.Reset(); +// The ReSkate menu on a controller: while it is open the pad drives ImGui's navigation (the game +// does not see the pad then), and nothing of it reaches the overlay otherwise. X is left out: ImGui +// holds it for its window switcher. +void feed_menu_gamepad(bool menu_open) { + auto& io = ImGui::GetIO(); + // Holding a direction in the full screen menu waits before it repeats, then moves at a pace + // the eye can follow (ImGui's own, made for a keyboard, races through a list). + const bool slow = menu_open && dingosdk::overlay::menu_fullscreen_shown().load(); + io.KeyRepeatDelay = slow ? 0.42f : 0.275f; + io.KeyRepeatRate = slow ? 0.16f : 0.05f; + // A page with its own focus (the full screen ONLINE page) takes the D-pad and the arrow keys itself. + if (menu_open && dingosdk::overlay::menu_page_owns_pad().load()) io.ConfigFlags &= ~ImGuiConfigFlags_NavEnableKeyboard; + else io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + struct Button { unsigned bit; ImGuiKey key; }; + static constexpr Button buttons[]{ + {0x0001, ImGuiKey_GamepadDpadUp}, {0x0002, ImGuiKey_GamepadDpadDown}, {0x0004, ImGuiKey_GamepadDpadLeft}, + {0x0008, ImGuiKey_GamepadDpadRight}, {0x0010, ImGuiKey_GamepadStart}, {0x0020, ImGuiKey_GamepadBack}, + {0x0040, ImGuiKey_GamepadL3}, {0x0080, ImGuiKey_GamepadR3}, {0x0100, ImGuiKey_GamepadL1}, {0x0200, ImGuiKey_GamepadR1}, + {0x1000, ImGuiKey_GamepadFaceDown}, {0x2000, ImGuiKey_GamepadFaceRight}, {0x8000, ImGuiKey_GamepadFaceUp}}; + static bool fed = false; + const auto pad = menu_open ? dingosdk::overlay::read_gamepad() : dingosdk::overlay::Gamepad{}; + { + // A button pressed, a trigger pulled or a stick pushed in the full screen menu hands it to + // the controller: the pointer hides until the mouse is used again. + static unsigned before{}; + const bool used = pad.valid && ((pad.buttons & ~before) != 0 || pad.left_trigger > 0.5f || pad.right_trigger > 0.5f || + std::abs(pad.left_x) > 0.5f || std::abs(pad.left_y) > 0.5f || std::abs(pad.right_x) > 0.5f || + std::abs(pad.right_y) > 0.5f); + before = pad.valid ? pad.buttons : 0; + if (used && slow) dingosdk::overlay::menu_pad_in_charge().store(true); + if (!menu_open) dingosdk::overlay::menu_pad_in_charge().store(false); + } + if (!pad.valid && !fed) return; + for (const auto& button : buttons) io.AddKeyEvent(button.key, (pad.buttons & button.bit) != 0); + io.AddKeyAnalogEvent(ImGuiKey_GamepadL2, pad.left_trigger > 0.5f, pad.left_trigger); + io.AddKeyAnalogEvent(ImGuiKey_GamepadR2, pad.right_trigger > 0.5f, pad.right_trigger); + // The left stick moves like the D-pad, past a dead zone. + constexpr float dead = 0.35f; + const auto stick = [&](ImGuiKey key, float value) { + const float past = std::clamp((value - dead) / (1.0f - dead), 0.0f, 1.0f); + io.AddKeyAnalogEvent(key, value > dead, past); + }; + stick(ImGuiKey_GamepadLStickLeft, -pad.left_x); + stick(ImGuiKey_GamepadLStickRight, pad.left_x); + stick(ImGuiKey_GamepadLStickUp, pad.left_y); + stick(ImGuiKey_GamepadLStickDown, -pad.left_y); + // The right stick scrolls the full screen menu's page. + stick(ImGuiKey_GamepadRStickUp, pad.right_y); + stick(ImGuiKey_GamepadRStickDown, -pad.right_y); + fed = pad.valid; + if (fed && !(menu_open && dingosdk::overlay::menu_page_owns_pad().load())) { + io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; + io.BackendFlags |= ImGuiBackendFlags_HasGamepad; + } else if (fed) { + io.ConfigFlags &= ~ImGuiConfigFlags_NavEnableGamepad; + io.BackendFlags |= ImGuiBackendFlags_HasGamepad; + } else { + io.ConfigFlags &= ~ImGuiConfigFlags_NavEnableGamepad; + io.BackendFlags &= ~ImGuiBackendFlags_HasGamepad; + } +} + +// Pixels made into a texture in the SRV heap's slot `slot`: the map download card's icon (1) or +// the menu's map pictures (2). Recorded on the frame's command list ahead of ImGui's draws, so +// the copy is done before anything samples it. Returns the texture's id for ImGui, or 0. Nothing +// here is fatal: without it a card shows a plain tile. +ImTextureID upload_image(State& s, const CardPixels& pixels, UINT slot, ComPtr& texture_out, + ComPtr& upload_out) { + texture_out.Reset(); + upload_out.Reset(); D3D12_RESOURCE_DESC texture{}; texture.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D; texture.Width = pixels.width; @@ -565,10 +626,10 @@ void upload_card_image(State& s) { IID_PPV_ARGS(&made))) || FAILED(s.device->CreateCommittedResource(&upload_heap, D3D12_HEAP_FLAG_NONE, &buffer, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&upload)))) - return; + return {}; void* mapped{}; const D3D12_RANGE none{}; - if (FAILED(upload->Map(0, &none, &mapped)) || !mapped) return; + if (FAILED(upload->Map(0, &none, &mapped)) || !mapped) return {}; for (UINT row = 0; row < pixels.height; ++row) std::memcpy(static_cast(mapped) + layout.Offset + std::size_t{row} * layout.Footprint.RowPitch, pixels.rgba.data() + std::size_t{row} * pixels.width * 4, std::size_t{pixels.width} * 4); @@ -590,18 +651,35 @@ void upload_card_image(State& s) { const auto step = s.device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV); auto cpu = s.srvs->GetCPUDescriptorHandleForHeapStart(); auto gpu = s.srvs->GetGPUDescriptorHandleForHeapStart(); - cpu.ptr += step; - gpu.ptr += step; + cpu.ptr += std::size_t{step} * slot; + gpu.ptr += std::uint64_t{step} * slot; D3D12_SHADER_RESOURCE_VIEW_DESC view{}; view.Format = texture.Format; view.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D; view.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING; view.Texture2D.MipLevels = 1; s.device->CreateShaderResourceView(made.Get(), &view, cpu); - s.card_texture = std::move(made); - s.card_upload = std::move(upload); + texture_out = std::move(made); + upload_out = std::move(upload); // Drawable from the next frame on: this frame's draws were made before the copy was recorded. - card_image_uploaded(static_cast(gpu.ptr), pixels.serial); + return static_cast(gpu.ptr); +} + +void upload_card_image(State& s) { + CardPixels pixels; + if (!take_card_pixels(pixels) || pixels.rgba.empty() || !s.device || !s.srvs) return; + // Frames still on the GPU may sample the icon this one replaces. + if (s.card_texture && s.next_fence && !completed(s.next_fence, render_fence_timeout_ms)) return; + if (const auto id = upload_image(s, pixels, 1, s.card_texture, s.card_upload)) card_image_uploaded(id, pixels.serial); +} + +// The menu's map pictures (menu_map_pictures.cpp), when a new set is waiting. +void upload_menu_map_image(State& s) { + if (!s.device || !s.srvs) return; + if (s.menu_map_texture && s.next_fence && !completed(s.next_fence, render_fence_timeout_ms)) return; + CardPixels pixels; + if (!take_menu_map_pixels(pixels) || pixels.rgba.empty()) return; + if (const auto id = upload_image(s, pixels, 2, s.menu_map_texture, s.menu_map_upload)) menu_map_pixels_uploaded(id, pixels.serial); } void render(IDXGISwapChain* presented, UINT flags) { @@ -656,6 +734,7 @@ void render(IDXGISwapChain* presented, UINT flags) { const bool hub_frame = hub_page_pending(); if (trainer_open_requested()) s.visible.store(true); if (dingosdk::drone::menu_flag().load()) s.visible.store(true); // `dronemenu`: the menu opens on its DRONE page + if (dingosdk::overlay::menu_open_flag().exchange(false)) s.visible.store(true); // `menu`, or its controller bind const bool menu_frame = interactive_visible(s); if (!menu_frame) { if (s.ui_was_interactive) { @@ -710,6 +789,7 @@ void render(IDXGISwapChain* presented, UINT flags) { // (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(); } + feed_menu_gamepad(interactive && s.visible.load() && !s.editor_visible.load()); ImGui::GetIO().MouseDrawCursor = owns_menu_cursor(s); ImGui_ImplDX12_NewFrame(); { OverlayInputAccess access; ImGui_ImplWin32_NewFrame(); } @@ -752,6 +832,7 @@ void render(IDXGISwapChain* presented, UINT flags) { ImGui::SetCurrentContext(previous); s.failed = true; restore_input(true); return; } upload_card_image(s); + upload_menu_map_image(s); D3D12_RESOURCE_BARRIER barrier{}; barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; barrier.Transition.pResource = frame.buffer.Get(); diff --git a/Extension/UI/Overlay/progression_menu.cpp b/Extension/UI/Overlay/progression_menu.cpp index 78cf06e2..80bb998b 100644 --- a/Extension/UI/Overlay/progression_menu.cpp +++ b/Extension/UI/Overlay/progression_menu.cpp @@ -76,7 +76,7 @@ void bus_stops(SkateMenu& menu, const ProgressionModel& model, const CallbacksV3 for (const auto& row : model.bus_stops) if (!menu.bus_stop_map || static_cast(row.world) + 1 == menu.bus_stop_map) rows.push_back(&row); note((std::to_string(rows.size()) + " bus stops, saved locally. Reload the level after edits to refresh their icons.").c_str()); - ImGui::BeginChild("bus-stop-list", ImVec2(0, std::max(px(120), ImGui::GetContentRegionAvail().y))); + ImGui::BeginChild("bus-stop-list", ImVec2(0, std::max(px(120), ImGui::GetContentRegionAvail().y)), ImGuiChildFlags_NavFlattened); ImGui::BeginDisabled(!callbacks.queue_console_command); ImGuiListClipper clipper; clipper.Begin(static_cast(rows.size()), list_row_height()); @@ -123,7 +123,7 @@ void challenge_progress(SkateMenu& menu, const ProgressionModel& model, const Ca note((std::to_string(rows.size()) + " challenges, " + std::to_string(done) + " fully complete, " + (!model.challenges_enabled ? "local challenges disabled" : model.challenges_hidden ? "hidden" : "saved locally") + ". Hover one to see its completed goals.").c_str()); - ImGui::BeginChild("challenge-list", ImVec2(0, std::max(px(120), ImGui::GetContentRegionAvail().y))); + ImGui::BeginChild("challenge-list", ImVec2(0, std::max(px(120), ImGui::GetContentRegionAvail().y)), ImGuiChildFlags_NavFlattened); ImGuiListClipper clipper; clipper.Begin(static_cast(rows.size()), list_row_height()); while (clipper.Step()) for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) { @@ -245,7 +245,7 @@ void missions(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) ? (std::to_string(rows.size()) + " missions, saved locally. Reload the level after changing an active mission.").c_str() : model.mission_feedback.c_str()); if (!model.missions_available) warn("The local profile is unavailable."); - ImGui::BeginChild("mission-list", ImVec2(0, std::max(px(120), ImGui::GetContentRegionAvail().y))); + ImGui::BeginChild("mission-list", ImVec2(0, std::max(px(120), ImGui::GetContentRegionAvail().y)), ImGuiChildFlags_NavFlattened); ImGui::BeginDisabled(!model.missions_available || !callbacks.queue_console_command); ImGuiListClipper clipper; clipper.Begin(static_cast(rows.size()), list_row_height()); @@ -278,7 +278,7 @@ void progression_page(SkateMenu& menu, const Model& model, const CallbacksV3& ca category_tabs(menu, menu.progression_tab, {"MISSIONS", "CHALLENGES", "BUS STOPS", "PROFILE"}, "progression-categories"); const bool feedback_shown = !menu.feedback.empty() && ImGui::GetTime() < menu.feedback_until; ImGui::PushID(menu.progression_tab); - ImGui::BeginChild("progression-category", ImVec2(0, page_body_height(menu))); + ImGui::BeginChild("progression-category", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); if (menu.progression_tab == 0) missions(menu, model, callbacks); else if (!model.progression.available) warn("The local profile is unavailable."); else { diff --git a/Extension/UI/Overlay/skate_menu.cpp b/Extension/UI/Overlay/skate_menu.cpp index 00e25494..6e519bed 100644 --- a/Extension/UI/Overlay/skate_menu.cpp +++ b/Extension/UI/Overlay/skate_menu.cpp @@ -7,9 +7,14 @@ #include "Extension/UI/skate_theme.h" #include +#include #include +#include +#include #include +#include #include +#include namespace dingosdk::overlay { using namespace menu; @@ -61,8 +66,192 @@ void debug_request(SkateMenu& menu, const CallbacksV3& callbacks, DebugRequest r feedback(menu, result[0] ? result.data() : (queued ? "" : "Change unavailable.")); } +// ---------------------------------------------------------------- full screen controls +// Full screen is made for a controller, so the shared controls draw differently there: tall +// rows, on/off as a switch, a few options as "< option >" and a number as "< value >" with a bar, +// both changed with left / right (which then never moves the focus). What the focused control +// does shows in the bottom bar (focus_hint): a controller has no hover tooltips. +namespace { +bool console_mode = false; +bool tab_switched = false; // a page or tab changed this frame (the full screen menu's tab sound) +bool console_playstation = false; // the pad in use names LT / RT as L2 / R2 +std::string focus_hint, pending_hint; +ImFont* console_font = nullptr; // bold, for the controls drawn by hand +double next_step = 0; // set_console_step: the next stepper's step (0: a fortieth of its range) +// Left / right this frame. A list of options moves one per press (`repeat` off); a number, held, +// runs on after half a second, as the pause menu's sliders do. (ImGui's own repeat starts so soon +// that an unhurried press counted as two or three.) +bool sideways_pressed(std::initializer_list keys, bool repeat) { + for (const auto key : keys) + if (repeat ? ImGui::GetKeyPressedAmount(key, 0.5f, 0.07f) > 0 : ImGui::IsKeyPressed(key, false)) return true; + return false; +} +bool left_pressed(bool repeat = false) { + return sideways_pressed({ImGuiKey_GamepadDpadLeft, ImGuiKey_GamepadLStickLeft, ImGuiKey_LeftArrow}, repeat); +} +bool right_pressed(bool repeat = false) { + return sideways_pressed({ImGuiKey_GamepadDpadRight, ImGuiKey_GamepadLStickRight, ImGuiKey_RightArrow}, repeat); +} +bool sideways_held() { + return ImGui::IsKeyDown(ImGuiKey_GamepadDpadLeft) || ImGui::IsKeyDown(ImGuiKey_GamepadDpadRight) || + ImGui::IsKeyDown(ImGuiKey_GamepadLStickLeft) || ImGui::IsKeyDown(ImGuiKey_GamepadLStickRight) || + ImGui::IsKeyDown(ImGuiKey_LeftArrow) || ImGui::IsKeyDown(ImGuiKey_RightArrow); +} +float held_for() { + float longest = 0; + for (const auto key : {ImGuiKey_GamepadDpadLeft, ImGuiKey_GamepadDpadRight, ImGuiKey_GamepadLStickLeft, ImGuiKey_GamepadLStickRight, + ImGuiKey_LeftArrow, ImGuiKey_RightArrow}) + longest = std::max(longest, ImGui::GetKeyData(key)->DownDuration); + return longest; +} +// The row a full screen control sits on: its fill, and a white edge while focused. +void console_frame(ImDrawList* draw, ImVec2 at, ImVec2 end, bool focused, bool hovered, bool disabled) { + // As the pause menu's tiles: rough-cut, blue under the focus or the pointer. + skate_theme::rough_rect(draw, at, end, disabled ? IM_COL32(26, 26, 26, 160) : focused || hovered ? blue : IM_COL32(26, 26, 26, 235), + static_cast(ImGui::GetItemID()) & 0xfff, ui_scale); +} +void arrows(ImDrawList* draw, ImVec2 at, ImVec2 end, bool lit) { + const float h = end.y - at.y, s = px(7), mid = at.y + h * .5f; + const ImU32 colour = lit ? paper : muted; + draw->AddTriangleFilled(ImVec2(at.x + px(16), mid), ImVec2(at.x + px(16) + s, mid - s), ImVec2(at.x + px(16) + s, mid + s), colour); + draw->AddTriangleFilled(ImVec2(end.x - px(16), mid), ImVec2(end.x - px(16) - s, mid - s), ImVec2(end.x - px(16) - s, mid + s), colour); +} +// The stepper behind a full screen slider. A step reports the control active and edited, and +// letting go of left / right deactivates it, so callers that save on IsItemDeactivatedAfterEdit +// (as they do when a slider is let go) save once a run of steps ends. +template +bool stepper(const char* id, Value* value, Value low, Value high, const char* format, ImGuiSliderFlags flags) { + ImGuiContext& g = *GImGui; + const double unit = std::exchange(next_step, 0.0); + const float width = ImGui::CalcItemWidth(), height = px(46); + ImGui::PushID(id); + const auto at = ImGui::GetCursorScreenPos(); + const bool clicked = ImGui::InvisibleButton("##stepper", ImVec2(width, height), ImGuiButtonFlags_EnableNav); + const ImGuiID item = ImGui::GetItemID(); + const bool focused = ImGui::IsItemFocused(), hovered = ImGui::IsItemHovered(); + const bool disabled = (g.CurrentItemFlags & ImGuiItemFlags_Disabled) != 0; + // Most settings go to the game and come back a few frames later: held, each step would start + // again from the value the game has not changed yet. So the stepped value is kept, and shown, + // while left / right is held and for a second after; then the game's own value shows again. + static ImGuiID kept{}; + static Value kept_value{}; + static double kept_until{}; + if (kept == item && g.Time < kept_until) *value = kept_value; + else if (kept == item) kept = 0; + int direction = 0; + if (clicked && !disabled && hovered && g.IO.MouseReleased[0]) direction = g.IO.MousePos.x < at.x + width * .5f ? -1 : 1; + const bool holding = focused && !disabled && sideways_held(); + if (focused && !disabled) { + if (left_pressed(true)) direction = -1; + if (right_pressed(true)) direction = 1; + if (holding || direction) ImGui::NavMoveRequestCancel(); + if (!pending_hint.empty()) focus_hint = pending_hint; + } + bool changed = false; + if (direction) { + const bool logarithmic = (flags & ImGuiSliderFlags_Logarithmic) && low > 0 && high > low; + const double speed = held_for() > 1.2f ? 4.0 : 1.0; + double next = static_cast(*value); + if (unit > 0) next += direction * speed * unit; + else if (logarithmic) next *= std::pow(static_cast(high) / static_cast(low), direction * speed / 40.0); + else if constexpr (std::is_integral_v) next += direction * speed * std::max(1.0, std::round((static_cast(high) - low) / 40.0)); + else next += direction * speed * (static_cast(high) - low) / 40.0; + next = std::clamp(next, static_cast(low), static_cast(high)); + Value stepped; + if constexpr (std::is_integral_v) stepped = static_cast(std::lround(next)); + else stepped = static_cast(next); + if (stepped != *value) { + *value = stepped; + changed = true; + } + kept = item, kept_value = *value, kept_until = g.Time + 1.0; + } + if (kept == item && sideways_held() && focused) kept_until = g.Time + 1.0; + static ImGuiID stepping{}; + static bool stepped_edit{}; + if (direction || (holding && stepping == item)) { + if (stepping != item) stepping = item, stepped_edit = false; + ImGui::SetActiveID(item, g.CurrentWindow); + stepped_edit |= changed; + if (stepped_edit) ImGui::MarkItemEdited(item); + } else if (stepping == item && !holding) { + stepping = 0; + } + auto* draw = ImGui::GetWindowDrawList(); + const ImVec2 end(at.x + width, at.y + height); + console_frame(draw, at, end, focused, hovered, disabled); + arrows(draw, at, end, focused); + char text[96]{}; + ImFormatString(text, sizeof(text), format && *format ? format : (std::is_integral_v ? "%d" : "%.2f"), *value); + ImFont* font = console_font ? console_font : ImGui::GetFont(); + const auto size = font->CalcTextSizeA(px(17), FLT_MAX, 0, text); + draw->AddText(font, px(17), ImVec2(at.x + (width - size.x) * .5f, at.y + (height - size.y) * .5f - px(4)), disabled ? muted : paper, text); + // Where the value sits in its range, in steps. + double fraction = 0; + if (high > low) { + if ((flags & ImGuiSliderFlags_Logarithmic) && low > 0) + fraction = std::log(static_cast(*value) / low) / std::log(static_cast(high) / low); + else fraction = (static_cast(*value) - low) / (static_cast(high) - low); + } + constexpr int ticks = 20; + const float bar_left = at.x + px(48), bar_width = width - px(96), tick = bar_width / ticks; + for (int i = 0; i < ticks && (unit <= 0 || (static_cast(high) - low) / unit < 2000); ++i) { + const bool lit = (i + 0.5) / ticks <= std::clamp(fraction, 0.0, 1.0); + draw->AddRectFilled(ImVec2(bar_left + tick * i + px(1), end.y - px(10)), ImVec2(bar_left + tick * (i + 1) - px(1), end.y - px(6)), + lit ? (focused || hovered ? paper : blue) : IM_COL32(255, 255, 255, 40)); + } + ImGui::PopID(); + return changed; +} +} +bool console_style() { return console_mode; } +int console_step(bool focused) { + if (!focused) return 0; + const int direction = left_pressed() ? -1 : right_pressed() ? 1 : 0; + if (direction || sideways_held()) ImGui::NavMoveRequestCancel(); + return direction; +} +void set_console_hint(const char* hint) { focus_hint = hint ? hint : ""; } +void set_console_step(double step) { next_step = step; } +const std::string& console_hint() { return focus_hint; } +void begin_console_frame(bool full, ImFont* bold, bool playstation) { + console_mode = full; + console_playstation = playstation; + console_font = bold; + focus_hint.clear(); + pending_hint.clear(); + next_step = 0; +} + bool toggle_row(SkateMenu& menu, const char* label, const char* hint, bool& value, bool available, const char* unavailable) { + if (console_mode) { + // Full screen: a tall row, the switch at its right end. + ImGui::PushID(label); + ImGui::BeginDisabled(!available); + const auto at = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x, height = px(54); + const bool clicked = ImGui::Selectable("##toggle", false, ImGuiSelectableFlags_None, ImVec2(width, height)); + const bool hovered = ImGui::IsItemHovered(), focused = ImGui::IsItemFocused(); + if (clicked) value = !value; + if (focused || hovered) focus_hint = hint; + auto* draw = ImGui::GetWindowDrawList(); + const ImVec2 end(at.x + width, at.y + height); + console_frame(draw, at, end, focused, hovered, !available); + const auto text = menu.bold->CalcTextSizeA(px(17), FLT_MAX, 0, label); + draw->PushClipRect(at, ImVec2(end.x - px(110), end.y), true); + draw->AddText(menu.bold, px(17), ImVec2(at.x + px(18), at.y + (height - text.y) * .5f), available ? paper : muted, label); + draw->PopClipRect(); + // What it is now on the right, as the pause menu's settings show it: blue, white under the focus. + const char* state = available ? (value ? "ON" : "OFF") : unavailable; + const auto size = menu.bold->CalcTextSizeA(px(17), FLT_MAX, 0, state); + const ImU32 state_ink = !available ? muted : focused || hovered ? paper : value ? blue : muted; + draw->AddText(menu.bold, px(17), ImVec2(end.x - px(24) - size.x, at.y + (height - size.y) * .5f), state_ink, state); + if (hovered) ImGui::SetTooltip("%s", hint); + ImGui::EndDisabled(); + ImGui::PopID(); + return clicked; + } ImGui::PushID(label); ImGui::BeginDisabled(!available); const auto at = ImGui::GetCursorScreenPos(); @@ -99,22 +288,44 @@ bool toggle_row(SkateMenu& menu, const char* label, const char* hint, bool& valu namespace { // The label column of field() and info(), and the width below which they stack. -float label_column() { return px(180); } +float label_column() { return px(console_mode ? 300.0f : 180.0f); } bool narrow() { return ImGui::GetContentRegionAvail().x < px(440); } } void begin_card(SkateMenu& menu, const char* id, const char* title, const char* subtitle) { - ImGui::PushStyleColor(ImGuiCol_ChildBg, skate_theme::tile); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(px(16), px(14))); + if (console_mode) { + // Full screen a card is a group, not a child window: ImGui leaves out a child window that is + // all below the screen, controls and all, so the D-pad could not move down into it (and + // scroll it into view). A group's controls can always be reached. + ImGui::PushID(id); + ImGui::BeginGroup(); + ImGui::Dummy(ImVec2(0, px(2))); + if (title) { + ImGui::PushFont(menu.heading); + ImGui::TextUnformatted(title); + ImGui::PopFont(); + if (subtitle) { + ImGui::SameLine(0, px(10)); + ImGui::AlignTextToFramePadding(); + ImGui::PushStyleColor(ImGuiCol_Text, muted); + ImGui::TextUnformatted(subtitle); + ImGui::PopStyleColor(); + } + ImGui::Dummy(ImVec2(0, px(4))); + } + return; + } + ImGui::PushStyleColor(ImGuiCol_ChildBg, console_mode ? IM_COL32(0, 0, 0, 0) : skate_theme::tile); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, console_mode ? ImVec2(0, px(10)) : ImVec2(px(16), px(14))); ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 0); // A card never scrolls itself; the wheel keeps scrolling the page around it. - ImGui::BeginChild(id, ImVec2(0, 0), ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_AlwaysUseWindowPadding, + ImGui::BeginChild(id, ImVec2(0, 0), ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_AlwaysUseWindowPadding | ImGuiChildFlags_NavFlattened, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); ImGui::PopStyleVar(2); ImGui::PopStyleColor(); const auto at = ImGui::GetWindowPos(); - ImGui::GetWindowDrawList()->AddRectFilled(at, ImVec2(at.x + px(4), at.y + ImGui::GetWindowHeight()), blue); + if (!console_mode) ImGui::GetWindowDrawList()->AddRectFilled(at, ImVec2(at.x + px(4), at.y + ImGui::GetWindowHeight()), blue); if (title) { - ImGui::PushFont(menu.bold); + ImGui::PushFont(console_mode ? menu.heading : menu.bold); ImGui::TextUnformatted(title); ImGui::PopFont(); if (subtitle) { @@ -127,10 +338,18 @@ void begin_card(SkateMenu& menu, const char* id, const char* title, const char* } } void end_card() { + if (console_mode) { + ImGui::Dummy(ImVec2(0, px(10))); + ImGui::EndGroup(); + ImGui::PopID(); + ImGui::Dummy(ImVec2(0, px(8))); + return; + } ImGui::EndChild(); ImGui::Dummy(ImVec2(0, px(8))); } void field(SkateMenu&, const char* label, const char* tooltip) { + pending_hint = tooltip ? tooltip : ""; const float start = ImGui::GetCursorPosX(); const bool stacked = narrow(); ImGui::AlignTextToFramePadding(); @@ -165,6 +384,47 @@ void warn(const char* text) { } bool choice(SkateMenu& menu, const char* id, int& selected, std::initializer_list options, bool enabled, float total, std::initializer_list tooltips) { + if (console_mode) { + // Full screen: one row, "< option >", left / right (or A) to change it. + ImGui::PushID(id); + ImGui::BeginDisabled(!enabled); + const int count = static_cast(options.size()); + const float width = total > 0 ? total : ImGui::GetContentRegionAvail().x, height = px(46); + const auto at = ImGui::GetCursorScreenPos(); + const bool clicked = ImGui::InvisibleButton("##cycle", ImVec2(width, height), ImGuiButtonFlags_EnableNav); + const bool hovered = ImGui::IsItemHovered(), focused = ImGui::IsItemFocused(); + int step = 0; + if (clicked && enabled) step = hovered && ImGui::GetIO().MouseReleased[0] && ImGui::GetIO().MousePos.x < at.x + width * .5f ? -1 : 1; + if (focused && enabled) { + if (left_pressed()) step = -1; + if (right_pressed()) step = 1; + if (step || sideways_held()) ImGui::NavMoveRequestCancel(); + } + bool changed = false; + if (step && count > 0) { + selected = ((selected < 0 ? (step > 0 ? -1 : 0) : selected) + step + count) % count; + changed = true; + } + const char* tooltip = selected >= 0 && selected < static_cast(tooltips.size()) ? tooltips.begin()[selected] : nullptr; + if (focused || hovered) focus_hint = tooltip ? tooltip : pending_hint; + auto* draw = ImGui::GetWindowDrawList(); + const ImVec2 end(at.x + width, at.y + height); + console_frame(draw, at, end, focused, hovered, !enabled); + arrows(draw, at, end, focused); + const char* label = selected >= 0 && selected < count ? options.begin()[selected] : "Choose"; + const auto size = menu.bold->CalcTextSizeA(px(17), FLT_MAX, 0, label); + draw->AddText(menu.bold, px(17), ImVec2(at.x + (width - size.x) * .5f, at.y + (height - size.y) * .5f - px(4)), + enabled ? paper : muted, label); + // Which of the options this is. + const float dot = px(6), spacing = px(5), dots = count * dot + (count - 1) * spacing; + for (int i = 0; i < count; ++i) + draw->AddRectFilled(ImVec2(at.x + (width - dots) * .5f + i * (dot + spacing), end.y - px(10)), + ImVec2(at.x + (width - dots) * .5f + i * (dot + spacing) + dot, end.y - px(6)), + i == selected ? (focused || hovered ? paper : blue) : IM_COL32(255, 255, 255, 50)); + ImGui::EndDisabled(); + ImGui::PopID(); + return changed; + } ImGui::PushID(id); const auto count = static_cast(options.size()); const float gap = px(4); @@ -177,7 +437,7 @@ bool choice(SkateMenu& menu, const char* id, int& selected, std::initializer_lis const auto at = ImGui::GetCursorScreenPos(); ImGui::PushID(i); ImGui::BeginDisabled(!enabled); - if (ImGui::InvisibleButton("##choice", ImVec2(width, height)) && selected != i) { + if (ImGui::InvisibleButton("##choice", ImVec2(width, height), ImGuiButtonFlags_EnableNav) && selected != i) { selected = i; changed = true; } @@ -216,6 +476,7 @@ void tag(SkateMenu& menu, const char* text, ImU32 colour) { draw->AddText(menu.bold, px(12), ImVec2(at.x + pad.x, top + pad.y), skate_theme::black, text); } bool primary_button(SkateMenu& menu, const char* label, bool enabled) { + if (console_mode) return button_row(menu, label, {{label, enabled, true}}) == 0; ImGui::BeginDisabled(!enabled); skate_theme::push_primary_button(); ImGui::PushFont(menu.bold); @@ -226,9 +487,280 @@ bool primary_button(SkateMenu& menu, const char* label, bool enabled) { return clicked; } +namespace { +// The number a slider's format shows ("x %.2f", "%.0f%%", "%d degrees"), as the box beside it +// types it; a format with none ("Everyone") falls back to a plain number. +std::string box_format(const char* format, bool integer) { + for (const char* at = format; at && *at; ++at) { + if (*at != '%') continue; + if (at[1] == '%') { ++at; continue; } + const char* end = at + 1; + while (*end && !std::strchr("dfgeiu", *end)) ++end; + if (!*end) break; + return std::string(at, end + 1); + } + return integer ? "%d" : "%.2f"; +} +// A slider with a number box beside it for an exact value. Drawn as one group, so a caller's +// IsItemActive / IsItemDeactivatedAfterEdit cover both: a typed value counts once the box is left. +template +bool slider_with_box(const char* id, Value* value, Value low, Value high, ImGuiSliderFlags flags, const char* format, const char* box, + Slide slide, Type type) { + if (console_mode) { + ImGui::BeginGroup(); + const bool changed = stepper(id, value, low, high, format, flags); + ImGui::EndGroup(); + return changed; + } + next_step = 0; + const float total = ImGui::CalcItemWidth(); + const bool boxed = !(flags & ImGuiSliderFlags_NoInput); + const float box_width = px(78), gap = ImGui::GetStyle().ItemInnerSpacing.x; + ImGui::BeginGroup(); + ImGui::PushID(id); + ImGui::SetNextItemWidth(boxed ? std::max(px(60), total - box_width - gap) : total); + bool changed = slide(id, value); + if (boxed) { + ImGui::SameLine(0, gap); + ImGui::SetNextItemWidth(box_width); + Value typed = *value; + type("##exact", &typed, box); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Type an exact value"); + if (ImGui::IsItemDeactivatedAfterEdit()) { + *value = (flags & ImGuiSliderFlags_AlwaysClamp) ? std::clamp(typed, low, high) : typed; + changed = true; + } + } + ImGui::PopID(); + ImGui::EndGroup(); + return changed; +} +} +bool slider_float(const char* id, float* value, float low, float high, const char* format, ImGuiSliderFlags flags) { + return slider_with_box(id, value, low, high, flags, format, box_format(format, false).c_str(), + [&](const char* label, float* v) { return ImGui::SliderFloat(label, v, low, high, format, flags); }, + [](const char* label, float* v, const char* box) { return ImGui::InputFloat(label, v, 0, 0, box); }); +} +bool slider_int(const char* id, int* value, int low, int high, const char* format, ImGuiSliderFlags flags) { + return slider_with_box(id, value, low, high, flags, format, box_format(format, true).c_str(), + [&](const char* label, int* v) { return ImGui::SliderInt(label, v, low, high, format, flags); }, + [](const char* label, int* v, const char*) { return ImGui::InputInt(label, v, 0, 0); }); +} +bool slider_double(const char* id, double* value, double low, double high, const char* format, ImGuiSliderFlags flags) { + return slider_with_box(id, value, low, high, flags, format, box_format(format, false).c_str(), + [&](const char* label, double* v) { return ImGui::SliderScalar(label, ImGuiDataType_Double, v, &low, &high, format, flags); }, + [](const char* label, double* v, const char* box) { return ImGui::InputDouble(label, v, 0, 0, box); }); +} + +// ---------------------------------------------------------------- rows for a controller +int button_row(SkateMenu& menu, const char* id, std::initializer_list buttons) { + int pressed = -1; + ImGui::PushID(id); + if (!console_mode) { + // A window keeps ImGui's buttons, side by side at their own width. + int i = 0; + for (const auto& button : buttons) { + if (i) ImGui::SameLine(); + ImGui::PushID(i); + ImGui::BeginDisabled(!button.enabled); + if (button.on) ImGui::PushStyleColor(ImGuiCol_Button, blue); + if (ImGui::Button(button.label)) pressed = i; + if (button.on) ImGui::PopStyleColor(); + ImGui::EndDisabled(); + if (button.hint && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", button.hint); + ImGui::PopID(); + ++i; + } + ImGui::PopID(); + return pressed; + } + // Full screen: one row of equal tiles as the pause menu's buttons, blue under the focus, white + // for the one that is on. + const int count = static_cast(buttons.size()); + const float gap = px(10), height = px(50); + const float width = (ImGui::GetContentRegionAvail().x - gap * static_cast(count - 1)) / static_cast(count); + auto* draw = ImGui::GetWindowDrawList(); + int i = 0; + for (const auto& button : buttons) { + if (i) ImGui::SameLine(0, gap); + ImGui::PushID(i); + const auto at = ImGui::GetCursorScreenPos(); + ImGui::BeginDisabled(!button.enabled); + if (ImGui::InvisibleButton("##button", ImVec2(width, height), ImGuiButtonFlags_EnableNav)) pressed = i; + ImGui::EndDisabled(); + const bool focused = ImGui::IsItemFocused() && ImGui::GetIO().NavVisible, hovered = button.enabled && ImGui::IsItemHovered(); + if ((focused || hovered) && button.hint) focus_hint = button.hint; + const ImVec2 end(at.x + width, at.y + height); + const bool lit = button.enabled && (focused || hovered); + const ImU32 fill = lit ? blue : button.on ? paper : button.enabled ? IM_COL32(26, 26, 26, 235) : IM_COL32(26, 26, 26, 140); + skate_theme::rough_rect(draw, at, end, fill, static_cast(ImGui::GetItemID()) & 0xfff, ui_scale); + const char* shown_end = ImGui::FindRenderedTextEnd(button.label); + const auto size = menu.bold->CalcTextSizeA(px(16), FLT_MAX, 0, button.label, shown_end); + draw->PushClipRect(at, end, true); + draw->AddText(menu.bold, px(16), ImVec2(at.x + std::max(px(10), (width - size.x) * .5f), at.y + (height - size.y) * .5f), + !button.enabled ? muted : button.on && !lit ? skate_theme::black : paper, button.label, shown_end); + draw->PopClipRect(); + ImGui::PopID(); + ++i; + } + ImGui::PopID(); + return pressed; +} + +bool option_row(SkateMenu& menu, const char* label, const char* hint, int& selected, const std::vector& options, bool enabled, + bool* pressed) { + const int count = static_cast(options.size()); + // "Name###id": the name is shown, the id names the control (so a name can change under the focus). + const char* id = std::strstr(label, "###"); + const std::string name(label, ImGui::FindRenderedTextEnd(label)); + ImGui::PushID(id ? id : label); + bool changed = false; + if (!console_mode) { + field(menu, name.c_str(), hint); + ImGui::BeginDisabled(!enabled); + if (ImGui::BeginCombo("##option", selected >= 0 && selected < count ? options[static_cast(selected)].c_str() : "Choose")) { + for (int i = 0; i < count; ++i) + if (ImGui::Selectable(options[static_cast(i)].c_str(), i == selected) && i != selected) selected = i, changed = true; + ImGui::EndCombo(); + } + ImGui::EndDisabled(); + ImGui::PopID(); + return changed; + } + // Full screen: the name on the left, "< option >" on the right; left / right change it, one + // per press. A steps on too, or, for a row that applies on A (`pressed`), applies it. + ImGui::BeginDisabled(!enabled); + const auto at = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x, height = px(54); + const bool clicked = ImGui::InvisibleButton("##option", ImVec2(width, height), ImGuiButtonFlags_EnableNav); + ImGui::EndDisabled(); + const bool focused = ImGui::IsItemFocused(), hovered = enabled && ImGui::IsItemHovered(); + const float split = at.x + width * .48f; + int step = 0; + const bool mouse = clicked && ImGui::GetIO().MouseReleased[0] && hovered; + if (enabled && clicked) { + if (mouse && ImGui::GetIO().MousePos.x >= split) step = ImGui::GetIO().MousePos.x < split + (at.x + width - split) * .5f ? -1 : 1; + else if (pressed) *pressed = true; + else step = 1; + } + if (focused && enabled) { + if (left_pressed()) step = -1; + if (right_pressed()) step = 1; + if (step || sideways_held()) ImGui::NavMoveRequestCancel(); + } + if (step && count > 0) { + selected = ((selected < 0 ? (step > 0 ? -1 : 0) : selected) + step + count) % count; + changed = true; + } + if ((focused && ImGui::GetIO().NavVisible) || hovered) focus_hint = hint ? hint : ""; + auto* draw = ImGui::GetWindowDrawList(); + const ImVec2 end(at.x + width, at.y + height); + const bool lit = (focused && ImGui::GetIO().NavVisible) || hovered; + console_frame(draw, at, end, lit, false, !enabled); + const auto text = menu.bold->CalcTextSizeA(px(17), FLT_MAX, 0, name.c_str()); + draw->PushClipRect(at, ImVec2(split - px(10), end.y), true); + draw->AddText(menu.bold, px(17), ImVec2(at.x + px(18), at.y + (height - text.y) * .5f), enabled ? paper : muted, name.c_str()); + draw->PopClipRect(); + arrows(draw, ImVec2(split, at.y), end, lit); + const char* value = selected >= 0 && selected < count ? options[static_cast(selected)].c_str() : "Choose"; + const float room = end.x - split - px(80); + const auto size = menu.bold->CalcTextSizeA(px(17), FLT_MAX, 0, value); + draw->PushClipRect(ImVec2(split + px(36), at.y), ImVec2(end.x - px(36), end.y), true); + draw->AddText(menu.bold, px(17), ImVec2(split + px(40) + std::max(0.0f, (room - size.x) * .5f), at.y + (height - size.y) * .5f - (count <= 12 ? px(4) : 0)), + !enabled ? muted : lit ? paper : blue, value); + draw->PopClipRect(); + if (count > 1 && count <= 12) { + const float dot = px(6), spacing = px(5), dots = count * dot + (count - 1) * spacing, left = split + (end.x - split - dots) * .5f; + for (int i = 0; i < count; ++i) + draw->AddRectFilled(ImVec2(left + i * (dot + spacing), end.y - px(10)), ImVec2(left + i * (dot + spacing) + dot, end.y - px(6)), + i == selected ? (lit ? paper : blue) : IM_COL32(255, 255, 255, 50)); + } + ImGui::PopID(); + return changed; +} + +bool list_row(SkateMenu& menu, const char* id, const std::string& text, const std::string& side, bool current, bool enabled) { + ImGui::PushID(id); + const auto at = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x, height = px(48); + ImGui::BeginDisabled(!enabled); + const bool clicked = ImGui::InvisibleButton("##row", ImVec2(width, height), ImGuiButtonFlags_EnableNav); + ImGui::EndDisabled(); + const bool lit = enabled && ((ImGui::IsItemFocused() && ImGui::GetIO().NavVisible) || ImGui::IsItemHovered()); + auto* draw = ImGui::GetWindowDrawList(); + const ImVec2 end(at.x + width, at.y + height); + skate_theme::rough_rect(draw, at, end, lit ? blue : current ? paper : IM_COL32(26, 26, 26, 200), static_cast(ImGui::GetItemID()) & 0xfff, + ui_scale); + const ImU32 text_ink = lit ? paper : current ? skate_theme::black : enabled ? paper : muted; + const auto side_size = menu.body->CalcTextSizeA(px(15), FLT_MAX, 0, side.c_str()); + draw->PushClipRect(at, ImVec2(end.x - side_size.x - px(36), end.y), true); + draw->AddText(menu.bold, px(17), ImVec2(at.x + px(18), at.y + (height - px(17)) * .5f), text_ink, text.c_str()); + draw->PopClipRect(); + draw->AddText(menu.body, px(15), ImVec2(end.x - side_size.x - px(18), at.y + (height - side_size.y) * .5f), + lit ? paper : current ? IM_COL32(40, 40, 40, 255) : muted, side.c_str()); + ImGui::PopID(); + return clicked && enabled; +} + void category_tabs(SkateMenu& menu, int& selected, std::initializer_list tabs, const char* id) { // A row of tiles like skate.'s tab bar: the selected tab is blue with black text. selected = std::clamp(selected, 0, static_cast(tabs.size()) - 1); + // LT / RT on a controller step the page's first row of tabs (the one drawn first each frame), + // not one nested in it. + static int stepped_frame = -1; + const bool primary = stepped_frame != ImGui::GetFrameCount(); + if (stepped_frame != ImGui::GetFrameCount()) { + stepped_frame = ImGui::GetFrameCount(); + const int count = static_cast(tabs.size()); + if (!ImGui::IsAnyItemActive() && !ImGui::GetIO().WantTextInput && !menu.recording_bind && !menu.voice_bind_capture) { + const int was = selected; + if (ImGui::IsKeyPressed(ImGuiKey_GamepadL2, false)) selected = (selected + count - 1) % count; + if (ImGui::IsKeyPressed(ImGuiKey_GamepadR2, false)) selected = (selected + 1) % count; + if (selected != was) menu.feedback.clear(), tab_switched = true; + } + } + // Full screen, the page's first row of tabs looks as the pause menu's do: rough tiles, the open one + // white with the game's blue line under it, LT and RT at the ends (they step through them). + if (primary && menu.fullscreen_drawn) { + ImGui::PushID(id); + auto* draw = ImGui::GetWindowDrawList(); + // In the pause menu's own units (hub_page.cpp), so these are the ONLINE page's tabs exactly. + const auto display = ImGui::GetIO().DisplaySize; + const float u = std::min(display.x / 3840.0f, display.y / 2160.0f); + const float height = 84 * u, size = 44 * u; + const auto start = ImGui::GetCursorScreenPos(); + float x = start.x; + const auto cap = [&](const char* name) { + x += keycap(draw, menu.bold, u * 2.2f, ImVec2(x, start.y + 20 * u), name, "") + 4 * u; + }; + cap(console_playstation ? "L2" : "LT"); + for (int i = 0; i < static_cast(tabs.size()); ++i) { + const char* label = tabs.begin()[i]; + const float width = menu.bold->CalcTextSizeA(size, FLT_MAX, 0, label).x + 56 * u; + ImGui::SetCursorScreenPos(ImVec2(x, start.y)); + ImGui::PushID(i); + if (ImGui::InvisibleButton("##section", ImVec2(width, height), ImGuiButtonFlags_EnableNav) && selected != i) { + selected = i; + menu.feedback.clear(); + tab_switched = true; + } + const bool lit = ImGui::IsItemHovered() || (ImGui::IsItemFocused() && ImGui::GetIO().NavVisible); + const unsigned seed = static_cast(ImGui::GetItemID()); + ImGui::PopID(); + const bool on = selected == i; + const ImVec2 p(x, start.y), q(x + width, start.y + height); + skate_theme::rough_rect(draw, p, q, lit && !on ? blue : on ? paper : IM_COL32(26, 26, 26, 200), seed & 0xfff, u * 2); + draw->AddText(menu.bold, size, ImVec2(p.x + 28 * u, p.y + (height - size) * .5f), on ? skate_theme::black : paper, label); + if (on) draw->AddRectFilled(ImVec2(p.x + 6 * u, q.y + 4 * u), ImVec2(q.x - 6 * u, q.y + 14 * u), blue, 5 * u); + x = q.x + 20 * u; + } + cap(console_playstation ? "R2" : "RT"); + ImGui::PopID(); + // The page under them where the ONLINE page's starts. + ImGui::SetCursorScreenPos(ImVec2(start.x, start.y + (84 + 64) * u)); + ImGui::Dummy(ImVec2(0, 0)); + return; + } ImGui::PushID(id); const float gap = px(6); const float tab_width = (ImGui::GetContentRegionAvail().x - gap * (tabs.size() - 1)) / tabs.size(); @@ -238,7 +770,7 @@ void category_tabs(SkateMenu& menu, int& selected, std::initializer_list page_names{ - "MAP", "WORLD", "BUILD", "SKATER", "TRAINER", "DRONE", "MULTIPLAYER", "PROGRESS", "MODS", "MUSIC", "SETTINGS", "SPECIAL", "DEVELOPER"}; + "HOME", "MAP", "BUILD", "SKATER", "SANDBOX", "ADDONS", "ONLINE", "PROGRESS", "MODS", "SETTINGS", "DEVELOPER"}; constexpr std::array page_subtitles{ - "Pick your spot.", "Set the vibe.", "Make the park yours.", "Ride it your way.", "Tune it. Drill it. Measure it.", "Take it to the sky.", - "Bring your crew.", "Pick up where you want.", "Bring your own.", "Your sound, in the game.", "Your controls, your screen.", "Not everyone gets this page.", - "Under the hood."}; + "Everything, a button away.", "Pick your spot. Set the vibe.", "Make the park yours.", "Ride it your way.", + "Tune it. Drill it.", "What ReSkate adds to skate.", "Bring your crew.", "Pick up where you want.", + "Bring your own.", "Your controls, your screen.", "Under the hood."}; +constexpr std::array addon_names{"ADDONS", "DRONE", "BOARD BREAK", "ROAD RASH", "HALL OF MEAT", "GRIND EXITS", "MUSIC"}; +constexpr std::array addon_subtitles{ + "What ReSkate adds to skate.", "Take it to the sky.", "Slams snap your board.", "Bails leave their mark.", "Every bone counts.", "Pick the side you pop out.", + "Your sound, in the game."}; +// The side rail's groups: the heading above the page that starts each. +const char* group_of(int page) { + switch (page) { + case page_map: return "PLAY"; + case page_skater: return "RIDE"; + case page_addons: return "EXTRAS"; + case page_online: return "CREW"; + case page_mods: return "SYSTEM"; + default: return nullptr; + } +} } void load_skate_fonts(SkateMenu& menu) { @@ -295,9 +841,9 @@ void load_skate_fonts(SkateMenu& menu) { static const ImWchar ranges[]{0x0020, 0x024F, 0x0400, 0x052F, 0x2000, 0x206F, 0x20A0, 0x20CF, 0x2190, 0x21FF, 0}; std::array windows{}; const auto length = GetWindowsDirectoryA(windows.data(), static_cast(windows.size())); - const auto font = [&](const wchar_t* resource, const char* filename, float size) -> ImFont* { + const auto font = [&](const wchar_t* resource, const char* filename, float size, const ImWchar* glyphs = ranges) -> ImFont* { if (resource) - if (auto* loaded = embedded_font(resource, size, ranges)) return loaded; + if (auto* loaded = embedded_font(resource, size, glyphs)) return loaded; if (length && length < windows.size()) { const auto path = std::string(windows.data()) + "\\Fonts\\" + filename; if (GetFileAttributesA(path.c_str()) != INVALID_FILE_ATTRIBUTES) @@ -314,41 +860,192 @@ void load_skate_fonts(SkateMenu& menu) { menu.heading = font(L"FONT_HEADING", "arialbd.ttf", 22); menu.title = font(L"FONT_BRUSH", "arialbi.ttf", 44); menu.mono = font(nullptr, "consola.ttf", 15); + // Full screen's larger set. The brushed titles are page and section names: plain Latin is enough. + static const ImWchar latin[]{0x0020, 0x00FF, 0}; + menu.body_large = font(L"FONT_BODY", "arial.ttf", 17 * large_font_ratio); + menu.bold_large = font(L"FONT_HEADING", "arialbd.ttf", 17 * large_font_ratio); + menu.heading_large = font(L"FONT_HEADING", "arialbd.ttf", 22 * large_font_ratio); + menu.title_large = font(L"FONT_BRUSH", "arialbi.ttf", 44 * large_font_ratio, latin); } -void draw_skate_menu(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks, - bool& visible) { - const bool on_controls = menu.page == settings && menu.settings_tab == 0; - const bool on_voice = menu.page == multiplayer && menu.multiplayer_tab == multiplayer_voice_tab; - if (!visible || !on_controls || menu.last_menu_frame + 1 != ImGui::GetFrameCount()) menu.recording_bind = 0; - if (!visible || !on_voice || menu.last_menu_frame + 1 != ImGui::GetFrameCount()) menu.voice_bind_capture = 0; - // Esc closes the menu, unless it is busy with something else: typing in a - // field, an open popup, or recording a key or controller binding. - if (visible && ImGui::IsKeyPressed(ImGuiKey_Escape, false) && !ImGui::GetIO().WantTextInput && - !menu.recording_bind && !menu.voice_bind_capture && - !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel)) - visible = false; - menu.last_menu_frame = ImGui::GetFrameCount(); +namespace menu { +bool menu_fullscreen(const SkateMenu& menu, const Model& model) { return menu.fullscreen_pending.value_or(model.menu_fullscreen); } +void set_menu_fullscreen(SkateMenu& menu, const CallbacksV3& callbacks, bool fullscreen) { + menu.fullscreen_pending = fullscreen; + menu.fullscreen_until = ImGui::GetTime() + 3.0; + if (!callbacks.queue_console_command) return; + std::array result{}; + callbacks.queue_console_command(callbacks.user, fullscreen ? "ui fullscreen 1" : "ui fullscreen 0", result.data(), result.size()); +} +void open_page(SkateMenu& menu, int page, int addon) { + if (menu.page != page || (addon >= 0 && addon != menu.addon)) tab_switched = true; + if (menu.page != page) { + menu.recording_bind = 0; + menu.voice_bind_capture = 0; + menu.feedback.clear(); + } + menu.page = page; + if (addon >= 0) menu.addon = addon; +} +} + +namespace { +bool page_hidden(const SkateMenu& menu, const Model& model, int page) { + // Developer (the network view) is hidden for now; offline there is no Online. HOME is the + // full screen menu's: a window has its side rail instead. + return page == page_developer || (page == page_home && !menu.fullscreen_drawn) || (model.steam_offline && page == page_online); +} +const char* page_title(const SkateMenu& menu) { + return menu.page == page_addons && menu.addon > addon_all ? addon_names[static_cast(menu.addon)] : page_names[static_cast(menu.page)]; +} +const char* page_subtitle(const SkateMenu& menu) { + return menu.page == page_addons ? addon_subtitles[static_cast(menu.addon)] : page_subtitles[static_cast(menu.page)]; +} +void step_page(SkateMenu& menu, const Model& model, int step) { + int page = menu.page; + for (int tries = 0; tries < page_count; ++tries) { + page = (page + step + page_count) % page_count; + if (!page_hidden(menu, model, page)) break; + } + open_page(menu, page); +} + +// The brushed title, tilted like the HUB's, with its subtitle beside it. +void brushed_title(ImDrawList* draw, SkateMenu& menu, ImVec2 at, const char* text, const char* subtitle, float size) { + const int start = draw->VtxBuffer.Size; + draw->AddText(menu.title, size, ImVec2(at.x + px(2), at.y + px(3)), IM_COL32(0, 0, 0, 160), text); + draw->AddText(menu.title, size, at, paper, text); + const auto extent = menu.title->CalcTextSizeA(size, FLT_MAX, 0, text); + skate_theme::rotate_since(draw, start, -3.0f, ImVec2(at.x + extent.x * .5f, at.y + extent.y * .5f)); + if (subtitle) draw->AddText(menu.body, px(14), ImVec2(at.x + extent.x + px(18), at.y + size * 0.5f), muted, subtitle); +} + +// A controller button as the pad in use shows it: Xbox's coloured letters, or PlayStation's shapes +// (drawn: the menu font has no ✕ â—‹ â–¡ â–³), and the shoulders, triggers and middle buttons by name. +enum class Glyph { a, b, x, y, lb, rb, lt, rt, view, rs }; +float glyph(ImDrawList* draw, SkateMenu& menu, ImVec2 at, Glyph which, bool playstation) { + const float size = px(22); + const ImVec2 centre(at.x + size * .5f, at.y + size * .5f); + const auto face = [&](ImU32 xbox_fill, const char* letter, ImU32 shape) { + if (!playstation) { + draw->AddCircleFilled(centre, size * .5f, xbox_fill); + const auto text = menu.bold->CalcTextSizeA(px(13), FLT_MAX, 0, letter); + draw->AddText(menu.bold, px(13), ImVec2(centre.x - text.x * .5f, centre.y - text.y * .5f), skate_theme::black, letter); + return size; + } + draw->AddCircleFilled(centre, size * .5f, IM_COL32(32, 32, 34, 255)); + const float r = size * .24f, w = px(2); + switch (which) { + case Glyph::a: + draw->AddLine(ImVec2(centre.x - r, centre.y - r), ImVec2(centre.x + r, centre.y + r), shape, w); + draw->AddLine(ImVec2(centre.x - r, centre.y + r), ImVec2(centre.x + r, centre.y - r), shape, w); + break; + case Glyph::b: draw->AddCircle(centre, r, shape, 0, w); break; + case Glyph::x: draw->AddRect(ImVec2(centre.x - r, centre.y - r), ImVec2(centre.x + r, centre.y + r), shape, 0, 0, w); break; + default: + draw->AddTriangle(ImVec2(centre.x, centre.y - r * 1.1f), ImVec2(centre.x - r * 1.1f, centre.y + r * .8f), + ImVec2(centre.x + r * 1.1f, centre.y + r * .8f), shape, w); + break; + } + return size; + }; + switch (which) { + case Glyph::a: return face(IM_COL32(95, 211, 106, 255), "A", IM_COL32(159, 185, 255, 255)); + case Glyph::b: return face(IM_COL32(255, 107, 107, 255), "B", IM_COL32(255, 154, 168, 255)); + case Glyph::x: return face(IM_COL32(77, 163, 255, 255), "X", IM_COL32(240, 166, 224, 255)); + case Glyph::y: return face(IM_COL32(255, 210, 77, 255), "Y", IM_COL32(127, 224, 200, 255)); + default: break; + } + static constexpr std::array xbox{"LB", "RB", "LT", "RT", "VIEW", "RS"}; + static constexpr std::array sony{"L1", "R1", "L2", "R2", "CREATE", "R"}; + const auto index = static_cast(which) - static_cast(Glyph::lb); + const char* name = (playstation ? sony : xbox)[index]; + const auto text = menu.bold->CalcTextSizeA(px(12), FLT_MAX, 0, name); + const float width = text.x + px(14); + draw->AddRectFilled(at, ImVec2(at.x + width, at.y + size), IM_COL32(45, 45, 47, 255), px(4)); + draw->AddText(menu.bold, px(12), ImVec2(at.x + px(7), at.y + (size - text.y) * .5f), paper, name); + return width; +} +// Buttons, then what they do, as one prompt. Returns the width drawn. +float prompt(ImDrawList* draw, SkateMenu& menu, ImVec2 at, std::initializer_list glyphs, const char* label, bool playstation) { + float x = at.x; + for (const auto which : glyphs) x += glyph(draw, menu, ImVec2(x, at.y), which, playstation) + px(4); + draw->AddText(menu.bold, px(14), ImVec2(x + px(4), at.y + px(3)), paper, label); + return x + px(4) + menu.bold->CalcTextSizeA(px(14), FLT_MAX, 0, label).x - at.x; +} + +void draw_page_body(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { + switch (menu.page) { + case page_home: home_page(menu, model, callbacks); break; + case page_map: map_page(menu, model, callbacks); break; + case page_build: build_page(menu, model, callbacks); break; + case page_skater: skater_page(menu, model, callbacks); break; + case page_sandbox: trainer_page(menu, model, callbacks); break; + case page_addons: addons_page(menu, model, callbacks); break; + case page_online: + if (console_style()) { + const auto at = ImGui::GetCursorScreenPos(); + const auto room = ImGui::GetContentRegionAvail(); + detail::draw_online_embedded(ImGui::GetWindowDrawList(), at, ImVec2(at.x + room.x, at.y + room.y), model, callbacks, true); + ImGui::Dummy(room); + } else { + multiplayer_page(menu, model, callbacks); + } + break; + case page_progress: progression_page(menu, model, callbacks); break; + case page_mods: + // The SCRIPTS tab (custom Lua scripts, modding_menu.cpp) is not offered for now: + // nothing uses them. The page is the installed mods. + menu.mods_tab = 0; + ImGui::PushID(menu.mods_tab); + ImGui::BeginChild("mods-tab", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); + draw_modding_menu(menu, menu.mods_tab, model, callbacks); + ImGui::EndChild(); + ImGui::PopID(); + break; + case page_settings: settings_page(menu, model, callbacks); break; + case page_developer: developer_page(menu, model, callbacks); break; + } + if (menu.rail_indent > 0) { + ImGui::Unindent(menu.rail_indent); + menu.rail_indent = 0; + } + if (!menu.feedback.empty() && ImGui::GetTime() < menu.feedback_until) { + // The feedback line sits in a dark strip with a blue edge, like the game's toasts. + ImGui::Spacing(); + const bool debug_page = menu.page == page_skater || menu.page == page_sandbox || (menu.page == page_settings && menu.settings_tab == 1); + const auto& result = debug_page && !model.debug.status.empty() && ImGui::GetTime() > menu.feedback_until - 5.75 ? model.debug.status + : menu.feedback; + const auto at = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x; + const float text_height = ImGui::CalcTextSize(result.c_str(), nullptr, false, width - px(24)).y; + auto* page = ImGui::GetWindowDrawList(); + page->AddRectFilled(at, ImVec2(at.x + width, at.y + text_height + px(12)), skate_theme::tile); + page->AddRectFilled(at, ImVec2(at.x + px(4), at.y + text_height + px(12)), blue); + ImGui::SetCursorScreenPos(ImVec2(at.x + px(14), at.y + px(6))); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + width - px(24)); + ImGui::TextUnformatted(result.c_str()); + ImGui::PopTextWrapPos(); + } +} + +void wordmark(ImDrawList* draw, SkateMenu& menu, ImVec2 at, float size) { + const int start = draw->VtxBuffer.Size; + draw->AddText(menu.title, size, ImVec2(at.x + px(2), at.y + px(3)), IM_COL32(0, 0, 0, 160), "RESKATE"); + draw->AddText(menu.title, size, at, paper, "RESKATE"); + const auto extent = menu.title->CalcTextSizeA(size, FLT_MAX, 0, "RESKATE"); + skate_theme::rotate_since(draw, start, -4.0f, ImVec2(at.x + extent.x * .5f, at.y + extent.y * .5f)); +} + +// A window over the game, moved by dragging and resized from its edges: made for a mouse. The +// side rail lists the pages in their groups, and the addons under ADDONS. +void draw_windowed(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks, bool& visible, bool appearing) { auto& io = ImGui::GetIO(); - ui_scale = std::clamp(model.menu_scale, min_menu_scale, max_menu_scale); - const auto restore_font_scale = io.FontGlobalScale; - // Widget text follows the global font scale; the pixel layout below follows px(). - io.FontGlobalScale = ui_scale; const ImVec2 maximum(std::max(px(400.0f), io.DisplaySize.x - 24), std::max(px(320.0f), io.DisplaySize.y - 24)); ImGui::SetNextWindowSize(ImVec2(std::min(px(1000.0f), maximum.x), std::min(px(700.0f), maximum.y)), ImGuiCond_FirstUseEver); ImGui::SetNextWindowPos(ImVec2(io.DisplaySize.x * .5f, io.DisplaySize.y * .5f), ImGuiCond_FirstUseEver, ImVec2(.5f, .5f)); ImGui::SetNextWindowSizeConstraints(ImVec2(std::min(px(680.0f), maximum.x), std::min(px(480.0f), maximum.y)), maximum); - ImGui::PushFont(menu.body); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0); - ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 0); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(px(10), px(7))); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(px(8), px(8))); - ImGui::PushStyleVar(ImGuiStyleVar_GrabRounding, 0); - ImGui::PushStyleVar(ImGuiStyleVar_PopupRounding, 0); - ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 0); - const int colours = skate_theme::push_widget_colours(); + if (appearing) ImGui::SetNextWindowFocus(); if (ImGui::Begin("ReSkate###skate-menu", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoScrollbar)) { const auto size = ImGui::GetWindowSize(); const auto current = ImGui::GetWindowPos(); @@ -359,119 +1056,331 @@ void draw_skate_menu(SkateMenu& menu, const Model& model, const CallbacksV3& cal auto* draw = ImGui::GetWindowDrawList(); const float sidebar = px(190.0f); - // Sidebar: brushed wordmark, then one tile per page like the HUB's tab icons. + // Side rail: brushed wordmark, then the pages in their groups like the HUB's tab icons. draw->AddRectFilled(origin, ImVec2(origin.x + sidebar, origin.y + size.y), IM_COL32(10, 10, 11, 250)); - { - const int start = draw->VtxBuffer.Size; - const ImVec2 at(origin.x + px(16), origin.y + px(14)); - draw->AddText(menu.title, px(40), ImVec2(at.x + px(2), at.y + px(3)), IM_COL32(0, 0, 0, 160), "RESKATE"); - draw->AddText(menu.title, px(40), at, paper, "RESKATE"); - const auto extent = menu.title->CalcTextSizeA(px(40), FLT_MAX, 0, "RESKATE"); - skate_theme::rotate_since(draw, start, -4.0f, ImVec2(at.x + extent.x * .5f, at.y + extent.y * .5f)); - } + wordmark(draw, menu, ImVec2(origin.x + px(16), origin.y + px(14)), px(40)); draw->AddText(menu.body, px(12), ImVec2(origin.x + px(18), origin.y + px(66)), muted, "YOUR SESSION. YOUR RULES."); - if (trainer_page_wanted()) menu.page = training; - if (dingosdk::drone::menu_flag().exchange(false)) menu.page = drone; // `dronemenu` - menu.page = std::clamp(menu.page, 0, static_cast(page_count) - 1); - // Developer (the network view) is hidden for now; offline there is no Multiplayer either. - // Special is only there for a player the backend lists. - const auto hidden = [&](int page) { - return page == developer || (model.steam_offline && page == multiplayer) || - (page == special && model.multiplayer.identity_tag.empty()); - }; - if (hidden(menu.page)) menu.page = map; - ImGui::SetCursorPos(ImVec2(px(10), px(96))); - ImGui::BeginChild("navigation", ImVec2(sidebar - px(20), size.y - px(140)), ImGuiChildFlags_None); - const float tab_height = px(40); - for (int i = 0; i < static_cast(page_count); ++i) { - if (hidden(i)) continue; + ImGui::SetCursorPos(ImVec2(px(10), px(90))); + ImGui::BeginChild("navigation", ImVec2(sidebar - px(20), size.y - px(134)), ImGuiChildFlags_NavFlattened); + const float rail = sidebar - px(20); + const auto row = [&](const char* id, const char* label, bool selected, float height, float indent, ImFont* font, float text) { const auto at = ImGui::GetCursorScreenPos(); - const bool selected = menu.page == i; - ImGui::PushID(i); - if (ImGui::InvisibleButton("##page", ImVec2(sidebar - px(20), tab_height)) && !selected) { - menu.page = i; - menu.recording_bind = 0; - menu.voice_bind_capture = 0; - menu.feedback.clear(); - } + const bool pressed = ImGui::InvisibleButton(id, ImVec2(rail, height), ImGuiButtonFlags_EnableNav) && !selected; const bool hovered = ImGui::IsItemHovered(); auto* nav = ImGui::GetWindowDrawList(); - const ImVec2 end(at.x + sidebar - px(20), at.y + tab_height - px(4)); + const ImVec2 end(at.x + rail, at.y + height - px(3)); if (selected || hovered) - skate_theme::rough_rect(nav, at, end, selected ? blue : skate_theme::tile_grey, static_cast(i + 3), ui_scale); + skate_theme::rough_rect(nav, ImVec2(at.x + indent, at.y), end, selected ? blue : skate_theme::tile_grey, + static_cast(ImGui::GetItemID()), px(1)); if (ImGui::IsItemFocused() && io.NavVisible) nav->AddRect(at, end, paper, 0, 0, px(2)); - nav->AddText(menu.bold, px(16), ImVec2(at.x + px(12), at.y + (tab_height - px(4) - px(16)) * .5f), - selected ? skate_theme::black : paper, page_names[i]); + nav->AddText(font, text, ImVec2(at.x + indent + px(12), at.y + (height - px(3) - text) * .5f), + selected ? skate_theme::black : paper, label); + return pressed; + }; + for (int i = 0; i < page_count; ++i) { + if (page_hidden(menu, model, i)) continue; + if (const char* group = group_of(i)) { + ImGui::Dummy(ImVec2(0, px(2))); + ImGui::PushStyleColor(ImGuiCol_Text, muted); + ImGui::PushFont(menu.bold); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + px(10)); + ImGui::TextUnformatted(group); + ImGui::PopFont(); + ImGui::PopStyleColor(); + } + ImGui::PushID(i); + const bool on_page = menu.page == i; + if (row("##page", page_names[static_cast(i)], on_page && !(i == page_addons && menu.addon > addon_all), px(36), 0, + menu.bold, px(16))) + open_page(menu, i, i == page_addons ? addon_all : -1); + if (i == page_addons && on_page) + for (int a = addon_drone; a < addon_count; ++a) { + ImGui::PushID(a); + if (row("##addon", addon_names[static_cast(a)], menu.addon == a, px(30), px(14), menu.bold, px(13))) + open_page(menu, page_addons, a); + ImGui::PopID(); + } ImGui::PopID(); } ImGui::EndChild(); - // Keycap-style hint, like the game's "Esc Back" prompt. { static const auto label = dingosdk::launcher::key_cap(dingosdk::launcher::overlay_keys().menu); - keycap(draw, menu.bold, ui_scale, ImVec2(origin.x + px(16), origin.y + size.y - px(34)), label.c_str(), "Close"); + keycap(draw, menu.bold, px(1), ImVec2(origin.x + px(16), origin.y + size.y - px(34)), label.c_str(), "Close"); } - // Page header: brushed title like the game's "HUB", with its subtitle. - { - const int start = draw->VtxBuffer.Size; - const ImVec2 at(origin.x + sidebar + px(22), origin.y + px(12)); - draw->AddText(menu.title, px(44), ImVec2(at.x + px(2), at.y + px(3)), IM_COL32(0, 0, 0, 160), page_names[menu.page]); - draw->AddText(menu.title, px(44), at, paper, page_names[menu.page]); - const auto extent = menu.title->CalcTextSizeA(px(44), FLT_MAX, 0, page_names[menu.page]); - skate_theme::rotate_since(draw, start, -3.0f, ImVec2(at.x + extent.x * .5f, at.y + extent.y * .5f)); - draw->AddText(menu.body, px(14), ImVec2(at.x + extent.x + px(18), at.y + px(22)), muted, page_subtitles[menu.page]); - } - ImGui::SetCursorPos(ImVec2(size.x - px(44), px(16))); + brushed_title(draw, menu, ImVec2(origin.x + sidebar + px(22), origin.y + px(12)), page_title(menu), page_subtitle(menu), px(44)); + // Close, and beside it the full screen menu. + const float switch_width = ImGui::CalcTextSize("SWITCH TO FULLSCREEN").x + px(20); + ImGui::SetCursorPos(ImVec2(size.x - px(44) - px(6) - switch_width, px(16))); + if (ImGui::Button("SWITCH TO FULLSCREEN##fullscreen", ImVec2(switch_width, px(28)))) set_menu_fullscreen(menu, callbacks, true); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Full screen, made for a controller (F11)"); + ImGui::SameLine(0, px(6)); if (ImGui::Button("X", ImVec2(px(28), px(28)))) visible = false; ImGui::SetCursorPos(ImVec2(sidebar + px(20), px(76))); - ImGui::BeginChild("page", ImVec2(size.x - sidebar - px(40), size.y - px(96)), ImGuiChildFlags_None); - switch (menu.page) { - case map: map_page(menu, model, callbacks); break; - case world: world_page(menu, model, callbacks); break; - case build: build_page(menu, model, callbacks); break; - case skater: skater_page(menu, model, callbacks); break; - case training: trainer_page(menu, model, callbacks); break; - case drone: drone_page(menu, model, callbacks); break; - case multiplayer: multiplayer_page(menu, model, callbacks); break; - case progress: progression_page(menu, model, callbacks); break; - case mods: - // The SCRIPTS tab (custom Lua scripts, modding_menu.cpp) is not offered for now: - // nothing uses them. The page is the installed mods. - menu.mods_tab = 0; - ImGui::PushID(menu.mods_tab); - ImGui::BeginChild("mods-tab", ImVec2(0, page_body_height(menu))); - draw_modding_menu(menu, menu.mods_tab, model, callbacks); - ImGui::EndChild(); - ImGui::PopID(); - break; - case music: music_page(menu, model, callbacks); break; - case settings: settings_page(menu, model, callbacks); break; - case special: special_page(menu, model, callbacks); break; - case developer: developer_page(menu, model, callbacks); break; - } - if (!menu.feedback.empty() && ImGui::GetTime() < menu.feedback_until) { - // The feedback line sits in a dark strip with a blue edge, like the game's toasts. - ImGui::Spacing(); - const auto& result = (menu.page == skater || (menu.page == settings && menu.settings_tab == 1)) && - !model.debug.status.empty() && ImGui::GetTime() > menu.feedback_until - 5.75 ? model.debug.status : menu.feedback; - const auto at = ImGui::GetCursorScreenPos(); - const float width = ImGui::GetContentRegionAvail().x; - const float text_height = ImGui::CalcTextSize(result.c_str(), nullptr, false, width - px(24)).y; - auto* page = ImGui::GetWindowDrawList(); - page->AddRectFilled(at, ImVec2(at.x + width, at.y + text_height + px(12)), skate_theme::tile); - page->AddRectFilled(at, ImVec2(at.x + px(4), at.y + text_height + px(12)), blue); - ImGui::SetCursorScreenPos(ImVec2(at.x + px(14), at.y + px(6))); - ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + width - px(24)); - ImGui::TextUnformatted(result.c_str()); - ImGui::PopTextWrapPos(); - } + ImGui::BeginChild("page", ImVec2(size.x - sidebar - px(40), size.y - px(96)), ImGuiChildFlags_NavFlattened); + draw_page_body(menu, model, callbacks); ImGui::EndChild(); } ImGui::End(); +} + +// Full screen, scrolling a page by pad: the right stick scrolls it, and up / down past its first or +// last control scrolls on to what is above or below (text, a disabled control) that the focus +// cannot land on. The page is the smallest scrolling part of the menu that holds the focus. +void pad_scroll(ImGuiWindow* root) { + ImGuiContext& g = *GImGui; + ImRect focus; + const bool has_focus = g.NavWindow && g.NavId && g.NavWindow->RootWindow == root; + if (has_focus) focus = ImGui::WindowRectRelToAbs(g.NavWindow, g.NavWindow->NavRectRel[g.NavLayer]); + ImGuiWindow* page = nullptr; + for (ImGuiWindow* window : g.Windows) { + if (!window->Active || window->RootWindow != root || window == root || window->ScrollMax.y <= 0) continue; + const bool holds = !has_focus || window->InnerRect.Contains(focus.GetCenter()); + if (holds && (!page || window->InnerRect.GetHeight() < page->InnerRect.GetHeight())) page = window; + } + // An edge press: the focus did not move on the frame after a press up / down. + static ImGuiID pressed_on{}; + static int pressed_frame{-10}, pressed_dir{}; + const int frame = ImGui::GetFrameCount(); + if (page && pressed_frame == frame - 1 && pressed_dir && g.NavId == pressed_on && !ImGui::IsAnyItemActive()) + ImGui::SetScrollY(page, std::clamp(page->Scroll.y + pressed_dir * px(220), 0.0f, page->ScrollMax.y)); + pressed_dir = 0; + const auto down = [](ImGuiKey key) { return ImGui::IsKeyPressed(key, true); }; + if (down(ImGuiKey_GamepadDpadDown) || down(ImGuiKey_GamepadLStickDown) || down(ImGuiKey_DownArrow)) pressed_dir = 1; + else if (down(ImGuiKey_GamepadDpadUp) || down(ImGuiKey_GamepadLStickUp) || down(ImGuiKey_UpArrow)) pressed_dir = -1; + pressed_on = g.NavId; + pressed_frame = frame; + if (!page) return; + const float stick = ImGui::GetKeyData(ImGuiKey_GamepadRStickDown)->AnalogValue - ImGui::GetKeyData(ImGuiKey_GamepadRStickUp)->AnalogValue; + // Gentle near the middle, a page a second or so at full tilt. + if (std::abs(stick) > 0.01f) + ImGui::SetScrollY(page, std::clamp(page->Scroll.y + stick * std::abs(stick) * px(650) * ImGui::GetIO().DeltaTime, 0.0f, page->ScrollMax.y)); +} + +// Fills the screen, with the pages across the top and button prompts at the bottom: made for a +// controller. LB / RB change page, LT / RT the page's tabs, View goes back to a window. +void draw_fullscreen(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks, bool& visible, bool appearing, bool pad, + bool playstation) { + auto& io = ImGui::GetIO(); + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(io.DisplaySize); + if (appearing) ImGui::SetNextWindowFocus(); + ImGui::PushStyleColor(ImGuiCol_WindowBg, IM_COL32(8, 9, 11, 238)); + const bool began = ImGui::Begin("ReSkate###skate-menu-full", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoSavedSettings); + ImGui::PopStyleColor(); + if (began) { + { + ImGuiContext& g = *GImGui; + ImGuiWindow* self = ImGui::GetCurrentWindow(); + bool pad_pressed = false; + for (int key = ImGuiKey_GamepadStart; key <= ImGuiKey_GamepadRStickDown && !pad_pressed; ++key) + pad_pressed = ImGui::IsKeyPressed(static_cast(key), false); + const bool ours = g.NavWindow && g.NavWindow->RootWindow == self; + if (!ours && (pad_pressed || !g.NavWindow) && !io.WantTextInput) ImGui::FocusWindow(self); + } + const auto size = ImGui::GetWindowSize(); + const auto origin = ImGui::GetWindowPos(); + auto* draw = ImGui::GetWindowDrawList(); + const float bar = px(68), footer = px(56); + draw->AddRectFilled(origin, ImVec2(origin.x + size.x, origin.y + bar), IM_COL32(10, 10, 11, 255)); + draw->AddRectFilled(ImVec2(origin.x, origin.y + bar - px(1)), ImVec2(origin.x + size.x, origin.y + bar), skate_theme::tile_grey); + wordmark(draw, menu, ImVec2(origin.x + px(26), origin.y + px(16)), px(34)); + + // The pages across the top, LB and RB at their ends. + const float tab_height = px(42), tab_pad = px(16); + float tabs_width = 0; + for (int i = 0; i < page_count; ++i) + if (!page_hidden(menu, model, i)) + tabs_width += menu.bold->CalcTextSizeA(px(16), FLT_MAX, 0, page_names[static_cast(i)]).x + tab_pad * 2 + px(2); + const float glyphs = pad ? px(36) * 2 : 0; + float x = origin.x + std::max(px(220), (size.x - tabs_width - glyphs) * .5f); + const float tab_y = origin.y + (bar - tab_height) * .5f; + if (pad) x += glyph(draw, menu, ImVec2(x, tab_y + (tab_height - px(22)) * .5f), Glyph::lb, playstation) + px(12); + for (int i = 0; i < page_count; ++i) { + if (page_hidden(menu, model, i)) continue; + const char* name = page_names[static_cast(i)]; + const auto text = menu.bold->CalcTextSizeA(px(16), FLT_MAX, 0, name); + const float width = text.x + tab_pad * 2; + ImGui::SetCursorScreenPos(ImVec2(x, tab_y)); + ImGui::PushID(i); + if (ImGui::InvisibleButton("##top-page", ImVec2(width, tab_height), ImGuiButtonFlags_EnableNav)) open_page(menu, i, i == page_addons ? addon_all : -1); + const bool hovered = ImGui::IsItemHovered(); + if (ImGui::IsItemFocused() && io.NavVisible) draw->AddRect(ImVec2(x, tab_y), ImVec2(x + width, tab_y + tab_height), paper, 0, 0, px(2)); + ImGui::PopID(); + const bool on = menu.page == i; + if (on || hovered) draw->AddRectFilled(ImVec2(x, tab_y), ImVec2(x + width, tab_y + tab_height), on ? blue : skate_theme::tile_grey); + draw->AddText(menu.bold, px(16), ImVec2(x + tab_pad, tab_y + (tab_height - text.y) * .5f), on ? skate_theme::black : paper, name); + x += width + px(2); + } + if (pad) glyph(draw, menu, ImVec2(x + px(10), tab_y + (tab_height - px(22)) * .5f), Glyph::rb, playstation); + + // Back to a window, and close. + const float switch_width = ImGui::CalcTextSize("SWITCH TO WINDOWED").x + px(28); + ImGui::SetCursorScreenPos(ImVec2(origin.x + size.x - px(26) - px(36) - px(8) - switch_width, origin.y + (bar - px(36)) * .5f)); + if (ImGui::Button("SWITCH TO WINDOWED##windowed", ImVec2(switch_width, px(36)))) set_menu_fullscreen(menu, callbacks, false); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("A window over the game, made for a mouse (F11)"); + ImGui::SameLine(0, px(8)); + if (ImGui::Button("X##close", ImVec2(px(36), px(36)))) visible = false; + + // The page, in a column that keeps lines readable on a wide screen. + const float width = std::min(size.x - px(80), px(1360)); + const float left = (size.x - width) * .5f; + if (menu.page != page_home) + brushed_title(draw, menu, ImVec2(origin.x + left, origin.y + bar + px(16)), page_title(menu), page_subtitle(menu), px(44)); + const float top = bar + (menu.page == page_home ? px(24) : px(84)); + ImGui::SetCursorPos(ImVec2(left, top)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(px(16), px(13))); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(px(12), px(12))); + ImGui::BeginChild("page", ImVec2(width, size.y - top - footer - px(12)), ImGuiChildFlags_NavFlattened); + draw_page_body(menu, model, callbacks); + ImGui::EndChild(); + ImGui::PopStyleVar(2); + if (menu.page != page_online && !menu.recording_bind) pad_scroll(ImGui::GetCurrentWindow()); + + // What the buttons do: the pad's, or the keys when there is none. + const float foot = origin.y + size.y - footer; + draw->AddRectFilled(ImVec2(origin.x, foot), ImVec2(origin.x + size.x, origin.y + size.y), IM_COL32(10, 10, 11, 255)); + draw->AddRectFilled(ImVec2(origin.x, foot), ImVec2(origin.x + size.x, foot + px(1)), skate_theme::tile_grey); + const ImVec2 at(origin.x + left, foot + (footer - px(22)) * .5f); + float across = 0; + if (pad) { + across += prompt(draw, menu, ImVec2(at.x + across, at.y), {Glyph::a}, "Select", playstation) + px(26); + across += prompt(draw, menu, ImVec2(at.x + across, at.y), {Glyph::b}, menu.page == page_home ? "Close" : "Back", playstation) + px(26); + across += prompt(draw, menu, ImVec2(at.x + across, at.y), {Glyph::lb, Glyph::rb}, "Pages", playstation) + px(26); + across += prompt(draw, menu, ImVec2(at.x + across, at.y), {Glyph::lt, Glyph::rt}, "Sections", playstation) + px(26); + across += prompt(draw, menu, ImVec2(at.x + across, at.y), {Glyph::view}, "Windowed", playstation) + px(26); + if (menu.page != page_online) across += prompt(draw, menu, ImVec2(at.x + across, at.y), {Glyph::rs}, "Scroll", playstation) + px(40); + else across += px(14); + if (!console_hint().empty()) { + draw->PushClipRect(ImVec2(at.x + across, foot), ImVec2(origin.x + size.x - px(20), origin.y + size.y), true); + draw->AddText(menu.body, px(15), ImVec2(at.x + across, at.y + px(3)), muted, console_hint().c_str()); + draw->PopClipRect(); + } + } else { + static const auto label = dingosdk::launcher::key_cap(dingosdk::launcher::overlay_keys().menu); + across += keycap(draw, menu.bold, px(1), ImVec2(at.x + across, at.y), "Esc", "Close") + px(26); + across += keycap(draw, menu.bold, px(1), ImVec2(at.x + across, at.y), "F11", "Windowed") + px(26); + keycap(draw, menu.bold, px(1), ImVec2(at.x + across, at.y), label.c_str(), "Close"); + } + } + ImGui::End(); +} +} + +namespace { +// Full screen, the game's menu sounds as its pause menu makes them: a tab or page changing, a press +// (A, Enter, a click on a control), the focus moving. The ONLINE page is the pause menu's own and +// makes its own, so only the page changes are made for it. +void menu_sounds(bool page_makes_its_own) { + ImGuiContext& g = *GImGui; + static ImGuiID heard{}; + static double moved_at{}; + constexpr std::uint32_t navigate = 3, select = 7, tab = 56; + if (menu::tab_switched) { + play_ui_sound(tab); + } else if (!page_makes_its_own) { + const bool pressed = (g.NavId && (ImGui::IsKeyPressed(ImGuiKey_GamepadFaceDown, false) || ImGui::IsKeyPressed(ImGuiKey_Enter, false) || + ImGui::IsKeyPressed(ImGuiKey_Space, false))) || + (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && g.HoveredId); + if (pressed) play_ui_sound(select); + // (Sweeping the pointer across tiles is not a sound for each one: only the D-pad's focus.) + else if (g.NavId != heard && g.NavId && g.NavCursorVisible && ImGui::GetTime() - moved_at > 0.045) { + play_ui_sound(navigate); + moved_at = ImGui::GetTime(); + } + } + heard = g.NavId; +} +} + +void draw_skate_menu(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks, + bool& visible) { + menu::tab_switched = false; + const bool on_controls = menu.page == page_settings && menu.settings_tab == 0; + const bool on_voice = menu.page == page_online && menu.multiplayer_tab == multiplayer_voice_tab; + const bool was_drawn = menu.last_menu_frame + 1 == ImGui::GetFrameCount(); + if (!visible || !on_controls || !was_drawn) menu.recording_bind = 0; + if (!visible || !on_voice || !was_drawn) menu.voice_bind_capture = 0; + auto& io = ImGui::GetIO(); + // Esc (B on a controller) closes the menu, unless it is busy with something else: typing in a + // field, an open popup, or recording a key or controller binding. Full screen, B first goes + // back to HOME. + const bool idle = !io.WantTextInput && !detail::online_embedded_typing() && !menu.recording_bind && !menu.voice_bind_capture && !ImGui::IsAnyItemActive() && + !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel); + if (menu.fullscreen_pending && (*menu.fullscreen_pending == model.menu_fullscreen || ImGui::GetTime() > menu.fullscreen_until)) + menu.fullscreen_pending.reset(); + const bool full = menu_fullscreen(menu, model); + const bool appearing = !was_drawn || full != menu.fullscreen_drawn; + menu.fullscreen_drawn = full; + menu_fullscreen_shown().store(full); + if (visible && idle && was_drawn) { + if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) visible = false; + if (ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false)) { + if (full && menu.page != page_home) open_page(menu, page_home); + else visible = false; + } + if (ImGui::IsKeyPressed(ImGuiKey_GamepadL1, false)) step_page(menu, model, -1); + if (ImGui::IsKeyPressed(ImGuiKey_GamepadR1, false)) step_page(menu, model, 1); + if (ImGui::IsKeyPressed(ImGuiKey_GamepadBack, false) || ImGui::IsKeyPressed(ImGuiKey_F11, false)) + set_menu_fullscreen(menu, callbacks, !full); + } + menu.last_menu_frame = ImGui::GetFrameCount(); + // Full screen is read from the sofa: everything grows with the screen, drawn from the larger fonts. + const float boost = full ? std::clamp(io.DisplaySize.y / 1080.0f, 1.0f, 2.0f) * 1.3f : 1.0f; + // Each style keeps its own size; a slider held shows its size as it moves. + const float chosen = menu.scale_editing ? menu.scale : full ? model.menu_scale_fullscreen : model.menu_scale; + ui_scale = std::clamp(chosen, min_menu_scale, max_menu_scale) * boost; + const bool large = full && menu.body_large && menu.bold_large && menu.heading_large && menu.title_large; + const std::array small_fonts{menu.body, menu.bold, menu.heading, menu.title}; + if (large) { + menu.body = menu.body_large; + menu.bold = menu.bold_large; + menu.heading = menu.heading_large; + menu.title = menu.title_large; + } + const auto restore_font_scale = io.FontGlobalScale; + // Widget text follows the global font scale; the pixel layout below follows px(). + io.FontGlobalScale = large ? ui_scale / large_font_ratio : ui_scale; + menu.rail_indent = 0; + if (trainer_page_wanted()) open_page(menu, page_sandbox); + if (dingosdk::drone::menu_flag().exchange(false)) open_page(menu, page_addons, addon_drone); // `dronemenu` + menu.page = std::clamp(menu.page, 0, static_cast(page_count) - 1); + if (page_hidden(menu, model, menu.page)) menu.page = full ? page_home : page_map; + menu_page_owns_pad().store(full && (menu.page == page_online || menu.recording_bind)); + // The pad in use names its buttons in the full screen prompts. + const auto pad = read_gamepad(); + + ImGui::PushFont(menu.body); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 0); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(px(10), px(7))); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(px(8), px(8))); + ImGui::PushStyleVar(ImGuiStyleVar_GrabRounding, 0); + ImGui::PushStyleVar(ImGuiStyleVar_PopupRounding, 0); + ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 0); + int colours = skate_theme::push_widget_colours(); + // A controller's focus in white: the theme's blue vanishes on blue buttons and tabs. + if (full) { + ImGui::PushStyleColor(ImGuiCol_NavCursor, IM_COL32(245, 245, 245, 255)); + ++colours; + } + begin_console_frame(full, menu.bold, pad.playstation); + if (full) draw_fullscreen(menu, model, callbacks, visible, appearing, pad.valid, pad.playstation); + else draw_windowed(menu, model, callbacks, visible, appearing); + if (full) menu_sounds(menu.page == page_online); ImGui::PopStyleColor(colours); ImGui::PopStyleVar(9); ImGui::PopFont(); io.FontGlobalScale = restore_font_scale; + if (large) { + menu.body = small_fonts[0]; + menu.bold = small_fonts[1]; + menu.heading = small_fonts[2]; + menu.title = small_fonts[3]; + } } } - diff --git a/Extension/UI/Overlay/skate_menu.h b/Extension/UI/Overlay/skate_menu.h index fd85eaf9..787b52c2 100644 --- a/Extension/UI/Overlay/skate_menu.h +++ b/Extension/UI/Overlay/skate_menu.h @@ -71,8 +71,13 @@ struct SkateMenu { // Live while the scale slider is held; the saved value is authoritative otherwise. float scale = default_menu_scale; bool scale_editing{}; - // Selected tab on each page; see the Page list in skate_menu.cpp. - int map_tab = 0, world_tab = 0, build_tab = 0, skater_tab = 0, settings_tab = 0, mods_tab = 0; + // Selected tab on each page; see the Page list in skate_menu_internal.h. + int map_tab = 0, build_tab = 0, skater_tab = 0, settings_tab = 0, mods_tab = 0; + int addon = 0; // the ADDONS page's addon (menu::Addon) + // Window or full screen, as just chosen, until the saved setting comes back through the model. + std::optional fullscreen_pending; + double fullscreen_until = 0; + bool fullscreen_drawn = false; // the last frame was drawn full screen bool loose_files_settings_loaded{}, loose_files_saved{true}; bool custom_scripts_scanned{}; std::vector custom_script_rows; @@ -105,7 +110,17 @@ struct SkateMenu { ImFont* title = nullptr; // brushed page titles ImFont* heading = nullptr; // tile and section headers ImFont* mono = nullptr; + // The same faces drawn larger (large_font_ratio times), swapped in while the menu is full + // screen, where everything is scaled up: text stays sharp instead of stretched. + ImFont* body_large = nullptr; + ImFont* bold_large = nullptr; + ImFont* heading_large = nullptr; + ImFont* title_large = nullptr; + // Full screen: the page's section rail (its first row of tabs, drawn down the left), indenting + // what follows it until the page is done. + float rail_indent = 0; }; +inline constexpr float large_font_ratio = 30.0f / 17.0f; void load_skate_fonts(SkateMenu& menu); void draw_skate_menu(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks, bool& visible); diff --git a/Extension/UI/Overlay/skate_menu_addons.cpp b/Extension/UI/Overlay/skate_menu_addons.cpp new file mode 100644 index 00000000..2f16b326 --- /dev/null +++ b/Extension/UI/Overlay/skate_menu_addons.cpp @@ -0,0 +1,293 @@ +#include "skate_menu_internal.h" +#include "skate_style.h" +#include "Extension/UI/skate_theme.h" +#include "Extension/UI/NativeMenu/native_tools_view.h" +#include "Extension/Skater/drone_flight.h" + +#include +#include +#include +#include + +// The ADDONS page (what ReSkate adds to skate.: the drone, Board Break, Road Rash and Hall of Meat, +// music) and the full screen menu's HOME page. +namespace dingosdk::overlay::menu { +using namespace theme; +namespace { +// A tile for an addon on ALL ADDONS: what it is, whether it is on, and a way in. +void addon_tile(SkateMenu& menu, const char* id, const char* title, const char* about, const char* state, ImU32 state_colour, + int addon) { + if (console_style()) { + // Full screen: a tile as the pause menu's, A opens the addon. + const auto at = ImGui::GetCursorScreenPos(); + const ImVec2 size((ImGui::GetContentRegionAvail().x - px(12)) * .5f, px(168)); + ImGui::PushID(id); + const bool pressed = ImGui::InvisibleButton("##addon", size, ImGuiButtonFlags_EnableNav); + const bool lit = (ImGui::IsItemFocused() && ImGui::GetIO().NavVisible) || ImGui::IsItemHovered(); + if (lit) set_console_hint((std::string("Open ") + title + ".").c_str()); + auto* draw = ImGui::GetWindowDrawList(); + const ImVec2 end(at.x + size.x, at.y + size.y); + skate_theme::rough_rect(draw, at, end, lit ? blue : IM_COL32(26, 26, 26, 235), static_cast(ImGui::GetItemID()) & 0xfff, px(1)); + draw->PushClipRect(at, end, true); + draw->AddText(menu.heading, px(26), ImVec2(at.x + px(20), at.y + px(18)), paper, title); + if (state) { + const auto text = menu.bold->CalcTextSizeA(px(13), FLT_MAX, 0, state); + const ImVec2 tag_at(end.x - px(20) - text.x - px(14), at.y + px(22)); + draw->AddRectFilled(tag_at, ImVec2(tag_at.x + text.x + px(14), tag_at.y + text.y + px(8)), lit ? paper : state_colour); + draw->AddText(menu.bold, px(13), ImVec2(tag_at.x + px(7), tag_at.y + px(4)), skate_theme::black, state); + } + draw->AddText(menu.body, px(16), ImVec2(at.x + px(20), at.y + px(66)), lit ? paper : muted, about, nullptr, size.x - px(40)); + draw->PopClipRect(); + ImGui::PopID(); + if (pressed) open_page(menu, page_addons, addon); + return; + } + begin_card(menu, id, title); + if (state) { + ImGui::SameLine(); + tag(menu, state, state_colour); + } + note(about); + ImGui::Dummy(ImVec2(0, px(2))); + if (ImGui::Button((std::string("OPEN ") + title + "##" + id).c_str(), ImVec2(-FLT_MIN, px(34)))) open_page(menu, page_addons, addon); + end_card(); +} + +void all_addons(SkateMenu& menu, const Model& model) { + const auto on_off = [](bool available, bool on) -> const char* { return !available ? "NOT AVAILABLE" : on ? "ON" : "OFF"; }; + const auto colour = [](bool available, bool on) { return !available ? skate_theme::tile_light : on ? skate_theme::good : skate_theme::tile_light; }; + addon_tile(menu, "addon-drone", "DRONE", "Fly an FPV drone from the free camera, or let it follow you and film your line.", nullptr, 0, + addon_drone); + if (console_style()) ImGui::SameLine(0, px(12)); + const auto& bb = model.board_break; + addon_tile(menu, "addon-board-break", "BOARD BREAK", "Slams snap your board, boards wear out, and every crack has its sound.", + on_off(bb.available, bb.enabled), colour(bb.available, bb.enabled), addon_board_break); + const auto& rash = model.road_rash; + addon_tile(menu, "addon-road-rash", "ROAD RASH", "Bails leave marks that build up: dust, scrapes, bruises, wounds.", + on_off(rash.available, rash.enabled), colour(rash.available, rash.enabled), addon_road_rash); + if (console_style()) ImGui::SameLine(0, px(12)); + const auto& meat = model.hall_of_meat; + addon_tile(menu, "addon-hall-of-meat", "HALL OF MEAT", "Your bails show the bones they hurt and score a Meat card.", + on_off(meat.available, meat.enabled), colour(meat.available, meat.enabled), addon_hall_of_meat); + const auto& grind = model.grind_exit; + addon_tile(menu, "addon-grind-exits", "GRIND EXITS", "Press LT or RT while grinding to pick the side you pop out to.", + on_off(grind.available, grind.enabled), colour(grind.available, grind.enabled), addon_grind_exits); + if (console_style()) ImGui::SameLine(0, px(12)); + addon_tile(menu, "addon-music", "MUSIC", "Spotify, YouTube Music, your own files and online radio through the game's music player.", + nullptr, 0, addon_music); +} +} + +void addons_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { + menu.addon = std::clamp(menu.addon, 0, static_cast(addon_count) - 1); + // A window lists the addons in its side rail; full screen has no rail, so they are tabs here + // (LT / RT on a controller). + if (menu.fullscreen_drawn) + category_tabs(menu, menu.addon, {"ALL ADDONS", "DRONE", "BOARD BREAK", "ROAD RASH", "HALL OF MEAT", "GRIND EXITS", "MUSIC"}, "addon-tabs"); + ImGui::PushID(menu.addon); + switch (menu.addon) { + case addon_drone: drone_page(menu, model, callbacks); break; + case addon_music: music_page(menu, model, callbacks); break; + default: + ImGui::BeginChild("addon", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); + if (menu.addon == addon_board_break) board_break_addon(menu, model, callbacks); + else if (menu.addon == addon_road_rash) road_rash_addon(menu, model, callbacks); + else if (menu.addon == addon_hall_of_meat) hall_of_meat_addon(menu, model, callbacks); + else if (menu.addon == addon_grind_exits) grind_exits_addon(menu, model, callbacks); + else all_addons(menu, model); + ImGui::EndChild(); + break; + } + ImGui::PopID(); +} + +namespace { +// A HOME tile, in the pause menu's look: rough-cut, blue under the focus or the pointer. Its name +// at the top; what it is now (ON, OFF, the time of day) under it, or a line about the page it +// opens. Returns 1 when pressed (A, a click), -1 / 1 for left / right on a tile that `steps`. +int home_tile(SkateMenu& menu, const char* id, const char* name, const std::string& value, ImVec2 size, bool enabled, bool on, + bool steps, const char* hint, bool big_value = true) { + ImGui::BeginDisabled(!enabled); + const auto at = ImGui::GetCursorScreenPos(); + const bool pressed = ImGui::InvisibleButton(id, size, ImGuiButtonFlags_EnableNav); + const bool hovered = ImGui::IsItemHovered(), focused = ImGui::IsItemFocused() && ImGui::GetIO().NavVisible; + const unsigned seed = static_cast(ImGui::GetItemID()) & 0xfff; + ImGui::EndDisabled(); + int result = 0; + // A tile that steps is changed as the pause menu's settings are: A to start (it turns white), + // then left / right, and A or B when done. Until then the D-pad moves past it. A click steps it on. + static ImGuiID editing{}; + const ImGuiID item = ImGui::GetItemID(); + const bool mouse = pressed && hovered && ImGui::GetIO().MouseReleased[0]; + if (enabled && steps) { + // (While it is held active ImGui makes no press of A, so A / B / Enter / Esc are read here.) + if (editing == item && (!ImGui::IsItemFocused() || ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false) || + ImGui::IsKeyPressed(ImGuiKey_GamepadFaceDown, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false) || + ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_Space, false))) + editing = 0; + else if (pressed && !mouse) editing = item; + else if (mouse) result = 1; + if (editing == item) { + result = console_step(true); + // Held active while it is changed: B ends the change, not the page. + ImGui::SetActiveID(item, ImGui::GetCurrentWindow()); + } + } else if (enabled && pressed) { + result = 1; + } + const bool changing = steps && editing == item; + if (focused || hovered) set_console_hint(changing ? "Left / right to change it. A or B when done." : hint); + auto* draw = ImGui::GetWindowDrawList(); + const ImVec2 end(at.x + size.x, at.y + size.y); + const bool lit = enabled && (focused || hovered); + skate_theme::rough_rect(draw, at, end, changing ? paper : lit ? blue : enabled ? IM_COL32(26, 26, 26, 235) : IM_COL32(26, 26, 26, 140), seed, px(1)); + draw->PushClipRect(at, end, true); + draw->AddText(menu.bold, px(14), ImVec2(at.x + px(18), at.y + px(14)), changing ? skate_theme::black : lit ? paper : muted, name); + const float value_size = big_value ? px(24) : px(15); + const ImU32 value_ink = changing ? skate_theme::black : !enabled ? muted : lit ? paper : on ? blue : paper; + draw->AddText(big_value ? menu.heading : menu.body, value_size, ImVec2(at.x + px(18), end.y - px(16) - value_size), value_ink, value.c_str(), + nullptr, size.x - px(36)); + if (steps && enabled) { + // Left / right change it. + const float s = px(6), mid = end.y - px(16) - value_size * .5f; + const ImU32 arrow = changing ? skate_theme::black : lit ? paper : muted; + draw->AddTriangleFilled(ImVec2(end.x - px(40), mid), ImVec2(end.x - px(40) + s, mid - s), ImVec2(end.x - px(40) + s, mid + s), arrow); + draw->AddTriangleFilled(ImVec2(end.x - px(16), mid), ImVec2(end.x - px(16) - s, mid - s), ImVec2(end.x - px(16) - s, mid + s), arrow); + } + draw->PopClipRect(); + return result; +} +void heading(SkateMenu& menu, const char* text) { + ImGui::PushFont(menu.heading); + ImGui::TextUnformatted(text); + ImGui::PopFont(); + ImGui::Dummy(ImVec2(0, px(2))); +} +// A console line, without its reply on the page (a quick control says what it did by its tile). +void quietly(const CallbacksV3& callbacks, const std::string& command) { + if (!callbacks.queue_console_command) return; + std::array reply{}; + callbacks.queue_console_command(callbacks.user, command.c_str(), reply.data(), reply.size()); +} +} + +void home_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { + ImGui::BeginChild("home", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); + const float gap = px(12); + const auto grid = [&](int columns) { + return (ImGui::GetContentRegionAvail().x - gap * static_cast(columns - 1)) / static_cast(columns); + }; + const float left = ImGui::GetCursorPosX(); + const auto place = [&](float width, int column) { ImGui::SameLine(left + (width + gap) * static_cast(column)); }; + const auto& debug = model.debug; + const auto on_off = [](bool value) { return std::string(value ? "ON" : "OFF"); }; + const auto request = [&](DebugAction action, bool value) { + if (!callbacks.queue_debug) return; + std::array reply{}; + callbacks.queue_debug(callbacks.user, {action, value}, reply.data(), reply.size()); + }; + + // ---- the switches people reach for while skating + heading(menu, "QUICK"); + { + constexpr int columns = 5; + const float width = grid(columns); + const ImVec2 size(width, px(96)); + namespace tod = dingosdk::native_tools; + static constexpr std::array times{"Default", "Morning", "Noon", "Afternoon", "Evening", "Night", "Weather day", "Weather night"}; + const bool tod_available = tod::time_of_day_available(model, callbacks); + const int now = time_of_day_shown(model); + const int step = home_tile(menu, "##time", "TIME OF DAY", times[static_cast(std::clamp(now, 0, 7))], size, tod_available, now != 0, + true, "Press A to change the time of day. Default follows the level's own."); + // One time a press, on from the one last asked for. + if (step) set_time_of_day(menu, model, callbacks, (now + step + 8) % 8, true); + place(width, 1); + if (home_tile(menu, "##noclip", "NOCLIP", on_off(debug.noclip), size, (debug.noclip_available || debug.noclip) && callbacks.queue_debug, + debug.noclip, false, "Fly with the normal player camera, through anything. Includes No Bail.") == 1) + request(DebugAction::set_noclip, !debug.noclip); + place(width, 2); + if (home_tile(menu, "##no-bail", "NO BAIL", on_off(debug.no_bail), size, (debug.no_bail_available || debug.no_bail) && callbacks.queue_debug, + debug.no_bail, false, "Never wipe out.") == 1) + request(DebugAction::set_no_bail, !debug.no_bail); + place(width, 3); + if (home_tile(menu, "##hide-hud", "HIDE HUD", on_off(debug.game_ui_hidden), size, debug.available && debug.ui_available && callbacks.queue_debug, + debug.game_ui_hidden, false, "Keep the view clear for riding and captures.") == 1) + request(DebugAction::set_game_ui_hidden, !debug.game_ui_hidden); + place(width, 4); + if (home_tile(menu, "##first-person", "FIRST PERSON", on_off(debug.first_person), size, debug.available && callbacks.queue_debug, + debug.first_person, false, "See it through your skater's eyes.") == 1) + request(DebugAction::set_first_person, !debug.first_person); + ImGui::Dummy(ImVec2(0, gap - ImGui::GetStyle().ItemSpacing.y)); + + if (home_tile(menu, "##freecam", "FREECAM", on_off(debug.free_camera), size, debug.camera_available && callbacks.queue_debug, + debug.free_camera, false, "Leave your skater and fly the camera.") == 1) + request(DebugAction::set_free_camera, !debug.free_camera); + place(width, 1); + const bool flying = drone::is_enabled() && debug.free_camera; + if (home_tile(menu, "##drone", "DRONE", flying ? "FLYING" : "FLY IT", size, debug.camera_available && callbacks.queue_console_command, flying, + false, "Take off with the FPV drone, or land it. Its settings are in Addons > Drone.") == 1) { + if (flying) quietly(callbacks, "dronemode 0"), quietly(callbacks, "freecam 0"); + else quietly(callbacks, "freecam 1"), quietly(callbacks, "dronemode 1"); + } + place(width, 2); + const auto& bb = model.board_break; + if (home_tile(menu, "##board-break", "BOARD BREAK", on_off(bb.enabled), size, bb.available && callbacks.queue_console_command, bb.enabled, + false, "Slams snap your board. Its deck and settings are in Addons > Board Break.") == 1) + quietly(callbacks, bb.enabled ? "boardbreak 0" : "boardbreak 1"); + place(width, 3); + const auto& rash = model.road_rash; + if (home_tile(menu, "##road-rash", "ROAD RASH", on_off(rash.enabled), size, rash.available && callbacks.queue_console_command, rash.enabled, + false, "Bails leave marks that build up: dust, scrapes, bruises, wounds.") == 1) + quietly(callbacks, rash.enabled ? "roadrash 0" : "roadrash 1"); + place(width, 4); + const auto& meat = model.hall_of_meat; + if (home_tile(menu, "##hall-of-meat", "HALL OF MEAT", on_off(meat.enabled), size, meat.available && callbacks.queue_console_command, + meat.enabled, false, "Your bails show the bones they hurt and score a Meat card.") == 1) + quietly(callbacks, meat.enabled ? "hallofmeat 0" : "hallofmeat 1"); + } + ImGui::Dummy(ImVec2(0, px(18))); + + // ---- the rest of the menu + heading(menu, "GO TO"); + { + constexpr int columns = 5; + const float width = grid(columns); + const ImVec2 size(width, px(96)); + const auto& mp = model.multiplayer; + const std::string session = model.steam_offline ? "Offline: no multiplayer" + : mp.active ? (mp.lobby_name.empty() ? std::string("In a session") : mp.lobby_name) + " - " + std::to_string(mp.players) + " / " + + std::to_string(mp.capacity) + : "Servers, hosting, voice"; + if (home_tile(menu, "##go-online", "ONLINE", session, ImVec2(width * 2 + gap, size.y), !model.steam_offline, mp.active, false, + "Join a server or a friend's lobby, host your own, voice chat.", false) == 1) + open_page(menu, page_online); + place(width, 2); + if (home_tile(menu, "##go-map", "MAP", "Travel, parks, time and weather", size, true, false, false, "Pick a map and load it.", false) == 1) + open_page(menu, page_map); + place(width, 3); + if (home_tile(menu, "##go-addons", "ADDONS", "Drone, Board Break, grind exits, music", size, true, false, false, + "What ReSkate adds to skate.", false) == 1) + open_page(menu, page_addons, addon_all); + place(width, 4); + if (home_tile(menu, "##go-music", "MUSIC", "Now playing, queue, sources", size, true, false, false, "Your music in the game's player.", false) == 1) + open_page(menu, page_addons, addon_music); + ImGui::Dummy(ImVec2(0, gap - ImGui::GetStyle().ItemSpacing.y)); + if (home_tile(menu, "##go-skater", "SKATER", "Freecam, first person, cheats", size, true, false, false, "The free camera, first person, noclip and the boosts.", false) == 1) + open_page(menu, page_skater); + place(width, 1); + if (home_tile(menu, "##go-sandbox", "SANDBOX", "Tuning, practice, HUD", size, true, false, false, "Tune the game's feel and drill your lines.", false) == 1) + open_page(menu, page_sandbox); + place(width, 2); + if (home_tile(menu, "##go-build", "BUILD", "Park editor, park mods", size, true, false, false, "Make the park yours.", false) == 1) + open_page(menu, page_build); + place(width, 3); + if (home_tile(menu, "##go-mods", "MODS", "Yours and the official ones", size, true, false, false, "The mods the game has loaded.", false) == 1) + open_page(menu, page_mods); + place(width, 4); + if (home_tile(menu, "##go-settings", "SETTINGS", "Controls, screen, performance", size, true, false, false, + "Your binds, how the menu looks, the performance HUD.", false) == 1) + open_page(menu, page_settings); + } + ImGui::EndChild(); +} +} diff --git a/Extension/UI/Overlay/skate_menu_internal.h b/Extension/UI/Overlay/skate_menu_internal.h index 56e39104..780af278 100644 --- a/Extension/UI/Overlay/skate_menu_internal.h +++ b/Extension/UI/Overlay/skate_menu_internal.h @@ -2,6 +2,22 @@ #include "skate_menu.h" #include #include +#include + +namespace dingosdk::overlay::detail { +// The game's pause menu Multiplayer pages (hub_page.cpp), drawn into the full screen menu's ONLINE +// page: in `a`..`z` of `draw`, their own focus taking the D-pad, A and LT / RT. And whether one of +// their text boxes has the keyboard. +void draw_online_embedded(ImDrawList* draw, ImVec2 a, ImVec2 z, const Model& model, const CallbacksV3& callbacks, bool input); +bool online_embedded_typing(); +} + +namespace dingosdk::overlay { +// The map grid's pictures (menu_map_pictures.cpp): asked for with the levels shown, then each +// level's square, once it is on the GPU. +void want_menu_map_pictures(std::vector levels); +bool menu_map_picture(const std::string& asset, ImTextureID& texture, ImVec2& uv0, ImVec2& uv1); +} namespace dingosdk::overlay::menu { // The menu chrome is laid out in pixels; px() applies the user's menu scale to @@ -34,6 +50,39 @@ void warn(const char* text); bool choice(SkateMenu& menu, const char* id, int& selected, std::initializer_list options, bool enabled = true, float width = 0.0f, std::initializer_list tooltips = {}); void tag(SkateMenu& menu, const char* text, ImU32 colour); +// Whether the menu is drawing full screen this frame: its shared controls (toggle_row, choice, the +// sliders, cards) draw for a controller then. console_hint is what the focused one does. +bool console_style(); +const std::string& console_hint(); +// For a full screen control drawn by hand: left / right while it has the focus (-1, 0, 1), which +// then never moves the focus; and the line the bottom bar shows for it. +int console_step(bool focused); +void set_console_hint(const char* hint); +// The step the next full screen slider takes (left / right), in place of a fortieth of its range: +// for a value with no sensible range (nudged where it is), or one that moves in whole units. +void set_console_step(double step); +// A row of buttons. In a window, ImGui's own side by side; full screen, equal tiles across the +// row, the pause menu's look (`on` draws one as chosen). Returns the one pressed, or -1. +struct RowButton { + const char* label; + bool enabled = true; + bool on = false; + const char* hint = nullptr; +}; +int button_row(SkateMenu& menu, const char* id, std::initializer_list buttons); +// One of a list, under a label. In a window, a drop-down after a field() label; full screen, one +// row: the label, then "< option >" that left / right change. With `pressed`, A on the row sets it +// (for a choice applied by A, such as a park layout) instead of stepping on. +bool option_row(SkateMenu& menu, const char* label, const char* hint, int& selected, const std::vector& options, + bool enabled = true, bool* pressed = nullptr); +// Full screen: one entry of a list (a song) as a tall row, `side` at its right end; true on A. +bool list_row(SkateMenu& menu, const char* id, const std::string& text, const std::string& side, bool current, bool enabled = true); +// ImGui's sliders with a number box beside them for an exact value (none with NoInput, which +// picks from named steps). Same arguments; IsItemActive / IsItemDeactivatedAfterEdit afterwards +// cover the slider and the box together. +bool slider_float(const char* id, float* value, float low, float high, const char* format = "%.3f", ImGuiSliderFlags flags = 0); +bool slider_int(const char* id, int* value, int low, int high, const char* format = "%d", ImGuiSliderFlags flags = 0); +bool slider_double(const char* id, double* value, double low, double high, const char* format = "%.3f", ImGuiSliderFlags flags = 0); bool primary_button(SkateMenu& menu, const char* label, bool enabled = true); // Height left for a tab's body, keeping room for the feedback line below it. float page_body_height(const SkateMenu& menu); @@ -44,7 +93,7 @@ void send_console(SkateMenu& menu, const CallbacksV3& callbacks, const std::stri void board_break_deck_card(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks); void debug_request(SkateMenu& menu, const CallbacksV3& callbacks, DebugRequest request); // HOST shows the host form until a session exists, then that session (SESSION). -inline constexpr int multiplayer_session_tab = 1, multiplayer_voice_tab = 2, multiplayer_bans_tab = 3; +inline constexpr int multiplayer_session_tab = 1, multiplayer_voice_tab = 2, multiplayer_bans_tab = 3, multiplayer_chat_tab = 4; void multiplayer_display_settings(SkateMenu&, const Model&); void special_page(SkateMenu&, const Model&, const CallbacksV3&); void multiplayer_network_page(SkateMenu&, const Model&, const CallbacksV3&); @@ -53,7 +102,6 @@ void progression_page(SkateMenu&, const Model&, const CallbacksV3&); void multiplayer_page(SkateMenu&, const Model&, const CallbacksV3&); // Sidebar pages. void map_page(SkateMenu&, const Model&, const CallbacksV3&); -void world_page(SkateMenu&, const Model&, const CallbacksV3&); void build_page(SkateMenu&, const Model&, const CallbacksV3&); void skater_page(SkateMenu&, const Model&, const CallbacksV3&); void settings_page(SkateMenu&, const Model&, const CallbacksV3&); @@ -64,7 +112,35 @@ void developer_page(SkateMenu&, const Model&, const CallbacksV3&); void music_page(SkateMenu&, const Model&, const CallbacksV3&); // The FPV drone (drone_page.cpp). void drone_page(SkateMenu&, const Model&, const CallbacksV3&); -// Extension/Trainer/trainer_page.cpp +// Extension/Trainer/trainer_page.cpp: the SANDBOX page (once the Trainer). void trainer_page(SkateMenu&, const Model&, const CallbacksV3&); bool trainer_page_wanted(); +// Skater > CHEATS: noclip, No Bail and the boosts (skate_menu_skater.cpp). +void cheats_tab(SkateMenu&, const Model&, const CallbacksV3&); +// The addons' own pages (skate_menu_skater.cpp), and the ADDONS page itself (skate_menu_addons.cpp). +void board_break_addon(SkateMenu&, const Model&, const CallbacksV3&); +void road_rash_addon(SkateMenu&, const Model&, const CallbacksV3&); +void hall_of_meat_addon(SkateMenu&, const Model&, const CallbacksV3&); +void grind_exits_addon(SkateMenu&, const Model&, const CallbacksV3&); +void addons_page(SkateMenu&, const Model&, const CallbacksV3&); +// The time of day the menu shows: the one last asked for while the game is still changing to it +// (skate_menu_world.cpp). Setting it from a menu control goes through set_time_of_day, `quiet` +// leaving the game's reply off the page. +int time_of_day_shown(const Model&); +void set_time_of_day(SkateMenu&, const Model&, const CallbacksV3&, int next, bool quiet); +// The fullscreen menu's first page: a tile for each part of the menu (skate_menu_addons.cpp). +void home_page(SkateMenu&, const Model&, const CallbacksV3&); +// Shuffle, on the Music addon (skate_menu_settings.cpp). +void music_playback_card(SkateMenu&); +// Whether the menu fills the screen (Settings > Screen > Menu style), and changing it: saved +// with the profile (`ui fullscreen`), shown at once. +bool menu_fullscreen(const SkateMenu&, const Model&); +void set_menu_fullscreen(SkateMenu&, const CallbacksV3&, bool fullscreen); +// The pages, for the shells and the home tiles (skate_menu.cpp). +enum Page { + page_home, page_map, page_build, page_skater, page_sandbox, page_addons, page_online, page_progress, page_mods, + page_settings, page_developer, page_count +}; +enum Addon { addon_all, addon_drone, addon_board_break, addon_road_rash, addon_hall_of_meat, addon_grind_exits, addon_music, addon_count }; +void open_page(SkateMenu&, int page, int addon = -1); } diff --git a/Extension/UI/Overlay/skate_menu_settings.cpp b/Extension/UI/Overlay/skate_menu_settings.cpp index 69ea8ddf..b24491e0 100644 --- a/Extension/UI/Overlay/skate_menu_settings.cpp +++ b/Extension/UI/Overlay/skate_menu_settings.cpp @@ -1,5 +1,7 @@ #include "Engine/Core/Platform/launcher_support.h" #include "skate_menu_internal.h" +#include "skate_style.h" +#include "Extension/UI/skate_theme.h" #include "Engine/Core/Profiling/profiler.h" #include "Extension/Music/local_music_playback.h" #include "Extension/Profile/local_profile_runtime.h" @@ -9,7 +11,8 @@ // The SETTINGS and DEVELOPER pages. namespace dingosdk::overlay::menu { -void ui_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { +using namespace theme; +void music_playback_card(SkateMenu& menu) { begin_card(menu, "music-playback", "MUSIC PLAYBACK"); bool shuffle = dingosdk::profile_runtime::music_shuffle_enabled(); if (toggle_row(menu, "Shuffle playlists", "Off plays songs in playlist order. On shuffles.", shuffle, @@ -18,7 +21,10 @@ void ui_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) dingosdk::profile_runtime::set_local_preference("MusicShuffle", shuffle); } end_card(); +} +// Settings > SCREEN: what shows, and how this menu looks. +void screen_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { begin_card(menu, "on-screen", "ON SCREEN"); bool hidden = model.debug.game_ui_hidden; if (toggle_row(menu, "Hide game UI", "Keep the view clear for riding and captures.", hidden, @@ -28,29 +34,44 @@ void ui_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) note(back.c_str()); end_card(); - begin_card(menu, "menu-scale", "MENU SIZE"); + // The size of the style this is drawn in: the window and full screen keep their own. + const bool full = console_style(); + const char* command = full ? "ui scalefull " : "ui scale "; + begin_card(menu, "menu-scale", "MENU SIZE", full ? "Full screen" : "Window"); // Drag locally for an immediate preview, then save once on release: the // saved value only returns through the model on a later frame. - if (!menu.scale_editing) menu.scale = model.menu_scale; - field(menu, "ReSkate menu size"); + if (!menu.scale_editing) menu.scale = full ? model.menu_scale_fullscreen : model.menu_scale; + field(menu, full ? "Full screen menu size" : "Window menu size"); const bool resettable = menu.scale != default_menu_scale; const float reset = ImGui::CalcTextSize("Reset").x + ImGui::GetStyle().FramePadding.x * 2; ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - reset - ImGui::GetStyle().ItemSpacing.x); - ImGui::SliderFloat("##menu-scale", &menu.scale, min_menu_scale, max_menu_scale, "%.2fx", + ::dingosdk::overlay::menu::slider_float("##menu-scale", &menu.scale, min_menu_scale, max_menu_scale, "%.2fx", ImGuiSliderFlags_AlwaysClamp); menu.scale_editing = ImGui::IsItemActive(); if (ImGui::IsItemDeactivatedAfterEdit()) - send_console(menu, callbacks, "ui scale " + std::to_string(menu.scale)); - ImGui::SameLine(); - ImGui::BeginDisabled(!resettable); - if (ImGui::Button("Reset")) { + send_console(menu, callbacks, command + std::to_string(menu.scale)); + if (!full) ImGui::SameLine(); + if (button_row(menu, "scale-reset", {{full ? "RESET SIZE" : "Reset", resettable}}) == 0) { menu.scale = default_menu_scale; - send_console(menu, callbacks, "ui scale " + std::to_string(default_menu_scale)); + send_console(menu, callbacks, command + std::to_string(default_menu_scale)); } - ImGui::EndDisabled(); - note("Scales this menu and its text. Saved with your profile."); + note(full ? "Scales the full screen menu and its text; the window keeps its own size. Saved with your profile." + : "Scales this window and its text; the full screen menu keeps its own size. Saved with your profile."); end_card(); + begin_card(menu, "menu-style", "MENU STYLE"); + int style = menu_fullscreen(menu, model) ? 1 : 0; + if (choice(menu, "menu-style", style, {"Window", "Full screen"}, callbacks.queue_console_command != nullptr, 0.0f, + {"A window over the game you can move and resize: made for a mouse.", + "Fills the screen, with bigger rows and button prompts: made for a controller."})) + set_menu_fullscreen(menu, callbacks, style == 1); + note("Also the button beside the menu's close button, F11, or View on a controller. Saved with your profile."); + end_card(); + graphics_page(menu, model, callbacks); +} + +// Settings > PERFORMANCE. +void performance_page(SkateMenu& menu, const Model&, const CallbacksV3& callbacks) { begin_card(menu, "performance", "PERFORMANCE"); // The profiler is thread-safe: its state is read here directly, not through the model. bool hud = dingosdk::profiler::hud(); @@ -63,11 +84,57 @@ void ui_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) dingosdk::profiler::set_window(window); note("Console: perf, perf sample [seconds] [client|present|thread id], perf report, perf status."); end_card(); - if (!model.steam_offline) multiplayer_display_settings(menu, model); +} + +namespace { +// The binds in the groups they are looked for in. An action is its number in `bind` (1..9), or +// 10 + its place in action_binds. +struct BindGroup { + const char* title; + std::initializer_list actions; +}; +constexpr int action_bind(std::string_view key) { + for (std::size_t i = 0; i < action_binds.size(); ++i) + if (action_binds[i].key == key) return 10 + static_cast(i); + return 0; +} +const std::array bind_groups{{ + {"GENERAL", {action_bind("reskate_menu"), action_bind("hide_hud"), action_bind("challenges"), action_bind("time_of_day")}}, + {"CAMERA", {2, 1, 6, action_bind("first_person")}}, + {"CHEATS AND BOOSTS", {3, action_bind("no_bail"), 4, 5, 7}}, + {"ONLINE", {action_bind("voice_chat"), action_bind("nametags"), action_bind("nametag_dots"), 8, 9}}, +}}; +const char* bind_name(int action) { + switch (action) { + case 1: return "Freecam Controller"; + case 2: return "Freecam"; + case 3: return "Noclip"; + case 4: return "Forward Boost"; + case 5: return "Up Boost"; + case 6: return "TP to Freecam"; + case 7: return "Off-board Up Boost"; + case 8: return "Vote yes"; + case 9: return "Vote no"; + default: return action >= 10 ? action_binds[static_cast(action - 10)].label.data() : ""; + } +} +std::uint32_t bind_combo(const ControllerBindingsModel& b, int action) { + switch (action) { + case 1: return b.freecam_controller_combo; + case 2: return b.freecam_combo; + case 3: return b.noclip_combo; + case 4: return b.forward_velocity_combo; + case 5: return b.up_velocity_combo; + case 6: return b.tp_to_freecam_combo; + case 7: return b.offboard_up_velocity_combo; + case 8: return b.vote_yes_combo; + case 9: return b.vote_no_combo; + default: return action >= 10 ? b.action_combos[static_cast(action - 10)] : 0; + } +} } void binds_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - begin_card(menu, "controller-binds", "ACTION BINDS"); const bool available = model.bindings.available && callbacks.queue_console_command; const auto save = [&](int action, std::uint32_t combo) { std::array result{}; @@ -89,81 +156,113 @@ void binds_page(SkateMenu& menu, const Model& model, const CallbacksV3& callback save(action, *combo); } } - ImGui::BeginDisabled(!available); - if (ImGui::BeginTable("controller-binds", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { - ImGui::TableSetupColumn("Action", ImGuiTableColumnFlags_WidthFixed, px(130)); - ImGui::TableSetupColumn("Controller combo / key", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("##controls", ImGuiTableColumnFlags_WidthFixed, px(170)); - ImGui::TableHeadersRow(); - const auto row = [&](int action, const char* name, std::uint32_t combo) { - ImGui::PushID(action); - ImGui::TableNextRow(); ImGui::TableNextColumn(); - ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(name); - ImGui::TableNextColumn(); ImGui::AlignTextToFramePadding(); - const auto label = menu.recording_bind == action ? "Recording..." : controller_combo_label(combo, controller.style); - ImGui::TextWrapped("%s", label.c_str()); - ImGui::TableNextColumn(); - if (menu.recording_bind == action) { - if (ImGui::Button("Cancel", ImVec2(-1, 0))) menu.recording_bind = 0; - } else { - ImGui::BeginDisabled(menu.recording_bind != 0); - if (ImGui::Button("Record", ImVec2(px(90), 0))) { - menu.bind_capture = {}; - menu.recording_bind = action; - menu.bind_capture_until = ImGui::GetTime() + 30; - menu.feedback.clear(); - } - ImGui::SameLine(); ImGui::BeginDisabled(!combo); - if (ImGui::Button("Clear", ImVec2(-1, 0))) save(action, 0); - ImGui::EndDisabled(); - ImGui::EndDisabled(); - } - ImGui::PopID(); - }; - row(1, "Freecam Controller", model.bindings.freecam_controller_combo); - row(2, "Freecam", model.bindings.freecam_combo); - row(3, "Noclip", model.bindings.noclip_combo); - row(4, "Forward Boost", model.bindings.forward_velocity_combo); - row(5, "Up Boost", model.bindings.up_velocity_combo); - row(6, "TP to Freecam", model.bindings.tp_to_freecam_combo); - row(7, "Off-board Up Boost", model.bindings.offboard_up_velocity_combo); - row(8, "Vote yes", model.bindings.vote_yes_combo); - row(9, "Vote no", model.bindings.vote_no_combo); - for (std::size_t i = 0; i < action_binds.size(); ++i) - row(10 + static_cast(i), action_binds[i].label.data(), model.bindings.action_combos[i]); - ImGui::EndTable(); - } - ImGui::EndDisabled(); + const auto record = [&](int action) { + menu.bind_capture = {}; + menu.recording_bind = action; + menu.bind_capture_until = ImGui::GetTime() + (console_style() ? 10 : 30); + menu.feedback.clear(); + }; + const bool console = console_style(); if (menu.recording_bind) { warn(!menu.bind_capture.ready ? "Release controller buttons and keyboard keys first." : + console ? "Hold up to five buttons together, then let go. Nothing for 10 seconds cancels." : "Hold up to five keys together, then release them. A-Z, 0-9, Space, F1-F12, Ctrl, Shift and Alt work; controller combos work too."); - } else { + } + for (const auto& group : bind_groups) { + if (std::string_view(group.title) == "ONLINE" && model.steam_offline) continue; + begin_card(menu, group.title, group.title); + ImGui::PushID(group.title); + if (console) { + // Full screen: a row a bind, its combo at the right end. A records, Y clears. + auto* draw = ImGui::GetWindowDrawList(); + for (const int action : group.actions) { + const std::uint32_t combo = bind_combo(model.bindings, action); + const bool recording = menu.recording_bind == action; + ImGui::PushID(action); + const auto at = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x, height = px(54); + ImGui::BeginDisabled(!available || (menu.recording_bind && !recording)); + const bool pressed = ImGui::InvisibleButton("##bind", ImVec2(width, height), ImGuiButtonFlags_EnableNav); + ImGui::EndDisabled(); + const bool focused = ImGui::IsItemFocused() && ImGui::GetIO().NavVisible, hovered = ImGui::IsItemHovered(); + if (pressed && !menu.recording_bind) record(action); + if (focused && !menu.recording_bind && combo && ImGui::IsKeyPressed(ImGuiKey_GamepadFaceUp, false)) save(action, 0); + if (focused || hovered) + set_console_hint(recording ? "Hold the buttons together, then let go." + : combo ? "A records a new bind. Y clears this one." : "A records a bind."); + const ImVec2 end(at.x + width, at.y + height); + const bool lit = focused || hovered; + skate_theme::rough_rect(draw, at, end, recording ? paper : lit ? blue : IM_COL32(26, 26, 26, 235), + static_cast(ImGui::GetItemID()) & 0xfff, px(1)); + const ImU32 text = recording ? skate_theme::black : available ? paper : muted; + const auto name = menu.bold->CalcTextSizeA(px(17), FLT_MAX, 0, bind_name(action)); + draw->AddText(menu.bold, px(17), ImVec2(at.x + px(18), at.y + (height - name.y) * .5f), text, bind_name(action)); + const auto label = recording ? std::string(menu.bind_capture.ready ? "Press the buttons..." : "Let go first...") + : controller_combo_label(combo, controller.style); + const auto size = menu.bold->CalcTextSizeA(px(17), FLT_MAX, 0, label.c_str()); + draw->AddText(menu.bold, px(17), ImVec2(end.x - px(24) - size.x, at.y + (height - size.y) * .5f), + recording ? skate_theme::black : lit ? paper : combo ? blue : muted, label.c_str()); + ImGui::PopID(); + } + } else { + ImGui::BeginDisabled(!available); + if (ImGui::BeginTable("binds", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { + ImGui::TableSetupColumn("Action", ImGuiTableColumnFlags_WidthFixed, px(150)); + ImGui::TableSetupColumn("Controller combo / key", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("##controls", ImGuiTableColumnFlags_WidthFixed, px(170)); + for (const int action : group.actions) { + const std::uint32_t combo = bind_combo(model.bindings, action); + ImGui::PushID(action); + ImGui::TableNextRow(); ImGui::TableNextColumn(); + ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(bind_name(action)); + ImGui::TableNextColumn(); ImGui::AlignTextToFramePadding(); + const auto label = menu.recording_bind == action ? "Recording..." : controller_combo_label(combo, controller.style); + ImGui::TextWrapped("%s", label.c_str()); + ImGui::TableNextColumn(); + if (menu.recording_bind == action) { + if (ImGui::Button("Cancel", ImVec2(-1, 0))) menu.recording_bind = 0; + } else { + ImGui::BeginDisabled(menu.recording_bind != 0); + if (ImGui::Button("Record", ImVec2(px(90), 0))) record(action); + ImGui::SameLine(); ImGui::BeginDisabled(!combo); + if (ImGui::Button("Clear", ImVec2(-1, 0))) save(action, 0); + ImGui::EndDisabled(); + ImGui::EndDisabled(); + } + ImGui::PopID(); + } + ImGui::EndTable(); + } + ImGui::EndDisabled(); + } + ImGui::PopID(); + end_card(); + } + if (!menu.recording_bind) { note(("Record a key, keyboard chord or controller combo, such as Ctrl + F5 or " + controller_combo_label(0x300, controller.style) + ". Freecam, Freecam Controller, and Noclip toggle; boosts add velocity once per press. Off-board Up Boost also works while falling or gliding; release and press again to repeat. On-board and off-board boosts can share a combo. Give toggles different combos.").c_str()); note("Saved to your profile."); if (!model.bindings.status.empty()) note(model.bindings.status.c_str()); if (!model.bindings.available) warn("Waiting for the local profile."); } - if (!model.steam_offline) note("The push-to-talk button is set in Multiplayer > Voice."); - end_card(); + if (!model.steam_offline) note("The push-to-talk button is set in Online > Voice."); } void settings_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - menu.settings_tab = std::min(menu.settings_tab, 2); // Mods moved to its own page - category_tabs(menu, menu.settings_tab, {"CONTROLS", "INTERFACE", "POST FX"}, "settings-tabs"); + category_tabs(menu, menu.settings_tab, {"CONTROLS", "SCREEN", "PERFORMANCE"}, "settings-tabs"); ImGui::PushID(menu.settings_tab); - ImGui::BeginChild("settings-tab", ImVec2(0, page_body_height(menu))); + ImGui::BeginChild("settings-tab", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); switch (menu.settings_tab) { case 0: binds_page(menu, model, callbacks); break; - case 1: ui_page(menu, model, callbacks); break; - case 2: graphics_page(menu, model, callbacks); break; + case 1: screen_page(menu, model, callbacks); break; + case 2: performance_page(menu, model, callbacks); break; } ImGui::EndChild(); ImGui::PopID(); } void developer_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - ImGui::BeginChild("developer", ImVec2(0, page_body_height(menu))); + ImGui::BeginChild("developer", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); multiplayer_network_page(menu, model, callbacks); ImGui::EndChild(); } diff --git a/Extension/UI/Overlay/skate_menu_skater.cpp b/Extension/UI/Overlay/skate_menu_skater.cpp index 9d8c0696..4ecd5e0d 100644 --- a/Extension/UI/Overlay/skate_menu_skater.cpp +++ b/Extension/UI/Overlay/skate_menu_skater.cpp @@ -39,7 +39,7 @@ void camera_controls(SkateMenu& menu, const Model& model, const CallbacksV3& cal const float reset = trailing_width("Default"); ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - reset - ImGui::GetStyle().ItemSpacing.x); ImGui::BeginDisabled(!callbacks.queue_debug); - if (ImGui::SliderInt("##free-camera-fov", &free_fov, 40, 120, custom ? "%d degrees" : "%d degrees (game)", + if (::dingosdk::overlay::menu::slider_int("##free-camera-fov", &free_fov, 40, 120, custom ? "%d degrees" : "%d degrees (game)", ImGuiSliderFlags_AlwaysClamp)) debug_request(menu, callbacks, {DebugAction::set_free_camera_fov, false, static_cast(free_fov)}); ImGui::SameLine(); @@ -56,13 +56,16 @@ void camera_controls(SkateMenu& menu, const Model& model, const CallbacksV3& cal int selected = 3; for (int i = 0; i < static_cast(speeds.size()); ++i) if (std::abs(debug.camera_speed - speeds[i]) < .01f) selected = i; - if (ImGui::SliderInt("##flight-speed", &selected, 0, 6, labels[selected], ImGuiSliderFlags_NoInput)) + if (::dingosdk::overlay::menu::slider_int("##flight-speed", &selected, 0, 6, labels[selected], ImGuiSliderFlags_NoInput)) debug_request(menu, callbacks, {DebugAction::set_camera_speed, false, speeds[selected]}); ImGui::EndDisabled(); if (debug.free_camera) note("Close the menu to fly. WASD / Q E move; hold the right mouse button to look."); if (!debug.camera_available) warn(debug.camera_unavailable.c_str()); end_card(); +} +void first_person_controls(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { + const auto& debug = model.debug; begin_card(menu, "first-person", "FIRST PERSON"); bool first_person = debug.first_person; if (toggle_row(menu, "First person", "See the world through the skater's eyes.", first_person, @@ -71,7 +74,7 @@ void camera_controls(SkateMenu& menu, const Model& model, const CallbacksV3& cal field(menu, "Field of view"); int fov = static_cast(std::lround(debug.first_person_fov > 0 ? debug.first_person_fov : debug.camera_fov)); ImGui::BeginDisabled(!debug.first_person || !callbacks.queue_debug); - if (ImGui::SliderInt("##first-person-fov", &fov, 40, 120, "%d degrees", ImGuiSliderFlags_AlwaysClamp)) + if (::dingosdk::overlay::menu::slider_int("##first-person-fov", &fov, 40, 120, "%d degrees", ImGuiSliderFlags_AlwaysClamp)) debug_request(menu, callbacks, {DebugAction::set_first_person_fov, false, static_cast(fov)}); ImGui::EndDisabled(); { @@ -83,7 +86,7 @@ void camera_controls(SkateMenu& menu, const Model& model, const CallbacksV3& cal ImGui::BeginDisabled(!settings.stabilize || !can_edit); const auto percent = [&](const char* label, const char* id, float value, DebugAction action) { field(menu, label); - if (ImGui::SliderFloat(id, &value, 0, dingosdk::first_person::strength_limit, "%.0f%%", ImGuiSliderFlags_AlwaysClamp)) + if (::dingosdk::overlay::menu::slider_float(id, &value, 0, dingosdk::first_person::strength_limit, "%.0f%%", ImGuiSliderFlags_AlwaysClamp)) debug_request(menu, callbacks, {action, false, value}); }; percent("Smoothing", "##fp-smoothing", settings.smoothing, DebugAction::set_first_person_smoothing); @@ -116,7 +119,7 @@ void camera_controls(SkateMenu& menu, const Model& model, const CallbacksV3& cal const auto slider = [&](const char* label, float value, float low, float high, const char* format, DebugAction action) { field(menu, label); ImGui::PushID(label); - if (ImGui::SliderFloat("##arm-value", &value, low, high, format, ImGuiSliderFlags_AlwaysClamp)) + if (::dingosdk::overlay::menu::slider_float("##arm-value", &value, low, high, format, ImGuiSliderFlags_AlwaysClamp)) debug_request(menu, callbacks, {action, false, value}); ImGui::PopID(); }; @@ -172,7 +175,7 @@ void board_break_settings_card(SkateMenu& menu, const Model& model, const Callba int shown = edited[slot] >= 0 ? edited[slot] : value; field(menu, label, hint); ImGui::BeginDisabled(!console); - if (ImGui::SliderInt((std::string("##") + id).c_str(), &shown, low, high, "%d%%", ImGuiSliderFlags_AlwaysClamp)) { + if (::dingosdk::overlay::menu::slider_int((std::string("##") + id).c_str(), &shown, low, high, "%d%%", ImGuiSliderFlags_AlwaysClamp)) { edited[slot] = std::clamp((shown + step / 2) / step * step, low, high); edited_at[slot] = ImGui::GetTime(); } @@ -187,15 +190,21 @@ void board_break_settings_card(SkateMenu& menu, const Model& model, const Callba "sensitivity"); percent("bb-joined", "Grip tape holds", "How many breaks the grip tape holds together instead of a piece coming off.", bb.joined, 0, 100, 5, "joined"); percent("bb-wear-rate", "Wear speed", "How fast boards wear. 0% keeps a board as it is.", bb.wear_rate, 0, 500, 10, "wearrate"); - ImGui::BeginDisabled(!console || !bb.has_deck); - if (ImGui::Button("New board", ImVec2(-FLT_MIN, 0))) send_console(menu, callbacks, "boardbreak worn 0"); - ImGui::EndDisabled(); + if (console_style()) { + if (button_row(menu, "bb-new", {{"NEW BOARD", console && bb.has_deck, false, "Your board as good as new: no wear."}}) == 0) + send_console(menu, callbacks, "boardbreak worn 0"); + } else { + ImGui::BeginDisabled(!console || !bb.has_deck); + if (ImGui::Button("New board", ImVec2(-FLT_MIN, 0))) send_console(menu, callbacks, "boardbreak worn 0"); + ImGui::EndDisabled(); + } end_card(); } -void movement_controls(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { +// Skater > CHEATS: flying through things, never bailing, and the boosts. +void cheats_tab(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { const auto& debug = model.debug; - begin_card(menu, "movement", "FLIGHT & BAILS"); + begin_card(menu, "cheats", "NOCLIP AND NO BAIL"); bool noclip = debug.noclip; if (toggle_row(menu, "Noclip", "Fly with the normal player camera. Includes No Bail; uses the Freecam flight speed.", noclip, (debug.noclip_available || debug.noclip) && callbacks.queue_debug)) @@ -210,16 +219,31 @@ void movement_controls(SkateMenu& menu, const Model& model, const CallbacksV3& c if (toggle_row(menu, "No Bail", bail_help, no_bail, (debug.no_bail_available || debug.no_bail) && callbacks.queue_debug)) debug_request(menu, callbacks, {DebugAction::set_no_bail, no_bail}); - bool hall_of_meat = model.hall_of_meat.enabled; - // No Bail (and noclip, which includes it) stops the bails Hall of Meat scores. - const bool bails_off = debug.no_bail || debug.no_bail_active; - const char* meat_help = hall_of_meat && bails_off - ? "On, but No Bail is stopping your bails, so nothing shows. Turn No Bail (and noclip) off to use it." - : bails_off - ? "Your bails show the bones they hurt and score a Meat card. Needs No Bail (and noclip) off." - : "Your bails show the bones they hurt and score a Meat card. A break slows the game in single player."; - if (toggle_row(menu, "Hall of Meat", meat_help, hall_of_meat, model.hall_of_meat.available && callbacks.queue_console_command)) - send_console(menu, callbacks, hall_of_meat ? "hallofmeat 1" : "hallofmeat 0"); + end_card(); + + begin_card(menu, "boosts", "BOOSTS", "Buttons are set in Settings > Controls"); + ImGui::BeginDisabled(!callbacks.queue_debug); + field(menu, "Forward boost"); + float forward_velocity = debug.forward_velocity_speed; + if (slider_float("##forward-velocity", &forward_velocity, 1.0f, 300.0f, "+%.1f", ImGuiSliderFlags_AlwaysClamp)) + debug_request(menu, callbacks, {DebugAction::set_forward_velocity_speed, false, forward_velocity}); + field(menu, "Up boost"); + float up_velocity = debug.up_velocity_speed; + if (slider_float("##up-velocity", &up_velocity, 1.0f, 25.0f, "+%.1f", ImGuiSliderFlags_AlwaysClamp)) + debug_request(menu, callbacks, {DebugAction::set_up_velocity_speed, false, up_velocity}); + field(menu, "Off-board up boost"); + float offboard_up_velocity = debug.offboard_up_velocity_speed; + if (slider_float("##offboard-up-velocity", &offboard_up_velocity, 1.0f, 25.0f, "+%.1f", ImGuiSliderFlags_AlwaysClamp)) + debug_request(menu, callbacks, {DebugAction::set_offboard_up_velocity_speed, false, offboard_up_velocity}); + ImGui::EndDisabled(); + note("Controller: left stick moves, right stick looks, RT / LT rise and fall, click the left stick to boost."); + note("Keyboard: WASD / Q E, Shift to boost. Close the menu to fly."); + end_card(); +} + +// Addons > ROAD RASH: bails leave marks. +void road_rash_addon(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { + begin_card(menu, "road-rash", "ROAD RASH"); bool road_rash = model.road_rash.enabled; const bool rash_console = model.road_rash.available && callbacks.queue_console_command; if (toggle_row(menu, "Road Rash", "Bails leave marks that build up: dust, scrapes, bruises, wounds. Needs the Road Rash item pack.", @@ -229,6 +253,29 @@ void movement_controls(SkateMenu& menu, const Model& model, const CallbacksV3& c if (toggle_row(menu, "Road Rash blood", "Fresh blood on the worst wounds. Off, they are dry and scabbed.", rash_blood, rash_console && model.road_rash.enabled)) send_console(menu, callbacks, rash_blood ? "roadrashblood 1" : "roadrashblood 0"); + end_card(); +} + +// Addons > HALL OF MEAT: bails show the bones they hurt and score a Meat card. +void hall_of_meat_addon(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { + const auto& debug = model.debug; + begin_card(menu, "hall-of-meat", "HALL OF MEAT"); + bool hall_of_meat = model.hall_of_meat.enabled; + // No Bail (and noclip, which includes it) stops the bails Hall of Meat scores. + const bool bails_off = debug.no_bail || debug.no_bail_active; + const char* meat_help = hall_of_meat && bails_off + ? "On, but No Bail is stopping your bails, so nothing shows. Turn No Bail (and noclip) off in Skater > Cheats." + : bails_off + ? "Your bails show the bones they hurt and score a Meat card. Needs No Bail (and noclip) off." + : "Your bails show the bones they hurt and score a Meat card. A break slows the game in single player."; + if (toggle_row(menu, "Hall of Meat", meat_help, hall_of_meat, model.hall_of_meat.available && callbacks.queue_console_command)) + send_console(menu, callbacks, hall_of_meat ? "hallofmeat 1" : "hallofmeat 0"); + end_card(); +} + +// Addons > BOARD BREAK: the switch, the deck in the game, and how boards break, wear and sound. +void board_break_addon(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { + begin_card(menu, "board-break", "BOARD BREAK"); bool board_break = model.board_break.enabled; if (toggle_row(menu, "Board Break", model.board_break.has_deck ? "A hard slam can snap your board: at the nose, in the middle or at the tail. Whole again once you pick it up." @@ -238,8 +285,10 @@ void movement_controls(SkateMenu& menu, const Model& model, const CallbacksV3& c end_card(); board_break_deck_card(menu, model, callbacks); board_break_settings_card(menu, model, callbacks); +} - // Grind exit control (Extension/Skater/grind_exit_control.h). +// Addons > GRIND EXITS: grind exit control (Extension/Skater/grind_exit_control.h). +void grind_exits_addon(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { const auto& grind = model.grind_exit; const bool grind_console = grind.available && callbacks.queue_console_command; begin_card(menu, "grind-exits", "GRIND EXITS", "Choose the side you pop out of a grind using LT/RT"); @@ -267,7 +316,7 @@ void movement_controls(SkateMenu& menu, const Model& model, const CallbacksV3& c int strength = edited >= 0 ? edited : grind.strength; field(menu, "Pop-out strength", "How far to the side a trigger exit pops. 100% is the full exit; lower it for rails next to a wall."); ImGui::BeginDisabled(!grind_console); - if (ImGui::SliderInt("##grind-strength", &strength, 10, 100, "%d%%", ImGuiSliderFlags_AlwaysClamp)) { + if (slider_int("##grind-strength", &strength, 10, 100, "%d%%", ImGuiSliderFlags_AlwaysClamp)) { edited = std::clamp((strength + 2) / 5 * 5, 10, 100); edited_at = ImGui::GetTime(); } @@ -279,33 +328,18 @@ void movement_controls(SkateMenu& menu, const Model& model, const CallbacksV3& c ImGui::EndDisabled(); } end_card(); - - begin_card(menu, "boosts", "BOOSTS", "Buttons are set in Settings > Controls"); - ImGui::BeginDisabled(!callbacks.queue_debug); - field(menu, "Forward boost"); - float forward_velocity = debug.forward_velocity_speed; - if (ImGui::SliderFloat("##forward-velocity", &forward_velocity, 1.0f, 300.0f, "+%.1f", ImGuiSliderFlags_AlwaysClamp)) - debug_request(menu, callbacks, {DebugAction::set_forward_velocity_speed, false, forward_velocity}); - field(menu, "Up boost"); - float up_velocity = debug.up_velocity_speed; - if (ImGui::SliderFloat("##up-velocity", &up_velocity, 1.0f, 25.0f, "+%.1f", ImGuiSliderFlags_AlwaysClamp)) - debug_request(menu, callbacks, {DebugAction::set_up_velocity_speed, false, up_velocity}); - field(menu, "Off-board up boost"); - float offboard_up_velocity = debug.offboard_up_velocity_speed; - if (ImGui::SliderFloat("##offboard-up-velocity", &offboard_up_velocity, 1.0f, 25.0f, "+%.1f", ImGuiSliderFlags_AlwaysClamp)) - debug_request(menu, callbacks, {DebugAction::set_offboard_up_velocity_speed, false, offboard_up_velocity}); - ImGui::EndDisabled(); - note("Controller: left stick moves, right stick looks, RT / LT rise and fall, click the left stick to boost."); - note("Keyboard: WASD / Q E, Shift to boost. Close the menu to fly."); - end_card(); } void skater_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - category_tabs(menu, menu.skater_tab, {"CAMERA", "GAMEPLAY"}, "skater-tabs"); + // (Grind exits are an addon: Addons > Grind Exits.) + category_tabs(menu, menu.skater_tab, {"FREECAM", "FIRST PERSON", "CHEATS"}, "skater-tabs"); ImGui::PushID(menu.skater_tab); - ImGui::BeginChild("skater-tab", ImVec2(0, page_body_height(menu))); - if (menu.skater_tab == 0) camera_controls(menu, model, callbacks); - else movement_controls(menu, model, callbacks); + ImGui::BeginChild("skater-tab", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); + switch (menu.skater_tab) { + case 0: camera_controls(menu, model, callbacks); break; + case 1: first_person_controls(menu, model, callbacks); break; + default: cheats_tab(menu, model, callbacks); break; + } ImGui::EndChild(); ImGui::PopID(); } diff --git a/Extension/UI/Overlay/skate_menu_world.cpp b/Extension/UI/Overlay/skate_menu_world.cpp index acaf7f62..9b18d7cd 100644 --- a/Extension/UI/Overlay/skate_menu_world.cpp +++ b/Extension/UI/Overlay/skate_menu_world.cpp @@ -27,56 +27,123 @@ std::string level_name(const Level& level) { } void level_picker(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - begin_card(menu, "travel", "FIND YOUR SPOT"); std::vector destinations; const Level* root = nullptr; - const Level* selected = nullptr; for (const auto& level : model.levels) { if (same(level.asset, root_asset)) root = &level; - else { + // (Not the splash level: it only hosts the startup screen, as the pause menu's map list has it. + // Nor Tutorial Island: the game's first-time tutorial, not a place to skate.) + else if (dingosdk::native_tools::listed(level) && world_level_name(level.asset) != "Tutorial Island") destinations.push_back(&level); - if (same(level.asset, menu.destination)) selected = &level; - } } std::sort(destinations.begin(), destinations.end(), [](const auto* a, const auto* b) { return level_name(*a) < level_name(*b); }); - field(menu, "Destination"); - if (ImGui::BeginCombo("##destination", selected ? level_name(*selected).c_str() : "Choose a level")) { - for (const auto* level : destinations) { - ImGui::PushID(level->asset.c_str()); - if (ImGui::Selectable(level_name(*level).c_str(), selected == level)) { - selected = level; - menu.destination = level->asset; - menu.feedback.clear(); - } - ImGui::PopID(); - } - if (destinations.empty()) ImGui::TextDisabled("Waiting for levels..."); - ImGui::EndCombo(); + { + std::vector wanted; + for (const auto* level : destinations) wanted.push_back({level->asset, level_name(*level)}); + want_menu_map_pictures(std::move(wanted)); } - ImGui::Dummy(ImVec2(0, px(4))); - const bool ready = root && selected && root->can_load && selected->can_load && model.can_queue_load && callbacks.queue_load; - ImGui::BeginDisabled(!ready); - skate_theme::push_primary_button(); - ImGui::PushFont(menu.heading); - if (ImGui::Button("LET'S SKATE", ImVec2(-1, px(46)))) { + const bool loading = root && root->can_load && model.can_queue_load && callbacks.queue_load; + const auto load = [&](const Level* level) { std::array result{}; - const auto* point = selected->automatic_start_point(); - const bool queued = callbacks.queue_load(callbacks.user, root->asset.c_str(), "", selected->asset.c_str(), + const auto* point = level->automatic_start_point(); + const bool queued = callbacks.queue_load(callbacks.user, root->asset.c_str(), "", level->asset.c_str(), point ? point->c_str() : "", result.data(), result.size()); result.back() = '\0'; - feedback(menu, result[0] ? result.data() : (queued ? "Loading level..." : "Load unavailable.")); + feedback(menu, result[0] ? result.data() : (queued ? ("Loading " + level_name(*level) + "...").c_str() : "Load unavailable.")); + }; + if (!loading) { + const std::string reason = !root ? "Waiting for levels..." : !root->can_load ? root->load_block_reason : model.load_block_reason; + warn(reason.empty() ? "Level loading is unavailable." : reason.c_str()); } - ImGui::PopFont(); - skate_theme::pop_primary_button(); - ImGui::EndDisabled(); - if (!ready) { - const std::string reason = !root ? "Waiting for levels..." : !selected ? "Choose a destination." : - !root->can_load ? root->load_block_reason : !selected->can_load ? selected->load_block_reason : model.load_block_reason; - note(reason.empty() ? "Level loading is unavailable." : reason.c_str()); + + // A window has a keyboard: a box to find a map by name. (Full screen shows them all.) + const bool console = console_style(); + if (!console) { + static std::array search{}; + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputTextWithHint("##map-search", "Search maps...", search.data(), search.size()); + const auto lower = [](std::string text) { + std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return text; + }; + if (const auto words = lower(search.data()); !words.empty()) { + std::erase_if(destinations, [&](const Level* level) { return lower(level_name(*level)).find(words) == std::string::npos; }); + if (destinations.empty()) note("No maps match that search."); + } } - end_card(); + + // A card per map with its picture. In a window a LOAD button under each; full screen the card + // itself loads (A). + auto* draw = ImGui::GetWindowDrawList(); + const auto grid = [&](const char* heading, bool custom) { + std::vector shown; + for (const auto* level : destinations) + if (level->custom == custom) shown.push_back(level); + if (shown.empty()) return; + section(menu, heading); + const float gap = px(12); + const float across = ImGui::GetContentRegionAvail().x; + const int columns = std::max(1, static_cast((across + gap) / (px(console ? 260.0f : 210.0f) + gap))); + const float width = (across - gap * static_cast(columns - 1)) / static_cast(columns); + const float picture = width * 0.58f, height = picture + px(42); + for (std::size_t i = 0; i < shown.size(); ++i) { + const auto* level = shown[i]; + if (i % static_cast(columns)) ImGui::SameLine(0, gap); + ImGui::PushID(level->asset.c_str()); + ImGui::BeginGroup(); + const bool can = loading && level->can_load; + const auto at = ImGui::GetCursorScreenPos(); + bool hovered{}, focused{}; + if (console) { + ImGui::BeginDisabled(!can); + if (ImGui::InvisibleButton("##map", ImVec2(width, height), ImGuiButtonFlags_EnableNav) && can) load(level); + ImGui::EndDisabled(); + hovered = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled); + focused = ImGui::IsItemFocused() && ImGui::GetIO().NavVisible; + } else { + ImGui::Dummy(ImVec2(width, height)); + } + const ImVec2 end(at.x + width, at.y + height); + draw->AddRectFilled(at, end, skate_theme::tile); + ImTextureID texture{}; + ImVec2 uv0, uv1; + if (menu_map_picture(level->asset, texture, uv0, uv1)) { + // The middle band of the square picture, to the card's shape. + const float band = (uv1.y - uv0.y) * (1.0f - picture / width) * 0.5f; + draw->AddImage(texture, at, ImVec2(end.x, at.y + picture), ImVec2(uv0.x, uv0.y + band), ImVec2(uv1.x, uv1.y - band), + level->can_load ? IM_COL32_WHITE : IM_COL32(255, 255, 255, 90)); + } else { + draw->AddRectFilled(at, ImVec2(end.x, at.y + picture), skate_theme::tile_grey); + const char* initial = custom ? "CUSTOM" : "MAP"; + const auto size = menu.bold->CalcTextSizeA(px(13), FLT_MAX, 0, initial); + draw->AddText(menu.bold, px(13), ImVec2(at.x + (width - size.x) * .5f, at.y + (picture - size.y) * .5f), muted, initial); + } + draw->AddRectFilled(ImVec2(at.x, at.y + picture), end, focused ? blue : hovered ? skate_theme::tile_light : skate_theme::tile); + draw->PushClipRect(at, end, true); + draw->AddText(menu.bold, px(15), ImVec2(at.x + px(12), at.y + picture + px(12)), + focused ? skate_theme::black : level->can_load ? paper : muted, level_name(*level).c_str()); + draw->PopClipRect(); + if (focused) draw->AddRect(ImVec2(at.x - px(3), at.y - px(3)), ImVec2(end.x + px(3), end.y + px(3)), paper, 0, 0, px(3)); + if (!console) { + ImGui::BeginDisabled(!can); + skate_theme::push_primary_button(); + if (ImGui::Button("LOAD", ImVec2(width, px(32)))) load(level); + skate_theme::pop_primary_button(); + ImGui::EndDisabled(); + if (!level->can_load && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("%s", level->load_block_reason.c_str()); + } else if (!level->can_load && (hovered || focused)) { + ImGui::SetTooltip("%s", level->load_block_reason.c_str()); + } + ImGui::EndGroup(); + ImGui::PopID(); + } + }; + grid("OFFICIAL MAPS", false); + grid("CUSTOM MAPS", true); + if (destinations.empty()) note("Waiting for levels..."); } void park_editor_panel(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { @@ -269,6 +336,20 @@ void population_controls(SkateMenu& menu, const Model& model, const CallbacksV3& return queued; }; begin_card(menu, "population", "POPULATION"); + if (console_style()) { + const bool can = controls.population_available && model.world.ready && callbacks.queue_console_command; + std::vector modes; + for (const auto* label : population_labels) modes.emplace_back(label); + for (unsigned i = 0; i < 2; ++i) { + int chosen = (i == 0 ? c.traffic : c.pedestrians) + 1; + const auto nearby = controls.population_ready[i] ? std::to_string(controls.population[i]) + " nearby right now." + : std::string("Waiting for the population count."); + if (option_row(menu, i == 0 ? "Traffic" : "Pedestrians", nearby.c_str(), chosen, modes, can)) + send(i == 0 ? "traffic" : "pedestrians", static_cast(chosen - 1)); + } + end_card(); + return; + } ImGui::PushID("world-controls"); ImGui::BeginDisabled(!controls.population_available || !model.world.ready || !callbacks.queue_console_command); for (unsigned i = 0; i < 2; ++i) { @@ -307,6 +388,45 @@ void parks(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { callbacks.queue_console_command(callbacks.user, command, result.data(), result.size()); result.back() = '\0'; feedback(menu, result.data()); }; + if (console_style()) { + // Full screen: a row a slot, left / right to pick its layout and A to load it. + const bool locked = !model.parks.available || tools::parks_locked(model) || !callbacks.queue_console_command; + if (button_row(menu, "park-random", {{"LOAD RANDOM PARKS", !locked, false, "A random layout in every slot."}}) == 0) { + park_command("park random"); + menu.park_edit = {}; + } + auto random_on_launch = model.parks.randomize_on_launch; + if (toggle_row(menu, "Randomize on launch", "A random layout in every slot once each time the game starts. Multiplayer follows the host.", + random_on_launch, model.parks.available && callbacks.queue_console_command)) + park_command(random_on_launch ? "park random-on-launch 1" : "park random-on-launch 0"); + for (unsigned lot = 0; lot < park_lots.size(); ++lot) { + auto& staged = menu.park_edit[lot]; + if (staged.empty() || menu.park_seen[lot] != model.parks.choices[lot] || tools::parks_locked(model)) + staged = model.parks.choices[lot]; + menu.park_seen[lot] = model.parks.choices[lot]; + std::vector ids{"empty"}, names{"Empty lot"}; + for (unsigned family = 0; family < park_families.size(); ++family) + for (unsigned variant = 1; variant <= park_lots[lot].counts[family]; ++variant) { + ids.push_back(park_id(family, variant)); + names.push_back(park_label(ids.back())); + } + int at = -1; + for (std::size_t i = 0; i < ids.size(); ++i) + if (ids[i] == staged) at = static_cast(i); + const bool loaded = staged == model.parks.choices[lot]; + const auto label = std::string(park_lots[lot].label) + (loaded ? "" : " - A loads it") + "###" + std::string(park_lots[lot].key); + bool load = false; + if (option_row(menu, label.c_str(), loaded ? "Left / right to pick a layout, then A to load it." : "Not loaded yet: press A to load it.", + at, names, !locked, &load) && at >= 0) + staged = ids[static_cast(at)]; + if (load && !staged.empty()) + park_command(("park " + std::string(park_lots[lot].key) + " " + staged).c_str()); + } + if (!model.parks.ready) note("Load San Vansterdam to apply park choices."); + if (!model.parks.feedback.empty()) note(model.parks.feedback.c_str()); + end_card(); + return; + } ImGui::BeginDisabled(!model.parks.available || tools::parks_locked(model) || !callbacks.queue_console_command); if (ImGui::Button("Load Random Parks")) { park_command("park random"); @@ -379,6 +499,32 @@ void world_layers_page(SkateMenu& menu, const Model& model, const CallbacksV3& c note("Load a map to see its world layers."); return; } + if (console_style()) { + // Full screen: which kind, then a switch a layer (no search box: that wants a keyboard). + static const std::vector kinds{"All categories", "Seasonal", "Activities", "World", "Lighting", "Structure", "Debug", "Parks"}; + begin_card(menu, "world-layers", "WORLD LAYERS"); + option_row(menu, "Show", "Left / right for one kind of layer.", menu.world_layer_category, kinds); + const bool can = !dingosdk::native_tools::layers_locked(model) && model.world.available && model.world.ready && callbacks.queue_console_command; + unsigned shown{}; + for (unsigned i = 0; i < world_layers().size(); ++i) { + const auto& layer = world_layers()[i]; + if (layer.map != model.world.map || !model.world.supported[i] || world_layer_kept_off(layer.key)) continue; + if (menu.world_layer_category && layer.category != kinds[static_cast(menu.world_layer_category)]) continue; + ++shown; + const bool follows_game = model.world.choices[i] == "default"; + bool enabled = follows_game ? model.world.enabled[i].value_or(false) : model.world.choices[i] == "on"; + ImGui::PushID(layer.key.data()); + if (toggle_row(menu, world_layer_title(layer.label).c_str(), layer.detail.data(), enabled, can)) + send_console(menu, callbacks, "world " + std::string(layer.key) + (enabled ? " on" : " off")); + ImGui::PopID(); + } + if (!shown) note("No layers of that kind on this map."); + if (button_row(menu, "layer-defaults", {{"RESTORE DEFAULTS", can, false, "Every layer on this map back to the game's control."}}) == 0) + send_console(menu, callbacks, "world defaults " + std::string(world_map_key(model.world.map))); + if (!model.world.feedback.empty()) note(model.world.feedback.c_str()); + end_card(); + return; + } ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * .61f); ImGui::InputTextWithHint("##layer-search", "Search layers...", menu.world_layer_search.data(), menu.world_layer_search.size()); ImGui::SameLine(); ImGui::SetNextItemWidth(-1); @@ -462,15 +608,27 @@ void world_layers_page(SkateMenu& menu, const Model& model, const CallbacksV3& c if (!model.world.feedback.empty()) note(model.world.feedback.c_str()); } +void time_of_day_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks); + // One page per sidebar entry; each page's tabs switch between focused panels. void map_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - category_tabs(menu, menu.map_tab, {"TRAVEL", "ROTATING PARKS", "WORLD LAYERS"}, "map-tabs"); + category_tabs(menu, menu.map_tab, {"TRAVEL", "PARKS", "TIME AND WEATHER", "LAYERS AND CROWDS"}, "map-tabs"); ImGui::PushID(menu.map_tab); - ImGui::BeginChild("map-tab", ImVec2(0, page_body_height(menu))); + ImGui::BeginChild("map-tab", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); + const bool loaded = model.world.map != WorldMap::none; switch (menu.map_tab) { case 0: level_picker(menu, model, callbacks); break; case 1: parks(menu, model, callbacks); break; - case 2: world_layers_page(menu, model, callbacks); break; + case 2: + time_of_day_page(menu, model, callbacks); + if (loaded) atmosphere_menu(menu, model, callbacks); + else note("Load a map in Map > Travel to change its fog, sky and wind."); + break; + case 3: + world_layers_page(menu, model, callbacks); + if (loaded) population_controls(menu, model, callbacks); + else note("Load a map in Map > Travel to change its traffic and pedestrians."); + break; } ImGui::EndChild(); ImGui::PopID(); @@ -518,19 +676,50 @@ void graphics_page(SkateMenu& menu, const Model& model, const CallbacksV3& callb // The map's seven time-of-day world layers as one choice, like the native menu: // forcing one turns the others off; Default hands all seven back to the level. +// A time of day is seven layer changes the game takes a few frames to make, and until they are +// all made the layers read as some other time (or none). So the time asked for is kept and shown, +// and the next step goes on from it, until the layers say it or four seconds pass. +namespace { +int tod_asked = -1; +double tod_asked_until = 0; +} +int time_of_day_shown(const Model& model) { + const int now = static_cast(dingosdk::native_tools::time_of_day(model)); + if (tod_asked >= 0 && (now == tod_asked || ImGui::GetTime() > tod_asked_until)) tod_asked = -1; + return tod_asked >= 0 ? tod_asked : now; +} +void set_time_of_day(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks, int next, bool quiet) { + tod_asked = std::clamp(next, 0, 7); + tod_asked_until = ImGui::GetTime() + 4.0; + for (const auto& line : dingosdk::native_tools::time_of_day_commands(model, static_cast(tod_asked))) { + if (!quiet) { + send_console(menu, callbacks, line); + } else if (callbacks.queue_console_command) { + std::array reply{}; + callbacks.queue_console_command(callbacks.user, line.c_str(), reply.data(), reply.size()); + } + } +} + void time_of_day_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { namespace tod = dingosdk::native_tools; begin_card(menu, "time-of-day", "TIME OF DAY"); const bool available = tod::time_of_day_available(model, callbacks); - const int current = static_cast(tod::time_of_day(model)); - const auto apply = [&](unsigned next) { - for (const auto& line : tod::time_of_day_commands(model, next)) send_console(menu, callbacks, line); - }; + const int current = time_of_day_shown(model); + const auto apply = [&](unsigned next) { set_time_of_day(menu, model, callbacks, static_cast(next), false); }; + if (console_style()) { + static const std::vector times{"Default", "Morning", "Noon", "Afternoon", "Evening", "Night", "Weather day", "Weather night"}; + int chosen = current; + if (option_row(menu, "Time of day", "Left / right to change it. Default follows the level's own time.", chosen, times, available)) + apply(static_cast(chosen)); + } int top = current < 4 ? current : -1; + if (!console_style()) { if (choice(menu, "tod-top", top, {"Default", "Morning", "Noon", "Afternoon"}, available)) apply(static_cast(top)); int bottom = current >= 4 ? current - 4 : -1; if (choice(menu, "tod-bottom", bottom, {"Evening", "Night", "Weather day", "Weather night"}, available)) apply(static_cast(bottom + 4)); + } if (model.world.controlled_by_host && server_can(model.multiplayer, "tod")) note("Your role on this server sets the time: the one you pick applies to everyone."); else if (model.world.controlled_by_host) warn("The lobby host controls world layers, including time of day."); @@ -540,30 +729,10 @@ void time_of_day_page(SkateMenu& menu, const Model& model, const CallbacksV3& ca end_card(); } -void world_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - category_tabs(menu, menu.world_tab, {"TIME OF DAY", "ATMOSPHERE", "POPULATION"}, "world-tabs"); - ImGui::PushID(menu.world_tab); - ImGui::BeginChild("world-tab", ImVec2(0, page_body_height(menu))); - const bool loaded = model.world.map != WorldMap::none; - switch (menu.world_tab) { - case 0: time_of_day_page(menu, model, callbacks); break; - case 1: - if (loaded) atmosphere_menu(menu, model, callbacks); - else note("Load a map in Map > Travel to change its fog, sky and wind."); - break; - case 2: - if (loaded) population_controls(menu, model, callbacks); - else note("Load a map in Map > Travel to change its traffic and pedestrians."); - break; - } - ImGui::EndChild(); - ImGui::PopID(); -} - void build_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { category_tabs(menu, menu.build_tab, {"PARK EDITOR", "PARK MODS", "SAVED OBJECTS"}, "build-tabs"); ImGui::PushID(menu.build_tab); - ImGui::BeginChild("build-tab", ImVec2(0, page_body_height(menu))); + ImGui::BeginChild("build-tab", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); if (menu.build_tab == 0) park_editor_panel(menu, model, callbacks); else if (menu.build_tab == 1) park_mods_panel(menu, model, callbacks); else saved_objects(menu, model, callbacks); diff --git a/Runtime/overlay_callbacks.cpp b/Runtime/overlay_callbacks.cpp index cef88c44..fe619b5e 100644 --- a/Runtime/overlay_callbacks.cpp +++ b/Runtime/overlay_callbacks.cpp @@ -58,6 +58,8 @@ void read_model(void*, dingosdk::overlay::Model& output) { output.engine_settings = std::move(settings); output.console_log.clear(); output.menu_scale = dingosdk::profile_runtime::local_menu_scale(); + output.menu_scale_fullscreen = dingosdk::profile_runtime::local_menu_scale(true); + output.menu_fullscreen = dingosdk::profile_runtime::local_menu_fullscreen(); output.steam_offline = dingosdk::launcher::offline_mode(); output.multiplayer=dingosdk::multiplayer::model(); if (dingosdk::multiplayer_controls_level(output.multiplayer)) { diff --git a/cmake/UI.cmake b/cmake/UI.cmake index 38b43cb6..778adce6 100644 --- a/cmake/UI.cmake +++ b/cmake/UI.cmake @@ -25,6 +25,8 @@ add_library(dingosdk_overlay STATIC Extension/UI/Overlay/console_suggestions.cpp Extension/UI/Overlay/skate_menu.cpp Extension/UI/Overlay/skate_menu_world.cpp + Extension/UI/Overlay/skate_menu_addons.cpp + Extension/UI/Overlay/menu_map_pictures.cpp Extension/UI/Overlay/skate_menu_skater.cpp Extension/UI/Overlay/skate_menu_settings.cpp Extension/UI/Overlay/park_editor.cpp