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)) {