mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-02 13:18:12 +00:00
feat: structures cosmetic effect (hover-shown gradient/transition recolor) (#4492)
## Description: Adds a new `structures` cosmetic effect type: an equippable effect that recolors the owner's structure icons (City, Port, Factory, Defense Post, SAM Launcher, Missile Silo) with gradient or transition color styles. The effect is **shown while the owner's territory is hovered** — structures otherwise keep their normal player colors, so the map stays readable. **Cosmetics / selection** - `StructuresEffectAttributesSchema` (`CosmeticSchemas.ts`): its own discriminated union (`gradient` / `transition`) — structurally identical to the trail attributes today, but structures aren't trails, so it's a separate schema free to diverge. - Slot = the effectType itself: `effectTypeForSlot` is generalized to map any non-nukeExplosion effect type to itself, so server privilege checks (`Privilege.ts`), client selection, and persistence all work with no per-type code. - Effects tab, Default tile, and the store preview (shared color swatch) come from `EFFECT_TYPES`; the only UI addition is the `effects.type.structures` label in `en.json`. **Rendering** - The shared per-player effect palette grows from 2 to 3 blocks (`EFFECT_PALETTE_BLOCKS`; structures = block 2, pinned by a build-breaking guard). `syncPlayerEffects` resolves the `structures` selection through the same `writeEffectEntry` used by trails. - `StructurePass` binds the effect texture plus `uTime` and `uHoverOwner` (fed from the existing `HoverHighlightController` → `setHighlightOwner` path, now forwarded to the pass). - `structure.frag.glsl` recolors the **fill only** — the border keeps the player color for ownership legibility; alt view and construction gray bypass the effect entirely. - Style semantics: - `gradient` — the palette spans each icon's diagonal once (a visible gradient across the shape), sliding one full cycle every `colorSize · 4 · count / movementSpeed` seconds (the trail-equivalent pace; world-space banding like the trail's would put a whole icon inside one band and read as a flat color) - `transition` — the whole icon is one color at a time, cross-fading at `frequency` colors/s - Glyph contrast: the inner icon's black/white decision is now a smooth luminance fade (`smoothstep(0.25, 0.45)`) instead of a hard flip at 0.25, so animated fills cross-fade the glyph instead of snapping it between black and white. ## 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:
@@ -431,6 +431,7 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"nukeExplosion": "Nuke Explosion",
|
"nukeExplosion": "Nuke Explosion",
|
||||||
"nukeTrail": "Nuke Trail",
|
"nukeTrail": "Nuke Trail",
|
||||||
|
"structures": "Structures",
|
||||||
"transportShipTrail": "Boat Trail"
|
"transportShipTrail": "Boat Trail"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
isTrailEffect,
|
isTrailEffect,
|
||||||
type NukeExplosionAttributes,
|
type NukeExplosionAttributes,
|
||||||
type NukeExplosionType,
|
type NukeExplosionType,
|
||||||
|
type StructuresEffectAttributes,
|
||||||
TRAIL_EFFECT_TYPES,
|
TRAIL_EFFECT_TYPES,
|
||||||
type TrailEffectAttributes,
|
type TrailEffectAttributes,
|
||||||
} from "../core/CosmeticSchemas";
|
} from "../core/CosmeticSchemas";
|
||||||
@@ -26,8 +27,9 @@ import {
|
|||||||
// Value import from the leaf module (not the ./render/gl barrel) so non-Vite
|
// 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.
|
// consumers don't pull in GPURenderer and its shaders — see note above.
|
||||||
import {
|
import {
|
||||||
|
EFFECT_PALETTE_BLOCKS,
|
||||||
MAX_TRAIL_COLORS,
|
MAX_TRAIL_COLORS,
|
||||||
TRAIL_EFFECT_BLOCKS,
|
STRUCTURES_EFFECT_BLOCK,
|
||||||
} from "./render/gl/utils/ColorUtils";
|
} from "./render/gl/utils/ColorUtils";
|
||||||
import {
|
import {
|
||||||
UT_ATOM_BOMB,
|
UT_ATOM_BOMB,
|
||||||
@@ -38,15 +40,18 @@ import type { GameView } from "./view";
|
|||||||
|
|
||||||
const PALETTE_SIZE = 4096;
|
const PALETTE_SIZE = 4096;
|
||||||
|
|
||||||
// TRAIL_EFFECT_TYPES order IS the effect-palette block order: index = block (rows
|
// The effect-palette block order: index = block (rows block·MAX_TRAIL_COLORS …).
|
||||||
// block·MAX_TRAIL_COLORS …). The shader (trail.frag.glsl) picks the block from
|
// trail.frag.glsl picks its block from the trail tile's nuke bit — block 0 =
|
||||||
// the trail tile's nuke bit — block 0 = transportShipTrail (nuke bit 0), block 1
|
// transportShipTrail (nuke bit 0), block 1 = nukeTrail (nuke bit 1, set by
|
||||||
// = nukeTrail (nuke bit 1, set by NUKE_TRAIL_BIT in TrailManager). Reordering
|
// NUKE_TRAIL_BIT in TrailManager) — and structure.frag.glsl reads block
|
||||||
// TRAIL_EFFECT_TYPES in CosmeticSchemas would silently swap the two trails' colors,
|
// STRUCTURES_EFFECT_BLOCK (2). Reordering TRAIL_EFFECT_TYPES in CosmeticSchemas
|
||||||
// so this guard fails the build if the shader-coupled order ever drifts.
|
// (or moving the structures block) would silently swap effect colors, so these
|
||||||
|
// guards fail the build if the shader-coupled order ever drifts.
|
||||||
const _EFFECT_BLOCK_ORDER: readonly ["transportShipTrail", "nukeTrail"] =
|
const _EFFECT_BLOCK_ORDER: readonly ["transportShipTrail", "nukeTrail"] =
|
||||||
TRAIL_EFFECT_TYPES;
|
TRAIL_EFFECT_TYPES;
|
||||||
void _EFFECT_BLOCK_ORDER;
|
void _EFFECT_BLOCK_ORDER;
|
||||||
|
const _STRUCTURES_BLOCK_IS_2: 2 = STRUCTURES_EFFECT_BLOCK;
|
||||||
|
void _STRUCTURES_BLOCK_IS_2;
|
||||||
|
|
||||||
// Attribute → render-param mappings:
|
// Attribute → render-param mappings:
|
||||||
// size = the ring's final WIDTH (diameter) in world tiles when it fades
|
// size = the ring's final WIDTH (diameter) in world tiles when it fades
|
||||||
@@ -106,10 +111,11 @@ function attributesToExplosionParams(
|
|||||||
*/
|
*/
|
||||||
export class WebGLFrameBuilder {
|
export class WebGLFrameBuilder {
|
||||||
private readonly palette: Float32Array;
|
private readonly palette: Float32Array;
|
||||||
// Per-player trail-effect palette, keyed by smallID. Layout is
|
// Per-player effect palette, keyed by smallID. Layout is
|
||||||
// 4096×(MAX_TRAIL_COLORS·TRAIL_EFFECT_BLOCKS): block 0 (rows 0–7) =
|
// 4096×(MAX_TRAIL_COLORS·EFFECT_PALETTE_BLOCKS): block 0 (rows 0–7) =
|
||||||
// transportShipTrail, block 1 (rows 8–15) = nukeTrail. Consumed by TrailPass's
|
// transportShipTrail, block 1 (rows 8–15) = nukeTrail, block 2 (rows 16–23)
|
||||||
// effect texture; the shader picks the block from the trail tile's nuke bit.
|
// = structures. Consumed by TrailPass (block from the trail tile's nuke bit)
|
||||||
|
// and StructurePass (block 2).
|
||||||
private readonly effectPalette: Float32Array;
|
private readonly effectPalette: Float32Array;
|
||||||
private readonly patternMeta: Float32Array;
|
private readonly patternMeta: Float32Array;
|
||||||
private readonly patternData: Uint8Array;
|
private readonly patternData: Uint8Array;
|
||||||
@@ -140,7 +146,7 @@ export class WebGLFrameBuilder {
|
|||||||
constructor(private readonly view: MapRenderer) {
|
constructor(private readonly view: MapRenderer) {
|
||||||
this.palette = new Float32Array(PALETTE_SIZE * 2 * 4);
|
this.palette = new Float32Array(PALETTE_SIZE * 2 * 4);
|
||||||
this.effectPalette = new Float32Array(
|
this.effectPalette = new Float32Array(
|
||||||
PALETTE_SIZE * MAX_TRAIL_COLORS * TRAIL_EFFECT_BLOCKS * 4,
|
PALETTE_SIZE * MAX_TRAIL_COLORS * EFFECT_PALETTE_BLOCKS * 4,
|
||||||
);
|
);
|
||||||
this.patternMeta = new Float32Array(PALETTE_SIZE * 4);
|
this.patternMeta = new Float32Array(PALETTE_SIZE * 4);
|
||||||
this.patternData = new Uint8Array(PALETTE_SIZE * 1024);
|
this.patternData = new Uint8Array(PALETTE_SIZE * 1024);
|
||||||
@@ -407,16 +413,21 @@ export class WebGLFrameBuilder {
|
|||||||
if (this.effectResolved.has(smallID)) continue;
|
if (this.effectResolved.has(smallID)) continue;
|
||||||
this.effectResolved.add(smallID);
|
this.effectResolved.add(smallID);
|
||||||
|
|
||||||
// Resolve each trail effectType into its own block of the effect palette.
|
// Resolve each trail-styled effectType into its own block of the effect
|
||||||
// rowBase block*MAX_TRAIL_COLORS must match the shader's block layout
|
// palette. rowBase block*MAX_TRAIL_COLORS must match the consumer
|
||||||
// (ship=0, nuke=1) — see _EFFECT_BLOCK_ORDER above and trail.frag.glsl.
|
// shaders' block layout (ship=0, nuke=1 in trail.frag.glsl; structures=2
|
||||||
// Only trail effect types render here; nukeExplosion is not a trail.
|
// in structure.frag.glsl) — see _EFFECT_BLOCK_ORDER above. nukeExplosion
|
||||||
TRAIL_EFFECT_TYPES.forEach((effectType, block) => {
|
// is not trail-styled and renders through the FX pass instead.
|
||||||
|
const blockOrder = [...TRAIL_EFFECT_TYPES, "structures"] as const;
|
||||||
|
blockOrder.forEach((effectType, block) => {
|
||||||
const selected = p.cosmetics.effects?.[effectType];
|
const selected = p.cosmetics.effects?.[effectType];
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
const effect = findEffect(catalog, effectType, selected.name);
|
const effect = findEffect(catalog, effectType, selected.name);
|
||||||
if (!effect || effect.effectType !== effectType) return;
|
if (!effect || effect.effectType !== effectType) return;
|
||||||
if (!isTrailEffect(effect)) return; // narrows attributes to trail attrs
|
// Narrows attributes to trail attrs (structures share the shape).
|
||||||
|
if (!isTrailEffect(effect) && effect.effectType !== "structures") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const rowBase = block * MAX_TRAIL_COLORS;
|
const rowBase = block * MAX_TRAIL_COLORS;
|
||||||
if (this.writeEffectEntry(smallID, effect.attributes, rowBase)) {
|
if (this.writeEffectEntry(smallID, effect.attributes, rowBase)) {
|
||||||
dirty = true;
|
dirty = true;
|
||||||
@@ -427,9 +438,9 @@ export class WebGLFrameBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encode a player's trail effect into one block of the effect palette. The
|
* Encode a player's trail-styled effect into one block of the effect palette.
|
||||||
* block starts at row `rowBase` (0 = transportShipTrail, MAX_TRAIL_COLORS =
|
* The block starts at row `rowBase` (block · MAX_TRAIL_COLORS; see
|
||||||
* nukeTrail). Within the block, row r holds color r's rgb, and the spare alpha
|
* _EFFECT_BLOCK_ORDER). Within the block, row r holds color r's rgb, and the spare alpha
|
||||||
* channels (rows rowBase+0..3 always exist) carry the scalar params —
|
* channels (rows rowBase+0..3 always exist) carry the scalar params —
|
||||||
* row 0.a = color count (0 → the shader falls back to the territory color),
|
* row 0.a = color count (0 → the shader falls back to the territory color),
|
||||||
* row 1.a = styleId (0 = gradient, 1 = transition),
|
* row 1.a = styleId (0 = gradient, 1 = transition),
|
||||||
@@ -441,7 +452,7 @@ export class WebGLFrameBuilder {
|
|||||||
*/
|
*/
|
||||||
private writeEffectEntry(
|
private writeEffectEntry(
|
||||||
smallID: number,
|
smallID: number,
|
||||||
attrs: TrailEffectAttributes,
|
attrs: TrailEffectAttributes | StructuresEffectAttributes,
|
||||||
rowBase: number,
|
rowBase: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
const colors = attrs.colors
|
const colors = attrs.colors
|
||||||
|
|||||||
@@ -188,8 +188,9 @@ export class CosmeticButton extends LitElement {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
// Nuke explosions preview per visual type (expanding ring or sparkle
|
// Nuke explosions preview per visual type (expanding ring or sparkle
|
||||||
// burst); every trail effectType (transportShipTrail, nukeTrail) shares
|
// burst); every trail effectType (transportShipTrail, nukeTrail) and the
|
||||||
// the same attributes shape and previews as a color swatch.
|
// structures effect share the same attribute shapes and preview as a
|
||||||
|
// color swatch.
|
||||||
if (isNukeExplosionEffect(c)) {
|
if (isNukeExplosionEffect(c)) {
|
||||||
if (c.attributes.type === "sparkles") {
|
if (c.attributes.type === "sparkles") {
|
||||||
return html`<sparkles-swatch
|
return html`<sparkles-swatch
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { html, LitElement, TemplateResult } from "lit";
|
|||||||
import { customElement, property } from "lit/decorators.js";
|
import { customElement, property } from "lit/decorators.js";
|
||||||
import {
|
import {
|
||||||
NukeExplosionAttributes,
|
NukeExplosionAttributes,
|
||||||
|
StructuresEffectAttributes,
|
||||||
TrailEffectAttributes,
|
TrailEffectAttributes,
|
||||||
} from "../../core/CosmeticSchemas";
|
} from "../../core/CosmeticSchemas";
|
||||||
|
|
||||||
@@ -9,7 +10,8 @@ import {
|
|||||||
const EMPTY_BG = "#444";
|
const EMPTY_BG = "#444";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Swatch preview of a transport-ship-trail effect, filling its container.
|
* Swatch preview of a trail-styled effect (trails and the structures effect
|
||||||
|
* share the same gradient/transition attribute shapes), filling its container.
|
||||||
*
|
*
|
||||||
* - gradient / single color: a static swatch (flat color or left-to-right
|
* - gradient / single color: a static swatch (flat color or left-to-right
|
||||||
* gradient — a multi-color list reads as a rainbow).
|
* gradient — a multi-color list reads as a rainbow).
|
||||||
@@ -20,7 +22,7 @@ const EMPTY_BG = "#444";
|
|||||||
export class TrailSwatch extends LitElement {
|
export class TrailSwatch extends LitElement {
|
||||||
// Named `trail` (not `attributes`) to avoid clashing with Element.attributes.
|
// Named `trail` (not `attributes`) to avoid clashing with Element.attributes.
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
trail: TrailEffectAttributes | null = null;
|
trail: TrailEffectAttributes | StructuresEffectAttributes | null = null;
|
||||||
|
|
||||||
private animation: Animation | null = null;
|
private animation: Animation | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -61,10 +61,10 @@ import { WorldTextPass } from "./passes/WorldTextPass";
|
|||||||
import type { RenderSettings } from "./RenderSettings";
|
import type { RenderSettings } from "./RenderSettings";
|
||||||
import { AffiliationPalette } from "./utils/Affiliation";
|
import { AffiliationPalette } from "./utils/Affiliation";
|
||||||
import {
|
import {
|
||||||
|
EFFECT_PALETTE_BLOCKS,
|
||||||
getPaletteSize,
|
getPaletteSize,
|
||||||
hexToRgb,
|
hexToRgb,
|
||||||
MAX_TRAIL_COLORS,
|
MAX_TRAIL_COLORS,
|
||||||
TRAIL_EFFECT_BLOCKS,
|
|
||||||
} from "./utils/ColorUtils";
|
} from "./utils/ColorUtils";
|
||||||
import { renderDpr } from "./utils/Dpr";
|
import { renderDpr } from "./utils/Dpr";
|
||||||
import {
|
import {
|
||||||
@@ -267,10 +267,11 @@ export class GPURenderer {
|
|||||||
filter: gl.NEAREST,
|
filter: gl.NEAREST,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Per-player trail-effect texture: TRAIL_EFFECT_BLOCKS stacked blocks of
|
// Per-player effect texture: EFFECT_PALETTE_BLOCKS stacked blocks of
|
||||||
// MAX_TRAIL_COLORS rows (block 0 = transportShipTrail, block 1 = nukeTrail).
|
// MAX_TRAIL_COLORS rows (block 0 = transportShipTrail, block 1 = nukeTrail,
|
||||||
// Starts zeroed (color count 0 everywhere = no effect → territory color).
|
// block 2 = structures). Starts zeroed (color count 0 everywhere = no
|
||||||
const effectRows = MAX_TRAIL_COLORS * TRAIL_EFFECT_BLOCKS;
|
// effect → territory/player color).
|
||||||
|
const effectRows = MAX_TRAIL_COLORS * EFFECT_PALETTE_BLOCKS;
|
||||||
this.effectTex = createTexture2D(gl, {
|
this.effectTex = createTexture2D(gl, {
|
||||||
width: palW,
|
width: palW,
|
||||||
height: effectRows,
|
height: effectRows,
|
||||||
@@ -511,6 +512,7 @@ export class GPURenderer {
|
|||||||
gl,
|
gl,
|
||||||
header,
|
header,
|
||||||
this.paletteTex,
|
this.paletteTex,
|
||||||
|
this.effectTex,
|
||||||
this.settings,
|
this.settings,
|
||||||
);
|
);
|
||||||
this.structureLevelPass = new StructureLevelPass(gl, header, this.settings);
|
this.structureLevelPass = new StructureLevelPass(gl, header, this.settings);
|
||||||
@@ -676,7 +678,7 @@ export class GPURenderer {
|
|||||||
this.namePass.refreshPlayerColors(this.paletteData);
|
this.namePass.refreshPlayerColors(this.paletteData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-upload the per-player trail-effect texture (style + colors by smallID). */
|
/** Re-upload the per-player effect texture (style + colors by smallID). */
|
||||||
updateEffectPalette(effectData: Float32Array): void {
|
updateEffectPalette(effectData: Float32Array): void {
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
gl.activeTexture(gl.TEXTURE0);
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
@@ -687,7 +689,7 @@ export class GPURenderer {
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
getPaletteSize(),
|
getPaletteSize(),
|
||||||
MAX_TRAIL_COLORS * TRAIL_EFFECT_BLOCKS,
|
MAX_TRAIL_COLORS * EFFECT_PALETTE_BLOCKS,
|
||||||
gl.RGBA,
|
gl.RGBA,
|
||||||
gl.FLOAT,
|
gl.FLOAT,
|
||||||
effectData,
|
effectData,
|
||||||
@@ -980,6 +982,7 @@ export class GPURenderer {
|
|||||||
this.borderPass.setHighlightOwner(ownerID);
|
this.borderPass.setHighlightOwner(ownerID);
|
||||||
this.territoryPass.setHighlightOwner(ownerID);
|
this.territoryPass.setHighlightOwner(ownerID);
|
||||||
this.namePass.setHighlightOwner(ownerID);
|
this.namePass.setHighlightOwner(ownerID);
|
||||||
|
this.structurePass.setHighlightOwner(ownerID);
|
||||||
}
|
}
|
||||||
setMouseWorldPos(x: number, y: number): void {
|
setMouseWorldPos(x: number, y: number): void {
|
||||||
this.namePass.setMouseWorldPos(x, y);
|
this.namePass.setMouseWorldPos(x, y);
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ import {
|
|||||||
} from "../../types";
|
} from "../../types";
|
||||||
import { DynamicInstanceBuffer } from "../DynamicBuffer";
|
import { DynamicInstanceBuffer } from "../DynamicBuffer";
|
||||||
import type { RenderSettings } from "../RenderSettings";
|
import type { RenderSettings } from "../RenderSettings";
|
||||||
import { getPaletteSize } from "../utils/ColorUtils";
|
import {
|
||||||
|
getPaletteSize,
|
||||||
|
MAX_TRAIL_COLORS,
|
||||||
|
STRUCTURES_EFFECT_BLOCK,
|
||||||
|
} from "../utils/ColorUtils";
|
||||||
import { createProgram, shaderSrc } from "../utils/GlUtils";
|
import { createProgram, shaderSrc } from "../utils/GlUtils";
|
||||||
|
|
||||||
import { assetUrl } from "src/core/AssetUrls";
|
import { assetUrl } from "src/core/AssetUrls";
|
||||||
@@ -92,12 +96,19 @@ export class StructurePass {
|
|||||||
private uIconAlpha: WebGLUniformLocation;
|
private uIconAlpha: WebGLUniformLocation;
|
||||||
private uIconColor: WebGLUniformLocation;
|
private uIconColor: WebGLUniformLocation;
|
||||||
private uIconDarken: WebGLUniformLocation;
|
private uIconDarken: WebGLUniformLocation;
|
||||||
|
private uTime: WebGLUniformLocation;
|
||||||
|
private uHoverOwner: WebGLUniformLocation;
|
||||||
|
// Anchored at construction so the uniform stays small (float32 precision).
|
||||||
|
private readonly startTime = performance.now();
|
||||||
|
/** smallID of the hovered territory's owner (0 = none) — gates the effect. */
|
||||||
|
private hoverOwner = 0;
|
||||||
|
|
||||||
private vao: WebGLVertexArrayObject;
|
private vao: WebGLVertexArrayObject;
|
||||||
private instanceBuf: DynamicInstanceBuffer;
|
private instanceBuf: DynamicInstanceBuffer;
|
||||||
private ghostInstanceBuf: WebGLBuffer;
|
private ghostInstanceBuf: WebGLBuffer;
|
||||||
|
|
||||||
private paletteTex: WebGLTexture;
|
private paletteTex: WebGLTexture;
|
||||||
|
private effectTex: WebGLTexture;
|
||||||
private atlasTex: WebGLTexture;
|
private atlasTex: WebGLTexture;
|
||||||
private affiliationTex: WebGLTexture | null = null;
|
private affiliationTex: WebGLTexture | null = null;
|
||||||
private altView = false;
|
private altView = false;
|
||||||
@@ -120,12 +131,14 @@ export class StructurePass {
|
|||||||
gl: WebGL2RenderingContext,
|
gl: WebGL2RenderingContext,
|
||||||
header: RendererConfig,
|
header: RendererConfig,
|
||||||
paletteTex: WebGLTexture,
|
paletteTex: WebGLTexture,
|
||||||
|
effectTex: WebGLTexture,
|
||||||
settings: RenderSettings,
|
settings: RenderSettings,
|
||||||
) {
|
) {
|
||||||
this.gl = gl;
|
this.gl = gl;
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.mapW = header.mapWidth;
|
this.mapW = header.mapWidth;
|
||||||
this.paletteTex = paletteTex;
|
this.paletteTex = paletteTex;
|
||||||
|
this.effectTex = effectTex;
|
||||||
|
|
||||||
// Build unitType string → atlas column mapping
|
// Build unitType string → atlas column mapping
|
||||||
for (let i = 0; i < header.unitTypes.length; i++) {
|
for (let i = 0; i < header.unitTypes.length; i++) {
|
||||||
@@ -144,6 +157,8 @@ export class StructurePass {
|
|||||||
shaderSrc(structureFragSrc, {
|
shaderSrc(structureFragSrc, {
|
||||||
PALETTE_SIZE: getPaletteSize(),
|
PALETTE_SIZE: getPaletteSize(),
|
||||||
ATLAS_COLS,
|
ATLAS_COLS,
|
||||||
|
// First row of the structures block in the shared effect palette.
|
||||||
|
STRUCT_EFFECT_ROW_BASE: STRUCTURES_EFFECT_BLOCK * MAX_TRAIL_COLORS,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
this.uLocalPlayerID = gl.getUniformLocation(
|
this.uLocalPlayerID = gl.getUniformLocation(
|
||||||
@@ -182,12 +197,15 @@ export class StructurePass {
|
|||||||
this.uIconAlpha = gl.getUniformLocation(this.program, "uIconAlpha")!;
|
this.uIconAlpha = gl.getUniformLocation(this.program, "uIconAlpha")!;
|
||||||
this.uIconColor = gl.getUniformLocation(this.program, "uIconColor")!;
|
this.uIconColor = gl.getUniformLocation(this.program, "uIconColor")!;
|
||||||
this.uIconDarken = gl.getUniformLocation(this.program, "uIconDarken")!;
|
this.uIconDarken = gl.getUniformLocation(this.program, "uIconDarken")!;
|
||||||
|
this.uTime = gl.getUniformLocation(this.program, "uTime")!;
|
||||||
|
this.uHoverOwner = gl.getUniformLocation(this.program, "uHoverOwner")!;
|
||||||
|
|
||||||
// Texture unit bindings + ghost defaults
|
// Texture unit bindings + ghost defaults
|
||||||
gl.useProgram(this.program);
|
gl.useProgram(this.program);
|
||||||
gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 0);
|
gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 0);
|
||||||
gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 1);
|
gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 1);
|
||||||
gl.uniform1i(gl.getUniformLocation(this.program, "uAffiliation"), 2);
|
gl.uniform1i(gl.getUniformLocation(this.program, "uAffiliation"), 2);
|
||||||
|
gl.uniform1i(gl.getUniformLocation(this.program, "uEffect"), 3);
|
||||||
gl.uniform1f(this.uGhostAlpha, 1.0);
|
gl.uniform1f(this.uGhostAlpha, 1.0);
|
||||||
gl.uniform3f(this.uOutlineColor, 0, 0, 0);
|
gl.uniform3f(this.uOutlineColor, 0, 0, 0);
|
||||||
gl.uniform1i(this.uHighlightMask, 0);
|
gl.uniform1i(this.uHighlightMask, 0);
|
||||||
@@ -280,6 +298,11 @@ export class StructurePass {
|
|||||||
this.localPlayerID = smallID;
|
this.localPlayerID = smallID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hovered territory's owner (0 = none) — shows that player's structures effect. */
|
||||||
|
setHighlightOwner(ownerID: number): void {
|
||||||
|
this.hoverOwner = ownerID;
|
||||||
|
}
|
||||||
|
|
||||||
updateStructures(units: Map<number, UnitState>): void {
|
updateStructures(units: Map<number, UnitState>): void {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
@@ -384,6 +407,9 @@ export class StructurePass {
|
|||||||
gl.uniform1f(this.uIconAlpha, ss.iconAlpha);
|
gl.uniform1f(this.uIconAlpha, ss.iconAlpha);
|
||||||
gl.uniform3f(this.uIconColor, ss.iconR, ss.iconG, ss.iconB);
|
gl.uniform3f(this.uIconColor, ss.iconR, ss.iconG, ss.iconB);
|
||||||
gl.uniform1f(this.uIconDarken, ss.iconDarken);
|
gl.uniform1f(this.uIconDarken, ss.iconDarken);
|
||||||
|
// Seconds; drives the structures-effect gradient scroll / transition cycle.
|
||||||
|
gl.uniform1f(this.uTime, (performance.now() - this.startTime) / 1000);
|
||||||
|
gl.uniform1f(this.uHoverOwner, this.hoverOwner);
|
||||||
|
|
||||||
gl.activeTexture(gl.TEXTURE0);
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
gl.bindTexture(gl.TEXTURE_2D, this.paletteTex);
|
gl.bindTexture(gl.TEXTURE_2D, this.paletteTex);
|
||||||
@@ -396,6 +422,9 @@ export class StructurePass {
|
|||||||
gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex);
|
gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gl.activeTexture(gl.TEXTURE3);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, this.effectTex);
|
||||||
|
|
||||||
gl.bindVertexArray(this.vao);
|
gl.bindVertexArray(this.vao);
|
||||||
|
|
||||||
// --- Real structures ---
|
// --- Real structures ---
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ precision highp float;
|
|||||||
uniform sampler2D uPalette;
|
uniform sampler2D uPalette;
|
||||||
uniform sampler2D uAtlas;
|
uniform sampler2D uAtlas;
|
||||||
uniform sampler2D uAffiliation; // 256×2 RGBA8 — row 1 = unit affiliation
|
uniform sampler2D uAffiliation; // 256×2 RGBA8 — row 1 = unit affiliation
|
||||||
|
uniform sampler2D uEffect; // RGBA32F — shared effect palette, keyed by
|
||||||
|
// ownerID. The structures block starts at row
|
||||||
|
// STRUCT_EFFECT_ROW_BASE; same layout as
|
||||||
|
// trail.frag.glsl (row r = color r's rgb;
|
||||||
|
// row 0.a = count, 1.a = styleId,
|
||||||
|
// 2.a = scalar0, 3.a = scalar1)
|
||||||
|
uniform float uTime; // seconds, for animated effect styles
|
||||||
|
uniform float uHoverOwner; // smallID of the hovered territory's owner
|
||||||
|
// (0 = none) — gates the structures effect
|
||||||
uniform float uDotsThreshold;
|
uniform float uDotsThreshold;
|
||||||
uniform float uGhostAlpha; // 1.0 = normal, <1.0 = ghost transparency
|
uniform float uGhostAlpha; // 1.0 = normal, <1.0 = ghost transparency
|
||||||
uniform vec3 uOutlineColor; // ghost outline color (vec3(0) = no outline)
|
uniform vec3 uOutlineColor; // ghost outline color (vec3(0) = no outline)
|
||||||
@@ -49,6 +58,49 @@ vec3 darken(vec3 rgb, float vScale) {
|
|||||||
return hsv2rgb(hsv);
|
return hsv2rgb(hsv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The owner's structures cosmetic color, if equipped. Reads the structures
|
||||||
|
// block of the shared effect palette; the gradient/transition math mirrors
|
||||||
|
// trail.frag.glsl so the same catalog attributes look identical on both.
|
||||||
|
// Returns false when the owner has no structures effect (count 0).
|
||||||
|
bool structuresEffectColor(int owner, out vec3 color) {
|
||||||
|
const int rowBase = STRUCT_EFFECT_ROW_BASE;
|
||||||
|
int count = int(texelFetch(uEffect, ivec2(owner, rowBase), 0).a + 0.5);
|
||||||
|
if (count <= 0) return false;
|
||||||
|
if (count == 1) {
|
||||||
|
// Single color — flat recolor.
|
||||||
|
color = texelFetch(uEffect, ivec2(owner, rowBase), 0).rgb;
|
||||||
|
} else if (int(texelFetch(uEffect, ivec2(owner, rowBase + 1), 0).a + 0.5) == 1) {
|
||||||
|
// transition — one color at a time, cross-fading through the list.
|
||||||
|
// frequency = color changes per second.
|
||||||
|
float frequency = texelFetch(uEffect, ivec2(owner, rowBase + 2), 0).a;
|
||||||
|
float t = uTime * frequency;
|
||||||
|
int i = int(t) % count;
|
||||||
|
int j = (i + 1) % count;
|
||||||
|
vec3 a = texelFetch(uEffect, ivec2(owner, rowBase + i), 0).rgb;
|
||||||
|
vec3 b = texelFetch(uEffect, ivec2(owner, rowBase + j), 0).rgb;
|
||||||
|
color = mix(a, b, fract(t));
|
||||||
|
} else {
|
||||||
|
// gradient — the palette spans the icon's diagonal once, so the whole
|
||||||
|
// gradient is visible across the shape (world-space banding like the
|
||||||
|
// trail's would put the entire icon inside one band and read as a flat
|
||||||
|
// color). It scrolls along the diagonal at the trail-equivalent pace:
|
||||||
|
// one full slide every colorSize · 4 · count / movementSpeed seconds,
|
||||||
|
// so both knobs keep their trail timing semantics.
|
||||||
|
float colorSize = max(texelFetch(uEffect, ivec2(owner, rowBase + 2), 0).a, 0.001);
|
||||||
|
float movementSpeed = texelFetch(uEffect, ivec2(owner, rowBase + 3), 0).a;
|
||||||
|
float dn = (vLocalPos.x + vLocalPos.y) * 0.5; // icon diagonal, -0.5..0.5
|
||||||
|
float phase =
|
||||||
|
fract(dn - uTime * movementSpeed / (colorSize * 4.0 * float(count)));
|
||||||
|
float f = phase * float(count);
|
||||||
|
int i = int(f) % count;
|
||||||
|
int j = (i + 1) % count;
|
||||||
|
vec3 a = texelFetch(uEffect, ivec2(owner, rowBase + i), 0).rgb;
|
||||||
|
vec3 b = texelFetch(uEffect, ivec2(owner, rowBase + j), 0).rgb;
|
||||||
|
color = mix(a, b, fract(f));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
#define PI 3.14159265
|
#define PI 3.14159265
|
||||||
|
|
||||||
// Signed distance to regular polygon edge.
|
// Signed distance to regular polygon edge.
|
||||||
@@ -119,6 +171,22 @@ void main() {
|
|||||||
borderColor.a = 1.0;
|
borderColor.a = 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// structures cosmetic: while the owner's territory is hovered, recolor the
|
||||||
|
// fill with their effect (raw catalog colors, like trails — no darken). The
|
||||||
|
// border keeps the player color so ownership stays readable. Skipped for
|
||||||
|
// alt view and construction gray.
|
||||||
|
bool effectActive = false;
|
||||||
|
if (uAltView == 0 && vUnderConstruction < 0.5) {
|
||||||
|
int effOwner = int(vOwnerID + 0.5);
|
||||||
|
if (effOwner == int(uHoverOwner + 0.5) && effOwner > 0) {
|
||||||
|
vec3 effectRGB;
|
||||||
|
if (structuresEffectColor(effOwner, effectRGB)) {
|
||||||
|
fillColor.rgb = effectRGB;
|
||||||
|
effectActive = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
vec4 bgColor = mix(borderColor, fillColor, borderMask);
|
vec4 bgColor = mix(borderColor, fillColor, borderMask);
|
||||||
|
|
||||||
// Sample icon from atlas (white on transparent)
|
// Sample icon from atlas (white on transparent)
|
||||||
@@ -143,10 +211,20 @@ void main() {
|
|||||||
// color. When the shape itself is already dark, that darkened glyph blends
|
// color. When the shape itself is already dark, that darkened glyph blends
|
||||||
// into the shape (and the dark territory behind it) and becomes unreadable —
|
// into the shape (and the dark territory behind it) and becomes unreadable —
|
||||||
// so flip the glyph to the light icon color when the fill is too dark.
|
// so flip the glyph to the light icon color when the fill is too dark.
|
||||||
|
// While the structures effect is animating the fill, the flip becomes a
|
||||||
|
// smooth luminance fade so the glyph cross-fades instead of snapping;
|
||||||
|
// without the effect this is the classic hard threshold, pixel-identical
|
||||||
|
// to having no cosmetic equipped.
|
||||||
vec3 glyphColor = uIconColor;
|
vec3 glyphColor = uIconColor;
|
||||||
if (uIconDarken > 0.0) {
|
if (uIconDarken > 0.0) {
|
||||||
float fillLum = dot(fillColor.rgb, vec3(0.299, 0.587, 0.114));
|
float fillLum = dot(fillColor.rgb, vec3(0.299, 0.587, 0.114));
|
||||||
glyphColor = fillLum < 0.25 ? uIconColor : darken(fillColor.rgb, uIconDarken);
|
if (effectActive) {
|
||||||
|
float t = smoothstep(0.25, 0.45, fillLum); // 0 = dark fill → light glyph
|
||||||
|
glyphColor = mix(uIconColor, darken(fillColor.rgb, uIconDarken), t);
|
||||||
|
} else {
|
||||||
|
glyphColor =
|
||||||
|
fillLum < 0.25 ? uIconColor : darken(fillColor.rgb, uIconDarken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
vec3 finalRGB = mix(bgColor.rgb, glyphColor, iconAlpha);
|
vec3 finalRGB = mix(bgColor.rgb, glyphColor, iconAlpha);
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ export function getPaletteSize(): number {
|
|||||||
export const MAX_TRAIL_COLORS = 8;
|
export const MAX_TRAIL_COLORS = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The trail-effect texture stacks one MAX_TRAIL_COLORS-row block per trail
|
* The effect-palette texture stacks one MAX_TRAIL_COLORS-row block per
|
||||||
* effectType: block 0 = transportShipTrail, block 1 = nukeTrail (matching the
|
* trail-styled effectType: block 0 = transportShipTrail, block 1 = nukeTrail
|
||||||
* nuke bit in trail.frag.glsl). Bump this if another trailed effectType is added
|
* (matching the nuke bit in trail.frag.glsl), block 2 = structures (read by
|
||||||
* (and add its rowBase branch to the shader).
|
* structure.frag.glsl). Bump this if another trail-styled effectType is added
|
||||||
|
* (and give its consumer shader the new rowBase).
|
||||||
*/
|
*/
|
||||||
export const TRAIL_EFFECT_BLOCKS = 2;
|
export const EFFECT_PALETTE_BLOCKS = 3;
|
||||||
|
|
||||||
|
/** Block index of the structures effect within the effect-palette texture. */
|
||||||
|
export const STRUCTURES_EFFECT_BLOCK = 2;
|
||||||
|
|
||||||
// ---------- Terrain ----------
|
// ---------- Terrain ----------
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export type Skin = z.infer<typeof SkinSchema>;
|
|||||||
export type Pack = z.infer<typeof PackSchema>;
|
export type Pack = z.infer<typeof PackSchema>;
|
||||||
export type Subscription = z.infer<typeof SubscriptionSchema>;
|
export type Subscription = z.infer<typeof SubscriptionSchema>;
|
||||||
// An effect cosmetic of any type — discriminated on effectType (today
|
// An effect cosmetic of any type — discriminated on effectType (today
|
||||||
// transportShipTrail + nukeTrail + nukeExplosion; gains a member per effectType).
|
// transportShipTrail + nukeTrail + nukeExplosion + structures; gains a member
|
||||||
|
// per effectType).
|
||||||
export type Effect = z.infer<typeof EffectSchema>;
|
export type Effect = z.infer<typeof EffectSchema>;
|
||||||
export type EffectType = z.infer<typeof EffectTypeSchema>;
|
export type EffectType = z.infer<typeof EffectTypeSchema>;
|
||||||
// Shared by every trail effectType (transportShipTrail, nukeTrail, …).
|
// Shared by every trail effectType (transportShipTrail, nukeTrail, …).
|
||||||
@@ -19,6 +20,10 @@ export type TrailEffectAttributes = z.infer<typeof TrailEffectAttributesSchema>;
|
|||||||
export type NukeExplosionAttributes = z.infer<
|
export type NukeExplosionAttributes = z.infer<
|
||||||
typeof NukeExplosionAttributesSchema
|
typeof NukeExplosionAttributesSchema
|
||||||
>;
|
>;
|
||||||
|
// Attributes of a structures effect (recolors structure icons, not a trail).
|
||||||
|
export type StructuresEffectAttributes = z.infer<
|
||||||
|
typeof StructuresEffectAttributesSchema
|
||||||
|
>;
|
||||||
export type PatternName = z.infer<typeof CosmeticNameSchema>;
|
export type PatternName = z.infer<typeof CosmeticNameSchema>;
|
||||||
export type Product = z.infer<typeof ProductSchema>;
|
export type Product = z.infer<typeof ProductSchema>;
|
||||||
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
|
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
|
||||||
@@ -106,6 +111,7 @@ export const EFFECT_TYPES = [
|
|||||||
"transportShipTrail",
|
"transportShipTrail",
|
||||||
"nukeTrail",
|
"nukeTrail",
|
||||||
"nukeExplosion",
|
"nukeExplosion",
|
||||||
|
"structures",
|
||||||
] as const;
|
] as const;
|
||||||
export const EffectTypeSchema = z.enum(EFFECT_TYPES);
|
export const EffectTypeSchema = z.enum(EFFECT_TYPES);
|
||||||
|
|
||||||
@@ -199,11 +205,45 @@ const NukeExplosionEffectSchema = CosmeticSchema.extend({
|
|||||||
url: z.string().optional(),
|
url: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Structures-effect attributes, discriminated on `type`. Structurally the
|
||||||
|
// same shapes as the trail attributes today, but structures are not trails —
|
||||||
|
// separate schema, and the spatial semantics differ:
|
||||||
|
// - "gradient": the palette spans each structure icon's diagonal once (a
|
||||||
|
// visible gradient across the shape), sliding one full cycle every
|
||||||
|
// colorSize · 4 · count / movementSpeed seconds (the trail-equivalent pace).
|
||||||
|
// - "transition": the whole icon is one color at a time, cross-fading through
|
||||||
|
// the list. `frequency` = color changes per second.
|
||||||
|
// Colors are unvalidated strings; the renderer drops any it can't parse (and
|
||||||
|
// an empty list leaves the structure on its normal player color).
|
||||||
|
export const StructuresEffectAttributesSchema = z.discriminatedUnion("type", [
|
||||||
|
z.object({
|
||||||
|
type: z.literal("gradient"),
|
||||||
|
colors: z.array(z.string()),
|
||||||
|
colorSize: z.number(),
|
||||||
|
movementSpeed: z.number(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("transition"),
|
||||||
|
colors: z.array(z.string()),
|
||||||
|
frequency: z.number(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Recolors the owner's structures (city, port, factory, defense post, SAM,
|
||||||
|
// silo) with gradient / transition styles. Shown while the owner's territory
|
||||||
|
// is hovered; structures otherwise keep their normal player colors.
|
||||||
|
const StructuresEffectSchema = CosmeticSchema.extend({
|
||||||
|
effectType: z.literal("structures"),
|
||||||
|
attributes: StructuresEffectAttributesSchema,
|
||||||
|
url: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
// Any catalog effect, discriminated on effectType. Add a member per effectType.
|
// Any catalog effect, discriminated on effectType. Add a member per effectType.
|
||||||
export const EffectSchema = z.discriminatedUnion("effectType", [
|
export const EffectSchema = z.discriminatedUnion("effectType", [
|
||||||
TransportShipTrailEffectSchema,
|
TransportShipTrailEffectSchema,
|
||||||
NukeTrailEffectSchema,
|
NukeTrailEffectSchema,
|
||||||
NukeExplosionEffectSchema,
|
NukeExplosionEffectSchema,
|
||||||
|
StructuresEffectSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -226,17 +266,21 @@ export function isNukeExplosionEffect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A player selects one effect per "slot". A slot is the effectType for trails
|
* A player selects one effect per "slot". A slot is the effectType itself for
|
||||||
* (transportShipTrail, nukeTrail) and the nukeType for nuke explosions (atom,
|
* per-type effects (transportShipTrail, nukeTrail, structures) and the
|
||||||
* hydro, mirvWarhead) — so a player can equip a distinct explosion per bomb.
|
* nukeType for nuke explosions (atom, hydro, mirvWarhead) — so a player can
|
||||||
* Returns the effectType a slot resolves to for catalog lookup, or undefined for
|
* equip a distinct explosion per bomb. Returns the effectType a slot resolves
|
||||||
* an unknown/stale slot (e.g. a bare "nukeExplosion" key from before this split).
|
* to for catalog lookup, or undefined for an unknown/stale slot (e.g. a bare
|
||||||
|
* "nukeExplosion" key from before the per-nukeType split).
|
||||||
*/
|
*/
|
||||||
export function effectTypeForSlot(slot: string): EffectType | undefined {
|
export function effectTypeForSlot(slot: string): EffectType | undefined {
|
||||||
if ((NUKE_EXPLOSION_TYPES as readonly string[]).includes(slot)) {
|
if ((NUKE_EXPLOSION_TYPES as readonly string[]).includes(slot)) {
|
||||||
return "nukeExplosion";
|
return "nukeExplosion";
|
||||||
}
|
}
|
||||||
if ((TRAIL_EFFECT_TYPES as readonly string[]).includes(slot)) {
|
if (
|
||||||
|
(EFFECT_TYPES as readonly string[]).includes(slot) &&
|
||||||
|
slot !== "nukeExplosion"
|
||||||
|
) {
|
||||||
return slot as EffectType;
|
return slot as EffectType;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -330,6 +374,7 @@ export const CosmeticsSchema = z.object({
|
|||||||
).optional(),
|
).optional(),
|
||||||
nukeTrail: lenientRecord(NukeTrailEffectSchema).optional(),
|
nukeTrail: lenientRecord(NukeTrailEffectSchema).optional(),
|
||||||
nukeExplosion: lenientRecord(NukeExplosionEffectSchema).optional(),
|
nukeExplosion: lenientRecord(NukeExplosionEffectSchema).optional(),
|
||||||
|
structures: lenientRecord(StructuresEffectSchema).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
currencyPacks: z.record(z.string(), PackSchema).optional(),
|
currencyPacks: z.record(z.string(), PackSchema).optional(),
|
||||||
|
|||||||
@@ -580,6 +580,91 @@ describe("nukeExplosion in the cosmetics catalog", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("structures effects", () => {
|
||||||
|
const gradient = {
|
||||||
|
name: "rwb_structure_gradient",
|
||||||
|
effectType: "structures",
|
||||||
|
attributes: {
|
||||||
|
type: "gradient",
|
||||||
|
colors: ["#f00000", "#ffffff", "#1000f5"],
|
||||||
|
colorSize: 5,
|
||||||
|
movementSpeed: 5,
|
||||||
|
},
|
||||||
|
affiliateCode: null,
|
||||||
|
product: null,
|
||||||
|
rarity: "common",
|
||||||
|
};
|
||||||
|
const transition = {
|
||||||
|
name: "rwb_structure_transistion",
|
||||||
|
effectType: "structures",
|
||||||
|
attributes: {
|
||||||
|
type: "transition",
|
||||||
|
colors: ["#ff0000", "#ffffff", "#0008ff"],
|
||||||
|
frequency: 5,
|
||||||
|
},
|
||||||
|
affiliateCode: null,
|
||||||
|
product: null,
|
||||||
|
priceHard: 1,
|
||||||
|
rarity: "common",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("parses the gradient and transition catalog entries", () => {
|
||||||
|
const result = CosmeticsSchema.safeParse({
|
||||||
|
patterns: {},
|
||||||
|
flags: {},
|
||||||
|
effects: {
|
||||||
|
structures: {
|
||||||
|
rwb_structure_gradient: gradient,
|
||||||
|
rwb_structure_transistion: transition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(
|
||||||
|
result.data.effects?.structures?.rwb_structure_gradient?.attributes
|
||||||
|
.type,
|
||||||
|
).toBe("gradient");
|
||||||
|
expect(
|
||||||
|
result.data.effects?.structures?.rwb_structure_transistion?.attributes
|
||||||
|
.type,
|
||||||
|
).toBe("transition");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the structures slot (slot = effectType)", () => {
|
||||||
|
expect(effectTypeForSlot("structures")).toBe("structures");
|
||||||
|
const parsed = CosmeticsSchema.safeParse({
|
||||||
|
patterns: {},
|
||||||
|
flags: {},
|
||||||
|
effects: { structures: { rwb_structure_gradient: gradient } },
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
if (!parsed.success) return;
|
||||||
|
expect(
|
||||||
|
findEffectForSlot(parsed.data, "structures", "rwb_structure_gradient")
|
||||||
|
?.name,
|
||||||
|
).toBe("rwb_structure_gradient");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares trail attribute shapes but is not a trail effect", () => {
|
||||||
|
const eff = EffectSchema.parse(gradient);
|
||||||
|
// Renders through the structures palette block, not a trail block.
|
||||||
|
expect(isTrailEffect(eff)).toBe(false);
|
||||||
|
expect(effectMatchesSlot(eff, "structures")).toBe(true);
|
||||||
|
expect(effectMatchesSlot(eff, "transportShipTrail")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a structures effect with an unknown attribute type", () => {
|
||||||
|
expect(
|
||||||
|
EffectSchema.safeParse({
|
||||||
|
...gradient,
|
||||||
|
attributes: { type: "sparkle", colors: [] },
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("isTrailEffect", () => {
|
describe("isTrailEffect", () => {
|
||||||
it("is true for a trail effect and false for a nukeExplosion", () => {
|
it("is true for a trail effect and false for a nukeExplosion", () => {
|
||||||
const trail = EffectSchema.parse({
|
const trail = EffectSchema.parse({
|
||||||
|
|||||||
Reference in New Issue
Block a user