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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "melonjs",
"version": "20.8.0",
"version": "20.9.0",
"description": "Build games with melonJS — 23 guides to the 2D, 2.5D and 3D HTML5 game engine, its conventions and idioms, so generated code runs the first time.",
"author": {
"name": "melonJS",
Expand Down
6 changes: 6 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [20.9.0] (melonJS 2) - _unreleased_

### Fixed
- Skills: the UI skill listed `NineSliceSprite` panels among what it covers but never mentioned them. It now shows both ways to make one (`createSpriteFromName(name, { width, height }, true)` from an atlas, or the constructor from an image) and the three things that trip people up: `width` and `height` are the size to stretch to and are mandatory, `insetx` / `insety` default to a quarter of the image rather than its border, and the anchor is the centre
- Skills: the audio skill now covers what positional sound needs and did not say: the listener is fixed at the origin with no public call to move it, so positions are relative to the player; the panner defaults assume metres, so a sound placed in pixels is near silent a few tiles out (`refDistance: 1` puts one 100 px away at about 1%); and `stereo()` and `position()` drive the same per-instance panner, so whichever runs first makes the other a silent no-op. It also shows how to put reverb, echo or a compressor on the whole mix through `getMasterGain()`, noting that a streamed clip bypasses it, and documents `hasAudio()`, `hasFormat()`, runtime `audio.load()`, and the `rate` and `fade` ranges

## [20.8.0] (melonJS 2) - _2026-10-06_

### Added
Expand Down
2 changes: 1 addition & 1 deletion packages/melonjs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "melonjs",
"version": "20.8.0",
"version": "20.9.0",
"description": "melonJS Game Engine",
"homepage": "http://www.melonjs.org/",
"type": "module",
Expand Down
88 changes: 86 additions & 2 deletions packages/melonjs/skills/melonjs-audio/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: melonjs-audio
description: "Use this skill for sound and music in melonJS — loading audio, playing effects, background tracks, audio sprites, volume and muting, spatial audio, and procedural tone/noise generation. Covers the mandatory audio.init format list, the directory-not-file src convention, playTrack versus play, and browser autoplay unlocking. Triggers on: audio, sound, audio.init, audio.play, playTrack, stopTrack, audio.load, sfx, music, volume, mute, sprite audio, stereo, panner, audio.tone, audio.noise, getAudioContext, autoplay."
description: "Use this skill for sound and music in melonJS — loading audio, playing effects, background tracks, audio sprites, volume and muting, spatial audio, procedural tone/noise generation, and effects such as reverb or a compressor on the whole mix. Covers the mandatory audio.init format list, the directory-not-file src convention, playTrack versus play, the fixed listener and pixel-scale distances of positional sound, and browser autoplay unlocking. Triggers on: audio, sound, audio.init, audio.play, playTrack, stopTrack, audio.load, sfx, music, volume, mute, fade, rate, pitch, sprite audio, stereo, panner, position, listener, refDistance, reverb, echo, delay, compressor, distortion, filter, audio effect, audio.tone, audio.noise, getAudioContext, getMasterGain, hasAudio, hasFormat, autoplay."
license: MIT
---

Expand Down Expand Up @@ -29,6 +29,10 @@ await loader.preload([
gives you a 404. A `data:audio/...` URL is the one exception: it is used
verbatim, prefix and extension skipped.

`audio.hasAudio()` says whether the browser can play audio at all, and
`audio.hasFormat("ogg")` whether it can play one codec — useful for choosing
the `init` list or hiding a sound toggle on a device with no audio.

Order matters — melonJS tries the listed formats left to right, so put the
preferred one first. Two formats (`"webm,mp3"` or `"mp3,ogg"`) is the usual
belt-and-braces for codec coverage. Accepted tokens: `mp3`, `mpeg`, `opus`,
Expand Down Expand Up @@ -88,7 +92,9 @@ audio.state("theme"); // "unloaded" | "loading" | "loaded"
`stop()`, `pause()` and `resume()` take an optional instance `id` too; omit it
and the whole group is affected. `audio.stop()` with no arguments at all stops
every sound. `audio.seek(name)` reads the position, `audio.seek(name, s, id?)`
writes it; `audio.rate` and `audio.fade(name, from, to, ms, id?)` round it out.
writes it; `audio.rate(name, r, id?)` changes speed and pitch together
(`0.5`..`4.0`, `1` is normal) and `audio.fade(name, from, to, ms, id?)` ramps
the volume (`0`..`1`, over milliseconds).

## Volume and muting

Expand Down Expand Up @@ -120,6 +126,40 @@ Called with just the clip name, each of the four returns the current value —
`stereo` gives `0` and `position` gives `[0, 0, 0]` before they have ever been
set.

### The listener is fixed at the origin

The ear sits at `(0, 0, 0)` facing `-z`, and **there is no public call to move
it**. Positions are therefore *relative to the listener*: for a sound to come
from the left of the player, pass the source's position minus the player's,
every frame either one moves.

```js
// in the emitter's update()
audio.position("engine", this.pos.x - player.pos.x, 0, -0.5, this.engineId);
```

### Distances are in the units you pass — and the defaults assume metres

The panner defaults are `distanceModel: "inverse"`, `refDistance: 1`,
`rolloffFactor: 1`, `maxDistance: 10000`, `panningModel: "HRTF"`. With
`refDistance: 1`, a source 100 units away plays at about **1%** volume, so a
sound positioned in raw pixels is effectively silent a few tiles out. Either
set `refDistance` to the distance in pixels at which it should still be at full
volume, or scale positions down (divide by a tile size) before passing them:

```js
audio.panner("engine", { refDistance: 200, rolloffFactor: 1 });
// or: audio.position("engine", dx / 32, 0, -0.5);
```

### `stereo` and `position` are one or the other

Both drive the same per-instance panner node, and **the first one called
decides what that node is**: a stereo panner after `stereo()`, a 3D panner after
`position()`. After that the other call silently does nothing on that instance.
For a 2D game, left-right `stereo()` is usually all you want; reach for
`position()` only when distance falloff matters, and then use it alone.

**Spatial audio is WebAudio-only.** On a clip loaded with `stream: true` (or
`html5: true`) `stereo` / `position` / `orientation` return early and do
nothing — which is exactly the case for the long music tracks people reach for
Expand Down Expand Up @@ -167,6 +207,42 @@ custom Web Audio work; both return `null` when audio is unavailable, so guard.
Connect a custom graph to the master gain rather than `ctx.destination` if you
want it to respect `setVolume` / `muteAll`.

## Effects on all game audio: reverb, echo, compressor

There is no built-in reverb, delay, distortion or compressor, and no per-clip
filter on file playback. There does not need to be one for the common case:
every buffered clip and every `tone` / `noise` passes through the master gain
on its way to the speakers, so an effect inserted **after** it applies to the
whole mix.

```js
const ctx = audio.getAudioContext(); // creates the context if needed
const master = audio.getMasterGain();
if (ctx && master) { // null when audio is unavailable
const comp = ctx.createDynamicsCompressor();
master.disconnect(); // master -> speakers becomes
master.connect(comp); // master -> compressor -> speakers
comp.connect(ctx.destination);
}
```

Any Web Audio node or chain works the same way: a `ConvolverNode` with an
impulse response for reverb, a `DelayNode` with a feedback gain for echo, a
`BiquadFilterNode` for an underwater low-pass, a `WaveShaperNode` for
distortion. Do it once, at startup; the master gain lives as long as the
context.

- **`setVolume` / `muteAll` still work**, because they act on the master gain,
which is now upstream of the effect.
- **Streamed clips are not affected.** A `stream: true` (`html5: true`) clip
plays through an `<audio>` element and never reaches the master gain — which
is usually the music. Preload the music buffered if it must go through the
effect.
- **It is all or nothing.** The per-instance nodes are not public, so an effect
on one clip only (muffling one sound behind a wall) is not possible through
this route; `noise` has its own `filter` for generated sounds.
- To remove it, reverse the wiring: `master.disconnect(); master.connect(ctx.destination);`.

## Autoplay: sound is silent until the first gesture

Browsers create the audio context in the `suspended` state and keep it there
Expand Down Expand Up @@ -209,6 +285,10 @@ same switch under its backend name.) `on` accepts the full lifecycle set:
`play`, `pause`, `stop`, `end`, `fade`, `seek`, `rate`, `volume`, `mute`,
`unlock`.

Outside a preload, `audio.load(asset, onload?, onerror?)` takes the same
descriptor and loads one clip at runtime — for a level's sounds fetched when
the level starts. The loader calls the same function, so the same rules apply.

Re-preloading a manifest that already contains a loaded clip is a no-op —
`audio.unload(name)` first if you genuinely want to reload it.

Expand Down Expand Up @@ -247,6 +327,10 @@ either way — blank screen, empty console.
| a streamed clip ignores `withCredentials` | `stream: true` plays through an `<audio>` element; preload it buffered instead |
| `audio.tone(440, {...})` does nothing / errors | `tone` takes a single options object with a required `duration` |
| `tone` / `noise` are silent with no error | no WebAudio context — `getAudioContext()` returned `null` |
| a positioned sound is silent, or only audible right next to the player | positions in pixels with the default `refDistance: 1` — set `refDistance` in pixels, or scale positions down |
| a positioned sound does not follow the player | the listener never moves — pass positions relative to the player, every frame |
| `position()` does nothing on a clip that pans with `stereo()` (or the reverse) | the first call fixed the instance's panner type — use one or the other |
| a reverb / compressor on the master gain does not affect the music | the music is `stream: true`, which bypasses the master gain — preload it buffered |

## Related skills

Expand Down
51 changes: 51 additions & 0 deletions packages/melonjs/skills/melonjs-ui-and-text/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,57 @@ Giving a child a huge `z` to "put it in front" lifts it only within its own
panel. This is also why a widget's `z` never has to be coordinated with
anything outside its own container.

### Panels and boxes that stretch: `NineSliceSprite`

A panel, dialogue box or button background drawn from one small image wants
`NineSliceSprite`, not a `Sprite` scaled up. It cuts the image into a 3×3 grid,
keeps the four corners at their own size, and stretches only the edges and the
middle, so the border stays crisp at any size. Do not hand-roll it from nine
sprites.

From a texture atlas, the third argument of `createSpriteFromName` asks for the
9-slice version. This is how the UI example's draggable panel draws itself:

```js
class Panel extends UIBaseElement {
constructor(x, y, width, height) {
super(x, y, width, height);
// `true` returns a NineSliceSprite stretched to the panel's size
this.addChild(
texture.createSpriteFromName("grey_panel", { width, height }, true),
);
}
}
```

From a loaded image, construct it directly (the Text example's dialogue box):

```js
const box = new NineSliceSprite(48, 640, {
image: "panel",
width: 900,
height: 256,
insetx: 36, // the border thickness in the source image
insety: 36,
tint: "#3a3f58", // tint works as on any sprite
});
box.anchorPoint.set(0, 0); // place it by its top-left corner
```

Three things trip people up:

- **`width` and `height` are mandatory.** They are the size to stretch *to*, not
the image's size, and the constructor throws without them.
- **Set `insetx` / `insety` to the art's border.** Left out, each defaults to a
quarter of the source image, which on most panel art cuts through the border:
the corners smear or the border thins out when the box grows.
- **The anchor is the centre**, as for any `Sprite`. Set it to `(0, 0)` to place
the box by its corner, as a layout usually wants.

It resizes live: set `width` / `height` and the next draw re-slices, so a box
that grows with its text, or a panel the player drags larger, needs no new
sprite.

### An opaque panel has to say so

The hit test asks the topmost renderable first and then keeps walking down, so
Expand Down
Loading