From 0f4b5da1dcee5e5e7c9e2574a20672416c7e5adf Mon Sep 17 00:00:00 2001 From: DanielF737 Date: Thu, 16 Oct 2025 09:53:16 +1100 Subject: [PATCH 1/5] add lobby info to join private lobby --- resources/lang/en.json | 28 +- src/client/HostLobbyModal.ts | 216 +++------- src/client/JoinPrivateLobbyModal.ts | 56 ++- src/client/PublicLobby.ts | 97 ++--- src/client/SinglePlayerModal.ts | 171 +++----- src/client/Transport.ts | 2 +- src/client/components/GameOptionsDisplay.ts | 374 ++++++++++++++++++ src/client/components/LobbyCard.ts | 238 +++++++++++ src/client/utilities/RenderUnitTypeOptions.ts | 49 --- 9 files changed, 837 insertions(+), 394 deletions(-) create mode 100644 src/client/components/GameOptionsDisplay.ts create mode 100644 src/client/components/LobbyCard.ts delete mode 100644 src/client/utilities/RenderUnitTypeOptions.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index a8a130767b..49e12d0464 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/GameOptionsDisplay.ts b/src/client/components/GameOptionsDisplay.ts new file mode 100644 index 0000000000..6f8fa57e52 --- /dev/null +++ b/src/client/components/GameOptionsDisplay.ts @@ -0,0 +1,374 @@ +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"; + +/** + * 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; + + if (this.editable) { + return html` + + `; + } else { + // Read-only mode + return html` +
+
+ ${translateText(`host_modal.${label}`)} +
+
+ `; + } + } + + /** + * Renders the compact map option + */ + private renderCompactMapOption(): TemplateResult { + if (!this.gameConfig) return html``; + + const isCompact = this.gameConfig.gameMapSize === GameMapSize.Compact; + + if (this.editable) { + return html` + + `; + } else { + // Read-only mode + return html` +
+
+ ${translateText("host_modal.compact_map")} +
+
+ `; + } + } + + /** + * 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` + + `, + )} +
+ ` + : unitOptions.map( + ({ type, translationKey }) => html` +
+
+ ${translateText(translationKey)} +
+
+ `, + )} + `; + } +} diff --git a/src/client/components/LobbyCard.ts b/src/client/components/LobbyCard.ts new file mode 100644 index 0000000000..3436110f52 --- /dev/null +++ b/src/client/components/LobbyCard.ts @@ -0,0 +1,238 @@ +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`${this.gameConfig.gameMap}` + : 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/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` - - `, - ); -} From 781edcddcf203f9fb5db3f7aceb10414d123cb86 Mon Sep 17 00:00:00 2001 From: DanielF737 Date: Thu, 16 Oct 2025 10:03:22 +1100 Subject: [PATCH 2/5] fix translation of template string --- resources/lang/en.json | 2 +- src/client/components/LobbyCard.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 49e12d0464..d4b49d0d4e 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -214,7 +214,7 @@ "game_settings": "Game Settings", "map": "Map", "map_size": "Map Size", - "difficulty": "Difficulty", + "difficulty": "Difficulty: ", "bots": "Bots", "game_mode": "Game Mode", "teams": "Teams", diff --git a/src/client/components/LobbyCard.ts b/src/client/components/LobbyCard.ts index 3436110f52..b67b21d322 100644 --- a/src/client/components/LobbyCard.ts +++ b/src/client/components/LobbyCard.ts @@ -175,8 +175,9 @@ export class LobbyCard extends LitElement { ${this.showDifficulty ? html`
- ${translateText("private_lobby.difficulty")}: - ${this.formatDifficulty()} + ${translateText( + "private_lobby.difficulty", + )}${this.formatDifficulty()}
` : ""} From 904662d3f8786acf4b4f0e980797a03f0df958bc Mon Sep 17 00:00:00 2001 From: DanielF737 Date: Thu, 16 Oct 2025 11:03:22 +1100 Subject: [PATCH 3/5] fix issues with checkbox state double handling --- src/client/components/CheckboxCard.ts | 172 +++++++++++++++++++ src/client/components/GameOptionsDisplay.ts | 173 ++++++-------------- 2 files changed, 220 insertions(+), 125 deletions(-) create mode 100644 src/client/components/CheckboxCard.ts diff --git a/src/client/components/CheckboxCard.ts b/src/client/components/CheckboxCard.ts new file mode 100644 index 0000000000..0609e47f7f --- /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 }) id: 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` +
+
+ ${this.label} +
+
+ `; + } +} diff --git a/src/client/components/GameOptionsDisplay.ts b/src/client/components/GameOptionsDisplay.ts index 6f8fa57e52..b0d861b13a 100644 --- a/src/client/components/GameOptionsDisplay.ts +++ b/src/client/components/GameOptionsDisplay.ts @@ -3,6 +3,7 @@ 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 @@ -176,53 +177,19 @@ export class GameOptionsDisplay extends LitElement { const value = this.gameConfig[key as keyof GameConfig] as boolean; - if (this.editable) { - return html` - - `; - } else { - // Read-only mode - return html` -
-
- ${translateText(`host_modal.${label}`)} -
-
- `; - } + return html` + { + if (this.onOptionChange) { + this.onOptionChange(key, checked); + } + }} + > + `; } /** @@ -233,53 +200,19 @@ export class GameOptionsDisplay extends LitElement { const isCompact = this.gameConfig.gameMapSize === GameMapSize.Compact; - if (this.editable) { - return html` - - `; - } else { - // Read-only mode - return html` -
-
- ${translateText("host_modal.compact_map")} -
-
- `; - } + return html` + { + if (this.onOptionChange) { + this.onOptionChange("compactMap", checked); + } + }} + > + `; } /** @@ -330,43 +263,33 @@ export class GameOptionsDisplay extends LitElement { > ${unitOptions.map( ({ type, translationKey }) => html` - + { + if (this.onUnitToggle) { + this.onUnitToggle(type, !checked); + } + }} + > `, )} ` : unitOptions.map( ({ type, translationKey }) => html` -
-
- ${translateText(translationKey)} -
-
+ `, )} `; From 4e07166977783b38b70579c86b1c857a6d8e4e59 Mon Sep 17 00:00:00 2001 From: DanielF737 Date: Thu, 16 Oct 2025 11:17:03 +1100 Subject: [PATCH 4/5] dont override the native id --- src/client/components/CheckboxCard.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/client/components/CheckboxCard.ts b/src/client/components/CheckboxCard.ts index 0609e47f7f..d8ee76aed2 100644 --- a/src/client/components/CheckboxCard.ts +++ b/src/client/components/CheckboxCard.ts @@ -11,7 +11,7 @@ export class CheckboxCard extends LitElement { /** * Unique identifier for the checkbox input */ - @property({ type: String }) id: string = ""; + @property({ type: String }) inputId: string = ""; /** * Display label text for the checkbox @@ -129,14 +129,14 @@ export class CheckboxCard extends LitElement { return html`