Random spawn (#2375)

## Description:

https://github.com/openfrontio/OpenFrontIO/issues/2352

This is my first PR in this project, and I’ll continue refining it based
on feedback.

<img width="1088" height="859" alt="image"
src="https://github.com/user-attachments/assets/07f4f8b1-52fa-4136-add4-19b00aefd963"
/>

<img width="1157" height="783" alt="image"
src="https://github.com/user-attachments/assets/1c5be80d-72f8-4ead-8d4b-706a3a04fd73"
/>

<img width="1488" height="777" alt="image"
src="https://github.com/user-attachments/assets/4d743548-f0c3-4579-963b-43676f68fab1"
/>

<img width="1499" height="778" alt="image"
src="https://github.com/user-attachments/assets/f808e44f-ef97-467f-9e41-812e2857c36e"
/>


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

nikolaj_mykola

---------

Co-authored-by: Evan <evanpelle@gmail.com>
This commit is contained in:
Mykola
2025-11-07 01:49:37 +02:00
committed by GitHub
parent 020486686f
commit 25ea11114e
17 changed files with 276 additions and 4 deletions
+42 -1
View File
@@ -48,6 +48,7 @@ import {
} from "./Transport";
import { createCanvas } from "./Utils";
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
import { GoToPlayerEvent } from "./graphics/layers/Leaderboard";
import SoundManager from "./sound/SoundManager";
export interface LobbyConfig {
@@ -202,6 +203,8 @@ export class ClientGameRunner {
private lastMessageTime: number = 0;
private connectionCheckInterval: NodeJS.Timeout | null = null;
private goToPlayerTimeout: NodeJS.Timeout | null = null;
private lastTickReceiveTime: number = 0;
private currentTickDelay: number | undefined = undefined;
@@ -325,6 +328,39 @@ export class ClientGameRunner {
if (message.type === "start") {
this.hasJoined = true;
console.log("starting game!");
if (this.gameView.config().isRandomSpawn()) {
const goToPlayer = () => {
const myPlayer = this.gameView.myPlayer();
if (this.gameView.inSpawnPhase() && !myPlayer?.hasSpawned()) {
this.goToPlayerTimeout = setTimeout(goToPlayer, 1000);
return;
}
if (!myPlayer) {
return;
}
if (!this.gameView.inSpawnPhase() && !myPlayer.hasSpawned()) {
showErrorModal(
"spawn_failed",
translateText("error_modal.spawn_failed.description"),
this.lobby.gameID,
this.lobby.clientID,
true,
false,
translateText("error_modal.spawn_failed.title"),
);
return;
}
this.eventBus.emit(new GoToPlayerEvent(myPlayer));
};
goToPlayer();
}
for (const turn of message.turns) {
if (turn.turnNumber < this.turnsSeen) {
continue;
@@ -402,6 +438,10 @@ export class ClientGameRunner {
clearInterval(this.connectionCheckInterval);
this.connectionCheckInterval = null;
}
if (this.goToPlayerTimeout) {
clearTimeout(this.goToPlayerTimeout);
this.goToPlayerTimeout = null;
}
}
private inputEvent(event: MouseUpEvent) {
@@ -420,7 +460,8 @@ export class ClientGameRunner {
if (
this.gameView.isLand(tile) &&
!this.gameView.hasOwner(tile) &&
this.gameView.inSpawnPhase()
this.gameView.inSpawnPhase() &&
!this.gameView.config().isRandomSpawn()
) {
this.eventBus.emit(new SendSpawnIntentEvent(tile));
return;
+23
View File
@@ -48,6 +48,7 @@ export class HostLobbyModal extends LitElement {
@state() private maxTimer: boolean = false;
@state() private maxTimerValue: number | undefined = undefined;
@state() private instantBuild: boolean = false;
@state() private randomSpawn: boolean = false;
@state() private compactMap: boolean = false;
@state() private lobbyId = "";
@state() private copySuccess = false;
@@ -390,6 +391,22 @@ export class HostLobbyModal extends LitElement {
</div>
</label>
<label
for="random-spawn"
class="option-card ${this.randomSpawn ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="random-spawn"
@change=${this.handleRandomSpawnChange}
.checked=${this.randomSpawn}
/>
<div class="option-card-title">
${translateText("host_modal.random_spawn")}
</div>
</label>
<label
for="donate-gold"
class="option-card ${this.donateGold ? "selected" : ""}"
@@ -668,6 +685,11 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig();
}
private handleRandomSpawnChange(e: Event) {
this.randomSpawn = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig();
}
private handleInfiniteGoldChange(e: Event) {
this.infiniteGold = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig();
@@ -749,6 +771,7 @@ export class HostLobbyModal extends LitElement {
infiniteTroops: this.infiniteTroops,
donateTroops: this.donateTroops,
instantBuild: this.instantBuild,
randomSpawn: this.randomSpawn,
gameMode: this.gameMode,
disabledUnits: this.disabledUnits,
playerTeams: this.teamCount,
+22
View File
@@ -44,6 +44,7 @@ export class SinglePlayerModal extends LitElement {
@state() private maxTimer: boolean = false;
@state() private maxTimerValue: number | undefined = undefined;
@state() private instantBuild: boolean = false;
@state() private randomSpawn: boolean = false;
@state() private useRandomMap: boolean = false;
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: TeamCountConfig = 2;
@@ -293,6 +294,22 @@ export class SinglePlayerModal extends LitElement {
</div>
</label>
<label
for="singleplayer-modal-random-spawn"
class="option-card ${this.randomSpawn ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="singleplayer-modal-random-spawn"
@change=${this.handleRandomSpawnChange}
.checked=${this.randomSpawn}
/>
<div class="option-card-title">
${translateText("single_modal.random_spawn")}
</div>
</label>
<label
for="singleplayer-modal-infinite-gold"
class="option-card ${this.infiniteGold ? "selected" : ""}"
@@ -440,6 +457,10 @@ export class SinglePlayerModal extends LitElement {
this.instantBuild = Boolean((e.target as HTMLInputElement).checked);
}
private handleRandomSpawnChange(e: Event) {
this.randomSpawn = Boolean((e.target as HTMLInputElement).checked);
}
private handleInfiniteGoldChange(e: Event) {
this.infiniteGold = Boolean((e.target as HTMLInputElement).checked);
}
@@ -563,6 +584,7 @@ export class SinglePlayerModal extends LitElement {
donateTroops: true,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
randomSpawn: this.randomSpawn,
disabledUnits: this.disabledUnits
.map((u) => Object.values(UnitType).find((ut) => ut === u))
.filter((ut): ut is UnitType => ut !== undefined),
+3 -1
View File
@@ -40,7 +40,9 @@ export class HeadsUpMessage extends LitElement implements Layer {
backdrop-blur-md text-white text-md lg:text-xl p-1 lg:p-2"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
${translateText("heads_up_message.choose_spawn")}
${this.game.config().isRandomSpawn()
? translateText("heads_up_message.random_spawn")
: translateText("heads_up_message.choose_spawn")}
</div>
`;
}
+3
View File
@@ -100,6 +100,9 @@ export class GameRunner {
) {}
init() {
if (this.game.config().isRandomSpawn()) {
this.game.addExecution(...this.execManager.spawnPlayers());
}
if (this.game.config().bots() > 0) {
this.game.addExecution(
...this.execManager.spawnBots(this.game.config().numBots()),
+1
View File
@@ -167,6 +167,7 @@ export const GameConfigSchema = z.object({
infiniteGold: z.boolean(),
infiniteTroops: z.boolean(),
instantBuild: z.boolean(),
randomSpawn: z.boolean(),
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).optional(),
disabledUnits: z.enum(UnitType).array().optional(),
+1
View File
@@ -88,6 +88,7 @@ export interface Config {
infiniteTroops(): boolean;
donateTroops(): boolean;
instantBuild(): boolean;
isRandomSpawn(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
playerTeams(): TeamCountConfig;
+3
View File
@@ -338,6 +338,9 @@ export class DefaultConfig implements Config {
instantBuild(): boolean {
return this._gameConfig.instantBuild;
}
isRandomSpawn(): boolean {
return this._gameConfig.randomSpawn;
}
infiniteGold(): boolean {
return this._gameConfig.infiniteGold;
}
+5
View File
@@ -26,6 +26,7 @@ import { SpawnExecution } from "./SpawnExecution";
import { TargetPlayerExecution } from "./TargetPlayerExecution";
import { TransportShipExecution } from "./TransportShipExecution";
import { UpgradeStructureExecution } from "./UpgradeStructureExecution";
import { PlayerSpawner } from "./utils/PlayerSpawner";
export class Executor {
// private random = new PseudoRandom(999)
@@ -131,6 +132,10 @@ export class Executor {
return new BotSpawner(this.mg, this.gameID).spawnBots(numBots);
}
spawnPlayers(): Execution[] {
return new PlayerSpawner(this.mg, this.gameID).spawnPlayers();
}
fakeHumanExecutions(): Execution[] {
const execs: Execution[] = [];
for (const nation of this.mg.nations()) {
+83
View File
@@ -0,0 +1,83 @@
import { Game, PlayerType } from "../../game/Game";
import { TileRef } from "../../game/GameMap";
import { PseudoRandom } from "../../PseudoRandom";
import { GameID } from "../../Schemas";
import { simpleHash } from "../../Util";
import { SpawnExecution } from "../SpawnExecution";
export class PlayerSpawner {
private random: PseudoRandom;
private players: SpawnExecution[] = [];
private static readonly MAX_SPAWN_TRIES = 10_000;
private static readonly MIN_SPAWN_DISTANCE = 30;
constructor(
private gm: Game,
gameID: GameID,
) {
this.random = new PseudoRandom(simpleHash(gameID));
}
private randTile(): TileRef {
const x = this.random.nextInt(0, this.gm.width());
const y = this.random.nextInt(0, this.gm.height());
return this.gm.ref(x, y);
}
private randomSpawnLand(): TileRef | null {
let tries = 0;
while (tries < PlayerSpawner.MAX_SPAWN_TRIES) {
tries++;
const tile = this.randTile();
if (
!this.gm.isLand(tile) ||
this.gm.hasOwner(tile) ||
this.gm.isBorder(tile)
) {
continue;
}
let tooCloseToOtherPlayer = false;
for (const spawn of this.players) {
if (
this.gm.manhattanDist(spawn.tile, tile) <
PlayerSpawner.MIN_SPAWN_DISTANCE
) {
tooCloseToOtherPlayer = true;
break;
}
}
if (tooCloseToOtherPlayer) {
continue;
}
return tile;
}
return null;
}
spawnPlayers(): SpawnExecution[] {
for (const player of this.gm.allPlayers()) {
if (player.type() !== PlayerType.Human) {
continue;
}
const spawnLand = this.randomSpawnLand();
if (spawnLand === null) {
// TODO: this should normally not happen, additional logic may be needed, if this occurs
continue;
}
this.players.push(new SpawnExecution(player.info(), spawnLand));
}
return this.players;
}
}
+1
View File
@@ -56,6 +56,7 @@ export class GameManager {
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
randomSpawn: false,
gameMode: GameMode.FFA,
bots: 400,
disabledUnits: [],
+3
View File
@@ -115,6 +115,9 @@ export class GameServer {
if (gameConfig.instantBuild !== undefined) {
this.gameConfig.instantBuild = gameConfig.instantBuild;
}
if (gameConfig.randomSpawn !== undefined) {
this.gameConfig.randomSpawn = gameConfig.randomSpawn;
}
if (gameConfig.gameMode !== undefined) {
this.gameConfig.gameMode = gameConfig.gameMode;
}
+1
View File
@@ -95,6 +95,7 @@ export class MapPlaylist {
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
randomSpawn: false,
disableNPCs: mode === GameMode.Team && playerTeams !== HumansVsNations,
gameMode: mode,
playerTeams,