Initial Commit
This commit is contained in:
391
packages/gaming/games/ecs/src/world/world.ts
Normal file
391
packages/gaming/games/ecs/src/world/world.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user