mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 21:16:21 +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:
@@ -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 0–7) =
|
||||
// transportShipTrail, block 1 (rows 8–15) = 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 0–7) =
|
||||
// transportShipTrail, block 1 (rows 8–15) = nukeTrail, block 2 (rows 16–23)
|
||||
// = 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
|
||||
|
||||
Reference in New Issue
Block a user