import Snowflake3Png from "../../resources/images/Snowflake.webp"; import version from "../../resources/version.txt"; import { UserMeResponse } from "../core/ApiSchemas"; import { EventBus } from "../core/EventBus"; import { GameRecord, GameStartInfo, ID } from "../core/Schemas"; import { GameEnv } from "../core/configuration/Config"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import "./AccountModal"; import { getUserMe } from "./Api"; import { userAuth } from "./Auth"; import { joinLobby } from "./ClientGameRunner"; import { fetchCosmetics } from "./Cosmetics"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import "./DarkModeButton"; import { DarkModeButton } from "./DarkModeButton"; import "./FlagInput"; import { FlagInput } from "./FlagInput"; import { FlagInputModal } from "./FlagInputModal"; import { GameStartingModal } from "./GameStartingModal"; import "./GoogleAdElement"; import { GutterAds } from "./GutterAds"; import { HelpModal } from "./HelpModal"; import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal"; import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal"; import "./LangSelector"; import { LangSelector } from "./LangSelector"; import { LanguageModal } from "./LanguageModal"; import "./Matchmaking"; import { MatchmakingModal } from "./Matchmaking"; import "./NewsModal"; import "./PublicLobby"; import { PublicLobby } from "./PublicLobby"; import { SinglePlayerModal } from "./SinglePlayerModal"; import "./StatsModal"; import { TerritoryPatternsModal } from "./TerritoryPatternsModal"; import { TokenLoginModal } from "./TokenLoginModal"; import { SendKickPlayerIntentEvent } from "./Transport"; import { UserSettingModal } from "./UserSettingModal"; import "./UsernameInput"; import { UsernameInput } from "./UsernameInput"; import { incrementGamesPlayed, isInIframe } from "./Utils"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; import "./snow.css"; import "./styles.css"; declare global { interface Window { turnstile: any; enableAds: boolean; PageOS: { session: { newPageView: () => void; }; }; fusetag: { registerZone: (id: string) => void; destroyZone: (id: string) => void; pageInit: (options?: any) => void; que: Array<() => void>; destroySticky: () => void; }; ramp: { que: Array<() => void>; passiveMode: boolean; spaAddAds: (ads: Array<{ type: string; selectorId: string }>) => void; destroyUnits: (adType: string) => void; settings?: { slots?: any; }; spaNewPage: (url: string) => void; }; } // Extend the global interfaces to include your custom events interface DocumentEventMap { "join-lobby": CustomEvent; "kick-player": CustomEvent; } } export interface JoinLobbyEvent { clientID: string; // Multiplayer games only have gameID, gameConfig is not known until game starts. gameID: string; // GameConfig only exists when playing a singleplayer game. gameStartInfo?: GameStartInfo; // GameRecord exists when replaying an archived game. gameRecord?: GameRecord; } class Client { private gameStop: (() => void) | null = null; private eventBus: EventBus = new EventBus(); private usernameInput: UsernameInput | null = null; private flagInput: FlagInput | null = null; private darkModeButton: DarkModeButton | null = null; private joinModal: JoinPrivateLobbyModal; private publicLobby: PublicLobby; 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 { crazyGamesSDK.maybeInit(); // Prefetch turnstile token so it is available when // the user joins a lobby. this.turnstileTokenPromise = getTurnstileToken(); const gameVersion = document.getElementById( "game-version", ) as HTMLDivElement; if (!gameVersion) { console.warn("Game version element not found"); } gameVersion.innerText = version; const langSelector = document.querySelector( "lang-selector", ) as LangSelector; const languageModal = document.querySelector( "language-modal", ) as LanguageModal; if (!langSelector) { console.warn("Lang selector element not found"); } if (!languageModal) { console.warn("Language modal element not found"); } this.flagInput = document.querySelector("flag-input") as FlagInput; if (!this.flagInput) { console.warn("Flag input element not found"); } this.darkModeButton = document.querySelector( "dark-mode-button", ) as DarkModeButton; if (!this.darkModeButton) { console.warn("Dark mode button element not found"); } this.usernameInput = document.querySelector( "username-input", ) as UsernameInput; if (!this.usernameInput) { console.warn("Username input element not found"); } this.publicLobby = document.querySelector("public-lobby") as PublicLobby; window.addEventListener("beforeunload", async () => { console.log("Browser is closing"); if (this.gameStop !== null) { this.gameStop(); await crazyGamesSDK.gameplayStop(); } }); const gutterAds = document.querySelector("gutter-ads"); if (!(gutterAds instanceof GutterAds)) throw new Error("Missing gutter-ads"); this.gutterAds = gutterAds; document.addEventListener("join-lobby", this.handleJoinLobby.bind(this)); document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this)); document.addEventListener("kick-player", this.handleKickPlayer.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()) { spModal.open(); } }); const hlpModal = document.querySelector("help-modal") as HelpModal; if (!hlpModal || !(hlpModal instanceof HelpModal)) { console.warn("Help modal element not found"); } const helpButton = document.getElementById("help-button"); if (helpButton === null) throw new Error("Missing help-button"); helpButton.addEventListener("click", () => { hlpModal.open(); }); const flagInputModal = document.querySelector( "flag-input-modal", ) as FlagInputModal; if (!flagInputModal || !(flagInputModal instanceof FlagInputModal)) { console.warn("Flag input modal element not found"); } const flgInput = document.getElementById("flag-input_"); if (flgInput === null) throw new Error("Missing flag-input_"); flgInput.addEventListener("click", () => { flagInputModal.open(); }); this.patternsModal = document.querySelector( "territory-patterns-modal", ) as TerritoryPatternsModal; if ( !this.patternsModal || !(this.patternsModal instanceof TerritoryPatternsModal) ) { console.warn("Territory patterns modal element not found"); } const patternButton = document.getElementById( "territory-patterns-input-preview-button", ); if (isInIframe() && patternButton) { patternButton.style.display = "none"; } if ( !this.patternsModal || !(this.patternsModal instanceof TerritoryPatternsModal) ) { console.warn("Territory patterns modal element not found"); } if (patternButton === null) throw new Error("territory-patterns-input-preview-button"); this.patternsModal.previewButton = patternButton; this.patternsModal.refresh(); patternButton.addEventListener("click", () => { this.patternsModal.open(); }); this.tokenLoginModal = document.querySelector( "token-login", ) as TokenLoginModal; if ( !this.tokenLoginModal || !(this.tokenLoginModal instanceof TokenLoginModal) ) { console.warn("Token login modal element not found"); } this.matchmakingModal = document.querySelector( "matchmaking-modal", ) as MatchmakingModal; if ( !this.matchmakingModal || !(this.matchmakingModal instanceof MatchmakingModal) ) { console.warn("Matchmaking modal element not found"); } const onUserMe = async (userMeResponse: UserMeResponse | false) => { document.dispatchEvent( new CustomEvent("userMeResponse", { detail: userMeResponse, bubbles: true, cancelable: true, }), ); if (userMeResponse !== false) { // Authorized console.log( `Your player ID is ${userMeResponse.player.publicId}\n` + "Sharing this ID will allow others to view your game history and stats.", ); } }; if ((await userAuth()) === false) { // Not logged in onUserMe(false); } else { // JWT appears to be valid // TODO: Add caching getUserMe().then(onUserMe); } const settingsModal = document.querySelector( "user-setting", ) as UserSettingModal; if (!settingsModal || !(settingsModal instanceof UserSettingModal)) { console.warn("User settings modal element not found"); } document .getElementById("settings-button") ?.addEventListener("click", () => { settingsModal.open(); }); const hostModal = document.querySelector( "host-lobby-modal", ) as HostPrivateLobbyModal; if (!hostModal || !(hostModal instanceof HostPrivateLobbyModal)) { console.warn("Host private lobby modal element not found"); } const hostLobbyButton = document.getElementById("host-lobby-button"); if (hostLobbyButton === null) throw new Error("Missing host-lobby-button"); hostLobbyButton.addEventListener("click", () => { if (this.usernameInput?.isValid()) { hostModal.open(); this.publicLobby.leaveLobby(); } }); this.joinModal = document.querySelector( "join-private-lobby-modal", ) as JoinPrivateLobbyModal; if (!this.joinModal || !(this.joinModal instanceof JoinPrivateLobbyModal)) { console.warn("Join private lobby modal element not found"); } 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()) { this.joinModal.open(); } }); if (this.userSettings.darkMode()) { document.documentElement.classList.add("dark"); } else { document.documentElement.classList.remove("dark"); } // Attempt to join lobby this.handleUrl(); const onHashUpdate = () => { // Reset the UI to its initial state this.joinModal.close(); if (this.gameStop !== null) { this.handleLeaveLobby(); } // Attempt to join lobby this.handleUrl(); }; // Handle browser navigation & manual hash edits window.addEventListener("popstate", onHashUpdate); window.addEventListener("hashchange", onHashUpdate); function updateSliderProgress(slider: HTMLInputElement) { const percent = ((Number(slider.value) - Number(slider.min)) / (Number(slider.max) - Number(slider.min))) * 100; slider.style.setProperty("--progress", `${percent}%`); } document .querySelectorAll( "#bots-count, #private-lobby-bots-count", ) .forEach((slider) => { updateSliderProgress(slider); slider.addEventListener("input", () => updateSliderProgress(slider)); }); this.initializeFuseTag(); } private handleUrl() { // Check if CrazyGames SDK is enabled first (no hash needed in CrazyGames) if (crazyGamesSDK.isOnCrazyGames()) { const lobbyId = crazyGamesSDK.getInviteGameId(); if (lobbyId && ID.safeParse(lobbyId).success) { this.joinModal.open(lobbyId); console.log(`CrazyGames: joining lobby ${lobbyId} from invite param`); return; } } const strip = () => history.replaceState( null, "", window.location.pathname + window.location.search, ); const alertAndStrip = (message: string) => { alert(message); strip(); }; const hash = window.location.hash; // Decode the hash first to handle encoded characters const decodedHash = decodeURIComponent(hash); const params = new URLSearchParams(decodedHash.split("?")[1] || ""); // Handle different hash sections if (decodedHash.startsWith("#purchase-completed")) { // Parse params after the ? const status = params.get("status"); if (status !== "true") { alertAndStrip("purchase failed"); return; } const patternName = params.get("pattern"); if (!patternName) { alert("Something went wrong. Please contact support."); console.error("purchase-completed but no pattern name"); return; } this.userSettings.setSelectedPatternName(patternName); const token = params.get("login-token"); if (token) { strip(); window.addEventListener("beforeunload", () => { // The page reloads after token login, so we need to save the pattern name // in case it is unset during reload. this.userSettings.setSelectedPatternName(patternName); }); this.tokenLoginModal.open(token); } else { alertAndStrip(`purchase succeeded: ${patternName}`); this.patternsModal.refresh(); } return; } if (decodedHash.startsWith("#token-login")) { const token = params.get("token-login"); if (!token) { alertAndStrip( `login failed! Please try again later or contact support.`, ); return; } strip(); this.tokenLoginModal.open(token); return; } // Fallback to hash-based join for non-CrazyGames environments if (decodedHash.startsWith("#join=")) { const lobbyId = decodedHash.substring(6); // Remove "#join=" if (lobbyId && ID.safeParse(lobbyId).success) { this.joinModal.open(lobbyId); console.log(`joining lobby ${lobbyId}`); } } if (decodedHash.startsWith("#affiliate=")) { const affiliateCode = decodedHash.replace("#affiliate=", ""); strip(); if (affiliateCode) { this.patternsModal.open(affiliateCode); } } if (decodedHash.startsWith("#refresh")) { window.location.href = "/"; } } private async handleJoinLobby(event: CustomEvent) { const lobby = event.detail; console.log(`joining lobby ${lobby.gameID}`); if (this.gameStop !== null) { console.log("joining lobby, stopping existing game"); this.gameStop(); } const config = await getServerConfigFromClient(); const pattern = this.userSettings.getSelectedPatternName( await fetchCosmetics(), ); this.gameStop = joinLobby( this.eventBus, { gameID: lobby.gameID, serverConfig: config, cosmetics: { color: this.userSettings.getSelectedColor() ?? undefined, patternName: pattern?.name ?? undefined, patternColorPaletteName: pattern?.colorPalette?.name ?? undefined, flag: this.flagInput === null || this.flagInput.getCurrentFlag() === "xx" ? "" : this.flagInput.getCurrentFlag(), }, turnstileToken: await this.getTurnstileToken(lobby), playerName: this.usernameInput?.getCurrentUsername() ?? "", clientID: lobby.clientID, gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, }, () => { console.log("Closing modals"); document.getElementById("settings-button")?.classList.add("hidden"); if (this.usernameInput) { // fix edge case where username-validation-error is re-rendered and hidden tag removed this.usernameInput.validationError = ""; } document .getElementById("username-validation-error") ?.classList.add("hidden"); [ "single-player-modal", "host-lobby-modal", "join-private-lobby-modal", "game-starting-modal", "game-top-bar", "help-modal", "user-setting", "territory-patterns-modal", "language-modal", "news-modal", "flag-input-modal", "account-button", "stats-button", "token-login", "matchmaking-modal", ].forEach((tag) => { const modal = document.querySelector(tag) as HTMLElement & { close?: () => void; isModalOpen?: boolean; }; if (modal?.close) { modal.close(); } else if (modal && "isModalOpen" in modal) { modal.isModalOpen = false; } }); this.publicLobby.stop(); document.querySelectorAll(".ad").forEach((ad) => { (ad as HTMLElement).style.display = "none"; }); // Hide snowflakes when joining lobby document.documentElement.classList.add("in-game"); removeSnowflakes(); // Stop snowflakes when joining a game crazyGamesSDK.loadingStart(); // show when the game loads const startingModal = document.querySelector( "game-starting-modal", ) as GameStartingModal; if (startingModal && startingModal instanceof GameStartingModal) { startingModal.show(); } this.gutterAds.hide(); }, () => { this.joinModal.close(); this.publicLobby.stop(); incrementGamesPlayed(); document.querySelectorAll(".ad").forEach((ad) => { (ad as HTMLElement).style.display = "none"; }); crazyGamesSDK.loadingStop(); crazyGamesSDK.gameplayStart(); // Ensure there's a homepage entry in history before adding the lobby entry if (window.location.hash === "" || window.location.hash === "#") { history.replaceState(null, "", window.location.origin + "#refresh"); } history.pushState(null, "", `#join=${lobby.gameID}`); }, ); } private async handleLeaveLobby(/* event: CustomEvent */) { if (this.gameStop === null) { return; } console.log("leaving lobby, cancelling game"); this.gameStop(); this.gameStop = null; crazyGamesSDK.gameplayStop(); this.gutterAds.hide(); this.publicLobby.leaveLobby(); // Show snowflakes when leaving lobby (back to homepage) document.documentElement.classList.remove("in-game"); enableSnowflakes(); // Restart snowflakes when leaving a game } private handleKickPlayer(event: CustomEvent) { const { target } = event.detail; // Forward to eventBus if available if (this.eventBus) { this.eventBus.emit(new SendKickPlayerIntentEvent(target)); } } private initializeFuseTag() { const tryInitFuseTag = (): boolean => { if (window.fusetag && typeof window.fusetag.pageInit === "function") { console.log("initializing fuse tag"); window.fusetag.que.push(() => { window.fusetag.pageInit({ blockingFuseIds: ["lhs_sticky_vrec", "rhs_sticky_vrec"], }); }); return true; } else { return false; } }; const interval = setInterval(() => { if (tryInitFuseTag()) { clearInterval(interval); } }, 100); } private async getTurnstileToken( lobby: JoinLobbyEvent, ): Promise { const config = await getServerConfigFromClient(); if ( config.env() === GameEnv.Dev || lobby.gameStartInfo?.config.gameType === GameType.Singleplayer ) { return null; } if (this.turnstileTokenPromise === null) { console.log("No prefetched turnstile token, getting new token"); return (await getTurnstileToken())?.token ?? null; } const token = await this.turnstileTokenPromise; // Clear promise so a new token is fetched next time this.turnstileTokenPromise = null; if (!token) { console.log("No turnstile token"); return null; } const tokenTTL = 3 * 60 * 1000; if (Date.now() < token.createdAt + tokenTTL) { console.log("Prefetched turnstile token is valid"); return token.token; } else { console.log("Turnstile token expired, getting new token"); return (await getTurnstileToken())?.token ?? null; } } } function enableSnowflakes() { // Respect user's motion preferences const prefersReducedMotion = window.matchMedia( "(prefers-reduced-motion: reduce)", ).matches; if (prefersReducedMotion) { return; } const snowContainer = document.querySelector(".snow") as HTMLElement; if (!snowContainer) { console.warn("Snow container element not found"); return; } // Clear existing snowflakes if any removeSnowflakes(); const isMobile = window.innerWidth <= 768; const numberOfSnowflakes = isMobile ? 30 : 75; // Increased count for (let i = 0; i < numberOfSnowflakes; i++) { const snowflake = document.createElement("div"); snowflake.classList.add("snowflake"); snowflake.style.left = `${Math.random() * 100}vw`; // Random horizontal position snowflake.style.animationDuration = `${Math.random() * 10 + 5}s`; // Random duration between 5-15s snowflake.style.animationDelay = `${Math.random() * -10}s`; // Random delay snowflake.style.opacity = `${Math.random() * 0.5 + 0.5}`; // Random opacity between 0.5-1 const size = Math.random() * 20 + 10; // Random size between 10-30px snowflake.style.width = `${size}px`; snowflake.style.height = `${size}px`; snowflake.style.backgroundImage = `url(${Snowflake3Png})`; snowContainer.appendChild(snowflake); } } function removeSnowflakes() { const snowContainer = document.querySelector(".snow") as HTMLElement; if (snowContainer) { snowContainer.replaceChildren(); } } // Initialize the client when the DOM is loaded document.addEventListener("DOMContentLoaded", () => { new Client().initialize(); // Initially enable snowflakes if not in-game if (!document.documentElement.classList.contains("in-game")) { enableSnowflakes(); } }); async function getTurnstileToken(): Promise<{ token: string; createdAt: number; }> { // Wait for Turnstile script to load (handles slow connections) let attempts = 0; while (typeof window.turnstile === "undefined" && attempts < 100) { await new Promise((resolve) => setTimeout(resolve, 100)); attempts++; } if (typeof window.turnstile === "undefined") { throw new Error("Failed to load Turnstile script"); } const config = await getServerConfigFromClient(); const widgetId = window.turnstile.render("#turnstile-container", { sitekey: config.turnstileSiteKey(), size: "normal", appearance: "interaction-only", theme: "light", }); return new Promise((resolve, reject) => { window.turnstile.execute(widgetId, { callback: (token: string) => { window.turnstile.remove(widgetId); console.log(`Turnstile token received: ${token}`); resolve({ token, createdAt: Date.now() }); }, "error-callback": (errorCode: string) => { window.turnstile.remove(widgetId); console.error(`Turnstile error: ${errorCode}`); alert(`Turnstile error: ${errorCode}. Please refresh and try again.`); reject(new Error(`Turnstile failed: ${errorCode}`)); }, }); }); }