/** * BuildPreviewController — build-ghost state machine + click-to-build flow. * * All rendering for the build ghost (outline, range circle, rail snap, * crosshair) lives in the WebGL renderer. This controller owns the state: * it queries buildables for the cursor tile, tracks whether the placement * is valid, and pushes preview data straight to the WebGL view. */ import { EventBus } from "../../core/EventBus"; import { listNukeBreakAlliance, wouldNukeBreakAlliance, } from "../../core/execution/Util"; import { BuildableUnit, PlayerBuildableUnitType, UnitType, } from "../../core/game/Game"; import { TileRef } from "../../core/game/GameMap"; import { UserSettings } from "../../core/game/UserSettings"; import { Controller } from "../Controller"; import { ConfirmGhostStructureEvent, MouseMoveEvent, MouseUpEvent, } from "../InputHandler"; import { MapRenderer, buildNukeTrajectory } from "../render/gl"; import type { SAMInfo } from "../render/gl/utils/NukeTrajectory"; import type { GhostPreviewData } from "../render/types"; import { TransformHandler } from "../TransformHandler"; import { BuildUnitIntentEvent, SendUpgradeStructureIntentEvent, } from "../Transport"; import { UIState } from "../UIState"; import { GameView } from "../view"; /** True for nuke types (AtomBomb, HydrogenBomb): ghost is preserved after placement so user can place multiple or keep selection (Enter/key confirm). */ export function shouldPreserveGhostAfterBuild(unitType: UnitType): boolean { return unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb; } /** * Whether a SAM belongs in the nuke trajectory preview's threat set. * Allied SAMs are excluded unless the strike would betray that ally — * the alliance breaks at launch, so their SAMs will engage the nuke. * (Own SAMs never threaten; the caller filters those out first.) */ export function samThreatensNukePreview( samOwnerSmallId: number, allySmallIds: ReadonlySet, betrayedSmallIds: ReadonlySet, ): boolean { return ( !allySmallIds.has(samOwnerSmallId) || betrayedSmallIds.has(samOwnerSmallId) ); } export class BuildPreviewController implements Controller { /** Current ghost (null when no build type is active). */ private ghostUnit: { buildableUnit: BuildableUnit } | null = null; private readonly connectedAllySmallIds: Set = new Set(); private readonly mousePos = { x: 0, y: 0 }; private lastGhostQueryAt: number = 0; private pendingConfirm: MouseUpEvent | null = null; // Buildable validation runs on the snapped tile under the cursor, but the // rendered icon follows the cursor at sub-tile precision so motion is // continuous instead of stepping tile-to-tile. cursorLoop re-emits each // frame with the current cursor world position. private lastGhostData: GhostPreviewData | null = null; // Static inputs for the nuke trajectory preview (source silo + threatening // SAMs). Recomputed in the throttled renderGhost path; cursorLoop rebuilds // the Bezier each frame with the live cursor position as the destination so // the arc tracks the cursor smoothly instead of snapping tile-to-tile. private nukeTrajectoryStatic: { srcX: number; srcY: number; sams: SAMInfo[]; } | null = null; constructor( private game: GameView, private eventBus: EventBus, public uiState: UIState, private transformHandler: TransformHandler, private view: MapRenderer, private userSettings: UserSettings, ) {} init() { this.eventBus.on(MouseMoveEvent, (e) => this.moveGhost(e)); this.eventBus.on(MouseUpEvent, (e) => this.requestConfirmStructure(e)); this.eventBus.on(ConfirmGhostStructureEvent, () => this.requestConfirmStructure( new MouseUpEvent(this.mousePos.x, this.mousePos.y), ), ); // Re-emit the ghost each render frame at the cursor's current world // position (sub-tile). Buildable validation still runs on the snapped // tile in renderGhost(); this loop just keeps the icon under the cursor // so motion is continuous instead of stepping tile-to-tile. // The shader treats (tileX + 0.5, tileY + 0.5) as the icon center (so an // integer tile coord centers on that tile), so we subtract 0.5 here to // place the icon exactly under the cursor. const cursorLoop = () => { const ghost = this.lastGhostData; const traj = this.nukeTrajectoryStatic; if (ghost !== null || traj !== null) { const w = this.transformHandler.screenToWorldCoordinatesFloat( this.mousePos.x, this.mousePos.y, ); 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 // upgrading, the circle is anchored to the existing structure's tile // (stationary, correctly snapped) — leave it alone in that case. const radiusFollowsCursor = !( ghost.canUpgrade && ghost.upgradeTargetTile !== null ); this.view.updateGhostPreview({ ...ghost, tileX: w.x - 0.5, tileY: w.y - 0.5, ...(radiusFollowsCursor ? { radiusTileX: w.x - 0.5, radiusTileY: w.y - 0.5 } : {}), }); } if (traj !== null) { // Rebuild the arc with the live cursor as the destination (same // tile-center convention as the icon: shader adds +0.5). this.view.updateNukeTrajectory( buildNukeTrajectory( traj.srcX, traj.srcY, w.x - 0.5, w.y - 0.5, this.game.height(), this.uiState.rocketDirectionUp, traj.sams, ), ); } } requestAnimationFrame(cursorLoop); }; requestAnimationFrame(cursorLoop); } tick() { // Re-query buildables periodically (world state can change — tiles may // become buildable as troops/territory move). this.syncGhostState(); this.renderGhost(); } /** * Reconcile our internal ghost state with uiState.ghostStructure. Other * UI bits (build menu, key bindings) toggle uiState; we mirror it here. */ private syncGhostState(): void { const target = this.uiState.ghostStructure; if (this.ghostUnit) { if (target === null) { this.removeGhostStructure(); } else if (target !== this.ghostUnit.buildableUnit.type) { this.clearGhostStructure(); this.createGhostStructure(target); } } else if (target !== null) { this.createGhostStructure(target); } } renderGhost() { if (!this.ghostUnit) return; const now = performance.now(); if (now - this.lastGhostQueryAt < 50) return; this.lastGhostQueryAt = now; let tileRef: TileRef | undefined; const tile = this.transformHandler.screenToWorldCoordinates( this.mousePos.x, this.mousePos.y, ); 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) let targetingAlly = false; const myPlayer = this.game.myPlayer(); const nukeType = this.ghostUnit.buildableUnit.type; if ( tileRef && myPlayer && (nukeType === UnitType.AtomBomb || nukeType === UnitType.HydrogenBomb) ) { this.connectedAllySmallIds.clear(); const allies = myPlayer.allies(); for (let i = 0; i < allies.length; i++) { const ally = allies[i]; if (!ally.isDisconnected()) { this.connectedAllySmallIds.add(ally.smallID()); } } if (this.connectedAllySmallIds.size > 0) { targetingAlly = wouldNukeBreakAlliance({ game: this.game, targetTile: tileRef, magnitude: this.game.config().nukeMagnitudes(nukeType), allySmallIds: this.connectedAllySmallIds, threshold: this.game.config().nukeAllianceBreakThreshold(), }); } } this.game ?.myPlayer() ?.buildables(tileRef, [this.ghostUnit?.buildableUnit.type]) .then((buildables) => { if (!this.ghostUnit) { this.pendingConfirm = null; this.emitGhostPreview(tileRef, targetingAlly); return; } const unit = buildables.find( (u) => u.type === this.ghostUnit!.buildableUnit.type, ); if (!unit) { Object.assign(this.ghostUnit.buildableUnit, { canBuild: false, canUpgrade: false, }); this.pendingConfirm = null; this.emitGhostPreview(tileRef, targetingAlly); 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); }); } /** * Push a GhostPreviewData snapshot to the WebGL view (StructurePass / * RangeCirclePass / RailroadPass / CrosshairPass all read it). null when * the ghost can't be placed. smoothLoop interpolates displayed position * toward the target tile each frame. */ private emitGhostPreview( tileRef: TileRef | undefined, targetingAlly: boolean, ): void { const data = this.buildGhostPreviewData(tileRef, targetingAlly); if (data === null) { this.lastGhostData = null; this.view.updateGhostPreview(null); } else { this.lastGhostData = data; } this.updateNukeTrajectoryPreview(tileRef); } /** * For AtomBomb / HydrogenBomb ghosts, push the Bezier trajectory preview * (closest player-owned silo → target, accounting for non-allied SAMs). * Cleared whenever the ghost isn't a nuke, has no target, or the player * has no silos. */ private updateNukeTrajectoryPreview(tileRef: TileRef | undefined): void { if (!this.ghostUnit || tileRef === undefined) { this.clearNukeTrajectory(); return; } const type = this.ghostUnit.buildableUnit.type; if (type !== UnitType.AtomBomb && type !== UnitType.HydrogenBomb) { this.clearNukeTrajectory(); return; } const myPlayer = this.game.myPlayer(); if (!myPlayer) { this.clearNukeTrajectory(); return; } // Mirror PlayerImpl.nukeSpawn (the source NukeExecution actually fires // from): only silos that are active, not reloading, and not under // construction are eligible, and the nearest is chosen by Manhattan // distance. Keeping these in sync prevents the preview arc from // originating from a silo the game wouldn't use. const silos = myPlayer .units(UnitType.MissileSilo) .filter( (u) => u.isActive() && !u.isInCooldown() && !u.isUnderConstruction(), ); if (silos.length === 0) { this.clearNukeTrajectory(); return; } const dstX = this.game.x(tileRef); const dstY = this.game.y(tileRef); let bestSilo = silos[0]; let bestDist = Infinity; for (const s of silos) { const sx = this.game.x(s.tile()); const sy = this.game.y(s.tile()); const d = Math.abs(sx - dstX) + Math.abs(sy - dstY); if (d < bestDist) { bestDist = d; bestSilo = s; } } const srcX = this.game.x(bestSilo.tile()); const srcY = this.game.y(bestSilo.tile()); // Non-allied SAMs threaten the trajectory; own + allied SAMs don't — // except allies this strike would betray: the alliance breaks at launch // (NukeExecution.maybeBreakAlliances), so their SAMs will intercept. // listNukeBreakAlliance is the same function the sim uses there. const allyIds = new Set(); for (const a of myPlayer.allies()) allyIds.add(a.smallID()); const betrayedIds: ReadonlySet = allyIds.size > 0 ? listNukeBreakAlliance({ game: this.game, targetTile: tileRef, magnitude: this.game.config().nukeMagnitudes(type), threshold: this.game.config().nukeAllianceBreakThreshold(), }) : new Set(); const sams: SAMInfo[] = []; for (const s of this.game.units(UnitType.SAMLauncher)) { if (!s.isActive()) continue; const owner = s.owner(); if (owner === myPlayer) continue; if (!samThreatensNukePreview(owner.smallID(), allyIds, betrayedIds)) { continue; } const r = this.game.config().samRange(s.level()); sams.push({ x: this.game.x(s.tile()), y: this.game.y(s.tile()), rangeSq: r * r, }); } // Stash the static inputs; cursorLoop rebuilds the Bezier each frame with // the live cursor as the destination so the arc tracks smoothly. this.nukeTrajectoryStatic = { srcX, srcY, sams }; } private clearNukeTrajectory(): void { this.nukeTrajectoryStatic = null; this.view.updateNukeTrajectory(null); } private buildGhostPreviewData( tileRef: TileRef | undefined, targetingAlly: boolean, ): GhostPreviewData | null { if (!this.ghostUnit) return null; if (tileRef === undefined) return null; const myPlayer = this.game.myPlayer(); if (!myPlayer) return null; const u = this.ghostUnit.buildableUnit; // Upgrade-target tile — only when upgrading an existing unit. let upgradeTargetTile: number | null = null; if (u.canUpgrade !== false) { upgradeTargetTile = this.game.unit(u.canUpgrade)?.tile() ?? null; } // Range circle: SAM placement preview shows targetable radius; nuke // previews show the outer blast radius at the target tile. let rangeRadius = 0; switch (u.type) { case UnitType.SAMLauncher: { const level = this.resolveGhostRangeLevel(u) ?? 1; rangeRadius = this.game.config().samRange(level); break; } case UnitType.AtomBomb: case UnitType.HydrogenBomb: rangeRadius = this.game.config().nukeMagnitudes(u.type).outer; break; case UnitType.Factory: rangeRadius = this.game.config().trainStationMaxRange(); break; case UnitType.DefensePost: rangeRadius = this.game.config().defensePostRange(); break; } let radiusTileX = this.game.x(tileRef); let radiusTileY = this.game.y(tileRef); if ( rangeRadius > 0 && u.canUpgrade !== false && upgradeTargetTile !== null ) { radiusTileX = this.game.x(upgradeTargetTile); radiusTileY = this.game.y(upgradeTargetTile); } const cost = u.cost; return { ghostType: u.type, tileX: this.game.x(tileRef), tileY: this.game.y(tileRef), radiusTileX, radiusTileY, canBuild: u.canBuild !== false, canUpgrade: u.canUpgrade !== false, cost: Number(cost), showCost: this.userSettings.cursorCostLabel(), canAfford: myPlayer.gold() >= cost, ghostRailPaths: u.ghostRailPaths, overlappingRailroads: u.overlappingRailroads, ownerID: myPlayer.smallID(), upgradeTargetTile, rangeRadius, rangeWarning: targetingAlly, }; } 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; if (this.isGhostReadyForConfirm()) { this.createStructure(e); } else { this.pendingConfirm = e; } } private createStructure(e: MouseUpEvent) { 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) { this.eventBus.emit( new SendUpgradeStructureIntentEvent( this.ghostUnit.buildableUnit.canUpgrade, this.ghostUnit.buildableUnit.type, ), ); this.removeGhostStructure(); } 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, ), ); if (!shouldPreserveGhostAfterBuild(unitType)) { this.removeGhostStructure(); } } else { this.removeGhostStructure(); } } private moveGhost(e: MouseMoveEvent) { this.mousePos.x = e.x; this.mousePos.y = e.y; } private createGhostStructure(type: PlayerBuildableUnitType | null) { if (type === null) return; if (this.game.myPlayer() === null) return; this.ghostUnit = { buildableUnit: { type, canBuild: false, canUpgrade: false, cost: 0n, overlappingRailroads: [], ghostRailPaths: [], }, }; } private clearGhostStructure() { this.pendingConfirm = null; this.ghostUnit = null; this.lastGhostData = null; this.view.updateGhostPreview(null); this.clearNukeTrajectory(); } private removeGhostStructure() { this.clearGhostStructure(); this.uiState.ghostStructure = null; } 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; } }