diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 166088827..b79052094 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -5,6 +5,7 @@ **Highlights:** glTF / GLB scene loading lands — author a 3D scene in Blender (or any DCC tool), export a `.glb`, and load it like a Tiled map with `level.load(...)`. Animated models play back through the same `setCurrentAnimation` / `play` / `pause` / `stop` API as a 2D `Sprite`. Scene meshes are lit by the authored sun, and 3D meshes can now report a real bounding box. ### Added +- **`loader.preload()` / `loader.load()` are now `await`-able** — `preload(assets)` returns a `Promise` that resolves once every asset has loaded (and rejects on failure), so you can `await loader.preload(assets)` instead of nesting an `onload` callback. `load(asset)` called **without** callbacks likewise returns a Promise for a one-off dynamic load. Both are fully back-compat: the callback forms (and `loader.onload` / `LOADER_PROGRESS` / `LOADER_ERROR` events) are unchanged, and `load(asset, onload, onerror)` still returns the resource count. (`preload` was already promise-based internally — it just didn't hand the promise back.) - **glTF / GLB scene loader (Tier 1)** — preload a `.glb`/`.gltf` and it auto-registers with the `level` director, so `level.load(name, { scale, rightHanded, onLoaded })` instantiates every mesh node as a `Mesh` in one call, exactly like a Tiled map. Parses the node graph, mesh primitives (`POSITION` / `NORMAL` / `TEXCOORD_0` / `COLOR_0` / indices), materials (`pbrMetallicRoughness.baseColorTexture` + `baseColorFactor`), perspective cameras, scene bounds, `KHR_lights_punctual` lights, and node animations. `loader.getGLTF(name)` returns the raw `{ nodes, cameras, lights, bounds, graph, animations }` descriptor for custom framing/instantiation. View under a `Camera3d`. New **glTF Scene** example (Kenney Platformer Kit, CC0). - **glTF node animation + `GLTFModel`** — assets that define animation channels load as a single rig-driven `GLTFModel` that keeps the node **hierarchy** intact (a parent transform carries its children — rotate a character's `torso` and its `arm`/`head` follow). Each frame the active clip is sampled (translation/scale `LERP`, rotation `SLERP`, plus `STEP`; `CUBICSPLINE` keyframe values) and the rig is re-posed. This is rigid node/TRS animation (no vertex skinning) — walk/idle/sprint characters, spinning pickups, doors, lifts. The animation API mirrors `Sprite`: `setCurrentAnimation(name, { loop, speed, onComplete, next })`, `isCurrentAnimation`, `getAnimationNames`, `animationspeed` (a playback multiplier), `play` / `pause` / `stop`. Retrieve the model after loading with `world.getChildByName(assetName)[0]`. New **glTF Animated Model** example (Kenney Blocky Characters, CC0). - **Aligned 2D + 3D animation API** — `Sprite.setCurrentAnimation(name, options)` now also accepts an options object `{ loop, speed, onComplete, next }` (the existing string / callback / no-arg forms are unchanged), plus a `speed` playback multiplier, and new `getAnimationNames()`. Both `Sprite` and `GLTFModel` gained `play(name?, options?)` (switch-and-play, or resume), chainable `pause()`, and `stop()` (reset to the first frame / bind pose) so 2D and 3D animation share one vocabulary. diff --git a/packages/melonjs/src/loader/loader.js b/packages/melonjs/src/loader/loader.js index 1ee873af3..8cc6697a9 100644 --- a/packages/melonjs/src/loader/loader.js +++ b/packages/melonjs/src/loader/loader.js @@ -243,10 +243,11 @@ function completeLoading(onloadcb) { const callback = onloadcb || onload; if (typeof callback === "function") { callback(); - emit(LOADER_COMPLETE); - } else { - throw new Error("no load callback defined"); } + // always signal completion — with the Promise form of preload() a callback + // is optional (you can `await` instead), so a missing callback is no longer + // an error; the loading screen / consumers react to LOADER_COMPLETE. + emit(LOADER_COMPLETE); } /** @@ -371,6 +372,9 @@ export function setParser(type, parserFn) { * @param {Asset[]} assets - list of assets to load * @param {Function} [onloadcb=loader.onload] - function to be called when all resources are loaded * @param {boolean} [switchToLoadState=true] - automatically switch to the loading screen + * @returns {Promise} resolves once every asset has loaded (rejects on a + * load failure). The `onloadcb` callback is still invoked on success, so both + * the callback and `await` forms work — use whichever you prefer. * @example * game.assets = [ * // PNG tileset @@ -407,8 +411,10 @@ export function setParser(type, parserFn) { * me.loader.load({name: "avatar", type:"video", src: "data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZ..."}; * ]; * ... - * // set all resources to be loaded + * // set all resources to be loaded (callback form) * me.loader.preload(game.assets, () => this.loaded()); + * // ...or await it (the callback still fires too) + * await me.loader.preload(game.assets); * @category Assets */ export function preload(assets, onloadcb, switchToLoadState = true) { @@ -417,38 +423,37 @@ export function preload(assets, onloadcb, switchToLoadState = true) { onload = onloadcb; } - // parse the resources and collect promises for each asset - const promises = []; - for (let i = 0; i < assets.length; i++) { - const asset = assets[i]; - const promise = new Promise((resolve, reject) => { - const count = load( - asset, - () => { - onResourceLoaded(asset); - resolve(); - }, - (err) => { - onLoadingError.call(this, asset); - reject(err); - }, - ); - resourceCount += count; - if (count === 0) { - // asset already loaded, resolve immediately - resolve(); - } - }); - promises.push(promise); - } + // parse the resources and collect a promise for each asset. `load()` now + // returns a Promise in its no-callback form, so there's no need to wrap a + // callback in `new Promise` here — just layer the progress / error reporting + // on top of it. Each asset counts as one toward the progress total (parsers + // load 0 or 1 resource; a cached asset resolves immediately and still + // reports progress, keeping `loadCount`/`resourceCount` balanced). + const promises = assets.map((asset) => { + resourceCount += 1; + return load(asset) + .then(() => { + onResourceLoaded(asset); + }) + .catch((err) => { + // onLoadingError emits LOADER_ERROR and throws by design, so this + // re-rejects → Promise.all (and the returned promise) reject and + // `await loader.preload(...)` surfaces the failure. The trailing + // throw keeps the rejection meaningful should it ever stop throwing. + onLoadingError.call(this, asset); + throw err; + }); + }); if (switchToLoadState === true) { // switch to the loading screen state.change(state.LOADING); } - // call the completion callback as soon as all assets are loaded - Promise.all(promises).then(() => { + // Resolve once every asset has loaded; call the completion callback on + // success (back-compat). Returned so callers can `await loader.preload(...)` + // instead of (or as well as) passing a callback. + return Promise.all(promises).then(() => { completeLoading(onload); }); } @@ -501,10 +506,15 @@ export function reload(src) { * @param {Asset} asset * @param {Function} [onload] - function to be called when the asset is loaded * @param {Function} [onerror] - function to be called in case of error - * @returns {number} the amount of corresponding resource to be preloaded + * @returns {number|Promise} with `onload`/`onerror` provided, the amount + * of corresponding resource to be preloaded (the legacy callback form). With + * **both omitted**, a Promise that resolves once the asset has loaded, so you + * can `await loader.load(asset)` for a one-off dynamic load. * @example - * // load an image asset + * // load an image asset (callback form) * me.loader.load({name: "avatar", type:"image", src: "data/avatar.png"}, () => this.onload(), () => this.onerror()); + * // ...or await a single dynamic asset (no callbacks) + * await me.loader.load({name: "avatar", type: "image", src: "data/avatar.png"}); * // load a compressed texture with fallback chain * me.loader.load({name: "terrain", type:"image", src: ["data/gfx/terrain.astc.ktx", "data/gfx/terrain.dds", "data/gfx/terrain.png"]}, () => this.onload()); * // load a base64 image asset @@ -555,12 +565,36 @@ export function load(asset, onload, onerror) { throw new Error("load : unknown or invalid resource type : " + asset.type); } - // parser returns the amount of asset to be loaded (usually 1 unless an asset is splitted into several ones) - return parser.call(this, asset, onload, onerror, { + const settings = { nocache: nocache, crossOrigin: crossOrigin, withCredentials: withCredentials, - }); + }; + + // Promise form: with no callbacks, resolve once the asset (and any + // sub-resources) have loaded — for a one-off `await loader.load(asset)`. + // The legacy callback form below still returns the resource count. + if (onload === undefined && onerror === undefined) { + return new Promise((resolve, reject) => { + // parser returns the amount of asset to be loaded (usually 1, more + // if it splits into several); 0 means already cached → resolve now. + const count = parser.call( + this, + asset, + () => { + resolve(); + }, + reject, + settings, + ); + if (count === 0) { + resolve(); + } + }); + } + + // parser returns the amount of asset to be loaded (usually 1 unless an asset is splitted into several ones) + return parser.call(this, asset, onload, onerror, settings); } /** diff --git a/packages/melonjs/tests/loader.spec.js b/packages/melonjs/tests/loader.spec.js index 2894f13c1..0901506f4 100644 --- a/packages/melonjs/tests/loader.spec.js +++ b/packages/melonjs/tests/loader.spec.js @@ -539,4 +539,120 @@ describe("loader", () => { ).resolves.toBe(true); }); }); + + // preload() and load() support BOTH a legacy callback form and a Promise + // (await) form; these cover the two forms side-by-side. + describe("callback form (legacy)", () => { + it("preload() invokes the completion callback", async () => { + // drive the test off the callback itself (don't await preload's return) + await new Promise((resolve) => { + loader.preload( + [{ name: "cb_pre", type: "image", src: imgURI }], + () => { + resolve(); + }, + false, // don't switch to the loading screen in the test + ); + }); + expect(loader.getImage("cb_pre")).toBeTruthy(); + }); + + it("load() invokes the onload callback", async () => { + await new Promise((resolve) => { + loader.load({ name: "cb_load", type: "image", src: imgURI }, () => { + resolve(); + }); + }); + expect(loader.getImage("cb_load")).toBeTruthy(); + }); + + it("load() invokes onerror on a failed asset", async () => { + let errored = false; + await new Promise((resolve) => { + loader.load( + { name: "cb_bad", type: "image", src: "data:image/png;base64,Zm9v" }, + () => { + resolve(); + }, + () => { + errored = true; + resolve(); + }, + ); + }); + expect(errored).toBe(true); + }); + + it("load() with callbacks still returns the resource count", () => { + const count = loader.load( + { name: "cb_count", type: "image", src: imgURI }, + () => {}, + () => {}, + ); + expect(typeof count).toBe("number"); + }); + }); + + describe("promise form (await)", () => { + it("preload() resolves as a Promise, and the callback still fires too", async () => { + let cbFired = false; + await loader.preload( + [{ name: "promise_pre", type: "image", src: imgURI }], + () => { + cbFired = true; + }, + false, + ); + expect(cbFired).toBe(true); + expect(loader.getImage("promise_pre")).toBeTruthy(); + }); + + it("preload() resolves with no callback at all (pure await)", async () => { + await loader.preload( + [{ name: "promise_pre_nocb", type: "image", src: imgURI }], + undefined, + false, + ); + expect(loader.getImage("promise_pre_nocb")).toBeTruthy(); + }); + + it("load() with no callbacks resolves as a Promise", async () => { + await loader.load({ name: "promise_load", type: "image", src: imgURI }); + expect(loader.getImage("promise_load")).toBeTruthy(); + }); + + it("ADVERSARIAL: load() (promise form) rejects on a failed asset", async () => { + let rejected = false; + await loader + .load({ + name: "promise_bad", + type: "image", + src: "data:image/png;base64,Zm9v", // not a valid PNG → onerror + }) + .catch(() => { + rejected = true; + }); + expect(rejected).toBe(true); + }); + + it("ADVERSARIAL: preload() (promise form) rejects when an asset fails", async () => { + let rejected = false; + await loader + .preload( + [ + { + name: "promise_bad2", + type: "image", + src: "data:image/png;base64,Zm9v", + }, + ], + undefined, + false, + ) + .catch(() => { + rejected = true; + }); + expect(rejected).toBe(true); + }); + }); });