import * as I from 'immutable'; import D from 'debug'; import without from 'lodash.without'; import {compose, EventEmitter, fastApply} from '@avocado/core'; import {Resource} from '@avocado/resource'; import {Synchronized} from '@avocado/state'; import {hasTrait, lookupTrait} from './trait/registry'; const debug = D('@avocado:entity:traits'); function copyTraitProperties(from, to, instance) { do { Object.getOwnPropertyNames(from).forEach((property) => { // Nooope. switch (property) { case 'state': return; } // Make sure it's actually a property. let descriptor = Object.getOwnPropertyDescriptor(from, property); if (!descriptor.get && !descriptor.set) { return; } // Bind properties to trait instance. if (descriptor.get) { descriptor.get = descriptor.get.bind(instance); } if (descriptor.set) { descriptor.set = descriptor.set.bind(instance); } Object.defineProperty(to, property, descriptor); }); } while (Object.prototype !== (from = Object.getPrototypeOf(from))); } function enumerateTraitProperties(prototype) { const keys = []; do { Object.getOwnPropertyNames(prototype).forEach((property) => { let descriptor = Object.getOwnPropertyDescriptor(prototype, property); if (!descriptor.get && !descriptor.set) { return; } keys.push(property); }); } while (Object.prototype !== (prototype = Object.getPrototypeOf(prototype))); return keys; } const decorate = compose( EventEmitter, Synchronized, ); export class Entity extends decorate(Resource) { constructor(json) { super(); this._hooks = {}; this.isDirty = true; this._traits = {}; this._traitsFlat = []; this._traitsToTick = []; this._traitsToRenderTick = []; this._traitsAcceptingPackets = []; this.once('destroyed', () => { this.removeAllTraits(); }); this.initializeSynchronizedChildren(); if ('undefined' !== typeof json) { this.fromJSON(json); } } 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. copyTraitProperties(Trait.prototype, this, instance); // Let the Trait do its initialization. instance.initialize(); // 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(), }); } // Add state. this.state = this._setInstanceState(this.state, type, instance); // Track trait. this._traits[type] = instance; this._traitsFlat.push(instance); if ('tick' in instance) { this._traitsToTick.push(instance); } if ('renderTick' in instance) { this._traitsToRenderTick.push(instance); } 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() { const promises = []; for (let i = 0; i < this._traitsFlat.length; i++) { const instance = this._traitsFlat[i]; promises.push(instance.hydrate()); } return Promise.all(promises); } 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; } patchStateStep(type, step) { let instance = this._traits[type]; // New trait requested? if (!this._traits[type]) { // Doesn't exist? if (!hasTrait(type)) { return; } this.addTrait(type, step.value); instance = this._traits[type]; } else { // Accept state. instance.patchState([step]); } this.state = this._setInstanceState(this.state, type, instance); } renderTick(elapsed) { for (let i = 0; i < this._traitsToRenderTick.length; i++) { const instance = this._traitsToRenderTick[i]; instance.renderTick(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) { delete this._hooks[key]; } const Trait = lookupTrait(type); const properties = enumerateTraitProperties(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 state. this.state = this.state.delete(type); // Remove instance. delete this._traits[type]; this._traitsFlat.splice(this._traitsFlat.indexOf(instance), 1); const tickIndex = this._traitsToTick.indexOf(instance); if (-1 !== tickIndex) { this._traitsToTick.splice(tickIndex, 1); } const renderTickIndex = this._traitsToRenderTick.indexOf(instance); if (-1 !== renderTickIndex) { this._traitsToRenderTick.splice(renderTickIndex, 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)); } _setInstanceState(state, type, instance) { return state.setIn( [type, 'state'], instance.state ).setIn( [type, 'params'], instance.params ); } tick(elapsed) { const traitsToTick = this._traitsToTick.slice(0); for (let i = 0; i < traitsToTick.length; i++) { const instance = traitsToTick[i]; // If .destroy is called immediately when ticking a trait, then the // entity will go away. if (instance.entity) { instance.tick(elapsed); } } if (AVOCADO_SERVER) { this.tickMutateState(); } } tickMutateState() { this.state = this.state.withMutations((state) => { for (let i = 0; i < this._traitsFlat.length; i++) { const instance = this._traitsFlat[i]; if (!instance.isDirty) { continue; } instance.isDirty = false; if (this.is('listed')) { this.list.markEntityDirty(this); } this._setInstanceState(state, instance.constructor.type(), instance); } }); } toJSON() { const json = {}; for (const type in this._traits) { json[type] = this._traits[type].toJSON(); } return { ...super.toJSON(), traits: json, }; } } export {EntityPacket} from './entity.packet'; export {EntityList, EntityListView} from './list'; export {EntityPacketSynchronizer} from './packet-synchronizer'; export { hasTrait, lookupTrait, registerTrait, } from './trait/registry'; export {StateProperty, Trait} from './trait';