From 9af6976c26debdaf72d0a2127a2d16f9f1ee158f Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 5 Sep 2026 15:21:10 +0800 Subject: [PATCH 01/17] Level: add level.loadAsync(), and defer with a microtask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `level.load()` deferred its work with a timer so the current frame could unwind before the world is reset. The deferral is still needed — it is routinely called from a trigger handler mid-loop, and `safeLoadLevel` resets and destroys the very container the loop may be iterating, while `state.stop()` only sets a flag — but the timer is a 2011 artefact. That line and its comment date to v0.9.0, four years before promises existed; there was never a macrotask semantic to preserve. Browsers clamp a timer to at least a second in a background tab, so a load queued as the tab hides was stranded behind that clamp. A microtask drains when the JS stack empties, which unwinds the frame just the same and is not clamped. The no-loop branch stays synchronous exactly as before: with no loop there is no frame to unwind, and deferring would change when the level exists for anyone loading one before the game starts. `loadAsync()` then returns that completion instead of discarding it. `load()` is unchanged and still returns `true` — the emitted type stays `boolean`, so a typed consumer doing `const ok: boolean = level.load(id)` keeps compiling, which is why this is a sibling rather than a changed return type. `options.onLoaded` still fires either way. An unknown level id throws synchronously rather than rejecting: that is a typo, not a load failure, and it should not need `await` to surface. `load()` rethrows a rejection on a clean stack so a failure still surfaces as an uncaught error the way it did under the timer, rather than as a silent unhandled rejection. `Trigger` stops rewriting its caller's options. Its fade/mask path sequenced hide → load → reveal by replacing `settings.onLoaded` with its own function and calling the user's from inside it; awaiting the load removes that interception. The viewport is deliberately re-read after the load — `game.reset()` reassigns `app.viewport`, which is exactly why the callback this replaces read it late. Tests: no spec called `level.load()` at all before this, so both files are new. Fifteen tests over the legacy contract, the new method, the scheduling, and the trigger paths; all eight mutations of the changed behaviour fail as they should, including a source guard on the viewport re-read, which the reveal path cannot cover behaviourally because its tween needs a live loop. Closes #1646 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/CHANGELOG.md | 2 + packages/melonjs/src/level/level.js | 75 ++++++- packages/melonjs/src/renderable/trigger.js | 67 +++--- .../melonjs/tests/level_load_async.spec.js | 191 ++++++++++++++++++ .../tests/trigger_level_change.spec.js | 124 ++++++++++++ 5 files changed, 418 insertions(+), 41 deletions(-) create mode 100644 packages/melonjs/tests/level_load_async.spec.js create mode 100644 packages/melonjs/tests/trigger_level_change.spec.js diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index e470e5799..07970205d 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,6 +3,7 @@ ## [20.4.0] (melonJS 2) - _unreleased_ ### Added +- `level.loadAsync(levelId, options)` — the same load as {@link level.load}, resolving once the level is in the world instead of discarding the completion. `options.onLoaded` still fires, so the two forms mix freely, and `load()` is unchanged and still returns `true`. An unknown level id throws synchronously rather than rejecting: that is a typo, not a load failure, and it should not need `await` to surface ([#1646](https://github.com/melonjs/melonJS/issues/1646)) - **Soft transparency for the 3D tier** ([#1516](https://github.com/melonjs/melonJS/issues/1516)): a mesh now fades when you fade it. Draws that resolve to fractional alpha go into a **transparent pass**, replayed back-to-front after the opaque one — blending, writing no depth but still depth-tested, so transparent objects composite with each other and stay correctly hidden behind opaque geometry. Blending honours the renderable's existing `blendMode`, so `"additive"` gives glows. `transparent: true` opts in a soft-alpha *texture* the automatic check cannot see into — a glTF `alphaMode: "BLEND"` material, a glow sprite — and `transparent: false` pins the opaque path. Sorting is per object, so intersecting transparent meshes remain order-dependent. Needs a GPU backend and a `Camera3d`; a scene with no transparent objects never enters the queue - **Distance fog for the 3D tier** ([#1622](https://github.com/melonjs/melonJS/issues/1622)): `camera.setFog({ mode, near, far, density, color })` fades mesh geometry toward a colour with distance — `"linear"` between two distances, or `"exp2"` from a single density. Every parameter is optional and the omitted ones resolve **live**: the distances track the camera's own clip planes, so fog cannot silently disagree with them after a later `setClipPlanes`, and the colour tracks `renderer.backgroundColor`, so geometry dissolves into the sky you already set. Measured radially and applied per fragment, so it neither slides as the camera turns nor bands across large triangles. Fog belongs to the camera, so split-screen and minimap views fog independently and a `Camera2d` never fogs; a mesh opts out with `fog: false`. **Off by default**, and compiled out on both backends rather than skipped at runtime - **Height falloff for distance fog** ([#1633](https://github.com/melonjs/melonJS/issues/1633)): `camera.setFog({ …, fogHeight, heightFalloff })` makes fog density drop with altitude, so mist pools in low ground instead of hanging as thickly over a ridge as over the valley floor. `heightFalloff` defaults to `0`, which is not a special case but the same integral with the dial at zero, so a scene that omits it renders exactly as before. Costs one `exp` per vertex: an exponential integrates analytically along a straight segment, so there is no ray marching and no volume texture. Render space is **Y-down**, so `fogHeight` is the floor and density rises below it @@ -11,6 +12,7 @@ - Docs: the API reference carries the engine's own identity — logo, brand palette and favicon — and the header links out to the site, the wiki, the repository and Discord. A **Copy page** control hands the page you are reading to an assistant: it copies the page as Markdown with its canonical URL attached, or opens it directly in a chat. The landing page also gained a short section on using the reference with an AI assistant ### Fixed +- Level: a level load could sit for a second or more before starting when the tab was in the background. `level.load()` deferred its work with a timer so the current frame could unwind before the world is reset — necessary, since it is routinely called from a trigger handler mid-loop — but browsers clamp a timer to at least a second in a background tab. It now defers with a microtask, which unwinds the frame just the same and is not clamped - Lit meshes: specular highlights sat in the wrong place under a scaled ancestor ([#1636](https://github.com/melonjs/melonJS/issues/1636)). The camera position was derived from the view as `-Rᵀ·t`, which is only the right point when the upper 3×3 is orthonormal — and `Container.draw` folds every ancestor into that matrix. It is now the translation column of the view's inverse - Lit meshes: specular lighting, and a mesh's alpha-map cutout, were wrong on whichever tier drew second in a frame. The instanced and non-instanced tiers are two programs sharing one batcher, and its skip-the-redundant-upload cache was not dropped when the program changed under it — so an instanced set behind a lit prop at the same shininess lost its specular outright, and instanced foliage rendered as opaque rectangles. Present since 20.0.0 - Ground shadows: a scene could lose every blob it drew. The queue drained on any batcher switch, including inside the screen-projection window `Container.draw` opens around a `floating` child — so a single HUD deleted every ground shadow — and mid-scene whenever anything non-mesh sorted there. It now drains only where the world draw is finished diff --git a/packages/melonjs/src/level/level.js b/packages/melonjs/src/level/level.js index c2e597cb3..0fd3f7506 100644 --- a/packages/melonjs/src/level/level.js +++ b/packages/melonjs/src/level/level.js @@ -186,6 +186,50 @@ export const level = { * const sun = app.world.getChildByName("Sun")[0]; */ load(levelId, options) { + // Fire-and-forget by contract: this returns `true`, not the promise, so + // existing (including typed) callers are unaffected. Use `loadAsync()` + // to await the load. The rejection is rethrown on a clean stack so a + // failure still surfaces as an uncaught error the way it did when the + // deferral was a timer, rather than as a silent unhandled rejection. + this.loadAsync(levelId, options).catch((error) => { + queueMicrotask(() => { + throw error; + }); + }); + return true; + }, + + /** + * Load a level, and resolve once it is in the world. + * + * Same as {@link level.load} in every respect except that it hands back the + * completion of the load instead of discarding it. `options.onLoaded` still + * fires, so the two forms can be mixed. + * + * An unknown `levelId` throws SYNCHRONOUSLY rather than rejecting — that is + * a programmer error, not a load failure, and it should not need `await` to + * surface. + * @public + * @param {string} levelId - level id + * @param {object} [options] - additional options, as accepted by {@link level.load} + * @param {Container} [options.container=game.world] - container in which to load the specified level + * @param {Function} [options.onLoaded=game.onLevelLoaded] - callback for when the level is fully loaded + * @param {boolean} [options.flatten=game.mergeGroup] - (TMX only) if true, flatten all objects into the given container + * @param {boolean} [options.setViewportBounds=true] - (TMX only) if true, set the viewport bounds to the map size + * @param {number} [options.scale=1] - (glTF/GLB only) pixels per glTF unit applied to the whole scene + * @param {boolean} [options.rightHanded=true] - (glTF/GLB only) convert the right-handed (Y-up) source to the engine's Y-down via a rotation rather than a mirror + * @param {boolean} [options.lights=true] - (glTF/GLB only) add the scene's authored lights as {@link Light3d} world children + * @param {number} [options.lightIntensityScale] - (glTF/GLB only) multiply each light's authored physical intensity by this factor + * @param {boolean} [options.castGroundShadow=false] - (glTF/GLB only) give every mesh in the scene a ground shadow + * @param {number} [options.shadowGroundY] - (glTF/GLB only) world Y the ground shadows land on + * @returns {Promise} resolves once the level is in the world + * @example + * // await it, then start play + * await me.loader.preload(game.assets); + * await me.level.loadAsync("map1"); + * @category Level + */ + loadAsync(levelId, options) { options = Object.assign( { container: game.world, @@ -201,21 +245,30 @@ export const level = { throw new Error("level " + levelId + " not found"); } - // check the status of the state mngr - const wasRunning = state.isRunning(); - - if (wasRunning) { - // stop the game loop to avoid - // some silly side effects + // Deferred so the current frame can unwind first. `level.load()` is + // routinely called from inside the loop — a trigger handler, an update + // step — and `safeLoadLevel` resets and destroys the very container the + // loop may be iterating. `state.stop()` sets a flag; it does not unwind + // the frame already on the stack. + // + // A microtask rather than a timer. Both unwind the stack — a microtask + // drains when the JS stack empties, i.e. at the end of the rAF callback + // holding update AND draw — but `setTimeout` is clamped to >= 1s in a + // background tab, which would strand a load queued as the tab hides. + // The timer this replaced dated to 2011, before promises existed; there + // was never a macrotask semantic to preserve. + if (state.isRunning()) { + // stop the game loop to avoid some silly side effects state.stop(); - - setTimeout(() => { + return Promise.resolve().then(() => { safeLoadLevel(levelId, options, true); }); - } else { - safeLoadLevel(levelId, options); } - return true; + // No loop means no frame to unwind, so this stays SYNCHRONOUS exactly as + // before — deferring it would change when the level exists for anyone + // loading one before the game starts. + safeLoadLevel(levelId, options); + return Promise.resolve(); }, /** diff --git a/packages/melonjs/src/renderable/trigger.js b/packages/melonjs/src/renderable/trigger.js index 003b38040..92f88e8d6 100644 --- a/packages/melonjs/src/renderable/trigger.js +++ b/packages/melonjs/src/renderable/trigger.js @@ -169,37 +169,44 @@ export default class Trigger extends Renderable { const useMask = this.transition === "mask" && this.transitionShape; const shape = this.transitionShape; - // wrap the user's onLoaded to add the reveal effect - const userOnLoaded = settings.onLoaded; - settings.onLoaded = function (levelId) { - // re-read viewport after game.reset reassigns it - const vp = app.viewport; - // reveal effect (same type as hide) - if (useMask) { - vp.addCameraEffect( - new MaskEffect(vp, { - shape, - color, - duration, - direction: "reveal", - }), - ); - } else { - vp.addCameraEffect( - new FadeEffect(vp, { - color, - duration, - direction: "out", - }), - ); - } - // call the user's onLoaded if any - if (typeof userOnLoaded === "function") { - userOnLoaded.call(this, levelId); - } - }; + // Await the load rather than intercepting the caller's + // `onLoaded`: the reveal used to be injected by wrapping + // `settings.onLoaded` and calling the user's through it, + // which meant rewriting an option the caller passed in. const onComplete = () => { - level.load(gotolevel, settings); + level + .loadAsync(gotolevel, settings) + .then(() => { + // re-read AFTER the load: `game.reset()` reassigns + // `app.viewport`, so a viewport captured before it + // is stale by the time the reveal runs + const vp = app.viewport; + // reveal effect (same type as hide) + if (useMask) { + vp.addCameraEffect( + new MaskEffect(vp, { + shape, + color, + duration, + direction: "reveal", + }), + ); + } else { + vp.addCameraEffect( + new FadeEffect(vp, { + color, + duration, + direction: "out", + }), + ); + } + }) + .catch((error) => { + // same loudness as the fire-and-forget form + queueMicrotask(() => { + throw error; + }); + }); }; // hide effect, then load level + reveal diff --git a/packages/melonjs/tests/level_load_async.spec.js b/packages/melonjs/tests/level_load_async.spec.js new file mode 100644 index 000000000..888ea5a16 --- /dev/null +++ b/packages/melonjs/tests/level_load_async.spec.js @@ -0,0 +1,191 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + Application, + boot, + Container, + event, + level, + video, +} from "../src/index.js"; +import GLTFScene from "../src/level/gltf/GLTFScene.js"; +import state from "../src/state/state.ts"; + +/** + * `level.loadAsync()` and the scheduling behind `level.load()` (#1646). + * + * The deferral in `level.load()` dates to 2011 and used a timer because that + * was the only way to defer at the time. It is still needed — `level.load()` is + * routinely called from inside the loop, and `safeLoadLevel` resets and + * destroys the very container the loop may be iterating — but it is now a + * microtask, and the completion it produces is what `loadAsync()` hands back. + * + * The level content is irrelevant here: `GLTFScene.addTo` is stubbed so these + * tests pin the SCHEDULING, which is what changed. `getGLTF` returns null for + * an unregistered asset, so a scene registers without one. + */ +describe("level.loadAsync (#1646)", () => { + let app; + let calls; + let originalAddTo; + + beforeAll(async () => { + boot(); + app = new Application(320, 240, { + parent: "screen", + renderer: video.CANVAS, + consoleHeader: false, + }); + await app.init(); + originalAddTo = GLTFScene.prototype.addTo; + level.add("gltf", "unit-test-level"); + }); + + afterAll(() => { + GLTFScene.prototype.addTo = originalAddTo; + app?.destroy(); + }); + + afterEach(() => { + // leave the loop stopped between tests; each one sets what it needs + state.stop(); + }); + + /** record every time the level director actually puts a scene in the world */ + const track = () => { + calls = []; + GLTFScene.prototype.addTo = function (container) { + calls.push(container); + }; + return calls; + }; + + const container = () => { + return new Container(0, 0, 320, 240); + }; + + describe("the legacy load() contract is unchanged", () => { + it("still returns true, not a promise", () => { + track(); + state.stop(); + const result = level.load("unit-test-level", { container: container() }); + expect(result).toBe(true); + expect(typeof result).toBe("boolean"); + expect(result).not.toBeInstanceOf(Promise); + }); + + it("still fires options.onLoaded and emits LEVEL_LOADED", async () => { + track(); + state.stop(); + let calledWith = null; + let emitted = null; + const handler = (id) => { + emitted = id; + }; + event.on(event.LEVEL_LOADED, handler); + level.load("unit-test-level", { + container: container(), + onLoaded: (id) => { + calledWith = id; + }, + }); + await Promise.resolve(); + event.off(event.LEVEL_LOADED, handler); + expect(calledWith).toBe("unit-test-level"); + expect(emitted).toBe("unit-test-level"); + }); + + it("still throws SYNCHRONOUSLY on an unknown level id", () => { + // a programmer error, not a load failure — it must not need `await` + expect(() => { + return level.load("no-such-level"); + }).toThrow(/not found/); + }); + }); + + describe("loadAsync", () => { + it("resolves only once the level is in the world", async () => { + const seen = track(); + state.restart(); + const target = container(); + const promise = level.loadAsync("unit-test-level", { container: target }); + expect(promise).toBeInstanceOf(Promise); + await promise; + expect(seen).toHaveLength(1); + expect(seen[0]).toBe(target); + }); + + it("fires onLoaded as well, so the two forms can be mixed", async () => { + track(); + state.restart(); + let calledWith = null; + await level.loadAsync("unit-test-level", { + container: container(), + onLoaded: (id) => { + calledWith = id; + }, + }); + expect(calledWith).toBe("unit-test-level"); + }); + + it("throws SYNCHRONOUSLY on an unknown level id, rather than rejecting", () => { + // if this rejected instead, a caller that forgot `await` would get an + // unhandled rejection in place of a stack pointing at their typo + expect(() => { + return level.loadAsync("no-such-level"); + }).toThrow(/not found/); + }); + }); + + describe("the deferral it schedules", () => { + it("does NOT mutate the world synchronously while the loop runs", () => { + // the whole reason the deferral exists: `level.load` is called from + // trigger handlers mid-loop, and `safeLoadLevel` resets and destroys + // the container the loop may be iterating + const seen = track(); + state.restart(); + expect(state.isRunning()).toBe(true); + level.loadAsync("unit-test-level", { container: container() }); + expect(seen).toHaveLength(0); + }); + + it("stops the loop when it was running", () => { + track(); + state.restart(); + level.loadAsync("unit-test-level", { container: container() }); + expect(state.isRunning()).toBe(false); + }); + + it("still loads SYNCHRONOUSLY when the loop is not running", () => { + // preserved from the timer version: with no loop there is no frame to + // unwind, and deferring would change when the level exists for anyone + // loading one before the game starts + const seen = track(); + state.stop(); + level.loadAsync("unit-test-level", { container: container() }); + expect(seen).toHaveLength(1); + }); + + it("defers by a MICROTASK, not a timer", async () => { + // A timer is clamped to >= 1s in a background tab, which would strand + // a level load queued as the tab hides. A microtask drains when the + // stack empties, so it lands before any macrotask queued alongside it. + const order = []; + track(); + GLTFScene.prototype.addTo = () => { + order.push("load"); + }; + state.restart(); + const promise = level.loadAsync("unit-test-level", { + container: container(), + }); + const timer = new Promise((resolve) => { + setTimeout(() => { + order.push("timer"); + resolve(); + }, 0); + }); + await Promise.all([promise, timer]); + expect(order).toEqual(["load", "timer"]); + }); + }); +}); diff --git a/packages/melonjs/tests/trigger_level_change.spec.js b/packages/melonjs/tests/trigger_level_change.spec.js new file mode 100644 index 000000000..612922c79 --- /dev/null +++ b/packages/melonjs/tests/trigger_level_change.spec.js @@ -0,0 +1,124 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { Application, boot, level, Trigger, video } from "../src/index.js"; +import GLTFScene from "../src/level/gltf/GLTFScene.js"; +import triggerSource from "../src/renderable/trigger.js?raw"; +import state from "../src/state/state.ts"; + +/** + * `Trigger` level changes, across the `loadAsync` refactor (#1646). + * + * The fade/mask path used to sequence "hide → load → reveal" by REWRITING the + * caller's own `settings.onLoaded`: it saved the user's callback, replaced the + * option with its own, and called theirs from inside. Awaiting the load removes + * that interception. These pin the behaviour that must not change with it. + */ +describe("Trigger level change (#1646)", () => { + let app; + let loaded; + let originalAddTo; + + beforeAll(async () => { + boot(); + app = new Application(320, 240, { + parent: "screen", + renderer: video.CANVAS, + consoleHeader: false, + }); + await app.init(); + originalAddTo = GLTFScene.prototype.addTo; + GLTFScene.prototype.addTo = function (container) { + loaded.push(container); + }; + level.add("gltf", "trigger-target"); + }); + + afterAll(() => { + GLTFScene.prototype.addTo = originalAddTo; + app?.destroy(); + }); + + beforeEach(() => { + loaded = []; + state.stop(); + }); + + /** a Trigger attached to the world, so `getRootAncestor().app` resolves */ + const trigger = (settings) => { + const t = new Trigger(0, 0, { + width: 8, + height: 8, + event: "level", + to: "trigger-target", + ...settings, + }); + app.world.addChild(t); + return t; + }; + + it("loads directly when no transition is configured", () => { + // the plain path, unchanged by the refactor + const t = trigger({}); + t.triggerEvent(); + expect(loaded).toHaveLength(1); + app.world.removeChildNow(t); + }); + + it("does NOT overwrite the caller's onLoaded on the transition path", () => { + // The regression this refactor exists to remove. The old code did + // `settings.onLoaded = function (…) { …reveal…; userOnLoaded.call(…) }`, + // mutating an option object the caller owns and handed in. + const mine = () => {}; + const t = trigger({ + color: "#000000", + duration: 10, + onLoaded: mine, + }); + t.triggerEvent(); + expect(t.getTriggerSettings().onLoaded).toBe(mine); + app.world.removeChildNow(t); + }); + + it("defers the load until the hide transition completes", () => { + // the load must not fire on the same tick the trigger is hit — the + // fade has to play first + const t = trigger({ color: "#000000", duration: 10 }); + t.triggerEvent(); + expect(loaded).toHaveLength(0); + app.world.removeChildNow(t); + }); + + it("re-reads the viewport AFTER the load, not before it", () => { + // `Application.reset()` reassigns `app.viewport`, and `safeLoadLevel` + // calls `game.reset()` — so a viewport captured before the load is stale + // by the time the reveal runs. The callback this refactor replaced + // re-read it for exactly that reason. + // + // Asserted on the source because the reveal only runs when the hide + // tween completes, which needs a live game loop this suite does not + // have. Weaker than a behavioural test, and deliberately narrow: it + // pins the one line whose removal reintroduces a known bug. + const load = triggerSource.indexOf("loadAsync(gotolevel, settings)"); + const reveal = triggerSource.indexOf("addCameraEffect", load); + expect(load).toBeGreaterThan(-1); + expect(reveal).toBeGreaterThan(load); + // comment lines stripped: the explanation above this assertion mentions + // `app.viewport` too, and matching that would make this always pass + const code = triggerSource + .slice(load, reveal) + .split("\n") + .filter((line) => { + return !line.trim().startsWith("//"); + }) + .join("\n"); + expect(code).toContain("app.viewport"); + }); + + it("guards against re-entry while a transition is already running", () => { + const t = trigger({ color: "#000000", duration: 10 }); + t.triggerEvent(); + expect(t.fading).toBe(true); + t.triggerEvent(); + expect(loaded).toHaveLength(0); + app.world.removeChildNow(t); + }); +}); From bdb0dc233501fd726f9196c9fd9db7e2fa667b0a Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 5 Sep 2026 16:32:21 +0800 Subject: [PATCH 02/17] Level: awaitable twins for reload / next / previous too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `loadAsync()` on its own left the other three loading calls with no awaitable form, so a game could await its first level but not a reload or a level transition. Each twin resolves with exactly what its synchronous counterpart returns, which makes a port mechanical: `if (level.next())` becomes `if (await level.nextAsync())`. That is why `loadAsync()` now resolves `true` rather than `void` — the rule is worth more than the slightly noisier type. Running out of levels resolves `false` without loading anything rather than rejecting: `next()` returns `false` there, and reaching the end of a game is an ordinary outcome, not an error. The four originals are untouched, and their emitted types are unchanged — `load`, `next` and `previous` still declare `boolean`. Not included: `reload()` declares `object` from a stale `@returns {object} the current level`, but it returns whatever `load()` returns. Correcting that would change an emitted type, which is the one thing this change set is careful not to do, so it is left alone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/CHANGELOG.md | 2 +- packages/melonjs/src/level/level.js | 54 +++++++++++- .../melonjs/tests/level_load_async.spec.js | 84 ++++++++++++++++++- 3 files changed, 136 insertions(+), 4 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 07970205d..c115fd94b 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,7 +3,7 @@ ## [20.4.0] (melonJS 2) - _unreleased_ ### Added -- `level.loadAsync(levelId, options)` — the same load as {@link level.load}, resolving once the level is in the world instead of discarding the completion. `options.onLoaded` still fires, so the two forms mix freely, and `load()` is unchanged and still returns `true`. An unknown level id throws synchronously rather than rejecting: that is a typo, not a load failure, and it should not need `await` to surface ([#1646](https://github.com/melonjs/melonJS/issues/1646)) +- `level.loadAsync()`, `reloadAsync()`, `nextAsync()` and `previousAsync()` — awaitable twins of the four level-loading calls, resolving once the level is actually in the world instead of discarding the completion. Each resolves with exactly what its synchronous twin returns, so a port is mechanical: `if (level.next())` becomes `if (await level.nextAsync())`, and running out of levels still resolves `false` rather than rejecting. `options.onLoaded` still fires, so the two forms mix freely, and the originals are unchanged — `load()` still returns `true`, and its emitted type is still `boolean`. An unknown level id throws synchronously rather than rejecting: that is a typo, not a load failure, and it should not need `await` to surface ([#1646](https://github.com/melonjs/melonJS/issues/1646)) - **Soft transparency for the 3D tier** ([#1516](https://github.com/melonjs/melonJS/issues/1516)): a mesh now fades when you fade it. Draws that resolve to fractional alpha go into a **transparent pass**, replayed back-to-front after the opaque one — blending, writing no depth but still depth-tested, so transparent objects composite with each other and stay correctly hidden behind opaque geometry. Blending honours the renderable's existing `blendMode`, so `"additive"` gives glows. `transparent: true` opts in a soft-alpha *texture* the automatic check cannot see into — a glTF `alphaMode: "BLEND"` material, a glow sprite — and `transparent: false` pins the opaque path. Sorting is per object, so intersecting transparent meshes remain order-dependent. Needs a GPU backend and a `Camera3d`; a scene with no transparent objects never enters the queue - **Distance fog for the 3D tier** ([#1622](https://github.com/melonjs/melonJS/issues/1622)): `camera.setFog({ mode, near, far, density, color })` fades mesh geometry toward a colour with distance — `"linear"` between two distances, or `"exp2"` from a single density. Every parameter is optional and the omitted ones resolve **live**: the distances track the camera's own clip planes, so fog cannot silently disagree with them after a later `setClipPlanes`, and the colour tracks `renderer.backgroundColor`, so geometry dissolves into the sky you already set. Measured radially and applied per fragment, so it neither slides as the camera turns nor bands across large triangles. Fog belongs to the camera, so split-screen and minimap views fog independently and a `Camera2d` never fogs; a mesh opts out with `fog: false`. **Off by default**, and compiled out on both backends rather than skipped at runtime - **Height falloff for distance fog** ([#1633](https://github.com/melonjs/melonJS/issues/1633)): `camera.setFog({ …, fogHeight, heightFalloff })` makes fog density drop with altitude, so mist pools in low ground instead of hanging as thickly over a ridge as over the valley floor. `heightFalloff` defaults to `0`, which is not a special case but the same integral with the dial at zero, so a scene that omits it renders exactly as before. Costs one `exp` per vertex: an exponential integrates analytically along a straight segment, so there is no ray marching and no volume texture. Render space is **Y-down**, so `fogHeight` is the floor and density rises below it diff --git a/packages/melonjs/src/level/level.js b/packages/melonjs/src/level/level.js index 0fd3f7506..533253e38 100644 --- a/packages/melonjs/src/level/level.js +++ b/packages/melonjs/src/level/level.js @@ -222,7 +222,7 @@ export const level = { * @param {number} [options.lightIntensityScale] - (glTF/GLB only) multiply each light's authored physical intensity by this factor * @param {boolean} [options.castGroundShadow=false] - (glTF/GLB only) give every mesh in the scene a ground shadow * @param {number} [options.shadowGroundY] - (glTF/GLB only) world Y the ground shadows land on - * @returns {Promise} resolves once the level is in the world + * @returns {Promise} resolves `true` once the level is in the world * @example * // await it, then start play * await me.loader.preload(game.assets); @@ -262,13 +262,14 @@ export const level = { state.stop(); return Promise.resolve().then(() => { safeLoadLevel(levelId, options, true); + return true; }); } // No loop means no frame to unwind, so this stays SYNCHRONOUS exactly as // before — deferring it would change when the level exists for anyone // loading one before the game starts. safeLoadLevel(levelId, options); - return Promise.resolve(); + return Promise.resolve(true); }, /** @@ -306,6 +307,19 @@ export const level = { return this.load(this.getCurrentLevelId(), options); }, + /** + * Reload the current level, and resolve once it is in the world. + * + * The awaitable twin of {@link level.reload} — see {@link level.loadAsync}. + * @public + * @param {object} [options] - additional optional parameters, as accepted by {@link level.load} + * @returns {Promise} resolves `true` once the level is in the world + * @category Level + */ + reloadAsync(options) { + return this.loadAsync(this.getCurrentLevelId(), options); + }, + /** * load the next level * @public @@ -324,6 +338,25 @@ export const level = { } }, + /** + * Load the next level, and resolve once it is in the world. + * + * The awaitable twin of {@link level.next}: it resolves with the same value + * that one returns, so `if (level.next())` ports to + * `if (await level.nextAsync())`. With no next level it resolves `false` + * **without loading anything** — that is not an error, so it does not reject. + * @public + * @param {object} [options] - additional optional parameters, as accepted by {@link level.load} + * @returns {Promise} resolves `true` once the next level is in the world, or `false` if there is none + * @category Level + */ + nextAsync(options) { + if (currentLevelIdx + 1 < levelIdx.length) { + return this.loadAsync(levelIdx[currentLevelIdx + 1], options); + } + return Promise.resolve(false); + }, + /** * load the previous level
* @public @@ -342,6 +375,23 @@ export const level = { } }, + /** + * Load the previous level, and resolve once it is in the world. + * + * The awaitable twin of {@link level.previous} — resolves `false` without + * loading anything when there is no previous level. See {@link level.nextAsync}. + * @public + * @param {object} [options] - additional optional parameters, as accepted by {@link level.load} + * @returns {Promise} resolves `true` once the previous level is in the world, or `false` if there is none + * @category Level + */ + previousAsync(options) { + if (currentLevelIdx - 1 >= 0) { + return this.loadAsync(levelIdx[currentLevelIdx - 1], options); + } + return Promise.resolve(false); + }, + /** * return the amount of level preloaded * @public diff --git a/packages/melonjs/tests/level_load_async.spec.js b/packages/melonjs/tests/level_load_async.spec.js index 888ea5a16..4c3192369 100644 --- a/packages/melonjs/tests/level_load_async.spec.js +++ b/packages/melonjs/tests/level_load_async.spec.js @@ -38,6 +38,7 @@ describe("level.loadAsync (#1646)", () => { await app.init(); originalAddTo = GLTFScene.prototype.addTo; level.add("gltf", "unit-test-level"); + level.add("gltf", "unit-test-level-2"); }); afterAll(() => { @@ -109,7 +110,8 @@ describe("level.loadAsync (#1646)", () => { const target = container(); const promise = level.loadAsync("unit-test-level", { container: target }); expect(promise).toBeInstanceOf(Promise); - await promise; + // resolves with what `load()` returns, so a port is mechanical + await expect(promise).resolves.toBe(true); expect(seen).toHaveLength(1); expect(seen[0]).toBe(target); }); @@ -136,6 +138,86 @@ describe("level.loadAsync (#1646)", () => { }); }); + describe("the reload / next / previous twins", () => { + it("reloadAsync resolves once the current level is back in the world", async () => { + const seen = track(); + state.stop(); + await level.loadAsync("unit-test-level", { container: container() }); + seen.length = 0; + state.restart(); + await expect(level.reloadAsync({ container: container() })).resolves.toBe( + true, + ); + expect(seen).toHaveLength(1); + }); + + it("nextAsync loads the next level and resolves true", async () => { + const seen = track(); + state.stop(); + await level.loadAsync("unit-test-level", { container: container() }); + seen.length = 0; + state.restart(); + await expect(level.nextAsync({ container: container() })).resolves.toBe( + true, + ); + expect(seen).toHaveLength(1); + expect(level.getCurrentLevelId()).toBe("unit-test-level-2"); + }); + + it("nextAsync resolves FALSE without loading when there is no next", async () => { + // `next()` returns false here rather than throwing, so the twin must + // resolve false rather than reject — running out of levels is an + // ordinary outcome, not an error + const seen = track(); + state.stop(); + await level.loadAsync("unit-test-level-2", { container: container() }); + seen.length = 0; + state.restart(); + await expect(level.nextAsync({ container: container() })).resolves.toBe( + false, + ); + expect(seen).toHaveLength(0); + }); + + it("previousAsync loads the previous level and resolves true", async () => { + const seen = track(); + state.stop(); + await level.loadAsync("unit-test-level-2", { container: container() }); + seen.length = 0; + state.restart(); + await expect( + level.previousAsync({ container: container() }), + ).resolves.toBe(true); + expect(seen).toHaveLength(1); + expect(level.getCurrentLevelId()).toBe("unit-test-level"); + }); + + it("previousAsync resolves FALSE without loading when there is no previous", async () => { + const seen = track(); + state.stop(); + await level.loadAsync("unit-test-level", { container: container() }); + seen.length = 0; + state.restart(); + await expect( + level.previousAsync({ container: container() }), + ).resolves.toBe(false); + expect(seen).toHaveLength(0); + }); + + it("each sync twin still returns the same value, unchanged", () => { + track(); + state.stop(); + level.load("unit-test-level", { container: container() }); + expect(level.reload({ container: container() })).toBe(true); + expect(level.next({ container: container() })).toBe(true); + // now on the last level: no next + expect(level.next({ container: container() })).toBe(false); + expect(level.previous({ container: container() })).toBe(true); + // back on the first: no previous + expect(level.previous({ container: container() })).toBe(false); + }); + }); + describe("the deferral it schedules", () => { it("does NOT mutate the world synchronously while the loop runs", () => { // the whole reason the deferral exists: `level.load` is called from From 8ad10f6c391fdaf5b94048691727b9134a44305c Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 5 Sep 2026 16:47:29 +0800 Subject: [PATCH 03/17] Level: reload() returns a boolean, not the level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@returns {object} the current level` was never true. `reload()` is `return this.load(...)`, and `load()` returns `true` — and the 2011 original returned nothing at all, so the declaration has been wrong for the method's entire life. `getCurrentLevel()` is the call that hands back the level object. This corrects the emitted type from `object` to `boolean`. A `const lvl: object = level.reload()` that compiled while receiving `true` now fails to compile, which surfaces a bug that was already there rather than introducing one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/level/level.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index c115fd94b..66809a1af 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -12,6 +12,7 @@ - Docs: the API reference carries the engine's own identity — logo, brand palette and favicon — and the header links out to the site, the wiki, the repository and Discord. A **Copy page** control hands the page you are reading to an assistant: it copies the page as Markdown with its canonical URL attached, or opens it directly in a chat. The landing page also gained a short section on using the reference with an AI assistant ### Fixed +- Level: `level.reload()` was documented as returning `object` — "the current level" — but it returns whatever `level.load()` returns, which is `true`. The declared type has been wrong for the method's whole life: the 2011 original returned nothing at all. `getCurrentLevel()` is the call that hands back the level object. This corrects the emitted type from `object` to `boolean`, so a `const lvl: object = level.reload()` that compiled while receiving `true` now fails to compile, at the site that was already wrong - Level: a level load could sit for a second or more before starting when the tab was in the background. `level.load()` deferred its work with a timer so the current frame could unwind before the world is reset — necessary, since it is routinely called from a trigger handler mid-loop — but browsers clamp a timer to at least a second in a background tab. It now defers with a microtask, which unwinds the frame just the same and is not clamped - Lit meshes: specular highlights sat in the wrong place under a scaled ancestor ([#1636](https://github.com/melonjs/melonJS/issues/1636)). The camera position was derived from the view as `-Rᵀ·t`, which is only the right point when the upper 3×3 is orthonormal — and `Container.draw` folds every ancestor into that matrix. It is now the translation column of the view's inverse - Lit meshes: specular lighting, and a mesh's alpha-map cutout, were wrong on whichever tier drew second in a frame. The instanced and non-instanced tiers are two programs sharing one batcher, and its skip-the-redundant-upload cache was not dropped when the program changed under it — so an instanced set behind a lit prop at the same shininess lost its specular outright, and instanced foliage rendered as opaque rectangles. Present since 20.0.0 diff --git a/packages/melonjs/src/level/level.js b/packages/melonjs/src/level/level.js index 533253e38..b8714b052 100644 --- a/packages/melonjs/src/level/level.js +++ b/packages/melonjs/src/level/level.js @@ -299,7 +299,7 @@ export const level = { * @param {Container} [options.container=game.world] - container in which to load the specified level * @param {Function} [options.onLoaded=game.onLevelLoaded] - callback for when the level is fully loaded * @param {boolean} [options.flatten=game.mergeGroup] - if true, flatten all objects into the given container - * @returns {object} the current level + * @returns {boolean} true if the level was reloaded */ reload(options) { // reset the level to initial state From b7f1e6e70334f705e43a44d5017ed1d272a64cb0 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 5 Sep 2026 16:51:55 +0800 Subject: [PATCH 04/17] Level: loadAsync rejects consistently, whichever branch runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review catch. The deferred branch turned a failing load into a rejection, but the synchronous one let the exception escape the call — so the error surface depended on whether the loop happened to be running, and `loadAsync(...).catch()` could never see the synchronous case, because the throw beat the handler being attached. The unknown-id check still throws synchronously, before either branch: that is a typo rather than a load failure, and should not need `await`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/src/level/level.js | 12 +++++++++- .../melonjs/tests/level_load_async.spec.js | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/melonjs/src/level/level.js b/packages/melonjs/src/level/level.js index b8714b052..a7cf12633 100644 --- a/packages/melonjs/src/level/level.js +++ b/packages/melonjs/src/level/level.js @@ -268,7 +268,17 @@ export const level = { // No loop means no frame to unwind, so this stays SYNCHRONOUS exactly as // before — deferring it would change when the level exists for anyone // loading one before the game starts. - safeLoadLevel(levelId, options); + // + // Wrapped so a failure arrives the same way it does from the deferred + // branch above: as a REJECTION. Letting it escape as an exception would + // make the error surface depend on whether the loop happened to be + // running, and `loadAsync(...).catch()` could not see it at all, since + // the throw would beat the handler being attached. + try { + safeLoadLevel(levelId, options); + } catch (error) { + return Promise.reject(error); + } return Promise.resolve(true); }, diff --git a/packages/melonjs/tests/level_load_async.spec.js b/packages/melonjs/tests/level_load_async.spec.js index 4c3192369..9f91b036b 100644 --- a/packages/melonjs/tests/level_load_async.spec.js +++ b/packages/melonjs/tests/level_load_async.spec.js @@ -129,6 +129,30 @@ describe("level.loadAsync (#1646)", () => { expect(calledWith).toBe("unit-test-level"); }); + it("REJECTS when the load itself fails, whether or not the loop runs", async () => { + // The failure surface must not depend on `state.isRunning()`. The + // deferred branch naturally produces a rejection; the synchronous + // one would let the exception escape the call, where a + // `loadAsync(...).catch()` could never see it — the throw beats the + // handler being attached. + const boom = new Error("addTo exploded"); + GLTFScene.prototype.addTo = () => { + throw boom; + }; + + state.stop(); + expect(state.isRunning()).toBe(false); + await expect( + level.loadAsync("unit-test-level", { container: container() }), + ).rejects.toBe(boom); + + state.restart(); + expect(state.isRunning()).toBe(true); + await expect( + level.loadAsync("unit-test-level", { container: container() }), + ).rejects.toBe(boom); + }); + it("throws SYNCHRONOUSLY on an unknown level id, rather than rejecting", () => { // if this rejected instead, a caller that forgot `await` would get an // unhandled rejection in place of a stack pointing at their typo From 7ef4ebb54cddb2516933e58f7e5896a48d22cdd6 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 5 Sep 2026 16:58:28 +0800 Subject: [PATCH 05/17] Level: an `async` option instead of four *Async siblings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight loading methods for four operations was too much surface. The switch moves into the options object the calls already take, so `load`, `reload`, `next` and `previous` each keep one name and gain a flag. No type break, which is the part that had to be got right. The signatures are preserved as JSDoc `@overload` pairs rather than a `boolean | Promise` union — a union would fail every existing `const ok: boolean = level.load(id)`, verified with tsc. The overload form compiles both that and `await level.load(id, { async: true })` against the real emitted build. The bounds check `next` and `previous` each spelled out is now a shared `levelIdAt(offset)` helper, so the two cannot drift. The cost of putting the switch in the options is that `await level.load(id)` without the flag is silent — `await true` is valid. It happens to be harmless today, since the deferral is a single microtask queued before the await's continuation, so the load still runs first; that is incidental ordering rather than a contract. Documented on the options typedef and pinned by a test. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/CHANGELOG.md | 2 +- packages/melonjs/src/level/level.js | 315 ++++++++---------- packages/melonjs/src/renderable/trigger.js | 2 +- .../melonjs/tests/level_load_async.spec.js | 124 +++++-- .../tests/trigger_level_change.spec.js | 4 +- 5 files changed, 241 insertions(+), 206 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 66809a1af..6dea6cac6 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,7 +3,7 @@ ## [20.4.0] (melonJS 2) - _unreleased_ ### Added -- `level.loadAsync()`, `reloadAsync()`, `nextAsync()` and `previousAsync()` — awaitable twins of the four level-loading calls, resolving once the level is actually in the world instead of discarding the completion. Each resolves with exactly what its synchronous twin returns, so a port is mechanical: `if (level.next())` becomes `if (await level.nextAsync())`, and running out of levels still resolves `false` rather than rejecting. `options.onLoaded` still fires, so the two forms mix freely, and the originals are unchanged — `load()` still returns `true`, and its emitted type is still `boolean`. An unknown level id throws synchronously rather than rejecting: that is a typo, not a load failure, and it should not need `await` to surface ([#1646](https://github.com/melonjs/melonJS/issues/1646)) +- `level.load()`, `reload()`, `next()` and `previous()` take an `async` option: set it and the call hands back a promise that settles once the level is actually in the world, instead of the boolean it has always returned. `options.onLoaded` still fires either way, so the two forms mix freely, and omitting the flag changes nothing — the existing signatures are preserved as TypeScript overloads, so `const ok: boolean = level.load("map1")` still compiles. Running out of levels still reports `false` rather than rejecting, and an unknown level id throws synchronously in both forms: that is a typo, not a load failure, and it should not need `await` to surface ([#1646](https://github.com/melonjs/melonJS/issues/1646)) - **Soft transparency for the 3D tier** ([#1516](https://github.com/melonjs/melonJS/issues/1516)): a mesh now fades when you fade it. Draws that resolve to fractional alpha go into a **transparent pass**, replayed back-to-front after the opaque one — blending, writing no depth but still depth-tested, so transparent objects composite with each other and stay correctly hidden behind opaque geometry. Blending honours the renderable's existing `blendMode`, so `"additive"` gives glows. `transparent: true` opts in a soft-alpha *texture* the automatic check cannot see into — a glTF `alphaMode: "BLEND"` material, a glow sprite — and `transparent: false` pins the opaque path. Sorting is per object, so intersecting transparent meshes remain order-dependent. Needs a GPU backend and a `Camera3d`; a scene with no transparent objects never enters the queue - **Distance fog for the 3D tier** ([#1622](https://github.com/melonjs/melonJS/issues/1622)): `camera.setFog({ mode, near, far, density, color })` fades mesh geometry toward a colour with distance — `"linear"` between two distances, or `"exp2"` from a single density. Every parameter is optional and the omitted ones resolve **live**: the distances track the camera's own clip planes, so fog cannot silently disagree with them after a later `setClipPlanes`, and the colour tracks `renderer.backgroundColor`, so geometry dissolves into the sky you already set. Measured radially and applied per fragment, so it neither slides as the camera turns nor bands across large triangles. Fog belongs to the camera, so split-screen and minimap views fog independently and a `Camera2d` never fogs; a mesh opts out with `fog: false`. **Off by default**, and compiled out on both backends rather than skipped at runtime - **Height falloff for distance fog** ([#1633](https://github.com/melonjs/melonJS/issues/1633)): `camera.setFog({ …, fogHeight, heightFalloff })` makes fog density drop with altitude, so mist pools in low ground instead of hanging as thickly over a ridge as over the valley floor. `heightFalloff` defaults to `0`, which is not a special case but the same integral with the dial at zero, so a scene that omits it renders exactly as before. Costs one `exp` per vertex: an exponential integrates analytically along a straight segment, so there is no ray marching and no volume texture. Render space is **Y-down**, so `fogHeight` is the floor and density rises below it diff --git a/packages/melonjs/src/level/level.js b/packages/melonjs/src/level/level.js index a7cf12633..f127ea9ad 100644 --- a/packages/melonjs/src/level/level.js +++ b/packages/melonjs/src/level/level.js @@ -84,6 +84,45 @@ function loadTMXLevel(levelId, container, flatten, setViewportBounds) { level.addTo(container, flatten, setViewportBounds); } +/** + * The id of the level `offset` steps from the current one, or `null` when that + * lands outside the set. Shared so the bounds check lives in exactly one place + * — it used to be spelled out in `next` and `previous` separately. + * @param {number} offset - steps from the current level (1 = next, -1 = previous) + * @returns {string|null} the level id, or null when there is none + * @ignore + * @internal + */ +function levelIdAt(offset) { + const index = currentLevelIdx + offset; + return index >= 0 && index < levelIdx.length ? levelIdx[index] : null; +} + +/** + * Options accepted by every level-loading call. + * + * `async` is the switch that decides what the call HANDS BACK: leave it out and + * you get the boolean these calls have always returned, set it and you get a + * promise that settles once the level is actually in the world. Everything else + * behaves identically either way, `onLoaded` included. + * + * Note that awaiting a call WITHOUT `async: true` is not an error — `await true` + * is valid and resolves immediately — so the level will not be loaded yet. Pass + * the flag whenever you intend to await. + * @typedef {object} LevelLoadOptions + * @property {Container} [container=game.world] - container in which to load the specified level + * @property {Function} [onLoaded=game.onLevelLoaded] - callback for when the level is fully loaded, called in both forms + * @property {boolean} [async=false] - return a promise that settles once the level is in the world, instead of a boolean + * @property {boolean} [flatten=game.mergeGroup] - (TMX only) if true, flatten all objects into the given container + * @property {boolean} [setViewportBounds=true] - (TMX only) if true, set the viewport bounds to the map size + * @property {number} [scale=1] - (glTF/GLB only) pixels per glTF unit applied to the whole scene + * @property {boolean} [rightHanded=true] - (glTF/GLB only) convert the right-handed (Y-up) source to the engine's Y-down via a rotation rather than a mirror + * @property {boolean} [lights=true] - (glTF/GLB only) add the scene's authored `KHR_lights_punctual` lights (plus a soft ambient fill) as {@link Light3d} world children + * @property {number} [lightIntensityScale] - (glTF/GLB only) multiply each light's authored physical intensity by this factor instead of normalizing it to 1 + * @property {boolean} [castGroundShadow=false] - (glTF/GLB only) give every mesh in the scene a ground shadow + * @property {number} [shadowGroundY] - (glTF/GLB only) world Y the ground shadows land on + */ + /** * a level manager. once resources loaded, the level manager contains all references of defined levels. * @namespace level @@ -132,104 +171,49 @@ export const level = { return true; }, + /** + * @overload + * @param {string} levelId + * @param {LevelLoadOptions & { async: true }} options + * @returns {Promise} + */ + /** + * @overload + * @param {string} levelId + * @param {LevelLoadOptions & { async?: false }} [options] + * @returns {boolean} + */ /** * load a level into the game manager
* (will also create all level defined entities, etc..) + * + * Pass `async: true` to get a promise that settles once the level is in the + * world, instead of the boolean. `options.onLoaded` still fires either way, + * so the two forms mix freely. + * + * An unknown `levelId` throws SYNCHRONOUSLY in both forms — that is a typo + * rather than a load failure, and it should not need `await` to surface. * @public * @param {string} levelId - level id - * @param {object} [options] - additional optional parameters - * @param {Container} [options.container=game.world] - container in which to load the specified level - * @param {Function} [options.onLoaded=game.onLevelLoaded] - callback for when the level is fully loaded - * @param {boolean} [options.flatten=game.mergeGroup] - (TMX only) if true, flatten all objects into the given container - * @param {boolean} [options.setViewportBounds=true] - (TMX only) if true, set the viewport bounds to the map size - * @param {number} [options.scale=1] - (glTF/GLB only) pixels per glTF unit applied to the whole scene - * @param {boolean} [options.rightHanded=true] - (glTF/GLB only) convert the right-handed (Y-up) source to the engine's Y-down via a rotation rather than a mirror - * @param {boolean} [options.lights=true] - (glTF/GLB only) add the scene's authored `KHR_lights_punctual` lights (plus a soft ambient fill) as {@link Light3d} world children; each carries its authored name for `getChildByName` lookups - * @param {number} [options.lightIntensityScale] - (glTF/GLB only) multiply each light's authored physical intensity (lux/candela) by this factor instead of normalizing it to 1 — see {@link GLTFScene#addTo} - * @param {boolean} [options.castGroundShadow] - (glTF/GLB only) give this scene's meshes a ground shadow ({@link Mesh#castGroundShadow}). Overrides the application's `castGroundShadow` setting for this scene, in both directions; omit it to inherit. As a scene-wide opt-in it skips nodes with no vertical extent — a scene's ground plane is exactly that, and shadowing it with itself smears a blob across the whole floor - * @param {number} [options.shadowGroundY] - (glTF/GLB only) world Y of the floor those shadows land on ({@link Mesh#shadowGroundY}); omit it and each blob sits at its own object's base at full strength, which is right for a scene whose props already rest on the ground - * @returns {boolean} true if the level was successfully loaded + * @param {LevelLoadOptions} [options] - additional optional parameters + * @returns {boolean|Promise} `true`, or a promise resolving `true` when `async` is set * @example - * // the game assets to be be preloaded - * // TMX maps - * let resources = [ - * {name: "a4_level1", type: "tmx", src: "data/level/a4_level1.tmx"}, - * {name: "a4_level2", type: "tmx", src: "data/level/a4_level2.tmx"}, - * {name: "a4_level3", type: "tmx", src: "data/level/a4_level3.tmx"}, - * // ... - * ]; - * - * // ... - * - * // load a level into the game world + * // load a level, the way it has always worked * me.level.load("a4_level1"); - * ... - * ... - * // load a level into a specific container - * let levelContainer = new me.Container(); - * me.level.load("a4_level2", {container:levelContainer}); - * // add a simple transformation - * levelContainer.translate(levelContainer.width / 2, levelContainer.height / 2 ); - * levelContainer.rotate(0.05); - * levelContainer.translate(-levelContainer.width / 2, -levelContainer.height / 2 ); - * // add it to the game world - * app.world.addChild(levelContainer); * - * // load a glTF/GLB scene (preloaded with type "glb") under a Camera3d: - * // 50 pixels per glTF unit, authored lux/candela intensities kept at - * // a 1/1000 scale instead of being normalized to 1 - * me.level.load("diorama", { scale: 50, lightIntensityScale: 0.001 }); - * // …and give every prop in it a ground shadow landing on the floor at y = 0 - * // (the scene's own ground plane is skipped — it has no height to cast) - * me.level.load("diorama", { scale: 50, castGroundShadow: true, shadowGroundY: 0 }); - * // the authored lights are world children — grab the sun for a day/night cycle - * const sun = app.world.getChildByName("Sun")[0]; - */ - load(levelId, options) { - // Fire-and-forget by contract: this returns `true`, not the promise, so - // existing (including typed) callers are unaffected. Use `loadAsync()` - // to await the load. The rejection is rethrown on a clean stack so a - // failure still surfaces as an uncaught error the way it did when the - // deferral was a timer, rather than as a silent unhandled rejection. - this.loadAsync(levelId, options).catch((error) => { - queueMicrotask(() => { - throw error; - }); - }); - return true; - }, - - /** - * Load a level, and resolve once it is in the world. + * // ...or wait for it + * await me.loader.preload(game.assets); + * await me.level.load("a4_level1", { async: true }); * - * Same as {@link level.load} in every respect except that it hands back the - * completion of the load instead of discarding it. `options.onLoaded` still - * fires, so the two forms can be mixed. + * // load into a specific container + * me.level.load("a4_level2", { container: levelContainer }); * - * An unknown `levelId` throws SYNCHRONOUSLY rather than rejecting — that is - * a programmer error, not a load failure, and it should not need `await` to - * surface. - * @public - * @param {string} levelId - level id - * @param {object} [options] - additional options, as accepted by {@link level.load} - * @param {Container} [options.container=game.world] - container in which to load the specified level - * @param {Function} [options.onLoaded=game.onLevelLoaded] - callback for when the level is fully loaded - * @param {boolean} [options.flatten=game.mergeGroup] - (TMX only) if true, flatten all objects into the given container - * @param {boolean} [options.setViewportBounds=true] - (TMX only) if true, set the viewport bounds to the map size - * @param {number} [options.scale=1] - (glTF/GLB only) pixels per glTF unit applied to the whole scene - * @param {boolean} [options.rightHanded=true] - (glTF/GLB only) convert the right-handed (Y-up) source to the engine's Y-down via a rotation rather than a mirror - * @param {boolean} [options.lights=true] - (glTF/GLB only) add the scene's authored lights as {@link Light3d} world children - * @param {number} [options.lightIntensityScale] - (glTF/GLB only) multiply each light's authored physical intensity by this factor - * @param {boolean} [options.castGroundShadow=false] - (glTF/GLB only) give every mesh in the scene a ground shadow - * @param {number} [options.shadowGroundY] - (glTF/GLB only) world Y the ground shadows land on - * @returns {Promise} resolves `true` once the level is in the world - * @example - * // await it, then start play - * await me.loader.preload(game.assets); - * await me.level.loadAsync("map1"); + * // a glTF/GLB scene (preloaded with type "glb") under a Camera3d: + * // 50 pixels per glTF unit, authored intensities kept at a 1/1000 scale + * me.level.load("diorama", { scale: 50, lightIntensityScale: 0.001 }); * @category Level */ - loadAsync(levelId, options) { + load(levelId, options) { options = Object.assign( { container: game.world, @@ -245,6 +229,8 @@ export const level = { throw new Error("level " + levelId + " not found"); } + const wantsPromise = options.async === true; + // Deferred so the current frame can unwind first. `level.load()` is // routinely called from inside the loop — a trigger handler, an update // step — and `safeLoadLevel` resets and destroys the very container the @@ -260,26 +246,41 @@ export const level = { if (state.isRunning()) { // stop the game loop to avoid some silly side effects state.stop(); - return Promise.resolve().then(() => { + const deferred = Promise.resolve().then(() => { safeLoadLevel(levelId, options, true); return true; }); + if (wantsPromise) { + return deferred; + } + // Fire-and-forget: rethrow on a clean stack so a failure still + // surfaces as an uncaught error the way it did when the deferral was + // a timer, rather than as a silent unhandled rejection. + deferred.catch((error) => { + queueMicrotask(() => { + throw error; + }); + }); + return true; } + // No loop means no frame to unwind, so this stays SYNCHRONOUS exactly as // before — deferring it would change when the level exists for anyone // loading one before the game starts. - // - // Wrapped so a failure arrives the same way it does from the deferred - // branch above: as a REJECTION. Letting it escape as an exception would - // make the error surface depend on whether the loop happened to be - // running, and `loadAsync(...).catch()` could not see it at all, since - // the throw would beat the handler being attached. - try { - safeLoadLevel(levelId, options); - } catch (error) { - return Promise.reject(error); + if (wantsPromise) { + // wrapped so a failure arrives as a REJECTION here too: letting it + // escape as an exception would make the error surface depend on + // whether the loop happened to be running, and `.catch()` could not + // see it, since the throw beats the handler being attached + try { + safeLoadLevel(levelId, options); + } catch (error) { + return Promise.reject(error); + } + return Promise.resolve(true); } - return Promise.resolve(true); + safeLoadLevel(levelId, options); + return true; }, /** @@ -303,13 +304,21 @@ export const level = { }, /** - * reload the current level + * @overload + * @param {LevelLoadOptions & { async: true }} options + * @returns {Promise} + */ + /** + * @overload + * @param {LevelLoadOptions & { async?: false }} [options] + * @returns {boolean} + */ + /** + * reload the current level. Pass `async: true` for a promise — see {@link level.load}. * @public - * @param {object} [options] - additional optional parameters - * @param {Container} [options.container=game.world] - container in which to load the specified level - * @param {Function} [options.onLoaded=game.onLevelLoaded] - callback for when the level is fully loaded - * @param {boolean} [options.flatten=game.mergeGroup] - if true, flatten all objects into the given container - * @returns {boolean} true if the level was reloaded + * @param {LevelLoadOptions} [options] - additional optional parameters + * @returns {boolean|Promise} `true`, or a promise resolving `true` when `async` is set + * @category Level */ reload(options) { // reset the level to initial state @@ -318,88 +327,60 @@ export const level = { }, /** - * Reload the current level, and resolve once it is in the world. - * - * The awaitable twin of {@link level.reload} — see {@link level.loadAsync}. - * @public - * @param {object} [options] - additional optional parameters, as accepted by {@link level.load} - * @returns {Promise} resolves `true` once the level is in the world - * @category Level + * @overload + * @param {LevelLoadOptions & { async: true }} options + * @returns {Promise} */ - reloadAsync(options) { - return this.loadAsync(this.getCurrentLevelId(), options); - }, - /** - * load the next level - * @public - * @param {object} [options] - additional optional parameters - * @param {Container} [options.container=game.world] - container in which to load the specified level - * @param {Function} [options.onLoaded=game.onLevelLoaded] - callback for when the level is fully loaded - * @param {boolean} [options.flatten=game.mergeGroup] - if true, flatten all objects into the given container - * @returns {boolean} true if the next level was successfully loaded + * @overload + * @param {LevelLoadOptions & { async?: false }} [options] + * @returns {boolean} */ - next(options) { - //go to the next level - if (currentLevelIdx + 1 < levelIdx.length) { - return this.load(levelIdx[currentLevelIdx + 1], options); - } else { - return false; - } - }, - /** - * Load the next level, and resolve once it is in the world. + * load the next level. Pass `async: true` for a promise — see {@link level.load}. * - * The awaitable twin of {@link level.next}: it resolves with the same value - * that one returns, so `if (level.next())` ports to - * `if (await level.nextAsync())`. With no next level it resolves `false` - * **without loading anything** — that is not an error, so it does not reject. + * With no next level this reports `false` WITHOUT loading anything, and the + * promise form resolves `false` rather than rejecting: reaching the end of a + * game is an ordinary outcome, not an error. * @public - * @param {object} [options] - additional optional parameters, as accepted by {@link level.load} - * @returns {Promise} resolves `true` once the next level is in the world, or `false` if there is none + * @param {LevelLoadOptions} [options] - additional optional parameters + * @returns {boolean|Promise} `true` if the next level was loaded, `false` if there is none * @category Level */ - nextAsync(options) { - if (currentLevelIdx + 1 < levelIdx.length) { - return this.loadAsync(levelIdx[currentLevelIdx + 1], options); + next(options) { + const levelId = levelIdAt(1); + if (levelId !== null) { + return this.load(levelId, options); } - return Promise.resolve(false); + return options?.async === true ? Promise.resolve(false) : false; }, /** - * load the previous level
- * @public - * @param {object} [options] - additional optional parameters - * @param {Container} [options.container=game.world] - container in which to load the specified level - * @param {Function} [options.onLoaded=game.onLevelLoaded] - callback for when the level is fully loaded - * @param {boolean} [options.flatten=game.mergeGroup] - if true, flatten all objects into the given container - * @returns {boolean} true if the previous level was successfully loaded + * @overload + * @param {LevelLoadOptions & { async: true }} options + * @returns {Promise} + */ + /** + * @overload + * @param {LevelLoadOptions & { async?: false }} [options] + * @returns {boolean} */ - previous(options) { - // go to previous level - if (currentLevelIdx - 1 >= 0) { - return this.load(levelIdx[currentLevelIdx - 1], options); - } else { - return false; - } - }, - /** - * Load the previous level, and resolve once it is in the world. + * load the previous level. Pass `async: true` for a promise — see {@link level.load}. * - * The awaitable twin of {@link level.previous} — resolves `false` without - * loading anything when there is no previous level. See {@link level.nextAsync}. + * With no previous level this reports `false` without loading anything; see + * {@link level.next}. * @public - * @param {object} [options] - additional optional parameters, as accepted by {@link level.load} - * @returns {Promise} resolves `true` once the previous level is in the world, or `false` if there is none + * @param {LevelLoadOptions} [options] - additional optional parameters + * @returns {boolean|Promise} `true` if the previous level was loaded, `false` if there is none * @category Level */ - previousAsync(options) { - if (currentLevelIdx - 1 >= 0) { - return this.loadAsync(levelIdx[currentLevelIdx - 1], options); + previous(options) { + const levelId = levelIdAt(-1); + if (levelId !== null) { + return this.load(levelId, options); } - return Promise.resolve(false); + return options?.async === true ? Promise.resolve(false) : false; }, /** diff --git a/packages/melonjs/src/renderable/trigger.js b/packages/melonjs/src/renderable/trigger.js index 92f88e8d6..e1449eef0 100644 --- a/packages/melonjs/src/renderable/trigger.js +++ b/packages/melonjs/src/renderable/trigger.js @@ -175,7 +175,7 @@ export default class Trigger extends Renderable { // which meant rewriting an option the caller passed in. const onComplete = () => { level - .loadAsync(gotolevel, settings) + .load(gotolevel, { ...settings, async: true }) .then(() => { // re-read AFTER the load: `game.reset()` reassigns // `app.viewport`, so a viewport captured before it diff --git a/packages/melonjs/tests/level_load_async.spec.js b/packages/melonjs/tests/level_load_async.spec.js index 9f91b036b..c328ed6ee 100644 --- a/packages/melonjs/tests/level_load_async.spec.js +++ b/packages/melonjs/tests/level_load_async.spec.js @@ -11,19 +11,19 @@ import GLTFScene from "../src/level/gltf/GLTFScene.js"; import state from "../src/state/state.ts"; /** - * `level.loadAsync()` and the scheduling behind `level.load()` (#1646). + * `level.load({ async: true })` and the scheduling behind it (#1646). * * The deferral in `level.load()` dates to 2011 and used a timer because that * was the only way to defer at the time. It is still needed — `level.load()` is * routinely called from inside the loop, and `safeLoadLevel` resets and * destroys the very container the loop may be iterating — but it is now a - * microtask, and the completion it produces is what `loadAsync()` hands back. + * microtask, and `async: true` hands that completion back instead of a boolean. * * The level content is irrelevant here: `GLTFScene.addTo` is stubbed so these * tests pin the SCHEDULING, which is what changed. `getGLTF` returns null for * an unregistered asset, so a scene registers without one. */ -describe("level.loadAsync (#1646)", () => { +describe("level.load({ async }) (#1646)", () => { let app; let calls; let originalAddTo; @@ -103,12 +103,15 @@ describe("level.loadAsync (#1646)", () => { }); }); - describe("loadAsync", () => { + describe("the async form", () => { it("resolves only once the level is in the world", async () => { const seen = track(); state.restart(); const target = container(); - const promise = level.loadAsync("unit-test-level", { container: target }); + const promise = level.load("unit-test-level", { + container: target, + async: true, + }); expect(promise).toBeInstanceOf(Promise); // resolves with what `load()` returns, so a port is mechanical await expect(promise).resolves.toBe(true); @@ -120,8 +123,9 @@ describe("level.loadAsync (#1646)", () => { track(); state.restart(); let calledWith = null; - await level.loadAsync("unit-test-level", { + await level.load("unit-test-level", { container: container(), + async: true, onLoaded: (id) => { calledWith = id; }, @@ -133,7 +137,7 @@ describe("level.loadAsync (#1646)", () => { // The failure surface must not depend on `state.isRunning()`. The // deferred branch naturally produces a rejection; the synchronous // one would let the exception escape the call, where a - // `loadAsync(...).catch()` could never see it — the throw beats the + // `load(...).catch()` could never see it — the throw beats the // handler being attached. const boom = new Error("addTo exploded"); GLTFScene.prototype.addTo = () => { @@ -143,13 +147,13 @@ describe("level.loadAsync (#1646)", () => { state.stop(); expect(state.isRunning()).toBe(false); await expect( - level.loadAsync("unit-test-level", { container: container() }), + level.load("unit-test-level", { container: container(), async: true }), ).rejects.toBe(boom); state.restart(); expect(state.isRunning()).toBe(true); await expect( - level.loadAsync("unit-test-level", { container: container() }), + level.load("unit-test-level", { container: container(), async: true }), ).rejects.toBe(boom); }); @@ -157,73 +161,88 @@ describe("level.loadAsync (#1646)", () => { // if this rejected instead, a caller that forgot `await` would get an // unhandled rejection in place of a stack pointing at their typo expect(() => { - return level.loadAsync("no-such-level"); + return level.load("no-such-level", { async: true }); }).toThrow(/not found/); }); }); - describe("the reload / next / previous twins", () => { - it("reloadAsync resolves once the current level is back in the world", async () => { + describe("reload / next / previous take the same flag", () => { + it("reload({ async }) resolves once the current level is back in the world", async () => { const seen = track(); state.stop(); - await level.loadAsync("unit-test-level", { container: container() }); + await level.load("unit-test-level", { + container: container(), + async: true, + }); seen.length = 0; state.restart(); - await expect(level.reloadAsync({ container: container() })).resolves.toBe( - true, - ); + await expect( + level.reload({ container: container(), async: true }), + ).resolves.toBe(true); expect(seen).toHaveLength(1); }); - it("nextAsync loads the next level and resolves true", async () => { + it("next({ async }) loads the next level and resolves true", async () => { const seen = track(); state.stop(); - await level.loadAsync("unit-test-level", { container: container() }); + await level.load("unit-test-level", { + container: container(), + async: true, + }); seen.length = 0; state.restart(); - await expect(level.nextAsync({ container: container() })).resolves.toBe( - true, - ); + await expect( + level.next({ container: container(), async: true }), + ).resolves.toBe(true); expect(seen).toHaveLength(1); expect(level.getCurrentLevelId()).toBe("unit-test-level-2"); }); - it("nextAsync resolves FALSE without loading when there is no next", async () => { + it("next({ async }) resolves FALSE without loading when there is no next", async () => { // `next()` returns false here rather than throwing, so the twin must // resolve false rather than reject — running out of levels is an // ordinary outcome, not an error const seen = track(); state.stop(); - await level.loadAsync("unit-test-level-2", { container: container() }); + await level.load("unit-test-level-2", { + container: container(), + async: true, + }); seen.length = 0; state.restart(); - await expect(level.nextAsync({ container: container() })).resolves.toBe( - false, - ); + await expect( + level.next({ container: container(), async: true }), + ).resolves.toBe(false); expect(seen).toHaveLength(0); }); - it("previousAsync loads the previous level and resolves true", async () => { + it("previous({ async }) loads the previous level and resolves true", async () => { const seen = track(); state.stop(); - await level.loadAsync("unit-test-level-2", { container: container() }); + await level.load("unit-test-level-2", { + container: container(), + async: true, + }); seen.length = 0; state.restart(); await expect( - level.previousAsync({ container: container() }), + level.previous({ container: container(), async: true }), ).resolves.toBe(true); expect(seen).toHaveLength(1); expect(level.getCurrentLevelId()).toBe("unit-test-level"); }); - it("previousAsync resolves FALSE without loading when there is no previous", async () => { + it("previous({ async }) resolves FALSE without loading when there is no previous", async () => { const seen = track(); state.stop(); - await level.loadAsync("unit-test-level", { container: container() }); + await level.load("unit-test-level", { + container: container(), + async: true, + }); seen.length = 0; state.restart(); await expect( - level.previousAsync({ container: container() }), + level.previous({ container: container(), async: true }), ).resolves.toBe(false); expect(seen).toHaveLength(0); }); @@ -242,6 +261,38 @@ describe("level.loadAsync (#1646)", () => { }); }); + describe("the flag is what decides the return", () => { + it("returns a boolean without it, and a promise with it", () => { + track(); + state.stop(); + expect(level.load("unit-test-level", { container: container() })).toBe( + true, + ); + const promise = level.load("unit-test-level", { + container: container(), + async: true, + }); + expect(promise).toBeInstanceOf(Promise); + return promise; + }); + + it("awaiting WITHOUT the flag still yields to the deferred load", async () => { + // `await true` is valid JavaScript, so forgetting the flag is silent. + // It happens to be harmless TODAY: the deferral is a single + // microtask queued before the await's continuation, so the load runs + // first either way. That is incidental ordering, not a contract — + // hence the flag exists — so this pins the observable part (no + // promise is returned) and merely records the rest. + const seen = track(); + state.restart(); + const value = level.load("unit-test-level", { container: container() }); + expect(value).toBe(true); + expect(value).not.toBeInstanceOf(Promise); + await value; + expect(seen).toHaveLength(1); + }); + }); + describe("the deferral it schedules", () => { it("does NOT mutate the world synchronously while the loop runs", () => { // the whole reason the deferral exists: `level.load` is called from @@ -250,14 +301,14 @@ describe("level.loadAsync (#1646)", () => { const seen = track(); state.restart(); expect(state.isRunning()).toBe(true); - level.loadAsync("unit-test-level", { container: container() }); + level.load("unit-test-level", { container: container(), async: true }); expect(seen).toHaveLength(0); }); it("stops the loop when it was running", () => { track(); state.restart(); - level.loadAsync("unit-test-level", { container: container() }); + level.load("unit-test-level", { container: container(), async: true }); expect(state.isRunning()).toBe(false); }); @@ -267,7 +318,7 @@ describe("level.loadAsync (#1646)", () => { // loading one before the game starts const seen = track(); state.stop(); - level.loadAsync("unit-test-level", { container: container() }); + level.load("unit-test-level", { container: container(), async: true }); expect(seen).toHaveLength(1); }); @@ -281,8 +332,9 @@ describe("level.loadAsync (#1646)", () => { order.push("load"); }; state.restart(); - const promise = level.loadAsync("unit-test-level", { + const promise = level.load("unit-test-level", { container: container(), + async: true, }); const timer = new Promise((resolve) => { setTimeout(() => { diff --git a/packages/melonjs/tests/trigger_level_change.spec.js b/packages/melonjs/tests/trigger_level_change.spec.js index 612922c79..689daf130 100644 --- a/packages/melonjs/tests/trigger_level_change.spec.js +++ b/packages/melonjs/tests/trigger_level_change.spec.js @@ -97,7 +97,9 @@ describe("Trigger level change (#1646)", () => { // tween completes, which needs a live game loop this suite does not // have. Weaker than a behavioural test, and deliberately narrow: it // pins the one line whose removal reintroduces a known bug. - const load = triggerSource.indexOf("loadAsync(gotolevel, settings)"); + const load = triggerSource.indexOf( + "load(gotolevel, { ...settings, async: true })", + ); const reveal = triggerSource.indexOf("addCameraEffect", load); expect(load).toBeGreaterThan(-1); expect(reveal).toBeGreaterThan(load); From cfe4c09198dfa4bb90091e79ad44d25dadca4bba Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 5 Sep 2026 17:29:22 +0800 Subject: [PATCH 06/17] Tests: drop a stale reference to the removed sibling API Left over from the rename to the `async` option. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/tests/trigger_level_change.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/melonjs/tests/trigger_level_change.spec.js b/packages/melonjs/tests/trigger_level_change.spec.js index 689daf130..9a767a7f4 100644 --- a/packages/melonjs/tests/trigger_level_change.spec.js +++ b/packages/melonjs/tests/trigger_level_change.spec.js @@ -5,7 +5,7 @@ import triggerSource from "../src/renderable/trigger.js?raw"; import state from "../src/state/state.ts"; /** - * `Trigger` level changes, across the `loadAsync` refactor (#1646). + * `Trigger` level changes, across the awaitable-load refactor (#1646). * * The fade/mask path used to sequence "hide → load → reveal" by REWRITING the * caller's own `settings.onLoaded`: it saved the user's callback, replaced the From d2c26e4efe9a850e5a77bbcd95cb5eedc6875a79 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 5 Sep 2026 17:31:29 +0800 Subject: [PATCH 07/17] Skills: document the `async` level-loading option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `melonjs-tilemaps` described the deferral as a `setTimeout` and told the reader the only way to sequence work after a load was `onLoaded` or `LEVEL_LOADED`. Both are now out of date: the deferral is a microtask, and `async: true` gives a promise to await. Also states the caveat that comes with putting the switch in the options — `await level.load(id)` without the flag returns a boolean, so it does not await the load. It happens to finish first today, because the deferral is a single microtask queued ahead of the await's continuation, but that is incidental ordering rather than a contract, and the skills say so rather than implying either that it is safe or that it is broken. `melonjs-3d-assets` gains the `async` row in its `level.load` options table and the same note; glTF/GLB scenes load through the same call. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- .../melonjs/skills/melonjs-3d-assets/SKILL.md | 20 ++++++--- .../melonjs/skills/melonjs-tilemaps/SKILL.md | 43 ++++++++++++++----- 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/packages/melonjs/skills/melonjs-3d-assets/SKILL.md b/packages/melonjs/skills/melonjs-3d-assets/SKILL.md index f4b4c8880..42cfa9ad7 100644 --- a/packages/melonjs/skills/melonjs-3d-assets/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d-assets/SKILL.md @@ -1,6 +1,6 @@ --- name: melonjs-3d-assets -description: "Use this skill when loading 3D models into melonJS — glTF and GLB scenes, OBJ/MTL models, materials, imported lights, node animation, ground shadows and GPU instancing. Covers level.load options, the rightHanded conversion, and exactly what the loader does and does not support. Triggers on: glTF, gltf, glb, OBJ, MTL, 3D model, getGLTF, getOBJ, getMTL, GLTFModel, GLTFScene, level.load glb, rightHanded, lightIntensityScale, castGroundShadow, shadowGroundY, EXT_mesh_gpu_instancing, KHR_lights_punctual, skinning, Blender export, 3D asset." +description: "Use this skill when loading 3D models into melonJS — glTF and GLB scenes, OBJ/MTL models, materials, imported lights, node animation, ground shadows and GPU instancing. Covers level.load options including the async flag, the rightHanded conversion, and exactly what the loader does and does not support. Triggers on: glTF, gltf, glb, OBJ, MTL, 3D model, getGLTF, getOBJ, getMTL, GLTFModel, GLTFScene, level.load glb, rightHanded, lightIntensityScale, castGroundShadow, shadowGroundY, EXT_mesh_gpu_instancing, KHR_lights_punctual, skinning, Blender export, 3D asset." license: MIT --- @@ -34,6 +34,7 @@ or it renders flat. See `melonjs-3d` for the camera. | `scale` | `1` | pixels per glTF unit, applied to the whole scene. Blender's metre-scale export usually needs 20–100. | | `container` | `game.world` | where the nodes are added | | `onLoaded` | `app.onLevelLoaded` | called with the **level id**, not the scene | +| `async` | `false` | return a promise that settles once the scene is in the world, instead of a boolean | | `rightHanded` | `true` | see below | | `lights` | `true` | instantiate authored `KHR_lights_punctual` lights as `Light3d` world children | | `lightIntensityScale` | — | keep authored intensity ratios instead of normalising every light to 1 | @@ -41,10 +42,19 @@ or it renders flat. See `melonjs-3d` for the camera. | `shadowGroundY` | each object's own base | world Y of the floor the blobs land on | `onLoaded` receives the level id — it is a "done" signal, not a handle on the -scene. You need it: with the game loop running, `level.load` stops the loop and -defers the actual load to the next tick, so it returns *before* anything is in -the world. To get at what was loaded, load into a container you own, or look the -nodes up by their authored names: +scene. You need it, or `async`: with the game loop running, `level.load` stops +the loop and defers the actual load to a microtask, so by default it returns +*before* anything is in the world. + +```js +await level.load("diorama", { scale: 50, async: true }); +// the scene is in the world here +``` + +Note `await level.load("diorama")` without the flag does not await the load — the +call returns a boolean, and `await true` resolves immediately. To get at what was +loaded, +load into a container you own, or look the nodes up by their authored names: ```js level.load("diorama", { scale: 50, onLoaded: () => { diff --git a/packages/melonjs/skills/melonjs-tilemaps/SKILL.md b/packages/melonjs/skills/melonjs-tilemaps/SKILL.md index 29f1923fa..99d9ea069 100644 --- a/packages/melonjs/skills/melonjs-tilemaps/SKILL.md +++ b/packages/melonjs/skills/melonjs-tilemaps/SKILL.md @@ -1,6 +1,6 @@ --- name: melonjs-tilemaps -description: "Use this skill for Tiled maps in melonJS — loading TMX/TSX levels, spawning entities from Tiled objects, collision shapes authored in Tiled, isometric and hexagonal maps, and image layers. Covers the pool.register name contract, camera bounds, compressed maps needing the inflate plugin, and the level director API. Triggers on: Tiled, TMX, TSX, tilemap, level.load, tileset, ImageLayer, isometric, hexagonal, staggered, pool.register, Collectable, Trigger, object layer, collision layer, parallax." +description: "Use this skill for Tiled maps in melonJS — loading TMX/TSX levels, spawning entities from Tiled objects, collision shapes authored in Tiled, isometric and hexagonal maps, and image layers. Covers the pool.register name contract, camera bounds, compressed maps needing the inflate plugin, and the level director API. Triggers on: Tiled, TMX, TSX, tilemap, level.load, level.load async, await level.load, tileset, ImageLayer, isometric, hexagonal, staggered, pool.register, Collectable, Trigger, object layer, collision layer, parallax." license: MIT --- @@ -33,18 +33,38 @@ also skip `src` and pass the map inline via `data` (with `format: "json"` or `"xml"`). `level.load(levelId, options)` accepts `container` (default `game.world`), -`onLoaded` (default `game.onLevelLoaded`), `flatten` (default `game.mergeGroup`) -and `setViewportBounds` (default **`true`**). It throws `level not found` -for an unknown id. +`onLoaded` (default `game.onLevelLoaded`), `flatten` (default `game.mergeGroup`), +`setViewportBounds` (default **`true`**) and `async` (default `false`). It throws +`level not found` for an unknown id — synchronously, in both forms, because +that is a typo rather than a load failure. **`level.load` is deferred while the game loop is running.** It calls -`state.stop()` and finishes the load in a `setTimeout`, so it returns `true` -before anything is in the world. Do follow-up work from the `onLoaded` callback -or an `event.LEVEL_LOADED` listener, not on the next line. +`state.stop()` and finishes the load in a microtask, so by default it returns +`true` before anything is in the world. Two ways to sequence work after it: -`level.reload()`, `level.next()`, `level.previous()`, `level.getCurrentLevelId()` -and `level.levelCount()` round out the namespace. `flatten: false` wraps each -Tiled object group in its own `Container` named after the group. +```js +// await it +await level.load("map1", { async: true }); +// the world is populated here + +// ...or use the callback / event, which fire in both forms +level.load("map1", { onLoaded: () => this.spawnPlayer() }); +``` + +`async: true` is the only thing that changes the return value — everything else +behaves identically, `onLoaded` included. Without it the call returns a boolean, +so `await level.load("map1")` is not an error and does not await the load: +`await true` resolves immediately. (The load does finish first today, because the +deferral is a single microtask queued ahead of the await's continuation — but +that is incidental ordering, not a contract.) Pass the flag when you mean to +await. + +`level.reload()`, `level.next()` and `level.previous()` take the same `async` +option and resolve the same value they return — so `if (level.next())` becomes +`if (await level.next({ async: true }))`. Running out of levels reports `false` +either way rather than throwing. `level.getCurrentLevelId()` and +`level.levelCount()` round out the namespace. `flatten: false` wraps each Tiled +object group in its own `Container` named after the group. ## Spawning entities from Tiled objects @@ -177,7 +197,8 @@ unanimated layer into the offscreen-bake path instead. | symptom | cause | |---|---| | a Tiled object becomes a plain shape with no behaviour | its class/name does not match any registered factory, or it was registered after `level.load` | -| the world is still empty right after `level.load` | the load is deferred via `setTimeout` while the loop runs — use `onLoaded` / `LEVEL_LOADED` | +| the world is still empty right after `level.load` | the load is deferred to a microtask while the loop runs — `await level.load(id, { async: true })`, or use `onLoaded` / `LEVEL_LOADED` | +| `await level.load(id)` returned `true` rather than a promise | without `async: true` the call returns a boolean; `await true` resolves immediately. The load happens to finish first today by microtask ordering, but that is incidental — pass the flag when you mean to await | | `level not found` | the map was never preloaded, or the asset `name` differs from the id passed to `load` | | `unknown or invalid resource type` | asset `type` set to `"tmj"` / `"tsj"` — use `"tmx"` / `"tsx"` with the `.tmj` / `.tsj` file | | camera will not scroll | `setViewportBounds: false`, or the map was added with `addTo()` (which defaults to `false`) | From 35eb6e1db569d9e69c68297752b167e4918eac2f Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 5 Sep 2026 17:37:59 +0800 Subject: [PATCH 08/17] Level: make the `async` option visible in the generated reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems, both in what the docs render rather than what the code does. `LevelLoadOptions` was declared but never re-exported from the public entry point, so the reference showed `load`/`reload`/`next`/`previous` taking an opaque type name with no properties — the `async` option, and every other option, was undocumented. Exporting the type gives it a page, lists its properties, and puts it in `llms.txt`. TypeDoc documents each `@overload` block and ignores the implementation's comment, so the description and examples written there rendered nowhere: the page was signatures and nothing else. Each overload now carries its own prose, which reads better than one shared blurb — the awaited form and the boolean form describe what they each do. `@public` had to come off the overload blocks. Inside one it makes tsc emit `function load(): any`, erasing the parameters and the return type from the declarations. Bisected against `@category` and `@example`, which are both harmless. The methods stay documented without it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/src/index.ts | 1 + packages/melonjs/src/level/level.js | 148 ++++++++++++++++++---------- 2 files changed, 96 insertions(+), 53 deletions(-) diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index 2f52d7a28..f753c8877 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -128,6 +128,7 @@ export { Sphere } from "./geometries/sphere.ts"; export * as input from "./input/input.ts"; // Backward compatibility for deprecated method or properties export * from "./lang/deprecated.js"; +export type { LevelLoadOptions } from "./level/level.js"; export { level } from "./level/level.js"; export { registerTiledObjectClass, diff --git a/packages/melonjs/src/level/level.js b/packages/melonjs/src/level/level.js index f127ea9ad..819f2f48e 100644 --- a/packages/melonjs/src/level/level.js +++ b/packages/melonjs/src/level/level.js @@ -172,39 +172,42 @@ export const level = { }, /** + * load a level into the game manager, and return a promise that settles once + * it is actually in the world
+ * (will also create all level defined entities, etc..) + * + * `options.onLoaded` still fires, so the two forms mix freely. An unknown + * `levelId` throws SYNCHRONOUSLY rather than rejecting — that is a typo, not + * a load failure, and it should not need `await` to surface. * @overload - * @param {string} levelId - * @param {LevelLoadOptions & { async: true }} options - * @returns {Promise} - */ - /** - * @overload - * @param {string} levelId - * @param {LevelLoadOptions & { async?: false }} [options] - * @returns {boolean} + * @param {string} levelId - level id + * @param {LevelLoadOptions & { async: true }} options - load options, with `async` set + * @returns {Promise} resolves `true` once the level is in the world + * @example + * await me.loader.preload(game.assets); + * await me.level.load("a4_level1", { async: true }); + * // the world is populated here + * @category Level */ /** * load a level into the game manager
* (will also create all level defined entities, etc..) * - * Pass `async: true` to get a promise that settles once the level is in the - * world, instead of the boolean. `options.onLoaded` still fires either way, - * so the two forms mix freely. + * While the game loop is running the load is DEFERRED to a microtask, so + * this returns before anything is in the world. Sequence follow-up work from + * `options.onLoaded`, from an `event.LEVEL_LOADED` listener, or by passing + * `async: true` and awaiting the promise that overload returns. * - * An unknown `levelId` throws SYNCHRONOUSLY in both forms — that is a typo - * rather than a load failure, and it should not need `await` to surface. - * @public + * Note that `await me.level.load(id)` without `async: true` does not await + * the load: the call returns a boolean, and `await true` resolves at once. + * @overload * @param {string} levelId - level id - * @param {LevelLoadOptions} [options] - additional optional parameters - * @returns {boolean|Promise} `true`, or a promise resolving `true` when `async` is set + * @param {LevelLoadOptions & { async?: false }} [options] - additional optional parameters + * @returns {boolean} `true` * @example - * // load a level, the way it has always worked + * // load a level * me.level.load("a4_level1"); * - * // ...or wait for it - * await me.loader.preload(game.assets); - * await me.level.load("a4_level1", { async: true }); - * * // load into a specific container * me.level.load("a4_level2", { container: levelContainer }); * @@ -213,6 +216,12 @@ export const level = { * me.level.load("diorama", { scale: 50, lightIntensityScale: 0.001 }); * @category Level */ + /** + * @param {string} levelId - level id + * @param {LevelLoadOptions} [options] - additional optional parameters + * @returns {boolean|Promise} `true`, or a promise when `async` is set + * @ignore + */ load(levelId, options) { options = Object.assign( { @@ -304,21 +313,29 @@ export const level = { }, /** + * reload the current level, and return a promise that settles once the level is in the world. + * * @overload - * @param {LevelLoadOptions & { async: true }} options - * @returns {Promise} + * @param {LevelLoadOptions & { async: true }} options - load options, with `async` set + * @returns {Promise} resolves `true` once the level is back in the world + * @example + * await me.level.reload({ async: true }); + * @category Level */ /** + * reload the current level. + * + * While the game loop is running the load is deferred to a microtask, so this + * returns before anything is in the world — see {@link level.load}. * @overload - * @param {LevelLoadOptions & { async?: false }} [options] - * @returns {boolean} + * @param {LevelLoadOptions & { async?: false }} [options] - additional optional parameters + * @returns {boolean} `true` + * @category Level */ /** - * reload the current level. Pass `async: true` for a promise — see {@link level.load}. - * @public * @param {LevelLoadOptions} [options] - additional optional parameters - * @returns {boolean|Promise} `true`, or a promise resolving `true` when `async` is set - * @category Level + * @returns {boolean|Promise} see the overloads + * @ignore */ reload(options) { // reset the level to initial state @@ -327,25 +344,37 @@ export const level = { }, /** + * load the next level, and return a promise that settles once the level is in the world. + * + * With no level to go to this reports `false` WITHOUT loading anything, and + * the promise form resolves `false` rather than rejecting: running out of + * levels is an ordinary outcome, not an error. + * * @overload - * @param {LevelLoadOptions & { async: true }} options - * @returns {Promise} + * @param {LevelLoadOptions & { async: true }} options - load options, with `async` set + * @returns {Promise} resolves `true`, or `false` if there is no next level + * @example + * await me.level.next({ async: true }); + * @category Level */ /** + * load the next level. + * + * With no level to go to this reports `false` WITHOUT loading anything, and + * the promise form resolves `false` rather than rejecting: running out of + * levels is an ordinary outcome, not an error. + * + * While the game loop is running the load is deferred to a microtask, so this + * returns before anything is in the world — see {@link level.load}. * @overload - * @param {LevelLoadOptions & { async?: false }} [options] - * @returns {boolean} + * @param {LevelLoadOptions & { async?: false }} [options] - additional optional parameters + * @returns {boolean} `true` if the next level was loaded, `false` if there is none + * @category Level */ /** - * load the next level. Pass `async: true` for a promise — see {@link level.load}. - * - * With no next level this reports `false` WITHOUT loading anything, and the - * promise form resolves `false` rather than rejecting: reaching the end of a - * game is an ordinary outcome, not an error. - * @public * @param {LevelLoadOptions} [options] - additional optional parameters - * @returns {boolean|Promise} `true` if the next level was loaded, `false` if there is none - * @category Level + * @returns {boolean|Promise} see the overloads + * @ignore */ next(options) { const levelId = levelIdAt(1); @@ -356,24 +385,37 @@ export const level = { }, /** + * load the previous level, and return a promise that settles once the level is in the world. + * + * With no level to go to this reports `false` WITHOUT loading anything, and + * the promise form resolves `false` rather than rejecting: running out of + * levels is an ordinary outcome, not an error. + * * @overload - * @param {LevelLoadOptions & { async: true }} options - * @returns {Promise} + * @param {LevelLoadOptions & { async: true }} options - load options, with `async` set + * @returns {Promise} resolves `true`, or `false` if there is no previous level + * @example + * await me.level.previous({ async: true }); + * @category Level */ /** + * load the previous level. + * + * With no level to go to this reports `false` WITHOUT loading anything, and + * the promise form resolves `false` rather than rejecting: running out of + * levels is an ordinary outcome, not an error. + * + * While the game loop is running the load is deferred to a microtask, so this + * returns before anything is in the world — see {@link level.load}. * @overload - * @param {LevelLoadOptions & { async?: false }} [options] - * @returns {boolean} + * @param {LevelLoadOptions & { async?: false }} [options] - additional optional parameters + * @returns {boolean} `true` if the previous level was loaded, `false` if there is none + * @category Level */ /** - * load the previous level. Pass `async: true` for a promise — see {@link level.load}. - * - * With no previous level this reports `false` without loading anything; see - * {@link level.next}. - * @public * @param {LevelLoadOptions} [options] - additional optional parameters - * @returns {boolean|Promise} `true` if the previous level was loaded, `false` if there is none - * @category Level + * @returns {boolean|Promise} see the overloads + * @ignore */ previous(options) { const levelId = levelIdAt(-1); From 09bf51603051c57012a5b7715d2f40411afa6e8b Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 5 Sep 2026 18:41:23 +0800 Subject: [PATCH 09/17] Level: correct the options doc, and de-brittle the trigger source guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `LevelLoadOptions` note claimed that awaiting without `async: true` leaves the level unloaded. It does not: with no loop the load is already synchronous, and with one running the deferral is a single microtask queued ahead of the await's continuation, so it finishes first either way. The point stands — there is no completion point to await without the flag — but the ordering is incidental and the doc now says so instead of promising the opposite. The same wording was already fixed in the skills; this is the copy that was missed. The trigger guard searched for the full call text including argument spacing, so reformatting or an added option would have failed it for no reason. Anchored on `load(gotolevel` instead — still fails when the viewport is captured before the load, which is the only thing it is for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/src/level/level.js | 10 +++++++--- packages/melonjs/tests/trigger_level_change.spec.js | 6 +++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/melonjs/src/level/level.js b/packages/melonjs/src/level/level.js index 819f2f48e..9e8bd1703 100644 --- a/packages/melonjs/src/level/level.js +++ b/packages/melonjs/src/level/level.js @@ -106,9 +106,13 @@ function levelIdAt(offset) { * promise that settles once the level is actually in the world. Everything else * behaves identically either way, `onLoaded` included. * - * Note that awaiting a call WITHOUT `async: true` is not an error — `await true` - * is valid and resolves immediately — so the level will not be loaded yet. Pass - * the flag whenever you intend to await. + * Awaiting a call WITHOUT `async: true` is not an error, but it is not a wait + * either: the call hands back a boolean, and `await true` resolves immediately, + * so there is no completion point to await. Whether the load has finished by + * then is incidental — it has when there is no loop running, and it currently + * does when there is, because the deferral is a single microtask queued ahead + * of the await's continuation. Do not rely on either. Pass the flag when you + * mean to await. * @typedef {object} LevelLoadOptions * @property {Container} [container=game.world] - container in which to load the specified level * @property {Function} [onLoaded=game.onLevelLoaded] - callback for when the level is fully loaded, called in both forms diff --git a/packages/melonjs/tests/trigger_level_change.spec.js b/packages/melonjs/tests/trigger_level_change.spec.js index 9a767a7f4..5b2edf247 100644 --- a/packages/melonjs/tests/trigger_level_change.spec.js +++ b/packages/melonjs/tests/trigger_level_change.spec.js @@ -97,9 +97,9 @@ describe("Trigger level change (#1646)", () => { // tween completes, which needs a live game loop this suite does not // have. Weaker than a behavioural test, and deliberately narrow: it // pins the one line whose removal reintroduces a known bug. - const load = triggerSource.indexOf( - "load(gotolevel, { ...settings, async: true })", - ); + // anchored on the call shape rather than the exact argument text, so + // reformatting or an added option does not fail this + const load = triggerSource.indexOf("load(gotolevel"); const reveal = triggerSource.indexOf("addCameraEffect", load); expect(load).toBeGreaterThan(-1); expect(reveal).toBeGreaterThan(load); From f3e7c17ef5ff1ef748d402109894f00ab191edda Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 5 Sep 2026 19:12:45 +0800 Subject: [PATCH 10/17] Tests: close the four coverage gaps on the level-loading change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **TMX.** Every test stubbed a glTF scene, so `safeLoadLevel`'s format branch was only ever exercised on the non-TMX arm — and Tiled maps are the main use of `level.load`. A real map now loads in both forms, passed inline through the loader's `data` field so it needs no fixture file. The map carries an object group, because `flatten: false` wrapping it in a named `Container` is behaviour only `loadTMXLevel` produces: routing a map through the generic `addTo` arm passes the whole options object as its positional `flatten` argument and flattens everything, which that assertion now catches. **The trigger reveal.** Previously source-guarded, because its tween needed a live loop. Driving the tween by hand with `_onTick` removes that, so the sequencing is asserted for real: the load happens, then the reveal, and on the viewport that exists AFTER the load — `game.reset()` reassigns it. The test runs with the loop RUNNING; with it stopped the load is synchronous and the ordering proves nothing. **LEVEL_LOADED and onLoaded ordering.** Both must land before the promise resolves, which is what a caller awaiting the load then reading world state depends on. **A throwing callback.** The async form rejects, and the boolean form with no loop still throws synchronously. Three mutations that survived the first pass were equivalent mutants rather than gaps — a microtask queued after the load's own microtask still runs after it, so "emit late" and "reveal without waiting" needed genuinely-late variants (`setTimeout`, and a synchronous reveal) to express the bug. Both fail now, as does disabling the TMX arm. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- .../melonjs/tests/level_load_async.spec.js | 187 ++++++++++++++++++ .../tests/trigger_level_change.spec.js | 72 ++++++- 2 files changed, 258 insertions(+), 1 deletion(-) diff --git a/packages/melonjs/tests/level_load_async.spec.js b/packages/melonjs/tests/level_load_async.spec.js index c328ed6ee..bfe6b8095 100644 --- a/packages/melonjs/tests/level_load_async.spec.js +++ b/packages/melonjs/tests/level_load_async.spec.js @@ -5,6 +5,7 @@ import { Container, event, level, + loader, video, } from "../src/index.js"; import GLTFScene from "../src/level/gltf/GLTFScene.js"; @@ -293,6 +294,192 @@ describe("level.load({ async }) (#1646)", () => { }); }); + describe("a real TMX map, not just a stubbed scene", () => { + // Every other test here stubs `GLTFScene.addTo`, which exercises the + // non-TMX arm of `safeLoadLevel`'s format branch. Tiled maps are the + // main use of `level.load` and go down the other arm — `loadTMXLevel`, + // with GUID reset, object flattening and viewport bounds — so the flag + // has to work there too. The map is passed inline via the loader's + // `data` field, so this needs no fixture file. + const MAP = { + type: "map", + version: "1.10", + orientation: "orthogonal", + renderorder: "right-down", + infinite: false, + width: 4, + height: 4, + tilewidth: 16, + tileheight: 16, + nextlayerid: 2, + nextobjectid: 1, + layers: [ + { + id: 1, + name: "ground", + type: "tilelayer", + visible: true, + opacity: 1, + x: 0, + y: 0, + width: 4, + height: 4, + data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + { + id: 2, + name: "entities", + type: "objectgroup", + visible: true, + opacity: 1, + x: 0, + y: 0, + objects: [ + { + id: 1, + name: "spawn", + type: "", + x: 8, + y: 8, + width: 8, + height: 8, + rotation: 0, + visible: true, + }, + ], + }, + ], + tilesets: [], + }; + + beforeAll(async () => { + // `switchToLoadState` false: this spec is not driving the state + // machine, and the LOADING state would fight the tests below + await loader.preload( + [{ name: "unit-test-map", type: "tmx", data: MAP }], + undefined, + false, + ); + }); + + it("loads a TMX map in the boolean form", () => { + state.stop(); + const target = container(); + expect( + level.load("unit-test-map", { + container: target, + setViewportBounds: false, + }), + ).toBe(true); + expect(target.children.length).toBeGreaterThan(0); + }); + + it("loads a TMX map in the async form, resolving once it is in the world", async () => { + state.restart(); + const target = container(); + const promise = level.load("unit-test-map", { + container: target, + setViewportBounds: false, + async: true, + }); + // deferred: nothing yet (`children` is undefined until the first add) + expect(target.children ?? []).toHaveLength(0); + await expect(promise).resolves.toBe(true); + expect(target.children.length).toBeGreaterThan(0); + }); + + it("still honours flatten on the TMX arm in the async form", async () => { + // `flatten: false` wraps each Tiled group in its own Container named + // after it — behaviour only `loadTMXLevel` produces, so this also + // pins that a TMX map goes down the TMX arm rather than the generic + // `addTo` one, which would silently load it with the wrong arguments + state.restart(); + const target = container(); + await level.load("unit-test-map", { + container: target, + setViewportBounds: false, + flatten: false, + async: true, + }); + expect(target.children.length).toBeGreaterThan(0); + // only `loadTMXLevel` wraps an object group in a Container named + // after it. The generic `addTo` arm takes (container, flatten, + // setViewportBounds) positionally, so routing a map through it + // passes the whole options object as `flatten` and flattens + // everything — no wrapper, and this assertion catches it. + expect(target.getChildByName("entities")).toHaveLength(1); + }); + }); + + describe("ordering and failure surfaces", () => { + it("emits LEVEL_LOADED before the promise resolves", async () => { + // what a caller awaiting the load then reading world state depends + // on: the event must not arrive after the await has resumed + track(); + state.restart(); + const order = []; + const handler = () => { + order.push("event"); + }; + event.on(event.LEVEL_LOADED, handler); + await level.load("unit-test-level", { + container: container(), + async: true, + }); + order.push("resolved"); + event.off(event.LEVEL_LOADED, handler); + expect(order).toEqual(["event", "resolved"]); + }); + + it("calls onLoaded before the promise resolves", async () => { + track(); + state.restart(); + const order = []; + await level.load("unit-test-level", { + container: container(), + async: true, + onLoaded: () => { + order.push("onLoaded"); + }, + }); + order.push("resolved"); + expect(order).toEqual(["onLoaded", "resolved"]); + }); + + it("REJECTS when onLoaded throws, in the async form", async () => { + // the callback runs inside the load, so its failure belongs to the + // same surface as any other load failure + track(); + state.restart(); + const boom = new Error("onLoaded exploded"); + await expect( + level.load("unit-test-level", { + container: container(), + async: true, + onLoaded: () => { + throw boom; + }, + }), + ).rejects.toBe(boom); + }); + + it("THROWS when onLoaded throws with no loop running, in the boolean form", () => { + // the synchronous path stays synchronous, errors included, so a + // caller can still `try { level.load(id) } catch` + track(); + state.stop(); + const boom = new Error("onLoaded exploded"); + expect(() => { + return level.load("unit-test-level", { + container: container(), + onLoaded: () => { + throw boom; + }, + }); + }).toThrow(boom); + }); + }); + describe("the deferral it schedules", () => { it("does NOT mutate the world synchronously while the loop runs", () => { // the whole reason the deferral exists: `level.load` is called from diff --git a/packages/melonjs/tests/trigger_level_change.spec.js b/packages/melonjs/tests/trigger_level_change.spec.js index 5b2edf247..774c44a8f 100644 --- a/packages/melonjs/tests/trigger_level_change.spec.js +++ b/packages/melonjs/tests/trigger_level_change.spec.js @@ -1,5 +1,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { Application, boot, level, Trigger, video } from "../src/index.js"; +import { + Application, + boot, + Camera2d, + level, + Trigger, + video, +} from "../src/index.js"; import GLTFScene from "../src/level/gltf/GLTFScene.js"; import triggerSource from "../src/renderable/trigger.js?raw"; import state from "../src/state/state.ts"; @@ -87,6 +94,69 @@ describe("Trigger level change (#1646)", () => { app.world.removeChildNow(t); }); + it("reveals only after the load, on the CURRENT viewport", async () => { + // The reveal used to be injected by rewriting `settings.onLoaded`; it is + // now chained off the awaited load. Driving the hide tween by hand lets + // this run without a live loop, so the sequencing is asserted for real + // rather than by reading the source. + // + // `Application.reset()` reassigns `app.viewport`, and `safeLoadLevel` + // calls it — so a viewport captured before the load is stale by the time + // the reveal runs. The swap below stands in for that. + const original = app.viewport; + const swapped = new Camera2d(0, 0, 320, 240); + const seen = []; + const record = (who) => { + return (effect) => { + seen.push({ who, effect, loadedSoFar: loaded.length }); + return effect; + }; + }; + original.addCameraEffect = record("original"); + swapped.addCameraEffect = record("swapped"); + + // with the loop RUNNING, so the load genuinely defers — with it stopped + // the load is synchronous and the ordering below proves nothing + state.restart(); + const t = trigger({ color: "#000000", duration: 10 }); + t.triggerEvent(); + + // the hide effect, captured rather than added + expect(seen).toHaveLength(1); + expect(seen[0].loadedSoFar).toBe(0); + + // swap the viewport while the load runs, as `game.reset()` would + const previousAddTo = GLTFScene.prototype.addTo; + GLTFScene.prototype.addTo = function (container) { + app.viewport = swapped; + loaded.push(container); + }; + + // Drive the hide tween to completion -> onComplete -> the load. Stop + // ticking the moment the load starts: further ticks re-fire onComplete + // and would queue a second load. + const tween = seen[0].effect.tween; + for (let i = 1; i <= 20 && loaded.length === 0; i++) { + tween._onTick(i * 5); + await Promise.resolve(); + } + // let the load's microtask and the reveal chained after it settle + for (let i = 0; i < 4; i++) { + await Promise.resolve(); + } + + GLTFScene.prototype.addTo = previousAddTo; + app.viewport = original; + app.world.removeChildNow(t); + + // the load happened, then the reveal — and on the viewport that existed + // AFTER the load, not the one captured before it + expect(loaded).toHaveLength(1); + expect(seen).toHaveLength(2); + expect(seen[1].loadedSoFar).toBe(1); + expect(seen[1].who).toBe("swapped"); + }); + it("re-reads the viewport AFTER the load, not before it", () => { // `Application.reset()` reassigns `app.viewport`, and `safeLoadLevel` // calls `game.reset()` — so a viewport captured before the load is stale From 74cb78a4835af6efa36c41f7c5c63e010531505b Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 6 Sep 2026 08:19:28 +0800 Subject: [PATCH 11/17] Level: defer with the engine's own `defer`, not queueMicrotask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `queueMicrotask` is gone. It was also the wrong thing to reach for: it is not an ECMAScript feature at all — it comes from the HTML spec, declared in `lib.dom.d.ts`, not in any `lib.es2022`. The argument that the ES2022 target implied its presence was simply wrong, however true the conclusion happened to be for real runtimes. The deferral goes back to a timer, through `utils.function.defer`, which is what `state`, `Container` and `timer` already schedule with. That also restores the guarantee the 2011 timer actually provided: a macrotask cannot run inside another, so the load lands after the current frame whatever the frame does. The microtask version only landed there while the whole update-and-draw path stayed synchronous — true today, but it would have started running mid-frame, silently, the day anything in that path awaited. The fire-and-forget path gets simpler rather than more complex: no promise is created, so nothing can swallow a failure, and a throw inside the timer lands on an empty stack as the uncaught error it has always been. The rethrow machinery is deleted outright. The trade is the background-tab clamp, which comes back — that CHANGELOG entry is removed rather than left claiming a fix that no longer applies. Consequence for the docs, in three places: awaiting without `async: true` now genuinely does not wait. The microtask version happened to complete first by queue ordering; a timer does not. The typedef, both skills and the test all say so plainly now, which is the version I described before measuring and then had to walk back — it is true again, for a different reason. Adds unit tests for `defer` itself, which had none despite being public API and load-bearing in five modules: that it does not run synchronously, that it lands on a macrotask rather than a microtask, that it binds `thisArg` and forwards arguments, that its handle cancels, and that a throw inside it surfaces as an uncaught error rather than a rejection — the property the fire-and-forget load now depends on. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/CHANGELOG.md | 1 - .../melonjs/skills/melonjs-3d-assets/SKILL.md | 2 +- .../melonjs/skills/melonjs-tilemaps/SKILL.md | 15 ++-- packages/melonjs/src/level/level.js | 55 ++++++------ packages/melonjs/src/renderable/trigger.js | 9 +- .../melonjs/tests/level_load_async.spec.js | 60 +++++++------ packages/melonjs/tests/utils.spec.js | 88 +++++++++++++++++++ 7 files changed, 162 insertions(+), 68 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 6dea6cac6..28d38d813 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -13,7 +13,6 @@ ### Fixed - Level: `level.reload()` was documented as returning `object` — "the current level" — but it returns whatever `level.load()` returns, which is `true`. The declared type has been wrong for the method's whole life: the 2011 original returned nothing at all. `getCurrentLevel()` is the call that hands back the level object. This corrects the emitted type from `object` to `boolean`, so a `const lvl: object = level.reload()` that compiled while receiving `true` now fails to compile, at the site that was already wrong -- Level: a level load could sit for a second or more before starting when the tab was in the background. `level.load()` deferred its work with a timer so the current frame could unwind before the world is reset — necessary, since it is routinely called from a trigger handler mid-loop — but browsers clamp a timer to at least a second in a background tab. It now defers with a microtask, which unwinds the frame just the same and is not clamped - Lit meshes: specular highlights sat in the wrong place under a scaled ancestor ([#1636](https://github.com/melonjs/melonJS/issues/1636)). The camera position was derived from the view as `-Rᵀ·t`, which is only the right point when the upper 3×3 is orthonormal — and `Container.draw` folds every ancestor into that matrix. It is now the translation column of the view's inverse - Lit meshes: specular lighting, and a mesh's alpha-map cutout, were wrong on whichever tier drew second in a frame. The instanced and non-instanced tiers are two programs sharing one batcher, and its skip-the-redundant-upload cache was not dropped when the program changed under it — so an instanced set behind a lit prop at the same shininess lost its specular outright, and instanced foliage rendered as opaque rectangles. Present since 20.0.0 - Ground shadows: a scene could lose every blob it drew. The queue drained on any batcher switch, including inside the screen-projection window `Container.draw` opens around a `floating` child — so a single HUD deleted every ground shadow — and mid-scene whenever anything non-mesh sorted there. It now drains only where the world draw is finished diff --git a/packages/melonjs/skills/melonjs-3d-assets/SKILL.md b/packages/melonjs/skills/melonjs-3d-assets/SKILL.md index 42cfa9ad7..b598e6315 100644 --- a/packages/melonjs/skills/melonjs-3d-assets/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d-assets/SKILL.md @@ -43,7 +43,7 @@ or it renders flat. See `melonjs-3d` for the camera. `onLoaded` receives the level id — it is a "done" signal, not a handle on the scene. You need it, or `async`: with the game loop running, `level.load` stops -the loop and defers the actual load to a microtask, so by default it returns +the loop and defers the actual load to a timer, so by default it returns *before* anything is in the world. ```js diff --git a/packages/melonjs/skills/melonjs-tilemaps/SKILL.md b/packages/melonjs/skills/melonjs-tilemaps/SKILL.md index 99d9ea069..6c26a8465 100644 --- a/packages/melonjs/skills/melonjs-tilemaps/SKILL.md +++ b/packages/melonjs/skills/melonjs-tilemaps/SKILL.md @@ -39,8 +39,9 @@ also skip `src` and pass the map inline via `data` (with `format: "json"` or that is a typo rather than a load failure. **`level.load` is deferred while the game loop is running.** It calls -`state.stop()` and finishes the load in a microtask, so by default it returns -`true` before anything is in the world. Two ways to sequence work after it: +`state.stop()` and finishes the load on a timer, after the current frame, so by +default it returns `true` before anything is in the world. Two ways to sequence +work after it: ```js // await it @@ -54,10 +55,8 @@ level.load("map1", { onLoaded: () => this.spawnPlayer() }); `async: true` is the only thing that changes the return value — everything else behaves identically, `onLoaded` included. Without it the call returns a boolean, so `await level.load("map1")` is not an error and does not await the load: -`await true` resolves immediately. (The load does finish first today, because the -deferral is a single microtask queued ahead of the await's continuation — but -that is incidental ordering, not a contract.) Pass the flag when you mean to -await. +`await true` resolves immediately, while the load is still sitting on a timer. +Pass the flag when you mean to await. `level.reload()`, `level.next()` and `level.previous()` take the same `async` option and resolve the same value they return — so `if (level.next())` becomes @@ -197,8 +196,8 @@ unanimated layer into the offscreen-bake path instead. | symptom | cause | |---|---| | a Tiled object becomes a plain shape with no behaviour | its class/name does not match any registered factory, or it was registered after `level.load` | -| the world is still empty right after `level.load` | the load is deferred to a microtask while the loop runs — `await level.load(id, { async: true })`, or use `onLoaded` / `LEVEL_LOADED` | -| `await level.load(id)` returned `true` rather than a promise | without `async: true` the call returns a boolean; `await true` resolves immediately. The load happens to finish first today by microtask ordering, but that is incidental — pass the flag when you mean to await | +| the world is still empty right after `level.load` | the load is deferred to a timer while the loop runs — `await level.load(id, { async: true })`, or use `onLoaded` / `LEVEL_LOADED` | +| `await level.load(id)` returned `true` rather than a promise | without `async: true` the call returns a boolean; `await true` resolves immediately and the load has not run yet — pass the flag when you mean to await | | `level not found` | the map was never preloaded, or the asset `name` differs from the id passed to `load` | | `unknown or invalid resource type` | asset `type` set to `"tmj"` / `"tsj"` — use `"tmx"` / `"tsx"` with the `.tmj` / `.tsj` file | | camera will not scroll | `setViewportBounds: false`, or the map was added with `addTo()` (which defaults to `false`) | diff --git a/packages/melonjs/src/level/level.js b/packages/melonjs/src/level/level.js index 9e8bd1703..334384224 100644 --- a/packages/melonjs/src/level/level.js +++ b/packages/melonjs/src/level/level.js @@ -2,6 +2,7 @@ import { game } from "../application/application.ts"; import { getTMX } from "./../loader/loader.js"; import state from "./../state/state.ts"; import { emit, LEVEL_LOADED } from "../system/event.ts"; +import { defer } from "../utils/function.ts"; import { resetGUID } from "./../utils/utils.ts"; import GLTFScene from "./gltf/GLTFScene.js"; import TMXTileMap from "./tiled/TMXTileMap.js"; @@ -107,12 +108,10 @@ function levelIdAt(offset) { * behaves identically either way, `onLoaded` included. * * Awaiting a call WITHOUT `async: true` is not an error, but it is not a wait - * either: the call hands back a boolean, and `await true` resolves immediately, - * so there is no completion point to await. Whether the load has finished by - * then is incidental — it has when there is no loop running, and it currently - * does when there is, because the deferral is a single microtask queued ahead - * of the await's continuation. Do not rely on either. Pass the flag when you - * mean to await. + * either: the call hands back a boolean, and `await true` resolves immediately. + * While the loop is running the load is deferred onto a timer, so it has NOT + * happened by the time such an `await` resumes. Pass the flag when you mean to + * await. * @typedef {object} LevelLoadOptions * @property {Container} [container=game.world] - container in which to load the specified level * @property {Function} [onLoaded=game.onLevelLoaded] - callback for when the level is fully loaded, called in both forms @@ -244,36 +243,40 @@ export const level = { const wantsPromise = options.async === true; - // Deferred so the current frame can unwind first. `level.load()` is + // Deferred so the current frame can finish first. `level.load()` is // routinely called from inside the loop — a trigger handler, an update // step — and `safeLoadLevel` resets and destroys the very container the // loop may be iterating. `state.stop()` sets a flag; it does not unwind // the frame already on the stack. // - // A microtask rather than a timer. Both unwind the stack — a microtask - // drains when the JS stack empties, i.e. at the end of the rAF callback - // holding update AND draw — but `setTimeout` is clamped to >= 1s in a - // background tab, which would strand a load queued as the tab hides. - // The timer this replaced dated to 2011, before promises existed; there - // was never a macrotask semantic to preserve. + // A TIMER, through the engine's own `defer`, and deliberately not a + // microtask. A macrotask cannot run inside another, so the load lands + // after the current frame whatever the frame does. A microtask only + // lands there while the whole update-and-draw path stays synchronous — + // true today, but it would start running mid-frame the day anything in + // that path awaits, silently and with nothing to catch it. if (state.isRunning()) { // stop the game loop to avoid some silly side effects state.stop(); - const deferred = Promise.resolve().then(() => { - safeLoadLevel(levelId, options, true); - return true; - }); if (wantsPromise) { - return deferred; - } - // Fire-and-forget: rethrow on a clean stack so a failure still - // surfaces as an uncaught error the way it did when the deferral was - // a timer, rather than as a silent unhandled rejection. - deferred.catch((error) => { - queueMicrotask(() => { - throw error; + return new Promise((resolve, reject) => { + defer(() => { + try { + safeLoadLevel(levelId, options, true); + resolve(true); + } catch (error) { + reject(error); + } + }, null); }); - }); + } + // Fire-and-forget: no promise is created, so there is nothing to + // swallow a failure — a throw inside the timer lands on an empty + // stack as an uncaught error, which is the surface this has always + // had. + defer(() => { + safeLoadLevel(levelId, options, true); + }, null); return true; } diff --git a/packages/melonjs/src/renderable/trigger.js b/packages/melonjs/src/renderable/trigger.js index e1449eef0..e4afaf520 100644 --- a/packages/melonjs/src/renderable/trigger.js +++ b/packages/melonjs/src/renderable/trigger.js @@ -5,6 +5,7 @@ import { level } from "./../level/level.js"; import { vector2dPool } from "../math/vector2d.ts"; import { boundsPool } from "./../physics/bounds.ts"; import { collision } from "./../physics/collision.js"; +import { defer } from "./../utils/function.ts"; import Renderable from "./renderable.js"; /** @@ -202,10 +203,12 @@ export default class Trigger extends Renderable { } }) .catch((error) => { - // same loudness as the fire-and-forget form - queueMicrotask(() => { + // rethrow on an empty stack, so a failed transition + // surfaces as an uncaught error rather than a silent + // unhandled rejection + defer(() => { throw error; - }); + }, null); }); }; diff --git a/packages/melonjs/tests/level_load_async.spec.js b/packages/melonjs/tests/level_load_async.spec.js index bfe6b8095..221f34e0c 100644 --- a/packages/melonjs/tests/level_load_async.spec.js +++ b/packages/melonjs/tests/level_load_async.spec.js @@ -47,9 +47,14 @@ describe("level.load({ async }) (#1646)", () => { app?.destroy(); }); - afterEach(() => { + afterEach(async () => { // leave the loop stopped between tests; each one sets what it needs state.stop(); + // and flush any timer-deferred load still pending, so it cannot land in + // the middle of the next test + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); }); /** record every time the level director actually puts a scene in the world */ @@ -277,20 +282,18 @@ describe("level.load({ async }) (#1646)", () => { return promise; }); - it("awaiting WITHOUT the flag still yields to the deferred load", async () => { - // `await true` is valid JavaScript, so forgetting the flag is silent. - // It happens to be harmless TODAY: the deferral is a single - // microtask queued before the await's continuation, so the load runs - // first either way. That is incidental ordering, not a contract — - // hence the flag exists — so this pins the observable part (no - // promise is returned) and merely records the rest. + it("awaiting WITHOUT the flag does NOT wait for the load", async () => { + // The cost of putting the switch in the options: `await true` is + // valid JavaScript, so forgetting the flag is silent. The deferral + // is a timer, and awaiting a boolean yields only one microtask — + // nowhere near it. This is the whole reason the flag exists. const seen = track(); state.restart(); const value = level.load("unit-test-level", { container: container() }); expect(value).toBe(true); expect(value).not.toBeInstanceOf(Promise); await value; - expect(seen).toHaveLength(1); + expect(seen).toHaveLength(0); }); }); @@ -509,28 +512,27 @@ describe("level.load({ async }) (#1646)", () => { expect(seen).toHaveLength(1); }); - it("defers by a MICROTASK, not a timer", async () => { - // A timer is clamped to >= 1s in a background tab, which would strand - // a level load queued as the tab hides. A microtask drains when the - // stack empties, so it lands before any macrotask queued alongside it. - const order = []; - track(); - GLTFScene.prototype.addTo = () => { - order.push("load"); - }; + it("defers past every microtask, onto a macrotask", async () => { + // A macrotask cannot run inside another, so the load lands after the + // current frame whatever that frame does. A microtask would only + // land there while the whole update-and-draw path stays synchronous + // — true today, but it would start running mid-frame the day + // anything in that path awaits. + const seen = track(); state.restart(); - const promise = level.load("unit-test-level", { - container: container(), - async: true, - }); - const timer = new Promise((resolve) => { - setTimeout(() => { - order.push("timer"); - resolve(); - }, 0); + level.load("unit-test-level", { container: container() }); + + // drain the microtask queue: a microtask-deferred load runs here + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + expect(seen).toHaveLength(0); + + // ...this one lands on the next macrotask + await new Promise((resolve) => { + setTimeout(resolve, 0); }); - await Promise.all([promise, timer]); - expect(order).toEqual(["load", "timer"]); + expect(seen).toHaveLength(1); }); }); }); diff --git a/packages/melonjs/tests/utils.spec.js b/packages/melonjs/tests/utils.spec.js index 812da5a86..9a49354a9 100644 --- a/packages/melonjs/tests/utils.spec.js +++ b/packages/melonjs/tests/utils.spec.js @@ -39,6 +39,94 @@ describe("utils", () => { }); }); + describe("Function", () => { + // `defer` is public API and load-bearing: `state`, `Container`, `timer`, + // `level.load` and `Trigger` all schedule through it. Nothing pinned its + // contract before. + + it("does not run the callback synchronously", () => { + let ran = false; + utils.function.defer(() => { + ran = true; + }, null); + expect(ran).toBe(false); + }); + + it("defers past the microtask queue, onto a macrotask", async () => { + // the property `level.load` depends on: a macrotask cannot run + // inside another, so the callback lands after the current frame + // whatever that frame does + let ran = false; + utils.function.defer(() => { + ran = true; + }, null); + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + expect(ran).toBe(false); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(ran).toBe(true); + }); + + it("binds thisArg and forwards the extra arguments", async () => { + const context = { tag: "ctx" }; + let seen = null; + utils.function.defer( + function (a, b) { + seen = { tag: this.tag, a, b }; + }, + context, + 1, + 2, + ); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(seen).toEqual({ tag: "ctx", a: 1, b: 2 }); + }); + + it("returns a handle that cancels the call", async () => { + // `Container.pendingSort` keeps the handle for exactly this + let ran = false; + const handle = utils.function.defer(() => { + ran = true; + }, null); + clearTimeout(handle); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(ran).toBe(false); + }); + + it("surfaces a throw as an uncaught error, not a rejection", async () => { + // This is why the fire-and-forget level load schedules through + // `defer` rather than a promise: a failure has to stay reachable + // from `window.onerror`, which never sees an unhandled rejection. + const seen = []; + const onError = (e) => { + seen.push("uncaught-error"); + e.preventDefault(); + }; + const onRejection = (e) => { + seen.push("unhandled-rejection"); + e.preventDefault(); + }; + window.addEventListener("error", onError); + window.addEventListener("unhandledrejection", onRejection); + utils.function.defer(() => { + throw new Error("deferred boom"); + }, null); + await new Promise((resolve) => { + setTimeout(resolve, 60); + }); + window.removeEventListener("error", onError); + window.removeEventListener("unhandledrejection", onRejection); + expect(seen).toEqual(["uncaught-error"]); + }); + }); + describe("File", () => { const filename = "/src/bar/foo.bar-test.bar.baz"; From 676b40ae371a510e77f8653e326ff30fbb28fc06 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 6 Sep 2026 08:38:18 +0800 Subject: [PATCH 12/17] Level: name the deferral, drop the promisify boilerplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The promise branch wrapped the deferral in a `new Promise` executor with a try/catch to funnel a throw into `reject`. A `.then` already turns a throw into a rejection, so a small `nextTask()` helper — a promise that settles on the next task, through the engine's own `defer` — removes the executor, the try/catch and the manual resolve/reject. Deliberately not converted further. Nothing in the level lifecycle is actually asynchronous: both `addTo` implementations, glTF and TMX, are fully synchronous. Making `safeLoadLevel` async would buy syntax and cost atomicity — it currently runs to completion inside one task, so the world is never observable half-built, and spreading it across microtask ticks would give up that property for nothing. The fire-and-forget branch keeps calling `defer` directly. Routing it through `nextTask()` would create a promise, and a promise is exactly what must not exist there: with nothing holding it, a failed load would become a silent unhandled rejection instead of an uncaught error. Adds the test that made the refactor honest. The existing macrotask test drives the fire-and-forget path, which calls `defer` directly, so it never touched the promise branch — `nextTask()` could have reverted to a microtask unnoticed. Now pinned for both. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/src/level/level.js | 27 ++++++++++++------- .../melonjs/tests/level_load_async.spec.js | 18 +++++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/melonjs/src/level/level.js b/packages/melonjs/src/level/level.js index 334384224..221bf1372 100644 --- a/packages/melonjs/src/level/level.js +++ b/packages/melonjs/src/level/level.js @@ -62,6 +62,19 @@ function safeLoadLevel(levelId, options, restart) { } } +/** + * A promise that settles on the next task, scheduled through the engine's own + * deferral so it lands in the same place `defer` does. + * @returns {Promise} resolves after the current frame + * @ignore + * @internal + */ +function nextTask() { + return new Promise((resolve) => { + defer(resolve, null); + }); +} + /** * Load a TMX level * @private @@ -259,15 +272,11 @@ export const level = { // stop the game loop to avoid some silly side effects state.stop(); if (wantsPromise) { - return new Promise((resolve, reject) => { - defer(() => { - try { - safeLoadLevel(levelId, options, true); - resolve(true); - } catch (error) { - reject(error); - } - }, null); + // `.then` turns a throw into a rejection on its own, so this + // needs no executor and no try/catch + return nextTask().then(() => { + safeLoadLevel(levelId, options, true); + return true; }); } // Fire-and-forget: no promise is created, so there is nothing to diff --git a/packages/melonjs/tests/level_load_async.spec.js b/packages/melonjs/tests/level_load_async.spec.js index 221f34e0c..14f4b08c8 100644 --- a/packages/melonjs/tests/level_load_async.spec.js +++ b/packages/melonjs/tests/level_load_async.spec.js @@ -512,6 +512,24 @@ describe("level.load({ async }) (#1646)", () => { expect(seen).toHaveLength(1); }); + it("defers the ASYNC form past every microtask too", async () => { + // the promise branch schedules through its own helper, so the + // fire-and-forget test above does not cover it: without this, that + // branch could quietly revert to a microtask + const seen = track(); + state.restart(); + const promise = level.load("unit-test-level", { + container: container(), + async: true, + }); + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + expect(seen).toHaveLength(0); + await expect(promise).resolves.toBe(true); + expect(seen).toHaveLength(1); + }); + it("defers past every microtask, onto a macrotask", async () => { // A macrotask cannot run inside another, so the load lands after the // current frame whatever that frame does. A microtask would only From 26709132a2a200c682fd1e6924e6ea7700a8b2ad Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 6 Sep 2026 08:43:29 +0800 Subject: [PATCH 13/17] Level: inline the single-use nextTask helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One definition, one call site — the same shape as the `scheduleLoad` helper removed earlier. A private function plus a JSDoc block to name a three-line idiom is more ceremony than the name is worth. The `.then` structure stays, and the comment now says why it is load-bearing rather than incidental: a throw from inside `defer`'s callback escapes the promise executor entirely, leaving the promise pending forever. Raised one level up, in `.then`, it rejects — which is what removes the try/catch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/src/level/level.js | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/packages/melonjs/src/level/level.js b/packages/melonjs/src/level/level.js index 221bf1372..ea5a1a37f 100644 --- a/packages/melonjs/src/level/level.js +++ b/packages/melonjs/src/level/level.js @@ -62,19 +62,6 @@ function safeLoadLevel(levelId, options, restart) { } } -/** - * A promise that settles on the next task, scheduled through the engine's own - * deferral so it lands in the same place `defer` does. - * @returns {Promise} resolves after the current frame - * @ignore - * @internal - */ -function nextTask() { - return new Promise((resolve) => { - defer(resolve, null); - }); -} - /** * Load a TMX level * @private @@ -272,9 +259,13 @@ export const level = { // stop the game loop to avoid some silly side effects state.stop(); if (wantsPromise) { - // `.then` turns a throw into a rejection on its own, so this - // needs no executor and no try/catch - return nextTask().then(() => { + // The load runs in `.then`, not in the executor: a throw from + // inside `defer`'s callback would escape the executor entirely, + // leaving the promise pending forever. Raised one level up it + // rejects, which is why this needs no try/catch. + return new Promise((resolve) => { + defer(resolve, null); + }).then(() => { safeLoadLevel(levelId, options, true); return true; }); From 366eac3836724de02fa6b2469f4f2ad543748d19 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 6 Sep 2026 08:55:24 +0800 Subject: [PATCH 14/17] Tests: flush pending deferred loads before the trigger spec tears down `triggerEvent` schedules the load through `defer`, so a test that fires a trigger leaves a timer pending. `afterAll` then destroyed the application, and the callback landed on a torn-down world: TypeError: Cannot read properties of undefined (reading 'set') World.reset physics/world.js:266 safeLoadLevel level/level.js:23 Vitest reports that as a fatal unhandled error while still counting every test as passed, so the suite reads green and the job fails. The level spec already flushed for this reason when the deferral moved back to a timer; the trigger spec was missed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- .../tests/trigger_level_change.spec.js | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/melonjs/tests/trigger_level_change.spec.js b/packages/melonjs/tests/trigger_level_change.spec.js index 774c44a8f..ffdf0a349 100644 --- a/packages/melonjs/tests/trigger_level_change.spec.js +++ b/packages/melonjs/tests/trigger_level_change.spec.js @@ -1,4 +1,12 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vitest"; import { Application, boot, @@ -49,6 +57,16 @@ describe("Trigger level change (#1646)", () => { state.stop(); }); + afterEach(async () => { + // Flush any timer-deferred load still pending. `triggerEvent` schedules + // through `defer`, so without this the callback fires after `afterAll` + // has destroyed the app and `World.reset` throws on a torn-down world — + // a fatal unhandled error, with every test still reporting green. + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }); + /** a Trigger attached to the world, so `getRootAncestor().app` resolves */ const trigger = (settings) => { const t = new Trigger(0, 0, { From 66aaf538061a7f24263a543fd645faee11c3c7bf Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 6 Sep 2026 12:31:09 +0800 Subject: [PATCH 15/17] Tests: cover the level.load option defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `container`, `setViewportBounds` and `flatten` are pre-existing defaults this change does not touch, but nothing called `level.load` before, so nothing pinned them either — and they are the contract a game gets when it passes no options at all. - `container` defaults to the application's world - `setViewportBounds` defaults to TRUE on the TMX arm, which is what calls `viewport.setBounds`; asserted against an explicit `false` so the default is what is being measured rather than the code path merely running - `flatten` defaults to the application's `mergeGroup` rather than to a constant, so it is pinned in both positions Flipping each default fails a test. Replacing the container default with a different container fails too, though loudly — the assertion is a direct identity check against the app's world. The TMX map fixture moves to the top-level hook. It was registered inside a sibling describe, so the new tests only saw it through declaration order, which is not something to rely on. That adds a third level to the registry, so the next/previous boundary tests now name the last level instead of assuming which one it is. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- .../melonjs/tests/level_load_async.spec.js | 189 ++++++++++++------ 1 file changed, 127 insertions(+), 62 deletions(-) diff --git a/packages/melonjs/tests/level_load_async.spec.js b/packages/melonjs/tests/level_load_async.spec.js index 14f4b08c8..8b31aa901 100644 --- a/packages/melonjs/tests/level_load_async.spec.js +++ b/packages/melonjs/tests/level_load_async.spec.js @@ -24,6 +24,57 @@ import state from "../src/state/state.ts"; * tests pin the SCHEDULING, which is what changed. `getGLTF` returns null for * an unregistered asset, so a scene registers without one. */ +const MAP = { + type: "map", + version: "1.10", + orientation: "orthogonal", + renderorder: "right-down", + infinite: false, + width: 4, + height: 4, + tilewidth: 16, + tileheight: 16, + nextlayerid: 2, + nextobjectid: 1, + layers: [ + { + id: 1, + name: "ground", + type: "tilelayer", + visible: true, + opacity: 1, + x: 0, + y: 0, + width: 4, + height: 4, + data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + { + id: 2, + name: "entities", + type: "objectgroup", + visible: true, + opacity: 1, + x: 0, + y: 0, + objects: [ + { + id: 1, + name: "spawn", + type: "", + x: 8, + y: 8, + width: 8, + height: 8, + rotation: 0, + visible: true, + }, + ], + }, + ], + tilesets: [], +}; + describe("level.load({ async }) (#1646)", () => { let app; let calls; @@ -40,6 +91,15 @@ describe("level.load({ async }) (#1646)", () => { originalAddTo = GLTFScene.prototype.addTo; level.add("gltf", "unit-test-level"); level.add("gltf", "unit-test-level-2"); + // registered up here rather than inside the TMX describe: "the option + // defaults" needs the map too, and a sibling describe's `beforeAll` + // only runs for that describe — the tests passed purely on declaration + // order, which is not a thing to rely on + await loader.preload( + [{ name: "unit-test-map", type: "tmx", data: MAP }], + undefined, + false, + ); }); afterAll(() => { @@ -210,8 +270,10 @@ describe("level.load({ async }) (#1646)", () => { // ordinary outcome, not an error const seen = track(); state.stop(); - await level.load("unit-test-level-2", { + // the LAST registered level — see the top-level beforeAll + await level.load("unit-test-map", { container: container(), + setViewportBounds: false, async: true, }); seen.length = 0; @@ -256,12 +318,18 @@ describe("level.load({ async }) (#1646)", () => { it("each sync twin still returns the same value, unchanged", () => { track(); state.stop(); + // three levels are registered: unit-test-level, unit-test-level-2, + // then the TMX map — so walking forward twice reaches the end level.load("unit-test-level", { container: container() }); expect(level.reload({ container: container() })).toBe(true); expect(level.next({ container: container() })).toBe(true); + expect( + level.next({ container: container(), setViewportBounds: false }), + ).toBe(true); // now on the last level: no next expect(level.next({ container: container() })).toBe(false); expect(level.previous({ container: container() })).toBe(true); + expect(level.previous({ container: container() })).toBe(true); // back on the first: no previous expect(level.previous({ container: container() })).toBe(false); }); @@ -304,67 +372,6 @@ describe("level.load({ async }) (#1646)", () => { // with GUID reset, object flattening and viewport bounds — so the flag // has to work there too. The map is passed inline via the loader's // `data` field, so this needs no fixture file. - const MAP = { - type: "map", - version: "1.10", - orientation: "orthogonal", - renderorder: "right-down", - infinite: false, - width: 4, - height: 4, - tilewidth: 16, - tileheight: 16, - nextlayerid: 2, - nextobjectid: 1, - layers: [ - { - id: 1, - name: "ground", - type: "tilelayer", - visible: true, - opacity: 1, - x: 0, - y: 0, - width: 4, - height: 4, - data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - }, - { - id: 2, - name: "entities", - type: "objectgroup", - visible: true, - opacity: 1, - x: 0, - y: 0, - objects: [ - { - id: 1, - name: "spawn", - type: "", - x: 8, - y: 8, - width: 8, - height: 8, - rotation: 0, - visible: true, - }, - ], - }, - ], - tilesets: [], - }; - - beforeAll(async () => { - // `switchToLoadState` false: this spec is not driving the state - // machine, and the LOADING state would fight the tests below - await loader.preload( - [{ name: "unit-test-map", type: "tmx", data: MAP }], - undefined, - false, - ); - }); - it("loads a TMX map in the boolean form", () => { state.stop(); const target = container(); @@ -414,6 +421,64 @@ describe("level.load({ async }) (#1646)", () => { }); }); + describe("the option defaults", () => { + // These are pre-existing defaults rather than anything this change + // introduced, but nothing called `level.load` before, so nothing pinned + // them either. They are the contract a game gets when it passes no + // options at all, which is the common case. + + it("defaults the container to the app's world", () => { + const seen = track(); + state.stop(); + level.load("unit-test-level"); + expect(seen).toHaveLength(1); + expect(seen[0]).toBe(app.world); + }); + + it("defaults setViewportBounds to TRUE on the TMX arm", async () => { + // the TMX arm reads `container.getRootAncestor().app`, so this has + // to go through the attached default container + const calls = []; + const original = app.viewport.setBounds.bind(app.viewport); + app.viewport.setBounds = (...args) => { + calls.push(args); + return original(...args); + }; + state.stop(); + level.load("unit-test-map"); + const withDefault = calls.length; + + calls.length = 0; + level.load("unit-test-map", { setViewportBounds: false }); + const withFalse = calls.length; + app.viewport.setBounds = original; + + expect(withDefault).toBeGreaterThan(0); + expect(withFalse).toBe(0); + }); + + it("defaults flatten to the app's mergeGroup", () => { + // `flatten` decides whether a Tiled object group keeps its own + // Container. The default is not `true` or `false` but whatever the + // application says, which is the part worth pinning. + const previous = app.mergeGroup; + state.stop(); + + app.mergeGroup = false; + level.load("unit-test-map", { setViewportBounds: false }); + const wrappedWhenFalse = app.world.getChildByName("entities").length; + + app.mergeGroup = true; + level.load("unit-test-map", { setViewportBounds: false }); + const wrappedWhenTrue = app.world.getChildByName("entities").length; + + app.mergeGroup = previous; + + expect(wrappedWhenFalse).toBe(1); + expect(wrappedWhenTrue).toBe(0); + }); + }); + describe("ordering and failure surfaces", () => { it("emits LEVEL_LOADED before the promise resolves", async () => { // what a caller awaiting the load then reading world state depends From 9f94c5c384578dea7e5ff796e3ba1d0502f15ef1 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 6 Sep 2026 15:16:10 +0800 Subject: [PATCH 16/17] Level: correct the stale microtask wording, and make the reveal test deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four JSDoc blocks and the level spec's header still said the running-loop load is deferred to a microtask. It is a timer again, through `defer` — the wording was left behind by the revert. The trigger reveal test yielded only microtasks between tween ticks while the load it waits for is timer-deferred. It passed, but on scheduling that nothing in the test guarantees: `loaded` reached 1 during a microtask-only loop, which is not a property to rely on. It yields to a macrotask now, and both mutations it exists for still fail — a stale viewport captured before the load, and a reveal that does not wait for it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/src/level/level.js | 10 +++++----- packages/melonjs/tests/level_load_async.spec.js | 2 +- .../melonjs/tests/trigger_level_change.spec.js | 17 ++++++++++++----- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/melonjs/src/level/level.js b/packages/melonjs/src/level/level.js index ea5a1a37f..2bb7a84c0 100644 --- a/packages/melonjs/src/level/level.js +++ b/packages/melonjs/src/level/level.js @@ -196,8 +196,8 @@ export const level = { * load a level into the game manager
* (will also create all level defined entities, etc..) * - * While the game loop is running the load is DEFERRED to a microtask, so - * this returns before anything is in the world. Sequence follow-up work from + * While the game loop is running the load is DEFERRED to a timer, so this + * returns before anything is in the world. Sequence follow-up work from * `options.onLoaded`, from an `event.LEVEL_LOADED` listener, or by passing * `async: true` and awaiting the promise that overload returns. * @@ -332,7 +332,7 @@ export const level = { /** * reload the current level. * - * While the game loop is running the load is deferred to a microtask, so this + * While the game loop is running the load is deferred to a timer, so this * returns before anything is in the world — see {@link level.load}. * @overload * @param {LevelLoadOptions & { async?: false }} [options] - additional optional parameters @@ -371,7 +371,7 @@ export const level = { * the promise form resolves `false` rather than rejecting: running out of * levels is an ordinary outcome, not an error. * - * While the game loop is running the load is deferred to a microtask, so this + * While the game loop is running the load is deferred to a timer, so this * returns before anything is in the world — see {@link level.load}. * @overload * @param {LevelLoadOptions & { async?: false }} [options] - additional optional parameters @@ -412,7 +412,7 @@ export const level = { * the promise form resolves `false` rather than rejecting: running out of * levels is an ordinary outcome, not an error. * - * While the game loop is running the load is deferred to a microtask, so this + * While the game loop is running the load is deferred to a timer, so this * returns before anything is in the world — see {@link level.load}. * @overload * @param {LevelLoadOptions & { async?: false }} [options] - additional optional parameters diff --git a/packages/melonjs/tests/level_load_async.spec.js b/packages/melonjs/tests/level_load_async.spec.js index 8b31aa901..b40c08ca6 100644 --- a/packages/melonjs/tests/level_load_async.spec.js +++ b/packages/melonjs/tests/level_load_async.spec.js @@ -18,7 +18,7 @@ import state from "../src/state/state.ts"; * was the only way to defer at the time. It is still needed — `level.load()` is * routinely called from inside the loop, and `safeLoadLevel` resets and * destroys the very container the loop may be iterating — but it is now a - * microtask, and `async: true` hands that completion back instead of a boolean. + * timer, and `async: true` hands that completion back instead of a boolean. * * The level content is irrelevant here: `GLTFScene.addTo` is stubbed so these * tests pin the SCHEDULING, which is what changed. `getGLTF` returns null for diff --git a/packages/melonjs/tests/trigger_level_change.spec.js b/packages/melonjs/tests/trigger_level_change.spec.js index ffdf0a349..1833b24b2 100644 --- a/packages/melonjs/tests/trigger_level_change.spec.js +++ b/packages/melonjs/tests/trigger_level_change.spec.js @@ -154,14 +154,21 @@ describe("Trigger level change (#1646)", () => { // ticking the moment the load starts: further ticks re-fire onComplete // and would queue a second load. const tween = seen[0].effect.tween; + // Yield to a MACROTASK between ticks, not a microtask: with the loop + // running the load is deferred through `defer`, i.e. a timer, so a + // microtask-only yield never lets it run and the assertions below would + // be measuring whatever the scheduler happened to do. + const nextTask = () => { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }; for (let i = 1; i <= 20 && loaded.length === 0; i++) { tween._onTick(i * 5); - await Promise.resolve(); - } - // let the load's microtask and the reveal chained after it settle - for (let i = 0; i < 4; i++) { - await Promise.resolve(); + await nextTask(); } + // and let the reveal chained after the load settle + await nextTask(); GLTFScene.prototype.addTo = previousAddTo; app.viewport = original; From e4cf8bc5c3d9986e7d0990129fdd09248c3cb3d0 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 6 Sep 2026 15:38:38 +0800 Subject: [PATCH 17/17] Skills: fix a line wrap left by the timer-wording correction Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/skills/melonjs-3d-assets/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/melonjs/skills/melonjs-3d-assets/SKILL.md b/packages/melonjs/skills/melonjs-3d-assets/SKILL.md index b598e6315..d95599e27 100644 --- a/packages/melonjs/skills/melonjs-3d-assets/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d-assets/SKILL.md @@ -53,8 +53,8 @@ await level.load("diorama", { scale: 50, async: true }); Note `await level.load("diorama")` without the flag does not await the load — the call returns a boolean, and `await true` resolves immediately. To get at what was -loaded, -load into a container you own, or look the nodes up by their authored names: +loaded, load into a container you own, or look the nodes up by their authored +names: ```js level.load("diorama", { scale: 50, onLoaded: () => {