This commit is contained in:
evanpelle
2026-02-14 13:45:06 -08:00
parent 8e889fe857
commit 70b5d085ba
45 changed files with 1055 additions and 724 deletions
+34 -9
View File
@@ -122,12 +122,29 @@
<body
class="h-full select-none font-sans min-h-screen bg-cover bg-center bg-fixed transition-opacity duration-300 ease-in-out flex flex-row overflow-hidden"
>
<div
class="fixed inset-0 w-full h-full -z-50 bg-cover bg-center bg-fixed pointer-events-none brightness-[0.5]"
style="
background-image: url(&quot;/images/EuropeBackgroundBlurred.webp&quot;);
"
></div>
<div id="hex-grid" class="fixed inset-0 -z-50 pointer-events-none">
<div
id="background-layer"
class="absolute inset-0 bg-cover bg-center opacity-50 [filter:brightness(1.0)] dark:[filter:sepia(0.2)_saturate(1.2)_hue-rotate(180deg)_brightness(0.9)]"
style="
background-image: url(&quot;/resources/images/background.png&quot;);
"
></div>
<div
class="absolute inset-0 bg-center bg-no-repeat bg-contain hidden lg:block"
style="
background-image: url(&quot;/resources/images/OpenFront.png&quot;);
opacity: 0.25;
"
></div>
<div
class="absolute inset-0 bg-center bg-no-repeat bg-contain lg:hidden"
style="
background-image: url(&quot;/resources/images/OF.png&quot;);
opacity: 0.25;
"
></div>
</div>
<!-- LEFT SIDEBAR MENU -->
@@ -140,10 +157,9 @@
<mobile-nav-bar
id="sidebar-menu"
class="peer in-[.in-game]:hidden z-40001 fixed left-0 top-0 h-full flex flex-col justify-start overflow-visible bg-black/60 backdrop-blur-md transition-transform duration-500 ease-out transform -translate-x-full w-[80%] [&.open]:translate-x-0 lg:hidden"
class="peer [.in-game_&]:hidden z-[40001] fixed left-0 top-0 h-full flex flex-col justify-start overflow-visible bg-black/70 backdrop-blur-xl border-r border-white/10 transition-transform duration-500 ease-out transform -translate-x-full w-[70%] [&.open]:translate-x-0 lg:hidden"
role="dialog"
data-i18n-aria-label="main.menu"
aria-hidden="true"
></mobile-nav-bar>
<!-- MAIN CONTENT AREA -->
@@ -163,7 +179,11 @@
<!-- Main container with responsive padding -->
<main-layout class="contents">
<play-page class="contents"></play-page>
<matchmaking-modal
id="page-matchmaking"
inline
class="hidden w-full h-full page-content"
></matchmaking-modal>
<news-modal
id="page-news"
inline
@@ -225,6 +245,11 @@
inline
class="hidden w-full h-full page-content"
></flag-input-modal>
<ranked-modal
id="page-ranked"
inline
class="hidden w-full h-full page-content"
></ranked-modal>
</main-layout>
<!-- Desktop Footer -->
-21
View File
@@ -1232,7 +1232,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -1276,7 +1275,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2210,7 +2208,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -4604,7 +4601,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.32.tgz",
"integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -4770,7 +4766,6 @@
"integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/types": "8.34.1",
@@ -5250,7 +5245,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5650,7 +5644,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001718",
"electron-to-chromium": "^1.5.160",
@@ -5804,7 +5797,6 @@
"integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
@@ -6726,7 +6718,6 @@
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"dev": true,
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -7296,7 +7287,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8627,7 +8617,6 @@
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.28",
"@asamuzakjp/dom-selector": "^6.7.6",
@@ -10223,7 +10212,6 @@
"integrity": "sha512-dyuThzncsgEgJZnvd/A/5x6IkUERbK+phXqUQrI+0C6WE+8xqGH5VChRTLecemhgZF0kQ+gZOM3tJTX9937xpg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@pixi/colord": "^2.9.6",
"@types/css-font-loading-module": "^0.0.12",
@@ -10268,7 +10256,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -10371,7 +10358,6 @@
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -11194,7 +11180,6 @@
"integrity": "sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@sinonjs/commons": "^3.0.1",
"@sinonjs/fake-timers": "^15.1.0",
@@ -11616,7 +11601,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11859,7 +11843,6 @@
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz",
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -11928,7 +11911,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12075,7 +12057,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -12825,7 +12806,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -12839,7 +12819,6 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

