mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-21 12:44:50 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user