@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.
Files changed (124) hide show
  1. package/README.md +18 -0
  2. package/dist/Archetype.d.ts +11 -0
  3. package/dist/Archetype.d.ts.map +1 -0
  4. package/dist/Archetype.js +19 -0
  5. package/dist/Archetype.js.map +1 -0
  6. package/dist/Bitset.d.ts +12 -0
  7. package/dist/Bitset.d.ts.map +1 -0
  8. package/dist/Bitset.js +43 -0
  9. package/dist/Bitset.js.map +1 -0
  10. package/dist/CommandBuffer.d.ts +16 -0
  11. package/dist/CommandBuffer.d.ts.map +1 -0
  12. package/dist/CommandBuffer.js +86 -0
  13. package/dist/CommandBuffer.js.map +1 -0
  14. package/dist/Component.d.ts +18 -0
  15. package/dist/Component.d.ts.map +1 -0
  16. package/dist/Component.js +7 -0
  17. package/dist/Component.js.map +1 -0
  18. package/dist/ComponentRegistry.d.ts +12 -0
  19. package/dist/ComponentRegistry.d.ts.map +1 -0
  20. package/dist/ComponentRegistry.js +36 -0
  21. package/dist/ComponentRegistry.js.map +1 -0
  22. package/dist/ComponentStore.d.ts +21 -0
  23. package/dist/ComponentStore.d.ts.map +1 -0
  24. package/dist/ComponentStore.js +66 -0
  25. package/dist/ComponentStore.js.map +1 -0
  26. package/dist/ECSProfiler.d.ts +12 -0
  27. package/dist/ECSProfiler.d.ts.map +1 -0
  28. package/dist/ECSProfiler.js +13 -0
  29. package/dist/ECSProfiler.js.map +1 -0
  30. package/dist/ECSSerializer.d.ts +18 -0
  31. package/dist/ECSSerializer.d.ts.map +1 -0
  32. package/dist/ECSSerializer.js +37 -0
  33. package/dist/ECSSerializer.js.map +1 -0
  34. package/dist/Entity.d.ts +8 -0
  35. package/dist/Entity.d.ts.map +1 -0
  36. package/dist/Entity.js +14 -0
  37. package/dist/Entity.js.map +1 -0
  38. package/dist/EntityManager.d.ts +12 -0
  39. package/dist/EntityManager.d.ts.map +1 -0
  40. package/dist/EntityManager.js +33 -0
  41. package/dist/EntityManager.js.map +1 -0
  42. package/dist/Query.d.ts +24 -0
  43. package/dist/Query.d.ts.map +1 -0
  44. package/dist/Query.js +47 -0
  45. package/dist/Query.js.map +1 -0
  46. package/dist/SparseSet.d.ts +14 -0
  47. package/dist/SparseSet.d.ts.map +1 -0
  48. package/dist/SparseSet.js +50 -0
  49. package/dist/SparseSet.js.map +1 -0
  50. package/dist/System.d.ts +18 -0
  51. package/dist/System.d.ts.map +1 -0
  52. package/dist/System.js +2 -0
  53. package/dist/System.js.map +1 -0
  54. package/dist/SystemScheduler.d.ts +10 -0
  55. package/dist/SystemScheduler.d.ts.map +1 -0
  56. package/dist/SystemScheduler.js +79 -0
  57. package/dist/SystemScheduler.js.map +1 -0
  58. package/dist/World.d.ts +27 -0
  59. package/dist/World.d.ts.map +1 -0
  60. package/dist/World.js +68 -0
  61. package/dist/World.js.map +1 -0
  62. package/dist/components/ActiveComponent.d.ts +16 -0
  63. package/dist/components/ActiveComponent.d.ts.map +1 -0
  64. package/dist/components/ActiveComponent.js +46 -0
  65. package/dist/components/ActiveComponent.js.map +1 -0
  66. package/dist/components/HierarchyComponent.d.ts +34 -0
  67. package/dist/components/HierarchyComponent.d.ts.map +1 -0
  68. package/dist/components/HierarchyComponent.js +103 -0
  69. package/dist/components/HierarchyComponent.js.map +1 -0
  70. package/dist/components/NameComponent.d.ts +5 -0
  71. package/dist/components/NameComponent.d.ts.map +1 -0
  72. package/dist/components/NameComponent.js +10 -0
  73. package/dist/components/NameComponent.js.map +1 -0
  74. package/dist/components/TagComponent.d.ts +5 -0
  75. package/dist/components/TagComponent.d.ts.map +1 -0
  76. package/dist/components/TagComponent.js +10 -0
  77. package/dist/components/TagComponent.js.map +1 -0
  78. package/dist/components/TransformComponent.d.ts +18 -0
  79. package/dist/components/TransformComponent.d.ts.map +1 -0
  80. package/dist/components/TransformComponent.js +27 -0
  81. package/dist/components/TransformComponent.js.map +1 -0
  82. package/dist/index.d.ts +22 -0
  83. package/dist/index.d.ts.map +1 -0
  84. package/dist/index.js +22 -0
  85. package/dist/index.js.map +1 -0
  86. package/dist/systems/ActiveSystem.d.ts +16 -0
  87. package/dist/systems/ActiveSystem.d.ts.map +1 -0
  88. package/dist/systems/ActiveSystem.js +61 -0
  89. package/dist/systems/ActiveSystem.js.map +1 -0
  90. package/dist/systems/HierarchySystem.d.ts +25 -0
  91. package/dist/systems/HierarchySystem.d.ts.map +1 -0
  92. package/dist/systems/HierarchySystem.js +185 -0
  93. package/dist/systems/HierarchySystem.js.map +1 -0
  94. package/dist/systems/index.d.ts +3 -0
  95. package/dist/systems/index.d.ts.map +1 -0
  96. package/dist/systems/index.js +3 -0
  97. package/dist/systems/index.js.map +1 -0
  98. package/package.json +18 -0
  99. package/src/Archetype.ts +23 -0
  100. package/src/Bitset.ts +47 -0
  101. package/src/CommandBuffer.ts +97 -0
  102. package/src/Component.ts +25 -0
  103. package/src/ComponentRegistry.ts +38 -0
  104. package/src/ComponentStore.ts +72 -0
  105. package/src/ECSProfiler.ts +19 -0
  106. package/src/ECSSerializer.ts +45 -0
  107. package/src/Entity.ts +19 -0
  108. package/src/EntityManager.ts +36 -0
  109. package/src/Query.ts +57 -0
  110. package/src/SparseSet.ts +56 -0
  111. package/src/System.ts +20 -0
  112. package/src/SystemScheduler.ts +79 -0
  113. package/src/World.ts +76 -0
  114. package/src/components/ActiveComponent.ts +56 -0
  115. package/src/components/HierarchyComponent.ts +124 -0
  116. package/src/components/NameComponent.ts +7 -0
  117. package/src/components/TagComponent.ts +7 -0
  118. package/src/components/TransformComponent.ts +34 -0
  119. package/src/index.ts +21 -0
  120. package/src/systems/ActiveSystem.ts +64 -0
  121. package/src/systems/HierarchySystem.ts +182 -0
  122. package/src/systems/index.ts +2 -0
  123. package/tests/ecs.test.ts +134 -0
  124. package/tsconfig.json +9 -0
