Files
AyeZeeBB 4c07f2493b Menu: a full screen layout for controllers beside the window, and the pages regrouped
The ReSkate menu now has two styles, switched with View / F11 or the buttons in its top bar
and saved with the profile (`ui fullscreen`):
- Window: the mouse menu as before, with a side rail of pages in groups.
- Full screen: made for a controller, in the pause menu's look. Pages across the top (LB / RB),
  each page's sections as the ONLINE page's tabs (LT / RT), button prompts at the bottom, the
  pause menu's sounds, and the pause menu's own Multiplayer pages as ONLINE.

The pages are regrouped: HOME (quick switches and a tile for each page), MAP, BUILD, SKATER
(freecam, first person, cheats), SANDBOX (once Trainer), ADDONS (drone, Board Break, Road
Rash, Hall of Meat, grind exits, music), ONLINE, PROGRESS, MODS, SETTINGS, DEVELOPER.

Full screen, every shared control is drawn for a pad: switches and option rows changed with
left / right one step a press, sliders as steppers that repeat when held, rows of buttons,
and pages that scroll with the right stick. Search boxes and typed values stay in the window.
A pad press takes the menu's focus back and hides the pointer until the mouse moves.

Also: map pictures in the map picker (official loading screens, a mod's icon) with a search
box in the window, Splash and Tutorial Island left out of it; number boxes beside the
window's sliders; the binds in groups, with an action bind to open the menu (`menu`); a menu
size for each style (`ui scalefull`); the time of day stepping from the one last asked for.
2026-10-16 23:37:33 -05:00

743 lines
43 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) {
std::vector<const Level*> destinations;
const Level* root = nullptr;
for (const auto& level : model.levels) {
if (same(level.asset, root_asset)) root = &level;
// (Not the splash level: it only hosts the startup screen, as the pause menu's map list has it.
// Nor Tutorial Island: the game's first-time tutorial, not a place to skate.)
else if (dingosdk::native_tools::listed(level) && world_level_name(level.asset) != "Tutorial Island")
destinations.push_back(&level);
}
std::sort(destinations.begin(), destinations.end(), [](const auto* a, const auto* b) {
return level_name(*a) < level_name(*b);
});
{
std::vector<MenuMapWanted> wanted;
for (const auto* level : destinations) wanted.push_back({level->asset, level_name(*level)});
want_menu_map_pictures(std::move(wanted));
}
const bool loading = root && root->can_load && model.can_queue_load && callbacks.queue_load;
const auto load = [&](const Level* level) {
std::array<char, 512> result{};
const auto* point = level->automatic_start_point();
const bool queued = callbacks.queue_load(callbacks.user, root->asset.c_str(), "", level->asset.c_str(),
point ? point->c_str() : "", result.data(), result.size());
result.back() = '\0';
feedback(menu, result[0] ? result.data() : (queued ? ("Loading " + level_name(*level) + "...").c_str() : "Load unavailable."));
};
if (!loading) {
const std::string reason = !root ? "Waiting for levels..." : !root->can_load ? root->load_block_reason : model.load_block_reason;
warn(reason.empty() ? "Level loading is unavailable." : reason.c_str());
}
// A window has a keyboard: a box to find a map by name. (Full screen shows them all.)
const bool console = console_style();
if (!console) {
static std::array<char, 64> search{};
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##map-search", "Search maps...", search.data(), search.size());
const auto lower = [](std::string text) {
std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return text;
};
if (const auto words = lower(search.data()); !words.empty()) {
std::erase_if(destinations, [&](const Level* level) { return lower(level_name(*level)).find(words) == std::string::npos; });
if (destinations.empty()) note("No maps match that search.");
}
}
// A card per map with its picture. In a window a LOAD button under each; full screen the card
// itself loads (A).
auto* draw = ImGui::GetWindowDrawList();
const auto grid = [&](const char* heading, bool custom) {
std::vector<const Level*> shown;
for (const auto* level : destinations)
if (level->custom == custom) shown.push_back(level);
if (shown.empty()) return;
section(menu, heading);
const float gap = px(12);
const float across = ImGui::GetContentRegionAvail().x;
const int columns = std::max(1, static_cast<int>((across + gap) / (px(console ? 260.0f : 210.0f) + gap)));
const float width = (across - gap * static_cast<float>(columns - 1)) / static_cast<float>(columns);
const float picture = width * 0.58f, height = picture + px(42);
for (std::size_t i = 0; i < shown.size(); ++i) {
const auto* level = shown[i];
if (i % static_cast<std::size_t>(columns)) ImGui::SameLine(0, gap);
ImGui::PushID(level->asset.c_str());
ImGui::BeginGroup();
const bool can = loading && level->can_load;
const auto at = ImGui::GetCursorScreenPos();
bool hovered{}, focused{};
if (console) {
ImGui::BeginDisabled(!can);
if (ImGui::InvisibleButton("##map", ImVec2(width, height), ImGuiButtonFlags_EnableNav) && can) load(level);
ImGui::EndDisabled();
hovered = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled);
focused = ImGui::IsItemFocused() && ImGui::GetIO().NavVisible;
} else {
ImGui::Dummy(ImVec2(width, height));
}
const ImVec2 end(at.x + width, at.y + height);
draw->AddRectFilled(at, end, skate_theme::tile);
ImTextureID texture{};
ImVec2 uv0, uv1;
if (menu_map_picture(level->asset, texture, uv0, uv1)) {
// The middle band of the square picture, to the card's shape.
const float band = (uv1.y - uv0.y) * (1.0f - picture / width) * 0.5f;
draw->AddImage(texture, at, ImVec2(end.x, at.y + picture), ImVec2(uv0.x, uv0.y + band), ImVec2(uv1.x, uv1.y - band),
level->can_load ? IM_COL32_WHITE : IM_COL32(255, 255, 255, 90));
} else {
draw->AddRectFilled(at, ImVec2(end.x, at.y + picture), skate_theme::tile_grey);
const char* initial = custom ? "CUSTOM" : "MAP";
const auto size = menu.bold->CalcTextSizeA(px(13), FLT_MAX, 0, initial);
draw->AddText(menu.bold, px(13), ImVec2(at.x + (width - size.x) * .5f, at.y + (picture - size.y) * .5f), muted, initial);
}
draw->AddRectFilled(ImVec2(at.x, at.y + picture), end, focused ? blue : hovered ? skate_theme::tile_light : skate_theme::tile);
draw->PushClipRect(at, end, true);
draw->AddText(menu.bold, px(15), ImVec2(at.x + px(12), at.y + picture + px(12)),
focused ? skate_theme::black : level->can_load ? paper : muted, level_name(*level).c_str());
draw->PopClipRect();
if (focused) draw->AddRect(ImVec2(at.x - px(3), at.y - px(3)), ImVec2(end.x + px(3), end.y + px(3)), paper, 0, 0, px(3));
if (!console) {
ImGui::BeginDisabled(!can);
skate_theme::push_primary_button();
if (ImGui::Button("LOAD", ImVec2(width, px(32)))) load(level);
skate_theme::pop_primary_button();
ImGui::EndDisabled();
if (!level->can_load && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
ImGui::SetTooltip("%s", level->load_block_reason.c_str());
} else if (!level->can_load && (hovered || focused)) {
ImGui::SetTooltip("%s", level->load_block_reason.c_str());
}
ImGui::EndGroup();
ImGui::PopID();
}
};
grid("OFFICIAL MAPS", false);
grid("CUSTOM MAPS", true);
if (destinations.empty()) note("Waiting for levels...");
}
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");
if (console_style()) {
const bool can = controls.population_available && model.world.ready && callbacks.queue_console_command;
std::vector<std::string> modes;
for (const auto* label : population_labels) modes.emplace_back(label);
for (unsigned i = 0; i < 2; ++i) {
int chosen = (i == 0 ? c.traffic : c.pedestrians) + 1;
const auto nearby = controls.population_ready[i] ? std::to_string(controls.population[i]) + " nearby right now."
: std::string("Waiting for the population count.");
if (option_row(menu, i == 0 ? "Traffic" : "Pedestrians", nearby.c_str(), chosen, modes, can))
send(i == 0 ? "traffic" : "pedestrians", static_cast<float>(chosen - 1));
}
end_card();
return;
}
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());
};
if (console_style()) {
// Full screen: a row a slot, left / right to pick its layout and A to load it.
const bool locked = !model.parks.available || tools::parks_locked(model) || !callbacks.queue_console_command;
if (button_row(menu, "park-random", {{"LOAD RANDOM PARKS", !locked, false, "A random layout in every slot."}}) == 0) {
park_command("park random");
menu.park_edit = {};
}
auto random_on_launch = model.parks.randomize_on_launch;
if (toggle_row(menu, "Randomize on launch", "A random layout in every slot once each time the game starts. Multiplayer follows the host.",
random_on_launch, model.parks.available && callbacks.queue_console_command))
park_command(random_on_launch ? "park random-on-launch 1" : "park random-on-launch 0");
for (unsigned lot = 0; lot < park_lots.size(); ++lot) {
auto& staged = menu.park_edit[lot];
if (staged.empty() || menu.park_seen[lot] != model.parks.choices[lot] || tools::parks_locked(model))
staged = model.parks.choices[lot];
menu.park_seen[lot] = model.parks.choices[lot];
std::vector<std::string> ids{"empty"}, names{"Empty lot"};
for (unsigned family = 0; family < park_families.size(); ++family)
for (unsigned variant = 1; variant <= park_lots[lot].counts[family]; ++variant) {
ids.push_back(park_id(family, variant));
names.push_back(park_label(ids.back()));
}
int at = -1;
for (std::size_t i = 0; i < ids.size(); ++i)
if (ids[i] == staged) at = static_cast<int>(i);
const bool loaded = staged == model.parks.choices[lot];
const auto label = std::string(park_lots[lot].label) + (loaded ? "" : " - A loads it") + "###" + std::string(park_lots[lot].key);
bool load = false;
if (option_row(menu, label.c_str(), loaded ? "Left / right to pick a layout, then A to load it." : "Not loaded yet: press A to load it.",
at, names, !locked, &load) && at >= 0)
staged = ids[static_cast<std::size_t>(at)];
if (load && !staged.empty())
park_command(("park " + std::string(park_lots[lot].key) + " " + staged).c_str());
}
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();
return;
}
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;
}
if (console_style()) {
// Full screen: which kind, then a switch a layer (no search box: that wants a keyboard).
static const std::vector<std::string> kinds{"All categories", "Seasonal", "Activities", "World", "Lighting", "Structure", "Debug", "Parks"};
begin_card(menu, "world-layers", "WORLD LAYERS");
option_row(menu, "Show", "Left / right for one kind of layer.", menu.world_layer_category, kinds);
const bool can = !dingosdk::native_tools::layers_locked(model) && model.world.available && model.world.ready && callbacks.queue_console_command;
unsigned shown{};
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 != kinds[static_cast<std::size_t>(menu.world_layer_category)]) continue;
++shown;
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::PushID(layer.key.data());
if (toggle_row(menu, world_layer_title(layer.label).c_str(), layer.detail.data(), enabled, can))
send_console(menu, callbacks, "world " + std::string(layer.key) + (enabled ? " on" : " off"));
ImGui::PopID();
}
if (!shown) note("No layers of that kind on this map.");
if (button_row(menu, "layer-defaults", {{"RESTORE DEFAULTS", can, false, "Every layer on this map back to the game's control."}}) == 0)
send_console(menu, callbacks, "world defaults " + std::string(world_map_key(model.world.map)));
if (!model.world.feedback.empty()) note(model.world.feedback.c_str());
end_card();
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());
}
void time_of_day_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks);
// 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", "PARKS", "TIME AND WEATHER", "LAYERS AND CROWDS"}, "map-tabs");
ImGui::PushID(menu.map_tab);
ImGui::BeginChild("map-tab", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened);
const bool loaded = model.world.map != WorldMap::none;
switch (menu.map_tab) {
case 0: level_picker(menu, model, callbacks); break;
case 1: parks(menu, model, callbacks); break;
case 2:
time_of_day_page(menu, model, callbacks);
if (loaded) atmosphere_menu(menu, model, callbacks);
else note("Load a map in Map > Travel to change its fog, sky and wind.");
break;
case 3:
world_layers_page(menu, model, callbacks);
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 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.
// A time of day is seven layer changes the game takes a few frames to make, and until they are
// all made the layers read as some other time (or none). So the time asked for is kept and shown,
// and the next step goes on from it, until the layers say it or four seconds pass.
namespace {
int tod_asked = -1;
double tod_asked_until = 0;
}
int time_of_day_shown(const Model& model) {
const int now = static_cast<int>(dingosdk::native_tools::time_of_day(model));
if (tod_asked >= 0 && (now == tod_asked || ImGui::GetTime() > tod_asked_until)) tod_asked = -1;
return tod_asked >= 0 ? tod_asked : now;
}
void set_time_of_day(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks, int next, bool quiet) {
tod_asked = std::clamp(next, 0, 7);
tod_asked_until = ImGui::GetTime() + 4.0;
for (const auto& line : dingosdk::native_tools::time_of_day_commands(model, static_cast<unsigned>(tod_asked))) {
if (!quiet) {
send_console(menu, callbacks, line);
} else if (callbacks.queue_console_command) {
std::array<char, 256> reply{};
callbacks.queue_console_command(callbacks.user, line.c_str(), reply.data(), reply.size());
}
}
}
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 = time_of_day_shown(model);
const auto apply = [&](unsigned next) { set_time_of_day(menu, model, callbacks, static_cast<int>(next), false); };
if (console_style()) {
static const std::vector<std::string> times{"Default", "Morning", "Noon", "Afternoon", "Evening", "Night", "Weather day", "Weather night"};
int chosen = current;
if (option_row(menu, "Time of day", "Left / right to change it. Default follows the level's own time.", chosen, times, available))
apply(static_cast<unsigned>(chosen));
}
int top = current < 4 ? current : -1;
if (!console_style()) {
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 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)), ImGuiChildFlags_NavFlattened);
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();
}
}