Files
OpenFrontIO/tests/TrailManager.test.ts
T
828d7b1d96 feat(client): spiral nuke trail cosmetic effect (#4630)
## Summary

Adds a **spiral** `nukeTrail` effect type — a 3D vortex of glowing helix
strands projected onto the map, trailing behind nukes whose owner has
the cosmetic equipped.

Catalog attributes (`spiral_tail` shape):

| attribute | meaning |
| --- | --- |
| `colors` | palette, wrapped once around the vortex circumference |
| `radius` | helix amplitude in tiles |
| `strands` | helix strand count (renderer clamps to 8) |
| `rotationSpeed` | vortex spin, radians/sec |

## How it works

The per-tile trail texture holds persistent state; the vortex is a
transient animation that follows a path — so it renders as **ribbon
geometry**, not tile stamps.

**Path recording** (`SpiralTrails`): each live spiral-owner nuke gets an
append-only centerline polyline — ~2 samples per tile of travel carrying
position, a smoothed perpendicular (blended across tick segments so
curved paths don't kink), and cumulative distance. Params are pushed
once per player by `WebGLFrameBuilder` when the cosmetics catalog
resolves; a ribbon is dropped the moment its unit disappears, matching
stamped-trail cleanup.

**Rendering** (`SpiralRibbonPass`): one triangle-strip VBO per nuke (2
verts per sample, streamed append-only via `bufferSubData`, grown by
doubling); the vertex shader swings each sample sideways by the helix
offset and evaluates the head-cone convergence as a function of
`uHeadDist − d`, so uploaded vertices are immutable — the cone feeding
the strands into the missile needs no rewriting as the nuke flies. One
draw per strand reuses the same strip with a different phase offset
(`uPhase0`).

The glow look is a bloom-style split:

- **halo** — wide quadratic falloff, rendered premultiplied into a
quarter-resolution buffer (`mapOverlay.spiralResolutionScale = 0.25`,
~16× cheaper fragments) and composited **additively** over the scene, so
it reads as emitted light and the bilinear upsample keeps it soft;
- **core** — sharp full-resolution ribbons on top, with a white-hot
center on segments facing the viewer (neon-tube look).

Shading spins the helix angle with time: a `cos` depth cue brightens
facing segments and darkens receding ones, and the palette cross-fades
around the circumference.

The spiral nuke still stamps its plain centerline through the unchanged
`TrailManager` — `trail.frag` styleId 2 draws it flat in the first color
as the missile's spine, so alt view, death cleanup, and trail overlap
behave identically to non-cosmetic nukes. Ribbons draw above the plain
trails, below the missiles, and are skipped in alt view.

**Perf**: both ribbon stages are skipped entirely (CPU-side, before any
GL work) while no spiral nuke is in flight — games without the cosmetic,
and frames without a spiral nuke, pay nothing new. Vertex uploads stream
only newly appended samples; the halo's fragment cost is capped by the
quarter-res buffer. MIRV warheads are explicitly excluded from ribbons
(one MIRV splits into 350 of them).

**Store preview**: `TrailSwatch` mirrors the bloom split in SVG — a
screen-blended blurred halo, a crisp colored core, and a white-hot
center line — with a phase-offset per-strand fade matching the in-game
depth-shaded spin.

Note: requires the `spiral_tail` catalog entry on the API side to be
purchasable/selectable; without it nothing changes visually and the
schema tolerates its absence.

## Testing

- New `tests/SpiralTrails.test.ts`: ribbon gating by owner/unit type,
sample spacing + monotonic distances, strand clamp + pitch-derived
twist, death cleanup mutating the live array, params staying fixed for
in-flight ribbons
- New `tests/TrailManager.test.ts`: baseline stamping behavior (plain
boat trails, nuke-bit stamping up to lastPos, death cleanup with overlap
repaint)
- `tests/CosmeticSchemas.test.ts`: spiral attribute parsing incl. the
exact catalog shape, required-field/positivity rejections
- Full suite green; `tsc` and lint clean
- Verified visually: a standalone WebGL harness drove the real ribbon
shaders (glow split, palette colors, spin, cone convergence), and a real
solo game boots with zero GL errors

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:51:31 -07:00

120 lines
3.2 KiB
TypeScript

import {
NUKE_TRAIL_BIT,
TrailManager,
} from "../src/client/render/frame/TrailManager";
import type { UnitState } from "../src/client/render/types";
import {
UT_ATOM_BOMB,
UT_TRANSPORT,
} from "../src/client/render/types/UnitType";
const W = 64;
const H = 64;
const ref = (x: number, y: number) => y * W + x;
function makeUnit(
id: number,
ownerID: number,
unitType: string,
pos: number,
lastPos: number,
): UnitState {
return {
id,
unitType,
ownerID,
lastOwnerID: null,
pos,
lastPos,
isActive: true,
reachedTarget: false,
retreating: false,
targetable: true,
markedForDeletion: false,
health: null,
underConstruction: false,
targetUnitId: null,
targetTile: null,
troops: 0,
missileTimerQueue: [],
level: 1,
veterancy: 0,
hasTrainStation: false,
trainType: null,
loaded: null,
constructionStartTick: null,
};
}
/** All non-zero texels as [ref, value] pairs. */
function stampedTexels(tm: TrailManager): Array<[number, number]> {
const out: Array<[number, number]> = [];
tm.getTrailState().forEach((v, r) => {
if (v !== 0) out.push([r, v]);
});
return out;
}
describe("TrailManager", () => {
it("stamps a plain boat trail with the bare owner value", () => {
const tm = new TrailManager(W, H);
const units = new Map<number, UnitState>();
const u = makeUnit(1, 3, UT_TRANSPORT, ref(5, 10), ref(5, 10));
units.set(1, u);
tm.update(units, [1]);
u.lastPos = u.pos;
u.pos = ref(12, 10);
tm.update(units, [1]);
for (let x = 5; x <= 12; x++) {
expect(tm.getTrailState()[ref(x, 10)]).toBe(3);
}
});
it("stamps nuke trails up to lastPos with the nuke bit set", () => {
const tm = new TrailManager(W, H);
const units = new Map<number, UnitState>();
const u = makeUnit(2, 5, UT_ATOM_BOMB, ref(8, 20), ref(4, 20));
units.set(2, u);
tm.update(units, [2]);
u.lastPos = ref(10, 20);
u.pos = ref(14, 20);
tm.update(units, [2]);
// Head = lastPos, so the stamp reaches x=10, not pos (x=14).
for (let x = 4; x <= 10; x++) {
expect(tm.getTrailState()[ref(x, 20)]).toBe(5 | NUKE_TRAIL_BIT);
}
expect(tm.getTrailState()[ref(12, 20)]).toBe(0);
});
it("clears a dead unit's tiles and repaints overlaps from survivors", () => {
const tm = new TrailManager(W, H);
const units = new Map<number, UnitState>();
// Boat A along row 10, boat B down column 8 — they cross at (8, 10).
const a = makeUnit(1, 3, UT_TRANSPORT, ref(5, 10), ref(5, 10));
const b = makeUnit(2, 4, UT_TRANSPORT, ref(8, 5), ref(8, 5));
units.set(1, a);
units.set(2, b);
tm.update(units, [1, 2]);
a.lastPos = a.pos;
a.pos = ref(12, 10);
b.lastPos = b.pos;
b.pos = ref(8, 12);
tm.update(units, [1, 2]);
units.delete(1);
tm.update(units, [2]);
// A's exclusive tiles are gone; the crossing keeps B's full value.
expect(tm.getTrailState()[ref(6, 10)]).toBe(0);
expect(tm.getTrailState()[ref(8, 10)]).toBe(4);
// B's own trail is intact.
for (let y = 5; y <= 12; y++) {
expect(tm.getTrailState()[ref(8, y)]).toBe(4);
}
expect(stampedTexels(tm).every(([, v]) => v === 4)).toBe(true);
});
});