import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { Difficulty, Duos, GameMapSize, GameMapType, GameMode, HumansVsNations, Quads, Trios, UnitType, mapCategories, } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import { ClientInfo, GameConfig, GameInfo, TeamCountConfig, } from "../core/Schemas"; import { generateID } from "../core/Util"; import "./components/baseComponents/Modal"; import "./components/Difficulties"; import "./components/FluentSlider"; import "./components/LobbyTeamView"; import "./components/Maps"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import { JoinLobbyEvent } from "./Main"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; import randomMap from "/images/RandomMap.webp?url"; @customElement("host-lobby-modal") export class HostLobbyModal extends LitElement { @query("o-modal") private modalEl!: HTMLElement & { open: () => void; close: () => void; onClose?: () => void; }; @state() private selectedMap: GameMapType = GameMapType.World; @state() private selectedDifficulty: Difficulty = Difficulty.Medium; @state() private disableNations = false; @state() private gameMode: GameMode = GameMode.FFA; @state() private teamCount: TeamCountConfig = 2; @state() private bots: number = 400; @state() private spawnImmunity: boolean = false; @state() private spawnImmunityDurationMinutes: number | undefined = undefined; @state() private infiniteGold: boolean = false; @state() private donateGold: boolean = false; @state() private infiniteTroops: boolean = false; @state() private donateTroops: boolean = false; @state() private maxTimer: boolean = false; @state() private maxTimerValue: number | undefined = undefined; @state() private instantBuild: boolean = false; @state() private randomSpawn: boolean = false; @state() private compactMap: boolean = false; @state() private lobbyId = ""; @state() private copySuccess = false; @state() private clients: ClientInfo[] = []; @state() private useRandomMap: boolean = false; @state() private disabledUnits: UnitType[] = []; @state() private lobbyCreatorClientID: string = ""; @state() private lobbyIdVisible: boolean = true; @state() private nationCount: number = 0; private playersInterval: NodeJS.Timeout | null = null; // Add a new timer for debouncing bot changes private botsUpdateTimer: number | null = null; private userSettings: UserSettings = new UserSettings(); private mapLoader = terrainMapFileLoader; connectedCallback() { super.connectedCallback(); window.addEventListener("keydown", this.handleKeyDown); } disconnectedCallback() { window.removeEventListener("keydown", this.handleKeyDown); super.disconnectedCallback(); } private handleKeyDown = (e: KeyboardEvent) => { if (e.code === "Escape") { e.preventDefault(); this.close(); } }; render() { return html` ${ this.lobbyIdVisible ? html` { this.lobbyIdVisible = !this.lobbyIdVisible; this.requestUpdate(); }} style="margin-right: 8px; cursor: pointer;" stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 512 512" height="18px" width="18px" xmlns="http://www.w3.org/2000/svg" > ` : html` { this.lobbyIdVisible = !this.lobbyIdVisible; this.requestUpdate(); }} style="margin-right: 8px; cursor: pointer;" stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 512 512" height="18px" width="18px" xmlns="http://www.w3.org/2000/svg" > ` } ${this.lobbyIdVisible ? this.lobbyId : "••••••••"} ${ this.copySuccess ? html`✓` : html` ` } ${translateText("map.map")} ${Object.entries(mapCategories).map( ([categoryKey, maps]) => html` ${translateText(`map_categories.${categoryKey}`)} ${maps.map((mapValue) => { const mapKey = Object.keys(GameMapType).find( (key) => GameMapType[key as keyof typeof GameMapType] === mapValue, ); return html` this.handleMapSelection(mapValue)} > `; })} `, )} ${translateText("map.random")} ${translateText("difficulty.difficulty")} ${Object.entries(Difficulty) .filter(([key]) => isNaN(Number(key))) .map( ([key, value]) => html` !this.disableNations && this.handleDifficultySelection(value)} > ${translateText(`difficulty.${key.toLowerCase()}`)} `, )} ${translateText("host_modal.mode")} this.handleGameModeSelection(GameMode.FFA)} > ${translateText("game_mode.ffa")} this.handleGameModeSelection(GameMode.Team)} > ${translateText("game_mode.teams")} ${ this.gameMode === GameMode.FFA ? "" : html` ${translateText("host_modal.team_count")} ${[ 2, 3, 4, 5, 6, 7, Quads, Trios, Duos, HumansVsNations, ].map( (o) => html` this.handleTeamCountSelection(o)} > ${typeof o === "string" ? o === HumansVsNations ? translateText("public_lobby.teams_hvn") : translateText(`host_modal.teams_${o}`) : translateText("public_lobby.teams", { num: o, })} `, )} ` } ${translateText("host_modal.options_title")} ${ !( this.gameMode === GameMode.Team && this.teamCount === HumansVsNations ) ? html` ${translateText("host_modal.disable_nations")} ` : "" } ${translateText("host_modal.instant_build")} ${translateText("host_modal.random_spawn")} ${translateText("host_modal.donate_gold")} ${translateText("host_modal.donate_troops")} ${translateText("host_modal.infinite_gold")} ${translateText("host_modal.infinite_troops")} ${translateText("host_modal.compact_map")} { const checked = (e.target as HTMLInputElement).checked; if (!checked) { this.maxTimerValue = undefined; } this.maxTimer = checked; this.putGameConfig(); }} .checked=${this.maxTimer} /> ${ this.maxTimer === false ? "" : html`` } ${translateText("host_modal.max_timer")} { const checked = (e.target as HTMLInputElement).checked; if (!checked) { this.spawnImmunityDurationMinutes = undefined; } this.spawnImmunity = checked; this.putGameConfig(); }} .checked=${this.spawnImmunity} /> ${ this.spawnImmunity === false ? "" : html`` } ${translateText("host_modal.player_immunity_duration")} ${translateText("host_modal.enables_title")} ${renderUnitTypeOptions({ disabledUnits: this.disabledUnits, toggleUnit: this.toggleUnit.bind(this), })} ${this.clients.length} ${ this.clients.length === 1 ? translateText("host_modal.player") : translateText("host_modal.players") } • ${this.getEffectiveNationCount()} ${ this.getEffectiveNationCount() === 1 ? translateText("host_modal.nation_player") : translateText("host_modal.nation_players") } this.kickPlayer(clientID)} > ${ this.clients.length === 1 ? translateText("host_modal.waiting") : translateText("host_modal.start") } `; } createRenderRoot() { return this; } public open() { this.lobbyCreatorClientID = generateID(); this.lobbyIdVisible = this.userSettings.get( "settings.lobbyIdVisibility", true, ); createLobby(this.lobbyCreatorClientID) .then((lobby) => { this.lobbyId = lobby.gameID; crazyGamesSDK.showInviteButton(this.lobbyId); }) .then(() => { this.dispatchEvent( new CustomEvent("join-lobby", { detail: { gameID: this.lobbyId, clientID: this.lobbyCreatorClientID, } as JoinLobbyEvent, bubbles: true, composed: true, }), ); }); this.modalEl?.open(); this.modalEl.onClose = () => { this.close(); }; this.playersInterval = setInterval(() => this.pollPlayers(), 1000); this.loadNationCount(); } public close() { console.log("Closing host lobby modal"); crazyGamesSDK.hideInviteButton(); this.modalEl?.close(); this.copySuccess = false; if (this.playersInterval) { clearInterval(this.playersInterval); this.playersInterval = null; } // Clear any pending bot updates if (this.botsUpdateTimer !== null) { clearTimeout(this.botsUpdateTimer); this.botsUpdateTimer = null; } } private async handleRandomMapToggle() { this.useRandomMap = true; this.selectedMap = this.getRandomMap(); await this.loadNationCount(); this.putGameConfig(); } private async handleMapSelection(value: GameMapType) { this.selectedMap = value; this.useRandomMap = false; await this.loadNationCount(); this.putGameConfig(); } private async handleDifficultySelection(value: Difficulty) { this.selectedDifficulty = value; this.putGameConfig(); } // Modified to include debouncing private handleBotsChange(e: Event) { const customEvent = e as CustomEvent<{ value: number }>; const value = customEvent.detail.value; if (isNaN(value) || value < 0 || value > 400) { return; } // Update the display value immediately this.bots = value; // Clear any existing timer if (this.botsUpdateTimer !== null) { clearTimeout(this.botsUpdateTimer); } // Set a new timer to call putGameConfig after 300ms of inactivity this.botsUpdateTimer = window.setTimeout(() => { this.putGameConfig(); this.botsUpdateTimer = null; }, 300); } private handleInstantBuildChange(e: Event) { this.instantBuild = Boolean((e.target as HTMLInputElement).checked); this.putGameConfig(); } private handleSpawnImmunityDurationKeyDown(e: KeyboardEvent) { if (["-", "+", "e", "E"].includes(e.key)) { e.preventDefault(); } } private handleSpawnImmunityDurationInput(e: Event) { const input = e.target as HTMLInputElement; input.value = input.value.replace(/[eE+-]/g, ""); const value = parseInt(input.value, 10); if (Number.isNaN(value) || value < 0 || value > 120) { return; } this.spawnImmunityDurationMinutes = value; this.putGameConfig(); } private handleRandomSpawnChange(e: Event) { this.randomSpawn = Boolean((e.target as HTMLInputElement).checked); this.putGameConfig(); } private handleInfiniteGoldChange(e: Event) { this.infiniteGold = Boolean((e.target as HTMLInputElement).checked); this.putGameConfig(); } private handleDonateGoldChange(e: Event) { this.donateGold = Boolean((e.target as HTMLInputElement).checked); this.putGameConfig(); } private handleInfiniteTroopsChange(e: Event) { this.infiniteTroops = Boolean((e.target as HTMLInputElement).checked); this.putGameConfig(); } private handleCompactMapChange(e: Event) { this.compactMap = Boolean((e.target as HTMLInputElement).checked); this.putGameConfig(); } private handleDonateTroopsChange(e: Event) { this.donateTroops = Boolean((e.target as HTMLInputElement).checked); this.putGameConfig(); } private handleMaxTimerValueKeyDown(e: KeyboardEvent) { if (["-", "+", "e"].includes(e.key)) { e.preventDefault(); } } private handleMaxTimerValueChanges(e: Event) { (e.target as HTMLInputElement).value = ( e.target as HTMLInputElement ).value.replace(/[e+-]/gi, ""); const value = parseInt((e.target as HTMLInputElement).value); if (isNaN(value) || value < 0 || value > 120) { return; } this.maxTimerValue = value; this.putGameConfig(); } private async handleDisableNationsChange(e: Event) { this.disableNations = Boolean((e.target as HTMLInputElement).checked); console.log(`updating disable nations to ${this.disableNations}`); this.putGameConfig(); } private async handleGameModeSelection(value: GameMode) { this.gameMode = value; this.putGameConfig(); } private async handleTeamCountSelection(value: TeamCountConfig) { this.teamCount = value; this.putGameConfig(); } private async putGameConfig() { const spawnImmunityTicks = this.spawnImmunityDurationMinutes ? this.spawnImmunityDurationMinutes * 60 * 10 : 0; this.dispatchEvent( new CustomEvent("update-game-config", { detail: { config: { gameMap: this.selectedMap, gameMapSize: this.compactMap ? GameMapSize.Compact : GameMapSize.Normal, difficulty: this.selectedDifficulty, bots: this.bots, infiniteGold: this.infiniteGold, donateGold: this.donateGold, infiniteTroops: this.infiniteTroops, donateTroops: this.donateTroops, instantBuild: this.instantBuild, randomSpawn: this.randomSpawn, gameMode: this.gameMode, disabledUnits: this.disabledUnits, spawnImmunityDuration: this.spawnImmunity ? spawnImmunityTicks : undefined, playerTeams: this.teamCount, ...(this.gameMode === GameMode.Team && this.teamCount === HumansVsNations ? { disableNations: false, } : { disableNations: this.disableNations, }), maxTimerValue: this.maxTimer === true ? this.maxTimerValue : undefined, } satisfies Partial, }, bubbles: true, composed: true, }), ); } private toggleUnit(unit: UnitType, checked: boolean): void { console.log(`Toggling unit type: ${unit} to ${checked}`); this.disabledUnits = checked ? [...this.disabledUnits, unit] : this.disabledUnits.filter((u) => u !== unit); this.putGameConfig(); } private getRandomMap(): GameMapType { const maps = Object.values(GameMapType); const randIdx = Math.floor(Math.random() * maps.length); return maps[randIdx] as GameMapType; } private async startGame() { await this.putGameConfig(); console.log( `Starting private game with map: ${GameMapType[this.selectedMap as keyof typeof GameMapType]} ${this.useRandomMap ? " (Randomly selected)" : ""}`, ); this.close(); const config = await getServerConfigFromClient(); const response = await fetch( `${window.location.origin}/${config.workerPath(this.lobbyId)}/api/start_game/${this.lobbyId}`, { method: "POST", headers: { "Content-Type": "application/json", }, }, ); return response; } private async copyToClipboard() { try { await navigator.clipboard.writeText( `${location.origin}/#join=${this.lobbyId}`, ); this.copySuccess = true; setTimeout(() => { this.copySuccess = false; }, 2000); } catch (err) { console.error(`Failed to copy text: ${err}`); } } 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( new CustomEvent("kick-player", { detail: { target: clientID }, bubbles: true, composed: true, }), ); } private async loadNationCount() { try { const mapData = this.mapLoader.getMapData(this.selectedMap); const manifest = await mapData.manifest(); this.nationCount = manifest.nations.length; } catch (error) { console.warn("Failed to load nation count", error); this.nationCount = 0; } } /** * Returns the effective nation count for display purposes. * In HumansVsNations mode, this equals the number of human players. * Otherwise, it uses the manifest nation count (or 0 if nations are disabled). */ private getEffectiveNationCount(): number { if (this.disableNations) { return 0; } if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) { return this.clients.length; } return this.nationCount; } } async function createLobby(creatorClientID: string): Promise { const config = await getServerConfigFromClient(); try { const id = generateID(); const response = await fetch( `/${config.workerPath(id)}/api/create_game/${id}?creatorClientID=${encodeURIComponent(creatorClientID)}`, { method: "POST", headers: { "Content-Type": "application/json", }, // body: JSON.stringify(data), // Include this if you need to send data }, ); if (!response.ok) { const errorText = await response.text(); console.error("Server error response:", errorText); throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); console.log("Success:", data); return data as GameInfo; } catch (error) { console.error("Error creating lobby:", error); throw error; } }
${translateText(`difficulty.${key.toLowerCase()}`)}