mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-23 22:16:00 +02:00
Dedicated servers: roles, with their tags, commands and permissions A dedicated server had one list of admins who could do everything, and everyone else could do nothing. It now has roles, kept in data/roles.json beside the bans: each a name, the tag its players wear in the game, the server commands it may use, what it may do besides commands, and a rank. A server starts with five: player (everyone given no role: chat, votes, parties, private messages), vip (a reserved slot), moderator (kick, ban, messages, giving out lower roles), admin (everything) and muted. The default role is whoever was given none; a role has what the default has as well as its own unless it says it does not inherit. A reserved slot is a permission, so a role's players have one without being listed. Nobody kicks, bans or changes the role of a player whose rank is theirs or higher, or gives out a role that is; the console is above every role. "*" is every command but role and reserved, which a role has only when it lists them. New commands: role <player or SteamID64> <role>|none, roles, and roles reload. "admin add|remove" and "admins" are gone: the admins an older config lists are moved into the admin role the first time the server starts, and leave the config. In the game, a player's role tag and colour show in chat and on their nametag, and each control in the Multiplayer menu, the map, time of day, world layers and parks follows the command it needs rather than one admin flag, as do the commands chat offers. Protocol 48: the roster carries each player's tag, and a new permissions packet tells a player what their role gives them. Older games and servers do not connect to newer ones. Also fixes a name clash that kept the server config tests from compiling.
574 lines
32 KiB
C++
574 lines
32 KiB
C++
#include "Engine/Game/World/world_names.h"
|
|
#include "Extension/UI/NativeMenu/native_tools_view.h"
|
|
#include "skate_menu_internal.h"
|
|
#include "skate_style.h"
|
|
#include "Extension/UI/skate_theme.h"
|
|
#include <imgui_internal.h>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cctype>
|
|
|
|
// The MAP, WORLD and BUILD pages.
|
|
namespace dingosdk::overlay::menu {
|
|
using namespace theme;
|
|
constexpr char root_asset[] = "Levels/Game/DingoLevel_Root/DingoLevel_Root";
|
|
|
|
bool same(const std::string& a, const std::string& b) {
|
|
return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), [](unsigned char x, unsigned char y) {
|
|
return std::tolower(x) == std::tolower(y);
|
|
});
|
|
}
|
|
std::string level_name(const std::string& asset) {
|
|
return world_level_name(asset);
|
|
}
|
|
std::string level_name(const Level& level) {
|
|
return level.display_name.empty() ? level_name(level.asset) : level.display_name;
|
|
}
|
|
|
|
void level_picker(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
|
begin_card(menu, "travel", "FIND YOUR SPOT");
|
|
std::vector<const Level*> destinations;
|
|
const Level* root = nullptr;
|
|
const Level* selected = nullptr;
|
|
for (const auto& level : model.levels) {
|
|
if (same(level.asset, root_asset)) root = &level;
|
|
else {
|
|
destinations.push_back(&level);
|
|
if (same(level.asset, menu.destination)) selected = &level;
|
|
}
|
|
}
|
|
std::sort(destinations.begin(), destinations.end(), [](const auto* a, const auto* b) {
|
|
return level_name(*a) < level_name(*b);
|
|
});
|
|
field(menu, "Destination");
|
|
if (ImGui::BeginCombo("##destination", selected ? level_name(*selected).c_str() : "Choose a level")) {
|
|
for (const auto* level : destinations) {
|
|
ImGui::PushID(level->asset.c_str());
|
|
if (ImGui::Selectable(level_name(*level).c_str(), selected == level)) {
|
|
selected = level;
|
|
menu.destination = level->asset;
|
|
menu.feedback.clear();
|
|
}
|
|
ImGui::PopID();
|
|
}
|
|
if (destinations.empty()) ImGui::TextDisabled("Waiting for levels...");
|
|
ImGui::EndCombo();
|
|
}
|
|
ImGui::Dummy(ImVec2(0, px(4)));
|
|
const bool ready = root && selected && root->can_load && selected->can_load && model.can_queue_load && callbacks.queue_load;
|
|
ImGui::BeginDisabled(!ready);
|
|
skate_theme::push_primary_button();
|
|
ImGui::PushFont(menu.heading);
|
|
if (ImGui::Button("LET'S SKATE", ImVec2(-1, px(46)))) {
|
|
std::array<char, 512> result{};
|
|
const auto* point = selected->automatic_start_point();
|
|
const bool queued = callbacks.queue_load(callbacks.user, root->asset.c_str(), "", selected->asset.c_str(),
|
|
point ? point->c_str() : "", result.data(), result.size());
|
|
result.back() = '\0';
|
|
feedback(menu, result[0] ? result.data() : (queued ? "Loading level..." : "Load unavailable."));
|
|
}
|
|
ImGui::PopFont();
|
|
skate_theme::pop_primary_button();
|
|
ImGui::EndDisabled();
|
|
if (!ready) {
|
|
const std::string reason = !root ? "Waiting for levels..." : !selected ? "Choose a destination." :
|
|
!root->can_load ? root->load_block_reason : !selected->can_load ? selected->load_block_reason : model.load_block_reason;
|
|
note(reason.empty() ? "Level loading is unavailable." : reason.c_str());
|
|
}
|
|
end_card();
|
|
}
|
|
|
|
void park_editor_panel(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
|
begin_card(menu, "park-editor", "PARK EDITOR");
|
|
note("Build a park with freecam, an object library and XYZ handles, then save it as a mod "
|
|
"others can install and load.");
|
|
ImGui::Dummy(ImVec2(0, px(4)));
|
|
ImGui::BeginDisabled(!model.editor.available || !model.debug.camera_available || !model.debug.ui_available || !callbacks.queue_debug);
|
|
skate_theme::push_primary_button();
|
|
ImGui::PushFont(menu.heading);
|
|
if (ImGui::Button("OPEN PARK EDITOR", ImVec2(-1, px(46))))
|
|
debug_request(menu, callbacks, {DebugAction::set_park_editor, true});
|
|
ImGui::PopFont();
|
|
skate_theme::pop_primary_button();
|
|
ImGui::EndDisabled();
|
|
if (!model.editor.available) warn(model.editor.status.c_str());
|
|
note("Placed objects are listed under Saved Objects.");
|
|
end_card();
|
|
}
|
|
|
|
// Parks shared as mods (Mods/<name>/parks/<map>.park.json): one click loads
|
|
// a mod's park for this map in place of the current layout. In multiplayer
|
|
// only the host can, and the lobby sees the result.
|
|
void park_mods_panel(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
|
const auto& editor = model.editor;
|
|
begin_card(menu, "park-mods", "PARK MODS");
|
|
note("Parks made in the park editor are saved as mods, so they can be shared and installed with the launcher. "
|
|
"Loading one replaces your layout on this map; Undo in the park editor brings yours back.");
|
|
if (!editor.can_load_parks) warn("In multiplayer only the host can load park mods.");
|
|
else if (!editor.available) note(editor.status.c_str());
|
|
end_card();
|
|
const auto quoted = [](std::string_view text) {
|
|
std::string out = "\"";
|
|
for (const auto c : text) {
|
|
if (c == '"' || c == '\\') out += '\\';
|
|
out += c;
|
|
}
|
|
return out + '"';
|
|
};
|
|
const auto command = [&](const char* operation, const std::string& folder) {
|
|
return std::string("editor ") + operation + " " + quoted(editor.map) + " " + std::to_string(editor.generation) +
|
|
" " + std::to_string(editor.revision) + (folder.empty() ? "" : " " + quoted(folder));
|
|
};
|
|
const bool ready = editor.available && !editor.busy && !editor.failed && callbacks.queue_console_command;
|
|
std::vector<const EditorParkMod*> here, elsewhere;
|
|
for (const auto& mod : editor.park_mods)
|
|
if (mod.enabled) (mod.has_map ? here : elsewhere).push_back(&mod);
|
|
if (here.empty())
|
|
note(editor.map.empty() ? "Load a level to see its park mods."
|
|
: "No enabled park mod has a park for this map yet.");
|
|
auto* draw = ImGui::GetWindowDrawList();
|
|
for (std::size_t i = 0; i < here.size(); ++i) {
|
|
const auto& mod = *here[i];
|
|
ImGui::PushID(mod.folder.c_str());
|
|
const auto top = ImGui::GetCursorScreenPos();
|
|
const float width = ImGui::GetContentRegionAvail().x;
|
|
const float buttons = px(210);
|
|
const float text_width = width - buttons - px(36);
|
|
const float description_height = mod.description.empty() ? 0.0f
|
|
: ImGui::CalcTextSize(mod.description.c_str(), nullptr, false, text_width).y + px(4);
|
|
const float height = std::max(px(74), px(58) + description_height);
|
|
skate_theme::rough_rect(draw, top, ImVec2(top.x + width, top.y + height), skate_theme::tile,
|
|
static_cast<unsigned>(i + 71), px(1));
|
|
if (mod.folder == editor.project)
|
|
draw->AddRectFilled(top, ImVec2(top.x + px(4), top.y + height), blue);
|
|
ImGui::SetCursorScreenPos(ImVec2(top.x + px(16), top.y + px(12)));
|
|
ImGui::BeginGroup();
|
|
ImGui::PushFont(menu.bold);
|
|
ImGui::TextUnformatted(mod.title.empty() ? mod.folder.c_str() : mod.title.c_str());
|
|
ImGui::PopFont();
|
|
std::string byline = mod.author.empty() ? std::string("Unknown author") : "by " + mod.author;
|
|
if (!mod.version.empty()) byline += " / v" + mod.version;
|
|
if (mod.folder == editor.project) byline += " / open in the park editor";
|
|
ImGui::TextDisabled("%s", byline.c_str());
|
|
if (!mod.description.empty()) {
|
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + text_width);
|
|
ImGui::TextUnformatted(mod.description.c_str());
|
|
ImGui::PopTextWrapPos();
|
|
}
|
|
ImGui::EndGroup();
|
|
ImGui::SetCursorScreenPos(ImVec2(top.x + width - buttons - px(12), top.y + (height - ImGui::GetFrameHeight()) * .5f));
|
|
ImGui::BeginDisabled(!ready || !editor.can_load_parks);
|
|
skate_theme::push_primary_button();
|
|
ImGui::PushFont(menu.bold);
|
|
if (ImGui::Button("LOAD", ImVec2(px(100), 0)))
|
|
send_console(menu, callbacks, command("mod-load", mod.folder));
|
|
ImGui::PopFont();
|
|
skate_theme::pop_primary_button();
|
|
ImGui::SameLine();
|
|
if (ImGui::Button("Edit", ImVec2(px(100), 0))) {
|
|
// Open it as the editor's project, then show the editor.
|
|
send_console(menu, callbacks, command("mod-open", mod.folder));
|
|
debug_request(menu, callbacks, {DebugAction::set_park_editor, true});
|
|
}
|
|
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
|
ImGui::SetTooltip("Load it into the park editor to change and save it.");
|
|
ImGui::EndDisabled();
|
|
ImGui::SetCursorScreenPos(ImVec2(top.x, top.y + height + px(8)));
|
|
ImGui::PopID();
|
|
}
|
|
if (!elsewhere.empty()) {
|
|
std::string names;
|
|
for (const auto* mod : elsewhere) {
|
|
std::string maps;
|
|
for (const auto& map : mod->maps) maps += (maps.empty() ? "" : ", ") + map;
|
|
names += (names.empty() ? "" : "; ") + (mod->title.empty() ? mod->folder : mod->title) + " (" + maps + ")";
|
|
}
|
|
note(("For other maps: " + names).c_str());
|
|
}
|
|
ImGui::Dummy(ImVec2(0, px(4)));
|
|
ImGui::BeginDisabled(!ready);
|
|
if (ImGui::Button("Refresh park mods", ImVec2(-FLT_MIN, 0)))
|
|
send_console(menu, callbacks, command("mods-refresh", {}));
|
|
ImGui::EndDisabled();
|
|
note("Disabled mods are hidden; enable them in the launcher's Mods panel.");
|
|
if (!editor.status.empty() && editor.available) note(editor.status.c_str());
|
|
}
|
|
|
|
void saved_objects(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
|
const auto& state = model.object_persistence;
|
|
const auto send = [&](const std::string& command) {
|
|
std::array<char, 512> result{};
|
|
callbacks.queue_console_command(callbacks.user, command.c_str(), result.data(), result.size());
|
|
result.back() = '\0'; feedback(menu, result.data());
|
|
};
|
|
begin_card(menu, "persistence", "OBJECT PERSISTENCE");
|
|
bool enabled = state.enabled;
|
|
if (toggle_row(menu, "Save and restore placed objects",
|
|
"Off pauses saving and restoring. Existing saved layouts are kept.", enabled,
|
|
state.available && callbacks.queue_console_command))
|
|
send(enabled ? "objects enabled 1" : "objects enabled 0");
|
|
if (!state.map.empty())
|
|
info(menu, "Current level", std::string(world_map_label(model.world.map)) + ", " + std::to_string(state.count) + " saved");
|
|
else note("Load a level to manage its saved objects.");
|
|
ImGui::BeginDisabled(!state.available || !callbacks.queue_console_command || !state.can_clear);
|
|
if (ImGui::Button(state.clearing ? "Clearing saved objects..." : "Delete saved objects for this level", ImVec2(-FLT_MIN, 0)))
|
|
send("objects clear " + state.map);
|
|
ImGui::EndDisabled();
|
|
if (!state.status.empty()) note(state.status.c_str());
|
|
end_card();
|
|
section(menu, "PLACED OBJECTS");
|
|
ImGui::SetNextItemWidth(-1);
|
|
ImGui::InputTextWithHint("##object-search", "Search objects...", menu.object_search.data(), menu.object_search.size());
|
|
if (state.rows.empty()) {
|
|
note("No saved or spawned objects in this level.");
|
|
return;
|
|
}
|
|
std::string filter(menu.object_search.data());
|
|
std::transform(filter.begin(), filter.end(), filter.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
if (ImGui::BeginTable("placed-objects", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerH |
|
|
ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp, ImVec2(0, std::max(50.0f, ImGui::GetContentRegionAvail().y - 2)))) {
|
|
ImGui::TableSetupColumn("Object", ImGuiTableColumnFlags_WidthStretch);
|
|
ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, ImGui::CalcTextSize("Saved + spawned").x + 4);
|
|
ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed, 112);
|
|
ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableHeadersRow();
|
|
for (const auto& row : state.rows) {
|
|
std::string label = row.item.starts_with("own_") ? row.item.substr(4) : row.item;
|
|
std::replace(label.begin(), label.end(), '_', ' ');
|
|
std::string searchable = label;
|
|
std::transform(searchable.begin(), searchable.end(), searchable.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
if (!filter.empty() && searchable.find(filter) == std::string::npos && row.item.find(filter) == std::string::npos) continue;
|
|
const auto token = std::to_string(row.token);
|
|
ImGui::PushID(token.c_str());
|
|
ImGui::TableNextRow(); ImGui::TableNextColumn();
|
|
ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(label.c_str());
|
|
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s\nPosition: %.1f, %.1f, %.1f", row.item.c_str(), row.position[0], row.position[1], row.position[2]);
|
|
ImGui::TableNextColumn(); ImGui::AlignTextToFramePadding();
|
|
ImGui::TextUnformatted(row.saved ? (row.spawned ? "Saved + spawned" : "Saved") : "Spawned");
|
|
ImGui::TableNextColumn();
|
|
ImGui::BeginDisabled(!state.available || state.busy || !callbacks.queue_console_command);
|
|
if (ImGui::Button("TP", ImVec2(34, 0))) send("objects tp " + state.map + " " + token);
|
|
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Teleport beside this object's current or saved position.");
|
|
ImGui::SameLine(0, 6);
|
|
if (ImGui::Button("Delete", ImVec2(68, 0))) send("objects delete " + state.map + " " + token);
|
|
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Remove this object from the level and its saved layout.");
|
|
ImGui::EndDisabled(); ImGui::PopID();
|
|
}
|
|
ImGui::EndTable();
|
|
}
|
|
}
|
|
|
|
void population_controls(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
|
const auto& controls = model.world_controls;
|
|
const auto& c = controls.choices;
|
|
const auto send = [&](const char* key, float value) {
|
|
const auto command = "environment " + std::string(key) + " " + std::to_string(value);
|
|
std::array<char, 512> result{};
|
|
const bool queued = callbacks.queue_console_command(callbacks.user, command.c_str(), result.data(), result.size());
|
|
result.back() = '\0'; feedback(menu, result.data());
|
|
return queued;
|
|
};
|
|
begin_card(menu, "population", "POPULATION");
|
|
ImGui::PushID("world-controls");
|
|
ImGui::BeginDisabled(!controls.population_available || !model.world.ready || !callbacks.queue_console_command);
|
|
for (unsigned i = 0; i < 2; ++i) {
|
|
ImGui::PushID(static_cast<int>(i));
|
|
const auto nearby = controls.population_ready[i] ? std::to_string(controls.population[i]) + " nearby right now"
|
|
: std::string("Waiting for the population count");
|
|
field(menu, i == 0 ? "Traffic" : "Pedestrians", nearby.c_str());
|
|
const int choice = i == 0 ? c.traffic : c.pedestrians;
|
|
if (ImGui::BeginCombo("##population", population_labels[choice + 1])) {
|
|
for (int mode = -1; mode <= 3; ++mode)
|
|
if (ImGui::Selectable(population_labels[mode + 1], choice == mode)) send(i == 0 ? "traffic" : "pedestrians", static_cast<float>(mode));
|
|
ImGui::EndCombo();
|
|
}
|
|
ImGui::PopID();
|
|
}
|
|
ImGui::EndDisabled();
|
|
ImGui::PopID();
|
|
std::string counts;
|
|
for (unsigned i = 0; i < 2; ++i)
|
|
if (controls.population_ready[i])
|
|
counts += (counts.empty() ? "" : ", ") + std::to_string(controls.population[i]) + (i == 0 ? " vehicles" : " pedestrians");
|
|
note(counts.empty() ? "Waiting for the population count." : ("Nearby now: " + counts + ".").c_str());
|
|
end_card();
|
|
}
|
|
|
|
void parks(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
|
if (model.world.map == WorldMap::bam) {
|
|
begin_card(menu, "rotating-parks", "ROTATING PARKS", "San Vansterdam");
|
|
namespace tools = dingosdk::native_tools;
|
|
note(tools::parks_locked(model) ? "The lobby host controls these layouts. Changes apply automatically."
|
|
: model.parks.controlled_by_host ? "You are an admin of this server: a layout you load changes for everyone."
|
|
: "Choose a layout for each slot. Choices save and are shared when you host.");
|
|
note("Construction controls both the construction site and the Hedgemont park.");
|
|
const auto park_command = [&](const char* command) {
|
|
std::array<char, 512> result{};
|
|
callbacks.queue_console_command(callbacks.user, command, result.data(), result.size());
|
|
result.back() = '\0'; feedback(menu, result.data());
|
|
};
|
|
ImGui::BeginDisabled(!model.parks.available || tools::parks_locked(model) || !callbacks.queue_console_command);
|
|
if (ImGui::Button("Load Random Parks")) {
|
|
park_command("park random");
|
|
menu.park_edit = {}; // Discard staged dropdown edits, including slots that reroll the same layout.
|
|
}
|
|
ImGui::EndDisabled();
|
|
ImGui::SameLine();
|
|
ImGui::BeginDisabled(!model.parks.available || !callbacks.queue_console_command);
|
|
auto random_on_launch = model.parks.randomize_on_launch;
|
|
if (ImGui::Checkbox("Randomize on Launch", &random_on_launch))
|
|
park_command(random_on_launch ? "park random-on-launch 1" : "park random-on-launch 0");
|
|
ImGui::EndDisabled();
|
|
note("Pick a random layout for each slot. Randomize on Launch runs once per game launch; multiplayer follows the host.");
|
|
ImGui::BeginDisabled(!model.parks.available || tools::parks_locked(model) || !callbacks.queue_console_command);
|
|
if (ImGui::BeginTable("park-layouts", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerH | ImGuiTableFlags_SizingStretchProp)) {
|
|
ImGui::TableSetupColumn("Location", ImGuiTableColumnFlags_WidthStretch, 0.9f);
|
|
ImGui::TableSetupColumn("Layout", ImGuiTableColumnFlags_WidthStretch, 1.2f);
|
|
ImGui::TableSetupColumn("##apply", ImGuiTableColumnFlags_WidthFixed, px(80));
|
|
ImGui::TableHeadersRow();
|
|
for (unsigned lot = 0; lot < park_lots.size(); ++lot) {
|
|
ImGui::PushID(park_lots[lot].key.data());
|
|
auto& selected = menu.park_edit[lot];
|
|
if (selected.empty() || menu.park_seen[lot] != model.parks.choices[lot] || tools::parks_locked(model))
|
|
selected = model.parks.choices[lot];
|
|
menu.park_seen[lot] = model.parks.choices[lot];
|
|
ImGui::TableNextRow(); ImGui::TableNextColumn();
|
|
ImGui::AlignTextToFramePadding();
|
|
ImGui::TextUnformatted(park_lots[lot].label.data());
|
|
ImGui::TableNextColumn();
|
|
ImGui::SetNextItemWidth(-1);
|
|
if (ImGui::BeginCombo("##layout", park_label(selected).c_str())) {
|
|
if (ImGui::Selectable("Empty lot", selected == "empty")) selected = "empty";
|
|
for (unsigned family = 0; family < park_families.size(); ++family)
|
|
for (unsigned variant = 1; variant <= park_lots[lot].counts[family]; ++variant) {
|
|
const auto id = park_id(family, variant);
|
|
if (ImGui::Selectable(park_label(id).c_str(), selected == id)) selected = id;
|
|
}
|
|
ImGui::EndCombo();
|
|
}
|
|
ImGui::TableNextColumn();
|
|
ImGui::BeginDisabled(selected.empty());
|
|
if (ImGui::Button("LOAD", ImVec2(-1, 0))) {
|
|
const auto command = "park " + std::string(park_lots[lot].key) + " " + selected;
|
|
std::array<char, 512> result{};
|
|
callbacks.queue_console_command(callbacks.user, command.c_str(), result.data(), result.size());
|
|
result.back() = '\0'; feedback(menu, result.data());
|
|
}
|
|
ImGui::EndDisabled();
|
|
ImGui::PopID();
|
|
}
|
|
ImGui::EndTable();
|
|
}
|
|
ImGui::EndDisabled();
|
|
if (!model.parks.ready) note("Load San Vansterdam to apply park choices.");
|
|
if (!model.parks.feedback.empty()) note(model.parks.feedback.c_str());
|
|
end_card();
|
|
} else {
|
|
begin_card(menu, "rotating-parks", "ROTATING PARKS");
|
|
note("Rotating parks are on San Vansterdam. Switch maps in Travel.");
|
|
end_card();
|
|
}
|
|
}
|
|
|
|
void world_layers_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
|
if (model.world.controlled_by_host && server_can(model.multiplayer, "layers"))
|
|
note("The server is syncing world layers. Your role may change them, so your changes apply to everyone.");
|
|
else if (model.world.controlled_by_host)
|
|
warn("The host is syncing world layers. Your own choices come back when sync ends or you leave.");
|
|
if (model.world.map == WorldMap::none) {
|
|
note("Load a map to see its world layers.");
|
|
return;
|
|
}
|
|
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * .61f);
|
|
ImGui::InputTextWithHint("##layer-search", "Search layers...", menu.world_layer_search.data(), menu.world_layer_search.size());
|
|
ImGui::SameLine(); ImGui::SetNextItemWidth(-1);
|
|
constexpr const char* categories[]{"All categories", "Seasonal", "Activities", "World", "Lighting", "Structure", "Debug", "Parks"};
|
|
ImGui::Combo("##layer-category", &menu.world_layer_category, categories, static_cast<int>(std::size(categories)));
|
|
const std::string_view query(menu.world_layer_search.data());
|
|
const auto matches_search = [&](std::string_view text) {
|
|
return query.empty() || std::search(text.begin(), text.end(), query.begin(), query.end(), [](unsigned char a, unsigned char b) {
|
|
return std::tolower(a) == std::tolower(b);
|
|
}) != text.end();
|
|
};
|
|
ImGui::BeginDisabled(dingosdk::native_tools::layers_locked(model) || !model.world.available || !model.world.ready ||
|
|
!callbacks.queue_console_command);
|
|
const float footer_height = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y +
|
|
(model.world.feedback.empty() ? 0.0f : ImGui::CalcTextSize(model.world.feedback.c_str(), nullptr, false,
|
|
ImGui::GetContentRegionAvail().x).y + ImGui::GetStyle().ItemSpacing.y);
|
|
if (ImGui::BeginTable("world-layers", 2, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerH |
|
|
ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_ScrollY,
|
|
ImVec2(0, std::max(95.0f, ImGui::GetContentRegionAvail().y - footer_height)))) {
|
|
ImGui::TableSetupScrollFreeze(0, 1);
|
|
ImGui::TableSetupColumn("Layer", ImGuiTableColumnFlags_WidthStretch);
|
|
ImGui::TableSetupColumn("Enabled", ImGuiTableColumnFlags_WidthFixed, 68);
|
|
ImGui::TableHeadersRow();
|
|
unsigned visible_layers{};
|
|
for (unsigned i = 0; i < world_layers().size(); ++i) {
|
|
const auto& layer = world_layers()[i];
|
|
if (layer.map != model.world.map || !model.world.supported[i] || world_layer_kept_off(layer.key)) continue;
|
|
if (menu.world_layer_category && layer.category != categories[menu.world_layer_category]) continue;
|
|
const auto title = world_layer_title(layer.label);
|
|
if (!matches_search(title) && !matches_search(layer.key)) continue;
|
|
++visible_layers;
|
|
// Every row owns its checkbox ID, independent of table headers.
|
|
ImGui::PushID(layer.key.data());
|
|
ImGui::TableNextRow(); ImGui::TableNextColumn();
|
|
ImGui::AlignTextToFramePadding();
|
|
ImGui::TextWrapped("%s", title.c_str());
|
|
if (ImGui::IsItemHovered()) {
|
|
ImGui::BeginTooltip(); ImGui::PushTextWrapPos(ImGui::GetFontSize() * 30);
|
|
ImGui::TextUnformatted(layer.detail.data());
|
|
if (!model.world.status[i].empty()) ImGui::Text("%s", model.world.status[i].c_str());
|
|
ImGui::PopTextWrapPos(); ImGui::EndTooltip();
|
|
}
|
|
ImGui::TableNextColumn();
|
|
const bool follows_game = model.world.choices[i] == "default";
|
|
bool enabled = follows_game ? model.world.enabled[i].value_or(false) : model.world.choices[i] == "on";
|
|
ImGui::SetCursorPosX(ImGui::GetCursorPosX() +
|
|
std::max(0.0f, ImGui::GetContentRegionAvail().x - ImGui::GetFrameHeight()));
|
|
ImGui::PushItemFlag(ImGuiItemFlags_MixedValue, follows_game && !model.world.enabled[i].has_value());
|
|
if (ImGui::Checkbox("##enabled", &enabled)) {
|
|
const auto command = "world " + std::string(layer.key) + (enabled ? " on" : " off");
|
|
std::array<char, 512> result{};
|
|
callbacks.queue_console_command(callbacks.user, command.c_str(), result.data(), result.size());
|
|
result.back() = '\0'; feedback(menu, result.data());
|
|
}
|
|
ImGui::PopItemFlag();
|
|
if (ImGui::IsItemHovered()) {
|
|
ImGui::BeginTooltip(); ImGui::PushTextWrapPos(ImGui::GetFontSize() * 26);
|
|
ImGui::TextUnformatted(follows_game ? "Following the game's state." : "Saved override.");
|
|
if (!model.world.enabled[i].has_value()) ImGui::TextUnformatted("Waiting for the layer's live state.");
|
|
if (!model.world.status[i].empty()) ImGui::TextUnformatted(model.world.status[i].c_str());
|
|
ImGui::TextUnformatted("Disabled parents take priority. Changes save automatically.");
|
|
ImGui::PopTextWrapPos(); ImGui::EndTooltip();
|
|
}
|
|
ImGui::PopID();
|
|
}
|
|
if (!visible_layers) {
|
|
ImGui::TableNextRow(); ImGui::TableNextColumn();
|
|
ImGui::TextDisabled("No matching layers.");
|
|
}
|
|
ImGui::EndTable();
|
|
}
|
|
if (ImGui::Button("Restore Defaults", ImVec2(-1, 0))) {
|
|
const auto command = "world defaults " + std::string(world_map_key(model.world.map));
|
|
std::array<char, 512> result{};
|
|
callbacks.queue_console_command(callbacks.user, command.c_str(), result.data(), result.size());
|
|
result.back() = '\0'; feedback(menu, result.data());
|
|
}
|
|
if (ImGui::IsItemHovered())
|
|
ImGui::SetTooltip("Reset all layers on this map, including filtered rows,\nand return them to game control.");
|
|
ImGui::EndDisabled();
|
|
if (!model.world.feedback.empty()) note(model.world.feedback.c_str());
|
|
}
|
|
|
|
// One page per sidebar entry; each page's tabs switch between focused panels.
|
|
void map_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
|
category_tabs(menu, menu.map_tab, {"TRAVEL", "ROTATING PARKS", "WORLD LAYERS"}, "map-tabs");
|
|
ImGui::PushID(menu.map_tab);
|
|
ImGui::BeginChild("map-tab", ImVec2(0, page_body_height(menu)));
|
|
switch (menu.map_tab) {
|
|
case 0: level_picker(menu, model, callbacks); break;
|
|
case 1: parks(menu, model, callbacks); break;
|
|
case 2: world_layers_page(menu, model, callbacks); break;
|
|
}
|
|
ImGui::EndChild();
|
|
ImGui::PopID();
|
|
}
|
|
|
|
void graphics_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
|
begin_card(menu, "post-fx", "POST PROCESSING");
|
|
const auto command = [](SkateMenu& target, const CallbacksV3& cb, const std::string& text) {
|
|
std::array<char, 512> result{};
|
|
cb.queue_console_command(cb.user, text.c_str(), result.data(), result.size());
|
|
result.back() = '\0';
|
|
feedback(target, result.data());
|
|
};
|
|
constexpr std::array<const char*, 3> labels{"Film grain", "Vignette", "Chromatic aberration"};
|
|
constexpr std::array<const char*, 3> hints{
|
|
"Grain over the picture. On allows the scene's authored grain amount.",
|
|
"Darkening around the edges of the picture.",
|
|
"Color fringing around the edges of the picture."};
|
|
const auto& graphics = model.graphics;
|
|
for (unsigned i = 0; i < labels.size(); ++i) {
|
|
auto& pending = menu.graphics_pending[i];
|
|
if (pending && (*pending == graphics.choices.effects[i] || ImGui::GetTime() > menu.graphics_pending_until[i] ||
|
|
graphics.status == "Graphics controls could not be saved.")) pending.reset();
|
|
const int choice = pending.value_or(graphics.choices.effects[i]);
|
|
bool enabled = choice < 0 ? (graphics.ready[i] ? graphics.enabled[i] : true) : choice != 0;
|
|
if (toggle_row(menu, labels[i], hints[i], enabled, graphics.available && callbacks.queue_console_command)) {
|
|
const int value = enabled ? 1 : 0;
|
|
command(menu, callbacks, std::string("graphics ") + graphics_keys[i] + " " + std::to_string(value));
|
|
pending = value; menu.graphics_pending_until[i] = ImGui::GetTime() + 3;
|
|
}
|
|
}
|
|
ImGui::Dummy(ImVec2(0, px(2)));
|
|
ImGui::BeginDisabled(!graphics.available || !callbacks.queue_console_command);
|
|
if (ImGui::Button("Restore defaults", ImVec2(-FLT_MIN, 0))) {
|
|
command(menu, callbacks, "graphics reset -1");
|
|
for (unsigned i = 0; i < labels.size(); ++i) {
|
|
menu.graphics_pending[i] = -1; menu.graphics_pending_until[i] = ImGui::GetTime() + 3;
|
|
}
|
|
}
|
|
ImGui::EndDisabled();
|
|
note("Changes save automatically.");
|
|
if (!graphics.status.empty()) note(graphics.status.c_str());
|
|
end_card();
|
|
}
|
|
|
|
// The map's seven time-of-day world layers as one choice, like the native menu:
|
|
// forcing one turns the others off; Default hands all seven back to the level.
|
|
void time_of_day_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
|
namespace tod = dingosdk::native_tools;
|
|
begin_card(menu, "time-of-day", "TIME OF DAY");
|
|
const bool available = tod::time_of_day_available(model, callbacks);
|
|
const int current = static_cast<int>(tod::time_of_day(model));
|
|
const auto apply = [&](unsigned next) {
|
|
for (const auto& line : tod::time_of_day_commands(model, next)) send_console(menu, callbacks, line);
|
|
};
|
|
int top = current < 4 ? current : -1;
|
|
if (choice(menu, "tod-top", top, {"Default", "Morning", "Noon", "Afternoon"}, available)) apply(static_cast<unsigned>(top));
|
|
int bottom = current >= 4 ? current - 4 : -1;
|
|
if (choice(menu, "tod-bottom", bottom, {"Evening", "Night", "Weather day", "Weather night"}, available))
|
|
apply(static_cast<unsigned>(bottom + 4));
|
|
if (model.world.controlled_by_host && server_can(model.multiplayer, "tod"))
|
|
note("Your role on this server sets the time: the one you pick applies to everyone.");
|
|
else if (model.world.controlled_by_host) warn("The lobby host controls world layers, including time of day.");
|
|
else if (model.world.map == WorldMap::none) note("Load a map in Map > Travel to change its time of day.");
|
|
else if (!available) note("Time of day is not available on this map right now.");
|
|
else note("Default follows the level's own time. Your choice saves and returns when you load this map again.");
|
|
end_card();
|
|
}
|
|
|
|
void world_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
|
category_tabs(menu, menu.world_tab, {"TIME OF DAY", "ATMOSPHERE", "POPULATION"}, "world-tabs");
|
|
ImGui::PushID(menu.world_tab);
|
|
ImGui::BeginChild("world-tab", ImVec2(0, page_body_height(menu)));
|
|
const bool loaded = model.world.map != WorldMap::none;
|
|
switch (menu.world_tab) {
|
|
case 0: time_of_day_page(menu, model, callbacks); break;
|
|
case 1:
|
|
if (loaded) atmosphere_menu(menu, model, callbacks);
|
|
else note("Load a map in Map > Travel to change its fog, sky and wind.");
|
|
break;
|
|
case 2:
|
|
if (loaded) population_controls(menu, model, callbacks);
|
|
else note("Load a map in Map > Travel to change its traffic and pedestrians.");
|
|
break;
|
|
}
|
|
ImGui::EndChild();
|
|
ImGui::PopID();
|
|
}
|
|
|
|
void build_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
|
category_tabs(menu, menu.build_tab, {"PARK EDITOR", "PARK MODS", "SAVED OBJECTS"}, "build-tabs");
|
|
ImGui::PushID(menu.build_tab);
|
|
ImGui::BeginChild("build-tab", ImVec2(0, page_body_height(menu)));
|
|
if (menu.build_tab == 0) park_editor_panel(menu, model, callbacks);
|
|
else if (menu.build_tab == 1) park_mods_panel(menu, model, callbacks);
|
|
else saved_objects(menu, model, callbacks);
|
|
ImGui::EndChild();
|
|
ImGui::PopID();
|
|
}
|
|
}
|