mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
aa4b490e68
## Summary The WebGL renderer was adapted from an external extension and carried a lot of machinery this integration never uses (replay playback, its own input/event system, a GL radial menu). This PR is two mechanical cleanup passes with **no behavior change**: delete the dead code, then untangle the `GameView` naming collision. **78 files, +142 / −2,197.** ### Pass 1 — remove dead extension baggage - **Replay/copy mode**: `FrameData.tileMode` was hard-coded `"live"`; the copy branches in `frame/Upload.ts`, `UploadOptions` (never passed), `applyFullFrame`/`applyFullTiles`/`applyDelta` on the facade and `GPURenderer`, `HeatManager.resetForSeek`, and the seek-upload methods on `TerritoryPass`/`TrailPass` were all unreachable. Also deletes `types/Replay.ts`, `types/FrameSource.ts`, `types/GameUpdates.ts`, `types/Game.ts` (imported only by the types barrel). - **FrameEvents**: trimmed from 14 fields to the 3 actually populated and read (`deadUnits`, `conquestEvents`, `bonusEvents`). The other 11 fed the extension's stats system and were never written or read here. - **GL radial menu**: `RadialMenuPass`, its 4 shaders, and ~10 API methods on facade + renderer had zero callers — the game uses the DOM/d3 radial menu in `hud/layers/RadialMenu.ts`. The pass was constructed and drawn every frame for nothing. - **Facade event system**: `GameViewEventMap` defined 10 event types (`click`, `hover`, `scroll`, …) but only `contextrestored` was ever emitted — input actually flows through `InputHandler` → EventBus → controllers. Replaced the listener map with a single `onContextRestored` callback and deleted `Events.ts`. Also fixed the stale header comment claiming the facade handles user interaction. - **Unused API surface**: removed ~20 facade/renderer methods with zero callers (camera passthroughs like `panTo`/`zoomTo`/`fitMap`/`screenToWorld`, hit-testing queries, SAM replay setters, `setSelectedUnit`, `clearFx`/`setFxTimeFn`, `onFrame`/`afterRender`/fps tracking). Deliberately left alone: `Camera`'s pan/zoom primitives (building blocks for a possible future camera unification) and the `timeFn` plumbing inside the FX passes (deeply embedded as defaults; only the dead renderer-level wrappers were removed). ### Pass 2 — untangle the three GameViews - `render/gl/GameView.ts` → **`MapRenderer.ts`** (class `MapRenderer`). Every importer was already aliasing it as `WebGLGameView` to dodge the collision with the simulation-mirror `GameView` in `client/view/`, so this removes aliasing rather than adding churn. `render/CLAUDE.md` updated. - Deleted the `src/core/game/GameView.ts` back-compat shim (its own TODO asked for this). All 51 importers now import from `src/client/view/` directly via a new 3-line barrel `view/index.ts`. ## Test plan - `tsc --noEmit` clean, `eslint` clean - Full test suite passes (1,385 + 65 server tests) - Manual verification via headless Chromium: started a singleplayer game and confirmed the renderer works end-to-end — terrain draws, spawn-phase overlay shows, territories fill with borders after spawning, player names/flags render, no renderer console errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
546 lines
18 KiB
TypeScript
546 lines
18 KiB
TypeScript
/**
|
|
* 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<number>,
|
|
betrayedSmallIds: ReadonlySet<number>,
|
|
): 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<number> = 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<number>();
|
|
for (const a of myPlayer.allies()) allyIds.add(a.smallID());
|
|
const betrayedIds: ReadonlySet<number> =
|
|
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;
|
|
}
|
|
}
|