Files
ReSkate/Extension/UI/Overlay/skate_menu.cpp
T
AyeZeeBB 86aa8d38fb Music: local files, online radio, equaliser, queue, and a Music page in the menu
Sources
- Local Files: add a folder of music from the setup page (MP3, M4A, AAC, Ogg,
  FLAC, WAV; up to 200 songs a folder, 12 folders). Names and covers come from
  the files' tags. Each folder is a tile in its own row.
- Online Radio: find stations by name (radio-browser.info) or add a stream's
  address (MP3, AAC, .pls, .m3u; no HLS). Each station is a tile in its own
  row; the track name follows what the station sends. Up to 24.
- Both are decoded by the library built into the DLL (SpotifyHelper), which
  now plays files and HTTP streams as well as a program's output, reads tags
  and embedded pictures, and accepts MPEG-2 ADTS from AAC stations.

Sound
- Five-band equaliser (60 Hz, 250 Hz, 1 kHz, 4 kHz, 12 kHz) and loudness
  levelling, applied to every source on its way into the game.
- Loudness, the speakers option, the equaliser and levelling are now kept in
  music-settings.json between sessions.

Setup page
- Remade in the mod's own colours and type, with numbered steps per source.
- Queue pinned to the right: now playing, the playlist's songs with covers,
  find, and press to jump. Spotify's own queue shows once signed in.
- Cards for Local files and Online radio; equaliser and levelling under Sound.

In game
- MUSIC page in the menu: player and queue, sound settings, source status and
  a button for the setup page.
- "Loading song..." notice in the corner for songs that take a moment, with a
  switch to hide it. Presses made while a song is loading no longer restart it.
- The Spotify row is hidden when Spotify is not in use.
- Console: "spotify ..." is now "music ..." (music, music setup, music gain,
  music through, music spotify client|login|logout).

Logs
- The music sources' working lines (traces, per-song and per-bind lines,
  librespot's own) are debug only; stack captures are skipped unless debug
  logging is on. Grind exits: the per-pop line is debug only too.
- A "Pace:" line when the game takes the sound faster or slower than real
  time, to find why music sounds fast after tabbing back in.

Chat filter
- A run of digits is a number, not a word spelled with look-alikes: a race
  time such as 2:55.0 is no longer masked.
2026-10-12 16:03:35 -05:00

475 lines
25 KiB
C++

