@codehz/ecs 0.7.2 → 0.7.3

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 (81) hide show
  1. package/examples/advanced-scheduling.ts +96 -0
  2. package/examples/collision-detection.ts +229 -0
  3. package/examples/inventory-system-relations.ts +108 -0
  4. package/examples/parent-child-hierarchy.ts +206 -0
  5. package/examples/serialization.ts +337 -0
  6. package/examples/simple.ts +96 -0
  7. package/examples/spatial-grid.ts +276 -0
  8. package/examples/state-machine.ts +273 -0
  9. package/examples/tag-filtering.ts +266 -0
  10. package/package.json +58 -12
  11. package/src/__tests__/commands/buffer-limits.test.ts +72 -0
  12. package/src/__tests__/commands/buffer.test.ts +195 -0
  13. package/src/__tests__/component/singleton.test.ts +148 -0
  14. package/src/__tests__/core/archetype.test.ts +247 -0
  15. package/src/__tests__/core/bitset.test.ts +171 -0
  16. package/src/__tests__/core/changeset.test.ts +254 -0
  17. package/src/__tests__/core/multi-map.test.ts +74 -0
  18. package/src/__tests__/entity/component-registry.test.ts +66 -0
  19. package/src/__tests__/entity/entity.test.ts +520 -0
  20. package/src/__tests__/entity/id-manager.test.ts +157 -0
  21. package/src/__tests__/entity/id-system.test.ts +260 -0
  22. package/src/__tests__/perf/comprehensive.perf.test.ts +300 -0
  23. package/src/__tests__/perf/sync-hotpath.perf.test.ts +79 -0
  24. package/src/__tests__/query/basic.test.ts +341 -0
  25. package/src/__tests__/query/caching.test.ts +112 -0
  26. package/src/__tests__/query/filter.test.ts +111 -0
  27. package/src/__tests__/query/optional.test.ts +231 -0
  28. package/src/__tests__/query/perf.test.ts +99 -0
  29. package/src/__tests__/relations/dont-fragment/basic.test.ts +496 -0
  30. package/src/__tests__/relations/dont-fragment/query-notification.test.ts +125 -0
  31. package/src/__tests__/relations/wildcard.test.ts +179 -0
  32. package/src/__tests__/serialization/bounds.test.ts +237 -0
  33. package/src/__tests__/testing/assertions.test.ts +224 -0
  34. package/src/__tests__/testing/entity-builder.test.ts +84 -0
  35. package/src/__tests__/testing/snapshot.test.ts +150 -0
  36. package/src/__tests__/testing/world-fixture.test.ts +73 -0
  37. package/src/__tests__/world/component-hooks.test.ts +185 -0
  38. package/src/__tests__/world/component-management.test.ts +447 -0
  39. package/src/__tests__/world/entity-management.test.ts +86 -0
  40. package/src/__tests__/world/get-optional.test.ts +96 -0
  41. package/src/__tests__/world/multi-component-hooks.test.ts +502 -0
  42. package/src/__tests__/world/perf.test.ts +93 -0
  43. package/src/__tests__/world/query.test.ts +223 -0
  44. package/src/__tests__/world/serialize.test.ts +83 -0
  45. package/src/__tests__/world/wildcard-relation-hooks.test.ts +332 -0
  46. package/src/archetype/archetype.ts +472 -0
  47. package/src/archetype/helpers.ts +186 -0
  48. package/src/archetype/store.ts +33 -0
  49. package/src/commands/buffer.ts +110 -0
  50. package/src/commands/changeset.ts +104 -0
  51. package/src/component/entity-store.ts +223 -0
  52. package/src/component/registry.ts +657 -0
  53. package/src/component/type-utils.ts +9 -0
  54. package/src/entity/index.ts +63 -0
  55. package/src/entity/manager.ts +115 -0
  56. package/src/entity/relation.ts +319 -0
  57. package/src/entity/types.ts +135 -0
  58. package/src/index.ts +41 -0
  59. package/src/query/filter.ts +75 -0
  60. package/src/query/query.ts +313 -0
  61. package/src/query/registry.ts +101 -0
  62. package/src/storage/serialization.ts +130 -0
  63. package/src/testing/index.ts +634 -0
  64. package/src/types/index.ts +99 -0
  65. package/src/utils/bit-set.ts +133 -0
  66. package/src/utils/multi-map.ts +96 -0
  67. package/src/utils/utils.ts +19 -0
  68. package/src/world/builder.ts +100 -0
  69. package/src/world/commands.ts +378 -0
  70. package/src/world/hooks.ts +358 -0
  71. package/src/world/references.ts +38 -0
  72. package/src/world/serialization.ts +122 -0
  73. package/src/world/world.ts +1201 -0
  74. /package/{builder.d.mts → dist/builder.d.mts} +0 -0
  75. /package/{index.d.mts → dist/index.d.mts} +0 -0
  76. /package/{index.mjs → dist/index.mjs} +0 -0
  77. /package/{testing.d.mts → dist/testing.d.mts} +0 -0
  78. /package/{testing.mjs → dist/testing.mjs} +0 -0
  79. /package/{testing.mjs.map → dist/testing.mjs.map} +0 -0
  80. /package/{world.mjs → dist/world.mjs} +0 -0
  81. /package/{world.mjs.map → dist/world.mjs.map} +0 -0
