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
3 changes: 3 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
- **`getPool("text")` and `getPool("colorLayer")`**: typed pools for the two renderables whose only recycling route was the legacy name-keyed pool. `getPool("text").get(x, y, settings)` hands back a label with its measurement cache already allocated, and `release()` gives it back
- **Gamepad vibration**: `input.vibrateGamepad(index, options)` plays a rumble effect, `input.stopGamepadVibration(index)` cuts a long one short, and `input.hasGamepadVibration(index)` reports whether a pad has motors at all, for an options screen that should not offer a setting the hardware cannot honour. Safe to call blind: an empty slot, a pad without motors and a browser with no Gamepad API all resolve `false` and do nothing, so game code never branches on support. Magnitudes default to FULL, unlike the underlying web API where every parameter defaults to zero and a bare `playEffect` is a silent no-op. A second call preempts the first on the same pad, which is what makes a rapid series of hits read as a rapid series of hits rather than a queue
- **`input.activeGamepad()`**: the index of the pad the player is actually holding, which is not something the browser reports. Slots are handed out in plug order, so index 0 is a guess: a wheel, a spare pad or a keyboard-shaped device left plugged in can hold slot 0 for a whole session while the player presses buttons on slot 1. This follows the last pad to produce a BOUND input, falling back to the first connected pad and then to -1 when there is none, and it is what a one-player game should pass to `vibrateGamepad`, since rumbling the wrong pad is both useless and impossible to notice in testing. It follows bound inputs only, which is what makes it an answer about the player rather than about electrical noise: a worn stick at rest reports movement for ever and would otherwise claim to be the active pad. Bindings are still per index, so a game that wants any pad to drive it binds the slots it will accept to the same actions, and a bound index with no pad in it costs nothing in the poll
- **`UIPanel`**: a rectangular surface with a background, an optional border and children, which is what every dialog, menu card and HUD frame starts by writing by hand. One example in this repository had three of them, each repeating the same half dozen renderer calls and the same correction for the way `setColor` folds a colour's own alpha into the global one, which is the part that is easy to leave out and does not fail when you do, it just renders the panel at the wrong opacity. It is a `UIBaseElement`, so children are positioned relative to the panel and are asked about a click before it is, and `blocking` (on by default) decides whether a click that misses every child is swallowed rather than reaching the game underneath. `bobAmplitude` and `bobPeriod` give it the slow float an idle dialogue or pause panel has in a lot of games, which is there so a screen waiting for input does not look frozen: the panel is clickable where it is drawn, because the offset goes into `getAbsolutePosition()`, which is what the bounds pipeline reads, so the hit area of the panel and of every widget inside it follows the float at any depth, while `pos` is left alone so a caller still reads and writes the resting place rather than a value that moves under them. It is fed from `update(dt)` rather than a wall clock, so it obeys the pause like everything else, and a panel that should keep moving while the game is paused, which is what a pause menu is, sets `updateWhenPaused`. Where it lands in the paint order is the depth it is added at, and in a world sorted on `"depth"`, which is every `Camera3d` scene, a floating child is ranked by the magnitude of its `pos.z` with the SMALLER one drawn on top, so a dialog there wants a depth of 0 or 1 rather than the 2D habit of 9000 ([#1722](https://github.com/melonjs/melonJS/issues/1722))
- **`createPool` is exported**, along with the `Pool` and `CreatePoolOptions` types it names, so a pool for one of your own classes no longer needs a deep import and a field holding one can be declared. `getPool()` only reaches the pools the engine ships, so this is what a game moving off `pool.register(name, Class, true)` needs

