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>
This commit is contained in:
Evan
2026-06-29 21:53:33 -07:00
committed by GitHub
parent 7c151e76ad
commit 200f276ab2
7 changed files with 161 additions and 57 deletions
+7 -4
View File
@@ -8,7 +8,7 @@ import {
EFFECTS_KEY,
USER_SETTINGS_CHANGED_EVENT,
} from "../core/game/UserSettings";
import { renderTransportShipTrailSwatch } from "./components/EffectPreview";
import "./components/EffectPreview"; // registers <trail-swatch>
import { fetchCosmetics, getPlayerCosmetics } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { translateText } from "./Utils";
@@ -87,9 +87,12 @@ export class EffectsInput extends LitElement {
>
${translateText("effects.title")}
</span>`
: html`<span class="w-full h-full p-1.5"
>${renderTransportShipTrailSwatch(this.trailAttributes)}</span
>`;
: html`<span class="w-full h-full p-1.5">
<trail-swatch
class="block w-full h-full"
.trail=${this.trailAttributes}
></trail-swatch>
</span>`;
return html`
<button
+13 -8
View File
@@ -308,11 +308,13 @@ export class WebGLFrameBuilder {
}
/**
* Encode a player's transport-ship-trail gradient into the effect palette.
* 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 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).
* 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.
@@ -334,11 +336,14 @@ export class WebGLFrameBuilder {
this.effectPalette[off + 2] = c.b / 255;
this.effectPalette[off + 3] = 0;
}
// Scalar params packed into spare alpha channels (rows 02 always exist).
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] = attrs.colorSize;
this.effectPalette[(2 * PALETTE_SIZE + smallID) * 4 + 3] =
attrs.movementSpeed;
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;
}
+5 -2
View File
@@ -19,7 +19,7 @@ import { translateText } from "../Utils";
import "./CapIcon";
import "./CosmeticContainer";
import "./CosmeticInfo";
import { renderTransportShipTrailSwatch } from "./EffectPreview";
import "./EffectPreview"; // registers <trail-swatch>
import { renderPatternPreview } from "./PatternPreview";
import "./PlutoniumIcon";
@@ -187,7 +187,10 @@ export class CosmeticButton extends LitElement {
</div>`;
}
// Only effectType today is transportShipTrail; c.attributes is its style.
return renderTransportShipTrailSwatch(c.attributes);
return html`<trail-swatch
class="block w-full h-full"
.trail=${c.attributes}
></trail-swatch>`;
}
if (this.activeResolved.type === "pack") {
+70 -19
View File
@@ -1,27 +1,78 @@
import { html, TemplateResult } from "lit";
import { html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { TransportShipTrailAttributes } from "../../core/CosmeticSchemas";
// 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. 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.
* Swatch preview of a transport-ship-trail effect, filling its container.
*
* - gradient / single color: a static swatch (flat color or left-to-right
* gradient — a multi-color list reads as a rainbow).
* - transition: cross-fades through the colors over time, mirroring the trail
* (each color step lasts 1/frequency seconds, matching the shader).
*/
export function renderTransportShipTrailSwatch(
attributes: TransportShipTrailAttributes,
): TemplateResult {
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>`;
@customElement("trail-swatch")
export class TrailSwatch extends LitElement {
// Named `trail` (not `attributes`) to avoid clashing with Element.attributes.
@property({ attribute: false })
trail: TransportShipTrailAttributes | null = null;
private animation: Animation | null = null;
// Light DOM so the shared Tailwind classes apply.
createRenderRoot(): HTMLElement {
return this;
}
render(): TemplateResult {
const colors = this.trail?.colors ?? [];
let background: string;
if (colors.length === 0) {
background = EMPTY_BG;
} else if (this.trail?.type === "transition") {
// The animation (see updated) cross-fades from here through the list.
background = colors[0];
} else if (colors.length === 1) {
background = colors[0];
} else {
background = `linear-gradient(90deg,${colors.join(",")})`;
}
return html`<div
class="w-full h-full rounded-md"
style="background:${background};"
></div>`;
}
updated(changed: Map<string, unknown>): void {
if (!changed.has("trail")) return;
this.animation?.cancel();
this.animation = null;
const attrs = this.trail;
if (attrs?.type !== "transition") return;
const colors = attrs.colors;
if (colors.length < 2 || attrs.frequency <= 0) return;
const fill = this.querySelector<HTMLElement>("div");
if (!fill) return;
// Cross-fade color0 → color1 → … → color0; each step lasts 1/frequency s,
// matching the shader's i = floor(uTime * frequency) mod count.
const keyframes = [...colors, colors[0]].map((c) => ({
backgroundColor: c,
}));
this.animation = fill.animate(keyframes, {
duration: (colors.length / attrs.frequency) * 1000,
iterations: Infinity,
easing: "linear",
});
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.animation?.cancel();
this.animation = null;
}
}
@@ -5,13 +5,15 @@ 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:
uniform sampler2D uEffect; // RGBA32F — trail effect, 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
// row 1.a = styleId (0 = gradient, 1 = transition),
// row 2.a = scalar0 (gradient colorSize / transition freq),
// row 3.a = scalar1 (gradient movementSpeed)
uniform vec2 uMapSize;
uniform float uTrailAlpha;
uniform float uTime; // seconds, for the flowing gradient animation
uniform float uTime; // seconds, for animated effect styles
uniform int uAltView;
in vec2 vWorldPos;
@@ -40,13 +42,23 @@ void main() {
} else if (count == 1) {
// Single color — flat trail.
color = texelFetch(uEffect, ivec2(owner, 0), 0).rgb;
} else if (int(texelFetch(uEffect, ivec2(owner, 1), 0).a + 0.5) == 1) {
// transition — the whole trail is one color at a time, cross-fading
// through the list over time. frequency = color changes per second.
float frequency = texelFetch(uEffect, ivec2(owner, 2), 0).a;
float t = uTime * frequency;
int i = int(t) % 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(t));
} 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;
// gradient — 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 ≈ 4 tiles per band); movementSpeed
// = tiles/sec the bands travel.
float colorSize = max(texelFetch(uEffect, ivec2(owner, 2), 0).a, 0.001);
float movementSpeed = texelFetch(uEffect, ivec2(owner, 3), 0).a;
// 4.0 = tiles per band at colorSize 1; tune for default band thickness.
float cycle = colorSize * 4.0 * float(count);
float phase =
+22 -14
View File
@@ -102,20 +102,28 @@ export const SkinSchema = CosmeticSchema.extend({
export const EFFECT_TYPES = ["transportShipTrail"] as const;
export const EffectTypeSchema = z.enum(EFFECT_TYPES);
// 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(),
});
// A boat trail effect, discriminated on `type`:
// - "gradient": the colors form a spatial gradient banded along the trail.
// `colorSize` = band width in tiles (larger = bigger bands); `movementSpeed`
// = how fast the bands scroll, in tiles/sec (0 = static).
// - "transition": the whole trail is one color at a time, cross-fading through
// the color list over time. `frequency` = color changes per second.
// solid = a single-color list; rainbow = the spectrum as a gradient. 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).
export const TransportShipTrailAttributesSchema = 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(),
}),
]);
const TransportShipTrailEffectSchema = CosmeticSchema.extend({
effectType: z.literal("transportShipTrail"),
+23 -1
View File
@@ -51,7 +51,7 @@ describe("Effect cosmetic schemas", () => {
});
it("requires the gradient type, colors, colorSize, and movementSpeed", () => {
// The old solid/rainbow/pulse styles are gone — only gradient remains.
// Unrecognized styles (no discriminated-union member) are rejected.
expect(
TransportShipTrailAttributesSchema.safeParse({ type: "solid" }).success,
).toBe(false);
@@ -66,6 +66,28 @@ describe("Effect cosmetic schemas", () => {
false,
);
});
it("parses a transition with a color list and frequency", () => {
const parsed = TransportShipTrailAttributesSchema.parse({
type: "transition",
colors: ["#002aff", "#4805ff"],
frequency: 1,
});
expect(parsed).toEqual({
type: "transition",
colors: ["#002aff", "#4805ff"],
frequency: 1,
});
});
it("requires frequency for a transition", () => {
expect(
TransportShipTrailAttributesSchema.safeParse({
type: "transition",
colors: ["#002aff", "#4805ff"],
}).success,
).toBe(false);
});
});
describe("EffectSchema", () => {