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>
This commit is contained in:
Evan
2026-07-17 17:51:31 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 774d98ddad
commit 828d7b1d96
19 changed files with 1426 additions and 30 deletions
+42 -7
View File
@@ -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;
+144 -22
View File
@@ -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`<div
class="w-full h-full rounded-md overflow-hidden"
style="background:${SPIRAL_BG};"
>
<svg
class="w-full h-full"
viewBox="0 0 ${SPIRAL_VIEW_W} ${SPIRAL_VIEW_H}"
preserveAspectRatio="none"
>
${Array.from({ length: strands }, (_, s) => {
const d = spiralStrandPath((s * 2 * Math.PI) / strands);
const color = colors[s % colors.length];
// Glow halo (screen ≈ additive light) under a crisp core under a
// white-hot center — the in-game bloom split.
return svg`<g data-strand>
<path
d="${d}"
fill="none"
stroke="${color}"
stroke-width="10"
stroke-linecap="round"
opacity="0.55"
style="filter:blur(3px);mix-blend-mode:screen"
/>
<path
d="${d}"
fill="none"
stroke="${color}"
stroke-width="3.5"
stroke-linecap="round"
/>
<path
data-hot
d="${d}"
fill="none"
stroke="#fff"
stroke-width="1.4"
stroke-linecap="round"
opacity="0.9"
style="filter:blur(0.3px)"
/>
</g>`;
})}
</svg>
</div>`;
}
let background: string;
if (colors.length === 0) {
background = EMPTY_BG;
@@ -52,33 +136,71 @@ export class TrailSwatch extends LitElement {
updated(changed: Map<string, unknown>): 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<HTMLElement>("div");
if (!fill) return;
const fill = this.querySelector<HTMLElement>("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<SVGGElement>("[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<SVGPathElement>("[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 = [];
}
}
+242
View File
@@ -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<readonly [number, number, number]>; // 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<readonly [number, number, number]>;
/** 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<number, SpiralParams>();
private readonly ribbonsById = new Map<number, RibbonState>();
// 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<number, UnitState>, 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++;
}
}
+5
View File
@@ -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<number, UnitState>, 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) {
+4
View File
@@ -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);
}
+5
View File
@@ -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. */
+15
View File
@@ -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();
@@ -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<number, RibbonBuffers>();
// 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<number>();
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);
}
}
@@ -73,6 +73,7 @@
},
"mapOverlay": {
"trailAlpha": 0.588,
"spiralResolutionScale": 0.25,
"defenseCheckerDarken": 0.7,
"territoryDefenseDarken": 0.85,
"territorySaturation": 0.85,
@@ -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);
}
@@ -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
}
}
@@ -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;
}
@@ -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.
+8
View File
@@ -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<number, PlayerStatusData>;
+21
View File
@@ -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<string, NameEntry>();
@@ -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<number, import("../render/types").UnitState>,
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<number, import("../render/types").UnitState>,
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);
+18
View File
@@ -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
+64
View File
@@ -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<string, unknown> = { ...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);
});
+219
View File
@@ -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<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);
});
});
+119
View File
@@ -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<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);
});
});