@codehz/ecs 0.7.2 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/examples/advanced-scheduling.ts +96 -0
- package/examples/collision-detection.ts +229 -0
- package/examples/inventory-system-relations.ts +108 -0
- package/examples/parent-child-hierarchy.ts +206 -0
- package/examples/serialization.ts +337 -0
- package/examples/simple.ts +96 -0
- package/examples/spatial-grid.ts +276 -0
- package/examples/state-machine.ts +273 -0
- package/examples/tag-filtering.ts +266 -0
- package/package.json +60 -12
- package/src/__tests__/commands/buffer-limits.test.ts +72 -0
- package/src/__tests__/commands/buffer.test.ts +195 -0
- package/src/__tests__/component/singleton.test.ts +148 -0
- package/src/__tests__/core/archetype.test.ts +247 -0
- package/src/__tests__/core/bitset.test.ts +171 -0
- package/src/__tests__/core/changeset.test.ts +254 -0
- package/src/__tests__/core/multi-map.test.ts +74 -0
- package/src/__tests__/entity/component-registry.test.ts +66 -0
- package/src/__tests__/entity/entity.test.ts +520 -0
- package/src/__tests__/entity/id-manager.test.ts +157 -0
- package/src/__tests__/entity/id-system.test.ts +260 -0
- package/src/__tests__/perf/comprehensive.perf.test.ts +300 -0
- package/src/__tests__/perf/sync-hotpath.perf.test.ts +79 -0
- package/src/__tests__/query/basic.test.ts +341 -0
- package/src/__tests__/query/caching.test.ts +112 -0
- package/src/__tests__/query/filter.test.ts +111 -0
- package/src/__tests__/query/optional.test.ts +231 -0
- package/src/__tests__/query/perf.test.ts +99 -0
- package/src/__tests__/relations/dont-fragment/basic.test.ts +496 -0
- package/src/__tests__/relations/dont-fragment/query-notification.test.ts +125 -0
- package/src/__tests__/relations/wildcard.test.ts +179 -0
- package/src/__tests__/serialization/bounds.test.ts +237 -0
- package/src/__tests__/testing/assertions.test.ts +224 -0
- package/src/__tests__/testing/entity-builder.test.ts +84 -0
- package/src/__tests__/testing/snapshot.test.ts +150 -0
- package/src/__tests__/testing/world-fixture.test.ts +73 -0
- package/src/__tests__/world/component-hooks.test.ts +185 -0
- package/src/__tests__/world/component-management.test.ts +447 -0
- package/src/__tests__/world/entity-management.test.ts +86 -0
- package/src/__tests__/world/get-optional.test.ts +96 -0
- package/src/__tests__/world/multi-component-hooks.test.ts +502 -0
- package/src/__tests__/world/perf.test.ts +93 -0
- package/src/__tests__/world/query.test.ts +223 -0
- package/src/__tests__/world/serialize.test.ts +83 -0
- package/src/__tests__/world/wildcard-relation-hooks.test.ts +332 -0
- package/src/archetype/archetype.ts +472 -0
- package/src/archetype/helpers.ts +186 -0
- package/src/archetype/store.ts +33 -0
- package/src/commands/buffer.ts +110 -0
- package/src/commands/changeset.ts +104 -0
- package/src/component/entity-store.ts +223 -0
- package/src/component/registry.ts +657 -0
- package/src/component/type-utils.ts +9 -0
- package/src/entity/index.ts +63 -0
- package/src/entity/manager.ts +115 -0
- package/src/entity/relation.ts +319 -0
- package/src/entity/types.ts +135 -0
- package/src/index.ts +41 -0
- package/src/query/filter.ts +75 -0
- package/src/query/query.ts +313 -0
- package/src/query/registry.ts +101 -0
- package/src/storage/serialization.ts +130 -0
- package/src/testing/index.ts +634 -0
- package/src/types/index.ts +99 -0
- package/src/utils/bit-set.ts +133 -0
- package/src/utils/multi-map.ts +96 -0
- package/src/utils/utils.ts +19 -0
- package/src/world/builder.ts +100 -0
- package/src/world/commands.ts +378 -0
- package/src/world/hooks.ts +358 -0
- package/src/world/references.ts +38 -0
- package/src/world/serialization.ts +122 -0
- package/src/world/world.ts +1201 -0
- /package/{builder.d.mts → dist/builder.d.mts} +0 -0
- /package/{index.d.mts → dist/index.d.mts} +0 -0
- /package/{index.mjs → dist/index.mjs} +0 -0
- /package/{testing.d.mts → dist/testing.d.mts} +0 -0
- /package/{testing.mjs → dist/testing.mjs} +0 -0
- /package/{testing.mjs.map → dist/testing.mjs.map} +0 -0
- /package/{world.mjs → dist/world.mjs} +0 -0
- /package/{world.mjs.map → dist/world.mjs.map} +0 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import type { Archetype } from "../archetype/archetype";
|
|
2
|
+
import {
|
|
3
|
+
getComponentIdFromRelationId,
|
|
4
|
+
getTargetIdFromRelationId,
|
|
5
|
+
isWildcardRelationId,
|
|
6
|
+
type EntityId,
|
|
7
|
+
} from "../entity";
|
|
8
|
+
import { isOptionalEntityId, type ComponentType, type LifecycleHookEntry } from "../types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Unified hook invocation: prefers entry.callback (callback style) over hook.on_* (object style).
|
|
12
|
+
*/
|
|
13
|
+
function invokeHook(
|
|
14
|
+
entry: LifecycleHookEntry,
|
|
15
|
+
event: "init" | "set" | "remove",
|
|
16
|
+
entityId: EntityId,
|
|
17
|
+
components: any[],
|
|
18
|
+
): void {
|
|
19
|
+
if (entry.callback) {
|
|
20
|
+
entry.callback(event as any, entityId, ...components);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const hook = entry.hook;
|
|
24
|
+
if (event === "init") hook.on_init?.(entityId, ...components);
|
|
25
|
+
else if (event === "set") hook.on_set?.(entityId, ...components);
|
|
26
|
+
else hook.on_remove?.(entityId, ...components);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if a component change matches a hook component type.
|
|
31
|
+
* Handles wildcard-relation matching: if hookComponent is a wildcard relation (e.g., relation(A, "*")),
|
|
32
|
+
* it matches any concrete relation with the same component ID (e.g., relation(A, entity1)).
|
|
33
|
+
*/
|
|
34
|
+
function componentMatchesHookType(changedComponent: EntityId<any>, hookComponent: EntityId<any>): boolean {
|
|
35
|
+
if (changedComponent === hookComponent) return true;
|
|
36
|
+
|
|
37
|
+
// Check if hookComponent is a wildcard relation and changedComponent is a matching relation
|
|
38
|
+
if (isWildcardRelationId(hookComponent)) {
|
|
39
|
+
const hookComponentId = getComponentIdFromRelationId(hookComponent);
|
|
40
|
+
const changedComponentId = getComponentIdFromRelationId(changedComponent);
|
|
41
|
+
if (hookComponentId !== undefined && changedComponentId !== undefined) {
|
|
42
|
+
return hookComponentId === changedComponentId;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if any component in the changes map matches a hook component type.
|
|
51
|
+
*/
|
|
52
|
+
function anyComponentMatches(changes: Map<EntityId<any>, any>, hookComponent: EntityId<any>): boolean {
|
|
53
|
+
for (const changedComponent of changes.keys()) {
|
|
54
|
+
if (componentMatchesHookType(changedComponent, hookComponent)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Find a matching component in the changes map that matches the hook component type.
|
|
63
|
+
* Returns [componentId, value] if found, undefined otherwise.
|
|
64
|
+
*/
|
|
65
|
+
function findMatchingComponent(
|
|
66
|
+
changes: Map<EntityId<any>, any>,
|
|
67
|
+
hookComponent: EntityId<any>,
|
|
68
|
+
): [EntityId<any>, any] | undefined {
|
|
69
|
+
for (const [changedComponent, value] of changes.entries()) {
|
|
70
|
+
if (componentMatchesHookType(changedComponent, hookComponent)) {
|
|
71
|
+
return [changedComponent, value];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface HooksContext {
|
|
78
|
+
multiHooks: Set<LifecycleHookEntry>;
|
|
79
|
+
has: (entityId: EntityId, componentType: EntityId<any>) => boolean;
|
|
80
|
+
get: <T>(entityId: EntityId, componentType: EntityId<T>) => T;
|
|
81
|
+
getOptional: <T>(entityId: EntityId, componentType: EntityId<T>) => { value: T } | undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function triggerLifecycleHooks(
|
|
85
|
+
ctx: HooksContext,
|
|
86
|
+
entityId: EntityId,
|
|
87
|
+
addedComponents: Map<EntityId<any>, any>,
|
|
88
|
+
removedComponents: Map<EntityId<any>, any>,
|
|
89
|
+
oldArchetype: Archetype,
|
|
90
|
+
newArchetype: Archetype,
|
|
91
|
+
): void {
|
|
92
|
+
triggerMultiComponentHooks(ctx, entityId, addedComponents, removedComponents, oldArchetype, newArchetype);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fast path for triggering lifecycle hooks when an entity is being deleted.
|
|
97
|
+
* This avoids unnecessary archetype lookups and on_set checks since the entity
|
|
98
|
+
* is being completely removed.
|
|
99
|
+
*/
|
|
100
|
+
export function triggerRemoveHooksForEntityDeletion(
|
|
101
|
+
entityId: EntityId,
|
|
102
|
+
removedComponents: Map<EntityId<any>, any>,
|
|
103
|
+
oldArchetype: Archetype,
|
|
104
|
+
): void {
|
|
105
|
+
if (removedComponents.size === 0) return;
|
|
106
|
+
|
|
107
|
+
// Trigger multi-component hooks - only on_remove since entity is being deleted
|
|
108
|
+
for (const entry of oldArchetype.matchingMultiHooks) {
|
|
109
|
+
const { requiredComponents, componentTypes } = entry;
|
|
110
|
+
|
|
111
|
+
// Skip if neither callback-style nor hook-style on_remove is provided
|
|
112
|
+
if (!entry.callback && !entry.hook.on_remove) continue;
|
|
113
|
+
|
|
114
|
+
// Check if any required component was removed
|
|
115
|
+
const anyRequiredRemoved = requiredComponents.some((c) => anyComponentMatches(removedComponents, c));
|
|
116
|
+
if (!anyRequiredRemoved) continue;
|
|
117
|
+
|
|
118
|
+
// For entity deletion, we know:
|
|
119
|
+
// 1. All components are being removed, so entity "had" all required components
|
|
120
|
+
// 2. Entity will no longer match after deletion
|
|
121
|
+
// Just need to verify the entity actually had all required components before
|
|
122
|
+
const hadAllRequired = requiredComponents.every((c) => anyComponentMatches(removedComponents, c));
|
|
123
|
+
if (!hadAllRequired) continue;
|
|
124
|
+
|
|
125
|
+
// Collect component values from removedComponents directly (no entity lookup needed)
|
|
126
|
+
const components = collectComponentsFromRemoved(componentTypes, removedComponents);
|
|
127
|
+
invokeHook(entry, "remove", entityId, components);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function triggerMultiComponentHooks(
|
|
132
|
+
ctx: HooksContext,
|
|
133
|
+
entityId: EntityId,
|
|
134
|
+
addedComponents: Map<EntityId<any>, any>,
|
|
135
|
+
removedComponents: Map<EntityId<any>, any>,
|
|
136
|
+
oldArchetype: Archetype,
|
|
137
|
+
newArchetype: Archetype,
|
|
138
|
+
): void {
|
|
139
|
+
// Handle on_set:
|
|
140
|
+
// 1. Required/optional components changed while entity still matches
|
|
141
|
+
// 2. Entity entered the matching set (e.g. removed a negative filter component)
|
|
142
|
+
for (const entry of newArchetype.matchingMultiHooks) {
|
|
143
|
+
const { requiredComponents, optionalComponents, componentTypes } = entry;
|
|
144
|
+
|
|
145
|
+
// Skip if neither callback-style nor hook-style on_set is provided
|
|
146
|
+
if (!entry.callback && !entry.hook.on_set) continue;
|
|
147
|
+
|
|
148
|
+
const anyRequiredAdded = requiredComponents.some((c) => anyComponentMatches(addedComponents, c));
|
|
149
|
+
const anyOptionalAdded = optionalComponents.some((c) => anyComponentMatches(addedComponents, c));
|
|
150
|
+
const anyOptionalRemoved = optionalComponents.some((c) => anyComponentMatches(removedComponents, c));
|
|
151
|
+
const enteredMatchingSet = !oldArchetype.matchingMultiHooks.has(entry);
|
|
152
|
+
const hasRelevantComponentChange = anyRequiredAdded || anyOptionalAdded || anyOptionalRemoved;
|
|
153
|
+
const shouldTriggerSet =
|
|
154
|
+
enteredMatchingSet || (hasRelevantComponentChange && entityHasAllComponents(ctx, entityId, requiredComponents));
|
|
155
|
+
|
|
156
|
+
if (shouldTriggerSet) {
|
|
157
|
+
const components = collectMultiHookComponents(ctx, entityId, componentTypes);
|
|
158
|
+
invokeHook(entry, "set", entityId, components);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Handle on_remove:
|
|
163
|
+
// 1. Required component removal made the entity stop matching
|
|
164
|
+
// 2. Entity exited the matching set (e.g. added a negative filter component)
|
|
165
|
+
for (const entry of oldArchetype.matchingMultiHooks) {
|
|
166
|
+
const { requiredComponents, componentTypes } = entry;
|
|
167
|
+
|
|
168
|
+
// Skip if neither callback-style nor hook-style on_remove is provided
|
|
169
|
+
if (!entry.callback && !entry.hook.on_remove) continue;
|
|
170
|
+
|
|
171
|
+
const anyRequiredRemoved = requiredComponents.some((c) => anyComponentMatches(removedComponents, c));
|
|
172
|
+
const lostRequiredMatch =
|
|
173
|
+
anyRequiredRemoved &&
|
|
174
|
+
entityHadAllComponentsBefore(ctx, entityId, requiredComponents, removedComponents) &&
|
|
175
|
+
!entityHasAllComponents(ctx, entityId, requiredComponents);
|
|
176
|
+
const exitedMatchingSet = !newArchetype.matchingMultiHooks.has(entry);
|
|
177
|
+
const shouldTriggerRemove = lostRequiredMatch || exitedMatchingSet;
|
|
178
|
+
|
|
179
|
+
if (shouldTriggerRemove) {
|
|
180
|
+
const components = collectMultiHookComponentsWithRemoved(ctx, entityId, componentTypes, removedComponents);
|
|
181
|
+
invokeHook(entry, "remove", entityId, components);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function entityHasAllComponents(ctx: HooksContext, entityId: EntityId, requiredComponents: EntityId<any>[]): boolean {
|
|
187
|
+
return requiredComponents.every((c) => {
|
|
188
|
+
// For wildcard relations, check if the entity has the wildcard relation data
|
|
189
|
+
if (isWildcardRelationId(c)) {
|
|
190
|
+
const wildcardResult = ctx.getOptional(entityId, c);
|
|
191
|
+
if (!wildcardResult) return false;
|
|
192
|
+
const wildcardData = wildcardResult.value;
|
|
193
|
+
return Array.isArray(wildcardData) && wildcardData.length > 0;
|
|
194
|
+
}
|
|
195
|
+
return ctx.has(entityId, c);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function entityHadAllComponentsBefore(
|
|
200
|
+
ctx: HooksContext,
|
|
201
|
+
entityId: EntityId,
|
|
202
|
+
requiredComponents: EntityId<any>[],
|
|
203
|
+
removedComponents: Map<EntityId<any>, any>,
|
|
204
|
+
): boolean {
|
|
205
|
+
return requiredComponents.every((c) => {
|
|
206
|
+
// Check if a matching component was removed
|
|
207
|
+
if (anyComponentMatches(removedComponents, c)) return true;
|
|
208
|
+
|
|
209
|
+
// For wildcard relations, check if the entity still has matching relations
|
|
210
|
+
if (isWildcardRelationId(c)) {
|
|
211
|
+
const wildcardResult = ctx.getOptional(entityId, c);
|
|
212
|
+
if (!wildcardResult) return false;
|
|
213
|
+
const wildcardData = wildcardResult.value;
|
|
214
|
+
return Array.isArray(wildcardData) && wildcardData.length > 0;
|
|
215
|
+
}
|
|
216
|
+
return ctx.has(entityId, c);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function collectMultiHookComponents(
|
|
221
|
+
ctx: HooksContext,
|
|
222
|
+
entityId: EntityId,
|
|
223
|
+
componentTypes: readonly ComponentType<any>[],
|
|
224
|
+
): any[] {
|
|
225
|
+
return componentTypes.map((ct) =>
|
|
226
|
+
isOptionalEntityId(ct) ? ctx.getOptional(entityId, ct.optional) : ctx.get(entityId, ct as EntityId<any>),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Reconstructs wildcard relation data by merging current data with removed components.
|
|
232
|
+
* Returns an array of [targetId, value] tuples for the wildcard relation.
|
|
233
|
+
*
|
|
234
|
+
* This is used during "on_remove" hook invocation: the removed components have already
|
|
235
|
+
* been taken out of the entity's archetype, but the hook callback expects to see the
|
|
236
|
+
* full data as it existed *before* removal. We reconstruct that snapshot by taking the
|
|
237
|
+
* current wildcard data (post-removal) and adding back the entries that were just removed.
|
|
238
|
+
*/
|
|
239
|
+
function reconstructWildcardWithRemoved(
|
|
240
|
+
ctx: HooksContext,
|
|
241
|
+
entityId: EntityId,
|
|
242
|
+
wildcardId: EntityId<any>,
|
|
243
|
+
removedComponents: Map<EntityId<any>, any>,
|
|
244
|
+
): [EntityId, any][] {
|
|
245
|
+
// ctx.get() for a wildcard relation ID always returns [EntityId, any][] at runtime
|
|
246
|
+
// (see Archetype.getWildcardRelations / ComponentEntityStore.getWildcard).
|
|
247
|
+
// The HooksContext interface erases the WildcardRelationId overload for simplicity,
|
|
248
|
+
// so we assert the expected shape here rather than silently falling back to [].
|
|
249
|
+
const currentData = ctx.get(entityId, wildcardId);
|
|
250
|
+
if (!Array.isArray(currentData)) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`Expected wildcard relation data to be an array, but got ${typeof currentData} ` +
|
|
253
|
+
`for entity ${entityId} and wildcard ${wildcardId}. ` +
|
|
254
|
+
`This indicates a HooksContext implementation that does not conform to the expected contract.`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Spread-copy the array so that pushing removed entries below does not mutate
|
|
259
|
+
// the archetype's internal storage. Without the copy, we would leak removed
|
|
260
|
+
// component data back into the live entity data.
|
|
261
|
+
const result = [...currentData];
|
|
262
|
+
|
|
263
|
+
// Re-inject matching relations that were just removed, so the hook callback
|
|
264
|
+
// sees the complete snapshot as it existed before the removal.
|
|
265
|
+
for (const [removedCompId, removedValue] of removedComponents.entries()) {
|
|
266
|
+
if (componentMatchesHookType(removedCompId, wildcardId)) {
|
|
267
|
+
const targetId = getTargetIdFromRelationId(removedCompId);
|
|
268
|
+
if (targetId !== undefined) {
|
|
269
|
+
result.push([targetId, removedValue]);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function collectMultiHookComponentsWithRemoved(
|
|
278
|
+
ctx: HooksContext,
|
|
279
|
+
entityId: EntityId,
|
|
280
|
+
componentTypes: readonly ComponentType<any>[],
|
|
281
|
+
removedComponents: Map<EntityId<any>, any>,
|
|
282
|
+
): any[] {
|
|
283
|
+
return componentTypes.map((ct) => {
|
|
284
|
+
if (isOptionalEntityId(ct)) {
|
|
285
|
+
const optionalId = ct.optional;
|
|
286
|
+
|
|
287
|
+
if (isWildcardRelationId(optionalId)) {
|
|
288
|
+
const result = reconstructWildcardWithRemoved(ctx, entityId, optionalId, removedComponents);
|
|
289
|
+
return result.length > 0 ? { value: result } : undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const match = findMatchingComponent(removedComponents, optionalId);
|
|
293
|
+
return match ? { value: match[1] } : ctx.getOptional(entityId, optionalId);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const compId = ct as EntityId<any>;
|
|
297
|
+
|
|
298
|
+
if (isWildcardRelationId(compId)) {
|
|
299
|
+
return reconstructWildcardWithRemoved(ctx, entityId, compId, removedComponents);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const match = findMatchingComponent(removedComponents, compId);
|
|
303
|
+
return match ? match[1] : ctx.get(entityId, compId);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Collect component values directly from removedComponents map.
|
|
309
|
+
* Used for entity deletion fast path where the entity no longer exists.
|
|
310
|
+
*/
|
|
311
|
+
function collectComponentsFromRemoved(
|
|
312
|
+
componentTypes: readonly ComponentType<any>[],
|
|
313
|
+
removedComponents: Map<EntityId<any>, any>,
|
|
314
|
+
): any[] {
|
|
315
|
+
return componentTypes.map((ct) => {
|
|
316
|
+
if (isOptionalEntityId(ct)) {
|
|
317
|
+
const optionalId = ct.optional;
|
|
318
|
+
|
|
319
|
+
if (isWildcardRelationId(optionalId)) {
|
|
320
|
+
const result = collectWildcardFromRemoved(optionalId, removedComponents);
|
|
321
|
+
return result.length > 0 ? { value: result } : undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const match = findMatchingComponent(removedComponents, optionalId);
|
|
325
|
+
return match ? { value: match[1] } : undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const compId = ct as EntityId<any>;
|
|
329
|
+
|
|
330
|
+
if (isWildcardRelationId(compId)) {
|
|
331
|
+
return collectWildcardFromRemoved(compId, removedComponents);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const match = findMatchingComponent(removedComponents, compId);
|
|
335
|
+
return match ? match[1] : undefined;
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Collect all matching wildcard relation data from removed components.
|
|
341
|
+
*/
|
|
342
|
+
function collectWildcardFromRemoved(
|
|
343
|
+
wildcardId: EntityId<any>,
|
|
344
|
+
removedComponents: Map<EntityId<any>, any>,
|
|
345
|
+
): [EntityId, any][] {
|
|
346
|
+
const result: [EntityId, any][] = [];
|
|
347
|
+
|
|
348
|
+
for (const [removedCompId, removedValue] of removedComponents.entries()) {
|
|
349
|
+
if (componentMatchesHookType(removedCompId, wildcardId)) {
|
|
350
|
+
const targetId = getTargetIdFromRelationId(removedCompId);
|
|
351
|
+
if (targetId !== undefined) {
|
|
352
|
+
result.push([targetId, removedValue]);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return result;
|
|
358
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { EntityId } from "../entity";
|
|
2
|
+
import { MultiMap } from "../utils/multi-map";
|
|
3
|
+
|
|
4
|
+
export type EntityReferencesMap = Map<EntityId, MultiMap<EntityId, EntityId>>;
|
|
5
|
+
|
|
6
|
+
export function trackEntityReference(
|
|
7
|
+
entityReferences: EntityReferencesMap,
|
|
8
|
+
sourceEntityId: EntityId,
|
|
9
|
+
componentType: EntityId,
|
|
10
|
+
targetEntityId: EntityId,
|
|
11
|
+
): void {
|
|
12
|
+
if (!entityReferences.has(targetEntityId)) {
|
|
13
|
+
entityReferences.set(targetEntityId, new MultiMap());
|
|
14
|
+
}
|
|
15
|
+
entityReferences.get(targetEntityId)!.add(sourceEntityId, componentType);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function untrackEntityReference(
|
|
19
|
+
entityReferences: EntityReferencesMap,
|
|
20
|
+
sourceEntityId: EntityId,
|
|
21
|
+
componentType: EntityId,
|
|
22
|
+
targetEntityId: EntityId,
|
|
23
|
+
): void {
|
|
24
|
+
const references = entityReferences.get(targetEntityId);
|
|
25
|
+
if (references) {
|
|
26
|
+
references.remove(sourceEntityId, componentType);
|
|
27
|
+
if (references.keyCount === 0) {
|
|
28
|
+
entityReferences.delete(targetEntityId);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getEntityReferences(
|
|
34
|
+
entityReferences: EntityReferencesMap,
|
|
35
|
+
targetEntityId: EntityId,
|
|
36
|
+
): Iterable<[EntityId, EntityId]> {
|
|
37
|
+
return entityReferences.get(targetEntityId) ?? new MultiMap();
|
|
38
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { MISSING_COMPONENT, type Archetype } from "../archetype/archetype";
|
|
2
|
+
import type { ComponentEntityStore } from "../component/entity-store";
|
|
3
|
+
import { getDetailedIdType, type EntityId, type EntityIdManager } from "../entity";
|
|
4
|
+
import {
|
|
5
|
+
decodeSerializedId,
|
|
6
|
+
encodeEntityId,
|
|
7
|
+
type SerializedComponent,
|
|
8
|
+
type SerializedEntity,
|
|
9
|
+
type SerializedWorld,
|
|
10
|
+
} from "../storage/serialization";
|
|
11
|
+
import { trackEntityReference, type EntityReferencesMap } from "./references";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Serializes the full world state to a plain JS object suitable for JSON encoding.
|
|
15
|
+
*/
|
|
16
|
+
export function serializeWorld(
|
|
17
|
+
archetypes: Archetype[],
|
|
18
|
+
componentEntities: ComponentEntityStore,
|
|
19
|
+
entityIdManager: EntityIdManager,
|
|
20
|
+
): SerializedWorld {
|
|
21
|
+
const entities: SerializedEntity[] = [];
|
|
22
|
+
|
|
23
|
+
for (const archetype of archetypes) {
|
|
24
|
+
const dumpedEntities = archetype.dump();
|
|
25
|
+
for (const { entity, components } of dumpedEntities) {
|
|
26
|
+
entities.push({
|
|
27
|
+
id: encodeEntityId(entity),
|
|
28
|
+
components: Array.from(components.entries()).map(([rawType, value]) => ({
|
|
29
|
+
type: encodeEntityId(rawType),
|
|
30
|
+
value: value === MISSING_COMPONENT ? undefined : value,
|
|
31
|
+
})),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const componentEntitiesArr: SerializedEntity[] = [];
|
|
37
|
+
for (const [entityId, components] of componentEntities.entries()) {
|
|
38
|
+
componentEntitiesArr.push({
|
|
39
|
+
id: encodeEntityId(entityId),
|
|
40
|
+
components: Array.from(components.entries()).map(([rawType, value]) => ({
|
|
41
|
+
type: encodeEntityId(rawType),
|
|
42
|
+
value: value === MISSING_COMPONENT ? undefined : value,
|
|
43
|
+
})),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
version: 1,
|
|
49
|
+
entityManager: entityIdManager.serializeState(),
|
|
50
|
+
entities,
|
|
51
|
+
componentEntities: componentEntitiesArr,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Context needed by `deserializeWorld` to populate world-internal state.
|
|
57
|
+
* Defined as an interface to avoid a circular import between world.ts and this module.
|
|
58
|
+
*/
|
|
59
|
+
export interface WorldDeserializationContext {
|
|
60
|
+
entityIdManager: EntityIdManager;
|
|
61
|
+
componentEntities: ComponentEntityStore;
|
|
62
|
+
entityReferences: EntityReferencesMap;
|
|
63
|
+
ensureArchetype(componentTypes: EntityId<any>[]): Archetype;
|
|
64
|
+
setEntityToArchetype(entityId: EntityId, archetype: Archetype): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Restores world state from a snapshot into the provided context.
|
|
69
|
+
* Intended to be called from `World`'s constructor.
|
|
70
|
+
*/
|
|
71
|
+
export function deserializeWorld(ctx: WorldDeserializationContext, snapshot: SerializedWorld): void {
|
|
72
|
+
if (snapshot.entityManager) {
|
|
73
|
+
ctx.entityIdManager.deserializeState(snapshot.entityManager);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (Array.isArray(snapshot.componentEntities)) {
|
|
77
|
+
for (const entry of snapshot.componentEntities) {
|
|
78
|
+
const entityId = decodeSerializedId(entry.id);
|
|
79
|
+
if (!ctx.componentEntities.exists(entityId)) continue;
|
|
80
|
+
|
|
81
|
+
const componentsArray: SerializedComponent[] = entry.components || [];
|
|
82
|
+
const componentMap = new Map<EntityId<any>, any>();
|
|
83
|
+
|
|
84
|
+
for (const componentEntry of componentsArray) {
|
|
85
|
+
const componentType = decodeSerializedId(componentEntry.type);
|
|
86
|
+
componentMap.set(componentType, componentEntry.value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
ctx.componentEntities.initFromSnapshot(entityId, componentMap);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (Array.isArray(snapshot.entities)) {
|
|
94
|
+
for (const entry of snapshot.entities) {
|
|
95
|
+
const entityId = decodeSerializedId(entry.id);
|
|
96
|
+
const componentsArray: SerializedComponent[] = entry.components || [];
|
|
97
|
+
|
|
98
|
+
const componentMap = new Map<EntityId<any>, any>();
|
|
99
|
+
const componentTypes: EntityId<any>[] = [];
|
|
100
|
+
|
|
101
|
+
for (const componentEntry of componentsArray) {
|
|
102
|
+
const componentType = decodeSerializedId(componentEntry.type);
|
|
103
|
+
componentMap.set(componentType, componentEntry.value);
|
|
104
|
+
componentTypes.push(componentType);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const archetype = ctx.ensureArchetype(componentTypes);
|
|
108
|
+
archetype.addEntity(entityId, componentMap);
|
|
109
|
+
ctx.setEntityToArchetype(entityId, archetype);
|
|
110
|
+
|
|
111
|
+
for (const compType of componentTypes) {
|
|
112
|
+
const detailedType = getDetailedIdType(compType);
|
|
113
|
+
if (detailedType.type === "entity-relation") {
|
|
114
|
+
// Safe: targetId guaranteed for entity-relation type
|
|
115
|
+
trackEntityReference(ctx.entityReferences, entityId, compType, detailedType.targetId);
|
|
116
|
+
} else if (detailedType.type === "entity") {
|
|
117
|
+
trackEntityReference(ctx.entityReferences, entityId, compType, compType);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|