From dd6011ea0578cf1874a27c9f1a0a5866d99a39e9 Mon Sep 17 00:00:00 2001 From: AppieKalac <217355059+AppieKalac@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:50:28 +0200 Subject: [PATCH] Add mobile quick-build and tap-preview controls --- index.html | 2 +- src/client/InputHandler.ts | 90 +++++++++- .../controllers/BuildPreviewController.ts | 167 +++++++++++++----- src/client/hud/layers/UnitDisplay.ts | 26 ++- tests/InputHandler.test.ts | 138 +++++++++++++++ .../BuildPreviewController.test.ts | 121 ++++++++++++- 6 files changed, 480 insertions(+), 64 deletions(-) diff --git a/index.html b/index.html index 315f930332..5d436a75ca 100644 --- a/index.html +++ b/index.html @@ -389,7 +389,7 @@ class="pointer-events-auto bg-gray-800/92 backdrop-blur-sm sm:rounded-tr-lg lg:rounded-t-lg min-[1200px]:rounded-lg shadow-lg order-3 sm:order-none" > - + diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 312891bc98..cb4fe1b6ec 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -99,6 +99,29 @@ export class ToggleStructureEvent implements GameEvent { export class ConfirmGhostStructureEvent implements GameEvent {} +export class TouchGhostPlacementEvent implements GameEvent { + constructor( + public readonly x: number, + public readonly y: number, + ) {} +} + +export class TouchGhostPlacementMoveEvent implements GameEvent { + constructor( + public readonly x: number, + public readonly y: number, + ) {} +} + +export class TouchGhostPlacementDragStartEvent implements GameEvent { + public handled = false; + + constructor( + public readonly x: number, + public readonly y: number, + ) {} +} + export class SwapRocketDirectionEvent implements GameEvent { constructor(public readonly rocketDirectionUp: boolean) {} } @@ -230,6 +253,7 @@ export class InputHandler { private lastGestureScale: number | null = null; private pointerDown: boolean = false; + private lastPointerType: string | null = null; private alternateView = false; @@ -245,6 +269,7 @@ export class InputHandler { private longPressTimer: ReturnType | null = null; private longPressActive: boolean = false; private suppressNextTap: boolean = false; + private touchPlacementDragState: "idle" | "candidate" | "active" = "idle"; private readonly LONG_PRESS_MS = 800; private moveInterval: NodeJS.Timeout | null = null; @@ -511,6 +536,7 @@ export class InputHandler { } this.longPressActive = false; this.suppressNextTap = false; + this.touchPlacementDragState = "idle"; if (this.selectionBoxActive || this.multiSelectionActive) { this.selectionBoxActive = false; this.multiSelectionActive = false; @@ -739,6 +765,7 @@ export class InputHandler { } private onPointerDown(event: PointerEvent) { + this.lastPointerType = event.pointerType; if (event.button === 1) { event.preventDefault(); this.eventBus.emit(new AutoUpgradeEvent(event.clientX, event.clientY)); @@ -761,8 +788,26 @@ export class InputHandler { this.eventBus.emit(new MouseDownEvent(event.clientX, event.clientY)); + this.touchPlacementDragState = "idle"; + if ( + event.pointerType === "touch" && + this.uiState.ghostStructure !== null + ) { + const dragStart = new TouchGhostPlacementDragStartEvent( + event.clientX, + event.clientY, + ); + this.eventBus.emit(dragStart); + if (dragStart.handled) { + this.touchPlacementDragState = "candidate"; + } + } + // Start long-press timer for touch devices - if (event.pointerType === "touch") { + if ( + event.pointerType === "touch" && + this.touchPlacementDragState !== "candidate" + ) { this.longPressActive = false; if (this.longPressTimer !== null) { clearTimeout(this.longPressTimer); @@ -771,6 +816,11 @@ export class InputHandler { this.longPressTimer = setTimeout(() => { this.longPressTimer = null; this.longPressActive = true; + if (this.uiState.ghostStructure !== null) { + this.longPressActive = false; + this.suppressNextTap = true; + return; + } this.canvas.style.cursor = "crosshair"; this.eventBus.emit( new TouchLongPressStartEvent( @@ -791,6 +841,7 @@ export class InputHandler { this.longPressActive = false; this.canvas.style.cursor = ""; } + this.touchPlacementDragState = "idle"; this.lastPinchDistance = this.getPinchDistance(); } } @@ -814,6 +865,12 @@ export class InputHandler { } const wasLongPress = this.longPressActive; this.longPressActive = false; + const wasTouchPlacementDrag = this.touchPlacementDragState === "active"; + this.touchPlacementDragState = "idle"; + if (wasTouchPlacementDrag) { + event.preventDefault(); + return; + } if (wasLongPress) { this.canvas.style.cursor = ""; // If long-press fired but no drag happened (selectionBoxActive is false), @@ -864,7 +921,11 @@ export class InputHandler { event.preventDefault(); return; } - this.eventBus.emit(new TouchEvent(event.x, event.y)); + if (this.uiState.ghostStructure !== null) { + this.eventBus.emit(new TouchGhostPlacementEvent(event.x, event.y)); + } else { + this.eventBus.emit(new TouchEvent(event.x, event.y)); + } event.preventDefault(); return; } @@ -963,12 +1024,19 @@ export class InputHandler { if (this.pointers.size === 1) { const deltaX = event.clientX - this.lastPointerX; const deltaY = event.clientY - this.lastPointerY; + const moveDist = + Math.abs(event.clientX - this.lastPointerDownX) + + Math.abs(event.clientY - this.lastPointerDownY); + + if ( + this.touchPlacementDragState === "candidate" && + moveDist >= this.DRAG_THRESHOLD_PX + ) { + this.touchPlacementDragState = "active"; + } // Cancel long-press if finger moved significantly before timer fires if (this.longPressTimer !== null) { - const moveDist = - Math.abs(event.clientX - this.lastPointerDownX) + - Math.abs(event.clientY - this.lastPointerDownY); if (moveDist >= this.DRAG_THRESHOLD_PX) { clearTimeout(this.longPressTimer); this.longPressTimer = null; @@ -977,7 +1045,14 @@ export class InputHandler { // If shift is held OR touch long-press is active OR selection box already // started, continue emitting selection box updates - if ( + if (this.touchPlacementDragState === "active") { + this.eventBus.emit( + new TouchGhostPlacementMoveEvent(event.clientX, event.clientY), + ); + } else if (this.touchPlacementDragState === "candidate") { + // Keep a hold that began on the preview stable until the long-press + // threshold is reached; ordinary touches elsewhere still pan. + } else if ( this.selectionBoxActive || this.activeKeys.has(this.keybinds.boxSelectWarships) || this.longPressActive @@ -1013,6 +1088,9 @@ export class InputHandler { private onContextMenu(event: MouseEvent) { event.preventDefault(); + if (this.lastPointerType === "touch") { + return; + } if (this.gameView.inSpawnPhase()) { return; } diff --git a/src/client/controllers/BuildPreviewController.ts b/src/client/controllers/BuildPreviewController.ts index 23bb674b93..ba6aa23532 100644 --- a/src/client/controllers/BuildPreviewController.ts +++ b/src/client/controllers/BuildPreviewController.ts @@ -15,6 +15,7 @@ import { import { BuildableUnit, bulkCost, + Cell, PlayerBuildableUnitType, UnitType, } from "../../core/game/Game"; @@ -25,6 +26,9 @@ import { ConfirmGhostStructureEvent, MouseMoveEvent, MouseUpEvent, + TouchGhostPlacementDragStartEvent, + TouchGhostPlacementEvent, + TouchGhostPlacementMoveEvent, } from "../InputHandler"; import { buildNukeTrajectory, MapRenderer } from "../render/gl"; import type { SAMInfo } from "../render/gl/utils/NukeTrajectory"; @@ -46,6 +50,7 @@ export function shouldPreserveGhostAfterBuild(unitType: UnitType): boolean { // draws the red X marker essentially at the destination while leaving the // visible line unchanged (1.0 would mean "no marker"). const T_BLOCKED_DST = 0.9999; +const TOUCH_CONFIRM_DISTANCE_PX = 32; /** * Whether a SAM belongs in the nuke trajectory preview's threat set. @@ -76,7 +81,9 @@ export class BuildPreviewController implements Controller { private readonly usedSafetyAllies: Set = new Set(); private readonly mousePos = { x: 0, y: 0 }; private lastGhostQueryAt: number = 0; - private pendingConfirm: MouseUpEvent | null = null; + private confirmRequestId = 0; + private touchPreviewTile: TileRef | null = null; + private touchPlacementMode = false; // Buildable validation runs on the snapped tile under the cursor, but the // rendered icon follows the cursor at sub-tile precision so motion is @@ -107,6 +114,15 @@ export class BuildPreviewController implements Controller { init() { this.eventBus.on(MouseMoveEvent, (e) => this.moveGhost(e)); this.eventBus.on(MouseUpEvent, (e) => this.requestConfirmStructure(e)); + this.eventBus.on(TouchGhostPlacementEvent, (e) => + this.handleTouchPlacement(e), + ); + this.eventBus.on(TouchGhostPlacementMoveEvent, (e) => + this.moveTouchPreview(e.x, e.y), + ); + this.eventBus.on(TouchGhostPlacementDragStartEvent, (e) => { + e.handled = this.isNearTouchPreview(e.x, e.y); + }); this.eventBus.on(ConfirmGhostStructureEvent, () => this.requestConfirmStructure( new MouseUpEvent(this.mousePos.x, this.mousePos.y), @@ -124,10 +140,16 @@ export class BuildPreviewController implements Controller { const ghost = this.lastGhostData; const traj = this.nukeTrajectoryStatic; if (ghost !== null || traj !== null) { - const w = this.transformHandler.screenToWorldCoordinatesFloat( - this.mousePos.x, - this.mousePos.y, - ); + const w = + this.touchPreviewTile === null + ? this.transformHandler.screenToWorldCoordinatesFloat( + this.mousePos.x, + this.mousePos.y, + ) + : { + x: this.game.x(this.touchPreviewTile) + 0.5, + y: this.game.y(this.touchPreviewTile) + 0.5, + }; if (ghost !== null) { // The range circle (defense post / SAM / nuke radius) normally // follows the cursor, so smooth it the same way as the icon. When @@ -203,16 +225,28 @@ export class BuildPreviewController implements Controller { renderGhost() { if (!this.ghostUnit) return; + if (this.touchPlacementMode && this.touchPreviewTile === null) { + this.lastGhostData = null; + this.view.updateGhostPreview(null); + this.clearNukeTrajectory(); + return; + } const now = performance.now(); if (now - this.lastGhostQueryAt < 50) return; this.lastGhostQueryAt = now; let tileRef: TileRef | undefined; let trajectoryTileRef: TileRef | undefined; - const tile = this.transformHandler.screenToWorldCoordinates( - this.mousePos.x, - this.mousePos.y, - ); + const tile = + this.touchPreviewTile === null + ? this.transformHandler.screenToWorldCoordinates( + this.mousePos.x, + this.mousePos.y, + ) + : new Cell( + this.game.x(this.touchPreviewTile), + this.game.y(this.touchPreviewTile), + ); if (this.game.isValidCoord(tile.x, tile.y)) { tileRef = this.game.ref(tile.x, tile.y); trajectoryTileRef = tileRef; @@ -260,7 +294,6 @@ export class BuildPreviewController implements Controller { ?.buildables(tileRef, [this.ghostUnit?.buildableUnit.type]) .then((buildables) => { if (!this.ghostUnit) { - this.pendingConfirm = null; this.emitGhostPreview(tileRef, targetingAlly, trajectoryTileRef); return; } @@ -273,21 +306,12 @@ export class BuildPreviewController implements Controller { canBuild: false, canUpgrade: false, }); - this.pendingConfirm = null; this.emitGhostPreview(tileRef, targetingAlly, trajectoryTileRef); return; } this.ghostUnit.buildableUnit = unit; - if (this.pendingConfirm !== null) { - const ev = this.pendingConfirm; - this.pendingConfirm = null; - if (this.isGhostReadyForConfirm()) { - this.createStructure(ev); - } - } - this.emitGhostPreview(tileRef, targetingAlly, trajectoryTileRef); }); } @@ -507,45 +531,56 @@ export class BuildPreviewController implements Controller { }; } - private isGhostReadyForConfirm(): boolean { - if (!this.ghostUnit) return false; - const bu = this.ghostUnit.buildableUnit; - return bu.canBuild !== false || bu.canUpgrade !== false; + private requestConfirmStructure(e: MouseUpEvent): void { + if (!this.ghostUnit && !this.uiState.ghostStructure) return; + const ghostType = this.uiState.ghostStructure; + const player = this.game.myPlayer(); + if (!player || ghostType === null) return; + + const tile = this.transformHandler.screenToWorldCoordinates(e.x, e.y); + if (!this.game.isValidCoord(tile.x, tile.y)) return; + const tileRef = this.game.ref(tile.x, tile.y); + if (this.game.isImpassable(tileRef)) return; + + this.requestConfirmTile(tileRef); } - private requestConfirmStructure(e: MouseUpEvent): void { + private requestConfirmTile(tileRef: TileRef): void { if (!this.ghostUnit && !this.uiState.ghostStructure) return; - if (this.isGhostReadyForConfirm()) { - this.createStructure(e); - } else { - this.pendingConfirm = e; - } + const ghostType = this.uiState.ghostStructure; + const player = this.game.myPlayer(); + if (!player || ghostType === null) return; + + const requestId = ++this.confirmRequestId; + player.buildables(tileRef, [ghostType]).then((buildables) => { + if ( + requestId !== this.confirmRequestId || + this.uiState.ghostStructure !== ghostType || + !this.ghostUnit + ) { + return; + } + const validated = buildables.find((u) => u.type === ghostType); + if (!validated) return; + this.ghostUnit.buildableUnit = validated; + this.createStructure(tileRef, validated); + }); } - private createStructure(e: MouseUpEvent) { + private createStructure(tile: TileRef, buildableUnit: BuildableUnit) { if (!this.ghostUnit) return; - if ( - this.ghostUnit.buildableUnit.canBuild === false && - this.ghostUnit.buildableUnit.canUpgrade === false - ) { - this.removeGhostStructure(); - return; - } - const tile = this.transformHandler.screenToWorldCoordinates(e.x, e.y); - if (this.ghostUnit.buildableUnit.canUpgrade !== false) { + if (buildableUnit.canUpgrade !== false) { this.eventBus.emit( new SendUpgradeStructureIntentEvent( - this.ghostUnit.buildableUnit.canUpgrade, - this.ghostUnit.buildableUnit.type, + buildableUnit.canUpgrade, + buildableUnit.type, this.uiState.upgradeMultiplier || 1, ), ); this.removeGhostStructure(); - } else if (this.ghostUnit.buildableUnit.canBuild) { - const unitType = this.ghostUnit.buildableUnit.type; - const targetTile = this.game.ref(tile.x, tile.y); - - if (this.shouldBlockRecentAllyNuke(targetTile, unitType)) { + } else if (buildableUnit.canBuild) { + const unitType = buildableUnit.type; + if (this.shouldBlockRecentAllyNuke(tile, unitType)) { return; } @@ -557,12 +592,13 @@ export class BuildPreviewController implements Controller { this.eventBus.emit( new BuildUnitIntentEvent( unitType, - targetTile, + tile, rocketDirectionUp, isNuke ? this.uiState.upgradeMultiplier || 1 : undefined, ), ); - if (!shouldPreserveGhostAfterBuild(unitType)) { + this.touchPreviewTile = null; + if (this.touchPlacementMode || !shouldPreserveGhostAfterBuild(unitType)) { this.removeGhostStructure(); } } else { @@ -628,10 +664,42 @@ export class BuildPreviewController implements Controller { } private moveGhost(e: MouseMoveEvent) { + this.touchPlacementMode = false; + this.touchPreviewTile = null; this.mousePos.x = e.x; this.mousePos.y = e.y; } + private handleTouchPlacement(e: TouchGhostPlacementEvent): void { + this.touchPlacementMode = true; + if (this.touchPreviewTile !== null && this.isNearTouchPreview(e.x, e.y)) { + this.requestConfirmTile(this.touchPreviewTile); + return; + } + + this.moveTouchPreview(e.x, e.y); + } + + private moveTouchPreview(x: number, y: number): void { + const tile = this.transformHandler.screenToWorldCoordinates(x, y); + if (!this.game.isValidCoord(tile.x, tile.y)) return; + this.touchPreviewTile = this.game.ref(tile.x, tile.y); + this.lastGhostQueryAt = 0; + } + + private isNearTouchPreview(x: number, y: number): boolean { + if (this.touchPreviewTile === null) return false; + const preview = this.transformHandler.worldToScreenCoordinates( + new Cell( + this.game.x(this.touchPreviewTile) + 0.5, + this.game.y(this.touchPreviewTile) + 0.5, + ), + ); + return ( + Math.hypot(x - preview.x, y - preview.y) <= TOUCH_CONFIRM_DISTANCE_PX + ); + } + private createGhostStructure(type: PlayerBuildableUnitType | null) { if (type === null) return; if (this.game.myPlayer() === null) return; @@ -648,7 +716,8 @@ export class BuildPreviewController implements Controller { } private clearGhostStructure() { - this.pendingConfirm = null; + this.confirmRequestId++; + this.touchPreviewTile = null; this.ghostUnit = null; this.lastGhostData = null; this.view.updateGhostPreview(null); diff --git a/src/client/hud/layers/UnitDisplay.ts b/src/client/hud/layers/UnitDisplay.ts index cdae3bf995..2a3ce6660c 100644 --- a/src/client/hud/layers/UnitDisplay.ts +++ b/src/client/hud/layers/UnitDisplay.ts @@ -121,7 +121,9 @@ export class UnitDisplay extends LitElement implements Controller { return html`
-
+
${this.renderUnitItem( cityIcon, this._cities, @@ -229,7 +231,7 @@ export class UnitDisplay extends LitElement implements Controller { ${hovered ? html`
${translateText( @@ -258,7 +260,7 @@ export class UnitDisplay extends LitElement implements Controller {
{ @@ -290,13 +292,23 @@ export class UnitDisplay extends LitElement implements Controller { @mouseleave=${() => this.eventBus?.emit(new ToggleStructureEvent(null))} > - ${html`
+ ${html``} -
- ${structureKey} +
+ ${structureKey} ${number !== null - ? html`${renderNumber(number)}` + ? html`${renderNumber(number)}` : null}
diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 5d7c86aee3..5931ebb686 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -3,6 +3,9 @@ import { ConfirmGhostStructureEvent, ContextMenuEvent, InputHandler, + TouchGhostPlacementDragStartEvent, + TouchGhostPlacementEvent, + TouchGhostPlacementMoveEvent, UnitSelectionEvent, WarshipSelectionBoxCancelEvent, WarshipSelectionBoxCompleteEvent, @@ -1171,6 +1174,141 @@ describe("Warship box selection (Shift+drag)", () => { }); }); +describe("mobile tap-preview placement", () => { + let inputHandler: InputHandler; + let eventBus: EventBus; + let uiState: UIState; + + beforeEach(() => { + eventBus = new EventBus(); + uiState = { + attackRatio: 20, + ghostStructure: UnitType.City, + rocketDirectionUp: true, + } as UIState; + inputHandler = new InputHandler( + { inSpawnPhase: () => false } as GameView, + uiState, + document.createElement("canvas"), + eventBus, + ); + }); + + afterEach(() => { + inputHandler.destroy(); + }); + + test("touch taps are delegated to the preview controller", () => { + const placements: TouchGhostPlacementEvent[] = []; + eventBus.on(TouchGhostPlacementEvent, (event) => placements.push(event)); + + const tap = (x: number, y: number, pointerId: number) => { + inputHandler["onPointerDown"]( + new PointerEvent("pointerdown", { + button: 0, + clientX: x, + clientY: y, + pointerId, + pointerType: "touch", + }), + ); + inputHandler["onPointerUp"]( + new PointerEvent("pointerup", { + button: 0, + clientX: x, + clientY: y, + pointerId, + pointerType: "touch", + }), + ); + }; + + tap(100, 110, 1); + tap(140, 150, 2); + + expect(placements).toEqual([ + expect.objectContaining({ x: 100, y: 110 }), + expect.objectContaining({ x: 140, y: 150 }), + ]); + }); + + test("movement starting on the preview drags it without a hold delay", () => { + const moves: TouchGhostPlacementMoveEvent[] = []; + eventBus.on(TouchGhostPlacementDragStartEvent, (event) => { + event.handled = true; + }); + eventBus.on(TouchGhostPlacementMoveEvent, (event) => moves.push(event)); + + inputHandler["onPointerDown"]( + new PointerEvent("pointerdown", { + button: 0, + clientX: 100, + clientY: 100, + pointerId: 1, + pointerType: "touch", + }), + ); + inputHandler["onPointerMove"]( + new PointerEvent("pointermove", { + button: 0, + clientX: 104, + clientY: 104, + pointerId: 1, + pointerType: "touch", + }), + ); + expect(moves).toHaveLength(0); + + inputHandler["onPointerMove"]( + new PointerEvent("pointermove", { + button: 0, + clientX: 125, + clientY: 130, + pointerId: 1, + pointerType: "touch", + }), + ); + + expect(moves).toEqual([expect.objectContaining({ x: 125, y: 130 })]); + }); + + test("a touch-generated context menu does not cancel placement", () => { + inputHandler["onPointerDown"]( + new PointerEvent("pointerdown", { + button: 0, + clientX: 100, + clientY: 100, + pointerId: 1, + pointerType: "touch", + }), + ); + + inputHandler["onContextMenu"]( + new MouseEvent("contextmenu", { clientX: 100, clientY: 100 }), + ); + + expect(uiState.ghostStructure).toBe(UnitType.City); + }); + + test("a mouse context menu still cancels placement", () => { + inputHandler["onPointerDown"]( + new PointerEvent("pointerdown", { + button: 2, + clientX: 100, + clientY: 100, + pointerId: 1, + pointerType: "mouse", + }), + ); + + inputHandler["onContextMenu"]( + new MouseEvent("contextmenu", { clientX: 100, clientY: 100 }), + ); + + expect(uiState.ghostStructure).toBeNull(); + }); +}); + describe("InputHandler right-click cancels unit selection (#4692)", () => { let inputHandler: InputHandler; let eventBus: EventBus; diff --git a/tests/client/controllers/BuildPreviewController.test.ts b/tests/client/controllers/BuildPreviewController.test.ts index 267997cb46..227d4eaa87 100644 --- a/tests/client/controllers/BuildPreviewController.test.ts +++ b/tests/client/controllers/BuildPreviewController.test.ts @@ -1,8 +1,15 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { + BuildPreviewController, samThreatensNukePreview, shouldPreserveGhostAfterBuild, } from "../../../src/client/controllers/BuildPreviewController"; +import { + MouseUpEvent, + TouchGhostPlacementEvent, +} from "../../../src/client/InputHandler"; +import { SendUpgradeStructureIntentEvent } from "../../../src/client/Transport"; +import { EventBus } from "../../../src/core/EventBus"; import { UnitType } from "../../../src/core/game/Game"; describe("BuildPreviewController ghost preservation (locked nuke / Enter confirm)", () => { @@ -72,3 +79,115 @@ describe("samThreatensNukePreview (nuke trajectory threat set, #4226)", () => { ).toBe(false); }); }); + +describe("BuildPreviewController confirmation validation", () => { + test("uses canUpgrade from the tapped tile instead of the cached preview", async () => { + const eventBus = new EventBus(); + const upgrades: SendUpgradeStructureIntentEvent[] = []; + eventBus.on(SendUpgradeStructureIntentEvent, (event) => + upgrades.push(event), + ); + const buildables = vi.fn().mockResolvedValue([ + { + type: UnitType.City, + canBuild: false, + canUpgrade: 22, + cost: 0n, + overlappingRailroads: [], + ghostRailPaths: [], + }, + ]); + const uiState = { + ghostStructure: UnitType.City, + upgradeMultiplier: 1, + }; + const controller = new BuildPreviewController( + { + myPlayer: () => ({ buildables }), + isValidCoord: () => true, + ref: () => 123, + isImpassable: () => false, + } as any, + eventBus, + uiState as any, + { screenToWorldCoordinates: () => ({ x: 4, y: 5 }) } as any, + { + updateGhostPreview: vi.fn(), + updateNukeTrajectory: vi.fn(), + } as any, + { nukeAllianceSafetyDuration: () => 0 } as any, + ); + (controller as any).ghostUnit = { + buildableUnit: { + type: UnitType.City, + canBuild: false, + canUpgrade: 11, + }, + }; + + (controller as any).requestConfirmStructure(new MouseUpEvent(40, 50)); + await vi.waitFor(() => expect(upgrades).toHaveLength(1)); + + expect(buildables).toHaveBeenCalledWith(123, [UnitType.City]); + expect(upgrades[0].unitId).toBe(22); + }); + + test("a distant tap moves the anchored preview and a nearby tap confirms it", async () => { + const eventBus = new EventBus(); + const buildables = vi.fn().mockResolvedValue([ + { + type: UnitType.City, + canBuild: true, + canUpgrade: false, + cost: 0n, + overlappingRailroads: [], + ghostRailPaths: [], + }, + ]); + const uiState = { ghostStructure: UnitType.City, upgradeMultiplier: 1 }; + const controller = new BuildPreviewController( + { + myPlayer: () => ({ buildables }), + isValidCoord: () => true, + ref: (x: number, y: number) => x * 100 + y, + x: (ref: number) => Math.floor(ref / 100), + y: (ref: number) => ref % 100, + isImpassable: () => false, + } as any, + eventBus, + uiState as any, + { + screenToWorldCoordinates: (x: number) => + x < 150 ? { x: 1, y: 1 } : { x: 2, y: 2 }, + worldToScreenCoordinates: (cell: { x: number }) => + cell.x < 2 ? { x: 100, y: 100 } : { x: 200, y: 200 }, + } as any, + { + updateGhostPreview: vi.fn(), + updateNukeTrajectory: vi.fn(), + } as any, + { nukeAllianceSafetyDuration: () => 0 } as any, + ); + (controller as any).ghostUnit = { + buildableUnit: { type: UnitType.City }, + }; + + (controller as any).handleTouchPlacement( + new TouchGhostPlacementEvent(100, 100), + ); + expect((controller as any).touchPreviewTile).toBe(101); + + (controller as any).handleTouchPlacement( + new TouchGhostPlacementEvent(200, 200), + ); + expect((controller as any).touchPreviewTile).toBe(202); + expect(buildables).not.toHaveBeenCalled(); + + (controller as any).handleTouchPlacement( + new TouchGhostPlacementEvent(210, 210), + ); + await vi.waitFor(() => + expect(buildables).toHaveBeenCalledWith(202, [UnitType.City]), + ); + }); +});