diff --git a/resources/lang/en.json b/resources/lang/en.json index eeea5f18f..c4e8426cc 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -542,6 +542,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", @@ -603,9 +604,9 @@ "build_mirv_desc": "Build a MIRV under your cursor.", "menu_shortcuts": "Menu Shortcuts", "build_menu_modifier": "Build Menu Modifier", - "build_menu_modifier_desc": "Hold this key while clicking to open the build menu.", + "build_menu_modifier_desc": "Hold this key while clicking to open the legacy build menu. No Shift.", "emoji_menu_modifier": "Emoji Menu Modifier", - "emoji_menu_modifier_desc": "Hold this key while clicking to open the emoji menu.", + "emoji_menu_modifier_desc": "Hold this key while clicking to open the emoji menu. No Shift.", "pause_game": "Pause", "pause_game_desc": "Pause or resume the game (single player and custom games for host).", "game_speed_up": "Game Speed Up", diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index 261a0b0d7..418efb1a2 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -1,8 +1,12 @@ import { html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; -import { translateText, TUTORIAL_VIDEO_URL } from "../client/Utils"; +import { + formatKeyForDisplay, + translateText, + TUTORIAL_VIDEO_URL, +} from "../client/Utils"; import { assetUrl } from "../core/AssetUrls"; -import { UserSettings } from "../core/game/UserSettings"; +import { KeybindAction, UserSettings } from "../core/game/UserSettings"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; import { modalHeader } from "./components/ui/ModalHeader"; @@ -11,46 +15,15 @@ import { TroubleshootingModal } from "./TroubleshootingModal"; @customElement("help-modal") export class HelpModal extends BaseModal { - @state() private keybinds: Record = this.getKeybinds(); + @state() private keybinds: Record = this.getKeybinds(); @query("#tutorial-video-iframe") private videoIframe?: HTMLIFrameElement; - private getKeybinds(): Record { + private getKeybinds(): Record { return new UserSettings().keybinds(Platform.isMac); } - private getKeyLabel(code: string): string { - if (!code) return ""; - - const specialLabels: Record = { - ShiftLeft: "⇧ Shift", - ShiftRight: "⇧ Shift", - ControlLeft: "Ctrl", - ControlRight: "Ctrl", - AltLeft: "Alt", - AltRight: "Alt", - MetaLeft: "⌘", - MetaRight: "⌘", - Space: "Space", - Escape: "Esc", - Enter: "↵ Return", - ArrowUp: "↑", - ArrowDown: "↓", - ArrowLeft: "←", - ArrowRight: "→", - Period: ">", - Comma: "<", - }; - - if (specialLabels[code]) return specialLabels[code]; - if (code.startsWith("Key") && code.length === 4) return code.slice(3); - if (code.startsWith("Digit")) return code.slice(5); - if (code.startsWith("Numpad")) return `Num ${code.slice(6)}`; - - return code; - } - private renderKey(code: string) { - const label = this.getKeyLabel(code); + const label = formatKeyForDisplay(code); return html`${label}
- ${this.renderKey(keybinds.modifierKey)} + ${this.renderKey(keybinds.buildMenuModifier)} +
- ${this.renderKey(keybinds.altKey)} + ${this.renderKey(keybinds.emojiMenuModifier)} +
- ${this.renderKey(keybinds.altKey)} + ${this.renderKey(keybinds.emojiMenuModifier)} + ${this.renderKey(keybinds.resetGfx)}
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 21638fcd0..b1deca441 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -1,7 +1,7 @@ import { EventBus, GameEvent } from "../core/EventBus"; import { PlayerBuildableUnitType, UnitType } from "../core/game/Game"; import { GameView, UnitView } from "../core/game/GameView"; -import { UserSettings } from "../core/game/UserSettings"; +import { KeybindAction, UserSettings } from "../core/game/UserSettings"; import { UIState } from "./graphics/UIState"; import { Platform } from "./Platform"; import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier"; @@ -168,7 +168,7 @@ export class InputHandler { private moveInterval: NodeJS.Timeout | null = null; private activeKeys = new Set(); - private keybinds: Record = {}; + private keybinds: Record; private coordinateGridEnabled = false; private readonly PAN_SPEED = 5; @@ -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( @@ -342,6 +345,8 @@ export class InputHandler { this.keybinds.attackRatioDown, this.keybinds.attackRatioUp, this.keybinds.centerCamera, + this.keybinds.buildMenuModifier, + this.keybinds.emojiMenuModifier, "ControlLeft", "ControlRight", "ShiftLeft", @@ -375,67 +380,69 @@ 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)); } - const resetKey = this.keybinds.resetGfx ?? "KeyR"; - if (e.code === resetKey && this.isAltKeyHeld(e)) { + if (this.keybindMatchesEvent(e, this.keybinds.resetGfx) && e.altKey) { e.preventDefault(); 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()); } @@ -490,11 +497,11 @@ export class InputHandler { this.pointerDown = false; this.pointers.clear(); - if (this.isModifierKeyPressed(event)) { + if (this.isMenuModifierPressed(event, this.keybinds.buildMenuModifier)) { this.eventBus.emit(new ShowBuildMenuEvent(event.clientX, event.clientY)); return; } - if (this.isAltKeyPressed(event)) { + if (this.isMenuModifierPressed(event, this.keybinds.emojiMenuModifier)) { this.eventBus.emit(new ShowEmojiMenuEvent(event.clientX, event.clientY)); return; } @@ -615,6 +622,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 +665,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,27 +691,32 @@ 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; + action: KeybindAction; type: PlayerBuildableUnitType; }> = [ - { key: "buildCity", type: UnitType.City }, - { key: "buildFactory", type: UnitType.Factory }, - { key: "buildPort", type: UnitType.Port }, - { key: "buildDefensePost", type: UnitType.DefensePost }, - { key: "buildMissileSilo", type: UnitType.MissileSilo }, - { key: "buildSamLauncher", type: UnitType.SAMLauncher }, - { key: "buildAtomBomb", type: UnitType.AtomBomb }, - { key: "buildHydrogenBomb", type: UnitType.HydrogenBomb }, - { key: "buildWarship", type: UnitType.Warship }, - { key: "buildMIRV", type: UnitType.MIRV }, + { action: KeybindAction.buildCity, type: UnitType.City }, + { action: KeybindAction.buildFactory, type: UnitType.Factory }, + { action: KeybindAction.buildPort, type: UnitType.Port }, + { action: KeybindAction.buildDefensePost, type: UnitType.DefensePost }, + { action: KeybindAction.buildMissileSilo, type: UnitType.MissileSilo }, + { action: KeybindAction.buildSamLauncher, type: UnitType.SAMLauncher }, + { action: KeybindAction.buildAtomBomb, type: UnitType.AtomBomb }, + { action: KeybindAction.buildHydrogenBomb, type: UnitType.HydrogenBomb }, + { action: KeybindAction.buildWarship, type: UnitType.Warship }, + { action: KeybindAction.buildMIRV, type: UnitType.MIRV }, ]; - for (const { key, type } of buildKeybinds) { - if (this.buildKeybindMatches(code, this.keybinds[key])) return type; + for (const { action, type } of buildKeybinds) { + if (this.buildKeybindMatches(code, shiftKey, this.keybinds[action])) + return type; } - for (const { key, type } of buildKeybinds) { - if (this.buildKeybindMatchesDigit(code, this.keybinds[key])) return type; + for (const { action, type } of buildKeybinds) { + if (this.buildKeybindMatchesDigit(code, shiftKey, this.keybinds[action])) + return type; } return null; } @@ -718,65 +759,19 @@ export class InputHandler { this.activeKeys.clear(); } - isModifierKeyPressed(event: PointerEvent): boolean { + private isMenuModifierPressed(event: PointerEvent, modifierBind: string): boolean { + // Don't accept Shift: menus are meant to be modifier+click, + // but Shift+click is hardcoded for attack when leftClickOpensMenu is false. + // Do allow other keys than strict modifier keys by definition (like "keyU"), + // because non-modifier keys could already be assigned in UserSettingModal. return ( - ((this.keybinds.modifierKey === "AltLeft" || - this.keybinds.modifierKey === "AltRight") && + ((modifierBind === "AltLeft" || modifierBind === "AltRight") && event.altKey) || - ((this.keybinds.modifierKey === "ControlLeft" || - this.keybinds.modifierKey === "ControlRight") && + ((modifierBind === "ControlLeft" || modifierBind === "ControlRight") && event.ctrlKey) || - ((this.keybinds.modifierKey === "ShiftLeft" || - this.keybinds.modifierKey === "ShiftRight") && - event.shiftKey) || - ((this.keybinds.modifierKey === "MetaLeft" || - this.keybinds.modifierKey === "MetaRight") && - event.metaKey) - ); - } - - private isAltKeyHeld(event: KeyboardEvent): boolean { - if ( - this.keybinds.altKey === "AltLeft" || - this.keybinds.altKey === "AltRight" - ) { - return event.altKey && !event.ctrlKey; - } - if ( - this.keybinds.altKey === "ControlLeft" || - this.keybinds.altKey === "ControlRight" - ) { - return event.ctrlKey; - } - if ( - this.keybinds.altKey === "ShiftLeft" || - this.keybinds.altKey === "ShiftRight" - ) { - return event.shiftKey; - } - if ( - this.keybinds.altKey === "MetaLeft" || - this.keybinds.altKey === "MetaRight" - ) { - return event.metaKey; - } - return false; - } - - isAltKeyPressed(event: PointerEvent): boolean { - return ( - ((this.keybinds.altKey === "AltLeft" || - this.keybinds.altKey === "AltRight") && - event.altKey) || - ((this.keybinds.altKey === "ControlLeft" || - this.keybinds.altKey === "ControlRight") && - event.ctrlKey) || - ((this.keybinds.altKey === "ShiftLeft" || - this.keybinds.altKey === "ShiftRight") && - event.shiftKey) || - ((this.keybinds.altKey === "MetaLeft" || - this.keybinds.altKey === "MetaRight") && - event.metaKey) + ((modifierBind === "MetaLeft" || modifierBind === "MetaRight") && + event.metaKey) || + this.activeKeys.has(modifierBind) ); } } diff --git a/src/client/Main.ts b/src/client/Main.ts index f4fb076e9..388b00648 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -58,6 +58,7 @@ import "./UsernameInput"; import { genAnonUsername, UsernameInput } from "./UsernameInput"; import { getDiscordAvatarUrl, + getKeyboardLayoutMap, incrementGamesPlayed, isInIframe, translateText, @@ -257,6 +258,8 @@ class Client { // Prefetch turnstile token so it is available when // the user joins a lobby. this.turnstileTokenPromise = getTurnstileToken(); + // Prefetch keyboard layout for use by formatKeyForDisplay + getKeyboardLayoutMap(); // Wait for components to render before setting version await customElements.whenDefined("mobile-nav-bar"); diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 220051b7f..d7efe32bc 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -1,7 +1,12 @@ import { html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { formatKeyForDisplay, translateText } from "../client/Utils"; -import { getDefaultKeybinds, UserSettings } from "../core/game/UserSettings"; +import { + getDefaultKeybinds, + KeybindAction, + KeyUnbound, + UserSettings, +} from "../core/game/UserSettings"; import "./components/baseComponents/setting/SettingKeybind"; import { SettingKeybind } from "./components/baseComponents/setting/SettingKeybind"; import "./components/baseComponents/setting/SettingNumber"; @@ -22,9 +27,8 @@ export class UserSettingModal extends BaseModal { @state() private keySequence: string[] = []; @state() private showEasterEggSettings = false; - @state() private userKeybinds: Record< - string, - { value: string; key: string } + @state() private userKeybinds: Partial< + Record > = {}; connectedCallback() { @@ -44,7 +48,9 @@ export class UserSettingModal extends BaseModal { return; } - const validated: Record = {}; + const validated: Partial< + Record + > = {}; for (const [action, entry] of Object.entries(parsed)) { if (typeof entry === "string") { @@ -54,12 +60,12 @@ export class UserSettingModal extends BaseModal { entry !== null && !Array.isArray(entry) ) { - const rawValue = (entry as any).value ?? "Null"; + const rawValue = entry.value ?? KeyUnbound; const value = Array.isArray(rawValue) ? rawValue.find((v) => typeof v === "string") : rawValue; - const rawKey = (entry as any).key ?? value; + const rawKey = entry.key ?? value; const key = Array.isArray(rawKey) ? rawKey.find((v) => typeof v === "string") : rawKey; @@ -75,29 +81,38 @@ export class UserSettingModal extends BaseModal { private handleKeybindChange( e: CustomEvent<{ - action: string; + action: KeybindAction; value: string; key: string; prevValue?: string; }>, ) { - const { action, value, key, prevValue } = e.detail; + let { action, value, key, prevValue } = e.detail; + + console.info( + "handleKeybindChange recieved value: " + value, + ", key: " + key, + ); + + // Don't display "Dead" for Quote / Backquote https://en.wikipedia.org/wiki/QWERTY#US-International + // nor "Unidentified" for some keys in Firefox ("" in Chrome). Empty the key to use value (key code). + key = key === "Dead" || key === "Unidentified" ? "" : key; const activeKeybinds = { ...this.defaultKeybinds }; - for (const [k, v] of Object.entries(this.userKeybinds)) { - const normalizedValue = v.value; - if (normalizedValue === "Null") { - delete activeKeybinds[k]; + for (const [action, codeAndKey] of Object.entries(this.userKeybinds)) { + const normalizedCode = codeAndKey.value; + if (normalizedCode === KeyUnbound) { + delete activeKeybinds[action]; } else { - activeKeybinds[k] = normalizedValue; + activeKeybinds[action] = normalizedCode; } } - const values = Object.entries(activeKeybinds) - .filter(([k]) => k !== action) - .map(([, v]) => v); + const codes = Object.entries(activeKeybinds) + .filter(([a]) => a !== action) + .map(([, code]) => code); - if (values.includes(value) && value !== "Null") { + if (codes.includes(value) && value !== KeyUnbound) { const displayKey = formatKeyForDisplay(key || value); window.dispatchEvent( new CustomEvent("show-message", { @@ -142,7 +157,6 @@ export class UserSettingModal extends BaseModal { ); if (element) { element.value = prevValue ?? this.defaultKeybinds[action] ?? ""; - element.requestUpdate(); } return; } @@ -151,18 +165,18 @@ export class UserSettingModal extends BaseModal { ...this.userKeybinds, [action]: { value: value, key: key }, }; - this.userSettings.setKeybinds(this.userKeybinds); + this.userSettings.setUserKeybinds(this.userKeybinds); } - private getKeyValue(action: string): string | undefined { + private getKeyValue(action: KeybindAction): string | undefined { const entry = this.userKeybinds[action]; if (!entry) return undefined; const normalizedValue = entry.value; - if (normalizedValue === "Null") return ""; + if (normalizedValue === KeyUnbound) return ""; return normalizedValue || undefined; } - private getKeyChar(action: string): string { + private getKeyChar(action: KeybindAction): string { const entry = this.userKeybinds[action]; if (!entry) return ""; return entry.key || ""; @@ -383,6 +397,26 @@ export class UserSettingModal extends BaseModal { private renderKeybindSettings() { return html` +
+ + + + ${translateText("user_setting.keybinds_hint")} +
+

