aframe
Version:
A web framework for building virtual reality experiences.
804 lines (712 loc) • 26.8 kB
JavaScript
/* global Node */
var schema = require('./schema');
var scenes = require('./scene/scenes');
var systems = require('./system');
var utils = require('../utils/');
var components = module.exports.components = {}; // Keep track of registered components.
var parseProperties = schema.parseProperties;
var parseProperty = schema.parseProperty;
var processSchema = schema.process;
var isSingleProp = schema.isSingleProperty;
var stringifyProperties = schema.stringifyProperties;
var stringifyProperty = schema.stringifyProperty;
var styleParser = utils.styleParser;
var warn = utils.debug('core:component:warn');
var aframeScript = document.currentScript;
var upperCaseRegExp = new RegExp('[A-Z]+');
// Object pools by component, created upon registration.
var objectPools = {};
/**
* Component class definition.
*
* Components configure appearance, modify behavior, or add functionality to
* entities. The behavior and appearance of an entity can be changed at runtime
* by adding, removing, or updating components. Entities do not share instances
* of components.
*
* @member {object} el - Reference to the entity element.
* @member {string} attrValue - Value of the corresponding HTML attribute.
* @member {object} data - Component data populated by parsing the
* mapped attribute of the component plus applying defaults and mixins.
*/
var Component = module.exports.Component = function (el, attrValue, id) {
var self = this;
this.el = el;
this.id = id;
this.attrName = this.name + (id ? '__' + id : '');
this.evtDetail = {id: this.id, name: this.name};
this.initialized = false;
this.isSingleProperty = isSingleProp(this.schema);
this.isSinglePropertyObject = this.isSingleProperty &&
isObject(parseProperty(undefined, this.schema)) &&
!(this.schema.default instanceof window.HTMLElement);
this.isObjectBased = !this.isSingleProperty || this.isSinglePropertyObject;
this.el.components[this.attrName] = this;
this.objectPool = objectPools[this.name];
const events = this.events;
this.events = {};
eventsBind(this, events);
// Store component data from previous update call.
this.attrValue = undefined;
if (this.isObjectBased) {
this.nextData = this.objectPool.use();
// Drop any properties added by dynamic schemas in previous use
utils.objectPool.removeUnusedKeys(this.nextData, this.schema);
this.oldData = this.objectPool.use();
utils.objectPool.removeUnusedKeys(this.oldData, this.schema);
this.previousOldData = this.objectPool.use();
utils.objectPool.removeUnusedKeys(this.previousOldData, this.schema);
this.parsingAttrValue = this.objectPool.use();
utils.objectPool.removeUnusedKeys(this.parsingAttrValue, this.schema);
} else {
this.nextData = undefined;
this.oldData = undefined;
this.previousOldData = undefined;
this.parsingAttrValue = undefined;
}
// Last value passed to updateProperties.
this.throttledEmitComponentChanged = utils.throttleComponentChanged(function emitChange () {
el.emit('componentchanged', self.evtDetail, false);
}, 200);
this.updateProperties(attrValue);
};
Component.prototype = {
/**
* Contains the type schema and defaults for the data values.
* Data is coerced into the types of the values of the defaults.
*/
schema: {},
/**
* Init handler. Similar to attachedCallback.
* Called during component initialization and is only run once.
* Components can use this to set initial state.
*/
init: function () { /* no-op */ },
/**
* Map of event names to binded event handlers that will be lifecycle-handled.
* Will be detached on pause / remove.
* Will be attached on play.
*/
events: {},
/**
* Update handler. Similar to attributeChangedCallback.
* Called whenever component's data changes.
* Also called on component initialization when the component receives initial data.
*
* @param {object} prevData - Previous attributes of the component.
*/
update: function (prevData) { /* no-op */ },
updateSchema: undefined,
/**
* Tick handler.
* Called on each tick of the scene render loop.
* Affected by play and pause.
*
* @param {number} time - Scene tick time.
* @param {number} timeDelta - Difference in current render time and previous render time.
*/
tick: undefined,
/**
* Tock handler.
* Called on each tock of the scene render loop.
* Affected by play and pause.
*
* @param {number} time - Scene tick time.
* @param {number} timeDelta - Difference in current render time and previous render time.
* @param {object} camera - Camera used to render the last frame.
*/
tock: undefined,
/**
* Called to start any dynamic behavior (e.g., animation, AI, events, physics).
*/
play: function () { /* no-op */ },
/**
* Called to stop any dynamic behavior (e.g., animation, AI, events, physics).
*/
pause: function () { /* no-op */ },
/**
* Remove handler. Similar to detachedCallback.
* Called whenever component is removed from the entity (i.e., removeAttribute).
* Components can use this to reset behavior on the entity.
*/
remove: function () { /* no-op */ },
/**
* Parses each property based on property type.
* If component is single-property, then parses the single property value.
*
* @param {string} value - HTML attribute value.
* @param {boolean} silent - Suppress warning messages.
* @returns {object} Component data.
*/
parse: function (value, silent) {
var schema = this.schema;
if (this.isSingleProperty) { return parseProperty(value, schema); }
return parseProperties(styleParser.parse(value), schema, true, this.name, silent);
},
/**
* Stringify properties if necessary.
*
* Only called from `Entity.setAttribute` for properties whose parsers accept a non-string
* value (e.g., selector, vec3 property types).
*
* @param {object} data - Complete component data.
* @returns {string}
*/
stringify: function (data) {
var schema = this.schema;
if (typeof data === 'string') { return data; }
if (this.isSingleProperty) { return stringifyProperty(data, schema); }
data = stringifyProperties(data, schema);
return styleParser.stringify(data);
},
/**
* Update the cache of the pre-parsed attribute value.
*
* @param {string} value - New data.
* @param {boolean } clobber - Whether to wipe out and replace previous data.
*/
updateCachedAttrValue: function (value, clobber) {
var newAttrValue;
var tempObject;
var property;
if (value === undefined) { return; }
// If null value is the new attribute value, make the attribute value falsy.
if (value === null) {
if (this.isObjectBased && this.attrValue) {
this.objectPool.recycle(this.attrValue);
}
this.attrValue = undefined;
return;
}
if (value instanceof Object && !(value instanceof window.HTMLElement)) {
// If value is an object, copy it to our pooled newAttrValue object to use to update
// the attrValue.
tempObject = this.objectPool.use();
newAttrValue = utils.extend(tempObject, value);
} else {
newAttrValue = this.parseAttrValueForCache(value);
}
// Merge new data with previous `attrValue` if updating and not clobbering.
if (this.isObjectBased && !clobber && this.attrValue) {
for (property in this.attrValue) {
if (newAttrValue[property] === undefined) {
newAttrValue[property] = this.attrValue[property];
}
}
}
// Update attrValue.
if (this.isObjectBased && !this.attrValue) {
this.attrValue = this.objectPool.use();
}
utils.objectPool.clearObject(this.attrValue);
this.attrValue = extendProperties(this.attrValue, newAttrValue, this.isObjectBased);
utils.objectPool.clearObject(tempObject);
},
/**
* Given an HTML attribute value parses the string based on the component schema.
* To avoid double parsings of strings into strings we store the original instead
* of the parsed one
*
* @param {string} value - HTML attribute value
*/
parseAttrValueForCache: function (value) {
var parsedValue;
if (typeof value !== 'string') { return value; }
if (this.isSingleProperty) {
parsedValue = this.schema.parse(value);
/**
* To avoid bogus double parsings. Cached values will be parsed when building
* component data. For instance when parsing a src id to its url, we want to cache
* original string and not the parsed one (#monster -> models/monster.dae)
* so when building data we parse the expected value.
*/
if (typeof parsedValue === 'string') { parsedValue = value; }
} else {
// Parse using the style parser to avoid double parsing of individual properties.
utils.objectPool.clearObject(this.parsingAttrValue);
parsedValue = styleParser.parse(value, this.parsingAttrValue);
}
return parsedValue;
},
/**
* Write cached attribute data to the entity DOM element.
*
* @param {boolean} isDefault - Whether component is a default component. Always flush for
* default components.
*/
flushToDOM: function (isDefault) {
var attrValue = isDefault ? this.data : this.attrValue;
if (attrValue === null || attrValue === undefined) { return; }
window.HTMLElement.prototype.setAttribute.call(this.el, this.attrName,
this.stringify(attrValue));
},
/**
* Apply new component data if data has changed (from setAttribute).
*
* @param {string} attrValue - HTML attribute value.
* If undefined, use the cached attribute value and continue updating properties.
* @param {boolean} clobber - The previous component data is overwritten by the atrrValue
*/
updateProperties: function (attrValue, clobber) {
var el = this.el;
// Just cache the attribute if the entity has not loaded
// Components are not initialized until the entity has loaded
if (!el.hasLoaded) {
this.updateCachedAttrValue(attrValue);
return;
}
// Parse the attribute value.
// Cache current attrValue for future updates. Updates `this.attrValue`.
// `null` means no value on purpose, do not set a default value, let mixins take over.
if (attrValue !== null) {
attrValue = this.parseAttrValueForCache(attrValue);
}
// Cache current attrValue for future updates.
this.updateCachedAttrValue(attrValue, clobber);
if (this.initialized) {
this.updateComponent(attrValue, clobber);
this.callUpdateHandler();
} else {
this.initComponent();
}
},
initComponent: function () {
var el = this.el;
var initialOldData;
// Build data.
if (this.updateSchema) { this.updateSchema(this.buildData(this.attrValue, false, true)); }
this.data = this.buildData(this.attrValue);
// Component is being already initialized.
if (el.initializingComponents[this.name]) { return; }
// Prevent infinite loop in case of init method setting same component on the entity.
el.initializingComponents[this.name] = true;
// Initialize component.
this.init();
this.initialized = true;
delete el.initializingComponents[this.name];
// Store current data as previous data for future updates.
this.oldData = extendProperties(this.oldData, this.data, this.isObjectBased);
// For oldData, pass empty object to multiple-prop schemas or object single-prop schema.
// Pass undefined to rest of types.
initialOldData = this.isObjectBased ? this.objectPool.use() : undefined;
this.update(initialOldData);
if (this.isObjectBased) { this.objectPool.recycle(initialOldData); }
// Play the component if the entity is playing.
if (el.isPlaying) { this.play(); }
el.emit('componentinitialized', this.evtDetail, false);
},
/**
* @param attrValue - Passed argument from setAttribute.
*/
updateComponent: function (attrValue, clobber) {
var key;
var mayNeedSchemaUpdate;
if (clobber) {
// Clobber. Rebuild.
if (this.updateSchema) {
this.updateSchema(this.buildData(this.attrValue, true, true));
}
this.data = this.buildData(this.attrValue, true, false);
return;
}
// Apply new value to this.data in place since direct update.
if (this.isSingleProperty) {
if (this.isObjectBased) {
parseProperty(attrValue, this.schema);
}
// Single-property (already parsed).
this.data = attrValue;
return;
}
parseProperties(attrValue, this.schema, true, this.name);
// Check if we need to update schema.
if (this.schemaChangeKeys.length) {
for (key in attrValue) {
if (this.schema[key].schemaChange) {
mayNeedSchemaUpdate = true;
break;
}
}
}
if (mayNeedSchemaUpdate) {
// Rebuild data if need schema update.
if (this.updateSchema) {
this.updateSchema(this.buildData(this.attrValue, true, true));
}
this.data = this.buildData(this.attrValue, true, false);
return;
}
// Normal update.
for (key in attrValue) {
if (attrValue[key] === undefined) { continue; }
this.data[key] = attrValue[key];
}
},
/**
* Check if component should fire update and fire update lifecycle handler.
*/
callUpdateHandler: function () {
var hasComponentChanged;
// Store the previous old data before we calculate the new oldData.
if (this.previousOldData instanceof Object) {
utils.objectPool.clearObject(this.previousOldData);
}
if (this.isObjectBased) {
copyData(this.previousOldData, this.oldData);
} else {
this.previousOldData = this.oldData;
}
hasComponentChanged = !utils.deepEqual(this.oldData, this.data);
// Don't update if properties haven't changed.
// Always update rotation, position, scale.
if (!this.isPositionRotationScale && !hasComponentChanged) { return; }
// Store current data as previous data for future updates.
// Reuse `this.oldData` object to try not to allocate another one.
if (this.oldData instanceof Object) { utils.objectPool.clearObject(this.oldData); }
this.oldData = extendProperties(this.oldData, this.data, this.isObjectBased);
// Update component with the previous old data.
this.update(this.previousOldData);
this.throttledEmitComponentChanged();
},
handleMixinUpdate: function () {
this.data = this.buildData(this.attrValue);
this.callUpdateHandler();
},
/**
* Reset value of a property to the property's default value.
* If single-prop component, reset value to component's default value.
*
* @param {string} propertyName - Name of property to reset.
*/
resetProperty: function (propertyName) {
if (this.isObjectBased) {
if (!(propertyName in this.attrValue)) { return; }
delete this.attrValue[propertyName];
this.data[propertyName] = this.schema[propertyName].default;
} else {
this.attrValue = this.schema.default;
this.data = this.schema.default;
}
this.updateProperties(this.attrValue);
},
/**
* Extend schema of component given a partial schema.
*
* Some components might want to mutate their schema based on certain properties.
* e.g., Material component changes its schema based on `shader` to account for different
* uniforms
*
* @param {object} schemaAddon - Schema chunk that extend base schema.
*/
extendSchema: function (schemaAddon) {
var extendedSchema;
// Clone base schema.
extendedSchema = utils.extend({}, components[this.name].schema);
// Extend base schema with new schema chunk.
utils.extend(extendedSchema, schemaAddon);
this.schema = processSchema(extendedSchema);
this.el.emit('schemachanged', this.evtDetail);
},
/**
* Build component data from the current state of the entity.data.
*
* Precedence:
* 1. Defaults data
* 2. Mixin data.
* 3. Attribute data.
*
* Finally coerce the data to the types of the defaults.
*
* @param {object} newData - Element new data.
* @param {boolean} clobber - The previous data is completely replaced by the new one.
* @param {boolean} silent - Suppress warning messages.
* @return {object} The component data
*/
buildData: function (newData, clobber, silent) {
var componentDefined;
var data;
var defaultValue;
var key;
var mixinData;
var nextData = this.nextData;
var schema = this.schema;
var i;
var mixinEls = this.el.mixinEls;
var previousData;
// Whether component has a defined value. For arrays, treat empty as not defined.
componentDefined = newData && newData.constructor === Array
? newData.length
: newData !== undefined && newData !== null;
if (this.isObjectBased) { utils.objectPool.clearObject(nextData); }
// 1. Gather default values (lowest precendence).
if (this.isSingleProperty) {
if (this.isObjectBased) {
// If object-based single-prop, then copy over the data to our pooled object.
data = copyData(nextData, schema.default);
} else {
// If is plain single-prop, copy by value the default.
data = isObjectOrArray(schema.default)
? utils.clone(schema.default)
: schema.default;
}
} else {
// Preserve previously set properties if clobber not enabled.
previousData = !clobber && this.attrValue;
// Clone default value if object so components don't share object
data = previousData instanceof Object
? copyData(nextData, previousData)
: nextData;
// Apply defaults.
for (key in schema) {
defaultValue = schema[key].default;
if (data[key] !== undefined) { continue; }
// Clone default value if object so components don't share object
data[key] = isObjectOrArray(defaultValue)
? utils.clone(defaultValue)
: defaultValue;
}
}
// 2. Gather mixin values.
for (i = 0; i < mixinEls.length; i++) {
mixinData = mixinEls[i].getAttribute(this.attrName);
if (!mixinData) { continue; }
data = extendProperties(data, mixinData, this.isObjectBased);
}
// 3. Gather attribute values (highest precendence).
if (componentDefined) {
if (this.isSingleProperty) {
// If object-based, copy the value to not modify the original.
if (isObject(newData)) {
copyData(this.parsingAttrValue, newData);
return parseProperty(this.parsingAttrValue, schema);
}
return parseProperty(newData, schema);
}
data = extendProperties(data, newData, this.isObjectBased);
} else {
// Parse and coerce using the schema.
if (this.isSingleProperty) { return parseProperty(data, schema); }
}
return parseProperties(data, schema, undefined, this.name, silent);
},
/**
* Attach events from component-defined events map.
*/
eventsAttach: function () {
var eventName;
// Safety detach to prevent double-registration.
this.eventsDetach();
for (eventName in this.events) {
this.el.addEventListener(eventName, this.events[eventName]);
}
},
/**
* Detach events from component-defined events map.
*/
eventsDetach: function () {
var eventName;
for (eventName in this.events) {
this.el.removeEventListener(eventName, this.events[eventName]);
}
},
/**
* Release and free memory.
*/
destroy: function () {
this.objectPool.recycle(this.attrValue);
this.objectPool.recycle(this.oldData);
this.objectPool.recycle(this.parsingAttrValue);
this.attrValue = this.oldData = this.parsingAttrValue = undefined;
}
};
function eventsBind (component, events) {
var eventName;
for (eventName in events) {
component.events[eventName] = events[eventName].bind(component);
}
}
// For testing.
if (window.debug) {
var registrationOrderWarnings = module.exports.registrationOrderWarnings = {};
}
/**
* Register a component to A-Frame.
*
* @param {string} name - Component name.
* @param {object} definition - Component schema and lifecycle method handlers.
* @returns {object} Component.
*/
module.exports.registerComponent = function (name, definition) {
var NewComponent;
var propertyName;
var proto = {};
var schema;
var schemaIsSingleProp;
// Warning if component is statically registered after the scene.
if (document.currentScript && document.currentScript !== aframeScript) {
scenes.forEach(function checkPosition (sceneEl) {
// Okay to register component after the scene at runtime.
if (sceneEl.hasLoaded) { return; }
// Check that component is declared before the scene.
if (document.currentScript.compareDocumentPosition(sceneEl) ===
Node.DOCUMENT_POSITION_FOLLOWING) { return; }
warn('The component `' + name + '` was registered in a <script> tag after the scene. ' +
'Component <script> tags in an HTML file should be declared *before* the scene ' +
'such that the component is available to entities during scene initialization.');
// For testing.
if (window.debug) { registrationOrderWarnings[name] = true; }
});
}
if (upperCaseRegExp.test(name) === true) {
warn('The component name `' + name + '` contains uppercase characters, but ' +
'HTML will ignore the capitalization of attribute names. ' +
'Change the name to be lowercase: `' + name.toLowerCase() + '`');
}
if (name.indexOf('__') !== -1) {
throw new Error('The component name `' + name + '` is not allowed. ' +
'The sequence __ (double underscore) is reserved to specify an id' +
' for multiple components of the same type');
}
// Format definition object to prototype object.
Object.keys(definition).forEach(function (key) {
proto[key] = {
value: definition[key],
writable: true
};
});
if (components[name]) {
throw new Error('The component `' + name + '` has been already registered. ' +
'Check that you are not loading two versions of the same component ' +
'or two different components of the same name.');
}
NewComponent = function (el, attr, id) {
Component.call(this, el, attr, id);
};
NewComponent.prototype = Object.create(Component.prototype, proto);
NewComponent.prototype.name = name;
NewComponent.prototype.isPositionRotationScale =
name === 'position' || name === 'rotation' || name === 'scale';
NewComponent.prototype.constructor = NewComponent;
NewComponent.prototype.system = systems && systems.systems[name];
NewComponent.prototype.play = wrapPlay(NewComponent.prototype.play);
NewComponent.prototype.pause = wrapPause(NewComponent.prototype.pause);
schema = utils.extend(processSchema(NewComponent.prototype.schema,
NewComponent.prototype.name));
schemaIsSingleProp = isSingleProp(NewComponent.prototype.schema);
// Keep track of keys that may potentially change the schema.
if (!schemaIsSingleProp) {
NewComponent.prototype.schemaChangeKeys = [];
for (propertyName in schema) {
if (schema[propertyName].schemaChange) {
NewComponent.prototype.schemaChangeKeys.push(propertyName);
}
}
}
// Create object pool for class of components.
objectPools[name] = utils.objectPool.createPool();
components[name] = {
Component: NewComponent,
dependencies: NewComponent.prototype.dependencies,
isSingleProp: schemaIsSingleProp,
multiple: NewComponent.prototype.multiple,
name: name,
parse: NewComponent.prototype.parse,
parseAttrValueForCache: NewComponent.prototype.parseAttrValueForCache,
schema: schema,
stringify: NewComponent.prototype.stringify,
type: NewComponent.prototype.type
};
return NewComponent;
};
/**
* Clone component data.
* Clone only the properties that are plain objects while keeping a reference for the rest.
*
* @param data - Component data to clone.
* @returns Cloned data.
*/
function copyData (dest, sourceData) {
var parsedProperty;
var key;
for (key in sourceData) {
if (sourceData[key] === undefined) { continue; }
parsedProperty = sourceData[key];
dest[key] = isObjectOrArray(parsedProperty)
? utils.clone(parsedProperty)
: parsedProperty;
}
return dest;
}
/**
* Object extending with checking for single-property schema.
*
* @param dest - Destination object or value.
* @param source - Source object or value
* @param {boolean} isObjectBased - Whether values are objects.
* @returns Overridden object or value.
*/
function extendProperties (dest, source, isObjectBased) {
var key;
if (isObjectBased && source.constructor === Object) {
for (key in source) {
if (source[key] === undefined) { continue; }
if (source[key] && source[key].constructor === Object) {
dest[key] = utils.clone(source[key]);
} else {
dest[key] = source[key];
}
}
return dest;
}
return source;
}
/**
* Checks if a component has defined a method that needs to run every frame.
*/
function hasBehavior (component) {
return component.tick || component.tock;
}
/**
* Wrapper for defined pause method.
* Pause component by removing tick behavior and calling user's pause method.
*
* @param pauseMethod {function}
*/
function wrapPause (pauseMethod) {
return function pause () {
var sceneEl = this.el.sceneEl;
if (!this.isPlaying) { return; }
pauseMethod.call(this);
this.isPlaying = false;
this.eventsDetach();
// Remove tick behavior.
if (!hasBehavior(this)) { return; }
sceneEl.removeBehavior(this);
};
}
/**
* Wrapper for defined play method.
* Play component by adding tick behavior and calling user's play method.
*
* @param playMethod {function}
*/
function wrapPlay (playMethod) {
return function play () {
var sceneEl = this.el.sceneEl;
var shouldPlay = this.el.isPlaying && !this.isPlaying;
if (!this.initialized || !shouldPlay) { return; }
playMethod.call(this);
this.isPlaying = true;
this.eventsAttach();
// Add tick behavior.
if (!hasBehavior(this)) { return; }
sceneEl.addBehavior(this);
};
}
function isObject (value) {
return value && value.constructor === Object && !(value instanceof window.HTMLElement);
}
function isObjectOrArray (value) {
return value && (value.constructor === Object || value.constructor === Array) &&
!(value instanceof window.HTMLElement);
}