diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index e470e5799..6dea6cac6 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,6 +3,7 @@ ## [20.4.0] (melonJS 2) - _unreleased_ ### Added +- `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 @@ -11,6 +12,8 @@ - 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 - 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 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`) | 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 c2e597cb3..9e8bd1703 100644 --- a/packages/melonjs/src/level/level.js +++ b/packages/melonjs/src/level/level.js @@ -84,6 +84,49 @@ 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. + * + * 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 + * @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 @@ -133,57 +176,55 @@ export const level = { }, /** - * load a level into the game manager
+ * 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..) - * @public + * + * `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 - 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 & { async: true }} options - load options, with `async` set + * @returns {Promise} resolves `true` once the level is in the world * @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"}, - * // ... - * ]; + * 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..) * - * // ... + * 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. * - * // load a level into the game world + * 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 & { async?: false }} [options] - additional optional parameters + * @returns {boolean} `true` + * @example + * // load a level * 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 + * // load into a specific container + * me.level.load("a4_level2", { container: levelContainer }); + * + * // 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 }); - * // …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]; + * @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( @@ -201,20 +242,57 @@ export const level = { throw new Error("level " + levelId + " not found"); } - // check the status of the state mngr - const wasRunning = state.isRunning(); + const wantsPromise = options.async === true; - 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(() => { + const deferred = Promise.resolve().then(() => { safeLoadLevel(levelId, options, true); + return true; }); - } else { - safeLoadLevel(levelId, options); + 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. + 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); } + safeLoadLevel(levelId, options); return true; }, @@ -239,13 +317,29 @@ export const level = { }, /** - * reload the current 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 {object} the current level + * reload the current level, and return a promise that settles once the level is in the world. + * + * @overload + * @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] - additional optional parameters + * @returns {boolean} `true` + * @category Level + */ + /** + * @param {LevelLoadOptions} [options] - additional optional parameters + * @returns {boolean|Promise} see the overloads + * @ignore */ reload(options) { // reset the level to initial state @@ -254,39 +348,85 @@ export const level = { }, /** - * 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 + * 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 - 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] - additional optional parameters + * @returns {boolean} `true` if the next level was loaded, `false` if there is none + * @category Level + */ + /** + * @param {LevelLoadOptions} [options] - additional optional parameters + * @returns {boolean|Promise} see the overloads + * @ignore */ next(options) { - //go to the next level - if (currentLevelIdx + 1 < levelIdx.length) { - return this.load(levelIdx[currentLevelIdx + 1], options); - } else { - return false; + const levelId = levelIdAt(1); + if (levelId !== null) { + return this.load(levelId, options); } + 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 + * 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 - 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] - additional optional parameters + * @returns {boolean} `true` if the previous level was loaded, `false` if there is none + * @category Level + */ + /** + * @param {LevelLoadOptions} [options] - additional optional parameters + * @returns {boolean|Promise} see the overloads + * @ignore */ previous(options) { - // go to previous level - if (currentLevelIdx - 1 >= 0) { - return this.load(levelIdx[currentLevelIdx - 1], options); - } else { - return false; + const levelId = levelIdAt(-1); + if (levelId !== null) { + return this.load(levelId, options); } + 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 003b38040..e1449eef0 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 + .load(gotolevel, { ...settings, async: true }) + .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..bfe6b8095 --- /dev/null +++ b/packages/melonjs/tests/level_load_async.spec.js @@ -0,0 +1,536 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + Application, + boot, + Container, + event, + level, + loader, + video, +} from "../src/index.js"; +import GLTFScene from "../src/level/gltf/GLTFScene.js"; +import state from "../src/state/state.ts"; + +/** + * `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 `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.load({ async }) (#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"); + level.add("gltf", "unit-test-level-2"); + }); + + 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("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.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); + 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.load("unit-test-level", { + container: container(), + async: true, + onLoaded: (id) => { + calledWith = id; + }, + }); + 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 + // `load(...).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.load("unit-test-level", { container: container(), async: true }), + ).rejects.toBe(boom); + + state.restart(); + expect(state.isRunning()).toBe(true); + await expect( + level.load("unit-test-level", { container: container(), async: true }), + ).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 + expect(() => { + return level.load("no-such-level", { async: true }); + }).toThrow(/not found/); + }); + }); + + 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.load("unit-test-level", { + container: container(), + async: true, + }); + seen.length = 0; + state.restart(); + await expect( + level.reload({ container: container(), async: true }), + ).resolves.toBe(true); + expect(seen).toHaveLength(1); + }); + + it("next({ async }) loads the next level and resolves true", async () => { + const seen = track(); + state.stop(); + await level.load("unit-test-level", { + container: container(), + async: true, + }); + seen.length = 0; + state.restart(); + await expect( + level.next({ container: container(), async: true }), + ).resolves.toBe(true); + expect(seen).toHaveLength(1); + expect(level.getCurrentLevelId()).toBe("unit-test-level-2"); + }); + + 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.load("unit-test-level-2", { + container: container(), + async: true, + }); + seen.length = 0; + state.restart(); + await expect( + level.next({ container: container(), async: true }), + ).resolves.toBe(false); + expect(seen).toHaveLength(0); + }); + + it("previous({ async }) loads the previous level and resolves true", async () => { + const seen = track(); + state.stop(); + await level.load("unit-test-level-2", { + container: container(), + async: true, + }); + seen.length = 0; + state.restart(); + await expect( + level.previous({ container: container(), async: true }), + ).resolves.toBe(true); + expect(seen).toHaveLength(1); + expect(level.getCurrentLevelId()).toBe("unit-test-level"); + }); + + it("previous({ async }) resolves FALSE without loading when there is no previous", async () => { + const seen = track(); + state.stop(); + await level.load("unit-test-level", { + container: container(), + async: true, + }); + seen.length = 0; + state.restart(); + await expect( + level.previous({ container: container(), async: true }), + ).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 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("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 + // 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.load("unit-test-level", { container: container(), async: true }); + expect(seen).toHaveLength(0); + }); + + it("stops the loop when it was running", () => { + track(); + state.restart(); + level.load("unit-test-level", { container: container(), async: true }); + 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.load("unit-test-level", { container: container(), async: true }); + 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.load("unit-test-level", { + container: container(), + async: true, + }); + 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..774c44a8f --- /dev/null +++ b/packages/melonjs/tests/trigger_level_change.spec.js @@ -0,0 +1,196 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +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"; + +/** + * `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 + * 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("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 + // 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. + // 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); + // 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); + }); +});