UNPKG

@hastearcade/snowglobe

Version:

A TypeScript port of CrystalOrb, a high-level Rust game networking library

355 lines 16.2 kB
import { Analytics } from './analytics.js'; import { ClockSyncer } from './clock_sync.js'; import { CommandBuffer } from './command.js'; import { timestampedFromInterpolation, tweenedFromInterpolation } from './display_state.js'; import { TerminationCondition, TimeKeeper } from './fixed_timestepper.js'; import { blendProgressPerFrame, serverTimeDelayFrameCount, shapeInterpolationT } from './lib.js'; import { clamp } from './math.js'; import { COMMAND_MESSAGE_TYPE_ID } from './message.js'; import { OldNew } from './old_new.js'; import { Simulation } from './simulation.js'; import * as Timestamp from './timestamp.js'; export var StageState; (function (StageState) { StageState[StageState["SyncingClock"] = 0] = "SyncingClock"; StageState[StageState["SyncingInitialState"] = 1] = "SyncingInitialState"; StageState[StageState["Ready"] = 2] = "Ready"; })(StageState || (StageState = {})); export var ReconciliationState; (function (ReconciliationState) { ReconciliationState[ReconciliationState["AwaitingSnapshot"] = 0] = "AwaitingSnapshot"; ReconciliationState[ReconciliationState["FastForwardingHealthy"] = 1] = "FastForwardingHealthy"; ReconciliationState[ReconciliationState["FastForwardingObsolete"] = 2] = "FastForwardingObsolete"; ReconciliationState[ReconciliationState["FastForwardingOvershot"] = 3] = "FastForwardingOvershot"; ReconciliationState[ReconciliationState["Blending"] = 4] = "Blending"; })(ReconciliationState || (ReconciliationState = {})); export class Client { id; _state = StageState.SyncingClock; _stage; _makeWorld; _config; fromInterpolation; constructor(makeWorld, config, fromInterpolation, id) { this.id = id; this._makeWorld = makeWorld; this._config = config; this.fromInterpolation = fromInterpolation; this._stage = { clockSyncer: new ClockSyncer(config), initStateSync: undefined, ready: undefined }; } state() { return this._state; } stage() { return this._stage; } update(deltaSeconds, secondsSinceStartup, net) { const startTime = Date.now(); if (deltaSeconds < 0) { console.warn('Attempt to update a client with negative delta seconds. The delta is being clamped to 0.'); deltaSeconds = 0; } switch (this._state) { case StageState.SyncingClock: this._stage.clockSyncer.update(deltaSeconds, secondsSinceStartup, net); if (this._stage.clockSyncer.isReady()) { this._stage.initStateSync = new ActiveClient(this._makeWorld, secondsSinceStartup, this._config, this._stage.clockSyncer, this.fromInterpolation, this.id); this._state = StageState.SyncingInitialState; } break; case StageState.SyncingInitialState: this._stage.initStateSync.update(deltaSeconds, secondsSinceStartup, net); if (this._stage.initStateSync.isReady()) { this._stage.ready = this._stage.initStateSync; this._state = StageState.Ready; } break; case StageState.Ready: this._stage.ready.update(deltaSeconds, secondsSinceStartup, net); break; } if (Date.now() - startTime > 15) { console.log(`updating took too long: ${Date.now() - startTime}`); } } } export class ActiveClient { clockSyncer; timekeepingSimulations; analytics; constructor(makeWorld, secondsSinceStartup, config, clockSyncer, fromInterpolation, id) { this.analytics = new Analytics(id ?? 'blerg'); const serverTime = clockSyncer.serverSecondsSinceStartup(secondsSinceStartup); console.assert(serverTime !== undefined, 'Active client can only be constructed with a synchronized clock'); // TODO: Proper assert function const initialTimestamp = Timestamp.fromSeconds(serverTime, 1 / 60); this.clockSyncer = clockSyncer; this.timekeepingSimulations = new TimeKeeper(new ClientWorldSimulations(makeWorld, config, initialTimestamp, fromInterpolation), config, TerminationCondition.FirstOvershoot); } lastCompletedTimestamp() { return this.timekeepingSimulations.stepper.lastCompletedTimestamp(); } simulatingTimestamp() { return Timestamp.add(this.timekeepingSimulations.stepper.lastCompletedTimestamp(), 1); } issueCommand(command, net) { const timestampCommand = Timestamp.set(command, this.simulatingTimestamp()); this.timekeepingSimulations.stepper.receiveCommand(timestampCommand); net.broadcastMessage(COMMAND_MESSAGE_TYPE_ID, timestampCommand); // if (process.env['SNOWGLOBE_DEBUG']) { // this.analytics.store( // this.simulatingTimestamp(), // AnalyticType.issuecommand, // JSON.stringify(timestampCommand) // ) // } } bufferedCommands() { return this.timekeepingSimulations.stepper.baseCommandBuffer[Symbol.iterator](); } displayState() { return this.timekeepingSimulations.stepper.displayState; } worldSimulations() { return this.timekeepingSimulations.stepper.worldSimulations; } reconciliationStatus() { return this.timekeepingSimulations.stepper.reconciliationStatus; } isReady() { return this.timekeepingSimulations.stepper.displayState !== undefined; } update(deltaSeconds, secondsSinceStartup, net) { this.clockSyncer.update(deltaSeconds, secondsSinceStartup, net); const recvCommand = []; for (const [, connection] of net.connections()) { let command; while ((command = connection.recvCommand()) != null) { this.timekeepingSimulations.stepper.receiveCommand(command); recvCommand.push(command); } let snapshot; while ((snapshot = connection.recvSnapshot()) != null) { this.timekeepingSimulations.stepper.receiveSnapshot(snapshot); } } const timeSinceSync = this.clockSyncer.serverSecondsSinceStartup(secondsSinceStartup); if (!timeSinceSync) { throw Error('Clock should be synced'); } // if (process.env['SNOWGLOBE_DEBUG']) { // this.analytics.store( // this.simulatingTimestamp(), // AnalyticType.recvcommand, // JSON.stringify(recvCommand) // ) // this.analytics.store( // this.simulatingTimestamp(), // AnalyticType.snapshotapplied, // JSON.stringify(this.timekeepingSimulations.stepper.queuedSnapshot) // ) // } this.timekeepingSimulations.update(deltaSeconds, timeSinceSync + this.timekeepingSimulations.config.serverTimeDelayLatency); // if (process.env['SNOWGLOBE_DEBUG']) { // this.analytics.store( // this.lastCompletedTimestamp(), // AnalyticType.currentworld, // JSON.stringify( // this.worldSimulations() // .get() // // eslint-disable-next-line @typescript-eslint/ban-ts-comment // // @ts-ignore // .new.getWorld().players // ) // ) // } } } class ClientWorldSimulations { config; queuedSnapshot; lastQueuedSnapshotTimestamp; lastReceivedSnapshotTimestamp; baseCommandBuffer = new CommandBuffer(); worldSimulations; displayState; blendOldNewInterpolationT; states; fromInterpolation; constructor(makeWorld, config, initialTimestamp, fromInterpolation) { this.config = config; const { old: oldWorldSimulation, new: newWorldSimulation } = (this.worldSimulations = new OldNew(new Simulation(makeWorld('old')), new Simulation(makeWorld('new')))).get(); oldWorldSimulation.resetLastCompletedTimestamp(initialTimestamp); newWorldSimulation.resetLastCompletedTimestamp(initialTimestamp); this.blendOldNewInterpolationT = 1; this.states = new OldNew(undefined, undefined); this.fromInterpolation = fromInterpolation; } get reconciliationStatus() { const worldSimulation = this.worldSimulations.get(); if (Timestamp.cmp(worldSimulation.new.lastCompletedTimestamp(), worldSimulation.old.lastCompletedTimestamp()) === 0) { if (this.blendOldNewInterpolationT < 1) { return ReconciliationState.Blending; } else { return ReconciliationState.AwaitingSnapshot; } } else { const isSnapshotNewer = this.queuedSnapshot != null && Timestamp.get(this.queuedSnapshot) > worldSimulation.new.lastCompletedTimestamp(); if (worldSimulation.new.lastCompletedTimestamp() > worldSimulation.old.lastCompletedTimestamp()) { return ReconciliationState.FastForwardingOvershot; } else if (isSnapshotNewer) { return ReconciliationState.FastForwardingObsolete; } return ReconciliationState.FastForwardingHealthy; } } step() { const loadSnapshot = (snapshot) => { const worldSimulation = this.worldSimulations.get(); const commands = this.baseCommandBuffer.drainUpTo(Timestamp.get(snapshot)); worldSimulation.new.applyCompletedSnapshot(snapshot, this.baseCommandBuffer.clone()); commands.forEach(c => { c.dispose(); }); if (Timestamp.cmp(worldSimulation.new.lastCompletedTimestamp(), worldSimulation.old.lastCompletedTimestamp()) === 1) { console.warn("Server's snapshot is newer than client!"); } this.blendOldNewInterpolationT = 0; }; const simulateNextFrame = () => { const worldSimulation = this.worldSimulations.get(); worldSimulation.old.step(); worldSimulation.new.tryCompletingSimulationsUpTo(worldSimulation.old.lastCompletedTimestamp(), this.config.fastForwardMaxPerStep); }; const publishOldState = () => { this.states.swap(); this.states.setNew(this.worldSimulations.get().old.displayState()); }; const publishBlendedState = () => { const worldSimulation = this.worldSimulations.get(); let stateToPublish; const oldDisplayState = worldSimulation.old.displayState(); const newDisplayState = worldSimulation.new.displayState(); if (oldDisplayState != null && newDisplayState != null) { stateToPublish = timestampedFromInterpolation(oldDisplayState, newDisplayState, this.blendOldNewInterpolationT, this.fromInterpolation); } else if (oldDisplayState == null && newDisplayState != null) { stateToPublish = newDisplayState; } this.states.swap(); this.states.setNew(stateToPublish); }; const status = this.reconciliationStatus; if (status === ReconciliationState.Blending) { this.blendOldNewInterpolationT += blendProgressPerFrame(this.config); this.blendOldNewInterpolationT = clamp(this.blendOldNewInterpolationT, 0, 1); simulateNextFrame(); publishBlendedState(); } else if (status === ReconciliationState.AwaitingSnapshot) { const snapshot = this.queuedSnapshot; if (snapshot != null) { this.queuedSnapshot = undefined; this.worldSimulations.swap(); loadSnapshot(snapshot); simulateNextFrame(); publishOldState(); } else { simulateNextFrame(); publishBlendedState(); } } else if (status === ReconciliationState.FastForwardingHealthy) { simulateNextFrame(); publishOldState(); } else if (status === ReconciliationState.FastForwardingObsolete) { const snapshot = this.queuedSnapshot; if (snapshot != null) { this.queuedSnapshot = undefined; loadSnapshot(snapshot); simulateNextFrame(); publishOldState(); } } else { const worldSimulation = this.worldSimulations.get(); worldSimulation.new.resetLastCompletedTimestamp(worldSimulation.old.lastCompletedTimestamp()); simulateNextFrame(); publishBlendedState(); } } lastCompletedTimestamp() { return this.worldSimulations.get().old.lastCompletedTimestamp(); } receiveCommand(command) { const worldSimulation = this.worldSimulations.get(); this.baseCommandBuffer.insert(command); worldSimulation.old.scheduleCommand(command); worldSimulation.new.scheduleCommand(command); } receiveSnapshot(snapshot) { const timestamp = Timestamp.get(snapshot); this.lastReceivedSnapshotTimestamp = timestamp; if (Timestamp.cmp(timestamp, this.lastCompletedTimestamp()) === 1) { console.warn(`Received snapshot from the future: ${JSON.stringify(timestamp)}! Ignoring snapshot ${this.lastCompletedTimestamp()}.`); return; } if (this.lastQueuedSnapshotTimestamp == null) { this.queuedSnapshot = snapshot; } else { if (Timestamp.cmp(timestamp, this.lastQueuedSnapshotTimestamp) === 1) { this.queuedSnapshot = snapshot; } else { console.warn('Received stale snapshot, ignoring'); } } if (this.queuedSnapshot != null) { this.lastQueuedSnapshotTimestamp = Timestamp.get(this.queuedSnapshot); } } resetLastCompletedTimestamp(correctedTimestamp) { const { old: oldWorldSimulation, new: newWorldSimulation } = this.worldSimulations.get(); const oldTimestamp = oldWorldSimulation.lastCompletedTimestamp(); if (newWorldSimulation.lastCompletedTimestamp() === oldWorldSimulation.lastCompletedTimestamp()) { newWorldSimulation.resetLastCompletedTimestamp(correctedTimestamp); } oldWorldSimulation.resetLastCompletedTimestamp(correctedTimestamp); // Note: If timeskip was so large that timestamp has wrapped around to the past, // then we need to clear all the commands in the base command buffer so that any // pending commands to get replayed unexpectedly in the future at the wrong time. if (Timestamp.cmp(correctedTimestamp, oldTimestamp) === -1) { this.baseCommandBuffer.drainAll(); } } postUpdate(timestepOvershootSeconds) { const { old: optionalUndershotState, new: optionalOvershotState } = this.states.get(); const tweenT = shapeInterpolationT(this.config.tweeningMethod, 1 - timestepOvershootSeconds / this.config.timestepSeconds); if (optionalUndershotState != null && optionalOvershotState != null) { this.displayState = tweenedFromInterpolation(optionalUndershotState, optionalOvershotState, tweenT, this.fromInterpolation); } this.baseCommandBuffer.updateTimestamp(this.lastCompletedTimestamp()); if (this.lastQueuedSnapshotTimestamp != null) { if (!Timestamp.acceptableTimestampRange(this.lastCompletedTimestamp(), this.lastQueuedSnapshotTimestamp) || Timestamp.cmp(this.lastQueuedSnapshotTimestamp, this.lastCompletedTimestamp()) === 1) { this.lastQueuedSnapshotTimestamp = Timestamp.sub(this.lastCompletedTimestamp(), serverTimeDelayFrameCount(this.config) * 2); } } } } //# sourceMappingURL=client.js.map