diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 1d8db72d8..38d449c24 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -270,7 +270,7 @@ async function createClientGame( clientID, eventBus, gameRenderer, - new InputHandler(gameRenderer.uiState, canvas, eventBus), + new InputHandler(gameView, gameRenderer.uiState, canvas, eventBus), transport, worker, gameView, diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index bb8956cab..253cea83c 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -1,6 +1,6 @@ import { EventBus, GameEvent } from "../core/EventBus"; import { PlayerBuildableUnitType, UnitType } from "../core/game/Game"; -import { UnitView } from "../core/game/GameView"; +import { GameView, UnitView } from "../core/game/GameView"; import { UserSettings } from "../core/game/UserSettings"; import { UIState } from "./graphics/UIState"; import { Platform } from "./Platform"; @@ -177,6 +177,7 @@ export class InputHandler { private readonly userSettings: UserSettings = new UserSettings(); constructor( + private gameView: GameView, public uiState: UIState, private canvas: HTMLCanvasElement, private eventBus: EventBus, @@ -571,7 +572,11 @@ export class InputHandler { return; } - if (!this.userSettings.leftClickOpensMenu() || event.shiftKey) { + if ( + !this.userSettings.leftClickOpensMenu() || + event.shiftKey || + this.gameView.inSpawnPhase() // No Radial Menu during spawn phase, only spawn point selection + ) { this.eventBus.emit(new MouseUpEvent(event.x, event.y)); } else { this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY)); @@ -658,6 +663,9 @@ export class InputHandler { private onContextMenu(event: MouseEvent) { event.preventDefault(); + if (this.gameView.inSpawnPhase()) { + return; + } if (this.uiState.ghostStructure !== null) { this.setGhostStructure(null); return; diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index e5d72f0e8..3dd62c8e2 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -167,14 +167,17 @@ export class UnitLayer implements Layer { } const clickRef = this.game.ref(cell.x, cell.y); - if (!this.game.isOcean(clickRef)) { - // No isValidCoord/Ref check yet, that is done for ContextMenuEvent later - // No warship to find because no Ocean tile, open Radial Menu - this.eventBus.emit(new ContextMenuEvent(event.x, event.y)); + if (this.game.inSpawnPhase()) { + // No Radial Menu during spawn phase, only spawn point selection + if (!this.game.isOcean(clickRef)) { + this.eventBus.emit(new MouseUpEvent(event.x, event.y)); + } return; } - if (!this.game.isValidRef(clickRef)) { + if (!this.game.isOcean(clickRef)) { + // No warship to find because no Ocean tile, open Radial Menu + this.eventBus.emit(new ContextMenuEvent(event.x, event.y)); return; } diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 6924c6d2d..cc5446bc4 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -6,13 +6,17 @@ import { import { UIState } from "../src/client/graphics/UIState"; import { EventBus } from "../src/core/EventBus"; import { UnitType } from "../src/core/game/Game"; +import { GameView } from "../src/core/game/GameView"; 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) { @@ -20,7 +24,10 @@ class MockPointerEvent { 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(); } } @@ -29,10 +36,12 @@ global.PointerEvent = MockPointerEvent as any; describe("InputHandler AutoUpgrade", () => { let inputHandler: InputHandler; + let mockGameView: GameView; let eventBus: EventBus; let mockCanvas: HTMLCanvasElement; beforeEach(() => { + mockGameView = { inSpawnPhase: () => false } as GameView; mockCanvas = document.createElement("canvas"); mockCanvas.width = 800; mockCanvas.height = 600; @@ -40,6 +49,7 @@ describe("InputHandler AutoUpgrade", () => { eventBus = new EventBus(); inputHandler = new InputHandler( + mockGameView, { attackRatio: 20, ghostStructure: null, @@ -218,6 +228,56 @@ describe("InputHandler AutoUpgrade", () => { }); }); + 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"); @@ -481,7 +541,12 @@ describe("InputHandler AutoUpgrade", () => { overlappingRailroads: [], ghostRailPaths: [], } as UIState; - inputHandler = new InputHandler(uiState, mockCanvas, eventBus); + inputHandler = new InputHandler( + mockGameView, + uiState, + mockCanvas, + eventBus, + ); inputHandler.initialize(); }); @@ -533,7 +598,12 @@ describe("InputHandler AutoUpgrade", () => { overlappingRailroads: [], ghostRailPaths: [], } as UIState; - inputHandler = new InputHandler(uiState, mockCanvas, eventBus); + inputHandler = new InputHandler( + mockGameView, + uiState, + mockCanvas, + eventBus, + ); inputHandler.initialize(); }); @@ -570,7 +640,12 @@ describe("InputHandler AutoUpgrade", () => { overlappingRailroads: [], ghostRailPaths: [], } as UIState; - inputHandler = new InputHandler(uiState, mockCanvas, eventBus); + inputHandler = new InputHandler( + mockGameView, + uiState, + mockCanvas, + eventBus, + ); inputHandler.initialize(); }); @@ -590,7 +665,12 @@ describe("InputHandler AutoUpgrade", () => { overlappingRailroads: [], ghostRailPaths: [], } as UIState; - inputHandler = new InputHandler(uiState, mockCanvas, eventBus); + inputHandler = new InputHandler( + mockGameView, + uiState, + mockCanvas, + eventBus, + ); inputHandler.initialize(); window.dispatchEvent( @@ -616,7 +696,12 @@ describe("InputHandler AutoUpgrade", () => { overlappingRailroads: [], ghostRailPaths: [], } as UIState; - inputHandler = new InputHandler(uiState, mockCanvas, eventBus); + inputHandler = new InputHandler( + mockGameView, + uiState, + mockCanvas, + eventBus, + ); inputHandler.initialize(); window.dispatchEvent( @@ -639,7 +724,12 @@ describe("InputHandler AutoUpgrade", () => { overlappingRailroads: [], ghostRailPaths: [], } as UIState; - inputHandler = new InputHandler(uiState, mockCanvas, eventBus); + inputHandler = new InputHandler( + mockGameView, + uiState, + mockCanvas, + eventBus, + ); inputHandler.initialize(); window.dispatchEvent(