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