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
1 change: 1 addition & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
### Fixed
- Mesh: `alpha = 0` painted the mesh **opaque black** instead of hiding it, on both GPU backends. `CanvasRenderer.drawMesh` has always skipped when the global alpha falls below `1/255` — the same guard eight other Canvas draw methods use — but neither GPU renderer had it, and the mesh path disables blending (`MeshBatcher.bind`), so the alpha never reached the blend stage: the shader multiplied the colour by zero and wrote the result opaque. Hiding a mesh with `alpha = 0` left a black silhouette of it, and the same property behaved differently per backend. Both GPU renderers now skip at the same threshold
- Color: `toUint32()` returned a **negative** number for any colour with alpha at or above 0.5. The packing used `|`, which yields a signed int32, so a method named `toUint32` — documented as returning "a Uint32 ARGB representation" — handed back e.g. `-16711936` for green. Every consumer inside the engine writes it into a `Uint32Array` or a shader attribute where the bit pattern is identical, so nothing rendered wrong; what broke was reading the value back, comparing it, or printing it. The four unit tests covering this had the correct expectations commented out and the signed values asserted instead
- Container: a `floating` child in a depth-sorted world was ordered by its **screen** position. `Container.draw` gives a floating child `resetTransform()` and the camera's screen projection, so its `pos.x/y` are canvas pixels — but `_sortDepth` fed those to a world-space distance and subtracted the camera position on top. Two consequences, both visible under a `Camera3d`: a HUD's layering depended on where it sat on the screen (a score in a corner scored `20² + 16²` and floated above the scene, while the same text centred scored `512² + 200²` and sank behind it), and it drifted as the camera travelled, so a HUD that was correct at the start of a level was buried by the end of it. A floating child is now ordered by `|pos.z|` alone — a small depth draws in front of the world, a large one behind it — which is the convention screen-space content already used (a HUD at -150, a sky backdrop at -10000 or 100000), now holding at any camera position and from anywhere on the screen rather than by luck of the numbers

## [20.3.0] (melonJS 2) - _2026-08-31_

Expand Down
16 changes: 16 additions & 0 deletions packages/melonjs/skills/melonjs-3d/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,21 @@ index** — a real world depth in a 3D scene, and never the one you wanted. Pass
it (`world.addChild(mesh, z)`), or set `mesh.depth` afterwards. The glTF
importer turns `autoDepth` off on the container it loads into for this reason.

**`floating` does not opt out of this sort.** It skips the camera *transform*,
not the depth *order*. A floating child is ordered by `|pos.z|` alone — its
`pos.x/y` are screen pixels, and the camera does not move relative to it — so
the magnitude is the distance and the sign is ignored:

```js
world.addChild(hud, -150); // small -> nearer than anything -> on top
world.addChild(skybox, -10000); // large -> farther -> behind everything
world.addChild(skybox, 100000); // equally far: sign does not matter
```

Both hold at any camera position. A HUD given the huge z that would put it on
top in 2D lands at the far end of the level instead, with the scenery drawing
over it.

## Meshes

