mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 13:50:43 +00:00
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.  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:
@@ -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:",
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -266,6 +266,7 @@ export class Transport {
|
||||
onconnect,
|
||||
onmessage,
|
||||
this.lobbyConfig.gameRecord !== undefined,
|
||||
this.eventBus,
|
||||
);
|
||||
this.localServer.start();
|
||||
}
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user