From 6ff202afb5415bef1af7c5c93a36434f61a25070 Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 2 Jul 2026 14:21:01 -0700 Subject: [PATCH] feat: nuke-explosion cosmetic effects (per-bomb-type shockwave customization) (#4485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Adds a new `nukeExplosion` cosmetic effect type: when a bomb detonates, every client renders the shockwave in the firing player's equipped effect for that bomb type. **Cosmetics / selection** - New `nukeExplosion` effect schema (`CosmeticSchemas.ts`) with per-bomb selection slots — a slot is the effectType for trails and the `nukeType` for explosions (`atom` / `hydro` / `mirvWarhead`), so players can equip a distinct explosion per bomb type. - Slot resolution + validation is one shared helper (`findEffectForSlot`) used by client selection, server privilege checks (`Privilege.ts`), and the renderer; a compile-time guard keeps the nukeType and effectType slot namespaces disjoint. - Effects picker gains an Atom / Hydrogen / MIRV sub-tab bar when browsing nuke explosions; selections persist per slot in UserSettings and are validated/dropped like other cosmetics. **Rendering** - `WebGLFrameBuilder` resolves each dead nuke's owner cosmetic onto the dead-unit event; `FxShockwavePass` renders an EMP-style procedural ring (jagged crackling front, rotating lightning arcs, inner energy fill) from per-instance attributes. SAM interceptions and players with no cosmetic keep the classic white ring. - Catalog attributes have literal units: - `size` — final ring width (diameter) in world tiles at fade-out, absolute — independent of the bomb's blast radius - `speed` — tiles/s the width grows; duration = size / speed, clamped to 0.1–15 s - `thickness` (required) — ring band thickness in tiles, constant while the ring expands - `colors` — palette of up to 4 colors, cycled at `transitionSpeed` steps/s (0 = static, negative = reverse; same semantics as trail transitions) - The shockwave quad is sized radius + thickness so the absolute-width band isn't clipped into a box while the ring is young. ## 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 --- resources/lang/en.json | 6 + src/client/Cosmetics.ts | 34 +-- src/client/EffectsInput.ts | 14 +- src/client/WebGLFrameBuilder.ts | 107 ++++++- src/client/components/CosmeticButton.ts | 14 +- src/client/components/EffectPreview.ts | 96 ++++++- src/client/components/EffectsGrid.ts | 83 +++++- .../gl/passes/fx-pass/FxShockwavePass.ts | 115 +++++++- src/client/render/gl/passes/fx-pass/index.ts | 2 +- .../render/gl/shaders/fx/shockwave.frag.glsl | 124 ++++++-- .../render/gl/shaders/fx/shockwave.vert.glsl | 90 ++++-- src/client/render/types/Renderer.ts | 39 +++ src/client/render/types/index.ts | 8 +- src/client/view/GameView.ts | 1 + src/core/CosmeticSchemas.ts | 126 ++++++++- src/core/Schemas.ts | 6 +- src/core/game/UserSettings.ts | 20 +- src/server/Privilege.ts | 23 +- tests/CosmeticSchemas.test.ts | 267 ++++++++++++++++++ tests/Privilege.test.ts | 44 +++ tests/UserSettings.test.ts | 12 + 21 files changed, 1103 insertions(+), 128 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 7b0d8b763..765194b6c 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -402,9 +402,15 @@ }, "effects": { "button_title": "Pick an effect!", + "nukeType": { + "atom": "Atom", + "hydro": "Hydrogen", + "mirvWarhead": "MIRV" + }, "search": "Search...", "title": "Effects", "type": { + "nukeExplosion": "Nuke Explosion", "nukeTrail": "Nuke Trail", "transportShipTrail": "Boat Trail" } diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 769ed8dcb..c623f8c84 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -5,8 +5,7 @@ import { Cosmetics, CosmeticsSchema, Effect, - EffectType, - findEffect, + findEffectForSlot, Flag, Pack, Pattern, @@ -646,16 +645,17 @@ export async function getPlayerCosmeticsRefs(): Promise { } } - // Effects: a per-effectType map (effectType -> effect name). Drop any entry - // whose effect no longer exists or the user can't access. Like - // skins/flags/patterns above, a selection is kept (and left to the server to - // validate) when cosmetics or userMe fail to load. + // Effects: a per-slot map (slot -> effect name). A slot is the effectType for + // trails and the nukeType for nuke explosions (see effectTypeForSlot). Drop any + // entry whose effect no longer exists, doesn't fit the slot, or the user can't + // access. Like skins/flags/patterns above, a selection is kept (and left to the + // server to validate) when cosmetics or userMe fail to load. const selectedEffects = userSettings.getSelectedEffects(); const effects: Record = {}; - for (const [effectType, name] of Object.entries(selectedEffects)) { - const effect = findEffect(cosmetics, effectType, name); + for (const [slot, name] of Object.entries(selectedEffects)) { + const effect = findEffectForSlot(cosmetics, slot, name); if (cosmetics && !effect) { - userSettings.setSelectedEffectName(effectType as EffectType, undefined); + userSettings.setSelectedEffectName(slot, undefined); continue; } if (effect) { @@ -664,15 +664,12 @@ export async function getPlayerCosmeticsRefs(): Promise { const flares = userMe.player.flares ?? []; const hasWildcard = flares.includes("effect:*"); if (!hasWildcard && !flares.includes(`effect:${effect.name}`)) { - userSettings.setSelectedEffectName( - effectType as EffectType, - undefined, - ); + userSettings.setSelectedEffectName(slot, undefined); continue; } } } - effects[effectType] = name; + effects[slot] = name; } return { @@ -725,13 +722,10 @@ export async function getPlayerCosmetics(): Promise { if (refs.effects && cosmetics) { const effects: Record = {}; - for (const [effectType, name] of Object.entries(refs.effects)) { - const effect = findEffect(cosmetics, effectType, name); + for (const [slot, name] of Object.entries(refs.effects)) { + const effect = findEffectForSlot(cosmetics, slot, name); if (effect) { - effects[effectType] = { - name: effect.name, - effectType: effect.effectType, - }; + effects[slot] = { name: effect.name, effectType: effect.effectType }; } } if (Object.keys(effects).length > 0) result.effects = effects; diff --git a/src/client/EffectsInput.ts b/src/client/EffectsInput.ts index ea14c5c93..e7d5c2118 100644 --- a/src/client/EffectsInput.ts +++ b/src/client/EffectsInput.ts @@ -1,8 +1,9 @@ import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import { - EFFECT_TYPES, findEffect, + isTrailEffect, + TRAIL_EFFECT_TYPES, TrailEffectAttributes, } from "../core/CosmeticSchemas"; import { @@ -23,17 +24,18 @@ export class EffectsInput extends LitElement { private _abortController: AbortController | null = null; // PlayerEffect is just { name, effectType }; resolve the visual style from the - // cosmetics catalog by (effectType, name). The button shows a single swatch, - // so preview the first selected trail effect across effectTypes (boat trail - // before nuke trail, per EFFECT_TYPES order). + // cosmetics catalog by (effectType, name). The button shows a single trail + // swatch, so preview the first selected trail effect across trail effectTypes + // (boat trail before nuke trail, per TRAIL_EFFECT_TYPES order). nukeExplosion + // is not a trail and has no swatch preview here. private async resolveTrailAttributes(): Promise { const cosmetics = await getPlayerCosmetics(); const catalog = await fetchCosmetics(); - for (const effectType of EFFECT_TYPES) { + for (const effectType of TRAIL_EFFECT_TYPES) { const name = cosmetics.effects?.[effectType]?.name; if (!name) continue; const effect = findEffect(catalog, effectType, name); - if (effect) return effect.attributes; + if (effect && isTrailEffect(effect)) return effect.attributes; } return null; } diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts index 6a850fc24..15420a3a8 100644 --- a/src/client/WebGLFrameBuilder.ts +++ b/src/client/WebGLFrameBuilder.ts @@ -2,8 +2,13 @@ import { Colord, colord } from "colord"; import { base64url } from "jose"; import { assetUrl } from "../core/AssetUrls"; import { - EFFECT_TYPES, findEffect, + findEffectForSlot, + isNukeExplosionEffect, + isTrailEffect, + type NukeExplosionAttributes, + type NukeExplosionType, + TRAIL_EFFECT_TYPES, type TrailEffectAttributes, } from "../core/CosmeticSchemas"; import { decodePatternData } from "../core/PatternDecoder"; @@ -13,26 +18,78 @@ import { uploadFrameData } from "./render/frame/Upload"; // Type-only: a value import would pull GPURenderer and its `.glsl?raw` shader // imports into any non-Vite consumer (e.g. the Node perf harness). import type { MapRenderer, PlayerStatic, SpawnCenter } from "./render/gl"; +import { + DEFAULT_NUKE_EXPLOSION_COLOR, + MAX_NUKE_EXPLOSION_COLORS, + type NukeExplosionRenderParams, +} from "./render/types"; // Value import from the leaf module (not the ./render/gl barrel) so non-Vite // consumers don't pull in GPURenderer and its shaders — see note above. import { MAX_TRAIL_COLORS, TRAIL_EFFECT_BLOCKS, } from "./render/gl/utils/ColorUtils"; +import { + UT_ATOM_BOMB, + UT_HYDROGEN_BOMB, + UT_MIRV_WARHEAD, +} from "./render/types/UnitType"; import type { GameView } from "./view"; const PALETTE_SIZE = 4096; -// EFFECT_TYPES order IS the effect-palette block order: index = block (rows +// TRAIL_EFFECT_TYPES order IS the effect-palette block order: index = block (rows // block·MAX_TRAIL_COLORS …). The shader (trail.frag.glsl) picks the block from // the trail tile's nuke bit — block 0 = transportShipTrail (nuke bit 0), block 1 // = nukeTrail (nuke bit 1, set by NUKE_TRAIL_BIT in TrailManager). Reordering -// EFFECT_TYPES in CosmeticSchemas would silently swap the two trails' colors, so -// this guard fails the build if the shader-coupled prefix ever drifts. +// TRAIL_EFFECT_TYPES in CosmeticSchemas would silently swap the two trails' colors, +// so this guard fails the build if the shader-coupled order ever drifts. const _EFFECT_BLOCK_ORDER: readonly ["transportShipTrail", "nukeTrail"] = - EFFECT_TYPES; + TRAIL_EFFECT_TYPES; void _EFFECT_BLOCK_ORDER; +// Attribute → render-param mappings: +// size = the ring's final WIDTH (diameter) in world tiles when it fades +// out — absolute, so maxRadius = size / 2 regardless of bomb type. +// speed = world tiles/s the ring's width grows, so the effect lasts +// size / speed seconds (the pass clamps the duration). +// thickness = the ring band's thickness in world tiles. +// transitionSpeed passes through as the palette step rate (colors/s). + +// Detonating bomb → nuke-explosion slot. +// Only these unit types produce a shockwave; plain MIRV splits and never detonates. +const UNIT_TYPE_TO_NUKE_TYPE: Readonly> = { + [UT_ATOM_BOMB]: "atom", + [UT_HYDROGEN_BOMB]: "hydro", + [UT_MIRV_WARHEAD]: "mirvWarhead", +}; + +function toRgb01(s: string): [number, number, number] | null { + const c = colord(s); + if (!c.isValid()) return null; + const { r, g, b } = c.toRgb(); + return [r / 255, g / 255, b / 255]; +} + +/** Resolve a nuke-explosion cosmetic's catalog attributes into render params. */ +function attributesToExplosionParams( + attrs: NukeExplosionAttributes, +): NukeExplosionRenderParams { + // The shader cycles through the whole palette; the instance layout carries + // at most MAX_NUKE_EXPLOSION_COLORS, extras are dropped. + const colors = attrs.colors + .map(toRgb01) + .filter((c): c is [number, number, number] => c !== null) + .slice(0, MAX_NUKE_EXPLOSION_COLORS); + return { + colors: colors.length > 0 ? colors : [DEFAULT_NUKE_EXPLOSION_COLOR], + maxRadius: attrs.size / 2, + speed: attrs.speed, + thickness: attrs.thickness, + transitionSpeed: attrs.transitionSpeed, + }; +} + /** * The renderer-side glue between GameView (which already builds the full * FrameData each tick) and the WebGL view. Two responsibilities: @@ -128,9 +185,45 @@ export class WebGLFrameBuilder { this.syncLocalPlayer(gameView); this.syncSpawnOverlay(gameView); this.syncTerrainDeltas(gameView); + this.resolveDeadUnitExplosions(gameView); uploadFrameData(this.view, gameView.frameData()); } + /** + * Attach the firing player's resolved nuke-explosion cosmetic to each dead + * nuke event, so every client renders the shockwave in the owner's colors. + * The effect is per-bomb-type: the detonating unit maps to a nukeType slot + * (atom / hydro / mirvWarhead) and we resolve the player's selection for THAT + * slot, so an atom effect only shows on atom bombs, etc. Runs before + * uploadFrameData so the FX pass sees the params on the event; a player with no + * selection for that bomb is left undefined (the shockwave falls back to default). + */ + private resolveDeadUnitExplosions(gameView: GameView): void { + const deadUnits = gameView.frameData().events.deadUnits; + if (deadUnits.length === 0) return; + const catalog = getCachedCosmetics(); + if (!catalog) return; // Catalog not loaded yet — default FX this frame. + for (const du of deadUnits) { + if (!du.reachedTarget) continue; // SAM interceptions have no explosion cosmetic + const nukeType = UNIT_TYPE_TO_NUKE_TYPE[du.unitType]; + if (!nukeType) continue; // not a shockwave-producing bomb + // playerBySmallID throws on an unknown smallID; a stale/bad event must + // not kill the frame builder — skip it (default FX). + let player: ReturnType; + try { + player = gameView.playerBySmallID(du.ownerSmallID); + } catch { + continue; + } + if (!player.isPlayer()) continue; + const name = player.cosmetics.effects?.[nukeType]?.name; + if (!name) continue; + const effect = findEffectForSlot(catalog, nukeType, name); + if (!effect || !isNukeExplosionEffect(effect)) continue; + du.explosion = attributesToExplosionParams(effect.attributes); + } + } + /** * Push each player's current spawn tile to the renderer as the skin anchor * (image center lines up with this tile). Players re-pick spawn during the @@ -314,11 +407,13 @@ export class WebGLFrameBuilder { // Resolve each trail effectType into its own block of the effect palette. // rowBase block*MAX_TRAIL_COLORS must match the shader's block layout // (ship=0, nuke=1) — see _EFFECT_BLOCK_ORDER above and trail.frag.glsl. - EFFECT_TYPES.forEach((effectType, block) => { + // Only trail effect types render here; nukeExplosion is not a trail. + TRAIL_EFFECT_TYPES.forEach((effectType, block) => { const selected = p.cosmetics.effects?.[effectType]; if (!selected) return; const effect = findEffect(catalog, effectType, selected.name); if (!effect || effect.effectType !== effectType) return; + if (!isTrailEffect(effect)) return; // narrows attributes to trail attrs const rowBase = block * MAX_TRAIL_COLORS; if (this.writeEffectEntry(smallID, effect.attributes, rowBase)) { dirty = true; diff --git a/src/client/components/CosmeticButton.ts b/src/client/components/CosmeticButton.ts index c4288cd6e..afb4c84f9 100644 --- a/src/client/components/CosmeticButton.ts +++ b/src/client/components/CosmeticButton.ts @@ -3,6 +3,7 @@ import { customElement, property, state } from "lit/decorators.js"; import { Effect, Flag, + isNukeExplosionEffect, Pack, Pattern, Skin, @@ -19,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"; @@ -186,8 +187,15 @@ export class CosmeticButton extends LitElement { ${translateText("territory_patterns.pattern.default")} `; } - // Every trail effectType (transportShipTrail, nukeTrail) shares the same - // attributes shape; c.attributes is the gradient/transition style. + // Nuke explosions preview as an expanding ring; every trail effectType + // (transportShipTrail, nukeTrail) shares the same attributes shape and + // previews as a color swatch. + if (isNukeExplosionEffect(c)) { + return html``; + } 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 ring = this.querySelector("[data-ring]"); + if (!attrs || !ring) return; + const colors = + attrs.colors.length > 0 ? attrs.colors : [DEFAULT_RING_COLOR]; + + // Border thickness ∝ thickness/size, measured against the tile; a + // thickness ≥ size/2 renders as a filled disc, like in game. + const d = ring.clientWidth || 100; + const ratio = attrs.size > 0 ? attrs.thickness / attrs.size : 0.1; + const px = Math.min(Math.max(ratio * d, 2), d / 2); + ring.style.borderStyle = "solid"; + ring.style.borderWidth = `${px}px`; + ring.style.borderColor = colors[0]; + + // Expansion + fade, looping at the in-game pace (size / speed seconds), + // clamped so extreme catalog values still read as an explosion. + const durS = Math.min( + Math.max(attrs.size / Math.max(attrs.speed, 0.001), 0.6), + 3, + ); + this.animations.push( + ring.animate( + [ + { transform: "scale(0.1)", opacity: 1 }, + { transform: "scale(1)", opacity: 0 }, + ], + { duration: durS * 1000, iterations: Infinity, easing: "linear" }, + ), + ); + + // Palette cycle at transitionSpeed steps/s (one full cycle = + // count / |transitionSpeed| s); 0 or a single color stays static. + if (colors.length >= 2 && attrs.transitionSpeed !== 0) { + const list = attrs.transitionSpeed > 0 ? colors : [...colors].reverse(); + this.animations.push( + ring.animate( + [...list, list[0]].map((c) => ({ borderColor: 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/components/EffectsGrid.ts b/src/client/components/EffectsGrid.ts index a90a78165..aeefe1d57 100644 --- a/src/client/components/EffectsGrid.ts +++ b/src/client/components/EffectsGrid.ts @@ -6,6 +6,9 @@ import { Effect, EFFECT_TYPES, EffectType, + isNukeExplosionEffect, + NUKE_EXPLOSION_TYPES, + NukeExplosionType, } from "../../core/CosmeticSchemas"; import { EFFECTS_KEY, @@ -58,6 +61,9 @@ export class EffectsGrid extends LitElement { // Render an internal tab bar (one tab per effectType), one type at a time. @property({ type: Boolean }) tabbed = false; @state() private activeType: EffectType = EFFECT_TYPES[0]; + // Active nuke-explosion sub-tab (atom / hydro / mirv); only shown for the + // nukeExplosion effectType, which groups its effects by nukeType. + @state() private activeNukeType: NukeExplosionType = NUKE_EXPLOSION_TYPES[0]; private userSettings = new UserSettings(); private _onChange = () => this.requestUpdate(); @@ -82,12 +88,21 @@ export class EffectsGrid extends LitElement { return this; } - private select(effectType: EffectType, name: string | null) { - this.userSettings.setSelectedEffectName(effectType, name ?? undefined); + // slot = effectType for trails, or the active nukeType for nuke explosions. + private select(slot: string, name: string | null) { + this.userSettings.setSelectedEffectName(slot, name ?? undefined); // Stay rendered; the change event re-renders this grid and the home button. this.requestUpdate(); } + // The selection slot for a tile: for nuke explosions the effect's own nukeType + // (one selection per bomb type; the Default tile has none, so use the active + // sub-tab), else the effectType itself. + private slotForTile(effectType: EffectType, r: ResolvedCosmetic): string { + if (effectType !== "nukeExplosion") return effectType; + return this.nukeTypeOf(r) ?? this.activeNukeType; + } + private matchesSearch(r: ResolvedCosmetic): boolean { const q = this.search.trim().toLowerCase(); if (!q) return true; @@ -118,10 +133,7 @@ export class EffectsGrid extends LitElement { return this.search.trim() ? owned : [noneTile(effectType), ...owned]; } - private renderTile( - effectType: EffectType, - r: ResolvedCosmetic, - ): TemplateResult { + private renderTile(slot: string, r: ResolvedCosmetic): TemplateResult { if (this.mode === "purchase") { return html``; } const name = (r.cosmetic as Effect | null)?.name ?? null; - const selected = this.userSettings.getSelectedEffectName(effectType); + const selected = this.userSettings.getSelectedEffectName(slot); const isSelected = (name === null && selected === null) || (name !== null && selected === name); return html` this.select(effectType, name)} + .onSelect=${() => this.select(slot, name)} >`; } + // The nukeType attribute of a nukeExplosion effect, else null (trail effects + // and the Default tile have none). + private nukeTypeOf(r: ResolvedCosmetic): string | null { + const c = r.cosmetic as Effect | null; + return c && isNukeExplosionEffect(c) ? c.attributes.nukeType : null; + } + + // Secondary sub-tab bar for the nukeExplosion type: one pill per nukeType + // (atom / hydro / mirv). Sits below the effectType label; always all three. + private renderNukeTypeTabBar(): TemplateResult { + return html` +
+ ${NUKE_EXPLOSION_TYPES.map((nt) => { + const active = this.activeNukeType === nt; + return html``; + })} +
+ `; + } + // Store's sub-tab bar: one tab per effectType, always present, styled like the // store's top-level tabs (blue active + underline). private renderTabBar(): TemplateResult { @@ -174,8 +213,22 @@ export class EffectsGrid extends LitElement { const types: readonly EffectType[] = activeType ? [activeType] : EFFECT_TYPES; + // nukeExplosion is split into per-nukeType sub-tabs: items are always + // filtered to the active nukeType (keep the Default tile) so the Default + // tile's slot matches what's on screen. The sub-tab bar renders at the top + // when nukeExplosion is the single active type, else inside its section. + const showNukeTabs = activeType === "nukeExplosion"; const sections = types - .map((type) => ({ type, items: this.itemsForType(all, type) })) + .map((type) => { + let items = this.itemsForType(all, type); + if (type === "nukeExplosion") { + items = items.filter( + (r) => + r.cosmetic === null || this.nukeTypeOf(r) === this.activeNukeType, + ); + } + return { type, items }; + }) .filter((s) => s.items.length > 0); let panel: TemplateResult; @@ -202,10 +255,15 @@ export class EffectsGrid extends LitElement { > ${translateText(`effects.type.${s.type}`)} `} + ${!activeType && s.type === "nukeExplosion" + ? this.renderNukeTypeTabBar() + : nothing}
- ${s.items.map((r) => this.renderTile(s.type, r))} + ${s.items.map((r) => + this.renderTile(this.slotForTile(s.type, r), r), + )}
`, @@ -214,6 +272,9 @@ export class EffectsGrid extends LitElement { `; } - return this.tabbed ? html`${this.renderTabBar()}${panel}` : panel; + const nukeTabs = showNukeTabs ? this.renderNukeTypeTabBar() : nothing; + return this.tabbed + ? html`${this.renderTabBar()}${nukeTabs}${panel}` + : html`${nukeTabs}${panel}`; } } diff --git a/src/client/render/gl/passes/fx-pass/FxShockwavePass.ts b/src/client/render/gl/passes/fx-pass/FxShockwavePass.ts index 41f3e4598..a2a826b57 100644 --- a/src/client/render/gl/passes/fx-pass/FxShockwavePass.ts +++ b/src/client/render/gl/passes/fx-pass/FxShockwavePass.ts @@ -5,6 +5,11 @@ * Uses an SDF circle rendered in a unit quad, no texture required. */ +import { + DEFAULT_NUKE_EXPLOSION_COLOR, + MAX_NUKE_EXPLOSION_COLORS, + type NukeExplosionRenderParams, +} from "../../../types"; import { DynamicInstanceBuffer } from "../../DynamicBuffer"; import type { RenderSettings } from "../../RenderSettings"; import { createProgram } from "../../utils/GlUtils"; @@ -16,19 +21,33 @@ import shockwaveVertSrc from "../../shaders/fx/shockwave.vert.glsl?raw"; // Active state // --------------------------------------------------------------------------- +type RGB = readonly [number, number, number]; + +// SAM interception keeps the classic white ring (color fields go unused there). +const WHITE: RGB = [1, 1, 1]; + interface ActiveShockwave { x: number; y: number; startMs: number; durationMs: number; maxRadius: number; + style: number; // 0 = classic ring (SAM + no-cosmetic nuke), 1 = EMP + 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 } // --------------------------------------------------------------------------- -// Instance data layout: x, y, radius, alpha +// Instance data layout (21 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. // --------------------------------------------------------------------------- -const SHOCKWAVE_FLOATS = 4; +const SHOCKWAVE_FLOATS = 21; +const SHOCKWAVE_STRIDE = SHOCKWAVE_FLOATS * 4; // bytes // --------------------------------------------------------------------------- // FxShockwavePass @@ -41,6 +60,7 @@ export class FxShockwavePass { private program: WebGLProgram; private uCamera: WebGLUniformLocation; private uRingWidth: WebGLUniformLocation; + private uTime: WebGLUniformLocation; private vao: WebGLVertexArrayObject; private instanceBuf: DynamicInstanceBuffer; private shockwaveCount = 0; @@ -55,6 +75,7 @@ export class FxShockwavePass { this.program = createProgram(gl, shockwaveVertSrc, shockwaveFragSrc); this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; this.uRingWidth = gl.getUniformLocation(this.program, "uRingWidth")!; + this.uTime = gl.getUniformLocation(this.program, "uTime")!; const glBuf = gl.createBuffer()!; this.instanceBuf = new DynamicInstanceBuffer( @@ -78,9 +99,43 @@ export class FxShockwavePass { gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, glBuf); + // location 1: x, y, radius, alpha gl.enableVertexAttribArray(1); - gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 0, 0); + gl.vertexAttribPointer(1, 4, gl.FLOAT, false, SHOCKWAVE_STRIDE, 0); gl.vertexAttribDivisor(1, 1); + // location 2: style (0 classic, 1 EMP) + gl.enableVertexAttribArray(2); + gl.vertexAttribPointer(2, 1, gl.FLOAT, false, SHOCKWAVE_STRIDE, 16); + gl.vertexAttribDivisor(2, 1); + // locations 3-6: color0..color3 rgb + for (let i = 0; i < MAX_NUKE_EXPLOSION_COLORS; i++) { + gl.enableVertexAttribArray(3 + i); + gl.vertexAttribPointer( + 3 + i, + 3, + gl.FLOAT, + false, + SHOCKWAVE_STRIDE, + 20 + i * 12, + ); + gl.vertexAttribDivisor(3 + i, 1); + } + // location 7: colorCount + gl.enableVertexAttribArray(7); + gl.vertexAttribPointer(7, 1, gl.FLOAT, false, SHOCKWAVE_STRIDE, 68); + gl.vertexAttribDivisor(7, 1); + // location 8: speed + gl.enableVertexAttribArray(8); + gl.vertexAttribPointer(8, 1, gl.FLOAT, false, SHOCKWAVE_STRIDE, 72); + gl.vertexAttribDivisor(8, 1); + // location 9: transitionSpeed + gl.enableVertexAttribArray(9); + gl.vertexAttribPointer(9, 1, gl.FLOAT, false, SHOCKWAVE_STRIDE, 76); + gl.vertexAttribDivisor(9, 1); + // location 10: thickness + gl.enableVertexAttribArray(10); + gl.vertexAttribPointer(10, 1, gl.FLOAT, false, SHOCKWAVE_STRIDE, 80); + gl.vertexAttribDivisor(10, 1); gl.bindVertexArray(null); } @@ -89,14 +144,43 @@ export class FxShockwavePass { // Spawning // ------------------------------------------------------------------------- - pushNukeShockwave(x: number, y: number, nukeRadius: number): void { + // params = the firing player's resolved nuke-explosion cosmetic (undefined = + // no cosmetic → default purple, default radius/speed). + pushNukeShockwave( + x: number, + y: number, + nukeRadius: number, + params?: NukeExplosionRenderParams, + ): void { const fx = this.settings.fx; + // Cosmetic speed = world tiles/s the ring's WIDTH grows, so the effect + // lasts width / speed seconds. Clamped so a bad catalog value can't make + // the ring near-immortal (speed → 0) or a single-frame strobe. + let durationMs = fx.nukeShockwaveDurationMs; + if (params) { + const widthPx = params.maxRadius * 2; + durationMs = Math.min( + Math.max((widthPx / Math.max(params.speed, 0.001)) * 1000, 100), + 15_000, + ); + } + // 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; this.active.push({ x, y, startMs: this.timeFn(), - durationMs: fx.nukeShockwaveDurationMs, - maxRadius: nukeRadius * fx.nukeShockwaveRadiusFactor, + durationMs, + // 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, + colors: params?.colors ?? [DEFAULT_NUKE_EXPLOSION_COLOR], + speed, + transitionSpeed: params?.transitionSpeed ?? 0, + thickness: params?.thickness ?? 0, }); } @@ -108,6 +192,11 @@ export class FxShockwavePass { startMs: this.timeFn(), durationMs: fx.samShockwaveDurationMs, maxRadius: fx.samShockwaveRadius, + style: 0, // SAM interception keeps the classic ring + colors: [WHITE], + speed: 1, + transitionSpeed: 0, + thickness: 0, // classic style uses uRingWidth }); } @@ -142,6 +231,19 @@ export class FxShockwavePass { data[off + 1] = sw.y; data[off + 2] = t * sw.maxRadius; data[off + 3] = 1 - t; + data[off + 4] = sw.style; + // Pad unused slots with the last palette color (see layout note above). + for (let j = 0; j < MAX_NUKE_EXPLOSION_COLORS; j++) { + const c = sw.colors[Math.min(j, sw.colors.length - 1)]; + const co = off + 5 + j * 3; + data[co] = c[0]; + data[co + 1] = c[1]; + data[co + 2] = c[2]; + } + data[off + 17] = Math.min(sw.colors.length, MAX_NUKE_EXPLOSION_COLORS); + data[off + 18] = sw.speed; + data[off + 19] = sw.transitionSpeed; + data[off + 20] = sw.thickness; } this.shockwaveCount = count; @@ -157,6 +259,7 @@ export class FxShockwavePass { gl.useProgram(this.program); gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); gl.uniform1f(this.uRingWidth, this.settings.fx.shockwaveRingWidth); + gl.uniform1f(this.uTime, this.timeFn() * 0.001); gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer); gl.bufferSubData( gl.ARRAY_BUFFER, diff --git a/src/client/render/gl/passes/fx-pass/index.ts b/src/client/render/gl/passes/fx-pass/index.ts index 21eb52c10..66b228018 100644 --- a/src/client/render/gl/passes/fx-pass/index.ts +++ b/src/client/render/gl/passes/fx-pass/index.ts @@ -62,7 +62,7 @@ export class FxPass { if (nukeRadius !== undefined) { if (unit.reachedTarget) { this.spritePass.spawnFxForUnit(unit, now); - this.shockwavePass.pushNukeShockwave(x, y, nukeRadius); + this.shockwavePass.pushNukeShockwave(x, y, nukeRadius, unit.explosion); } else { // SAM interception: sprite pass handles the SAM explosion sprite this.spritePass.spawnFxForUnit(unit, now); diff --git a/src/client/render/gl/shaders/fx/shockwave.frag.glsl b/src/client/render/gl/shaders/fx/shockwave.frag.glsl index 81bb071b1..af3b5773f 100644 --- a/src/client/render/gl/shaders/fx/shockwave.frag.glsl +++ b/src/client/render/gl/shaders/fx/shockwave.frag.glsl @@ -1,17 +1,107 @@ -#version 300 es -precision highp float; - -uniform float uRingWidth; - -in vec2 vLocalPos; -flat in float vAlpha; - -out vec4 fragColor; - -void main() { - float dist = length(vLocalPos); - float ringDist = abs(dist - 1.0); - float ring = 1.0 - smoothstep(0.0, uRingWidth, ringDist); - if (ring < 0.01) discard; - fragColor = vec4(1.0, 1.0, 1.0, ring * vAlpha); -} +#version 300 es +precision highp float; + +uniform float uRingWidth; +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 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) + +// Palette lookup by (already-wrapped) index. +vec3 colorAt(float i) { + if (i < 0.5) return vColor0; + if (i < 1.5) return vColor1; + if (i < 2.5) return vColor2; + return vColor3; +} + +out vec4 fragColor; + +// --- cheap 1D value noise ------------------------------------------------- +float hash11(float p) { + p = fract(p * 0.1031); + p *= p + 33.33; + p *= p + p; + return fract(p); +} +float vnoise(float x) { + float i = floor(x); + float f = fract(x); + float u = f * f * (3.0 - 2.0 * f); + return mix(hash11(i), hash11(i + 1.0), u); +} + +// Classic expanding white ring (SAM, and nuke style 0). +void classicRing(float dist) { + float ringDist = abs(dist - 1.0); + float ring = 1.0 - smoothstep(0.0, uRingWidth, ringDist); + if (ring < 0.01) discard; + fragColor = vec4(1.0, 1.0, 1.0, ring * vAlpha); +} + +// EMP energy pulse — a jagged, crackling ring with rotating lightning arcs and +// a faint trailing energy fill. Colored by the firing player's cosmetic. +void empPulse(float dist) { + float ang = atan(vLocalPos.y, vLocalPos.x); // -pi..pi + float tt = uTime * vSpeed; // speed-scaled animation time + + // Jagged front: perturb the ideal r=1.0 ring by angular + time noise. + float n = vnoise(ang * 6.0 + tt * 6.0) + + 0.5 * vnoise(ang * 17.0 - tt * 11.0); + float ringR = 0.95 + n * 0.05; + float ringDist = abs(dist - ringR); + + // Band half-width in local units (dist 1.0 = vRadius world tiles), so the + // cosmetic's thickness stays constant in tiles while the ring expands. + // Per-angle flicker (averages ~1.0×) keeps it feeling electric. + float halfW = 0.5 * vThickness / max(vRadius, 0.001); + float w = halfW * (0.7 + 0.6 * vnoise(ang * 9.0 + tt * 20.0)); + float ring = 1.0 - smoothstep(0.0, w, ringDist); + + // A couple of bright rotating arcs of "lightning" chasing around the ring. + float arc = pow(0.5 + 0.5 * sin(ang * 5.0 - tt * 8.0), 8.0) + + pow(0.5 + 0.5 * sin(ang * 8.0 + tt * 13.0), 12.0); + + // Faint inner energy fill trailing behind the front. + float inner = smoothstep(ringR, 0.0, dist) * 0.12 + * (0.6 + 0.4 * vnoise(ang * 20.0 + tt * 25.0)); + + float glow = ring * (0.8 + arc) + inner; + if (glow < 0.01) discard; + + // Base color cycles through the whole palette (color0 → color1 → … → wrap) + // at transitionSpeed steps/s, like the trail shader's transition (0 → + // static color0, negative → reverse cycle). Arcs flare toward white. + float idx = uTime * vTransSpeed; + vec3 base = mix( + colorAt(mod(floor(idx), vColorCount)), + colorAt(mod(floor(idx) + 1.0, vColorCount)), + fract(idx)); + // Padded slots repeat a real color, so this max spans the active palette. + vec3 bright = max(max(vColor0, vColor1), max(vColor2, vColor3)); + vec3 hot = mix(bright, vec3(1.0), 0.4); + vec3 col = mix(base, hot, clamp(arc * 0.8, 0.0, 1.0)); + + // Whole-ring flicker on top of the lifetime fade (speed-scaled like the rest). + float life = vAlpha * (0.75 + 0.25 * vnoise(tt * 30.0)); + fragColor = vec4(col, clamp(glow, 0.0, 1.0) * life); +} + +void main() { + float dist = length(vLocalPos); + 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 e27aa4037..b80978a2f 100644 --- a/src/client/render/gl/shaders/fx/shockwave.vert.glsl +++ b/src/client/render/gl/shaders/fx/shockwave.vert.glsl @@ -1,27 +1,63 @@ -#version 300 es -precision highp float; - -layout(location = 0) in vec2 aPos; -layout(location = 1) in vec4 aInstData; // x, y, radius, alpha - -uniform mat3 uCamera; - -out vec2 vLocalPos; -flat out float vAlpha; - -// Extra margin so the ring's outer feathering isn't clipped at the quad edge. -const float MARGIN = 1.1; // 10% beyond ring radius - -void main() { - vec2 center = vec2(aInstData.x + 0.5, aInstData.y + 0.5); - float r = aInstData.z; - vAlpha = aInstData.w; - - vec2 worldPos = center + (aPos - 0.5) * r * 2.0 * MARGIN; - - vec3 clip = uCamera * vec3(worldPos, 1.0); - gl_Position = vec4(clip.xy, 0.0, 1.0); - - // Scale vLocalPos by the same margin so dist=1.0 stays at the ring radius - vLocalPos = (aPos - 0.5) * 2.0 * MARGIN; -} +#version 300 es +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 = 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 +layout(location = 6) in vec3 aColor3; // EMP: palette color 3 +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) + +uniform mat3 uCamera; + +out vec2 vLocalPos; +flat out float vAlpha; +flat out float vStyle; +flat out vec3 vColor0; +flat out vec3 vColor1; +flat out vec3 vColor2; +flat out vec3 vColor3; +flat out float vColorCount; +flat out float vSpeed; +flat out float vTransSpeed; +flat out float vThickness; +flat out float vRadius; // current ring radius (world tiles) — converts + // absolute thickness into local ring units + +// Extra margin so the ring's outer feathering isn't clipped at the quad edge. +const float MARGIN = 1.1; // 10% beyond ring radius + +void main() { + vec2 center = vec2(aInstData.x + 0.5, aInstData.y + 0.5); + float r = aInstData.z; + vAlpha = aInstData.w; + vStyle = aStyle; + vColor0 = aColor0; + vColor1 = aColor1; + vColor2 = aColor2; + vColor3 = aColor3; + vColorCount = aColorCount; + vSpeed = aSpeed; + vTransSpeed = aTransSpeed; + vThickness = aThickness; + vRadius = r; + + // Quad extent: ring radius plus the full band thickness. The band is + // absolute (world tiles), so while the ring is young it can be wider than + // the radius itself — a pure percentage margin would clip it into a box. + // aThickness is 0 for the classic style, giving the original r * MARGIN. + float extent = (r + aThickness) * MARGIN; + vec2 worldPos = center + (aPos - 0.5) * 2.0 * extent; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + // vLocalPos stays normalized to the ring radius (dist 1.0 = radius r), so + // scale by extent/r instead of the fixed margin. + vLocalPos = (aPos - 0.5) * 2.0 * (extent / max(r, 0.001)); +} diff --git a/src/client/render/types/Renderer.ts b/src/client/render/types/Renderer.ts index 15d0c58d2..0dc95c7d2 100644 --- a/src/client/render/types/Renderer.ts +++ b/src/client/render/types/Renderer.ts @@ -108,10 +108,49 @@ export interface DeadUnitFx { unitType: string; pos: number; reachedTarget: boolean; + /** Firing player's smallID — resolves their nuke-explosion cosmetic. */ + ownerSmallID: number; + /** + * Resolved nuke-explosion render params (the firing player's cosmetic). + * Attached by WebGLFrameBuilder before the FX pass consumes the event; + * undefined when the owner has no nuke-explosion cosmetic (default FX). + */ + explosion?: NukeExplosionRenderParams; /** Ticks since the event occurred (0 = this frame, >0 = seeked past it). */ tickAge?: number; } +/** + * Max palette colors a shockwave instance can carry (vertex-attribute budget); + * a longer cosmetic palette is truncated. + */ +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 + * (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. + */ +export interface NukeExplosionRenderParams { + colors: readonly (readonly [number, number, number])[]; + maxRadius: number; + speed: number; + thickness: number; + transitionSpeed: 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, +]; + /** Conquest event data for the gold popup + sword sprite FX. */ export interface ConquestFx { x: number; // world tile X (conquered player's name location) diff --git a/src/client/render/types/index.ts b/src/client/render/types/index.ts index a5c455a20..d2ebe356d 100644 --- a/src/client/render/types/index.ts +++ b/src/client/render/types/index.ts @@ -1,5 +1,10 @@ // Renderer types (units, players, tiles, names, config) -export { PlayerTypeEnum, TrainType } from "./Renderer"; +export { + DEFAULT_NUKE_EXPLOSION_COLOR, + MAX_NUKE_EXPLOSION_COLORS, + PlayerTypeEnum, + TrainType, +} from "./Renderer"; export type { AllianceData, AttackData, @@ -9,6 +14,7 @@ export type { EmojiData, GhostPreviewData, NameEntry, + NukeExplosionRenderParams, NukeTelegraphData, NukeTrajectoryData, PlayerState, diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts index 3d5ba9053..0f696f19c 100644 --- a/src/client/view/GameView.ts +++ b/src/client/view/GameView.ts @@ -627,6 +627,7 @@ export class GameView implements GameMap { unitType: u.unitType, pos: u.pos, reachedTarget: u.reachedTarget, + ownerSmallID: u.ownerID, }); } const myID = this._myPlayer?.id(); diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index e1105a016..feafe9f5b 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -10,11 +10,15 @@ export type Skin = z.infer; export type Pack = z.infer; export type Subscription = z.infer; // An effect cosmetic of any type — discriminated on effectType (today -// transportShipTrail + nukeTrail; gains a member per effectType). +// transportShipTrail + nukeTrail + nukeExplosion; gains a member per effectType). export type Effect = z.infer; export type EffectType = z.infer; // Shared by every trail effectType (transportShipTrail, nukeTrail, …). export type TrailEffectAttributes = z.infer; +// Attributes of a nuke-explosion effect (a detonation FX, not a trail). +export type NukeExplosionAttributes = z.infer< + typeof NukeExplosionAttributesSchema +>; export type PatternName = z.infer; export type Product = z.infer; export type ColorPalette = z.infer; @@ -98,9 +102,21 @@ export const SkinSchema = CosmeticSchema.extend({ // stay precisely typed; an effectType the client doesn't list is dropped at parse // (the UI only handles EFFECT_TYPES), so a new server-side effectType never fails // the whole cosmetics parse. -export const EFFECT_TYPES = ["transportShipTrail", "nukeTrail"] as const; +export const EFFECT_TYPES = [ + "transportShipTrail", + "nukeTrail", + "nukeExplosion", +] as const; export const EffectTypeSchema = z.enum(EFFECT_TYPES); +// The subset of effect types that render as trails through the shared trail +// palette (their attributes are TrailEffectAttributes; block order matches +// trail.frag.glsl — transportShipTrail=0, nukeTrail=1). nukeExplosion is an +// effect type but NOT a trail: it's a detonation FX with its own attributes and +// renders through the FX shockwave pass, so it's excluded here. +export const TRAIL_EFFECT_TYPES = ["transportShipTrail", "nukeTrail"] as const; +export type TrailEffectType = (typeof TRAIL_EFFECT_TYPES)[number]; + // A trail effect, discriminated on `type`. Shared by every trail effectType // (transport-ship trails, nuke trails, …) — the attributes are the same; only // the unit whose trail they color differs. @@ -126,6 +142,30 @@ export const TrailEffectAttributesSchema = z.discriminatedUnion("type", [ }), ]); +// The bomb a nuke-explosion effect applies to. The store/selection UI groups +// nukeExplosion effects into one tab per type. Enum, so an effect for a bomb +// this client doesn't know is dropped by lenientRecord (not rendered wrong). +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(), +}); + const TransportShipTrailEffectSchema = CosmeticSchema.extend({ effectType: z.literal("transportShipTrail"), attributes: TrailEffectAttributesSchema, @@ -138,12 +178,93 @@ const NukeTrailEffectSchema = CosmeticSchema.extend({ url: z.string().optional(), }); +const NukeExplosionEffectSchema = CosmeticSchema.extend({ + effectType: z.literal("nukeExplosion"), + attributes: NukeExplosionAttributesSchema, + url: z.string().optional(), +}); + // Any catalog effect, discriminated on effectType. Add a member per effectType. export const EffectSchema = z.discriminatedUnion("effectType", [ TransportShipTrailEffectSchema, NukeTrailEffectSchema, + NukeExplosionEffectSchema, ]); +/** + * True for effects that render through the shared trail palette (their + * attributes are TrailEffectAttributes). Narrows the Effect union so callers can + * treat `attributes` as trail attributes; a nukeExplosion (or any future + * non-trail effect) returns false. + */ +export function isTrailEffect( + effect: Effect, +): effect is Extract { + return (TRAIL_EFFECT_TYPES as readonly string[]).includes(effect.effectType); +} + +/** Narrows an Effect to a nuke-explosion effect (exposes its nukeType). */ +export function isNukeExplosionEffect( + effect: Effect, +): effect is Extract { + return effect.effectType === "nukeExplosion"; +} + +/** + * A player selects one effect per "slot". A slot is the effectType for trails + * (transportShipTrail, nukeTrail) and the nukeType for nuke explosions (atom, + * hydro, mirvWarhead) — so a player can equip a distinct explosion per bomb. + * Returns the effectType a slot resolves to for catalog lookup, or undefined for + * an unknown/stale slot (e.g. a bare "nukeExplosion" key from before this split). + */ +export function effectTypeForSlot(slot: string): EffectType | undefined { + if ((NUKE_EXPLOSION_TYPES as readonly string[]).includes(slot)) { + return "nukeExplosion"; + } + if ((TRAIL_EFFECT_TYPES as readonly string[]).includes(slot)) { + return slot as EffectType; + } + return undefined; +} + +/** + * Whether `effect` may occupy selection `slot`: the slot's effectType matches, + * and for a nuke-explosion slot the effect's nukeType matches the slot (so an + * atom effect can only sit in the atom slot, etc.). + */ +export function effectMatchesSlot(effect: Effect, slot: string): boolean { + if (effect.effectType !== effectTypeForSlot(slot)) return false; + if (isNukeExplosionEffect(effect)) return effect.attributes.nukeType === slot; + return true; +} + +/** + * Resolve a selection slot + effect name against the catalog: look up the + * slot's effectType (effectTypeForSlot) and require the found effect to fit + * the slot (effectMatchesSlot). Returns undefined for an unknown slot, a + * missing effect, or a slot mismatch. + */ +export function findEffectForSlot( + cosmetics: Cosmetics | null | undefined, + slot: string, + name: string, +): Effect | undefined { + const effectType = effectTypeForSlot(slot); + const effect = effectType + ? findEffect(cosmetics, effectType, name) + : undefined; + return effect && effectMatchesSlot(effect, slot) ? effect : undefined; +} + +// Slots put nukeType and effectType names in one flat string namespace +// (effectTypeForSlot disambiguates by list membership), so the two enums must +// stay disjoint — a nukeType named like an effectType would silently hijack +// that slot. This guard fails the build if they ever collide. +type _SlotCollision = Extract; +const _SLOT_NAMESPACES_DISJOINT: _SlotCollision extends never ? true : false = + true; +void _SLOT_NAMESPACES_DISJOINT; + /** * A record that drops entries failing `schema` instead of failing the whole * parse. Used for the effect catalog: a newer effect the server ships before @@ -193,6 +314,7 @@ export const CosmeticsSchema = z.object({ TransportShipTrailEffectSchema, ).optional(), nukeTrail: lenientRecord(NukeTrailEffectSchema).optional(), + nukeExplosion: lenientRecord(NukeExplosionEffectSchema).optional(), }) .optional(), currencyPacks: z.record(z.string(), PackSchema).optional(), diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 2f07023d3..0b058f036 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -595,7 +595,8 @@ export const PlayerCosmeticRefsSchema = z.object({ patternName: CosmeticNameSchema.optional(), patternColorPaletteName: z.string().optional(), skinName: CosmeticNameSchema.optional(), - // At most one selected effect per effectType: key = effectType, value = effect name. + // One selected effect per slot: key = slot (effectType for trails, nukeType for + // nuke explosions — see effectTypeForSlot), value = effect name. effects: z.record(z.string(), CosmeticNameSchema).optional(), }); @@ -619,7 +620,8 @@ export const PlayerCosmeticsSchema = z.object({ pattern: PlayerPatternSchema.optional(), color: PlayerColorSchema.optional(), skin: PlayerSkinSchema.optional(), - // Resolved effects keyed by effectType. + // Resolved effects keyed by slot (effectType for trails, nukeType for nuke + // explosions). effects: z.record(z.string(), PlayerEffectSchema).optional(), }); diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 9ef3807b7..6e509774a 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -2,7 +2,7 @@ import { GraphicsOverrides, GraphicsOverridesSchema, } from "../../client/render/gl/GraphicsOverrides"; -import { Cosmetics, EffectType } from "../CosmeticSchemas"; +import { Cosmetics } from "../CosmeticSchemas"; import { PlayerPattern } from "../Schemas"; export function getDefaultKeybinds(isMac: boolean): Record { @@ -315,8 +315,9 @@ export class UserSettings { } /** - * Selected effect cosmetics, keyed by effectType (at most one per type). - * Persisted as a single JSON blob under EFFECTS_KEY. + * Selected effect cosmetics, keyed by selection slot (at most one per slot). + * A slot is the effectType for trails and the nukeType for nuke explosions — + * see effectTypeForSlot. Persisted as a single JSON blob under EFFECTS_KEY. */ getSelectedEffects(): Record { const raw = this.getString(EFFECTS_KEY, ""); @@ -331,17 +332,14 @@ export class UserSettings { } } - getSelectedEffectName(effectType: EffectType): string | null { - return this.getSelectedEffects()[effectType] ?? null; + getSelectedEffectName(slot: string): string | null { + return this.getSelectedEffects()[slot] ?? null; } - setSelectedEffectName( - effectType: EffectType, - name: string | undefined, - ): void { + setSelectedEffectName(slot: string, name: string | undefined): void { const map = this.getSelectedEffects(); - if (name === undefined) delete map[effectType]; - else map[effectType] = name; + if (name === undefined) delete map[slot]; + else map[slot] = name; if (Object.keys(map).length === 0) this.removeCached(EFFECTS_KEY); else this.setString(EFFECTS_KEY, JSON.stringify(map)); } diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index c11f4df6f..35b3fe9a5 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -11,7 +11,7 @@ import { } from "obscenity"; import countries from "resources/countries.json"; -import { Cosmetics, EffectType, findEffect } from "../core/CosmeticSchemas"; +import { Cosmetics, findEffectForSlot } from "../core/CosmeticSchemas"; import { decodePatternData } from "../core/PatternDecoder"; import { PlayerColor, @@ -259,14 +259,10 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker { } } if (refs.effects) { - for (const [type, name] of Object.entries(refs.effects)) { + for (const [slot, name] of Object.entries(refs.effects)) { try { cosmetics.effects ??= {}; - cosmetics.effects[type] = this.isEffectAllowed( - flares, - type as EffectType, - name, - ); + cosmetics.effects[slot] = this.isEffectAllowed(flares, slot, name); } catch (e) { const message = e instanceof Error ? e.message : String(e); return { type: "forbidden", reason: "invalid effect: " + message }; @@ -277,13 +273,12 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker { return { type: "allowed", cosmetics }; } - isEffectAllowed( - flares: string[], - effectType: EffectType, - name: string, - ): PlayerEffect { - const found = findEffect(this.cosmetics, effectType, name); - if (!found) throw new Error(`Effect ${name} not found`); + // slot = effectType (trails) or nukeType (nuke explosions); see effectTypeForSlot. + isEffectAllowed(flares: string[], slot: string, name: string): PlayerEffect { + const found = findEffectForSlot(this.cosmetics, slot, name); + if (!found) { + throw new Error(`Effect ${name} not found for slot ${slot}`); + } if ( flares.includes("effect:*") || flares.includes(`effect:${found.name}`) diff --git a/tests/CosmeticSchemas.test.ts b/tests/CosmeticSchemas.test.ts index 42ee1bdc5..32029b4d2 100644 --- a/tests/CosmeticSchemas.test.ts +++ b/tests/CosmeticSchemas.test.ts @@ -1,8 +1,15 @@ import { Cosmetics, CosmeticsSchema, + Effect, + effectMatchesSlot, EffectSchema, + effectTypeForSlot, findEffect, + findEffectForSlot, + isNukeExplosionEffect, + isTrailEffect, + NukeExplosionAttributesSchema, TrailEffectAttributesSchema, } from "../src/core/CosmeticSchemas"; import { PlayerEffectSchema } from "../src/core/Schemas"; @@ -373,3 +380,263 @@ describe("PlayerEffectSchema (identity: name + effectType)", () => { ); }); }); + +describe("NukeExplosionAttributesSchema", () => { + const atomShockwave = { + type: "shockwave", + nukeType: "atom", + colors: ["#ff0000", "#bb00ff"], + size: 50, + speed: 50, + thickness: 4, + transitionSpeed: 5, + }; + + it("parses the atom shockwave attributes", () => { + expect(NukeExplosionAttributesSchema.safeParse(atomShockwave).success).toBe( + true, + ); + }); + + it("parses all three nukeTypes (atom, hydro, mirvWarhead)", () => { + for (const nukeType of ["atom", "hydro", "mirvWarhead"]) { + expect( + NukeExplosionAttributesSchema.safeParse({ ...atomShockwave, nukeType }) + .success, + ).toBe(true); + } + }); + + it("rejects an unknown nukeType or type (so it's dropped, not rendered wrong)", () => { + expect( + NukeExplosionAttributesSchema.safeParse({ + ...atomShockwave, + nukeType: "hydrogen", + }).success, + ).toBe(false); + expect( + NukeExplosionAttributesSchema.safeParse({ + ...atomShockwave, + type: "fireball", + }).success, + ).toBe(false); + }); + + it("rejects non-positive size and thickness (dropped, not rendered wrong)", () => { + for (const patch of [ + { size: 0 }, + { size: -50 }, + { thickness: 0 }, + { thickness: -4 }, + ]) { + expect( + NukeExplosionAttributesSchema.safeParse({ ...atomShockwave, ...patch }) + .success, + ).toBe(false); + } + }); + + it("requires colors, size, speed, thickness, and transitionSpeed", () => { + expect( + NukeExplosionAttributesSchema.safeParse({ + type: "shockwave", + nukeType: "atom", + }).success, + ).toBe(false); + }); +}); + +describe("nukeExplosion in the cosmetics catalog", () => { + it("parses the atom shockwave catalog entry", () => { + const result = CosmeticsSchema.safeParse({ + patterns: {}, + flags: {}, + effects: { + nukeExplosion: { + atom_shockwave_purple_red: { + name: "atom_shockwave_purple_red", + effectType: "nukeExplosion", + attributes: { + size: 50, + speed: 50, + thickness: 4, + colors: ["#ff0000", "#bb00ff"], + nukeType: "atom", + type: "shockwave", + transitionSpeed: 5, + }, + affiliateCode: null, + product: null, + priceHard: 1, + rarity: "common", + }, + }, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + const eff = result.data.effects?.nukeExplosion?.atom_shockwave_purple_red; + expect(eff?.effectType).toBe("nukeExplosion"); + expect(eff?.attributes.colors).toEqual(["#ff0000", "#bb00ff"]); + } + }); + + it("drops a nukeExplosion effect with an unknown nukeType without failing the catalog", () => { + const attrs = (nukeType: string) => ({ + type: "shockwave", + nukeType, + colors: [], + size: 1, + speed: 1, + thickness: 1, + transitionSpeed: 1, + }); + const result = CosmeticsSchema.safeParse({ + patterns: {}, + flags: {}, + effects: { + nukeExplosion: { + atom: { + name: "atom", + effectType: "nukeExplosion", + attributes: attrs("atom"), + product: null, + rarity: "common", + }, + future: { + name: "future", + effectType: "nukeExplosion", + attributes: attrs("hydrogen"), + product: null, + rarity: "common", + }, + }, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.effects?.nukeExplosion?.atom?.name).toBe("atom"); + expect(result.data.effects?.nukeExplosion?.future).toBeUndefined(); + } + }); +}); + +describe("isTrailEffect", () => { + it("is true for a trail effect and false for a nukeExplosion", () => { + const trail = EffectSchema.parse({ + name: "spectrum", + effectType: "transportShipTrail", + product: null, + rarity: "common", + attributes: { + type: "gradient", + colors: ["#fff"], + colorSize: 16, + movementSpeed: 0.15, + }, + }); + const boom = EffectSchema.parse({ + name: "atom_shockwave_purple_red", + effectType: "nukeExplosion", + product: null, + rarity: "common", + attributes: { + type: "shockwave", + nukeType: "atom", + colors: ["#f00"], + size: 50, + speed: 50, + thickness: 4, + transitionSpeed: 5, + }, + }); + expect(isTrailEffect(trail)).toBe(true); + expect(isTrailEffect(boom)).toBe(false); + }); +}); + +describe("effect selection slots", () => { + const trail: Effect = EffectSchema.parse({ + name: "spectrum", + effectType: "transportShipTrail", + product: null, + rarity: "common", + attributes: { + type: "gradient", + colors: ["#fff"], + colorSize: 16, + movementSpeed: 0.15, + }, + }); + const atomBoom: Effect = EffectSchema.parse({ + name: "atom_boom", + effectType: "nukeExplosion", + product: null, + rarity: "common", + attributes: { + type: "shockwave", + nukeType: "atom", + colors: ["#f00"], + size: 50, + speed: 50, + thickness: 4, + transitionSpeed: 5, + }, + }); + + it("isNukeExplosionEffect narrows nukeExplosion effects", () => { + expect(isNukeExplosionEffect(atomBoom)).toBe(true); + expect(isNukeExplosionEffect(trail)).toBe(false); + }); + + it("effectTypeForSlot maps trail slots to themselves and nukeTypes to nukeExplosion", () => { + expect(effectTypeForSlot("transportShipTrail")).toBe("transportShipTrail"); + expect(effectTypeForSlot("nukeTrail")).toBe("nukeTrail"); + expect(effectTypeForSlot("atom")).toBe("nukeExplosion"); + expect(effectTypeForSlot("hydro")).toBe("nukeExplosion"); + expect(effectTypeForSlot("mirvWarhead")).toBe("nukeExplosion"); + // A bare "nukeExplosion" is no longer a valid slot (selection is per nukeType). + expect(effectTypeForSlot("nukeExplosion")).toBeUndefined(); + expect(effectTypeForSlot("bogus")).toBeUndefined(); + }); + + it("effectMatchesSlot ties a nuke effect to its own nukeType slot", () => { + expect(effectMatchesSlot(atomBoom, "atom")).toBe(true); + expect(effectMatchesSlot(atomBoom, "hydro")).toBe(false); + expect(effectMatchesSlot(atomBoom, "mirvWarhead")).toBe(false); + // A trail matches its effectType slot, not a nukeType slot. + expect(effectMatchesSlot(trail, "transportShipTrail")).toBe(true); + expect(effectMatchesSlot(trail, "atom")).toBe(false); + }); + + it("findEffectForSlot resolves a slot + name against the catalog", () => { + const parsed = CosmeticsSchema.safeParse({ + patterns: {}, + flags: {}, + effects: { + transportShipTrail: { spectrum: trail }, + nukeExplosion: { atom_boom: atomBoom }, + }, + }); + expect(parsed.success).toBe(true); + if (!parsed.success) return; + const catalog = parsed.data; + + expect(findEffectForSlot(catalog, "atom", "atom_boom")?.name).toBe( + "atom_boom", + ); + expect( + findEffectForSlot(catalog, "transportShipTrail", "spectrum")?.name, + ).toBe("spectrum"); + // Slot mismatch: an atom effect can't fill the hydro slot. + expect(findEffectForSlot(catalog, "hydro", "atom_boom")).toBeUndefined(); + // A bare effectType is not a nuke-explosion slot. + expect( + findEffectForSlot(catalog, "nukeExplosion", "atom_boom"), + ).toBeUndefined(); + expect(findEffectForSlot(catalog, "atom", "missing")).toBeUndefined(); + expect(findEffectForSlot(catalog, "bogus", "atom_boom")).toBeUndefined(); + // No catalog (failed load) resolves nothing. + expect(findEffectForSlot(null, "atom", "atom_boom")).toBeUndefined(); + }); +}); diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index eef54c753..8951d1f55 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -129,6 +129,27 @@ const effectCosmetics = { rarity: "common", }, }, + nukeExplosion: { + atom_boom: { + name: "atom_boom", + effectType: "nukeExplosion" as const, + attributes: { + type: "shockwave" as const, + nukeType: "atom" as const, + colors: ["#ff0000", "#7300ff"], + size: 50, + speed: 50, + thickness: 4, + transitionSpeed: 5, + }, + url: "", + affiliateCode: null, + product: null, + priceSoft: undefined, + priceHard: undefined, + rarity: "common", + }, + }, }, }; const effectChecker = new PrivilegeCheckerImpl( @@ -607,6 +628,29 @@ describe("Effect validation in isAllowed", () => { } }); + test("allows a nuke-explosion effect in its matching nukeType slot", () => { + const result = effectChecker.isAllowed(["effect:*"], { + effects: { atom: "atom_boom" }, + }); + expect(result.type).toBe("allowed"); + if (result.type === "allowed") { + expect(result.cosmetics.effects?.atom).toEqual({ + name: "atom_boom", + effectType: "nukeExplosion", + }); + } + }); + + test("rejects a nuke-explosion effect in a mismatched nukeType slot", () => { + const result = effectChecker.isAllowed(["effect:*"], { + effects: { hydro: "atom_boom" }, + }); + expect(result.type).toBe("forbidden"); + if (result.type === "forbidden") { + expect(result.reason).toMatch(/not found for slot hydro/); + } + }); + test("rejects effect under an unknown effectType key", () => { const result = effectChecker.isAllowed(["effect:*"], { effects: { wrongType: "spectrum" }, diff --git a/tests/UserSettings.test.ts b/tests/UserSettings.test.ts index c475b6255..b70aaa9ad 100644 --- a/tests/UserSettings.test.ts +++ b/tests/UserSettings.test.ts @@ -45,4 +45,16 @@ describe("UserSettings effect selection", () => { localStorage.setItem(EFFECTS_KEY, "not json"); expect(new UserSettings().getSelectedEffects()).toEqual({}); }); + + it("keeps per-nukeType nuke-explosion slots independent", () => { + const s = new UserSettings(); + s.setSelectedEffectName("atom", "atom_boom"); + s.setSelectedEffectName("hydro", "hydro_boom"); + expect(s.getSelectedEffectName("atom")).toBe("atom_boom"); + expect(s.getSelectedEffectName("hydro")).toBe("hydro_boom"); + // Clearing one bomb's slot leaves the others intact. + s.setSelectedEffectName("atom", undefined); + expect(s.getSelectedEffectName("atom")).toBeNull(); + expect(s.getSelectedEffectName("hydro")).toBe("hydro_boom"); + }); });