/** * Return the name of a component * @param {Component} Component * @private */ /** * Get a key from a list of components * @param {Array(Component)} Components Array of components to generate the key * @private */ function queryKey(Components) { var ids = []; for (var n = 0; n < Components.length; n++) { var T = Components[n]; if (!componentRegistered(T)) { throw new Error(`Tried to create a query with an unregistered component`); } if (typeof T === "object") { var operator = T.operator === "not" ? "!" : T.operator; ids.push(operator + T.Component._typeId); } else { ids.push(T._typeId); } } return ids.sort().join("-"); } // Detector for browser's "window" const hasWindow = typeof window !== "undefined"; // performance.now() "polyfill" const now = hasWindow && typeof window.performance !== "undefined" ? performance.now.bind(performance) : Date.now.bind(Date); function componentRegistered(T) { return ( (typeof T === "object" && T.Component._typeId !== undefined) || (T.isComponent && T._typeId !== undefined) ); } class SystemManager { constructor(world) { this._systems = []; this._executeSystems = []; // Systems that have `execute` method this.world = world; this.lastExecutedSystem = null; } registerSystem(SystemClass, attributes) { if (!SystemClass.isSystem) { throw new Error( `System '${SystemClass.name}' does not extend 'System' class` ); } if (this.getSystem(SystemClass) !== undefined) { console.warn(`System '${SystemClass.getName()}' already registered.`); return this; } var system = new SystemClass(this.world, attributes); if (system.init) system.init(attributes); system.order = this._systems.length; this._systems.push(system); if (system.execute) { this._executeSystems.push(system); this.sortSystems(); } return this; } unregisterSystem(SystemClass) { let system = this.getSystem(SystemClass); if (system === undefined) { console.warn( `Can unregister system '${SystemClass.getName()}'. It doesn't exist.` ); return this; } this._systems.splice(this._systems.indexOf(system), 1); if (system.execute) { this._executeSystems.splice(this._executeSystems.indexOf(system), 1); } // @todo Add system.unregister() call to free resources return this; } sortSystems() { this._executeSystems.sort((a, b) => { return a.priority - b.priority || a.order - b.order; }); } getSystem(SystemClass) { return this._systems.find((s) => s instanceof SystemClass); } getSystems() { return this._systems; } removeSystem(SystemClass) { var index = this._systems.indexOf(SystemClass); if (!~index) return; this._systems.splice(index, 1); } executeSystem(system, delta, time) { if (system.initialized) { if (system.canExecute()) { let startTime = now(); system.execute(delta, time); system.executeTime = now() - startTime; this.lastExecutedSystem = system; system.clearEvents(); } } } stop() { this._executeSystems.forEach((system) => system.stop()); } execute(delta, time, forcePlay) { this._executeSystems.forEach( (system) => (forcePlay || system.enabled) && this.executeSystem(system, delta, time) ); } stats() { var stats = { numSystems: this._systems.length, systems: {}, }; for (var i = 0; i < this._systems.length; i++) { var system = this._systems[i]; var systemStats = (stats.systems[system.getName()] = { queries: {}, executeTime: system.executeTime, }); for (var name in system.ctx) { systemStats.queries[name] = system.ctx[name].stats(); } } return stats; } } class ObjectPool { // @todo Add initial size constructor(T, initialSize) { this.freeList = []; this.count = 0; this.T = T; this.isObjectPool = true; if (typeof initialSize !== "undefined") { this.expand(initialSize); } } acquire() { // Grow the list by 20%ish if we're out if (this.freeList.length <= 0) { this.expand(Math.round(this.count * 0.2) + 1); } var item = this.freeList.pop(); return item; } release(item) { item.reset(); this.freeList.push(item); } expand(count) { for (var n = 0; n < count; n++) { var clone = new this.T(); clone._pool = this; this.freeList.push(clone); } this.count += count; } totalSize() { return this.count; } totalFree() { return this.freeList.length; } totalUsed() { return this.count - this.freeList.length; } } /** * @private * @class EventDispatcher */ class EventDispatcher { constructor() { this._listeners = {}; this.stats = { fired: 0, handled: 0, }; } /** * Add an event listener * @param {String} eventName Name of the event to listen * @param {Function} listener Callback to trigger when the event is fired */ addEventListener(eventName, listener) { let listeners = this._listeners; if (listeners[eventName] === undefined) { listeners[eventName] = []; } if (listeners[eventName].indexOf(listener) === -1) { listeners[eventName].push(listener); } } /** * Check if an event listener is already added to the list of listeners * @param {String} eventName Name of the event to check * @param {Function} listener Callback for the specified event */ hasEventListener(eventName, listener) { return ( this._listeners[eventName] !== undefined && this._listeners[eventName].indexOf(listener) !== -1 ); } /** * Remove an event listener * @param {String} eventName Name of the event to remove * @param {Function} listener Callback for the specified event */ removeEventListener(eventName, listener) { var listenerArray = this._listeners[eventName]; if (listenerArray !== undefined) { var index = listenerArray.indexOf(listener); if (index !== -1) { listenerArray.splice(index, 1); } } } /** * Dispatch an event * @param {String} eventName Name of the event to dispatch * @param {Entity} entity (Optional) Entity to emit * @param {Component} component */ dispatchEvent(eventName, entity, component) { this.stats.fired++; var listenerArray = this._listeners[eventName]; if (listenerArray !== undefined) { var array = listenerArray.slice(0); for (var i = 0; i < array.length; i++) { array[i].call(this, entity, component); } } } /** * Reset stats counters */ resetCounters() { this.stats.fired = this.stats.handled = 0; } } class Query { /** * @param {Array(Component)} Components List of types of components to query */ constructor(Components, manager) { this.Components = []; this.NotComponents = []; Components.forEach((component) => { if (typeof component === "object") { this.NotComponents.push(component.Component); } else { this.Components.push(component); } }); if (this.Components.length === 0) { throw new Error("Can't create a query without components"); } this.entities = []; this.eventDispatcher = new EventDispatcher(); // This query is being used by a reactive system this.reactive = false; this.key = queryKey(Components); // Fill the query with the existing entities for (var i = 0; i < manager._entities.length; i++) { var entity = manager._entities[i]; if (this.match(entity)) { // @todo ??? this.addEntity(entity); => preventing the event to be generated entity.queries.push(this); this.entities.push(entity); } } } /** * Add entity to this query * @param {Entity} entity */ addEntity(entity) { entity.queries.push(this); this.entities.push(entity); this.eventDispatcher.dispatchEvent(Query.prototype.ENTITY_ADDED, entity); } /** * Remove entity from this query * @param {Entity} entity */ removeEntity(entity) { let index = this.entities.indexOf(entity); if (~index) { this.entities.splice(index, 1); index = entity.queries.indexOf(this); entity.queries.splice(index, 1); this.eventDispatcher.dispatchEvent( Query.prototype.ENTITY_REMOVED, entity ); } } match(entity) { return ( entity.hasAllComponents(this.Components) && !entity.hasAnyComponents(this.NotComponents) ); } toJSON() { return { key: this.key, reactive: this.reactive, components: { included: this.Components.map((C) => C.name), not: this.NotComponents.map((C) => C.name), }, numEntities: this.entities.length, }; } /** * Return stats for this query */ stats() { return { numComponents: this.Components.length, numEntities: this.entities.length, }; } } Query.prototype.ENTITY_ADDED = "Query#ENTITY_ADDED"; Query.prototype.ENTITY_REMOVED = "Query#ENTITY_REMOVED"; Query.prototype.COMPONENT_CHANGED = "Query#COMPONENT_CHANGED"; /** * @private * @class QueryManager */ class QueryManager { constructor(world) { this._world = world; // Queries indexed by a unique identifier for the components it has this._queries = {}; } onEntityRemoved(entity) { for (var queryName in this._queries) { var query = this._queries[queryName]; if (entity.queries.indexOf(query) !== -1) { query.removeEntity(entity); } } } /** * Callback when a component is added to an entity * @param {Entity} entity Entity that just got the new component * @param {Component} Component Component added to the entity */ onEntityComponentAdded(entity, Component) { // @todo Use bitmask for checking components? // Check each indexed query to see if we need to add this entity to the list for (var queryName in this._queries) { var query = this._queries[queryName]; if ( !!~query.NotComponents.indexOf(Component) && ~query.entities.indexOf(entity) ) { query.removeEntity(entity); continue; } // Add the entity only if: // Component is in the query // and Entity has ALL the components of the query // and Entity is not already in the query if ( !~query.Components.indexOf(Component) || !query.match(entity) || ~query.entities.indexOf(entity) ) continue; query.addEntity(entity); } } /** * Callback when a component is removed from an entity * @param {Entity} entity Entity to remove the component from * @param {Component} Component Component to remove from the entity */ onEntityComponentRemoved(entity, Component) { for (var queryName in this._queries) { var query = this._queries[queryName]; if ( !!~query.NotComponents.indexOf(Component) && !~query.entities.indexOf(entity) && query.match(entity) ) { query.addEntity(entity); continue; } if ( !!~query.Components.indexOf(Component) && !!~query.entities.indexOf(entity) && !query.match(entity) ) { query.removeEntity(entity); continue; } } } /** * Get a query for the specified components * @param {Component} Components Components that the query should have */ getQuery(Components) { var key = queryKey(Components); var query = this._queries[key]; if (!query) { this._queries[key] = query = new Query(Components, this._world); } return query; } /** * Return some stats from this class */ stats() { var stats = {}; for (var queryName in this._queries) { stats[queryName] = this._queries[queryName].stats(); } return stats; } } class Component { constructor(props) { if (props !== false) { const schema = this.constructor.schema; for (const key in schema) { if (props && props.hasOwnProperty(key)) { this[key] = props[key]; } else { const schemaProp = schema[key]; if (schemaProp.hasOwnProperty("default")) { this[key] = schemaProp.type.clone(schemaProp.default); } else { const type = schemaProp.type; this[key] = type.clone(type.default); } } } if ( props !== undefined) { this.checkUndefinedAttributes(props); } } this._pool = null; } copy(source) { const schema = this.constructor.schema; for (const key in schema) { const prop = schema[key]; if (source.hasOwnProperty(key)) { this[key] = prop.type.copy(source[key], this[key]); } } // @DEBUG { this.checkUndefinedAttributes(source); } return this; } clone() { return new this.constructor().copy(this); } reset() { const schema = this.constructor.schema; for (const key in schema) { const schemaProp = schema[key]; if (schemaProp.hasOwnProperty("default")) { this[key] = schemaProp.type.copy(schemaProp.default, this[key]); } else { const type = schemaProp.type; this[key] = type.copy(type.default, this[key]); } } } dispose() { if (this._pool) { this._pool.release(this); } } getName() { return this.constructor.getName(); } checkUndefinedAttributes(src) { const schema = this.constructor.schema; // Check that the attributes defined in source are also defined in the schema Object.keys(src).forEach((srcKey) => { if (!schema.hasOwnProperty(srcKey)) { console.warn( `Trying to set attribute '${srcKey}' not defined in the '${this.constructor.name}' schema. Please fix the schema, the attribute value won't be set` ); } }); } } Component.schema = {}; Component.isComponent = true; Component.getName = function () { return this.displayName || this.name; }; class SystemStateComponent extends Component {} SystemStateComponent.isSystemStateComponent = true; class EntityPool extends ObjectPool { constructor(entityManager, entityClass, initialSize) { super(entityClass, undefined); this.entityManager = entityManager; if (typeof initialSize !== "undefined") { this.expand(initialSize); } } expand(count) { for (var n = 0; n < count; n++) { var clone = new this.T(this.entityManager); clone._pool = this; this.freeList.push(clone); } this.count += count; } } /** * @private * @class EntityManager */ class EntityManager { constructor(world) { this.world = world; this.componentsManager = world.componentsManager; // All the entities in this instance this._entities = []; this._nextEntityId = 0; this._entitiesByNames = {}; this._queryManager = new QueryManager(this); this.eventDispatcher = new EventDispatcher(); this._entityPool = new EntityPool( this, this.world.options.entityClass, this.world.options.entityPoolSize ); // Deferred deletion this.entitiesWithComponentsToRemove = []; this.entitiesToRemove = []; this.deferredRemovalEnabled = true; } getEntityByName(name) { return this._entitiesByNames[name]; } /** * Create a new entity */ createEntity(name) { var entity = this._entityPool.acquire(); entity.alive = true; entity.name = name || ""; if (name) { if (this._entitiesByNames[name]) { console.warn(`Entity name '${name}' already exist`); } else { this._entitiesByNames[name] = entity; } } this._entities.push(entity); this.eventDispatcher.dispatchEvent(ENTITY_CREATED, entity); return entity; } // COMPONENTS /** * Add a component to an entity * @param {Entity} entity Entity where the component will be added * @param {Component} Component Component to be added to the entity * @param {Object} values Optional values to replace the default attributes */ entityAddComponent(entity, Component, values) { // @todo Probably define Component._typeId with a default value and avoid using typeof if ( typeof Component._typeId === "undefined" && !this.world.componentsManager._ComponentsMap[Component._typeId] ) { throw new Error( `Attempted to add unregistered component "${Component.getName()}"` ); } if (~entity._ComponentTypes.indexOf(Component)) { { console.warn( "Component type already exists on entity.", entity, Component.getName() ); } return; } entity._ComponentTypes.push(Component); if (Component.__proto__ === SystemStateComponent) { entity.numStateComponents++; } var componentPool = this.world.componentsManager.getComponentsPool( Component ); var component = componentPool ? componentPool.acquire() : new Component(values); if (componentPool && values) { component.copy(values); } entity._components[Component._typeId] = component; this._queryManager.onEntityComponentAdded(entity, Component); this.world.componentsManager.componentAddedToEntity(Component); this.eventDispatcher.dispatchEvent(COMPONENT_ADDED, entity, Component); } /** * Remove a component from an entity * @param {Entity} entity Entity which will get removed the component * @param {*} Component Component to remove from the entity * @param {Bool} immediately If you want to remove the component immediately instead of deferred (Default is false) */ entityRemoveComponent(entity, Component, immediately) { var index = entity._ComponentTypes.indexOf(Component); if (!~index) return; this.eventDispatcher.dispatchEvent(COMPONENT_REMOVE, entity, Component); if (immediately) { this._entityRemoveComponentSync(entity, Component, index); } else { if (entity._ComponentTypesToRemove.length === 0) this.entitiesWithComponentsToRemove.push(entity); entity._ComponentTypes.splice(index, 1); entity._ComponentTypesToRemove.push(Component); entity._componentsToRemove[Component._typeId] = entity._components[Component._typeId]; delete entity._components[Component._typeId]; } // Check each indexed query to see if we need to remove it this._queryManager.onEntityComponentRemoved(entity, Component); if (Component.__proto__ === SystemStateComponent) { entity.numStateComponents--; // Check if the entity was a ghost waiting for the last system state component to be removed if (entity.numStateComponents === 0 && !entity.alive) { entity.remove(); } } } _entityRemoveComponentSync(entity, Component, index) { // Remove T listing on entity and property ref, then free the component. entity._ComponentTypes.splice(index, 1); var component = entity._components[Component._typeId]; delete entity._components[Component._typeId]; component.dispose(); this.world.componentsManager.componentRemovedFromEntity(Component); } /** * Remove all the components from an entity * @param {Entity} entity Entity from which the components will be removed */ entityRemoveAllComponents(entity, immediately) { let Components = entity._ComponentTypes; for (let j = Components.length - 1; j >= 0; j--) { if (Components[j].__proto__ !== SystemStateComponent) this.entityRemoveComponent(entity, Components[j], immediately); } } /** * Remove the entity from this manager. It will clear also its components * @param {Entity} entity Entity to remove from the manager * @param {Bool} immediately If you want to remove the component immediately instead of deferred (Default is false) */ removeEntity(entity, immediately) { var index = this._entities.indexOf(entity); if (!~index) throw new Error("Tried to remove entity not in list"); entity.alive = false; this.entityRemoveAllComponents(entity, immediately); if (entity.numStateComponents === 0) { // Remove from entity list this.eventDispatcher.dispatchEvent(ENTITY_REMOVED, entity); this._queryManager.onEntityRemoved(entity); if (immediately === true) { this._releaseEntity(entity, index); } else { this.entitiesToRemove.push(entity); } } } _releaseEntity(entity, index) { this._entities.splice(index, 1); if (this._entitiesByNames[entity.name]) { delete this._entitiesByNames[entity.name]; } entity._pool.release(entity); } /** * Remove all entities from this manager */ removeAllEntities() { for (var i = this._entities.length - 1; i >= 0; i--) { this.removeEntity(this._entities[i]); } } processDeferredRemoval() { if (!this.deferredRemovalEnabled) { return; } for (let i = 0; i < this.entitiesToRemove.length; i++) { let entity = this.entitiesToRemove[i]; let index = this._entities.indexOf(entity); this._releaseEntity(entity, index); } this.entitiesToRemove.length = 0; for (let i = 0; i < this.entitiesWithComponentsToRemove.length; i++) { let entity = this.entitiesWithComponentsToRemove[i]; while (entity._ComponentTypesToRemove.length > 0) { let Component = entity._ComponentTypesToRemove.pop(); var component = entity._componentsToRemove[Component._typeId]; delete entity._componentsToRemove[Component._typeId]; component.dispose(); this.world.componentsManager.componentRemovedFromEntity(Component); //this._entityRemoveComponentSync(entity, Component, index); } } this.entitiesWithComponentsToRemove.length = 0; } /** * Get a query based on a list of components * @param {Array(Component)} Components List of components that will form the query */ queryComponents(Components) { return this._queryManager.getQuery(Components); } // EXTRAS /** * Return number of entities */ count() { return this._entities.length; } /** * Return some stats */ stats() { var stats = { numEntities: this._entities.length, numQueries: Object.keys(this._queryManager._queries).length, queries: this._queryManager.stats(), numComponentPool: Object.keys(this.componentsManager._componentPool) .length, componentPool: {}, eventDispatcher: this.eventDispatcher.stats, }; for (var ecsyComponentId in this.componentsManager._componentPool) { var pool = this.componentsManager._componentPool[ecsyComponentId]; stats.componentPool[pool.T.getName()] = { used: pool.totalUsed(), size: pool.count, }; } return stats; } } const ENTITY_CREATED = "EntityManager#ENTITY_CREATE"; const ENTITY_REMOVED = "EntityManager#ENTITY_REMOVED"; const COMPONENT_ADDED = "EntityManager#COMPONENT_ADDED"; const COMPONENT_REMOVE = "EntityManager#COMPONENT_REMOVE"; class ComponentManager { constructor() { this.Components = []; this._ComponentsMap = {}; this._componentPool = {}; this.numComponents = {}; this.nextComponentId = 0; } hasComponent(Component) { return this.Components.indexOf(Component) !== -1; } registerComponent(Component, objectPool) { if (this.Components.indexOf(Component) !== -1) { console.warn( `Component type: '${Component.getName()}' already registered.` ); return; } const schema = Component.schema; if (!schema) { throw new Error( `Component "${Component.getName()}" has no schema property.` ); } for (const propName in schema) { const prop = schema[propName]; if (!prop.type) { throw new Error( `Invalid schema for component "${Component.getName()}". Missing type for "${propName}" property.` ); } } Component._typeId = this.nextComponentId++; this.Components.push(Component); this._ComponentsMap[Component._typeId] = Component; this.numComponents[Component._typeId] = 0; if (objectPool === undefined) { objectPool = new ObjectPool(Component); } else if (objectPool === false) { objectPool = undefined; } this._componentPool[Component._typeId] = objectPool; } componentAddedToEntity(Component) { this.numComponents[Component._typeId]++; } componentRemovedFromEntity(Component) { this.numComponents[Component._typeId]--; } getComponentsPool(Component) { return this._componentPool[Component._typeId]; } } const Version = "0.3.1"; const proxyMap = new WeakMap(); const proxyHandler = { set(target, prop) { throw new Error( `Tried to write to "${target.constructor.getName()}#${String( prop )}" on immutable component. Use .getMutableComponent() to modify a component.` ); }, }; function wrapImmutableComponent(T, component) { if (component === undefined) { return undefined; } let wrappedComponent = proxyMap.get(component); if (!wrappedComponent) { wrappedComponent = new Proxy(component, proxyHandler); proxyMap.set(component, wrappedComponent); } return wrappedComponent; } class Entity { constructor(entityManager) { this._entityManager = entityManager || null; // Unique ID for this entity this.id = entityManager._nextEntityId++; // List of components types the entity has this._ComponentTypes = []; // Instance of the components this._components = {}; this._componentsToRemove = {}; // Queries where the entity is added this.queries = []; // Used for deferred removal this._ComponentTypesToRemove = []; this.alive = false; //if there are state components on a entity, it can't be removed completely this.numStateComponents = 0; } // COMPONENTS getComponent(Component, includeRemoved) { var component = this._components[Component._typeId]; if (!component && includeRemoved === true) { component = this._componentsToRemove[Component._typeId]; } return wrapImmutableComponent(Component, component) ; } getRemovedComponent(Component) { const component = this._componentsToRemove[Component._typeId]; return wrapImmutableComponent(Component, component) ; } getComponents() { return this._components; } getComponentsToRemove() { return this._componentsToRemove; } getComponentTypes() { return this._ComponentTypes; } getMutableComponent(Component) { var component = this._components[Component._typeId]; if (!component) { return; } for (var i = 0; i < this.queries.length; i++) { var query = this.queries[i]; // @todo accelerate this check. Maybe having query._Components as an object // @todo add Not components if (query.reactive && query.Components.indexOf(Component) !== -1) { query.eventDispatcher.dispatchEvent( Query.prototype.COMPONENT_CHANGED, this, component ); } } return component; } addComponent(Component, values) { this._entityManager.entityAddComponent(this, Component, values); return this; } removeComponent(Component, forceImmediate) { this._entityManager.entityRemoveComponent(this, Component, forceImmediate); return this; } hasComponent(Component, includeRemoved) { return ( !!~this._ComponentTypes.indexOf(Component) || (includeRemoved === true && this.hasRemovedComponent(Component)) ); } hasRemovedComponent(Component) { return !!~this._ComponentTypesToRemove.indexOf(Component); } hasAllComponents(Components) { for (var i = 0; i < Components.length; i++) { if (!this.hasComponent(Components[i])) return false; } return true; } hasAnyComponents(Components) { for (var i = 0; i < Components.length; i++) { if (this.hasComponent(Components[i])) return true; } return false; } removeAllComponents(forceImmediate) { return this._entityManager.entityRemoveAllComponents(this, forceImmediate); } copy(src) { // TODO: This can definitely be optimized for (var ecsyComponentId in src._components) { var srcComponent = src._components[ecsyComponentId]; this.addComponent(srcComponent.constructor); var component = this.getComponent(srcComponent.constructor); component.copy(srcComponent); } return this; } clone() { return new Entity(this._entityManager).copy(this); } reset() { this.id = this._entityManager._nextEntityId++; this._ComponentTypes.length = 0; this.queries.length = 0; for (var ecsyComponentId in this._components) { delete this._components[ecsyComponentId]; } } remove(forceImmediate) { return this._entityManager.removeEntity(this, forceImmediate); } } const DEFAULT_OPTIONS = { entityPoolSize: 0, entityClass: Entity, }; class World { constructor(options = {}) { this.options = Object.assign({}, DEFAULT_OPTIONS, options); this.componentsManager = new ComponentManager(this); this.entityManager = new EntityManager(this); this.systemManager = new SystemManager(this); this.enabled = true; this.eventQueues = {}; if (hasWindow && typeof CustomEvent !== "undefined") { var event = new CustomEvent("ecsy-world-created", { detail: { world: this, version: Version }, }); window.dispatchEvent(event); } this.lastTime = now() / 1000; } registerComponent(Component, objectPool) { this.componentsManager.registerComponent(Component, objectPool); return this; } registerSystem(System, attributes) { this.systemManager.registerSystem(System, attributes); return this; } hasRegisteredComponent(Component) { return this.componentsManager.hasComponent(Component); } unregisterSystem(System) { this.systemManager.unregisterSystem(System); return this; } getSystem(SystemClass) { return this.systemManager.getSystem(SystemClass); } getSystems() { return this.systemManager.getSystems(); } execute(delta, time) { if (!delta) { time = now() / 1000; delta = time - this.lastTime; this.lastTime = time; } if (this.enabled) { this.systemManager.execute(delta, time); this.entityManager.processDeferredRemoval(); } } stop() { this.enabled = false; } play() { this.enabled = true; } createEntity(name) { return this.entityManager.createEntity(name); } stats() { var stats = { entities: this.entityManager.stats(), system: this.systemManager.stats(), }; return stats; } } class System { canExecute() { if (this._mandatoryQueries.length === 0) return true; for (let i = 0; i < this._mandatoryQueries.length; i++) { var query = this._mandatoryQueries[i]; if (query.entities.length === 0) { return false; } } return true; } getName() { return this.constructor.getName(); } constructor(world, attributes) { this.world = world; this.enabled = true; // @todo Better naming :) this._queries = {}; this.queries = {}; this.priority = 0; // Used for stats this.executeTime = 0; if (attributes && attributes.priority) { this.priority = attributes.priority; } this._mandatoryQueries = []; this.initialized = true; if (this.constructor.queries) { for (var queryName in this.constructor.queries) { var queryConfig = this.constructor.queries[queryName]; var Components = queryConfig.components; if (!Components || Components.length === 0) { throw new Error("'components' attribute can't be empty in a query"); } // Detect if the components have already been registered let unregisteredComponents = Components.filter( (Component) => !componentRegistered(Component) ); if (unregisteredComponents.length > 0) { throw new Error( `Tried to create a query '${ this.constructor.name }.${queryName}' with unregistered components: [${unregisteredComponents .map((c) => c.getName()) .join(", ")}]` ); } var query = this.world.entityManager.queryComponents(Components); this._queries[queryName] = query; if (queryConfig.mandatory === true) { this._mandatoryQueries.push(query); } this.queries[queryName] = { results: query.entities, }; // Reactive configuration added/removed/changed var validEvents = ["added", "removed", "changed"]; const eventMapping = { added: Query.prototype.ENTITY_ADDED, removed: Query.prototype.ENTITY_REMOVED, changed: Query.prototype.COMPONENT_CHANGED, // Query.prototype.ENTITY_CHANGED }; if (queryConfig.listen) { validEvents.forEach((eventName) => { if (!this.execute) { console.warn( `System '${this.getName()}' has defined listen events (${validEvents.join( ", " )}) for query '${queryName}' but it does not implement the 'execute' method.` ); } // Is the event enabled on this system's query? if (queryConfig.listen[eventName]) { let event = queryConfig.listen[eventName]; if (eventName === "changed") { query.reactive = true; if (event === true) { // Any change on the entity from the components in the query let eventList = (this.queries[queryName][eventName] = []); query.eventDispatcher.addEventListener( Query.prototype.COMPONENT_CHANGED, (entity) => { // Avoid duplicates if (eventList.indexOf(entity) === -1) { eventList.push(entity); } } ); } else if (Array.isArray(event)) { let eventList = (this.queries[queryName][eventName] = []); query.eventDispatcher.addEventListener( Query.prototype.COMPONENT_CHANGED, (entity, changedComponent) => { // Avoid duplicates if ( event.indexOf(changedComponent.constructor) !== -1 && eventList.indexOf(entity) === -1 ) { eventList.push(entity); } } ); } } else { let eventList = (this.queries[queryName][eventName] = []); query.eventDispatcher.addEventListener( eventMapping[eventName], (entity) => { // @fixme overhead? if (eventList.indexOf(entity) === -1) eventList.push(entity); } ); } } }); } } } } stop() { this.executeTime = 0; this.enabled = false; } play() { this.enabled = true; } // @question rename to clear queues? clearEvents() { for (let queryName in this.queries) { var query = this.queries[queryName]; if (query.added) { query.added.length = 0; } if (query.removed) { query.removed.length = 0; } if (query.changed) { if (Array.isArray(query.changed)) { query.changed.length = 0; } else { for (let name in query.changed) { query.changed[name].length = 0; } } } } } toJSON() { var json = { name: this.getName(), enabled: this.enabled, executeTime: this.executeTime, priority: this.priority, queries: {}, }; if (this.constructor.queries) { var queries = this.constructor.queries; for (let queryName in queries) { let query = this.queries[queryName]; let queryDefinition = queries[queryName]; let jsonQuery = (json.queries[queryName] = { key: this._queries[queryName].key, }); jsonQuery.mandatory = queryDefinition.mandatory === true; jsonQuery.reactive = queryDefinition.listen && (queryDefinition.listen.added === true || queryDefinition.listen.removed === true || queryDefinition.listen.changed === true || Array.isArray(queryDefinition.listen.changed)); if (jsonQuery.reactive) { jsonQuery.listen = {}; const methods = ["added", "removed", "changed"]; methods.forEach((method) => { if (query[method]) { jsonQuery.listen[method] = { entities: query[method].length, }; } }); } } } return json; } } System.isSystem = true; System.getName = function () { return this.displayName || this.name; }; function Not(Component) { return { operator: "not", Component: Component, }; } class TagComponent extends Component { constructor() { super(false); } } TagComponent.isTagComponent = true; const copyValue = (src) => src; const cloneValue = (src) => src; const copyArray = (src, dest) => { if (!src) { return src; } if (!dest) { return src.slice(); } dest.length = 0; for (let i = 0; i < src.length; i++) { dest.push(src[i]); } return dest; }; const cloneArray = (src) => src && src.slice(); const copyJSON = (src) => JSON.parse(JSON.stringify(src)); const cloneJSON = (src) => JSON.parse(JSON.stringify(src)); const copyCopyable = (src, dest) => { if (!src) { return src; } if (!dest) { return src.clone(); } return dest.copy(src); }; const cloneClonable = (src) => src && src.clone(); function createType(typeDefinition) { var mandatoryProperties = ["name", "default", "copy", "clone"]; var undefinedProperties = mandatoryProperties.filter((p) => { return !typeDefinition.hasOwnProperty(p); }); if (undefinedProperties.length > 0) { throw new Error( `createType expects a type definition with the following properties: ${undefinedProperties.join( ", " )}` ); } typeDefinition.isType = true; return typeDefinition; } /** * Standard types */ const Types = { Number: createType({ name: "Number", default: 0, copy: copyValue, clone: cloneValue, }), Boolean: createType({ name: "Boolean", default: false, copy: copyValue, clone: cloneValue, }), String: createType({ name: "String", default: "", copy: copyValue, clone: cloneValue, }), Array: createType({ name: "Array", default: [], copy: copyArray, clone: cloneArray, }), Ref: createType({ name: "Ref", default: undefined, copy: copyValue, clone: cloneValue, }), JSON: createType({ name: "JSON", default: null, copy: copyJSON, clone: cloneJSON, }), }; function generateId(length) { var result = ""; var characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; var charactersLength = characters.length; for (var i = 0; i < length; i++) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); } return result; } function injectScript(src, onLoad) { var script = document.createElement("script"); // @todo Use link to the ecsy-devtools repo? script.src = src; script.onload = onLoad; (document.head || document.documentElement).appendChild(script); } /* global Peer */ function hookConsoleAndErrors(connection) { var wrapFunctions = ["error", "warning", "log"]; wrapFunctions.forEach((key) => { if (typeof console[key] === "function") { var fn = console[key].bind(console); console[key] = (...args) => { connection.send({ method: "console", type: key, args: JSON.stringify(args), }); return fn.apply(null, args); }; } }); window.addEventListener("error", (error) => { connection.send({ method: "error", error: JSON.stringify({ message: error.error.message, stack: error.error.stack, }), }); }); } function includeRemoteIdHTML(remoteId) { let infoDiv = document.createElement("div"); infoDiv.style.cssText = ` align-items: center; background-color: #333; color: #aaa; display:flex; font-family: Arial; font-size: 1.1em; height: 40px; justify-content: center; left: 0; opacity: 0.9; position: absolute; right: 0; text-align: center; top: 0; `; infoDiv.innerHTML = `Open ECSY devtools to connect to this page using the code: <b style="color: #fff">${remoteId}</b> <button onClick="generateNewCode()">Generate new code</button>`; document.body.appendChild(infoDiv); return infoDiv; } function enableRemoteDevtools(remoteId) { if (!hasWindow) { console.warn("Remote devtools not available outside the browser"); return; } window.generateNewCode = () => { window.localStorage.clear(); remoteId = generateId(6); window.localStorage.setItem("ecsyRemoteId", remoteId); window.location.reload(false); }; remoteId = remoteId || window.localStorage.getItem("ecsyRemoteId"); if (!remoteId) { remoteId = generateId(6); window.localStorage.setItem("ecsyRemoteId", remoteId); } let infoDiv = includeRemoteIdHTML(remoteId); window.__ECSY_REMOTE_DEVTOOLS_INJECTED = true; window.__ECSY_REMOTE_DEVTOOLS = {}; let Version = ""; // This is used to collect the worlds created before the communication is being established let worldsBeforeLoading = []; let onWorldCreated = (e) => { var world = e.detail.world; Version = e.detail.version; worldsBeforeLoading.push(world); }; window.addEventListener("ecsy-world-created", onWorldCreated); let onLoaded = () => { // var peer = new Peer(remoteId); var peer = new Peer(remoteId, { host: "peerjs.ecsy.io", secure: true, port: 443, config: { iceServers: [ { url: "stun:stun.l.google.com:19302" }, { url: "stun:stun1.l.google.com:19302" }, { url: "stun:stun2.l.google.com:19302" }, { url: "stun:stun3.l.google.com:19302" }, { url: "stun:stun4.l.google.com:19302" }, ], }, debug: 3, }); peer.on("open", (/* id */) => { peer.on("connection", (connection) => { window.__ECSY_REMOTE_DEVTOOLS.connection = connection; connection.on("open", function () { // infoDiv.style.visibility = "hidden"; infoDiv.innerHTML = "Connected"; // Receive messages connection.on("data", function (data) { if (data.type === "init") { var script = document.createElement("script"); script.setAttribute("type", "text/javascript"); script.onload = () => { script.parentNode.removeChild(script); // Once the script is injected we don't need to listen window.removeEventListener( "ecsy-world-created", onWorldCreated ); worldsBeforeLoading.forEach((world) => { var event = new CustomEvent("ecsy-world-created", { detail: { world: world, version: Version }, }); window.dispatchEvent(event); }); }; script.innerHTML = data.script; (document.head || document.documentElement).appendChild(script); script.onload(); hookConsoleAndErrors(connection); } else if (data.type === "executeScript") { let value = eval(data.script); if (data.returnEval) { connection.send({ method: "evalReturn", value: value, }); } } }); }); }); }); }; // Inject PeerJS script injectScript( "https://cdn.jsdelivr.net/npm/peerjs@0.3.20/dist/peer.min.js", onLoaded ); } if (hasWindow) { const urlParams = new URLSearchParams(window.location.search); // @todo Provide a way to disable it if needed if (urlParams.has("enable-remote-devtools")) { enableRemoteDevtools(); } } export { Component, Not, ObjectPool, System, SystemStateComponent, TagComponent, Types, Version, World, Entity as _Entity, cloneArray, cloneClonable, cloneJSON, cloneValue, copyArray, copyCopyable, copyJSON, copyValue, createType, enableRemoteDevtools };