@codehz/ecs 0.8.1 → 0.9.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 +28 -3
- package/dist/builder.d.mts +296 -46
- 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 +452 -179
- 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/package.json +1 -1
- package/skills/ecs/SKILL.md +9 -4
- package/src/__tests__/component/singleton.test.ts +40 -1
- 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 +134 -1
- package/src/__tests__/world/commands.test.ts +337 -0
- 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 +13 -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/commands.ts +44 -56
- package/src/world/hooks.ts +8 -0
- package/src/world/serialization.ts +32 -18
- package/src/world/world.ts +387 -20
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
|
}
|
|
@@ -138,7 +138,7 @@ export interface ComponentOptions<T = any> {
|
|
|
138
138
|
* `cascadeDelete`, deleting the target entity will both (a) delete the
|
|
139
139
|
* referencing entity, and (b) the exclusivity constraint prevents the
|
|
140
140
|
* entity from having multiple cascade-delete relations of the same type.
|
|
141
|
-
* - **`
|
|
141
|
+
* - **`sparse`**: Compatible. Exclusivity is enforced at the data level
|
|
142
142
|
* regardless of whether the archetype is fragmented.
|
|
143
143
|
*
|
|
144
144
|
* @example
|
|
@@ -196,7 +196,8 @@ export interface ComponentOptions<T = any> {
|
|
|
196
196
|
*/
|
|
197
197
|
cascadeDelete?: boolean;
|
|
198
198
|
/**
|
|
199
|
-
* If true, relations with this component will not cause
|
|
199
|
+
* If true, relations with this component use sparse storage and will not cause
|
|
200
|
+
* archetype fragmentation.
|
|
200
201
|
*
|
|
201
202
|
* **Problem it solves**: By default, each unique relation pair `(component, target)`
|
|
202
203
|
* creates a **separate archetype**. If 100 entities each have a `ChildOf` relation
|
|
@@ -204,28 +205,28 @@ export interface ComponentOptions<T = any> {
|
|
|
204
205
|
* Queries that iterate over all entities with a `ChildOf` relation must check all
|
|
205
206
|
* 100 archetypes, which degrades iteration performance and increases memory overhead.
|
|
206
207
|
*
|
|
207
|
-
* **How it works**: When `
|
|
208
|
-
* contribute to the archetype signature. Entities with different targets
|
|
209
|
-
* relation component share a **single archetype**, and the per-entity
|
|
210
|
-
* stored in a separate
|
|
211
|
-
* A wildcard relation marker (`relation(Comp, "*")`) is placed
|
|
212
|
-
* component list so queries can still discover matching archetypes.
|
|
208
|
+
* **How it works (sparse storage)**: When `sparse` is enabled, the relation's target
|
|
209
|
+
* does **not** contribute to the archetype signature. Entities with different targets
|
|
210
|
+
* for the same relation component share a **single archetype**, and the per-entity
|
|
211
|
+
* target data is stored in a separate side store (historically called
|
|
212
|
+
* `DontFragmentStore`, now `SparseStore`). A wildcard relation marker (`relation(Comp, "*")`) is placed
|
|
213
|
+
* in the archetype component list so queries can still discover matching archetypes.
|
|
213
214
|
*
|
|
214
215
|
* **Use cases**:
|
|
215
216
|
* - **Hierarchy/ownership**: `ChildOf` relations where thousands of entities each
|
|
216
217
|
* point to different parent entities.
|
|
217
218
|
* - **Dynamic targeting**: Relations where targets change frequently (e.g., AI
|
|
218
|
-
* targeting, inventory slots) — without `
|
|
219
|
-
*
|
|
219
|
+
* targeting, inventory slots) — without `sparse`, each target change would cause
|
|
220
|
+
* an archetype migration, which is expensive.
|
|
220
221
|
* - **High-cardinality relations**: Any relation where the number of unique targets
|
|
221
222
|
* is large compared to the number of entities.
|
|
222
223
|
*
|
|
223
224
|
* **Performance implications**:
|
|
224
|
-
* - **Without `
|
|
225
|
+
* - **Without `sparse`**: Archetype count grows linearly with unique targets.
|
|
225
226
|
* Each archetype migration (changing a relation target) requires moving the entity's
|
|
226
227
|
* data between component arrays.
|
|
227
|
-
* - **With `
|
|
228
|
-
*
|
|
228
|
+
* - **With `sparse`**: Archetype count stays constant regardless of target diversity.
|
|
229
|
+
* Changing a relation target is an O(1) update in the sparse side store.
|
|
229
230
|
* The trade-off is an extra map lookup when accessing the relation data.
|
|
230
231
|
*
|
|
231
232
|
* **Constraints**:
|
|
@@ -234,13 +235,16 @@ export interface ComponentOptions<T = any> {
|
|
|
234
235
|
* archetype carries a wildcard marker so queries can discover it.
|
|
235
236
|
* - Works with `exclusive` and `cascadeDelete` simultaneously.
|
|
236
237
|
*
|
|
238
|
+
* **Backward compatibility**: The legacy key `dontFragment` is still accepted and
|
|
239
|
+
* behaves identically. Prefer `sparse` in new code.
|
|
240
|
+
*
|
|
237
241
|
* @example
|
|
238
242
|
* ```ts
|
|
239
|
-
* // Without
|
|
243
|
+
* // Without sparse: 100 entities with different parents = 100 archetypes
|
|
240
244
|
* const ChildOf = component(); // default: fragmentation happens
|
|
241
245
|
*
|
|
242
|
-
* // With
|
|
243
|
-
* const ChildOf = component({
|
|
246
|
+
* // With sparse: 100 entities with different parents = 1 archetype
|
|
247
|
+
* const ChildOf = component({ sparse: true });
|
|
244
248
|
*
|
|
245
249
|
* for (let i = 0; i < 100; i++) {
|
|
246
250
|
* const parent = world.new();
|
|
@@ -249,11 +253,17 @@ export interface ComponentOptions<T = any> {
|
|
|
249
253
|
* world.set(child, relation(ChildOf, parent));
|
|
250
254
|
* }
|
|
251
255
|
* world.sync();
|
|
252
|
-
* //
|
|
256
|
+
* // sparse: 1 archetype for all 100 entities
|
|
253
257
|
* // without: 100 archetypes, one per unique parent
|
|
254
258
|
* ```
|
|
255
259
|
*
|
|
256
|
-
* Inspired by Flecs' `DontFragment` trait.
|
|
260
|
+
* Inspired by Flecs' `DontFragment` trait (now exposed as the clearer `sparse` option).
|
|
261
|
+
*/
|
|
262
|
+
sparse?: boolean;
|
|
263
|
+
/**
|
|
264
|
+
* @deprecated Use `sparse: true` instead. This key is kept solely for backward
|
|
265
|
+
* compatibility; `component({ dontFragment: true })` continues to work exactly
|
|
266
|
+
* as before and is equivalent to `sparse: true`.
|
|
257
267
|
*/
|
|
258
268
|
dontFragment?: boolean;
|
|
259
269
|
/**
|
|
@@ -324,7 +334,7 @@ const componentNames: (string | undefined)[] = new Array(COMPONENT_ID_MAX + 1);
|
|
|
324
334
|
// BitSets for fast component option checks (Component ID range: 1-1023)
|
|
325
335
|
const exclusiveFlags = new BitSet(COMPONENT_ID_MAX + 1);
|
|
326
336
|
const cascadeDeleteFlags = new BitSet(COMPONENT_ID_MAX + 1);
|
|
327
|
-
const
|
|
337
|
+
const sparseFlags = new BitSet(COMPONENT_ID_MAX + 1);
|
|
328
338
|
const componentMerges: (ComponentMerge<any> | undefined)[] = new Array(COMPONENT_ID_MAX + 1);
|
|
329
339
|
|
|
330
340
|
/**
|
|
@@ -370,7 +380,8 @@ export function component<T = void>(nameOrOptions?: string | ComponentOptions<T>
|
|
|
370
380
|
// Set bitset flags for fast lookup
|
|
371
381
|
if (options.exclusive) exclusiveFlags.set(id);
|
|
372
382
|
if (options.cascadeDelete) cascadeDeleteFlags.set(id);
|
|
373
|
-
|
|
383
|
+
// Support both `sparse` (preferred) and the legacy `dontFragment` alias for BC
|
|
384
|
+
if (options.sparse || options.dontFragment) sparseFlags.set(id);
|
|
374
385
|
if (options.merge) componentMerges[id] = options.merge;
|
|
375
386
|
}
|
|
376
387
|
|
|
@@ -406,12 +417,14 @@ export function getComponentOptions<T = any>(id: ComponentId<T>): ComponentOptio
|
|
|
406
417
|
const hasName = componentNames[id] !== undefined;
|
|
407
418
|
const hasExclusive = exclusiveFlags.has(id);
|
|
408
419
|
const hasCascadeDelete = cascadeDeleteFlags.has(id);
|
|
409
|
-
const
|
|
420
|
+
const hasSparse = sparseFlags.has(id);
|
|
410
421
|
return {
|
|
411
422
|
name: hasName ? componentNames[id] : undefined,
|
|
412
423
|
exclusive: hasExclusive ? true : undefined,
|
|
413
424
|
cascadeDelete: hasCascadeDelete ? true : undefined,
|
|
414
|
-
|
|
425
|
+
sparse: hasSparse ? true : undefined,
|
|
426
|
+
// For full backward compatibility with code that inspects options.dontFragment
|
|
427
|
+
dontFragment: hasSparse ? true : undefined,
|
|
415
428
|
merge: componentMerges[id] as ComponentMerge<T> | undefined,
|
|
416
429
|
};
|
|
417
430
|
}
|
|
@@ -487,20 +500,21 @@ export function isCascadeDeleteComponent(id: ComponentId<any>): boolean {
|
|
|
487
500
|
}
|
|
488
501
|
|
|
489
502
|
/**
|
|
490
|
-
* Check if a component is marked as `
|
|
503
|
+
* Check if a component is marked as `sparse` (sparse storage for relations).
|
|
491
504
|
*
|
|
492
|
-
* When a component has `
|
|
493
|
-
*
|
|
494
|
-
*
|
|
505
|
+
* When a component has `sparse: true`, relations using it do not cause archetype
|
|
506
|
+
* fragmentation — entities with different relation targets can share the same
|
|
507
|
+
* archetype. This is a fast O(1) bitset lookup. The legacy `dontFragment` key
|
|
508
|
+
* is still accepted and sets the same internal flag.
|
|
495
509
|
*
|
|
496
510
|
* @param id - The component ID to check.
|
|
497
|
-
* @returns `true` if the component was created with `
|
|
511
|
+
* @returns `true` if the component was created with `sparse: true` (or the
|
|
512
|
+
* legacy `dontFragment: true`).
|
|
498
513
|
*
|
|
499
|
-
* @see {@link ComponentOptions.
|
|
500
|
-
* `dontFragment` prevents archetype fragmentation.
|
|
514
|
+
* @see {@link ComponentOptions.sparse} for the full explanation of sparse storage.
|
|
501
515
|
*/
|
|
502
|
-
export function
|
|
503
|
-
return
|
|
516
|
+
export function isSparseComponent(id: ComponentId<any>): boolean {
|
|
517
|
+
return sparseFlags.has(id);
|
|
504
518
|
}
|
|
505
519
|
|
|
506
520
|
/**
|
|
@@ -511,9 +525,8 @@ export function isDontFragmentComponent(id: ComponentId<any>): boolean {
|
|
|
511
525
|
* ID and checking: (1) the ID is a valid relation, (2) the component ID is in the
|
|
512
526
|
* valid range, (3) the target satisfies the condition, and (4) the flag bit is set.
|
|
513
527
|
*
|
|
514
|
-
* Used as the fast-path implementation for `
|
|
515
|
-
* `
|
|
516
|
-
* and `isCascadeDeleteRelation`.
|
|
528
|
+
* Used as the fast-path implementation for `isSparseRelation`, `isSparseWildcard`,
|
|
529
|
+
* `isExclusiveRelation`, `isExclusiveWildcard`, and `isCascadeDeleteRelation`.
|
|
517
530
|
*
|
|
518
531
|
* @param id - The entity/relation ID to check.
|
|
519
532
|
* @param flagBitSet - The bitset tracking which component IDs have the flag.
|
|
@@ -534,53 +547,41 @@ function checkRelationFlag(
|
|
|
534
547
|
}
|
|
535
548
|
|
|
536
549
|
/**
|
|
537
|
-
* Check if an ID is a specific (non-wildcard) relation backed by a `
|
|
538
|
-
* component.
|
|
550
|
+
* Check if an ID is a specific (non-wildcard) relation backed by a `sparse`
|
|
551
|
+
* component (i.e. stored in the side sparse store rather than the archetype).
|
|
539
552
|
*
|
|
540
553
|
* This is used in hot paths (archetype resolution, command processing) to determine
|
|
541
|
-
* whether a relation should be excluded from the archetype signature.
|
|
542
|
-
* `dontFragment` components are stored in the shared {@link DontFragmentStore} instead
|
|
543
|
-
* of being part of the archetype's component type list.
|
|
544
|
-
*
|
|
545
|
-
* This is an optimized function that avoids the overhead of `getDetailedIdType`
|
|
546
|
-
* by directly decoding and checking the relation's component ID against the
|
|
547
|
-
* `dontFragment` bitset.
|
|
554
|
+
* whether a relation should be excluded from the archetype signature.
|
|
548
555
|
*
|
|
549
556
|
* @param id - The entity/relation ID to check (must be a relation ID, not a plain
|
|
550
557
|
* component ID).
|
|
551
558
|
* @returns `true` if this is a specific-target relation (not wildcard) whose base
|
|
552
|
-
* component was created with `dontFragment: true
|
|
559
|
+
* component was created with `sparse: true` (or legacy `dontFragment: true`).
|
|
553
560
|
*
|
|
554
|
-
* @see {@link
|
|
555
|
-
* @see {@link ComponentOptions.
|
|
561
|
+
* @see {@link isSparseWildcard} for the wildcard variant.
|
|
562
|
+
* @see {@link ComponentOptions.sparse} for the full explanation.
|
|
556
563
|
*/
|
|
557
|
-
export function
|
|
558
|
-
return checkRelationFlag(id,
|
|
564
|
+
export function isSparseRelation(id: EntityId<any>): boolean {
|
|
565
|
+
return checkRelationFlag(id, sparseFlags, (targetId) => targetId !== WILDCARD_TARGET_ID);
|
|
559
566
|
}
|
|
560
567
|
|
|
561
568
|
/**
|
|
562
569
|
* Check if an ID is a wildcard relation (`relation(Comp, "*")`) backed by a
|
|
563
|
-
* `
|
|
564
|
-
*
|
|
565
|
-
* Wildcard markers for `dontFragment` components are placed in the archetype
|
|
566
|
-
* component list so that queries can discover archetypes containing entities
|
|
567
|
-
* with that relation type. This function is used in `filterRegularComponentTypes`
|
|
568
|
-
* to **keep** these wildcard markers in the archetype signature while stripping
|
|
569
|
-
* out specific-target `dontFragment` relations.
|
|
570
|
+
* `sparse` component.
|
|
570
571
|
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
572
|
+
* Wildcard markers for sparse components are placed in the archetype component
|
|
573
|
+
* list so that queries can discover archetypes containing entities with that
|
|
574
|
+
* relation type.
|
|
574
575
|
*
|
|
575
576
|
* @param id - The entity/relation ID to check.
|
|
576
577
|
* @returns `true` if this is a wildcard relation (`"*"` target) whose base
|
|
577
|
-
* component was created with `dontFragment: true
|
|
578
|
+
* component was created with `sparse: true` (or legacy `dontFragment: true`).
|
|
578
579
|
*
|
|
579
|
-
* @see {@link
|
|
580
|
-
* @see {@link ComponentOptions.
|
|
580
|
+
* @see {@link isSparseRelation} for the specific-target variant.
|
|
581
|
+
* @see {@link ComponentOptions.sparse} for the full explanation.
|
|
581
582
|
*/
|
|
582
|
-
export function
|
|
583
|
-
return checkRelationFlag(id,
|
|
583
|
+
export function isSparseWildcard(id: EntityId<any>): boolean {
|
|
584
|
+
return checkRelationFlag(id, sparseFlags, (targetId) => targetId === WILDCARD_TARGET_ID);
|
|
584
585
|
}
|
|
585
586
|
|
|
586
587
|
/**
|
package/src/entity/index.ts
CHANGED
|
@@ -54,10 +54,13 @@ export {
|
|
|
54
54
|
getComponentOptions,
|
|
55
55
|
isCascadeDeleteComponent,
|
|
56
56
|
isCascadeDeleteRelation,
|
|
57
|
-
isDontFragmentComponent,
|
|
58
|
-
isDontFragmentRelation,
|
|
59
|
-
isDontFragmentWildcard,
|
|
57
|
+
isSparseComponent as isDontFragmentComponent,
|
|
58
|
+
isSparseRelation as isDontFragmentRelation,
|
|
59
|
+
isSparseWildcard as isDontFragmentWildcard,
|
|
60
60
|
isExclusiveComponent,
|
|
61
61
|
isExclusiveRelation,
|
|
62
62
|
isExclusiveWildcard,
|
|
63
|
+
isSparseComponent,
|
|
64
|
+
isSparseRelation,
|
|
65
|
+
isSparseWildcard,
|
|
63
66
|
} from "../component/registry";
|
package/src/index.ts
CHANGED
|
@@ -39,3 +39,16 @@ export { Query } from "./query/query";
|
|
|
39
39
|
|
|
40
40
|
// Type utilities
|
|
41
41
|
export type { ComponentTuple, ComponentType, LifecycleCallback, LifecycleHook } from "./types";
|
|
42
|
+
|
|
43
|
+
// Debug / observability types
|
|
44
|
+
export type { DebugStatsCollector, SyncDebugStats } from "./types";
|
|
45
|
+
|
|
46
|
+
// Sparse / dontFragment flag checks (preferred + legacy aliases for BC)
|
|
47
|
+
export {
|
|
48
|
+
isSparseComponent as isDontFragmentComponent,
|
|
49
|
+
isSparseRelation as isDontFragmentRelation,
|
|
50
|
+
isSparseWildcard as isDontFragmentWildcard,
|
|
51
|
+
isSparseComponent,
|
|
52
|
+
isSparseRelation,
|
|
53
|
+
isSparseWildcard,
|
|
54
|
+
} from "./component/registry";
|
package/src/query/filter.ts
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import type { Archetype } from "../archetype/archetype";
|
|
2
2
|
import type { EntityId } from "../entity";
|
|
3
|
-
import {
|
|
4
|
-
getComponentIdFromRelationId,
|
|
5
|
-
getDetailedIdType,
|
|
6
|
-
isDontFragmentComponent,
|
|
7
|
-
isRelationId,
|
|
8
|
-
relation,
|
|
9
|
-
} from "../entity";
|
|
3
|
+
import { getComponentIdFromRelationId, getDetailedIdType, isRelationId, isSparseComponent, relation } from "../entity";
|
|
10
4
|
|
|
11
5
|
/**
|
|
12
6
|
* Filter options for queries
|
|
@@ -41,13 +35,13 @@ export function matchesComponentTypes(archetype: Archetype, componentTypes: Enti
|
|
|
41
35
|
} else if (
|
|
42
36
|
(detailedType.type === "entity-relation" || detailedType.type === "component-relation") &&
|
|
43
37
|
detailedType.componentId !== undefined &&
|
|
44
|
-
|
|
38
|
+
isSparseComponent(detailedType.componentId)
|
|
45
39
|
) {
|
|
46
|
-
// For specific
|
|
40
|
+
// For specific sparse relations, check if archetype has the wildcard marker
|
|
47
41
|
const wildcardMarker = relation(detailedType.componentId, "*");
|
|
48
42
|
return archetype.componentTypeSet.has(wildcardMarker);
|
|
49
43
|
} else {
|
|
50
|
-
// For regular components and non-
|
|
44
|
+
// For regular components and non-sparse relations, check direct inclusion
|
|
51
45
|
return archetype.componentTypeSet.has(type);
|
|
52
46
|
}
|
|
53
47
|
});
|
package/src/query/query.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Archetype } from "../archetype/archetype";
|
|
2
2
|
import { normalizeComponentTypes } from "../component/type-utils";
|
|
3
3
|
import type { EntityId, WildcardRelationId } from "../entity";
|
|
4
|
-
import { getDetailedIdType,
|
|
4
|
+
import { getDetailedIdType, isSparseComponent } from "../entity";
|
|
5
5
|
import type { ComponentTuple, ComponentType } from "../types";
|
|
6
6
|
import type { World } from "../world/world";
|
|
7
7
|
import { matchesComponentTypes, matchesFilter, type QueryFilter } from "./filter";
|
|
@@ -33,8 +33,8 @@ export class Query {
|
|
|
33
33
|
_cacheKey: string | undefined;
|
|
34
34
|
/** Cached wildcard component types for faster entity filtering */
|
|
35
35
|
private wildcardTypes: WildcardRelationId<any>[];
|
|
36
|
-
/** Cached specific
|
|
37
|
-
private
|
|
36
|
+
/** Cached specific sparse relation types that need entity-level filtering */
|
|
37
|
+
private specificSparseRelationTypes: EntityId<any>[];
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* @internal Queries should be created via {@link World.createQuery}, not instantiated directly.
|
|
@@ -47,13 +47,13 @@ export class Query {
|
|
|
47
47
|
this.wildcardTypes = this.componentTypes.filter(
|
|
48
48
|
(ct) => getDetailedIdType(ct).type === "wildcard-relation",
|
|
49
49
|
) as WildcardRelationId<any>[];
|
|
50
|
-
// Pre-compute specific
|
|
51
|
-
this.
|
|
50
|
+
// Pre-compute specific sparse relation types that need entity-level filtering
|
|
51
|
+
this.specificSparseRelationTypes = this.componentTypes.filter((ct) => {
|
|
52
52
|
const detailedType = getDetailedIdType(ct);
|
|
53
53
|
return (
|
|
54
54
|
(detailedType.type === "entity-relation" || detailedType.type === "component-relation") &&
|
|
55
55
|
detailedType.componentId !== undefined &&
|
|
56
|
-
|
|
56
|
+
isSparseComponent(detailedType.componentId)
|
|
57
57
|
);
|
|
58
58
|
});
|
|
59
59
|
this.updateCache();
|
|
@@ -86,8 +86,8 @@ export class Query {
|
|
|
86
86
|
getEntities(): EntityId[] {
|
|
87
87
|
this.ensureNotDisposed();
|
|
88
88
|
|
|
89
|
-
// Fast path: no wildcard relations and no specific
|
|
90
|
-
if (this.wildcardTypes.length === 0 && this.
|
|
89
|
+
// Fast path: no wildcard relations and no specific sparse relations
|
|
90
|
+
if (this.wildcardTypes.length === 0 && this.specificSparseRelationTypes.length === 0) {
|
|
91
91
|
const result: EntityId[] = [];
|
|
92
92
|
for (const archetype of this.cachedArchetypes) {
|
|
93
93
|
for (const entity of archetype.getEntities()) {
|
|
@@ -100,7 +100,7 @@ export class Query {
|
|
|
100
100
|
// Slow path: need to filter entities that actually have the required relations
|
|
101
101
|
// This is necessary for:
|
|
102
102
|
// 1. Wildcard relations where an archetype can contain entities with/without the relation
|
|
103
|
-
// 2. Specific
|
|
103
|
+
// 2. Specific sparse relations where the archetype only has the wildcard marker
|
|
104
104
|
const result: EntityId[] = [];
|
|
105
105
|
for (const archetype of this.cachedArchetypes) {
|
|
106
106
|
for (const entity of archetype.getEntities()) {
|
|
@@ -113,7 +113,7 @@ export class Query {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
|
-
* Check if entity matches all query requirements (wildcards and specific
|
|
116
|
+
* Check if entity matches all query requirements (wildcards and specific sparse relations)
|
|
117
117
|
*/
|
|
118
118
|
private entityMatchesQuery(archetype: Archetype, entity: EntityId): boolean {
|
|
119
119
|
// Check wildcard relations
|
|
@@ -124,8 +124,8 @@ export class Query {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
// Check specific
|
|
128
|
-
for (const specificType of this.
|
|
127
|
+
// Check specific sparse relations
|
|
128
|
+
for (const specificType of this.specificSparseRelationTypes) {
|
|
129
129
|
const result = archetype.getOptional(entity, specificType);
|
|
130
130
|
if (result === undefined) {
|
|
131
131
|
return false;
|
|
@@ -33,9 +33,9 @@ export type SerializedComponent = {
|
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
36
|
+
* Core encoding logic (no cache). Extracted so cached wrapper can reuse it without duplication.
|
|
37
37
|
*/
|
|
38
|
-
|
|
38
|
+
function encodeEntityIdCore(id: EntityId<any>): SerializedEntityId {
|
|
39
39
|
const detailed = getDetailedIdType(id);
|
|
40
40
|
switch (detailed.type) {
|
|
41
41
|
case "component": {
|
|
@@ -81,6 +81,33 @@ export function encodeEntityId(id: EntityId<any>): SerializedEntityId {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Encode an internal EntityId into a SerializedEntityId for snapshots.
|
|
86
|
+
* Use encodeEntityIdCached when serializing many entities to benefit from memoization
|
|
87
|
+
* of repeated component/relation type IDs.
|
|
88
|
+
*/
|
|
89
|
+
export function encodeEntityId(id: EntityId<any>): SerializedEntityId {
|
|
90
|
+
return encodeEntityIdCore(id);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Encode an EntityId, using an optional cache Map to avoid repeated getDetailedIdType
|
|
95
|
+
* + name lookup work for IDs that appear many times (typical during full world snapshot).
|
|
96
|
+
*/
|
|
97
|
+
export function encodeEntityIdCached(
|
|
98
|
+
id: EntityId<any>,
|
|
99
|
+
cache?: Map<EntityId<any>, SerializedEntityId>,
|
|
100
|
+
): SerializedEntityId {
|
|
101
|
+
if (cache) {
|
|
102
|
+
const cached = cache.get(id);
|
|
103
|
+
if (cached !== undefined) return cached;
|
|
104
|
+
const result = encodeEntityIdCore(id);
|
|
105
|
+
cache.set(id, result);
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
return encodeEntityIdCore(id);
|
|
109
|
+
}
|
|
110
|
+
|
|
84
111
|
/**
|
|
85
112
|
* Decode a SerializedEntityId back into an internal EntityId
|
|
86
113
|
*/
|