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-07 15:27:25 -08:00
committed by GitHub
parent fe89713f46
commit 936928fed9
4 changed files with 362 additions and 51 deletions
@@ -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();