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
co-authored by Claude Fable 5
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