From f8b1f49124f76356891380b9b22ede2c078791ec Mon Sep 17 00:00:00 2001 From: Ryan Barlow Date: Thu, 8 Jan 2026 10:01:29 +0000 Subject: [PATCH] move into seperate manager --- .../graphics/layers/GhostStructureManager.ts | 583 +++++++++++++++++ .../graphics/layers/StructureIconsLayer.ts | 607 +----------------- 2 files changed, 618 insertions(+), 572 deletions(-) create mode 100644 src/client/graphics/layers/GhostStructureManager.ts diff --git a/src/client/graphics/layers/GhostStructureManager.ts b/src/client/graphics/layers/GhostStructureManager.ts new file mode 100644 index 000000000..888ba0d37 --- /dev/null +++ b/src/client/graphics/layers/GhostStructureManager.ts @@ -0,0 +1,583 @@ +import { OutlineFilter } from "pixi-filters"; +import { BitmapText, Container, Graphics } from "pixi.js"; +import { EventBus } from "../../../core/EventBus"; +import { wouldNukeBreakAlliance } from "../../../core/execution/Util"; +import { + BuildableUnit, + Cell, + PlayerActions, + UnitType, +} from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView } from "../../../core/game/GameView"; +import { + ContextMenuEvent, + GhostStructureChangedEvent, + MouseMoveEvent, + MouseUpEvent, + SwapRocketDirectionEvent, +} from "../../InputHandler"; +import { + BuildUnitIntentEvent, + SendUpgradeStructureIntentEvent, +} from "../../Transport"; +import { renderNumber } from "../../Utils"; +import { TransformHandler } from "../TransformHandler"; +import { UIState } from "../UIState"; +import { + ICON_SCALE_FACTOR_ZOOMED_IN, + ICON_SCALE_FACTOR_ZOOMED_OUT, + SpriteFactory, + ZOOM_THRESHOLD, +} from "./StructureDrawingUtils"; + +export interface GhostUnit { + container: Container; + priceText: BitmapText; + priceBg: Graphics; + priceGroup: Container; + priceBox: { height: number; y: number; paddingX: number; minWidth: number }; + range: Container | null; + rangeLevel?: number; + targetingAlly?: boolean; + buildableUnit: BuildableUnit; +} + +export class GhostStructureManager { + private ghostUnit: GhostUnit | null = null; + private ghostControls: { + container: HTMLDivElement; + confirm: HTMLButtonElement; + cancel: HTMLButtonElement; + flip: HTMLButtonElement; + } | null = null; + private ghostControlsStyle: { + left: number; + top: number; + scale: number; + } | null = null; + private lastGhostQueryAt: number = 0; + private readonly mousePos = { x: 0, y: 0 }; + private onHighlightUpgrade: (unitId: number | null) => void; + + constructor( + private game: GameView, + private eventBus: EventBus, + private uiState: UIState, + private transformHandler: TransformHandler, + private ghostStage: Container, + private factory: SpriteFactory, + onHighlightUpgrade: (unitId: number | null) => void, + ) { + this.onHighlightUpgrade = onHighlightUpgrade; + } + + init() { + this.eventBus.on(MouseMoveEvent, (e) => this.moveGhost(e)); + this.eventBus.on(MouseUpEvent, (e) => this.createStructure(e)); + this.eventBus.on(ContextMenuEvent, (e) => this.updateLockedBombTarget(e)); + } + + destroy() { + this.removeGhostStructure(); + } + + renderGhost(rendererCanvas: HTMLCanvasElement) { + if (!this.ghostUnit) { + if (this.uiState.ghostStructure !== null) { + this.createGhostStructure(this.uiState.ghostStructure); + } + return; + } + + if (this.uiState.ghostStructure === null) { + this.removeGhostStructure(); + return; + } else if ( + this.uiState.ghostStructure !== this.ghostUnit.buildableUnit.type + ) { + this.clearGhostStructure(); + // It will be recreated next frame or we can recreate now + this.createGhostStructure(this.uiState.ghostStructure); + } + + 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.destroyGhostControls(); + 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; + } + this.lastGhostQueryAt = now; + + // Check if targeting an ally (for nuke warning visual) + let targetingAlly = false; + const myPlayer = this.game.myPlayer(); + const nukeType = this.ghostUnit.buildableUnit.type; + if ( + tileRef && + myPlayer && + (nukeType === UnitType.AtomBomb || nukeType === UnitType.HydrogenBomb) + ) { + const allies = myPlayer.allies(); + if (allies.length > 0) { + targetingAlly = wouldNukeBreakAlliance({ + gm: this.game, + targetTile: tileRef, + magnitude: this.game.config().nukeMagnitudes(nukeType), + allySmallIds: new Set(allies.map((a) => a.smallID())), + threshold: this.game.config().nukeAllianceBreakThreshold(), + }); + } + } + + this.game + ?.myPlayer() + ?.actions(tileRef) + .then((actions) => { + // Clear previous highlights/filters + this.onHighlightUpgrade(null); + if (this.ghostUnit?.container) { + this.ghostUnit.container.filters = []; + } + + if (!this.ghostUnit) return; + + const unit = actions.buildableUnits.find( + (u) => u.type === this.ghostUnit!.buildableUnit.type, + ); + const showPrice = this.game.config().userSettings().cursorCostLabel(); + if (!unit) { + Object.assign(this.ghostUnit.buildableUnit, { + canBuild: false, + canUpgrade: false, + }); + this.updateGhostPrice(0, showPrice); + this.ghostUnit.container.filters = [ + new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }), + ]; + return; + } + + this.ghostUnit.buildableUnit = unit; + this.updateGhostPrice(unit.cost ?? 0, showPrice); + + const targetLevel = this.resolveGhostRangeLevel(unit); + this.updateGhostRange(targetLevel, targetingAlly); + + if (unit.canUpgrade) { + this.onHighlightUpgrade(unit.canUpgrade); + } else if (unit.canBuild === false) { + this.ghostUnit.container.filters = [ + new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }), + ]; + } + + const scale = this.transformHandler.scale; + const s = + scale >= ZOOM_THRESHOLD + ? Math.max(1, scale / ICON_SCALE_FACTOR_ZOOMED_IN) + : Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT); + this.ghostUnit.container.scale.set(s); + this.ghostUnit.range?.scale.set(this.transformHandler.scale); + }); + } + + private updateGhostPrice(cost: bigint | number, showPrice: boolean) { + if (!this.ghostUnit) return; + const { priceText, priceBg, priceBox, priceGroup } = this.ghostUnit; + priceGroup.visible = showPrice; + if (!showPrice) return; + + priceText.text = renderNumber(cost); + priceText.position.set(0, priceBox.y); + + const textWidth = priceText.width; + const boxWidth = Math.max( + priceBox.minWidth, + textWidth + priceBox.paddingX * 2, + ); + + priceBg.clear(); + priceBg + .roundRect( + -boxWidth / 2, + priceBox.y - priceBox.height / 2, + boxWidth, + priceBox.height, + 4, + ) + .fill({ color: 0x000000, alpha: 0.65 }); + } + + private createStructure(e: MouseUpEvent) { + if ( + this.uiState.lockedGhostTile && + this.isLockableGhost(this.ghostUnit?.buildableUnit.type ?? null) + ) { + return; + } + + if (!this.ghostUnit) return; + if (this.isGhostBuildBlocked()) { + this.removeGhostStructure(); + return; + } + const tileRef = this.resolveTargetTileFromEvent(e); + if (!tileRef) { + this.removeGhostStructure(); + return; + } + this.commitStructure(tileRef); + } + + private updateLockedBombTarget(e: ContextMenuEvent) { + if ( + !this.uiState.lockedGhostTile || + !this.isLockableGhost(this.ghostUnit?.buildableUnit.type ?? null) + ) { + return; + } + const newTile = this.getTileFromContextMenuEvent(e); + if (newTile) { + this.uiState.lockedGhostTile = newTile; + this.eventBus.emit( + new GhostStructureChangedEvent(this.uiState.ghostStructure), + ); + } + } + + 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; + + if (!this.ghostUnit) return; + const rect = this.transformHandler.boundingRect(); + if (!rect) return; + + const localX = e.x - rect.left; + const localY = e.y - rect.top; + this.ghostUnit.container.position.set(localX, localY); + this.ghostUnit.range?.position.set(localX, localY); + } + + private createGhostStructure(type: UnitType | null) { + const player = this.game.myPlayer(); + if (!player) return; + if (type === null) { + return; + } + if (!this.isLockableGhost(type)) { + this.uiState.lockedGhostTile = null; + } + const rect = this.transformHandler.boundingRect(); + 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, + { x: localX, y: localY }, + type, + ); + this.ghostUnit = { + container: ghost.container, + priceText: ghost.priceText, + priceBg: ghost.priceBg, + priceGroup: ghost.priceGroup, + priceBox: ghost.priceBox, + range: null, + buildableUnit: { type, canBuild: false, canUpgrade: false, cost: 0n }, + }; + const showPrice = this.game.config().userSettings().cursorCostLabel(); + this.updateGhostPrice(0, showPrice); + const baseLevel = this.resolveGhostRangeLevel(this.ghostUnit.buildableUnit); + this.updateGhostRange(baseLevel); + } + + private clearGhostStructure() { + if (this.ghostUnit) { + this.ghostUnit.container.destroy(); + this.ghostUnit.range?.destroy(); + this.ghostUnit = null; + } + this.destroyGhostControls(); + this.onHighlightUpgrade(null); + } + + 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 getTileFromScreenCoords(x: number, y: number): TileRef | null { + const rect = this.transformHandler.boundingRect(); + if (!rect) return null; + const localX = x - rect.left; + const localY = y - rect.top; + const tile = this.transformHandler.screenToWorldCoordinates(localX, localY); + if (!this.game.isValidCoord(tile.x, tile.y)) return null; + return this.game.ref(tile.x, tile.y); + } + + private getTileFromContextMenuEvent(e: ContextMenuEvent): TileRef | null { + return this.getTileFromScreenCoords(e.x, e.y); + } + + private getTileFromMouseEvent(e: MouseUpEvent): TileRef | null { + return this.getTileFromScreenCoords(e.x, e.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 = "fixed"; + 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, + ariaLabel: string, + onClick: () => void, + ): HTMLButtonElement => { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.setAttribute("aria-label", ariaLabel); + button.style.minHeight = "48px"; + button.style.minWidth = "48px"; + button.style.height = "48px"; + button.style.width = "48px"; + button.style.padding = "0"; + button.style.display = "flex"; + button.style.alignItems = "center"; + button.style.justifyContent = "center"; + button.style.borderRadius = "6px"; + button.style.border = "none"; + button.style.fontWeight = "700"; + button.style.fontSize = "26px"; + button.style.lineHeight = "1"; + 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("✓", "#2e7d32", "Confirm bomb placement", () => { + if (this.uiState.lockedGhostTile) { + this.emitBuildIntent(this.uiState.lockedGhostTile); + } + }); + + const flip = makeButton("↕", "#1565c0", "Flip rocket direction", () => { + const next = !this.uiState.rocketDirectionUp; + this.eventBus.emit(new SwapRocketDirectionEvent(next)); + }); + + const cancel = makeButton("✕", "#b71c1c", "Cancel bomb placement", () => + 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; + this.ghostControlsStyle = null; + } + + 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 nukeType = this.ghostUnit.buildableUnit.type; + const magnitude = + nukeType === UnitType.AtomBomb + ? this.game.config().nukeMagnitudes(UnitType.AtomBomb).outer + : this.game.config().nukeMagnitudes(UnitType.HydrogenBomb).outer; + const radiusPixels = magnitude * this.transformHandler.scale; + const offsetY = radiusPixels + 1; + const scale = Math.max( + 0.75, + Math.min(1.4, this.transformHandler.scale / 2), + ); + const left = rect.left + localX; + const top = rect.top + localY + offsetY; + + const cached = this.ghostControlsStyle; + if ( + !cached || + cached.left !== left || + cached.top !== top || + cached.scale !== scale + ) { + this.ghostControls!.container.style.left = `${left}px`; + this.ghostControls!.container.style.top = `${top}px`; + this.ghostControls!.container.style.transform = `translate(-50%, 0) scale(${scale})`; + this.ghostControlsStyle = { left, top, scale }; + } + } + + private resolveGhostRangeLevel( + buildableUnit: BuildableUnit, + ): number | undefined { + if (buildableUnit.type !== UnitType.SAMLauncher) { + return undefined; + } + if (buildableUnit.canUpgrade !== false) { + const existing = this.game.unit(buildableUnit.canUpgrade); + if (existing) { + return existing.level() + 1; + } else { + console.error("Failed to find existing SAMLauncher for upgrade"); + } + } + + return 1; + } + + private updateGhostRange(level?: number, targetingAlly: boolean = false) { + if (!this.ghostUnit) { + return; + } + + if ( + this.ghostUnit.range && + this.ghostUnit.rangeLevel === level && + this.ghostUnit.targetingAlly === targetingAlly + ) { + return; + } + + this.ghostUnit.range?.destroy(); + this.ghostUnit.range = null; + this.ghostUnit.rangeLevel = level; + this.ghostUnit.targetingAlly = targetingAlly; + + const position = this.ghostUnit.container.position; + const range = this.factory.createRange( + this.ghostUnit.buildableUnit.type, + this.ghostStage, + { x: position.x, y: position.y }, + level, + targetingAlly, + ); + if (range) { + this.ghostUnit.range = range; + } + } +} diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 4babd6a3f..61b39e817 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -4,33 +4,23 @@ import { OutlineFilter } from "pixi-filters"; import * as PIXI from "pixi.js"; import { Theme } from "../../../core/configuration/Config"; import { EventBus } from "../../../core/EventBus"; -import { wouldNukeBreakAlliance } from "../../../core/execution/Util"; import { - BuildableUnit, Cell, PlayerActions, PlayerID, UnitType, } from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; +import { + GameUpdateType, +} from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; import { - ContextMenuEvent, - GhostStructureChangedEvent, - MouseMoveEvent, - MouseUpEvent, - SwapRocketDirectionEvent, ToggleStructureEvent as ToggleStructuresEvent, } from "../../InputHandler"; -import { - BuildUnitIntentEvent, - SendUpgradeStructureIntentEvent, -} from "../../Transport"; -import { renderNumber } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; import { UIState } from "../UIState"; import { Layer } from "./Layer"; +import { GhostStructureManager } from "./GhostStructureManager"; import { DOTS_ZOOM_THRESHOLD, ICON_SCALE_FACTOR_ZOOMED_IN, @@ -60,17 +50,7 @@ class StructureRenderInfo { } export class StructureIconsLayer implements Layer { - private ghostUnit: { - container: PIXI.Container; - priceText: PIXI.BitmapText; - priceBg: PIXI.Graphics; - priceGroup: PIXI.Container; - priceBox: { height: number; y: number; paddingX: number; minWidth: number }; - range: PIXI.Container | null; - rangeLevel?: number; - targetingAlly?: boolean; - buildableUnit: BuildableUnit; - } | null = null; + private ghostManager: GhostStructureManager | null = null; private pixicanvas: HTMLCanvasElement; private iconsStage: PIXI.Container; private ghostStage: PIXI.Container; @@ -83,20 +63,8 @@ export class StructureIconsLayer implements Layer { private rendererInitialized: boolean = false; private renders: StructureRenderInfo[] = []; private readonly seenUnits: Set = new Set(); - 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 ghostControlsStyle: { - left: number; - top: number; - scale: number; - } | null = null; private readonly structures: Map = new Map([ [UnitType.City, { visible: true }], [UnitType.Factory, { visible: true }], @@ -105,8 +73,7 @@ export class StructureIconsLayer implements Layer { [UnitType.MissileSilo, { visible: true }], [UnitType.SAMLauncher, { visible: true }], ]); - private lastGhostQueryAt: number; - potentialUpgrade: StructureRenderInfo | undefined; + private potentialUpgrade: StructureRenderInfo | undefined; constructor( private game: GameView, @@ -172,6 +139,16 @@ export class StructureIconsLayer implements Layer { this.renderer = renderer; this.rendererInitialized = true; + + this.ghostManager = new GhostStructureManager( + this.game, + this.eventBus, + this.uiState, + this.transformHandler, + this.ghostStage, + this.factory, + (unitId) => this.onHighlightUpgrade(unitId), + ); } shouldTransform(): boolean { @@ -182,13 +159,10 @@ export class StructureIconsLayer implements Layer { this.eventBus.on(ToggleStructuresEvent, (e) => this.toggleStructures(e.structureTypes), ); - this.eventBus.on(MouseMoveEvent, (e) => this.moveGhost(e)); - - this.eventBus.on(MouseUpEvent, (e) => this.createStructure(e)); - this.eventBus.on(ContextMenuEvent, (e) => this.updateLockedBombTarget(e)); window.addEventListener("resize", () => this.resizeCanvas()); await this.setupRenderer(); + this.ghostManager?.init(); this.redraw(); } @@ -226,18 +200,9 @@ export class StructureIconsLayer implements Layer { return; } - if (this.ghostUnit) { - if (this.uiState.ghostStructure === null) { - this.removeGhostStructure(); - } else if ( - this.uiState.ghostStructure !== this.ghostUnit.buildableUnit.type - ) { - this.clearGhostStructure(); - } - } else if (this.uiState.ghostStructure !== null) { - this.createGhostStructure(this.uiState.ghostStructure); + if (this.ghostManager) { + this.ghostManager.renderGhost(this.pixicanvas); } - this.renderGhost(); if (this.transformHandler.hasChanged()) { for (const render of this.renders) { @@ -255,532 +220,30 @@ export class StructureIconsLayer implements Layer { mainContext.drawImage(this.renderer.canvas, 0, 0); } - 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.destroyGhostControls(); - 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; - } - this.lastGhostQueryAt = now; - - // Check if targeting an ally (for nuke warning visual) - // Uses shared logic with NukeExecution.maybeBreakAlliances() - let targetingAlly = false; - const myPlayer = this.game.myPlayer(); - const nukeType = this.ghostUnit.buildableUnit.type; - if ( - tileRef && - myPlayer && - (nukeType === UnitType.AtomBomb || nukeType === UnitType.HydrogenBomb) - ) { - // Only check if player has allies - const allies = myPlayer.allies(); - if (allies.length > 0) { - targetingAlly = wouldNukeBreakAlliance({ - gm: this.game, - targetTile: tileRef, - magnitude: this.game.config().nukeMagnitudes(nukeType), - allySmallIds: new Set(allies.map((a) => a.smallID())), - threshold: this.game.config().nukeAllianceBreakThreshold(), - }); - } - } - - this.game - ?.myPlayer() - ?.actions(tileRef) - .then((actions) => { - if (this.potentialUpgrade) { - this.potentialUpgrade.iconContainer.filters = []; - this.potentialUpgrade.dotContainer.filters = []; - } - if (this.ghostUnit?.container) { - this.ghostUnit.container.filters = []; - } - - if (!this.ghostUnit) return; - - const unit = actions.buildableUnits.find( - (u) => u.type === this.ghostUnit!.buildableUnit.type, - ); - const showPrice = this.game.config().userSettings().cursorCostLabel(); - if (!unit) { - Object.assign(this.ghostUnit.buildableUnit, { - canBuild: false, - canUpgrade: false, - }); - this.updateGhostPrice(0, showPrice); - this.ghostUnit.container.filters = [ - new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }), - ]; - return; - } - - this.ghostUnit.buildableUnit = unit; - this.updateGhostPrice(unit.cost ?? 0, showPrice); - - const targetLevel = this.resolveGhostRangeLevel(unit); - this.updateGhostRange(targetLevel, targetingAlly); - - if (unit.canUpgrade) { - this.potentialUpgrade = this.renders.find( - (r) => - r.unit.id() === unit.canUpgrade && - r.unit.owner().id() === this.game.myPlayer()?.id(), - ); - if (this.potentialUpgrade) { - this.potentialUpgrade.iconContainer.filters = [ - new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }), - ]; - this.potentialUpgrade.dotContainer.filters = [ - new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }), - ]; - } - } else if (unit.canBuild === false) { - this.ghostUnit.container.filters = [ - new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }), - ]; - } - - const scale = this.transformHandler.scale; - const s = - scale >= ZOOM_THRESHOLD - ? Math.max(1, scale / ICON_SCALE_FACTOR_ZOOMED_IN) - : Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT); - this.ghostUnit.container.scale.set(s); - this.ghostUnit.range?.scale.set(this.transformHandler.scale); - }); - } - - private updateGhostPrice(cost: bigint | number, showPrice: boolean) { - if (!this.ghostUnit) return; - const { priceText, priceBg, priceBox, priceGroup } = this.ghostUnit; - priceGroup.visible = showPrice; - if (!showPrice) return; - - priceText.text = renderNumber(cost); - priceText.position.set(0, priceBox.y); - - const textWidth = priceText.width; - const boxWidth = Math.max( - priceBox.minWidth, - textWidth + priceBox.paddingX * 2, - ); - - priceBg.clear(); - priceBg - .roundRect( - -boxWidth / 2, - priceBox.y - priceBox.height / 2, - boxWidth, - priceBox.height, - 4, - ) - .fill({ color: 0x000000, alpha: 0.65 }); - } - - private createStructure(e: MouseUpEvent) { - // Ignore left clicks when locked bomb is active (use right-click instead) - if ( - this.uiState.lockedGhostTile && - this.isLockableGhost(this.ghostUnit?.buildableUnit.type ?? null) - ) { - return; - } - - if (!this.ghostUnit) return; - if (this.isGhostBuildBlocked()) { - this.removeGhostStructure(); - return; - } - const tileRef = this.resolveTargetTileFromEvent(e); - if (!tileRef) { - this.removeGhostStructure(); - return; - } - this.commitStructure(tileRef); - } - - private updateLockedBombTarget(e: ContextMenuEvent) { - // Only allow right-click to change bomb target when locked - if ( - !this.uiState.lockedGhostTile || - !this.isLockableGhost(this.ghostUnit?.buildableUnit.type ?? null) - ) { - return; - } - const newTile = this.getTileFromContextMenuEvent(e); - if (newTile) { - this.uiState.lockedGhostTile = newTile; - // Force trajectory recalculation - this.eventBus.emit( - new GhostStructureChangedEvent(this.uiState.ghostStructure), - ); - } - } - - 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; - - if (!this.ghostUnit) return; - const rect = this.transformHandler.boundingRect(); - if (!rect) return; - - const localX = e.x - rect.left; - const localY = e.y - rect.top; - this.ghostUnit.container.position.set(localX, localY); - this.ghostUnit.range?.position.set(localX, localY); - } - - private createGhostStructure(type: UnitType | null) { - const player = this.game.myPlayer(); - if (!player) return; - if (type === null) { - return; - } - if (!this.isLockableGhost(type)) { - this.uiState.lockedGhostTile = null; - } - const rect = this.transformHandler.boundingRect(); - 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, - { x: localX, y: localY }, - type, - ); - this.ghostUnit = { - container: ghost.container, - priceText: ghost.priceText, - priceBg: ghost.priceBg, - priceGroup: ghost.priceGroup, - priceBox: ghost.priceBox, - range: null, - buildableUnit: { type, canBuild: false, canUpgrade: false, cost: 0n }, - }; - const showPrice = this.game.config().userSettings().cursorCostLabel(); - this.updateGhostPrice(0, showPrice); - const baseLevel = this.resolveGhostRangeLevel(this.ghostUnit.buildableUnit); - this.updateGhostRange(baseLevel); - } - - private clearGhostStructure() { - if (this.ghostUnit) { - this.ghostUnit.container.destroy(); - this.ghostUnit.range?.destroy(); - this.ghostUnit = null; - } - this.destroyGhostControls(); + /* + * Highlight an existing structure when the ghost is hovered over it for an upgrade. + */ + private onHighlightUpgrade(unitId: number | null) { if (this.potentialUpgrade) { this.potentialUpgrade.iconContainer.filters = []; this.potentialUpgrade.dotContainer.filters = []; this.potentialUpgrade = undefined; } - } - private removeGhostStructure() { - this.clearGhostStructure(); - this.uiState.ghostStructure = null; - this.uiState.lockedGhostTile = null; - this.eventBus.emit(new GhostStructureChangedEvent(null)); - } - - /** - * Emit the appropriate build/upgrade intent for the current ghost unit. - * Used by locked-bomb controls to send the final build action. - */ - 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, - ), + if (unitId !== null) { + this.potentialUpgrade = this.renders.find( + (r) => + r.unit.id() === unitId && + r.unit.owner().id() === this.game.myPlayer()?.id(), ); - } 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), - ); - } - } - - /** - * Finalise a locked ghost placement: emit intent and clean up ghost/controls. - */ - private commitStructure(tileRef: TileRef) { - this.emitBuildIntent(tileRef); - this.removeGhostStructure(); - } - - /** - * Convert raw screen coordinates into a TileRef if valid; returns null when off-map. - */ - private getTileFromScreenCoords(x: number, y: number): TileRef | null { - const rect = this.transformHandler.boundingRect(); - if (!rect) return null; - const localX = x - rect.left; - const localY = y - rect.top; - const tile = this.transformHandler.screenToWorldCoordinates(localX, localY); - if (!this.game.isValidCoord(tile.x, tile.y)) return null; - return this.game.ref(tile.x, tile.y); - } - - private getTileFromContextMenuEvent(e: ContextMenuEvent): TileRef | null { - return this.getTileFromScreenCoords(e.x, e.y); - } - - private getTileFromMouseEvent(e: MouseUpEvent): TileRef | null { - return this.getTileFromScreenCoords(e.x, e.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) - ); - } - - /** - * Returns true when the ghost type supports the locked bomb flow (nukes only). - */ - private isLockableGhost(type: UnitType | null): boolean { - return type === UnitType.AtomBomb || type === UnitType.HydrogenBomb; - } - - /** - * Create the DOM controls (✓/↕/✕) for locked bomb placement. - * Controls are fixed-positioned so they stay put on mobile scroll/zoom. - */ - private ensureGhostControls() { - if (this.ghostControls) return; - - const container = document.createElement("div"); - // Fixed positioning keeps controls anchored to the viewport even if the page scrolls - container.style.position = "fixed"; - 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, - ariaLabel: string, - onClick: () => void, - ): HTMLButtonElement => { - const button = document.createElement("button"); - button.type = "button"; - button.textContent = label; - button.setAttribute("aria-label", ariaLabel); - button.style.minHeight = "48px"; - button.style.minWidth = "48px"; - button.style.height = "48px"; - button.style.width = "48px"; - button.style.padding = "0"; - button.style.display = "flex"; - button.style.alignItems = "center"; - button.style.justifyContent = "center"; - button.style.borderRadius = "6px"; - button.style.border = "none"; - button.style.fontWeight = "700"; - button.style.fontSize = "26px"; - button.style.lineHeight = "1"; - 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("✓", "#2e7d32", "Confirm bomb placement", () => { - if (this.uiState.lockedGhostTile) { - this.emitBuildIntent(this.uiState.lockedGhostTile); + if (this.potentialUpgrade) { + this.potentialUpgrade.iconContainer.filters = [ + new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }), + ]; + this.potentialUpgrade.dotContainer.filters = [ + new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }), + ]; } - }); - - const flip = makeButton("↕", "#1565c0", "Flip rocket direction", () => { - const next = !this.uiState.rocketDirectionUp; - this.eventBus.emit(new SwapRocketDirectionEvent(next)); - }); - - const cancel = makeButton("✕", "#b71c1c", "Cancel bomb placement", () => - 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; - this.ghostControlsStyle = null; - } - - /** - * Position and scale the ghost controls under the nuke radius. - * Uses cached values to avoid redundant DOM writes each frame. - */ - 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(); - // Get the nuke radius and add 1px below it - const nukeType = this.ghostUnit.buildableUnit.type; - const magnitude = - nukeType === UnitType.AtomBomb - ? this.game.config().nukeMagnitudes(UnitType.AtomBomb).outer - : this.game.config().nukeMagnitudes(UnitType.HydrogenBomb).outer; - const radiusPixels = magnitude * this.transformHandler.scale; - const offsetY = radiusPixels + 1; - const scale = Math.max( - 0.75, - Math.min(1.4, this.transformHandler.scale / 2), - ); - const left = rect.left + localX; - const top = rect.top + localY + offsetY; - - const cached = this.ghostControlsStyle; - if ( - !cached || - cached.left !== left || - cached.top !== top || - cached.scale !== scale - ) { - this.ghostControls!.container.style.left = `${left}px`; - this.ghostControls!.container.style.top = `${top}px`; - this.ghostControls!.container.style.transform = `translate(-50%, 0) scale(${scale})`; - this.ghostControlsStyle = { left, top, scale }; - } - } - - private resolveGhostRangeLevel( - buildableUnit: BuildableUnit, - ): number | undefined { - if (buildableUnit.type !== UnitType.SAMLauncher) { - return undefined; - } - if (buildableUnit.canUpgrade !== false) { - const existing = this.game.unit(buildableUnit.canUpgrade); - if (existing) { - return existing.level() + 1; - } else { - console.error("Failed to find existing SAMLauncher for upgrade"); - } - } - - return 1; - } - - private updateGhostRange(level?: number, targetingAlly: boolean = false) { - if (!this.ghostUnit) { - return; - } - - if ( - this.ghostUnit.range && - this.ghostUnit.rangeLevel === level && - this.ghostUnit.targetingAlly === targetingAlly - ) { - return; - } - - this.ghostUnit.range?.destroy(); - this.ghostUnit.range = null; - this.ghostUnit.rangeLevel = level; - this.ghostUnit.targetingAlly = targetingAlly; - - const position = this.ghostUnit.container.position; - const range = this.factory.createRange( - this.ghostUnit.buildableUnit.type, - this.ghostStage, - { x: position.x, y: position.y }, - level, - targetingAlly, - ); - if (range) { - this.ghostUnit.range = range; } }