From ea0fcfd077292c223dbd30a2612b51c81f85f4ea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:11:55 +0000 Subject: [PATCH 1/2] Skills: NineSliceSprite in the UI skill, and open 20.9.0 The UI skill's description listed NineSliceSprite panels among what it covers, but the body never mentioned them. It now shows both ways to make one (createSpriteFromName with the nineSlice flag, as the UI example's draggable panel does, and the constructor, as the Text example's dialogue box does) and the three things that trip people up: width/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. melonjs and .claude-plugin/plugin.json move to 20.9.0 together, with an unreleased 20.9.0 section in the changelog. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0146cnBA4Z2zLzWYa3wjnYaW --- .claude-plugin/plugin.json | 2 +- packages/melonjs/CHANGELOG.md | 5 ++ packages/melonjs/package.json | 2 +- .../skills/melonjs-ui-and-text/SKILL.md | 51 +++++++++++++++++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 0d790195d..2742c1a29 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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", diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index a2a225330..dd3d06223 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -1,5 +1,10 @@ # 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 + ## [20.8.0] (melonJS 2) - _2026-10-06_ ### Added diff --git a/packages/melonjs/package.json b/packages/melonjs/package.json index adca8522c..e9cfabebc 100644 --- a/packages/melonjs/package.json +++ b/packages/melonjs/package.json @@ -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", diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index e0c1a7a89..88700e7f9 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -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 From d569e3b7e7e71b36dc3115669fa64d8589195e63 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:45:31 +0000 Subject: [PATCH 2/2] Skills: the audio skill covers positional sound, master-bus effects and the rest of the API Checked against every export of the audio module and the vendored backend: - the listener is fixed at the origin and has no public setter, so positions must be passed relative to the player - the panner defaults (inverse, refDistance 1) assume metres: a source 100 px away plays at ~1%, so set refDistance in pixels or scale down - stereo() and position() share one per-instance panner; whichever runs first fixes its type and the other silently does nothing - reverb / echo / compressor on the whole mix: insert after getMasterGain(); streamed clips bypass the master gain, and per-clip effects are not reachable - hasAudio(), hasFormat(), runtime audio.load(), rate and fade ranges - four new symptom-table rows, and triggers for effects and listener Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0146cnBA4Z2zLzWYa3wjnYaW --- packages/melonjs/CHANGELOG.md | 1 + .../melonjs/skills/melonjs-audio/SKILL.md | 88 ++++++++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index dd3d06223..08587b2f4 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -4,6 +4,7 @@ ### 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_ diff --git a/packages/melonjs/skills/melonjs-audio/SKILL.md b/packages/melonjs/skills/melonjs-audio/SKILL.md index 2abca19fb..0e98eaf0f 100644 --- a/packages/melonjs/skills/melonjs-audio/SKILL.md +++ b/packages/melonjs/skills/melonjs-audio/SKILL.md @@ -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 --- @@ -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`, @@ -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 @@ -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 @@ -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 `