mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 01:42:27 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user