UNPKG

incheon

Version:

A Node.js based real-time game server

315 lines (265 loc) 12 kB
'use strict'; var io = require('socket.io-client'); const Serializer = require('./serialize/Serializer'); const NetworkTransmitter = require('./network/NetworkTransmitter'); const NetworkMonitor = require('./network/NetworkMonitor'); const Synchronizer = require('./Synchronizer'); const Scheduler = require('./lib/Scheduler'); // externalizing these parameters as options would add confusion to game // developers, and provide no real benefit. const STEP_DRIFT_THRESHOLDS = { onServerSync: { MAX_LEAD: 1, MAX_LAG: 4 }, // max step lead/lag allowed after every server sync onEveryStep: { MAX_LEAD: 10, MAX_LAG: 10 } // max step lead/lag allowed at every step }; const STEP_DRIFT_THRESHOLD__CLIENT_RESET = 20; // if we are behind this many steps, just reset the step counter const GAME_UPS = 60; // default number of game steps per second const STEP_DELAY_MSEC = 12; // if drift detected, delay 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. */ class ClientEngine { /** * Create a client engine instance. * * @param {GameEngine} gameEngine - a game engine * @param {Object} inputOptions - options object * @param {Boolean} inputOptions.autoConnect - if true, the client will automatically attempt connect to 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 {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 of bending towards original client position, after each sync, for local objects * @param {Number} inputOptions.syncOptions.remoteObjBending - amount of bending towards original client position, after each sync, for remote objects * @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 }, 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); /** * client's player ID, as a string. * @member {String} */ this.playerId = NaN; 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] = []; } } /** * Check if a given object is owned by the player on this client * * @param {Object} object the game object to check * @return {Boolean} true if the game object is owned by the player on this client */ isOwnedByPlayer(object) { return (object.playerId == this.playerId); } 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; } const synchronizer = new Synchronizer(this, syncOptions); } /** * Makes a connection to the game server * * @return {Promise} Resolved when the connection is made to the server */ connect() { let connectionPromise = new Promise((resolve, reject) => { this.socket = io(this.options.serverURL); this.networkMonitor.registerClient(this); this.socket.once('connect', () => { console.log('connection made'); resolve(); }); this.socket.on('playerJoined', (playerData) => { this.playerId = playerData.playerId; this.messageIndex = Number(this.playerId) * 10000; }); this.socket.on('worldUpdate', (worldData) => { this.inboundMessages.push(worldData); }); }); return connectionPromise; } /** * 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() { // schedule and start the game loop this.scheduler = new Scheduler({ period: this.options.stepPeriod, tick: this.step.bind(this), delay: STEP_DELAY_MSEC }); this.gameEngine.start(); this.scheduler.start(); // initialize the renderer // the render loop waits for next animation frame if (!this.renderer) alert('ERROR: game has not defined a renderer'); let renderLoop = () => { this.renderer.draw(); window.requestAnimationFrame(renderLoop); }; return this.renderer.init().then(() => { if (typeof window !== 'undefined') window.requestAnimationFrame(renderLoop); if (this.options.autoConnect) { this.connect(); } }); } // check if client step is too far ahead (leading) or too far // behing (lagging) the server step checkDrift(checkType) { if (!this.gameEngine.serverStep) return; let maxLead = STEP_DRIFT_THRESHOLDS[checkType].MAX_LEAD; let maxLag = STEP_DRIFT_THRESHOLDS[checkType].MAX_LAG; let clientStep = this.gameEngine.world.stepCount; let serverStep = this.gameEngine.serverStep; if (clientStep > serverStep + maxLead) { this.gameEngine.trace.warn(`step drift ${checkType}. [${clientStep} > ${serverStep} + ${maxLead}] Client is ahead of server. Delaying next step.`); this.scheduler.delayTick(); } else if (serverStep > clientStep + maxLag) { this.gameEngine.trace.warn(`step drift ${checkType}. [${serverStep} > ${clientStep} + ${maxLag}] Client is behind server. Hurrying next step.`); this.scheduler.hurryTick(); } } step() { // 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 this.handleOutboundInput(); this.applyDelayedInputs(); this.gameEngine.step(); this.gameEngine.emit('client__postStep'); if (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())); } } doInputLocal(message) { if (this.gameEngine.passive) { return; } this.gameEngine.emit('client__preInput', message.data); this.gameEngine.processInput(message.data, this.playerId); this.gameEngine.emit('client__postInput', message.data); } 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 event client__preStep) * * @param {Object} input - string representing the input * @param {Object} inputOptions - options for the input */ sendInput(input, inputOptions) { var message = { 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(message); } else { this.doInputLocal(message); } this.outboundMessages.push(message); this.messageIndex++; } handleInboundMessage(syncData) { let syncEvents = this.networkTransmitter.deserializePayload(syncData).events; let syncHeader = syncEvents.find((e) => e.eventName === 'syncHeader'); // emit that a snapshot has been received this.gameEngine.serverStep = syncHeader.stepCount; this.gameEngine.emit('client__syncReceived', { syncEvents: syncEvents, stepCount: syncHeader.stepCount }); this.gameEngine.trace.info(`========== inbound world update ${syncHeader.stepCount} ==========`); // finally update the stepCount if (syncHeader.stepCount > this.gameEngine.world.stepCount + STEP_DRIFT_THRESHOLD__CLIENT_RESET) { this.gameEngine.trace.info(`========== world step count updated from ${this.gameEngine.world.stepCount} to ${syncHeader.stepCount} ==========`); this.gameEngine.world.stepCount = syncHeader.stepCount; } } handleOutboundInput() { for (var x = 0; x < this.outboundMessages.length; x++) { this.socket.emit(this.outboundMessages[x].command, this.outboundMessages[x].data); } this.outboundMessages = []; } } module.exports = ClientEngine;