From 5eaddc6c4c1dfd725fe9f017b6174c72def60ab9 Mon Sep 17 00:00:00 2001 From: Sardi Date: Mon, 18 May 2026 14:07:44 +0200 Subject: [PATCH] Added button to restart single-player games --- resources/lang/en.json | 2 + src/client/Main.ts | 82 +++++++++++++++++++ .../graphics/layers/GameRightSidebar.ts | 51 ++++++++++++ 3 files changed, 135 insertions(+) diff --git a/resources/lang/en.json b/resources/lang/en.json index 8a57a4cf2d..92d12166f4 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -188,6 +188,8 @@ "icon_request": "Envelope - Alliance request. This player has sent you an alliance request.", "info_enemy_panel": "Enemy info panel", "exit_confirmation": "Are you sure you want to exit the game?", + "restart_confirmation": "Are you sure you want to restart the game? Your current progress will be lost.", + "restart_game": "Restart Game", "bomb_direction": "Atom / Hydrogen bomb arc direction", "icon_alt_player_leaderboard": "Player Leaderboard Icon", "icon_alt_team_leaderboard": "Team Leaderboard Icon", diff --git a/src/client/Main.ts b/src/client/Main.ts index bd73b5f46c..2484db40c3 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -10,6 +10,7 @@ import { GameStartInfo, PublicGameInfo, } from "../core/Schemas"; +import { generateID } from "../core/Util"; import { GameEnv } from "../core/configuration/Config"; import { GameType } from "../core/game/Game"; import { @@ -83,6 +84,8 @@ import "./styles/layout/container.css"; import "./styles/layout/header.css"; import "./styles/modal/chat.css"; +const SINGLEPLAYER_RESTART_KEY = "openfront:restart-singleplayer"; + function updateAccountNavButton(userMeResponse: UserMeResponse | false) { const button = document.getElementById("nav-account-button"); if (!button) return; @@ -225,6 +228,7 @@ declare global { userMeResponse: CustomEvent; "leave-lobby": CustomEvent; "update-game-config": CustomEvent; + "restart-lobby": CustomEvent; } // Fixes the globalThis.addEventListener errors @@ -246,6 +250,7 @@ export interface JoinLobbyEvent { class Client { private lobbyHandle: JoinLobbyResult | null = null; + private lastSingleplayerLobby: JoinLobbyEvent | null = null; private eventBus: EventBus = new EventBus(); private currentUrl: string | null = null; @@ -375,6 +380,10 @@ class Client { document.addEventListener("join-lobby", this.handleJoinLobby.bind(this)); document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this)); + document.addEventListener( + "restart-lobby", + this.handleRestartLobby.bind(this), + ); document.addEventListener("kick-player", this.handleKickPlayer.bind(this)); document.addEventListener("start-game", this.handleStartGame.bind(this)); document.addEventListener( @@ -794,6 +803,8 @@ class Client { if (this.consumeRequeueUrl()) { document.dispatchEvent(new CustomEvent("open-matchmaking")); } + + this.consumeSingleplayerRestart(); } private consumeRequeueUrl(): boolean { @@ -814,6 +825,12 @@ class Client { private async handleJoinLobby(event: CustomEvent) { const lobby = event.detail; this.mostRecentJoinEvent = event.timeStamp; + if ( + lobby.gameStartInfo?.config.gameType === GameType.Singleplayer && + lobby.gameRecord === undefined + ) { + this.lastSingleplayerLobby = lobby; + } if (this.usernameInput && !this.usernameInput.validateOrShowError()) { return; } @@ -994,6 +1011,71 @@ class Client { crazyGamesSDK.gameplayStop(); } + private async handleRestartLobby() { + const previous = this.lastSingleplayerLobby; + if (previous?.gameStartInfo === undefined) { + console.warn("restart-lobby: no singleplayer game to restart"); + return; + } + + // The engine assumes a fresh page between games: terrain maps are cached + // module-side (loadTerrainMap) and keep the previous game's per-tile + // ownership, so an in-session restart crashes the new game's renderer. + // Persist the config and full-reload so the next game starts from a clean + // slate (restored in consumeSingleplayerRestart). + try { + sessionStorage.setItem( + SINGLEPLAYER_RESTART_KEY, + JSON.stringify(previous.gameStartInfo), + ); + } catch (e) { + console.warn("restart-lobby: failed to persist config", e); + return; + } + + await crazyGamesSDK.gameplayStop(); + window.location.href = "/"; + } + + private consumeSingleplayerRestart() { + let raw: string | null; + try { + raw = sessionStorage.getItem(SINGLEPLAYER_RESTART_KEY); + if (raw === null) return; + sessionStorage.removeItem(SINGLEPLAYER_RESTART_KEY); + } catch { + return; + } + + let gameStartInfo: GameStartInfo; + try { + gameStartInfo = JSON.parse(raw) as GameStartInfo; + } catch (e) { + console.warn("restart: failed to parse persisted config", e); + return; + } + + // Fresh game id/timestamp; reuse the original map/mode/settings/cheats. + const newGameID = generateID(); + const detail: JoinLobbyEvent = { + gameID: newGameID, + gameStartInfo: { + ...gameStartInfo, + gameID: newGameID, + lobbyCreatedAt: Date.now(), + }, + source: "singleplayer", + }; + + document.dispatchEvent( + new CustomEvent("join-lobby", { + detail, + bubbles: true, + composed: true, + }), + ); + } + private handleOpenMatchmaking(_event: CustomEvent) { this.matchmakingModal?.open(); } diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index 6770b0658e..5de1825f7e 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -1,3 +1,4 @@ +import { Howler } from "howler"; import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import { assetUrl } from "../../../core/AssetUrls"; @@ -20,6 +21,7 @@ const playIcon = assetUrl("images/PlayIconWhite.svg"); const settingsIcon = assetUrl("images/SettingIconWhite.svg"); const fullscreenIcon = assetUrl("images/FullscreenIconWhite.svg"); const exitFullscreenIcon = assetUrl("images/ExitFullscreenIconWhite.svg"); +const restartIcon = assetUrl("images/ReplayRegularIconWhite.svg"); @customElement("game-right-sidebar") export class GameRightSidebar extends LitElement implements Layer { @@ -29,6 +31,9 @@ export class GameRightSidebar extends LitElement implements Layer { @state() private _isSinglePlayer: boolean = false; + @state() + private _canRestart: boolean = false; + @state() private _isReplayVisible: boolean = false; @@ -57,6 +62,9 @@ export class GameRightSidebar extends LitElement implements Layer { this._isSinglePlayer = this.game?.config()?.gameConfig()?.gameType === GameType.Singleplayer || this.game.config().isReplay(); + this._canRestart = + this.game?.config()?.gameConfig()?.gameType === GameType.Singleplayer && + !this.game.config().isReplay(); this._isVisible = true; this.eventBus.on(SpawnBarVisibleEvent, (e) => { @@ -188,6 +196,35 @@ export class GameRightSidebar extends LitElement implements Layer { window.location.href = "/"; } + private resumeAudioContext() { + if (Howler.ctx?.state === "suspended") { + void Howler.ctx.resume(); + } + } + + private onRestartButtonClick() { + const isConfirmed = confirm( + translateText("help_modal.restart_confirmation"), + ); + if (!isConfirmed) { + // The native confirm() dialog asynchronously blurs the window, which + // makes Chrome suspend Howler's Web Audio context *after* confirm() + // returns (it is still "running" synchronously here). On confirm we + // reload so it doesn't matter, but on cancel we must resume it once the + // suspend has landed, or music and sound effects stay silent for the + // rest of the game. Resume on focus return (fast path) plus a timeout + // fallback in case the suspend lands after the focus event. + window.addEventListener("focus", () => this.resumeAudioContext(), { + once: true, + }); + setTimeout(() => this.resumeAudioContext(), 1000); + return; + } + document.dispatchEvent( + new CustomEvent("restart-lobby", { bubbles: true, composed: true }), + ); + } + private onSettingsButtonClick() { this.eventBus.emit( new ShowSettingsModalEvent(true, this._isSinglePlayer, this.isPaused), @@ -248,6 +285,20 @@ export class GameRightSidebar extends LitElement implements Layer { /> ` : ""} + ${this._canRestart + ? html`
+ ${translateText("help_modal.restart_game")} +
` + : ""}
exit