diff --git a/index.html b/index.html index b53683ca3a..7704ca8db3 100644 --- a/index.html +++ b/index.html @@ -122,12 +122,29 @@ -
+
+
+ +
+
@@ -140,10 +157,9 @@ @@ -163,7 +179,11 @@ - + diff --git a/resources/images/OF.png b/resources/images/OF.png new file mode 100644 index 0000000000..8b573105bb Binary files /dev/null and b/resources/images/OF.png differ diff --git a/resources/images/OpenFront.png b/resources/images/OpenFront.png new file mode 100644 index 0000000000..2ddd74dedd Binary files /dev/null and b/resources/images/OpenFront.png differ diff --git a/resources/images/background.png b/resources/images/background.png new file mode 100644 index 0000000000..7e2e90e73e Binary files /dev/null and b/resources/images/background.png differ diff --git a/resources/lang/en.json b/resources/lang/en.json index 7e0a7fb820..83160eb119 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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": { diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 8169e95669..6879218dca 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -61,18 +61,9 @@ export class AccountModal extends BaseModal { render() { const content = this.isLoadingUser - ? html` -
-
-

- ${translateText("account_modal.fetching_account")} -

-
- ` + ? 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` -
+
${modalHeader({ title, onBack: () => this.close(), diff --git a/src/client/FlagInput.ts b/src/client/FlagInput.ts index 9bf17fce66..b265ff5f3d 100644 --- a/src/client/FlagInput.ts +++ b/src/client/FlagInput.ts @@ -84,7 +84,7 @@ export class FlagInput extends LitElement { return html` + `; + } + + 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` + + `; + } + + 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 ""; + } +} diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index fde1e65451..95ba888807 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -99,14 +99,10 @@ export class HelpModal extends BaseModal { const keybinds = this.keybinds; const content = html` -
+
${modalHeader({ title: translateText("main.help"), - onBack: this.close, + onBack: () => this.close(), ariaLabel: translateText("common.back"), })} diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 71e661842e..978fa12ac5 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -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` -
+
${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( diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index cb0aafebd9..22e674fdab 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -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` -
+
${modalHeader({ title: translateText("public_lobby.title"), onBack: () => this.closeAndLeave(), @@ -149,7 +148,7 @@ export class JoinLobbyModal extends BaseModal { ${this.isPrivateLobby() ? html`
- ` : html` - `; - } - - 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, - }), - ); - } -} diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 950c2f6a61..20cfd79fb7 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -217,13 +217,11 @@ export class SinglePlayerModal extends BaseModal { ]; const content = html` -
+
${modalHeader({ title: translateText("main.solo") || "Solo", - onBack: this.close, + onBack: () => this.close(), ariaLabel: translateText("common.back"), rightContent: hasLinkedAccount(this.userMeResponse) ? html`
${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, diff --git a/src/client/Utils.ts b/src/client/Utils.ts index c4709f945b..285e717139 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -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"). */ diff --git a/src/client/components/BaseModal.ts b/src/client/components/BaseModal.ts index 0f7d7b2e4e..80e40d9001 100644 --- a/src/client/components/BaseModal.ts +++ b/src/client/components/BaseModal.ts @@ -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` +
+
+ ${message + ? html`

+ ${message} +

` + : ""} +
+ `; + } } diff --git a/src/client/components/DesktopNavBar.ts b/src/client/components/DesktopNavBar.ts index 52d033054a..77bd1e0e64 100644 --- a/src/client/components/DesktopNavBar.ts +++ b/src/client/components/DesktopNavBar.ts @@ -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`