This commit is contained in:
evanpelle
2025-12-02 14:01:55 -08:00
parent 997cfea730
commit 09e88f0376
12 changed files with 772 additions and 57 deletions
+337 -27
View File
@@ -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 {
@@ -25,9 +25,18 @@ import {
import { GameView, PlayerView } from "../core/game/GameView";
import { loadTerrainMap, TerrainMapData } from "../core/game/TerrainMapLoader";
import { UserSettings } from "../core/game/UserSettings";
import {
createSharedTileRingBuffers,
createSharedTileRingViews,
drainTileUpdates,
SharedTileRingBuffers,
SharedTileRingViews,
TILE_RING_HEADER_OVERFLOW,
} from "../core/worker/SharedTileRing";
import { WorkerClient } from "../core/worker/WorkerClient";
import {
AutoUpgradeEvent,
BacklogStatusEvent,
DoBoatAttackEvent,
DoGroundAttackEvent,
InputHandler,
@@ -171,9 +180,44 @@ async function createClientGame(
mapLoader,
);
}
let sharedTileRingBuffers: SharedTileRingBuffers | undefined;
let sharedTileRingViews: SharedTileRingViews | null = null;
let sharedDirtyBuffer: SharedArrayBuffer | undefined;
let sharedDirtyFlags: Uint8Array | null = null;
const isIsolated =
typeof (globalThis as any).crossOriginIsolated === "boolean"
? (globalThis as any).crossOriginIsolated === true
: false;
const canUseSharedBuffers =
typeof SharedArrayBuffer !== "undefined" &&
typeof Atomics !== "undefined" &&
isIsolated;
const sharedStateBuffer =
canUseSharedBuffers && gameMap.sharedStateBuffer
? gameMap.sharedStateBuffer
: undefined;
const usesSharedTileState = !!sharedStateBuffer;
if (canUseSharedBuffers) {
const numTiles = gameMap.gameMap.width() * gameMap.gameMap.height();
// Ring capacity scales with world size: at most one entry per tile.
const TILE_RING_CAPACITY = numTiles;
sharedTileRingBuffers = createSharedTileRingBuffers(
TILE_RING_CAPACITY,
numTiles,
);
sharedTileRingViews = createSharedTileRingViews(sharedTileRingBuffers);
sharedDirtyBuffer = sharedTileRingBuffers.dirty;
sharedDirtyFlags = sharedTileRingViews.dirtyFlags;
}
const worker = new WorkerClient(
lobbyConfig.gameStartInfo,
lobbyConfig.clientID,
sharedTileRingBuffers,
sharedStateBuffer,
sharedDirtyBuffer,
);
await worker.initialize();
const gameView = new GameView(
@@ -183,6 +227,7 @@ async function createClientGame(
lobbyConfig.clientID,
lobbyConfig.gameStartInfo.gameID,
lobbyConfig.gameStartInfo.players,
usesSharedTileState,
);
const canvas = createCanvas();
@@ -200,6 +245,8 @@ async function createClientGame(
transport,
worker,
gameView,
sharedTileRingViews,
sharedDirtyFlags,
);
}
@@ -218,6 +265,22 @@ 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 lastRenderedTick: number = 0;
private workerTicksSinceSample: number = 0;
private renderTicksSinceSample: number = 0;
private metricsSampleStart: number = 0;
private pendingUpdates: GameUpdateViewData[] = [];
private pendingStart = 0;
private isProcessingUpdates = false;
private tileRingViews: SharedTileRingViews | null;
private dirtyFlags: Uint8Array | null;
constructor(
private lobby: LobbyConfig,
private eventBus: EventBus,
@@ -226,8 +289,12 @@ export class ClientGameRunner {
private transport: Transport,
private worker: WorkerClient,
private gameView: GameView,
tileRingViews: SharedTileRingViews | null,
dirtyFlags: Uint8Array | null,
) {
this.lastMessageTime = Date.now();
this.tileRingViews = tileRingViews;
this.dirtyFlags = dirtyFlags;
}
private saveGame(update: WinUpdate) {
@@ -302,33 +369,9 @@ 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);
this.processPendingUpdates();
});
const worker = this.worker;
const keepWorkerAlive = () => {
if (this.isActive) {
worker.sendHeartbeat();
requestAnimationFrame(keepWorkerAlive);
}
};
requestAnimationFrame(keepWorkerAlive);
const onconnect = () => {
console.log("Connected to game server!");
@@ -373,6 +416,10 @@ export class ClientGameRunner {
}
for (const turn of message.turns) {
this.serverTurnHighWater = Math.max(
this.serverTurnHighWater,
turn.turnNumber,
);
if (turn.turnNumber < this.turnsSeen) {
continue;
}
@@ -425,6 +472,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}`,
@@ -455,6 +507,264 @@ export class ClientGameRunner {
}
}
private processPendingUpdates() {
const pendingCount = this.pendingUpdates.length - this.pendingStart;
if (this.isProcessingUpdates || pendingCount <= 0) {
return;
}
this.isProcessingUpdates = true;
const processFrame = () => {
const BASE_SLICE_BUDGET_MS = 8; // keep UI responsive while catching up
const MAX_SLICE_BUDGET_MS = 1000; // allow longer slices when backlog is large
const BACKLOG_FREE_TURNS = 10; // scaling starts at this many turns
const BACKLOG_MAX_TURNS = 500; // MAX_SLICE_BUDGET_MS is reached at this many turns
const MAX_TICKS_PER_SLICE = 1000;
const backlogOverhead = Math.max(
0,
this.backlogTurns - BACKLOG_FREE_TURNS,
);
const backlogScale = Math.min(
1,
backlogOverhead / (BACKLOG_MAX_TURNS - BACKLOG_FREE_TURNS),
);
const sliceBudgetMs =
BASE_SLICE_BUDGET_MS +
backlogScale * (MAX_SLICE_BUDGET_MS - BASE_SLICE_BUDGET_MS);
const frameStart = performance.now();
const batch: GameUpdateViewData[] = [];
let lastTickDuration: number | undefined;
let lastTick: number | undefined;
let processedCount = 0;
// Consume updates until we hit the time budget or per-slice cap.
while (this.pendingStart < this.pendingUpdates.length) {
const gu = this.pendingUpdates[this.pendingStart++];
processedCount++;
this.workerTicksSinceSample++;
batch.push(gu);
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;
const elapsed = performance.now() - frameStart;
if (processedCount >= MAX_TICKS_PER_SLICE || elapsed >= sliceBudgetMs) {
break;
}
}
// Compact the queue if we've advanced far into it.
if (
this.pendingStart > 0 &&
(this.pendingStart > 1024 ||
this.pendingStart >= this.pendingUpdates.length / 2)
) {
this.pendingUpdates = this.pendingUpdates.slice(this.pendingStart);
this.pendingStart = 0;
}
// Only update view and render when ALL processing is complete
if (
this.pendingStart >= this.pendingUpdates.length &&
batch.length > 0 &&
lastTick !== undefined
) {
const { gameUpdate: combinedGu, tileMetrics } =
this.mergeGameUpdates(batch);
if (combinedGu) {
this.gameView.update(combinedGu);
}
const ticksPerRender =
this.lastRenderedTick === 0
? lastTick
: lastTick - this.lastRenderedTick;
this.lastRenderedTick = lastTick;
this.renderTicksSinceSample++;
let workerTicksPerSecond: number | undefined;
let renderTicksPerSecond: number | undefined;
const now = performance.now();
if (this.metricsSampleStart === 0) {
this.metricsSampleStart = now;
} else {
const elapsedSeconds = (now - this.metricsSampleStart) / 1000;
if (elapsedSeconds > 0) {
workerTicksPerSecond = this.workerTicksSinceSample / elapsedSeconds;
renderTicksPerSecond = this.renderTicksSinceSample / elapsedSeconds;
}
this.metricsSampleStart = now;
this.workerTicksSinceSample = 0;
this.renderTicksSinceSample = 0;
}
this.renderer.tick();
this.eventBus.emit(
new TickMetricsEvent(
lastTickDuration,
this.currentTickDelay,
this.backlogTurns,
ticksPerRender,
workerTicksPerSecond,
renderTicksPerSecond,
tileMetrics.count,
tileMetrics.utilization,
tileMetrics.overflow,
tileMetrics.drainTime,
),
);
// Reset tick delay for next measurement
this.currentTickDelay = undefined;
}
if (this.pendingStart < this.pendingUpdates.length) {
requestAnimationFrame(processFrame);
} else {
this.isProcessingUpdates = false;
}
};
requestAnimationFrame(processFrame);
}
private mergeGameUpdates(batch: GameUpdateViewData[]): {
gameUpdate: GameUpdateViewData | null;
tileMetrics: {
count: number;
utilization: number;
overflow: number;
drainTime: number;
};
} {
if (batch.length === 0) {
return {
gameUpdate: null,
tileMetrics: {
count: 0,
utilization: 0,
overflow: 0,
drainTime: 0,
},
};
}
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);
}
}
let tileMetrics = {
count: 0,
utilization: 0,
overflow: 0,
drainTime: 0,
};
if (this.tileRingViews) {
const MAX_TILE_UPDATES_PER_RENDER = 100000;
const tileRefs: TileRef[] = [];
const drainStart = performance.now();
drainTileUpdates(
this.tileRingViews,
MAX_TILE_UPDATES_PER_RENDER,
tileRefs,
);
const drainTime = performance.now() - drainStart;
// Deduplicate tile refs for this render slice
const uniqueTiles = new Set<TileRef>();
for (const ref of tileRefs) {
uniqueTiles.add(ref);
}
// Calculate ring buffer utilization and overflow using dynamic capacity
const TILE_RING_CAPACITY = this.tileRingViews.capacity;
const utilization = (uniqueTiles.size / TILE_RING_CAPACITY) * 100;
const overflow = Atomics.load(
this.tileRingViews.header,
TILE_RING_HEADER_OVERFLOW,
);
tileMetrics = {
count: uniqueTiles.size,
utilization,
overflow,
drainTime,
};
for (const ref of uniqueTiles) {
if (this.dirtyFlags) {
Atomics.store(this.dirtyFlags, ref, 0);
}
combinedPackedTileUpdates.push(BigInt(ref));
}
} else {
// Non-SAB mode: count tile updates from batch
let totalTileUpdates = 0;
for (const gu of batch) {
totalTileUpdates += gu.packedTileUpdates.length;
}
tileMetrics.count = totalTileUpdates;
}
return {
gameUpdate: {
tick: last.tick,
updates: combinedUpdates,
packedTileUpdates: new BigUint64Array(combinedPackedTileUpdates),
playerNameViewData: last.playerNameViewData,
tickExecutionDuration: last.tickExecutionDuration,
},
tileMetrics,
};
}
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;
this.eventBus.emit(
new BacklogStatusEvent(this.backlogTurns, this.backlogGrowing),
);
}
private inputEvent(event: MouseUpEvent) {
if (!this.isActive || this.renderer.uiState.ghostStructure !== null) {
return;
+20
View File
@@ -129,6 +129,26 @@ 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,
// Number of simulation ticks applied since last render
public readonly ticksPerRender?: number,
// Approximate worker simulation ticks per second
public readonly workerTicksPerSecond?: number,
// Approximate render tick() calls per second
public readonly renderTicksPerSecond?: number,
// Tile update metrics
public readonly tileUpdatesCount?: number,
public readonly ringBufferUtilization?: number,
public readonly ringBufferOverflows?: number,
public readonly ringDrainTime?: number,
) {}
}
export class BacklogStatusEvent implements GameEvent {
constructor(
public readonly backlogTurns: number,
public readonly backlogGrowing: boolean,
) {}
}
+33 -1
View File
@@ -2,7 +2,10 @@ import { EventBus } from "../../core/EventBus";
import { GameView } from "../../core/game/GameView";
import { UserSettings } from "../../core/game/UserSettings";
import { GameStartingModal } from "../GameStartingModal";
import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
import {
BacklogStatusEvent,
RefreshGraphicsEvent as RedrawGraphicsEvent,
} from "../InputHandler";
import { FrameProfiler } from "./FrameProfiler";
import { TransformHandler } from "./TransformHandler";
import { UIState } from "./UIState";
@@ -292,6 +295,9 @@ export function createRenderer(
export class GameRenderer {
private context: CanvasRenderingContext2D;
private backlogTurns: number = 0;
private backlogGrowing: boolean = false;
private lastRenderTime: number = 0;
constructor(
private game: GameView,
@@ -309,6 +315,10 @@ export class GameRenderer {
initialize() {
this.eventBus.on(RedrawGraphicsEvent, () => this.redraw());
this.eventBus.on(BacklogStatusEvent, (event: BacklogStatusEvent) => {
this.backlogTurns = event.backlogTurns;
this.backlogGrowing = event.backlogGrowing;
});
this.layers.forEach((l) => l.init?.());
document.body.appendChild(this.canvas);
@@ -344,6 +354,28 @@ export class GameRenderer {
}
renderGame() {
const now = performance.now();
if (this.backlogTurns > 0) {
const BASE_FPS = 60;
const MIN_FPS = 10;
const BACKLOG_MAX_TURNS = 50;
const scale = Math.min(1, this.backlogTurns / BACKLOG_MAX_TURNS);
const targetFps = BASE_FPS - scale * (BASE_FPS - MIN_FPS);
const minFrameInterval = 1000 / targetFps;
if (this.lastRenderTime !== 0) {
const sinceLast = now - this.lastRenderTime;
if (sinceLast < minFrameInterval) {
requestAnimationFrame(() => this.renderGame());
return;
}
}
}
this.lastRenderTime = now;
FrameProfiler.clear();
const start = performance.now();
// Set background
@@ -229,7 +229,18 @@ 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.ticksPerRender,
event.workerTicksPerSecond,
event.renderTicksPerSecond,
event.tileUpdatesCount,
event.ringBufferUtilization,
event.ringBufferOverflows,
event.ringDrainTime,
);
});
}
@@ -312,6 +323,14 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.layerStats.clear();
this.layerBreakdown = [];
// reset tile metrics
this.tileUpdatesPerRender = 0;
this.tileUpdatesPeak = 0;
this.ringBufferUtilization = 0;
this.ringBufferOverflows = 0;
this.ringDrainTime = 0;
this.totalTilesUpdated = 0;
this.requestUpdate();
};
@@ -418,7 +437,48 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.layerBreakdown = breakdown;
}
updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) {
@state()
private backlogTurns: number = 0;
@state()
private ticksPerRender: number = 0;
@state()
private workerTicksPerSecond: number = 0;
@state()
private renderTicksPerSecond: number = 0;
@state()
private tileUpdatesPerRender: number = 0;
@state()
private tileUpdatesPeak: number = 0;
@state()
private ringBufferUtilization: number = 0;
@state()
private ringBufferOverflows: number = 0;
@state()
private ringDrainTime: number = 0;
@state()
private totalTilesUpdated: number = 0;
updateTickMetrics(
tickExecutionDuration?: number,
tickDelay?: number,
backlogTurns?: number,
ticksPerRender?: number,
workerTicksPerSecond?: number,
renderTicksPerSecond?: number,
tileUpdatesCount?: number,
ringBufferUtilization?: number,
ringBufferOverflows?: number,
ringDrainTime?: number,
) {
if (!this.isVisible || !this.userSettings.performanceOverlay()) return;
// Update tick execution duration stats
@@ -455,6 +515,42 @@ export class PerformanceOverlay extends LitElement implements Layer {
}
}
if (backlogTurns !== undefined) {
this.backlogTurns = backlogTurns;
}
if (ticksPerRender !== undefined) {
this.ticksPerRender = ticksPerRender;
}
if (workerTicksPerSecond !== undefined) {
this.workerTicksPerSecond = workerTicksPerSecond;
}
if (renderTicksPerSecond !== undefined) {
this.renderTicksPerSecond = renderTicksPerSecond;
}
if (tileUpdatesCount !== undefined) {
this.tileUpdatesPerRender = tileUpdatesCount;
this.tileUpdatesPeak = Math.max(this.tileUpdatesPeak, tileUpdatesCount);
this.totalTilesUpdated += tileUpdatesCount;
}
if (ringBufferUtilization !== undefined) {
this.ringBufferUtilization =
Math.round(ringBufferUtilization * 100) / 100;
}
if (ringBufferOverflows !== undefined && ringBufferOverflows !== 0) {
// Remember that an overflow has occurred at least once this run.
this.ringBufferOverflows = 1;
}
if (ringDrainTime !== undefined) {
this.ringDrainTime = Math.round(ringDrainTime * 100) / 100;
}
this.requestUpdate();
}
@@ -485,6 +581,14 @@ export class PerformanceOverlay extends LitElement implements Layer {
executionSamples: [...this.tickExecutionTimes],
delaySamples: [...this.tickDelayTimes],
},
tiles: {
updatesPerRender: this.tileUpdatesPerRender,
peakUpdates: this.tileUpdatesPeak,
ringBufferUtilization: this.ringBufferUtilization,
ringBufferOverflows: this.ringBufferOverflows,
ringDrainTimeMs: this.ringDrainTime,
totalTilesUpdated: this.totalTilesUpdated,
},
layers: this.layerBreakdown.map((layer) => ({ ...layer })),
};
}
@@ -600,6 +704,37 @@ 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">
Worker ticks/s:
<span>${this.workerTicksPerSecond.toFixed(1)}</span>
</div>
<div class="performance-line">
Render ticks/s:
<span>${this.renderTicksPerSecond.toFixed(1)}</span>
</div>
<div class="performance-line">
Ticks per render:
<span>${this.ticksPerRender}</span>
</div>
<div class="performance-line">
Backlog turns:
<span>${this.backlogTurns}</span>
</div>
<div class="performance-line">
Tile updates/render:
<span>${this.tileUpdatesPerRender}</span>
(peak: <span>${this.tileUpdatesPeak}</span>)
</div>
<div class="performance-line">
Ring buffer:
<span>${this.ringBufferUtilization}%</span>
(${this.totalTilesUpdated} total, ${this.ringBufferOverflows}
overflows)
</div>
<div class="performance-line">
Ring drain time:
<span>${this.ringDrainTime.toFixed(2)}ms</span>
</div>
${this.layerBreakdown.length
? html`<div class="layers-section">
<div class="performance-line">
+24 -3
View File
@@ -37,12 +37,15 @@ export async function createGameRunner(
clientID: ClientID,
mapLoader: GameMapLoader,
callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
tileUpdateSink?: (tile: TileRef) => void,
sharedStateBuffer?: SharedArrayBuffer,
): Promise<GameRunner> {
const config = await getConfig(gameStart.config, null);
const gameMap = await loadGameMap(
gameStart.config.gameMap,
gameStart.config.gameMapSize,
mapLoader,
sharedStateBuffer,
);
const random = new PseudoRandom(simpleHash(gameStart.gameID));
@@ -85,6 +88,7 @@ export async function createGameRunner(
game,
new Executor(game, gameStart.gameID, clientID),
callBack,
tileUpdateSink,
);
gr.init();
return gr;
@@ -101,6 +105,7 @@ export class GameRunner {
public game: Game,
private execManager: Executor,
private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
private tileUpdateSink?: (tile: TileRef) => void,
) {}
init() {
@@ -175,13 +180,25 @@ export class GameRunner {
});
}
// Many tiles are updated to pack it into an array
const packedTileUpdates = updates[GameUpdateType.Tile].map((u) => u.update);
// Many tiles are updated; either publish them via a shared sink or pack
// them into the view data.
let packedTileUpdates: BigUint64Array;
const tileUpdates = updates[GameUpdateType.Tile];
if (this.tileUpdateSink !== undefined) {
for (const u of tileUpdates) {
const tileRef = Number(u.update >> 16n) as TileRef;
this.tileUpdateSink(tileRef);
}
packedTileUpdates = new BigUint64Array();
} else {
const raw = tileUpdates.map((u) => u.update);
packedTileUpdates = new BigUint64Array(raw);
}
updates[GameUpdateType.Tile] = [];
this.callBack({
tick: this.game.ticks(),
packedTileUpdates: new BigUint64Array(packedTileUpdates),
packedTileUpdates,
updates: updates,
playerNameViewData: this.playerViewData,
tickExecutionDuration: tickExecutionDuration,
@@ -272,4 +289,8 @@ export class GameRunner {
}
return player.bestTransportShipSpawn(targetTile);
}
public hasPendingTurns(): boolean {
return this.currTurn < this.turns.length;
}
}
+12 -1
View File
@@ -80,6 +80,7 @@ export class GameMapImpl implements GameMap {
height: number,
terrainData: Uint8Array,
private numLandTiles_: number,
stateBuffer?: ArrayBufferLike,
) {
if (terrainData.length !== width * height) {
throw new Error(
@@ -89,7 +90,17 @@ export class GameMapImpl implements GameMap {
this.width_ = width;
this.height_ = height;
this.terrain = terrainData;
this.state = new Uint16Array(width * height);
if (stateBuffer !== undefined) {
const state = new Uint16Array(stateBuffer);
if (state.length !== width * height) {
throw new Error(
`State buffer length ${state.length} doesn't match dimensions ${width}x${height}`,
);
}
this.state = state;
} else {
this.state = new Uint16Array(width * height);
}
// Precompute the LUTs
let ref = 0;
this.refToX = new Array(width * height);
+11 -3
View File
@@ -482,6 +482,7 @@ export class GameView implements GameMap {
private _myClientID: ClientID,
private _gameID: GameID,
private humans: Player[],
private usesSharedTileState: boolean = false,
) {
this._map = this._mapData.gameMap;
this.lastUpdate = null;
@@ -512,9 +513,16 @@ export class GameView implements GameMap {
this.lastUpdate = gu;
this.updatedTiles = [];
this.lastUpdate.packedTileUpdates.forEach((tu) => {
this.updatedTiles.push(this.updateTile(tu));
});
if (this.usesSharedTileState) {
this.lastUpdate.packedTileUpdates.forEach((tu) => {
const tileRef = Number(tu);
this.updatedTiles.push(tileRef);
});
} else {
this.lastUpdate.packedTileUpdates.forEach((tu) => {
this.updatedTiles.push(this.updateTile(tu));
});
}
if (gu.updates === null) {
throw new Error("lastUpdate.updates not initialized");
+45 -5
View File
@@ -6,6 +6,8 @@ export type TerrainMapData = {
nations: Nation[];
gameMap: GameMap;
miniGameMap: GameMap;
sharedStateBuffer?: SharedArrayBuffer;
sharedDirtyBuffer?: SharedArrayBuffer;
};
const loadedMaps = new Map<GameMapType, TerrainMapData>();
@@ -35,15 +37,42 @@ export async function loadTerrainMap(
map: GameMapType,
mapSize: GameMapSize,
terrainMapFileLoader: GameMapLoader,
sharedStateBuffer?: SharedArrayBuffer,
): Promise<TerrainMapData> {
const cached = loadedMaps.get(map);
if (cached !== undefined) return cached;
const useCache = sharedStateBuffer === undefined;
const canUseSharedBuffers =
typeof SharedArrayBuffer !== "undefined" &&
typeof Atomics !== "undefined" &&
typeof (globalThis as any).crossOriginIsolated === "boolean" &&
(globalThis as any).crossOriginIsolated === true;
// Don't use cache if we can create SharedArrayBuffer but none was provided
const shouldUseCache = useCache && !canUseSharedBuffers;
if (shouldUseCache) {
const cached = loadedMaps.get(map);
if (cached !== undefined) return cached;
}
const mapFiles = terrainMapFileLoader.getMapData(map);
const manifest = await mapFiles.manifest();
const stateBuffer =
sharedStateBuffer ??
(canUseSharedBuffers
? new SharedArrayBuffer(
manifest.map.width *
manifest.map.height *
Uint16Array.BYTES_PER_ELEMENT,
)
: undefined);
const gameMap =
mapSize === GameMapSize.Normal
? await genTerrainFromBin(manifest.map, await mapFiles.mapBin())
? await genTerrainFromBin(
manifest.map,
await mapFiles.mapBin(),
stateBuffer,
)
: await genTerrainFromBin(manifest.map4x, await mapFiles.map4xBin());
const miniMap =
@@ -63,18 +92,28 @@ export async function loadTerrainMap(
});
}
const result = {
const result: TerrainMapData = {
nations: manifest.nations,
gameMap: gameMap,
miniGameMap: miniMap,
sharedStateBuffer:
typeof SharedArrayBuffer !== "undefined" &&
stateBuffer instanceof SharedArrayBuffer
? stateBuffer
: undefined,
sharedDirtyBuffer: undefined, // populated by consumer when needed
};
loadedMaps.set(map, result);
// Only cache the result when caching is actually used (non-SAB path)
if (shouldUseCache) {
loadedMaps.set(map, result);
}
return result;
}
export async function genTerrainFromBin(
mapData: MapMetadata,
data: Uint8Array,
stateBuffer?: ArrayBufferLike,
): Promise<GameMap> {
if (data.length !== mapData.width * mapData.height) {
throw new Error(
@@ -87,5 +126,6 @@ export async function genTerrainFromBin(
mapData.height,
data,
mapData.num_land_tiles,
stateBuffer,
);
}
+85
View File
@@ -0,0 +1,85 @@
import { TileRef } from "../game/GameMap";
export interface SharedTileRingBuffers {
header: SharedArrayBuffer;
data: SharedArrayBuffer;
dirty: SharedArrayBuffer;
}
export interface SharedTileRingViews {
header: Int32Array;
buffer: Uint32Array;
dirtyFlags: Uint8Array;
capacity: number;
}
// Header indices
export const TILE_RING_HEADER_WRITE_INDEX = 0;
export const TILE_RING_HEADER_READ_INDEX = 1;
export const TILE_RING_HEADER_OVERFLOW = 2;
export function createSharedTileRingBuffers(
capacity: number,
numTiles: number,
): SharedTileRingBuffers {
const header = new SharedArrayBuffer(3 * Int32Array.BYTES_PER_ELEMENT);
const data = new SharedArrayBuffer(capacity * Uint32Array.BYTES_PER_ELEMENT);
const dirty = new SharedArrayBuffer(numTiles * Uint8Array.BYTES_PER_ELEMENT);
return { header, data, dirty };
}
export function createSharedTileRingViews(
buffers: SharedTileRingBuffers,
): SharedTileRingViews {
const header = new Int32Array(buffers.header);
const buffer = new Uint32Array(buffers.data);
const dirtyFlags = new Uint8Array(buffers.dirty);
return {
header,
buffer,
dirtyFlags,
capacity: buffer.length,
};
}
export function pushTileUpdate(
views: SharedTileRingViews,
value: TileRef,
): void {
const { header, buffer, capacity } = views;
const write = Atomics.load(header, TILE_RING_HEADER_WRITE_INDEX);
const read = Atomics.load(header, TILE_RING_HEADER_READ_INDEX);
const nextWrite = (write + 1) % capacity;
// If the buffer is full, advance read (drop oldest) and mark overflow.
if (nextWrite === read) {
Atomics.store(header, TILE_RING_HEADER_OVERFLOW, 1);
const nextRead = (read + 1) % capacity;
Atomics.store(header, TILE_RING_HEADER_READ_INDEX, nextRead);
}
buffer[write] = value;
Atomics.store(header, TILE_RING_HEADER_WRITE_INDEX, nextWrite);
}
export function drainTileUpdates(
views: SharedTileRingViews,
maxItems: number,
out: TileRef[],
): void {
const { header, buffer, capacity } = views;
let read = Atomics.load(header, TILE_RING_HEADER_READ_INDEX);
const write = Atomics.load(header, TILE_RING_HEADER_WRITE_INDEX);
let count = 0;
while (read !== write && count < maxItems) {
out.push(buffer[read]);
read = (read + 1) % capacity;
count++;
}
Atomics.store(header, TILE_RING_HEADER_READ_INDEX, read);
}
+56 -3
View File
@@ -1,7 +1,13 @@
import version from "../../../resources/version.txt";
import { createGameRunner, GameRunner } from "../GameRunner";
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
import { TileRef } from "../game/GameMap";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import {
createSharedTileRingViews,
pushTileUpdate,
SharedTileRingViews,
} from "./SharedTileRing";
import {
AttackAveragePositionResultMessage,
InitializedMessage,
@@ -16,6 +22,9 @@ import {
const ctx: Worker = self as any;
let gameRunner: Promise<GameRunner> | null = null;
const mapLoader = new FetchGameMapLoader(`/maps`, version);
let isProcessingTurns = false;
let sharedTileRing: SharedTileRingViews | null = null;
let dirtyFlags: Uint8Array | null = null;
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
// skip if ErrorUpdate
@@ -32,25 +41,68 @@ 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 {
if (message.sharedTileRingHeader && message.sharedTileRingData) {
sharedTileRing = createSharedTileRingViews({
header: message.sharedTileRingHeader,
data: message.sharedTileRingData,
dirty: message.sharedDirtyBuffer!,
});
dirtyFlags = sharedTileRing.dirtyFlags;
} else {
sharedTileRing = null;
dirtyFlags = null;
}
gameRunner = createGameRunner(
message.gameStartInfo,
message.clientID,
mapLoader,
gameUpdate,
sharedTileRing && dirtyFlags
? (tile: TileRef) => {
if (Atomics.compareExchange(dirtyFlags!, tile, 0, 1) === 0) {
pushTileUpdate(sharedTileRing!, tile);
}
}
: sharedTileRing
? (tile: TileRef) => pushTileUpdate(sharedTileRing!, tile)
: undefined,
message.sharedStateBuffer,
).then((gr) => {
sendMessage({
type: "initialized",
id: message.id,
} as InitializedMessage);
processPendingTurns();
return gr;
});
} catch (error) {
@@ -67,6 +119,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;
+8 -6
View File
@@ -9,6 +9,7 @@ import { TileRef } from "../game/GameMap";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
import { generateID } from "../Util";
import { SharedTileRingBuffers } from "./SharedTileRing";
import { WorkerMessage } from "./WorkerMessages";
export class WorkerClient {
@@ -22,6 +23,9 @@ export class WorkerClient {
constructor(
private gameStartInfo: GameStartInfo,
private clientID: ClientID,
private sharedTileRingBuffers?: SharedTileRingBuffers,
private sharedStateBuffer?: SharedArrayBuffer,
private sharedDirtyBuffer?: SharedArrayBuffer,
) {
this.worker = new Worker(new URL("./Worker.worker.ts", import.meta.url));
this.messageHandlers = new Map();
@@ -70,6 +74,10 @@ export class WorkerClient {
id: messageId,
gameStartInfo: this.gameStartInfo,
clientID: this.clientID,
sharedTileRingHeader: this.sharedTileRingBuffers?.header,
sharedTileRingData: this.sharedTileRingBuffers?.data,
sharedStateBuffer: this.sharedStateBuffer,
sharedDirtyBuffer: this.sharedDirtyBuffer,
});
// Add timeout for initialization
@@ -100,12 +108,6 @@ export class WorkerClient {
});
}
sendHeartbeat() {
this.worker.postMessage({
type: "heartbeat",
});
}
playerProfile(playerID: number): Promise<PlayerProfile> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
+4 -6
View File
@@ -9,7 +9,6 @@ import { GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
export type WorkerMessageType =
| "heartbeat"
| "init"
| "initialized"
| "turn"
@@ -31,15 +30,15 @@ interface BaseWorkerMessage {
id?: string;
}
export interface HeartbeatMessage extends BaseWorkerMessage {
type: "heartbeat";
}
// Messages from main thread to worker
export interface InitMessage extends BaseWorkerMessage {
type: "init";
gameStartInfo: GameStartInfo;
clientID: ClientID;
sharedTileRingHeader?: SharedArrayBuffer;
sharedTileRingData?: SharedArrayBuffer;
sharedStateBuffer?: SharedArrayBuffer;
sharedDirtyBuffer?: SharedArrayBuffer;
}
export interface TurnMessage extends BaseWorkerMessage {
@@ -114,7 +113,6 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
// Union types for type safety
export type MainThreadMessage =
| HeartbeatMessage
| InitMessage
| TurnMessage
| PlayerActionsMessage