From c3576a50b9770b6d36b311e721dbb628fb765f6d Mon Sep 17 00:00:00 2001 From: Kipstz <140314732+Kipstz@users.noreply.github.com> Date: Tue, 5 Aug 2025 04:48:11 +0200 Subject: [PATCH] Add auto-upgrade buildings feature with middle mouse click (#1597) ## Description: This PR implements a new feature allowing automatic upgrade of the nearest building using the middle mouse button. This feature greatly simplifies the upgrade process that previously required a right-click + building recreation. ## 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 - [x] I have read and accepted the CLA agreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: Kipstzz --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- resources/lang/en.json | 1 + src/client/ClientGameRunner.ts | 73 ++++++ src/client/HelpModal.ts | 8 + src/client/InputHandler.ts | 23 ++ tests/AutoUpgrade.test.ts | 155 +++++++++++++ tests/InputHandler.test.ts | 400 +++++++++++++++++++++++++++++++++ 6 files changed, 660 insertions(+) create mode 100644 tests/AutoUpgrade.test.ts create mode 100644 tests/InputHandler.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 1b5accb22..ec87195a0 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -43,6 +43,7 @@ "action_move_camera": "Move camera", "action_ratio_change": "Decrease/Increase attack ratio", "action_reset_gfx": "Reset graphics", + "action_auto_upgrade": "Auto-upgrade nearest building", "ui_section": "Game UI", "ui_leaderboard": "Leaderboard", "ui_your_team": "Your team:", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index c6f17706b..8723657e0 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -26,6 +26,7 @@ import { loadTerrainMap, TerrainMapData } from "../core/game/TerrainMapLoader"; import { UserSettings } from "../core/game/UserSettings"; import { WorkerClient } from "../core/worker/WorkerClient"; import { + AutoUpgradeEvent, DoBoatAttackEvent, DoGroundAttackEvent, InputHandler, @@ -40,6 +41,7 @@ import { SendBoatAttackIntentEvent, SendHashEvent, SendSpawnIntentEvent, + SendUpgradeStructureIntentEvent, Transport, } from "./Transport"; import { createCanvas } from "./Utils"; @@ -248,6 +250,7 @@ export class ClientGameRunner { }, 20000); this.eventBus.on(MouseUpEvent, this.inputEvent.bind(this)); this.eventBus.on(MouseMoveEvent, this.onMouseMove.bind(this)); + this.eventBus.on(AutoUpgradeEvent, this.autoUpgradeEvent.bind(this)); this.eventBus.on( DoBoatAttackEvent, this.doBoatAttackUnderCursor.bind(this), @@ -424,6 +427,76 @@ export class ClientGameRunner { }); } + private autoUpgradeEvent(event: AutoUpgradeEvent) { + if (!this.isActive) { + return; + } + + const cell = this.renderer.transformHandler.screenToWorldCoordinates( + event.x, + event.y, + ); + if (!this.gameView.isValidCoord(cell.x, cell.y)) { + return; + } + + const tile = this.gameView.ref(cell.x, cell.y); + + if (this.myPlayer === null) { + const myPlayer = this.gameView.playerByClientID(this.lobby.clientID); + if (myPlayer === null) return; + this.myPlayer = myPlayer; + } + + if (this.gameView.inSpawnPhase()) { + return; + } + + this.findAndUpgradeNearestBuilding(tile); + } + + private findAndUpgradeNearestBuilding(clickedTile: TileRef) { + this.myPlayer!.actions(clickedTile).then((actions) => { + const upgradeUnits: { + unitId: number; + unitType: UnitType; + distance: number; + }[] = []; + + for (const bu of actions.buildableUnits) { + if (bu.canUpgrade !== false) { + const existingUnit = this.gameView + .units() + .find((unit) => unit.id() === bu.canUpgrade); + if (existingUnit) { + const distance = this.gameView.manhattanDist( + clickedTile, + existingUnit.tile(), + ); + + upgradeUnits.push({ + unitId: bu.canUpgrade, + unitType: bu.type, + distance: distance, + }); + } + } + } + + if (upgradeUnits.length > 0) { + upgradeUnits.sort((a, b) => a.distance - b.distance); + const bestUpgrade = upgradeUnits[0]; + + this.eventBus.emit( + new SendUpgradeStructureIntentEvent( + bestUpgrade.unitId, + bestUpgrade.unitType, + ), + ); + } + }); + } + private doBoatAttackUnderCursor(): void { const tile = this.getTileUnderCursor(); if (tile === null) { diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index 97e74be6d..2c9e33cbb 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -138,6 +138,14 @@ export class HelpModal extends LitElement { ${translateText("help_modal.action_reset_gfx")} + + +
+
+
+ + ${translateText("help_modal.action_auto_upgrade")} + diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index f089df82f..3031858df 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -107,6 +107,13 @@ export class CenterCameraEvent implements GameEvent { constructor() {} } +export class AutoUpgradeEvent implements GameEvent { + constructor( + public readonly x: number, + public readonly y: number, + ) {} +} + export class InputHandler { private lastPointerX: number = 0; private lastPointerY: number = 0; @@ -325,6 +332,12 @@ export class InputHandler { } private onPointerDown(event: PointerEvent) { + if (event.button === 1) { + event.preventDefault(); + this.eventBus.emit(new AutoUpgradeEvent(event.clientX, event.clientY)); + return; + } + if (event.button > 0) { return; } @@ -346,6 +359,11 @@ export class InputHandler { } onPointerUp(event: PointerEvent) { + if (event.button === 1) { + event.preventDefault(); + return; + } + if (event.button > 0) { return; } @@ -398,6 +416,11 @@ export class InputHandler { } private onPointerMove(event: PointerEvent) { + if (event.button === 1) { + event.preventDefault(); + return; + } + if (event.button > 0) { return; } diff --git a/tests/AutoUpgrade.test.ts b/tests/AutoUpgrade.test.ts new file mode 100644 index 000000000..126929098 --- /dev/null +++ b/tests/AutoUpgrade.test.ts @@ -0,0 +1,155 @@ +/** + * @jest-environment jsdom + */ +import { AutoUpgradeEvent } from "../src/client/InputHandler"; +import { EventBus } from "../src/core/EventBus"; + +describe("AutoUpgrade Feature", () => { + let eventBus: EventBus; + + beforeEach(() => { + eventBus = new EventBus(); + }); + + describe("AutoUpgradeEvent", () => { + test("should create AutoUpgradeEvent with correct coordinates", () => { + const event = new AutoUpgradeEvent(100, 200); + expect(event.x).toBe(100); + expect(event.y).toBe(200); + }); + + test("should emit AutoUpgradeEvent when created", () => { + const mockEmit = jest.spyOn(eventBus, "emit"); + + const event = new AutoUpgradeEvent(150, 250); + eventBus.emit(event); + + expect(mockEmit).toHaveBeenCalledWith(event); + expect(mockEmit).toHaveBeenCalledWith( + expect.objectContaining({ + x: 150, + y: 250, + }), + ); + }); + }); + + describe("AutoUpgradeEvent Integration", () => { + test("should handle multiple AutoUpgradeEvents", () => { + const mockEmit = jest.spyOn(eventBus, "emit"); + + const event1 = new AutoUpgradeEvent(100, 200); + const event2 = new AutoUpgradeEvent(300, 400); + + eventBus.emit(event1); + eventBus.emit(event2); + + expect(mockEmit).toHaveBeenCalledTimes(2); + expect(mockEmit).toHaveBeenNthCalledWith(1, event1); + expect(mockEmit).toHaveBeenNthCalledWith(2, event2); + }); + + test("should handle AutoUpgradeEvent with zero coordinates", () => { + const event = new AutoUpgradeEvent(0, 0); + expect(event.x).toBe(0); + expect(event.y).toBe(0); + }); + + test("should handle AutoUpgradeEvent with negative coordinates", () => { + const event = new AutoUpgradeEvent(-100, -200); + expect(event.x).toBe(-100); + expect(event.y).toBe(-200); + }); + + test("should handle AutoUpgradeEvent with decimal coordinates", () => { + const event = new AutoUpgradeEvent(100.5, 200.7); + expect(event.x).toBe(100.5); + expect(event.y).toBe(200.7); + }); + }); + + describe("AutoUpgradeEvent Event Bus Integration", () => { + test("should allow event listeners to subscribe to AutoUpgradeEvent", () => { + const mockListener = jest.fn(); + const event = new AutoUpgradeEvent(100, 200); + + eventBus.on(AutoUpgradeEvent, mockListener); + eventBus.emit(event); + + expect(mockListener).toHaveBeenCalledWith(event); + }); + + test("should allow multiple listeners for AutoUpgradeEvent", () => { + const mockListener1 = jest.fn(); + const mockListener2 = jest.fn(); + const event = new AutoUpgradeEvent(100, 200); + + eventBus.on(AutoUpgradeEvent, mockListener1); + eventBus.on(AutoUpgradeEvent, mockListener2); + eventBus.emit(event); + + expect(mockListener1).toHaveBeenCalledWith(event); + expect(mockListener2).toHaveBeenCalledWith(event); + }); + + test("should not call unsubscribed listeners", () => { + const mockListener = jest.fn(); + const event = new AutoUpgradeEvent(100, 200); + + eventBus.on(AutoUpgradeEvent, mockListener); + eventBus.off(AutoUpgradeEvent, mockListener); + eventBus.emit(event); + + expect(mockListener).not.toHaveBeenCalled(); + }); + }); + + describe("AutoUpgradeEvent Edge Cases", () => { + test("should handle very large coordinates", () => { + const event = new AutoUpgradeEvent( + Number.MAX_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER, + ); + expect(event.x).toBe(Number.MAX_SAFE_INTEGER); + expect(event.y).toBe(Number.MAX_SAFE_INTEGER); + }); + + test("should handle very small coordinates", () => { + const event = new AutoUpgradeEvent( + Number.MIN_SAFE_INTEGER, + Number.MIN_SAFE_INTEGER, + ); + expect(event.x).toBe(Number.MIN_SAFE_INTEGER); + expect(event.y).toBe(Number.MIN_SAFE_INTEGER); + }); + + test("should handle NaN coordinates", () => { + const event = new AutoUpgradeEvent(NaN, NaN); + expect(isNaN(event.x)).toBe(true); + expect(isNaN(event.y)).toBe(true); + }); + + test("should handle Infinity coordinates", () => { + const event = new AutoUpgradeEvent(Infinity, -Infinity); + expect(event.x).toBe(Infinity); + expect(event.y).toBe(-Infinity); + }); + }); + + describe("AutoUpgradeEvent Serialization", () => { + test("should maintain coordinate precision", () => { + const event = new AutoUpgradeEvent(100.123456789, 200.987654321); + expect(event.x).toBe(100.123456789); + expect(event.y).toBe(200.987654321); + }); + + test("should handle string conversion", () => { + const event = new AutoUpgradeEvent(100, 200); + const eventString = JSON.stringify(event); + const parsedEvent = JSON.parse(eventString); + + expect(parsedEvent.x).toBe(100); + expect(parsedEvent.y).toBe(200); + }); + }); +}); diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts new file mode 100644 index 000000000..82050eaa0 --- /dev/null +++ b/tests/InputHandler.test.ts @@ -0,0 +1,400 @@ +/** + * @jest-environment jsdom + */ +import { AutoUpgradeEvent, InputHandler } from "../src/client/InputHandler"; +import { EventBus } from "../src/core/EventBus"; + +class MockPointerEvent { + button: number; + clientX: number; + clientY: number; + pointerId: number; + type: string; + preventDefault: () => void; + + constructor(type: string, init: any) { + this.type = type; + this.button = init.button; + this.clientX = init.clientX; + this.clientY = init.clientY; + this.pointerId = init.pointerId; + this.preventDefault = jest.fn(); + } +} + +global.PointerEvent = MockPointerEvent as any; + +describe("InputHandler AutoUpgrade", () => { + let inputHandler: InputHandler; + let eventBus: EventBus; + let mockCanvas: HTMLCanvasElement; + + beforeEach(() => { + mockCanvas = document.createElement("canvas"); + mockCanvas.width = 800; + mockCanvas.height = 600; + + eventBus = new EventBus(); + + inputHandler = new InputHandler(mockCanvas, eventBus); + }); + + describe("Middle Mouse Button Handling", () => { + test("should emit AutoUpgradeEvent on middle mouse button press", () => { + const mockEmit = jest.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 = jest.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 = jest.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 = jest.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 = jest.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 = jest.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 = jest.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("Pointer Event Handling", () => { + test("should handle pointer events with different pointer IDs", () => { + const mockEmit = jest.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 = jest.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 = jest.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 = jest.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 = jest.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 = jest.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 = jest.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 = jest.fn(); + const mockListener2 = jest.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 = jest.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(); + }); + }); +});