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
co-authored by Claude Fable 5
parent 006f1690a5
commit 6ff202afb5
21 changed files with 1103 additions and 128 deletions
+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}`;
}
}