UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

1,614 lines (1,244 loc) • 67.1 kB
import { assert } from "../../core/assert.js"; import { BitSet } from "../../core/binary/BitSet.js"; import { array_set_diff } from "../../core/collection/array/array_set_diff.js"; import { array_shrink_to_size } from "../../core/collection/array/array_shrink_to_size.js"; import { findSignalHandlerIndexByHandle } from "../../core/events/signal/findSignalHandlerIndexByHandle.js"; import { findSignalHandlerIndexByHandleAndContext } from "../../core/events/signal/findSignalHandlerIndexByHandleAndContext.js"; import Signal from "../../core/events/signal/Signal.js"; import { SignalHandler } from "../../core/events/signal/SignalHandler.js"; import { max3 } from "../../core/math/max3.js"; import { EventType } from "./EventType.js"; /** * * @param {number} entityIndex * @returns {boolean} */ function validateEntityIndex(entityIndex) { return validateIndexValue(entityIndex, "entityIndex"); } /** * * @param {number} componentIndex * @returns {boolean} */ function validateComponentIndex(componentIndex) { return validateIndexValue(componentIndex, "componentIndex"); } /** * * @param {number} index * @param {string} name * @returns {boolean} */ function validateIndexValue(index, name) { if (typeof index !== "number") { throw new TypeError(`${name} must be a number, instead was ${typeof index}(=${index})`); } if (!Number.isInteger(index)) { throw new Error(`${name} must be an integer, instead was ${index}`); } if (index < 0) { throw new Error(`${name} must be non-negative, instead was ${index}`); } return true; } /** * Matches a supplies component mask against a larger set * @param {BitSet} componentOccupancy * @param {number} entityIndex * @param {number} componentTypeCount * @param {BitSet} mask * @returns {boolean} true if mask matches completely, false otherwise */ function matchComponentMask( componentOccupancy, entityIndex, componentTypeCount, mask ) { const offset = entityIndex * componentTypeCount; for ( let componentIndex = mask.nextSetBit(0); componentIndex !== -1; componentIndex = mask.nextSetBit(componentIndex + 1) ) { const componentPresent = componentOccupancy.get(componentIndex + offset); if (!componentPresent) { return false; } } return true; } /** * * @param {number} entityIndex * @param {BitSet} mask * @param {number[]} componentIndexMap * @param {[]} components * @param {[]} result */ function buildObserverCallbackArgs(entityIndex, mask, componentIndexMap, components, result) { for ( let i = mask.nextSetBit(0); i !== -1; i = mask.nextSetBit(i + 1) ) { const componentDataset = components[i]; const componentInstance = componentDataset[entityIndex]; const resultIndex = componentIndexMap[i]; result[resultIndex] = componentInstance; } } /** * * @type {number[]} */ const scratch_indices = []; /** * Used for constructing arguments prior to traversal visitor call * @type {*[]} */ const scratch_args = []; /** * Represents a storage for entities and their associated components. * Entities are just integer IDs and components are stored in a virtual table, where each component type has a separate column and each entity is a row in that table. * It is valid for entities to have no components or to have every possible component. * The entity IDs are compacted, meaning that when an entity is removed - its ID can later be reused. * Typically, you would use {@link Entity} helper class instead of working directly with the dataset as it offers a higher-level API. * * Designed to handle millions of objects at the same time. * @implements {Iterable<number>} iterate over the entity IDs * @example * const ecd = new EntityComponentDataset(); * * const entityId = ecd.createEntity(); // create an entity * * ecd.addComponentToEntity(entityId, myComponentInstance); // add a component to your entity * * // ... once no longer needed, destroy the entity * ecd.removeEntity(entityId); * * @see https://en.wikipedia.org/wiki/Entity_component_system * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class EntityComponentDataset { /** * Set a bit at index of an entity if index is used, unset otherwise * @private * @type {BitSet} */ entityOccupancy = new BitSet(); /** * For each entity ID records generation when entity was created * Values are invalid for unused entity IDs * @private * @type {Uint32Array} */ entityGeneration = new Uint32Array(0); /** * Bit table, if a bit is set - that means component is present. * The format is * entity_0: [component_0, component_1, ... component_n] * entity_1: [component_0, component_1, ... component_n] * ... * entity_n: [component_0, component_1, ... component_n] * @private * @type {BitSet} */ componentOccupancy = new BitSet(); /** * Do not modify directly. * Use {@link setComponentTypeMap} instead. * @private * @type {Class[]} */ componentTypeMap = []; /** * Fast index lookup from a component class * @type {Map<Class, number>} * @private */ __type_to_index_map = new Map(); /** * How many component types exist for this collection. This is the same as componentMap.length * @private * @type {number} */ componentTypeCount = 0; /** * 2d array of following structure: components[component_index][entity_index]. * @private * @type {Object[][]} */ components = []; /** * Current number of entities * @private * @type {number} */ entityCount = 0; /** * A counter that is incremented every time an entity is created * Generation is used to provide an entity with a unique identity * Entity IDs are re-used, but generation is always increasing * Two entities with the same ID but different generation represent two distinct entities, one with "younger" generation (larger number) is the more recently created one * @private * @type {number} */ generation = 0; /** * @readonly * @type {Signal<number>} */ onEntityCreated = new Signal(); /** * @readonly * @type {Signal<number>} */ onEntityRemoved = new Signal(); /** * * @type {Array<Object<SignalHandler[]>>} * @private */ __entityEventListeners = []; /** * * @type {SignalHandler[][]} * @private */ __entityAnyEventListeners = []; /** * @private * @type {Array<Array<EntityObserver>>} */ observers = []; /** * returns a promise of a component instance based on a given type * @template T, R * @param {int} entity * @param {T} component_type * @returns {Promise<R>} */ promiseComponent(entity, component_type) { assert.ok(this.entityExists(entity), `Entity ${entity} doesn't exist`); const self = this; return new Promise(function (resolve, reject) { const component = self.getComponent(entity, component_type); if (component !== undefined) { resolve(component); return; } function handler(event, entity) { const componentClass = event.klass; if (componentClass === component_type) { //found the right one const instance = event.instance; self.removeEntityEventListener(entity, EventType.ComponentAdded, handler); resolve(instance); } } function handleRemoval() { reject(`Entity ${entity} has been removed`); } self.addEntityEventListener(entity, EventType.ComponentAdded, handler); self.addEntityEventListener(entity, EventType.EntityRemoved, handleRemoval); }); } /** * * @param {EntityObserver} observer * @param {boolean} [immediate=false] whenever pre-existing matches should be processed * @returns {boolean} */ addObserver(observer, immediate) { if (observer.dataset === this) { // already connected return false; } observer.dataset = this; let i; //build the observer observer.build(this.componentTypeMap); //add to observer stores const componentMask = observer.componentMask; for (i = componentMask.nextSetBit(0); i !== -1; i = componentMask.nextSetBit(i + 1)) { const observerStore = this.observers[i]; observerStore.push(observer); } if (immediate === true) { //process existing matches const componentTypeCount = this.componentTypeCount; const componentOccupancy = this.componentOccupancy; const entityOccupancy = this.entityOccupancy; const components = this.components; const componentIndexMap = observer.componentIndexMapping; const args = []; for (i = entityOccupancy.nextSetBit(0); i !== -1; i = entityOccupancy.nextSetBit(i + 1)) { const match = matchComponentMask(componentOccupancy, i, componentTypeCount, componentMask); if (match) { buildObserverCallbackArgs(i, componentMask, componentIndexMap, components, args); //write entityIndex args[observer.componentTypeCount] = i; observer.callbackComplete.apply(observer.thisArg, args); } } } return true; } /** * * @param {EntityObserver} observer * @param {boolean} [immediate=false] if flag set, matches will be broken after observer is removed * @returns {boolean} */ removeObserver(observer, immediate) { if (observer.dataset !== this) { // not connected to this dataset return false; } let i; let foundFlag = false; const componentMask = observer.componentMask; //remove observer from stores for (i = componentMask.nextSetBit(0); i !== -1; i = componentMask.nextSetBit(i + 1)) { const observerStore = this.observers[i]; const index = observerStore.indexOf(observer); if (index === -1) { continue; } foundFlag = true; observerStore.splice(index, 1); } if (foundFlag && immediate === true) { //process existing matches const componentTypeCount = this.componentTypeCount; const componentOccupancy = this.componentOccupancy; const entityOccupancy = this.entityOccupancy; const components = this.components; const componentIndexMap = observer.componentIndexMapping; const args = []; for (i = entityOccupancy.nextSetBit(0); i !== -1; i = entityOccupancy.nextSetBit(i + 1)) { const match = matchComponentMask(componentOccupancy, i, componentTypeCount, componentMask); if (match) { buildObserverCallbackArgs(i, componentMask, componentIndexMap, components, args); //write entityIndex args[observer.componentTypeCount] = i; //break the match observer.callbackBroken.apply(observer.thisArg, args); } } } // clear out observer's state observer.dataset = null; return foundFlag; } /** * * @returns {number} */ getEntityCount() { return this.entityCount; } /** * * @returns {number} */ getComponentTypeCount() { return this.componentTypeCount; } /** * Convenience method for retrieving a collection of components for a given entity * @param {number} entity ID of the entity * @param {[]} component_classes Classes of components to extract * @returns {Array} */ getComponents(entity, component_classes) { assert.ok(this.entityExists(entity), `Entity ${entity} doesn't exist`); assert.notNull(component_classes, "component_classes"); assert.defined(component_classes, "component_classes"); assert.isArray(component_classes, "component_classes"); //assert.notOk(componentClasses.some((c, i) => componentClasses.indexOf(c) !== i), 'componentClasses contains duplicates'); const resultLength = component_classes.length; const result = new Array(resultLength); const componentTypeCount = this.componentTypeCount; const occupancyStart = componentTypeCount * entity; const occupancyEnd = occupancyStart + componentTypeCount; for (let i = this.componentOccupancy.nextSetBit(occupancyStart); i < occupancyEnd && i !== -1; i = this.componentOccupancy.nextSetBit(i + 1)) { const componentIndex = i % componentTypeCount; const componentType = this.componentTypeMap[componentIndex]; const resultIndex = component_classes.indexOf(componentType); if (resultIndex === -1) { // not requested, skip continue; } result[resultIndex] = this.components[componentIndex][entity]; } return result; } /** * * @param {[]} output * @param {number} output_offset * @param {number} entity_id * @returns {number} how many components were written to the output * @see getAllComponents */ readEntityComponents( output, output_offset, entity_id ) { assert.isNonNegativeInteger(entity_id, 'entity_id'); assert.ok(this.entityExists(entity_id), `Entity ${entity_id} doesn't exist`); assert.isArray(output, "output"); assert.isNonNegativeInteger(output_offset, 'output_offset'); const componentTypeCount = this.componentTypeCount; const occupancy_start = componentTypeCount * entity_id; const occupancy_end = occupancy_start + componentTypeCount; const occupancy = this.componentOccupancy; let offset = output_offset; for ( let i = occupancy.nextSetBit(occupancy_start); i < occupancy_end && i !== -1; i = occupancy.nextSetBit(i + 1) ) { const componentIndex = i % componentTypeCount; const component = this.components[componentIndex][entity_id]; output[offset++] = component; } return offset - output_offset; } /** * Get all components associated with a given entity. * Note that this method allocates. If performance is important - prefer alternatives. * Prefer to use {@link readEntityComponents} for performance reasons. * @param {number} entity_id * @returns {[]} all components attached to the entity, array is not compacted * @see readEntityComponents */ getAllComponents(entity_id) { assert.isNonNegativeInteger(entity_id, 'entity_id'); assert.ok(this.entityExists(entity_id), `Entity ${entity_id} doesn't exist`); const ret = []; const componentTypeCount = this.componentTypeCount; const occupancy_start = componentTypeCount * entity_id; const occupancy_end = occupancy_start + componentTypeCount; const occupancy = this.componentOccupancy; for ( let i = occupancy.nextSetBit(occupancy_start); i < occupancy_end && i !== -1; i = occupancy.nextSetBit(i + 1) ) { const componentIndex = i % componentTypeCount; ret[componentIndex] = this.components[componentIndex][entity_id]; } return ret; } /** * Modify dataset component mapping. Algorithm will attempt to mutate dataset even if entities exist, however, it will not remove component classes for which instances exist in the dataset. * @param {Class[]} map collection of component classes * @returns {void} * @throws Error when attempting to remove component classes with live instances */ setComponentTypeMap(map) { assert.defined(map, "map"); assert.isArray(map, 'map'); const newComponentTypeCount = map.length; const diff = array_set_diff(map, this.componentTypeMap); const typesToAdd = diff.uniqueA; const typesToRemove = diff.uniqueB; const typesCommon = diff.common; const self = this; function existingComponentsRemovalCheck() { const presentComponentTypes = []; for (let i = 0; i < typesToRemove.length; i++) { const type = typesToRemove[i]; self.traverseComponents(type, function () { presentComponentTypes.push(type); //stop traversal return false; }); } if (presentComponentTypes.length > 0) { const sTypes = presentComponentTypes.map(t => t.typeName).join(", "); throw new Error(`Component types can not be unmapped due to presence of live components: ${sTypes}`); } } /** * * @returns {number[]} */ function computeComponentIndexRemapping() { const indexRemapping = []; let i; let l; for (i = 0, l = typesCommon.length; i < l; i++) { const commonType = typesCommon[i]; //get old index const indexOld = self.componentTypeMap.indexOf(commonType); const indexNew = map.indexOf(commonType); indexRemapping[indexOld] = indexNew; } return indexRemapping; } /** * * @param {number[]} indexRemapping */ function updateComponentOccupancy(indexRemapping) { //build new component occupancy map const newComponentOccupancy = new BitSet(); let i; const oldComponentTypeCount = self.componentTypeCount; for (i = self.componentOccupancy.nextSetBit(0); i !== -1; i = self.componentOccupancy.nextSetBit(i + 1)) { //determine component index const oldComponentIndex = i % oldComponentTypeCount; const newComponentIndex = indexRemapping[oldComponentIndex]; if (newComponentIndex !== undefined) { const entity = Math.floor(i / oldComponentTypeCount); newComponentOccupancy.set(entity * newComponentTypeCount + newComponentIndex, true); } } self.componentOccupancy = newComponentOccupancy; } /** * * @param {number[]} indexRemapping */ function updateComponentStores(indexRemapping) { let i; let l; const newStore = []; for (i = 0, l = typesToAdd.length; i < l; i++) { const type = typesToAdd[i]; assert.defined(type, 'type'); const newIndex = map.indexOf(type); //initialize component store newStore[newIndex] = []; } for (i = 0, l = indexRemapping.length; i < l; i++) { const newIndex = indexRemapping[i]; if (newIndex === undefined) { continue; } newStore[newIndex] = self.components[i]; } self.components = newStore; } /** * * @param {number[]} indexRemapping */ function updateObservers(indexRemapping) { let i; let l; const newStore = []; for (i = 0, l = typesToAdd.length; i < l; i++) { const type = typesToAdd[i]; const newIndex = map.indexOf(type); //initialize component store newStore[newIndex] = []; } for (i = 0, l = indexRemapping.length; i < l; i++) { const newIndex = indexRemapping[i]; if (newIndex === undefined) { continue; } const observers = self.observers[i]; //rebuild observers observers.forEach(function (observer) { observer.build(map); }); newStore[newIndex] = observers; } self.observers = newStore; } //make sure that no components exist of type scheduled for removal existingComponentsRemovalCheck(); const indexRemapping = computeComponentIndexRemapping(); updateComponentOccupancy(indexRemapping); updateComponentStores(indexRemapping); updateObservers(indexRemapping); this.componentTypeMap = map; // rebuild class->index lookup this.__type_to_index_map.clear(); for (let i = 0; i < newComponentTypeCount; i++) { this.__type_to_index_map.set(map[i], i); } this.componentTypeCount = newComponentTypeCount; } /** * * @param {Class[]} types * @returns {boolean} true if all types are present, false otherwise */ areComponentTypesRegistered(types) { assert.isArray(types, 'types'); const count = types.length; for (let i = 0; i < count; i++) { const type = types[i]; if (!this.isComponentTypeRegistered(type)) { return false; } } return true; } /** * Does this dataset have a given component registered? * Use {@link registerComponentType}/{@link unregisterComponentType} to alter registered set * @param {Class|Function} type * @return {boolean} */ isComponentTypeRegistered(type) { assert.defined(type, 'type'); assert.notNull(type, 'type'); const componentTypeMap = this.getComponentTypeMap(); return componentTypeMap.indexOf(type) !== -1; } /** * * @returns {Class[]} */ getComponentTypeMap() { return this.componentTypeMap; } /** * * @param {Class[]} types * @returns {boolean} false if no new classes were added, true if at least one new class was added */ registerManyComponentTypes(types) { const diff = array_set_diff(types, this.componentTypeMap); if (diff.uniqueA.length === 0) { // all classes area already registered return false; } // new set const coalesced = this.componentTypeMap.concat(diff.uniqueA); this.setComponentTypeMap(coalesced); return true; } /** * Attempt to add a component class to dataset registry * @param {Class|Function} type * @returns {boolean} true if component successfully added, false if component is already registered */ registerComponentType(type) { if (this.isComponentTypeRegistered(type)) { // already registered return false; } const classes = this.componentTypeMap.concat([type]); this.setComponentTypeMap(classes); return true; } /** * Attempt to remove a component class from the registry * @param {Class} type * @returns {boolean} true iff component is removed, false if it was not registered */ unregisterComponentType(type) { if (!this.isComponentTypeRegistered(type)) { // not registered return false; } const classes = this.componentTypeMap.slice(); const t = classes.indexOf(type); classes.splice(t, 1); this.setComponentTypeMap(classes); return true; } /** * * @param {number} min_size * @returns {void} */ enlargeGenerationTable(min_size) { assert.isNonNegativeInteger(min_size, 'min_size'); const old_generation_table_size = this.entityGeneration.length; const new_size = max3( min_size, Math.ceil(old_generation_table_size * 1.2), old_generation_table_size + 16 ); const new_generation_table = new Uint32Array(new_size); // copy over old data new_generation_table.set(this.entityGeneration); this.entityGeneration = new_generation_table } /** * Produces generation ID for a given entity * NOTE: this method doesn't check for entity's existence, make sure to check that separately if needed * @param {number} entity_id * @returns {number} */ getEntityGeneration(entity_id) { assert.isNonNegativeInteger(entity_id, 'entity_id'); assert.ok(this.entityExists(entity_id), `Entity ${entity_id} does not exist`); return this.entityGeneration[entity_id]; } /** * @private * @param {number} entity_id * @returns {void} */ createEntityUnsafe(entity_id) { this.entityOccupancy.set(entity_id, true); // record entity generation if (this.entityGeneration.length <= entity_id) { // needs to be resized this.enlargeGenerationTable(entity_id + 1); } const current_generation = this.generation; this.generation = current_generation + 1; this.entityGeneration[entity_id] = current_generation; this.entityCount++; this.onEntityCreated.send1(entity_id); } /** * * @returns {number} entityIndex */ createEntity() { const entity_id = this.entityOccupancy.nextClearBit(0); this.createEntityUnsafe(entity_id); return entity_id; } /** * * @param {number} entity_id * @throws {Error} if entity index is already in use * @returns {void} */ createEntitySpecific(entity_id) { if (this.entityExists(entity_id)) { throw new Error(`EntityId ${entity_id} is already in use`); } this.createEntityUnsafe(entity_id); } /** * * @param {number} entityIndex * @returns {boolean} */ entityExists(entityIndex) { assert.ok(validateEntityIndex(entityIndex)); return this.entityOccupancy.get(entityIndex); } /** * * @param {number} componentIndex * @returns {boolean} */ componentIndexExists(componentIndex) { assert.ok(validateComponentIndex(componentIndex)); return componentIndex >= 0 && componentIndex < this.componentTypeCount; } /** * Remove entity from the dataset, effectively destroying it from the world * @param {number} entity_id * @returns {boolean} true if entity was removed, false if it doesn't exist */ removeEntity(entity_id) { assert.isNonNegativeInteger(entity_id, 'entity_id'); if (!this.entityExists(entity_id)) { // entity doesn't exist return false; } const componentOccupancy = this.componentOccupancy; const typeCount = this.componentTypeCount; const occupancyStart = entity_id * typeCount; const occupancyEnd = occupancyStart + typeCount; // remove all components from the entity for ( let i = componentOccupancy.nextSetBit(occupancyStart); i < occupancyEnd && i !== -1; i = componentOccupancy.nextSetBit(i + 1) ) { const componentIndex = i % typeCount; this.removeComponentFromEntityByIndex_Unchecked(entity_id, componentIndex, i); } //dispatch event this.sendEvent(entity_id, EventType.EntityRemoved, entity_id); //purge all event listeners delete this.__entityEventListeners[entity_id]; delete this.__entityAnyEventListeners[entity_id]; this.entityOccupancy.set(entity_id, false); this.entityCount--; this.onEntityRemoved.send1(entity_id); return true; } /** * Convenience method for removal of multiple entities * Works the same as {@link removeEntity} but for multiple elements * @param {number[]} entity_ids * @returns {void} */ removeEntities(entity_ids) { const length = entity_ids.length; for (let i = 0; i < length; i++) { const entityIndex = entity_ids[i]; this.removeEntity(entityIndex); } } /** * * @param {number} entity_id * @param {Class} klass * @returns {void} */ removeComponentFromEntity(entity_id, klass) { const component_index = this.componentTypeMap.indexOf(klass); if (component_index === -1) { throw new Error(`Component class not found in this dataset`); } this.removeComponentFromEntityByIndex(entity_id, component_index); } /** * * @param {number} entity_id * @param {number} component_index * @returns {void} */ removeComponentFromEntityByIndex(entity_id, component_index) { assert.ok(this.entityExists(entity_id), `entity ${entity_id} does not exist`); assert.ok(this.componentIndexExists(component_index), `componentIndex ${component_index} is out of bounds`); //check if component exists const componentOccupancyIndex = entity_id * this.componentTypeCount + component_index; const exists = this.componentOccupancy.get(componentOccupancyIndex); if (!exists) { //nothing to remove console.warn(`Entity ${entity_id} doesn't have a component with index ${component_index}`); return; } this.removeComponentFromEntityByIndex_Unchecked(entity_id, component_index, componentOccupancyIndex); } /** * This method doesn't perform any checks, make sure you understand what you are doing when using it * @private * @param {number} entity_id * @param {number} component_index * @param {number} component_occupancy_index * @returns {void} */ removeComponentFromEntityByIndex_Unchecked(entity_id, component_index, component_occupancy_index) { this.processObservers_ComponentRemoved(entity_id, component_index); const componentInstance = this.components[component_index][entity_id]; //remove component from record delete this.components[component_index][entity_id]; //clear occupancy bit this.componentOccupancy.clear(component_occupancy_index); //dispatch events const componentClass = this.componentTypeMap[component_index]; //dispatch event to components this.sendEvent(entity_id, EventType.ComponentRemoved, { klass: componentClass, instance: componentInstance }); } /** * Internally every component class is mapped to an index, this method is used to retrieve such mapping. * @param {Function|Class} klass * @returns {number} integer index, -1 if not found */ computeComponentTypeIndex(klass) { assert.defined(klass, "klass"); assert.notNull(klass, "klass"); const idx = this.__type_to_index_map.get(klass); if (idx === undefined) { return -1; } return idx; } /** * @template T * @param {T} klass * @returns {number} */ computeComponentCount(klass) { let result = 0; this.traverseComponents(klass, function () { result++; }); return result; } /** * Retrieves any instance of a given component. * If no component instances exist - component will be `null` and entity will be `-1`. * Useful for singleton components such as a camera or an audio listener. * @template T * @param {Class<T>} component_type * @returns {{entity:number, component:T}} */ getAnyComponent(component_type) { let entity = -1; let component = null; const index = this.computeComponentTypeIndex(component_type); if (index !== -1) { const components = this.components[index]; for (const entity_key in components) { const c = components[entity_key]; if (c !== undefined) { entity = Number(entity_key); component = c; break; } } } return { entity, component }; } /** * * Associate a component with a particular entity. * NOTE: An entity can have *AT MOST* one component of a given type. * @template C * @param {number} entity_id * @param {C} component_instance * @returns {void} */ addComponentToEntity(entity_id, component_instance) { assert.notNull(component_instance, "componentInstance"); assert.defined(component_instance, "componentInstance"); /** * * @type {Class<C>} */ const klass = component_instance.constructor; const componentTypeIndex = this.__type_to_index_map.get(klass); if (typeof componentTypeIndex !== "number") { throw new Error(`Component class not found in this dataset for component_instance ${stringifyComponent(component_instance)}`); } this.addComponentToEntityByIndex(entity_id, componentTypeIndex, component_instance); } /** * If in doubt, prefer to use {@link addComponentToEntity} instead. * @template C * @param {number} entity_id * @param {number} component_index ordered index of the entity type, matching order in {@link getComponentTypeMap} * @param {C} component_instance * @returns {void} */ addComponentToEntityByIndex(entity_id, component_index, component_instance) { assert.ok(this.entityExists(entity_id), `entity ${entity_id} does not exist`); assert.ok(this.componentIndexExists(component_index), `component_index ${component_index} is out of bounds`); assert.defined(component_instance, "component_instance"); assert.equal(this.getComponentByIndex(entity_id, component_index), undefined, `entity ${entity_id} already has component ${component_index}`); const componentOccupancyIndex = entity_id * this.componentTypeCount + component_index; //record component occupancy this.componentOccupancy.set(componentOccupancyIndex, true); //inset component instance into component dataset this.components[component_index][entity_id] = component_instance; //process observers this.processObservers_ComponentAdded(entity_id, component_index); //dispatch events const componentClass = this.componentTypeMap[component_index]; //dispatch event to components this.sendEvent(entity_id, EventType.ComponentAdded, { klass: componentClass, instance: component_instance }); } /** * @template C * @param {number} entity_id * @param {number} component_index * @returns {C|undefined} */ getComponentByIndex(entity_id, component_index) { assert.ok(this.entityExists(entity_id), `entity ${entity_id} does not exist`); assert.ok(this.componentIndexExists(component_index), `component_index ${component_index} is out of bounds`); return this.components[component_index][entity_id]; } /** * Whether a given entity has a component of the specified class attached * @template C * @param {number} entity_id * @param {Class<C>} klass * @returns {boolean} */ hasComponent(entity_id, klass) { return this.getComponent(entity_id, klass) !== undefined; } /** * @template T * @param {number} entity_id * @param {Class<T>} klass * @returns {T|undefined} */ getComponent(entity_id, klass) { assert.isNonNegativeInteger(entity_id, 'entity_id'); assert.ok(this.entityExists(entity_id), `entity ${entity_id} does not exist`); assert.defined(klass, "klass"); const componentIndex = this.computeComponentTypeIndex(klass); if (componentIndex === -1) { // throw new Error(`Component class ${computeComponentClassName(klass)} not registered in this dataset`); return undefined; } return this.getComponentByIndex(entity_id, componentIndex); } /** * Always returns non-null value, if the component is not found - an error is thrown instead * @template C * @param {number} entity_id * @param {Class<C>} klass * @returns {C} * @throws {Error} when component not found */ getComponentSafe(entity_id, klass) { const component = this.getComponent(entity_id, klass); if (component === undefined) { throw new Error("Component not found"); } return component; } /** * same as getComponent when component exists, if component is not associated with the entity, callback will be invoked once when it is added. * @param {Number} entity_id * @param {Class} component_class * @param {function} callback * @param {*} [thisArg] * @returns {void} */ getComponentAsync(entity_id, component_class, callback, thisArg) { const component = this.getComponent(entity_id, component_class); const handler = (options) => { if (options.klass === component_class) { this.removeEntityEventListener(entity_id, EventType.ComponentAdded, handler); callback.call(thisArg, options.instance); } } if (component === undefined) { this.addEntityEventListener(entity_id, EventType.ComponentAdded, handler); } else { callback.call(thisArg, component); } }; /** * Performs traversal on a subset of entities which have specified components. * @example * ecd.traverseEntities( * [Transform, Renderable, Tag], // Component classes to match * (transform, renderable, tag, entity ) => { // actual component instances along with entity ID * // do something * } * ); * @param {Array} classes * @param {function(...args):boolean} visitor Visitor can return optional "false" to terminate traversal earlier * @param {object} [thisArg] specifies context object on which callbacks are to be called, optional * @returns {void} */ traverseEntities(classes, visitor, thisArg) { assert.isArray(classes, "classes"); // assert.notOk(classes.some((c, i) => classes.indexOf(c) !== i), 'classes contains duplicates'); assert.isFunction(visitor, "visitor"); let entityIndex, i; //map classes to indices const indices = scratch_indices; // prepare sorted array of component indices const numClasses = classes.length; for (i = 0; i < numClasses; i++) { const k = classes[i]; const componentIndex = this.computeComponentTypeIndex(k); if (componentIndex === -1) { // throw new Error(`Component (index=${i}) not found in the dataset`); // no chance to match any entities return; } indices[i] = componentIndex; } const args = scratch_args; // crop args array to the right length (since we're re-using a scratch array) const args_length_intended = numClasses + 1; array_shrink_to_size(scratch_args, args_length_intended); const entityOccupancy = this.entityOccupancy; const component_type_count = this.componentTypeCount; const component_occupancy = this.componentOccupancy; const components = this.components; entity_loop: for ( entityIndex = entityOccupancy.nextSetBit(0); entityIndex !== -1; entityIndex = entityOccupancy.nextSetBit(entityIndex + 1) ) { const componentOccupancyAddress = entityIndex * component_type_count; for (i = 0; i < numClasses; i++) { const componentIndex = indices[i]; const componentPresent = component_occupancy.get(componentOccupancyAddress + componentIndex); if (!componentPresent) { continue entity_loop; } args[i] = components[componentIndex][entityIndex]; } args[numClasses] = entityIndex; const keepGoing = visitor.apply(thisArg, args); if (keepGoing === false) { //stop traversal return; } } } /** * Performs traversal on a subset of entities which have only the specified components and no others * @example traverseEntitiesExact([Transform,Renderable,Tag],function(transform, renderable, tag, entity){ ... }, this); * @param {Array.<class>} classes * @param {Function} visitor * @param {Object} [thisArg] specifies context object on which callbacks are to be called, optional * @returns {void} */ traverseEntitiesExact(classes, visitor, thisArg) { let entityIndex, i; //map classes to indices const indices = []; const numClasses = classes.length; for (i = 0; i < numClasses; i++) { const k = classes[i]; const componentIndex = this.computeComponentTypeIndex(k); indices[i] = componentIndex; } const args = []; entity_loop: for (entityIndex = this.entityOccupancy.nextSetBit(0); entityIndex !== -1; entityIndex = this.entityOccupancy.nextSetBit(entityIndex + 1)) { const componentOccupancyAddress = entityIndex * this.componentTypeCount; const componentOccupancyEnd = componentOccupancyAddress + this.componentTypeCount; let matched = 0; for ( i = this.componentOccupancy.nextSetBit(componentOccupancyAddress); i < componentOccupancyEnd && i !== -1; i = this.componentOccupancy.nextSetBit(i + 1) ) { const componentIndex = i - componentOccupancyAddress; const componentPosition = indices.indexOf(componentIndex); if (componentPosition === -1) { //undesirable component present (Extra) continue entity_loop; } matched++; args[componentPosition] = this.components[componentIndex][entityIndex]; } if (matched !== numClasses) { //Not all components were present continue; } args[numClasses] = entityIndex; const keepGoing = visitor.apply(thisArg, args); if (keepGoing === false) { //stop traversal return; } } } /** * Iterate over all entities * @return {Generator<number>} */ * [Symbol.iterator]() { const eo = this.entityOccupancy; for ( let i = eo.nextSetBit(0); i !== -1; i = eo.nextSetBit(i + 1) ) { yield i; } } /** * Traverse all entities with a given component class instance. * @example * ecd.traverseComponents(Name, n => n.value = `${n.value} the Great`); // turn every name X to "X the Great", like "John" -> "John the Great" * * @template T * @param {Class<T>} klass * @param {function(instance:T, entity:number)} visitor * @param {*} [thisArg=undefined] optional `this` argument for the visitor callback * @returns {void} */ traverseComponents(klass, visitor, thisArg) { const componentTypeIndex = this.computeComponentTypeIndex(klass); if (componentTypeIndex === -1) { // throw new Error(`Component class is not registered in this dataset`); // no chance to match any components return; } this.traverseComponentsByIndex(componentTypeIndex, visitor, thisArg); } /** * * @param {number} component_index * @param {function} visitor * @param {*} [thisArg] * @returns {void} */ traverseComponentsByIndex(component_index, visitor, thisArg) { assert.isNumber(component_index, "component_index"); assert.isNonNegativeInteger(component_index, "component_index"); assert.isFunction(visitor, "visitor"); this.__traverseComponentsByIndex_via_property(component_index, visitor, thisArg); } /** * Alternative to {@link __traverseComponentsByIndex_via_property} as of 2020, appears to be significantly slower on Chrome with larger datasets * @private * @param {number} component_index * @param {function} visitor * @param {*} [thisArg] * @returns {void} */ __traverseComponentsByIndex_via_bitset(component_index, visitor, thisArg) { const componentDataset = this.components[component_index]; const componentTypeCount = this.componentTypeCount; const entityOccupancy = this.entityOccupancy; const componentOccupancy = this.componentOccupancy; for (let entityIndex = entityOccupancy.nextSetBit(0); entityIndex !== -1; entityIndex = entityOccupancy.nextSetBit(entityIndex + 1)) { const componentOccupancyIndex = entityIndex * componentTypeCount + component_index; if (componentOccupancy.get(componentOccupancyIndex)) { const componentInstance = componentDataset[entityIndex]; const continueFlag = visitor.call(thisArg, componentInstance, entityIndex); if (continueFlag === false) { //stop traversal break; } } } } /** * @private * @param {number} component_index * @param {function} visitor * @param {*} [thisArg] * @returns {void} */ __traverseComponentsByIndex_via_property(component_index, visitor, thisArg) { const componentDataset = this.components[component_index]; for (const entity_key in componentDataset) { const entityIndex = Number.parseInt(entity_key); const componentInstance = componentDataset[entityIndex]; if (componentInstance === undefined) { continue; } const continueFlag = visitor.call(thisArg, componentInstance, entityIndex); if (continueFlag === false) { //stop traversal break; } } } /** * @private * @param {number} entity_id * @param {number} component_index * @returns {void} */ processObservers_ComponentAdded(entity_id, component_index) { const observers = this.observers; const observersStore = observers[component_index]; const numObservers = observersStore.length; if (numObservers === 0) { // no observers return; } let i = 0; const args = []; for (; i < numObservers; i++) { const observer = observersStore[i]; const match = matchComponentMask(this.componentOccupancy, entity_id, this.componentTypeCount, observer.componentMask); if (match) { //match completing addition buildObserverCallbackArgs(entity_id, observer.componentMask, observer.componentIndexMapping, this.components, args);