mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-19 00:43:16 +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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user