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>
`;
}