Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions packages/examples/src/examples/afterBurner/GameController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ import {
} from "./textures";
import type {
BulletMover,
Camera3dWithRoll,
ContrailNode,
EnemyBulletMover,
EnemyMover,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
8 changes: 7 additions & 1 deletion packages/examples/src/examples/afterBurner/HUD.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,13 @@ export class HUD {
app: Application,
x: number,
y: number,
settings: ConstructorParameters<typeof Text>[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<ConstructorParameters<typeof Text>[2], "font"> & {
font?: string;
},
): Text {
const t = new Text(x, y, {
font: "Courier New",
Expand Down
12 changes: 12 additions & 0 deletions packages/examples/src/examples/afterBurner/SkyboxStage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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<Camera3dWithRoll>).roll ?? 0;
const roll = this.roll;
if (roll !== 0) {
renderer.translate(w / 2, h / 2);
renderer.rotate(roll);
Expand Down
12 changes: 1 addition & 11 deletions packages/examples/src/examples/afterBurner/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
4 changes: 4 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@
- `save`: registered keys are reachable from TypeScript without a cast. The namespace was typed `Record<string, unknown>`, 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`
Expand Down
12 changes: 9 additions & 3 deletions packages/melonjs/skills/melonjs-3d/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/melonjs/skills/melonjs-performance/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 22 additions & 8 deletions packages/melonjs/src/audio/backend/sound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
54 changes: 54 additions & 0 deletions packages/melonjs/src/camera/camera2d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading