@forgeax/engine-scene 0.1.27 → 0.1.29
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.md +50 -0
- package/dist/__tests__/keyed-scene.integration.test.d.ts +2 -0
- package/dist/__tests__/keyed-scene.integration.test.d.ts.map +1 -0
- package/dist/assets/scene-decoder.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +848 -229
- package/dist/index.mjs.map +1 -1
- package/dist/instances/binding.d.ts +7 -5
- package/dist/instances/binding.d.ts.map +1 -1
- package/dist/instances/externalization.d.ts +1 -1
- package/dist/instances/externalization.d.ts.map +1 -1
- package/dist/instances/keyed.d.ts +38 -0
- package/dist/instances/keyed.d.ts.map +1 -0
- package/dist/instances/legacy.d.ts +20 -0
- package/dist/instances/legacy.d.ts.map +1 -0
- package/dist/instances/runtime-types.d.ts +20 -0
- package/dist/instances/runtime-types.d.ts.map +1 -0
- package/dist/instances/scene-instances.d.ts +27 -37
- package/dist/instances/scene-instances.d.ts.map +1 -1
- package/dist/instances/state.d.ts +6 -1
- package/dist/instances/state.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/asset-owner.integration.test.ts +4 -7
- package/src/__tests__/flat-propagation.perf.test.ts +18 -17
- package/src/__tests__/keyed-scene.integration.test.ts +269 -0
- package/src/__tests__/scene-binding.integration.test.ts +141 -32
- package/src/__tests__/structural.test.ts +4 -1
- package/src/assets/scene-decoder.ts +123 -33
- package/src/collect-subtree.ts +2 -2
- package/src/index.ts +10 -1
- package/src/instances/binding.ts +26 -19
- package/src/instances/externalization.ts +115 -97
- package/src/instances/keyed.ts +505 -0
- package/src/instances/legacy.ts +108 -0
- package/src/instances/runtime-types.ts +21 -0
- package/src/instances/scene-instances.ts +220 -83
- package/src/instances/state.ts +6 -1
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import type { Component, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { classifyEntityField, remapEntityFieldValue } from '@forgeax/engine-ecs/externalization';
|
|
3
|
+
import { componentSchema } from '@forgeax/engine-ecs/internal';
|
|
4
|
+
import type {
|
|
5
|
+
ComponentValuesMap,
|
|
6
|
+
LocalEntityId,
|
|
7
|
+
SceneAsset,
|
|
8
|
+
SceneEntityAddress,
|
|
9
|
+
} from '@forgeax/engine-types';
|
|
10
|
+
import {
|
|
11
|
+
err,
|
|
12
|
+
type Handle,
|
|
13
|
+
ok,
|
|
14
|
+
PACK_ERROR_HINTS,
|
|
15
|
+
type Result,
|
|
16
|
+
type SceneEntity,
|
|
17
|
+
} from '@forgeax/engine-types';
|
|
18
|
+
import { migrateLegacySceneComponentFields, normalizeLegacySceneAsset } from './legacy.js';
|
|
19
|
+
import type { MountOverride, SceneInstanceMount } from './runtime-types.js';
|
|
20
|
+
|
|
21
|
+
/** Numeric representation used only inside the Scene runtime. */
|
|
22
|
+
export interface CompiledSceneEntity {
|
|
23
|
+
readonly localId: LocalEntityId;
|
|
24
|
+
readonly components: Partial<ComponentValuesMap>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CompiledSceneAsset {
|
|
28
|
+
readonly kind: 'scene';
|
|
29
|
+
readonly entities: readonly CompiledSceneEntity[];
|
|
30
|
+
readonly mounts?: readonly SceneInstanceMount[];
|
|
31
|
+
readonly skinGuids?: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CompiledSceneResult {
|
|
35
|
+
readonly asset: CompiledSceneAsset;
|
|
36
|
+
readonly keyByLocalId: ReadonlyMap<number, string>;
|
|
37
|
+
readonly mountKeyByLocalId: ReadonlyMap<number, string>;
|
|
38
|
+
/** Private slots that attach directly to this scene's synthetic root. */
|
|
39
|
+
readonly rootLocalIds: readonly number[];
|
|
40
|
+
/** Effective private ChildOf edges, including nested scene attachment. */
|
|
41
|
+
readonly hierarchyParentByLocalId: ReadonlyMap<number, number>;
|
|
42
|
+
readonly resolveAddress: (address: unknown, field?: string) => number | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface KeyedSceneCompileContext {
|
|
46
|
+
readonly resolveSource: (
|
|
47
|
+
source: string,
|
|
48
|
+
parent: Handle<'SceneAsset', 'shared'>,
|
|
49
|
+
) => Result<Handle<'SceneAsset', 'shared'>, unknown>;
|
|
50
|
+
readonly resolveAsset: (handle: Handle<'SceneAsset', 'shared'>) => Result<SceneAsset, unknown>;
|
|
51
|
+
readonly stack: ReadonlySet<number>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fail(reason: string, detail: Record<string, unknown> = {}): Result<never, unknown> {
|
|
55
|
+
return err({
|
|
56
|
+
code: 'asset-package-invalid',
|
|
57
|
+
expected: 'a keyed SceneAsset with valid entity and instance addresses',
|
|
58
|
+
hint: 'repair the SceneAsset source and recook the asset',
|
|
59
|
+
detail: { reason, ...detail },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function keyList(entities: Readonly<Record<string, SceneEntity>>): string[] {
|
|
64
|
+
return Object.keys(entities).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function addressParts(value: unknown): readonly string[] | undefined {
|
|
68
|
+
if (typeof value === 'string' && value.length > 0) return [value];
|
|
69
|
+
// ScriptablePack 0.1.27 serialized ChildOf/Children addresses as numeric
|
|
70
|
+
// local IDs. Accept that legacy wire form only at this compiler boundary;
|
|
71
|
+
// the authoring contract remains string keyed.
|
|
72
|
+
if (Number.isSafeInteger(value)) return [String(value)];
|
|
73
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
74
|
+
if (!value.every((part) => typeof part === 'string' && part.length > 0)) return undefined;
|
|
75
|
+
return value as readonly string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function fieldRemap(
|
|
79
|
+
world: World,
|
|
80
|
+
componentName: string,
|
|
81
|
+
fields: Record<string, unknown>,
|
|
82
|
+
resolveAddress: (address: unknown, field: string) => number | undefined,
|
|
83
|
+
entityKey?: string,
|
|
84
|
+
): Result<Record<string, unknown>, unknown> {
|
|
85
|
+
const token = world.components.resolve(componentName);
|
|
86
|
+
if (token === undefined) return fail('unknown component', { component: componentName });
|
|
87
|
+
const schema = componentSchema(token) as Record<string, string>;
|
|
88
|
+
const out: Record<string, unknown> = {};
|
|
89
|
+
for (const [fieldName, value] of Object.entries(
|
|
90
|
+
migrateLegacySceneComponentFields(componentName, fields),
|
|
91
|
+
)) {
|
|
92
|
+
const fieldType = schema[fieldName];
|
|
93
|
+
if (fieldType === undefined) {
|
|
94
|
+
return fail('unknown component field', {
|
|
95
|
+
component: componentName,
|
|
96
|
+
field: fieldName,
|
|
97
|
+
...(entityKey === undefined ? {} : { entity: entityKey }),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const kind = classifyEntityField(token as Component, fieldName);
|
|
101
|
+
if (kind === null) {
|
|
102
|
+
out[fieldName] = value;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const remap = (address: number): number =>
|
|
106
|
+
resolveAddress(address, `${componentName}.${fieldName}`) ?? address;
|
|
107
|
+
// The keyed authoring model uses string addresses for scalar entity fields
|
|
108
|
+
// and an address per element for array<entity>. The ECS kernel still gets
|
|
109
|
+
// numeric local slots, so conversion is complete before spawn.
|
|
110
|
+
if (kind.isArray) {
|
|
111
|
+
if (!Array.isArray(value))
|
|
112
|
+
return fail('array entity field is not an array', {
|
|
113
|
+
component: componentName,
|
|
114
|
+
field: fieldName,
|
|
115
|
+
});
|
|
116
|
+
const numeric: number[] = [];
|
|
117
|
+
for (const item of value) {
|
|
118
|
+
const parts = addressParts(item);
|
|
119
|
+
if (parts === undefined)
|
|
120
|
+
return fail('invalid entity address', {
|
|
121
|
+
component: componentName,
|
|
122
|
+
field: fieldName,
|
|
123
|
+
address: item,
|
|
124
|
+
});
|
|
125
|
+
const slot = resolveAddress(parts, `${componentName}.${fieldName}`);
|
|
126
|
+
if (slot === undefined)
|
|
127
|
+
return fail('missing entity address target', {
|
|
128
|
+
component: componentName,
|
|
129
|
+
field: fieldName,
|
|
130
|
+
address: parts,
|
|
131
|
+
});
|
|
132
|
+
numeric.push(slot);
|
|
133
|
+
}
|
|
134
|
+
out[fieldName] = remapEntityFieldValue(numeric, kind, remap);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (value === null) {
|
|
138
|
+
out[fieldName] = null;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const parts = addressParts(value);
|
|
142
|
+
if (parts === undefined)
|
|
143
|
+
return fail('invalid entity address', {
|
|
144
|
+
component: componentName,
|
|
145
|
+
field: fieldName,
|
|
146
|
+
address: value,
|
|
147
|
+
});
|
|
148
|
+
const slot = resolveAddress(parts, `${componentName}.${fieldName}`);
|
|
149
|
+
if (slot === undefined)
|
|
150
|
+
return fail('missing entity address target', {
|
|
151
|
+
component: componentName,
|
|
152
|
+
field: fieldName,
|
|
153
|
+
address: parts,
|
|
154
|
+
});
|
|
155
|
+
out[fieldName] = remapEntityFieldValue(slot, kind, remap);
|
|
156
|
+
}
|
|
157
|
+
return ok(out);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Compile keyed author data to the private numeric Scene representation. The
|
|
162
|
+
* compiler establishes every local and nested address before the caller starts
|
|
163
|
+
* spawning entities, so malformed references and recursive instances cannot
|
|
164
|
+
* leave a partially usable World projection.
|
|
165
|
+
*/
|
|
166
|
+
export function compileKeyedSceneAsset(
|
|
167
|
+
world: World,
|
|
168
|
+
handle: Handle<'SceneAsset', 'shared'>,
|
|
169
|
+
asset: SceneAsset,
|
|
170
|
+
context: KeyedSceneCompileContext,
|
|
171
|
+
): Result<CompiledSceneResult, unknown> {
|
|
172
|
+
asset = normalizeLegacySceneAsset(asset);
|
|
173
|
+
if (
|
|
174
|
+
asset.kind !== 'scene' ||
|
|
175
|
+
asset.entities === null ||
|
|
176
|
+
typeof asset.entities !== 'object' ||
|
|
177
|
+
Array.isArray(asset.entities)
|
|
178
|
+
) {
|
|
179
|
+
return fail('entities must be a keyed object');
|
|
180
|
+
}
|
|
181
|
+
const currentRaw = Number(handle);
|
|
182
|
+
const activeStack = context.stack.has(currentRaw)
|
|
183
|
+
? context.stack
|
|
184
|
+
: new Set([...context.stack, currentRaw]);
|
|
185
|
+
const keys = keyList(asset.entities);
|
|
186
|
+
if (keys.some((key) => key.length === 0)) return fail('entity keys must be non-empty');
|
|
187
|
+
|
|
188
|
+
const ownKeys = keys.filter((key) => asset.entities[key]?.instance === undefined);
|
|
189
|
+
const instanceKeys = keys.filter((key) => asset.entities[key]?.instance !== undefined);
|
|
190
|
+
const ownSlotByKey = new Map<string, number>();
|
|
191
|
+
const instanceSlotByKey = new Map<string, number>();
|
|
192
|
+
const keyByLocalId = new Map<number, string>();
|
|
193
|
+
for (let index = 0; index < ownKeys.length; index += 1) {
|
|
194
|
+
const key = ownKeys[index] as string;
|
|
195
|
+
ownSlotByKey.set(key, index);
|
|
196
|
+
keyByLocalId.set(index, key);
|
|
197
|
+
}
|
|
198
|
+
for (let index = 0; index < instanceKeys.length; index += 1) {
|
|
199
|
+
const key = instanceKeys[index] as string;
|
|
200
|
+
const slot = ownKeys.length + index;
|
|
201
|
+
instanceSlotByKey.set(key, slot);
|
|
202
|
+
keyByLocalId.set(slot, key);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const childCompiled = new Map<
|
|
206
|
+
string,
|
|
207
|
+
{ handle: Handle<'SceneAsset', 'shared'>; compiled: CompiledSceneResult }
|
|
208
|
+
>();
|
|
209
|
+
for (const key of instanceKeys) {
|
|
210
|
+
const declaration = asset.entities[key]?.instance;
|
|
211
|
+
if (
|
|
212
|
+
declaration === undefined ||
|
|
213
|
+
typeof declaration.source !== 'string' ||
|
|
214
|
+
declaration.source.length === 0
|
|
215
|
+
) {
|
|
216
|
+
return fail('instance source must be a non-empty GUID', { entity: key });
|
|
217
|
+
}
|
|
218
|
+
const childHandle = context.resolveSource(declaration.source, handle);
|
|
219
|
+
if (!childHandle.ok) return childHandle;
|
|
220
|
+
const childRaw = Number(childHandle.value);
|
|
221
|
+
if (activeStack.has(childRaw)) {
|
|
222
|
+
return err({
|
|
223
|
+
code: 'pack-cyclic-reference',
|
|
224
|
+
expected: 'acyclic SceneAsset instance graph',
|
|
225
|
+
hint: PACK_ERROR_HINTS['pack-cyclic-reference'],
|
|
226
|
+
detail: {
|
|
227
|
+
code: 'pack-cyclic-reference',
|
|
228
|
+
kind: 'mount-asset',
|
|
229
|
+
cycle: [...activeStack, childRaw].map(String),
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const childAsset = context.resolveAsset(childHandle.value);
|
|
234
|
+
if (!childAsset.ok) return childAsset;
|
|
235
|
+
const childContext: KeyedSceneCompileContext = {
|
|
236
|
+
...context,
|
|
237
|
+
stack: activeStack,
|
|
238
|
+
};
|
|
239
|
+
const compiled = compileKeyedSceneAsset(
|
|
240
|
+
world,
|
|
241
|
+
childHandle.value,
|
|
242
|
+
childAsset.value,
|
|
243
|
+
childContext,
|
|
244
|
+
);
|
|
245
|
+
if (!compiled.ok) return compiled;
|
|
246
|
+
childCompiled.set(key, { handle: childHandle.value, compiled: compiled.value });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const mountKeyByLocalId = new Map<number, string>();
|
|
250
|
+
const mounts: SceneInstanceMount[] = [];
|
|
251
|
+
let nextMemberFirst = ownKeys.length + instanceKeys.length;
|
|
252
|
+
for (let index = 0; index < instanceKeys.length; index += 1) {
|
|
253
|
+
const key = instanceKeys[index] as string;
|
|
254
|
+
const slot = instanceSlotByKey.get(key) as number;
|
|
255
|
+
const child = childCompiled.get(key) as {
|
|
256
|
+
handle: Handle<'SceneAsset', 'shared'>;
|
|
257
|
+
compiled: CompiledSceneResult;
|
|
258
|
+
};
|
|
259
|
+
const node = asset.entities[key] as SceneEntity;
|
|
260
|
+
mountKeyByLocalId.set(slot, key);
|
|
261
|
+
mounts.push({
|
|
262
|
+
localId: slot as LocalEntityId,
|
|
263
|
+
source: Number(child.handle),
|
|
264
|
+
memberFirst: nextMemberFirst as LocalEntityId,
|
|
265
|
+
memberCount:
|
|
266
|
+
child.compiled.asset.entities.length +
|
|
267
|
+
(child.compiled.asset.mounts?.length ?? 0) +
|
|
268
|
+
(child.compiled.asset.mounts ?? []).reduce((sum, mount) => sum + mount.memberCount, 0),
|
|
269
|
+
...(Object.keys(node.components).length > 0 ? { components: node.components } : {}),
|
|
270
|
+
});
|
|
271
|
+
nextMemberFirst += mounts[index]?.memberCount ?? 0;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const mountByKey = new Map<string, SceneInstanceMount>();
|
|
275
|
+
for (const mount of mounts)
|
|
276
|
+
mountByKey.set(mountKeyByLocalId.get(Number(mount.localId)) as string, mount);
|
|
277
|
+
|
|
278
|
+
const resolveInChild = (
|
|
279
|
+
childResult: CompiledSceneResult,
|
|
280
|
+
value: unknown,
|
|
281
|
+
_field?: string,
|
|
282
|
+
): number | undefined => {
|
|
283
|
+
const parts = addressParts(value);
|
|
284
|
+
if (parts === undefined) return undefined;
|
|
285
|
+
return childResult.resolveAddress(parts);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const resolveAddress = (value: unknown, field?: string): number | undefined => {
|
|
289
|
+
const parts = addressParts(value);
|
|
290
|
+
if (parts === undefined) return undefined;
|
|
291
|
+
const first = parts[0];
|
|
292
|
+
if (first === undefined) return undefined;
|
|
293
|
+
const own = ownSlotByKey.get(first) ?? instanceSlotByKey.get(first);
|
|
294
|
+
if (own !== undefined && parts.length === 1) return own;
|
|
295
|
+
const mount = mountByKey.get(first);
|
|
296
|
+
if (mount === undefined) return undefined;
|
|
297
|
+
const child = childCompiled.get(first);
|
|
298
|
+
if (child === undefined) return undefined;
|
|
299
|
+
const childSlot = resolveInChild(child.compiled, parts.slice(1), field);
|
|
300
|
+
return childSlot === undefined ? undefined : (mount.memberFirst as number) + childSlot;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// Instance entities carry their own authored components. They occupy the
|
|
304
|
+
// mount slot in the private representation, so run the same schema driven
|
|
305
|
+
// conversion as ordinary entities before any spawn occurs.
|
|
306
|
+
for (const key of instanceKeys) {
|
|
307
|
+
const node = asset.entities[key] as SceneEntity;
|
|
308
|
+
const mount = mountByKey.get(key) as SceneInstanceMount;
|
|
309
|
+
const convertedFields = Object.fromEntries(
|
|
310
|
+
Object.entries(node.components).map(([componentName, raw]) => [
|
|
311
|
+
componentName,
|
|
312
|
+
fieldRemap(
|
|
313
|
+
world,
|
|
314
|
+
componentName,
|
|
315
|
+
{ ...(raw as Record<string, unknown>) },
|
|
316
|
+
resolveAddress,
|
|
317
|
+
key,
|
|
318
|
+
),
|
|
319
|
+
]),
|
|
320
|
+
) as Record<string, Result<Record<string, unknown>, unknown>>;
|
|
321
|
+
const bad = Object.values(convertedFields).find((result) => !result.ok);
|
|
322
|
+
if (bad !== undefined && !bad.ok) return bad;
|
|
323
|
+
const components: Record<string, Record<string, unknown>> = {};
|
|
324
|
+
for (const [componentName, result] of Object.entries(convertedFields)) {
|
|
325
|
+
if (!result.ok) return result;
|
|
326
|
+
components[componentName] = result.value;
|
|
327
|
+
}
|
|
328
|
+
const index = mounts.findIndex((item) => item.localId === mount.localId);
|
|
329
|
+
if (index >= 0) {
|
|
330
|
+
const childOf = components.ChildOf?.parent;
|
|
331
|
+
// An instance declaration's ChildOf belongs to the private mount slot,
|
|
332
|
+
// whose deferred parent wiring runs after own entities exist. Keeping it
|
|
333
|
+
// inside mount.components would remap the parent before that slot is
|
|
334
|
+
// live and silently lose the authored hierarchy edge.
|
|
335
|
+
if (typeof childOf === 'number') {
|
|
336
|
+
const { ChildOf: _ignored, ...mountComponents } = components;
|
|
337
|
+
void _ignored;
|
|
338
|
+
mounts[index] = { ...mount, components: mountComponents, parent: childOf as LocalEntityId };
|
|
339
|
+
} else {
|
|
340
|
+
mounts[index] = { ...mount, components };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const converted: CompiledSceneEntity[] = [];
|
|
346
|
+
for (const key of ownKeys) {
|
|
347
|
+
const node = asset.entities[key] as SceneEntity;
|
|
348
|
+
const components: Record<string, Record<string, unknown>> = {};
|
|
349
|
+
for (const [componentName, raw] of Object.entries(node.components)) {
|
|
350
|
+
const convertedFields = fieldRemap(
|
|
351
|
+
world,
|
|
352
|
+
componentName,
|
|
353
|
+
{ ...(raw as Record<string, unknown>) },
|
|
354
|
+
resolveAddress,
|
|
355
|
+
key,
|
|
356
|
+
);
|
|
357
|
+
if (!convertedFields.ok) return convertedFields;
|
|
358
|
+
components[componentName] = convertedFields.value;
|
|
359
|
+
}
|
|
360
|
+
converted.push({ localId: ownSlotByKey.get(key) as LocalEntityId, components });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Convert ordered child-relative overrides into the private field patch
|
|
364
|
+
// representation. Values resolve in the declaring parent namespace.
|
|
365
|
+
// A component declaration that is absent on the target is represented as one
|
|
366
|
+
// component-add override; an existing component stays field-granular so an
|
|
367
|
+
// override cannot erase fields that were not mentioned by the author.
|
|
368
|
+
const childHasComponent = (
|
|
369
|
+
result: CompiledSceneResult,
|
|
370
|
+
target: number,
|
|
371
|
+
componentName: string,
|
|
372
|
+
): boolean => {
|
|
373
|
+
const own = result.asset.entities.find((entity) => Number(entity.localId) === target);
|
|
374
|
+
if (own !== undefined && own.components[componentName] !== undefined) return true;
|
|
375
|
+
const mount = result.asset.mounts?.find((entry) => Number(entry.localId) === target);
|
|
376
|
+
return mount?.components?.[componentName] !== undefined;
|
|
377
|
+
};
|
|
378
|
+
for (const key of instanceKeys) {
|
|
379
|
+
const node = asset.entities[key] as SceneEntity;
|
|
380
|
+
const declaration = node.instance as NonNullable<SceneEntity['instance']>;
|
|
381
|
+
const mount = mountByKey.get(key) as SceneInstanceMount;
|
|
382
|
+
const child = childCompiled.get(key) as { compiled: CompiledSceneResult };
|
|
383
|
+
const childSlot = (target: SceneEntityAddress): number | undefined =>
|
|
384
|
+
resolveInChild(child.compiled, target, `${key}.instance`);
|
|
385
|
+
const overrides: MountOverride[] = [];
|
|
386
|
+
for (const override of declaration.overrides ?? []) {
|
|
387
|
+
const target = childSlot(override.target);
|
|
388
|
+
if (target === undefined)
|
|
389
|
+
return fail('instance override target does not exist', {
|
|
390
|
+
entity: key,
|
|
391
|
+
target: override.target,
|
|
392
|
+
});
|
|
393
|
+
for (const [componentName, fields] of Object.entries(override.components)) {
|
|
394
|
+
const convertedFields = fieldRemap(
|
|
395
|
+
world,
|
|
396
|
+
componentName,
|
|
397
|
+
{ ...(fields as Record<string, unknown>) },
|
|
398
|
+
resolveAddress,
|
|
399
|
+
`${key}.instance.${override.target.join('.')}`,
|
|
400
|
+
);
|
|
401
|
+
if (!convertedFields.ok) return convertedFields;
|
|
402
|
+
if (!childHasComponent(child.compiled, target, componentName)) {
|
|
403
|
+
overrides.push({
|
|
404
|
+
localId: ((mount.memberFirst as number) + target) as LocalEntityId,
|
|
405
|
+
comp: componentName,
|
|
406
|
+
value: convertedFields.value,
|
|
407
|
+
});
|
|
408
|
+
} else {
|
|
409
|
+
overrides.push(
|
|
410
|
+
...Object.entries(convertedFields.value).map(([field, value]) => ({
|
|
411
|
+
localId: ((mount.memberFirst as number) + target) as LocalEntityId,
|
|
412
|
+
comp: componentName,
|
|
413
|
+
field,
|
|
414
|
+
value,
|
|
415
|
+
})),
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (overrides.length > 0) {
|
|
421
|
+
const index = mounts.findIndex((item) => item.localId === mount.localId);
|
|
422
|
+
const existing = mounts[index];
|
|
423
|
+
if (index >= 0 && existing !== undefined) mounts[index] = { ...existing, overrides };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const rootLocalIds: number[] = [
|
|
428
|
+
...convertedRootLocalIds(converted),
|
|
429
|
+
...mounts.filter((mount) => mount.parent === undefined).map((mount) => Number(mount.localId)),
|
|
430
|
+
];
|
|
431
|
+
|
|
432
|
+
// Validate the authored hierarchy using the same keyed address resolver that
|
|
433
|
+
// will be used for component fields. General component reference cycles are
|
|
434
|
+
// legal; only ChildOf cycles are rejected before any spawn.
|
|
435
|
+
const hierarchyParentByLocalId = new Map<number, number>();
|
|
436
|
+
for (const node of converted) {
|
|
437
|
+
const parent = node.components.ChildOf?.parent;
|
|
438
|
+
if (typeof parent === 'number' && parent >= 0) {
|
|
439
|
+
hierarchyParentByLocalId.set(Number(node.localId), parent);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
for (const mount of mounts) {
|
|
443
|
+
if (mount.parent !== undefined) {
|
|
444
|
+
hierarchyParentByLocalId.set(Number(mount.localId), mount.parent);
|
|
445
|
+
}
|
|
446
|
+
const key = mountKeyByLocalId.get(Number(mount.localId));
|
|
447
|
+
const child = key === undefined ? undefined : childCompiled.get(key);
|
|
448
|
+
if (child !== undefined) {
|
|
449
|
+
for (const [childLocalId, childParent] of child.compiled.hierarchyParentByLocalId) {
|
|
450
|
+
hierarchyParentByLocalId.set(
|
|
451
|
+
Number(mount.memberFirst) + childLocalId,
|
|
452
|
+
Number(mount.memberFirst) + childParent,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
for (const childRoot of child.compiled.rootLocalIds) {
|
|
456
|
+
hierarchyParentByLocalId.set(Number(mount.memberFirst) + childRoot, Number(mount.localId));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
for (const override of mount.overrides ?? []) {
|
|
460
|
+
if (override.comp !== 'ChildOf') continue;
|
|
461
|
+
if (override.field === 'parent' && typeof override.value === 'number') {
|
|
462
|
+
hierarchyParentByLocalId.set(Number(override.localId), override.value);
|
|
463
|
+
} else if (
|
|
464
|
+
override.field === undefined &&
|
|
465
|
+
typeof override.value === 'object' &&
|
|
466
|
+
override.value !== null
|
|
467
|
+
) {
|
|
468
|
+
const parentValue = (override.value as Record<string, unknown>).parent;
|
|
469
|
+
if (typeof parentValue === 'number') {
|
|
470
|
+
hierarchyParentByLocalId.set(Number(override.localId), parentValue);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
for (const start of hierarchyParentByLocalId.keys()) {
|
|
476
|
+
const seen = new Set<number>();
|
|
477
|
+
let current: number | undefined = start;
|
|
478
|
+
while (current !== undefined && hierarchyParentByLocalId.has(current)) {
|
|
479
|
+
if (seen.has(current))
|
|
480
|
+
return fail('hierarchy cycle', { entity: keyByLocalId.get(start), address: [...seen] });
|
|
481
|
+
seen.add(current);
|
|
482
|
+
current = hierarchyParentByLocalId.get(current);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return ok({
|
|
487
|
+
asset: {
|
|
488
|
+
kind: 'scene',
|
|
489
|
+
entities: converted,
|
|
490
|
+
...(mounts.length > 0 ? { mounts } : {}),
|
|
491
|
+
...(asset.skinGuids === undefined ? {} : { skinGuids: asset.skinGuids }),
|
|
492
|
+
},
|
|
493
|
+
keyByLocalId,
|
|
494
|
+
mountKeyByLocalId,
|
|
495
|
+
rootLocalIds,
|
|
496
|
+
hierarchyParentByLocalId,
|
|
497
|
+
resolveAddress,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function convertedRootLocalIds(nodes: readonly CompiledSceneEntity[]): number[] {
|
|
502
|
+
return nodes
|
|
503
|
+
.filter((node) => node.components.ChildOf === undefined)
|
|
504
|
+
.map((node) => Number(node.localId));
|
|
505
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize the small set of SceneAsset fields emitted by the 0.1.27
|
|
3
|
+
* ScriptablePack template before the current keyed runtime validates them.
|
|
4
|
+
*
|
|
5
|
+
* This is deliberately a boundary migration. Current authoring and runtime
|
|
6
|
+
* types stay keyed and use `shadowFilter`; only old payloads carry numeric
|
|
7
|
+
* ChildOf addresses or `pcfKernelSize`.
|
|
8
|
+
*/
|
|
9
|
+
export function migrateLegacySceneComponentFields(
|
|
10
|
+
componentName: string,
|
|
11
|
+
source: Record<string, unknown>,
|
|
12
|
+
addressByLocalId?: ReadonlyMap<number, string>,
|
|
13
|
+
): Record<string, unknown> {
|
|
14
|
+
const fields = { ...source };
|
|
15
|
+
const address = (value: unknown): unknown =>
|
|
16
|
+
Number.isSafeInteger(value) ? (addressByLocalId?.get(value as number) ?? String(value)) : value;
|
|
17
|
+
if (componentName === 'DirectionalLight' && Object.hasOwn(fields, 'pcfKernelSize')) {
|
|
18
|
+
const kernel = fields.pcfKernelSize;
|
|
19
|
+
const shadowFilter = kernel === 1 ? 1 : kernel === 3 ? 2 : kernel === 5 ? 3 : undefined;
|
|
20
|
+
if (shadowFilter !== undefined && !Object.hasOwn(fields, 'shadowFilter')) {
|
|
21
|
+
delete fields.pcfKernelSize;
|
|
22
|
+
fields.shadowFilter = shadowFilter;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (componentName === 'ChildOf' && Number.isSafeInteger(fields.parent)) {
|
|
26
|
+
fields.parent = address(fields.parent);
|
|
27
|
+
}
|
|
28
|
+
if (componentName === 'Children' && Array.isArray(fields.entities)) {
|
|
29
|
+
fields.entities = fields.entities.map(address);
|
|
30
|
+
}
|
|
31
|
+
return fields;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface LegacySceneEntity {
|
|
35
|
+
readonly localId?: unknown;
|
|
36
|
+
readonly bindingKey?: unknown;
|
|
37
|
+
readonly components?: unknown;
|
|
38
|
+
readonly instance?: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Lift the pre-keyed SceneAsset array into the current keyed shape.
|
|
43
|
+
*
|
|
44
|
+
* The 0.1.27 template used `localId` for storage and `bindingKey` for the
|
|
45
|
+
* gameplay-facing names. Keeping the binding key is essential: the runtime
|
|
46
|
+
* resolves `player`, `camera`, and joints by that name, not by the old number.
|
|
47
|
+
* This function is intentionally structural and accepts `unknown` only at the
|
|
48
|
+
* compatibility boundary; current authoring types remain keyed.
|
|
49
|
+
*/
|
|
50
|
+
export function normalizeLegacySceneAsset(
|
|
51
|
+
scene: unknown,
|
|
52
|
+
): import('@forgeax/engine-types').SceneAsset {
|
|
53
|
+
if (scene === null || typeof scene !== 'object') return scene as never;
|
|
54
|
+
const candidate = scene as { readonly entities?: unknown };
|
|
55
|
+
if (!Array.isArray(candidate.entities))
|
|
56
|
+
return scene as import('@forgeax/engine-types').SceneAsset;
|
|
57
|
+
|
|
58
|
+
const rows = candidate.entities as readonly LegacySceneEntity[];
|
|
59
|
+
const addressByLocalId = new Map<number, string>();
|
|
60
|
+
const rowKeys: string[] = [];
|
|
61
|
+
const used = new Set<string>();
|
|
62
|
+
for (const [index, row] of rows.entries()) {
|
|
63
|
+
const localId = Number.isSafeInteger(row?.localId) ? (row.localId as number) : index;
|
|
64
|
+
const bindingKey =
|
|
65
|
+
typeof row?.bindingKey === 'string' && row.bindingKey.length > 0
|
|
66
|
+
? row.bindingKey
|
|
67
|
+
: String(localId);
|
|
68
|
+
const key = used.has(bindingKey) ? String(localId) : bindingKey;
|
|
69
|
+
used.add(key);
|
|
70
|
+
addressByLocalId.set(localId, key);
|
|
71
|
+
rowKeys.push(key);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const entities: Record<
|
|
75
|
+
string,
|
|
76
|
+
{ readonly components: Record<string, Record<string, unknown>>; readonly instance?: unknown }
|
|
77
|
+
> = {};
|
|
78
|
+
for (const [index, row] of rows.entries()) {
|
|
79
|
+
const key = rowKeys[index] as string;
|
|
80
|
+
const rawComponents = row?.components;
|
|
81
|
+
const components: Record<string, Record<string, unknown>> = {};
|
|
82
|
+
if (
|
|
83
|
+
rawComponents !== null &&
|
|
84
|
+
typeof rawComponents === 'object' &&
|
|
85
|
+
!Array.isArray(rawComponents)
|
|
86
|
+
) {
|
|
87
|
+
for (const [componentName, rawFields] of Object.entries(
|
|
88
|
+
rawComponents as Record<string, unknown>,
|
|
89
|
+
)) {
|
|
90
|
+
if (rawFields === null || typeof rawFields !== 'object' || Array.isArray(rawFields))
|
|
91
|
+
continue;
|
|
92
|
+
components[componentName] = migrateLegacySceneComponentFields(
|
|
93
|
+
componentName,
|
|
94
|
+
rawFields as Record<string, unknown>,
|
|
95
|
+
addressByLocalId,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
entities[key] = {
|
|
100
|
+
components,
|
|
101
|
+
...(row?.instance === undefined ? {} : { instance: row.instance }),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
...(scene as Record<string, unknown>),
|
|
106
|
+
entities,
|
|
107
|
+
} as import('@forgeax/engine-types').SceneAsset;
|
|
108
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ComponentValuesMap, LocalEntityId } from '@forgeax/engine-types';
|
|
2
|
+
|
|
3
|
+
/** Runtime-only component update used while expanding a keyed scene. */
|
|
4
|
+
export interface MountOverride {
|
|
5
|
+
readonly localId: LocalEntityId;
|
|
6
|
+
readonly comp: string;
|
|
7
|
+
readonly field?: string;
|
|
8
|
+
readonly value: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Numeric SceneInstance projection; never part of SceneAsset authoring/wire data. */
|
|
12
|
+
export interface SceneInstanceMount {
|
|
13
|
+
readonly localId: LocalEntityId;
|
|
14
|
+
readonly source: number | string;
|
|
15
|
+
readonly memberFirst: LocalEntityId;
|
|
16
|
+
readonly memberCount: number;
|
|
17
|
+
readonly parent?: LocalEntityId;
|
|
18
|
+
readonly components?: Partial<ComponentValuesMap>;
|
|
19
|
+
readonly overrides?: readonly MountOverride[];
|
|
20
|
+
readonly publicationFence?: import('@forgeax/engine-types').ScenePublicationFence;
|
|
21
|
+
}
|