mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:50:43 +00:00
Added pause functionality for private multiplayer games (#2657)
If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #2491 ## Description: Adds pause/unpause functionality for private multiplayer games. Only the lobby creator can pause the game, and all players see a pause overlay when the game is paused. **Key features:** - Lobby creator sees pause/play button in control panel (alongside existing singleplayer/replay controls) - Server validates that only lobby creator can toggle pause - All players see "Game paused by Lobby Creator" overlay when paused - Game state freezes (no turn execution) while paused - Unpause resumes normal gameplay **Implementation details:** - Server-side pause state (`isPaused`) prevents turn execution during pause - Each client receives `isLobbyCreator` flag in `GameStartInfo` to show/hide pause button - Added `TogglePauseIntent` that broadcasts to all clients via `NoOpExecution` - New `PauseOverlay` component (shows in single player also) ## 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: furo18 <img width="1459" height="861" alt="Screenshot 2025-12-20 at 15 16 33" src="https://github.com/user-attachments/assets/f5a3222f-f54b-473c-b0f6-104ce4c1e7a8" />
This commit is contained in:
@@ -744,6 +744,10 @@
|
||||
"choose_spawn": "Choose a starting location",
|
||||
"random_spawn": "Random spawn is enabled. Selecting starting location for you..."
|
||||
},
|
||||
"pause": {
|
||||
"singleplayer_game_paused": "Game paused",
|
||||
"multiplayer_game_paused": "Game paused by Lobby Creator"
|
||||
},
|
||||
"territory_patterns": {
|
||||
"title": "Skins",
|
||||
"colors": "Colors",
|
||||
|
||||
@@ -328,6 +328,7 @@ export class ClientGameRunner {
|
||||
this.saveGame(gu.updates[GameUpdateType.Win][0]);
|
||||
}
|
||||
});
|
||||
|
||||
const worker = this.worker;
|
||||
const keepWorkerAlive = () => {
|
||||
if (this.isActive) {
|
||||
@@ -432,7 +433,17 @@ export class ClientGameRunner {
|
||||
`got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`,
|
||||
);
|
||||
} else {
|
||||
this.worker.sendTurn(message.turn);
|
||||
this.worker.sendTurn(
|
||||
// Filter out pause intents in replays
|
||||
this.gameView.config().isReplay()
|
||||
? {
|
||||
...message.turn,
|
||||
intents: message.turn.intents.filter(
|
||||
(i) => i.type !== "toggle_pause",
|
||||
),
|
||||
}
|
||||
: message.turn,
|
||||
);
|
||||
this.turnsSeen++;
|
||||
}
|
||||
}
|
||||
|
||||
+15
-11
@@ -97,14 +97,6 @@ export class LocalServer {
|
||||
} satisfies ServerStartGameMessage);
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.paused = true;
|
||||
}
|
||||
|
||||
resume() {
|
||||
this.paused = false;
|
||||
}
|
||||
|
||||
onMessage(clientMsg: ClientMessage) {
|
||||
if (clientMsg.type === "rejoin") {
|
||||
this.clientMessage({
|
||||
@@ -115,13 +107,25 @@ export class LocalServer {
|
||||
} satisfies ServerStartGameMessage);
|
||||
}
|
||||
if (clientMsg.type === "intent") {
|
||||
if (this.lobbyConfig.gameRecord) {
|
||||
// If we are replaying a game, we don't want to process intents
|
||||
if (clientMsg.intent.type === "toggle_pause") {
|
||||
if (clientMsg.intent.paused) {
|
||||
// Pausing: add intent and end turn before pause takes effect
|
||||
this.intents.push(clientMsg.intent);
|
||||
this.endTurn();
|
||||
this.paused = true;
|
||||
} else {
|
||||
// Unpausing: clear pause flag before adding intent so next turn can execute
|
||||
this.paused = false;
|
||||
this.intents.push(clientMsg.intent);
|
||||
this.endTurn();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.paused) {
|
||||
// Don't process non-pause intents during replays or while paused
|
||||
if (this.lobbyConfig.gameRecord || this.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.intents.push(clientMsg.intent);
|
||||
}
|
||||
if (clientMsg.type === "hash") {
|
||||
|
||||
+9
-12
@@ -29,7 +29,7 @@ import { getPlayToken } from "./Auth";
|
||||
import { LobbyConfig } from "./ClientGameRunner";
|
||||
import { LocalServer } from "./LocalServer";
|
||||
|
||||
export class PauseGameEvent implements GameEvent {
|
||||
export class PauseGameIntentEvent implements GameEvent {
|
||||
constructor(public readonly paused: boolean) {}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ export class Transport {
|
||||
|
||||
private pingInterval: number | null = null;
|
||||
public readonly isLocal: boolean;
|
||||
|
||||
constructor(
|
||||
private lobbyConfig: LobbyConfig,
|
||||
private eventBus: EventBus,
|
||||
@@ -237,7 +238,7 @@ export class Transport {
|
||||
);
|
||||
this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e));
|
||||
|
||||
this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e));
|
||||
this.eventBus.on(PauseGameIntentEvent, (e) => this.onPauseGameIntent(e));
|
||||
this.eventBus.on(SendWinnerEvent, (e) => this.onSendWinnerEvent(e));
|
||||
this.eventBus.on(SendHashEvent, (e) => this.onSendHashEvent(e));
|
||||
this.eventBus.on(CancelAttackIntentEvent, (e) =>
|
||||
@@ -575,16 +576,12 @@ export class Transport {
|
||||
});
|
||||
}
|
||||
|
||||
private onPauseGameEvent(event: PauseGameEvent) {
|
||||
if (!this.isLocal) {
|
||||
console.log(`cannot pause multiplayer games`);
|
||||
return;
|
||||
}
|
||||
if (event.paused) {
|
||||
this.localServer.pause();
|
||||
} else {
|
||||
this.localServer.resume();
|
||||
}
|
||||
private onPauseGameIntent(event: PauseGameIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "toggle_pause",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
paused: event.paused,
|
||||
});
|
||||
}
|
||||
|
||||
private onSendWinnerEvent(event: SendWinnerEvent) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { GameType } from "../../../core/game/Game";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { crazyGamesSDK } from "../../CrazyGamesSDK";
|
||||
import { PauseGameEvent } from "../../Transport";
|
||||
import { PauseGameIntentEvent } from "../../Transport";
|
||||
import { translateText } from "../../Utils";
|
||||
import { Layer } from "./Layer";
|
||||
import { ShowReplayPanelEvent } from "./ReplayPanel";
|
||||
@@ -37,6 +37,7 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
private timer: number = 0;
|
||||
|
||||
private hasWinner = false;
|
||||
private isLobbyCreator = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
@@ -48,6 +49,7 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
this.game.config().isReplay();
|
||||
this._isVisible = true;
|
||||
this.game.inSpawnPhase();
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -57,6 +59,13 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
if (updates) {
|
||||
this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0;
|
||||
}
|
||||
|
||||
// Check if the player is the lobby creator
|
||||
if (!this.isLobbyCreator && this.game.myPlayer()?.isLobbyCreator()) {
|
||||
this.isLobbyCreator = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
const maxTimerValue = this.game.config().gameConfig().maxTimerValue;
|
||||
if (maxTimerValue !== undefined) {
|
||||
if (this.game.inSpawnPhase()) {
|
||||
@@ -96,7 +105,7 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
|
||||
private onPauseButtonClick() {
|
||||
this.isPaused = !this.isPaused;
|
||||
this.eventBus.emit(new PauseGameEvent(this.isPaused));
|
||||
this.eventBus.emit(new PauseGameIntentEvent(this.isPaused));
|
||||
}
|
||||
|
||||
private onExitButtonClick() {
|
||||
@@ -153,25 +162,35 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
maybeRenderReplayButtons() {
|
||||
if (this._isSinglePlayer || this.game?.config()?.isReplay()) {
|
||||
return html` <div class="cursor-pointer" @click=${this.toggleReplayPanel}>
|
||||
<img
|
||||
src=${FastForwardIconSolid}
|
||||
alt="replay"
|
||||
width="20"
|
||||
height="20"
|
||||
/>
|
||||
</div>
|
||||
<div class="cursor-pointer" @click=${this.onPauseButtonClick}>
|
||||
<img
|
||||
src=${this.isPaused ? playIcon : pauseIcon}
|
||||
alt="play/pause"
|
||||
width="20"
|
||||
height="20"
|
||||
/>
|
||||
</div>`;
|
||||
} else {
|
||||
return html``;
|
||||
}
|
||||
const isReplayOrSingleplayer =
|
||||
this._isSinglePlayer || this.game?.config()?.isReplay();
|
||||
const showPauseButton = isReplayOrSingleplayer || this.isLobbyCreator;
|
||||
|
||||
return html`
|
||||
${isReplayOrSingleplayer
|
||||
? html`
|
||||
<div class="cursor-pointer" @click=${this.toggleReplayPanel}>
|
||||
<img
|
||||
src=${FastForwardIconSolid}
|
||||
alt="replay"
|
||||
width="20"
|
||||
height="20"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
${showPauseButton
|
||||
? html`
|
||||
<div class="cursor-pointer" @click=${this.onPauseButtonClick}>
|
||||
<img
|
||||
src=${this.isPaused ? playIcon : pauseIcon}
|
||||
alt="play/pause"
|
||||
width="20"
|
||||
height="20"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { GameType } from "../../../core/game/Game";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { translateText } from "../../Utils";
|
||||
import { Layer } from "./Layer";
|
||||
@@ -11,6 +13,9 @@ export class HeadsUpMessage extends LitElement implements Layer {
|
||||
@state()
|
||||
private isVisible = false;
|
||||
|
||||
@state()
|
||||
private isPaused = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
@@ -21,10 +26,27 @@ export class HeadsUpMessage extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (!this.game.inSpawnPhase()) {
|
||||
this.isVisible = false;
|
||||
this.requestUpdate();
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
if (updates && updates[GameUpdateType.GamePaused].length > 0) {
|
||||
const pauseUpdate = updates[GameUpdateType.GamePaused][0];
|
||||
this.isPaused = pauseUpdate.paused;
|
||||
}
|
||||
|
||||
this.isVisible = this.game.inSpawnPhase() || this.isPaused;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private getMessage(): string {
|
||||
if (this.isPaused) {
|
||||
if (this.game.config().gameConfig().gameType === GameType.Singleplayer) {
|
||||
return translateText("pause.singleplayer_game_paused");
|
||||
} else {
|
||||
return translateText("pause.multiplayer_game_paused");
|
||||
}
|
||||
}
|
||||
return this.game.config().isRandomSpawn()
|
||||
? translateText("heads_up_message.random_spawn")
|
||||
: translateText("heads_up_message.choose_spawn");
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -32,17 +54,17 @@ export class HeadsUpMessage extends LitElement implements Layer {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const message = this.getMessage();
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="flex items-center relative
|
||||
w-full justify-evenly h-8 lg:h-10 md:top-[70px] left-0 lg:left-4
|
||||
bg-opacity-60 bg-gray-900 rounded-md lg:rounded-lg
|
||||
w-full justify-evenly h-8 lg:h-10 md:top-[70px] left-0 lg:left-4
|
||||
bg-opacity-60 bg-gray-900 rounded-md lg:rounded-lg
|
||||
backdrop-blur-md text-white text-md lg:text-xl p-1 lg:p-2"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
${this.game.config().isRandomSpawn()
|
||||
? translateText("heads_up_message.random_spawn")
|
||||
: translateText("heads_up_message.choose_spawn")}
|
||||
${message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import musicIcon from "../../../../resources/images/music.svg";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler";
|
||||
import { PauseGameEvent } from "../../Transport";
|
||||
import { PauseGameIntentEvent } from "../../Transport";
|
||||
import { translateText } from "../../Utils";
|
||||
import SoundManager from "../../sound/SoundManager";
|
||||
import { Layer } from "./Layer";
|
||||
@@ -108,7 +108,7 @@ export class SettingsModal extends LitElement implements Layer {
|
||||
|
||||
private pauseGame(pause: boolean) {
|
||||
if (this.shouldPause && !this.wasPausedWhenOpened)
|
||||
this.eventBus.emit(new PauseGameEvent(pause));
|
||||
this.eventBus.emit(new PauseGameIntentEvent(pause));
|
||||
}
|
||||
|
||||
private onTerrainButtonClick() {
|
||||
|
||||
@@ -52,6 +52,7 @@ export async function createGameRunner(
|
||||
PlayerType.Human,
|
||||
p.clientID,
|
||||
random.nextID(),
|
||||
p.isLobbyCreator ?? false,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+11
-1
@@ -47,7 +47,8 @@ export type Intent =
|
||||
| EmbargoAllIntent
|
||||
| UpgradeStructureIntent
|
||||
| DeleteUnitIntent
|
||||
| KickPlayerIntent;
|
||||
| KickPlayerIntent
|
||||
| TogglePauseIntent;
|
||||
|
||||
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
|
||||
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
|
||||
@@ -79,6 +80,7 @@ export type AllianceExtensionIntent = z.infer<
|
||||
>;
|
||||
export type DeleteUnitIntent = z.infer<typeof DeleteUnitIntentSchema>;
|
||||
export type KickPlayerIntent = z.infer<typeof KickPlayerIntentSchema>;
|
||||
export type TogglePauseIntent = z.infer<typeof TogglePauseIntentSchema>;
|
||||
|
||||
export type Turn = z.infer<typeof TurnSchema>;
|
||||
export type GameConfig = z.infer<typeof GameConfigSchema>;
|
||||
@@ -91,6 +93,7 @@ export type ClientMessage =
|
||||
| ClientRejoinMessage
|
||||
| ClientLogMessage
|
||||
| ClientHashMessage;
|
||||
|
||||
export type ServerMessage =
|
||||
| ServerTurnMessage
|
||||
| ServerStartGameMessage
|
||||
@@ -354,6 +357,11 @@ export const KickPlayerIntentSchema = BaseIntentSchema.extend({
|
||||
target: ID,
|
||||
});
|
||||
|
||||
export const TogglePauseIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("toggle_pause"),
|
||||
paused: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const IntentSchema = z.discriminatedUnion("type", [
|
||||
AttackIntentSchema,
|
||||
CancelAttackIntentSchema,
|
||||
@@ -377,6 +385,7 @@ const IntentSchema = z.discriminatedUnion("type", [
|
||||
AllianceExtensionIntentSchema,
|
||||
DeleteUnitIntentSchema,
|
||||
KickPlayerIntentSchema,
|
||||
TogglePauseIntentSchema,
|
||||
]);
|
||||
|
||||
//
|
||||
@@ -430,6 +439,7 @@ export const PlayerSchema = z.object({
|
||||
clientID: ID,
|
||||
username: UsernameSchema,
|
||||
cosmetics: PlayerCosmeticsSchema.optional(),
|
||||
isLobbyCreator: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const GameStartInfoSchema = z.object({
|
||||
|
||||
@@ -20,6 +20,7 @@ import { MarkDisconnectedExecution } from "./MarkDisconnectedExecution";
|
||||
import { MoveWarshipExecution } from "./MoveWarshipExecution";
|
||||
import { NationExecution } from "./NationExecution";
|
||||
import { NoOpExecution } from "./NoOpExecution";
|
||||
import { PauseExecution } from "./PauseExecution";
|
||||
import { QuickChatExecution } from "./QuickChatExecution";
|
||||
import { RetreatExecution } from "./RetreatExecution";
|
||||
import { SpawnExecution } from "./SpawnExecution";
|
||||
@@ -123,6 +124,8 @@ export class Executor {
|
||||
);
|
||||
case "mark_disconnected":
|
||||
return new MarkDisconnectedExecution(player, intent.isDisconnected);
|
||||
case "toggle_pause":
|
||||
return new PauseExecution(player, intent.paused);
|
||||
default:
|
||||
throw new Error(`intent type ${intent} not found`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Execution, Game, GameType, Player } from "../game/Game";
|
||||
|
||||
export class PauseExecution implements Execution {
|
||||
constructor(
|
||||
private player: Player,
|
||||
private paused: boolean,
|
||||
) {}
|
||||
|
||||
isActive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
init(game: Game, ticks: number): void {
|
||||
if (
|
||||
this.player.isLobbyCreator() ||
|
||||
game.config().gameConfig().gameType === GameType.Singleplayer
|
||||
) {
|
||||
game.setPaused(this.paused);
|
||||
}
|
||||
}
|
||||
|
||||
tick(ticks: number): void {}
|
||||
}
|
||||
@@ -418,6 +418,7 @@ export class PlayerInfo {
|
||||
public readonly clientID: ClientID | null,
|
||||
// TODO: make player id the small id
|
||||
public readonly id: PlayerID,
|
||||
public readonly isLobbyCreator: boolean = false,
|
||||
) {
|
||||
this.clan = getClanTag(name);
|
||||
}
|
||||
@@ -538,6 +539,7 @@ export interface Player {
|
||||
type(): PlayerType;
|
||||
isPlayer(): this is Player;
|
||||
toString(): string;
|
||||
isLobbyCreator(): boolean;
|
||||
|
||||
// State & Properties
|
||||
isAlive(): boolean;
|
||||
@@ -707,6 +709,8 @@ export interface Game extends GameMap {
|
||||
executeNextTick(): GameUpdates;
|
||||
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void;
|
||||
config(): Config;
|
||||
isPaused(): boolean;
|
||||
setPaused(paused: boolean): void;
|
||||
|
||||
// Units
|
||||
units(...types: UnitType[]): Unit[];
|
||||
|
||||
@@ -85,6 +85,8 @@ export class GameImpl implements Game {
|
||||
// Used to assign unique IDs to each new alliance
|
||||
private nextAllianceID: number = 0;
|
||||
|
||||
private _isPaused: boolean = false;
|
||||
|
||||
constructor(
|
||||
private _humans: PlayerInfo[],
|
||||
private _nations: Nation[],
|
||||
@@ -337,6 +339,15 @@ export class GameImpl implements Game {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
isPaused(): boolean {
|
||||
return this._isPaused;
|
||||
}
|
||||
|
||||
setPaused(paused: boolean): void {
|
||||
this._isPaused = paused;
|
||||
this.addUpdate({ type: GameUpdateType.GamePaused, paused });
|
||||
}
|
||||
|
||||
inSpawnPhase(): boolean {
|
||||
return this._ticks <= this.config().numSpawnPhaseTurns();
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export enum GameUpdateType {
|
||||
RailroadEvent,
|
||||
ConquestEvent,
|
||||
EmbargoEvent,
|
||||
GamePaused,
|
||||
}
|
||||
|
||||
export type GameUpdate =
|
||||
@@ -68,7 +69,8 @@ export type GameUpdate =
|
||||
| BonusEventUpdate
|
||||
| RailroadUpdate
|
||||
| ConquestUpdate
|
||||
| EmbargoUpdate;
|
||||
| EmbargoUpdate
|
||||
| GamePausedUpdate;
|
||||
|
||||
export interface BonusEventUpdate {
|
||||
type: GameUpdateType.BonusEvent;
|
||||
@@ -172,6 +174,7 @@ export interface PlayerUpdate {
|
||||
hasSpawned: boolean;
|
||||
betrayals: number;
|
||||
lastDeleteUnitTick: Tick;
|
||||
isLobbyCreator: boolean;
|
||||
}
|
||||
|
||||
export interface AllianceView {
|
||||
@@ -269,3 +272,8 @@ export interface EmbargoUpdate {
|
||||
playerID: number;
|
||||
embargoedID: number;
|
||||
}
|
||||
|
||||
export interface GamePausedUpdate {
|
||||
type: GameUpdateType.GamePaused;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
@@ -505,6 +505,10 @@ export class PlayerView {
|
||||
return this.smallID() === this.game.myPlayer()?.smallID();
|
||||
}
|
||||
|
||||
isLobbyCreator(): boolean {
|
||||
return this.data.isLobbyCreator;
|
||||
}
|
||||
|
||||
isAlliedWith(other: PlayerView): boolean {
|
||||
return this.data.allies.some((n) => other.smallID() === n);
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ export class PlayerImpl implements Player {
|
||||
hasSpawned: this.hasSpawned(),
|
||||
betrayals: this._betrayalCount,
|
||||
lastDeleteUnitTick: this.lastDeleteUnitTick,
|
||||
isLobbyCreator: this.isLobbyCreator(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -338,6 +339,11 @@ export class PlayerImpl implements Player {
|
||||
info(): PlayerInfo {
|
||||
return this.playerInfo;
|
||||
}
|
||||
|
||||
isLobbyCreator(): boolean {
|
||||
return this.playerInfo.isLobbyCreator;
|
||||
}
|
||||
|
||||
isAlive(): boolean {
|
||||
return this._tiles.size > 0;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ export class GameServer {
|
||||
private kickedClients: Set<ClientID> = new Set();
|
||||
private outOfSyncClients: Set<ClientID> = new Set();
|
||||
|
||||
private isPaused = false;
|
||||
|
||||
private websockets: Set<WebSocket> = new Set();
|
||||
|
||||
private winnerVotes: Map<
|
||||
@@ -346,8 +348,40 @@ export class GameServer {
|
||||
this.kickClient(clientMsg.intent.target);
|
||||
return;
|
||||
}
|
||||
case "toggle_pause": {
|
||||
// Only lobby creator can pause/resume
|
||||
if (client.clientID !== this.lobbyCreatorID) {
|
||||
this.log.warn(`Only lobby creator can toggle pause`, {
|
||||
clientID: client.clientID,
|
||||
creatorID: this.lobbyCreatorID,
|
||||
gameID: this.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientMsg.intent.paused) {
|
||||
// Pausing: send intent and complete current turn before pause takes effect
|
||||
this.addIntent(clientMsg.intent);
|
||||
this.endTurn();
|
||||
this.isPaused = true;
|
||||
} else {
|
||||
// Unpausing: clear pause flag before sending intent so next turn can execute
|
||||
this.isPaused = false;
|
||||
this.addIntent(clientMsg.intent);
|
||||
this.endTurn();
|
||||
}
|
||||
|
||||
this.log.info(`Game ${this.isPaused ? "paused" : "resumed"}`, {
|
||||
clientID: client.clientID,
|
||||
gameID: this.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
this.addIntent(clientMsg.intent);
|
||||
// Don't process intents while game is paused
|
||||
if (!this.isPaused) {
|
||||
this.addIntent(clientMsg.intent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -461,6 +495,7 @@ export class GameServer {
|
||||
username: c.username,
|
||||
clientID: c.clientID,
|
||||
cosmetics: c.cosmetics,
|
||||
isLobbyCreator: this.lobbyCreatorID === c.clientID,
|
||||
})),
|
||||
});
|
||||
if (!result.success) {
|
||||
@@ -488,6 +523,19 @@ export class GameServer {
|
||||
}
|
||||
|
||||
private sendStartGameMsg(ws: WebSocket, lastTurn: number) {
|
||||
// Find which client this websocket belongs to
|
||||
const client = this.activeClients.find((c) => c.ws === ws);
|
||||
if (!client) {
|
||||
this.log.warn("Could not find client for websocket in sendStartGameMsg");
|
||||
return;
|
||||
}
|
||||
|
||||
this.log.info(`Sending start message to client`, {
|
||||
clientID: client.clientID,
|
||||
lobbyCreatorID: this.lobbyCreatorID,
|
||||
isLobbyCreator: this.lobbyCreatorID === client.clientID,
|
||||
});
|
||||
|
||||
try {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -508,6 +556,11 @@ export class GameServer {
|
||||
}
|
||||
|
||||
private endTurn() {
|
||||
// Skip turn execution if game is paused
|
||||
if (this.isPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pastTurn: Turn = {
|
||||
turnNumber: this.turns.length,
|
||||
intents: this.intents,
|
||||
|
||||
Reference in New Issue
Block a user