```js
Expand Down Expand Up @@ -296,6 +311,7 @@ To branch rather than fail, read `app.renderer.supportsDepthBuffer` after
| distant surfaces z-fight | `near` too small for the scene scale |
| black canvas under `Camera3d` | Canvas renderer (no depth buffer) — check the `console.warn` |
| everything flat and unlit | `lit: true` with no `Light3d` in the world (falls back to fullbright), or a mesh under a 2D camera |
| a `floating` HUD draws behind the scenery | a large \|z\| is *far* under `Camera3d` — use a small depth |
| a mesh sits at the wrong depth after being added | `autoDepth` overwrote `pos.z` with the child index — pass `addChild(mesh, z)` |
| a mesh sits half its size off | `anchorPoint` — only on the 2D-camera path; a `Camera3d` mesh pivots on its model origin |
| a billboard tips over when the camera looks down | `"spherical"`, or a mistyped mode string falling through to it — use `true` / `"cylindrical"` |
Expand Down
144 changes: 17 additions & 127 deletions packages/melonjs/skills/melonjs-ui-and-text/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,137 +39,27 @@ Three things matter here:
real accessor is `depth` (an alias for `pos.z`); `addChild(child, z)` sets it
for you.

Relayout on resize by listening for `event.CANVAS_ONRESIZE`.
### In a 3D scene, a HUD needs a SMALL depth

## Buttons and interactive elements
`floating` opts a renderable out of the camera transform. It does **not** opt it
out of the depth sort, and the two sorts read z differently:

Use the built-ins rather than hand-rolling — they set `isKinematic = false` for
you, which is the trap that stops hand-rolled buttons receiving clicks at all.
| container `sortOn` | ordered by | on top |
| --- | --- | --- |
| `"z"` (default, 2D) | `pos.z` | **highest** z |
| `"depth"` (what `Camera3d` sets) | distance from the camera | **nearest** the camera |

```js
class PlayButton extends UISpriteElement {
constructor(x, y) {
super(x, y, { image: atlas, region: "play.png" });
}
onClick() { state.change(state.PLAY); return false; }
onOver() { this.setOpacity(1.0); }
onOut() { this.setOpacity(0.8); }
onRelease() { return false; }
}
```

| class | for |
|---|---|
| `UIBaseElement` | a `Container` base — clickable, optionally draggable and holdable |
| `UISpriteElement` | a `Sprite`-backed button with hover/press callbacks |
| `UITextButton` | a `BitmapText` label on a `RoundRect` background — it needs a **bitmap** font, not a `fontface` |
| `Draggable` / `DropTarget` | drag-and-drop; `dropTarget.setCheckMethod(dropTarget.CHECKMETHOD_CONTAINS)` to require containment instead of overlap |

The callbacks to override are `onClick`, `onOver`, `onOut`, `onRelease` and
`onHold`; `UIBaseElement` adds `onMove` while dragging. Returning `false` from
`onClick` / `onRelease` stops the event propagating further.

## Text

```js
new Text(x, y, {
font: "Arial",
size: 32,
fillStyle: "#FFFFFF",
textAlign: "center",
textBaseline: "middle",
text: "Score: 0",
wordWrapWidth: 400, // enables wrapping
});
```

`text` accepts a string or an array of lines. Update with `setText()`.

**Custom web fonts must be preloaded** with the `"fontface"` asset type:

```js
{ name: "PressStart2P", type: "fontface", src: "data/font/PressStart2P.ttf" }
```

Drawing before the font has loaded silently renders in a fallback font — the
layout looks subtly wrong rather than failing.

## `BitmapText`

For pixel-perfect text that scales without antialiasing, and for text drawn in
volume: every `BitmapText` sharing a font draws glyph quads from that one page
image, so they batch together. Each `Text` instead owns a private canvas
texture (re-rasterised whenever it changes), so a screenful of them is a
screenful of distinct textures.
Under `"depth"` a floating child is ordered by `|pos.z|` alone — its `pos.x/y`
are screen pixels, not a place in the world, and the camera does not move
relative to it. So the *magnitude* is the distance, and the sign is ignored:

```js
await loader.preload([
{ name: "PressStart2P", type: "image", src: "data/font/font.png" },
{ name: "PressStart2P", type: "binary", src: "data/font/font.fnt" },
]);

new BitmapText(x, y, {
font: "PressStart2P",
size: 2, // a scale ratio, not a pixel size
text: "GAME OVER",
});
```

It needs **both** assets, and by default they must share the **same asset
name** — `settings.font` resolves the image *and*, unless you pass
`settings.fontData`, the descriptor. Registering the descriptor under a
different name (`"…-fnt"`) is the usual mistake; either use one name for both,
or pass `fontData: "PressStart2P-fnt"` explicitly.

The descriptor is AngelCode BMFont in either flavour — the text `.fnt` form or
the XML form — auto-detected, so an `.xml` export loads as-is.

## Never draw text through the raw context

```js
// ✗ works on Canvas only — getContext() returns the GL/GPU context on the
// GPU backends, and neither has fillText
app.renderer.getContext().fillText("hi", 10, 10);