@@ -390,22 +424,22 @@ export class UserSettingModal extends BaseModal {

@@ -416,102 +450,102 @@ export class UserSettingModal extends BaseModal { @@ -522,52 +556,52 @@ export class UserSettingModal extends BaseModal { @@ -578,26 +612,28 @@ export class UserSettingModal extends BaseModal { @@ -608,32 +644,32 @@ export class UserSettingModal extends BaseModal { @@ -644,22 +680,22 @@ export class UserSettingModal extends BaseModal { @@ -670,52 +706,52 @@ export class UserSettingModal extends BaseModal { `; diff --git a/src/client/Utils.ts b/src/client/Utils.ts index 419d1fe21..33c979918 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -295,6 +295,26 @@ export function formatPercentage(value: number): string { return perc.toFixed(1) + "%"; } +let cachedKeyboardLayoutMap: Map | null = null; +let triedGetKeyboardLayoutMap = false; + +export async function getKeyboardLayoutMap(): Promise | null> { + if (triedGetKeyboardLayoutMap) return cachedKeyboardLayoutMap; + + triedGetKeyboardLayoutMap = true; + if (navigator.keyboard) { + try { + cachedKeyboardLayoutMap = await navigator.keyboard.getLayoutMap(); + } catch (e) { + console.warn("Failed to fetch keyboard layout map", e); + } + } + return cachedKeyboardLayoutMap; +} + /** * Formats a keyboard key code for user-friendly display. * Handles empty values, spaces, and normalizes key codes like "Digit1" and "KeyA". @@ -311,12 +331,31 @@ export function formatPercentage(value: number): string { * formatKeyForDisplay("") // returns "" */ export function formatKeyForDisplay(value: string): string { + // TODO remove after testing + console.info("formatKeyForDisplay recieved: " + value); + // Handle empty string if (!value) return ""; + // Keyboard API years old, only supported in Chromium + // keyboardLayoutMap translates "KeyW" to "Z" on AZERTY for example + // Or layouts, e.g. Thai keyboard, that we may not support in the code below + // It doesn't know about Alt, AltGr, Ctrl etc. though. + if (!triedGetKeyboardLayoutMap) { + getKeyboardLayoutMap(); + } else if (cachedKeyboardLayoutMap) { + const key = cachedKeyboardLayoutMap.get(value); + if (key) return key; + } + // Handle space character or "Space" key if (value === " " || value === "Space") return "Space"; + // Handle Shift+ prefix: format as "Shift+X" + if (value.startsWith("Shift+")) { + return "Shift+" + formatKeyForDisplay(value.slice(6)); + } + // Handle DigitN pattern (e.g., "Digit1" -> "1") if (/^Digit\d$/.test(value)) { return value.replace("Digit", ""); @@ -327,6 +366,57 @@ export function formatKeyForDisplay(value: string): string { return value.replace("Key", ""); } + const physicalMap: Record = { + BracketLeft: "[", + BracketRight: "]", + Backquote: "`", + Quote: "'", + Minus: "-", + Equal: "=", + Semicolon: ";", + Comma: ",", + Period: ".", + Slash: "/", + Backslash: "\\", + Shift: "Shift ⇧", + ShiftLeft: "Shift ⇧", + ShiftRight: "Shift ⇧", + Control: "Ctrl", + // "Alt Gr" emits ControlLeft+Alt in Windows on many keyboard layouts and we catch the first code. + // Is undiscernable normally from ControlLeft but user sees "alt gr" on the key so display it too + ControlLeft: "Ctrl / Alt Gr", + ControlRight: "Ctrl", + Alt: Platform.isMac ? "⌥" : "Alt", + AltLeft: Platform.isMac ? "⌥" : "Alt", + AltRight: Platform.isMac ? "⌥" : "Alt", + Metat: Platform.isMac ? "⌘" : "⊞", + MetaLeft: Platform.isMac ? "⌘" : "⊞", //"⊞" is Windows key, "⌘" is Command key on Mac + MetaRight: Platform.isMac ? "⌘" : "⊞", + Escape: "Esc", // Cannot be bound to action by user, but used as reserved key + Enter: Platform.isMac ? "↵ Return" : "↵ Enter", // Called "Return" on Mac, "Enter" on Windows + ArrowUp: "↑", + ArrowDown: "↓", + ArrowLeft: "←", + ArrowRight: "→", + NumpadAdd: "Num +", + NumpadMultiply: "Num *", + NumpadSubtract: "Num -", + NumpadDivide: "Num /", + NumpadDecimal: "Num .", + NumpadEnter: "Num Enter", // Called "Enter" on most Mac and Windows keyboards + NumLock: "Num Lock", + }; + + if (physicalMap[value]) { + return physicalMap[value]; + } + + // TODO: display Numpad digits as only 0-9, no Num prefix + // Also handle Numpadx the same as Digitx in InputHandler, just like it already does for building structures + if (value.startsWith("Numpad")) { + return `Num ${value.slice(6)}`; + } + // Fallback: capitalize first letter return value.charAt(0).toUpperCase() + value.slice(1); } @@ -528,14 +618,6 @@ export function getMessageTypeClasses(type: MessageType): string { } } -export function getModifierKey(): string { - return Platform.isMac ? "⌘" : "Ctrl"; -} - -export function getAltKey(): string { - return Platform.isMac ? "⌥" : "Alt"; -} - export function getGamesPlayed(): number { try { return parseInt(localStorage.getItem("gamesPlayed") ?? "0", 10) || 0; diff --git a/src/client/components/baseComponents/setting/SettingKeybind.ts b/src/client/components/baseComponents/setting/SettingKeybind.ts index e72b52867..db2d15a80 100644 --- a/src/client/components/baseComponents/setting/SettingKeybind.ts +++ b/src/client/components/baseComponents/setting/SettingKeybind.ts @@ -1,12 +1,14 @@ -import { LitElement, html } from "lit"; +import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { Platform } from "src/client/Platform"; import { formatKeyForDisplay, translateText } from "../../../../client/Utils"; +import { KeybindAction, KeyUnbound } from "../../../../core/game/UserSettings"; @customElement("setting-keybind") export class SettingKeybind extends LitElement { @property() label = "Setting"; @property() description = ""; - @property({ type: String, reflect: true }) action = ""; + @property({ type: String, reflect: true }) action!: KeybindAction; @property({ type: String }) defaultKey = ""; @property({ type: String }) value = ""; @property({ type: String }) display = ""; @@ -78,7 +80,7 @@ export class SettingKeybind extends LitElement { } private displayKey(key: string): string { - if (!key || key === "Null") return translateText("common.none"); + if (!key || key === KeyUnbound) return translateText("common.none"); return formatKeyForDisplay(key); } @@ -90,6 +92,7 @@ export class SettingKeybind extends LitElement { private handleKeydown(e: KeyboardEvent) { if (!this.listening) return; + // TODO: add Enter, and ARROW KEYS (just like Alt+R, etc should be in new reserved keys enum in UserSettings or so) // Allow Tab and Escape to work normally (don't trap focus) if (e.key === "Tab" || e.key === "Escape") { if (e.key === "Escape") { @@ -100,17 +103,47 @@ export class SettingKeybind extends LitElement { return; } + console.log("Keydown event:", e); + + // On Windows, Meta (Win) key always opens Start Menu + // Don't allow binding, this will lead to frustration + // On Apple, Meta (Cmd) key is commonly used as modifier, so allow it + if ( + Platform.isWindows && + (e.code === "MetaLeft" || e.code === "MetaRight") + ) { + return; + } + + // - Don't capture lone Shift — wait for the actual key + // - Lone Meta (if not Windows), Ctrl and Alt are allowed: for buildMenuModifier and emojiMenuModifier, + // and to prevent setting a Ctrl+key/Alt+key etc combos in the future if code further down would come to accept other combos, + // (to prevent issues with browser combos like Ctrl+T or Alt+N which should keep working) + // and to prevent e.g. AltGr+8 from confusingly showing as e.key "3/4", even when e.code is still just "Digit8", since AltGr is Ctrl+Alt in Windows + if (e.code === "ShiftLeft" || e.code === "ShiftRight") { + return; + } + // Prevent default only for keys we're actually capturing e.preventDefault(); - const code = e.code; + // buildMenuModifier and emojiMenuModifier should not get combo key: + // because they work as 'modifier'+left mouse click already, and + // Shift+click is reserved for attack when leftClickOpensMenu is false. + const noShiftModifier = + this.action === KeybindAction.buildMenuModifier || + this.action === KeybindAction.emojiMenuModifier; + + const code = !noShiftModifier && e.shiftKey ? `Shift+${e.code}` : e.code; + const displayKey = + !noShiftModifier && 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; + 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, }); @@ -142,12 +175,12 @@ export class SettingKeybind extends LitElement { } private unbindKey() { - this.value = "Null"; + this.value = KeyUnbound; this.dispatchEvent( new CustomEvent("change", { detail: { action: this.action, - value: "Null", + value: KeyUnbound, key: "", }, bubbles: true, diff --git a/src/client/graphics/layers/UnitDisplay.ts b/src/client/graphics/layers/UnitDisplay.ts index 29831ab3d..c0aa858e3 100644 --- a/src/client/graphics/layers/UnitDisplay.ts +++ b/src/client/graphics/layers/UnitDisplay.ts @@ -10,7 +10,7 @@ import { UnitType, } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; -import { UserSettings } from "../../../core/game/UserSettings"; +import { KeybindAction, UserSettings } from "../../../core/game/UserSettings"; import { GhostStructureChangedEvent, ToggleStructureEvent, @@ -36,7 +36,9 @@ export class UnitDisplay extends LitElement implements Layer { public eventBus: EventBus; public uiState: UIState; private playerBuildables: BuildableUnit[] | null = null; - private keybinds: Record = {}; + private keybinds: Partial< + Record + > = {}; private _cities = 0; private _warships = 0; private _factories = 0; @@ -53,9 +55,7 @@ export class UnitDisplay extends LitElement implements Layer { init() { const config = this.game.config(); - const userSettings = new UserSettings(); - - this.keybinds = userSettings.parsedUserKeybinds(); + this.keybinds = new UserSettings().parsedUserKeybinds(); this.allDisabled = BuildMenus.types.every((u) => config.isUnitDisabled(u)); this.requestUpdate(); @@ -131,70 +131,70 @@ export class UnitDisplay extends LitElement implements Layer { this._cities, UnitType.City, "city", - this.keybinds["buildCity"]?.key ?? "1", + this.keybinds.buildCity?.key ?? "", )} ${this.renderUnitItem( factoryIcon, this._factories, UnitType.Factory, "factory", - this.keybinds["buildFactory"]?.key ?? "2", + this.keybinds.buildFactory?.key ?? "", )} ${this.renderUnitItem( portIcon, this._port, UnitType.Port, "port", - this.keybinds["buildPort"]?.key ?? "3", + this.keybinds.buildPort?.key ?? "", )} ${this.renderUnitItem( defensePostIcon, this._defensePost, UnitType.DefensePost, "defense_post", - this.keybinds["buildDefensePost"]?.key ?? "4", + this.keybinds.buildDefensePost?.key ?? "", )} ${this.renderUnitItem( missileSiloIcon, this._missileSilo, UnitType.MissileSilo, "missile_silo", - this.keybinds["buildMissileSilo"]?.key ?? "5", + this.keybinds.buildMissileSilo?.key ?? "", )} ${this.renderUnitItem( samLauncherIcon, this._samLauncher, UnitType.SAMLauncher, "sam_launcher", - this.keybinds["buildSamLauncher"]?.key ?? "6", + this.keybinds.buildSamLauncher?.key ?? "", )} ${this.renderUnitItem( warshipIcon, this._warships, UnitType.Warship, "warship", - this.keybinds["buildWarship"]?.key ?? "7", + this.keybinds.buildWarship?.key ?? "", )} ${this.renderUnitItem( atomBombIcon, null, UnitType.AtomBomb, "atom_bomb", - this.keybinds["buildAtomBomb"]?.key ?? "8", + this.keybinds.buildAtomBomb?.key ?? "", )} ${this.renderUnitItem( hydrogenBombIcon, null, UnitType.HydrogenBomb, "hydrogen_bomb", - this.keybinds["buildHydrogenBomb"]?.key ?? "9", + this.keybinds.buildHydrogenBomb?.key ?? "", )} ${this.renderUnitItem( mirvIcon, null, UnitType.MIRV, "mirv", - this.keybinds["buildMIRV"]?.key ?? "0", + this.keybinds.buildMIRV?.key ?? "", )}
diff --git a/src/client/vite-env.d.ts b/src/client/vite-env.d.ts index d00e00e09..fe243f204 100644 --- a/src/client/vite-env.d.ts +++ b/src/client/vite-env.d.ts @@ -34,3 +34,12 @@ declare module "*.webp" { const webpContent: string; export default webpContent; } + +// keyboard API is 'Expirimental' even if 8 years old because only supported in Chromium +// but we want to use it without having to cast 'as any', so define it here. +// https://developer.mozilla.org/en-US/docs/Web/API/Keyboard_API +interface Navigator { + keyboard?: { + getLayoutMap(): Promise>; + }; +} diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 158d10894..8e2508a64 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -1,39 +1,83 @@ import { Cosmetics } from "../CosmeticSchemas"; import { PlayerPattern } from "../Schemas"; -export function getDefaultKeybinds(isMac: boolean): Record { +// ADD Reserved keys here or in Utils.ts: +// (maybe also comment about Shift+left quick being reserved for future devs, see HelpModal, code in onPointerUp in InputHandler: when leftClickOpensMenu is false, Shift+left click is hardcoded to be attack. So it should not be used elsewhere where modifier+click is expected) +// Shift+D for performance overlay, Alt+R for reset gfx (NO not reserved anymore, can be changed with fallback removed), Escape for menu close and cancel ghost build, Enter for confirm Ghost build +// But also browser combos: Ctrl+Shift+I for dev tools, etc. Shift+Tab for backwards tabbing through fields. Alt+N for new browser screen, etc. If we won't just suppress Alt/Ctrl etc alltogether or if used in combination (but then confusing things could still happen) +// These keys won't be available for binding in UserSettingsModal. + +export const KeyUnbound = "Null"; + +export enum KeybindAction { + toggleView = "toggleView", + coordinateGrid = "coordinateGrid", + buildCity = "buildCity", + buildFactory = "buildFactory", + buildPort = "buildPort", + buildDefensePost = "buildDefensePost", + buildMissileSilo = "buildMissileSilo", + buildSamLauncher = "buildSamLauncher", + buildWarship = "buildWarship", + buildAtomBomb = "buildAtomBomb", + buildHydrogenBomb = "buildHydrogenBomb", + buildMIRV = "buildMIRV", + attackRatioDown = "attackRatioDown", + attackRatioUp = "attackRatioUp", + boatAttack = "boatAttack", + groundAttack = "groundAttack", + swapDirection = "swapDirection", + zoomOut = "zoomOut", + zoomIn = "zoomIn", + centerCamera = "centerCamera", + moveUp = "moveUp", + moveLeft = "moveLeft", + moveDown = "moveDown", + moveRight = "moveRight", + buildMenuModifier = "buildMenuModifier", + emojiMenuModifier = "emojiMenuModifier", + shiftKey = "shiftKey", + resetGfx = "resetGfx", + pauseGame = "pauseGame", + gameSpeedUp = "gameSpeedUp", + gameSpeedDown = "gameSpeedDown", +} + +export function getDefaultKeybinds( + isMac: boolean, +): Record { 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", + [KeybindAction.toggleView]: "Space", + [KeybindAction.coordinateGrid]: "KeyM", + [KeybindAction.buildCity]: "Digit1", + [KeybindAction.buildFactory]: "Digit2", + [KeybindAction.buildPort]: "Digit3", + [KeybindAction.buildDefensePost]: "Digit4", + [KeybindAction.buildMissileSilo]: "Digit5", + [KeybindAction.buildSamLauncher]: "Digit6", + [KeybindAction.buildWarship]: "Digit7", + [KeybindAction.buildAtomBomb]: "Digit8", + [KeybindAction.buildHydrogenBomb]: "Digit9", + [KeybindAction.buildMIRV]: "Digit0", + [KeybindAction.attackRatioDown]: "KeyT", + [KeybindAction.attackRatioUp]: "KeyY", + [KeybindAction.boatAttack]: "KeyB", + [KeybindAction.groundAttack]: "KeyG", + [KeybindAction.swapDirection]: "KeyU", + [KeybindAction.zoomOut]: "KeyQ", + [KeybindAction.zoomIn]: "KeyE", + [KeybindAction.centerCamera]: "KeyC", + [KeybindAction.moveUp]: "KeyW", + [KeybindAction.moveLeft]: "KeyA", + [KeybindAction.moveDown]: "KeyS", + [KeybindAction.moveRight]: "KeyD", + [KeybindAction.buildMenuModifier]: isMac ? "MetaLeft" : "ControlLeft", + [KeybindAction.emojiMenuModifier]: "AltLeft", + [KeybindAction.shiftKey]: "ShiftLeft", + [KeybindAction.resetGfx]: "KeyR", + [KeybindAction.pauseGame]: "KeyP", + [KeybindAction.gameSpeedUp]: "Period", + [KeybindAction.gameSpeedDown]: "Comma", }; } @@ -77,7 +121,7 @@ export class UserSettings { } } - public removeCached(key: string, emitChange: boolean = true) { + private removeCached(key: string, emitChange: boolean = true) { localStorage.removeItem(key); UserSettings.cache.set(key, null); if (emitChange) { @@ -321,7 +365,7 @@ export class UserSettings { } // In case localStorage was manually edited to be invalid, return an empty object - parsedUserKeybinds(): Record { + parsedUserKeybinds(): Partial> { const raw = this.getString(KEYBINDS_KEY, "{}"); try { const parsed = JSON.parse(raw); @@ -334,49 +378,54 @@ export class UserSettings { return {}; } - // Returns a flat keybind map { action: "keyCode" }, handling nested objects and legacy strings - private normalizedUserKeybinds(): Record { + // Returns a flat keybind map { action: "code" }, handling nested objects and legacy strings + private normalizedUserKeybinds(): Record { 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; + .map(([action, codeAndKey]) => { + let code = codeAndKey; + if ( + codeAndKey && + typeof codeAndKey === "object" && + !Array.isArray(codeAndKey) && + "value" in codeAndKey + ) { + code = codeAndKey.value; } - if (Array.isArray(val) && typeof val[0] === "string") { - val = val[0]; + if (Array.isArray(code) && typeof code[0] === "string") { + code = code[0]; } - return [k, val]; + return [action, code]; }) - .filter(([, v]) => typeof v === "string"), - ) as Record; + .filter(([, code]) => typeof code === "string"), + ); } - keybinds(isMac: boolean): Record { - const merged = { + keybinds(isMac: boolean): Record { + const mergedKeybinds = { ...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. + // keybind is KeyUnbound. 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]; + for (const action in mergedKeybinds) { + if (mergedKeybinds[action] === KeyUnbound) { + delete mergedKeybinds[action]; } } - return merged; + return mergedKeybinds; } - setKeybinds(value: string | Record): void { - if (typeof value === "string") { - this.setString(KEYBINDS_KEY, value); - } else { - this.setString(KEYBINDS_KEY, JSON.stringify(value)); - } + setUserKeybinds(value: Record): void { + this.setString(KEYBINDS_KEY, JSON.stringify(value)); + } + + removeUserKeybinds(emitChange: boolean = true): void { + this.removeCached(KEYBINDS_KEY, emitChange); } soundEffectsVolume(): number { diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index a9bd708f9..a752460f4 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -7,7 +7,11 @@ 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"; +import { + KEYBINDS_KEY, + KeyUnbound, + UserSettings, +} from "../src/core/game/UserSettings"; class MockPointerEvent { button: number; @@ -38,15 +42,23 @@ global.PointerEvent = MockPointerEvent as any; describe("InputHandler AutoUpgrade", () => { let inputHandler: InputHandler; let mockGameView: GameView; + let mockUIState: UIState; let eventBus: EventBus; let mockCanvas: HTMLCanvasElement; let testSettings: UserSettings; beforeEach(() => { testSettings = new UserSettings(); - testSettings.removeCached(KEYBINDS_KEY, false); + testSettings.removeUserKeybinds(false); mockGameView = { inSpawnPhase: () => false } as GameView; + mockUIState = { + attackRatio: 20, + ghostStructure: null, + rocketDirectionUp: true, + overlappingRailroads: [], + ghostRailPaths: [], + }; mockCanvas = document.createElement("canvas"); mockCanvas.width = 800; mockCanvas.height = 600; @@ -55,13 +67,7 @@ describe("InputHandler AutoUpgrade", () => { inputHandler = new InputHandler( mockGameView, - { - attackRatio: 20, - ghostStructure: null, - rocketDirectionUp: true, - overlappingRailroads: [], - ghostRailPaths: [], - }, + mockUIState, mockCanvas, eventBus, ); @@ -238,6 +244,7 @@ describe("InputHandler AutoUpgrade", () => { mockGameView.inSpawnPhase = () => true; const mockEmit = vi.spyOn(eventBus, "emit"); + inputHandler.initialize(); inputHandler["userSettings"].leftClickOpensMenu = () => true; const pointerEvent = new PointerEvent("pointerup", { @@ -485,7 +492,7 @@ describe("InputHandler AutoUpgrade", () => { const nested = { moveUp: { key: "moveUp", value: "KeyZ" }, }; - testSettings.setKeybinds(nested); + testSettings.setUserKeybinds(nested); inputHandler.initialize(); @@ -493,30 +500,33 @@ describe("InputHandler AutoUpgrade", () => { }); test("accepts legacy string values", () => { - testSettings.setKeybinds({ moveUp: "KeyX" }); + testSettings.setUserKeybinds({ moveUp: "KeyX" }); inputHandler.initialize(); expect((inputHandler as any).keybinds.moveUp).toBe("KeyX"); }); - test("ignores non-string values and preserves defaults, removes 'Null' for unbound keys", () => { + test("ignores non-string values and preserves defaults, removes KeyUnbound keys", () => { const mixed = { moveUp: { key: "moveUp", value: null }, - moveLeft: "Null", + moveLeft: KeyUnbound, }; - testSettings.setKeybinds(mixed); + testSettings.setUserKeybinds(mixed); inputHandler.initialize(); expect((inputHandler as any).keybinds.moveUp).toBe("KeyW"); - // "Null" entries are removed entirely to indicate unbound keybind + // KeyUnbound 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(() => {}); - testSettings.setKeybinds("not a json"); + // in case someone tried to directly save it in localStorage, + // because it's not possible to send a non-Object to setKeybinds + (UserSettings as any).cache.delete(KEYBINDS_KEY); + localStorage.setItem(KEYBINDS_KEY, "not a json"); inputHandler.initialize(); @@ -645,7 +655,7 @@ describe("InputHandler AutoUpgrade", () => { }); test("exact code match wins: Digit1 sets City when buildCity=Digit1 and buildFactory=Numpad1", () => { - testSettings.setKeybinds({ + testSettings.setUserKeybinds({ buildCity: "Digit1", buildFactory: "Numpad1", }); @@ -673,7 +683,7 @@ describe("InputHandler AutoUpgrade", () => { }); test("exact code match wins: Numpad1 sets Factory when buildCity=Digit1 and buildFactory=Numpad1", () => { - testSettings.setKeybinds({ + testSettings.setUserKeybinds({ buildCity: "Digit1", buildFactory: "Numpad1", }); @@ -701,7 +711,7 @@ describe("InputHandler AutoUpgrade", () => { }); test("digit alias used when no exact match: Numpad1 sets City when only buildCity=Digit1", () => { - testSettings.setKeybinds({ buildCity: "Digit1" }); + testSettings.setUserKeybinds({ buildCity: "Digit1" }); inputHandler.destroy(); const uiState: UIState = { attackRatio: 20, @@ -725,4 +735,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.setUserKeybinds({ 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.setUserKeybinds({ 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.setUserKeybinds({ 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.setUserKeybinds({ 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.setUserKeybinds({ + 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.setUserKeybinds({ 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); + }); + }); });