collapse skins under one banner (#4432)

## Description:

merges skins under one item with a circular button below it:

<img width="877" height="647" alt="image"
src="https://github.com/user-attachments/assets/a405ba34-a970-4e8c-9287-fe0055d6a02e"
/>


## 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

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

w.o.n
This commit is contained in:
Ryan
2026-06-28 03:09:05 +01:00
committed by GitHub
parent 6b95a23606
commit c622e8581c
4 changed files with 209 additions and 33 deletions
+24
View File
@@ -477,6 +477,30 @@ export function resolveCosmetics(
return result;
}
/**
* Groups resolved cosmetics so that colour-palette variants of the same pattern
* collapse into a single entry. Returns an array of groups in first-seen order
*/
export function groupCosmeticVariants(
items: ResolvedCosmetic[],
): ResolvedCosmetic[][] {
const groups: ResolvedCosmetic[][] = [];
const patternGroupByName = new Map<string, number>();
for (const item of items) {
if (item.type === "pattern" && item.cosmetic !== null) {
const name = item.cosmetic.name;
const existing = patternGroupByName.get(name);
if (existing !== undefined) {
groups[existing].push(item);
continue;
}
patternGroupByName.set(name, groups.length);
}
groups.push([item]);
}
return groups;
}
export function resolvedToPlayerPattern(
resolved: ResolvedCosmetic,
): PlayerPattern | null {
+11 -6
View File
@@ -10,6 +10,7 @@ import "./components/NotLoggedInWarning";
import { modalHeader } from "./components/ui/ModalHeader";
import {
fetchCosmetics,
groupCosmeticVariants,
purchaseCosmetic,
resolveCosmetics,
SUBSCRIPTIONS_ENABLED,
@@ -92,14 +93,17 @@ export class StoreModal extends BaseModal {
</div>`;
}
// Collapse colour-palette variants of the same pattern into one tile; the
// variants become clickable colour swatches on the cosmetic-button.
return html`
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${items.map(
(r) => html`
${groupCosmeticVariants(items).map(
(group) => html`
<cosmetic-button
.resolved=${r}
.resolved=${group[0]}
.variants=${group}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
@@ -264,10 +268,11 @@ export class StoreModal extends BaseModal {
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${items.map(
(r) => html`
${groupCosmeticVariants(items).map(
(group) => html`
<cosmetic-button
.resolved=${r}
.resolved=${group[0]}
.variants=${group}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
+106 -26
View File
@@ -1,5 +1,5 @@
import { html, LitElement, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { customElement, property, state } from "lit/decorators.js";
import {
Flag,
Pack,
@@ -39,47 +39,118 @@ export class CosmeticButton extends LitElement {
@property({ type: Boolean })
userHasSubscription: boolean = false;
/** Colour variants of one pattern; 2+ become clickable swatches. */
@property({ attribute: false })
variants?: ResolvedCosmetic[];
/** Key of the swatch the user has picked; null until they pick one. */
@state() private activeVariantKey: string | null = null;
/** The variant currently previewed/purchased: picked swatch, else fallback. */
private get activeResolved(): ResolvedCosmetic {
const variants = this.variants;
if (variants && variants.length > 0) {
return (
variants.find((v) => v.key === this.activeVariantKey) ?? variants[0]
);
}
return this.resolved;
}
createRenderRoot() {
return this;
}
private handleClick() {
this.onSelect?.(this.resolved);
this.onSelect?.(this.activeResolved);
}
private get displayName(): string {
const c = this.resolved.cosmetic;
const c = this.activeResolved.cosmetic;
if (c === null) {
return translateText("territory_patterns.pattern.default");
}
if (this.resolved.type === "pattern" || this.resolved.type === "skin") {
if (
this.activeResolved.type === "pattern" ||
this.activeResolved.type === "skin"
) {
return translateCosmetic("territory_patterns.pattern", c.name);
}
if (this.resolved.type === "pack") {
if (this.activeResolved.type === "pack") {
return (c as Pack).displayName;
}
if (this.resolved.type === "subscription") {
if (this.activeResolved.type === "subscription") {
return translateCosmetic("subscriptions", c.name);
}
return translateCosmetic("flags", c.name);
}
/** True when the variants carry colour palettes to show as swatches. */
private get hasColorRow(): boolean {
return (
this.variants !== undefined &&
this.variants.some((v) => v.colorPalette !== null)
);
}
/** Row of clickable split-circle colour swatches, one per palette. */
private renderColorSwatches(): TemplateResult | typeof nothing {
if (!this.hasColorRow) {
return nothing;
}
const activeKey = this.activeResolved.key;
return html`
<div
class="flex flex-wrap items-center justify-center gap-1.5 w-full px-1"
>
${this.variants!.map((v) => {
const primary = v.colorPalette?.primaryColor ?? "#ffffff";
const secondary = v.colorPalette?.secondaryColor ?? "#000000";
const isActive = v.key === activeKey;
const label = v.colorPalette
? translateCosmetic(
"territory_patterns.color_palette",
v.colorPalette.name,
)
: "";
const outline = isActive
? "0 0 0 2px rgba(255,255,255,0.95)"
: "inset 0 0 0 1px rgba(255,255,255,0.2), 0 0 0 1px rgba(0,0,0,0.45)";
return html`<button
type="button"
title=${label}
aria-label=${label}
aria-pressed=${isActive}
class="w-5 h-5 shrink-0 rounded-full p-0 m-0 appearance-none cursor-pointer outline-none transition-transform duration-150 hover:scale-110 ${isActive
? "scale-110"
: ""}"
style="background-image: linear-gradient(135deg, ${primary} 0 calc(50% - 0.5px), rgba(255,255,255,0.55) calc(50% - 0.5px) calc(50% + 0.5px), ${secondary} calc(50% + 0.5px) 100%); box-shadow: ${outline};"
@click=${(e: Event) => {
e.stopPropagation();
this.activeVariantKey = v.key;
}}
></button>`;
})}
</div>
`;
}
private renderPreview(): TemplateResult {
if (this.resolved.type === "pattern") {
const c = this.resolved.cosmetic;
if (this.activeResolved.type === "pattern") {
const c = this.activeResolved.cosmetic;
const playerPattern: PlayerPattern | null =
c === null
? null
: {
name: c.name,
patternData: (c as Pattern).pattern,
colorPalette: this.resolved.colorPalette ?? undefined,
colorPalette: this.activeResolved.colorPalette ?? undefined,
};
return renderPatternPreview(playerPattern, 150, 150);
}
if (this.resolved.type === "skin") {
const c = this.resolved.cosmetic as Skin | null;
if (this.activeResolved.type === "skin") {
const c = this.activeResolved.cosmetic as Skin | null;
if (c === null) {
// "Default" tile — visually consistent with pattern's default tile.
return html`<div
@@ -97,8 +168,8 @@ export class CosmeticButton extends LitElement {
/>`;
}
if (this.resolved.type === "pack") {
const pack = this.resolved.cosmetic as Pack;
if (this.activeResolved.type === "pack") {
const pack = this.activeResolved.cosmetic as Pack;
const isHard = pack.currency === "hard";
const icon = isHard
? html`<plutonium-icon
@@ -133,8 +204,8 @@ export class CosmeticButton extends LitElement {
</div>`;
}
if (this.resolved.type === "subscription") {
const sub = this.resolved.cosmetic as Subscription;
if (this.activeResolved.type === "subscription") {
const sub = this.activeResolved.cosmetic as Subscription;
return html`<div
class="flex flex-col items-center justify-between h-full w-full text-center gap-2 p-1"
>
@@ -164,7 +235,7 @@ export class CosmeticButton extends LitElement {
</div>`;
}
const c = this.resolved.cosmetic as Flag;
const c = this.activeResolved.cosmetic as Flag;
return html`<img
src=${c.url}
alt=${c.name}
@@ -182,17 +253,18 @@ export class CosmeticButton extends LitElement {
}
render() {
const c = this.resolved.cosmetic;
const active = this.activeResolved;
const c = active.cosmetic;
const priced = c as Pattern | Skin | Flag | Pack | null;
const priceHard = priced?.priceHard;
const priceSoft = priced?.priceSoft;
const artist = priced?.artist;
const isPurchasable = this.resolved.relationship === "purchasable";
const type = this.resolved.type;
const isPurchasable = active.relationship === "purchasable";
const type = active.type;
const isPattern = type === "pattern";
const isSkin = type === "skin";
const isOwnedSubscription =
type === "subscription" && this.resolved.relationship === "owned";
type === "subscription" && active.relationship === "owned";
const dollarLabelKey =
type === "subscription"
? this.userHasSubscription
@@ -203,10 +275,15 @@ export class CosmeticButton extends LitElement {
type === "subscription" ? translateText("store.price_per_month") : "";
const sizeClass = type === "flag" ? "gap-1 p-1.5 w-36" : "gap-2 p-3 w-48";
const crazygamesClass = isPattern || isSkin ? "no-crazygames " : "";
// Colour-row tiles top-align so the skin box, swatches and price buttons
// line up across the grid; other tiles fill height with justify-between.
const hasColorRow = this.hasColorRow;
return html`
<cosmetic-container
class="${crazygamesClass}flex flex-col items-center justify-between ${sizeClass} h-full"
class="${crazygamesClass}flex flex-col items-center ${hasColorRow
? "justify-start"
: "justify-between"} ${sizeClass} h-full"
.rarity=${c?.rarity ?? "common"}
.selected=${this.selected}
.product=${isPurchasable && c?.product ? c.product : null}
@@ -215,13 +292,13 @@ export class CosmeticButton extends LitElement {
.dollarLabelKey=${dollarLabelKey}
.priceSuffix=${priceSuffix}
.onPurchaseDollar=${isPurchasable && c?.product
? () => this.onPurchase?.(this.resolved, "dollar")
? () => this.onPurchase?.(this.activeResolved, "dollar")
: undefined}
.onPurchaseHard=${isPurchasable && priceHard !== undefined
? () => this.onPurchase?.(this.resolved, "hard")
? () => this.onPurchase?.(this.activeResolved, "hard")
: undefined}
.onPurchaseSoft=${isPurchasable && priceSoft !== undefined
? () => this.onPurchase?.(this.resolved, "soft")
? () => this.onPurchase?.(this.activeResolved, "soft")
: undefined}
.name=${this.displayName}
>
@@ -229,14 +306,16 @@ export class CosmeticButton extends LitElement {
class="group relative flex flex-col items-center w-full ${isPattern ||
isSkin
? "gap-2"
: "gap-1"} rounded-lg cursor-pointer transition-all duration-200 flex-1"
: "gap-1"} rounded-lg cursor-pointer transition-all duration-200 ${hasColorRow
? ""
: "flex-1"}"
@click=${() => this.handleClick()}
>
${(c?.product ?? priceHard ?? priceSoft)
? html`<cosmetic-info
.artist=${artist}
.rarity=${c!.rarity}
.colorPalette=${this.resolved.colorPalette?.name}
.colorPalette=${active.colorPalette?.name}
.showAdFree=${isPurchasable}
></cosmetic-info>`
: nothing}
@@ -247,6 +326,7 @@ export class CosmeticButton extends LitElement {
${this.renderPreview()}
</div>
</button>
${this.renderColorSwatches()}
${isOwnedSubscription
? html`<div
class="w-full mt-2 px-4 py-2 bg-amber-500/20 text-amber-300 border border-amber-500/40 rounded-lg text-xs font-bold uppercase tracking-wider text-center"
+68 -1
View File
@@ -1,4 +1,8 @@
import { resolveCosmetics } from "../src/client/Cosmetics";
import {
groupCosmeticVariants,
resolveCosmetics,
ResolvedCosmetic,
} from "../src/client/Cosmetics";
import { UserMeResponse } from "../src/core/ApiSchemas";
import { Cosmetics } from "../src/core/CosmeticSchemas";
@@ -289,6 +293,69 @@ describe("resolveCosmetics", () => {
});
});
describe("groupCosmeticVariants", () => {
const patternVariant = (
patternName: string,
paletteName: string | null,
): ResolvedCosmetic => ({
type: "pattern",
cosmetic: { name: patternName } as any,
colorPalette: paletteName
? { name: paletteName, primaryColor: "#fff", secondaryColor: "#000" }
: null,
relationship: "purchasable",
key: paletteName
? `pattern:${patternName}:${paletteName}`
: `pattern:${patternName}`,
});
const skinVariant = (name: string): ResolvedCosmetic => ({
type: "skin",
cosmetic: { name } as any,
colorPalette: null,
relationship: "purchasable",
key: `skin:${name}`,
});
test("collapses colour variants of the same pattern into one group", () => {
const groups = groupCosmeticVariants([
patternVariant("stripes", "red"),
patternVariant("stripes", "blue"),
patternVariant("stripes", "green"),
]);
expect(groups).toHaveLength(1);
expect(groups[0].map((r) => r.key)).toEqual([
"pattern:stripes:red",
"pattern:stripes:blue",
"pattern:stripes:green",
]);
});
test("keeps distinct patterns in separate groups, first-seen order", () => {
const groups = groupCosmeticVariants([
patternVariant("stripes", "red"),
patternVariant("dots", "red"),
patternVariant("stripes", "blue"),
]);
expect(groups).toHaveLength(2);
expect(groups[0].map((r) => r.key)).toEqual([
"pattern:stripes:red",
"pattern:stripes:blue",
]);
expect(groups[1].map((r) => r.key)).toEqual(["pattern:dots:red"]);
});
test("skins are never grouped — one group each", () => {
const groups = groupCosmeticVariants([
skinVariant("mountain"),
skinVariant("ocean"),
patternVariant("stripes", "red"),
]);
expect(groups).toHaveLength(3);
expect(groups.map((g) => g.length)).toEqual([1, 1, 1]);
});
});
describe("mixed cosmetics", () => {
test("returns all types in order: default, patterns, flags", () => {
const cosmetics = makeCosmetics({