@rtsdk/lance-topia
Version:
A Node.js based real-time multiplayer game server
1,611 lines (1,354 loc) • 331 kB
JavaScript
(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