UNPKG

ecsjs

Version:

An entity component system library for JavaScript

456 lines (406 loc) 15 kB
/* 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) } }