/*
ecsjs is an entity component system library for JavaScript
Copyright (C) 2014 Peter Flannery
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
*/
import { ComponentClassesMap } from './component-classes.js';
import { ComponentMap } from './component-map.js';
import {
ComponentAlreadyRegistered,
ComponentNotRegistered,
ComponentTypeKeyMissing
} from './errors.js';
import { ComponentIterator } from './iterator.js';
import { ComponentQuery } from './query.js';
import {
ComponentClassesMapKey,
ComponentMapKey,
type Component,
type ComponentClass,
type ComponentClasses,
type SingleOrArray
} from './types.js';
import { hashString } from './utils.js';
/**
* Class for storing entities and their relationships.
* @category Maps
*/
export class EntityMap {
/**
* Registered component classes that contain the component data
*/
public components = new ComponentClassesMap()
private nextId: number = 0
private freeIds: number[] = []
/**
* Registers component classes with the {@link EntityMap}
* @param componentClasses - One or more component classes to register
* @returns The {@link EntityMap} instance for chaining
* @throws {@link ComponentTypeKeyMissing} when the specified component type is missing a 'name' parameter (e.g. anonymous classes)
* @throws {@link ComponentAlreadyRegistered} when the specified component is already registered
* @example
* // component class
* class MyComponent {
* constructor(x) {
* this.x = x;
* }
* }
*
* ecs.register(MyComponent);
* // or multiple
* ecs.register(MyComponent1, MyComponent2);
*/
register[]>(...componentClasses: TComponentClasses) {
for (const componentClass of componentClasses) {
const componentName = componentClass.name;
if (componentName === undefined || componentName === '') throw new ComponentTypeKeyMissing();
if (componentClass.hashId === undefined) componentClass.hashId = hashString(componentName);
if (this.components.has(componentClass)) throw new ComponentAlreadyRegistered(componentName);
// create the component map
this.components.set(componentClass, new ComponentMap());
}
// chain
return this;
}
/**
* Gets a component class map
* @param component - The component class to get the map for
* @returns The {@link ComponentMap} for the specified component, or undefined if not found
* @throws {@link ComponentNotRegistered} when the specified component is not registered
* @example
* const positionMap = ecs.getMap(Position)
* for(const [entityId, position] of positionMap) {
* position.x += 1
* }
*/
getMap(component: ComponentClass): ComponentMap | undefined {
const map = this.components.get(component);
if (map === undefined) throw new ComponentNotRegistered(component.name)
return map
}
/**
* @deprecated Use {@link EntityMap.firstEntity} instead
*
* Returns an array of component data for the first entity associated with the keyComponent
* @param keyComponent - The component class used to find the first entity
* @returns An array of components for the first entity, or undefined if not found
* @throws {@link ComponentNotRegistered} when the specified component is not registered
* @example
* // get the first entity
* const playerEntity = ecs.first(Player) ?? []
*/
first(keyComponent: ComponentClass): Component[] | undefined { return this.firstEntity(keyComponent); }
/**
* Returns an array of component data for the first entity associated with the keyComponent
* @param keyComponent - The component class used to find the first entity
* @returns An array of components for the first entity, or undefined if not found
* @throws {@link ComponentNotRegistered} when the specified component is not registered
* @example
* // get the first entity
* const playerEntity = ecs.firstEntity(Player) ?? []
*/
firstEntity(keyComponent: ComponentClass): Component[] | undefined {
const entityId = this.getMap(keyComponent)?.firstKey();
if (entityId === undefined) return undefined;
return this.get(entityId);
}
/**
* Returns an array of component data arrays associated with the keyComponent
* @param keyComponent - The component class used to find the entities
* @returns An array of component arrays, or undefined if the key component map is not found
* @throws {@link ComponentNotRegistered} when the specified component is not registered
* @example
* // get an array of component arrays: e.g. [[Player, Position, Velocity], [Player, Position, Velocity], ...]
* const entities = ecs.entityValues(Player) ?? []
*/
entityValues(keyComponent: ComponentClass): Array | undefined {
const entities = this.getMap(keyComponent)?.keys();
if (entities === undefined) return undefined;
return [...entities].map(x => this.get(x)!)
}
/**
* Gets the first entity entry for a component class
* @param keyComponent - The component class to find the first entry for
* @param components - Additional component classes to retrieve for the same entity
* @returns A tuple containing the entity id followed by the component data, or undefined if not found
* @throws {@link ComponentNotRegistered} when the specified component is not registered
* @example
* // return the first entry
* const [entityId, player] = ecs.firstEntry(Player) ?? []
*
* // or return multiple related components in addition to the first entry
* const [entityId, player, position, direction] = ecs.firstEntry(
* Player,
* Position,
* Direction
* ) ?? []
*/
firstEntry(
keyComponent: ComponentClass,
...components: ComponentClasses
): [number, TKey, ...TRelated] | undefined;
firstEntry(keyComponent: ComponentClass, ...components: ComponentClass[]) {
if (components.length === 0) return this.getMap(keyComponent)?.firstEntry()
return this.firstKey(keyComponent, keyComponent, ...components);
}
/**
* Gets the first entity id for a component class
* and optionally any related component data
* @param keyComponent - The component class used to find the first entity
* @param components - Additional component classes to retrieve for the same entity
* @returns If only keyComponent is provided, returns the entity id.
*
* If related components are provided, returns a tuple with the id and component data.
*
* Returns undefined if not found.
* @throws {@link ComponentNotRegistered} when any of specified component(s) are not registered
* @example
* // return the first entity id
* const entityId = ecs.firstKey(Player)
*
* // or return multiple related component in addition to entity id
* const [entityId, position, direction] = ecs.firstKey(
* Player,
* Position,
* Direction
* ) ?? []
*/
firstKey(
keyComponent: TKey,
...components: ComponentClasses
): SingleOrArray<[number, ...TRelated]> | undefined;
firstKey(keyComponent: ComponentClass, ...components: ComponentClass[]) {
const entityId = this.getMap(keyComponent)?.firstKey();
// single component key
if (arguments.length === 1) return entityId;
if (entityId === undefined) return undefined;
// attach related component data
return [entityId, ...components.map(x => this.getEntity(entityId, x))];
}
/**
* Gets the first entity component data for a component class
* and optionally any related component data
* @param keyComponent - The component class used to find the first entity
* @param components - Additional component classes to retrieve for the same entity
* @returns If only keyComponent is provided, returns the component data.
*
* If related components are provided, returns a tuple with all component data.
*
* Returns undefined if not found.
* @throws {@link ComponentNotRegistered} when any of specified component(s) are not registered
* @example
* // return the first component data
* const player = ecs.firstValue(Player)
*
* // or multiple related data in addition to the first component
* const [player, position, direction] = ecs.firstValue(
* Player,
* Position,
* Direction
* ) ?? []
*/
firstValue(
keyComponent: ComponentClass,
...components: ComponentClasses
): SingleOrArray<[TKey, ...TRelated]> | undefined;
firstValue(keyComponent: ComponentClass, ...components: ComponentClass[]) {
// single component
if (arguments.length === 1) return this.getMap(keyComponent)?.firstValue();
// get the first entry
const [entityId, keyValue] = this.getMap(keyComponent)?.firstEntry() ?? [];
if (entityId === undefined) return undefined;
// attach related components
return [keyValue, ...components.map(x => this.getEntity(entityId, x))]
}
/**
* Internal helper to get component data for an entity
* @param entityId - The entity id to get the component for
* @param component - The component class to get the data of
* @returns The component data if it exists, otherwise undefined
* @throws {@link ComponentNotRegistered} when the specified component is not registered
*/
private getEntity(entityId: number, component: ComponentClass): T | undefined {
const map = this.components.get(component);
if (map === undefined) throw new ComponentNotRegistered(component.name)
return map.get(entityId);
}
/**
* Gets component data related to an entity id
* @param entityId - The entity id to get component(s) for
* @param components - One or more component classes to retrieve. If none provided, retrieves all components for the entity.
* @returns Depending on parameters: a single component, an array of specified components, or an array of all components for the entity.
* @throws {@link ComponentNotRegistered} when any of specified component(s) are not registered
* @example
* // get one by id
* const player = ecs.get(entityId, Player)
*
* // get multiple by id
* const [player, position] = ecs.get(entityId, Player, Position) ?? []
*
* // get all by id
* const playerEntity = ecs.get(entityId) ?? []
*/
get(entityId: number): Component[] | undefined;
get(entityId: number, component: ComponentClass): T | undefined;
get(entityId: number, ...components: ComponentClasses): SingleOrArray | undefined;
get(entityId: number, ...components: ComponentClasses): Component | Component[] | undefined {
// return a single component
if (components.length === 1) return this.getEntity(entityId, components[0]);
// return filtered components
if (components.length > 1) return components.map(x => this.getEntity(entityId, x));
// return all components
return [...this.components.values()]
.filter(v => v.has(entityId))
.map(v => v.get(entityId));
}
/**
* Check if a component exists for an entity
* @param entityId - The entity id to check for the component
* @param component - The component class to check
* @returns True if the entity has the component, otherwise false
* @throws {@link ComponentNotRegistered} when the specified component is not registered
* @example
* const exists = ecs.has(entityId, Position)
*/
has(entityId: number, component: ComponentClass): boolean {
// get the component map
const map = this.components.get(component);
if (map === undefined) throw new ComponentNotRegistered(component.name)
return map.has(entityId);
}
/**
* Checks if all of the specified components exist for an entity
* @param entityId - The entity id to check
* @param components - One or more component classes to check for
* @returns True if the entity has ALL specified components, otherwise false
* @throws {@link ComponentNotRegistered} when any of the specified component(s) are not registered
* @example
* const hasAll = ecs.hasAll(entityId, Position, Velocity)
*/
hasAll[]>(entityId: number, ...components: T): boolean {
for (let index = 0; index < components.length; index++) {
const component = components[index];
const map = this.components.get(component);
if (map === undefined) throw new ComponentNotRegistered(component.name)
if (map.has(entityId) === false) return false;
}
return true
}
/**
* Checks if any of the specified components exist for an entity
* @param entityId - The entity id to check
* @param components - One or more component classes to check for
* @returns True if the entity has ANY of the specified components, otherwise false
* @throws {@link ComponentNotRegistered} when any of the specified component(s) are not registered
* @example
* const hasAny = ecs.hasAny(entityId, Position, Velocity)
*/
hasAny[]>(entityId: number, ...components: T): boolean {
for (let index = 0; index < components.length; index++) {
const component = components[index];
const map = this.components.get(component);
if (map === undefined) throw new ComponentNotRegistered(component.name)
if (map.has(entityId)) return true;
}
return false
}
/**
* Internal helper to set component data for an entity
* @param entityId - The entity id to set the component for
* @param componentData - The component data to set
* @returns The component data that was set
* @throws {@link ComponentNotRegistered} when the component class of the instance is not registered
*/
private setEntity(entityId: number, componentData: T): T {
// get the component map
const map = this.components.get((>componentData.constructor));
if (map === undefined) throw new ComponentNotRegistered(componentData.constructor.name)
// set the entity on the entity map
map.set(entityId, componentData);
// return instance
return componentData;
}
/**
* Add or update multiple component data for an entity
* @param entityId - The entity id to set components for
* @param component - A single component data instance to set
* @param components - Multiple component data instances to set
* @returns The single component data or an array of component data that were set
* @throws {@link ComponentNotRegistered} when any of the component classes are not registered
* @example
* // set one
* const player = ecs.set(entityId, new Player());
*
* // or set multiple
* const [player, position] = ecs.set(
* entityId,
* new Player(),
* new Position()
* );
*/
set(entityId: number, component: T): T;
set(entityId: number, ...components: T): T;
set(entityId: number, ...components: Component[]): Component | Component[] {
if (components.length > 1) return components.map(x => this.setEntity(entityId, x))
// set and return a single component
return this.setEntity(entityId, components[0])
}
/**
* Removes the specified component(s) from an entity
* @param entityId - The entity id to remove components from
* @param components - One or more component classes to remove
* @throws {@link ComponentNotRegistered} when any of the specified component(s) are not registered
* @example
* ecs.remove(entityId, Position);
*/
remove[]>(entityId: number, ...components: T) {
for (const component of components) {
this.removeByKey(entityId, component)
}
}
/**
* Removes the specified component from an entity
* @param entityId - The entity id to remove the component from
* @param component - The component class to remove
* @returns True if the component was successfully removed, otherwise false
* @throws {@link ComponentNotRegistered} when the specified component is not registered
* @example
* ecs.removeByKey(entityId, Position);
*/
removeByKey(entityId: number, component: ComponentClass) {
// get the entity map
const entityMap = this.components.get(component);
// ensure the map is defined
if (entityMap === undefined) throw new ComponentNotRegistered(component.name);
// get the entity
const entity = entityMap.get(entityId);
if (entity === undefined) return false;
// remove the entity from the entity map
return entityMap.delete(entityId);
}
/**
* Deletes all components from an entity and reclaims the id for reuse
* @param entityIds - One or more entity ids to destroy
* @returns The total number of components destroyed
* @example
* const destroyedCount = ecs.destroyEntity(entityId1)
*
* // or multiple
* const destroyedCount = ecs.destroyEntity(entityId1, entityId2)
*/
destroyEntity(...entityIds: number[]): number {
let deletedCount = 0;
for (let index = 0; index < entityIds.length; index++) {
const entityId = entityIds[index];
let found = false;
for (const map of this.components.values()) {
if (map.has(entityId)) {
map.delete(entityId);
deletedCount++;
found = true;
}
}
if (found) {
this.freeIds.push(entityId);
}
}
return deletedCount;
}
/**
* Creates a new entity id for the EntityMap.
*
* Reuses Ids from destroyed entities otherwise increments the Id counter.
* @returns A new unique entity id
* @example
* const newEntityId = ecs.getNextId()
* ecs.set(newEntityId, new Player())
*/
getNextId(): number {
const reclaimedId = this.freeIds.pop();
if (reclaimedId !== undefined) return reclaimedId;
this.nextId++;
return this.nextId;
}
/**
* Clears all registered component classes, all entity data and all reclaimed IDs
* @returns The {@link EntityMap} instance for chaining
* @example
* ecs.clear()
*/
clear() {
this.components.clear();
this.nextId = 0;
this.freeIds.length = 0;
return this;
}
/**
* Clears all component data and reclaimed IDs while keeping the registered component classes
* @returns The {@link EntityMap} instance for chaining
* @example
* ecs.clearComponents()
*/
clearComponents() {
this.components.forEach(x => x.clear())
this.nextId = 0
this.freeIds.length = 0;
return this;
}
/**
* Iterates over each entity that contains the specified key component
* @param keyComponent - The primary component class used to filter entities
* @param components - Additional component classes to retrieve for each entity
* @returns A {@link ComponentIterator} that yields tuples of [entityId, keyComponentData, ...relatedComponentData]
* @throws {@link ComponentNotRegistered} when any of specified component(s) are not registered
* @example
* // iterate each component data that is related to the Player entity
* const iterator = ecs.iterator(Player, Position)
*
* for(const [playerId, player, position] of iterator) { }
*
* // you can also declare the type of iterator before it's assigned
* // using the ComponentIterator type
* let iterator: ComponentIterator<[Player, Position]>
*
* // then with late bound assignment (keeping the iterator intellisense)
* iterator = ecs.iterator(Player, Position)
*
* for(const [playerId, player, position] of iterator) {
* const moving = player.isMoving
* }
*/
iterator(
keyComponent: ComponentClass,
...components: ComponentClasses
) {
return new ComponentIterator(new ComponentQuery(this, keyComponent, ...components));
}
/**
* Creates a query that can be stored and reused
* @param keyComponent - The primary component class used to filter entities
* @param components - Additional component classes to retrieve for each entity
* @returns A {@link ComponentQuery} instance
* @throws {@link ComponentNotRegistered} when any of specified component(s) are not registered
* @example
* const query = ecs.query(Player, Position)
*
* // get the first entry
* const [playerId, player, position] = query.firstEntry() ?? []
*
* // get the first key
* const [playerId, position] = query.firstKey() ?? []
*
* // get the first value
* const [player, position] = query.firstValue() ?? []
*
* // iterate
* for (const [playerId, player, position] of query) {
*
* }
*/
query(
keyComponent: ComponentClass,
...components: ComponentClasses
) {
return new ComponentQuery(this, keyComponent, ...components)
}
/**
* Prints all component maps in a tabular format to the console.
*
* Additional generated columns are:
*
* 'Entity.Key' is the entity id
*
* 'Entity.Type' is the component name
*
* @param components - Optional filter. Only includes specific component class names.
* @param properties - Optional filter. Specifies which property columns to display in the tables.
* @returns The {@link EntityMap} instance for chaining
*/
printTable(components?: string[]): this;
printTable(components?: string[], properties?: string[]): this;
printTable(components: string[] = [], properties: string[] = []) {
this.components.forEach((map, key) => {
if (components.length === 0 || components.includes(key)) {
console.table(map.toTable(), properties);
}
});
return this;
}
/**
* Prints all component data for the specified entity id in a tabular format to the console
*
* Additional generated columns are:
*
* 'Entity.Type' is the component name
*
* @param entityId - The entity id to print
* @param properties - Optional filter. Specifies which property columns to display in the tables.
* @returns The {@link EntityMap} instance for chaining
*/
printEntity(entityId: number, properties: string[] = []) {
for (const map of this.components.values()) {
if (map.has(entityId) === false) continue;
const data = map.get(entityId);
const columns = {
'entity.type': data.constructor.name,
...data
};
console.table({ [entityId]: columns }, properties);
}
return this;
}
/**
* Parses the JSON and returns an {@link EntityMap} object
* @param json - The JSON string representing an {@link EntityMap}
* @returns A restored {@link EntityMap} instance
* @example
* const json = JSON.stringify(ecs);
* const restoredMap = EntityMap.parse(json);
*/
static parse(json: string): EntityMap {
const restored = JSON.parse(json, function (key: string, value: any) {
if (value.hasOwnProperty('components')) {
Reflect.setPrototypeOf(value, EntityMap.prototype);
return value;
}
if (value.hasOwnProperty('nextId')) return value;
if (value.hasOwnProperty('freeIds')) return value;
if (value.hasOwnProperty(ComponentMapKey)) return new ComponentMap(value.iterable);
if (value.hasOwnProperty(ComponentClassesMapKey)) return new ComponentClassesMap(value.iterable);
return this[key];
});
return restored;
}
/**
* A tracing method used for debugging.
* Intercepts all functions specified and logs each call to the console.
* @param funcFilter - A list of function names you want to intercept. If no function names are specified then will log all functions called.
* @returns A new proxy of an {@link EntityMap} with tracing enabled
* @example
* // trace all method calls
* const ecs = EntityMap.createWithTracing();
*
* // trace only 'set' and 'remove' calls
* const ecs = EntityMap.createWithTracing(['set', 'remove']);
*/
static createWithTracing(funcFilter: string[] = []) {
const traceHandler = {
get(target: any, propKey: string) {
const targetValue = target[propKey]
if (typeof targetValue === 'function' && (funcFilter.length === 0 || funcFilter.includes(propKey))) {
return function (this: any, ...args: any[]) {
console.groupCollapsed('ecs trace', propKey, args);
console.trace();
console.groupEnd();
return targetValue.apply(this, args);
}
}
return targetValue;
}
}
return new Proxy(new EntityMap(), traceHandler)
}
}