cosmetic refactor

This commit is contained in:
evanpelle
2026-04-09 19:28:44 -07:00
parent 592dadf80d
commit 6ca309462e
17 changed files with 850 additions and 521 deletions
-1
View File
@@ -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";
+68
View File
@@ -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();
+65 -35
View File
@@ -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
-1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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);
+43 -46
View File
@@ -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);
-1
View File
@@ -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";
+116
View File
@@ -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>
`;
}
}
+8 -3
View File
@@ -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")}
-82
View File
@@ -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>
`;
}
}
-234
View File
@@ -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;
}
+129
View File
@@ -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;
}
+17 -40
View File
@@ -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>
+11 -3
View File
@@ -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(),
+3 -1
View File
@@ -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,
+319
View File
@@ -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);
});
});
});