@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { component, type EntityId } from "../../entity";
|
|
3
|
+
import type { SyncDebugStats } from "../../types";
|
|
3
4
|
import { World } from "../../world/world";
|
|
4
5
|
|
|
5
6
|
describe("World - Multi-Component Hooks", () => {
|
|
@@ -11,6 +12,9 @@ describe("World - Multi-Component Hooks", () => {
|
|
|
11
12
|
const setCalls: { entityId: EntityId; value: number }[] = [];
|
|
12
13
|
const removeCalls: { entityId: EntityId; value: number }[] = [];
|
|
13
14
|
|
|
15
|
+
const collectedStats: SyncDebugStats[] = [];
|
|
16
|
+
using _collector = world.createDebugStatsCollector((stats) => collectedStats.push(stats));
|
|
17
|
+
|
|
14
18
|
// First create an entity before registering the hook (for on_init test)
|
|
15
19
|
const existingEntity = world.spawn().with(A, 100).build();
|
|
16
20
|
world.sync();
|
|
@@ -64,6 +68,15 @@ describe("World - Multi-Component Hooks", () => {
|
|
|
64
68
|
expect(removeCalls.length).toBe(2);
|
|
65
69
|
expect(removeCalls[1]!.entityId).toBe(existingEntity);
|
|
66
70
|
expect(removeCalls[1]!.value).toBe(100);
|
|
71
|
+
|
|
72
|
+
// Cross-verify with the new debug stats collector
|
|
73
|
+
const lastStats = collectedStats[collectedStats.length - 1];
|
|
74
|
+
expect(lastStats).toBeDefined();
|
|
75
|
+
expect(lastStats!.hooks.total).toBeGreaterThanOrEqual(1);
|
|
76
|
+
// Note: hooksExecuted counts individual invokeHook calls. The exact number
|
|
77
|
+
// can be lower than the sum of manual arrays depending on event paths.
|
|
78
|
+
// We only assert that at least some hook activity was recorded.
|
|
79
|
+
expect(lastStats!.activity.hooksExecuted).toBeGreaterThanOrEqual(1);
|
|
67
80
|
});
|
|
68
81
|
|
|
69
82
|
it("should throw error when hook has no required components (only optional)", () => {
|
|
@@ -499,4 +512,35 @@ describe("World - Multi-Component Hooks", () => {
|
|
|
499
512
|
expect(world.has(entity1, A)).toBe(true);
|
|
500
513
|
expect(world.has(entity2, A)).toBe(true);
|
|
501
514
|
});
|
|
515
|
+
|
|
516
|
+
it("should support callback-style registration for multi-component hooks (set/remove)", () => {
|
|
517
|
+
const world = new World();
|
|
518
|
+
const A = component<number>();
|
|
519
|
+
const B = component<string>();
|
|
520
|
+
|
|
521
|
+
const calls: { event: string; entityId: EntityId; components: any[] }[] = [];
|
|
522
|
+
|
|
523
|
+
// Register callback-style hook *before* any entities to ensure runtime set/remove
|
|
524
|
+
// go through invokeHook's callback branch (init replay for existing bypasses invokeHook).
|
|
525
|
+
const unhook = world.hook([A, B], (event, entityId, a, b) => {
|
|
526
|
+
calls.push({ event, entityId, components: [a, b] });
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// New entity after registration → "set" via normal trigger path + invokeHook(callback)
|
|
530
|
+
const e = world.spawn().with(A, 42).with(B, "hi").build();
|
|
531
|
+
world.sync();
|
|
532
|
+
expect(calls.some((c) => c.event === "set" && c.entityId === e)).toBe(true);
|
|
533
|
+
|
|
534
|
+
// Update also triggers set
|
|
535
|
+
world.set(e, A, 100);
|
|
536
|
+
world.sync();
|
|
537
|
+
expect(calls.some((c) => c.event === "set" && c.components[0] === 100)).toBe(true);
|
|
538
|
+
|
|
539
|
+
// Remove required component → "remove" via invokeHook(callback)
|
|
540
|
+
world.remove(e, A);
|
|
541
|
+
world.sync();
|
|
542
|
+
expect(calls.some((c) => c.event === "remove" && c.entityId === e)).toBe(true);
|
|
543
|
+
|
|
544
|
+
unhook();
|
|
545
|
+
});
|
|
502
546
|
});
|
|
@@ -80,4 +80,21 @@ describe("World serialization", () => {
|
|
|
80
80
|
const c = restored.new();
|
|
81
81
|
expect(c).toBeGreaterThanOrEqual(b + 1);
|
|
82
82
|
});
|
|
83
|
+
|
|
84
|
+
it("should serialize and deserialize component-relations", () => {
|
|
85
|
+
const world = new World();
|
|
86
|
+
const A = component<string>("A");
|
|
87
|
+
const B = component<number>("B");
|
|
88
|
+
const relAB = relation(A, B); // component-relation
|
|
89
|
+
|
|
90
|
+
const e = world.new();
|
|
91
|
+
world.set(e, relAB, "linked-via-comp");
|
|
92
|
+
world.sync();
|
|
93
|
+
|
|
94
|
+
const snapshot = world.serialize();
|
|
95
|
+
const restored = new World(snapshot);
|
|
96
|
+
|
|
97
|
+
expect(restored.has(e, relAB)).toBe(true);
|
|
98
|
+
expect(restored.get(e, relAB)).toBe("linked-via-comp");
|
|
99
|
+
});
|
|
83
100
|
});
|
|
@@ -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
|
);
|