@aura3d/ecs 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/Archetype.d.ts +11 -0
- package/dist/Archetype.d.ts.map +1 -0
- package/dist/Archetype.js +19 -0
- package/dist/Archetype.js.map +1 -0
- package/dist/Bitset.d.ts +12 -0
- package/dist/Bitset.d.ts.map +1 -0
- package/dist/Bitset.js +43 -0
- package/dist/Bitset.js.map +1 -0
- package/dist/CommandBuffer.d.ts +16 -0
- package/dist/CommandBuffer.d.ts.map +1 -0
- package/dist/CommandBuffer.js +86 -0
- package/dist/CommandBuffer.js.map +1 -0
- package/dist/Component.d.ts +18 -0
- package/dist/Component.d.ts.map +1 -0
- package/dist/Component.js +7 -0
- package/dist/Component.js.map +1 -0
- package/dist/ComponentRegistry.d.ts +12 -0
- package/dist/ComponentRegistry.d.ts.map +1 -0
- package/dist/ComponentRegistry.js +36 -0
- package/dist/ComponentRegistry.js.map +1 -0
- package/dist/ComponentStore.d.ts +21 -0
- package/dist/ComponentStore.d.ts.map +1 -0
- package/dist/ComponentStore.js +66 -0
- package/dist/ComponentStore.js.map +1 -0
- package/dist/ECSProfiler.d.ts +12 -0
- package/dist/ECSProfiler.d.ts.map +1 -0
- package/dist/ECSProfiler.js +13 -0
- package/dist/ECSProfiler.js.map +1 -0
- package/dist/ECSSerializer.d.ts +18 -0
- package/dist/ECSSerializer.d.ts.map +1 -0
- package/dist/ECSSerializer.js +37 -0
- package/dist/ECSSerializer.js.map +1 -0
- package/dist/Entity.d.ts +8 -0
- package/dist/Entity.d.ts.map +1 -0
- package/dist/Entity.js +14 -0
- package/dist/Entity.js.map +1 -0
- package/dist/EntityManager.d.ts +12 -0
- package/dist/EntityManager.d.ts.map +1 -0
- package/dist/EntityManager.js +33 -0
- package/dist/EntityManager.js.map +1 -0
- package/dist/Query.d.ts +24 -0
- package/dist/Query.d.ts.map +1 -0
- package/dist/Query.js +47 -0
- package/dist/Query.js.map +1 -0
- package/dist/SparseSet.d.ts +14 -0
- package/dist/SparseSet.d.ts.map +1 -0
- package/dist/SparseSet.js +50 -0
- package/dist/SparseSet.js.map +1 -0
- package/dist/System.d.ts +18 -0
- package/dist/System.d.ts.map +1 -0
- package/dist/System.js +2 -0
- package/dist/System.js.map +1 -0
- package/dist/SystemScheduler.d.ts +10 -0
- package/dist/SystemScheduler.d.ts.map +1 -0
- package/dist/SystemScheduler.js +79 -0
- package/dist/SystemScheduler.js.map +1 -0
- package/dist/World.d.ts +27 -0
- package/dist/World.d.ts.map +1 -0
- package/dist/World.js +68 -0
- package/dist/World.js.map +1 -0
- package/dist/components/ActiveComponent.d.ts +16 -0
- package/dist/components/ActiveComponent.d.ts.map +1 -0
- package/dist/components/ActiveComponent.js +46 -0
- package/dist/components/ActiveComponent.js.map +1 -0
- package/dist/components/HierarchyComponent.d.ts +34 -0
- package/dist/components/HierarchyComponent.d.ts.map +1 -0
- package/dist/components/HierarchyComponent.js +103 -0
- package/dist/components/HierarchyComponent.js.map +1 -0
- package/dist/components/NameComponent.d.ts +5 -0
- package/dist/components/NameComponent.d.ts.map +1 -0
- package/dist/components/NameComponent.js +10 -0
- package/dist/components/NameComponent.js.map +1 -0
- package/dist/components/TagComponent.d.ts +5 -0
- package/dist/components/TagComponent.d.ts.map +1 -0
- package/dist/components/TagComponent.js +10 -0
- package/dist/components/TagComponent.js.map +1 -0
- package/dist/components/TransformComponent.d.ts +18 -0
- package/dist/components/TransformComponent.d.ts.map +1 -0
- package/dist/components/TransformComponent.js +27 -0
- package/dist/components/TransformComponent.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/systems/ActiveSystem.d.ts +16 -0
- package/dist/systems/ActiveSystem.d.ts.map +1 -0
- package/dist/systems/ActiveSystem.js +61 -0
- package/dist/systems/ActiveSystem.js.map +1 -0
- package/dist/systems/HierarchySystem.d.ts +25 -0
- package/dist/systems/HierarchySystem.d.ts.map +1 -0
- package/dist/systems/HierarchySystem.js +185 -0
- package/dist/systems/HierarchySystem.js.map +1 -0
- package/dist/systems/index.d.ts +3 -0
- package/dist/systems/index.d.ts.map +1 -0
- package/dist/systems/index.js +3 -0
- package/dist/systems/index.js.map +1 -0
- package/package.json +18 -0
- package/src/Archetype.ts +23 -0
- package/src/Bitset.ts +47 -0
- package/src/CommandBuffer.ts +97 -0
- package/src/Component.ts +25 -0
- package/src/ComponentRegistry.ts +38 -0
- package/src/ComponentStore.ts +72 -0
- package/src/ECSProfiler.ts +19 -0
- package/src/ECSSerializer.ts +45 -0
- package/src/Entity.ts +19 -0
- package/src/EntityManager.ts +36 -0
- package/src/Query.ts +57 -0
- package/src/SparseSet.ts +56 -0
- package/src/System.ts +20 -0
- package/src/SystemScheduler.ts +79 -0
- package/src/World.ts +76 -0
- package/src/components/ActiveComponent.ts +56 -0
- package/src/components/HierarchyComponent.ts +124 -0
- package/src/components/NameComponent.ts +7 -0
- package/src/components/TagComponent.ts +7 -0
- package/src/components/TransformComponent.ts +34 -0
- package/src/index.ts +21 -0
- package/src/systems/ActiveSystem.ts +64 -0
- package/src/systems/HierarchySystem.ts +182 -0
- package/src/systems/index.ts +2 -0
- package/tests/ecs.test.ts +134 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { ValidationError } from "@aura3d/core";
|
|
2
|
+
import { type ComponentCtor } from "./Component.js";
|
|
3
|
+
import { entityKey, type Entity } from "./Entity.js";
|
|
4
|
+
import { type World } from "./World.js";
|
|
5
|
+
|
|
6
|
+
export type EntityRef = Entity | { readonly tempId: number };
|
|
7
|
+
|
|
8
|
+
type Command =
|
|
9
|
+
| { kind: "create"; tempId: number }
|
|
10
|
+
| { kind: "destroy"; entity: EntityRef }
|
|
11
|
+
| { kind: "add"; entity: EntityRef; ctor: ComponentCtor; component: object }
|
|
12
|
+
| { kind: "remove"; entity: EntityRef; ctor: ComponentCtor };
|
|
13
|
+
|
|
14
|
+
export class CommandBuffer {
|
|
15
|
+
private nextTempId = 1;
|
|
16
|
+
private readonly commands: Command[] = [];
|
|
17
|
+
|
|
18
|
+
createEntity(): EntityRef {
|
|
19
|
+
const ref = { tempId: this.nextTempId };
|
|
20
|
+
this.nextTempId += 1;
|
|
21
|
+
this.commands.push({ kind: "create", tempId: ref.tempId });
|
|
22
|
+
return ref;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
destroyEntity(entity: EntityRef): void {
|
|
26
|
+
this.commands.push({ kind: "destroy", entity });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
add<T extends object>(entity: EntityRef, ctor: ComponentCtor<T>, component: T): void {
|
|
30
|
+
this.commands.push({ kind: "add", entity, ctor, component });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
remove<T extends object>(entity: EntityRef, ctor: ComponentCtor<T>): void {
|
|
34
|
+
this.commands.push({ kind: "remove", entity, ctor });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
flush(world: World): Entity[] {
|
|
38
|
+
validateCommands(world, this.commands);
|
|
39
|
+
const tempEntities = new Map<number, Entity>();
|
|
40
|
+
const created: Entity[] = [];
|
|
41
|
+
const commands = this.commands.splice(0, this.commands.length);
|
|
42
|
+
for (const command of commands) {
|
|
43
|
+
if (command.kind === "create") {
|
|
44
|
+
const entity = world.createEntity();
|
|
45
|
+
tempEntities.set(command.tempId, entity);
|
|
46
|
+
created.push(entity);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const entity = resolve(command.entity, tempEntities);
|
|
50
|
+
if (command.kind === "destroy") world.destroyEntity(entity);
|
|
51
|
+
if (command.kind === "add") world.add(entity, command.ctor, command.component);
|
|
52
|
+
if (command.kind === "remove") world.remove(entity, command.ctor);
|
|
53
|
+
}
|
|
54
|
+
return created;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validateCommands(world: World, commands: readonly Command[]): void {
|
|
59
|
+
const tempAlive = new Set<number>();
|
|
60
|
+
const deadEntities = new Set<string>();
|
|
61
|
+
|
|
62
|
+
for (const command of commands) {
|
|
63
|
+
if (command.kind === "create") {
|
|
64
|
+
tempAlive.add(command.tempId);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
validateEntityRef(world, command.entity, tempAlive, deadEntities);
|
|
69
|
+
if (command.kind === "remove") world.registry.require(command.ctor);
|
|
70
|
+
if (command.kind === "destroy") {
|
|
71
|
+
if ("tempId" in command.entity) {
|
|
72
|
+
tempAlive.delete(command.entity.tempId);
|
|
73
|
+
} else {
|
|
74
|
+
deadEntities.add(entityKey(command.entity));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function validateEntityRef(world: World, ref: EntityRef, tempAlive: Set<number>, deadEntities: Set<string>): void {
|
|
81
|
+
if ("tempId" in ref) {
|
|
82
|
+
if (!tempAlive.has(ref.tempId)) throw new ValidationError("TEMP_ENTITY", `Temporary entity is not alive: ${ref.tempId}`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
world.assertAlive(ref);
|
|
87
|
+
if (deadEntities.has(entityKey(ref))) throw new ValidationError("ENTITY_NOT_ALIVE", `Entity was already destroyed by this command buffer: ${entityKey(ref)}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolve(ref: EntityRef, tempEntities: Map<number, Entity>): Entity {
|
|
91
|
+
if ("tempId" in ref) {
|
|
92
|
+
const entity = tempEntities.get(ref.tempId);
|
|
93
|
+
if (!entity) throw new ValidationError("TEMP_ENTITY", `Temporary entity was not created: ${ref.tempId}`);
|
|
94
|
+
return entity;
|
|
95
|
+
}
|
|
96
|
+
return ref;
|
|
97
|
+
}
|
package/src/Component.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ValidationError } from "@aura3d/core";
|
|
2
|
+
|
|
3
|
+
export interface ComponentCtor<T = object> {
|
|
4
|
+
readonly prototype: T;
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly schema?: ComponentSchema;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ComponentSchema {
|
|
10
|
+
readonly version: number;
|
|
11
|
+
serialize?(component: object): unknown;
|
|
12
|
+
deserialize?(data: unknown): object;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ComponentType<T = object> {
|
|
16
|
+
readonly id: number;
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly ctor: ComponentCtor<T>;
|
|
19
|
+
readonly schema: ComponentSchema;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function componentName(ctor: ComponentCtor): string {
|
|
23
|
+
if (!ctor.name) throw new ValidationError("COMPONENT_NAME", "Component classes must have a stable name.");
|
|
24
|
+
return ctor.name;
|
|
25
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ValidationError } from "@aura3d/core";
|
|
2
|
+
import { componentName, type ComponentCtor, type ComponentSchema, type ComponentType } from "./Component.js";
|
|
3
|
+
|
|
4
|
+
export class ComponentRegistry {
|
|
5
|
+
private nextId = 0;
|
|
6
|
+
private readonly byCtor = new Map<ComponentCtor, ComponentType>();
|
|
7
|
+
private readonly byName = new Map<string, ComponentType>();
|
|
8
|
+
|
|
9
|
+
register<T extends object>(ctor: ComponentCtor<T>, schema?: ComponentSchema): ComponentType<T> {
|
|
10
|
+
const name = componentName(ctor);
|
|
11
|
+
const existingByCtor = this.byCtor.get(ctor);
|
|
12
|
+
if (existingByCtor) return existingByCtor as ComponentType<T>;
|
|
13
|
+
if (this.byName.has(name)) throw new ValidationError("DUPLICATE_COMPONENT", `Duplicate component name: ${name}`);
|
|
14
|
+
const type: ComponentType<T> = { id: this.nextId, name, ctor, schema: schema ?? ctor.schema ?? { version: 1 } };
|
|
15
|
+
this.nextId += 1;
|
|
16
|
+
this.byCtor.set(ctor, type as ComponentType);
|
|
17
|
+
this.byName.set(name, type as ComponentType);
|
|
18
|
+
return type;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get<T extends object>(ctor: ComponentCtor<T>): ComponentType<T> | undefined {
|
|
22
|
+
return this.byCtor.get(ctor) as ComponentType<T> | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
require<T extends object>(ctor: ComponentCtor<T>): ComponentType<T> {
|
|
26
|
+
const type = this.get(ctor);
|
|
27
|
+
if (!type) throw new ValidationError("UNREGISTERED_COMPONENT", `Component is not registered: ${componentName(ctor)}`);
|
|
28
|
+
return type;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getByName(name: string): ComponentType | undefined {
|
|
32
|
+
return this.byName.get(name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
list(): ComponentType[] {
|
|
36
|
+
return [...this.byCtor.values()].sort((a, b) => a.id - b.id);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Bitset } from "./Bitset.js";
|
|
2
|
+
import { type ComponentCtor, type ComponentType } from "./Component.js";
|
|
3
|
+
import { ComponentRegistry } from "./ComponentRegistry.js";
|
|
4
|
+
import { type Entity } from "./Entity.js";
|
|
5
|
+
import { SparseSet } from "./SparseSet.js";
|
|
6
|
+
|
|
7
|
+
export class ComponentStore {
|
|
8
|
+
private readonly stores = new Map<number, SparseSet<object>>();
|
|
9
|
+
private readonly signatures = new Map<number, Bitset>();
|
|
10
|
+
|
|
11
|
+
constructor(private readonly registry: ComponentRegistry) {}
|
|
12
|
+
|
|
13
|
+
add<T extends object>(entity: Entity, ctor: ComponentCtor<T>, component: T): void {
|
|
14
|
+
const type = this.registry.require(ctor);
|
|
15
|
+
this.storeFor(type).add(entity, component);
|
|
16
|
+
this.signatureFor(entity).add(type.id);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
remove<T extends object>(entity: Entity, ctor: ComponentCtor<T>): boolean {
|
|
20
|
+
const type = this.registry.require(ctor);
|
|
21
|
+
const removed = this.storeFor(type).delete(entity);
|
|
22
|
+
if (removed) this.signatureFor(entity).delete(type.id);
|
|
23
|
+
return removed;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get<T extends object>(entity: Entity, ctor: ComponentCtor<T>): T | undefined {
|
|
27
|
+
const type = this.registry.require(ctor);
|
|
28
|
+
return this.storeFor(type).get(entity) as T | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
has<T extends object>(entity: Entity, ctor: ComponentCtor<T>): boolean {
|
|
32
|
+
const type = this.registry.require(ctor);
|
|
33
|
+
return this.storeFor(type).has(entity);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
deleteEntity(entity: Entity): void {
|
|
37
|
+
for (const store of this.stores.values()) store.delete(entity);
|
|
38
|
+
this.signatures.delete(entity.id);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
signature(entity: Entity): Bitset {
|
|
42
|
+
return this.signatures.get(entity.id)?.clone() ?? new Bitset();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
entries<T extends object>(ctor: ComponentCtor<T>): [Entity, T][] {
|
|
46
|
+
const type = this.registry.require(ctor);
|
|
47
|
+
return this.storeFor(type).entries() as [Entity, T][];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
entitiesWith<T extends object>(ctor: ComponentCtor<T>): Entity[] {
|
|
51
|
+
const type = this.registry.require(ctor);
|
|
52
|
+
return this.storeFor(type).entities();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private storeFor(type: ComponentType): SparseSet<object> {
|
|
56
|
+
let store = this.stores.get(type.id);
|
|
57
|
+
if (!store) {
|
|
58
|
+
store = new SparseSet<object>();
|
|
59
|
+
this.stores.set(type.id, store);
|
|
60
|
+
}
|
|
61
|
+
return store;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private signatureFor(entity: Entity): Bitset {
|
|
65
|
+
let signature = this.signatures.get(entity.id);
|
|
66
|
+
if (!signature) {
|
|
67
|
+
signature = new Bitset();
|
|
68
|
+
this.signatures.set(entity.id, signature);
|
|
69
|
+
}
|
|
70
|
+
return signature;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type World } from "./World.js";
|
|
2
|
+
|
|
3
|
+
export interface ECSProfileSnapshot {
|
|
4
|
+
entities: number;
|
|
5
|
+
components: { name: string; id: number; count: number }[];
|
|
6
|
+
version: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function profileWorld(world: World): ECSProfileSnapshot {
|
|
10
|
+
return {
|
|
11
|
+
entities: world.entities.size,
|
|
12
|
+
components: world.registry.list().map((type) => ({
|
|
13
|
+
name: type.name,
|
|
14
|
+
id: type.id,
|
|
15
|
+
count: world.components.entries(type.ctor).length
|
|
16
|
+
})),
|
|
17
|
+
version: world.getVersion()
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ValidationError } from "@aura3d/core";
|
|
2
|
+
import { type ComponentCtor } from "./Component.js";
|
|
3
|
+
import { type Entity } from "./Entity.js";
|
|
4
|
+
import { World } from "./World.js";
|
|
5
|
+
|
|
6
|
+
export interface SerializedEntity {
|
|
7
|
+
id: number;
|
|
8
|
+
generation: number;
|
|
9
|
+
components: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SerializedWorld {
|
|
13
|
+
version: 1;
|
|
14
|
+
entities: SerializedEntity[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function serializeWorld(world: World): SerializedWorld {
|
|
18
|
+
const entities: SerializedEntity[] = world.entities.entities().map((entity) => {
|
|
19
|
+
const components: Record<string, unknown> = {};
|
|
20
|
+
for (const type of world.registry.list()) {
|
|
21
|
+
const value = world.components.get(entity, type.ctor as ComponentCtor);
|
|
22
|
+
if (value) components[type.name] = type.schema.serialize ? type.schema.serialize(value) : structuredClone(value);
|
|
23
|
+
}
|
|
24
|
+
return { id: entity.id, generation: entity.generation, components };
|
|
25
|
+
});
|
|
26
|
+
return { version: 1, entities };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function deserializeWorld(serialized: SerializedWorld, componentTypes: ComponentCtor[]): { world: World; remap: Map<string, Entity> } {
|
|
30
|
+
if (serialized.version !== 1) throw new ValidationError("ECS_VERSION", `Unsupported ECS version: ${serialized.version}`);
|
|
31
|
+
const world = new World();
|
|
32
|
+
for (const type of componentTypes) world.registerComponent(type);
|
|
33
|
+
const remap = new Map<string, Entity>();
|
|
34
|
+
for (const serializedEntity of serialized.entities) {
|
|
35
|
+
const entity = world.createEntity();
|
|
36
|
+
remap.set(`${serializedEntity.id}:${serializedEntity.generation}`, entity);
|
|
37
|
+
for (const [name, data] of Object.entries(serializedEntity.components)) {
|
|
38
|
+
const type = world.registry.getByName(name);
|
|
39
|
+
if (!type) throw new ValidationError("ECS_DESERIALIZE_COMPONENT", `Missing component type during ECS deserialize: ${name}`);
|
|
40
|
+
const component = type.schema.deserialize ? type.schema.deserialize(data) : data;
|
|
41
|
+
world.add(entity, type.ctor as ComponentCtor, component as object);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { world, remap };
|
|
45
|
+
}
|
package/src/Entity.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ValidationError } from "@aura3d/core";
|
|
2
|
+
|
|
3
|
+
export interface Entity {
|
|
4
|
+
readonly id: number;
|
|
5
|
+
readonly generation: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function entityKey(entity: Entity): string {
|
|
9
|
+
return `${entity.id}:${entity.generation}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function sameEntity(a: Entity, b: Entity): boolean {
|
|
13
|
+
return a.id === b.id && a.generation === b.generation;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function assertEntity(entity: Entity): void {
|
|
17
|
+
if (!Number.isSafeInteger(entity.id) || entity.id < 0) throw new ValidationError("ENTITY_ID", "Entity id must be a non-negative safe integer.");
|
|
18
|
+
if (!Number.isSafeInteger(entity.generation) || entity.generation < 0) throw new ValidationError("ENTITY_GENERATION", "Entity generation must be a non-negative safe integer.");
|
|
19
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ValidationError } from "@aura3d/core";
|
|
2
|
+
import { type Entity } from "./Entity.js";
|
|
3
|
+
|
|
4
|
+
export class EntityManager {
|
|
5
|
+
private readonly generations: number[] = [];
|
|
6
|
+
private readonly alive = new Set<number>();
|
|
7
|
+
private readonly free: number[] = [];
|
|
8
|
+
|
|
9
|
+
create(): Entity {
|
|
10
|
+
const id = this.free.pop() ?? this.generations.length;
|
|
11
|
+
this.generations[id] ??= 0;
|
|
12
|
+
this.alive.add(id);
|
|
13
|
+
return { id, generation: this.generations[id] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
destroy(entity: Entity): boolean {
|
|
17
|
+
if (!this.isAlive(entity)) return false;
|
|
18
|
+
this.alive.delete(entity.id);
|
|
19
|
+
this.generations[entity.id] += 1;
|
|
20
|
+
if (!Number.isSafeInteger(this.generations[entity.id])) throw new ValidationError("ENTITY_GENERATION_OVERFLOW", "Entity generation overflow.");
|
|
21
|
+
this.free.push(entity.id);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
isAlive(entity: Entity): boolean {
|
|
26
|
+
return this.alive.has(entity.id) && this.generations[entity.id] === entity.generation;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get size(): number {
|
|
30
|
+
return this.alive.size;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
entities(): Entity[] {
|
|
34
|
+
return [...this.alive].sort((a, b) => a - b).map((id) => ({ id, generation: this.generations[id] }));
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/Query.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ValidationError } from "@aura3d/core";
|
|
2
|
+
import { Bitset } from "./Bitset.js";
|
|
3
|
+
import { type ComponentCtor } from "./Component.js";
|
|
4
|
+
import { type ComponentRegistry } from "./ComponentRegistry.js";
|
|
5
|
+
import { type ComponentStore } from "./ComponentStore.js";
|
|
6
|
+
import { type Entity } from "./Entity.js";
|
|
7
|
+
import { type EntityManager } from "./EntityManager.js";
|
|
8
|
+
|
|
9
|
+
export interface QueryOptions {
|
|
10
|
+
include?: ComponentCtor[];
|
|
11
|
+
exclude?: ComponentCtor[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class Query {
|
|
15
|
+
private readonly includeMask: Bitset;
|
|
16
|
+
private readonly excludeMask: Bitset;
|
|
17
|
+
private readonly include: readonly ComponentCtor[];
|
|
18
|
+
private readonly exclude: readonly ComponentCtor[];
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly entities: EntityManager,
|
|
22
|
+
private readonly store: ComponentStore,
|
|
23
|
+
registry: ComponentRegistry,
|
|
24
|
+
options: QueryOptions
|
|
25
|
+
) {
|
|
26
|
+
this.include = options.include ?? [];
|
|
27
|
+
this.exclude = options.exclude ?? [];
|
|
28
|
+
this.includeMask = Bitset.from(this.include.map((ctor) => registry.require(ctor).id));
|
|
29
|
+
this.excludeMask = Bitset.from(this.exclude.map((ctor) => registry.require(ctor).id));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
matches(entity: Entity): boolean {
|
|
33
|
+
const signature = this.store.signature(entity);
|
|
34
|
+
return signature.containsAll(this.includeMask) && !signature.intersects(this.excludeMask);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
toArray(): Entity[] {
|
|
38
|
+
if (this.include.length === 1 && this.exclude.length === 0) {
|
|
39
|
+
return this.store.entitiesWith(this.include[0]!);
|
|
40
|
+
}
|
|
41
|
+
return this.entities.entities().filter((entity) => this.matches(entity));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
forEach(visitor: (entity: Entity) => void): void {
|
|
45
|
+
for (const entity of this.toArray()) visitor(entity);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
first(): Entity | undefined {
|
|
49
|
+
return this.toArray()[0];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
single(): Entity {
|
|
53
|
+
const result = this.toArray();
|
|
54
|
+
if (result.length !== 1) throw new ValidationError("QUERY_SINGLE", `Expected exactly one entity, found ${result.length}.`);
|
|
55
|
+
return result[0];
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/SparseSet.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { entityKey, type Entity } from "./Entity.js";
|
|
2
|
+
|
|
3
|
+
export class SparseSet<T> {
|
|
4
|
+
private readonly denseEntities: Entity[] = [];
|
|
5
|
+
private readonly denseValues: T[] = [];
|
|
6
|
+
private readonly sparse = new Map<string, number>();
|
|
7
|
+
|
|
8
|
+
add(entity: Entity, value: T): void {
|
|
9
|
+
const key = entityKey(entity);
|
|
10
|
+
const existing = this.sparse.get(key);
|
|
11
|
+
if (existing !== undefined) {
|
|
12
|
+
this.denseValues[existing] = value;
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
this.sparse.set(key, this.denseEntities.length);
|
|
16
|
+
this.denseEntities.push(entity);
|
|
17
|
+
this.denseValues.push(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get(entity: Entity): T | undefined {
|
|
21
|
+
const index = this.sparse.get(entityKey(entity));
|
|
22
|
+
return index === undefined ? undefined : this.denseValues[index];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
has(entity: Entity): boolean {
|
|
26
|
+
return this.sparse.has(entityKey(entity));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
delete(entity: Entity): boolean {
|
|
30
|
+
const key = entityKey(entity);
|
|
31
|
+
const index = this.sparse.get(key);
|
|
32
|
+
if (index === undefined) return false;
|
|
33
|
+
const lastIndex = this.denseEntities.length - 1;
|
|
34
|
+
const lastEntity = this.denseEntities[lastIndex];
|
|
35
|
+
const lastValue = this.denseValues[lastIndex];
|
|
36
|
+
this.denseEntities[index] = lastEntity;
|
|
37
|
+
this.denseValues[index] = lastValue;
|
|
38
|
+
this.sparse.set(entityKey(lastEntity), index);
|
|
39
|
+
this.denseEntities.pop();
|
|
40
|
+
this.denseValues.pop();
|
|
41
|
+
this.sparse.delete(key);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
entries(): [Entity, T][] {
|
|
46
|
+
return this.denseEntities.map((entity, index) => [entity, this.denseValues[index]]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
entities(): Entity[] {
|
|
50
|
+
return this.denseEntities.map((entity) => ({ ...entity }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get size(): number {
|
|
54
|
+
return this.denseEntities.length;
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/System.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type World } from "./World.js";
|
|
2
|
+
|
|
3
|
+
export type SystemPhase = "fixed" | "update" | "late";
|
|
4
|
+
|
|
5
|
+
export interface SystemContext {
|
|
6
|
+
readonly deltaTime: number;
|
|
7
|
+
readonly elapsedTime: number;
|
|
8
|
+
readonly frame: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface System {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly phase?: SystemPhase;
|
|
14
|
+
readonly priority?: number;
|
|
15
|
+
readonly before?: readonly string[];
|
|
16
|
+
readonly after?: readonly string[];
|
|
17
|
+
onAdd?(world: World): void;
|
|
18
|
+
onRemove?(world: World): void;
|
|
19
|
+
update(world: World, context: SystemContext): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ValidationError } from "@aura3d/core";
|
|
2
|
+
import { type System, type SystemContext, type SystemPhase } from "./System.js";
|
|
3
|
+
import { type World } from "./World.js";
|
|
4
|
+
|
|
5
|
+
const phaseOrder: SystemPhase[] = ["fixed", "update", "late"];
|
|
6
|
+
|
|
7
|
+
export class SystemScheduler {
|
|
8
|
+
private readonly systems = new Map<string, System>();
|
|
9
|
+
|
|
10
|
+
add(system: System, world?: World): void {
|
|
11
|
+
if (this.systems.has(system.name)) throw new ValidationError("DUPLICATE_SYSTEM", `Duplicate system name: ${system.name}`);
|
|
12
|
+
this.systems.set(system.name, system);
|
|
13
|
+
system.onAdd?.(world as World);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
remove(name: string, world?: World): boolean {
|
|
17
|
+
const system = this.systems.get(name);
|
|
18
|
+
if (!system) return false;
|
|
19
|
+
system.onRemove?.(world as World);
|
|
20
|
+
return this.systems.delete(name);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
order(phase?: SystemPhase): System[] {
|
|
24
|
+
const systems = [...this.systems.values()].filter((system) => (system.phase ?? "update") === (phase ?? system.phase ?? "update"));
|
|
25
|
+
return sortSystems(systems);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
update(world: World, context: SystemContext): void {
|
|
29
|
+
for (const phase of phaseOrder) {
|
|
30
|
+
for (const system of this.order(phase)) system.update(world, context);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sortSystems(systems: System[]): System[] {
|
|
36
|
+
const byName = new Map(systems.map((system) => [system.name, system]));
|
|
37
|
+
const graph = new Map<string, Set<string>>();
|
|
38
|
+
const indegree = new Map<string, number>();
|
|
39
|
+
const insertion = new Map(systems.map((system, index) => [system.name, index]));
|
|
40
|
+
for (const system of systems) {
|
|
41
|
+
graph.set(system.name, new Set());
|
|
42
|
+
indegree.set(system.name, 0);
|
|
43
|
+
}
|
|
44
|
+
for (const system of systems) {
|
|
45
|
+
for (const dependency of system.after ?? []) addEdge(dependency, system.name);
|
|
46
|
+
for (const dependent of system.before ?? []) addEdge(system.name, dependent);
|
|
47
|
+
}
|
|
48
|
+
const ready = systems
|
|
49
|
+
.filter((system) => indegree.get(system.name) === 0)
|
|
50
|
+
.sort(compareSystems);
|
|
51
|
+
const result: System[] = [];
|
|
52
|
+
while (ready.length > 0) {
|
|
53
|
+
const system = ready.shift() as System;
|
|
54
|
+
result.push(system);
|
|
55
|
+
for (const dependent of graph.get(system.name) ?? []) {
|
|
56
|
+
indegree.set(dependent, (indegree.get(dependent) ?? 0) - 1);
|
|
57
|
+
if (indegree.get(dependent) === 0) {
|
|
58
|
+
ready.push(byName.get(dependent) as System);
|
|
59
|
+
ready.sort(compareSystems);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (result.length !== systems.length) throw new ValidationError("ECS_SYSTEM_CYCLE", "ECS system dependency cycle rejected.");
|
|
64
|
+
return result;
|
|
65
|
+
|
|
66
|
+
function addEdge(from: string, to: string): void {
|
|
67
|
+
if (!byName.has(from)) throw new ValidationError("MISSING_ECS_SYSTEM_DEPENDENCY", `Missing ECS system dependency: ${from}`);
|
|
68
|
+
if (!byName.has(to)) throw new ValidationError("MISSING_ECS_SYSTEM_DEPENDENCY", `Missing ECS system dependency: ${to}`);
|
|
69
|
+
const edges = graph.get(from) as Set<string>;
|
|
70
|
+
if (!edges.has(to)) {
|
|
71
|
+
edges.add(to);
|
|
72
|
+
indegree.set(to, (indegree.get(to) ?? 0) + 1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function compareSystems(a: System, b: System): number {
|
|
77
|
+
return (a.priority ?? 0) - (b.priority ?? 0) || (insertion.get(a.name) ?? 0) - (insertion.get(b.name) ?? 0);
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/World.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { ValidationError } from "@aura3d/core";
|
|
2
|
+
import { type ComponentCtor, type ComponentSchema } from "./Component.js";
|
|
3
|
+
import { ComponentRegistry } from "./ComponentRegistry.js";
|
|
4
|
+
import { ComponentStore } from "./ComponentStore.js";
|
|
5
|
+
import { type Entity } from "./Entity.js";
|
|
6
|
+
import { EntityManager } from "./EntityManager.js";
|
|
7
|
+
import { Query, type QueryOptions } from "./Query.js";
|
|
8
|
+
import { SystemScheduler } from "./SystemScheduler.js";
|
|
9
|
+
import { type SystemContext } from "./System.js";
|
|
10
|
+
|
|
11
|
+
export class World {
|
|
12
|
+
readonly entities = new EntityManager();
|
|
13
|
+
readonly registry = new ComponentRegistry();
|
|
14
|
+
readonly components = new ComponentStore(this.registry);
|
|
15
|
+
readonly systems = new SystemScheduler();
|
|
16
|
+
private version = 0;
|
|
17
|
+
|
|
18
|
+
registerComponent<T extends object>(ctor: ComponentCtor<T>, schema?: ComponentSchema): void {
|
|
19
|
+
this.registry.register(ctor, schema);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
createEntity(): Entity {
|
|
23
|
+
this.version += 1;
|
|
24
|
+
return this.entities.create();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
destroyEntity(entity: Entity): boolean {
|
|
28
|
+
this.assertAlive(entity);
|
|
29
|
+
const destroyed = this.entities.destroy(entity);
|
|
30
|
+
if (destroyed) {
|
|
31
|
+
this.components.deleteEntity(entity);
|
|
32
|
+
this.version += 1;
|
|
33
|
+
}
|
|
34
|
+
return destroyed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
add<T extends object>(entity: Entity, ctor: ComponentCtor<T>, component: T): void {
|
|
38
|
+
this.assertAlive(entity);
|
|
39
|
+
if (!this.registry.get(ctor)) this.registry.register(ctor);
|
|
40
|
+
this.components.add(entity, ctor, component);
|
|
41
|
+
this.version += 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
remove<T extends object>(entity: Entity, ctor: ComponentCtor<T>): boolean {
|
|
45
|
+
this.assertAlive(entity);
|
|
46
|
+
const removed = this.components.remove(entity, ctor);
|
|
47
|
+
if (removed) this.version += 1;
|
|
48
|
+
return removed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get<T extends object>(entity: Entity, ctor: ComponentCtor<T>): T | undefined {
|
|
52
|
+
this.assertAlive(entity);
|
|
53
|
+
return this.components.get(entity, ctor);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
has<T extends object>(entity: Entity, ctor: ComponentCtor<T>): boolean {
|
|
57
|
+
this.assertAlive(entity);
|
|
58
|
+
return this.components.has(entity, ctor);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
query(options: QueryOptions): Query {
|
|
62
|
+
return new Query(this.entities, this.components, this.registry, options);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
update(context: SystemContext): void {
|
|
66
|
+
this.systems.update(this, context);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getVersion(): number {
|
|
70
|
+
return this.version;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
assertAlive(entity: Entity): void {
|
|
74
|
+
if (!this.entities.isAlive(entity)) throw new ValidationError("ENTITY_NOT_ALIVE", `Entity is not alive: ${entity.id}:${entity.generation}`);
|
|
75
|
+
}
|
|
76
|
+
}
|