@@ -0,0 +1,56 @@
1
+ import { ValidationError } from "@aura3d/core";
2
+ import { type ComponentSchema } from "../Component.js";
3
+
4
+ export interface SerializedActiveComponent {
5
+ readonly activeSelf: boolean;
6
+ }
7
+
8
+ export class ActiveComponent {
9
+ activeSelf: boolean;
10
+ activeInHierarchy: boolean;
11
+
12
+ constructor(active = true) {
13
+ if (typeof active !== "boolean") throw new ValidationError("ACTIVE_COMPONENT", "ActiveComponent expects a boolean active state.");
14
+ this.activeSelf = active;
15
+ this.activeInHierarchy = active;
16
+ }
17
+
18
+ get isActive(): boolean {
19
+ return this.activeSelf;
20
+ }
21
+
22
+ get isActiveInHierarchy(): boolean {
23
+ return this.activeInHierarchy;
24
+ }
25
+
26
+ setActive(active: boolean): void {
27
+ if (typeof active !== "boolean") throw new ValidationError("ACTIVE_COMPONENT", "Active state must be boolean.");
28
+ this.activeSelf = active;
29
+ }
30
+
31
+ toggle(): void {
32
+ this.activeSelf = !this.activeSelf;
33
+ }
34
+
35
+ toJSON(): SerializedActiveComponent {
36
+ return { activeSelf: this.activeSelf };
37
+ }
38
+
39
+ static readonly schema: ComponentSchema = {
40
+ version: 1,
41
+ serialize(component: object): SerializedActiveComponent {
42
+ const active = component as ActiveComponent;
43
+ return { activeSelf: active.activeSelf };
44
+ },
45
+ deserialize(data: unknown): ActiveComponent {
46
+ if (!isRecord(data) || typeof data.activeSelf !== "boolean") {
47
+ throw new ValidationError("ACTIVE_COMPONENT_DESERIALIZE", "ActiveComponent data requires activeSelf boolean.");
48
+ }
49
+ return new ActiveComponent(data.activeSelf);
50
+ }
51
+ };
52
+ }
53
+
54
+ function isRecord(value: unknown): value is Record<string, unknown> {
55
+ return typeof value === "object" && value !== null;
56
+ }
@@ -0,0 +1,124 @@
1
+ import { ValidationError } from "@aura3d/core";
2
+ import { type ComponentSchema } from "../Component.js";
3
+ import { entityKey, sameEntity, type Entity } from "../Entity.js";
4
+
5
+ export interface SerializedHierarchyComponent {
6
+ readonly parent: Entity | null;
7
+ readonly children: readonly Entity[];
8
+ readonly previousSibling: Entity | null;
9
+ readonly nextSibling: Entity | null;
10
+ readonly depth: number;
11
+ }
12
+
13
+ export class HierarchyComponent {
14
+ parent: Entity | null;
15
+ previousSibling: Entity | null = null;
16
+ nextSibling: Entity | null = null;
17
+ depth = 0;
18
+ private readonly childEntities: Entity[] = [];
19
+
20
+ constructor(parent: Entity | null = null) {
21
+ this.parent = parent;
22
+ }
23
+
24
+ get children(): readonly Entity[] {
25
+ return this.childEntities;
26
+ }
27
+
28
+ get childCount(): number {
29
+ return this.childEntities.length;
30
+ }
31
+
32
+ get firstChild(): Entity | null {
33
+ return this.childEntities[0] ?? null;
34
+ }
35
+
36
+ get lastChild(): Entity | null {
37
+ return this.childEntities[this.childEntities.length - 1] ?? null;
38
+ }
39
+
40
+ hasParent(): boolean {
41
+ return this.parent !== null;
42
+ }
43
+
44
+ hasChildren(): boolean {
45
+ return this.childEntities.length > 0;
46
+ }
47
+
48
+ includesChild(entity: Entity): boolean {
49
+ return this.childEntities.some((child) => sameEntity(child, entity));
50
+ }
51
+
52
+ forEachChild(callback: (child: Entity) => void): void {
53
+ for (const child of this.childEntities) callback(child);
54
+ }
55
+
56
+ getSiblingIndex(entity?: Entity): number {
57
+ if (!entity) return this.parent ? 0 : -1;
58
+ return this.childEntities.findIndex((child) => sameEntity(child, entity));
59
+ }
60
+
61
+ _setParent(parent: Entity | null): void {
62
+ this.parent = parent;
63
+ }
64
+
65
+ _addChild(child: Entity): void {
66
+ if (!this.includesChild(child)) this.childEntities.push(child);
67
+ }
68
+
69
+ _removeChild(child: Entity): boolean {
70
+ const index = this.childEntities.findIndex((entry) => sameEntity(entry, child));
71
+ if (index < 0) return false;
72
+ this.childEntities.splice(index, 1);
73
+ return true;
74
+ }
75
+
76
+ _clearChildren(): void {
77
+ this.childEntities.length = 0;
78
+ }
79
+
80
+ _moveChild(child: Entity, index: number): void {
81
+ const current = this.childEntities.findIndex((entry) => sameEntity(entry, child));
82
+ if (current < 0) throw new ValidationError("ECS_HIERARCHY_CHILD", `Entity is not a child: ${entityKey(child)}`);
83
+ const [entry] = this.childEntities.splice(current, 1);
84
+ this.childEntities.splice(Math.max(0, Math.min(index, this.childEntities.length)), 0, entry);
85
+ }
86
+
87
+ toJSON(): SerializedHierarchyComponent {
88
+ return {
89
+ parent: this.parent ? { ...this.parent } : null,
90
+ children: this.childEntities.map((child) => ({ ...child })),
91
+ previousSibling: this.previousSibling ? { ...this.previousSibling } : null,
92
+ nextSibling: this.nextSibling ? { ...this.nextSibling } : null,
93
+ depth: this.depth
94
+ };
95
+ }
96
+
97
+ static readonly schema: ComponentSchema = {
98
+ version: 1,
99
+ serialize(component: object): SerializedHierarchyComponent {
100
+ return (component as HierarchyComponent).toJSON();
101
+ },
102
+ deserialize(data: unknown): HierarchyComponent {
103
+ if (!isRecord(data) || !Array.isArray(data.children)) {
104
+ throw new ValidationError("HIERARCHY_COMPONENT_DESERIALIZE", "HierarchyComponent data requires children array.");
105
+ }
106
+ const hierarchy = new HierarchyComponent(isEntity(data.parent) ? data.parent : null);
107
+ for (const child of data.children) {
108
+ if (isEntity(child)) hierarchy._addChild(child);
109
+ }
110
+ hierarchy.previousSibling = isEntity(data.previousSibling) ? data.previousSibling : null;
111
+ hierarchy.nextSibling = isEntity(data.nextSibling) ? data.nextSibling : null;
112
+ hierarchy.depth = typeof data.depth === "number" && Number.isFinite(data.depth) ? Math.max(0, Math.floor(data.depth)) : 0;
113
+ return hierarchy;
114
+ }
115
+ };
116
+ }
117
+
118
+ function isRecord(value: unknown): value is Record<string, unknown> {
119
+ return typeof value === "object" && value !== null;
120
+ }
121
+
122
+ function isEntity(value: unknown): value is Entity {
123
+ return isRecord(value) && Number.isSafeInteger(value.id) && Number.isSafeInteger(value.generation);
124
+ }
@@ -0,0 +1,7 @@
1
+ import { ValidationError } from "@aura3d/core";
2
+
3
+ export class NameComponent {
4
+ constructor(public name: string) {
5
+ if (!name) throw new ValidationError("NAME_COMPONENT", "NameComponent requires a non-empty name.");
6
+ }
7
+ }
@@ -0,0 +1,7 @@
1
+ import { ValidationError } from "@aura3d/core";
2
+
3
+ export class TagComponent {
4
+ constructor(public tag: string) {
5
+ if (!tag) throw new ValidationError("TAG_COMPONENT", "TagComponent requires a non-empty tag.");
6
+ }
7
+ }
@@ -0,0 +1,34 @@
1
+ import { Quaternion, Vector3 } from "@aura3d/math";
2
+
3
+ export class TransformComponent {
4
+ position: [number, number, number];
5
+ rotation: [number, number, number, number];
6
+ scale: [number, number, number];
7
+
8
+ constructor(
9
+ position: Vector3 | readonly [number, number, number] = Vector3.zero.clone(),
10
+ rotation: Quaternion | readonly [number, number, number, number] = Quaternion.identity.clone(),
11
+ scale: Vector3 | readonly [number, number, number] = Vector3.one.clone()
12
+ ) {
13
+ this.position = position instanceof Vector3 ? position.toArray() : [...position];
14
+ const normalizedRotation = rotation instanceof Quaternion ? rotation.normalize() : new Quaternion(rotation[0], rotation[1], rotation[2], rotation[3]).normalize();
15
+ this.rotation = [normalizedRotation.x, normalizedRotation.y, normalizedRotation.z, normalizedRotation.w];
16
+ this.scale = scale instanceof Vector3 ? scale.toArray() : [...scale];
17
+ }
18
+
19
+ toJSON(): { position: [number, number, number]; rotation: [number, number, number, number]; scale: [number, number, number] } {
20
+ return {
21
+ position: [...this.position],
22
+ rotation: [...this.rotation],
23
+ scale: [...this.scale]
24
+ };
25
+ }
26
+
27
+ toMath(): { position: Vector3; rotation: Quaternion; scale: Vector3 } {
28
+ return {
29
+ position: new Vector3(this.position[0], this.position[1], this.position[2]),
30
+ rotation: new Quaternion(this.rotation[0], this.rotation[1], this.rotation[2], this.rotation[3]),
31
+ scale: new Vector3(this.scale[0], this.scale[1], this.scale[2])
32
+ };
33
+ }
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ export * from "./Entity.js";
2
+ export * from "./EntityManager.js";
3
+ export * from "./Component.js";
4
+ export * from "./ComponentRegistry.js";
5
+ export * from "./SparseSet.js";
6
+ export * from "./Bitset.js";
7
+ export * from "./ComponentStore.js";
8
+ export * from "./Archetype.js";
9
+ export * from "./Query.js";
10
+ export * from "./CommandBuffer.js";
11
+ export * from "./System.js";
12
+ export * from "./SystemScheduler.js";
13
+ export * from "./World.js";
14
+ export * from "./ECSSerializer.js";
15
+ export * from "./ECSProfiler.js";
16
+ export * from "./components/TransformComponent.js";
17
+ export * from "./components/NameComponent.js";
18
+ export * from "./components/TagComponent.js";
19
+ export * from "./components/ActiveComponent.js";
20
+ export * from "./components/HierarchyComponent.js";
21
+ export * from "./systems/index.js";
@@ -0,0 +1,64 @@
1
+ import { type Entity } from "../Entity.js";
2
+ import { type System, type SystemContext } from "../System.js";
3
+ import { type World } from "../World.js";
4
+ import { ActiveComponent } from "../components/ActiveComponent.js";
5
+ import { HierarchyComponent } from "../components/HierarchyComponent.js";
6
+
7
+ export class ActiveSystem implements System {
8
+ readonly name = "ActiveSystem";
9
+ readonly phase = "update";
10
+ readonly priority = -90;
11
+ onActiveChanged?: (entity: Entity, activeInHierarchy: boolean) => void;
12
+
13
+ update(world: World, _context: SystemContext): void {
14
+ const memo = new Map<string, boolean>();
15
+ const entities = world.query({ include: [ActiveComponent] }).toArray().sort((left, right) => {
16
+ return (world.get(left, HierarchyComponent)?.depth ?? 0) - (world.get(right, HierarchyComponent)?.depth ?? 0);
17
+ });
18
+ for (const entity of entities) {
19
+ const component = world.get(entity, ActiveComponent);
20
+ if (!component) continue;
21
+ const previous = component.activeInHierarchy;
22
+ component.activeInHierarchy = computeActiveInHierarchy(world, entity, memo);
23
+ if (previous !== component.activeInHierarchy) this.onActiveChanged?.(entity, component.activeInHierarchy);
24
+ }
25
+ }
26
+
27
+ setActive(world: World, entity: Entity, active: boolean): void {
28
+ let component = world.get(entity, ActiveComponent);
29
+ if (!component) {
30
+ component = new ActiveComponent(active);
31
+ world.add(entity, ActiveComponent, component);
32
+ } else {
33
+ component.setActive(active);
34
+ }
35
+ this.update(world, { deltaTime: 0, elapsedTime: 0, frame: 0 });
36
+ }
37
+
38
+ isActive(world: World, entity: Entity): boolean {
39
+ return world.get(entity, ActiveComponent)?.activeSelf ?? true;
40
+ }
41
+
42
+ isActiveInHierarchy(world: World, entity: Entity): boolean {
43
+ return world.get(entity, ActiveComponent)?.activeInHierarchy ?? true;
44
+ }
45
+
46
+ getActiveEntities(world: World): Entity[] {
47
+ return world.query({ include: [ActiveComponent] }).toArray().filter((entity) => this.isActiveInHierarchy(world, entity));
48
+ }
49
+
50
+ getInactiveEntities(world: World): Entity[] {
51
+ return world.query({ include: [ActiveComponent] }).toArray().filter((entity) => !this.isActiveInHierarchy(world, entity));
52
+ }
53
+ }
54
+
55
+ function computeActiveInHierarchy(world: World, entity: Entity, memo: Map<string, boolean>): boolean {
56
+ const key = `${entity.id}:${entity.generation}`;
57
+ const cached = memo.get(key);
58
+ if (cached !== undefined) return cached;
59
+ const activeSelf = world.get(entity, ActiveComponent)?.activeSelf ?? true;
60
+ const parent = world.get(entity, HierarchyComponent)?.parent ?? null;
61
+ const active = activeSelf && (parent ? computeActiveInHierarchy(world, parent, memo) : true);
62
+ memo.set(key, active);
63
+ return active;
64
+ }
@@ -0,0 +1,182 @@
1
+ import { ValidationError } from "@aura3d/core";
2
+ import { entityKey, sameEntity, type Entity } from "../Entity.js";
3
+ import { type System, type SystemContext } from "../System.js";
4
+ import { type World } from "../World.js";
5
+ import { HierarchyComponent } from "../components/HierarchyComponent.js";
6
+
7
+ export class HierarchySystem implements System {
8
+ readonly name = "HierarchySystem";
9
+ readonly phase = "update";
10
+ readonly priority = -100;
11
+
12
+ update(world: World, _context: SystemContext): void {
13
+ for (const entity of world.query({ include: [HierarchyComponent] }).toArray()) {
14
+ const hierarchy = world.get(entity, HierarchyComponent);
15
+ if (!hierarchy) continue;
16
+ if (hierarchy.parent && !world.entities.isAlive(hierarchy.parent)) {
17
+ hierarchy._setParent(null);
18
+ hierarchy.depth = 0;
19
+ }
20
+ }
21
+ const cycles = this.detectCycles(world);
22
+ if (cycles.length > 0) throw new ValidationError("ECS_HIERARCHY_CYCLE", `Hierarchy cycle detected: ${cycles.map(entityKey).join(", ")}`);
23
+ }
24
+
25
+ setParent(world: World, child: Entity, parent: Entity | null): void {
26
+ world.assertAlive(child);
27
+ if (parent) world.assertAlive(parent);
28
+ if (parent && sameEntity(child, parent)) throw new ValidationError("ECS_HIERARCHY_SELF_PARENT", "Entity cannot be parented to itself.");
29
+ const childHierarchy = ensureHierarchy(world, child);
30
+ if (parent) ensureHierarchy(world, parent);
31
+ if (parent && this.isAncestorOf(world, child, parent)) {
32
+ throw new ValidationError("ECS_HIERARCHY_CYCLE", `Parenting ${entityKey(child)} to ${entityKey(parent)} would create a cycle.`);
33
+ }
34
+
35
+ const previousParent = childHierarchy.parent;
36
+ if (sameOptionalEntity(childHierarchy.parent, parent)) return;
37
+ if (previousParent) {
38
+ world.get(previousParent, HierarchyComponent)?._removeChild(child);
39
+ this.refreshChildSiblings(world, previousParent);
40
+ }
41
+
42
+ childHierarchy._setParent(parent ? { ...parent } : null);
43
+ childHierarchy.previousSibling = null;
44
+ childHierarchy.nextSibling = null;
45
+ if (parent) {
46
+ const parentHierarchy = ensureHierarchy(world, parent);
47
+ parentHierarchy._addChild(child);
48
+ this.refreshChildSiblings(world, parent);
49
+ childHierarchy.depth = parentHierarchy.depth + 1;
50
+ } else {
51
+ childHierarchy.depth = 0;
52
+ }
53
+ this.updateDescendantDepths(world, child);
54
+ }
55
+
56
+ addChild(world: World, parent: Entity, child: Entity): void {
57
+ this.setParent(world, child, parent);
58
+ }
59
+
60
+ removeFromParent(world: World, entity: Entity): void {
61
+ this.setParent(world, entity, null);
62
+ }
63
+
64
+ getParent(world: World, entity: Entity): Entity | null {
65
+ return world.get(entity, HierarchyComponent)?.parent ?? null;
66
+ }
67
+
68
+ getChildren(world: World, entity: Entity): readonly Entity[] {
69
+ return world.get(entity, HierarchyComponent)?.children ?? [];
70
+ }
71
+
72
+ getDepth(world: World, entity: Entity): number {
73
+ return world.get(entity, HierarchyComponent)?.depth ?? 0;
74
+ }
75
+
76
+ forEachDescendant(world: World, entity: Entity, callback: (descendant: Entity, depth: number) => void): void {
77
+ const hierarchy = world.get(entity, HierarchyComponent);
78
+ if (!hierarchy) return;
79
+ for (const child of hierarchy.children) {
80
+ const childHierarchy = world.get(child, HierarchyComponent);
81
+ callback(child, childHierarchy?.depth ?? hierarchy.depth + 1);
82
+ this.forEachDescendant(world, child, callback);
83
+ }
84
+ }
85
+
86
+ isAncestorOf(world: World, ancestor: Entity, descendant: Entity): boolean {
87
+ let cursor = world.get(descendant, HierarchyComponent)?.parent ?? null;
88
+ const guard = new Set<string>();
89
+ while (cursor) {
90
+ if (sameEntity(cursor, ancestor)) return true;
91
+ const key = entityKey(cursor);
92
+ if (guard.has(key)) return false;
93
+ guard.add(key);
94
+ cursor = world.get(cursor, HierarchyComponent)?.parent ?? null;
95
+ }
96
+ return false;
97
+ }
98
+
99
+ isDescendantOf(world: World, descendant: Entity, ancestor: Entity): boolean {
100
+ return this.isAncestorOf(world, ancestor, descendant);
101
+ }
102
+
103
+ setSiblingIndex(world: World, entity: Entity, index: number): void {
104
+ const hierarchy = world.get(entity, HierarchyComponent);
105
+ if (!hierarchy?.parent) return;
106
+ world.get(hierarchy.parent, HierarchyComponent)?._moveChild(entity, index);
107
+ this.refreshChildSiblings(world, hierarchy.parent);
108
+ }
109
+
110
+ detachChildren(world: World, entity: Entity): Entity[] {
111
+ const children = [...this.getChildren(world, entity)];
112
+ for (const child of children) this.setParent(world, child, null);
113
+ return children;
114
+ }
115
+
116
+ validateHierarchy(world: World): boolean {
117
+ return this.detectCycles(world).length === 0 && world.query({ include: [HierarchyComponent] }).toArray().every((entity) => {
118
+ const hierarchy = world.get(entity, HierarchyComponent);
119
+ if (!hierarchy?.parent) return true;
120
+ const parent = world.get(hierarchy.parent, HierarchyComponent);
121
+ return Boolean(parent?.includesChild(entity));
122
+ });
123
+ }
124
+
125
+ detectCycles(world: World): Entity[] {
126
+ const cycles: Entity[] = [];
127
+ const visiting = new Set<string>();
128
+ const visited = new Set<string>();
129
+ const visit = (entity: Entity): void => {
130
+ const key = entityKey(entity);
131
+ if (visiting.has(key)) {
132
+ cycles.push(entity);
133
+ return;
134
+ }
135
+ if (visited.has(key)) return;
136
+ visiting.add(key);
137
+ const parent = world.get(entity, HierarchyComponent)?.parent;
138
+ if (parent) visit(parent);
139
+ visiting.delete(key);
140
+ visited.add(key);
141
+ };
142
+ for (const entity of world.query({ include: [HierarchyComponent] }).toArray()) visit(entity);
143
+ return cycles;
144
+ }
145
+
146
+ private updateDescendantDepths(world: World, entity: Entity): void {
147
+ const hierarchy = world.get(entity, HierarchyComponent);
148
+ if (!hierarchy) return;
149
+ for (const child of hierarchy.children) {
150
+ const childHierarchy = world.get(child, HierarchyComponent);
151
+ if (!childHierarchy) continue;
152
+ childHierarchy.depth = hierarchy.depth + 1;
153
+ this.updateDescendantDepths(world, child);
154
+ }
155
+ }
156
+
157
+ private refreshChildSiblings(world: World, parent: Entity): void {
158
+ const parentHierarchy = world.get(parent, HierarchyComponent);
159
+ if (!parentHierarchy) return;
160
+ const children = parentHierarchy.children;
161
+ for (let index = 0; index < children.length; index += 1) {
162
+ const child = children[index];
163
+ const hierarchy = world.get(child, HierarchyComponent);
164
+ if (!hierarchy) continue;
165
+ hierarchy.previousSibling = children[index - 1] ? { ...children[index - 1] } : null;
166
+ hierarchy.nextSibling = children[index + 1] ? { ...children[index + 1] } : null;
167
+ }
168
+ }
169
+ }
170
+
171
+ function ensureHierarchy(world: World, entity: Entity): HierarchyComponent {
172
+ if (!world.registry.get(HierarchyComponent)) world.registerComponent(HierarchyComponent);
173
+ const existing = world.get(entity, HierarchyComponent);
174
+ if (existing) return existing;
175
+ world.add(entity, HierarchyComponent, new HierarchyComponent());
176
+ return world.get(entity, HierarchyComponent) as HierarchyComponent;
177
+ }
178
+
179
+ function sameOptionalEntity(left: Entity | null, right: Entity | null): boolean {
180
+ if (!left || !right) return left === right;
181
+ return sameEntity(left, right);
182
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./ActiveSystem.js";
2
+ export * from "./HierarchySystem.js";
@@ -0,0 +1,134 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ CommandBuffer,
5
+ NameComponent,
6
+ SystemScheduler,
7
+ TagComponent,
8
+ TransformComponent,
9
+ World,
10
+ deserializeWorld,
11
+ profileWorld,
12
+ serializeWorld,
13
+ type System
14
+ } from "../src/index.js";
15
+
16
+ class VelocityComponent {
17
+ constructor(public value: [number, number, number]) {}
18
+ }
19
+
20
+ test("entities reject stale handles after destroy and reuse", () => {
21
+ const world = new World();
22
+ const first = world.createEntity();
23
+ assert.equal(world.destroyEntity(first), true);
24
+ const second = world.createEntity();
25
+
26
+ assert.equal(first.id, second.id);
27
+ assert.notEqual(first.generation, second.generation);
28
+ assert.throws(() => world.get(first, NameComponent), /not alive/);
29
+ });
30
+
31
+ test("component registration is deterministic per world", () => {
32
+ const a = new World();
33
+ const b = new World();
34
+ a.registerComponent(TransformComponent);
35
+ a.registerComponent(NameComponent);
36
+ b.registerComponent(TransformComponent);
37
+ b.registerComponent(NameComponent);
38
+
39
+ assert.equal(a.registry.require(TransformComponent).id, 0);
40
+ assert.equal(a.registry.require(NameComponent).id, 1);
41
+ assert.equal(b.registry.require(TransformComponent).id, 0);
42
+ assert.equal(b.registry.require(NameComponent).id, 1);
43
+ });
44
+
45
+ test("query include and exclude masks update after component transitions", () => {
46
+ const world = new World();
47
+ const moving = world.createEntity();
48
+ const named = world.createEntity();
49
+ world.add(moving, TransformComponent, new TransformComponent());
50
+ world.add(moving, VelocityComponent, new VelocityComponent([1, 0, 0]));
51
+ world.add(named, TransformComponent, new TransformComponent());
52
+ world.add(named, NameComponent, new NameComponent("stationary"));
53
+
54
+ assert.deepEqual(world.query({ include: [TransformComponent], exclude: [NameComponent] }).toArray(), [moving]);
55
+
56
+ world.remove(moving, VelocityComponent);
57
+ world.add(moving, NameComponent, new NameComponent("moving"));
58
+
59
+ assert.deepEqual(world.query({ include: [TransformComponent], exclude: [NameComponent] }).toArray(), []);
60
+ });
61
+
62
+ test("command buffer supports create/add/destroy during query iteration", () => {
63
+ const world = new World();
64
+ const original = world.createEntity();
65
+ world.add(original, TransformComponent, new TransformComponent());
66
+ const commands = new CommandBuffer();
67
+
68
+ world.query({ include: [TransformComponent] }).forEach((entity) => {
69
+ const temp = commands.createEntity();
70
+ commands.add(temp, TransformComponent, new TransformComponent([1, 2, 3]));
71
+ commands.destroyEntity(entity);
72
+ });
73
+ const created = commands.flush(world);
74
+
75
+ assert.equal(created.length, 1);
76
+ assert.deepEqual(world.query({ include: [TransformComponent] }).toArray(), created);
77
+ assert.deepEqual(world.get(created[0], TransformComponent)?.position, [1, 2, 3]);
78
+ });
79
+
80
+ test("system scheduler orders dependencies and rejects cycles", () => {
81
+ const order: string[] = [];
82
+ const make = (name: string, after: string[] = []): System => ({
83
+ name,
84
+ after,
85
+ update: () => order.push(name)
86
+ });
87
+ const scheduler = new SystemScheduler();
88
+ scheduler.add(make("input"));
89
+ scheduler.add(make("movement", ["input"]));
90
+ scheduler.add(make("commit", ["movement"]));
91
+
92
+ assert.deepEqual(scheduler.order().map((system) => system.name), ["input", "movement", "commit"]);
93
+
94
+ const cycle = new SystemScheduler();
95
+ cycle.add(make("a", ["b"]));
96
+ cycle.add(make("b", ["a"]));
97
+ assert.throws(() => cycle.order(), /cycle rejected/);
98
+ });
99
+
100
+ test("world update runs fixed, update, and late phases in explicit order", () => {
101
+ const world = new World();
102
+ const order: string[] = [];
103
+ world.systems.add({ name: "late", phase: "late", update: () => order.push("late") });
104
+ world.systems.add({ name: "fixed", phase: "fixed", update: () => order.push("fixed") });
105
+ world.systems.add({ name: "update", phase: "update", update: () => order.push("update") });
106
+
107
+ world.update({ deltaTime: 1 / 60, elapsedTime: 1, frame: 1 });
108
+
109
+ assert.deepEqual(order, ["fixed", "update", "late"]);
110
+ });
111
+
112
+ test("ECS serialization roundtrips registered component data and remaps entities", () => {
113
+ const world = new World();
114
+ world.registerComponent(TransformComponent);
115
+ world.registerComponent(NameComponent);
116
+ const entity = world.createEntity();
117
+ world.add(entity, TransformComponent, new TransformComponent([1, 2, 3]));
118
+ world.add(entity, NameComponent, new NameComponent("hero"));
119
+
120
+ const { world: restored, remap } = deserializeWorld(serializeWorld(world), [TransformComponent, NameComponent]);
121
+ const restoredEntity = remap.get(`${entity.id}:${entity.generation}`);
122
+
123
+ assert.ok(restoredEntity);
124
+ assert.deepEqual(restored.get(restoredEntity, TransformComponent), { position: [1, 2, 3], rotation: [0, 0, 0, 1], scale: [1, 1, 1] });
125
+ assert.deepEqual(restored.get(restoredEntity, NameComponent), { name: "hero" });
126
+ });
127
+
128
+ test("profiler reports entity and component counts", () => {
129
+ const world = new World();
130
+ const entity = world.createEntity();
131
+ world.add(entity, TagComponent, new TagComponent("player"));
132
+
133
+ assert.deepEqual(profileWorld(world).components.map((entry) => [entry.name, entry.count]), [["TagComponent", 1]]);
134
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "declaration": true,
5
+ "outDir": "dist",
6
+ "emitDeclarationOnly": true
7
+ },
8
+ "include": ["src/**/*.ts"]
9
+ }