#include "settings.h" #include "game.h" #include #include #include #ifdef __EMSCRIPTEN__ #include #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_=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); }