// ✓
world.addChild(new Text(10, 10, { text: "hi", /* … */ }));
```

`getContext()` hands back the *backend's* context — a
`CanvasRenderingContext2D` only under the Canvas renderer. On WebGL or WebGPU
the call throws `TypeError: … .fillText is not a function`, so this fails loudly
— but only on the machines that picked a GPU backend, which under `video.AUTO`
is most of them and probably not yours.

## Panels

`NineSliceSprite` stretches a panel without distorting its corners — the right
tool for dialogue boxes and windows:

```js
new NineSliceSprite(x, y, {
image: "panel", width: 300, height: 120, insetx: 12, insety: 12,
});
world.addChild(hud, -150); // small -> in front of the whole scene
world.addChild(backdrop, -10000); // large -> behind the whole scene
world.addChild(backdrop, 100000); // equally far: sign does not matter
```

The inset keys are lowercase `insetx` / `insety`; `insetX` is silently ignored
and the corners fall back to a quarter of the frame. `width` and `height` are
mandatory — the constructor throws without them.

## Symptom → cause

| symptom | cause |
|---|---|
| HUD scrolls away with the camera | missing `floating = true` on the container |
| HUD drawn under the game | `this.z = …` instead of `addChild(hud, z)` |
| hand-rolled button never responds | `isKinematic` left `true` — use `UISpriteElement` |
| text renders in the wrong font | web font not preloaded as `"fontface"` |
| `BitmapText` renders nothing | the `.fnt` / image pair was loaded under two different asset names |
| `TypeError: … .fillText is not a function` | drawn via `getContext()` under a GPU backend |
| `UIContainer is not defined` | no such class — use `Container` or `UIBaseElement` |
| UI misplaced after a window resize | no `CANVAS_ONRESIZE` relayout |

## Related skills

- `melonjs-input` — the `isKinematic` requirement in full
- `melonjs-renderables` — `floating`, draw order, containers
Give a HUD the huge z that would put it on top in 2D and it lands at the far end
of the level instead, with the scenery drawing over it. Both shipped idioms are
the same rule: afterBurner's HUD sits at `-150`, and the glTF, Billboard, Night
City and Instanced Forest examples park a floating sky at `-10000` or `100000`.
44 changes: 37 additions & 7 deletions packages/melonjs/src/renderable/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,42 @@ function captureDepthCamera() {
}
}

/**
* Painter-sort key: squared distance from the camera, so a smaller key
* is nearer. `draw` walks children backwards, so index 0 is drawn last
* and the nearest child lands on top.
*
* A `floating` child is measured differently, because its `pos` is not
* a world position: `draw` resets the transform and swaps in the
* camera's screen projection for it, making `pos.x/y` pixels on the
* canvas. Feeding those to a world-space distance makes a HUD's
* layering depend on where it sits on the screen — a centred banner
* scores `512² + 200²` against the scene and sinks behind it while the
* same text in a corner floats on top — and subtracting the camera
* position makes it drift as the camera travels. Neither is meaningful
* for something pinned to the screen, so only `pos.z` orders it.
*
* What remains is the magnitude: `|pos.z|` is how far in front of the
* scene the overlay sits, so a small depth draws on top of the world and
* a large one draws behind it. That is the convention the screen-space
* idioms already use — a HUD at -150, a sky backdrop at -10000 or
* 100000 — and dropping the screen coordinates and the camera position
* makes it hold at any camera position instead of by luck of the
* numbers. The sign is not meaningful here and is ignored: a backdrop
* parked at a large negative depth is as far away as one parked at the
* same positive depth.
* @ignore
*/
function depthKey(r) {
if (r.floating === true) {
return r.pos.z * r.pos.z;
}
const x = r.pos.x + _depthOffsetX - _depthCamX;
const y = r.pos.y + _depthOffsetY - _depthCamY;
const z = r.pos.z + _depthOffsetZ - _depthCamZ;
return x * x + y * y + z * z;
}

/**
* Capture the world-space position of the given container into the
* module-level `_depthOffset*` triple. Called once per sort by `sort` /
Expand Down Expand Up @@ -1073,13 +1109,7 @@ export default class Container extends Renderable {
// between "particles all sort identically because their
// local d²=0" and "particles sort by their actual world
// distance from the camera".
const ax = a.pos.x + _depthOffsetX - _depthCamX;
const ay = a.pos.y + _depthOffsetY - _depthCamY;
const az = a.pos.z + _depthOffsetZ - _depthCamZ;
const bx = b.pos.x + _depthOffsetX - _depthCamX;
const by = b.pos.y + _depthOffsetY - _depthCamY;
const bz = b.pos.z + _depthOffsetZ - _depthCamZ;
return ax * ax + ay * ay + az * az - (bx * bx + by * by + bz * bz);
return depthKey(a) - depthKey(b);
}

/**
Expand Down
Loading
Loading