Add a Replay speed control feature (#1106)

## Description:
PR for https://github.com/openfrontio/OpenFrontIO/issues/1105
This pull request introduces a new replay-panel under options-menu to
control singleplayer and replay speed.

![image](https://github.com/user-attachments/assets/2f83b969-eff1-4a87-bdca-867f6a98e46b)

User can select 4 different speed multipliers based on the
turnIntervalMs config.

I locally tested the feature in singleplayer mode. I couldn't find a way
to test replay mode.
Tested with the pause button, working as you would expect.


Here is an example:

[replay-speed.webm](https://github.com/user-attachments/assets/7b3a7616-5f8f-4fbb-88ba-0b2414c6f2ea)


## 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
⚠️ I need help to test this feature in real conditions with a replayed
game in dev env.

- [x] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors

## Please put your Discord username so you can be contacted if a bug or
regression is found:

ghisloufou

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: evanpelle <evanpelle@gmail.com>
This commit is contained in:
Ghis
2025-06-10 22:40:20 +02:00
committed by GitHub
parent bf26557dac
commit 22e0de9526
10 changed files with 172 additions and 11 deletions
+3
View File
@@ -442,6 +442,9 @@
"none": "None",
"alliances": "Alliances"
},
"replay_panel": {
"replay_speed": "Replay speed"
},
"error_modal": {
"crashed": "Game crashed!",
"paste_discord": "Please paste the following in your bug report in Discord:",
+5
View File
@@ -1,6 +1,7 @@
import { EventBus, GameEvent } from "../core/EventBus";
import { UnitView } from "../core/game/GameView";
import { UserSettings } from "../core/game/UserSettings";
import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
export class MouseUpEvent implements GameEvent {
constructor(
@@ -82,6 +83,10 @@ export class AttackRatioEvent implements GameEvent {
constructor(public readonly attackRatio: number) {}
}
export class ReplaySpeedChangeEvent implements GameEvent {
constructor(public readonly replaySpeedMultiplier: ReplaySpeedMultiplier) {}
}
export class CenterCameraEvent implements GameEvent {
constructor() {}
}
+1
View File
@@ -186,6 +186,7 @@ export class LangSelector extends LitElement {
"game-starting-modal",
"top-bar",
"player-panel",
"replay-panel",
"help-modal",
"username-input",
"public-lobby",
+20 -10
View File
@@ -1,3 +1,4 @@
import { EventBus } from "../core/EventBus";
import {
AllPlayersStats,
ClientMessage,
@@ -12,7 +13,9 @@ import {
} from "../core/Schemas";
import { createGameRecord, decompressGameRecord, replacer } from "../core/Util";
import { LobbyConfig } from "./ClientGameRunner";
import { ReplaySpeedChangeEvent } from "./InputHandler";
import { getPersistentID } from "./Main";
import { defaultReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
export class LocalServer {
// All turns from the game record on replay.
@@ -24,6 +27,7 @@ export class LocalServer {
private startedAt: number;
private paused = false;
private replaySpeedMultiplier = defaultReplaySpeedMultiplier;
private winner: ClientSendWinnerMessage | null = null;
private allPlayersStats: AllPlayersStats = {};
@@ -38,23 +42,29 @@ export class LocalServer {
private clientConnect: () => void,
private clientMessage: (message: ServerMessage) => void,
private isReplay: boolean,
private eventBus: EventBus,
) {}
start() {
this.turnCheckInterval = setInterval(() => {
if (this.turnsExecuted === this.turns.length) {
if (
this.isReplay ||
Date.now() >
this.turnStartTime + this.lobbyConfig.serverConfig.turnIntervalMs()
) {
this.turnStartTime = Date.now();
// End turn on the server means the client will start processing the turn.
this.endTurn();
}
const turnIntervalMs =
this.lobbyConfig.serverConfig.turnIntervalMs() *
this.replaySpeedMultiplier;
if (
this.turnsExecuted === this.turns.length &&
Date.now() > this.turnStartTime + turnIntervalMs
) {
this.turnStartTime = Date.now();
// End turn on the server means the client will start processing the turn.
this.endTurn();
}
}, 5);
this.eventBus.on(ReplaySpeedChangeEvent, (event) => {
this.replaySpeedMultiplier = event.replaySpeedMultiplier;
});
this.startedAt = Date.now();
this.clientConnect();
if (this.lobbyConfig.gameRecord) {
+1
View File
@@ -266,6 +266,7 @@ export class Transport {
onconnect,
onmessage,
this.lobbyConfig.gameRecord !== undefined,
this.eventBus,
);
this.localServer.start();
}
+9
View File
@@ -21,6 +21,7 @@ import { OptionsMenu } from "./layers/OptionsMenu";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
import { PlayerTeamLabel } from "./layers/PlayerTeamLabel";
import { ReplayPanel } from "./layers/ReplayPanel";
import { SpawnTimer } from "./layers/SpawnTimer";
import { StructureLayer } from "./layers/StructureLayer";
import { TeamStats } from "./layers/TeamStats";
@@ -126,6 +127,13 @@ export function createRenderer(
optionsMenu.eventBus = eventBus;
optionsMenu.game = game;
const replayPanel = document.querySelector("replay-panel") as ReplayPanel;
if (!(replayPanel instanceof ReplayPanel)) {
console.error("ReplayPanel element not found in the DOM");
}
replayPanel.eventBus = eventBus;
replayPanel.game = game;
const topBar = document.querySelector("top-bar") as TopBar;
if (!(topBar instanceof TopBar)) {
console.error("top bar not found");
@@ -215,6 +223,7 @@ export function createRenderer(
playerInfo,
winModel,
optionsMenu,
replayPanel,
teamStats,
topBar,
playerPanel,
+1 -1
View File
@@ -1,4 +1,4 @@
import { LitElement, html } from "lit";
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameType } from "../../../core/game/Game";
+123
View File
@@ -0,0 +1,123 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { ReplaySpeedChangeEvent } from "../../InputHandler";
import {
defaultReplaySpeedMultiplier,
ReplaySpeedMultiplier,
} from "../../utilities/ReplaySpeedMultiplier";
import { translateText } from "../../Utils";
import { Layer } from "./Layer";
@customElement("replay-panel")
export class ReplayPanel extends LitElement implements Layer {
public game: GameView | undefined;
public eventBus: EventBus | undefined;
@state()
private _replaySpeedMultiplier: number = defaultReplaySpeedMultiplier;
@state()
private _isVisible = false;
init() {
if (this.game?.config().gameConfig().gameType === GameType.Singleplayer) {
this.setVisible(true);
}
}
tick() {
if (!this._isVisible && this.game?.config().isReplay()) {
this.setVisible(true);
}
this.requestUpdate();
}
onReplaySpeedChange(value: ReplaySpeedMultiplier) {
this._replaySpeedMultiplier = value;
this.eventBus?.emit(new ReplaySpeedChangeEvent(value));
}
renderLayer(context: CanvasRenderingContext2D) {
// Render any necessary canvas elements
}
shouldTransform(): boolean {
return false;
}
setVisible(visible: boolean) {
this._isVisible = visible;
this.requestUpdate();
}
render() {
if (!this._isVisible) {
return html``;
}
return html`
<div
class="bg-opacity-60 bg-gray-900 p-1 lg:p-2 rounded-es-sm lg:rounded-lg backdrop-blur-md"
@contextmenu=${(e) => e.preventDefault()}
>
<label class="block mb-1 text-white" translate="no">
${translateText("replay_panel.replay_speed")}
</label>
<div class="grid grid-cols-2 gap-1">
<button
class="text-white font-bold py-0 rounded border transition ${this
._replaySpeedMultiplier === ReplaySpeedMultiplier.slow
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => {
this.onReplaySpeedChange(ReplaySpeedMultiplier.slow);
}}
>
×0.5
</button>
<button
class="text-white font-bold py-0 rounded border transition ${this
._replaySpeedMultiplier === ReplaySpeedMultiplier.normal
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => {
this.onReplaySpeedChange(ReplaySpeedMultiplier.normal);
}}
>
×1
</button>
<button
class="text-white font-bold py-0 rounded border transition ${this
._replaySpeedMultiplier === ReplaySpeedMultiplier.fast
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => {
this.onReplaySpeedChange(ReplaySpeedMultiplier.fast);
}}
>
×2
</button>
<button
class="text-white font-bold py-0 rounded border transition ${this
._replaySpeedMultiplier === ReplaySpeedMultiplier.fastest
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => {
this.onReplaySpeedChange(ReplaySpeedMultiplier.fastest);
}}
>
max
</button>
</div>
</div>
`;
}
createRenderRoot() {
return this; // Disable shadow DOM to allow Tailwind styles
}
}
+1
View File
@@ -306,6 +306,7 @@
class="flex flex-column gap-2 fixed right-[10px] top-[10px] z-50 flex flex-col w-32 sm:w-32 lg:w-48"
>
<options-menu></options-menu>
<replay-panel></replay-panel>
<player-info-overlay></player-info-overlay>
</div>
<div
@@ -0,0 +1,8 @@
export enum ReplaySpeedMultiplier {
slow = 2,
normal = 1,
fast = 0.5,
fastest = 0,
}
export const defaultReplaySpeedMultiplier = ReplaySpeedMultiplier.normal;