feat: sparkles nuke-explosion visual type (#4490)

## 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 `<sparkles-swatch>` 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 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-02 15:21:26 -07:00
committed by GitHub
parent a7245518e2
commit b6317964a7
9 changed files with 380 additions and 50 deletions
+4 -1
View File
@@ -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" };
}
/**
+10 -4
View File
@@ -20,7 +20,7 @@ import { translateText } from "../Utils";
import "./CapIcon";
import "./CosmeticContainer";
import "./CosmeticInfo";
import "./EffectPreview"; // registers <trail-swatch> + <shockwave-swatch>
import "./EffectPreview"; // registers <trail-swatch>, <shockwave-swatch>, <sparkles-swatch>
import { renderPatternPreview } from "./PatternPreview";
import "./PlutoniumIcon";
@@ -187,10 +187,16 @@ export class CosmeticButton extends LitElement {
${translateText("territory_patterns.pattern.default")}
</div>`;
}
// 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`<sparkles-swatch
class="block w-full h-full"
.explosion=${c.attributes}
></sparkles-swatch>`;
}
return html`<shockwave-swatch
class="block w-full h-full"
.explosion=${c.attributes}
+139
View File
@@ -170,3 +170,142 @@ export class ShockwaveSwatch extends LitElement {
this.animations = [];
}
}
// Deterministic 0..1 from an index (shader-style hash) so dot positions are
// stable across re-renders without storing state. The fixed offsets below
// (101/211/307) decouple the position/twinkle/size hashes from the dot count.
function dotRand(n: number): number {
const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}
/**
* Preview of a nuke-explosion sparkles burst: a firework dots start at the
* center and ride outward (the whole burst scales up, mirroring the in-game
* front-normalized anchoring), twinkling on the way and fading at the end of
* the loop. Loop duration is size / speed (clamped watchable), dot count
* follows density, dot size follows thickness/size, and each dot takes a
* palette color by index; the colors cycle at transitionSpeed steps/s
* (negative = reverse), like in game.
*/
@customElement("sparkles-swatch")
export class SparklesSwatch extends LitElement {
@property({ attribute: false })
explosion: NukeExplosionAttributes | null = null;
private animations: Animation[] = [];
// Light DOM so the shared Tailwind classes apply.
createRenderRoot(): HTMLElement {
return this;
}
// Dot count follows the cosmetic's density (≈ total glints in the burst),
// clamped to keep the DOM preview cheap.
private dotCount(): number {
const attrs = this.explosion;
const density = attrs?.type === "sparkles" ? attrs.density : 10;
return Math.round(Math.min(Math.max(density, 4), 40));
}
render(): TemplateResult {
// Dots are positioned on a uniform disc (sqrt for area-uniformity) at
// deterministic hashed angles, as a fraction of the container.
return html`<div data-box class="relative w-full h-full overflow-hidden">
${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`<div
data-dot
class="absolute rounded-full"
style="left:${left}%;top:${top}%;transform:translate(-50%,-50%);opacity:0;"
></div>`;
})}
</div>`;
}
updated(changed: Map<string, unknown>): void {
if (!changed.has("explosion")) return;
for (const a of this.animations) a.cancel();
this.animations = [];
const attrs = this.explosion;
const box = this.querySelector<HTMLElement>("[data-box]");
if (!attrs || !box) return;
const dots = this.querySelectorAll<HTMLElement>("[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 = [];
}
}
@@ -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;
@@ -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);
@@ -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
+20 -9
View File
@@ -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,
+31 -16
View File
@@ -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"),
+59
View File
@@ -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",