import io from 'socket.io-client';
import Utils from './lib/Utils';
import Scheduler from './lib/Scheduler';
import Synchronizer from './Synchronizer';
import Serializer from './serialize/Serializer';
import NetworkMonitor from './network/NetworkMonitor';
import NetworkTransmitter from './network/NetworkTransmitter';
// TODO: the GAME_UPS below should be common to the value implemented in the server engine,
// or better yet, it should be configurable in the GameEngine instead of ServerEngine+ClientEngine
const GAME_UPS = 60; // default number of game steps per second
const STEP_DELAY_MSEC = 12; // if forward drift detected, delay next execution by this amount
const STEP_HURRY_MSEC = 8; // if backward drift detected, hurry next execution by this amount
/**
* The client engine is the singleton which manages the client-side
* process, starting the game engine, listening to network messages,
* starting client steps, and handling world updates which arrive from
* the server.
* Normally, a game will implement its own sub-class of ClientEngine, and may
* override the constructor {@link ClientEngine#constructor} and the methods
* {@link ClientEngine#start} and {@link ClientEngine#connect}
*/
class ClientEngine {
/**
* Create a client engine instance.
*
* @param {GameEngine} gameEngine - a game engine
* @param {Object} inputOptions - options object
* @param {Boolean} inputOptions.verbose - print logs to console
* @param {Boolean} inputOptions.autoConnect - if true, the client will automatically attempt connect to server.
* @param {Boolean} inputOptions.standaloneMode - if true, the client will never try to connect to a server
* @param {Number} inputOptions.delayInputCount - if set, inputs will be delayed by this many steps before they are actually applied on the client.
* @param {Number} inputOptions.healthCheckInterval - health check message interval (millisec). Default is 1000.
* @param {Number} inputOptions.healthCheckRTTSample - health check RTT calculation sample size. Default is 10.
* @param {String} inputOptions.scheduler - When set to "render-schedule" the game step scheduling is controlled by the renderer and step time is variable. When set to "fixed" the game step is run independently with a fixed step time. Default is "render-schedule".
* @param {Object} inputOptions.syncOptions - an object describing the synchronization method. If not set, will be set to extrapolate, with local object bending set to 0.0 and remote object bending set to 0.6. If the query-string parameter "sync" is defined, then that value is passed to this object's sync attribute.
* @param {String} inputOptions.syncOptions.sync - chosen sync option, can be "interpolate", "extrapolate", or "frameSync"
* @param {Number} inputOptions.syncOptions.localObjBending - amount (0 to 1.0) of bending towards original client position, after each sync, for local objects
* @param {Number} inputOptions.syncOptions.remoteObjBending - amount (0 to 1.0) of bending towards original client position, after each sync, for remote objects
* @param {String} inputOptions.serverURL - Socket server url
* @param {Renderer} Renderer - the Renderer class constructor
*/
constructor(gameEngine, inputOptions, Renderer) {
this.options = Object.assign({
autoConnect: true,
healthCheckInterval: 1000,
healthCheckRTTSample: 10,
stepPeriod: 1000 / GAME_UPS,
scheduler: 'render-schedule',
serverURL: null,
}, inputOptions);
/**
* reference to serializer
* @member {Serializer}
*/
this.serializer = new Serializer();
/**
* reference to game engine
* @member {GameEngine}
*/
this.gameEngine = gameEngine;
this.gameEngine.registerClasses(this.serializer);
this.networkTransmitter = new NetworkTransmitter(this.serializer);
this.networkMonitor = new NetworkMonitor();
this.inboundMessages = [];
this.outboundMessages = [];
// create the renderer
this.renderer = this.gameEngine.renderer = new Renderer(gameEngine, this);
// step scheduler
this.scheduler = null;
this.lastStepTime = 0;
this.correction = 0;
if (this.options.standaloneMode !== true) {
this.configureSynchronization();
}
// create a buffer of delayed inputs (fifo)
if (inputOptions && inputOptions.delayInputCount) {
this.delayedInputs = [];
for (let i = 0; i < inputOptions.delayInputCount; i++)
this.delayedInputs[i] = [];
}
this.gameEngine.emit('client__init');
}
// configure the Synchronizer singleton
configureSynchronization() {
// the reflect syncronizer is just interpolate strategy,
// configured to show server syncs
let syncOptions = this.options.syncOptions;
if (syncOptions.sync === 'reflect') {
syncOptions.sync = 'interpolate';
syncOptions.reflect = true;
}
this.synchronizer = new Synchronizer(this, syncOptions);
}
/**
* Makes a connection to the game server. Extend this method if you want to add additional
* logic on every connection. Call the super-class connect first, and return a promise which
* executes when the super-class promise completes.
*
* @param {Object} [options] additional socket.io options
* @return {Promise} Resolved when the connection is made to the server
*/
connect(options = {}) {
let connectSocket = matchMakerAnswer => {
return new Promise((resolve, reject) => {
if (matchMakerAnswer.status !== 'ok')
reject('matchMaker failed status: ' + matchMakerAnswer.status);
if (this.options.verbose)
console.log(`connecting to game server ${matchMakerAnswer.serverURL}`);
this.socket = io(matchMakerAnswer.serverURL, options);
this.networkMonitor.registerClient(this);
this.socket.once('connect', () => {
if (this.options.verbose)
console.log('connection made');
resolve();
});
this.socket.once('error', (error) => {
reject(error);
});
this.socket.on('playerJoined', (playerData) => {
this.gameEngine.playerId = playerData.playerId;
this.messageIndex = Number(this.gameEngine.playerId) * 10000;
});
this.socket.on('worldUpdate', (worldData) => {
this.inboundMessages.push(worldData);
});
this.socket.on('roomUpdate', (roomData) => {
this.gameEngine.emit('client__roomUpdate', roomData);
});
});
};
let matchmaker = Promise.resolve({ serverURL: this.options.serverURL, status: 'ok' });
if (this.options.matchmaker)
matchmaker = Utils.httpGetPromise(this.options.matchmaker);
return matchmaker.then(connectSocket);
}
/**
* Start the client engine, setting up the game loop, rendering loop and renderer.
*
* @return {Promise} Resolves once the Renderer has been initialized, and the game is
* ready to connect
*/
start() {
this.stopped = false;
this.resolved = false;
// initialize the renderer
// the render loop waits for next animation frame
if (!this.renderer) alert('ERROR: game has not defined a renderer');
let renderLoop = (timestamp) => {
if (this.stopped) {
this.renderer.stop();
return;
}
this.lastTimestamp = this.lastTimestamp || timestamp;
this.renderer.draw(timestamp, timestamp - this.lastTimestamp);
this.lastTimestamp = timestamp;
window.requestAnimationFrame(renderLoop);
};
return this.renderer.init().then(() => {
this.gameEngine.start();
if (this.options.scheduler === 'fixed') {
// schedule and start the game loop
this.scheduler = new Scheduler({
period: this.options.stepPeriod,
tick: this.step.bind(this),
delay: STEP_DELAY_MSEC
});
this.scheduler.start();
}
if (typeof window !== 'undefined')
window.requestAnimationFrame(renderLoop);
if (this.options.autoConnect && this.options.standaloneMode !== true) {
return this.connect()
.catch((error) => {
this.stopped = true;
throw error;
});
}
}).then(() => {
return new Promise((resolve, reject) => {
this.resolveGame = resolve;
if (this.socket) {
this.socket.on('disconnect', () => {
if (!this.resolved && !this.stopped) {
if (this.options.verbose)
console.log('disconnected by server...');
this.stopped = true;
reject();
}
});
}
});
});
}
/**
* Disconnect from game server
*/
disconnect() {
if (!this.stopped) {
this.socket.disconnect();
this.stopped = true;
}
}
// check if client step is too far ahead (leading) or too far
// behing (lagging) the server step
checkDrift(checkType) {
if (!this.gameEngine.highestServerStep)
return;
let thresholds = this.synchronizer.syncStrategy.STEP_DRIFT_THRESHOLDS;
let maxLead = thresholds[checkType].MAX_LEAD;
let maxLag = thresholds[checkType].MAX_LAG;
let clientStep = this.gameEngine.world.stepCount;
let serverStep = this.gameEngine.highestServerStep;
if (clientStep > serverStep + maxLead) {
this.gameEngine.trace.warn(() => `step drift ${checkType}. [${clientStep} > ${serverStep} + ${maxLead}] Client is ahead of server. Delaying next step.`);
if (this.scheduler) this.scheduler.delayTick();
this.lastStepTime += STEP_DELAY_MSEC;
this.correction += STEP_DELAY_MSEC;
} else if (serverStep > clientStep + maxLag) {
this.gameEngine.trace.warn(() => `step drift ${checkType}. [${serverStep} > ${clientStep} + ${maxLag}] Client is behind server. Hurrying next step.`);
if (this.scheduler) this.scheduler.hurryTick();
this.lastStepTime -= STEP_HURRY_MSEC;
this.correction -= STEP_HURRY_MSEC;
}
}
// execute a single game step. This is normally called by the Renderer
// at each draw event.
step(t, dt, physicsOnly) {
if (!this.resolved) {
const result = this.gameEngine.getPlayerGameOverResult();
if (result) {
this.resolved = true;
this.resolveGame(result);
// simulation can continue...
// call disconnect to quit
}
}
// physics only case
if (physicsOnly) {
this.gameEngine.step(false, t, dt, physicsOnly);
return;
}
// first update the trace state
this.gameEngine.trace.setStep(this.gameEngine.world.stepCount + 1);
// skip one step if requested
if (this.skipOneStep === true) {
this.skipOneStep = false;
return;
}
this.gameEngine.emit('client__preStep');
while (this.inboundMessages.length > 0) {
this.handleInboundMessage(this.inboundMessages.pop());
this.checkDrift('onServerSync');
}
// check for server/client step drift without update
this.checkDrift('onEveryStep');
// perform game engine step
if (this.options.standaloneMode !== true) {
this.handleOutboundInput();
}
this.applyDelayedInputs();
this.gameEngine.step(false, t, dt);
this.gameEngine.emit('client__postStep', { dt });
if (this.options.standaloneMode !== true && this.gameEngine.trace.length && this.socket) {
// socket might not have been initialized at this point
this.socket.emit('trace', JSON.stringify(this.gameEngine.trace.rotate()));
}
}
// apply a user input on the client side
doInputLocal(message) {
// some synchronization strategies (interpolate) ignore inputs on client side
if (this.gameEngine.ignoreInputs) {
return;
}
const inputEvent = { input: message.data, playerId: this.gameEngine.playerId };
this.gameEngine.emit('client__processInput', inputEvent);
this.gameEngine.emit('processInput', inputEvent);
this.gameEngine.processInput(message.data, this.gameEngine.playerId, false);
}
// apply user inputs which have been queued in order to create
// an artificial delay
applyDelayedInputs() {
if (!this.delayedInputs) {
return;
}
let that = this;
let delayed = this.delayedInputs.shift();
if (delayed && delayed.length) {
delayed.forEach(that.doInputLocal.bind(that));
}
this.delayedInputs.push([]);
}
/**
* This function should be called by the client whenever a user input
* occurs. This function will emit the input event,
* forward the input to the client's game engine (with a delay if
* so configured) and will transmit the input to the server as well.
*
* This function can be called by the extended client engine class,
* typically at the beginning of client-side step processing {@see GameEngine#client__preStep}.
*
* @param {String} input - string representing the input
* @param {Object} inputOptions - options for the input
*/
sendInput(input, inputOptions) {
let inputEvent = {
command: 'move',
data: {
messageIndex: this.messageIndex,
step: this.gameEngine.world.stepCount,
input: input,
options: inputOptions
}
};
this.gameEngine.trace.info(() => `USER INPUT[${this.messageIndex}]: ${input} ${inputOptions ? JSON.stringify(inputOptions) : '{}'}`);
// if we delay input application on client, then queue it
// otherwise apply it now
if (this.delayedInputs) {
this.delayedInputs[this.delayedInputs.length - 1].push(inputEvent);
} else {
this.doInputLocal(inputEvent);
}
if (this.options.standaloneMode !== true) {
this.outboundMessages.push(inputEvent);
}
this.messageIndex++;
}
// handle a message that has been received from the server
handleInboundMessage(syncData) {
let syncEvents = this.networkTransmitter.deserializePayload(syncData).events;
let syncHeader = syncEvents.find((e) => e.eventName === 'syncHeader');
// emit that a snapshot has been received
if (!this.gameEngine.highestServerStep || syncHeader.stepCount > this.gameEngine.highestServerStep)
this.gameEngine.highestServerStep = syncHeader.stepCount;
this.gameEngine.emit('client__syncReceived', {
syncEvents: syncEvents,
stepCount: syncHeader.stepCount,
fullUpdate: syncHeader.fullUpdate
});
this.gameEngine.trace.info(() => `========== inbound world update ${syncHeader.stepCount} ==========`);
// finally update the stepCount
if (syncHeader.stepCount > this.gameEngine.world.stepCount + this.synchronizer.syncStrategy.STEP_DRIFT_THRESHOLDS.clientReset) {
this.gameEngine.trace.info(() => `========== world step count updated from ${this.gameEngine.world.stepCount} to ${syncHeader.stepCount} ==========`);
this.gameEngine.emit('client__stepReset', { oldStep: this.gameEngine.world.stepCount, newStep: syncHeader.stepCount });
this.gameEngine.world.stepCount = syncHeader.stepCount;
}
}
// emit an input to the authoritative server
handleOutboundInput() {
for (var x = 0; x < this.outboundMessages.length; x++) {
this.socket.emit(this.outboundMessages[x].command, this.outboundMessages[x].data);
}
this.outboundMessages = [];
}
}
export default ClientEngine;