Smooth build-ghost range circle and nuke trajectory to cursor

The build-ghost icon already tracked the cursor at sub-tile precision, but
the range circle (defense post / SAM / nuke radius) and the nuke trajectory
arc still snapped to the hover tile, making them look jagged as the cursor
moved.

Range circle: cursorLoop now smooths radiusTileX/Y the same way as the icon,
except when upgrading an existing structure (the circle stays anchored to
that structure's real tile).

Nuke trajectory: split the work by cadence. The throttled renderGhost path
caches the static inputs (nearest silo + threatening SAMs) in
nukeTrajectoryStatic; cursorLoop rebuilds the Bezier each frame with the
live cursor as the destination. Source stays on the silo's tile; only the
endpoint follows the cursor.

All three previews now use the same tile-center (+0.5) convention.
This commit is contained in:
evanpelle
2026-06-10 16:20:14 -07:00
parent 5cb155267d
commit dceb2798b3
@@ -52,6 +52,16 @@ export class BuildPreviewController implements Controller {
// 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,
@@ -78,16 +88,45 @@ export class BuildPreviewController implements Controller {
// integer tile coord centers on that tile), so we subtract 0.5 here to
// place the icon exactly under the cursor.
const cursorLoop = () => {
if (this.lastGhostData !== null) {
const ghost = this.lastGhostData;
const traj = this.nukeTrajectoryStatic;
if (ghost !== null || traj !== null) {
const w = this.transformHandler.screenToWorldCoordinatesFloat(
this.mousePos.x,
this.mousePos.y,
);
this.view.updateGhostPreview({
...this.lastGhostData,
tileX: w.x - 0.5,
tileY: w.y - 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
// 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);
};
@@ -228,17 +267,17 @@ export class BuildPreviewController implements Controller {
*/
private updateNukeTrajectoryPreview(tileRef: TileRef | undefined): void {
if (!this.ghostUnit || tileRef === undefined) {
this.view.updateNukeTrajectory(null);
this.clearNukeTrajectory();
return;
}
const type = this.ghostUnit.buildableUnit.type;
if (type !== UnitType.AtomBomb && type !== UnitType.HydrogenBomb) {
this.view.updateNukeTrajectory(null);
this.clearNukeTrajectory();
return;
}
const myPlayer = this.game.myPlayer();
if (!myPlayer) {
this.view.updateNukeTrajectory(null);
this.clearNukeTrajectory();
return;
}
@@ -246,7 +285,7 @@ export class BuildPreviewController implements Controller {
.units(UnitType.MissileSilo)
.filter((u) => u.isActive());
if (silos.length === 0) {
this.view.updateNukeTrajectory(null);
this.clearNukeTrajectory();
return;
}
@@ -285,17 +324,14 @@ export class BuildPreviewController implements Controller {
});
}
this.view.updateNukeTrajectory(
buildNukeTrajectory(
srcX,
srcY,
dstX,
dstY,
this.game.height(),
this.uiState.rocketDirectionUp,
sams,
),
);
// 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(
@@ -446,7 +482,7 @@ export class BuildPreviewController implements Controller {
this.ghostUnit = null;
this.lastGhostData = null;
this.view.updateGhostPreview(null);
this.view.updateNukeTrajectory(null);
this.clearNukeTrajectory();
}
private removeGhostStructure() {