/* 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) } }