Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/client/graphics/layers/GameRightSidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export class GameRightSidebar extends LitElement implements Layer {

private hasWinner = false;
private isLobbyCreator = false;
private singleplayerStartTick: number | null = null;
private spawnBarVisible = false;
private immunityBarVisible = false;

Expand Down Expand Up @@ -85,14 +86,24 @@ export class GameRightSidebar extends LitElement implements Layer {
const maxTimerValue = this.game.config().gameConfig().maxTimerValue;
const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns();
const ticks = this.game.ticks();
const gameTicks = Math.max(0, ticks - spawnPhaseTurns);
const elapsedSeconds = Math.floor(gameTicks / 10); // 10 ticks per second
const isSingleplayer =
this.game.config().gameConfig().gameType === GameType.Singleplayer;

if (this.game.inSpawnPhase()) {
this.singleplayerStartTick = null;
this.timer = maxTimerValue !== undefined ? maxTimerValue * 60 : 0;
return;
}

if (isSingleplayer && this.singleplayerStartTick === null) {
this.singleplayerStartTick = ticks;
}

const gameStartTick = isSingleplayer
? (this.singleplayerStartTick ?? ticks)
: spawnPhaseTurns;
const elapsedSeconds = Math.floor(Math.max(0, ticks - gameStartTick) / 10); // 10 ticks per second

if (this.hasWinner) {
return;
}
Expand Down
13 changes: 12 additions & 1 deletion src/client/graphics/layers/SpawnTimer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { GameMode, Team } from "../../../core/game/Game";
import { GameMode, GameType, Team } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
Expand Down Expand Up @@ -38,6 +38,17 @@ export class SpawnTimer extends LitElement implements Layer {
}

tick() {
if (
this.game.config().gameConfig().gameType === GameType.Singleplayer &&
this.game.inSpawnPhase()
) {
// Singleplayer has no spawn countdown.
this.ratios = [];
this.colors = [];
this.requestUpdate();
return;
}

if (this.game.inSpawnPhase()) {
// During spawn phase, only one segment filling full width
this.ratios = [
Expand Down
113 changes: 95 additions & 18 deletions src/core/execution/NationExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import {
Difficulty,
Execution,
Game,
GameType,
Nation,
Player,
PlayerID,
PlayerType,
Relation,
TerrainType,
} from "../game/Game";
Expand Down Expand Up @@ -42,6 +44,7 @@ export class NationExecution implements Execution {
private expandRatio: number;

private readonly embargoMalusApplied = new Set<PlayerID>();
private spawnQueuedAtTick: number | null = null;

constructor(
private gameID: GameID,
Expand Down Expand Up @@ -84,6 +87,30 @@ export class NationExecution implements Execution {
}

tick(ticks: number) {
if (this.player !== null && this.player.hasSpawned()) {
this.spawnQueuedAtTick = null;
} else if (
this.spawnQueuedAtTick !== null &&
ticks > this.spawnQueuedAtTick + 1
) {
// Give queued SpawnExecution one full tick to run, then allow retries.
this.spawnQueuedAtTick = null;
}

if (this.player === null) {
return;
}

if (this.shouldSpawnNationNow()) {
if (this.spawnQueuedAtTick !== null) {
return;
}
if (this.queueSpawnExecution()) {
this.spawnQueuedAtTick = ticks;
}
return;
}

// Ship tracking
if (
this.behaviorsInitialized &&
Expand All @@ -94,33 +121,40 @@ export class NationExecution implements Execution {
this.warshipBehavior.trackShipsAndRetaliate();
}

if (this.player === null) {
if (ticks % this.attackRate !== this.attackTick) {
// Call handleStructures twice between regular attack ticks (at 1/3 and 2/3 of the interval)
// Otherwise it is possible that we earn more gold than we can spend
// The alternative is placing multiple structures in handleStructures, but that causes problems
if (
this.behaviorsInitialized &&
this.player !== null &&
this.player.isAlive()
) {
const offset = ticks % this.attackRate;
const oneThird =
(this.attackTick + Math.floor(this.attackRate / 3)) % this.attackRate;
const twoThirds =
(this.attackTick + Math.floor((this.attackRate * 2) / 3)) %
this.attackRate;
if (offset === oneThird || offset === twoThirds) {
this.structureBehavior.handleStructures();
}
}
return;
}

if (this.mg.inSpawnPhase()) {
if (ticks % this.attackRate !== this.attackTick) {
return;
}
// Place nations without a spawn cell (Dynamically created for HumansVsNations) randomly by SpawnExecution
if (this.nation.spawnCell === undefined) {
this.mg.addExecution(
new SpawnExecution(this.gameID, this.nation.playerInfo),
);
// In singleplayer nations should spawn only after the human spawns.
if (this.mg.config().gameConfig().gameType === GameType.Singleplayer) {
return;
}

// Select a tile near the position defined in the map manifest
const rl = this.randomSpawnLand();

if (rl === null) {
console.warn(`cannot spawn ${this.nation.playerInfo.name}`);
if (this.spawnQueuedAtTick !== null) {
return;
}

this.mg.addExecution(
new SpawnExecution(this.gameID, this.nation.playerInfo, rl),
);
if (this.queueSpawnExecution()) {
this.spawnQueuedAtTick = ticks;
}
return;
}

Expand Down Expand Up @@ -218,6 +252,49 @@ export class NationExecution implements Execution {
this.behaviorsInitialized = true;
}

private shouldSpawnNationNow(): boolean {
if (this.player === null || this.player.hasSpawned()) {
return false;
}

const isSingleplayer =
this.mg.config().gameConfig().gameType === GameType.Singleplayer;
if (!isSingleplayer) {
return false;
}

return this.allHumansSpawned();
}

private allHumansSpawned(): boolean {
return this.mg
.allPlayers()
.filter((p) => p.type() === PlayerType.Human)
.every((p) => p.hasSpawned());
}

private queueSpawnExecution(): boolean {
// Place nations without a spawn cell (dynamically created for HumansVsNations) randomly by SpawnExecution.
if (this.nation.spawnCell === undefined) {
this.mg.addExecution(
new SpawnExecution(this.gameID, this.nation.playerInfo),
);
return true;
}

// Select a tile near the position defined in the map manifest.
const rl = this.randomSpawnLand();
if (rl === null) {
console.warn(`cannot spawn ${this.nation.playerInfo.name}`);
return false;
}

this.mg.addExecution(
new SpawnExecution(this.gameID, this.nation.playerInfo, rl),
);
return true;
}

private randomSpawnLand(): TileRef | null {
if (this.nation.spawnCell === undefined) throw new Error("not initialized");

Expand Down
35 changes: 29 additions & 6 deletions src/core/execution/SpawnExecution.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { Execution, Game, Player, PlayerInfo, PlayerType } from "../game/Game";
import {
Execution,
Game,
GameType,
Player,
PlayerInfo,
PlayerType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { GameID } from "../Schemas";
Expand Down Expand Up @@ -30,18 +37,30 @@ export class SpawnExecution implements Execution {
tick(ticks: number) {
this.active = false;

if (!this.mg.inSpawnPhase()) {
this.active = false;
return;
}

let player: Player | null = null;
if (this.mg.hasPlayer(this.playerInfo.id)) {
player = this.mg.player(this.playerInfo.id);
} else {
player = this.mg.addPlayer(this.playerInfo);
}

const isSingleplayer =
this.mg.config().gameConfig().gameType === GameType.Singleplayer;
const isSingleplayerNationSpawn =
this.playerInfo.playerType === PlayerType.Nation && isSingleplayer;
const isSingleplayerInitialHumanSpawn =
isSingleplayer &&
this.playerInfo.playerType === PlayerType.Human &&
!player.hasSpawned();

if (
!this.mg.inSpawnPhase() &&
!isSingleplayerNationSpawn &&
!isSingleplayerInitialHumanSpawn
) {
return;
}

// Security: If random spawn is enabled, prevent players from re-rolling their spawn location
if (this.mg.config().isRandomSpawn() && player.hasSpawned()) {
return;
Expand All @@ -67,6 +86,10 @@ export class SpawnExecution implements Execution {
}

player.setSpawnTile(this.tile);

if (isSingleplayerInitialHumanSpawn) {
this.mg.endSpawnPhase();
}
}

isActive(): boolean {
Expand Down
6 changes: 2 additions & 4 deletions src/core/execution/WinCheckExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@ export class WinCheckExecution implements Execution {
}

const max = sorted[0];
const timeElapsed =
(this.mg.ticks() - this.mg.config().numSpawnPhaseTurns()) / 10;
const timeElapsed = this.mg.elapsedGameSeconds();
const numTilesWithoutFallout =
this.mg.numLandTiles() - this.mg.numTilesWithFallout();
if (
Expand Down Expand Up @@ -95,8 +94,7 @@ export class WinCheckExecution implements Execution {
return;
}
const max = sorted[0];
const timeElapsed =
(this.mg.ticks() - this.mg.config().numSpawnPhaseTurns()) / 10;
const timeElapsed = this.mg.elapsedGameSeconds();
const numTilesWithoutFallout =
this.mg.numLandTiles() - this.mg.numTilesWithFallout();
const percentage = (max[1] / numTilesWithoutFallout) * 100;
Expand Down
2 changes: 2 additions & 0 deletions src/core/game/Game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,10 +760,12 @@ export interface Game extends GameMap {
// Immunity timer
isSpawnImmunityActive(): boolean;
isNationSpawnImmunityActive(): boolean;
elapsedGameSeconds(): number;

// Game State
ticks(): Tick;
inSpawnPhase(): boolean;
endSpawnPhase(): void;
executeNextTick(): GameUpdates;
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void;
getWinner(): Player | Team | null;
Expand Down
Loading
Loading