Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/client/EffectsInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
21 changes: 13 additions & 8 deletions src/client/WebGLFrameBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}

Expand Down
7 changes: 5 additions & 2 deletions src/client/components/CosmeticButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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") {
Expand Down
89 changes: 70 additions & 19 deletions src/client/components/EffectPreview.ts
Original file line number Diff line number Diff line change
@@ -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>`;
Comment on lines +29 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Filter preview colors the same way the renderer does.

TransportShipTrailAttributesSchema allows raw strings, and WebGLFrameBuilder.writeEffectEntry() drops any color it can't parse. Here Lines 30-40 and Lines 55-66 use those raw values directly in CSS/WAAPI, so one bad entry can make the swatch blank or disagree with the actual trail. Normalize/filter first, then fall back to EMPTY_BG when nothing valid remains.

💡 Proposed fix
+import { colord } from "colord";
 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`";
+
+function getRenderableColors(
+  trail: TransportShipTrailAttributes | null,
+): string[] {
+  return (trail?.colors ?? []).filter((color) => colord(color).isValid());
+}
@@
   render(): TemplateResult {
-    const colors = this.trail?.colors ?? [];
+    const colors = getRenderableColors(this.trail);
@@
-    const colors = attrs.colors;
+    const colors = getRenderableColors(attrs);
     if (colors.length < 2 || attrs.frequency <= 0) return;

Also applies to: 53-69

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/components/EffectPreview.ts` around lines 29 - 45, The
EffectPreview swatch is using raw trail colors directly, which can disagree with
the renderer when invalid color strings are present. Update
EffectPreview.render() to normalize/filter this.trail?.colors the same way the
renderer logic does before building the CSS background, and fall back to
EMPTY_BG when no valid colors remain. Keep the logic in render() aligned with
the trail color handling used by WebGLFrameBuilder.writeEffectEntry() so the
preview matches the actual effect.

}

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;
}
}
30 changes: 21 additions & 9 deletions src/client/render/gl/shaders/map-overlay/trail.frag.glsl
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 =
Expand Down
36 changes: 22 additions & 14 deletions src/core/CosmeticSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Comment on lines +121 to +124

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject negative transition frequencies.

Line 124 currently accepts negative values. TrailSwatch.updated() treats non-positive frequencies as “don’t animate”, but trail.frag.glsl still uses the encoded value in modulo math, so a negative catalog entry can preview as static and render with invalid color indexing. Validate this as non-negative here (or positive if zero should also be rejected), and add a regression test for that contract.

💡 Proposed fix
   z.object({
     type: z.literal("transition"),
     colors: z.array(z.string()),
-    frequency: z.number(),
+    frequency: z.number().nonnegative(),
   }),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
z.object({
type: z.literal("transition"),
colors: z.array(z.string()),
frequency: z.number(),
z.object({
type: z.literal("transition"),
colors: z.array(z.string()),
frequency: z.number().nonnegative(),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/CosmeticSchemas.ts` around lines 121 - 124, The transition schema in
CosmeticSchemas is currently allowing negative frequency values, which can later
break preview/render behavior. Update the z.object validator for the transition
type to reject negative frequencies (or require strictly positive if zero should
be disallowed) so the contract matches TrailSwatch.updated() and
trail.frag.glsl. Add a regression test covering the transition schema validation
to ensure invalid negative frequency values are rejected.

}),
]);

const TransportShipTrailEffectSchema = CosmeticSchema.extend({
effectType: z.literal("transportShipTrail"),
Expand Down
24 changes: 23 additions & 1 deletion tests/CosmeticSchemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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", () => {
Expand Down
Loading