var ANode = require('./a-node'); var COMPONENTS = require('./component').components; var registerElement = require('./a-register-element').registerElement; var THREE = require('../lib/three'); var utils = require('../utils/'); var AEntity; var debug = utils.debug('core:a-entity:debug'); var warn = utils.debug('core:a-entity:warn'); var MULTIPLE_COMPONENT_DELIMITER = '__'; var OBJECT3D_COMPONENTS = ['position', 'rotation', 'scale', 'visible']; var ONCE = {once: true}; /** * Entity is a container object that components are plugged into to comprise everything in * the scene. In A-Frame, they inherently have position, rotation, and scale. * * To be able to take components, the scene element inherits from the entity definition. * * @member {object} components - entity's currently initialized components. * @member {object} object3D - three.js object. * @member {array} states * @member {boolean} isPlaying - false if dynamic behavior of the entity is paused. */ var proto = Object.create(ANode.prototype, { createdCallback: { value: function () { this.components = {}; // To avoid double initializations and infinite loops. this.initializingComponents = {}; this.componentsToUpdate = {}; this.isEntity = true; this.isPlaying = false; this.object3D = new THREE.Group(); this.object3D.el = this; this.object3DMap = {}; this.parentEl = null; this.rotationObj = {}; this.states = []; } }, /** * Handle changes coming from the browser DOM inspector. */ attributeChangedCallback: { value: function (attr, oldVal, newVal) { var component = this.components[attr]; // If the empty string is passed by the component initialization // logic we ignore the component update. if (component && component.justInitialized && newVal === '') { delete component.justInitialized; return; } // When a component is removed after calling el.removeAttribute('material') if (!component && newVal === null) { return; } this.setEntityAttribute(attr, oldVal, newVal); } }, /** * Add to parent, load, play. */ attachedCallback: { value: function () { var assetsEl; // Asset management system element. var sceneEl = this.sceneEl; var self = this; // Component. this.addToParent(); // Don't .load() scene on attachedCallback. if (this.isScene) { return; } // Gracefully not error when outside of <a-scene> (e.g., tests). if (!sceneEl) { this.load(); return; } // Wait for asset management system to finish before loading. assetsEl = sceneEl.querySelector('a-assets'); if (assetsEl && !assetsEl.hasLoaded) { assetsEl.addEventListener('loaded', function () { self.load(); }); return; } this.load(); } }, /** * Tell parent to remove this element's object3D from its object3D. * Do not call on scene element because that will cause a call to document.body.remove(). */ detachedCallback: { value: function () { var componentName; if (!this.parentEl) { return; } // Remove components. for (componentName in this.components) { this.removeComponent(componentName, false); } if (this.isScene) { return; } this.removeFromParent(); ANode.prototype.detachedCallback.call(this); // Remove cyclic reference. this.object3D.el = null; } }, getObject3D: { value: function (type) { return this.object3DMap[type]; } }, /** * Set a THREE.Object3D into the map. * * @param {string} type - Developer-set name of the type of object, will be unique per type. * @param {object} obj - A THREE.Object3D. */ setObject3D: { value: function (type, obj) { var oldObj; var self = this; if (!(obj instanceof THREE.Object3D)) { throw new Error( '`Entity.setObject3D` was called with an object that was not an instance of ' + 'THREE.Object3D.' ); } // Remove existing object of the type. oldObj = this.getObject3D(type); if (oldObj) { this.object3D.remove(oldObj); } // Set references to A-Frame entity. obj.el = this; if (obj.children.length) { obj.traverse(function bindEl (child) { child.el = self; }); } // Add. this.object3D.add(obj); this.object3DMap[type] = obj; this.emit('object3dset', {object: obj, type: type}); } }, /** * Remove object from scene and entity object3D map. */ removeObject3D: { value: function (type) { var obj = this.getObject3D(type); if (!obj) { warn('Tried to remove `Object3D` of type:', type, 'which was not defined.'); return; } this.object3D.remove(obj); delete this.object3DMap[type]; this.emit('object3dremove', {type: type}); } }, /** * Gets or creates an object3D of a given type. * * @param {string} type - Type of the object3D. * @param {string} Constructor - Constructor to use to create the object3D if needed. * @returns {object} */ getOrCreateObject3D: { value: function (type, Constructor) { var object3D = this.getObject3D(type); if (!object3D && Constructor) { object3D = new Constructor(); this.setObject3D(type, object3D); } warn('`getOrCreateObject3D` has been deprecated. Use `setObject3D()` ' + 'and `object3dset` event instead.'); return object3D; } }, /** * Add child entity. * * @param {Element} el - Child entity. */ add: { value: function (el) { if (!el.object3D) { throw new Error("Trying to add an element that doesn't have an `object3D`"); } this.object3D.add(el.object3D); this.emit('child-attached', {el: el}); } }, /** * Tell parentNode to add this entity to itself. */ addToParent: { value: function () { var parentNode = this.parentEl = this.parentNode; // `!parentNode` check primarily for unit tests. if (!parentNode || !parentNode.add || this.attachedToParent) { return; } parentNode.add(this); this.attachedToParent = true; // To prevent multiple attachments to same parent. } }, /** * Tell parentNode to remove this entity from itself. */ removeFromParent: { value: function () { var parentEl = this.parentEl; this.parentEl.remove(this); this.attachedToParent = false; this.parentEl = null; parentEl.emit('child-detached', {el: this}); } }, load: { value: function () { var self = this; if (this.hasLoaded || !this.parentEl) { return; } ANode.prototype.load.call(this, function entityLoadCallback () { // Check if entity was detached while it was waiting to load. if (!self.parentEl) { return; } self.updateComponents(); if (self.isScene || self.parentEl.isPlaying) { self.play(); } }); }, writable: window.debug }, /** * Remove child entity. * * @param {Element} el - Child entity. */ remove: { value: function (el) { if (el) { this.object3D.remove(el.object3D); } else { this.parentNode.removeChild(this); } } }, /** * @returns {array} Direct children that are entities. */ getChildEntities: { value: function () { var children = this.children; var childEntities = []; for (var i = 0; i < children.length; i++) { var child = children[i]; if (child instanceof AEntity) { childEntities.push(child); } } return childEntities; } }, /** * Initialize component. * * @param {string} attrName - Attribute name asociated to the component. * @param {object} data - Component data * @param {boolean} isDependency - True if the component is a dependency. */ initComponent: { value: function (attrName, data, isDependency) { var component; var componentId; var componentInfo; var componentName; var isComponentDefined; componentInfo = utils.split(attrName, MULTIPLE_COMPONENT_DELIMITER); componentName = componentInfo[0]; componentId = componentInfo.length > 2 ? componentInfo.slice(1).join('__') : componentInfo[1]; // Not a registered component. if (!COMPONENTS[componentName]) { return; } // Component is not a dependency and is undefined. // If a component is a dependency, then it is okay to have no data. isComponentDefined = checkComponentDefined(this, attrName) || data !== undefined; if (!isComponentDefined && !isDependency) { return; } // Component already initialized. if (attrName in this.components) { return; } // Initialize dependencies first this.initComponentDependencies(componentName); // If component name has an id we check component type multiplic if (componentId && !COMPONENTS[componentName].multiple) { throw new Error('Trying to initialize multiple ' + 'components of type `' + componentName + '`. There can only be one component of this type per entity.'); } component = new COMPONENTS[componentName].Component(this, data, componentId); if (this.isPlaying) { component.play(); } // Components are reflected in the DOM as attributes but the state is not shown // hence we set the attribute to empty string. // The flag justInitialized is for attributeChangedCallback to not overwrite // the component with the empty string. if (!this.hasAttribute(attrName)) { component.justInitialized = true; window.HTMLElement.prototype.setAttribute.call(this, attrName, ''); } debug('Component initialized: %s', attrName); }, writable: window.debug }, /** * Initialize dependencies of a component. * * @param {string} name - Root component name. */ initComponentDependencies: { value: function (name) { var self = this; var component = COMPONENTS[name]; var dependencies; var i; // Not a component. if (!component) { return; } // No dependencies. dependencies = COMPONENTS[name].dependencies; if (!dependencies) { return; } // Initialize dependencies. for (i = 0; i < dependencies.length; i++) { // Call getAttribute to initialize the data from the DOM. self.initComponent( dependencies[i], window.HTMLElement.prototype.getAttribute.call(self, dependencies[i]) || undefined, true ); } } }, removeComponent: { value: function (name, destroy) { var component; component = this.components[name]; if (!component) { return; } // Wait for component to initialize. if (!component.initialized) { this.addEventListener('componentinitialized', function tryRemoveLater (evt) { if (evt.detail.name !== name) { return; } this.removeComponent(name, destroy); this.removeEventListener('componentinitialized', tryRemoveLater); }); return; } component.pause(); component.remove(); // Keep component attached to entity in case of just full entity detach. if (destroy) { component.destroy(); delete this.components[name]; } this.emit('componentremoved', component.evtDetail, false); }, writable: window.debug }, /** * Initialize or update all components. * Build data using initial components, defined attributes, mixins, and defaults. * Update default components before the rest. * * @member {function} getExtraComponents - Can be implemented to include component data * from other sources (e.g., implemented by primitives). */ updateComponents: { value: function () { var data; var extraComponents; var i; var name; var componentsToUpdate = this.componentsToUpdate; if (!this.hasLoaded) { return; } // Gather mixin-defined components. for (i = 0; i < this.mixinEls.length; i++) { for (name in this.mixinEls[i].componentCache) { if (isComponent(name)) { componentsToUpdate[name] = true; } } } // Gather from extra initial component data if defined (e.g., primitives). if (this.getExtraComponents) { extraComponents = this.getExtraComponents(); for (name in extraComponents) { if (isComponent(name)) { componentsToUpdate[name] = true; } } } // Gather entity-defined components. for (i = 0; i < this.attributes.length; ++i) { name = this.attributes[i].name; if (OBJECT3D_COMPONENTS.indexOf(name) !== -1) { continue; } if (isComponent(name)) { componentsToUpdate[name] = true; } } // object3D components first (position, rotation, scale, visible). for (i = 0; i < OBJECT3D_COMPONENTS.length; i++) { name = OBJECT3D_COMPONENTS[i]; if (!this.hasAttribute(name)) { continue; } this.updateComponent(name, this.getDOMAttribute(name)); } // Initialize or update rest of components. for (name in componentsToUpdate) { data = mergeComponentData(this.getDOMAttribute(name), extraComponents && extraComponents[name]); this.updateComponent(name, data); delete componentsToUpdate[name]; } }, writable: window.debug }, /** * Initialize, update, or remove a single component. * * When initializing, we set the component on `this.components`. * * @param {string} attr - Component name. * @param {object} attrValue - Value of the DOM attribute. * @param {boolean} clobber - If new attrValue completely replaces previous properties. */ updateComponent: { value: function (attr, attrValue, clobber) { var component = this.components[attr]; if (component) { // Remove component. if (attrValue === null && !checkComponentDefined(this, attr)) { this.removeComponent(attr, true); return; } // Component already initialized. Update component. component.updateProperties(attrValue, clobber); return; } // Component not yet initialized. Initialize component. this.initComponent(attr, attrValue, false); } }, /** * If `attr` is a component name, detach the component from the entity. * * If `propertyName` is given, reset the component property value to its default. * * @param {string} attr - Attribute name, which could also be a component name. * @param {string} propertyName - Component prop name, if resetting an individual prop. */ removeAttribute: { value: function (attr, propertyName) { var component = this.components[attr]; // Remove component. if (component && propertyName === undefined) { this.removeComponent(attr, true); } // Reset component property value. if (component && propertyName !== undefined) { component.resetProperty(propertyName); return; } // Remove mixins. if (attr === 'mixin') { this.mixinUpdate(''); } window.HTMLElement.prototype.removeAttribute.call(this, attr); } }, /** * Start dynamic behavior associated with entity such as dynamic components and animations. * Tell all children entities to also play. */ play: { value: function () { var entities; var i; var key; // Already playing. if (this.isPlaying || !this.hasLoaded) { return; } this.isPlaying = true; // Wake up all components. for (key in this.components) { this.components[key].play(); } // Tell all child entities to play. entities = this.getChildEntities(); for (i = 0; i < entities.length; i++) { entities[i].play(); } this.emit('play'); }, writable: true }, /** * Pause dynamic behavior associated with entity such as dynamic components and animations. * Tell all children entities to also pause. */ pause: { value: function () { var entities; var i; var key; if (!this.isPlaying) { return; } this.isPlaying = false; // Sleep all components. for (key in this.components) { this.components[key].pause(); } // Tell all child entities to pause. entities = this.getChildEntities(); for (i = 0; i < entities.length; i++) { entities[i].pause(); } this.emit('pause'); }, writable: true }, /** * Deals with updates on entity-specific attributes (i.e., components and mixins). * * @param {string} attr * @param {string} oldVal * @param {string|object} newVal */ setEntityAttribute: { value: function (attr, oldVal, newVal) { if (COMPONENTS[attr] || this.components[attr]) { this.updateComponent(attr, newVal); return; } if (attr === 'mixin') { // Ignore if `<a-node>` code is just updating computed mixin in the DOM. if (newVal === this.computedMixinStr) { return; } this.mixinUpdate(newVal, oldVal); } } }, /** * When mixins updated, trigger init or optimized-update of relevant components. */ mixinUpdate: { value: (function () { var componentsUpdated = []; return function (newMixins, oldMixins) { var component; var mixinEl; var mixinIds; var i; var self = this; if (!this.hasLoaded) { this.addEventListener('loaded', function () { self.mixinUpdate(newMixins, oldMixins); }, ONCE); return; } oldMixins = oldMixins || this.getAttribute('mixin'); mixinIds = this.updateMixins(newMixins, oldMixins); // Loop over current mixins. componentsUpdated.length = 0; for (i = 0; i < this.mixinEls.length; i++) { for (component in this.mixinEls[i].componentCache) { if (componentsUpdated.indexOf(component) === -1) { if (this.components[component]) { // Update. Just rebuild data. this.components[component].handleMixinUpdate(); } else { // Init. buildData will gather mixin values. this.initComponent(component, null); } componentsUpdated.push(component); } } } // Loop over old mixins to call for data rebuild. for (i = 0; i < mixinIds.oldMixinIds.length; i++) { mixinEl = document.getElementById(mixinIds.oldMixinIds[i]); if (!mixinEl) { continue; } for (component in mixinEl.componentCache) { if (componentsUpdated.indexOf(component) === -1) { if (this.components[component]) { if (this.getDOMAttribute(component)) { // Update component if explicitly defined. this.components[component].handleMixinUpdate(); } else { // Remove component if not explicitly defined. this.removeComponent(component, true); } } } } } }; })() }, /** * setAttribute can: * * 1. Set a single property of a multi-property component. * 2. Set multiple properties of a multi-property component. * 3. Replace properties of a multi-property component. * 4. Set a value for a single-property component, mixin, or normal HTML attribute. * * @param {string} attrName - Component or attribute name. * @param {*} arg1 - Can be a value, property name, CSS-style property string, or * object of properties. * @param {*|bool} arg2 - If arg1 is a property name, this should be a value. Otherwise, * it is a boolean indicating whether to clobber previous values (defaults to false). */ setAttribute: { value: (function () { var singlePropUpdate = {}; return function (attrName, arg1, arg2) { var newAttrValue; var clobber; var componentName; var delimiterIndex; var isDebugMode; var key; delimiterIndex = attrName.indexOf(MULTIPLE_COMPONENT_DELIMITER); componentName = delimiterIndex > 0 ? attrName.substring(0, delimiterIndex) : attrName; // Not a component. Normal set attribute. if (!COMPONENTS[componentName]) { if (attrName === 'mixin') { this.mixinUpdate(arg1); } ANode.prototype.setAttribute.call(this, attrName, arg1); return; } // Initialize component first if not yet initialized. if (!this.components[attrName] && this.hasAttribute(attrName)) { this.updateComponent( attrName, window.HTMLElement.prototype.getAttribute.call(this, attrName)); } // Determine new attributes from the arguments if (typeof arg2 !== 'undefined' && typeof arg1 === 'string' && arg1.length > 0 && typeof utils.styleParser.parse(arg1) === 'string') { // Update a single property of a multi-property component for (key in singlePropUpdate) { delete singlePropUpdate[key]; } newAttrValue = singlePropUpdate; newAttrValue[arg1] = arg2; clobber = false; } else { // Update with a value, object, or CSS-style property string, with the possiblity // of clobbering previous values. newAttrValue = arg1; clobber = (arg2 === true); } // Update component this.updateComponent(attrName, newAttrValue, clobber); // In debug mode, write component data up to the DOM. isDebugMode = this.sceneEl && this.sceneEl.getAttribute('debug'); if (isDebugMode) { this.components[attrName].flushToDOM(); } }; })(), writable: window.debug }, /** * Reflect component data in the DOM (as seen from the browser DOM Inspector). * * @param {bool} recursive - Also flushToDOM on the children. **/ flushToDOM: { value: function (recursive) { var components = this.components; var child; var children = this.children; var i; var key; // Flush entity's components to DOM. for (key in components) { components[key].flushToDOM(); } // Recurse. if (!recursive) { return; } for (i = 0; i < children.length; ++i) { child = children[i]; if (!child.flushToDOM) { continue; } child.flushToDOM(recursive); } } }, /** * If `attr` is a component, returns ALL component data including applied mixins and * defaults. * * If `attr` is not a component, fall back to HTML getAttribute. * * @param {string} attr * @returns {object|string} Object if component, else string. */ getAttribute: { value: function (attr) { // If component, return component data. var component; if (attr === 'position') { return this.object3D.position; } if (attr === 'rotation') { return getRotation(this); } if (attr === 'scale') { return this.object3D.scale; } if (attr === 'visible') { return this.object3D.visible; } component = this.components[attr]; if (component) { return component.data; } return window.HTMLElement.prototype.getAttribute.call(this, attr); }, writable: window.debug }, /** * If `attr` is a component, returns JUST the component data defined on the entity. * Like a partial version of `getComputedAttribute` as returned component data * does not include applied mixins or defaults. * * If `attr` is not a component, fall back to HTML getAttribute. * * @param {string} attr * @returns {object|string} Object if component, else string. */ getDOMAttribute: { value: function (attr) { // If cached value exists, return partial component data. var component = this.components[attr]; if (component) { return component.attrValue; } return window.HTMLElement.prototype.getAttribute.call(this, attr); }, writable: window.debug }, addState: { value: function (state) { if (this.is(state)) { return; } this.states.push(state); this.emit('stateadded', state); } }, removeState: { value: function (state) { var stateIndex = this.states.indexOf(state); if (stateIndex === -1) { return; } this.states.splice(stateIndex, 1); this.emit('stateremoved', state); } }, /** * Checks if the element is in a given state. e.g. el.is('alive'); * @type {string} state - Name of the state we want to check */ is: { value: function (state) { return this.states.indexOf(state) !== -1; } }, /** * Open Inspector to this entity. */ inspect: { value: function () { this.sceneEl.components.inspector.openInspector(this); } }, /** * Clean up memory and return memory to object pools. */ destroy: { value: function () { var key; if (this.parentNode) { warn('Entity can only be destroyed if detached from scenegraph.'); return; } for (key in this.components) { this.components[key].destroy(); } } } }); /** * Check if a component is *defined* for an entity, including defaults and mixins. * Does not check whether the component has been *initialized* for an entity. * * @param {string} el - Entity. * @param {string} name - Component name. * @returns {boolean} */ function checkComponentDefined (el, name) { // Check if element contains the component. if (el.components[name] && el.components[name].attrValue) { return true; } return isComponentMixedIn(name, el.mixinEls); } /** * Check if any mixins contains a component. * * @param {string} name - Component name. * @param {array} mixinEls - Array of <a-mixin>s. */ function isComponentMixedIn (name, mixinEls) { var i; var inMixin = false; for (i = 0; i < mixinEls.length; ++i) { inMixin = mixinEls[i].hasAttribute(name); if (inMixin) { break; } } return inMixin; } /** * Given entity defined value, merge in extra data if necessary. * Handle both single and multi-property components. * * @param {string} attrValue - Entity data. * @param extraData - Entity data from another source to merge in. */ function mergeComponentData (attrValue, extraData) { // Extra data not defined, just return attrValue. if (!extraData) { return attrValue; } // Merge multi-property data. if (extraData.constructor === Object) { return utils.extend(extraData, utils.styleParser.parse(attrValue || {})); } // Return data, precendence to the defined value. return attrValue || extraData; } function isComponent (componentName) { if (componentName.indexOf(MULTIPLE_COMPONENT_DELIMITER) !== -1) { componentName = utils.split(componentName, MULTIPLE_COMPONENT_DELIMITER)[0]; } if (!COMPONENTS[componentName]) { return false; } return true; } function getRotation (entityEl) { var radToDeg = THREE.Math.radToDeg; var rotation = entityEl.object3D.rotation; var rotationObj = entityEl.rotationObj; rotationObj.x = radToDeg(rotation.x); rotationObj.y = radToDeg(rotation.y); rotationObj.z = radToDeg(rotation.z); return rotationObj; } AEntity = registerElement('a-entity', {prototype: proto}); module.exports = AEntity;