From a15338e7bd76230311d196f45834f25a9de10239 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 29 Jun 2026 20:52:52 -0700 Subject: [PATCH 1/2] transition --- src/client/WebGLFrameBuilder.ts | 21 ++-- src/client/components/EffectPreview.ts | 95 +++++++++++++++---- .../gl/shaders/map-overlay/trail.frag.glsl | 30 ++++-- src/core/CosmeticSchemas.ts | 36 ++++--- tests/CosmeticSchemas.test.ts | 24 ++++- 5 files changed, 158 insertions(+), 48 deletions(-) diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts index 84ae010a08..bf2ecd2048 100644 --- a/src/client/WebGLFrameBuilder.ts +++ b/src/client/WebGLFrameBuilder.ts @@ -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; } diff --git a/src/client/components/EffectPreview.ts b/src/client/components/EffectPreview.ts index afee3dcf6f..ab940cbea0 100644 --- a/src/client/components/EffectPreview.ts +++ b/src/client/components/EffectPreview.ts @@ -1,27 +1,90 @@ -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). */ +@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`
`; + } + + updated(changed: Map): 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("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; + } +} + +/** Render a transport-ship-trail swatch (animated for the transition style). */ 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`
`; + // block + full size so the inner swatch fills the host (custom elements are + // inline by default, which would collapse the inner w-full/h-full). + return html``; } diff --git a/src/client/render/gl/shaders/map-overlay/trail.frag.glsl b/src/client/render/gl/shaders/map-overlay/trail.frag.glsl index 8ab748451b..cc2144d5c7 100644 --- a/src/client/render/gl/shaders/map-overlay/trail.frag.glsl +++ b/src/client/render/gl/shaders/map-overlay/trail.frag.glsl @@ -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 = diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index 84c66779a9..7819209763 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -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"), diff --git a/tests/CosmeticSchemas.test.ts b/tests/CosmeticSchemas.test.ts index 3caa7481cb..1cdb905ae8 100644 --- a/tests/CosmeticSchemas.test.ts +++ b/tests/CosmeticSchemas.test.ts @@ -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", () => { From cbd759a42b35e2b82b3a5d7f5d382aebd5712ac3 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 29 Jun 2026 21:49:39 -0700 Subject: [PATCH 2/2] refactor: inline renderTransportShipTrailSwatch into call sites Use the element directly in CosmeticButton and EffectsInput and drop the thin wrapper; the named imports become side-effect imports so the element still registers. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/client/EffectsInput.ts | 11 +++++++---- src/client/components/CosmeticButton.ts | 7 +++++-- src/client/components/EffectPreview.ts | 12 ------------ 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/client/EffectsInput.ts b/src/client/EffectsInput.ts index 3285c562f1..f9404b73bd 100644 --- a/src/client/EffectsInput.ts +++ b/src/client/EffectsInput.ts @@ -8,7 +8,7 @@ import { EFFECTS_KEY, USER_SETTINGS_CHANGED_EVENT, } from "../core/game/UserSettings"; -import { renderTransportShipTrailSwatch } from "./components/EffectPreview"; +import "./components/EffectPreview"; // registers 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")} ` - : html`${renderTransportShipTrailSwatch(this.trailAttributes)}`; + : html` + + `; return html`