mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-18 09:14:09 +00:00
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:
+79
-47
@@ -92,6 +92,8 @@ export class GhostStructureChangedEvent implements GameEvent {
|
|||||||
constructor(public readonly ghostStructure: PlayerBuildableUnitType | null) {}
|
constructor(public readonly ghostStructure: PlayerBuildableUnitType | null) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ConfirmGhostStructureEvent implements GameEvent {}
|
||||||
|
|
||||||
export class SwapRocketDirectionEvent implements GameEvent {
|
export class SwapRocketDirectionEvent implements GameEvent {
|
||||||
constructor(public readonly rocketDirectionUp: boolean) {}
|
constructor(public readonly rocketDirectionUp: boolean) {}
|
||||||
}
|
}
|
||||||
@@ -339,6 +341,14 @@ export class InputHandler {
|
|||||||
this.setGhostStructure(null);
|
this.setGhostStructure(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(e.code === "Enter" || e.code === "NumpadEnter") &&
|
||||||
|
this.uiState.ghostStructure !== null
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.eventBus.emit(new ConfirmGhostStructureEvent());
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
this.keybinds.moveUp,
|
this.keybinds.moveUp,
|
||||||
@@ -410,54 +420,11 @@ export class InputHandler {
|
|||||||
this.eventBus.emit(new CenterCameraEvent());
|
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();
|
e.preventDefault();
|
||||||
this.setGhostStructure(UnitType.City);
|
this.setGhostStructure(matchedBuild);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.code === this.keybinds.swapDirection) {
|
if (e.code === this.keybinds.swapDirection) {
|
||||||
@@ -616,6 +583,71 @@ export class InputHandler {
|
|||||||
this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure));
|
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 {
|
private getPinchDistance(): number {
|
||||||
const pointerEvents = Array.from(this.pointers.values());
|
const pointerEvents = Array.from(this.pointers.values());
|
||||||
const dx = pointerEvents[0].clientX - pointerEvents[1].clientX;
|
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 { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||||
import { GameView, UnitView } from "../../../core/game/GameView";
|
import { GameView, UnitView } from "../../../core/game/GameView";
|
||||||
import {
|
import {
|
||||||
|
ConfirmGhostStructureEvent,
|
||||||
GhostStructureChangedEvent,
|
GhostStructureChangedEvent,
|
||||||
MouseMoveEvent,
|
MouseMoveEvent,
|
||||||
MouseUpEvent,
|
MouseUpEvent,
|
||||||
@@ -43,6 +44,11 @@ import {
|
|||||||
} from "./StructureDrawingUtils";
|
} from "./StructureDrawingUtils";
|
||||||
import bitmapFont from "/fonts/round_6x6_modified.xml?url";
|
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]);
|
extend([a11yPlugin]);
|
||||||
|
|
||||||
class StructureRenderInfo {
|
class StructureRenderInfo {
|
||||||
@@ -92,6 +98,7 @@ export class StructureIconsLayer implements Layer {
|
|||||||
> = new Map(Structures.types.map((type) => [type, { visible: true }]));
|
> = new Map(Structures.types.map((type) => [type, { visible: true }]));
|
||||||
private lastGhostQueryAt: number;
|
private lastGhostQueryAt: number;
|
||||||
private visibilityStateDirty = true;
|
private visibilityStateDirty = true;
|
||||||
|
private pendingConfirm: MouseUpEvent | null = null;
|
||||||
private hasHiddenStructure = false;
|
private hasHiddenStructure = false;
|
||||||
potentialUpgrade: StructureRenderInfo | undefined;
|
potentialUpgrade: StructureRenderInfo | undefined;
|
||||||
|
|
||||||
@@ -171,7 +178,12 @@ export class StructureIconsLayer implements Layer {
|
|||||||
);
|
);
|
||||||
this.eventBus.on(MouseMoveEvent, (e) => this.moveGhost(e));
|
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());
|
window.addEventListener("resize", () => this.resizeCanvas());
|
||||||
await this.setupRenderer();
|
await this.setupRenderer();
|
||||||
@@ -307,7 +319,10 @@ export class StructureIconsLayer implements Layer {
|
|||||||
this.ghostUnit.container.filters = [];
|
this.ghostUnit.container.filters = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.ghostUnit) return;
|
if (!this.ghostUnit) {
|
||||||
|
this.pendingConfirm = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const unit = buildables.find(
|
const unit = buildables.find(
|
||||||
(u) => u.type === this.ghostUnit!.buildableUnit.type,
|
(u) => u.type === this.ghostUnit!.buildableUnit.type,
|
||||||
@@ -322,6 +337,7 @@ export class StructureIconsLayer implements Layer {
|
|||||||
this.ghostUnit.container.filters = [
|
this.ghostUnit.container.filters = [
|
||||||
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
|
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
|
||||||
];
|
];
|
||||||
|
this.pendingConfirm = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +385,14 @@ export class StructureIconsLayer implements Layer {
|
|||||||
: Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT);
|
: Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT);
|
||||||
this.ghostUnit.container.scale.set(s);
|
this.ghostUnit.container.scale.set(s);
|
||||||
this.ghostUnit.range?.scale.set(this.transformHandler.scale);
|
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 });
|
.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) {
|
private createStructure(e: MouseUpEvent) {
|
||||||
if (!this.ghostUnit) return;
|
if (!this.ghostUnit) return;
|
||||||
if (
|
if (
|
||||||
@@ -420,6 +468,7 @@ export class StructureIconsLayer implements Layer {
|
|||||||
this.ghostUnit.buildableUnit.type,
|
this.ghostUnit.buildableUnit.type,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
this.removeGhostStructure();
|
||||||
} else if (this.ghostUnit.buildableUnit.canBuild) {
|
} else if (this.ghostUnit.buildableUnit.canBuild) {
|
||||||
const unitType = this.ghostUnit.buildableUnit.type;
|
const unitType = this.ghostUnit.buildableUnit.type;
|
||||||
const rocketDirectionUp =
|
const rocketDirectionUp =
|
||||||
@@ -433,8 +482,12 @@ export class StructureIconsLayer implements Layer {
|
|||||||
rocketDirectionUp,
|
rocketDirectionUp,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (!shouldPreserveGhostAfterBuild(unitType)) {
|
||||||
|
this.removeGhostStructure();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.removeGhostStructure();
|
||||||
}
|
}
|
||||||
this.removeGhostStructure();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private moveGhost(e: MouseMoveEvent) {
|
private moveGhost(e: MouseMoveEvent) {
|
||||||
@@ -489,6 +542,7 @@ export class StructureIconsLayer implements Layer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private clearGhostStructure() {
|
private clearGhostStructure() {
|
||||||
|
this.pendingConfirm = null;
|
||||||
if (this.ghostUnit) {
|
if (this.ghostUnit) {
|
||||||
this.ghostUnit.container.destroy();
|
this.ghostUnit.container.destroy();
|
||||||
this.ghostUnit.range?.destroy();
|
this.ghostUnit.range?.destroy();
|
||||||
|
|||||||
+188
-1
@@ -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 { EventBus } from "../src/core/EventBus";
|
||||||
|
import { UnitType } from "../src/core/game/Game";
|
||||||
|
|
||||||
class MockPointerEvent {
|
class MockPointerEvent {
|
||||||
button: number;
|
button: number;
|
||||||
@@ -462,4 +468,185 @@ describe("InputHandler AutoUpgrade", () => {
|
|||||||
spy.mockRestore();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user