Files
ReSkate/Launcher/gui_settings.cpp
T
AyeZeeBB 228ce65f84 Launcher: changing the update settings checks again without a restart
Turning "Install ReSkate updates" or "Test pre-releases" on or off took
effect at the next start. Closing settings after changing either now runs
the update check again: a pre-release is offered as soon as it is wanted,
the latest release when it no longer is, and a pending update is dropped
when updates are turned off.

- Not while a download, an update or a launch is under way, while the game
  runs, or while a merge waits on an answer about mods; nor as the launcher
  closes. The next check or start picks the change up then.
- The "Test pre-releases" help says the check happens when settings close.
2026-10-15 18:43:11 -05:00

491 lines
23 KiB
C++

#include "gui_internal.h"
#include <cmath>
#include <format>
// The Settings panel.
namespace dingosdk::launcher_gui::detail {
namespace {
// A muted explanation line.
void setting_note(const char* text) {
ImGui::PushTextWrapPos(0);
ImGui::TextDisabled("%s", text);
ImGui::PopTextWrapPos();
ImGui::Spacing();
}
// A checkbox with its label and a muted explanation stacked beside the box,
// so the explanation sits right under the label instead of under the box.
void setting_check(const char* label, const char* note, bool& value) {
ImGui::PushID(label);
const auto start = ImGui::GetCursorPos();
ImGui::Checkbox("##check", &value);
const float below_box = ImGui::GetCursorPosY();
// Not SameLine: that would drop the text to the box's frame padding. The
// label's top lines up with the box's top.
ImGui::SetCursorPos(ImVec2(start.x + ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.x, start.y));
ImGui::BeginGroup();
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, S(1)));
ImGui::TextUnformatted(label);
if (ImGui::IsItemClicked()) value = !value;
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::PushTextWrapPos(0);
ImGui::TextDisabled("%s", note);
ImGui::PopTextWrapPos();
ImGui::PopStyleVar();
ImGui::EndGroup();
if (ImGui::GetCursorPosY() < below_box) ImGui::SetCursorPosY(below_box);
ImGui::PopID();
ImGui::Spacing();
ImGui::Spacing();
}
void section_caption(const Fonts& fonts, const char* text) {
ImGui::PushFont(fonts.caption);
ImGui::TextDisabled("%s", text);
ImGui::PopFont();
}
// ---------------------------------------------------------------- skate.'s own settings
namespace game = launcher_game_settings;
constexpr float game_control_x = 300, game_control_width = 260;
// Reads the game's settings the first time a page of them is drawn after Settings opens, and
// says why when there are none to show. False: nothing to draw.
bool game_settings_ready(Launcher& launcher, Ui& ui, bool& running) {
auto& pages = ui.game;
if (!pages.loaded) {
pages.loaded = true;
pages.error.clear();
try {
pages.saved = game::load(game::save_root());
} catch (const std::exception& failure) {
pages.saved = {};
pages.error = failure.what();
}
pages.values = pages.saved.values;
pages.resolutions = game::display_resolutions();
}
if (pages.saved.folders.empty()) {
setting_note(pages.error.empty() ? "skate. has not saved its settings on this PC yet. Start the game once and they "
"appear here." : pages.error.c_str());
return false;
}
running = launcher.game() != 0 || launcher.busy();
setting_note("skate.'s own settings, the ones in its Settings menu. The game reads them when it starts.");
if (running) setting_note("Skate is running: close it to change these here.");
if (!pages.error.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color::danger));
ImGui::PushTextWrapPos(0);
ImGui::TextUnformatted(pages.error.c_str());
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
ImGui::Spacing();
}
return true;
}
// Stores what the pages show once it differs from the game's save and no control is held (a
// slider being dragged writes when it is let go).
void store_game_settings(Ui& ui) {
auto& pages = ui.game;
if (!pages.loaded || pages.saved.folders.empty() || ImGui::IsAnyItemActive()) return;
game::Values changes;
for (const auto& [key, value] : pages.values) {
const auto before = pages.saved.values.find(key);
if (before != pages.saved.values.end() && before->second != value) changes.emplace(key, value);
}
if (changes.empty()) return;
try {
// (...\ReSkate\Game\Skate\data -> ...\ReSkate\game-settings-backup)
game::save(pages.saved, changes, game::save_root().parent_path().parent_path().parent_path() / L"game-settings-backup");
pages.saved.values = pages.values;
pages.error.clear();
} catch (const std::exception& failure) {
pages.error = std::string("Not saved: ") + failure.what() + ".";
pages.values = pages.saved.values;
}
}
// One of the game's settings pages: each setting a label and its control, under its section.
void game_settings_page(const Fonts& fonts, Ui& ui, std::string_view page, bool running) {
auto& pages = ui.game;
const char* section = "";
ImGui::BeginDisabled(running);
for (const auto& item : game::items()) {
if (page != item.page) continue;
// A setting this game version does not save is not offered.
const auto found = pages.values.find(item.key);
if (found == pages.values.end()) continue;
if (std::string_view(section) != item.section) {
if (*section) ImGui::Spacing(), ImGui::Spacing();
section = item.section;
section_caption(fonts, section);
}
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(item.label);
ImGui::SameLine(S(game_control_x));
ImGui::PushID(item.key);
ImGui::SetNextItemWidth(S(game_control_width));
const auto steps = [&](float value) {
const float snapped = item.least + std::round((value - item.least) / item.step) * item.step;
return std::clamp(snapped, item.least, item.most);
};
switch (item.kind) {
case game::Kind::toggle:
if (auto* on = std::get_if<bool>(&found->second)) toggle("##value", on);
break;
case game::Kind::whole:
if (auto* number = std::get_if<std::int32_t>(&found->second)) {
int value = *number;
if (ImGui::SliderInt("##value", &value, static_cast<int>(item.least), static_cast<int>(item.most), "%d",
ImGuiSliderFlags_AlwaysClamp))
*number = static_cast<std::int32_t>(steps(static_cast<float>(value)));
}
break;
case game::Kind::real:
if (auto* number = std::get_if<float>(&found->second)) {
float value = *number;
if (ImGui::SliderFloat("##value", &value, item.least, item.most,
item.step >= 1 ? "%.0f" : item.step >= 0.1f ? "%.1f" : "%.2f", ImGuiSliderFlags_AlwaysClamp))
*number = steps(value);
}
break;
case game::Kind::choice:
if (auto* number = std::get_if<std::int32_t>(&found->second)) {
// A value the launcher has no name for shows as its number.
std::string preview = std::to_string(*number);
for (const auto& choice : item.choices)
if (choice.value == *number) preview = choice.label;
if (ImGui::BeginCombo("##value", preview.c_str())) {
for (const auto& choice : item.choices)
if (ImGui::Selectable(choice.label, choice.value == *number)) *number = choice.value;
ImGui::EndCombo();
}
}
break;
case game::Kind::resolution:
// The game saves a place in this PC's list of display sizes.
if (auto* number = std::get_if<std::int32_t>(&found->second)) {
const auto name = [&](int index) {
if (index < 0 || index >= static_cast<int>(pages.resolutions.size())) return std::format("Mode {}", index);
const auto [width, height] = pages.resolutions[static_cast<std::size_t>(index)];
return std::format("{} x {}", width, height);
};
if (ImGui::BeginCombo("##value", name(*number).c_str())) {
for (int index = 0; index < static_cast<int>(pages.resolutions.size()); ++index)
if (ImGui::Selectable(name(index).c_str(), index == *number)) *number = index;
ImGui::EndCombo();
}
}
break;
}
ImGui::PopID();
}
ImGui::EndDisabled();
}
// Settings > GRAPHICS, under the display settings: the game's graphics options, which it
// saves together as one text.
void graphics_options(const Fonts& fonts, Ui& ui, bool running) {
auto& pages = ui.game;
const auto text = pages.values.find(game::options_key);
if (text == pages.values.end() || !std::holds_alternative<std::string>(text->second)) return;
game::Options options;
try {
options = game::parse_options(std::get<std::string>(text->second));
} catch (const std::exception&) {
return;
}
const auto value_of = [&](std::string_view name) -> std::string* {
for (auto& option : options)
if (option.name == name) return &option.value;
return nullptr;
};
bool changed = false;
ImGui::BeginDisabled(running);
ImGui::Spacing();
ImGui::Spacing();
section_caption(fonts, "SET EVERY QUALITY OPTION TO");
static constexpr std::array<const char*, 4> levels{"Low", "Medium", "High", "Ultra"};
for (int level = 0; level < static_cast<int>(levels.size()); ++level) {
if (level) ImGui::SameLine();
if (!ImGui::Button(levels[static_cast<std::size_t>(level)], ImVec2(S(110), 0))) continue;
for (const auto& setting : game::graphics_options())
if (const auto* wanted = game::level_value(setting, level))
if (auto* value = value_of(setting.name)) *value = wanted, changed = true;
}
const char* category = "";
for (const auto& setting : game::graphics_options()) {
// An option this game version does not save is not offered.
auto* value = value_of(setting.name);
if (!value) continue;
if (std::string_view(category) != setting.category) {
category = setting.category;
ImGui::Spacing();
ImGui::Spacing();
section_caption(fonts, category);
}
const auto* needed = *setting.needs_name ? value_of(setting.needs_name) : nullptr;
ImGui::BeginDisabled(*setting.needs_name && (!needed || *needed != setting.needs_value));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(setting.label);
ImGui::SameLine(S(game_control_x));
ImGui::SetNextItemWidth(S(game_control_width));
// A value the launcher has no name for (set in game, or by a newer game) shows as it is.
const char* preview = value->c_str();
for (const auto& choice : setting.choices)
if (*value == choice.value) preview = choice.label;
ImGui::PushID(setting.name);
if (ImGui::BeginCombo("##value", preview)) {
for (const auto& choice : setting.choices)
if (ImGui::Selectable(choice.label, *value == choice.value) && *value != choice.value)
*value = choice.value, changed = true;
ImGui::EndCombo();
}
ImGui::PopID();
ImGui::EndDisabled();
}
ImGui::EndDisabled();
if (!changed) return;
// The game's preset no longer describes them.
if (auto* preset = value_of(game::preset_name)) *preset = game::preset_custom;
try {
text->second = game::join_options(options);
} catch (const std::exception&) {}
}
// Settings > KEYS: the keys that open ReSkate's menu and console in game.
// Returns true when a key changed, so the caller can save straight away.
bool key_bindings(const Fonts& fonts, Settings& settings, Ui& ui) {
bool changed = false;
if (ui.binding && !g_capturing_key) {
const auto key = g_captured_key.exchange(0);
int& target = ui.binding == 1 ? settings.menu_key : settings.console_key;
const int other = ui.binding == 1 ? settings.console_key : settings.menu_key;
if (key == VK_ESCAPE) ui.key_error.clear();
else if (!launcher::bindable_key(key))
ui.key_error = launcher::key_name(key) + " can't be used. Pick another key.";
else if (static_cast<int>(key) == other)
ui.key_error = launcher::key_name(key) + " already opens the " + (ui.binding == 1 ? "console." : "menu.");
else { changed = target != static_cast<int>(key); target = static_cast<int>(key); ui.key_error.clear(); }
ui.binding = 0;
}
section_caption(fonts, "IN-GAME KEYS");
setting_note("Click a key, then press the one you want. Esc cancels.");
const auto row = [&](int which, const char* label, const char* detail, int key) {
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(label);
ImGui::SameLine(S(200));
const bool waiting = ui.binding == which;
const auto text = (waiting ? std::string("Press a key...")
: launcher::key_name(static_cast<unsigned>(key))) + "##key" + std::to_string(which);
if (waiting) push_primary_button();
if (ImGui::Button(text.c_str(), ImVec2(-1, 0)) && !waiting) {
ui.binding = which;
ui.key_error.clear();
g_captured_key = 0;
g_capturing_key = true;
}
if (waiting) pop_primary_button();
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + S(200));
ImGui::TextDisabled("%s", detail);
ImGui::Spacing();
};
row(1, "ReSkate menu", "Opens and closes the ReSkate menu.", settings.menu_key);
row(2, "Console", "Opens and closes the command console.", settings.console_key);
if (!ui.key_error.empty())
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger), "%s", ui.key_error.c_str());
ImGui::Spacing();
const bool defaults = settings.menu_key == static_cast<int>(launcher::default_menu_key) &&
settings.console_key == static_cast<int>(launcher::default_console_key);
ImGui::BeginDisabled(defaults || ui.binding);
if (ImGui::Button("Reset to defaults")) {
settings.menu_key = static_cast<int>(launcher::default_menu_key);
settings.console_key = static_cast<int>(launcher::default_console_key);
ui.key_error.clear();
changed = true;
}
ImGui::EndDisabled();
ImGui::Spacing();
setting_note("The chosen keys stop reaching the game while it runs, so avoid keys you skate with.");
return changed;
}
} // namespace
void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, HWND window) {
bool& open = ui.settings;
// A page, like the mod manager: the same rail, the same way out.
const auto frame = begin_page("##settings_panel", size);
auto* draw = ImGui::GetWindowDrawList();
auto& settings = launcher.settings();
const auto& paths = launcher.session().paths;
const bool busy = launcher.busy();
const bool binding = ui.binding != 0;
window_buttons(draw, window, frame);
const float rail_x = S(28), rail_width = S(236);
const float content_x = rail_x + rail_width + S(26);
const float top = S(52), bottom = frame.y - S(24);
const float title_size = S(44);
page_title(draw, fonts, ImVec2(rail_x + S(2), top - S(4)), "SETTINGS", title_size);
// ------------------------------------------------ rail
const float rail_top = top + title_size + S(20);
ImGui::SetCursorPos(ImVec2(rail_x, rail_top));
// Flattened, so a controller's D-pad crosses from the rail into the page and back.
ImGui::BeginChild("##settings_rail", ImVec2(rail_width, bottom - rail_top), ImGuiChildFlags_NavFlattened);
// The launcher's own pages, then the game's (game_settings.h).
static constexpr std::array<const char*, 9> names{"GAME", "DISPLAY", "KEYS", "ADVANCED",
"GRAPHICS", "AUDIO", "CAMERA", "CONTROLS", "REPLAY"};
constexpr int game_pages = 4;
ImGui::BeginDisabled(binding);
for (int i = 0; i < static_cast<int>(names.size()); ++i) {
if (i == game_pages) {
ImGui::Dummy(ImVec2(0, S(4)));
section_caption(fonts, "SKATE. SETTINGS");
}
if (nav_tile(fonts, rail_width, names[static_cast<std::size_t>(i)], ui.settings_tab == i))
ui.settings_tab = i;
// A controller starts on the open tab's tile.
if (ui.settings_tab == i) default_focus();
}
ImGui::SetCursorPosY(ImGui::GetWindowHeight() - S(38));
push_primary_button();
if (ImGui::Button("\xe2\x86\x90 BACK", ImVec2(-1, S(34)))) open = false;
pop_primary_button();
ImGui::EndDisabled();
ImGui::EndChild();
// ------------------------------------------------ the open tab
// Kept to a readable column: settings are sentences, not a table.
ImGui::SetCursorPos(ImVec2(content_x, top));
ImGui::BeginChild("##settings_content",
ImVec2(std::min(S(760), frame.x - content_x - S(28)), bottom - top), ImGuiChildFlags_NavFlattened);
const float footer = ImGui::GetTextLineHeight() + S(16);
ImGui::BeginChild("##settings_page", ImVec2(0, ImGui::GetWindowHeight() - footer), ImGuiChildFlags_NavFlattened);
switch (ui.settings_tab) {
case 0: {
section_caption(fonts, "GAME FOLDER");
ImGui::PushTextWrapPos(0);
ImGui::TextUnformatted(utf8(paths.directory.wstring()).c_str());
ImGui::PopTextWrapPos();
if (ImGui::Button("Open folder")) open_path(paths.directory);
ImGui::SameLine();
ImGui::BeginDisabled(busy);
if (ImGui::Button("Verify game files")) { open = false; open_sign_in(launcher, ui, true); }
ImGui::EndDisabled();
ImGui::Spacing();
ImGui::Spacing();
section_caption(fonts, "PLAY");
setting_check("Offline mode", "Play without Steam as Unknown Player; multiplayer is hidden. "
"Used automatically whenever Steam isn't running.", settings.offline);
setting_check("Loose files", "Export the game's scripts/ and config/ beside Skate.exe so you can edit them.",
settings.loose_files);
setting_check("Discord status", "Show on your Discord profile where you are skating: the map, the server or lobby "
"and how many are in it. Off, the game does not talk to Discord at all.", settings.discord_status);
ImGui::Spacing();
section_caption(fonts, "EXTRA GAME ARGUMENTS");
std::array<char, 1024> arguments{};
std::copy_n(settings.arguments.begin(), std::min(settings.arguments.size(), arguments.size() - 1), arguments.begin());
ImGui::SetNextItemWidth(-1);
if (ImGui::InputTextWithHint("##arguments", "Arguments for Skate.exe", arguments.data(), arguments.size()))
settings.arguments = arguments.data();
setting_note("Passed to Skate.exe as they are.");
break;
}
case 1:
section_caption(fonts, "WINDOW");
setting_check("Windowed", "Start in a window of this size instead of the game's saved display settings.",
settings.windowed);
ImGui::BeginDisabled(!settings.windowed);
ImGui::Indent(ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.x);
ImGui::SetNextItemWidth(S(110));
ImGui::InputInt("##width", &settings.width, 0);
ImGui::SameLine();
ImGui::TextDisabled("x");
ImGui::SameLine();
ImGui::SetNextItemWidth(S(110));
ImGui::InputInt("##height", &settings.height, 0);
ImGui::Unindent(ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.x);
ImGui::EndDisabled();
settings.width = std::clamp(settings.width, 320, 16384);
settings.height = std::clamp(settings.height, 200, 16384);
break;
case 2:
if (key_bindings(fonts, settings, ui)) launcher.save();
break;
default: {
bool running{};
if (!game_settings_ready(launcher, ui, running)) break;
const std::string_view page = names[static_cast<std::size_t>(std::clamp(ui.settings_tab, game_pages, static_cast<int>(names.size()) - 1))];
game_settings_page(fonts, ui, page, running);
if (page == "GRAPHICS") graphics_options(fonts, ui, running);
break;
}
case 3:
section_caption(fonts, "LAUNCHER");
setting_check("Keep the launcher open after launch",
"Hide while you play and return when Skate closes. Turn off to close after a successful launch.",
settings.keep_open_after_launch);
setting_check("Install ReSkate updates",
"Turn off to keep the ReSkate.dll and launcher you have, such as a test build someone gave you. "
"Skate's own Steam updates are not affected.",
settings.updates);
setting_check("Test pre-releases",
"Update to the newest pre-release on GitHub when there is one, to try what is coming before it "
"is released. Pre-releases are less tested. Turn off to go back to the latest release. "
"The launcher checks again as soon as these settings are closed.",
settings.prereleases);
ImGui::Spacing();
section_caption(fonts, "TROUBLESHOOTING");
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted("Log level");
ImGui::SameLine(S(200));
ImGui::SetNextItemWidth(S(160));
ImGui::Combo("##log_level", &settings.log_level, log_levels.data(), static_cast<int>(log_levels.size()));
ImGui::SameLine();
if (ImGui::Button("Open logs")) open_path(paths.logs);
setting_note("How much ReSkate.log records. Use debug or trace when reporting a problem.");
ImGui::Spacing();
setting_check("GPU crash diagnostics (DRED)", "Records extra detail when the graphics driver crashes. Slightly slower.",
settings.gpu_diagnostics);
setting_check("Send crash reports",
"When the launcher or Skate crashes, upload a crash dump and that session's log so the ReSkate "
"developers can fix it. The launcher picks a change up when it next starts.",
settings.crash_reports);
setting_check("Send performance timings",
"Send how long downloads, mod installs, the mod merge, level loads and joining a "
"session took, and each level's average frame rate, so slow spots can be found and fixed. Nothing "
"about your account is included. Needs crash reports on; the launcher picks a change up when it "
"next starts.",
settings.performance_timings);
break;
}
ImGui::EndChild();
store_game_settings(ui);
ImGui::SetCursorPosY(ImGui::GetWindowHeight() - footer + S(8));
ImGui::PushFont(fonts.caption);
ImGui::TextDisabled("%s", !update::binary_updates_enabled() ? "Development build: ReSkate files are never replaced."
: settings.updates ? "Release build: updates install automatically."
: "Updates are off: ReSkate files are never replaced.");
ImGui::PopFont();
ImGui::EndChild();
if (!binding && !ImGui::IsAnyItemActive() && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) open = false;
g_drag_allowed = !ImGui::IsAnyItemHovered() && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId);
ImGui::End();
if (!open) {
launcher.save();
launcher.check_again_if_update_choices_changed();
// Read again the next time: the game may have run in between.
ui.game.loaded = false;
}
}
} // namespace dingosdk::launcher_gui::detail