#include "multiplayer_menu_internal.h" #include "input_capture.h" #include "skate_style.h" #include "Extension/UI/skate_theme.h" #include "Engine/Game/Input/voice_input.h" #include #include #include #include #include namespace dingosdk::overlay::menu::multiplayer_detail { namespace { // Two buttons sharing a row equally. bool half_button(const char* label, bool first) { const float width = (ImGui::GetContentRegionAvail().x - (first ? ImGui::GetStyle().ItemSpacing.x : 0.0f)) * (first ? .5f : 1.0f); return ImGui::Button(label, ImVec2(width, 0)); } // A button filling the row after a label from field(), leaving room for a // small trailing button. bool bind_button(const std::string& label, const char* trailing) { const float trailing_width = ImGui::CalcTextSize(trailing).x + ImGui::GetStyle().FramePadding.x * 2; return ImGui::Button(label.c_str(), ImVec2(ImGui::GetContentRegionAvail().x - trailing_width - ImGui::GetStyle().ItemSpacing.x, 0)); } // Who may change the session's settings: its host, or an admin of the dedicated // server it runs on (their changes are sent to the server). bool controls_session(const MultiplayerModel &mp) { return mp.hosting || mp.server_admin; } // The same for one setting: the host, or a role with the server command that changes it. bool controls(const MultiplayerModel &mp, std::string_view command) { return mp.hosting || server_can(mp, command); } const char *set_by(const MultiplayerModel &mp, std::string_view command) { return controls(mp, command) ? nullptr : mp.dedicated ? "Set by the server" : "Set by the host"; } const char *set_by(const MultiplayerModel &mp) { return controls_session(mp) ? nullptr : mp.dedicated ? "Set by the server" : "Set by the host"; } void distance_settings(SkateMenu &menu, const MultiplayerModel &mp) { if (!menu.multiplayer_distance_initialized || menu.multiplayer_distance_applied != mp.distances || menu.multiplayer_distance_hosting != mp.hosting || menu.multiplayer_distance_lobby != mp.invite) { menu.multiplayer_distance_draft = menu.multiplayer_distance_applied = mp.distances; menu.multiplayer_distance_hosting = mp.hosting; menu.multiplayer_distance_lobby = mp.invite; menu.multiplayer_distance_initialized = true; } auto &draft = menu.multiplayer_distance_draft; begin_card(menu, "distances", "NETWORK DISTANCES", set_by(mp, "distances")); note("Skaters further away update less often, which keeps big lobbies smooth."); ImGui::BeginDisabled(!controls(mp, "distances")); field(menu, "Drop to 10 TPS after"); ImGui::DragInt("##half-start", &draft.half_rate_start, 1.f, 1, 9999, "%d m", ImGuiSliderFlags_AlwaysClamp); field(menu, "Drop to 5 TPS after"); ImGui::DragInt("##low-start", &draft.low_rate_start, 1.f, 2, 10000, "%d m", ImGuiSliderFlags_AlwaysClamp); ImGui::EndDisabled(); if (ImGui::TreeNode("Return distances")) { note("Slightly shorter than the drop distances, so a skater on the boundary does not flip between rates."); ImGui::BeginDisabled(!controls(mp, "distances")); field(menu, "Back to full rate within"); ImGui::DragInt("##full-return", &draft.full_rate_return, 1.f, 0, 9998, "%d m", ImGuiSliderFlags_AlwaysClamp); field(menu, "Back to 10 TPS within"); ImGui::DragInt("##half-return", &draft.half_rate_return, 1.f, 1, 9999, "%d m", ImGuiSliderFlags_AlwaysClamp); ImGui::EndDisabled(); ImGui::TreePop(); } const bool valid = draft.valid(); if (!valid) warn("Keep the distances in order: back to full rate < drop to 10 <= back to 10 < drop to 5."); ImGui::BeginDisabled(!controls(mp, "distances")); ImGui::BeginDisabled(!valid || draft == mp.distances); if (half_button("Apply to lobby", true)) { std::array unused{}; send_private(menu, "distances", std::to_string(draft.full_rate_return) + " " + std::to_string(draft.half_rate_start) + " " + std::to_string(draft.half_rate_return) + " " + std::to_string(draft.low_rate_start), unused, false); } ImGui::EndDisabled(); ImGui::SameLine(); if (half_button("Reset values", false)) draft = {}; ImGui::EndDisabled(); const auto rates = "Rates: " + std::to_string(mp.tps) + " / 10 / 5 TPS. Players who join later get these too."; note(rates.c_str()); end_card(); } void map_pool_settings(SkateMenu &menu, const Model &model) { // the server's own maps, not this PC's const auto &mp = model.multiplayer; if (!(server_can(mp, "map-pool") || server_can(mp, "rotation")) || mp.server_maps.empty()) return; std::array unused{}; const auto server = [&](const std::string &command) { return send_private(menu, "server", command, unused, false); }; const auto &pool = mp.server_map_pool; const auto pooled = pool.empty() ? mp.server_maps.size() : pool.size(); const auto count = std::to_string(pooled) + " of " + std::to_string(mp.server_maps.size()) + " maps"; begin_card(menu, "map-pool", "MAP POOL", count.c_str()); note("The maps players can vote for, in the order the rotation goes through them. These are the server's own " "maps: its retail maps and the custom maps in its Mods folder."); bool votes = mp.server_map_votes; if (toggle_row(menu, "Player map votes", "Players start a vote with /vote map .", votes)) server(votes ? "votes map on" : "votes map off"); ImGui::Dummy(ImVec2(0, px(2))); const auto now = ImGui::GetTime(); std::erase_if(menu.map_pool_pending, [&](const auto &item) { const bool listed = pool.empty() || std::find(pool.begin(), pool.end(), item.first) != pool.end(); return listed == item.second.first || now >= item.second.second; }); const auto folded = [](std::string_view text) { std::string result(text); for (auto &c : result) c = c == '\\' ? '/' : c >= 'A' && c <= 'Z' ? static_cast(c + 32) : c; return result; }; for (const auto &asset : mp.server_maps) { const auto at = std::find(pool.begin(), pool.end(), asset); bool listed = pool.empty() || at != pool.end(); if (const auto pending = menu.map_pool_pending.find(asset); pending != menu.map_pool_pending.end()) listed = pending->second.first; auto label = map_label(model, asset); if (at != pool.end()) label = std::to_string(at - pool.begin() + 1) + ". " + label; const bool installed = std::any_of(model.levels.begin(), model.levels.end(), [&](const auto &level) { return folded(level.asset) == folded(asset); }); const auto hint = std::string(installed ? "" : "Not installed on this PC: you need its map mod to load it. ") + (listed ? "Untick to take it out of votes and the rotation." : "Tick to add it to the end of the rotation."); const bool last = listed && pooled == 1; // the pool keeps at least one map ImGui::PushID(asset.c_str()); if (toggle_row(menu, label.c_str(), last ? "The pool needs at least one map." : hint.c_str(), listed, !last, "ONLY") && server(std::string(listed ? "map-pool add " : "map-pool remove ") + asset)) menu.map_pool_pending[asset] = {listed, now + 2}; ImGui::PopID(); } if (!pool.empty() && ImGui::Button("Use every map", ImVec2(-FLT_MIN, 0))) server("map-pool clear"); note(pool.empty() ? "Every map is in the pool. Untick maps to leave them out." : "Ticked maps join the end of the rotation; the numbers are its order."); ImGui::Dummy(ImVec2(0, px(2))); field(menu, "Rotation", "Minutes on each map before the server moves to the next map in the pool. 0 turns it off."); int minutes = menu.map_rotation_pending.value_or(static_cast(mp.server_map_rotation)); ImGui::DragInt("##map-rotation", &minutes, .25f, 0, static_cast(max_map_rotation), minutes ? "Every %d min" : "Off", ImGuiSliderFlags_AlwaysClamp); if (ImGui::IsItemActive()) menu.map_rotation_pending = minutes; if (ImGui::IsItemDeactivatedAfterEdit()) { server(minutes ? "rotation " + std::to_string(minutes) : std::string("rotation off")); menu.map_rotation_until = now + 2; } if (!ImGui::IsItemActive() && menu.map_rotation_pending && (*menu.map_rotation_pending == static_cast(mp.server_map_rotation) || now >= menu.map_rotation_until)) menu.map_rotation_pending.reset(); note(mp.server_map_rotation ? "Set to 0 to turn the auto rotation off. Players get a minute's warning; the clock " "waits while nobody is on, and starts over whenever the map changes." : "Auto rotation is off (0). Drag above 0 to change the map on a timer; until then it " "changes only by a vote or an admin."); end_card(); } } // namespace void voice_controls(SkateMenu &menu, const MultiplayerModel &mp) { std::array unused{}; if (menu.voice_pending && (*menu.voice_pending == mp.voice.settings || ImGui::GetTime() >= menu.voice_pending_until)) menu.voice_pending.reset(); auto value = menu.voice_pending.value_or(mp.voice.settings); bool changed{}; // The lobby's rules, for the host only, apart from everyone's own settings below. if (controls(mp, "voice") || controls(mp, "voice-range")) { begin_card(menu, mp.dedicated ? "SERVER VOICE" : "LOBBY VOICE", mp.dedicated ? "SERVER VOICE" : "LOBBY VOICE", mp.dedicated ? "Server settings for everyone" : "Host settings for everyone"); bool allowed = mp.voice.allowed; if (toggle_row(menu, "Allow voice chat in this lobby", "Turn voice off for everyone in your lobby.", allowed)) send_private(menu, "voice-allow", allowed ? "on" : "off", unused, false); field(menu, "Voice range", "How far apart players can be and still hear each other. Past this the host " "stops sending their voice."); float range = menu.voice_range_pending.value_or(mp.voice_range); ::dingosdk::overlay::menu::slider_float("##voice-range", &range, min_voice_range, max_voice_range, "%.0f m", ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_Logarithmic); if (ImGui::IsItemActive()) menu.voice_range_pending = range; if (ImGui::IsItemDeactivatedAfterEdit()) { send_private(menu, "voice-range", std::to_string(static_cast(range)), unused, false); menu.voice_range_until = ImGui::GetTime() + 2; } if (!ImGui::IsItemActive() && menu.voice_range_pending && (*menu.voice_range_pending == mp.voice_range || ImGui::GetTime() >= menu.voice_range_until)) menu.voice_range_pending.reset(); note("Players further apart than the voice range cannot hear each other. Closer in, each player's own " "hearing distance fades voices out."); end_card(); } begin_card(menu, "voice", "TALKING", mp.hosting ? "Your own settings" : nullptr); if (!mp.hosting && !mp.voice.allowed && mp.active) warn("The host has turned voice chat off for this lobby."); changed |= toggle_row(menu, "Voice chat", "Talk to other players. Uses your Steam microphone settings.", value.enabled); changed |= toggle_row(menu, "Open microphone", "Transmit whenever you speak, without push-to-talk.", value.open_mic); if (value.open_mic) menu.voice_bind_capture = 0; ImGui::Dummy(ImVec2(0, px(2))); ImGui::BeginDisabled(value.open_mic); ControllerInput controller; DingoSDKOverlayReadControllerInput(&controller, true); DWORD foreground_process{}; GetWindowThreadProcessId(GetForegroundWindow(), &foreground_process); if (menu.voice_bind_capture && (ImGui::GetTime() >= menu.voice_capture_until || foreground_process != GetCurrentProcessId())) menu.voice_bind_capture = 0; field(menu, "Push-to-talk key"); const auto key_label = (menu.voice_bind_capture == 1 ? std::string("Press a key...") : voice_key_name(value.push_to_talk)) + "###voice-key"; if (bind_button(key_label, "Clear")) { menu.voice_bind_capture = 1; menu.voice_capture_until = ImGui::GetTime() + 30; detail::OverlayInputAccess access; for (int key = 0; key < 256; ++key) menu.voice_keys_down[key] = (GetAsyncKeyState(key) & 0x8000) != 0; } ImGui::SameLine(); if (ImGui::Button("Clear##voice-key")) { value.push_to_talk = 0; changed = true; menu.voice_bind_capture = 0; } field(menu, "Controller combo"); const auto pad_label = (menu.voice_bind_capture == 2 ? std::string("Recording...") : controller_combo_label(value.controller_combo, controller.style)) + "###voice-controller"; if (bind_button(pad_label, "Clear")) { menu.voice_bind_capture = 2; menu.voice_controller_capture = {}; menu.voice_capture_until = ImGui::GetTime() + 30; } ImGui::SameLine(); if (ImGui::Button("Clear##voice-controller")) { value.controller_combo = 0; changed = true; menu.voice_bind_capture = 0; } if (menu.voice_bind_capture) { detail::OverlayInputAccess access; if (GetAsyncKeyState(VK_ESCAPE) & 0x8000) menu.voice_bind_capture = 0; if (menu.voice_bind_capture == 1) { for (int key = 1; key < 256; ++key) { const bool down = (GetAsyncKeyState(key) & 0x8000) != 0; const bool pressed = down && !menu.voice_keys_down[key]; menu.voice_keys_down[key] = down; if (!pressed || key == VK_LBUTTON || key == VK_RBUTTON || key == VK_SHIFT || key == VK_CONTROL || key == VK_MENU) continue; menu.voice_bind_capture = 0; value.push_to_talk = key; changed = true; break; } } else if (menu.voice_bind_capture == 2) { if (const auto combo = menu.voice_controller_capture.update(controller)) { value.controller_combo = *combo; changed = true; menu.voice_bind_capture = 0; } } if (menu.voice_bind_capture) { note(menu.voice_bind_capture == 1 ? "Press a key, middle mouse or a side mouse button. Escape cancels." : !controller.available ? "Connect a controller. Escape cancels." : !menu.voice_controller_capture.ready ? "Release all controller buttons first." : "Hold your button or combo, then release it to save. Escape cancels."); if (ImGui::SmallButton("Cancel binding")) menu.voice_bind_capture = 0; } } if (!value.push_to_talk && !value.controller_combo && !value.open_mic) warn("Assign a key or controller combo to talk, or turn on Open microphone."); ImGui::EndDisabled(); ImGui::Dummy(ImVec2(0, px(2))); // Steam records the voice, so the input device is the one chosen in Steam; // its API cannot be pointed at another. field(menu, "Input device", "Voice is recorded by Steam, so it uses the microphone chosen in Steam's voice settings."); if (ImGui::Button("Choose in Steam voice settings", ImVec2(-FLT_MIN, 0))) ShellExecuteW(nullptr, L"open", L"steam://settings/voice", nullptr, nullptr, SW_SHOWNORMAL); field(menu, "Your microphone", "How loud other players hear you."); changed |= ::dingosdk::overlay::menu::slider_float("##microphone", &value.microphone, 0.f, max_voice_volume, "%.2fx", ImGuiSliderFlags_AlwaysClamp); if (mp.voice.transmitting) { tag(menu, "TRANSMITTING", skate_theme::good); ImGui::SameLine(); } note(mp.voice.status.c_str()); note("The microphone pauses while this menu is open or the game is in the background. Big boosts can distort."); end_card(); begin_card(menu, "hearing", "HEARING"); field(menu, "Voices volume", "How loud you hear everyone. Adjust single players below."); changed |= ::dingosdk::overlay::menu::slider_float("##listening", &value.volume, 0.f, max_voice_volume, "%.2fx", ImGuiSliderFlags_AlwaysClamp); changed |= toggle_row(menu, "Proximity voice", "Hear players quieter the further away they are. Off: everyone at full volume.", value.proximity); ImGui::BeginDisabled(!value.proximity); field(menu, "Hearing distance", "How far away you hear nearby players. Voices get quieter with distance and " "fade out towards this edge."); changed |= ::dingosdk::overlay::menu::slider_float("##distance", &value.distance, min_hearing_distance, max_hearing_distance, "%.0f m", ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_Logarithmic); ImGui::EndDisabled(); end_card(); if (changed) { if (send_private(menu, "voice", 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), unused, false)) { menu.voice_pending = value; menu.voice_pending_until = ImGui::GetTime() + 2; } } begin_card(menu, "voice-players", "PLAYER VOLUMES", "Only changes what you hear"); std::erase_if(menu.voice_volume_pending, [&](const auto &item) { return ImGui::GetTime() >= item.second.second; }); if (mp.roster.empty()) note("Players appear here once you are in a session with them."); ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(px(8), px(6))); if (!mp.roster.empty() && ImGui::BeginTable("voice-players", 4, ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { ImGui::TableSetupColumn("Player"); ImGui::TableSetupColumn("Volume"); ImGui::TableSetupColumn("##state", ImGuiTableColumnFlags_WidthFixed, px(100)); ImGui::TableSetupColumn("##mute", ImGuiTableColumnFlags_WidthFixed, px(76)); ImGui::TableHeadersRow(); for (const auto &peer : mp.roster) { ImGui::PushID(std::to_string(peer.id).c_str()); const auto found = std::find_if(mp.voice.players.begin(), mp.voice.players.end(), [&](const auto &p) { return p.id == peer.id; }); const auto player = found == mp.voice.players.end() ? VoicePlayer{peer.id} : *found; auto gain = player.volume; if (const auto pending = menu.voice_volume_pending.find(peer.id); pending != menu.voice_volume_pending.end()) { if (pending->second.first == gain) menu.voice_volume_pending.erase(pending); else gain = pending->second.first; } ImGui::TableNextRow(); ImGui::TableNextColumn(); ImGui::AlignTextToFramePadding(); cell_text(peer.name); ImGui::TableNextColumn(); ImGui::SetNextItemWidth(-1); if (::dingosdk::overlay::menu::slider_float("##volume", &gain, 0.f, max_voice_volume, "%.2fx", ImGuiSliderFlags_AlwaysClamp) && send_private(menu, "voice-volume", std::to_string(peer.id) + " " + std::to_string(gain), unused, false)) menu.voice_volume_pending[peer.id] = {gain, ImGui::GetTime() + 2}; ImGui::TableNextColumn(); if (player.muted) tag(menu, "MUTED", skate_theme::danger); else if (mp.voice.allowed && player.speaking) tag(menu, "SPEAKING", skate_theme::good); ImGui::TableNextColumn(); if (ImGui::Button(player.muted ? "Unmute" : "Mute", ImVec2(-1, 0))) send_private(menu, "voice-mute", std::to_string(peer.id) + (player.muted ? " off" : " on"), unused, false); ImGui::PopID(); } ImGui::EndTable(); } ImGui::PopStyleVar(); end_card(); } void ban_popup(SkateMenu &menu) { constexpr auto title = "Ban player###multiplayer-ban"; if (menu.ban_confirm_requested) { ImGui::OpenPopup(title); menu.ban_confirm_requested = false; } if (!ImGui::IsPopupOpen(title)) return; const auto *viewport = ImGui::GetMainViewport(); ImGui::SetNextWindowPos(viewport->GetCenter(), ImGuiCond_Appearing, ImVec2(.5f, .5f)); ImGui::SetNextWindowSize(ImVec2(std::min(px(440), viewport->WorkSize.x - 24.f), 0), ImGuiCond_Always); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(px(18), px(16))); if (ImGui::BeginPopupModal(title, nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoResize)) { ImGui::PushFont(menu.bold); ImGui::TextWrapped("Ban %s?", menu.ban_confirm_name.empty() ? std::to_string(menu.ban_confirm_id).c_str() : menu.ban_confirm_name.c_str()); ImGui::PopFont(); const bool server = menu.ban_confirm_on_server; note(server ? "They are removed now and can never join this server again. Unban them any time in Online > Bans." : "They are removed now and can never join a lobby you host, even with your join code. " "Unban them any time in Online > Bans."); ImGui::Dummy(ImVec2(0, px(4))); const float half = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * .5f; skate_theme::push_primary_button(); const bool ban = ImGui::Button("Ban", ImVec2(half, 0)); skate_theme::pop_primary_button(); ImGui::SameLine(); const bool cancel = ImGui::Button("Cancel", ImVec2(-FLT_MIN, 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape); if (ban) { std::array unused{}; send_private(menu, "ban", std::to_string(menu.ban_confirm_id) + " " + menu.ban_confirm_name, unused, false); } if (ban || cancel) { menu.ban_confirm_id = 0; menu.ban_confirm_name.clear(); ImGui::CloseCurrentPopup(); } ImGui::EndPopup(); } ImGui::PopStyleVar(); } void bans_page(SkateMenu &menu, const Model &model) { const auto &bans = model.multiplayer.bans; // An admin of a dedicated server manages the server's list; otherwise, this PC's. const bool server = model.multiplayer.server_bans; std::array unused{}; const auto total = server ? model.multiplayer.server_ban_total : static_cast(bans.size()); const auto count = std::to_string(total) + (total == 1 ? " player" : " players"); begin_card(menu, "bans", server ? "SERVER BANS" : "BANNED PLAYERS", count.c_str()); if (server) note("You are an admin here, so this is the server's own ban list. Your own list is back when you leave."); if (server && total > bans.size()) note(("Showing the newest " + std::to_string(bans.size()) + ". Unban older ones with mp server unban .").c_str()); if (bans.empty()) note(server ? "Nobody is banned from this server. Ban a player from the Session tab, or add one below." : "Nobody is banned. Ban a player from the Session tab while you host, or add one below."); ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(px(8), px(6))); if (!bans.empty() && ImGui::BeginTable("bans-table", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { ImGui::TableSetupColumn("Player", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("Banned", ImGuiTableColumnFlags_WidthFixed, px(120)); ImGui::TableSetupColumn("##unban", ImGuiTableColumnFlags_WidthFixed, px(90)); ImGui::TableHeadersRow(); for (const auto &ban : bans) { ImGui::PushID(std::to_string(ban.id).c_str()); ImGui::TableNextRow(); ImGui::TableNextColumn(); ImGui::PushFont(menu.bold); cell_text(ban.name.empty() ? std::string("Unknown player") : ban.name); ImGui::PopFont(); ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::grey_text); ImGui::TextUnformatted(std::to_string(ban.id).c_str()); ImGui::PopStyleColor(); ImGui::TableNextColumn(); ImGui::AlignTextToFramePadding(); std::array date{}; const auto added = static_cast(ban.added); std::tm local{}; if (ban.added > 0 && localtime_s(&local, &added) == 0) std::strftime(date.data(), date.size(), "%d %b %Y", &local); ImGui::TextUnformatted(date[0] ? date.data() : "-"); ImGui::TableNextColumn(); if (ImGui::Button("Unban", ImVec2(-1, 0))) send_private(menu, "unban", std::to_string(ban.id), unused, false); ImGui::PopID(); } ImGui::EndTable(); } ImGui::PopStyleVar(); end_card(); begin_card(menu, "ban-add", server ? "BAN FROM THIS SERVER" : "BAN BY STEAM ID"); field(menu, "Steam ID", "The 17-digit SteamID64, starting 7656119."); ImGui::InputTextWithHint("##ban-id", "7656119...", menu.ban_id.data(), menu.ban_id.size(), ImGuiInputTextFlags_CharsDecimal); field(menu, "Name", server ? "Optional; shown in the server's list." : "Optional; only for your own list."); ImGui::InputTextWithHint("##ban-name", "Optional", menu.ban_name.data(), menu.ban_name.size()); ImGui::Dummy(ImVec2(0, px(2))); // Checked here too: a queued command's refusal never comes back to the menu. const std::string typed(menu.ban_id.data()); std::uint64_t id{}; const auto parsed = std::from_chars(typed.data(), typed.data() + typed.size(), id); const bool valid = parsed.ec == std::errc{} && parsed.ptr == typed.data() + typed.size() && id >= 76561197960265728ULL && id <= 76561202255233023ULL; const bool self = valid && id == model.multiplayer.local_id; const bool known = valid && std::any_of(bans.begin(), bans.end(), [&](const auto &ban) { return ban.id == id; }); if (!typed.empty() && !valid) warn("That is not a SteamID64: it is 17 digits and starts with 7656119."); else if (self) warn("That is your own Steam ID."); else if (known) note("That player is already banned."); if (primary_button(menu, "BAN PLAYER", valid && !self && !known)) { if (send_private(menu, "ban", std::string(menu.ban_id.data()) + " " + menu.ban_name.data(), unused, false)) { menu.ban_id.fill(0); menu.ban_name.fill(0); } } note(server ? "Banned players can never join this server. A SteamID64 is the long number in a Steam profile's " "address (steamcommunity.com/profiles/...)." : "Banned players can never join a lobby you host, even with your join code. A SteamID64 is the long number " "in a Steam profile's address (steamcommunity.com/profiles/...)."); end_card(); } void session_page(SkateMenu &menu, const Model &model) { const auto &mp = model.multiplayer; begin_card(menu, "session", mp.lobby_name.empty() ? "CURRENT SESSION" : mp.lobby_name.c_str()); if (mp.lobby_joining) tag(menu, "CONNECTING", skate_theme::warning); else tag(menu, mp.hosting ? "HOST" : "GUEST", mp.hosting ? skate_theme::blue : skate_theme::good); if (mp.dedicated) { ImGui::SameLine(0, px(6)); tag(menu, "DEDICATED SERVER", skate_theme::blue); if (!mp.server_role_tag.empty() || mp.server_admin) { // Their role on the server, as its tag reads (or its name, for a role without one). std::string role = mp.server_role_tag.empty() ? mp.server_role : mp.server_role_tag; for (auto &c : role) c = c >= 'a' && c <= 'z' ? static_cast(c - 32) : c; ImGui::SameLine(0, px(6)); tag(menu, role.c_str(), skate_theme::warning); } } if (mp.hosting) { ImGui::SameLine(0, px(6)); tag(menu, mp.public_host ? "PUBLIC" : "JOIN CODE", skate_theme::white); if (mp.password_required) { ImGui::SameLine(0, px(6)); tag(menu, "PASSWORD", skate_theme::warning); } } note(mp.status.c_str()); ImGui::Dummy(ImVec2(0, px(2))); info(menu, "Map", map_label(model, mp.map)); if (mp.active) info(menu, "Players", std::to_string(mp.players) + " / " + std::to_string(mp.capacity)); if (mp.hosting && !mp.invite.empty()) { field(menu, "Join code", "Friends paste this in Servers > Join with a code."); std::array code{}; std::copy_n(mp.invite.begin(), std::min(mp.invite.size(), code.size() - 1), code.begin()); const float copy = ImGui::CalcTextSize("Copy").x + ImGui::GetStyle().FramePadding.x * 2; ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - copy - ImGui::GetStyle().ItemSpacing.x); ImGui::InputText("##join-code", code.data(), code.size(), ImGuiInputTextFlags_ReadOnly); ImGui::SameLine(); if (ImGui::Button("Copy")) { ImGui::SetClipboardText(mp.invite.c_str()); feedback(menu, "Join code copied."); } } if (!mp.lobby_status.empty()) note(mp.lobby_status.c_str()); ImGui::Dummy(ImVec2(0, px(2))); if (ImGui::Button(mp.lobby_joining ? "Cancel join" : mp.hosting ? "End session" : "Leave session", ImVec2(-FLT_MIN, 0))) send_private(menu, "stop", "", menu.multiplayer_join_password, false); end_card(); if (!mp.active) return; const auto count = std::to_string(mp.players) + " / " + std::to_string(mp.capacity); begin_card(menu, "players", "PLAYERS", count.c_str()); auto *draw = ImGui::GetWindowDrawList(); const auto player = [&](int index, const std::string &name, bool you, bool host, std::string detail, const char *status, ImU32 colour, const MultiplayerPlayer *peer) { ImGui::PushID(index); const auto top = ImGui::GetCursorScreenPos(); const float width = ImGui::GetContentRegionAvail().x, height = px(54), pad = px(14); skate_theme::rough_rect(draw, top, ImVec2(top.x + width, top.y + height), skate_theme::tile_grey, static_cast(index + 57), px(1)); if (host) draw->AddRectFilled(top, ImVec2(top.x + px(4), top.y + height), skate_theme::blue); const float middle = top.y + (height - ImGui::GetFrameHeight()) * .5f; float right = top.x + width - pad; // Right: the host's actions, then the status tag leading into them. if (peer && (controls(mp, "kick") || controls(mp, "ban")) && peer->id != mp.local_id && peer->id != mp.host_id) { const float button = px(76), spacing = ImGui::GetStyle().ItemSpacing.x; right -= button * 2 + spacing; ImGui::SetCursorScreenPos(ImVec2(right, middle)); ImGui::BeginDisabled(!peer->connected || !peer->epoch); if (ImGui::Button("Kick", ImVec2(button, 0))) { std::array unused{}; send_private(menu, "kick", std::to_string(peer->id) + " " + std::to_string(peer->epoch), unused, false); } ImGui::EndDisabled(); if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", mp.dedicated ? "Remove them until the server restarts." : "Remove them from this session. They can join your next one."); ImGui::SameLine(); if (ImGui::Button("Ban", ImVec2(button, 0))) { menu.ban_confirm_id = peer->id; menu.ban_confirm_name = peer->name; menu.ban_confirm_on_server = mp.dedicated; menu.ban_confirm_requested = true; } if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", mp.dedicated ? "Remove them and keep them off this server for good." : "Remove them and keep them out of every lobby you host."); right -= px(14); } // Their placed objects: ghosts for you (skated and walked through) until made solid. if (peer && peer->id != mp.local_id) { const float button = px(124); right -= button; ImGui::SetCursorScreenPos(ImVec2(right, middle)); if (peer->objects_solid) skate_theme::push_primary_button(); if (ImGui::Button(peer->objects_solid ? "Objects: solid" : "Objects: ghost", ImVec2(button, 0))) { std::array unused{}; send_private(menu, "objects-solid", std::to_string(peer->id) + (peer->objects_solid ? " off" : " on"), unused, false); } if (peer->objects_solid) skate_theme::pop_primary_button(); if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", peer->objects_solid ? "Everything this player places is solid for you. Press to pass through their objects instead." : "You pass through everything this player places. Press to make their objects solid, to skate them."); right -= px(14); } const float status_width = menu.bold->CalcTextSizeA(px(12), FLT_MAX, 0, status).x + px(14); right -= status_width; ImGui::SetCursorScreenPos(ImVec2(right, middle)); tag(menu, status, colour); right -= px(14); // Left: the name, with who they are under it. ImGui::SetCursorScreenPos(ImVec2(top.x + pad + (host ? px(4) : 0.0f), top.y + px(8))); ImGui::PushClipRect(ImGui::GetCursorScreenPos(), ImVec2(right, top.y + height), true); ImGui::BeginGroup(); ImGui::PushFont(menu.bold); ImGui::TextUnformatted(name.c_str()); ImGui::PopFont(); if (you) { ImGui::SameLine(0, px(8)); ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::blue); ImGui::TextUnformatted("you"); ImGui::PopStyleColor(); } ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::grey_text); ImGui::TextUnformatted(detail.c_str()); // The ReSkate team's lists see who is who: the Steam ID, copied by a click. if (const auto id = peer ? peer->id : mp.local_id; mp.steam_ids_shown && id) { const auto text = std::to_string(id); ImGui::SameLine(0, 0); ImGui::TextUnformatted(" - "); ImGui::SameLine(0, 0); ImGui::TextUnformatted(text.c_str()); if (ImGui::IsItemHovered()) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); ImGui::SetTooltip("Steam ID. Click to copy."); } if (ImGui::IsItemClicked()) ImGui::SetClipboardText(text.c_str()); } ImGui::PopStyleColor(); ImGui::EndGroup(); ImGui::PopClipRect(); ImGui::SetCursorScreenPos(ImVec2(top.x, top.y + height + px(6))); ImGui::Dummy(ImVec2(0, 0)); ImGui::PopID(); }; player(0, mp.local_name.empty() ? std::string("You") : mp.local_name, true, mp.hosting, mp.hosting ? "Host" : "Guest", mp.local_ready ? "IN GAME" : "LOADING", mp.local_ready ? skate_theme::good : skate_theme::warning, nullptr); // Only the tiles in view are drawn; every tile is the same height. ImGuiListClipper clipper; clipper.Begin(static_cast(mp.roster.size())); while (clipper.Step()) { for (auto i = static_cast(clipper.DisplayStart); i < static_cast(clipper.DisplayEnd); ++i) { const auto &peer = mp.roster[i]; const bool in_game = peer.connected && peer.visible; std::string detail = peer.id == mp.host_id ? "Host" : "Guest"; if (peer.distance_m >= 0) detail += " - " + std::to_string(static_cast(peer.distance_m)) + " m away"; player(static_cast(i) + 1, peer.name, false, peer.id == mp.host_id, std::move(detail), !peer.connected ? "CONNECTING" : in_game ? "IN GAME" : "LOADING", in_game ? skate_theme::good : skate_theme::warning, &peer); } } ImGui::Dummy(ImVec2(0, px(2))); if (!mp.object_status.empty()) note(("Objects: " + mp.object_status).c_str()); if (mp.hosting) note("Travel from Map > Travel to take everyone with you."); else if (server_can(mp, "map")) note("Pick a level in Levels or Map > Travel to move the whole server there."); end_card(); if (mp.server_admin) { begin_card(menu, "server-command", "SERVER COMMAND", mp.server_all_commands ? "Admins only" : "Your role's commands"); note(mp.server_all_commands ? "Any server console command: status, map grom, name , tps 60, park historic megapark_02, " "layer-sync on... Type help for the list. Replies appear in chat." : "The server commands your role has: type help for what there is. Replies appear in chat."); const float send = ImGui::CalcTextSize("Send").x + ImGui::GetStyle().FramePadding.x * 2; ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - send - ImGui::GetStyle().ItemSpacing.x); const bool enter = ImGui::InputTextWithHint("##server-command", "help", menu.server_command.data(), menu.server_command.size(), ImGuiInputTextFlags_EnterReturnsTrue); ImGui::SameLine(); if ((ImGui::Button("Send") || enter) && menu.server_command[0]) { std::array unused{}; send_private(menu, "server", menu.server_command.data(), unused, false); menu.server_command.fill(0); } end_card(); } map_pool_settings(menu, model); begin_card(menu, "host-options", mp.dedicated ? "SERVER OPTIONS" : "HOST OPTIONS", mp.dedicated && mp.server_admin ? nullptr : set_by(mp)); std::array unused{}; field(menu, "Who can build"); int placement = mp.object_placement == ObjectPlacement::everyone ? 0 : mp.object_placement == ObjectPlacement::host_only ? 1 : 2; // On a dedicated server "host only" is its admins. if (choice(menu, "placement", placement, {"Everyone", mp.dedicated ? "Admins only" : "Host only", "Nobody"}, controls(mp, "placement"))) send_private(menu, "object-placement", placement == 0 ? "everyone" : placement == 1 ? "host" : "nobody", unused, false); note(mp.object_placement == ObjectPlacement::everyone ? "Everyone can place, move and delete objects." : mp.object_placement == ObjectPlacement::host_only ? (mp.dedicated ? (mp.server_admin ? "Only admins can build. Everyone else's editor is locked." : "Only the server's admins can build. Your editor and object tools are locked.") : mp.hosting ? "Only you can build. Guests' editors are locked and their objects stay as everyone sees them now." : "Only the host can build. Your editor and object tools are locked.") : mp.dedicated ? "Nobody can build. Existing objects stay." : "Nobody can build, including the host. Existing objects stay."); // How many objects each player may have placed: a few round numbers, and whatever the // server's file or console set that is not one of them. { 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 std::string custom = std::to_string(mp.object_limit); std::array labels{"No limit", "10", "25", "50", "100", "250", "500", custom.c_str()}; int picked = static_cast(found - limits.begin()); field(menu, "Objects per player"); ImGui::BeginDisabled(!controls(mp, "objects")); ImGui::SetNextItemWidth(-FLT_MIN); if (ImGui::Combo("##object-limit", &picked, labels.data(), found == limits.end() ? 8 : 7) && picked < 7) send_private(menu, "object-limit", picked ? std::to_string(limits[static_cast(picked)]) : std::string("off"), unused, false); ImGui::EndDisabled(); const char *exempt = mp.dedicated ? "Admins" : "The host"; if (!mp.object_limit) note("Everyone can place as many objects as they like."); else if (mp.object_limit_own) note(("You have placed " + std::to_string(mp.objects_placed) + " of " + std::to_string(mp.object_limit_own) + " objects. Delete one to place another once you reach the limit.").c_str()); else note((std::string("Each player can have ") + std::to_string(mp.object_limit) + " objects placed. " + exempt + (mp.dedicated ? " are not limited." : " is not limited.")).c_str()); } if (controls(mp, "clear-objects")) { note(mp.dedicated ? "Enforced by the server, so modified clients cannot get around it." : "Enforced by the host, so modified clients cannot get around it."); if (ImGui::Button(mp.dedicated ? "Delete all placed objects" : "Delete all guest objects", ImVec2(-FLT_MIN, 0))) send_private(menu, "clear-objects", "", unused, false); note(mp.dedicated ? "Removes every object players placed, for everyone. Their saved parks stay." : "Removes every object guests placed, for everyone. Your objects and guests' saved parks stay."); } ImGui::Dummy(ImVec2(0, px(2))); const char *who = mp.dedicated ? "Players" : "Guests"; const char *exempt = mp.dedicated ? " Admins always can." : " You always can."; bool noclip = mp.guest_noclip; if (toggle_row(menu, "Allow noclip", (std::string(who) + " may fly with noclip and teleport." + exempt).c_str(), noclip, controls(mp, "noclip"), noclip ? "ALLOWED" : "OFF")) send_private(menu, "noclip-allow", noclip ? "on" : "off", unused, false); bool no_bail = mp.guest_no_bail; if (toggle_row(menu, "Allow No Bail", (std::string(who) + " may turn on No Bail." + exempt).c_str(), no_bail, controls(mp, "nobail"), no_bail ? "ALLOWED" : "OFF")) send_private(menu, "nobail-allow", no_bail ? "on" : "off", unused, false); bool boosts = mp.guest_boosts; if (toggle_row(menu, "Allow boosts", (std::string(who) + " may use the forward and up boosts." + exempt).c_str(), boosts, controls(mp, "boosts"), boosts ? "ALLOWED" : "OFF")) send_private(menu, "boosts-allow", boosts ? "on" : "off", unused, false); bool tuning = mp.enforce_tuning; if (toggle_row(menu, "Enforce physics tuning", mp.dedicated ? "Players skate with the game's own Gameplay/SkatePhysicsTuning, not their edited copies." : "Guests skate with your Gameplay/SkatePhysicsTuning (truck positions and the rest), not their own.", tuning, controls(mp, "tuning"), tuning ? "ON" : "OFF")) send_private(menu, "tuning-enforce", tuning ? "on" : "off", unused, false); if (!mp.tuning_status.empty()) note(mp.tuning_status.c_str()); ImGui::Dummy(ImVec2(0, px(2))); bool force_layers = mp.force_world_layers; if (toggle_row(menu, "Sync world layers", "Guests follow your Map > Layers and crowds choices and cannot change their own.", force_layers, controls(mp, "layer-sync"), force_layers ? "ON" : "OFF")) send_private(menu, "world-layer-sync", force_layers ? "on" : "off", unused, false); if (mp.dedicated) note("The server's layers are set with server commands: layer on|off (keys are listed in Map > Layers and crowds)."); else note(force_layers ? "Guests follow the host's world layers; their layer controls are locked." : "Everyone picks their own world layers. Turning sync off restores each guest's own choices."); end_card(); distance_settings(menu, mp); } } // namespace dingosdk::overlay::menu::multiplayer_detail