mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-01 05:53:25 +00:00
200f276ab2
## 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>
370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
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 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),
|
||
* 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;
|
||
}
|
||
}
|