cosmetic refactor (#3628)

## Description:

The motivation is to have a single "cosmetic-button" element, so we can
abstract out the cosmetic types. This will make it much easier to add
new cosmetic types in the future.

Unifies PatternButton and FlagButton into a single CosmeticButton
component. Extracts a resolveCosmetics() function that flattens patterns
× color palettes + flags into a ResolvedCosmetic[] with relationship
status pre-computed, replacing duplicated resolution logic across four
callers.

* New CosmeticButton — renders patterns or flags based on
ResolvedCosmetic.type
* New resolveCosmetics() — centralizes ownership/purchase/blocked
resolution
* Extracted PatternPreview — canvas rendering split into its own module
* Added type: "pattern" | "flag" discriminator to Zod cosmetic schemas
* Deleted FlagButton.ts and PatternButton.ts
* Added 320-line test suite for resolveCosmetics


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2026-04-09 21:07:07 -07:00
committed by GitHub
parent f0b3c490b1
commit d5a2cc0fca
16 changed files with 826 additions and 594 deletions
+115
View File
@@ -0,0 +1,115 @@
import { html, LitElement, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Flag, Pattern } from "../../core/CosmeticSchemas";
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() {
this.onSelect?.(this.resolved);
}
private get displayName(): string {
const c = this.resolved.cosmetic;
if (c === null) {
return translateText("territory_patterns.pattern.default");
}
if (this.resolved.type === "pattern") {
return translateCosmetic("territory_patterns.pattern", c.name);
}
return translateCosmetic("flags", c.name);
}
private renderPreview(): TemplateResult {
if (this.resolved.type === "pattern") {
const c = this.resolved.cosmetic;
const playerPattern: PlayerPattern | null =
c === null
? null
: {
name: c.name,
patternData: (c as Pattern).pattern,
colorPalette: this.resolved.colorPalette ?? undefined,
};
return renderPatternPreview(playerPattern, 150, 150);
}
const c = this.resolved.cosmetic as Flag;
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 = this.resolved.type === "pattern";
const sizeClass = isPattern ? "gap-2 p-3 w-48" : "gap-1 p-1.5 w-36";
const crazygamesClass = isPattern ? "no-crazygames " : "";
return html`
<cosmetic-container
class="${crazygamesClass}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;
}