mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 20:55:27 +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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user