feat: nuke-explosion cosmetic effects (per-bomb-type shockwave customization) (#4485)

## 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 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-02 14:21:01 -07:00
committed by GitHub
parent 006f1690a5
commit 6ff202afb5
21 changed files with 1103 additions and 128 deletions
+6
View File
@@ -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"
}
+14 -20
View File
@@ -5,8 +5,7 @@ import {
Cosmetics,
CosmeticsSchema,
Effect,
EffectType,
findEffect,
findEffectForSlot,
Flag,
Pack,
Pattern,
@@ -646,16 +645,17 @@ export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
}
}
// 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<string, string> = {};
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<PlayerCosmeticRefs> {
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<PlayerCosmetics> {
if (refs.effects && cosmetics) {
const effects: Record<string, PlayerEffect> = {};
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;
+8 -6
View File
@@ -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<TrailEffectAttributes | null> {
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;
}
+101 -6
View File
@@ -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<Record<string, NukeExplosionType>> = {
[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<GameView["playerBySmallID"]>;
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;
+11 -3
View File
@@ -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 <trail-swatch>
import "./EffectPreview"; // registers <trail-swatch> + <shockwave-swatch>
import { renderPatternPreview } from "./PatternPreview";
import "./PlutoniumIcon";
@@ -186,8 +187,15 @@ export class CosmeticButton extends LitElement {
${translateText("territory_patterns.pattern.default")}
</div>`;
}
// 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`<shockwave-swatch
class="block w-full h-full"
.explosion=${c.attributes}
></shockwave-swatch>`;
}
return html`<trail-swatch
class="block w-full h-full"
.trail=${c.attributes}
+95 -1
View File
@@ -1,6 +1,9 @@
import { html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { TrailEffectAttributes } from "../../core/CosmeticSchemas";
import {
NukeExplosionAttributes,
TrailEffectAttributes,
} from "../../core/CosmeticSchemas";
// Neutral fallback when a trail has no usable colors.
const EMPTY_BG = "#444";
@@ -76,3 +79,94 @@ export class TrailSwatch extends LitElement {
this.animation = null;
}
}
// Fallback ring color when a shockwave has no usable colors (matches the
// renderer's default purple).
const DEFAULT_RING_COLOR = "#9919ff";
/**
* Preview of a nuke-explosion shockwave: a ring expanding from the center and
* fading out, looping. Mirrors the in-game semantics — loop duration is
* size / speed (clamped watchable), border thickness follows thickness/size,
* and the color cycles through the palette at transitionSpeed steps/s
* (negative = reverse).
*/
@customElement("shockwave-swatch")
export class ShockwaveSwatch extends LitElement {
@property({ attribute: false })
explosion: NukeExplosionAttributes | null = null;
private animations: Animation[] = [];
// Light DOM so the shared Tailwind classes apply.
createRenderRoot(): HTMLElement {
return this;
}
render(): TemplateResult {
return html`<div
class="w-full h-full flex items-center justify-center overflow-hidden"
>
<div data-ring class="rounded-full" style="width:85%;height:85%;"></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 ring = this.querySelector<HTMLElement>("[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 = [];
}
}
+72 -11
View File
@@ -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`<cosmetic-button
.resolved=${r}
@@ -129,17 +141,44 @@ export class EffectsGrid extends LitElement {
></cosmetic-button>`;
}
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`<cosmetic-button
.resolved=${r}
.selected=${isSelected}
.onSelect=${() => this.select(effectType, name)}
.onSelect=${() => this.select(slot, name)}
></cosmetic-button>`;
}
// 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`
<div class="flex items-center justify-center gap-2 px-4 pt-3">
${NUKE_EXPLOSION_TYPES.map((nt) => {
const active = this.activeNukeType === nt;
return html`<button
class="px-4 py-1.5 rounded-full text-xs font-black uppercase tracking-wider transition-colors ${active
? "bg-blue-600 text-white"
: "bg-white/5 text-white/50 hover:text-white/80 hover:bg-white/10"}"
@click=${() => (this.activeNukeType = nt)}
>
${translateText(`effects.nukeType.${nt}`)}
</button>`;
})}
</div>
`;
}
// 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}`)}
</h3>`}
${!activeType && s.type === "nukeExplosion"
? this.renderNukeTypeTabBar()
: nothing}
<div
class="flex flex-wrap gap-4 justify-center items-stretch content-start"
>
${s.items.map((r) => this.renderTile(s.type, r))}
${s.items.map((r) =>
this.renderTile(this.slotForTile(s.type, r), r),
)}
</div>
</div>
`,
@@ -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}`;
}
}
@@ -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,
+1 -1
View File
@@ -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);
@@ -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);
}
}
@@ -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));
}
+39
View File
@@ -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)
+7 -1
View File
@@ -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,
+1
View File
@@ -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();
+124 -2
View File
@@ -10,11 +10,15 @@ export type Skin = z.infer<typeof SkinSchema>;
export type Pack = z.infer<typeof PackSchema>;
export type Subscription = z.infer<typeof SubscriptionSchema>;
// 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<typeof EffectSchema>;
export type EffectType = z.infer<typeof EffectTypeSchema>;
// Shared by every trail effectType (transportShipTrail, nukeTrail, …).
export type TrailEffectAttributes = z.infer<typeof TrailEffectAttributesSchema>;
// Attributes of a nuke-explosion effect (a detonation FX, not a trail).
export type NukeExplosionAttributes = z.infer<
typeof NukeExplosionAttributesSchema
>;
export type PatternName = z.infer<typeof CosmeticNameSchema>;
export type Product = z.infer<typeof ProductSchema>;
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
@@ -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<Effect, { effectType: TrailEffectType }> {
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<Effect, { effectType: "nukeExplosion" }> {
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<NukeExplosionType, EffectType>;
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(),
+4 -2
View File
@@ -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(),
});
+9 -11
View File
@@ -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<string, string> {
@@ -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<string, string> {
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));
}
+9 -14
View File
@@ -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}`)
+267
View File
@@ -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();
});
});
+44
View File
@@ -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" },
+12
View File
@@ -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");
});
});