{
}
}
+ let transportTrailName =
+ userSettings.getSelectedTransportTrailName() ?? undefined;
+ if (transportTrailName) {
+ const trail = cosmetics?.transportTrails?.[transportTrailName];
+ if (cosmetics && !trail) {
+ // Cosmetics loaded but the saved trail no longer exists.
+ transportTrailName = undefined;
+ } else if (trail) {
+ const userMe = await getUserMe();
+ if (userMe) {
+ const flares = userMe.player.flares ?? [];
+ const hasWildcard = flares.includes("trail:*");
+ if (!hasWildcard && !flares.includes(`trail:${trail.name}`)) {
+ transportTrailName = undefined;
+ }
+ }
+ }
+ if (transportTrailName === undefined) {
+ userSettings.setSelectedTransportTrailName(undefined);
+ }
+ }
+
return {
flag: flag ?? undefined,
patternName: pattern?.name ?? undefined,
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
skinName,
+ transportTrailName,
};
}
@@ -608,6 +675,19 @@ export async function getPlayerCosmetics(): Promise {
}
}
+ const devTrail = new UserSettings().getDevOnlyTransportTrail();
+ if (devTrail) {
+ result.transportTrail = devTrail;
+ } else if (refs.transportTrailName && cosmetics) {
+ const trail = cosmetics.transportTrails?.[refs.transportTrailName];
+ if (trail) {
+ result.transportTrail = {
+ name: refs.transportTrailName,
+ effect: trail.effect,
+ };
+ }
+ }
+
return result;
}
diff --git a/src/client/Main.ts b/src/client/Main.ts
index b3dabb9ad..6a2e4f35d 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -53,6 +53,9 @@ import {
SendToggleGameStartTimer,
SendUpdateGameConfigIntentEvent,
} from "./Transport";
+import "./TransportTrailInput";
+import "./TransportTrailModal";
+import { TransportTrailModal } from "./TransportTrailModal";
import { UserSettingModal } from "./UserSettingModal";
import "./UsernameInput";
import { genAnonUsername, UsernameInput } from "./UsernameInput";
@@ -310,6 +313,9 @@ class Client {
modalRouter.register("territory-patterns", {
tag: "territory-patterns-modal",
});
+ modalRouter.register("transport-trail", {
+ tag: "transport-trail-modal",
+ });
modalRouter.register("flag-input", { tag: "flag-input-modal" });
// Prefetch turnstile token so it is available when
@@ -440,6 +446,18 @@ class Client {
});
});
+ const trailModal = document.getElementById(
+ "transport-trail-modal",
+ ) as TransportTrailModal;
+ if (!trailModal || !(trailModal instanceof TransportTrailModal)) {
+ console.warn("Transport trail modal element not found");
+ }
+ document.querySelectorAll("transport-trail-input").forEach((trailInput) => {
+ trailInput.addEventListener("transport-trail-input-click", () => {
+ trailModal.open();
+ });
+ });
+
if (isInIframe()) {
const mobilePat = document.getElementById("pattern-input-mobile");
if (mobilePat) mobilePat.style.display = "none";
@@ -860,6 +878,7 @@ class Client {
"user-setting",
"troubleshooting-modal",
"territory-patterns-modal",
+ "transport-trail-modal",
"store-modal",
"language-modal",
"news-modal",
diff --git a/src/client/Store.ts b/src/client/Store.ts
index 7189ebf6c..ae0b9560b 100644
--- a/src/client/Store.ts
+++ b/src/client/Store.ts
@@ -16,7 +16,7 @@ import {
} from "./Cosmetics";
import { translateText } from "./Utils";
-type StoreTab = "patterns" | "flags" | "packs" | "subscriptions";
+type StoreTab = "patterns" | "flags" | "trails" | "packs" | "subscriptions";
@customElement("store-modal")
export class StoreModal extends BaseModal {
@@ -43,6 +43,7 @@ export class StoreModal extends BaseModal {
: []),
{ key: "patterns", label: translateText("store.patterns") },
{ key: "flags", label: translateText("store.flags") },
+ { key: "trails", label: translateText("store.trails") },
],
};
}
@@ -146,6 +147,45 @@ export class StoreModal extends BaseModal {
`;
}
+ private renderTrailGrid(): TemplateResult {
+ const items = resolveCosmetics(
+ this.cosmetics,
+ this.userMeResponse,
+ this.affiliateCode,
+ ).filter(
+ (r) =>
+ r.type === "transportTrail" &&
+ r.relationship !== "blocked" &&
+ r.relationship !== "owned",
+ );
+
+ if (items.length === 0) {
+ return html`
+ ${translateText("store.no_trails")}
+
`;
+ }
+
+ const selectedTrail =
+ new UserSettings().getSelectedTransportTrailName() ?? "";
+ return html`
+
+ ${items.map(
+ (r) => html`
+
+ `,
+ )}
+
+ `;
+ }
+
private renderPackGrid(): TemplateResult {
const items = resolveCosmetics(
this.cosmetics,
@@ -230,6 +270,8 @@ export class StoreModal extends BaseModal {
return this.renderPatternGrid();
case "flags":
return this.renderFlagGrid();
+ case "trails":
+ return this.renderTrailGrid();
case "subscriptions":
return this.renderSubscriptionGrid();
case "packs":
@@ -248,6 +290,7 @@ export class StoreModal extends BaseModal {
(r.type === "pattern" ||
r.type === "skin" ||
r.type === "flag" ||
+ r.type === "transportTrail" ||
r.type === "pack") &&
r.relationship === "purchasable",
);
diff --git a/src/client/TransportTrailInput.ts b/src/client/TransportTrailInput.ts
new file mode 100644
index 000000000..f8fe9b580
--- /dev/null
+++ b/src/client/TransportTrailInput.ts
@@ -0,0 +1,106 @@
+import { LitElement, html } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { TrailEffect } from "../core/CosmeticSchemas";
+import {
+ TRANSPORT_TRAIL_KEY,
+ USER_SETTINGS_CHANGED_EVENT,
+} from "../core/game/UserSettings";
+import { renderTrailSwatch } from "./components/TransportTrailPreview";
+import { getPlayerCosmetics } from "./Cosmetics";
+import { crazyGamesSDK } from "./CrazyGamesSDK";
+import { translateText } from "./Utils";
+
+@customElement("transport-trail-input")
+export class TransportTrailInput extends LitElement {
+ @state() private effect: TrailEffect | null = null;
+ @state() private isLoading: boolean = true;
+
+ private _abortController: AbortController | null = null;
+
+ private _onCosmeticSelected = async () => {
+ const cosmetics = await getPlayerCosmetics();
+ this.effect = cosmetics.transportTrail?.effect ?? null;
+ };
+
+ private onInputClick(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent("transport-trail-input-click", {
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+
+ async connectedCallback() {
+ super.connectedCallback();
+ this._abortController = new AbortController();
+ this.isLoading = true;
+ const cosmetics = await getPlayerCosmetics();
+ this.effect = cosmetics.transportTrail?.effect ?? null;
+ if (!this.isConnected) return;
+ this.isLoading = false;
+ window.addEventListener(
+ `${USER_SETTINGS_CHANGED_EVENT}:${TRANSPORT_TRAIL_KEY}`,
+ this._onCosmeticSelected,
+ { signal: this._abortController.signal },
+ );
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ if (this._abortController) {
+ this._abortController.abort();
+ this._abortController = null;
+ }
+ }
+
+ createRenderRoot() {
+ return this;
+ }
+
+ render() {
+ if (crazyGamesSDK.isOnCrazyGames()) {
+ return html``;
+ }
+
+ const buttonTitle = translateText("transport_trails.title");
+
+ if (this.isLoading) {
+ return html`
+
+ `;
+ }
+
+ const preview =
+ this.effect === null
+ ? html`
+ ${translateText("transport_trails.select")}
+ `
+ : html`${renderTrailSwatch(this.effect)}`;
+
+ return html`
+
+ `;
+ }
+}
diff --git a/src/client/TransportTrailModal.ts b/src/client/TransportTrailModal.ts
new file mode 100644
index 000000000..ecb3fa8ec
--- /dev/null
+++ b/src/client/TransportTrailModal.ts
@@ -0,0 +1,194 @@
+import type { TemplateResult } from "lit";
+import { html } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { UserMeResponse } from "../core/ApiSchemas";
+import { Cosmetics, TransportTrail } from "../core/CosmeticSchemas";
+import {
+ TRANSPORT_TRAIL_KEY,
+ USER_SETTINGS_CHANGED_EVENT,
+ UserSettings,
+} from "../core/game/UserSettings";
+import { BaseModal } from "./components/BaseModal";
+import "./components/CosmeticButton";
+import "./components/NotLoggedInWarning";
+import { modalHeader } from "./components/ui/ModalHeader";
+import {
+ fetchCosmetics,
+ getPlayerCosmetics,
+ resolveCosmetics,
+ ResolvedCosmetic,
+} from "./Cosmetics";
+import { translateText } from "./Utils";
+
+// "Default" tile — selecting it clears the trail back to the player color.
+const DEFAULT_TRAIL: ResolvedCosmetic = {
+ type: "transportTrail",
+ cosmetic: null,
+ colorPalette: null,
+ relationship: "owned",
+ key: "transportTrail:default",
+};
+
+@customElement("transport-trail-modal")
+export class TransportTrailModal extends BaseModal {
+ protected routerName = "transport-trail";
+
+ @state() private selectedTrailName: string | null = null;
+ @state() private search = "";
+
+ private cosmetics: Cosmetics | null = null;
+ private userSettings: UserSettings = new UserSettings();
+ private userMeResponse: UserMeResponse | false = false;
+
+ private _onTrailSelected = async () => {
+ await this.updateFromSettings();
+ this.refresh();
+ };
+
+ connectedCallback() {
+ super.connectedCallback();
+ document.addEventListener(
+ "userMeResponse",
+ (event: CustomEvent) => {
+ this.onUserMe(event.detail);
+ },
+ );
+ window.addEventListener(
+ `${USER_SETTINGS_CHANGED_EVENT}:${TRANSPORT_TRAIL_KEY}`,
+ this._onTrailSelected,
+ );
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ window.removeEventListener(
+ `${USER_SETTINGS_CHANGED_EVENT}:${TRANSPORT_TRAIL_KEY}`,
+ this._onTrailSelected,
+ );
+ }
+
+ private async updateFromSettings() {
+ const cosmetics = await getPlayerCosmetics();
+ this.selectedTrailName = cosmetics.transportTrail?.name ?? null;
+ }
+
+ async onUserMe(userMeResponse: UserMeResponse | false) {
+ this.userMeResponse = userMeResponse;
+ this.cosmetics = await fetchCosmetics();
+ await this.updateFromSettings();
+ this.refresh();
+ }
+
+ private includedInSearch(name: string): boolean {
+ const displayName = name.replace(/_/g, " ");
+ return displayName.toLowerCase().includes(this.search.toLowerCase());
+ }
+
+ private handleSearch(event: Event) {
+ this.search = (event.target as HTMLInputElement).value;
+ }
+
+ private renderGrid(): TemplateResult {
+ const owned = resolveCosmetics(
+ this.cosmetics,
+ this.userMeResponse,
+ null,
+ ).filter(
+ (r) =>
+ r.type === "transportTrail" &&
+ r.relationship === "owned" &&
+ this.includedInSearch(r.cosmetic?.name ?? ""),
+ );
+ // The default (clear) tile always shows; the search filter only narrows
+ // the owned cosmetics.
+ const items = this.search ? owned : [DEFAULT_TRAIL, ...owned];
+
+ return html`
+
+
+ ${items.map((r) => {
+ const name = (r.cosmetic as TransportTrail | null)?.name ?? null;
+ const isSelected =
+ (name === null && this.selectedTrailName === null) ||
+ (name !== null && this.selectedTrailName === name);
+ return html`
+ this.selectTrail(rc)}
+ >
+ `;
+ })}
+
+
+ `;
+ }
+
+ protected renderHeaderSlot() {
+ return html`
+
+ ${modalHeader({
+ title: translateText("transport_trails.title"),
+ onBack: () => this.close(),
+ ariaLabel: translateText("common.back"),
+ rightContent: html`
`,
+ })}
+
+
+
+
+
+ `;
+ }
+
+ protected renderBody() {
+ return html`
+
+ {
+ this.close();
+ window.showPage?.("page-item-store");
+ }}
+ >
+
+ ${this.renderGrid()}
+ `;
+ }
+
+ protected async onOpen(): Promise {
+ await this.refresh();
+ }
+
+ protected onClose(): void {
+ this.search = "";
+ }
+
+ private selectTrail(resolved: ResolvedCosmetic) {
+ const name = (resolved.cosmetic as TransportTrail | null)?.name ?? null;
+ this.userSettings.setSelectedTransportTrailName(name ?? undefined);
+ this.selectedTrailName = name;
+ this.refresh();
+ this.close();
+ }
+
+ public async refresh() {
+ this.requestUpdate();
+ }
+}
diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts
index 0e6422a96..3e89c8f67 100644
--- a/src/client/WebGLFrameBuilder.ts
+++ b/src/client/WebGLFrameBuilder.ts
@@ -6,6 +6,7 @@ import { PlayerType } from "../core/game/Game";
import { uploadFrameData } from "./render/frame/Upload";
// Type-only: a value import would pull GPURenderer and its `.glsl?raw` shader
// imports into any non-Vite consumer (e.g. the Node perf harness).
+import type { PlayerTransportTrail } from "../core/Schemas";
import type { MapRenderer, PlayerStatic, SpawnCenter } from "./render/gl";
import type { GameView } from "./view";
@@ -204,6 +205,7 @@ export class WebGLFrameBuilder {
this.knownSmallIDs.add(smallID);
this.writePaletteEntry(smallID, p.territoryColor(), p.borderColor());
+ this.syncTrailStyle(smallID, p.cosmetics.transportTrail);
// p.cosmetics.flag has already been server-resolved to either a full URL
// or a relative asset path (e.g. "/flags/US.svg" or a CDN URL for a
@@ -254,6 +256,50 @@ export class WebGLFrameBuilder {
}
}
+ /**
+ * Translate a player's transport-trail cosmetic into the renderer's per-owner
+ * trail style (base color + effect id). No-op when the player has no trail
+ * cosmetic — the style texture is zero-initialized (effect 0 = palette color).
+ */
+ private syncTrailStyle(
+ smallID: number,
+ trail: PlayerTransportTrail | undefined,
+ ): void {
+ if (!trail) return;
+ const effect = trail.effect;
+ let effectId = 0;
+ let rgb = { r: 0, g: 0, b: 0 };
+ let rgb2 = { r: 0, g: 0, b: 0 };
+ switch (effect.type) {
+ case "solid":
+ effectId = 1;
+ rgb = new Colord(effect.color).toRgb();
+ break;
+ case "rainbow":
+ effectId = 2;
+ break;
+ case "pulse":
+ effectId = 3;
+ rgb = new Colord(effect.color).toRgb();
+ break;
+ case "gradient":
+ effectId = 4;
+ rgb = new Colord(effect.color).toRgb();
+ rgb2 = new Colord(effect.color2).toRgb();
+ break;
+ }
+ this.view.setPlayerTrailStyle(
+ smallID,
+ rgb.r,
+ rgb.g,
+ rgb.b,
+ effectId,
+ rgb2.r,
+ rgb2.g,
+ rgb2.b,
+ );
+ }
+
private writePaletteEntry(
smallID: number,
fill: Colord,
diff --git a/src/client/components/CosmeticButton.ts b/src/client/components/CosmeticButton.ts
index 039bb0fbc..9be45abfb 100644
--- a/src/client/components/CosmeticButton.ts
+++ b/src/client/components/CosmeticButton.ts
@@ -6,6 +6,7 @@ import {
Pattern,
Skin,
Subscription,
+ TransportTrail,
} from "../../core/CosmeticSchemas";
import { PlayerPattern } from "../../core/Schemas";
import {
@@ -20,6 +21,7 @@ import "./CosmeticInfo";
import { renderPatternPreview } from "./PatternPreview";
import "./PlutoniumIcon";
import { DEFAULT_DOLLAR_LABEL_KEY } from "./PurchaseButton";
+import { renderTrailSwatch } from "./TransportTrailPreview";
@customElement("cosmetic-button")
export class CosmeticButton extends LitElement {
@@ -61,6 +63,9 @@ export class CosmeticButton extends LitElement {
if (this.resolved.type === "subscription") {
return translateCosmetic("subscriptions", c.name);
}
+ if (this.resolved.type === "transportTrail") {
+ return translateCosmetic("transport_trails", c.name);
+ }
return translateCosmetic("flags", c.name);
}
@@ -97,6 +102,19 @@ export class CosmeticButton extends LitElement {
/>`;
}
+ if (this.resolved.type === "transportTrail") {
+ const c = this.resolved.cosmetic as TransportTrail | null;
+ if (c === null) {
+ // "Default" tile — selecting it clears the trail back to the player color.
+ return html`
+ ${translateText("territory_patterns.pattern.default")}
+
`;
+ }
+ return renderTrailSwatch(c.effect);
+ }
+
if (this.resolved.type === "pack") {
const pack = this.resolved.cosmetic as Pack;
const isHard = pack.currency === "hard";
diff --git a/src/client/components/PlayPage.ts b/src/client/components/PlayPage.ts
index ef33aa100..2ef25aea1 100644
--- a/src/client/components/PlayPage.ts
+++ b/src/client/components/PlayPage.ts
@@ -96,6 +96,10 @@ export class PlayPage extends LitElement {
show-select-label
class="shrink-0 lg:hidden h-10 w-10"
>
+
@@ -111,6 +115,10 @@ export class PlayPage extends LitElement {
show-select-label
class="flex-1 h-full"
>
+
diff --git a/src/client/components/TransportTrailPreview.ts b/src/client/components/TransportTrailPreview.ts
new file mode 100644
index 000000000..5913c23aa
--- /dev/null
+++ b/src/client/components/TransportTrailPreview.ts
@@ -0,0 +1,32 @@
+import { html, TemplateResult } from "lit";
+import { TrailEffect } from "../../core/CosmeticSchemas";
+
+// A flowing spectrum used for the "rainbow" effect preview. The in-game shader
+// animates the hue over time; the swatch shows the full spectrum at rest.
+const RAINBOW_GRADIENT =
+ "linear-gradient(90deg,#ff0000,#ff8a00,#ffe600,#28c76f,#00a8ff,#7d5fff,#ff0000)";
+
+/**
+ * Render a swatch preview of a transport-trail effect, filling its container.
+ * Mirrors the shader: solid = flat color, pulse = same color pulsing, rainbow =
+ * the full spectrum.
+ */
+export function renderTrailSwatch(effect: TrailEffect): TemplateResult {
+ if (effect.type === "rainbow") {
+ return html``;
+ }
+ if (effect.type === "gradient") {
+ return html``;
+ }
+ const pulseClass = effect.type === "pulse" ? "animate-pulse" : "";
+ return html``;
+}
diff --git a/src/client/render/gl/MapRenderer.ts b/src/client/render/gl/MapRenderer.ts
index 08ba9d862..71b55bfce 100644
--- a/src/client/render/gl/MapRenderer.ts
+++ b/src/client/render/gl/MapRenderer.ts
@@ -143,6 +143,18 @@ export class MapRenderer {
setPlayerSkin(smallID: number, url: string): void {
this.renderer?.setPlayerSkin(smallID, url);
}
+ setPlayerTrailStyle(
+ smallID: number,
+ r: number,
+ g: number,
+ b: number,
+ effectId: number,
+ r2: number,
+ g2: number,
+ b2: number,
+ ): void {
+ this.renderer?.setPlayerTrailStyle(smallID, r, g, b, effectId, r2, g2, b2);
+ }
initSkinAtlas(urls: readonly string[]): void {
this.renderer?.initSkinAtlas(urls);
}
diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts
index 7b690d992..a38a7c05d 100644
--- a/src/client/render/gl/Renderer.ts
+++ b/src/client/render/gl/Renderer.ts
@@ -727,6 +727,24 @@ export class GPURenderer {
this.uploadSkinLayerTex();
}
+ /**
+ * Set a player's transport-trail cosmetic style. `effectId`: 0 none (palette
+ * fallback), 1 solid, 2 rainbow, 3 pulse, 4 gradient. r/g/b are the 0–255
+ * base color; r2/g2/b2 the second color (gradient only).
+ */
+ setPlayerTrailStyle(
+ smallID: number,
+ r: number,
+ g: number,
+ b: number,
+ effectId: number,
+ r2: number,
+ g2: number,
+ b2: number,
+ ): void {
+ this.trailPass.setPlayerTrailStyle(smallID, r, g, b, effectId, r2, g2, b2);
+ }
+
private uploadSkinLayerTex(): void {
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.skinLayerTex);
diff --git a/src/client/render/gl/passes/TrailPass.ts b/src/client/render/gl/passes/TrailPass.ts
index 6bb658ec1..a52b64f56 100644
--- a/src/client/render/gl/passes/TrailPass.ts
+++ b/src/client/render/gl/passes/TrailPass.ts
@@ -8,7 +8,12 @@
import type { RenderSettings } from "../RenderSettings";
import { getPaletteSize } from "../utils/ColorUtils";
-import { createMapQuad, createProgram, shaderSrc } from "../utils/GlUtils";
+import {
+ createMapQuad,
+ createProgram,
+ createTexture2D,
+ shaderSrc,
+} from "../utils/GlUtils";
import { TILE_DEFINES } from "../utils/TileCodec";
import overlayVertSrc from "../shaders/map-overlay/overlay.vert.glsl?raw";
@@ -24,6 +29,7 @@ export class TrailPass {
private uCamera: WebGLUniformLocation;
private uMapSize: WebGLUniformLocation;
private uTrailAlpha: WebGLUniformLocation;
+ private uTime: WebGLUniformLocation;
private uAltView: WebGLUniformLocation;
private vao: WebGLVertexArrayObject;
@@ -32,6 +38,16 @@ export class TrailPass {
private affiliationTex: WebGLTexture | null = null;
private altView = false;
+ /**
+ * Per-owner trail cosmetic style (RGBA8, width = palette size, height 2).
+ * Row 0: rgb = base color, a = effect id (0 none, 1 solid, 2 rainbow,
+ * 3 pulse, 4 gradient). Row 1: rgb = second color (gradient only).
+ * Zero-initialized so players without a trail cosmetic fall back to their
+ * palette color in the shader.
+ */
+ private trailStyleTex: WebGLTexture;
+ private trailStyleCpu: Uint8Array;
+
/** CPU-side trail state (R8UI, 0=none, 1–255=ownerID). */
private cpuTrailState: Uint8Array;
private trailsDirty = false;
@@ -59,6 +75,19 @@ export class TrailPass {
this.paletteTex = paletteTex;
this.cpuTrailState = new Uint8Array(mapW * mapH);
+ const palW = getPaletteSize();
+ // Two rows: row 0 = base color + effect id, row 1 = second color (gradient).
+ this.trailStyleCpu = new Uint8Array(palW * 4 * 2);
+ this.trailStyleTex = createTexture2D(gl, {
+ width: palW,
+ height: 2,
+ internalFormat: gl.RGBA8,
+ format: gl.RGBA,
+ type: gl.UNSIGNED_BYTE,
+ data: this.trailStyleCpu,
+ filter: gl.NEAREST,
+ });
+
this.program = createProgram(
gl,
overlayVertSrc,
@@ -70,16 +99,61 @@ export class TrailPass {
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
this.uTrailAlpha = gl.getUniformLocation(this.program, "uTrailAlpha")!;
+ this.uTime = gl.getUniformLocation(this.program, "uTime")!;
this.uAltView = gl.getUniformLocation(this.program, "uAltView")!;
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uTrailTex"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 1);
gl.uniform1i(gl.getUniformLocation(this.program, "uAffiliation"), 2);
+ gl.uniform1i(gl.getUniformLocation(this.program, "uTrailStyle"), 3);
this.vao = createMapQuad(gl, mapW, mapH);
}
+ /**
+ * Set a player's trail cosmetic style. `effectId` is 0 (none, falls back to
+ * the palette color), 1 (solid), 2 (rainbow), 3 (pulse), or 4 (gradient).
+ * r/g/b (0–255) are the base color (solid/pulse/gradient); r2/g2/b2 are the
+ * second color (gradient only). Uploads the whole texture — only called when
+ * a player first appears, so the cost is negligible.
+ */
+ setPlayerTrailStyle(
+ smallID: number,
+ r: number,
+ g: number,
+ b: number,
+ effectId: number,
+ r2: number,
+ g2: number,
+ b2: number,
+ ): void {
+ const palW = getPaletteSize();
+ const off = smallID * 4;
+ this.trailStyleCpu[off] = r;
+ this.trailStyleCpu[off + 1] = g;
+ this.trailStyleCpu[off + 2] = b;
+ this.trailStyleCpu[off + 3] = effectId;
+ const off2 = palW * 4 + smallID * 4;
+ this.trailStyleCpu[off2] = r2;
+ this.trailStyleCpu[off2 + 1] = g2;
+ this.trailStyleCpu[off2 + 2] = b2;
+ this.trailStyleCpu[off2 + 3] = 0;
+ const gl = this.gl;
+ gl.bindTexture(gl.TEXTURE_2D, this.trailStyleTex);
+ gl.texSubImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ 0,
+ 0,
+ palW,
+ 2,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ this.trailStyleCpu,
+ );
+ }
+
setAltView(active: boolean): void {
this.altView = active;
}
@@ -168,6 +242,7 @@ export class TrailPass {
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
gl.uniform1f(this.uTrailAlpha, this.settings.mapOverlay.trailAlpha);
+ gl.uniform1f(this.uTime, performance.now() / 1000);
gl.uniform1i(this.uAltView, this.altView ? 1 : 0);
gl.activeTexture(gl.TEXTURE0);
@@ -178,6 +253,8 @@ export class TrailPass {
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex);
}
+ gl.activeTexture(gl.TEXTURE3);
+ gl.bindTexture(gl.TEXTURE_2D, this.trailStyleTex);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
@@ -187,5 +264,6 @@ export class TrailPass {
const gl = this.gl;
gl.deleteProgram(this.program);
gl.deleteVertexArray(this.vao);
+ gl.deleteTexture(this.trailStyleTex);
}
}
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 45d832819..944c79c21 100644
--- a/src/client/render/gl/shaders/map-overlay/trail.frag.glsl
+++ b/src/client/render/gl/shaders/map-overlay/trail.frag.glsl
@@ -1,31 +1,63 @@
-#version 300 es
-precision highp float;
-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 vec2 uMapSize;
-uniform float uTrailAlpha;
-uniform int uAltView;
-
-in vec2 vWorldPos;
-out vec4 fragColor;
-
-void main() {
- ivec2 tc = ivec2(floor(vWorldPos));
- if (tc.x < 0 || tc.y < 0 || tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y))
- discard;
-
- uint trailOwner = texelFetch(uTrailTex, tc, 0).r;
- if (trailOwner == 0u) discard;
-
- vec3 color;
- if (uAltView != 0) {
- color = texelFetch(uAffiliation, ivec2(int(trailOwner), 1), 0).rgb;
- } else {
- float u = (float(trailOwner) + 0.5) / float(PALETTE_SIZE);
- color = texture(uPalette, vec2(u, 0.25)).rgb;
- }
- fragColor = vec4(color, uTrailAlpha);
-}
+#version 300 es
+precision highp float;
+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 uTrailStyle; // RGBA8, height 2 — per-owner trail cosmetic.
+ // row 0: rgb = base color, a = effect id
+ // (0 none, 1 solid, 2 rainbow, 3 pulse, 4 gradient)
+ // row 1: rgb = second color (gradient only)
+uniform vec2 uMapSize;
+uniform float uTrailAlpha;
+uniform float uTime; // seconds, for animated trail effects
+uniform int uAltView;
+
+in vec2 vWorldPos;
+out vec4 fragColor;
+
+vec3 hsv2rgb(vec3 c) {
+ vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
+ vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
+ return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
+}
+
+void main() {
+ ivec2 tc = ivec2(floor(vWorldPos));
+ if (tc.x < 0 || tc.y < 0 || tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y))
+ discard;
+
+ uint trailOwner = texelFetch(uTrailTex, tc, 0).r;
+ if (trailOwner == 0u) discard;
+
+ vec3 color;
+ if (uAltView != 0) {
+ color = texelFetch(uAffiliation, ivec2(int(trailOwner), 1), 0).rgb;
+ } else {
+ vec4 style = texelFetch(uTrailStyle, ivec2(int(trailOwner), 0), 0);
+ int effect = int(style.a * 255.0 + 0.5);
+ if (effect == 1) {
+ // Solid cosmetic color.
+ color = style.rgb;
+ } else if (effect == 2) {
+ // Rainbow — hue flows along the wake and animates over time.
+ float hue = fract(uTime * 0.15 + (vWorldPos.x + vWorldPos.y) * 0.03);
+ color = hsv2rgb(vec3(hue, 0.9, 1.0));
+ } else if (effect == 3) {
+ // Pulse — base color modulated in brightness over time.
+ float pulse = 0.55 + 0.45 * sin(uTime * 3.0);
+ color = style.rgb * pulse;
+ } else if (effect == 4) {
+ // Gradient — blend between two colors, flowing along the wake over time.
+ vec3 c2 = texelFetch(uTrailStyle, ivec2(int(trailOwner), 1), 0).rgb;
+ float t = 0.5 + 0.5 * sin(uTime * 1.5 + (vWorldPos.x + vWorldPos.y) * 0.05);
+ color = mix(style.rgb, c2, t);
+ } else {
+ // No trail cosmetic — fall back to the player's palette color.
+ float u = (float(trailOwner) + 0.5) / float(PALETTE_SIZE);
+ color = texture(uPalette, vec2(u, 0.25)).rgb;
+ }
+ }
+ fragColor = vec4(color, uTrailAlpha);
+}
diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts
index 704fc61a3..a4b1252dc 100644
--- a/src/core/CosmeticSchemas.ts
+++ b/src/core/CosmeticSchemas.ts
@@ -9,6 +9,8 @@ export type Flag = z.infer;
export type Skin = z.infer;
export type Pack = z.infer;
export type Subscription = z.infer;
+export type TransportTrail = z.infer;
+export type TrailEffect = z.infer;
export type PatternName = z.infer;
export type Product = z.infer;
export type ColorPalette = z.infer;
@@ -85,6 +87,25 @@ export const SkinSchema = CosmeticSchema.extend({
url: z.string(),
});
+// A transport-ship trail (the colored breadcrumb drawn behind moving boats)
+// is either a solid color or an animated effect. `color` is a hex string and
+// is required for the color-based effects ("solid", "pulse") and unused by
+// "rainbow".
+export const TrailEffectSchema = z.discriminatedUnion("type", [
+ z.object({ type: z.literal("solid"), color: z.string() }),
+ z.object({ type: z.literal("rainbow") }),
+ z.object({ type: z.literal("pulse"), color: z.string() }),
+ z.object({
+ type: z.literal("gradient"),
+ color: z.string(),
+ color2: z.string(),
+ }),
+]);
+
+export const TransportTrailSchema = CosmeticSchema.extend({
+ effect: TrailEffectSchema,
+});
+
export const PackSchema = CosmeticSchema.extend({
displayName: z.string(),
currency: z.enum(["hard", "soft"]),
@@ -105,6 +126,7 @@ export const CosmeticsSchema = z.object({
patterns: z.record(z.string(), PatternSchema),
flags: z.record(z.string(), FlagSchema),
skins: z.record(z.string(), SkinSchema).optional(),
+ transportTrails: z.record(z.string(), TransportTrailSchema).optional(),
currencyPacks: z.record(z.string(), PackSchema).optional(),
subscriptions: z.record(z.string(), SubscriptionSchema).optional(),
});
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index cfcc6849d..99e7d2731 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -4,6 +4,7 @@ import {
ColorPaletteSchema,
CosmeticNameSchema,
PatternDataSchema,
+ TrailEffectSchema,
} from "./CosmeticSchemas";
import type { GameEvent } from "./EventBus";
import {
@@ -142,6 +143,7 @@ export type PlayerCosmeticRefs = z.infer;
export type PlayerPattern = z.infer;
export type PlayerColor = z.infer;
export type PlayerSkin = z.infer;
+export type PlayerTransportTrail = z.infer;
export type GameStartInfo = z.infer;
export type GameInfo = z.infer;
export type PublicGames = z.infer;
@@ -582,6 +584,11 @@ export const PlayerColorSchema = z.object({
color: z.string(),
});
+export const PlayerTransportTrailSchema = z.object({
+ name: CosmeticNameSchema,
+ effect: TrailEffectSchema,
+});
+
// Refs contain cosmetics names, will be replaced by the actual
// content in the server
export const PlayerCosmeticRefsSchema = z.object({
@@ -590,6 +597,7 @@ export const PlayerCosmeticRefsSchema = z.object({
patternName: CosmeticNameSchema.optional(),
patternColorPaletteName: z.string().optional(),
skinName: CosmeticNameSchema.optional(),
+ transportTrailName: CosmeticNameSchema.optional(),
});
export const PlayerSkinSchema = z.object({
@@ -603,6 +611,7 @@ export const PlayerCosmeticsSchema = z.object({
pattern: PlayerPatternSchema.optional(),
color: PlayerColorSchema.optional(),
skin: PlayerSkinSchema.optional(),
+ transportTrail: PlayerTransportTrailSchema.optional(),
});
export const PlayerSchema = z.object({
diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts
index 8b7f730a0..17418121b 100644
--- a/src/core/game/UserSettings.ts
+++ b/src/core/game/UserSettings.ts
@@ -2,8 +2,8 @@ import {
GraphicsOverrides,
GraphicsOverridesSchema,
} from "../../client/render/gl/GraphicsOverrides";
-import { Cosmetics } from "../CosmeticSchemas";
-import { PlayerPattern } from "../Schemas";
+import { Cosmetics, TrailEffect } from "../CosmeticSchemas";
+import { PlayerPattern, PlayerTransportTrail } from "../Schemas";
export function getDefaultKeybinds(isMac: boolean): Record {
return {
@@ -54,6 +54,7 @@ export const USER_SETTINGS_CHANGED_EVENT = "event:user-settings-changed";
export const PATTERN_KEY = "territoryPattern";
export const FLAG_KEY = "flag";
export const COLOR_KEY = "settings.territoryColor";
+export const TRANSPORT_TRAIL_KEY = "transportTrail";
export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay";
export const KEYBINDS_KEY = "settings.keybinds";
export const GRAPHICS_KEY = "settings.graphics";
@@ -245,6 +246,35 @@ export class UserSettings {
} satisfies PlayerPattern;
}
+ // For development only. Used for previewing transport-trail effects without
+ // a cosmetics.json entry — set in the console manually, e.g.
+ // localStorage.setItem("dev-trail-type", "rainbow") // solid|rainbow|pulse|gradient
+ // localStorage.setItem("dev-trail-color", "#ff00ff")
+ // localStorage.setItem("dev-trail-color2", "#00ffff") // gradient only
+ // then reload and start a singleplayer game.
+ getDevOnlyTransportTrail(): PlayerTransportTrail | undefined {
+ const type = localStorage.getItem("dev-trail-type") ?? undefined;
+ if (type === undefined) return undefined;
+ const color = localStorage.getItem("dev-trail-color") ?? "#ff0000";
+ const color2 = localStorage.getItem("dev-trail-color2") ?? "#0000ff";
+ let effect: TrailEffect;
+ switch (type) {
+ case "rainbow":
+ effect = { type: "rainbow" };
+ break;
+ case "pulse":
+ effect = { type: "pulse", color };
+ break;
+ case "gradient":
+ effect = { type: "gradient", color, color2 };
+ break;
+ default:
+ effect = { type: "solid", color };
+ break;
+ }
+ return { name: "dev-trail", effect } satisfies PlayerTransportTrail;
+ }
+
getSelectedPatternName(cosmetics: Cosmetics | null): PlayerPattern | null {
if (cosmetics === null) return null;
let data = this.getCached(PATTERN_KEY);
@@ -312,6 +342,19 @@ export class UserSettings {
this.removeCached(FLAG_KEY, emitChange);
}
+ /** Returns the bare transport-trail cosmetic name, or null if none selected. */
+ getSelectedTransportTrailName(): string | null {
+ return this.getCached(TRANSPORT_TRAIL_KEY);
+ }
+
+ setSelectedTransportTrailName(value: string | undefined): void {
+ if (value === undefined) {
+ this.removeCached(TRANSPORT_TRAIL_KEY);
+ } else {
+ this.setCached(TRANSPORT_TRAIL_KEY, value);
+ }
+ }
+
backgroundMusicVolume(): number {
return this.getFloat("settings.backgroundMusicVolume", 0);
}
diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts
index c31da062e..adab881a0 100644
--- a/src/server/Privilege.ts
+++ b/src/server/Privilege.ts
@@ -19,6 +19,7 @@ import {
PlayerCosmetics,
PlayerPattern,
PlayerSkin,
+ PlayerTransportTrail,
} from "../core/Schemas";
import { simpleHash } from "../core/Util";
@@ -257,6 +258,20 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
return { type: "forbidden", reason: "invalid skin: " + message };
}
}
+ if (refs.transportTrailName) {
+ try {
+ cosmetics.transportTrail = this.isTransportTrailAllowed(
+ flares,
+ refs.transportTrailName,
+ );
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ return {
+ type: "forbidden",
+ reason: "invalid transport trail: " + message,
+ };
+ }
+ }
return { type: "allowed", cosmetics };
}
@@ -335,6 +350,18 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
}
}
+ isTransportTrailAllowed(
+ flares: string[],
+ name: string,
+ ): PlayerTransportTrail {
+ const found = this.cosmetics.transportTrails?.[name];
+ if (!found) throw new Error(`Transport trail ${name} not found`);
+ if (flares.includes("trail:*") || flares.includes(`trail:${found.name}`)) {
+ return { name: found.name, effect: found.effect };
+ }
+ throw new Error(`No flares for transport trail ${name}`);
+ }
+
isColorAllowed(flares: string[], color: string): PlayerColor {
const allowedColors = flares
.filter((flare) => flare.startsWith("color:"))
diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts
index 5fc8e753d..b3663d247 100644
--- a/tests/Privilege.test.ts
+++ b/tests/Privilege.test.ts
@@ -88,6 +88,50 @@ const skinChecker = new PrivilegeCheckerImpl(
bannedWords,
);
+const trailCosmetics = {
+ patterns: {},
+ colorPalettes: {},
+ flags: {},
+ transportTrails: {
+ crimson: {
+ name: "crimson",
+ effect: { type: "solid" as const, color: "#e01b24" },
+ affiliateCode: null,
+ product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
+ priceSoft: undefined,
+ priceHard: undefined,
+ rarity: "common",
+ },
+ spectrum: {
+ name: "spectrum",
+ effect: { type: "rainbow" as const },
+ affiliateCode: null,
+ product: null,
+ priceSoft: undefined,
+ priceHard: undefined,
+ rarity: "legendary",
+ },
+ sunset: {
+ name: "sunset",
+ effect: {
+ type: "gradient" as const,
+ color: "#ff6b00",
+ color2: "#7d2bff",
+ },
+ affiliateCode: null,
+ product: null,
+ priceSoft: undefined,
+ priceHard: undefined,
+ rarity: "epic",
+ },
+ },
+};
+const trailChecker = new PrivilegeCheckerImpl(
+ trailCosmetics,
+ mockDecoder,
+ bannedWords,
+);
+
describe("UsernameCensor", () => {
describe("isProfane (via matcher.hasMatch)", () => {
test("detects exact banned words", () => {
@@ -521,6 +565,82 @@ describe("Skin validation", () => {
});
});
+describe("Transport trail validation in isAllowed", () => {
+ test("allows valid solid trail with wildcard flare", () => {
+ const result = trailChecker.isAllowed(["trail:*"], {
+ transportTrailName: "crimson",
+ });
+ expect(result.type).toBe("allowed");
+ if (result.type === "allowed") {
+ expect(result.cosmetics.transportTrail).toEqual({
+ name: "crimson",
+ effect: { type: "solid", color: "#e01b24" },
+ });
+ }
+ });
+
+ test("allows valid rainbow trail with exact-match flare", () => {
+ const result = trailChecker.isAllowed(["trail:spectrum"], {
+ transportTrailName: "spectrum",
+ });
+ expect(result.type).toBe("allowed");
+ if (result.type === "allowed") {
+ expect(result.cosmetics.transportTrail).toEqual({
+ name: "spectrum",
+ effect: { type: "rainbow" },
+ });
+ }
+ });
+
+ test("allows valid gradient trail with wildcard flare", () => {
+ const result = trailChecker.isAllowed(["trail:*"], {
+ transportTrailName: "sunset",
+ });
+ expect(result.type).toBe("allowed");
+ if (result.type === "allowed") {
+ expect(result.cosmetics.transportTrail).toEqual({
+ name: "sunset",
+ effect: { type: "gradient", color: "#ff6b00", color2: "#7d2bff" },
+ });
+ }
+ });
+
+ test("rejects trail when user lacks flare", () => {
+ const result = trailChecker.isAllowed([], {
+ transportTrailName: "crimson",
+ });
+ expect(result.type).toBe("forbidden");
+ if (result.type === "forbidden") {
+ expect(result.reason).toMatch(/invalid transport trail/);
+ }
+ });
+
+ test("rejects trail when flare is for a different trail", () => {
+ const result = trailChecker.isAllowed(["trail:spectrum"], {
+ transportTrailName: "crimson",
+ });
+ expect(result.type).toBe("forbidden");
+ });
+
+ test("rejects nonexistent trail", () => {
+ const result = trailChecker.isAllowed(["trail:*"], {
+ transportTrailName: "ghost",
+ });
+ expect(result.type).toBe("forbidden");
+ if (result.type === "forbidden") {
+ expect(result.reason).toMatch(/Transport trail ghost not found/);
+ }
+ });
+
+ test("no trail in refs leaves cosmetics.transportTrail undefined", () => {
+ const result = trailChecker.isAllowed(["trail:*"], {});
+ expect(result.type).toBe("allowed");
+ if (result.type === "allowed") {
+ expect(result.cosmetics.transportTrail).toBeUndefined();
+ }
+ });
+});
+
describe("PrivilegeCheckerImpl#resolveClanTag", () => {
// Reserved tags are stored uppercase, exactly as PrivilegeRefresher loads them.
const makeChecker = (reservedTags: string[]) =>