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/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 65eaa5e3d..cd9ee5ec1 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -9,11 +9,15 @@ - `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)) - 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/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/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 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/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/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 diff --git a/packages/melonjs/tests/audio.spec.js b/packages/melonjs/tests/audio.spec.js index e2f3dedf9..9c7802d4d 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(); @@ -714,5 +715,71 @@ 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"); + }); + }); }); }); 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); + } + }); + }); });