### Changed
Expand All @@ -40,6 +41,8 @@
- Input: the gamepad axis `threshold` is now documented as what it is, a value ADDED to the deadzone rather than one used instead of it. The test is `abs(value) >= deadzone + abs(threshold)`, so under the default deadzone of 0.1 the figure of `0.5` in the documented example does not fire until 0.6 of full travel, and raising `setGamepadDeadzone` moves every axis binding with it. The behaviour is unchanged: what a game should pass is the point it wants the stick to break at, minus the deadzone
- Audio: a page left idle for thirty seconds before its first click could lose every loaded clip for the life of the page while `tone()` and `noise()` still played. The idle power-saving suspend waited for the browser to confirm it before the engine would resume anything, and WebKit never confirms a suspend asked for outside a user gesture on a context the page was never allowed to start. The first click then started the hardware through the unlock path, which is why generated sound was heard, but the engine still believed its suspend was in flight and parked each clip's playback waiting for a resume that could not come. A resume requested while the suspend is in flight is now issued at once, and the browser orders it after the suspend. A user gesture or the tab coming back to the front also revives playback whenever the engine and the context disagree about whether it is running
- Audio: a sound's `onend` callback could fire for a DIFFERENT sound. Stopping a voice clears the callbacks bound to it, but the clear removed only the first of them and left any others attached; playback ids are handed to the next voice to take the slot, so a leftover callback outlived its own sound and then ran when an unrelated one finished. Most visible on a clip played many times with a per-play `onend`, where the callbacks accumulated
- UI: a `ProgressBar` border was drawn centred on the bar's own edge, so half of it fell outside the width and height the bar was given and painted over whatever sat next to it, and the bar's `getBounds()` did not account for those pixels. A square border is now four fills that tile the frame exactly, and a rounded one is stroked on a path pulled in by half the border width with its radius pulled in to match, so the border lands inside the rectangle either way. This also fixes a square border at the default width of 1 coming up short at all four corners, since the renderers only generate corner joins above a line width of one
- UI: destroying a `ProgressBar` twice threw `Instance is already in pool.` instead of doing nothing the second time. It handed its cached colour back to the pool without clearing the handle, and a pool refuses a double release; a renderable's `destroy` is reached more than once often enough that this was not a fault at the call site
- UI: `UITextButton.bindKey` now activates the button from an `input.KEY` keycode or an existing action name. Held keys click once, only the initiating key releases the button, and blur or removal clears the keyboard press without changing global bindings. Two things to know before binding a key to a button that already exists: the handlers fire with NO pointer, so an `onClick(event)` written when only a pointer could press it throws the first time someone uses the shortcut, and losing focus cancels a held key without calling `onRelease()`, so a button that fires while held has to stop on `event.BLUR` as well ([#1722](https://github.com/melonjs/melonJS/issues/1722), thanks @snowyukitty)
- Application: coming back to the tab resumed a game that had paused ITSELF. The focus handler undid the pause whatever had applied it, so switching away from an open pause menu and back left the run playing underneath it. Only a pause this application applied on blur is now reversed on focus, which is the rule `unmuteOnFocus` already followed. The flag recording it is raised and never cleared on the way out, because a tab switch emits `BLUR` twice, once from the window and once from `visibilitychange`, and the second one finds the state already paused by the first: clearing it there threw away the record of the pause just applied and the game never came back at all
- Camera / state: a camera effect's `onComplete` ran on every frame after the effect finished, not once when it finished. It sat in the completed branch of `update()` with nothing to say it had already fired, which went unnoticed while every completed effect was swept away on the frame it completed. A persistent one is kept for its owner, and a state transition's hide half is exactly that: its callback defers the switch and the effect is only taken away once that switch lands, so every frame in between queued another switch into the state being entered. Nothing refuses a switch to the state already current at that point, so the second one tore down the stage it had just built and built it again, which for a game means the whole scene constructed twice and every `onResetEvent` run twice. `MaskEffect` and `ShakeEffect` now fire on the transition into complete
Expand Down
91 changes: 87 additions & 4 deletions packages/melonjs/skills/melonjs-ui-and-text/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: melonjs-ui-and-text
description: "Use this skill for HUDs, buttons, menus, dialogue panels, progress bars and on-screen text in melonJS. Covers UIBaseElement/UISpriteElement/UITextButton, ProgressBar, Draggable and DropTarget, the floating screen-space container pattern, which of two overlapping panels gets the pointer, Text and BitmapText, web font loading, and NineSliceSprite panels. Triggers on: UI, HUD, button, UIBaseElement, UISpriteElement, UITextButton, ProgressBar, progress bar, health bar, gauge, Draggable, DropTarget, menu, dialogue, overlapping panels, moveToTop, onOver, onClick, bindKey, keyboard shortcut, button keyboard, Tween, easing, animate a label, wavy text, per-glyph effect, glyphEffect, shake text, rainbow text, Text, BitmapText, fillStyle, fillGradient, gradient text, tint, font, fontface, wordWrapWidth, NineSliceSprite, score display, floating."
description: "Use this skill for HUDs, buttons, menus, dialogue panels, progress bars and on-screen text in melonJS. Covers UIBaseElement/UISpriteElement/UITextButton, ProgressBar, UIPanel, Draggable and DropTarget, the floating screen-space container pattern, which of two overlapping panels gets the pointer, Text and BitmapText, web font loading, and NineSliceSprite panels. Triggers on: UI, HUD, button, UIBaseElement, UISpriteElement, UITextButton, ProgressBar, progress bar, health bar, gauge, Draggable, DropTarget, menu, dialogue, overlapping panels, moveToTop, onOver, onClick, bindKey, keyboard shortcut, button keyboard, Tween, easing, animate a label, wavy text, per-glyph effect, glyphEffect, shake text, rainbow text, Text, BitmapText, fillStyle, fillGradient, gradient text, tint, font, fontface, wordWrapWidth, NineSliceSprite, score display, floating."
license: MIT
---

Expand Down Expand Up @@ -204,9 +204,10 @@ class HUD extends Container {
app.world.addChild(new HUD(app), 100); // explicit z — not `.z = Infinity`
```

There is no `UIContainer` class. Use `Container`, or `UIBaseElement` when the
panel itself must react to the pointer — it is a `Container` that already sets
`floating = true` and `isKinematic = false`.
There is no `UIContainer` class. Use `Container` for a bare grouping,
`UIPanel` for anything with a background or a border (see below), or
`UIBaseElement` when you want the pointer handling and nothing drawn — it is a
`Container` that already sets `floating = true` and `isKinematic = false`.

Three things matter here:

Expand Down Expand Up @@ -294,6 +295,88 @@ Two more worth knowing:
- **`trackColor: null` leaves the bar hollow**, which is what a HUD over a busy
scene usually wants: the border is then the only chrome.

### Panels and cards: `UIPanel`

A dialog, a menu card, a HUD frame: a rectangle with a fill, an optional
border and children on top. `UIPanel` is a `UIBaseElement`, so it is a
container that carries pointer events.

```js
import { UIPanel, Text } from "melonjs";

const card = new UIPanel(cx - 200, cy - 120, {
width: 400, height: 240,
fillColor: "#0a1526", // null leaves it transparent
borderColor: "rgba(130, 200, 240, 0.55)", // null for none
borderWidth: 2,
radius: 14,
});
card.addChild(new Text(200, 24, { text: "PAUSED", textAlign: "center" }));
app.world.addChild(card, -150); // small depth: see the 3D section below
```

**`fillColor`, not `backgroundColor`.** `Container` already has a
`backgroundColor` and it does something else entirely: it is handed to
`clearColor` and wipes the whole render target, so a panel setting it repaints
the screen. The names here match `ProgressBarSettings`.

**The border is drawn INSIDE the box.** `width` and `height` are the outer
size, border included, so a panel never paints past the rectangle you gave it
and its `getBounds()`, which is what the pointer dispatcher hit-tests, stays
that rectangle. One thing to know with a `radius`: keep `borderWidth` at 2 or
more. A rounded border has to be stroked, and a stroke at exactly 1 goes out as
plain lines, which drops a pixel where each arc meets its straight edge. A
square border is four fills and is fine at 1.

**`blocking` (default `true`)** makes the panel consume clicks and hovers that
miss its children, which is what a dialog wants — a click on the card, or on
the dimmed scene behind it, must not reach the game. Children are unaffected:
the dispatcher asks a child before the container holding it. Set it `false` for
a frame drawn over a playfield that still has to receive clicks.

A full-screen panel with a translucent `fillColor` and `borderColor: null` is
the veil behind a modal, and it blocks for free.

**`bobAmplitude` / `bobPeriod`** give the panel the slow float an idle
dialogue or pause panel usually has, so a screen waiting for input does not
look frozen:

```js
card.bobAmplitude = 3; // pixels either side; 2 to 4 is the usual range
card.bobPeriod = 2500; // ms for a full up-and-down cycle
```

Three things to know. **The panel is clickable where it is drawn**: the offset
goes into `getAbsolutePosition()`, which is what the bounds pipeline reads, so
the hit area of the panel and of every widget inside it follows the float at
any depth. `pos` is left alone, so it still reads and writes the resting place.
It is fed from `update(dt)`, so it stops with the rest of the game on
`state.pause()` unless the panel sets `updateWhenPaused`, which a pause menu
does anyway. And do not go below an amplitude of 2: the offset is rounded to whole
pixels, so an amplitude of 1 has three positions to move through and twitches
instead of drifting.

One consequence worth knowing: `getBounds()` on a bobbing panel answers
differently each frame, so do not cache it or compare two children's bounds
while a bob is running.

### A pause overlay: `updateWhenPaused` cascades

`state.pause()` stops every world child stepping while rendering carries on, so
the overlay that reads the key to UNPAUSE has to opt out:

```js
const overlay = new UIPanel(0, 0, { width: w, height: h, fillColor: "rgba(4,8,16,0.72)" });
overlay.updateWhenPaused = true; // the whole subtree, not just this one
```

The flag covers the container's **entire subtree**, so nested labels, buttons
and the card inside the veil all keep stepping without being flagged one by
one. That matters for more than their `update`: `inViewport` is computed in the
same loop that honours the pause, so a skipped child keeps whatever it had —
`false` for one built while paused — and `Container.draw` then declines to draw
it, silently. A panel whose labels never appear while paused is this.

### Buttons: extend the handlers, do not bind listeners

`UISpriteElement` is a `Sprite` that already registers itself for pointer
Expand Down
3 changes: 3 additions & 0 deletions packages/melonjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import Trail3d from "./renderable/trail3d.js";
import Trigger from "./renderable/trigger.js";
import ProgressBar from "./renderable/ui/progressbar.ts";
import UIBaseElement from "./renderable/ui/uibaseelement.ts";
import UIPanel from "./renderable/ui/uipanel.ts";
import UISpriteElement from "./renderable/ui/uispriteelement.ts";
import UITextButton from "./renderable/ui/uitextbutton.ts";
import Stage from "./state/stage.ts";
Expand Down Expand Up @@ -194,6 +195,7 @@ export type {
ProgressBarDirection,
ProgressBarSettings,
} from "./renderable/ui/progressbar.js";
export type { UIPanelSettings } from "./renderable/ui/uipanel.js";
export * as device from "./system/device.js";
export * as event from "./system/event.ts";
// and the two types it names, so a game can declare a field holding one
Expand Down Expand Up @@ -301,6 +303,7 @@ export {
Tween,
timer,
UIBaseElement,
UIPanel,
UISpriteElement,
UITextButton,
VignetteEffect,
Expand Down
Loading
Loading