mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-04 16:12:43 +00:00
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:
@@ -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" };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user