mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
feat(timeline): integrate timeline functionality into game client
- Added TimelineController to manage timeline events and state. - Introduced TimelinePanel for user interaction with the timeline. - Implemented LruCache for efficient storage of timeline records. - Enhanced Transport and GameRenderer to support timeline features. - Updated various layers to respond to timeline events, ensuring synchronization with game state. - Added support for seeking and jumping within the timeline, improving user experience during gameplay. This commit lays the groundwork for a more interactive and responsive timeline feature in the game client.
This commit is contained in:
@@ -51,6 +51,7 @@ import { createCanvas } from "./Utils";
|
||||
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
|
||||
import { GoToPlayerEvent } from "./graphics/layers/Leaderboard";
|
||||
import SoundManager from "./sound/SoundManager";
|
||||
import { TimelineController } from "./timeline/TimelineController";
|
||||
|
||||
export interface LobbyConfig {
|
||||
serverConfig: ServerConfig;
|
||||
@@ -234,6 +235,13 @@ async function createClientGame(
|
||||
|
||||
const canvas = createCanvas();
|
||||
const gameRenderer = createRenderer(canvas, gameView, eventBus);
|
||||
const timelineController = new TimelineController(
|
||||
worker,
|
||||
gameView,
|
||||
gameRenderer,
|
||||
eventBus,
|
||||
);
|
||||
await timelineController.initialize();
|
||||
|
||||
console.log(
|
||||
`creating private game got difficulty: ${lobbyConfig.gameStartInfo.config.difficulty}`,
|
||||
@@ -248,6 +256,7 @@ async function createClientGame(
|
||||
transport,
|
||||
worker,
|
||||
gameView,
|
||||
timelineController,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -274,6 +283,7 @@ export class ClientGameRunner {
|
||||
private transport: Transport,
|
||||
private worker: WorkerClient,
|
||||
private gameView: GameView,
|
||||
private timeline: TimelineController,
|
||||
) {
|
||||
this.lastMessageTime = Date.now();
|
||||
}
|
||||
@@ -369,8 +379,7 @@ export class ClientGameRunner {
|
||||
gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => {
|
||||
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
|
||||
});
|
||||
this.gameView.update(gu);
|
||||
this.renderer.tick();
|
||||
this.timeline.onWorkerUpdate(gu);
|
||||
|
||||
// Emit tick metrics event for performance overlay
|
||||
this.eventBus.emit(
|
||||
|
||||
@@ -29,6 +29,7 @@ import { replacer } from "../core/Util";
|
||||
import { getPlayToken } from "./Auth";
|
||||
import { LobbyConfig } from "./ClientGameRunner";
|
||||
import { LocalServer } from "./LocalServer";
|
||||
import { TimelineModeChangedEvent } from "./timeline/TimelineEvents";
|
||||
|
||||
export class PauseGameIntentEvent implements GameEvent {
|
||||
constructor(public readonly paused: boolean) {}
|
||||
@@ -185,6 +186,7 @@ export class Transport {
|
||||
|
||||
private pingInterval: number | null = null;
|
||||
public readonly isLocal: boolean;
|
||||
private timelineIsLive = true;
|
||||
|
||||
constructor(
|
||||
private lobbyConfig: LobbyConfig,
|
||||
@@ -262,6 +264,10 @@ export class Transport {
|
||||
this.eventBus.on(SendUpdateGameConfigIntentEvent, (e) =>
|
||||
this.onSendUpdateGameConfigIntent(e),
|
||||
);
|
||||
|
||||
this.eventBus.on(TimelineModeChangedEvent, (e) => {
|
||||
this.timelineIsLive = e.isLive;
|
||||
});
|
||||
}
|
||||
|
||||
private startPing() {
|
||||
@@ -644,6 +650,9 @@ export class Transport {
|
||||
}
|
||||
|
||||
private sendIntent(intent: Intent) {
|
||||
if (!this.timelineIsLive && intent.type !== "toggle_pause") {
|
||||
return;
|
||||
}
|
||||
if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) {
|
||||
const msg = {
|
||||
type: "intent",
|
||||
|
||||
@@ -41,6 +41,7 @@ import { StructureLayer } from "./layers/StructureLayer";
|
||||
import { TeamStats } from "./layers/TeamStats";
|
||||
import { TerrainLayer } from "./layers/TerrainLayer";
|
||||
import { TerritoryLayer } from "./layers/TerritoryLayer";
|
||||
import { TimelinePanel } from "./layers/TimelinePanel";
|
||||
import { UILayer } from "./layers/UILayer";
|
||||
import { UnitDisplay } from "./layers/UnitDisplay";
|
||||
import { UnitLayer } from "./layers/UnitLayer";
|
||||
@@ -168,6 +169,15 @@ export function createRenderer(
|
||||
replayPanel.eventBus = eventBus;
|
||||
replayPanel.game = game;
|
||||
|
||||
const timelinePanel = document.querySelector(
|
||||
"timeline-panel",
|
||||
) as TimelinePanel;
|
||||
if (!(timelinePanel instanceof TimelinePanel)) {
|
||||
console.error("timeline panel not found");
|
||||
}
|
||||
timelinePanel.eventBus = eventBus;
|
||||
timelinePanel.game = game;
|
||||
|
||||
const gameRightSidebar = document.querySelector(
|
||||
"game-right-sidebar",
|
||||
) as GameRightSidebar;
|
||||
@@ -313,6 +323,7 @@ export function createRenderer(
|
||||
controlPanel,
|
||||
playerInfo,
|
||||
winModal,
|
||||
timelinePanel,
|
||||
replayPanel,
|
||||
settingsModal,
|
||||
teamStats,
|
||||
|
||||
@@ -27,6 +27,7 @@ export class AlertFrame extends LitElement implements Layer {
|
||||
private animationTimeout: number | null = null;
|
||||
private seenAttackIds: Set<string> = new Set();
|
||||
private lastAlertTick: number = -1;
|
||||
private lastTickSeen: number = -1;
|
||||
// Map of player ID -> tick when we last attacked them
|
||||
private outgoingAttackTicks: Map<number, number> = new Map();
|
||||
|
||||
@@ -92,6 +93,16 @@ export class AlertFrame extends LitElement implements Layer {
|
||||
return; // Game not initialized yet
|
||||
}
|
||||
|
||||
const currentTick = this.game.ticks();
|
||||
if (this.lastTickSeen >= 0 && currentTick < this.lastTickSeen) {
|
||||
// Timeline scrubbed backwards; clear state that assumes monotonic time.
|
||||
this.seenAttackIds.clear();
|
||||
this.outgoingAttackTicks.clear();
|
||||
this.lastAlertTick = -1;
|
||||
this.isActive = false;
|
||||
}
|
||||
this.lastTickSeen = currentTick;
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
|
||||
// Clear tracked attacks if player dies or doesn't exist
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
UnitIncomingUpdate,
|
||||
} from "../../../core/game/GameUpdates";
|
||||
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
|
||||
import {
|
||||
TimelineJumpEvent,
|
||||
TimelineModeChangedEvent,
|
||||
} from "../../timeline/TimelineEvents";
|
||||
import {
|
||||
CancelAttackIntentEvent,
|
||||
CancelBoatIntentEvent,
|
||||
@@ -44,7 +48,14 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
return this;
|
||||
}
|
||||
|
||||
init() {}
|
||||
init() {
|
||||
this.eventBus.on(TimelineJumpEvent, () => this.resetForTimeline());
|
||||
this.eventBus.on(TimelineModeChangedEvent, (e) => {
|
||||
if (!e.isLive) {
|
||||
this.resetForTimeline();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.active = true;
|
||||
@@ -114,6 +125,16 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
|
||||
renderLayer(): void {}
|
||||
|
||||
private resetForTimeline() {
|
||||
this.incomingBoatIDs.clear();
|
||||
this.incomingAttacks = [];
|
||||
this.outgoingAttacks = [];
|
||||
this.outgoingLandAttacks = [];
|
||||
this.outgoingBoats = [];
|
||||
this.incomingBoats = [];
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private renderButton(options: {
|
||||
content: any;
|
||||
onClick?: () => void;
|
||||
|
||||
@@ -34,6 +34,10 @@ import { onlyImages } from "../../../core/Util";
|
||||
import { renderNumber } from "../../Utils";
|
||||
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
|
||||
|
||||
import {
|
||||
TimelineJumpEvent,
|
||||
TimelineModeChangedEvent,
|
||||
} from "../../timeline/TimelineEvents";
|
||||
import { getMessageTypeClasses, translateText } from "../../Utils";
|
||||
import { UIState } from "../UIState";
|
||||
import allianceIcon from "/images/AllianceIconWhite.svg?url";
|
||||
@@ -183,7 +187,27 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
this.events = [];
|
||||
}
|
||||
|
||||
init() {}
|
||||
init() {
|
||||
this.eventBus.on(TimelineJumpEvent, () => this.resetForTimeline());
|
||||
this.eventBus.on(TimelineModeChangedEvent, (e) => {
|
||||
if (!e.isLive) {
|
||||
this.resetForTimeline();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resetForTimeline() {
|
||||
this.events = [];
|
||||
this.alliancesCheckedAt.clear();
|
||||
this.newEvents = 0;
|
||||
this.latestGoldAmount = null;
|
||||
this.goldAmountAnimating = false;
|
||||
if (this.goldAmountTimeoutId) {
|
||||
clearTimeout(this.goldAmountTimeoutId);
|
||||
this.goldAmountTimeoutId = null;
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.active = true;
|
||||
|
||||
@@ -5,6 +5,10 @@ import { TileRef } from "../../../core/game/GameMap";
|
||||
import { ConquestUpdate, GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView, UnitView } from "../../../core/game/GameView";
|
||||
import SoundManager, { SoundEffect } from "../../sound/SoundManager";
|
||||
import {
|
||||
TimelineJumpEvent,
|
||||
TimelineModeChangedEvent,
|
||||
} from "../../timeline/TimelineEvents";
|
||||
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
|
||||
import { conquestFxFactory } from "../fx/ConquestFx";
|
||||
import { Fx, FxType } from "../fx/Fx";
|
||||
@@ -235,6 +239,13 @@ export class FxLayer implements Layer {
|
||||
this.eventBus.on(RailTileChangedEvent, (e) => {
|
||||
this.onRailroadEvent(e.tile);
|
||||
});
|
||||
|
||||
this.eventBus.on(TimelineJumpEvent, () => this.resetForTimeline());
|
||||
this.eventBus.on(TimelineModeChangedEvent, (e) => {
|
||||
if (!e.isLive) {
|
||||
this.resetForTimeline();
|
||||
}
|
||||
});
|
||||
try {
|
||||
this.animatedSpriteLoader.loadAllAnimatedSpriteImages();
|
||||
console.log("FX sprites loaded successfully");
|
||||
@@ -253,6 +264,14 @@ export class FxLayer implements Layer {
|
||||
this.canvas.height = this.game.height();
|
||||
}
|
||||
|
||||
private resetForTimeline() {
|
||||
this.allFx = [];
|
||||
this.hasBufferedFrame = false;
|
||||
if (this.context) {
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
const nowMs = performance.now();
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export class HeadsUpMessage extends LitElement implements Layer {
|
||||
@state()
|
||||
private isCatchingUp = false;
|
||||
private catchingUpTicks = 0;
|
||||
private lastTickSeen = -1;
|
||||
|
||||
private static readonly CATCHING_UP_SHOW_THRESHOLD = 10;
|
||||
|
||||
@@ -82,6 +83,16 @@ export class HeadsUpMessage extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
tick() {
|
||||
const currentTick = this.game.ticks();
|
||||
if (this.lastTickSeen >= 0 && currentTick < this.lastTickSeen) {
|
||||
// Timeline scrubbed backwards; clear state that assumes monotonic time.
|
||||
this.catchingUpTicks = 0;
|
||||
this.isCatchingUp = false;
|
||||
this.isPaused = false;
|
||||
this.isImmunityActive = false;
|
||||
}
|
||||
this.lastTickSeen = currentTick;
|
||||
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
if (updates && updates[GameUpdateType.GamePaused].length > 0) {
|
||||
const pauseUpdate = updates[GameUpdateType.GamePaused][0];
|
||||
|
||||
@@ -7,6 +7,7 @@ import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import { AlternateViewEvent } from "../../InputHandler";
|
||||
import { createCanvas, renderNumber, renderTroops } from "../../Utils";
|
||||
import { TimelineJumpEvent } from "../../timeline/TimelineEvents";
|
||||
import {
|
||||
computeAllianceClipPath,
|
||||
createAllianceProgressIcon,
|
||||
@@ -99,6 +100,33 @@ export class NameLayer implements Layer {
|
||||
this.container.appendChild(style);
|
||||
|
||||
this.eventBus.on(AlternateViewEvent, (e) => this.onAlternateViewChange(e));
|
||||
this.eventBus.on(TimelineJumpEvent, () => this.resetTimelineState());
|
||||
}
|
||||
|
||||
private resetTimelineState() {
|
||||
for (const render of this.renders) {
|
||||
render.element.remove();
|
||||
}
|
||||
this.renders = [];
|
||||
this.seenPlayers.clear();
|
||||
this.firstPlace = null;
|
||||
|
||||
// Rebuild immediately so name labels match the new GameView objects even if
|
||||
// renderer.tick() is throttled by per-layer tick intervals.
|
||||
for (const player of this.game.playerViews()) {
|
||||
if (!player.isAlive()) continue;
|
||||
this.seenPlayers.add(player);
|
||||
const info = new RenderInfo(
|
||||
player,
|
||||
0,
|
||||
null,
|
||||
0,
|
||||
"",
|
||||
this.createPlayerElement(player),
|
||||
);
|
||||
this.updateElementVisibility(info);
|
||||
this.renders.push(info);
|
||||
}
|
||||
}
|
||||
|
||||
private onAlternateViewChange(event: AlternateViewEvent) {
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
MouseUpEvent,
|
||||
SwapRocketDirectionEvent,
|
||||
} from "../../InputHandler";
|
||||
import {
|
||||
TimelineJumpEvent,
|
||||
TimelineModeChangedEvent,
|
||||
} from "../../timeline/TimelineEvents";
|
||||
import {
|
||||
SendAllianceRequestIntentEvent,
|
||||
SendBreakAllianceIntentEvent,
|
||||
@@ -85,6 +89,8 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
eventBus.on(TimelineJumpEvent, () => this.hide());
|
||||
eventBus.on(TimelineModeChangedEvent, () => this.hide());
|
||||
eventBus.on(SwapRocketDirectionEvent, (event) => {
|
||||
this.uiState.rocketDirectionUp = event.rocketDirectionUp;
|
||||
this.requestUpdate();
|
||||
|
||||
@@ -2,24 +2,14 @@ import { colord } from "colord";
|
||||
import { EventBus, GameEvent } from "../../../core/EventBus";
|
||||
import { PlayerID, UnitType } from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import {
|
||||
GameUpdateType,
|
||||
RailroadConstructionUpdate,
|
||||
RailroadDestructionUpdate,
|
||||
RailroadSnapUpdate,
|
||||
} from "../../../core/game/GameUpdates";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { AlternateViewEvent } from "../../InputHandler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { UIState } from "../UIState";
|
||||
import { Layer } from "./Layer";
|
||||
import { getBridgeRects, getRailroadRects } from "./RailroadSprites";
|
||||
import {
|
||||
computeRailTiles,
|
||||
RailroadView,
|
||||
RailTile,
|
||||
RailType,
|
||||
} from "./RailroadView";
|
||||
import { computeRailTiles, RailTile, RailType } from "./RailroadView";
|
||||
|
||||
type RailRef = {
|
||||
tile: RailTile;
|
||||
@@ -41,9 +31,6 @@ export class RailroadLayer implements Layer {
|
||||
private alternativeView = false;
|
||||
// Save the number of railroads per tiles. Delete when it reaches 0
|
||||
private existingRailroads = new Map<TileRef, RailRef>();
|
||||
private railroads = new Map<number, RailroadView>();
|
||||
// Railroads under construction
|
||||
private pendingRailroads = new Set<number>();
|
||||
private nextRailIndexToCheck = 0;
|
||||
private railTileList: TileRef[] = [];
|
||||
private railTileIndex = new Map<TileRef, number>();
|
||||
@@ -62,45 +49,15 @@ export class RailroadLayer implements Layer {
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.updatePendingRailroads();
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
if (!updates) return;
|
||||
// The event has to be handled in this specific order: construction / snap / destruction
|
||||
// Otherwise some ID may not be available yet/anymore
|
||||
updates[GameUpdateType.RailroadConstructionEvent]?.forEach((update) => {
|
||||
if (update === undefined) return;
|
||||
this.onRailroadConstruction(update);
|
||||
});
|
||||
updates[GameUpdateType.RailroadSnapEvent]?.forEach((update) => {
|
||||
if (update === undefined) return;
|
||||
this.onRailroadSnapEvent(update);
|
||||
});
|
||||
updates[GameUpdateType.RailroadDestructionEvent]?.forEach((update) => {
|
||||
if (update === undefined) return;
|
||||
this.onRailroadDestruction(update);
|
||||
});
|
||||
}
|
||||
|
||||
updatePendingRailroads() {
|
||||
for (const id of this.pendingRailroads) {
|
||||
const pending = this.railroads.get(id);
|
||||
if (pending === undefined) {
|
||||
// Rail deleted or snapped before the end of the animation
|
||||
this.pendingRailroads.delete(id);
|
||||
continue;
|
||||
}
|
||||
const newTiles = pending.tick();
|
||||
if (newTiles.length === 0) {
|
||||
// Animation complete
|
||||
this.pendingRailroads.delete(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const railTile of newTiles) {
|
||||
this.paintRailTile(railTile);
|
||||
this.eventBus.emit(new RailTileChangedEvent(railTile.tile));
|
||||
}
|
||||
const hasRailUpdates =
|
||||
(updates?.[GameUpdateType.RailroadConstructionEvent]?.length ?? 0) > 0 ||
|
||||
(updates?.[GameUpdateType.RailroadSnapEvent]?.length ?? 0) > 0 ||
|
||||
(updates?.[GameUpdateType.RailroadDestructionEvent]?.length ?? 0) > 0;
|
||||
if (hasRailUpdates) {
|
||||
this.rebuildFromGameView();
|
||||
}
|
||||
this.updateRailColors();
|
||||
}
|
||||
|
||||
updateRailColors() {
|
||||
@@ -142,9 +99,7 @@ export class RailroadLayer implements Layer {
|
||||
init() {
|
||||
this.eventBus.on(AlternateViewEvent, (e) => {
|
||||
this.alternativeView = e.alternateView;
|
||||
for (const { tile } of this.existingRailroads.values()) {
|
||||
this.paintRail(tile);
|
||||
}
|
||||
this.rebuildFromGameView();
|
||||
});
|
||||
this.redraw();
|
||||
}
|
||||
@@ -162,9 +117,28 @@ export class RailroadLayer implements Layer {
|
||||
this.canvas.width = this.game.width() * 2;
|
||||
this.canvas.height = this.game.height() * 2;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
for (const [_, rail] of this.existingRailroads) {
|
||||
this.paintRail(rail.tile);
|
||||
this.rebuildFromGameView();
|
||||
}
|
||||
|
||||
private rebuildFromGameView() {
|
||||
if (this.context === undefined) return;
|
||||
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.existingRailroads.clear();
|
||||
this.railTileList = [];
|
||||
this.railTileIndex.clear();
|
||||
this.nextRailIndexToCheck = 0;
|
||||
|
||||
for (const tiles of this.game.railroads().values()) {
|
||||
const railTiles = computeRailTiles(this.game, Array.from(tiles));
|
||||
for (const railTile of railTiles) {
|
||||
this.registerRailTile(railTile);
|
||||
}
|
||||
}
|
||||
|
||||
for (const { tile } of this.existingRailroads.values()) {
|
||||
this.paintRail(tile);
|
||||
this.eventBus.emit(new RailTileChangedEvent(tile.tile));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,13 +157,12 @@ export class RailroadLayer implements Layer {
|
||||
const offsetY = -this.game.height() / 2;
|
||||
context.fillStyle = "rgba(0, 255, 0, 0.4)";
|
||||
for (const id of this.uiState.overlappingRailroads) {
|
||||
const rail = this.railroads.get(id);
|
||||
if (rail) {
|
||||
for (const railTile of rail.drawnTiles()) {
|
||||
const x = this.game.x(railTile.tile);
|
||||
const y = this.game.y(railTile.tile);
|
||||
context.fillRect(x + offsetX - 1, y + offsetY - 1, 2.5, 2.5);
|
||||
}
|
||||
const tiles = this.game.railroads().get(id);
|
||||
if (!tiles) continue;
|
||||
for (const tile of tiles) {
|
||||
const x = this.game.x(tile);
|
||||
const y = this.game.y(tile);
|
||||
context.fillRect(x + offsetX - 1, y + offsetY - 1, 2.5, 2.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,77 +266,7 @@ export class RailroadLayer implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
private onRailroadSnapEvent(update: RailroadSnapUpdate) {
|
||||
const original = this.railroads.get(update.originalId);
|
||||
if (!original) {
|
||||
console.warn("Could not snap railroad: ", update.originalId);
|
||||
return;
|
||||
}
|
||||
if (!original.isComplete()) {
|
||||
// The animation is not complete but we don't want to compute where the animation should resume
|
||||
// Just draw every remaining rails at once
|
||||
this.drawRemainingTiles(original);
|
||||
}
|
||||
|
||||
// No need to compute the directions here, the rails are already painted
|
||||
const directions1: RailTile[] = update.tiles1.map((tile) => ({
|
||||
tile,
|
||||
type: RailType.HORIZONTAL,
|
||||
}));
|
||||
const directions2: RailTile[] = update.tiles2.map((tile) => ({
|
||||
tile,
|
||||
type: RailType.HORIZONTAL,
|
||||
}));
|
||||
// The rails are already painted, consider them complete
|
||||
this.railroads.set(
|
||||
update.newId1,
|
||||
new RailroadView(update.newId1, directions1, true),
|
||||
);
|
||||
this.railroads.set(
|
||||
update.newId2,
|
||||
new RailroadView(update.newId2, directions2, true),
|
||||
);
|
||||
|
||||
this.railroads.delete(update.originalId);
|
||||
}
|
||||
|
||||
private drawRemainingTiles(railroad: RailroadView) {
|
||||
for (const tile of railroad.remainingTiles()) {
|
||||
this.paintRail(tile);
|
||||
}
|
||||
this.pendingRailroads.delete(railroad.id);
|
||||
}
|
||||
|
||||
private onRailroadConstruction(railUpdate: RailroadConstructionUpdate) {
|
||||
const railTiles = computeRailTiles(this.game, railUpdate.tiles);
|
||||
const rail = new RailroadView(railUpdate.id, railTiles);
|
||||
this.addRailroad(rail);
|
||||
}
|
||||
|
||||
private onRailroadDestruction(railUpdate: RailroadDestructionUpdate) {
|
||||
const railroad = this.railroads.get(railUpdate.id);
|
||||
if (!railroad) {
|
||||
console.warn("Can't remove unexisting railroad: ", railUpdate.id);
|
||||
return;
|
||||
}
|
||||
this.removeRailroad(railroad);
|
||||
}
|
||||
|
||||
private addRailroad(railroad: RailroadView) {
|
||||
this.railroads.set(railroad.id, railroad);
|
||||
this.pendingRailroads.add(railroad.id);
|
||||
}
|
||||
|
||||
private removeRailroad(railroad: RailroadView) {
|
||||
this.pendingRailroads.delete(railroad.id);
|
||||
for (const railTile of railroad.drawnTiles()) {
|
||||
this.clearRailroad(railTile.tile);
|
||||
this.eventBus.emit(new RailTileChangedEvent(railTile.tile));
|
||||
}
|
||||
this.railroads.delete(railroad.id);
|
||||
}
|
||||
|
||||
private paintRailTile(railTile: RailTile) {
|
||||
private registerRailTile(railTile: RailTile) {
|
||||
const currentOwner = this.game.owner(railTile.tile)?.id() ?? null;
|
||||
const railRef = this.existingRailroads.get(railTile.tile);
|
||||
|
||||
@@ -379,51 +282,6 @@ export class RailroadLayer implements Layer {
|
||||
});
|
||||
this.railTileIndex.set(railTile.tile, this.railTileList.length);
|
||||
this.railTileList.push(railTile.tile);
|
||||
this.paintRail(railTile);
|
||||
}
|
||||
}
|
||||
|
||||
private clearRailroad(railroad: TileRef) {
|
||||
const ref = this.existingRailroads.get(railroad);
|
||||
if (ref) ref.numOccurence--;
|
||||
|
||||
if (!ref || ref.numOccurence <= 0) {
|
||||
this.existingRailroads.delete(railroad);
|
||||
this.removeRailTile(railroad);
|
||||
if (this.context === undefined) throw new Error("Not initialized");
|
||||
if (this.game.isWater(railroad)) {
|
||||
this.context.clearRect(
|
||||
this.game.x(railroad) * 2 - 2,
|
||||
this.game.y(railroad) * 2 - 2,
|
||||
5,
|
||||
6,
|
||||
);
|
||||
} else {
|
||||
this.context.clearRect(
|
||||
this.game.x(railroad) * 2 - 1,
|
||||
this.game.y(railroad) * 2 - 1,
|
||||
3,
|
||||
3,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeRailTile(tile: TileRef) {
|
||||
const idx = this.railTileIndex.get(tile);
|
||||
if (idx === undefined) return;
|
||||
|
||||
const lastIndex = this.railTileList.length - 1;
|
||||
const lastTile = this.railTileList[lastIndex];
|
||||
|
||||
this.railTileList[idx] = lastTile;
|
||||
this.railTileIndex.set(lastTile, idx);
|
||||
|
||||
this.railTileList.pop();
|
||||
this.railTileIndex.delete(tile);
|
||||
|
||||
if (this.nextRailIndexToCheck >= this.railTileList.length) {
|
||||
this.nextRailIndexToCheck = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import {
|
||||
TimelineGoLiveEvent,
|
||||
TimelineRangeEvent,
|
||||
TimelineRangeRequestEvent,
|
||||
TimelineSeekEvent,
|
||||
} from "../../timeline/TimelineEvents";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@customElement("timeline-panel")
|
||||
export class TimelinePanel extends LitElement implements Layer {
|
||||
public eventBus: EventBus | undefined;
|
||||
public game: GameView | undefined;
|
||||
|
||||
@property({ type: Boolean })
|
||||
visible: boolean = true;
|
||||
|
||||
@state() private liveTick = 0;
|
||||
@state() private displayTick = 0;
|
||||
@state() private isLive = true;
|
||||
@state() private isSeeking = false;
|
||||
@state() private storageError: string | null = null;
|
||||
@state() private isDragging = false;
|
||||
@state() private dragTick: number | null = null;
|
||||
|
||||
createRenderRoot() {
|
||||
return this; // Enable Tailwind CSS
|
||||
}
|
||||
|
||||
init() {
|
||||
this.eventBus?.on(TimelineRangeEvent, (e) => {
|
||||
this.liveTick = e.liveTick;
|
||||
this.displayTick = e.displayTick;
|
||||
this.isLive = e.isLive;
|
||||
this.isSeeking = e.isSeeking;
|
||||
this.storageError = e.storageError;
|
||||
if (!this.isDragging) {
|
||||
this.dragTick = null;
|
||||
}
|
||||
this.requestUpdate();
|
||||
});
|
||||
|
||||
this.eventBus?.emit(new TimelineRangeRequestEvent());
|
||||
}
|
||||
|
||||
getTickIntervalMs() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
tick() {
|
||||
// Render is driven by events.
|
||||
}
|
||||
|
||||
shouldTransform() {
|
||||
return false;
|
||||
}
|
||||
|
||||
renderLayer(_ctx: CanvasRenderingContext2D) {}
|
||||
|
||||
private onSeekInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const t = Number.parseInt(input.value, 10);
|
||||
if (!Number.isFinite(t)) return;
|
||||
this.dragTick = t;
|
||||
this.eventBus?.emit(new TimelineSeekEvent(t));
|
||||
}
|
||||
|
||||
private onSeekPointerDown() {
|
||||
this.isDragging = true;
|
||||
this.dragTick = this.displayTick;
|
||||
}
|
||||
|
||||
private onSeekPointerUp() {
|
||||
this.isDragging = false;
|
||||
this.dragTick = null;
|
||||
}
|
||||
|
||||
private onGoLive() {
|
||||
this.eventBus?.emit(new TimelineGoLiveEvent());
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.visible) return html``;
|
||||
|
||||
const shownTick = this.isDragging
|
||||
? (this.dragTick ?? this.displayTick)
|
||||
: this.displayTick;
|
||||
|
||||
const delta = this.liveTick - shownTick;
|
||||
const status = this.isLive ? "Live" : `Rewinding (-${delta})`;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="pointer-events-auto p-2 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-l-lg w-[320px]"
|
||||
@contextmenu=${(e: Event) => e.preventDefault()}
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="text-white text-sm" translate="no">
|
||||
${status}${this.isSeeking ? " (seeking...)" : ""}
|
||||
</div>
|
||||
<button
|
||||
class="py-0.5 px-2 text-sm text-white rounded-sm border transition border-gray-500 hover:border-gray-200 ${this
|
||||
.isLive
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: ""}"
|
||||
?disabled=${this.isLive}
|
||||
@click=${this.onGoLive}
|
||||
>
|
||||
Live
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-200 mb-1" translate="no">
|
||||
Tick ${shownTick} / ${this.liveTick}
|
||||
</div>
|
||||
|
||||
<input
|
||||
class="w-full"
|
||||
type="range"
|
||||
min="0"
|
||||
max=${this.liveTick}
|
||||
.value=${String(shownTick)}
|
||||
@input=${this.onSeekInput}
|
||||
@pointerdown=${this.onSeekPointerDown}
|
||||
@pointerup=${this.onSeekPointerUp}
|
||||
/>
|
||||
|
||||
${this.storageError
|
||||
? html`<div class="mt-2 text-xs text-amber-200" translate="no">
|
||||
${this.storageError}
|
||||
</div>`
|
||||
: html``}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export class LruCache<K, V> {
|
||||
private readonly map = new Map<K, V>();
|
||||
|
||||
constructor(private readonly capacity: number) {
|
||||
if (!Number.isFinite(capacity) || capacity <= 0) {
|
||||
throw new Error(`Invalid LruCache capacity: ${capacity}`);
|
||||
}
|
||||
}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
const value = this.map.get(key);
|
||||
if (value === undefined) return undefined;
|
||||
// Refresh recency
|
||||
this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
if (this.map.has(key)) {
|
||||
this.map.delete(key);
|
||||
}
|
||||
this.map.set(key, value);
|
||||
while (this.map.size > this.capacity) {
|
||||
const oldestKey = this.map.keys().next().value as K | undefined;
|
||||
if (oldestKey === undefined) break;
|
||||
this.map.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
values(): IterableIterator<V> {
|
||||
return this.map.values();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { LruCache } from "./LruCache";
|
||||
import { TimelineIdb } from "./TimelineIdb";
|
||||
import { TimelineCheckpointRecord, TimelineTickRecord } from "./types";
|
||||
|
||||
export class TimelineArchive {
|
||||
private readonly tickCache: LruCache<number, TimelineTickRecord>;
|
||||
private readonly checkpointCache: LruCache<number, TimelineCheckpointRecord>;
|
||||
private readonly idb: TimelineIdb;
|
||||
private _storageError: string | null = null;
|
||||
|
||||
constructor(
|
||||
opts: {
|
||||
tickCacheSize?: number;
|
||||
checkpointCacheSize?: number;
|
||||
idb?: TimelineIdb;
|
||||
} = {},
|
||||
) {
|
||||
this.tickCache = new LruCache(opts.tickCacheSize ?? 5000);
|
||||
this.checkpointCache = new LruCache(opts.checkpointCacheSize ?? 50);
|
||||
this.idb = opts.idb ?? new TimelineIdb();
|
||||
}
|
||||
|
||||
get storageError(): string | null {
|
||||
return this._storageError;
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
try {
|
||||
await this.idb.open();
|
||||
} catch (e) {
|
||||
this._storageError = `IndexedDB unavailable: ${String(e)}`;
|
||||
}
|
||||
}
|
||||
|
||||
putTickRecord(record: TimelineTickRecord): void {
|
||||
this.tickCache.set(record.tick, record);
|
||||
if (!this.idb.isAvailable) return;
|
||||
void this.idb.putTickRecord(record).catch((e) => {
|
||||
this._storageError = `IndexedDB write failed: ${String(e)}`;
|
||||
});
|
||||
}
|
||||
|
||||
async getTickRecord(tick: number): Promise<TimelineTickRecord | null> {
|
||||
const cached = this.tickCache.get(tick);
|
||||
if (cached) return cached;
|
||||
if (!this.idb.isAvailable) return null;
|
||||
try {
|
||||
const rec = await this.idb.getTickRecord(tick);
|
||||
if (rec) this.tickCache.set(tick, rec);
|
||||
return rec;
|
||||
} catch (e) {
|
||||
this._storageError = `IndexedDB read failed: ${String(e)}`;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getTickRecordsRange(
|
||||
fromTick: number,
|
||||
toTick: number,
|
||||
): Promise<TimelineTickRecord[]> {
|
||||
if (fromTick > toTick) return [];
|
||||
|
||||
if (!this.idb.isAvailable) {
|
||||
const out: TimelineTickRecord[] = [];
|
||||
for (let t = fromTick; t <= toTick; t++) {
|
||||
const rec = this.tickCache.get(t);
|
||||
if (!rec) {
|
||||
throw new Error(`Missing tick record ${t} (memory-only archive)`);
|
||||
}
|
||||
out.push(rec);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
try {
|
||||
const recs = await this.idb.getTickRecordsRange(fromTick, toTick);
|
||||
for (const rec of recs) {
|
||||
this.tickCache.set(rec.tick, rec);
|
||||
}
|
||||
return recs;
|
||||
} catch (e) {
|
||||
this._storageError = `IndexedDB range read failed: ${String(e)}`;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
putCheckpoint(record: TimelineCheckpointRecord): void {
|
||||
this.checkpointCache.set(record.tick, record);
|
||||
if (!this.idb.isAvailable) return;
|
||||
void this.idb.putCheckpoint(record).catch((e) => {
|
||||
this._storageError = `IndexedDB checkpoint write failed: ${String(e)}`;
|
||||
});
|
||||
}
|
||||
|
||||
async getCheckpointAtOrBefore(
|
||||
tick: number,
|
||||
): Promise<TimelineCheckpointRecord | null> {
|
||||
let best: TimelineCheckpointRecord | null = null;
|
||||
for (const rec of this.checkpointCache.values()) {
|
||||
if (rec.tick <= tick && (best === null || rec.tick > best.tick)) {
|
||||
best = rec;
|
||||
}
|
||||
}
|
||||
if (best) return best;
|
||||
|
||||
if (!this.idb.isAvailable) return null;
|
||||
try {
|
||||
const rec = await this.idb.getCheckpointAtOrBefore(tick);
|
||||
if (rec) this.checkpointCache.set(rec.tick, rec);
|
||||
return rec;
|
||||
} catch (e) {
|
||||
this._storageError = `IndexedDB checkpoint read failed: ${String(e)}`;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { EventBus } from "../../core/EventBus";
|
||||
import {
|
||||
GameUpdateType,
|
||||
GameUpdateViewData,
|
||||
} from "../../core/game/GameUpdates";
|
||||
import { GameView } from "../../core/game/GameView";
|
||||
import { WorkerClient } from "../../core/worker/WorkerClient";
|
||||
import { GameRenderer } from "../graphics/GameRenderer";
|
||||
import { ReplaySpeedChangeEvent } from "../InputHandler";
|
||||
import { defaultReplaySpeedMultiplier } from "../utilities/ReplaySpeedMultiplier";
|
||||
import { TimelineArchive } from "./TimelineArchive";
|
||||
import {
|
||||
TimelineGoLiveEvent,
|
||||
TimelineJumpEvent,
|
||||
TimelineModeChangedEvent,
|
||||
TimelineRangeEvent,
|
||||
TimelineRangeRequestEvent,
|
||||
TimelineSeekEvent,
|
||||
} from "./TimelineEvents";
|
||||
import { TimelineCheckpointRecord, TimelineTickRecord } from "./types";
|
||||
|
||||
const CHECKPOINT_EVERY_TICKS = 300;
|
||||
|
||||
export class TimelineController {
|
||||
private readonly archive = new TimelineArchive();
|
||||
private isLive = true;
|
||||
private isSeeking = false;
|
||||
private liveTick = 0;
|
||||
private displayTick = 0;
|
||||
private isPaused = false;
|
||||
private replaySpeedMultiplier = defaultReplaySpeedMultiplier;
|
||||
private playbackTimer: number | null = null;
|
||||
|
||||
private pendingSeekTick: number | null = null;
|
||||
private seekScheduled = false;
|
||||
private seekToken = 0;
|
||||
|
||||
private rewindCheckpointSnapshotInFlight = false;
|
||||
private rewindCheckpointSnapshotQueued = false;
|
||||
|
||||
constructor(
|
||||
private readonly worker: WorkerClient,
|
||||
private readonly gameView: GameView,
|
||||
private readonly renderer: GameRenderer,
|
||||
private readonly eventBus: EventBus,
|
||||
) {
|
||||
this.eventBus.on(TimelineSeekEvent, (e) => this.requestSeek(e.targetTick));
|
||||
this.eventBus.on(TimelineGoLiveEvent, () => void this.goLive());
|
||||
this.eventBus.on(TimelineRangeRequestEvent, () => this.emitRange());
|
||||
this.eventBus.on(ReplaySpeedChangeEvent, (e) => {
|
||||
this.replaySpeedMultiplier = e.replaySpeedMultiplier;
|
||||
this.maybeSchedulePlayback();
|
||||
});
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.archive.open();
|
||||
|
||||
// Create a base checkpoint (usually tick 0) so rewind has a stable origin.
|
||||
try {
|
||||
const snapshot = await this.worker.snapshot();
|
||||
this.liveTick = snapshot.tick;
|
||||
this.displayTick = snapshot.tick;
|
||||
this.archive.putCheckpoint({
|
||||
tick: snapshot.tick,
|
||||
mapStateBuffer: snapshot.mapState.buffer,
|
||||
numTilesWithFallout: snapshot.numTilesWithFallout,
|
||||
players: snapshot.players,
|
||||
units: snapshot.units,
|
||||
playerNameViewData: snapshot.playerNameViewData,
|
||||
toDeleteUnitIds: snapshot.toDeleteUnitIds,
|
||||
railroads: snapshot.railroads,
|
||||
});
|
||||
|
||||
this.gameView.importWorkerSnapshot(snapshot);
|
||||
this.renderer.redraw();
|
||||
} catch (e) {
|
||||
// If snapshot fails we can still function once we start receiving ticks.
|
||||
console.warn("Timeline init snapshot failed:", e);
|
||||
}
|
||||
|
||||
this.emitRange();
|
||||
}
|
||||
|
||||
onWorkerUpdate(gu: GameUpdateViewData): void {
|
||||
this.liveTick = Math.max(this.liveTick, gu.tick);
|
||||
|
||||
const pauseUpdate = gu.updates?.[GameUpdateType.GamePaused]?.[0];
|
||||
if (pauseUpdate) {
|
||||
this.isPaused = pauseUpdate.paused;
|
||||
}
|
||||
|
||||
const packedTileUpdatesBuffer =
|
||||
gu.packedTileUpdates.byteOffset === 0 &&
|
||||
gu.packedTileUpdates.byteLength === gu.packedTileUpdates.buffer.byteLength
|
||||
? gu.packedTileUpdates.buffer
|
||||
: gu.packedTileUpdates.buffer.slice(
|
||||
gu.packedTileUpdates.byteOffset,
|
||||
gu.packedTileUpdates.byteOffset + gu.packedTileUpdates.byteLength,
|
||||
);
|
||||
|
||||
const tickRecord: TimelineTickRecord = {
|
||||
tick: gu.tick,
|
||||
packedTileUpdatesBuffer,
|
||||
updates: gu.updates,
|
||||
playerNameViewData: gu.playerNameViewData,
|
||||
};
|
||||
this.archive.putTickRecord(tickRecord);
|
||||
|
||||
if (this.isLive) {
|
||||
const before = this.displayTick;
|
||||
this.displayTick = gu.tick;
|
||||
|
||||
this.gameView.update(gu);
|
||||
|
||||
if (gu.tick % CHECKPOINT_EVERY_TICKS === 0) {
|
||||
const cp = this.gameView.exportCheckpoint();
|
||||
const cpRecord: TimelineCheckpointRecord = {
|
||||
tick: cp.tick,
|
||||
mapStateBuffer: cp.mapState.buffer,
|
||||
numTilesWithFallout: cp.numTilesWithFallout,
|
||||
players: cp.players,
|
||||
units: cp.units,
|
||||
playerNameViewData: cp.playerNameViewData,
|
||||
toDeleteUnitIds: cp.toDeleteUnitIds,
|
||||
railroads: cp.railroads,
|
||||
};
|
||||
this.archive.putCheckpoint(cpRecord);
|
||||
}
|
||||
|
||||
// Normal live tick: let layers consume the delta for this tick.
|
||||
this.renderer.tick();
|
||||
this.emitRange();
|
||||
|
||||
// Keep internal caches stable across big jumps (e.g., after snapshot init).
|
||||
if (gu.tick - before > 5) {
|
||||
this.renderer.redraw();
|
||||
}
|
||||
} else {
|
||||
// Rewinding: do not mutate view state, only extend timeline range.
|
||||
this.emitRange();
|
||||
|
||||
// Still store checkpoints via worker snapshots so forward scrubs stay fast.
|
||||
if (gu.tick % CHECKPOINT_EVERY_TICKS === 0) {
|
||||
this.requestRewindCheckpointSnapshot();
|
||||
}
|
||||
|
||||
this.maybeSchedulePlayback();
|
||||
}
|
||||
}
|
||||
|
||||
private requestRewindCheckpointSnapshot(): void {
|
||||
this.rewindCheckpointSnapshotQueued = true;
|
||||
if (this.rewindCheckpointSnapshotInFlight) return;
|
||||
|
||||
this.rewindCheckpointSnapshotInFlight = true;
|
||||
this.rewindCheckpointSnapshotQueued = false;
|
||||
|
||||
void this.worker
|
||||
.snapshot()
|
||||
.then((snapshot) => {
|
||||
this.archive.putCheckpoint({
|
||||
tick: snapshot.tick,
|
||||
mapStateBuffer: snapshot.mapState.buffer,
|
||||
numTilesWithFallout: snapshot.numTilesWithFallout,
|
||||
players: snapshot.players,
|
||||
units: snapshot.units,
|
||||
playerNameViewData: snapshot.playerNameViewData,
|
||||
toDeleteUnitIds: snapshot.toDeleteUnitIds,
|
||||
railroads: snapshot.railroads,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore; archive.storageError will be surfaced if persistent
|
||||
})
|
||||
.finally(() => {
|
||||
this.rewindCheckpointSnapshotInFlight = false;
|
||||
if (this.rewindCheckpointSnapshotQueued) {
|
||||
this.requestRewindCheckpointSnapshot();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private requestSeek(targetTick: number): void {
|
||||
this.pendingSeekTick = targetTick;
|
||||
if (this.isSeeking) return;
|
||||
if (this.seekScheduled) return;
|
||||
this.seekScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.seekScheduled = false;
|
||||
const t = this.pendingSeekTick;
|
||||
this.pendingSeekTick = null;
|
||||
if (t === null) return;
|
||||
void this.seekTo(t);
|
||||
});
|
||||
}
|
||||
|
||||
private setLive(isLive: boolean): void {
|
||||
if (this.isLive === isLive) return;
|
||||
this.isLive = isLive;
|
||||
this.eventBus.emit(new TimelineModeChangedEvent(isLive));
|
||||
if (isLive) {
|
||||
this.clearPlaybackTimer();
|
||||
} else {
|
||||
this.maybeSchedulePlayback();
|
||||
}
|
||||
}
|
||||
|
||||
private emitRange(): void {
|
||||
this.eventBus.emit(
|
||||
new TimelineRangeEvent(
|
||||
this.liveTick,
|
||||
this.displayTick,
|
||||
this.isLive,
|
||||
this.isSeeking,
|
||||
this.archive.storageError,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async seekTo(targetTick: number): Promise<void> {
|
||||
const clamped = Math.max(0, Math.min(targetTick, this.liveTick));
|
||||
|
||||
if (clamped === this.liveTick) {
|
||||
await this.goLive();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = ++this.seekToken;
|
||||
const fromTick = this.displayTick;
|
||||
this.setLive(false);
|
||||
this.isSeeking = true;
|
||||
this.emitRange();
|
||||
|
||||
const checkpoint =
|
||||
(await this.archive.getCheckpointAtOrBefore(clamped)) ?? null;
|
||||
if (token !== this.seekToken) return;
|
||||
if (!checkpoint) {
|
||||
this.isSeeking = false;
|
||||
this.emitRange();
|
||||
return;
|
||||
}
|
||||
|
||||
this.gameView.importCheckpoint({
|
||||
tick: checkpoint.tick,
|
||||
mapState: new Uint16Array(checkpoint.mapStateBuffer),
|
||||
numTilesWithFallout: checkpoint.numTilesWithFallout,
|
||||
players: checkpoint.players,
|
||||
units: checkpoint.units,
|
||||
playerNameViewData: checkpoint.playerNameViewData,
|
||||
toDeleteUnitIds: checkpoint.toDeleteUnitIds,
|
||||
railroads: checkpoint.railroads,
|
||||
});
|
||||
|
||||
const tickRecords = await this.archive.getTickRecordsRange(
|
||||
checkpoint.tick + 1,
|
||||
clamped,
|
||||
);
|
||||
if (token !== this.seekToken) return;
|
||||
|
||||
for (const rec of tickRecords) {
|
||||
if (token !== this.seekToken) return;
|
||||
this.gameView.update({
|
||||
tick: rec.tick,
|
||||
packedTileUpdates: new BigUint64Array(rec.packedTileUpdatesBuffer),
|
||||
updates: rec.updates,
|
||||
playerNameViewData: rec.playerNameViewData,
|
||||
});
|
||||
}
|
||||
|
||||
this.displayTick = clamped;
|
||||
this.isSeeking = false;
|
||||
this.eventBus.emit(new TimelineJumpEvent(fromTick, clamped));
|
||||
this.renderer.redraw();
|
||||
this.renderer.tick();
|
||||
this.emitRange();
|
||||
this.maybeSchedulePlayback();
|
||||
|
||||
if (this.pendingSeekTick !== null) {
|
||||
const next = this.pendingSeekTick;
|
||||
this.pendingSeekTick = null;
|
||||
this.requestSeek(next);
|
||||
}
|
||||
}
|
||||
|
||||
private async goLive(): Promise<void> {
|
||||
const token = ++this.seekToken;
|
||||
const fromTick = this.displayTick;
|
||||
this.isSeeking = true;
|
||||
this.emitRange();
|
||||
|
||||
try {
|
||||
const snapshot = await this.worker.snapshot();
|
||||
if (token !== this.seekToken) return;
|
||||
|
||||
this.gameView.importWorkerSnapshot(snapshot);
|
||||
this.liveTick = Math.max(this.liveTick, snapshot.tick);
|
||||
this.displayTick = snapshot.tick;
|
||||
this.setLive(true);
|
||||
this.isSeeking = false;
|
||||
this.eventBus.emit(new TimelineJumpEvent(fromTick, snapshot.tick));
|
||||
this.renderer.redraw();
|
||||
this.renderer.tick();
|
||||
this.emitRange();
|
||||
} catch (e) {
|
||||
console.warn("Failed to go live via snapshot:", e);
|
||||
this.isSeeking = false;
|
||||
this.emitRange();
|
||||
}
|
||||
|
||||
this.maybeSchedulePlayback();
|
||||
|
||||
if (this.pendingSeekTick !== null) {
|
||||
const next = this.pendingSeekTick;
|
||||
this.pendingSeekTick = null;
|
||||
this.requestSeek(next);
|
||||
}
|
||||
}
|
||||
|
||||
private clearPlaybackTimer(): void {
|
||||
if (this.playbackTimer === null) return;
|
||||
window.clearTimeout(this.playbackTimer);
|
||||
this.playbackTimer = null;
|
||||
}
|
||||
|
||||
private maybeSchedulePlayback(): void {
|
||||
if (this.isLive) {
|
||||
this.clearPlaybackTimer();
|
||||
return;
|
||||
}
|
||||
if (this.isSeeking) return;
|
||||
if (this.pendingSeekTick !== null) return;
|
||||
if (this.isPaused) return;
|
||||
if (this.playbackTimer !== null) return;
|
||||
|
||||
const baseMs = this.gameView.config().serverConfig().turnIntervalMs();
|
||||
const intervalMs = baseMs * this.replaySpeedMultiplier;
|
||||
|
||||
if (intervalMs <= 0) {
|
||||
// "Fastest": step a few ticks per frame-like cadence without blocking.
|
||||
this.playbackTimer = window.setTimeout(() => {
|
||||
this.playbackTimer = null;
|
||||
void this.playbackFastStep();
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
this.playbackTimer = window.setTimeout(() => {
|
||||
this.playbackTimer = null;
|
||||
void this.playbackSingleStep();
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
private async playbackSingleStep(): Promise<void> {
|
||||
if (this.isLive || this.isSeeking || this.isPaused) return;
|
||||
|
||||
if (this.displayTick >= this.liveTick) {
|
||||
await this.goLive();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTick = this.displayTick + 1;
|
||||
const rec = await this.archive.getTickRecord(nextTick);
|
||||
if (!rec) {
|
||||
// Tick record not available yet (worker still processing / IDB lag).
|
||||
this.maybeSchedulePlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
this.gameView.update({
|
||||
tick: rec.tick,
|
||||
packedTileUpdates: new BigUint64Array(rec.packedTileUpdatesBuffer),
|
||||
updates: rec.updates,
|
||||
playerNameViewData: rec.playerNameViewData,
|
||||
});
|
||||
this.displayTick = rec.tick;
|
||||
this.renderer.tick();
|
||||
this.emitRange();
|
||||
this.maybeSchedulePlayback();
|
||||
}
|
||||
|
||||
private async playbackFastStep(): Promise<void> {
|
||||
if (this.isLive || this.isSeeking || this.isPaused) return;
|
||||
|
||||
if (this.displayTick >= this.liveTick) {
|
||||
await this.goLive();
|
||||
return;
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
let steps = 0;
|
||||
while (
|
||||
steps < 10 &&
|
||||
this.displayTick < this.liveTick &&
|
||||
performance.now() - start < 8
|
||||
) {
|
||||
const nextTick = this.displayTick + 1;
|
||||
const rec = await this.archive.getTickRecord(nextTick);
|
||||
if (!rec) break;
|
||||
this.gameView.update({
|
||||
tick: rec.tick,
|
||||
packedTileUpdates: new BigUint64Array(rec.packedTileUpdatesBuffer),
|
||||
updates: rec.updates,
|
||||
playerNameViewData: rec.playerNameViewData,
|
||||
});
|
||||
this.displayTick = rec.tick;
|
||||
steps++;
|
||||
}
|
||||
|
||||
if (steps > 0) {
|
||||
this.renderer.tick();
|
||||
this.emitRange();
|
||||
}
|
||||
|
||||
this.maybeSchedulePlayback();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { GameEvent } from "../../core/EventBus";
|
||||
|
||||
export class TimelineSeekEvent implements GameEvent {
|
||||
constructor(public readonly targetTick: number) {}
|
||||
}
|
||||
|
||||
export class TimelineGoLiveEvent implements GameEvent {}
|
||||
|
||||
export class TimelineModeChangedEvent implements GameEvent {
|
||||
constructor(public readonly isLive: boolean) {}
|
||||
}
|
||||
|
||||
export class TimelineJumpEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly fromTick: number,
|
||||
public readonly toTick: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class TimelineRangeEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly liveTick: number,
|
||||
public readonly displayTick: number,
|
||||
public readonly isLive: boolean,
|
||||
public readonly isSeeking: boolean,
|
||||
public readonly storageError: string | null,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class TimelineRangeRequestEvent implements GameEvent {}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { TimelineCheckpointRecord, TimelineTickRecord } from "./types";
|
||||
|
||||
const DB_NAME = "openfront_timeline_v1";
|
||||
const DB_VERSION = 1;
|
||||
const TICK_STORE = "tickRecords";
|
||||
const CHECKPOINT_STORE = "checkpoints";
|
||||
|
||||
function isIndexedDbAvailable(): boolean {
|
||||
return typeof indexedDB !== "undefined";
|
||||
}
|
||||
|
||||
export class TimelineIdb {
|
||||
private db: IDBDatabase | null = null;
|
||||
|
||||
get isAvailable(): boolean {
|
||||
return isIndexedDbAvailable();
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
if (!this.isAvailable) return;
|
||||
if (this.db) return;
|
||||
|
||||
this.db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(TICK_STORE)) {
|
||||
db.createObjectStore(TICK_STORE, { keyPath: "tick" });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(CHECKPOINT_STORE)) {
|
||||
db.createObjectStore(CHECKPOINT_STORE, { keyPath: "tick" });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () =>
|
||||
reject(req.error ?? new Error("indexedDB open failed"));
|
||||
});
|
||||
}
|
||||
|
||||
private requireDb(): IDBDatabase {
|
||||
if (!this.db) {
|
||||
throw new Error("TimelineIdb not opened");
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
|
||||
async putTickRecord(record: TimelineTickRecord): Promise<void> {
|
||||
if (!this.isAvailable) return;
|
||||
const db = this.requireDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(TICK_STORE, "readwrite");
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error ?? new Error("tickRecords tx failed"));
|
||||
tx.objectStore(TICK_STORE).put(record);
|
||||
});
|
||||
}
|
||||
|
||||
async getTickRecord(tick: number): Promise<TimelineTickRecord | null> {
|
||||
if (!this.isAvailable) return null;
|
||||
const db = this.requireDb();
|
||||
return await new Promise<TimelineTickRecord | null>((resolve, reject) => {
|
||||
const tx = db.transaction(TICK_STORE, "readonly");
|
||||
const req = tx.objectStore(TICK_STORE).get(tick);
|
||||
req.onsuccess = () => resolve((req.result as TimelineTickRecord) ?? null);
|
||||
req.onerror = () =>
|
||||
reject(req.error ?? new Error("tickRecords get failed"));
|
||||
});
|
||||
}
|
||||
|
||||
async getTickRecordsRange(
|
||||
fromTick: number,
|
||||
toTick: number,
|
||||
): Promise<TimelineTickRecord[]> {
|
||||
if (!this.isAvailable) return [];
|
||||
const db = this.requireDb();
|
||||
const range = IDBKeyRange.bound(fromTick, toTick);
|
||||
return await new Promise<TimelineTickRecord[]>((resolve, reject) => {
|
||||
const out: TimelineTickRecord[] = [];
|
||||
const tx = db.transaction(TICK_STORE, "readonly");
|
||||
const store = tx.objectStore(TICK_STORE);
|
||||
const req = store.openCursor(range, "next");
|
||||
req.onsuccess = () => {
|
||||
const cursor = req.result;
|
||||
if (!cursor) {
|
||||
resolve(out);
|
||||
return;
|
||||
}
|
||||
out.push(cursor.value as TimelineTickRecord);
|
||||
cursor.continue();
|
||||
};
|
||||
req.onerror = () =>
|
||||
reject(req.error ?? new Error("tickRecords openCursor failed"));
|
||||
});
|
||||
}
|
||||
|
||||
async putCheckpoint(record: TimelineCheckpointRecord): Promise<void> {
|
||||
if (!this.isAvailable) return;
|
||||
const db = this.requireDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(CHECKPOINT_STORE, "readwrite");
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error ?? new Error("checkpoints tx failed"));
|
||||
tx.objectStore(CHECKPOINT_STORE).put(record);
|
||||
});
|
||||
}
|
||||
|
||||
async getCheckpointAtOrBefore(
|
||||
tick: number,
|
||||
): Promise<TimelineCheckpointRecord | null> {
|
||||
if (!this.isAvailable) return null;
|
||||
const db = this.requireDb();
|
||||
const range = IDBKeyRange.upperBound(tick);
|
||||
return await new Promise<TimelineCheckpointRecord | null>(
|
||||
(resolve, reject) => {
|
||||
const tx = db.transaction(CHECKPOINT_STORE, "readonly");
|
||||
const store = tx.objectStore(CHECKPOINT_STORE);
|
||||
const req = store.openCursor(range, "prev");
|
||||
req.onsuccess = () => {
|
||||
const cursor = req.result;
|
||||
resolve(cursor ? (cursor.value as TimelineCheckpointRecord) : null);
|
||||
};
|
||||
req.onerror = () =>
|
||||
reject(req.error ?? new Error("checkpoints openCursor failed"));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { GameUpdates, NameViewData } from "../../core/game/Game";
|
||||
import { TileRef } from "../../core/game/GameMap";
|
||||
import { PlayerUpdate, UnitUpdate } from "../../core/game/GameUpdates";
|
||||
|
||||
export type TimelineRailroad = { id: number; tiles: TileRef[] };
|
||||
|
||||
export type TimelineTickRecord = {
|
||||
tick: number;
|
||||
packedTileUpdatesBuffer: ArrayBuffer;
|
||||
updates: GameUpdates;
|
||||
playerNameViewData: Record<string, NameViewData>;
|
||||
};
|
||||
|
||||
export type TimelineCheckpointRecord = {
|
||||
tick: number;
|
||||
mapStateBuffer: ArrayBuffer;
|
||||
numTilesWithFallout: number;
|
||||
players: PlayerUpdate[];
|
||||
units: UnitUpdate[];
|
||||
playerNameViewData: Record<string, NameViewData>;
|
||||
toDeleteUnitIds: number[];
|
||||
railroads: TimelineRailroad[];
|
||||
};
|
||||
@@ -94,6 +94,10 @@ export class GameRunner {
|
||||
private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
|
||||
) {}
|
||||
|
||||
public playerNameViewData(): Record<PlayerID, NameViewData> {
|
||||
return this.playerViewData;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.game.config().isRandomSpawn()) {
|
||||
this.game.addExecution(...this.execManager.spawnPlayers());
|
||||
|
||||
@@ -109,6 +109,28 @@ export class GameMapImpl implements GameMap {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exportMutableState(): { state: Uint16Array; numTilesWithFallout: number } {
|
||||
return {
|
||||
state: new Uint16Array(this.state),
|
||||
numTilesWithFallout: this._numTilesWithFallout,
|
||||
};
|
||||
}
|
||||
|
||||
importMutableState(state: Uint16Array, numTilesWithFallout: number): void {
|
||||
if (state.length !== this.state.length) {
|
||||
throw new Error(
|
||||
`State length ${state.length} doesn't match map state length ${this.state.length}`,
|
||||
);
|
||||
}
|
||||
this.state.set(state);
|
||||
this._numTilesWithFallout = numTilesWithFallout;
|
||||
}
|
||||
|
||||
resetMutableState(): void {
|
||||
this.state.fill(0);
|
||||
this._numTilesWithFallout = 0;
|
||||
}
|
||||
numTilesWithFallout(): number {
|
||||
return this._numTilesWithFallout;
|
||||
}
|
||||
|
||||
+154
-1
@@ -6,6 +6,7 @@ import { PatternDecoder } from "../PatternDecoder";
|
||||
import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas";
|
||||
import { createRandomName } from "../Util";
|
||||
import { WorkerClient } from "../worker/WorkerClient";
|
||||
import { WorkerSnapshot } from "../worker/WorkerMessages";
|
||||
import {
|
||||
Cell,
|
||||
EmojiMessage,
|
||||
@@ -25,13 +26,16 @@ import {
|
||||
UnitInfo,
|
||||
UnitType,
|
||||
} from "./Game";
|
||||
import { GameMap, TileRef, TileUpdate } from "./GameMap";
|
||||
import { GameMap, GameMapImpl, TileRef, TileUpdate } from "./GameMap";
|
||||
import {
|
||||
AllianceView,
|
||||
AttackUpdate,
|
||||
GameUpdateType,
|
||||
GameUpdateViewData,
|
||||
PlayerUpdate,
|
||||
RailroadConstructionUpdate,
|
||||
RailroadDestructionUpdate,
|
||||
RailroadSnapUpdate,
|
||||
UnitUpdate,
|
||||
} from "./GameUpdates";
|
||||
import { TerrainMapData } from "./TerrainMapLoader";
|
||||
@@ -83,6 +87,10 @@ export class UnitView {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
snapshotUpdate(): UnitUpdate {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
id(): number {
|
||||
return this.data.id;
|
||||
}
|
||||
@@ -582,12 +590,24 @@ export class PlayerView {
|
||||
}
|
||||
}
|
||||
|
||||
export type GameViewCheckpoint = {
|
||||
tick: number;
|
||||
mapState: Uint16Array;
|
||||
numTilesWithFallout: number;
|
||||
players: PlayerUpdate[];
|
||||
units: UnitUpdate[];
|
||||
playerNameViewData: Record<string, NameViewData>;
|
||||
toDeleteUnitIds: number[];
|
||||
railroads: { id: number; tiles: TileRef[] }[];
|
||||
};
|
||||
|
||||
export class GameView implements GameMap {
|
||||
private lastUpdate: GameUpdateViewData | null;
|
||||
private smallIDToID = new Map<number, PlayerID>();
|
||||
private _players = new Map<PlayerID, PlayerView>();
|
||||
private _units = new Map<number, UnitView>();
|
||||
private updatedTiles: TileRef[] = [];
|
||||
private railroadsById = new Map<number, TileRef[]>();
|
||||
|
||||
private _myPlayer: PlayerView | null = null;
|
||||
|
||||
@@ -629,6 +649,10 @@ export class GameView implements GameMap {
|
||||
}
|
||||
}
|
||||
|
||||
railroads(): ReadonlyMap<number, readonly TileRef[]> {
|
||||
return this.railroadsById;
|
||||
}
|
||||
|
||||
isOnEdgeOfMap(ref: TileRef): boolean {
|
||||
return this._map.isOnEdgeOfMap(ref);
|
||||
}
|
||||
@@ -702,6 +726,135 @@ export class GameView implements GameMap {
|
||||
this.toDelete.add(unit.id());
|
||||
}
|
||||
});
|
||||
|
||||
gu.updates[GameUpdateType.RailroadConstructionEvent]?.forEach(
|
||||
(u: RailroadConstructionUpdate) => {
|
||||
this.railroadsById.set(u.id, u.tiles);
|
||||
},
|
||||
);
|
||||
gu.updates[GameUpdateType.RailroadSnapEvent]?.forEach(
|
||||
(u: RailroadSnapUpdate) => {
|
||||
this.railroadsById.delete(u.originalId);
|
||||
this.railroadsById.set(u.newId1, u.tiles1);
|
||||
this.railroadsById.set(u.newId2, u.tiles2);
|
||||
},
|
||||
);
|
||||
gu.updates[GameUpdateType.RailroadDestructionEvent]?.forEach(
|
||||
(u: RailroadDestructionUpdate) => {
|
||||
this.railroadsById.delete(u.id);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
exportCheckpoint(): GameViewCheckpoint {
|
||||
const tick = this.ticks();
|
||||
const map = this._map as unknown as GameMapImpl;
|
||||
if (typeof map.exportMutableState !== "function") {
|
||||
throw new Error("GameView map does not support exportMutableState()");
|
||||
}
|
||||
const { state, numTilesWithFallout } = map.exportMutableState();
|
||||
return {
|
||||
tick,
|
||||
mapState: state,
|
||||
numTilesWithFallout,
|
||||
players: Array.from(this._players.values()).map((p) => p.data),
|
||||
units: Array.from(this._units.values()).map((u) => u.snapshotUpdate()),
|
||||
playerNameViewData: this.lastUpdate?.playerNameViewData ?? {},
|
||||
toDeleteUnitIds: Array.from(this.toDelete.values()),
|
||||
railroads: Array.from(this.railroadsById.entries()).map(
|
||||
([id, tiles]) => ({
|
||||
id,
|
||||
tiles: tiles.slice(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
importCheckpoint(checkpoint: GameViewCheckpoint): void {
|
||||
const map = this._map as unknown as GameMapImpl;
|
||||
if (typeof map.importMutableState !== "function") {
|
||||
throw new Error("GameView map does not support mutable state import");
|
||||
}
|
||||
|
||||
map.importMutableState(checkpoint.mapState, checkpoint.numTilesWithFallout);
|
||||
|
||||
this.smallIDToID.clear();
|
||||
this._players.clear();
|
||||
this._units.clear();
|
||||
this.updatedTiles = [];
|
||||
this._myPlayer = null;
|
||||
this.toDelete = new Set(checkpoint.toDeleteUnitIds);
|
||||
this.unitGrid = new UnitGrid(this._map);
|
||||
this.railroadsById = new Map(
|
||||
checkpoint.railroads.map((r) => [r.id, r.tiles.slice()]),
|
||||
);
|
||||
|
||||
this.lastUpdate = {
|
||||
tick: checkpoint.tick,
|
||||
packedTileUpdates: new BigUint64Array(0),
|
||||
updates: GameView.createEmptyGameUpdates(),
|
||||
playerNameViewData: checkpoint.playerNameViewData,
|
||||
};
|
||||
|
||||
const getNameData = (playerId: PlayerID): NameViewData => {
|
||||
return (
|
||||
(checkpoint.playerNameViewData[playerId] as
|
||||
| NameViewData
|
||||
| undefined) ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 0,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
for (const pu of checkpoint.players) {
|
||||
this.smallIDToID.set(pu.smallID, pu.id);
|
||||
this._players.set(
|
||||
pu.id,
|
||||
new PlayerView(
|
||||
this,
|
||||
pu,
|
||||
getNameData(pu.id),
|
||||
this._cosmetics.get(pu.clientID ?? "") ??
|
||||
this._cosmetics.get(pu.name) ??
|
||||
{},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const uu of checkpoint.units) {
|
||||
const unit = new UnitView(this, uu);
|
||||
this._units.set(uu.id, unit);
|
||||
if (uu.isActive) {
|
||||
this.unitGrid.addUnit(unit);
|
||||
}
|
||||
}
|
||||
|
||||
this._myPlayer = this.playerByClientID(this._myClientID);
|
||||
}
|
||||
|
||||
importWorkerSnapshot(snapshot: WorkerSnapshot): void {
|
||||
this.importCheckpoint({
|
||||
tick: snapshot.tick,
|
||||
mapState: snapshot.mapState,
|
||||
numTilesWithFallout: snapshot.numTilesWithFallout,
|
||||
players: snapshot.players,
|
||||
units: snapshot.units,
|
||||
playerNameViewData: snapshot.playerNameViewData,
|
||||
toDeleteUnitIds: snapshot.toDeleteUnitIds,
|
||||
railroads: snapshot.railroads,
|
||||
});
|
||||
}
|
||||
|
||||
private static createEmptyGameUpdates(): GameUpdates {
|
||||
const map = {} as GameUpdates;
|
||||
Object.values(GameUpdateType)
|
||||
.filter((key) => !isNaN(Number(key)))
|
||||
.forEach((key) => {
|
||||
map[key as GameUpdateType] = [];
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
recentlyUpdatedTiles(): TileRef[] {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { TileRef } from "./GameMap";
|
||||
import { StationManager } from "./RailNetworkImpl";
|
||||
import { TrainStation } from "./TrainStation";
|
||||
|
||||
export type RailroadSnapshot = { id: number; tiles: TileRef[] };
|
||||
|
||||
export interface RailNetwork {
|
||||
connectStation(station: TrainStation): void;
|
||||
removeStation(unit: Unit): void;
|
||||
@@ -11,4 +13,5 @@ export interface RailNetwork {
|
||||
overlappingRailroads(tile: TileRef): number[];
|
||||
computeGhostRailPaths(unitType: UnitType, tile: TileRef): TileRef[][];
|
||||
recomputeClusters(): void;
|
||||
exportRailroads(): RailroadSnapshot[];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PathFinding } from "../pathfinding/PathFinder";
|
||||
import { Game, Unit, UnitType } from "./Game";
|
||||
import { TileRef } from "./GameMap";
|
||||
import { GameUpdateType } from "./GameUpdates";
|
||||
import { RailNetwork } from "./RailNetwork";
|
||||
import { RailNetwork, RailroadSnapshot } from "./RailNetwork";
|
||||
import { Railroad } from "./Railroad";
|
||||
import { RailSpatialGrid } from "./RailroadSpatialGrid";
|
||||
import { Cluster, TrainStation } from "./TrainStation";
|
||||
@@ -130,6 +130,13 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
this.dirtyClusters.clear();
|
||||
}
|
||||
|
||||
exportRailroads(): RailroadSnapshot[] {
|
||||
return this.railGrid.allRails().map((r) => ({
|
||||
id: r.id,
|
||||
tiles: r.tiles.slice(),
|
||||
}));
|
||||
}
|
||||
|
||||
removeStation(unit: Unit): void {
|
||||
const station = this._stationManager.findStation(unit);
|
||||
if (!station) return;
|
||||
|
||||
@@ -84,6 +84,10 @@ export class RailSpatialGrid {
|
||||
return result;
|
||||
}
|
||||
|
||||
allRails(): Railroad[] {
|
||||
return Array.from(this.railToCells.keys());
|
||||
}
|
||||
|
||||
private key(cx: number, cy: number): string {
|
||||
return `${cx}:${cy}`;
|
||||
}
|
||||
|
||||
@@ -9,14 +9,17 @@ import {
|
||||
PlayerActionsResultMessage,
|
||||
PlayerBorderTilesResultMessage,
|
||||
PlayerProfileResultMessage,
|
||||
SnapshotResponseMessage,
|
||||
TransportShipSpawnResultMessage,
|
||||
WorkerMessage,
|
||||
WorkerSnapshot,
|
||||
} from "./WorkerMessages";
|
||||
|
||||
const ctx: Worker = self as any;
|
||||
let gameRunner: Promise<GameRunner> | null = null;
|
||||
const mapLoader = new FetchGameMapLoader(`/maps`, version);
|
||||
const MAX_TICKS_PER_HEARTBEAT = 4;
|
||||
let processTimer: number | null = null;
|
||||
|
||||
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
|
||||
// skip if ErrorUpdate
|
||||
@@ -36,25 +39,91 @@ function sendMessage(message: WorkerMessage) {
|
||||
ctx.postMessage(message, [message.gameUpdate.packedTileUpdates.buffer]);
|
||||
return;
|
||||
}
|
||||
if (message.type === "snapshot_response") {
|
||||
ctx.postMessage(message, [message.snapshot.mapState.buffer]);
|
||||
return;
|
||||
}
|
||||
ctx.postMessage(message);
|
||||
}
|
||||
|
||||
function scheduleProcessing(delayMs: number) {
|
||||
if (processTimer !== null) return;
|
||||
processTimer = setTimeout(async () => {
|
||||
processTimer = null;
|
||||
await runProcessingStep();
|
||||
}, delayMs) as unknown as number;
|
||||
}
|
||||
|
||||
async function runProcessingStep() {
|
||||
const gr = await gameRunner;
|
||||
if (!gr) return;
|
||||
|
||||
const pendingTurns = gr.pendingTurns();
|
||||
if (pendingTurns <= 0) {
|
||||
scheduleProcessing(10);
|
||||
return;
|
||||
}
|
||||
|
||||
const ticksToRun = Math.min(pendingTurns, MAX_TICKS_PER_HEARTBEAT);
|
||||
for (let i = 0; i < ticksToRun; i++) {
|
||||
if (!gr.executeNextTick(gr.pendingTurns())) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
scheduleProcessing(0);
|
||||
}
|
||||
|
||||
async function buildSnapshot(gr: GameRunner): Promise<WorkerSnapshot> {
|
||||
const tick = gr.game.ticks();
|
||||
const map = gr.game.map() as any;
|
||||
if (
|
||||
typeof map.exportMutableState !== "function" ||
|
||||
typeof map.numTilesWithFallout !== "function"
|
||||
) {
|
||||
throw new Error("Game map does not support snapshot export");
|
||||
}
|
||||
const exported = map.exportMutableState() as {
|
||||
state: Uint16Array;
|
||||
numTilesWithFallout: number;
|
||||
};
|
||||
|
||||
const mapState = exported.state;
|
||||
const numTilesWithFallout = exported.numTilesWithFallout;
|
||||
|
||||
const players = gr.game.allPlayers().map((p) => p.toUpdate());
|
||||
const units = gr.game
|
||||
.allPlayers()
|
||||
.flatMap((p) => p.units())
|
||||
.map((u) => u.toUpdate());
|
||||
|
||||
// Best-effort: emulate client-side deferred deletion behavior by scheduling
|
||||
// inactive units for deletion on the next tick.
|
||||
const toDeleteUnitIds = units.filter((u) => !u.isActive).map((u) => u.id);
|
||||
|
||||
const railroads = gr.game.railNetwork().exportRailroads();
|
||||
|
||||
// Player name view data is only computed periodically in GameRunner.
|
||||
const playerNameViewData = gr.playerNameViewData();
|
||||
|
||||
return {
|
||||
tick,
|
||||
mapState,
|
||||
numTilesWithFallout,
|
||||
players,
|
||||
units,
|
||||
playerNameViewData,
|
||||
toDeleteUnitIds,
|
||||
railroads,
|
||||
};
|
||||
}
|
||||
|
||||
ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
const message = e.data;
|
||||
|
||||
switch (message.type) {
|
||||
case "heartbeat": {
|
||||
const gr = await gameRunner;
|
||||
if (!gr) {
|
||||
break;
|
||||
}
|
||||
const pendingTurns = gr.pendingTurns();
|
||||
const ticksToRun = Math.min(pendingTurns, MAX_TICKS_PER_HEARTBEAT);
|
||||
for (let i = 0; i < ticksToRun; i++) {
|
||||
if (!gr.executeNextTick(gr.pendingTurns())) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Heartbeats are optional; the worker is self-clocked.
|
||||
scheduleProcessing(0);
|
||||
break;
|
||||
}
|
||||
case "init":
|
||||
@@ -69,6 +138,7 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
type: "initialized",
|
||||
id: message.id,
|
||||
} as InitializedMessage);
|
||||
scheduleProcessing(0);
|
||||
return gr;
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -85,11 +155,25 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
try {
|
||||
const gr = await gameRunner;
|
||||
await gr.addTurn(message.turn);
|
||||
scheduleProcessing(0);
|
||||
} catch (error) {
|
||||
console.error("Failed to process turn:", error);
|
||||
throw error;
|
||||
}
|
||||
break;
|
||||
case "snapshot_request": {
|
||||
const gr = await gameRunner;
|
||||
if (!gr) {
|
||||
throw new Error("Game runner not initialized");
|
||||
}
|
||||
const snapshot = await buildSnapshot(gr);
|
||||
sendMessage({
|
||||
type: "snapshot_response",
|
||||
id: message.id,
|
||||
snapshot,
|
||||
} as SnapshotResponseMessage);
|
||||
break;
|
||||
}
|
||||
|
||||
case "player_actions":
|
||||
if (!gameRunner) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { TileRef } from "../game/GameMap";
|
||||
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
|
||||
import { ClientID, GameStartInfo, Turn } from "../Schemas";
|
||||
import { generateID } from "../Util";
|
||||
import { WorkerMessage } from "./WorkerMessages";
|
||||
import { WorkerMessage, WorkerSnapshot } from "./WorkerMessages";
|
||||
|
||||
export class WorkerClient {
|
||||
private worker: Worker;
|
||||
@@ -109,6 +109,27 @@ export class WorkerClient {
|
||||
});
|
||||
}
|
||||
|
||||
snapshot(): Promise<WorkerSnapshot> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isInitialized) {
|
||||
reject(new Error("Worker not initialized"));
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = generateID();
|
||||
this.messageHandlers.set(messageId, (message) => {
|
||||
if (message.type === "snapshot_response") {
|
||||
resolve(message.snapshot);
|
||||
}
|
||||
});
|
||||
|
||||
this.worker.postMessage({
|
||||
type: "snapshot_request",
|
||||
id: messageId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
playerProfile(playerID: number): Promise<PlayerProfile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isInitialized) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
NameViewData,
|
||||
PlayerActions,
|
||||
PlayerBorderTiles,
|
||||
PlayerID,
|
||||
@@ -6,7 +7,12 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { GameUpdateViewData } from "../game/GameUpdates";
|
||||
import {
|
||||
GameUpdateViewData,
|
||||
PlayerUpdate,
|
||||
UnitUpdate,
|
||||
} from "../game/GameUpdates";
|
||||
import { RailroadSnapshot } from "../game/RailNetwork";
|
||||
import { ClientID, GameStartInfo, Turn } from "../Schemas";
|
||||
|
||||
export type WorkerMessageType =
|
||||
@@ -15,6 +21,8 @@ export type WorkerMessageType =
|
||||
| "initialized"
|
||||
| "turn"
|
||||
| "game_update"
|
||||
| "snapshot_request"
|
||||
| "snapshot_response"
|
||||
| "player_actions"
|
||||
| "player_actions_result"
|
||||
| "player_profile"
|
||||
@@ -58,6 +66,26 @@ export interface GameUpdateMessage extends BaseWorkerMessage {
|
||||
gameUpdate: GameUpdateViewData;
|
||||
}
|
||||
|
||||
export type WorkerSnapshot = {
|
||||
tick: number;
|
||||
mapState: Uint16Array;
|
||||
numTilesWithFallout: number;
|
||||
players: PlayerUpdate[];
|
||||
units: UnitUpdate[];
|
||||
playerNameViewData: Record<string, NameViewData>;
|
||||
toDeleteUnitIds: number[];
|
||||
railroads: RailroadSnapshot[];
|
||||
};
|
||||
|
||||
export interface SnapshotRequestMessage extends BaseWorkerMessage {
|
||||
type: "snapshot_request";
|
||||
}
|
||||
|
||||
export interface SnapshotResponseMessage extends BaseWorkerMessage {
|
||||
type: "snapshot_response";
|
||||
snapshot: WorkerSnapshot;
|
||||
}
|
||||
|
||||
export interface PlayerActionsMessage extends BaseWorkerMessage {
|
||||
type: "player_actions";
|
||||
playerID: PlayerID;
|
||||
@@ -119,6 +147,7 @@ export type MainThreadMessage =
|
||||
| HeartbeatMessage
|
||||
| InitMessage
|
||||
| TurnMessage
|
||||
| SnapshotRequestMessage
|
||||
| PlayerActionsMessage
|
||||
| PlayerProfileMessage
|
||||
| PlayerBorderTilesMessage
|
||||
@@ -129,6 +158,7 @@ export type MainThreadMessage =
|
||||
export type WorkerMessage =
|
||||
| InitializedMessage
|
||||
| GameUpdateMessage
|
||||
| SnapshotResponseMessage
|
||||
| PlayerActionsResultMessage
|
||||
| PlayerProfileResultMessage
|
||||
| PlayerBorderTilesResultMessage
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GameMapImpl } from "../src/core/game/GameMap";
|
||||
|
||||
describe("GameMapImpl mutable state", () => {
|
||||
it("exports and imports mutable state losslessly", () => {
|
||||
const w = 4;
|
||||
const h = 3;
|
||||
const terrain = new Uint8Array(w * h).fill(1 << 7); // mark as land
|
||||
|
||||
const map1 = new GameMapImpl(w, h, terrain, w * h);
|
||||
const t0 = map1.ref(0, 0);
|
||||
const t1 = map1.ref(1, 0);
|
||||
const t2 = map1.ref(2, 0);
|
||||
|
||||
map1.setOwnerID(t0, 123);
|
||||
map1.setFallout(t1, true);
|
||||
map1.setDefenseBonus(t2, true);
|
||||
|
||||
const exported = map1.exportMutableState();
|
||||
|
||||
const map2 = new GameMapImpl(w, h, terrain, w * h);
|
||||
map2.importMutableState(exported.state, exported.numTilesWithFallout);
|
||||
|
||||
expect(map2.ownerID(t0)).toBe(123);
|
||||
expect(map2.hasFallout(t1)).toBe(true);
|
||||
expect(map2.hasDefenseBonus(t2)).toBe(true);
|
||||
expect(map2.numTilesWithFallout()).toBe(1);
|
||||
});
|
||||
|
||||
it("resets mutable state", () => {
|
||||
const w = 2;
|
||||
const h = 2;
|
||||
const terrain = new Uint8Array(w * h).fill(1 << 7);
|
||||
const map = new GameMapImpl(w, h, terrain, w * h);
|
||||
|
||||
map.setOwnerID(map.ref(0, 0), 1);
|
||||
map.setFallout(map.ref(1, 0), true);
|
||||
expect(map.numTilesWithFallout()).toBe(1);
|
||||
|
||||
map.resetMutableState();
|
||||
expect(map.ownerID(map.ref(0, 0))).toBe(0);
|
||||
expect(map.hasFallout(map.ref(1, 0))).toBe(false);
|
||||
expect(map.numTilesWithFallout()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Config } from "../src/core/configuration/Config";
|
||||
import { UnitType } from "../src/core/game/Game";
|
||||
import { GameMapImpl } from "../src/core/game/GameMap";
|
||||
import {
|
||||
GameUpdateType,
|
||||
type GameUpdateViewData,
|
||||
} from "../src/core/game/GameUpdates";
|
||||
import { GameView } from "../src/core/game/GameView";
|
||||
import type { TerrainMapData } from "../src/core/game/TerrainMapLoader";
|
||||
import type { WorkerClient } from "../src/core/worker/WorkerClient";
|
||||
|
||||
function createEmptyGameUpdates() {
|
||||
const updates: any = {};
|
||||
for (const v of Object.values(GameUpdateType)) {
|
||||
if (typeof v === "number") {
|
||||
updates[v] = [];
|
||||
}
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
function createMinimalGameView(): GameView {
|
||||
const w = 4;
|
||||
const h = 3;
|
||||
const terrain = new Uint8Array(w * h).fill(1 << 7); // land
|
||||
const gameMap = new GameMapImpl(w, h, terrain, w * h);
|
||||
const mapData: TerrainMapData = {
|
||||
nations: [],
|
||||
gameMap,
|
||||
miniGameMap: gameMap,
|
||||
};
|
||||
|
||||
return new GameView(
|
||||
{} as unknown as WorkerClient,
|
||||
{} as unknown as Config,
|
||||
mapData,
|
||||
"client1" as any,
|
||||
"me",
|
||||
"game1" as any,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
describe("GameView checkpoints", () => {
|
||||
it("roundtrips a checkpoint (map, units, railroads, toDelete)", () => {
|
||||
const view1 = createMinimalGameView();
|
||||
|
||||
const tileA = view1.ref(0, 0);
|
||||
const tileB = view1.ref(1, 0);
|
||||
const ownerA = 7;
|
||||
const hasFallout = true;
|
||||
const defenseBonus = true;
|
||||
|
||||
const stateA = ownerA;
|
||||
const stateB =
|
||||
3 /* owner */ | (hasFallout ? 1 << 13 : 0) | (defenseBonus ? 1 << 14 : 0);
|
||||
|
||||
const packedTileUpdates = new BigUint64Array([
|
||||
(BigInt(tileA) << 16n) | BigInt(stateA),
|
||||
(BigInt(tileB) << 16n) | BigInt(stateB),
|
||||
]);
|
||||
|
||||
const updates = createEmptyGameUpdates();
|
||||
updates[GameUpdateType.Unit].push({
|
||||
type: GameUpdateType.Unit,
|
||||
unitType: UnitType.City,
|
||||
troops: 10,
|
||||
id: 42,
|
||||
ownerID: 1,
|
||||
pos: tileA,
|
||||
lastPos: tileA,
|
||||
isActive: false,
|
||||
reachedTarget: false,
|
||||
retreating: false,
|
||||
targetable: false,
|
||||
markedForDeletion: false,
|
||||
missileTimerQueue: [],
|
||||
level: 1,
|
||||
hasTrainStation: false,
|
||||
});
|
||||
updates[GameUpdateType.RailroadConstructionEvent].push({
|
||||
type: GameUpdateType.RailroadConstructionEvent,
|
||||
id: 99,
|
||||
tiles: [tileA, tileB],
|
||||
});
|
||||
|
||||
const gu: GameUpdateViewData = {
|
||||
tick: 1,
|
||||
packedTileUpdates,
|
||||
updates,
|
||||
playerNameViewData: {},
|
||||
};
|
||||
|
||||
view1.update(gu);
|
||||
const cp1 = view1.exportCheckpoint();
|
||||
|
||||
const view2 = createMinimalGameView();
|
||||
view2.importCheckpoint(cp1);
|
||||
const cp2 = view2.exportCheckpoint();
|
||||
|
||||
expect(view2.ticks()).toBe(1);
|
||||
expect(view2.ownerID(tileA)).toBe(ownerA);
|
||||
expect(view2.hasFallout(tileB)).toBe(true);
|
||||
expect(((cp2.mapState[tileB] >> 14) & 1) === 1).toBe(true);
|
||||
|
||||
expect(cp2.toDeleteUnitIds).toEqual(cp1.toDeleteUnitIds);
|
||||
expect(cp2.railroads).toEqual(cp1.railroads);
|
||||
expect(Array.from(cp2.mapState)).toEqual(Array.from(cp1.mapState));
|
||||
expect(cp2.numTilesWithFallout).toBe(cp1.numTilesWithFallout);
|
||||
|
||||
const unit = view2.unit(42);
|
||||
expect(unit).toBeDefined();
|
||||
expect(unit?.isActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Config } from "../src/core/configuration/Config";
|
||||
import { GameMapImpl } from "../src/core/game/GameMap";
|
||||
import {
|
||||
GameUpdateType,
|
||||
type GameUpdateViewData,
|
||||
} from "../src/core/game/GameUpdates";
|
||||
import { GameView } from "../src/core/game/GameView";
|
||||
import type { TerrainMapData } from "../src/core/game/TerrainMapLoader";
|
||||
import type { WorkerClient } from "../src/core/worker/WorkerClient";
|
||||
|
||||
function createEmptyGameUpdates() {
|
||||
const updates: any = {};
|
||||
for (const v of Object.values(GameUpdateType)) {
|
||||
if (typeof v === "number") {
|
||||
updates[v] = [];
|
||||
}
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
function createView(w: number, h: number): GameView {
|
||||
const terrain = new Uint8Array(w * h).fill(1 << 7);
|
||||
const gameMap = new GameMapImpl(w, h, terrain, w * h);
|
||||
const mapData: TerrainMapData = {
|
||||
nations: [],
|
||||
gameMap,
|
||||
miniGameMap: gameMap,
|
||||
};
|
||||
|
||||
return new GameView(
|
||||
{} as unknown as WorkerClient,
|
||||
{} as unknown as Config,
|
||||
mapData,
|
||||
"client1" as any,
|
||||
"me",
|
||||
"game1" as any,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
function packTileUpdate(tile: number, state16: number): bigint {
|
||||
return (BigInt(tile) << 16n) | BigInt(state16 & 0xffff);
|
||||
}
|
||||
|
||||
describe("Timeline-style seek via checkpoints + tick replay", () => {
|
||||
it("reconstructs map state for arbitrary ticks", () => {
|
||||
const w = 5;
|
||||
const h = 4;
|
||||
const totalTicks = 30;
|
||||
const checkpointEvery = 5;
|
||||
|
||||
const baseline = createView(w, h);
|
||||
const byTickState = new Map<number, Uint16Array>();
|
||||
const checkpoints: {
|
||||
tick: number;
|
||||
checkpoint: ReturnType<GameView["exportCheckpoint"]>;
|
||||
}[] = [{ tick: 0, checkpoint: baseline.exportCheckpoint() }];
|
||||
const tickRecords: {
|
||||
tick: number;
|
||||
packedTileUpdatesBuffer: ArrayBuffer;
|
||||
updates: any;
|
||||
}[] = [];
|
||||
|
||||
for (let tick = 1; tick <= totalTicks; tick++) {
|
||||
const tile1 = tick % (w * h);
|
||||
const tile2 = (tick * 7) % (w * h);
|
||||
|
||||
const owner1 = (tick % 15) + 1;
|
||||
const owner2 = ((tick + 3) % 15) + 1;
|
||||
const fallout2 = tick % 2 === 0;
|
||||
|
||||
const state1 = owner1;
|
||||
const state2 = owner2 | (fallout2 ? 1 << 13 : 0);
|
||||
|
||||
const packedTileUpdates = new BigUint64Array([
|
||||
packTileUpdate(tile1, state1),
|
||||
packTileUpdate(tile2, state2),
|
||||
]);
|
||||
|
||||
const updates = createEmptyGameUpdates();
|
||||
const gu: GameUpdateViewData = {
|
||||
tick,
|
||||
packedTileUpdates,
|
||||
updates,
|
||||
playerNameViewData: {},
|
||||
};
|
||||
|
||||
baseline.update(gu);
|
||||
byTickState.set(tick, baseline.exportCheckpoint().mapState);
|
||||
|
||||
tickRecords.push({
|
||||
tick,
|
||||
packedTileUpdatesBuffer: packedTileUpdates.buffer.slice(0),
|
||||
updates,
|
||||
});
|
||||
|
||||
if (tick % checkpointEvery === 0) {
|
||||
checkpoints.push({ tick, checkpoint: baseline.exportCheckpoint() });
|
||||
}
|
||||
}
|
||||
|
||||
const nearestCheckpoint = (targetTick: number) => {
|
||||
let best = checkpoints[0]?.checkpoint ?? baseline.exportCheckpoint();
|
||||
for (const cp of checkpoints) {
|
||||
if (cp.tick <= targetTick && cp.tick >= best.tick) {
|
||||
best = cp.checkpoint;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
const targets = [1, 2, 7, 13, 19, 24, 30];
|
||||
for (const target of targets) {
|
||||
const view = createView(w, h);
|
||||
const cp = nearestCheckpoint(target);
|
||||
view.importCheckpoint(cp);
|
||||
|
||||
for (const rec of tickRecords) {
|
||||
if (rec.tick <= cp.tick) continue;
|
||||
if (rec.tick > target) break;
|
||||
view.update({
|
||||
tick: rec.tick,
|
||||
packedTileUpdates: new BigUint64Array(rec.packedTileUpdatesBuffer),
|
||||
updates: rec.updates,
|
||||
playerNameViewData: {},
|
||||
});
|
||||
}
|
||||
|
||||
const expected = byTickState.get(target);
|
||||
expect(expected).toBeDefined();
|
||||
expect(Array.from(view.exportCheckpoint().mapState)).toEqual(
|
||||
Array.from(expected!),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user