From 828d7b1d965a9e0d188daf44977c910213421d52 Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 17 Jul 2026 17:51:31 -0700 Subject: [PATCH] feat(client): spiral nuke trail cosmetic effect (#4630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- src/client/WebGLFrameBuilder.ts | 49 ++- src/client/components/EffectPreview.ts | 166 +++++++- src/client/render/frame/SpiralTrails.ts | 242 +++++++++++ src/client/render/frame/Upload.ts | 5 + src/client/render/gl/MapRenderer.ts | 4 + src/client/render/gl/RenderSettings.ts | 5 + src/client/render/gl/Renderer.ts | 15 + .../render/gl/passes/SpiralRibbonPass.ts | 378 ++++++++++++++++++ src/client/render/gl/render-settings.json | 1 + .../map-overlay/spiral-composite.frag.glsl | 16 + .../map-overlay/spiral-ribbon.frag.glsl | 67 ++++ .../map-overlay/spiral-ribbon.vert.glsl | 52 +++ .../gl/shaders/map-overlay/trail.frag.glsl | 7 +- src/client/render/types/FrameData.ts | 8 + src/client/view/GameView.ts | 21 + src/core/CosmeticSchemas.ts | 18 + tests/CosmeticSchemas.test.ts | 64 +++ tests/SpiralTrails.test.ts | 219 ++++++++++ tests/TrailManager.test.ts | 119 ++++++ 19 files changed, 1426 insertions(+), 30 deletions(-) create mode 100644 src/client/render/frame/SpiralTrails.ts create mode 100644 src/client/render/gl/passes/SpiralRibbonPass.ts create mode 100644 src/client/render/gl/shaders/map-overlay/spiral-composite.frag.glsl create mode 100644 src/client/render/gl/shaders/map-overlay/spiral-ribbon.frag.glsl create mode 100644 src/client/render/gl/shaders/map-overlay/spiral-ribbon.vert.glsl create mode 100644 tests/SpiralTrails.test.ts create mode 100644 tests/TrailManager.test.ts 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`
+ + ${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` + + + + `; + })} + +
`; + } 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); + }); +});