@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
package/src/world/commands.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import type { Archetype } from "../archetype/archetype";
|
|
2
|
-
import type {
|
|
2
|
+
import type { SparseStore } from "../archetype/store";
|
|
3
3
|
import type { Command } from "../commands/buffer";
|
|
4
4
|
import type { ComponentChangeset } from "../commands/changeset";
|
|
5
5
|
import { normalizeComponentTypes } from "../component/type-utils";
|
|
6
6
|
import {
|
|
7
7
|
getComponentIdFromRelationId,
|
|
8
8
|
getComponentMerge,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
isSparseComponent,
|
|
10
|
+
isSparseRelation,
|
|
11
|
+
isSparseWildcard,
|
|
12
12
|
isWildcardRelationId,
|
|
13
13
|
relation,
|
|
14
14
|
type ComponentId,
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from "../entity";
|
|
17
17
|
|
|
18
18
|
export interface CommandProcessorContext {
|
|
19
|
-
|
|
19
|
+
sparseStore: SparseStore;
|
|
20
20
|
ensureArchetype: (componentTypes: Iterable<EntityId<any>>) => Archetype;
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -59,8 +59,8 @@ function processSetCommand(
|
|
|
59
59
|
// Handle exclusive relations by removing existing relations with the same base component
|
|
60
60
|
handleExclusiveRelation(entityId, currentArchetype, componentId);
|
|
61
61
|
|
|
62
|
-
// For
|
|
63
|
-
if (
|
|
62
|
+
// For sparse relations, ensure wildcard marker is in archetype signature
|
|
63
|
+
if (isSparseComponent(componentId)) {
|
|
64
64
|
const wildcardMarker = relation(componentId, "*");
|
|
65
65
|
// Add wildcard marker to changeset if not already in archetype
|
|
66
66
|
if (!currentArchetype.componentTypeSet.has(wildcardMarker)) {
|
|
@@ -111,10 +111,10 @@ export function removeMatchingRelations(
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
// Check
|
|
115
|
-
const
|
|
116
|
-
if (
|
|
117
|
-
for (const componentType of
|
|
114
|
+
// Check sparse relations stored on entity
|
|
115
|
+
const sparseData = archetype.getEntitySparseRelations(entityId);
|
|
116
|
+
if (sparseData) {
|
|
117
|
+
for (const componentType of sparseData.keys()) {
|
|
118
118
|
if (getComponentIdFromRelationId(componentType) === baseComponentId) {
|
|
119
119
|
changeset.delete(componentType);
|
|
120
120
|
}
|
|
@@ -130,8 +130,8 @@ function removeWildcardRelations(
|
|
|
130
130
|
): void {
|
|
131
131
|
removeMatchingRelations(entityId, currentArchetype, baseComponentId, changeset);
|
|
132
132
|
|
|
133
|
-
// If removing
|
|
134
|
-
if (
|
|
133
|
+
// If removing sparse relations, also remove the wildcard marker
|
|
134
|
+
if (isSparseComponent(baseComponentId)) {
|
|
135
135
|
changeset.delete(relation(baseComponentId, "*"));
|
|
136
136
|
}
|
|
137
137
|
}
|
|
@@ -143,7 +143,7 @@ export function maybeRemoveWildcardMarker(
|
|
|
143
143
|
componentId: ComponentId<any> | undefined,
|
|
144
144
|
changeset: ComponentChangeset,
|
|
145
145
|
): void {
|
|
146
|
-
if (componentId === undefined || !
|
|
146
|
+
if (componentId === undefined || !isSparseComponent(componentId)) {
|
|
147
147
|
return;
|
|
148
148
|
}
|
|
149
149
|
|
|
@@ -160,9 +160,9 @@ export function maybeRemoveWildcardMarker(
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
const
|
|
164
|
-
if (
|
|
165
|
-
for (const otherComponentType of
|
|
163
|
+
const sparseData = archetype.getEntitySparseRelations(entityId);
|
|
164
|
+
if (sparseData) {
|
|
165
|
+
for (const otherComponentType of sparseData.keys()) {
|
|
166
166
|
if (otherComponentType === removedComponentType) continue;
|
|
167
167
|
if (changeset.removes.has(otherComponentType)) continue;
|
|
168
168
|
|
|
@@ -173,7 +173,7 @@ export function maybeRemoveWildcardMarker(
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
// Also check if this changeset itself is adding another relation of the same kind
|
|
176
|
-
// (common in exclusive
|
|
176
|
+
// (common in exclusive sparse flips: remove old target + add new target in one batch)
|
|
177
177
|
for (const addedType of changeset.adds.keys()) {
|
|
178
178
|
if (addedType === removedComponentType) continue;
|
|
179
179
|
if (getComponentIdFromRelationId(addedType) === componentId) {
|
|
@@ -189,7 +189,7 @@ function hasEntityComponent(archetype: Archetype, entityId: EntityId, componentT
|
|
|
189
189
|
return true;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
return archetype.
|
|
192
|
+
return archetype.getEntitySparseRelations(entityId)?.has(componentType) ?? false;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
function pruneMissingRemovals(changeset: ComponentChangeset, archetype: Archetype, entityId: EntityId): void {
|
|
@@ -210,13 +210,13 @@ function pruneMissingRemovals(changeset: ComponentChangeset, archetype: Archetyp
|
|
|
210
210
|
|
|
211
211
|
function hasArchetypeStructuralChange(changeset: ComponentChangeset, currentArchetype: Archetype): boolean {
|
|
212
212
|
for (const componentType of changeset.removes) {
|
|
213
|
-
if (!
|
|
213
|
+
if (!isSparseRelation(componentType) && currentArchetype.componentTypeSet.has(componentType)) {
|
|
214
214
|
return true;
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
for (const componentType of changeset.adds.keys()) {
|
|
219
|
-
if (!
|
|
219
|
+
if (!isSparseRelation(componentType) && !currentArchetype.componentTypeSet.has(componentType)) {
|
|
220
220
|
return true;
|
|
221
221
|
}
|
|
222
222
|
}
|
|
@@ -228,13 +228,13 @@ function buildFinalRegularComponentTypes(currentArchetype: Archetype, changeset:
|
|
|
228
228
|
const finalRegularTypes = new Set(currentArchetype.componentTypes);
|
|
229
229
|
|
|
230
230
|
for (const componentType of changeset.removes) {
|
|
231
|
-
if (!
|
|
231
|
+
if (!isSparseRelation(componentType)) {
|
|
232
232
|
finalRegularTypes.delete(componentType);
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
for (const [componentType] of changeset.adds) {
|
|
237
|
-
if (!
|
|
237
|
+
if (!isSparseRelation(componentType)) {
|
|
238
238
|
finalRegularTypes.add(componentType);
|
|
239
239
|
}
|
|
240
240
|
}
|
|
@@ -269,16 +269,16 @@ export function applyChangeset(
|
|
|
269
269
|
return newArchetype;
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
-
// No archetype move needed: only component payload updates and/or
|
|
272
|
+
// No archetype move needed: only component payload updates and/or sparse relation updates.
|
|
273
273
|
if (removedComponents !== null) {
|
|
274
|
-
|
|
274
|
+
applySparseChanges(ctx.sparseStore, entityId, changeset, removedComponents);
|
|
275
275
|
} else {
|
|
276
|
-
|
|
276
|
+
applySparseChangesNoHooks(ctx.sparseStore, entityId, changeset);
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
// Direct update for regular components in archetype
|
|
280
280
|
for (const [componentType, component] of changeset.adds) {
|
|
281
|
-
if (
|
|
281
|
+
if (isSparseRelation(componentType)) {
|
|
282
282
|
continue;
|
|
283
283
|
}
|
|
284
284
|
currentArchetype.set(entityId, componentType, component);
|
|
@@ -287,52 +287,40 @@ export function applyChangeset(
|
|
|
287
287
|
return currentArchetype;
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
*
|
|
293
|
-
* Rewritten for the new DontFragmentStore interface (ComponentId-primary storage).
|
|
294
|
-
*/
|
|
295
|
-
function applyDontFragmentChanges(
|
|
296
|
-
dontFragmentRelations: DontFragmentStore,
|
|
290
|
+
function applySparseChanges(
|
|
291
|
+
sparseStore: SparseStore,
|
|
297
292
|
entityId: EntityId,
|
|
298
293
|
changeset: ComponentChangeset,
|
|
299
294
|
removedComponents: Map<EntityId<any>, any>,
|
|
300
295
|
): void {
|
|
301
296
|
for (const componentType of changeset.removes) {
|
|
302
|
-
if (
|
|
303
|
-
const removedValue =
|
|
297
|
+
if (isSparseRelation(componentType)) {
|
|
298
|
+
const removedValue = sparseStore.getValue(entityId, componentType);
|
|
304
299
|
// Record for hooks if we are actually removing something
|
|
305
|
-
if (
|
|
306
|
-
removedValue !== undefined ||
|
|
307
|
-
dontFragmentRelations.getAllForEntity(entityId).some(([t]) => t === componentType)
|
|
308
|
-
) {
|
|
300
|
+
if (removedValue !== undefined || sparseStore.getAllForEntity(entityId).some(([t]) => t === componentType)) {
|
|
309
301
|
removedComponents.set(componentType, removedValue);
|
|
310
302
|
}
|
|
311
|
-
|
|
303
|
+
sparseStore.deleteValue(entityId, componentType);
|
|
312
304
|
}
|
|
313
305
|
}
|
|
314
306
|
|
|
315
307
|
for (const [componentType, component] of changeset.adds) {
|
|
316
|
-
if (
|
|
317
|
-
|
|
308
|
+
if (isSparseRelation(componentType)) {
|
|
309
|
+
sparseStore.setValue(entityId, componentType, component);
|
|
318
310
|
}
|
|
319
311
|
}
|
|
320
312
|
}
|
|
321
313
|
|
|
322
|
-
function
|
|
323
|
-
dontFragmentRelations: DontFragmentStore,
|
|
324
|
-
entityId: EntityId,
|
|
325
|
-
changeset: ComponentChangeset,
|
|
326
|
-
): void {
|
|
314
|
+
function applySparseChangesNoHooks(sparseStore: SparseStore, entityId: EntityId, changeset: ComponentChangeset): void {
|
|
327
315
|
for (const componentType of changeset.removes) {
|
|
328
|
-
if (
|
|
329
|
-
|
|
316
|
+
if (isSparseRelation(componentType)) {
|
|
317
|
+
sparseStore.deleteValue(entityId, componentType);
|
|
330
318
|
}
|
|
331
319
|
}
|
|
332
320
|
|
|
333
321
|
for (const [componentType, component] of changeset.adds) {
|
|
334
|
-
if (
|
|
335
|
-
|
|
322
|
+
if (isSparseRelation(componentType)) {
|
|
323
|
+
sparseStore.setValue(entityId, componentType, component);
|
|
336
324
|
}
|
|
337
325
|
}
|
|
338
326
|
}
|
|
@@ -341,14 +329,14 @@ export function filterRegularComponentTypes(componentTypes: Iterable<EntityId<an
|
|
|
341
329
|
const regularTypes: EntityId<any>[] = [];
|
|
342
330
|
|
|
343
331
|
for (const componentType of componentTypes) {
|
|
344
|
-
// Keep wildcard markers for
|
|
345
|
-
if (
|
|
332
|
+
// Keep wildcard markers for sparse components (they mark the archetype)
|
|
333
|
+
if (isSparseWildcard(componentType)) {
|
|
346
334
|
regularTypes.push(componentType);
|
|
347
335
|
continue;
|
|
348
336
|
}
|
|
349
337
|
|
|
350
|
-
// Skip specific
|
|
351
|
-
if (
|
|
338
|
+
// Skip specific sparse relations from archetype signature
|
|
339
|
+
if (isSparseRelation(componentType)) {
|
|
352
340
|
continue;
|
|
353
341
|
}
|
|
354
342
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { DebugStatsCollector, SyncDebugStats } from "../types";
|
|
2
|
+
import { debugHookExecutionCounter } from "./hooks";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Manages debug stats collectors and transient activity counters for World#sync().
|
|
6
|
+
*
|
|
7
|
+
* Extracted from World to shrink the main class while keeping the entire debug/observability
|
|
8
|
+
* path isolated, zero-cost when no collectors are active, and easy to test/maintain.
|
|
9
|
+
*
|
|
10
|
+
* Follows the same context/callback injection style as ArchetypeManager, CommandProcessorContext,
|
|
11
|
+
* and HooksContext to avoid tight coupling.
|
|
12
|
+
*
|
|
13
|
+
* All collectors receive the *exact same* stats object for a given sync (as before).
|
|
14
|
+
* Exceptions in user callbacks are swallowed (as before).
|
|
15
|
+
*/
|
|
16
|
+
export class DebugStatsManager {
|
|
17
|
+
private readonly collectors = new Set<(stats: SyncDebugStats) => void>();
|
|
18
|
+
|
|
19
|
+
// Transient activity counters for the current armed sync (reset each time collectors are present)
|
|
20
|
+
private migrations = 0;
|
|
21
|
+
private archetypesCreated = 0;
|
|
22
|
+
private archetypesRemoved = 0;
|
|
23
|
+
|
|
24
|
+
/** Fast check used to arm timing + reset + counting in hot paths. */
|
|
25
|
+
hasActiveCollectors(): boolean {
|
|
26
|
+
return this.collectors.size > 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Registers a collector. Returns a disposable handle (supports `using`).
|
|
31
|
+
* Collection stops when the handle is disposed.
|
|
32
|
+
*/
|
|
33
|
+
createCollector(callback: (stats: SyncDebugStats) => void): DebugStatsCollector {
|
|
34
|
+
this.collectors.add(callback);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
[Symbol.dispose]: () => {
|
|
38
|
+
this.collectors.delete(callback);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ------------------------------------------------------------------
|
|
44
|
+
// Recording hooks (called from ArchetypeManager ctx and command apply paths)
|
|
45
|
+
// These are cheap no-ops when no collectors are active.
|
|
46
|
+
// ------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
recordArchetypeCreated(): void {
|
|
49
|
+
if (this.hasActiveCollectors()) {
|
|
50
|
+
this.archetypesCreated++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
recordArchetypeRemoved(): void {
|
|
55
|
+
if (this.hasActiveCollectors()) {
|
|
56
|
+
this.archetypesRemoved++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
incrementMigrations(): void {
|
|
61
|
+
if (this.hasActiveCollectors()) {
|
|
62
|
+
this.migrations++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Reset all activity counters + the shared hook execution counter. Called at start of an armed sync. */
|
|
67
|
+
resetActivity(): void {
|
|
68
|
+
this.migrations = 0;
|
|
69
|
+
this.archetypesCreated = 0;
|
|
70
|
+
this.archetypesRemoved = 0;
|
|
71
|
+
debugHookExecutionCounter.value = 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build and deliver a SyncDebugStats payload to every active collector.
|
|
76
|
+
* World supplies the pre-computed snapshot numbers (keeps debug manager decoupled from
|
|
77
|
+
* internal World maps/registries while preserving exact original stats shape and values).
|
|
78
|
+
*/
|
|
79
|
+
deliver(
|
|
80
|
+
timings: {
|
|
81
|
+
syncStart: number;
|
|
82
|
+
syncEnd: number;
|
|
83
|
+
commandBufferStart: number;
|
|
84
|
+
commandBufferEnd: number;
|
|
85
|
+
commandIterations: number;
|
|
86
|
+
},
|
|
87
|
+
data: {
|
|
88
|
+
entityCount: number;
|
|
89
|
+
freelistSize: number;
|
|
90
|
+
nextId: number;
|
|
91
|
+
archetypeCount: number;
|
|
92
|
+
emptyArchetypes: number;
|
|
93
|
+
archetypesByComponentSize: number;
|
|
94
|
+
cachedQueryCount: number;
|
|
95
|
+
registeredQueryCount: number;
|
|
96
|
+
hookCount: number;
|
|
97
|
+
entityReferencesSize: number;
|
|
98
|
+
entityToReferencingArchetypesSize: number;
|
|
99
|
+
},
|
|
100
|
+
): void {
|
|
101
|
+
const stats: SyncDebugStats = {
|
|
102
|
+
timestamps: {
|
|
103
|
+
syncStart: timings.syncStart,
|
|
104
|
+
syncEnd: timings.syncEnd,
|
|
105
|
+
commandBufferStart: timings.commandBufferStart,
|
|
106
|
+
commandBufferEnd: timings.commandBufferEnd,
|
|
107
|
+
},
|
|
108
|
+
commandIterations: timings.commandIterations,
|
|
109
|
+
|
|
110
|
+
entities: {
|
|
111
|
+
total: data.entityCount,
|
|
112
|
+
freelistSize: data.freelistSize,
|
|
113
|
+
nextId: data.nextId,
|
|
114
|
+
},
|
|
115
|
+
archetypes: {
|
|
116
|
+
total: data.archetypeCount,
|
|
117
|
+
empty: data.emptyArchetypes,
|
|
118
|
+
},
|
|
119
|
+
queries: {
|
|
120
|
+
cached: data.cachedQueryCount,
|
|
121
|
+
registered: data.registeredQueryCount,
|
|
122
|
+
},
|
|
123
|
+
hooks: {
|
|
124
|
+
total: data.hookCount,
|
|
125
|
+
},
|
|
126
|
+
indices: {
|
|
127
|
+
entityReferences: data.entityReferencesSize,
|
|
128
|
+
entityToReferencingArchetypes: data.entityToReferencingArchetypesSize,
|
|
129
|
+
archetypesByComponent: data.archetypesByComponentSize,
|
|
130
|
+
},
|
|
131
|
+
activity: {
|
|
132
|
+
migrations: this.migrations,
|
|
133
|
+
hooksExecuted: debugHookExecutionCounter.value,
|
|
134
|
+
archetypesCreated: this.archetypesCreated,
|
|
135
|
+
archetypesRemoved: this.archetypesRemoved,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
for (const cb of this.collectors) {
|
|
140
|
+
try {
|
|
141
|
+
cb(stats);
|
|
142
|
+
} catch {
|
|
143
|
+
// Intentionally ignore user callback errors (preserves original behavior)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/world/hooks.ts
CHANGED
|
@@ -7,6 +7,12 @@ import {
|
|
|
7
7
|
} from "../entity";
|
|
8
8
|
import { isOptionalEntityId, type ComponentType, type LifecycleHookEntry } from "../types";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Debug-only counter incremented on every invokeHook call when armed.
|
|
12
|
+
* World reads and resets this during armed syncs.
|
|
13
|
+
*/
|
|
14
|
+
export const debugHookExecutionCounter = { value: 0 };
|
|
15
|
+
|
|
10
16
|
/**
|
|
11
17
|
* Unified hook invocation: prefers entry.callback (callback style) over hook.on_* (object style).
|
|
12
18
|
*/
|
|
@@ -16,6 +22,8 @@ function invokeHook(
|
|
|
16
22
|
entityId: EntityId,
|
|
17
23
|
components: any[],
|
|
18
24
|
): void {
|
|
25
|
+
debugHookExecutionCounter.value++;
|
|
26
|
+
|
|
19
27
|
if (entry.callback) {
|
|
20
28
|
entry.callback(event as any, entityId, ...components);
|
|
21
29
|
return;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ComponentId, EntityId } from "../entity";
|
|
2
|
+
import { getDetailedIdType } from "../entity";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validation and overload-resolution helpers extracted from World.
|
|
6
|
+
*
|
|
7
|
+
* These were previously private methods on World. Moving them reduces line count
|
|
8
|
+
* in the core class with almost zero coupling (the only dep is a liveness predicate
|
|
9
|
+
* for assertEntityExists, supplied by the caller).
|
|
10
|
+
*
|
|
11
|
+
* Pure type checks (assert*TypeValid) and the resolve* helpers for set/remove
|
|
12
|
+
* overloads live here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Assert that an entity (or component-entity) is alive in the world.
|
|
17
|
+
* The caller supplies the liveness check (World.exists or equivalent) to keep
|
|
18
|
+
* this module free of direct references to stores.
|
|
19
|
+
*/
|
|
20
|
+
export function assertEntityExists(
|
|
21
|
+
entityId: EntityId,
|
|
22
|
+
label: "Entity" | "Component entity",
|
|
23
|
+
exists: (id: EntityId) => boolean,
|
|
24
|
+
): void {
|
|
25
|
+
if (!exists(entityId)) {
|
|
26
|
+
throw new Error(`${label} ${entityId} does not exist`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function assertComponentTypeValid(componentType: EntityId): void {
|
|
31
|
+
const detailedType = getDetailedIdType(componentType);
|
|
32
|
+
if (detailedType.type === "invalid") {
|
|
33
|
+
throw new Error(`Invalid component type: ${componentType}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function assertSetComponentTypeValid(componentType: EntityId): void {
|
|
38
|
+
const detailedType = getDetailedIdType(componentType);
|
|
39
|
+
if (detailedType.type === "invalid") {
|
|
40
|
+
throw new Error(`Invalid component type: ${componentType}`);
|
|
41
|
+
}
|
|
42
|
+
if (detailedType.type === "wildcard-relation") {
|
|
43
|
+
throw new Error(`Cannot directly add wildcard relation components: ${componentType}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the (entity, componentType, value) for a set() call.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveSetOperation(
|
|
51
|
+
entityId: EntityId | ComponentId,
|
|
52
|
+
componentTypeOrComponent?: EntityId | any,
|
|
53
|
+
maybeComponent?: any,
|
|
54
|
+
exists: (id: EntityId) => boolean = () => true, // default permissive for tests / internal
|
|
55
|
+
): { entityId: EntityId; componentType: EntityId; component: any } {
|
|
56
|
+
const targetEntityId = entityId as EntityId;
|
|
57
|
+
const componentType = componentTypeOrComponent as EntityId;
|
|
58
|
+
assertEntityExists(targetEntityId, "Entity", exists);
|
|
59
|
+
assertSetComponentTypeValid(componentType);
|
|
60
|
+
|
|
61
|
+
return { entityId: targetEntityId, componentType, component: maybeComponent };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the (entity, componentType) for a remove() call, handling the
|
|
66
|
+
* singleton component overload (remove(componentId)).
|
|
67
|
+
*/
|
|
68
|
+
export function resolveRemoveOperation<T>(
|
|
69
|
+
entityId: EntityId | ComponentId,
|
|
70
|
+
componentType?: EntityId<T>,
|
|
71
|
+
exists: (id: EntityId) => boolean = () => true,
|
|
72
|
+
): { entityId: EntityId; componentType: EntityId } {
|
|
73
|
+
// Handle singleton component overload: remove(componentId)
|
|
74
|
+
if (componentType === undefined) {
|
|
75
|
+
const componentId = entityId as ComponentId<T>;
|
|
76
|
+
assertEntityExists(componentId, "Component entity", exists);
|
|
77
|
+
return { entityId: componentId, componentType: componentId };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const targetEntityId = entityId as EntityId;
|
|
81
|
+
assertEntityExists(targetEntityId, "Entity", exists);
|
|
82
|
+
assertComponentTypeValid(componentType);
|
|
83
|
+
|
|
84
|
+
return { entityId: targetEntityId, componentType };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Re-export the type for callers that need it in signatures (ComponentId lives in entity)
|
|
88
|
+
export type { ComponentId } from "../entity";
|
|
@@ -3,7 +3,7 @@ import type { ComponentEntityStore } from "../component/entity-store";
|
|
|
3
3
|
import { getDetailedIdType, type EntityId, type EntityIdManager } from "../entity";
|
|
4
4
|
import {
|
|
5
5
|
decodeSerializedId,
|
|
6
|
-
|
|
6
|
+
encodeEntityIdCached,
|
|
7
7
|
type SerializedComponent,
|
|
8
8
|
type SerializedEntity,
|
|
9
9
|
type SerializedWorld,
|
|
@@ -18,29 +18,25 @@ export function serializeWorld(
|
|
|
18
18
|
componentEntities: ComponentEntityStore,
|
|
19
19
|
entityIdManager: EntityIdManager,
|
|
20
20
|
): SerializedWorld {
|
|
21
|
+
// ID cache turns repeated encode work (especially component type IDs) into O(#unique IDs)
|
|
22
|
+
const idCache = new Map<any, any>();
|
|
23
|
+
|
|
21
24
|
const entities: SerializedEntity[] = [];
|
|
22
25
|
|
|
23
26
|
for (const archetype of archetypes) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
value: value === MISSING_COMPONENT ? undefined : value,
|
|
31
|
-
})),
|
|
32
|
-
});
|
|
33
|
-
}
|
|
27
|
+
// Pre-encode this archetype's component type IDs exactly once (big win when many entities share the archetype)
|
|
28
|
+
const encodedComponentTypes = archetype.componentTypes.map((t) => encodeEntityIdCached(t, idCache));
|
|
29
|
+
|
|
30
|
+
// The append method will use the bulk helper internally when a pre-fetched map is supplied.
|
|
31
|
+
// For now we rely on the per-entity fallback inside the archetype (already much cheaper than old dump path).
|
|
32
|
+
archetype.appendSerializedEntities(entities, (id) => encodeEntityIdCached(id, idCache), encodedComponentTypes);
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
const componentEntitiesArr: SerializedEntity[] = [];
|
|
37
36
|
for (const [entityId, components] of componentEntities.entries()) {
|
|
38
37
|
componentEntitiesArr.push({
|
|
39
|
-
id:
|
|
40
|
-
components:
|
|
41
|
-
type: encodeEntityId(rawType),
|
|
42
|
-
value: value === MISSING_COMPONENT ? undefined : value,
|
|
43
|
-
})),
|
|
38
|
+
id: encodeEntityIdCached(entityId, idCache),
|
|
39
|
+
components: serializeComponentsFromMap(components, idCache),
|
|
44
40
|
});
|
|
45
41
|
}
|
|
46
42
|
|
|
@@ -52,6 +48,21 @@ export function serializeWorld(
|
|
|
52
48
|
};
|
|
53
49
|
}
|
|
54
50
|
|
|
51
|
+
/** Small helper to avoid duplicating the "Map → SerializedComponent[] with cache" pattern. */
|
|
52
|
+
function serializeComponentsFromMap(
|
|
53
|
+
components: Map<EntityId<any>, any>,
|
|
54
|
+
idCache: Map<any, any>,
|
|
55
|
+
): SerializedComponent[] {
|
|
56
|
+
const result: SerializedComponent[] = [];
|
|
57
|
+
for (const [rawType, value] of components) {
|
|
58
|
+
result.push({
|
|
59
|
+
type: encodeEntityIdCached(rawType, idCache),
|
|
60
|
+
value: value === MISSING_COMPONENT ? undefined : value,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
55
66
|
/**
|
|
56
67
|
* Context needed by `deserializeWorld` to populate world-internal state.
|
|
57
68
|
* Defined as an interface to avoid a circular import between world.ts and this module.
|
|
@@ -96,14 +107,17 @@ export function deserializeWorld(ctx: WorldDeserializationContext, snapshot: Ser
|
|
|
96
107
|
const componentsArray: SerializedComponent[] = entry.components || [];
|
|
97
108
|
|
|
98
109
|
const componentMap = new Map<EntityId<any>, any>();
|
|
99
|
-
const componentTypes: EntityId<any>[] = [];
|
|
100
110
|
|
|
101
111
|
for (const componentEntry of componentsArray) {
|
|
102
112
|
const componentType = decodeSerializedId(componentEntry.type);
|
|
103
113
|
componentMap.set(componentType, componentEntry.value);
|
|
104
|
-
componentTypes.push(componentType);
|
|
105
114
|
}
|
|
106
115
|
|
|
116
|
+
// Build the list of component types from the map we just populated (no redundant push loop)
|
|
117
|
+
const componentTypes = Array.from(componentMap.keys());
|
|
118
|
+
|
|
119
|
+
// ensureArchetype is internally memoized (getOrCompute on signature), so repeated calls
|
|
120
|
+
// for the same component set are cheap after the first archetype is created.
|
|
107
121
|
const archetype = ctx.ensureArchetype(componentTypes);
|
|
108
122
|
archetype.addEntity(entityId, componentMap);
|
|
109
123
|
ctx.setEntityToArchetype(entityId, archetype);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ComponentId } from "../entity";
|
|
2
|
+
|
|
3
|
+
export interface SingletonHandleOps<T> {
|
|
4
|
+
has(): boolean;
|
|
5
|
+
get(): T;
|
|
6
|
+
getOptional(): { value: T } | undefined;
|
|
7
|
+
remove(): void;
|
|
8
|
+
set(value: T | undefined): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Explicit handle for a singleton component (component-as-entity).
|
|
13
|
+
*
|
|
14
|
+
* This provides an explicit and concise API for singleton components without
|
|
15
|
+
* overloading `world.set()` semantics.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* const config = world.singleton(Config);
|
|
19
|
+
* config.set({ debug: true });
|
|
20
|
+
* world.sync();
|
|
21
|
+
* console.log(config.get());
|
|
22
|
+
*/
|
|
23
|
+
export class SingletonHandle<T = void> {
|
|
24
|
+
readonly componentId: ComponentId<T>;
|
|
25
|
+
private readonly ops: SingletonHandleOps<T>;
|
|
26
|
+
|
|
27
|
+
constructor(componentId: ComponentId<T>, ops: SingletonHandleOps<T>) {
|
|
28
|
+
this.componentId = componentId;
|
|
29
|
+
this.ops = ops;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
has(): boolean {
|
|
33
|
+
return this.ops.has();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get(): T {
|
|
37
|
+
return this.ops.get();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getOptional(): { value: T } | undefined {
|
|
41
|
+
return this.ops.getOptional();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
remove(): void {
|
|
45
|
+
this.ops.remove();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
set(...args: T extends void ? [] : [value: NoInfer<T>]): void {
|
|
49
|
+
this.ops.set(args[0] as T | undefined);
|
|
50
|
+
}
|
|
51
|
+
}
|