-
-
+
+
+
+
+ ${translateText("host_modal.options_title")}
+
+
+
+
+
+
-
-
- ${
- !(
- this.gameMode === GameMode.Team &&
- this.teamCount === HumansVsNations
- )
- ? html`
-
- `
- : ""
- }
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ${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")
- }
-
+
+
+
+
+
+ ${translateText("host_modal.enables_title")}
+
+
+
+ ${renderUnitTypeOptions({
+ disabledUnits: this.disabledUnits,
+ toggleUnit: this.toggleUnit.bind(this),
+ })}
+
+
-
this.kickPlayer(clientID)}
- >
+
+
+
+
+ ${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)}
+ >
+
+
-
-
+ `;
+
+ if (this.inline) {
+ return content;
+ }
+
+ return html`
+
+ ${content}
+
`;
}
- createRenderRoot() {
- return this;
- }
-
- public open() {
+ protected onOpen(): void {
this.lobbyCreatorClientID = generateID();
this.lobbyIdVisible = this.userSettings.get(
"settings.lobbyIdVisibility",
@@ -661,31 +840,105 @@ export class HostLobbyModal extends LitElement {
}),
);
});
- this.modalEl?.open();
- this.modalEl.onClose = () => {
- this.close();
- };
+ if (this.modalEl) {
+ this.modalEl.onClose = () => {
+ this.close();
+ };
+ }
this.playersInterval = setInterval(() => this.pollPlayers(), 1000);
this.loadNationCount();
}
- public close() {
+ private createToggleHandlers(
+ toggleStateGetter: () => boolean,
+ toggleStateSetter: (val: boolean) => void,
+ valueGetter: () => number | undefined,
+ valueSetter: (val: number | undefined) => void,
+ defaultValue: number = 0,
+ ) {
+ const toggleLogic = () => {
+ const newState = !toggleStateGetter();
+ toggleStateSetter(newState);
+ if (newState) {
+ valueSetter(valueGetter() ?? defaultValue);
+ } else {
+ valueSetter(undefined);
+ }
+ this.putGameConfig();
+ this.requestUpdate();
+ };
+
+ return {
+ click: (e: Event) => {
+ if ((e.target as HTMLElement).tagName.toLowerCase() === "input") return;
+ toggleLogic();
+ },
+ keydown: (e: KeyboardEvent) => {
+ if ((e.target as HTMLElement).tagName.toLowerCase() === "input") return;
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ toggleLogic();
+ }
+ },
+ };
+ }
+
+ private leaveLobby() {
+ if (!this.lobbyId) {
+ return;
+ }
+ this.dispatchEvent(
+ new CustomEvent("leave-lobby", {
+ detail: { lobby: this.lobbyId },
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+
+ protected onClose(): void {
console.log("Closing host lobby modal");
crazyGamesSDK.hideInviteButton();
- this.modalEl?.close();
- this.copySuccess = false;
+
+ // Clean up timers and resources
if (this.playersInterval) {
clearInterval(this.playersInterval);
this.playersInterval = null;
}
- // Clear any pending bot updates
if (this.botsUpdateTimer !== null) {
clearTimeout(this.botsUpdateTimer);
this.botsUpdateTimer = null;
}
+
+ // Reset all transient form state to ensure clean slate
+ this.selectedMap = GameMapType.World;
+ this.selectedDifficulty = Difficulty.Medium;
+ this.disableNations = false;
+ this.gameMode = GameMode.FFA;
+ this.teamCount = 2;
+ this.bots = 400;
+ this.spawnImmunity = false;
+ this.spawnImmunityDurationMinutes = undefined;
+ this.infiniteGold = false;
+ this.donateGold = false;
+ this.infiniteTroops = false;
+ this.donateTroops = false;
+ this.maxTimer = false;
+ this.maxTimerValue = undefined;
+ this.instantBuild = false;
+ this.randomSpawn = false;
+ this.compactMap = false;
+ this.useRandomMap = false;
+ this.disabledUnits = [];
+ this.lobbyId = "";
+ this.copySuccess = false;
+ this.clients = [];
+ this.lobbyCreatorClientID = "";
+ this.lobbyIdVisible = true;
+ this.nationCount = 0;
}
- private async handleRandomMapToggle() {
+ private async handleSelectRandomMap() {
this.useRandomMap = true;
this.selectedMap = this.getRandomMap();
await this.loadNationCount();
@@ -727,10 +980,10 @@ export class HostLobbyModal extends LitElement {
}, 300);
}
- private handleInstantBuildChange(e: Event) {
- this.instantBuild = Boolean((e.target as HTMLInputElement).checked);
+ private handleInstantBuildChange = (val: boolean) => {
+ this.instantBuild = val;
this.putGameConfig();
- }
+ };
private handleSpawnImmunityDurationKeyDown(e: KeyboardEvent) {
if (["-", "+", "e", "E"].includes(e.key)) {
@@ -749,35 +1002,35 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig();
}
- private handleRandomSpawnChange(e: Event) {
- this.randomSpawn = Boolean((e.target as HTMLInputElement).checked);
+ private handleRandomSpawnChange = (val: boolean) => {
+ this.randomSpawn = val;
this.putGameConfig();
- }
+ };
- private handleInfiniteGoldChange(e: Event) {
- this.infiniteGold = Boolean((e.target as HTMLInputElement).checked);
+ private handleInfiniteGoldChange = (val: boolean) => {
+ this.infiniteGold = val;
this.putGameConfig();
- }
+ };
- private handleDonateGoldChange(e: Event) {
- this.donateGold = Boolean((e.target as HTMLInputElement).checked);
+ private handleDonateGoldChange = (val: boolean) => {
+ this.donateGold = val;
this.putGameConfig();
- }
+ };
- private handleInfiniteTroopsChange(e: Event) {
- this.infiniteTroops = Boolean((e.target as HTMLInputElement).checked);
+ private handleInfiniteTroopsChange = (val: boolean) => {
+ this.infiniteTroops = val;
this.putGameConfig();
- }
+ };
- private handleCompactMapChange(e: Event) {
- this.compactMap = Boolean((e.target as HTMLInputElement).checked);
+ private handleCompactMapChange = (val: boolean) => {
+ this.compactMap = val;
this.putGameConfig();
- }
+ };
- private handleDonateTroopsChange(e: Event) {
- this.donateTroops = Boolean((e.target as HTMLInputElement).checked);
+ private handleDonateTroopsChange = (val: boolean) => {
+ this.donateTroops = val;
this.putGameConfig();
- }
+ };
private handleMaxTimerValueKeyDown(e: KeyboardEvent) {
if (["-", "+", "e"].includes(e.key)) {
@@ -798,14 +1051,21 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig();
}
- private async handleDisableNationsChange(e: Event) {
- this.disableNations = Boolean((e.target as HTMLInputElement).checked);
+ private handleDisableNationsChange = async (val: boolean) => {
+ this.disableNations = val;
console.log(`updating disable nations to ${this.disableNations}`);
this.putGameConfig();
- }
+ };
private async handleGameModeSelection(value: GameMode) {
this.gameMode = value;
+ if (this.gameMode === GameMode.Team) {
+ this.donateGold = true;
+ this.donateTroops = true;
+ } else {
+ this.donateGold = false;
+ this.donateTroops = false;
+ }
this.putGameConfig();
}
@@ -859,7 +1119,6 @@ export class HostLobbyModal extends LitElement {
}
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);
@@ -878,7 +1137,6 @@ export class HostLobbyModal extends LitElement {
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}`,
@@ -893,17 +1151,11 @@ export class HostLobbyModal extends LitElement {
}
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}`);
- }
+ await copyToClipboard(
+ `${location.origin}/#join=${this.lobbyId}`,
+ () => (this.copySuccess = true),
+ () => (this.copySuccess = false),
+ );
}
private async pollPlayers() {
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts
index d01798445..3bd9378b0 100644
--- a/src/client/InputHandler.ts
+++ b/src/client/InputHandler.ts
@@ -176,13 +176,21 @@ export class InputHandler {
saved = Object.fromEntries(
Object.entries(parsed)
.map(([k, v]) => {
- if (v && typeof v === "object" && "value" in (v as any)) {
- return [k, (v as any).value as string];
+ // Extract value from nested object or plain string
+ let val: unknown;
+ if (v && typeof v === "object" && "value" in v) {
+ val = (v as { value: unknown }).value;
+ } else {
+ val = v;
}
- if (typeof v === "string") return [k, v];
- return [k, undefined];
+
+ // Map invalid values to undefined (filtered later)
+ if (typeof val !== "string" || val === "Null") {
+ return [k, undefined];
+ }
+ return [k, val];
})
- .filter(([, v]) => typeof v === "string" && v !== "Null"),
+ .filter(([, v]) => typeof v === "string"),
) as Record;
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts
index 7a7ece0d8..f6531472f 100644
--- a/src/client/JoinPrivateLobbyModal.ts
+++ b/src/client/JoinPrivateLobbyModal.ts
@@ -1,122 +1,369 @@
-import { LitElement, html } from "lit";
+import { html, TemplateResult } from "lit";
import { customElement, query, state } from "lit/decorators.js";
-import { translateText } from "../client/Utils";
-import { GameInfo, GameRecordSchema } from "../core/Schemas";
+import { copyToClipboard, translateText } from "../client/Utils";
+import {
+ ClientInfo,
+ GameConfig,
+ GameInfo,
+ GameRecordSchema,
+} from "../core/Schemas";
import { generateID } from "../core/Util";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
+import { GameMode } from "../core/game/Game";
+import { UserSettings } from "../core/game/UserSettings";
import { getApiBase } from "./Api";
import { JoinLobbyEvent } from "./Main";
-import "./components/baseComponents/Button";
-import "./components/baseComponents/Modal";
+import { BaseModal } from "./components/BaseModal";
+import "./components/Difficulties";
+import "./components/LobbyTeamView";
@customElement("join-private-lobby-modal")
-export class JoinPrivateLobbyModal extends LitElement {
- @query("o-modal") private modalEl!: HTMLElement & {
- open: () => void;
- close: () => void;
- };
+export class JoinPrivateLobbyModal extends BaseModal {
@query("#lobbyIdInput") private lobbyIdInput!: HTMLInputElement;
@state() private message: string = "";
@state() private hasJoined = false;
- @state() private players: string[] = [];
+ @state() private players: ClientInfo[] = [];
+ @state() private gameConfig: GameConfig | null = null;
+ @state() private lobbyCreatorClientID: string | null = null;
+ @state() private lobbyIdVisible: boolean = true;
+ @state() private copySuccess: boolean = false;
+ @state() private currentLobbyId: string = "";
private playersInterval: NodeJS.Timeout | null = null;
+ private userSettings: UserSettings = new UserSettings();
- connectedCallback() {
- super.connectedCallback();
- window.addEventListener("keydown", this.handleKeyDown);
+ updated(changedProperties: Map) {
+ super.updated(changedProperties);
}
- disconnectedCallback() {
- window.removeEventListener("keydown", this.handleKeyDown);
- super.disconnectedCallback();
- }
-
- private handleKeyDown = (e: KeyboardEvent) => {
- if (e.code === "Escape") {
- e.preventDefault();
- this.close();
- }
- };
-
render() {
- return html`
-
-
-
-
-
+
+ `;
+
+ if (this.inline) {
+ return content;
+ }
+
+ return html`
+
+ ${content}
+
`;
}
}
diff --git a/src/client/Layout.ts b/src/client/Layout.ts
new file mode 100644
index 000000000..4e33ab2bb
--- /dev/null
+++ b/src/client/Layout.ts
@@ -0,0 +1,81 @@
+export function initLayout() {
+ const hb = document.getElementById("hamburger-btn");
+ const sidebar = document.getElementById("sidebar-menu");
+ const backdrop = document.getElementById("mobile-menu-backdrop");
+
+ // Force sidebar visibility style to ensure it's not hidden by other CSS
+ if (sidebar && window.innerWidth < 768) {
+ sidebar.style.display = "flex";
+ }
+
+ if (!hb) {
+ console.error("Hamburger button not found");
+ return;
+ }
+
+ // Disable fallback inline handler now that JS is loaded
+ hb.onclick = null;
+
+ if (!sidebar) {
+ console.error("Sidebar menu not found");
+ return;
+ }
+ if (!backdrop) {
+ console.error("Mobile menu backdrop not found");
+ return;
+ }
+
+ const setMenuState = (open: boolean) => {
+ sidebar.classList.toggle("open", open);
+ backdrop.classList.toggle("open", open);
+ document.documentElement.classList.toggle("overflow-hidden", open);
+ hb.setAttribute("aria-expanded", open ? "true" : "false");
+ };
+
+ const closeMenu = () => setMenuState(false);
+ const openMenu = () => setMenuState(true);
+
+ const toggle = (e: Event) => {
+ e.stopPropagation();
+ // Only prevent default if it's a touchstart to avoid ghost clicks
+ if ((e as any).type === "touchstart") {
+ (e as Event).preventDefault();
+ }
+
+ const opening = !sidebar.classList.contains("open");
+ if (opening) {
+ openMenu();
+ } else {
+ closeMenu();
+ }
+ };
+
+ hb.addEventListener("click", toggle);
+
+ backdrop.addEventListener("click", closeMenu);
+
+ // Close menu when clicking a menu link or button (Mobile only)
+ sidebar.addEventListener("click", (e) => {
+ // On desktop, we want the menu to stay open unless explicitly toggled
+ if (window.innerWidth >= 768) return;
+
+ // If the click happened on or inside an anchor/button/menu item, close the menu
+ const clickedElement = (e.target as Element).closest
+ ? (e.target as Element).closest(
+ 'a, button, [role="menuitem"], .nav-menu-item',
+ )
+ : null;
+
+ if (clickedElement) {
+ closeMenu();
+ }
+ });
+
+ // Close on Escape (Mobile only)
+ document.addEventListener("keydown", (e) => {
+ if (window.innerWidth >= 768) return;
+ if (e.key === "Escape" && sidebar.classList.contains("open")) {
+ closeMenu();
+ }
+ });
+}
diff --git a/src/client/Main.ts b/src/client/Main.ts
index 00c2950a2..4d91c0880 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -12,10 +12,9 @@ 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";
import { FlagInputModal } from "./FlagInputModal";
import { GameInfoModal } from "./GameInfoModal";
import { GameStartingModal } from "./GameStartingModal";
@@ -24,11 +23,13 @@ import { GutterAds } from "./GutterAds";
import { HelpModal } from "./HelpModal";
import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal";
import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
+import "./KeybindsModal";
import "./LangSelector";
import { LangSelector } from "./LangSelector";
-import { LanguageModal } from "./LanguageModal";
+import { initLayout } from "./Layout";
import "./Matchmaking";
import { MatchmakingModal } from "./Matchmaking";
+import { initNavigation } from "./Navigation";
import "./NewsModal";
import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
@@ -47,16 +48,12 @@ import { incrementGamesPlayed, isInIframe } from "./Utils";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import "./styles.css";
-import "./styles/components/button.css";
-import "./styles/components/controls.css";
-import "./styles/components/modal.css";
-import "./styles/components/setting.css";
-import "./styles/core/flag-animation.css";
import "./styles/core/typography.css";
import "./styles/core/variables.css";
import "./styles/layout/container.css";
import "./styles/layout/header.css";
import "./styles/modal/chat.css";
+
declare global {
interface Window {
turnstile: any;
@@ -83,6 +80,7 @@ declare global {
};
spaNewPage: (url: string) => void;
};
+ showPage?: (pageId: string) => void;
}
// Extend the global interfaces to include your custom events
@@ -108,7 +106,6 @@ class Client {
private usernameInput: UsernameInput | null = null;
private flagInput: FlagInput | null = null;
- private darkModeButton: DarkModeButton | null = null;
private joinModal: JoinPrivateLobbyModal;
private publicLobby: PublicLobby;
@@ -132,39 +129,31 @@ class Client {
// the user joins a lobby.
this.turnstileTokenPromise = getTurnstileToken();
- const gameVersion = document.getElementById(
- "game-version",
- ) as HTMLDivElement;
- if (!gameVersion) {
+ const versionElements = document.querySelectorAll(
+ "#game-version, .game-version-display",
+ );
+ if (versionElements.length === 0) {
console.warn("Game version element not found");
+ } else {
+ const trimmed = version.trim();
+ const displayVersion = trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
+ versionElements.forEach((el) => {
+ el.textContent = displayVersion;
+ });
}
- 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;
@@ -206,7 +195,17 @@ class Client {
if (singlePlayer === null) throw new Error("Missing single-player");
singlePlayer.addEventListener("click", () => {
if (this.usernameInput?.isValid()) {
- spModal.open();
+ window.showPage?.("page-single-player");
+ } else {
+ window.dispatchEvent(
+ new CustomEvent("show-message", {
+ detail: {
+ message: this.usernameInput?.validationError,
+ color: "red",
+ duration: 3000,
+ },
+ }),
+ );
}
});
@@ -219,10 +218,13 @@ class Client {
console.warn("Game info modal element not found");
}
const helpButton = document.getElementById("help-button");
- if (helpButton === null) throw new Error("Missing help-button");
- helpButton.addEventListener("click", () => {
- hlpModal.open();
- });
+ if (helpButton) {
+ helpButton.addEventListener("click", () => {
+ if (hlpModal && hlpModal instanceof HelpModal) {
+ hlpModal.open();
+ }
+ });
+ }
const flagInputModal = document.querySelector(
"flag-input-modal",
@@ -231,13 +233,28 @@ class Client {
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();
+ // Wait for the flag-input component to be fully ready
+ customElements.whenDefined("flag-input").then(() => {
+ // Use a small delay to ensure the component has rendered
+ setTimeout(() => {
+ const flagButton = document.querySelector(
+ "#flag-input-component #flag-input_",
+ );
+ if (!flagButton) {
+ console.warn("Flag button not found inside component");
+ return;
+ }
+ flagButton.addEventListener("click", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (flagInputModal && flagInputModal instanceof FlagInputModal) {
+ flagInputModal.open();
+ }
+ });
+ }, 100);
});
- this.patternsModal = document.querySelector(
+ this.patternsModal = document.getElementById(
"territory-patterns-modal",
) as TerritoryPatternsModal;
if (
@@ -253,6 +270,44 @@ class Client {
patternButton.style.display = "none";
}
+ // Move button to desktop wrapper on large screens
+ const desktopWrapper = document.getElementById(
+ "territory-patterns-preview-desktop-wrapper",
+ );
+ if (desktopWrapper && patternButton) {
+ const moveButtonBasedOnScreenSize = () => {
+ if (window.innerWidth >= 1024) {
+ // Desktop: move to wrapper
+ if (
+ patternButton.parentElement?.id !==
+ "territory-patterns-preview-desktop-wrapper"
+ ) {
+ patternButton.className =
+ "w-full h-[60px] border border-white/20 bg-white/5 hover:bg-white/10 active:bg-white/20 rounded-lg cursor-pointer focus:outline-none transition-all duration-200 hover:scale-105 overflow-hidden";
+ patternButton.style.backgroundSize = "auto 100%";
+ patternButton.style.backgroundRepeat = "repeat-x";
+ desktopWrapper.appendChild(patternButton);
+ }
+ } else {
+ // Mobile: move back to bar
+ const mobileParent = document.querySelector(".lg\\:col-span-9.flex");
+ if (
+ mobileParent &&
+ patternButton.parentElement?.id ===
+ "territory-patterns-preview-desktop-wrapper"
+ ) {
+ patternButton.className =
+ "aspect-square h-[40px] sm:h-[50px] lg:hidden border border-white/20 bg-white/5 hover:bg-white/10 active:bg-white/20 rounded-lg cursor-pointer focus:outline-none transition-all duration-200 hover:scale-105 overflow-hidden shrink-0";
+ patternButton.style.backgroundSize = "";
+ patternButton.style.backgroundRepeat = "";
+ mobileParent.appendChild(patternButton);
+ }
+ }
+ };
+ moveButtonBasedOnScreenSize();
+ window.addEventListener("resize", moveButtonBasedOnScreenSize);
+ }
+
if (
!this.patternsModal ||
!(this.patternsModal instanceof TerritoryPatternsModal)
@@ -263,8 +318,30 @@ class Client {
throw new Error("territory-patterns-input-preview-button");
this.patternsModal.previewButton = patternButton;
this.patternsModal.refresh();
+ // Listen for pattern selection to update preview button
+ this.patternsModal.addEventListener("pattern-selected", () => {
+ this.patternsModal.refresh();
+ });
+
+ window.addEventListener("showPage", (e: any) => {
+ if (typeof e?.detail === "string" && e.detail === "page-play") {
+ setTimeout(() => {
+ this.patternsModal.refresh();
+ }, 50);
+ }
+ });
+
patternButton.addEventListener("click", () => {
- this.patternsModal.open();
+ window.showPage?.("page-item-store");
+ const skinStoreModal = document.getElementById(
+ "page-item-store",
+ ) as HTMLElement & { open?: (opts: any) => void };
+ if (skinStoreModal) {
+ skinStoreModal.classList.remove("hidden");
+ if (typeof skinStoreModal.open === "function") {
+ skinStoreModal.open({ showOnlyOwned: true });
+ }
+ }
});
this.tokenLoginModal = document.querySelector(
@@ -286,8 +363,50 @@ class Client {
) {
console.warn("Matchmaking modal element not found");
}
+ const matchmakingButton = document.getElementById("matchmaking-button");
+ const matchmakingButtonLoggedOut = document.getElementById(
+ "matchmaking-button-logged-out",
+ );
+
+ const updateMatchmakingButton = (loggedIn: boolean) => {
+ if (!loggedIn) {
+ matchmakingButton?.classList.add("hidden");
+ matchmakingButtonLoggedOut?.classList.remove("hidden");
+ } else {
+ matchmakingButton?.classList.remove("hidden");
+ matchmakingButtonLoggedOut?.classList.add("hidden");
+ }
+ };
+
+ if (matchmakingButton) {
+ matchmakingButton.addEventListener("click", () => {
+ if (this.usernameInput?.isValid()) {
+ window.showPage?.("page-matchmaking");
+ this.publicLobby.leaveLobby();
+ } else {
+ window.dispatchEvent(
+ new CustomEvent("show-message", {
+ detail: {
+ message: this.usernameInput?.validationError,
+ color: "red",
+ duration: 3000,
+ },
+ }),
+ );
+ }
+ });
+ }
const onUserMe = async (userMeResponse: UserMeResponse | false) => {
+ // Check if user has actual authentication (discord or email), not just a publicId
+ const loggedIn =
+ userMeResponse !== false &&
+ userMeResponse !== null &&
+ typeof userMeResponse === "object" &&
+ userMeResponse.user &&
+ (userMeResponse.user.discord !== undefined ||
+ userMeResponse.user.email !== undefined);
+ updateMatchmakingButton(loggedIn);
document.dispatchEvent(
new CustomEvent("userMeResponse", {
detail: userMeResponse,
@@ -323,7 +442,9 @@ class Client {
document
.getElementById("settings-button")
?.addEventListener("click", () => {
- settingsModal.open();
+ if (settingsModal && settingsModal instanceof UserSettingModal) {
+ settingsModal.open();
+ }
});
const hostModal = document.querySelector(
@@ -336,8 +457,18 @@ class Client {
if (hostLobbyButton === null) throw new Error("Missing host-lobby-button");
hostLobbyButton.addEventListener("click", () => {
if (this.usernameInput?.isValid()) {
- hostModal.open();
+ window.showPage?.("page-host-lobby");
this.publicLobby.leaveLobby();
+ } else {
+ window.dispatchEvent(
+ new CustomEvent("show-message", {
+ detail: {
+ message: this.usernameInput?.validationError,
+ color: "red",
+ duration: 3000,
+ },
+ }),
+ );
}
});
@@ -354,7 +485,17 @@ class Client {
throw new Error("Missing join-private-lobby-button");
joinPrivateLobbyButton.addEventListener("click", () => {
if (this.usernameInput?.isValid()) {
- this.joinModal.open();
+ window.showPage?.("page-join-private-lobby");
+ } else {
+ window.dispatchEvent(
+ new CustomEvent("show-message", {
+ detail: {
+ message: this.usernameInput?.validationError,
+ color: "red",
+ duration: 3000,
+ },
+ }),
+ );
}
});
@@ -367,9 +508,17 @@ class Client {
// Attempt to join lobby
this.handleUrl();
+ let preventHashUpdate = false;
+
const onHashUpdate = () => {
+ // Prevent double-handling when both popstate and hashchange fire
+ if (preventHashUpdate) {
+ preventHashUpdate = false;
+ return;
+ }
+
// Reset the UI to its initial state
- this.joinModal.close();
+ this.joinModal?.close();
if (this.gameStop !== null) {
this.handleLeaveLobby();
}
@@ -379,7 +528,10 @@ class Client {
};
// Handle browser navigation & manual hash edits
- window.addEventListener("popstate", onHashUpdate);
+ window.addEventListener("popstate", () => {
+ preventHashUpdate = true;
+ onHashUpdate();
+ });
window.addEventListener("hashchange", onHashUpdate);
function updateSliderProgress(slider: HTMLInputElement) {
@@ -407,7 +559,8 @@ class Client {
if (crazyGamesSDK.isOnCrazyGames()) {
const lobbyId = crazyGamesSDK.getInviteGameId();
if (lobbyId && ID.safeParse(lobbyId).success) {
- this.joinModal.open(lobbyId);
+ window.showPage?.("page-join-private-lobby");
+ this.joinModal?.open(lobbyId);
console.log(`CrazyGames: joining lobby ${lobbyId} from invite param`);
return;
}
@@ -485,7 +638,8 @@ class Client {
if (decodedHash.startsWith("#join=")) {
const lobbyId = decodedHash.substring(6); // Remove "#join="
if (lobbyId && ID.safeParse(lobbyId).success) {
- this.joinModal.open(lobbyId);
+ window.showPage?.("page-join-private-lobby");
+ this.joinModal?.open(lobbyId);
console.log(`joining lobby ${lobbyId}`);
}
}
@@ -493,7 +647,7 @@ class Client {
const affiliateCode = decodedHash.replace("#affiliate=", "");
strip();
if (affiliateCode) {
- this.patternsModal.open(affiliateCode);
+ this.patternsModal?.open(affiliateCode);
}
}
if (decodedHash.startsWith("#refresh")) {
@@ -507,6 +661,7 @@ class Client {
if (this.gameStop !== null) {
console.log("joining lobby, stopping existing game");
this.gameStop();
+ document.body.classList.remove("in-game");
}
const config = await getServerConfigFromClient();
@@ -549,16 +704,15 @@ class Client {
"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",
"lang-selector",
].forEach((tag) => {
@@ -589,7 +743,7 @@ class Client {
this.gutterAds.hide();
},
() => {
- this.joinModal.close();
+ this.joinModal?.close();
this.publicLobby.stop();
incrementGamesPlayed();
@@ -599,6 +753,7 @@ class Client {
crazyGamesSDK.loadingStop();
crazyGamesSDK.gameplayStart();
+ document.body.classList.add("in-game");
// Ensure there's a homepage entry in history before adding the lobby entry
if (window.location.hash === "" || window.location.hash === "#") {
@@ -617,6 +772,8 @@ class Client {
this.gameStop();
this.gameStop = null;
+ document.body.classList.remove("in-game");
+
crazyGamesSDK.gameplayStop();
this.gutterAds.hide();
@@ -699,9 +856,17 @@ class Client {
}
// Initialize the client when the DOM is loaded
-document.addEventListener("DOMContentLoaded", () => {
+const bootstrap = () => {
+ initLayout();
new Client().initialize();
-});
+ initNavigation();
+};
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", bootstrap);
+} else {
+ bootstrap();
+}
async function getTurnstileToken(): Promise<{
token: string;
diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts
index c7c26a631..59d1fbd0d 100644
--- a/src/client/Matchmaking.ts
+++ b/src/client/Matchmaking.ts
@@ -1,31 +1,27 @@
import { html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators.js";
-import { UserMeResponse } from "src/core/ApiSchemas";
+import { UserMeResponse } from "../core/ApiSchemas";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { generateID } from "../core/Util";
+import { getUserMe } from "./Api";
import { getPlayToken } from "./Auth";
+import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import "./components/PatternButton";
import { JoinLobbyEvent } from "./Main";
import { translateText } from "./Utils";
@customElement("matchmaking-modal")
-export class MatchmakingModal extends LitElement {
+export class MatchmakingModal extends BaseModal {
private gameCheckInterval: ReturnType
| null = null;
- private connected = false;
- private elo = "unknown";
+ @state() private connected = false;
@state() private socket: WebSocket | null = null;
-
@state() private gameID: string | null = null;
- @query("o-modal") private modalEl!: HTMLElement & {
- open: () => void;
- close: () => void;
- onClose?: () => void;
- isModalOpen: boolean;
- };
+ private elo = "unknown";
constructor() {
super();
+ this.id = "page-matchmaking";
document.addEventListener("userMeResponse", (event: Event) => {
const customEvent = event as CustomEvent;
if (customEvent.detail) {
@@ -43,33 +39,106 @@ export class MatchmakingModal extends LitElement {
}
render() {
+ const eloDisplay = html`
+
+ ${translateText("matchmaking_modal.elo", { elo: this.elo })}
+
+ `;
+
+ const content = html`
+
+
+
+
+
+ ${translateText("matchmaking_modal.title")}
+
+
+
+
+ ${eloDisplay} ${this.renderInner()}
+
+
+ `;
+
+ if (this.inline) {
+ return content;
+ }
+
return html`
-
- ${translateText("matchmaking_modal.elo", { elo: this.elo })}
-
- ${this.renderInner()}
+ ${content}
`;
}
private renderInner() {
if (!this.connected) {
- return html`
- ${translateText("matchmaking_modal.connecting")}
-
`;
+ return html`
+
+
+
+ ${translateText("matchmaking_modal.connecting")}
+
+
+ `;
}
if (this.gameID === null) {
- return html`
- ${translateText("matchmaking_modal.searching")}
-
`;
+ return html`
+
+
+
+ ${translateText("matchmaking_modal.searching")}
+
+
+ `;
} else {
- return html`
- ${translateText("matchmaking_modal.waiting_for_game")}
-
`;
+ return html`
+
+
+
+ ${translateText("matchmaking_modal.waiting_for_game")}
+
+
+ `;
}
}
@@ -104,29 +173,51 @@ export class MatchmakingModal extends LitElement {
this.socket.onerror = (event: ErrorEvent) => {
console.error("WebSocket error occurred:", event);
};
- this.socket.onclose = (event) => {
+ this.socket.onclose = () => {
console.log("Matchmaking server closed connection");
};
}
- public close() {
+ protected async onOpen(): Promise {
+ const userMe = await getUserMe();
+
+ // Early return if modal was closed during async operation
+ if (!this.isModalOpen) {
+ return;
+ }
+
+ const isLoggedIn =
+ userMe &&
+ userMe.user &&
+ (userMe.user.discord !== undefined || userMe.user.email !== undefined);
+ if (!isLoggedIn) {
+ window.dispatchEvent(
+ new CustomEvent("show-message", {
+ detail: {
+ message: translateText("matchmaking_button.must_login"),
+ color: "red",
+ duration: 3000,
+ },
+ }),
+ );
+ this.close();
+ return;
+ }
+ this.connected = false;
+ this.gameID = null;
+ this.connect();
+ this.gameCheckInterval = setInterval(() => this.checkGame(), 3000);
+ }
+
+ protected onClose(): void {
this.connected = false;
this.socket?.close();
- this.modalEl?.close();
if (this.gameCheckInterval) {
clearInterval(this.gameCheckInterval);
this.gameCheckInterval = null;
}
}
- public async open() {
- this.modalEl!.onClose = () => this.close();
- this.modalEl?.open();
- this.requestUpdate();
- this.connect();
- this.gameCheckInterval = setInterval(() => this.checkGame(), 3000);
- }
-
private async checkGame() {
if (this.gameID === null) {
return;
@@ -171,7 +262,7 @@ export class MatchmakingModal extends LitElement {
@customElement("matchmaking-button")
export class MatchmakingButton extends LitElement {
- @query("matchmaking-modal") private matchmakingModal: MatchmakingModal;
+ @query("matchmaking-modal") private matchmakingModal?: MatchmakingModal;
constructor() {
super();
diff --git a/src/client/Navigation.ts b/src/client/Navigation.ts
new file mode 100644
index 000000000..04d876782
--- /dev/null
+++ b/src/client/Navigation.ts
@@ -0,0 +1,77 @@
+export function initNavigation() {
+ const showPage = (pageId: string) => {
+ // Hide all pages
+ document.querySelectorAll(".page-content").forEach((el) => {
+ el.classList.add("hidden");
+ el.classList.remove("block");
+ });
+ document.getElementById("page-play")?.classList.add("hidden");
+
+ const target = document.getElementById(pageId);
+ if (target) {
+ target.classList.remove("hidden");
+ // Modals need block display explicitly
+ if (target.classList.contains("page-content")) {
+ target.classList.add("block");
+ }
+
+ // If the target itself is a modal component with inline attribute, open it
+ if (
+ target.hasAttribute("inline") &&
+ typeof (target as any).open === "function"
+ ) {
+ (target as any).open();
+ }
+ }
+
+ // Update active state on menu items
+ document.querySelectorAll(".nav-menu-item").forEach((item) => {
+ if ((item as HTMLElement).dataset.page === pageId) {
+ item.classList.add("active");
+ } else {
+ item.classList.remove("active");
+ }
+ });
+
+ // Dispatch CustomEvent to notify listeners of page change
+ window.dispatchEvent(new CustomEvent("showPage", { detail: pageId }));
+ };
+
+ window.showPage = showPage;
+
+ document.querySelectorAll(".nav-menu-item[data-page]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const pageId = (el as HTMLElement).dataset.page;
+ if (pageId) showPage(pageId);
+ });
+ });
+
+ // Handle clicks on main container to close open modals (navigate back)
+ const mainEl = document.querySelector("main");
+ if (mainEl) {
+ mainEl.addEventListener("click", (e: Event) => {
+ const target = e.target as HTMLElement;
+ const isPlayPageHidden = document
+ .getElementById("page-play")
+ ?.classList.contains("hidden");
+
+ // Only proceed if we are NOT on the play page (meaning a modal page is open)
+ if (isPlayPageHidden) {
+ // If clicking on the main container directly (e.g. padding/background)
+ // or the max-width wrapper div directly
+ const wrapper = mainEl.firstElementChild as HTMLElement;
+ if (target === mainEl || (wrapper && target === wrapper)) {
+ showPage("page-play");
+ }
+ }
+ });
+ }
+
+ // Set default active if not set
+ const initialPage = document.querySelector(
+ '.nav-menu-item[data-page="page-play"]',
+ );
+ if (initialPage && !initialPage.classList.contains("active")) {
+ showPage("page-play");
+ }
+}
diff --git a/src/client/NewsModal.ts b/src/client/NewsModal.ts
index 4fe01609c..2cca111c7 100644
--- a/src/client/NewsModal.ts
+++ b/src/client/NewsModal.ts
@@ -1,114 +1,102 @@
-import { LitElement, css, html } from "lit";
+import { html, LitElement } from "lit";
import { resolveMarkdown } from "lit-markdown";
import { customElement, property, query } from "lit/decorators.js";
import version from "resources/version.txt?raw";
import { translateText } from "../client/Utils";
-import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
+import { BaseModal } from "./components/BaseModal";
import changelog from "/changelog.md?url";
import megaphone from "/images/Megaphone.svg?url";
@customElement("news-modal")
-export class NewsModal extends LitElement {
- @query("o-modal") private modalEl!: HTMLElement & {
- open: () => void;
- close: () => void;
- };
-
- 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();
- }
- };
-
+export class NewsModal extends BaseModal {
@property({ type: String }) markdown = "Loading...";
private initialized: boolean = false;
- static styles = css`
- :host {
- display: block;
- }
-
- .news-container {
- overflow-y: auto;
- padding: 1rem;
- display: flex;
- flex-direction: column;
- gap: 1.5rem;
- }
-
- .news-content {
- color: #ddd;
- line-height: 1.5;
- background: rgba(0, 0, 0, 0.6);
- border-radius: 8px;
- padding: 1rem;
- }
-
- .news-content a {
- color: #4a9eff !important;
- text-decoration: underline !important;
- transition: color 0.2s ease;
- }
-
- .news-content a:hover {
- color: #6fb3ff !important;
- }
- `;
-
render() {
- return html`
-
-
-
-
-
- ${resolveMarkdown(this.markdown, {
- includeImages: true,
- includeCodeBlockClassNames: true,
- })}
-
-
+ const content = html`
+
+
+
+
+
+ ${translateText("news.title")}
+
-
-
- ${translateText("news.see_all_releases")}
-
${translateText("news.github_link")}.
+
+ ${resolveMarkdown(this.markdown, {
+ includeImages: true,
+ includeCodeBlockClassNames: true,
+ })}
+
+ `;
-
+ if (this.inline) {
+ return content;
+ }
+
+ return html`
+
+ ${content}
`;
}
- public open() {
+ protected onOpen(): void {
if (!this.initialized) {
this.initialized = true;
fetch(changelog)
.then((response) => (response.ok ? response.text() : "Failed to load"))
.then((markdown) =>
markdown
+ // Convert bold header lines (e.g. "**Title**") into real Markdown headers
+ // Exclude lines starting with - or * to avoid converting bullet points
+ .replace(/^([^\-*\s].*?) \*\*(.+?)\*\*$/gm, "## $1 $2")
.replace(
/(?
@@ -122,12 +110,6 @@ export class NewsModal extends LitElement {
)
.then((markdown) => (this.markdown = markdown));
}
- this.requestUpdate();
- this.modalEl?.open();
- }
-
- private close() {
- this.modalEl?.close();
}
}
@@ -144,35 +126,29 @@ export class NewsButton extends LitElement {
const lastSeenVersion = localStorage.getItem("last-seen-version");
if (lastSeenVersion !== null && lastSeenVersion !== version) {
setTimeout(() => {
- this.openNewsModel();
+ this.open();
}, 500);
}
}
- private openNewsModel() {
+ public open() {
localStorage.setItem("last-seen-version", version);
this.newsModal.open();
}
render() {
return html`
-
-
-
+
`;
}
-
- createRenderRoot() {
- return this;
- }
}
diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts
index 52cc2e2ab..c5ac27ffb 100644
--- a/src/client/PublicLobby.ts
+++ b/src/client/PublicLobby.ts
@@ -5,7 +5,6 @@ import {
Duos,
GameMapType,
GameMode,
- hasUnusualThumbnailSize,
HumansVsNations,
PublicGameModifiers,
Quads,
@@ -120,78 +119,117 @@ export class PublicLobby extends LitElement {
);
const mapImageSrc = this.mapImages.get(lobby.gameID);
- const isUnusualThumbnailSize = hasUnusualThumbnailSize(
- lobby.gameConfig.gameMap,
- );
return html`