@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,337 @@
1
+ import type { EntityId, SerializedWorld } from "../src";
2
+ import { World, component } 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; maxValue: number };
8
+ type Name = { value: string };
9
+
10
+ // Define component IDs
11
+ const Position = component<Position>("Position");
12
+ const Velocity = component<Velocity>("Velocity");
13
+ const Health = component<Health>("Health");
14
+ const Name = component<Name>("Name");
15
+
16
+ // Helper: print world state by iterating entities that have given components
17
+ function printWorldState(world: World, label: string): void {
18
+ console.log(`\n=== ${label} ===`);
19
+
20
+ // Query all entities that have Position (our "base" component)
21
+ const results = world.query([Position], true);
22
+
23
+ if (results.length === 0) {
24
+ console.log(" (no entities found)");
25
+ return;
26
+ }
27
+
28
+ for (const {
29
+ entity,
30
+ components: [pos],
31
+ } of results) {
32
+ const parts: string[] = [`Entity ${entity}:`, ` Position: (${pos.x}, ${pos.y})`];
33
+
34
+ if (world.has(entity, Velocity)) {
35
+ const vel = world.get(entity, Velocity);
36
+ parts.push(` Velocity: (${vel.x}, ${vel.y})`);
37
+ }
38
+
39
+ if (world.has(entity, Health)) {
40
+ const hp = world.get(entity, Health);
41
+ parts.push(` Health: ${hp.value}/${hp.maxValue}`);
42
+ }
43
+
44
+ if (world.has(entity, Name)) {
45
+ const name = world.get(entity, Name);
46
+ parts.push(` Name: "${name.value}"`);
47
+ }
48
+
49
+ console.log(parts.join("\n"));
50
+ }
51
+ }
52
+
53
+ // Helper: verify two worlds have equivalent state
54
+ function verifyWorldsMatch(original: World, restored: World): boolean {
55
+ const origResults = original.query([Position], true);
56
+ const restResults = restored.query([Position], true);
57
+
58
+ if (origResults.length !== restResults.length) {
59
+ console.error(` Entity count mismatch: ${origResults.length} vs ${restResults.length}`);
60
+ return false;
61
+ }
62
+
63
+ for (let i = 0; i < origResults.length; i++) {
64
+ const orig = origResults[i]!;
65
+ const rest = restResults[i]!;
66
+
67
+ if (orig.entity !== rest.entity) {
68
+ console.error(` Entity ID mismatch at index ${i}: ${orig.entity} vs ${rest.entity}`);
69
+ return false;
70
+ }
71
+
72
+ const posMatch = orig.components[0].x === rest.components[0].x && orig.components[0].y === rest.components[0].y;
73
+ if (!posMatch) {
74
+ console.error(` Position mismatch for entity ${orig.entity}`);
75
+ return false;
76
+ }
77
+
78
+ // Check optional components
79
+ const checkComp = (compId: EntityId<any>): boolean => {
80
+ const origHas = original.has(orig.entity, compId);
81
+ const restHas = restored.has(rest.entity, compId);
82
+ if (origHas !== restHas) {
83
+ console.error(` Component presence mismatch for entity ${orig.entity}`);
84
+ return false;
85
+ }
86
+ if (origHas && restHas) {
87
+ const origVal = JSON.stringify(original.get(orig.entity, compId));
88
+ const restVal = JSON.stringify(restored.get(rest.entity, compId));
89
+ if (origVal !== restVal) {
90
+ console.error(` Component value mismatch for entity ${orig.entity}: ${origVal} vs ${restVal}`);
91
+ return false;
92
+ }
93
+ }
94
+ return true;
95
+ };
96
+ if (!checkComp(Velocity) || !checkComp(Health) || !checkComp(Name)) {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ return true;
102
+ }
103
+
104
+ // Bonus: Custom encode/decode pattern
105
+ // Instead of relying on JSON.stringify/parse, you can manually build
106
+ // a serialized format. This is useful when you need to:
107
+ // - Integrate with a binary format or custom protocol
108
+ // - Add versioning/metadata beyond the snapshot structure
109
+ // - Transform data during serialization (e.g., compress coordinates)
110
+ interface CustomSaveFormat {
111
+ meta: {
112
+ version: number;
113
+ timestamp: number;
114
+ entityCount: number;
115
+ };
116
+ entities: Array<{
117
+ id: number;
118
+ position: { x: number; y: number };
119
+ velocity?: { x: number; y: number };
120
+ health?: { value: number; maxValue: number };
121
+ name?: string;
122
+ }>;
123
+ }
124
+
125
+ function customEncode(world: World): CustomSaveFormat {
126
+ const results = world.query([Position], true);
127
+ const entities: CustomSaveFormat["entities"] = [];
128
+
129
+ for (const {
130
+ entity,
131
+ components: [pos],
132
+ } of results) {
133
+ const entry: CustomSaveFormat["entities"][number] = {
134
+ id: entity as number,
135
+ position: { x: pos.x, y: pos.y },
136
+ };
137
+
138
+ if (world.has(entity, Velocity)) {
139
+ const vel = world.get(entity, Velocity);
140
+ entry.velocity = { x: vel.x, y: vel.y };
141
+ }
142
+
143
+ if (world.has(entity, Health)) {
144
+ const hp = world.get(entity, Health);
145
+ entry.health = { value: hp.value, maxValue: hp.maxValue };
146
+ }
147
+
148
+ if (world.has(entity, Name)) {
149
+ const name = world.get(entity, Name);
150
+ entry.name = name.value;
151
+ }
152
+
153
+ entities.push(entry);
154
+ }
155
+
156
+ return {
157
+ meta: {
158
+ version: 1,
159
+ timestamp: Date.now(),
160
+ entityCount: entities.length,
161
+ },
162
+ entities,
163
+ };
164
+ }
165
+
166
+ function customDecode(data: CustomSaveFormat): World {
167
+ const world = new World();
168
+
169
+ for (const entry of data.entities) {
170
+ const builder = world.spawn().with(Position, entry.position);
171
+
172
+ if (entry.velocity) {
173
+ builder.with(Velocity, entry.velocity);
174
+ }
175
+
176
+ if (entry.health) {
177
+ builder.with(Health, entry.health);
178
+ }
179
+
180
+ if (entry.name !== undefined) {
181
+ builder.with(Name, { value: entry.name });
182
+ }
183
+
184
+ builder.build();
185
+ }
186
+
187
+ world.sync();
188
+ return world;
189
+ }
190
+
191
+ function main() {
192
+ console.log("ECS Serialization Demo - Save/Load Roundtrip");
193
+ console.log("==============================================");
194
+
195
+ // =========================================================================
196
+ // Part 1: Build the original world
197
+ // =========================================================================
198
+ console.log("\n[1] Creating original world and spawning entities...");
199
+
200
+ const world = new World();
201
+
202
+ // Spawn a player entity
203
+ const player = world
204
+ .spawn()
205
+ .with(Position, { x: 0, y: 0 })
206
+ .with(Velocity, { x: 1, y: 0.5 })
207
+ .with(Health, { value: 100, maxValue: 100 })
208
+ .with(Name, { value: "Player" })
209
+ .build();
210
+ void player;
211
+
212
+ // Spawn an enemy entity
213
+ const enemy = world
214
+ .spawn()
215
+ .with(Position, { x: 50, y: 30 })
216
+ .with(Velocity, { x: -0.5, y: 0.2 })
217
+ .with(Health, { value: 50, maxValue: 50 })
218
+ .with(Name, { value: "Goblin" })
219
+ .build();
220
+ void enemy;
221
+
222
+ // Spawn a static prop (no velocity, no health)
223
+ const prop = world.spawn().with(Position, { x: 100, y: 200 }).with(Name, { value: "TreasureChest" }).build();
224
+ void prop;
225
+
226
+ // Apply all deferred commands
227
+ world.sync();
228
+
229
+ printWorldState(world, "Original World State");
230
+
231
+ // =========================================================================
232
+ // Part 2: Serialize to snapshot
233
+ // =========================================================================
234
+ console.log("\n[2] Serializing world to snapshot...");
235
+
236
+ const snapshot: SerializedWorld = world.serialize();
237
+
238
+ console.log("\n=== Snapshot Structure ===");
239
+ console.log(` version: ${snapshot.version}`);
240
+ console.log(` entityManager.nextId: ${snapshot.entityManager.nextId}`);
241
+ console.log(` entities count: ${snapshot.entities.length}`);
242
+ console.log(" entity IDs:", snapshot.entities.map((e) => e.id).join(", "));
243
+ console.log(
244
+ " components per entity:",
245
+ snapshot.entities.map((e) => `${e.id}: [${e.components.map((c) => c.type).join(", ")}]`).join(" | "),
246
+ );
247
+
248
+ // =========================================================================
249
+ // Part 3: JSON roundtrip
250
+ // =========================================================================
251
+ console.log("\n[3] JSON serialization roundtrip...");
252
+
253
+ const json = JSON.stringify(snapshot);
254
+ console.log(` JSON size: ${json.length} bytes`);
255
+
256
+ // Print abbreviated JSON
257
+ if (json.length > 300) {
258
+ console.log(` JSON (first 300 chars): ${json.slice(0, 300)}...`);
259
+ } else {
260
+ console.log(` JSON: ${json}`);
261
+ }
262
+
263
+ const parsed = JSON.parse(json);
264
+ console.log(" Parsed back successfully.");
265
+
266
+ // =========================================================================
267
+ // Part 4: Restore from snapshot
268
+ // =========================================================================
269
+ console.log("\n[4] Restoring world from parsed snapshot...");
270
+
271
+ const restoredWorld = new World(parsed);
272
+
273
+ printWorldState(restoredWorld, "Restored World State");
274
+
275
+ // =========================================================================
276
+ // Part 5: Verify roundtrip integrity
277
+ // =========================================================================
278
+ console.log("\n[5] Verifying roundtrip integrity...");
279
+
280
+ const match = verifyWorldsMatch(world, restoredWorld);
281
+ if (match) {
282
+ console.log(" ✅ Original and restored worlds match exactly!");
283
+ } else {
284
+ console.log(" ❌ Mismatch detected between original and restored worlds!");
285
+ }
286
+
287
+ // =========================================================================
288
+ // Part 6: Bonus - Custom encode/decode pattern
289
+ // =========================================================================
290
+ console.log("\n[6] Bonus: Custom encode/decode pattern...");
291
+
292
+ // Demonstrate custom encoding (e.g., for a hand-rolled save format)
293
+ const customData = customEncode(world);
294
+ console.log("\n=== Custom Save Format ===");
295
+ console.log(` Meta: v${customData.meta.version}, ${customData.meta.entityCount} entities`);
296
+ for (const entry of customData.entities) {
297
+ const extras: string[] = [];
298
+ if (entry.velocity) extras.push(`velocity`);
299
+ if (entry.health) extras.push(`health`);
300
+ if (entry.name) extras.push(`name`);
301
+ console.log(
302
+ ` Entity ${entry.id}: pos(${entry.position.x}, ${entry.position.y})` +
303
+ (extras.length > 0 ? ` [${extras.join(", ")}]` : ""),
304
+ );
305
+ }
306
+
307
+ // Restore from custom format
308
+ const customRestoredWorld = customDecode(customData);
309
+
310
+ printWorldState(customRestoredWorld, "Custom-Decoded World State");
311
+
312
+ // Verify custom roundtrip (self-consistency: re-encode and compare component data)
313
+ // Note: entity IDs may differ, so we compare only component content
314
+ const reEncoded = customEncode(customRestoredWorld);
315
+ const customMatch =
316
+ reEncoded.entities.length === customData.entities.length &&
317
+ reEncoded.entities.every((entry, i) => {
318
+ const orig = customData.entities[i]!;
319
+ return (
320
+ entry.position.x === orig.position.x &&
321
+ entry.position.y === orig.position.y &&
322
+ JSON.stringify(entry.velocity) === JSON.stringify(orig.velocity) &&
323
+ JSON.stringify(entry.health) === JSON.stringify(orig.health) &&
324
+ entry.name === orig.name
325
+ );
326
+ });
327
+ if (customMatch) {
328
+ console.log(" ✅ Custom encode/decode roundtrip preserves all component data!");
329
+ } else {
330
+ console.log(" ❌ Custom encode/decode mismatch detected!");
331
+ }
332
+
333
+ console.log("\n==============================================");
334
+ console.log("Serialization demo completed successfully!");
335
+ }
336
+
337
+ main();
@@ -0,0 +1,96 @@
1
+ import { pipeline } from "@codehz/pipeline";
2
+ import { component, relation, World, type Query } from "../src";
3
+
4
+ // Define component types
5
+ type Position = { x: number; y: number };
6
+ type Velocity = { x: number; y: number };
7
+
8
+ // Define component IDs
9
+ const Position = component<Position>();
10
+ const Velocity = component<Velocity>();
11
+ const ChildOf = component({ exclusive: true }); // Exclusive relation component
12
+
13
+ // Create the world
14
+ const world = new World();
15
+
16
+ // Pre-cache queries
17
+ const movementQuery: Query = world.createQuery([Position, Velocity]);
18
+
19
+ // Build game loop using pipeline
20
+ const gameLoop = pipeline<{ deltaTime: number }>()
21
+ // Movement pass
22
+ .addPass((env) => {
23
+ movementQuery.forEach([Position, Velocity], (entity, position, velocity) => {
24
+ position.x += velocity.x * env.deltaTime;
25
+ position.y += velocity.y * env.deltaTime;
26
+ console.log(`Entity ${entity}: Position (${position.x.toFixed(2)}, ${position.y.toFixed(2)})`);
27
+ });
28
+ })
29
+ // Sync pass - must be called as the last pass to execute all deferred commands
30
+ .addPass(() => {
31
+ world.sync();
32
+ })
33
+ .build();
34
+
35
+ function main() {
36
+ console.log("ECS Simple Demo");
37
+
38
+ // Create entity 1
39
+ const entity1 = world.spawn().with(Position, { x: 0, y: 0 }).with(Velocity, { x: 1, y: 0.5 }).build();
40
+
41
+ // Create entity 2
42
+ const entity2 = world.spawn().with(Position, { x: 10, y: 10 }).with(Velocity, { x: -0.5, y: 1 }).build();
43
+ void entity2;
44
+
45
+ // Demonstrate Exclusive Relations
46
+ console.log("\nExclusive Relations Demo:");
47
+ const parent1 = world.spawn().build();
48
+ const parent2 = world.spawn().build();
49
+ const child = world.spawn().with(relation(ChildOf, parent1)).build();
50
+
51
+ // ChildOf is already marked as exclusive in component definition
52
+
53
+ world.sync();
54
+ console.log(`Child has ChildOf(parent1): ${world.has(child, relation(ChildOf, parent1))}`);
55
+ console.log(`Child has ChildOf(parent2): ${world.has(child, relation(ChildOf, parent2))}`);
56
+
57
+ // Add second parent relation - should replace the first
58
+ world.set(child, relation(ChildOf, parent2));
59
+ world.sync();
60
+ console.log(`After adding ChildOf(parent2):`);
61
+ console.log(`Child has ChildOf(parent1): ${world.has(child, relation(ChildOf, parent1))}`);
62
+ console.log(`Child has ChildOf(parent2): ${world.has(child, relation(ChildOf, parent2))}`);
63
+
64
+ // Register component hooks
65
+ world.hook([Position], {
66
+ on_set: (entityId, component) => {
67
+ console.log(`Component set hook triggered: Entity ${entityId} Position is (${component.x}, ${component.y})`);
68
+ },
69
+ });
70
+
71
+ world.hook([Velocity], {
72
+ on_remove: (entityId) => {
73
+ console.log(`Component remove hook triggered: Entity ${entityId} removed Velocity component`);
74
+ },
75
+ });
76
+
77
+ // Execute commands to apply component additions
78
+ world.sync();
79
+
80
+ // Run a few update cycles
81
+ const deltaTime = 1.0; // 1 second
82
+ for (let i = 0; i < 5; i++) {
83
+ console.log(`\nUpdate ${i + 1}:`);
84
+ gameLoop({ deltaTime });
85
+ }
86
+
87
+ // Demonstrate component removal hooks
88
+ console.log("\nComponent Removal Demo:");
89
+ world.remove(entity1, Velocity);
90
+ world.sync();
91
+
92
+ console.log("\nDemo completed!");
93
+ }
94
+
95
+ // Run demo
96
+ main();
@@ -0,0 +1,276 @@
1
+ import { pipeline } from "@codehz/pipeline";
2
+ import { World, component, type EntityId, type Query } from "../src";
3
+
4
+ // =============================================================================
5
+ // Component Type Definitions
6
+ // =============================================================================
7
+
8
+ type Position = { x: number; y: number };
9
+ type Velocity = { x: number; y: number };
10
+ type GridCell = { cellX: number; cellY: number };
11
+ type SpatialGrid = { cells: Map<string, EntityId[]>; cellSize: number };
12
+
13
+ // =============================================================================
14
+ // Component ID Definitions
15
+ // =============================================================================
16
+
17
+ const Position = component<Position>();
18
+ const Velocity = component<Velocity>();
19
+ const GridCell = component<GridCell>();
20
+ const Enemy = component(); // void tag
21
+ const Player = component(); // void tag
22
+ const Projectile = component(); // void tag
23
+ const Dead = component(); // void tag (negative filter to exclude dead entities)
24
+ const SpatialGrid = component<SpatialGrid>(); // singleton component
25
+
26
+ // =============================================================================
27
+ // World & Queries
28
+ // =============================================================================
29
+
30
+ const world = new World();
31
+
32
+ // Pre-cache all queries (long-term reuse)
33
+ const movementQuery: Query = world.createQuery([Position, Velocity], {
34
+ negativeComponentTypes: [Dead],
35
+ });
36
+ const enemyQuery: Query = world.createQuery([Position, Enemy], {
37
+ negativeComponentTypes: [Dead],
38
+ });
39
+ const playerQuery: Query = world.createQuery([Position, Player], {
40
+ negativeComponentTypes: [Dead],
41
+ });
42
+ const projectileQuery: Query = world.createQuery([Position, Projectile, Velocity], {
43
+ negativeComponentTypes: [Dead],
44
+ });
45
+ const gridCellQuery: Query = world.createQuery([Position, GridCell], {
46
+ negativeComponentTypes: [Dead],
47
+ });
48
+
49
+ // =============================================================================
50
+ // Per-frame dead-entity tracking (Dead component is buffered; sync happens at
51
+ // the end of the frame, so we use a local set for same-frame cleanup).
52
+ // =============================================================================
53
+
54
+ const newlyDead = new Set<EntityId>();
55
+
56
+ // =============================================================================
57
+ // Pipeline Passes
58
+ // =============================================================================
59
+
60
+ const gameLoop = pipeline<{ deltaTime: number }>()
61
+ // ---------------------------------------------------------------------------
62
+ // MovementPass — move all entities by velocity * deltaTime
63
+ // ---------------------------------------------------------------------------
64
+ .addPass((env) => {
65
+ console.log("[MovementPass] Updating positions...");
66
+ let count = 0;
67
+ movementQuery.forEach([Position, Velocity], (_entity, position, velocity) => {
68
+ position.x += velocity.x * env.deltaTime;
69
+ position.y += velocity.y * env.deltaTime;
70
+ count++;
71
+ });
72
+ console.log(` Moved ${count} entities`);
73
+ })
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // GridUpdatePass — rebuild spatial grid
77
+ // ---------------------------------------------------------------------------
78
+ .addPass(() => {
79
+ console.log("[GridUpdatePass] Rebuilding spatial grid...");
80
+ const grid = world.get(SpatialGrid);
81
+ const { cellSize } = grid;
82
+ grid.cells.clear();
83
+
84
+ let count = 0;
85
+ gridCellQuery.forEach([Position, GridCell], (_entity, position, gridCell) => {
86
+ const cx = Math.floor(position.x / cellSize);
87
+ const cy = Math.floor(position.y / cellSize);
88
+ gridCell.cellX = cx;
89
+ gridCell.cellY = cy;
90
+
91
+ const key = `${cx},${cy}`;
92
+ const bucket = grid.cells.get(key);
93
+ if (bucket) {
94
+ bucket.push(_entity);
95
+ } else {
96
+ grid.cells.set(key, [_entity]);
97
+ }
98
+ count++;
99
+ });
100
+ console.log(` Grid rebuilt: ${grid.cells.size} cell(s), ${count} entity/ies`);
101
+ })
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // ProximityCheckPass — check if any enemy is near the player (grid-based)
105
+ // ---------------------------------------------------------------------------
106
+ .addPass(() => {
107
+ console.log("[ProximityCheckPass] Checking enemy-player proximity...");
108
+ const playerEntities = playerQuery.getEntities();
109
+ if (playerEntities.length === 0) {
110
+ console.log(" No player found — skipping");
111
+ return;
112
+ }
113
+
114
+ const playerEntity = playerEntities[0]!;
115
+ const playerPos = world.get(playerEntity, Position);
116
+ const grid = world.get(SpatialGrid);
117
+ const { cellSize } = grid;
118
+ const pcx = Math.floor(playerPos.x / cellSize);
119
+ const pcy = Math.floor(playerPos.y / cellSize);
120
+
121
+ let alerts = 0;
122
+ enemyQuery.forEach([Position, Enemy], (enemyEntity, enemyPos, _enemy) => {
123
+ const ecx = Math.floor(enemyPos.x / cellSize);
124
+ const ecy = Math.floor(enemyPos.y / cellSize);
125
+
126
+ // Same or adjacent cell (3x3 neighborhood)
127
+ if (Math.abs(ecx - pcx) <= 1 && Math.abs(ecy - pcy) <= 1) {
128
+ console.log(
129
+ ` ⚠ Player detected! Enemy ${enemyEntity} is nearby ` +
130
+ `(player cell: ${pcx},${pcy} | enemy cell: ${ecx},${ecy})`,
131
+ );
132
+ alerts++;
133
+ }
134
+ });
135
+ console.log(` Proximity check done: ${alerts} alert(s)`);
136
+ })
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // ProjectileCheckPass — projectiles hit enemies in the same grid cell
140
+ // ---------------------------------------------------------------------------
141
+ .addPass(() => {
142
+ console.log("[ProjectileCheckPass] Checking projectile-enemy collisions...");
143
+ const grid = world.get(SpatialGrid);
144
+ let hits = 0;
145
+
146
+ projectileQuery.forEach([Position, Projectile, Velocity], (projectileEntity, _pos, _proj, _vel) => {
147
+ const cx = Math.floor(_pos.x / grid.cellSize);
148
+ const cy = Math.floor(_pos.y / grid.cellSize);
149
+ const key = `${cx},${cy}`;
150
+ const bucket = grid.cells.get(key);
151
+
152
+ if (!bucket || bucket.length === 0) return;
153
+
154
+ // Find an enemy in the same cell
155
+ for (const otherEntity of bucket) {
156
+ if (!world.has(otherEntity, Enemy)) continue;
157
+ if (newlyDead.has(otherEntity as EntityId)) continue; // already dead this frame
158
+
159
+ // Hit! Mark both projectile and enemy as Dead
160
+ world.set(projectileEntity, Dead);
161
+ world.set(otherEntity, Dead);
162
+ newlyDead.add(projectileEntity as EntityId);
163
+ newlyDead.add(otherEntity as EntityId);
164
+ console.log(
165
+ ` 💥 Hit! Projectile ${projectileEntity} destroyed Enemy ${otherEntity} ` + `in cell (${cx},${cy})`,
166
+ );
167
+ hits++;
168
+ break; // one projectile hits at most one enemy per frame
169
+ }
170
+ });
171
+ console.log(` Projectile check done: ${hits} hit(s)`);
172
+ })
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // CleanupPass — delete all entities marked as Dead this frame
176
+ // ---------------------------------------------------------------------------
177
+ .addPass(() => {
178
+ console.log("[CleanupPass] Removing dead entities...");
179
+ for (const entity of newlyDead) {
180
+ world.delete(entity);
181
+ }
182
+ const count = newlyDead.size;
183
+ if (count > 0) {
184
+ console.log(` Cleaned up ${count} dead entity/ies`);
185
+ } else {
186
+ console.log(" No dead entities to clean up");
187
+ }
188
+ newlyDead.clear();
189
+ })
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // RenderPass — log counts of each entity type
193
+ // ---------------------------------------------------------------------------
194
+ .addPass(() => {
195
+ console.log("[RenderPass] Entity counts:");
196
+ const enemies = enemyQuery.getEntities().length;
197
+ const players = playerQuery.getEntities().length;
198
+ const projectiles = projectileQuery.getEntities().length;
199
+ console.log(` Enemies: ${enemies} | Players: ${players} | Projectiles: ${projectiles}`);
200
+ })
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // SyncPass — execute all buffered structural changes
204
+ // ---------------------------------------------------------------------------
205
+ .addPass(() => {
206
+ world.sync();
207
+ })
208
+ .build();
209
+
210
+ // =============================================================================
211
+ // Setup & Main
212
+ // =============================================================================
213
+
214
+ function main() {
215
+ console.log("ECS Spatial Grid Demo — Grid-based Proximity & Collision");
216
+ console.log("=========================================================\n");
217
+
218
+ // Create singleton SpatialGrid
219
+ world.set(SpatialGrid, { cells: new Map(), cellSize: 64 });
220
+ console.log("SpatialGrid singleton created (cellSize=64)");
221
+
222
+ // Create 1 player near the center
223
+ world.spawn().with(Position, { x: 128, y: 128 }).with(GridCell, { cellX: 0, cellY: 0 }).with(Player).build();
224
+ console.log("Player spawned at (128, 128)");
225
+
226
+ // Create ~5 enemies scattered across grid cells
227
+ const enemyPositions: [number, number][] = [
228
+ [64, 64],
229
+ [200, 60],
230
+ [50, 180],
231
+ [192, 192],
232
+ [256, 100],
233
+ ];
234
+ for (const [x, y] of enemyPositions) {
235
+ world
236
+ .spawn()
237
+ .with(Position, { x, y })
238
+ .with(GridCell, { cellX: 0, cellY: 0 })
239
+ .with(Velocity, { x: Math.random() * 20 - 10, y: Math.random() * 20 - 10 })
240
+ .with(Enemy)
241
+ .build();
242
+ console.log(`Enemy spawned at (${x}, ${y})`);
243
+ }
244
+
245
+ // Create ~3 projectiles with velocity
246
+ const projectileData: [number, number, number, number][] = [
247
+ [64, 70, 30, 0],
248
+ [128, 128, -40, 0],
249
+ [30, 180, 50, 10],
250
+ ];
251
+ for (const [x, y, vx, vy] of projectileData) {
252
+ world
253
+ .spawn()
254
+ .with(Position, { x, y })
255
+ .with(GridCell, { cellX: 0, cellY: 0 })
256
+ .with(Velocity, { x: vx, y: vy })
257
+ .with(Projectile)
258
+ .build();
259
+ console.log(`Projectile spawned at (${x}, ${y}) with velocity (${vx}, ${vy})`);
260
+ }
261
+
262
+ // Execute initial sync to materialize all entities
263
+ world.sync();
264
+ console.log("\nInitial sync complete. Starting simulation...\n");
265
+
266
+ // Run 4 frames
267
+ for (let frame = 1; frame <= 4; frame++) {
268
+ console.log(`--- Frame ${frame} ---`);
269
+ gameLoop({ deltaTime: 1.0 });
270
+ console.log();
271
+ }
272
+
273
+ console.log("Demo completed!");
274
+ }
275
+
276
+ main();