mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-01 04:33:30 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 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.
|
||||
@@ -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 0–2 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user