Merge skin/effects pickers into one lobby Cosmetics modal (#4617)

## What

Replaces the three lobby cosmetic buttons (skin, flag, effects) with
two: **Flag** and **Cosmetics**. The new Cosmetics button opens a single
tabbed modal (`#modal=cosmetics`) with:

- **Skins** — the combined patterns + image-skins grid (from
TerritoryPatternsModal)
- **Effects** — all effect types via the tabbed effects-grid, same
layout as the Store

## Changes

- New `CosmeticsModal` and `CosmeticsInput` (lobby button previews the
selected skin/pattern, shows a "Cosmetics" label on defaults, hidden on
CrazyGames via `no-crazygames`)
- Removed `TerritoryPatternsModal`, `EffectsModal`, `PatternInput`,
`EffectsInput` and their wiring in Main.ts / index.html / LangSelector
- Selecting a skin or pattern no longer closes the modal — the tile
highlight moves to the new selection (matches the Effects tab behavior)
- i18n: added `cosmetics.title/button_title/search`; removed the keys
orphaned by the deleted components

## Notes

- Deep links to the old `#modal=territory-patterns` and `#modal=effects`
no longer resolve
- A follow-up PR (crowns cosmetic) stacks on this branch

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-15 12:39:56 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent ef6e8be2be
commit 7636f4770c
9 changed files with 115 additions and 346 deletions
@@ -10,8 +10,13 @@ import { getPlayerCosmetics } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { translateText } from "./Utils";
@customElement("pattern-input")
export class PatternInput extends LitElement {
/**
* Lobby button that opens the cosmetics modal (skins, crowns, effects).
* Previews the selected skin/pattern; shows a "Cosmetics" label when the
* player is on defaults.
*/
@customElement("cosmetics-input")
export class CosmeticsInput extends LitElement {
@state() public pattern: PlayerPattern | null = null;
@state() public skin: PlayerSkin | null = null;
@state() public selectedColor: string | null = null;
@@ -36,7 +41,7 @@ export class PatternInput extends LitElement {
e.preventDefault();
e.stopPropagation();
this.dispatchEvent(
new CustomEvent("pattern-input-click", {
new CustomEvent("cosmetics-input-click", {
bubbles: true,
composed: true,
}),
@@ -109,13 +114,13 @@ export class PatternInput extends LitElement {
}
const showSelect = this.shouldShowSelectLabel();
const buttonTitle = translateText("territory_patterns.title");
const buttonTitle = translateText("cosmetics.button_title");
// Show loading state
if (this.isLoading) {
return html`
<button
id="pattern-input"
id="cosmetics-input"
class="pattern-btn m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 bg-surface rounded-lg overflow-hidden"
disabled
>
@@ -144,7 +149,7 @@ export class PatternInput extends LitElement {
return html`
<button
id="pattern-input"
id="cosmetics-input"
class="pattern-btn m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:shadow-[var(--shadow-action-card-hover)] rounded-lg overflow-hidden"
title=${buttonTitle}
@click=${this.onInputClick}
@@ -162,7 +167,7 @@ export class PatternInput extends LitElement {
? "text-[7px] leading-tight px-0.5"
: "text-[10px] leading-none break-words px-1"} font-black text-white uppercase w-full text-center"
>
${translateText("territory_patterns.select_skin")}
${translateText("cosmetics.title")}
</span>`
: null}
</button>
@@ -11,6 +11,7 @@ import {
import { PlayerPattern } from "../core/Schemas";
import { BaseModal } from "./components/BaseModal";
import "./components/CosmeticButton";
import "./components/EffectsGrid";
import "./components/NotLoggedInWarning";
import { modalHeader } from "./components/ui/ModalHeader";
import {
@@ -22,12 +23,16 @@ import {
} from "./Cosmetics";
import { translateText } from "./Utils";
@customElement("territory-patterns-modal")
export class TerritoryPatternsModal extends BaseModal {
protected routerName = "territory-patterns";
public previewButton: HTMLElement | null = null;
/**
* One modal for every non-flag cosmetic: a Skins tab (patterns + image skins)
* and an Effects tab (all effect types via the tabbed effects-grid). Opened
* from the lobby's "Cosmetics" button.
*/
@customElement("cosmetics-modal")
export class CosmeticsModal extends BaseModal {
protected routerName = "cosmetics";
@state() private selectedPattern: PlayerPattern | null;
@state() private selectedPattern: PlayerPattern | null = null;
@state() private selectedColor: string | null = null;
@state() private selectedSkinName: string | null = null;
@state() private search = "";
@@ -36,7 +41,16 @@ export class TerritoryPatternsModal extends BaseModal {
private userSettings: UserSettings = new UserSettings();
private userMeResponse: UserMeResponse | false = false;
private _onPatternSelected = async () => {
protected modalConfig() {
return {
tabs: [
{ key: "skins", label: translateText("store.patterns") },
{ key: "effects", label: translateText("store.effects") },
],
};
}
private _onCosmeticSelected = async () => {
await this.updateFromSettings();
this.refresh();
};
@@ -51,7 +65,7 @@ export class TerritoryPatternsModal extends BaseModal {
);
window.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
this._onPatternSelected,
this._onCosmeticSelected,
);
}
@@ -59,7 +73,7 @@ export class TerritoryPatternsModal extends BaseModal {
super.disconnectedCallback();
window.removeEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
this._onPatternSelected,
this._onCosmeticSelected,
);
}
@@ -102,34 +116,32 @@ export class TerritoryPatternsModal extends BaseModal {
);
return html`
<div class="flex flex-col">
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${items.map((r) => {
const isSelected =
r.type === "pattern"
? (r.cosmetic === null && this.selectedPattern === null) ||
(r.cosmetic !== null &&
this.selectedPattern?.name === r.cosmetic.name &&
(this.selectedPattern?.colorPalette?.name ?? null) ===
(r.colorPalette?.name ?? null))
: (() => {
const skinName = (r.cosmetic as Skin | null)?.name ?? null;
return (
(skinName === null && this.selectedSkinName === null) ||
(skinName !== null && this.selectedSkinName === skinName)
);
})();
return html`
<cosmetic-button
.resolved=${r}
.selected=${isSelected}
.onSelect=${(rc: ResolvedCosmetic) => this.selectCosmetic(rc)}
></cosmetic-button>
`;
})}
</div>
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${items.map((r) => {
const isSelected =
r.type === "pattern"
? (r.cosmetic === null && this.selectedPattern === null) ||
(r.cosmetic !== null &&
this.selectedPattern?.name === r.cosmetic.name &&
(this.selectedPattern?.colorPalette?.name ?? null) ===
(r.colorPalette?.name ?? null))
: (() => {
const skinName = (r.cosmetic as Skin | null)?.name ?? null;
return (
(skinName === null && this.selectedSkinName === null) ||
(skinName !== null && this.selectedSkinName === skinName)
);
})();
return html`
<cosmetic-button
.resolved=${r}
.selected=${isSelected}
.onSelect=${(rc: ResolvedCosmetic) => this.selectCosmetic(rc)}
></cosmetic-button>
`;
})}
</div>
`;
}
@@ -140,7 +152,7 @@ export class TerritoryPatternsModal extends BaseModal {
class="relative flex flex-col border-b border-white/10 pb-4 shrink-0"
>
${modalHeader({
title: translateText("territory_patterns.title"),
title: translateText("cosmetics.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
@@ -152,7 +164,7 @@ export class TerritoryPatternsModal extends BaseModal {
rounded-xl shadow-inner text-xl text-center focus:outline-none
focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 text-white placeholder-white/30 transition-all"
type="text"
placeholder=${translateText("territory_patterns.search")}
placeholder=${translateText("cosmetics.search")}
.value=${this.search}
@change=${this.handleSearch}
@keyup=${this.handleSearch}
@@ -162,7 +174,19 @@ export class TerritoryPatternsModal extends BaseModal {
`;
}
protected renderBody() {
protected renderBody(tab: string) {
let grid: TemplateResult;
if (tab === "effects") {
grid = html`<effects-grid
mode="select"
tabbed
.cosmetics=${this.cosmetics}
.userMeResponse=${this.userMeResponse}
.search=${this.search}
></effects-grid>`;
} else {
grid = this.renderSkinGrid();
}
return html`
<div class="flex justify-center py-3 shrink-0">
<o-button
@@ -176,7 +200,7 @@ export class TerritoryPatternsModal extends BaseModal {
}}
></o-button>
</div>
<div class="px-3 pb-3">${this.renderSkinGrid()}</div>
<div class="px-3 pb-3">${grid}</div>
`;
}
@@ -202,8 +226,8 @@ export class TerritoryPatternsModal extends BaseModal {
);
this.selectedSkinName = skinName;
this.selectedPattern = null;
// Stay open — the tile highlight moves to the new selection.
this.refresh();
this.close();
}
private selectPattern(pattern: PlayerPattern | null) {
@@ -219,9 +243,9 @@ export class TerritoryPatternsModal extends BaseModal {
}
this.selectedPattern = pattern;
this.selectedSkinName = null;
// Stay open — the tile highlight moves to the new selection.
this.refresh();
this.showSkinSelectedPopup();
this.close();
}
private showSkinSelectedPopup() {
-111
View File
@@ -1,111 +0,0 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
findEffect,
isTrailEffect,
TRAIL_EFFECT_TYPES,
TrailEffectAttributes,
} from "../core/CosmeticSchemas";
import {
EFFECTS_KEY,
USER_SETTINGS_CHANGED_EVENT,
} from "../core/game/UserSettings";
import "./components/EffectPreview"; // registers <trail-swatch>
import { fetchCosmetics, getPlayerCosmetics } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { translateText } from "./Utils";
@customElement("effects-input")
export class EffectsInput extends LitElement {
// The selected trail effect's attributes for the button preview, if any.
// Not named `attributes` — that collides with HTMLElement.attributes.
@state() private trailAttributes: TrailEffectAttributes | null = null;
private _abortController: AbortController | null = null;
// PlayerEffect is just { name, effectType }; resolve the visual style from the
// cosmetics catalog by (effectType, name). The button shows a single trail
// swatch, so preview the first selected trail effect across trail effectTypes
// (boat trail before nuke trail, per TRAIL_EFFECT_TYPES order). nukeExplosion
// is not a trail and has no swatch preview here.
private async resolveTrailAttributes(): Promise<TrailEffectAttributes | null> {
const cosmetics = await getPlayerCosmetics();
const catalog = await fetchCosmetics();
for (const effectType of TRAIL_EFFECT_TYPES) {
const name = cosmetics.effects?.[effectType]?.name;
if (!name) continue;
const effect = findEffect(catalog, effectType, name);
if (effect && isTrailEffect(effect)) return effect.attributes;
}
return null;
}
private _onCosmeticSelected = async () => {
this.trailAttributes = await this.resolveTrailAttributes();
};
private onInputClick(e: Event) {
e.preventDefault();
e.stopPropagation();
this.dispatchEvent(
new CustomEvent("effects-input-click", {
bubbles: true,
composed: true,
}),
);
}
async connectedCallback() {
super.connectedCallback();
this._abortController = new AbortController();
this.trailAttributes = await this.resolveTrailAttributes();
window.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${EFFECTS_KEY}`,
this._onCosmeticSelected,
{ signal: this._abortController.signal },
);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._abortController) {
this._abortController.abort();
this._abortController = null;
}
}
createRenderRoot() {
return this;
}
render() {
if (crazyGamesSDK.isOnCrazyGames()) {
return html``;
}
const preview =
this.trailAttributes === null
? html`<span
class="text-[7px] lg:text-[10px] font-black tracking-wider text-white uppercase leading-tight lg:leading-none w-full text-center px-0.5 lg:px-1"
>
${translateText("effects.title")}
</span>`
: html`<span class="w-full h-full p-1.5">
<trail-swatch
class="block w-full h-full"
.trail=${this.trailAttributes}
></trail-swatch>
</span>`;
return html`
<button
id="effects-input"
class="p-0 m-0 border-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:shadow-[var(--shadow-action-card-hover)] rounded-lg overflow-hidden"
title=${translateText("effects.button_title")}
@click=${this.onInputClick}
>
${preview}
</button>
`;
}
}
-103
View File
@@ -1,103 +0,0 @@
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { Cosmetics, EFFECT_TYPES } from "../core/CosmeticSchemas";
import { BaseModal } from "./components/BaseModal";
import "./components/EffectsGrid";
import "./components/NotLoggedInWarning";
import { modalHeader } from "./components/ui/ModalHeader";
import { fetchCosmetics } from "./Cosmetics";
import { translateText } from "./Utils";
@customElement("effects-modal")
export class EffectsModal extends BaseModal {
protected routerName = "effects";
@state() private cosmetics: Cosmetics | null = null;
@state() private userMeResponse: UserMeResponse | false = false;
@state() private search = "";
// One tab per trail effectType; BaseModal owns activeTab + renders the bar.
protected modalConfig() {
return {
tabs: EFFECT_TYPES.map((type) => ({
key: type,
label: translateText(`effects.type.${type}`),
})),
};
}
private handleSearch(event: Event) {
this.search = (event.target as HTMLInputElement).value;
}
connectedCallback() {
super.connectedCallback();
document.addEventListener(
"userMeResponse",
(event: CustomEvent<UserMeResponse | false>) => {
this.onUserMe(event.detail);
},
);
}
async onUserMe(userMeResponse: UserMeResponse | false) {
this.userMeResponse = userMeResponse;
this.cosmetics = await fetchCosmetics();
this.requestUpdate();
}
protected renderHeaderSlot() {
return html`
<div
class="relative flex flex-col border-b border-white/10 pb-4 shrink-0"
>
${modalHeader({
title: translateText("effects.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
})}
<div class="md:flex items-center gap-2 justify-center mt-4">
<input
class="h-12 w-full max-w-md border border-white/10 bg-black/60
rounded-xl shadow-inner text-xl text-center focus:outline-none
focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 text-white placeholder-white/30 transition-all"
type="text"
placeholder=${translateText("effects.search")}
.value=${this.search}
@change=${this.handleSearch}
@keyup=${this.handleSearch}
/>
</div>
</div>
`;
}
protected renderBody(tab: string) {
return html`
<div class="flex flex-col">
<div class="flex justify-center py-3 shrink-0">
<o-button
class="no-crazygames"
variant="primary"
size="sm"
translationKey="main.store"
@click=${() => {
this.close();
window.showPage?.("page-item-store");
}}
></o-button>
</div>
<effects-grid
mode="select"
.cosmetics=${this.cosmetics}
.userMeResponse=${this.userMeResponse}
.search=${this.search}
.effectType=${tab}
></effects-grid>
</div>
`;
}
}
+2 -4
View File
@@ -225,10 +225,10 @@ export class LangSelector extends LitElement {
"user-setting",
"o-modal",
"o-button",
"territory-patterns-modal",
"cosmetics-modal",
"store-modal",
"custom-currency-card",
"pattern-input",
"cosmetics-input",
"fluent-slider",
"news-modal",
"news-button",
@@ -236,8 +236,6 @@ export class LangSelector extends LitElement {
"leaderboard-modal",
"flag-input-modal",
"flag-input",
"effects-modal",
"effects-input",
"effects-grid",
"matchmaking-button",
"token-login",
+16 -45
View File
@@ -19,11 +19,11 @@ import { reauthAfterCrazyGamesChange, userAuth } from "./Auth";
import "./ClanModal";
import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner";
import { getPlayerCosmeticsRefs } from "./Cosmetics";
import "./CosmeticsInput";
import "./CosmeticsModal";
import { CosmeticsModal } from "./CosmeticsModal";
import { updateCrazyGamesNavButton } from "./CrazyGamesAccountButton";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import "./EffectsInput";
import "./EffectsModal";
import { EffectsModal } from "./EffectsModal";
import "./FlagInput";
import { FlagInput } from "./FlagInput";
import "./FlagInputModal";
@@ -47,12 +47,9 @@ import { MatchmakingModal } from "./Matchmaking";
import { modalRouter } from "./ModalRouter";
import { initNavigation } from "./Navigation";
import "./NewsModal";
import "./PatternInput";
import { RewardsModal } from "./RewardsModal";
import "./SinglePlayerModal";
import { StoreModal } from "./Store";
import "./TerritoryPatternsModal";
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
import { TokenLoginModal } from "./TokenLoginModal";
import {
SendKickPlayerIntentEvent,
@@ -321,11 +318,8 @@ class Client {
tag: "troubleshooting-modal",
pageId: "page-troubleshooting",
});
modalRouter.register("territory-patterns", {
tag: "territory-patterns-modal",
});
modalRouter.register("cosmetics", { tag: "cosmetics-modal" });
modalRouter.register("flag-input", { tag: "flag-input-modal" });
modalRouter.register("effects", { tag: "effects-modal" });
// Prefetch turnstile token so it is available when
// the user joins a lobby.
@@ -436,52 +430,30 @@ class Client {
});
});
const effectsModal = document.querySelector(
"effects-modal",
) as EffectsModal;
if (!effectsModal || !(effectsModal instanceof EffectsModal)) {
console.warn("Effects modal element not found");
}
document.querySelectorAll("effects-input").forEach((effectsInput) => {
effectsInput.addEventListener("effects-input-click", () => {
if (effectsModal && effectsModal instanceof EffectsModal) {
effectsModal.open();
}
});
});
this.storeModal = document.getElementById("page-item-store") as StoreModal;
if (!this.storeModal || !(this.storeModal instanceof StoreModal)) {
console.warn("Store modal element not found");
}
const patternsModal = document.getElementById(
"territory-patterns-modal",
) as TerritoryPatternsModal;
if (!patternsModal || !(patternsModal instanceof TerritoryPatternsModal)) {
console.warn("Patterns modal element not found");
const cosmeticsModal = document.getElementById(
"cosmetics-modal",
) as CosmeticsModal;
if (!cosmeticsModal || !(cosmeticsModal instanceof CosmeticsModal)) {
console.warn("Cosmetics modal element not found");
}
// Attach listener to any pattern-input component
document.querySelectorAll("pattern-input").forEach((patternInput) => {
patternInput.addEventListener("pattern-input-click", () => {
patternsModal.open();
// Attach listener to any cosmetics-input component
document.querySelectorAll("cosmetics-input").forEach((cosmeticsInput) => {
cosmeticsInput.addEventListener("cosmetics-input-click", () => {
cosmeticsModal.open();
});
});
if (isInIframe()) {
const mobilePat = document.getElementById("pattern-input-mobile");
if (mobilePat) mobilePat.style.display = "none";
const mobileCosmetics = document.getElementById("cosmetics-input-mobile");
if (mobileCosmetics) mobileCosmetics.style.display = "none";
}
if (!this.storeModal || !(this.storeModal instanceof StoreModal)) {
console.warn("Store modal element not found");
}
// We no longer need to manually manage the preview button as PatternInput handles it component-side.
// However, we still want to ensure the modal can be opened.
// The setupPatternInput above handles the click event for the new buttons.
this.storeModal.refresh();
window.addEventListener("showPage", (e: any) => {
@@ -958,12 +930,11 @@ class Client {
"help-modal",
"user-setting",
"troubleshooting-modal",
"territory-patterns-modal",
"cosmetics-modal",
"store-modal",
"language-modal",
"news-modal",
"flag-input-modal",
"effects-modal",
"account-button",
"leaderboard-button",
"token-login",
+12 -20
View File
@@ -121,40 +121,32 @@ export class PlayPage extends LitElement {
<username-input
class="flex-1 min-w-0 h-10 lg:h-[50px]"
></username-input>
<pattern-input
id="pattern-input-mobile"
show-select-label
adaptive-size
class="shrink-0 lg:hidden"
></pattern-input>
<flag-input
id="flag-input-mobile"
show-select-label
class="shrink-0 lg:hidden h-10 w-10"
></flag-input>
<effects-input
id="effects-input-mobile"
class="shrink-0 lg:hidden h-10 w-10"
></effects-input>
<cosmetics-input
id="cosmetics-input-mobile"
show-select-label
adaptive-size
class="no-crazygames shrink-0 lg:hidden"
></cosmetics-input>
</div>
</div>
<!-- Skin + flag: right col -->
<!-- Flag + cosmetics: right col -->
<div class="hidden lg:flex h-[60px] gap-2">
<pattern-input
id="pattern-input-desktop"
show-select-label
class="flex-1 h-full"
></pattern-input>
<flag-input
id="flag-input-desktop"
show-select-label
class="flex-1 h-full"
></flag-input>
<effects-input
id="effects-input-desktop"
class="flex-1 h-full"
></effects-input>
<cosmetics-input
id="cosmetics-input-desktop"
show-select-label
class="no-crazygames flex-1 h-full"
></cosmetics-input>
</div>
</div>