diff --git a/resources/lang/en.json b/resources/lang/en.json index e45598c076..66bbda6ed2 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 5089b6be22..27c2f01cbd 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -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++; } } diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index cba4287393..100bbc3f98 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -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") { diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 8c94d215f7..15bba631ec 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -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) { diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index d73c473600..cc0f777e59 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -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`
- replay -
-
- play/pause -
`; - } else { - return html``; - } + const isReplayOrSingleplayer = + this._isSinglePlayer || this.game?.config()?.isReplay(); + const showPauseButton = isReplayOrSingleplayer || this.isLobbyCreator; + + return html` + ${isReplayOrSingleplayer + ? html` +
+ replay +
+ ` + : ""} + ${showPauseButton + ? html` +
+ play/pause +
+ ` + : ""} + `; } } diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts index 9ab20dae35..d9c224bea7 100644 --- a/src/client/graphics/layers/HeadsUpMessage.ts +++ b/src/client/graphics/layers/HeadsUpMessage.ts @@ -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`
e.preventDefault()} > - ${this.game.config().isRandomSpawn() - ? translateText("heads_up_message.random_spawn") - : translateText("heads_up_message.choose_spawn")} + ${message}
`; } diff --git a/src/client/graphics/layers/SettingsModal.ts b/src/client/graphics/layers/SettingsModal.ts index f2fecc5ffa..742caa298b 100644 --- a/src/client/graphics/layers/SettingsModal.ts +++ b/src/client/graphics/layers/SettingsModal.ts @@ -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() { diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index ed8c8cd7b2..92eba215b7 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -52,6 +52,7 @@ export async function createGameRunner( PlayerType.Human, p.clientID, random.nextID(), + p.isLobbyCreator ?? false, ); }); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 9370d6a477..27ceb7c053 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -47,7 +47,8 @@ export type Intent = | EmbargoAllIntent | UpgradeStructureIntent | DeleteUnitIntent - | KickPlayerIntent; + | KickPlayerIntent + | TogglePauseIntent; export type AttackIntent = z.infer; export type CancelAttackIntent = z.infer; @@ -79,6 +80,7 @@ export type AllianceExtensionIntent = z.infer< >; export type DeleteUnitIntent = z.infer; export type KickPlayerIntent = z.infer; +export type TogglePauseIntent = z.infer; export type Turn = z.infer; export type GameConfig = z.infer; @@ -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({ diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 5e39140119..e0625fddce 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -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`); } diff --git a/src/core/execution/PauseExecution.ts b/src/core/execution/PauseExecution.ts new file mode 100644 index 0000000000..4cc20eb634 --- /dev/null +++ b/src/core/execution/PauseExecution.ts @@ -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 {} +} diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 9c5ef95ffd..1dff9aee39 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -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[]; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 61a6e38d72..9100767092 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -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(); } diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index c558a33911..6cba024bcf 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -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; +} diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index a35416364e..2db92f91f5 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -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); } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 9dab88905d..e771012eca 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -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; } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 0f26df3688..428275f3c0 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -62,6 +62,8 @@ export class GameServer { private kickedClients: Set = new Set(); private outOfSyncClients: Set = new Set(); + private isPaused = false; + private websockets: Set = 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,