mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-26 12:12:44 +00:00
+187
-20
@@ -12,7 +12,7 @@ import {
|
||||
import { createPartialGameRecord, replacer } from "../core/Util";
|
||||
import { ServerConfig } from "../core/configuration/Config";
|
||||
import { getConfig } from "../core/configuration/ConfigLoader";
|
||||
import { PlayerActions, UnitType } from "../core/game/Game";
|
||||
import { GameUpdates, PlayerActions, UnitType } from "../core/game/Game";
|
||||
import { TileRef } from "../core/game/GameMap";
|
||||
import { GameMapLoader } from "../core/game/GameMapLoader";
|
||||
import {
|
||||
@@ -208,6 +208,21 @@ export class ClientGameRunner {
|
||||
private lastTickReceiveTime: number = 0;
|
||||
private currentTickDelay: number | undefined = undefined;
|
||||
|
||||
// Track how far behind the client simulation is compared to the server.
|
||||
private serverTurnHighWater: number = 0;
|
||||
private lastProcessedTick: number = 0;
|
||||
private backlogTurns: number = 0;
|
||||
private backlogGrowing: boolean = false;
|
||||
|
||||
private pendingUpdates: GameUpdateViewData[] = [];
|
||||
private isProcessingUpdates = false;
|
||||
|
||||
// Adaptive rendering when frames are heavy: render at most once every N frames.
|
||||
private renderEveryN: number = 1;
|
||||
private renderSkipCounter: number = 0;
|
||||
private lastFrameTime: number = 0;
|
||||
private readonly MAX_RENDER_EVERY_N = 5;
|
||||
|
||||
constructor(
|
||||
private lobby: LobbyConfig,
|
||||
private eventBus: EventBus,
|
||||
@@ -292,29 +307,36 @@ export class ClientGameRunner {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
this.transport.turnComplete();
|
||||
gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => {
|
||||
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
|
||||
});
|
||||
this.gameView.update(gu);
|
||||
this.renderer.tick();
|
||||
|
||||
// Emit tick metrics event for performance overlay
|
||||
this.eventBus.emit(
|
||||
new TickMetricsEvent(gu.tickExecutionDuration, this.currentTickDelay),
|
||||
);
|
||||
|
||||
// Reset tick delay for next measurement
|
||||
this.currentTickDelay = undefined;
|
||||
|
||||
if (gu.updates[GameUpdateType.Win].length > 0) {
|
||||
this.saveGame(gu.updates[GameUpdateType.Win][0]);
|
||||
this.pendingUpdates.push(gu);
|
||||
if (this.renderEveryN === 1) {
|
||||
this.processPendingUpdates();
|
||||
}
|
||||
});
|
||||
const worker = this.worker;
|
||||
const keepWorkerAlive = () => {
|
||||
if (this.isActive) {
|
||||
worker.sendHeartbeat();
|
||||
const now = performance.now();
|
||||
let frameDuration = 0;
|
||||
if (this.lastFrameTime !== 0) {
|
||||
frameDuration = now - this.lastFrameTime;
|
||||
}
|
||||
this.lastFrameTime = now;
|
||||
|
||||
// Decide whether to render (and thus process pending updates) this frame.
|
||||
let shouldRender = true;
|
||||
if (
|
||||
this.renderEveryN > 1 &&
|
||||
this.renderSkipCounter < this.renderEveryN - 1
|
||||
) {
|
||||
shouldRender = false;
|
||||
this.renderSkipCounter++;
|
||||
} else if (this.renderEveryN > 1) {
|
||||
this.renderSkipCounter = 0;
|
||||
}
|
||||
|
||||
if (shouldRender) {
|
||||
this.processPendingUpdates();
|
||||
}
|
||||
this.adaptRenderFrequency(frameDuration);
|
||||
requestAnimationFrame(keepWorkerAlive);
|
||||
}
|
||||
};
|
||||
@@ -363,6 +385,10 @@ export class ClientGameRunner {
|
||||
}
|
||||
|
||||
for (const turn of message.turns) {
|
||||
this.serverTurnHighWater = Math.max(
|
||||
this.serverTurnHighWater,
|
||||
turn.turnNumber,
|
||||
);
|
||||
if (turn.turnNumber < this.turnsSeen) {
|
||||
continue;
|
||||
}
|
||||
@@ -415,6 +441,11 @@ export class ClientGameRunner {
|
||||
}
|
||||
this.lastTickReceiveTime = now;
|
||||
|
||||
this.serverTurnHighWater = Math.max(
|
||||
this.serverTurnHighWater,
|
||||
message.turn.turnNumber,
|
||||
);
|
||||
|
||||
if (this.turnsSeen !== message.turn.turnNumber) {
|
||||
console.error(
|
||||
`got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`,
|
||||
@@ -445,6 +476,142 @@ export class ClientGameRunner {
|
||||
}
|
||||
}
|
||||
|
||||
private processPendingUpdates() {
|
||||
if (this.isProcessingUpdates) {
|
||||
return;
|
||||
}
|
||||
if (this.pendingUpdates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessingUpdates = true;
|
||||
const batch = this.pendingUpdates.splice(0);
|
||||
|
||||
let processedCount = 0;
|
||||
let lastTickDuration: number | undefined;
|
||||
let lastTick: number | undefined;
|
||||
|
||||
try {
|
||||
for (const gu of batch) {
|
||||
processedCount++;
|
||||
|
||||
this.transport.turnComplete();
|
||||
gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => {
|
||||
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
|
||||
});
|
||||
this.updateBacklogMetrics(gu.tick);
|
||||
|
||||
if (gu.updates[GameUpdateType.Win].length > 0) {
|
||||
this.saveGame(gu.updates[GameUpdateType.Win][0]);
|
||||
}
|
||||
|
||||
if (gu.tickExecutionDuration !== undefined) {
|
||||
lastTickDuration = gu.tickExecutionDuration;
|
||||
}
|
||||
lastTick = gu.tick;
|
||||
}
|
||||
} finally {
|
||||
this.isProcessingUpdates = false;
|
||||
}
|
||||
|
||||
if (processedCount > 0 && lastTick !== undefined) {
|
||||
const combinedGu = this.mergeGameUpdates(batch);
|
||||
if (combinedGu) {
|
||||
this.gameView.update(combinedGu);
|
||||
}
|
||||
|
||||
this.renderer.tick();
|
||||
this.eventBus.emit(
|
||||
new TickMetricsEvent(
|
||||
lastTickDuration,
|
||||
this.currentTickDelay,
|
||||
this.backlogTurns,
|
||||
this.renderEveryN,
|
||||
),
|
||||
);
|
||||
|
||||
// Reset tick delay for next measurement
|
||||
this.currentTickDelay = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private adaptRenderFrequency(frameDuration: number) {
|
||||
// Frameskip only matters while we have a backlog; otherwise stay at 1.
|
||||
if (this.backlogTurns === 0) {
|
||||
this.renderEveryN = 1;
|
||||
this.renderSkipCounter = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const HIGH_FRAME_MS = 25;
|
||||
const LOW_FRAME_MS = 18;
|
||||
|
||||
// Only throttle rendering if backlog is still growing; otherwise drift back toward 1.
|
||||
if (this.backlogGrowing && frameDuration > HIGH_FRAME_MS) {
|
||||
if (this.renderEveryN < this.MAX_RENDER_EVERY_N) {
|
||||
this.renderEveryN++;
|
||||
}
|
||||
} else if (
|
||||
!this.backlogGrowing &&
|
||||
frameDuration > 0 &&
|
||||
frameDuration < LOW_FRAME_MS
|
||||
) {
|
||||
if (this.renderEveryN > 1) {
|
||||
this.renderEveryN--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mergeGameUpdates(
|
||||
batch: GameUpdateViewData[],
|
||||
): GameUpdateViewData | null {
|
||||
if (batch.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const last = batch[batch.length - 1];
|
||||
const combinedUpdates: GameUpdates = {} as GameUpdates;
|
||||
|
||||
// Initialize combinedUpdates with empty arrays for each existing key
|
||||
for (const key in last.updates) {
|
||||
const type = Number(key) as GameUpdateType;
|
||||
combinedUpdates[type] = [];
|
||||
}
|
||||
|
||||
const combinedPackedTileUpdates: bigint[] = [];
|
||||
|
||||
for (const gu of batch) {
|
||||
for (const key in gu.updates) {
|
||||
const type = Number(key) as GameUpdateType;
|
||||
// We don't care about the specific update subtype here; just treat it
|
||||
// as an array we can concatenate.
|
||||
const updatesForType = gu.updates[type] as unknown as any[];
|
||||
(combinedUpdates[type] as unknown as any[]).push(...updatesForType);
|
||||
}
|
||||
gu.packedTileUpdates.forEach((tu) => {
|
||||
combinedPackedTileUpdates.push(tu);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tick: last.tick,
|
||||
updates: combinedUpdates,
|
||||
packedTileUpdates: new BigUint64Array(combinedPackedTileUpdates),
|
||||
playerNameViewData: last.playerNameViewData,
|
||||
tickExecutionDuration: last.tickExecutionDuration,
|
||||
};
|
||||
}
|
||||
|
||||
private updateBacklogMetrics(processedTick: number) {
|
||||
this.lastProcessedTick = processedTick;
|
||||
const previousBacklog = this.backlogTurns;
|
||||
this.backlogTurns = Math.max(
|
||||
0,
|
||||
this.serverTurnHighWater - this.lastProcessedTick,
|
||||
);
|
||||
this.backlogGrowing = this.backlogTurns > previousBacklog;
|
||||
}
|
||||
|
||||
private inputEvent(event: MouseUpEvent) {
|
||||
if (!this.isActive || this.renderer.uiState.ghostStructure !== null) {
|
||||
return;
|
||||
|
||||
@@ -129,6 +129,9 @@ export class TickMetricsEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly tickExecutionDuration?: number,
|
||||
public readonly tickDelay?: number,
|
||||
// Number of turns the client is behind the server (if known)
|
||||
public readonly backlogTurns?: number,
|
||||
public readonly renderEveryN?: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -229,7 +229,12 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
this.setVisible(this.userSettings.performanceOverlay());
|
||||
});
|
||||
this.eventBus.on(TickMetricsEvent, (event: TickMetricsEvent) => {
|
||||
this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay);
|
||||
this.updateTickMetrics(
|
||||
event.tickExecutionDuration,
|
||||
event.tickDelay,
|
||||
event.backlogTurns,
|
||||
event.renderEveryN,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -418,7 +423,18 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
this.layerBreakdown = breakdown;
|
||||
}
|
||||
|
||||
updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) {
|
||||
@state()
|
||||
private renderEveryN: number = 1;
|
||||
|
||||
@state()
|
||||
private backlogTurns: number = 0;
|
||||
|
||||
updateTickMetrics(
|
||||
tickExecutionDuration?: number,
|
||||
tickDelay?: number,
|
||||
backlogTurns?: number,
|
||||
renderEveryN?: number,
|
||||
) {
|
||||
if (!this.isVisible || !this.userSettings.performanceOverlay()) return;
|
||||
|
||||
// Update tick execution duration stats
|
||||
@@ -455,6 +471,13 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
if (backlogTurns !== undefined) {
|
||||
this.backlogTurns = backlogTurns;
|
||||
}
|
||||
if (renderEveryN !== undefined) {
|
||||
this.renderEveryN = renderEveryN;
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -600,6 +623,15 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
<span>${this.tickDelayAvg.toFixed(2)}ms</span>
|
||||
(max: <span>${this.tickDelayMax}ms</span>)
|
||||
</div>
|
||||
<div class="performance-line">
|
||||
Backlog turns:
|
||||
<span>${this.backlogTurns}</span>
|
||||
</div>
|
||||
${this.renderEveryN > 1
|
||||
? html`<div class="performance-line">
|
||||
Render every <span>${this.renderEveryN}</span> frame(s)
|
||||
</div>`
|
||||
: html``}
|
||||
${this.layerBreakdown.length
|
||||
? html`<div class="layers-section">
|
||||
<div class="performance-line">
|
||||
|
||||
@@ -268,4 +268,8 @@ export class GameRunner {
|
||||
}
|
||||
return player.bestTransportShipSpawn(targetTile);
|
||||
}
|
||||
|
||||
public hasPendingTurns(): boolean {
|
||||
return this.currTurn < this.turns.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
const ctx: Worker = self as any;
|
||||
let gameRunner: Promise<GameRunner> | null = null;
|
||||
const mapLoader = new FetchGameMapLoader(`/maps`, version);
|
||||
let isProcessingTurns = false;
|
||||
|
||||
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
|
||||
// skip if ErrorUpdate
|
||||
@@ -32,13 +33,33 @@ function sendMessage(message: WorkerMessage) {
|
||||
ctx.postMessage(message);
|
||||
}
|
||||
|
||||
async function processPendingTurns() {
|
||||
if (isProcessingTurns) {
|
||||
return;
|
||||
}
|
||||
if (!gameRunner) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gr = await gameRunner;
|
||||
if (!gr || !gr.hasPendingTurns()) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessingTurns = true;
|
||||
try {
|
||||
while (gr.hasPendingTurns()) {
|
||||
gr.executeNextTick();
|
||||
}
|
||||
} finally {
|
||||
isProcessingTurns = false;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
const message = e.data;
|
||||
|
||||
switch (message.type) {
|
||||
case "heartbeat":
|
||||
(await gameRunner)?.executeNextTick();
|
||||
break;
|
||||
case "init":
|
||||
try {
|
||||
gameRunner = createGameRunner(
|
||||
@@ -51,6 +72,7 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
type: "initialized",
|
||||
id: message.id,
|
||||
} as InitializedMessage);
|
||||
processPendingTurns();
|
||||
return gr;
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -67,6 +89,7 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
try {
|
||||
const gr = await gameRunner;
|
||||
await gr.addTurn(message.turn);
|
||||
processPendingTurns();
|
||||
} catch (error) {
|
||||
console.error("Failed to process turn:", error);
|
||||
throw error;
|
||||
|
||||
@@ -100,12 +100,6 @@ export class WorkerClient {
|
||||
});
|
||||
}
|
||||
|
||||
sendHeartbeat() {
|
||||
this.worker.postMessage({
|
||||
type: "heartbeat",
|
||||
});
|
||||
}
|
||||
|
||||
playerProfile(playerID: number): Promise<PlayerProfile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isInitialized) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { GameUpdateViewData } from "../game/GameUpdates";
|
||||
import { ClientID, GameStartInfo, Turn } from "../Schemas";
|
||||
|
||||
export type WorkerMessageType =
|
||||
| "heartbeat"
|
||||
| "init"
|
||||
| "initialized"
|
||||
| "turn"
|
||||
@@ -31,10 +30,6 @@ interface BaseWorkerMessage {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface HeartbeatMessage extends BaseWorkerMessage {
|
||||
type: "heartbeat";
|
||||
}
|
||||
|
||||
// Messages from main thread to worker
|
||||
export interface InitMessage extends BaseWorkerMessage {
|
||||
type: "init";
|
||||
@@ -114,7 +109,6 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
|
||||
|
||||
// Union types for type safety
|
||||
export type MainThreadMessage =
|
||||
| HeartbeatMessage
|
||||
| InitMessage
|
||||
| TurnMessage
|
||||
| PlayerActionsMessage
|
||||
|
||||
Reference in New Issue
Block a user