Send nukes as motion plans and render them smoothly per frame (#4255)

## Summary

Follow-up to #4244's payload work: nukes were the last per-tick movers
flooding the worker → main update stream.

- **Core**: nuke trajectories are fully determined at launch
(precomputed parabola), so `NukeExecution` now records a `GridPathPlan`
when the nuke is built — same mechanism trade ships use — and the client
derives the position each tick. Per-tick `UnitUpdate`s for nukes in
flight are suppressed; only targetable flips and deletion
(interception/detonation) still emit. This covers atom bombs, hydrogen
bombs, and MIRV warheads (dozens of per-tick movers per MIRV
separation).
- The plan path replays a separate pathfinder rather than reusing the
stored trajectory array: the curve's cached points don't advance exactly
one index per tick, and the plan must match the movement pathfinder's
exact per-tick tile sequence.
  - `startTick` accounts for MIRV warheads' staggered `waitTicks`.
- **Render**: `UnitPass.drawMissiles` now lerps each nuke's instance
position `lastPos→pos` by wall-clock progress through the current tick,
so nukes glide along their arc at render framerate instead of jumping
once per 100ms tick. Both endpoints are real simulated positions — the
rendered nuke trails the sim by at most one tick and settles exactly on
it when ticks stop. Plan-driven units sync `lastPos` on path-stall ticks
so the lerp never replays a segment. Shells keep their existing
two-instance trail; SAM missiles are unchanged.

## Test plan

- New `tests/nukes/NukeMotionPlan.test.ts`: tick-exact alignment between
the recorded plan and core nuke position over the whole flight
(mirroring `GameView.advanceMotionPlannedUnits` math), `waitTicks`
offset, and that no per-tick unit updates are emitted in flight except
targetable flips and deletion.
- Full suite passes (1452 + 65), tsc/eslint/prettier clean.
- Verified in-game (headless Chromium, real WebGL): atom bomb arcs from
silo to target with the client position driven by the plan, missile
sprite renders intact while the smoothing rewrites the instance buffer
every frame, detonation FX land at the target.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-06-13 13:59:03 -07:00
committed by GitHub
parent e494f83e8e
commit f4db4a33c8
12 changed files with 589 additions and 14 deletions
+14 -7
View File
@@ -11,6 +11,7 @@
*/
import type { UnitState } from "../types";
import { SMOOTHED_NUKE_TYPES } from "../types";
interface UnitTrail {
ownerID: number;
@@ -69,14 +70,20 @@ export class TrailManager {
trail = { ownerID: unit.ownerID, tiles: new Set(), lastPosStamped: -1 };
this.unitTrails.set(id, trail);
}
// Smoothed nukes render lastPos→pos interpolated per frame (UnitPass);
// stamp their trail only up to lastPos so the tail never leads the
// rendered missile.
const head = SMOOTHED_NUKE_TYPES.has(unit.unitType)
? unit.lastPos
: unit.pos;
if (trail.lastPosStamped === -1) {
// First sighting — just stamp current pos
this.stamp(unit.pos, trail.ownerID);
trail.tiles.add(unit.pos);
trail.lastPosStamped = unit.pos;
} else if (trail.lastPosStamped !== unit.pos) {
this.bresenham(trail.lastPosStamped, unit.pos, trail);
trail.lastPosStamped = unit.pos;
// First sighting — just stamp the current head
this.stamp(head, trail.ownerID);
trail.tiles.add(head);
trail.lastPosStamped = head;
} else if (trail.lastPosStamped !== head) {
this.bresenham(trail.lastPosStamped, head, trail);
trail.lastPosStamped = head;
}
}
}
+7 -1
View File
@@ -456,7 +456,13 @@ export class GPURenderer {
this.settings,
);
this.structureLevelPass = new StructureLevelPass(gl, header, this.settings);
this.unitPass = new UnitPass(gl, header, this.paletteTex, this.settings);
this.unitPass = new UnitPass(
gl,
header,
this.paletteTex,
this.settings,
config,
);
this.namePass = new NamePass(
gl,
header,
+69 -3
View File
@@ -33,8 +33,10 @@
*/
import { assetUrl } from "src/core/AssetUrls";
import type { Config } from "src/core/configuration/Config";
import type { RendererConfig, UnitState } from "../../types";
import {
SMOOTHED_NUKE_TYPES,
TrainType,
UT_ATOM_BOMB,
UT_HYDROGEN_BOMB,
@@ -94,7 +96,8 @@ const HYDROGEN_BOMB_COL = UNIT_ORDER.indexOf(UT_HYDROGEN_BOMB);
* float x, y, ownerID — 12 bytes (3 floats)
* uint8 atlasIdx — 1 byte (atlas column 011)
* uint8 flags — 1 byte (0 = normal, 1 = flicker, 2 = angry, 3 = trade-friendly, 4 = retreating, 5 = flicker-untargetable)
* 2 bytes padding — aligns to 4-byte boundary
* uint8 flickerHash 1 byte (per-instance flicker phase offset)
* 1 byte padding — aligns to 4-byte boundary
*/
const FLOATS_PER_INSTANCE = 4;
const BYTES_PER_INSTANCE = FLOATS_PER_INSTANCE * 4;
@@ -133,6 +136,20 @@ const MISSILE_TYPES: ReadonlySet<string> = new Set([
UT_MIRV_WARHEAD,
]);
/** Values per smoothing segment in the flat `smoothSegs` array:
* (instanceIdx, lastX, lastY, x, y). The push site and the read loop must
* agree on this width — it's the record size, not a tunable. */
const SMOOTH_SEG_STRIDE = 5;
/** Per-instance flicker phase offset, hashed from the tick position. Computed
* CPU-side (not from the shader's instance position) so per-frame position
* smoothing doesn't re-roll the flicker every frame. Matches the formula the
* vertex shader previously applied to its rendered position. */
export function flickerHashByte(x: number, y: number): number {
const f = x * 0.1731 + y * 0.3179;
return ((f - Math.floor(f)) * 255) | 0;
}
// ---------------------------------------------------------------------------
// Helper: create a VAO for instanced unit rendering
// ---------------------------------------------------------------------------
@@ -156,9 +173,9 @@ function createUnitVao(
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, BYTES_PER_INSTANCE, 0);
gl.vertexAttribDivisor(1, 1);
// Attribute 2: per-instance (atlasIdx, flags) — 2 uint8s at offset 12, converted to float
// Attribute 2: per-instance (atlasIdx, flags, flickerHash) — 3 uint8s at offset 12, converted to float
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 2, gl.UNSIGNED_BYTE, false, BYTES_PER_INSTANCE, 12);
gl.vertexAttribPointer(2, 3, gl.UNSIGNED_BYTE, false, BYTES_PER_INSTANCE, 12);
gl.vertexAttribDivisor(2, 1);
gl.bindVertexArray(null);
@@ -199,6 +216,14 @@ export class UnitPass {
private missileBuf: DynamicInstanceBuffer;
private missileCount = 0;
// Per-frame nuke smoothing: flat SMOOTH_SEG_STRIDE-wide tuples
// (instanceIdx, lastX, lastY, x, y) recorded each tick, lerped into the
// missile buffer in drawMissiles.
private smoothSegs: number[] = [];
private lastUnitsUpdateMs = 0;
/** Simulation tick duration in ms (Config.msPerTick). */
private tickIntervalMs: number;
private quadBuf: WebGLBuffer;
private paletteTex: WebGLTexture;
private atlasTex: WebGLTexture;
@@ -220,11 +245,13 @@ export class UnitPass {
header: RendererConfig,
paletteTex: WebGLTexture,
settings: RenderSettings,
config: Config,
) {
this.gl = gl;
this.settings = settings;
this.mapW = header.mapWidth;
this.paletteTex = paletteTex;
this.tickIntervalMs = config.msPerTick();
// Build unitType string → atlas column mapping
for (let i = 0; i < header.unitTypes.length; i++) {
@@ -356,6 +383,7 @@ export class UnitPass {
const byteOff = this.groundCount * BYTES_PER_INSTANCE;
this.groundBuf.uint8[byteOff + 12] = atlasIdx;
this.groundBuf.uint8[byteOff + 13] = flags;
this.groundBuf.uint8[byteOff + 14] = flickerHashByte(x, y);
this.groundCount++;
}
@@ -374,6 +402,7 @@ export class UnitPass {
const byteOff = this.missileCount * BYTES_PER_INSTANCE;
this.missileBuf.uint8[byteOff + 12] = atlasIdx;
this.missileBuf.uint8[byteOff + 13] = flags;
this.missileBuf.uint8[byteOff + 14] = flickerHashByte(x, y);
this.missileCount++;
}
@@ -381,6 +410,8 @@ export class UnitPass {
this.frameTick = tick;
this.groundCount = 0;
this.missileCount = 0;
this.smoothSegs.length = 0;
this.lastUnitsUpdateMs = performance.now();
for (const unit of units.values()) {
if (!unit.isActive) continue;
@@ -442,6 +473,14 @@ export class UnitPass {
const y = (unit.pos - x) / this.mapW;
if (isMissile) {
if (
SMOOTHED_NUKE_TYPES.has(unit.unitType) &&
unit.lastPos !== unit.pos
) {
const lx = unit.lastPos % this.mapW;
const ly = (unit.lastPos - lx) / this.mapW;
this.smoothSegs.push(this.missileCount, lx, ly, x, y);
}
this.emitMissile(x, y, unit.ownerID, atlasIdx, flags);
// Shells emit a second instance at lastPos (2-pixel trail effect)
@@ -541,12 +580,39 @@ export class UnitPass {
/** Draw missiles/projectiles (nukes, shells, SAM, MIRV warheads). Render above structures. */
drawMissiles(cameraMatrix: Float32Array): void {
if (this.missileCount === 0) return;
this.applyMissileSmoothing();
this.bindProgram(cameraMatrix);
const gl = this.gl;
gl.bindVertexArray(this.missileVao);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.missileCount);
}
/** Lerp smoothed nukes lastPos→pos by wall-clock progress through the
* current tick and re-upload the (small) missile instance buffer. */
private applyMissileSmoothing(): void {
const segs = this.smoothSegs;
if (segs.length === 0) return;
const alpha = Math.min(
1,
(performance.now() - this.lastUnitsUpdateMs) / this.tickIntervalMs,
);
const f32 = this.missileBuf.float32;
for (let i = 0; i < segs.length; i += SMOOTH_SEG_STRIDE) {
const off = segs[i] * FLOATS_PER_INSTANCE;
f32[off + 0] = segs[i + 1] + (segs[i + 3] - segs[i + 1]) * alpha;
f32[off + 1] = segs[i + 2] + (segs[i + 4] - segs[i + 2]) * alpha;
}
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, this.missileBuf.buffer);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
f32,
0,
this.missileCount * FLOATS_PER_INSTANCE,
);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
@@ -5,7 +5,7 @@ layout(location = 0) in vec2 aPos;
// Per-instance attributes
layout(location = 1) in vec3 aInstPos; // x, y, ownerID
layout(location = 2) in vec2 aInstFlags; // atlasIdx (uint8→float), flags (uint8→float)
layout(location = 2) in vec3 aInstFlags; // atlasIdx, flags, flickerHash (uint8→float)
uniform mat3 uCamera;
@@ -29,8 +29,10 @@ void main() {
vFlags = aInstFlags.y;
vAtlasCol = atlasCol;
// Position-based hash so each unit flickers independently
vHash = fract(worldX * 0.1731 + worldY * 0.3179);
// Per-instance hash so each unit flickers independently. Computed CPU-side
// from the tick position — hashing worldX/Y here would re-roll the phase
// every frame for nukes whose position is smoothed per frame.
vHash = aInstFlags.z * (1.0 / 255.0);
// Hydrogen bombs render an enlarged quad so there's room for a glow halo
// around the sprite. All other units keep scale 1 (no behavior change).
+10
View File
@@ -49,6 +49,16 @@ export const NUKE_TYPES: ReadonlySet<string> = new Set([
UT_MIRV,
]);
/** Nuke types whose rendered position is interpolated lastPos→pos each render
* frame (UnitPass). Their trails stamp only up to lastPos so the tail never
* leads the smoothly-moving missile. */
export const SMOOTHED_NUKE_TYPES: ReadonlySet<string> = new Set([
UT_ATOM_BOMB,
UT_HYDROGEN_BOMB,
UT_MIRV,
UT_MIRV_WARHEAD,
]);
/** Blast radii (in tiles) matching upstream DefaultConfig.nukeMagnitudes(). */
export const NUKE_MAGNITUDES: Readonly<
Record<string, { inner: number; outer: number }>
+1
View File
@@ -30,6 +30,7 @@ export {
ALL_UNIT_TYPES,
NUKE_MAGNITUDES,
NUKE_TYPES,
SMOOTHED_NUKE_TYPES,
STRUCTURE_TYPES,
UT_ATOM_BOMB,
UT_CITY,