#include "Engine/Core/Platform/launcher_support.h"
#include "skate_menu_internal.h"
#include "skate_menu.h"
#include "modding_menu.h"
#include "skate_style.h"
#include "Extension/UI/skate_theme.h"
#include <Windows.h>
#include <algorithm>
#include <array>
#include <initializer_list>
namespace dingosdk::overlay {
using namespace menu;
namespace menu {
using namespace theme;
// Set once per frame in draw_skate_menu, then read through px() while the menu
// and its pages draw.
namespace { float ui_scale = default_menu_scale; }
float px(float value) { return value * ui_scale; }
// Heavy uppercase header over a thin rule, like the titles on skate.'s HUB tiles.
void section(SkateMenu& menu, const char* text) {
ImGui::Spacing();
ImGui::PushFont(menu.heading);
ImGui::TextUnformatted(text);
ImGui::PopFont();
const auto at = ImGui::GetCursorScreenPos();
auto* draw = ImGui::GetWindowDrawList();
draw->AddRectFilled(at, ImVec2(at.x + ImGui::GetContentRegionAvail().x, at.y + px(2)), skate_theme::tile_light);
draw->AddRectFilled(at, ImVec2(at.x + px(34), at.y + px(3)), blue);
ImGui::Dummy(ImVec2(0, px(8)));
}
float page_body_height(const SkateMenu& menu) {
const bool feedback_shown = !menu.feedback.empty() && ImGui::GetTime() < menu.feedback_until;
return std::max(80.0f, ImGui::GetContentRegionAvail().y - (feedback_shown ? px(48) : 0.0f));
}
void feedback(SkateMenu& menu, const char* text) {
// An accepted request has nothing to say; only problems and results show.
if (!text || !*text) return;
menu.feedback = text;
menu.feedback_until = ImGui::GetTime() + 6.0;
}
void send_console(SkateMenu& menu, const CallbacksV3& callbacks, const std::string& command) {
if (!callbacks.queue_console_command) {
feedback(menu, "The game command dispatcher is unavailable.");
return;
}
std::array<char, 512> result{};
callbacks.queue_console_command(callbacks.user, command.c_str(), result.data(), result.size());
result.back() = 0;
feedback(menu, result.data());
}
void debug_request(SkateMenu& menu, const CallbacksV3& callbacks, DebugRequest request) {
std::array<char, 512> result{};
if (!callbacks.queue_debug) return;
const bool queued = callbacks.queue_debug(callbacks.user, request, result.data(), result.size());
result.back() = '\0';
feedback(menu, result[0] ? result.data() : (queued ? "" : "Change unavailable."));
}
bool toggle_row(SkateMenu& menu, const char* label, const char* hint, bool& value, bool available,
const char* unavailable) {
ImGui::PushID(label);
ImGui::BeginDisabled(!available);
const auto at = ImGui::GetCursorScreenPos();
const float width = ImGui::GetContentRegionAvail().x;
const float height = px(36);
const bool clicked = ImGui::Selectable("##toggle", false, ImGuiSelectableFlags_None, ImVec2(width, height));
const bool hovered = ImGui::IsItemHovered();
const bool focused = ImGui::IsItemFocused();
if (clicked) value = !value;
// A dark tile per option with an ON/OFF block, like the game's option rows.
auto* draw = ImGui::GetWindowDrawList();
const unsigned seed = static_cast<unsigned>(ImGui::GetItemID());
skate_theme::rough_rect(draw, at, ImVec2(at.x + width, at.y + height - px(3)),
hovered ? skate_theme::tile_light : skate_theme::tile_grey, seed, ui_scale);
if (!available) skate_theme::scribble(draw, at, ImVec2(at.x + width * 0.6f, at.y + height - px(3)), seed, ui_scale * 0.5f);
if (focused) draw->AddRect(at, ImVec2(at.x + width, at.y + height - px(3)), paper, 0, 0, px(2));
const auto color = available ? paper : muted;
const float block = px(58);
draw->PushClipRect(at, ImVec2(at.x + width - block - px(12), at.y + height), true);
draw->AddText(menu.bold, px(16), ImVec2(at.x + px(12), at.y + px(8)), color, label);
draw->PopClipRect();
const char* state = available ? (value ? "ON" : "OFF") : unavailable;
const ImVec2 box(at.x + width - block - px(6), at.y + px(6));
const bool on = available && value;
draw->AddRectFilled(box, ImVec2(box.x + block, at.y + height - px(9)), on ? blue : IM_COL32(0, 0, 0, 90));
const auto text = menu.bold->CalcTextSizeA(px(14), FLT_MAX, 0, state);
draw->AddText(menu.bold, px(14), ImVec2(box.x + (block - text.x) * 0.5f, box.y + (height - px(15) - text.y) * 0.5f),
on ? skate_theme::black : muted, state);
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", hint);
ImGui::EndDisabled();
ImGui::PopID();
return clicked;
}
namespace {
// The label column of field() and info(), and the width below which they stack.
float label_column() { return px(180); }
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)));
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,
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 (title) {
ImGui::PushFont(menu.bold);
ImGui::TextUnformatted(title);
ImGui::PopFont();
if (subtitle) {
ImGui::SameLine(0, px(10));
ImGui::PushStyleColor(ImGuiCol_Text, muted);
ImGui::TextUnformatted(subtitle);
ImGui::PopStyleColor();
}
ImGui::Dummy(ImVec2(0, px(4)));
}
}
void end_card() {
ImGui::EndChild();
ImGui::Dummy(ImVec2(0, px(8)));
}
void field(SkateMenu&, const char* label, const char* tooltip) {
const float start = ImGui::GetCursorPosX();
const bool stacked = narrow();
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(label);
if (tooltip && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", tooltip);
if (!stacked) ImGui::SameLine(start + label_column());
ImGui::SetNextItemWidth(-FLT_MIN);
}
void info(SkateMenu&, const char* label, const std::string& value) {
const float start = ImGui::GetCursorPosX();
ImGui::PushStyleColor(ImGuiCol_Text, muted);
ImGui::TextUnformatted(label);
ImGui::PopStyleColor();
if (!narrow()) ImGui::SameLine(start + label_column());
ImGui::PushTextWrapPos(0);
ImGui::TextUnformatted(value.c_str());
ImGui::PopTextWrapPos();
}
void note(const char* text) {
ImGui::PushStyleColor(ImGuiCol_Text, muted);
ImGui::PushTextWrapPos(0);
ImGui::TextUnformatted(text);
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
}
void warn(const char* text) {
ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::warning);
ImGui::PushTextWrapPos(0);
ImGui::TextUnformatted(text);
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
}
bool choice(SkateMenu& menu, const char* id, int& selected, std::initializer_list<const char*> options, bool enabled,
float total, std::initializer_list<const char*> tooltips) {
ImGui::PushID(id);
const auto count = static_cast<int>(options.size());
const float gap = px(4);
const float width = ((total > 0 ? total : ImGui::GetContentRegionAvail().x) - gap * (count - 1)) / count;
const float height = px(32);
auto* draw = ImGui::GetWindowDrawList();
bool changed{};
for (int i = 0; i < count; ++i) {
if (i) ImGui::SameLine(0, gap);
const auto at = ImGui::GetCursorScreenPos();
ImGui::PushID(i);
ImGui::BeginDisabled(!enabled);
if (ImGui::InvisibleButton("##choice", ImVec2(width, height)) && selected != i) {
selected = i;
changed = true;
}
ImGui::EndDisabled();
const bool hovered = enabled && ImGui::IsItemHovered();
const char* tooltip = i < static_cast<int>(tooltips.size()) ? tooltips.begin()[i] : nullptr;
if (tooltip && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", tooltip);
if (ImGui::IsItemFocused() && ImGui::GetIO().NavVisible)
draw->AddRect(at, ImVec2(at.x + width, at.y + height), paper, 0, 0, px(2));
ImGui::PopID();
const bool on = selected == i;
const ImU32 fill = on ? (enabled ? blue : IM_COL32(1, 131, 255, 120))
: hovered ? skate_theme::tile_light : skate_theme::tile_grey;
skate_theme::rough_rect(draw, at, ImVec2(at.x + width, at.y + height), fill, static_cast<unsigned>(i + 29), ui_scale);
const char* label = options.begin()[i];
const auto size = menu.bold->CalcTextSizeA(px(14), FLT_MAX, 0, label);
draw->PushClipRect(at, ImVec2(at.x + width, at.y + height), true);
draw->AddText(menu.bold, px(14), ImVec2(at.x + std::max(px(6), (width - size.x) * .5f), at.y + (height - size.y) * .5f),
on ? skate_theme::black : enabled ? paper : muted, label);
draw->PopClipRect();
}
ImGui::PopID();
return changed;
}
void tag(SkateMenu& menu, const char* text, ImU32 colour) {
const auto size = menu.bold->CalcTextSizeA(px(12), FLT_MAX, 0, text);
const ImVec2 pad(px(7), px(3));
const auto at = ImGui::GetCursorScreenPos();
// Centred on a widget row, so it lines up with text beside it that was
// aligned to frame padding (table rows, buttons).
const float line = ImGui::GetFrameHeight();
const float top = at.y + std::max(0.0f, (line - size.y - pad.y * 2) * .5f);
ImGui::Dummy(ImVec2(size.x + pad.x * 2, std::max(line, size.y + pad.y * 2)));
auto* draw = ImGui::GetWindowDrawList();
draw->AddRectFilled(ImVec2(at.x, top), ImVec2(at.x + size.x + pad.x * 2, top + size.y + pad.y * 2), 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) {
ImGui::BeginDisabled(!enabled);
skate_theme::push_primary_button();
ImGui::PushFont(menu.bold);
const bool clicked = ImGui::Button(label, ImVec2(-FLT_MIN, px(40)));
ImGui::PopFont();
skate_theme::pop_primary_button();
ImGui::EndDisabled();
return clicked;
}
void category_tabs(SkateMenu& menu, int& selected, std::initializer_list<const char*> 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<int>(tabs.size()) - 1);
ImGui::PushID(id);
const float gap = px(6);
const float tab_width = (ImGui::GetContentRegionAvail().x - gap * (tabs.size() - 1)) / tabs.size();
const float height = px(34);
auto* draw = ImGui::GetWindowDrawList();
for (int i = 0; i < static_cast<int>(tabs.size()); ++i) {
if (i) ImGui::SameLine(0, gap);
const auto at = ImGui::GetCursorScreenPos();
ImGui::PushID(i);
if (ImGui::InvisibleButton("##tab", ImVec2(tab_width, height)) && selected != i) {
selected = i;
menu.feedback.clear();
}
const bool hovered = ImGui::IsItemHovered();
if (ImGui::IsItemFocused() && ImGui::GetIO().NavVisible)
draw->AddRect(at, ImVec2(at.x + tab_width, at.y + height), paper, 0, 0, px(2));
ImGui::PopID();
const bool on = selected == i;
skate_theme::rough_rect(draw, at, ImVec2(at.x + tab_width, at.y + height),
on ? blue : hovered ? skate_theme::tile_light : skate_theme::tile_grey, static_cast<unsigned>(i + 11), ui_scale);
const char* label = tabs.begin()[i];
const auto size = menu.bold->CalcTextSizeA(px(15), FLT_MAX, 0, label);
draw->PushClipRect(at, ImVec2(at.x + tab_width, at.y + height), true);
draw->AddText(menu.bold, px(15), ImVec2(at.x + std::max(px(6), (tab_width - size.x) * .5f), at.y + (height - size.y) * .5f),
on ? skate_theme::black : paper, label);
draw->PopClipRect();
}
ImGui::PopID();
ImGui::Dummy(ImVec2(0, px(6)));
}
}
namespace {
// The menu fonts are built into ReSkate.dll (External/fonts, shared with the
// launcher); Windows fonts are the fallback.
ImFont* embedded_font(const wchar_t* name, float size, const ImWchar* ranges) {
HMODULE module{};
if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(&embedded_font), &module)) return nullptr;
const auto resource = FindResourceW(module, name, MAKEINTRESOURCEW(10) /* RT_RCDATA */);
const auto loaded = resource ? LoadResource(module, resource) : nullptr;
void* data = loaded ? LockResource(loaded) : nullptr;
if (!data) return nullptr;
ImFontConfig config;
config.FontDataOwnedByAtlas = false; // the resource lives as long as the DLL
config.OversampleH = size >= 32 ? 1 : 2;
return ImGui::GetIO().Fonts->AddFontFromMemoryTTF(data, static_cast<int>(SizeofResource(module, resource)), size,
&config, ranges);
}
enum Page { map, world, build, skater, training, multiplayer, progress, mods, music, settings, special, developer, page_count };
constexpr std::array<const char*, page_count> page_names{
"MAP", "WORLD", "BUILD", "SKATER", "TRAINER", "MULTIPLAYER", "PROGRESS", "MODS", "MUSIC", "SETTINGS", "SPECIAL", "DEVELOPER"};
constexpr std::array<const char*, page_count> page_subtitles{
"Pick your spot.", "Set the vibe.", "Make the park yours.", "Ride it your way.", "Tune it. Drill it. Measure it.",
"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."};
}
void load_skate_fonts(SkateMenu& menu) {
auto* atlas = ImGui::GetIO().Fonts;
atlas->TexGlyphPadding = 2;
// Latin, Cyrillic and general punctuation cover player and lobby names.
static const ImWchar ranges[]{0x0020, 0x024F, 0x0400, 0x052F, 0x2000, 0x206F, 0x20A0, 0x20CF, 0x2190, 0x21FF, 0};
std::array<char, MAX_PATH> windows{};
const auto length = GetWindowsDirectoryA(windows.data(), static_cast<UINT>(windows.size()));
const auto font = [&](const wchar_t* resource, const char* filename, float size) -> ImFont* {
if (resource)
if (auto* loaded = embedded_font(resource, size, ranges)) return loaded;
if (length && length < windows.size()) {
const auto path = std::string(windows.data()) + "\\Fonts\\" + filename;
if (GetFileAttributesA(path.c_str()) != INVALID_FILE_ATTRIBUTES)
if (auto* loaded = atlas->AddFontFromFileTTF(path.c_str(), size)) return loaded;
}
ImFontConfig config;
config.SizePixels = size;
return atlas->AddFontDefault(&config);
};
// Every role has a Windows font and then ImGui's own font as fallbacks.
atlas->AddFontDefault();
menu.body = font(L"FONT_BODY", "arial.ttf", 17);
menu.bold = font(L"FONT_HEADING", "arialbd.ttf", 17);
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);
}
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();
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 (ImGui::Begin("ReSkate###skate-menu", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoScrollbar)) {
const auto size = ImGui::GetWindowSize();
const auto current = ImGui::GetWindowPos();
const ImVec2 bounded(std::clamp(current.x, 0.0f, std::max(0.0f, io.DisplaySize.x - size.x)),
std::clamp(current.y, 0.0f, std::max(0.0f, io.DisplaySize.y - size.y)));
if (bounded.x != current.x || bounded.y != current.y) ImGui::SetWindowPos(bounded);
const auto origin = ImGui::GetWindowPos();
auto* draw = ImGui::GetWindowDrawList();
const float sidebar = px(190.0f);
// Sidebar: brushed wordmark, then one tile per page 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));
}
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;
menu.page = std::clamp(menu.page, 0, static_cast<int>(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<int>(page_count); ++i) {
if (hidden(i)) continue;
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 hovered = ImGui::IsItemHovered();
auto* nav = ImGui::GetWindowDrawList();
const ImVec2 end(at.x + sidebar - px(20), at.y + tab_height - px(4));
if (selected || hovered)
skate_theme::rough_rect(nav, at, end, selected ? blue : skate_theme::tile_grey, static_cast<unsigned>(i + 3), ui_scale);
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]);
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");
}
// 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)));
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 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::EndChild();
}
ImGui::End();
ImGui::PopStyleColor(colours);
ImGui::PopStyleVar(9);
ImGui::PopFont();
io.FontGlobalScale = restore_font_scale;
}
}