diff --git a/index.html b/index.html index 4af07ad2d..f5d4c97dc 100644 --- a/index.html +++ b/index.html @@ -272,6 +272,11 @@ inline class="hidden w-full h-full page-content relative z-50" > + { } } + 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[]) =>