@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
1,614 lines (1,244 loc) • 67.1 kB
JavaScript
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);