diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 42c5e1c62..9d9d666eb 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -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(); + 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 { diff --git a/src/client/Store.ts b/src/client/Store.ts index 7189ebf6c..d2ba99006 100644 --- a/src/client/Store.ts +++ b/src/client/Store.ts @@ -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 { `; } + // Collapse colour-palette variants of the same pattern into one tile; the + // variants become clickable colour swatches on the cosmetic-button. return html`
- ${items.map( - (r) => html` + ${groupCosmeticVariants(items).map( + (group) => html` `, @@ -264,10 +268,11 @@ export class StoreModal extends BaseModal {
- ${items.map( - (r) => html` + ${groupCosmeticVariants(items).map( + (group) => html` `, diff --git a/src/client/components/CosmeticButton.ts b/src/client/components/CosmeticButton.ts index 039bb0fbc..2a705a995 100644 --- a/src/client/components/CosmeticButton.ts +++ b/src/client/components/CosmeticButton.ts @@ -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` +
+ ${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``; + })} +
+ `; + } + 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`
`; } - 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``; } - 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`
@@ -164,7 +235,7 @@ export class CosmeticButton extends LitElement {
`; } - const c = this.resolved.cosmetic as Flag; + const c = this.activeResolved.cosmetic as Flag; return html`${c.name} 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`` : nothing} @@ -247,6 +326,7 @@ export class CosmeticButton extends LitElement { ${this.renderPreview()}
+ ${this.renderColorSwatches()} ${isOwnedSubscription ? html`
{ }); }); + 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({