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
+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(),