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:
YoussfeCantCode
2025-12-26 10:21:18 -08:00
committed by GitHub
parent 7f3ca3c97e
commit 1c52d20e83
17 changed files with 243 additions and 59 deletions
+41 -22
View File
@@ -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>
`
: ""}
`;
}
}
+30 -8
View File
@@ -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>
`;
}
+2 -2
View File
@@ -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() {