From 63ec90dbf3c53d54eb9055255e663ce00fee183f Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 14 Sep 2026 19:18:44 +0800 Subject: [PATCH 1/5] audio: a degenerate fade no longer poisons the volume with NaN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `audio.fade(name, 0.5, 0.5, 0)` left the volume `NaN`, which surfaced later as a non-finite `setValueAtTime` on the next playback. Two faults, both in the interval the fade starts: - the per-tick step divides the elapsed time by the duration, so a zero duration yields Infinity (or 0/0) and `diff * tick` writes NaN - the exit test is `(to < from && ...) || (to > from && ...)`, which equal endpoints satisfy neither of — so the interval never cleared, and kept ticking even with a real duration A fade with nothing to interpolate now settles on the target volume immediately and starts no interval. Four regression tests. They drive `fade` on a loaded-but-unplayed clip deliberately: `play()` holds `_playLock` until the source starts, which never happens headlessly without a user gesture, and `fade` queues rather than applies while that lock is held. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/audio/backend/sound.ts | 30 ++++++--- packages/melonjs/tests/audio.spec.js | 70 ++++++++++++++++++++- 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 65eaa5e3d..74b61171c 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -14,6 +14,7 @@ - Typings: a parsed glTF node's geometry is readable from TypeScript. `GLTFData.nodes` was `object[]` with its fields listed in prose, so `node.vertices` did not compile — the array is `GLTFNode[]` now. `Mesh`'s `texture` accepts a `Texture2d` and its `indices` a `Uint32Array`, both of which it already handled; a shader `Asset` may carry the `{glsl, wgsl}` pair the loader has always parsed ### Fixed +- `audio.fade()` with a zero duration, or with matching start and end volumes, poisoned the volume with `NaN` and left an interval running forever — the step divided by the duration, and the exit test compares the two volumes strictly so equal ones never satisfied it. The reported symptom was a later `setValueAtTime` throwing on a non-finite value. Such a fade now settles on the target immediately without starting an interval - Renderable: opacity now cascades — `alpha` multiplies with the alpha already on the renderer instead of replacing it, so a child at 0.5 inside a parent at 0.5 draws at 0.25. **A nested renderable that was visibly opaque under a faded ancestor will now fade with it** - GLTFModel: a loaded model reported no bounds at all, so a model with a `Body` was filed in the broadphase away from where it stood and collided with nothing. Its extent now comes from the glTF bounding box, measured once at load - Mesh: `resize()`, `recalc()` and the `width`/`height` setters threw on every mesh — `Polygon#recalc` walked the `normals` and `indices` slots a `Mesh` repurposes for typed arrays. The polygon's edge normals are now `edgeNormals` diff --git a/packages/melonjs/src/audio/backend/sound.ts b/packages/melonjs/src/audio/backend/sound.ts index 70e3959b8..b4fa09f10 100644 --- a/packages/melonjs/src/audio/backend/sound.ts +++ b/packages/melonjs/src/audio/backend/sound.ts @@ -945,14 +945,28 @@ class Sound { } } - this._startFadeInterval( - sound, - from, - to, - len, - ids[i], - typeof id === "undefined", - ); + const isGroup = typeof id === "undefined"; + + // A fade with no duration, or one whose endpoints are equal, + // has nothing to interpolate — and an interval for it is not + // merely pointless, it is broken twice over. The tick divides + // the elapsed time by `len`, so a zero duration yields + // Infinity (or 0/0) and `diff * tick` writes NaN into the + // volume; and the exit test wants a STRICT inequality between + // `from` and `to`, which equal endpoints can never satisfy, so + // the interval would keep ticking forever. Settle on the + // target immediately instead. `!(len > 0)` rather than + // `len <= 0` so a NaN duration takes this path too. + if (!(len > 0) || to === from) { + if (isGroup) { + this._volume = to; + } + this.volume(to, ids[i]); + this._emit("fade", ids[i]); + continue; + } + + this._startFadeInterval(sound, from, to, len, ids[i], isGroup); } } diff --git a/packages/melonjs/tests/audio.spec.js b/packages/melonjs/tests/audio.spec.js index e2f3dedf9..c6171a73d 100644 --- a/packages/melonjs/tests/audio.spec.js +++ b/packages/melonjs/tests/audio.spec.js @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { getSoundOrThrow } from "../src/audio/state.ts"; import { audio } from "../src/index.js"; // Build a valid silent WAV in-memory and serve it as a data URL. @@ -46,14 +47,14 @@ const makeSilentWavDataUrl = (durationSec = 0.01) => { return `data:audio/wav;base64,${btoa(bin)}`; }; -const loadClip = (name) => { +const loadClip = (name, durationSec) => { audio.init("wav"); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error(`timeout loading ${name}`)); }, 2000); audio.load( - { name, src: makeSilentWavDataUrl() }, + { name, src: makeSilentWavDataUrl(durationSec) }, () => { clearTimeout(timeout); resolve(); @@ -66,6 +67,9 @@ const loadClip = (name) => { }); }; +/** let `play()`'s `_playLock` clear, so a fade applies instead of queueing */ +const settle = () => new Promise((r) => setTimeout(r, 60)); + describe("audio", () => { it("should export init function", () => { expect(typeof audio.init).toBe("function"); @@ -714,5 +718,67 @@ describe("audio", () => { audio.unload(CLIP); }); }); + // Regression: a fade with nothing to interpolate used to start an + // interval anyway, and that interval was broken two ways at once — + // see the guard in `Sound#fade`. + // + // These drive `fade` on a loaded-but-not-playing clip. `play()` holds + // `_playLock` until the source actually starts, which never happens in + // a headless context with no user gesture, and `fade` QUEUES rather + // than applies while that lock is held — so a test that played first + // would assert against a fade that never ran. + describe("degenerate fades (regression)", () => { + const voiceOf = async (name) => { + await loadClip(name, 2); + const sound = getSoundOrThrow(name); + const id = sound._getSoundIds()[0]; + return { sound, id, voice: sound._soundById(id) }; + }; + + it("zero duration settles immediately, with no interval", async () => { + const { id, voice } = await voiceOf("fade-zero-len"); + // `tick` divides the elapsed time by the duration, so len=0 + // gave Infinity (or 0/0) and `diff * tick` wrote NaN into the + // volume — surfacing later as a non-finite `setValueAtTime`. + audio.fade("fade-zero-len", 1, 0, 0, id); + expect(Number.isFinite(voice._volume)).toBe(true); + expect(voice._volume).toBeCloseTo(0, 5); + expect(voice._interval).toBeUndefined(); + audio.unload("fade-zero-len"); + }); + + it("zero duration with equal endpoints is finite too", async () => { + const { id, voice } = await voiceOf("fade-zero-equal"); + audio.fade("fade-zero-equal", 0.5, 0.5, 0, id); + expect(Number.isFinite(voice._volume)).toBe(true); + expect(voice._volume).toBeCloseTo(0.5, 5); + expect(voice._interval).toBeUndefined(); + audio.unload("fade-zero-equal"); + }); + + it("equal endpoints settle instead of ticking forever", async () => { + const { id, voice } = await voiceOf("fade-equal"); + // The exit test wants `to < from` or `to > from`; equal + // endpoints satisfy neither, so the interval never cleared + // even with a real duration. + audio.fade("fade-equal", 0.3, 0.3, 200, id); + expect(voice._volume).toBeCloseTo(0.3, 5); + expect(voice._interval).toBeUndefined(); + await new Promise((r) => setTimeout(r, 60)); + expect(Number.isFinite(voice._volume)).toBe(true); + audio.unload("fade-equal"); + }); + + it("a genuine fade still starts an interval", async () => { + // the guard must not swallow the normal case + const { id, voice } = await voiceOf("fade-real"); + audio.fade("fade-real", 1, 0, 60, id); + expect(voice._interval).toBeDefined(); + await new Promise((r) => setTimeout(r, 200)); + expect(Number.isFinite(voice._volume)).toBe(true); + expect(voice._volume).toBeCloseTo(0, 1); + audio.unload("fade-real"); + }); + }); }); }); From 196dad10d5398f22790cab6a1e45ca10d76d25d6 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 14 Sep 2026 19:19:03 +0800 Subject: [PATCH 2/5] Camera: add roll, completing the pitch / yaw / roll trio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 3D camera could look up/down and left/right but not bank, which every flight, racing or cockpit game wants. `Camera2d.rotation` is inherited but never read by the 3D view, so `camera.rotate()` on a Camera3d was silently inert — the worst of both worlds. Camera3d composes `roll` into all six sites that build the orientation: world basis as `R(yaw) ∘ R(pitch) ∘ R(roll)`, and its inverse — roll FIRST — in the view matrix and the container transform, whose revert undoes it last. The frustum planes are extracted from `projection × view` via Gribb-Hartmann, so culling follows a banked view with no extra work. Camera2d gets the same name for the same idea, but implemented through its `currentTransform` — which is what a 2D camera's rotation already is, and what `worldToLocal` / `localToWorld` already undo, so picking stays correct under a roll for free. The setter REBUILDS that matrix rather than composing onto it: a camera banked every frame would otherwise accumulate float error and never return to an exact identity. Camera3d overrides the accessor so it writes the angle only, never that matrix. 16 tests, including that the frustum genuinely follows the bank (a tall-thin camera sees a probe upright and culls it rolled) and that the container apply/revert round-trips to identity. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/melonjs/CHANGELOG.md | 2 + packages/melonjs/skills/melonjs-3d/SKILL.md | 12 +- packages/melonjs/src/camera/camera2d.ts | 54 +++++++ packages/melonjs/src/camera/camera3d.ts | 53 ++++++- packages/melonjs/tests/camera.spec.js | 60 ++++++++ packages/melonjs/tests/camera3d.spec.js | 161 ++++++++++++++++++++ 6 files changed, 336 insertions(+), 6 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 74b61171c..e95575190 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -9,6 +9,8 @@ - `save`: registered keys are reachable from TypeScript without a cast. The namespace was typed `Record`, and that index signature swallowed its own members, so under `strict` `save.add()` was `unknown` and could not be called at all. `add()` now returns the namespace typed with the keys just registered, and chained calls accumulate - `audio.tone()` and `audio.noise()` take a `delay` in seconds, scheduled on the audio clock, so a multi-part sound — a stinger's second note, an explosion's double-tap — sequences without a `setTimeout`. A timer fires on the main thread and a busy frame slips the note; this is scheduled up front and is sample-accurate. Omitting it is unchanged +- Camera: `roll` completes the `pitch` / `yaw` trio. On a `Camera3d` it banks the view and the frustum planes follow, so culling stays correct under a bank; on a `Camera2d` it is the screen-plane rotation, and picking compensates for it. Assigning it rebuilds the camera transform, so a camera banked every frame cannot drift + - `Light3d`: `direction` and `position` accept a `Vector3d` as well as an `[x, y, z]` array, so a value the game already holds goes straight in — `color` already took a `Color`, a string or an array. The vector is read, not retained. Passing one used to be read by index and silently produce a `NaN` direction, so the light contributed nothing ([#1661](https://github.com/melonjs/melonJS/issues/1661)) - Typings: a parsed glTF node's geometry is readable from TypeScript. `GLTFData.nodes` was `object[]` with its fields listed in prose, so `node.vertices` did not compile — the array is `GLTFNode[]` now. `Mesh`'s `texture` accepts a `Texture2d` and its `indices` a `Uint32Array`, both of which it already handled; a shader `Asset` may carry the `{glsl, wgsl}` pair the loader has always parsed diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index 6e4b5cbb7..c4dc0f6e9 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -20,9 +20,15 @@ This is the single most important thing on this page. | vertical | **Y-down** — higher `y` is *lower* on screen | Y-up | | depth | **+Z forward** — higher `z` is *farther* away | −Z forward | -Rotations are extrinsic XYZ. `Camera3d` exposes two of the three: `camera.pitch` -(X, look up/down) and `camera.yaw` (Y, look left/right). **There is no `roll`**; -neither camera applies a Z-axis bank to the view, so assigning one does nothing. +Rotations are extrinsic XYZ, and `Camera3d` exposes all three: `camera.pitch` +(X, look up/down), `camera.yaw` (Y, look left/right) and `camera.roll` (Z, bank +the horizon). The view is `R(yaw) ∘ R(pitch) ∘ R(roll)` inverted, and the frustum +planes come off that same matrix — so culling follows a banked view. + +`camera.rotate()` is NOT the way to bank a 3D camera. It writes the inherited +`currentTransform`, which a 3D view never reads, so the call is silently inert. +Set `roll`. (On a `Camera2d` it is the other way round: roll IS that transform's +rotation, which is why `worldToLocal` / `localToWorld` compensate for it.) The payoff is that 2D code translates directly — anywhere you used `pos.x` / `pos.y`, add `pos.z` and the maths still holds. The cost is that every diff --git a/packages/melonjs/src/camera/camera2d.ts b/packages/melonjs/src/camera/camera2d.ts index d560ae7a3..2972f6639 100644 --- a/packages/melonjs/src/camera/camera2d.ts +++ b/packages/melonjs/src/camera/camera2d.ts @@ -144,6 +144,9 @@ export default class Camera2d extends Renderable { */ _zoom: number; + /** backing store for {@link Camera2d#roll} — see the accessor */ + _roll: number; + /** * the world-space projection matrix for non-default cameras (offset/zoomed). * Maps world coordinates to the camera's screen viewport. @@ -267,6 +270,7 @@ export default class Camera2d extends Renderable { this.screenX = 0; this.screenY = 0; this.zoom = 1; + this._roll = 0; this.worldProjection = new Matrix3d(); this.screenProjection = new Matrix3d(); this._worldView = new Bounds(); @@ -381,6 +385,54 @@ export default class Camera2d extends Renderable { * // zoom out to show the full level in a 180x100 minimap * camera.zoom = Math.min(180 / levelWidth, 100 / levelHeight); */ + /** + * Roll this camera in the screen plane, in radians — the 2D spelling of + * {@link Camera3d#roll}, so a helper that banks "a camera" works on either. + * + * Absolute and idempotent: assigning the same angle twice leaves the view + * where it was. It is a thin accessor over the camera's own + * `currentTransform`, which is what {@link Renderable#rotate} writes and + * what `worldToLocal` / `localToWorld` already undo — so picking under a + * rolled 2D camera stays correct for free. + * + * A 2D camera has no pitch or yaw, so a screen-plane rotation is its only + * rotation; on a `Camera3d` the roll is a real view angle instead, because + * a 3D view is built from its three angles and never reads + * `currentTransform`. + * + * This OWNS the camera's `currentTransform`: the setter rebuilds it rather + * than composing onto it, so assigning every frame cannot drift and + * `roll = 0` lands back on an exact identity. (A delta-based version does + * neither — a banking camera would accumulate float error across thousands + * of frames.) The cost is that a manual `rotate()` / `scale()` on the + * camera itself is discarded by the next assignment; a camera has `zoom` + * and `shake` for those jobs, and neither touches this matrix. + * @default 0 + * @example + * camera.roll = Math.PI / 32; // a slight dutch angle + */ + get roll(): number { + return this._roll; + } + + set roll(value: number) { + if (value === this._roll) { + return; + } + this._roll = value; + // Rebuilt, not composed — see the note above on drift. + this.currentTransform.identity(); + if (value !== 0) { + this.currentTransform.rotate(value); + } + // `worldToLocal` reads this cached inverse, and `update()` only + // refreshes it once a frame — refresh it here so a pick taken straight + // after assigning the roll is already correct. + this.invCurrentTransform.copy(this.currentTransform).invert(); + this.updateBounds(); + this.isDirty = true; + } + get zoom(): number { return this._zoom; } @@ -450,6 +502,8 @@ export default class Camera2d extends Renderable { // reset the transformation matrix this.currentTransform.identity(); + // the roll lives IN that matrix, so clearing one clears the other + this._roll = 0; this.invCurrentTransform.identity().invert(); // reset the projection matrices diff --git a/packages/melonjs/src/camera/camera3d.ts b/packages/melonjs/src/camera/camera3d.ts index 8723b0c6d..db8911728 100644 --- a/packages/melonjs/src/camera/camera3d.ts +++ b/packages/melonjs/src/camera/camera3d.ts @@ -17,6 +17,7 @@ export type { Fog3dState, FogMode, FogOptions } from "./fog.ts"; // allocation only happens once per module load, not per frame. const AXIS_X = new Vector3d(1, 0, 0); const AXIS_Y = new Vector3d(0, 1, 0); +const AXIS_Z = new Vector3d(0, 0, 1); // Scratch matrices reused by `_rebuildFrustumPlanes` to avoid per-frame // allocation. Single-instance is safe because draw / update is @@ -55,9 +56,11 @@ const _bScratchB = new Vector3d(); * farther from the camera and renders smaller. Matches melonJS's * 2D conventions so existing Camera2d code translates directly. * - **Rotations are extrinsic XY.** `pitch` (X axis, look up/down) and - * `yaw` (Y axis, look left/right). There is no roll: the inherited - * `Camera2d.rotation` is not read by the view transform or the frustum - * rebuild, so a screen-plane bank has no effect on a 3D camera. + * `yaw` (Y axis, look left/right) and `roll` (Z axis, bank the horizon). + * The view is `R(yaw) ∘ R(pitch) ∘ R(roll)` inverted; the frustum planes are + * extracted from that same matrix, so culling follows a banked view. The + * inherited `currentTransform` is still NOT read — `camera.rotate()` on a + * 3D camera does nothing, so set `roll` rather than rotating the camera. * - **Follow offset (PR B scope).** When a target is set, * `followOffset` is applied in **world space**: * `camera.pos = target.pos + followOffset`. Target-rotation-aware @@ -190,6 +193,35 @@ export default class Camera3d extends Camera2d { */ yaw: number; + /** + * Z-axis rotation in radians (bank the horizon). Positive values + * roll the camera clockwise, so the world tilts anticlockwise — + * the view from a cockpit banking right. + * + * Completes the `pitch` / `yaw` / `roll` trio. Note this is NOT the + * inherited {@link Renderable#rotation}: a 3D camera builds its view + * from these three angles and never reads `currentTransform`, which + * is why `camera.rotate()` on a `Camera3d` is silently inert. A + * `Camera2d` is the other way round — it has no `roll` because a + * screen-plane rotation IS its only rotation, and `rotate()` already + * does it through `currentTransform`. + * @default 0 + * @example + * // bank with the player's steering, as a flight game would + * camera.roll = (player.pos.x / PLAY_BOUND_X) * MAX_BANK; + */ + override get roll(): number { + return this._roll; + } + + override set roll(value: number) { + // Overrides the 2D accessor deliberately. A 2D camera's roll IS a + // `currentTransform` rotation, but a 3D view is built from its three + // angles and never reads that matrix — so rotating it here would leave + // a transform that does nothing in 3D but still skews `worldToLocal`. + this._roll = value; + } + /** * World-space offset from the followed target. When `target` is * set via {@link Camera2d#follow}, the camera position resolves to @@ -242,6 +274,7 @@ export default class Camera3d extends Camera2d { this.pitch = 0; this.yaw = 0; + this.roll = 0; this.followOffset = new Vector3d(0, 0, 0); this.lookAhead = new Vector3d(0, 0, 1); @@ -603,6 +636,7 @@ export default class Camera3d extends Camera2d { _basis.identity(); _basis.rotate(this.yaw, AXIS_Y); _basis.rotate(this.pitch, AXIS_X); + _basis.rotate(this.roll, AXIS_Z); const b = _basis.val; state.heightAxis[0] = k * b[1]; state.heightAxis[1] = k * b[5]; @@ -642,6 +676,7 @@ export default class Camera3d extends Camera2d { _basis.identity(); _basis.rotate(this.yaw, AXIS_Y); _basis.rotate(this.pitch, AXIS_X); + _basis.rotate(this.roll, AXIS_Z); const v = _basis.val; right.set(v[0], v[1], v[2]); up.set(v[4], v[5], v[6]); @@ -804,6 +839,9 @@ export default class Camera3d extends Camera2d { // 1. rotate(-pitch, X) → currentTransform = R(-pitch) // 2. rotate(-yaw, Y) → currentTransform = R(-pitch) ∘ R(-yaw) // 3. translate(-pos) → currentTransform = R(-pitch) ∘ R(-yaw) ∘ T(-pos) + if (this.roll !== 0) { + container.rotate(-this.roll, AXIS_Z); + } if (this.pitch !== 0) { container.rotate(-this.pitch, AXIS_X); } @@ -847,6 +885,9 @@ export default class Camera3d extends Camera2d { if (this.pitch !== 0) { container.rotate(this.pitch, AXIS_X); } + if (this.roll !== 0) { + container.rotate(this.roll, AXIS_Z); + } } /** @@ -1136,6 +1177,9 @@ export default class Camera3d extends Camera2d { // rotate (pitch then yaw), translate by -pos, then pre-multiply by the // frustum projection. _viewMatrix.identity(); + if (this.roll !== 0) { + _viewMatrix.rotate(-this.roll, AXIS_Z); + } if (this.pitch !== 0) { _viewMatrix.rotate(-this.pitch, AXIS_X); } @@ -1181,6 +1225,9 @@ export default class Camera3d extends Camera2d { // `_applyContainerViewTransform` builds it on the container — // rotate first (pitch then yaw), then translate. _viewMatrix.identity(); + if (this.roll !== 0) { + _viewMatrix.rotate(-this.roll, AXIS_Z); + } if (this.pitch !== 0) { _viewMatrix.rotate(-this.pitch, AXIS_X); } diff --git a/packages/melonjs/tests/camera.spec.js b/packages/melonjs/tests/camera.spec.js index fe0bccf8e..43b660663 100644 --- a/packages/melonjs/tests/camera.spec.js +++ b/packages/melonjs/tests/camera.spec.js @@ -1725,4 +1725,64 @@ describe("Camera2d", () => { expect(camera.pos.y).toBe(100); }); }); + describe("roll (2D)", () => { + it("defaults to zero", () => { + expect(new Camera2d(0, 0, 800, 600).roll).toBe(0); + }); + + it("is absolute, not cumulative", () => { + const cam = new Camera2d(0, 0, 800, 600); + cam.roll = 0.4; + cam.roll = 0.4; + expect(cam.roll).toBeCloseTo(0.4, 6); + // one rotation's worth in the matrix, not two + const once = new Camera2d(0, 0, 800, 600); + once.rotate(0.4); + const a = cam.currentTransform.val; + const b = once.currentTransform.val; + for (let i = 0; i < 16; i++) { + expect(a[i]).toBeCloseTo(b[i], 5); + } + }); + + it("does not drift when assigned every frame", () => { + // the reason the setter rebuilds instead of composing: a banking + // camera assigns this thousands of times per run + const cam = new Camera2d(0, 0, 800, 600); + for (let i = 0; i < 2000; i++) { + cam.roll = Math.sin(i / 10) * 0.5; + } + cam.roll = 0; + expect(cam.currentTransform.isIdentity()).toBe(true); + }); + + it("going back to zero restores the identity transform", () => { + const cam = new Camera2d(0, 0, 800, 600); + cam.roll = 0.7; + expect(cam.currentTransform.isIdentity()).toBe(false); + cam.roll = 0; + expect(cam.roll).toBe(0); + expect(cam.currentTransform.isIdentity()).toBe(true); + }); + + it("rolls THROUGH currentTransform, so picking compensates for it", () => { + // the whole reason the 2D roll is a currentTransform rotation: + // worldToLocal / localToWorld already undo that matrix, so a rolled + // 2D camera still converts screen <-> world correctly + const cam = new Camera2d(0, 0, 800, 600); + cam.roll = Math.PI / 5; + const world = cam.localToWorld(120, 80); + const back = cam.worldToLocal(world.x, world.y); + expect(back.x).toBeCloseTo(120, 4); + expect(back.y).toBeCloseTo(80, 4); + }); + + it("reset() clears the roll with the transform it lives in", () => { + const cam = new Camera2d(0, 0, 800, 600); + cam.roll = 0.6; + cam.reset(); + expect(cam.roll).toBe(0); + expect(cam.currentTransform.isIdentity()).toBe(true); + }); + }); }); diff --git a/packages/melonjs/tests/camera3d.spec.js b/packages/melonjs/tests/camera3d.spec.js index e23df80d9..0826b2de8 100644 --- a/packages/melonjs/tests/camera3d.spec.js +++ b/packages/melonjs/tests/camera3d.spec.js @@ -806,4 +806,165 @@ describe("Camera3d", () => { expect(out.y).toBe(-67890); }); }); + describe("roll", () => { + const W = 800; + const H = 600; + // looking straight down +Z from the origin, as the worldToScreen + // suite does — a forward-axis point lands dead centre, so anything a + // roll does to an OFF-axis point is unambiguous. + const mk = () => { + const cam = new Camera3d(0, 0, W, H); + cam.pos.set(0, 0, 0); + cam.lookAt(0, 0, 100); + return cam; + }; + + it("initializes to zero", () => { + expect(new Camera3d(0, 0, W, H).roll).toBe(0); + }); + + it("leaves the projection untouched at roll = 0", () => { + const cam = mk(); + const before = cam.worldToScreen(new Vector3d(10, 0, 100)).clone(); + cam.roll = 0; + const after = cam.worldToScreen(new Vector3d(10, 0, 100)); + expect(after.x).toBeCloseTo(before.x, 5); + expect(after.y).toBeCloseTo(before.y, 5); + }); + + it("does not move a point ON the view axis", () => { + // the roll axis passes through the screen centre, so the one point + // a bank must NOT move is the one it turns about + const cam = mk(); + cam.roll = Math.PI / 3; + const p = cam.worldToScreen(new Vector3d(0, 0, 100)); + expect(p.x).toBeCloseTo(W / 2, 0); + expect(p.y).toBeCloseTo(H / 2, 0); + }); + + it("rotates an off-axis point about the screen centre", () => { + // a point on the +x axis, rolled a quarter turn, lands on the + // vertical through the centre — the radius is preserved, the angle + // has moved by exactly 90 degrees + const cam = mk(); + const flat = cam.worldToScreen(new Vector3d(10, 0, 100)).clone(); + const radius = Math.abs(flat.x - W / 2); + expect(radius).toBeGreaterThan(1); // guard: the fixture must be off-axis + + cam.roll = Math.PI / 2; + const rolled = cam.worldToScreen(new Vector3d(10, 0, 100)); + expect(rolled.x).toBeCloseTo(W / 2, 0); + expect(Math.abs(rolled.y - H / 2)).toBeCloseTo(radius, 0); + }); + + it("is signed: opposite rolls mirror across the axis", () => { + const cam = mk(); + cam.roll = Math.PI / 4; + const cw = cam.worldToScreen(new Vector3d(10, 0, 100)).clone(); + cam.roll = -Math.PI / 4; + const ccw = cam.worldToScreen(new Vector3d(10, 0, 100)); + // same distance from the centre line, opposite sides of it + expect(cw.y - H / 2).toBeCloseTo(-(ccw.y - H / 2), 0); + expect(cw.x).toBeCloseTo(ccw.x, 0); + }); + + it("a full turn returns the projection to where it started", () => { + const cam = mk(); + const before = cam.worldToScreen(new Vector3d(10, -7, 100)).clone(); + cam.roll = Math.PI * 2; + const after = cam.worldToScreen(new Vector3d(10, -7, 100)); + expect(after.x).toBeCloseTo(before.x, 0); + expect(after.y).toBeCloseTo(before.y, 0); + }); + + it("rolls the basis: the right axis tilts, the forward axis does not", () => { + // roll turns the camera ABOUT its forward axis, so `forward` is the + // one basis vector a bank must leave alone + const cam = mk(); + const right = new Vector3d(); + const up = new Vector3d(); + const forward = new Vector3d(); + + cam.roll = 0; + cam.getBasis(right, up, forward); + const forward0 = forward.clone(); + const right0 = right.clone(); + + cam.roll = Math.PI / 2; + cam.getBasis(right, up, forward); + expect(forward.x).toBeCloseTo(forward0.x, 5); + expect(forward.y).toBeCloseTo(forward0.y, 5); + expect(forward.z).toBeCloseTo(forward0.z, 5); + // the right axis has swung a quarter turn away from where it was + const dot = right.x * right0.x + right.y * right0.y + right.z * right0.z; + expect(dot).toBeCloseTo(0, 5); + }); + + it("is absolute, not cumulative: assigning twice does not double it", () => { + const cam = mk(); + cam.roll = 0.3; + cam.roll = 0.3; + expect(cam.roll).toBeCloseTo(0.3, 6); + // and the projection agrees with a camera that got there in one step + const twice = cam.worldToScreen(new Vector3d(10, 0, 100)).clone(); + const once = mk(); + once.roll = 0.3; + const direct = once.worldToScreen(new Vector3d(10, 0, 100)); + expect(twice.x).toBeCloseTo(direct.x, 4); + expect(twice.y).toBeCloseTo(direct.y, 4); + }); + + it("does NOT touch currentTransform (the 3D view never reads it)", () => { + // the 2D accessor rolls by rotating currentTransform; the 3D + // override must not, or a 3D camera ends up with a transform that + // draws nothing but still skews worldToLocal + const cam = mk(); + cam.roll = 0.9; + expect(cam.currentTransform.isIdentity()).toBe(true); + }); + + it("the frustum follows the bank", () => { + // The planes are extracted from projection x view, so a roll has to + // reach culling too. A tall-thin camera sees far along its own up + // axis and not along its right axis; rolling a quarter turn must + // swap which of the two a point is visible in. + const cam = new Camera3d(0, 0, 200, 900); + cam.pos.set(0, 0, 0); + cam.lookAt(0, 0, 100); + // fov 60 deg at z=400 -> half-height tan(30)*400 = 231, and + // half-width = 231 * (200/900) = 51. y=150 clears the tall axis + // and busts the narrow one, so the bank decides visibility. + const probe = new Renderable(0, 0, 1, 1); + probe.pos.set(0, 150, 400); + probe.depth = 400; + + cam.roll = 0; + cam.update(16); + expect(cam.isVisible(probe)).toBe(true); + + cam.roll = Math.PI / 2; + cam.update(16); + expect(cam.isVisible(probe)).toBe(false); + }); + + it("survives the container apply/revert round trip", () => { + // `_applyContainerViewTransform` must be undone exactly by its + // revert, or the transform leaks into whatever draws next + const cam = mk(); + cam.roll = 0.7; + const container = new Renderable(0, 0, 1, 1); + container.currentTransform.identity(); + const before = new Matrix3d().copy(container.currentTransform); + + cam._applyContainerViewTransform(container, 5, 9); + expect(container.currentTransform.isIdentity()).toBe(false); + cam._revertContainerViewTransform(container, 5, 9); + + const a = container.currentTransform.val; + const b = before.val; + for (let i = 0; i < 16; i++) { + expect(a[i]).toBeCloseTo(b[i], 5); + } + }); + }); }); From b0e35893a7c4eff1fe096a336203d3d8b7b08e59 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 14 Sep 2026 19:19:25 +0800 Subject: [PATCH 3/5] Gradient: expose toCanvasGradient as public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 20.5 made `Gradient` public so `Text.fillStyle` could take one, but left no supported way to rasterise it — a user could build a gradient and use it in exactly one place. Both rasterisation methods were `@internal` and stripped from the emitted `.d.ts`, so reaching for either failed the type gate (the AfterBurner example does exactly this). `toCanvasGradient(ctx)` is the right one to publish: it returns a native `CanvasGradient` for the caller's own 2D context and holds no shared state. `toCanvas` stays internal deliberately — it renders into a single shared bake target valid only until the next call by anyone, which is renderer implementation detail rather than something a caller should have to reason about. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/video/gradient.js | 23 +++++++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index e95575190..cd9ee5ec1 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -9,6 +9,7 @@ - `save`: registered keys are reachable from TypeScript without a cast. The namespace was typed `Record`, and that index signature swallowed its own members, so under `strict` `save.add()` was `unknown` and could not be called at all. `add()` now returns the namespace typed with the keys just registered, and chained calls accumulate - `audio.tone()` and `audio.noise()` take a `delay` in seconds, scheduled on the audio clock, so a multi-part sound — a stinger's second note, an explosion's double-tap — sequences without a `setTimeout`. A timer fires on the main thread and a busy frame slips the note; this is scheduled up front and is sample-accurate. Omitting it is unchanged +- `Gradient#toCanvasGradient(ctx)` is public: a `Gradient` could be built and handed to `Text.fillStyle`, but there was no supported way to rasterise one into your own canvas. It returns a native `CanvasGradient` and holds no shared state - Camera: `roll` completes the `pitch` / `yaw` trio. On a `Camera3d` it banks the view and the frustum planes follow, so culling stays correct under a bank; on a `Camera2d` it is the screen-plane rotation, and picking compensates for it. Assigning it rebuilds the camera transform, so a camera banked every frame cannot drift - `Light3d`: `direction` and `position` accept a `Vector3d` as well as an `[x, y, z]` array, so a value the game already holds goes straight in — `color` already took a `Color`, a string or an array. The vector is read, not retained. Passing one used to be read by index and silently produce a `NaN` direction, so the light contributed nothing ([#1661](https://github.com/melonjs/melonJS/issues/1661)) diff --git a/packages/melonjs/src/video/gradient.js b/packages/melonjs/src/video/gradient.js index 2d7f65828..e8f2f2c6c 100644 --- a/packages/melonjs/src/video/gradient.js +++ b/packages/melonjs/src/video/gradient.js @@ -138,11 +138,22 @@ export class Gradient { } /** - * Get or create a native CanvasGradient for use with a 2D context. + * Get or create a native `CanvasGradient` for use with a 2D context. + * + * This is the one to reach for when baking a gradient into your own + * canvas — it hands back a value for `ctx.fillStyle` and touches no + * shared state, so two callers cannot clobber each other. + * + * The result is cached and reused until a stop changes. * @param {CanvasRenderingContext2D} context - the 2D context to create the gradient on - * @returns {CanvasGradient} - * @ignore - * @internal + * @returns {CanvasGradient} a native gradient bound to that context + * @example + * // bake a gradient into a standalone canvas + * const c = document.createElement("canvas"); + * c.width = 64; c.height = 64; + * const ctx = c.getContext("2d"); + * ctx.fillStyle = myGradient.toCanvasGradient(ctx); + * ctx.fillRect(0, 0, 64, 64); */ toCanvasGradient(context) { if (this._canvasGradient && !this._dirty) { @@ -184,6 +195,10 @@ export class Gradient { * which is visually equivalent (linear stop interpolation × linear * texture filtering). The returned `width`/`height` describe the region * of the canvas the caller must use as the drawImage SOURCE rect. + * Internal: the bake target is SHARED across every gradient and valid only + * until the next call, which is a renderer implementation detail rather + * than something a caller should reason about. The public way to rasterise + * a gradient is {@link Gradient#toCanvasGradient}. * @param {CanvasRenderer|WebGLRenderer} renderer - the active renderer (used to invalidate the GPU texture) * @param {number} x - draw rect x * @param {number} y - draw rect y From 6b0110a45e5d06cf2e81386382d39c5068ab58f2 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 14 Sep 2026 19:19:26 +0800 Subject: [PATCH 4/5] AfterBurner: fix a silently-ignored tween duration, and the skill's pool name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while reviewing the example against the 20.5 API. `Tween.to(properties, options)` reads `options.duration`; the example passed a bare number, which has no such property, so every enemy barrel roll ran the 1000ms default and `ENEMY_ROLL_DURATION_MIN/MAX_MS` were dead constants. The backdrop now owns its bank angle instead of hanging an ad-hoc `roll` property on the camera. `Camera3d.roll` exists now and would bank the whole view, but this camera sits behind and below the ship rather than in its cockpit: rolling the view spins everything about the camera's own forward axis, which swings the player's craft off its anchored lower-centre spot and drags the enemies around a screen-space reticle that does not rotate with them. Rolling only the painted backdrop is the arcade original's trick and stays. Also: the HUD's text helper typed its settings so loosely that every caller failed `exactOptionalPropertyTypes`, and `melonjs-performance` documented `pool.pull("me.Tween")` — a 1.x spelling for a name registered as plain `"Tween"`, the only `me.`-prefixed pool name left in the skills. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- .../examples/afterBurner/GameController.ts | 31 +++++++++++++------ .../examples/src/examples/afterBurner/HUD.ts | 8 ++++- .../src/examples/afterBurner/SkyboxStage.ts | 12 +++++++ .../afterBurner/backdrop/BackdropContainer.ts | 15 +++++---- .../src/examples/afterBurner/types.ts | 12 +------ .../skills/melonjs-performance/SKILL.md | 5 ++- 6 files changed, 55 insertions(+), 28 deletions(-) diff --git a/packages/examples/src/examples/afterBurner/GameController.ts b/packages/examples/src/examples/afterBurner/GameController.ts index c6d74ac60..665ecfc43 100644 --- a/packages/examples/src/examples/afterBurner/GameController.ts +++ b/packages/examples/src/examples/afterBurner/GameController.ts @@ -90,7 +90,6 @@ import { } from "./textures"; import type { BulletMover, - Camera3dWithRoll, ContrailNode, EnemyBulletMover, EnemyMover, @@ -214,6 +213,8 @@ export class GameController extends Renderable { // current bank state, smoothed toward an input-driven target each // frame. Player mesh transform is rebuilt from these every tick. playerRoll = 0; + /** horizon bank, fed to the backdrop — see `updateCamera` for why not `camera.roll` */ + bankRoll = 0; playerPitch = 0; // Tiny generated canvas used as the laser-bolt texture for bullets — // avoids hauling around a placeholder PNG and keeps the asset list @@ -467,13 +468,25 @@ export class GameController extends Renderable { ); this.camera.pitch = (-this.player.pos.y / PLAY_BOUND_Y) * MAX_BANK_PITCH; this.camera.yaw = (this.player.pos.x / PLAY_BOUND_X) * MAX_BANK_YAW; - // Roll lives as an ad-hoc property on the camera (Camera3d - // doesn't have a built-in roll field for now). SkyboxStage - // reads it back to rotate the horizon. Sign convention: banking - // right (positive X) rolls the cockpit left, which tilts the - // world to the right from the pilot's POV. - (this.camera as Camera3dWithRoll).roll = - (-this.player.pos.x / PLAY_BOUND_X) * MAX_BANK_ROLL; + // The HORIZON banks, the gameplay layer does not — and that is a + // deliberate arcade cheat, not a missing feature. `Camera3d.roll` + // exists and would bank the whole view, but this camera sits behind + // and below the ship rather than in its cockpit: rolling the view + // spins everything about the camera's own forward axis, which swings + // the player's craft out of its anchored lower-centre spot and drags + // the enemies around a screen-space reticle that does not rotate with + // them. A chase camera banking with its subject has to rotate ABOUT + // the subject, which is a roll plus a compensating translation. Until + // that exists, rolling only the painted backdrop is what sells the + // bank — the same trick the arcade original uses. + // + // Sign convention: banking right (positive X) rolls the cockpit left, + // tilting the world right from the pilot's POV. + this.bankRoll = (-this.player.pos.x / PLAY_BOUND_X) * MAX_BANK_ROLL; + const skybox = state.current(); + if (skybox instanceof SkyboxStage) { + skybox.setRoll(this.bankRoll); + } } spawnBullet(): void { @@ -627,7 +640,7 @@ export class GameController extends Renderable { ENEMY_ROLL_DURATION_MAX_MS, ); return new Tween(state) - .to({ roll: Math.PI * 2 }, duration) + .to({ roll: Math.PI * 2 }, { duration }) .delay(delay) .onUpdate(() => { mesh.currentTransform.identity(); diff --git a/packages/examples/src/examples/afterBurner/HUD.ts b/packages/examples/src/examples/afterBurner/HUD.ts index 5700fac4e..5c5862686 100644 --- a/packages/examples/src/examples/afterBurner/HUD.ts +++ b/packages/examples/src/examples/afterBurner/HUD.ts @@ -231,7 +231,13 @@ export class HUD { app: Application, x: number, y: number, - settings: ConstructorParameters[2], + // `font` is supplied below, so callers must not be required to repeat + // it. Omitting it from the parameter type is also what lets TypeScript + // infer `textAlign` / `textBaseline` as their literal unions rather + // than widening them to `string`. + settings: Omit[2], "font"> & { + font?: string; + }, ): Text { const t = new Text(x, y, { font: "Courier New", diff --git a/packages/examples/src/examples/afterBurner/SkyboxStage.ts b/packages/examples/src/examples/afterBurner/SkyboxStage.ts index d0cb0593b..efa2733fc 100644 --- a/packages/examples/src/examples/afterBurner/SkyboxStage.ts +++ b/packages/examples/src/examples/afterBurner/SkyboxStage.ts @@ -38,6 +38,18 @@ export class SkyboxStage extends Stage { * via `state.isPaused()` inside `GroundGrid.update`; this flag * covers the game-over case where the engine isn't paused.) */ + /** + * Feed the backdrop the horizon bank. Not `Camera3d.roll`: rolling the + * real camera also rolls the gameplay layer, which for a behind-the-ship + * view swings the player's craft off its anchor. See `GameController`. + * @param roll - bank angle in radians + */ + setRoll(roll: number): void { + if (this.backdrop) { + this.backdrop.roll = roll; + } + } + setScrollPaused(paused: boolean): void { if (this.backdrop) { this.backdrop.grid.scrollPaused = paused; diff --git a/packages/examples/src/examples/afterBurner/backdrop/BackdropContainer.ts b/packages/examples/src/examples/afterBurner/backdrop/BackdropContainer.ts index 741ada361..4095d5a47 100644 --- a/packages/examples/src/examples/afterBurner/backdrop/BackdropContainer.ts +++ b/packages/examples/src/examples/afterBurner/backdrop/BackdropContainer.ts @@ -20,7 +20,6 @@ import { Container, type WebGLRenderer, } from "melonjs"; -import type { Camera3dWithRoll } from "../types"; import { GroundGrid } from "./GroundGrid"; import { MountainHorizon } from "./MountainHorizon"; import { SkyGradient } from "./SkyGradient"; @@ -39,6 +38,8 @@ type Renderer = CanvasRenderer | WebGLRenderer; export const BACKDROP_DEPTH = 10000; export class BackdropContainer extends Container { + /** horizon bank in radians, fed by `SkyboxStage#setRoll` */ + roll = 0; readonly grid: GroundGrid; readonly mountains: MountainHorizon; readonly sky: SkyGradient; @@ -61,13 +62,15 @@ export class BackdropContainer extends Container { } override draw(renderer: Renderer, viewport: Camera3d): void { - // Apply camera roll once for the whole backdrop. The engine - // already wrapped us with `setProjection(screenProjection)` + - // `resetTransform` because we're floating, so we're free to - // translate/rotate from identity. + // Bank the horizon in screen space. We are floating, so the engine + // wrapped us with `setProjection(screenProjection)` + `resetTransform` + // and no camera transform reached us — which is exactly what makes + // this the one layer that CAN bank without dragging the gameplay + // elements with it. See `GameController` for why the camera itself is + // deliberately left unrolled. const w = renderer.width; const h = renderer.height; - const roll = (viewport as Partial).roll ?? 0; + const roll = this.roll; if (roll !== 0) { renderer.translate(w / 2, h / 2); renderer.rotate(roll); diff --git a/packages/examples/src/examples/afterBurner/types.ts b/packages/examples/src/examples/afterBurner/types.ts index 868e91afc..614509118 100644 --- a/packages/examples/src/examples/afterBurner/types.ts +++ b/packages/examples/src/examples/afterBurner/types.ts @@ -3,7 +3,7 @@ * * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. */ -import type { Camera3d, Mesh, Sprite, Tween } from "melonjs"; +import type { Mesh, Sprite, Tween } from "melonjs"; export interface BulletMover { sprite: Sprite; @@ -70,13 +70,3 @@ export interface ContrailNode { ageMs: number; startScale: number; } - -/** - * Camera3d augmented with an ad-hoc `roll` field set by GameController - * and read by SkyboxStage to rotate the horizon. Engine-side Camera3d - * doesn't model roll as a first-class rotation axis yet (pitch + yaw - * only); we hang the value on the camera object via this typed view so - * both producer and consumer agree on the contract without spreading - * `(camera as Camera3d & { roll: number })` casts through the code. - */ -export type Camera3dWithRoll = Camera3d & { roll: number }; diff --git a/packages/melonjs/skills/melonjs-performance/SKILL.md b/packages/melonjs/skills/melonjs-performance/SKILL.md index ec66d6f0b..6cf5bbe0b 100644 --- a/packages/melonjs/skills/melonjs-performance/SKILL.md +++ b/packages/melonjs/skills/melonjs-performance/SKILL.md @@ -81,7 +81,10 @@ Two rules that cause subtle bugs when missed: successfully recycled object never sees it. Pair event subscriptions with `onActivateEvent` / `onDeactivateEvent` instead, or you leak handlers. -Engine classes are poolable too: `pool.pull("me.Tween", target)`. +Engine classes are poolable too: `pool.pull("Tween", target)`. The registered +names carry no `me.` prefix — `Entity`, `Collectable`, `Trigger`, `Light2d`, +`Particle`, `Sprite`, `NineSliceSprite`, `Renderable`, `Text`, `BitmapText`, +`ImageLayer`, `Tween`, `ColorLayer`. ## `scale()` is multiplicative From 7905473e6b0a86dcd3ec01e4afc30b5fa1b10961 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 14 Sep 2026 19:27:34 +0800 Subject: [PATCH 5/5] audio tests: satisfy the lint gate Belongs with the fade fix two commits back; kept separate rather than rewriting a pushed branch. - drop a `settle()` helper that became dead when the tests moved to driving `fade` on an unplayed clip - give the promise executors block bodies: `new Promise((r) => setTimeout(r, ms))` returns the timer id out of the executor, which `no-promise-executor-return` and `arrow-body-style` both reject Caught by CI because I ran biome rather than the full `pnpm lint`, which is the gate that runs eslint. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/melonjs/tests/audio.spec.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/melonjs/tests/audio.spec.js b/packages/melonjs/tests/audio.spec.js index c6171a73d..9c7802d4d 100644 --- a/packages/melonjs/tests/audio.spec.js +++ b/packages/melonjs/tests/audio.spec.js @@ -67,9 +67,6 @@ const loadClip = (name, durationSec) => { }); }; -/** let `play()`'s `_playLock` clear, so a fade applies instead of queueing */ -const settle = () => new Promise((r) => setTimeout(r, 60)); - describe("audio", () => { it("should export init function", () => { expect(typeof audio.init).toBe("function"); @@ -764,7 +761,9 @@ describe("audio", () => { audio.fade("fade-equal", 0.3, 0.3, 200, id); expect(voice._volume).toBeCloseTo(0.3, 5); expect(voice._interval).toBeUndefined(); - await new Promise((r) => setTimeout(r, 60)); + await new Promise((r) => { + setTimeout(r, 60); + }); expect(Number.isFinite(voice._volume)).toBe(true); audio.unload("fade-equal"); }); @@ -774,7 +773,9 @@ describe("audio", () => { const { id, voice } = await voiceOf("fade-real"); audio.fade("fade-real", 1, 0, 60, id); expect(voice._interval).toBeDefined(); - await new Promise((r) => setTimeout(r, 200)); + await new Promise((r) => { + setTimeout(r, 200); + }); expect(Number.isFinite(voice._volume)).toBe(true); expect(voice._volume).toBeCloseTo(0, 1); audio.unload("fade-real");