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()} - > -
+
+ - -
-
-
- - - -
-

- ${translateText("difficulty.difficulty")} -

-
- -
- ${Object.entries(Difficulty) - .filter(([key]) => isNaN(Number(key))) - .map(([key, value]) => { - const isSelected = this.selectedDifficulty === value; - const isDisabled = this.disableNations; - return html` - - `; - })} -
-
- - -
-
-
- - - -
-

- ${translateText("host_modal.mode")} -

-
-
- ${[GameMode.FFA, GameMode.Team].map((mode) => { - const isSelected = this.gameMode === mode; - return html` - - `; - })} -
-
- - ${this.gameMode === GameMode.FFA - ? "" - : html` - -
-
- ${translateText("host_modal.team_count")} -
-
- ${[ - 2, - 3, - 4, - 5, - 6, - 7, - Quads, - Trios, - Duos, - HumansVsNations, - ].map((o) => { - const isSelected = this.teamCount === o; - return html` - - `; - })} -
-
- `} - - -
-
-
- - - -
-

- ${translateText("host_modal.options_title")} -

-
-
- -
- -
- - ${!( - this.gameMode === GameMode.Team && - this.teamCount === HumansVsNations - ) - ? this.renderOptionToggle( - "host_modal.disable_nations", - this.disableNations, - this.handleDisableNationsChange, - ) - : ""} - ${this.renderOptionToggle( - "host_modal.instant_build", - this.instantBuild, - this.handleInstantBuildChange, - )} - ${this.renderOptionToggle( - "host_modal.random_spawn", - this.randomSpawn, - this.handleRandomSpawnChange, - )} - ${this.renderOptionToggle( - "host_modal.donate_gold", - this.donateGold, - this.handleDonateGoldChange, - )} - ${this.renderOptionToggle( - "host_modal.donate_troops", - this.donateTroops, - this.handleDonateTroopsChange, - )} - ${this.renderOptionToggle( - "host_modal.infinite_gold", - this.infiniteGold, - this.handleInfiniteGoldChange, - )} - ${this.renderOptionToggle( - "host_modal.infinite_troops", - this.infiniteTroops, - this.handleInfiniteTroopsChange, - )} - ${this.renderOptionToggle( - "host_modal.compact_map", - this.compactMap, - this.handleCompactMapChange, - )} - - - ${renderToggleInputCard({ - labelKey: "host_modal.max_timer", - checked: this.maxTimer, - onClick: maxTimerHandlers.click, - input: renderToggleInputCardInput({ - min: 0, - max: 120, - value: this.maxTimerValue ?? 0, - ariaLabel: translateText("host_modal.max_timer"), - placeholder: translateText("host_modal.mins_placeholder"), - onInput: this.handleMaxTimerValueChanges, - onKeyDown: this.handleMaxTimerValueKeyDown, - }), - })} - - - ${renderToggleInputCard({ - labelKey: "host_modal.player_immunity_duration", - checked: this.spawnImmunity, - onClick: spawnImmunityHandlers.click, - input: renderToggleInputCardInput({ - min: 0, - max: 120, - step: 1, - value: this.spawnImmunityDurationMinutes ?? 0, - ariaLabel: translateText( - "host_modal.player_immunity_duration", - ), - placeholder: translateText("host_modal.mins_placeholder"), - onInput: this.handleSpawnImmunityDurationInput, - onKeyDown: this.handleSpawnImmunityDurationKeyDown, - }), - })} - - - ${renderToggleInputCard({ - labelKey: "single_modal.gold_multiplier", - checked: this.goldMultiplier, - onClick: goldMultiplierHandlers.click, - 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: startingGoldHandlers.click, - 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("host_modal.enables_title")} -

-
-
- ${renderUnitTypeOptions({ - disabledUnits: this.disabledUnits, - toggleUnit: this.toggleUnit.bind(this), - })} -
-
- - - this.kickPlayer(clientID)} - > -
+ 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` +
+
+ + ${iconSvg} + +
+

+ ${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" >
diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index b39d3f8de..31f496c05 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -251,7 +251,11 @@ export class EventsDisplay extends LitElement implements Layer { const myPlayer = this.game.myPlayer(); if (!myPlayer?.isAlive()) return; + const currentAllianceIds = new Set(); + for (const alliance of myPlayer.alliances()) { + currentAllianceIds.add(alliance.id); + if ( alliance.expiresAt > this.game.ticks() + this.game.config().allianceExtensionPromptOffset() @@ -270,7 +274,6 @@ export class EventsDisplay extends LitElement implements Layer { this.alliancesCheckedAt.set(alliance.id, this.game.ticks()); const other = this.game.player(alliance.other) as PlayerView; - if (!other.isAlive()) continue; this.addEvent({ description: translateText("events_display.about_to_expire", { @@ -305,6 +308,13 @@ export class EventsDisplay extends LitElement implements Layer { allianceID: alliance.id, }); } + + for (const [allianceId] of this.alliancesCheckedAt) { + if (!currentAllianceIds.has(allianceId)) { + this.removeAllianceRenewalEvents(allianceId); + this.alliancesCheckedAt.delete(allianceId); + } + } } private addEvent(event: GameEvent) { @@ -530,6 +540,7 @@ export class EventsDisplay extends LitElement implements Layer { if (!myPlayer) return; this.removeAllianceRenewalEvents(update.allianceID); + this.alliancesCheckedAt.delete(update.allianceID); this.requestUpdate(); const betrayed = this.game.playerBySmallID(update.betrayedID) as PlayerView; diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts index 0239e2f15..a0b0937ce 100644 --- a/src/client/graphics/layers/HeadsUpMessage.ts +++ b/src/client/graphics/layers/HeadsUpMessage.ts @@ -16,6 +16,9 @@ export class HeadsUpMessage extends LitElement implements Layer { @state() private isPaused = false; + @state() + private isImmunityActive = false; + @state() private toastMessage: string | import("lit").TemplateResult | null = null; @state() @@ -79,7 +82,18 @@ export class HeadsUpMessage extends LitElement implements Layer { this.isPaused = pauseUpdate.paused; } - this.isVisible = this.game.inSpawnPhase() || this.isPaused; + const showImmunityHudDuration = 10 * 10; + const spawnEnd = this.game.config().numSpawnPhaseTurns(); + const ticksSinceSpawnEnd = this.game.ticks() - spawnEnd; + + this.isImmunityActive = + this.game.config().hasExtendedSpawnImmunity() && + !this.game.inSpawnPhase() && + this.game.isSpawnImmunityActive() && + ticksSinceSpawnEnd < showImmunityHudDuration; + + this.isVisible = + this.game.inSpawnPhase() || this.isPaused || this.isImmunityActive; this.requestUpdate(); } @@ -91,6 +105,11 @@ export class HeadsUpMessage extends LitElement implements Layer { return translateText("heads_up_message.multiplayer_game_paused"); } } + if (this.isImmunityActive) { + return translateText("heads_up_message.pvp_immunity_active", { + seconds: Math.round(this.game.config().spawnImmunityDuration() / 10), + }); + } return this.game.config().isRandomSpawn() ? translateText("heads_up_message.random_spawn") : translateText("heads_up_message.choose_spawn"); diff --git a/src/client/graphics/layers/ImmunityTimer.ts b/src/client/graphics/layers/ImmunityTimer.ts index 29f66fbac..cfb44ae2b 100644 --- a/src/client/graphics/layers/ImmunityTimer.ts +++ b/src/client/graphics/layers/ImmunityTimer.ts @@ -41,7 +41,10 @@ export class ImmunityTimer extends LitElement implements Layer { const immunityDuration = this.game.config().spawnImmunityDuration(); const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns(); - if (immunityDuration <= 5 * 10 || this.game.inSpawnPhase()) { + if ( + !this.game.config().hasExtendedSpawnImmunity() || + this.game.inSpawnPhase() + ) { this.setInactive(); return; } diff --git a/src/client/utilities/GameConfigHelpers.ts b/src/client/utilities/GameConfigHelpers.ts new file mode 100644 index 000000000..a149dee88 --- /dev/null +++ b/src/client/utilities/GameConfigHelpers.ts @@ -0,0 +1,93 @@ +import { GameMapType, UnitType } from "../../core/game/Game"; + +export function 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; +} + +export function preventDisallowedKeys( + e: KeyboardEvent, + disallowedKeys: string[], +): void { + if (disallowedKeys.includes(e.key)) { + e.preventDefault(); + } +} + +export function parseBoundedIntegerFromInput( + input: HTMLInputElement, + { + min, + max, + stripPattern = /[eE+-]/g, + radix = 10, + }: { + min: number; + max: number; + stripPattern?: RegExp; + radix?: number; + }, +): number | undefined { + input.value = input.value.replace(stripPattern, ""); + const value = parseInt(input.value, radix); + + if (isNaN(value) || value < min || value > max) { + return undefined; + } + + return value; +} + +export function parseBoundedFloatFromInput( + input: HTMLInputElement, + { min, max }: { min: number; max: number }, +): number | undefined { + const value = parseFloat(input.value); + + if (isNaN(value) || value < min || value > max) { + return undefined; + } + + return value; +} + +export function getBotsForCompactMap( + bots: number, + compactMapEnabled: boolean, +): number { + if (compactMapEnabled && bots === 400) { + return 100; + } + + if (!compactMapEnabled && bots === 100) { + return 400; + } + + return bots; +} + +export function getRandomMapType(): GameMapType { + const maps = Object.values(GameMapType); + const randIdx = Math.floor(Math.random() * maps.length); + return maps[randIdx] as GameMapType; +} + +export function getUpdatedDisabledUnits( + disabledUnits: UnitType[], + unit: UnitType, + checked: boolean, +): UnitType[] { + return checked + ? [...disabledUnits, unit] + : disabledUnits.filter((u) => u !== unit); +} diff --git a/src/client/utilities/RenderToggleInputCard.ts b/src/client/utilities/RenderToggleInputCard.ts deleted file mode 100644 index 44fbd437e..000000000 --- a/src/client/utilities/RenderToggleInputCard.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { TemplateResult, html, nothing } from "lit"; -import { translateText } from "../Utils"; - -export const TOGGLE_INPUT_CARD_CLASSES = { - containerActive: - "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]", - containerInactive: - "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 opacity-80", - labelBase: - "text-[10px] uppercase font-bold tracking-wider text-center w-full leading-tight break-words hyphens-auto", - labelActive: "text-white", - labelInactive: "text-white/60", - input: - "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", -}; - -export interface ToggleInputCardInputOptions { - id?: string; - type?: string; - min?: number | string; - max?: number | string; - step?: number | string; - value?: number | string; - ariaLabel?: string; - placeholder?: string; - onInput?: (e: Event) => void; - onChange?: (e: Event) => void; - onKeyDown?: (e: KeyboardEvent) => void; - onClick?: (e: Event) => void; - className?: string; -} - -export function renderToggleInputCardInput({ - id, - type = "number", - min, - max, - step, - value, - ariaLabel, - placeholder, - onInput, - onChange, - onKeyDown, - onClick, - className = TOGGLE_INPUT_CARD_CLASSES.input, -}: ToggleInputCardInputOptions): TemplateResult { - const resolvedValue = value ?? ""; - const handleClick = onClick ?? ((e: Event) => e.stopPropagation()); - - return html` - - `; -} - -export interface ToggleInputCardRenderContext { - labelKey: string; - checked: boolean; - input?: TemplateResult; - onClick?: (e: Event) => void; - onKeyDown?: (e: KeyboardEvent) => void; - activeClassName?: string; - inactiveClassName?: string; - labelBaseClassName?: string; - labelActiveClassName?: string; - labelInactiveClassName?: string; - role?: string; - tabIndex?: number; -} - -export function renderToggleInputCard({ - labelKey, - checked, - input, - onClick, - onKeyDown, - activeClassName = TOGGLE_INPUT_CARD_CLASSES.containerActive, - inactiveClassName = TOGGLE_INPUT_CARD_CLASSES.containerInactive, - labelBaseClassName = TOGGLE_INPUT_CARD_CLASSES.labelBase, - labelActiveClassName = TOGGLE_INPUT_CARD_CLASSES.labelActive, - labelInactiveClassName = TOGGLE_INPUT_CARD_CLASSES.labelInactive, - role, - tabIndex, -}: ToggleInputCardRenderContext): TemplateResult { - const shouldBehaveLikeButton = Boolean(onClick ?? onKeyDown); - const resolvedRole = role ?? (shouldBehaveLikeButton ? "button" : undefined); - const resolvedTabIndex = tabIndex ?? (shouldBehaveLikeButton ? 0 : undefined); - const resolvedOnKeyDown = - onKeyDown ?? - (onClick - ? (e: KeyboardEvent) => { - if ((e.target as HTMLElement).tagName.toLowerCase() === "input") { - return; - } - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onClick(e); - } - } - : undefined); - - return html` -
-
-
- ${checked - ? html` - - ` - : ""} -
-
- - ${checked - ? (input ?? html``) - : html`
`} - -
- ${translateText(labelKey)} -
-
- `; -} diff --git a/src/client/utilities/RenderUnitTypeOptions.ts b/src/client/utilities/RenderUnitTypeOptions.ts deleted file mode 100644 index c04dea7f4..000000000 --- a/src/client/utilities/RenderUnitTypeOptions.ts +++ /dev/null @@ -1,47 +0,0 @@ -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 }) => { - const isEnabled = !disabledUnits.includes(type); - return html` - - `; - }); -} diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 13a6fea73..b375a25d1 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -58,6 +58,7 @@ export interface NukeMagnitude { export interface Config { spawnImmunityDuration(): Tick; + hasExtendedSpawnImmunity(): boolean; serverConfig(): ServerConfig; gameConfig(): GameConfig; theme(): Theme; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 70fb5b7a7..f4d9e3253 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -28,6 +28,7 @@ import { PastelThemeDark } from "./PastelThemeDark"; const DEFENSE_DEBUFF_MIDPOINT = 150_000; const DEFENSE_DEBUFF_DECAY_RATE = Math.LN2 / 50000; +const DEFAULT_SPAWN_IMMUNITY_TICKS = 5 * 10; const JwksSchema = z.object({ keys: z @@ -163,7 +164,12 @@ export class DefaultConfig implements Config { return 30 * 10; // 30 seconds } spawnImmunityDuration(): Tick { - return this._gameConfig.spawnImmunityDuration ?? 5 * 10; // default to 5 seconds + return ( + this._gameConfig.spawnImmunityDuration ?? DEFAULT_SPAWN_IMMUNITY_TICKS + ); + } + hasExtendedSpawnImmunity(): boolean { + return this.spawnImmunityDuration() > DEFAULT_SPAWN_IMMUNITY_TICKS; } gameConfig(): GameConfig { @@ -730,9 +736,9 @@ export class DefaultConfig implements Config { case Difficulty.Easy: return 12_500; case Difficulty.Medium: - return 25_000; // Like humans + return 18_750; case Difficulty.Hard: - return 28_125; + return 25_000; // Like humans case Difficulty.Impossible: return 31_250; default: @@ -765,9 +771,9 @@ export class DefaultConfig implements Config { case Difficulty.Easy: return maxTroops * 0.5; case Difficulty.Medium: - return maxTroops * 1; // Like humans + return maxTroops * 0.75; case Difficulty.Hard: - return maxTroops * 1.125; + return maxTroops * 1; // Like humans case Difficulty.Impossible: return maxTroops * 1.25; default: @@ -793,10 +799,10 @@ export class DefaultConfig implements Config { toAdd *= 0.9; break; case Difficulty.Medium: - toAdd *= 1; // Like humans + toAdd *= 0.95; break; case Difficulty.Hard: - toAdd *= 1.025; + toAdd *= 1; // Like humans break; case Difficulty.Impossible: toAdd *= 1.05; diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts index 85fabb34a..491bf1b21 100644 --- a/src/core/execution/BotExecution.ts +++ b/src/core/execution/BotExecution.ts @@ -39,6 +39,7 @@ export class BotExecution implements Execution { if (ticks % this.attackRate !== this.attackTick) return; if (!this.bot.isAlive()) { + //removeOnDeath is called from bot's PlayerExecution this.active = false; return; } diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts index 466adcdaa..45abd3729 100644 --- a/src/core/execution/NationExecution.ts +++ b/src/core/execution/NationExecution.ts @@ -144,6 +144,7 @@ export class NationExecution implements Execution { } if (!this.player.isAlive()) { + //removeOnDeath is called from nation's PlayerExecution this.active = false; return; } diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index e1d80d0b4..1e9ae4a10 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -60,19 +60,7 @@ export class PlayerExecution implements Execution { } if (!this.player.isAlive()) { - // Player has no tiles, delete any remaining units and gold - const gold = this.player.gold(); - this.player.removeGold(gold); - this.player.units().forEach((u) => { - if ( - u.type() !== UnitType.AtomBomb && - u.type() !== UnitType.HydrogenBomb && - u.type() !== UnitType.MIRVWarhead && - u.type() !== UnitType.MIRV - ) { - u.delete(); - } - }); + this.removeOnDeath(); this.active = false; this.mg.stats().playerKilled(this.player, ticks); return; @@ -400,4 +388,24 @@ export class PlayerExecution implements Execution { return result; } + + private removeOnDeath(): void { + // Player (bot, human, nation) has no tiles + // Delete any remaining gold, non-nuke units and alliances + const gold = this.player.gold(); + this.player.removeGold(gold); + + this.player.units().forEach((u) => { + if ( + u.type() !== UnitType.AtomBomb && + u.type() !== UnitType.HydrogenBomb && + u.type() !== UnitType.MIRVWarhead && + u.type() !== UnitType.MIRV + ) { + u.delete(); + } + }); + + this.player.removeAllAlliances(); + } } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 1057e7e6b..d0a1d3f97 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -663,6 +663,7 @@ export interface Player { allianceWith(other: Player): MutableAlliance | null; canSendAllianceRequest(other: Player): boolean; breakAlliance(alliance: Alliance): void; + removeAllAlliances(): void; createAllianceRequest(recipient: Player): AllianceRequest | null; betrayals(): number; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 6dd76beff..45d77878f 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -719,10 +719,16 @@ export class GameImpl implements Game { }); } + public removeAlliancesByPlayerSilently(player: Player): void { + this.alliances_ = this.alliances_.filter( + (a) => a.requestor() !== player && a.recipient() !== player, + ); + } + public isSpawnImmunityActive(): boolean { return ( this.config().numSpawnPhaseTurns() + - this.config().spawnImmunityDuration() >= + this.config().spawnImmunityDuration() > this.ticks() ); } diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 65a5b74d8..d66b9361b 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -813,6 +813,12 @@ export class GameView implements GameMap { inSpawnPhase(): boolean { return this.ticks() <= this._config.numSpawnPhaseTurns(); } + isSpawnImmunityActive(): boolean { + return ( + this._config.numSpawnPhaseTurns() + this._config.spawnImmunityDuration() > + this.ticks() + ); + } config(): Config { return this._config; } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index dd72a3307..d00948d44 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -450,6 +450,10 @@ export class PlayerImpl implements Player { this.mg.breakAlliance(this, alliance); } + removeAllAlliances(): void { + this.mg.removeAlliancesByPlayerSilently(this); + } + isTraitor(): boolean { return this.getTraitorRemainingTicks() > 0; } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 0b0bcacec..0abbf4ed2 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -150,8 +150,7 @@ export class MapPlaylist { startingGold, }, startingGold, - difficulty: - playerTeams === HumansVsNations ? Difficulty.Medium : Difficulty.Easy, + difficulty: Difficulty.Medium, infiniteGold: false, infiniteTroops: false, maxTimerValue: undefined, @@ -161,7 +160,7 @@ export class MapPlaylist { gameMode: mode, playerTeams, bots: isCompact ? 100 : 400, - spawnImmunityDuration: 5 * 10, + spawnImmunityDuration: startingGold ? 30 * 10 : 5 * 10, disabledUnits: [], } satisfies GameConfig; } diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index e2bf619dc..ff875870d 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -183,7 +183,7 @@ describe("Attack race condition with alliance requests", () => { null, "playerB_id", ); - playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 10)); + playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 11)); while (game.inSpawnPhase()) { game.executeNextTick(); @@ -224,6 +224,9 @@ describe("Attack race condition with alliance requests", () => { game.executeNextTick(); } + expect(playerA.isAlive()).toBe(true); + expect(playerB.isAlive()).toBe(true); + // Player A should not be marked as traitor because the alliance was formed after the attack started expect(playerA.isTraitor()).toBe(false); @@ -391,17 +394,17 @@ describe("Attack immunity", () => { test("Ensure a player can't attack during all the immunity phase", async () => { // Execute a few ticks but stop right before the immunity phase is over - for (let i = 0; i < immunityPhaseTicks - 1; i++) { + for (let i = 0; i < immunityPhaseTicks - 2; i++) { game.executeNextTick(); } // Player A attacks Player B game.addExecution(new AttackExecution(null, playerA, playerB.id(), null)); - game.executeNextTick(); // ticks === immunityPhaseTicks here + game.executeNextTick(); // ticks === immunityPhaseTicks - 1 here // Attack is not possible during immunity expect(playerA.outgoingAttacks()).toHaveLength(0); // Retry after the immunity is over - game.executeNextTick(); // ticks === immunityPhaseTicks + 1 + game.executeNextTick(); // ticks === immunityPhaseTicks game.addExecution(new AttackExecution(null, playerA, playerB.id(), null)); game.executeNextTick(); // Attack is now possible right after