diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts
index 3dad8c682..7b168602a 100644
--- a/src/client/WebGLFrameBuilder.ts
+++ b/src/client/WebGLFrameBuilder.ts
@@ -488,6 +488,28 @@ export class WebGLFrameBuilder {
if (!isTrailEffect(effect) && effect.effectType !== "structures") {
return;
}
+ // Spiral vortexes render as ribbon geometry (SpiralRibbonPass) —
+ // hand the geometry + palette to the view's SpiralTrails. Colors are
+ // parsed here so a fully unparseable list degrades to the plain
+ // stamped trail instead of an uncolored vortex.
+ if (effectType === "nukeTrail" && effect.attributes.type === "spiral") {
+ const colors = effect.attributes.colors
+ .map((s) => colord(s))
+ .filter((c) => c.isValid())
+ .slice(0, MAX_TRAIL_COLORS)
+ .map((c) => {
+ const { r, g, b } = c.toRgb();
+ return [r / 255, g / 255, b / 255] as [number, number, number];
+ });
+ if (colors.length > 0) {
+ gameView.setNukeTrailSpiral(smallID, {
+ radius: effect.attributes.radius,
+ strands: effect.attributes.strands,
+ rotationSpeed: effect.attributes.rotationSpeed,
+ colors,
+ });
+ }
+ }
const rowBase = block * MAX_TRAIL_COLORS;
if (this.writeEffectEntry(smallID, effect.attributes, rowBase)) {
dirty = true;
@@ -503,9 +525,10 @@ export class WebGLFrameBuilder {
* _EFFECT_BLOCK_ORDER). Within the block, row r holds color r's rgb, and the spare alpha
* channels (rows rowBase+0..3 always exist) carry the scalar params —
* row 0.a = color count (0 → the shader falls back to the territory color),
- * row 1.a = styleId (0 = gradient, 1 = transition),
- * row 2.a = scalar0 (gradient: colorSize; transition: frequency),
- * row 3.a = scalar1 (gradient: movementSpeed; transition: unused).
+ * row 1.a = styleId (0 = gradient, 1 = transition, 2 = spiral),
+ * row 2.a = scalar0 (gradient: colorSize; transition: frequency;
+ * spiral: rotationSpeed),
+ * row 3.a = scalar1 (gradient: movementSpeed; others: unused).
* colord doesn't throw on a bad color string (it returns black), so unparseable
* colors are dropped — leaving an empty list, which falls back to the territory
* color rather than rendering black. Returns whether any color was written.
@@ -528,10 +551,22 @@ export class WebGLFrameBuilder {
this.effectPalette[off + 2] = c.b / 255;
this.effectPalette[off + 3] = 0;
}
- const [styleId, scalar0, scalar1] =
- attrs.type === "transition"
- ? [1, attrs.frequency, 0]
- : [0, attrs.colorSize, attrs.movementSpeed];
+ let styleId: number;
+ let scalar0: number;
+ let scalar1: number;
+ if (attrs.type === "transition") {
+ styleId = 1;
+ scalar0 = attrs.frequency;
+ scalar1 = 0;
+ } else if (attrs.type === "spiral") {
+ styleId = 2;
+ scalar0 = attrs.rotationSpeed;
+ scalar1 = 0;
+ } else {
+ styleId = 0;
+ scalar0 = attrs.colorSize;
+ scalar1 = attrs.movementSpeed;
+ }
const alpha = (row: number) =>
((rowBase + row) * PALETTE_SIZE + smallID) * 4 + 3;
this.effectPalette[alpha(0)] = colors.length;
diff --git a/src/client/components/EffectPreview.ts b/src/client/components/EffectPreview.ts
index c55313fbe..54663a8d7 100644
--- a/src/client/components/EffectPreview.ts
+++ b/src/client/components/EffectPreview.ts
@@ -1,4 +1,4 @@
-import { html, LitElement, TemplateResult } from "lit";
+import { html, LitElement, svg, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import {
NukeExplosionAttributes,
@@ -8,15 +8,50 @@ import {
// Neutral fallback when a trail has no usable colors.
const EMPTY_BG = "#444";
+// Spiral swatch backdrop — the app's recessed-surface navy (bg-surface); the
+// glow reads as emitted light only against a dark ground.
+const SPIRAL_BG = "#082f49";
+
+// Spiral swatch geometry: sine strands across a 100×48 viewBox, two full
+// waves wide, sampled every 4 units. Like in game, the strands converge into
+// the nuke: amplitude tapers to 0 at the right edge (the missile's side) over
+// the last SPIRAL_TAPER_W units.
+const SPIRAL_VIEW_W = 100;
+const SPIRAL_VIEW_H = 48;
+const SPIRAL_AMPLITUDE = 16;
+const SPIRAL_WAVELENGTH = 50;
+const SPIRAL_TAPER_W = 40;
+
+/** Polyline path of one sine strand at the given phase offset (radians). */
+function spiralStrandPath(phase: number): string {
+ const pts: string[] = [];
+ for (let x = 0; x <= SPIRAL_VIEW_W; x += 4) {
+ const taper = Math.min((SPIRAL_VIEW_W - x) / SPIRAL_TAPER_W, 1);
+ const y =
+ SPIRAL_VIEW_H / 2 +
+ SPIRAL_AMPLITUDE *
+ Math.sin((Math.PI / 2) * taper) *
+ Math.sin((x / SPIRAL_WAVELENGTH) * 2 * Math.PI + phase);
+ pts.push(`${x} ${y.toFixed(1)}`);
+ }
+ return `M ${pts.join(" L ")}`;
+}
/**
* Swatch preview of a trail-styled effect (trails and the structures effect
- * share the same gradient/transition attribute shapes), filling its container.
+ * share the same gradient/transition/spiral attribute shapes), filling its
+ * container.
*
* - gradient / single color: a static swatch (flat color or left-to-right
* gradient — a multi-color list reads as a rainbow).
* - transition: cross-fades through the colors over time, mirroring the trail
* (each color step lasts 1/frequency seconds, matching the shader).
+ * - spiral: neon sine strands on a dark backdrop (the helix seen side-on)
+ * tapering into the nuke's side. Mirrors the in-game glow split: a wide
+ * screen-blended blur (the additive halo), a crisp colored core, and a
+ * white-hot center line that only shows while the strand faces the viewer.
+ * Strands dim toward the backdrop and back in phase order, once per
+ * revolution (2π/rotationSpeed s) — the depth-shaded spin.
*/
@customElement("trail-swatch")
export class TrailSwatch extends LitElement {
@@ -24,7 +59,7 @@ export class TrailSwatch extends LitElement {
@property({ attribute: false })
trail: TrailEffectAttributes | StructuresEffectAttributes | null = null;
- private animation: Animation | null = null;
+ private animations: Animation[] = [];
// Light DOM so the shared Tailwind classes apply.
createRenderRoot(): HTMLElement {
@@ -33,6 +68,55 @@ export class TrailSwatch extends LitElement {
render(): TemplateResult {
const colors = this.trail?.colors ?? [];
+ if (this.trail?.type === "spiral" && colors.length > 0) {
+ // Strand count mirrors the in-game clamp (max 8).
+ const strands = Math.min(Math.max(Math.round(this.trail.strands), 1), 8);
+ return html`
+
+
`;
+ }
let background: string;
if (colors.length === 0) {
background = EMPTY_BG;
@@ -52,33 +136,71 @@ export class TrailSwatch extends LitElement {
updated(changed: Map): void {
if (!changed.has("trail")) return;
- this.animation?.cancel();
- this.animation = null;
+ for (const a of this.animations) a.cancel();
+ this.animations = [];
const attrs = this.trail;
- if (attrs?.type !== "transition") return;
- const colors = attrs.colors;
- if (colors.length < 2 || attrs.frequency <= 0) return;
+ if (attrs?.type === "transition") {
+ const colors = attrs.colors;
+ if (colors.length < 2 || attrs.frequency <= 0) return;
- const fill = this.querySelector("div");
- if (!fill) return;
+ const fill = this.querySelector("div");
+ if (!fill) return;
- // Cross-fade color0 → color1 → … → color0; each step lasts 1/frequency s,
- // matching the shader's i = floor(uTime * frequency) mod count.
- const keyframes = [...colors, colors[0]].map((c) => ({
- backgroundColor: c,
- }));
- this.animation = fill.animate(keyframes, {
- duration: (colors.length / attrs.frequency) * 1000,
- iterations: Infinity,
- easing: "linear",
- });
+ // Cross-fade color0 → color1 → … → color0; each step lasts 1/frequency s,
+ // matching the shader's i = floor(uTime * frequency) mod count.
+ const keyframes = [...colors, colors[0]].map((c) => ({
+ backgroundColor: c,
+ }));
+ this.animations.push(
+ fill.animate(keyframes, {
+ duration: (colors.length / attrs.frequency) * 1000,
+ iterations: Infinity,
+ easing: "linear",
+ }),
+ );
+ return;
+ }
+
+ if (attrs?.type === "spiral") {
+ if (attrs.rotationSpeed <= 0) return;
+
+ // The vortex spin: each strand group (halo + core + hot line) dims
+ // toward the backdrop and back once per revolution (2π/rotationSpeed
+ // s), phase-offset by its position around the axis, and the white-hot
+ // center vanishes entirely while the strand recedes — facing strands
+ // read white-hot, receding ones dark, like the in-game depth shading.
+ const strandGroups = this.querySelectorAll("[data-strand]");
+ const periodMs = ((2 * Math.PI) / attrs.rotationSpeed) * 1000;
+ strandGroups.forEach((group, s) => {
+ const delay = (-s * periodMs) / strandGroups.length;
+ this.animations.push(
+ group.animate([{ opacity: 1 }, { opacity: 0.35 }, { opacity: 1 }], {
+ duration: periodMs,
+ delay,
+ iterations: Infinity,
+ easing: "ease-in-out",
+ }),
+ );
+ const hot = group.querySelector("[data-hot]");
+ if (hot) {
+ this.animations.push(
+ hot.animate([{ opacity: 0.9 }, { opacity: 0 }, { opacity: 0.9 }], {
+ duration: periodMs,
+ delay,
+ iterations: Infinity,
+ easing: "ease-in-out",
+ }),
+ );
+ }
+ });
+ }
}
disconnectedCallback(): void {
super.disconnectedCallback();
- this.animation?.cancel();
- this.animation = null;
+ for (const a of this.animations) a.cancel();
+ this.animations = [];
}
}
diff --git a/src/client/render/frame/SpiralTrails.ts b/src/client/render/frame/SpiralTrails.ts
new file mode 100644
index 000000000..a12f0bde3
--- /dev/null
+++ b/src/client/render/frame/SpiralTrails.ts
@@ -0,0 +1,242 @@
+/**
+ * SpiralTrails — per-nuke centerline polylines for the spiral nukeTrail
+ * cosmetic, consumed by SpiralRibbonPass.
+ *
+ * Unlike TrailManager (per-tile state stamped into the trail texture), spiral
+ * vortexes are pure animation: the renderer draws helix ribbons as geometry
+ * from each nuke's path. This manager only records that path — ~1-tile-spaced
+ * centerline samples with a smoothed perpendicular (strand offsets swing along
+ * it) and the cumulative distance at each point. Samples are append-only; the
+ * head-cone convergence is a function of (headDist - sampleDist) evaluated in
+ * the ribbon vertex shader, so nothing here is ever rewritten. A nuke's
+ * ribbon is dropped the moment the unit disappears, matching how TrailManager
+ * clears a dead unit's stamped trail.
+ *
+ * The spiral nuke still stamps its plain centerline through TrailManager like
+ * any other nuke, so alt view, death cleanup, and trail overlap behave
+ * identically to non-cosmetic nukes — the ribbon is purely additive on top.
+ */
+
+import type { UnitState } from "../types";
+import { SMOOTHED_NUKE_TYPES, UT_MIRV_WARHEAD } from "../types";
+
+export const MAX_TRAIL_STRANDS = 8;
+
+const TAU = 2 * Math.PI;
+
+// A spiral completes one full rotation every max(radius * PITCH_PER_RADIUS, 8)
+// tiles of travel — pitch scales with amplitude so wide helixes don't zigzag.
+const PITCH_PER_RADIUS = 4;
+const MIN_PITCH = 8;
+// Centerline samples per tile of travel — the ribbon strip's segment length.
+const SAMPLES_PER_TILE = 2;
+
+/** Floats per sample in SpiralRibbon.samples: cx, cy, px, py, d. */
+export const SAMPLE_FLOATS = 5;
+
+/** Spiral nuke-trail cosmetic parameters (from the owner's nukeTrail effect). */
+export interface SpiralParams {
+ radius: number; // helix amplitude in tiles
+ strands: number; // helix strand count (clamped to MAX_TRAIL_STRANDS)
+ rotationSpeed: number; // vortex spin, radians/sec
+ colors: ReadonlyArray; // rgb 0..1
+}
+
+/** One live spiral nuke's path, exposed to the renderer as a live ref. */
+export interface SpiralRibbon {
+ readonly id: number; // unit id — stable key for per-ribbon GPU buffers
+ readonly radius: number;
+ readonly strands: number;
+ readonly twist: number; // helix phase advance, radians per tile
+ readonly rotationSpeed: number;
+ readonly colors: ReadonlyArray;
+ /** Cumulative centerline distance at the nuke's head (grows every tick). */
+ headDist: number;
+ /** Valid sample count; samples may have spare capacity beyond it. */
+ sampleCount: number;
+ /** [cx, cy, px, py, d] × sampleCount. Append-only; grown by doubling. */
+ samples: Float32Array;
+}
+
+interface RibbonState extends SpiralRibbon {
+ headDist: number;
+ sampleCount: number;
+ samples: Float32Array;
+ lastPos: number; // tile ref of the last recorded head position
+ // Unit direction of the previously appended segment. Per-sample
+ // perpendiculars blend from it into the new segment's direction across the
+ // segment, so strands turn smoothly on curved paths instead of kinking at
+ // tick boundaries.
+ dirX: number;
+ dirY: number;
+ hasDir: boolean;
+}
+
+export class SpiralTrails {
+ // Per-owner spiral geometry (from the nukeTrail cosmetic); owners without
+ // an entry get no ribbon.
+ private readonly params = new Map();
+ private readonly ribbonsById = new Map();
+ // Stable array instance — FrameData keeps a live ref to it.
+ private readonly ribbonList: SpiralRibbon[] = [];
+ private readonly mapW: number;
+
+ constructor(mapW: number) {
+ this.mapW = mapW;
+ }
+
+ /**
+ * Set a player's spiral geometry. Applies to nukes that start after the
+ * call; in-flight ribbons keep their geometry.
+ */
+ setParams(ownerID: number, params: SpiralParams): void {
+ this.params.set(ownerID, {
+ radius: params.radius,
+ strands: Math.min(
+ Math.max(Math.round(params.strands), 1),
+ MAX_TRAIL_STRANDS,
+ ),
+ rotationSpeed: params.rotationSpeed,
+ colors: params.colors,
+ });
+ }
+
+ /** Live ref to the current ribbons (mutated in place each update). */
+ getRibbons(): readonly SpiralRibbon[] {
+ return this.ribbonList;
+ }
+
+ reset(): void {
+ this.ribbonsById.clear();
+ this.ribbonList.length = 0;
+ }
+
+ /**
+ * Advance ribbons from the current unit set: extend the path of each live
+ * spiral-owner nuke, drop ribbons whose unit disappeared.
+ */
+ update(units: Map, trackedIds: number[]): void {
+ let changed = false;
+ for (const id of this.ribbonsById.keys()) {
+ if (!units.has(id)) {
+ this.ribbonsById.delete(id);
+ changed = true;
+ }
+ }
+ for (const id of trackedIds) {
+ const unit = units.get(id);
+ if (!unit || !SMOOTHED_NUKE_TYPES.has(unit.unitType)) continue;
+ // MIRV warheads never grow ribbons — one MIRV splits into 350 of them,
+ // which would fan out into 350 VBOs and per-strand draw calls at once.
+ if (unit.unitType === UT_MIRV_WARHEAD) continue;
+ let ribbon = this.ribbonsById.get(id);
+ if (!ribbon) {
+ const params = this.params.get(unit.ownerID);
+ if (!params) continue;
+ // Like TrailManager, stamp only up to lastPos — UnitPass renders the
+ // missile interpolated lastPos→pos, and the ribbon must trail it.
+ ribbon = this.newRibbon(id, params, unit.lastPos);
+ this.ribbonsById.set(id, ribbon);
+ changed = true;
+ }
+ if (unit.lastPos !== ribbon.lastPos) {
+ this.advance(ribbon, unit.lastPos);
+ }
+ }
+ if (changed) {
+ this.ribbonList.length = 0;
+ for (const r of this.ribbonsById.values()) this.ribbonList.push(r);
+ }
+ }
+
+ private newRibbon(
+ id: number,
+ params: SpiralParams,
+ startPos: number,
+ ): RibbonState {
+ const pitch = Math.max(params.radius * PITCH_PER_RADIUS, MIN_PITCH);
+ return {
+ id,
+ radius: params.radius,
+ strands: params.strands,
+ twist: TAU / pitch,
+ rotationSpeed: params.rotationSpeed,
+ colors: params.colors,
+ headDist: 0,
+ sampleCount: 0,
+ samples: new Float32Array(256 * SAMPLE_FLOATS),
+ lastPos: startPos,
+ dirX: 0,
+ dirY: 0,
+ hasDir: false,
+ };
+ }
+
+ /** Append ~1/SAMPLES_PER_TILE-spaced samples along lastPos → head. */
+ private advance(r: RibbonState, head: number): void {
+ const w = this.mapW;
+ const x0 = r.lastPos % w;
+ const y0 = (r.lastPos - x0) / w;
+ const x1 = head % w;
+ const y1 = (head - x1) / w;
+ r.lastPos = head;
+ const dx = x1 - x0;
+ const dy = y1 - y0;
+ const segLen = Math.hypot(dx, dy);
+ if (segLen === 0) return;
+ const ndx = dx / segLen;
+ const ndy = dy / segLen;
+ const fromDirX = r.hasDir ? r.dirX : ndx;
+ const fromDirY = r.hasDir ? r.dirY : ndy;
+ const dirAt = (f: number): [number, number] => {
+ const bx = fromDirX + (ndx - fromDirX) * f;
+ const by = fromDirY + (ndy - fromDirY) * f;
+ const len = Math.hypot(bx, by);
+ if (len < 1e-6) return [ndx, ndy]; // 180° turn — no meaningful blend
+ return [bx / len, by / len];
+ };
+ if (r.sampleCount === 0) {
+ const [bx, by] = dirAt(0);
+ this.pushSample(r, x0, y0, -by, bx, 0);
+ }
+ const steps = Math.ceil(segLen * SAMPLES_PER_TILE);
+ for (let i = 1; i <= steps; i++) {
+ const f = i / steps;
+ const [bx, by] = dirAt(f);
+ this.pushSample(
+ r,
+ x0 + dx * f,
+ y0 + dy * f,
+ -by,
+ bx,
+ r.headDist + segLen * f,
+ );
+ }
+ r.dirX = ndx;
+ r.dirY = ndy;
+ r.hasDir = true;
+ r.headDist += segLen;
+ }
+
+ private pushSample(
+ r: RibbonState,
+ cx: number,
+ cy: number,
+ px: number,
+ py: number,
+ d: number,
+ ): void {
+ const off = r.sampleCount * SAMPLE_FLOATS;
+ if (off + SAMPLE_FLOATS > r.samples.length) {
+ const grown = new Float32Array(r.samples.length * 2);
+ grown.set(r.samples);
+ r.samples = grown;
+ }
+ r.samples[off] = cx;
+ r.samples[off + 1] = cy;
+ r.samples[off + 2] = px;
+ r.samples[off + 3] = py;
+ r.samples[off + 4] = d;
+ r.sampleCount++;
+ }
+}
diff --git a/src/client/render/frame/Upload.ts b/src/client/render/frame/Upload.ts
index 3545e7fc1..6f5b639de 100644
--- a/src/client/render/frame/Upload.ts
+++ b/src/client/render/frame/Upload.ts
@@ -10,6 +10,7 @@ import type {
PlayerStatusData,
UnitState,
} from "../types";
+import type { SpiralRibbon } from "./SpiralTrails";
/**
* Structural interface for the GPU view target.
@@ -29,6 +30,7 @@ export interface FrameUploadTarget {
dirtyRowMin: number,
dirtyRowMax: number,
): void;
+ updateSpiralRibbons(ribbons: readonly SpiralRibbon[]): void;
uploadRailroadState(data: Uint8Array): void;
applyRailroadDust(tileRefs: number[]): void;
updateUnits(units: ReadonlyMap, gameTick: number): void;
@@ -76,6 +78,9 @@ export function uploadFrameData(
} else {
view.uploadTileAndTrailState(frame.tileState, frame.trailState);
}
+ // Live refs into SpiralTrails; streams only newly appended samples, and a
+ // no-op while no spiral nuke is in flight.
+ view.updateSpiralRibbons(frame.spiralRibbons);
// --- Railroads ---
if (frame.railroadDirty) {
diff --git a/src/client/render/gl/MapRenderer.ts b/src/client/render/gl/MapRenderer.ts
index 2fa815b3b..61dcc7211 100644
--- a/src/client/render/gl/MapRenderer.ts
+++ b/src/client/render/gl/MapRenderer.ts
@@ -12,6 +12,7 @@
*/
import type { Config } from "../../../core/configuration/Config";
+import type { SpiralRibbon } from "../frame/SpiralTrails";
import type {
AttackRingInput,
BonusEvent,
@@ -149,6 +150,9 @@ export class MapRenderer {
): void {
this.renderer?.uploadTileAndTrailState(tileState, trailState);
}
+ updateSpiralRibbons(ribbons: readonly SpiralRibbon[]): void {
+ this.renderer?.updateSpiralRibbons(ribbons);
+ }
updatePalette(paletteData: Float32Array): void {
this.renderer?.updatePalette(paletteData);
}
diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts
index 4c946315c..1056ae2d3 100644
--- a/src/client/render/gl/RenderSettings.ts
+++ b/src/client/render/gl/RenderSettings.ts
@@ -119,6 +119,11 @@ export interface RenderSettings {
};
mapOverlay: {
trailAlpha: number;
+ /**
+ * Resolution of the offscreen spiral-trail buffer relative to the canvas
+ * (0..1). Lower = cheaper + softer/glowier (bilinear upsample).
+ */
+ spiralResolutionScale: number;
defenseCheckerDarken: number;
territoryDefenseDarken: number;
/** Saturation of the territory fill. 1 = full color, 0 = grayscale. */
diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts
index 006ecaea5..e2ed0fa5b 100644
--- a/src/client/render/gl/Renderer.ts
+++ b/src/client/render/gl/Renderer.ts
@@ -10,6 +10,7 @@
*/
import type { Config } from "../../../core/configuration/Config";
+import type { SpiralRibbon } from "../frame/SpiralTrails";
import type {
AttackRingInput,
BonusEvent,
@@ -51,6 +52,7 @@ import { SkinAtlasArray } from "./passes/SkinAtlasArray";
import { SmallPlayerGlowPass } from "./passes/SmallPlayerGlowPass";
import type { SpawnCenter } from "./passes/SpawnOverlayPass";
import { SpawnOverlayPass } from "./passes/SpawnOverlayPass";
+import { SpiralRibbonPass } from "./passes/SpiralRibbonPass";
import { StructureLevelPass } from "./passes/StructureLevelPass";
import { StructurePass } from "./passes/StructurePass";
import { TerrainPass } from "./passes/TerrainPass";
@@ -114,6 +116,7 @@ export class GPURenderer {
private terrainPass: TerrainPass;
private territoryPass: TerritoryPass;
private trailPass: TrailPass;
+ private spiralRibbonPass: SpiralRibbonPass;
private borderStampPass: BorderStampPass;
private borderPass: BorderComputePass;
private defenseCoveragePass: DefenseCoveragePass;
@@ -450,6 +453,9 @@ export class GPURenderer {
this.settings,
);
+ // --- Spiral nukeTrail ribbons (drawn above trails, below missiles) ---
+ this.spiralRibbonPass = new SpiralRibbonPass(gl, this.settings);
+
// --- Border stamp (needs tileTex, paletteTex, borderTex) ---
this.borderStampPass = new BorderStampPass(
gl,
@@ -677,6 +683,11 @@ export class GPURenderer {
this.trailPass.applyLiveDelta(trailState, dirtyRowMin, dirtyRowMax);
}
+ /** Adopt this tick's spiral nukeTrail ribbons (live refs from SpiralTrails). */
+ updateSpiralRibbons(ribbons: readonly SpiralRibbon[]): void {
+ this.spiralRibbonPass.updateRibbons(ribbons);
+ }
+
/** Re-upload palette data to the GPU texture (e.g. when players appear after initial startup). */
updatePalette(paletteData: Float32Array): void {
const gl = this.gl;
@@ -1271,6 +1282,9 @@ export class GPURenderer {
this.moveIndicatorPass.draw(cam, zoom);
this.nukeTelegraphPass.draw(cam);
if (pe.trail) this.trailPass.draw(cam);
+ // Spiral vortexes sit above the plain trails, below the missiles that
+ // trail them. Skipped in alt view — the strategic overlay stays effects-free.
+ if (!this.altView) this.spiralRibbonPass.draw(cam);
if (pe.unit) this.unitPass.drawMissiles(cam);
if (pe.fx) {
@@ -1301,6 +1315,7 @@ export class GPURenderer {
this.terrainPass.dispose();
this.territoryPass.dispose();
this.trailPass.dispose();
+ this.spiralRibbonPass.dispose();
this.borderStampPass.dispose();
this.borderPass.dispose();
this.defenseCoveragePass.dispose();
diff --git a/src/client/render/gl/passes/SpiralRibbonPass.ts b/src/client/render/gl/passes/SpiralRibbonPass.ts
new file mode 100644
index 000000000..58f3deaf7
--- /dev/null
+++ b/src/client/render/gl/passes/SpiralRibbonPass.ts
@@ -0,0 +1,378 @@
+/**
+ * SpiralRibbonPass — draws spiral nukeTrail vortexes as helix ribbon
+ * geometry, above the plain trails and below the missiles.
+ *
+ * Each live spiral nuke (from SpiralTrails) owns a triangle-strip VBO of its
+ * centerline samples, expanded to two edge vertices per sample (aSide ±1);
+ * the vertex shader swings each sample by the helix offset and handles the
+ * head-cone convergence from uHeadDist, so vertex data is append-only —
+ * per frame we only bufferSubData the samples added since the last upload.
+ * One draw per strand (≤ MAX_TRAIL_STRANDS) reuses the same strip with a
+ * different uPhase0.
+ *
+ * The glow look is a two-pass split: the soft halo renders into a
+ * reduced-resolution offscreen buffer (spiralResolutionScale — cuts its
+ * fragment cost by the scale factor squared, and the bilinear upsample keeps
+ * it soft) composited ADDITIVELY over the scene like emitted light; the
+ * sharp core ribbons then draw on top at full resolution, so the strands
+ * stay crisp instead of inheriting the upsample blur. Everything is skipped
+ * CPU-side while no spiral nuke is in flight.
+ */
+
+import type { SpiralRibbon } from "../../frame/SpiralTrails";
+import { SAMPLE_FLOATS } from "../../frame/SpiralTrails";
+import type { RenderSettings } from "../RenderSettings";
+import { createProgram } from "../utils/GlUtils";
+
+import spiralCompositeFragSrc from "../shaders/map-overlay/spiral-composite.frag.glsl?raw";
+import spiralRibbonFragSrc from "../shaders/map-overlay/spiral-ribbon.frag.glsl?raw";
+import spiralRibbonVertSrc from "../shaders/map-overlay/spiral-ribbon.vert.glsl?raw";
+import fullscreenVertSrc from "../shaders/shared/fullscreen.vert.glsl?raw";
+
+// Strip vertex: cx, cy, px, py, d, side.
+const VERT_FLOATS = 6;
+// Strip half-widths in tiles per pass — each must cover its profile in the
+// fragment shader (core: RIB_OUT 1.0; halo: GLOW_OUT 3.0) with slack so the
+// falloff reaches zero inside the strip.
+const CORE_HALF_WIDTH = 1.2;
+const GLOW_HALF_WIDTH = 3.2;
+const TAU = 2 * Math.PI;
+
+interface RibbonBuffers {
+ vao: WebGLVertexArrayObject;
+ vbo: WebGLBuffer;
+ capacityVerts: number;
+ uploadedSamples: number;
+}
+
+export class SpiralRibbonPass {
+ private gl: WebGL2RenderingContext;
+ private settings: RenderSettings;
+
+ private program: WebGLProgram;
+ private uCamera: WebGLUniformLocation;
+ private uHeadDist: WebGLUniformLocation;
+ private uConeLen: WebGLUniformLocation;
+ private uRadius: WebGLUniformLocation;
+ private uTwist: WebGLUniformLocation;
+ private uPhase0: WebGLUniformLocation;
+ private uHalfWidth: WebGLUniformLocation;
+ private uTime: WebGLUniformLocation;
+ private uRotSpeed: WebGLUniformLocation;
+ private uTrailAlpha: WebGLUniformLocation;
+ private uColorCount: WebGLUniformLocation;
+ private uColors: WebGLUniformLocation;
+ private uCorePass: WebGLUniformLocation;
+
+ private compositeProgram: WebGLProgram;
+ private fsQuadVao: WebGLVertexArrayObject;
+ private fbo: WebGLFramebuffer | null = null;
+ private fboTex: WebGLTexture | null = null;
+ private fboW = 0;
+ private fboH = 0;
+
+ private ribbons: readonly SpiralRibbon[] = [];
+ private readonly buffers = new Map();
+ // Scratch for expanding samples to strip vertices; grown on demand.
+ private vertScratch = new Float32Array(512 * 2 * VERT_FLOATS);
+ // Flat scratch for the uColors uniform (8 × vec3).
+ private readonly colorScratch = new Float32Array(8 * 3);
+ // Anchor animation time at construction (like TrailPass) so the value
+ // stays small and sin()/fract() don't quantize over long sessions.
+ private readonly startTime = performance.now();
+
+ constructor(gl: WebGL2RenderingContext, settings: RenderSettings) {
+ this.gl = gl;
+ this.settings = settings;
+
+ this.program = createProgram(gl, spiralRibbonVertSrc, spiralRibbonFragSrc);
+ const u = (name: string) => gl.getUniformLocation(this.program, name)!;
+ this.uCamera = u("uCamera");
+ this.uHeadDist = u("uHeadDist");
+ this.uConeLen = u("uConeLen");
+ this.uRadius = u("uRadius");
+ this.uTwist = u("uTwist");
+ this.uPhase0 = u("uPhase0");
+ this.uHalfWidth = u("uHalfWidth");
+ this.uTime = u("uTime");
+ this.uRotSpeed = u("uRotSpeed");
+ this.uTrailAlpha = u("uTrailAlpha");
+ this.uColorCount = u("uColorCount");
+ this.uColors = u("uColors");
+ this.uCorePass = u("uCorePass");
+
+ // Composite: fullscreen quad sampling the ribbon buffer (unit 0).
+ this.compositeProgram = createProgram(
+ gl,
+ fullscreenVertSrc,
+ spiralCompositeFragSrc,
+ );
+ gl.useProgram(this.compositeProgram);
+ gl.uniform1i(gl.getUniformLocation(this.compositeProgram, "uTex"), 0);
+ this.fsQuadVao = gl.createVertexArray()!;
+ gl.bindVertexArray(this.fsQuadVao);
+ const fsBuf = gl.createBuffer()!;
+ gl.bindBuffer(gl.ARRAY_BUFFER, fsBuf);
+ gl.bufferData(
+ gl.ARRAY_BUFFER,
+ new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]),
+ gl.STATIC_DRAW,
+ );
+ gl.enableVertexAttribArray(0);
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
+ gl.bindVertexArray(null);
+ }
+
+ /**
+ * Adopt this frame's ribbons (live refs from SpiralTrails) and stream any
+ * newly appended samples into each ribbon's VBO.
+ */
+ updateRibbons(ribbons: readonly SpiralRibbon[]): void {
+ this.ribbons = ribbons;
+ const live = new Set();
+ for (const r of ribbons) {
+ live.add(r.id);
+ this.uploadRibbon(r);
+ }
+ for (const [id, buf] of this.buffers) {
+ if (live.has(id)) continue;
+ this.gl.deleteVertexArray(buf.vao);
+ this.gl.deleteBuffer(buf.vbo);
+ this.buffers.delete(id);
+ }
+ }
+
+ private uploadRibbon(r: SpiralRibbon): void {
+ const gl = this.gl;
+ let buf = this.buffers.get(r.id);
+ if (!buf) {
+ const vbo = gl.createBuffer()!;
+ const vao = gl.createVertexArray()!;
+ gl.bindVertexArray(vao);
+ gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
+ const stride = VERT_FLOATS * 4;
+ gl.enableVertexAttribArray(0);
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, stride, 0); // aCenter
+ gl.enableVertexAttribArray(1);
+ gl.vertexAttribPointer(1, 2, gl.FLOAT, false, stride, 8); // aPerp
+ gl.enableVertexAttribArray(2);
+ gl.vertexAttribPointer(2, 1, gl.FLOAT, false, stride, 16); // aDist
+ gl.enableVertexAttribArray(3);
+ gl.vertexAttribPointer(3, 1, gl.FLOAT, false, stride, 20); // aSide
+ gl.bindVertexArray(null);
+ buf = { vao, vbo, capacityVerts: 0, uploadedSamples: 0 };
+ this.buffers.set(r.id, buf);
+ }
+ if (r.sampleCount <= buf.uploadedSamples) return;
+
+ const neededVerts = r.sampleCount * 2;
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf.vbo);
+ if (neededVerts > buf.capacityVerts) {
+ // Grow by doubling and re-upload everything (paths only append, so
+ // this happens a handful of times per flight).
+ let cap = Math.max(buf.capacityVerts, 512);
+ while (cap < neededVerts) cap *= 2;
+ gl.bufferData(gl.ARRAY_BUFFER, cap * VERT_FLOATS * 4, gl.DYNAMIC_DRAW);
+ buf.capacityVerts = cap;
+ buf.uploadedSamples = 0;
+ }
+ const first = buf.uploadedSamples;
+ const count = r.sampleCount - first;
+ const data = this.expandSamples(r.samples, first, count);
+ gl.bufferSubData(gl.ARRAY_BUFFER, first * 2 * VERT_FLOATS * 4, data);
+ buf.uploadedSamples = r.sampleCount;
+ }
+
+ /** Expand samples [first, first+count) to 2 strip vertices each. */
+ private expandSamples(
+ samples: Float32Array,
+ first: number,
+ count: number,
+ ): Float32Array {
+ const floats = count * 2 * VERT_FLOATS;
+ if (this.vertScratch.length < floats) {
+ let len = this.vertScratch.length;
+ while (len < floats) len *= 2;
+ this.vertScratch = new Float32Array(len);
+ }
+ const out = this.vertScratch;
+ let w = 0;
+ for (let s = 0; s < count; s++) {
+ const off = (first + s) * SAMPLE_FLOATS;
+ for (let side = -1; side <= 1; side += 2) {
+ out[w++] = samples[off];
+ out[w++] = samples[off + 1];
+ out[w++] = samples[off + 2];
+ out[w++] = samples[off + 3];
+ out[w++] = samples[off + 4];
+ out[w++] = side;
+ }
+ }
+ return out.subarray(0, floats);
+ }
+
+ /**
+ * Draw the vortexes: the soft halo (reduced-resolution buffer, composited
+ * additively so it reads as emitted light), then the sharp full-resolution
+ * core ribbons on top. No-op while no spiral nuke is in flight.
+ */
+ draw(cameraMatrix: Float32Array): void {
+ let anyStrip = false;
+ for (const r of this.ribbons) {
+ if (r.sampleCount >= 2) {
+ anyStrip = true;
+ break;
+ }
+ }
+ if (!anyStrip) return;
+ this.renderBuffer(cameraMatrix);
+ this.composite();
+ this.drawCores(cameraMatrix);
+ }
+
+ /** (Re)create the render target at the current scaled canvas size. */
+ private ensureTarget(w: number, h: number): void {
+ if (this.fbo !== null && w === this.fboW && h === this.fboH) return;
+ const gl = this.gl;
+ if (this.fboTex === null) {
+ this.fboTex = gl.createTexture();
+ this.fbo = gl.createFramebuffer();
+ }
+ gl.bindTexture(gl.TEXTURE_2D, this.fboTex);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RGBA8,
+ w,
+ h,
+ 0,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ null,
+ );
+ // LINEAR — the bilinear upsample at composite is what softens the look.
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.fboTex,
+ 0,
+ );
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+ this.fboW = w;
+ this.fboH = h;
+ }
+
+ private renderBuffer(cameraMatrix: Float32Array): void {
+ const gl = this.gl;
+ const scale = this.settings.mapOverlay.spiralResolutionScale;
+ const w = Math.max(1, Math.round(gl.drawingBufferWidth * scale));
+ const h = Math.max(1, Math.round(gl.drawingBufferHeight * scale));
+ this.ensureTarget(w, h);
+
+ // The surrounding pipeline may be rendering into its own target
+ // (day-night scene FBO) — save and restore rather than assuming screen.
+ const prevFbo = gl.getParameter(
+ gl.FRAMEBUFFER_BINDING,
+ ) as WebGLFramebuffer | null;
+ const prevViewport = gl.getParameter(gl.VIEWPORT) as Int32Array;
+
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo);
+ gl.viewport(0, 0, w, h);
+ gl.clearColor(0, 0, 0, 0);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ // Halo crossings accumulate premultiplied-over within the buffer (bounded
+ // — the additive step to the scene happens once, at composite).
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
+ this.drawStrips(cameraMatrix, 0, GLOW_HALF_WIDTH);
+
+ gl.bindFramebuffer(gl.FRAMEBUFFER, prevFbo);
+ gl.viewport(
+ prevViewport[0],
+ prevViewport[1],
+ prevViewport[2],
+ prevViewport[3],
+ );
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // restore overlay default
+ }
+
+ /** Draw every ribbon's strands once with the given pass mode + strip width. */
+ private drawStrips(
+ cameraMatrix: Float32Array,
+ corePass: number,
+ halfWidth: number,
+ ): void {
+ const gl = this.gl;
+ gl.useProgram(this.program);
+ gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
+ gl.uniform1i(this.uCorePass, corePass);
+ gl.uniform1f(this.uHalfWidth, halfWidth);
+ gl.uniform1f(this.uTime, (performance.now() - this.startTime) / 1000);
+ gl.uniform1f(this.uTrailAlpha, this.settings.mapOverlay.trailAlpha);
+
+ for (const r of this.ribbons) {
+ if (r.sampleCount < 2) continue;
+ const buf = this.buffers.get(r.id);
+ if (!buf) continue;
+ gl.uniform1f(this.uHeadDist, r.headDist);
+ gl.uniform1f(this.uConeLen, TAU / r.twist);
+ gl.uniform1f(this.uRadius, r.radius);
+ gl.uniform1f(this.uTwist, r.twist);
+ gl.uniform1f(this.uRotSpeed, r.rotationSpeed);
+ const count = Math.min(r.colors.length, 8);
+ for (let c = 0; c < count; c++) {
+ this.colorScratch[c * 3] = r.colors[c][0];
+ this.colorScratch[c * 3 + 1] = r.colors[c][1];
+ this.colorScratch[c * 3 + 2] = r.colors[c][2];
+ }
+ gl.uniform1i(this.uColorCount, count);
+ gl.uniform3fv(this.uColors, this.colorScratch);
+ gl.bindVertexArray(buf.vao);
+ const verts = Math.min(r.sampleCount, buf.uploadedSamples) * 2;
+ for (let k = 0; k < r.strands; k++) {
+ gl.uniform1f(this.uPhase0, (k * TAU) / r.strands);
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, verts);
+ }
+ }
+ }
+
+ /**
+ * Additively composite the halo buffer over the scene — light adds, so the
+ * vortex brightens what's beneath instead of veiling it, and the bilinear
+ * upsample keeps it soft.
+ */
+ private composite(): void {
+ const gl = this.gl;
+ gl.useProgram(this.compositeProgram);
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, this.fboTex);
+ gl.blendFunc(gl.ONE, gl.ONE);
+ gl.bindVertexArray(this.fsQuadVao);
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // restore overlay default
+ }
+
+ /** The sharp cores, full resolution, straight-alpha over the halo. */
+ private drawCores(cameraMatrix: Float32Array): void {
+ this.drawStrips(cameraMatrix, 1, CORE_HALF_WIDTH);
+ }
+
+ dispose(): void {
+ const gl = this.gl;
+ gl.deleteProgram(this.program);
+ gl.deleteProgram(this.compositeProgram);
+ gl.deleteVertexArray(this.fsQuadVao);
+ for (const buf of this.buffers.values()) {
+ gl.deleteVertexArray(buf.vao);
+ gl.deleteBuffer(buf.vbo);
+ }
+ this.buffers.clear();
+ if (this.fbo) gl.deleteFramebuffer(this.fbo);
+ if (this.fboTex) gl.deleteTexture(this.fboTex);
+ }
+}
diff --git a/src/client/render/gl/render-settings.json b/src/client/render/gl/render-settings.json
index 9fcff9a71..4a88ab5b5 100644
--- a/src/client/render/gl/render-settings.json
+++ b/src/client/render/gl/render-settings.json
@@ -73,6 +73,7 @@
},
"mapOverlay": {
"trailAlpha": 0.588,
+ "spiralResolutionScale": 0.25,
"defenseCheckerDarken": 0.7,
"territoryDefenseDarken": 0.85,
"territorySaturation": 0.85,
diff --git a/src/client/render/gl/shaders/map-overlay/spiral-composite.frag.glsl b/src/client/render/gl/shaders/map-overlay/spiral-composite.frag.glsl
new file mode 100644
index 000000000..c7353f7c7
--- /dev/null
+++ b/src/client/render/gl/shaders/map-overlay/spiral-composite.frag.glsl
@@ -0,0 +1,16 @@
+#version 300 es
+precision highp float;
+
+// Composite the reduced-resolution spiral halo buffer over the scene. The
+// buffer holds premultiplied color; the composite blends ADDITIVELY
+// (ONE, ONE) so the halo reads as emitted light, and bilinear upsampling
+// keeps it soft. The sharp core ribbons draw above this at full resolution.
+
+uniform sampler2D uTex;
+
+in vec2 vUV;
+out vec4 fragColor;
+
+void main() {
+ fragColor = texture(uTex, vUV);
+}
diff --git a/src/client/render/gl/shaders/map-overlay/spiral-ribbon.frag.glsl b/src/client/render/gl/shaders/map-overlay/spiral-ribbon.frag.glsl
new file mode 100644
index 000000000..3a564cf14
--- /dev/null
+++ b/src/client/render/gl/shaders/map-overlay/spiral-ribbon.frag.glsl
@@ -0,0 +1,67 @@
+#version 300 es
+precision highp float;
+
+// Spiral ribbon shading — a 3D vortex projected onto the map. Spin the helix
+// angle with time and derive color (position around the circumference →
+// palette, cross-faded) plus a depth cue (cos: segments facing the viewer
+// bright, receding ones dark).
+//
+// Drawn twice per strand (see SpiralRibbonPass) for a glow look — a sharp
+// bright core over a soft halo:
+// uCorePass 1: the core ribbon, full resolution, straight alpha into the
+// scene; facing segments get a white-hot center (neon-tube look).
+// uCorePass 0: the halo only, into the reduced-resolution buffer
+// (premultiplied); it is bilinearly upsampled and composited
+// ADDITIVELY over the scene, so it reads as emitted light.
+
+uniform float uTime; // seconds
+uniform float uRotSpeed; // vortex spin, radians/sec
+uniform float uTrailAlpha;
+uniform int uColorCount; // 1..MAX_TRAIL_COLORS colors
+uniform vec3 uColors[8]; // palette, wrapped once around the circumference
+uniform int uCorePass; // 1 = full-res core, 0 = low-res glow halo
+
+in float vTheta;
+in float vLateral;
+out vec4 fragColor;
+
+const float TAU = 6.28318530718;
+
+// Core ribbon profile: opaque within RIB_IN of the strand centerline,
+// fading out by RIB_OUT (half-width in tiles). The smoothstep edge doubles
+// as anti-aliasing at full resolution.
+const float RIB_IN = 0.55;
+const float RIB_OUT = 1.0;
+// Halo: quadratic falloff to GLOW_OUT tiles with GLOW_STRENGTH peak alpha.
+// Composited additively, so crossings and the vortex interior brighten.
+const float GLOW_OUT = 3.0;
+const float GLOW_STRENGTH = 0.4;
+// Core brightness: alpha boost over the plain-trail alpha (a glow's core
+// reads as a light source, not a translucent breadcrumb).
+const float CORE_ALPHA_BOOST = 1.5;
+
+void main() {
+ float d = abs(vLateral);
+ float theta = vTheta - uTime * uRotSpeed;
+ float f = fract(theta / TAU) * float(uColorCount);
+ int i = int(f) % uColorCount;
+ int j = (i + 1) % uColorCount;
+ float depth = 0.5 + 0.5 * cos(theta);
+ vec3 base = mix(uColors[i], uColors[j], fract(f)) * mix(0.55, 1.1, depth);
+
+ if (uCorePass == 1) {
+ float core = 1.0 - smoothstep(RIB_IN, RIB_OUT, d);
+ if (core <= 0.003) discard;
+ // White-hot center, strongest on segments facing the viewer.
+ float hot = (1.0 - smoothstep(0.0, RIB_IN, d)) * mix(0.15, 0.55, depth);
+ vec3 color = mix(base, vec3(1.0), hot);
+ float a = min(uTrailAlpha * CORE_ALPHA_BOOST, 1.0) * core;
+ fragColor = vec4(color, a); // straight alpha — scene default blending
+ } else {
+ float glowFall = 1.0 - smoothstep(0.0, GLOW_OUT, d);
+ float glow = GLOW_STRENGTH * glowFall * glowFall;
+ if (glow <= 0.003) discard;
+ float a = uTrailAlpha * glow;
+ fragColor = vec4(base * a, a); // premultiplied — halo buffer
+ }
+}
diff --git a/src/client/render/gl/shaders/map-overlay/spiral-ribbon.vert.glsl b/src/client/render/gl/shaders/map-overlay/spiral-ribbon.vert.glsl
new file mode 100644
index 000000000..ce1cb593b
--- /dev/null
+++ b/src/client/render/gl/shaders/map-overlay/spiral-ribbon.vert.glsl
@@ -0,0 +1,52 @@
+#version 300 es
+precision highp float;
+
+// Spiral ribbon strip — one strand of a spiral nuke trail. Vertices are
+// centerline samples expanded to a fixed-width strip (aSide = ±1); this
+// shader swings each sample sideways along its perpendicular by the helix
+// offset, so the strip follows the strand. Amplitude ramps 0 → uRadius over
+// one pitch behind the head (the cone that converges into the missile) —
+// evaluated here from uHeadDist so appended vertices never need rewriting.
+
+layout(location = 0) in vec2 aCenter; // centerline sample (world tiles)
+layout(location = 1) in vec2 aPerp; // unit perpendicular of the path there
+layout(location = 2) in float aDist; // cumulative centerline distance
+layout(location = 3) in float aSide; // ±1 — which edge of the strip
+
+uniform mat3 uCamera;
+uniform float uHeadDist; // cumulative distance at the nuke's head
+uniform float uConeLen; // cone length = one helix pitch, tiles
+uniform float uRadius; // helix amplitude, tiles
+uniform float uTwist; // helix phase advance, radians per tile
+uniform float uPhase0; // this strand's phase offset around the axis
+uniform float uHalfWidth; // strip half-width, tiles (covers core + glow)
+
+out float vTheta; // helix angle at this point (un-spun)
+out float vLateral; // signed distance from the strand centerline, tiles
+
+const float HALF_PI = 1.57079632679;
+
+void main() {
+ float behind = clamp((uHeadDist - aDist) / uConeLen, 0.0, 1.0);
+ float amp = uRadius * sin(HALF_PI * behind);
+ float theta = aDist * uTwist + uPhase0;
+ float off = amp * sin(theta);
+
+ // Strand tangent = centerline direction + lateral swing rate, so the strip
+ // stays perpendicular to the strand even on steep swings and in the cone.
+ // d(amp)/d(dist) via behind' = -1/uConeLen inside the cone, 0 past it.
+ float ampDeriv = behind < 1.0
+ ? -uRadius * HALF_PI * cos(HALF_PI * behind) / uConeLen
+ : 0.0;
+ float offDeriv = ampDeriv * sin(theta) + amp * uTwist * cos(theta);
+ // aPerp = (-dirY, dirX), so the centerline direction is (aPerp.y, -aPerp.x).
+ vec2 dir = vec2(aPerp.y, -aPerp.x);
+ vec2 tangent = dir + aPerp * offDeriv; // never zero: |dir|=1, aPerp ⊥ dir
+ vec2 n = normalize(vec2(-tangent.y, tangent.x));
+
+ vec2 world = aCenter + aPerp * off + n * (aSide * uHalfWidth);
+ vec3 clip = uCamera * vec3(world, 1.0);
+ gl_Position = vec4(clip.xy, 0.0, 1.0);
+ vTheta = theta;
+ vLateral = aSide * uHalfWidth;
+}
diff --git a/src/client/render/gl/shaders/map-overlay/trail.frag.glsl b/src/client/render/gl/shaders/map-overlay/trail.frag.glsl
index 5ad5413f8..0e10966de 100644
--- a/src/client/render/gl/shaders/map-overlay/trail.frag.glsl
+++ b/src/client/render/gl/shaders/map-overlay/trail.frag.glsl
@@ -11,7 +11,7 @@ uniform sampler2D uEffect; // RGBA32F — trail effect, keyed by ownerID.
// block 1 = nukeTrail. Within a block (rowBase = block start):
// row r = color r's rgb; spare alphas hold scalars:
// row 0.a = color count (0 = no effect → territory color),
- // row 1.a = styleId (0 = gradient, 1 = transition),
+ // row 1.a = styleId (0 = gradient, 1 = transition, 2 = spiral),
// row 2.a = scalar0 (gradient colorSize / transition freq),
// row 3.a = scalar1 (gradient movementSpeed)
uniform vec2 uMapSize;
@@ -50,6 +50,11 @@ void main() {
} else if (count == 1) {
// Single color — flat trail.
color = texelFetch(uEffect, ivec2(o, rowBase), 0).rgb;
+ } else if (int(texelFetch(uEffect, ivec2(o, rowBase + 1), 0).a + 0.5) == 2) {
+ // spiral — the vortex itself renders as ribbon geometry above this
+ // pass (SpiralRibbonPass); the stamped centerline underneath draws
+ // flat in the first color, as the missile's spine.
+ color = texelFetch(uEffect, ivec2(o, rowBase), 0).rgb;
} else if (int(texelFetch(uEffect, ivec2(o, rowBase + 1), 0).a + 0.5) == 1) {
// transition — the whole trail is one color at a time, cross-fading
// through the list over time. frequency = color changes per second.
diff --git a/src/client/render/types/FrameData.ts b/src/client/render/types/FrameData.ts
index 06a9a3c45..275bc253e 100644
--- a/src/client/render/types/FrameData.ts
+++ b/src/client/render/types/FrameData.ts
@@ -1,3 +1,4 @@
+import type { SpiralRibbon } from "../frame/SpiralTrails";
import type { FrameEvents } from "./FrameEvents";
import type {
AttackRingInput,
@@ -52,6 +53,13 @@ export interface FrameData {
readonly trailDirtyRowMin: number;
readonly trailDirtyRowMax: number;
+ /**
+ * Live spiral nukeTrail ribbons (helix polylines from SpiralTrails) —
+ * empty while no spiral-cosmetic nuke is in flight. Live ref, mutated in
+ * place each tick like the state buffers above.
+ */
+ readonly spiralRibbons: readonly SpiralRibbon[];
+
// ── Derived (computed once by producer) ────────────────────────────────
readonly playerStatus: ReadonlyMap;
diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts
index 6a3b73bc7..71044ea82 100644
--- a/src/client/view/GameView.ts
+++ b/src/client/view/GameView.ts
@@ -33,6 +33,8 @@ import { extractNukeTelegraphs } from "../render/frame/derive/NukeTelegraphs";
import { computePlayerStatus } from "../render/frame/derive/PlayerStatus";
import { buildRelationMatrix } from "../render/frame/derive/RelationMatrix";
import { RailroadCache } from "../render/frame/RailroadCache";
+import type { SpiralParams } from "../render/frame/SpiralTrails";
+import { SpiralTrails } from "../render/frame/SpiralTrails";
import { TrailManager } from "../render/frame/TrailManager";
import type { FrameData, NameEntry } from "../render/types";
import { STRUCTURE_TYPES } from "../render/types";
@@ -92,6 +94,7 @@ export class GameView implements GameMap {
// ── FrameData accumulators (renderer-bound state) ─────────────────────
private trailManager!: TrailManager;
+ private spiralTrails!: SpiralTrails;
private railroadCache!: RailroadCache;
/** Long-lived NameEntry map for the renderer's NamePass. */
private _names = new Map();
@@ -171,6 +174,7 @@ export class GameView implements GameMap {
const mapW = this._map.width();
const mapH = this._map.height();
this.trailManager = new TrailManager(mapW, mapH);
+ this.spiralTrails = new SpiralTrails(mapW);
this.railroadCache = new RailroadCache(mapW, mapH);
// Long-lived FrameData. Most fields are mutable references to long-lived
@@ -183,6 +187,7 @@ export class GameView implements GameMap {
inSpawnPhase: true,
tileState: this._map.tileStateBuffer(),
trailState: this.trailManager.getTrailState(),
+ spiralRibbons: this.spiralTrails.getRibbons(),
railroadState: this.railroadCache.railroadState,
units: this._unitStates,
players: this._playerStates,
@@ -527,6 +532,12 @@ export class GameView implements GameMap {
this._unitStates as Map,
this._trailIdsScratch,
);
+ // Spiral nukeTrail ribbons follow the same tracked units; extends the
+ // path of each live spiral-cosmetic nuke and drops dead ones.
+ this.spiralTrails.update(
+ this._unitStates as Map,
+ this._trailIdsScratch,
+ );
// Names map — rebuilt only when a placement record arrived or a player
// was added (nameData values cannot change between those ticks). Entry
@@ -674,6 +685,16 @@ export class GameView implements GameMap {
return this._frame;
}
+ /**
+ * Set a player's spiral nuke-trail geometry (from their nukeTrail
+ * cosmetic). Pushed by WebGLFrameBuilder once the player's effect
+ * resolves; their nukes then grow helix ribbons (SpiralTrails →
+ * SpiralRibbonPass) on top of the plain stamped trail.
+ */
+ setNukeTrailSpiral(smallID: number, params: SpiralParams): void {
+ this.spiralTrails.setParams(smallID, params);
+ }
+
private advanceMotionPlannedUnits(currentTick: Tick): void {
for (const [unitId, plan] of this.unitMotionPlans) {
const unit = this._units.get(unitId);
diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts
index 158d37023..77b9f3734 100644
--- a/src/core/CosmeticSchemas.ts
+++ b/src/core/CosmeticSchemas.ts
@@ -136,6 +136,17 @@ export type TrailEffectType = (typeof TRAIL_EFFECT_TYPES)[number];
// = how fast the bands scroll, in tiles/sec (0 = static).
// - "transition": the whole trail is one color at a time, cross-fading through
// the color list over time. `frequency` = color changes per second.
+// - "spiral": a 3D vortex of helix strands around the unit's path, projected
+// onto the map — strands emerge from the unit, flare to full width, and
+// spin with depth shading (facing segments bright, receding ones dark).
+// `radius` = helix amplitude in tiles; `strands` = number of strands (the
+// renderer clamps to 8); `rotationSpeed` = how fast the vortex spins, in
+// radians per second; the palette wraps once around the vortex
+// circumference. radius must be positive (the geometry degenerates
+// otherwise), so a non-positive value drops the entry like the enums. The
+// vortex geometry is only rendered for nuke trails (as ribbons above the
+// stamped trail); a spiral ship trail renders as a flat line in the first
+// color.
// solid = a single-color list; rainbow = the spectrum as a gradient. Colors are
// unvalidated strings here; the renderer drops any it can't parse (and an empty
// list falls back to the player's territory color).
@@ -151,6 +162,13 @@ export const TrailEffectAttributesSchema = z.discriminatedUnion("type", [
colors: z.array(z.string()),
frequency: z.number(),
}),
+ z.object({
+ type: z.literal("spiral"),
+ colors: z.array(z.string()),
+ radius: z.number().positive(),
+ strands: z.number().int().positive(),
+ rotationSpeed: z.number(),
+ }),
]);
// The bomb a nuke-explosion effect applies to. The store/selection UI groups
diff --git a/tests/CosmeticSchemas.test.ts b/tests/CosmeticSchemas.test.ts
index a539055e2..6f298ef18 100644
--- a/tests/CosmeticSchemas.test.ts
+++ b/tests/CosmeticSchemas.test.ts
@@ -94,6 +94,50 @@ describe("Effect cosmetic schemas", () => {
}).success,
).toBe(false);
});
+
+ it("parses a spiral with colors, radius, strands, and rotationSpeed", () => {
+ const parsed = TrailEffectAttributesSchema.parse({
+ type: "spiral",
+ colors: ["#ff0000", "#001eff", "#fcfcfc", "#00ffaa"],
+ radius: 15,
+ strands: 4,
+ rotationSpeed: 5,
+ });
+ expect(parsed).toEqual({
+ type: "spiral",
+ colors: ["#ff0000", "#001eff", "#fcfcfc", "#00ffaa"],
+ radius: 15,
+ strands: 4,
+ rotationSpeed: 5,
+ });
+ });
+
+ it("requires spiral radius/strands/rotationSpeed, radius > 0, integer strands", () => {
+ const valid = {
+ type: "spiral",
+ colors: ["#f00", "#00f"],
+ radius: 15,
+ strands: 4,
+ rotationSpeed: 5,
+ };
+ for (const key of ["radius", "strands", "rotationSpeed"] as const) {
+ const missing: Record = { ...valid };
+ delete missing[key];
+ expect(TrailEffectAttributesSchema.safeParse(missing).success).toBe(
+ false,
+ );
+ }
+ expect(
+ TrailEffectAttributesSchema.safeParse({ ...valid, radius: 0 }).success,
+ ).toBe(false);
+ expect(
+ TrailEffectAttributesSchema.safeParse({ ...valid, strands: 2.5 })
+ .success,
+ ).toBe(false);
+ expect(
+ TrailEffectAttributesSchema.safeParse({ ...valid, strands: 0 }).success,
+ ).toBe(false);
+ });
});
describe("EffectSchema", () => {
@@ -127,6 +171,26 @@ describe("Effect cosmetic schemas", () => {
).toBe(true);
});
+ it("parses a spiral nukeTrail effect (the catalog spiral_tail shape)", () => {
+ expect(
+ EffectSchema.safeParse({
+ name: "spiral_tail",
+ effectType: "nukeTrail",
+ attributes: {
+ type: "spiral",
+ colors: ["#ff0000", "#001eff", "#fcfcfc", "#00ffaa"],
+ radius: 15,
+ strands: 4,
+ rotationSpeed: 5,
+ },
+ affiliateCode: null,
+ product: null,
+ priceHard: 123,
+ rarity: "common",
+ }).success,
+ ).toBe(true);
+ });
+
it("rejects an effect with no attributes", () => {
expect(EffectSchema.safeParse({ ...base }).success).toBe(false);
});
diff --git a/tests/SpiralTrails.test.ts b/tests/SpiralTrails.test.ts
new file mode 100644
index 000000000..abdd5f1b4
--- /dev/null
+++ b/tests/SpiralTrails.test.ts
@@ -0,0 +1,219 @@
+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,
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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);
+ });
+});
diff --git a/tests/TrailManager.test.ts b/tests/TrailManager.test.ts
new file mode 100644
index 000000000..84af1430a
--- /dev/null
+++ b/tests/TrailManager.test.ts
@@ -0,0 +1,119 @@
+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();
+ 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();
+ 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();
+ // 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);
+ });
+});