From 96c9c4e92b8803df3f7f931b5985f4ae111dbdd5 Mon Sep 17 00:00:00 2001 From: Justin Young Date: Sat, 11 Apr 2026 12:15:19 -0400 Subject: [PATCH] Added SceneLoader.ts --- src/engine/director/SceneLoader.ts | 146 +++++++++++++++++++ src/engine/director/index.ts | 1 + src/engine/entity-component-system/entity.ts | 6 +- 3 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 src/engine/director/SceneLoader.ts diff --git a/src/engine/director/SceneLoader.ts b/src/engine/director/SceneLoader.ts new file mode 100644 index 00000000..a3e063a5 --- /dev/null +++ b/src/engine/director/SceneLoader.ts @@ -0,0 +1,146 @@ +import type { Loadable, SceneActivationContext } from 'excalibur'; +import { Future, Scene, Sound, WebAudio, Util } from 'excalibur'; + +export class BaseSceneLoader extends Scene { + private _resourceReferences: Record> | Loadable[] = {}; + private _resources: Loadable[] = []; + private _loadedResources = new Set>(); + private _isLoading = false; + private _loaderCompleteFuture: Future[]> | null = null; + + constructor(resources: Record> | Loadable[] = {}) { + super(); + const resourceList = Array.isArray(resources) ? resources : Object.values(resources); + this._resources = resourceList; + this._resourceReferences = resources; + + // Track any already-loaded resources upfront + for (const r of this._resources) { + if (r.isLoaded()) { + this._loadedResources.add(r); + } + } + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + public isLoaded(): boolean { + return this._resources.every((r) => this._loadedResources.has(r) && r.isLoaded()); + } + + public get progress(): number { + if (this._resources.length === 0) { + return 1; + } + return this._loadedResources.size / this._resources.length; + } + + /** + * Load all resources not yet loaded. Safe to call multiple times — + * concurrent calls coalesce onto the in-flight promise, and once that + * batch completes a subsequent call will only load newly added resources. + */ + + public async load(): Promise[]> { + // Coalesce concurrent calls onto the in-flight batch + + if (this._isLoading && this._loaderCompleteFuture) { + return this._loaderCompleteFuture.promise; + } + const pending = this._resources.filter((r) => !this._loadedResources.has(r) && !r.isLoaded()); + if (pending.length === 0) { + return this._resources; + } + + this._isLoading = true; + this._loaderCompleteFuture = new Future(); + const isInitialLoad = this._loadedResources.size === 0; + await this.onBeforeLoad(pending, isInitialLoad); + this.events.emit('beforeload', { resources: pending, isInitialLoad }); + await Promise.all( + pending.map(async (r) => { + this.events.emit('loadresourcestart', r); + try { + await r.load(); + } finally { + this._loadedResources.add(r); + this.events.emit('loadresourceend', r); + } + }) + ); + // Wire sounds to the engine + for (const resource of pending) { + if (resource instanceof Sound) { + resource.wireEngine(this.engine); + } + } + + // Only prompt for user action / unlock audio on the initial load + if (isInitialLoad) { + await this.onUserAction(); + this.events.emit('useraction'); + await WebAudio.unlock(); + } + + await this.onAfterLoad(pending, isInitialLoad); + this.events.emit('afterload', { resources: pending, isInitialLoad }); + + if (isInitialLoad) { + this.onInitialLoadComplete(); + } + + this._isLoading = false; + this._loaderCompleteFuture.resolve(this._resources); + this._loaderCompleteFuture = null; + return this._resources; + } + + override async onActivate(ctx: SceneActivationContext) { + // Renew _resources from references in case new ones were added after construction + if (!Array.isArray(this._resourceReferences)) { + this._resources = Object.values(this._resourceReferences); + } else { + this._resources = this._resourceReferences; + } + + for (const r of this._resources) { + if (r.isLoaded()) { + this._loadedResources.add(r); + } + } + + await this.load(); + } + + // ------------------------------------------------------------------------- + // Overridable hooks + // ------------------------------------------------------------------------- + + public onBeforeLoad(_pending: Loadable[], _isInitialLoad: boolean): Promise { + return Promise.resolve(); + } + + public onAfterLoad(_loaded: Loadable[], isInitialLoad: boolean): Promise { + return Promise.resolve(); + } + + public async onUserAction(delay: number = 200): Promise { + await Util.delay(delay, this.engine?.clock); + await this.showPlayButton(); + } + + /** Called once after the very first load batch completes. Override to trigger scene transition etc. */ + public onInitialLoadComplete(): void { + return; + } + + public showPlayButton(): Promise { + return Promise.resolve(); + } + + public dispose(): void { + return; + } +} diff --git a/src/engine/director/index.ts b/src/engine/director/index.ts index dc2c9b05..8e01a2f4 100644 --- a/src/engine/director/index.ts +++ b/src/engine/director/index.ts @@ -5,3 +5,4 @@ export * from './slide'; export * from './director'; export * from './loader'; export * from './default-loader'; +export * from './SceneLoader'; diff --git a/src/engine/entity-component-system/entity.ts b/src/engine/entity-component-system/entity.ts index cab1e34a..6d8b8d8e 100644 --- a/src/engine/entity-component-system/entity.ts +++ b/src/engine/entity-component-system/entity.ts @@ -56,10 +56,10 @@ export function isRemovedComponent(x: Message): x is RemovedCom */ export interface EntityEvents { initialize: InitializeEvent; + // @ts-ignore + add: AddEvent; //@ts-ignore - add: AddEvent; - //@ts-ignore - remove: RemoveEvent; + remove: RemoveEvent; preupdate: PreUpdateEvent; postupdate: PostUpdateEvent; kill: KillEvent; -- 2.51.2