import { EquirectangularReflectionMapping, Object3D, Scene, Texture } from "three"; import { AssetReference } from "../engine/engine_addressables.js"; import { destroy } from "../engine/engine_gameobject.js"; import { InputEvents } from "../engine/engine_input.js"; import { isLocalNetwork } from "../engine/engine_networking_utils.js"; import { serializable } from "../engine/engine_serialization.js"; import { getParam, setParamWithoutReload } from "../engine/engine_utils.js"; import { registerObservableAttribute } from "../engine/webcomponents/needle-engine.extras.js"; import { Behaviour, GameObject } from "./Component.js"; import { EventList } from "./EventList.js"; const debug = getParam("debugsceneswitcher"); const experimental_clearSceneOnLoad = getParam("sceneswitcher:clearscene"); const ENGINE_ELEMENT_SCENE_ATTRIBUTE_NAME = "scene"; registerObservableAttribute(ENGINE_ELEMENT_SCENE_ATTRIBUTE_NAME); // ContextRegistry.registerCallback(ContextEvent.ContextRegistered, async _ => { // // We need to defer import to not get issues with circular dependencies // import("../engine/engine_element").then(res => { // const webcomponent = res.NeedleEngineHTMLElement; // if (debug) console.log("SceneSwitcher: registering scene attribute", webcomponent.observedAttributes); // if (!webcomponent.observedAttributes.includes(ENGINE_ELEMENT_SCENE_ATTRIBUTE_NAME)) // webcomponent.observedAttributes.push(ENGINE_ELEMENT_SCENE_ATTRIBUTE_NAME); // }); // }); const couldNotLoadScenePromise = Promise.resolve(false); /** * {@link SceneSwitcher} event argument data */ export type LoadSceneEvent = { /** * The {@link SceneSwitcher} that is loading the scene */ switcher: SceneSwitcher; /** * The scene that is being loaded */ scene: AssetReference; /** * The index of the scene that is being loaded */ index: number; } export type LoadSceneProgressEvent = LoadSceneEvent & { progress: number, } /** * The ISceneEventListener is called by the {@link SceneSwitcher} when a scene is loaded or unloaded. * It must be added to the root object of your scene (that is being loaded) or on the same object as the SceneSwitcher * It can be used to e.g. smooth the transition between scenes or to load additional content when a scene is loaded. * @example * ```ts * import { ISceneEventListener } from "@needle-tools/engine"; * * // Add this component to the root object of a scene loaded by a SceneSwitcher or to the same object as the SceneSwitcher * export class MySceneListener implements ISceneEventListener { * async sceneOpened(sceneSwitcher: SceneSwitcher) { * console.log("Scene opened", sceneSwitcher.currentlyLoadedScene?.url); * } * } * ``` * **/ export interface ISceneEventListener { /** Called when the scene is loaded and added */ sceneOpened(sceneSwitcher: SceneSwitcher): Promise /** Called before the scene is being removed (due to another scene being loaded) */ sceneClosing(): Promise } /** The SceneSwitcher can be used to dynamically load and unload extra content * Available scenes are defined in the `scenes` array. * Loaded scenes will be added to the SceneSwitcher's GameObject as a child and removed when another scene is loaded by the same SceneSwitcher. * Live Examples * - [Multi Scenes Sample](https://engine.needle.tools/samples/multi-scenes-dynamic-loading) (source code available) * - [Needle Website](https://needle.tools) * - [Songs Of Cultures](https://app.songsofcultures.com) * * ### Interfaces * Use the {@link ISceneEventListener} interface to listen to scene open and closing events with the ability to modify transitions and stall the scene loading process. * * ### Events * - `loadscene-start`: Called when a scene starts loading * - `loadscene-finished`: Called when a scene finished loading * - `progress`: Called when a scene is loading and the progress changes * - `scene-opened`: Called when a scene is loaded and added to the SceneSwitcher's GameObject * @example * ```ts * sceneSwitcher.addEventListener("loadscene-start", (e) => { * console.log("Loading scene", e.detail.scene.url); * }); * sceneSwitcher.addEventListener("loadscene-finished", (e) => { * console.log("Finished loading scene", e.detail.scene.url); * }); * sceneSwitcher.addEventListener("progress", (e) => { * console.log("Loading progress", e.loaded, e.total); * }); * sceneSwitcher.addEventListener("scene-opened", (e) => { * console.log("Scene opened", e.detail.scene.url); * }); * ``` * * @category Asset Management * @group Components */ export class SceneSwitcher extends Behaviour { /** When enabled the first scene will be loaded when the SceneSwitcher becomes active * @default true */ @serializable() autoLoadFirstScene: boolean = true; /** * The scenes that can be loaded by the SceneSwitcher. * @default [] */ @serializable(AssetReference) scenes: AssetReference[] = []; /** * The scene that is displayed while a scene is loading. * @default undefined */ @serializable(AssetReference) loadingScene?: AssetReference; /** the url parameter that is set/used to store the currently loaded scene in, set to "" to disable * @default "scene" */ @serializable() queryParameterName: string = "scene"; /** * when enabled the scene name will be used as the query parameter (otherwise the scene index will be used) * Needs `queryParameterName` set * @default true */ @serializable() useSceneName: boolean = true; /** * When enabled the current scene index will be clamped to the scenes array bounds. * For example when the last scene is loaded and `clamp` is true then trying to load the `next()` scene will not change the scene. * When `clamp` is false and the last scene is loaded then the first scene will be loaded instead. * @default true */ @serializable() clamp: boolean = true; /** when enabled the new scene is pushed to the browser navigation history, only works with a valid query parameter set * @default true */ @serializable() useHistory: boolean = true; /** when enabled you can switch between scenes using keyboard left, right, A and D or number keys * @default true */ @serializable() useKeyboard: boolean = true; /** when enabled you can switch between scenes using swipe (mobile only) * @default true */ @serializable() useSwipe: boolean = true; /** when enabled will automatically apply the environment scene lights * @default true */ @serializable() useSceneLighting: boolean = true; /** When enabled will automatically apply the skybox from the loaded scene * @default true */ @serializable() useSceneBackground: boolean = true; /** how many scenes after the currently active scene should be preloaded * @default 1 */ @serializable() preloadNext: number = 1; /** how many scenes before the currently active scene should be preloaded * @default 1 */ @serializable() preloadPrevious: number = 1; /** how many scenes can be loaded in parallel * @default 2 */ @serializable() preloadConcurrent: number = 2; /** * When enabled will create a button for the Needle menu to switch to the next or previous scene * @default false */ @serializable() createMenuButtons: boolean = false; /** The index of the currently loaded and active scene */ get currentIndex(): number { return this._currentIndex; } /** Get the progress of the currently loading scene. This is undefined if no scene is loading * You can also subscribe to the loading event by adding an event listener to the scene switcher. * For example like this `sceneSwitcher.addEventListeneer("progress", (e) => {...})` */ get currentLoadingProgress() { return this._currentLoadingProgress; } /** The currently loading scene. This is undefined if no scene is loading. */ get currentlyLoadingScene() { return this._currentlyLoadingScene; } /** * The currently loaded scene. This is undefined if no scene is loaded. */ get currentlyLoadedScene() { return this._currentScene; } /** * Called when a scene starts loading */ @serializable(EventList) sceneLoadingStart: EventList = new EventList(); @serializable(EventList) sceneLoadingProgress: EventList = new EventList(); /** * The sceneLoaded event is called when a scene/glTF is loaded and added to the scene */ @serializable(EventList) sceneLoaded: EventList = new EventList(); private _currentIndex: number = -1; private _currentScene: AssetReference | undefined = undefined; private _engineElementOverserver: MutationObserver | undefined = undefined; private _preloadScheduler?: PreLoadScheduler; private _menuButtons?: HTMLElement[]; /** @internal */ awake(): void { if (this.scenes === undefined) this.scenes = []; // remove all scenes from the url that are in the scenes array at startup for (const scene of this.scenes) { if (scene && !scene.hasUrl && scene.asset instanceof Object3D) { GameObject.remove(scene.asset); } else if(scene instanceof Object3D) { GameObject.remove(scene); } } if (debug) console.log("SceneSwitcher", this); } /** @internal */ async onEnable() { globalThis.addEventListener("popstate", this.onPopState); this.context.input.addEventListener(InputEvents.KeyDown, this.onInputKeyDown); this.context.input.addEventListener(InputEvents.PointerMove, this.onInputPointerMove); this.context.input.addEventListener(InputEvents.PointerUp, this.onInputPointerUp); if (!this._engineElementOverserver) { this._engineElementOverserver = new MutationObserver((mutations) => { for (const mut of mutations) { if (mut.type === "attributes" && mut.attributeName === ENGINE_ELEMENT_SCENE_ATTRIBUTE_NAME) { const value = this.context.domElement.getAttribute(ENGINE_ELEMENT_SCENE_ATTRIBUTE_NAME); if (value !== null) { this.trySelectSceneFromValue(value); } } } }); } this._engineElementOverserver.observe(this.context.domElement, { attributes: true }); if (!this._preloadScheduler) this._preloadScheduler = new PreLoadScheduler(this); this._preloadScheduler.maxLoadAhead = this.preloadNext; this._preloadScheduler.maxLoadBehind = this.preloadPrevious; this._preloadScheduler.maxConcurrent = this.preloadConcurrent; this._preloadScheduler.begin(2000); // Begin loading the loading scene if (this.autoLoadFirstScene && this._currentIndex === -1 && !await this.tryLoadFromQueryParam()) { const value = this.context.domElement.getAttribute(ENGINE_ELEMENT_SCENE_ATTRIBUTE_NAME); // let locked = this.lock; try { // this.lock = false; if (value === null || !await this.trySelectSceneFromValue(value)) { if (this._currentIndex === -1) this.select(0); } } finally { // this.lock = locked; } } // create the menu buttons if (this.createMenuButtons) { this._menuButtons ??= []; this._menuButtons.push(this.context.menu.appendChild({ label: "Previous", icon: "arrow_back_ios", onClick: () => this.selectPrev(), priority: -1005, class: "row2" })); this._menuButtons.push(this.context.menu.appendChild({ label: "Next", icon: "arrow_forward_ios", iconSide: "right", onClick: () => this.selectNext(), priority: -1000, class: "row2" })); } } /** @internal */ onDisable(): void { globalThis.removeEventListener("popstate", this.onPopState); this.context.input.removeEventListener(InputEvents.KeyDown, this.onInputKeyDown); this.context.input.removeEventListener(InputEvents.PointerMove, this.onInputPointerMove); this.context.input.removeEventListener(InputEvents.PointerUp, this.onInputPointerUp); this._preloadScheduler?.stop(); // remove the menu buttons if (this._menuButtons) { for (const button of this._menuButtons) { button.remove(); } this._menuButtons = undefined; } } private onPopState = async (_state: PopStateEvent) => { if (!this.useHistory) return; const wasUsingHistory = this.useHistory; try { this.useHistory = false; let didResolve = false; if (this.queryParameterName) didResolve = await this.tryLoadFromQueryParam(); if (!didResolve) { const state = _state?.state; if (state && state.startsWith(this.guid)) { const value = state.substr(this.guid.length + 2); if (debug) console.log("PopState", value); await this.trySelectSceneFromValue(value); } } } finally { this.useHistory = wasUsingHistory; } } private normalizedSwipeThresholdX = 0.1; private _didSwipe: boolean = false; private onInputPointerMove = (e: any) => { if (!this.useSwipe) return; if (!this._didSwipe && e.button === 0 && e.pointerType === "touch" && this.context.input.getPointerPressedCount() === 1) { const delta = this.context.input.getPointerPositionDelta(e.button); if (delta) { const normalizedX = delta.x / this.context.domWidth; if (normalizedX >= this.normalizedSwipeThresholdX) { this._didSwipe = true; this.selectPrev(); } else if (normalizedX <= -this.normalizedSwipeThresholdX) { this._didSwipe = true; this.selectNext(); } } } } private onInputPointerUp = (e: any) => { if (e.button === 0) { this._didSwipe = false; } }; private onInputKeyDown = (e: any) => { if (!this.useKeyboard) return; if (!this.scenes) return; const key = e.key.toLowerCase(); if (!key) return; const index = parseInt(key) - 1; if (index >= 0) { this.trySelectSceneFromValue(index); return; } switch (key) { case "arrowright": case "d": this.selectNext(); break; case "arrowleft": case "a": this.selectPrev(); break; } } /** * Add a scene to the SceneSwitcher. * If the scene is already added it will be added again. * @param urlOrAssetReference The url of the scene or an AssetReference to the scene * @returns The AssetReference of the scene that was added * @example * ```ts * // adding a scene: * sceneSwitcher.addScene("scene1.glb"); * // add another scene and load it: * const scene2 = sceneSwitcher.addScene("scene2.glb"); * sceneSwitcher.switchScene(scene2).then(res => { console.log("Scene loaded", res); }); * ``` */ addScene(urlOrAssetReference: string | AssetReference): AssetReference { if (typeof urlOrAssetReference === "string") { let assetReference = this.context.addressables.findAssetReference(urlOrAssetReference); if (!assetReference) { assetReference = new AssetReference(urlOrAssetReference); this.context.addressables.registerAssetReference(assetReference); } this.scenes.push(assetReference); return assetReference; } this.scenes.push(urlOrAssetReference); return urlOrAssetReference; } /** * Load the next scene in the scenes array ({@link this.currentIndex} + 1) * If the current scene is the last scene in the array and {@link this.clamp} is disabled then the first scene will be loaded. * @returns a promise that resolves to true if the scene was loaded successfully */ selectNext(): Promise { return this.select(this._currentIndex + 1); } /** * Load the previous scene in the scenes array ({@link this.currentIndex} - 1) * If the current scene is the first scene in the array and {@link this.clamp} is disabled then the last scene will be loaded. * @returns a promise that resolves to true if the scene was loaded successfully */ selectPrev(): Promise { return this.select(this._currentIndex - 1); } /** * Load a scene by its index in the scenes array. * @param index The index of the scene or a string that represents the scene uri (if the url is not known to the SceneSwitcher it will try to load the scene by its uri but it won't be added to the current scenes array. Use {@link addScene} to add a scene to the SceneSwitcher) * @returns a promise that resolves to true if the scene was loaded successfully */ select(index: number | string): Promise { if (debug) console.log("select", index); if (typeof index === "object") { // If a user tries to reference a scene object in a UnityEvent and invoke select(obj) // Then the object will be serialized as a object { guid : ... } or with the index json pointer // This case is not supported right now. Object references in the editor must not be scene references console.warn("Switching to \"" + index + "\" might not work. Please either use an index or a AssetReference (not a scene reference)"); } if (typeof index === "string") { // If the parameter is a string we try to resolve the scene by its uri // it's either already known in the scenes array // or we create/get a new AssetReference and try to switch to that const scene = this.scenes?.find(s => s.url === index); if (!scene) { // Ok the scene is unknown to the scene switcher // we create a new asset reference (or get an existing one) // And switch to that. With this we can not modify the history // Until the scene switcher can store the uri in the history instead of the index const reference = AssetReference.getOrCreate(this.sourceId ?? "", index, this.context); return this.switchScene(reference); } if (scene) index = this.scenes?.indexOf(scene); else return couldNotLoadScenePromise; } if (!this.scenes?.length) return couldNotLoadScenePromise; if (index < 0) { if (this.clamp) return couldNotLoadScenePromise; index = this.scenes.length - 1; } else if (index >= this.scenes.length) { if (this.clamp) return couldNotLoadScenePromise; index = 0; } const scene = this.scenes[index]; return this.switchScene(scene); } /** * Unload the currently loaded scene. */ unload(): Promise { this.__lastSwitchScene = undefined; this.__lastSwitchScenePromise = undefined; return this.__unloadCurrentScene(); } /** * Reload the last scene that was loaded * @returns a promise that resolves to true if the scene was loaded successfully */ async reload() { if (this.__lastSwitchScene) { const scene = this.__lastSwitchScene; this.__lastSwitchScene = undefined; return this.switchScene(scene); } return false; } // this is the scene that was requested last private __lastSwitchScene?: AssetReference; private __lastSwitchScenePromise?: Promise; /** * Switch to a scene by its AssetReference. * If the scene is already loaded it will be unloaded and the new scene will be loaded. * If the scene is already loading it will wait for the scene to be loaded. * If the scene is already loaded and the same scene is requested again it will return the same promise that was returned the first time the scene was requested. * @param scene The AssetReference of the scene to switch to * @returns a promise that resolves to true if the scene was loaded successfully * @example * ```ts * const myAssetReference = new AssetReference("scene1.glb"); * sceneSwitcher.switchScene(myAssetReference).then(res => { console.log("Scene loaded", res); }); * ``` */ async switchScene(scene: AssetReference): Promise { if (!(scene instanceof AssetReference)) { const type = typeof scene; if (type === "string") { return this.select(scene); } else if (type === "number") { return this.select(scene); } // If we're switching an Object3D else if (scene && (scene as Object3D) instanceof Object3D) { const i = this.scenes?.indexOf(scene); scene = new AssetReference((scene as Object3D).name, undefined, scene); if (i >= 0) this.scenes[i] = scene; // update the asset in the list to the new asset reference } else { console.warn(`[SceneSwitcher] Can't switch to scene of type ${type}`); return false; } } if (scene.url === this.sourceId) { console.warn("[SceneSwitcher] Can't load own scene - prevent recursive loading", this.sourceId); return false; } // ensure that we never run the same scene switch multiple times (at the same time) for the same requested scene if (this.__lastSwitchScene === scene && this.__lastSwitchScenePromise) { return this.__lastSwitchScenePromise; } this.__lastSwitchScene = scene; this.__lastSwitchScenePromise = this.__internalSwitchScene(scene); const res = await this.__lastSwitchScenePromise; return res; } private async __unloadCurrentScene() { const current = this._currentScene; this._currentScene = undefined; if (current) { if (debug) console.log("UNLOAD", current.url, "HasURL?: " + current.hasUrl) const sceneListener = this.tryGetSceneEventListener(current.asset as any as Object3D); if (sceneListener?.sceneClosing) { const res = sceneListener.sceneClosing(); if (res instanceof Promise) await res; } // if the current scene has a URL (so it can be reloaded) // then we unload it if (current.hasUrl) current.unload(); // otherwise if it's a regular Object3D we just remove it from the scene else if (current.asset instanceof Object3D) { GameObject.remove(current.asset as any as Object3D); } } } private _currentlyLoadingScene?: AssetReference; /** @internal */ private async __internalSwitchScene(scene: AssetReference): Promise { await this.__unloadCurrentScene(); const index = this._currentIndex = this.scenes?.indexOf(scene) ?? -1; try { this._currentlyLoadingScene = scene; this._currentLoadingProgress = new ProgressEvent("progress", { loaded: 0, total: 1 }); const loadStartEvt = new CustomEvent("loadscene-start", { detail: { scene: scene, switcher: this, index: index } }) this.dispatchEvent(loadStartEvt); this.sceneLoadingStart?.invoke(loadStartEvt.detail); await this.onStartLoading(); // start loading and wait for the scene to be loaded await scene.loadAssetAsync((_, prog) => { if (debug) { const t01 = prog.loaded / prog.total; const progressBarString = "[" + "=".repeat(Math.floor(t01 * 20)) + "-".repeat(20 - Math.floor(t01 * 20)) + "]"; console.debug(`[SceneSwitcher] Download ${(t01 * 100).toFixed(1)} % ${progressBarString}`, scene.url); } this._currentLoadingProgress = prog; this.dispatchEvent(prog); this.sceneLoadingProgress?.invoke(prog); }).catch(console.error); await this.onEndLoading(); const finishedEvt = new CustomEvent("loadscene-finished", { detail: { scene: scene, switcher: this, index: index } }); this.dispatchEvent(finishedEvt); this._currentLoadingProgress = undefined; this._currentlyLoadingScene = undefined; if (finishedEvt.defaultPrevented) { if (debug) console.warn("Adding loaded scene prevented:", scene, finishedEvt); return false; } if (!scene.asset) { if (debug) console.warn("Failed loading scene:", scene); return false; } if (this._currentIndex === index) { if (debug) console.log("ADD", scene.url); this._currentScene = scene; // Experimental: replace the whole content of the scene if (experimental_clearSceneOnLoad) { const camera = this.context.mainCameraComponent?.gameObject || this.context.mainCamera; camera?.removeFromParent(); const self = this.gameObject.removeFromParent(); destroy(this.context.scene, true, true) this.context.scene = new Scene(); this.context.scene.add(self); if (camera) { this.context.scene.add(camera); } } GameObject.add(scene.asset, this.gameObject); if (this.useSceneLighting) this.context.sceneLighting.enable(scene); // Set the background texture from the loaded scene if (this.useSceneBackground) { const skybox = this.context.lightmaps.tryGetSkybox(scene.url) as Texture; if (skybox) { skybox.mapping = EquirectangularReflectionMapping; this.context.scene.background = skybox; } else if (debug) { console.warn("SceneSwitcher: Can't find skybox for scene " + scene.url); } } if (this.useHistory && index >= 0) { // take the index as the query parameter value let queryParameterValue = index.toString(); // unless the user defines that he wants to use the scene name if (this.useSceneName) { if (scene instanceof Object3D) queryParameterValue = scene.name; else if (scene.url) queryParameterValue = sceneUriToName(scene.url); } // save the loaded scene as an url parameter if (this.queryParameterName?.length) setParamWithoutReload(this.queryParameterName, queryParameterValue, this.useHistory); // or set the history state without updating the url parameter else { const lastState = history.state; const stateName = this.guid + "::" + index; if (lastState !== stateName) history.pushState(stateName, "unused", location.href); } } // Call SceneListener opened callback (if a SceneListener is in the scene) const sceneListener = this.tryGetSceneEventListener(scene.asset as any as Object3D); if (sceneListener?.sceneOpened) { const res = sceneListener.sceneOpened(this); if (res instanceof Promise) await res; } const openedEvt = new CustomEvent("scene-opened", { detail: { scene: scene, switcher: this, index: index } }); this.dispatchEvent(openedEvt); this.sceneLoaded?.invoke(this); return true; } } catch (err) { console.error(err); } return false; } preload(index: number) { if (index >= 0 && index < this.scenes.length) { const scene = this.scenes[index]; if (scene instanceof AssetReference) return scene.preload(); } return couldNotLoadScenePromise; } private tryLoadFromQueryParam() { if (!this.queryParameterName?.length) return couldNotLoadScenePromise; // try restore the scene from the url const value = getParam(this.queryParameterName); if (typeof value === "boolean") return couldNotLoadScenePromise; return this.trySelectSceneFromValue(value); } /** try to select a scene from a string or index */ private trySelectSceneFromValue(value: string | number) { if (typeof value === "string") { const index = parseInt(value as string); if (index >= 0 && index < this.scenes.length) { return this.select(index);; } else { // Try to find a scene with a matching name // we don't care about casing. e.g. Scene1 and scene1 should both match const lowerCaseValue = value.toLowerCase(); for (let i = 0; i < this.scenes.length; i++) { const scene = this.scenes[i]; if (!scene) continue; const name = scene instanceof Object3D ? scene.name : sceneUriToName(scene.url); if (name.toLowerCase().includes(lowerCaseValue)) { return this.select(i); } } } } else if (typeof value === "number") { if (value >= 0 && value < this.scenes.length) { return this.select(value);; } } if (isLocalNetwork()) console.warn("Can not find scene: \"" + value + "\"", this) return couldNotLoadScenePromise; } private _lastLoadingScene: AssetReference | undefined = undefined; private _loadingScenePromise: Promise | undefined = undefined; private _isCurrentlyLoading: boolean = false; private _currentLoadingProgress: ProgressEvent | undefined = undefined; private async onStartLoading() { this._isCurrentlyLoading = true; if (this.loadingScene) { // save the last loading scene reference so that it can be changed at runtime // since we cache the loading promise here if (this._lastLoadingScene !== this.loadingScene) { this._loadingScenePromise = undefined; } this._lastLoadingScene = this.loadingScene; if (!this._loadingScenePromise) { this._loadingScenePromise = this.loadingScene?.loadAssetAsync().then(res => res != null); } await this._loadingScenePromise; if (this._isCurrentlyLoading && this.loadingScene?.asset) { if (debug) console.log("Add loading scene", this.loadingScene.url, this.loadingScene.asset) const loadingScene = this.loadingScene.asset as any as Object3D; GameObject.add(loadingScene, this.gameObject); const sceneListener = this.tryGetSceneEventListener(loadingScene); if (sceneListener?.sceneOpened) { const res = sceneListener.sceneOpened(this); if (res instanceof Promise) await res; } } } // Invoke the event on a loading listener on the same object as the scene switcher if (this._isCurrentlyLoading) { const listener = this.tryGetSceneEventListener(this.gameObject); if (listener) { if (listener.sceneOpened) { const res = listener.sceneOpened(this); if (res instanceof Promise) await res; } } } } private async onEndLoading() { this._isCurrentlyLoading = false; if (this.loadingScene?.asset) { if (debug) console.log("Remove loading scene", this.loadingScene.url); const obj = this.loadingScene.asset as any as Object3D; // try to find an ISceneEventListener component const sceneListener = this.tryGetSceneEventListener(obj); if (typeof sceneListener?.sceneClosing === "function") { const res = sceneListener.sceneClosing(); if (res instanceof Promise) await res; } GameObject.remove(obj); } // Invoke the event on a loading listener on the same object as the scene switcher if (!this._isCurrentlyLoading) { const listener = this.tryGetSceneEventListener(this.gameObject); if (listener) { if (listener.sceneClosing) { const res = listener.sceneClosing(); if (res instanceof Promise) await res; } } } } private tryGetSceneEventListener(obj: Object3D, level: number = 0): ISceneEventListener | null { if (!obj) { return null; } const sceneListener = GameObject.foreachComponent(obj, c => { const i = c as any as ISceneEventListener; if (i.sceneClosing! || i.sceneOpened!) return i; else return undefined; }); // if we didnt find any component with the listener on the root object // we also check the first level of its children because a scene might be a group if (level === 0 && !sceneListener && obj.children.length) { for (const ch of obj.children) { const res = this.tryGetSceneEventListener(ch, level + 1); if (res) return res; } } if (!sceneListener) return null; return sceneListener; } } function sceneUriToName(uri: string): string { const name = uri.split("/").pop(); const value = name?.split(".").shift(); if (value?.length) return value; return uri; } /** * PreLoadScheduler is responsible for managing preloading of scenes. * It handles scheduling and limiting concurrent downloads of scenes based on specified parameters. */ class PreLoadScheduler { /** Maximum number of scenes to preload ahead of the current scene */ maxLoadAhead: number; /** Maximum number of scenes to preload behind the current scene */ maxLoadBehind: number; /** Maximum number of scenes that can be preloaded concurrently */ maxConcurrent: number; private _isRunning: boolean = false; private _switcher: SceneSwitcher; private _loadTasks: LoadTask[] = []; private _maxConcurrentLoads: number = 1; /** * Creates a new PreLoadScheduler instance * @param rooms The SceneSwitcher that this scheduler belongs to * @param ahead Number of scenes to preload ahead of current scene * @param behind Number of scenes to preload behind current scene * @param maxConcurrent Maximum number of concurrent preloads allowed */ constructor(rooms: SceneSwitcher, ahead: number = 1, behind: number = 1, maxConcurrent: number = 2) { this._switcher = rooms; this.maxLoadAhead = ahead; this.maxLoadBehind = behind; this.maxConcurrent = maxConcurrent; } /** * Starts the preloading process after a specified delay * @param delay Time in milliseconds to wait before starting preload */ begin(delay: number) { if (this._isRunning) return; if (debug) console.log("Preload begin", { delay }) this._isRunning = true; let lastRoom: number = -10; let searchDistance: number; let searchCall: number; const array = this._switcher.scenes; const startTime = Date.now() + delay; const interval = setInterval(() => { if (this.allLoaded()) { if (debug) console.log("All scenes (pre-)loaded"); this.stop(); } if (!this._isRunning) { clearInterval(interval); return; } if (Date.now() < startTime) return; if (this.canLoadNewScene() === false) return; if (lastRoom === -10 || lastRoom !== this._switcher.currentIndex) { lastRoom = this._switcher.currentIndex; searchCall = 0; searchDistance = 0; } const searchForward = searchCall % 2 === 0; if (searchForward) searchDistance += 1; searchCall += 1; const maxSearchDistance = searchForward ? this.maxLoadAhead : this.maxLoadBehind; if (searchDistance > maxSearchDistance) return; const roomIndex = searchForward ? lastRoom + searchDistance : lastRoom - searchDistance; if (roomIndex < 0) return; // if (roomIndex < 0) roomIndex = array.length + roomIndex; if (roomIndex < 0 || roomIndex >= array.length) return; if (!this._loadTasks.some(t => t.index === roomIndex)) { const scene = array[roomIndex]; if (debug) console.log("Preload scene", { roomIndex, searchForward, lastRoom, currentIndex: this._switcher.currentIndex, tasks: this._loadTasks.length }, scene?.url); new LoadTask(roomIndex, scene, this._loadTasks); } }, 200); } /** * Stops the preloading process */ stop() { this._isRunning = false; } /** * Checks if a new scene can be loaded based on current load limits * @returns True if a new scene can be loaded, false otherwise */ canLoadNewScene(): boolean { return this._loadTasks.length < this._maxConcurrentLoads; } /** * Checks if all scenes in the SceneSwitcher have been loaded * @returns True if all scenes are loaded, false otherwise */ allLoaded(): boolean { if (this._switcher.scenes) { for (const scene of this._switcher.scenes) { if (!scene?.isLoaded) continue; if (scene.isLoaded() === false) return false; } } return true; } } /** * Represents a single preloading task for a scene */ class LoadTask { /** The index of the scene in the scenes array */ index: number; /** The AssetReference to be loaded */ asset: AssetReference; /** The collection of active load tasks this task belongs to */ tasks: LoadTask[]; /** * Creates a new LoadTask and begins loading immediately * @param index The index of the scene in the scenes array * @param asset The AssetReference to preload * @param tasks The collection of active load tasks */ constructor(index: number, asset: AssetReference, tasks: LoadTask[]) { this.index = index; this.asset = asset; this.tasks = tasks; tasks.push(this); this.awaitLoading(); } /** * Asynchronously loads the asset and removes this task from the active tasks list when complete */ private async awaitLoading() { if (this.asset && !this.asset.isLoaded()) { if (debug) console.log("Preload start: " + this.asset.url, this.index); await this.asset.preload(); if (debug) console.log("Preload finished: " + this.asset.url, this.index); } const i = this.tasks.indexOf(this); if (i >= 0) this.tasks.splice(i, 1); } }