@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.
- package/examples/advanced-scheduling.ts +96 -0
- package/examples/collision-detection.ts +229 -0
- package/examples/inventory-system-relations.ts +108 -0
- package/examples/parent-child-hierarchy.ts +206 -0
- package/examples/serialization.ts +337 -0
- package/examples/simple.ts +96 -0
- package/examples/spatial-grid.ts +276 -0
- package/examples/state-machine.ts +273 -0
- package/examples/tag-filtering.ts +266 -0
- package/package.json +58 -12
- package/src/__tests__/commands/buffer-limits.test.ts +72 -0
- package/src/__tests__/commands/buffer.test.ts +195 -0
- package/src/__tests__/component/singleton.test.ts +148 -0
- package/src/__tests__/core/archetype.test.ts +247 -0
- package/src/__tests__/core/bitset.test.ts +171 -0
- package/src/__tests__/core/changeset.test.ts +254 -0
- package/src/__tests__/core/multi-map.test.ts +74 -0
- package/src/__tests__/entity/component-registry.test.ts +66 -0
- package/src/__tests__/entity/entity.test.ts +520 -0
- package/src/__tests__/entity/id-manager.test.ts +157 -0
- package/src/__tests__/entity/id-system.test.ts +260 -0
- package/src/__tests__/perf/comprehensive.perf.test.ts +300 -0
- package/src/__tests__/perf/sync-hotpath.perf.test.ts +79 -0
- package/src/__tests__/query/basic.test.ts +341 -0
- package/src/__tests__/query/caching.test.ts +112 -0
- package/src/__tests__/query/filter.test.ts +111 -0
- package/src/__tests__/query/optional.test.ts +231 -0
- package/src/__tests__/query/perf.test.ts +99 -0
- package/src/__tests__/relations/dont-fragment/basic.test.ts +496 -0
- package/src/__tests__/relations/dont-fragment/query-notification.test.ts +125 -0
- package/src/__tests__/relations/wildcard.test.ts +179 -0
- package/src/__tests__/serialization/bounds.test.ts +237 -0
- package/src/__tests__/testing/assertions.test.ts +224 -0
- package/src/__tests__/testing/entity-builder.test.ts +84 -0
- package/src/__tests__/testing/snapshot.test.ts +150 -0
- package/src/__tests__/testing/world-fixture.test.ts +73 -0
- package/src/__tests__/world/component-hooks.test.ts +185 -0
- package/src/__tests__/world/component-management.test.ts +447 -0
- package/src/__tests__/world/entity-management.test.ts +86 -0
- package/src/__tests__/world/get-optional.test.ts +96 -0
- package/src/__tests__/world/multi-component-hooks.test.ts +502 -0
- package/src/__tests__/world/perf.test.ts +93 -0
- package/src/__tests__/world/query.test.ts +223 -0
- package/src/__tests__/world/serialize.test.ts +83 -0
- package/src/__tests__/world/wildcard-relation-hooks.test.ts +332 -0
- package/src/archetype/archetype.ts +472 -0
- package/src/archetype/helpers.ts +186 -0
- package/src/archetype/store.ts +33 -0
- package/src/commands/buffer.ts +110 -0
- package/src/commands/changeset.ts +104 -0
- package/src/component/entity-store.ts +223 -0
- package/src/component/registry.ts +657 -0
- package/src/component/type-utils.ts +9 -0
- package/src/entity/index.ts +63 -0
- package/src/entity/manager.ts +115 -0
- package/src/entity/relation.ts +319 -0
- package/src/entity/types.ts +135 -0
- package/src/index.ts +41 -0
- package/src/query/filter.ts +75 -0
- package/src/query/query.ts +313 -0
- package/src/query/registry.ts +101 -0
- package/src/storage/serialization.ts +130 -0
- package/src/testing/index.ts +634 -0
- package/src/types/index.ts +99 -0
- package/src/utils/bit-set.ts +133 -0
- package/src/utils/multi-map.ts +96 -0
- package/src/utils/utils.ts +19 -0
- package/src/world/builder.ts +100 -0
- package/src/world/commands.ts +378 -0
- package/src/world/hooks.ts +358 -0
- package/src/world/references.ts +38 -0
- package/src/world/serialization.ts +122 -0
- package/src/world/world.ts +1201 -0
- /package/{builder.d.mts → dist/builder.d.mts} +0 -0
- /package/{index.d.mts → dist/index.d.mts} +0 -0
- /package/{index.mjs → dist/index.mjs} +0 -0
- /package/{testing.d.mts → dist/testing.d.mts} +0 -0
- /package/{testing.mjs → dist/testing.mjs} +0 -0
- /package/{testing.mjs.map → dist/testing.mjs.map} +0 -0
- /package/{world.mjs → dist/world.mjs} +0 -0
- /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();
|