import type { Engine } from '../Engine'; import type { LoaderConstructor } from './DefaultLoader'; import { DefaultLoader, isLoaderConstructor } from './DefaultLoader'; import type { SceneConstructor } from '../Scene'; import { Scene, isSceneConstructor } from '../Scene'; import type { Transition } from './Transition'; import { Loader } from './Loader'; import { Logger } from '../Util/Log'; import { ActivateEvent, DeactivateEvent } from '../Events'; import { EventEmitter } from '../EventEmitter'; export interface DirectorNavigationEvent { sourceName: string; sourceScene: Scene; destinationName: string; destinationScene: Scene; } export type DirectorEvents = { navigationstart: DirectorNavigationEvent; navigation: DirectorNavigationEvent; navigationend: DirectorNavigationEvent; }; export const DirectorEvents = { NavigationStart: 'navigationstart', Navigation: 'navigation', NavigationEnd: 'navigationend' } as const; export interface SceneWithOptions { /** * Scene associated with this route * * If a constructor is provided it will not be constructed until navigation is requested */ scene: Scene | SceneConstructor; /** * Specify scene transitions */ transitions?: { /** * Optionally specify a transition when going "in" to this scene */ in?: Transition; /** * Optionally specify a transition when going "out" of this scene */ out?: Transition; }; /** * Optionally specify a loader for the scene */ loader?: DefaultLoader | LoaderConstructor; } export type WithRoot = TScenes | 'root'; export type SceneMap = Record; export interface StartOptions { /** * Optionally provide first transition from the game start screen */ inTransition?: Transition; /** * Optionally provide a main loader to run before the game starts */ loader?: DefaultLoader | LoaderConstructor; } /** * Provide scene activation data and override any existing configured route transitions or loaders */ export interface GoToOptions { /** * Optionally supply scene activation data passed to Scene.onActivate */ sceneActivationData?: TActivationData; /** * Optionally supply destination scene "in" transition, this will override any previously defined transition */ destinationIn?: Transition; /** * Optionally supply source scene "out" transition, this will override any previously defined transition */ sourceOut?: Transition; /** * Optionally supply a different loader for the destination scene, this will override any previously defined loader */ loader?: DefaultLoader; } /** * The Director is responsible for managing scenes and changing scenes in Excalibur. * * It deals with transitions, scene loaders, switching scenes * * This is used internally by Excalibur, generally not mean to * be instantiated end users directly. */ export class Director { public events = new EventEmitter(); private _logger = Logger.getInstance(); private _deferredGoto?: string; private _deferredTransition?: Transition; private _initialized = false; /** * Current scene's name */ currentSceneName: string; /** * Current scene playing in excalibur */ currentScene: Scene; /** * Current transition if any */ currentTransition?: Transition; /** * All registered scenes in Excalibur */ public readonly scenes: SceneMap> = {} as SceneMap>; /** * Holds all instantiated scenes */ private _sceneToInstance = new Map(); startScene?: string; mainLoader?: DefaultLoader; /** * The default {@apilink Scene} of the game, use {@apilink Engine.goToScene} to transition to different scenes. */ public readonly rootScene: Scene; private _sceneToLoader = new Map(); private _sceneToTransition = new Map(); /** * Used to keep track of scenes that have already been loaded so we don't load multiple times */ private _loadedScenes = new Set(); private _isTransitioning = false; /** * Gets whether the director currently transitioning between scenes * * Useful if you need to block behavior during transition */ public get isTransitioning() { return this._isTransitioning; } constructor( private _engine: Engine, scenes: SceneMap ) { this.rootScene = this.currentScene = new Scene(); this.add('root', this.rootScene); this.currentScene = this.rootScene; this.currentSceneName = 'root'; for (const sceneKey in scenes) { const sceneOrOptions = scenes[sceneKey]; this.add(sceneKey, sceneOrOptions); if (sceneKey === 'root') { this.rootScene = this.getSceneInstance('root')!; // always a root scene this.currentScene = this.rootScene; } } } /** * Initialize the director's internal state */ async onInitialize() { if (!this._initialized) { this._initialized = true; if (this._deferredGoto) { const deferredScene = this._deferredGoto; this._deferredGoto = undefined; const deferredTransition = this._deferredTransition; this._deferredTransition = undefined; const deferredSceneInstance = this.getSceneInstance(deferredScene); if (deferredSceneInstance && deferredTransition) { deferredTransition._addToTargetScene(this._engine, deferredSceneInstance); } const inTransition = this._getInTransition(deferredScene); const hideLoader = inTransition?.hideLoader; // eslint-disable-next-line @typescript-eslint/no-floating-promises this.maybeLoadScene(deferredScene, hideLoader); await this.swapScene(deferredScene); if (deferredSceneInstance && deferredTransition) { await this.playTransition(deferredTransition, deferredSceneInstance); } } else { await this.swapScene('root'); } } } get isInitialized() { return this._initialized; } /** * Configures the start scene, and optionally the transition & loader for the director * * Typically this is called at the beginning of the game to the start scene and transition and never again. * @param startScene * @param options */ configureStart(startScene: WithRoot, options?: StartOptions) { const maybeLoaderOrCtor = options?.loader; if (maybeLoaderOrCtor instanceof DefaultLoader) { this.mainLoader = maybeLoaderOrCtor; } else if (isLoaderConstructor(maybeLoaderOrCtor)) { this.mainLoader = new maybeLoaderOrCtor(); } else { this.mainLoader = new Loader(); } let maybeStartTransition: Transition | undefined; if (options?.inTransition) { const { inTransition } = options; maybeStartTransition = inTransition; } this.startScene = startScene; const maybeHideLoader = options?.inTransition?.hideLoader ?? false; // eslint-disable-next-line @typescript-eslint/no-floating-promises this.maybeLoadScene(startScene, maybeHideLoader); // Fire and forget promise for the initial scene if (maybeStartTransition) { const startSceneInstance = this.getSceneInstance(startScene); if (startSceneInstance) { maybeStartTransition._addToTargetScene(this._engine, startSceneInstance); // eslint-disable-next-line @typescript-eslint/no-floating-promises this.swapScene(startScene).then(() => { startSceneInstance.onTransition('in'); return this.playTransition(maybeStartTransition, startSceneInstance); }); } } else { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.swapScene(startScene); } this.currentSceneName = this.startScene; } private _getLoader(sceneName: string) { return this._sceneToLoader.get(sceneName); } private _getInTransition(sceneName: string): Transition | undefined { const sceneOrRoute = this.scenes[sceneName as TKnownScenes]; if (sceneOrRoute instanceof Scene || isSceneConstructor(sceneOrRoute)) { return undefined; } return sceneOrRoute?.transitions?.in; } private _getOutTransition(sceneName: string): Transition | undefined { const sceneOrRoute = this.scenes[sceneName as TKnownScenes]; if (sceneOrRoute instanceof Scene || isSceneConstructor(sceneOrRoute)) { return undefined; } return sceneOrRoute?.transitions?.out; } getDeferredScene() { const maybeDeferred = this.getSceneDefinition(this._deferredGoto); if (this._deferredGoto && maybeDeferred) { return maybeDeferred; } return null; } /** * Returns a scene by name if it exists, might be the constructor and not the instance of a scene * @param name */ getSceneDefinition(name?: string): Scene | SceneConstructor | undefined { const maybeScene = this.scenes[name as TKnownScenes]; if (maybeScene instanceof Scene || isSceneConstructor(maybeScene)) { return maybeScene; } else if (maybeScene) { return maybeScene.scene; } return undefined; } /** * Returns the name of the registered scene, null if none can be found * @param scene */ getSceneName(scene: Scene): string | null { for (const [name, maybeScene] of Object.entries(this.scenes)) { if (maybeScene instanceof Scene) { if (scene === maybeScene) { return name; } } else if (!isSceneConstructor(maybeScene)) { if (scene === maybeScene.scene) { return name; } } } for (const [name, maybeScene] of Object.entries(this.scenes)) { if (isSceneConstructor(maybeScene)) { if (scene.constructor === maybeScene) { return name; } } else if (!(maybeScene instanceof Scene)) { if (scene.constructor === maybeScene.scene) { return name; } } } return null; } /** * Returns the same Director, but asserts a scene DOES exist to the type system * @param name */ assertAdded(name: TScene): Director { return this as Director; } /** * Returns the same Director, but asserts a scene DOES NOT exist to the type system * @param name */ assertRemoved(name: TScene): Director> { return this as Director>; } /** * Adds additional Scenes to the game! * @param name * @param sceneOrRoute */ add(name: TScene, sceneOrRoute: Scene | SceneConstructor | SceneWithOptions): Director { if (!(sceneOrRoute instanceof Scene) && !isSceneConstructor(sceneOrRoute)) { const { loader, transitions } = sceneOrRoute; const { in: inTransition, out: outTransition } = transitions ?? {}; this._sceneToTransition.set(name, { in: inTransition, out: outTransition }); if (isLoaderConstructor(loader)) { this._sceneToLoader.set(name, new loader()); } else if (loader) { this._sceneToLoader.set(name, loader); } } if (this.scenes[name as unknown as TKnownScenes]) { this._logger.warn('Scene', name, 'already exists overwriting'); } this.scenes[name as unknown as TKnownScenes] = sceneOrRoute; return this.assertAdded(name); } remove(scene: Scene): void; remove(sceneCtor: SceneConstructor): void; remove(name: WithRoot): void; remove(nameOrScene: TKnownScenes | Scene | SceneConstructor | string) { if (nameOrScene instanceof Scene || isSceneConstructor(nameOrScene)) { const sceneOrCtor = nameOrScene; // remove scene for (const key in this.scenes) { if (this.scenes.hasOwnProperty(key)) { const potentialSceneOrOptions = this.scenes[key as TKnownScenes]; let scene: Scene | SceneConstructor; if (potentialSceneOrOptions instanceof Scene || isSceneConstructor(potentialSceneOrOptions)) { scene = potentialSceneOrOptions; } else { scene = potentialSceneOrOptions.scene; } if (scene === sceneOrCtor) { if (key === this.currentSceneName) { throw new Error(`Cannot remove a currently active scene: ${key}`); } this._sceneToInstance.delete(key); this._sceneToTransition.delete(key); this._sceneToLoader.delete(key); delete this.scenes[key as TKnownScenes]; } } } } if (typeof nameOrScene === 'string') { if (nameOrScene === this.currentSceneName) { throw new Error(`Cannot remove a currently active scene: ${nameOrScene}`); } // remove scene this._sceneToInstance.delete(nameOrScene); this._sceneToTransition.delete(nameOrScene); this._sceneToLoader.delete(nameOrScene); delete this.scenes[nameOrScene as TKnownScenes]; } } /** * Go to a specific scene, and optionally override loaders and transitions * @param destinationScene * @param options */ async goToScene(destinationScene: TKnownScenes | string, options?: GoToOptions) { const maybeDest = this.getSceneInstance(destinationScene); if (!maybeDest) { this._logger.warn(`Scene ${destinationScene} does not exist! Check the name, are you sure you added it?`); return; } const sourceSceneInstance = this.currentScene; const sourceScene = this.currentSceneName; const engineInputEnabled = this._engine.input?.enabled ?? true; this._isTransitioning = true; const maybeSourceOut = this.getSceneInstance(sourceScene)?.onTransition('out'); const maybeDestinationIn = maybeDest?.onTransition('in'); options = { // Engine configuration then dynamic scene transitions ...{ sourceOut: this._getOutTransition(this.currentSceneName) ?? maybeSourceOut }, ...{ destinationIn: this._getInTransition(destinationScene) ?? maybeDestinationIn }, // Goto options ...options }; const { sourceOut, destinationIn, sceneActivationData } = options; const outTransition = sourceOut ?? this._getOutTransition(this.currentSceneName); const inTransition = destinationIn ?? this._getInTransition(destinationScene); const hideLoader = outTransition?.hideLoader || inTransition?.hideLoader; if (hideLoader) { // Start hidden loader early and take advantage of the transition // Don't await and block on a hidden loader // eslint-disable-next-line @typescript-eslint/no-floating-promises this.maybeLoadScene(destinationScene, hideLoader); } this._emitEvent('navigationstart', sourceScene, destinationScene); // Run the out transition on the current scene if present if (outTransition) { await this.playTransition(outTransition, sourceSceneInstance); } // Run the loader if present await this.maybeLoadScene(destinationScene, hideLoader); // Give incoming transition a chance to grab info from previous if (inTransition) { await inTransition.onPreviousSceneDeactivate(this.currentScene); } // Setup the in transition on the destination scene if present // this is important to it can be initialized with the if (inTransition) { inTransition._addToTargetScene(this._engine, maybeDest); } // Swap to the new scene // Runs scene lifecycle init and activate await this.swapScene(destinationScene, sceneActivationData); this._emitEvent('navigation', sourceScene, destinationScene); // Run the in transition on the new scene if present if (inTransition) { await this.playTransition(inTransition, maybeDest); } this._emitEvent('navigationend', sourceScene, destinationScene); this._engine.input?.toggleEnabled(engineInputEnabled); this._isTransitioning = false; } /** * Retrieves a scene instance by key if it's registered. * * This will call any constructors that were given as a definition * @param scene */ getSceneInstance(scene: string): Scene | undefined { const sceneDefinition = this.getSceneDefinition(scene); if (!sceneDefinition) { return undefined; } if (this._sceneToInstance.has(scene)) { return this._sceneToInstance.get(scene) as Scene; } if (sceneDefinition instanceof Scene) { this._sceneToInstance.set(scene, sceneDefinition); return sceneDefinition; } const newScene = new sceneDefinition(); this._sceneToInstance.set(scene, newScene); return newScene; } /** * Triggers scene loading if has not already been loaded * @param scene * @param hideLoader */ async maybeLoadScene(scene: string, hideLoader = false) { const loader = this._getLoader(scene) ?? new DefaultLoader(); const sceneToLoad = this.getSceneDefinition(scene); const sceneToLoadInstance = this.getSceneInstance(scene); if (sceneToLoad && sceneToLoadInstance && !this._loadedScenes.has(sceneToLoadInstance)) { sceneToLoadInstance.onPreLoad(loader); sceneToLoadInstance.events.emit('preload', { loader }); if (hideLoader) { // Don't await a hidden loader // eslint-disable-next-line @typescript-eslint/no-floating-promises this._engine.load(loader, hideLoader); } else { await this._engine.load(loader); } this._loadedScenes.add(sceneToLoadInstance); } } /** * Plays a transition in the current scene and does book keeping for input. * @param transition */ async playTransition(transition: Transition, targetScene: Scene) { if (!this.isInitialized) { this._deferredTransition = transition; return; } if (transition) { this.currentTransition = transition; const sceneInputEnabled = targetScene.input?.enabled ?? true; targetScene.input?.toggleEnabled(!transition.blockInput); this._engine.input?.toggleEnabled(!transition.blockInput); targetScene.events.emit('transitionstart', transition); this.currentTransition._addToTargetScene(this._engine, targetScene); await this.currentTransition._play(); targetScene.events.emit('transitionend', transition); targetScene.input?.toggleEnabled(sceneInputEnabled); } this.currentTransition?.kill(); this.currentTransition?.reset(); this.currentTransition = undefined; } /** * Swaps the current and destination scene after performing required lifecycle events * * Note: swap scene will wait for any pending loader on the destination scene * @param destinationScene * @param data */ async swapScene(destinationScene: string, data?: TData): Promise { const engine = this._engine; // if not yet initialized defer goToScene if (!this.isInitialized) { this._deferredGoto = destinationScene; return; } const maybeDest = this.getSceneInstance(destinationScene); if (maybeDest) { const previousScene = this.currentScene; const nextScene = maybeDest; let previousSceneData: any = undefined; this._logger.debug('Going to scene:', destinationScene); // only deactivate when initialized if (this.currentScene.isInitialized) { const context = { engine, previousScene, nextScene }; previousSceneData = await this.currentScene._deactivate(context); this.currentScene.events.emit('deactivate', new DeactivateEvent(context, this.currentScene)); this.currentScene.input.clear(); } // wait for the scene to be loaded if needed const destLoader = this._sceneToLoader.get(destinationScene); await destLoader?.areResourcesLoaded(); // set current scene to new one this.currentScene = nextScene; this.currentSceneName = destinationScene; engine.screen.setCurrentCamera(nextScene.camera); // initialize the current scene if has not been already await this.currentScene._initialize(engine); const context = { engine, previousScene, previousSceneData, nextScene, data }; await this.currentScene._activate(context); this.currentScene.events.emit('activate', new ActivateEvent(context, this.currentScene)); } else { this._logger.error('Scene', destinationScene, 'does not exist!'); } } private _emitEvent(eventName: keyof DirectorEvents, sourceScene: string, destinationScene: string) { const source = this.getSceneDefinition(sourceScene)!; const dest = this.getSceneDefinition(destinationScene)!; this.events.emit(eventName, { sourceScene: source, sourceName: sourceScene, destinationScene: dest, destinationName: destinationScene } as DirectorNavigationEvent); } }