mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-01 01:33:29 +00:00
feat: render transport-ship trail cosmetic as a gradient (#4454)
## What
Renders the `transportShipTrail` cosmetic effect in-game. Transport
ships already left a trail, but it was always drawn in the player's
**territory color** — this wires the selected effect through to the
renderer so the trail shows the player's chosen **gradient**.
## How
- **Per-player effect texture** (`RGBA32F`, mirrors the palette texture)
keyed by `smallID`, sampled by the trail fragment shader. Each row holds
a gradient color; spare alpha channels carry the color count,
`colorSize`, and `movementSpeed`.
- **Shader**
([trail.frag.glsl](src/client/render/gl/shaders/map-overlay/trail.frag.glsl))
cycles a flowing gradient through the color list: 1 color → flat, 2+ →
animated bands scrolling along the trail. No effect (count 0) falls back
to the territory color; alt-view keeps affiliation colors.
- **WebGLFrameBuilder** resolves each player's catalog attributes (the
in-game cosmetic is only `{ name, effectType }`; the style/colors live
in the catalog) and encodes them. Resolution is decoupled from the
first-seen palette path so it retries until the catalog loads, and
unparseable colors are dropped so bad catalog data degrades to the
territory color rather than rendering black.
## Schema
Collapses the trail attributes to a single gradient shape:
```ts
{ type: "gradient", colors: string[], colorSize: number, movementSpeed: number }
```
- `colors` — solid = one color, rainbow = the spectrum, gradient = two
or more.
- `colorSize` — band width (tiles per color band; `1` is the default, ~4
tiles).
- `movementSpeed` — scroll rate along the trail (tiles/sec; `0` =
static).
## Notes
- Animation is render-only (local time), no simulation/determinism
impact.
- The catalog (`cosmetics.json`, served by the closed-source API) must
ship effects in this `{ type: "gradient", colors, colorSize,
movementSpeed }` shape.
- Band thickness (`4.0` base in the shader) and the gradient frequency
are visual constants picked without in-game verification — easy to tune.
## Testing
- `tsc --noEmit`, ESLint, Prettier, `build-prod` all clean.
- Schema + Privilege test suites updated for the gradient shape (92
tests pass).
- Not yet visually verified in a running game (effect selection is
flare-gated).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,20 @@
|
||||
import { Colord } from "colord";
|
||||
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;
|
||||
@@ -24,10 +32,21 @@ const PALETTE_SIZE = 4096;
|
||||
*/
|
||||
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
|
||||
@@ -45,6 +64,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 * 4);
|
||||
this.patternMeta = new Float32Array(PALETTE_SIZE * 4);
|
||||
this.patternData = new Uint8Array(PALETTE_SIZE * 1024);
|
||||
}
|
||||
@@ -52,6 +72,7 @@ export class WebGLFrameBuilder {
|
||||
/** 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;
|
||||
@@ -85,6 +106,7 @@ export class WebGLFrameBuilder {
|
||||
|
||||
update(gameView: GameView): void {
|
||||
this.syncPlayers(gameView);
|
||||
this.syncPlayerEffects(gameView);
|
||||
this.syncPlayerSpawns(gameView);
|
||||
this.syncLocalPlayer(gameView);
|
||||
this.syncSpawnOverlay(gameView);
|
||||
@@ -254,6 +276,72 @@ export class WebGLFrameBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 gradient into the effect palette.
|
||||
* Layout matches trail.frag.glsl: row r holds color r's rgb, and the spare
|
||||
* alpha channels carry the gradient's scalar params — row 0's alpha = color
|
||||
* count (0 → the shader falls back to the territory color), row 1's alpha =
|
||||
* colorSize (band width), row 2's alpha = movementSpeed (scroll rate).
|
||||
* 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;
|
||||
}
|
||||
// Scalar params packed into spare alpha channels (rows 0–2 always exist).
|
||||
this.effectPalette[(0 * PALETTE_SIZE + smallID) * 4 + 3] = colors.length;
|
||||
this.effectPalette[(1 * PALETTE_SIZE + smallID) * 4 + 3] = attrs.colorSize;
|
||||
this.effectPalette[(2 * PALETTE_SIZE + smallID) * 4 + 3] =
|
||||
attrs.movementSpeed;
|
||||
return colors.length > 0;
|
||||
}
|
||||
|
||||
private writePaletteEntry(
|
||||
smallID: number,
|
||||
fill: Colord,
|
||||
|
||||
@@ -1,48 +1,27 @@
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { TransportShipTrailAttributes } from "../../core/CosmeticSchemas";
|
||||
|
||||
// A flowing spectrum used for the "rainbow" transport-ship-trail preview.
|
||||
const RAINBOW_GRADIENT =
|
||||
"linear-gradient(90deg,#ff0000,#ff8a00,#ffe600,#28c76f,#00a8ff,#7d5fff,#ff0000)";
|
||||
|
||||
// Neutral fallback for attribute types we don't recognize.
|
||||
const UNKNOWN_BG = "#444";
|
||||
// Neutral fallback when a trail has no usable colors.
|
||||
const EMPTY_BG = "#444";
|
||||
|
||||
/**
|
||||
* Render a swatch preview of a transport-ship-trail's attributes, filling its
|
||||
* container: solid = flat color, pulse = same color pulsing, rainbow = full
|
||||
* spectrum, gradient = two-color blend. Unknown attribute types render a neutral
|
||||
* swatch (we ignore types we don't know about).
|
||||
* container. A trail is a list of colors: one color renders as a flat swatch,
|
||||
* two or more as a left-to-right gradient (a multi-color list reads as a
|
||||
* rainbow). An empty list renders a neutral swatch.
|
||||
*/
|
||||
export function renderTransportShipTrailSwatch(
|
||||
attributes: TransportShipTrailAttributes,
|
||||
): TemplateResult {
|
||||
switch (attributes.type) {
|
||||
case "rainbow":
|
||||
return html`<div
|
||||
class="w-full h-full rounded-md"
|
||||
style="background:${RAINBOW_GRADIENT};"
|
||||
></div>`;
|
||||
case "gradient":
|
||||
return html`<div
|
||||
class="w-full h-full rounded-md"
|
||||
style="background:linear-gradient(90deg,${attributes.color},${attributes.color2});"
|
||||
></div>`;
|
||||
case "pulse":
|
||||
return html`<div
|
||||
class="w-full h-full rounded-md animate-pulse"
|
||||
style="background:${attributes.color};"
|
||||
></div>`;
|
||||
case "solid":
|
||||
return html`<div
|
||||
class="w-full h-full rounded-md"
|
||||
style="background:${attributes.color};"
|
||||
></div>`;
|
||||
default:
|
||||
// Unknown / unrecognized style — neutral swatch.
|
||||
return html`<div
|
||||
class="w-full h-full rounded-md"
|
||||
style="background:${UNKNOWN_BG};"
|
||||
></div>`;
|
||||
}
|
||||
const colors = attributes.colors;
|
||||
const background =
|
||||
colors.length === 0
|
||||
? EMPTY_BG
|
||||
: colors.length === 1
|
||||
? colors[0]
|
||||
: `linear-gradient(90deg,${colors.join(",")})`;
|
||||
return html`<div
|
||||
class="w-full h-full rounded-md"
|
||||
style="background:${background};"
|
||||
></div>`;
|
||||
}
|
||||
|
||||
@@ -132,6 +132,9 @@ export class MapRenderer {
|
||||
updatePalette(paletteData: Float32Array): void {
|
||||
this.renderer?.updatePalette(paletteData);
|
||||
}
|
||||
updateEffectPalette(effectData: Float32Array): void {
|
||||
this.renderer?.updateEffectPalette(effectData);
|
||||
}
|
||||
addPlayers(
|
||||
players: PlayerStatic[],
|
||||
paletteData: Float32Array,
|
||||
|
||||
@@ -59,7 +59,7 @@ import { UnitPass } from "./passes/UnitPass";
|
||||
import { WorldTextPass } from "./passes/WorldTextPass";
|
||||
import type { RenderSettings } from "./RenderSettings";
|
||||
import { AffiliationPalette } from "./utils/Affiliation";
|
||||
import { getPaletteSize, hexToRgb } from "./utils/ColorUtils";
|
||||
import { getPaletteSize, hexToRgb, MAX_TRAIL_COLORS } from "./utils/ColorUtils";
|
||||
import { renderDpr } from "./utils/Dpr";
|
||||
import {
|
||||
createTexture2D,
|
||||
@@ -132,6 +132,10 @@ export class GPURenderer {
|
||||
|
||||
private paletteTex: WebGLTexture;
|
||||
private paletteData: Float32Array;
|
||||
// Per-player transport-ship-trail gradient, keyed by smallID (RGBA32F,
|
||||
// 4096×MAX_TRAIL_COLORS): row r = color r's rgb; row 0's alpha = color count.
|
||||
// Sampled by TrailPass.
|
||||
private effectTex: WebGLTexture;
|
||||
private patternMetaTex: WebGLTexture;
|
||||
private patternDataTex: WebGLTexture;
|
||||
private skinAtlas: SkinAtlasArray;
|
||||
@@ -235,6 +239,18 @@ export class GPURenderer {
|
||||
filter: gl.NEAREST,
|
||||
});
|
||||
|
||||
// Per-player trail-effect texture (one row per gradient color). Starts zeroed
|
||||
// (color count 0 everywhere = no effect → trail uses territory color).
|
||||
this.effectTex = createTexture2D(gl, {
|
||||
width: palW,
|
||||
height: MAX_TRAIL_COLORS,
|
||||
internalFormat: gl.RGBA32F,
|
||||
format: gl.RGBA,
|
||||
type: gl.FLOAT,
|
||||
data: new Float32Array(palW * MAX_TRAIL_COLORS * 4),
|
||||
filter: gl.NEAREST,
|
||||
});
|
||||
|
||||
this.patternMetaTex = createTexture2D(gl, {
|
||||
width: palW,
|
||||
height: 1,
|
||||
@@ -371,13 +387,14 @@ export class GPURenderer {
|
||||
this.settings.spawnOverlay,
|
||||
);
|
||||
|
||||
// --- Trail (needs trailTex, paletteTex) ---
|
||||
// --- Trail (needs trailTex, paletteTex, effectTex) ---
|
||||
this.trailPass = new TrailPass(
|
||||
gl,
|
||||
mapW,
|
||||
mapH,
|
||||
this.res.trailTex,
|
||||
this.paletteTex,
|
||||
this.effectTex,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
@@ -629,6 +646,24 @@ export class GPURenderer {
|
||||
this.namePass.refreshPlayerColors(this.paletteData);
|
||||
}
|
||||
|
||||
/** Re-upload the per-player trail-effect texture (style + colors by smallID). */
|
||||
updateEffectPalette(effectData: Float32Array): void {
|
||||
const gl = this.gl;
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.effectTex);
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
getPaletteSize(),
|
||||
MAX_TRAIL_COLORS,
|
||||
gl.RGBA,
|
||||
gl.FLOAT,
|
||||
effectData,
|
||||
);
|
||||
}
|
||||
|
||||
/** Register late-arriving players (updates palette + NamePass lookup maps). */
|
||||
addPlayers(
|
||||
players: PlayerStatic[],
|
||||
@@ -1228,6 +1263,7 @@ export class GPURenderer {
|
||||
this.barPass.dispose();
|
||||
disposeGPUResources(this.gl, this.res);
|
||||
this.gl.deleteTexture(this.paletteTex);
|
||||
this.gl.deleteTexture(this.effectTex);
|
||||
this.gl.deleteTexture(this.patternMetaTex);
|
||||
this.gl.deleteTexture(this.patternDataTex);
|
||||
this.gl.deleteTexture(this.skinLayerTex);
|
||||
|
||||
@@ -10,7 +10,11 @@ export { applyGraphicsOverrides } from "./RenderOverrides";
|
||||
export { createRenderSettings, dumpSettings } from "./RenderSettings";
|
||||
export type { RenderSettings } from "./RenderSettings";
|
||||
export { deepAssign, deepDiff } from "./SettingsUtils";
|
||||
export { buildTerrainRGBA, getPaletteSize } from "./utils/ColorUtils";
|
||||
export {
|
||||
MAX_TRAIL_COLORS,
|
||||
buildTerrainRGBA,
|
||||
getPaletteSize,
|
||||
} from "./utils/ColorUtils";
|
||||
export { renderDpr } from "./utils/Dpr";
|
||||
export { buildNukeTrajectory, samRange } from "./utils/NukeTrajectory";
|
||||
export type { SAMInfo } from "./utils/NukeTrajectory";
|
||||
|
||||
@@ -24,13 +24,18 @@ export class TrailPass {
|
||||
private uCamera: WebGLUniformLocation;
|
||||
private uMapSize: WebGLUniformLocation;
|
||||
private uTrailAlpha: WebGLUniformLocation;
|
||||
private uTime: WebGLUniformLocation;
|
||||
private uAltView: WebGLUniformLocation;
|
||||
|
||||
private vao: WebGLVertexArrayObject;
|
||||
private trailTex: WebGLTexture;
|
||||
private paletteTex: WebGLTexture;
|
||||
private effectTex: WebGLTexture;
|
||||
private affiliationTex: WebGLTexture | null = null;
|
||||
private altView = false;
|
||||
// Anchor animation time at construction (like NukeTelegraphPass/SamRadiusPass)
|
||||
// so the value stays small and sin()/fract() don't quantize over long sessions.
|
||||
private readonly startTime = performance.now();
|
||||
|
||||
/** CPU-side trail state (R8UI, 0=none, 1–255=ownerID). */
|
||||
private cpuTrailState: Uint8Array;
|
||||
@@ -49,6 +54,7 @@ export class TrailPass {
|
||||
mapH: number,
|
||||
trailTex: WebGLTexture,
|
||||
paletteTex: WebGLTexture,
|
||||
effectTex: WebGLTexture,
|
||||
settings: RenderSettings,
|
||||
) {
|
||||
this.gl = gl;
|
||||
@@ -57,6 +63,7 @@ export class TrailPass {
|
||||
this.mapH = mapH;
|
||||
this.trailTex = trailTex;
|
||||
this.paletteTex = paletteTex;
|
||||
this.effectTex = effectTex;
|
||||
this.cpuTrailState = new Uint8Array(mapW * mapH);
|
||||
|
||||
this.program = createProgram(
|
||||
@@ -70,12 +77,14 @@ export class TrailPass {
|
||||
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
|
||||
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
|
||||
this.uTrailAlpha = gl.getUniformLocation(this.program, "uTrailAlpha")!;
|
||||
this.uTime = gl.getUniformLocation(this.program, "uTime")!;
|
||||
this.uAltView = gl.getUniformLocation(this.program, "uAltView")!;
|
||||
|
||||
gl.useProgram(this.program);
|
||||
gl.uniform1i(gl.getUniformLocation(this.program, "uTrailTex"), 0);
|
||||
gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 1);
|
||||
gl.uniform1i(gl.getUniformLocation(this.program, "uAffiliation"), 2);
|
||||
gl.uniform1i(gl.getUniformLocation(this.program, "uEffect"), 3);
|
||||
|
||||
this.vao = createMapQuad(gl, mapW, mapH);
|
||||
}
|
||||
@@ -168,6 +177,7 @@ export class TrailPass {
|
||||
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
|
||||
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
|
||||
gl.uniform1f(this.uTrailAlpha, this.settings.mapOverlay.trailAlpha);
|
||||
gl.uniform1f(this.uTime, (performance.now() - this.startTime) / 1000);
|
||||
gl.uniform1i(this.uAltView, this.altView ? 1 : 0);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
@@ -178,6 +188,8 @@ export class TrailPass {
|
||||
gl.activeTexture(gl.TEXTURE2);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex);
|
||||
}
|
||||
gl.activeTexture(gl.TEXTURE3);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.effectTex);
|
||||
|
||||
gl.bindVertexArray(this.vao);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
|
||||
@@ -5,8 +5,13 @@ precision highp usampler2D;
|
||||
uniform usampler2D uTrailTex; // R8UI — trail ownerID per cell (0 = none)
|
||||
uniform sampler2D uPalette; // RGBA32F — player colors
|
||||
uniform sampler2D uAffiliation; // RGBA8 — affiliation colors (row 0 = border, row 1 = unit)
|
||||
uniform sampler2D uEffect; // RGBA32F — trail gradient, keyed by ownerID:
|
||||
// row r = color r's rgb; spare alphas hold scalars:
|
||||
// row 0.a = color count (0 = no effect → territory color),
|
||||
// row 1.a = colorSize (band width), row 2.a = movementSpeed
|
||||
uniform vec2 uMapSize;
|
||||
uniform float uTrailAlpha;
|
||||
uniform float uTime; // seconds, for the flowing gradient animation
|
||||
uniform int uAltView;
|
||||
|
||||
in vec2 vWorldPos;
|
||||
@@ -22,10 +27,37 @@ void main() {
|
||||
|
||||
vec3 color;
|
||||
if (uAltView != 0) {
|
||||
// Alt view recolors everything by affiliation — effects stay off so the
|
||||
// strategic overlay reads consistently.
|
||||
color = texelFetch(uAffiliation, ivec2(int(trailOwner), 1), 0).rgb;
|
||||
} else {
|
||||
float u = (float(trailOwner) + 0.5) / float(PALETTE_SIZE);
|
||||
color = texture(uPalette, vec2(u, 0.25)).rgb;
|
||||
int owner = int(trailOwner);
|
||||
int count = int(texelFetch(uEffect, ivec2(owner, 0), 0).a + 0.5);
|
||||
if (count <= 0) {
|
||||
// No effect — fall back to the player's territory color.
|
||||
float u = (float(trailOwner) + 0.5) / float(PALETTE_SIZE);
|
||||
color = texture(uPalette, vec2(u, 0.25)).rgb;
|
||||
} else if (count == 1) {
|
||||
// Single color — flat trail.
|
||||
color = texelFetch(uEffect, ivec2(owner, 0), 0).rgb;
|
||||
} else {
|
||||
// Multiple colors — cyclic gradient banded across the map (world-space
|
||||
// diagonal), scrolling over time so a moving trail shifts hue along it.
|
||||
// colorSize scales the band width (colorSize = 1 is the default size, ~4
|
||||
// tiles per band); movementSpeed = tiles/sec the bands travel.
|
||||
float colorSize = max(texelFetch(uEffect, ivec2(owner, 1), 0).a, 0.001);
|
||||
float movementSpeed = texelFetch(uEffect, ivec2(owner, 2), 0).a;
|
||||
// 4.0 = tiles per band at colorSize 1; tune for default band thickness.
|
||||
float cycle = colorSize * 4.0 * float(count);
|
||||
float phase =
|
||||
fract((vWorldPos.x + vWorldPos.y - uTime * movementSpeed) / cycle);
|
||||
float f = phase * float(count);
|
||||
int i = int(f) % count;
|
||||
int j = (i + 1) % count;
|
||||
vec3 a = texelFetch(uEffect, ivec2(owner, i), 0).rgb;
|
||||
vec3 b = texelFetch(uEffect, ivec2(owner, j), 0).rgb;
|
||||
color = mix(a, b, fract(f));
|
||||
}
|
||||
}
|
||||
fragColor = vec4(color, uTrailAlpha);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ export function getPaletteSize(): number {
|
||||
return PALETTE_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Max colors per transport-ship-trail gradient = rows in the trail-effect
|
||||
* texture. Longer catalog color lists are truncated. Shared so the CPU side
|
||||
* that fills the texture and the GPU side that allocates it can't drift.
|
||||
*/
|
||||
export const MAX_TRAIL_COLORS = 8;
|
||||
|
||||
// ---------- Terrain ----------
|
||||
|
||||
/** Parse a "#rrggbb" (or "rrggbb") hex string into an RGB tuple, or null. */
|
||||
|
||||
+38
-25
@@ -102,27 +102,20 @@ export const SkinSchema = CosmeticSchema.extend({
|
||||
export const EFFECT_TYPES = ["transportShipTrail"] as const;
|
||||
export const EffectTypeSchema = z.enum(EFFECT_TYPES);
|
||||
|
||||
// Boat-trail styles, discriminated on `type`: each known style carries exactly
|
||||
// the fields it uses (rainbow has none; solid/pulse need a color; gradient needs
|
||||
// both). A `type` we don't recognize — a style shipped to cosmetics.json before
|
||||
// this client updated — normalizes to { type: "unknown" } instead of failing the
|
||||
// catalog parse, so one new style never wipes the whole catalog; the renderer
|
||||
// shows a neutral swatch. `type` itself stays required.
|
||||
export const TransportShipTrailAttributesSchema = z.union([
|
||||
z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("solid"), color: z.string() }),
|
||||
z.object({ type: z.literal("rainbow") }),
|
||||
z.object({ type: z.literal("pulse"), color: z.string() }),
|
||||
z.object({
|
||||
type: z.literal("gradient"),
|
||||
color: z.string(),
|
||||
color2: z.string(),
|
||||
}),
|
||||
]),
|
||||
z
|
||||
.object({ type: z.string() })
|
||||
.transform(() => ({ type: "unknown" as const })),
|
||||
]);
|
||||
// A boat trail is a gradient of one or more colors, cycled along the trail. The
|
||||
// old solid/rainbow styles are just color lists now: solid = a single color,
|
||||
// rainbow = the spectrum, gradient = two or more. The server only ships this
|
||||
// "gradient" shape. Colors are unvalidated strings here; the renderer drops any
|
||||
// it can't parse (and an empty list falls back to the player's territory color).
|
||||
// `colorSize` is how wide each color band is, in tiles (larger = bigger bands);
|
||||
// `movementSpeed` is how fast the bands scroll along the trail, in tiles per
|
||||
// second (0 = static).
|
||||
export const TransportShipTrailAttributesSchema = z.object({
|
||||
type: z.literal("gradient"),
|
||||
colors: z.array(z.string()),
|
||||
colorSize: z.number(),
|
||||
movementSpeed: z.number(),
|
||||
});
|
||||
|
||||
const TransportShipTrailEffectSchema = CosmeticSchema.extend({
|
||||
effectType: z.literal("transportShipTrail"),
|
||||
@@ -135,6 +128,23 @@ export const EffectSchema = z.discriminatedUnion("effectType", [
|
||||
TransportShipTrailEffectSchema,
|
||||
]);
|
||||
|
||||
/**
|
||||
* A record that drops entries failing `schema` instead of failing the whole
|
||||
* parse. Used for the effect catalog: a newer effect the server ships before
|
||||
* this client is updated to understand it is skipped rather than taking patterns,
|
||||
* flags, and skins down with it.
|
||||
*/
|
||||
function lenientRecord<T extends z.ZodType>(schema: T) {
|
||||
return z.record(z.string(), z.unknown()).transform((rec) => {
|
||||
const out: Record<string, z.infer<T>> = {};
|
||||
for (const [key, value] of Object.entries(rec)) {
|
||||
const parsed = schema.safeParse(value);
|
||||
if (parsed.success) out[key] = parsed.data;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
export const PackSchema = CosmeticSchema.extend({
|
||||
displayName: z.string(),
|
||||
currency: z.enum(["hard", "soft"]),
|
||||
@@ -157,12 +167,15 @@ export const CosmeticsSchema = z.object({
|
||||
skins: z.record(z.string(), SkinSchema).optional(),
|
||||
// Grouped by effectType. Each effect also carries its own effectType (matching
|
||||
// this outer key) so an Effect stands alone and EffectSchema can discriminate
|
||||
// on it. Add a key per new effectType.
|
||||
// on it. Add a key per new effectType. Forward-compat: a brand-new effectType
|
||||
// key is ignored (z.object strips keys it doesn't list), and lenientRecord
|
||||
// extends that to new entries under a known effectType (a dropped effect just
|
||||
// degrades to "no effect" — the trail keeps its territory color).
|
||||
effects: z
|
||||
.object({
|
||||
transportShipTrail: z
|
||||
.record(z.string(), TransportShipTrailEffectSchema)
|
||||
.optional(),
|
||||
transportShipTrail: lenientRecord(
|
||||
TransportShipTrailEffectSchema,
|
||||
).optional(),
|
||||
})
|
||||
.optional(),
|
||||
currencyPacks: z.record(z.string(), PackSchema).optional(),
|
||||
|
||||
+113
-75
@@ -15,94 +15,70 @@ describe("Effect cosmetic schemas", () => {
|
||||
rarity: "common",
|
||||
};
|
||||
|
||||
describe("TransportShipTrailAttributesSchema (lenient)", () => {
|
||||
it("parses the known attribute variants", () => {
|
||||
describe("TransportShipTrailAttributesSchema", () => {
|
||||
it("parses a gradient with a color list, colorSize, and movementSpeed", () => {
|
||||
const parsed = TransportShipTrailAttributesSchema.parse({
|
||||
type: "gradient",
|
||||
colors: ["#f00", "#00f"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
});
|
||||
expect(parsed).toEqual({
|
||||
type: "gradient",
|
||||
colors: ["#f00", "#00f"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a single-color list (solid) and an empty list", () => {
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.safeParse({
|
||||
type: "solid",
|
||||
color: "#f00",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.safeParse({ type: "rainbow" })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.safeParse({
|
||||
type: "pulse",
|
||||
color: "#0f0",
|
||||
type: "gradient",
|
||||
colors: ["#f00"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.safeParse({
|
||||
type: "gradient",
|
||||
color: "#f00",
|
||||
color2: "#00f",
|
||||
colors: [],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("tolerates an unknown attribute type (ignored at render time)", () => {
|
||||
it("requires the gradient type, colors, colorSize, and movementSpeed", () => {
|
||||
// The old solid/rainbow/pulse styles are gone — only gradient remains.
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.safeParse({ type: "sparkle" })
|
||||
.success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a `type`", () => {
|
||||
TransportShipTrailAttributesSchema.safeParse({ type: "solid" }).success,
|
||||
).toBe(false);
|
||||
// colors, colorSize, and movementSpeed are all required.
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.safeParse({
|
||||
type: "gradient",
|
||||
colors: ["#f00"],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(TransportShipTrailAttributesSchema.safeParse({}).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TransportShipTrailAttributesSchema (discriminated styles)", () => {
|
||||
it("keeps the fields of a known style", () => {
|
||||
const solid = TransportShipTrailAttributesSchema.parse({
|
||||
type: "solid",
|
||||
color: "#f00",
|
||||
});
|
||||
expect(solid).toEqual({ type: "solid", color: "#f00" });
|
||||
const gradient = TransportShipTrailAttributesSchema.parse({
|
||||
type: "gradient",
|
||||
color: "#f00",
|
||||
color2: "#00f",
|
||||
});
|
||||
expect(gradient).toEqual({
|
||||
type: "gradient",
|
||||
color: "#f00",
|
||||
color2: "#00f",
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes an unrecognized style to { type: "unknown" }', () => {
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.parse({ type: "sparkle" }),
|
||||
).toEqual({ type: "unknown" });
|
||||
});
|
||||
|
||||
it("normalizes a known style missing required fields to unknown", () => {
|
||||
// solid without color / gradient without color2 don't match their strict
|
||||
// variant, so they degrade to the neutral unknown swatch rather than
|
||||
// failing the parse.
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.parse({ type: "solid" }),
|
||||
).toEqual({ type: "unknown" });
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.parse({
|
||||
type: "gradient",
|
||||
color: "#f00",
|
||||
}),
|
||||
).toEqual({ type: "unknown" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("EffectSchema", () => {
|
||||
it("parses an effect (discriminated on effectType)", () => {
|
||||
expect(
|
||||
EffectSchema.safeParse({
|
||||
...base,
|
||||
attributes: { type: "rainbow" },
|
||||
attributes: {
|
||||
type: "gradient",
|
||||
colors: ["#f00", "#0f0", "#00f"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
},
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -116,18 +92,23 @@ describe("Effect cosmetic schemas", () => {
|
||||
EffectSchema.safeParse({
|
||||
...base,
|
||||
effectType: "glow",
|
||||
attributes: { type: "rainbow" },
|
||||
attributes: {
|
||||
type: "gradient",
|
||||
colors: ["#f00"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates an effect with an unknown attribute type", () => {
|
||||
it("rejects an effect with a non-gradient attribute type", () => {
|
||||
expect(
|
||||
EffectSchema.safeParse({
|
||||
...base,
|
||||
attributes: { type: "sparkle" },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,7 +124,12 @@ describe("Effect cosmetic schemas", () => {
|
||||
rainbow_ship: {
|
||||
name: "rainbow_ship",
|
||||
effectType: "transportShipTrail",
|
||||
attributes: { type: "rainbow" },
|
||||
attributes: {
|
||||
type: "gradient",
|
||||
colors: ["#ff0000", "#ffe600", "#00a8ff", "#7d5fff"],
|
||||
colorSize: 24,
|
||||
movementSpeed: 0.2,
|
||||
},
|
||||
affiliateCode: null,
|
||||
product: null,
|
||||
priceHard: 123,
|
||||
@@ -154,8 +140,9 @@ describe("Effect cosmetic schemas", () => {
|
||||
effectType: "transportShipTrail",
|
||||
attributes: {
|
||||
type: "gradient",
|
||||
color: "#aea2a2",
|
||||
color2: "#a80000",
|
||||
colors: ["#aea2a2", "#a80000"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
},
|
||||
affiliateCode: null,
|
||||
product: {
|
||||
@@ -172,8 +159,9 @@ describe("Effect cosmetic schemas", () => {
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(
|
||||
result.data.effects?.transportShipTrail?.rainbow_ship?.attributes?.type,
|
||||
).toBe("rainbow");
|
||||
result.data.effects?.transportShipTrail?.rainbow_ship?.attributes
|
||||
?.colors,
|
||||
).toEqual(["#ff0000", "#ffe600", "#00a8ff", "#7d5fff"]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -186,7 +174,12 @@ describe("Effect cosmetic schemas", () => {
|
||||
ship: {
|
||||
name: "ship",
|
||||
effectType: "transportShipTrail",
|
||||
attributes: { type: "solid", color: "#fff" },
|
||||
attributes: {
|
||||
type: "gradient",
|
||||
colors: ["#fff"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
},
|
||||
product: null,
|
||||
rarity: "common",
|
||||
},
|
||||
@@ -203,12 +196,57 @@ describe("Effect cosmetic schemas", () => {
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("drops a newer-shaped effect within a known effectType without failing the catalog", () => {
|
||||
const result = CosmeticsSchema.safeParse({
|
||||
patterns: {},
|
||||
flags: {},
|
||||
effects: {
|
||||
transportShipTrail: {
|
||||
good: {
|
||||
name: "good",
|
||||
effectType: "transportShipTrail",
|
||||
attributes: {
|
||||
type: "gradient",
|
||||
colors: ["#fff"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
},
|
||||
product: null,
|
||||
rarity: "common",
|
||||
},
|
||||
// A newer effect shape this client doesn't understand yet — must be
|
||||
// dropped, not fail the whole catalog parse.
|
||||
future: {
|
||||
name: "future",
|
||||
effectType: "transportShipTrail",
|
||||
attributes: { type: "hologram", intensity: 3 },
|
||||
product: null,
|
||||
rarity: "common",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const trails = result.data.effects?.transportShipTrail;
|
||||
// The good effect survives...
|
||||
expect(trails?.good?.name).toBe("good");
|
||||
// ...and only the unparseable newer one is dropped.
|
||||
expect(trails?.future).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("findEffect", () => {
|
||||
const effect = (name: string) => ({
|
||||
name,
|
||||
attributes: { type: "solid", color: "#fff" } as const,
|
||||
attributes: {
|
||||
type: "gradient",
|
||||
colors: ["#fff"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
} as const,
|
||||
product: null,
|
||||
rarity: "common" as const,
|
||||
});
|
||||
|
||||
+18
-3
@@ -99,7 +99,12 @@ const effectCosmetics = {
|
||||
spectrum: {
|
||||
name: "spectrum",
|
||||
effectType: "transportShipTrail" as const,
|
||||
attributes: { type: "rainbow" } as const,
|
||||
attributes: {
|
||||
type: "gradient" as const,
|
||||
colors: ["#ff0000", "#00ff00", "#0000ff"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
},
|
||||
url: "",
|
||||
affiliateCode: null,
|
||||
product: null,
|
||||
@@ -110,7 +115,12 @@ const effectCosmetics = {
|
||||
crimson: {
|
||||
name: "crimson",
|
||||
effectType: "transportShipTrail" as const,
|
||||
attributes: { type: "solid", color: "#e01b24" } as const,
|
||||
attributes: {
|
||||
type: "gradient" as const,
|
||||
colors: ["#e01b24"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
},
|
||||
url: "",
|
||||
affiliateCode: null,
|
||||
product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
|
||||
@@ -638,7 +648,12 @@ describe("Effect validation in isAllowed", () => {
|
||||
trail_01: {
|
||||
name: "spectrum",
|
||||
effectType: "transportShipTrail" as const,
|
||||
attributes: { type: "rainbow" } as const,
|
||||
attributes: {
|
||||
type: "gradient" as const,
|
||||
colors: ["#ff0000", "#00ff00", "#0000ff"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
},
|
||||
url: "",
|
||||
affiliateCode: null,
|
||||
product: null,
|
||||
|
||||
Reference in New Issue
Block a user