mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-11 03:14:15 +00:00
boat effects
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:"))
|
||||
|
||||
@@ -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[]) =>
|
||||
|
||||
Reference in New Issue
Block a user