mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
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:
@@ -94,6 +94,7 @@
|
||||
"disable_nations": "single_modal.disable_nations",
|
||||
"instant_build": "single_modal.instant_build",
|
||||
"infinite_gold": "single_modal.infinite_gold",
|
||||
"random_spawn": "single_modal.random_spawn",
|
||||
"infinite_troops": "single_modal.infinite_troops",
|
||||
"disable_nukes": "single_modal.disable_nukes",
|
||||
"start": "single_modal.start"
|
||||
@@ -146,6 +147,7 @@
|
||||
"bots_disabled": "host_modal.bots_disabled",
|
||||
"disable_nations": "host_modal.disable_nations",
|
||||
"instant_build": "host_modal.instant_build",
|
||||
"random_spawn": "host_modal.random_spawn",
|
||||
"infinite_gold": "host_modal.infinite_gold",
|
||||
"infinite_troops": "host_modal.infinite_troops",
|
||||
"disable_nukes": "host_modal.disable_nukes",
|
||||
@@ -166,6 +168,7 @@
|
||||
"Impossible": "difficulty.Impossible"
|
||||
},
|
||||
"heads_up_message": {
|
||||
"choose_spawn": "heads_up_message.choose_spawn"
|
||||
"choose_spawn": "heads_up_message.choose_spawn",
|
||||
"random_spawn": "heads_up_message.random_spawn"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@
|
||||
},
|
||||
"single_modal": {
|
||||
"title": "Single Player",
|
||||
"random_spawn": "Random spawn",
|
||||
"allow_alliances": "Allow alliances",
|
||||
"options_title": "Options",
|
||||
"bots": "Bots: ",
|
||||
@@ -262,6 +263,7 @@
|
||||
"player": "Player",
|
||||
"players": "Players",
|
||||
"waiting": "Waiting for players...",
|
||||
"random_spawn": "Random spawn",
|
||||
"start": "Start Game",
|
||||
"host_badge": "Host"
|
||||
},
|
||||
@@ -661,10 +663,15 @@
|
||||
"copy_clipboard": "Copy to clipboard",
|
||||
"copied": "Copied!",
|
||||
"failed_copy": "Failed to copy",
|
||||
"spawn_failed": {
|
||||
"title": "Spawn failed",
|
||||
"description": "Automatic spawn selection failed. You can't play this game."
|
||||
},
|
||||
"desync_notice": "You are desynced from other players. What you see might differ from other players."
|
||||
},
|
||||
"heads_up_message": {
|
||||
"choose_spawn": "Choose a starting location"
|
||||
"choose_spawn": "Choose a starting location",
|
||||
"random_spawn": "Random spawn is enabled. Selecting starting location for you..."
|
||||
},
|
||||
"territory_patterns": {
|
||||
"title": "Skins",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -88,6 +88,7 @@ export interface Config {
|
||||
infiniteTroops(): boolean;
|
||||
donateTroops(): boolean;
|
||||
instantBuild(): boolean;
|
||||
isRandomSpawn(): boolean;
|
||||
numSpawnPhaseTurns(): number;
|
||||
userSettings(): UserSettings;
|
||||
playerTeams(): TeamCountConfig;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export class GameManager {
|
||||
infiniteTroops: false,
|
||||
maxTimerValue: undefined,
|
||||
instantBuild: false,
|
||||
randomSpawn: false,
|
||||
gameMode: GameMode.FFA,
|
||||
bots: 400,
|
||||
disabledUnits: [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export class MapPlaylist {
|
||||
infiniteTroops: false,
|
||||
maxTimerValue: undefined,
|
||||
instantBuild: false,
|
||||
randomSpawn: false,
|
||||
disableNPCs: mode === GameMode.Team && playerTeams !== HumansVsNations,
|
||||
gameMode: mode,
|
||||
playerTeams,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { PlayerSpawner } from "../../../../src/core/execution/utils/PlayerSpawner";
|
||||
import { PlayerInfo, PlayerType } from "../../../../src/core/game/Game";
|
||||
import { setup } from "../../../util/Setup";
|
||||
|
||||
describe("PlayerSpawner", () => {
|
||||
// Manually calculated based on number of tiles in manifest of each map
|
||||
// and minimum distance between players in PlayerSpawner
|
||||
test.each([
|
||||
["big_plains", 49],
|
||||
["half_land_half_ocean", 1],
|
||||
["ocean_and_land", 1],
|
||||
["plains", 9],
|
||||
])(
|
||||
"Spawn location is found for all players in %s map with %i players",
|
||||
async (mapName, maxPlayers) => {
|
||||
const players: PlayerInfo[] = [];
|
||||
|
||||
for (let i = 0; i < maxPlayers; i++) {
|
||||
players.push(
|
||||
new PlayerInfo(
|
||||
`player${i}`,
|
||||
PlayerType.Human,
|
||||
`client_id${i}`,
|
||||
`player_id${i}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const game = await setup(mapName, undefined, players);
|
||||
|
||||
const executors = new PlayerSpawner(game, "game_id").spawnPlayers();
|
||||
expect(executors.length).toBe(maxPlayers);
|
||||
|
||||
for (const executor of executors) {
|
||||
expect(game.isLand(executor.tile)).toBe(true);
|
||||
expect(game.isBorder(executor.tile)).toBe(false);
|
||||
}
|
||||
|
||||
for (let i = 0; i < executors.length; i++) {
|
||||
for (let j = i + 1; j < executors.length; j++) {
|
||||
const distance = game.manhattanDist(
|
||||
executors[i].tile,
|
||||
executors[j].tile,
|
||||
);
|
||||
expect(distance).toBeGreaterThanOrEqual(30);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("Handles spawn failure when map is too crowded", async () => {
|
||||
const players: PlayerInfo[] = [];
|
||||
|
||||
// Try to spawn more players than possible on a small map
|
||||
for (let i = 0; i < 5; i++) {
|
||||
players.push(
|
||||
new PlayerInfo(
|
||||
`player${i}`,
|
||||
PlayerType.Human,
|
||||
`client_id${i}`,
|
||||
`player_id${i}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const game = await setup("half_land_half_ocean", undefined, players);
|
||||
const executors = new PlayerSpawner(game, "game_id").spawnPlayers();
|
||||
|
||||
// Should spawn fewer than requested when map is too small
|
||||
expect(executors.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,7 @@ export async function setup(
|
||||
infiniteGold: false,
|
||||
infiniteTroops: false,
|
||||
instantBuild: false,
|
||||
randomSpawn: false,
|
||||
..._gameConfig,
|
||||
};
|
||||
const config = new ConfigClass(
|
||||
|
||||
Reference in New Issue
Block a user