Source: framework/utils/entity-reference.js

Object.assign(pc, function () {
    /**
     * @private
     * @name pc.EntityReference
     * @description Helper class used for managing component properties that represent entity references.
     * @classdesc An EntityReference can be used in scenarios where a component has one or more properties that
     * refer to entities in the scene graph. Using an EntityReference simplifies the job of dealing with the
     * presence or non-presence of the underlying entity and its components, especially when it comes to dealing
     * with the runtime addition or removal of components, and addition/removal of associated event listeners.
     *
     * <h2>Usage Scenario</h2>
     *
     * Imagine that you're creating a Checkbox component, which has a reference to an entity representing
     * the checkmark/tickmark that is rendered in the Checkbox. The reference is modelled as an entity guid
     * property on the Checkbox component, called simply 'checkmark'. We have to implement a basic piece of
     * functionality whereby when the 'checkmark' entity reference is set, the Checkbox component must toggle
     * the tint of an ImageElementComponent present on the checkmark entity to indicate whether the Checkbox
     * is currently in the active or inactive state.
     *
     * Without using an EntityReference, the Checkbox component must implement some or all of the following:
     *
     *  - Listen for its 'checkmark' property being set to a valid guid, and retrieve a reference to the
     *    entity associated with this guid whenever it changes (i.e. via app.root.findByGuid()).
     *  - Once a valid entity is received, check to see whether it has already has an ImageElementComponent
     *    or not:
     *      - If it has one, proceed to set the tint of the ImageElementComponent based on whether the Checkbox
     *        is currently active or inactive.
     *      - If it doesn't have one, add a listener to wait for the addition of an ImageElementComponent,
     *        and then apply the tint once one becomes present.
     *  - If the checkmark entity is then reassigned (such as if the user reassigns the field in the editor,
     *    or if this is done at runtime via a script), a well-behaved Checkbox component must also undo the
     *    tinting so that no lasting effect is applied to the old entity.
     *  - If the checkmark entity's ImageElementComponent is removed and then another ImageElementComponent
     *    is added, the Checkbox component must handle this in order to re-apply the tint.
     *  - To prevent memory leaks, the Checkbox component must also make sure to correctly remove listeners
     *    in each of the following scenarios:
     *      - Destruction of the Checkbox component.
     *      - Reassignment of the checkmark entity.
     *      - Removal of the ImageElementComponent.
     *  - It must also be careful not to double-add listeners in any of the above code paths, to avoid various
     *    forms of undesirable behavior.
     *
     * If the Checkbox component becomes more complicated and has multiple entity reference properties,
     * all of the above must be done correctly for each entity. Similarly, if it depends on multiple different
     * component types being present on the entities it has references to, it must correctly handle the presence
     * and non-presence of each of these components in the various possible sequences of addition and removal.
     * In addition to generating a lot of boilerplate, it's also very easy for subtle mistakes to be made that
     * lead to memory leaks, null reference errors or visual bugs.
     *
     * By using an EntityReference, all of the above can be reduced to the following:
     *
     * <code class="javascript hljs">
     * function CheckboxComponent() {
     *    this._checkmarkReference = new pc.EntityReference(this, 'checkmark', {
     *        'element#gain': this._onCheckmarkImageElementGain,
     *        'element#lose': this._onCheckmarkImageElementLose
     *    });
     * }
     * </code>
     *
     * Using the above code snippet, the <code>_onCheckmarkImageElementGain()</code> listener will be called
     * in either of the following scenarios:
     *
     *   1. A checkmark entity is assigned and already has an ElementComponent.
     *   2. A checkmark entity is assigned that does not have an ElementComponent, but one is added later.
     *
     * Similarly, the <code>_onCheckmarkImageElementLose()</code> listener will be called in either of the
     * following scenarios:
     *
     *   1. An ElementComponent is removed from the checkmark entity.
     *   2. The checkmark entity is re-assigned (i.e. to another entity), or nullified. In this scenario the
     *      callback will only be called if the entity actually had an ElementComponent.
     *
     * <h2>Event String Format</h2>
     *
     * The event string (i.e. "element#gain" in the above examples) is of the format <code>sourceName#eventName</code>,
     * and is defined as follows:
     *
     *   - <code>sourceName</code>: May be any component name, or the special string "entity", which refers
     *     to the entity itself.
     *   - <code>eventName</code>: May be the name of any event dispatched by the relevant component or
     *     entity, as well as the special strings "gain" or "lose".
     *
     * Some examples are as follows:
     *
     * <code class="javascript hljs">
     * "entity#destroy"    // Called when the entity managed by the entity reference is destroyed.
     * "element#set:width" // Called when the width of an ElementComponent is set.
     * </code>
     *
     * <h2>Ownership and Destruction</h2>
     *
     * The lifetime of an ElementReference is tied to the parent component that instantiated it. This
     * coupling is indicated by the provision of the `this` keyword to the ElementReference's constructor
     * in the above examples (i.e. <code>new pc.EntityReference(this, ...</code>).
     *
     * Any event listeners managed by the ElementReference are automatically cleaned up when the parent
     * component is removed or the parent component's entity is destroyed – as such you should never have
     * to worry about dangling listeners.
     *
     * Additionally, any callbacks listed in the event config will automatically be called in the scope
     * of the parent component – you should never have to worry about manually calling <code>Function.bind()</code>.
     *
     * @param {pc.Component} parentComponent A reference to the parent component that owns this entity reference.
     * @param {String} entityPropertyName The name of the component property that contains the entity guid.
     * @param {Object<String, Function>} [eventConfig] A map of event listener configurations.
     * @property {Entity} entity A reference to the entity, if present.
     */
    function EntityReference(parentComponent, entityPropertyName, eventConfig) {
        if (!parentComponent || !(parentComponent instanceof pc.Component)) {
            throw new Error('The parentComponent argument is required and must be a Component');
        } else if (!entityPropertyName || typeof entityPropertyName !== 'string') {
            throw new Error('The propertyName argument is required and must be a string');
        } else if (eventConfig && typeof eventConfig !== 'object') {
            throw new Error('If provided, the eventConfig argument must be an object');
        }

        this._parentComponent = parentComponent;
        this._entityPropertyName = entityPropertyName;
        this._entity = null;
        this._app = parentComponent.system.app;

        this._configureEventListeners(eventConfig || {}, {
            'entity#destroy': this._onEntityDestroy
        });
        this._toggleLifecycleListeners('on');
    }

    Object.assign(EntityReference.prototype, {
        _configureEventListeners: function (externalEventConfig, internalEventConfig) {
            var externalEventListenerConfigs = this._parseEventListenerConfig(externalEventConfig, 'external', this._parentComponent);
            var internalEventListenerConfigs = this._parseEventListenerConfig(internalEventConfig, 'internal', this);

            this._eventListenerConfigs = externalEventListenerConfigs.concat(internalEventListenerConfigs);
            this._listenerStatusFlags = {};
            this._gainListeners = {};
            this._loseListeners = {};
        },

        _parseEventListenerConfig: function (eventConfig, prefix, scope) {
            return Object.keys(eventConfig).map(function (listenerDescription, index) {
                var listenerDescriptionParts = listenerDescription.split('#');
                var sourceName = listenerDescriptionParts[0];
                var eventName = listenerDescriptionParts[1];
                var callback = eventConfig[listenerDescription];

                if (listenerDescriptionParts.length !== 2 ||
                    typeof sourceName !== 'string' || sourceName.length === 0 ||
                    typeof eventName !== 'string' || eventName.length === 0) {
                    throw new Error('Invalid event listener description: `' + listenerDescription + '`');
                }

                if (typeof callback !== 'function') {
                    throw new Error('Invalid or missing callback for event listener `' + listenerDescription + '`');
                }

                return {
                    id: prefix + '_' + index + '_' + listenerDescription,
                    sourceName: sourceName,
                    eventName: eventName,
                    callback: callback,
                    scope: scope
                };
            }, this);
        },

        _toggleLifecycleListeners: function (onOrOff) {
            this._parentComponent[onOrOff]('set_' + this._entityPropertyName, this._onSetEntity, this);
            this._parentComponent.system[onOrOff]('beforeremove', this._onParentComponentRemove, this);

            pc.ComponentSystem[onOrOff]('postinitialize', this._onPostInitialize, this);
            this._app[onOrOff]('tools:sceneloaded', this._onSceneLoaded, this);

            // For any event listeners that relate to the gain/loss of a component, register
            // listeners that will forward the add/remove component events
            var allComponentSystems = [];

            for (var i = 0; i < this._eventListenerConfigs.length; ++i) {
                var config = this._eventListenerConfigs[i];
                var componentSystem = this._app.systems[config.sourceName];

                if (componentSystem) {
                    if (allComponentSystems.indexOf(componentSystem) === -1) {
                        allComponentSystems.push(componentSystem);
                    }

                    if (componentSystem && config.eventName === 'gain') {
                        this._gainListeners[config.sourceName] = config;
                    }

                    if (componentSystem && config.eventName === 'lose') {
                        this._loseListeners[config.sourceName] = config;
                    }
                }
            }

            for (var j = 0; j < allComponentSystems.length; ++j) {
                allComponentSystems[j][onOrOff]('add', this._onComponentAdd, this);
                allComponentSystems[j][onOrOff]('beforeremove', this._onComponentRemove, this);
            }
        },

        _onSetEntity: function (name, oldValue, newValue) {
            if (newValue instanceof pc.Entity) {
                this._updateEntityReference();
            } else  {
                if (newValue !== null && newValue !== undefined && typeof newValue !== 'string') {
                    console.warn("Entity field `" + this._entityPropertyName + "` was set to unexpected type '" + (typeof newValue) + "'");
                    return;
                }

                if (oldValue !== newValue) {
                    this._updateEntityReference();
                }
            }
        },

        _onPostInitialize: function () {
            this._updateEntityReference();
        },

        /**
         * Must be called from the parent component's onEnable() method in order for entity
         * references to be correctly resolved when {@link pc.Entity#clone} is called.
         */
        onParentComponentEnable: function () {
            // When an entity is cloned via the JS API, we won't be able to resolve the
            // entity reference until the cloned entity has been added to the scene graph.
            // We can detect this by waiting for the parent component to be enabled, in the
            // specific case where we haven't yet been able to resolve an entity reference.
            if (!this._entity) {
                this._updateEntityReference();
            }
        },

        // When running within the editor, postInitialize is fired before the scene graph
        // has been fully constructed. As such we use the special tools:sceneloaded event
        // in order to know when the graph is ready to traverse.
        _onSceneLoaded: function () {
            this._updateEntityReference();
        },

        _updateEntityReference: function () {
            var nextEntityGuid = this._parentComponent.data[this._entityPropertyName];
            var nextEntity;

            if (nextEntityGuid instanceof pc.Entity) {
                // if value is set to a Entity itself replace value with the GUID
                nextEntity = nextEntityGuid;
                nextEntityGuid = nextEntity.getGuid();
                this._parentComponent.data[this._entityPropertyName] = nextEntityGuid;
            } else {
                var root = this._parentComponent.system.app.root;
                var isOnSceneGraph = this._parentComponent.entity.isDescendantOf(root);

                nextEntity = (isOnSceneGraph && nextEntityGuid) ? root.findByGuid(nextEntityGuid) : null;
            }

            var hasChanged = this._entity !== nextEntity;

            if (hasChanged) {
                if (this._entity) {
                    this._onBeforeEntityChange();
                }

                this._entity = nextEntity;

                if (this._entity) {
                    this._onAfterEntityChange();
                }
            }
        },

        _onBeforeEntityChange: function () {
            this._toggleEntityListeners('off');
            this._callAllGainOrLoseListeners(this._loseListeners);
        },

        _onAfterEntityChange: function () {
            this._toggleEntityListeners('on');
            this._callAllGainOrLoseListeners(this._gainListeners);
        },

        _onComponentAdd: function (entity, component) {
            var componentName = component.system.id;

            if (entity === this._entity) {
                this._callGainOrLoseListener(componentName, this._gainListeners);
                this._toggleComponentListeners('on', componentName);
            }
        },

        _onComponentRemove: function (entity, component) {
            var componentName = component.system.id;

            if (entity === this._entity) {
                this._callGainOrLoseListener(componentName, this._loseListeners);
                this._toggleComponentListeners('off', componentName, true);
            }
        },

        _callAllGainOrLoseListeners: function (listenerMap) {
            for (var componentName in this._entity.c) {
                this._callGainOrLoseListener(componentName, listenerMap);
            }
        },

        _callGainOrLoseListener: function (componentName, listenerMap) {
            if (this._entity.c.hasOwnProperty(componentName) && listenerMap[componentName]) {
                var config = listenerMap[componentName];
                config.callback.call(config.scope);
            }
        },

        _toggleEntityListeners: function (onOrOff, isDestroying) {
            if (this._entity) {
                for (var i = 0; i < this._eventListenerConfigs.length; ++i) {
                    this._safeToggleListener(onOrOff, this._eventListenerConfigs[i], isDestroying);
                }
            }
        },

        _toggleComponentListeners: function (onOrOff, componentName, isDestroying) {
            for (var i = 0; i < this._eventListenerConfigs.length; ++i) {
                var config = this._eventListenerConfigs[i];

                if (config.sourceName === componentName) {
                    this._safeToggleListener(onOrOff, config, isDestroying);
                }
            }
        },

        _safeToggleListener: function (onOrOff, config, isDestroying) {
            var isAdding = (onOrOff === 'on');

            // Prevent duplicate listeners
            if (isAdding && this._listenerStatusFlags[config.id]) {
                return;
            }

            var source = this._getEventSource(config.sourceName, isDestroying);

            if (source) {
                source[onOrOff](config.eventName, config.callback, config.scope);
                this._listenerStatusFlags[config.id] = isAdding;
            }
        },

        _getEventSource: function (sourceName, isDestroying) {
            // The 'entity' source name is a special case - we just want to return
            // a reference to the entity itself. For all other cases the source name
            // should refer to a component.
            if (sourceName === 'entity') {
                return this._entity;
            }

            var component = this._entity[sourceName];

            if (component) {
                return component;
            }

            if (!isDestroying) {
                console.warn('Entity has no component with name ' + sourceName);
            }

            return null;
        },

        _onEntityDestroy: function (entity) {
            if (this._entity === entity) {
                this._toggleEntityListeners('off', true);
                this._entity = null;
            }
        },

        _onParentComponentRemove: function (entity, component) {
            if (component === this._parentComponent) {
                this._toggleLifecycleListeners('off');
                this._toggleEntityListeners('off', true);
            }
        },

        /**
         * Convenience method indicating whether the entity exists and has a component of the provided type.
         *
         * @param {String} componentName Name of the component.
         * @returns {Boolean} True if the entity exists and has a component of the provided type.
         */
        hasComponent: function (componentName) {
            return (this._entity && this._entity.c) ? !!this._entity.c[componentName] : false;
        }
    });

    Object.defineProperty(EntityReference.prototype, 'entity', {
        get: function () {
            return this._entity;
        }
    });

    return {
        EntityReference: EntityReference
    };
}());