From b6317964a72d98a3bb859a97e397619af75b63a8 Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 2 Jul 2026 15:21:26 -0700 Subject: [PATCH] feat: sparkles nuke-explosion visual type (#4490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Follow-up to #4485: adds a second nuke-explosion visual, `"sparkles"` — a firework burst of twinkling glints that start at the detonation point and ride outward with the expanding front, reaching the cosmetic's full `size` at fade-out. **Schema (`CosmeticSchemas.ts`)** - `NukeExplosionAttributesSchema` is now a discriminated union on `type` (`"shockwave" | "sparkles"`), matching `TrailEffectAttributesSchema`. Old clients drop sparkles entries via `lenientRecord` and render the default ring. - The sparkles member adds `density` (required, positive) — roughly the total number of glints in the burst. - Literal attribute semantics, consistent with shockwave: - `size` — final burst width (diameter) in world tiles at fade-out - `speed` — tiles/s the width grows; duration = size / speed, clamped 0.1–15 s - `thickness` — **average** sparkle size in tiles; each glint hash-varies ±50% around it - `density` — approximate glint count; renderer clamps to 2–5000 - `colors` + `transitionSpeed` — shared palette-cycle semantics, with a hashed per-glint palette offset on top **Rendering** - `NukeExplosionRenderParams` now carries the visual type through to the pass as a matching TS union (previously any cosmetic was hardwired to the EMP style — this closes that gap for future visuals). - Sparkles are style 2 in the same `FxShockwavePass` instance stream: one new float (grid cell pitch, derived CPU-side from density), no other layout changes. - Fragment shader: one hashed glint per rotated front-normalized grid cell (jittered, cell-confined so each fragment samples only its own cell, ~1/3 dropout for organic scatter), hashed birth stagger. Glints are **fully opaque** — twinkle modulates color brightness, not alpha — holding full opacity through life and fading only over the last quarter. - SAM interceptions, the classic ring, and EMP shockwaves are unchanged. **Store / selection** - New `` preview (burst scales from center, density-scaled dot count, size-varied dots, palette cycling), branched in `CosmeticButton` by `attributes.type`. **Verification** - Schema tests incl. the real `rgb_nuke_sparkles` catalog entry, missing/non-positive `density` rejection. - Verified in-game via headless Chromium: size-250 RGB burst renders opaque red/white/blue glints expanding from the detonation point; sparse (40) vs dense (400) density comparison; no page errors. ## Please complete the following: - [ ] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 --- src/client/WebGLFrameBuilder.ts | 5 +- src/client/components/CosmeticButton.ts | 14 +- src/client/components/EffectPreview.ts | 139 ++++++++++++++++++ .../gl/passes/fx-pass/FxShockwavePass.ts | 36 +++-- .../render/gl/shaders/fx/shockwave.frag.glsl | 96 ++++++++++-- .../render/gl/shaders/fx/shockwave.vert.glsl | 5 +- src/client/render/types/Renderer.ts | 29 ++-- src/core/CosmeticSchemas.ts | 47 ++++-- tests/CosmeticSchemas.test.ts | 59 ++++++++ 9 files changed, 380 insertions(+), 50 deletions(-) diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts index 15420a3a8..1bd4bbb36 100644 --- a/src/client/WebGLFrameBuilder.ts +++ b/src/client/WebGLFrameBuilder.ts @@ -81,13 +81,16 @@ function attributesToExplosionParams( .map(toRgb01) .filter((c): c is [number, number, number] => c !== null) .slice(0, MAX_NUKE_EXPLOSION_COLORS); - return { + const base = { colors: colors.length > 0 ? colors : [DEFAULT_NUKE_EXPLOSION_COLOR], maxRadius: attrs.size / 2, speed: attrs.speed, thickness: attrs.thickness, transitionSpeed: attrs.transitionSpeed, }; + return attrs.type === "sparkles" + ? { ...base, type: "sparkles", density: attrs.density } + : { ...base, type: "shockwave" }; } /** diff --git a/src/client/components/CosmeticButton.ts b/src/client/components/CosmeticButton.ts index afb4c84f9..41cfd1451 100644 --- a/src/client/components/CosmeticButton.ts +++ b/src/client/components/CosmeticButton.ts @@ -20,7 +20,7 @@ import { translateText } from "../Utils"; import "./CapIcon"; import "./CosmeticContainer"; import "./CosmeticInfo"; -import "./EffectPreview"; // registers + +import "./EffectPreview"; // registers , , import { renderPatternPreview } from "./PatternPreview"; import "./PlutoniumIcon"; @@ -187,10 +187,16 @@ export class CosmeticButton extends LitElement { ${translateText("territory_patterns.pattern.default")} `; } - // Nuke explosions preview as an expanding ring; every trail effectType - // (transportShipTrail, nukeTrail) shares the same attributes shape and - // previews as a color swatch. + // Nuke explosions preview per visual type (expanding ring or sparkle + // burst); every trail effectType (transportShipTrail, nukeTrail) shares + // the same attributes shape and previews as a color swatch. if (isNukeExplosionEffect(c)) { + if (c.attributes.type === "sparkles") { + return html``; + } return html` + ${Array.from({ length: this.dotCount() }, (_, i) => { + const ang = dotRand(i) * 2 * Math.PI; + const dist = Math.sqrt(dotRand(i + 101)) * 42; // % of box + const left = 50 + Math.cos(ang) * dist; + const top = 50 + Math.sin(ang) * dist; + return html`
`; + })} + `; + } + + updated(changed: Map): void { + if (!changed.has("explosion")) return; + for (const a of this.animations) a.cancel(); + this.animations = []; + + const attrs = this.explosion; + const box = this.querySelector("[data-box]"); + if (!attrs || !box) return; + const dots = this.querySelectorAll("[data-dot]"); + if (dots.length === 0) return; + const colors = + attrs.colors.length > 0 ? attrs.colors : [DEFAULT_RING_COLOR]; + + // Average dot size ∝ thickness/size, measured against the tile, like the + // ring's border thickness; each dot varies ±50% around it, like in game. + const d = box.clientWidth || 100; + const ratio = attrs.size > 0 ? attrs.thickness / attrs.size : 0.05; + const px = Math.min(Math.max(ratio * d, 3), d / 4); + + // One loop = the in-game pace (size / speed seconds), clamped watchable. + const durS = Math.min( + Math.max(attrs.size / Math.max(attrs.speed, 0.001), 0.6), + 3, + ); + + // The whole burst expands from the center — dots keep their layout + // positions and the container scales up, so each dot rides outward + // radially (matching the shader's front-normalized anchoring) — and + // everything fades together at the end of the loop. + this.animations.push( + box.animate( + [ + { transform: "scale(0.05)", opacity: 1, offset: 0 }, + { transform: "scale(1)", opacity: 1, offset: 0.75 }, + { transform: "scale(1)", opacity: 0, offset: 1 }, + ], + { duration: durS * 1000, iterations: Infinity, easing: "linear" }, + ), + ); + + dots.forEach((dot, i) => { + const dotPx = px * (0.5 + dotRand(i + 307)); + dot.style.width = `${dotPx}px`; + dot.style.height = `${dotPx}px`; + dot.style.backgroundColor = colors[i % colors.length]; + + // Continuous twinkle on a hashed phase, independent of the loop. Kept + // shallow — in game the glints stay opaque and twinkle in brightness. + this.animations.push( + dot.animate([{ opacity: 1 }, { opacity: 0.65 }, { opacity: 1 }], { + duration: (0.5 + dotRand(i + 211) * 0.6) * 1000, + iterations: Infinity, + easing: "ease-in-out", + }), + ); + + // Palette cycle at transitionSpeed steps/s, rotated by the dot's own + // palette index (mirroring the shader's per-glint offset). + if (colors.length >= 2 && attrs.transitionSpeed !== 0) { + const list = attrs.transitionSpeed > 0 ? colors : [...colors].reverse(); + const start = i % list.length; + const rotated = [...list.slice(start), ...list.slice(0, start)]; + this.animations.push( + dot.animate( + [...rotated, rotated[0]].map((c) => ({ backgroundColor: c })), + { + duration: + (colors.length / Math.abs(attrs.transitionSpeed)) * 1000, + iterations: Infinity, + easing: "linear", + }, + ), + ); + } + }); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + for (const a of this.animations) a.cancel(); + this.animations = []; + } +} diff --git a/src/client/render/gl/passes/fx-pass/FxShockwavePass.ts b/src/client/render/gl/passes/fx-pass/FxShockwavePass.ts index a2a826b57..89a40ecbd 100644 --- a/src/client/render/gl/passes/fx-pass/FxShockwavePass.ts +++ b/src/client/render/gl/passes/fx-pass/FxShockwavePass.ts @@ -32,21 +32,22 @@ interface ActiveShockwave { startMs: number; durationMs: number; maxRadius: number; - style: number; // 0 = classic ring (SAM + no-cosmetic nuke), 1 = EMP + style: number; // 0 = classic ring (SAM + no-cosmetic nuke), 1 = EMP, 2 = sparkles colors: readonly RGB[]; // 1..MAX_NUKE_EXPLOSION_COLORS palette, never empty speed: number; // crackle-animation multiplier (effect pace vs the default) transitionSpeed: number; // palette step rate (colors/s); 0 = static, <0 = reverse - thickness: number; // EMP ring band thickness (world tiles); unused by classic + thickness: number; // ring band / avg sparkle size (world tiles); unused by classic + cell: number; // sparkles grid pitch (front-normalized); 0 for other styles } // --------------------------------------------------------------------------- -// Instance data layout (21 floats): +// Instance data layout (22 floats): // x, y, radius, alpha, style, color0..color3 (rgb each), colorCount, speed, -// transitionSpeed, thickness. Unused color slots repeat the last palette -// color so the shader can take a max over all four. +// transitionSpeed, thickness, cell. Unused color slots repeat the last +// palette color so the shader can take a max over all four. // --------------------------------------------------------------------------- -const SHOCKWAVE_FLOATS = 21; +const SHOCKWAVE_FLOATS = 22; const SHOCKWAVE_STRIDE = SHOCKWAVE_FLOATS * 4; // bytes // --------------------------------------------------------------------------- @@ -103,7 +104,7 @@ export class FxShockwavePass { gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 4, gl.FLOAT, false, SHOCKWAVE_STRIDE, 0); gl.vertexAttribDivisor(1, 1); - // location 2: style (0 classic, 1 EMP) + // location 2: style (0 classic, 1 EMP, 2 sparkles) gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 1, gl.FLOAT, false, SHOCKWAVE_STRIDE, 16); gl.vertexAttribDivisor(2, 1); @@ -136,6 +137,10 @@ export class FxShockwavePass { gl.enableVertexAttribArray(10); gl.vertexAttribPointer(10, 1, gl.FLOAT, false, SHOCKWAVE_STRIDE, 80); gl.vertexAttribDivisor(10, 1); + // location 11: cell (sparkles grid pitch) + gl.enableVertexAttribArray(11); + gl.vertexAttribPointer(11, 1, gl.FLOAT, false, SHOCKWAVE_STRIDE, 84); + gl.vertexAttribDivisor(11, 1); gl.bindVertexArray(null); } @@ -167,6 +172,15 @@ export class FxShockwavePass { // The shader's crackle animation runs on a multiplier of real time; pace // it to how fast this effect plays relative to the default duration. const speed = fx.nukeShockwaveDurationMs / durationMs; + // Sparkles: density ≈ total glints in the burst. The unit disc holds + // π/cell² grid cells and ~2/3 survive dropout, so cell = √((2π/3)/d). + // Clamped so a bad catalog value can't degenerate into per-pixel noise + // or an empty burst. + let cell = 0; + if (params?.type === "sparkles") { + const density = Math.min(Math.max(params.density, 2), 5000); + cell = Math.sqrt((2 * Math.PI) / 3 / density); + } this.active.push({ x, y, @@ -175,12 +189,14 @@ export class FxShockwavePass { // Cosmetic maxRadius is absolute (world tiles); the default look scales // with the bomb's blast radius. maxRadius: params?.maxRadius ?? nukeRadius * fx.nukeShockwaveRadiusFactor, - // Cosmetic → EMP; no cosmetic → classic ring (the original nuke look). - style: params ? 1 : 0, + // Cosmetic type → its style; no cosmetic → classic ring (the original + // nuke look). + style: params ? (params.type === "sparkles" ? 2 : 1) : 0, colors: params?.colors ?? [DEFAULT_NUKE_EXPLOSION_COLOR], speed, transitionSpeed: params?.transitionSpeed ?? 0, thickness: params?.thickness ?? 0, + cell, }); } @@ -197,6 +213,7 @@ export class FxShockwavePass { speed: 1, transitionSpeed: 0, thickness: 0, // classic style uses uRingWidth + cell: 0, }); } @@ -244,6 +261,7 @@ export class FxShockwavePass { data[off + 18] = sw.speed; data[off + 19] = sw.transitionSpeed; data[off + 20] = sw.thickness; + data[off + 21] = sw.cell; } this.shockwaveCount = count; diff --git a/src/client/render/gl/shaders/fx/shockwave.frag.glsl b/src/client/render/gl/shaders/fx/shockwave.frag.glsl index af3b5773f..a58e9da57 100644 --- a/src/client/render/gl/shaders/fx/shockwave.frag.glsl +++ b/src/client/render/gl/shaders/fx/shockwave.frag.glsl @@ -6,16 +6,17 @@ uniform float uTime; // seconds — animates procedural styles in vec2 vLocalPos; flat in float vAlpha; // 1 - lifetime progress (fades out over the effect) -flat in float vStyle; // 1.0 = EMP energy pulse, 0.0 = classic ring -flat in vec3 vColor0; // EMP: palette color 0 -flat in vec3 vColor1; // EMP: palette color 1 -flat in vec3 vColor2; // EMP: palette color 2 (pads repeat the last color) -flat in vec3 vColor3; // EMP: palette color 3 (pads repeat the last color) +flat in float vStyle; // 0 = classic ring, 1 = EMP pulse, 2 = sparkles +flat in vec3 vColor0; // cosmetic: palette color 0 +flat in vec3 vColor1; // cosmetic: palette color 1 +flat in vec3 vColor2; // cosmetic: palette color 2 (pads repeat the last color) +flat in vec3 vColor3; // cosmetic: palette color 3 (pads repeat the last color) flat in float vColorCount; // active palette size (1..4) -flat in float vSpeed; // EMP: animation-speed multiplier -flat in float vTransSpeed; // EMP: palette step rate (colors/s) -flat in float vThickness; // EMP: ring band thickness (world tiles) -flat in float vRadius; // current ring radius (world tiles) +flat in float vSpeed; // cosmetic: animation-speed multiplier +flat in float vTransSpeed; // cosmetic: palette step rate (colors/s) +flat in float vThickness; // ring band thickness / avg sparkle size (world tiles) +flat in float vCell; // sparkles: grid pitch (front-normalized units) +flat in float vRadius; // current front radius (world tiles) // Palette lookup by (already-wrapped) index. vec3 colorAt(float i) { @@ -97,9 +98,84 @@ void empPulse(float dist) { fragColor = vec4(col, clamp(glow, 0.0, 1.0) * life); } +// Sparkles — a firework burst: glints start at the center and ride outward +// with the expanding front (fixed positions in front-normalized space, so +// world position = normalized position · radius), reaching the cosmetic's +// full size at fade-out. One candidate glint per normalized grid cell +// (jittered but confined to its cell so each fragment samples only its own +// cell; the pitch vCell comes from the cosmetic's density). Glints keep a +// constant world size (hash-varied ±50% around vThickness, so thickness is +// the average sparkle size) once the burst outgrows the grid; while it's +// young they're capped to their cell, so the early burst reads as a dense +// compact cluster. Each glint twinkles on a hashed phase and takes a hashed +// palette color; the palette cycle advances at transitionSpeed steps/s on +// top of that offset. +void sparkles() { + float cell = max(vCell, 0.001); // grid pitch (front-normalized units) + // Rotate the grid off the world axes so the young, dense burst doesn't + // read as a lattice (the disc cull below is rotation-invariant). + const mat2 ROT = mat2(0.8253, -0.5646, 0.5646, 0.8253); + vec2 gp = ROT * vLocalPos; + vec2 cid = floor(gp / cell); + float h1 = hash11(dot(cid, vec2(157.0, 113.0)) + 41.7); + float h2 = hash11(h1 * 251.0 + 7.3); + float h3 = hash11(h2 * 199.0 + 3.1); + float h4 = hash11(h3 * 173.0 + 11.3); + float h5 = hash11(h4 * 149.0 + 5.7); + + // Drop ~1/3 of cells — an organic scatter, not one glint per cell. + if (h3 < 0.33) discard; + + // Glint radius: constant world size, hash-varied ±50% per glint so + // vThickness is the AVERAGE sparkle size, and capped to its cell. The + // jitter amplitude is fixed (cap + jitter = half a cell exactly) so glint + // positions don't drift as the cap relaxes with the growing radius. + float rs = min( + 0.5 * vThickness * (0.5 + h5) / max(vRadius, 0.001), + 0.35 * cell); + vec2 center = (cid + 0.5) * cell + (vec2(h1, h2) - 0.5) * 0.3 * cell; + + // Glints outside the unit disc are culled so the burst stays circular. + if (length(center) > 1.0) discard; + + // Hashed birth stagger so glints pop in over the first fifth of the life + // instead of the whole disc appearing at once. + float lifeT = 1.0 - vAlpha; + float birth = smoothstep(h4 * 0.2, h4 * 0.2 + 0.08, lifeT); + if (birth <= 0.0) discard; + + // Solid glint core with a thin anti-aliased rim — glints render fully + // opaque (the twinkle modulates color brightness, not alpha), holding full + // opacity through life and fading only over the last quarter. + float g = clamp((1.0 - length(gp - center) / max(rs, 1e-4)) * 3.0, 0.0, 1.0); + float tt = uTime * vSpeed; + float tw = 0.35 + + 0.65 * pow(0.5 + 0.5 * sin(6.2832 * (h3 + tt * (1.5 + h1))), 2.0); + float endFade = smoothstep(0.0, 0.25, vAlpha); + + float glow = g * birth * endFade; + if (glow < 0.01) discard; + + // Whole palette steps per glint (floor) keep static colors exact; the cycle + // blends between steps at transitionSpeed steps/s (0 = static hashed color, + // negative = reverse), like the EMP base color. + float idx = uTime * vTransSpeed + floor(h2 * vColorCount); + vec3 base = mix( + colorAt(mod(floor(idx), vColorCount)), + colorAt(mod(floor(idx) + 1.0, vColorCount)), + fract(idx)); + // Twinkle lives in the color: peaks flare toward white, troughs dim the + // base — alpha stays saturated so the glints read solid. + vec3 col = mix(base * (0.7 + 0.3 * tw), vec3(1.0), 0.5 * tw); + + fragColor = vec4(col, clamp(glow, 0.0, 1.0)); +} + void main() { float dist = length(vLocalPos); - if (vStyle > 0.5) { + if (vStyle > 1.5) { + sparkles(); + } else if (vStyle > 0.5) { empPulse(dist); } else { classicRing(dist); diff --git a/src/client/render/gl/shaders/fx/shockwave.vert.glsl b/src/client/render/gl/shaders/fx/shockwave.vert.glsl index b80978a2f..4adb76bb0 100644 --- a/src/client/render/gl/shaders/fx/shockwave.vert.glsl +++ b/src/client/render/gl/shaders/fx/shockwave.vert.glsl @@ -3,7 +3,7 @@ precision highp float; layout(location = 0) in vec2 aPos; layout(location = 1) in vec4 aInstData; // x, y, radius, alpha -layout(location = 2) in float aStyle; // 1.0 = EMP energy pulse, 0.0 = classic ring +layout(location = 2) in float aStyle; // 0 = classic ring, 1 = EMP pulse, 2 = sparkles layout(location = 3) in vec3 aColor0; // EMP: palette color 0 layout(location = 4) in vec3 aColor1; // EMP: palette color 1 layout(location = 5) in vec3 aColor2; // EMP: palette color 2 @@ -12,6 +12,7 @@ layout(location = 7) in float aColorCount; // active palette size (1..4) layout(location = 8) in float aSpeed; // animation-speed multiplier layout(location = 9) in float aTransSpeed; // palette step rate (colors/s) layout(location = 10) in float aThickness; // EMP: ring band thickness (world tiles) +layout(location = 11) in float aCell; // sparkles: grid pitch (front-normalized) uniform mat3 uCamera; @@ -26,6 +27,7 @@ flat out float vColorCount; flat out float vSpeed; flat out float vTransSpeed; flat out float vThickness; +flat out float vCell; flat out float vRadius; // current ring radius (world tiles) — converts // absolute thickness into local ring units @@ -45,6 +47,7 @@ void main() { vSpeed = aSpeed; vTransSpeed = aTransSpeed; vThickness = aThickness; + vCell = aCell; vRadius = r; // Quad extent: ring radius plus the full band thickness. The band is diff --git a/src/client/render/types/Renderer.ts b/src/client/render/types/Renderer.ts index 0dc95c7d2..c7277b71b 100644 --- a/src/client/render/types/Renderer.ts +++ b/src/client/render/types/Renderer.ts @@ -128,17 +128,24 @@ export const MAX_NUKE_EXPLOSION_COLORS = 4; /** * A firing player's nuke-explosion cosmetic, resolved from catalog attributes - * into renderer-ready values. `colors` is the palette the effect cycles - * through (1..MAX_NUKE_EXPLOSION_COLORS rgb in 0..1, never empty); - * maxRadius is the ring's final radius in world tiles when it fades out + * into renderer-ready values. `type` picks the visual — an expanding + * "shockwave" ring, or a firework burst of twinkling "sparkles" that ride + * outward from the center with the expanding front. + * `colors` is the palette the effect cycles through + * (1..MAX_NUKE_EXPLOSION_COLORS rgb in 0..1, never empty); + * maxRadius is the effect's final radius in world tiles when it fades out * (absolute — it does NOT scale with the bomb's blast radius); speed is the - * rate the ring's width grows in world tiles/s (the effect lasts - * 2·maxRadius / speed seconds); thickness is the ring band's thickness in - * world tiles (constant while the ring expands); transitionSpeed is the - * palette step rate in colors/s (0 = static first color, negative = reverse - * cycle) — same semantics as the trail shader's transition frequency. + * rate the effect's width grows in world tiles/s (the effect lasts + * 2·maxRadius / speed seconds); thickness is the ring band's thickness — or + * the average sparkle size, glints hash-vary ±50% around it — in world tiles + * (constant while the effect expands); + * transitionSpeed is the palette step rate in colors/s (0 = static, negative + * = reverse cycle) — same semantics as the trail shader's transition + * frequency (sparkles hash a per-sparkle palette offset on top). + * Sparkles additionally carry density — roughly the total number of glints + * in the burst (the renderer derives its grid pitch from it, clamped sane). */ -export interface NukeExplosionRenderParams { +interface NukeExplosionRenderParamsBase { colors: readonly (readonly [number, number, number])[]; maxRadius: number; speed: number; @@ -146,6 +153,10 @@ export interface NukeExplosionRenderParams { transitionSpeed: number; } +export type NukeExplosionRenderParams = + | (NukeExplosionRenderParamsBase & { type: "shockwave" }) + | (NukeExplosionRenderParamsBase & { type: "sparkles"; density: number }); + /** Default nuke-explosion color (purple) when a cosmetic has no usable color. */ export const DEFAULT_NUKE_EXPLOSION_COLOR: readonly [number, number, number] = [ 0.6, 0.1, 1, diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index feafe9f5b..bff52e183 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -149,22 +149,37 @@ export const NUKE_EXPLOSION_TYPES = ["atom", "hydro", "mirvWarhead"] as const; export type NukeExplosionType = (typeof NUKE_EXPLOSION_TYPES)[number]; // A nuke-explosion effect — a detonation FX, not a trail. `type` picks the -// visual (only "shockwave" today) and `nukeType` the bomb; both are enums so an -// effect using a value this client can't render is dropped by lenientRecord -// instead of rendering wrong. `colors` is the palette; size (final ring width -// in tiles), speed (tiles/s the width grows), thickness (ring band thickness -// in tiles), and transitionSpeed (palette colors/s) drive the animation. size -// and thickness must be positive — a non-positive value hits undefined shader -// behavior, so the entry is dropped like the enums; the renderer clamps speed. -export const NukeExplosionAttributesSchema = z.object({ - type: z.enum(["shockwave"]), - nukeType: z.enum(NUKE_EXPLOSION_TYPES), - colors: z.array(z.string()), - size: z.number().positive(), - speed: z.number(), - thickness: z.number().positive(), - transitionSpeed: z.number(), -}); +// visual (an expanding "shockwave" ring, or a firework burst of twinkling +// "sparkles") and `nukeType` the bomb; a value this client can't render is +// dropped by lenientRecord instead of rendering wrong. Shared knobs: +// `colors` is the palette; size (final effect width in tiles), speed (tiles/s +// the width grows), thickness (ring band thickness — or average sparkle size, +// glints vary ±50% around it — in tiles), and transitionSpeed (palette +// colors/s) drive the animation. Sparkles also take density — roughly how +// many sparkles the burst contains. size, thickness, and density must be +// positive — a non-positive value hits undefined shader behavior, so the +// entry is dropped like the enums; the renderer clamps speed and density. +export const NukeExplosionAttributesSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("shockwave"), + nukeType: z.enum(NUKE_EXPLOSION_TYPES), + colors: z.array(z.string()), + size: z.number().positive(), + speed: z.number(), + thickness: z.number().positive(), + transitionSpeed: z.number(), + }), + z.object({ + type: z.literal("sparkles"), + nukeType: z.enum(NUKE_EXPLOSION_TYPES), + colors: z.array(z.string()), + size: z.number().positive(), + speed: z.number(), + thickness: z.number().positive(), + transitionSpeed: z.number(), + density: z.number().positive(), + }), +]); const TransportShipTrailEffectSchema = CosmeticSchema.extend({ effectType: z.literal("transportShipTrail"), diff --git a/tests/CosmeticSchemas.test.ts b/tests/CosmeticSchemas.test.ts index 32029b4d2..c5b4a434e 100644 --- a/tests/CosmeticSchemas.test.ts +++ b/tests/CosmeticSchemas.test.ts @@ -407,6 +407,31 @@ describe("NukeExplosionAttributesSchema", () => { } }); + it("parses both visual types (shockwave, sparkles)", () => { + expect(NukeExplosionAttributesSchema.safeParse(atomShockwave).success).toBe( + true, + ); + expect( + NukeExplosionAttributesSchema.safeParse({ + ...atomShockwave, + type: "sparkles", + density: 150, + }).success, + ).toBe(true); + }); + + it("sparkles require a positive density", () => { + for (const density of [undefined, 0, -50]) { + expect( + NukeExplosionAttributesSchema.safeParse({ + ...atomShockwave, + type: "sparkles", + density, + }).success, + ).toBe(false); + } + }); + it("rejects an unknown nukeType or type (so it's dropped, not rendered wrong)", () => { expect( NukeExplosionAttributesSchema.safeParse({ @@ -481,6 +506,40 @@ describe("nukeExplosion in the cosmetics catalog", () => { } }); + it("parses the rgb sparkles catalog entry", () => { + const result = CosmeticsSchema.safeParse({ + patterns: {}, + flags: {}, + effects: { + nukeExplosion: { + rgb_nuke_sparkles: { + name: "rgb_nuke_sparkles", + effectType: "nukeExplosion", + attributes: { + size: 250, + type: "sparkles", + speed: 10, + colors: ["#ff0000", "#ffffff", "#0033ff"], + nukeType: "atom", + thickness: 3, + transitionSpeed: 3, + density: 150, + }, + affiliateCode: null, + product: null, + priceHard: 1, + rarity: "common", + }, + }, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + const eff = result.data.effects?.nukeExplosion?.rgb_nuke_sparkles; + expect(eff?.attributes.type).toBe("sparkles"); + } + }); + it("drops a nukeExplosion effect with an unknown nukeType without failing the catalog", () => { const attrs = (nukeType: string) => ({ type: "shockwave",