Files
ReSkate/Extension/UI/Overlay/modding_menu.cpp
T
Fourth Studio bd628abc76 Board Break: a hard slam can snap the board
A slam can break the local skater's board at the nose, in the middle or at
the tail. Half the time the grip tape holds the pieces together and the board
folds or an end hangs; otherwise a piece comes off, flies and lies where it
lands. The board is whole again in hand, on riding and on a respawn.

The pieces are four stretches of the deck in a content mod's copy of the
game's one deck model, each on a spare bone of the board's rig. The boards'
render pose hook only moves bones, because the replay records where a bone is
and not its size: a broken board's deck goes away and its stretches go to
where the pieces lie, and a whole board's stretches go away.

The deck the player chose loads first and other mods that bring a deck model
are set aside for the run, without touching mods.json. Another deck is chosen
while the game runs, through a live apply of the mods.

contrib/board-break.md says how it works and how a deck pack is made.
2026-10-11 20:07:00 +02:00

283 lines
14 KiB
C++

#include "modding_menu.h"
#include "skate_menu.h"
#include "skate_menu_internal.h"
#include "Engine/Vfs/initfs.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Extension/Assets/live_mods.h"
#include "Extension/UI/skate_theme.h"
#include "Engine/Core/Log/logging.h"
#include <Windows.h>
#include <algorithm>
#include <filesystem>
#include <optional>
#include <stdexcept>
namespace dingosdk::overlay {
namespace {
std::filesystem::path game_directory() {
std::array<wchar_t, 32768> executable{};
const auto length = GetModuleFileNameW(nullptr, executable.data(), static_cast<DWORD>(executable.size()));
if (!length || length >= executable.size()) throw std::runtime_error("Cannot locate the game directory.");
return std::filesystem::path(std::wstring(executable.data(), length)).parent_path();
}
// What a mod brings, as the native Mods page words it.
std::string mod_contents(const mods::Mod& mod) {
std::string text = mod.provides_layout && mod.provides_levels ? "Maps + game data"
: mod.provides_levels ? "Maps" : mod.provides_layout ? "Game data" : "";
if (!mod.park_maps.empty()) text += text.empty() ? "Parks" : " + parks";
return text.empty() ? "Nothing to load" : text;
}
// Every mod folder and what mods.json says about it, re-read every 1.5 s while
// the card is open, so a folder added or an edit made outside the game shows
// up. Enabling or disabling saves mods.json; "Apply now" merges the change in
// while the game runs (it takes effect at the next level load), including a
// mod folder added since launch.
void draw_installed_mods(SkateMenu& menu) {
static mods::ModList list;
static ULONGLONG next_scan{};
static std::string action_error;
const auto now = GetTickCount64();
if (now >= next_scan) {
list = mods::scan_mods(mods::engine_data_root());
next_scan = now + 1500;
}
const auto& launch = mods::catalog();
const auto applied = live_mods::applied_mods();
const auto in_effect = [&](const std::string& name) {
return std::ranges::any_of(applied, [&](const std::string& other) { return _stricmp(other.c_str(), name.c_str()) == 0; });
};
const auto excluded = [&](const std::string& name) {
return std::ranges::any_of(launch.excluded, [&](const mods::Mod& mod) { return _stricmp(mod.name.c_str(), name.c_str()) == 0; });
};
// Board Break's other decks and other mods that bring a deck model: enabled, not loaded, and
// nothing about them waits to be applied.
const auto aside = mods::set_aside_for_board_break();
const auto set_aside = [&](const mods::ModEntry& entry) {
return entry.enabled && !in_effect(entry.mod.name) &&
std::ranges::any_of(aside, [&](const std::string& other) { return _stricmp(other.c_str(), entry.mod.name.c_str()) == 0; });
};
std::size_t loaded{}, pending{};
for (const auto& entry : list.entries) {
const bool effect = in_effect(entry.mod.name);
if (effect) ++loaded;
if (entry.enabled != effect && !excluded(entry.mod.name) && !set_aside(entry)) ++pending;
}
// Mods still in effect whose folder has been deleted: applying unloads them.
std::vector<std::string> removed;
for (const auto& name : applied)
if (std::ranges::none_of(list.entries, [&](const mods::ModEntry& entry) { return _stricmp(entry.mod.name.c_str(), name.c_str()) == 0; }))
removed.push_back(name);
pending += removed.size();
loaded += removed.size();
std::string count = std::to_string(loaded) + " loaded";
if (!launch.excluded.empty()) count += ", " + std::to_string(launch.excluded.size()) + " not loaded";
menu::begin_card(menu, "installed-mods", "INSTALLED MODS", count.c_str());
// Unapplied changes first, with the button that applies them.
if (live_mods::busy()) {
menu::note("Applying mods...");
} else if (pending) {
menu::warn((std::to_string(pending) + " change(s) not applied yet.").c_str());
// One button, labelled by what the changes do to the level being played:
// maps only just need applying; anything the level shows needs it
// reloaded; taking away the map being played sends the player to San Van.
const auto effect = live_mods::preview(list);
if (ImGui::Button(live_mods::apply_label(effect), ImVec2(-FLT_MIN, 0))) {
action_error.clear();
menu::feedback(menu, live_mods::apply(effect == live_mods::ApplyEffect::reload_level).c_str());
}
}
if (const auto last = live_mods::status(); !last.empty()) menu::note(("Last apply: " + last).c_str());
auto* draw = ImGui::GetWindowDrawList();
// A mod's tile; the button on its right enables or disables it.
const auto tile = [&](int index, const std::string& title, const std::string& byline, const char* status,
ImU32 colour, const std::string& contents, const std::string& hover, const char* action) {
bool clicked{};
ImGui::PushID(index);
const float button_width = action ? menu::px(96) : 0.f;
const auto top = ImGui::GetCursorScreenPos();
const float width = ImGui::GetContentRegionAvail().x - (action ? button_width + menu::px(8) : 0.f);
const float height = menu::px(54), pad = menu::px(14);
skate_theme::rough_rect(draw, top, ImVec2(top.x + width, top.y + height), skate_theme::tile_grey,
static_cast<unsigned>(index + 151), menu::px(1));
float right = top.x + width - pad;
const auto contents_size = menu.body->CalcTextSizeA(menu::px(14), FLT_MAX, 0, contents.c_str());
right -= contents_size.x;
draw->AddText(menu.body, menu::px(14), ImVec2(right, top.y + (height - contents_size.y) * .5f),
skate_theme::grey_text, contents.c_str());
right -= menu::px(14);
const float status_width = menu.bold->CalcTextSizeA(menu::px(12), FLT_MAX, 0, status).x + menu::px(14);
right -= status_width;
ImGui::SetCursorScreenPos(ImVec2(right, top.y + (height - ImGui::GetFrameHeight()) * .5f));
menu::tag(menu, status, colour);
right -= menu::px(14);
ImGui::SetCursorScreenPos(ImVec2(top.x + pad, top.y + menu::px(8)));
ImGui::PushClipRect(ImGui::GetCursorScreenPos(), ImVec2(right, top.y + height), true);
ImGui::BeginGroup();
ImGui::PushFont(menu.bold);
ImGui::TextUnformatted(title.c_str());
ImGui::PopFont();
ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::grey_text);
ImGui::TextUnformatted(byline.c_str());
ImGui::PopStyleColor();
ImGui::EndGroup();
ImGui::PopClipRect();
ImGui::SetCursorScreenPos(top);
ImGui::Dummy(ImVec2(width, height));
if (!hover.empty() && ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
ImGui::PushTextWrapPos(ImGui::GetFontSize() * 28);
ImGui::TextUnformatted(hover.c_str());
ImGui::PopTextWrapPos();
ImGui::EndTooltip();
}
if (action) {
ImGui::SameLine(0, menu::px(8));
ImGui::SetCursorScreenPos(ImVec2(top.x + width + menu::px(8), top.y + (height - ImGui::GetFrameHeight()) * .5f));
clicked = ImGui::Button(action, ImVec2(button_width, 0));
ImGui::SetCursorScreenPos(ImVec2(top.x, top.y + height + ImGui::GetStyle().ItemSpacing.y));
ImGui::Dummy(ImVec2(0, 0));
}
ImGui::PopID();
return clicked;
};
const auto byline = [](const mods::Mod& mod) {
std::string text = mod.author.empty() ? "Unknown author" : "by " + mod.author;
if (!mod.version.empty()) text += " - v" + mod.version;
return text;
};
std::optional<std::size_t> toggle;
int index{};
for (std::size_t i = 0; i < list.entries.size(); ++i) {
const auto& entry = list.entries[i];
const auto& mod = entry.mod;
const bool effect = in_effect(mod.name);
const char* status = "DISABLED";
ImU32 colour = skate_theme::tile_light;
std::string hover = mod.description;
if (excluded(mod.name)) {
status = "NOT LOADED";
colour = skate_theme::danger;
hover = "Not loaded: it could not be merged cleanly, so none of it is used.\n\n"
"Reinstall the whole mod folder, or rebuild it with a current ReSkate Studio.";
} else if (set_aside(entry)) {
status = "SET ASIDE";
hover = "Set aside while a Board Break deck is the one in the game: the game has one deck model, so one "
"mod's deck loads at a time. Pick another deck with Board Break deck, or disable Board Break's decks.";
} else if (entry.enabled && effect) {
status = "LOADED";
colour = skate_theme::good;
} else if (entry.enabled) {
status = "APPLY TO LOAD";
colour = skate_theme::danger;
} else if (effect) {
status = "APPLY TO UNLOAD";
colour = skate_theme::danger;
}
if (tile(index++, mod.title.empty() ? mod.name : mod.title, byline(mod), status, colour, mod_contents(mod), hover,
entry.enabled ? "Disable" : "Enable"))
toggle = i;
}
for (const auto& name : removed)
tile(index++, name, "Folder deleted", "APPLY TO UNLOAD", skate_theme::danger, "",
"This mod's folder is gone, but it stays loaded until you apply.", nullptr);
if (toggle && list.issue.empty()) {
try {
auto entries = list.entries;
entries[*toggle].enabled = !entries[*toggle].enabled;
mods::save_mod_order(list.root, entries);
next_scan = 0;
action_error.clear();
} catch (const std::exception& error) { action_error = error.what(); }
}
if (!index) menu::note(list.present ? "No mods installed yet." : "No Mods folder yet. Create one next to Skate.exe.");
if (!list.issue.empty()) menu::warn(("mods.json problem: " + list.issue).c_str());
if (!action_error.empty()) menu::warn(("Could not save mods.json: " + action_error).c_str());
menu::note("Order mods in the launcher's Mods panel. Maps, loading screens and cosmetics change at the next level load; "
"other changes to game settings or the UI take effect after a restart.");
menu::end_card();
}
void draw_custom_scripts(SkateMenu& menu) {
menu::begin_card(menu, "custom-scripts", "CUSTOM SCRIPTS");
menu::note("Drop .lua files or folders with init.lua into scripts/Custom/. Scripts and saved changes load on the next launch.");
if (ImGui::Button("Refresh script list", ImVec2(-FLT_MIN, 0))) menu.custom_scripts_scanned = false;
if (!menu.custom_scripts_scanned) {
menu.custom_scripts_scanned = true;
menu.custom_script_rows.clear();
menu.custom_script_error.clear();
try { menu.custom_script_rows = custom_scripts::discover(game_directory()); }
catch (const std::exception& error) { menu.custom_script_error = error.what(); }
}
if (!menu.loose_files_saved) menu::warn("Turn on loose Lua / config files in the launcher to load custom scripts.");
menu::note(custom_scripts::status("").c_str());
if (!menu.custom_script_error.empty()) menu::warn(("Script settings error: " + menu.custom_script_error).c_str());
if (menu.custom_script_rows.empty()) {
menu::note("No custom scripts found. Names starting with _ or . are skipped.");
menu::end_card();
return;
}
std::optional<std::pair<std::size_t, std::size_t>> move;
if (ImGui::BeginTable("custom_scripts", 4, ImGuiTableFlags_RowBg)) {
ImGui::TableSetupColumn("On", ImGuiTableColumnFlags_WidthFixed, menu::px(32));
ImGui::TableSetupColumn("Script", ImGuiTableColumnFlags_WidthStretch, 1.0f);
ImGui::TableSetupColumn("Order", ImGuiTableColumnFlags_WidthFixed, menu::px(100));
ImGui::TableSetupColumn("This session", ImGuiTableColumnFlags_WidthStretch, 1.0f);
ImGui::TableHeadersRow();
for (std::size_t index = 0; index < menu.custom_script_rows.size(); ++index) {
auto& script = menu.custom_script_rows[index];
ImGui::PushID(script.id.c_str());
ImGui::TableNextRow();
ImGui::TableNextColumn();
bool enabled = script.enabled;
if (ImGui::Checkbox("##enabled", &enabled)) {
try {
custom_scripts::save_enabled(game_directory(), script.id, enabled);
script.enabled = enabled;
menu.custom_script_error.clear();
menu::feedback(menu, "Script selection saved for the next launch.");
} catch (const std::exception& error) { menu.custom_script_error = error.what(); }
}
ImGui::TableNextColumn();
ImGui::TextWrapped("%s", script.id.c_str());
ImGui::TableNextColumn();
ImGui::BeginDisabled(index == 0);
if (ImGui::SmallButton("Up")) move = {{index, index - 1}};
ImGui::EndDisabled();
ImGui::SameLine();
ImGui::BeginDisabled(index + 1 == menu.custom_script_rows.size());
if (ImGui::SmallButton("Down")) move = {{index, index + 1}};
ImGui::EndDisabled();
ImGui::TableNextColumn();
ImGui::TextWrapped("%s", custom_scripts::status(script.id).c_str());
ImGui::PopID();
}
ImGui::EndTable();
}
if (move) {
try {
auto ordered = menu.custom_script_rows;
std::swap(ordered[move->first], ordered[move->second]);
custom_scripts::save_order(game_directory(), ordered);
menu.custom_script_rows = std::move(ordered);
menu.custom_script_error.clear();
menu::feedback(menu, "Script order saved for the next launch.");
} catch (const std::exception& error) { menu.custom_script_error = error.what(); }
}
menu::end_card();
}
}
void draw_modding_menu(SkateMenu& menu, int tab) {
// Loose files are switched in the launcher; custom scripts only need to know
// whether they are on.
if (!menu.loose_files_settings_loaded) {
menu.loose_files_settings_loaded = true;
try { menu.loose_files_saved = initfs::loose_files_preference(game_directory()); }
catch (const std::exception&) {}
}
if (tab == 1) draw_custom_scripts(menu);
else draw_installed_mods(menu);
}
}