diff --git a/resources/lang/bg.json b/resources/lang/bg.json
index 562270927..601803e73 100644
--- a/resources/lang/bg.json
+++ b/resources/lang/bg.json
@@ -790,15 +790,15 @@
"title_with_name": "Изпрати войници на {name}",
"available_tooltip": "Текущите ти войници на разположение",
"min_keep": "Минимално запазени",
- "slider_tooltip": "{{percent}}% • {{amount}}",
+ "slider_tooltip": "{percent}% • {amount}",
"aria_slider": "Плъзгач за войници",
- "capacity_note": "Получателят може да приеме само {{amount}} в момента."
+ "capacity_note": "Получателят може да приеме само {amount} в момента."
},
"send_gold_modal": {
"title_with_name": "Изпрати злато на {name}",
"available_tooltip": "Текущото ти злато на разположение",
"aria_slider": "Количествен плъзгач",
- "slider_tooltip": "{{percent}}% • {{amount}}"
+ "slider_tooltip": "{percent}% • {amount}"
},
"replay_panel": {
"replay_speed": "Скорост на повторението",
diff --git a/resources/lang/de.json b/resources/lang/de.json
index f1ad97fad..5161f74e6 100644
--- a/resources/lang/de.json
+++ b/resources/lang/de.json
@@ -659,15 +659,15 @@
"title_with_name": "Truppen an {name} senden",
"available_tooltip": "Verfügbare Truppen",
"min_keep": "Mindestbestand",
- "slider_tooltip": "{{percent}} % • {{amount}}",
+ "slider_tooltip": "{percent} % • {amount}",
"aria_slider": "Truppenregler",
- "capacity_note": "Der Empfänger kann derzeit nur {{amount}} annehmen."
+ "capacity_note": "Der Empfänger kann derzeit nur {amount} annehmen."
},
"send_gold_modal": {
"title_with_name": "Gold an {name} senden",
"available_tooltip": "Verfügbares Gold",
"aria_slider": "Mengenregler",
- "slider_tooltip": "{{percent}} % • {{amount}}"
+ "slider_tooltip": "{percent} % • {amount}"
},
"replay_panel": {
"replay_speed": "Wiedergabegeschwindigkeit",
diff --git a/resources/lang/el.json b/resources/lang/el.json
index cde595ed1..1b56b61f2 100644
--- a/resources/lang/el.json
+++ b/resources/lang/el.json
@@ -631,17 +631,17 @@
"available_tooltip": "Τα τρέχοντα διαθέσιμα στρατεύματά σου",
"min_keep": "Ελάχιστη διατήρηση",
"min_keep_pct": "(30%)",
- "slider_tooltip": "{{percent}}% • {{amount}}",
+ "slider_tooltip": "{percent}% • {amount}",
"toggle_attack_bar_mode": "Χρησιμοποίησε την μπάρα επίθεσης για να στείλεις στρατεύματα",
"warning_attackbar": "Μόλις ενεργοποιηθεί, δεν θα μπορείς να ανοίξεις απευθείας αυτό το modal. Θα στέλνεις στρατεύματα μόνο μέσω της μπάρας επίθεσης.",
"aria_slider": "Ρυθμιστής στρατευμάτων",
- "capacity_note": "Ο παραλήπτης μπορεί να δεχτεί μόνο {{amount}} τώρα."
+ "capacity_note": "Ο παραλήπτης μπορεί να δεχτεί μόνο {amount} τώρα."
},
"send_gold_modal": {
"title_with_name": "Αποστολή Χρυσού στον {name}",
"available_tooltip": "Διαθέσιμος χρυσός",
"aria_slider": "Ρυθμιστικό ποσού",
- "slider_tooltip": "{{percent}}% • {{amount}}"
+ "slider_tooltip": "{percent}% • {amount}"
},
"replay_panel": {
"replay_speed": "Ταχύτητα επανάληψης",
diff --git a/resources/lang/en.json b/resources/lang/en.json
index f19a112a7..3da94f112 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -847,7 +847,8 @@
"choose_spawn": "Choose a starting location",
"random_spawn": "Random spawn is enabled. Selecting starting location for you...",
"singleplayer_game_paused": "Game paused",
- "multiplayer_game_paused": "Game paused by Lobby Creator"
+ "multiplayer_game_paused": "Game paused by Lobby Creator",
+ "pvp_immunity_active": "PVP immunity active for {seconds}s"
},
"territory_patterns": {
"title": "Skins",
diff --git a/resources/lang/fa.json b/resources/lang/fa.json
index 34cc78ce2..2651bb558 100644
--- a/resources/lang/fa.json
+++ b/resources/lang/fa.json
@@ -659,15 +659,15 @@
"title_with_name": "ارسال نیروها به {name}",
"available_tooltip": "نیروهای فعلی در دسترس شما",
"min_keep": "حداقل نگهداری",
- "slider_tooltip": "{{percent}}% • {{amount}}",
+ "slider_tooltip": "{percent}% • {amount}",
"aria_slider": "نوار نیروها",
- "capacity_note": "گیرنده فقط میتواند {{amount}} را در حال حاضر بپذیرد."
+ "capacity_note": "گیرنده فقط میتواند {amount} را در حال حاضر بپذیرد."
},
"send_gold_modal": {
"title_with_name": "ارسال طلا به {name}",
"available_tooltip": "طلا فعلی در دسترس شما",
"aria_slider": "اسلاید مقدار",
- "slider_tooltip": "{{percent}}% • {{amount}}"
+ "slider_tooltip": "{percent}% • {amount}"
},
"replay_panel": {
"replay_speed": "سرعت بازپخش",
diff --git a/resources/lang/fr.json b/resources/lang/fr.json
index 0df822e4e..3b00095aa 100644
--- a/resources/lang/fr.json
+++ b/resources/lang/fr.json
@@ -790,15 +790,15 @@
"title_with_name": "Envoyer des troupes à {name}",
"available_tooltip": "Vos troupes actuellement disponibles",
"min_keep": "Garder au minimum",
- "slider_tooltip": "{{percent}}% • {{amount}}",
+ "slider_tooltip": "{percent}% • {amount}",
"aria_slider": "Curseur de troupes",
- "capacity_note": "Le destinataire ne peut accepter que {{amount}} pour le moment."
+ "capacity_note": "Le destinataire ne peut accepter que {amount} pour le moment."
},
"send_gold_modal": {
"title_with_name": "Envoyer de l'or à {name}",
"available_tooltip": "Votre or disponible",
"aria_slider": "Curseur de quantité",
- "slider_tooltip": "{{percent}}% • {{amount}}"
+ "slider_tooltip": "{percent}% • {amount}"
},
"replay_panel": {
"replay_speed": "Vitesse de relecture",
diff --git a/resources/lang/id.json b/resources/lang/id.json
index 7f7a55fc4..7336db6e4 100644
--- a/resources/lang/id.json
+++ b/resources/lang/id.json
@@ -790,15 +790,15 @@
"title_with_name": "Kirim Pasukan ke {name}",
"available_tooltip": "Pasukan Anda yang tersedia saat ini",
"min_keep": "Minimal yang ditinggalkan",
- "slider_tooltip": "{{percent}}% • {{amount}}",
+ "slider_tooltip": "{percent}% • {amount}",
"aria_slider": "Penggeser pasukan",
- "capacity_note": "Penerima hanya dapat menerima {{amount}} saat ini."
+ "capacity_note": "Penerima hanya dapat menerima {amount} saat ini."
},
"send_gold_modal": {
"title_with_name": "Kirim Emas ke {name}",
"available_tooltip": "Emas yang Anda miliki saat ini",
"aria_slider": "Penggeser jumlah",
- "slider_tooltip": "{{percent}}% • {{amount}}"
+ "slider_tooltip": "{percent}% • {amount}"
},
"replay_panel": {
"replay_speed": "Kecepatan tanyangan ulang",
diff --git a/resources/lang/ja.json b/resources/lang/ja.json
index 89751baae..52cb8b127 100644
--- a/resources/lang/ja.json
+++ b/resources/lang/ja.json
@@ -790,15 +790,15 @@
"title_with_name": "{name}へ軍隊を送信",
"available_tooltip": "あなたの兵士数",
"min_keep": "最小値を保つ",
- "slider_tooltip": "{{percent}}% • {{amount}}",
+ "slider_tooltip": "{percent}% • {amount}",
"aria_slider": "軍隊スライダー",
- "capacity_note": "現在、受取主は {{amount}} のみ受け取ることができます。"
+ "capacity_note": "現在、受取主は {amount} のみ受け取ることができます。"
},
"send_gold_modal": {
"title_with_name": "{name}へゴールドを送信",
"available_tooltip": "現在利用可能な資金",
"aria_slider": "スライダー",
- "slider_tooltip": "{{percent}}% • {{amount}}"
+ "slider_tooltip": "{percent}% • {amount}"
},
"replay_panel": {
"replay_speed": "再生速度",
diff --git a/resources/lang/ko.json b/resources/lang/ko.json
index c7addd441..986b2aa64 100644
--- a/resources/lang/ko.json
+++ b/resources/lang/ko.json
@@ -659,15 +659,15 @@
"title_with_name": "{name}에게 병력 보내기",
"available_tooltip": "현재 지원 가능한 병력",
"min_keep": "최소 보유량",
- "slider_tooltip": "{{percent}}% • {{amount}}",
+ "slider_tooltip": "{percent}% • {amount}",
"aria_slider": "병력 슬라이더",
- "capacity_note": "상대방은 지금 {{amount}}까지만 받을 수 있습니다."
+ "capacity_note": "상대방은 지금 {amount}까지만 받을 수 있습니다."
},
"send_gold_modal": {
"title_with_name": "{name}에게 금 보내기",
"available_tooltip": "현재 보유 중인 금",
"aria_slider": "수량 슬라이더",
- "slider_tooltip": "{{percent}}% • {{amount}}"
+ "slider_tooltip": "{percent}% • {amount}"
},
"replay_panel": {
"replay_speed": "리플레이 속도",
diff --git a/resources/lang/nl.json b/resources/lang/nl.json
index f8784c045..f4f30f145 100644
--- a/resources/lang/nl.json
+++ b/resources/lang/nl.json
@@ -790,15 +790,15 @@
"title_with_name": "Stuur Troepen naar {name}",
"available_tooltip": "Jouw nu beschikbare troepen",
"min_keep": "Min. houden",
- "slider_tooltip": "{{percent}}% • {{amount}}",
+ "slider_tooltip": "{percent}% • {amount}",
"aria_slider": "Troepen schuifbalk",
- "capacity_note": "Ontvanger kan slechts {{amount}} accepteren momenteel."
+ "capacity_note": "Ontvanger kan slechts {amount} accepteren momenteel."
},
"send_gold_modal": {
"title_with_name": "Stuur Goud naar {name}",
"available_tooltip": "Jouw nu beschikbare goud",
"aria_slider": "Bedrag schuifbalk",
- "slider_tooltip": "{{percent}}% • {{amount}}"
+ "slider_tooltip": "{percent}% • {amount}"
},
"replay_panel": {
"replay_speed": "Afspeelsnelheid",
diff --git a/resources/lang/pl.json b/resources/lang/pl.json
index 9ab359593..a0be1ccbc 100644
--- a/resources/lang/pl.json
+++ b/resources/lang/pl.json
@@ -631,17 +631,17 @@
"available_tooltip": "Obecnie dostępne wojsko",
"min_keep": "Min. utrzymanie",
"min_keep_pct": "(30%)",
- "slider_tooltip": "{{percent}}% • {{amount}}",
+ "slider_tooltip": "{percent}% • {amount}",
"toggle_attack_bar_mode": "Użyj suwaka ataku, aby wysłać wojsko",
"warning_attackbar": "Po włączeniu tej opcji nie możesz bezpośrednio otworzyć tego modułu. Będziesz wysyłał wojsko tylko przez suwak ataku.",
"aria_slider": "Suwak Jednostek",
- "capacity_note": "Odbiorca może teraz zaakceptować tylko {{amount}}."
+ "capacity_note": "Odbiorca może teraz zaakceptować tylko {amount}."
},
"send_gold_modal": {
"title_with_name": "Wyślij Złoto do {name}",
"available_tooltip": "Obecnie dostępne złoto",
"aria_slider": "Suwak Ilości Złota",
- "slider_tooltip": "{{percent}}% • {{amount}}"
+ "slider_tooltip": "{percent}% • {amount}"
},
"replay_panel": {
"replay_speed": "Prędkość powtórki",
diff --git a/resources/lang/ru.json b/resources/lang/ru.json
index 89e9daf93..96d3d7a3d 100644
--- a/resources/lang/ru.json
+++ b/resources/lang/ru.json
@@ -790,15 +790,15 @@
"title_with_name": "Отправить войска игроку {name}",
"available_tooltip": "Ваши доступные в данный момент войска",
"min_keep": "Минимальный остаток",
- "slider_tooltip": "{{percent}}% • {{amount}}",
+ "slider_tooltip": "{percent}% • {amount}",
"aria_slider": "Ползунок войск",
- "capacity_note": "Получатель в данный момент может принять только {{amount}}."
+ "capacity_note": "Получатель в данный момент может принять только {amount}."
},
"send_gold_modal": {
"title_with_name": "Отправить золото игроку {name}",
"available_tooltip": "Ваше доступное в данный момент золото",
"aria_slider": "Ползунок количества",
- "slider_tooltip": "{{percent}}% • {{amount}}"
+ "slider_tooltip": "{percent}% • {amount}"
},
"replay_panel": {
"replay_speed": "Скорость повтора",
diff --git a/resources/lang/tr.json b/resources/lang/tr.json
index 940e12098..d6c483718 100644
--- a/resources/lang/tr.json
+++ b/resources/lang/tr.json
@@ -790,15 +790,15 @@
"title_with_name": "{name}'e Birlik Gönder",
"available_tooltip": "Şu anda mevcut olan birliklerin",
"min_keep": "Minimum Elinde Tutma",
- "slider_tooltip": "%{{percent}} •{{amount}}",
+ "slider_tooltip": "%{percent} •{amount}",
"aria_slider": "Birlik çubuğu",
- "capacity_note": "Alıcı ancak {{amount}} kadar alabilir."
+ "capacity_note": "Alıcı ancak {amount} kadar alabilir."
},
"send_gold_modal": {
"title_with_name": "{name}'e Altın Gönder",
"available_tooltip": "Mevcut altının",
"aria_slider": "Miktar çubuğu",
- "slider_tooltip": "%{{percent}} • {{amount}}"
+ "slider_tooltip": "%{percent} • {amount}"
},
"replay_panel": {
"replay_speed": "Tekrar oynatma hızı",
diff --git a/resources/lang/uk.json b/resources/lang/uk.json
index 80e797bac..bf680f6f3 100644
--- a/resources/lang/uk.json
+++ b/resources/lang/uk.json
@@ -790,15 +790,15 @@
"title_with_name": "Надіслати війська до {name}",
"available_tooltip": "Ваші поточні доступні війська",
"min_keep": "Мінімальний залишок",
- "slider_tooltip": "{{percent}}% • {{amount}}",
+ "slider_tooltip": "{percent}% • {amount}",
"aria_slider": "Повзунок військ",
- "capacity_note": "Зараз отримувач може прийняти лише {{amount}}."
+ "capacity_note": "Зараз отримувач може прийняти лише {amount}."
},
"send_gold_modal": {
"title_with_name": "Надіслати золото до {name}",
"available_tooltip": "Ваше поточне доступне золото",
"aria_slider": "Повзунок кількості",
- "slider_tooltip": "{{percent}}% • {{amount}}"
+ "slider_tooltip": "{percent}% • {amount}"
},
"replay_panel": {
"replay_speed": "Швидкість відтворення",
diff --git a/resources/lang/zh-CN.json b/resources/lang/zh-CN.json
index c090cd319..9cb843e61 100644
--- a/resources/lang/zh-CN.json
+++ b/resources/lang/zh-CN.json
@@ -790,15 +790,15 @@
"title_with_name": "向 {name} 发送军队",
"available_tooltip": "你的剩余军队",
"min_keep": "最小保留值",
- "slider_tooltip": "{{percent}}% • {{amount}}",
+ "slider_tooltip": "{percent}% • {amount}",
"aria_slider": "军队滑块",
- "capacity_note": "接收者现在仅能接收 {{amount}}。"
+ "capacity_note": "接收者现在仅能接收 {amount}。"
},
"send_gold_modal": {
"title_with_name": "向 {name} 发送黄金",
"available_tooltip": "你的剩余黄金",
"aria_slider": "数量滑块",
- "slider_tooltip": "{{percent}}% • {{amount}}"
+ "slider_tooltip": "{percent}% • {amount}"
},
"replay_panel": {
"replay_speed": "回放速度",
diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts
index 3a8f5c48f..71e661842 100644
--- a/src/client/HostLobbyModal.ts
+++ b/src/client/HostLobbyModal.ts
@@ -1,17 +1,14 @@
-import { TemplateResult, html } from "lit";
+import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { EventBus } from "../core/EventBus";
import {
Difficulty,
- Duos,
GameMapSize,
GameMapType,
GameMode,
HumansVsNations,
- Quads,
- Trios,
UnitType,
} from "../core/game/Game";
import {
@@ -27,19 +24,23 @@ import { getPlayToken } from "./Auth";
import "./components/baseComponents/Modal";
import { BaseModal } from "./components/BaseModal";
import "./components/CopyButton";
-import "./components/Difficulties";
-import "./components/FluentSlider";
+import "./components/GameConfigSettings";
import "./components/LobbyPlayerView";
-import "./components/map/MapPicker";
+import "./components/ToggleInputCard";
import { modalHeader } from "./components/ui/ModalHeader";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { JoinLobbyEvent } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import {
- renderToggleInputCard,
- renderToggleInputCardInput,
-} from "./utilities/RenderToggleInputCard";
-import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
+ getBotsForCompactMap,
+ getRandomMapType,
+ getUpdatedDisabledUnits,
+ parseBoundedFloatFromInput,
+ parseBoundedIntegerFromInput,
+ preventDisallowedKeys,
+ toOptionalNumber,
+} from "./utilities/GameConfigHelpers";
+
@customElement("host-lobby-modal")
export class HostLobbyModal extends BaseModal {
@state() private selectedMap: GameMapType = GameMapType.World;
@@ -93,32 +94,6 @@ export class HostLobbyModal extends BaseModal {
}
};
- private renderOptionToggle(
- labelKey: string,
- checked: boolean,
- onChange: (val: boolean) => void,
- hidden: boolean = false,
- ): TemplateResult {
- if (hidden) return html``;
-
- return html`
-
- `;
- }
-
private getRandomString(): string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
return Array.from(
@@ -165,34 +140,73 @@ export class HostLobbyModal extends BaseModal {
}
render() {
- const maxTimerHandlers = this.createToggleHandlers(
- () => this.maxTimer,
- (val) => (this.maxTimer = val),
- () => this.maxTimerValue,
- (val) => (this.maxTimerValue = val),
- 30,
- );
- const spawnImmunityHandlers = this.createToggleHandlers(
- () => this.spawnImmunity,
- (val) => (this.spawnImmunity = val),
- () => this.spawnImmunityDurationMinutes,
- (val) => (this.spawnImmunityDurationMinutes = val),
- 5,
- );
- const goldMultiplierHandlers = this.createToggleHandlers(
- () => this.goldMultiplier,
- (val) => (this.goldMultiplier = val),
- () => this.goldMultiplierValue,
- (val) => (this.goldMultiplierValue = val),
- 2,
- );
- const startingGoldHandlers = this.createToggleHandlers(
- () => this.startingGold,
- (val) => (this.startingGold = val),
- () => this.startingGoldValue,
- (val) => (this.startingGoldValue = val),
- 5000000,
- );
+ const inputCards = [
+ html``,
+ html``,
+ html``,
+ html``,
+ ];
const content = html`
-
-
-
-
-
-
-
- ${translateText("map.map")}
-
-
-
- this.handleMapSelection(mapValue)}
- .onSelectRandom=${() => this.handleSelectRandomMap()}
- >
-
+
+
this.kickPlayer(clientID)}
+ >
@@ -700,40 +395,6 @@ export class HostLobbyModal extends BaseModal {
this.loadNationCount();
}
- 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;
@@ -800,11 +461,15 @@ export class HostLobbyModal extends BaseModal {
private async handleSelectRandomMap() {
this.useRandomMap = true;
- this.selectedMap = this.getRandomMap();
+ this.selectedMap = getRandomMapType();
await this.loadNationCount();
this.putGameConfig();
}
+ private handleConfigRandomMapSelected = () => {
+ void this.handleSelectRandomMap();
+ };
+
private async handleMapSelection(value: GameMapType) {
this.selectedMap = value;
this.useRandomMap = false;
@@ -812,13 +477,81 @@ export class HostLobbyModal extends BaseModal {
this.putGameConfig();
}
+ private handleConfigMapSelected = (e: Event) => {
+ const customEvent = e as CustomEvent<{ map: GameMapType }>;
+ void this.handleMapSelection(customEvent.detail.map);
+ };
+
private async handleDifficultySelection(value: Difficulty) {
this.selectedDifficulty = value;
this.putGameConfig();
}
+ private handleConfigDifficultySelected = (e: Event) => {
+ const customEvent = e as CustomEvent<{ difficulty: Difficulty }>;
+ void this.handleDifficultySelection(customEvent.detail.difficulty);
+ };
+
+ private handleConfigGameModeSelected = (e: Event) => {
+ const customEvent = e as CustomEvent<{ mode: GameMode }>;
+ void this.handleGameModeSelection(customEvent.detail.mode);
+ };
+
+ private handleConfigTeamCountSelected = (e: Event) => {
+ const customEvent = e as CustomEvent<{ count: TeamCountConfig }>;
+ void this.handleTeamCountSelection(customEvent.detail.count);
+ };
+
+ private handleConfigOptionToggleChanged = (e: Event) => {
+ const customEvent = e as CustomEvent<{
+ labelKey: string;
+ checked: boolean;
+ }>;
+ const { labelKey, checked } = customEvent.detail;
+
+ switch (labelKey) {
+ case "host_modal.disable_nations":
+ void this.handleDisableNationsChange(checked);
+ break;
+ case "host_modal.instant_build":
+ this.handleInstantBuildChange(checked);
+ break;
+ case "host_modal.random_spawn":
+ this.handleRandomSpawnChange(checked);
+ break;
+ case "host_modal.donate_gold":
+ this.handleDonateGoldChange(checked);
+ break;
+ case "host_modal.donate_troops":
+ this.handleDonateTroopsChange(checked);
+ break;
+ case "host_modal.infinite_gold":
+ this.handleInfiniteGoldChange(checked);
+ break;
+ case "host_modal.infinite_troops":
+ this.handleInfiniteTroopsChange(checked);
+ break;
+ case "host_modal.compact_map":
+ this.handleCompactMapChange(checked);
+ break;
+ default:
+ break;
+ }
+ };
+
+ private handleConfigUnitToggleChanged = (e: Event) => {
+ const customEvent = e as CustomEvent<{ unit: UnitType; checked: boolean }>;
+ const { unit, checked } = customEvent.detail;
+ this.disabledUnits = getUpdatedDisabledUnits(
+ this.disabledUnits,
+ unit,
+ checked,
+ );
+ this.putGameConfig();
+ };
+
// Modified to include debouncing
- private handleBotsChange(e: Event) {
+ private handleBotsChange = (e: Event) => {
const customEvent = e as CustomEvent<{ value: number }>;
const value = customEvent.detail.value;
if (isNaN(value) || value < 0 || value > 400) {
@@ -838,67 +571,94 @@ export class HostLobbyModal extends BaseModal {
this.putGameConfig();
this.botsUpdateTimer = null;
}, 300);
- }
+ };
private handleInstantBuildChange = (val: boolean) => {
this.instantBuild = val;
this.putGameConfig();
};
- private handleSpawnImmunityDurationKeyDown(e: KeyboardEvent) {
- if (["-", "+", "e", "E"].includes(e.key)) {
- e.preventDefault();
- }
- }
+ private handleMaxTimerToggle = (
+ checked: boolean,
+ value: number | string | undefined,
+ ) => {
+ this.maxTimer = checked;
+ this.maxTimerValue = toOptionalNumber(value);
+ this.putGameConfig();
+ };
- private handleSpawnImmunityDurationInput(e: Event) {
+ private handleSpawnImmunityToggle = (
+ checked: boolean,
+ value: number | string | undefined,
+ ) => {
+ this.spawnImmunity = checked;
+ this.spawnImmunityDurationMinutes = toOptionalNumber(value);
+ this.putGameConfig();
+ };
+
+ private handleGoldMultiplierToggle = (
+ checked: boolean,
+ value: number | string | undefined,
+ ) => {
+ this.goldMultiplier = checked;
+ this.goldMultiplierValue = toOptionalNumber(value);
+ this.putGameConfig();
+ };
+
+ private handleStartingGoldToggle = (
+ checked: boolean,
+ value: number | string | undefined,
+ ) => {
+ this.startingGold = checked;
+ this.startingGoldValue = toOptionalNumber(value);
+ this.putGameConfig();
+ };
+
+ private handleSpawnImmunityDurationKeyDown = (e: KeyboardEvent) => {
+ preventDisallowedKeys(e, ["-", "+", "e", "E"]);
+ };
+
+ private handleSpawnImmunityDurationInput = (e: Event) => {
const input = e.target as HTMLInputElement;
- input.value = input.value.replace(/[eE+-]/g, "");
- const value = parseInt(input.value, 10);
- if (Number.isNaN(value) || value < 0 || value > 120) {
+ const value = parseBoundedIntegerFromInput(input, { min: 0, max: 120 });
+ if (value === undefined) {
return;
}
this.spawnImmunityDurationMinutes = value;
this.putGameConfig();
- }
+ };
- private handleGoldMultiplierValueKeyDown(e: KeyboardEvent) {
- if (["+", "-", "e", "E"].includes(e.key)) {
- e.preventDefault();
- }
- }
+ private handleGoldMultiplierValueKeyDown = (e: KeyboardEvent) => {
+ preventDisallowedKeys(e, ["+", "-", "e", "E"]);
+ };
- private handleGoldMultiplierValueChanges(e: Event) {
+ private handleGoldMultiplierValueChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
- const value = parseFloat(input.value);
+ const value = parseBoundedFloatFromInput(input, { min: 0.1, max: 1000 });
- if (isNaN(value) || value < 0.1 || value > 1000) {
+ if (value === undefined) {
this.goldMultiplierValue = undefined;
input.value = "";
} else {
this.goldMultiplierValue = value;
}
this.putGameConfig();
- }
+ };
- private handleStartingGoldValueKeyDown(e: KeyboardEvent) {
- if (["-", "+", "e", "E"].includes(e.key)) {
- e.preventDefault();
- }
- }
+ private handleStartingGoldValueKeyDown = (e: KeyboardEvent) => {
+ preventDisallowedKeys(e, ["-", "+", "e", "E"]);
+ };
- private handleStartingGoldValueChanges(e: Event) {
+ private handleStartingGoldValueChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
- input.value = input.value.replace(/[eE+-]/g, "");
- const value = parseInt(input.value);
+ const value = parseBoundedIntegerFromInput(input, {
+ min: 0,
+ max: 1000000000,
+ });
- if (isNaN(value) || value < 0 || value > 1000000000) {
- this.startingGoldValue = undefined;
- } else {
- this.startingGoldValue = value;
- }
+ this.startingGoldValue = value;
this.putGameConfig();
- }
+ };
private handleRandomSpawnChange = (val: boolean) => {
this.randomSpawn = val;
@@ -922,11 +682,7 @@ export class HostLobbyModal extends BaseModal {
private handleCompactMapChange = (val: boolean) => {
this.compactMap = val;
- if (val && this.bots === 400) {
- this.bots = 100;
- } else if (!val && this.bots === 100) {
- this.bots = 400;
- }
+ this.bots = getBotsForCompactMap(this.bots, val);
this.putGameConfig();
};
@@ -935,24 +691,24 @@ export class HostLobbyModal extends BaseModal {
this.putGameConfig();
};
- private handleMaxTimerValueKeyDown(e: KeyboardEvent) {
- if (["-", "+", "e"].includes(e.key)) {
- e.preventDefault();
- }
- }
+ private handleMaxTimerValueKeyDown = (e: KeyboardEvent) => {
+ preventDisallowedKeys(e, ["-", "+", "e"]);
+ };
- private handleMaxTimerValueChanges(e: Event) {
- (e.target as HTMLInputElement).value = (
- e.target as HTMLInputElement
- ).value.replace(/[e+-]/gi, "");
- const value = parseInt((e.target as HTMLInputElement).value);
+ private handleMaxTimerValueChanges = (e: Event) => {
+ const input = e.target as HTMLInputElement;
+ const value = parseBoundedIntegerFromInput(input, {
+ min: 1,
+ max: 120,
+ stripPattern: /[e+-]/gi,
+ });
- if (isNaN(value) || value < 0 || value > 120) {
+ if (value === undefined) {
return;
}
this.maxTimerValue = value;
this.putGameConfig();
- }
+ };
private handleDisableNationsChange = async (val: boolean) => {
this.disableNations = val;
@@ -1029,20 +785,6 @@ export class HostLobbyModal extends BaseModal {
);
}
- private toggleUnit(unit: UnitType, checked: boolean): void {
- this.disabledUnits = checked
- ? [...this.disabledUnits, unit]
- : this.disabledUnits.filter((u) => u !== unit);
-
- this.putGameConfig();
- }
-
- private getRandomMap(): GameMapType {
- const maps = Object.values(GameMapType);
- const randIdx = Math.floor(Math.random() * maps.length);
- return maps[randIdx] as GameMapType;
- }
-
private async startGame() {
await this.putGameConfig();
console.log(
diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts
index 4f7bf279c..6a72726bc 100644
--- a/src/client/SinglePlayerModal.ts
+++ b/src/client/SinglePlayerModal.ts
@@ -4,14 +4,11 @@ import { translateText } from "../client/Utils";
import { UserMeResponse } from "../core/ApiSchemas";
import {
Difficulty,
- Duos,
GameMapSize,
GameMapType,
GameMode,
GameType,
HumansVsNations,
- Quads,
- Trios,
UnitType,
} from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
@@ -21,9 +18,8 @@ import { hasLinkedAccount } from "./Api";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import { BaseModal } from "./components/BaseModal";
-import "./components/Difficulties";
-import "./components/FluentSlider";
-import "./components/map/MapPicker";
+import "./components/GameConfigSettings";
+import "./components/ToggleInputCard";
import { modalHeader } from "./components/ui/ModalHeader";
import { fetchCosmetics } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
@@ -31,10 +27,14 @@ import { FlagInput } from "./FlagInput";
import { JoinLobbyEvent } from "./Main";
import { UsernameInput } from "./UsernameInput";
import {
- renderToggleInputCard,
- renderToggleInputCardInput,
-} from "./utilities/RenderToggleInputCard";
-import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
+ getBotsForCompactMap,
+ getRandomMapType,
+ getUpdatedDisabledUnits,
+ parseBoundedFloatFromInput,
+ parseBoundedIntegerFromInput,
+ preventDisallowedKeys,
+ toOptionalNumber,
+} from "./utilities/GameConfigHelpers";
const DEFAULT_OPTIONS = {
selectedMap: GameMapType.World,
@@ -166,6 +166,60 @@ export class SinglePlayerModal extends BaseModal {
}
render() {
+ const inputCards = [
+ html`
`,
+ html`
`,
+ html`
`,
+ ];
+
const content = html`
-
-
-
-
-
-
-
- ${translateText("map.map")}
-
-
-
-
- this.handleMapSelection(mapValue)}
- .onSelectRandom=${() => this.handleSelectRandomMap()}
- >
-
-
-
-
-
-
-
- ${translateText("difficulty.difficulty")}
-
-
-
-
- ${Object.entries(Difficulty)
- .filter(([key]) => isNaN(Number(key)))
- .map(
- ([key, value]) => html`
-
- `,
- )}
-
-
-
-
-
-
-
-
- ${translateText("host_modal.mode")}
-
-
-
-
- ${[GameMode.FFA, GameMode.Team].map((mode) => {
- const isSelected = this.gameMode === mode;
- const label =
- mode === GameMode.FFA
- ? translateText("game_mode.ffa")
- : translateText("game_mode.teams");
-
- return html`
-
- `;
- })}
-
-
-
- ${this.gameMode === GameMode.FFA
- ? ""
- : html`
-
-
-
- ${translateText("host_modal.team_count")}
-
-
- ${[
- 2,
- 3,
- 4,
- 5,
- 6,
- 7,
- Quads,
- Trios,
- Duos,
- HumansVsNations,
- ].map(
- (o) => html`
-
- `,
- )}
-
-
- `}
-
-
-
-
-
-
- ${translateText("single_modal.options_title")}
-
-
-
-
-
-
-
-
-
- ${this.renderOptionToggle(
- "single_modal.disable_nations",
- this.disableNations,
- (val) => (this.disableNations = val),
- this.gameMode === GameMode.Team &&
- this.teamCount === HumansVsNations,
- )}
- ${this.renderOptionToggle(
- "single_modal.instant_build",
- this.instantBuild,
- (val) => (this.instantBuild = val),
- )}
- ${this.renderOptionToggle(
- "single_modal.random_spawn",
- this.randomSpawn,
- (val) => (this.randomSpawn = val),
- )}
- ${this.renderOptionToggle(
- "single_modal.infinite_gold",
- this.infiniteGold,
- (val) => (this.infiniteGold = val),
- )}
- ${this.renderOptionToggle(
- "single_modal.infinite_troops",
- this.infiniteTroops,
- (val) => (this.infiniteTroops = val),
- )}
- ${this.renderOptionToggle(
- "single_modal.compact_map",
- this.compactMap,
- (val) => {
- this.compactMap = val;
- if (val && this.bots === 400) {
- this.bots = 100;
- } else if (!val && this.bots === 100) {
- this.bots = 400;
- }
+
+ {
- this.maxTimer = !this.maxTimer;
- if (!this.maxTimer) {
- this.maxTimerValue = undefined;
- } else {
- // Set default value when enabling if not already set or invalid
- if (!this.maxTimerValue || this.maxTimerValue <= 0) {
- this.maxTimerValue = 30;
- }
- // Focus the input after render
- setTimeout(() => {
- const input = this.getEndTimerInput();
- if (input) {
- input.focus();
- input.select();
- }
- }, 0);
- }
+ {
+ labelKey: "single_modal.instant_build",
+ checked: this.instantBuild,
},
- input: renderToggleInputCardInput({
- id: "end-timer-value",
- min: 1,
- max: 120,
- value: this.maxTimerValue ?? "",
- ariaLabel: translateText("single_modal.max_timer"),
- placeholder: translateText(
- "single_modal.max_timer_placeholder",
- ),
- onInput: this.handleMaxTimerValueChanges,
- onKeyDown: this.handleMaxTimerValueKeyDown,
- }),
- })}
-
-
- ${renderToggleInputCard({
- labelKey: "single_modal.gold_multiplier",
- checked: this.goldMultiplier,
- onClick: () => {
- this.goldMultiplier = !this.goldMultiplier;
- if (!this.goldMultiplier) {
- this.goldMultiplierValue = undefined;
- } else {
- if (
- !this.goldMultiplierValue ||
- this.goldMultiplierValue <= 0
- ) {
- this.goldMultiplierValue = 2;
- }
- setTimeout(() => {
- const input = this.renderRoot.querySelector(
- "#gold-multiplier-value",
- ) as HTMLInputElement;
- if (input) {
- input.focus();
- input.select();
- }
- }, 0);
- }
+ {
+ labelKey: "single_modal.random_spawn",
+ checked: this.randomSpawn,
},
- input: renderToggleInputCardInput({
- id: "gold-multiplier-value",
- min: 0.1,
- max: 1000,
- step: "any",
- value: this.goldMultiplierValue ?? "",
- ariaLabel: translateText("single_modal.gold_multiplier"),
- placeholder: translateText(
- "single_modal.gold_multiplier_placeholder",
- ),
- onChange: this.handleGoldMultiplierValueChanges,
- onKeyDown: this.handleGoldMultiplierValueKeyDown,
- }),
- })}
-
-
- ${renderToggleInputCard({
- labelKey: "single_modal.starting_gold",
- checked: this.startingGold,
- onClick: () => {
- this.startingGold = !this.startingGold;
- if (!this.startingGold) {
- this.startingGoldValue = undefined;
- } else {
- if (
- !this.startingGoldValue ||
- this.startingGoldValue < 0
- ) {
- this.startingGoldValue = 5000000;
- }
- setTimeout(() => {
- const input = this.renderRoot.querySelector(
- "#starting-gold-value",
- ) as HTMLInputElement;
- if (input) {
- input.focus();
- input.select();
- }
- }, 0);
- }
+ {
+ labelKey: "single_modal.infinite_gold",
+ checked: this.infiniteGold,
},
- input: renderToggleInputCardInput({
- id: "starting-gold-value",
- min: 0,
- max: 1000000000,
- step: 100000,
- value: this.startingGoldValue ?? "",
- ariaLabel: translateText("single_modal.starting_gold"),
- placeholder: translateText(
- "single_modal.starting_gold_placeholder",
- ),
- onInput: this.handleStartingGoldValueChanges,
- onKeyDown: this.handleStartingGoldValueKeyDown,
- }),
- })}
-
-
-
-
-
-
-
-
- ${translateText("single_modal.enables_title")}
-
-
-
- ${renderUnitTypeOptions({
- disabledUnits: this.disabledUnits,
- toggleUnit: this.toggleUnit.bind(this),
- })}
-
-
-
+ {
+ labelKey: "single_modal.infinite_troops",
+ checked: this.infiniteTroops,
+ },
+ {
+ labelKey: "single_modal.compact_map",
+ checked: this.compactMap,
+ },
+ ],
+ inputCards,
+ },
+ unitTypes: {
+ titleKey: "single_modal.enables_title",
+ disabledUnits: this.disabledUnits,
+ },
+ }}
+ @map-selected=${this.handleConfigMapSelected}
+ @random-map-selected=${this.handleConfigRandomMapSelected}
+ @difficulty-selected=${this.handleConfigDifficultySelected}
+ @game-mode-selected=${this.handleConfigGameModeSelected}
+ @team-count-selected=${this.handleConfigTeamCountSelected}
+ @bots-changed=${this.handleBotsChange}
+ @option-toggle-changed=${this.handleConfigOptionToggleChanged}
+ @unit-toggle-changed=${this.handleConfigUnitToggleChanged}
+ >
@@ -698,33 +383,6 @@ export class SinglePlayerModal extends BaseModal {
);
}
- // Helper for consistent option buttons
- private renderOptionToggle(
- labelKey: string,
- checked: boolean,
- onChange: (val: boolean) => void,
- hidden: boolean = false,
- ): TemplateResult {
- if (hidden) return html``;
-
- return html`
-
- `;
- }
-
protected onClose(): void {
// Reset all transient form state to ensure clean slate
this.selectedMap = DEFAULT_OPTIONS.selectedMap;
@@ -752,29 +410,121 @@ export class SinglePlayerModal extends BaseModal {
this.useRandomMap = true;
}
+ private handleConfigRandomMapSelected = () => {
+ this.handleSelectRandomMap();
+ };
+
private handleMapSelection(value: GameMapType) {
this.selectedMap = value;
this.useRandomMap = false;
}
+ private handleConfigMapSelected = (e: Event) => {
+ const customEvent = e as CustomEvent<{ map: GameMapType }>;
+ this.handleMapSelection(customEvent.detail.map);
+ };
+
private handleDifficultySelection(value: Difficulty) {
this.selectedDifficulty = value;
}
- private handleBotsChange(e: Event) {
+ private handleConfigDifficultySelected = (e: Event) => {
+ const customEvent = e as CustomEvent<{ difficulty: Difficulty }>;
+ this.handleDifficultySelection(customEvent.detail.difficulty);
+ };
+
+ private handleConfigGameModeSelected = (e: Event) => {
+ const customEvent = e as CustomEvent<{ mode: GameMode }>;
+ this.handleGameModeSelection(customEvent.detail.mode);
+ };
+
+ private handleConfigTeamCountSelected = (e: Event) => {
+ const customEvent = e as CustomEvent<{ count: TeamCountConfig }>;
+ this.handleTeamCountSelection(customEvent.detail.count);
+ };
+
+ private handleCompactMapChange(val: boolean) {
+ this.compactMap = val;
+ this.bots = getBotsForCompactMap(this.bots, val);
+ }
+
+ private handleConfigOptionToggleChanged = (e: Event) => {
+ const customEvent = e as CustomEvent<{
+ labelKey: string;
+ checked: boolean;
+ }>;
+ const { labelKey, checked } = customEvent.detail;
+
+ switch (labelKey) {
+ case "single_modal.disable_nations":
+ this.disableNations = checked;
+ break;
+ case "single_modal.instant_build":
+ this.instantBuild = checked;
+ break;
+ case "single_modal.random_spawn":
+ this.randomSpawn = checked;
+ break;
+ case "single_modal.infinite_gold":
+ this.infiniteGold = checked;
+ break;
+ case "single_modal.infinite_troops":
+ this.infiniteTroops = checked;
+ break;
+ case "single_modal.compact_map":
+ this.handleCompactMapChange(checked);
+ break;
+ default:
+ break;
+ }
+ };
+
+ private handleConfigUnitToggleChanged = (e: Event) => {
+ const customEvent = e as CustomEvent<{ unit: UnitType; checked: boolean }>;
+ const { unit, checked } = customEvent.detail;
+ this.disabledUnits = getUpdatedDisabledUnits(
+ this.disabledUnits,
+ unit,
+ checked,
+ );
+ };
+
+ private handleBotsChange = (e: Event) => {
const customEvent = e as CustomEvent<{ value: number }>;
const value = customEvent.detail.value;
if (isNaN(value) || value < 0 || value > 400) {
return;
}
this.bots = value;
- }
+ };
- private handleMaxTimerValueKeyDown(e: KeyboardEvent) {
- if (["-", "+", "e"].includes(e.key)) {
- e.preventDefault();
- }
- }
+ private handleMaxTimerToggle = (
+ checked: boolean,
+ value: number | string | undefined,
+ ) => {
+ this.maxTimer = checked;
+ this.maxTimerValue = toOptionalNumber(value);
+ };
+
+ private handleGoldMultiplierToggle = (
+ checked: boolean,
+ value: number | string | undefined,
+ ) => {
+ this.goldMultiplier = checked;
+ this.goldMultiplierValue = toOptionalNumber(value);
+ };
+
+ private handleStartingGoldToggle = (
+ checked: boolean,
+ value: number | string | undefined,
+ ) => {
+ this.startingGold = checked;
+ this.startingGoldValue = toOptionalNumber(value);
+ };
+
+ private handleMaxTimerValueKeyDown = (e: KeyboardEvent) => {
+ preventDisallowedKeys(e, ["-", "+", "e"]);
+ };
private getEndTimerInput(): HTMLInputElement | null {
return (
@@ -785,55 +535,46 @@ export class SinglePlayerModal extends BaseModal {
);
}
- private handleMaxTimerValueChanges(e: Event) {
+ private handleMaxTimerValueChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
- input.value = input.value.replace(/[e+-]/gi, "");
- const value = parseInt(input.value);
+ const value = parseBoundedIntegerFromInput(input, {
+ min: 1,
+ max: 120,
+ stripPattern: /[e+-]/gi,
+ });
- // Always update state to keep UI and internal state in sync
- if (isNaN(value) || value < 1 || value > 120) {
- // Set to undefined for invalid/empty/out-of-range values
- this.maxTimerValue = undefined;
- } else {
- this.maxTimerValue = value;
- }
- }
+ this.maxTimerValue = value;
+ };
- private handleGoldMultiplierValueKeyDown(e: KeyboardEvent) {
- if (["+", "-", "e", "E"].includes(e.key)) {
- e.preventDefault();
- }
- }
+ private handleGoldMultiplierValueKeyDown = (e: KeyboardEvent) => {
+ preventDisallowedKeys(e, ["+", "-", "e", "E"]);
+ };
- private handleGoldMultiplierValueChanges(e: Event) {
+ private handleGoldMultiplierValueChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
- const value = parseFloat(input.value);
+ const value = parseBoundedFloatFromInput(input, { min: 0.1, max: 1000 });
- if (isNaN(value) || value < 0.1 || value > 1000) {
+ if (value === undefined) {
this.goldMultiplierValue = undefined;
input.value = "";
} else {
this.goldMultiplierValue = value;
}
- }
+ };
- private handleStartingGoldValueKeyDown(e: KeyboardEvent) {
- if (["-", "+", "e", "E"].includes(e.key)) {
- e.preventDefault();
- }
- }
+ private handleStartingGoldValueKeyDown = (e: KeyboardEvent) => {
+ preventDisallowedKeys(e, ["-", "+", "e", "E"]);
+ };
- private handleStartingGoldValueChanges(e: Event) {
+ private handleStartingGoldValueChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
- input.value = input.value.replace(/[eE+-]/g, "");
- const value = parseInt(input.value);
+ const value = parseBoundedIntegerFromInput(input, {
+ min: 0,
+ max: 1000000000,
+ });
- if (isNaN(value) || value < 0 || value > 1000000000) {
- this.startingGoldValue = undefined;
- } else {
- this.startingGoldValue = value;
- }
- }
+ this.startingGoldValue = value;
+ };
private handleGameModeSelection(value: GameMode) {
this.gameMode = value;
@@ -843,18 +584,6 @@ export class SinglePlayerModal extends BaseModal {
this.teamCount = value;
}
- private getRandomMap(): GameMapType {
- const maps = Object.values(GameMapType);
- const randIdx = Math.floor(Math.random() * maps.length);
- return maps[randIdx] as GameMapType;
- }
-
- private toggleUnit(unit: UnitType, checked: boolean): void {
- this.disabledUnits = checked
- ? [...this.disabledUnits, unit]
- : this.disabledUnits.filter((u) => u !== unit);
- }
-
private async startGame() {
// Validate and clamp maxTimer setting before starting
let finalMaxTimerValue: number | undefined = undefined;
@@ -879,7 +608,7 @@ export class SinglePlayerModal extends BaseModal {
// If random map is selected, choose a random map now
if (this.useRandomMap) {
- this.selectedMap = this.getRandomMap();
+ this.selectedMap = getRandomMapType();
}
console.log(
diff --git a/src/client/components/GameConfigSettings.ts b/src/client/components/GameConfigSettings.ts
new file mode 100644
index 000000000..b981a89fe
--- /dev/null
+++ b/src/client/components/GameConfigSettings.ts
@@ -0,0 +1,447 @@
+import {
+ LitElement,
+ SVGTemplateResult,
+ TemplateResult,
+ html,
+ nothing,
+ svg,
+} from "lit";
+import { customElement, property } from "lit/decorators.js";
+import {
+ Difficulty,
+ Duos,
+ GameMapType,
+ GameMode,
+ HumansVsNations,
+ Quads,
+ Trios,
+ UnitType,
+} from "../../core/game/Game";
+import { TeamCountConfig } from "../../core/Schemas";
+import { translateText } from "../Utils";
+import "./Difficulties";
+import "./FluentSlider";
+import "./map/MapPicker";
+
+const ACTIVE_CARD =
+ "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]";
+const INACTIVE_CARD =
+ "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
+
+const DISABLED_CARD =
+ "w-full rounded-xl border transition-all duration-200 opacity-30 grayscale cursor-not-allowed bg-white/5 border-white/5";
+
+function cardClass(active: boolean, extra = ""): string {
+ return `w-full rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 ${extra} ${active ? ACTIVE_CARD : INACTIVE_CARD}`;
+}
+
+const CARD_LABEL_CLASS =
+ "text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto";
+
+const DIFFICULTY_OPTIONS = Object.entries(Difficulty).filter(([key]) =>
+ isNaN(Number(key)),
+) as Array<[string, Difficulty]>;
+const TEAM_COUNT_OPTIONS: TeamCountConfig[] = [
+ 2,
+ 3,
+ 4,
+ 5,
+ 6,
+ 7,
+ Quads,
+ Trios,
+ Duos,
+ HumansVsNations,
+];
+
+function stateTextClass(active: boolean): string {
+ return active ? "text-white" : "text-white/60";
+}
+
+function renderTextCardButton(
+ label: string,
+ active: boolean,
+ onClick: () => void,
+ cardExtraClass: string,
+): TemplateResult {
+ return html`
+
+ `;
+}
+
+function renderSection(
+ iconSvg: SVGTemplateResult,
+ colorClass: string,
+ bgClass: string,
+ titleKey: string,
+ content: TemplateResult | TemplateResult[],
+ sectionClass = "space-y-6",
+): TemplateResult {
+ return html`
+
+ ${renderSectionHeader(iconSvg, colorClass, bgClass, titleKey)} ${content}
+
+ `;
+}
+
+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" },
+];
+
+const MAP_ICON = svg`
`;
+
+const DIFFICULTY_ICON = svg`
`;
+
+const MODE_ICON = svg`
`;
+
+const OPTIONS_ICON = svg`
`;
+
+const ENABLES_ICON = svg`
`;
+
+function renderSectionHeader(
+ iconSvg: SVGTemplateResult,
+ colorClass: string,
+ bgClass: string,
+ titleKey: string,
+): TemplateResult {
+ return html`
+
+
+
+
+
+ ${translateText(titleKey)}
+
+
+ `;
+}
+
+export interface ToggleOptionConfig {
+ labelKey: string;
+ checked: boolean;
+ hidden?: boolean;
+}
+
+export interface GameConfigSettingsData {
+ map: {
+ selected: GameMapType;
+ useRandom: boolean;
+ randomMapDivider?: boolean;
+ showMedals?: boolean;
+ mapWins?: Map
>;
+ };
+ difficulty: {
+ selected: Difficulty;
+ disabled: boolean;
+ };
+ gameMode: {
+ selected: GameMode;
+ };
+ teamCount: {
+ selected: TeamCountConfig;
+ };
+ options: {
+ titleKey: string;
+ bots: {
+ value: number;
+ labelKey: string;
+ disabledKey: string;
+ };
+ toggles: ToggleOptionConfig[];
+ inputCards: TemplateResult[];
+ };
+ unitTypes: {
+ titleKey: string;
+ disabledUnits: UnitType[];
+ };
+}
+
+@customElement("game-config-settings")
+export class GameConfigSettings extends LitElement {
+ @property({ attribute: false }) settings?: GameConfigSettingsData;
+ @property({ attribute: false }) sectionGapClass = "space-y-6";
+
+ createRenderRoot() {
+ return this;
+ }
+
+ private emit(name: string, detail: T) {
+ this.dispatchEvent(
+ new CustomEvent(name, {
+ detail,
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+
+ private handleSelectMap = (map: GameMapType) => {
+ this.emit("map-selected", { map });
+ };
+
+ private handleSelectRandom = () => {
+ this.emit("random-map-selected", {});
+ };
+
+ private handleDifficultySelect = (difficulty: Difficulty) => {
+ this.emit("difficulty-selected", { difficulty });
+ };
+
+ private handleGameModeSelect = (mode: GameMode) => {
+ this.emit("game-mode-selected", { mode });
+ };
+
+ private handleTeamCountSelect = (count: TeamCountConfig) => {
+ this.emit("team-count-selected", { count });
+ };
+
+ private handleOptionToggle = (toggle: ToggleOptionConfig) => {
+ this.emit("option-toggle-changed", {
+ labelKey: toggle.labelKey,
+ checked: !toggle.checked,
+ });
+ };
+
+ private handleBotsChanged = (event: Event) => {
+ const customEvent = event as CustomEvent<{ value: number }>;
+ this.emit("bots-changed", customEvent.detail);
+ };
+
+ private handleUnitToggle = (unit: UnitType, checked: boolean) => {
+ this.emit("unit-toggle-changed", { unit, checked });
+ };
+
+ private renderOptionToggle(toggle: ToggleOptionConfig): TemplateResult {
+ if (toggle.hidden) return html``;
+
+ return renderTextCardButton(
+ translateText(toggle.labelKey),
+ toggle.checked,
+ () => this.handleOptionToggle(toggle),
+ "p-4 text-center",
+ );
+ }
+
+ private renderUnitTypeOptions(disabledUnits: UnitType[]): TemplateResult[] {
+ return unitOptions.map(({ type, translationKey }) => {
+ const isEnabled = !disabledUnits.includes(type);
+ return html`
+
+ `;
+ });
+ }
+
+ render() {
+ if (!this.settings) return nothing;
+ const settings = this.settings;
+
+ return html`
+
+ ${renderSection(
+ MAP_ICON,
+ "text-blue-400",
+ "bg-blue-500/20",
+ "map.map",
+ html`
`,
+ )}
+ ${renderSection(
+ DIFFICULTY_ICON,
+ "text-green-400",
+ "bg-green-500/20",
+ "difficulty.difficulty",
+ html`
+
+ ${DIFFICULTY_OPTIONS.map(([key, value]) => {
+ const isSelected = settings.difficulty.selected === value;
+ const isDisabled = settings.difficulty.disabled;
+ return html`
+
+ `;
+ })}
+
+ `,
+ )}
+ ${renderSection(
+ MODE_ICON,
+ "text-purple-400",
+ "bg-purple-500/20",
+ "host_modal.mode",
+ html`
+
+ ${[GameMode.FFA, GameMode.Team].map((mode) => {
+ const isSelected = settings.gameMode.selected === mode;
+ return html`
+
+ `;
+ })}
+
+ `,
+ )}
+ ${settings.gameMode.selected === GameMode.FFA
+ ? nothing
+ : html`
+
+
+ ${translateText("host_modal.team_count")}
+
+
+ ${TEAM_COUNT_OPTIONS.map((o) => {
+ const isSelected = settings.teamCount.selected === o;
+ return html`
+
+ `;
+ })}
+
+
+ `}
+ ${renderSection(
+ OPTIONS_ICON,
+ "text-orange-400",
+ "bg-orange-500/20",
+ settings.options.titleKey,
+ html`
+
+
+
+
+
+ ${settings.options.toggles.map((toggle) =>
+ this.renderOptionToggle(toggle),
+ )}
+ ${settings.options.inputCards}
+
+ `,
+ )}
+ ${renderSection(
+ ENABLES_ICON,
+ "text-teal-400",
+ "bg-teal-500/20",
+ settings.unitTypes.titleKey,
+ html`
+
+ ${this.renderUnitTypeOptions(settings.unitTypes.disabledUnits)}
+
+ `,
+ "space-y-6 pb-6",
+ )}
+
+ `;
+ }
+}
diff --git a/src/client/components/ToggleInputCard.ts b/src/client/components/ToggleInputCard.ts
new file mode 100644
index 000000000..279366aff
--- /dev/null
+++ b/src/client/components/ToggleInputCard.ts
@@ -0,0 +1,173 @@
+import { LitElement, PropertyValues, html, nothing } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { translateText } from "../Utils";
+
+const ACTIVE_CARD =
+ "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]";
+const INACTIVE_CARD =
+ "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
+const INPUT_CLASS =
+ "w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1";
+const CARD_LABEL_CLASS =
+ "text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto";
+
+function cardClass(active: boolean, extra = ""): string {
+ return `w-full rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 ${extra} ${active ? ACTIVE_CARD : INACTIVE_CARD}`;
+}
+
+@customElement("toggle-input-card")
+export class ToggleInputCard extends LitElement {
+ @property({ attribute: false }) labelKey = "";
+ @property({ type: Boolean, attribute: false }) checked = false;
+ @property({ attribute: false }) inputId?: string;
+ @property({ attribute: false }) inputType = "number";
+ @property({ attribute: false }) inputMin?: number | string;
+ @property({ attribute: false }) inputMax?: number | string;
+ @property({ attribute: false }) inputStep?: number | string;
+ @property({ attribute: false }) inputValue?: number | string;
+ @property({ attribute: false }) inputAriaLabel?: string;
+ @property({ attribute: false }) inputPlaceholder?: string;
+ @property({ attribute: false }) defaultInputValue?: number | string;
+ @property({ attribute: false }) minValidOnEnable?: number;
+ @property({ attribute: false }) onToggle?: (
+ checked: boolean,
+ value: number | string | undefined,
+ ) => void;
+ @property({ attribute: false }) onInput?: (e: Event) => void;
+ @property({ attribute: false }) onChange?: (e: Event) => void;
+ @property({ attribute: false }) onKeyDown?: (e: KeyboardEvent) => void;
+
+ createRenderRoot() {
+ return this;
+ }
+
+ protected updated(changedProperties: PropertyValues) {
+ if (!changedProperties.has("checked")) return;
+ const previousChecked = changedProperties.get("checked");
+ if (previousChecked === false && this.checked) {
+ const input = this.querySelector("input");
+ if (input) {
+ input.focus();
+ input.select();
+ }
+ }
+ }
+
+ private toOptionalNumber(
+ value: number | string | undefined,
+ ): number | undefined {
+ if (typeof value === "number") {
+ return Number.isFinite(value) ? value : undefined;
+ }
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ if (!trimmed) return undefined;
+ const numeric = Number(trimmed);
+ return Number.isFinite(numeric) ? numeric : undefined;
+ }
+ return undefined;
+ }
+
+ private resolveValueOnEnable(): number | string | undefined {
+ const currentValue = this.inputValue;
+
+ if (
+ currentValue === undefined ||
+ currentValue === null ||
+ currentValue === ""
+ ) {
+ return this.defaultInputValue;
+ }
+
+ if (this.minValidOnEnable === undefined) {
+ return currentValue;
+ }
+
+ const numericValue = this.toOptionalNumber(currentValue);
+ if (numericValue === undefined || numericValue < this.minValidOnEnable) {
+ return this.defaultInputValue;
+ }
+
+ return numericValue;
+ }
+
+ private emitToggle() {
+ const nextChecked = !this.checked;
+ const nextValue = nextChecked ? this.resolveValueOnEnable() : undefined;
+ this.onToggle?.(nextChecked, nextValue);
+ }
+
+ private handleCardClick = () => {
+ this.emitToggle();
+ };
+
+ render() {
+ return html`
+
+
+
+ ${this.checked
+ ? html`
+
+
+
+ `
+ : nothing}
+
+ `;
+ }
+}
diff --git a/src/client/components/map/MapDisplay.ts b/src/client/components/map/MapDisplay.ts
index d843a9999..61241eff2 100644
--- a/src/client/components/map/MapDisplay.ts
+++ b/src/client/components/map/MapDisplay.ts
@@ -53,6 +53,10 @@ export class MapDisplay extends LitElement {
}
}
+ private preventImageDrag(event: DragEvent) {
+ event.preventDefault();
+ }
+
render() {
return html`
${this.isLoading
? html`
{
return this.mapWins?.get(mapValue) ?? new Set();
}
@@ -54,7 +58,7 @@ export class MapPicker extends LitElement {
return html`
this.handleMapSelection(mapValue)}
- class="cursor-pointer transition-transform duration-200 active:scale-95"
+ class="cursor-pointer"
>