Files
suaveplan/packages/gaming/games/ecs/src/world/world.ts
2026-05-27 23:47:53 +01:00

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);
}