Refactor/Fix(UserSettings): last localstorage calls now use UserSettings, and Keybinds fixes (#3619)

## Description:

1) Have last localstorage calls for keybinds and attack ratio also use
UserSettings cache instead, after #3481. Remaining calls to localstorage
are for different things than user settings, so they are left as is.

2) Consolidate and centralize keybinds logic. And three fixes for it.

- **UnitDisplay** and **UserSettingsModal**: _parsedUserKeybinds_ is
introduced in **UserSettings** to centralize their logic. It is also
used by _normalizedUserKeybinds_, see point below.

- **UserSettingsModal**
-- replaced unwanted cast `as SettingKeybind` by a typed QuerySelector.
-- renamed this.keybinds to this.userKeybinds for more clarity, and
distinction from defaultKeybinds.
-- state private _userKeybinds_: remove type string[] since
loadKeybindsFromStorage replaces a value array by its first string
element, so it can not contain string[] anymore.
-- _handleKeybindChange_ and _getKeyValue_: no need to check for
Array.isArray anymore, see above reason.
-- **Fix**: checks after calling _parsedUserKeybinds_ are improved a
bit: don't delete all keybinds and print a console warning when finding
just one invalid keybind and (i think i have seen people complaining
about things being removed). Instead it now migrates or throws out the
invalid ones but keeps the valid ones. Also works with the "Null" value
expected and removed within
**UserSettingsModal**._handleyKeybindChange_() and in **HelpModal**.
When legacy value is an array and key is empty, don't put value as key
but get first array element or empty string as key name. So that check
on line 68 is true.

- **HelpModal** and **InputHandler**: Also centralize/consolidate their
logic more, by having __keybinds()_ from **UserSettings** perform
fetching _getDefaultKeybinds_ and _normalizedUserKeybinds_.
-- Functionality in _normalizedUserKeybinds_ is the same: Where
HelpModal did return [k, v.value] if typeof (v as any).value ===
"string", this is now handled by lines 309-310 of normalizedKeybinds
still the same but with less lines. Same for old HelplModal if (typeof v
=== "string") return [k, v], this is stil returned by line 112 of
normalizedKeybinds. And return [k, undefined] when (typeof val !==
"string") as was done in InputHandler, isn't needed as values that
weren't strings were already filtered out right after which we still do
on line 314 of normalizedKeybinds.
-- **Fix** in _normalizedUserKeybinds_: added one extra thing that was a
discrepancy between **HelpModal**/**InputHandler** and
**UserSettingsModal** before: **UserSettingsModal** would handle array
values, and normalize them by picking only the first value if it is a
string. Now have _normalizedKeybinds_ do the same. Otherwise it would
have thrown those values out while **UserSettingsModal** would have kept
the first value. This may still help a returning player who hasn't
played in the last version (i think i have seen people complaining about
things being removed, but that may not have been about this). And makes
the logic more consistent between **UserSettingsModal** and
**HelpModal**/**InputHandler**.

- **UserSettings**: 
-- _getDefaultKeybinds_: centralized/consolidated logic, accepts
Platform.isMac parameter. In **HelpModal**, **InputHandler** and
**UserSettingsModal** the same list with default keybinds was hardcoded.
Now they all read from _getDefaultKeybinds_. The list of default
keybinds in **HelpModal** was a little shorter, but that doesn't matter
since its _render_() function has hardcoded which of the hotkeys
**HelpModal** shows. Have thought about putting default keybinds in
**DefaultConfig** but with all the logic handled through
**UserSettings**, this seemed the better place in the current refactor.
-- _removeCached_: make public, now that **InputHandler.test.ts** needs
to be able to call it. We could instead make a public function like
removeKeybinds() and keep removeCached() private, but went with this for
now.
-- _parsedUserKeybinds_: centralized/consolidated logic for
**UserSettingsModal**/**UserDisplay**. Always returns an object, even an
empty one if the JSON wasn't parsable.
-- _normalizedKeybinds_: centralized/consolidated logic. Used by
_keybinds_() which is now called by **HelpModal**/**InputHandler**.
-- _keybinds_: now uses getDefaultKeybinds() and normalizedKeybinds() to
get the default and user changed keybinds.
-- **Fix** in _keybinds_: it now removes a key if it is Unbound by the
user in **UserSettingsModal**. Instead of first loading the
parsedUserKeybinds, removing "Null" keys from it, and then merging that
with defaultKeybinds (so default key would overwrite an unbound key), we
now merge parsedUserKeybinds with defaultKeybinds and after that remove
"Null" keys from it (so that unbound key stays removed).
For example if Boat Attack Up is set to "None" ("Null") by clicking
Unbind, there is now no hotkey working for it anymore. Even when the
default is "B".
Why? This prevents the user from being confused, they have deliberately
Unbound it, they don't understand why it still works (have seen bug
reports and game feedback about this)? Also more importantly: they used
to now be able to bind "B" to another action. Effectively making key "B"
bound to two actions: the user choosen one and Boat Attack. This also
makes the logic more consistent. Because building hotkeys in
**UnitDisplay** already didn't work when unbound, eg. when Build Missile
Silo was Unbound, the "5" key did not do anything anymore (there is a
fallback in **UnitDisplay** in case the key is actually null, but it
does respect "Null" as it should).
-- _setKeybinds_: have it accept an object, it stringifies it itself.
Callers UserSettingsModal and InputHandler.test.ts now just send either
a string or an object.

