mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 21:34:14 +00:00
2ee2fb97e3
## Description: Show factory and defence post radius for ghost structure when placing structures from build bar (unitdisplay). Show when city/port is placed directly over existing railroad, by highlighting the railroad green. The railroad is not highlighted when instead a city/port nearby the ghost structure will be upgraded instead of placing it on the railroad. This works with the existing code in buildableUnits in PlayerImpl: it would already return an empty array [] for overlappingRailroads and for ghostRailPaths when canUpgrade is false. So the old checks for uiState for Canvas2D in BuildPreviewController weren't even needed per se, they followed the same logic as buildableUnits in PlayerImpl already did. Both changes emulate how it worked before the move to WebGL. - OverlappingRailroads now returns TileRefs instead of a railroad ID, and it does so with less allocations than the previous code. It's a determistic outcome, sorted and deduplicated. In doubt about this a bit, because it's better also in case we ever do desync checks using this data, but for the rendering it isn't needed per se and could be more performant without allocations. - Also: Cleanup obsolete Canvas2D rail highlighting state (UIState) that was superseded by GhostPreviewData. ## 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: tryout33
1043 lines
28 KiB
TypeScript
1043 lines
28 KiB
TypeScript
import {
|
|
AutoUpgradeEvent,
|
|
ConfirmGhostStructureEvent,
|
|
InputHandler,
|
|
WarshipSelectionBoxCancelEvent,
|
|
WarshipSelectionBoxCompleteEvent,
|
|
WarshipSelectionBoxUpdateEvent,
|
|
} from "../src/client/InputHandler";
|
|
import { UIState } from "../src/client/UIState";
|
|
import { EventBus } from "../src/core/EventBus";
|
|
import { UnitType } from "../src/core/game/Game";
|
|
import { GameView, PlayerView } from "../src/core/game/GameView";
|
|
import { KEYBINDS_KEY, UserSettings } from "../src/core/game/UserSettings";
|
|
|
|
class MockPointerEvent {
|
|
button: number;
|
|
clientX: number;
|
|
clientY: number;
|
|
x: number;
|
|
y: number;
|
|
pointerId: number;
|
|
type: string;
|
|
pointerType: string;
|
|
preventDefault: () => void;
|
|
|
|
constructor(type: string, init: any) {
|
|
this.type = type;
|
|
this.button = init.button;
|
|
this.clientX = init.clientX;
|
|
this.clientY = init.clientY;
|
|
this.x = init.x ?? init.clientX;
|
|
this.y = init.y ?? init.clientY;
|
|
this.pointerId = init.pointerId;
|
|
this.pointerType = init.pointerType ?? "mouse";
|
|
this.preventDefault = vi.fn();
|
|
}
|
|
}
|
|
|
|
global.PointerEvent = MockPointerEvent as any;
|
|
|
|
describe("InputHandler AutoUpgrade", () => {
|
|
let inputHandler: InputHandler;
|
|
let mockGameView: GameView;
|
|
let eventBus: EventBus;
|
|
let mockCanvas: HTMLCanvasElement;
|
|
let testSettings: UserSettings;
|
|
|
|
beforeEach(() => {
|
|
testSettings = new UserSettings();
|
|
testSettings.removeCached(KEYBINDS_KEY, false);
|
|
|
|
mockGameView = {
|
|
inSpawnPhase: () => false,
|
|
myPlayer: () => ({ isAlive: () => true }),
|
|
} as GameView;
|
|
mockCanvas = document.createElement("canvas");
|
|
mockCanvas.width = 800;
|
|
mockCanvas.height = 600;
|
|
|
|
eventBus = new EventBus();
|
|
|
|
inputHandler = new InputHandler(
|
|
mockGameView,
|
|
{
|
|
attackRatio: 20,
|
|
ghostStructure: null,
|
|
rocketDirectionUp: true,
|
|
},
|
|
mockCanvas,
|
|
eventBus,
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
inputHandler.destroy();
|
|
});
|
|
|
|
describe("Middle Mouse Button Handling", () => {
|
|
test("should emit AutoUpgradeEvent on middle mouse button press", () => {
|
|
const mockEmit = vi.spyOn(eventBus, "emit");
|
|
|
|
const pointerEvent = new PointerEvent("pointerdown", {
|
|
button: 1,
|
|
clientX: 150,
|
|
clientY: 250,
|
|
pointerId: 1,
|
|
});
|
|
|
|
inputHandler["onPointerDown"](pointerEvent);
|
|
|
|
expect(mockEmit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
x: 150,
|
|
y: 250,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("should emit MouseDownEvent on left mouse button press instead of AutoUpgradeEvent", () => {
|
|
const mockEmit = vi.spyOn(eventBus, "emit");
|
|
|
|
const pointerEvent = new PointerEvent("pointerdown", {
|
|
button: 0,
|
|
clientX: 150,
|
|
clientY: 250,
|
|
pointerId: 1,
|
|
});
|
|
|
|
inputHandler["onPointerDown"](pointerEvent);
|
|
|
|
expect(mockEmit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
x: 150,
|
|
y: 250,
|
|
}),
|
|
);
|
|
|
|
const calls = mockEmit.mock.calls;
|
|
const lastCall = calls[calls.length - 1];
|
|
expect(lastCall[0]).not.toBeInstanceOf(AutoUpgradeEvent);
|
|
});
|
|
|
|
test("should not emit AutoUpgradeEvent on right mouse button press", () => {
|
|
const mockEmit = vi.spyOn(eventBus, "emit");
|
|
|
|
const pointerEvent = new PointerEvent("pointerdown", {
|
|
button: 2,
|
|
clientX: 150,
|
|
clientY: 250,
|
|
pointerId: 1,
|
|
});
|
|
|
|
inputHandler["onPointerDown"](pointerEvent);
|
|
|
|
expect(mockEmit).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
x: 150,
|
|
y: 250,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("should handle multiple middle mouse button presses", () => {
|
|
const mockEmit = vi.spyOn(eventBus, "emit");
|
|
|
|
const pointerEvent1 = new PointerEvent("pointerdown", {
|
|
button: 1,
|
|
clientX: 100,
|
|
clientY: 200,
|
|
pointerId: 1,
|
|
});
|
|
inputHandler["onPointerDown"](pointerEvent1);
|
|
|
|
const pointerEvent2 = new PointerEvent("pointerdown", {
|
|
button: 1,
|
|
clientX: 300,
|
|
clientY: 400,
|
|
pointerId: 2,
|
|
});
|
|
inputHandler["onPointerDown"](pointerEvent2);
|
|
|
|
expect(mockEmit).toHaveBeenCalledTimes(2);
|
|
expect(mockEmit).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({
|
|
x: 100,
|
|
y: 200,
|
|
}),
|
|
);
|
|
expect(mockEmit).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({
|
|
x: 300,
|
|
y: 400,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("should handle middle mouse button press with zero coordinates", () => {
|
|
const mockEmit = vi.spyOn(eventBus, "emit");
|
|
|
|
const pointerEvent = new PointerEvent("pointerdown", {
|
|
button: 1,
|
|
clientX: 0,
|
|
clientY: 0,
|
|
pointerId: 1,
|
|
});
|
|
|
|
inputHandler["onPointerDown"](pointerEvent);
|
|
|
|
expect(mockEmit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
x: 0,
|
|
y: 0,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("should handle middle mouse button press with negative coordinates", () => {
|
|
const mockEmit = vi.spyOn(eventBus, "emit");
|
|
|
|
const pointerEvent = new PointerEvent("pointerdown", {
|
|
button: 1,
|
|
clientX: -100,
|
|
clientY: -200,
|
|
pointerId: 1,
|
|
});
|
|
|
|
inputHandler["onPointerDown"](pointerEvent);
|
|
|
|
expect(mockEmit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
x: -100,
|
|
y: -200,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("should handle middle mouse button press with decimal coordinates", () => {
|
|
const mockEmit = vi.spyOn(eventBus, "emit");
|
|
|
|
const pointerEvent = new PointerEvent("pointerdown", {
|
|
button: 1,
|
|
clientX: 100.5,
|
|
clientY: 200.7,
|
|
pointerId: 1,
|
|
});
|
|
|
|
inputHandler["onPointerDown"](pointerEvent);
|
|
|
|
expect(mockEmit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
x: 100.5,
|
|
y: 200.7,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Spawn Phase Handling", () => {
|
|
test("should emit MouseUpEvent and not ContextMenuEvent on left click release during spawn phase", () => {
|
|
mockGameView.inSpawnPhase = () => true;
|
|
const mockEmit = vi.spyOn(eventBus, "emit");
|
|
|
|
inputHandler["userSettings"].leftClickOpensMenu = () => true;
|
|
|
|
const pointerEvent = new PointerEvent("pointerup", {
|
|
button: 0,
|
|
clientX: 150,
|
|
clientY: 250,
|
|
});
|
|
inputHandler["lastPointerDownX"] = 149;
|
|
inputHandler["lastPointerDownY"] = 249;
|
|
|
|
inputHandler["onPointerUp"](pointerEvent);
|
|
|
|
expect(mockEmit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
x: 150,
|
|
y: 250,
|
|
}),
|
|
);
|
|
const emittedTypes = mockEmit.mock.calls.map(
|
|
(call) => call[0].constructor.name,
|
|
);
|
|
expect(emittedTypes).toContain("MouseUpEvent");
|
|
expect(emittedTypes).not.toContain("ContextMenuEvent");
|
|
});
|
|
|
|
test("should suppress/ignore context menu events during spawn phase", () => {
|
|
mockGameView.inSpawnPhase = () => true;
|
|
const mockEmit = vi.spyOn(eventBus, "emit");
|
|
|
|
const mouseEvent = new MouseEvent("contextmenu", {
|
|
clientX: 150,
|
|
clientY: 250,
|
|
});
|
|
const preventDefaultSpy = vi.spyOn(mouseEvent, "preventDefault");
|
|
|
|
inputHandler["onContextMenu"](mouseEvent);
|
|
|
|
expect(preventDefaultSpy).toHaveBeenCalled();
|
|
const emittedTypes = mockEmit.mock.calls.map(
|
|
(call) => call[0].constructor.name,
|
|
);
|
|
expect(emittedTypes).not.toContain("ContextMenuEvent");
|
|
});
|
|
});
|
|
|
|
describe("Pointer Event Handling", () => {
|
|
test("should handle pointer events with different pointer IDs", () => {
|
|
const mockEmit = vi.spyOn(eventBus, "emit");
|
|
|
|
const pointerEvent1 = new PointerEvent("pointerdown", {
|
|
button: 1,
|
|
clientX: 100,
|
|
clientY: 200,
|
|
pointerId: 1,
|
|
});
|
|
inputHandler["onPointerDown"](pointerEvent1);
|
|
|
|
const pointerEvent2 = new PointerEvent("pointerdown", {
|
|
button: 1,
|
|
clientX: 300,
|
|
clientY: 400,
|
|
pointerId: 2,
|
|
});
|
|
inputHandler["onPointerDown"](pointerEvent2);
|
|
|
|
expect(mockEmit).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test("should handle pointer events with same pointer ID", () => {
|
|
const mockEmit = vi.spyOn(eventBus, "emit");
|
|
|
|
const pointerEvent1 = new PointerEvent("pointerdown", {
|
|
button: 1,
|
|
clientX: 100,
|
|
clientY: 200,
|
|
pointerId: 1,
|
|
});
|
|
inputHandler["onPointerDown"](pointerEvent1);
|
|
|
|
const pointerEvent2 = new PointerEvent("pointerdown", {
|
|
button: 1,
|
|
clientX: 300,
|
|
clientY: 400,
|
|
pointerId: 1,
|
|
});
|
|
inputHandler["onPointerDown"](pointerEvent2);
|
|
|
|
expect(mockEmit).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("Edge Cases", () => {
|
|
test("should handle very large coordinates", () => {
|
|
const mockEmit = vi.spyOn(eventBus, "emit");
|
|
|
|
const pointerEvent = new PointerEvent("pointerdown", {
|
|
button: 1,
|
|
clientX: Number.MAX_SAFE_INTEGER,
|
|
clientY: Number.MAX_SAFE_INTEGER,
|
|
pointerId: 1,
|
|
});
|
|
|
|
inputHandler["onPointerDown"](pointerEvent);
|
|
|
|
expect(mockEmit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
x: Number.MAX_SAFE_INTEGER,
|
|
y: Number.MAX_SAFE_INTEGER,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("should handle very small coordinates", () => {
|
|
const mockEmit = vi.spyOn(eventBus, "emit");
|
|
|
|
const pointerEvent = new PointerEvent("pointerdown", {
|
|
button: 1,
|
|
clientX: Number.MIN_SAFE_INTEGER,
|
|
clientY: Number.MIN_SAFE_INTEGER,
|
|
pointerId: 1,
|
|
});
|
|
|
|
inputHandler["onPointerDown"](pointerEvent);
|
|
|
|
expect(mockEmit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
x: Number.MIN_SAFE_INTEGER,
|
|
y: Number.MIN_SAFE_INTEGER,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("should handle NaN coordinates", () => {
|
|
const mockEmit = vi.spyOn(eventBus, "emit");
|
|
|
|
const pointerEvent = new PointerEvent("pointerdown", {
|
|
button: 1,
|
|
clientX: NaN,
|
|
clientY: NaN,
|
|
pointerId: 1,
|
|
});
|
|
|
|
inputHandler["onPointerDown"](pointerEvent);
|
|
|
|
expect(mockEmit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
x: NaN,
|
|
y: NaN,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("should handle Infinity coordinates", () => {
|
|
const mockEmit = vi.spyOn(eventBus, "emit");
|
|
|
|
const pointerEvent = new PointerEvent("pointerdown", {
|
|
button: 1,
|
|
clientX: Infinity,
|
|
clientY: -Infinity,
|
|
pointerId: 1,
|
|
});
|
|
|
|
inputHandler["onPointerDown"](pointerEvent);
|
|
|
|
expect(mockEmit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
x: Infinity,
|
|
y: -Infinity,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Integration with Event Bus", () => {
|
|
test("should allow event listeners to receive AutoUpgradeEvents", () => {
|
|
const mockListener = vi.fn();
|
|
|
|
eventBus.on(AutoUpgradeEvent, mockListener);
|
|
|
|
const pointerEvent = new PointerEvent("pointerdown", {
|
|
button: 1,
|
|
clientX: 150,
|
|
clientY: 250,
|
|
pointerId: 1,
|
|
});
|
|
inputHandler["onPointerDown"](pointerEvent);
|
|
|
|
expect(mockListener).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
x: 150,
|
|
y: 250,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("should allow multiple listeners for AutoUpgradeEvent", () => {
|
|
const mockListener1 = vi.fn();
|
|
const mockListener2 = vi.fn();
|
|
|
|
eventBus.on(AutoUpgradeEvent, mockListener1);
|
|
eventBus.on(AutoUpgradeEvent, mockListener2);
|
|
|
|
const pointerEvent = new PointerEvent("pointerdown", {
|
|
button: 1,
|
|
clientX: 150,
|
|
clientY: 250,
|
|
pointerId: 1,
|
|
});
|
|
inputHandler["onPointerDown"](pointerEvent);
|
|
|
|
expect(mockListener1).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
x: 150,
|
|
y: 250,
|
|
}),
|
|
);
|
|
expect(mockListener2).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
x: 150,
|
|
y: 250,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("should not call unsubscribed listeners", () => {
|
|
const mockListener = vi.fn();
|
|
|
|
eventBus.on(AutoUpgradeEvent, mockListener);
|
|
eventBus.off(AutoUpgradeEvent, mockListener);
|
|
|
|
const pointerEvent = new PointerEvent("pointerdown", {
|
|
button: 1,
|
|
clientX: 150,
|
|
clientY: 250,
|
|
pointerId: 1,
|
|
});
|
|
inputHandler["onPointerDown"](pointerEvent);
|
|
|
|
expect(mockListener).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Keybinds JSON parsing", () => {
|
|
test("parses nested object values and flattens them to strings", () => {
|
|
const nested = {
|
|
moveUp: { key: "moveUp", value: "KeyZ" },
|
|
};
|
|
testSettings.setKeybinds(nested);
|
|
|
|
inputHandler.initialize();
|
|
|
|
expect((inputHandler as any).keybinds.moveUp).toBe("KeyZ");
|
|
});
|
|
|
|
test("accepts legacy string values", () => {
|
|
testSettings.setKeybinds({ 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", () => {
|
|
const mixed = {
|
|
moveUp: { key: "moveUp", value: null },
|
|
moveLeft: "Null",
|
|
};
|
|
testSettings.setKeybinds(mixed);
|
|
|
|
inputHandler.initialize();
|
|
|
|
expect((inputHandler as any).keybinds.moveUp).toBe("KeyW");
|
|
// "Null" 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");
|
|
|
|
inputHandler.initialize();
|
|
|
|
expect(spy).toHaveBeenCalled();
|
|
// default remains when parsing fails
|
|
expect((inputHandler as any).keybinds.moveUp).toBe("KeyW");
|
|
spy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("Enter key confirm ghost structure", () => {
|
|
let uiState: UIState;
|
|
|
|
beforeEach(() => {
|
|
uiState = {
|
|
attackRatio: 20,
|
|
ghostStructure: null,
|
|
rocketDirectionUp: true,
|
|
} as UIState;
|
|
inputHandler = new InputHandler(
|
|
mockGameView,
|
|
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(() => {
|
|
inputHandler.destroy();
|
|
const uiState: UIState = {
|
|
attackRatio: 20,
|
|
ghostStructure: null,
|
|
rocketDirectionUp: true,
|
|
} as UIState;
|
|
inputHandler = new InputHandler(
|
|
mockGameView,
|
|
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);
|
|
});
|
|
|
|
test("does not set ghost structure when the player is dead", () => {
|
|
mockGameView.myPlayer = () =>
|
|
({ isAlive: () => false }) as unknown as PlayerView;
|
|
|
|
window.dispatchEvent(
|
|
new KeyboardEvent("keyup", { code: "Numpad1", key: "1" }),
|
|
);
|
|
|
|
expect(inputHandler["uiState"].ghostStructure).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Build keybind two-phase matching (exact code first, then digit/Numpad alias)", () => {
|
|
beforeEach(() => {
|
|
inputHandler.destroy();
|
|
const uiState: UIState = {
|
|
attackRatio: 20,
|
|
ghostStructure: null,
|
|
rocketDirectionUp: true,
|
|
} as UIState;
|
|
inputHandler = new InputHandler(
|
|
mockGameView,
|
|
uiState,
|
|
mockCanvas,
|
|
eventBus,
|
|
);
|
|
inputHandler.initialize();
|
|
});
|
|
|
|
test("exact code match wins: Digit1 sets City when buildCity=Digit1 and buildFactory=Numpad1", () => {
|
|
testSettings.setKeybinds({
|
|
buildCity: "Digit1",
|
|
buildFactory: "Numpad1",
|
|
});
|
|
inputHandler.destroy();
|
|
const uiState: UIState = {
|
|
attackRatio: 20,
|
|
ghostStructure: null,
|
|
rocketDirectionUp: true,
|
|
} as UIState;
|
|
inputHandler = new InputHandler(
|
|
mockGameView,
|
|
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", () => {
|
|
testSettings.setKeybinds({
|
|
buildCity: "Digit1",
|
|
buildFactory: "Numpad1",
|
|
});
|
|
inputHandler.destroy();
|
|
const uiState: UIState = {
|
|
attackRatio: 20,
|
|
ghostStructure: null,
|
|
rocketDirectionUp: true,
|
|
} as UIState;
|
|
inputHandler = new InputHandler(
|
|
mockGameView,
|
|
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", () => {
|
|
testSettings.setKeybinds({ buildCity: "Digit1" });
|
|
inputHandler.destroy();
|
|
const uiState: UIState = {
|
|
attackRatio: 20,
|
|
ghostStructure: null,
|
|
rocketDirectionUp: true,
|
|
} as UIState;
|
|
inputHandler = new InputHandler(
|
|
mockGameView,
|
|
uiState,
|
|
mockCanvas,
|
|
eventBus,
|
|
);
|
|
inputHandler.initialize();
|
|
|
|
window.dispatchEvent(
|
|
new KeyboardEvent("keyup", { code: "Numpad1", key: "1" }),
|
|
);
|
|
|
|
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.City);
|
|
});
|
|
});
|
|
|
|
describe("Shift+ keybind support", () => {
|
|
let uiState: UIState;
|
|
|
|
beforeEach(() => {
|
|
inputHandler.destroy();
|
|
uiState = {
|
|
attackRatio: 20,
|
|
ghostStructure: null,
|
|
rocketDirectionUp: true,
|
|
} as UIState;
|
|
});
|
|
|
|
test("Shift+Digit1 sets City when buildCity is bound to Shift+Digit1", () => {
|
|
testSettings.setKeybinds({ 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.setKeybinds({ 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.setKeybinds({ 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.setKeybinds({ 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.setKeybinds({
|
|
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.setKeybinds({ 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);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Warship box selection (Shift+drag)", () => {
|
|
let inputHandler: InputHandler;
|
|
let eventBus: EventBus;
|
|
let mockCanvas: HTMLCanvasElement;
|
|
let uiState: UIState;
|
|
|
|
beforeEach(() => {
|
|
const mockGameView = { inSpawnPhase: () => false } as GameView;
|
|
mockCanvas = document.createElement("canvas");
|
|
eventBus = new EventBus();
|
|
uiState = {
|
|
attackRatio: 20,
|
|
ghostStructure: null,
|
|
rocketDirectionUp: true,
|
|
} as UIState;
|
|
inputHandler = new InputHandler(
|
|
mockGameView,
|
|
uiState,
|
|
mockCanvas,
|
|
eventBus,
|
|
);
|
|
inputHandler.initialize();
|
|
});
|
|
|
|
afterEach(() => {
|
|
inputHandler.destroy();
|
|
});
|
|
|
|
test("Shift keydown sets canvas cursor to crosshair", () => {
|
|
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
|
|
expect(mockCanvas.style.cursor).toBe("crosshair");
|
|
});
|
|
|
|
test("ShiftRight keydown also sets cursor to crosshair", () => {
|
|
// ShiftRight is not the default shiftKey keybind (ShiftLeft is).
|
|
// This test verifies the configured shiftKey works, not a hardcoded ShiftRight.
|
|
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
|
|
expect(mockCanvas.style.cursor).toBe("crosshair");
|
|
});
|
|
|
|
test("Shift keyup resets cursor when no selection box active", () => {
|
|
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
|
|
window.dispatchEvent(new KeyboardEvent("keyup", { code: "ShiftLeft" }));
|
|
expect(mockCanvas.style.cursor).toBe("");
|
|
});
|
|
|
|
test("Shift keydown discards active ghostStructure", () => {
|
|
uiState.ghostStructure = UnitType.Warship;
|
|
|
|
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
|
|
|
|
expect(uiState.ghostStructure).toBeNull();
|
|
});
|
|
|
|
test("Shift+drag emits WarshipSelectionBoxUpdateEvent", () => {
|
|
const listener = vi.fn();
|
|
eventBus.on(WarshipSelectionBoxUpdateEvent, listener);
|
|
|
|
inputHandler["onPointerDown"](
|
|
new PointerEvent("pointerdown", {
|
|
button: 0,
|
|
clientX: 100,
|
|
clientY: 100,
|
|
pointerId: 1,
|
|
}),
|
|
);
|
|
inputHandler["activeKeys"].add("ShiftLeft");
|
|
inputHandler["onPointerMove"](
|
|
new PointerEvent("pointermove", {
|
|
button: 0,
|
|
clientX: 200,
|
|
clientY: 200,
|
|
pointerId: 1,
|
|
}),
|
|
);
|
|
|
|
expect(listener).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
startX: 100,
|
|
startY: 100,
|
|
endX: 200,
|
|
endY: 200,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("Shift+drag then pointerup emits WarshipSelectionBoxCompleteEvent", () => {
|
|
const listener = vi.fn();
|
|
eventBus.on(WarshipSelectionBoxCompleteEvent, listener);
|
|
|
|
inputHandler["onPointerDown"](
|
|
new PointerEvent("pointerdown", {
|
|
button: 0,
|
|
clientX: 50,
|
|
clientY: 50,
|
|
pointerId: 1,
|
|
}),
|
|
);
|
|
inputHandler["activeKeys"].add("ShiftLeft");
|
|
inputHandler["onPointerMove"](
|
|
new PointerEvent("pointermove", {
|
|
button: 0,
|
|
clientX: 200,
|
|
clientY: 200,
|
|
pointerId: 1,
|
|
}),
|
|
);
|
|
expect(inputHandler["selectionBoxActive"]).toBe(true);
|
|
|
|
inputHandler["onPointerUp"](
|
|
new PointerEvent("pointerup", {
|
|
button: 0,
|
|
clientX: 200,
|
|
clientY: 200,
|
|
pointerId: 1,
|
|
}),
|
|
);
|
|
|
|
expect(listener).toHaveBeenCalledWith(
|
|
expect.objectContaining({ startX: 50, startY: 50, endX: 200, endY: 200 }),
|
|
);
|
|
expect(inputHandler["selectionBoxActive"]).toBe(false);
|
|
});
|
|
|
|
test("Escape cancels active selection box", () => {
|
|
const listener = vi.fn();
|
|
eventBus.on(WarshipSelectionBoxCancelEvent, listener);
|
|
|
|
inputHandler["selectionBoxActive"] = true;
|
|
window.dispatchEvent(new KeyboardEvent("keydown", { code: "Escape" }));
|
|
|
|
expect(listener).toHaveBeenCalled();
|
|
expect(inputHandler["selectionBoxActive"]).toBe(false);
|
|
});
|
|
|
|
test("tiny drag (< 10px) cancels selection box instead of completing it", () => {
|
|
const cancelListener = vi.fn();
|
|
const completeListener = vi.fn();
|
|
eventBus.on(WarshipSelectionBoxCancelEvent, cancelListener);
|
|
eventBus.on(WarshipSelectionBoxCompleteEvent, completeListener);
|
|
|
|
inputHandler["onPointerDown"](
|
|
new PointerEvent("pointerdown", {
|
|
button: 0,
|
|
clientX: 100,
|
|
clientY: 100,
|
|
pointerId: 1,
|
|
}),
|
|
);
|
|
inputHandler["activeKeys"].add("ShiftLeft");
|
|
inputHandler["onPointerMove"](
|
|
new PointerEvent("pointermove", {
|
|
button: 0,
|
|
clientX: 104,
|
|
clientY: 104,
|
|
pointerId: 1,
|
|
}),
|
|
);
|
|
inputHandler["onPointerUp"](
|
|
new PointerEvent("pointerup", {
|
|
button: 0,
|
|
clientX: 104,
|
|
clientY: 104,
|
|
pointerId: 1,
|
|
}),
|
|
);
|
|
|
|
expect(cancelListener).toHaveBeenCalled();
|
|
expect(completeListener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("window blur resets cursor", () => {
|
|
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
|
|
expect(mockCanvas.style.cursor).toBe("crosshair");
|
|
window.dispatchEvent(new Event("blur"));
|
|
expect(mockCanvas.style.cursor).toBe("");
|
|
});
|
|
});
|