diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts
index 2cc738984..3d92670a5 100644
--- a/src/client/HostLobbyModal.ts
+++ b/src/client/HostLobbyModal.ts
@@ -269,9 +269,8 @@ export class HostLobbyModal extends LitElement {
${
- this.gameMode === GameMode.FFA
- ? ""
- : html`
+ this.gameMode === GameMode.Team
+ ? html`
@@ -299,6 +298,7 @@ export class HostLobbyModal extends LitElement {
`
+ : ""
}
@@ -692,6 +692,8 @@ export class HostLobbyModal extends LitElement {
// Enforce Nuke Wars restrictions
if (value === GameMode.NukeWars) {
+ // Force 2 teams for Nuke Wars
+ this.teamCount = 2;
// Force Baikal map
if (this.selectedMap !== GameMapType.Baikal) {
this.selectedMap = GameMapType.Baikal;
diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts
index 3d695133e..c01cbaca9 100644
--- a/src/client/SinglePlayerModal.ts
+++ b/src/client/SinglePlayerModal.ts
@@ -206,9 +206,8 @@ export class SinglePlayerModal extends LitElement {
- ${this.gameMode === GameMode.FFA
- ? ""
- : html`
+ ${this.gameMode === GameMode.Team
+ ? html`
@@ -233,7 +232,8 @@ export class SinglePlayerModal extends LitElement {
)}
- `}
+ `
+ : ""}
@@ -477,6 +477,8 @@ export class SinglePlayerModal extends LitElement {
// Enforce Nuke Wars restrictions
if (value === GameMode.NukeWars) {
+ // Force 2 teams for Nuke Wars
+ this.teamCount = 2;
// Force Baikal map
if (this.selectedMap !== GameMapType.Baikal) {
this.selectedMap = GameMapType.Baikal;
diff --git a/src/client/graphics/layers/NukeWarsTopBanner.ts b/src/client/graphics/layers/NukeWarsTopBanner.ts
index 1b47a398a..61484a339 100644
--- a/src/client/graphics/layers/NukeWarsTopBanner.ts
+++ b/src/client/graphics/layers/NukeWarsTopBanner.ts
@@ -21,41 +21,102 @@ export class NukeWarsTopBanner implements Layer {
const config = this.game.config().gameConfig();
if (config.gameMode !== GameMode.NukeWars) return;
if (config.gameMap !== GameMapType.Baikal) return;
- if (!this.game.inSpawnPhase()) return;
-
- const numSpawn = this.game.config().numSpawnPhaseTurns();
- const remainingTicks = Math.max(0, numSpawn - this.game.ticks());
- // 1 second = 10 ticks (100ms per tick)
- const remainingSeconds = Math.ceil(remainingTicks / 10);
- const minutes = Math.floor(remainingSeconds / 60);
- const seconds = remainingSeconds % 60;
- const timeStr = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
-
const canvasWidth = context.canvas.width;
-
const padding = 12;
- const fontSize = Math.max(16, Math.floor(canvasWidth * 0.02));
+ const fontSize = Math.max(14, Math.floor(canvasWidth * 0.02));
context.save();
context.font = `bold ${fontSize}px sans-serif`;
context.textAlign = "center";
context.textBaseline = "top";
- // background rounded rectangle
- const textMetrics = context.measureText(timeStr);
- const textWidth = textMetrics.width;
- const rectWidth = textWidth + padding * 2;
- const rectHeight = fontSize + padding * 2;
- const x = canvasWidth / 2 - rectWidth / 2;
- const y = 8;
+ // During spawn phase show spawn timer centered
+ if (this.game.inSpawnPhase()) {
+ const numSpawn = this.game.config().numSpawnPhaseTurns();
+ const remainingTicks = Math.max(0, numSpawn - this.game.ticks());
+ const remainingSeconds = Math.ceil(remainingTicks / 10);
+ const minutes = Math.floor(remainingSeconds / 60);
+ const seconds = remainingSeconds % 60;
+ const timeStr = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
- // shadow / backdrop
- context.fillStyle = "rgba(0,0,0,0.55)";
- roundRect(context, x, y, rectWidth, rectHeight, 8);
- context.fill();
+ const textMetrics = context.measureText(timeStr);
+ const textWidth = textMetrics.width;
+ const rectWidth = textWidth + padding * 2;
+ const rectHeight = fontSize + padding * 2;
+ const x = canvasWidth / 2 - rectWidth / 2;
+ const y = 8;
- // text
- context.fillStyle = "#ffcc00";
- context.fillText(timeStr, canvasWidth / 2, y + padding);
+ context.fillStyle = "rgba(0,0,0,0.55)";
+ roundRect(context, x, y, rectWidth, rectHeight, 8);
+ context.fill();
+
+ context.fillStyle = "#ffcc00";
+ context.fillText(timeStr, canvasWidth / 2, y + padding);
+ }
+
+ // During preparation phase show prep countdown centered and elapsed timer top-right
+ if (this.game.inPreparationPhase()) {
+ const spawn = this.game.config().numSpawnPhaseTurns();
+ const prep = this.game.config().numPreparationPhaseTurns();
+ const remainingTicks = Math.max(0, spawn + prep - this.game.ticks());
+ const remainingSeconds = Math.ceil(remainingTicks / 10);
+ const minutes = Math.floor(remainingSeconds / 60);
+ const seconds = remainingSeconds % 60;
+ const prepStr = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
+
+ // center prep timer
+ const textMetrics = context.measureText(prepStr);
+ const textWidth = textMetrics.width;
+ const rectWidth = textWidth + padding * 2;
+ const rectHeight = fontSize + padding * 2;
+ const x = canvasWidth / 2 - rectWidth / 2;
+ const y = 8;
+
+ context.fillStyle = "rgba(0,0,0,0.55)";
+ roundRect(context, x, y, rectWidth, rectHeight, 8);
+ context.fill();
+ context.fillStyle = "#ffcc00";
+ context.fillText(prepStr, canvasWidth / 2, y + padding);
+
+ // elapsed game time (top-right)
+ const elapsedSeconds = Math.floor(this.game.ticks() / 10);
+ const eMinutes = Math.floor(elapsedSeconds / 60);
+ const eSeconds = elapsedSeconds % 60;
+ const elapsedStr = `${String(eMinutes).padStart(2, "0")}:${String(eSeconds).padStart(2, "0")}`;
+ context.textAlign = "right";
+ context.textBaseline = "top";
+ const elTextMetrics = context.measureText(elapsedStr);
+ const elRectWidth = elTextMetrics.width + padding * 2;
+ const ex = canvasWidth - 8 - elRectWidth;
+ const ey = 8;
+ context.fillStyle = "rgba(0,0,0,0.4)";
+ roundRect(context, ex, ey, elRectWidth, rectHeight, 8);
+ context.fill();
+ context.fillStyle = "#ffffff";
+ context.fillText(elapsedStr, canvasWidth - 8 - padding, ey + padding);
+
+ // restore text alignment for any following draws
+ context.textAlign = "center";
+ }
+
+ // After preparation phase show elapsed game timer centered
+ if (!this.game.inSpawnPhase() && !this.game.inPreparationPhase()) {
+ const elapsedSeconds = Math.floor(this.game.ticks() / 10);
+ const eMinutes = Math.floor(elapsedSeconds / 60);
+ const eSeconds = elapsedSeconds % 60;
+ const elapsedStr = `${String(eMinutes).padStart(2, "0")}:${String(eSeconds).padStart(2, "0")}`;
+ const textMetrics = context.measureText(elapsedStr);
+ const textWidth = textMetrics.width;
+ const rectWidth = textWidth + padding * 2;
+ const rectHeight = fontSize + padding * 2;
+ const x = canvasWidth / 2 - rectWidth / 2;
+ const y = 8;
+
+ context.fillStyle = "rgba(0,0,0,0.45)";
+ roundRect(context, x, y, rectWidth, rectHeight, 8);
+ context.fill();
+ context.fillStyle = "#ffffff";
+ context.fillText(elapsedStr, canvasWidth / 2, y + padding);
+ }
context.restore();
}
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index baea027af..3527cedee 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -88,6 +88,7 @@ export interface Config {
donateTroops(): boolean;
instantBuild(): boolean;
numSpawnPhaseTurns(): number;
+ numPreparationPhaseTurns(): number;
userSettings(): UserSettings;
playerTeams(): TeamCountConfig;
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 0e197bd25..352be5fb5 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -613,13 +613,17 @@ export class DefaultConfig implements Config {
return 3;
}
numSpawnPhaseTurns(): number {
- // Nuke Wars uses a 3 minute preparation phase (3 minutes = 180 seconds)
- // Server tick is 100ms => 10 ticks per second -> 180 * 10 = 1800 ticks
- if (this._gameConfig.gameMode === GameMode.NukeWars) {
- return 180 * 10;
- }
+ // Spawn phase (choosing spawn points) defaults
return this._gameConfig.gameType === GameType.Singleplayer ? 100 : 300;
}
+
+ numPreparationPhaseTurns(): number {
+ // Preparation phase duration (Nuke Wars uses a 3 minute prep phase)
+ if (this._gameConfig.gameMode === GameMode.NukeWars) {
+ return 180 * 10; // 180 seconds * 10 ticks/sec
+ }
+ return 0;
+ }
numBots(): number {
return this.bots();
}
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index 181cf259a..e43d6b22a 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -656,6 +656,7 @@ export interface Game extends GameMap {
isOnMap(cell: Cell): boolean;
width(): number;
height(): number;
+ inPreparationPhase(): boolean;
map(): GameMap;
miniMap(): GameMap;
forEachTile(fn: (tile: TileRef) => void): void;
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index b4712954a..08487471a 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -96,8 +96,11 @@ export class GameImpl implements Game {
this._width = _map.width();
this._height = _map.height();
this.unitGrid = new UnitGrid(this._map);
-
- if (_config.gameConfig().gameMode === GameMode.Team) {
+ // Treat Team and NukeWars as team-based games (Nuke Wars is 2-team only)
+ if (
+ _config.gameConfig().gameMode === GameMode.Team ||
+ _config.gameConfig().gameMode === GameMode.NukeWars
+ ) {
this.populateTeams();
}
this.addPlayers();
@@ -105,6 +108,10 @@ export class GameImpl implements Game {
private populateTeams() {
let numPlayerTeams = this._config.playerTeams();
+ // Force 2 teams for NukeWars
+ if (this._config.gameConfig().gameMode === GameMode.NukeWars) {
+ numPlayerTeams = 2;
+ }
if (typeof numPlayerTeams !== "number") {
const players = this._humans.length + this._nations.length;
switch (numPlayerTeams) {
@@ -323,6 +330,14 @@ export class GameImpl implements Game {
return this._ticks <= this.config().numSpawnPhaseTurns();
}
+ inPreparationPhase(): boolean {
+ const spawn = this.config().numSpawnPhaseTurns();
+ const prep = this.config().numPreparationPhaseTurns?.()
+ ? this.config().numPreparationPhaseTurns()
+ : 0;
+ return this._ticks > spawn && this._ticks <= spawn + prep;
+ }
+
ticks(): number {
return this._ticks;
}
@@ -666,7 +681,10 @@ export class GameImpl implements Game {
teams(): Team[] {
if (this._config.gameConfig().gameMode !== GameMode.Team) {
- return [];
+ // Treat NukeWars as a team-based mode (2 teams)
+ if (this._config.gameConfig().gameMode !== GameMode.NukeWars) {
+ return [];
+ }
}
return [this.botTeam, ...this.playerTeams];
}
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index d6ed3730d..3e158714c 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -637,6 +637,12 @@ export class GameView implements GameMap {
inSpawnPhase(): boolean {
return this.ticks() <= this._config.numSpawnPhaseTurns();
}
+
+ inPreparationPhase(): boolean {
+ const spawn = this._config.numSpawnPhaseTurns();
+ const prep = this._config.numPreparationPhaseTurns();
+ return this.ticks() > spawn && this.ticks() <= spawn + prep;
+ }
config(): Config {
return this._config;
}
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index 7972439ab..e0de8a258 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -991,6 +991,12 @@ export class PlayerImpl implements Player {
}
nukeSpawn(tile: TileRef): TileRef | false {
+ // During the Nuke Wars preparation phase nukes cannot be launched across
+ // to enemy territory. Block all nuke launches while in the preparation phase.
+ const gc = this.mg.config().gameConfig();
+ if (gc.gameMode === GameMode.NukeWars && this.mg.inPreparationPhase()) {
+ return false;
+ }
const owner = this.mg.owner(tile);
if (owner.isPlayer()) {
if (this.isOnSameTeam(owner)) {