Merge branch 'v30'

This commit is contained in:
evanpelle
2026-04-06 20:38:22 -07:00
19 changed files with 1187 additions and 479 deletions
+1
View File
@@ -10,6 +10,7 @@ resources/.DS_Store
.DS_Store
.clinic/
CLAUDE.md
.claude/
.idea/
# this is autogenerated by script
src/assets/
+1 -1
View File
@@ -178,7 +178,7 @@
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-99999"
></div>
<gutter-ads></gutter-ads>
<homepage-promos></homepage-promos>
<!-- Main container with responsive padding -->
<main-layout class="contents">
+8 -1
View File
@@ -951,7 +951,14 @@
"search": "Search..."
},
"cosmetics": {
"artist_label": "Artist:"
"artist_label": "Artist:",
"color_label": "Color:",
"common": "Common",
"uncommon": "Uncommon",
"rare": "Rare",
"epic": "Epic",
"legendary": "Legendary",
"adfree": "ad-free for life!"
},
"flag_input": {
"title": "Select Flag",
+2 -7
View File
@@ -41,12 +41,7 @@ export class FlagInputModal extends BaseModal {
.map(
([key, flag]) => html`
<flag-button
.flag=${{
key: `flag:${key}`,
name: flag.name,
url: flag.url,
artist: flag.artist,
}}
.flag=${{ ...flag, key: `flag:${key}` }}
.selected=${selectedFlag === `flag:${key}`}
.onSelect=${onSelect}
></flag-button>
@@ -88,7 +83,7 @@ export class FlagInputModal extends BaseModal {
return html`
<div
class="pt-1 flex flex-wrap gap-1.5 justify-center items-stretch content-start"
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${noFlag} ${cosmeticFlags} ${countryFlags}
</div>
-162
View File
@@ -1,162 +0,0 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { FOOTER_AD_MIN_HEIGHT } from "./components/HomeFooterAd";
@customElement("gutter-ads")
export class GutterAds extends LitElement {
@state()
private isVisible: boolean = false;
@state()
private adLoaded: boolean = false;
@state()
private hasFooterAd: boolean = false;
private onResize = () => {
const isDesktop = window.innerWidth >= 640;
this.hasFooterAd = isDesktop && window.innerHeight >= FOOTER_AD_MIN_HEIGHT;
};
private leftAdType: string = "standard_iab_left2";
private rightAdType: string = "standard_iab_rght1";
private leftContainerId: string = "gutter-ad-container-left";
private rightContainerId: string = "gutter-ad-container-right";
// Override createRenderRoot to disable shadow DOM
createRenderRoot() {
return this;
}
static styles = css``;
connectedCallback() {
super.connectedCallback();
this.onResize();
window.addEventListener("resize", this.onResize);
document.addEventListener("userMeResponse", () => {
if (window.adsEnabled) {
console.log("showing gutter ads");
this.show();
} else {
console.log("not showing gutter ads");
}
});
}
// Called after the component's DOM is first rendered
firstUpdated() {
// DOM is guaranteed to be available here
console.log("GutterAdModal DOM is ready");
}
public show(): void {
this.isVisible = true;
this.requestUpdate();
// Wait for the update to complete, then load ads
this.updateComplete.then(() => {
this.loadAds();
});
}
public close(): void {
try {
window.ramp.destroyUnits(this.leftAdType);
window.ramp.destroyUnits(this.rightAdType);
console.log("successfully destroyed gutter ads");
} catch (e) {
console.error("error destroying gutter ads", e);
}
}
private loadAds(): void {
console.log("loading ramp ads");
// Ensure the container elements exist before loading ads
const leftContainer = this.querySelector(`#${this.leftContainerId}`);
const rightContainer = this.querySelector(`#${this.rightContainerId}`);
if (!leftContainer || !rightContainer) {
console.warn("Ad containers not found in DOM");
return;
}
if (!window.ramp) {
console.warn("Playwire RAMP not available");
return;
}
if (this.adLoaded) {
console.log("Ads already loaded, skipping");
return;
}
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([
{
type: this.leftAdType,
selectorId: this.leftContainerId,
},
{
type: this.rightAdType,
selectorId: this.rightContainerId,
},
]);
this.adLoaded = true;
console.log(
"Playwire ads loaded:",
this.leftAdType,
this.rightAdType,
);
} catch (e) {
console.log(e);
}
});
} catch (error) {
console.error("Failed to load Playwire ads:", error);
}
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this.onResize);
}
render() {
if (!this.isVisible) {
return html``;
}
return html`
<!-- Left Gutter Ad -->
<div
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
style="left: calc(50% - var(--half-content) - 208px); top: calc(50% + 10px${this
.hasFooterAd
? " - 1.2cm"
: ""});"
>
<div
id="${this.leftContainerId}"
class="w-full h-full flex items-center justify-center p-2"
></div>
</div>
<!-- Right Gutter Ad -->
<div
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
style="left: calc(50% + var(--half-content) + 48px); top: calc(50% + 10px${this
.hasFooterAd
? " - 1.2cm"
: ""});"
>
<div
id="${this.rightContainerId}"
class="w-full h-full flex items-center justify-center p-2"
></div>
</div>
`;
}
}
+259
View File
@@ -0,0 +1,259 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
export const FOOTER_AD_MIN_HEIGHT = 880;
const FOOTER_AD_TYPE = "standard_iab_head2";
const FOOTER_AD_CONTAINER_ID = "home-footer-ad-container";
// ─── Gutter Ads ──────────────────────────────────────────────────────────────
@customElement("homepage-promos")
export class HomepagePromos extends LitElement {
@state() private isVisible: boolean = false;
@state() private adLoaded: boolean = false;
private cornerAdLoaded: boolean = false;
@state() private hasFooterAd: boolean = false;
private onResize = () => {
const isDesktop = window.innerWidth >= 640;
this.hasFooterAd = isDesktop && window.innerHeight >= FOOTER_AD_MIN_HEIGHT;
};
private onUserMeResponse = () => {
if (window.adsEnabled) {
console.log("showing homepage ads");
this.show();
this.loadCornerAdVideo();
} else {
console.log("not showing homepage ads");
}
};
private leftAdType: string = "standard_iab_left2";
private rightAdType: string = "standard_iab_rght1";
private leftContainerId: string = "gutter-ad-container-left";
private rightContainerId: string = "gutter-ad-container-right";
createRenderRoot() {
return this;
}
static styles = css``;
connectedCallback() {
super.connectedCallback();
this.onResize();
window.addEventListener("resize", this.onResize);
document.addEventListener("userMeResponse", this.onUserMeResponse);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this.onResize);
document.removeEventListener("userMeResponse", this.onUserMeResponse);
}
public show(): void {
this.isVisible = true;
this.requestUpdate();
this.updateComplete.then(() => {
this.loadGutterAds();
});
}
public close(): void {
try {
// Keep corner video ad alive.
window.ramp.destroyUnits(this.leftAdType);
window.ramp.destroyUnits(this.rightAdType);
console.log("successfully destroyed gutter ads");
} catch (e) {
console.error("error destroying gutter ads", e);
}
}
private loadGutterAds(): void {
console.log("loading ramp gutter ads");
const leftContainer = this.querySelector(`#${this.leftContainerId}`);
const rightContainer = this.querySelector(`#${this.rightContainerId}`);
if (!leftContainer || !rightContainer) {
console.warn("Ad containers not found in DOM");
return;
}
if (!window.ramp) {
console.warn("Playwire RAMP not available");
return;
}
if (this.adLoaded) {
console.log("Ads already loaded, skipping");
return;
}
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([
{ type: this.leftAdType, selectorId: this.leftContainerId },
{ type: this.rightAdType, selectorId: this.rightContainerId },
]);
this.adLoaded = true;
console.log("Gutter ads loaded:", this.leftAdType, this.rightAdType);
} catch (e) {
console.log(e);
}
});
} catch (error) {
console.error("Failed to load gutter ads:", error);
}
}
private loadCornerAdVideo(): void {
if (this.cornerAdLoaded) return;
if (window.innerWidth < 1280) return;
if (!window.ramp) {
console.warn("Playwire RAMP not available for corner_ad_video");
return;
}
try {
window.ramp.que.push(() => {
try {
window.ramp
.addUnits([{ type: "corner_ad_video" }])
.then(() => {
this.cornerAdLoaded = true;
window.ramp.displayUnits();
console.log("corner_ad_video loaded");
})
.catch((e: unknown) => {
console.error("Failed to display corner_ad_video:", e);
});
} catch (e) {
console.error("Failed to add corner_ad_video:", e);
}
});
} catch (error) {
console.error("Failed to load corner_ad_video:", error);
}
}
render() {
if (!this.isVisible) {
return html``;
}
return html`
<!-- Left Gutter Ad -->
<div
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
style="left: calc(50% - var(--half-content) - 208px); top: calc(50% + 10px${this
.hasFooterAd
? " - 1.2cm"
: ""});"
>
<div
id="${this.leftContainerId}"
class="w-full h-full flex items-center justify-center p-2"
></div>
</div>
<!-- Right Gutter Ad -->
<div
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
style="left: calc(50% + var(--half-content) + 48px); top: calc(50% + 10px${this
.hasFooterAd
? " - 1.2cm"
: ""});"
>
<div
id="${this.rightContainerId}"
class="w-full h-full flex items-center justify-center p-2"
></div>
</div>
`;
}
}
// ─── Footer Ad ───────────────────────────────────────────────────────────────
@customElement("home-footer-ad")
export class HomeFooterAd extends LitElement {
@state() private shouldShow: boolean = false;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.style.display = "contents";
document.addEventListener("userMeResponse", this.onUserMeResponse);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener("userMeResponse", this.onUserMeResponse);
this.destroyAd();
}
private onUserMeResponse = () => {
const isDesktop = window.innerWidth >= 640;
if (
!window.adsEnabled ||
(isDesktop && window.innerHeight < FOOTER_AD_MIN_HEIGHT)
) {
return;
}
this.shouldShow = true;
this.updateComplete.then(() => {
this.loadAd();
});
};
private loadAd(): void {
if (!window.ramp) {
console.warn("Playwire RAMP not available for footer ad");
return;
}
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([
{ type: FOOTER_AD_TYPE, selectorId: FOOTER_AD_CONTAINER_ID },
]);
console.log("Footer ad loaded:", FOOTER_AD_TYPE);
} catch (e) {
console.error("Failed to add footer ad:", e);
}
});
} catch (error) {
console.error("Failed to load footer ad:", error);
}
}
private destroyAd(): void {
try {
window.ramp.destroyUnits(FOOTER_AD_TYPE);
console.log("successfully destroyed footer ad");
} catch (e) {
console.error("error destroying footer ad", e);
}
}
render() {
if (!this.shouldShow) {
return nothing;
}
return html`
<div
id="${FOOTER_AD_CONTAINER_ID}"
class="flex justify-center items-center w-full pointer-events-auto [&_*]:!m-0 [&_*]:!p-0"
style="margin: 0; padding: 0; line-height: 0;"
></div>
`;
}
}
+5 -8
View File
@@ -27,8 +27,8 @@ import "./GameModeSelector";
import { GameModeSelector } from "./GameModeSelector";
import { GameStartingModal } from "./GameStartingModal";
import "./GoogleAdElement";
import { GutterAds } from "./GutterAds";
import { HelpModal } from "./HelpModal";
import { HomepagePromos } from "./HomepagePromos";
import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal";
import { JoinLobbyModal } from "./JoinLobbyModal";
import "./LangSelector";
@@ -60,7 +60,6 @@ import {
} from "./Utils";
import "./components/DesktopNavBar";
import "./components/Footer";
import "./components/HomeFooterAd";
import "./components/MainLayout";
import "./components/MobileNavBar";
import "./components/PlayPage";
@@ -244,7 +243,6 @@ class Client {
private tokenLoginModal: TokenLoginModal;
private matchmakingModal: MatchmakingModal;
private gutterAds: GutterAds;
private turnstileTokenPromise: Promise<{
token: string;
createdAt: number;
@@ -304,10 +302,9 @@ class Client {
}
});
const gutterAds = document.querySelector("gutter-ads");
if (!(gutterAds instanceof GutterAds))
throw new Error("Missing gutter-ads");
this.gutterAds = gutterAds;
const gutterAds = document.querySelector("homepage-promos");
if (!(gutterAds instanceof HomepagePromos))
throw new Error("Missing homepage-promos");
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
@@ -778,7 +775,7 @@ class Client {
"token-login",
"matchmaking-modal",
"lang-selector",
"gutter-ads",
"homepage-promos",
].forEach((tag) => {
const modal = document.querySelector(tag) as HTMLElement & {
close?: () => void;
+3 -9
View File
@@ -159,7 +159,7 @@ export class StoreModal extends BaseModal {
return html`
<div
class="flex flex-wrap gap-4 p-2 justify-center items-stretch content-start"
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${buttons}
</div>
@@ -179,13 +179,7 @@ export class StoreModal extends BaseModal {
const selectedFlag = new UserSettings().getFlag() ?? "";
buttons.push(html`
<flag-button
.flag=${{
key: `flag:${key}`,
name: flag.name,
url: flag.url,
product: flag.product,
artist: flag.artist,
}}
.flag=${{ ...flag, key: `flag:${key}` }}
.selected=${selectedFlag === `flag:${key}`}
.requiresPurchase=${rel === "purchasable"}
.onPurchase=${() => handlePurchase(flag.product!)}
@@ -203,7 +197,7 @@ export class StoreModal extends BaseModal {
return html`
<div
class="flex flex-wrap gap-4 p-2 justify-center items-stretch content-start"
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${buttons}
</div>
+1 -1
View File
@@ -131,7 +131,7 @@ export class TerritoryPatternsModal extends BaseModal {
return html`
<div class="flex flex-col">
<div
class="flex flex-wrap gap-4 p-2 justify-center items-stretch content-start"
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${buttons}
</div>
-37
View File
@@ -1,37 +0,0 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../Utils";
@customElement("artist-info")
export class ArtistInfo extends LitElement {
@property({ type: String })
artist?: string;
createRenderRoot() {
return this;
}
render() {
if (!this.artist) {
return nothing;
}
return html`
<div
class="absolute -top-1 -right-1 z-10 group/artist"
@click=${(e: Event) => e.stopPropagation()}
>
<div
class="w-6 h-6 rounded-full bg-white/20 hover:bg-white/40 flex items-center justify-center cursor-help transition-colors duration-150"
>
<span class="text-xs font-bold text-white/70">?</span>
</div>
<div
class="hidden group-hover/artist:block absolute top-7 right-0 bg-zinc-800 text-white text-xs px-2.5 py-1.5 rounded shadow-lg whitespace-nowrap z-20 border border-white/10"
>
${translateText("cosmetics.artist_label")} ${this.artist}
</div>
</div>
`;
}
}
+432
View File
@@ -0,0 +1,432 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Product } from "../../core/CosmeticSchemas";
import "./PurchaseButton";
type Rarity = "common" | "uncommon" | "rare" | "epic" | "legendary" | string;
interface RarityConfig {
gradient: string;
border: string;
glow: string;
hoverGlowSize: string;
nameColor: string;
legendary?: boolean;
shimmer?: boolean;
shimmerColor?: string; // rgb triplet e.g. "255,200,80"
borderSweep?: boolean;
borderSweepColor?: string; // rgb triplet e.g. "192,132,252"
}
const rarityConfig: Record<string, RarityConfig> = {
common: {
gradient: "rgba(80,80,80,0.55)",
border: "rgba(255,255,255,0.15)",
glow: "rgba(255,255,255,0.5)",
hoverGlowSize: "10px",
nameColor: "rgba(255,255,255,0.7)",
},
uncommon: {
gradient: "rgba(30,100,30,0.65)",
border: "rgba(74,222,128,0.45)",
glow: "rgba(74,222,128,0.6)",
hoverGlowSize: "12px",
nameColor: "rgba(255,255,255,1)",
},
rare: {
gradient: "rgba(20,60,160,0.70)",
border: "rgba(96,165,250,0.50)",
glow: "rgba(96,165,250,0.7)",
hoverGlowSize: "14px",
nameColor: "rgba(255,255,255,1)",
},
epic: {
gradient: "rgba(90,20,160,0.75)",
border: "rgba(192,132,252,0.60)",
glow: "rgba(192,132,252,0.85)",
hoverGlowSize: "14px",
nameColor: "rgba(255,255,255,1)",
shimmer: true,
shimmerColor: "192,132,252",
},
legendary: {
gradient: "rgba(180,80,0,0.75)",
border: "rgba(251,146,60,0.65)",
glow: "rgba(251,146,60,0.95)",
hoverGlowSize: "25px",
nameColor: "rgba(255,255,255,1)",
legendary: true,
shimmer: true,
shimmerColor: "255,200,80",
borderSweep: true,
borderSweepColor: "255,200,80",
},
};
const fallback = rarityConfig["common"];
const STYLE_ID = "cosmetic-container-styles";
if (!document.getElementById(STYLE_ID)) {
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
@keyframes legendary-pulse {
0% { box-shadow: 0 0 15px rgba(251,146,60,0.8), 0 0 30px rgba(251,146,60,0.4); }
50% { box-shadow: 0 0 25px rgba(251,146,60,0.9), 0 0 45px rgba(251,146,60,0.5); }
100% { box-shadow: 0 0 15px rgba(251,146,60,0.8), 0 0 30px rgba(251,146,60,0.4); }
}
@keyframes legendary-shimmer {
0% { left: -60%; }
100% { left: 160%; }
}
@keyframes legendary-border-sweep {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes sparkle-twinkle-0 {
0%, 100% { opacity: 0; transform: scale(0.5) rotate(0deg); }
40%, 60% { opacity: 1; transform: scale(1.2) rotate(20deg); }
}
@keyframes sparkle-twinkle-1 {
0%, 100% { opacity: 0; transform: scale(0.5) rotate(0deg); }
30%, 55% { opacity: 1; transform: scale(1.1) rotate(-15deg); }
}
@keyframes sparkle-twinkle-2 {
0%, 100% { opacity: 0; transform: scale(0.5) rotate(0deg); }
45%, 65% { opacity: 1; transform: scale(1.3) rotate(10deg); }
}
@keyframes sparkle-twinkle-3 {
0%, 100% { opacity: 0; transform: scale(0.5) rotate(0deg); }
35%, 58% { opacity: 1; transform: scale(1.0) rotate(-20deg); }
}
.legendary-hovered {
animation: legendary-pulse 1.4s ease-in-out infinite;
}
.legendary-shimmer.active {
animation: legendary-shimmer 0.8s ease-in-out;
}
.legendary-border-sweep {
animation: legendary-border-sweep 8s linear infinite;
}
.legendary-sparkle-0 { animation: sparkle-twinkle-0 1.6s ease-in-out infinite; }
.legendary-sparkle-1 { animation: sparkle-twinkle-1 1.9s ease-in-out infinite 0.3s; }
.legendary-sparkle-2 { animation: sparkle-twinkle-2 1.7s ease-in-out infinite 0.7s; }
.legendary-sparkle-3 { animation: sparkle-twinkle-3 2.0s ease-in-out infinite 0.1s; }
@keyframes cosmetic-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.cosmetic-loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.6);
border-radius: 0.75rem;
z-index: 20;
}
.cosmetic-loading-spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255,255,255,0.2);
border-top-color: rgb(74,222,128);
border-radius: 50%;
animation: cosmetic-spin 0.8s linear infinite;
}
`;
document.head.appendChild(style);
}
@customElement("cosmetic-container")
export class CosmeticContainer extends LitElement {
@property({ type: String })
rarity: Rarity = "common";
@property({ type: Boolean })
selected: boolean = false;
@property({ type: String })
name: string = "";
@property({ type: Object })
product: Product | null = null;
@property({ type: Function })
onPurchase?: () => void;
private static _backdrop: HTMLDivElement | null = null;
private static _ensureBackdrop(): HTMLDivElement {
if (!CosmeticContainer._backdrop) {
const el = document.createElement("div");
el.style.cssText = `
pointer-events: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0);
z-index: 9;
transition: background 0.3s ease;
`;
document.body.appendChild(el);
CosmeticContainer._backdrop = el;
}
return CosmeticContainer._backdrop;
}
private _shimmer: HTMLDivElement | null = null;
private _borderSweep: HTMLDivElement | null = null;
private _sparkles: HTMLDivElement[] = [];
private _glowColor = fallback.glow;
private _glowSize = fallback.hoverGlowSize;
private _isLegendary = false;
private _hasGlint = false;
private _hasBorderSweep = false;
private _loading = false;
private _loadingOverlay: HTMLDivElement | null = null;
createRenderRoot() {
return this;
}
private applyHostStyles() {
const cfg = rarityConfig[this.rarity] ?? fallback;
this._glowColor = cfg.glow;
this._glowSize = cfg.hoverGlowSize;
this._isLegendary = !!cfg.legendary;
this._hasGlint = !!cfg.shimmer;
this._hasBorderSweep = !!cfg.borderSweep;
this.style.position = "relative";
this.style.overflow = "hidden";
this.style.background = `linear-gradient(to top, ${cfg.gradient} 0%, rgba(15,15,20,0.85) 100%)`;
this.style.border = `1px solid ${this.selected ? cfg.glow : cfg.border}`;
this.style.backdropFilter = "blur(8px)";
this.style.borderRadius = "0.75rem";
this.style.transition =
"border-color 0.2s, background 0.2s, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.2s";
this.style.zIndex = "0";
this.style.cursor = this.product ? "pointer" : "";
if (this.selected) {
this.style.boxShadow = `0 0 18px ${cfg.glow}`;
} else if (!this.classList.contains("legendary-hovered")) {
this.style.boxShadow = "";
}
}
private _ensureLegendaryElements() {
if (this._shimmer || this._borderSweep) return;
// Shimmer sweep — epic and legendary
if (this._hasGlint) {
const shimmer = document.createElement("div");
shimmer.className = "legendary-shimmer";
shimmer.style.cssText = `
pointer-events: none;
position: absolute;
top: 0;
left: -60%;
width: 40%;
height: 100%;
background: linear-gradient(90deg, transparent 0%, rgba(${(rarityConfig[this.rarity] ?? fallback).shimmerColor ?? "255,200,80"},0.45) 50%, transparent 100%);
transform: skewX(-15deg);
z-index: 10;
display: none;
`;
this.appendChild(shimmer);
this._shimmer = shimmer;
}
if (!this._hasBorderSweep) return;
const sweepWrap = document.createElement("div");
sweepWrap.style.cssText = `
pointer-events: none;
position: absolute;
inset: -2px;
border-radius: 0.85rem;
z-index: -1;
overflow: hidden;
display: none;
`;
const sweepInner = document.createElement("div");
sweepInner.className = "legendary-border-sweep";
const sc =
(rarityConfig[this.rarity] ?? fallback).borderSweepColor ?? "255,200,80";
sweepInner.style.cssText = `
position: absolute;
inset: -100%;
background: conic-gradient(
from 0deg,
transparent 0deg,
rgba(${sc},0.0) 60deg,
rgba(${sc},0.9) 120deg,
rgba(${sc},1) 180deg,
rgba(${sc},0.9) 240deg,
rgba(${sc},0.0) 300deg,
transparent 360deg
);
`;
// Inner mask to hide center, show only border ring
const sweepMask = document.createElement("div");
sweepMask.style.cssText = `
position: absolute;
inset: 2px;
border-radius: 0.75rem;
background: transparent;
`;
sweepWrap.appendChild(sweepInner);
sweepWrap.appendChild(sweepMask);
this.appendChild(sweepWrap);
this._borderSweep = sweepWrap;
// Corner sparkles ✦
const corners = [
{ top: "4px", left: "4px" },
{ top: "4px", right: "4px" },
{ bottom: "4px", left: "4px" },
{ bottom: "4px", right: "4px" },
];
this._sparkles = corners.map((pos, i) => {
const el = document.createElement("div");
el.className = `legendary-sparkle-${i}`;
el.textContent = "✦";
el.style.cssText = `
pointer-events: none;
position: absolute;
font-size: 10px;
color: rgba(255,220,100,0.9);
text-shadow: 0 0 6px rgba(255,200,60,1);
z-index: 11;
opacity: 0;
display: none;
line-height: 1;
`;
Object.assign(el.style, pos);
this.appendChild(el);
return el;
});
}
private _onClick = () => {
if (CosmeticContainer._backdrop) {
CosmeticContainer._backdrop.style.background = "rgba(0,0,0,0)";
}
if (this.product && this.onPurchase && !this._loading) {
this._loading = true;
this._showLoadingOverlay();
Promise.resolve(this.onPurchase()).catch(() => {
this._hideLoadingOverlay();
});
}
};
private _showLoadingOverlay() {
if (this._loadingOverlay) return;
const overlay = document.createElement("div");
overlay.className = "cosmetic-loading-overlay";
overlay.innerHTML = `<div class="cosmetic-loading-spinner"></div>`;
this.appendChild(overlay);
this._loadingOverlay = overlay;
}
private _hideLoadingOverlay() {
this._loadingOverlay?.remove();
this._loadingOverlay = null;
this._loading = false;
}
private _onMouseEnter = () => {
if (this._hasGlint || this._hasBorderSweep) {
this._ensureLegendaryElements();
}
if (this._isLegendary) {
this.style.transform = "scale(1.12)";
this.style.zIndex = "10";
this.classList.add("legendary-hovered");
this._sparkles.forEach((s) => (s.style.display = "block"));
CosmeticContainer._ensureBackdrop().style.background = "rgba(0,0,0,0.6)";
}
if (this._hasBorderSweep && this._borderSweep) {
this._borderSweep.style.display = "block";
}
if (this._hasGlint && this._shimmer) {
this._shimmer.style.display = "block";
this._shimmer.classList.remove("active");
void this._shimmer.offsetWidth;
this._shimmer.classList.add("active");
}
if (!this._isLegendary && !this.selected) {
this.style.boxShadow = `0 0 ${this._glowSize} ${this._glowColor}`;
}
};
private _onMouseLeave = () => {
if (this._isLegendary) {
this.style.transform = "";
this.style.zIndex = "0";
this.classList.remove("legendary-hovered");
this._sparkles.forEach((s) => (s.style.display = "none"));
if (CosmeticContainer._backdrop) {
CosmeticContainer._backdrop.style.background = "rgba(0,0,0,0)";
}
}
if (this._hasGlint && this._shimmer) this._shimmer.style.display = "none";
if (this._hasBorderSweep && this._borderSweep)
this._borderSweep.style.display = "none";
if (!this.selected) this.style.boxShadow = "";
};
private _nameEl: HTMLDivElement | null = null;
private _updateNameEl() {
if (this.name) {
this._nameEl ??= document.createElement("div");
const cfg = rarityConfig[this.rarity] ?? fallback;
this._nameEl.className = `text-xs font-bold uppercase tracking-wider text-center truncate w-full`;
this._nameEl.style.color = cfg.nameColor;
this._nameEl.title = this.name;
this._nameEl.textContent = this.name;
// Always ensure it's the first child
if (this.firstChild !== this._nameEl) {
this.prepend(this._nameEl);
}
} else if (this._nameEl) {
this._nameEl.remove();
this._nameEl = null;
}
}
connectedCallback() {
super.connectedCallback();
this.applyHostStyles();
this._updateNameEl();
this.addEventListener("mouseenter", this._onMouseEnter);
this.addEventListener("mouseleave", this._onMouseLeave);
this.addEventListener("click", this._onClick);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("mouseenter", this._onMouseEnter);
this.removeEventListener("mouseleave", this._onMouseLeave);
this.removeEventListener("click", this._onClick);
}
updated() {
this.applyHostStyles();
this._updateNameEl();
}
render() {
return html`
<slot></slot>
${this.product && this.onPurchase
? html`<purchase-button
.product=${this.product}
.rarity=${this.rarity}
.onPurchase=${this.onPurchase}
></purchase-button>`
: null}
`;
}
}
+77
View File
@@ -0,0 +1,77 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateCosmetic } from "../Cosmetics";
import { translateText } from "../Utils";
const rarityColors: Record<string, string> = {
common: "text-white/60",
uncommon: "text-green-400",
rare: "text-blue-400",
epic: "text-purple-300",
legendary: "text-orange-400",
};
@customElement("cosmetic-info")
export class CosmeticInfo extends LitElement {
@property({ type: String })
artist?: string;
@property({ type: String })
rarity?: string;
@property({ type: String })
colorPalette?: string;
createRenderRoot() {
return this;
}
render() {
if (!this.artist && !this.rarity && !this.colorPalette) {
return nothing;
}
const rarityColor = rarityColors[this.rarity ?? ""] ?? "text-white/70";
return html`
<div
class="absolute -top-1 -right-1 z-10 group/artist"
@click=${(e: Event) => e.stopPropagation()}
>
<div
class="w-6 h-6 rounded-full bg-white/20 hover:bg-white/40 flex items-center justify-center cursor-help transition-colors duration-150"
>
<span class="text-xs font-bold text-white/70">?</span>
</div>
<div
class="hidden group-hover/artist:block absolute top-7 right-0 bg-zinc-800 text-white text-xs px-2.5 py-1.5 rounded shadow-lg whitespace-nowrap z-20 border border-white/10 flex flex-col gap-0.5"
>
${this.rarity
? html`<div
class="font-bold uppercase tracking-wider ${rarityColor}"
>
${translateText(`cosmetics.${this.rarity}`) || this.rarity}
</div>`
: nothing}
<div class="text-green-400 font-bold">
${translateText("cosmetics.adfree")}
</div>
${this.colorPalette
? html`<div>
${translateText("cosmetics.color_label")}
${translateCosmetic(
"territory_patterns.color_palette",
this.colorPalette,
)}
</div>`
: nothing}
${this.artist
? html`<div>
${translateText("cosmetics.artist_label")} ${this.artist}
</div>`
: nothing}
</div>
</div>
`;
}
}
+23 -40
View File
@@ -1,17 +1,11 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Product } from "../../core/CosmeticSchemas";
import { Flag } from "../../core/CosmeticSchemas";
import { translateCosmetic } from "../Cosmetics";
import "./ArtistInfo";
import "./PurchaseButton";
import "./CosmeticContainer";
import "./CosmeticInfo";
export interface FlagItem {
key: string;
name: string;
url: string;
product?: Product | null;
artist?: string;
}
export type FlagItem = Flag & { key: string };
@customElement("flag-button")
export class FlagButton extends LitElement {
@@ -35,35 +29,33 @@ export class FlagButton extends LitElement {
}
private handleClick() {
if (this.requiresPurchase) {
this.onPurchase?.();
return;
}
this.onSelect?.(this.flag.key);
}
render() {
return html`
<div
class="flex flex-col items-center justify-between gap-1 p-1.5 bg-white/5 backdrop-blur-sm border rounded-lg w-36 h-full transition-all duration-200 ${this
.selected
? "border-green-500 shadow-[0_0_15px_rgba(34,197,94,0.5)]"
: "hover:bg-white/10 hover:border-white/20 hover:shadow-xl border-white/10"}"
<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
disabled:cursor-not-allowed flex-1"
?disabled=${this.requiresPurchase}
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}
>
<artist-info .artist=${this.flag.artist}></artist-info>
<div
class="text-[10px] font-bold text-white uppercase tracking-wider mt-1 ${this
.flag.artist
? "pr-5"
: ""} text-center truncate w-full ${this.requiresPurchase
? "opacity-50"
: ""}"
title="${translateCosmetic("flags", this.flag.name)}"
>
${translateCosmetic("flags", this.flag.name)}
</div>
<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"
@@ -84,16 +76,7 @@ export class FlagButton extends LitElement {
/>
</div>
</button>
${this.requiresPurchase && this.flag.product
? html`
<purchase-button
.product=${this.flag.product}
.onPurchase=${() => this.onPurchase?.()}
></purchase-button>
`
: null}
</div>
</cosmetic-container>
`;
}
}
-86
View File
@@ -1,86 +0,0 @@
import { LitElement, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
export const FOOTER_AD_MIN_HEIGHT = 880;
const FOOTER_AD_TYPE = "standard_iab_head2";
const FOOTER_AD_CONTAINER_ID = "home-footer-ad-container";
@customElement("home-footer-ad")
export class HomeFooterAd extends LitElement {
@state() private shouldShow: boolean = false;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.style.display = "contents";
document.addEventListener("userMeResponse", this.onUserMeResponse);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener("userMeResponse", this.onUserMeResponse);
this.destroyAd();
}
private onUserMeResponse = () => {
const isDesktop = window.innerWidth >= 640;
if (
!window.adsEnabled ||
(isDesktop && window.innerHeight < FOOTER_AD_MIN_HEIGHT)
) {
return;
}
this.shouldShow = true;
this.updateComplete.then(() => {
this.loadAd();
});
};
private loadAd(): void {
if (!window.ramp) {
console.warn("Playwire RAMP not available for footer ad");
return;
}
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([
{ type: FOOTER_AD_TYPE, selectorId: FOOTER_AD_CONTAINER_ID },
]);
console.log("Footer ad loaded:", FOOTER_AD_TYPE);
} catch (e) {
console.error("Failed to add footer ad:", e);
}
});
} catch (error) {
console.error("Failed to load footer ad:", error);
}
}
private destroyAd(): void {
try {
window.ramp.destroyUnits(FOOTER_AD_TYPE);
console.log("successfully destroyed footer ad");
} catch (e) {
console.error("error destroying footer ad", e);
}
}
render() {
if (!this.shouldShow) {
return nothing;
}
return html`
<div
id="${FOOTER_AD_CONTAINER_ID}"
class="flex justify-center items-center w-full pointer-events-auto [&_*]:!m-0 [&_*]:!p-0"
style="margin: 0; padding: 0; line-height: 0;"
></div>
`;
}
}
+24 -59
View File
@@ -11,8 +11,8 @@ import { PatternDecoder } from "../../core/PatternDecoder";
import { PlayerPattern } from "../../core/Schemas";
import { translateCosmetic } from "../Cosmetics";
import { translateText } from "../Utils";
import "./ArtistInfo";
import "./PurchaseButton";
import "./CosmeticContainer";
import "./CosmeticInfo";
export const BUTTON_WIDTH = 150;
@@ -40,6 +40,10 @@ export class PatternButton extends LitElement {
}
private handleClick() {
if (this.requiresPurchase) {
this.handlePurchase();
return;
}
if (this.pattern === null) {
this.onSelect?.(null);
return;
@@ -61,57 +65,27 @@ export class PatternButton extends LitElement {
const isDefaultPattern = this.pattern === null;
return html`
<div
class="no-crazygames flex flex-col items-center justify-between gap-2 p-3 bg-white/5 backdrop-blur-sm border rounded-xl w-48 h-full transition-all duration-200 ${this
.selected
? "border-green-500 shadow-[0_0_15px_rgba(34,197,94,0.5)]"
: "hover:bg-white/10 hover:border-white/20 hover:shadow-xl border-white/10"}"
<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
disabled:cursor-not-allowed flex-1"
?disabled=${this.requiresPurchase}
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}
>
<artist-info .artist=${this.pattern?.artist}></artist-info>
<div class="flex flex-col items-center w-full">
<div
class="text-xs font-bold text-white uppercase tracking-wider mb-1 ${this
.pattern?.artist
? "pr-5"
: ""} text-center truncate w-full ${this.requiresPurchase
? "opacity-50"
: ""}"
title="${isDefaultPattern
? translateText("territory_patterns.pattern.default")
: translateCosmetic(
"territory_patterns.pattern",
this.pattern!.name,
)}"
>
${isDefaultPattern
? translateText("territory_patterns.pattern.default")
: translateCosmetic(
"territory_patterns.pattern",
this.pattern!.name,
)}
</div>
${this.colorPalette !== null
? html`
<div
class="text-[10px] font-bold text-white/40 uppercase tracking-widest mb-2 text-center truncate w-full ${this
.requiresPurchase
? "opacity-50"
: ""}"
>
${translateCosmetic(
"territory_patterns.color_palette",
this.colorPalette!.name,
)}
</div>
`
: html`<div class="h-[22px] mb-2 w-full"></div>`}
</div>
<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"
@@ -129,16 +103,7 @@ export class PatternButton extends LitElement {
)}
</div>
</button>
${this.requiresPurchase && this.pattern?.product
? html`
<purchase-button
.product=${this.pattern.product}
.onPurchase=${() => this.handlePurchase()}
></purchase-button>
`
: null}
</div>
</cosmetic-container>
`;
}
}
+173 -5
View File
@@ -3,11 +3,156 @@ import { customElement, property } from "lit/decorators.js";
import { Product } from "../../core/CosmeticSchemas";
import { translateText } from "../Utils";
const PURCHASE_STYLE_ID = "purchase-button-styles";
if (!document.getElementById(PURCHASE_STYLE_ID)) {
const style = document.createElement("style");
style.id = PURCHASE_STYLE_ID;
style.textContent = `
@keyframes purchase-streak {
0% { left: -60%; opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { left: 160%; opacity: 0; }
}
.purchase-sparkle-streak {
pointer-events: none;
position: absolute;
top: 0;
left: -60%;
width: 40%;
height: 100%;
background: linear-gradient(90deg, transparent 0%, rgba(134,239,172,0.5) 50%, transparent 100%);
transform: skewX(-15deg);
opacity: 0;
}
cosmetic-container:hover .purchase-sparkle-streak {
animation: purchase-streak 0.7s ease-in-out;
}
cosmetic-container:hover .purchase-sparkle-btn {
background: rgb(34,197,94);
border-color: rgb(74,222,128);
color: white;
box-shadow: 0 0 20px rgba(74,222,128,0.6);
}
@keyframes purchase-pulse {
0% { box-shadow: 0 0 15px rgba(74,222,128,0.6), 0 0 30px rgba(34,197,94,0.3); }
50% { box-shadow: 0 0 25px rgba(74,222,128,0.9), 0 0 50px rgba(34,197,94,0.5); }
100% { box-shadow: 0 0 15px rgba(74,222,128,0.6), 0 0 30px rgba(34,197,94,0.3); }
}
.purchase-sparkle-btn:hover {
background: rgb(22,163,74) !important;
border-color: rgb(74,222,128) !important;
color: white !important;
animation: purchase-pulse 1.2s ease-in-out infinite !important;
}
@keyframes purchase-ember-0 {
0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; }
100% { transform: translateY(-35px) translateX(5px) scale(0.2); opacity: 0; }
}
@keyframes purchase-ember-1 {
0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; }
100% { transform: translateY(-30px) translateX(-6px) scale(0.3); opacity: 0; }
}
@keyframes purchase-ember-2 {
0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; }
100% { transform: translateY(-40px) translateX(3px) scale(0.2); opacity: 0; }
}
@keyframes purchase-ember-3 {
0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; }
100% { transform: translateY(-28px) translateX(-4px) scale(0.3); opacity: 0; }
}
.purchase-ember {
pointer-events: none;
position: absolute;
top: 0;
width: 3px;
height: 3px;
border-radius: 50%;
background: rgba(74,222,128,0.9);
box-shadow: 0 0 4px rgba(74,222,128,0.8);
opacity: 0;
display: none;
}
.purchase-ember-0 { left: 20%; animation: purchase-ember-0 1.2s ease-out infinite; }
.purchase-ember-1 { left: 40%; animation: purchase-ember-1 1.5s ease-out infinite 0.25s; }
.purchase-ember-2 { left: 60%; animation: purchase-ember-2 1.3s ease-out infinite 0.5s; }
.purchase-ember-3 { left: 80%; animation: purchase-ember-3 1.6s ease-out infinite 0.15s; }
cosmetic-container:hover .purchase-ember {
display: block;
}
@keyframes purchase-burst-a { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-70px) translateX(14px) scale(0); opacity:0; } }
@keyframes purchase-burst-b { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-60px) translateX(-12px) scale(0); opacity:0; } }
@keyframes purchase-burst-c { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-80px) translateX(8px) scale(0); opacity:0; } }
@keyframes purchase-burst-d { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-55px) translateX(-16px) scale(0); opacity:0; } }
@keyframes purchase-burst-e { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-75px) translateX(18px) scale(0); opacity:0; } }
@keyframes purchase-burst-f { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-65px) translateX(-6px) scale(0); opacity:0; } }
.purchase-burst {
pointer-events: none;
position: absolute;
top: 0;
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(74,222,128,1);
box-shadow: 0 0 6px rgba(74,222,128,0.9), 0 0 2px rgba(255,255,255,0.5);
opacity: 0;
display: none;
}
.purchase-burst-0 { left: 3%; animation: purchase-burst-a 0.9s ease-out infinite 0.00s; }
.purchase-burst-1 { left: 8%; animation: purchase-burst-d 1.1s ease-out infinite 0.73s; }
.purchase-burst-2 { left: 12%; animation: purchase-burst-c 0.95s ease-out infinite 0.41s; }
.purchase-burst-3 { left: 16%; animation: purchase-burst-f 1.05s ease-out infinite 0.17s; }
.purchase-burst-4 { left: 20%; animation: purchase-burst-b 0.85s ease-out infinite 0.89s; }
.purchase-burst-5 { left: 24%; animation: purchase-burst-e 1.0s ease-out infinite 0.53s; }
.purchase-burst-6 { left: 28%; animation: purchase-burst-a 1.1s ease-out infinite 0.29s; }
.purchase-burst-7 { left: 32%; animation: purchase-burst-c 0.9s ease-out infinite 0.97s; }
.purchase-burst-8 { left: 36%; animation: purchase-burst-f 1.05s ease-out infinite 0.61s; }
.purchase-burst-9 { left: 40%; animation: purchase-burst-d 0.95s ease-out infinite 0.07s; }
.purchase-burst-10 { left: 44%; animation: purchase-burst-b 1.0s ease-out infinite 0.83s; }
.purchase-burst-11 { left: 48%; animation: purchase-burst-e 0.85s ease-out infinite 0.37s; }
.purchase-burst-12 { left: 52%; animation: purchase-burst-a 1.1s ease-out infinite 0.67s; }
.purchase-burst-13 { left: 56%; animation: purchase-burst-f 0.9s ease-out infinite 0.11s; }
.purchase-burst-14 { left: 60%; animation: purchase-burst-c 1.05s ease-out infinite 0.79s; }
.purchase-burst-15 { left: 64%; animation: purchase-burst-d 0.95s ease-out infinite 0.47s; }
.purchase-burst-16 { left: 68%; animation: purchase-burst-b 1.0s ease-out infinite 0.23s; }
.purchase-burst-17 { left: 72%; animation: purchase-burst-e 0.85s ease-out infinite 1.03s; }
.purchase-burst-18 { left: 76%; animation: purchase-burst-a 1.1s ease-out infinite 0.57s; }
.purchase-burst-19 { left: 80%; animation: purchase-burst-f 0.95s ease-out infinite 0.31s; }
.purchase-burst-20 { left: 6%; animation: purchase-burst-b 0.92s ease-out infinite 0.15s; }
.purchase-burst-21 { left: 14%; animation: purchase-burst-e 1.08s ease-out infinite 0.86s; }
.purchase-burst-22 { left: 22%; animation: purchase-burst-a 0.88s ease-out infinite 0.44s; }
.purchase-burst-23 { left: 30%; animation: purchase-burst-d 1.02s ease-out infinite 0.71s; }
.purchase-burst-24 { left: 38%; animation: purchase-burst-f 0.93s ease-out infinite 0.03s; }
.purchase-burst-25 { left: 46%; animation: purchase-burst-c 1.07s ease-out infinite 0.59s; }
.purchase-burst-26 { left: 54%; animation: purchase-burst-b 0.87s ease-out infinite 0.92s; }
.purchase-burst-27 { left: 62%; animation: purchase-burst-e 0.98s ease-out infinite 0.26s; }
.purchase-burst-28 { left: 70%; animation: purchase-burst-a 1.12s ease-out infinite 0.64s; }
.purchase-burst-29 { left: 78%; animation: purchase-burst-d 0.91s ease-out infinite 0.38s; }
.purchase-burst-30 { left: 84%; animation: purchase-burst-c 1.03s ease-out infinite 0.77s; }
.purchase-burst-31 { left: 88%; animation: purchase-burst-f 0.86s ease-out infinite 0.09s; }
.purchase-burst-32 { left: 92%; animation: purchase-burst-b 1.06s ease-out infinite 0.52s; }
.purchase-burst-33 { left: 96%; animation: purchase-burst-e 0.94s ease-out infinite 0.81s; }
.purchase-burst-34 { left: 10%; animation: purchase-burst-d 0.89s ease-out infinite 0.34s; }
.purchase-burst-35 { left: 26%; animation: purchase-burst-a 1.04s ease-out infinite 0.96s; }
.purchase-burst-36 { left: 42%; animation: purchase-burst-f 0.91s ease-out infinite 0.19s; }
.purchase-burst-37 { left: 58%; animation: purchase-burst-c 1.09s ease-out infinite 0.69s; }
.purchase-burst-38 { left: 74%; animation: purchase-burst-b 0.87s ease-out infinite 0.46s; }
.purchase-burst-39 { left: 90%; animation: purchase-burst-e 1.01s ease-out infinite 0.13s; }
.purchase-btn-wrap:hover .purchase-burst {
display: block;
}
`;
document.head.appendChild(style);
}
@customElement("purchase-button")
export class PurchaseButton extends LitElement {
@property({ type: Object })
product!: Product;
@property({ type: String })
rarity: string = "common";
@property({ type: Function })
onPurchase?: () => void;
@@ -17,19 +162,42 @@ export class PurchaseButton extends LitElement {
private handleClick(e: Event) {
e.stopPropagation();
this.onPurchase?.();
const container = this.closest("cosmetic-container") as HTMLElement | null;
if (container && !container.querySelector(".cosmetic-loading-overlay")) {
const overlay = document.createElement("div");
overlay.className = "cosmetic-loading-overlay";
overlay.innerHTML = `<div class="cosmetic-loading-spinner"></div>`;
container.appendChild(overlay);
}
Promise.resolve(this.onPurchase?.()).catch(() => {
container?.querySelector(".cosmetic-loading-overlay")?.remove();
});
}
render() {
return html`
<div class="no-crazygames w-full mt-2">
<div class="no-crazygames w-full mt-2 relative purchase-btn-wrap">
${this.rarity !== "common"
? html`<span class="purchase-ember purchase-ember-0"></span>
<span class="purchase-ember purchase-ember-1"></span>
<span class="purchase-ember purchase-ember-2"></span>
<span class="purchase-ember purchase-ember-3"></span>
${Array.from(
{ length: 40 },
(_, i) =>
html`<span
class="purchase-burst purchase-burst-${i}"
></span>`,
)}`
: null}
<button
class="w-full px-4 py-2 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
hover:bg-green-500/30 hover:shadow-[0_0_15px_rgba(74,222,128,0.2)]"
class="purchase-sparkle-btn relative overflow-hidden w-full px-4 py-2 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
hover:bg-green-500 hover:border-green-400 hover:text-white hover:shadow-[0_0_20px_rgba(74,222,128,0.6)]"
@click=${this.handleClick}
>
<span class="purchase-sparkle-streak"></span>
${translateText("territory_patterns.purchase")}
<span class="ml-1 text-white/60">(${this.product.price})</span>
<span class="ml-1 text-white/50">(${this.product.price})</span>
</button>
</div>
`;
+11 -9
View File
@@ -51,8 +51,17 @@ export const ColorPaletteSchema = z.object({
secondaryColor: z.string(),
});
export const PatternSchema = z.object({
const CosmeticSchema = z.object({
name: CosmeticNameSchema,
affiliateCode: z.string().nullable(),
product: ProductSchema.nullable(),
artist: z.string().optional(),
rarity: z
.enum(["common", "uncommon", "rare", "epic", "legendary"])
.or(z.string()),
});
export const PatternSchema = CosmeticSchema.extend({
pattern: PatternDataSchema,
colorPalettes: z
.object({
@@ -61,17 +70,10 @@ export const PatternSchema = z.object({
})
.array()
.optional(),
affiliateCode: z.string().nullable(),
product: ProductSchema.nullable(),
artist: z.string().optional(),
});
export const FlagSchema = z.object({
name: CosmeticNameSchema,
export const FlagSchema = CosmeticSchema.extend({
url: z.string(),
affiliateCode: z.string().nullable(),
product: ProductSchema.nullable(),
artist: z.string().optional(),
});
// Schema for resources/cosmetics/cosmetics.json
+50 -17
View File
@@ -6,7 +6,6 @@ import {
pattern,
resolveConfusablesTransformer,
resolveLeetSpeakTransformer,
skipNonAlphabeticTransformer,
toAsciiLowerCaseTransformer,
} from "obscenity";
import countries from "resources/countries.json";
@@ -47,31 +46,52 @@ export const shadowNames = [
"AlmostPottyTrained",
];
export function createMatcher(bannedWords: string[]): RegExpMatcher {
const customDataset = new DataSet<{ originalWord: string }>().addAll(
function buildDataset(bannedWords: string[], dedup: boolean) {
const dataset = new DataSet<{ originalWord: string }>().addAll(
englishDataset,
);
for (const word of bannedWords) {
try {
customDataset.addPhrase((phrase) =>
phrase.setMetadata({ originalWord: word }).addPattern(pattern`${word}`),
const w = dedup ? word.toLowerCase().replace(/(.)\1+/g, "$1") : word;
dataset.addPhrase((phrase) =>
phrase.setMetadata({ originalWord: word }).addPattern(pattern`${w}`),
);
} catch (e) {
console.error(`Invalid banned word pattern "${word}": ${e}`);
}
}
return dataset.build();
}
return new RegExpMatcher({
...customDataset.build(),
export function createMatcher(bannedWords: string[]): RegExpMatcher {
const baseTransformers = [
toAsciiLowerCaseTransformer(),
resolveConfusablesTransformer(),
resolveLeetSpeakTransformer(),
];
// substringMatcher: literal patterns, no collapse — catches "niggertesting" as a substring
// collapseMatcher: deduped patterns + collapse transformer — catches "niiiigger", "hiiitler"
const substringMatcher = new RegExpMatcher({
...buildDataset(bannedWords, false),
blacklistMatcherTransformers: baseTransformers,
});
const collapseMatcher = new RegExpMatcher({
...buildDataset(bannedWords, true),
blacklistMatcherTransformers: [
toAsciiLowerCaseTransformer(),
resolveConfusablesTransformer(),
resolveLeetSpeakTransformer(),
...baseTransformers,
collapseDuplicatesTransformer(),
skipNonAlphabeticTransformer(),
],
});
return {
hasMatch: (input: string) =>
input.toLowerCase().includes("kkk") ||
substringMatcher.hasMatch(input) ||
collapseMatcher.hasMatch(input),
getAllMatches: (input: string, sorted?: boolean) => [
...substringMatcher.getAllMatches(input, sorted),
...collapseMatcher.getAllMatches(input, sorted),
],
} as unknown as RegExpMatcher;
}
/**
@@ -100,9 +120,19 @@ function censorWithMatcher(
? shadowNames[simpleHash(username) % shadowNames.length]
: username;
const clanTagIsProfane = clanTag ? matcher.hasMatch(clanTag) : false;
const censoredClanTag =
clanTag && !clanTagIsProfane ? clanTag.toUpperCase() : null;
const clanTagIsProfane = clanTag
? matcher.hasMatch(clanTag) || clanTag.toLowerCase() === "ss"
: false;
const usernameIsProfane = matcher.hasMatch(nameWithoutClan);
const censoredName = usernameIsProfane
? shadowNames[simpleHash(nameWithoutClan) % shadowNames.length]
: nameWithoutClan;
// Restore clan tag only if it's clean, otherwise remove it entirely
if (clanTag && !clanTagIsProfane) {
return `[${clanTag.toUpperCase()}] ${censoredName}`;
}
return { username: censoredName, clanTag: censoredClanTag };
}
@@ -242,8 +272,11 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
}
}
// Default matcher with no custom banned words (just englishDataset)
const defaultMatcher = createMatcher([]);
// Words the englishDataset misses or only catches as standalone tokens.
// These are always enforced even when the remote banned-words list is unavailable.
const baselineBannedWords = ["nigger", "nigga", "chink", "spic", "kike"];
const defaultMatcher = createMatcher(baselineBannedWords);
export class FailOpenPrivilegeChecker implements PrivilegeChecker {
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
+117 -37
View File
@@ -12,6 +12,13 @@ const bannedWords = [
"auschwitz",
"whitepower",
"heil",
"nigger",
"nigga",
"chink",
"spic",
"kike",
"faggot",
"retard",
"chair", // Test word to verify custom banned words work
];
@@ -36,6 +43,7 @@ const flagCosmetics = {
url: "https://example.com/cool.png",
affiliateCode: null,
product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
rarity: "common",
},
},
};
@@ -51,45 +59,84 @@ describe("UsernameCensor", () => {
expect(matcher.hasMatch("hitler")).toBe(true);
expect(matcher.hasMatch("nazi")).toBe(true);
expect(matcher.hasMatch("auschwitz")).toBe(true);
});
test("detects custom banned words like 'chair'", () => {
expect(matcher.hasMatch("chair")).toBe(true);
expect(matcher.hasMatch("Chair")).toBe(true);
expect(matcher.hasMatch("CHAIR")).toBe(true);
expect(matcher.hasMatch("MyChairName")).toBe(true);
expect(matcher.hasMatch("nigger")).toBe(true);
expect(matcher.hasMatch("nigga")).toBe(true);
expect(matcher.hasMatch("chink")).toBe(true);
expect(matcher.hasMatch("spic")).toBe(true);
expect(matcher.hasMatch("kike")).toBe(true);
expect(matcher.hasMatch("faggot")).toBe(true);
expect(matcher.hasMatch("retard")).toBe(true);
});
test("detects banned words case-insensitively", () => {
expect(matcher.hasMatch("Hitler")).toBe(true);
expect(matcher.hasMatch("NAZI")).toBe(true);
expect(matcher.hasMatch("Adolf")).toBe(true);
expect(matcher.hasMatch("NIGGER")).toBe(true);
expect(matcher.hasMatch("Nigga")).toBe(true);
expect(matcher.hasMatch("FAGGOT")).toBe(true);
expect(matcher.hasMatch("Retard")).toBe(true);
});
test("detects banned words with leet speak", () => {
expect(matcher.hasMatch("h1tl3r")).toBe(true);
expect(matcher.hasMatch("4d0lf")).toBe(true);
expect(matcher.hasMatch("n4z1")).toBe(true);
expect(matcher.hasMatch("n1gg3r")).toBe(true);
expect(matcher.hasMatch("f4gg0t")).toBe(true);
expect(matcher.hasMatch("r3t4rd")).toBe(true);
});
test("detects banned words with duplicated characters", () => {
expect(matcher.hasMatch("hiiitler")).toBe(true);
expect(matcher.hasMatch("naazzii")).toBe(true);
expect(matcher.hasMatch("niiiigger")).toBe(true);
expect(matcher.hasMatch("faaggot")).toBe(true);
});
test("detects banned words with accented characters", () => {
test("detects banned words with accented/confusable characters", () => {
expect(matcher.hasMatch("Adölf")).toBe(true);
expect(matcher.hasMatch("nïgger")).toBe(true);
});
test("detects banned words as substrings", () => {
expect(matcher.hasMatch("xhitlerx")).toBe(true);
expect(matcher.hasMatch("IloveNazi")).toBe(true);
// Regression: slur + suffix / prefix must be caught
expect(matcher.hasMatch("niggertesting")).toBe(true);
expect(matcher.hasMatch("testingnigger")).toBe(true);
expect(matcher.hasMatch("xnazix")).toBe(true);
expect(matcher.hasMatch("faggotry")).toBe(true);
expect(matcher.hasMatch("retarded")).toBe(true);
expect(matcher.hasMatch("MyChairName")).toBe(true);
});
test("detects banned words with underscores/dots/numbers mixed in", () => {
// These should NOT bypass the filter (skipNonAlphabetic was intentionally removed)
// Words separated by non-alpha chars are treated as separate tokens
expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(false); // dots break the word
expect(matcher.hasMatch("hi_tler")).toBe(false); // underscore breaks it
});
test("allows clean usernames", () => {
expect(matcher.hasMatch("CoolPlayer")).toBe(false);
expect(matcher.hasMatch("GameMaster")).toBe(false);
expect(matcher.hasMatch("xXx_Sniper_xXx")).toBe(false);
expect(matcher.hasMatch("ProGamer123")).toBe(false);
expect(matcher.hasMatch("NightOwl")).toBe(false);
expect(matcher.hasMatch("DragonSlayer")).toBe(false);
});
test("does not false-positive on words containing banned substrings legitimately", () => {
// "snigger" is whitelisted in englishDataset
expect(matcher.hasMatch("snigger")).toBe(false);
});
test("catches kkk as substring", () => {
expect(matcher.hasMatch("kkk")).toBe(true);
expect(matcher.hasMatch("KKK")).toBe(true);
expect(matcher.hasMatch("kkklover")).toBe(true);
expect(matcher.hasMatch("ilovekkkboys")).toBe(true);
});
});
@@ -115,40 +162,73 @@ describe("UsernameCensor", () => {
expect(shadowNames).toContain(result.username);
});
test("removes profane clan tag but keeps clean username", () => {
const result = checker.censor("CoolPlayer", "NAZI");
expect(result.username).toBe("CoolPlayer");
expect(result.clanTag).toBeNull();
});
describe("clan tag censoring", () => {
test("removes profane clan tag, keeps clean username", () => {
expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer");
expect(checker.censorUsername("[ADOLF]CoolPlayer")).toBe("CoolPlayer");
expect(checker.censorUsername("[HEIL]CoolPlayer")).toBe("CoolPlayer");
});
test("removes clan tag with leet speak profanity", () => {
const result = checker.censor("CoolPlayer", "N4Z1");
expect(result.username).toBe("CoolPlayer");
expect(result.clanTag).toBeNull();
});
test("removes clan tag that is a slur abbreviation", () => {
// [NIG] is caught as a standalone word by englishDataset's |nig| pattern
expect(checker.censorUsername("[NIG]CoolPlayer")).toBe("CoolPlayer");
expect(checker.censorUsername("[NIGG]CoolPlayer")).toBe("CoolPlayer");
});
test("removes clan tag with uppercased banned word", () => {
const result = checker.censor("CoolPlayer", "ADOLF");
expect(result.username).toBe("CoolPlayer");
expect(result.clanTag).toBeNull();
});
test("removes clan tag containing full slur (≤5 chars)", () => {
// Clan tags are capped at 5 chars — only slurs that fit are catchable this way
expect(checker.censorUsername("[NIGGA]CoolPlayer")).toBe("CoolPlayer");
expect(checker.censorUsername("[CHINK]CoolPlayer")).toBe("CoolPlayer");
expect(checker.censorUsername("[SPIC]CoolPlayer")).toBe("CoolPlayer");
expect(checker.censorUsername("[KIKE]CoolPlayer")).toBe("CoolPlayer");
});
test("removes clan tag containing banned word substring", () => {
const result = checker.censor("CoolPlayer", "JEWS");
expect(result.username).toBe("CoolPlayer");
expect(result.clanTag).toBeNull();
});
test("removes clan tag with leet speak profanity (≤5 chars)", () => {
expect(checker.censorUsername("[N4Z1]CoolPlayer")).toBe("CoolPlayer");
});
test("removes profane clan tag and censors profane username", () => {
const result = checker.censor("hitler", "NAZI");
expect(result.clanTag).toBeNull();
expect(shadowNames).toContain(result.username);
});
test("removes clan tag containing banned word as substring (≤5 chars)", () => {
expect(checker.censorUsername("[JEWS]CoolPlayer")).toBe("CoolPlayer");
expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer");
});
test("removes leet speak profane clan tag and censors leet speak username", () => {
const result = checker.censor("h1tl3r", "N4Z1");
expect(result.clanTag).toBeNull();
expect(shadowNames).toContain(result.username);
test("removes [SS] clan tag", () => {
expect(checker.censorUsername("[SS]Player")).toBe("Player");
expect(checker.censorUsername("[ss]Player")).toBe("Player");
});
test("removes [KKK] clan tag", () => {
expect(checker.censorUsername("[KKK]Player")).toBe("Player");
});
test("keeps clean clan tag when username is clean", () => {
expect(checker.censorUsername("[COOL]Player")).toBe("[COOL] Player");
expect(checker.censorUsername("[PRO]Player")).toBe("[PRO] Player");
});
test("keeps clean clan tag, censors profane username", () => {
const result = checker.censorUsername("[COOL]nigger");
expect(result).toMatch(/^\[COOL\] /);
expect(shadowNames).toContain(result.replace("[COOL] ", ""));
});
test("removes profane clan tag and censors profane username", () => {
const result = checker.censorUsername("[NAZI]hitler");
expect(shadowNames).toContain(result);
expect(result).not.toContain("[");
});
test("removes profane clan tag and censors leet speak username", () => {
const result = checker.censorUsername("[N4Z1]h1tl3r");
expect(shadowNames).toContain(result);
expect(result).not.toContain("[");
});
test("removes profane clan tag with slur, censors profane username", () => {
const result = checker.censorUsername("[NIG]nigger");
expect(shadowNames).toContain(result);
expect(result).not.toContain("[");
});
});
test("returns deterministic shadow name for same input", () => {