@@ -0,0 +1,96 @@
1
+ import { pipeline } from "@codehz/pipeline";
2
+ import { World, component, type Query } from "../src";
3
+
4
+ // Define component types
5
+ type Position = { x: number; y: number };
6
+ type Velocity = { x: number; y: number };
7
+ type Health = { value: number };
8
+
9
+ // Define component IDs
10
+ const Position = component<Position>();
11
+ const Velocity = component<Velocity>();
12
+ const Health = component<Health>();
13
+
14
+ // Create the world
15
+ const world = new World();
16
+
17
+ // Cache queries
18
+ const movementQuery: Query = world.createQuery([Position, Velocity]);
19
+ const damageQuery: Query = world.createQuery([Position, Health]);
20
+ const renderQuery: Query = world.createQuery([Position]);
21
+
22
+ // Build game loop using pipeline
23
+ // Pass execution order is determined by addition order; no need to manually manage dependencies
24
+ const gameLoop = pipeline<{ deltaTime: number }>()
25
+ // Input pass - handle user input
26
+ .addPass(() => {
27
+ console.log(`[InputPass] Processing input at ${Date.now()}`);
28
+ // Keyboard/mouse input handling etc. goes here
29
+ })
30
+ // Movement pass - update positions
31
+ .addPass((env) => {
32
+ console.log(`[MovementPass] Updating positions`);
33
+ movementQuery.forEach([Position, Velocity], (entity, position, velocity) => {
34
+ position.x += velocity.x * env.deltaTime;
35
+ position.y += velocity.y * env.deltaTime;
36
+ console.log(` Entity ${entity}: Position (${position.x.toFixed(2)}, ${position.y.toFixed(2)})`);
37
+ });
38
+ })
39
+ // Damage pass - calculate damage based on position
40
+ .addPass(() => {
41
+ console.log(`[DamagePass] Applying damage based on position`);
42
+ damageQuery.forEach([Position, Health], (entity, position, health) => {
43
+ // Calculate damage based on position (example logic)
44
+ const damage = Math.abs(position.x) * 0.1;
45
+ health.value -= damage;
46
+ console.log(` Entity ${entity}: Health reduced by ${damage.toFixed(2)}, now ${health.value.toFixed(2)}`);
47
+ });
48
+ })
49
+ // Render pass - render entities
50
+ .addPass(() => {
51
+ console.log(`[RenderPass] Rendering entities`);
52
+ renderQuery.forEach([Position], (entity, position) => {
53
+ console.log(` Rendering Entity ${entity} at (${position.x.toFixed(2)}, ${position.y.toFixed(2)})`);
54
+ });
55
+ })
56
+ // Sync pass - must be called as the last pass to execute all deferred commands
57
+ .addPass(() => {
58
+ world.sync();
59
+ })
60
+ .build();
61
+
62
+ function main() {
63
+ console.log("ECS Advanced Scheduling Demo - Pipeline-based Execution");
64
+ console.log("========================================================");
65
+
66
+ // Create some entities
67
+ const entity1 = world
68
+ .spawn()
69
+ .with(Position, { x: 0, y: 0 })
70
+ .with(Velocity, { x: 2, y: 1 })
71
+ .with(Health, { value: 100 })
72
+ .build();
73
+ void entity1;
74
+
75
+ const entity2 = world
76
+ .spawn()
77
+ .with(Position, { x: 5, y: 3 })
78
+ .with(Velocity, { x: -1, y: 0.5 })
79
+ .with(Health, { value: 80 })
80
+ .build();
81
+ void entity2;
82
+
83
+ // Execute initial sync
84
+ world.sync();
85
+
86
+ // Run a few frames
87
+ console.log("\n--- Frame 1 ---");
88
+ gameLoop({ deltaTime: 1.0 });
89
+
90
+ console.log("\n--- Frame 2 ---");
91
+ gameLoop({ deltaTime: 1.0 });
92
+
93
+ console.log("\nDemo completed!");
94
+ }
95
+
96
+ main();
@@ -0,0 +1,229 @@
1
+ import { pipeline } from "@codehz/pipeline";
2
+ import { World, component, type EntityId, type Query } from "../src";
3
+
4
+ // =============================================================================
5
+ // Component Types
6
+ // =============================================================================
7
+
8
+ type Position = { x: number; y: number };
9
+ type Velocity = { x: number; y: number };
10
+ type Radius = number;
11
+ type Health = { value: number };
12
+ type CollisionEvent = { other: EntityId; overlap: number };
13
+
14
+ // =============================================================================
15
+ // Component IDs
16
+ // =============================================================================
17
+
18
+ const Position = component<Position>();
19
+ const Velocity = component<Velocity>();
20
+ const Radius = component<Radius>();
21
+ const Health = component<Health>();
22
+ const CollisionEvent = component<CollisionEvent>();
23
+
24
+ // =============================================================================
25
+ // World & Pre-cached Queries
26
+ // =============================================================================
27
+
28
+ const world = new World();
29
+
30
+ const movementQuery: Query = world.createQuery([Position, Velocity]);
31
+ const collidableQuery: Query = world.createQuery([Position, Radius]);
32
+ const damagedQuery: Query = world.createQuery([Health]);
33
+ const collisionEventQuery: Query = world.createQuery([CollisionEvent]);
34
+
35
+ // =============================================================================
36
+ // Game Loop (Pipeline Passes)
37
+ // =============================================================================
38
+
39
+ const gameLoop = pipeline<{ deltaTime: number }>()
40
+ // ---------------------------------------------------------------------------
41
+ // MovementPass: Move entities by velocity * deltaTime
42
+ // ---------------------------------------------------------------------------
43
+ .addPass((env) => {
44
+ console.log(`[MovementPass] deltaTime=${env.deltaTime}`);
45
+ movementQuery.forEach([Position, Velocity], (_entity, pos, vel) => {
46
+ pos.x += vel.x * env.deltaTime;
47
+ pos.y += vel.y * env.deltaTime;
48
+ });
49
+ })
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // CollisionDetectionPass: O(n^2) pair check
53
+ // ---------------------------------------------------------------------------
54
+ .addPass(() => {
55
+ console.log(`[CollisionDetectionPass]`);
56
+ const collidables = collidableQuery.getEntitiesWithComponents([Position, Radius]);
57
+ let collisionCount = 0;
58
+
59
+ for (let i = 0; i < collidables.length; i++) {
60
+ const a = collidables[i]!;
61
+ for (let j = i + 1; j < collidables.length; j++) {
62
+ const b = collidables[j]!;
63
+ const dx = a.components[0].x - b.components[0].x;
64
+ const dy = a.components[0].y - b.components[0].y;
65
+ const dist = Math.sqrt(dx * dx + dy * dy);
66
+ const overlap = a.components[1] + b.components[1] - dist;
67
+
68
+ if (overlap > 0) {
69
+ console.log(` Collision: Entity ${a.entity} <-> Entity ${b.entity} ` + `(overlap: ${overlap.toFixed(2)})`);
70
+ world.set(a.entity, CollisionEvent, { other: b.entity, overlap });
71
+ world.set(b.entity, CollisionEvent, { other: a.entity, overlap });
72
+ collisionCount++;
73
+ }
74
+ }
75
+ }
76
+
77
+ if (collisionCount === 0) {
78
+ console.log(` No collisions detected`);
79
+ }
80
+
81
+ // Sync so CollisionEvents are visible to the response pass
82
+ world.sync();
83
+ })
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // CollisionResponsePass: Apply damage & remove transient CollisionEvent
87
+ // ---------------------------------------------------------------------------
88
+ .addPass(() => {
89
+ console.log(`[CollisionResponsePass]`);
90
+ collisionEventQuery.forEach([CollisionEvent], (entity, event) => {
91
+ if (world.has(entity, Health)) {
92
+ const health = world.get(entity, Health);
93
+ health.value -= event.overlap;
94
+ console.log(
95
+ ` Entity ${entity}: took ${event.overlap.toFixed(2)} damage ` +
96
+ `from Entity ${event.other}, health now ${health.value.toFixed(2)}`,
97
+ );
98
+ } else {
99
+ console.log(
100
+ ` Entity ${entity}: collision with Entity ${event.other} ` + `(no Health component, skipping damage)`,
101
+ );
102
+ }
103
+ world.remove(entity, CollisionEvent);
104
+ });
105
+ })
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // CleanupPass: Delete dead entities (health <= 0)
109
+ // ---------------------------------------------------------------------------
110
+ .addPass(() => {
111
+ console.log(`[CleanupPass]`);
112
+ const toDelete: EntityId[] = [];
113
+
114
+ damagedQuery.forEach([Health], (entity, health) => {
115
+ if (health.value <= 0) {
116
+ console.log(` Entity ${entity}: destroyed (health: ${health.value.toFixed(2)})`);
117
+ toDelete.push(entity);
118
+ }
119
+ });
120
+
121
+ for (const entity of toDelete) {
122
+ world.delete(entity);
123
+ }
124
+
125
+ if (toDelete.length === 0) {
126
+ console.log(` No entities to clean up`);
127
+ }
128
+ })
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // RenderPass: Log positions and health of all living entities
132
+ // ---------------------------------------------------------------------------
133
+ .addPass(() => {
134
+ console.log(`[RenderPass]`);
135
+ movementQuery.forEach([Position, Velocity], (entity, pos) => {
136
+ const healthOpt = world.getOptional(entity, Health);
137
+ const healthStr = healthOpt ? healthOpt.value.value.toFixed(2) : "N/A";
138
+ console.log(` Entity ${entity}: pos=(${pos.x.toFixed(2)}, ${pos.y.toFixed(2)}) ` + `health=${healthStr}`);
139
+ });
140
+ })
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // SyncPass: Apply all remaining buffered commands
144
+ // ---------------------------------------------------------------------------
145
+ .addPass(() => {
146
+ world.sync();
147
+ })
148
+ .build();
149
+
150
+ // =============================================================================
151
+ // Setup & Main
152
+ // =============================================================================
153
+
154
+ function main() {
155
+ console.log("ECS Collision Detection Demo");
156
+ console.log("============================\n");
157
+
158
+ // Spawn ~6 entities with diverse properties
159
+ console.log("Spawning entities...\n");
160
+
161
+ // Entity 1: Fast mover, small radius, moderate health
162
+ world
163
+ .spawn()
164
+ .with(Position, { x: 0, y: 0 })
165
+ .with(Velocity, { x: 3, y: 1 })
166
+ .with(Radius, 10)
167
+ .with(Health, { value: 50 })
168
+ .build();
169
+
170
+ // Entity 2: Slow mover, large radius, high health
171
+ world
172
+ .spawn()
173
+ .with(Position, { x: 30, y: 10 })
174
+ .with(Velocity, { x: -1.5, y: 2 })
175
+ .with(Radius, 18)
176
+ .with(Health, { value: 120 })
177
+ .build();
178
+
179
+ // Entity 3: Stationary, medium radius, low health
180
+ world
181
+ .spawn()
182
+ .with(Position, { x: 15, y: 20 })
183
+ .with(Velocity, { x: 0, y: 0 })
184
+ .with(Radius, 14)
185
+ .with(Health, { value: 25 })
186
+ .build();
187
+
188
+ // Entity 4: Diagonal mover, small radius, moderate health
189
+ world
190
+ .spawn()
191
+ .with(Position, { x: 40, y: 5 })
192
+ .with(Velocity, { x: -3, y: -2 })
193
+ .with(Radius, 8)
194
+ .with(Health, { value: 60 })
195
+ .build();
196
+
197
+ // Entity 5: Opposite diagonal, medium radius, high health
198
+ world
199
+ .spawn()
200
+ .with(Position, { x: 10, y: 35 })
201
+ .with(Velocity, { x: 2.5, y: -1.5 })
202
+ .with(Radius, 16)
203
+ .with(Health, { value: 100 })
204
+ .build();
205
+
206
+ // Entity 6: Slow vertical mover, large radius, moderate health
207
+ world
208
+ .spawn()
209
+ .with(Position, { x: 50, y: 25 })
210
+ .with(Velocity, { x: -0.5, y: 3 })
211
+ .with(Radius, 20)
212
+ .with(Health, { value: 80 })
213
+ .build();
214
+
215
+ // Apply initial spawns
216
+ world.sync();
217
+ console.log("All entities spawned and synced.\n");
218
+
219
+ // Run 5 frames
220
+ for (let frame = 1; frame <= 5; frame++) {
221
+ console.log(`--- Frame ${frame} ---`);
222
+ gameLoop({ deltaTime: 1.0 });
223
+ console.log();
224
+ }
225
+
226
+ console.log("Demo completed!");
227
+ }
228
+
229
+ main();
@@ -0,0 +1,108 @@
1
+ import { World, component, relation, type EntityId, type Query } from "../src";
2
+
3
+ type ItemName = { name: string };
4
+ type Stackable = { count: number; maxCount: number };
5
+ type Gold = { amount: number };
6
+ type EquipmentSlot = { slot: string };
7
+
8
+ const ItemName = component<ItemName>({ name: "ItemName" });
9
+ const Stackable = component<Stackable>({ name: "Stackable" });
10
+ const Gold = component<Gold>({ name: "Gold" });
11
+ const EquipmentSlot = component<EquipmentSlot>({ name: "EquipmentSlot" });
12
+ const InInventory = component<void>({ name: "InInventory", dontFragment: true });
13
+
14
+ const world = new World();
15
+
16
+ const inventoryOwners: Query = world.createQuery([relation(InInventory, "*")]);
17
+
18
+ function formatItem(item: EntityId): string {
19
+ const itemName = world.get(item, ItemName);
20
+ const stack = world.getOptional(item, Stackable)?.value;
21
+ const slot = world.getOptional(item, EquipmentSlot)?.value;
22
+
23
+ const details: string[] = [];
24
+ if (stack) {
25
+ details.push(`stack ${stack.count}/${stack.maxCount}`);
26
+ }
27
+ if (slot) {
28
+ details.push(`slot=${slot.slot}`);
29
+ }
30
+
31
+ return details.length > 0 ? `${itemName.name} (${details.join(", ")})` : itemName.name;
32
+ }
33
+
34
+ function printInventory(owner: EntityId, label: string): void {
35
+ if (!world.has(owner, relation(InInventory, "*"))) {
36
+ console.log(`${label}: empty inventory`);
37
+ return;
38
+ }
39
+
40
+ const entries = world.get(owner, relation(InInventory, "*"));
41
+ const itemLines = entries.map(([item]) => ` - Item ${item}: ${formatItem(item)}`);
42
+ console.log(`${label}:\n${itemLines.join("\n")}`);
43
+ }
44
+
45
+ function countInventoryItems(owner: EntityId): number {
46
+ return world.getOptional(owner, relation(InInventory, "*"))?.value.length ?? 0;
47
+ }
48
+
49
+ function printInventorySummary(): void {
50
+ console.log("\n[InventorySummary]");
51
+ inventoryOwners.forEach([relation(InInventory, "*")], (owner, items) => {
52
+ const gold = world.getOptional(owner, Gold)?.value;
53
+ console.log(`Owner ${owner}: ${items.length} item(s), gold=${gold?.amount ?? 0}`);
54
+ });
55
+ }
56
+
57
+ function main() {
58
+ console.log("ECS Inventory System Demo - Non-exclusive Relations");
59
+ console.log("===================================================");
60
+
61
+ const sword = world.spawn().with(ItemName, { name: "Iron Sword" }).with(EquipmentSlot, { slot: "weapon" }).build();
62
+ const armor = world.spawn().with(ItemName, { name: "Leather Armor" }).with(EquipmentSlot, { slot: "armor" }).build();
63
+ const potion = world
64
+ .spawn()
65
+ .with(ItemName, { name: "Health Potion" })
66
+ .with(Stackable, { count: 3, maxCount: 20 })
67
+ .build();
68
+ const arrows = world
69
+ .spawn()
70
+ .with(ItemName, { name: "Arrow Bundle" })
71
+ .with(Stackable, { count: 48, maxCount: 99 })
72
+ .build();
73
+
74
+ const player = world
75
+ .spawn()
76
+ .with(Gold, { amount: 125 })
77
+ .with(relation(InInventory, sword))
78
+ .with(relation(InInventory, armor))
79
+ .with(relation(InInventory, potion))
80
+ .with(relation(InInventory, arrows))
81
+ .build();
82
+ world.sync();
83
+
84
+ console.log(`\nPlayer ${player} starts with ${world.get(player, Gold).amount} gold.`);
85
+ printInventory(player, "Initial inventory");
86
+
87
+ const potionStack = world.get(potion, Stackable);
88
+ potionStack.count += 2;
89
+ console.log(`\nPicked up more potions. Potion stack is now ${potionStack.count}/${potionStack.maxCount}.`);
90
+
91
+ const swordSlot = world.get(sword, EquipmentSlot);
92
+ console.log(`Equipped ${world.get(sword, ItemName).name} to ${swordSlot.slot}.`);
93
+
94
+ world.remove(player, relation(InInventory, armor));
95
+ world.set(player, Gold, { amount: world.get(player, Gold).amount + 35 });
96
+ world.sync();
97
+
98
+ console.log(`\nSold ${world.get(armor, ItemName).name} for 35 gold.`);
99
+ console.log(`Player ${player} now has ${world.get(player, Gold).amount} gold.`);
100
+ printInventory(player, "Inventory after selling armor");
101
+
102
+ console.log(`\nInventory count via wildcard relation: ${countInventoryItems(player)}`);
103
+ printInventorySummary();
104
+
105
+ console.log("\nDemo completed!");
106
+ }
107
+
108
+ main();
@@ -0,0 +1,206 @@
1
+ import { pipeline } from "@codehz/pipeline";
2
+ import { World, component, relation, type EntityId, type Query } from "../src";
3
+
4
+ // Define component types
5
+ type Transform = { x: number; y: number; rotation: number; scale: number };
6
+ type LinearVelocity = { x: number; y: number };
7
+ type AngularVelocity = { degreesPerSecond: number };
8
+ type Name = { value: string };
9
+
10
+ // Define component IDs
11
+ const Name = component<Name>({ name: "Name" });
12
+ const LocalTransform = component<Transform>({ name: "LocalTransform" });
13
+ const WorldTransform = component<Transform>({ name: "WorldTransform" });
14
+ const LinearVelocity = component<LinearVelocity>({ name: "LinearVelocity" });
15
+ const AngularVelocity = component<AngularVelocity>({ name: "AngularVelocity" });
16
+ const ChildOf = component<void>({ exclusive: true, dontFragment: true, name: "ChildOf" });
17
+
18
+ // Create the world
19
+ const world = new World();
20
+
21
+ // Cache queries
22
+ const movementQuery: Query = world.createQuery([LocalTransform, LinearVelocity]);
23
+ const rotationQuery: Query = world.createQuery([LocalTransform, AngularVelocity]);
24
+ const transformQuery: Query = world.createQuery([Name, LocalTransform, WorldTransform]);
25
+ const childQuery: Query = world.createQuery([relation(ChildOf, "*")]);
26
+ const renderQuery: Query = world.createQuery([Name, WorldTransform]);
27
+
28
+ function toRadians(degrees: number): number {
29
+ return (degrees * Math.PI) / 180;
30
+ }
31
+
32
+ function copyTransform(target: Transform, source: Transform): void {
33
+ target.x = source.x;
34
+ target.y = source.y;
35
+ target.rotation = source.rotation;
36
+ target.scale = source.scale;
37
+ }
38
+
39
+ function composeTransform(local: Transform, parent?: Transform): Transform {
40
+ if (!parent) {
41
+ return { ...local };
42
+ }
43
+
44
+ const angle = toRadians(parent.rotation);
45
+ const scaledX = local.x * parent.scale;
46
+ const scaledY = local.y * parent.scale;
47
+ const cos = Math.cos(angle);
48
+ const sin = Math.sin(angle);
49
+
50
+ return {
51
+ x: parent.x + scaledX * cos - scaledY * sin,
52
+ y: parent.y + scaledX * sin + scaledY * cos,
53
+ rotation: parent.rotation + local.rotation,
54
+ scale: parent.scale * local.scale,
55
+ };
56
+ }
57
+
58
+ function formatTransform(transform: Transform): string {
59
+ return `pos=(${transform.x.toFixed(2)}, ${transform.y.toFixed(2)}) rot=${transform.rotation.toFixed(1)}deg scale=${transform.scale.toFixed(2)}`;
60
+ }
61
+
62
+ function buildChildrenByParent(): Map<EntityId, EntityId[]> {
63
+ const childrenByParent = new Map<EntityId, EntityId[]>();
64
+
65
+ childQuery.forEach([relation(ChildOf, "*")], (child, parents) => {
66
+ const parent = parents[0]?.[0];
67
+ if (parent === undefined) return;
68
+
69
+ const children = childrenByParent.get(parent) ?? [];
70
+ children.push(child);
71
+ childrenByParent.set(parent, children);
72
+ });
73
+
74
+ return childrenByParent;
75
+ }
76
+
77
+ function propagateChildren(
78
+ parent: EntityId,
79
+ parentWorld: Transform,
80
+ childrenByParent: Map<EntityId, EntityId[]>,
81
+ ): void {
82
+ const children = childrenByParent.get(parent);
83
+ if (!children) return;
84
+
85
+ for (const child of children) {
86
+ const name = world.get(child, Name);
87
+ const local = world.get(child, LocalTransform);
88
+ const worldTransform = world.get(child, WorldTransform);
89
+ copyTransform(worldTransform, composeTransform(local, parentWorld));
90
+ console.log(` Child ${name.value}: ${formatTransform(worldTransform)}`);
91
+ propagateChildren(child, worldTransform, childrenByParent);
92
+ }
93
+ }
94
+
95
+ // Build game loop using pipeline
96
+ // Pass execution order is determined by addition order; no need to manually manage dependencies
97
+ const gameLoop = pipeline<{ deltaTime: number }>()
98
+ // Local movement pass - update local positions
99
+ .addPass((env) => {
100
+ console.log(`[LocalMovementPass] Updating local positions`);
101
+ movementQuery.forEach([LocalTransform, LinearVelocity], (entity, localTransform, velocity) => {
102
+ localTransform.x += velocity.x * env.deltaTime;
103
+ localTransform.y += velocity.y * env.deltaTime;
104
+ const name = world.get(entity, Name);
105
+ console.log(` ${name.value}: local pos=(${localTransform.x.toFixed(2)}, ${localTransform.y.toFixed(2)})`);
106
+ });
107
+ })
108
+ // Local rotation pass - update local rotation
109
+ .addPass((env) => {
110
+ console.log(`[LocalRotationPass] Updating local rotations`);
111
+ rotationQuery.forEach([LocalTransform, AngularVelocity], (entity, localTransform, angularVelocity) => {
112
+ localTransform.rotation += angularVelocity.degreesPerSecond * env.deltaTime;
113
+ const name = world.get(entity, Name);
114
+ console.log(` ${name.value}: local rot=${localTransform.rotation.toFixed(1)}deg`);
115
+ });
116
+ })
117
+ // Hierarchy pass - propagate parent transforms into world transforms
118
+ .addPass(() => {
119
+ console.log(`[HierarchyPass] Propagating world transforms`);
120
+ const childrenByParent = buildChildrenByParent();
121
+
122
+ transformQuery.forEach([Name, LocalTransform, WorldTransform], (entity, name, localTransform, worldTransform) => {
123
+ if (world.has(entity, relation(ChildOf, "*"))) return;
124
+
125
+ copyTransform(worldTransform, composeTransform(localTransform));
126
+ console.log(` Root ${name.value}: ${formatTransform(worldTransform)}`);
127
+ propagateChildren(entity, worldTransform, childrenByParent);
128
+ });
129
+ })
130
+ // Render pass - render propagated world transforms
131
+ .addPass(() => {
132
+ console.log(`[RenderPass] Rendering world transforms`);
133
+ renderQuery.forEach([Name, WorldTransform], (_entity, name, worldTransform) => {
134
+ console.log(` ${name.value}: ${formatTransform(worldTransform)}`);
135
+ });
136
+ })
137
+ // Sync pass - must be called as the last pass to execute all deferred commands
138
+ .addPass(() => {
139
+ world.sync();
140
+ })
141
+ .build();
142
+
143
+ function main() {
144
+ console.log("ECS Parent-Child Hierarchy Demo - Transform Propagation");
145
+ console.log("=======================================================");
146
+
147
+ // Create a moving root entity
148
+ const ship = world
149
+ .spawn()
150
+ .with(Name, { value: "Ship" })
151
+ .with(LocalTransform, { x: 0, y: 0, rotation: 0, scale: 1 })
152
+ .with(WorldTransform, { x: 0, y: 0, rotation: 0, scale: 1 })
153
+ .with(LinearVelocity, { x: 1, y: 0.25 })
154
+ .build();
155
+
156
+ // Create a rotating child attached to the ship
157
+ const turret = world
158
+ .spawn()
159
+ .with(Name, { value: "Turret" })
160
+ .with(LocalTransform, { x: 2, y: 0, rotation: 0, scale: 1 })
161
+ .with(WorldTransform, { x: 0, y: 0, rotation: 0, scale: 1 })
162
+ .with(AngularVelocity, { degreesPerSecond: 45 })
163
+ .with(relation(ChildOf, ship))
164
+ .build();
165
+
166
+ // Create a grandchild so propagation traverses multiple levels
167
+ const muzzle = world
168
+ .spawn()
169
+ .with(Name, { value: "Muzzle" })
170
+ .with(LocalTransform, { x: 1.5, y: 0.5, rotation: 0, scale: 1 })
171
+ .with(WorldTransform, { x: 0, y: 0, rotation: 0, scale: 1 })
172
+ .with(relation(ChildOf, turret))
173
+ .build();
174
+ void muzzle;
175
+
176
+ // Create a second root entity to show independent hierarchies
177
+ const drone = world
178
+ .spawn()
179
+ .with(Name, { value: "Drone" })
180
+ .with(LocalTransform, { x: -4, y: 3, rotation: 15, scale: 0.75 })
181
+ .with(WorldTransform, { x: -4, y: 3, rotation: 15, scale: 0.75 })
182
+ .with(LinearVelocity, { x: 0.5, y: -0.25 })
183
+ .build();
184
+ void drone;
185
+
186
+ // Execute initial sync
187
+ world.sync();
188
+
189
+ // Run an initial propagation frame so children receive their world transforms
190
+ console.log("\n--- Initial Hierarchy ---");
191
+ gameLoop({ deltaTime: 0.0 });
192
+
193
+ // Run a few frames
194
+ console.log("\n--- Frame 1 ---");
195
+ gameLoop({ deltaTime: 1.0 });
196
+
197
+ console.log("\n--- Frame 2 ---");
198
+ gameLoop({ deltaTime: 1.0 });
199
+
200
+ console.log("\n--- Frame 3 ---");
201
+ gameLoop({ deltaTime: 1.0 });
202
+
203
+ console.log("\nDemo completed!");
204
+ }
205
+
206
+ main();