diff --git a/packages/core/src/asset/ResourceManager.ts b/packages/core/src/asset/ResourceManager.ts index 54b8309589..e20e5fd139 100644 --- a/packages/core/src/asset/ResourceManager.ts +++ b/packages/core/src/asset/ResourceManager.ts @@ -44,7 +44,7 @@ export class ResourceManager { /** Asset path pool, key is the `instanceID` of resource, value is asset path. */ private _assetPool: Record = Object.create(null); /** Asset url pool, key is the asset path and the value is the asset. */ - private _assetUrlPool: Record = Object.create(null); + private _assetUrlPool: Record = Object.create(null); /** Referable resource pool, key is the `instanceID` of resource. */ private _referResourcePool: Record = Object.create(null); @@ -88,7 +88,7 @@ export class ResourceManager { */ load(path: string): AssetPromise; - load(assetInfo: string | LoadItem | (LoadItem | string)[]): AssetPromise { + load(assetInfo: string | LoadItem | (LoadItem | string)[]): AssetPromise { // single item if (!Array.isArray(assetInfo)) { return this._loadSingleItem(assetInfo); @@ -221,21 +221,6 @@ export class ResourceManager { } } - /** - * @internal - */ - _onSubAssetFail(assetBaseURL: string, assetSubPath: string, value: Error): void { - const subPromiseCallback = this._subAssetPromiseCallbacks[assetBaseURL]?.[assetSubPath]; - if (subPromiseCallback) { - subPromiseCallback.reject(value); - } else { - // Pending - (this._subAssetPromiseCallbacks[assetBaseURL] ||= {})[assetSubPath] = { - rejectedValue: value - }; - } - } - /** * @internal */ @@ -339,7 +324,7 @@ export class ResourceManager { this._loadingPromises = null; } - private _resolveLoadItemOptions(assetInfo: LoadItem, virtualResourceEntry?: EditorResourceItem): void { + private _resolveLoadItemOptions(assetInfo: LoadItem, virtualResourceEntry?: VirtualResource): void { assetInfo.type = virtualResourceEntry?.type ?? assetInfo.type ?? ResourceManager._getTypeByUrl(assetInfo.url); if (assetInfo.type === undefined) { throw `asset type should be specified: ${assetInfo.url}`; @@ -416,11 +401,8 @@ export class ResourceManager { const mainPromise = loadingPromises[remoteAssetBaseURL] || this._loadSubpackageAndMainAsset(loader, item, remoteAssetBaseURL, subpackageName); - mainPromise.catch((e) => { - this._onSubAssetFail(remoteAssetBaseURL, queryPath, e); - }); - return this._createSubAssetPromiseCallback(remoteAssetBaseURL, remoteAssetURL, queryPath); + return this._createSubAssetPromiseCallback(remoteAssetBaseURL, remoteAssetURL, queryPath, mainPromise); } return this._loadSubpackageAndMainAsset(loader, item, remoteAssetBaseURL, subpackageName); @@ -461,30 +443,35 @@ export class ResourceManager { private _createSubAssetPromiseCallback( remoteAssetBaseURL: string, remoteAssetURL: string, - assetSubPath: string + assetSubPath: string, + mainPromise: AssetPromise ): AssetPromise { const loadingPromises = this._loadingPromises; const subPromiseCallback = this._subAssetPromiseCallbacks[remoteAssetBaseURL]?.[assetSubPath]; const resolvedValue = subPromiseCallback?.resolvedValue; - const rejectedValue = subPromiseCallback?.rejectedValue; - - // Already resolved or rejected - if (resolvedValue || rejectedValue) { - return new AssetPromise((resolve, reject) => { - if (resolvedValue) { - resolve(resolvedValue); - } else if (rejectedValue) { - reject(rejectedValue); - } - }); + + // Already resolved + if (resolvedValue) { + return AssetPromise.resolve(resolvedValue); } // Pending - const promise = new AssetPromise((resolve, reject) => { + const promise = new AssetPromise((resolve, reject, setTaskCompleteProgress, setTaskDetailProgress) => { (this._subAssetPromiseCallbacks[remoteAssetBaseURL] ||= {})[assetSubPath] = { resolve, reject }; + + // A loader may finish the main asset before its eager sub-asset notification reaches this callback. + // Always resolve from the completed main asset as the authoritative fallback so callback cleanup cannot + // strand a sub-asset request. + mainPromise.onProgress(setTaskCompleteProgress, setTaskDetailProgress).then((resource) => { + try { + resolve(this._getResolveResource(resource, this._parseQueryPath(assetSubPath)) as T); + } catch (error) { + reject(error); + } + }, reject); }); loadingPromises[remoteAssetURL] = promise; @@ -514,7 +501,10 @@ export class ResourceManager { if (paths) { for (let i = 0, n = paths.length; i < n; i++) { const path = paths[i]; - subResource = subResource[path]; + subResource = subResource?.[path]; + if (subResource === undefined) { + throw new Error(`Sub-asset path does not exist: ${paths.join(".")}`); + } } } return subResource; @@ -560,12 +550,23 @@ export class ResourceManager { delete this._subAssetPromiseCallbacks[assetBaseURL]; } - //-----------------Editor temp solution----------------- + // Virtual resource mapping /** @internal */ _objectPool: { [key: string]: any } = Object.create(null); /** @internal */ - _virtualPathResourceMap: Record = Object.create(null); + _virtualPathResourceMap: Record = Object.create(null); + + /** + * Register virtual asset paths and their load descriptors. + * @remarks References inside runtime scenes and Prefabs can keep stable virtual paths while the backing URLs + * are generated dynamically, such as object URLs created from a resource package. + */ + registerVirtualResources(resources: readonly VirtualResource[]): void { + resources.forEach((resource) => { + this._virtualPathResourceMap[resource.virtualPath] = resource; + }); + } /** * @internal @@ -594,21 +595,6 @@ export class ResourceManager { const promise = this.load({ url: loadUrl }); return isClone ? promise.then((item) => ((item)).clone()) : promise; } - - /** - * @internal - * @beta Just for internal editor, not recommended for developers. - */ - initVirtualResources(config: EditorResourceItem[]): void { - config.forEach((element) => { - this._virtualPathResourceMap[element.virtualPath] = element; - if (element.dependentAssetMap) { - this._virtualPathResourceMap[element.virtualPath].dependentAssetMap = element.dependentAssetMap; - } - }); - } - - //-----------------Editor temp solution----------------- } /** @@ -644,14 +630,14 @@ const rePropName = RegExp( ); type VirtualPath = string; -type EditorResourceItem = { +export interface VirtualResource { virtualPath: string; path: string; type: string; dependentAssetMap?: { [key: string]: string }; subpackageName?: string; params?: Record; -}; +} type SubAssetPromiseCallbacks = Record< // main asset url, ie. "https://***.glb" string, @@ -659,9 +645,8 @@ type SubAssetPromiseCallbacks = Record< // sub asset url, ie. "textures[0]" string, { - // Already resolved or rejected + // Already resolved resolvedValue?: T; - rejectedValue?: Error; // Pending resolve?: (value: T) => void; reject?: (reason: any) => void; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index be483eccbd..dbb7b3efd9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,7 +27,7 @@ export type { RequestConfig } from "./asset/request"; export { Loader } from "./asset/Loader"; export { ContentRestorer } from "./asset/ContentRestorer"; export { RenderingStatistics } from "./asset/RenderingStatistics"; -export { ResourceManager, resourceLoader } from "./asset/ResourceManager"; +export { ResourceManager, resourceLoader, type VirtualResource } from "./asset/ResourceManager"; export { AssetPromise } from "./asset/AssetPromise"; export type { LoadItem } from "./asset/LoadItem"; export { AssetType } from "./asset/AssetType"; diff --git a/packages/loader/src/ProjectLoader.ts b/packages/loader/src/ProjectLoader.ts index de0ce3807b..9bfd6aa335 100644 --- a/packages/loader/src/ProjectLoader.ts +++ b/packages/loader/src/ProjectLoader.ts @@ -18,8 +18,7 @@ class ProjectLoader extends Loader { // @ts-ignore ._request(item.url, { ...item, type: "json" }) .then((data) => { - // @ts-ignore - engine.resourceManager.initVirtualResources(data.files); + engine.resourceManager.registerVirtualResources(data.files); return resourceManager .load({ type: AssetType.Scene, url: data.scene }) .onProgress(onTaskCompeteProgress) diff --git a/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts b/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts index 2e038238ce..68ff6a3531 100644 --- a/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts +++ b/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts @@ -14,7 +14,7 @@ export class ReflectionParser { /** * Apply v2 props to a component/object instance. - * Each prop value is resolved recursively (handling $ref, $type, $class, $entity, $component, $signal). + * Each prop value is resolved recursively (handling $ref, $number, $type, $class, $entity, $component, $signal). */ parseProps(instance: any, props?: Record): Promise { const promises: Promise[] = []; @@ -40,13 +40,21 @@ export class ReflectionParser { for (let i = 0, n = calls.length; i < n; i++) { const call = calls[i]; chain = chain.then(() => { - const method = instance?.[call.method]; + let target = instance; + if (call.target !== undefined) { + if (!Array.isArray(call.target) || call.target.some((key) => typeof key !== "string" || key.length === 0)) { + return Promise.reject(new Error(`Call "${call.method}" target must be an array of non-empty strings`)); + } + for (const key of call.target) target = target?.[key]; + } + const method = target?.[call.method]; if (typeof method !== "function") { - return Promise.reject(new Error(`Call target does not have method "${call.method}"`)); + const path = call.target?.length ? `${call.target.join(".")}.` : ""; + return Promise.reject(new Error(`Call target does not have method "${path}${call.method}"`)); } return Promise.all((call.args ?? []).map((arg) => this._resolveValue(arg))) - .then((resolvedArgs) => Promise.resolve(method.apply(instance, resolvedArgs))) + .then((resolvedArgs) => Promise.resolve(method.apply(target, resolvedArgs))) .then((result) => { if (!call.result) return result; if (result == null || (typeof result !== "object" && typeof result !== "function")) { @@ -63,11 +71,13 @@ export class ReflectionParser { } /** - * Apply props and calls from the same mutation block without imposing ordering between them. + * Apply props before executing calls from the same mutation block. */ parseMutationBlock(target: any, block?: MutationBlock): Promise { if (!block) return Promise.resolve(target); - return Promise.all([this.parseProps(target, block.props), this.parseCalls(target, block.calls)]).then(() => target); + return this.parseProps(target, block.props) + .then(() => this.parseCalls(target, block.calls)) + .then(() => target); } /** @@ -77,12 +87,13 @@ export class ReflectionParser { * 1. null/undefined/primitive → passthrough * 2. Array → recurse each element * 3. { $ref } → asset reference - * 4. { $type } → polymorphic type construct - * 5. { $class } → registered class constructor - * 6. { $entity } → entity reference by path (flat index + optional children descent) - * 7. { $component } → component reference - * 8. { $signal } → signal binding - * 9. plain object → recurse values (modify originValue in place if exists) + * 4. { $number } → JSON-safe special number + * 5. { $type, $args? } → polymorphic type construct + * 6. { $class } → registered class constructor + * 7. { $entity } → entity reference by path (flat index + optional children descent) + * 8. { $component } → component reference + * 9. { $signal } → signal binding + * 10. plain object → recurse values (modify originValue in place if exists) */ private _resolveValue(value: unknown, originValue?: any): Promise { if (value == null || typeof value !== "object") return Promise.resolve(value); @@ -90,6 +101,10 @@ export class ReflectionParser { const obj = value as Record; + if ("$args" in obj && !("$type" in obj)) { + return Promise.reject(new Error("$args requires $type")); + } + // $ref — asset reference (index into refs array) if ("$ref" in obj) { const { _context: context } = this; @@ -109,12 +124,25 @@ export class ReflectionParser { }); } - // $type — polymorphic type: construct instance and apply remaining props + if ("$number" in obj) { + if (Object.keys(obj).length !== 1 || obj.$number !== "Infinity") { + return Promise.reject(new Error('$number must be exactly "Infinity"')); + } + return Promise.resolve(Infinity); + } + + // $type — polymorphic type: resolve constructor args, construct instance, then apply remaining props if ("$type" in obj) { - const { $type, ...rest } = obj; + const { $type, $args, ...rest } = obj; + if ($args !== undefined && !Array.isArray($args)) { + return Promise.reject(new Error("$args must be an array when used with $type")); + } + const constructorArgs = Array.isArray($args) ? $args : []; return this._resolveRegisteredClass($type, "$type").then((Class) => { - const instance = new Class(); - return Object.keys(rest).length > 0 ? this.parseProps(instance, rest) : instance; + return Promise.all(constructorArgs.map((arg) => this._resolveValue(arg))).then((args) => { + const instance = new Class(...args); + return Object.keys(rest).length > 0 ? this.parseProps(instance, rest) : instance; + }); }); } diff --git a/packages/loader/src/schema/CommonSchema.ts b/packages/loader/src/schema/CommonSchema.ts index ae61dc8ce4..83fb55c3c0 100644 --- a/packages/loader/src/schema/CommonSchema.ts +++ b/packages/loader/src/schema/CommonSchema.ts @@ -20,6 +20,18 @@ export interface ClassRef { $class: string; } +/** Registered runtime value with optional recursively-resolved constructor arguments. */ +export interface TypeValue { + $type: string; + $args?: unknown[]; + [key: string]: unknown; +} + +/** JSON-safe encoding for positive infinity. */ +export interface SpecialNumberValue { + $number: "Infinity"; +} + export interface SignalListener { target: { $component: ComponentRef }; methodName: string; @@ -27,6 +39,8 @@ export interface SignalListener { } export interface CallSpec { + /** Optional property path from the mutation root to the method owner. */ + target?: string[]; method: string; args?: unknown[]; result?: MutationBlock; diff --git a/tests/src/core/resource/ResourceManager.test.ts b/tests/src/core/resource/ResourceManager.test.ts index b0fea63848..eb7b66121c 100644 --- a/tests/src/core/resource/ResourceManager.test.ts +++ b/tests/src/core/resource/ResourceManager.test.ts @@ -91,7 +91,7 @@ describe("ResourceManager", () => { describe("virtualPath loading", () => { it("infers loader type from virtualPathResourceMap when type is omitted", () => { const resourceManager = engine.resourceManager; - resourceManager.initVirtualResources([ + resourceManager.registerVirtualResources([ { virtualPath: "Assets/extensionless", path: "https://cdn.ali.com/a.json", type: AssetType.Texture } ]); // @ts-ignore @@ -108,7 +108,7 @@ describe("ResourceManager", () => { it("fills params from virtualPathResourceMap when params is omitted", () => { const resourceManager = engine.resourceManager; - resourceManager.initVirtualResources([ + resourceManager.registerVirtualResources([ { virtualPath: "Assets/withParams", path: "https://cdn.ali.com/p.json", @@ -130,7 +130,7 @@ describe("ResourceManager", () => { it("prefers explicit params over the virtualPath map params", () => { const resourceManager = engine.resourceManager; - resourceManager.initVirtualResources([ + resourceManager.registerVirtualResources([ { virtualPath: "Assets/overrideParams", path: "https://cdn.ali.com/o.json", @@ -165,9 +165,63 @@ describe("ResourceManager", () => { loaderSpy.mockRestore(); }); + it("resolves a sub-asset from the completed main asset when no eager callback arrives", async () => { + const resourceManager = engine.resourceManager; + const material = { name: "material" }; + const mainAsset = { instanceId: 987654321, materials: [material] }; + // @ts-ignore + const loaderSpy = vi + .spyOn(ResourceManager._loaders[AssetType.GLTF], "load") + .mockReturnValue(AssetPromise.resolve(mainAsset) as any); + + try { + const loaded = await resourceManager.load("https://cdn.ali.com/sub-asset-fallback.glb?q=materials[0]"); + expect(loaded).equal(material); + } finally { + loaderSpy.mockRestore(); + } + }); + + it("rejects a missing sub-asset path from the completed main asset", async () => { + const resourceManager = engine.resourceManager; + const mainAsset = { instanceId: 987654322, materials: [] }; + // @ts-ignore + const loaderSpy = vi + .spyOn(ResourceManager._loaders[AssetType.GLTF], "load") + .mockReturnValue(AssetPromise.resolve(mainAsset) as any); + + try { + await expect( + resourceManager.load("https://cdn.ali.com/missing-sub-asset.glb?q=materials[0]") + ).rejects.toThrow(); + } finally { + loaderSpy.mockRestore(); + } + }); + + it("retries a sub-asset after its main asset initially fails", async () => { + const resourceManager = engine.resourceManager; + const material = { name: "material" }; + const mainAsset = { instanceId: 987654323, materials: [material] }; + // @ts-ignore + const loaderSpy = vi + .spyOn(ResourceManager._loaders[AssetType.GLTF], "load") + .mockReturnValueOnce(new AssetPromise((_resolve, reject) => reject(new Error())) as any) + .mockReturnValueOnce(AssetPromise.resolve(mainAsset) as any); + const url = "https://cdn.ali.com/retry-sub-asset.glb?q=materials[0]"; + + try { + await expect(resourceManager.load(url)).rejects.toThrow(); + expect(await resourceManager.load(url)).equal(material); + expect(loaderSpy).toHaveBeenCalledTimes(2); + } finally { + loaderSpy.mockRestore(); + } + }); + it("prefers the virtualPath map type over an explicit type", () => { const resourceManager = engine.resourceManager; - resourceManager.initVirtualResources([ + resourceManager.registerVirtualResources([ { virtualPath: "Assets/explicit", path: "https://cdn.ali.com/x.json", type: AssetType.Texture } ]); // @ts-ignore @@ -186,7 +240,7 @@ describe("ResourceManager", () => { it("getResourceByRef resolves the type from the map without passing it explicitly", () => { const resourceManager = engine.resourceManager; - resourceManager.initVirtualResources([ + resourceManager.registerVirtualResources([ { virtualPath: "Assets/byRef", path: "https://cdn.ali.com/r.json", type: AssetType.Texture } ]); // @ts-ignore @@ -204,7 +258,7 @@ describe("ResourceManager", () => { it("resolves virtualPath via map even when baseUrl is set", () => { const resourceManager = engine.resourceManager; - resourceManager.initVirtualResources([ + resourceManager.registerVirtualResources([ { virtualPath: "Assets/withBaseUrl", path: "https://cdn.ali.com/real.json", type: AssetType.Texture } ]); // @ts-ignore diff --git a/tests/src/loader/SceneFormatV2.test.ts b/tests/src/loader/SceneFormatV2.test.ts index 95cdc3a693..afcade0d29 100644 --- a/tests/src/loader/SceneFormatV2.test.ts +++ b/tests/src/loader/SceneFormatV2.test.ts @@ -8,6 +8,9 @@ import { Entity, FogMode, Loader, + ParticleCurve, + ParticleCurveMode, + ParticleRenderer, PostProcess, Scene, Script, @@ -37,10 +40,26 @@ class TestValueType { } Loader.registerClass("TestValueType", TestValueType); +class ConstructorValueType { + label = ""; + + constructor( + readonly seed: unknown, + readonly child: TestValueType + ) {} +} +Loader.registerClass("ConstructorValueType", ConstructorValueType); + class CallOrderComponent extends Script { value = ""; receivedArgs: any[] = []; lastResult: any = null; + nested = { + value: "", + setValue(value: string) { + this.value = value; + } + }; appendSuffix(suffix: string): void { this.value += suffix; @@ -50,6 +69,10 @@ class CallOrderComponent extends Script { this.receivedArgs = args; } + captureCurrentValue(): void { + this.receivedArgs = [this.value]; + } + setBaseDelayed(base: string): Promise { return Promise.resolve().then(() => { this.value = base; @@ -331,6 +354,97 @@ describe("ReflectionParser calls resolution", () => { expect(target.lastResult.y).to.equal(2); }); + it("should invoke methods on a nested target path", async () => { + const scene = new Scene(engine); + const context = new ParserContext(engine, ParserType.Scene, scene); + const parser = new ReflectionParser(context, []); + const target = new CallOrderComponent(new Entity(engine, "host")); + + await parser.parseCalls(target, [{ target: ["nested"], method: "setValue", args: ["ready"] }]); + + expect(target.nested.value).to.equal("ready"); + }); + + it("should finish resolving props before executing calls", async () => { + const scene = new Scene(engine); + const context = new ParserContext(engine, ParserType.Scene, scene); + let resolveValue: (value: string) => void; + const valuePromise = new Promise((resolve) => { + resolveValue = resolve; + }); + const getResourceByRef = vi + .spyOn(engine.resourceManager as any, "getResourceByRef") + .mockReturnValue(valuePromise as any); + const parser = new ReflectionParser(context, [{ url: "delayed-value" }]); + const target = new CallOrderComponent(new Entity(engine, "host")); + + try { + const parsing = parser.parseMutationBlock(target, { + props: { value: { $ref: 0 } }, + calls: [{ method: "captureCurrentValue" }] + }); + await Promise.resolve(); + resolveValue!("ready"); + await parsing; + } finally { + getResourceByRef.mockRestore(); + } + + expect(target.receivedArgs).to.deep.equal(["ready"]); + }); + + it("should restore particle curves and bursts through existing public APIs", async () => { + const scene = new Scene(engine); + const context = new ParserContext(engine, ParserType.Scene, scene); + const parser = new ReflectionParser(context, []); + const entity = new Entity(engine, "particle"); + const renderer = entity.addComponent(ParticleRenderer); + + await parser.parseMutationBlock(renderer, { + props: { + generator: { + main: { + startDelay: { + mode: ParticleCurveMode.Curve, + curveMax: { + $type: "ParticleCurve", + $args: [ + { $type: "CurveKey", $args: [0, 1] }, + { $type: "CurveKey", $args: [1, 2] } + ] + } + } + } + } + }, + calls: [ + { target: ["generator", "emission"], method: "clearBurst" }, + { + target: ["generator", "emission"], + method: "addBurst", + args: [ + { + $type: "Burst", + $args: [0.5, { $type: "ParticleCompositeCurve", $args: [8] }, { $number: "Infinity" }, 0.1] + } + ] + } + ] + }); + + const curve = renderer.generator.main.startDelay.curve; + expect(curve).to.be.instanceOf(ParticleCurve); + expect(curve.keys.map(({ time, value }) => [time, value])).to.deep.equal([ + [0, 1], + [1, 2] + ]); + expect(renderer.generator.emission.bursts).to.have.length(1); + expect(renderer.generator.emission.bursts[0].time).to.equal(0.5); + expect(renderer.generator.emission.bursts[0].count.constant).to.equal(8); + expect(renderer.generator.emission.bursts[0].cycles).to.equal(Infinity); + entity.destroy(); + }); + it("should await each call before executing the next one", async () => { const scene = new Scene(engine); const context = new ParserContext(engine, ParserType.Scene, scene); @@ -799,6 +913,45 @@ describe("ReflectionParser $type resolution", () => { expect(target.value.y).to.equal(0); }); + it("should recursively resolve $args before construction and then apply props", async () => { + const scene = new Scene(engine); + const context = new ParserContext(engine, ParserType.Scene, scene); + const parser = new ReflectionParser(context, []); + const target: any = {}; + await parser.parseProps(target, { + value: { + $type: "ConstructorValueType", + $args: [{ $number: "Infinity" }, { $type: "TestValueType", x: 3, y: 4 }], + label: "ready" + } + }); + expect(target.value).to.be.instanceOf(ConstructorValueType); + expect(target.value.seed).to.equal(Infinity); + expect(target.value.child).to.be.instanceOf(TestValueType); + expect(target.value.child).to.deep.include({ x: 3, y: 4 }); + expect(target.value.label).to.equal("ready"); + }); + + it("should reject $args without an array-valued $type constructor contract", async () => { + const scene = new Scene(engine); + const context = new ParserContext(engine, ParserType.Scene, scene); + const parser = new ReflectionParser(context, []); + const target: any = {}; + + await expect( + parser.parseProps(target, { + value: { $type: "TestValueType", $args: "invalid" } as any + }) + ).rejects.toThrow("$args must be an array when used with $type"); + await expect(parser.parseProps(target, { value: { $args: [] } })).rejects.toThrow("$args requires $type"); + await expect(parser.parseProps(target, { value: { $class: "TestValueType", $args: [] } })).rejects.toThrow( + "$args requires $type" + ); + await expect(parser.parseProps(target, { value: { $number: "NaN" } })).rejects.toThrow( + '$number must be exactly "Infinity"' + ); + }); + it("should throw a clear error when $type references an unregistered class", async () => { const scene = new Scene(engine); const context = new ParserContext(engine, ParserType.Scene, scene);