UNPKG

@rtsdk/lance-topia

Version:

A Node.js based real-time multiplayer game server

417 lines (355 loc) 17 kB
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;