ecsjs
Version:
An entity component system library for JavaScript
456 lines (406 loc) • 15 kB
text/typescript
/*
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 <http://www.gnu.org/licenses/>.
*/
import { ComponentClassesMap, ComponentMap } from './component-map.js';
import { ComponentNotRegistered, ComponentTypeKeyMissing } from './errors.js';
import { ComponentIterator } from './iterators.js';
import {
ComponentClassesMapKey,
ComponentMapKey,
type Component,
type ComponentClass,
type ComponentInstances
} from './types.js';
/**
* Class for storing entities and their relationships
* @category Maps
*/
export class EntityMap {
/**
* Registered component classes that contain the component instance data
*/
public components = new ComponentClassesMap()
private nextId: number = 0
/**
* Registers component classes with the {@link EntityMap}
* @throwsError {@link ComponentTypeKeyMissing} when the specified component type is missing a 'name' parameter
* @example
* // component class
* class MyComponent {
* constructor(x) {
* this.x = x;
* }
* }
*
* ecs.register(MyComponent);
* // or mulitple
* ecs.register(MyComponent1, MyComponent2);
*/
register<TComponentClasses extends ComponentClass<any>[]>(...componentClasses: TComponentClasses) {
for (const componentClass of componentClasses) {
const componentName = componentClass.name;
if (componentName === undefined) throw new ComponentTypeKeyMissing()
// create the component map
const componentDataMap: ComponentMap<any> = new ComponentMap();
this.components.set(componentName, componentDataMap);
}
// chain
return this;
}
/**
* Gets a component class map
* @throwsError {@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<T>(component: ComponentClass<T>): ComponentMap<T> | undefined {
const map = this.components.get(component.name);
if (map === undefined) throw new ComponentNotRegistered(component.name)
return map
}
/**
* Gets the first entity entry for a component class
* @throwsError {@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<TKey extends ComponentClass<any>, T extends ComponentClass<any>[]>(
keyComponent: TKey,
...components: T
): ComponentInstances<[ComponentClass<number>, TKey, ...T]> | undefined;
firstEntry(keyComponent: ComponentClass<any>, ...components: ComponentClass<any>[]) {
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
* @throwsError {@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<TKey extends ComponentClass<any>, T extends ComponentClass<any>[]>(
keyComponent: TKey,
...components: T
): ComponentInstances<[ComponentClass<number>, ...T]> | undefined;
firstKey(keyComponent: ComponentClass<any>, ...components: ComponentClass<any>[]) {
const entityId = this.getMap(keyComponent)?.firstKey();
// single component key
if (arguments.length === 1) return entityId;
if (entityId === undefined) return undefined;
// attach multiple related component values
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
* @throwsError {@link ComponentNotRegistered} when any of specified component(s) are not registered
* @example
* // return the first component value
* const player = ecs.firstValue(Player)
*
* // or multiple related values in addition to the first component
* const [player, position, direction] = ecs.firstValue(
* Player,
* Position,
* Direction
* )
*/
firstValue<TKey extends ComponentClass<any>, T extends ComponentClass<any>[]>(
keyComponent: TKey,
...components: T
): ComponentInstances<[TKey, ...T]> | undefined;
firstValue(keyComponent: ComponentClass<any>, ...components: ComponentClass<any>[]) {
// single component
if (arguments.length === 1) return this.getMap(keyComponent)?.firstValue();
// get the first entry
const [entityId, value] = this.getMap(keyComponent)?.firstEntry() ?? [];
if (entityId === undefined) return undefined;
// attach multiple related components
return [value, ...components.map(x => this.getEntity(entityId, x))]
}
/**
* @throwsError {@link ComponentNotRegistered} when the specified component is not registered
*/
private getEntity<T>(entityId: number, component: ComponentClass<T>): T | undefined {
const map = this.components.get(component.name);
if (map === undefined) throw new ComponentNotRegistered(component.name)
return map.get(entityId);
}
/**
* Gets component values related to an entity id
* @throwsError {@link ComponentNotRegistered} when any of specified component(s) are not registered
* @example
* // get one
* const player = ecs.get(entityId, Player)
*
* // or get multiple
* const [player, position] = ecs.get(entityId, Player, Position) ?? []
*/
get<T extends ComponentClass<any>[]>(entityId: number, ...components: T): ComponentInstances<T> | undefined;
get<T extends Component>(entityId: number, ...components: ComponentClass<T>[]): T | (T | undefined)[] | undefined {
if (components.length > 1) return components.map(x => this.getEntity(entityId, x))
// return a single component
return this.getEntity(entityId, components[0])
}
/**
* Check if a component exists for an entity
* @example
* const exists = ecs.has(entityId, Position)
*/
has<T>(entityId: number, component: ComponentClass<T>): boolean {
// get the component map
const map = this.components.get(component.name);
if (map === undefined) return false
return map.has(entityId);
}
/**
* Checks if all of the specified components exist for an entity
* @example
* const hasAll = ecs.hasAll(entityId, Position, Velocity)
*/
hasAll<T extends ComponentClass<any>[]>(entityId: number, ...components: T): boolean {
for (let index = 0; index < components.length; index++) {
const component = components[index];
const map = this.components.get(component.name);
if (map === undefined) return false;
if (map.has(entityId) === false) return false;
}
return true
}
/**
* Checks if any of the specified components exist for an entity
* @example
* const hasAny = ecs.hasAny(entityId, Position, Velocity)
*/
hasAny<T extends ComponentClass<any>[]>(entityId: number, ...components: T): boolean {
for (let index = 0; index < components.length; index++) {
const component = components[index];
const map = this.components.get(component.name);
if (map === undefined) continue;
if (map.has(entityId)) return true;
}
return false
}
private setEntity<T extends Component>(entityId: number, componentData: T): T {
// get the component map
const map = this.components.get(componentData.constructor.name);
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 values for an entity
* @example
* // set one
* const player = ecs.set(entityId, new Player());
*
* // or set multiple
* const [player, position] = ecs.set(
* entityId,
* new Player(),
* new Position()
* );
*/
set<T extends Component>(entityId: number, component: T): T;
set<T extends Component[]>(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
* @example
* ecs.remove(entityId, Position);
*/
remove<T extends ComponentClass<any>[]>(entityId: number, ...components: T) {
for (const component of components) {
this.removeByKey(entityId, component.name)
}
}
/**
* Removes the specified component from an entity
* @throwsError {@link ComponentNotRegistered} when the specified component is not registered
* @example
* ecs.removeByKey(entityId, "Position");
*/
removeByKey(entityId: number, componentName: string) {
// get the entity map
const entityMap = this.components.get(componentName);
// ensure the map is defined
if (entityMap === undefined) throw new ComponentNotRegistered(componentName);
// 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
* @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];
for (const map of this.components.values()) {
if (map.has(entityId)) {
map.delete(entityId);
deletedCount++;
}
}
}
return deletedCount;
}
// TODO create id generator
/**
* Creates a new entity id for the EntityMap
* @example
* const newEntityId = ecs.getNextId()
* ecs.set(newEntityId, new Player())
*/
getNextId(): number {
this.nextId++;
return this.nextId;
}
/**
* Clears all registered components
*/
clear() {
this.components.clear();
return this;
}
/**
* Clears all component data
*/
clearComponents() {
this.components.forEach(x => x.clear())
return this;
}
/**
* Iterates over each component value that is related to the key component
* @throwsError {@link ComponentNotRegistered} when any of specified component(s) are not registered
* @example
* // iterate each component value 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
* let iterator: IComponentIterator<[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<K extends ComponentClass<any>, T extends ComponentClass<any>[]>(
keyComponent: K,
...components: T
): ComponentIterator<K, T>
iterator(keyComponent: ComponentClass<any>, ...components: ComponentClass<any>[]) {
return new ComponentIterator(this, keyComponent, ...components) as any;
}
/**
* Prints all component maps in a tabular format to the console
*/
printTable() {
this.components.forEach(map => console.table(map.toTable(true)));
return this;
}
/**
* Prints all component data for the specified entity id in a tabular format to the console
*/
printEntity(entityId: number) {
for (const map of this.components.values()) {
if (map.has(entityId) === false) continue;
const data = map.get(entityId);
const columns = {
name: data.constructor.name,
...data
};
console.table({ [entityId]: columns });
}
return this;
}
/**
* Parse's the JSON and returns an EntityMap object
* @example
* const json = JSON.stringfy(ecs);
* const restoredMap = ecs.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(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 {Array} funcFilter A list of function names you want to intercept. If no function names are specified then will log all functions called
* @return {EntityMap} A new entity map with tracing enabled
*/
static createWithTracing(funcFilter: any) {
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)
}
}