mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-18 00:27:06 +02:00
Player list. A player on the developer, staff or homie list sees each player's Steam ID in the overlay's PLAYERS list for the session they are in, after "Host" / "Guest" and the distance, on their own row too. A click copies it. The lists are the backend's, the same ones the tags come from; every client in a session already knew the IDs, so this changes who is shown them and nothing else. Custom scripts. Nothing in scripts/Custom is read or run any more, whatever the launcher's "Loose files" setting says: nothing used them, and a mod was using the folder to load a DLL into the game (package.loadlib from its init.lua). The loader is kept behind one switch (custom_scripts_enabled in custom_script_loader.cpp) for the day they come back. For that day, the loader now also takes Lua's ways of running native code away before the first custom script runs: package.loadlib, require through the two C searchers and package.cpath, os.execute and io.popen. If that does not hold, no custom script runs. The overlay's Mods page no longer has the INSTALLED / SCRIPTS tabs; it is the installed mods. Editing the game's own Lua and config through loose files is unchanged.
690 lines
41 KiB
C++
690 lines
41 KiB
C++
#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 <algorithm>
|
|
#include <charconv>
|
|
#include <ctime>
|
|
#include <Windows.h>
|
|
#include <shellapi.h>
|
|
|
|
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; }
|
|
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));
|
|
note("Skaters further away update less often, which keeps big lobbies smooth.");
|
|
ImGui::BeginDisabled(!controls_session(mp));
|
|
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_session(mp));
|
|
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_session(mp));
|
|
ImGui::BeginDisabled(!valid || draft == mp.distances);
|
|
if (half_button("Apply to lobby", true)) {
|
|
std::array<char, 65> 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 (!mp.server_admin || mp.server_maps.empty()) return;
|
|
std::array<char, 65> 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 <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<char>(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<int>(mp.server_map_rotation));
|
|
ImGui::DragInt("##map-rotation", &minutes, .25f, 0, static_cast<int>(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<int>(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<char, 65> 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_session(mp)) {
|
|
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);
|
|
ImGui::SliderFloat("##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<int>(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 |= ImGui::SliderFloat("##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 |= ImGui::SliderFloat("##listening", &value.volume, 0.f, max_voice_volume, "%.2fx", ImGuiSliderFlags_AlwaysClamp);
|
|
changed |= toggle_row(menu, "Proximity voice", "Hear players quieter the further away they are. Off: everyone at full volume.",
|
|
value.proximity);
|
|
ImGui::BeginDisabled(!value.proximity);
|
|
field(menu, "Hearing distance", "How far away you hear nearby players. Voices get quieter with distance and "
|
|
"fade out towards this edge.");
|
|
changed |= ImGui::SliderFloat("##distance", &value.distance, min_hearing_distance, max_hearing_distance, "%.0f m",
|
|
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 (ImGui::SliderFloat("##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 Multiplayer > Bans."
|
|
: "They are removed now and can never join a lobby you host, even with your join code. "
|
|
"Unban them any time in Multiplayer > Bans.");
|
|
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<char, 65> 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<char, 65> unused{};
|
|
const auto total = server ? model.multiplayer.server_ban_total : static_cast<unsigned>(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 <id>.").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<char, 32> date{};
|
|
const auto added = static_cast<std::time_t>(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_admin) {
|
|
ImGui::SameLine(0, px(6));
|
|
tag(menu, "ADMIN", 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<char, 128> 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<unsigned>(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_session(mp) && 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<char, 65> 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);
|
|
}
|
|
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<int>(mp.roster.size()));
|
|
while (clipper.Step()) {
|
|
for (auto i = static_cast<std::size_t>(clipper.DisplayStart); i < static_cast<std::size_t>(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<int>(peer.distance_m)) + " m away";
|
|
player(static_cast<int>(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 (mp.server_admin) 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", "Admins only");
|
|
note("Any server console command: status, map grom, name <text>, tps 60, park historic megapark_02, "
|
|
"layer-sync on... Type help for the list. 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<char, 65> 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", set_by(mp));
|
|
std::array<char, 65> 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_session(mp)))
|
|
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<unsigned, 7> 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<const char *, 8> labels{"No limit", "10", "25", "50", "100", "250", "500", custom.c_str()};
|
|
int picked = static_cast<int>(found - limits.begin());
|
|
field(menu, "Objects per player");
|
|
ImGui::BeginDisabled(!controls_session(mp));
|
|
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<std::size_t>(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_session(mp)) {
|
|
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_session(mp), 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_session(mp), 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_session(mp), 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_session(mp), 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 World > Layers choices and cannot change their own.",
|
|
force_layers, controls_session(mp), 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 <key> on|off (keys are listed in World > Layers).");
|
|
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
|