diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index ffb642ab7..cd6b1d31e 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -54,6 +54,7 @@ export function createRenderer( attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, + lockedGhostTile: null, } as UIState; //hide when the game renders diff --git a/src/client/graphics/UIState.ts b/src/client/graphics/UIState.ts index f47acef88..292532466 100644 --- a/src/client/graphics/UIState.ts +++ b/src/client/graphics/UIState.ts @@ -1,7 +1,9 @@ import { UnitType } from "../../core/game/Game"; +import { TileRef } from "../../core/game/GameMap"; export interface UIState { attackRatio: number; ghostStructure: UnitType | null; rocketDirectionUp: boolean; + lockedGhostTile: TileRef | null; } diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts index 32bd471f0..0b9551564 100644 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -104,30 +104,44 @@ export class NukeTrajectoryPreviewLayer implements Layer { return; } - // Convert mouse position to world coordinates - const rect = this.transformHandler.boundingRect(); - if (!rect) { - this.trajectoryPoints = []; - this.cachedSpawnTile = null; - return; + let targetTile: TileRef | null = null; + + // If ghost is locked, use the locked tile; otherwise use mouse position + if (this.uiState.lockedGhostTile) { + targetTile = this.uiState.lockedGhostTile; + } else { + // Convert mouse position to world coordinates + const rect = this.transformHandler.boundingRect(); + if (!rect) { + this.trajectoryPoints = []; + this.cachedSpawnTile = null; + return; + } + + const localX = this.mousePos.x - rect.left; + const localY = this.mousePos.y - rect.top; + const worldCoords = this.transformHandler.screenToWorldCoordinates( + localX, + localY, + ); + + if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) { + this.trajectoryPoints = []; + this.lastTargetTile = null; + this.cachedSpawnTile = null; + return; + } + + targetTile = this.game.ref(worldCoords.x, worldCoords.y); } - const localX = this.mousePos.x - rect.left; - const localY = this.mousePos.y - rect.top; - const worldCoords = this.transformHandler.screenToWorldCoordinates( - localX, - localY, - ); - - if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) { + if (!targetTile) { this.trajectoryPoints = []; this.lastTargetTile = null; this.cachedSpawnTile = null; return; } - const targetTile = this.game.ref(worldCoords.x, worldCoords.y); - // Only recalculate if target tile changed if (this.lastTargetTile === targetTile) { return; @@ -190,27 +204,39 @@ export class NukeTrajectoryPreviewLayer implements Layer { return; } - // Convert mouse position to world coordinates - const rect = this.transformHandler.boundingRect(); - if (!rect) { + let targetTile: TileRef | null = null; + + // If ghost is locked, use the locked tile; otherwise use mouse position + if (this.uiState.lockedGhostTile) { + targetTile = this.uiState.lockedGhostTile; + } else { + // Convert mouse position to world coordinates + const rect = this.transformHandler.boundingRect(); + if (!rect) { + this.trajectoryPoints = []; + return; + } + + const localX = this.mousePos.x - rect.left; + const localY = this.mousePos.y - rect.top; + const worldCoords = this.transformHandler.screenToWorldCoordinates( + localX, + localY, + ); + + if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) { + this.trajectoryPoints = []; + return; + } + + targetTile = this.game.ref(worldCoords.x, worldCoords.y); + } + + if (!targetTile) { this.trajectoryPoints = []; return; } - const localX = this.mousePos.x - rect.left; - const localY = this.mousePos.y - rect.top; - const worldCoords = this.transformHandler.screenToWorldCoordinates( - localX, - localY, - ); - - if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) { - this.trajectoryPoints = []; - return; - } - - const targetTile = this.game.ref(worldCoords.x, worldCoords.y); - // Calculate trajectory using ParabolaPathFinder with cached spawn tile const pathFinder = new ParabolaPathFinder(this.game); const speed = this.game.config().defaultNukeSpeed(); diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 6f72f7149..53e30ea23 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -3,6 +3,7 @@ import { AllPlayers, PlayerActions, UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { Emoji, flattenedEmojiTable } from "../../../core/Util"; +import { GhostStructureChangedEvent } from "../../InputHandler"; import { renderNumber, translateText } from "../../Utils"; import { UIState } from "../UIState"; import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu"; @@ -412,7 +413,23 @@ function createMenuElements( if (buildableUnit === undefined) { return; } - if (params.buildMenu.canBuildOrUpgrade(item)) { + const isNukeSelection = + filterType === "attack" && + (item.unitType === UnitType.AtomBomb || + item.unitType === UnitType.HydrogenBomb); + + const canBuild = params.buildMenu.canBuildOrUpgrade(item); + + if (isNukeSelection && canBuild && params.uiState) { + // Reuse ghost placement flow so players see the full nuke trajectory/interception preview + params.uiState.ghostStructure = item.unitType; + params.uiState.lockedGhostTile = params.tile; + params.eventBus.emit(new GhostStructureChangedEvent(item.unitType)); + params.closeMenu(); + return; + } + + if (canBuild) { params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile); } params.closeMenu(); diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 0c896f717..4d1ce82cd 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -19,6 +19,7 @@ import { GhostStructureChangedEvent, MouseMoveEvent, MouseUpEvent, + SwapRocketDirectionEvent, ToggleStructureEvent as ToggleStructuresEvent, } from "../../InputHandler"; import { @@ -84,6 +85,12 @@ export class StructureIconsLayer implements Layer { private readonly mousePos = { x: 0, y: 0 }; private renderSprites = true; private factory: SpriteFactory; + private ghostControls: { + container: HTMLDivElement; + confirm: HTMLButtonElement; + cancel: HTMLButtonElement; + flip: HTMLButtonElement; + } | null = null; private readonly structures: Map = new Map([ [UnitType.City, { visible: true }], [UnitType.Factory, { visible: true }], @@ -244,21 +251,44 @@ export class StructureIconsLayer implements Layer { renderGhost() { if (!this.ghostUnit) return; + const rect = this.transformHandler.boundingRect(); + if (!rect) return; + + let localX = this.mousePos.x - rect.left; + let localY = this.mousePos.y - rect.top; + let tileRef: TileRef | undefined; + + // Always reposition locked ghost every frame (smooth when panning) + if ( + this.uiState.lockedGhostTile && + this.isLockableGhost(this.ghostUnit.buildableUnit.type) + ) { + tileRef = this.uiState.lockedGhostTile; + const screen = this.transformHandler.worldToScreenCoordinates( + new Cell(this.game.x(tileRef), this.game.y(tileRef)), + ); + localX = screen.x - rect.left; + localY = screen.y - rect.top; + this.ghostUnit.container.position.set(localX, localY); + this.ghostUnit.range?.position.set(localX, localY); + this.updateGhostControls(localX, localY, rect); + } else { + this.hideGhostControls(); + const tile = this.transformHandler.screenToWorldCoordinates( + localX, + localY, + ); + if (this.game.isValidCoord(tile.x, tile.y)) { + tileRef = this.game.ref(tile.x, tile.y); + } + } + + // Throttle expensive tile action queries const now = performance.now(); if (now - this.lastGhostQueryAt < 50) { return; } - const rect = this.transformHandler.boundingRect(); - if (!rect) return; - - const localX = this.mousePos.x - rect.left; - const localY = this.mousePos.y - rect.top; this.lastGhostQueryAt = now; - let tileRef: TileRef | undefined; - const tile = this.transformHandler.screenToWorldCoordinates(localX, localY); - if (this.game.isValidCoord(tile.x, tile.y)) { - tileRef = this.game.ref(tile.x, tile.y); - } // Check if targeting an ally (for nuke warning visual) // Uses shared logic with NukeExecution.maybeBreakAlliances() @@ -377,44 +407,42 @@ export class StructureIconsLayer implements Layer { } private createStructure(e: MouseUpEvent) { - if (!this.ghostUnit) return; + // When locked ghost is active, clicking changes the target location if ( - this.ghostUnit.buildableUnit.canBuild === false && - this.ghostUnit.buildableUnit.canUpgrade === false + this.uiState.lockedGhostTile && + this.isLockableGhost(this.ghostUnit?.buildableUnit.type ?? null) ) { + const newTile = this.getTileFromMouseEvent(e); + if (newTile) { + this.uiState.lockedGhostTile = newTile; + // Force trajectory recalculation by clearing cached tile + this.eventBus.emit( + new GhostStructureChangedEvent(this.uiState.ghostStructure), + ); + } + return; + } + + if (!this.ghostUnit) return; + if (this.isGhostBuildBlocked()) { this.removeGhostStructure(); return; } - const rect = this.transformHandler.boundingRect(); - if (!rect) return; - const x = e.x - rect.left; - const y = e.y - rect.top; - const tile = this.transformHandler.screenToWorldCoordinates(x, y); - if (this.ghostUnit.buildableUnit.canUpgrade !== false) { - this.eventBus.emit( - new SendUpgradeStructureIntentEvent( - this.ghostUnit.buildableUnit.canUpgrade, - this.ghostUnit.buildableUnit.type, - ), - ); - } else if (this.ghostUnit.buildableUnit.canBuild) { - const unitType = this.ghostUnit.buildableUnit.type; - const rocketDirectionUp = - unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb - ? this.uiState.rocketDirectionUp - : undefined; - this.eventBus.emit( - new BuildUnitIntentEvent( - unitType, - this.game.ref(tile.x, tile.y), - rocketDirectionUp, - ), - ); + const tileRef = this.resolveTargetTileFromEvent(e); + if (!tileRef) { + this.removeGhostStructure(); + return; } - this.removeGhostStructure(); + this.commitStructure(tileRef); } private moveGhost(e: MouseMoveEvent) { + if ( + this.uiState.lockedGhostTile && + this.isLockableGhost(this.ghostUnit?.buildableUnit.type ?? null) + ) { + return; + } this.mousePos.x = e.x; this.mousePos.y = e.y; @@ -434,9 +462,22 @@ export class StructureIconsLayer implements Layer { if (type === null) { return; } + if (!this.isLockableGhost(type)) { + this.uiState.lockedGhostTile = null; + } const rect = this.transformHandler.boundingRect(); - const localX = this.mousePos.x - rect.left; - const localY = this.mousePos.y - rect.top; + let localX = this.mousePos.x - rect.left; + let localY = this.mousePos.y - rect.top; + if (this.uiState.lockedGhostTile && this.isLockableGhost(type)) { + const screen = this.transformHandler.worldToScreenCoordinates( + new Cell( + this.game.x(this.uiState.lockedGhostTile), + this.game.y(this.uiState.lockedGhostTile), + ), + ); + localX = screen.x - rect.left; + localY = screen.y - rect.top; + } const ghost = this.factory.createGhostContainer( player, this.ghostStage, @@ -464,6 +505,7 @@ export class StructureIconsLayer implements Layer { this.ghostUnit.range?.destroy(); this.ghostUnit = null; } + this.destroyGhostControls(); if (this.potentialUpgrade) { this.potentialUpgrade.iconContainer.filters = []; this.potentialUpgrade.dotContainer.filters = []; @@ -474,9 +516,154 @@ export class StructureIconsLayer implements Layer { private removeGhostStructure() { this.clearGhostStructure(); this.uiState.ghostStructure = null; + this.uiState.lockedGhostTile = null; this.eventBus.emit(new GhostStructureChangedEvent(null)); } + private emitBuildIntent(tileRef: TileRef) { + if (!this.ghostUnit) return; + if (this.ghostUnit.buildableUnit.canUpgrade !== false) { + this.eventBus.emit( + new SendUpgradeStructureIntentEvent( + this.ghostUnit.buildableUnit.canUpgrade, + this.ghostUnit.buildableUnit.type, + ), + ); + } else if (this.ghostUnit.buildableUnit.canBuild) { + const unitType = this.ghostUnit.buildableUnit.type; + const rocketDirectionUp = + unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb + ? this.uiState.rocketDirectionUp + : undefined; + this.eventBus.emit( + new BuildUnitIntentEvent(unitType, tileRef, rocketDirectionUp), + ); + } + } + + private commitStructure(tileRef: TileRef) { + this.emitBuildIntent(tileRef); + this.removeGhostStructure(); + } + + private getTileFromMouseEvent(e: MouseUpEvent): TileRef | null { + const rect = this.transformHandler.boundingRect(); + if (!rect) return null; + const x = e.x - rect.left; + const y = e.y - rect.top; + const tile = this.transformHandler.screenToWorldCoordinates(x, y); + if (!this.game.isValidCoord(tile.x, tile.y)) return null; + return this.game.ref(tile.x, tile.y); + } + + private resolveTargetTileFromEvent(e: MouseUpEvent): TileRef | null { + if ( + this.uiState.lockedGhostTile && + this.isLockableGhost(this.ghostUnit?.buildableUnit.type ?? null) + ) { + return this.uiState.lockedGhostTile; + } + return this.getTileFromMouseEvent(e); + } + + private isGhostBuildBlocked(): boolean { + return ( + !this.ghostUnit || + (this.ghostUnit.buildableUnit.canBuild === false && + this.ghostUnit.buildableUnit.canUpgrade === false) + ); + } + + private isLockableGhost(type: UnitType | null): boolean { + return type === UnitType.AtomBomb || type === UnitType.HydrogenBomb; + } + + private ensureGhostControls() { + if (this.ghostControls) return; + + const container = document.createElement("div"); + container.style.position = "absolute"; + container.style.display = "flex"; + container.style.gap = "8px"; + container.style.transform = "translate(-50%, 0)"; + container.style.pointerEvents = "auto"; + container.style.zIndex = "5"; + + const makeButton = ( + label: string, + background: string, + onClick: () => void, + ): HTMLButtonElement => { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.style.minHeight = "48px"; + button.style.minWidth = "48px"; + button.style.padding = "8px 16px"; + button.style.borderRadius = "6px"; + button.style.border = "none"; + button.style.fontWeight = "700"; + button.style.fontSize = "13px"; + button.style.color = "#ffffff"; + button.style.background = background; + button.style.cursor = "pointer"; + button.style.boxShadow = "0 2px 6px rgba(0,0,0,0.25)"; + button.style.whiteSpace = "nowrap"; + button.addEventListener("click", onClick); + return button; + }; + + const confirm = makeButton("OK", "#2e7d32", () => { + if (this.uiState.lockedGhostTile) { + this.emitBuildIntent(this.uiState.lockedGhostTile); + } + }); + + const flip = makeButton("Flip", "#1565c0", () => { + const next = !this.uiState.rocketDirectionUp; + this.eventBus.emit(new SwapRocketDirectionEvent(next)); + }); + + const cancel = makeButton("X", "#b71c1c", () => + this.removeGhostStructure(), + ); + + container.append(confirm, flip, cancel); + document.body.appendChild(container); + + this.ghostControls = { container, confirm, cancel, flip }; + } + + private destroyGhostControls() { + if (!this.ghostControls) return; + this.ghostControls.container.remove(); + this.ghostControls = null; + } + + private hideGhostControls() { + this.destroyGhostControls(); + } + + private updateGhostControls(localX: number, localY: number, rect: DOMRect) { + if ( + !this.ghostUnit || + !this.uiState.lockedGhostTile || + !this.isLockableGhost(this.ghostUnit.buildableUnit.type) + ) { + this.destroyGhostControls(); + return; + } + this.ensureGhostControls(); + const offsetY = 40; + const scale = Math.max( + 0.75, + Math.min(1.4, this.transformHandler.scale / 2), + ); + this.ghostControls!.container.style.left = `${rect.left + localX}px`; + this.ghostControls!.container.style.top = `${rect.top + localY + offsetY}px`; + this.ghostControls!.container.style.transform = `translate(-50%, 0) scale(${scale})`; + } + private resolveGhostRangeLevel( buildableUnit: BuildableUnit, ): number | undefined {