import { html, LitElement, nothing, type TemplateResult } from "lit"; import { customElement, state } from "lit/decorators.js"; import { ClientEnv } from "src/client/ClientEnv"; import { Duos, GameMapType, GameMode, HumansVsNations, Quads, Trios, } from "../core/game/Game"; import { PublicGameInfo, PublicGames } from "../core/Schemas"; import "./components/IOSAddToHomeScreenBanner"; import { crazyGamesSDK } from "./CrazyGamesSDK"; 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 { UsernameInput } from "./UsernameInput"; import { calculateServerTimeOffset, getMapName, getModifierLabels, getSecondsUntilServerTimestamp, renderDuration, translateText, } from "./Utils"; const CARD_BG = "bg-surface"; @customElement("game-mode-selector") export class GameModeSelector extends LitElement { @state() private lobbies: PublicGames | null = null; @state() private mapAspectRatios: Map = new Map(); @state() private inputValid: boolean = true; private serverTimeOffset: number = 0; private defaultLobbyTime: number = 0; private lobbySocket = new PublicLobbySocket((lobbies) => this.handleLobbiesUpdate(lobbies), ); createRenderRoot() { return this; } // Silent backstop; the buttons are already disabled while input is invalid. private validateUsername(): boolean { const usernameInput = document.querySelector( "username-input", ) as UsernameInput | null; return usernameInput ? usernameInput.canPlay() : true; } connectedCallback() { super.connectedCallback(); this.lobbySocket.start(); this.defaultLobbyTime = ClientEnv.gameCreationRate() / 1000; window.addEventListener( "username-validity-change", this.handleValidityChange, ); // Pick up the current value in case username-input validated before us. const usernameInput = document.querySelector( "username-input", ) as UsernameInput | null; if (usernameInput) { this.inputValid = usernameInput.canPlay(); } } disconnectedCallback() { this.stop(); window.removeEventListener( "username-validity-change", this.handleValidityChange, ); super.disconnectedCallback(); } private handleValidityChange = (e: Event) => { this.inputValid = (e as CustomEvent).detail?.isValid ?? true; }; public stop() { this.lobbySocket.stop(); } private handleLobbiesUpdate(lobbies: PublicGames) { this.lobbies = lobbies; this.serverTimeOffset = calculateServerTimeOffset(lobbies.serverTime); document.dispatchEvent( new CustomEvent("public-lobbies-update", { detail: { payload: lobbies }, }), ); this.requestUpdate(); const allGames = Object.values(lobbies.games ?? {}).flat(); for (const game of allGames) { const mapType = game.gameConfig?.gameMap as GameMapType; if (mapType && !this.mapAspectRatios.has(mapType)) { // New Map reference triggers Lit reactivity; placeholder ratio 1 lets // has() guard against duplicate in-flight fetches. this.mapAspectRatios = new Map(this.mapAspectRatios).set(mapType, 1); terrainMapFileLoader .getMapData(mapType) .manifest() .then((m: any) => { if (m?.map?.width && m?.map?.height) { this.mapAspectRatios = new Map(this.mapAspectRatios).set( mapType, m.map.width / m.map.height, ); } }) .catch((e) => console.error(`Failed to load manifest for ${mapType}`, e), ); } } } render() { const ffa = this.lobbies?.games?.["ffa"]?.[0]; const teams = this.lobbies?.games?.["team"]?.[0]; const special = this.lobbies?.games?.["special"]?.[0]; return html`
${this.renderSmallActionCard( translateText("main.solo"), this.openSinglePlayerModal, "bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 hover:scale-y-105 hover:scale-x-[1.01]", )}
${this.renderSmallActionCard( translateText("main.create"), this.openHostLobby, "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]", )} ${!crazyGamesSDK.isOnCrazyGames() ? this.renderSmallActionCard( translateText("mode_selector.ranked_title"), this.openRankedMenu, "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]", ) : html``} ${this.renderSmallActionCard( translateText("main.join"), this.openJoinLobby, "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]", )}
${this.lobbies === null ? html`
` : html`
${ffa ? html`` : nothing}
${special ? this.renderSpecialLobbyCard(special) : nothing}
${ffa ? this.renderLobbyCard(ffa, this.getLobbyTitle(ffa)) : nothing}
${teams ? this.renderLobbyCard(teams, this.getLobbyTitle(teams)) : nothing}
`}
`; } private renderSpecialLobbyCard(lobby: PublicGameInfo) { return this.renderLobbyCard(lobby, this.getLobbyTitle(lobby)); } 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, bgClass: string = CARD_BG, ) { return html` `; } private renderLobbyCard( lobby: PublicGameInfo, titleContent: string | TemplateResult, ) { const mapType = lobby.gameConfig!.gameMap as GameMapType; const mapImageSrc = terrainMapFileLoader.getMapData(mapType).webpPath; const aspectRatio = this.mapAspectRatios.get(mapType); // Use object-contain for extreme aspect ratios (e.g. Amazon River ~20:1) so // the full map is visible instead of being cropped by object-cover. const useContain = aspectRatio !== undefined && (aspectRatio > 4 || aspectRatio < 0.25); const timeRemaining = lobby.startsAt ? getSecondsUntilServerTimestamp(lobby.startsAt, this.serverTimeOffset) : undefined; let timeDisplay: string; let timeDisplayUppercase = false; if (timeRemaining === undefined) { timeDisplay = renderDuration(this.defaultLobbyTime); } else if (timeRemaining > 0) { timeDisplay = renderDuration(timeRemaining); } else { timeDisplay = translateText("public_lobby.starting_game"); timeDisplayUppercase = true; } const mapName = getMapName(lobby.gameConfig?.gameMap); const modifierLabels = 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 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 formatTeamsOf(teamCount, 2); } case Trios: { const teamCount = totalPlayers ? Math.floor(totalPlayers / 3) : undefined; return formatTeamsOf(teamCount, 3); } case Quads: { const teamCount = totalPlayers ? Math.floor(totalPlayers / 4) : undefined; return formatTeamsOf(teamCount, 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 ""; } }