From dceb2798b3c198ed402ecd099bbdca62727949db Mon Sep 17 00:00:00 2001 From: evanpelle Date: Wed, 10 Jun 2026 16:20:14 -0700 Subject: [PATCH] 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. --- .../controllers/BuildPreviewController.ts | 80 ++++++++++++++----- 1 file changed, 58 insertions(+), 22 deletions(-) diff --git a/src/client/controllers/BuildPreviewController.ts b/src/client/controllers/BuildPreviewController.ts index 26a55b1b3..b9f771df8 100644 --- a/src/client/controllers/BuildPreviewController.ts +++ b/src/client/controllers/BuildPreviewController.ts @@ -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() {