mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 18:10:21 +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>
220 lines
5.6 KiB
TypeScript
220 lines
5.6 KiB
TypeScript
import {
|
|
MAX_TRAIL_STRANDS,
|
|
SAMPLE_FLOATS,
|
|
SpiralTrails,
|
|
} from "../src/client/render/frame/SpiralTrails";
|
|
import type { UnitState } from "../src/client/render/types";
|
|
import {
|
|
UT_ATOM_BOMB,
|
|
UT_MIRV_WARHEAD,
|
|
UT_TRANSPORT,
|
|
} from "../src/client/render/types/UnitType";
|
|
|
|
const W = 64;
|
|
|
|
const ref = (x: number, y: number) => y * W + x;
|
|
|
|
const COLORS: Array<[number, number, number]> = [
|
|
[1, 0, 0],
|
|
[0, 0, 1],
|
|
];
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/** Drive a nuke (head = lastPos) left-to-right along row y, one update per step. */
|
|
function flyNuke(
|
|
st: SpiralTrails,
|
|
units: Map<number, UnitState>,
|
|
id: number,
|
|
ownerID: number,
|
|
y: number,
|
|
fromX: number,
|
|
toX: number,
|
|
stepX = 4,
|
|
): void {
|
|
const u = makeUnit(id, ownerID, UT_ATOM_BOMB, ref(fromX, y), ref(fromX, y));
|
|
units.set(id, u);
|
|
st.update(units, [id]);
|
|
for (let x = fromX + stepX; x <= toX; x += stepX) {
|
|
u.lastPos = ref(x, y);
|
|
u.pos = ref(Math.min(x + stepX, W - 1), y);
|
|
st.update(units, [id]);
|
|
}
|
|
}
|
|
|
|
describe("SpiralTrails", () => {
|
|
it("builds a ribbon only for nukes whose owner has spiral params", () => {
|
|
const st = new SpiralTrails(W);
|
|
st.setParams(5, {
|
|
radius: 4,
|
|
strands: 2,
|
|
rotationSpeed: 5,
|
|
colors: COLORS,
|
|
});
|
|
const units = new Map<number, UnitState>();
|
|
flyNuke(st, units, 1, 5, 32, 4, 24); // owner 5 — spiral
|
|
flyNuke(st, units, 2, 6, 20, 4, 24); // owner 6 — plain
|
|
|
|
const ribbons = st.getRibbons();
|
|
expect(ribbons.length).toBe(1);
|
|
expect(ribbons[0].id).toBe(1);
|
|
});
|
|
|
|
it("ignores non-nuke units even for spiral owners", () => {
|
|
const st = new SpiralTrails(W);
|
|
st.setParams(5, {
|
|
radius: 4,
|
|
strands: 2,
|
|
rotationSpeed: 5,
|
|
colors: COLORS,
|
|
});
|
|
const units = new Map<number, UnitState>();
|
|
const u = makeUnit(1, 5, UT_TRANSPORT, ref(5, 10), ref(5, 10));
|
|
units.set(1, u);
|
|
st.update(units, [1]);
|
|
u.lastPos = u.pos;
|
|
u.pos = ref(12, 10);
|
|
st.update(units, [1]);
|
|
|
|
expect(st.getRibbons().length).toBe(0);
|
|
});
|
|
|
|
it("never grows ribbons for MIRV warheads (350 per MIRV)", () => {
|
|
const st = new SpiralTrails(W);
|
|
st.setParams(5, {
|
|
radius: 4,
|
|
strands: 2,
|
|
rotationSpeed: 5,
|
|
colors: COLORS,
|
|
});
|
|
const units = new Map<number, UnitState>();
|
|
const u = makeUnit(1, 5, UT_MIRV_WARHEAD, ref(5, 10), ref(5, 10));
|
|
units.set(1, u);
|
|
st.update(units, [1]);
|
|
u.lastPos = u.pos;
|
|
u.pos = ref(12, 10);
|
|
st.update(units, [1]);
|
|
|
|
expect(st.getRibbons().length).toBe(0);
|
|
});
|
|
|
|
it("appends ~2 samples per tile with increasing distance up to the head", () => {
|
|
const st = new SpiralTrails(W);
|
|
st.setParams(5, {
|
|
radius: 4,
|
|
strands: 2,
|
|
rotationSpeed: 5,
|
|
colors: COLORS,
|
|
});
|
|
const units = new Map<number, UnitState>();
|
|
flyNuke(st, units, 1, 5, 32, 4, 40);
|
|
|
|
const r = st.getRibbons()[0];
|
|
// 36 tiles traveled at SAMPLES_PER_TILE=2, plus the seed sample.
|
|
expect(r.headDist).toBeCloseTo(36);
|
|
expect(r.sampleCount).toBe(36 * 2 + 1);
|
|
let prevD = -1;
|
|
for (let s = 0; s < r.sampleCount; s++) {
|
|
const off = s * SAMPLE_FLOATS;
|
|
const d = r.samples[off + 4];
|
|
expect(d).toBeGreaterThan(prevD);
|
|
prevD = d;
|
|
// Horizontal path: centerline on row 32, unit perpendicular (0, 1).
|
|
expect(r.samples[off + 1]).toBeCloseTo(32);
|
|
expect(r.samples[off + 2]).toBeCloseTo(0);
|
|
expect(Math.abs(r.samples[off + 3])).toBeCloseTo(1);
|
|
}
|
|
expect(prevD).toBeCloseTo(r.headDist);
|
|
});
|
|
|
|
it("clamps strands to MAX_TRAIL_STRANDS and derives twist from the pitch", () => {
|
|
const st = new SpiralTrails(W);
|
|
st.setParams(9, {
|
|
radius: 10,
|
|
strands: 12,
|
|
rotationSpeed: 5,
|
|
colors: COLORS,
|
|
});
|
|
const units = new Map<number, UnitState>();
|
|
flyNuke(st, units, 1, 9, 32, 4, 12);
|
|
|
|
const r = st.getRibbons()[0];
|
|
expect(r.strands).toBe(MAX_TRAIL_STRANDS);
|
|
// Pitch = max(radius * 4, 8) = 40 tiles per revolution.
|
|
expect(r.twist).toBeCloseTo((2 * Math.PI) / 40);
|
|
});
|
|
|
|
it("drops the ribbon when the nuke dies, mutating the live array", () => {
|
|
const st = new SpiralTrails(W);
|
|
st.setParams(5, {
|
|
radius: 4,
|
|
strands: 2,
|
|
rotationSpeed: 5,
|
|
colors: COLORS,
|
|
});
|
|
const units = new Map<number, UnitState>();
|
|
const live = st.getRibbons();
|
|
flyNuke(st, units, 1, 5, 32, 4, 40);
|
|
expect(live.length).toBe(1);
|
|
|
|
units.delete(1);
|
|
st.update(units, []);
|
|
expect(live.length).toBe(0);
|
|
});
|
|
|
|
it("keeps geometry for in-flight ribbons when params change", () => {
|
|
const st = new SpiralTrails(W);
|
|
st.setParams(5, {
|
|
radius: 4,
|
|
strands: 2,
|
|
rotationSpeed: 5,
|
|
colors: COLORS,
|
|
});
|
|
const units = new Map<number, UnitState>();
|
|
flyNuke(st, units, 1, 5, 32, 4, 20);
|
|
st.setParams(5, {
|
|
radius: 9,
|
|
strands: 3,
|
|
rotationSpeed: 1,
|
|
colors: COLORS,
|
|
});
|
|
flyNuke(st, units, 1, 5, 32, 20, 40);
|
|
|
|
const r = st.getRibbons()[0];
|
|
expect(r.radius).toBe(4);
|
|
expect(r.strands).toBe(2);
|
|
});
|
|
});
|