mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 18:05:50 +00:00
## 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>
120 lines
3.2 KiB
TypeScript
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);
|
|
});
|
|
});
|