Files
OpenFrontIO/src/client/WebGLFrameBuilder.ts
T
Evan 200f276ab2 feat: transport-ship trail transition effect + animated store swatch (#4455)
## What

Adds a second transport-ship trail style, **transition**, alongside the
existing **gradient** (#4454). Where `gradient` paints a spatial band of
colors along the trail, `transition` makes the whole trail one color at
a time, cross-fading through the color list over time.

```json
"attributes": {
  "type": "transition",
  "colors": ["#002aff", "#4805ff"],
  "frequency": 1
}
```

## How

- **Schema** ([CosmeticSchemas.ts](src/core/CosmeticSchemas.ts)) —
`TransportShipTrailAttributesSchema` is now a discriminated union on
`type`:
  - `gradient`: `{ colors, colorSize, movementSpeed }`
- `transition`: `{ colors, frequency }` — `frequency` = color changes
per second.
- **Renderer** — the effect texture gained a `styleId` discriminator
(row 1's alpha; 0 = gradient, 1 = transition), with the gradient scalars
shifted down a row.
- [WebGLFrameBuilder.ts](src/client/WebGLFrameBuilder.ts) encodes
`styleId` + the style's scalars.
-
[trail.frag.glsl](src/client/render/gl/shaders/map-overlay/trail.frag.glsl):
for `transition`, the trail color is `mix(colors[i], colors[i+1],
fract(t))` with `i = floor(uTime · frequency) mod count` — one color
step every `1/frequency` seconds.
- **Store/picker swatch**
([EffectPreview.ts](src/client/components/EffectPreview.ts)) — the
swatch is now a `<trail-swatch>` Lit element. For `transition` it
cross-fades through the colors via the Web Animations API, timed to
match the shader (each step `1/frequency` s); gradient/solid stay
static. The animation is canceled on disconnect.

## Notes

- Animation is render-only (local time) — no simulation/determinism
impact.
- `gradient` swatches remain static (they don't scroll like the in-game
trail) — easy to add later if wanted.

## Testing

- `tsc --noEmit`, ESLint, Prettier, `build-prod` all clean.
- Schema tests cover the transition member (parse + required
`frequency`); 95 tests pass.
- The animated swatch is visual-only (no automated coverage) and not yet
verified in a running store.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:53:33 -07:00

370 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Colord, colord } from "colord";
import { base64url } from "jose";
import { assetUrl } from "../core/AssetUrls";
import {
findEffect,
type TransportShipTrailAttributes,
} from "../core/CosmeticSchemas";
import { decodePatternData } from "../core/PatternDecoder";
import { PlayerType } from "../core/game/Game";
import { getCachedCosmetics } from "./Cosmetics";
import { uploadFrameData } from "./render/frame/Upload";
// Type-only: a value import would pull GPURenderer and its `.glsl?raw` shader
// imports into any non-Vite consumer (e.g. the Node perf harness).
import type { MapRenderer, PlayerStatic, SpawnCenter } from "./render/gl";
// 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 { MAX_TRAIL_COLORS } from "./render/gl/utils/ColorUtils";
import type { GameView } from "./view";
const PALETTE_SIZE = 4096;
/**
* The renderer-side glue between GameView (which already builds the full
* FrameData each tick) and the WebGL view. Two responsibilities:
*
* 1. Palette management — translate PlayerView colors into a Float32Array
* the renderer uploads to a 1D texture, and call view.addPlayers() when
* new players appear (this is a renderer-side lifecycle event, not part
* of FrameData).
* 2. Per-tick upload — pass the FrameData to the renderer's uploadFrameData
* helper, which dispatches to all the view.update*() methods.
*/
export class WebGLFrameBuilder {
private readonly palette: Float32Array;
// Per-player transport-ship-trail gradient, keyed by smallID. Layout is
// 4096×MAX_TRAIL_COLORS: row 0 = (color0.rgb, colorCount), row r = (colorR.rgb).
// Consumed by TrailPass's effect texture.
private readonly effectPalette: Float32Array;
private readonly patternMeta: Float32Array;
private readonly patternData: Uint8Array;
private readonly knownSmallIDs = new Set<number>();
/**
* smallIDs whose trail effect has been resolved into the effect palette.
* Separate from knownSmallIDs because effect resolution depends on the
* cosmetics catalog, which may not be loaded the tick a player is first seen
* — keeping it separate lets us retry next tick instead of skipping forever.
*/
private readonly effectResolved = new Set<number>();
/**
* Last spawn tile pushed to the renderer per smallID. Players can re-pick
* spawn during the spawn phase, so this tracks the latest value rather than
* just first-seen — re-uploads only when the tile actually changes.
*/
private readonly lastSpawnTile = new Map<number, number>();
/** Skin atlas allocated once on first syncPlayers — player set is locked at game start. */
private skinsInitialized = false;
// The renderer needs to know which player is "me" so affiliation tint,
// unit colors, and SAM-radius perspective work. Push it once the local
// player's update arrives (may take several ticks during join).
private localPlayerSmallID = 0;
// Scratch buffer for terrain-delta uploads (parallel to the refs list).
private terrainDeltaBytes: Uint8Array = new Uint8Array(0);
constructor(private readonly view: MapRenderer) {
this.palette = new Float32Array(PALETTE_SIZE * 2 * 4);
this.effectPalette = new Float32Array(PALETTE_SIZE * MAX_TRAIL_COLORS * 4);
this.patternMeta = new Float32Array(PALETTE_SIZE * 4);
this.patternData = new Uint8Array(PALETTE_SIZE * 1024);
}
/** Drop internal caches to force a full re-upload of state on the next update(). */
clearCaches(): void {
this.knownSmallIDs.clear();
this.effectResolved.clear();
this.lastSpawnTile.clear();
this.localPlayerSmallID = 0;
this.skinsInitialized = false;
}
/**
* Re-write every player's palette entry from their current (possibly re-themed)
* colors and re-upload just the palette texture. Used after a mid-game theme
* change (e.g. toggling colorblind mode) so existing territories re-color
* without re-syncing players, skins, or spawns.
*/
refreshPalette(gameView: GameView): void {
for (const p of gameView.players()) {
this.writePaletteEntry(p.smallID(), p.territoryColor(), p.borderColor());
}
this.view.updatePalette(this.palette);
}
/**
* Re-resolve every player's display name (e.g. after toggling the
* anonymous-names setting) and push it to the renderer so the names drawn on
* the map switch live, matching the leaderboard.
*/
refreshNames(gameView: GameView): void {
const displayNames = new Map<string, string>();
for (const p of gameView.players()) {
displayNames.set(p.id(), p.displayName());
}
this.view.refreshNames(displayNames);
}
update(gameView: GameView): void {
this.syncPlayers(gameView);
this.syncPlayerEffects(gameView);
this.syncPlayerSpawns(gameView);
this.syncLocalPlayer(gameView);
this.syncSpawnOverlay(gameView);
this.syncTerrainDeltas(gameView);
uploadFrameData(this.view, gameView.frameData());
}
/**
* Push each player's current spawn tile to the renderer as the skin anchor
* (image center lines up with this tile). Players re-pick spawn during the
* spawn phase, so we re-upload whenever the tile changes, not just on first
* sighting. Once spawn phase ends, spawnTile is locked and this becomes a
* no-op via the cache check.
*/
private syncPlayerSpawns(gameView: GameView): void {
for (const p of gameView.players()) {
const smallID = p.smallID();
const spawnTile = p.state.spawnTile;
if (spawnTile === undefined) continue;
if (this.lastSpawnTile.get(smallID) === spawnTile) continue;
this.lastSpawnTile.set(smallID, spawnTile);
this.view.setPlayerSpawn(
smallID,
gameView.x(spawnTile),
gameView.y(spawnTile),
);
}
}
/**
* Water-nuke conversions (land → water) mutate the underlying terrain.
* Forward this tick's terrain-changed refs to the renderer so it can
* re-upload those texels in both the RGBA color texture and the R8UI
* water-detection texture used by railroads/bridges.
*/
private syncTerrainDeltas(gameView: GameView): void {
const refs = gameView.recentlyUpdatedTerrainTiles();
if (refs.length === 0) return;
if (this.terrainDeltaBytes.length < refs.length) {
this.terrainDeltaBytes = new Uint8Array(refs.length);
}
for (let i = 0; i < refs.length; i++) {
this.terrainDeltaBytes[i] = gameView.terrainByte(refs[i]);
}
this.view.applyTerrainDelta(refs, this.terrainDeltaBytes);
}
private syncLocalPlayer(gameView: GameView): void {
const me = gameView.myPlayer();
const sid = me?.smallID() ?? 0;
if (sid === this.localPlayerSmallID) return;
this.localPlayerSmallID = sid;
this.view.setLocalPlayerID(sid);
if (me) {
const rail = me.railColor().toRgb();
this.view.setLocalRailColor(rail.r / 255, rail.g / 255, rail.b / 255);
}
}
/**
* Spawn-phase highlights: each already-spawned human player gets a colored
* ring + tile glow around their starting territory. Pushed every tick
* during spawn phase; the pass animates locally from the snapshot.
*/
private syncSpawnOverlay(gameView: GameView): void {
const inSpawnPhase = gameView.inSpawnPhase();
if (!inSpawnPhase) {
this.view.updateSpawnOverlay(false, []);
return;
}
const me = gameView.myPlayer();
const myTeam = me?.team() ?? null;
const centers: SpawnCenter[] = [];
for (const p of gameView.players()) {
if (!p.isPlayer() || p.type() !== PlayerType.Human) continue;
const spawnTile = p.state.spawnTile;
if (spawnTile === undefined) continue;
const isSelf = me !== null && p.smallID() === me.smallID();
// myPlayer's ring pulses white→this color in SpawnOverlayPass: gold
// when teamless, own territory tint in team games (matches teammates'
// rings). Everyone else uses their territory tint directly.
const c = p.territoryColor().toRgb();
const useGold = isSelf && myTeam === null;
centers.push({
// spawnTile tracks the player's currently-selected spawn directly —
// updates the same tick the player picks a new location (faster than
// the nameData centroid which only refreshes every 2 ticks).
x: gameView.x(spawnTile),
y: gameView.y(spawnTile),
r: useGold ? 1 : c.r / 255,
g: useGold ? 0.84 : c.g / 255,
b: useGold ? 0 : c.b / 255,
isSelf,
isTeammate:
myTeam !== null &&
p.team() === myTeam &&
p.smallID() !== me?.smallID(),
});
}
this.view.updateSpawnOverlay(true, centers);
}
private syncPlayers(gameView: GameView): void {
if (!this.skinsInitialized) {
this.skinsInitialized = true;
const urls = new Set<string>();
for (const p of gameView.players()) {
const url = p.cosmetics.skin?.url;
if (url) urls.add(assetUrl(url));
}
this.view.initSkinAtlas([...urls]);
}
const newPlayers: PlayerStatic[] = [];
for (const p of gameView.players()) {
const smallID = p.smallID();
if (this.knownSmallIDs.has(smallID)) continue;
this.knownSmallIDs.add(smallID);
this.writePaletteEntry(smallID, p.territoryColor(), p.borderColor());
// p.cosmetics.flag has already been server-resolved to either a full URL
// or a relative asset path (e.g. "/flags/US.svg" or a CDN URL for a
// custom flag). assetUrl() passes URLs through and rewrites paths.
const flagRef = p.cosmetics.flag;
const flagUrl = flagRef ? assetUrl(flagRef) : undefined;
const skin = p.cosmetics.skin;
if (skin?.url) {
this.view.setPlayerSkin(smallID, assetUrl(skin.url));
}
const pattern = p.cosmetics.pattern;
if (pattern && pattern.patternData) {
try {
const decoded = decodePatternData(
pattern.patternData,
base64url.decode,
);
const metaOff = smallID * 4;
this.patternMeta[metaOff] = 1.0; // hasPattern = true
this.patternMeta[metaOff + 1] = decoded.width;
this.patternMeta[metaOff + 2] = decoded.height;
this.patternMeta[metaOff + 3] = decoded.scale;
this.patternData.set(decoded.bytes.slice(3), smallID * 1024);
} catch (e) {
console.warn("Failed to decode territory pattern", e);
}
}
newPlayers.push({
...p.static,
// displayName() honors the anonymous-names setting; static.displayName
// is always the real name.
displayName: p.displayName(),
flag: flagUrl,
color: p.territoryColor().toHex(),
});
}
if (newPlayers.length > 0) {
this.view.addPlayers(
newPlayers,
this.palette,
this.patternMeta,
this.patternData,
);
}
}
/**
* Resolve each player's transport-ship-trail effect into the effect palette.
* A player's resolved cosmetic is just { name, effectType }; the style and
* colors live in the catalog, so we look them up via the cached cosmetics.
* Decoupled from syncPlayers' first-seen guard: if the catalog isn't loaded
* yet we leave the player unresolved and retry next tick (the trail keeps its
* territory color meanwhile). Re-uploads the effect texture only when a
* recognized style was actually written.
*/
private syncPlayerEffects(gameView: GameView): void {
const catalog = getCachedCosmetics();
if (!catalog) return; // Catalog not loaded yet — retry on a later tick.
let dirty = false;
for (const p of gameView.players()) {
const smallID = p.smallID();
if (this.effectResolved.has(smallID)) continue;
this.effectResolved.add(smallID);
const trailEffect = p.cosmetics.effects?.["transportShipTrail"];
if (!trailEffect) continue; // No effect — nothing to write or upload.
const effect = findEffect(
catalog,
"transportShipTrail",
trailEffect.name,
);
if (effect?.effectType !== "transportShipTrail") continue;
if (this.writeEffectEntry(smallID, effect.attributes)) dirty = true;
}
if (dirty) this.view.updateEffectPalette(this.effectPalette);
}
/**
* Encode a player's transport-ship-trail effect into the effect palette.
* Layout matches trail.frag.glsl: row r holds color r's rgb, and the spare
* alpha channels (rows 03 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),
* row 2.a = scalar0 (gradient: colorSize; transition: frequency),
* row 3.a = scalar1 (gradient: movementSpeed; transition: unused).
* colord doesn't throw on a bad color string (it returns black), so unparseable
* colors are dropped — leaving an empty list, which falls back to the territory
* color rather than rendering black. Returns whether any color was written.
*/
private writeEffectEntry(
smallID: number,
attrs: TransportShipTrailAttributes,
): boolean {
const colors = attrs.colors
.map((s) => colord(s))
.filter((c) => c.isValid())
.slice(0, MAX_TRAIL_COLORS)
.map((c) => c.toRgb());
for (let r = 0; r < MAX_TRAIL_COLORS; r++) {
const off = (r * PALETTE_SIZE + smallID) * 4;
const c = colors[r] ?? { r: 0, g: 0, b: 0 };
this.effectPalette[off] = c.r / 255;
this.effectPalette[off + 1] = c.g / 255;
this.effectPalette[off + 2] = c.b / 255;
this.effectPalette[off + 3] = 0;
}
const [styleId, scalar0, scalar1] =
attrs.type === "transition"
? [1, attrs.frequency, 0]
: [0, attrs.colorSize, attrs.movementSpeed];
this.effectPalette[(0 * PALETTE_SIZE + smallID) * 4 + 3] = colors.length;
this.effectPalette[(1 * PALETTE_SIZE + smallID) * 4 + 3] = styleId;
this.effectPalette[(2 * PALETTE_SIZE + smallID) * 4 + 3] = scalar0;
this.effectPalette[(3 * PALETTE_SIZE + smallID) * 4 + 3] = scalar1;
return colors.length > 0;
}
private writePaletteEntry(
smallID: number,
fill: Colord,
border: Colord,
): void {
const fillRgba = fill.toRgb();
const fillOff = smallID * 4;
this.palette[fillOff] = fillRgba.r / 255;
this.palette[fillOff + 1] = fillRgba.g / 255;
this.palette[fillOff + 2] = fillRgba.b / 255;
this.palette[fillOff + 3] = 150 / 255;
const borderRgba = border.toRgb();
const borderOff = PALETTE_SIZE * 4 + smallID * 4;
this.palette[borderOff] = borderRgba.r / 255;
this.palette[borderOff + 1] = borderRgba.g / 255;
this.palette[borderOff + 2] = borderRgba.b / 255;
this.palette[borderOff + 3] = 1.0;
}
}