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:
Evan
2026-07-03 12:41:39 -07:00
committed by GitHub
parent 5e4b2791aa
commit be77ab4fc9
10 changed files with 306 additions and 47 deletions
+33 -22
View File
@@ -8,6 +8,7 @@ import {
isTrailEffect,
type NukeExplosionAttributes,
type NukeExplosionType,
type StructuresEffectAttributes,
TRAIL_EFFECT_TYPES,
type TrailEffectAttributes,
} from "../core/CosmeticSchemas";
@@ -26,8 +27,9 @@ import {
// 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 {
EFFECT_PALETTE_BLOCKS,
MAX_TRAIL_COLORS,
TRAIL_EFFECT_BLOCKS,
STRUCTURES_EFFECT_BLOCK,
} from "./render/gl/utils/ColorUtils";
import {
UT_ATOM_BOMB,
@@ -38,15 +40,18 @@ import type { GameView } from "./view";
const PALETTE_SIZE = 4096;
// 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
// 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.
// The effect-palette block order: index = block (rows block·MAX_TRAIL_COLORS …).
// trail.frag.glsl picks its 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) — and structure.frag.glsl reads block
// STRUCTURES_EFFECT_BLOCK (2). Reordering TRAIL_EFFECT_TYPES in CosmeticSchemas
// (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"] =
TRAIL_EFFECT_TYPES;
void _EFFECT_BLOCK_ORDER;
const _STRUCTURES_BLOCK_IS_2: 2 = STRUCTURES_EFFECT_BLOCK;
void _STRUCTURES_BLOCK_IS_2;
// Attribute → render-param mappings:
// size = the ring's final WIDTH (diameter) in world tiles when it fades
@@ -106,10 +111,11 @@ function attributesToExplosionParams(
*/
export class WebGLFrameBuilder {
private readonly palette: Float32Array;
// Per-player trail-effect palette, keyed by smallID. Layout is
// 4096×(MAX_TRAIL_COLORS·TRAIL_EFFECT_BLOCKS): block 0 (rows 07) =
// transportShipTrail, block 1 (rows 815) = nukeTrail. Consumed by TrailPass's
// effect texture; the shader picks the block from the trail tile's nuke bit.
// Per-player effect palette, keyed by smallID. Layout is
// 4096×(MAX_TRAIL_COLORS·EFFECT_PALETTE_BLOCKS): block 0 (rows 07) =
// transportShipTrail, block 1 (rows 815) = nukeTrail, block 2 (rows 1623)
// = structures. Consumed by TrailPass (block from the trail tile's nuke bit)
// and StructurePass (block 2).
private readonly effectPalette: Float32Array;
private readonly patternMeta: Float32Array;
private readonly patternData: Uint8Array;
@@ -140,7 +146,7 @@ export class WebGLFrameBuilder {
constructor(private readonly view: MapRenderer) {
this.palette = new Float32Array(PALETTE_SIZE * 2 * 4);
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.patternData = new Uint8Array(PALETTE_SIZE * 1024);
@@ -407,16 +413,21 @@ export class WebGLFrameBuilder {
if (this.effectResolved.has(smallID)) continue;
this.effectResolved.add(smallID);
// 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.
// Only trail effect types render here; nukeExplosion is not a trail.
TRAIL_EFFECT_TYPES.forEach((effectType, block) => {
// Resolve each trail-styled effectType into its own block of the effect
// palette. rowBase block*MAX_TRAIL_COLORS must match the consumer
// shaders' block layout (ship=0, nuke=1 in trail.frag.glsl; structures=2
// in structure.frag.glsl) — see _EFFECT_BLOCK_ORDER above. nukeExplosion
// 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];
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
// Narrows attributes to trail attrs (structures share the shape).
if (!isTrailEffect(effect) && effect.effectType !== "structures") {
return;
}
const rowBase = block * MAX_TRAIL_COLORS;
if (this.writeEffectEntry(smallID, effect.attributes, rowBase)) {
dirty = true;
@@ -427,9 +438,9 @@ export class WebGLFrameBuilder {
}
/**
* Encode a player's trail effect into one block of the effect palette. The
* block starts at row `rowBase` (0 = transportShipTrail, MAX_TRAIL_COLORS =
* nukeTrail). Within the block, row r holds color r's rgb, and the spare alpha
* Encode a player's trail-styled effect into one block of the effect palette.
* The block starts at row `rowBase` (block · MAX_TRAIL_COLORS; see
* _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 —
* row 0.a = color count (0 → the shader falls back to the territory color),
* row 1.a = styleId (0 = gradient, 1 = transition),
@@ -441,7 +452,7 @@ export class WebGLFrameBuilder {
*/
private writeEffectEntry(
smallID: number,
attrs: TrailEffectAttributes,
attrs: TrailEffectAttributes | StructuresEffectAttributes,
rowBase: number,
): boolean {
const colors = attrs.colors
+3 -2
View File
@@ -188,8 +188,9 @@ export class CosmeticButton extends LitElement {
</div>`;
}
// Nuke explosions preview per visual type (expanding ring or sparkle
// burst); every trail effectType (transportShipTrail, nukeTrail) shares
// the same attributes shape and previews as a color swatch.
// burst); every trail effectType (transportShipTrail, nukeTrail) and the
// structures effect share the same attribute shapes and preview as a
// color swatch.
if (isNukeExplosionEffect(c)) {
if (c.attributes.type === "sparkles") {
return html`<sparkles-swatch
+4 -2
View File
@@ -2,6 +2,7 @@ import { html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import {
NukeExplosionAttributes,
StructuresEffectAttributes,
TrailEffectAttributes,
} from "../../core/CosmeticSchemas";
@@ -9,7 +10,8 @@ import {
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 — a multi-color list reads as a rainbow).
@@ -20,7 +22,7 @@ const EMPTY_BG = "#444";
export class TrailSwatch extends LitElement {
// Named `trail` (not `attributes`) to avoid clashing with Element.attributes.
@property({ attribute: false })
trail: TrailEffectAttributes | null = null;
trail: TrailEffectAttributes | StructuresEffectAttributes | null = null;
private animation: Animation | null = null;
+10 -7
View File
@@ -61,10 +61,10 @@ import { WorldTextPass } from "./passes/WorldTextPass";
import type { RenderSettings } from "./RenderSettings";
import { AffiliationPalette } from "./utils/Affiliation";
import {
EFFECT_PALETTE_BLOCKS,
getPaletteSize,
hexToRgb,
MAX_TRAIL_COLORS,
TRAIL_EFFECT_BLOCKS,
} from "./utils/ColorUtils";
import { renderDpr } from "./utils/Dpr";
import {
@@ -267,10 +267,11 @@ export class GPURenderer {
filter: gl.NEAREST,
});
// Per-player trail-effect texture: TRAIL_EFFECT_BLOCKS stacked blocks of
// MAX_TRAIL_COLORS rows (block 0 = transportShipTrail, block 1 = nukeTrail).
// Starts zeroed (color count 0 everywhere = no effect → territory color).
const effectRows = MAX_TRAIL_COLORS * TRAIL_EFFECT_BLOCKS;
// Per-player effect texture: EFFECT_PALETTE_BLOCKS stacked blocks of
// MAX_TRAIL_COLORS rows (block 0 = transportShipTrail, block 1 = nukeTrail,
// block 2 = structures). Starts zeroed (color count 0 everywhere = no
// effect → territory/player color).
const effectRows = MAX_TRAIL_COLORS * EFFECT_PALETTE_BLOCKS;
this.effectTex = createTexture2D(gl, {
width: palW,
height: effectRows,
@@ -511,6 +512,7 @@ export class GPURenderer {
gl,
header,
this.paletteTex,
this.effectTex,
this.settings,
);
this.structureLevelPass = new StructureLevelPass(gl, header, this.settings);
@@ -676,7 +678,7 @@ export class GPURenderer {
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 {
const gl = this.gl;
gl.activeTexture(gl.TEXTURE0);
@@ -687,7 +689,7 @@ export class GPURenderer {
0,
0,
getPaletteSize(),
MAX_TRAIL_COLORS * TRAIL_EFFECT_BLOCKS,
MAX_TRAIL_COLORS * EFFECT_PALETTE_BLOCKS,
gl.RGBA,
gl.FLOAT,
effectData,
@@ -980,6 +982,7 @@ export class GPURenderer {
this.borderPass.setHighlightOwner(ownerID);
this.territoryPass.setHighlightOwner(ownerID);
this.namePass.setHighlightOwner(ownerID);
this.structurePass.setHighlightOwner(ownerID);
}
setMouseWorldPos(x: number, y: number): void {
this.namePass.setMouseWorldPos(x, y);
+30 -1
View File
@@ -25,7 +25,11 @@ import {
} from "../../types";
import { DynamicInstanceBuffer } from "../DynamicBuffer";
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 { assetUrl } from "src/core/AssetUrls";
@@ -92,12 +96,19 @@ export class StructurePass {
private uIconAlpha: WebGLUniformLocation;
private uIconColor: 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 instanceBuf: DynamicInstanceBuffer;
private ghostInstanceBuf: WebGLBuffer;
private paletteTex: WebGLTexture;
private effectTex: WebGLTexture;
private atlasTex: WebGLTexture;
private affiliationTex: WebGLTexture | null = null;
private altView = false;
@@ -120,12 +131,14 @@ export class StructurePass {
gl: WebGL2RenderingContext,
header: RendererConfig,
paletteTex: WebGLTexture,
effectTex: WebGLTexture,
settings: RenderSettings,
) {
this.gl = gl;
this.settings = settings;
this.mapW = header.mapWidth;
this.paletteTex = paletteTex;
this.effectTex = effectTex;
// Build unitType string → atlas column mapping
for (let i = 0; i < header.unitTypes.length; i++) {
@@ -144,6 +157,8 @@ export class StructurePass {
shaderSrc(structureFragSrc, {
PALETTE_SIZE: getPaletteSize(),
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(
@@ -182,12 +197,15 @@ export class StructurePass {
this.uIconAlpha = gl.getUniformLocation(this.program, "uIconAlpha")!;
this.uIconColor = gl.getUniformLocation(this.program, "uIconColor")!;
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
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 1);
gl.uniform1i(gl.getUniformLocation(this.program, "uAffiliation"), 2);
gl.uniform1i(gl.getUniformLocation(this.program, "uEffect"), 3);
gl.uniform1f(this.uGhostAlpha, 1.0);
gl.uniform3f(this.uOutlineColor, 0, 0, 0);
gl.uniform1i(this.uHighlightMask, 0);
@@ -280,6 +298,11 @@ export class StructurePass {
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 {
let count = 0;
@@ -384,6 +407,9 @@ export class StructurePass {
gl.uniform1f(this.uIconAlpha, ss.iconAlpha);
gl.uniform3f(this.uIconColor, ss.iconR, ss.iconG, ss.iconB);
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.bindTexture(gl.TEXTURE_2D, this.paletteTex);
@@ -396,6 +422,9 @@ export class StructurePass {
gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex);
}
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, this.effectTex);
gl.bindVertexArray(this.vao);
// --- Real structures ---
@@ -4,6 +4,15 @@ precision highp float;
uniform sampler2D uPalette;
uniform sampler2D uAtlas;
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 uGhostAlpha; // 1.0 = normal, <1.0 = ghost transparency
uniform vec3 uOutlineColor; // ghost outline color (vec3(0) = no outline)
@@ -49,6 +58,49 @@ vec3 darken(vec3 rgb, float vScale) {
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
// Signed distance to regular polygon edge.
@@ -119,6 +171,22 @@ void main() {
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);
// 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
// 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.
// 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;
if (uIconDarken > 0.0) {
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);
+9 -5
View File
@@ -25,12 +25,16 @@ export function getPaletteSize(): number {
export const MAX_TRAIL_COLORS = 8;
/**
* The trail-effect texture stacks one MAX_TRAIL_COLORS-row block per trail
* effectType: block 0 = transportShipTrail, block 1 = nukeTrail (matching the
* nuke bit in trail.frag.glsl). Bump this if another trailed effectType is added
* (and add its rowBase branch to the shader).
* The effect-palette texture stacks one MAX_TRAIL_COLORS-row block per
* trail-styled effectType: block 0 = transportShipTrail, block 1 = nukeTrail
* (matching the nuke bit in trail.frag.glsl), block 2 = structures (read by
* 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 ----------
+52 -7
View File
@@ -10,7 +10,8 @@ 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 + nukeExplosion; gains a member per effectType).
// transportShipTrail + nukeTrail + nukeExplosion + structures; 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, …).
@@ -19,6 +20,10 @@ export type TrailEffectAttributes = z.infer<typeof TrailEffectAttributesSchema>;
export type NukeExplosionAttributes = z.infer<
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 Product = z.infer<typeof ProductSchema>;
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
@@ -106,6 +111,7 @@ export const EFFECT_TYPES = [
"transportShipTrail",
"nukeTrail",
"nukeExplosion",
"structures",
] as const;
export const EffectTypeSchema = z.enum(EFFECT_TYPES);
@@ -199,11 +205,45 @@ const NukeExplosionEffectSchema = CosmeticSchema.extend({
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.
export const EffectSchema = z.discriminatedUnion("effectType", [
TransportShipTrailEffectSchema,
NukeTrailEffectSchema,
NukeExplosionEffectSchema,
StructuresEffectSchema,
]);
/**
@@ -226,17 +266,21 @@ export function isNukeExplosionEffect(
}
/**
* 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).
* A player selects one effect per "slot". A slot is the effectType itself for
* per-type effects (transportShipTrail, nukeTrail, structures) 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 the per-nukeType 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)) {
if (
(EFFECT_TYPES as readonly string[]).includes(slot) &&
slot !== "nukeExplosion"
) {
return slot as EffectType;
}
return undefined;
@@ -330,6 +374,7 @@ export const CosmeticsSchema = z.object({
).optional(),
nukeTrail: lenientRecord(NukeTrailEffectSchema).optional(),
nukeExplosion: lenientRecord(NukeExplosionEffectSchema).optional(),
structures: lenientRecord(StructuresEffectSchema).optional(),
})
.optional(),
currencyPacks: z.record(z.string(), PackSchema).optional(),