Enhance InputHandler to allow using NumPad (#3317)

## Description:

Adds **Enter** and **Numpad Enter** as confirmation for placing a ghost
structure after selecting a building with hotkeys (1–0 or numpad).
Players can cancel with Esc but previously had to click to confirm; they
can now confirm with Enter or Numpad Enter at the current cursor
position. This supports keyboard-only or mouse + numpad workflows (e.g.
one hand on numpad for select + confirm, one on mouse for aiming).

## 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:

.wozniakpl
This commit is contained in:
Bartosz Woźniak
2026-03-08 00:27:25 +01:00
committed by GitHub
parent fe89713f46
commit 936928fed9
4 changed files with 362 additions and 51 deletions
+79 -47
View File
@@ -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;
@@ -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();
+188 -1
View File
@@ -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);
});
});
});
@@ -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);
});
});
});