diff --git a/resources/lang/debug.json b/resources/lang/debug.json
index 924720ce31..ea4e1a5a32 100644
--- a/resources/lang/debug.json
+++ b/resources/lang/debug.json
@@ -96,6 +96,8 @@
"infinite_gold": "single_modal.infinite_gold",
"random_spawn": "single_modal.random_spawn",
"infinite_troops": "single_modal.infinite_troops",
+ "starting_gold": "single_modal.starting_gold",
+ "gold_multiplier": "single_modal.gold_multiplier",
"disable_nukes": "single_modal.disable_nukes",
"start": "single_modal.start"
},
@@ -145,6 +147,9 @@
"options_title": "host_modal.options_title",
"bots": "host_modal.bots",
"bots_disabled": "host_modal.bots_disabled",
+ "spawn_immunity_duration": "host_modal.spawn_immunity_duration",
+ "starting_gold": "host_modal.starting_gold",
+ "gold_multiplier": "host_modal.gold_multiplier",
"disable_nations": "host_modal.disable_nations",
"instant_build": "host_modal.instant_build",
"random_spawn": "host_modal.random_spawn",
diff --git a/resources/lang/en.json b/resources/lang/en.json
index 77e6b24b53..09a7a8b7a5 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -145,6 +145,8 @@
"instant_build": "Instant build",
"infinite_gold": "Infinite gold",
"infinite_troops": "Infinite troops",
+ "starting_gold": "Starting gold",
+ "gold_multiplier": "Gold multiplier",
"compact_map": "Compact Map",
"max_timer": "Game length (minutes)",
"disable_nukes": "Disable Nukes",
@@ -261,6 +263,9 @@
"options_title": "Options",
"bots": "Bots: ",
"bots_disabled": "Disabled",
+ "spawn_immunity_duration": "Spawn PVP Immunity",
+ "starting_gold": "Starting gold",
+ "gold_multiplier": "Gold multiplier",
"nations": "Nations: ",
"disable_nations": "Disable Nations",
"max_timer": "Game length (minutes)",
diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts
index ccb01843c8..a5603accaa 100644
--- a/src/client/HostLobbyModal.ts
+++ b/src/client/HostLobbyModal.ts
@@ -1,7 +1,7 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import randomMap from "../../resources/images/RandomMap.webp";
-import { translateText } from "../client/Utils";
+import { renderNumber, translateText } from "../client/Utils";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import {
Difficulty,
@@ -23,11 +23,17 @@ import {
TeamCountConfig,
} from "../core/Schemas";
import { generateID } from "../core/Util";
+import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import "./components/Difficulties";
import "./components/Maps";
import { JoinLobbyEvent } from "./Main";
import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
+import {
+ STARTING_GOLD_PRESETS,
+ startingGoldIndexFromValue,
+ startingGoldValueFromIndex,
+} from "./utilities/StartingGoldPresets";
@customElement("host-lobby-modal")
export class HostLobbyModal extends LitElement {
@@ -41,6 +47,7 @@ export class HostLobbyModal extends LitElement {
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: TeamCountConfig = 2;
@state() private bots: number = 400;
+ @state() private spawnImmunityDurationSeconds: number = 5;
@state() private infiniteGold: boolean = false;
@state() private donateGold: boolean = false;
@state() private infiniteTroops: boolean = false;
@@ -49,6 +56,10 @@ export class HostLobbyModal extends LitElement {
@state() private maxTimerValue: number | undefined = undefined;
@state() private instantBuild: boolean = false;
@state() private randomSpawn: boolean = false;
+ @state() private startingGold: number = 0;
+ @state() private goldMultiplier: number = 1;
+ @state() private startingGoldEnabled = false;
+ @state() private goldMultiplierEnabled = false;
@state() private compactMap: boolean = false;
@state() private lobbyId = "";
@state() private copySuccess = false;
@@ -335,9 +346,11 @@ export class HostLobbyModal extends LitElement {
min="0"
max="400"
step="1"
+ class="option-slider"
+ style=${this.sliderStyle(this.bots, 0, 400)}
@input=${this.handleBotsChange}
@change=${this.handleBotsChange}
- .value="${String(this.bots)}"
+ .value=${String(this.bots)}
/>
${translateText("host_modal.bots")}${
@@ -522,6 +535,108 @@ export class HostLobbyModal extends LitElement {
${translateText("host_modal.max_timer")}
+
+
+
+
+
@@ -610,6 +725,26 @@ export class HostLobbyModal extends LitElement {
createLobby(this.lobbyCreatorClientID)
.then((lobby) => {
this.lobbyId = lobby.gameID;
+ if (lobby.gameConfig) {
+ const startingGoldFromServer =
+ lobby.gameConfig.startingGold ?? STARTING_GOLD_PRESETS[0];
+ this.startingGold = this.snapStartingGoldValue(
+ startingGoldFromServer,
+ );
+ this.startingGoldEnabled = this.startingGold > 0;
+
+ const goldMultiplierFromServer = lobby.gameConfig.goldMultiplier ?? 1;
+ this.goldMultiplier = this.normalizeGoldMultiplier(
+ goldMultiplierFromServer,
+ );
+ this.goldMultiplierEnabled = this.goldMultiplier !== 1;
+
+ if (typeof lobby.gameConfig.spawnImmunityDuration === "number") {
+ this.spawnImmunityDurationSeconds = Math.floor(
+ lobby.gameConfig.spawnImmunityDuration / 10,
+ );
+ }
+ }
// join lobby
})
.then(() => {
@@ -660,7 +795,9 @@ export class HostLobbyModal extends LitElement {
// Modified to include debouncing
private handleBotsChange(e: Event) {
- const value = parseInt((e.target as HTMLInputElement).value);
+ const slider = e.target as HTMLInputElement;
+ this.updateSliderProgressElement(slider);
+ const value = parseInt(slider.value, 10);
if (isNaN(value) || value < 0 || value > 400) {
return;
}
@@ -685,11 +822,78 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig();
}
+ private handleSpawnImmunityDurationSlider(e: Event) {
+ const slider = e.target as HTMLInputElement;
+ const value = parseInt(slider.value, 10);
+ if (Number.isNaN(value)) {
+ return;
+ }
+ const clamped = Math.min(300, Math.max(0, value));
+ this.spawnImmunityDurationSeconds = Math.round(clamped / 5) * 5;
+ slider.value = String(this.spawnImmunityDurationSeconds);
+ this.updateSliderProgressElement(slider);
+ this.putGameConfig();
+ }
+
+ private formatSecondsAsClock(seconds: number): string {
+ const minutes = Math.floor(seconds / 60)
+ .toString()
+ .padStart(2, "0");
+ const remainder = (seconds % 60).toString().padStart(2, "0");
+ return `${minutes}:${remainder}`;
+ }
+
private handleRandomSpawnChange(e: Event) {
this.randomSpawn = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig();
}
+ private handleStartingGoldToggle(e: Event) {
+ const enabled = (e.target as HTMLInputElement).checked;
+ this.startingGoldEnabled = enabled;
+ if (!enabled) {
+ this.startingGold = STARTING_GOLD_PRESETS[0];
+ }
+ this.putGameConfig();
+ }
+
+ private handleStartingGoldSliderChange(e: Event) {
+ const slider = e.target as HTMLInputElement;
+ this.updateSliderProgressElement(slider);
+ const index = parseInt(slider.value, 10);
+ if (Number.isNaN(index)) {
+ return;
+ }
+ this.startingGold = startingGoldValueFromIndex(index);
+ this.startingGoldEnabled = true;
+ this.putGameConfig();
+ }
+
+ private getStartingGoldSliderIndex(): number {
+ return startingGoldIndexFromValue(this.startingGold);
+ }
+
+ private handleGoldMultiplierToggle(e: Event) {
+ const enabled = (e.target as HTMLInputElement).checked;
+ this.goldMultiplierEnabled = enabled;
+ if (!enabled) {
+ this.goldMultiplier = 1;
+ }
+ this.putGameConfig();
+ }
+
+ private handleGoldMultiplierSliderChange(e: Event) {
+ const slider = e.target as HTMLInputElement;
+ this.updateSliderProgressElement(slider);
+ const value = parseFloat(slider.value);
+ if (Number.isNaN(value)) {
+ return;
+ }
+ this.goldMultiplier = this.normalizeGoldMultiplier(value);
+ this.goldMultiplierEnabled = true;
+ this.putGameConfig();
+ }
+
private handleInfiniteGoldChange(e: Event) {
this.infiniteGold = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig();
@@ -774,6 +978,9 @@ export class HostLobbyModal extends LitElement {
randomSpawn: this.randomSpawn,
gameMode: this.gameMode,
disabledUnits: this.disabledUnits,
+ spawnImmunityDuration: this.spawnImmunityDurationSeconds * 10,
+ startingGold: this.startingGold,
+ goldMultiplier: this.goldMultiplier,
playerTeams: this.teamCount,
...(this.gameMode === GameMode.Team &&
this.teamCount === HumansVsNations
@@ -800,6 +1007,36 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig();
}
+ private snapStartingGoldValue(value: number): number {
+ return startingGoldValueFromIndex(startingGoldIndexFromValue(value));
+ }
+
+ private normalizeGoldMultiplier(value: number): number {
+ const clamped = Math.min(10, Math.max(0, value));
+ return Math.round(clamped * 10) / 10;
+ }
+
+ private sliderStyle(value: number, min: number, max: number): string {
+ if (max === min) return "--progress:0%";
+ const percent = ((value - min) / (max - min)) * 100;
+ return `--progress:${Math.max(0, Math.min(100, percent))}%`;
+ }
+
+ private updateSliderProgressElement(slider: HTMLInputElement): void {
+ const min = Number(slider.min);
+ const max = Number(slider.max);
+ const value = Number(slider.value);
+ if (Number.isNaN(min) || Number.isNaN(max) || max === min) {
+ slider.style.setProperty("--progress", "0%");
+ return;
+ }
+ const percent = ((value - min) / (max - min)) * 100;
+ slider.style.setProperty(
+ "--progress",
+ `${Math.max(0, Math.min(100, percent))}%`,
+ );
+ }
+
private getRandomMap(): GameMapType {
const maps = Object.values(GameMapType);
const randIdx = Math.floor(Math.random() * maps.length);
@@ -857,6 +1094,33 @@ export class HostLobbyModal extends LitElement {
console.log(`got game info response: ${JSON.stringify(data)}`);
this.clients = data.clients ?? [];
+ if (data.gameConfig) {
+ if (typeof data.gameConfig.startingGold === "number") {
+ const snapped = this.snapStartingGoldValue(
+ data.gameConfig.startingGold,
+ );
+ const startingGoldChanged = this.startingGold !== snapped;
+ this.startingGold = snapped;
+ if (startingGoldChanged) {
+ this.startingGoldEnabled = this.startingGold !== 0;
+ }
+ }
+ if (typeof data.gameConfig.goldMultiplier === "number") {
+ const normalized = this.normalizeGoldMultiplier(
+ data.gameConfig.goldMultiplier,
+ );
+ const goldMultiplierChanged = this.goldMultiplier !== normalized;
+ this.goldMultiplier = normalized;
+ if (goldMultiplierChanged) {
+ this.goldMultiplierEnabled = this.goldMultiplier !== 1;
+ }
+ }
+ if (typeof data.gameConfig.spawnImmunityDuration === "number") {
+ this.spawnImmunityDurationSeconds = Math.floor(
+ data.gameConfig.spawnImmunityDuration / 10,
+ );
+ }
+ }
});
}
diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts
index 9c51bf9050..ffa873cdfb 100644
--- a/src/client/SinglePlayerModal.ts
+++ b/src/client/SinglePlayerModal.ts
@@ -1,7 +1,7 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import randomMap from "../../resources/images/RandomMap.webp";
-import { translateText } from "../client/Utils";
+import { renderNumber, translateText } from "../client/Utils";
import {
Difficulty,
Duos,
@@ -27,6 +27,13 @@ import { FlagInput } from "./FlagInput";
import { JoinLobbyEvent } from "./Main";
import { UsernameInput } from "./UsernameInput";
import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
+import {
+ STARTING_GOLD_PRESETS,
+ startingGoldIndexFromValue,
+ startingGoldValueFromIndex,
+} from "./utilities/StartingGoldPresets";
+
+const DEFAULT_SPAWN_IMMUNITY_DURATION_SECONDS = 5;
@customElement("single-player-modal")
export class SinglePlayerModal extends LitElement {
@@ -45,6 +52,10 @@ export class SinglePlayerModal extends LitElement {
@state() private maxTimerValue: number | undefined = undefined;
@state() private instantBuild: boolean = false;
@state() private randomSpawn: boolean = false;
+ @state() private startingGold: number = 0;
+ @state() private goldMultiplier: number = 1;
+ @state() private startingGoldEnabled = false;
+ @state() private goldMultiplierEnabled = false;
@state() private useRandomMap: boolean = false;
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: TeamCountConfig = 2;
@@ -243,9 +254,11 @@ export class SinglePlayerModal extends LitElement {
min="0"
max="400"
step="1"
+ class="option-slider"
+ style=${this.sliderStyle(this.bots, 0, 400)}
@input=${this.handleBotsChange}
@change=${this.handleBotsChange}
- .value="${String(this.bots)}"
+ .value=${String(this.bots)}
/>
${translateText("single_modal.bots")}${this
@@ -309,6 +322,77 @@ export class SinglePlayerModal extends LitElement {
${translateText("single_modal.random_spawn")}
+
+
+