boat effects

This commit is contained in:
evanpelle
2026-06-24 22:02:16 -07:00
parent c8a42d4c33
commit c29b0565f1
20 changed files with 957 additions and 38 deletions
+5
View File
@@ -272,6 +272,11 @@
inline
class="hidden w-full h-full page-content relative z-50"
></territory-patterns-modal>
<transport-trail-modal
id="transport-trail-modal"
inline
class="hidden w-full h-full page-content relative z-50"
></transport-trail-modal>
<ranked-modal
id="page-ranked"
inline
+8 -1
View File
@@ -1249,6 +1249,7 @@
"no_packs": "No packs available. Check back later for new items.",
"no_skins": "No skins available. Check back later for new items.",
"no_subscriptions": "No subscriptions available. Check back later for new items.",
"no_trails": "No boat trails available. Check back later for new items.",
"not_enough_currency": "Not enough currency for this purchase.",
"packs": "Packs",
"patterns": "Skins",
@@ -1259,7 +1260,8 @@
"subscription_purchase_success": "Subscription activated!",
"subscriptions": "Subscriptions",
"switch_button": "Switch",
"title": "Store"
"title": "Store",
"trails": "Boat Trails"
},
"team_colors": {
"blue": "Blue",
@@ -1288,6 +1290,11 @@
"success": "Successfully logged in as {email}!",
"title": "Logging in..."
},
"transport_trails": {
"search": "Search...",
"select": "Trail",
"title": "Boat Trails"
},
"troubleshooting": {
"battery": "Battery",
"battery_level": "Battery Level",
+82 -2
View File
@@ -10,6 +10,7 @@ import {
Product,
Skin,
Subscription,
TransportTrail,
} from "../core/CosmeticSchemas";
import {
PlayerCosmeticRefs,
@@ -357,9 +358,34 @@ export function skinRelationship(
);
}
export function transportTrailRelationship(
trail: TransportTrail,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): "owned" | "purchasable" | "blocked" {
return cosmeticRelationship(
{
wildcardFlare: "trail:*",
requiredFlare: `trail:${trail.name}`,
product: trail.product,
priceSoft: trail.priceSoft,
priceHard: trail.priceHard,
affiliateCode,
itemAffiliateCode: trail.affiliateCode ?? null,
},
userMeResponse,
);
}
export type ResolvedCosmetic = {
type: "pattern" | "skin" | "flag" | "pack" | "subscription";
cosmetic: Pattern | Skin | Flag | Pack | Subscription | null;
type:
| "pattern"
| "skin"
| "flag"
| "transportTrail"
| "pack"
| "subscription";
cosmetic: Pattern | Skin | Flag | TransportTrail | Pack | Subscription | null;
colorPalette: ColorPalette | null;
relationship: "owned" | "purchasable" | "blocked";
/** Unique key for selection/identity, e.g. "pattern:hearts:red" or "skin:mountain" */
@@ -438,6 +464,24 @@ export function resolveCosmetics(
});
}
// Transport-ship trails (the colored wake drawn behind moving boats)
for (const [trailKey, trail] of Object.entries(
cosmetics.transportTrails ?? {},
)) {
const rel = transportTrailRelationship(
trail,
userMeResponse,
affiliateCode,
);
result.push({
type: "transportTrail",
cosmetic: trail,
colorPalette: null,
relationship: rel,
key: `transportTrail:${trailKey}`,
});
}
// Packs
for (const [packKey, pack] of Object.entries(cosmetics.currencyPacks ?? {})) {
const rel = pack.product ? "purchasable" : "blocked";
@@ -561,11 +605,34 @@ export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
}
}
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<PlayerCosmetics> {
}
}
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;
}
+19
View File
@@ -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",
+44 -1
View File
@@ -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`<div
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
>
${translateText("store.no_trails")}
</div>`;
}
const selectedTrail =
new UserSettings().getSelectedTransportTrailName() ?? "";
return html`
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${items.map(
(r) => html`
<cosmetic-button
.resolved=${r}
.selected=${`transportTrail:${selectedTrail}` === r.key}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
)}
</div>
`;
}
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",
);
+106
View File
@@ -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`
<button
id="transport-trail-input"
class="m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 bg-surface rounded-lg overflow-hidden"
disabled
>
<span
class="w-6 h-6 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin"
></span>
</button>
`;
}
const preview =
this.effect === null
? html`<span
class="text-[10px] leading-none break-words px-1 font-black text-white uppercase w-full text-center"
>
${translateText("transport_trails.select")}
</span>`
: html`<span class="w-full h-full p-1.5"
>${renderTrailSwatch(this.effect)}</span
>`;
return html`
<button
id="transport-trail-input"
class="m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:shadow-[var(--shadow-action-card-hover)] rounded-lg overflow-hidden"
title=${buttonTitle}
@click=${this.onInputClick}
>
${preview}
</button>
`;
}
}
+194
View File
@@ -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<UserMeResponse | false>) => {
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`
<div class="flex flex-col">
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${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`
<cosmetic-button
.resolved=${r}
.selected=${isSelected}
.onSelect=${(rc: ResolvedCosmetic) => this.selectTrail(rc)}
></cosmetic-button>
`;
})}
</div>
</div>
`;
}
protected renderHeaderSlot() {
return html`
<div
class="relative flex flex-col border-b border-white/10 pb-4 shrink-0"
>
${modalHeader({
title: translateText("transport_trails.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
})}
<div class="md:flex items-center gap-2 justify-center mt-4">
<input
class="h-12 w-full max-w-md border border-white/10 bg-black/60
rounded-xl shadow-inner text-xl text-center focus:outline-none
focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 text-white placeholder-white/30 transition-all"
type="text"
placeholder=${translateText("transport_trails.search")}
.value=${this.search}
@change=${this.handleSearch}
@keyup=${this.handleSearch}
/>
</div>
</div>
`;
}
protected renderBody() {
return html`
<div class="flex justify-center py-3 shrink-0">
<o-button
class="no-crazygames"
variant="primary"
size="sm"
translationKey="main.store"
@click=${() => {
this.close();
window.showPage?.("page-item-store");
}}
></o-button>
</div>
<div class="px-3 pb-3">${this.renderGrid()}</div>
`;
}
protected async onOpen(): Promise<void> {
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();
}
}
+46
View File
@@ -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,
+18
View File
@@ -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`<div
class="w-full h-full flex items-center justify-center text-white/40 text-xs uppercase"
>
${translateText("territory_patterns.pattern.default")}
</div>`;
}
return renderTrailSwatch(c.effect);
}
if (this.resolved.type === "pack") {
const pack = this.resolved.cosmetic as Pack;
const isHard = pack.currency === "hard";
+8
View File
@@ -96,6 +96,10 @@ export class PlayPage extends LitElement {
show-select-label
class="shrink-0 lg:hidden h-10 w-10"
></flag-input>
<transport-trail-input
id="transport-trail-input-mobile"
class="shrink-0 lg:hidden h-10 w-10"
></transport-trail-input>
</div>
</div>
@@ -111,6 +115,10 @@ export class PlayPage extends LitElement {
show-select-label
class="flex-1 h-full"
></flag-input>
<transport-trail-input
id="transport-trail-input-desktop"
class="flex-1 h-full"
></transport-trail-input>
</div>
</div>
@@ -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`<div
class="w-full h-full rounded-md"
style="background:${RAINBOW_GRADIENT};"
></div>`;
}
if (effect.type === "gradient") {
return html`<div
class="w-full h-full rounded-md"
style="background:linear-gradient(90deg,${effect.color},${effect.color2});"
></div>`;
}
const pulseClass = effect.type === "pulse" ? "animate-pulse" : "";
return html`<div
class="w-full h-full rounded-md ${pulseClass}"
style="background:${effect.color};"
></div>`;
}
+12
View File
@@ -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);
}
+18
View File
@@ -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 0255
* 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);
+79 -1
View File
@@ -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, 1255=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 (0255) 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);
}
}
@@ -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);
}
+22
View File
@@ -9,6 +9,8 @@ export type Flag = z.infer<typeof FlagSchema>;
export type Skin = z.infer<typeof SkinSchema>;
export type Pack = z.infer<typeof PackSchema>;
export type Subscription = z.infer<typeof SubscriptionSchema>;
export type TransportTrail = z.infer<typeof TransportTrailSchema>;
export type TrailEffect = z.infer<typeof TrailEffectSchema>;
export type PatternName = z.infer<typeof CosmeticNameSchema>;
export type Product = z.infer<typeof ProductSchema>;
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
@@ -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(),
});
+9
View File
@@ -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<typeof PlayerCosmeticRefsSchema>;
export type PlayerPattern = z.infer<typeof PlayerPatternSchema>;
export type PlayerColor = z.infer<typeof PlayerColorSchema>;
export type PlayerSkin = z.infer<typeof PlayerSkinSchema>;
export type PlayerTransportTrail = z.infer<typeof PlayerTransportTrailSchema>;
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
export type GameInfo = z.infer<typeof GameInfoSchema>;
export type PublicGames = z.infer<typeof PublicGamesSchema>;
@@ -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({
+45 -2
View File
@@ -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<string, string> {
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);
}
+27
View File
@@ -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:"))
+120
View File
@@ -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[]) =>