diff --git a/resources/lang/en.json b/resources/lang/en.json index 3e70f89e8..1d7b1625b 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -545,6 +545,7 @@ "title": "Settings", "tab_basic": "Basic Settings", "tab_keybinds": "Keybinds", + "keybinds_hint": "Click a key to rebind it. You can assign a single key or Shift + key combination.", "dark_mode_label": "Dark Mode", "dark_mode_desc": "Toggle the site’s appearance between light and dark themes", "emojis_label": "Emojis", diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 21638fcd0..1782d43d2 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -281,7 +281,7 @@ export class InputHandler { return; } - if (e.code === this.keybinds.toggleView) { + if (this.keybindMatchesEvent(e, this.keybinds.toggleView)) { e.preventDefault(); if (!this.alternateView) { this.alternateView = true; @@ -289,7 +289,10 @@ export class InputHandler { } } - if (e.code === this.keybinds.coordinateGrid && !e.repeat) { + if ( + this.keybindMatchesEvent(e, this.keybinds.coordinateGrid) && + !e.repeat + ) { e.preventDefault(); this.coordinateGridEnabled = !this.coordinateGridEnabled; this.eventBus.emit( @@ -375,7 +378,7 @@ export class InputHandler { this.activeKeys.delete(this.keybinds.zoomOut); } - if (e.code === this.keybinds.toggleView) { + if (this.keybindMatchesEvent(e, this.keybinds.toggleView)) { e.preventDefault(); this.alternateView = false; this.eventBus.emit(new AlternateViewEvent(false)); @@ -387,55 +390,58 @@ export class InputHandler { this.eventBus.emit(new RefreshGraphicsEvent()); } - if (e.code === this.keybinds.boatAttack) { + if (this.keybindMatchesEvent(e, this.keybinds.boatAttack)) { e.preventDefault(); this.eventBus.emit(new DoBoatAttackEvent()); } - if (e.code === this.keybinds.groundAttack) { + if (this.keybindMatchesEvent(e, this.keybinds.groundAttack)) { e.preventDefault(); this.eventBus.emit(new DoGroundAttackEvent()); } - if (e.code === this.keybinds.attackRatioDown) { + if (this.keybindMatchesEvent(e, this.keybinds.attackRatioDown)) { e.preventDefault(); const increment = this.userSettings.attackRatioIncrement(); this.eventBus.emit(new AttackRatioEvent(-increment)); } - if (e.code === this.keybinds.attackRatioUp) { + if (this.keybindMatchesEvent(e, this.keybinds.attackRatioUp)) { e.preventDefault(); const increment = this.userSettings.attackRatioIncrement(); this.eventBus.emit(new AttackRatioEvent(increment)); } - if (e.code === this.keybinds.centerCamera) { + if (this.keybindMatchesEvent(e, this.keybinds.centerCamera)) { e.preventDefault(); this.eventBus.emit(new CenterCameraEvent()); } // Two-phase build keybind matching: exact code match first, then digit/Numpad alias. - const matchedBuild = this.resolveBuildKeybind(e.code); + const matchedBuild = this.resolveBuildKeybind(e.code, e.shiftKey); if (matchedBuild !== null) { e.preventDefault(); this.setGhostStructure(matchedBuild); } - if (e.code === this.keybinds.swapDirection) { + if (this.keybindMatchesEvent(e, this.keybinds.swapDirection)) { e.preventDefault(); const nextDirection = !this.uiState.rocketDirectionUp; this.eventBus.emit(new SwapRocketDirectionEvent(nextDirection)); } - if (!e.repeat && e.code === this.keybinds.pauseGame) { + if (!e.repeat && this.keybindMatchesEvent(e, this.keybinds.pauseGame)) { e.preventDefault(); this.eventBus.emit(new TogglePauseIntentEvent()); } - if (!e.repeat && e.code === this.keybinds.gameSpeedUp) { + if (!e.repeat && this.keybindMatchesEvent(e, this.keybinds.gameSpeedUp)) { e.preventDefault(); this.eventBus.emit(new GameSpeedUpIntentEvent()); } - if (!e.repeat && e.code === this.keybinds.gameSpeedDown) { + if ( + !e.repeat && + this.keybindMatchesEvent(e, this.keybinds.gameSpeedDown) + ) { e.preventDefault(); this.eventBus.emit(new GameSpeedDownIntentEvent()); } @@ -615,6 +621,27 @@ export class InputHandler { this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure)); } + /** + * Parses a keybind value that may include a "Shift+" prefix. + * e.g. "Shift+KeyB" → { shift: true, code: "KeyB" } + * "KeyB" → { shift: false, code: "KeyB" } + */ + private parseKeybind(value: string): { shift: boolean; code: string } { + if (value?.startsWith("Shift+")) { + return { shift: true, code: value.slice(6) }; + } + return { shift: false, code: value }; + } + + /** + * Returns true if the keyboard event matches the given keybind value, + * including optional Shift+ prefix support. + */ + private keybindMatchesEvent(e: KeyboardEvent, keybindValue: string): boolean { + const parsed = this.parseKeybind(keybindValue); + return e.code === parsed.code && e.shiftKey === parsed.shift; + } + /** * Extracts the digit character from KeyboardEvent.code. * Codes look like "Digit0".."Digit9" (6 chars, digit at index 5) and @@ -637,17 +664,25 @@ export class InputHandler { } /** Strict equality only: used for first-pass exact KeyboardEvent.code match. */ - private buildKeybindMatches(code: string, keybindValue: string): boolean { - return code === keybindValue; + private buildKeybindMatches( + code: string, + shiftKey: boolean, + keybindValue: string, + ): boolean { + const parsed = this.parseKeybind(keybindValue); + return code === parsed.code && shiftKey === parsed.shift; } /** Digit/Numpad alias match: used only when no exact match was found. */ private buildKeybindMatchesDigit( code: string, + shiftKey: boolean, keybindValue: string, ): boolean { + const parsed = this.parseKeybind(keybindValue); + if (shiftKey !== parsed.shift) return false; const digit = this.digitFromKeyCode(code); - const bindDigit = this.digitFromKeyCode(keybindValue); + const bindDigit = this.digitFromKeyCode(parsed.code); return digit !== null && bindDigit !== null && digit === bindDigit; } @@ -655,7 +690,10 @@ export class InputHandler { * Resolves a keyup code to a build action: exact code match first, then digit/Numpad alias. * Returns the UnitType to set as ghost, or null if no build keybind matched. */ - private resolveBuildKeybind(code: string): PlayerBuildableUnitType | null { + private resolveBuildKeybind( + code: string, + shiftKey: boolean, + ): PlayerBuildableUnitType | null { const buildKeybinds: ReadonlyArray<{ key: string; type: PlayerBuildableUnitType; @@ -672,10 +710,12 @@ export class InputHandler { { key: "buildMIRV", type: UnitType.MIRV }, ]; for (const { key, type } of buildKeybinds) { - if (this.buildKeybindMatches(code, this.keybinds[key])) return type; + if (this.buildKeybindMatches(code, shiftKey, this.keybinds[key])) + return type; } for (const { key, type } of buildKeybinds) { - if (this.buildKeybindMatchesDigit(code, this.keybinds[key])) return type; + if (this.buildKeybindMatchesDigit(code, shiftKey, this.keybinds[key])) + return type; } return null; } diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 220051b7f..77a0f33d1 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -383,6 +383,26 @@ export class UserSettingModal extends BaseModal { private renderKeybindSettings() { return html` +
+ + + + ${translateText("user_setting.keybinds_hint")} +
+

diff --git a/src/client/Utils.ts b/src/client/Utils.ts index 419d1fe21..e4a0c948f 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -314,6 +314,11 @@ export function formatKeyForDisplay(value: string): string { // Handle empty string if (!value) return ""; + // Handle Shift+ prefix: format as "Shift+X" + if (value.startsWith("Shift+")) { + return "Shift+" + formatKeyForDisplay(value.slice(6)); + } + // Handle space character or "Space" key if (value === " " || value === "Space") return "Space"; diff --git a/src/client/components/baseComponents/setting/SettingKeybind.ts b/src/client/components/baseComponents/setting/SettingKeybind.ts index e72b52867..9725db81f 100644 --- a/src/client/components/baseComponents/setting/SettingKeybind.ts +++ b/src/client/components/baseComponents/setting/SettingKeybind.ts @@ -100,17 +100,32 @@ export class SettingKeybind extends LitElement { return; } + // Don't capture lone modifier keys — wait for the actual key + if ( + e.code === "ShiftLeft" || + e.code === "ShiftRight" || + e.code === "ControlLeft" || + e.code === "ControlRight" || + e.code === "AltLeft" || + e.code === "AltRight" || + e.code === "MetaLeft" || + e.code === "MetaRight" + ) { + return; + } + // Prevent default only for keys we're actually capturing e.preventDefault(); - const code = e.code; + const code = e.shiftKey ? `Shift+${e.code}` : e.code; + const displayKey = e.shiftKey ? `Shift+${e.key.toUpperCase()}` : e.key; const prevValue = this.value; // Temporarily set the value to the new code for validation in parent this.value = code; const event = new CustomEvent("change", { - detail: { action: this.action, value: code, key: e.key, prevValue }, + detail: { action: this.action, value: code, key: displayKey, prevValue }, bubbles: true, composed: true, }); diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index a9bd708f9..15185ff20 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -725,4 +725,138 @@ describe("InputHandler AutoUpgrade", () => { expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.City); }); }); + + describe("Shift+ keybind support", () => { + let uiState: UIState; + + beforeEach(() => { + inputHandler.destroy(); + uiState = { + attackRatio: 20, + ghostStructure: null, + rocketDirectionUp: true, + overlappingRailroads: [], + ghostRailPaths: [], + } as UIState; + }); + + test("Shift+Digit1 sets City when buildCity is bound to Shift+Digit1", () => { + testSettings.setKeybinds({ buildCity: "Shift+Digit1" }); + inputHandler = new InputHandler( + mockGameView, + uiState, + mockCanvas, + eventBus, + ); + inputHandler.initialize(); + + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "Digit1", shiftKey: true }), + ); + + expect(uiState.ghostStructure).toBe(UnitType.City); + }); + + test("plain Digit1 does NOT trigger buildCity when bound to Shift+Digit1", () => { + testSettings.setKeybinds({ buildCity: "Shift+Digit1" }); + inputHandler = new InputHandler( + mockGameView, + uiState, + mockCanvas, + eventBus, + ); + inputHandler.initialize(); + + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "Digit1", shiftKey: false }), + ); + + expect(uiState.ghostStructure).toBeNull(); + }); + + test("Shift+KeyB triggers boatAttack when bound to Shift+KeyB", () => { + testSettings.setKeybinds({ boatAttack: "Shift+KeyB" }); + inputHandler = new InputHandler( + mockGameView, + uiState, + mockCanvas, + eventBus, + ); + inputHandler.initialize(); + + const mockEmit = vi.spyOn(eventBus, "emit"); + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "KeyB", shiftKey: true }), + ); + + const emittedTypes = mockEmit.mock.calls.map( + (call) => call[0].constructor.name, + ); + expect(emittedTypes).toContain("DoBoatAttackEvent"); + }); + + test("plain KeyB does NOT trigger boatAttack when bound to Shift+KeyB", () => { + testSettings.setKeybinds({ boatAttack: "Shift+KeyB" }); + inputHandler = new InputHandler( + mockGameView, + uiState, + mockCanvas, + eventBus, + ); + inputHandler.initialize(); + + const mockEmit = vi.spyOn(eventBus, "emit"); + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "KeyB", shiftKey: false }), + ); + + const emittedTypes = mockEmit.mock.calls.map( + (call) => call[0].constructor.name, + ); + expect(emittedTypes).not.toContain("DoBoatAttackEvent"); + }); + + test("Shift+Digit1 and Digit1 can be bound to different actions without conflict", () => { + testSettings.setKeybinds({ + buildCity: "Digit1", + buildFactory: "Shift+Digit1", + }); + inputHandler = new InputHandler( + mockGameView, + uiState, + mockCanvas, + eventBus, + ); + inputHandler.initialize(); + + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "Digit1", shiftKey: false }), + ); + expect(uiState.ghostStructure).toBe(UnitType.City); + + uiState.ghostStructure = null; + + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "Digit1", shiftKey: true }), + ); + expect(uiState.ghostStructure).toBe(UnitType.Factory); + }); + + test("Numpad alias works with Shift+Digit keybind", () => { + testSettings.setKeybinds({ buildCity: "Shift+Digit1" }); + inputHandler = new InputHandler( + mockGameView, + uiState, + mockCanvas, + eventBus, + ); + inputHandler.initialize(); + + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "Numpad1", shiftKey: true }), + ); + + expect(uiState.ghostStructure).toBe(UnitType.City); + }); + }); });