392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
import { EventBus } from "@suaveplan/events";
|
|
import { Archetype } from "../archetype/archetype.js";
|
|
import { BitSet } from "../bitset/bitset.js";
|
|
import { ComponentRegistry } from "../component-registry/component-registry.js";
|
|
import { EntityPool } from "../entity-pool/entity-pool.js";
|
|
import { ECS_CODES, EcsError } from "../errors/ecs-error.js";
|
|
import {
|
|
recordExecuteDuration,
|
|
recordLifecycle,
|
|
} from "../observability/otel.js";
|
|
import { Query } from "../query/query.js";
|
|
import { WorldSnapshotSchema } from "../schemas/world.schemas.js";
|
|
import { SystemScheduler } from "../system/system.js";
|
|
import type {
|
|
ComponentData,
|
|
ComponentId,
|
|
ComponentSchema,
|
|
Entity,
|
|
EntitySnapshot,
|
|
QueryDescriptor,
|
|
SystemDefinition,
|
|
WorldApi,
|
|
WorldEventMap,
|
|
WorldSnapshot,
|
|
} from "../types/ecs.types.js";
|
|
|
|
/** Configuration accepted by {@link createWorld} / {@link World}. */
|
|
export interface WorldConfig {
|
|
/** Hard cap on the number of distinct component types. Default 256. */
|
|
readonly maxComponents?: number;
|
|
}
|
|
|
|
/**
|
|
* The World is the central container for an ECS instance. It owns the
|
|
* component registry, entity pool, archetype graph, query cache, and system
|
|
* scheduler, and emits typed lifecycle events through `world.events`.
|
|
*
|
|
* Public mutators ({@link spawn}, {@link despawn}, {@link addComponent},
|
|
* {@link removeComponent}, {@link execute}, {@link snapshot},
|
|
* {@link restore}) are instrumented with OpenTelemetry counters / histograms.
|
|
*/
|
|
export class World implements WorldApi {
|
|
public readonly events = new EventBus<WorldEventMap>();
|
|
public readonly registry: ComponentRegistry;
|
|
|
|
private readonly pool: EntityPool;
|
|
private readonly scheduler: SystemScheduler;
|
|
private readonly archetypes: Archetype[] = [];
|
|
private readonly archetypeMap = new Map<string, Archetype>();
|
|
private readonly entityArchetype = new Map<Entity, Archetype>();
|
|
|
|
public constructor(config?: WorldConfig) {
|
|
this.registry = new ComponentRegistry(config?.maxComponents);
|
|
this.pool = new EntityPool();
|
|
this.scheduler = new SystemScheduler();
|
|
}
|
|
|
|
/** Register a component schema. Idempotent by schema identity. */
|
|
public registerComponent<T extends ComponentData>(
|
|
schema: ComponentSchema<T>
|
|
): void {
|
|
this.registry.register(schema);
|
|
}
|
|
|
|
/** Spawn a new entity in the empty archetype and emit `entitySpawned`. */
|
|
public spawn(): Entity {
|
|
const entity = this.pool.allocate();
|
|
const emptyArch = this.getOrCreateArchetype(
|
|
new BitSet(this.registry.maxComponents),
|
|
[]
|
|
);
|
|
emptyArch.addEntity(entity, new Map());
|
|
this.entityArchetype.set(entity, emptyArch);
|
|
recordLifecycle("spawn");
|
|
this.events.emit("entitySpawned", { entity });
|
|
return entity;
|
|
}
|
|
|
|
/** Despawn `entity`, freeing its ID and emitting `entityDespawned`. */
|
|
public despawn(entity: Entity): void {
|
|
const arch = this.entityArchetype.get(entity);
|
|
if (arch === undefined) return;
|
|
arch.removeEntity(entity);
|
|
this.entityArchetype.delete(entity);
|
|
this.pool.free(entity);
|
|
|
|
recordLifecycle("despawn");
|
|
this.events.emit("entityDespawned", { entity });
|
|
}
|
|
|
|
/** True when `entity` is currently alive in this world. */
|
|
public isAlive(entity: Entity): boolean {
|
|
return this.pool.isAlive(entity);
|
|
}
|
|
|
|
/**
|
|
* Add a component to `entity`, moving it to a new archetype as needed.
|
|
* Throws `ECS.ENTITY_NOT_ALIVE` if the entity has been despawned.
|
|
* No-op when the entity already has the component.
|
|
*/
|
|
public addComponent<T extends ComponentData>(
|
|
entity: Entity,
|
|
schema: ComponentSchema<T>,
|
|
data?: Partial<T>
|
|
): void {
|
|
if (!this.registry.has(schema)) this.registry.register(schema);
|
|
const componentId = this.registry.getId(schema);
|
|
const oldArch = this.entityArchetype.get(entity);
|
|
if (oldArch === undefined) {
|
|
throw new EcsError(
|
|
ECS_CODES.ENTITY_NOT_ALIVE,
|
|
"error.ecs.entity_not_alive",
|
|
{ context: { entity } }
|
|
);
|
|
}
|
|
|
|
if (oldArch.hasComponent(componentId)) return;
|
|
|
|
const merged = { ...schema.defaults, ...data } as T;
|
|
this.validateComponent(schema, merged);
|
|
|
|
const oldData = oldArch.removeEntity(entity) as Map<
|
|
ComponentId,
|
|
ComponentData
|
|
>;
|
|
oldData.set(componentId, merged);
|
|
|
|
const newMask = oldArch.mask.clone();
|
|
newMask.set(componentId);
|
|
const newIds = [...oldArch.getComponentIds(), componentId].sort(
|
|
(a, b) => a - b
|
|
);
|
|
const newArch = this.getOrCreateArchetype(newMask, newIds);
|
|
newArch.addEntity(entity, oldData);
|
|
this.entityArchetype.set(entity, newArch);
|
|
|
|
this.events.emit("componentAdded", {
|
|
entity,
|
|
component: schema.name,
|
|
});
|
|
}
|
|
|
|
/** Remove a component from `entity`, moving it to a new archetype. */
|
|
public removeComponent(entity: Entity, schema: ComponentSchema): void {
|
|
const componentId = this.registry.getId(schema);
|
|
const oldArch = this.entityArchetype.get(entity);
|
|
if (oldArch === undefined) {
|
|
throw new EcsError(
|
|
ECS_CODES.ENTITY_NOT_ALIVE,
|
|
"error.ecs.entity_not_alive",
|
|
{ context: { entity } }
|
|
);
|
|
}
|
|
|
|
if (!oldArch.hasComponent(componentId)) return;
|
|
|
|
const oldData = oldArch.removeEntity(entity) as Map<
|
|
ComponentId,
|
|
ComponentData
|
|
>;
|
|
oldData.delete(componentId);
|
|
const newMask = oldArch.mask.clone();
|
|
newMask.unset(componentId);
|
|
const newIds = oldArch
|
|
.getComponentIds()
|
|
.filter((id) => id !== componentId);
|
|
|
|
const newArch = this.getOrCreateArchetype(newMask, newIds);
|
|
newArch.addEntity(entity, oldData);
|
|
this.entityArchetype.set(entity, newArch);
|
|
|
|
this.events.emit("componentRemoved", {
|
|
entity,
|
|
component: schema.name,
|
|
});
|
|
}
|
|
|
|
/** Read a component value, or `undefined` if absent. */
|
|
public getComponent<T extends ComponentData>(
|
|
entity: Entity,
|
|
schema: ComponentSchema<T>
|
|
): T | undefined {
|
|
const componentId = this.registry.getId(schema);
|
|
const arch = this.entityArchetype.get(entity);
|
|
return arch?.getComponent(entity, componentId) as T | undefined;
|
|
}
|
|
|
|
/**
|
|
* Partially merge new data into an existing component value.
|
|
* Throws `ECS.ENTITY_NOT_ALIVE` if the entity is absent and
|
|
* `ECS.COMPONENT_VALIDATION_FAILED` when the descriptor's Zod schema rejects
|
|
* the merged result.
|
|
*/
|
|
public setComponent<T extends ComponentData>(
|
|
entity: Entity,
|
|
schema: ComponentSchema<T>,
|
|
data: Partial<T>
|
|
): void {
|
|
if (!this.registry.has(schema)) this.registry.register(schema);
|
|
const componentId = this.registry.getId(schema);
|
|
const arch = this.entityArchetype.get(entity);
|
|
if (arch === undefined) {
|
|
throw new EcsError(
|
|
ECS_CODES.ENTITY_NOT_ALIVE,
|
|
"error.ecs.entity_not_alive",
|
|
{ context: { entity } }
|
|
);
|
|
}
|
|
const current = arch.getComponent(entity, componentId) as T | undefined;
|
|
if (current === undefined) {
|
|
this.addComponent(entity, schema, data);
|
|
return;
|
|
}
|
|
const merged = { ...current, ...data } as T;
|
|
this.validateComponent(schema, merged);
|
|
arch.setComponent(entity, componentId, merged);
|
|
}
|
|
|
|
/** True when `entity` has the component. */
|
|
public hasComponent(entity: Entity, schema: ComponentSchema): boolean {
|
|
const componentId = this.registry.getId(schema);
|
|
const arch = this.entityArchetype.get(entity);
|
|
return arch?.hasComponent(componentId) ?? false;
|
|
}
|
|
|
|
/** Evaluate a one-off query — for repeated queries register a system. */
|
|
public query(descriptor: QueryDescriptor): ReadonlyArray<Entity> {
|
|
const q = new Query(descriptor, this.registry);
|
|
return q.evaluate(this.archetypes);
|
|
}
|
|
|
|
/**
|
|
* Register a system. Throws `ECS.SYSTEM_NAME_CONFLICT` if another system
|
|
* shares the same name.
|
|
*/
|
|
public addSystem(system: SystemDefinition): void {
|
|
if (this.scheduler.has(system.name)) {
|
|
throw new EcsError(
|
|
ECS_CODES.SYSTEM_NAME_CONFLICT,
|
|
"error.ecs.system_name_conflict",
|
|
{ context: { name: system.name } }
|
|
);
|
|
}
|
|
const schemas: ComponentSchema[] = [
|
|
...system.query.include,
|
|
...(system.query.exclude ?? []),
|
|
];
|
|
for (const schema of schemas) {
|
|
if (!this.registry.has(schema)) this.registry.register(schema);
|
|
}
|
|
this.scheduler.add(system);
|
|
}
|
|
|
|
/** Remove a system by name. Returns `false` if no match. */
|
|
public removeSystem(name: string): boolean {
|
|
return this.scheduler.remove(name);
|
|
}
|
|
|
|
/**
|
|
* Run every enabled system in scheduler order. Records the wall-clock
|
|
* duration in the `ecs.world.execute_duration_ms` histogram.
|
|
*/
|
|
public execute(dt: number): void {
|
|
const start = performance.now();
|
|
const ordered = this.scheduler.getOrdered();
|
|
for (const system of ordered) {
|
|
if (!system.enabled) continue;
|
|
const q = new Query(system.query, this.registry);
|
|
const entities = q.evaluate(this.archetypes);
|
|
system.execute({ entities, world: this, dt });
|
|
}
|
|
recordExecuteDuration(performance.now() - start);
|
|
}
|
|
|
|
/** Capture a structured snapshot of every alive entity. */
|
|
public snapshot(): WorldSnapshot {
|
|
const entities: EntitySnapshot[] = [];
|
|
for (const [entity, arch] of this.entityArchetype) {
|
|
const components: {
|
|
readonly name: string;
|
|
readonly data: ComponentData;
|
|
}[] = [];
|
|
for (const cid of arch.getComponentIds()) {
|
|
const schema = this.registry.getSchema(cid) as ComponentSchema;
|
|
const data = arch.getComponent(entity, cid) as ComponentData;
|
|
components.push({
|
|
name: schema.name,
|
|
data: structuredClone(data),
|
|
});
|
|
}
|
|
entities.push({ id: entity, components });
|
|
}
|
|
return { entities, nextEntityId: this.pool.peekNextId };
|
|
}
|
|
|
|
/**
|
|
* Restore world state from a snapshot. The snapshot is Zod-validated;
|
|
* `ECS.SNAPSHOT_INVALID` is thrown on failure. Component schemas
|
|
* referenced by name must already be registered — unknown names are
|
|
* silently skipped.
|
|
*/
|
|
public restore(snap: WorldSnapshot): void {
|
|
const parsed = WorldSnapshotSchema.safeParse(snap);
|
|
if (!parsed.success) {
|
|
throw new EcsError(
|
|
ECS_CODES.SNAPSHOT_INVALID,
|
|
"error.ecs.snapshot_invalid",
|
|
{ context: { issues: parsed.error.issues } }
|
|
);
|
|
}
|
|
this.clear();
|
|
this.pool.setNextId(parsed.data.nextEntityId);
|
|
for (const es of parsed.data.entities) {
|
|
const entity = this.pool.allocate();
|
|
const emptyArch = this.getOrCreateArchetype(
|
|
new BitSet(this.registry.maxComponents),
|
|
[]
|
|
);
|
|
emptyArch.addEntity(entity, new Map());
|
|
this.entityArchetype.set(entity, emptyArch);
|
|
for (const comp of es.components) {
|
|
const schema = this.registry.getSchemaByName(comp.name);
|
|
if (schema !== undefined) {
|
|
this.addComponent(entity, schema, comp.data);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Remove every entity and archetype. Systems and registrations are kept. */
|
|
public clear(): void {
|
|
this.entityArchetype.clear();
|
|
this.archetypes.length = 0;
|
|
this.archetypeMap.clear();
|
|
this.pool.clear();
|
|
}
|
|
|
|
/** Number of alive entities. */
|
|
public get entityCount(): number {
|
|
return this.pool.aliveCount;
|
|
}
|
|
|
|
/** Number of distinct archetypes currently held. */
|
|
public get archetypeCount(): number {
|
|
return this.archetypes.length;
|
|
}
|
|
|
|
/** Read-only snapshot of every registered system. */
|
|
public getSystems(): ReadonlyArray<SystemDefinition> {
|
|
return this.scheduler.getAll();
|
|
}
|
|
|
|
private getOrCreateArchetype(
|
|
mask: BitSet,
|
|
componentIds: ReadonlyArray<ComponentId>
|
|
): Archetype {
|
|
const key = mask.toKey();
|
|
let arch = this.archetypeMap.get(key);
|
|
if (arch === undefined) {
|
|
arch = new Archetype(mask, componentIds);
|
|
this.archetypes.push(arch);
|
|
this.archetypeMap.set(key, arch);
|
|
}
|
|
return arch;
|
|
}
|
|
|
|
private validateComponent<T extends ComponentData>(
|
|
schema: ComponentSchema<T>,
|
|
value: T
|
|
): void {
|
|
if (schema.schema === undefined) return;
|
|
const result = schema.schema.safeParse(value);
|
|
if (!result.success) {
|
|
throw new EcsError(
|
|
ECS_CODES.COMPONENT_VALIDATION_FAILED,
|
|
"error.ecs.component_validation_failed",
|
|
{
|
|
context: {
|
|
name: schema.name,
|
|
issues: result.error.issues,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Factory wrapper around `new World(config)`. */
|
|
export function createWorld(config?: WorldConfig): World {
|
|
return new World(config);
|
|
}
|