Files
smack-a-brother/src/settings.cpp
T
shweetandClaude Opus 5.5 9a41474069 Tomatoes, match tabs, shared pings, and a round of fixes
- On the results, everyone but the winner splats tomatoes where they
  click (bots aim at the winner), with a new splat sound.
- During a match the menu has the item tabs (choosing when the
  countdown allows, and otherwise levelling the sandbox's levels for
  when you're back), Play (the game's rules) and Settings.
- Pings are shared through the server (protocol 4), so other players'
  connection bars show over their heads and on their windows.
- PvP comes into lobbies with your profile and is saved.
- The choice menu opens by itself however the countdown began.
- Knocked-out fighters keep their buttons current, so Ready on the
  results toggles once per press.
- The weapon wheel wraps both ways. Readying with empty slots plays
  the equip sound for your own fighter.
- Shock Absorber: up to 50% less weapon movement and 33% less
  knockback. Only the catalog previews the next level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:11:06 -05:00

689 lines
29 KiB
C++

#include "settings.h"
#include "game.h"
#include <SDL3/SDL.h>
#include <algorithm>
#include <string>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif
constexpr int SETTINGS_LENGTH = 16384;
constexpr int LINE_LENGTH = 64;
constexpr float BINDING_WIDTH = 116;
constexpr float FIELD_WIDTH = 120;
constexpr float SWATCH_GAP = 4;
constexpr float SWATCH_SIZE = 18;
constexpr int SWATCH_COLUMNS = 8;
constexpr float LABEL_WIDTH = 104;
constexpr float NUMBER_WIDTH = 84;
constexpr float PERCENT = 100;
constexpr float ROW_HEIGHT = 18;
constexpr float SCROLL_GAP = 8;
constexpr float SLIDER_WIDTH = 120;
constexpr float TOGGLE_SPACING = 96;
constexpr float VOLUME_STEP = 0.05f;
constexpr const char* DEFAULT_NAME = "Fighter";
constexpr const char* KEY_BIND = "bind_";
constexpr const char* KEY_COLLISION = "collision";
constexpr const char* KEY_COLOR = "color";
constexpr const char* KEY_FULLSCREEN = "fullscreen";
constexpr const char* KEY_MUTE = "mute";
constexpr const char* KEY_NAME = "name";
constexpr const char* KEY_TRINKET_SLOTS = "trinket_slots";
constexpr const char* KEY_VOLUME = "volume";
constexpr const char* KEY_SHAKE = "screenshake";
constexpr const char* KEY_HUD_AIM = "hud_aim";
constexpr const char* KEY_SERVER_URL = "server_url";
constexpr const char* KEY_RULES_OPEN = "rules_open";
constexpr float FOOTER_GAP = 4;
constexpr const char* KEY_HUD_DPS = "hud_dps";
constexpr const char* KEY_HUD_SPEED = "hud_speed";
constexpr const char* KEY_VSYNC = "vsync";
constexpr const char* KEY_WEAPON_SLOTS = "weapon_slots";
constexpr const char* SETTINGS_FILE = "settings.txt";
constexpr const char* KEY_SANDBOX_PVP = "sandbox_pvp";
constexpr const char* KEY_SANDBOX_SLOT = "sandbox_slot";
constexpr const char* KEY_SANDBOX_TRINKET_LEVELS = "sandbox_trinket_levels";
constexpr const char* KEY_SANDBOX_TRINKETS = "sandbox_trinkets";
constexpr const char* KEY_SANDBOX_WEAPON_LEVELS = "sandbox_weapon_levels";
constexpr const char* KEY_SANDBOX_WEAPONS = "sandbox_weapons";
constexpr const char* KEY_SANDBOX_X = "sandbox_x";
constexpr const char* KEY_SANDBOX_Y = "sandbox_y";
constexpr const char* SANDBOX_START_WEAPON = "Sword";
constexpr float CLEAR_PROBE = 4;
constexpr Uint64 WEB_SAVE_INTERVAL = 5000;
static std::string file_path_get()
{
char* folder = SDL_GetPrefPath("", GAME_ID);
std::string path = std::string(folder ? folder : "") + SETTINGS_FILE;
SDL_free(folder);
return path;
}
// The rules' keys: one per field, item rules prefixed with their kind.
struct RuleField
{
const char* key;
int Rules::* integer = nullptr;
float Rules::* number = nullptr;
bool Rules::* flag = nullptr;
};
constexpr RuleField RULE_FIELDS[] = {
{"rules_mode", &Rules::mode},
{"rules_players", &Rules::players},
{"rules_timer", &Rules::timer},
{"rules_health", nullptr, &Rules::health},
{"rules_health_multiplier", nullptr, &Rules::healthMultiplier},
{"rules_bot_fill", nullptr, nullptr, &Rules::isBotFill},
{"rules_best_of", &Rules::bestOf},
};
struct ItemField
{
const char* key;
int ItemRules::* integer = nullptr;
bool ItemRules::* flag = nullptr;
};
constexpr ItemField ITEM_FIELDS[] = {
{"period", &ItemRules::period},
{"slots", &ItemRules::slots},
{"start_level", &ItemRules::startLevel},
{"timing", &ItemRules::timing},
{"choice", nullptr, &ItemRules::isChoice},
{"duplicates", nullptr, &ItemRules::isDuplicates},
{"level_up", nullptr, &ItemRules::isLevelUp},
{"mirror", nullptr, &ItemRules::isMirror},
};
constexpr const char* ITEM_PREFIXES[] = {"weapons_", "trinkets_"};
// Sets whichever rule a line names, kept in range.
static void rules_read(Rules& rules, const char* key, const char* value)
{
for (const RuleField& field : RULE_FIELDS)
{
if (SDL_strcmp(key, field.key) != 0)
continue;
if (field.integer)
rules.*field.integer = SDL_atoi(value);
if (field.number)
rules.*field.number = SDL_atof(value);
if (field.flag)
rules.*field.flag = SDL_atoi(value);
}
for (int kind = 0; kind < 2; kind++)
{
ItemRules& items = kind == 0 ? rules.weapons : rules.trinkets;
int prefix = SDL_strlen(ITEM_PREFIXES[kind]);
if (SDL_strncmp(key, ITEM_PREFIXES[kind], prefix) != 0)
continue;
for (const ItemField& field : ITEM_FIELDS)
{
if (SDL_strcmp(key + prefix, field.key) != 0)
continue;
if (field.integer)
items.*field.integer = SDL_atoi(value);
if (field.flag)
items.*field.flag = SDL_atoi(value);
}
items.slots = std::clamp(items.slots, 1, SLOT_MAX);
items.startLevel = std::clamp(items.startLevel, 1, LEVEL_COUNT);
items.timing = std::clamp(items.timing, 0, (int)PICK_PERIOD);
}
// Races are set aside while deathmatch gets online (their mode choice is commented out in the menu's rules), so a
// saved race comes back as a deathmatch.
// rules.mode = std::clamp(rules.mode, (int)MODE_DEATHMATCH, (int)MODE_RACE);
rules.mode = MODE_DEATHMATCH;
rules.players = std::clamp(rules.players, PLAYERS_MIN, PLAYERS_MAX);
rules.bestOf = std::clamp(rules.bestOf, 1, BEST_OF_MAX) | 1;
}
static int rules_write(Rules& rules, char* text, int size)
{
int length = 0;
for (const RuleField& field : RULE_FIELDS)
if (field.number)
length += SDL_snprintf(text + length, size - length, "%s=%g\n", field.key, rules.*field.number);
else
length += SDL_snprintf(text + length, size - length, "%s=%d\n", field.key,
field.integer ? rules.*field.integer : (int)(rules.*field.flag));
for (int kind = 0; kind < 2; kind++)
{
ItemRules& items = kind == 0 ? rules.weapons : rules.trinkets;
for (const ItemField& field : ITEM_FIELDS)
length += SDL_snprintf(text + length, size - length, "%s%s=%d\n", ITEM_PREFIXES[kind], field.key,
field.integer ? items.*field.integer : (int)(items.*field.flag));
}
return length;
}
// An item's number from its name, or -1. Items are saved by name so reordering them never scrambles a save.
static int item_find(bool isTrinket, const char* name)
{
int count = isTrinket ? TRINKET_COUNT : WEAPON_COUNT;
for (int i = 0; i < count; i++)
if (SDL_strcmp(name, isTrinket ? TRINKET_DEFS[i].name : WEAPON_DEFS[i].name) == 0)
return i;
return -1;
}
// Reads a comma-separated list: a loadout of item names (empty for an empty slot), each with its copy's level as
// "name:level" (into levels by slot), or item levels as "name:level" (into levels by item).
static void list_read(char* value, bool isTrinket, int* loadout, int* levels)
{
int count = loadout ? SLOT_MAX : (isTrinket ? TRINKET_COUNT : WEAPON_COUNT);
char* entry = value;
for (int index = 0; entry && index < count; index++)
{
char* rest = SDL_strchr(entry, ',');
if (rest)
*rest++ = '\0';
char* colon = SDL_strchr(entry, ':');
if (colon)
*colon++ = '\0';
int item = item_find(isTrinket, entry);
if (loadout)
loadout[index] = item;
if (levels && colon && item >= 0)
levels[loadout ? index : item] = std::clamp(SDL_atoi(colon) - 1, 0, LEVEL_COUNT - 1);
entry = rest;
}
}
static void sandbox_read(SandboxSave& sandbox, const char* key, char* value)
{
bool isSandbox = true;
if (SDL_strcmp(key, KEY_SANDBOX_X) == 0)
sandbox.feet.x = SDL_atof(value);
else if (SDL_strcmp(key, KEY_SANDBOX_Y) == 0)
sandbox.feet.y = SDL_atof(value);
else if (SDL_strcmp(key, KEY_SANDBOX_SLOT) == 0)
sandbox.slot = SDL_atoi(value);
else if (SDL_strcmp(key, KEY_SANDBOX_PVP) == 0)
sandbox.isPvp = SDL_atoi(value) != 0;
else if (SDL_strcmp(key, KEY_SANDBOX_WEAPONS) == 0)
list_read(value, false, sandbox.weaponLoadout, sandbox.weaponSlotLevels);
else if (SDL_strcmp(key, KEY_SANDBOX_TRINKETS) == 0)
list_read(value, true, sandbox.trinketLoadout, sandbox.trinketSlotLevels);
else if (SDL_strcmp(key, KEY_SANDBOX_WEAPON_LEVELS) == 0)
list_read(value, false, nullptr, sandbox.weaponLevels);
else if (SDL_strcmp(key, KEY_SANDBOX_TRINKET_LEVELS) == 0)
list_read(value, true, nullptr, sandbox.trinketLevels);
else
isSandbox = false;
sandbox.isSaved |= isSandbox;
}
static int sandbox_write(Fighter& fighter, char* text, int size)
{
int length = SDL_snprintf(text, size, "%s=%g\n%s=%g\n%s=%d\n%s=%d\n", KEY_SANDBOX_X, fighter.center_get().x,
KEY_SANDBOX_Y, fighter.body.box.y + fighter.body.box.h, KEY_SANDBOX_SLOT, fighter.slot,
KEY_SANDBOX_PVP, (int)fighter.isPvp);
for (int kind = 0; kind < 2; kind++)
{
bool isTrinket = kind == 1;
int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout;
int* slotLevels = isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels;
length +=
SDL_snprintf(text + length, size - length, "%s=", isTrinket ? KEY_SANDBOX_TRINKETS : KEY_SANDBOX_WEAPONS);
for (int slot = 0; slot < SLOT_MAX; slot++)
{
int item = loadout[slot];
length += slot > 0 ? SDL_snprintf(text + length, size - length, ",") : 0;
if (item >= 0)
length +=
SDL_snprintf(text + length, size - length, "%s:%d",
isTrinket ? TRINKET_DEFS[item].name : WEAPON_DEFS[item].name, slotLevels[slot] + 1);
}
length += SDL_snprintf(text + length, size - length,
"\n%s=", isTrinket ? KEY_SANDBOX_TRINKET_LEVELS : KEY_SANDBOX_WEAPON_LEVELS);
int count = isTrinket ? TRINKET_COUNT : WEAPON_COUNT;
for (int item = 0; item < count; item++)
length += SDL_snprintf(text + length, size - length, item > 0 ? ",%s:%d" : "%s:%d",
isTrinket ? TRINKET_DEFS[item].name : WEAPON_DEFS[item].name,
(isTrinket ? fighter.trinketItemLevels : fighter.weaponItemLevels)[item] + 1);
length += SDL_snprintf(text + length, size - length, "\n");
}
return length;
}
Settings::Settings()
{
SDL_strlcpy(serverUrl, SERVER_URL_DEFAULT, sizeof(serverUrl));
}
// Reads the saved settings, keeping the defaults for anything missing. Bindings are "bind_<control>=key,pad".
void Settings::load(Game& game)
{
std::fill(std::begin(sandbox.weaponLoadout), std::end(sandbox.weaponLoadout), -1);
std::fill(std::begin(sandbox.trinketLoadout), std::end(sandbox.trinketLoadout), -1);
std::fill(std::begin(sandbox.weaponSlotLevels), std::end(sandbox.weaponSlotLevels), -1);
std::fill(std::begin(sandbox.trinketSlotLevels), std::end(sandbox.trinketSlotLevels), -1);
savedRules = game.sim.match.rules;
char* text = (char*)SDL_LoadFile(file_path_get().c_str(), nullptr);
if (!text)
return;
char* rest = nullptr;
for (char* line = SDL_strtok_r(text, "\r\n", &rest); line; line = SDL_strtok_r(nullptr, "\r\n", &rest))
{
char* value = SDL_strchr(line, '=');
if (!value)
continue;
*value++ = '\0';
int number = SDL_atoi(value);
if (SDL_strcmp(line, KEY_NAME) == 0 && *value)
SDL_strlcpy(name, value, sizeof(name));
if (SDL_strcmp(line, KEY_SERVER_URL) == 0 && *value)
SDL_strlcpy(serverUrl, value, sizeof(serverUrl));
if (SDL_strcmp(line, KEY_RULES_OPEN) == 0)
isRulesOpen = number;
if (SDL_strcmp(line, KEY_VOLUME) == 0)
volume = std::clamp(number / PERCENT, 0.0f, 1.0f);
if (SDL_strcmp(line, KEY_MUTE) == 0)
isMuted = number;
if (SDL_strcmp(line, KEY_FULLSCREEN) == 0)
isFullscreen = number;
if (SDL_strcmp(line, KEY_VSYNC) == 0)
isVsync = number;
if (SDL_strcmp(line, KEY_SHAKE) == 0)
isShaking = number;
if (SDL_strcmp(line, KEY_HUD_DPS) == 0)
isDpsShown = number;
if (SDL_strcmp(line, KEY_HUD_SPEED) == 0)
isSpeedShown = number;
if (SDL_strcmp(line, KEY_HUD_AIM) == 0)
isAimShown = number;
if (SDL_strcmp(line, KEY_COLLISION) == 0)
isCollisionShown = number;
if (SDL_strcmp(line, KEY_COLOR) == 0)
color = std::clamp(number, COLOR_UNASSIGNED, COLOR_COUNT - 1);
if (SDL_strcmp(line, KEY_WEAPON_SLOTS) == 0)
weaponSlots = std::clamp(number, 1, SLOT_MAX);
if (SDL_strcmp(line, KEY_TRINKET_SLOTS) == 0)
trinketSlots = std::clamp(number, 1, SLOT_MAX);
rules_read(game.sim.match.rules, line, value);
sandbox_read(sandbox, line, value);
for (int i = 0; i < CONTROL_COUNT; i++)
{
char key[LINE_LENGTH];
SDL_snprintf(key, sizeof(key), "%s%s", KEY_BIND, CONTROL_KEYS[i]);
const char* comma = SDL_strchr(value, ',');
if (SDL_strcmp(line, key) == 0 && comma)
game.input.bindings[i] = {number, SDL_atoi(comma + 1)};
}
}
SDL_free(text);
savedRules = game.sim.match.rules;
}
void Settings::save(Game& game)
{
char text[SETTINGS_LENGTH];
int length = SDL_snprintf(text, sizeof(text),
"%s=%s\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%"
"d\n%s=%s\n%s=%d\n",
KEY_NAME, name, KEY_COLOR, color, KEY_VOLUME, (int)std::round(volume * PERCENT), KEY_MUTE,
isMuted, KEY_FULLSCREEN, isFullscreen, KEY_VSYNC, isVsync, KEY_WEAPON_SLOTS, weaponSlots,
KEY_TRINKET_SLOTS, trinketSlots, KEY_COLLISION, isCollisionShown, KEY_SHAKE, isShaking,
KEY_HUD_DPS, isDpsShown, KEY_HUD_SPEED, isSpeedShown, KEY_HUD_AIM, isAimShown,
KEY_SERVER_URL, serverUrl, KEY_RULES_OPEN, isRulesOpen);
for (int i = 0; i < CONTROL_COUNT; i++)
{
Binding binding = game.input.bindings[i];
length += SDL_snprintf(text + length, sizeof(text) - length, "%s%s=%d,%d\n", KEY_BIND, CONTROL_KEYS[i],
binding.key, binding.pad);
}
Match& match = game.sim.match;
length += rules_write(match.rules, text + length, sizeof(text) - length);
length += sandbox_write(match.state == MATCH_PRACTICE ? game.local_get() : match.sandbox, text + length,
sizeof(text) - length);
savedRules = match.rules;
SDL_SaveFile(file_path_get().c_str(), text, length);
#ifdef __EMSCRIPTEN__
// The prefs folder is browser storage (see tools/web_pre.js), written back after every save.
EM_ASM(FS.syncfs(false, function(error){}););
#endif
isDirty = false;
}
void Settings::apply(Game& game)
{
fullscreen_apply(game);
game.renderer.vsync_set(isVsync);
game.audio.gain_set(isMuted ? 0 : volume);
if (game.sim.match.state == MATCH_PRACTICE)
{
Fighter& fighter = game.local_get();
fighter.slots_set(weaponSlots, trinketSlots);
}
}
// Puts the window in or out of fullscreen to match the setting. The web asks the browser to fill the screen instead,
// since SDL's own fullscreen there snaps the canvas into a corner.
void Settings::fullscreen_apply(Game& game)
{
#ifdef __EMSCRIPTEN__
EM_ASM(
{
if ($0 && !document.fullscreenElement)
document.documentElement.requestFullscreen().catch(function(error){});
if (!$0 && document.fullscreenElement)
document.exitFullscreen();
},
isFullscreen);
#else
SDL_SetWindowFullscreen(game.window, isFullscreen);
#endif
}
// Flips fullscreen from anywhere, as the F key does, and saves it.
void Settings::fullscreen_toggle(Game& game)
{
isFullscreen = !isFullscreen;
fullscreen_apply(game);
save(game);
}
// The Settings tab, scrolling: the sandbox's slot counts and collision display, the window, the audio (volume in 5%
// steps, then mute), and the controls. Changes take effect at once and are saved as soon as the mouse lets go.
void Settings::draw(Game& game, Rect area, Page& page)
{
Ui& ui = game.ui;
float x = area.x + LABEL_WIDTH;
float y = 0;
Rect row = {area.x, 0, area.w - SCROLL_GAP, WIDGET_HEIGHT};
// The Default button stays put along the bottom, under the scrolling settings.
Rect footer = {area.x, area.y + area.h - WIDGET_HEIGHT, area.w, WIDGET_HEIGHT};
if (ui.button_draw(game, footer, "Default"))
defaults_restore(game);
area.h -= WIDGET_HEIGHT + FOOTER_GAP;
ui.page_begin(game, page, area);
// The sandbox's slots only show in the sandbox.
bool isSandbox = false;
if (game.sim.match.state == MATCH_PRACTICE)
{
if (ui.row_get(page, ROW_HEIGHT, y))
ui.separator_draw(game, {row.x, y, row.w, row.h}, "Sandbox");
if (ui.row_get(page, ROW_HEIGHT, y))
{
ui.label_draw(game, "Weapon Slots", {area.x, y});
isSandbox |=
ui.number_draw(game, {x, y, NUMBER_WIDTH, WIDGET_HEIGHT}, weaponSlots, {.min = 1, .max = SLOT_MAX});
}
if (ui.row_get(page, ROW_HEIGHT, y))
{
ui.label_draw(game, "Trinket Slots", {area.x, y});
isSandbox |=
ui.number_draw(game, {x, y, NUMBER_WIDTH, WIDGET_HEIGHT}, trinketSlots, {.min = 1, .max = SLOT_MAX});
}
bool isPvp = game.local_get().isPvp;
bool isPvpChanged = false;
if (ui.row_get(page, ROW_HEIGHT, y))
isPvpChanged = ui.toggle_draw(game, {x, y}, "PvP", isPvp);
// The fighter changes through its controls, so every machine sees it on the same tick (see Edit).
int device = game.devices[game.local];
if (isSandbox)
game.input.edit_send(device,
edit_encode({.type = EDIT_SLOTS, .index = weaponSlots, .value = trinketSlots}));
if (isPvpChanged)
game.input.edit_send(device, edit_encode({.type = EDIT_PVP, .value = isPvp}));
}
if (ui.row_get(page, ROW_HEIGHT, y))
ui.separator_draw(game, {row.x, y, row.w, row.h}, "Window");
bool isFullscreenChanged = false;
bool isVsyncChanged = false;
if (ui.row_get(page, ROW_HEIGHT, y))
{
isFullscreenChanged = ui.toggle_draw(game, {x, y}, "Fullscreen", isFullscreen);
isVsyncChanged = ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Vsync", isVsync);
}
bool isShakeChanged = false;
if (ui.row_get(page, ROW_HEIGHT, y))
isShakeChanged = ui.toggle_draw(game, {x, y}, "Screenshake", isShaking);
if (isFullscreenChanged)
fullscreen_apply(game);
if (isVsyncChanged)
game.renderer.vsync_set(isVsync);
if (ui.row_get(page, ROW_HEIGHT, y))
ui.separator_draw(game, {row.x, y, row.w, row.h}, "HUD / Gameplay");
bool isHudChanged = false;
if (ui.row_get(page, ROW_HEIGHT, y))
{
isHudChanged |= ui.toggle_draw(game, {x, y}, "DPS Meter", isDpsShown);
isHudChanged |= ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Speed Meter", isSpeedShown);
}
if (ui.row_get(page, ROW_HEIGHT, y))
{
isHudChanged |= ui.toggle_draw(game, {x, y}, "Aim Preview", isAimShown);
isHudChanged |= ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Display Collision", isCollisionShown);
}
// How late this screen's own controls take effect online: more delay, fewer corrections to other players. A change
// takes effect the next time a lobby's game starts.
if (ui.row_get(page, ROW_HEIGHT, y))
ui.separator_draw(game, {row.x, y, row.w, row.h}, "Online");
if (ui.row_get(page, ROW_HEIGHT, y))
{
ui.label_draw(game, "Host", {area.x, y});
isHudChanged |= ui.field_draw(game, {x, y, row.w - LABEL_WIDTH, WIDGET_HEIGHT}, serverUrl, sizeof(serverUrl));
if (serverUrl[0] == '\0' && !ui.is_active(serverUrl))
SDL_strlcpy(serverUrl, SERVER_URL_DEFAULT, sizeof(serverUrl));
}
if (ui.row_get(page, ROW_HEIGHT, y))
ui.separator_draw(game, {row.x, y, row.w, row.h}, "Audio");
bool isSound = false;
if (ui.row_get(page, ROW_HEIGHT, y))
{
char percent[LINE_LENGTH];
SDL_snprintf(percent, sizeof(percent), "%d%%", (int)std::round(volume * PERCENT));
ui.label_draw(game, "Volume", {area.x, y});
isSound = ui.slider_draw(game, {x, y, SLIDER_WIDTH, WIDGET_HEIGHT}, volume, VOLUME_STEP);
ui.label_draw(game, percent, {x + SLIDER_WIDTH + WIDGET_GAP, y});
}
if (ui.row_get(page, ROW_HEIGHT, y))
isSound |= ui.toggle_draw(game, {x, y}, "Mute", isMuted);
if (isSound)
game.audio.gain_set(isMuted ? 0 : volume);
controls_draw(game, page, row);
ui.page_end(game, page);
isDirty |= isSandbox || isSound || isFullscreenChanged || isVsyncChanged || isShakeChanged || isHudChanged ||
game.input.isBindingChanged;
game.input.isBindingChanged = false;
if (isDirty && !game.input.is_mouse_down(MOUSE_LEFT))
save(game);
}
// The Controls section: a Default button, then a row per control with its keyboard and gamepad bindings. Clicking a
// binding listens for a new one, and right-clicking (or X on a focused one) clears it.
void Settings::controls_draw(Game& game, Page& page, Rect row)
{
Ui& ui = game.ui;
Input& input = game.input;
float y = 0;
float keyX = row.x + LABEL_WIDTH;
float padX = keyX + BINDING_WIDTH + WIDGET_GAP;
if (ui.row_get(page, ROW_HEIGHT, y))
ui.separator_draw(game, {row.x, y, row.w, row.h}, "Controls");
if (ui.row_get(page, ROW_HEIGHT, y))
{
if (ui.button_draw(game, {row.x, y, LABEL_WIDTH - WIDGET_GAP, WIDGET_HEIGHT}, "Default"))
{
input.bindings_reset();
input.isBindingChanged = true;
}
ui.label_draw(game, "Keyboard", {keyX, y});
ui.label_draw(game, "Gamepad", {padX, y});
}
for (int i = 0; i < CONTROL_COUNT; i++)
{
if (!ui.row_get(page, ROW_HEIGHT, y))
continue;
ui.label_draw(game, CONTROL_NAMES[i], {row.x, y});
for (int side = 0; side < 2; side++)
{
bool isGamepad = side == 1;
Rect cell = {isGamepad ? padX : keyX, y, BINDING_WIDTH, WIDGET_HEIGHT};
int& binding = isGamepad ? input.bindings[i].pad : input.bindings[i].key;
bool isCapturing = input.captureControl == i && input.isCaptureGamepad == isGamepad;
char name[LINE_LENGTH];
if (isCapturing)
SDL_strlcpy(name, "Press...", sizeof(name));
else
input.binding_name_get(binding, isGamepad, name, sizeof(name));
if (ui.button_draw(game, cell, name, &binding))
{
input.captureControl = i;
input.isCaptureGamepad = isGamepad;
}
bool isUnbound = ui.is_focused(&binding) && input.is_pad_pressed(DEVICE_KEYBOARD, SDL_GAMEPAD_BUTTON_WEST);
if (isUnbound || (is_point_in_rect(input.mouse, cell) && input.is_mouse_pressed(MOUSE_RIGHT)))
{
binding = -1;
input.isBindingChanged = true;
}
}
}
}
// The colour this screen's player asks for in a match: their pick, the session's random roll, or none.
int Settings::color_get()
{
return color;
}
// The Settings tab's Default: everything on it back as it starts out, except the controls (which have a Default of
// their own) and the name and colour (which are Customize's).
void Settings::defaults_restore(Game& game)
{
Settings fresh;
volume = fresh.volume;
isMuted = fresh.isMuted;
isFullscreen = fresh.isFullscreen;
isVsync = fresh.isVsync;
isShaking = fresh.isShaking;
isCollisionShown = fresh.isCollisionShown;
isDpsShown = fresh.isDpsShown;
isSpeedShown = fresh.isSpeedShown;
isAimShown = fresh.isAimShown;
SDL_strlcpy(serverUrl, fresh.serverUrl, sizeof(serverUrl));
weaponSlots = fresh.weaponSlots;
trinketSlots = fresh.trinketSlots;
fullscreen_apply(game);
game.renderer.vsync_set(isVsync);
game.audio.gain_set(isMuted ? 0 : volume);
if (game.sim.match.state == MATCH_PRACTICE)
game.input.edit_send(game.devices[game.local],
edit_encode({.type = EDIT_SLOTS, .index = weaponSlots, .value = trinketSlots}));
isDirty = true;
}
// The Customize tab: the screen name shown over this screen's fighter, and the colour it asks for in matches (a
// swatch, or Unassigned for whichever is free), with the fighter previewed on the right in it, walking. An emptied
// name goes back to the default.
void Settings::customize_draw(Game& game, Rect area)
{
Ui& ui = game.ui;
float x = area.x + LABEL_WIDTH;
float y = area.y;
ui.label_draw(game, "Name", {area.x, y});
isDirty |= ui.field_draw(game, {x, y, FIELD_WIDTH, WIDGET_HEIGHT}, name, sizeof(name));
y += ROW_HEIGHT;
ui.label_draw(game, "Color", {area.x, y});
isDirty |= ui.radio_draw(game, {x, y}, "Unassigned", color, COLOR_UNASSIGNED);
y += ROW_HEIGHT;
for (int i = 0; i < COLOR_COUNT; i++)
{
Rect box = {x + i % SWATCH_COLUMNS * (SWATCH_SIZE + SWATCH_GAP),
y + i / SWATCH_COLUMNS * (SWATCH_SIZE + SWATCH_GAP), SWATCH_SIZE, SWATCH_SIZE};
isDirty |= ui.swatch_draw(game, box, PALETTE[i], color, i);
}
// The preview: the fighter in the colour picked, its name over it and what the choice means under it.
char caption[LINE_LENGTH];
if (color == COLOR_UNASSIGNED)
SDL_strlcpy(caption, "Given at match start", sizeof(caption));
else
SDL_strlcpy(caption, COLOR_NAMES[color], sizeof(caption));
ui.fighter_preview_draw(game, area, name, color >= 0 ? PALETTE[color] : WHITE, caption);
if (name[0] == '\0' && !ui.is_active(name))
{
SDL_strlcpy(name, DEFAULT_NAME, sizeof(name));
isDirty = true;
}
if (isDirty && !game.input.is_mouse_down(MOUSE_LEFT) && !ui.is_active(name))
save(game);
}
// Puts the sandbox back as it was saved, once the world and fighter exist: loadouts, levels, the slot held, PvP, and
// where the fighter stood (unless that spot is now blocked). With nothing saved yet, the fighter starts with a sword in
// its first slot.
void Settings::restore(Game& game)
{
Fighter& fighter = game.local_get();
if (!sandbox.isSaved)
{
fighter.weaponLoadout[0] = item_find(false, SANDBOX_START_WEAPON);
return;
}
std::copy(std::begin(sandbox.weaponLoadout), std::end(sandbox.weaponLoadout), fighter.weaponLoadout);
std::copy(std::begin(sandbox.trinketLoadout), std::end(sandbox.trinketLoadout), fighter.trinketLoadout);
std::copy(std::begin(sandbox.weaponLevels), std::end(sandbox.weaponLevels), fighter.weaponItemLevels);
std::copy(std::begin(sandbox.trinketLevels), std::end(sandbox.trinketLevels), fighter.trinketItemLevels);
fighter.isPvp = sandbox.isPvp;
// Saves from before copies had their own levels give each copy its item's level.
for (int i = 0; i < SLOT_MAX; i++)
{
int weapon = fighter.weaponLoadout[i];
int trinket = fighter.trinketLoadout[i];
int weaponLevel = sandbox.weaponSlotLevels[i];
int trinketLevel = sandbox.trinketSlotLevels[i];
fighter.weaponSlotLevels[i] =
weaponLevel >= 0 || weapon < 0 ? std::max(0, weaponLevel) : fighter.weaponItemLevels[weapon];
fighter.trinketSlotLevels[i] =
trinketLevel >= 0 || trinket < 0 ? std::max(0, trinketLevel) : fighter.trinketItemLevels[trinket];
}
fighter.slots_set(weaponSlots, trinketSlots);
fighter.slot = std::clamp(sandbox.slot, 0, fighter.weaponSlots - 1);
// Stood a pixel up, so the feet don't start in the floor.
Rect box = fighter.body.box;
Vec2 centre = {sandbox.feet.x, sandbox.feet.y - box.h / 2 - 1};
for (float y = -box.h / 2; y <= box.h / 2; y += CLEAR_PROBE)
for (float x = -box.w / 2; x <= box.w / 2; x += CLEAR_PROBE)
if (game.sim.world.is_blocking_at({centre.x + x, centre.y + y}))
return;
fighter.teleport_to(centre);
}
// Saves once anything has changed and the mouse has let go: rules as soon as they're edited, and the sandbox as a
// match starts from it.
void Settings::update(Game& game)
{
bool isPractice = game.sim.match.state == MATCH_PRACTICE;
isDirty |= !(game.sim.match.rules == savedRules) || (wasPractice && !isPractice);
#ifdef __EMSCRIPTEN__
// A browser tab can close at any moment without a quit, so the sandbox is saved every so often there.
Uint64 now = SDL_GetTicks();
isDirty |= isPractice && now - savedAt >= WEB_SAVE_INTERVAL;
if (isDirty)
savedAt = now;
// The browser leaves fullscreen on its own (Escape), so the setting follows it.
isFullscreen = EM_ASM_INT({ return !!document.fullscreenElement; });
#endif
wasPractice = isPractice;
if (isDirty && !game.input.is_mouse_down(MOUSE_LEFT) && !SDL_TextInputActive(game.window))
save(game);
}