archetype-ecs 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -40
- package/bench/allocations-1m.js +1 -1
- package/bench/multi-ecs-bench.js +1 -1
- package/bench/typed-vs-bitecs-1m.js +1 -1
- package/bench/typed-vs-untyped.js +81 -81
- package/bench/vs-bitecs.js +31 -56
- package/dist/src/ComponentRegistry.d.ts +13 -0
- package/dist/src/ComponentRegistry.js +29 -0
- package/dist/src/EntityManager.d.ts +52 -0
- package/dist/src/EntityManager.js +853 -0
- package/dist/src/Profiler.d.ts +12 -0
- package/dist/src/Profiler.js +38 -0
- package/dist/src/System.d.ts +37 -0
- package/dist/src/System.js +139 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +29 -0
- package/dist/tests/EntityManager.test.d.ts +1 -0
- package/dist/tests/EntityManager.test.js +651 -0
- package/dist/tests/System.test.d.ts +1 -0
- package/dist/tests/System.test.js +630 -0
- package/dist/tests/types.d.ts +1 -0
- package/dist/tests/types.js +129 -0
- package/package.json +8 -7
- package/src/ComponentRegistry.ts +45 -0
- package/src/EntityManager.ts +986 -0
- package/src/{Profiler.js → Profiler.ts} +18 -5
- package/src/System.ts +201 -0
- package/src/index.ts +38 -0
- package/tests/{EntityManager.test.js → EntityManager.test.ts} +360 -57
- package/tests/System.test.ts +546 -0
- package/tests/types.ts +69 -68
- package/tsconfig.json +8 -5
- package/.claude/settings.local.json +0 -32
- package/src/ComponentRegistry.js +0 -21
- package/src/EntityManager.js +0 -462
- package/src/index.d.ts +0 -111
- package/src/index.js +0 -37
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createEntityManager, type EntityManager } from '../src/EntityManager.js';
|
|
4
|
+
import { createSystem, createSystems, System, OnAdded, OnRemoved } from '../src/System.js';
|
|
5
|
+
import type { EntityId } from '../src/EntityManager.js';
|
|
6
|
+
import { component } from '../src/index.js';
|
|
7
|
+
|
|
8
|
+
// ── Components (shared across all suites) ────────────────
|
|
9
|
+
|
|
10
|
+
const Position = component('Position', 'f32', ['x', 'y']);
|
|
11
|
+
const Velocity = component('Velocity', 'f32', ['vx', 'vy']);
|
|
12
|
+
const Health = component('Health', 'f32', ['hp']);
|
|
13
|
+
const Tag = component('Tag');
|
|
14
|
+
|
|
15
|
+
// ── Functional API ───────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
describe('createSystem (functional)', () => {
|
|
18
|
+
let em: EntityManager;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
em = createEntityManager();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('onAdded fires callback after flush + run', () => {
|
|
25
|
+
const collected: EntityId[] = [];
|
|
26
|
+
const sys = createSystem(em, (s) => {
|
|
27
|
+
s.onAdded(Health, (id: EntityId) => collected.push(id));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const e1 = em.createEntity();
|
|
31
|
+
em.addComponent(e1, Health, { hp: 100 });
|
|
32
|
+
em.flushHooks();
|
|
33
|
+
sys();
|
|
34
|
+
assert.deepEqual(collected, [e1]);
|
|
35
|
+
|
|
36
|
+
sys.dispose();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('onAdded with multiple types only fires when entity has ALL types', () => {
|
|
40
|
+
const collected: EntityId[] = [];
|
|
41
|
+
const sys = createSystem(em, (s) => {
|
|
42
|
+
s.onAdded(Health, Position, (id: EntityId) => collected.push(id));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const e1 = em.createEntity();
|
|
46
|
+
em.addComponent(e1, Health, { hp: 100 });
|
|
47
|
+
em.flushHooks();
|
|
48
|
+
sys();
|
|
49
|
+
assert.deepEqual(collected, []);
|
|
50
|
+
|
|
51
|
+
em.addComponent(e1, Position, { x: 0, y: 0 });
|
|
52
|
+
em.flushHooks();
|
|
53
|
+
sys();
|
|
54
|
+
assert.deepEqual(collected, [e1]);
|
|
55
|
+
|
|
56
|
+
sys.dispose();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('onAdded deduplicates when multiple types trigger for same entity', () => {
|
|
60
|
+
const collected: EntityId[] = [];
|
|
61
|
+
const sys = createSystem(em, (s) => {
|
|
62
|
+
s.onAdded(Health, Position, (id: EntityId) => collected.push(id));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const e1 = em.createEntityWith(Health, { hp: 50 }, Position, { x: 0, y: 0 });
|
|
66
|
+
em.flushHooks();
|
|
67
|
+
sys();
|
|
68
|
+
assert.deepEqual(collected, [e1]);
|
|
69
|
+
|
|
70
|
+
sys.dispose();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('onRemoved fires callback after flush + run', () => {
|
|
74
|
+
const collected: EntityId[] = [];
|
|
75
|
+
const sys = createSystem(em, (s) => {
|
|
76
|
+
s.onRemoved(Health, (id: EntityId) => collected.push(id));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const e1 = em.createEntity();
|
|
80
|
+
em.addComponent(e1, Health, { hp: 50 });
|
|
81
|
+
em.flushHooks();
|
|
82
|
+
sys();
|
|
83
|
+
|
|
84
|
+
em.removeComponent(e1, Health);
|
|
85
|
+
em.flushHooks();
|
|
86
|
+
sys();
|
|
87
|
+
assert.deepEqual(collected, [e1]);
|
|
88
|
+
|
|
89
|
+
sys.dispose();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('onRemoved with multiple types deduplicates', () => {
|
|
93
|
+
const collected: EntityId[] = [];
|
|
94
|
+
const sys = createSystem(em, (s) => {
|
|
95
|
+
s.onRemoved(Health, Position, (id: EntityId) => collected.push(id));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const e1 = em.createEntityWith(Health, { hp: 50 }, Position, { x: 0, y: 0 });
|
|
99
|
+
em.flushHooks();
|
|
100
|
+
sys();
|
|
101
|
+
|
|
102
|
+
em.removeComponent(e1, Health);
|
|
103
|
+
em.removeComponent(e1, Position);
|
|
104
|
+
em.flushHooks();
|
|
105
|
+
sys();
|
|
106
|
+
assert.deepEqual(collected, [e1]);
|
|
107
|
+
|
|
108
|
+
sys.dispose();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('buffers cleared after sys() — no double processing', () => {
|
|
112
|
+
const collected: EntityId[] = [];
|
|
113
|
+
const sys = createSystem(em, (s) => {
|
|
114
|
+
s.onAdded(Health, (id: EntityId) => collected.push(id));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const e1 = em.createEntity();
|
|
118
|
+
em.addComponent(e1, Health, { hp: 100 });
|
|
119
|
+
em.flushHooks();
|
|
120
|
+
sys();
|
|
121
|
+
assert.deepEqual(collected, [e1]);
|
|
122
|
+
|
|
123
|
+
collected.length = 0;
|
|
124
|
+
sys();
|
|
125
|
+
assert.deepEqual(collected, []);
|
|
126
|
+
|
|
127
|
+
sys.dispose();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('dispose() unsubscribes hooks', () => {
|
|
131
|
+
const collected: EntityId[] = [];
|
|
132
|
+
const sys = createSystem(em, (s) => {
|
|
133
|
+
s.onAdded(Health, (id: EntityId) => collected.push(id));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
sys.dispose();
|
|
137
|
+
|
|
138
|
+
const e1 = em.createEntity();
|
|
139
|
+
em.addComponent(e1, Health, { hp: 100 });
|
|
140
|
+
em.flushHooks();
|
|
141
|
+
sys();
|
|
142
|
+
assert.deepEqual(collected, []);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('forEach wraps em.forEach correctly (include + exclude)', () => {
|
|
146
|
+
const e1 = em.createEntity();
|
|
147
|
+
em.addComponent(e1, Position, { x: 1, y: 2 });
|
|
148
|
+
em.addComponent(e1, Velocity, { vx: 3, vy: 4 });
|
|
149
|
+
|
|
150
|
+
const e2 = em.createEntity();
|
|
151
|
+
em.addComponent(e2, Position, { x: 5, y: 6 });
|
|
152
|
+
em.addComponent(e2, Tag);
|
|
153
|
+
|
|
154
|
+
let count = 0;
|
|
155
|
+
const sys = createSystem(em, (s) => {
|
|
156
|
+
return () => {
|
|
157
|
+
s.forEach([Position], (view) => { count += view.count; }, [Tag]);
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
sys();
|
|
162
|
+
assert.equal(count, 1);
|
|
163
|
+
sys.dispose();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('hooks-only system (no tick) works', () => {
|
|
167
|
+
const added: EntityId[] = [];
|
|
168
|
+
const sys = createSystem(em, (s) => {
|
|
169
|
+
s.onAdded(Tag, (id: EntityId) => added.push(id));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const e1 = em.createEntity();
|
|
173
|
+
em.addComponent(e1, Tag);
|
|
174
|
+
em.flushHooks();
|
|
175
|
+
sys();
|
|
176
|
+
assert.deepEqual(added, [e1]);
|
|
177
|
+
sys.dispose();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('query-only system (no hooks) works', () => {
|
|
181
|
+
const e1 = em.createEntity();
|
|
182
|
+
em.addComponent(e1, Position, { x: 10, y: 20 });
|
|
183
|
+
em.addComponent(e1, Velocity, { vx: 1, vy: 2 });
|
|
184
|
+
|
|
185
|
+
let totalCount = 0;
|
|
186
|
+
const sys = createSystem(em, (s) => {
|
|
187
|
+
return () => {
|
|
188
|
+
s.forEach([Position, Velocity], (view) => { totalCount += view.count; });
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
sys();
|
|
193
|
+
assert.equal(totalCount, 1);
|
|
194
|
+
sys.dispose();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ── Decorator-based class API ────────────────────────────
|
|
199
|
+
|
|
200
|
+
describe('System (class + decorators)', () => {
|
|
201
|
+
let em: EntityManager;
|
|
202
|
+
|
|
203
|
+
beforeEach(() => {
|
|
204
|
+
em = createEntityManager();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('@OnAdded fires decorated method after flush + run', () => {
|
|
208
|
+
const collected: EntityId[] = [];
|
|
209
|
+
|
|
210
|
+
class TestSys extends System {
|
|
211
|
+
@OnAdded(Health)
|
|
212
|
+
handleAdd(id: EntityId) { collected.push(id); }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const sys = new TestSys(em);
|
|
216
|
+
const e1 = em.createEntity();
|
|
217
|
+
em.addComponent(e1, Health, { hp: 100 });
|
|
218
|
+
em.flushHooks();
|
|
219
|
+
sys.run();
|
|
220
|
+
assert.deepEqual(collected, [e1]);
|
|
221
|
+
|
|
222
|
+
sys.dispose();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('@OnAdded with multiple types only fires when entity has ALL types', () => {
|
|
226
|
+
const collected: EntityId[] = [];
|
|
227
|
+
|
|
228
|
+
class TestSys extends System {
|
|
229
|
+
@OnAdded(Health, Position)
|
|
230
|
+
handleAdd(id: EntityId) { collected.push(id); }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const sys = new TestSys(em);
|
|
234
|
+
|
|
235
|
+
// Only Health — should NOT trigger
|
|
236
|
+
const e1 = em.createEntity();
|
|
237
|
+
em.addComponent(e1, Health, { hp: 100 });
|
|
238
|
+
em.flushHooks();
|
|
239
|
+
sys.run();
|
|
240
|
+
assert.deepEqual(collected, []);
|
|
241
|
+
|
|
242
|
+
// Add Position — now matches
|
|
243
|
+
em.addComponent(e1, Position, { x: 0, y: 0 });
|
|
244
|
+
em.flushHooks();
|
|
245
|
+
sys.run();
|
|
246
|
+
assert.deepEqual(collected, [e1]);
|
|
247
|
+
|
|
248
|
+
sys.dispose();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('@OnAdded deduplicates with createEntityWith', () => {
|
|
252
|
+
const collected: EntityId[] = [];
|
|
253
|
+
|
|
254
|
+
class TestSys extends System {
|
|
255
|
+
@OnAdded(Health, Position)
|
|
256
|
+
handleAdd(id: EntityId) { collected.push(id); }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const sys = new TestSys(em);
|
|
260
|
+
const e1 = em.createEntityWith(Health, { hp: 50 }, Position, { x: 0, y: 0 });
|
|
261
|
+
em.flushHooks();
|
|
262
|
+
sys.run();
|
|
263
|
+
assert.deepEqual(collected, [e1]);
|
|
264
|
+
|
|
265
|
+
sys.dispose();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('@OnRemoved fires decorated method after flush + run', () => {
|
|
269
|
+
const collected: EntityId[] = [];
|
|
270
|
+
|
|
271
|
+
class TestSys extends System {
|
|
272
|
+
@OnRemoved(Health)
|
|
273
|
+
handleRemove(id: EntityId) { collected.push(id); }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const sys = new TestSys(em);
|
|
277
|
+
const e1 = em.createEntity();
|
|
278
|
+
em.addComponent(e1, Health, { hp: 50 });
|
|
279
|
+
em.flushHooks();
|
|
280
|
+
sys.run();
|
|
281
|
+
|
|
282
|
+
em.removeComponent(e1, Health);
|
|
283
|
+
em.flushHooks();
|
|
284
|
+
sys.run();
|
|
285
|
+
assert.deepEqual(collected, [e1]);
|
|
286
|
+
|
|
287
|
+
sys.dispose();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('@OnRemoved fires on destroyEntity for all component types', () => {
|
|
291
|
+
const removedHealth: EntityId[] = [];
|
|
292
|
+
const removedPos: EntityId[] = [];
|
|
293
|
+
|
|
294
|
+
class TestSys extends System {
|
|
295
|
+
@OnRemoved(Health)
|
|
296
|
+
handleHealth(id: EntityId) { removedHealth.push(id); }
|
|
297
|
+
|
|
298
|
+
@OnRemoved(Position)
|
|
299
|
+
handlePos(id: EntityId) { removedPos.push(id); }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const sys = new TestSys(em);
|
|
303
|
+
const e1 = em.createEntityWith(Health, { hp: 100 }, Position, { x: 0, y: 0 });
|
|
304
|
+
em.flushHooks();
|
|
305
|
+
sys.run();
|
|
306
|
+
|
|
307
|
+
em.destroyEntity(e1);
|
|
308
|
+
em.flushHooks();
|
|
309
|
+
sys.run();
|
|
310
|
+
assert.deepEqual(removedHealth, [e1]);
|
|
311
|
+
assert.deepEqual(removedPos, [e1]);
|
|
312
|
+
|
|
313
|
+
sys.dispose();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('buffers cleared after run() — no double processing', () => {
|
|
317
|
+
const collected: EntityId[] = [];
|
|
318
|
+
|
|
319
|
+
class TestSys extends System {
|
|
320
|
+
@OnAdded(Health)
|
|
321
|
+
handleAdd(id: EntityId) { collected.push(id); }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const sys = new TestSys(em);
|
|
325
|
+
const e1 = em.createEntity();
|
|
326
|
+
em.addComponent(e1, Health, { hp: 100 });
|
|
327
|
+
em.flushHooks();
|
|
328
|
+
sys.run();
|
|
329
|
+
assert.deepEqual(collected, [e1]);
|
|
330
|
+
|
|
331
|
+
collected.length = 0;
|
|
332
|
+
sys.run();
|
|
333
|
+
assert.deepEqual(collected, []);
|
|
334
|
+
|
|
335
|
+
sys.dispose();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('dispose() unsubscribes hooks', () => {
|
|
339
|
+
const collected: EntityId[] = [];
|
|
340
|
+
|
|
341
|
+
class TestSys extends System {
|
|
342
|
+
@OnAdded(Health)
|
|
343
|
+
handleAdd(id: EntityId) { collected.push(id); }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const sys = new TestSys(em);
|
|
347
|
+
sys.dispose();
|
|
348
|
+
|
|
349
|
+
const e1 = em.createEntity();
|
|
350
|
+
em.addComponent(e1, Health, { hp: 100 });
|
|
351
|
+
em.flushHooks();
|
|
352
|
+
sys.run();
|
|
353
|
+
assert.deepEqual(collected, []);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('tick() is called after hook callbacks', () => {
|
|
357
|
+
const order: string[] = [];
|
|
358
|
+
|
|
359
|
+
class TestSys extends System {
|
|
360
|
+
@OnAdded(Health)
|
|
361
|
+
handleAdd(_id: EntityId) { order.push('hook'); }
|
|
362
|
+
|
|
363
|
+
tick() { order.push('tick'); }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const sys = new TestSys(em);
|
|
367
|
+
const e1 = em.createEntity();
|
|
368
|
+
em.addComponent(e1, Health, { hp: 100 });
|
|
369
|
+
em.flushHooks();
|
|
370
|
+
sys.run();
|
|
371
|
+
assert.deepEqual(order, ['hook', 'tick']);
|
|
372
|
+
|
|
373
|
+
sys.dispose();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('forEach available on System instance', () => {
|
|
377
|
+
const e1 = em.createEntity();
|
|
378
|
+
em.addComponent(e1, Position, { x: 1, y: 2 });
|
|
379
|
+
em.addComponent(e1, Velocity, { vx: 3, vy: 4 });
|
|
380
|
+
|
|
381
|
+
let count = 0;
|
|
382
|
+
|
|
383
|
+
class TestSys extends System {
|
|
384
|
+
tick() {
|
|
385
|
+
this.forEach([Position, Velocity], (view) => { count += view.count; });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const sys = new TestSys(em);
|
|
390
|
+
sys.run();
|
|
391
|
+
assert.equal(count, 1);
|
|
392
|
+
sys.dispose();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('multiple decorated methods on same class', () => {
|
|
396
|
+
const added: EntityId[] = [];
|
|
397
|
+
const removed: EntityId[] = [];
|
|
398
|
+
|
|
399
|
+
class TestSys extends System {
|
|
400
|
+
@OnAdded(Health)
|
|
401
|
+
handleAdd(id: EntityId) { added.push(id); }
|
|
402
|
+
|
|
403
|
+
@OnRemoved(Health)
|
|
404
|
+
handleRemove(id: EntityId) { removed.push(id); }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const sys = new TestSys(em);
|
|
408
|
+
const e1 = em.createEntity();
|
|
409
|
+
em.addComponent(e1, Health, { hp: 100 });
|
|
410
|
+
em.flushHooks();
|
|
411
|
+
sys.run();
|
|
412
|
+
assert.deepEqual(added, [e1]);
|
|
413
|
+
assert.deepEqual(removed, []);
|
|
414
|
+
|
|
415
|
+
em.removeComponent(e1, Health);
|
|
416
|
+
em.flushHooks();
|
|
417
|
+
sys.run();
|
|
418
|
+
assert.deepEqual(removed, [e1]);
|
|
419
|
+
|
|
420
|
+
sys.dispose();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('multiple instances have independent buffers', () => {
|
|
424
|
+
const collectedA: EntityId[] = [];
|
|
425
|
+
const collectedB: EntityId[] = [];
|
|
426
|
+
|
|
427
|
+
class TestSys extends System {
|
|
428
|
+
out: EntityId[];
|
|
429
|
+
constructor(em: EntityManager, out: EntityId[]) {
|
|
430
|
+
super(em);
|
|
431
|
+
this.out = out;
|
|
432
|
+
}
|
|
433
|
+
@OnAdded(Health)
|
|
434
|
+
handleAdd(id: EntityId) { this.out.push(id); }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const sysA = new TestSys(em, collectedA);
|
|
438
|
+
const sysB = new TestSys(em, collectedB);
|
|
439
|
+
|
|
440
|
+
const e1 = em.createEntity();
|
|
441
|
+
em.addComponent(e1, Health, { hp: 100 });
|
|
442
|
+
em.flushHooks();
|
|
443
|
+
|
|
444
|
+
sysA.run();
|
|
445
|
+
sysB.run();
|
|
446
|
+
|
|
447
|
+
assert.deepEqual(collectedA, [e1]);
|
|
448
|
+
assert.deepEqual(collectedB, [e1]);
|
|
449
|
+
|
|
450
|
+
sysA.dispose();
|
|
451
|
+
sysB.dispose();
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// ── createSystems (activator) ────────────────────────────
|
|
456
|
+
|
|
457
|
+
describe('createSystems', () => {
|
|
458
|
+
let em: EntityManager;
|
|
459
|
+
|
|
460
|
+
beforeEach(() => {
|
|
461
|
+
em = createEntityManager();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('runs functional systems in order', () => {
|
|
465
|
+
const order: string[] = [];
|
|
466
|
+
function SysA() { return () => { order.push('A'); }; }
|
|
467
|
+
function SysB() { return () => { order.push('B'); }; }
|
|
468
|
+
|
|
469
|
+
const pipeline = createSystems(em, [SysA, SysB]);
|
|
470
|
+
pipeline();
|
|
471
|
+
assert.deepEqual(order, ['A', 'B']);
|
|
472
|
+
pipeline.dispose();
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('runs class-based systems in order', () => {
|
|
476
|
+
const order: string[] = [];
|
|
477
|
+
|
|
478
|
+
class SysA extends System { tick() { order.push('A'); } }
|
|
479
|
+
class SysB extends System { tick() { order.push('B'); } }
|
|
480
|
+
|
|
481
|
+
const pipeline = createSystems(em, [SysA, SysB]);
|
|
482
|
+
pipeline();
|
|
483
|
+
assert.deepEqual(order, ['A', 'B']);
|
|
484
|
+
pipeline.dispose();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('mixes functional and class-based systems', () => {
|
|
488
|
+
const order: string[] = [];
|
|
489
|
+
|
|
490
|
+
function FuncSys() { return () => { order.push('func'); }; }
|
|
491
|
+
class ClassSys extends System { tick() { order.push('class'); } }
|
|
492
|
+
|
|
493
|
+
const pipeline = createSystems(em, [FuncSys, ClassSys]);
|
|
494
|
+
pipeline();
|
|
495
|
+
assert.deepEqual(order, ['func', 'class']);
|
|
496
|
+
pipeline.dispose();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('dispose() disposes all systems', () => {
|
|
500
|
+
const collected: EntityId[] = [];
|
|
501
|
+
|
|
502
|
+
class TestSys extends System {
|
|
503
|
+
@OnAdded(Health)
|
|
504
|
+
handleAdd(id: EntityId) { collected.push(id); }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const pipeline = createSystems(em, [TestSys]);
|
|
508
|
+
pipeline.dispose();
|
|
509
|
+
|
|
510
|
+
const e1 = em.createEntity();
|
|
511
|
+
em.addComponent(e1, Health, { hp: 100 });
|
|
512
|
+
em.flushHooks();
|
|
513
|
+
pipeline();
|
|
514
|
+
assert.deepEqual(collected, []);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('class hooks fire through pipeline', () => {
|
|
518
|
+
const added: EntityId[] = [];
|
|
519
|
+
let moved = 0;
|
|
520
|
+
|
|
521
|
+
class HookSys extends System {
|
|
522
|
+
@OnAdded(Health)
|
|
523
|
+
handleAdd(id: EntityId) { added.push(id); }
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
class TickSys extends System {
|
|
527
|
+
tick() {
|
|
528
|
+
this.forEach([Position, Velocity], (view) => { moved += view.count; });
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const pipeline = createSystems(em, [HookSys, TickSys]);
|
|
533
|
+
|
|
534
|
+
const e1 = em.createEntityWith(
|
|
535
|
+
Health, { hp: 100 },
|
|
536
|
+
Position, { x: 0, y: 0 },
|
|
537
|
+
Velocity, { vx: 1, vy: 1 }
|
|
538
|
+
);
|
|
539
|
+
em.flushHooks();
|
|
540
|
+
pipeline();
|
|
541
|
+
|
|
542
|
+
assert.deepEqual(added, [e1]);
|
|
543
|
+
assert.equal(moved, 1);
|
|
544
|
+
pipeline.dispose();
|
|
545
|
+
});
|
|
546
|
+
});
|
package/tests/types.ts
CHANGED
|
@@ -1,101 +1,102 @@
|
|
|
1
1
|
// Compile-time type tests — run with: npx tsc --noEmit
|
|
2
|
-
// These tests validate TS
|
|
2
|
+
// These tests validate TS types flow correctly through the API.
|
|
3
3
|
// No runtime execution needed; if this file compiles, the types are correct.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
createEntityManager, createSystem, createSystems, component,
|
|
7
|
+
System, OnAdded, OnRemoved,
|
|
8
|
+
type ComponentDef, type FieldRef, type EntityId,
|
|
9
|
+
type SystemContext, type FunctionalSystemConstructor, type FunctionalSystem, type Pipeline
|
|
10
|
+
} from '../src/index.js';
|
|
6
11
|
|
|
7
|
-
// --- component()
|
|
12
|
+
// --- component() creates ComponentDef ---
|
|
8
13
|
const Position = component('Position', 'f32', ['x', 'y']);
|
|
9
14
|
const Velocity = component('Velocity', { vx: 'f32', vy: 'f32' });
|
|
10
15
|
const Tag = component('Tag');
|
|
11
16
|
const Name = component('Name', { name: 'string', title: 'string' });
|
|
12
17
|
const Label = component('Label', 'string', ['text', 'color']);
|
|
13
18
|
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
// Position.x should be a FieldRef<number>
|
|
19
|
-
type AssertFieldRef = typeof Position.x extends FieldRef<number> ? true : never;
|
|
20
|
-
const _assertField: AssertFieldRef = true;
|
|
21
|
-
|
|
22
|
-
// Name should be ComponentDef<{ name: string, title: string }>
|
|
23
|
-
type AssertName = typeof Name extends ComponentDef<{ name: string; title: string }> ? true : never;
|
|
24
|
-
const _assertName: AssertName = true;
|
|
25
|
-
|
|
26
|
-
// Name.name should be a FieldRef<string>
|
|
27
|
-
type AssertNameField = typeof Name.name extends FieldRef<string> ? true : never;
|
|
28
|
-
const _assertNameField: AssertNameField = true;
|
|
29
|
-
|
|
30
|
-
// Label short form: ComponentDef<{ text: string, color: string }>
|
|
31
|
-
type AssertLabel = typeof Label extends ComponentDef<{ text: string; color: string }> ? true : never;
|
|
32
|
-
const _assertLabel: AssertLabel = true;
|
|
33
|
-
|
|
34
|
-
// Tag should be ComponentDef<unknown> (no schema)
|
|
35
|
-
type AssertTag = typeof Tag extends ComponentDef ? true : never;
|
|
36
|
-
const _assertTag: AssertTag = true;
|
|
19
|
+
// component() returns ComponentDef
|
|
20
|
+
const _assertComp: ComponentDef = Position;
|
|
21
|
+
const _assertTag: ComponentDef = Tag;
|
|
37
22
|
|
|
38
23
|
// --- EntityManager typed methods ---
|
|
39
24
|
const em = createEntityManager();
|
|
40
25
|
const id: EntityId = em.createEntity();
|
|
41
26
|
|
|
42
|
-
// addComponent: accepts
|
|
27
|
+
// addComponent: accepts data
|
|
43
28
|
em.addComponent(id, Position, { x: 1, y: 2 });
|
|
44
29
|
em.addComponent(id, Velocity, { vx: 0, vy: 0 });
|
|
45
30
|
em.addComponent(id, Name, { name: 'Hero', title: 'Sir' });
|
|
46
31
|
|
|
47
|
-
// getComponent: returns
|
|
32
|
+
// getComponent: returns data or undefined
|
|
48
33
|
const pos = em.getComponent(id, Position);
|
|
49
|
-
if (pos) {
|
|
50
|
-
const x: number = pos.x;
|
|
51
|
-
const y: number = pos.y;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const nameComp = em.getComponent(id, Name);
|
|
55
|
-
if (nameComp) {
|
|
56
|
-
const n: string = nameComp.name;
|
|
57
|
-
const t: string = nameComp.title;
|
|
58
|
-
}
|
|
59
34
|
|
|
60
|
-
// get/set:
|
|
61
|
-
const px:
|
|
62
|
-
|
|
63
|
-
em.set(id, Position.x, 10);
|
|
64
|
-
em.set(id, Position.y, 20);
|
|
65
|
-
|
|
66
|
-
// get/set: string field descriptor access
|
|
67
|
-
const nameVal: string | undefined = em.get(id, Name.name);
|
|
68
|
-
em.set(id, Name.name, 'Villain');
|
|
35
|
+
// get/set: field descriptor access
|
|
36
|
+
const px: any = em.get(id, Position.x as any);
|
|
37
|
+
em.set(id, Position.x as any, 10);
|
|
69
38
|
|
|
70
39
|
// forEach + field: accepts FieldRef
|
|
71
40
|
em.forEach([Position, Velocity], (arch) => {
|
|
72
41
|
const count: number = arch.count;
|
|
73
42
|
const ids: EntityId[] = arch.entityIds;
|
|
74
|
-
const arrX = arch.field(Position.x);
|
|
75
|
-
const arrVx = arch.field(Velocity.vx);
|
|
43
|
+
const arrX = arch.field(Position.x as any);
|
|
76
44
|
});
|
|
77
45
|
|
|
78
46
|
// createEntityWith: alternating type, data
|
|
79
47
|
em.createEntityWith(Position, { x: 0, y: 0 }, Velocity, { vx: 1, vy: 1 });
|
|
80
48
|
|
|
81
|
-
// ---
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
49
|
+
// --- createSystem returns FunctionalSystem ---
|
|
50
|
+
const sys: FunctionalSystem = createSystem(em, (s: SystemContext) => {
|
|
51
|
+
// onAdded with 1 type
|
|
52
|
+
s.onAdded(Position, (entityId: EntityId) => {});
|
|
53
|
+
// onAdded with 2 types
|
|
54
|
+
s.onAdded(Position, Velocity, (entityId: EntityId) => {});
|
|
55
|
+
// onRemoved with 1 type
|
|
56
|
+
s.onRemoved(Position, (entityId: EntityId) => {});
|
|
57
|
+
// onRemoved with 2 types
|
|
58
|
+
s.onRemoved(Position, Velocity, (entityId: EntityId) => {});
|
|
59
|
+
|
|
60
|
+
return () => {
|
|
61
|
+
s.forEach([Position, Velocity], (view) => {
|
|
62
|
+
const count: number = view.count;
|
|
63
|
+
});
|
|
64
|
+
s.forEach([Position], (_view) => {}, [Velocity]);
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
sys();
|
|
68
|
+
sys.dispose();
|
|
69
|
+
|
|
70
|
+
// --- FunctionalSystemConstructor type ---
|
|
71
|
+
const ctor: FunctionalSystemConstructor = (s) => {
|
|
72
|
+
s.onAdded(Position, (_id) => {});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// --- createSystems returns Pipeline ---
|
|
76
|
+
const pipeline: Pipeline = createSystems(em, [ctor]);
|
|
77
|
+
pipeline();
|
|
78
|
+
pipeline.dispose();
|
|
79
|
+
|
|
80
|
+
// --- Class-based System ---
|
|
81
|
+
class TestSystem extends System {
|
|
82
|
+
@OnAdded(Position)
|
|
83
|
+
handleAdd(id: EntityId) {}
|
|
84
|
+
|
|
85
|
+
@OnRemoved(Position)
|
|
86
|
+
handleRemove(id: EntityId) {}
|
|
87
|
+
|
|
88
|
+
tick() {
|
|
89
|
+
this.forEach([Position], (view) => {
|
|
90
|
+
const count: number = view.count;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
96
94
|
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
const testSys = new TestSystem(em);
|
|
96
|
+
testSys.run();
|
|
97
|
+
testSys.dispose();
|
|
99
98
|
|
|
100
|
-
//
|
|
101
|
-
em
|
|
99
|
+
// --- createSystems accepts mixed entries ---
|
|
100
|
+
const mixedPipeline: Pipeline = createSystems(em, [ctor, TestSystem]);
|
|
101
|
+
mixedPipeline();
|
|
102
|
+
mixedPipeline.dispose();
|