mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
cosmetic refactor
This commit is contained in:
@@ -15,7 +15,6 @@ import "./components/baseComponents/stats/PlayerStatsTree";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/CopyButton";
|
||||
import "./components/Difficulties";
|
||||
import "./components/PatternButton";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import {
|
||||
ColorPalette,
|
||||
Cosmetics,
|
||||
CosmeticsSchema,
|
||||
Flag,
|
||||
@@ -187,6 +188,73 @@ export function flagRelationship(
|
||||
);
|
||||
}
|
||||
|
||||
export type ResolvedCosmetic = {
|
||||
cosmetic: Pattern | Flag | null;
|
||||
colorPalette: ColorPalette | null;
|
||||
relationship: "owned" | "purchasable" | "blocked";
|
||||
/** Unique key for selection/identity, e.g. "pattern:hearts:red" or "flag:cool_flag" */
|
||||
key: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves all cosmetics into a flat display-ready list with relationship
|
||||
* status and resolved color palettes. Callers can filter by relationship.
|
||||
*/
|
||||
export function resolveCosmetics(
|
||||
cosmetics: Cosmetics | null,
|
||||
userMeResponse: UserMeResponse | false,
|
||||
affiliateCode: string | null,
|
||||
): ResolvedCosmetic[] {
|
||||
if (!cosmetics) return [];
|
||||
const result: ResolvedCosmetic[] = [];
|
||||
|
||||
// Default pattern (always owned)
|
||||
result.push({
|
||||
cosmetic: null,
|
||||
colorPalette: null,
|
||||
relationship: "owned",
|
||||
key: "pattern:default",
|
||||
});
|
||||
|
||||
// Patterns × color palettes
|
||||
for (const [patternKey, pattern] of Object.entries(cosmetics.patterns)) {
|
||||
const colorPalettes = [...(pattern.colorPalettes ?? []), null];
|
||||
for (const cp of colorPalettes) {
|
||||
const rel = patternRelationship(
|
||||
pattern,
|
||||
cp,
|
||||
userMeResponse,
|
||||
affiliateCode,
|
||||
);
|
||||
const resolvedPalette = cp
|
||||
? (cosmetics.colorPalettes?.[cp.name] ?? null)
|
||||
: null;
|
||||
const key = cp
|
||||
? `pattern:${patternKey}:${cp.name}`
|
||||
: `pattern:${patternKey}`;
|
||||
result.push({
|
||||
cosmetic: pattern,
|
||||
colorPalette: resolvedPalette,
|
||||
relationship: rel,
|
||||
key,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Flags
|
||||
for (const [flagKey, flag] of Object.entries(cosmetics.flags)) {
|
||||
const rel = flagRelationship(flag, userMeResponse, affiliateCode);
|
||||
result.push({
|
||||
cosmetic: flag,
|
||||
colorPalette: null,
|
||||
relationship: rel,
|
||||
key: `flag:${flagKey}`,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
|
||||
const userSettings = new UserSettings();
|
||||
const cosmetics = await fetchCosmetics();
|
||||
|
||||
@@ -2,16 +2,31 @@ import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import Countries from "resources/countries.json" with { type: "json" };
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { Cosmetics, Flag } from "../core/CosmeticSchemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { getUserMe } from "./Api";
|
||||
import { fetchCosmetics, flagRelationship } from "./Cosmetics";
|
||||
import {
|
||||
fetchCosmetics,
|
||||
flagRelationship,
|
||||
ResolvedCosmetic,
|
||||
} from "./Cosmetics";
|
||||
import { translateText } from "./Utils";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/FlagButton";
|
||||
import "./components/CosmeticButton";
|
||||
import "./components/NotLoggedInWarning";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
|
||||
function countryFlag(name: string, code: string): Flag {
|
||||
return {
|
||||
type: "flag" as const,
|
||||
name,
|
||||
url: `/flags/${code}.svg`,
|
||||
product: null,
|
||||
rarity: "common",
|
||||
affiliateCode: null,
|
||||
};
|
||||
}
|
||||
|
||||
@customElement("flag-input-modal")
|
||||
export class FlagInputModal extends BaseModal {
|
||||
@state() private search = "";
|
||||
@@ -26,10 +41,6 @@ export class FlagInputModal extends BaseModal {
|
||||
private renderFlags() {
|
||||
const userSettings = new UserSettings();
|
||||
const selectedFlag = userSettings.getFlag() ?? "";
|
||||
const onSelect = (flagKey: string) => {
|
||||
this.setFlag(flagKey);
|
||||
this.close();
|
||||
};
|
||||
|
||||
const cosmeticFlags = Object.entries(this.cosmetics?.flags ?? {})
|
||||
.filter(([, flag]) => {
|
||||
@@ -37,28 +48,42 @@ export class FlagInputModal extends BaseModal {
|
||||
return false;
|
||||
return flagRelationship(flag, this.userMe, null) === "owned";
|
||||
})
|
||||
.map(
|
||||
([key, flag]) => html`
|
||||
<flag-button
|
||||
.flag=${{ ...flag, key: `flag:${key}` }}
|
||||
.map(([key, flag]) => {
|
||||
const r: ResolvedCosmetic = {
|
||||
cosmetic: flag,
|
||||
colorPalette: null,
|
||||
relationship: "owned",
|
||||
key: `flag:${key}`,
|
||||
};
|
||||
return html`
|
||||
<cosmetic-button
|
||||
.resolved=${r}
|
||||
.selected=${selectedFlag === `flag:${key}`}
|
||||
.onSelect=${onSelect}
|
||||
></flag-button>
|
||||
`,
|
||||
);
|
||||
.onSelect=${() => {
|
||||
this.setFlag(`flag:${key}`);
|
||||
this.close();
|
||||
}}
|
||||
></cosmetic-button>
|
||||
`;
|
||||
});
|
||||
|
||||
const noFlagResolved: ResolvedCosmetic = {
|
||||
cosmetic: countryFlag("None", "xx"),
|
||||
colorPalette: null,
|
||||
relationship: "owned",
|
||||
key: "country:xx",
|
||||
};
|
||||
const noFlag = this.search
|
||||
? null
|
||||
: html`
|
||||
<flag-button
|
||||
.flag=${{
|
||||
key: "country:xx",
|
||||
name: "None",
|
||||
url: "/flags/xx.svg",
|
||||
}}
|
||||
<cosmetic-button
|
||||
.resolved=${noFlagResolved}
|
||||
.selected=${selectedFlag === "" || selectedFlag === "country:xx"}
|
||||
.onSelect=${onSelect}
|
||||
></flag-button>
|
||||
.onSelect=${() => {
|
||||
this.setFlag("country:xx");
|
||||
this.close();
|
||||
}}
|
||||
></cosmetic-button>
|
||||
`;
|
||||
|
||||
const countryFlags = Countries.filter(
|
||||
@@ -66,19 +91,24 @@ export class FlagInputModal extends BaseModal {
|
||||
country.code !== "xx" &&
|
||||
!country.restricted &&
|
||||
this.includedInSearch(country),
|
||||
).map(
|
||||
(country) => html`
|
||||
<flag-button
|
||||
.flag=${{
|
||||
key: `country:${country.code}`,
|
||||
name: country.name,
|
||||
url: `/flags/${country.code}.svg`,
|
||||
}}
|
||||
).map((country) => {
|
||||
const r: ResolvedCosmetic = {
|
||||
cosmetic: countryFlag(country.name, country.code),
|
||||
colorPalette: null,
|
||||
relationship: "owned",
|
||||
key: `country:${country.code}`,
|
||||
};
|
||||
return html`
|
||||
<cosmetic-button
|
||||
.resolved=${r}
|
||||
.selected=${selectedFlag === `country:${country.code}`}
|
||||
.onSelect=${onSelect}
|
||||
></flag-button>
|
||||
`,
|
||||
);
|
||||
.onSelect=${() => {
|
||||
this.setFlag(`country:${country.code}`);
|
||||
this.close();
|
||||
}}
|
||||
></cosmetic-button>
|
||||
`;
|
||||
});
|
||||
|
||||
return html`
|
||||
<div
|
||||
|
||||
@@ -6,7 +6,6 @@ import { getUserMe, hasLinkedAccount } from "./Api";
|
||||
import { getPlayToken } from "./Auth";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/Difficulties";
|
||||
import "./components/PatternButton";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { PlayerPattern } from "../core/Schemas";
|
||||
import { renderPatternPreview } from "./components/PatternButton";
|
||||
import { renderPatternPreview } from "./components/PatternPreview";
|
||||
import { getPlayerCosmetics } from "./Cosmetics";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
+70
-73
@@ -2,20 +2,19 @@ import type { TemplateResult } from "lit";
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas";
|
||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { PlayerPattern } from "../core/Schemas";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/FlagButton";
|
||||
import "./components/CosmeticButton";
|
||||
import "./components/NotLoggedInWarning";
|
||||
import "./components/PatternButton";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import {
|
||||
fetchCosmetics,
|
||||
flagRelationship,
|
||||
getPlayerCosmetics,
|
||||
handlePurchase,
|
||||
patternRelationship,
|
||||
resolveCosmetics,
|
||||
ResolvedCosmetic,
|
||||
} from "./Cosmetics";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@@ -103,53 +102,18 @@ export class StoreModal extends BaseModal {
|
||||
}
|
||||
|
||||
private renderPatternGrid(): TemplateResult {
|
||||
const buttons: TemplateResult[] = [];
|
||||
const patterns: (Pattern | null)[] = [
|
||||
null,
|
||||
...Object.values(this.cosmetics?.patterns ?? {}),
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const colorPalettes = pattern
|
||||
? [...(pattern.colorPalettes ?? []), null]
|
||||
: [null];
|
||||
for (const colorPalette of colorPalettes) {
|
||||
let rel = "owned";
|
||||
if (pattern) {
|
||||
rel = patternRelationship(
|
||||
pattern,
|
||||
colorPalette,
|
||||
this.userMeResponse,
|
||||
this.affiliateCode,
|
||||
);
|
||||
}
|
||||
if (rel === "blocked" || rel === "owned") {
|
||||
continue;
|
||||
}
|
||||
const isDefaultPattern = pattern === null;
|
||||
const isSelected =
|
||||
(isDefaultPattern && this.selectedPattern === null) ||
|
||||
(!isDefaultPattern &&
|
||||
this.selectedPattern &&
|
||||
this.selectedPattern.name === pattern?.name &&
|
||||
(this.selectedPattern.colorPalette?.name ?? null) ===
|
||||
(colorPalette?.name ?? null));
|
||||
buttons.push(html`
|
||||
<pattern-button
|
||||
.pattern=${pattern}
|
||||
.colorPalette=${this.cosmetics?.colorPalettes?.[
|
||||
colorPalette?.name ?? ""
|
||||
] ?? null}
|
||||
.requiresPurchase=${rel === "purchasable"}
|
||||
.selected=${isSelected}
|
||||
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
|
||||
.onPurchase=${(p: Pattern, cp: ColorPalette | null) =>
|
||||
handlePurchase(p.product!, cp?.name)}
|
||||
></pattern-button>
|
||||
`);
|
||||
}
|
||||
}
|
||||
const items = resolveCosmetics(
|
||||
this.cosmetics,
|
||||
this.userMeResponse,
|
||||
this.affiliateCode,
|
||||
).filter(
|
||||
(r) =>
|
||||
(r.cosmetic === null || r.cosmetic.type === "pattern") &&
|
||||
r.relationship !== "blocked" &&
|
||||
r.relationship !== "owned",
|
||||
);
|
||||
|
||||
if (buttons.length === 0) {
|
||||
if (items.length === 0) {
|
||||
return html`<div
|
||||
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
|
||||
>
|
||||
@@ -161,33 +125,40 @@ export class StoreModal extends BaseModal {
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
||||
>
|
||||
${buttons}
|
||||
${items.map((r) => {
|
||||
const isSelected =
|
||||
(r.cosmetic === null && this.selectedPattern === null) ||
|
||||
(r.cosmetic !== null &&
|
||||
this.selectedPattern?.name === r.cosmetic.name &&
|
||||
(this.selectedPattern?.colorPalette?.name ?? null) ===
|
||||
(r.colorPalette?.name ?? null));
|
||||
return html`
|
||||
<cosmetic-button
|
||||
.resolved=${r}
|
||||
.selected=${isSelected}
|
||||
.onSelect=${(rc: ResolvedCosmetic) => this.selectCosmetic(rc)}
|
||||
.onPurchase=${(rc: ResolvedCosmetic) =>
|
||||
handlePurchase(rc.cosmetic!.product!, rc.colorPalette?.name)}
|
||||
></cosmetic-button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFlagGrid(): TemplateResult {
|
||||
const buttons: TemplateResult[] = [];
|
||||
const flags = Object.entries(this.cosmetics?.flags ?? {});
|
||||
for (const [key, flag] of flags) {
|
||||
const rel = flagRelationship(
|
||||
flag,
|
||||
this.userMeResponse,
|
||||
this.affiliateCode,
|
||||
);
|
||||
if (rel === "blocked" || rel === "owned") continue;
|
||||
const selectedFlag = new UserSettings().getFlag() ?? "";
|
||||
buttons.push(html`
|
||||
<flag-button
|
||||
.flag=${{ ...flag, key: `flag:${key}` }}
|
||||
.selected=${selectedFlag === `flag:${key}`}
|
||||
.requiresPurchase=${rel === "purchasable"}
|
||||
.onPurchase=${() => handlePurchase(flag.product!)}
|
||||
></flag-button>
|
||||
`);
|
||||
}
|
||||
const items = resolveCosmetics(
|
||||
this.cosmetics,
|
||||
this.userMeResponse,
|
||||
this.affiliateCode,
|
||||
).filter(
|
||||
(r) =>
|
||||
r.cosmetic?.type === "flag" &&
|
||||
r.relationship !== "blocked" &&
|
||||
r.relationship !== "owned",
|
||||
);
|
||||
|
||||
if (buttons.length === 0) {
|
||||
if (items.length === 0) {
|
||||
return html`<div
|
||||
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
|
||||
>
|
||||
@@ -195,11 +166,21 @@ export class StoreModal extends BaseModal {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const selectedFlag = new UserSettings().getFlag() ?? "";
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
||||
>
|
||||
${buttons}
|
||||
${items.map(
|
||||
(r) => html`
|
||||
<cosmetic-button
|
||||
.resolved=${r}
|
||||
.selected=${selectedFlag === r.key}
|
||||
.onPurchase=${(rc: ResolvedCosmetic) =>
|
||||
handlePurchase(rc.cosmetic!.product!)}
|
||||
></cosmetic-button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -261,6 +242,22 @@ export class StoreModal extends BaseModal {
|
||||
super.close();
|
||||
}
|
||||
|
||||
private selectCosmetic(resolved: ResolvedCosmetic) {
|
||||
const c = resolved.cosmetic;
|
||||
if (c === null) {
|
||||
this.selectPattern(null);
|
||||
return;
|
||||
}
|
||||
if (c.type === "pattern") {
|
||||
const pattern: PlayerPattern = {
|
||||
name: c.name,
|
||||
patternData: c.pattern,
|
||||
colorPalette: resolved.colorPalette ?? undefined,
|
||||
};
|
||||
this.selectPattern(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
private selectPattern(pattern: PlayerPattern | null) {
|
||||
this.selectedColor = null;
|
||||
this.userSettings.setSelectedColor(undefined);
|
||||
|
||||
@@ -2,17 +2,18 @@ import type { TemplateResult } from "lit";
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { Cosmetics, Pattern } from "../core/CosmeticSchemas";
|
||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { PlayerPattern } from "../core/Schemas";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/CosmeticButton";
|
||||
import "./components/NotLoggedInWarning";
|
||||
import "./components/PatternButton";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import {
|
||||
fetchCosmetics,
|
||||
getPlayerCosmetics,
|
||||
patternRelationship,
|
||||
resolveCosmetics,
|
||||
ResolvedCosmetic,
|
||||
} from "./Cosmetics";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@@ -68,56 +69,36 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
}
|
||||
|
||||
private renderPatternGrid(): TemplateResult {
|
||||
const buttons: TemplateResult[] = [];
|
||||
const patterns: (Pattern | null)[] = [
|
||||
const items = resolveCosmetics(
|
||||
this.cosmetics,
|
||||
this.userMeResponse,
|
||||
null,
|
||||
...Object.values(this.cosmetics?.patterns ?? {}),
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const colorPalettes = pattern
|
||||
? [...(pattern.colorPalettes ?? []), null]
|
||||
: [null];
|
||||
for (const colorPalette of colorPalettes) {
|
||||
let rel = "owned";
|
||||
if (pattern) {
|
||||
rel = patternRelationship(
|
||||
pattern,
|
||||
colorPalette,
|
||||
this.userMeResponse,
|
||||
null,
|
||||
);
|
||||
}
|
||||
if (rel !== "owned") {
|
||||
continue;
|
||||
}
|
||||
const isDefaultPattern = pattern === null;
|
||||
const isSelected =
|
||||
(isDefaultPattern && this.selectedPattern === null) ||
|
||||
(!isDefaultPattern &&
|
||||
this.selectedPattern &&
|
||||
this.selectedPattern.name === pattern?.name &&
|
||||
(this.selectedPattern.colorPalette?.name ?? null) ===
|
||||
(colorPalette?.name ?? null));
|
||||
buttons.push(html`
|
||||
<pattern-button
|
||||
.pattern=${pattern}
|
||||
.colorPalette=${this.cosmetics?.colorPalettes?.[
|
||||
colorPalette?.name ?? ""
|
||||
] ?? null}
|
||||
.requiresPurchase=${false}
|
||||
.selected=${isSelected}
|
||||
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
|
||||
></pattern-button>
|
||||
`);
|
||||
}
|
||||
}
|
||||
).filter(
|
||||
(r) =>
|
||||
(r.cosmetic === null || r.cosmetic.type === "pattern") &&
|
||||
r.relationship === "owned",
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
||||
>
|
||||
${buttons}
|
||||
${items.map((r) => {
|
||||
const isSelected =
|
||||
(r.cosmetic === null && this.selectedPattern === null) ||
|
||||
(r.cosmetic !== null &&
|
||||
this.selectedPattern?.name === r.cosmetic.name &&
|
||||
(this.selectedPattern?.colorPalette?.name ?? null) ===
|
||||
(r.colorPalette?.name ?? null));
|
||||
return html`
|
||||
<cosmetic-button
|
||||
.resolved=${r}
|
||||
.selected=${isSelected}
|
||||
.onSelect=${(rc: ResolvedCosmetic) => this.selectCosmetic(rc)}
|
||||
></cosmetic-button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -176,6 +157,22 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
private selectCosmetic(resolved: ResolvedCosmetic) {
|
||||
const c = resolved.cosmetic;
|
||||
if (c === null) {
|
||||
this.selectPattern(null);
|
||||
return;
|
||||
}
|
||||
if (c.type === "pattern") {
|
||||
const pattern: PlayerPattern = {
|
||||
name: c.name,
|
||||
patternData: c.pattern,
|
||||
colorPalette: resolved.colorPalette ?? undefined,
|
||||
};
|
||||
this.selectPattern(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
private selectPattern(pattern: PlayerPattern | null) {
|
||||
this.selectedColor = null;
|
||||
this.userSettings.setSelectedColor(undefined);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { customElement } from "lit/decorators.js";
|
||||
import { tempTokenLogin } from "./Auth";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/Difficulties";
|
||||
import "./components/PatternButton";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { html, LitElement, nothing, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { PlayerPattern } from "../../core/Schemas";
|
||||
import { ResolvedCosmetic, translateCosmetic } from "../Cosmetics";
|
||||
import { translateText } from "../Utils";
|
||||
import "./CosmeticContainer";
|
||||
import "./CosmeticInfo";
|
||||
import { renderPatternPreview } from "./PatternPreview";
|
||||
|
||||
@customElement("cosmetic-button")
|
||||
export class CosmeticButton extends LitElement {
|
||||
@property({ type: Object })
|
||||
resolved!: ResolvedCosmetic;
|
||||
|
||||
@property({ type: Boolean })
|
||||
selected: boolean = false;
|
||||
|
||||
@property({ type: Function })
|
||||
onSelect?: (resolved: ResolvedCosmetic) => void;
|
||||
|
||||
@property({ type: Function })
|
||||
onPurchase?: (resolved: ResolvedCosmetic) => void;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
if (this.resolved.relationship === "purchasable") {
|
||||
this.onPurchase?.(this.resolved);
|
||||
return;
|
||||
}
|
||||
this.onSelect?.(this.resolved);
|
||||
}
|
||||
|
||||
private get displayName(): string {
|
||||
const c = this.resolved.cosmetic;
|
||||
if (c === null) {
|
||||
return translateText("territory_patterns.pattern.default");
|
||||
}
|
||||
if (c.type === "pattern") {
|
||||
return translateCosmetic("territory_patterns.pattern", c.name);
|
||||
}
|
||||
return translateCosmetic("flags", c.name);
|
||||
}
|
||||
|
||||
private renderPreview(): TemplateResult {
|
||||
const c = this.resolved.cosmetic;
|
||||
if (c === null || c.type === "pattern") {
|
||||
const playerPattern: PlayerPattern | null =
|
||||
c === null
|
||||
? null
|
||||
: {
|
||||
name: c.name,
|
||||
patternData: c.pattern,
|
||||
colorPalette: this.resolved.colorPalette ?? undefined,
|
||||
};
|
||||
return renderPatternPreview(playerPattern, 150, 150);
|
||||
}
|
||||
|
||||
return html`<img
|
||||
src=${c.url}
|
||||
alt=${c.name}
|
||||
class="w-full h-full object-contain pointer-events-none"
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
@error=${(e: Event) => {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
const fallback = "/flags/xx.svg";
|
||||
if (img.src && !img.src.endsWith(fallback)) {
|
||||
img.src = fallback;
|
||||
}
|
||||
}}
|
||||
/>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const c = this.resolved.cosmetic;
|
||||
const isPurchasable = this.resolved.relationship === "purchasable";
|
||||
const isPattern = c === null || c.type === "pattern";
|
||||
const sizeClass = isPattern ? "gap-2 p-3 w-48" : "gap-1 p-1.5 w-36";
|
||||
|
||||
return html`
|
||||
<cosmetic-container
|
||||
class="no-crazygames flex flex-col items-center justify-between ${sizeClass} h-full"
|
||||
.rarity=${c?.rarity ?? "common"}
|
||||
.selected=${this.selected}
|
||||
.product=${isPurchasable && c?.product ? c.product : null}
|
||||
.onPurchase=${() => this.onPurchase?.(this.resolved)}
|
||||
.name=${this.displayName}
|
||||
>
|
||||
<button
|
||||
class="group relative flex flex-col items-center w-full ${isPattern
|
||||
? "gap-2"
|
||||
: "gap-1"} rounded-lg cursor-pointer transition-all duration-200 flex-1"
|
||||
@click=${() => this.handleClick()}
|
||||
>
|
||||
${c?.product
|
||||
? html`<cosmetic-info
|
||||
.artist=${c.artist}
|
||||
.rarity=${c.rarity}
|
||||
.colorPalette=${this.resolved.colorPalette?.name}
|
||||
.showAdFree=${isPurchasable}
|
||||
></cosmetic-info>`
|
||||
: nothing}
|
||||
|
||||
<div
|
||||
class="w-full aspect-square flex items-center justify-center bg-white/5 rounded-lg p-2 border border-white/10 group-hover:border-white/20 transition-colors duration-200 overflow-hidden"
|
||||
>
|
||||
${this.renderPreview()}
|
||||
</div>
|
||||
</button>
|
||||
</cosmetic-container>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export class CosmeticInfo extends LitElement {
|
||||
@property({ type: String })
|
||||
colorPalette?: string;
|
||||
|
||||
@property({ type: Boolean })
|
||||
showAdFree: boolean = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
@@ -53,9 +56,11 @@ export class CosmeticInfo extends LitElement {
|
||||
${translateText(`cosmetics.${this.rarity}`) || this.rarity}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="text-green-400 font-bold">
|
||||
${translateText("cosmetics.adfree")}
|
||||
</div>
|
||||
${this.showAdFree
|
||||
? html`<div class="text-green-400 font-bold">
|
||||
${translateText("cosmetics.adfree")}
|
||||
</div>`
|
||||
: nothing}
|
||||
${this.colorPalette
|
||||
? html`<div>
|
||||
${translateText("cosmetics.color_label")}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { Flag } from "../../core/CosmeticSchemas";
|
||||
import { translateCosmetic } from "../Cosmetics";
|
||||
import "./CosmeticContainer";
|
||||
import "./CosmeticInfo";
|
||||
|
||||
export type FlagItem = Flag & { key: string };
|
||||
|
||||
@customElement("flag-button")
|
||||
export class FlagButton extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
selected: boolean = false;
|
||||
|
||||
@property({ type: Object })
|
||||
flag!: FlagItem;
|
||||
|
||||
@property({ type: Boolean })
|
||||
requiresPurchase: boolean = false;
|
||||
|
||||
@property({ type: Function })
|
||||
onSelect?: (flagKey: string) => void;
|
||||
|
||||
@property({ type: Function })
|
||||
onPurchase?: () => void;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
if (this.requiresPurchase) {
|
||||
this.onPurchase?.();
|
||||
return;
|
||||
}
|
||||
this.onSelect?.(this.flag.key);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<cosmetic-container
|
||||
class="flex flex-col items-center justify-between gap-1 p-1.5 w-36 h-full"
|
||||
.rarity=${this.flag.rarity ?? "common"}
|
||||
.selected=${this.selected}
|
||||
.product=${this.requiresPurchase && this.flag.product
|
||||
? this.flag.product
|
||||
: null}
|
||||
.onPurchase=${() => this.onPurchase?.()}
|
||||
.name=${translateCosmetic("flags", this.flag.name)}
|
||||
>
|
||||
<button
|
||||
class="group relative flex flex-col items-center w-full gap-1 rounded-lg cursor-pointer transition-all duration-200 flex-1"
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<cosmetic-info
|
||||
.artist=${this.flag.artist}
|
||||
.rarity=${this.flag.rarity}
|
||||
></cosmetic-info>
|
||||
|
||||
<div
|
||||
class="w-full aspect-square flex items-center justify-center bg-white/5 rounded-lg p-2 border border-white/10 group-hover:border-white/20 transition-colors duration-200 overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src=${this.flag.url}
|
||||
alt=${this.flag.name}
|
||||
class="w-full h-full object-contain pointer-events-none"
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
@error=${(e: Event) => {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
const fallback = "/flags/xx.svg";
|
||||
if (img.src && !img.src.endsWith(fallback)) {
|
||||
img.src = fallback;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</cosmetic-container>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import { Colord } from "colord";
|
||||
import { base64url } from "jose";
|
||||
import { html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import {
|
||||
ColorPalette,
|
||||
DefaultPattern,
|
||||
Pattern,
|
||||
} from "../../core/CosmeticSchemas";
|
||||
import { PatternDecoder } from "../../core/PatternDecoder";
|
||||
import { PlayerPattern } from "../../core/Schemas";
|
||||
import { translateCosmetic } from "../Cosmetics";
|
||||
import { translateText } from "../Utils";
|
||||
import "./CosmeticContainer";
|
||||
import "./CosmeticInfo";
|
||||
|
||||
export const BUTTON_WIDTH = 150;
|
||||
|
||||
@customElement("pattern-button")
|
||||
export class PatternButton extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
selected: boolean = false;
|
||||
@property({ type: Object })
|
||||
pattern: Pattern | null = null;
|
||||
|
||||
@property({ type: Object })
|
||||
colorPalette: ColorPalette | null = null;
|
||||
|
||||
@property({ type: Boolean })
|
||||
requiresPurchase: boolean = false;
|
||||
|
||||
@property({ type: Function })
|
||||
onSelect?: (pattern: PlayerPattern | null) => void;
|
||||
|
||||
@property({ type: Function })
|
||||
onPurchase?: (pattern: Pattern, colorPalette: ColorPalette | null) => void;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
if (this.requiresPurchase) {
|
||||
this.handlePurchase();
|
||||
return;
|
||||
}
|
||||
if (this.pattern === null) {
|
||||
this.onSelect?.(null);
|
||||
return;
|
||||
}
|
||||
this.onSelect?.({
|
||||
name: this.pattern!.name,
|
||||
patternData: this.pattern!.pattern,
|
||||
colorPalette: this.colorPalette ?? undefined,
|
||||
} satisfies PlayerPattern);
|
||||
}
|
||||
|
||||
private handlePurchase() {
|
||||
if (this.pattern?.product) {
|
||||
this.onPurchase?.(this.pattern, this.colorPalette ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const isDefaultPattern = this.pattern === null;
|
||||
|
||||
return html`
|
||||
<cosmetic-container
|
||||
class="no-crazygames flex flex-col items-center justify-between gap-2 p-3 w-48 h-full"
|
||||
.rarity=${this.pattern?.rarity ?? "common"}
|
||||
.selected=${this.selected}
|
||||
.product=${this.requiresPurchase && this.pattern?.product
|
||||
? this.pattern.product
|
||||
: null}
|
||||
.onPurchase=${() => this.handlePurchase()}
|
||||
.name=${isDefaultPattern
|
||||
? translateText("territory_patterns.pattern.default")
|
||||
: translateCosmetic("territory_patterns.pattern", this.pattern!.name)}
|
||||
>
|
||||
<button
|
||||
class="group relative flex flex-col items-center w-full gap-2 rounded-lg cursor-pointer transition-all duration-200 flex-1"
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<cosmetic-info
|
||||
.artist=${this.pattern?.artist}
|
||||
.rarity=${this.pattern?.rarity}
|
||||
.colorPalette=${this.colorPalette?.name ?? undefined}
|
||||
></cosmetic-info>
|
||||
|
||||
<div
|
||||
class="w-full aspect-square flex items-center justify-center bg-white/5 rounded-lg p-2 border border-white/10 group-hover:border-white/20 transition-colors duration-200 overflow-hidden"
|
||||
>
|
||||
${renderPatternPreview(
|
||||
this.pattern !== null
|
||||
? ({
|
||||
name: this.pattern!.name,
|
||||
patternData: this.pattern!.pattern,
|
||||
colorPalette: this.colorPalette ?? undefined,
|
||||
} satisfies PlayerPattern)
|
||||
: DefaultPattern,
|
||||
BUTTON_WIDTH,
|
||||
BUTTON_WIDTH,
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</cosmetic-container>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderPatternPreview(
|
||||
pattern: PlayerPattern | null,
|
||||
width: number,
|
||||
height: number,
|
||||
): TemplateResult {
|
||||
if (pattern === null) {
|
||||
return renderBlankPreview(width, height);
|
||||
}
|
||||
return html`<img
|
||||
src="${generatePreviewDataUrl(pattern, width, height)}"
|
||||
alt="Pattern preview"
|
||||
class="w-full h-full object-contain [image-rendering:pixelated] pointer-events-none"
|
||||
draggable="false"
|
||||
/>`;
|
||||
}
|
||||
|
||||
function renderBlankPreview(width: number, height: number): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class="md:hidden flex items-center justify-center h-full w-full bg-white rounded overflow-hidden relative border border-[#ccc] box-border"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-2 grid-rows-2 gap-0 w-[calc(100%-1px)] h-[calc(100%-2px)] box-border"
|
||||
>
|
||||
<div class="bg-white border border-black/10 box-border"></div>
|
||||
<div class="bg-white border border-black/10 box-border"></div>
|
||||
<div class="bg-white border border-black/10 box-border"></div>
|
||||
<div class="bg-white border border-black/10 box-border"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="hidden md:flex items-center justify-center h-full w-full rounded overflow-hidden relative text-center p-1"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full"
|
||||
>
|
||||
${translateText("territory_patterns.select_skin")}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const patternCache = new Map<string, string>();
|
||||
const DEFAULT_PRIMARY = new Colord("#ffffff").toRgb(); // White
|
||||
const DEFAULT_SECONDARY = new Colord("#000000").toRgb(); // Black
|
||||
function generatePreviewDataUrl(
|
||||
pattern?: PlayerPattern,
|
||||
width?: number,
|
||||
height?: number,
|
||||
): string {
|
||||
pattern ??= DefaultPattern;
|
||||
const patternLookupKey = [
|
||||
pattern.name,
|
||||
pattern.colorPalette?.primaryColor ?? "undefined",
|
||||
pattern.colorPalette?.secondaryColor ?? "undefined",
|
||||
width,
|
||||
height,
|
||||
].join("-");
|
||||
|
||||
if (patternCache.has(patternLookupKey)) {
|
||||
return patternCache.get(patternLookupKey)!;
|
||||
}
|
||||
|
||||
// Calculate canvas size
|
||||
let decoder: PatternDecoder;
|
||||
try {
|
||||
decoder = new PatternDecoder(
|
||||
{
|
||||
name: pattern.name,
|
||||
patternData: pattern.patternData,
|
||||
colorPalette: pattern.colorPalette,
|
||||
},
|
||||
base64url.decode,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Error decoding pattern", e);
|
||||
return "";
|
||||
}
|
||||
|
||||
const scaledWidth = decoder.scaledWidth();
|
||||
const scaledHeight = decoder.scaledHeight();
|
||||
|
||||
width =
|
||||
width === undefined
|
||||
? scaledWidth
|
||||
: Math.max(1, Math.floor(width / scaledWidth)) * scaledWidth;
|
||||
height =
|
||||
height === undefined
|
||||
? scaledHeight
|
||||
: Math.max(1, Math.floor(height / scaledHeight)) * scaledHeight;
|
||||
|
||||
// Create the canvas
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("2D context not supported");
|
||||
|
||||
// Create an image
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
const data = imageData.data;
|
||||
const primary = pattern.colorPalette?.primaryColor
|
||||
? new Colord(pattern.colorPalette.primaryColor).toRgb()
|
||||
: DEFAULT_PRIMARY;
|
||||
const secondary = pattern.colorPalette?.secondaryColor
|
||||
? new Colord(pattern.colorPalette.secondaryColor).toRgb()
|
||||
: DEFAULT_SECONDARY;
|
||||
let i = 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const rgba = decoder.isPrimary(x, y) ? primary : secondary;
|
||||
data[i++] = rgba.r;
|
||||
data[i++] = rgba.g;
|
||||
data[i++] = rgba.b;
|
||||
data[i++] = 255; // Alpha
|
||||
}
|
||||
}
|
||||
|
||||
// Create a data URL
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
patternCache.set(patternLookupKey, dataUrl);
|
||||
return dataUrl;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Colord } from "colord";
|
||||
import { base64url } from "jose";
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { DefaultPattern } from "../../core/CosmeticSchemas";
|
||||
import { PatternDecoder } from "../../core/PatternDecoder";
|
||||
import { PlayerPattern } from "../../core/Schemas";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
export function renderPatternPreview(
|
||||
pattern: PlayerPattern | null,
|
||||
width: number,
|
||||
height: number,
|
||||
): TemplateResult {
|
||||
if (pattern === null) {
|
||||
return renderBlankPreview();
|
||||
}
|
||||
return html`<img
|
||||
src="${generatePreviewDataUrl(pattern, width, height)}"
|
||||
alt="Pattern preview"
|
||||
class="w-full h-full object-contain [image-rendering:pixelated] pointer-events-none"
|
||||
draggable="false"
|
||||
/>`;
|
||||
}
|
||||
|
||||
function renderBlankPreview(): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class="md:hidden flex items-center justify-center h-full w-full bg-white rounded overflow-hidden relative border border-[#ccc] box-border"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-2 grid-rows-2 gap-0 w-[calc(100%-1px)] h-[calc(100%-2px)] box-border"
|
||||
>
|
||||
<div class="bg-white border border-black/10 box-border"></div>
|
||||
<div class="bg-white border border-black/10 box-border"></div>
|
||||
<div class="bg-white border border-black/10 box-border"></div>
|
||||
<div class="bg-white border border-black/10 box-border"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="hidden md:flex items-center justify-center h-full w-full rounded overflow-hidden relative text-center p-1"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full"
|
||||
>
|
||||
${translateText("territory_patterns.select_skin")}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const patternCache = new Map<string, string>();
|
||||
const DEFAULT_PRIMARY = new Colord("#ffffff").toRgb();
|
||||
const DEFAULT_SECONDARY = new Colord("#000000").toRgb();
|
||||
|
||||
export function generatePreviewDataUrl(
|
||||
pattern?: PlayerPattern,
|
||||
width?: number,
|
||||
height?: number,
|
||||
): string {
|
||||
pattern ??= DefaultPattern;
|
||||
const patternLookupKey = [
|
||||
pattern.name,
|
||||
pattern.colorPalette?.primaryColor ?? "undefined",
|
||||
pattern.colorPalette?.secondaryColor ?? "undefined",
|
||||
width,
|
||||
height,
|
||||
].join("-");
|
||||
|
||||
if (patternCache.has(patternLookupKey)) {
|
||||
return patternCache.get(patternLookupKey)!;
|
||||
}
|
||||
|
||||
let decoder: PatternDecoder;
|
||||
try {
|
||||
decoder = new PatternDecoder(
|
||||
{
|
||||
name: pattern.name,
|
||||
patternData: pattern.patternData,
|
||||
colorPalette: pattern.colorPalette,
|
||||
},
|
||||
base64url.decode,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Error decoding pattern", e);
|
||||
return "";
|
||||
}
|
||||
|
||||
const scaledWidth = decoder.scaledWidth();
|
||||
const scaledHeight = decoder.scaledHeight();
|
||||
|
||||
width =
|
||||
width === undefined
|
||||
? scaledWidth
|
||||
: Math.max(1, Math.floor(width / scaledWidth)) * scaledWidth;
|
||||
height =
|
||||
height === undefined
|
||||
? scaledHeight
|
||||
: Math.max(1, Math.floor(height / scaledHeight)) * scaledHeight;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("2D context not supported");
|
||||
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
const data = imageData.data;
|
||||
const primary = pattern.colorPalette?.primaryColor
|
||||
? new Colord(pattern.colorPalette.primaryColor).toRgb()
|
||||
: DEFAULT_PRIMARY;
|
||||
const secondary = pattern.colorPalette?.secondaryColor
|
||||
? new Colord(pattern.colorPalette.secondaryColor).toRgb()
|
||||
: DEFAULT_SECONDARY;
|
||||
let i = 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const rgba = decoder.isPrimary(x, y) ? primary : secondary;
|
||||
data[i++] = rgba.r;
|
||||
data[i++] = rgba.g;
|
||||
data[i++] = rgba.b;
|
||||
data[i++] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
patternCache.set(patternLookupKey, dataUrl);
|
||||
return dataUrl;
|
||||
}
|
||||
@@ -6,17 +6,17 @@ import {
|
||||
translateText,
|
||||
TUTORIAL_VIDEO_URL,
|
||||
} from "../../../client/Utils";
|
||||
import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { RankedType } from "../../../core/game/Game";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { getUserMe } from "../../Api";
|
||||
import "../../components/PatternButton";
|
||||
import "../../components/CosmeticButton";
|
||||
import {
|
||||
fetchCosmetics,
|
||||
handlePurchase,
|
||||
patternRelationship,
|
||||
resolveCosmetics,
|
||||
ResolvedCosmetic,
|
||||
} from "../../Cosmetics";
|
||||
import { crazyGamesSDK } from "../../CrazyGamesSDK";
|
||||
import { Platform } from "../../Platform";
|
||||
@@ -157,54 +157,31 @@ export class WinModal extends LitElement implements Layer {
|
||||
|
||||
async loadPatternContent() {
|
||||
const me = await getUserMe();
|
||||
const patterns = await fetchCosmetics();
|
||||
const cosmetics = await fetchCosmetics();
|
||||
|
||||
const purchasablePatterns: {
|
||||
pattern: Pattern;
|
||||
colorPalette: ColorPalette;
|
||||
}[] = [];
|
||||
const purchasable = resolveCosmetics(cosmetics, me, null).filter(
|
||||
(r) => r.cosmetic?.type === "pattern" && r.relationship === "purchasable",
|
||||
);
|
||||
|
||||
for (const pattern of Object.values(patterns?.patterns ?? {})) {
|
||||
for (const colorPalette of pattern.colorPalettes ?? []) {
|
||||
if (
|
||||
patternRelationship(pattern, colorPalette, me, null) === "purchasable"
|
||||
) {
|
||||
const palette = patterns?.colorPalettes?.[colorPalette.name];
|
||||
if (palette) {
|
||||
purchasablePatterns.push({
|
||||
pattern,
|
||||
colorPalette: palette,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (purchasablePatterns.length === 0) {
|
||||
if (purchasable.length === 0) {
|
||||
this.patternContent = html``;
|
||||
return;
|
||||
}
|
||||
|
||||
// Shuffle the array and take patterns based on screen size
|
||||
const shuffled = [...purchasablePatterns].sort(() => Math.random() - 0.5);
|
||||
const shuffled = [...purchasable].sort(() => Math.random() - 0.5);
|
||||
const maxPatterns = Platform.isMobileWidth ? 1 : 3;
|
||||
const selectedPatterns = shuffled.slice(
|
||||
0,
|
||||
Math.min(maxPatterns, shuffled.length),
|
||||
);
|
||||
const selected = shuffled.slice(0, Math.min(maxPatterns, shuffled.length));
|
||||
|
||||
this.patternContent = html`
|
||||
<div class="flex gap-4 flex-wrap justify-start">
|
||||
${selectedPatterns.map(
|
||||
({ pattern, colorPalette }) => html`
|
||||
<pattern-button
|
||||
.pattern=${pattern}
|
||||
.colorPalette=${colorPalette}
|
||||
.requiresPurchase=${true}
|
||||
.onSelect=${(p: Pattern | null) => {}}
|
||||
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
|
||||
handlePurchase(p.product!, colorPalette?.name)}
|
||||
></pattern-button>
|
||||
${selected.map(
|
||||
(r) => html`
|
||||
<cosmetic-button
|
||||
.resolved=${r}
|
||||
.onPurchase=${(rc: ResolvedCosmetic) =>
|
||||
handlePurchase(rc.cosmetic!.product!, rc.colorPalette?.name)}
|
||||
></cosmetic-button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ export type PatternName = z.infer<typeof CosmeticNameSchema>;
|
||||
export type Product = z.infer<typeof ProductSchema>;
|
||||
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
|
||||
export type PatternData = z.infer<typeof PatternDataSchema>;
|
||||
export type Cosmetic = Pattern | Flag;
|
||||
|
||||
export const ProductSchema = z.object({
|
||||
productId: z.string(),
|
||||
@@ -51,7 +52,7 @@ export const ColorPaletteSchema = z.object({
|
||||
secondaryColor: z.string(),
|
||||
});
|
||||
|
||||
const CosmeticSchema = z.object({
|
||||
const CosmeticBaseSchema = z.object({
|
||||
name: CosmeticNameSchema,
|
||||
affiliateCode: z.string().nullable(),
|
||||
product: ProductSchema.nullable(),
|
||||
@@ -61,7 +62,8 @@ const CosmeticSchema = z.object({
|
||||
.or(z.string()),
|
||||
});
|
||||
|
||||
export const PatternSchema = CosmeticSchema.extend({
|
||||
export const PatternSchema = CosmeticBaseSchema.extend({
|
||||
type: z.literal("pattern").default("pattern"),
|
||||
pattern: PatternDataSchema,
|
||||
colorPalettes: z
|
||||
.object({
|
||||
@@ -72,10 +74,16 @@ export const PatternSchema = CosmeticSchema.extend({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const FlagSchema = CosmeticSchema.extend({
|
||||
export const FlagSchema = CosmeticBaseSchema.extend({
|
||||
type: z.literal("flag").default("flag"),
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
export const CosmeticSchema = z.discriminatedUnion("type", [
|
||||
PatternSchema,
|
||||
FlagSchema,
|
||||
]);
|
||||
|
||||
// Schema for resources/cosmetics/cosmetics.json
|
||||
export const CosmeticsSchema = z.object({
|
||||
colorPalettes: z.record(z.string(), ColorPaletteSchema).optional(),
|
||||
|
||||
@@ -25,7 +25,7 @@ const bannedWords = [
|
||||
const matcher = createMatcher(bannedWords);
|
||||
|
||||
// Create a minimal PrivilegeCheckerImpl for testing censorUsername
|
||||
const mockCosmetics = { patterns: {}, colorPalettes: {}, flags: {} };
|
||||
const mockCosmetics = { patterns: {}, colorPalettes: {}, flags: {}, packs: {} };
|
||||
const mockDecoder = () => new Uint8Array();
|
||||
const checker = new PrivilegeCheckerImpl(
|
||||
mockCosmetics,
|
||||
@@ -39,6 +39,7 @@ const flagCosmetics = {
|
||||
colorPalettes: {},
|
||||
flags: {
|
||||
cool_flag: {
|
||||
type: "flag" as const,
|
||||
name: "cool_flag",
|
||||
url: "https://example.com/cool.png",
|
||||
affiliateCode: null,
|
||||
@@ -46,6 +47,7 @@ const flagCosmetics = {
|
||||
rarity: "common",
|
||||
},
|
||||
},
|
||||
packs: {},
|
||||
};
|
||||
const flagChecker = new PrivilegeCheckerImpl(
|
||||
flagCosmetics,
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { resolveCosmetics } from "../src/client/Cosmetics";
|
||||
import { UserMeResponse } from "../src/core/ApiSchemas";
|
||||
import { Cosmetics } from "../src/core/CosmeticSchemas";
|
||||
|
||||
const product = { productId: "prod_1", priceId: "price_1", price: "$4.99" };
|
||||
|
||||
function makeCosmetics(overrides: Partial<Cosmetics> = {}): Cosmetics {
|
||||
return {
|
||||
patterns: {},
|
||||
flags: {},
|
||||
colorPalettes: {},
|
||||
...overrides,
|
||||
} as Cosmetics;
|
||||
}
|
||||
|
||||
function makeUserMe(flares: string[] = []): UserMeResponse {
|
||||
return {
|
||||
user: {},
|
||||
player: {
|
||||
publicId: "test",
|
||||
flares,
|
||||
achievements: { singleplayerMap: [] },
|
||||
},
|
||||
} as UserMeResponse;
|
||||
}
|
||||
|
||||
describe("resolveCosmetics", () => {
|
||||
test("returns empty array for null cosmetics", () => {
|
||||
expect(resolveCosmetics(null, false, null)).toEqual([]);
|
||||
});
|
||||
|
||||
test("always includes default pattern as first item, owned", () => {
|
||||
const result = resolveCosmetics(makeCosmetics(), false, null);
|
||||
expect(result[0]).toEqual({
|
||||
cosmetic: null,
|
||||
colorPalette: null,
|
||||
relationship: "owned",
|
||||
key: "pattern:default",
|
||||
});
|
||||
});
|
||||
|
||||
describe("patterns", () => {
|
||||
const pattern = {
|
||||
type: "pattern" as const,
|
||||
name: "stripes",
|
||||
pattern: "AAAAAA",
|
||||
affiliateCode: null,
|
||||
product,
|
||||
rarity: "common",
|
||||
colorPalettes: [
|
||||
{ name: "red", isArchived: false },
|
||||
{ name: "blue", isArchived: false },
|
||||
],
|
||||
};
|
||||
|
||||
const colorPalettes = {
|
||||
red: { name: "red", primaryColor: "#ff0000", secondaryColor: "#000000" },
|
||||
blue: {
|
||||
name: "blue",
|
||||
primaryColor: "#0000ff",
|
||||
secondaryColor: "#ffffff",
|
||||
},
|
||||
};
|
||||
|
||||
test("expands pattern × colorPalettes + null palette", () => {
|
||||
const cosmetics = makeCosmetics({
|
||||
patterns: { stripes: pattern as any },
|
||||
colorPalettes,
|
||||
});
|
||||
const result = resolveCosmetics(cosmetics, false, null);
|
||||
// default + red + blue + null-palette
|
||||
const patternItems = result.filter((r) =>
|
||||
r.key.startsWith("pattern:stripes"),
|
||||
);
|
||||
expect(patternItems).toHaveLength(3);
|
||||
expect(patternItems.map((r) => r.key)).toEqual([
|
||||
"pattern:stripes:red",
|
||||
"pattern:stripes:blue",
|
||||
"pattern:stripes",
|
||||
]);
|
||||
});
|
||||
|
||||
test("resolves color palette from cosmetics.colorPalettes", () => {
|
||||
const cosmetics = makeCosmetics({
|
||||
patterns: { stripes: pattern as any },
|
||||
colorPalettes,
|
||||
});
|
||||
const result = resolveCosmetics(cosmetics, false, null);
|
||||
const redItem = result.find((r) => r.key === "pattern:stripes:red");
|
||||
expect(redItem?.colorPalette).toEqual(colorPalettes.red);
|
||||
});
|
||||
|
||||
test("null palette entry has null colorPalette", () => {
|
||||
const cosmetics = makeCosmetics({
|
||||
patterns: { stripes: pattern as any },
|
||||
colorPalettes,
|
||||
});
|
||||
const result = resolveCosmetics(cosmetics, false, null);
|
||||
const nullPaletteItem = result.find((r) => r.key === "pattern:stripes");
|
||||
expect(nullPaletteItem?.colorPalette).toBeNull();
|
||||
});
|
||||
|
||||
test("pattern with no colorPalettes produces single null-palette entry", () => {
|
||||
const noPalettePattern = { ...pattern, colorPalettes: undefined };
|
||||
const cosmetics = makeCosmetics({
|
||||
patterns: { stripes: noPalettePattern as any },
|
||||
});
|
||||
const result = resolveCosmetics(cosmetics, false, null);
|
||||
const patternItems = result.filter((r) =>
|
||||
r.key.startsWith("pattern:stripes"),
|
||||
);
|
||||
expect(patternItems).toHaveLength(1);
|
||||
expect(patternItems[0].key).toBe("pattern:stripes");
|
||||
});
|
||||
|
||||
test("purchasable when user has no flares and product exists", () => {
|
||||
const cosmetics = makeCosmetics({
|
||||
patterns: { stripes: pattern as any },
|
||||
colorPalettes,
|
||||
});
|
||||
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
|
||||
const redItem = result.find((r) => r.key === "pattern:stripes:red");
|
||||
expect(redItem?.relationship).toBe("purchasable");
|
||||
});
|
||||
|
||||
test("owned when user has specific flare", () => {
|
||||
const cosmetics = makeCosmetics({
|
||||
patterns: { stripes: pattern as any },
|
||||
colorPalettes,
|
||||
});
|
||||
const result = resolveCosmetics(
|
||||
cosmetics,
|
||||
makeUserMe(["pattern:stripes:red"]),
|
||||
null,
|
||||
);
|
||||
const redItem = result.find((r) => r.key === "pattern:stripes:red");
|
||||
expect(redItem?.relationship).toBe("owned");
|
||||
});
|
||||
|
||||
test("owned when user has wildcard flare", () => {
|
||||
const cosmetics = makeCosmetics({
|
||||
patterns: { stripes: pattern as any },
|
||||
colorPalettes,
|
||||
});
|
||||
const result = resolveCosmetics(
|
||||
cosmetics,
|
||||
makeUserMe(["pattern:*"]),
|
||||
null,
|
||||
);
|
||||
const redItem = result.find((r) => r.key === "pattern:stripes:red");
|
||||
expect(redItem?.relationship).toBe("owned");
|
||||
});
|
||||
|
||||
test("blocked when affiliate code mismatch", () => {
|
||||
const affiliatePattern = { ...pattern, affiliateCode: "partner1" };
|
||||
const cosmetics = makeCosmetics({
|
||||
patterns: { stripes: affiliatePattern as any },
|
||||
colorPalettes,
|
||||
});
|
||||
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
|
||||
const redItem = result.find((r) => r.key === "pattern:stripes:red");
|
||||
expect(redItem?.relationship).toBe("blocked");
|
||||
});
|
||||
|
||||
test("purchasable when affiliate code matches", () => {
|
||||
const affiliatePattern = { ...pattern, affiliateCode: "partner1" };
|
||||
const cosmetics = makeCosmetics({
|
||||
patterns: { stripes: affiliatePattern as any },
|
||||
colorPalettes,
|
||||
});
|
||||
const result = resolveCosmetics(cosmetics, makeUserMe(), "partner1");
|
||||
const redItem = result.find((r) => r.key === "pattern:stripes:red");
|
||||
expect(redItem?.relationship).toBe("purchasable");
|
||||
});
|
||||
|
||||
test("archived palette is blocked unless owned", () => {
|
||||
const archivedPattern = {
|
||||
...pattern,
|
||||
colorPalettes: [{ name: "old", isArchived: true }],
|
||||
};
|
||||
const cosmetics = makeCosmetics({
|
||||
patterns: { stripes: archivedPattern as any },
|
||||
colorPalettes: {
|
||||
old: {
|
||||
name: "old",
|
||||
primaryColor: "#111",
|
||||
secondaryColor: "#222",
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
|
||||
const oldItem = result.find((r) => r.key === "pattern:stripes:old");
|
||||
expect(oldItem?.relationship).toBe("blocked");
|
||||
});
|
||||
|
||||
test("archived palette is owned when user has specific flare", () => {
|
||||
const archivedPattern = {
|
||||
...pattern,
|
||||
colorPalettes: [{ name: "old", isArchived: true }],
|
||||
};
|
||||
const cosmetics = makeCosmetics({
|
||||
patterns: { stripes: archivedPattern as any },
|
||||
colorPalettes: {
|
||||
old: {
|
||||
name: "old",
|
||||
primaryColor: "#111",
|
||||
secondaryColor: "#222",
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = resolveCosmetics(
|
||||
cosmetics,
|
||||
makeUserMe(["pattern:stripes:old"]),
|
||||
null,
|
||||
);
|
||||
const oldItem = result.find((r) => r.key === "pattern:stripes:old");
|
||||
expect(oldItem?.relationship).toBe("owned");
|
||||
});
|
||||
});
|
||||
|
||||
describe("flags", () => {
|
||||
const flag = {
|
||||
type: "flag" as const,
|
||||
name: "cool_flag",
|
||||
url: "https://example.com/cool.png",
|
||||
affiliateCode: null,
|
||||
product,
|
||||
rarity: "rare",
|
||||
};
|
||||
|
||||
test("includes flags with correct key", () => {
|
||||
const cosmetics = makeCosmetics({
|
||||
flags: { cool_flag: flag as any },
|
||||
});
|
||||
const result = resolveCosmetics(cosmetics, false, null);
|
||||
const flagItem = result.find((r) => r.key === "flag:cool_flag");
|
||||
expect(flagItem).toBeDefined();
|
||||
expect(flagItem?.cosmetic).toEqual(flag);
|
||||
expect(flagItem?.colorPalette).toBeNull();
|
||||
});
|
||||
|
||||
test("purchasable when not logged in and product exists", () => {
|
||||
const cosmetics = makeCosmetics({
|
||||
flags: { cool_flag: flag as any },
|
||||
});
|
||||
const result = resolveCosmetics(cosmetics, false, null);
|
||||
const flagItem = result.find((r) => r.key === "flag:cool_flag");
|
||||
expect(flagItem?.relationship).toBe("purchasable");
|
||||
});
|
||||
|
||||
test("owned with wildcard flare", () => {
|
||||
const cosmetics = makeCosmetics({
|
||||
flags: { cool_flag: flag as any },
|
||||
});
|
||||
const result = resolveCosmetics(cosmetics, makeUserMe(["flag:*"]), null);
|
||||
const flagItem = result.find((r) => r.key === "flag:cool_flag");
|
||||
expect(flagItem?.relationship).toBe("owned");
|
||||
});
|
||||
|
||||
test("owned with specific flare", () => {
|
||||
const cosmetics = makeCosmetics({
|
||||
flags: { cool_flag: flag as any },
|
||||
});
|
||||
const result = resolveCosmetics(
|
||||
cosmetics,
|
||||
makeUserMe(["flag:cool_flag"]),
|
||||
null,
|
||||
);
|
||||
const flagItem = result.find((r) => r.key === "flag:cool_flag");
|
||||
expect(flagItem?.relationship).toBe("owned");
|
||||
});
|
||||
|
||||
test("blocked with no product", () => {
|
||||
const freeFlag = { ...flag, product: null };
|
||||
const cosmetics = makeCosmetics({
|
||||
flags: { cool_flag: freeFlag as any },
|
||||
});
|
||||
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
|
||||
const flagItem = result.find((r) => r.key === "flag:cool_flag");
|
||||
expect(flagItem?.relationship).toBe("blocked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mixed cosmetics", () => {
|
||||
test("returns all types in order: default, patterns, flags", () => {
|
||||
const cosmetics = makeCosmetics({
|
||||
patterns: {
|
||||
stripes: {
|
||||
type: "pattern" as const,
|
||||
name: "stripes",
|
||||
pattern: "AAAAAA",
|
||||
affiliateCode: null,
|
||||
product,
|
||||
rarity: "common",
|
||||
} as any,
|
||||
},
|
||||
flags: {
|
||||
heart: {
|
||||
type: "flag" as const,
|
||||
name: "heart",
|
||||
url: "/flags/heart.svg",
|
||||
affiliateCode: null,
|
||||
product,
|
||||
rarity: "common",
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
const result = resolveCosmetics(cosmetics, false, null);
|
||||
const keys = result.map((r) => r.key);
|
||||
expect(keys[0]).toBe("pattern:default");
|
||||
expect(keys).toContain("pattern:stripes");
|
||||
expect(keys).toContain("flag:heart");
|
||||
// patterns come before flags
|
||||
const patternIdx = keys.indexOf("pattern:stripes");
|
||||
const flagIdx = keys.indexOf("flag:heart");
|
||||
expect(patternIdx).toBeLessThan(flagIdx);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user