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
+85
View File
@@ -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", () => {
it("is true for a trail effect and false for a nukeExplosion", () => {
const trail = EffectSchema.parse({