Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>` 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.
Expand Down
104 changes: 69 additions & 35 deletions packages/melonjs/src/loader/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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<void>} 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
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
});
}
Expand Down Expand Up @@ -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<void>} 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
Expand Down Expand Up @@ -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);
}

/**
Expand Down
116 changes: 116 additions & 0 deletions packages/melonjs/tests/loader.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading