import D from 'debug'; import without from 'lodash.without'; import { compose, EventEmitter, fastApply, merge, mergeDiff, } from '@avocado/core'; import {Resource} from '@avocado/resource'; import {Synchronized} from '@avocado/state'; import {EntityCreatePacket} from './packets/entity-create.packet'; import {hasTrait, lookupTrait} from './trait/registry'; const debug = D('@avocado:entity:traits'); const blacklistedAccessorKeys = [ 'state', ]; // This really seems like a whole lot of complicated nonsense, but it's an // unfortunate consequence of V8 (and maybe others) not optimizing mutable // accessors in fast hidden classes. const traitAccessorForPropertyMap = {}; function traitAccessorForProperty(type, property) { if (!(type in traitAccessorForPropertyMap)) { traitAccessorForPropertyMap[type] = {}; } if (!(property in traitAccessorForPropertyMap[type])) { traitAccessorForPropertyMap[type][property] = { get: new Function('', ` return this._traits['${type}']['${property}']; `), set: new Function('value', ` this._traits['${type}']['${property}'] = value; `), }; } return traitAccessorForPropertyMap[type][property]; } function defineTraitAccessors(from, to, instance) { const type = instance.constructor.type(); do { Object.getOwnPropertyNames(from).forEach((accessorKey) => { if (-1 !== blacklistedAccessorKeys.indexOf(accessorKey)) { return; } let descriptor = Object.getOwnPropertyDescriptor(from, accessorKey); // Make sure it's actually an accessor. if (!descriptor.get && !descriptor.set) { return; } const accessor = traitAccessorForProperty(type, accessorKey); if (descriptor.get) { descriptor.get = accessor.get; } if (descriptor.set) { descriptor.set = accessor.set; } Object.defineProperty(to, accessorKey, descriptor); }); } while (Object.prototype !== (from = Object.getPrototypeOf(from))); } function enumerateTraitAccessorKeys(prototype) { const keys = []; do { Object.getOwnPropertyNames(prototype).forEach((accessorKey) => { if (-1 !== blacklistedAccessorKeys.indexOf(accessorKey)) { return; } let descriptor = Object.getOwnPropertyDescriptor(prototype, accessorKey); // Make sure it's actually an accessor. if (!descriptor.get && !descriptor.set) { return; } keys.push(accessorKey); }); } while (Object.prototype !== (prototype = Object.getPrototypeOf(prototype))); return keys; } const decorate = compose( EventEmitter, Synchronized, ); let numericUid = 1; export class Entity extends decorate(Resource) { static jsonWithDefaults(json) { if ('undefined' === typeof json) { return; } const pristine = JSON.parse(JSON.stringify(json)); for (const type in json.traits) { if (!hasTrait(type)) { continue; } const Trait = lookupTrait(type); pristine.traits[type] = merge({}, Trait.defaultJSON(), json.traits[type]); } return pristine; } static loadOrInstance(json) { if (json.uri) { return Entity.read(json.uri).then((entityJSON) => { return new Entity(entityJSON, json); }); } else { return Promise.resolve(new Entity(json)); } } constructor(json, jsonext) { super(); this._hooks = {}; this._hydrationPromise = undefined; this._json = Entity.jsonWithDefaults(json); this._traits = {}; this._traitsFlat = []; this._traitTickers = []; this._traitRenderTickers = []; this._traitsAcceptingPackets = []; this.once('destroyed', () => { this.removeAllTraits(); }); // Bind to prevent lookup overhead. this.tick = this.tick.bind(this); // Fast props. this.numericUid = numericUid++; this.position = [0, 0]; this.room = null; this.visibleAabb = [0, 0, 0, 0]; // Fast path for instance. if ('undefined' !== typeof json) { this.fromJSON(merge({}, json, jsonext)); } } acceptPacket(packet) { for (let i = 0; i < this._traitsAcceptingPackets.length; i++) { const instance = this._traitsAcceptingPackets[i]; instance.acceptPacket(packet); } } addTrait(type, json = {}) { if (this.is(type)) { debug(`Tried to add trait "${type}" when it already exists!`); return; } if (!hasTrait(type)) { debug(`Tried to add trait "${type}" which isn't registered!`); return; } const Trait = lookupTrait(type); // Ensure dependencies. const dependencies = Trait.dependencies(); const allTypes = this.allTraitTypes(); const lacking = without(dependencies, ...allTypes); if (lacking.length > 0) { debug( `Tried to add trait "${type}" but lack one or more dependents: "${ lacking.join('", "') }"!` ); return; } // Instantiate. const {params, state} = json; const instance = new Trait(this, params, state); // Proxy properties. defineTraitAccessors(Trait.prototype, this, instance); // Attach listeners. const listeners = instance.memoizedListeners(); for (const eventName in listeners) { this.on(eventName, listeners[eventName]); } // Proxy methods. const methods = instance.methods(); for (const key in methods) { this[key] = methods[key]; } // Register hook listeners. const hooks = instance.hooks(); for (const key in hooks) { this._hooks[key] = this._hooks[key] || []; this._hooks[key].push({ fn: hooks[key], type: Trait.type(), }); } // Track trait. this._traits[type] = instance; this._traitsFlat.push(instance); if ('tick' in instance) { this._traitTickers.push(instance.tick); } if ('renderTick' in instance) { this._traitRenderTickers.push(instance.renderTick); } if ('acceptPacket' in instance) { this._traitsAcceptingPackets.push(instance); } this.emit('traitAdded', type, instance); } addTraits(traits) { for (const type in traits) { this.addTrait(type, traits[type]); } } allTraitInstances() { return this._traits; } allTraitTypes() { return Object.keys(this._traits); } fromJSON(json) { super.fromJSON(json); this.addTraits(json.traits); return this; } hydrate() { if (!this._hydrationPromise) { const promises = []; for (let i = 0; i < this._traitsFlat.length; i++) { promises.push(this._traitsFlat[i].hydrate()); } this._hydrationPromise = Promise.all(promises); } return this._hydrationPromise; } invokeHook(hook, ...args) { const results = {}; if (!(hook in this._hooks)) { return results; } for (const {fn, type} of this._hooks[hook]) { results[type] = fastApply(null, fn, args); } return results; } invokeHookFlat(hook, ...args) { const invokeResults = this.invokeHook(hook, ...args); const results = []; for (const type in invokeResults) { results.push(invokeResults[type]); } return results; } is(type) { return type in this._traits; } mergeDiff() { if (!this.uri) { return this.toJSON(); } return mergeDiff(this._json, this.toJSON()); } packetsForUpdate(force = false) { const packets = []; if (force) { const packet = new EntityCreatePacket(this.mergeDiff(), this); packets.push(packet); } else { for (let i = 0; i < this._traitsFlat.length; i++) { const traitPackets = this._traitsFlat[i].packetsForUpdate(); for (let j = 0; j < traitPackets.length; j++) { packets.push(traitPackets[j]); } } } return packets; } renderTick(elapsed) { for (let i = 0; i < this._traitRenderTickers.length; i++) { this._traitRenderTickers[i](elapsed); } } removeAllTraits() { const types = this.allTraitTypes(); this.removeTraits(types); } removeTrait(type) { if (!this.is(type)) { debug(`Tried to remove trait "${type}" when it doesn't exist!`); return; } // Destroy instance. const instance = this._traits[type]; instance.destroy(); // Remove methods, hooks, and properties. const methods = instance.methods(); for (const key in methods) { delete this[key]; } const hooks = instance.hooks(); for (const key in hooks) { const implementation = this._hooks[key].find(({type: hookType}) => { return hookType === type; }); this._hooks[key].splice(this._hooks[key].indexOf(implementation), 1); } const Trait = lookupTrait(type); const properties = enumerateTraitAccessorKeys(Trait.prototype); for (let i = 0; i < properties.length; ++i) { const property = properties[i]; delete this[property]; } // Remove all event listeners. const listeners = instance.memoizedListeners(); for (const eventName in listeners) { this.off(eventName, listeners[eventName]); } instance._memoizedListeners = {}; // Remove instance. delete this._traits[type]; this._traitsFlat.splice(this._traitsFlat.indexOf(instance), 1); if ('tick' in instance) { this._traitTickers.splice( this._traitTickers.indexOf(instance.tick), 1 ); } if ('renderTick' in instance) { this._traitRenderTickers.splice( this._traitRenderTickers.indexOf(instance.renderTick), 1 ); } const acceptPacketIndex = this._traitsAcceptingPackets.indexOf(instance); if (-1 !== acceptPacketIndex) { this._traitsAcceptingPackets.splice(acceptPacketIndex, 1); } // Unloop. instance.entity = undefined; } removeTraits(types) { types.forEach((type) => this.removeTrait(type)); } tick(elapsed) { for (let i = 0; i < this._traitTickers.length; i++) { this._traitTickers[i](elapsed); } } toJSON() { const json = {}; for (const type in this._traits) { json[type] = this._traits[type].toJSON(); } return { ...super.toJSON(), traits: json, }; } } export {EntityCreatePacket} from './packets/entity-create.packet'; export {EntityRemovePacket} from './packets/entity-remove.packet'; export {EntityPacket} from './packets/entity.packet'; export {EntityList, EntityListView} from './list'; export { hasTrait, lookupTrait, registerTrait, } from './trait/registry'; export {StateProperty, Trait} from './trait';