Skip to content

Camera roll, a degenerate-fade audio fix, and public Gradient rasterisation - #1665

Merged
obiot merged 5 commits into
masterfrom
fix/engine-batch-20-5
Sep 14, 2026
Merged

obiot merged 5 commits into
masterfrom
fix/engine-batch-20-5

Conversation

@obiot

@obiot obiot commented Sep 14, 2026

Copy link
Copy Markdown
Member

Four independent changes, one per commit, so they can be reviewed (or dropped) separately.

audio: a degenerate fade no longer poisons the volume with NaN

Reported: audio.fade("sound", 0.5, 0.5, 0) leaves the volume NaN, surfacing later as a non-finite setValueAtTime on the next playback. Two faults in the interval the fade starts:

  • the per-tick step divides elapsed time by the duration, so len = 0 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 immediately and starts no interval. Four regression tests; removing the guard fails three of them (volume stuck at 1, live interval handles left running) while the "a genuine fade still starts an interval" control passes either way.

Camera: roll

A 3D camera could pitch and yaw but not bank. Camera2d.rotation is inherited but never read by the 3D view, so camera.rotate() on a Camera3d was silently inert.

Camera3d composes roll into all six orientation sites — world basis as R(yaw) ∘ R(pitch) ∘ R(roll), its inverse (roll first) in the view matrix and container transform. The frustum planes come off projection × view via Gribb–Hartmann, so culling follows a banked view for free.

Camera2d gets the same name via its currentTransform — which is what a 2D camera's rotation already is, and what worldToLocal/localToWorld already undo, so picking stays correct under roll. The setter rebuilds that matrix rather than composing onto it, so a camera banked every frame cannot drift; there's a test that assigns 2000 times and asserts an exact identity at 0.

16 tests, including that the frustum genuinely follows the bank (a tall-thin camera sees a probe upright, culls it rolled).

Gradient#toCanvasGradient is public

20.5 made Gradient public for Text.fillStyle but left no supported way to rasterise one — both rasterisation methods were @internal and stripped from the .d.ts. toCanvas stays internal deliberately: it renders into a shared bake target valid only until the next call by anyone.

AfterBurner: a silently-ignored tween duration

Tween.to(properties, options) reads options.duration; the example passed a bare number, so every enemy barrel roll ran the 1000 ms default and both tuning constants were dead. Also fixes the HUD's over-loose settings type and a pool.pull("me.Tween") in melonjs-performance — a 1.x spelling for a name registered as plain "Tween".

The example deliberately does not adopt Camera3d.roll: its camera sits behind and below the ship rather than in its cockpit, so rolling the view swings the player's craft off its anchor and drags enemies around a screen-space reticle. Rolling only the painted backdrop stays.

Verification

  • full suite: 6888 passed, 9 skipped
  • pnpm -F melonjs doc: 0 errors; check-doc-readme passes
  • biome clean; pre-commit hook passed on each commit

🤖 Generated with Claude Code

https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

obiot and others added 4 commits September 14, 2026 19:18
`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
…ool name

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Copilot AI lite review requested due to automatic review settings September 14, 2026 11:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Copilot AI review requested due to automatic review settings September 14, 2026 11:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@obiot
obiot merged commit 45e61e2 into master Sep 14, 2026
6 checks passed
@obiot
obiot deleted the fix/engine-batch-20-5 branch September 14, 2026 11:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants