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
co-authored by Claude Opus 4.8
parent 7c151e76ad
commit 200f276ab2
7 changed files with 161 additions and 57 deletions
+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;
}
}