Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
82 changes: 82 additions & 0 deletions src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -225,6 +228,7 @@ declare global {
userMeResponse: CustomEvent<UserMeResponse | false>;
"leave-lobby": CustomEvent;
"update-game-config": CustomEvent;
"restart-lobby": CustomEvent;
}

// Fixes the globalThis.addEventListener errors
Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -794,6 +803,8 @@ class Client {
if (this.consumeRequeueUrl()) {
document.dispatchEvent(new CustomEvent("open-matchmaking"));
}

this.consumeSingleplayerRestart();
}

private consumeRequeueUrl(): boolean {
Expand All @@ -814,6 +825,12 @@ class Client {
private async handleJoinLobby(event: CustomEvent<JoinLobbyEvent>) {
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;
}
Expand Down Expand Up @@ -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<undefined>) {
this.matchmakingModal?.open();
}
Expand Down
51 changes: 51 additions & 0 deletions src/client/graphics/layers/GameRightSidebar.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand All @@ -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;

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -248,6 +285,20 @@ export class GameRightSidebar extends LitElement implements Layer {
/>
</div>`
: ""}
${this._canRestart
? html`<div
class="cursor-pointer"
@click=${this.onRestartButtonClick}
title=${translateText("help_modal.restart_game")}
>
<img
src=${restartIcon}
alt=${translateText("help_modal.restart_game")}
width="20"
height="20"
/>
</div>`
: ""}

<div class="cursor-pointer" @click=${this.onExitButtonClick}>
<img src=${exitIcon} alt="exit" width="20" height="20" />
Expand Down
Loading