Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 48 additions & 53 deletions src/client/controllers/BuildPreviewController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
} from "../../core/game/Game";
import { TileRef } from "../../core/game/GameMap";
import { UserSettings } from "../../core/game/UserSettings";
import { clearParabolaDirection } from "../../core/pathfinding/PathFinder.Parabola";
import { Controller } from "../Controller";
import {
ConfirmGhostStructureEvent,
Expand All @@ -42,6 +41,11 @@ export function shouldPreserveGhostAfterBuild(unitType: UnitType): boolean {
return unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb;
}

// tSamIntercept value used to flag an untargetable (impassable) destination:
// draws the red X marker essentially at the destination while leaving the
// visible line unchanged (1.0 would mean "no marker").
const T_BLOCKED_DST = 0.9999;

/**
* Whether a SAM belongs in the nuke trajectory preview's threat set.
* Mirrors SAMLauncherExecution: a SAM ignores a nuke whose owner it's
Expand Down Expand Up @@ -79,16 +83,14 @@ export class BuildPreviewController implements Controller {
private lastGhostData: GhostPreviewData | null = null;

// Static inputs for the nuke trajectory preview (source silo + threatening
// SAMs + impassable-terrain blocker). 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.
// 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;
directionUp: boolean;
sams: SAMInfo[];
isBlocked: (x: number, y: number) => boolean;
} | null = null;

constructor(
Expand Down Expand Up @@ -144,18 +146,27 @@ export class BuildPreviewController implements Controller {
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(),
traj.directionUp,
traj.sams,
traj.isBlocked,
),
const data = buildNukeTrajectory(
traj.srcX,
traj.srcY,
w.x - 0.5,
w.y - 0.5,
this.game.height(),
traj.directionUp,
traj.sams,
);
// Impassable terrain can't be targeted (nukeSpawn rejects it)
// even though nukes fly over it — mark the destination with the
// blocked X. Checked per frame so the X tracks the live cursor.
const tx = Math.floor(w.x);
const ty = Math.floor(w.y);
if (
this.game.isValidCoord(tx, ty) &&
this.game.isImpassable(this.game.ref(tx, ty))
) {
data.tSamIntercept = Math.min(data.tSamIntercept, T_BLOCKED_DST);
}
this.view.updateNukeTrajectory(data);
}
}
requestAnimationFrame(cursorLoop);
Expand Down Expand Up @@ -195,14 +206,19 @@ export class BuildPreviewController implements Controller {
if (now - this.lastGhostQueryAt < 50) return;
this.lastGhostQueryAt = now;
let tileRef: TileRef | undefined;
let trajectoryTileRef: 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);
trajectoryTileRef = tileRef;
// Impassable terrain is a void — treat hovering over it the same as
// hovering outside the map (no ghost, no trajectory, no blast circle).
// hovering outside the map (no ghost, no blast circle). The nuke
// trajectory preview is the exception: nukes fly over impassable
// terrain, so the arc still renders (with a blocked X at the
// untargetable destination — see cursorLoop).
if (this.game.isImpassable(tileRef)) {
tileRef = undefined;
}
Expand Down Expand Up @@ -243,7 +259,7 @@ export class BuildPreviewController implements Controller {
.then((buildables) => {
if (!this.ghostUnit) {
this.pendingConfirm = null;
this.emitGhostPreview(tileRef, targetingAlly);
this.emitGhostPreview(tileRef, targetingAlly, trajectoryTileRef);
return;
}

Expand All @@ -256,7 +272,7 @@ export class BuildPreviewController implements Controller {
canUpgrade: false,
});
this.pendingConfirm = null;
this.emitGhostPreview(tileRef, targetingAlly);
this.emitGhostPreview(tileRef, targetingAlly, trajectoryTileRef);
return;
}

Expand All @@ -270,7 +286,7 @@ export class BuildPreviewController implements Controller {
}
}

this.emitGhostPreview(tileRef, targetingAlly);
this.emitGhostPreview(tileRef, targetingAlly, trajectoryTileRef);
});
}

Expand All @@ -283,6 +299,7 @@ export class BuildPreviewController implements Controller {
private emitGhostPreview(
tileRef: TileRef | undefined,
targetingAlly: boolean,
trajectoryTileRef: TileRef | undefined,
): void {
const data = this.buildGhostPreviewData(tileRef, targetingAlly);
if (data === null) {
Expand All @@ -291,14 +308,17 @@ export class BuildPreviewController implements Controller {
} else {
this.lastGhostData = data;
}
this.updateNukeTrajectoryPreview(tileRef);
// The trajectory target is tracked separately from the ghost tile:
// impassable terrain voids the ghost but still gets a trajectory arc.
this.updateNukeTrajectoryPreview(trajectoryTileRef);
}

/**
* 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.
* has no silos. Unlike the ghost icon, the trajectory also renders when
* hovering impassable terrain (cursorLoop adds the blocked X there).
*/
private updateNukeTrajectoryPreview(tileRef: TileRef | undefined): void {
if (!this.ghostUnit || tileRef === undefined) {
Expand All @@ -318,10 +338,9 @@ export class BuildPreviewController implements Controller {

// 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 (Manhattan distance) whose
// parabola avoids impassable terrain on the up or down curve is chosen.
// Keeping these in sync prevents the preview arc from originating from
// a silo the game wouldn't use.
// construction are eligible, and the nearest (Manhattan distance) is
// chosen. 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(
Expand All @@ -342,25 +361,8 @@ export class BuildPreviewController implements Controller {
Math.abs(this.game.y(b.tile()) - dstY)),
);

// NukeExecution flies the requested curve direction if clear, otherwise
// the opposite one — resolve the direction the sim would actually use.
// If every silo is blocked both ways, fall back to the nearest silo and
// the requested direction so the arc renders red with the blocked X.
let bestSilo = silos[0];
let directionUp = this.uiState.rocketDirectionUp;
for (const s of silos) {
const dir = clearParabolaDirection(
this.game,
s.tile(),
tileRef,
this.uiState.rocketDirectionUp,
);
if (dir !== null) {
bestSilo = s;
directionUp = dir;
break;
}
}
const bestSilo = silos[0];
const directionUp = this.uiState.rocketDirectionUp;
const srcX = this.game.x(bestSilo.tile());
const srcY = this.game.y(bestSilo.tile());

Expand Down Expand Up @@ -409,18 +411,11 @@ export class BuildPreviewController implements Controller {

// Stash the static inputs; cursorLoop rebuilds the Bezier each frame with
// the live cursor as the destination so the arc tracks smoothly.
// The isBlocked callback tests impassable terrain so the trajectory turns
// red with a red X where it would cross impassable terrain (matching the
// simulation's abort-on-impassable behavior).
this.nukeTrajectoryStatic = {
srcX,
srcY,
directionUp,
sams,
isBlocked: (x: number, y: number) => {
if (!this.game.isValidCoord(x, y)) return false;
return this.game.isImpassable(this.game.ref(x, y));
},
};
}

Expand Down
78 changes: 2 additions & 76 deletions src/client/render/gl/utils/NukeTrajectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,19 +125,10 @@ function refineCrossing(

/**
* Sample the Bezier curve at regular t intervals and find color threshold
* t-values for untargetable zones, SAM intercept, and impassable terrain.
* t-values for untargetable zones and SAM intercept.
*
* Uses binary search refinement for sub-sample precision so that zone
* boundary markers don't jiggle when the cursor moves.
*
* @param isBlocked Optional callback: given a continuous (x, y) point on the
* Bezier, returns true if that point falls on impassable
* terrain. The scan covers the ENTIRE curve (including the
* untargetable mid-air zone), because impassable terrain
* blocks the nuke regardless of targetability. When a
* blocked point is found, its t-value is merged into
* `tSamIntercept` (via min) so the existing red-line + red-X
* machinery renders the trajectory as blocked.
*/
export function computeTrajectoryThresholds(
cp: {
Expand All @@ -155,7 +146,6 @@ export function computeTrajectoryThresholds(
dstX: number,
dstY: number,
sams: readonly SAMInfo[],
isBlocked?: (x: number, y: number) => boolean,
): {
tUntargetableStart: number;
tUntargetableEnd: number;
Expand All @@ -164,7 +154,6 @@ export function computeTrajectoryThresholds(
let tUntargetableStart = -1;
let tUntargetableEnd = -1;
let tSamIntercept = 1.0;
let tBlocked = 1.0;

const dt = 1.0 / THRESHOLD_SAMPLES;

Expand Down Expand Up @@ -243,66 +232,12 @@ export function computeTrajectoryThresholds(
}
}

// Pass 3: find impassable terrain intercept (scan the ENTIRE curve —
// impassable terrain blocks the nuke regardless of targetability, so
// unlike SAMs we do NOT skip the untargetable mid-air zone).
if (isBlocked) {
for (let i = 1; i <= THRESHOLD_SAMPLES; i++) {
const t = i * dt;
const x = bezier(t, cp.p0x, cp.p1x, cp.p2x, cp.p3x);
const y = bezier(t, cp.p0y, cp.p1y, cp.p2y, cp.p3y);
// Mirror the simulation's tile-sampling: floor to integer tile coords.
if (isBlocked(Math.floor(x), Math.floor(y))) {
tBlocked = refineBlockedCrossing(cp, isBlocked, t - dt, t);
break;
}
}
// Merge: the earlier of SAM intercept and impassable block determines
// where the trajectory turns red + shows the X.
tSamIntercept = Math.min(tSamIntercept, tBlocked);
}

return { tUntargetableStart, tUntargetableEnd, tSamIntercept };
}

/**
* Binary-search for the exact t where the curve first enters a blocked tile.
* Unlike refineCrossing (which uses a radial distance test), this tests
* isBlocked on the floored integer tile at each subdivision point.
*/
function refineBlockedCrossing(
cp: {
p0x: number;
p0y: number;
p1x: number;
p1y: number;
p2x: number;
p2y: number;
p3x: number;
p3y: number;
},
isBlocked: (x: number, y: number) => boolean,
tLo: number,
tHi: number,
): number {
for (let i = 0; i < 10; i++) {
const tMid = (tLo + tHi) * 0.5;
const x = Math.floor(bezier(tMid, cp.p0x, cp.p1x, cp.p2x, cp.p3x));
const y = Math.floor(bezier(tMid, cp.p0y, cp.p1y, cp.p2y, cp.p3y));
if (isBlocked(x, y)) tHi = tMid;
else tLo = tMid;
}
return (tLo + tHi) * 0.5;
}

/**
* Build complete NukeTrajectoryData from source/target positions.
* Convenience function combining control point + threshold computation.
*
* @param isBlocked Optional callback: returns true if a floored (x, y) point
* on the Bezier is impassable terrain. When provided, the
* trajectory turns red and shows the red X at the first
* impassable tile (merged with any SAM intercept).
*/
export function buildNukeTrajectory(
srcX: number,
Expand All @@ -312,7 +247,6 @@ export function buildNukeTrajectory(
mapH: number,
directionUp: boolean,
sams: readonly SAMInfo[],
isBlocked?: (x: number, y: number) => boolean,
): NukeTrajectoryData {
const cp = computeNukeControlPoints(
srcX,
Expand All @@ -322,14 +256,6 @@ export function buildNukeTrajectory(
mapH,
directionUp,
);
const th = computeTrajectoryThresholds(
cp,
srcX,
srcY,
dstX,
dstY,
sams,
isBlocked,
);
const th = computeTrajectoryThresholds(cp, srcX, srcY, dstX, dstY, sams);
return { ...cp, ...th };
}
29 changes: 1 addition & 28 deletions src/core/execution/NukeExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@ import {
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { UniversalPathFinding } from "../pathfinding/PathFinder";
import {
clearParabolaDirection,
ParabolaUniversalPathFinder,
} from "../pathfinding/PathFinder.Parabola";
import { ParabolaUniversalPathFinder } from "../pathfinding/PathFinder.Parabola";
import { PathStatus } from "../pathfinding/types";
import { PseudoRandom } from "../PseudoRandom";
import { NukeType } from "../StatsSchemas";
Expand Down Expand Up @@ -195,30 +192,6 @@ export class NukeExecution implements Execution {
// The launch tile can be overridden by the caller (e.g. MIRV warheads
// launch from the MIRV separation point, not a silo).
this.src ??= spawn;
// Nuke trajectories cannot pass over impassable terrain unless they are MIRV warheads, just as they
// cannot exceed the map border. Fly the requested curve direction if
// it is clear, otherwise the opposite one; if both curves cross
// impassable terrain, abort the launch.
if (this.nukeType !== UnitType.MIRVWarhead) {
const direction = clearParabolaDirection(
this.mg,
this.src,
this.dst,
this.rocketDirectionUp,
);
if (direction === null) {
console.warn(`nuke trajectory crosses impassable terrain`);
this.active = false;
return;
}
if (direction !== this.rocketDirectionUp) {
this.rocketDirectionUp = direction;
this.pathFinder = UniversalPathFinding.Parabola(this.mg, {
increment: this.speed,
directionUp: direction,
});
}
}
this.nuke = this.player.buildUnit(this.nukeType, this.src, {
targetTile: this.dst,
trajectory: this.getTrajectory(this.dst),
Expand Down
Loading
Loading