+12 -7
View File
@@ -355,7 +355,6 @@
},
"public_lobby": {
"title": "Waiting for Game Start...",
"join": "Join next Game",
"teams_Duos": "{team_count} teams of 2 (Duos)",
"teams_Trios": "{team_count} teams of 3 (Trios)",
"teams_Quads": "{team_count} teams of 4 (Quads)",
@@ -376,7 +375,8 @@
"connecting": "Connecting to matchmaking server...",
"searching": "Searching for game...",
"waiting_for_game": "Waiting for game to start...",
"elo": "Your ELO: {elo}"
"elo": "Your ELO: {elo}",
"no_elo": "No ELO yet"
},
"username": {
"enter_username": "Enter your username",
@@ -390,7 +390,6 @@
},
"host_modal": {
"title": "Create Private Lobby",
"label": "Private",
"mode": "Mode",
"team_count": "Number of Teams",
"team_type": "Team Type",
@@ -453,6 +452,16 @@
"ffa": "Free for All",
"teams": "Teams"
},
"mode_selector": {
"special_title": "Special Mix",
"teams_title": "Teams",
"teams_count": "{teamCount} teams",
"teams_of": "{teamCount} teams of {playersPerTeam}",
"ranked_title": "Ranked",
"ranked_1v1_title": "1v1",
"ranked_2v2_title": "2v2",
"coming_soon": "Coming Soon"
},
"public_game_modifier": {
"random_spawn": "Random Spawn",
"compact_map": "Compact Map",
@@ -928,8 +937,6 @@
"recent_games": "Recent Games",
"game_id": "Game ID",
"mode": "Mode",
"mode_ffa": "Free-for-All",
"mode_team": "Team",
"replay": "Replay",
"details": "Details",
"ranking": "Ranking",
@@ -946,8 +953,6 @@
"stats_losses": "Losses",
"stats_wlr": "Win:Loss Ratio",
"stats_games_played": "Games Played",
"mode_ffa": "Free-for-All",
"mode_team": "Team",
"no_stats": "No stats recorded for this selection."
},
"matchmaking_button": {
+4 -15
View File
@@ -61,18 +61,9 @@ export class AccountModal extends BaseModal {
render() {
const content = this.isLoadingUser
? html`
<div
class="flex flex-col items-center justify-center p-12 text-white bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 h-full min-h-[400px]"
>
<div
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mb-4"
></div>
<p class="text-white/60 font-medium tracking-wide animate-pulse">
${translateText("account_modal.fetching_account")}
</p>
</div>
`
? this.renderLoadingSpinner(
translateText("account_modal.fetching_account"),
)
: this.renderInner();
if (this.inline) {
@@ -99,9 +90,7 @@ export class AccountModal extends BaseModal {
const displayId = publicId || translateText("account_modal.not_found");
return html`
<div
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
>
<div class="${this.modalContainerClass}">
${modalHeader({
title,
onBack: () => this.close(),
+2 -2
View File
@@ -84,7 +84,7 @@ export class FlagInput extends LitElement {
return html`
<button
id="flag-input"
class="flag-btn 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-slate-900/80 hover:bg-slate-800/80 active:bg-slate-800/90 rounded-lg overflow-hidden"
class="flag-btn 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-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] hover:brightness-[1.08] active:brightness-[0.95] rounded-lg overflow-hidden"
title=${buttonTitle}
@click=${this.onInputClick}
>
@@ -94,7 +94,7 @@ export class FlagInput extends LitElement {
></span>
${showSelect
? html`<span
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full text-center px-1"
class="text-[10px] font-black text-white uppercase leading-none break-words w-full text-center px-1"
>
${translateText("flag_input.title")}
</span>`
+1 -3
View File
@@ -18,9 +18,7 @@ export class FlagInputModal extends BaseModal {
render() {
const content = html`
<div
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
>
<div class="${this.modalContainerClass}">
<div
class="relative flex flex-col border-b border-white/10 pb-4 shrink-0"
>
+2 -6
View File
@@ -99,14 +99,10 @@ export class HelpModal extends BaseModal {
const keybinds = this.keybinds;
const content = html`
<div
class="h-full flex flex-col ${this.inline
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
: ""}"
>
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("main.help"),
onBack: this.close,
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
+4 -24
View File
@@ -78,8 +78,6 @@ export class HostLobbyModal extends BaseModal {
@state() private nationCount: number = 0;
@property({ attribute: false }) eventBus: EventBus | null = null;
private playersInterval: NodeJS.Timeout | null = null;
// Add a new timer for debouncing bot changes
private botsUpdateTimer: number | null = null;
private mapLoader = terrainMapFileLoader;
@@ -88,6 +86,9 @@ export class HostLobbyModal extends BaseModal {
private readonly handleLobbyInfo = (event: LobbyInfoEvent) => {
const lobby = event.lobby;
if (!this.lobbyId || lobby.gameID !== this.lobbyId) {
return;
}
this.lobbyCreatorClientID = lobby.lobbyCreatorClientID ?? "";
if (lobby.clients) {
this.clients = lobby.clients;
@@ -209,9 +210,7 @@ export class HostLobbyModal extends BaseModal {
];
const content = html`
<div
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
>
<div class="${this.modalContainerClass}">
<!-- Header -->
${modalHeader({
title: translateText("host_modal.title"),
@@ -391,7 +390,6 @@ export class HostLobbyModal extends BaseModal {
this.close();
};
}
this.playersInterval = setInterval(() => this.pollPlayers(), 1000);
this.loadNationCount();
}
@@ -418,10 +416,6 @@ export class HostLobbyModal extends BaseModal {
crazyGamesSDK.hideInviteButton();
// Clean up timers and resources
if (this.playersInterval) {
clearInterval(this.playersInterval);
this.playersInterval = null;
}
if (this.botsUpdateTimer !== null) {
clearTimeout(this.botsUpdateTimer);
this.botsUpdateTimer = null;
@@ -811,20 +805,6 @@ export class HostLobbyModal extends BaseModal {
return response;
}
private async pollPlayers() {
const config = await getServerConfigFromClient();
fetch(`/${config.workerPath(this.lobbyId)}/api/game/${this.lobbyId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
.then((response) => response.json())
.then((data: GameInfo) => {
this.clients = data.clients ?? [];
});
}
private kickPlayer(clientID: string) {
// Dispatch event to be handled by WebSocket instead of HTTP
this.dispatchEvent(
+9 -36
View File
@@ -16,6 +16,7 @@ import {
GameInfo,
GameRecordSchema,
LobbyInfoEvent,
PublicGameInfo,
} from "../core/Schemas";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import {
@@ -96,9 +97,7 @@ export class JoinLobbyModal extends BaseModal {
? (this.lobbyCreatorClientID ?? "")
: "";
const content = html`
<div
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
>
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("public_lobby.title"),
onBack: () => this.closeAndLeave(),
@@ -149,7 +148,7 @@ export class JoinLobbyModal extends BaseModal {
${this.isPrivateLobby()
? html`
<div
class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0"
class="p-6 lg:p-6 border-t border-white/10 bg-black/20 shrink-0"
>
<button
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-blue-900/20 hover:shadow-blue-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
@@ -161,7 +160,7 @@ export class JoinLobbyModal extends BaseModal {
`
: html`
<div
class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0"
class="p-6 lg:p-6 border-t border-white/10 bg-black/20 shrink-0"
>
<div
class="w-full px-4 py-3 rounded-xl border border-white/10 bg-white/5 flex items-center justify-between gap-3"
@@ -216,9 +215,7 @@ export class JoinLobbyModal extends BaseModal {
private renderJoinForm() {
const content = html`
<div
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
>
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("private_lobby.title"),
onBack: () => this.closeAndLeave(),
@@ -280,15 +277,12 @@ export class JoinLobbyModal extends BaseModal {
`;
}
public open(lobbyId: string = "", isPublic: boolean = false) {
public open(lobbyId: string = "", lobbyInfo?: PublicGameInfo) {
super.open();
if (lobbyId) {
this.startTrackingLobby(lobbyId);
// If opened with lobbyInfo (public lobby case), auto-join the lobby
if (isPublic) {
this.joinPublicLobby(lobbyId);
} else {
// If opened with lobbyId but no lobbyInfo (URL join case), check if active and join
// If opened with lobbyId but no lobbyInfo (URL join case), auto-join the lobby
if (!lobbyInfo) {
this.handleUrlJoin(lobbyId);
}
}
@@ -326,21 +320,7 @@ export class JoinLobbyModal extends BaseModal {
}
}
private joinPublicLobby(lobbyId: string) {
// Dispatch join-lobby event to actually connect to the lobby
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameID: lobbyId,
source: "public",
} as JoinLobbyEvent,
bubbles: true,
composed: true,
}),
);
}
private startTrackingLobby(lobbyId: string, lobbyInfo?: GameInfo) {
private startTrackingLobby(lobbyId: string) {
this.currentLobbyId = lobbyId;
// clientID will be assigned by server via lobby_info message
this.currentClientID = "";
@@ -352,13 +332,6 @@ export class JoinLobbyModal extends BaseModal {
this.isConnecting = true;
this.handledJoinTimeout = false;
this.startLobbyUpdates();
if (lobbyInfo) {
this.updateFromLobby(lobbyInfo);
// Only stop showing spinner when we have player info
if (lobbyInfo.clients) {
this.isConnecting = false;
}
}
}
private resetTrackingState() {
+1 -1
View File
@@ -218,7 +218,6 @@ export class LangSelector extends LitElement {
"help-modal",
"settings-modal",
"username-input",
"public-lobby",
"user-setting",
"o-modal",
"o-button",
@@ -233,6 +232,7 @@ export class LangSelector extends LitElement {
"flag-input",
"matchmaking-button",
"token-login",
"game-mode-selector",
];
document.title = this.translateText("main.title") ?? document.title;
+3 -3
View File
@@ -31,12 +31,12 @@ export class LanguageModal extends BaseModal {
render() {
const content = html`
<div
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
class="${this.modalContainerClass}"
>
<!-- Header -->
${modalHeader({
title: translateText("select_lang.title"),
onBack: this.close,
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
@@ -70,7 +70,7 @@ export class LanguageModal extends BaseModal {
>
<img
src="/flags/${lang.svg}.svg"
class="w-8 h-6 object-contain shadow-sm rounded-sm shrink-0"
class="w-8 h-6 object-contain rounded-sm shrink-0"
alt="${lang.code}"
/>
<div class="flex flex-col items-start min-w-0">
+2 -6
View File
@@ -82,11 +82,7 @@ export class LeaderboardModal extends BaseModal {
>`;
const content = html`
<div
class="h-full flex flex-col overflow-hidden ${this.inline
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
: ""}"
>
<div class="${this.modalContainerClass}">
${modalHeader({
titleContent: html`
<div class="flex flex-wrap items-center gap-2">
@@ -99,7 +95,7 @@ export class LeaderboardModal extends BaseModal {
${this.activeTab === "players" ? refreshTime : ""}
</div>
`,
onBack: this.close,
onBack: () => this.close(),
ariaLabel: translateText("common.close"),
})}
+34 -91
View File
@@ -1,7 +1,12 @@
import version from "resources/version.txt?raw";
import { UserMeResponse } from "../core/ApiSchemas";
import { EventBus } from "../core/EventBus";
import { GAME_ID_REGEX, GameRecord, GameStartInfo } from "../core/Schemas";
import {
GAME_ID_REGEX,
GameRecord,
GameStartInfo,
PublicGameInfo,
} from "../core/Schemas";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
@@ -32,9 +37,7 @@ import { MatchmakingModal } from "./Matchmaking";
import { initNavigation } from "./Navigation";
import "./NewsModal";
import "./PatternInput";
import "./PublicLobby";
import { PublicLobby, ShowPublicLobbyModalEvent } from "./PublicLobby";
import { SinglePlayerModal } from "./SinglePlayerModal";
import "./SinglePlayerModal";
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
import { TokenLoginModal } from "./TokenLoginModal";
import {
@@ -52,9 +55,11 @@ import {
} from "./Utils";
import "./components/DesktopNavBar";
import "./components/Footer";
import { GameModeSelector } from "./components/GameModeSelector";
import "./components/MainLayout";
import "./components/MobileNavBar";
import "./components/PlayPage";
import "./components/RankedModal";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import "./styles.css";
@@ -209,9 +214,9 @@ declare global {
// Extend the global interfaces to include your custom events
interface DocumentEventMap {
"join-lobby": CustomEvent<JoinLobbyEvent>;
"show-public-lobby-modal": CustomEvent<ShowPublicLobbyModalEvent>;
"kick-player": CustomEvent;
"join-changed": CustomEvent;
"open-matchmaking": CustomEvent<undefined>;
}
}
@@ -223,6 +228,7 @@ export interface JoinLobbyEvent {
// GameRecord exists when replaying an archived game.
gameRecord?: GameRecord;
source?: "public" | "private" | "host" | "matchmaking" | "singleplayer";
publicLobbyInfo?: PublicGameInfo;
}
class Client {
@@ -236,21 +242,18 @@ class Client {
private hostModal: HostPrivateLobbyModal;
private joinModal: JoinLobbyModal;
private publicLobby: PublicLobby;
private gameModeSelector: GameModeSelector | null;
private userSettings: UserSettings = new UserSettings();
private patternsModal: TerritoryPatternsModal;
private tokenLoginModal: TokenLoginModal;
private matchmakingModal: MatchmakingModal;
private gutterAds: GutterAds;
private turnstileTokenPromise: Promise<{
token: string;
createdAt: number;
}> | null = null;
constructor() {}
async initialize(): Promise<void> {
crazyGamesSDK.maybeInit();
// Prefetch turnstile token so it is available when
@@ -293,8 +296,13 @@ class Client {
console.warn("Username input element not found");
}
this.publicLobby = document.querySelector("public-lobby") as PublicLobby;
const gameModeSelector = document.querySelector("game-mode-selector");
if (gameModeSelector instanceof GameModeSelector) {
this.gameModeSelector = gameModeSelector;
} else {
this.gameModeSelector = null;
console.warn("Game mode selector element not found");
}
window.addEventListener("beforeunload", async () => {
console.log("Browser is closing");
if (this.gameStop !== null) {
@@ -309,41 +317,16 @@ class Client {
this.gutterAds = gutterAds;
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
document.addEventListener(
"show-public-lobby-modal",
this.handleShowPublicLobbyModal.bind(this),
);
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
document.addEventListener("kick-player", this.handleKickPlayer.bind(this));
document.addEventListener(
"update-game-config",
this.handleUpdateGameConfig.bind(this),
);
const spModal = document.querySelector(
"single-player-modal",
) as SinglePlayerModal;
if (!spModal || !(spModal instanceof SinglePlayerModal)) {
console.warn("Singleplayer modal element not found");
}
const singlePlayer = document.getElementById("single-player");
if (singlePlayer === null) throw new Error("Missing single-player");
singlePlayer.addEventListener("click", () => {
if (this.usernameInput?.isValid()) {
window.showPage?.("page-single-player");
} else {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: this.usernameInput?.validationError,
color: "red",
duration: 3000,
},
}),
);
}
});
document.addEventListener(
"open-matchmaking",
this.handleOpenMatchmaking.bind(this),
);
const hlpModal = document.querySelector("help-modal") as HelpModal;
if (!hlpModal || !(hlpModal instanceof HelpModal)) {
@@ -512,23 +495,6 @@ class Client {
} else {
this.hostModal.eventBus = this.eventBus;
}
const hostLobbyButton = document.getElementById("host-lobby-button");
if (hostLobbyButton === null) throw new Error("Missing host-lobby-button");
hostLobbyButton.addEventListener("click", () => {
if (this.usernameInput?.isValid()) {
window.showPage?.("page-host-lobby");
} else {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: this.usernameInput?.validationError,
color: "red",
duration: 3000,
},
}),
);
}
});
this.joinModal = document.querySelector(
"join-lobby-modal",
@@ -538,26 +504,6 @@ class Client {
} else {
this.joinModal.eventBus = this.eventBus;
}
const joinPrivateLobbyButton = document.getElementById(
"join-private-lobby-button",
);
if (joinPrivateLobbyButton === null)
throw new Error("Missing join-private-lobby-button");
joinPrivateLobbyButton.addEventListener("click", () => {
if (this.usernameInput?.isValid()) {
window.showPage?.("page-join-lobby");
} else {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: this.usernameInput?.validationError,
color: "red",
duration: 3000,
},
}),
);
}
});
if (this.userSettings.darkMode()) {
document.documentElement.classList.add("dark");
@@ -798,6 +744,10 @@ class Client {
this.gameStop(true);
document.body.classList.remove("in-game");
}
if (lobby.source === "public") {
this.joinModal?.open(lobby.gameID, lobby.publicLobbyInfo);
}
const config = await getServerConfigFromClient();
// Only update URL immediately for private lobbies, not public ones
if (lobby.source !== "public") {
@@ -856,7 +806,7 @@ class Client {
modal.isModalOpen = false;
}
});
this.publicLobby.stop();
this.gameModeSelector?.stop();
document.querySelectorAll(".ad").forEach((ad) => {
(ad as HTMLElement).style.display = "none";
});
@@ -872,8 +822,8 @@ class Client {
}
},
() => {
this.joinModal.close();
this.publicLobby.stop();
this.joinModal?.closeWithoutLeaving();
this.gameModeSelector?.stop();
incrementGamesPlayed();
document.querySelectorAll(".ad").forEach((ad) => {
@@ -915,17 +865,6 @@ class Client {
}
}
private handleShowPublicLobbyModal(
event: CustomEvent<ShowPublicLobbyModalEvent>,
) {
const { lobby } = event.detail;
console.log(`Opening JoinLobbyModal for public lobby ${lobby.gameID}`);
// Open the join lobby modal page and pass the lobby info
window.showPage?.("page-join-lobby");
this.joinModal?.open(lobby.gameID, true);
}
private async handleLeaveLobby(/* event: CustomEvent */) {
if (this.gameStop === null) {
return;
@@ -946,6 +885,10 @@ class Client {
crazyGamesSDK.gameplayStop();
}
private handleOpenMatchmaking(_event: CustomEvent<undefined>) {
this.matchmakingModal?.open();
}
private handleKickPlayer(event: CustomEvent) {
const { target } = event.detail;
+20 -53
View File
@@ -1,5 +1,5 @@
import { html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { getUserMe, hasLinkedAccount } from "./Api";
@@ -18,7 +18,7 @@ export class MatchmakingModal extends BaseModal {
@state() private connected = false;
@state() private socket: WebSocket | null = null;
@state() private gameID: string | null = null;
private elo: number | "unknown" = "unknown";
private elo: number | string = "...";
constructor() {
super();
@@ -37,14 +37,10 @@ export class MatchmakingModal extends BaseModal {
`;
const content = html`
<div
class="h-full flex flex-col ${this.inline
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
: ""}"
>
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("matchmaking_modal.title"),
onBack: this.close,
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
<div class="flex-1 flex flex-col items-center justify-center gap-6 p-6">
@@ -71,39 +67,21 @@ export class MatchmakingModal extends BaseModal {
private renderInner() {
if (!this.connected) {
return html`
<div class="flex flex-col items-center gap-4">
<div
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin"
></div>
<p class="text-center text-white/80">
${translateText("matchmaking_modal.connecting")}
</p>
</div>
`;
return this.renderLoadingSpinner(
translateText("matchmaking_modal.connecting"),
"blue",
);
}
if (this.gameID === null) {
return html`
<div class="flex flex-col items-center gap-4">
<div
class="w-12 h-12 border-4 border-green-500/30 border-t-green-500 rounded-full animate-spin"
></div>
<p class="text-center text-white/80">
${translateText("matchmaking_modal.searching")}
</p>
</div>
`;
return this.renderLoadingSpinner(
translateText("matchmaking_modal.searching"),
"green",
);
} else {
return html`
<div class="flex flex-col items-center gap-4">
<div
class="w-12 h-12 border-4 border-yellow-500/30 border-t-yellow-500 rounded-full animate-spin"
></div>
<p class="text-center text-white/80">
${translateText("matchmaking_modal.waiting_for_game")}
</p>
</div>
`;
return this.renderLoadingSpinner(
translateText("matchmaking_modal.waiting_for_game"),
"yellow",
);
}
}
@@ -177,7 +155,9 @@ export class MatchmakingModal extends BaseModal {
return;
}
this.elo = userMe.player.leaderboard?.oneVone?.elo ?? "unknown";
this.elo =
userMe.player.leaderboard?.oneVone?.elo ??
translateText("matchmaking_modal.no_elo");
this.connected = false;
this.gameID = null;
@@ -241,7 +221,6 @@ export class MatchmakingModal extends BaseModal {
@customElement("matchmaking-button")
export class MatchmakingButton extends LitElement {
@query("matchmaking-modal") private matchmakingModal?: MatchmakingModal;
@state() private isLoggedIn = false;
constructor() {
@@ -281,7 +260,6 @@ export class MatchmakingButton extends LitElement {
${translateText("matchmaking_button.description")}
</span>
</button>
<matchmaking-modal></matchmaking-modal>
`
: html`
<button
@@ -297,11 +275,9 @@ export class MatchmakingButton extends LitElement {
private handleLoggedInClick() {
const usernameInput = document.querySelector("username-input") as any;
const publicLobby = document.querySelector("public-lobby") as any;
if (usernameInput?.isValid()) {
this.open();
publicLobby?.leaveLobby();
document.dispatchEvent(new CustomEvent("open-matchmaking"));
} else {
window.dispatchEvent(
new CustomEvent("show-message", {
@@ -318,13 +294,4 @@ export class MatchmakingButton extends LitElement {
private handleLoggedOutClick() {
window.showPage?.("page-account");
}
public open() {
this.matchmakingModal?.open();
}
public close() {
this.matchmakingModal?.close();
this.requestUpdate();
}
}
+19 -5
View File
@@ -1,7 +1,24 @@
export function initNavigation() {
const closeMobileSidebar = () => {
const sidebar = document.getElementById("sidebar-menu");
const backdrop = document.getElementById("mobile-menu-backdrop");
if (sidebar?.classList.contains("open")) {
sidebar.classList.remove("open");
backdrop?.classList.remove("open");
document.documentElement.classList.remove("overflow-hidden");
sidebar.setAttribute("aria-hidden", "true");
backdrop?.setAttribute("aria-hidden", "true");
const hb = document.getElementById("hamburger-btn");
if (hb) hb.setAttribute("aria-expanded", "false");
}
};
const showPage = (pageId: string) => {
(window as any).currentPageId = pageId;
// Close mobile sidebar if a nav item was clicked
closeMobileSidebar();
// Hide only the currently visible modal
const visibleModal = document.querySelector(".page-content:not(.hidden)");
if (visibleModal) {
@@ -106,9 +123,6 @@ export function initNavigation() {
}
});
// Set default page to play if no menu item is active
const anyActive = document.querySelector(".nav-menu-item.active");
if (!anyActive) {
showPage("page-play");
}
// Ensure Play is the default visible/active page on load.
showPage("page-play");
}
+2 -6
View File
@@ -17,14 +17,10 @@ export class NewsModal extends BaseModal {
render() {
const content = html`
<div
class="h-full flex flex-col ${this.inline
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
: ""}"
>
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("news.title"),
onBack: this.close,
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
<div
+34 -5
View File
@@ -15,6 +15,9 @@ export class PatternInput extends LitElement {
@property({ type: Boolean, attribute: "show-select-label" })
public showSelectLabel: boolean = false;
@property({ type: Boolean, attribute: "adaptive-size" })
public adaptiveSize: boolean = false;
private _abortController: AbortController | null = null;
private _onPatternSelected = async () => {
@@ -60,13 +63,39 @@ export class PatternInput extends LitElement {
return this;
}
private getIsDefaultPattern(): boolean {
return this.pattern === null && this.selectedColor === null;
}
private shouldShowSelectLabel(): boolean {
return this.showSelectLabel && this.getIsDefaultPattern();
}
private applyAdaptiveSize(): void {
if (!this.adaptiveSize) {
this.style.removeProperty("width");
this.style.removeProperty("height");
return;
}
const showSelect = this.showSelectLabel && this.getIsDefaultPattern();
this.style.setProperty("height", "3rem");
this.style.setProperty(
"width",
showSelect ? "clamp(6.5rem, 28vw, 9.5rem)" : "3rem",
);
}
protected updated(): void {
this.applyAdaptiveSize();
}
render() {
if (crazyGamesSDK.isOnCrazyGames()) {
return html``;
}
const isDefault = this.pattern === null && this.selectedColor === null;
const showSelect = this.showSelectLabel && isDefault;
const showSelect = this.shouldShowSelectLabel();
const buttonTitle = translateText("territory_patterns.title");
// Show loading state
@@ -74,7 +103,7 @@ export class PatternInput extends LitElement {
return html`
<button
id="pattern-input"
class="pattern-btn m-0 border-0 !p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 bg-slate-900/80 rounded-lg overflow-hidden"
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-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] rounded-lg overflow-hidden"
disabled
>
<span
@@ -94,7 +123,7 @@ export class PatternInput extends LitElement {
return html`
<button
id="pattern-input"
class="pattern-btn m-0 border-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-slate-900/80 hover:bg-slate-800/80 active:bg-slate-800/90 rounded-lg overflow-hidden"
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-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] hover:brightness-[1.08] active:brightness-[0.95] rounded-lg overflow-hidden"
title=${buttonTitle}
@click=${this.onInputClick}
>
@@ -107,7 +136,7 @@ export class PatternInput extends LitElement {
</span>
${showSelect
? html`<span
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full text-center px-1"
class="text-[10px] font-black text-white uppercase leading-none break-words w-full text-center px-1"
>
${translateText("territory_patterns.select_skin")}
</span>`
-241
View File
@@ -1,241 +0,0 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { GameMapType } from "../core/game/Game";
import { GameID, PublicGameInfo, PublicGames } from "../core/Schemas";
import { PublicLobbySocket } from "./LobbySocket";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import {
getGameModeLabel,
getModifierLabels,
normaliseMapKey,
renderDuration,
translateText,
} from "./Utils";
export interface ShowPublicLobbyModalEvent {
lobby: PublicGameInfo;
}
@customElement("public-lobby")
export class PublicLobby extends LitElement {
@state() private publicGames: PublicGames | null = null;
@state() public isLobbyHighlighted: boolean = false;
@state() private mapImages: Map<GameID, string> = new Map();
private lobbyIDToStart = new Map<GameID, number>();
private serverTimeOffset = 0;
private lobbySocket = new PublicLobbySocket((data) =>
this.handleLobbiesUpdate(data),
);
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.lobbySocket.start();
}
disconnectedCallback() {
super.disconnectedCallback();
this.lobbySocket.stop();
}
private handleLobbiesUpdate(publicGames: PublicGames) {
this.publicGames = publicGames;
// Calculate offset between server time and client time
if (this.publicGames) {
this.serverTimeOffset = this.publicGames.serverTime - Date.now();
}
// TODO: thihs is just a temporary scaffolding until PR #3191 is merged.
this.publicGames.games["ffa"]?.forEach((l) => {
if (!this.lobbyIDToStart.has(l.gameID)) {
// Convert server's startsAt to client time by subtracting offset
const startsAt = l.startsAt ?? Date.now();
this.lobbyIDToStart.set(l.gameID, startsAt - this.serverTimeOffset);
}
if (l.gameConfig && !this.mapImages.has(l.gameID)) {
this.loadMapImage(l.gameID, l.gameConfig.gameMap);
}
});
this.requestUpdate();
}
private async loadMapImage(gameID: GameID, gameMap: string) {
try {
const mapType = gameMap as GameMapType;
const data = terrainMapFileLoader.getMapData(mapType);
this.mapImages.set(gameID, await data.webpPath());
this.requestUpdate();
} catch (error) {
console.error("Failed to load map image:", error);
}
}
render() {
if (!this.publicGames) return html``;
const lobby = this.publicGames.games["ffa"]?.[0];
if (!lobby?.gameConfig) return html``;
const start = this.lobbyIDToStart.get(lobby.gameID) ?? 0;
const timeRemaining = Math.max(0, Math.floor((start - Date.now()) / 1000));
const isStarting = timeRemaining <= 2;
const timeDisplay = renderDuration(timeRemaining);
const modeLabel = getGameModeLabel(lobby.gameConfig);
const modifierLabels = getModifierLabels(
lobby.gameConfig.publicGameModifiers,
);
const mapImageSrc = this.mapImages.get(lobby.gameID);
return html`
<button
@click=${() => this.lobbyClicked(lobby)}
class="group relative isolate flex flex-col w-full h-80 lg:h-96 overflow-hidden rounded-2xl transition-all duration-200 bg-[#3d7bab] hover:scale-[1.01] active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/50"
>
<div class="font-sans w-full h-full flex flex-col">
<!-- Main card gradient - stops before text -->
<div class="absolute inset-0 pointer-events-none z-10"></div>
<!-- Map Image Area with gradient overlay -->
<div class="flex-1 w-full relative overflow-hidden">
${mapImageSrc
? html`<img
src="${mapImageSrc}"
alt="${lobby.gameConfig.gameMap}"
class="absolute inset-0 w-full h-full object-cover object-center z-10"
/>`
: ""}
<!-- Vignette overlay for dark edges -->
<div class="pointer-events-none absolute inset-0 z-20"></div>
</div>
<!-- Mode Badge in top left -->
${modeLabel
? html`<span
class="absolute top-4 left-4 px-4 py-1 rounded font-bold text-sm lg:text-base uppercase tracking-widest z-30 bg-slate-800 text-white ring-1 ring-white/10 shadow-sm"
>
${modeLabel}
</span>`
: ""}
<!-- Timer in top right -->
${timeRemaining > 0
? html`
<span
class="absolute top-4 right-4 px-4 py-1 rounded font-bold text-sm lg:text-base tracking-widest z-30 bg-blue-600 text-white"
>
${timeDisplay}
</span>
`
: html`<span
class="absolute top-4 right-4 px-4 py-1 rounded font-bold text-sm lg:text-base uppercase tracking-widest z-30 bg-green-600 text-white"
>
${translateText("public_lobby.started")}
</span>`}
<!-- Content Banner -->
<div class="absolute bottom-0 left-0 right-0 z-20">
<!-- Modifier badges placed just above the gradient overlay -->
${modifierLabels.length > 0
? html`<div
class="absolute -top-8 left-4 z-30 flex gap-2 flex-wrap"
>
${modifierLabels.map(
(label) => html`
<span
class="px-2 py-0.5 rounded text-xs font-medium uppercase tracking-wide bg-purple-600 text-white"
>
${label}
</span>
`,
)}
</div>`
: html``}
<!-- Gradient overlay for text area - adds extra darkening -->
<div
class="absolute inset-0 bg-gradient-to-b from-black/60 to-black/90 pointer-events-none"
></div>
<div class="relative p-6 flex flex-col gap-2 text-left">
<!-- Header row: Status/Join on left, Player Count on right -->
<div class="flex items-center justify-between w-full">
<div class="text-base uppercase tracking-widest text-white">
${isStarting
? html`<span class="text-green-400 animate-pulse"
>${translateText("public_lobby.starting_game")}</span
>`
: html`${translateText("public_lobby.join")}`}
</div>
<div class="flex items-center gap-2 text-white z-30">
<span class="text-base font-bold uppercase tracking-widest"
>${lobby.numClients}/${lobby.gameConfig.maxPlayers}</span
>
<svg
class="w-5 h-5 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"
></path>
</svg>
</div>
</div>
<!-- Map Name - Full Width -->
<div
class="text-2xl lg:text-3xl font-bold text-white leading-none uppercase tracking-widest w-full"
>
${translateText(
`map.${normaliseMapKey(lobby.gameConfig.gameMap)}`,
)}
</div>
<!-- modifiers moved above gradient overlay -->
</div>
</div>
</div>
</button>
`;
}
public stop() {
this.lobbySocket.stop();
}
private lobbyClicked(lobby: PublicGameInfo) {
// Validate username before opening the modal
const usernameInput = document.querySelector("username-input") as any;
if (
usernameInput &&
typeof usernameInput.isValid === "function" &&
!usernameInput.isValid()
) {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: usernameInput.validationError,
color: "red",
duration: 3000,
},
}),
);
return;
}
this.dispatchEvent(
new CustomEvent("show-public-lobby-modal", {
detail: { lobby } as ShowPublicLobbyModalEvent,
bubbles: true,
composed: true,
}),
);
}
}
+2 -4
View File
@@ -217,13 +217,11 @@ export class SinglePlayerModal extends BaseModal {
];
const content = html`
<div
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
>
<div class="${this.modalContainerClass}">
<!-- Header -->
${modalHeader({
title: translateText("main.solo") || "Solo",
onBack: this.close,
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: hasLinkedAccount(this.userMeResponse)
? html`<button
+2 -6
View File
@@ -81,7 +81,7 @@ export class TerritoryPatternsModal extends BaseModal {
return html`
${modalHeader({
title: translateText("territory_patterns.title"),
onBack: this.close,
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: !hasLinkedAccount(this.userMeResponse)
? html`<div class="flex items-center">
@@ -259,11 +259,7 @@ export class TerritoryPatternsModal extends BaseModal {
if (!this.isActive && !this.inline) return html``;
const content = html`
<div
class="h-full flex flex-col ${this.inline
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
: ""}"
>
<div class="${this.modalContainerClass}">
${this.renderTabNavigation()}
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
${this.activeTab === "patterns"
+1 -5
View File
@@ -26,11 +26,7 @@ export class TokenLoginModal extends BaseModal {
render() {
const title = translateText("token_login_modal.title");
const content = html`
<div
class="h-full flex flex-col ${this.inline
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
: ""}"
>
<div class="${this.modalContainerClass}">
${modalHeader({
title,
onBack: () => this.close(),
+2 -6
View File
@@ -30,11 +30,7 @@ export class TroubleshootingModal extends BaseModal {
render() {
const content = html`
<div
class="h-full select-text flex flex-col ${this.inline
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
: ""}"
>
<div class="${this.modalContainerClass}">
${modalHeader({
titleContent: html` <div
class="w-full flex flex-col sm:flex-row justify-between gap-2"
@@ -56,7 +52,7 @@ export class TroubleshootingModal extends BaseModal {
${translateText("common.copy")}
</button>
</div>`,
onBack: this.close,
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
${this.loading
+4 -6
View File
@@ -394,20 +394,18 @@ export class UserSettingModal extends BaseModal {
: this.renderKeybindSettings();
const content = html`
<div
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
>
<div class="${this.modalContainerClass}">
<div
class="relative flex flex-col border-b border-white/10 pb-4 shrink-0"
class="relative flex flex-col border-b border-white/10 lg:pb-4 shrink-0"
>
${modalHeader({
title: translateText("user_setting.title"),
onBack: this.close,
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
showDivider: true,
})}
<div class="hidden md:flex items-center gap-2 justify-center mt-4">
<div class="hidden lg:flex items-center gap-2 justify-center mt-4">
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "basic"
+9 -6
View File
@@ -78,7 +78,7 @@ export class UsernameInput extends LitElement {
@input=${this.handleClanTagChange}
placeholder="${translateText("username.tag")}"
maxlength="5"
class="w-[6rem] bg-transparent border-b border-white/20 text-white placeholder-white/30 text-xl font-bold text-center focus:outline-none focus:border-white/50 transition-colors uppercase shrink-0"
class="w-[6rem] text-xl font-bold text-center uppercase shrink-0 bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60"
/>
<input
type="text"
@@ -86,7 +86,7 @@ export class UsernameInput extends LitElement {
@input=${this.handleUsernameChange}
placeholder="${translateText("username.enter_username")}"
maxlength="${MAX_USERNAME_LENGTH}"
class="flex-1 min-w-0 bg-transparent border-0 text-white placeholder-white/30 text-2xl font-bold text-left focus:outline-none focus:ring-0 transition-colors overflow-x-auto whitespace-nowrap text-ellipsis pr-2"
class="flex-1 min-w-0 border-0 text-2xl font-bold text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
/>
</div>
${this.validationError
@@ -147,19 +147,22 @@ export class UsernameInput extends LitElement {
}
private validateAndStore() {
// Validate base username meets minimum length (clan tag doesn't count)
if (this.baseUsername.trim().length < MIN_USERNAME_LENGTH) {
// Prevent empty username even if clan tag is present
const trimmedBase = this.baseUsername.trim();
if (!trimmedBase || trimmedBase.length < MIN_USERNAME_LENGTH) {
this._isValid = false;
this.validationError = translateText("username.too_short", {
const msg = translateText("username.too_short", {
min: MIN_USERNAME_LENGTH,
});
this.validationError = msg;
return;
}
// Validate clan tag if present
if (this.clanTag.length > 0 && this.clanTag.length < 2) {
this._isValid = false;
this.validationError = translateText("username.tag_too_short");
const msg = translateText("username.tag_too_short");
this.validationError = msg;
return;
}
+50 -1
View File
@@ -1,4 +1,4 @@
import { LitElement } from "lit";
import { html, LitElement, TemplateResult } from "lit";
import { property, query, state } from "lit/decorators.js";
/**
@@ -10,11 +10,21 @@ import { property, query, state } from "lit/decorators.js";
* - Automatic listener lifecycle management
* - Common inline/modal element handling
* - Shared open/close logic with hooks for custom behavior
* - Standardized loading spinner UI
* - Consistent modal container styling
*/
export abstract class BaseModal extends LitElement {
@state() protected isModalOpen = false;
@property({ type: Boolean }) inline = false;
/**
* Standard modal container class string.
* Provides consistent dark glassmorphic styling across all modals.
* No rounding on mobile for full-screen appearance.
*/
protected readonly modalContainerClass =
"h-full flex flex-col overflow-hidden bg-black/70 backdrop-blur-xl lg:rounded-2xl lg:border border-white/10";
@query("o-modal") protected modalEl?: HTMLElement & {
open: () => void;
close: () => void;
@@ -121,4 +131,43 @@ export abstract class BaseModal extends LitElement {
this.modalEl?.close();
}
}
/**
* Renders a standardized loading spinner with optional custom message.
* Use this for consistent loading states across all modals.
*
* @param message - Optional loading message text. Defaults to no message.
* @param spinnerColor - Optional spinner color. Defaults to 'blue'.
* @returns TemplateResult of the loading UI
*/
protected renderLoadingSpinner(
message?: string,
spinnerColor: "blue" | "green" | "yellow" | "white" = "blue",
): TemplateResult {
const colorClasses = {
blue: "border-blue-500/30 border-t-blue-500",
green: "border-green-500/30 border-t-green-500",
yellow: "border-yellow-500/30 border-t-yellow-500",
white: "border-white/20 border-t-white",
};
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white h-full min-h-[400px]"
>
<div
class="w-12 h-12 border-4 ${colorClasses[
spinnerColor
]} rounded-full animate-spin mb-4"
></div>
${message
? html`<p
class="text-white/60 font-medium tracking-wide animate-pulse"
>
${message}
</p>`
: ""}
</div>
`;
}
}
+17 -6
View File
@@ -79,9 +79,14 @@ export class DesktopNavBar extends LitElement {
};
render() {
const currentPage = (window as any).currentPageId ?? "page-play";
if (!(window as any).currentPageId) {
(window as any).currentPageId = currentPage;
}
return html`
<nav
class="hidden lg:flex w-full bg-slate-950/70 backdrop-blur-md border-b border-white/10 items-center justify-center gap-8 py-4 shrink-0 transition-opacity z-50 relative"
class="hidden lg:flex w-full bg-slate-900 items-center justify-center gap-8 py-4 shrink-0 z-50 relative"
>
<div class="flex flex-col items-center justify-center">
<div class="h-8 text-[#2563eb]">
@@ -89,7 +94,7 @@ export class DesktopNavBar extends LitElement {
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1364 259"
fill="currentColor"
class="h-full w-auto drop-shadow-[0_0_10px_rgba(37,99,235,0.4)]"
class="h-full w-auto"
>
<path
d="M0,174V51h15.24v-17.14h16.81v-16.98h16.96V0h1266v17.23h17.13v16.81h16.98v16.96h14.88v123h-15.13v17.08h-17.08v17.08h-16.9v17.04H324.9v16.86h-16.9v16.95h-102v-17.12h-17.07v-17.05H48.73v-17.05h-16.89v-16.89H14.94v-16.89H0ZM1297.95,17.35H65.9v16.7h-17.08v17.08h-14.5v123.08h14.85v16.9h17.08v17.08h139.9v17.08h17.08v16.36h67.9v-16.72h17.08v-17.07h989.88v-17.07h17.08v-16.9h14.44V50.8h-14.75v-17.08h-16.9v-16.37Z"
@@ -131,20 +136,26 @@ export class DesktopNavBar extends LitElement {
class="l-header__highlightText text-center"
></div>
</div>
<!-- Desktop Navigation Menu Items -->
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
class="nav-menu-item ${currentPage === "page-play"
? "active"
: ""} text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-play"
data-i18n="main.play"
></button>
<!-- Desktop Navigation Menu Items -->
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
class="nav-menu-item ${currentPage === "page-news"
? "active"
: ""} text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-news"
data-i18n="main.news"
></button>
<div class="relative no-crazygames">
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
class="nav-menu-item ${currentPage === "page-item-store"
? "active"
: ""} text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-item-store"
data-i18n="main.store"
@click=${this.onStoreClick}
+2 -2
View File
@@ -10,7 +10,7 @@ export class Footer extends LitElement {
render() {
return html`
<footer
class="[.in-game_&]:hidden bg-black/60 backdrop-blur-md flex flex-col items-center justify-center gap-2 pt-[2px] pb-2 text-white/50 w-full border-t border-white/10 shrink-0 mt-auto"
class="[.in-game_&]:hidden bg-slate-950/70 backdrop-blur-md flex flex-col items-center justify-center gap-2 pt-[2px] pb-2 text-white/50 w-full border-t border-white/10 shrink-0 mt-auto"
>
<div class="flex items-center justify-center gap-6 pt-2">
<a
@@ -43,7 +43,7 @@ export class Footer extends LitElement {
</svg>
</a>
<a
href="https://discord.gg/jRpxXvG42t"
href="https://discord.gg/openfront"
target="_blank"
rel="noopener noreferrer"
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
+393
View File
@@ -0,0 +1,393 @@
import { html, LitElement, nothing, type TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { until } from "lit/directives/until.js";
import {
Duos,
GameMapType,
GameMode,
HumansVsNations,
PublicGameModifiers,
Quads,
Trios,
} from "../../core/game/Game";
import { PublicGameInfo, PublicGames } from "../../core/Schemas";
import { PublicLobbySocket } from "../LobbySocket";
import { JoinLobbyEvent } from "../Main";
import { terrainMapFileLoader } from "../TerrainMapFileLoader";
import { renderDuration, translateText } from "../Utils";
const CARD_BG = "bg-[color-mix(in_oklab,var(--frenchBlue)_70%,black)]";
@customElement("game-mode-selector")
export class GameModeSelector extends LitElement {
@state() private lobbies: PublicGames | null = null;
private timeOffset: number = 0;
private lobbySocket = new PublicLobbySocket((lobbies) =>
this.handleLobbiesUpdate(lobbies),
);
private updateIntervalId: number | null = null;
createRenderRoot() {
return this;
}
/**
* Validates username input and shows error message if invalid.
* Returns true if valid, false otherwise.
*/
private validateUsername(): boolean {
const usernameInput = document.querySelector("username-input") as any;
if (usernameInput?.isValid?.() === false) {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: usernameInput.validationError,
color: "red",
duration: 3000,
},
}),
);
return false;
}
return true;
}
connectedCallback() {
super.connectedCallback();
this.lobbySocket.start();
// Update time remaining every second
this.updateIntervalId = window.setInterval(
() => this.requestUpdate(),
1000,
);
}
disconnectedCallback() {
this.stop();
super.disconnectedCallback();
}
public stop() {
this.lobbySocket.stop();
if (this.updateIntervalId !== null) {
clearInterval(this.updateIntervalId);
this.updateIntervalId = null;
}
}
private handleLobbiesUpdate(lobbies: PublicGames) {
this.lobbies = lobbies;
// TODO: plus or minus?
this.timeOffset = Date.now() - lobbies.serverTime;
document.dispatchEvent(
new CustomEvent("public-lobbies-update", {
detail: { payload: lobbies },
}),
);
this.requestUpdate();
}
render() {
const ffa = this.lobbies?.games?.["ffa"]?.[0];
const teams = this.lobbies?.games?.["team"]?.[0];
const special = this.lobbies?.games?.["special"]?.[0];
return html`
<div
class="grid grid-cols-1 lg:grid-cols-2 gap-4 w-[70%] lg:w-full mx-auto"
>
${ffa
? until(this.renderLobbyCard(ffa, this.getLobbyTitle(ffa)), nothing)
: nothing}
${teams
? until(
this.renderLobbyCard(teams, this.getLobbyTitle(teams)),
nothing,
)
: nothing}
${special
? until(this.renderSpecialLobbyCard(special), nothing)
: nothing}
${this.renderQuickActionsSection()}
</div>
`;
}
private renderSpecialLobbyCard(lobby: PublicGameInfo) {
const subtitle = this.getLobbyTitle(lobby);
const mainTitle = translateText("mode_selector.special_title");
const titleContent = subtitle
? html`
<span class="block">${mainTitle}</span>
<span class="block text-[10px] leading-tight text-white/70">
${subtitle}
</span>
`
: mainTitle;
return this.renderLobbyCard(lobby, titleContent);
}
private renderQuickActionsSection() {
return html`
<div class="grid grid-cols-2 gap-2 h-40 lg:h-56">
${this.renderSmallActionCard(
translateText("main.solo"),
this.openSinglePlayerModal,
)}
${this.renderSmallActionCard(
translateText("mode_selector.ranked_title"),
this.openRankedMenu,
)}
${this.renderSmallActionCard(
translateText("main.create"),
this.openHostLobby,
)}
${this.renderSmallActionCard(
translateText("main.join"),
this.openJoinLobby,
)}
</div>
`;
}
private openRankedMenu = () => {
if (!this.validateUsername()) return;
const modal = document.getElementById("page-ranked") as any;
if (window.showPage) {
window.showPage("page-ranked");
} else if (modal) {
document.getElementById("page-play")?.classList.add("hidden");
modal.classList.remove("hidden");
modal.classList.add("block");
}
modal?.open?.();
};
private openSinglePlayerModal = () => {
if (!this.validateUsername()) return;
(document.querySelector("single-player-modal") as any)?.open();
};
private openHostLobby = () => {
if (!this.validateUsername()) return;
(document.querySelector("host-lobby-modal") as any)?.open();
};
private openJoinLobby = () => {
if (!this.validateUsername()) return;
(document.querySelector("join-lobby-modal") as any)?.open();
};
private renderSmallActionCard(title: string, onClick: () => void) {
return html`
<button
@click=${onClick}
class="flex items-center justify-center w-full h-full rounded-xl ${CARD_BG} border-0 transition-transform hover:scale-[1.02] active:scale-[0.98] text-sm lg:text-base font-bold text-white uppercase tracking-wider text-center"
>
${title}
</button>
`;
}
private async renderLobbyCard(
lobby: PublicGameInfo,
titleContent: string | TemplateResult,
) {
const mapType = lobby.gameConfig!.gameMap as GameMapType;
const data = terrainMapFileLoader.getMapData(mapType);
const mapImageSrc = await data.webpPath();
// TODO: plus or minus
const start = lobby.startsAt - this.timeOffset;
const timeRemaining = Math.max(0, Math.floor((start - Date.now()) / 1000));
const timeDisplay = renderDuration(timeRemaining);
const gameMap = lobby.gameConfig?.gameMap;
const mapName = gameMap
? translateText(`map.${gameMap.toLowerCase().replace(/[\s.]+/g, "")}`)
: null;
const modifierLabels = this.getModifierLabels(
lobby.gameConfig?.publicGameModifiers,
);
// Sort by length for visual consistency (shorter labels first)
if (modifierLabels.length > 1) {
modifierLabels.sort((a, b) => a.length - b.length);
}
return html`
<button
@click=${() => this.validateAndJoin(lobby)}
class="group flex flex-col w-full h-40 lg:h-56 text-white uppercase rounded-2xl overflow-hidden transition-transform duration-200 hover:scale-[1.02] active:scale-[0.98] ${CARD_BG}"
>
<div class="relative flex-1 overflow-hidden ${CARD_BG}">
${mapImageSrc
? html`<img
src="${mapImageSrc}"
alt="${mapName ?? gameMap ?? "map"}"
draggable="false"
class="absolute inset-0 w-full h-full object-contain object-center scale-[1.05] pointer-events-none"
/>`
: null}
<div
class="absolute inset-x-2 bottom-2 flex items-end justify-between gap-2"
>
${modifierLabels.length > 0
? html`<div class="flex flex-col items-start gap-1">
${modifierLabels.map(
(label) =>
html`<span
class="px-2 py-0.5 rounded text-[10px] font-medium uppercase tracking-wide bg-teal-600 text-white shadow-[0_0_6px_rgba(13,148,136,0.35)]"
>${label}</span
>`,
)}
</div>`
: html`<div></div>`}
<div class="shrink-0">
${timeRemaining > 0
? html`<span
class="text-[10px] font-bold uppercase tracking-widest bg-blue-600 px-2 py-0.5 rounded"
>${timeDisplay}</span
>`
: html`<span
class="text-[10px] font-bold uppercase tracking-widest bg-green-600 px-2 py-0.5 rounded"
>${translateText("public_lobby.starting_game")}</span
>`}
</div>
</div>
</div>
<div class="flex items-center justify-between px-3 py-2">
<div class="flex flex-col gap-0.5 min-w-0">
<h3
class="text-sm lg:text-base font-bold uppercase tracking-wider text-left leading-tight"
>
${titleContent}
</h3>
${mapName
? html`<p
class="text-[10px] text-white/70 uppercase tracking-wider text-left"
>
${mapName}
</p>`
: ""}
</div>
<span
class="text-xs font-bold uppercase tracking-widest shrink-0 ml-2"
>
${lobby.numClients}/${lobby.gameConfig?.maxPlayers}
</span>
</div>
</button>
`;
}
private validateAndJoin(lobby: PublicGameInfo) {
if (!this.validateUsername()) return;
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameID: lobby.gameID,
source: "public",
publicLobbyInfo: lobby,
} as JoinLobbyEvent,
bubbles: true,
composed: true,
}),
);
}
private getLobbyTitle(lobby: PublicGameInfo): string {
return this.getBaseModeTitle(lobby);
}
private getModifierLabels(mods: PublicGameModifiers | undefined): string[] {
if (!mods) return [];
return [
mods.isRandomSpawn && translateText("public_game_modifier.random_spawn"),
mods.isCompact && translateText("public_game_modifier.compact_map"),
mods.isCrowded && translateText("public_game_modifier.crowded"),
mods.startingGold && translateText("public_game_modifier.starting_gold"),
].filter((x): x is string => !!x);
}
private getBaseModeTitle(lobby: PublicGameInfo): string {
const config = lobby.gameConfig!;
if (config.gameMode === GameMode.FFA) {
return translateText("game_mode.ffa");
}
if (config?.gameMode === GameMode.Team) {
const totalPlayers = config.maxPlayers ?? lobby.numClients ?? undefined;
const formatTeamsOf = (
teamCount: number | undefined,
playersPerTeam: number | undefined,
label?: string,
) => {
if (!teamCount)
return label ?? translateText("mode_selector.teams_title");
const baseTitle = playersPerTeam
? translateText("mode_selector.teams_of", {
teamCount: String(teamCount),
playersPerTeam: String(playersPerTeam),
})
: translateText("mode_selector.teams_count", {
teamCount: String(teamCount),
});
return `${baseTitle}${label ? ` (${label})` : ""}`;
};
switch (config.playerTeams) {
case Duos: {
const teamCount = totalPlayers
? Math.floor(totalPlayers / 2)
: undefined;
return teamCount
? translateText("public_lobby.teams_Duos", {
team_count: String(teamCount),
})
: formatTeamsOf(undefined, 2);
}
case Trios: {
const teamCount = totalPlayers
? Math.floor(totalPlayers / 3)
: undefined;
return teamCount
? translateText("public_lobby.teams_Trios", {
team_count: String(teamCount),
})
: formatTeamsOf(undefined, 3);
}
case Quads: {
const teamCount = totalPlayers
? Math.floor(totalPlayers / 4)
: undefined;
return teamCount
? translateText("public_lobby.teams_Quads", {
team_count: String(teamCount),
})
: formatTeamsOf(undefined, 4);
}
case HumansVsNations: {
const humanSlots = config.maxPlayers ?? lobby.numClients;
return humanSlots
? translateText("public_lobby.teams_hvn_detailed", {
num: String(humanSlots),
})
: translateText("public_lobby.teams_hvn");
}
default:
if (typeof config.playerTeams === "number") {
const teamCount = config.playerTeams;
const playersPerTeam =
totalPlayers && teamCount > 0
? Math.floor(totalPlayers / teamCount)
: undefined;
return formatTeamsOf(teamCount, playersPerTeam);
}
}
}
return "";
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ export class LobbyTeamView extends LitElement {
willUpdate(changedProperties: Map<string, any>) {
// Recompute team preview when relevant properties change
// clients is 'changed' every 1s from pollPlayers, chose to not compare for actual change
// clients is updated from WebSocket lobby_info events
if (
changedProperties.has("gameMode") ||
changedProperties.has("clients") ||
+2 -2
View File
@@ -19,10 +19,10 @@ export class MainLayout extends LitElement {
render() {
return html`
<main
class="relative [.in-game_&]:hidden flex flex-col flex-1 overflow-hidden w-full px-[clamp(1.5rem,3vw,3rem)] pt-[clamp(0.75rem,1.5vw,1.5rem)] pb-[clamp(0.75rem,1.5vw,1.5rem)]"
class="relative [.in-game_&]:hidden flex flex-col flex-1 overflow-hidden w-full px-0 lg:px-[clamp(1.5rem,3vw,3rem)] pt-0 lg:pt-[clamp(0.75rem,1.5vw,1.5rem)] pb-0 lg:pb-[clamp(0.75rem,1.5vw,1.5rem)]"
>
<div
class="w-full max-w-[20cm] mx-auto flex flex-col flex-1 gap-[clamp(1.5rem,3vw,3rem)] overflow-y-auto overflow-x-hidden [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden lg:[scrollbar-width:auto] lg:[-ms-overflow-style:auto] lg:[&::-webkit-scrollbar]:block"
class="w-full lg:max-w-[20cm] mx-auto flex flex-col flex-1 gap-0 lg:gap-[clamp(1.5rem,3vw,3rem)] overflow-y-auto overflow-x-hidden"
>
${this._initialChildren}
</div>
+10 -2
View File
@@ -40,6 +40,11 @@ export class MobileNavBar extends LitElement {
}
render() {
const currentPage = (window as any).currentPageId ?? "page-play";
if (!(window as any).currentPageId) {
(window as any).currentPageId = currentPage;
}
return html`
<!-- Border Segments (Custom right border with gap for button) -->
<div
@@ -52,7 +57,7 @@ export class MobileNavBar extends LitElement {
></div>
<div
class="flex-1 w-full flex flex-col justify-start overflow-y-auto md:pt-[clamp(1rem,3vh,4rem)] md:pb-[clamp(0.5rem,2vh,2rem)] md:px-[clamp(1rem,1.5vw,2rem)] p-5 gap-[clamp(1rem,3vh,3rem)]"
class="flex-1 w-full flex flex-col justify-start overflow-y-auto lg:pt-[clamp(1rem,3vh,4rem)] lg:pb-[clamp(0.5rem,2vh,2rem)] lg:px-[clamp(1rem,1.5vw,2rem)] p-5 gap-[clamp(1rem,3vh,3rem)]"
>
<!-- Logo + Menu -->
<div
@@ -110,7 +115,10 @@ export class MobileNavBar extends LitElement {
</div>
<!-- Mobile Navigation Menu Items -->
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] ${currentPage ===
"page-play"
? "active"
: ""}"
data-page="page-play"
data-i18n="main.play"
></button>
+112 -111
View File
@@ -11,136 +11,137 @@ export class PlayPage extends LitElement {
return html`
<div
id="page-play"
class="flex flex-col gap-2 w-full max-w-6xl mx-auto px-0 sm:px-4 transition-all duration-300 my-auto min-h-0"
class="flex flex-col gap-2 w-full lg:max-w-6xl mx-auto px-0 lg:px-4 lg:my-auto min-h-0"
>
<token-login class="absolute"></token-login>
<!-- Header / Identity Section -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-2 lg:gap-6 w-full">
<!-- Mobile: Fixed top bar -->
<div
class="lg:hidden fixed left-0 right-0 top-0 z-40 pt-[env(safe-area-inset-top)] bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-b border-white/10"
>
<div
class="lg:col-span-9 flex flex-row flex-nowrap gap-x-2 h-[60px] items-center bg-slate-900/80 backdrop-blur-md p-3 rounded-xl relative z-20 text-sm sm:text-base shrink-0"
class="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center h-14 px-2 gap-2"
>
<!-- Flag -->
<div
class="h-[40px] sm:h-[50px] shrink-0 aspect-[4/3] flex items-center justify-center lg:hidden"
<button
id="hamburger-btn"
class="col-start-1 justify-self-start h-10 shrink-0 aspect-[4/3] flex text-white/90 rounded-md items-center justify-center transition-colors"
data-i18n-aria-label="main.menu"
aria-expanded="false"
aria-controls="sidebar-menu"
aria-haspopup="dialog"
data-i18n-title="main.menu"
>
<!-- Hamburger (Mobile) -->
<button
id="hamburger-btn"
class="lg:hidden flex w-full h-full bg-slate-800/40 text-white/90 hover:bg-slate-700/40 p-0 rounded-md items-center justify-center cursor-pointer transition-all duration-200"
data-i18n-aria-label="main.menu"
aria-expanded="false"
aria-controls="sidebar-menu"
aria-haspopup="dialog"
data-i18n-title="main.menu"
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-8"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-8 h-8"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</button>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</button>
<div
class="col-start-2 flex items-center justify-center text-[#2563eb] min-w-0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1364 259"
fill="currentColor"
class="h-6 w-auto drop-shadow-[0_0_10px_rgba(37,99,235,0.3)] shrink-0"
>
<path
d="M0,174V51h15.24v-17.14h16.81v-16.98h16.96V0h1266v17.23h17.13v16.81h16.98v16.96h14.88v123h-15.13v17.08h-17.08v17.08h-16.9v17.04H324.9v16.86h-16.9v16.95h-102v-17.12h-17.07v-17.05H48.73v-17.05h-16.89v-16.89H14.94v-16.89H0ZM1297.95,17.35H65.9v16.7h-17.08v17.08h-14.5v123.08h14.85v16.9h17.08v17.08h139.9v17.08h17.08v16.36h67.9v-16.72h17.08v-17.07h989.88v-17.07h17.08v-16.9h14.44V50.8h-14.75v-17.08h-16.9v-16.37Z"
/>
<path
d="M189.1,154.78v17.07h-16.9v16.75h-51.07v-16.42h-16.9v-17.07h-16.97v-84.88h16.63v-17.07h16.9v-16.84h51.07v16.5h17.07v17.07h16.7v84.89h-16.54ZM137.87,53.1v17.15h-16.6v84.86h16.97v16.61h16.89v-16.97h16.6v-84.86h-16.97v-16.79h-16.89Z"
/>
<path
d="M273.91,104.06v-16.73h50.92v16.45h16.85v68.05h-16.44v17.06h-50.96v16.88h16.4v16.96h-67.28v-16.61h16.33v-101.86h-16.38v-16.98h33.4v16.63c6.12,0,11.72,0,17.31,0,0,22.56,0,45.13,0,67.75h33.59v-67.61h-33.73Z"
/>
<path
d="M631.12,188.64v-16.36h16.53V53.2h-16.25v-16.86h118.33v33.29h-16.65v-16.36h-50.72v50.44h33.36v-16.35h16.99v50.25h-16.6v-16.33h-33.73v50.65h16.37v16.72h-67.63Z"
/>
<path
d="M596.78,103.8v84.94h-33.54v-84.39h-34.03v84.25h-33.85v-101.29h84.5v16.49h16.93Z"
/>
<path
d="M1107.12,188.71v-84.34h-34.03v84.37h-33.7v-101.41h84.42v16.41h16.86v84.96h-33.54Z"
/>
<path
d="M988.1,171.78v16.87h-67.88v-16.38h-16.87v-68.06h16.38v-16.87h68.06v16.38h16.87v68.06h-16.55ZM970.78,104.35h-33.39v67.38h33.39v-67.38Z"
/>
<path
d="M460.77,155.38v16.49h-16.58v16.83h-68.05v-16.5h-16.83v-68.05h16.49v-16.83h68.05v16.49h16.83v34.06h-67.31v33.82h33.47v-16.31h33.92ZM393.39,104.18v16.56h33.3v-16.56h-33.3Z"
/>
<path
d="M1209.13,172h-16.9v-67.9h-16.96v-16.9h16.68v-17.08h16.9v-16.82h16.9v33.58h50.98v16.91h-50.4v67.96h16.48v-16.43h50.95v16.54h-16.55v16.76h-68.08v-16.6Z"
/>
<path
d="M834.91,120.94v16.96h-16.65v33.88h16.41v16.96h-67.29v-16.63h16.34v-67.87h-16.4v-16.97h50.42v33.81h17.3l-.14-.14Z"
/>
<path
d="M835.05,121.08v-33.75h33.76v16.43h16.85v33.96h-33.43v-16.79c-6.13,0-11.73,0-17.32,0,0,0,.14.14.14.14Z"
/>
</svg>
</div>
<!-- Username -->
<div class="flex-1 min-w-0 h-[40px] sm:h-[50px] flex items-center">
<username-input
class="relative w-full h-full block text-ellipsis overflow-hidden whitespace-nowrap"
></username-input>
</div>
<!-- Pattern button (Mobile - inside bar, Desktop - hidden here) -->
<pattern-input
id="pattern-input-mobile"
show-select-label
class="aspect-square h-[50px] sm:h-[50px] lg:hidden shrink-0"
></pattern-input>
<div
aria-hidden="true"
class="col-start-3 justify-self-end h-10 shrink-0 aspect-[4/3]"
></div>
</div>
</div>
<!-- Pattern & Flag buttons (Desktop only - separate column) -->
<div class="hidden lg:flex lg:col-span-3">
<div class="w-full h-[60px] flex gap-2">
<div
class="w-full pb-4 lg:pb-0 flex flex-col gap-0 lg:grid lg:grid-cols-12 lg:gap-2"
>
<!-- Mobile: spacer for fixed top bar -->
<div class="lg:hidden h-[calc(env(safe-area-inset-top)+56px)]"></div>
<!-- Mobile: profile/setup controls -->
<div
class="lg:hidden px-2 py-2 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-y border-white/10 flex flex-col gap-2 overflow-visible"
>
<div class="flex items-center gap-2 min-w-0">
<username-input class="flex-1 min-w-0 h-10"></username-input>
<pattern-input
id="pattern-input-desktop"
id="pattern-input-mobile"
show-select-label
class="flex-1 h-full"
adaptive-size
class="shrink-0"
></pattern-input>
<flag-input
id="flag-input-desktop"
show-select-label
class="flex-1 h-full"
></flag-input>
</div>
</div>
</div>
<!-- Primary Game Actions Area -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 w-full">
<!-- Left Column: Featured Lobbies / Quick Play -->
<div class="lg:col-span-9 flex flex-col gap-6 min-w-0">
<!-- Public Lobby Card -->
<public-lobby
class="block w-full transition-all duration-[50ms]"
></public-lobby>
</div>
<!-- Right Column: Custom Games & Modes -->
<div class="lg:col-span-3">
<div
class="group relative isolate flex flex-col w-full h-40 lg:h-96 overflow-hidden rounded-2xl transition-all duration-300"
>
<div
class="h-full flex flex-col bg-slate-900/40 backdrop-blur-sm rounded-2xl overflow-hidden"
>
<div
class="py-2 bg-blue-900/20 text-center text-sm font-bold text-gray-300 uppercase tracking-widest"
data-i18n="host_modal.label"
></div>
<div class="flex-1 p-2 flex flex-row lg:flex-col gap-2">
<o-button
id="single-player"
data-i18n-title="main.solo"
translationKey="main.solo"
fill
class="flex-1 transition-transform"
></o-button>
<o-button
id="host-lobby-button"
data-i18n-title="main.create"
translationKey="main.create"
fill
secondary
class="flex-1 opacity-90 hover:opacity-100"
></o-button>
<o-button
id="join-private-lobby-button"
data-i18n-title="main.join"
translationKey="main.join"
fill
secondary
class="flex-1 opacity-90 hover:opacity-100"
></o-button>
</div>
</div>
</div>
</div>
<!-- Matchmaking Buttons (Full Width across entire grid) -->
<div class="lg:col-span-12 flex flex-col gap-6">
<matchmaking-button></matchmaking-button>
<!-- Desktop: Original layout -->
<div
class="hidden lg:flex lg:col-span-9 gap-x-2 h-[60px] items-center p-3 relative z-20 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] lg:rounded-xl"
>
<username-input class="flex-1 min-w-0 h-[50px]"></username-input>
</div>
<div class="hidden lg:flex lg:col-span-3 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>
</div>
</div>
<game-mode-selector></game-mode-selector>
</div>
`;
}
+197
View File
@@ -0,0 +1,197 @@
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../../core/ApiSchemas";
import { getUserMe, hasLinkedAccount } from "../Api";
import { userAuth } from "../Auth";
import { translateText } from "../Utils";
import { BaseModal } from "./BaseModal";
import { modalHeader } from "./ui/ModalHeader";
@customElement("ranked-modal")
export class RankedModal extends BaseModal {
@state() private elo: number | string = "...";
@state() private userMeResponse: UserMeResponse | false = false;
@state() private errorMessage: string | null = null;
constructor() {
super();
this.id = "page-ranked";
}
connectedCallback() {
super.connectedCallback();
document.addEventListener(
"userMeResponse",
this.handleUserMeResponse as EventListener,
);
}
disconnectedCallback() {
document.removeEventListener(
"userMeResponse",
this.handleUserMeResponse as EventListener,
);
super.disconnectedCallback();
}
private handleUserMeResponse = (
event: CustomEvent<UserMeResponse | false>,
) => {
this.errorMessage = null;
this.userMeResponse = event.detail;
this.updateElo();
};
private updateElo() {
if (this.errorMessage) {
this.elo = translateText("map_component.error");
return;
}
if (hasLinkedAccount(this.userMeResponse)) {
this.elo =
this.userMeResponse &&
this.userMeResponse.player.leaderboard?.oneVone?.elo
? this.userMeResponse.player.leaderboard.oneVone.elo
: translateText("matchmaking_modal.no_elo");
}
}
protected override async onOpen(): Promise<void> {
this.elo = "...";
this.errorMessage = null;
try {
const userMe = await getUserMe();
this.userMeResponse = userMe;
} catch (error) {
console.error("Failed to fetch user profile for ranked modal", error);
this.userMeResponse = false;
this.errorMessage = translateText("map_component.error");
this.elo = translateText("map_component.error");
} finally {
this.updateElo();
}
}
createRenderRoot() {
return this;
}
render() {
const content = html`
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("mode_selector.ranked_title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
<div class="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
${this.renderCard(
translateText("mode_selector.ranked_1v1_title"),
this.errorMessage ??
(hasLinkedAccount(this.userMeResponse)
? translateText("matchmaking_modal.elo", { elo: this.elo })
: translateText("mode_selector.ranked_title")),
() => this.handleRanked(),
)}
${this.renderDisabledCard(
translateText("mode_selector.ranked_2v2_title"),
translateText("mode_selector.coming_soon"),
)}
${this.renderDisabledCard(
translateText("mode_selector.coming_soon"),
"",
)}
${this.renderDisabledCard(
translateText("mode_selector.coming_soon"),
"",
)}
</div>
</div>
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal ?hideHeader=${true} ?hideCloseButton=${true}>
${content}
</o-modal>
`;
}
private renderCard(title: string, subtitle: string, onClick: () => void) {
return html`
<button
@click=${onClick}
class="flex flex-col w-full h-28 sm:h-32 rounded-2xl bg-[color-mix(in_oklab,var(--frenchBlue)_70%,black)] border-0 transition-transform hover:scale-[1.02] active:scale-[0.98] p-6 items-center justify-center gap-3"
>
<div class="flex flex-col items-center gap-1 text-center">
<h3
class="text-lg sm:text-xl font-bold text-white uppercase tracking-widest leading-tight"
>
${title}
</h3>
<p
class="text-xs text-white/60 uppercase tracking-wider whitespace-pre-line leading-tight"
>
${subtitle}
</p>
</div>
</button>
`;
}
private renderDisabledCard(title: string, subtitle: string) {
return html`
<div
class="group relative isolate flex flex-col w-full h-28 sm:h-32 overflow-hidden rounded-2xl bg-slate-900/40 backdrop-blur-md border-0 shadow-none p-6 items-center justify-center gap-3 opacity-50 cursor-not-allowed"
>
<div class="flex flex-col items-center gap-1 text-center">
<h3
class="text-lg sm:text-xl font-bold text-white/60 uppercase tracking-widest leading-tight"
>
${title}
</h3>
<p
class="text-xs text-white/40 uppercase tracking-wider whitespace-pre-line leading-tight"
>
${subtitle}
</p>
</div>
</div>
`;
}
private async handleRanked() {
if ((await userAuth()) === false) {
this.close();
window.showPage?.("page-account");
return;
}
const usernameInput = document.querySelector("username-input") as any;
if (
usernameInput &&
typeof usernameInput.isValid === "function" &&
!usernameInput.isValid()
) {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: usernameInput.validationError,
color: "red",
duration: 3000,
},
}),
);
return;
}
document.dispatchEvent(new CustomEvent("open-matchmaking"));
}
}
@@ -67,7 +67,7 @@ export class OModal extends LitElement {
const wrapperClass = this.inline
? "relative flex flex-col w-full h-full m-0 max-w-full max-h-none shadow-none"
: `relative flex flex-col w-[90%] min-w-[400px] max-w-[900px] m-8 rounded-lg shadow-[0_20px_60px_rgba(0,0,0,0.8)] max-h-[calc(100vh-4rem)] ${
: `relative flex flex-col w-full h-full lg:w-[90%] lg:h-auto lg:min-w-[400px] lg:max-w-[900px] lg:m-8 lg:rounded-lg shadow-[0_20px_60px_rgba(0,0,0,0.8)] lg:max-h-[calc(100vh-4rem)] ${
this.alwaysMaximized ? "h-auto" : ""
}`;
const wrapperStyle =
@@ -101,7 +101,7 @@ export class OModal extends LitElement {
</div>`
: html``}
<section
class="relative flex-1 min-h-0 p-[1.4rem] text-white bg-[#23232382] backdrop-blur-md rounded-lg overflow-y-auto"
class="relative flex-1 min-h-0 p-0 lg:p-[1.4rem] text-white bg-[#23232382] backdrop-blur-md lg:rounded-lg overflow-y-auto"
>
<slot></slot>
</section>
@@ -48,9 +48,9 @@ export class SettingSlider extends LitElement {
return html`
<div
class="flex flex-row items-center justify-between w-full p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all gap-4 ${rainbowClass}"
class="flex flex-col sm:flex-row sm:items-center sm:justify-between w-full p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all gap-3 sm:gap-4 ${rainbowClass}"
>
<div class="flex flex-col flex-1 min-w-0 mr-4">
<div class="flex flex-col flex-1 min-w-0 sm:mr-4">
<label class="text-white font-bold text-base block mb-1"
>${this.label}</label
>
@@ -59,21 +59,28 @@ export class SettingSlider extends LitElement {
</div>
</div>
<div class="flex flex-col items-end gap-2 shrink-0 w-[200px]">
<span class="text-white font-bold text-sm">${this.value}%</span>
<input
type="range"
class="w-full appearance-none h-2 bg-transparent rounded outline-none
<div
class="flex flex-col items-start sm:items-end gap-2 shrink-0 w-full sm:w-[200px]"
>
<div class="flex items-center gap-2 w-full">
<input
type="range"
class="flex-1 w-auto appearance-none h-2 bg-transparent rounded outline-none
[&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded [&::-webkit-slider-runnable-track]:bg-[image:linear-gradient(to_right,#3b82f6_0%,#3b82f6_var(--fill),rgba(255,255,255,0.1)_var(--fill),rgba(255,255,255,0.1)_100%)]
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-[18px] [&::-webkit-slider-thumb]:w-[18px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-500 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:-mt-[6px] [&::-webkit-slider-thumb]:shadow-[0_0_0_4px_rgba(59,130,246,0.2)] [&::-webkit-slider-thumb]:transition-all active:[&::-webkit-slider-thumb]:scale-110 active:[&::-webkit-slider-thumb]:shadow-[0_0_0_6px_rgba(59,130,246,0.3)]
[&::-moz-range-track]:h-2 [&::-moz-range-track]:rounded [&::-moz-range-track]:bg-white/10
[&::-moz-range-progress]:h-2 [&::-moz-range-progress]:rounded [&::-moz-range-progress]:bg-blue-500
[&::-moz-range-thumb]:h-[18px] [&::-moz-range-thumb]:w-[18px] [&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-blue-500 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:shadow-[0_0_0_4px_rgba(59,130,246,0.2)] [&::-moz-range-thumb]:transition-all active:[&::-moz-range-thumb]:scale-110 active:[&::-moz-range-thumb]:shadow-[0_0_0_6px_rgba(59,130,246,0.3)]"
min=${this.min}
max=${this.max}
.value=${String(this.value)}
@input=${this.handleInput}
/>
min=${this.min}
max=${this.max}
.value=${String(this.value)}
@input=${this.handleInput}
/>
<span
class="text-white font-bold text-sm shrink-0 text-right min-w-[3ch]"
>${this.value}%</span
>
</div>
</div>
</div>
`;
@@ -72,8 +72,8 @@ export class GameList extends LitElement {
>
${translateText("game_list.mode")}:
${game.mode === GameMode.FFA
? translateText("game_list.mode_ffa")
: html`${translateText("game_list.mode_team")}`}
? translateText("game_mode.ffa")
: html`${translateText("game_mode.teams")}`}
</div>
</div>
</div>
@@ -50,8 +50,8 @@ export class PlayerStatsTreeView extends LitElement {
private labelForMode(m: GameMode) {
return m === GameMode.FFA
? translateText("player_stats_tree.mode_ffa")
: translateText("player_stats_tree.mode_team");
? translateText("game_mode.ffa")
: translateText("game_mode.teams");
}
createRenderRoot() {
+2 -2
View File
@@ -15,13 +15,13 @@ export interface ModalHeaderProps {
const DEFAULT_WRAPPER_CLASS = "flex flex-wrap items-center gap-2 shrink-0";
const DEFAULT_DIVIDER_CLASS = "border-b border-white/10";
const DEFAULT_PADDING_CLASS = "p-6";
const DEFAULT_PADDING_CLASS = "p-4 lg:p-6";
const DEFAULT_LEFT_CLASS = "flex items-center gap-4 flex-1";
const DEFAULT_BUTTON_CLASS =
"group flex items-center justify-center w-10 h-10 rounded-full shrink-0 " +
"bg-white/5 hover:bg-white/10 transition-all border border-white/10";
const DEFAULT_TITLE_CLASS =
"text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase " +
"text-white text-xl lg:text-2xl font-bold uppercase " +
"tracking-widest break-words hyphens-auto";
const withClasses = (...classes: Array<string | undefined>) =>
+1 -1
View File
@@ -93,7 +93,7 @@ export class WinModal extends LitElement implements Layer {
@click=${this.hide}
class="flex-1 px-3 py-3 text-base cursor-pointer bg-blue-500/60 text-white border-0 rounded-sm transition-all duration-200 hover:bg-blue-500/80 hover:-translate-y-px active:translate-y-px"
>
${this.game.myPlayer()?.isAlive()
${this.game?.myPlayer()?.isAlive()
? translateText("win_modal.keep")
: translateText("win_modal.spectate")}
</button>
+25 -2
View File
@@ -57,8 +57,24 @@ body {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
scrollbar-width: auto;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
/* Hide scrollbar on mobile, show on larger screens */
@media (max-width: 1023px) {
* {
scrollbar-width: none;
-ms-overflow-style: none;
}
::-webkit-scrollbar {
display: none;
}
}
@media (min-width: 1024px) {
* {
scrollbar-width: auto;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
}
/* Add custom scrollbar styles */
@@ -66,6 +82,13 @@ body {
width: 12px;
}
@media (min-width: 1024px) {
::-webkit-scrollbar {
width: 12px;
display: block;
}
}
::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
+14 -8
View File
@@ -6,22 +6,28 @@
--boxBackgroundColor: #111827cc;
--fontColor: #202020;
--fontColorLight: #fff;
--primaryColor: #2563eb;
--primaryColorHover: #1d4ed8;
/* Palette: Deep French Blue / Muted Cyan / Black / Forest Teal */
--frenchBlue: #1f3a70; /* Deeper French Blue */
--cyanBlue: #0f6ca3; /* Muted Cyan secondary */
--tealAccent: #1f6c5a; /* Darker Teal accent */
--primaryColor: var(--frenchBlue);
--primaryColorHover: var(--tealAccent);
--primaryColorDisabled: linear-gradient(
to right,
rgb(74, 74, 74),
rgb(61, 61, 61)
);
--secondaryColor: #dbeafe;
--secondaryColorHover: #bfdbfe;
--secondaryColor: var(--cyanBlue);
--secondaryColorHover: var(--cyanBlue);
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
--primaryColorDark: #3b82f6;
--primaryColorHoverDark: #2563eb;
--primaryColorDark: var(--frenchBlue);
--primaryColorHoverDark: var(--tealAccent);
--primaryColorDisabledDark: #4b5563;
--secondaryColorDark: #374151;
--secondaryColorHoverDark: #4b5563;
--secondaryColorDark: var(--tealAccent);
--secondaryColorHoverDark: var(--frenchBlue);
--fontColorDark: #f3f4f6;
/* Achievements */
+1 -1
View File
@@ -25,7 +25,7 @@ export class DevServerConfig extends DefaultServerConfig {
}
gameCreationRate(): number {
return 5 * 1000;
return 15 * 1000;
}
numWorkers(): number {