commit
2007fb7edc
200 files changed
+27710
No files matched your search
@@ -0,0 +1,691 @@
|
||||
#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_INPUT_DELAY = "input_delay";
|
||||
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_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_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", KEY_SANDBOX_X, fighter.center_get().x, KEY_SANDBOX_Y,
|
||||
fighter.body.box.y + fighter.body.box.h, KEY_SANDBOX_SLOT, fighter.slot);
|
||||
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.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_INPUT_DELAY) == 0)
|
||||
inputDelay = std::clamp(number, 0, INPUT_DELAY_MAX);
|
||||
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.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.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=%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_INPUT_DELAY, inputDelay, 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.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.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.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.label_draw(game, "Input Delay", {area.x, y});
|
||||
isHudChanged |= ui.number_draw(game, {x, y, NUMBER_WIDTH, WIDGET_HEIGHT}, inputDelay,
|
||||
{.min = 0, .max = INPUT_DELAY_MAX, .suffix = " Ticks"});
|
||||
}
|
||||
|
||||
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;
|
||||
inputDelay = fresh.inputDelay;
|
||||
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.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, 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);
|
||||
// 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.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.match.state == MATCH_PRACTICE;
|
||||
isDirty |= !(game.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);
|
||||
}
|
||||
Reference in new issue
Block a user