@hastearcade/snowglobe
Version:
A TypeScript port of CrystalOrb, a high-level Rust game networking library
355 lines • 16.2 kB
JavaScript
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