mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
Homepage update & add 3 public lobbies (#3191)
## Description: Update UI check https://homepageupdate.openfront.dev/ Improved mobile UI (now fills whole screen for all modals) e.g.: <img width="432" height="852" alt="image" src="https://github.com/user-attachments/assets/56de40af-4137-4c57-96b7-3910c9a665b8" /> Converted PublicLobby to be "GameModeSelector" to get a nicer 4x4 grid div, where <GameModeSelector> now handles all the username validation now (removed redundant code from modals such as matchmaking) also fixed a bug where someone could have "[XX] X" as thier username (when the minimum should be 3 chars for their name) Now visually displays the 3 lobbies ffa/team/special (which is a continuation from the work done in: #3196 ) <img width="818" height="563" alt="image" src="https://github.com/user-attachments/assets/a15cd31b-6061-4fb8-83ee-ffde6225cfa7" /> updated the background: <img width="1919" height="807" alt="image" src="https://github.com/user-attachments/assets/358a7434-51b8-4540-baf2-d1be05053c44" /> slightly updated the glassy-look to be less glassy: <img width="825" height="729" alt="image" src="https://github.com/user-attachments/assets/1801871b-bbf8-43db-ac53-489337ae80a5" /> ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n
This commit is contained in:
+34
-9
@@ -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("/images/EuropeBackgroundBlurred.webp");
|
||||
"
|
||||
></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("/resources/images/background.png");
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-center bg-no-repeat bg-contain hidden lg:block"
|
||||
style="
|
||||
background-image: url("/resources/images/OpenFront.png");
|
||||
opacity: 0.25;
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-center bg-no-repeat bg-contain lg:hidden"
|
||||
style="
|
||||
background-image: url("/resources/images/OF.png");
|
||||
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 -->
|
||||
|
||||
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
@@ -356,7 +356,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)",
|
||||
@@ -377,7 +376,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",
|
||||
@@ -391,7 +391,6 @@
|
||||
},
|
||||
"host_modal": {
|
||||
"title": "Create Private Lobby",
|
||||
"label": "Private",
|
||||
"mode": "Mode",
|
||||
"team_count": "Number of Teams",
|
||||
"team_type": "Team Type",
|
||||
@@ -454,6 +453,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",
|
||||
@@ -931,8 +940,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",
|
||||
@@ -949,8 +956,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": {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -180,7 +180,7 @@ export class GameInfoModal extends LitElement {
|
||||
try {
|
||||
const mapType = gameMap as GameMapType;
|
||||
const data = terrainMapFileLoader.getMapData(mapType);
|
||||
this.mapImage = await data.webpPath();
|
||||
this.mapImage = data.webpPath;
|
||||
} catch (error) {
|
||||
console.error("Failed to load map image:", error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { html, LitElement, nothing, type TemplateResult } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import {
|
||||
Duos,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
HumansVsNations,
|
||||
PublicGameModifiers,
|
||||
Quads,
|
||||
Trios,
|
||||
} from "../core/game/Game";
|
||||
import { PublicGameInfo, PublicGames } from "../core/Schemas";
|
||||
import { HostLobbyModal } from "./HostLobbyModal";
|
||||
import { JoinLobbyModal } from "./JoinLobbyModal";
|
||||
import { PublicLobbySocket } from "./LobbySocket";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { SinglePlayerModal } from "./SinglePlayerModal";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
import { getMapName, 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 serverTimeOffset: number = 0;
|
||||
|
||||
private lobbySocket = new PublicLobbySocket((lobbies) =>
|
||||
this.handleLobbiesUpdate(lobbies),
|
||||
);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.stop();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.lobbySocket.stop();
|
||||
}
|
||||
|
||||
private handleLobbiesUpdate(lobbies: PublicGames) {
|
||||
this.lobbies = lobbies;
|
||||
this.serverTimeOffset = lobbies.serverTime - Date.now();
|
||||
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 ? this.renderLobbyCard(ffa, this.getLobbyTitle(ffa)) : nothing}
|
||||
${teams
|
||||
? this.renderLobbyCard(teams, this.getLobbyTitle(teams))
|
||||
: nothing}
|
||||
${special ? this.renderSpecialLobbyCard(special) : 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;
|
||||
window.showPage?.("page-ranked");
|
||||
};
|
||||
|
||||
private openSinglePlayerModal = () => {
|
||||
if (!this.validateUsername()) return;
|
||||
(
|
||||
document.querySelector("single-player-modal") as SinglePlayerModal
|
||||
)?.open();
|
||||
};
|
||||
|
||||
private openHostLobby = () => {
|
||||
if (!this.validateUsername()) return;
|
||||
(document.querySelector("host-lobby-modal") as HostLobbyModal)?.open();
|
||||
};
|
||||
|
||||
private openJoinLobby = () => {
|
||||
if (!this.validateUsername()) return;
|
||||
(document.querySelector("join-lobby-modal") as JoinLobbyModal)?.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 renderLobbyCard(
|
||||
lobby: PublicGameInfo,
|
||||
titleContent: string | TemplateResult,
|
||||
) {
|
||||
const mapType = lobby.gameConfig!.gameMap as GameMapType;
|
||||
const mapImageSrc = terrainMapFileLoader.getMapData(mapType).webpPath;
|
||||
const timeRemaining = Math.max(
|
||||
0,
|
||||
Math.floor((lobby.startsAt - this.serverTimeOffset - Date.now()) / 1000),
|
||||
);
|
||||
const timeDisplay = renderDuration(timeRemaining);
|
||||
const mapName = getMapName(lobby.gameConfig?.gameMap);
|
||||
|
||||
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 ?? lobby.gameConfig?.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 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 getLobbyTitle(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 "";
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
})}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import {
|
||||
getActiveModifiers,
|
||||
getGameModeLabel,
|
||||
normaliseMapKey,
|
||||
getMapName,
|
||||
renderDuration,
|
||||
renderNumber,
|
||||
translateText,
|
||||
@@ -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?: GameInfo | 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
|
||||
this.startTrackingLobby(lobbyId, lobbyInfo);
|
||||
// If opened with lobbyId but no lobbyInfo (URL join case), auto-join the lobby
|
||||
if (!lobbyInfo) {
|
||||
this.handleUrlJoin(lobbyId);
|
||||
}
|
||||
}
|
||||
@@ -326,21 +320,10 @@ 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,
|
||||
lobbyInfo?: GameInfo | PublicGameInfo,
|
||||
) {
|
||||
this.currentLobbyId = lobbyId;
|
||||
// clientID will be assigned by server via lobby_info message
|
||||
this.currentClientID = "";
|
||||
@@ -355,7 +338,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
if (lobbyInfo) {
|
||||
this.updateFromLobby(lobbyInfo);
|
||||
// Only stop showing spinner when we have player info
|
||||
if (lobbyInfo.clients) {
|
||||
if ("clients" in lobbyInfo && lobbyInfo.clients) {
|
||||
this.isConnecting = false;
|
||||
}
|
||||
}
|
||||
@@ -436,7 +419,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
if (!this.gameConfig) return html``;
|
||||
|
||||
const c = this.gameConfig;
|
||||
const mapName = translateText("map." + normaliseMapKey(c.gameMap));
|
||||
const mapName = getMapName(c.gameMap);
|
||||
const modeName = getGameModeLabel(c);
|
||||
const modifiers = getActiveModifiers(c.publicGameModifiers);
|
||||
|
||||
@@ -530,8 +513,8 @@ export class JoinLobbyModal extends BaseModal {
|
||||
|
||||
// --- Lobby event handling ---
|
||||
|
||||
private updateFromLobby(lobby: GameInfo) {
|
||||
this.players = lobby.clients ?? [];
|
||||
private updateFromLobby(lobby: GameInfo | PublicGameInfo) {
|
||||
this.players = "clients" in lobby ? (lobby.clients ?? []) : [];
|
||||
this.lobbyStartAt = lobby.startsAt ?? null;
|
||||
this.syncCountdownTimer();
|
||||
if (lobby.gameConfig) {
|
||||
@@ -542,7 +525,10 @@ export class JoinLobbyModal extends BaseModal {
|
||||
}
|
||||
}
|
||||
|
||||
this.lobbyCreatorClientID = lobby.lobbyCreatorClientID ?? null;
|
||||
this.lobbyCreatorClientID =
|
||||
"lobbyCreatorClientID" in lobby
|
||||
? (lobby.lobbyCreatorClientID ?? null)
|
||||
: null;
|
||||
}
|
||||
|
||||
private startLobbyUpdates() {
|
||||
|
||||
@@ -218,7 +218,7 @@ export class LangSelector extends LitElement {
|
||||
"help-modal",
|
||||
"settings-modal",
|
||||
"username-input",
|
||||
"public-lobby",
|
||||
"game-mode-selector",
|
||||
"user-setting",
|
||||
"o-modal",
|
||||
"o-button",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"),
|
||||
})}
|
||||
|
||||
|
||||
+33
-93
@@ -1,7 +1,13 @@
|
||||
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,
|
||||
GameInfo,
|
||||
GameRecord,
|
||||
GameStartInfo,
|
||||
PublicGameInfo,
|
||||
} from "../core/Schemas";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { GameType } from "../core/game/Game";
|
||||
@@ -17,6 +23,8 @@ import { FlagInput } from "./FlagInput";
|
||||
import "./FlagInputModal";
|
||||
import { FlagInputModal } from "./FlagInputModal";
|
||||
import { GameInfoModal } from "./GameInfoModal";
|
||||
import "./GameModeSelector";
|
||||
import { GameModeSelector } from "./GameModeSelector";
|
||||
import { GameStartingModal } from "./GameStartingModal";
|
||||
import "./GoogleAdElement";
|
||||
import { GutterAds } from "./GutterAds";
|
||||
@@ -32,9 +40,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 {
|
||||
@@ -55,6 +61,7 @@ import "./components/Footer";
|
||||
import "./components/MainLayout";
|
||||
import "./components/MobileNavBar";
|
||||
import "./components/PlayPage";
|
||||
import "./components/RankedModal";
|
||||
import "./components/baseComponents/Button";
|
||||
import "./components/baseComponents/Modal";
|
||||
import "./styles.css";
|
||||
@@ -203,15 +210,16 @@ declare global {
|
||||
BOLT_AD_CLICKED: string;
|
||||
SHOW_HIDDEN_CONTAINER: string;
|
||||
};
|
||||
currentPageId?: string;
|
||||
showPage?: (pageId: string) => void;
|
||||
}
|
||||
|
||||
// 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 +231,7 @@ export interface JoinLobbyEvent {
|
||||
// GameRecord exists when replaying an archived game.
|
||||
gameRecord?: GameRecord;
|
||||
source?: "public" | "private" | "host" | "matchmaking" | "singleplayer";
|
||||
publicLobbyInfo?: GameInfo | PublicGameInfo;
|
||||
}
|
||||
|
||||
class Client {
|
||||
@@ -236,21 +245,18 @@ class Client {
|
||||
|
||||
private hostModal: HostPrivateLobbyModal;
|
||||
private joinModal: JoinLobbyModal;
|
||||
private publicLobby: PublicLobby;
|
||||
private gameModeSelector: GameModeSelector;
|
||||
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,7 +299,9 @@ class Client {
|
||||
console.warn("Username input element not found");
|
||||
}
|
||||
|
||||
this.publicLobby = document.querySelector("public-lobby") as PublicLobby;
|
||||
this.gameModeSelector = document.querySelector(
|
||||
"game-mode-selector",
|
||||
) as GameModeSelector;
|
||||
|
||||
window.addEventListener("beforeunload", async () => {
|
||||
console.log("Browser is closing");
|
||||
@@ -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");
|
||||
@@ -773,8 +719,7 @@ class Client {
|
||||
(searchParams.toString() ? "?" + searchParams.toString() : "") +
|
||||
window.location.hash;
|
||||
history.replaceState(null, "", newUrl);
|
||||
// Wait for matchmaking button to be defined, then trigger its click handler
|
||||
// This goes through username validation instead of bypassing it
|
||||
// Wait for matchmaking button to be defined, then trigger its click handler.
|
||||
customElements.whenDefined("matchmaking-button").then(() => {
|
||||
const matchmakingButton = document.querySelector(
|
||||
"matchmaking-button button",
|
||||
@@ -798,12 +743,14 @@ 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") {
|
||||
this.updateJoinUrlForShare(lobby.gameID, config);
|
||||
}
|
||||
|
||||
this.gameStop = joinLobby(
|
||||
this.eventBus,
|
||||
{
|
||||
@@ -856,7 +803,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 +819,8 @@ class Client {
|
||||
}
|
||||
},
|
||||
() => {
|
||||
this.joinModal.close();
|
||||
this.publicLobby.stop();
|
||||
this.joinModal?.closeWithoutLeaving();
|
||||
this.gameModeSelector.stop();
|
||||
incrementGamesPlayed();
|
||||
|
||||
document.querySelectorAll(".ad").forEach((ad) => {
|
||||
@@ -915,17 +862,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 +882,10 @@ class Client {
|
||||
crazyGamesSDK.gameplayStop();
|
||||
}
|
||||
|
||||
private handleOpenMatchmaking(_event: CustomEvent<undefined>) {
|
||||
this.matchmakingModal?.open();
|
||||
}
|
||||
|
||||
private handleKickPlayer(event: CustomEvent) {
|
||||
const { target } = event.detail;
|
||||
|
||||
|
||||
+20
-67
@@ -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
|
||||
@@ -296,35 +274,10 @@ 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();
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: usernameInput?.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent("open-matchmaking"));
|
||||
}
|
||||
|
||||
private handleLoggedOutClick() {
|
||||
window.showPage?.("page-account");
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.matchmakingModal?.open();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.matchmakingModal?.close();
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
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;
|
||||
window.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)");
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -18,14 +18,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
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,8 +147,9 @@ 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", {
|
||||
min: MIN_USERNAME_LENGTH,
|
||||
|
||||
@@ -17,6 +17,11 @@ export function normaliseMapKey(mapName: string): string {
|
||||
return mapName.toLowerCase().replace(/[\s.]+/g, "");
|
||||
}
|
||||
|
||||
export function getMapName(mapName: string | undefined): string | null {
|
||||
if (!mapName) return null;
|
||||
return translateText(`map.${normaliseMapKey(mapName)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a display label for the game mode (e.g. "FFA", "4 Teams", "Duos").
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export class DesktopNavBar extends LitElement {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("showPage", this._onShowPage);
|
||||
|
||||
const current = (window as any).currentPageId;
|
||||
const current = window.currentPageId;
|
||||
if (current) {
|
||||
// Wait for render
|
||||
this.updateComplete.then(() => {
|
||||
@@ -79,9 +79,12 @@ export class DesktopNavBar extends LitElement {
|
||||
};
|
||||
|
||||
render() {
|
||||
window.currentPageId ??= "page-play";
|
||||
const currentPage = window.currentPageId;
|
||||
|
||||
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 +92,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 +134,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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,7 +11,7 @@ export class MobileNavBar extends LitElement {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("showPage", this._onShowPage);
|
||||
|
||||
const current = (window as any).currentPageId;
|
||||
const current = window.currentPageId;
|
||||
if (current) {
|
||||
this.updateComplete.then(() => {
|
||||
this._updateActiveState(current);
|
||||
@@ -40,6 +40,9 @@ export class MobileNavBar extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
window.currentPageId ??= "page-play";
|
||||
const currentPage = window.currentPageId;
|
||||
|
||||
return html`
|
||||
<!-- Border Segments (Custom right border with gap for button) -->
|
||||
<div
|
||||
@@ -52,7 +55,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 +113,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>
|
||||
|
||||
+106
-111
@@ -11,136 +11,131 @@ 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">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="col-start-3 justify-self-end h-10 shrink-0 aspect-[4/3]"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div
|
||||
class="px-2 py-2 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-y border-white/10 overflow-visible lg:col-span-9 lg:flex lg:items-center lg:gap-x-2 lg:h-[60px] lg:p-3 lg:relative lg:z-20 lg:border-y-0 lg:rounded-xl"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 w-full">
|
||||
<username-input
|
||||
class="relative w-full h-full block text-ellipsis overflow-hidden whitespace-nowrap"
|
||||
class="flex-1 min-w-0 h-10 lg:h-[50px]"
|
||||
></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>
|
||||
|
||||
<!-- 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">
|
||||
<pattern-input
|
||||
id="pattern-input-desktop"
|
||||
id="pattern-input-mobile"
|
||||
show-select-label
|
||||
class="flex-1 h-full"
|
||||
adaptive-size
|
||||
class="shrink-0 lg:hidden"
|
||||
></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>
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -50,7 +50,7 @@ export class MapDisplay extends LitElement {
|
||||
this.isLoading = true;
|
||||
const mapValue = GameMapType[this.mapKey as keyof typeof GameMapType];
|
||||
const data = terrainMapFileLoader.getMapData(mapValue);
|
||||
this.mapWebpPath = await data.webpPath();
|
||||
this.mapWebpPath = data.webpPath;
|
||||
const manifest = await data.manifest();
|
||||
this.mapName = manifest.name;
|
||||
this.hasNations =
|
||||
|
||||
@@ -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>) =>
|
||||
|
||||
+25
-2
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -54,9 +54,7 @@ export class BinaryLoaderGameMapLoader implements GameMapLoader {
|
||||
return res.json() as Promise<MapManifest>;
|
||||
}),
|
||||
),
|
||||
webpPath: this.createLazyLoader(() =>
|
||||
Promise.resolve(`${mapBasePath}/thumbnail.webp`),
|
||||
),
|
||||
webpPath: `${mapBasePath}/thumbnail.webp`,
|
||||
} satisfies MapData;
|
||||
|
||||
this.maps.set(map, mapData);
|
||||
|
||||
@@ -31,7 +31,7 @@ export class FetchGameMapLoader implements GameMapLoader {
|
||||
map4xBin: () => this.loadBinaryFromUrl(this.url(fileName, "map4x.bin")),
|
||||
map16xBin: () => this.loadBinaryFromUrl(this.url(fileName, "map16x.bin")),
|
||||
manifest: () => this.loadJsonFromUrl(this.url(fileName, "manifest.json")),
|
||||
webpPath: async () => this.url(fileName, "thumbnail.webp"),
|
||||
webpPath: this.url(fileName, "thumbnail.webp"),
|
||||
} satisfies MapData;
|
||||
|
||||
this.maps.set(map, mapData);
|
||||
|
||||
@@ -10,5 +10,5 @@ export interface MapData {
|
||||
map4xBin: () => Promise<Uint8Array>;
|
||||
map16xBin: () => Promise<Uint8Array>;
|
||||
manifest: () => Promise<MapManifest>;
|
||||
webpPath: () => Promise<string>;
|
||||
webpPath: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user