#include "overlay_internal.h" #include "skate_style.h" #include "skate_menu_internal.h" #include "cursor.h" #include "input_capture.h" #include "Extension/UI/NativeMenu/native_menu_view.h" #include "Extension/UI/NativeMenu/native_tools_view.h" #include "Extension/Boot/discord_presence.h" #include "Extension/Music/local_music_playback.h" #include "Extension/Profile/local_profile_runtime.h" #include "Engine/Game/Input/controller_bindings.h" #include "Engine/Game/Input/voice_input.h" #include "Engine/Game/Multiplayer/object_placement.h" #include "Engine/Game/Multiplayer/session_limits.h" #include "Engine/Game/Multiplayer/tick_settings.h" #include #include #include #include #include #include #include #include #include #include #include #include // ReSkate's two pages of the game's own pause menu, Multiplayer and Mod Options, drawn by // ReSkate: the game's menu widgets are a button, a text box and a line of text, which cannot // make a list with columns, so the game keeps the frame around a page (its top bar, title and // Back prompt) and this draws everything in it: the page's tabs and whichever is open (the // server browser, hosting, the session, voice chat; travel, parks, the player and visuals). // // The game still owns the mouse and the controller while its menu is up, so this layer only // watches them (State::hub_pointer). It keeps its own focus, moved with the D-pad or the arrow // keys and pressed with A or Enter, so every control is reachable without a mouse. Typing in a // text box is the exception: for that long the overlay takes the keyboard, as the chat box // does (State::hub_typing). namespace dingosdk::overlay { namespace { std::atomic page_feed{}; std::atomic ui_sound{}; std::mutex hub_callbacks_mutex; CallbacksV3 hub_callbacks; // under the mutex } // namespace void set_hub_page_feed(HubPageFeed feed) noexcept { page_feed.store(feed); } void set_ui_sound(UiSound play) noexcept { ui_sound.store(play); } void 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; } } // namespace dingosdk::overlay namespace dingosdk::overlay::detail { namespace { namespace view = dingosdk::multiplayer::menu_view; using Clock = std::chrono::steady_clock; using namespace std::chrono_literals; // The game's menu is laid out on a screen of 3840 by 2160 units, scaled to fit the window // whole (so by its width in a tall window and its height in a wide one). What does not fit // the 16:9 of that is given to the menu too: up to 4704 units across, centred in anything // wider, and all of a taller window's height, where the top of the page moves down and the // Back prompt up by a twentieth of the extra. Its pages keep 192 units clear at the sides. // In those units, on a 16:9 screen: the page's tabs sit under its title at 572, its body // starts at 770, and its Back prompt sits under 1905. (Measured on the game's own pages at // 16:9, 21:9, 32:9, 11:7 and a window taller than wide.) constexpr float layout_width = 3840.f, layout_height = 2160.f, layout_widest = 4704.f, side_margin = 192.f, tabs_top = 572.f, tabs_height = 84.f, body_top = 770.f, body_bottom = 1905.f, body_most = 4400.f; constexpr ImU32 sticky = IM_COL32(236, 215, 108, 255), sticky_hover = IM_COL32(246, 228, 132, 255), sticky_off = IM_COL32(120, 114, 80, 255), row_hover = IM_COL32(52, 53, 57, 240), hint_ink = IM_COL32(110, 110, 110, 255), soft = IM_COL32(210, 210, 214, 255); enum class Tab { browser, host, session, voice, settings }; // The game's own menu sounds, by their numbers in its list (UI/NativeMenu/ui_sound.h). constexpr std::uint32_t sound_navigate = 3, sound_select = 7, sound_tab = 56; enum class Kind { all, servers, lobbies }; enum class Look { tile, sticky, plain, chosen }; constexpr std::uint32_t hash(std::string_view text, std::uint64_t salt = 0) { std::uint32_t value = 2166136261U ^ static_cast(salt * 2654435761ULL); for (const char c : text) value = (value ^ static_cast(c)) * 16777619U; return value ? value : 1; } struct Row { const MultiplayerLobby *lobby{}; std::string map; bool joinable{}, here{}, full{}, own{}, favorite{}; }; struct Item { std::uint32_t id{}; ImVec2 a, b; std::uint32_t list{}; // the scrolling list it is a row of, or 0 }; struct Hub { HubPage page; Clock::time_point next_page{}, next_model{}; Model full; // the game as the menus show it, read a few times a second MultiplayerModel model; // its multiplayer part CallbacksV3 callbacks; bool shown{}, dismissed{}, was_active{}, searched{}; // Mod Options: the same rows and actions the page has always had (native_tools_view.h). native_tools::State tools; int tools_tab{}; // Recording a bind (the BINDS tab): which action (0: none), what has been pressed so far, // and when it gives up (GetTickCount64). int recording_bind{}; ControllerComboCapture bind_capture; ULONGLONG recording_until{}; Tab tab{Tab::browser}; // The browser. std::vector rows; std::size_t listed{}; int skaters{}; bool rows_stale{true}; std::string search, password, code, code_password; Kind kind{Kind::all}; bool friends_only{}, hide_full{}, same_map{}, code_mode{}; // Dedicated servers the player marked, by Steam ID: first in the list, saved with the profile. std::set favorites; bool favorites_loaded{}, favorites_only{}; view::Sort sort{view::Sort::players}; std::uint64_t selected{}; // Hosting. std::string host_name, host_password; bool host_seeded{}, host_named{}, public_lobby{true}; unsigned capacity = static_cast(multiplayer_lobby_player_limit), tps = multiplayer_default_tps; // The session. std::string selected_player; // Voice: a change sent but not yet in the model, so a second press builds on the first. std::optional voice_pending; ULONGLONG voice_pending_until{}; // Focus, the keyboard and scrolling. std::uint32_t focus{}, fire{}, typing{}; std::uint32_t focus_next{}; // a control that is only drawn from the next frame on, to focus then // The game's menu sound this frame calls for (its number; ~0: none), and the focus it was // last played for moving to. std::uint32_t sound = ~0U, focus_heard{}; ULONGLONG sound_at{}; bool ring{}, focus_moved{}; std::vector items, last; std::map scrolls; // The row of each list the focus was last on: where it goes back to when it returns to the // list, in place of the row that happens to be level with where it came from. std::map list_focus; std::uint32_t pad_before{}; std::array keys_before{}; ULONGLONG repeat_at{}; std::string notice; std::string joining_name; // the session a join was asked for from this page, for its card // A join followed to its end, so one that did not get in can say why (failed_page). // join_asked: when one was asked for here and has not shown in the model yet (GetTickCount64). bool join_seen{}, join_fetching{}, join_cancelled{}; 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; return value; } // What one frame draws with. struct Ui { Hub &h; ImDrawList *draw; ImFont *bold, *body, *heading; float u; ImVec2 mouse; bool input, clicked, doubled, moved; float wheel; const MultiplayerModel ∓ ControllerInput pad; }; void set_typing(Hub &h, std::uint32_t id) { auto &s = state(); if (h.typing == id) return; const bool was = h.typing != 0; h.typing = id; if (was == (id != 0)) return; s.hub_typing.store(id != 0); // The keyboard and the cursor change hands, as when the chat box opens and closes. sync_menu_cursor(); std::lock_guard lock(s.input_mutex); s.input.clear(); } void say(Hub &h, std::string text) { h.notice = std::move(text); h.notice_until = GetTickCount64() + 5000; } bool send(Hub &h, const char *action, const std::string &argument = {}, const std::string &password = {}) { if (queue_multiplayer_action(action, argument, password)) return true; say(h, "Couldn't do that just now. Try again."); return false; } // A join was asked for from this page. void asked_join(Hub &h) { h.join_asked = GetTickCount64(); h.join_cancelled = false; h.failed.clear(); } void wipe(std::string &secret) { SecureZeroMemory(secret.data(), secret.size()); secret.clear(); } bool inside(ImVec2 p, ImVec2 a, ImVec2 b) { return p.x >= a.x && p.x < b.x && p.y >= a.y && p.y < b.y; } float width_of(ImFont *font, float size, const std::string &text) { return font->CalcTextSizeA(size, FLT_MAX, 0, text.c_str()).x; } // One line of text inside a box, cut off at its right edge. void label(ImDrawList *draw, ImFont *font, float size, ImVec2 at, float right, ImU32 colour, const std::string &text) { if (right <= at.x) return; draw->PushClipRect(ImVec2(at.x, at.y - size), ImVec2(right, at.y + size * 2), true); draw->AddText(font, size, at, colour, text.c_str()); draw->PopClipRect(); } // A control: takes its place in this frame's focus order and says what is happening to it. // `clip`: the part of the screen it can be pointed at in (a row of a scrolling list). struct Hit { bool hover{}, focus{}, press{}; }; Hit item(Ui &ui, std::uint32_t id, ImVec2 a, ImVec2 b, const Item *clip = nullptr) { auto &h = ui.h; h.items.push_back({id, a, b, clip ? clip->id : 0}); if (clip && h.focus == id) h.list_focus[clip->id] = id; Hit hit; const bool over = ui.input && inside(ui.mouse, a, b) && (!clip || inside(ui.mouse, clip->a, clip->b)); if (over && ui.moved && !h.typing) h.focus = id, h.ring = false; // Lit by the pointer only while the pointer is in charge: once the D-pad or the keys have // the focus, what the mouse was left resting on is not lit beside it. A click there still counts. hit.hover = over && !h.ring; hit.focus = h.focus == id && (h.ring || hit.hover); hit.press = ui.input && ((over && ui.clicked) || h.fire == id); if (hit.press) h.focus = id, h.sound = sound_select; return hit; } // A button in one of the game's looks. `enabled` off: drawn dim, and not a control at all. bool button(Ui &ui, std::uint32_t id, ImVec2 a, ImVec2 b, const std::string &text, Look look = Look::tile, bool enabled = true, const std::string &value = {}, const Item *clip = nullptr) { const float u = ui.u, height = b.y - a.y, size = std::min(40 * u, height * .42f); const Hit hit = enabled ? item(ui, id, a, b, clip) : Hit{}; const bool lit = hit.focus || hit.hover; ImU32 fill = skate_theme::tile, ink = enabled ? theme::paper : theme::muted; if (look == Look::sticky) fill = !enabled ? sticky_off : lit ? sticky_hover : sticky, ink = skate_theme::black; else if (lit) fill = theme::blue; else if (look == Look::plain) fill = skate_theme::tile_light; else if (look == Look::chosen) fill = theme::paper, ink = skate_theme::black; skate_theme::rough_rect(ui.draw, a, b, fill, id & 0xfff, u * 2); if (look == Look::sticky && hit.focus) ui.draw->AddRect(a, b, theme::blue, 0, 0, 6 * u); if (value.empty()) { ui.draw->AddText(ui.bold, size, ImVec2(a.x + (b.x - a.x - width_of(ui.bold, size, text)) * .5f, a.y + (height - size) * .5f), ink, text.c_str()); } else { // A setting: its name on the left, what it is now on the right. const float w = width_of(ui.bold, size, value); label(ui.draw, ui.body, size, ImVec2(a.x + 30 * u, a.y + (height - size) * .5f), b.x - w - 50 * u, ink, text); ui.draw->AddText(ui.bold, size, ImVec2(b.x - w - 30 * u, a.y + (height - size) * .5f), enabled && !lit && look != Look::chosen ? theme::blue : ink, value.c_str()); } return hit.press; } // What was typed this frame goes into `text` (the overlay has the keyboard while typing). void take_typing(std::string &text, std::size_t limit) { auto &io = ImGui::GetIO(); for (const ImWchar c : io.InputQueueCharacters) { if (c < 32 || c == 127 || text.size() + 3 > limit) continue; if (c < 0x80) text += static_cast(c); else if (c < 0x800) text += static_cast(0xc0 | (c >> 6)), text += static_cast(0x80 | (c & 0x3f)); else text += static_cast(0xe0 | (c >> 12)), text += static_cast(0x80 | ((c >> 6) & 0x3f)), text += static_cast(0x80 | (c & 0x3f)); } if (ImGui::IsKeyPressed(ImGuiKey_Backspace, true) && !text.empty()) { text.pop_back(); while (!text.empty() && (static_cast(text.back()) & 0xc0) == 0x80) text.pop_back(); if (!text.empty() && static_cast(text.back()) >= 0xc0) text.pop_back(); } if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_V, false)) if (const char *pasted = ImGui::GetClipboardText()) for (; *pasted && text.size() < limit; ++pasted) if (static_cast(*pasted) >= 32) text += *pasted; } // A text box in the game's white. Pressing it gives it the keyboard; Enter (returns true) or // Escape gives the keyboard back, and so does a click anywhere else. bool field(Ui &ui, std::uint32_t id, ImVec2 a, ImVec2 b, std::string &text, const char *hint, bool secret = false, std::size_t limit = 64) { auto &h = ui.h; const float u = ui.u, size = 40 * u; const Hit hit = item(ui, id, a, b); bool typing = h.typing == id, entered{}; if (hit.press && !typing) set_typing(h, id), typing = true; else if (typing && ui.clicked && !hit.hover) set_typing(h, 0), typing = false; if (typing) { take_typing(text, limit); if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) set_typing(h, 0), typing = false; else if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) set_typing(h, 0), typing = false, entered = true; } skate_theme::rough_rect(ui.draw, a, b, theme::paper, id & 0xfff, u * 2); if (typing || hit.focus) ui.draw->AddRect(a, b, theme::blue, 0, 0, 6 * u); const bool empty = text.empty() && !typing; std::string shown = empty ? std::string(hint) : secret ? std::string(text.size(), '*') : text; if (typing && std::fmod(ImGui::GetTime(), 1.0) < .5) shown += '|'; label(ui.draw, ui.body, size, ImVec2(a.x + 28 * u, a.y + (b.y - a.y - size) * .5f), b.x - 16 * u, empty ? hint_ink : skate_theme::black, shown); return entered; } // A column that scrolls: rows are placed with next(), which gives where the next one goes. struct Scroller { Ui &ui; Item box; float &offset; float y; Scroller(Ui &owner, std::uint32_t id, ImVec2 a, ImVec2 b) : ui(owner), box{id, a, b}, offset(owner.h.scrolls[id]), y(a.y - owner.h.scrolls[id]) { ui.draw->PushClipRect(a, b, true); } // The top of a row `height` tall; `drawn` says whether any of it is in view. float next(float height, float gap, bool &drawn) { const float top = y; y += height + gap; drawn = top + height >= box.a.y && top <= box.b.y; return top; } // Keeps the focused row in view once the focus has moved to it. void reveal(std::uint32_t id, float top, float height) { if (ui.h.focus != id || !ui.h.focus_moved) return; if (top < box.a.y) offset -= box.a.y - top; else if (top + height > box.b.y) offset += top + height - box.b.y; } void end() { ui.draw->PopClipRect(); const float view_height = box.b.y - box.a.y, content = y + offset - box.a.y, most = std::max(0.f, content - view_height); if (ui.input && inside(ui.mouse, box.a, box.b) && ui.wheel != 0) offset -= ui.wheel * 170 * ui.u; offset = std::clamp(offset, 0.f, most); if (most > 0) { const float thumb = std::max(60 * ui.u, view_height * view_height / content); const float at = box.a.y + (view_height - thumb) * (offset / most); ui.draw->AddRectFilled(ImVec2(box.b.x - 10 * ui.u, at), ImVec2(box.b.x - 2 * ui.u, at + thumb), theme::muted, 4 * ui.u); } } }; // ---------------------------------------------------------------- the server browser // Favourite servers. A dedicated server signed in with a login token keeps its Steam ID, so that // is what is kept: "ServerFavorites" in the profile, the IDs with commas between. A player's // lobby has a new ID each time and cannot be one. constexpr std::size_t most_favorites = 256; void load_favorites(Hub &h) { if (h.favorites_loaded) return; h.favorites_loaded = true; const auto saved = dingosdk::profile_runtime::local_value("ServerFavorites"); if (!saved || !saved->is_string()) return; std::uint64_t id{}; for (const char c : saved->string() + ",") { if (c >= '0' && c <= '9') id = id * 10 + static_cast(c - '0'); else { if (id && h.favorites.size() < most_favorites) h.favorites.insert(id); id = 0; } } } void toggle_favorite(Hub &h, const MultiplayerLobby &lobby) { if (!lobby.dedicated) return; if (!h.favorites.erase(lobby.id)) { if (h.favorites.size() >= most_favorites) return say(h, "That is as many favorites as there is room for."); h.favorites.insert(lobby.id); } std::string text; for (const auto id : h.favorites) text += (text.empty() ? "" : ",") + std::to_string(id); dingosdk::profile_runtime::set_local_values({{"ServerFavorites", dingosdk::Json(text)}}); h.rows_stale = true; } // A five-pointed star, for a favorite's row. void star(ImDrawList *draw, ImVec2 centre, float radius, ImU32 colour) { ImVec2 points[10]; for (int i = 0; i < 10; ++i) { const float angle = -1.5707963f + static_cast(i) * .62831853f, r = i % 2 ? radius * .42f : radius; points[i] = ImVec2(centre.x + std::cos(angle) * r, centre.y + std::sin(angle) * r); } for (int i = 0; i < 10; i += 2) draw->AddTriangleFilled(points[i], points[(i + 1) % 10], points[(i + 9) % 10], colour); const ImVec2 inner[5]{points[1], points[3], points[5], points[7], points[9]}; draw->AddConvexPolyFilled(inner, 5, colour); } void rebuild(Hub &h) { load_favorites(h); h.rows_stale = false; h.rows.clear(); const auto &mp = h.model; const std::span levels = h.full.levels; const auto found = view::browse(mp, {h.search, h.same_map, h.sort}, levels); h.listed = mp.lobbies.size(); h.skaters = 0; for (const auto &lobby : mp.lobbies) h.skaters += std::max(0, lobby.players); for (const auto *lobby : found.lobbies) { const bool full = lobby->players >= lobby->capacity; if ((h.kind == Kind::servers && !lobby->dedicated) || (h.kind == Kind::lobbies && lobby->dedicated) || (h.friends_only && lobby->friends.empty()) || (h.hide_full && full)) continue; const bool favorite = lobby->dedicated && h.favorites.contains(lobby->id); if (h.favorites_only && !favorite) continue; h.rows.push_back({lobby, view::caption(view::map_name(lobby->map, levels), 40), view::can_join(mp, *lobby), mp.active && lobby->id == mp.public_lobby, full, lobby->owner == mp.local_id, favorite}); } // Favorites come first, in the order the rest are in. std::stable_partition(h.rows.begin(), h.rows.end(), [](const Row &row) { return row.favorite; }); if (std::none_of(h.rows.begin(), h.rows.end(), [&](const Row &row) { return row.lobby->id == h.selected; })) h.selected = h.rows.empty() ? 0 : h.rows.front().lobby->id; } const Row *selected_row(const Hub &h) { for (const auto &row : h.rows) if (row.lobby->id == h.selected) return &row; return nullptr; } void join(Hub &h, const Row &row) { if (!row.joinable) return; if (row.lobby->password_required && h.password.empty()) { h.focus_next = hash("password"); return say(h, "Enter the password, then join."); } if (send(h, "join-lobby", std::to_string(row.lobby->id), row.lobby->password_required ? h.password : std::string())) { h.joining_name = view::caption(row.lobby->name, 40); asked_join(h); say(h, "Joining " + h.joining_name + "..."); wipe(h.password); } } void browser_page(Ui &ui, ImVec2 a, ImVec2 z) { auto &h = ui.h; const auto &mp = ui.mp; const float u = ui.u, gap = 28 * u, bar = 96 * u, list_width = (z.x - a.x) * .62f; auto *draw = ui.draw; // Refreshing waits out the game-wide limit (multiplayer_browse_interval_us); the button counts it down. const int wait = browse_wait(mp); if (!h.searched && !wait) h.searched = true, queue_multiplayer_action("browse", ""); bool changed{}; // ---- the bar: search, filters, refresh { const float search_width = std::min(1000 * u, list_width * .4f); const auto before = h.search; field(ui, hash("search"), a, ImVec2(a.x + search_width, a.y + bar), h.search, "Search name or map"); changed |= before != h.search; float x = a.x + search_width + gap; const auto chip = [&](const char *text, bool on) { const float size = 34 * u, w = width_of(ui.bold, size, text) + 44 * u; const ImVec2 c(x, a.y + 10 * u), d(x + w, a.y + bar - 10 * u); const Hit hit = item(ui, hash(text, 7), c, d); const bool lit = hit.focus || hit.hover; skate_theme::rough_rect(draw, c, d, lit ? theme::blue : on ? theme::paper : skate_theme::tile, hash(text) & 0xfff, u * 2); draw->AddText(ui.bold, size, ImVec2(c.x + 22 * u, c.y + (d.y - c.y - size) * .5f), on && !lit ? skate_theme::black : theme::paper, text); x = d.x + 12 * u; return hit.press; }; if (chip("ALL", h.kind == Kind::all)) h.kind = Kind::all, changed = true; if (chip("SERVERS", h.kind == Kind::servers)) h.kind = Kind::servers, changed = true; if (chip("LOBBIES", h.kind == Kind::lobbies)) h.kind = Kind::lobbies, changed = true; x += 16 * u; if (chip("FAVORITES", h.favorites_only)) h.favorites_only = !h.favorites_only, changed = true; if (chip("FRIENDS", h.friends_only)) h.friends_only = !h.friends_only, changed = true; if (chip("HIDE FULL", h.hide_full)) h.hide_full = !h.hide_full, changed = true; if (chip("MY MAP", h.same_map)) h.same_map = !h.same_map, changed = true; const auto refresh = wait ? "REFRESH IN " + std::to_string(wait) : std::string(mp.lobby_searching ? "SEARCHING..." : "REFRESH"); if (button(ui, hash("refresh"), ImVec2(z.x - 420 * u, a.y), ImVec2(z.x, a.y + bar), refresh.c_str(), Look::sticky, !wait)) queue_multiplayer_action("browse", ""); } if (ui.input && !h.typing && (ui.pad.buttons & ~h.pad_before & 0x4000) && !wait) queue_multiplayer_action("browse", ""); // X / Square if (ui.input && !h.typing && (ui.pad.buttons & ~h.pad_before & 0x8000)) h.sort = static_cast((static_cast(h.sort) + 1) % 3), changed = true; // Y / Triangle // F marks the chosen server as a favorite, or takes the mark away. if (ui.input && !h.typing && !h.code_mode && ImGui::IsKeyPressed(ImGuiKey_F, false)) if (const auto *row = selected_row(h); row && row->lobby->dedicated) toggle_favorite(h, *row->lobby), changed = true; // ---- the list const ImVec2 la(a.x, a.y + bar + gap), lz(a.x + list_width, z.y - 60 * u); const float header = 52 * u, row_height = 104 * u, row_gap = 8 * u, pad_x = 28 * u; const float map_x = la.x + (lz.x - la.x) * .56f, count_right = lz.x - pad_x - 16 * u; { const float size = 28 * u; const auto column = [&](const char *text, float x, view::Sort sort, bool right_aligned) { const float w = width_of(ui.bold, size, text); const ImVec2 c(right_aligned ? x - w : x, la.y), d(c.x + w + 30 * u, la.y + header); const bool on = h.sort == sort; const Hit hit = item(ui, hash(text, 9), c, d); draw->AddText(ui.bold, size, ImVec2(c.x, c.y + 8 * u), on || hit.focus || hit.hover ? theme::blue : theme::muted, text); if (on) draw->AddTriangleFilled(ImVec2(c.x + w + 10 * u, c.y + 16 * u), ImVec2(c.x + w + 26 * u, c.y + 16 * u), ImVec2(c.x + w + 18 * u, c.y + 28 * u), theme::blue); if (hit.press && !on) h.sort = sort, changed = true; }; column("NAME", la.x + pad_x, view::Sort::name, false); column("MAP", map_x, view::Sort::map, false); column("SKATERS", count_right - 26 * u, view::Sort::players, true); } if (changed) rebuild(h), h.scrolls[hash("servers")] = 0; const Row *chosen{}; { Scroller list(ui, hash("servers"), ImVec2(la.x, la.y + header), lz); for (const auto &row : h.rows) { const auto &lobby = *row.lobby; const auto id = hash("server", lobby.id); bool drawn{}; const float top = list.next(row_height, row_gap, drawn); const ImVec2 p(la.x, top), q(lz.x - 20 * u, top + row_height); const Hit hit = item(ui, id, p, q, &list.box); list.reveal(id, top, row_height); // Pointing at a row (or moving the focus onto it) is choosing it: the panel beside the // list shows it. Pressing it joins. if (hit.focus && (h.ring || ui.moved || ui.clicked)) { if (h.selected != lobby.id) wipe(h.password), h.code_mode = false; h.selected = lobby.id; } if (hit.press) chosen = &row; if (!drawn) continue; // Blue is where the pointer or the focus is; the row the panel shows keeps a mark. const bool lit = hit.focus || hit.hover, shown = h.selected == lobby.id; const ImU32 tile = lit ? theme::blue : hit.hover ? row_hover : lobby.official ? skate_theme::official_tile : !lobby.friends.empty() ? skate_theme::friends_tile : skate_theme::tile; skate_theme::rough_rect(draw, p, q, tile, 0x900 + static_cast(lobby.id & 0xff), u * 2); if (shown && !lit) draw->AddRectFilled(ImVec2(p.x, p.y + 6 * u), ImVec2(p.x + 10 * u, q.y - 6 * u), theme::blue); const ImU32 ink = row.full && !lit ? theme::muted : theme::paper; const float name_size = 42 * u; float x = p.x + pad_x; const char *plate = lobby.official ? "OFFICIAL" : lobby.dedicated ? "SERVER" : "LOBBY"; const float plate_width = width_of(ui.bold, 24 * u, plate) + 20 * u, plate_top = top + (row_height - 38 * u) * .5f; draw->AddRectFilled(ImVec2(x, plate_top), ImVec2(x + plate_width, plate_top + 38 * u), lit ? IM_COL32(0, 0, 0, 90) : lobby.official ? skate_theme::official : skate_theme::tile_light, 4 * u); draw->AddText(ui.bold, 24 * u, ImVec2(x + 10 * u, plate_top + 7 * u), lit || lobby.official ? theme::paper : theme::muted, plate); x += plate_width + 18 * u; if (row.favorite) { star(draw, ImVec2(x + 18 * u, top + row_height * .5f), 20 * u, lit ? theme::paper : skate_theme::warning); x += 48 * u; } const bool friends = !lobby.friends.empty(); label(draw, ui.bold, name_size, ImVec2(x, top + (friends ? 14 * u : (row_height - name_size) * .5f)), map_x - 24 * u, ink, view::caption(lobby.name, 64)); if (friends) label(draw, ui.body, 28 * u, ImVec2(x, top + 62 * u), map_x - 24 * u, lit ? theme::paper : skate_theme::friends_text, "with " + lobby_friends_text(lobby)); const auto count = row.full ? std::string("FULL") : std::to_string(lobby.players) + " / " + std::to_string(lobby.capacity); const float count_x = count_right - width_of(ui.bold, name_size, count); float map_right = count_x - 24 * u; if (lit) { // What pressing it does, on the game's yellow: there is no other join button. const char *does = mp.lobby_joining ? "JOINING" : row.here ? "YOU ARE HERE" : row.own ? "YOURS" : row.full ? nullptr : mp.active && mp.hosting ? "END YOUR SESSION FIRST" : "JOIN"; if (does) { const float w = width_of(ui.bold, 30 * u, does) + 40 * u; map_right -= w + 20 * u; const ImVec2 c(map_right + 8 * u, top + 22 * u), d(c.x + w, top + row_height - 22 * u); skate_theme::rough_rect(draw, c, d, row.joinable ? sticky : sticky_off, 0x51, u * 2); draw->AddText(ui.bold, 30 * u, ImVec2(c.x + 20 * u, c.y + (d.y - c.y - 30 * u) * .5f), skate_theme::black, does); } } if (lobby.password_required) { map_right -= width_of(ui.bold, 24 * u, "PASSWORD") + 24 * u; draw->AddText(ui.bold, 24 * u, ImVec2(map_right + 8 * u, top + (row_height - 24 * u) * .5f), lit ? theme::paper : skate_theme::warning, "PASSWORD"); } label(draw, ui.body, 36 * u, ImVec2(map_x, top + (row_height - 36 * u) * .5f), map_right, lit ? theme::paper : row.full ? theme::muted : soft, row.map); draw->AddText(ui.bold, name_size, ImVec2(count_x, top + (row_height - name_size) * .5f), row.full && !lit ? skate_theme::danger : ink, count.c_str()); } list.end(); } if (h.rows.empty()) { const char *line = mp.lobby_searching ? "Finding skaters..." : !mp.lobby_searched ? "Looking for sessions..." : h.listed ? "Nothing matches these filters." : "Nobody is hosting right now. Host a lobby and invite some skaters."; draw->AddText(ui.body, 40 * u, ImVec2(la.x + pad_x, la.y + header + 40 * u), theme::muted, line); } { const auto total = std::to_string(h.rows.size()) + (h.rows.size() == h.listed ? "" : " of " + std::to_string(h.listed)) + (h.listed == 1 ? " session, " : " sessions, ") + std::to_string(h.skaters) + " skating"; draw->AddText(ui.body, 30 * u, ImVec2(la.x + pad_x, z.y - 44 * u), theme::muted, total.c_str()); float x = la.x + pad_x + width_of(ui.body, 30 * u, total) + 60 * u; const auto hint = [&](std::uint32_t pad_button, const char *key, const char *what) { const auto name = ui.pad.available ? controller_combo_label(pad_button, ui.pad.style) : std::string(key); if (!name.empty()) x += theme::keycap(draw, ui.bold, u * 2, ImVec2(x, z.y - 46 * u), name.c_str(), what) + 36 * u; }; hint(0x1000, "Enter", "Join"); if (ui.pad.available) hint(0x4000, "", "Refresh"), hint(0x8000, "", "Sort"); else hint(0, "F", "Favorite"); } // ---- beside the list: the chosen session, or joining with a code const ImVec2 da(lz.x + gap, la.y), dz(z.x, z.y); skate_theme::rough_rect(draw, da, dz, skate_theme::tile, 0x77, u * 2); const float inset = 40 * u, left = da.x + inset, edge = dz.x - inset, tall = 110 * u; const ImVec2 foot(left, dz.y - inset - 84 * u); if (h.code_mode) { float y = da.y + inset; draw->AddText(ui.heading, 56 * u, ImVec2(left, y), theme::paper, "JOIN BY CODE"); y += 84 * u; label(draw, ui.body, 32 * u, ImVec2(left, y), edge, theme::muted, "Ask the host for their session code. You will travel to their map."); y += 76 * u; draw->AddText(ui.bold, 28 * u, ImVec2(left, y), theme::muted, "JOIN CODE"); y += 44 * u; bool go = field(ui, hash("code"), ImVec2(left, y), ImVec2(edge, y + bar), h.code, "Paste a ReSkate join code", false, 120); y += bar + 36 * u; draw->AddText(ui.bold, 28 * u, ImVec2(left, y), theme::muted, "PASSWORD"); y += 44 * u; go |= field(ui, hash("code-password"), ImVec2(left, y), ImVec2(edge, y + bar), h.code_password, "Only if it has one", true); y += bar + 40 * u; const bool idle = !mp.active && !mp.lobby_joining; go |= button(ui, hash("join-code"), ImVec2(left, y), ImVec2(edge, y + tall), mp.lobby_joining ? "JOINING..." : idle ? "JOIN" : "LEAVE YOUR SESSION FIRST", Look::sticky, idle && !h.code.empty()); if (go && idle && !h.code.empty() && send(h, "join", h.code, h.code_password)) h.joining_name.clear(), asked_join(h), say(h, "Joining..."), wipe(h.code_password); y += tall + 20 * u; label(draw, ui.body, 30 * u, ImVec2(left, y), edge, theme::muted, view::caption(!h.notice.empty() ? h.notice : mp.status, 96)); if (button(ui, hash("code-back"), foot, ImVec2(edge, foot.y + 84 * u), "BACK TO THE LIST", Look::plain)) h.code_mode = false, h.focus_next = hash("code-open"); return; } if (const auto *row = selected_row(h)) { const auto &lobby = *row->lobby; float y = da.y + inset; label(draw, ui.heading, 56 * u, ImVec2(left, y), edge, theme::paper, view::caption(lobby.name, 64)); y += 72 * u; label(draw, ui.body, 32 * u, ImVec2(left, y), edge, lobby.official ? skate_theme::official_text : theme::muted, std::string(lobby.official ? "Official server" : lobby.dedicated ? "Dedicated server" : "Player lobby") + (lobby.password_required ? ", password needed" : "")); y += 66 * u; const auto fact = [&](const char *name, const std::string &value, ImU32 colour = theme::paper) { draw->AddText(ui.body, 34 * u, ImVec2(left, y), theme::muted, name); const float w = std::min(width_of(ui.bold, 34 * u, value), edge - left - 260 * u); label(draw, ui.bold, 34 * u, ImVec2(edge - w, y), edge, colour, value); y += 54 * u; }; fact("Map", row->map); fact("Skaters", std::to_string(lobby.players) + " / " + std::to_string(lobby.capacity), row->full ? skate_theme::danger : theme::paper); if (lobby.ping >= 0) fact("Ping", std::to_string(lobby.ping) + " ms"); if (!lobby.friends.empty()) fact("Friends here", lobby_friends_text(lobby), skate_theme::friends_text); const float fill = lobby.capacity > 0 ? std::clamp(static_cast(lobby.players) / static_cast(lobby.capacity), 0.f, 1.f) : 0.f; draw->AddRectFilled(ImVec2(left, y + 4 * u), ImVec2(edge, y + 18 * u), skate_theme::tile_light, 4 * u); draw->AddRectFilled(ImVec2(left, y + 4 * u), ImVec2(left + (edge - left) * fill, y + 18 * u), row->full ? skate_theme::danger : theme::blue, 4 * u); y += 56 * u; bool go = chosen == row; if (lobby.password_required && row->joinable) { draw->AddText(ui.bold, 28 * u, ImVec2(left, y), theme::muted, "PASSWORD"); y += 42 * u; go |= field(ui, hash("password"), ImVec2(left, y), ImVec2(edge, y + bar), h.password, "Type it here", true); y += bar + 24 * u; label(draw, ui.body, 28 * u, ImVec2(left, y), edge, theme::muted, "Type it, then press Enter or the session to join."); y += 52 * u; } if (go) join(h, *row); const auto &status = !h.notice.empty() ? h.notice : mp.active && !mp.hosting && !mp.echo && row->joinable ? std::string("Joining leaves the session you are in.") : mp.browser_status; label(draw, ui.body, 30 * u, ImVec2(left, y), edge, theme::muted, view::caption(status, 96)); // A server can be kept as a favorite: it is listed first from then on. if (lobby.dedicated && button(ui, hash("favorite"), ImVec2(left, foot.y - 100 * u), ImVec2(edge, foot.y - 16 * u), row->favorite ? "REMOVE FROM FAVORITES" : "ADD TO FAVORITES", Look::plain)) { toggle_favorite(h, lobby); rebuild(h); // `row` is gone from here on } } else { draw->AddText(ui.body, 36 * u, ImVec2(left, da.y + inset), theme::muted, "Pick a session to see it here."); label(draw, ui.body, 30 * u, ImVec2(left, da.y + inset + 60 * u), edge, theme::muted, view::caption(!h.notice.empty() ? h.notice : mp.browser_status, 96)); } if (button(ui, hash("code-open"), foot, ImVec2(edge, foot.y + 84 * u), "JOIN BY CODE", Look::plain)) h.code_mode = true, h.focus_next = hash("code"); } // ---------------------------------------------------------------- hosting void host_page(Ui &ui, ImVec2 a, ImVec2 z) { auto &h = ui.h; const auto &mp = ui.mp; const float u = ui.u, bar = 96 * u, row = 104 * u, gap = 12 * u; auto *draw = ui.draw; if (!h.host_seeded && mp.saved_host.loaded) { // Start from the host settings used last time. h.host_seeded = true; h.public_lobby = mp.saved_host.public_lobby; h.capacity = static_cast(mp.saved_host.capacity); h.tps = mp.saved_host.tps; if (!mp.saved_host.lobby_name.empty()) h.host_name = mp.saved_host.lobby_name, h.host_named = true; } if (!h.host_named && !mp.local_name.empty()) h.host_name = mp.local_name, h.host_named = true; const bool idle = !mp.active && !mp.lobby_joining; const float left_width = std::min((z.x - a.x) * .5f, 1800 * u); float y = a.y; draw->AddText(ui.heading, 52 * u, ImVec2(a.x, y), theme::paper, "MAKE IT YOUR SESSION"); y += 84 * u; draw->AddText(ui.bold, 28 * u, ImVec2(a.x, y), theme::muted, "LOBBY NAME"); y += 44 * u; field(ui, hash("host-name"), ImVec2(a.x, y), ImVec2(a.x + left_width, y + bar), h.host_name, "Your Steam name", false, 120); y += bar + 40 * u; draw->AddText(ui.bold, 28 * u, ImVec2(a.x, y), theme::muted, "PASSWORD (OPTIONAL)"); y += 44 * u; field(ui, hash("host-password"), ImVec2(a.x, y), ImVec2(a.x + left_width, y + bar), h.host_password, "Leave blank for an open lobby", true); y += bar + 48 * u; draw->AddText(ui.bold, 28 * u, ImVec2(a.x, y), theme::muted, "MAP"); label(draw, ui.bold, 40 * u, ImVec2(a.x, y + 44 * u), a.x + left_width, theme::paper, view::map_name(mp.map, ui.h.full.levels)); const ImVec2 sa(a.x + left_width + 60 * u, a.y); const float right = std::min(z.x, sa.x + 1500 * u); y = sa.y; draw->AddText(ui.heading, 52 * u, ImVec2(sa.x, y), theme::paper, "LOBBY SETTINGS"); y += 84 * u; const auto setting = [&](const char *name, const std::string &value) { const bool pressed = button(ui, hash(name, 3), ImVec2(sa.x, y), ImVec2(right, y + row), name, Look::tile, idle, value); y += row + gap; return pressed; }; if (setting("Visibility", (mp.hosting ? mp.public_host : h.public_lobby) ? "Public" : "Join code")) h.public_lobby = !h.public_lobby; if (setting("Skaters", std::to_string(mp.hosting ? static_cast(mp.capacity) : h.capacity))) { constexpr std::array limits{2, 4, 8, 16, static_cast(multiplayer_lobby_player_limit)}; const auto next = std::upper_bound(limits.begin(), limits.end(), h.capacity); h.capacity = next == limits.end() ? limits.front() : *next; } if (setting("Tick rate", std::to_string(mp.hosting ? mp.tps : h.tps))) { const auto next = std::upper_bound(multiplayer_tick_rates.begin(), multiplayer_tick_rates.end(), h.tps); h.tps = next == multiplayer_tick_rates.end() ? multiplayer_tick_rates.front() : *next; } label(draw, ui.body, 30 * u, ImVec2(sa.x, y + 4 * u), right, theme::muted, h.public_lobby ? "Listed in the server browser for anyone to join." : "Only skaters you give your code to can join."); y += 64 * u; const bool ready = idle && mp.local_ready; if (button(ui, hash("host"), ImVec2(sa.x, y), ImVec2(right, y + 120 * u), !idle ? "SESSION ALREADY ACTIVE" : mp.local_ready ? "HOST LOBBY" : "LOAD A MAP TO HOST", Look::sticky, ready)) { if (h.host_name.size() > 128) say(h, "Lobby name is too long. Use a shorter name."); else if (send(h, "host-config", std::string(h.public_lobby ? "public " : "code ") + std::to_string(h.capacity) + " " + std::to_string(h.tps) + " " + h.host_name, h.host_password)) wipe(h.host_password); } y += 140 * u; label(draw, ui.body, 30 * u, ImVec2(sa.x, y), right, theme::muted, view::caption(!h.notice.empty() ? h.notice : mp.status, 110)); } // ---------------------------------------------------------------- the session void session_page(Ui &ui, ImVec2 a, ImVec2 z) { auto &h = ui.h; const auto &mp = ui.mp; const float u = ui.u, gap = 28 * u, row = 96 * u, row_gap = 8 * u, list_width = (z.x - a.x) * .5f; auto *draw = ui.draw; label(draw, ui.heading, 52 * u, a, a.x + list_width, theme::paper, view::caption(mp.lobby_name.empty() ? "CURRENT SESSION" : mp.lobby_name, 64)); draw->AddText(ui.bold, 28 * u, ImVec2(a.x, a.y + 76 * u), theme::muted, ("SKATERS " + std::to_string(mp.players) + " / " + std::to_string(mp.capacity)).c_str()); { // Who is here: a list to read through. The rows take the focus only so that the D-pad // can scroll it. Scroller list(ui, hash("players"), ImVec2(a.x, a.y + 128 * u), ImVec2(a.x + list_width, z.y)); const auto person = [&](std::uint64_t id, const std::string &name, const std::string &role) { const auto key = hash("player", id); bool drawn{}; const float top = list.next(row, row_gap, drawn); const ImVec2 p(a.x, top), q(a.x + list_width - 20 * u, top + row); const Hit hit = item(ui, key, p, q, &list.box); list.reveal(key, top, row); if (!drawn) return; skate_theme::rough_rect(draw, p, q, hit.focus && h.ring ? row_hover : skate_theme::tile, key & 0xfff, u * 2); const float w = role.empty() ? 0 : width_of(ui.bold, 28 * u, role) + 30 * u; label(draw, ui.bold, 40 * u, ImVec2(p.x + 30 * u, top + (row - 40 * u) * .5f), q.x - w - 30 * u, theme::paper, view::caption(name, 48)); if (!role.empty()) draw->AddText(ui.bold, 28 * u, ImVec2(q.x - w, top + (row - 28 * u) * .5f), theme::muted, role.c_str()); }; person(mp.local_id, mp.local_name, mp.hosting ? "YOU, HOST" : mp.parties && mp.party_leader ? "YOU, PARTY LEADER" : "YOU"); for (const auto &player : mp.roster) { if (player.id == mp.local_id || !player.connected) continue; person(player.id, player.name, player.id == mp.host_id ? "HOST" : !mp.parties || !player.party_member ? "" : player.party_leader ? "PARTY LEADER" : "PARTY"); } list.end(); } // Beside the list: the session and its settings. const ImVec2 da(a.x + list_width + gap, a.y), dz(z.x, z.y); skate_theme::rough_rect(draw, da, dz, skate_theme::tile, 0x7a, u * 2); const float inset = 36 * u, left = da.x + inset, edge = dz.x - inset - 14 * u, opt = 84 * u, opt_gap = 10 * u; Scroller side(ui, hash("session-side"), ImVec2(da.x, da.y + inset), ImVec2(dz.x, dz.y - inset)); const auto text = [&](ImFont *font, float size, ImU32 colour, const std::string &line, float below) { bool drawn{}; const float top = side.next(size, below, drawn); if (drawn) label(draw, font, size, ImVec2(left, top), edge, colour, line); }; // A row of the panel: a setting (with `value`) or a plain action. Returns whether it was pressed. const auto option = [&](const std::string &name, const std::string &value, bool enabled, Look look = Look::plain) { const auto id = hash(name, 5); bool drawn{}; const float top = side.next(opt, opt_gap, drawn); side.reveal(id, top, opt); // (Drawn or not, it keeps its place in the focus order, so the D-pad can reach it.) return button(ui, id, ImVec2(left, top), ImVec2(edge, top + opt), name, look, enabled, value, &side.box); }; { // Leaving comes first: it is what most visits to this page are for. if (option(mp.hosting ? "End session" : "Leave server", {}, true, Look::sticky)) send(h, "stop"); bool drawn{}; side.next(10 * u, 10 * u, drawn); text(ui.bold, 28 * u, theme::muted, "MAP", 10 * u); text(ui.bold, 40 * u, theme::paper, view::map_name(mp.map, ui.h.full.levels), 30 * u); if (!mp.invite.empty()) { text(ui.bold, 28 * u, theme::muted, "JOIN CODE", 10 * u); text(ui.bold, 36 * u, theme::paper, mp.invite, 16 * u); if (option("Copy join code", {}, true)) ImGui::SetClipboardText(mp.invite.c_str()), say(h, "Join code copied."); } if (mp.parties) { for (const auto &invite : mp.party_invites) { const auto from = std::to_string(invite.from); text(ui.body, 32 * u, theme::paper, view::caption(invite.name, 32) + " invited you to their party", 12 * u); if (option("Accept invite from " + view::caption(invite.name, 20), {}, true)) send(h, "party", "accept " + from); if (option("Decline " + view::caption(invite.name, 20), {}, true)) send(h, "party", "decline " + from); } if (mp.party) { if (mp.party_leader && option("Party", mp.party_open ? "Anyone can join" : "Invite only", true)) send(h, "party", mp.party_open ? "close" : "open"); if (option("Leave party", {}, true)) send(h, "party", "leave"); } } text(ui.body, 30 * u, theme::muted, "Tick rate: " + std::to_string(mp.tps), 16 * u); } text(ui.body, 30 * u, theme::muted, view::caption(!h.notice.empty() ? h.notice : mp.status, 110), 0); side.end(); } // ---------------------------------------------------------------- voice chat constexpr std::array voice_distances{10.f, 20.f, 35.f, 50.f, 75.f, 100.f, 150.f, 300.f}; constexpr std::array voice_volumes{0.f, .25f, .5f, .75f, 1.f, 1.5f, 2.f, 3.f, 5.f}; template float next_step(float current, const std::array &steps) { const auto found = std::upper_bound(steps.begin(), steps.end(), current + 0.001f); return found == steps.end() ? steps.front() : *found; } std::string volume_label(float value) { auto text = std::to_string(value); return text.substr(0, text.find('.') + 3) + "x"; } void voice_page(Ui &ui, ImVec2 a, ImVec2 z) { auto &h = ui.h; const auto &mp = ui.mp; const float u = ui.u, gap = 28 * u, row = 96 * u, row_gap = 8 * u, list_width = (z.x - a.x) * .5f; auto *draw = ui.draw; if (h.voice_pending && (*h.voice_pending == mp.voice.settings || GetTickCount64() >= h.voice_pending_until)) h.voice_pending.reset(); auto value = h.voice_pending.value_or(mp.voice.settings); draw->AddText(ui.heading, 52 * u, a, theme::paper, "SKATERS IN YOUR SESSION"); { Scroller list(ui, hash("voices"), ImVec2(a.x, a.y + 96 * u), ImVec2(a.x + list_width, z.y)); bool anyone{}; for (const auto &player : mp.roster) { if (player.id == mp.local_id || !player.connected) continue; anyone = true; const auto id = std::to_string(player.id); const auto found = std::find_if(mp.voice.players.begin(), mp.voice.players.end(), [&](const auto &entry) { return entry.id == player.id; }); const auto voice = found == mp.voice.players.end() ? VoicePlayer{player.id} : *found; bool drawn{}; const float top = list.next(row, row_gap, drawn); const float right = a.x + list_width - 20 * u, volume_x = right - 330 * u, mute_x = volume_x - 12 * u - 260 * u; const auto mute = hash("mute", player.id), volume = hash("volume", player.id); list.reveal(mute, top, row), list.reveal(volume, top, row); if (drawn) { skate_theme::rough_rect(draw, ImVec2(a.x, top), ImVec2(mute_x - 12 * u, top + row), skate_theme::tile, mute & 0xfff, u * 2); label(draw, ui.bold, 40 * u, ImVec2(a.x + 30 * u, top + (row - 40 * u) * .5f), mute_x - 40 * u, mp.voice.allowed && voice.speaking && !voice.muted ? skate_theme::good : theme::paper, view::caption(player.name, 40)); } if (button(ui, mute, ImVec2(mute_x, top), ImVec2(volume_x - 12 * u, top + row), voice.muted ? "MUTED" : "MUTE", Look::plain, true, {}, &list.box)) send(h, "voice-mute", id + (voice.muted ? " off" : " on")); if (button(ui, volume, ImVec2(volume_x, top), ImVec2(right, top + row), "Volume", Look::plain, true, volume_label(voice.volume), &list.box)) send(h, "voice-volume", id + " " + std::to_string(next_step(voice.volume, voice_volumes))); } list.end(); if (!anyone) { draw->AddText(ui.body, 38 * u, ImVec2(a.x, a.y + 110 * u), theme::muted, mp.active ? "No other skaters in the session yet." : "You are not in a session yet."); draw->AddText(ui.body, 32 * u, ImVec2(a.x, a.y + 164 * u), theme::muted, "These voice settings apply to every session you join."); } } const ImVec2 sa(a.x + list_width + gap, a.y); const float right = std::min(z.x, sa.x + 1500 * u), opt = 92 * u; float y = sa.y; draw->AddText(ui.heading, 52 * u, ImVec2(sa.x, y), theme::paper, "VOICE SETTINGS"); y += 84 * u; bool changed{}; const auto setting = [&](const char *name, const std::string &shown, bool enabled = true) { const bool pressed = button(ui, hash(name, 4), ImVec2(sa.x, y), ImVec2(right, y + opt), name, Look::tile, enabled, shown); y += opt + 10 * u; return pressed; }; if (setting("Voice chat", value.enabled ? "On" : "Off")) value.enabled = !value.enabled, changed = true; if (setting("Transmit", value.open_mic ? "Open mic" : "Push to talk")) value.open_mic = !value.open_mic, changed = true; if (!value.open_mic) { std::string binds = "Talk key: " + voice_key_name(value.push_to_talk); if (value.controller_combo) binds += " Pad: " + controller_combo_label(value.controller_combo, ui.pad.style); label(draw, ui.body, 30 * u, ImVec2(sa.x + 30 * u, y), right, theme::muted, binds); y += 50 * u; } if (setting("Proximity voice", value.proximity ? "On" : "Off")) value.proximity = !value.proximity, changed = true; if (setting("Voice distance", std::to_string(static_cast(value.distance + .5f)) + " m", value.proximity)) value.distance = next_step(value.distance, voice_distances), changed = true; if (setting("Listening volume", volume_label(value.volume))) value.volume = next_step(value.volume, voice_volumes), changed = true; if (setting("Microphone volume", volume_label(value.microphone))) value.microphone = next_step(value.microphone, voice_volumes), changed = true; if (changed) { const auto argument = std::to_string(value.enabled) + " " + std::to_string(value.proximity) + " " + std::to_string(value.push_to_talk) + " " + std::to_string(value.distance) + " " + std::to_string(value.volume) + " " + std::to_string(value.open_mic) + " " + std::to_string(value.controller_combo) + " " + std::to_string(value.microphone); if (send(h, "voice", argument)) h.voice_pending = value, h.voice_pending_until = GetTickCount64() + 2000; } if (mp.hosting) { if (setting("Lobby voice", mp.voice.allowed ? "Allowed" : "Disabled")) send(h, "voice-allow", mp.voice.allowed ? "off" : "on"); } else if (mp.active && !mp.voice.allowed) { label(draw, ui.body, 30 * u, ImVec2(sa.x + 30 * u, y), right, skate_theme::warning, "The host has disabled voice chat for this lobby."); y += 50 * u; } label(draw, ui.body, 30 * u, ImVec2(sa.x + 30 * u, y + 8 * u), right, mp.voice.transmitting ? skate_theme::good : theme::muted, !h.notice.empty() ? h.notice : mp.voice.transmitting ? std::string("MICROPHONE TRANSMITTING") : mp.voice.status); } // A join under way, as a card over the middle of the page (which is dimmed behind it): where // to, what is happening, and the way out. void connecting_page(Ui &ui, ImVec2 a, ImVec2 z) { auto &h = ui.h; const auto &mp = ui.mp; const float u = ui.u, width = 1500 * u, inset = 48 * u, tall = 100 * u; const float height = inset + 28 * u + 30 * u + 64 * u + 30 * u + 34 * u + 40 * u + 14 * u + 44 * u + tall + inset; const ImVec2 p(a.x + (z.x - a.x - width) * .5f, a.y + std::max(0.f, (z.y - a.y - height) * .4f)), q(p.x + width, p.y + height); auto *draw = ui.draw; draw->AddRectFilled(ImVec2(0, 0), ImGui::GetIO().DisplaySize, IM_COL32(0, 0, 0, 150)); skate_theme::rough_rect(draw, p, q, IM_COL32(26, 26, 26, 252), 0x4c0, u * 2); const float left = p.x + inset, edge = q.x - inset; float y = p.y + inset; draw->AddText(ui.bold, 28 * u, ImVec2(left, y), theme::blue, "CONNECTING"); y += 28 * u + 30 * u; const auto name = !h.joining_name.empty() ? h.joining_name : !mp.lobby_name.empty() ? view::caption(mp.lobby_name, 40) : std::string("Joining the session"); label(draw, ui.heading, 64 * u, ImVec2(left, y), edge, theme::paper, name); y += 64 * u + 30 * u; label(draw, ui.body, 34 * u, ImVec2(left, y), edge, theme::muted, view::caption(mp.status.empty() ? std::string("Connecting...") : mp.status, 100)); y += 34 * u + 40 * u; // Not a measure of anything: a block going back and forth while it is under way. draw->AddRectFilled(ImVec2(left, y), ImVec2(edge, y + 14 * u), skate_theme::tile_light, 4 * u); const float at = static_cast(std::fmod(ImGui::GetTime(), 2.0)), span = edge - left; const float where = (at < 1.f ? at : 2.f - at) * span * .75f; draw->AddRectFilled(ImVec2(left + where, y), ImVec2(left + where + span * .25f, y + 14 * u), theme::blue, 4 * u); y += 14 * u + 44 * u; const auto cancel = hash("join-cancel"); if (button(ui, cancel, ImVec2(left, y), ImVec2(edge, y + tall), "CANCEL", Look::plain) && send(h, "stop")) h.join_cancelled = true, say(h, "Cancelled."); if (!h.focus) h.focus_next = cancel; } // A join that did not get in, in the connecting card's place: where to, why, and a button that // puts it away. void failed_page(Ui &ui, ImVec2 a, ImVec2 z) { auto &h = ui.h; const float u = ui.u, width = 1500 * u, inset = 48 * u, tall = 100 * u, size = 34 * u; const float column = width - inset * 2; const auto reason = view::caption(h.failed, 400); const float lines = std::min(ui.body->CalcTextSizeA(size, FLT_MAX, column, reason.c_str()).y, size * 6.2f); const float height = inset + 28 * u + 30 * u + 64 * u + 30 * u + lines + 44 * u + tall + inset; const ImVec2 p(a.x + (z.x - a.x - width) * .5f, a.y + std::max(0.f, (z.y - a.y - height) * .4f)), q(p.x + width, p.y + height); auto *draw = ui.draw; draw->AddRectFilled(ImVec2(0, 0), ImGui::GetIO().DisplaySize, IM_COL32(0, 0, 0, 150)); skate_theme::rough_rect(draw, p, q, IM_COL32(26, 26, 26, 252), 0x4c0, u * 2); const float left = p.x + inset, edge = q.x - inset; float y = p.y + inset; draw->AddText(ui.bold, 28 * u, ImVec2(left, y), skate_theme::danger, "COULDN'T CONNECT"); y += 28 * u + 30 * u; label(draw, ui.heading, 64 * u, ImVec2(left, y), edge, theme::paper, h.failed_name.empty() ? std::string("The session") : h.failed_name); y += 64 * u + 30 * u; draw->PushClipRect(ImVec2(left, y), ImVec2(edge, y + lines), true); draw->AddText(ui.body, size, ImVec2(left, y), theme::muted, reason.c_str(), nullptr, column); draw->PopClipRect(); y += lines + 44 * u; const auto ok = hash("join-failed-ok"); if (button(ui, ok, ImVec2(left, y), ImVec2(edge, y + tall), "OK", Look::plain)) h.failed.clear(); if (!h.focus) h.focus_next = ok; } // ---------------------------------------------------------------- Mod Options enum ToolsTab : int { official_tab, custom_tab, world_tab, parks_tab, player_tab, online_tab, visuals_tab, binds_tab }; // The next (or the one before) of a setting's steps from where it is now; it stops at the ends. template float stepped(float current, const std::array &steps, int direction) { if (direction > 0) { for (const float step : steps) if (step > current + .001f) return step; return steps.back(); } for (std::size_t i = steps.size(); i-- > 0;) if (steps[i] < current - .001f) return steps[i]; return steps.front(); } std::string whole(float value, const char *unit = "") { return std::to_string(static_cast(value + .5f)) + unit; } // A column of settings that scrolls: each a row with its name and what it is now. struct Column { Ui &ui; Scroller list; float left, right, tall, between; Column(Ui &owner, std::uint32_t id, ImVec2 a, ImVec2 b) : ui(owner), list(owner, id, a, b), left(a.x), right(b.x - 26 * owner.u), tall(96 * owner.u), between(10 * owner.u) {} // A row that is pressed: a switch, a choice that goes round, or an action (no value). bool setting(const std::string &name, const std::string &value, bool enabled, Look look = Look::tile) { const auto id = hash(name, 21); bool drawn{}; const float top = list.next(tall, between, drawn); list.reveal(id, top, tall); return button(ui, id, ImVec2(left, top), ImVec2(right, top + tall), name, look, enabled, value, &list.box); } // A row with a number: less and more at its right end. Returns -1, 0 or 1. int adjust(const std::string &name, const std::string &value, bool enabled) { const float u = ui.u, side = 96 * u, readout = 300 * u; const auto less = hash(name, 22), more = hash(name, 23); bool drawn{}; const float top = list.next(tall, between, drawn); list.reveal(less, top, tall), list.reveal(more, top, tall); const float more_x = right - side, less_x = more_x - readout - side; if (drawn) { skate_theme::rough_rect(ui.draw, ImVec2(left, top), ImVec2(less_x - 10 * u, top + tall), skate_theme::tile, less & 0xfff, u * 2); label(ui.draw, ui.body, 40 * u, ImVec2(left + 30 * u, top + (tall - 40 * u) * .5f), less_x - 30 * u, enabled ? theme::paper : theme::muted, name); skate_theme::rough_rect(ui.draw, ImVec2(less_x + side + 6 * u, top), ImVec2(more_x - 6 * u, top + tall), skate_theme::tile, more & 0xfff, u * 2); const float w = width_of(ui.bold, 40 * u, value); ui.draw->AddText(ui.bold, 40 * u, ImVec2(less_x + side + (readout - w) * .5f, top + (tall - 40 * u) * .5f), enabled ? theme::blue : theme::muted, value.c_str()); } int result{}; if (button(ui, less, ImVec2(less_x, top), ImVec2(less_x + side, top + tall), "-", Look::plain, enabled, {}, &list.box)) result = -1; if (button(ui, more, ImVec2(more_x, top), ImVec2(right, top + tall), "+", Look::plain, enabled, {}, &list.box)) result = 1; return result; } void heading(const char *text) { bool drawn{}; const float top = list.next(52 * ui.u, 30 * ui.u, drawn); if (drawn) ui.draw->AddText(ui.heading, 52 * ui.u, ImVec2(left, top), theme::paper, text); } void title(const char *text) { bool drawn{}; const float top = list.next(54 * ui.u, 12 * ui.u, drawn); if (drawn) ui.draw->AddText(ui.bold, 28 * ui.u, ImVec2(left, top + 26 * ui.u), theme::muted, text); } void note(const std::string &text, ImU32 colour = theme::muted) { if (text.empty()) return; const float size = 30 * ui.u, wrap = right - left; bool drawn{}; const float top = list.next(ui.body->CalcTextSizeA(size, FLT_MAX, wrap, text.c_str()).y + 6 * ui.u, 18 * ui.u, drawn); if (drawn) ui.draw->AddText(ui.body, size, ImVec2(left, top + 6 * ui.u), colour, text.c_str(), nullptr, wrap); } void end() { list.end(); } }; // The page's tabs. What a control does, and when it can, is the same as on ReSkate's own menu // (native_tools_view.h for travel, parks and the world; the same requests and commands that // menu sends for the rest); how it is laid out is here. void tools_page(Ui &ui, ImVec2 a, ImVec2 z) { auto &h = ui.h; const auto &m = h.full; const auto &mp = m.multiplayer; const auto &cb = h.callbacks; const float u = ui.u, gap = 28 * u, tall = 96 * u, between = 10 * u; auto *draw = ui.draw; native_tools::sync(h.tools, m); const auto changed = [&] { h.next_model = {}; }; // what it changed shows at once const auto act = [&](std::string_view command, const std::string &argument = {}) { native_tools::activate(h.tools, m, cb, command, argument); if (!h.tools.feedback.empty()) say(h, std::exchange(h.tools.feedback, {})); changed(); }; const bool console = cb.queue_console_command != nullptr, debugging = cb.queue_debug != nullptr; const auto run = [&](const std::string &line) { std::array result{}; if (!console || !cb.queue_console_command(cb.user, line.c_str(), result.data(), result.size())) say(h, "Couldn't apply that change. Try again when the game is ready."); changed(); }; const auto debug = [&](DebugRequest request) { std::array result{}; if (!debugging || !cb.queue_debug(cb.user, request, result.data(), result.size())) say(h, "Couldn't apply that change. Try again when the game is ready."); changed(); }; const auto online = [&](const char *action, const std::string &argument) { send(h, action, argument); changed(); }; const auto on_off = [](bool on) { return std::string(on ? "On" : "Off"); }; const float column_width = std::min(z.x - a.x, 2100 * u); // Every map as a grid of cards, each with its picture (an official map's loading screen, a custom // map's mod icon: the same pictures as the ReSkate menu's map grid, menu_map_pictures.cpp) and its // name under it: the game's maps, then the custom ones under their own heading. The one pointed at // says LOAD, and pressing it travels there. (Not Tutorial Island: the game's first-time tutorial.) const auto levels = [&](const char *title) { draw->AddText(ui.heading, 52 * u, a, theme::paper, title); std::vector listed; for (const bool custom : {false, true}) { const auto first = listed.size(); for (const auto &entry : m.levels) if (native_tools::listed(entry) && entry.custom == custom && world_level_name(entry.asset) != "Tutorial Island") listed.push_back(&entry); std::sort(listed.begin() + static_cast(first), listed.end(), [](const auto *x, const auto *y) { return native_tools::level_name(*x) < native_tools::level_name(*y); }); } { std::vector wanted; for (const auto *entry : listed) wanted.push_back({entry->asset, native_tools::level_name(*entry)}); want_menu_map_pictures(std::move(wanted)); } // Why a level cannot be loaded now, or what loading one said: beside the title. const std::string note = multiplayer_controls_level(mp) ? "Only the lobby host can change levels." : !m.can_queue_load ? "Travel is unavailable right now. Wait for the game to finish loading." : h.notice; if (!note.empty()) { const float after = a.x + width_of(ui.heading, 52 * u, title) + 48 * u; label(draw, ui.body, 30 * u, ImVec2(after, a.y + 14 * u), z.x - 20 * u, theme::muted, note); } const auto *root = native_tools::level(m, native_tools::root_asset); const bool travel = root && root->native_registered && root->can_load && m.can_queue_load && !multiplayer_controls_level(mp) && cb.queue_load; const float card_gap = 24 * u, room = z.x - a.x - 16 * u; const int columns = std::max(1, static_cast((room + card_gap) / (540 * u + card_gap))); const float card = (room - card_gap * static_cast(columns - 1)) / static_cast(columns); const float picture = card * 0.5625f, name_bar = 86 * u, card_tall = picture + name_bar; Scroller list(ui, hash(title, 30), ImVec2(a.x, a.y + 96 * u), ImVec2(z.x, z.y)); float row_top{}; bool row_drawn{}; std::size_t in_section{}; bool any_custom{}; for (std::size_t i = 0; i < listed.size(); ++i) { const auto *entry = listed[i]; // The custom maps start a new row under their own heading. if (entry->custom && !any_custom) { any_custom = true; in_section = 0; bool heading_drawn{}; const float heading_top = list.next(70 * u, 0, heading_drawn); if (heading_drawn) draw->AddText(ui.heading, 40 * u, ImVec2(a.x, heading_top + 18 * u), theme::paper, "CUSTOM MAPS"); } const auto column = static_cast(in_section++ % static_cast(columns)); if (!column) row_top = list.next(card_tall, card_gap, row_drawn); const auto id = hash("level", hash(entry->asset)); const bool can = travel && entry->can_load && entry != root; const ImVec2 p(a.x + (card + card_gap) * static_cast(column), row_top), q(p.x + card, row_top + card_tall); const Hit hit = item(ui, id, p, q, &list.box); list.reveal(id, row_top, card_tall); if (hit.press && can) h.tools.destination = entry->asset, act("load-level"); if (!row_drawn) continue; const bool lit = hit.focus || hit.hover; skate_theme::rough_rect(draw, p, q, lit ? theme::blue : skate_theme::tile, id & 0xfff, u * 2); // The picture: the middle band of its square, to the card's shape. const ImVec2 shot_a(p.x + 8 * u, p.y + 8 * u), shot_b(q.x - 8 * u, p.y + picture); ImTextureID texture{}; ImVec2 uv0, uv1; if (menu_map_picture(entry->asset, texture, uv0, uv1)) { const float band = (uv1.y - uv0.y) * (1.0f - (shot_b.y - shot_a.y) / (shot_b.x - shot_a.x)) * 0.5f; draw->AddImage(texture, shot_a, shot_b, ImVec2(uv0.x, uv0.y + band), ImVec2(uv1.x, uv1.y - band), can || lit ? IM_COL32_WHITE : IM_COL32(255, 255, 255, 110)); } else { draw->AddRectFilled(shot_a, shot_b, IM_COL32(18, 18, 20, 255)); } // The name under it, and on the one pointed at what pressing it does. const float bar_top = p.y + picture, text_y = bar_top + (name_bar - 38 * u) * .5f; float name_right = q.x - 24 * u; if (lit) { const char *does = can ? "LOAD" : multiplayer_controls_level(mp) ? "HOST'S CHOICE" : "NOT NOW"; const float w = width_of(ui.bold, 28 * u, does) + 36 * u; const ImVec2 c(q.x - w - 18 * u, bar_top + 18 * u), d(q.x - 18 * u, q.y - 18 * u); skate_theme::rough_rect(draw, c, d, can ? sticky : sticky_off, 0x52, u * 2); draw->AddText(ui.bold, 28 * u, ImVec2(c.x + 18 * u, c.y + (d.y - c.y - 28 * u) * .5f), skate_theme::black, does); name_right = c.x - 16 * u; } label(draw, ui.bold, 38 * u, ImVec2(p.x + 24 * u, text_y), name_right, can || lit ? theme::paper : theme::muted, native_tools::level_name(*entry)); } list.end(); if (listed.empty()) draw->AddText(ui.body, 38 * u, ImVec2(a.x, a.y + 110 * u), theme::muted, "Waiting for available levels..."); }; switch (h.tools_tab) { case official_tab: case custom_tab: levels("FIND YOUR SPOT"); break; // (custom maps are on the one tab now) case world_tab: { Column list(ui, hash("world"), a, ImVec2(a.x + column_width, z.y)); list.heading("THIS WORLD"); const bool population = m.world.ready && m.world_controls.population_available && console; if (list.setting("Traffic", population_labels[std::clamp(m.world_controls.choices.traffic, -1, 3) + 1], population)) act("traffic"); if (list.setting("Pedestrians", population_labels[std::clamp(m.world_controls.choices.pedestrians, -1, 3) + 1], population)) act("pedestrians"); if (list.setting("Time of day", native_tools::time_of_day_labels[native_tools::time_of_day(m)], native_tools::time_of_day_available(m, cb))) act("time-of-day"); if (m.world.controlled_by_host) list.note(server_can(mp, "tod") ? "Your role sets it: time of day changes for the whole server." : "The lobby host controls world layers, including time of day."); list.note(h.notice); list.end(); break; } case parks_tab: { // One card for each of the three lots: its style, its layout, and loading it. draw->AddText(ui.heading, 52 * u, a, theme::paper, "PARK LAYOUTS"); const bool available = native_tools::can_park(m, cb); const float cards = static_cast(park_lots.size()); const float width = std::min(((z.x - a.x) - gap * (cards - 1)) / cards, 1300 * u), top = a.y + 96 * u; const float card_height = 36 * u + 60 * u + (tall + between) * 2 + 16 * u + 110 * u + 36 * u; for (unsigned lot = 0; lot < park_lots.size(); ++lot) { const ImVec2 p(a.x + static_cast(lot) * (width + gap), top), q(p.x + width, top + card_height); skate_theme::rough_rect(draw, p, q, skate_theme::tile, 0x60 + lot, u * 2); const float left = p.x + 36 * u, right = q.x - 36 * u; float y = p.y + 36 * u; label(draw, ui.bold, 40 * u, ImVec2(left, y), right, theme::paper, std::string(park_lots[lot].label)); y += 60 * u; std::string family = "Empty lot"; for (unsigned i = 0; i < park_families.size(); ++i) if (h.tools.parks[lot].starts_with(park_families[i])) family = park_family_labels[i]; const auto row = [&](const char *name, const std::string &value, std::string_view command) { if (button(ui, hash(name, 40 + lot), ImVec2(left, y), ImVec2(right, y + tall), name, Look::plain, available, value)) h.tools.lot = lot, act(command); y += tall + between; }; row("Style", family, "park-family"); row("Layout", park_label(h.tools.parks[lot]), "park-layout"); y += 16 * u; if (button(ui, hash("load-park", lot), ImVec2(left, y), ImVec2(right, y + 110 * u), "LOAD THIS LAYOUT", Look::sticky, available && valid_park(lot, h.tools.parks[lot]))) h.tools.lot = lot, act("load-park"); } Column all(ui, hash("parks"), ImVec2(a.x, top + card_height + 30 * u), ImVec2(a.x + std::min(z.x - a.x, 1500 * u), z.y)); all.title("ALL THREE LOTS"); if (all.setting("Load random parks", {}, available)) act("load-random-parks"); if (all.setting("Randomize on launch", on_off(m.parks.randomize_on_launch), m.parks.available && console)) act("park-random-on-launch"); all.note(m.world.map != WorldMap::bam ? std::string("Load San Vansterdam to change parks.") : native_tools::parks_locked(m) ? std::string("The lobby host controls park layouts.") : m.parks.controlled_by_host ? std::string("Your role sets them: layouts you load change for the whole server.") : h.notice); all.end(); break; } case player_tab: { Column list(ui, hash("player"), a, ImVec2(a.x + column_width, z.y)); const auto &d = m.debug; list.heading("SKATE YOUR WAY"); if (list.setting("Noclip", on_off(d.noclip), (d.noclip_available || d.noclip) && debugging)) debug({DebugAction::set_noclip, !d.noclip}); if (list.setting("No bail", on_off(d.no_bail), (d.no_bail_available || d.no_bail) && debugging)) debug({DebugAction::set_no_bail, !d.no_bail}); if (list.setting("Hall of Meat", on_off(m.hall_of_meat.enabled), m.hall_of_meat.available && console)) act("hall-of-meat"); // (Not listed when its official mod is turned off in the launcher.) if (!m.road_rash.off) { if (list.setting("Road Rash", on_off(m.road_rash.enabled), m.road_rash.available && console)) act("road-rash"); if (list.setting("Road Rash blood", on_off(m.road_rash.blood), m.road_rash.available && m.road_rash.enabled && console)) act("road-rash-blood"); if (list.setting("Heal Road Rash", {}, m.road_rash.available && m.road_rash.enabled && console)) act("road-rash-heal"); } if (!m.board_break.off) { if (list.setting("Board Break", on_off(m.board_break.enabled), m.board_break.available && console)) act("board-break"); if (list.setting("Board Break deck", m.board_break.deck, m.board_break.available && m.board_break.has_deck && console)) act("board-break-deck"); } const auto &grind = m.grind_exit; if (list.setting("Grind exit triggers", on_off(grind.enabled), grind.available && console)) act("grind-exits"); if (list.setting("Input Mode", grind.toe_heel ? "Toe / Heel" : "Left / Right", grind.available && console)) act("grind-exit-sides"); if (grind.toe_heel && list.setting("Your stance", grind.stance == 2 ? "Goofy" : grind.stance == 1 ? "Regular" : "Not set", grind.available && console)) act("grind-exit-stance"); if (list.setting("Pop-out strength", std::to_string(grind.strength) + "%", grind.available && console)) act("grind-exit-strength"); const auto &wear = m.offline.board_wear; if (list.setting("Board wear", on_off(wear.effective), wear.available && cb.queue_offline_feature)) act("board-wear"); if (list.setting("Reset board wear", {}, wear.available && wear.effective && console)) act("board-wear-reset"); list.title("CAMERA"); const bool camera = d.available && d.camera_available && debugging, edit = d.available && debugging; if (list.setting("First person", on_off(d.first_person), camera)) debug({DebugAction::set_first_person, !d.first_person}); if (list.setting("True first person", on_off(d.first_person_arm.stabilize), edit)) debug({DebugAction::set_first_person_stabilize, !d.first_person_arm.stabilize}); if (list.setting("Third person on foot", on_off(d.first_person_arm.board_only), edit)) debug({DebugAction::set_first_person_board_only, !d.first_person_arm.board_only}); if (list.setting("Freecam", on_off(d.free_camera), camera)) debug({DebugAction::set_free_camera, !d.free_camera}); { constexpr std::array speeds{.6f, 3.f, 5.f, 15.f, 60.f, 300.f, 1500.f}; const auto text = std::to_string(d.camera_speed); if (const int way = list.adjust("Flight speed", text.substr(0, text.find('.') + 2), (d.free_camera || d.noclip) && d.camera_available && debugging)) debug({DebugAction::set_camera_speed, false, stepped(d.camera_speed, speeds, way)}); } list.title("BOOSTS"); { constexpr std::array forward{1.f, 5.f, 10.f, 25.f, 50.f, 100.f, 150.f, 200.f, 300.f}; constexpr std::array up{1.f, 2.f, 3.f, 5.f, 8.f, 12.f, 18.f, 25.f}; if (const int way = list.adjust("Forward boost", "+" + whole(d.forward_velocity_speed), debugging)) debug({DebugAction::set_forward_velocity_speed, false, stepped(d.forward_velocity_speed, forward, way)}); if (const int way = list.adjust("Up boost", "+" + whole(d.up_velocity_speed), debugging)) debug({DebugAction::set_up_velocity_speed, false, stepped(d.up_velocity_speed, up, way)}); if (const int way = list.adjust("Off-board up boost", "+" + whole(d.offboard_up_velocity_speed), debugging)) debug({DebugAction::set_offboard_up_velocity_speed, false, stepped(d.offboard_up_velocity_speed, up, way)}); } list.note(!h.notice.empty() ? h.notice : std::string("The buttons for the boosts are on the BINDS tab. Flight speed is for noclip and freecam; close the menu to fly.")); list.end(); break; } case online_tab: { Column list(ui, hash("online"), a, ImVec2(a.x + column_width, z.y)); list.heading("OTHER SKATERS"); if (m.steam_offline) { list.note("Multiplayer is off in offline mode."); } else { constexpr std::array shown{50.f, 75.f, 100.f, 150.f, 200.f, 300.f, 500.f, 1000.f}; constexpr std::array tags{10.f, 25.f, 50.f, 75.f, 120.f, 200.f, 300.f, 500.f}; constexpr std::array reach{5.f, 10.f, 20.f, 40.f, 75.f, 120.f, 200.f}; constexpr std::array seconds{1.f, 3.f, 5.f, 8.f, 15.f, 30.f}; constexpr std::array lines{1.f, 2.f, 3.f, 4.f, 5.f, 6.f, 7.f, 8.f}; if (const int way = list.adjust("Player distance", mp.player_distance >= player_distance_unlimited ? std::string("Everyone") : whole(mp.player_distance, " m"), true)) online("player-distance", whole(stepped(mp.player_distance, shown, way))); if (list.setting("Direct connections", on_off(mp.prefer_direct), true)) online("direct-connections", mp.prefer_direct ? "off" : "on"); list.title("NAMETAGS"); if (list.setting("Nametags", on_off(mp.nametags), true)) online("nametags", mp.nametags ? "off" : "on"); if (const int way = list.adjust("Nametag distance", whole(mp.nametag_distance, " m"), true)) online("nametag-distance", whole(stepped(mp.nametag_distance, tags, way))); if (list.setting("Nametag dots", on_off(mp.nametag_dots), true)) online("nametag-dots", mp.nametag_dots ? "off" : "on"); if (list.setting("Friends' nametags only", on_off(mp.nametags_friends), true)) online("nametags-friends", mp.nametags_friends ? "off" : "on"); list.title("CHAT"); if (list.setting("Text chat", on_off(mp.chat_visible), true)) online("chat-visible", mp.chat_visible ? "off" : "on"); if (list.setting("Chat bubbles", on_off(mp.chat_bubbles), true)) online("chat-bubbles", mp.chat_bubbles ? "off" : "on"); if (list.setting("Own chat bubbles", on_off(mp.chat_bubbles_own), mp.chat_bubbles)) online("chat-bubbles-own", mp.chat_bubbles_own ? "off" : "on"); if (const int way = list.adjust("Bubble distance", whole(mp.chat_bubbles_distance, " m"), mp.chat_bubbles)) online("chat-bubbles-distance", whole(stepped(mp.chat_bubbles_distance, reach, way))); if (const int way = list.adjust("Bubble time", whole(mp.chat_bubbles_duration, " s"), mp.chat_bubbles)) online("chat-bubbles-duration", whole(stepped(mp.chat_bubbles_duration, seconds, way))); if (const int way = list.adjust("Bubble lines", whole(static_cast(mp.chat_bubbles_history)), mp.chat_bubbles)) online("chat-bubbles-history", whole(stepped(static_cast(mp.chat_bubbles_history), lines, way))); if (dingosdk::discord_presence::available()) { list.title("DISCORD"); if (list.setting("Discord status", on_off(dingosdk::discord_presence::enabled()), true)) dingosdk::discord_presence::set_enabled(!dingosdk::discord_presence::enabled()); } if (!mp.identity_tag.empty()) { // Only for players the ReSkate team has given a tag. list.title("YOUR TAG"); if (list.setting("Show my tag", on_off(mp.identity_tag_shown), true)) online("mark-tag", mp.identity_tag_shown ? "off" : "on"); if (list.setting("Show my items", on_off(mp.identity_items_shown), true)) online("mark-items", mp.identity_items_shown ? "off" : "on"); } list.note(h.notice); } list.end(); break; } case visuals_tab: { Column list(ui, hash("visuals"), a, ImVec2(a.x + column_width, z.y)); list.heading("WHAT YOU SEE AND HEAR"); constexpr std::array names{"Film grain", "Vignette", "Chromatic aberration"}; for (unsigned i = 0; i < names.size(); ++i) { const auto choice = m.graphics.choices.effects[i]; const bool on = choice < 0 ? !m.graphics.ready[i] || m.graphics.enabled[i] : choice != 0; if (list.setting(names[i], on_off(on) + (choice < 0 ? " (default)" : ""), m.graphics.available && console)) act("graphics", std::string(graphics_keys[i])); } if (list.setting("Restore default effects", {}, m.graphics.available && console)) act("graphics-reset"); if (list.setting("Keyframe smoothing", on_off(m.keyframe_smoothing.enabled), m.keyframe_smoothing.available && console)) act("keyframe-smoothing"); if (list.setting("Keyframe smoothness", std::to_string(keyframe_smoothness_percent(m.keyframe_smoothing)) + "%", m.keyframe_smoothing.available && m.keyframe_smoothing.enabled && console)) act("keyframe-smoothness"); if (list.setting("Challenges", m.progression.challenges_hidden ? "Hidden" : "Shown", m.progression.challenges_enabled && console)) act("challenges"); { const bool shuffle = dingosdk::profile_runtime::music_shuffle_enabled(); if (list.setting("Shuffle playlists", on_off(shuffle), true)) { dingosdk::profile_runtime::set_music_shuffle_enabled(!shuffle); dingosdk::profile_runtime::set_local_preference("MusicShuffle", !shuffle); } } list.note(!h.notice.empty() ? h.notice : std::string("Hiding challenges keeps your progress.")); list.end(); break; } default: { // Buttons for ReSkate's own actions. Pressing a row records the next button, combination // of buttons or key; for that long the game does not see them. const auto &binds = m.bindings; const bool available = binds.available && console; const auto recording = hash("bind-recording"); const auto save = [&](int action, std::uint32_t combo) { run("bind " + (action >= 10 ? std::string(action_binds[static_cast(action - 10)].name) : std::string(action == 1 ? "freecamcontroller" : action == 2 ? "freecam" : action == 3 ? "noclip" : action == 4 ? "forwardvelocity" : action == 5 ? "upvelocity" : action == 6 ? "tptofreecam" : action == 8 ? "voteyes" : action == 9 ? "voteno" : "offboardupvelocity")) + " " + std::to_string(combo)); }; if (h.recording_bind) { ControllerInput controller; DingoSDKOverlayReadControllerInput(&controller, true); if (!available || GetTickCount64() >= h.recording_until || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { h.recording_bind = 0, set_typing(h, 0); say(h, "Recording cancelled. Your binding is unchanged."); } else if (const auto combo = h.bind_capture.update(controller, true)) { const int action = std::exchange(h.recording_bind, 0); set_typing(h, 0); save(action, *combo); } } Column list(ui, hash("binds"), a, ImVec2(a.x + column_width, z.y)); list.heading("ACTION BINDS"); list.note(h.recording_bind ? std::string("Press the button, combination or key to use. Escape cancels.") : !h.notice.empty() ? h.notice : std::string("Pick an action, then press what should do it. CLEAR takes its button away.")); const auto row = [&](int action, const std::string &name, std::uint32_t combo) { const float u2 = ui.u, clear_width = 260 * u2; const auto id = hash("bind", static_cast(action)), clear = hash("bind-clear", static_cast(action)); bool drawn{}; const float top = list.list.next(list.tall, list.between, drawn); list.list.reveal(id, top, list.tall), list.list.reveal(clear, top, list.tall); const bool mine = h.recording_bind == action, idle = available && !h.recording_bind; if (button(ui, id, ImVec2(list.left, top), ImVec2(list.right - clear_width - 12 * u2, top + list.tall), name, mine ? Look::chosen : Look::tile, idle, mine ? std::string("Recording...") : controller_combo_label(combo, ui.pad.style), &list.list.box)) { h.recording_bind = action; h.bind_capture = {}; h.recording_until = GetTickCount64() + 30000; h.notice.clear(); set_typing(h, recording); } if (button(ui, clear, ImVec2(list.right - clear_width, top), ImVec2(list.right, top + list.tall), "CLEAR", Look::plain, idle && combo != 0, {}, &list.list.box)) save(action, 0); }; row(1, "Freecam controller", binds.freecam_controller_combo); row(2, "Freecam", binds.freecam_combo); row(3, "Noclip", binds.noclip_combo); row(4, "Forward boost", binds.forward_velocity_combo); row(5, "Up boost", binds.up_velocity_combo); row(7, "Off-board up boost", binds.offboard_up_velocity_combo); row(6, "Teleport to freecam", binds.tp_to_freecam_combo); row(8, "Vote yes", binds.vote_yes_combo); row(9, "Vote no", binds.vote_no_combo); for (std::size_t i = 0; i < action_binds.size(); ++i) row(10 + static_cast(i), std::string(action_binds[i].label), binds.action_combos[i]); list.end(); break; } } } // ---------------------------------------------------------------- session settings // What the session allows, for whoever runs it: the host of a lobby, or an admin of a // dedicated server (whose changes go to the server). Nobody else has the tab. void settings_page(Ui &ui, ImVec2 a, ImVec2 z) { auto &h = ui.h; const auto &mp = ui.mp; Column list(ui, hash("session-settings"), a, ImVec2(a.x + std::min(z.x - a.x, 2100 * ui.u), z.y)); list.heading("SESSION SETTINGS"); // (Each is sent as the value it should have, the way a server's own console takes it.) list.note(mp.dedicated ? "You are an admin: these change for the whole server." : "You are the host: these change for everyone in your lobby."); const auto allowed = [](bool on) { return std::string(on ? "Allowed" : "Off"); }; list.title("BUILDING"); if (list.setting("Object placement", mp.dedicated && mp.object_placement == ObjectPlacement::host_only ? std::string("Admins only") : std::string(object_placement_name(mp.object_placement)), true)) send(h, "object-placement", mp.object_placement == ObjectPlacement::everyone ? "host" : mp.object_placement == ObjectPlacement::host_only ? "nobody" : "everyone"); { static constexpr std::array limits{0, 10, 25, 50, 100, 250, 500}; const auto found = std::find(limits.begin(), limits.end(), mp.object_limit); const auto next = limits[found == limits.end() ? 0 : static_cast(found - limits.begin() + 1) % limits.size()]; if (list.setting("Objects per player", mp.object_limit ? std::to_string(mp.object_limit) : std::string("No limit"), true)) send(h, "object-limit", next ? std::to_string(next) : std::string("off")); } if (list.setting("Delete all guest objects", {}, true)) send(h, "clear-objects"); list.title("WHAT GUESTS MAY USE"); if (list.setting("Guest noclip", allowed(mp.guest_noclip), true)) send(h, "noclip-allow", mp.guest_noclip ? "off" : "on"); if (list.setting("Guest No Bail", allowed(mp.guest_no_bail), true)) send(h, "nobail-allow", mp.guest_no_bail ? "off" : "on"); if (list.setting("Guest boosts", allowed(mp.guest_boosts), true)) send(h, "boosts-allow", mp.guest_boosts ? "off" : "on"); list.title("THE WORLD"); if (list.setting("Physics tuning", mp.enforce_tuning ? (mp.dedicated ? "Game's" : "Host's") : "Everyone's own", true)) send(h, "tuning-enforce", mp.enforce_tuning ? "off" : "on"); if (list.setting("World layer sync", mp.force_world_layers ? "On" : "Off", true)) send(h, "world-layer-sync", mp.force_world_layers ? "off" : "on"); list.note(!h.notice.empty() ? h.notice : mp.status); list.end(); } // ---------------------------------------------------------------- focus // Moves the focus one control over, by where the controls were drawn last frame. void move_focus(Hub &h, int dx, int dy) { const Item *from{}; for (const auto &entry : h.last) if (entry.id == h.focus) from = &entry; if (!from) { if (!h.last.empty()) h.focus = h.last.front().id; return; } const ImVec2 centre((from->a.x + from->b.x) * .5f, (from->a.y + from->b.y) * .5f); const Item *best{}; float best_score = FLT_MAX; for (const auto &entry : h.last) { if (entry.id == from->id) continue; // Measured from the nearest point of the other control, so a wide row is as close as it looks. const ImVec2 closest(std::clamp(centre.x, entry.a.x, entry.b.x), std::clamp(centre.y, entry.a.y, entry.b.y)); const float along = dx ? (closest.x - centre.x) * static_cast(dx) : (closest.y - centre.y) * static_cast(dy); const float across = dx ? std::abs(closest.y - centre.y) : std::abs(closest.x - centre.x); const float edge = dx ? (dx > 0 ? entry.a.x - from->b.x : from->a.x - entry.b.x) : (dy > 0 ? entry.a.y - from->b.y : from->a.y - entry.b.y); if (along <= 0 || edge < -2) continue; const float score = along + across * 2.5f; if (score < best_score) best_score = score, best = &entry; } if (!best) return; h.focus = best->id; // Into a list from outside it: back to the row it was left on, if that row is still there. if (best->list && best->list != from->list) if (const auto kept = h.list_focus.find(best->list); kept != h.list_focus.end()) for (const auto &entry : h.last) if (entry.id == kept->second && entry.list == best->list) h.focus = entry.id; } } // namespace bool hub_page_pending() { auto &h = hub(); auto &s = state(); const auto now = Clock::now(); if (now >= h.next_page) { h.next_page = now + 40ms; h.page = {}; if (const auto feed = page_feed.load()) { try { h.page = feed(); } catch (...) {} } if (!h.page.visible) h.dismissed = false; } const bool shown = h.page.visible && !h.dismissed; if (shown != h.shown) { h.shown = shown; h.next_model = {}; h.searched = false; h.last.clear(); if (!shown) { h.recording_bind = 0; set_typing(h, 0); wipe(h.password), wipe(h.code_password), wipe(h.host_password); h.notice.clear(); } else { // What is held as the page comes up is not a press on it: the Back that closed the // game's side menu over it would dismiss the page as well. h.keys_before.fill(true); h.pad_before = ~0U; } } // With the overlay's own menu or console open the page stays where it is, behind them, and // takes no input: they have the pointer then. const bool others = s.visible.load() || s.console_visible.load() || s.editor_visible.load() || s.chat_visible.load(); s.hub_pointer.store(shown && !others); if (shown && now >= h.next_model) { h.next_model = now + 200ms; { std::lock_guard lock(hub_callbacks_mutex); h.callbacks = hub_callbacks; } if (h.callbacks.read_model) { try { h.callbacks.read_model(h.callbacks.user, h.full); h.model = h.full.multiplayer; } catch (...) { h.full = {}, h.model = {}; } h.rows_stale = true; } } return shown; } // 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(); 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); ui.moved = io.MouseDelta.x != 0 || io.MouseDelta.y != 0; ui.wheel = ui.input ? io.MouseWheel : 0; const auto &mp = h.model; if (h.rows_stale) rebuild(h); if (h.notice_until && GetTickCount64() >= h.notice_until) h.notice.clear(), h.notice_until = 0; // Going into a session shows it; leaving one goes back to the browser. // In a session, as far as the tabs go: not one that is still waiting on a map being fetched, // which the player may yet turn down. const bool in_session = mp.active && !mp.lobby_joining && !mp.map_fetching; // A join under way (connecting, the host's map loading): a card over the page says so until // the player is in, or calls it off. A map that has to be fetched first has a card of its // own (map_download_card.cpp), which takes this one's place. const bool connecting = !mp.hosting && !mp.echo && mp.lobby_joining && !mp.map_fetching; // A join is followed to its end. One that ends with the player not in, and not by their own // choice (the card's CANCEL, or a map they were asked about: its card says its own piece), // says why. One asked for here that never shows in the model was turned down at once. if (!mp.hosting && !mp.echo && (mp.lobby_joining || mp.map_fetching)) { if (!h.join_seen) h.failed.clear(); h.join_seen = true; h.join_fetching = mp.map_fetching; h.join_asked = 0; } else if (mp.active) { h.join_seen = h.join_cancelled = false; h.join_asked = 0; } else if (h.join_seen || (h.join_asked && GetTickCount64() - h.join_asked > 2000)) { if (!h.join_cancelled && !(h.join_seen && h.join_fetching)) { const bool said = !mp.status.empty() && mp.status != MultiplayerModel{}.status; h.failed = said ? mp.status : std::string("The session did not answer. It may be full, closed or on another version of ReSkate."); h.failed_name = !h.joining_name.empty() ? h.joining_name : view::caption(mp.lobby_name, 40); h.notice.clear(), h.notice_until = 0; h.focus = 0; } h.join_seen = h.join_cancelled = false; h.join_asked = 0; } if (in_session != h.was_active) { h.was_active = in_session; h.tab = in_session ? Tab::session : Tab::browser; h.selected_player.clear(); } if ((h.tab == Tab::session || h.tab == Tab::voice) && !in_session) h.tab = Tab::browser; const bool runs_session = in_session && (mp.hosting || mp.server_admin); if (h.tab == Tab::settings && !runs_session) h.tab = in_session ? Tab::session : Tab::browser; // ---- the controller and the keys, watched while the game has them if (ui.input) DingoSDKOverlayReadControllerInput(&ui.pad, true); if (!ui.pad.available) ui.pad.buttons = 0; const std::uint32_t pressed = ui.pad.buttons & ~h.pad_before; std::array keys{}; if (ui.input && !h.typing) { OverlayInputAccess access; constexpr std::array codes{VK_UP, VK_DOWN, VK_LEFT, VK_RIGHT, VK_RETURN, 'Z', 'C', VK_ESCAPE}; for (std::size_t i = 0; i < codes.size(); ++i) keys[i] = (GetAsyncKeyState(codes[i]) & 0x8000) != 0; } const auto key_new = [&](std::size_t i) { return keys[i] && !h.keys_before[i]; }; const bool typing_before = h.typing != 0; h.fire = 0; // Moving the mouse puts the pointer in charge: the D-pad's highlight goes, and comes back // with the D-pad. if (ui.moved && !h.typing) h.ring = false; h.focus_moved = false; int previous_tab{}, next_tab{}; if (ui.input && !typing_before) { // Back closes the game's menu, which takes a moment to say so: this goes at once. if (!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 + (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 + (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); h.ring = true; h.focus_moved = true; } if (key_new(4) || (pressed & 0x1000)) { if (h.focus && std::any_of(h.last.begin(), h.last.end(), [&](const Item &entry) { return entry.id == h.focus; })) h.fire = h.focus, h.ring = true; } previous_tab = key_new(5) || (pressed & 0x10000); next_tab = key_new(6) || (pressed & 0x20000); } h.keys_before = keys; // A key that ended the typing (Enter, Escape) is still down: it is not a fresh press of anything. // And so is one held while the game was not the window in front. if (typing_before || !ui.input) h.keys_before.fill(true); if (!ui.input) h.pad_before = ~0U; h.items.clear(); // ---- the page's tabs const bool tools = !embedded && h.page.section == 1; { // (Each page keeps the tab it was left on.) std::vector> tabs; int multiplayer_tab = static_cast(h.tab); int &open = tools ? h.tools_tab : multiplayer_tab; if (tools) { if (h.tools_tab == custom_tab) h.tools_tab = official_tab; tabs = {{official_tab, "MAPS"}, {world_tab, "WORLD"}, {parks_tab, "PARKS"}, {player_tab, "PLAYER"}, {online_tab, "ONLINE"}, {visuals_tab, "VISUALS"}, {binds_tab, "BINDS"}}; } else { tabs = {{static_cast(Tab::browser), "SERVER BROWSER"}, {static_cast(Tab::host), "HOST LOBBY"}}; // The session and its voice chat are only there in one. if (in_session) tabs.emplace_back(static_cast(Tab::session), "CURRENT SESSION"), tabs.emplace_back(static_cast(Tab::voice), "VOICE CHAT"); // And its settings only for whoever runs it. if (runs_session) tabs.emplace_back(static_cast(Tab::settings), "SESSION SETTINGS"); } if (previous_tab || next_tab) { std::size_t at = 0; for (std::size_t i = 0; i < tabs.size(); ++i) if (tabs[i].first == open) at = i; at = (at + tabs.size() + (next_tab ? 1 : 0) - (previous_tab ? 1 : 0)) % tabs.size(); open = tabs[at].first; h.focus = 0; h.sound = sound_tab; set_typing(h, 0); } const float top = tabs_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); x += theme::keycap(ui.draw, ui.bold, u * 2.2f, ImVec2(x, top + 20 * u), name.c_str(), "") + 4 * u; }; cap(0x10000, "Z"); for (const auto &[tab, name] : tabs) { const float w = width_of(ui.bold, size, name) + 56 * u; const ImVec2 p(x, top), q(x + w, bottom); const Hit hit = item(ui, hash(name, 1), p, q); const bool on = open == tab, lit = hit.focus || hit.hover; skate_theme::rough_rect(ui.draw, p, q, lit && !on ? theme::blue : on ? theme::paper : IM_COL32(26, 26, 26, 200), hash(name) & 0xfff, u * 2); ui.draw->AddText(ui.bold, size, ImVec2(p.x + 28 * u, top + (bottom - top - size) * .5f), on ? skate_theme::black : theme::paper, name); // The game's brushed blue line under the tab that is open. if (on) ui.draw->AddRectFilled(ImVec2(p.x + 6 * u, bottom + 4 * u), ImVec2(q.x - 6 * u, bottom + 14 * u), theme::blue, 5 * u); if (hit.press && !on) open = tab, h.sound = sound_tab, set_typing(h, 0); x = q.x + 20 * u; } cap(0x20000, "C"); if (!tools) h.tab = static_cast(multiplayer_tab); } // Under the connecting card the page is still drawn, to look at and not to use. const bool page_input = ui.input, page_clicked = ui.clicked, page_doubled = ui.doubled; const bool failed = !connecting && !h.failed.empty() && !in_session; if ((connecting || failed) && !tools) ui.input = ui.clicked = ui.doubled = false; if (tools) tools_page(ui, a, z); else switch (h.tab) { case Tab::browser: browser_page(ui, a, z); break; case Tab::host: host_page(ui, a, z); break; case Tab::session: session_page(ui, a, z); break; case Tab::voice: voice_page(ui, a, z); break; case Tab::settings: settings_page(ui, a, z); break; } if ((connecting || failed) && !tools) { ui.input = page_input, ui.clicked = page_clicked, ui.doubled = page_doubled; h.items.clear(); // nothing of the page takes the focus: only the card's way out if (connecting) connecting_page(ui, a, z); else failed_page(ui, a, z); } if (ui.input) h.pad_before = ui.pad.buttons; // A press that found nothing to land on is forgotten; the focus stays on a control that is // still there, or goes to the first one. if (h.focus_next) { // Asked for this frame, drawn from the next: not there to find yet. h.focus = std::exchange(h.focus_next, 0); h.ring = h.focus_moved = true; h.last = h.items; return; } if (h.focus && std::none_of(h.items.begin(), h.items.end(), [&](const Item &entry) { return entry.id == h.focus; })) h.focus = 0; if (!h.focus && h.ring && !h.items.empty()) { // Past the tabs: into the page itself. const auto first = std::find_if(h.items.begin(), h.items.end(), [&](const Item &entry) { return entry.a.y >= a.y - 1; }); h.focus = first != h.items.end() ? first->id : h.items.front().id; } h.last = h.items; // The game's sounds, as its own menus make them: moving the focus, pressing, changing tab. if (h.sound == ~0U && h.focus && h.focus != h.focus_heard && ui.input) h.sound = sound_navigate; h.focus_heard = h.focus; if (const auto wanted = std::exchange(h.sound, ~0U); wanted != ~0U && ui.input) { const auto tick = GetTickCount64(); // (Sweeping the pointer down a list is not a sound for every row.) if (const auto play = ui_sound.load(); play && (wanted != sound_navigate || tick - h.sound_at >= 45)) play(wanted), h.sound_at = tick; } } 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