Files
OpenFrontIO/src/client/Store.ts
T
2026-05-27 00:04:10 +01:00

376 lines
10 KiB
TypeScript

import type { TemplateResult } from "lit";
import { html } from "lit";
import { customElement } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas";
import {
Difficulty,
GameMapSize,
GameMapType,
GameMode,
GameType,
UnitType,
} from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import { BaseModal } from "./components/BaseModal";
import "./components/CosmeticButton";
import "./components/NotLoggedInWarning";
import { modalHeader } from "./components/ui/ModalHeader";
import {
fetchCosmetics,
purchaseCosmetic,
resolveCosmetics,
ResolvedCosmetic,
} from "./Cosmetics";
import { translateText } from "./Utils";
type StoreTab = "patterns" | "flags" | "packs" | "subscriptions";
@customElement("store-modal")
export class StoreModal extends BaseModal {
protected routerName = "store";
private cosmetics: Cosmetics | null = null;
private affiliateCode: string | null = null;
private userMeResponse: UserMeResponse | false = false;
protected modalConfig() {
if (this.affiliateCode) {
// Affiliate mode: hide tabs, show only items associated with the code.
return {};
}
return {
tabs: [
{ key: "packs", label: translateText("store.packs") },
{ key: "subscriptions", label: translateText("store.subscriptions") },
{ key: "patterns", label: translateText("store.patterns") },
{ key: "flags", label: translateText("store.flags") },
],
};
}
connectedCallback() {
super.connectedCallback();
document.addEventListener(
"userMeResponse",
(event: CustomEvent<UserMeResponse | false>) => {
this.onUserMe(event.detail);
},
);
}
async onUserMe(userMeResponse: UserMeResponse | false) {
this.userMeResponse = userMeResponse;
this.cosmetics = await fetchCosmetics();
this.refresh();
}
private startTestGame(resolved: ResolvedCosmetic) {
if (!this.userMeResponse || resolved.type !== "pattern") return;
const pattern = resolved.cosmetic as Pattern;
const colorPalette = resolved.colorPalette as ColorPalette | null;
const clientID = this.userMeResponse.player.publicId;
const gameID = pattern.name;
const selectedPattern = {
name: pattern.name,
patternData: pattern.pattern,
colorPalette: colorPalette ?? undefined,
};
const translation = translateText(
`territory_patterns.pattern.${pattern.name}`,
);
const displayName = translation.startsWith("territory_patterns.pattern.")
? pattern.name
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")
: translation;
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
clientID,
gameID,
isSkinTest: true,
source: "singleplayer",
gameStartInfo: {
gameID,
players: [
{
clientID,
username: displayName,
cosmetics: {
pattern: selectedPattern,
},
},
],
config: {
gameMap: GameMapType.Iceland,
gameMapSize: GameMapSize.Compact,
gameType: GameType.Singleplayer,
gameMode: GameMode.FFA,
playerTeams: 1,
bots: 0,
difficulty: Difficulty.Easy,
donateGold: false,
donateTroops: false,
instantBuild: false,
randomSpawn: true,
disableNations: true,
infiniteGold: true,
infiniteTroops: true,
startingTroops: 10_000_000,
percentageTilesOwnedToWin: 99,
disabledUnits: [
UnitType.City,
UnitType.Factory,
UnitType.Port,
UnitType.MissileSilo,
UnitType.DefensePost,
UnitType.SAMLauncher,
UnitType.AtomBomb,
UnitType.HydrogenBomb,
UnitType.MIRV,
UnitType.Warship,
],
},
lobbyCreatedAt: Date.now(),
},
},
bubbles: true,
composed: true,
}),
);
}
private renderHeader(): TemplateResult {
return modalHeader({
title: translateText("store.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
});
}
private renderPatternGrid(): TemplateResult {
const items = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
this.affiliateCode,
).filter(
(r) =>
r.type === "pattern" &&
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_skins")}
</div>`;
}
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}
.onPurchase=${purchaseCosmetic}
.onTest=${this.userMeResponse !== false
? (resolved: ResolvedCosmetic) => this.startTestGame(resolved)
: undefined}
></cosmetic-button>
`,
)}
</div>
`;
}
private renderFlagGrid(): TemplateResult {
const items = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
this.affiliateCode,
).filter(
(r) =>
r.type === "flag" &&
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_flags")}
</div>`;
}
const selectedFlag = new UserSettings().getFlag() ?? "";
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=${selectedFlag === r.key}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
)}
</div>
`;
}
private renderPackGrid(): TemplateResult {
const items = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
this.affiliateCode,
).filter((r) => r.type === "pack" && r.relationship === "purchasable");
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_packs")}
</div>`;
}
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}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
)}
</div>
`;
}
private renderSubscriptionGrid(): TemplateResult {
const items = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
this.affiliateCode,
).filter(
(r) =>
r.type === "subscription" &&
(r.relationship === "purchasable" || 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_subscriptions")}
</div>`;
}
const userHasSubscription =
this.userMeResponse !== false &&
this.userMeResponse.player.subscription !== null;
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}
.onPurchase=${purchaseCosmetic}
.userHasSubscription=${userHasSubscription}
></cosmetic-button>
`,
)}
</div>
`;
}
protected renderHeaderSlot() {
return this.renderHeader();
}
protected renderBody(key: string): TemplateResult {
if (this.affiliateCode) {
return this.renderAffiliateGrid();
}
switch (key as StoreTab) {
case "patterns":
return this.renderPatternGrid();
case "flags":
return this.renderFlagGrid();
case "subscriptions":
return this.renderSubscriptionGrid();
case "packs":
default:
return this.renderPackGrid();
}
}
private renderAffiliateGrid(): TemplateResult {
const items = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
this.affiliateCode,
).filter(
(r) =>
(r.type === "pattern" || r.type === "flag" || r.type === "pack") &&
r.relationship === "purchasable",
);
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_skins")}
</div>`;
}
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}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
)}
</div>
`;
}
protected async onOpen(args?: Record<string, unknown>) {
const affiliate =
typeof args?.affiliateCode === "string" ? args.affiliateCode : null;
this.affiliateCode = affiliate;
this.cosmetics ??= await fetchCosmetics();
await this.refresh();
}
protected onClose(): void {
this.affiliateCode = null;
}
public async refresh() {
this.requestUpdate();
}
}