archetype-ecs 1.0.0 → 1.2.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/.claude/settings.local.json +32 -0
- package/README.md +359 -0
- package/bench/allocations-1m.js +161 -0
- package/bench/multi-ecs-bench.js +393 -0
- package/bench/typed-array-vs-objects.js +268 -0
- package/bench/typed-vs-bitecs-1m.js +86 -0
- package/bench/typed-vs-untyped.js +166 -0
- package/bench/vs-bitecs.js +267 -0
- package/package.json +20 -6
- package/src/ComponentRegistry.js +21 -0
- package/src/EntityManager.js +194 -94
- package/src/index.d.ts +111 -0
- package/src/index.js +35 -3
- package/tests/EntityManager.test.js +342 -77
- package/tests/types.ts +101 -0
- package/tsconfig.json +10 -0
- package/src/Container.js +0 -23
- package/src/EventBus.js +0 -29
- package/src/GameLoop.js +0 -110
- package/tests/Container.test.js +0 -38
- package/tests/EventBus.test.js +0 -41
|
@@ -1,89 +1,91 @@
|
|
|
1
|
-
import { describe,
|
|
1
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
2
3
|
import { createEntityManager } from '../src/EntityManager.js';
|
|
4
|
+
import { component } from '../src/index.js';
|
|
3
5
|
|
|
4
6
|
describe('EntityManager', () => {
|
|
5
7
|
let em;
|
|
6
|
-
const Position =
|
|
7
|
-
const Velocity =
|
|
8
|
-
const Health =
|
|
8
|
+
const Position = component('Position', 'f32', ['x', 'y']);
|
|
9
|
+
const Velocity = component('Velocity', 'f32', ['vx', 'vy']);
|
|
10
|
+
const Health = component('Health', 'f32', ['hp']);
|
|
9
11
|
|
|
10
12
|
beforeEach(() => {
|
|
11
13
|
em = createEntityManager();
|
|
12
14
|
});
|
|
13
15
|
|
|
14
16
|
describe('createEntity / destroyEntity', () => {
|
|
15
|
-
|
|
17
|
+
it('creates entities with incrementing ids', () => {
|
|
16
18
|
const a = em.createEntity();
|
|
17
19
|
const b = em.createEntity();
|
|
18
|
-
|
|
20
|
+
assert.equal(b, a + 1);
|
|
19
21
|
});
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
it('destroyEntity removes entity', () => {
|
|
22
24
|
const id = em.createEntity();
|
|
23
25
|
em.addComponent(id, Position, { x: 0, y: 0 });
|
|
24
26
|
em.destroyEntity(id);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
assert.deepEqual(em.getAllEntities(), []);
|
|
28
|
+
assert.equal(em.getComponent(id, Position), undefined);
|
|
27
29
|
});
|
|
28
30
|
});
|
|
29
31
|
|
|
30
32
|
describe('addComponent / getComponent / hasComponent', () => {
|
|
31
|
-
|
|
33
|
+
it('adds and retrieves a component', () => {
|
|
32
34
|
const id = em.createEntity();
|
|
33
35
|
em.addComponent(id, Position, { x: 1, y: 2 });
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
assert.deepEqual(em.getComponent(id, Position), { x: 1, y: 2 });
|
|
37
|
+
assert.equal(em.hasComponent(id, Position), true);
|
|
36
38
|
});
|
|
37
39
|
|
|
38
|
-
|
|
40
|
+
it('returns undefined for missing component', () => {
|
|
39
41
|
const id = em.createEntity();
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
assert.equal(em.getComponent(id, Position), undefined);
|
|
43
|
+
assert.equal(em.hasComponent(id, Position), false);
|
|
42
44
|
});
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
it('overwrites component data on duplicate add', () => {
|
|
45
47
|
const id = em.createEntity();
|
|
46
48
|
em.addComponent(id, Position, { x: 1, y: 2 });
|
|
47
49
|
em.addComponent(id, Position, { x: 10, y: 20 });
|
|
48
|
-
|
|
50
|
+
assert.deepEqual(em.getComponent(id, Position), { x: 10, y: 20 });
|
|
49
51
|
});
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
it('adds multiple component types', () => {
|
|
52
54
|
const id = em.createEntity();
|
|
53
55
|
em.addComponent(id, Position, { x: 0, y: 0 });
|
|
54
56
|
em.addComponent(id, Velocity, { vx: 1, vy: 1 });
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
assert.deepEqual(em.getComponent(id, Position), { x: 0, y: 0 });
|
|
58
|
+
assert.deepEqual(em.getComponent(id, Velocity), { vx: 1, vy: 1 });
|
|
57
59
|
});
|
|
58
60
|
});
|
|
59
61
|
|
|
60
62
|
describe('removeComponent', () => {
|
|
61
|
-
|
|
63
|
+
it('removes a component', () => {
|
|
62
64
|
const id = em.createEntity();
|
|
63
65
|
em.addComponent(id, Position, { x: 1, y: 2 });
|
|
64
66
|
em.addComponent(id, Velocity, { vx: 1, vy: 1 });
|
|
65
67
|
em.removeComponent(id, Position);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
assert.equal(em.hasComponent(id, Position), false);
|
|
69
|
+
assert.equal(em.hasComponent(id, Velocity), true);
|
|
68
70
|
});
|
|
69
71
|
|
|
70
|
-
|
|
72
|
+
it('removing last component leaves entity alive but without archetype', () => {
|
|
71
73
|
const id = em.createEntity();
|
|
72
74
|
em.addComponent(id, Position, { x: 1, y: 2 });
|
|
73
75
|
em.removeComponent(id, Position);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
assert.ok(em.getAllEntities().includes(id));
|
|
77
|
+
assert.equal(em.hasComponent(id, Position), false);
|
|
76
78
|
});
|
|
77
79
|
|
|
78
|
-
|
|
80
|
+
it('removing non-existent component is a no-op', () => {
|
|
79
81
|
const id = em.createEntity();
|
|
80
|
-
em.removeComponent(id, Position);
|
|
81
|
-
|
|
82
|
+
em.removeComponent(id, Position);
|
|
83
|
+
assert.ok(em.getAllEntities().includes(id));
|
|
82
84
|
});
|
|
83
85
|
});
|
|
84
86
|
|
|
85
87
|
describe('query', () => {
|
|
86
|
-
|
|
88
|
+
it('returns entities matching component types', () => {
|
|
87
89
|
const a = em.createEntity();
|
|
88
90
|
em.addComponent(a, Position, { x: 0, y: 0 });
|
|
89
91
|
em.addComponent(a, Velocity, { vx: 1, vy: 1 });
|
|
@@ -92,11 +94,11 @@ describe('EntityManager', () => {
|
|
|
92
94
|
em.addComponent(b, Position, { x: 5, y: 5 });
|
|
93
95
|
|
|
94
96
|
const result = em.query([Position, Velocity]);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
assert.ok(result.includes(a));
|
|
98
|
+
assert.ok(!result.includes(b));
|
|
97
99
|
});
|
|
98
100
|
|
|
99
|
-
|
|
101
|
+
it('exclude types filters out entities', () => {
|
|
100
102
|
const a = em.createEntity();
|
|
101
103
|
em.addComponent(a, Position, { x: 0, y: 0 });
|
|
102
104
|
|
|
@@ -105,25 +107,21 @@ describe('EntityManager', () => {
|
|
|
105
107
|
em.addComponent(b, Health, { hp: 100 });
|
|
106
108
|
|
|
107
109
|
const result = em.query([Position], [Health]);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
assert.ok(result.includes(a));
|
|
111
|
+
assert.ok(!result.includes(b));
|
|
110
112
|
});
|
|
111
113
|
});
|
|
112
114
|
|
|
113
115
|
describe('createEntityWith', () => {
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const id = em.createEntityWith(map);
|
|
120
|
-
expect(em.getComponent(id, Position)).toEqual({ x: 3, y: 4 });
|
|
121
|
-
expect(em.getComponent(id, Velocity)).toEqual({ vx: 1, vy: 0 });
|
|
116
|
+
it('creates entity with multiple components at once', () => {
|
|
117
|
+
const id = em.createEntityWith(Position, { x: 3, y: 4 }, Velocity, { vx: 1, vy: 0 });
|
|
118
|
+
assert.deepEqual(em.getComponent(id, Position), { x: 3, y: 4 });
|
|
119
|
+
assert.deepEqual(em.getComponent(id, Velocity), { vx: 1, vy: 0 });
|
|
122
120
|
});
|
|
123
121
|
});
|
|
124
122
|
|
|
125
123
|
describe('count', () => {
|
|
126
|
-
|
|
124
|
+
it('counts entities matching query', () => {
|
|
127
125
|
const a = em.createEntity();
|
|
128
126
|
em.addComponent(a, Position, { x: 0, y: 0 });
|
|
129
127
|
|
|
@@ -131,22 +129,20 @@ describe('EntityManager', () => {
|
|
|
131
129
|
em.addComponent(b, Position, { x: 1, y: 1 });
|
|
132
130
|
em.addComponent(b, Velocity, { vx: 1, vy: 0 });
|
|
133
131
|
|
|
134
|
-
|
|
135
|
-
|
|
132
|
+
assert.equal(em.count([Position]), 2);
|
|
133
|
+
assert.equal(em.count([Position, Velocity]), 1);
|
|
136
134
|
});
|
|
137
135
|
});
|
|
138
136
|
|
|
139
137
|
describe('serialize / deserialize', () => {
|
|
140
138
|
const symbolToName = new Map([
|
|
141
|
-
[Position, 'Position'],
|
|
142
|
-
[Velocity, 'Velocity'],
|
|
143
|
-
[Health, 'Health']
|
|
139
|
+
[Position._sym, 'Position'],
|
|
140
|
+
[Velocity._sym, 'Velocity'],
|
|
141
|
+
[Health._sym, 'Health']
|
|
144
142
|
]);
|
|
145
|
-
const nameToSymbol = {
|
|
146
|
-
Position, Velocity, Health
|
|
147
|
-
};
|
|
143
|
+
const nameToSymbol = { Position, Velocity, Health };
|
|
148
144
|
|
|
149
|
-
|
|
145
|
+
it('round-trips entities and components', () => {
|
|
150
146
|
const a = em.createEntity();
|
|
151
147
|
em.addComponent(a, Position, { x: 1, y: 2 });
|
|
152
148
|
em.addComponent(a, Velocity, { vx: 3, vy: 4 });
|
|
@@ -157,23 +153,23 @@ describe('EntityManager', () => {
|
|
|
157
153
|
const data = em.serialize(symbolToName);
|
|
158
154
|
em.deserialize(data, nameToSymbol);
|
|
159
155
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
156
|
+
assert.deepEqual(em.getAllEntities().sort(), [a, b].sort());
|
|
157
|
+
assert.deepEqual(em.getComponent(a, Position), { x: 1, y: 2 });
|
|
158
|
+
assert.deepEqual(em.getComponent(a, Velocity), { vx: 3, vy: 4 });
|
|
159
|
+
assert.deepEqual(em.getComponent(b, Position), { x: 5, y: 6 });
|
|
164
160
|
});
|
|
165
161
|
|
|
166
|
-
|
|
162
|
+
it('strip components excludes component data but keeps entity', () => {
|
|
167
163
|
const a = em.createEntity();
|
|
168
164
|
em.addComponent(a, Position, { x: 1, y: 2 });
|
|
169
165
|
em.addComponent(a, Velocity, { vx: 3, vy: 4 });
|
|
170
166
|
|
|
171
167
|
const data = em.serialize(symbolToName, [Velocity]);
|
|
172
|
-
|
|
173
|
-
|
|
168
|
+
assert.equal(data.components['Velocity'], undefined);
|
|
169
|
+
assert.notEqual(data.components['Position'], undefined);
|
|
174
170
|
});
|
|
175
171
|
|
|
176
|
-
|
|
172
|
+
it('skip entities with component excludes entire entity', () => {
|
|
177
173
|
const a = em.createEntity();
|
|
178
174
|
em.addComponent(a, Position, { x: 1, y: 2 });
|
|
179
175
|
|
|
@@ -182,43 +178,312 @@ describe('EntityManager', () => {
|
|
|
182
178
|
em.addComponent(b, Health, { hp: 100 });
|
|
183
179
|
|
|
184
180
|
const data = em.serialize(symbolToName, [], [Health]);
|
|
185
|
-
|
|
186
|
-
|
|
181
|
+
assert.ok(data.entities.includes(a));
|
|
182
|
+
assert.ok(!data.entities.includes(b));
|
|
187
183
|
});
|
|
188
184
|
|
|
189
|
-
|
|
185
|
+
it('custom serializers are used when provided', () => {
|
|
186
|
+
const Meta = component('Meta', { x: 'f32', y: 'f32', secret: 'i32' });
|
|
187
|
+
const metaSymbolToName = new Map([...symbolToName, [Meta._sym, 'Meta']]);
|
|
188
|
+
|
|
190
189
|
const a = em.createEntity();
|
|
191
|
-
em.addComponent(a,
|
|
190
|
+
em.addComponent(a, Meta, { x: 1, y: 2, secret: 42 });
|
|
192
191
|
|
|
193
192
|
const serializers = new Map([
|
|
194
|
-
['
|
|
193
|
+
['Meta', (data) => ({ x: data.x, y: data.y })]
|
|
195
194
|
]);
|
|
196
195
|
|
|
197
|
-
const result = em.serialize(
|
|
198
|
-
|
|
199
|
-
|
|
196
|
+
const result = em.serialize(metaSymbolToName, [], [], { serializers });
|
|
197
|
+
assert.deepEqual(result.components['Meta'][a], { x: 1, y: 2 });
|
|
198
|
+
assert.equal(result.components['Meta'][a].secret, undefined);
|
|
200
199
|
});
|
|
201
200
|
|
|
202
|
-
|
|
201
|
+
it('custom deserializers are used when provided', () => {
|
|
202
|
+
const Meta = component('Meta2', { x: 'f32', y: 'f32' });
|
|
203
|
+
const metaSymbolToName = new Map([...symbolToName, [Meta._sym, 'Meta2']]);
|
|
204
|
+
const metaNameToSymbol = { ...nameToSymbol, Meta2: Meta };
|
|
205
|
+
|
|
203
206
|
const a = em.createEntity();
|
|
204
|
-
em.addComponent(a,
|
|
207
|
+
em.addComponent(a, Meta, { x: 1, y: 2 });
|
|
205
208
|
|
|
206
|
-
const data = em.serialize(
|
|
209
|
+
const data = em.serialize(metaSymbolToName);
|
|
207
210
|
|
|
208
211
|
const deserializers = new Map([
|
|
209
|
-
['
|
|
212
|
+
['Meta2', (compData) => ({ ...compData, restored: true })]
|
|
210
213
|
]);
|
|
211
214
|
|
|
212
|
-
em.deserialize(data,
|
|
213
|
-
|
|
215
|
+
em.deserialize(data, metaNameToSymbol, { deserializers });
|
|
216
|
+
const result = em.getComponent(a, Meta);
|
|
217
|
+
assert.ok(Math.abs(result.x - 1) < 0.01);
|
|
218
|
+
assert.ok(Math.abs(result.y - 2) < 0.01);
|
|
214
219
|
});
|
|
215
220
|
|
|
216
|
-
|
|
221
|
+
it('deserialize clears previous state', () => {
|
|
217
222
|
const a = em.createEntity();
|
|
218
223
|
em.addComponent(a, Position, { x: 1, y: 2 });
|
|
219
224
|
|
|
220
225
|
em.deserialize({ nextId: 1, entities: [], components: {} }, nameToSymbol);
|
|
221
|
-
|
|
226
|
+
assert.deepEqual(em.getAllEntities(), []);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('Typed Components (SoA)', () => {
|
|
232
|
+
let em;
|
|
233
|
+
|
|
234
|
+
beforeEach(() => {
|
|
235
|
+
em = createEntityManager();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('typed component round-trip (add/get)', () => {
|
|
239
|
+
const Pos = component('Pos', 'f32', ['x', 'y']);
|
|
240
|
+
const id = em.createEntity();
|
|
241
|
+
em.addComponent(id, Pos, { x: 1.5, y: 2.5 });
|
|
242
|
+
const result = em.getComponent(id, Pos);
|
|
243
|
+
assert.ok(Math.abs(result.x - 1.5) < 0.001);
|
|
244
|
+
assert.ok(Math.abs(result.y - 2.5) < 0.001);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('growth past initial capacity (>64 entities)', () => {
|
|
248
|
+
const Pos = component('PosGrow', 'f32', ['x', 'y']);
|
|
249
|
+
const ids = [];
|
|
250
|
+
for (let i = 0; i < 100; i++) {
|
|
251
|
+
const id = em.createEntity();
|
|
252
|
+
em.addComponent(id, Pos, { x: i, y: i * 2 });
|
|
253
|
+
ids.push(id);
|
|
254
|
+
}
|
|
255
|
+
for (let i = 0; i < 100; i++) {
|
|
256
|
+
const result = em.getComponent(ids[i], Pos);
|
|
257
|
+
assert.ok(Math.abs(result.x - i) < 0.001);
|
|
258
|
+
assert.ok(Math.abs(result.y - i * 2) < 0.001);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('swap-remove preserves typed data', () => {
|
|
263
|
+
const Pos = component('PosSwap', 'f32', ['x', 'y']);
|
|
264
|
+
const a = em.createEntity();
|
|
265
|
+
const b = em.createEntity();
|
|
266
|
+
const c = em.createEntity();
|
|
267
|
+
em.addComponent(a, Pos, { x: 1, y: 2 });
|
|
268
|
+
em.addComponent(b, Pos, { x: 3, y: 4 });
|
|
269
|
+
em.addComponent(c, Pos, { x: 5, y: 6 });
|
|
270
|
+
|
|
271
|
+
em.destroyEntity(a);
|
|
272
|
+
|
|
273
|
+
const resultB = em.getComponent(b, Pos);
|
|
274
|
+
assert.ok(Math.abs(resultB.x - 3) < 0.001);
|
|
275
|
+
assert.ok(Math.abs(resultB.y - 4) < 0.001);
|
|
276
|
+
|
|
277
|
+
const resultC = em.getComponent(c, Pos);
|
|
278
|
+
assert.ok(Math.abs(resultC.x - 5) < 0.001);
|
|
279
|
+
assert.ok(Math.abs(resultC.y - 6) < 0.001);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('typed + tag on same entity', () => {
|
|
283
|
+
const Pos = component('PosMixed', 'f32', ['x', 'y']);
|
|
284
|
+
const Tag = component('Tag');
|
|
285
|
+
const id = em.createEntity();
|
|
286
|
+
em.addComponent(id, Pos, { x: 10, y: 20 });
|
|
287
|
+
em.addComponent(id, Tag, {});
|
|
288
|
+
|
|
289
|
+
const pos = em.getComponent(id, Pos);
|
|
290
|
+
assert.ok(Math.abs(pos.x - 10) < 0.001);
|
|
291
|
+
assert.ok(Math.abs(pos.y - 20) < 0.001);
|
|
292
|
+
|
|
293
|
+
// Tag has no schema, getComponent returns undefined
|
|
294
|
+
assert.equal(em.getComponent(id, Tag), undefined);
|
|
295
|
+
assert.equal(em.hasComponent(id, Tag), true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('forEach raw field access and mutation', () => {
|
|
299
|
+
const Pos = component('PosLoop', 'f32', ['x', 'y']);
|
|
300
|
+
const Vel = component('VelLoop', 'f32', ['vx', 'vy']);
|
|
301
|
+
|
|
302
|
+
for (let i = 0; i < 10; i++) {
|
|
303
|
+
const id = em.createEntity();
|
|
304
|
+
em.addComponent(id, Pos, { x: i, y: 0 });
|
|
305
|
+
em.addComponent(id, Vel, { vx: 1, vy: 2 });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
em.forEach([Pos, Vel], (arch) => {
|
|
309
|
+
const px = arch.field(Pos.x);
|
|
310
|
+
const py = arch.field(Pos.y);
|
|
311
|
+
const vx = arch.field(Vel.vx);
|
|
312
|
+
const vy = arch.field(Vel.vy);
|
|
313
|
+
for (let i = 0; i < arch.count; i++) {
|
|
314
|
+
px[i] += vx[i];
|
|
315
|
+
py[i] += vy[i];
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const ids = em.query([Pos, Vel]);
|
|
320
|
+
for (const id of ids) {
|
|
321
|
+
const pos = em.getComponent(id, Pos);
|
|
322
|
+
assert.ok(pos.x >= 1);
|
|
323
|
+
assert.ok(Math.abs(pos.y - 2) < 0.001);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('serialize/deserialize round-trip with typed components', () => {
|
|
328
|
+
const Pos = component('PosSer', 'f32', ['x', 'y']);
|
|
329
|
+
const symbolToName = new Map([[Pos._sym, 'PosSer']]);
|
|
330
|
+
const nameToSymbol = { PosSer: Pos };
|
|
331
|
+
|
|
332
|
+
const a = em.createEntity();
|
|
333
|
+
em.addComponent(a, Pos, { x: 1.5, y: 2.5 });
|
|
334
|
+
const b = em.createEntity();
|
|
335
|
+
em.addComponent(b, Pos, { x: 3.5, y: 4.5 });
|
|
336
|
+
|
|
337
|
+
const data = em.serialize(symbolToName);
|
|
338
|
+
em.deserialize(data, nameToSymbol);
|
|
339
|
+
|
|
340
|
+
const posA = em.getComponent(a, Pos);
|
|
341
|
+
assert.ok(Math.abs(posA.x - 1.5) < 0.01);
|
|
342
|
+
assert.ok(Math.abs(posA.y - 2.5) < 0.01);
|
|
343
|
+
|
|
344
|
+
const posB = em.getComponent(b, Pos);
|
|
345
|
+
assert.ok(Math.abs(posB.x - 3.5) < 0.01);
|
|
346
|
+
assert.ok(Math.abs(posB.y - 4.5) < 0.01);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('archetype migration with typed components', () => {
|
|
350
|
+
const Pos = component('PosMig', 'f32', ['x', 'y']);
|
|
351
|
+
const Vel = component('VelMig', 'f32', ['vx', 'vy']);
|
|
352
|
+
|
|
353
|
+
const id = em.createEntity();
|
|
354
|
+
em.addComponent(id, Pos, { x: 5, y: 10 });
|
|
355
|
+
em.addComponent(id, Vel, { vx: 1, vy: 2 });
|
|
356
|
+
|
|
357
|
+
const pos = em.getComponent(id, Pos);
|
|
358
|
+
assert.ok(Math.abs(pos.x - 5) < 0.001);
|
|
359
|
+
assert.ok(Math.abs(pos.y - 10) < 0.001);
|
|
360
|
+
|
|
361
|
+
const vel = em.getComponent(id, Vel);
|
|
362
|
+
assert.ok(Math.abs(vel.vx - 1) < 0.001);
|
|
363
|
+
assert.ok(Math.abs(vel.vy - 2) < 0.001);
|
|
364
|
+
|
|
365
|
+
em.removeComponent(id, Vel);
|
|
366
|
+
const pos2 = em.getComponent(id, Pos);
|
|
367
|
+
assert.ok(Math.abs(pos2.x - 5) < 0.001);
|
|
368
|
+
assert.ok(Math.abs(pos2.y - 10) < 0.001);
|
|
369
|
+
assert.equal(em.hasComponent(id, Vel), false);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('overwrite typed component data in-place', () => {
|
|
373
|
+
const Pos = component('PosOw', 'f32', ['x', 'y']);
|
|
374
|
+
const id = em.createEntity();
|
|
375
|
+
em.addComponent(id, Pos, { x: 1, y: 2 });
|
|
376
|
+
em.addComponent(id, Pos, { x: 99, y: 88 });
|
|
377
|
+
const result = em.getComponent(id, Pos);
|
|
378
|
+
assert.ok(Math.abs(result.x - 99) < 0.001);
|
|
379
|
+
assert.ok(Math.abs(result.y - 88) < 0.001);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('get/set for zero-allocation field access', () => {
|
|
383
|
+
const Pos = component('PosGS', 'f32', ['x', 'y']);
|
|
384
|
+
const id = em.createEntity();
|
|
385
|
+
em.addComponent(id, Pos, { x: 3.5, y: 7.5 });
|
|
386
|
+
assert.ok(Math.abs(em.get(id, Pos.x) - 3.5) < 0.001);
|
|
387
|
+
assert.ok(Math.abs(em.get(id, Pos.y) - 7.5) < 0.001);
|
|
388
|
+
em.set(id, Pos.x, 42);
|
|
389
|
+
assert.ok(Math.abs(em.get(id, Pos.x) - 42) < 0.001);
|
|
390
|
+
assert.ok(Math.abs(em.get(id, Pos.y) - 7.5) < 0.001);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('get returns undefined for missing entity/component', () => {
|
|
394
|
+
const Pos = component('PosGFM', 'f32', ['x', 'y']);
|
|
395
|
+
assert.equal(em.get(999, Pos.x), undefined);
|
|
396
|
+
const id = em.createEntity();
|
|
397
|
+
assert.equal(em.get(id, Pos.x), undefined);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('forEach field returns undefined for tag component', () => {
|
|
401
|
+
const Tag = component('TagFE');
|
|
402
|
+
const Pos = component('PosFE', 'f32', ['x', 'y']);
|
|
403
|
+
const id = em.createEntity();
|
|
404
|
+
em.addComponent(id, Pos, { x: 1, y: 2 });
|
|
405
|
+
em.addComponent(id, Tag, {});
|
|
406
|
+
|
|
407
|
+
em.forEach([Pos, Tag], (arch) => {
|
|
408
|
+
assert.ok(arch.field(Pos.x) instanceof Float32Array);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('string component round-trip (add/get/set)', () => {
|
|
413
|
+
const Name = component('NameRT', { name: 'string', title: 'string' });
|
|
414
|
+
const id = em.createEntity();
|
|
415
|
+
em.addComponent(id, Name, { name: 'Hero', title: 'Sir' });
|
|
416
|
+
|
|
417
|
+
assert.equal(em.get(id, Name.name), 'Hero');
|
|
418
|
+
assert.equal(em.get(id, Name.title), 'Sir');
|
|
419
|
+
|
|
420
|
+
em.set(id, Name.name, 'Villain');
|
|
421
|
+
assert.equal(em.get(id, Name.name), 'Villain');
|
|
422
|
+
|
|
423
|
+
const obj = em.getComponent(id, Name);
|
|
424
|
+
assert.deepEqual(obj, { name: 'Villain', title: 'Sir' });
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('string component short form', () => {
|
|
428
|
+
const Label = component('LabelSF', 'string', ['text', 'color']);
|
|
429
|
+
const id = em.createEntity();
|
|
430
|
+
em.addComponent(id, Label, { text: 'hello', color: 'red' });
|
|
431
|
+
|
|
432
|
+
assert.equal(em.get(id, Label.text), 'hello');
|
|
433
|
+
assert.equal(em.get(id, Label.color), 'red');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('string component growth past capacity', () => {
|
|
437
|
+
const Name = component('NameGrow', 'string', ['value']);
|
|
438
|
+
for (let i = 0; i < 100; i++) {
|
|
439
|
+
const id = em.createEntity();
|
|
440
|
+
em.addComponent(id, Name, { value: `entity_${i}` });
|
|
441
|
+
}
|
|
442
|
+
const ids = em.query([Name]);
|
|
443
|
+
assert.equal(ids.length, 100);
|
|
444
|
+
assert.equal(em.get(ids[0], Name.value), 'entity_0');
|
|
445
|
+
assert.equal(em.get(ids[99], Name.value), 'entity_99');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('string component swap-remove preserves data', () => {
|
|
449
|
+
const Name = component('NameSwap', 'string', ['value']);
|
|
450
|
+
const a = em.createEntity();
|
|
451
|
+
const b = em.createEntity();
|
|
452
|
+
const c = em.createEntity();
|
|
453
|
+
em.addComponent(a, Name, { value: 'aaa' });
|
|
454
|
+
em.addComponent(b, Name, { value: 'bbb' });
|
|
455
|
+
em.addComponent(c, Name, { value: 'ccc' });
|
|
456
|
+
|
|
457
|
+
em.destroyEntity(a);
|
|
458
|
+
assert.equal(em.get(b, Name.value), 'bbb');
|
|
459
|
+
assert.equal(em.get(c, Name.value), 'ccc');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('mixed string + numeric fields in one component', () => {
|
|
463
|
+
const Item = component('Item', { name: 'string', weight: 'f32' });
|
|
464
|
+
const id = em.createEntity();
|
|
465
|
+
em.addComponent(id, Item, { name: 'Sword', weight: 3.5 });
|
|
466
|
+
|
|
467
|
+
assert.equal(em.get(id, Item.name), 'Sword');
|
|
468
|
+
assert.ok(Math.abs(em.get(id, Item.weight) - 3.5) < 0.01);
|
|
469
|
+
|
|
470
|
+
const obj = em.getComponent(id, Item);
|
|
471
|
+
assert.equal(obj.name, 'Sword');
|
|
472
|
+
assert.ok(Math.abs(obj.weight - 3.5) < 0.01);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('string component forEach field access', () => {
|
|
476
|
+
const Name = component('NameFE', 'string', ['value']);
|
|
477
|
+
for (let i = 0; i < 5; i++) {
|
|
478
|
+
const id = em.createEntity();
|
|
479
|
+
em.addComponent(id, Name, { value: `e${i}` });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
em.forEach([Name], (arch) => {
|
|
483
|
+
const values = arch.field(Name.value);
|
|
484
|
+
assert.ok(Array.isArray(values));
|
|
485
|
+
assert.equal(values[0], 'e0');
|
|
486
|
+
assert.equal(values[4], 'e4');
|
|
222
487
|
});
|
|
223
488
|
});
|
|
224
489
|
});
|
package/tests/types.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Compile-time type tests — run with: npx tsc --noEmit
|
|
2
|
+
// These tests validate TS generics flow correctly through the API.
|
|
3
|
+
// No runtime execution needed; if this file compiles, the types are correct.
|
|
4
|
+
|
|
5
|
+
import { createEntityManager, component, type ComponentDef, type FieldRef, type EntityId } from '../src/index.js';
|
|
6
|
+
|
|
7
|
+
// --- component() infers schema fields ---
|
|
8
|
+
const Position = component('Position', 'f32', ['x', 'y']);
|
|
9
|
+
const Velocity = component('Velocity', { vx: 'f32', vy: 'f32' });
|
|
10
|
+
const Tag = component('Tag');
|
|
11
|
+
const Name = component('Name', { name: 'string', title: 'string' });
|
|
12
|
+
const Label = component('Label', 'string', ['text', 'color']);
|
|
13
|
+
|
|
14
|
+
// Position should be ComponentDef<{ x: number, y: number }>
|
|
15
|
+
type AssertPosition = typeof Position extends ComponentDef<{ x: number; y: number }> ? true : never;
|
|
16
|
+
const _assertPos: AssertPosition = true;
|
|
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;
|
|
37
|
+
|
|
38
|
+
// --- EntityManager typed methods ---
|
|
39
|
+
const em = createEntityManager();
|
|
40
|
+
const id: EntityId = em.createEntity();
|
|
41
|
+
|
|
42
|
+
// addComponent: accepts correct data shape
|
|
43
|
+
em.addComponent(id, Position, { x: 1, y: 2 });
|
|
44
|
+
em.addComponent(id, Velocity, { vx: 0, vy: 0 });
|
|
45
|
+
em.addComponent(id, Name, { name: 'Hero', title: 'Sir' });
|
|
46
|
+
|
|
47
|
+
// getComponent: returns typed object or undefined
|
|
48
|
+
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
|
+
|
|
60
|
+
// get/set: numeric field descriptor access
|
|
61
|
+
const px: number | undefined = em.get(id, Position.x);
|
|
62
|
+
const py: number | undefined = em.get(id, Position.y);
|
|
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');
|
|
69
|
+
|
|
70
|
+
// forEach + field: accepts FieldRef
|
|
71
|
+
em.forEach([Position, Velocity], (arch) => {
|
|
72
|
+
const count: number = arch.count;
|
|
73
|
+
const ids: EntityId[] = arch.entityIds;
|
|
74
|
+
const arrX = arch.field(Position.x);
|
|
75
|
+
const arrVx = arch.field(Velocity.vx);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// createEntityWith: alternating type, data
|
|
79
|
+
em.createEntityWith(Position, { x: 0, y: 0 }, Velocity, { vx: 1, vy: 1 });
|
|
80
|
+
|
|
81
|
+
// --- These should fail if uncommented (negative tests) ---
|
|
82
|
+
// @ts-expect-error 'z' does not exist on Position
|
|
83
|
+
em.get(id, Position.z);
|
|
84
|
+
|
|
85
|
+
// @ts-expect-error 'z' does not exist on Position
|
|
86
|
+
em.set(id, Position.z, 5);
|
|
87
|
+
|
|
88
|
+
// @ts-expect-error 'x' does not exist on Velocity
|
|
89
|
+
em.get(id, Velocity.x);
|
|
90
|
+
|
|
91
|
+
// @ts-expect-error missing 'y' in Position data
|
|
92
|
+
em.addComponent(id, Position, { x: 1 });
|
|
93
|
+
|
|
94
|
+
// @ts-expect-error 'z' does not exist on Position
|
|
95
|
+
em.forEach([Position], (arch) => { arch.field(Position.z); });
|
|
96
|
+
|
|
97
|
+
// @ts-expect-error wrong data shape for Name
|
|
98
|
+
em.addComponent(id, Name, { foo: 'bar' });
|
|
99
|
+
|
|
100
|
+
// @ts-expect-error number not assignable to string field
|
|
101
|
+
em.set(id, Name.name, 42);
|