UNPKG

@rtsdk/lance-topia

Version:

A Node.js based real-time multiplayer game server

1,611 lines (1,354 loc) 331 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Client = {})); })(this, (function (exports) { 'use strict'; /** * This class implements a singleton game world instance, created by Lance. * It represents an instance of the game world, and includes all the game objects. * It is the state of the game. */ class GameWorld { /** * Constructor of the World instance. Invoked by Lance on startup. * * @hideconstructor */ constructor() { this.stepCount = 0; this.objects = {}; this.playerCount = 0; this.idCount = 0; } /** * Gets a new, fresh and unused id that can be used for a new object * @private * @return {Number} the new id */ getNewId() { let possibleId = this.idCount; // find a free id while (possibleId in this.objects) possibleId++; this.idCount = possibleId + 1; return possibleId; } /** * Returns all the game world objects which match a criteria * @param {Object} query The query object * @param {Object} [query.id] object id * @param {Object} [query.playerId] player id * @param {Object} [query.roomName] roomName * @param {Object} [query.AI] Is AI. {AI: true} or {AI: false} * @param {Class} [query.instanceType] matches whether `object instanceof instanceType` * @param {Array} [query.components] An array of component names * @param {Boolean} [query.returnSingle] Return the first object matched * @return {Array | Object} All game objects which match all the query parameters, or the first match if returnSingle was specified */ queryObjects(query) { let queriedObjects = []; // todo this is currently a somewhat inefficient implementation for API testing purposes. // It should be implemented with cached dictionaries like in nano-ecs this.forEachObject((id, object) => { let conditions = []; // object id condition conditions.push(!("id" in query) || (query.id !== null && object.id === query.id)); // player id condition conditions.push(!("playerId" in query) || (query.playerId !== null && object.playerId === query.playerId)); // roomName condition conditions.push(!("roomName" in query) || (query.roomName !== null && object.roomName === query.roomName)); // roomName condition conditions.push(!("AI" in query) || (query.AI !== null && !!object.AI === !!query.AI)); // instance type conditio conditions.push( !("instanceType" in query) || (query.instanceType !== null && object instanceof query.instanceType), ); // components conditions if ("components" in query) { query.components.forEach((componentClass) => { conditions.push(object.hasComponent(componentClass)); }); } // all conditions are true, object is qualified for the query if (conditions.every((value) => value)) { queriedObjects.push(object); if (query.returnSingle) return false; } }); // return a single object or null if (query.returnSingle) { return queriedObjects.length > 0 ? queriedObjects[0] : null; } return queriedObjects; } /** * Returns The first game object encountered which matches a criteria. * Syntactic sugar for {@link queryObjects} with `returnSingle: true` * @param {Object} query See queryObjects * @return {Object} The game object, if found */ queryObject(query) { return this.queryObjects( Object.assign(query, { returnSingle: true, }), ); } /** * Add an object to the game world * @private * @param {Object} object object to add */ addObject(object) { this.objects[object.id] = object; } /** * Remove an object from the game world * @private * @param {number} id id of the object to remove */ removeObject(id) { delete this.objects[id]; } /** * World object iterator. * Invoke callback(objId, obj) for each object * * @param {function} callback function receives id and object. If callback returns false, the iteration will cease */ forEachObject(callback) { for (let id of Object.keys(this.objects)) { let returnValue = callback(id, this.objects[id]); // TODO: the key should be Number(id) if (returnValue === false) break; } } } function createCommonjsModule(fn) { var module = { exports: {} }; return fn(module, module.exports), module.exports; } // ES3 safe var _undefined$1 = void 0; var is$4 = function (value) { return value !== _undefined$1 && value !== null; }; // prettier-ignore var possibleTypes = { "object": true, "function": true, "undefined": true /* document.all */ }; var is$3 = function (value) { if (!is$4(value)) return false; return hasOwnProperty.call(possibleTypes, typeof value); }; var is$2 = function (value) { if (!is$3(value)) return false; try { if (!value.constructor) return false; return value.constructor.prototype === value; } catch (error) { return false; } }; var is$1 = function (value) { if (typeof value !== "function") return false; if (!hasOwnProperty.call(value, "length")) return false; try { if (typeof value.length !== "number") return false; if (typeof value.call !== "function") return false; if (typeof value.apply !== "function") return false; } catch (error) { return false; } return !is$2(value); }; var classRe = /^\s*class[\s{/}]/, functionToString = Function.prototype.toString; var is = function (value) { if (!is$1(value)) return false; if (classRe.test(functionToString.call(value))) return false; return true; }; var isImplemented$2 = function () { var assign = Object.assign, obj; if (typeof assign !== "function") return false; obj = { foo: "raz" }; assign(obj, { bar: "dwa" }, { trzy: "trzy" }); return obj.foo + obj.bar + obj.trzy === "razdwatrzy"; }; var isImplemented$1 = function () { try { Object.keys("primitive"); return true; } catch (e) { return false; } }; // eslint-disable-next-line no-empty-function var noop = function () {}; var _undefined = noop(); // Support ES3 engines var isValue = function (val) { return val !== _undefined && val !== null; }; var keys$1 = Object.keys; var shim$2 = function (object) { return keys$1(isValue(object) ? Object(object) : object); }; var keys = isImplemented$1() ? Object.keys : shim$2; var validValue = function (value) { if (!isValue(value)) throw new TypeError("Cannot use null or undefined"); return value; }; var max = Math.max; var shim$1 = function (dest, src /*, …srcn*/) { var error, i, length = max(arguments.length, 2), assign; dest = Object(validValue(dest)); assign = function (key) { try { dest[key] = src[key]; } catch (e) { if (!error) error = e; } }; for (i = 1; i < length; ++i) { src = arguments[i]; keys(src).forEach(assign); } if (error !== undefined) throw error; return dest; }; var assign = isImplemented$2() ? Object.assign : shim$1; var forEach = Array.prototype.forEach, create = Object.create; var process = function (src, obj) { var key; for (key in src) obj[key] = src[key]; }; // eslint-disable-next-line no-unused-vars var normalizeOptions = function (opts1 /*, …options*/) { var result = create(null); forEach.call(arguments, function (options) { if (!isValue(options)) return; process(Object(options), result); }); return result; }; var str = "razdwatrzy"; var isImplemented = function () { if (typeof str.contains !== "function") return false; return str.contains("dwa") === true && str.contains("foo") === false; }; var indexOf = String.prototype.indexOf; var shim = function (searchString /*, position*/) { return indexOf.call(this, searchString, arguments[1]) > -1; }; var contains = isImplemented() ? String.prototype.contains : shim; var d_1 = createCommonjsModule(function (module) { var d = (module.exports = function (dscr, value/*, options*/) { var c, e, w, options, desc; if (arguments.length < 2 || typeof dscr !== "string") { options = value; value = dscr; dscr = null; } else { options = arguments[2]; } if (is$4(dscr)) { c = contains.call(dscr, "c"); e = contains.call(dscr, "e"); w = contains.call(dscr, "w"); } else { c = w = true; e = false; } desc = { value: value, configurable: c, enumerable: e, writable: w }; return !options ? desc : assign(normalizeOptions(options), desc); }); d.gs = function (dscr, get, set/*, options*/) { var c, e, options, desc; if (typeof dscr !== "string") { options = set; set = get; get = dscr; dscr = null; } else { options = arguments[3]; } if (!is$4(get)) { get = undefined; } else if (!is(get)) { options = get; get = set = undefined; } else if (!is$4(set)) { set = undefined; } else if (!is(set)) { options = set; set = undefined; } if (is$4(dscr)) { c = contains.call(dscr, "c"); e = contains.call(dscr, "e"); } else { c = true; e = false; } desc = { get: get, set: set, configurable: c, enumerable: e }; return !options ? desc : assign(normalizeOptions(options), desc); }; }); var validCallable = function (fn) { if (typeof fn !== "function") throw new TypeError(fn + " is not a function"); return fn; }; var eventEmitter = createCommonjsModule(function (module, exports) { var apply = Function.prototype.apply, call = Function.prototype.call , create = Object.create, defineProperty = Object.defineProperty , defineProperties = Object.defineProperties , hasOwnProperty = Object.prototype.hasOwnProperty , descriptor = { configurable: true, enumerable: false, writable: true } , on, once, off, emit, methods, descriptors, base; on = function (type, listener) { var data; validCallable(listener); if (!hasOwnProperty.call(this, '__ee__')) { data = descriptor.value = create(null); defineProperty(this, '__ee__', descriptor); descriptor.value = null; } else { data = this.__ee__; } if (!data[type]) data[type] = listener; else if (typeof data[type] === 'object') data[type].push(listener); else data[type] = [data[type], listener]; return this; }; once = function (type, listener) { var once, self; validCallable(listener); self = this; on.call(this, type, once = function () { off.call(self, type, once); apply.call(listener, this, arguments); }); once.__eeOnceListener__ = listener; return this; }; off = function (type, listener) { var data, listeners, candidate, i; validCallable(listener); if (!hasOwnProperty.call(this, '__ee__')) return this; data = this.__ee__; if (!data[type]) return this; listeners = data[type]; if (typeof listeners === 'object') { for (i = 0; (candidate = listeners[i]); ++i) { if ((candidate === listener) || (candidate.__eeOnceListener__ === listener)) { if (listeners.length === 2) data[type] = listeners[i ? 0 : 1]; else listeners.splice(i, 1); } } } else { if ((listeners === listener) || (listeners.__eeOnceListener__ === listener)) { delete data[type]; } } return this; }; emit = function (type) { var i, l, listener, listeners, args; if (!hasOwnProperty.call(this, '__ee__')) return; listeners = this.__ee__[type]; if (!listeners) return; if (typeof listeners === 'object') { l = arguments.length; args = new Array(l - 1); for (i = 1; i < l; ++i) args[i - 1] = arguments[i]; listeners = listeners.slice(); for (i = 0; (listener = listeners[i]); ++i) { apply.call(listener, this, args); } } else { switch (arguments.length) { case 1: call.call(listeners, this); break; case 2: call.call(listeners, this, arguments[1]); break; case 3: call.call(listeners, this, arguments[1], arguments[2]); break; default: l = arguments.length; args = new Array(l - 1); for (i = 1; i < l; ++i) { args[i - 1] = arguments[i]; } apply.call(listeners, this, args); } } }; methods = { on: on, once: once, off: off, emit: emit }; descriptors = { on: d_1(on), once: d_1(once), off: d_1(off), emit: d_1(emit) }; base = defineProperties({}, descriptors); module.exports = exports = function (o) { return (o == null) ? create(base) : defineProperties(Object(o), descriptors); }; exports.methods = methods; }); // TODO: needs documentation // I think the API could be simpler // - Timer.run(waitSteps, cb) // - Timer.repeat(waitSteps, count, cb) // count=null=>forever // - Timer.cancel(cb) class Timer { constructor() { this.currentTime = 0; this.isActive = false; this.idCounter = 0; this.events = {}; } play() { this.isActive = true; } tick() { let event; let eventId; if (this.isActive) { this.currentTime++; for (eventId in this.events) { event = this.events[eventId]; if (event) { if (event.type == 'repeat') { if ((this.currentTime - event.startOffset) % event.time == 0) { event.callback.apply(event.thisContext, event.args); } } if (event.type == 'single') { if ((this.currentTime - event.startOffset) % event.time == 0) { event.callback.apply(event.thisContext, event.args); event.destroy(); } } } } } } destroyEvent(eventId) { delete this.events[eventId]; } loop(time, callback) { let timerEvent = new TimerEvent(this, TimerEvent.TYPES.repeat, time, callback ); this.events[timerEvent.id] = timerEvent; return timerEvent; } add(time, callback, thisContext, args) { let timerEvent = new TimerEvent(this, TimerEvent.TYPES.single, time, callback, thisContext, args ); this.events[timerEvent.id] = timerEvent; return timerEvent; } // todo implement timer delete all events destroy(id) { delete this.events[id]; } } // timer event class TimerEvent { constructor(timer, type, time, callback, thisContext, args) { this.id = ++timer.idCounter; this.timer = timer; this.type = type; this.time = time; this.callback = callback; this.startOffset = timer.currentTime; this.thisContext = thisContext; this.args = args; this.destroy = function() { this.timer.destroy(this.id); }; } } TimerEvent.TYPES = { repeat: 'repeat', single: 'single' }; /** * Tracing Services. * Use the trace functions to trace game state. Turn on tracing by * specifying the minimum trace level which should be recorded. For * example, setting traceLevel to Trace.TRACE_INFO will cause info, * warn, and error traces to be recorded. */ class Trace { constructor(options) { this.options = Object.assign({ traceLevel: this.TRACE_DEBUG }, options); this.traceBuffer = []; this.step = 'initializing'; // syntactic sugar functions this.error = this.trace.bind(this, Trace.TRACE_ERROR); this.warn = this.trace.bind(this, Trace.TRACE_WARN); this.info = this.trace.bind(this, Trace.TRACE_INFO); this.debug = this.trace.bind(this, Trace.TRACE_DEBUG); this.trace = this.trace.bind(this, Trace.TRACE_ALL); } /** * Include all trace levels. * @memberof Trace * @member {Number} TRACE_ALL */ static get TRACE_ALL() { return 0; } /** * Include debug traces and higher. * @memberof Trace * @member {Number} TRACE_DEBUG */ static get TRACE_DEBUG() { return 1; } /** * Include info traces and higher. * @memberof Trace * @member {Number} TRACE_INFO */ static get TRACE_INFO() { return 2; } /** * Include warn traces and higher. * @memberof Trace * @member {Number} TRACE_WARN */ static get TRACE_WARN() { return 3; } /** * Include error traces and higher. * @memberof Trace * @member {Number} TRACE_ERROR */ static get TRACE_ERROR() { return 4; } /** * Disable all tracing. * @memberof Trace * @member {Number} TRACE_NONE */ static get TRACE_NONE() { return 1000; } trace(level, dataCB) { // all traces must be functions which return strings if (typeof dataCB !== 'function') { throw new Error(`Lance trace was called but instead of passing a function, it received a [${typeof dataCB}]`); } if (level < this.options.traceLevel) return; this.traceBuffer.push({ data: dataCB(), level, step: this.step, time: new Date() }); } rotate() { let buffer = this.traceBuffer; this.traceBuffer = []; return buffer; } get length() { return this.traceBuffer.length; } setStep(s) { this.step = s; } } /** * The GameEngine contains the game logic. Extend this class * to implement game mechanics. The GameEngine derived * instance runs once on the server, where the final decisions * are always taken, and one instance will run on each client as well, * where the client emulates what it expects to be happening * on the server. * * The game engine's logic must listen to user inputs and * act on these inputs to change the game state. For example, * the game engine listens to controller/keyboard inputs to infer * movement for the player/ship/first-person. The game engine listens * to clicks, button-presses to infer firing, etc.. * * Note that the game engine runs on both the server and on the * clients - but the server decisions always have the final say, * and therefore clients must resolve server updates which conflict * with client-side predictions. */ class GameEngine { /** * Create a game engine instance. This needs to happen * once on the server, and once on each client. * * @param {Object} options - options object * @param {Number} options.traceLevel - the trace level. */ constructor(options) { // place the game engine in the LANCE globals const isServerSide = (typeof window === 'undefined'); const glob = isServerSide ? global : window; glob.LANCE = { gameEngine: this }; // set options const defaultOpts = { traceLevel: Trace.TRACE_NONE }; if (!isServerSide) defaultOpts.clientIDSpace = 1000000; this.options = Object.assign(defaultOpts, options); /** * client's player ID, as a string. If running on the client, this is set at runtime by the clientEngine * @member {String} */ this.playerId = NaN; // set up event emitting and interface let eventEmitter$1 = this.options.eventEmitter; if (typeof eventEmitter$1 === 'undefined') eventEmitter$1 = new eventEmitter(); /** * Register a handler for an event * * @method on * @memberof GameEngine * @instance * @param {String} eventName - name of the event * @param {Function} eventHandler - handler function */ this.on = eventEmitter$1.on; /** * Register a handler for an event, called just once (if at all) * * @method once * @memberof GameEngine * @instance * @param {String} eventName - name of the event * @param {Function} eventHandler - handler function */ this.once = eventEmitter$1.once; /** * Remove a handler * * @method removeListener * @memberof GameEngine * @instance * @param {String} eventName - name of the event * @param {Function} eventHandler - handler function */ this.removeListener = eventEmitter$1.off; this.off = eventEmitter$1.off; this.emit = eventEmitter$1.emit; // set up trace this.trace = new Trace({ traceLevel: this.options.traceLevel }); } findLocalShadow(serverObj) { for (let localId of Object.keys(this.world.objects)) { if (Number(localId) < this.options.clientIDSpace) continue; let localObj = this.world.objects[localId]; if (localObj.hasOwnProperty('inputId') && localObj.inputId === serverObj.inputId) return localObj; } return null; } initWorld(worldSettings) { this.world = new GameWorld(); // on the client we have a different ID space if (this.options.clientIDSpace) { this.world.idCount = this.options.clientIDSpace; } /** * The worldSettings defines the game world constants, such * as width, height, depth, etc. such that all other classes * can reference these values. * @member {Object} worldSettings * @memberof GameEngine */ this.worldSettings = Object.assign({}, worldSettings); } /** * Start the game. This method runs on both server * and client. Extending the start method is useful * for setting up the game's worldSettings attribute, * and registering methods on the event handler. */ start() { this.trace.info(() => '========== game engine started =========='); this.initWorld(); // create the default timer this.timer = new Timer(); this.timer.play(); this.on('postStep', (step, isReenact) => { if (!isReenact) this.timer.tick(); }); this.emit('start', { timestamp: (new Date()).getTime() }); } /** * Single game step. * * @param {Boolean} isReenact - is this step a re-enactment of the past. * @param {Number} t - the current time (optional) * @param {Number} dt - elapsed time since last step was called. (optional) * @param {Boolean} physicsOnly - do a physics step only, no game logic */ step(isReenact, t, dt, physicsOnly) { // physics-only step if (physicsOnly) { if (dt) dt /= 1000; // physics engines work in seconds this.physicsEngine.step(dt, objectFilter); return; } // emit preStep event if (isReenact === undefined) throw new Error('game engine does not forward argument isReenact to super class'); isReenact = Boolean(isReenact); let step = ++this.world.stepCount; let clientIDSpace = this.options.clientIDSpace; this.emit('preStep', { step, isReenact, dt }); // skip physics for shadow objects during re-enactment function objectFilter(o) { return !isReenact || o.id < clientIDSpace; } // physics step if (this.physicsEngine && !this.ignorePhysics) { if (dt) dt /= 1000; // physics engines work in seconds this.physicsEngine.step(dt, objectFilter); } // for each object // - apply incremental bending // - refresh object positions after physics this.world.forEachObject((id, o) => { if (typeof o.refreshFromPhysics === 'function') o.refreshFromPhysics(); this.trace.trace(() => `object[${id}] after ${isReenact ? 'reenact' : 'step'} : ${o.toString()}`); }); // emit postStep event this.emit('postStep', { step, isReenact }); } /** * Add object to the game world. * On the client side, the object may not be created, if the server copy * of this object is already in the game world. This could happen when the client * is using delayed-input, and the RTT is very low. * * @param {Object} object - the object. * @return {Object} the final object. */ addObjectToWorld(object) { // if we are asked to create a local shadow object // the server copy may already have arrived. if (Number(object.id) >= this.options.clientIDSpace) { let serverCopyArrived = false; this.world.forEachObject((id, o) => { if (o.hasOwnProperty('inputId') && o.inputId === object.inputId) { serverCopyArrived = true; return false; } }); if (serverCopyArrived) { this.trace.info(() => `========== shadow object NOT added ${object.toString()} ==========`); return null; } } this.world.addObject(object); // tell the object to join the game, by creating // its corresponding physical entities and renderer entities. if (typeof object.onAddToWorld === 'function') object.onAddToWorld(this); this.emit('objectAdded', object); this.trace.info(() => `========== object added ${object.toString()} ==========`); return object; } /** * Override this function to implement input handling. * This method will be called on the specific client where the * input was received, and will also be called on the server * when the input reaches the server. The client does not call this * method directly, rather the client calls {@link ClientEngine#sendInput} * so that the input is sent to both server and client, and so that * the input is delayed artificially if so configured. * * The input is described by a short string, and is given an index. * The index is used internally to keep track of inputs which have already been applied * on the client during synchronization. The input is also associated with * the ID of a player. * * @param {Object} inputDesc - input descriptor object * @param {String} inputDesc.input - describe the input (e.g. "up", "down", "fire") * @param {Number} inputDesc.messageIndex - input identifier * @param {Number} inputDesc.step - the step on which this input occurred * @param {Number} playerId - the player ID * @param {Boolean} isServer - indicate if this function is being called on the server side */ processInput(inputDesc, playerId, isServer) { this.trace.info(() => `game engine processing input[${inputDesc.messageIndex}] <${inputDesc.input}> from playerId ${playerId}`); } /** * Remove an object from the game world. * * @param {Object|String} objectId - the object or object ID */ removeObjectFromWorld(objectId) { if (typeof objectId === 'object') objectId = objectId.id; let object = this.world.objects[objectId]; if (!object) { throw new Error(`Game attempted to remove a game object which doesn't (or never did) exist, id=${objectId}`); } this.trace.info(() => `========== destroying object ${object.toString()} ==========`); if (typeof object.onRemoveFromWorld === 'function') object.onRemoveFromWorld(this); this.emit('objectDestroyed', object); this.world.removeObject(objectId); } /** * 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); } /** * Register Game Object Classes * * @example * registerClasses(serializer) { * serializer.registerClass(Paddle); * serializer.registerClass(Ball); * } * * @param {Serializer} serializer - the serializer */ registerClasses(serializer) { } /** * Decide whether the player game is over by returning an Object, need to be implemented * * @return {Object} truthful if the game is over for the player and the object is returned as GameOver data */ getPlayerGameOverResult() { return null; } } // The base Physics Engine class defines the expected interface // for all physics engines class PhysicsEngine { constructor(options) { this.options = options; this.gameEngine = options.gameEngine; if (!options.gameEngine) { console.warn('Physics engine initialized without gameEngine!'); } } /** * A single Physics step. * * @param {Number} dt - time elapsed since last step * @param {Function} objectFilter - a test function which filters which objects should move */ step(dt, objectFilter) {} } class Utils { static hashStr(str, bits) { let hash = 5381; let i = str.length; bits = bits ? bits : 8; while (i) { hash = (hash * 33) ^ str.charCodeAt(--i); } hash = hash >>> 0; hash = hash % (Math.pow(2, bits) - 1); // JavaScript does bitwise operations (like XOR, above) on 32-bit signed // integers. Since we want the results to be always positive, convert the // signed int to an unsigned by doing an unsigned bitshift. */ return hash; } static arrayBuffersEqual(buf1, buf2) { if (buf1.byteLength !== buf2.byteLength) return false; let dv1 = new Int8Array(buf1); let dv2 = new Int8Array(buf2); for (let i = 0; i !== buf1.byteLength; i++) { if (dv1[i] !== dv2[i]) return false; } return true; } static httpGetPromise(url) { return new Promise((resolve, reject) => { let req = new XMLHttpRequest(); req.open('GET', url, true); req.onload = () => { if (req.status >= 200 && req.status < 400) resolve(JSON.parse(req.responseText)); else reject(); }; req.onerror = () => {}; req.send(); }); } } /** * The BaseTypes class defines the base types used in Lance. * These are the types which can be used to define an object's netscheme attributes, * which can be serialized by lance. * @example * static get netScheme() { * return { * strength: { type: BaseTypes.TYPES.FLOAT32 }, * shield: { type: BaseTypes.TYPES.INT8 }, * name: { type: BaseTypes.TYPES.STRING }, * backpack: { type: BaseTypes.TYPES.CLASSINSTANCE }, * coins: { * type: BaseTypes.TYPES.LIST, * itemType: BaseTypes.TYPES.UINT8 * } * }; * } */ class BaseTypes {} /** * @type {object} * @property {string} FLOAT32 Seriablizable float * @property {string} INT32 Seriablizable 32-bit integer * @property {string} INT16 Seriablizable 16-bit integer * @property {string} INT8 Seriablizable 8-bit integer * @property {string} UINT8 Seriablizable unsigned 8-bit integer * @property {string} STRING Seriablizable string * @property {string} CLASSINSTANCE Seriablizable class. Make sure you register all the classes included in this way. * @property {string} LIST Seriablizable list. In the netScheme definition, if an attribute is defined as a list, the itemType should also be defined. */ BaseTypes.TYPES = { /** * Seriablizable float * @alias TYPES.FLOAT32 * @memberof! BaseTypes# */ FLOAT32: 'FLOAT32', /** * Seriablizable 32-bit int * @alias TYPES.INT32 * @memberof! BaseTypes# */ INT32: 'INT32', /** * Seriablizable 16-bit int * @alias TYPES.INT16 * @memberof! BaseTypes# */ INT16: 'INT16', /** * Seriablizable 8-bit int * @alias TYPES.INT8 * @memberof! BaseTypes# */ INT8: 'INT8', /** * Seriablizable unsigned 8-bit int * @alias TYPES.UINT8 * @memberof! BaseTypes# */ UINT8: 'UINT8', /** * Seriablizable string * @alias TYPES.STRING * @memberof! BaseTypes# */ STRING: 'STRING', /** * Seriablizable class. Make sure you registered the classes included in this way. * @alias TYPES.CLASSINSTANCE * @memberof! BaseTypes# */ CLASSINSTANCE: 'CLASSINSTANCE', /** * Seriablizable list. * @alias TYPES.LIST * @memberof! BaseTypes# */ LIST: 'LIST' }; class Serializable { /** * Class can be serialized using either: * - a class based netScheme * - an instance based netScheme * - completely dynamically (not implemented yet) * * @param {Object} serializer - Serializer instance * @param {Object} [options] - Options object * @param {Object} options.dataBuffer [optional] - Data buffer to write to. If null a new data buffer will be created * @param {Number} options.bufferOffset [optional] - The buffer data offset to start writing at. Default: 0 * @param {String} options.dry [optional] - Does not actually write to the buffer (useful to gather serializeable size) * @return {Object} the serialized object. Contains attributes: dataBuffer - buffer which contains the serialized data; bufferOffset - offset where the serialized data starts. */ serialize(serializer, options) { options = Object.assign({ bufferOffset: 0 }, options); let netScheme; let dataBuffer; let dataView; let classId = 0; let bufferOffset = options.bufferOffset; let localBufferOffset = 0; // used for counting the bufferOffset // instance classId if (this.classId) { classId = this.classId; } else { classId = Utils.hashStr(this.constructor.name); } // instance netScheme if (this.netScheme) { netScheme = this.netScheme; } else if (this.constructor.netScheme) { netScheme = this.constructor.netScheme; } else { // todo define behaviour when a netScheme is undefined console.warn('no netScheme defined! This will result in awful performance'); } // TODO: currently we serialize every node twice, once to calculate the size // of the buffers and once to write them out. This can be reduced to // a single pass by starting with a large (and static) ArrayBuffer and // recursively building it up. // buffer has one Uint8Array for class id, then payload if (options.dataBuffer == null && options.dry != true) { let bufferSize = this.serialize(serializer, { dry: true }).bufferOffset; dataBuffer = new ArrayBuffer(bufferSize); } else { dataBuffer = options.dataBuffer; } if (options.dry != true) { dataView = new DataView(dataBuffer); // first set the id of the class, so that the deserializer can fetch information about it dataView.setUint8(bufferOffset + localBufferOffset, classId); } // advance the offset counter localBufferOffset += Uint8Array.BYTES_PER_ELEMENT; if (netScheme) { for (let property of Object.keys(netScheme).sort()) { // write the property to buffer if (options.dry != true) { serializer.writeDataView(dataView, this[property], bufferOffset + localBufferOffset, netScheme[property]); } if (netScheme[property].type === BaseTypes.TYPES.STRING) { // derive the size of the string localBufferOffset += Uint16Array.BYTES_PER_ELEMENT; if (this[property] !== null && this[property] !== undefined) localBufferOffset += this[property].length * Uint16Array.BYTES_PER_ELEMENT; } else if (netScheme[property].type === BaseTypes.TYPES.CLASSINSTANCE) { // derive the size of the included class let objectInstanceBufferOffset = this[property].serialize(serializer, { dry: true }).bufferOffset; localBufferOffset += objectInstanceBufferOffset; } else if (netScheme[property].type === BaseTypes.TYPES.LIST) { // derive the size of the list // list starts with number of elements localBufferOffset += Uint16Array.BYTES_PER_ELEMENT; for (let item of this[property]) { // todo inelegant, currently doesn't support list of lists if (netScheme[property].itemType === BaseTypes.TYPES.CLASSINSTANCE) { let listBufferOffset = item.serialize(serializer, { dry: true }).bufferOffset; localBufferOffset += listBufferOffset; } else if (netScheme[property].itemType === BaseTypes.TYPES.STRING) { // size includes string length plus double-byte characters localBufferOffset += Uint16Array.BYTES_PER_ELEMENT * (1 + item.length); } else { localBufferOffset += serializer.getTypeByteSize(netScheme[property].itemType); } } } else { // advance offset localBufferOffset += serializer.getTypeByteSize(netScheme[property].type); } } } return { dataBuffer, bufferOffset: localBufferOffset }; } // build a clone of this object with pruned strings (if necessary) prunedStringsClone(serializer, prevObject) { if (!prevObject) return this; prevObject = serializer.deserialize(prevObject).obj; // get list of string properties which changed let netScheme = this.constructor.netScheme; let isString = p => netScheme[p].type === BaseTypes.TYPES.STRING; let hasChanged = p => prevObject[p] !== this[p]; let changedStrings = Object.keys(netScheme).filter(isString).filter(hasChanged); if (changedStrings.length == 0) return this; // build a clone with pruned strings let prunedCopy = new this.constructor(null, { id: null }); for (let p of Object.keys(netScheme)) prunedCopy[p] = changedStrings.indexOf(p) < 0 ? this[p] : null; return prunedCopy; } syncTo(other) { let netScheme = this.constructor.netScheme; for (let p of Object.keys(netScheme)) { // ignore classes and lists if (netScheme[p].type === BaseTypes.TYPES.LIST || netScheme[p].type === BaseTypes.TYPES.CLASSINSTANCE) continue; // strings might be pruned if (netScheme[p].type === BaseTypes.TYPES.STRING) { if (typeof other[p] === 'string') this[p] = other[p]; continue; } // all other values are copied this[p] = other[p]; } } } /** * A TwoVector is a geometric object which is completely described * by two values. */ class TwoVector extends Serializable { static get netScheme() { return { x: { type: BaseTypes.TYPES.FLOAT32 }, y: { type: BaseTypes.TYPES.FLOAT32 } }; } /** * Creates an instance of a TwoVector. * @param {Number} x - first value * @param {Number} y - second value * @return {TwoVector} v - the new TwoVector */ constructor(x, y) { super(); this.x = x; this.y = y; return this; } /** * Formatted textual description of the TwoVector. * @return {String} description */ toString() { function round3(x) { return Math.round(x * 1000) / 1000; } return `[${round3(this.x)}, ${round3(this.y)}]`; } /** * Set TwoVector values * * @param {Number} x x-value * @param {Number} y y-value * @return {TwoVector} returns self */ set(x, y) { this.x = x; this.y = y; return this; } multiply(other) { this.x *= other.x; this.y *= other.y; return this; } /** * Multiply this TwoVector by a scalar * * @param {Number} s the scale * @return {TwoVector} returns self */ multiplyScalar(s) { this.x *= s; this.y *= s; return this; } /** * Add other vector to this vector * * @param {TwoVector} other the other vector * @return {TwoVector} returns self */ add(other) { this.x += other.x; this.y += other.y; return this; } /** * Subtract other vector to this vector * * @param {TwoVector} other the other vector * @return {TwoVector} returns self */ subtract(other) { this.x -= other.x; this.y -= other.y; return this; } /** * Get vector length * * @return {Number} length of this vector */ length() { return Math.sqrt(this.x * this.x + this.y * this.y); } /** * Normalize this vector, in-place * * @return {TwoVector} returns self */ normalize() { this.multiplyScalar(1 / this.length()); return this; } /** * Copy values from another TwoVector into this TwoVector * * @param {TwoVector} sourceObj the other vector * @return {TwoVector} returns self */ copy(sourceObj) { this.x = sourceObj.x; this.y = sourceObj.y; return this; } /** * Create a clone of this vector * * @return {TwoVector} returns clone */ clone() { return new TwoVector(this.x, this.y); } /** * Apply in-place lerp (linear interpolation) to this TwoVector * towards another TwoVector * @param {TwoVector} target the target vector * @param {Number} p The percentage to interpolate * @return {TwoVector} returns self */ lerp(target, p) { this.x += (target.x - this.x) * p; this.y += (target.y - this.y) * p; return this; } /** * Get bending Delta Vector * towards another TwoVector * @param {TwoVector} target the target vector * @param {Object} options bending options * @param {Number} options.increments number of increments * @param {Number} options.percent The percentage to bend * @param {Number} options.min No less than this value * @param {Number} options.max No more than this value * @return {TwoVector} returns new Incremental Vector */ getBendingDelta(target, options) { let increment = target.clone(); increment.subtract(this); increment.multiplyScalar(options.percent); // check for max case if (((typeof options.max === 'number') && increment.length() > options.max) || ((typeof options.min === 'number') && increment.length() < options.min)) { return new TwoVector(0, 0); } // divide into increments increment.multiplyScalar(1 / options.increments); return increment; } } // Hierarchical Spatial Hash Grid: HSHG // source: https://gist.github.com/kirbysayshi/1760774 // --------------------------------------------------------------------- // GLOBAL FUNCTIONS // --------------------------------------------------------------------- /** * Updates every object's position in the grid, but only if * the hash value for that object has changed. * This method DOES NOT take into account object expansion or * contraction, just position, and does not attempt to change * the grid the object is currently in; it only (possibly) changes * the cell. * * If the object has significantly changed in size, the best bet is to * call removeObject() and addObject() sequentially, outside of the * normal update cycle of HSHG. * * @return void desc */ function update_RECOMPUTE() { var i, obj, grid, meta, objAABB, newObjHash; // for each object for (i = 0; i < this._globalObjects.length; i++) { obj = this._globalObjects[i]; meta = obj.HSHG; grid = meta.grid; // recompute hash objAABB = obj.getAABB(); newObjHash = grid.toHash(objAABB.min[0], objAABB.min[1]); if (newObjHash !== meta.hash) { // grid position has changed, update! grid.removeObject(obj); grid.addObject(obj, newObjHash); } } } // not implemented yet :) function update_REMOVEALL() { } function testAABBOverlap(objA, objB) { var a = objA.getAABB(), b = objB.getAABB(); // if(a.min[0] > b.max[0] || a.min[1] > b.max[1] || a.min[2] > b.max[2] // || a.max[0] < b.min[0] || a.max[1] < b.min[1] || a.max[2] < b.min[2]){ if (a.min[0] > b.max[0] || a.min[1] > b.max[1] || a.max[0] < b.min[0] || a.max[1] < b.min[1]) { return false; } return true; } function getLongestAABBEdge(min, max) { return Math.max( Math.abs(max[0] - min[0]) , Math.abs(max[1] - min[1]) // ,Math.abs(max[2] - min[2]) ); } // --------------------------------------------------------------------- // ENTITIES // --------------------------------------------------------------------- function HSHG() { this.MAX_OBJECT_CELL_DENSITY = 1 / 8; // objects / cells this.INITIAL_GRID_LENGTH = 256; // 16x16 this.HIERARCHY_FACTOR = 2; this.HIERARCHY_FACTOR_SQRT = Math.SQRT2; this.UPDATE_METHOD = update_RECOMPUTE; // or update_REMOVEALL this._grids = []; this._globalObjects = []; } // HSHG.prototype.init = function(){ // this._grids = []; // this._globalObjects = []; // } HSHG.prototype.addObject = function (obj) { var x, i, cellSize, objAABB = obj.getAABB(), objSize = getLongestAABBEdge(objAABB.min, objAABB.max), oneGrid, newGrid; // for HSHG metadata obj.HSHG = { globalObjectsIndex: this._globalObjects.length }; // add to global object array this._globalObjects.push(obj); if (this._grids.length == 0) { // no grids exist yet cellSize = objSize * this.HIERARCHY_FACTOR_SQRT; newGrid = new Grid(cellSize, this.INITIAL_GRID_LENGTH, this); newGrid.initCells(); newGrid.addObject(obj); this._grids.push(newGrid); } else { x = 0; // grids are sorted by cellSize, smallest to largest for (i = 0; i < this._grids.length; i++) { oneGrid = this._grids[i]; x = oneGrid.cellSize; if (objSize < x) { x /= this.HIERARCHY_FACTOR; if (objSize