@codehz/ecs 0.7.2 → 0.7.4
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 +60 -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,502 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { component, type EntityId } from "../../entity";
|
|
3
|
+
import { World } from "../../world/world";
|
|
4
|
+
|
|
5
|
+
describe("World - Multi-Component Hooks", () => {
|
|
6
|
+
it("should trigger init, set, remove events correctly when using array syntax with single element", () => {
|
|
7
|
+
const world = new World();
|
|
8
|
+
const A = component<number>();
|
|
9
|
+
|
|
10
|
+
const initCalls: { entityId: EntityId; value: number }[] = [];
|
|
11
|
+
const setCalls: { entityId: EntityId; value: number }[] = [];
|
|
12
|
+
const removeCalls: { entityId: EntityId; value: number }[] = [];
|
|
13
|
+
|
|
14
|
+
// First create an entity before registering the hook (for on_init test)
|
|
15
|
+
const existingEntity = world.spawn().with(A, 100).build();
|
|
16
|
+
world.sync();
|
|
17
|
+
|
|
18
|
+
// Register hook using array syntax with single element
|
|
19
|
+
world.hook([A], {
|
|
20
|
+
on_init: (entityId, value) => {
|
|
21
|
+
initCalls.push({ entityId, value });
|
|
22
|
+
},
|
|
23
|
+
on_set: (entityId, value) => {
|
|
24
|
+
setCalls.push({ entityId, value });
|
|
25
|
+
},
|
|
26
|
+
on_remove: (entityId, value) => {
|
|
27
|
+
removeCalls.push({ entityId, value });
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// on_init should be triggered for existing entity
|
|
32
|
+
expect(initCalls.length).toBe(1);
|
|
33
|
+
expect(initCalls[0]!.entityId).toBe(existingEntity);
|
|
34
|
+
expect(initCalls[0]!.value).toBe(100);
|
|
35
|
+
|
|
36
|
+
// Create a new entity - should trigger on_set
|
|
37
|
+
const newEntity = world.spawn().with(A, 42).build();
|
|
38
|
+
world.sync();
|
|
39
|
+
|
|
40
|
+
expect(setCalls.length).toBe(1);
|
|
41
|
+
expect(setCalls[0]!.entityId).toBe(newEntity);
|
|
42
|
+
expect(setCalls[0]!.value).toBe(42);
|
|
43
|
+
|
|
44
|
+
// Update the component - should trigger on_set again
|
|
45
|
+
world.set(newEntity, A, 99);
|
|
46
|
+
world.sync();
|
|
47
|
+
|
|
48
|
+
expect(setCalls.length).toBe(2);
|
|
49
|
+
expect(setCalls[1]!.entityId).toBe(newEntity);
|
|
50
|
+
expect(setCalls[1]!.value).toBe(99);
|
|
51
|
+
|
|
52
|
+
// Remove the component - should trigger on_remove
|
|
53
|
+
world.remove(newEntity, A);
|
|
54
|
+
world.sync();
|
|
55
|
+
|
|
56
|
+
expect(removeCalls.length).toBe(1);
|
|
57
|
+
expect(removeCalls[0]!.entityId).toBe(newEntity);
|
|
58
|
+
expect(removeCalls[0]!.value).toBe(99);
|
|
59
|
+
|
|
60
|
+
// Delete the existing entity - should trigger on_remove
|
|
61
|
+
world.delete(existingEntity);
|
|
62
|
+
world.sync();
|
|
63
|
+
|
|
64
|
+
expect(removeCalls.length).toBe(2);
|
|
65
|
+
expect(removeCalls[1]!.entityId).toBe(existingEntity);
|
|
66
|
+
expect(removeCalls[1]!.value).toBe(100);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should throw error when hook has no required components (only optional)", () => {
|
|
70
|
+
const world = new World();
|
|
71
|
+
const A = component<number>();
|
|
72
|
+
const B = component<string>();
|
|
73
|
+
|
|
74
|
+
expect(() => {
|
|
75
|
+
world.hook([{ optional: A }, { optional: B }], {
|
|
76
|
+
on_set: () => {},
|
|
77
|
+
});
|
|
78
|
+
}).toThrow();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should throw error when hook has empty component array", () => {
|
|
82
|
+
const world = new World();
|
|
83
|
+
|
|
84
|
+
expect(() => {
|
|
85
|
+
world.hook([], {
|
|
86
|
+
on_set: () => {},
|
|
87
|
+
});
|
|
88
|
+
}).toThrow();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should trigger on_set when all required components are present", () => {
|
|
92
|
+
const world = new World();
|
|
93
|
+
const A = component<number>();
|
|
94
|
+
const B = component<string>();
|
|
95
|
+
const calls: { entityId: EntityId; components: readonly [number, string] }[] = [];
|
|
96
|
+
|
|
97
|
+
world.hook([A, B], {
|
|
98
|
+
on_set: (entityId, ...components) => {
|
|
99
|
+
calls.push({ entityId, components });
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const entity = world.spawn().with(A, 42).with(B, "hello").build();
|
|
104
|
+
world.sync();
|
|
105
|
+
|
|
106
|
+
expect(calls.length).toBe(1);
|
|
107
|
+
expect(calls[0]!.entityId).toBe(entity);
|
|
108
|
+
expect(calls[0]!.components).toEqual([42, "hello"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("should not trigger on_set when some required components are missing", () => {
|
|
112
|
+
const world = new World();
|
|
113
|
+
const A = component<number>();
|
|
114
|
+
const B = component<string>();
|
|
115
|
+
const calls: any[] = [];
|
|
116
|
+
|
|
117
|
+
world.hook([A, B], {
|
|
118
|
+
on_set: (entityId, ...components) => {
|
|
119
|
+
calls.push({ entityId, components });
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const entity = world.spawn().with(A, 42).build();
|
|
124
|
+
world.sync();
|
|
125
|
+
|
|
126
|
+
expect(calls.length).toBe(0);
|
|
127
|
+
expect(world.has(entity, A)).toBe(true);
|
|
128
|
+
expect(world.has(entity, B)).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("should trigger on_set with optional component present", () => {
|
|
132
|
+
const world = new World();
|
|
133
|
+
const A = component<number>();
|
|
134
|
+
const B = component<string>();
|
|
135
|
+
const calls: { entityId: EntityId; components: readonly [number, { value: string } | undefined] }[] = [];
|
|
136
|
+
|
|
137
|
+
world.hook([A, { optional: B }], {
|
|
138
|
+
on_set: (entityId, ...components) => {
|
|
139
|
+
calls.push({ entityId, components });
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const entity = world.spawn().with(A, 42).with(B, "hello").build();
|
|
144
|
+
world.sync();
|
|
145
|
+
|
|
146
|
+
expect(calls.length).toBe(1);
|
|
147
|
+
expect(calls[0]!.entityId).toBe(entity);
|
|
148
|
+
expect(calls[0]!.components).toEqual([42, { value: "hello" }]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("should trigger on_set with optional component absent", () => {
|
|
152
|
+
const world = new World();
|
|
153
|
+
const A = component<number>();
|
|
154
|
+
const B = component<string>();
|
|
155
|
+
const calls: { entityId: EntityId; components: readonly [number, { value: string } | undefined] }[] = [];
|
|
156
|
+
|
|
157
|
+
world.hook([A, { optional: B }], {
|
|
158
|
+
on_set: (entityId, ...components) => {
|
|
159
|
+
calls.push({ entityId, components });
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const entity = world.spawn().with(A, 42).build();
|
|
164
|
+
world.sync();
|
|
165
|
+
|
|
166
|
+
expect(calls.length).toBe(1);
|
|
167
|
+
expect(calls[0]!.entityId).toBe(entity);
|
|
168
|
+
expect(calls[0]!.components).toEqual([42, undefined]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("should trigger on_set when optional component changes while required component unchanged", () => {
|
|
172
|
+
const world = new World();
|
|
173
|
+
const A = component<number>();
|
|
174
|
+
const B = component<string>();
|
|
175
|
+
const calls: { entityId: EntityId; components: readonly [number, { value: string } | undefined] }[] = [];
|
|
176
|
+
|
|
177
|
+
world.hook([A, { optional: B }], {
|
|
178
|
+
on_set: (entityId, ...components) => {
|
|
179
|
+
calls.push({ entityId, components });
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// First, create entity with only A
|
|
184
|
+
const entity = world.spawn().with(A, 42).build();
|
|
185
|
+
world.sync();
|
|
186
|
+
|
|
187
|
+
expect(calls.length).toBe(1);
|
|
188
|
+
expect(calls[0]!.components).toEqual([42, undefined]);
|
|
189
|
+
|
|
190
|
+
// Now add B - should trigger on_set
|
|
191
|
+
world.set(entity, B, "hello");
|
|
192
|
+
world.sync();
|
|
193
|
+
|
|
194
|
+
expect(calls.length).toBe(2);
|
|
195
|
+
expect(calls[1]!.entityId).toBe(entity);
|
|
196
|
+
expect(calls[1]!.components).toEqual([42, { value: "hello" }]);
|
|
197
|
+
|
|
198
|
+
// Update B - should also trigger on_set
|
|
199
|
+
world.set(entity, B, "world");
|
|
200
|
+
world.sync();
|
|
201
|
+
|
|
202
|
+
expect(calls.length).toBe(3);
|
|
203
|
+
expect(calls[2]!.entityId).toBe(entity);
|
|
204
|
+
expect(calls[2]!.components).toEqual([42, { value: "world" }]);
|
|
205
|
+
|
|
206
|
+
// Updating A should also trigger on_set with latest B value
|
|
207
|
+
world.set(entity, A, 100);
|
|
208
|
+
world.sync();
|
|
209
|
+
|
|
210
|
+
expect(calls.length).toBe(4);
|
|
211
|
+
expect(calls[3]!.entityId).toBe(entity);
|
|
212
|
+
expect(calls[3]!.components).toEqual([100, { value: "world" }]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("should trigger on_remove when required component is removed with optional present", () => {
|
|
216
|
+
const world = new World();
|
|
217
|
+
const A = component<number>();
|
|
218
|
+
const B = component<string>();
|
|
219
|
+
const removeCalls: { entityId: EntityId; components: readonly [number, { value: string } | undefined] }[] = [];
|
|
220
|
+
|
|
221
|
+
world.hook([A, { optional: B }], {
|
|
222
|
+
on_remove: (entityId, ...components) => {
|
|
223
|
+
removeCalls.push({ entityId, components });
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const entity = world.spawn().with(A, 42).with(B, "hello").build();
|
|
228
|
+
world.sync();
|
|
229
|
+
|
|
230
|
+
// Remove required component A
|
|
231
|
+
world.remove(entity, A);
|
|
232
|
+
world.sync();
|
|
233
|
+
|
|
234
|
+
expect(removeCalls.length).toBe(1);
|
|
235
|
+
expect(removeCalls[0]!.entityId).toBe(entity);
|
|
236
|
+
expect(removeCalls[0]!.components).toEqual([42, { value: "hello" }]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("should trigger on_remove when required component is removed with optional absent", () => {
|
|
240
|
+
const world = new World();
|
|
241
|
+
const A = component<number>();
|
|
242
|
+
const B = component<string>();
|
|
243
|
+
const removeCalls: { entityId: EntityId; components: readonly [number, { value: string } | undefined] }[] = [];
|
|
244
|
+
|
|
245
|
+
world.hook([A, { optional: B }], {
|
|
246
|
+
on_remove: (entityId, ...components) => {
|
|
247
|
+
removeCalls.push({ entityId, components });
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const entity = world.spawn().with(A, 42).build();
|
|
252
|
+
world.sync();
|
|
253
|
+
|
|
254
|
+
// Remove required component A
|
|
255
|
+
world.remove(entity, A);
|
|
256
|
+
world.sync();
|
|
257
|
+
|
|
258
|
+
expect(removeCalls.length).toBe(1);
|
|
259
|
+
expect(removeCalls[0]!.entityId).toBe(entity);
|
|
260
|
+
expect(removeCalls[0]!.components).toEqual([42, undefined]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("should not trigger on_remove when optional component is removed", () => {
|
|
264
|
+
const world = new World();
|
|
265
|
+
const A = component<number>();
|
|
266
|
+
const B = component<string>();
|
|
267
|
+
const removeCalls: { entityId: EntityId; components: readonly [number, { value: string } | undefined] }[] = [];
|
|
268
|
+
|
|
269
|
+
world.hook([A, { optional: B }], {
|
|
270
|
+
on_remove: (entityId, ...components) => {
|
|
271
|
+
removeCalls.push({ entityId, components });
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const entity = world.spawn().with(A, 42).with(B, "hello").build();
|
|
276
|
+
world.sync();
|
|
277
|
+
|
|
278
|
+
// Remove optional component B - should NOT trigger on_remove
|
|
279
|
+
world.remove(entity, B);
|
|
280
|
+
world.sync();
|
|
281
|
+
|
|
282
|
+
expect(removeCalls.length).toBe(0);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("should trigger on_set when optional component is removed", () => {
|
|
286
|
+
const world = new World();
|
|
287
|
+
const A = component<number>();
|
|
288
|
+
const B = component<string>();
|
|
289
|
+
const setCalls: { entityId: EntityId; components: readonly [number, { value: string } | undefined] }[] = [];
|
|
290
|
+
|
|
291
|
+
world.hook([A, { optional: B }], {
|
|
292
|
+
on_set: (entityId, ...components) => {
|
|
293
|
+
setCalls.push({ entityId, components });
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const entity = world.spawn().with(A, 42).with(B, "hello").build();
|
|
298
|
+
world.sync();
|
|
299
|
+
|
|
300
|
+
expect(setCalls.length).toBe(1);
|
|
301
|
+
expect(setCalls[0]!.components).toEqual([42, { value: "hello" }]);
|
|
302
|
+
|
|
303
|
+
// Remove optional component B - should trigger on_set with undefined for B
|
|
304
|
+
world.remove(entity, B);
|
|
305
|
+
world.sync();
|
|
306
|
+
|
|
307
|
+
expect(setCalls.length).toBe(2);
|
|
308
|
+
expect(setCalls[1]!.entityId).toBe(entity);
|
|
309
|
+
expect(setCalls[1]!.components).toEqual([42, undefined]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("should trigger on_remove with complete snapshot when required component is removed", () => {
|
|
313
|
+
const world = new World();
|
|
314
|
+
const A = component<number>();
|
|
315
|
+
const B = component<string>();
|
|
316
|
+
const removeCalls: { entityId: EntityId; components: readonly [number, string] }[] = [];
|
|
317
|
+
|
|
318
|
+
world.hook([A, B], {
|
|
319
|
+
on_remove: (entityId, ...components) => {
|
|
320
|
+
removeCalls.push({ entityId, components });
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const entity = world.spawn().with(A, 42).with(B, "hello").build();
|
|
325
|
+
world.sync();
|
|
326
|
+
|
|
327
|
+
world.remove(entity, A);
|
|
328
|
+
world.sync();
|
|
329
|
+
|
|
330
|
+
expect(removeCalls.length).toBe(1);
|
|
331
|
+
expect(removeCalls[0]!.entityId).toBe(entity);
|
|
332
|
+
expect(removeCalls[0]!.components).toEqual([42, "hello"]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("should trigger on_init for existing entities matching all required components", () => {
|
|
336
|
+
const world = new World();
|
|
337
|
+
const A = component<number>();
|
|
338
|
+
const B = component<string>();
|
|
339
|
+
|
|
340
|
+
const entity = world.spawn().with(A, 42).with(B, "hello").build();
|
|
341
|
+
world.sync();
|
|
342
|
+
|
|
343
|
+
const initCalls: { entityId: EntityId; components: readonly [number, string] }[] = [];
|
|
344
|
+
|
|
345
|
+
world.hook([A, B], {
|
|
346
|
+
on_init: (entityId, ...components) => {
|
|
347
|
+
initCalls.push({ entityId, components });
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
expect(initCalls.length).toBe(1);
|
|
352
|
+
expect(initCalls[0]!.entityId).toBe(entity);
|
|
353
|
+
expect(initCalls[0]!.components).toEqual([42, "hello"]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("should apply negative filter for on_init replay", () => {
|
|
357
|
+
const world = new World();
|
|
358
|
+
const A = component<number>();
|
|
359
|
+
const Disabled = component<void>();
|
|
360
|
+
|
|
361
|
+
const activeEntity = world.spawn().with(A, 1).build();
|
|
362
|
+
const filteredEntity = world.spawn().with(A, 2).with(Disabled).build();
|
|
363
|
+
world.sync();
|
|
364
|
+
|
|
365
|
+
const initCalls: EntityId[] = [];
|
|
366
|
+
world.hook(
|
|
367
|
+
[A],
|
|
368
|
+
{
|
|
369
|
+
on_init: (entityId) => {
|
|
370
|
+
initCalls.push(entityId);
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
{ negativeComponentTypes: [Disabled] },
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
expect(initCalls).toContain(activeEntity);
|
|
377
|
+
expect(initCalls).not.toContain(filteredEntity);
|
|
378
|
+
expect(initCalls.length).toBe(1);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("should trigger on_remove when entering negative filter state", () => {
|
|
382
|
+
const world = new World();
|
|
383
|
+
const A = component<number>();
|
|
384
|
+
const Disabled = component<void>();
|
|
385
|
+
const removeCalls: { entityId: EntityId; value: number }[] = [];
|
|
386
|
+
|
|
387
|
+
world.hook(
|
|
388
|
+
[A],
|
|
389
|
+
{
|
|
390
|
+
on_remove: (entityId, value) => {
|
|
391
|
+
removeCalls.push({ entityId, value });
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
{ negativeComponentTypes: [Disabled] },
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const entity = world.spawn().with(A, 42).build();
|
|
398
|
+
world.sync();
|
|
399
|
+
expect(removeCalls.length).toBe(0);
|
|
400
|
+
|
|
401
|
+
world.set(entity, Disabled);
|
|
402
|
+
world.sync();
|
|
403
|
+
|
|
404
|
+
expect(removeCalls.length).toBe(1);
|
|
405
|
+
expect(removeCalls[0]!.entityId).toBe(entity);
|
|
406
|
+
expect(removeCalls[0]!.value).toBe(42);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("should trigger on_set when leaving negative filter state", () => {
|
|
410
|
+
const world = new World();
|
|
411
|
+
const A = component<number>();
|
|
412
|
+
const Disabled = component<void>();
|
|
413
|
+
const setCalls: { entityId: EntityId; value: number }[] = [];
|
|
414
|
+
|
|
415
|
+
world.hook(
|
|
416
|
+
[A],
|
|
417
|
+
{
|
|
418
|
+
on_set: (entityId, value) => {
|
|
419
|
+
setCalls.push({ entityId, value });
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
{ negativeComponentTypes: [Disabled] },
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
const entity = world.spawn().with(A, 7).with(Disabled).build();
|
|
426
|
+
world.sync();
|
|
427
|
+
expect(setCalls.length).toBe(0);
|
|
428
|
+
|
|
429
|
+
world.remove(entity, Disabled);
|
|
430
|
+
world.sync();
|
|
431
|
+
|
|
432
|
+
expect(setCalls.length).toBe(1);
|
|
433
|
+
expect(setCalls[0]!.entityId).toBe(entity);
|
|
434
|
+
expect(setCalls[0]!.value).toBe(7);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("should suppress normal set events while filtered until re-entering", () => {
|
|
438
|
+
const world = new World();
|
|
439
|
+
const A = component<number>();
|
|
440
|
+
const B = component<string>();
|
|
441
|
+
const Disabled = component<void>();
|
|
442
|
+
const setCalls: { entityId: EntityId; components: readonly [number, { value: string } | undefined] }[] = [];
|
|
443
|
+
|
|
444
|
+
world.hook(
|
|
445
|
+
[A, { optional: B }],
|
|
446
|
+
{
|
|
447
|
+
on_set: (entityId, ...components) => {
|
|
448
|
+
setCalls.push({ entityId, components });
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
{ negativeComponentTypes: [Disabled] },
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
const entity = world.spawn().with(A, 1).with(Disabled).build();
|
|
455
|
+
world.sync();
|
|
456
|
+
expect(setCalls.length).toBe(0);
|
|
457
|
+
|
|
458
|
+
world.set(entity, B, "blocked");
|
|
459
|
+
world.sync();
|
|
460
|
+
expect(setCalls.length).toBe(0);
|
|
461
|
+
|
|
462
|
+
world.set(entity, A, 2);
|
|
463
|
+
world.sync();
|
|
464
|
+
expect(setCalls.length).toBe(0);
|
|
465
|
+
|
|
466
|
+
world.remove(entity, Disabled);
|
|
467
|
+
world.sync();
|
|
468
|
+
|
|
469
|
+
expect(setCalls.length).toBe(1);
|
|
470
|
+
expect(setCalls[0]!.entityId).toBe(entity);
|
|
471
|
+
expect(setCalls[0]!.components).toEqual([2, { value: "blocked" }]);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("should stop triggering after unhook for multi-component hooks", () => {
|
|
475
|
+
const world = new World();
|
|
476
|
+
const A = component<number>();
|
|
477
|
+
const B = component<string>();
|
|
478
|
+
const calls: any[] = [];
|
|
479
|
+
|
|
480
|
+
const hook = {
|
|
481
|
+
on_set: (entityId: EntityId, ...components: any[]) => {
|
|
482
|
+
calls.push({ entityId, components });
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
const unhook = world.hook([A, B], hook);
|
|
487
|
+
|
|
488
|
+
const entity1 = world.spawn().with(A, 1).with(B, "first").build();
|
|
489
|
+
world.sync();
|
|
490
|
+
|
|
491
|
+
expect(calls.length).toBe(1);
|
|
492
|
+
|
|
493
|
+
unhook();
|
|
494
|
+
|
|
495
|
+
const entity2 = world.spawn().with(A, 2).with(B, "second").build();
|
|
496
|
+
world.sync();
|
|
497
|
+
|
|
498
|
+
expect(calls.length).toBe(1);
|
|
499
|
+
expect(world.has(entity1, A)).toBe(true);
|
|
500
|
+
expect(world.has(entity2, A)).toBe(true);
|
|
501
|
+
});
|
|
502
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { component } from "../../entity";
|
|
3
|
+
import { World } from "../../world/world";
|
|
4
|
+
|
|
5
|
+
describe("World Performance", () => {
|
|
6
|
+
it("should handle archetype creation efficiently", () => {
|
|
7
|
+
const world = new World();
|
|
8
|
+
|
|
9
|
+
// Create multiple component types
|
|
10
|
+
const component1 = component<{}>();
|
|
11
|
+
const component2 = component<{}>();
|
|
12
|
+
const component3 = component<{}>();
|
|
13
|
+
|
|
14
|
+
// Create entities with different component combinations
|
|
15
|
+
const startTime = performance.now();
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < 100; i++) {
|
|
18
|
+
const entity = world.new();
|
|
19
|
+
// Add components in different combinations
|
|
20
|
+
world.set(entity, component1, {});
|
|
21
|
+
if (i % 2 === 0) world.set(entity, component2, {});
|
|
22
|
+
if (i % 3 === 0) world.set(entity, component3, {});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
world.sync();
|
|
26
|
+
|
|
27
|
+
const endTime = performance.now();
|
|
28
|
+
const duration = endTime - startTime;
|
|
29
|
+
|
|
30
|
+
console.log(`Created 100 entities with components in ${duration.toFixed(2)}ms`);
|
|
31
|
+
|
|
32
|
+
// Should complete in reasonable time (less than 100ms for this simple test)
|
|
33
|
+
expect(duration).toBeLessThan(100);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("should handle queries efficiently", () => {
|
|
37
|
+
const world = new World();
|
|
38
|
+
|
|
39
|
+
type Position = { x: number; y: number };
|
|
40
|
+
type Velocity = { x: number; y: number };
|
|
41
|
+
type Health = { value: number };
|
|
42
|
+
|
|
43
|
+
const positionComponent = component<Position>();
|
|
44
|
+
const velocityComponent = component<Velocity>();
|
|
45
|
+
const healthComponent = component<Health>();
|
|
46
|
+
|
|
47
|
+
// Create many entities
|
|
48
|
+
for (let i = 0; i < 1000; i++) {
|
|
49
|
+
const entity = world.new();
|
|
50
|
+
|
|
51
|
+
// Add position to all
|
|
52
|
+
world.set(entity, positionComponent, { x: i, y: i });
|
|
53
|
+
|
|
54
|
+
// Add velocity to half
|
|
55
|
+
if (i % 2 === 0) {
|
|
56
|
+
world.set(entity, velocityComponent, { x: 1, y: 1 });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Add health to quarter
|
|
60
|
+
if (i % 4 === 0) {
|
|
61
|
+
world.set(entity, healthComponent, { value: 100 });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
world.sync();
|
|
66
|
+
|
|
67
|
+
// Test query performance
|
|
68
|
+
const startTime = performance.now();
|
|
69
|
+
|
|
70
|
+
const positionEntities = world.query([positionComponent]);
|
|
71
|
+
const velocityEntities = world.query([velocityComponent]);
|
|
72
|
+
const healthEntities = world.query([healthComponent]);
|
|
73
|
+
const positionAndVelocityEntities = world.query([positionComponent, velocityComponent]);
|
|
74
|
+
|
|
75
|
+
const endTime = performance.now();
|
|
76
|
+
const duration = endTime - startTime;
|
|
77
|
+
|
|
78
|
+
console.log(`Queried entities in ${duration.toFixed(2)}ms`);
|
|
79
|
+
console.log(`Position entities: ${positionEntities.length}`);
|
|
80
|
+
console.log(`Velocity entities: ${velocityEntities.length}`);
|
|
81
|
+
console.log(`Health entities: ${healthEntities.length}`);
|
|
82
|
+
console.log(`Position+Velocity entities: ${positionAndVelocityEntities.length}`);
|
|
83
|
+
|
|
84
|
+
// Verify results
|
|
85
|
+
expect(positionEntities.length).toBe(1000);
|
|
86
|
+
expect(velocityEntities.length).toBe(500);
|
|
87
|
+
expect(healthEntities.length).toBe(250);
|
|
88
|
+
expect(positionAndVelocityEntities.length).toBe(500);
|
|
89
|
+
|
|
90
|
+
// Should complete quickly
|
|
91
|
+
expect(duration).toBeLessThan(10);
|
|
92
|
+
});
|
|
93
|
+
});
|