@codehz/ecs 0.8.2 → 0.10.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.en.md +26 -3
- package/README.md +41 -4
- package/dist/builder.d.mts +348 -83
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +1 -1
- package/dist/world.mjs +1922 -1400
- package/dist/world.mjs.map +1 -1
- package/examples/debug-observability.ts +92 -0
- package/examples/inventory-system-relations.ts +1 -1
- package/examples/parent-child-hierarchy.ts +18 -38
- package/examples/spatial-grid.ts +1 -1
- package/package.json +1 -1
- package/skills/ecs/SKILL.md +4 -4
- package/src/__tests__/component/singleton.test.ts +116 -35
- package/src/__tests__/core/archetype.test.ts +155 -13
- package/src/__tests__/core/bitset.test.ts +12 -0
- package/src/__tests__/entity/entity.test.ts +33 -0
- package/src/__tests__/entity/id-system.test.ts +40 -0
- package/src/__tests__/perf/comprehensive.perf.test.ts +6 -9
- package/src/__tests__/perf/serialization.perf.test.ts +242 -0
- package/src/__tests__/perf/{dontfragment-wildcard.perf.test.ts → sparse-wildcard.perf.test.ts} +13 -16
- package/src/__tests__/query/caching.test.ts +62 -0
- package/src/__tests__/query/filter.test.ts +16 -22
- package/src/__tests__/query/perf.test.ts +3 -5
- package/src/__tests__/relations/hierarchy.test.ts +208 -0
- package/src/__tests__/relations/{dont-fragment → sparse}/basic.test.ts +64 -69
- package/src/__tests__/relations/{dont-fragment → sparse}/query-notification.test.ts +17 -9
- package/src/__tests__/serialization/bounds.test.ts +133 -1
- package/src/__tests__/world/commands.test.ts +337 -0
- package/src/__tests__/world/component-management.test.ts +6 -5
- package/src/__tests__/world/debug-stats.test.ts +206 -0
- package/src/__tests__/world/multi-component-hooks.test.ts +44 -0
- package/src/__tests__/world/serialize.test.ts +17 -0
- package/src/__tests__/world/wildcard-relation-hooks.test.ts +127 -0
- package/src/archetype/archetype.ts +96 -46
- package/src/archetype/helpers.ts +7 -29
- package/src/archetype/store.ts +35 -20
- package/src/commands/buffer.ts +5 -2
- package/src/commands/changeset.ts +0 -31
- package/src/component/registry.ts +64 -63
- package/src/entity/index.ts +6 -3
- package/src/index.ts +15 -0
- package/src/query/filter.ts +4 -10
- package/src/query/query.ts +12 -12
- package/src/storage/serialization.ts +29 -2
- package/src/types/index.ts +71 -0
- package/src/world/archetype-manager.ts +283 -0
- package/src/world/command-executor.ts +258 -0
- package/src/world/commands.ts +44 -56
- package/src/world/debug-stats.ts +147 -0
- package/src/world/hooks.ts +8 -0
- package/src/world/operations.ts +88 -0
- package/src/world/serialization.ts +32 -18
- package/src/world/singleton.ts +51 -0
- package/src/world/world.ts +429 -457
|
@@ -370,4 +370,131 @@ describe("Wildcard-Relation Hooks", () => {
|
|
|
370
370
|
// It should still correctly report the actual removed relation
|
|
371
371
|
expect(reportedRelations).toContainEqual([target, { value: "hello" }]);
|
|
372
372
|
});
|
|
373
|
+
|
|
374
|
+
it("should correctly evaluate 'had before' for wildcard required component when removing a different required component", () => {
|
|
375
|
+
const world = new World();
|
|
376
|
+
const A = component<number>();
|
|
377
|
+
const RelData = component<{ value: string }>();
|
|
378
|
+
const target = world.new();
|
|
379
|
+
const wildcardRel = relation(RelData, "*");
|
|
380
|
+
|
|
381
|
+
const removeCalls: { entityId: EntityId; components: any }[] = [];
|
|
382
|
+
|
|
383
|
+
// Hook requires both A (regular) and wildcard relation
|
|
384
|
+
world.hook([A, wildcardRel], {
|
|
385
|
+
on_remove: (entityId, ...components) => {
|
|
386
|
+
removeCalls.push({ entityId, components });
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const entity = world.spawn().with(A, 42).with(relation(RelData, target), { value: "hello" }).build();
|
|
391
|
+
world.sync();
|
|
392
|
+
|
|
393
|
+
// Remove the non-wildcard required component A.
|
|
394
|
+
// This exercises the path in entityHadAllComponentsBefore where for the wildcard
|
|
395
|
+
// required component, anyComponentMatches(removed, wildcard) is false (only A was removed),
|
|
396
|
+
// so we fall through to the isWildcard getOptional branch.
|
|
397
|
+
world.remove(entity, A);
|
|
398
|
+
world.sync();
|
|
399
|
+
|
|
400
|
+
expect(removeCalls.length).toBe(1);
|
|
401
|
+
expect(removeCalls[0]!.entityId).toBe(entity);
|
|
402
|
+
expect(removeCalls[0]!.components[0]).toBe(42);
|
|
403
|
+
expect(Array.isArray(removeCalls[0]!.components[1])).toBe(true);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("should reconstruct optional wildcard relation data during on_remove of required component", () => {
|
|
407
|
+
const world = new World();
|
|
408
|
+
const A = component<number>();
|
|
409
|
+
const RelData = component<{ value: string }>();
|
|
410
|
+
const target = world.new();
|
|
411
|
+
const wildcardRel = relation(RelData, "*");
|
|
412
|
+
|
|
413
|
+
const removeCalls: { entityId: EntityId; components: any }[] = [];
|
|
414
|
+
|
|
415
|
+
// Required A + optional wildcard relation
|
|
416
|
+
world.hook([A, { optional: wildcardRel }], {
|
|
417
|
+
on_remove: (entityId, ...components) => {
|
|
418
|
+
removeCalls.push({ entityId, components });
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const entity = world.spawn().with(A, 99).with(relation(RelData, target), { value: "secret" }).build();
|
|
423
|
+
world.sync();
|
|
424
|
+
|
|
425
|
+
// Removing A triggers on_remove for the hook (required lost).
|
|
426
|
+
// The collection for the optional wildcard must go through reconstructWildcardWithRemoved
|
|
427
|
+
// (the if isWildcardRelationId(optionalId) branch in collectMultiHookComponentsWithRemoved).
|
|
428
|
+
world.remove(entity, A);
|
|
429
|
+
world.sync();
|
|
430
|
+
|
|
431
|
+
expect(removeCalls.length).toBe(1);
|
|
432
|
+
expect(removeCalls[0]!.entityId).toBe(entity);
|
|
433
|
+
expect(removeCalls[0]!.components[0]).toBe(99);
|
|
434
|
+
// optional wildcard should have been reconstructed from the (now removed) data
|
|
435
|
+
const opt = removeCalls[0]!.components[1];
|
|
436
|
+
expect(opt).toBeDefined();
|
|
437
|
+
expect(Array.isArray(opt.value)).toBe(true);
|
|
438
|
+
expect(opt.value).toContainEqual([target, { value: "secret" }]);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("should trigger on_remove via deletion fast-path for required wildcard relation hook", () => {
|
|
442
|
+
const world = new World();
|
|
443
|
+
const RelData = component<{ value: string }>();
|
|
444
|
+
const target = world.new();
|
|
445
|
+
const wildcardRel = relation(RelData, "*");
|
|
446
|
+
|
|
447
|
+
const removeCalls: { entityId: EntityId; relations: [EntityId, { value: string }][] }[] = [];
|
|
448
|
+
|
|
449
|
+
world.hook([wildcardRel], {
|
|
450
|
+
on_remove: (entityId, relations) => {
|
|
451
|
+
removeCalls.push({ entityId, relations });
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const entity = world.spawn().with(relation(RelData, target), { value: "to-be-deleted" }).build();
|
|
456
|
+
world.sync();
|
|
457
|
+
|
|
458
|
+
// Directly delete the entity that owns the wildcard relation data.
|
|
459
|
+
// This exercises triggerRemoveHooksForEntityDeletion + collectComponentsFromRemoved
|
|
460
|
+
// + collectWildcardFromRemoved (required wildcard case).
|
|
461
|
+
world.delete(entity);
|
|
462
|
+
world.sync();
|
|
463
|
+
|
|
464
|
+
expect(removeCalls.length).toBe(1);
|
|
465
|
+
expect(removeCalls[0]!.entityId).toBe(entity);
|
|
466
|
+
expect(removeCalls[0]!.relations).toContainEqual([target, { value: "to-be-deleted" }]);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("should trigger on_remove via deletion fast-path for optional wildcard in multi-hook", () => {
|
|
470
|
+
const world = new World();
|
|
471
|
+
const A = component<number>();
|
|
472
|
+
const RelData = component<{ value: string }>();
|
|
473
|
+
const target = world.new();
|
|
474
|
+
const wildcardRel = relation(RelData, "*");
|
|
475
|
+
|
|
476
|
+
const removeCalls: { entityId: EntityId; components: any }[] = [];
|
|
477
|
+
|
|
478
|
+
world.hook([A, { optional: wildcardRel }], {
|
|
479
|
+
on_remove: (entityId, ...components) => {
|
|
480
|
+
removeCalls.push({ entityId, components });
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const entity = world.spawn().with(A, 123).with(relation(RelData, target), { value: "gone-soon" }).build();
|
|
485
|
+
world.sync();
|
|
486
|
+
|
|
487
|
+
// Delete the entity: fast path should use collectComponentsFromRemoved which handles
|
|
488
|
+
// optional wildcard via collectWildcardFromRemoved and returns { value: [...] } or undefined.
|
|
489
|
+
world.delete(entity);
|
|
490
|
+
world.sync();
|
|
491
|
+
|
|
492
|
+
expect(removeCalls.length).toBe(1);
|
|
493
|
+
expect(removeCalls[0]!.entityId).toBe(entity);
|
|
494
|
+
expect(removeCalls[0]!.components[0]).toBe(123);
|
|
495
|
+
const opt = removeCalls[0]!.components[1];
|
|
496
|
+
expect(opt).toBeDefined();
|
|
497
|
+
expect(Array.isArray(opt.value)).toBe(true);
|
|
498
|
+
expect(opt.value).toContainEqual([target, { value: "gone-soon" }]);
|
|
499
|
+
});
|
|
373
500
|
});
|
|
@@ -4,13 +4,14 @@ import {
|
|
|
4
4
|
getComponentIdFromRelationId,
|
|
5
5
|
getDetailedIdType,
|
|
6
6
|
getIdType,
|
|
7
|
-
|
|
7
|
+
isSparseComponent,
|
|
8
8
|
isWildcardRelationId,
|
|
9
9
|
} from "../entity";
|
|
10
|
+
import type { SerializedComponent, SerializedEntity, SerializedEntityId } from "../storage/serialization";
|
|
10
11
|
import { isOptionalEntityId, type ComponentTuple, type ComponentType, type LifecycleHookEntry } from "../types";
|
|
11
12
|
import { getOrCompute } from "../utils/utils";
|
|
12
13
|
import { buildCacheKey, buildSingleComponent, getWildcardRelationDataSource, isRelationType } from "./helpers";
|
|
13
|
-
import type {
|
|
14
|
+
import type { SparseStore } from "./store";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Special value to represent missing component data
|
|
@@ -50,11 +51,10 @@ export class Archetype {
|
|
|
50
51
|
private entityToIndex: Map<EntityId, number> = new Map();
|
|
51
52
|
|
|
52
53
|
/**
|
|
53
|
-
*
|
|
54
|
-
* Uses optimized RelationEntry (single/multi) for the common exclusive case.
|
|
54
|
+
* SparseStore used for relations declared with `sparse: true`.
|
|
55
55
|
* See store.ts for implementation details.
|
|
56
56
|
*/
|
|
57
|
-
private
|
|
57
|
+
private sparseRelations: SparseStore;
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* Multi-hooks that match this archetype
|
|
@@ -66,10 +66,10 @@ export class Archetype {
|
|
|
66
66
|
*/
|
|
67
67
|
private componentDataSourcesCache: Map<string, (any[] | EntityId<any>[] | undefined)[]> = new Map();
|
|
68
68
|
|
|
69
|
-
constructor(componentTypes: EntityId<any>[],
|
|
69
|
+
constructor(componentTypes: EntityId<any>[], sparseStore: SparseStore) {
|
|
70
70
|
this.componentTypes = normalizeComponentTypes(componentTypes);
|
|
71
71
|
this.componentTypeSet = new Set(this.componentTypes);
|
|
72
|
-
this.
|
|
72
|
+
this.sparseRelations = sparseStore;
|
|
73
73
|
|
|
74
74
|
for (const componentType of this.componentTypes) {
|
|
75
75
|
this.componentData.set(componentType, []);
|
|
@@ -107,17 +107,17 @@ export class Archetype {
|
|
|
107
107
|
this.getComponentData(componentType).push(!componentData.has(componentType) ? MISSING_COMPONENT : data);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
// Add
|
|
111
|
-
this.
|
|
110
|
+
// Add sparse-stored relations separately
|
|
111
|
+
this.addSparseRelations(entityId, componentData);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
private
|
|
114
|
+
private addSparseRelations(entityId: EntityId, componentData: Map<EntityId<any>, any>): void {
|
|
115
115
|
for (const [componentType, data] of componentData) {
|
|
116
116
|
if (this.componentTypeSet.has(componentType)) continue;
|
|
117
117
|
|
|
118
118
|
const detailedType = getDetailedIdType(componentType);
|
|
119
|
-
if (isRelationType(detailedType) &&
|
|
120
|
-
this.
|
|
119
|
+
if (isRelationType(detailedType) && isSparseComponent(detailedType.componentId!)) {
|
|
120
|
+
this.sparseRelations.setValue(entityId, componentType, data);
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
}
|
|
@@ -134,9 +134,9 @@ export class Archetype {
|
|
|
134
134
|
entityData.set(componentType, data === MISSING_COMPONENT ? undefined : data);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
// Add
|
|
138
|
-
const
|
|
139
|
-
for (const [componentType, data] of
|
|
137
|
+
// Add sparse-stored relations
|
|
138
|
+
const sparseTuples = this.sparseRelations.getAllForEntity(entityId);
|
|
139
|
+
for (const [componentType, data] of sparseTuples) {
|
|
140
140
|
entityData.set(componentType, data);
|
|
141
141
|
}
|
|
142
142
|
|
|
@@ -144,13 +144,11 @@ export class Archetype {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
/**
|
|
147
|
-
* Returns all
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* Prefer the new DontFragmentStore methods when possible.
|
|
147
|
+
* Returns all sparse-stored relations for the given entity.
|
|
148
|
+
* Internal helper used by command processing and tests.
|
|
151
149
|
*/
|
|
152
|
-
|
|
153
|
-
const tuples = this.
|
|
150
|
+
getEntitySparseRelations(entityId: EntityId): Map<EntityId<any>, any> | undefined {
|
|
151
|
+
const tuples = this.sparseRelations.getAllForEntity(entityId);
|
|
154
152
|
if (tuples.length === 0) return undefined;
|
|
155
153
|
|
|
156
154
|
const map = new Map<EntityId<any>, any>();
|
|
@@ -169,8 +167,8 @@ export class Archetype {
|
|
|
169
167
|
components.set(componentType, data === MISSING_COMPONENT ? undefined : data);
|
|
170
168
|
}
|
|
171
169
|
|
|
172
|
-
const
|
|
173
|
-
for (const [componentType, data] of
|
|
170
|
+
const sparseTuples = this.sparseRelations.getAllForEntity(entity);
|
|
171
|
+
for (const [componentType, data] of sparseTuples) {
|
|
174
172
|
components.set(componentType, data);
|
|
175
173
|
}
|
|
176
174
|
|
|
@@ -178,6 +176,61 @@ export class Archetype {
|
|
|
178
176
|
});
|
|
179
177
|
}
|
|
180
178
|
|
|
179
|
+
/**
|
|
180
|
+
* @internal Serialization fast-path.
|
|
181
|
+
*
|
|
182
|
+
* Appends SerializedEntity records directly from the archetype's column storage
|
|
183
|
+
* (componentData arrays) plus sparse relations, avoiding per-entity Map
|
|
184
|
+
* allocation and repeated Array.from(entries()).
|
|
185
|
+
*
|
|
186
|
+
* Component type IDs should be pre-encoded by the caller (once per archetype)
|
|
187
|
+
* and passed in `encodedComponentTypes` (same order and length as this.componentTypes).
|
|
188
|
+
*
|
|
189
|
+
* The provided `encode` function should be the cached variant for best performance
|
|
190
|
+
* on entity IDs and any sparse relation type IDs.
|
|
191
|
+
*
|
|
192
|
+
* `sparseByEntity` is an optional pre-fetched map from a bulk
|
|
193
|
+
* `SparseStore.getAllForEntities` call (further reduces per-entity calls).
|
|
194
|
+
*/
|
|
195
|
+
appendSerializedEntities(
|
|
196
|
+
out: SerializedEntity[],
|
|
197
|
+
encode: (id: EntityId<any>) => SerializedEntityId,
|
|
198
|
+
encodedComponentTypes: SerializedEntityId[],
|
|
199
|
+
sparseByEntity?: Map<EntityId, Array<[EntityId<any>, any]>>,
|
|
200
|
+
): void {
|
|
201
|
+
if (encodedComponentTypes.length !== this.componentTypes.length) {
|
|
202
|
+
throw new Error("encodedComponentTypes length must match archetype componentTypes");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (let i = 0; i < this.entities.length; i++) {
|
|
206
|
+
const entity = this.entities[i]!;
|
|
207
|
+
|
|
208
|
+
const components: SerializedComponent[] = [];
|
|
209
|
+
// Regular (non-sparse) components from column arrays
|
|
210
|
+
for (let c = 0; c < this.componentTypes.length; c++) {
|
|
211
|
+
const data = this.getComponentData(this.componentTypes[c]!)[i];
|
|
212
|
+
components.push({
|
|
213
|
+
type: encodedComponentTypes[c]!,
|
|
214
|
+
value: data === MISSING_COMPONENT ? undefined : data,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Append any sparse relations for this entity (usually small or zero)
|
|
219
|
+
const sparseTuples = sparseByEntity?.get(entity) ?? this.sparseRelations.getAllForEntity(entity);
|
|
220
|
+
for (const [componentType, data] of sparseTuples) {
|
|
221
|
+
components.push({
|
|
222
|
+
type: encode(componentType),
|
|
223
|
+
value: data,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
out.push({
|
|
228
|
+
id: encode(entity),
|
|
229
|
+
components,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
181
234
|
removeEntity(entityId: EntityId): Map<EntityId<any>, any> | undefined {
|
|
182
235
|
const index = this.entityToIndex.get(entityId);
|
|
183
236
|
if (index === undefined) return undefined;
|
|
@@ -188,12 +241,12 @@ export class Archetype {
|
|
|
188
241
|
removedData.set(componentType, this.getComponentData(componentType)[index]);
|
|
189
242
|
}
|
|
190
243
|
|
|
191
|
-
// Include
|
|
192
|
-
const
|
|
193
|
-
for (const [componentType, data] of
|
|
244
|
+
// Include sparse relations
|
|
245
|
+
const sparseTuples = this.sparseRelations.getAllForEntity(entityId);
|
|
246
|
+
for (const [componentType, data] of sparseTuples) {
|
|
194
247
|
removedData.set(componentType, data);
|
|
195
248
|
}
|
|
196
|
-
this.
|
|
249
|
+
this.sparseRelations.deleteEntity(entityId);
|
|
197
250
|
|
|
198
251
|
this.entityToIndex.delete(entityId);
|
|
199
252
|
|
|
@@ -257,9 +310,9 @@ export class Archetype {
|
|
|
257
310
|
}
|
|
258
311
|
}
|
|
259
312
|
|
|
260
|
-
// Check
|
|
313
|
+
// Check sparse relations (now uses the efficient per-component path)
|
|
261
314
|
if (componentId !== undefined) {
|
|
262
|
-
const matches = this.
|
|
315
|
+
const matches = this.sparseRelations.getRelationsForComponent(entityId, componentId);
|
|
263
316
|
for (const m of matches) relations.push(m);
|
|
264
317
|
}
|
|
265
318
|
|
|
@@ -275,14 +328,11 @@ export class Archetype {
|
|
|
275
328
|
return data as T;
|
|
276
329
|
}
|
|
277
330
|
|
|
278
|
-
const value = this.
|
|
279
|
-
if (
|
|
280
|
-
value !== undefined ||
|
|
281
|
-
this.dontFragmentRelations.getAllForEntity(entityId).some(([t]) => t === componentType)
|
|
282
|
-
) {
|
|
331
|
+
const value = this.sparseRelations.getValue(entityId, componentType);
|
|
332
|
+
if (value !== undefined || this.sparseRelations.getAllForEntity(entityId).some(([t]) => t === componentType)) {
|
|
283
333
|
// Note: the extra check above handles the (rare) case where `undefined` is a legitimate stored value.
|
|
284
334
|
// For the common case we just return whatever getValue gave us.
|
|
285
|
-
return this.
|
|
335
|
+
return this.sparseRelations.getValue(entityId, componentType);
|
|
286
336
|
}
|
|
287
337
|
|
|
288
338
|
throw new Error(`Component type ${componentType} not found for entity ${entityId}`);
|
|
@@ -300,14 +350,14 @@ export class Archetype {
|
|
|
300
350
|
return { value: data as T };
|
|
301
351
|
}
|
|
302
352
|
|
|
303
|
-
const value = this.
|
|
353
|
+
const value = this.sparseRelations.getValue(entityId, componentType);
|
|
304
354
|
// We use getAllForEntity only as a presence check when the value itself might be undefined.
|
|
305
355
|
if (value !== undefined) {
|
|
306
356
|
return { value };
|
|
307
357
|
}
|
|
308
|
-
const all = this.
|
|
358
|
+
const all = this.sparseRelations.getAllForEntity(entityId);
|
|
309
359
|
if (all.some(([t]) => t === componentType)) {
|
|
310
|
-
return { value: this.
|
|
360
|
+
return { value: this.sparseRelations.getValue(entityId, componentType) };
|
|
311
361
|
}
|
|
312
362
|
return undefined;
|
|
313
363
|
}
|
|
@@ -324,8 +374,8 @@ export class Archetype {
|
|
|
324
374
|
}
|
|
325
375
|
|
|
326
376
|
const detailedType = getDetailedIdType(componentType);
|
|
327
|
-
if (isRelationType(detailedType) &&
|
|
328
|
-
this.
|
|
377
|
+
if (isRelationType(detailedType) && isSparseComponent(detailedType.componentId!)) {
|
|
378
|
+
this.sparseRelations.setValue(entityId, componentType, data);
|
|
329
379
|
return;
|
|
330
380
|
}
|
|
331
381
|
|
|
@@ -387,7 +437,7 @@ export class Archetype {
|
|
|
387
437
|
entityIndex,
|
|
388
438
|
entityId,
|
|
389
439
|
(type) => this.getComponentData(type),
|
|
390
|
-
this.
|
|
440
|
+
this.sparseRelations,
|
|
391
441
|
),
|
|
392
442
|
) as ComponentTuple<T>;
|
|
393
443
|
}
|
|
@@ -443,9 +493,9 @@ export class Archetype {
|
|
|
443
493
|
components.set(componentType, data === MISSING_COMPONENT ? undefined : data);
|
|
444
494
|
}
|
|
445
495
|
|
|
446
|
-
// Append
|
|
447
|
-
const
|
|
448
|
-
for (const [componentType, data] of
|
|
496
|
+
// Append sparse relations (entity-wide enumeration; acceptable cost for forEach)
|
|
497
|
+
const sparseTuples = this.sparseRelations.getAllForEntity(entity);
|
|
498
|
+
for (const [componentType, data] of sparseTuples) {
|
|
449
499
|
components.set(componentType, data);
|
|
450
500
|
}
|
|
451
501
|
|
|
@@ -462,11 +512,11 @@ export class Archetype {
|
|
|
462
512
|
}
|
|
463
513
|
}
|
|
464
514
|
|
|
465
|
-
// Check
|
|
515
|
+
// Check sparse relations only for entities that actually belong to *this* archetype.
|
|
466
516
|
// We must not use the global hasAnyForComponent here, otherwise unrelated archetypes
|
|
467
517
|
// can be incorrectly pulled into wildcard queries when any entity in the world has the relation.
|
|
468
518
|
for (const entityId of this.entities) {
|
|
469
|
-
const rels = this.
|
|
519
|
+
const rels = this.sparseRelations.getRelationsForComponent(entityId, componentId);
|
|
470
520
|
if (rels.length > 0) {
|
|
471
521
|
return true;
|
|
472
522
|
}
|
package/src/archetype/helpers.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "../entity";
|
|
9
9
|
import { isOptionalEntityId, type ComponentType } from "../types";
|
|
10
10
|
import { MISSING_COMPONENT } from "./archetype";
|
|
11
|
-
import type {
|
|
11
|
+
import type { SparseStore } from "./store";
|
|
12
12
|
|
|
13
13
|
type DetailedIdType = ReturnType<typeof getDetailedIdType>;
|
|
14
14
|
|
|
@@ -66,28 +66,6 @@ export function matchesRelationComponentId(componentType: EntityId<any>, compone
|
|
|
66
66
|
return isRelationType(detailedType) && detailedType.componentId === componentId;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
/**
|
|
70
|
-
* Find all relations in dontFragment data that match a component ID.
|
|
71
|
-
*
|
|
72
|
-
* @deprecated Prefer calling `DontFragmentStore.getRelationsForComponent` directly.
|
|
73
|
-
* This helper is kept temporarily for any remaining call sites during the refactor.
|
|
74
|
-
*/
|
|
75
|
-
export function findMatchingDontFragmentRelations(
|
|
76
|
-
dontFragmentData: Map<EntityId<any>, any> | undefined,
|
|
77
|
-
componentId: EntityId<any>,
|
|
78
|
-
relations: [EntityId<unknown>, any][] = [],
|
|
79
|
-
): [EntityId<unknown>, any][] {
|
|
80
|
-
if (!dontFragmentData) return relations;
|
|
81
|
-
|
|
82
|
-
for (const [relType, data] of dontFragmentData) {
|
|
83
|
-
const relDetailed = getDetailedIdType(relType);
|
|
84
|
-
if (isRelationType(relDetailed) && relDetailed.componentId === componentId) {
|
|
85
|
-
relations.push([relDetailed.targetId, data]);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return relations;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
69
|
/**
|
|
92
70
|
* Build cache key for component types
|
|
93
71
|
*/
|
|
@@ -109,13 +87,13 @@ export function getWildcardRelationDataSource(
|
|
|
109
87
|
|
|
110
88
|
/**
|
|
111
89
|
* Build wildcard relation value from matching relations.
|
|
112
|
-
*
|
|
90
|
+
* Receives the SparseStore directly for efficient per-component lookups.
|
|
113
91
|
*/
|
|
114
92
|
export function buildWildcardRelationValue(
|
|
115
93
|
wildcardRelationType: WildcardRelationId<any>,
|
|
116
94
|
matchingRelations: EntityId<any>[] | undefined,
|
|
117
95
|
getDataAtIndex: (relType: EntityId<any>) => any,
|
|
118
|
-
|
|
96
|
+
sparseStore: SparseStore,
|
|
119
97
|
entityId: EntityId,
|
|
120
98
|
optional: boolean,
|
|
121
99
|
): any {
|
|
@@ -129,9 +107,9 @@ export function buildWildcardRelationValue(
|
|
|
129
107
|
relations.push([targetId, data === MISSING_COMPONENT ? undefined : data]);
|
|
130
108
|
}
|
|
131
109
|
|
|
132
|
-
// Add
|
|
110
|
+
// Add sparse relations using the efficient store API (critical for wildcard query performance)
|
|
133
111
|
if (targetComponentId !== undefined) {
|
|
134
|
-
const dfMatches =
|
|
112
|
+
const dfMatches = sparseStore.getRelationsForComponent(entityId, targetComponentId);
|
|
135
113
|
for (const m of dfMatches) {
|
|
136
114
|
relations.push(m);
|
|
137
115
|
}
|
|
@@ -173,7 +151,7 @@ export function buildSingleComponent(
|
|
|
173
151
|
entityIndex: number,
|
|
174
152
|
entityId: EntityId,
|
|
175
153
|
getComponentData: (type: EntityId<any>) => any[],
|
|
176
|
-
|
|
154
|
+
sparseRelations: SparseStore,
|
|
177
155
|
): any {
|
|
178
156
|
const optional = isOptionalEntityId(compType);
|
|
179
157
|
const actualType = optional ? compType.optional : compType;
|
|
@@ -183,7 +161,7 @@ export function buildSingleComponent(
|
|
|
183
161
|
actualType as WildcardRelationId<any>,
|
|
184
162
|
dataSource as EntityId<any>[] | undefined,
|
|
185
163
|
(relType) => getComponentData(relType)[entityIndex],
|
|
186
|
-
|
|
164
|
+
sparseRelations,
|
|
187
165
|
entityId,
|
|
188
166
|
optional,
|
|
189
167
|
);
|
package/src/archetype/store.ts
CHANGED
|
@@ -10,46 +10,49 @@ type RelationEntry =
|
|
|
10
10
|
| { type: "multi"; targets: Map<EntityId, { relationType: EntityId<any>; data: any }> };
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Interface for
|
|
13
|
+
* Interface for the sparse side store used by components declared with `sparse: true`
|
|
14
|
+
* (or the legacy `dontFragment: true` alias).
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* wildcard-related paths (hasRelationWithComponentId, wildcard materialization
|
|
18
|
-
* during iteration, hook matching, etc.).
|
|
16
|
+
* Relation data for these components lives here instead of in archetype columns,
|
|
17
|
+
* preventing fragmentation for high-cardinality or frequently-changing relations.
|
|
19
18
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* The interface no longer leaks internal Map structures. Callers work with
|
|
25
|
-
* semantic operations only.
|
|
19
|
+
* Storage is primarily keyed by base relation ComponentId. This enables efficient
|
|
20
|
+
* per-component lookups required by wildcard queries (relation(Comp, "*")) and
|
|
21
|
+
* archetype filtering, while still supporting full-entity enumeration when needed.
|
|
26
22
|
*/
|
|
27
|
-
export interface
|
|
28
|
-
// High-frequency operations (
|
|
23
|
+
export interface SparseStore {
|
|
24
|
+
// High-frequency per-(entity, relation) operations (get/set/has/remove, structural changes)
|
|
29
25
|
getValue(entityId: EntityId, relationType: EntityId<any>): any | undefined;
|
|
30
26
|
setValue(entityId: EntityId, relationType: EntityId<any>, data: any): void;
|
|
31
27
|
deleteValue(entityId: EntityId, relationType: EntityId<any>): boolean;
|
|
32
28
|
|
|
33
|
-
//
|
|
29
|
+
// Hot paths for wildcard queries and archetype filtering (per-component lookups)
|
|
34
30
|
hasAnyForComponent(componentId: EntityId<any>): boolean;
|
|
35
31
|
getRelationsForComponent(entityId: EntityId, componentId: EntityId<any>): [target: EntityId, data: any][];
|
|
36
32
|
|
|
37
|
-
//
|
|
33
|
+
// Entity-wide enumeration paths (used for snapshots, serialization, forEach, and rare presence checks)
|
|
38
34
|
getAllForEntity(entityId: EntityId): Array<[relationType: EntityId<any>, data: any]>;
|
|
39
35
|
deleteEntity(entityId: EntityId): void;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @internal Bulk helper for serialization of many entities.
|
|
39
|
+
* Default implementation simply loops getAllForEntity; subclasses / future
|
|
40
|
+
* implementations can provide a more efficient fused walk.
|
|
41
|
+
*/
|
|
42
|
+
getAllForEntities(entityIds: readonly EntityId[]): Map<EntityId, Array<[EntityId<any>, any]>>;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
/**
|
|
43
|
-
* Production implementation of
|
|
46
|
+
* Production implementation of SparseStore.
|
|
44
47
|
*
|
|
45
48
|
* Internal layout (optimized):
|
|
46
49
|
* - byComponent: baseComponentId → (entityId → RelationEntry)
|
|
47
50
|
* RelationEntry uses a single-value form for the common exclusive case (1 target),
|
|
48
|
-
* avoiding Map allocation
|
|
51
|
+
* avoiding Map allocation for the vast majority of usage.
|
|
49
52
|
* - entityIndex: entityId → Set<baseComponentId>
|
|
50
53
|
* Lightweight reverse index.
|
|
51
54
|
*/
|
|
52
|
-
export class
|
|
55
|
+
export class SparseStoreImpl implements SparseStore {
|
|
53
56
|
/**
|
|
54
57
|
* Primary storage, keyed by the base relation component ID.
|
|
55
58
|
*/
|
|
@@ -60,7 +63,8 @@ export class DontFragmentStoreImpl implements DontFragmentStore {
|
|
|
60
63
|
|
|
61
64
|
/**
|
|
62
65
|
* Reverse index: which base component kinds an entity participates in.
|
|
63
|
-
*
|
|
66
|
+
* Only required to support getAllForEntity and deleteEntity efficiently.
|
|
67
|
+
* The primary storage (byComponent) is deliberately not optimized for these operations.
|
|
64
68
|
*/
|
|
65
69
|
private entityIndex = new Map<EntityId, Set<EntityId<any>>>();
|
|
66
70
|
|
|
@@ -87,7 +91,7 @@ export class DontFragmentStoreImpl implements DontFragmentStore {
|
|
|
87
91
|
setValue(entityId: EntityId, relationType: EntityId<any>, data: any): void {
|
|
88
92
|
const componentId = getComponentIdFromRelationId(relationType);
|
|
89
93
|
if (componentId === undefined) {
|
|
90
|
-
throw new Error("setValue called with a non-relation type on
|
|
94
|
+
throw new Error("setValue called with a non-relation type on SparseStore");
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
let entities = this.byComponent.get(componentId);
|
|
@@ -237,4 +241,15 @@ export class DontFragmentStoreImpl implements DontFragmentStore {
|
|
|
237
241
|
|
|
238
242
|
this.entityIndex.delete(entityId);
|
|
239
243
|
}
|
|
244
|
+
|
|
245
|
+
getAllForEntities(entityIds: readonly EntityId[]): Map<EntityId, Array<[EntityId<any>, any]>> {
|
|
246
|
+
const result = new Map<EntityId, Array<[EntityId<any>, any]>>();
|
|
247
|
+
for (const eid of entityIds) {
|
|
248
|
+
const data = this.getAllForEntity(eid);
|
|
249
|
+
if (data.length > 0) {
|
|
250
|
+
result.set(eid, data);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
240
255
|
}
|
package/src/commands/buffer.ts
CHANGED
|
@@ -55,9 +55,10 @@ export class CommandBuffer {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
* Execute all commands and clear the buffer
|
|
58
|
+
* Execute all commands and clear the buffer.
|
|
59
|
+
* Returns the number of iterations performed (for debug stats).
|
|
59
60
|
*/
|
|
60
|
-
execute():
|
|
61
|
+
execute(): number {
|
|
61
62
|
let iterations = 0;
|
|
62
63
|
|
|
63
64
|
while (this.commands.length > 0) {
|
|
@@ -92,6 +93,8 @@ export class CommandBuffer {
|
|
|
92
93
|
}
|
|
93
94
|
entityCommands.clear();
|
|
94
95
|
}
|
|
96
|
+
|
|
97
|
+
return iterations;
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
/**
|
|
@@ -70,35 +70,4 @@ export class ComponentChangeset {
|
|
|
70
70
|
|
|
71
71
|
return existingComponents;
|
|
72
72
|
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Get the final component types after applying the changeset
|
|
76
|
-
* @param existingComponentTypes - The current component types on the entity
|
|
77
|
-
* @returns The final component types or undefined if no changes
|
|
78
|
-
*/
|
|
79
|
-
getFinalComponentTypes(existingComponentTypes: EntityId<any>[]): EntityId<any>[] | undefined {
|
|
80
|
-
const finalComponentTypes = new Set<EntityId<any>>(existingComponentTypes);
|
|
81
|
-
let changed = false;
|
|
82
|
-
|
|
83
|
-
// Apply removals
|
|
84
|
-
for (const componentType of this.removes) {
|
|
85
|
-
if (!finalComponentTypes.has(componentType)) {
|
|
86
|
-
this.removes.delete(componentType);
|
|
87
|
-
continue; // Component not present, skip
|
|
88
|
-
}
|
|
89
|
-
changed = true;
|
|
90
|
-
finalComponentTypes.delete(componentType);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Apply additions
|
|
94
|
-
for (const componentType of this.adds.keys()) {
|
|
95
|
-
if (finalComponentTypes.has(componentType)) {
|
|
96
|
-
continue; // Component already present, skip
|
|
97
|
-
}
|
|
98
|
-
changed = true;
|
|
99
|
-
finalComponentTypes.add(componentType);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return changed ? Array.from(finalComponentTypes) : undefined;
|
|
103
|
-
}
|
|
104
73
|
}
|