From 341f344ce5f86dc82d9f41a12eab80beff30defb Mon Sep 17 00:00:00 2001
From: VariableVince <24507472+VariableVince@users.noreply.github.com>
Date: Tue, 7 Apr 2026 05:41:57 +0200
Subject: [PATCH] Perf/Refactor(UserSettings): caching makes it 10-20x faster
(#3481)
## Description:
Skip slow and blocking LocalStorage reads, replace by a Map. Also some
refactoring.
### Contains
- No out-of-sync issue between main and worker thread: Earlier PRs got a
comment from evan about main & worker.worker thread having their own
version of usersettings and possibly getting out-of-sync (see
https://github.com/openfrontio/OpenFrontIO/pull/760#pullrequestreview-2845155737,
https://github.com/openfrontio/OpenFrontIO/pull/896#pullrequestreview-2871836979
and https://github.com/openfrontio/OpenFrontIO/pull/1266.
But userSettings is not used in files ran by worker.worker, not even 10
months after evan's first comment about it. In GameRunner,
createGameRunner sends NULL to getConfig as argument for userSettings.
And DefaultConfig guards against userSettings being null by throwing an
error, but it has never been thrown which points to worker.worker thread
not using userSettings. So we do not need to worry about syncing between
the threads currently.
(If needed in the future after all, we could quite easily sync it, by
loading the userSettings cache on worker.worker and listening to the
"user-settings-changed" event @scamiv to keep it synced (changes in
WorkerMessages and WorkerClient etc would be needed to handle this).
- Went with cache in UserSettings, not with listening to
"user-settings-changed" event: "user-settings-changed" was added by
@scamiv and is used in PerformanceOverlay. Which is great for single
files that need the very best performance. But having to add that same
system to any file reading settings, scales poorly and would lead to
messy code. Also, a developer could make the mistake of not listening to
the event and it would end up just reading LocalStorage again just like
now. Also a developer might forget removing the listener or so etc. The
cache is a central solution and fast, without changes to other files
needed and future-proof.
- Make sure each setting is cached: UserSettingsModal was using
LocalStorage directly by itself for some things. Made it use the central
UserSettings methods instead so we avoid LocalStorage reads as much as
possible. For this, changed get() and set() in UserSettings to getBool()
and setBool(), to introduce a getString() and setString() for use in
UserSettingsModal while keeping getCached() and setCached() private
within UserSettings.
- Remove unused 'focusLocked' and 'toggleFocusLocked' from UserSettings:
was last changed 11 months ago to just return false. Since then we've
moved to different ways of highlighting and this setting isn't used
anymore. No existing references or callers are left.
- Other files:
-- Have callers call the renamed functions (see point above)
-- Remove userSettings from UILayer and Territorylayer: the variable is
unused in those files. Also remove from GameRenderer when it calls
TerritoryLayer.
-- Cache calls to defaultconfig Theme (which in turn calls dark mode
setting)/Config better in: GameView and Terrainlayer.
### Update on Contents later on
It wasn't really in scope of this PR but further consolidation was
called for. These changes could also pave the way for UserSettingsModal
(main menu) perhaps being partly mergable with SettingsModal (in-game)
one day as it begins to look more like it. Even though UserSettingsModal
still does things its own way, and does console.log where SettingsModal
doesn't, etc. They both have partially different content and settings
but also have a large overlap.
- UserSettings: Removed localStorage call from clearFlag() and setFlag()
which were added after creation of this PR, and were neatly merged in
silence without merge conflicts so i wasn't aware of them yet until now.
- UserSettings: added key constants, exported to use both inside
UserSettings and in files that listen to its events.
- UserSettings 'emitChange': now done from setCached, removed from
setBool, setFlag etc. Also removed from the new setFlag. And from
setPattern even though it emitted "pattern" instead of key name
"territoryPattern"; now it emits the default "territoryPattern" from
PATTERN_KEY which is re-used in Store, TerritoryPatternsModal and
PatternInput.
- UserSettingsModal: made UserSettingsModal call existing toggle
functions in UserSettings, or new or existing getter or setter. We do
not need CustomEvent: checked anymore. In UserSettingsModal, its toggle
functions did not all actually toggle, some like
toggleLeftClickOpensMenu actually just set a value. Based on the
'checked' value of the CustomEvent. But we don't need that 'checked'
value anymore and none of the checks for it inside the toggle functions
in UserSettingsModal, now that we just directly call
toggleLeftClickOpensMenu and others in UserSettings.
- SettingToggle: continuing about not needing CustomEvent anymore: the
old way actually fired two events. The native change event from
and our own CustomEvent from handleChange in SettingToggle. It prevented
handling both events by checking e.detail?.checked === undefined. But
now, the native event is all we need to show the visual toggle
change and trigger @changed in UserSettingsModal which calls the toggle
function.
- Use the toggle functions too from CopyButton and
PerformanceOverlay.ts. In PerformanceOverlay, change in
onUserSettingsChanged was needed because of how setBool works.
- UserSettingsModal 'toggleDarkMode': in UserSettingsModal, removed the
event from toggleDarkMode in UserSettingsModal; nothing is listening to
this event anymore after DarkModeButton.ts was removed some time ago.
Also both UserSettingsModal an UserSettings added/removed "dark" from
the document element. Now that UserSettingsModal calls toggleDarkMode in
UserSettings, we could centralize that. But UserSettings is in core, not
in client like UserSettingsModal. But now that we emit
"user-settings-changed", we could handle it even more centralized and
not have UserSettingsModal or UserSettings touch the element directly.
Instead have Main.ts listen to the event and change it dark mode from
there.
- UserSettings: added claryfing comment to attackRatioIncrement and the
new attackRatio setters/getters, to explain their difference. Noticed a
small omitment in its description and fixed that right away in en.json:
you can change attack ratio increment by shift+mouse wheel scroll or by
hotkey. So made "How much the attack ratio keybinds change per press"
also mention "/scroll."
**BEFORE** (with getDisplayName added back to NameLayer as a fix i will
do soon)
get > getItem in UserSettings

renderLayer in NameLayer (with getDisplayName added back to NameLayer as
a fix i will do soon)

**AFTER** (with getDisplayName added back to NameLayer as a fix i will
do soon)
getCached in UserSettings

renderLayer in NameLayer (with getDisplayName added back to NameLayer as
a fix i will do soon)

## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
## Please put your Discord username so you can be contacted if a bug or
regression is found:
tryout33
---
resources/lang/en.json | 2 +-
src/client/FlagInput.ts | 10 +-
src/client/Main.ts | 28 ++-
src/client/PatternInput.ts | 6 +-
src/client/Store.ts | 10 +-
src/client/TerritoryPatternsModal.ts | 10 +-
src/client/UserSettingModal.ts | 162 ++++++---------
src/client/components/CopyButton.ts | 5 +-
.../baseComponents/setting/SettingToggle.ts | 7 -
src/client/graphics/GameRenderer.ts | 2 +-
.../graphics/layers/PerformanceOverlay.ts | 18 +-
src/client/graphics/layers/TerrainLayer.ts | 11 +-
src/client/graphics/layers/TerritoryLayer.ts | 4 -
src/client/graphics/layers/UILayer.ts | 2 -
src/core/game/GameView.ts | 20 +-
src/core/game/UserSettings.ts | 187 +++++++++++-------
16 files changed, 256 insertions(+), 228 deletions(-)
diff --git a/resources/lang/en.json b/resources/lang/en.json
index fdeedbb4d..bf01844e0 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -611,7 +611,7 @@
"attack_ratio_down": "Decrease Attack Ratio",
"attack_ratio_down_desc": "Decrease attack ratio by {amount}%",
"attack_ratio_increment_label": "Attack Ratio Keybind Increment",
- "attack_ratio_increment_desc": "How much the attack ratio keybinds change per press.",
+ "attack_ratio_increment_desc": "How much the attack ratio keybinds change per press/scroll.",
"attack_keybinds": "Attack Keybinds",
"boat_attack": "Boat Attack",
"boat_attack_desc": "Send a boat attack to the tile under your cursor.",
diff --git a/src/client/FlagInput.ts b/src/client/FlagInput.ts
index edcdb06b1..db59a4eba 100644
--- a/src/client/FlagInput.ts
+++ b/src/client/FlagInput.ts
@@ -1,7 +1,11 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { FlagName } from "../core/Schemas";
-import { UserSettings } from "../core/game/UserSettings";
+import {
+ FLAG_KEY,
+ USER_SETTINGS_CHANGED_EVENT,
+ UserSettings,
+} from "../core/game/UserSettings";
import { resolveFlagUrl } from "./Cosmetics";
import { translateText } from "./Utils";
@@ -41,7 +45,7 @@ export class FlagInput extends LitElement {
super.connectedCallback();
this.flag = new UserSettings().getFlag() ?? "";
window.addEventListener(
- "event:user-settings-changed:flag",
+ `${USER_SETTINGS_CHANGED_EVENT}:${FLAG_KEY}`,
this.updateFlag as EventListener,
);
}
@@ -49,7 +53,7 @@ export class FlagInput extends LitElement {
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener(
- "event:user-settings-changed:flag",
+ `${USER_SETTINGS_CHANGED_EVENT}:${FLAG_KEY}`,
this.updateFlag as EventListener,
);
}
diff --git a/src/client/Main.ts b/src/client/Main.ts
index 222d944f3..4c15d5684 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -11,7 +11,11 @@ import {
import { GameEnv } from "../core/configuration/Config";
import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
-import { UserSettings } from "../core/game/UserSettings";
+import {
+ DARK_MODE_KEY,
+ USER_SETTINGS_CHANGED_EVENT,
+ UserSettings,
+} from "../core/game/UserSettings";
import "./AccountModal";
import { getUserMe } from "./Api";
import { userAuth } from "./Auth";
@@ -478,11 +482,23 @@ class Client {
this.joinModal.eventBus = this.eventBus;
}
- if (this.userSettings.darkMode()) {
- document.documentElement.classList.add("dark");
- } else {
- document.documentElement.classList.remove("dark");
- }
+ const applyDarkMode = (isDark: boolean) => {
+ if (isDark) {
+ document.documentElement.classList.add("dark");
+ } else {
+ document.documentElement.classList.remove("dark");
+ }
+ };
+
+ applyDarkMode(this.userSettings.darkMode());
+
+ globalThis.addEventListener(
+ `${USER_SETTINGS_CHANGED_EVENT}:${DARK_MODE_KEY}`,
+ (e: CustomEvent) => {
+ const isDark = e.detail === "true";
+ applyDarkMode(isDark);
+ },
+ );
// Attempt to join lobby
if (document.readyState === "loading") {
diff --git a/src/client/PatternInput.ts b/src/client/PatternInput.ts
index 46910c486..c38c8218f 100644
--- a/src/client/PatternInput.ts
+++ b/src/client/PatternInput.ts
@@ -1,5 +1,9 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
+import {
+ PATTERN_KEY,
+ USER_SETTINGS_CHANGED_EVENT,
+} from "../core/game/UserSettings";
import { PlayerPattern } from "../core/Schemas";
import { renderPatternPreview } from "./components/PatternButton";
import { getPlayerCosmetics } from "./Cosmetics";
@@ -47,7 +51,7 @@ export class PatternInput extends LitElement {
if (!this.isConnected) return;
this.isLoading = false;
window.addEventListener(
- "event:user-settings-changed:pattern",
+ `${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
this._onPatternSelected,
{
signal: this._abortController.signal,
diff --git a/src/client/Store.ts b/src/client/Store.ts
index b241345b7..2091a465e 100644
--- a/src/client/Store.ts
+++ b/src/client/Store.ts
@@ -3,7 +3,11 @@ import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas";
-import { UserSettings } from "../core/game/UserSettings";
+import {
+ PATTERN_KEY,
+ USER_SETTINGS_CHANGED_EVENT,
+ UserSettings,
+} from "../core/game/UserSettings";
import { PlayerPattern } from "../core/Schemas";
import { BaseModal } from "./components/BaseModal";
import "./components/FlagButton";
@@ -45,7 +49,7 @@ export class StoreModal extends BaseModal {
},
);
window.addEventListener(
- "event:user-settings-changed:pattern",
+ `${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
this._onPatternSelected,
);
}
@@ -53,7 +57,7 @@ export class StoreModal extends BaseModal {
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener(
- "event:user-settings-changed:pattern",
+ `${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
this._onPatternSelected,
);
}
diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts
index 050377e70..9e6537073 100644
--- a/src/client/TerritoryPatternsModal.ts
+++ b/src/client/TerritoryPatternsModal.ts
@@ -3,7 +3,11 @@ import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { Cosmetics, Pattern } from "../core/CosmeticSchemas";
-import { UserSettings } from "../core/game/UserSettings";
+import {
+ PATTERN_KEY,
+ USER_SETTINGS_CHANGED_EVENT,
+ UserSettings,
+} from "../core/game/UserSettings";
import { PlayerPattern } from "../core/Schemas";
import { BaseModal } from "./components/BaseModal";
import "./components/NotLoggedInWarning";
@@ -42,7 +46,7 @@ export class TerritoryPatternsModal extends BaseModal {
},
);
window.addEventListener(
- "event:user-settings-changed:pattern",
+ `${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
this._onPatternSelected,
);
}
@@ -50,7 +54,7 @@ export class TerritoryPatternsModal extends BaseModal {
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener(
- "event:user-settings-changed:pattern",
+ `${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
this._onPatternSelected,
);
}
diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts
index dba7be0fb..9914c66d2 100644
--- a/src/client/UserSettingModal.ts
+++ b/src/client/UserSettingModal.ts
@@ -71,7 +71,7 @@ export class UserSettingModal extends BaseModal {
}
private loadKeybindsFromStorage() {
- const savedKeybinds = localStorage.getItem("settings.keybinds");
+ const savedKeybinds = this.userSettings.keybinds();
if (!savedKeybinds) return;
try {
@@ -199,7 +199,7 @@ export class UserSettingModal extends BaseModal {
}
this.keybinds = { ...this.keybinds, [action]: { value: value, key: key } };
- localStorage.setItem("settings.keybinds", JSON.stringify(this.keybinds));
+ this.userSettings.setKeybinds(JSON.stringify(this.keybinds));
}
private getKeyValue(action: string): string | undefined {
@@ -251,101 +251,77 @@ export class UserSettingModal extends BaseModal {
}, 5000);
}
- toggleDarkMode(e: CustomEvent<{ checked: boolean }>) {
- const enabled = e.detail?.checked;
+ toggleDarkMode() {
+ this.userSettings.toggleDarkMode();
- if (typeof enabled !== "boolean") {
- console.warn("Unexpected toggle event payload", e);
- return;
- }
+ console.log("🌙 Dark Mode:", this.userSettings.darkMode() ? "ON" : "OFF");
+ }
- this.userSettings.set("settings.darkMode", enabled);
+ private toggleEmojis() {
+ this.userSettings.toggleEmojis();
- if (enabled) {
- document.documentElement.classList.add("dark");
- } else {
- document.documentElement.classList.remove("dark");
- }
+ console.log("🤡 Emojis:", this.userSettings.emojis() ? "ON" : "OFF");
+ }
- this.dispatchEvent(
- new CustomEvent("dark-mode-changed", {
- detail: { darkMode: enabled },
- bubbles: true,
- composed: true,
- }),
+ private toggleAlertFrame() {
+ this.userSettings.toggleAlertFrame();
+
+ console.log(
+ "🚨 Alert frame:",
+ this.userSettings.alertFrame() ? "ON" : "OFF",
);
-
- console.log("🌙 Dark Mode:", enabled ? "ON" : "OFF");
}
- private toggleEmojis(e: CustomEvent<{ checked: boolean }>) {
- const enabled = e.detail?.checked;
- if (typeof enabled !== "boolean") return;
+ private toggleFxLayer() {
+ this.userSettings.toggleFxLayer();
- this.userSettings.set("settings.emojis", enabled);
-
- console.log("🤡 Emojis:", enabled ? "ON" : "OFF");
+ console.log(
+ "💥 Special effects:",
+ this.userSettings.fxLayer() ? "ON" : "OFF",
+ );
}
- private toggleAlertFrame(e: CustomEvent<{ checked: boolean }>) {
- const enabled = e.detail?.checked;
- if (typeof enabled !== "boolean") return;
+ private toggleStructureSprites() {
+ this.userSettings.toggleStructureSprites();
- this.userSettings.set("settings.alertFrame", enabled);
-
- console.log("🚨 Alert frame:", enabled ? "ON" : "OFF");
+ console.log(
+ "🏠 Structure sprites:",
+ this.userSettings.structureSprites() ? "ON" : "OFF",
+ );
}
- private toggleFxLayer(e: CustomEvent<{ checked: boolean }>) {
- const enabled = e.detail?.checked;
- if (typeof enabled !== "boolean") return;
+ private toggleCursorCostLabel() {
+ this.userSettings.toggleCursorCostLabel();
- this.userSettings.set("settings.specialEffects", enabled);
-
- console.log("💥 Special effects:", enabled ? "ON" : "OFF");
+ console.log(
+ "💰 Cursor build cost:",
+ this.userSettings.cursorCostLabel() ? "ON" : "OFF",
+ );
}
- private toggleStructureSprites(e: CustomEvent<{ checked: boolean }>) {
- const enabled = e.detail?.checked;
- if (typeof enabled !== "boolean") return;
+ private toggleAnonymousNames() {
+ this.userSettings.toggleRandomName();
- this.userSettings.set("settings.structureSprites", enabled);
-
- console.log("🏠 Structure sprites:", enabled ? "ON" : "OFF");
+ console.log(
+ "🙈 Anonymous Names:",
+ this.userSettings.anonymousNames() ? "ON" : "OFF",
+ );
}
- private toggleCursorCostLabel(e: CustomEvent<{ checked: boolean }>) {
- const enabled = e.detail?.checked;
- if (typeof enabled !== "boolean") return;
-
- this.userSettings.set("settings.cursorCostLabel", enabled);
-
- console.log("💰 Cursor build cost:", enabled ? "ON" : "OFF");
+ private toggleLobbyIdVisibility() {
+ this.userSettings.toggleLobbyIdVisibility();
+ console.log(
+ "👁️ Hidden Lobby IDs:",
+ !this.userSettings.lobbyIdVisibility() ? "ON" : "OFF",
+ );
}
- private toggleAnonymousNames(e: CustomEvent<{ checked: boolean }>) {
- const enabled = e.detail?.checked;
- if (typeof enabled !== "boolean") return;
-
- this.userSettings.set("settings.anonymousNames", enabled);
-
- console.log("🙈 Anonymous Names:", enabled ? "ON" : "OFF");
- }
-
- private toggleLobbyIdVisibility(e: CustomEvent<{ checked: boolean }>) {
- const hideIds = e.detail?.checked;
- if (typeof hideIds !== "boolean") return;
-
- this.userSettings.set("settings.lobbyIdVisibility", !hideIds); // Invert because checked=hide
- console.log("👁️ Hidden Lobby IDs:", hideIds ? "ON" : "OFF");
- }
-
- private toggleLeftClickOpensMenu(e: CustomEvent<{ checked: boolean }>) {
- const enabled = e.detail?.checked;
- if (typeof enabled !== "boolean") return;
-
- this.userSettings.set("settings.leftClickOpensMenu", enabled);
- console.log("🖱️ Left Click Opens Menu:", enabled ? "ON" : "OFF");
+ private toggleLeftClickOpensMenu() {
+ this.userSettings.toggleLeftClickOpenMenu();
+ console.log(
+ "🖱️ Left Click Opens Menu:",
+ this.userSettings.leftClickOpensMenu() ? "ON" : "OFF",
+ );
this.requestUpdate();
}
@@ -354,7 +330,7 @@ export class UserSettingModal extends BaseModal {
const value = e.detail?.value;
if (typeof value === "number") {
const ratio = value / 100;
- localStorage.setItem("settings.attackRatio", ratio.toString());
+ this.userSettings.setAttackRatio(ratio);
} else {
console.warn("Slider event missing detail.value", e);
}
@@ -370,27 +346,21 @@ export class UserSettingModal extends BaseModal {
console.warn("Select event missing detail.value", e);
return;
}
- this.userSettings.setFloat(
- "settings.attackRatioIncrement",
- Math.round(value),
- );
+ this.userSettings.setAttackRatioIncrement(Math.round(value));
this.requestUpdate();
}
- private toggleTerritoryPatterns(e: CustomEvent<{ checked: boolean }>) {
- const enabled = e.detail?.checked;
- if (typeof enabled !== "boolean") return;
+ private toggleTerritoryPatterns() {
+ this.userSettings.toggleTerritoryPatterns();
- this.userSettings.set("settings.territoryPatterns", enabled);
-
- console.log("🏳️ Territory Patterns:", enabled ? "ON" : "OFF");
+ console.log(
+ "🏳️ Territory Patterns:",
+ this.userSettings.territoryPatterns() ? "ON" : "OFF",
+ );
}
- private togglePerformanceOverlay(e: CustomEvent<{ checked: boolean }>) {
- const enabled = e.detail?.checked;
- if (typeof enabled !== "boolean") return;
-
- this.userSettings.set("settings.performanceOverlay", enabled);
+ private togglePerformanceOverlay() {
+ this.userSettings.togglePerformanceOverlay();
}
render() {
@@ -809,8 +779,7 @@ export class UserSettingModal extends BaseModal {
description="${translateText("user_setting.dark_mode_desc")}"
id="dark-mode-toggle"
.checked=${this.userSettings.darkMode()}
- @change=${(e: CustomEvent<{ checked: boolean }>) =>
- this.toggleDarkMode(e)}
+ @change=${this.toggleDarkMode}
>
@@ -881,7 +850,7 @@ export class UserSettingModal extends BaseModal {
label="${translateText("user_setting.lobby_id_visibility_label")}"
description="${translateText("user_setting.lobby_id_visibility_desc")}"
id="lobby-id-visibility-toggle"
- .checked=${!this.userSettings.get("settings.lobbyIdVisibility", true)}
+ .checked=${!this.userSettings.lobbyIdVisibility()}
@change=${this.toggleLobbyIdVisibility}
>
@@ -909,8 +878,7 @@ export class UserSettingModal extends BaseModal {
description="${translateText("user_setting.attack_ratio_desc")}"
min="1"
max="100"
- .value=${Number(localStorage.getItem("settings.attackRatio") ?? "0.2") *
- 100}
+ .value=${this.userSettings.attackRatio() * 100}
@change=${this.sliderAttackRatio}
>
diff --git a/src/client/components/CopyButton.ts b/src/client/components/CopyButton.ts
index dea21f618..053d830ac 100644
--- a/src/client/components/CopyButton.ts
+++ b/src/client/components/CopyButton.ts
@@ -33,10 +33,7 @@ export class CopyButton extends LitElement {
changedProperties: Map,
) {
if (changedProperties.has("lobbyId")) {
- this.lobbyIdVisible = this.userSettings.get(
- "settings.lobbyIdVisibility",
- true,
- );
+ this.lobbyIdVisible = this.userSettings.lobbyIdVisibility();
this.copySuccess = false;
}
if (changedProperties.has("copyText")) {
diff --git a/src/client/components/baseComponents/setting/SettingToggle.ts b/src/client/components/baseComponents/setting/SettingToggle.ts
index 30ed71e30..79dab8b97 100644
--- a/src/client/components/baseComponents/setting/SettingToggle.ts
+++ b/src/client/components/baseComponents/setting/SettingToggle.ts
@@ -16,13 +16,6 @@ export class SettingToggle extends LitElement {
private handleChange(e: Event) {
const input = e.target as HTMLInputElement;
this.checked = input.checked;
- this.dispatchEvent(
- new CustomEvent("change", {
- detail: { checked: this.checked },
- bubbles: true,
- composed: true,
- }),
- );
}
render() {
diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts
index 1937b4a3e..fad782e2c 100644
--- a/src/client/graphics/GameRenderer.ts
+++ b/src/client/graphics/GameRenderer.ts
@@ -273,7 +273,7 @@ export function createRenderer(
// Not grouping the layers may cause excessive calls to context.save() and context.restore().
const layers: Layer[] = [
new TerrainLayer(game, transformHandler),
- new TerritoryLayer(game, eventBus, transformHandler, userSettings),
+ new TerritoryLayer(game, eventBus, transformHandler),
new RailroadLayer(game, eventBus, transformHandler, uiState),
new CoordinateGridLayer(game, eventBus, transformHandler),
structureLayer,
diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts
index 9dd097ab9..98c1ba40c 100644
--- a/src/client/graphics/layers/PerformanceOverlay.ts
+++ b/src/client/graphics/layers/PerformanceOverlay.ts
@@ -1,7 +1,11 @@
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
-import { UserSettings } from "../../../core/game/UserSettings";
+import {
+ PERFORMANCE_OVERLAY_KEY,
+ USER_SETTINGS_CHANGED_EVENT,
+ UserSettings,
+} from "../../../core/game/UserSettings";
import {
TickMetricsEvent,
TogglePerformanceOverlayEvent,
@@ -469,15 +473,15 @@ export class PerformanceOverlay extends LitElement implements Layer {
) => {
const nextVisible = !this.isVisible;
this.setVisible(nextVisible);
- this.userSettings.set("settings.performanceOverlay", nextVisible);
+ this.userSettings.setPerformanceOverlay(nextVisible);
};
private onTickMetricsEvent = (event: TickMetricsEvent) => {
this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay);
};
- private onUserSettingsChanged = (event: CustomEvent) => {
- const nextVisible = (event.detail as boolean) === true;
+ private onUserSettingsChanged = (event: CustomEvent) => {
+ const nextVisible = event.detail === "true";
if (this.isVisible === nextVisible) return;
this.setVisible(nextVisible);
};
@@ -505,7 +509,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
if (!this.isUserSettingsListenerAttached) {
globalThis.addEventListener(
- "event:user-settings-changed:settings.performanceOverlay",
+ `${USER_SETTINGS_CHANGED_EVENT}:${PERFORMANCE_OVERLAY_KEY}`,
this.onUserSettingsChanged,
);
this.isUserSettingsListenerAttached = true;
@@ -517,7 +521,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
if (this.isUserSettingsListenerAttached) {
globalThis.removeEventListener(
- "event:user-settings-changed:settings.performanceOverlay",
+ `${USER_SETTINGS_CHANGED_EVENT}:${PERFORMANCE_OVERLAY_KEY}`,
this.onUserSettingsChanged,
);
this.isUserSettingsListenerAttached = false;
@@ -576,7 +580,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
private handleClose() {
const nextVisible = false;
this.setVisible(nextVisible);
- this.userSettings.set("settings.performanceOverlay", nextVisible);
+ this.userSettings.setPerformanceOverlay(nextVisible);
}
private onDragPointerMove = (e: PointerEvent) => {
diff --git a/src/client/graphics/layers/TerrainLayer.ts b/src/client/graphics/layers/TerrainLayer.ts
index 57efc759f..542d21612 100644
--- a/src/client/graphics/layers/TerrainLayer.ts
+++ b/src/client/graphics/layers/TerrainLayer.ts
@@ -1,4 +1,4 @@
-import { Theme } from "../../../core/configuration/Config";
+import { Config, Theme } from "../../../core/configuration/Config";
import { GameView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
@@ -8,16 +8,19 @@ export class TerrainLayer implements Layer {
private context: CanvasRenderingContext2D;
private imageData: ImageData;
private theme: Theme;
+ private config: Config;
constructor(
private game: GameView,
private transformHandler: TransformHandler,
- ) {}
+ ) {
+ this.config = this.game.config();
+ }
shouldTransform(): boolean {
return true;
}
tick() {
- if (this.game.config().theme() !== this.theme) {
+ if (this.config.theme() !== this.theme) {
this.redraw();
}
}
@@ -46,7 +49,7 @@ export class TerrainLayer implements Layer {
}
initImageData() {
- this.theme = this.game.config().theme();
+ this.theme = this.config.theme();
this.game.forEachTile((tile) => {
const terrainColor = this.theme.terrainColor(this.game, tile);
// TODO: isn't tileref and index the same?
diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts
index 08d3f5a9c..cc66b2eb9 100644
--- a/src/client/graphics/layers/TerritoryLayer.ts
+++ b/src/client/graphics/layers/TerritoryLayer.ts
@@ -12,7 +12,6 @@ import {
import { euclDistFN, TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
-import { UserSettings } from "../../../core/game/UserSettings";
import { PseudoRandom } from "../../../core/PseudoRandom";
import {
AlternateViewEvent,
@@ -24,7 +23,6 @@ import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
export class TerritoryLayer implements Layer {
- private userSettings: UserSettings;
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private imageData: ImageData;
@@ -62,9 +60,7 @@ export class TerritoryLayer implements Layer {
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
- userSettings: UserSettings,
) {
- this.userSettings = userSettings;
this.theme = game.config().theme();
this.cachedTerritoryPatternsEnabled = undefined;
}
diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts
index d8edb3f02..79cebcb56 100644
--- a/src/client/graphics/layers/UILayer.ts
+++ b/src/client/graphics/layers/UILayer.ts
@@ -4,7 +4,6 @@ import { Theme } from "../../../core/configuration/Config";
import { UnitType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
-import { UserSettings } from "../../../core/game/UserSettings";
import { UnitSelectionEvent } from "../../InputHandler";
import { ProgressBar } from "../ProgressBar";
import { TransformHandler } from "../TransformHandler";
@@ -28,7 +27,6 @@ export class UILayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D | null;
private theme: Theme | null = null;
- private userSettings: UserSettings = new UserSettings();
private selectionAnimTime = 0;
private allProgressBars: Map<
number,
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index 1bd4dd61c..da1b5f6bf 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -228,14 +228,10 @@ export class PlayerView {
);
}
- const defaultTerritoryColor = this.game
- .config()
- .theme()
- .territoryColor(this);
- const defaultBorderColor = this.game
- .config()
- .theme()
- .borderColor(defaultTerritoryColor);
+ const theme = this.game.config().theme();
+
+ const defaultTerritoryColor = theme.territoryColor(this);
+ const defaultBorderColor = theme.borderColor(defaultTerritoryColor);
const pattern = userSettings.territoryPatterns()
? this.cosmetics.pattern
@@ -258,14 +254,11 @@ export class PlayerView {
this._territoryColor = defaultTerritoryColor;
}
- this._structureColors = this.game
- .config()
- .theme()
- .structureColors(this._territoryColor);
+ this._structureColors = theme.structureColors(this._territoryColor);
const maybeFocusedBorderColor =
this.game.myClientID() === this.data.clientID
- ? this.game.config().theme().focusedBorderColor()
+ ? theme.focusedBorderColor()
: defaultBorderColor;
this._borderColor = new Colord(
@@ -275,7 +268,6 @@ export class PlayerView {
);
// Pre-compute all border color variants once
- const theme = this.game.config().theme();
const baseRgb = this._borderColor.toRgb();
// Neutral is just the base color
diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts
index de7be1c2a..33be61982 100644
--- a/src/core/game/UserSettings.ts
+++ b/src/core/game/UserSettings.ts
@@ -1,15 +1,22 @@
import { Cosmetics } from "../CosmeticSchemas";
import { PlayerPattern } from "../Schemas";
-const PATTERN_KEY = "territoryPattern";
+export const USER_SETTINGS_CHANGED_EVENT = "event:user-settings-changed";
+export const PATTERN_KEY = "territoryPattern";
+export const FLAG_KEY = "flag";
+export const COLOR_KEY = "settings.territoryColor";
+export const DARK_MODE_KEY = "settings.darkMode";
+export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay";
export class UserSettings {
+ private static cache = new Map();
+
private emitChange(key: string, value: any): void {
try {
const maybeDispatch = (globalThis as any)?.dispatchEvent;
if (typeof maybeDispatch !== "function") return;
(globalThis as any).dispatchEvent(
- new CustomEvent(`event:user-settings-changed:${key}`, {
+ new CustomEvent(`${USER_SETTINGS_CHANGED_EVENT}:${key}`, {
detail: value,
}),
);
@@ -18,147 +25,167 @@ export class UserSettings {
}
}
- get(key: string, defaultValue: boolean): boolean {
- const value = localStorage.getItem(key);
+ private getCached(key: string): string | null {
+ if (!UserSettings.cache.has(key)) {
+ UserSettings.cache.set(key, localStorage.getItem(key));
+ }
+ return UserSettings.cache.get(key) ?? null;
+ }
+
+ private setCached(key: string, value: string, emitChange: boolean = true) {
+ localStorage.setItem(key, value);
+ UserSettings.cache.set(key, value);
+ if (emitChange) {
+ this.emitChange(key, value);
+ }
+ }
+
+ private removeCached(key: string, emitChange: boolean = true) {
+ localStorage.removeItem(key);
+ UserSettings.cache.set(key, null);
+ if (emitChange) {
+ this.emitChange(key, null);
+ }
+ }
+
+ private getBool(key: string, defaultValue: boolean): boolean {
+ const value = this.getCached(key);
if (!value) return defaultValue;
-
if (value === "true") return true;
-
if (value === "false") return false;
-
return defaultValue;
}
- set(key: string, value: boolean) {
- localStorage.setItem(key, value ? "true" : "false");
- this.emitChange(key, value);
+ private setBool(key: string, value: boolean) {
+ this.setCached(key, value ? "true" : "false");
}
- getFloat(key: string, defaultValue: number): number {
- const value = localStorage.getItem(key);
+ private getString(key: string, defaultValue: string = ""): string {
+ const value = this.getCached(key);
+ if (value === null) return defaultValue;
+ return value;
+ }
+
+ private setString(key: string, value: string) {
+ this.setCached(key, value);
+ }
+
+ private getFloat(key: string, defaultValue: number): number {
+ const value = this.getCached(key);
if (!value) return defaultValue;
const floatValue = parseFloat(value);
if (isNaN(floatValue)) return defaultValue;
-
return floatValue;
}
- setFloat(key: string, value: number) {
- localStorage.setItem(key, value.toString());
- this.emitChange(key, value);
+ private setFloat(key: string, value: number) {
+ this.setCached(key, value.toString());
}
emojis() {
- return this.get("settings.emojis", true);
+ return this.getBool("settings.emojis", true);
}
performanceOverlay() {
- return this.get("settings.performanceOverlay", false);
+ return this.getBool(PERFORMANCE_OVERLAY_KEY, false);
}
alertFrame() {
- return this.get("settings.alertFrame", true);
+ return this.getBool("settings.alertFrame", true);
}
anonymousNames() {
- return this.get("settings.anonymousNames", false);
+ return this.getBool("settings.anonymousNames", false);
}
lobbyIdVisibility() {
- return this.get("settings.lobbyIdVisibility", true);
+ return this.getBool("settings.lobbyIdVisibility", true);
}
fxLayer() {
- return this.get("settings.specialEffects", true);
+ return this.getBool("settings.specialEffects", true);
}
structureSprites() {
- return this.get("settings.structureSprites", true);
+ return this.getBool("settings.structureSprites", true);
}
darkMode() {
- return this.get("settings.darkMode", false);
+ return this.getBool(DARK_MODE_KEY, false);
}
leftClickOpensMenu() {
- return this.get("settings.leftClickOpensMenu", false);
+ return this.getBool("settings.leftClickOpensMenu", false);
}
territoryPatterns() {
- return this.get("settings.territoryPatterns", true);
+ return this.getBool("settings.territoryPatterns", true);
}
attackingTroopsOverlay() {
- return this.get("settings.attackingTroopsOverlay", true);
+ return this.getBool("settings.attackingTroopsOverlay", true);
}
toggleAttackingTroopsOverlay() {
- this.set("settings.attackingTroopsOverlay", !this.attackingTroopsOverlay());
+ this.setBool(
+ "settings.attackingTroopsOverlay",
+ !this.attackingTroopsOverlay(),
+ );
}
cursorCostLabel() {
- const legacy = this.get("settings.ghostPricePill", true);
- return this.get("settings.cursorCostLabel", legacy);
- }
-
- focusLocked() {
- return false;
- // TODO: re-enable when performance issues are fixed.
- this.get("settings.focusLocked", true);
+ const legacy = this.getBool("settings.ghostPricePill", true);
+ return this.getBool("settings.cursorCostLabel", legacy);
}
toggleLeftClickOpenMenu() {
- this.set("settings.leftClickOpensMenu", !this.leftClickOpensMenu());
- }
-
- toggleFocusLocked() {
- this.set("settings.focusLocked", !this.focusLocked());
+ this.setBool("settings.leftClickOpensMenu", !this.leftClickOpensMenu());
}
toggleEmojis() {
- this.set("settings.emojis", !this.emojis());
+ this.setBool("settings.emojis", !this.emojis());
+ }
+
+ // Performance overlay specifically needs a direct setter for Shift-D
+ setPerformanceOverlay(value: boolean) {
+ this.setBool(PERFORMANCE_OVERLAY_KEY, value);
}
togglePerformanceOverlay() {
- this.set("settings.performanceOverlay", !this.performanceOverlay());
+ this.setBool(PERFORMANCE_OVERLAY_KEY, !this.performanceOverlay());
}
toggleAlertFrame() {
- this.set("settings.alertFrame", !this.alertFrame());
+ this.setBool("settings.alertFrame", !this.alertFrame());
}
toggleRandomName() {
- this.set("settings.anonymousNames", !this.anonymousNames());
+ this.setBool("settings.anonymousNames", !this.anonymousNames());
}
toggleLobbyIdVisibility() {
- this.set("settings.lobbyIdVisibility", !this.lobbyIdVisibility());
+ this.setBool("settings.lobbyIdVisibility", !this.lobbyIdVisibility());
}
toggleFxLayer() {
- this.set("settings.specialEffects", !this.fxLayer());
+ this.setBool("settings.specialEffects", !this.fxLayer());
}
toggleStructureSprites() {
- this.set("settings.structureSprites", !this.structureSprites());
+ this.setBool("settings.structureSprites", !this.structureSprites());
}
toggleCursorCostLabel() {
- this.set("settings.cursorCostLabel", !this.cursorCostLabel());
+ this.setBool("settings.cursorCostLabel", !this.cursorCostLabel());
}
toggleTerritoryPatterns() {
- this.set("settings.territoryPatterns", !this.territoryPatterns());
+ this.setBool("settings.territoryPatterns", !this.territoryPatterns());
}
toggleDarkMode() {
- this.set("settings.darkMode", !this.darkMode());
- if (this.darkMode()) {
- document.documentElement.classList.add("dark");
- } else {
- document.documentElement.classList.remove("dark");
- }
+ this.setBool(DARK_MODE_KEY, !this.darkMode());
}
// For development only. Used for testing patterns, set in the console manually.
@@ -178,7 +205,7 @@ export class UserSettings {
getSelectedPatternName(cosmetics: Cosmetics | null): PlayerPattern | null {
if (cosmetics === null) return null;
- let data = localStorage.getItem(PATTERN_KEY) ?? null;
+ let data = this.getCached(PATTERN_KEY);
if (data === null) return null;
const patternPrefix = "pattern:";
if (data.startsWith(patternPrefix)) {
@@ -196,34 +223,32 @@ export class UserSettings {
setSelectedPatternName(patternName: string | undefined): void {
if (patternName === undefined) {
- localStorage.removeItem(PATTERN_KEY);
+ this.removeCached(PATTERN_KEY);
} else {
- localStorage.setItem(PATTERN_KEY, patternName);
+ this.setCached(PATTERN_KEY, patternName);
}
- this.emitChange("pattern", patternName);
}
getSelectedColor(): string | undefined {
- const data = localStorage.getItem("settings.territoryColor") ?? undefined;
- if (data === undefined) return undefined;
- return data;
+ return this.getCached(COLOR_KEY) ?? undefined;
}
setSelectedColor(color: string | undefined): void {
if (color === undefined) {
- localStorage.removeItem("settings.territoryColor");
+ this.removeCached(COLOR_KEY);
} else {
- localStorage.setItem("settings.territoryColor", color);
+ this.setCached(COLOR_KEY, color);
}
}
getFlag(): string | null {
- let flag = localStorage.getItem("flag");
+ let flag = this.getCached(FLAG_KEY);
if (!flag) return null;
// Migrate bare country codes to country: prefix
if (!flag.startsWith("flag:") && !flag.startsWith("country:")) {
flag = `country:${flag}`;
- localStorage.setItem("flag", flag);
+ // Silent migration: don't emit change event for FlagInput
+ this.setCached(FLAG_KEY, flag, false);
}
return flag;
}
@@ -232,14 +257,12 @@ export class UserSettings {
if (flag === "country:xx") {
this.clearFlag();
} else {
- localStorage.setItem("flag", flag);
+ this.setCached(FLAG_KEY, flag);
}
- console.log("emitting change!");
- this.emitChange("flag", flag);
}
clearFlag(): void {
- localStorage.removeItem("flag");
+ this.removeCached(FLAG_KEY);
}
backgroundMusicVolume(): number {
@@ -250,6 +273,7 @@ export class UserSettings {
this.setFloat("settings.backgroundMusicVolume", volume);
}
+ // What % attack ratio increments per click/scroll
attackRatioIncrement(): number {
const increment = Math.round(
this.getFloat("settings.attackRatioIncrement", 10),
@@ -258,6 +282,27 @@ export class UserSettings {
return increment;
}
+ setAttackRatioIncrement(value: number): void {
+ this.setFloat("settings.attackRatioIncrement", value);
+ }
+
+ // What % attack ratio is set to
+ attackRatio(): number {
+ return this.getFloat("settings.attackRatio", 0.2);
+ }
+
+ setAttackRatio(value: number): void {
+ this.setFloat("settings.attackRatio", value);
+ }
+
+ keybinds(): string {
+ return this.getString("settings.keybinds", "");
+ }
+
+ setKeybinds(value: string): void {
+ this.setString("settings.keybinds", value);
+ }
+
soundEffectsVolume(): number {
return this.getFloat("settings.soundEffectsVolume", 1);
}