diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 2645ead48..85c928f5c 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -92,6 +92,8 @@ export class GhostStructureChangedEvent implements GameEvent { constructor(public readonly ghostStructure: PlayerBuildableUnitType | null) {} } +export class ConfirmGhostStructureEvent implements GameEvent {} + export class SwapRocketDirectionEvent implements GameEvent { constructor(public readonly rocketDirectionUp: boolean) {} } @@ -339,6 +341,14 @@ export class InputHandler { this.setGhostStructure(null); } + if ( + (e.code === "Enter" || e.code === "NumpadEnter") && + this.uiState.ghostStructure !== null + ) { + e.preventDefault(); + this.eventBus.emit(new ConfirmGhostStructureEvent()); + } + if ( [ this.keybinds.moveUp, @@ -410,54 +420,11 @@ export class InputHandler { this.eventBus.emit(new CenterCameraEvent()); } - if (e.code === this.keybinds.buildCity) { + // Two-phase build keybind matching: exact code match first, then digit/Numpad alias. + const matchedBuild = this.resolveBuildKeybind(e.code); + if (matchedBuild !== null) { e.preventDefault(); - this.setGhostStructure(UnitType.City); - } - - if (e.code === this.keybinds.buildFactory) { - e.preventDefault(); - this.setGhostStructure(UnitType.Factory); - } - - if (e.code === this.keybinds.buildPort) { - e.preventDefault(); - this.setGhostStructure(UnitType.Port); - } - - if (e.code === this.keybinds.buildDefensePost) { - e.preventDefault(); - this.setGhostStructure(UnitType.DefensePost); - } - - if (e.code === this.keybinds.buildMissileSilo) { - e.preventDefault(); - this.setGhostStructure(UnitType.MissileSilo); - } - - if (e.code === this.keybinds.buildSamLauncher) { - e.preventDefault(); - this.setGhostStructure(UnitType.SAMLauncher); - } - - if (e.code === this.keybinds.buildAtomBomb) { - e.preventDefault(); - this.setGhostStructure(UnitType.AtomBomb); - } - - if (e.code === this.keybinds.buildHydrogenBomb) { - e.preventDefault(); - this.setGhostStructure(UnitType.HydrogenBomb); - } - - if (e.code === this.keybinds.buildWarship) { - e.preventDefault(); - this.setGhostStructure(UnitType.Warship); - } - - if (e.code === this.keybinds.buildMIRV) { - e.preventDefault(); - this.setGhostStructure(UnitType.MIRV); + this.setGhostStructure(matchedBuild); } if (e.code === this.keybinds.swapDirection) { @@ -616,6 +583,71 @@ export class InputHandler { this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure)); } + /** + * Extracts the digit character from KeyboardEvent.code. + * Codes look like "Digit0".."Digit9" (6 chars, digit at index 5) and + * "Numpad0".."Numpad9" (7 chars, digit at index 6). Returns null if not a digit key. + */ + private digitFromKeyCode(code: string): string | null { + if ( + code?.length === 6 && + code.startsWith("Digit") && + /^[0-9]$/.test(code[5]) + ) + return code[5]; + if ( + code?.length === 7 && + code.startsWith("Numpad") && + /^[0-9]$/.test(code[6]) + ) + return code[6]; + return null; + } + + /** Strict equality only: used for first-pass exact KeyboardEvent.code match. */ + private buildKeybindMatches(code: string, keybindValue: string): boolean { + return code === keybindValue; + } + + /** Digit/Numpad alias match: used only when no exact match was found. */ + private buildKeybindMatchesDigit( + code: string, + keybindValue: string, + ): boolean { + const digit = this.digitFromKeyCode(code); + const bindDigit = this.digitFromKeyCode(keybindValue); + return digit !== null && bindDigit !== null && digit === bindDigit; + } + + /** + * 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 { + const buildKeybinds: ReadonlyArray<{ + key: string; + 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 }, + ]; + for (const { key, type } of buildKeybinds) { + if (this.buildKeybindMatches(code, this.keybinds[key])) return type; + } + for (const { key, type } of buildKeybinds) { + if (this.buildKeybindMatchesDigit(code, this.keybinds[key])) return type; + } + return null; + } + private getPinchDistance(): number { const pointerEvents = Array.from(this.pointers.values()); const dx = pointerEvents[0].clientX - pointerEvents[1].clientX; diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 5b17d24aa..a3018d542 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -17,6 +17,7 @@ import { TileRef } from "../../../core/game/GameMap"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; import { + ConfirmGhostStructureEvent, GhostStructureChangedEvent, MouseMoveEvent, MouseUpEvent, @@ -43,6 +44,11 @@ import { } from "./StructureDrawingUtils"; import bitmapFont from "/fonts/round_6x6_modified.xml?url"; +/** True for nuke types (AtomBomb, HydrogenBomb): ghost is preserved after placement so user can place multiple or keep selection (Enter/key confirm). */ +export function shouldPreserveGhostAfterBuild(unitType: UnitType): boolean { + return unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb; +} + extend([a11yPlugin]); class StructureRenderInfo { @@ -92,6 +98,7 @@ export class StructureIconsLayer implements Layer { > = new Map(Structures.types.map((type) => [type, { visible: true }])); private lastGhostQueryAt: number; private visibilityStateDirty = true; + private pendingConfirm: MouseUpEvent | null = null; private hasHiddenStructure = false; potentialUpgrade: StructureRenderInfo | undefined; @@ -171,7 +178,12 @@ export class StructureIconsLayer implements Layer { ); this.eventBus.on(MouseMoveEvent, (e) => this.moveGhost(e)); - this.eventBus.on(MouseUpEvent, (e) => this.createStructure(e)); + this.eventBus.on(MouseUpEvent, (e) => this.requestConfirmStructure(e)); + this.eventBus.on(ConfirmGhostStructureEvent, () => + this.requestConfirmStructure( + new MouseUpEvent(this.mousePos.x, this.mousePos.y), + ), + ); window.addEventListener("resize", () => this.resizeCanvas()); await this.setupRenderer(); @@ -307,7 +319,10 @@ export class StructureIconsLayer implements Layer { this.ghostUnit.container.filters = []; } - if (!this.ghostUnit) return; + if (!this.ghostUnit) { + this.pendingConfirm = null; + return; + } const unit = buildables.find( (u) => u.type === this.ghostUnit!.buildableUnit.type, @@ -322,6 +337,7 @@ export class StructureIconsLayer implements Layer { this.ghostUnit.container.filters = [ new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }), ]; + this.pendingConfirm = null; return; } @@ -369,6 +385,14 @@ export class StructureIconsLayer implements Layer { : Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT); this.ghostUnit.container.scale.set(s); this.ghostUnit.range?.scale.set(this.transformHandler.scale); + + if (this.pendingConfirm !== null) { + const ev = this.pendingConfirm; + this.pendingConfirm = null; + if (this.isGhostReadyForConfirm()) { + this.createStructure(ev); + } + } }); } @@ -399,6 +423,30 @@ export class StructureIconsLayer implements Layer { .fill({ color: 0x000000, alpha: 0.65 }); } + /** + * True when the ghost exists and buildableUnit has been refreshed (canBuild or canUpgrade set). + * Used to avoid running createStructure before renderGhost's async buildables() has updated the ghost. + */ + private isGhostReadyForConfirm(): boolean { + if (!this.ghostUnit) return false; + const bu = this.ghostUnit.buildableUnit; + return bu.canBuild !== false || bu.canUpgrade !== false; + } + + /** + * Request confirm (place/upgrade): run createStructure now if ghost is ready, otherwise defer until + * renderGhost's buildables() callback has updated the ghost. Shared by Enter (ConfirmGhostStructureEvent) + * and mouse click (MouseUpEvent) so numpad-select-then-confirm works. + */ + private requestConfirmStructure(e: MouseUpEvent): void { + if (!this.ghostUnit && !this.uiState.ghostStructure) return; + if (this.isGhostReadyForConfirm()) { + this.createStructure(e); + } else { + this.pendingConfirm = e; + } + } + private createStructure(e: MouseUpEvent) { if (!this.ghostUnit) return; if ( @@ -420,6 +468,7 @@ export class StructureIconsLayer implements Layer { this.ghostUnit.buildableUnit.type, ), ); + this.removeGhostStructure(); } else if (this.ghostUnit.buildableUnit.canBuild) { const unitType = this.ghostUnit.buildableUnit.type; const rocketDirectionUp = @@ -433,8 +482,12 @@ export class StructureIconsLayer implements Layer { rocketDirectionUp, ), ); + if (!shouldPreserveGhostAfterBuild(unitType)) { + this.removeGhostStructure(); + } + } else { + this.removeGhostStructure(); } - this.removeGhostStructure(); } private moveGhost(e: MouseMoveEvent) { @@ -489,6 +542,7 @@ export class StructureIconsLayer implements Layer { } private clearGhostStructure() { + this.pendingConfirm = null; if (this.ghostUnit) { this.ghostUnit.container.destroy(); this.ghostUnit.range?.destroy(); diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index a49144d6a..6924c6d2d 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -1,5 +1,11 @@ -import { AutoUpgradeEvent, InputHandler } from "../src/client/InputHandler"; +import { + AutoUpgradeEvent, + ConfirmGhostStructureEvent, + InputHandler, +} from "../src/client/InputHandler"; +import { UIState } from "../src/client/graphics/UIState"; import { EventBus } from "../src/core/EventBus"; +import { UnitType } from "../src/core/game/Game"; class MockPointerEvent { button: number; @@ -462,4 +468,185 @@ describe("InputHandler AutoUpgrade", () => { spy.mockRestore(); }); }); + + describe("Enter key confirm ghost structure", () => { + let uiState: UIState; + + beforeEach(() => { + localStorage.removeItem("settings.keybinds"); + uiState = { + attackRatio: 20, + ghostStructure: null, + rocketDirectionUp: true, + overlappingRailroads: [], + ghostRailPaths: [], + } as UIState; + inputHandler = new InputHandler(uiState, mockCanvas, eventBus); + inputHandler.initialize(); + }); + + test("emits ConfirmGhostStructureEvent on Enter when ghost structure is set", () => { + const mockEmit = vi.spyOn(eventBus, "emit"); + uiState.ghostStructure = UnitType.City; + + window.dispatchEvent(new KeyboardEvent("keydown", { code: "Enter" })); + + expect(mockEmit).toHaveBeenCalledWith( + expect.any(ConfirmGhostStructureEvent), + ); + }); + + test("emits ConfirmGhostStructureEvent on NumpadEnter when ghost structure is set", () => { + const mockEmit = vi.spyOn(eventBus, "emit"); + uiState.ghostStructure = UnitType.Factory; + + window.dispatchEvent( + new KeyboardEvent("keydown", { code: "NumpadEnter" }), + ); + + expect(mockEmit).toHaveBeenCalledWith( + expect.any(ConfirmGhostStructureEvent), + ); + }); + + test("does not emit ConfirmGhostStructureEvent on Enter when no ghost structure", () => { + const mockEmit = vi.spyOn(eventBus, "emit"); + expect(uiState.ghostStructure).toBeNull(); + + window.dispatchEvent(new KeyboardEvent("keydown", { code: "Enter" })); + + const confirmCalls = mockEmit.mock.calls.filter( + (call) => call[0] instanceof ConfirmGhostStructureEvent, + ); + expect(confirmCalls).toHaveLength(0); + }); + }); + + describe("Numpad number keys for build keybinds", () => { + beforeEach(() => { + localStorage.removeItem("settings.keybinds"); + inputHandler.destroy(); + const uiState: UIState = { + attackRatio: 20, + ghostStructure: null, + rocketDirectionUp: true, + overlappingRailroads: [], + ghostRailPaths: [], + } as UIState; + inputHandler = new InputHandler(uiState, mockCanvas, eventBus); + inputHandler.initialize(); + }); + + test("Numpad1 sets ghost structure to City when buildCity is Digit1", () => { + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "Numpad1", key: "1" }), + ); + expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.City); + }); + + test("Numpad5 sets ghost structure to MissileSilo when buildMissileSilo is Digit5", () => { + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "Numpad5", key: "5" }), + ); + expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.MissileSilo); + }); + + test("Numpad0 sets ghost structure to MIRV when buildMIRV is Digit0", () => { + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "Numpad0", key: "0" }), + ); + expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.MIRV); + }); + }); + + 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, + ghostStructure: null, + rocketDirectionUp: true, + overlappingRailroads: [], + ghostRailPaths: [], + } as UIState; + inputHandler = new InputHandler(uiState, mockCanvas, eventBus); + inputHandler.initialize(); + }); + + test("exact code match wins: Digit1 sets City when buildCity=Digit1 and buildFactory=Numpad1", () => { + localStorage.setItem( + "settings.keybinds", + JSON.stringify({ + buildCity: "Digit1", + buildFactory: "Numpad1", + }), + ); + inputHandler.destroy(); + const uiState: UIState = { + attackRatio: 20, + ghostStructure: null, + rocketDirectionUp: true, + overlappingRailroads: [], + ghostRailPaths: [], + } as UIState; + inputHandler = new InputHandler(uiState, mockCanvas, eventBus); + inputHandler.initialize(); + + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "Digit1", key: "1" }), + ); + + expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.City); + }); + + test("exact code match wins: Numpad1 sets Factory when buildCity=Digit1 and buildFactory=Numpad1", () => { + localStorage.setItem( + "settings.keybinds", + JSON.stringify({ + buildCity: "Digit1", + buildFactory: "Numpad1", + }), + ); + inputHandler.destroy(); + const uiState: UIState = { + attackRatio: 20, + ghostStructure: null, + rocketDirectionUp: true, + overlappingRailroads: [], + ghostRailPaths: [], + } as UIState; + inputHandler = new InputHandler(uiState, mockCanvas, eventBus); + inputHandler.initialize(); + + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "Numpad1", key: "1" }), + ); + + expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.Factory); + }); + + test("digit alias used when no exact match: Numpad1 sets City when only buildCity=Digit1", () => { + localStorage.setItem( + "settings.keybinds", + JSON.stringify({ buildCity: "Digit1" }), + ); + inputHandler.destroy(); + const uiState: UIState = { + attackRatio: 20, + ghostStructure: null, + rocketDirectionUp: true, + overlappingRailroads: [], + ghostRailPaths: [], + } as UIState; + inputHandler = new InputHandler(uiState, mockCanvas, eventBus); + inputHandler.initialize(); + + window.dispatchEvent( + new KeyboardEvent("keyup", { code: "Numpad1", key: "1" }), + ); + + expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.City); + }); + }); }); diff --git a/tests/client/graphics/layers/StructureIconsLayer.test.ts b/tests/client/graphics/layers/StructureIconsLayer.test.ts new file mode 100644 index 000000000..7cb8b557c --- /dev/null +++ b/tests/client/graphics/layers/StructureIconsLayer.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "vitest"; +import { shouldPreserveGhostAfterBuild } from "../../../../src/client/graphics/layers/StructureIconsLayer"; +import { UnitType } from "../../../../src/core/game/Game"; + +/** + * Tests for StructureIconsLayer edge cases mentioned in comments: + * - Locked nuke / AtomBomb / HydrogenBomb: when confirming placement (Enter or key), + * the ghost is preserved so the user can place multiple nukes or keep the nuke + * selected. Other structure types clear the ghost after placement. + */ +describe("StructureIconsLayer ghost preservation (locked nuke / Enter confirm)", () => { + describe("shouldPreserveGhostAfterBuild", () => { + test("returns true for AtomBomb so ghost is not cleared after placement", () => { + expect(shouldPreserveGhostAfterBuild(UnitType.AtomBomb)).toBe(true); + }); + + test("returns true for HydrogenBomb so ghost is not cleared after placement", () => { + expect(shouldPreserveGhostAfterBuild(UnitType.HydrogenBomb)).toBe(true); + }); + + test("returns false for City so ghost is cleared after placement", () => { + expect(shouldPreserveGhostAfterBuild(UnitType.City)).toBe(false); + }); + + test("returns false for Factory so ghost is cleared after placement", () => { + expect(shouldPreserveGhostAfterBuild(UnitType.Factory)).toBe(false); + }); + + test("returns false for other buildable types (Port, DefensePost, MissileSilo, SAMLauncher, Warship, MIRV)", () => { + expect(shouldPreserveGhostAfterBuild(UnitType.Port)).toBe(false); + expect(shouldPreserveGhostAfterBuild(UnitType.DefensePost)).toBe(false); + expect(shouldPreserveGhostAfterBuild(UnitType.MissileSilo)).toBe(false); + expect(shouldPreserveGhostAfterBuild(UnitType.SAMLauncher)).toBe(false); + expect(shouldPreserveGhostAfterBuild(UnitType.Warship)).toBe(false); + expect(shouldPreserveGhostAfterBuild(UnitType.MIRV)).toBe(false); + }); + }); +});