Files
ReSkate/Extension/UI/Overlay/modding_menu.cpp
T
2026-10-05 17:27:50 -05:00

272 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; });
};
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)) ++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 (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);
}
}