ecspresso
Version:
A minimal Entity-Component-System library for typescript and javascript.
298 lines (297 loc) • 14.8 kB
TypeScript
import type { Entity, FilteredEntity, RemoveEntityOptions, HierarchyEntry, HierarchyIteratorOptions } from "./types";
import QueryCache from "./query-cache";
export default class EntityManager<ComponentTypes> {
private nextId;
private entities;
private componentIndices;
/**
* Callbacks registered for component additions
*/
private addedCallbacks;
/**
* Callbacks registered for component removals
*/
private removedCallbacks;
/**
* Hierarchy manager for parent-child relationships
*/
private hierarchyManager;
/**
* Per-type component dispose callbacks.
* Called when a component is removed (explicit removal, entity destruction, or replacement).
*/
private disposeCallbacks;
/**
* Per-entity per-component change sequence tracking.
* Flat storage: changeSeqs[entityId][componentIdx] = seq number when last changed.
* Component names are mapped to dense indices via componentNameToIdx.
* Uint32Array zero-init means "never changed" (seq numbers start at 1).
*/
private changeSeqs;
private componentNameToIdx;
private _idxCache0Name;
private _idxCache0Idx;
private _idxCache1Name;
private _idxCache1Idx;
/**
* Subscription bitmap for change tracking. `null` means track all (the
* default); a Uint8Array means explicit-only, with 1 at indices that opted
* in via `subscribeChanged`. Indices outside the array's bounds are
* treated as 0 (not tracked).
*/
private _subscribedComponentIdx;
/**
* Monotonic sequence counter for change detection.
* Each markChanged call increments this and stamps the new value.
*/
private _changeSeq;
private _afterComponentAddedHooks;
private _afterEntityMutatedHooks;
private _afterComponentRemovedHooks;
private _beforeEntityRemovedHooks;
private _afterParentChangedHooks;
/**
* Incrementally-maintained query result cache. Caches the static portion
* (with / without / parentHas) of each registered query shape and is
* updated via the lifecycle hook arrays above. Lazily created on the
* first cacheable query lookup.
*/
private readonly _queryCache;
private _batchingDepth;
private _batchedEntityIds;
/** Component keys being added in the current addComponents batch, if any.
* Used by required component resolution to skip auto-adding explicitly provided components. */
_pendingBatchKeys: ReadonlySet<keyof ComponentTypes> | null;
get entityCount(): number;
createEntity(): Entity<ComponentTypes>;
/**
* Register a dispose callback for a component type.
* Called when a component is removed (explicit removal, entity destruction, or replacement).
* Later registrations replace earlier ones for the same component type.
* @param componentName The component type to register disposal for
* @param callback Function receiving the component value being disposed and the entity ID
*/
registerDispose<ComponentName extends keyof ComponentTypes>(componentName: ComponentName, callback: (ctx: {
value: ComponentTypes[ComponentName];
entityId: number;
}) => void): void;
/**
* Get all registered dispose callbacks.
* @internal Used by ECSpresso for plugin installation
*/
getDisposeCallbacks(): Map<keyof ComponentTypes, (ctx: {
value: unknown;
entityId: number;
}) => void>;
/**
* Invoke the dispose callback for a component, if registered.
* Errors are caught and logged to prevent blocking removal.
*/
private invokeDispose;
addComponent<ComponentName extends keyof ComponentTypes>(entityId: number, componentName: ComponentName, data: ComponentTypes[ComponentName]): this;
/**
* Add multiple components to an entity at once
* @param entityId Entity ID to add components to
* @param components Object with component names as keys and component data as values
*/
addComponents<T extends {
[K in keyof ComponentTypes]?: ComponentTypes[K];
}>(entityId: number, components: T & Record<Exclude<keyof T, keyof ComponentTypes>, never>): this;
removeComponent<ComponentName extends keyof ComponentTypes>(entityId: number, componentName: ComponentName): this;
getComponent<ComponentName extends keyof ComponentTypes>(entityId: number, componentName: ComponentName): ComponentTypes[ComponentName] | undefined;
getEntitiesWithQuery<WithComponents extends keyof ComponentTypes = never, WithoutComponents extends keyof ComponentTypes = never>(required?: ReadonlyArray<WithComponents>, excluded?: ReadonlyArray<WithoutComponents>, changed?: ReadonlyArray<keyof ComponentTypes>, changeThreshold?: number, parentHas?: ReadonlyArray<keyof ComponentTypes>, changedIdx?: ReadonlyArray<number>): Array<FilteredEntity<ComponentTypes, WithComponents extends never ? never : WithComponents, WithoutComponents extends never ? never : WithoutComponents>>;
/**
* Fill an existing array with entities matching the query, clearing it first.
* Returns the same array reference for convenience.
*
* `changedIdx`, when supplied, skips the per-call name→idx resolution loop.
* The framework pre-resolves it once at system registration; ad-hoc callers
* may omit it and pay the per-call lookup cost.
*/
getEntitiesWithQueryInto<WithComponents extends keyof ComponentTypes = never, WithoutComponents extends keyof ComponentTypes = never>(output: Array<FilteredEntity<ComponentTypes, WithComponents extends never ? never : WithComponents, WithoutComponents extends never ? never : WithoutComponents>>, required?: ReadonlyArray<WithComponents>, excluded?: ReadonlyArray<WithoutComponents>, changed?: ReadonlyArray<keyof ComponentTypes>, changeThreshold?: number, parentHas?: ReadonlyArray<keyof ComponentTypes>, changedIdx?: ReadonlyArray<number>): Array<FilteredEntity<ComponentTypes, WithComponents extends never ? never : WithComponents, WithoutComponents extends never ? never : WithoutComponents>>;
/** Test-only accessor for the internal query cache. @internal */
get _queryCacheForTesting(): QueryCache<ComponentTypes>;
removeEntity(entityId: number, options?: RemoveEntityOptions): boolean;
/**
* Internal method to remove a single entity without cascade logic
*/
private removeEntityInternal;
getEntity(entityId: number): Entity<ComponentTypes> | undefined;
/**
* Register a callback when a specific component is added to any entity
* @param componentName The component key
* @param handler Function receiving the new component value and the entity
* @returns Unsubscribe function to remove the callback
*/
onComponentAdded<ComponentName extends keyof ComponentTypes>(componentName: ComponentName, handler: (ctx: {
value: ComponentTypes[ComponentName];
entity: Entity<ComponentTypes>;
}) => void): () => void;
/**
* Register a callback when a specific component is removed from any entity
* @param componentName The component key
* @param handler Function receiving the old component value and the entity
* @returns Unsubscribe function to remove the callback
*/
onComponentRemoved<ComponentName extends keyof ComponentTypes>(componentName: ComponentName, handler: (ctx: {
value: ComponentTypes[ComponentName];
entity: Entity<ComponentTypes>;
}) => void): () => void;
onAfterComponentAdded(hook: (entityId: number, componentName: keyof ComponentTypes) => void): () => void;
onAfterEntityMutated(hook: (entityId: number) => void): () => void;
onAfterComponentRemoved(hook: (entityId: number, componentName: keyof ComponentTypes) => void): () => void;
onBeforeEntityRemoved(hook: (entityId: number) => void): () => void;
onAfterParentChanged(hook: (childId: number) => void): () => void;
/**
* The current monotonic change sequence value.
* Each markChanged call increments this before stamping.
*/
get changeSeq(): number;
/**
* Mark a component as changed on an entity, stamping the next sequence number.
* @param entityId The entity ID
* @param componentName The component that changed
*/
markChanged<K extends keyof ComponentTypes>(entityId: number, componentName: K): void;
/**
* Fast-path companion to markChanged that skips the component-name lookup.
* Use after resolving names to indices once via getOrAssignComponentIdx.
*/
markChangedByIdx(entityId: number, componentIdx: number): void;
getOrAssignComponentIdx<K extends keyof ComponentTypes>(componentName: K): number;
/**
* @internal Subscribe a component to change tracking. First call transitions
* from default track-all (null bitmap) to explicit-only mode; subsequent calls
* extend the subscription set. Called by ECSpresso._registerSystem for each
* component named in a query's `changed:` filter.
*/
subscribeChanged<K extends keyof ComponentTypes>(componentName: K): void;
/**
* @internal Opt out of change tracking entirely. Installs an empty bitmap,
* making every markChanged call a no-op until subscribeChanged is called.
* Used by `.disableChangeTracking()` on the builder for worlds with no
* reactive consumers.
*/
disableChangeTracking(): void;
/**
* @internal True if at least one of `idxs` is currently subscribed for
* change tracking (or the bitmap is null = track-all). Used by the
* auto-mark walk to skip entity iteration when every mark would be a no-op.
*/
hasAnySubscribed(idxs: ReadonlyArray<number>): boolean;
/**
* Get the sequence number at which a component was last changed on an entity
* @param entityId The entity ID
* @param componentName The component to check
* @returns The sequence number when last changed, or -1 if never changed
*/
getChangeSeq<K extends keyof ComponentTypes>(entityId: number, componentName: K): number;
/**
* Create an entity as a child of another entity with initial components
* @param parentId The parent entity ID
* @param components Initial components to add
* @returns The created child entity
*/
spawnChild<T extends {
[K in keyof ComponentTypes]?: ComponentTypes[K];
}>(parentId: number, components: T & Record<Exclude<keyof T, keyof ComponentTypes>, never>): FilteredEntity<ComponentTypes, keyof T & keyof ComponentTypes>;
/**
* Set the parent of an entity
* @param childId The entity ID to set as a child
* @param parentId The entity ID to set as the parent
*/
setParent(childId: number, parentId: number): this;
/**
* Remove the parent relationship for an entity (orphan it)
* @param childId The entity ID to orphan
* @returns true if a parent was removed, false if entity had no parent
*/
removeParent(childId: number): boolean;
/**
* Get the parent of an entity
* @param entityId The entity ID to get the parent of
* @returns The parent entity ID, or null if no parent
*/
getParent(entityId: number): number | null;
/**
* Get all children of an entity in insertion order
* @param parentId The parent entity ID
* @returns Readonly array of child entity IDs
*/
getChildren(parentId: number): readonly number[];
/**
* Get a child at a specific index
* @param parentId The parent entity ID
* @param index The index of the child
* @returns The child entity ID, or null if index is out of bounds
*/
getChildAt(parentId: number, index: number): number | null;
/**
* Get the index of a child within its parent's children list
* @param parentId The parent entity ID
* @param childId The child entity ID to find
* @returns The index of the child, or -1 if not found
*/
getChildIndex(parentId: number, childId: number): number;
/**
* Get all ancestors of an entity in order [parent, grandparent, ...]
* @param entityId The entity ID to get ancestors of
* @returns Readonly array of ancestor entity IDs
*/
getAncestors(entityId: number): readonly number[];
/**
* Get all descendants of an entity in depth-first order
* @param entityId The entity ID to get descendants of
* @returns Readonly array of descendant entity IDs
*/
getDescendants(entityId: number): readonly number[];
/**
* Get the root ancestor of an entity (topmost parent), or self if no parent
* @param entityId The entity ID to get the root of
* @returns The root entity ID
*/
getRoot(entityId: number): number;
/**
* Get siblings of an entity (other children of the same parent)
* @param entityId The entity ID to get siblings of
* @returns Readonly array of sibling entity IDs
*/
getSiblings(entityId: number): readonly number[];
/**
* Check if an entity is a descendant of another entity
* @param entityId The potential descendant ID
* @param ancestorId The potential ancestor ID
* @returns true if entityId is a descendant of ancestorId
*/
isDescendantOf(entityId: number, ancestorId: number): boolean;
/**
* Check if an entity is an ancestor of another entity
* @param entityId The potential ancestor ID
* @param descendantId The potential descendant ID
* @returns true if entityId is an ancestor of descendantId
*/
isAncestorOf(entityId: number, descendantId: number): boolean;
/**
* Returns true when at least one parent-child relationship exists.
*/
get hasHierarchy(): boolean;
/**
* Get all root entities (entities that have children but no parent)
* @returns Readonly array of root entity IDs
*/
getRootEntities(): readonly number[];
/**
* Traverse the hierarchy in parent-first (breadth-first) order.
* Parents are guaranteed to be visited before their children.
* @param callback Function called for each entity with (entityId, parentId, depth)
* @param options Optional traversal options (roots to filter to specific subtrees)
*/
forEachInHierarchy(callback: (entityId: number, parentId: number | null, depth: number) => void, options?: HierarchyIteratorOptions): void;
/**
* Generator-based hierarchy traversal in parent-first (breadth-first) order.
* Supports early termination via break.
* @param options Optional traversal options (roots to filter to specific subtrees)
* @yields HierarchyEntry for each entity in parent-first order
*/
hierarchyIterator(options?: HierarchyIteratorOptions): Generator<HierarchyEntry, void, unknown>;
}