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