- **InputHandler.test.ts**: 
-- use **UserSettings** functions instead of localStorage for more
real-world testing.
-- change test "ignores non-string values and preserves defaults,
removes 'Null' for unbound keys". As explained above, as a fix we no
longer preserve unbound ("Null") keys within InputHandler.
UserSettings.keybinds() now removes "Null" keys as explained above.

- ControlPanel: use UserSettings to fetch initial attack ratio.

## 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
This commit is contained in:
VariableVince
2026-04-14 00:56:32 +02:00
committed by GitHub
parent 4354273af3
commit 318d1e2c44
7 changed files with 179 additions and 272 deletions
+2 -50
View File
@@ -2,6 +2,7 @@ import { html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { translateText, TUTORIAL_VIDEO_URL } from "../client/Utils";
import { assetUrl } from "../core/AssetUrls";
import { UserSettings } from "../core/game/UserSettings";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import { modalHeader } from "./components/ui/ModalHeader";
@@ -13,57 +14,8 @@ export class HelpModal extends BaseModal {
@state() private keybinds: Record<string, string> = this.getKeybinds();
@query("#tutorial-video-iframe") private videoIframe?: HTMLIFrameElement;
private isKeybindObject(v: unknown): v is { value: string } {
return (
typeof v === "object" &&
v !== null &&
"value" in v &&
typeof (v as any).value === "string"
);
}
private getKeybinds(): Record<string, string> {
let saved: Record<string, string> = {};
try {
const parsed = JSON.parse(
localStorage.getItem("settings.keybinds") ?? "{}",
);
saved = Object.fromEntries(
Object.entries(parsed)
.map(([k, v]) => {
if (this.isKeybindObject(v)) return [k, v.value];
if (typeof v === "string") return [k, v];
return [k, undefined];
})
.filter(([, v]) => typeof v === "string" && v !== "Null"),
) as Record<string, string>;
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
}
const isMac = Platform.isMac;
return {
toggleView: "Space",
coordinateGrid: "KeyM",
centerCamera: "KeyC",
moveUp: "KeyW",
moveDown: "KeyS",
moveLeft: "KeyA",
moveRight: "KeyD",
zoomOut: "KeyQ",
zoomIn: "KeyE",
attackRatioDown: "KeyT",
attackRatioUp: "KeyY",
swapDirection: "KeyU",
shiftKey: "ShiftLeft",
modifierKey: isMac ? "MetaLeft" : "ControlLeft",
altKey: "AltLeft",
resetGfx: "KeyR",
pauseGame: "KeyP",
gameSpeedUp: "Period",
gameSpeedDown: "Comma",
...saved,
};
return new UserSettings().keybinds(Platform.isMac);
}
private getKeyLabel(code: string): string {
+1 -64
View File
@@ -184,70 +184,7 @@ export class InputHandler {
) {}
initialize() {
let saved: Record<string, string> = {};
try {
const parsed = JSON.parse(
localStorage.getItem("settings.keybinds") ?? "{}",
);
// flatten { key: {key, value} } → { key: value } and accept legacy string values
saved = Object.fromEntries(
Object.entries(parsed)
.map(([k, v]) => {
// Extract value from nested object or plain string
let val: unknown;
if (v && typeof v === "object" && "value" in v) {
val = (v as { value: unknown }).value;
} else {
val = v;
}
// Map invalid values to undefined (filtered later)
if (typeof val !== "string") {
return [k, undefined];
}
return [k, val];
})
.filter(([, v]) => typeof v === "string"),
) as Record<string, string>;
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
}
// Mac users might have different keybinds
const isMac = Platform.isMac;
this.keybinds = {
toggleView: "Space",
coordinateGrid: "KeyM",
centerCamera: "KeyC",
moveUp: "KeyW",
moveDown: "KeyS",
moveLeft: "KeyA",
moveRight: "KeyD",
zoomOut: "KeyQ",
zoomIn: "KeyE",
attackRatioDown: "KeyT",
attackRatioUp: "KeyY",
boatAttack: "KeyB",
groundAttack: "KeyG",
swapDirection: "KeyU",
modifierKey: isMac ? "MetaLeft" : "ControlLeft",
altKey: "AltLeft",
buildCity: "Digit1",
buildFactory: "Digit2",
buildPort: "Digit3",
buildDefensePost: "Digit4",
buildMissileSilo: "Digit5",
buildSamLauncher: "Digit6",
buildWarship: "Digit7",
buildAtomBomb: "Digit8",
buildHydrogenBomb: "Digit9",
buildMIRV: "Digit0",
pauseGame: "KeyP",
gameSpeedUp: "Period",
gameSpeedDown: "Comma",
...saved,
};
this.keybinds = this.userSettings.keybinds(Platform.isMac);
this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e));
window.addEventListener("pointerup", (e) => this.onPointerUp(e));
+57 -107
View File
@@ -1,7 +1,7 @@
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { formatKeyForDisplay, translateText } from "../client/Utils";
import { UserSettings } from "../core/game/UserSettings";
import { getDefaultKeybinds, UserSettings } from "../core/game/UserSettings";
import "./components/baseComponents/setting/SettingKeybind";
import { SettingKeybind } from "./components/baseComponents/setting/SettingKeybind";
import "./components/baseComponents/setting/SettingNumber";
@@ -12,52 +12,19 @@ import { BaseModal } from "./components/BaseModal";
import { modalHeader } from "./components/ui/ModalHeader";
import { Platform } from "./Platform";
const isMac = Platform.isMac;
const DefaultKeybinds: Record<string, string> = {
toggleView: "Space",
coordinateGrid: "KeyM",
buildCity: "Digit1",
buildFactory: "Digit2",
buildPort: "Digit3",
buildDefensePost: "Digit4",
buildMissileSilo: "Digit5",
buildSamLauncher: "Digit6",
buildWarship: "Digit7",
buildAtomBomb: "Digit8",
buildHydrogenBomb: "Digit9",
buildMIRV: "Digit0",
attackRatioDown: "KeyT",
attackRatioUp: "KeyY",
boatAttack: "KeyB",
groundAttack: "KeyG",
swapDirection: "KeyU",
zoomOut: "KeyQ",
zoomIn: "KeyE",
centerCamera: "KeyC",
moveUp: "KeyW",
moveLeft: "KeyA",
moveDown: "KeyS",
moveRight: "KeyD",
modifierKey: isMac ? "MetaLeft" : "ControlLeft",
altKey: "AltLeft",
pauseGame: "KeyP",
gameSpeedUp: "Period",
gameSpeedDown: "Comma",
};
@customElement("user-setting")
export class UserSettingModal extends BaseModal {
private userSettings: UserSettings = new UserSettings();
private readonly defaultKeybinds = getDefaultKeybinds(Platform.isMac);
@state() private activeTab: "basic" | "keybinds" = "basic";
@state() private keySequence: string[] = [];
@state() private showEasterEggSettings = false;
@state() private keybinds: Record<
@state() private userKeybinds: Record<
string,
{ value: string | string[]; key: string }
{ value: string; key: string }
> = {};
connectedCallback() {
@@ -71,55 +38,39 @@ export class UserSettingModal extends BaseModal {
}
private loadKeybindsFromStorage() {
const savedKeybinds = this.userSettings.keybinds();
if (!savedKeybinds) return;
try {
const parsed = JSON.parse(savedKeybinds);
if (
typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
) {
const isValid = Object.values(parsed).every((entry) => {
if (
typeof entry !== "object" ||
entry === null ||
Array.isArray(entry)
) {
return false;
}
if (!("key" in entry) || typeof (entry as any).key !== "string") {
return false;
}
if (!("value" in entry)) {
return false;
}
const value = (entry as any).value;
if (typeof value === "string") {
return true;
}
if (Array.isArray(value)) {
return value.every((v) => typeof v === "string");
}
return false;
});
if (isValid) {
this.keybinds = parsed;
} else {
console.warn(
"Invalid keybinds structure: entries must be objects with 'key' (string) and 'value' (string or string[]) properties. Ignoring saved data.",
);
}
} else {
console.warn(
"Invalid keybinds data: expected non-array object. Ignoring saved data.",
);
}
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
const parsed = this.userSettings.parsedUserKeybinds();
if (Object.keys(parsed).length === 0) {
this.userKeybinds = {};
return;
}
const validated: Record<string, { value: string; key: string }> = {};
for (const [action, entry] of Object.entries(parsed)) {
if (typeof entry === "string") {
validated[action] = { value: entry, key: entry };
} else if (
typeof entry === "object" &&
entry !== null &&
!Array.isArray(entry)
) {
const rawValue = (entry as any).value ?? "Null";
const value = Array.isArray(rawValue)
? rawValue.find((v) => typeof v === "string")
: rawValue;
const rawKey = (entry as any).key ?? value;
const key = Array.isArray(rawKey)
? rawKey.find((v) => typeof v === "string")
: rawKey;
if (typeof value === "string" && typeof key === "string") {
validated[action] = { value, key };
}
}
}
this.userKeybinds = validated;
}
private handleKeybindChange(
@@ -132,11 +83,9 @@ export class UserSettingModal extends BaseModal {
) {
const { action, value, key, prevValue } = e.detail;
const activeKeybinds: Record<string, string> = { ...DefaultKeybinds };
for (const [k, v] of Object.entries(this.keybinds)) {
const normalizedValue = Array.isArray(v.value)
? v.value[0] || ""
: v.value;
const activeKeybinds = { ...this.defaultKeybinds };
for (const [k, v] of Object.entries(this.userKeybinds)) {
const normalizedValue = v.value;
if (normalizedValue === "Null") {
delete activeKeybinds[k];
} else {
@@ -188,32 +137,33 @@ export class UserSettingModal extends BaseModal {
}),
);
const element = this.renderRoot.querySelector(
const element = this.renderRoot.querySelector<SettingKeybind>(
`setting-keybind[action="${action}"]`,
) as SettingKeybind;
);
if (element) {
element.value = prevValue ?? DefaultKeybinds[action] ?? "";
element.value = prevValue ?? this.defaultKeybinds[action] ?? "";
element.requestUpdate();
}
return;
}
this.keybinds = { ...this.keybinds, [action]: { value: value, key: key } };
this.userSettings.setKeybinds(JSON.stringify(this.keybinds));
this.userKeybinds = {
...this.userKeybinds,
[action]: { value: value, key: key },
};
this.userSettings.setKeybinds(this.userKeybinds);
}
private getKeyValue(action: string): string | undefined {
const entry = this.keybinds[action];
const entry = this.userKeybinds[action];
if (!entry) return undefined;
const normalizedValue = Array.isArray(entry.value)
? entry.value[0] || ""
: entry.value;
const normalizedValue = entry.value;
if (normalizedValue === "Null") return "";
return normalizedValue || undefined;
}
private getKeyChar(action: string): string {
const entry = this.keybinds[action];
const entry = this.userKeybinds[action];
if (!entry) return "";
return entry.key || "";
}
@@ -453,7 +403,7 @@ export class UserSettingModal extends BaseModal {
action="coordinateGrid"
label=${translateText("user_setting.coordinate_grid_label")}
description=${translateText("user_setting.coordinate_grid_desc")}
defaultKey=${DefaultKeybinds.coordinateGrid}
defaultKey=${this.defaultKeybinds.coordinateGrid}
.value=${this.getKeyValue("coordinateGrid")}
.display=${this.getKeyChar("coordinateGrid")}
@change=${this.handleKeybindChange}
@@ -575,7 +525,7 @@ export class UserSettingModal extends BaseModal {
action="modifierKey"
label=${translateText("user_setting.build_menu_modifier")}
description=${translateText("user_setting.build_menu_modifier_desc")}
.defaultKey=${DefaultKeybinds.modifierKey}
.defaultKey=${this.defaultKeybinds.modifierKey}
.value=${this.getKeyValue("modifierKey")}
.display=${this.getKeyChar("modifierKey")}
@change=${this.handleKeybindChange}
@@ -585,7 +535,7 @@ export class UserSettingModal extends BaseModal {
action="altKey"
label=${translateText("user_setting.emoji_menu_modifier")}
description=${translateText("user_setting.emoji_menu_modifier_desc")}
.defaultKey=${DefaultKeybinds.altKey}
.defaultKey=${this.defaultKeybinds.altKey}
.value=${this.getKeyValue("altKey")}
.display=${this.getKeyChar("altKey")}
@change=${this.handleKeybindChange}
@@ -595,7 +545,7 @@ export class UserSettingModal extends BaseModal {
action="pauseGame"
label=${translateText("user_setting.pause_game")}
description=${translateText("user_setting.pause_game_desc")}
.defaultKey=${DefaultKeybinds.pauseGame}
.defaultKey=${this.defaultKeybinds.pauseGame}
.value=${this.getKeyValue("pauseGame")}
.display=${this.getKeyChar("pauseGame")}
@change=${this.handleKeybindChange}
@@ -605,7 +555,7 @@ export class UserSettingModal extends BaseModal {
action="gameSpeedUp"
label=${translateText("user_setting.game_speed_up")}
description=${translateText("user_setting.game_speed_up_desc")}
.defaultKey=${DefaultKeybinds.gameSpeedUp}
.defaultKey=${this.defaultKeybinds.gameSpeedUp}
.value=${this.getKeyValue("gameSpeedUp")}
.display=${this.getKeyChar("gameSpeedUp")}
@change=${this.handleKeybindChange}
@@ -615,7 +565,7 @@ export class UserSettingModal extends BaseModal {
action="gameSpeedDown"
label=${translateText("user_setting.game_speed_down")}
description=${translateText("user_setting.game_speed_down_desc")}
.defaultKey=${DefaultKeybinds.gameSpeedDown}
.defaultKey=${this.defaultKeybinds.gameSpeedDown}
.value=${this.getKeyValue("gameSpeedDown")}
.display=${this.getKeyChar("gameSpeedDown")}
@change=${this.handleKeybindChange}
@@ -681,7 +631,7 @@ export class UserSettingModal extends BaseModal {
action="swapDirection"
label=${translateText("user_setting.swap_direction")}
description=${translateText("user_setting.swap_direction_desc")}
.defaultKey=${DefaultKeybinds.swapDirection}
.defaultKey=${this.defaultKeybinds.swapDirection}
.value=${this.getKeyValue("swapDirection")}
.display=${this.getKeyChar("swapDirection")}
@change=${this.handleKeybindChange}
+2 -3
View File
@@ -4,6 +4,7 @@ import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { Gold } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { ClientID } from "../../../core/Schemas";
import { AttackRatioEvent } from "../../InputHandler";
import { renderNumber, renderTroops } from "../../Utils";
@@ -50,9 +51,7 @@ export class ControlPanel extends LitElement implements Layer {
}
init() {
this.attackRatio = Number(
localStorage.getItem("settings.attackRatio") ?? "0.2",
);
this.attackRatio = new UserSettings().attackRatio();
this.uiState.attackRatio = this.attackRatio;
this.eventBus.on(AttackRatioEvent, (event) => {
let newAttackRatio = this.attackRatio + event.attackRatio / 100;
+3 -8
View File
@@ -10,6 +10,7 @@ import {
UnitType,
} from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import {
GhostStructureChangedEvent,
ToggleStructureEvent,
@@ -52,15 +53,9 @@ export class UnitDisplay extends LitElement implements Layer {
init() {
const config = this.game.config();
const userSettings = new UserSettings();
const savedKeybinds = localStorage.getItem("settings.keybinds");
if (savedKeybinds) {
try {
this.keybinds = JSON.parse(savedKeybinds);
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
}
}
this.keybinds = userSettings.parsedUserKeybinds();
this.allDisabled = BuildMenus.types.every((u) => config.isUnitDisabled(u));
this.requestUpdate();
+93 -5
View File
@@ -1,12 +1,49 @@
import { Cosmetics } from "../CosmeticSchemas";
import { PlayerPattern } from "../Schemas";
export function getDefaultKeybinds(isMac: boolean): Record<string, string> {
return {
toggleView: "Space",
coordinateGrid: "KeyM",
buildCity: "Digit1",
buildFactory: "Digit2",
buildPort: "Digit3",
buildDefensePost: "Digit4",
buildMissileSilo: "Digit5",
buildSamLauncher: "Digit6",
buildWarship: "Digit7",
buildAtomBomb: "Digit8",
buildHydrogenBomb: "Digit9",
buildMIRV: "Digit0",
attackRatioDown: "KeyT",
attackRatioUp: "KeyY",
boatAttack: "KeyB",
groundAttack: "KeyG",
swapDirection: "KeyU",
zoomOut: "KeyQ",
zoomIn: "KeyE",
centerCamera: "KeyC",
moveUp: "KeyW",
moveLeft: "KeyA",
moveDown: "KeyS",
moveRight: "KeyD",
modifierKey: isMac ? "MetaLeft" : "ControlLeft",
altKey: "AltLeft",
shiftKey: "ShiftLeft",
resetGfx: "KeyR",
pauseGame: "KeyP",
gameSpeedUp: "Period",
gameSpeedDown: "Comma",
};
}
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 const KEYBINDS_KEY = "settings.keybinds";
export class UserSettings {
private static cache = new Map<string, string | null>();
@@ -40,7 +77,7 @@ export class UserSettings {
}
}
private removeCached(key: string, emitChange: boolean = true) {
public removeCached(key: string, emitChange: boolean = true) {
localStorage.removeItem(key);
UserSettings.cache.set(key, null);
if (emitChange) {
@@ -283,12 +320,63 @@ export class UserSettings {
this.setFloat("settings.attackRatio", value);
}
keybinds(): string {
return this.getString("settings.keybinds", "");
// In case localStorage was manually edited to be invalid, return an empty object
parsedUserKeybinds(): Record<string, any> {
const raw = this.getString(KEYBINDS_KEY, "{}");
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed;
}
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
}
return {};
}
setKeybinds(value: string): void {
this.setString("settings.keybinds", value);
// Returns a flat keybind map { action: "keyCode" }, handling nested objects and legacy strings
private normalizedUserKeybinds(): Record<string, string> {
const parsed = this.parsedUserKeybinds();
return Object.fromEntries(
Object.entries(parsed)
// Extract value from nested object or plain string, filter out non-string values
.map(([k, v]) => {
let val = v;
if (v && typeof v === "object" && !Array.isArray(v) && "value" in v) {
val = v.value;
}
if (Array.isArray(val) && typeof val[0] === "string") {
val = val[0];
}
return [k, val];
})
.filter(([, v]) => typeof v === "string"),
) as Record<string, string>;
}
keybinds(isMac: boolean): Record<string, string> {
const merged = {
...getDefaultKeybinds(isMac),
...this.normalizedUserKeybinds(),
};
// Actually unbind key: if Unbind is clicked in UserSettingsModal, eg. for Attack Ratio Up,
// keybind is "Null". Even if it is in default kindbinds (Y), it should not work anymore.
// The key (Y) can now be bound to another action like Boat Attack, and no two actions listen to the same key.
for (const k in merged) {
if (merged[k] === "Null") {
delete merged[k];
}
}
return merged;
}
setKeybinds(value: string | Record<string, any>): void {
if (typeof value === "string") {
this.setString(KEYBINDS_KEY, value);
} else {
this.setString(KEYBINDS_KEY, JSON.stringify(value));
}
}
soundEffectsVolume(): number {
+21 -35
View File
@@ -7,6 +7,7 @@ import { UIState } from "../src/client/graphics/UIState";
import { EventBus } from "../src/core/EventBus";
import { UnitType } from "../src/core/game/Game";
import { GameView } from "../src/core/game/GameView";
import { KEYBINDS_KEY, UserSettings } from "../src/core/game/UserSettings";
class MockPointerEvent {
button: number;
@@ -39,8 +40,12 @@ describe("InputHandler AutoUpgrade", () => {
let mockGameView: GameView;
let eventBus: EventBus;
let mockCanvas: HTMLCanvasElement;
let testSettings: UserSettings;
beforeEach(() => {
testSettings = new UserSettings();
testSettings.removeCached(KEYBINDS_KEY, false);
mockGameView = { inSpawnPhase: () => false } as GameView;
mockCanvas = document.createElement("canvas");
mockCanvas.width = 800;
@@ -476,15 +481,11 @@ describe("InputHandler AutoUpgrade", () => {
});
describe("Keybinds JSON parsing", () => {
beforeEach(() => {
localStorage.removeItem("settings.keybinds");
});
test("parses nested object values and flattens them to strings", () => {
const nested = {
moveUp: { key: "moveUp", value: "KeyZ" },
};
localStorage.setItem("settings.keybinds", JSON.stringify(nested));
testSettings.setKeybinds(nested);
inputHandler.initialize();
@@ -492,33 +493,30 @@ describe("InputHandler AutoUpgrade", () => {
});
test("accepts legacy string values", () => {
localStorage.setItem(
"settings.keybinds",
JSON.stringify({ moveUp: "KeyX" }),
);
testSettings.setKeybinds({ moveUp: "KeyX" });
inputHandler.initialize();
expect((inputHandler as any).keybinds.moveUp).toBe("KeyX");
});
test("ignores non-string values and preserves defaults, but keeps 'Null' for unbound keys", () => {
test("ignores non-string values and preserves defaults, removes 'Null' for unbound keys", () => {
const mixed = {
moveUp: { key: "moveUp", value: null },
moveLeft: "Null",
};
localStorage.setItem("settings.keybinds", JSON.stringify(mixed));
testSettings.setKeybinds(mixed);
inputHandler.initialize();
expect((inputHandler as any).keybinds.moveUp).toBe("KeyW");
// "Null" is preserved to indicate unbound keybind
expect((inputHandler as any).keybinds.moveLeft).toBe("Null");
// "Null" entries are removed entirely to indicate unbound keybind
expect((inputHandler as any).keybinds.moveLeft).toBeUndefined();
});
test("handles invalid JSON gracefully and warns", () => {
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
localStorage.setItem("settings.keybinds", "not a json");
testSettings.setKeybinds("not a json");
inputHandler.initialize();
@@ -533,7 +531,6 @@ describe("InputHandler AutoUpgrade", () => {
let uiState: UIState;
beforeEach(() => {
localStorage.removeItem("settings.keybinds");
uiState = {
attackRatio: 20,
ghostStructure: null,
@@ -589,7 +586,6 @@ describe("InputHandler AutoUpgrade", () => {
describe("Numpad number keys for build keybinds", () => {
beforeEach(() => {
localStorage.removeItem("settings.keybinds");
inputHandler.destroy();
const uiState: UIState = {
attackRatio: 20,
@@ -631,7 +627,6 @@ describe("InputHandler AutoUpgrade", () => {
describe("Build keybind two-phase matching (exact code first, then digit/Numpad alias)", () => {
beforeEach(() => {
localStorage.removeItem("settings.keybinds");
inputHandler.destroy();
const uiState: UIState = {
attackRatio: 20,
@@ -650,13 +645,10 @@ describe("InputHandler AutoUpgrade", () => {
});
test("exact code match wins: Digit1 sets City when buildCity=Digit1 and buildFactory=Numpad1", () => {
localStorage.setItem(
"settings.keybinds",
JSON.stringify({
buildCity: "Digit1",
buildFactory: "Numpad1",
}),
);
testSettings.setKeybinds({
buildCity: "Digit1",
buildFactory: "Numpad1",
});
inputHandler.destroy();
const uiState: UIState = {
attackRatio: 20,
@@ -681,13 +673,10 @@ describe("InputHandler AutoUpgrade", () => {
});
test("exact code match wins: Numpad1 sets Factory when buildCity=Digit1 and buildFactory=Numpad1", () => {
localStorage.setItem(
"settings.keybinds",
JSON.stringify({
buildCity: "Digit1",
buildFactory: "Numpad1",
}),
);
testSettings.setKeybinds({
buildCity: "Digit1",
buildFactory: "Numpad1",
});
inputHandler.destroy();
const uiState: UIState = {
attackRatio: 20,
@@ -712,10 +701,7 @@ describe("InputHandler AutoUpgrade", () => {
});
test("digit alias used when no exact match: Numpad1 sets City when only buildCity=Digit1", () => {
localStorage.setItem(
"settings.keybinds",
JSON.stringify({ buildCity: "Digit1" }),
);
testSettings.setKeybinds({ buildCity: "Digit1" });
inputHandler.destroy();
const uiState: UIState = {
attackRatio: 20,