import { TemplateResult, html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; import { UserMeResponse } from "../core/ApiSchemas"; import { Difficulty, GameMapSize, GameMapType, GameMode, GameType, UnitType, } from "../core/game/Game"; import { TeamCountConfig } from "../core/Schemas"; import { generateID } from "../core/Util"; import { hasLinkedAccount } from "./Api"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; import "./components/GameConfigSettings"; import "./components/ToggleInputCard"; import { modalHeader } from "./components/ui/ModalHeader"; import { getPlayerCosmetics } from "./Cosmetics"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import { JoinLobbyEvent } from "./Main"; import { UsernameInput } from "./UsernameInput"; import { getBotsForCompactMap, getNationsForCompactMap, getRandomMapType, getUpdatedDisabledUnits, parseBoundedFloatFromInput, parseBoundedIntegerFromInput, preventDisallowedKeys, sliderToNationsConfig, toOptionalNumber, } from "./utilities/GameConfigHelpers"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; const DEFAULT_OPTIONS = { selectedMap: GameMapType.World, selectedDifficulty: Difficulty.Easy, bots: 400, infiniteGold: false, infiniteTroops: false, compactMap: false, maxTimer: false, maxTimerValue: undefined as number | undefined, instantBuild: false, randomSpawn: false, useRandomMap: false, gameMode: GameMode.FFA, teamCount: 2 as TeamCountConfig, goldMultiplier: false, goldMultiplierValue: undefined as number | undefined, startingGold: false, startingGoldValue: undefined as number | undefined, disabledUnits: [] as UnitType[], } as const; @customElement("single-player-modal") export class SinglePlayerModal extends BaseModal { @state() private selectedMap: GameMapType = DEFAULT_OPTIONS.selectedMap; @state() private selectedDifficulty: Difficulty = DEFAULT_OPTIONS.selectedDifficulty; @state() private nations: number = 0; @state() private defaultNationCount: number = 0; @state() private bots: number = DEFAULT_OPTIONS.bots; @state() private infiniteGold: boolean = DEFAULT_OPTIONS.infiniteGold; @state() private infiniteTroops: boolean = DEFAULT_OPTIONS.infiniteTroops; @state() private compactMap: boolean = DEFAULT_OPTIONS.compactMap; @state() private maxTimer: boolean = DEFAULT_OPTIONS.maxTimer; @state() private maxTimerValue: number | undefined = DEFAULT_OPTIONS.maxTimerValue; @state() private instantBuild: boolean = DEFAULT_OPTIONS.instantBuild; @state() private randomSpawn: boolean = DEFAULT_OPTIONS.randomSpawn; @state() private useRandomMap: boolean = DEFAULT_OPTIONS.useRandomMap; @state() private gameMode: GameMode = DEFAULT_OPTIONS.gameMode; @state() private teamCount: TeamCountConfig = DEFAULT_OPTIONS.teamCount; @state() private showAchievements: boolean = false; @state() private mapWins: Map> = new Map(); @state() private userMeResponse: UserMeResponse | false = false; @state() private goldMultiplier: boolean = DEFAULT_OPTIONS.goldMultiplier; @state() private goldMultiplierValue: number | undefined = DEFAULT_OPTIONS.goldMultiplierValue; @state() private startingGold: boolean = DEFAULT_OPTIONS.startingGold; @state() private startingGoldValue: number | undefined = DEFAULT_OPTIONS.startingGoldValue; @state() private disabledUnits: UnitType[] = [ ...DEFAULT_OPTIONS.disabledUnits, ]; private mapLoader = terrainMapFileLoader; connectedCallback() { super.connectedCallback(); document.addEventListener( "userMeResponse", this.handleUserMeResponse as EventListener, ); void this.loadNationCount(); } disconnectedCallback() { document.removeEventListener( "userMeResponse", this.handleUserMeResponse as EventListener, ); super.disconnectedCallback(); } private toggleAchievements = () => { this.showAchievements = !this.showAchievements; }; private handleUserMeResponse = ( event: CustomEvent, ) => { this.userMeResponse = event.detail; this.applyAchievements(event.detail); }; private renderNotLoggedInBanner(): TemplateResult { if (crazyGamesSDK.isOnCrazyGames()) { return html``; } return html``; } private applyAchievements(userMe: UserMeResponse | false) { if (!userMe) { this.mapWins = new Map(); return; } const achievements = Array.isArray(userMe.player.achievements) ? userMe.player.achievements : []; const completions = achievements.find( (achievement) => achievement?.type === "singleplayer-map", )?.data ?? []; const winsMap = new Map>(); for (const entry of completions) { const { mapName, difficulty } = entry ?? {}; const isValidMap = typeof mapName === "string" && Object.values(GameMapType).includes(mapName as GameMapType); const isValidDifficulty = typeof difficulty === "string" && Object.values(Difficulty).includes(difficulty as Difficulty); if (!isValidMap || !isValidDifficulty) continue; const map = mapName as GameMapType; const set = winsMap.get(map) ?? new Set(); set.add(difficulty as Difficulty); winsMap.set(map, set); } this.mapWins = winsMap; } render() { const inputCards = [ html``, html``, html``, ]; const content = html`
${modalHeader({ title: translateText("main.solo") || "Solo", onBack: () => this.close(), ariaLabel: translateText("common.back"), rightContent: hasLinkedAccount(this.userMeResponse) ? html`` : this.renderNotLoggedInBanner(), })}
${hasLinkedAccount(this.userMeResponse) && this.hasOptionsChanged() ? html`
${translateText("single_modal.options_changed_no_achievements")}
` : null}
`; if (this.inline) { return content; } return html` ${content} `; } // Check if any options other than map and difficulty have been changed from defaults private hasOptionsChanged(): boolean { return ( this.nations !== this.defaultNationCount || this.bots !== DEFAULT_OPTIONS.bots || this.infiniteGold !== DEFAULT_OPTIONS.infiniteGold || this.infiniteTroops !== DEFAULT_OPTIONS.infiniteTroops || this.compactMap !== DEFAULT_OPTIONS.compactMap || this.maxTimer !== DEFAULT_OPTIONS.maxTimer || this.instantBuild !== DEFAULT_OPTIONS.instantBuild || this.randomSpawn !== DEFAULT_OPTIONS.randomSpawn || this.gameMode !== DEFAULT_OPTIONS.gameMode || this.goldMultiplier !== DEFAULT_OPTIONS.goldMultiplier || this.startingGold !== DEFAULT_OPTIONS.startingGold || this.disabledUnits.length > 0 ); } protected onClose(): void { // Reset all transient form state to ensure clean slate this.selectedMap = DEFAULT_OPTIONS.selectedMap; this.selectedDifficulty = DEFAULT_OPTIONS.selectedDifficulty; this.gameMode = DEFAULT_OPTIONS.gameMode; this.useRandomMap = DEFAULT_OPTIONS.useRandomMap; this.bots = DEFAULT_OPTIONS.bots; this.nations = 0; this.defaultNationCount = 0; this.infiniteGold = DEFAULT_OPTIONS.infiniteGold; this.infiniteTroops = DEFAULT_OPTIONS.infiniteTroops; this.compactMap = DEFAULT_OPTIONS.compactMap; this.maxTimer = DEFAULT_OPTIONS.maxTimer; this.maxTimerValue = DEFAULT_OPTIONS.maxTimerValue; this.instantBuild = DEFAULT_OPTIONS.instantBuild; this.randomSpawn = DEFAULT_OPTIONS.randomSpawn; this.teamCount = DEFAULT_OPTIONS.teamCount; this.disabledUnits = [...DEFAULT_OPTIONS.disabledUnits]; this.goldMultiplier = DEFAULT_OPTIONS.goldMultiplier; this.goldMultiplierValue = DEFAULT_OPTIONS.goldMultiplierValue; this.startingGold = DEFAULT_OPTIONS.startingGold; this.startingGoldValue = DEFAULT_OPTIONS.startingGoldValue; } protected onOpen(): void { void this.loadNationCount(); } private handleSelectRandomMap() { this.useRandomMap = true; this.selectedMap = getRandomMapType(); void this.loadNationCount(); } private handleConfigRandomMapSelected = () => { this.handleSelectRandomMap(); }; private handleMapSelection(value: GameMapType) { this.selectedMap = value; this.useRandomMap = false; void this.loadNationCount(); } private handleConfigMapSelected = (e: Event) => { const customEvent = e as CustomEvent<{ map: GameMapType }>; this.handleMapSelection(customEvent.detail.map); }; private handleDifficultySelection(value: Difficulty) { this.selectedDifficulty = value; } private handleConfigDifficultySelected = (e: Event) => { const customEvent = e as CustomEvent<{ difficulty: Difficulty }>; this.handleDifficultySelection(customEvent.detail.difficulty); }; private handleConfigGameModeSelected = (e: Event) => { const customEvent = e as CustomEvent<{ mode: GameMode }>; this.handleGameModeSelection(customEvent.detail.mode); }; private handleConfigTeamCountSelected = (e: Event) => { const customEvent = e as CustomEvent<{ count: TeamCountConfig }>; this.handleTeamCountSelection(customEvent.detail.count); }; private handleCompactMapChange(val: boolean) { this.compactMap = val; this.bots = getBotsForCompactMap(this.bots, val); this.nations = getNationsForCompactMap( this.nations, this.defaultNationCount, val, ); } private handleConfigOptionToggleChanged = (e: Event) => { const customEvent = e as CustomEvent<{ labelKey: string; checked: boolean; }>; const { labelKey, checked } = customEvent.detail; switch (labelKey) { case "single_modal.instant_build": this.instantBuild = checked; break; case "single_modal.random_spawn": this.randomSpawn = checked; break; case "single_modal.infinite_gold": this.infiniteGold = checked; break; case "single_modal.infinite_troops": this.infiniteTroops = checked; break; case "single_modal.compact_map": this.handleCompactMapChange(checked); break; default: break; } }; private handleConfigUnitToggleChanged = (e: Event) => { const customEvent = e as CustomEvent<{ unit: UnitType; checked: boolean }>; const { unit, checked } = customEvent.detail; this.disabledUnits = getUpdatedDisabledUnits( this.disabledUnits, unit, checked, ); }; private handleBotsChange = (e: Event) => { const customEvent = e as CustomEvent<{ value: number }>; const value = customEvent.detail.value; if (isNaN(value) || value < 0 || value > 400) { return; } this.bots = value; }; private handleNationsChange = (e: Event) => { const customEvent = e as CustomEvent<{ value: number }>; const value = customEvent.detail.value; if (isNaN(value) || value < 0 || value > 400) { return; } this.nations = value; }; private handleMaxTimerToggle = ( checked: boolean, value: number | string | undefined, ) => { this.maxTimer = checked; this.maxTimerValue = toOptionalNumber(value); }; private handleGoldMultiplierToggle = ( checked: boolean, value: number | string | undefined, ) => { this.goldMultiplier = checked; this.goldMultiplierValue = toOptionalNumber(value); }; private handleStartingGoldToggle = ( checked: boolean, value: number | string | undefined, ) => { this.startingGold = checked; this.startingGoldValue = toOptionalNumber(value); }; private handleMaxTimerValueKeyDown = (e: KeyboardEvent) => { preventDisallowedKeys(e, ["-", "+", "e"]); }; private getEndTimerInput(): HTMLInputElement | null { return ( (this.renderRoot.querySelector( "#end-timer-value", ) as HTMLInputElement | null) ?? (this.querySelector("#end-timer-value") as HTMLInputElement | null) ); } private handleMaxTimerValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; const value = parseBoundedIntegerFromInput(input, { min: 1, max: 120, stripPattern: /[e+-]/gi, }); this.maxTimerValue = value; }; private handleGoldMultiplierValueKeyDown = (e: KeyboardEvent) => { preventDisallowedKeys(e, ["+", "-", "e", "E"]); }; private handleGoldMultiplierValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; const value = parseBoundedFloatFromInput(input, { min: 0.1, max: 1000 }); if (value === undefined) { this.goldMultiplierValue = undefined; input.value = ""; } else { this.goldMultiplierValue = value; } }; private handleStartingGoldValueKeyDown = (e: KeyboardEvent) => { preventDisallowedKeys(e, ["-", "+", "e", "E"]); }; private handleStartingGoldValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; const value = parseBoundedFloatFromInput(input, { min: 0.1, max: 1000, }); if (value === undefined) { this.startingGoldValue = undefined; input.value = ""; } else { this.startingGoldValue = value; } }; private handleGameModeSelection(value: GameMode) { this.gameMode = value; } private handleTeamCountSelection(value: TeamCountConfig) { this.teamCount = value; } private async startGame() { // Validate and clamp maxTimer setting before starting let finalMaxTimerValue: number | undefined = undefined; if (this.maxTimer) { if (!this.maxTimerValue || this.maxTimerValue <= 0) { console.error("Max timer is enabled but no valid value is set"); alert( translateText("single_modal.max_timer_invalid") || "Please enter a valid max timer value (1-120 minutes)", ); // Focus the input const input = this.getEndTimerInput(); if (input) { input.focus(); input.select(); } return; } // Clamp value to valid range finalMaxTimerValue = Math.max(1, Math.min(120, this.maxTimerValue)); } console.log( `Starting single player game with map: ${GameMapType[this.selectedMap as keyof typeof GameMapType]}${this.useRandomMap ? " (Randomly selected)" : ""}`, ); const clientID = generateID(); const gameID = generateID(); const usernameInput = document.querySelector( "username-input", ) as UsernameInput; if (!usernameInput) { console.warn("Username input element not found"); } await crazyGamesSDK.requestMidgameAd(); this.dispatchEvent( new CustomEvent("join-lobby", { detail: { gameID: gameID, gameStartInfo: { gameID: gameID, players: [ { clientID, username: usernameInput.getCurrentUsername(), cosmetics: await getPlayerCosmetics(), }, ], config: { gameMap: this.selectedMap, gameMapSize: this.compactMap ? GameMapSize.Compact : GameMapSize.Normal, gameType: GameType.Singleplayer, gameMode: this.gameMode, playerTeams: this.teamCount, difficulty: this.selectedDifficulty, maxTimerValue: finalMaxTimerValue, bots: this.bots, infiniteGold: this.infiniteGold, donateGold: this.gameMode === GameMode.Team, donateTroops: this.gameMode === GameMode.Team, infiniteTroops: this.infiniteTroops, instantBuild: this.instantBuild, randomSpawn: this.randomSpawn, disabledUnits: this.disabledUnits .map((u) => Object.values(UnitType).find((ut) => ut === u)) .filter((ut): ut is UnitType => ut !== undefined), nations: sliderToNationsConfig( this.nations, this.defaultNationCount, ), ...(this.goldMultiplier && this.goldMultiplierValue ? { goldMultiplier: this.goldMultiplierValue } : {}), ...(this.startingGold && this.startingGoldValue !== undefined ? { startingGold: Math.round( this.startingGoldValue * 1_000_000, ), } : {}), }, lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP }, source: "singleplayer", } satisfies JoinLobbyEvent, bubbles: true, composed: true, }), ); this.close(); } private async loadNationCount() { const currentMap = this.selectedMap; try { const mapData = this.mapLoader.getMapData(currentMap); const manifest = await mapData.manifest(); // Only update if the map hasn't changed if (this.selectedMap === currentMap) { this.defaultNationCount = manifest.nations.length; this.nations = this.compactMap ? Math.max(0, Math.floor(manifest.nations.length * 0.25)) : manifest.nations.length; } } catch (error) { console.warn("Failed to load nation count", error); // Leave existing values unchanged so the UI stays consistent } } }