diff --git a/resources/lang/en.json b/resources/lang/en.json
index a8a130767b..d4b49d0d4e 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -210,7 +210,33 @@
"not_found": "Lobby not found. Please check the ID and try again.",
"error": "An error occurred. Please try again or contact support.",
"joined_waiting": "Joined successfully! Waiting for game to start...",
- "version_mismatch": "This game was created with a different version. Cannot join."
+ "version_mismatch": "This game was created with a different version. Cannot join.",
+ "game_settings": "Game Settings",
+ "map": "Map",
+ "map_size": "Map Size",
+ "difficulty": "Difficulty: ",
+ "bots": "Bots",
+ "game_mode": "Game Mode",
+ "teams": "Teams",
+ "modifiers": "Game Modifiers",
+ "disabled_units": "Disabled Units",
+ "npcs_disabled": "NPCs Disabled",
+ "infinite_gold": "Infinite Gold",
+ "infinite_troops": "Infinite Troops",
+ "instant_build": "Instant Build",
+ "donate_gold": "Gold Donations",
+ "donate_troops": "Troop Donations",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "normal": "Normal",
+ "compact": "Compact",
+ "easy": "Easy",
+ "medium": "Medium",
+ "hard": "Hard",
+ "ffa": "Free For All",
+ "team_mode": "Team",
+ "none": "None",
+ "enabled_settings": "Enabled Settings"
},
"public_lobby": {
"join": "Join next Game",
diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts
index 37f954476e..351047a34c 100644
--- a/src/client/HostLobbyModal.ts
+++ b/src/client/HostLobbyModal.ts
@@ -9,6 +9,7 @@ import {
GameMapSize,
GameMapType,
GameMode,
+ GameType,
Quads,
Trios,
UnitType,
@@ -24,9 +25,9 @@ import {
import { generateID } from "../core/Util";
import "./components/baseComponents/Modal";
import "./components/Difficulties";
+import "./components/GameOptionsDisplay";
import "./components/Maps";
import { JoinLobbyEvent } from "./Main";
-import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
@customElement("host-lobby-modal")
export class HostLobbyModal extends LitElement {
@@ -311,155 +312,14 @@ export class HostLobbyModal extends LitElement {
${translateText("host_modal.options_title")}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ${translateText("host_modal.enables_title")}
-
-
- ${renderUnitTypeOptions({
- disabledUnits: this.disabledUnits,
- toggleUnit: this.toggleUnit.bind(this),
- })}
-
-
-
+
@@ -579,8 +439,7 @@ export class HostLobbyModal extends LitElement {
}
// Modified to include debouncing
- private handleBotsChange(e: Event) {
- const value = parseInt((e.target as HTMLInputElement).value);
+ private handleBotsChange(value: number) {
if (isNaN(value) || value < 0 || value > 400) {
return;
}
@@ -636,6 +495,59 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig();
}
+ /**
+ * Gets the current game configuration for the options display component
+ */
+ private getCurrentGameConfig(): GameConfig {
+ return {
+ gameMap: this.selectedMap,
+ gameMapSize: this.compactMap ? GameMapSize.Compact : GameMapSize.Normal,
+ difficulty: this.selectedDifficulty,
+ disableNPCs: this.disableNPCs,
+ bots: this.bots,
+ infiniteGold: this.infiniteGold,
+ donateGold: this.donateGold,
+ infiniteTroops: this.infiniteTroops,
+ donateTroops: this.donateTroops,
+ instantBuild: this.instantBuild,
+ gameMode: this.gameMode,
+ gameType:
+ this.gameMode === GameMode.FFA ? GameType.Private : GameType.Private,
+ disabledUnits: this.disabledUnits,
+ playerTeams: this.teamCount,
+ } as GameConfig;
+ }
+
+ /**
+ * Unified handler for option changes from the shared component
+ */
+ private handleOptionChange(key: string, value: boolean) {
+ switch (key) {
+ case "disableNPCs":
+ this.disableNPCs = value;
+ break;
+ case "instantBuild":
+ this.instantBuild = value;
+ break;
+ case "donateGold":
+ this.donateGold = value;
+ break;
+ case "donateTroops":
+ this.donateTroops = value;
+ break;
+ case "infiniteGold":
+ this.infiniteGold = value;
+ break;
+ case "infiniteTroops":
+ this.infiniteTroops = value;
+ break;
+ case "compactMap":
+ this.compactMap = value;
+ break;
+ }
+ this.putGameConfig();
+ }
+
private async handleGameModeSelection(value: GameMode) {
this.gameMode = value;
this.putGameConfig();
diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts
index 2c89e9804e..babfb63837 100644
--- a/src/client/JoinPrivateLobbyModal.ts
+++ b/src/client/JoinPrivateLobbyModal.ts
@@ -1,10 +1,12 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
-import { GameInfo, GameRecordSchema } from "../core/Schemas";
+import { GameConfig, GameInfo, GameRecordSchema } from "../core/Schemas";
import { generateID } from "../core/Util";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { JoinLobbyEvent } from "./Main";
+import "./components/GameOptionsDisplay";
+import "./components/LobbyCard";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import { getApiBase } from "./jwt";
@@ -18,6 +20,7 @@ export class JoinPrivateLobbyModal extends LitElement {
@state() private message: string = "";
@state() private hasJoined = false;
@state() private players: string[] = [];
+ @state() private gameConfig: GameConfig | null = null;
private playersInterval: NodeJS.Timeout | null = null;
@@ -88,6 +91,7 @@ export class JoinPrivateLobbyModal extends LitElement {
`
: ""}
+ ${this.renderGameSettings()}
${!this.hasJoined
@@ -117,6 +121,7 @@ export class JoinPrivateLobbyModal extends LitElement {
public close() {
this.lobbyIdInput.value = "";
this.modalEl?.close();
+ this.gameConfig = null;
if (this.playersInterval) {
clearInterval(this.playersInterval);
this.playersInterval = null;
@@ -169,6 +174,40 @@ export class JoinPrivateLobbyModal extends LitElement {
}
}
+ /**
+ * Renders the game settings section
+ */
+ private renderGameSettings() {
+ if (!this.hasJoined || !this.gameConfig) {
+ return html``;
+ }
+
+ return html`
+
+
+
+
+
+
+ ${translateText("host_modal.options_title")}
+
+
+
+
+
+ `;
+ }
+
private async joinLobby(): Promise
{
const lobbyId = this.lobbyIdInput.value;
console.log(`Joining lobby with ID: ${lobbyId}`);
@@ -201,19 +240,27 @@ export class JoinPrivateLobbyModal extends LitElement {
private async checkActiveLobby(lobbyId: string): Promise {
const config = await getServerConfigFromClient();
- const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`;
+ const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}`;
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
- const gameInfo = await response.json();
+ if (response.status === 404) {
+ return false;
+ }
- if (gameInfo.exists) {
+ const gameInfo: GameInfo = await response.json();
+
+ if (gameInfo.gameID) {
this.message = translateText("private_lobby.joined_waiting");
this.hasJoined = true;
+ // Store the game config immediately
+ this.gameConfig = gameInfo.gameConfig ?? null;
+ this.players = gameInfo.clients?.map((p) => p.username) ?? [];
+
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
@@ -315,6 +362,7 @@ export class JoinPrivateLobbyModal extends LitElement {
.then((response) => response.json())
.then((data: GameInfo) => {
this.players = data.clients?.map((p) => p.username) ?? [];
+ this.gameConfig = data.gameConfig ?? null;
})
.catch((error) => {
console.error("Error polling players:", error);
diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts
index 3c186be1ff..07d906589f 100644
--- a/src/client/PublicLobby.ts
+++ b/src/client/PublicLobby.ts
@@ -1,9 +1,9 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
-import { renderDuration, translateText } from "../client/Utils";
-import { GameMapType, GameMode } from "../core/game/Game";
+import { GameMapType } from "../core/game/Game";
import { GameID, GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
+import "./components/LobbyCard";
import { JoinLobbyEvent } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
@@ -17,6 +17,7 @@ export class PublicLobby extends LitElement {
private currLobby: GameInfo | null = null;
private debounceDelay: number = 750;
private lobbyIDToStart = new Map();
+ private lobbyIDToCurrentMap = new Map();
createRenderRoot() {
return this;
@@ -50,9 +51,14 @@ export class PublicLobby extends LitElement {
this.lobbyIDToStart.set(l.gameID, msUntilStart + Date.now());
}
- // Load map image if not already loaded
- if (l.gameConfig && !this.mapImages.has(l.gameID)) {
- this.loadMapImage(l.gameID, l.gameConfig.gameMap);
+ // Load map image if not already loaded or if map has changed
+ if (l.gameConfig) {
+ const currentMap = this.lobbyIDToCurrentMap.get(l.gameID);
+ if (currentMap !== l.gameConfig.gameMap) {
+ // Map has changed or not loaded yet, reload the image
+ this.loadMapImage(l.gameID, l.gameConfig.gameMap);
+ this.lobbyIDToCurrentMap.set(l.gameID, l.gameConfig.gameMap);
+ }
}
});
} catch (error) {
@@ -103,75 +109,24 @@ export class PublicLobby extends LitElement {
const start = this.lobbyIDToStart.get(lobby.gameID) ?? 0;
const timeRemaining = Math.max(0, Math.floor((start - Date.now()) / 1000));
- // Format time to show minutes and seconds
- const timeDisplay = renderDuration(timeRemaining);
-
- const teamCount =
- lobby.gameConfig.gameMode === GameMode.Team
- ? (lobby.gameConfig.playerTeams ?? 0)
- : null;
-
const mapImageSrc = this.mapImages.get(lobby.gameID);
return html`
-
+ this.lobbyClicked(lobby)}
+ >
`;
}
diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts
index 01e62f9283..12c19eb02b 100644
--- a/src/client/SinglePlayerModal.ts
+++ b/src/client/SinglePlayerModal.ts
@@ -20,12 +20,12 @@ import { generateID } from "../core/Util";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import "./components/Difficulties";
+import "./components/GameOptionsDisplay";
import "./components/Maps";
import { fetchCosmetics } from "./Cosmetics";
import { FlagInput } from "./FlagInput";
import { JoinLobbyEvent } from "./Main";
import { UsernameInput } from "./UsernameInput";
-import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
@customElement("single-player-modal")
export class SinglePlayerModal extends LitElement {
@@ -219,119 +219,14 @@ export class SinglePlayerModal extends LitElement {
${translateText("single_modal.options_title")}
-
-
-
-
-
-
-
-
-
-
-
-
-
- ${translateText("single_modal.enables_title")}
-
-
- ${renderUnitTypeOptions({
- disabledUnits: this.disabledUnits,
- toggleUnit: this.toggleUnit.bind(this),
- })}
+
@@ -371,8 +266,7 @@ export class SinglePlayerModal extends LitElement {
this.selectedDifficulty = value;
}
- private handleBotsChange(e: Event) {
- const value = parseInt((e.target as HTMLInputElement).value);
+ private handleBotsChange(value: number) {
if (isNaN(value) || value < 0 || value > 400) {
return;
}
@@ -420,6 +314,51 @@ export class SinglePlayerModal extends LitElement {
: this.disabledUnits.filter((u) => u !== unit);
}
+ /**
+ * Gets the current game configuration for the options display component
+ */
+ private getCurrentGameConfig() {
+ return {
+ gameMap: this.selectedMap,
+ gameMapSize: this.compactMap ? GameMapSize.Compact : GameMapSize.Normal,
+ difficulty: this.selectedDifficulty,
+ disableNPCs: this.disableNPCs,
+ bots: this.bots,
+ infiniteGold: this.infiniteGold,
+ donateGold: true,
+ infiniteTroops: this.infiniteTroops,
+ donateTroops: true,
+ instantBuild: this.instantBuild,
+ gameMode: this.gameMode,
+ gameType: GameType.Singleplayer,
+ disabledUnits: this.disabledUnits,
+ playerTeams: this.teamCount,
+ };
+ }
+
+ /**
+ * Unified handler for option changes from the shared component
+ */
+ private handleOptionChange(key: string, value: boolean) {
+ switch (key) {
+ case "disableNPCs":
+ this.disableNPCs = value;
+ break;
+ case "instantBuild":
+ this.instantBuild = value;
+ break;
+ case "infiniteGold":
+ this.infiniteGold = value;
+ break;
+ case "infiniteTroops":
+ this.infiniteTroops = value;
+ break;
+ case "compactMap":
+ this.compactMap = value;
+ break;
+ }
+ }
+
private async startGame() {
// If random map is selected, choose a random map now
if (this.useRandomMap) {
diff --git a/src/client/Transport.ts b/src/client/Transport.ts
index e525aaf7fc..3ea8a7700a 100644
--- a/src/client/Transport.ts
+++ b/src/client/Transport.ts
@@ -575,7 +575,7 @@ export class Transport {
} else {
console.log(
"WebSocket is not open. Current state:",
- this.socket!.readyState,
+ this.socket?.readyState ?? "socket is null",
);
console.log("attempting reconnect");
}
diff --git a/src/client/components/BotSliderCard.ts b/src/client/components/BotSliderCard.ts
new file mode 100644
index 0000000000..24fe6ca337
--- /dev/null
+++ b/src/client/components/BotSliderCard.ts
@@ -0,0 +1,122 @@
+import { css, html, LitElement, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { translateText } from "../Utils";
+
+@customElement("bot-slider-card")
+export class BotSliderCard extends LitElement {
+ @property({ type: Number }) bots: number = 0;
+ @property({ type: Boolean }) editable: boolean = false;
+ @property({ attribute: false }) onBotsChange?: (value: number) => void;
+
+ static styles = css`
+ :host {
+ display: contents;
+ }
+
+ .option-card {
+ width: 100%;
+ min-width: 6.25rem;
+ max-width: 7.5rem;
+ padding: 0.25rem 0.25rem 0 0.25rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: space-between;
+ background: rgba(30, 30, 30, 0.95);
+ border: 0.125rem solid rgba(255, 255, 255, 0.1);
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.2s ease-in-out;
+ }
+
+ .option-card:hover {
+ transform: translateY(-0.125rem);
+ border-color: rgba(255, 255, 255, 0.3);
+ background: rgba(40, 40, 40, 0.95);
+ }
+
+ .option-card.selected {
+ border-color: #4a9eff;
+ background: rgba(74, 158, 255, 0.1);
+ }
+
+ .option-card-title {
+ font-size: 0.875rem;
+ color: #aaa;
+ text-align: center;
+ margin: 0 0 0.25rem 0;
+ }
+
+ input[type="range"] {
+ width: 90%;
+ accent-color: #4a9eff;
+ cursor: pointer;
+ }
+
+ input[type="range"]:disabled {
+ cursor: not-allowed;
+ opacity: 0.7;
+ }
+ `;
+
+ render() {
+ if (this.editable) {
+ return html`
+
+ `;
+ } else {
+ return html`
+
+
+
+ ${translateText("host_modal.bots")}
+ ${this.bots === 0
+ ? translateText("host_modal.bots_disabled")
+ : this.bots}
+
+
+ `;
+ }
+ }
+
+ private handleInput(e: Event) {
+ const value = parseInt((e.target as HTMLInputElement).value);
+ if (this.onBotsChange) {
+ this.onBotsChange(value);
+ }
+ }
+
+ private handleChange(e: Event) {
+ const value = parseInt((e.target as HTMLInputElement).value);
+ if (this.onBotsChange) {
+ this.onBotsChange(value);
+ }
+ }
+}
diff --git a/src/client/components/CheckboxCard.ts b/src/client/components/CheckboxCard.ts
new file mode 100644
index 0000000000..d8ee76aed2
--- /dev/null
+++ b/src/client/components/CheckboxCard.ts
@@ -0,0 +1,172 @@
+import { css, html, LitElement, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+
+/**
+ * A reusable checkbox card component for game options
+ * Displays a checkbox with label in a card-style layout
+ * Supports both editable and read-only modes
+ */
+@customElement("checkbox-card")
+export class CheckboxCard extends LitElement {
+ /**
+ * Unique identifier for the checkbox input
+ */
+ @property({ type: String }) inputId: string = "";
+
+ /**
+ * Display label text for the checkbox
+ */
+ @property({ type: String }) label: string = "";
+
+ /**
+ * Current checked state of the checkbox
+ */
+ @property({ type: Boolean }) checked: boolean = false;
+
+ /**
+ * Whether the checkbox should be editable (true) or read-only (false)
+ */
+ @property({ type: Boolean }) editable: boolean = false;
+
+ /**
+ * Optional custom width for the card (e.g., "8.75rem")
+ */
+ @property({ type: String }) width?: string;
+
+ /**
+ * Optional custom styles for the title
+ */
+ @property({ type: String }) titleStyle?: string;
+
+ /**
+ * Callback function when checkbox state changes
+ */
+ @property({ attribute: false }) onChange?: (checked: boolean) => void;
+
+ static styles = css`
+ :host {
+ display: contents;
+ }
+
+ .option-card {
+ width: 100%;
+ min-width: 6.25rem;
+ max-width: 7.5rem;
+ padding: 0.25rem 0.25rem 0 0.25rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: space-between;
+ background: rgba(30, 30, 30, 0.95);
+ border: 0.125rem solid rgba(255, 255, 255, 0.1);
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.2s ease-in-out;
+ }
+
+ .option-card:hover {
+ transform: translateY(-0.125rem);
+ border-color: rgba(255, 255, 255, 0.3);
+ background: rgba(40, 40, 40, 0.95);
+ }
+
+ .option-card.selected {
+ border-color: #4a9eff;
+ background: rgba(74, 158, 255, 0.1);
+ }
+
+ .option-card-title {
+ font-size: 0.875rem;
+ color: #aaa;
+ text-align: center;
+ margin: 0 0 0.25rem 0;
+ }
+
+ .option-card input[type="checkbox"] {
+ display: none;
+ }
+
+ label.option-card:hover {
+ transform: none;
+ }
+
+ .checkbox-icon {
+ width: 1rem;
+ height: 1rem;
+ border: 0.125rem solid #aaa;
+ border-radius: 0.375rem;
+ margin: 0.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease-in-out;
+ }
+
+ .option-card.selected .checkbox-icon {
+ border-color: #4a9eff;
+ background: #4a9eff;
+ }
+
+ .option-card.selected .checkbox-icon::after {
+ content: "✓";
+ color: white;
+ }
+ `;
+
+ render(): TemplateResult {
+ if (this.editable) {
+ return this.renderEditable();
+ } else {
+ return this.renderReadOnly();
+ }
+ }
+
+ /**
+ * Renders the editable (interactive) version of the checkbox card
+ */
+ private renderEditable(): TemplateResult {
+ const style = this.width ? `width: ${this.width};` : "";
+
+ return html`
+
+ `;
+ }
+
+ /**
+ * Renders the read-only version of the checkbox card
+ */
+ private renderReadOnly(): TemplateResult {
+ const style = this.width ? `width: ${this.width};` : "";
+
+ return html`
+
+ `;
+ }
+}
diff --git a/src/client/components/GameOptionsDisplay.ts b/src/client/components/GameOptionsDisplay.ts
new file mode 100644
index 0000000000..b0d861b13a
--- /dev/null
+++ b/src/client/components/GameOptionsDisplay.ts
@@ -0,0 +1,297 @@
+import { html, LitElement, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { GameMapSize, UnitType } from "../../core/game/Game";
+import { GameConfig } from "../../core/Schemas";
+import { translateText } from "../Utils";
+import "./CheckboxCard";
+
+/**
+ * Shared component for displaying game options and unit settings
+ * Used by both HostLobbyModal (interactive) and JoinPrivateLobbyModal (read-only)
+ */
+@customElement("game-options-display")
+export class GameOptionsDisplay extends LitElement {
+ /**
+ * The game configuration to display
+ */
+ @property({ type: Object }) gameConfig?: GameConfig;
+
+ /**
+ * Whether the options should be editable (true) or read-only (false)
+ */
+ @property({ type: Boolean }) editable: boolean = false;
+
+ /**
+ * Current bot count value (for interactive mode)
+ */
+ @property({ type: Number }) bots: number = 0;
+
+ /**
+ * Callback for when bot count changes (interactive mode only)
+ */
+ @property({ attribute: false }) onBotsChange?: (value: number) => void;
+
+ /**
+ * Callback for when a checkbox option changes (interactive mode only)
+ */
+ @property({ attribute: false }) onOptionChange?: (
+ key: string,
+ value: boolean,
+ ) => void;
+
+ /**
+ * Callback for when a unit is toggled (interactive mode only)
+ */
+ @property({ attribute: false }) onUnitToggle?: (
+ unit: UnitType,
+ disabled: boolean,
+ ) => void;
+
+ createRenderRoot() {
+ return this;
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ // Use display: contents so this element doesn't participate in flex layout
+ // This makes its children direct flex items of the parent .option-cards container
+ this.style.display = "contents";
+ }
+
+ render() {
+ if (!this.gameConfig) return html``;
+
+ return html`
+ ${this.renderBotSlider()} ${this.renderCheckboxOptions()}
+ ${this.renderUnitSettings()}
+ `;
+ }
+
+ /**
+ * Renders the bot count slider
+ */
+ private renderBotSlider() {
+ if (!this.gameConfig) return html``;
+
+ const botCount = this.editable ? this.bots : this.gameConfig.bots;
+
+ if (this.editable) {
+ return html`
+
+ `;
+ } else {
+ // Read-only mode
+ return html`
+
+ `;
+ }
+ }
+
+ /**
+ * Renders all checkbox options (NPCs, instant build, donations, etc.)
+ */
+ private renderCheckboxOptions() {
+ if (!this.gameConfig) return html``;
+
+ const options = [
+ { key: "disableNPCs", label: "disable_nations" },
+ { key: "instantBuild", label: "instant_build" },
+ { key: "donateGold", label: "donate_gold" },
+ { key: "donateTroops", label: "donate_troops" },
+ { key: "infiniteGold", label: "infinite_gold" },
+ { key: "infiniteTroops", label: "infinite_troops" },
+ ];
+
+ return html`
+ ${options.map((option) =>
+ this.renderCheckboxOption(option.key, option.label),
+ )}
+ ${this.renderCompactMapOption()}
+ `;
+ }
+
+ /**
+ * Renders a single checkbox option
+ */
+ private renderCheckboxOption(key: string, label: string): TemplateResult {
+ if (!this.gameConfig) return html``;
+
+ const value = this.gameConfig[key as keyof GameConfig] as boolean;
+
+ return html`
+ {
+ if (this.onOptionChange) {
+ this.onOptionChange(key, checked);
+ }
+ }}
+ >
+ `;
+ }
+
+ /**
+ * Renders the compact map option
+ */
+ private renderCompactMapOption(): TemplateResult {
+ if (!this.gameConfig) return html``;
+
+ const isCompact = this.gameConfig.gameMapSize === GameMapSize.Compact;
+
+ return html`
+ {
+ if (this.onOptionChange) {
+ this.onOptionChange("compactMap", checked);
+ }
+ }}
+ >
+ `;
+ }
+
+ /**
+ * Renders the unit enable/disable settings
+ */
+ private renderUnitSettings(): TemplateResult {
+ if (!this.gameConfig) return html``;
+
+ const unitOptions = [
+ { type: UnitType.City, translationKey: "unit_type.city" },
+ { type: UnitType.DefensePost, translationKey: "unit_type.defense_post" },
+ { type: UnitType.Port, translationKey: "unit_type.port" },
+ { type: UnitType.Warship, translationKey: "unit_type.warship" },
+ {
+ type: UnitType.MissileSilo,
+ translationKey: "unit_type.missile_silo",
+ },
+ { type: UnitType.SAMLauncher, translationKey: "unit_type.sam_launcher" },
+ { type: UnitType.AtomBomb, translationKey: "unit_type.atom_bomb" },
+ {
+ type: UnitType.HydrogenBomb,
+ translationKey: "unit_type.hydrogen_bomb",
+ },
+ { type: UnitType.MIRV, translationKey: "unit_type.mirv" },
+ { type: UnitType.Factory, translationKey: "unit_type.factory" },
+ ];
+
+ const disabledUnits = this.gameConfig.disabledUnits ?? [];
+
+ return html`
+
+
+
+ ${this.editable
+ ? translateText("host_modal.enables_title")
+ : translateText("private_lobby.enabled_settings")}
+
+
+ ${this.editable
+ ? html`
+
+ ${unitOptions.map(
+ ({ type, translationKey }) => html`
+ {
+ if (this.onUnitToggle) {
+ this.onUnitToggle(type, !checked);
+ }
+ }}
+ >
+ `,
+ )}
+
+ `
+ : unitOptions.map(
+ ({ type, translationKey }) => html`
+
+ `,
+ )}
+ `;
+ }
+}
diff --git a/src/client/components/LobbyCard.ts b/src/client/components/LobbyCard.ts
new file mode 100644
index 0000000000..b67b21d322
--- /dev/null
+++ b/src/client/components/LobbyCard.ts
@@ -0,0 +1,239 @@
+import { LitElement, html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { GameMode } from "../../core/game/Game";
+import { GameConfig } from "../../core/Schemas";
+import { terrainMapFileLoader } from "../TerrainMapFileLoader";
+import { renderDuration, translateText } from "../Utils";
+
+/**
+ * Reusable lobby card component
+ * Used by PublicLobby to advertise lobbies and JoinPrivateLobbyModal to display game info
+ */
+@customElement("lobby-card")
+export class LobbyCard extends LitElement {
+ /**
+ * Game configuration to display
+ */
+ @property({ type: Object }) gameConfig?: GameConfig;
+
+ /**
+ * Map image source URL
+ */
+ @property({ type: String }) mapImageSrc?: string;
+
+ /**
+ * Whether the card should be highlighted (green gradient)
+ */
+ @property({ type: Boolean }) highlighted: boolean = false;
+
+ /**
+ * Whether the card is clickable/interactive
+ */
+ @property({ type: Boolean }) interactive: boolean = true;
+
+ /**
+ * Show the "Join next Game" CTA
+ */
+ @property({ type: Boolean }) showCta: boolean = true;
+
+ /**
+ * Show player count
+ */
+ @property({ type: Boolean }) showPlayerCount: boolean = true;
+
+ /**
+ * Show countdown timer
+ */
+ @property({ type: Boolean }) showTimer: boolean = true;
+
+ /**
+ * Show difficulty level
+ */
+ @property({ type: Boolean }) showDifficulty: boolean = false;
+
+ /**
+ * Number of current players
+ */
+ @property({ type: Number }) numClients?: number;
+
+ /**
+ * Maximum number of players
+ */
+ @property({ type: Number }) maxPlayers?: number;
+
+ /**
+ * Time remaining in seconds
+ */
+ @property({ type: Number }) timeRemaining?: number;
+
+ /**
+ * Whether the card is in a debounced state
+ */
+ @property({ type: Boolean }) debounced: boolean = false;
+
+ private currentMapType: string = "";
+
+ createRenderRoot() {
+ return this;
+ }
+
+ async connectedCallback() {
+ super.connectedCallback();
+ // Load map image if needed and not provided
+ if (this.gameConfig && !this.mapImageSrc) {
+ this.loadMapImage();
+ }
+ }
+
+ updated(changedProperties: Map) {
+ super.updated(changedProperties);
+
+ // Check if gameConfig changed and if the map type is different
+ if (changedProperties.has("gameConfig") && this.gameConfig) {
+ if (this.gameConfig.gameMap !== this.currentMapType) {
+ this.loadMapImage();
+ }
+ }
+ }
+
+ private async loadMapImage() {
+ if (!this.gameConfig) return;
+
+ try {
+ const data = terrainMapFileLoader.getMapData(this.gameConfig.gameMap);
+ this.mapImageSrc = await data.webpPath();
+ this.currentMapType = this.gameConfig.gameMap;
+ this.requestUpdate();
+ } catch (error) {
+ console.error("Failed to load map image:", error);
+ }
+ }
+
+ render() {
+ if (!this.gameConfig) return html``;
+
+ const teamCount =
+ this.gameConfig.gameMode === GameMode.Team
+ ? (this.gameConfig.playerTeams ?? 0)
+ : null;
+
+ const timeDisplay =
+ this.timeRemaining !== undefined
+ ? renderDuration(this.timeRemaining)
+ : "";
+
+ const cardClasses = `
+ isolate grid h-40 grid-cols-[100%] grid-rows-[100%] place-content-stretch w-full overflow-hidden
+ ${
+ this.highlighted
+ ? "bg-gradient-to-r from-green-600 to-green-500"
+ : "bg-gradient-to-r from-blue-600 to-blue-500"
+ }
+ text-white font-medium rounded-xl transition-opacity duration-200
+ ${this.interactive ? "hover:opacity-90 cursor-pointer" : ""}
+ ${this.debounced ? "opacity-70 cursor-not-allowed" : ""}
+ `;
+
+ const cardContent = html`
+ ${this.mapImageSrc
+ ? html`
`
+ : html``}
+
+
+ ${this.showCta
+ ? html`
+ ${translateText("public_lobby.join")}
+
`
+ : ""}
+
+ ${this.gameConfig.gameMode === GameMode.Team
+ ? typeof teamCount === "string"
+ ? translateText(`public_lobby.teams_${teamCount}`)
+ : translateText("public_lobby.teams", {
+ num: teamCount ?? 0,
+ })
+ : translateText("game_mode.ffa")}
+
+ ${translateText(
+ `map.${this.gameConfig.gameMap.toLowerCase().replace(/\s+/g, "")}`,
+ )}
+
+ ${this.showDifficulty
+ ? html`
+ ${translateText(
+ "private_lobby.difficulty",
+ )}${this.formatDifficulty()}
+
`
+ : ""}
+
+
+
+ ${this.showPlayerCount &&
+ this.numClients !== undefined &&
+ this.maxPlayers !== undefined
+ ? html`
+ ${this.numClients} / ${this.maxPlayers}
+
`
+ : ""}
+ ${this.showTimer && timeDisplay
+ ? html`
+ ${timeDisplay}
+
`
+ : ""}
+
+
+ `;
+
+ return this.interactive
+ ? html`
+
+ `
+ : html` ${cardContent}
`;
+ }
+
+ private formatDifficulty(): string {
+ if (!this.gameConfig) return "";
+
+ const difficulty = this.gameConfig.difficulty;
+
+ // Difficulty is a string enum ("Easy", "Medium", "Hard", "Impossible")
+ const translated = translateText(`difficulty.${difficulty}`);
+ if (translated !== `difficulty.${difficulty}`) {
+ return translated;
+ }
+
+ return String(difficulty);
+ }
+
+ private handleClick() {
+ if (this.interactive && !this.debounced) {
+ this.dispatchEvent(
+ new CustomEvent("card-click", {
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+ }
+}
diff --git a/src/client/components/MaxTimerCard.ts b/src/client/components/MaxTimerCard.ts
new file mode 100644
index 0000000000..7631103478
--- /dev/null
+++ b/src/client/components/MaxTimerCard.ts
@@ -0,0 +1,191 @@
+import { css, html, LitElement, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { translateText } from "../Utils";
+
+/**
+ * A specialized card component for the Max Timer option
+ * Displays a checkbox and a number input when checked
+ */
+@customElement("max-timer-card")
+export class MaxTimerCard extends LitElement {
+ /**
+ * Unique identifier for the checkbox input
+ */
+ @property({ type: String }) inputId: string = "max-timer";
+
+ /**
+ * Current value of the timer (undefined if disabled)
+ */
+ @property({ type: Number }) value?: number;
+
+ /**
+ * Whether the option is editable
+ */
+ @property({ type: Boolean }) editable: boolean = false;
+
+ /**
+ * Callback when the checkbox is toggled
+ */
+ @property({ attribute: false }) onToggle?: (enabled: boolean) => void;
+
+ /**
+ * Callback when the timer value changes
+ */
+ @property({ attribute: false }) onValueChange?: (value: number) => void;
+
+ static styles = css`
+ :host {
+ display: contents;
+ }
+
+ .option-card {
+ width: 100%;
+ min-width: 6.25rem;
+ max-width: 7.5rem;
+ padding: 0.25rem 0.25rem 0 0.25rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: space-between;
+ background: rgba(30, 30, 30, 0.95);
+ border: 0.125rem solid rgba(255, 255, 255, 0.1);
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.2s ease-in-out;
+ }
+
+ .option-card:hover {
+ transform: translateY(-0.125rem);
+ border-color: rgba(255, 255, 255, 0.3);
+ background: rgba(40, 40, 40, 0.95);
+ }
+
+ .option-card.selected {
+ border-color: #4a9eff;
+ background: rgba(74, 158, 255, 0.1);
+ }
+
+ .option-card-title {
+ font-size: 0.875rem;
+ color: #aaa;
+ text-align: center;
+ margin: 0 0 0.25rem 0;
+ }
+
+ .option-card input[type="checkbox"] {
+ opacity: 0;
+ position: absolute;
+ pointer-events: none;
+ }
+
+ label.option-card:hover {
+ transform: none;
+ }
+
+ .checkbox-icon {
+ width: 1rem;
+ height: 1rem;
+ border: 0.125rem solid #aaa;
+ border-radius: 0.375rem;
+ margin: 0.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease-in-out;
+ }
+
+ .option-card.selected .checkbox-icon {
+ border-color: #4a9eff;
+ background: #4a9eff;
+ }
+
+ .option-card.selected .checkbox-icon::after {
+ content: "✓";
+ color: white;
+ }
+
+ input[type="number"] {
+ width: 60px;
+ color: black;
+ text-align: right;
+ border-radius: 8px;
+ padding: 2px 4px;
+ border: 1px solid #ccc;
+ }
+ `;
+
+ render(): TemplateResult {
+ const isEnabled = this.value !== undefined;
+
+ if (this.editable) {
+ return html`
+
+
+ ${isEnabled
+ ? html`
e.stopPropagation()}
+ />`
+ : ""}
+
+ ${translateText("host_modal.max_timer")}
+
+
+ `;
+ } else {
+ return html`
+
+
+ ${isEnabled
+ ? html`
${this.value}`
+ : ""}
+
+ ${translateText("host_modal.max_timer")}
+
+
+ `;
+ }
+ }
+
+ private handleCardClick(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (this.onToggle) {
+ this.onToggle(this.value === undefined);
+ }
+ }
+
+ private handleValueInput(e: Event) {
+ const input = e.target as HTMLInputElement;
+ input.value = input.value.replace(/[e+-]/gi, "");
+ const val = parseInt(input.value);
+
+ if (isNaN(val) || val < 0 || val > 120) {
+ return;
+ }
+
+ if (this.onValueChange) {
+ this.onValueChange(val);
+ }
+ }
+
+ private handleKeyDown(e: KeyboardEvent) {
+ if (["-", "+", "e"].includes(e.key)) {
+ e.preventDefault();
+ }
+ }
+}
diff --git a/src/client/utilities/RenderUnitTypeOptions.ts b/src/client/utilities/RenderUnitTypeOptions.ts
deleted file mode 100644
index 0392935d6f..0000000000
--- a/src/client/utilities/RenderUnitTypeOptions.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-// renderUnitTypeOptions.ts
-import { html, TemplateResult } from "lit";
-import { UnitType } from "../../core/game/Game";
-import { translateText } from "../Utils";
-
-export interface UnitTypeRenderContext {
- disabledUnits: UnitType[];
- toggleUnit: (unit: UnitType, checked: boolean) => void;
-}
-
-const unitOptions: { type: UnitType; translationKey: string }[] = [
- { type: UnitType.City, translationKey: "unit_type.city" },
- { type: UnitType.DefensePost, translationKey: "unit_type.defense_post" },
- { type: UnitType.Port, translationKey: "unit_type.port" },
- { type: UnitType.Warship, translationKey: "unit_type.warship" },
- { type: UnitType.MissileSilo, translationKey: "unit_type.missile_silo" },
- { type: UnitType.SAMLauncher, translationKey: "unit_type.sam_launcher" },
- { type: UnitType.AtomBomb, translationKey: "unit_type.atom_bomb" },
- { type: UnitType.HydrogenBomb, translationKey: "unit_type.hydrogen_bomb" },
- { type: UnitType.MIRV, translationKey: "unit_type.mirv" },
- { type: UnitType.Factory, translationKey: "unit_type.factory" },
-];
-
-export function renderUnitTypeOptions({
- disabledUnits,
- toggleUnit,
-}: UnitTypeRenderContext): TemplateResult[] {
- return unitOptions.map(
- ({ type, translationKey }) => html`
-
- `,
- );
-}