mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 12:12:15 +00:00
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:
@@ -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
@@ -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>
|
||||
`,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user