lance-gg
Version:
A Node.js based real-time multiplayer game server
212 lines (170 loc) • 8.99 kB
text/typescript
import { ClientEngine } from '../ClientEngine.js';
import { InputDesc } from '../GameEngine.js';
import { GameWorld } from '../GameWorld.js';
import NetworkTransmitter from '../network/NetworkTransmitter.js';
import { Sync, SyncStrategy, SyncStrategyOptions } from './SyncStrategy.js';
const defaults = {
syncsBufferLength: 5,
maxReEnactSteps: 60, // maximum number of steps to re-enact
RTTEstimate: 2, // estimate the RTT as two steps (for updateRate=6, that's 200ms)
extrapolate: 2, // player performs method "X" which means extrapolate to match server time. that 100 + (0..100)
localObjBending: 0.1, // amount of bending towards position of sync object
remoteObjBending: 0.6, // amount of bending towards position of sync object
bendingIncrements: 10 // the bending should be applied increments (how many steps for entire bend)
};
interface ExtrapolateSyncStrategyOptions extends SyncStrategyOptions {
localObjBending: number;
remoteObjBending: number;
bendingIncrements: number;
maxReEnactSteps?: number;
}
class ExtrapolateStrategy extends SyncStrategy {
static STEP_DRIFT_THRESHOLDS = {
onServerSync: { MAX_LEAD: 2, MAX_LAG: 3 }, // max step lead/lag allowed after every server sync
onEveryStep: { MAX_LEAD: 7, MAX_LAG: 4 }, // max step lead/lag allowed at every step
clientReset: 40 // if we are behind this many steps, just reset the step counter
};
private extrapolateOptions: ExtrapolateSyncStrategyOptions;
private recentInputs: { [key: number] : InputDesc[] };
constructor(extrapolateOptions: ExtrapolateSyncStrategyOptions) {
super(extrapolateOptions);
this.extrapolateOptions = Object.assign({}, defaults, extrapolateOptions);
this.lastSync = null;
this.recentInputs = {};
}
initClient(clientEngine: ClientEngine) {
super.initClient(clientEngine);
this.gameEngine.on('client__processInput', this.clientInputSave.bind(this));
}
// keep a buffer of inputs so that we can replay them on extrapolation
clientInputSave(inputEvent) {
// if no inputs have been stored for this step, create an array
if (!this.recentInputs[inputEvent.input.step]) {
this.recentInputs[inputEvent.input.step] = [];
}
this.recentInputs[inputEvent.input.step].push(inputEvent.input);
}
// clean up the input buffer
cleanRecentInputs(lastServerStep) {
for (let input of Object.keys(this.recentInputs)) {
if (this.recentInputs[input][0].step <= lastServerStep) {
delete this.recentInputs[input];
}
}
}
// apply a new sync
applySync(sync: Sync, required: boolean): string {
// if sync is in the future, we are not ready to apply yet.
if (!required && sync.stepCount > this.gameEngine.world.stepCount) {
return '';
}
this.gameEngine.trace.debug(() => 'extrapolate applying sync');
//
// scan all the objects in the sync
//
// 1. if the object has a local shadow, adopt the server object,
// and destroy the shadow
//
// 2. if the object exists locally, sync to the server object,
// later we will re-enact the missing steps and then bend to
// the current position
//
// 3. if the object is new, just create it
//
this.needFirstSync = false;
let world = this.gameEngine.world;
let serverStep = sync.stepCount;
for (let ids of Object.keys(sync.syncObjects)) {
// TODO: we are currently taking only the first event out of
// the events that may have arrived for this object
let ev = sync.syncObjects[ids][0];
let curObj = world.objects[ev.objectInstance.id];
let localShadowObj = this.gameEngine.findLocalShadow(ev.objectInstance);
if (localShadowObj) {
// case 1: this object has a local shadow object on the client
this.gameEngine.trace.debug(() => `object ${ev.objectInstance.id} replacing local shadow ${localShadowObj.id}`);
if (!world.objects.hasOwnProperty(ev.objectInstance.id)) {
let newObj = this.addNewObject(ev.objectInstance.id, ev.objectInstance);
newObj.saveState(localShadowObj);
}
this.gameEngine.removeObjectFromWorld(localShadowObj.id);
} else if (curObj) {
// case 2: this object already exists locally
this.gameEngine.trace.trace(() => `object before syncTo: ${curObj.toString()}`);
curObj.saveState();
curObj.syncTo(ev.objectInstance);
this.gameEngine.trace.trace(() => `object after syncTo: ${curObj.toString()} synced to step[${ev.stepCount}]`);
} else {
// case 3: object does not exist. create it now
this.addNewObject(ev.objectInstance.id, ev.objectInstance);
}
}
//
// reenact the steps that we want to extrapolate forwards
//
this.gameEngine.trace.debug(() => `extrapolate re-enacting steps from [${serverStep}] to [${world.stepCount}]`);
if (serverStep < world.stepCount - this.extrapolateOptions.maxReEnactSteps) {
serverStep = world.stepCount - this.extrapolateOptions.maxReEnactSteps;
this.gameEngine.trace.info(() => `too many steps to re-enact. Starting from [${serverStep}] to [${world.stepCount}]`);
}
let clientStep = world.stepCount;
for (world.stepCount = serverStep; world.stepCount < clientStep;) {
if (this.recentInputs[world.stepCount]) {
this.recentInputs[world.stepCount].forEach(inputDesc => {
// only movement inputs are re-enacted
if (!inputDesc.options || !inputDesc.options.movement) return;
this.gameEngine.trace.trace(() => `extrapolate re-enacting movement input[${inputDesc.messageIndex}]: ${inputDesc.input}`);
this.gameEngine.processInput(inputDesc, this.gameEngine.playerId, false);
});
}
// run the game engine step in "reenact" mode
this.gameEngine.step(true);
}
this.cleanRecentInputs(serverStep);
//
// bend back to original state
//
for (let objIdStr of Object.keys(world.objects)) {
let objId = Number(objIdStr);
// shadow objects are not bent
if (objId >= this.gameEngine.options.clientIDSpace)
continue;
// TODO: using == instead of === because of string/number mismatch
// These values should always be strings (which contain a number)
// Reminder: the reason we use a string is that these
// values are sometimes used as object keys
let obj = world.objects[objId];
let isLocal = (obj.playerId == this.gameEngine.playerId); // eslint-disable-line eqeqeq
let bending = isLocal ? this.extrapolateOptions.localObjBending : this.extrapolateOptions.remoteObjBending;
obj.bendToCurrentState(bending, this.gameEngine.worldSettings, isLocal, this.extrapolateOptions.bendingIncrements);
if (typeof obj.refreshRenderObject === 'function')
obj.refreshRenderObject();
this.gameEngine.trace.trace(() => `object[${objId}] ${obj.bendingToString()}`);
}
// trace object state after sync
for (let objId of Object.keys(world.objects)) {
this.gameEngine.trace.trace(() => `object after extrapolate replay: ${world.objects[objId].toString()}`);
}
// destroy objects
// TODO: use world.forEachObject((id, ob) => {});
// TODO: identical code is in InterpolateStrategy
for (let objIdStr of Object.keys(world.objects)) {
let objId = Number(objIdStr);
let objEvents = sync.syncObjects[objId];
// if this was a full sync, and we did not get a corresponding object,
// remove the local object
if (sync.fullUpdate && !objEvents && objId < this.gameEngine.options.clientIDSpace) {
this.gameEngine.removeObjectFromWorld(objId);
continue;
}
if (!objEvents || objId >= this.gameEngine.options.clientIDSpace)
continue;
// if we got an objectDestroy event, destroy the object
objEvents.forEach((e) => {
if (NetworkTransmitter.getNetworkEvent(e) == 'objectDestroy') this.gameEngine.removeObjectFromWorld(objId);
});
}
return SyncStrategy.SYNC_APPLIED;
}
}
export { ExtrapolateStrategy, ExtrapolateSyncStrategyOptions }