From 12218f406f930df55084866758e9377d72b90dbc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:09:39 +0000 Subject: [PATCH 01/43] 20.13.0: bump the version Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- .claude-plugin/plugin.json | 2 +- packages/melonjs/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a6fc28d6b..4a5fe294a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "melonjs", - "version": "20.12.0", + "version": "20.13.0", "description": "Build games with melonJS — 25 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/package.json b/packages/melonjs/package.json index 2c28f9231..fa234b98a 100644 --- a/packages/melonjs/package.json +++ b/packages/melonjs/package.json @@ -1,6 +1,6 @@ { "name": "melonjs", - "version": "20.12.0", + "version": "20.13.0", "description": "A modern & fully featured HTML5 game engine (2D & 3D) for JavaScript/TypeScript, with WebGL/WebGPU rendering, built-in physics, Tiled map & glTF support and WebAudio.", "homepage": "https://www.melonjs.org/", "type": "module", From acbb16301a847eb901b0f5a72063908ff35bcd04 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:39:43 +0000 Subject: [PATCH 02/43] UI: checkbox, slider, skinned button, panel layout and scrolling (#1722) - UICheckbox: a checkbox with its label, drawn or skinned, a radio button with a `group`; `onChange` only on user input - UISlider: horizontal or vertical, drag, track click, wheel, step, caption and value text, drawn or skinned; `onChange` and `onChangeEnd` - UITextButton: `onClick` setting on a completed click, atlas `skin` with lowered pressed art, `enabled`, `pressedColor`, CSS fonts - UIPanel: nine-slice `skin`, `title`, `draggable`, `layout` (column/row/grid, padding, gap, align, autoSize) and `scroll` (wheel, drag with momentum, scrollbar, scrollTo/scrollToChild) - UIBaseElement: cancelPress/onCancel, so a drag on a button in a scrolling panel scrolls instead of clicking - Input: children cropped by a clipping container, or scrolled out of a panel, no longer get pointer events there - The UI example uses the widgets, with volume sliders wired to audio and a scrolling level list; skill sections and changelog entries Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- .../examples/src/examples/ui/ExampleUI.tsx | 535 +++++---- packages/melonjs/CHANGELOG.md | 10 + .../skills/melonjs-ui-and-text/SKILL.md | 178 ++- packages/melonjs/src/index.ts | 26 +- packages/melonjs/src/input/pointerevent.ts | 33 +- packages/melonjs/src/renderable/container.js | 30 + .../src/renderable/ui/uibaseelement.ts | 78 ++ .../melonjs/src/renderable/ui/uicheckbox.ts | 541 +++++++++ packages/melonjs/src/renderable/ui/uilabel.ts | 77 ++ packages/melonjs/src/renderable/ui/uipanel.ts | 1011 ++++++++++++++++- packages/melonjs/src/renderable/ui/uiskin.ts | 140 +++ .../melonjs/src/renderable/ui/uislider.ts | 769 +++++++++++++ .../melonjs/src/renderable/ui/uitextbutton.ts | 365 +++++- packages/melonjs/tests/ui-widgets.spec.js | 957 ++++++++++++++++ packages/melonjs/tests/ui.spec.js | 19 +- 15 files changed, 4471 insertions(+), 298 deletions(-) create mode 100644 packages/melonjs/src/renderable/ui/uicheckbox.ts create mode 100644 packages/melonjs/src/renderable/ui/uilabel.ts create mode 100644 packages/melonjs/src/renderable/ui/uiskin.ts create mode 100644 packages/melonjs/src/renderable/ui/uislider.ts create mode 100644 packages/melonjs/tests/ui-widgets.spec.js diff --git a/packages/examples/src/examples/ui/ExampleUI.tsx b/packages/examples/src/examples/ui/ExampleUI.tsx index 34a42eca6..a2991ffab 100644 --- a/packages/examples/src/examples/ui/ExampleUI.tsx +++ b/packages/examples/src/examples/ui/ExampleUI.tsx @@ -6,299 +6,323 @@ import { Application as App, type Application, + audio, Color, ColorLayer, - Container, - input, loader, ProgressBar, Stage, state, Text, TextureAtlas, - UIBaseElement, - UISpriteElement, + UICheckbox, + UIPanel, + UISlider, + UITextButton, } from "melonjs"; import { createExampleComponent } from "../utils"; const base = `${import.meta.env.BASE_URL}assets/ui/`; +// borrowed from two other examples, so this one ships no audio of its own +const MUSIC = "ingame-loop"; +const MUSIC_PATH = `${import.meta.env.BASE_URL}assets/afterBurner/bgm/`; +const SFX = "dungeonCoins"; +const SFX_PATH = `${import.meta.env.BASE_URL}assets/isometricDungeon/sfx/`; let texture: TextureAtlas; -class ButtonUI extends UISpriteElement { - private unclicked_region: object; - private clicked_region: object; - label: Text; +/** the text style every widget here shares */ +const FONT = { font: "kenpixel", fontSize: 14, labelFillStyle: "black" }; - constructor(x: number, y: number, color: string, labelText: string) { - super(x, y, { - image: texture, - region: `${color}_button04`, - }); - - this.unclicked_region = texture.getRegion(`${color}_button04`); - this.clicked_region = texture.getRegion(`${color}_button05`); - this.anchorPoint.set(0, 0); - this.setOpacity(0.5); - this.floating = false; - - // create label as a sibling — added to the parent by the caller - this.label = new Text(x + this.width / 2, y + this.height / 2, { - font: "kenpixel", - size: 12, - fillStyle: "black", - textAlign: "center", - textBaseline: "middle", - text: labelText, - }); - } +/** the options as last accepted */ +const saved = { music: false, musicVolume: 0.5, sfx: true, sfxVolume: 0.8 }; +/** and as they are on screen */ +const current = { ...saved }; - override onOver() { - this.setOpacity(1.0); +/** + * Start or stop the music to match the current options. + */ +const applyMusic = () => { + if (current.music) { + if (audio.getCurrentTrack() !== MUSIC) { + audio.playTrack(MUSIC, current.musicVolume); + } + } else { + audio.stopTrack(); } +}; - override onOut() { - this.setOpacity(0.5); - } +/** + * A skinned button: the Kenney art has a shorter pressed image, which the + * button draws lowered so it looks pushed in. + * @param color - the art's colour + * @param text - the label + * @param width - the button's width; the art stretches to it + * @param onClick - what it does + * @returns the button + */ +const button = ( + color: string, + text: string, + width: number, + onClick: () => void, +) => { + return new UITextButton(0, 0, { + font: "kenpixel", + size: 12, + fillStyle: "black", + text, + borderWidth: width, + skin: { + atlas: texture, + normal: `${color}_button04`, + pressed: `${color}_button05`, + }, + onClick, + }); +}; - override onClick() { - this.translate( - 0, - this.height - (this.clicked_region as { height: number }).height, - ); - this.setRegion(this.clicked_region); - return false; - } +/** + * A volume slider on the Kenney track and thumb. + * @param label - its caption + * @param value - where it starts + * @param onChange - called while it moves + * @param onChangeEnd - called once when it is let go + * @returns the slider + */ +const volume = ( + label: string, + value: number, + onChange: (v: number) => void, + onChangeEnd?: (v: number) => void, +) => { + return new UISlider(0, 0, { + ...FONT, + label, + showValue: true, + format: (v) => { + return `${Math.round(v * 100)}%`; + }, + length: 320, + value, + step: 0.01, + skin: { + atlas: texture, + track: "grey_sliderHorizontal", + thumb: "blue_sliderDown", + }, + onChange, + onChangeEnd, + }); +}; - override onRelease() { - this.setRegion(this.unclicked_region); - this.translate( - 0, - -(this.height - (this.clicked_region as { height: number }).height), - ); - return false; - } -} +/** + * A checkbox on the Kenney boxes. + * @param labelOn - its label while checked + * @param labelOff - its label while not + * @param checked - whether it starts checked + * @param onChange - called when the user changes it + * @returns the checkbox + */ +const checkbox = ( + labelOn: string, + labelOff: string, + checked: boolean, + onChange: (on: boolean) => void, +) => { + return new UICheckbox(0, 0, { + ...FONT, + labelOn, + labelOff, + checked, + skin: { + atlas: texture, + on: "green_boxCheckmark", + off: "grey_boxCheckmark", + }, + onChange, + }); +}; -class CheckBoxUI extends UISpriteElement { - private on_icon_region: object; - private off_icon_region: object; - private isSelected: boolean; - private label_on: string; - private label_off: string; - label: Text; - - constructor( - x: number, - y: number, - tex: TextureAtlas, - onIcon: string, - offIcon: string, - onLabel: string, - offLabel: string, - ) { - super(x, y, { - image: tex, - region: onIcon, - }); +/** + * A panel on the Kenney grey frame, with a title, that can be dragged. + * @param x - left edge + * @param y - top edge + * @param width - its width + * @param height - its height + * @param title - its title + * @returns the panel, for more settings to be added to + */ +const framed = ( + x: number, + y: number, + width: number, + height: number, + title: string, +) => { + return { + x, + y, + settings: { + width, + height, + title, + titleFont: "kenpixel", + titleFontSize: 20, + titleFillStyle: "black", + skin: { + atlas: texture, + region: "grey_panel", + insetx: 10, + insety: 10, + }, + draggable: true, + }, + }; +}; - this.on_icon_region = tex.getRegion(onIcon); - this.off_icon_region = tex.getRegion(offIcon); - this.setOpacity(0.5); - this.isSelected = true; - this.label_on = onLabel; - this.label_off = offLabel; - this.floating = false; - - // create label as a sibling — added to the parent by the caller - this.label = new Text(x + this.width, y + this.height / 2, { - font: "kenpixel", - size: 12, - fillStyle: "black", - textAlign: "left", - textBaseline: "middle", - text: onLabel, - }); - } +class PlayScreen extends Stage { + override onResetEvent(app: Application) { + const background = new ColorLayer("background", "rgba(248, 194, 40, 1.0)"); + app.world.addChild(background, 0); - override onOver() { - this.setOpacity(1.0); + this.addOptions(app, background); + this.addBars(app); + this.addLevels(app); } - override onOut() { - this.setOpacity(0.5); - } + /** + * The options: two checkboxes, two volume sliders and three buttons, laid + * out as a column by the panel itself. + * @param app - the application + * @param background - the backdrop the video button recolours + */ + private addOptions(app: Application, background: ColorLayer) { + const frame = framed(20, 20, 380, 100, "OPTIONS"); + const panel = new UIPanel(frame.x, frame.y, { + ...frame.settings, + layout: "column", + padding: 16, + gap: 12, + // grows to fit what is added below + autoSize: true, + }); - setSelected(selected: boolean) { - if (selected) { - this.setRegion(this.on_icon_region); - this.isSelected = true; - this.label.setText(this.label_on); - } else { - this.setRegion(this.off_icon_region); - this.isSelected = false; - this.label.setText(this.label_off); - } - } + const music = checkbox("Music ON", "Music OFF", current.music, (on) => { + current.music = on; + applyMusic(); + }); + const musicVolume = volume("Music volume", current.musicVolume, (v) => { + current.musicVolume = v; + if (audio.getCurrentTrack() === MUSIC) { + audio.fade(MUSIC, v, v, 1); + } + }); + const sfx = checkbox("Sound FX ON", "Sound FX OFF", current.sfx, (on) => { + current.sfx = on; + if (on) { + audio.play(SFX, false, null, current.sfxVolume); + } + }); + const sfxVolume = volume( + "Sound FX volume", + current.sfxVolume, + (v) => { + current.sfxVolume = v; + }, + // once on release, not on every step of the drag + (v) => { + if (current.sfx) { + audio.play(SFX, false, null, v); + } + }, + ); - override onClick() { - this.setSelected(!this.isSelected); - return false; - } -} + const backdrops = ["#f8c228", "#5ab0e8", "#8fd16a"]; + let backdrop = 0; + const video = button("blue", "Video Options", 320, () => { + backdrop = (backdrop + 1) % backdrops.length; + background.color.parseCSS(backdrops[backdrop]); + }); -class UIContainer extends UIBaseElement { - constructor( - x: number, - y: number, - width: number, - height: number, - label: string, - ) { - super(x, y, width, height); - this.anchorPoint.set(0, 0); - this.name = "UIPanel"; - - this.addChild( - texture.createSpriteFromName( - "grey_panel", - { - width: this.width, - height: this.height, - }, - true, - ), + // a row inside the column: a panel that draws nothing and lets clicks + // through, laying out its two buttons side by side + const actions = new UIPanel(0, 0, { + width: 320, + height: 49, + layout: "row", + gap: 12, + blocking: false, + }); + actions.addChild( + button("green", "Accept", 154, () => { + Object.assign(saved, current); + }), ); - - this.addChild( - new Text(this.width / 2, 16, { - font: "kenpixel", - size: 20, - fillStyle: "black", - textAlign: "center", - textBaseline: "top", - text: label, + actions.addChild( + button("yellow", "Cancel", 154, () => { + // Restored from code, so no onChange runs: the widgets only + // change their look, and the audio is put back here. + Object.assign(current, saved); + music.checked = saved.music; + musicVolume.value = saved.musicVolume; + sfx.checked = saved.sfx; + sfxVolume.value = saved.sfxVolume; + applyMusic(); }), ); - this.isHoldable = true; - this.isDraggable = true; + panel.addChild(music); + panel.addChild(musicVolume); + panel.addChild(sfx); + panel.addChild(sfxVolume); + panel.addChild(video); + panel.addChild(actions); + app.world.addChild(panel, 1); } /** - * Bring this panel to the front of whatever holds it. - * - * Two panels that can be dragged will overlap, and without this the one - * you grabbed goes on being drawn under the other: a click would pick up - * the panel on top rather than the one being pointed at. - * - * `moveToTop` is the container's own job, so it is asked of the ancestor - * rather than done by writing `depth` here. It reorders the child list AND - * sets the depth past its new neighbour, which is what the sort then - * reads. - * - * The `instanceof` is not defensiveness: `ancestor` is typed - * `Entity | Container`, and only a container has `moveToTop`. Narrowing is - * what makes that safe without a cast. - * @returns false, which CONSUMES the click. A panel is opaque, so a click - * on it must not also reach whatever it is covering. The sense is the DOM - * one and the opposite of what it looks like: `triggerEvent` stops the - * walk when a handler returns `false`, so `false` means handled and - * anything else lets the event carry on down to whatever is underneath. + * A level picker: more levels than fit, in a grid that scrolls with the + * wheel or a drag. A drag that starts on a level scrolls the list rather + * than picking it. + * @param app - the application */ - override onClick() { - if (this.ancestor instanceof Container) { - this.ancestor.moveToTop(this); + private addLevels(app: Application) { + const frame = framed(420, 435, 340, 150, "LEVELS"); + const panel = new UIPanel(frame.x, frame.y, { + ...frame.settings, + layout: "grid", + columns: 5, + padding: 10, + gap: 8, + align: "center", + scroll: "vertical", + }); + for (let level = 1; level <= 20; level++) { + const pick = new UITextButton(0, 0, { + font: "kenpixel", + size: 16, + fillStyle: "black", + text: String(level), + skin: { + atlas: texture, + normal: "blue_button07", + pressed: "blue_button08", + }, + // the last five are locked + enabled: level <= 15, + onClick: () => { + panel.title?.setText(`LEVEL ${level}`); + }, + }); + panel.addChild(pick); } - return false; - } - - /** - * Hovering an opaque panel must not light up a button it covers. - * @returns false, consuming the event in the same sense as - * {@link UIContainer#onClick} - */ - override onOver() { - return false; - } - - /** - * `onOver` only runs on the frame the pointer crosses INTO the panel. On - * every frame after that the pointer is already inside, so the dispatcher - * skips the enter callbacks and reaches the plain `pointermove` ones - * instead. Consuming those too is what keeps the panel opaque to hover for - * as long as the pointer stays on it. - */ - override onActivateEvent() { - super.onActivateEvent(); - input.registerPointerEvent("pointermove", this, () => false); - } - - override onDeactivateEvent() { - input.releasePointerEvent("pointermove", this); - super.onDeactivateEvent(); - } -} - -class PlayScreen extends Stage { - override onResetEvent(app: Application) { - app.world.addChild( - new ColorLayer("background", "rgba(248, 194, 40, 1.0)"), - 0, - ); - - const panel = new UIContainer(100, 100, 450, 325, "OPTIONS"); - - const cbPanel = new UIBaseElement(125, 75, 100, 100); - - const cb1 = new CheckBoxUI( - 0, - 0, - texture, - "green_boxCheckmark", - "grey_boxCheckmark", - "Music ON", - "Music OFF", - ); - cbPanel.addChild(cb1); - cbPanel.addChild(cb1.label); - - const cb2 = new CheckBoxUI( - 0, - 50, - texture, - "green_boxCheckmark", - "grey_boxCheckmark", - "Sound FX ON", - "Sound FX OFF", - ); - cbPanel.addChild(cb2); - cbPanel.addChild(cb2.label); - - panel.addChild(cbPanel); - - const btn1 = new ButtonUI(125, 175, "blue", "Video Options"); - panel.addChild(btn1); - panel.addChild(btn1.label); - - const btn2 = new ButtonUI(30, 250, "green", "Accept"); - panel.addChild(btn2); - panel.addChild(btn2.label); - - const btn3 = new ButtonUI(230, 250, "yellow", "Cancel"); - panel.addChild(btn3); - panel.addChild(btn3.label); - app.world.addChild(panel, 1); - this.addBars(app); } /** * `ProgressBar`, in the shapes it tends to be wanted in. * - * In a panel of its own, like the options on the left: a bar is a UI - * widget, and showing it loose on the background would say otherwise. - * * All of them are drawn with primitives and no artwork. The track and the * fill are `fillRect`; a square border is four more rects, so it lands * exactly inside the bar and keeps its corners; a rounded one goes through @@ -306,7 +330,8 @@ class PlayScreen extends Stage { * @param app - the application */ private addBars(app: Application) { - const panel = new UIContainer(600, 100, 340, 400, "PROGRESS"); + const frame = framed(420, 20, 340, 400, "PROGRESS"); + const panel = new UIPanel(frame.x, frame.y, frame.settings); /** * one caption, in the panel's own coordinates @@ -440,6 +465,13 @@ class PlayScreen extends Stage { this.pulseUpdate?.(dt); return true; } + + /** + * The music belongs to this screen. + */ + override onDestroyEvent() { + audio.stopTrack(); + } } const createGame = async () => { @@ -449,6 +481,7 @@ const createGame = async () => { scaleMethod: "flex-width", }); await app.init(); + audio.init("mp3,ogg"); const resources = [ { name: "UI_Assets-0", type: "image", src: `${base}img/UI_Assets-0.png` }, @@ -462,6 +495,8 @@ const createGame = async () => { type: "fontface", src: `${base}font/kenvector_future.woff2`, }, + { name: MUSIC, type: "audio", src: MUSIC_PATH }, + { name: SFX, type: "audio", src: SFX_PATH }, ]; loader.preload(resources, () => { diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 7beb8e48b..c5ebe0a75 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -4,6 +4,16 @@ ### Added - Particles: a `depth` setting, the spawn area's size along Z, with `width` and `height`. Under a `Camera3d`, particles are born through a volume instead of on a flat sheet +- UI: `UICheckbox`, a checkbox with its label, drawn or from atlas art, that becomes a radio button with a `group` ([#1722](https://github.com/melonjs/melonJS/issues/1722)). `onChange` only fires on user input, not when `checked` is set from code +- UI: `UISlider`, a horizontal or vertical slider with `min`, `max`, `step`, drag, track clicks and the mouse wheel, an optional caption and value text, drawn or from atlas art. `onChange` fires while it moves, `onChangeEnd` once on release, neither when `value` is set from code +- UI: `UIPanel` lays its children out (`layout`: `"column"`, `"row"` or `"grid"`, with `padding`, `gap`, `align` and `autoSize`), scrolls them (`scroll`, with the wheel, dragging, momentum and a scrollbar; `scrollTo`, `scrollToChild`), and takes a nine-slice `skin`, a `title` and `draggable` +- UI: `UITextButton` takes an `onClick` setting, called on a completed click; a `skin` of atlas regions, with pressed art drawn lowered; `enabled`; and `pressedColor`. A font that is not a loaded bitmap font is drawn as a CSS font, where the button used to throw +- UI: `UIBaseElement#cancelPress()` and `onCancel()`, to call off a press without a release; a scrolling `UIPanel` uses them when a drag on a button turns into a scroll + +### Fixed +- Input: a child cropped out of view by a container with `clipping`, or scrolled out of a `UIPanel`, no longer gets pointer events where it is not drawn +- UI: a `UITextButton` moved after it was built draws its background where it is, not where it was first put +- UI: a blocking `UIPanel` stays opaque to hover for as long as the pointer is on it, not only on the frame the pointer enters ## [20.12.0] (melonJS 2) - _2026-10-30_ diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index efcc62c05..760f94185 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -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, 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., fullscreen, full screen, requestFullscreen, exitFullscreen, isFullscreen, fullscreen button, fullscreen option" +description: "Use this skill for HUDs, buttons, menus, options screens, dialogue panels, progress bars and on-screen text in melonJS. Covers UIBaseElement/UISpriteElement/UITextButton, UICheckbox, UISlider, ProgressBar, UIPanel (skins, layout, scrolling), 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, checkbox, radio button, UICheckbox, slider, UISlider, volume slider, options menu, settings screen, scrolling list, scroll panel, layout, column, grid, UI skin, 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., fullscreen, full screen, requestFullscreen, exitFullscreen, isFullscreen, fullscreen button, fullscreen option" license: MIT --- @@ -441,6 +441,79 @@ 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. +### Panel art, title, dragging: `skin`, `title`, `draggable` + +```js +const options = new UIPanel(20, 20, { + width: 380, height: 460, + // one atlas region, stretched as a nine-slice to the panel's size + skin: { atlas, region: "grey_panel", insetx: 10, insety: 10 }, + title: "OPTIONS", titleFont: "kenpixel", titleFontSize: 20, titleFillStyle: "black", + draggable: true, // comes to the front when grabbed +}); +``` + +The title sits at the top and the content starts below it. `insetx` / +`insety` are the unscaled corners of the art, and also where the frame ends: +layout `padding` is measured from there. + +### Laying a panel out: `layout` + +Leave `layout` at `"none"` and children stay where you put them. Otherwise +the panel places them, below the title and inside the frame: + +```js +const menu = new UIPanel(x, y, { + width: 360, height: 100, + layout: "column", // "row", "grid" (with `columns`), or "none" + padding: 16, // from the frame to the content + gap: 12, // between children + align: "stretch", // "start", "center", "end", or "stretch" + autoSize: true, // grow the panel to fit +}); +menu.addChild(new UITextButton(0, 0, { text: "Play", onClick: play })); +menu.addChild(new UITextButton(0, 0, { text: "Options", onClick: openOptions })); +``` + +Children are laid out in the order they were added. It runs when a child is +added or removed and when the panel is resized, **not every frame**: a child +that changes size afterwards, a label whose text grew, needs +`menu.relayout()`. `"stretch"` resizes children that have a `resize` method, +which `UITextButton` and other containers do; a `Text` keeps its size. + +A row inside a column is a second panel that draws nothing and lets clicks +through: `new UIPanel(0, 0, { width, height, layout: "row", blocking: false })`. + +### Scrolling lists: `scroll` + +```js +const levels = new UIPanel(420, 435, { + width: 340, height: 150, + layout: "grid", columns: 5, gap: 8, padding: 10, + scroll: "vertical", // "horizontal", "both", or "none" +}); +``` + +The content is cropped to the area inside the frame and below the title, and +scrolls with the wheel over the panel and by dragging, with momentum when +flung. A scrollbar shows while it does not fit. `scrollTo(x, y)`, +`scrollToChild(child)`, `scrollX` / `scrollY`, `maxScrollX` / `maxScrollY` and +an `onScroll(x, y, panel)` setting are there to drive it from code. + +What to know: + +- **Scrolling moves the children.** Their `pos` is where they are drawn, so + it changes as the list scrolls. A child added to a `"none"` layout while + scrolled is placed in content coordinates and moved into view. +- **A drag that starts on a button scrolls the list and cancels the press.** + The button gets `onCancel()` instead of `onRelease()`, and its `onClick` + SETTING is not called. The `onClick()` METHOD already ran on the press, so + a button in a list does its work in the setting: see the next section. +- **A slider in a scrolling panel is dragged, not the panel.** Any widget + that sets `keepsDrag` keeps its drags. +- Children scrolled out of view do not get pointer events. Nested scrolling + panels are not supported yet. + ### A pause overlay: `updateWhenPaused` cascades `state.pause()` stops every world child stepping while rendering carries on, so @@ -492,6 +565,44 @@ default on a plain `Renderable` — is skipped by the broadphase and receives nothing. `UISpriteElement` and `UIBaseElement` clear it for you; anything else you make clickable has to clear it itself. +### A button that does one thing: `UITextButton` + +No subclass needed for a single action: + +```js +const play = new UITextButton(100, 200, { + text: "Play", // a CSS font by default ("sans-serif") + size: 24, // pixels with a CSS font, a SCALE with a bitmap font + onClick: () => state.change(state.PLAY), +}); +``` + +**`onClick` is called on a COMPLETED click**: pressed and released over the +button, or its `bindKey` pressed and released. A press dragged off the button, +cancelled, or turned into a scroll does not call it, and neither does a +disabled button. The `onClick()` method is different: it runs on the press, +as before, for subclasses. + +`font` is a loaded bitmap font's name, drawn with `BitmapText` as before, or +any other name, drawn as a CSS font with `Text`. The label is +`button.label` (`bitmapText` is the old name for it). + +**Skinned** from an atlas, drawn as a nine-slice so one image makes a button +of any width: + +```js +new UITextButton(0, 0, { + font: "kenpixel", size: 12, text: "Accept", borderWidth: 160, + skin: { atlas, normal: "green_button04", pressed: "green_button05" }, +}); +``` + +Pressed art is usually shorter than the normal art. The button draws it +lowered by the difference and moves the label down with it, so it looks +pushed in. `hover` and `disabled` regions are optional. `enabled = false` +draws the `disabled` region, or the button at half opacity, and ignores the +pointer and the key. + ### Keyboard shortcuts on a button: `bindKey` A `UITextButton` can be activated from the keyboard as well as the pointer: @@ -533,6 +644,60 @@ Three more rules, each with a reason you can feel in a game: Shortcuts live only while the button is activated, and never start dragging or tap-and-hold: those stay pointer gestures. +### Checkboxes and radio buttons: `UICheckbox` + +```js +const music = new UICheckbox(40, 60, { + labelOn: "Music ON", labelOff: "Music OFF", // or one `label` + checked: settings.music, + onChange: (on) => { settings.music = on; on ? audio.playTrack("theme") : audio.stopTrack(); }, +}); +``` + +The label is part of the widget: clicking it toggles the box, and removing +the checkbox takes the label with it. Drawn with primitives by default, or +`skin: { atlas, on, off, disabled? }`. + +**Setting `checked` from code does NOT call `onChange`**, nor does +`toggle()`. Only the user does. Restoring saved settings into the widgets +therefore does not re-run the game logic attached to them, so apply what +they stand for yourself after restoring: + +```js +music.checked = saved.music; // the look only +applyMusic(saved.music); // the effect, once +``` + +**Radio buttons** are checkboxes sharing a `group`: exactly one is checked, +clicking the checked one leaves it checked, and they are drawn round. Only +the one the user picked gets `onChange`, as in a web page; read the group with +`UICheckbox.checkedIn("difficulty")`. A checkbox is in its group while it is in +the scene. + +### Sliders: `UISlider` + +```js +const volume = new UISlider(40, 120, { + label: "Music", showValue: true, + format: (v) => Math.round(v * 100) + "%", + min: 0, max: 1, step: 0.01, value: audio.getVolume(), + length: 300, // orientation: "vertical" too + onChange: (v) => audio.setVolume(v), // every step of the drag + onChangeEnd: () => saveSettings(), // once, on release +}); +``` + +Drag the thumb, or click the track to jump there. The drag follows the +pointer after it leaves the track, until it is released. The mouse wheel over +the slider moves it one `step`. `value` snaps to `step` and clamps to the +range, and setting it does not call `onChange` or `onChangeEnd`: the same rule +as the checkbox. `skin: { atlas, track, fill?, thumb, thumbHover? }` draws it +from art: the track and fill as nine-slices along its length, the thumb at its +own size. + +Put work that should not run 60 times a second, such as saving or playing a +test sound, in `onChangeEnd`. + ### A fullscreen option Three methods on the `Application`, not on `device` (they moved there in @@ -721,6 +886,17 @@ 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`. +## When the UI misbehaves + +| symptom | cause | fix | +|---|---|---| +| loading saved settings plays the music, saves again or fires game logic | the game called its own change handler while restoring | set `checkbox.checked` / `slider.value`, which never call `onChange`, then apply the setting once yourself | +| `onChange` never fires when settings are restored | by design: only user input calls it | apply the restored value yourself | +| a button in a scrolling list fires when the user only meant to scroll | the work is in the `onClick()` METHOD, which runs on the press | use the `onClick` SETTING, called on a completed click; a scroll cancels the press | +| a button scrolled out of a hand-made list still gets clicks | its container does not clip, so the pointer still finds it where it moved to | use `UIPanel`'s `scroll`, or set `clipping` on the container: since 20.13 the pointer skips children outside a clipping container | +| a layout ignores a label that changed size | layout runs on add, remove and resize only | call `panel.relayout()` | +| `new UITextButton` with a font name draws in a plain font | the name is not a loaded bitmap font, so it is drawn as a CSS font | load the bitmap font (image and `.fnt`) under that name, or load the web font | + ## Related skills - `melonjs-scenes-and-state` — `Tween` and the easing families, for animating any of this diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index 3ffee1c1b..eecfb17e3 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -55,7 +55,9 @@ 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 UICheckbox from "./renderable/ui/uicheckbox.ts"; import UIPanel from "./renderable/ui/uipanel.ts"; +import UISlider from "./renderable/ui/uislider.ts"; import UISpriteElement from "./renderable/ui/uispriteelement.ts"; import UITextButton from "./renderable/ui/uitextbutton.ts"; import Stage from "./state/stage.ts"; @@ -200,7 +202,27 @@ export type { ProgressBarDirection, ProgressBarSettings, } from "./renderable/ui/progressbar.js"; -export type { UIPanelSettings } from "./renderable/ui/uipanel.js"; +export type { + UICheckboxSettings, + UICheckboxSkin, +} from "./renderable/ui/uicheckbox.js"; +export type { UILabel } from "./renderable/ui/uilabel.js"; +export type { + UIPanelAlign, + UIPanelLayout, + UIPanelScroll, + UIPanelSettings, + UIPanelSkin, +} from "./renderable/ui/uipanel.js"; +export type { UISkin } from "./renderable/ui/uiskin.js"; +export type { + UISliderSettings, + UISliderSkin, +} from "./renderable/ui/uislider.js"; +export type { + UITextButtonSettings, + UITextButtonSkin, +} from "./renderable/ui/uitextbutton.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 @@ -311,7 +333,9 @@ export { Tween, timer, UIBaseElement, + UICheckbox, UIPanel, + UISlider, UISpriteElement, UITextButton, VignetteEffect, diff --git a/packages/melonjs/src/input/pointerevent.ts b/packages/melonjs/src/input/pointerevent.ts index 717e1d1b2..983013925 100644 --- a/packages/melonjs/src/input/pointerevent.ts +++ b/packages/melonjs/src/input/pointerevent.ts @@ -301,6 +301,28 @@ function triggerEvent( return false; } +/** + * Whether a point is outside the visible area of a container above a region. + * + * Asks each ancestor through `clipContains`, which a `Container` answers from + * its own rectangle while `clipping` is on, and a scrolling `UIPanel` from its + * content area. An ancestor without the method never clips. + * @param region - the region being hit-tested + * @param x - the point, in the coordinates of the region's bounds + * @param y - the point, in the coordinates of the region's bounds + * @returns true when some ancestor does not show that point + * @ignore + * @internal + */ +function isClippedOut(region: any, x: number, y: number): boolean { + for (let node = region.ancestor; node != null; node = node.ancestor) { + if (typeof node.clipContains === "function" && !node.clipContains(x, y)) { + return true; + } + } + return false; +} + /** * propagate events to registered objects * @ignore @@ -412,10 +434,15 @@ function dispatchEvent(normalizedEvents: Pointer[]): boolean { } } + const hitX = + region.isFloating === true ? pointer.gameX : absoluteWorldX; + const hitY = + region.isFloating === true ? pointer.gameY : absoluteWorldY; + // inside its bounds AND inside every clipping container above + // it: a child scrolled or cropped out of view is not drawn + // there, so it must not answer there either const eventInBounds = - region.isFloating === true - ? bounds.contains(pointer.gameX, pointer.gameY) - : bounds.contains(absoluteWorldX, absoluteWorldY); + bounds.contains(hitX, hitY) && !isClippedOut(region, hitX, hitY); // Covered by whatever consumed the move above. Being inside its // own bounds is no longer what decides it, so release the diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index 0b6845970..5dd45047e 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -1527,6 +1527,36 @@ export default class Container extends Renderable { return out; } + /** + * Whether this container shows its children at a point. + * + * Always true unless {@link Container#clipping} is on, in which case only + * its own rectangle is shown. The pointer asks every container above a + * region before letting it answer, so a child cropped out of view cannot + * be clicked where it is not drawn. + * @param {number} x - the point, in the same coordinates as the bounds of + * this container (screen pixels for a floating one) + * @param {number} y - the point + * @returns {boolean} false where the children are clipped away + */ + clipContains(x, y) { + if ( + this.clipping !== true || + this.root === true || + !Number.isFinite(this.width) || + !Number.isFinite(this.height) + ) { + return true; + } + const origin = this.getAbsolutePosition(); + return ( + x >= origin.x && + x < origin.x + this.width && + y >= origin.y && + y < origin.y + this.height + ); + } + /** * draw this renderable (automatically called by melonJS) * @param {Renderer} renderer - a renderer instance diff --git a/packages/melonjs/src/renderable/ui/uibaseelement.ts b/packages/melonjs/src/renderable/ui/uibaseelement.ts index cc831f2b9..632a82e94 100644 --- a/packages/melonjs/src/renderable/ui/uibaseelement.ts +++ b/packages/melonjs/src/renderable/ui/uibaseelement.ts @@ -9,6 +9,44 @@ import { off, on, POINTERMOVE } from "../../system/event.ts"; import timer from "../../system/timer.ts"; import Container from "../container.js"; +/** + * Whether a pointer event is the pointer coming up, rather than leaving or + * being cancelled. The dispatcher only delivers it to a region under the + * pointer, so for a widget it means a press completed over it. + * @param event - the event a widget's `release` was called with + * @returns true for a pointer, mouse or touch up + * @ignore + * @internal + */ +export function isPointerUp(event: Pointer | undefined): boolean { + const type = event?.type; + return type === "pointerup" || type === "mouseup" || type === "touchend"; +} + +/** + * Hand a press to the nearest scrolling panel above an element, if any, so + * that a drag starting on the element can still scroll the panel. + * + * Found by the method rather than by class, as `UIPanel` extends this class + * and could not be imported here without a cycle. + * @param element - the element pressed + * @param event - the pointer-down + * @ignore + * @internal + */ +function offerPressToScroller(element: UIBaseElement, event: Pointer) { + for (let node: any = element.ancestor; node != null; node = node.ancestor) { + // a panel that does not scroll answers false and the search goes on, + // so a plain panel inside a scrolling one does not stop it + if ( + typeof node._capturePress === "function" && + node._capturePress(event, element) === true + ) { + return; + } + } +} + /** * This is a basic clickable and draggable container which you can use in your game UI. * Use this for example if you want to display a panel that contains text, images or other UI elements. @@ -142,11 +180,51 @@ export default class UIBaseElement extends Container { if (this.isDraggable) { this.grabOffset!.set(event.gameX, event.gameY); this.grabOffset!.sub(this.pos); + } else if (!this.keepsDrag) { + offerPressToScroller(this, event); } return this.onClick(event); } } + /** + * Whether a drag that starts on this element belongs to it. + * + * `false` by default: inside a scrolling {@link UIPanel}, a drag that + * starts on a button scrolls the panel and cancels the press. A widget + * that is dragged itself, such as a {@link UISlider}, sets it, and so does + * anything `isDraggable`. + * @ignore + * @internal + */ + keepsDrag = false; + + /** + * Abandon a press in progress, without calling `onRelease`. + * + * The element goes back to its released look, and the pointer-up that + * follows does nothing. A scrolling {@link UIPanel} calls this when a drag + * that started on the element turns out to be a scroll, so a button + * dragged to scroll its list is not clicked. + */ + cancelPress(): void { + if (!this.released) { + this.released = true; + this.isDirty = true; + timer.clearTimer(this.holdTimeout); + this.holdTimeout = -1; + this.onCancel(); + } + } + + /** + * Called when a press is abandoned rather than completed: see + * {@link UIBaseElement#cancelPress}. To be extended. + */ + onCancel(): void { + // to be extended + } + /** * function called when the object is pressed (to be extended) * @param _event - the event object diff --git a/packages/melonjs/src/renderable/ui/uicheckbox.ts b/packages/melonjs/src/renderable/ui/uicheckbox.ts new file mode 100644 index 000000000..6ff57fd96 --- /dev/null +++ b/packages/melonjs/src/renderable/ui/uicheckbox.ts @@ -0,0 +1,541 @@ +import type Camera2d from "../../camera/camera2d.ts"; +import type Pointer from "../../input/pointer.ts"; +import type Renderer from "../../video/renderer.js"; +import type { Paint } from "./panelpaint.ts"; +import { PanelPainter } from "./panelpaint.ts"; +import UIBaseElement, { isPointerUp } from "./uibaseelement.ts"; +import type { UILabel } from "./uilabel.ts"; +import { createLabel } from "./uilabel.ts"; +import type { UISkin } from "./uiskin.ts"; +import { SkinDrawer } from "./uiskin.ts"; + +/** + * The art of a skinned {@link UICheckbox}: one atlas region per state. + * @category UI + */ +export interface UICheckboxSkin extends UISkin { + /** the box when checked */ + on: string; + /** the box when not checked */ + off: string; + /** the box while disabled; the current one at half opacity if left out */ + disabled?: string; +} + +/** + * Everything {@link UICheckbox} takes. + * @category UI + */ +export interface UICheckboxSettings { + /** + * whether it starts checked + * @default false + */ + checked?: boolean; + /** the text next to the box, whatever the state */ + label?: string; + /** the text while checked, in place of `label` */ + labelOn?: string; + /** the text while not checked, in place of `label` */ + labelOff?: string; + /** + * Called when the user changes it, never when `checked` is set from code. + * See {@link UICheckbox#onChange}. + */ + onChange?: (checked: boolean, checkbox: UICheckbox) => void; + /** + * whether it answers input + * @default true + */ + enabled?: boolean; + /** + * the side of the box, in pixels; 24, or the height of the skin's `off` + * region + */ + size?: number; + /** + * Make it one of a set of radio buttons: checkboxes sharing a group name + * have exactly one checked. See {@link UICheckbox#group}. + */ + group?: string; + /** + * a CSS font family, or the name of a loaded bitmap font + * @default "sans-serif" + */ + font?: string; + /** the label's height, in pixels; two thirds of `size` by default */ + fontSize?: number; + /** + * the label's colour + * @default "#000000" + */ + labelFillStyle?: string; + /** + * the space between the box and the label, in pixels + * @default 8 + */ + labelGap?: number; + /** + * the box's fill (drawn look) + * @default "#ffffff" + */ + boxColor?: Paint; + /** + * the box's fill under the pointer (drawn look) + * @default "#e4eefb" + */ + hoverOnColor?: Paint; + /** + * the box's fill when checked, and the radio dot (drawn look) + * @default "#2a7de1" + */ + checkColor?: Paint; + /** + * the box's outline (drawn look) + * @default "#3a3a3a" + */ + borderColor?: Paint; + /** + * the outline's thickness (drawn look) + * @default 2 + */ + borderWidth?: number; + /** + * the box's corner radius (drawn look); a radio button is round + * @default 4 + */ + radius?: number; + /** draw the box from atlas regions instead */ + skin?: UICheckboxSkin; +} + +/** checkboxes by group name, while they are in the scene */ +const groups = new Map>(); + +/** + * A box that can be checked, with a label next to it; with a `group`, a + * radio button. + * + * Drawn with primitives by default, so it needs no art, or from atlas + * regions through a {@link UICheckboxSkin}. The label is part of the + * widget: clicking it toggles the box, and removing the widget takes it + * along. + * + * Its state is {@link UICheckbox#checked}. The game hears about a change + * through `onChange`, which only user input calls: restoring saved settings + * by setting `checked` does not run the game logic attached to it. + * @category UI + * @example + * const music = new UICheckbox(40, 60, { + * labelOn: "Music ON", + * labelOff: "Music OFF", + * checked: settings.music, + * onChange: (on) => { + * settings.music = on; + * on ? audio.playTrack("theme") : audio.stopTrack(); + * }, + * }); + * panel.addChild(music); + * + * // three radio buttons, one checked at a time + * for (const [i, level] of ["Easy", "Normal", "Hard"].entries()) { + * panel.addChild(new UICheckbox(40, 120 + i * 32, { + * label: level, + * group: "difficulty", + * checked: level === "Normal", + * })); + * } + * const chosen = UICheckbox.checkedIn("difficulty"); + */ +export default class UICheckbox extends UIBaseElement { + /** + * Called when the user checks or unchecks it, with the new state. + * + * Never called when {@link UICheckbox#checked} is set, or + * {@link UICheckbox#toggle} is called, from code. In a radio group only + * the button the user picked is told; the one that was unchecked to make + * room is not, as with radio buttons in a web page. + */ + onChange: ((checked: boolean, checkbox: UICheckbox) => void) | undefined; + + /** + * The radio group this checkbox belongs to, if any. + * + * Checkboxes sharing a group have exactly one checked: checking one + * unchecks the others, and clicking the checked one leaves it checked. A + * checkbox is in its group while it is in the scene; removing it takes it + * out. Changing this property takes effect the next time it is added. + */ + group: string | undefined; + + /** the box's fill (drawn look) */ + boxColor: Paint; + /** the box's fill under the pointer (drawn look) */ + hoverOnColor: Paint; + /** the box's fill when checked, and the radio dot (drawn look) */ + checkColor: Paint; + /** the box's outline (drawn look) */ + borderColor: Paint; + /** the outline's thickness (drawn look) */ + borderWidth: number; + /** the box's corner radius (drawn look) */ + radius: number; + + /** the side of the box, in pixels */ + readonly boxSize: number; + /** the space between the box and the label, in pixels */ + readonly labelGap: number; + /** the label, absent when there is no text */ + readonly label: UILabel | undefined; + /** the art, when it is skinned */ + readonly skin: UICheckboxSkin | undefined; + + /** @ignore */ + private _checked: boolean; + /** @ignore */ + private _enabled = true; + /** @ignore */ + private readonly _labelOn: string; + /** @ignore */ + private readonly _labelOff: string; + /** @ignore */ + private readonly _painter = new PanelPainter(); + /** @ignore */ + private readonly _skin: SkinDrawer | undefined; + /** @ignore */ + private _joined: string | undefined; + + /** + * @param x - left edge + * @param y - top edge + * @param settings - see {@link UICheckboxSettings} + */ + constructor(x: number, y: number, settings: UICheckboxSettings = {}) { + super(x, y, 1, 1); + + this.skin = settings.skin; + let size = settings.size; + if (this.skin !== undefined) { + this._skin = new SkinDrawer(this.skin); + const off = this._skin.size(this.skin.off); + this._skin.size(this.skin.on); + if (this.skin.disabled !== undefined) { + this._skin.size(this.skin.disabled); + } + size ??= off.height; + } + this.boxSize = size ?? 24; + this.labelGap = settings.labelGap ?? 8; + + this._checked = settings.checked ?? false; + this.onChange = settings.onChange; + this.group = settings.group; + + this.boxColor = settings.boxColor ?? "#ffffff"; + this.hoverOnColor = settings.hoverOnColor ?? "#e4eefb"; + this.checkColor = settings.checkColor ?? "#2a7de1"; + this.borderColor = settings.borderColor ?? "#3a3a3a"; + this.borderWidth = settings.borderWidth ?? 2; + this.radius = settings.radius ?? 4; + + this._labelOn = settings.labelOn ?? settings.label ?? ""; + this._labelOff = settings.labelOff ?? settings.label ?? ""; + + let width = this.boxSize; + let height = this.boxSize; + if (this._labelOn !== "" || this._labelOff !== "") { + const label = createLabel({ + text: this._checked ? this._labelOn : this._labelOff, + font: settings.font ?? "sans-serif", + fontSize: + settings.fontSize ?? Math.round(Math.max(12, this.boxSize * 0.66)), + fillStyle: settings.labelFillStyle ?? "#000000", + textAlign: "left", + textBaseline: "middle", + }); + // as wide as the longer of the two texts, so the widget does not + // change size, and move what is laid out after it, as it toggles + const on = label.measureText(this._labelOn); + const off = label.measureText(this._labelOff); + width += this.labelGap + Math.max(on.width, off.width); + height = Math.max(height, on.height, off.height); + this.label = label; + } + this.resize(Math.ceil(width), Math.ceil(height)); + + this.enabled = settings.enabled ?? true; + } + + /** + * Whether it is checked. + * + * Setting it updates the look WITHOUT calling `onChange`, so restoring + * saved settings does not trigger the game logic that a click would. + * Checking a radio button unchecks the others in its group. + */ + get checked(): boolean { + return this._checked; + } + + set checked(value: boolean) { + if (value === this._checked) { + return; + } + this._checked = value; + if (value) { + this.uncheckOthers(); + } + this.label?.setText(value ? this._labelOn : this._labelOff); + this.isDirty = true; + } + + /** + * Flip it, from code. Like setting {@link UICheckbox#checked}, this does + * not call `onChange`. + * @returns the new state + */ + toggle(): boolean { + this.checked = !this._checked; + return this._checked; + } + + /** + * Whether it answers input. + * + * A disabled checkbox is drawn with its `disabled` skin region, or at + * half opacity, and ignores the pointer. + * @default true + */ + get enabled(): boolean { + return this._enabled; + } + + set enabled(value: boolean) { + this._enabled = value; + this.isClickable = value; + if (!value) { + this.cancelPress(); + } + this.isDirty = true; + } + + /** + * The checked checkbox of a radio group. + * @param group - the group name + * @returns the checked member in the scene, or undefined + */ + static checkedIn(group: string): UICheckbox | undefined { + const members = groups.get(group); + if (members !== undefined) { + for (const member of members) { + if (member.checked) { + return member; + } + } + } + return undefined; + } + + /** + * Uncheck the rest of the group, silently. + * @ignore + * @internal + */ + private uncheckOthers() { + if (this._joined === undefined) { + return; + } + const members = groups.get(this._joined); + if (members === undefined) { + return; + } + for (const member of members) { + if (member !== this && member._checked) { + member._checked = false; + member.label?.setText(member._labelOff); + member.isDirty = true; + } + } + } + + /** + * Toggle on a completed click: pressed and released over the widget. + * @ignore + * @internal + */ + override release(event: Pointer): boolean | void { + const completed = !this.released && isPointerUp(event); + const result = super.release(event); + if (completed && this._enabled) { + // a radio button that is already checked stays checked + if (!(this._checked && this._joined !== undefined)) { + this.checked = !this._checked; + this.onChange?.(this._checked, this); + } + } + return result; + } + + /** + * Join its group. + * @ignore + */ + override onActivateEvent(): void { + super.onActivateEvent(); + if (this.group !== undefined) { + let members = groups.get(this.group); + if (members === undefined) { + members = new Set(); + groups.set(this.group, members); + } + members.add(this); + this._joined = this.group; + // the newest checked member wins + if (this._checked) { + this.uncheckOthers(); + } + } + } + + /** + * Leave its group. + * @ignore + */ + override onDeactivateEvent(): void { + if (this._joined !== undefined) { + const members = groups.get(this._joined); + members?.delete(this); + if (members?.size === 0) { + groups.delete(this._joined); + } + this._joined = undefined; + } + super.onDeactivateEvent(); + } + + /** + * Draw the box, then the label. + * @param renderer - the renderer to draw with + * @param viewport - the camera being drawn through + */ + override draw(renderer: Renderer, viewport?: Camera2d) { + const alpha = renderer.globalAlpha(); + const size = this.boxSize; + const x = this.pos.x; + const y = this.pos.y + (this.height - size) / 2; + const faded = alpha * 0.5; + + if (this._skin !== undefined) { + const skin = this.skin!; + let region = this._checked ? skin.on : skin.off; + if (!this._enabled) { + if (skin.disabled !== undefined) { + region = skin.disabled; + } else { + renderer.setGlobalAlpha(faded); + } + } + this._skin.draw(renderer, region, x, y, size, size); + } else { + this.drawBox(renderer, x, y, size, this._enabled ? alpha : faded); + } + + if (this.label !== undefined) { + const lx = x + size + this.labelGap; + const ly = this.pos.y + this.height / 2; + if (this.label.pos.x !== lx || this.label.pos.y !== ly) { + this.label.setPosition(lx, ly); + } + renderer.setGlobalAlpha(this._enabled ? alpha : faded); + this.label.preDraw(renderer); + this.label.draw(renderer); + this.label.postDraw(renderer); + } + renderer.setGlobalAlpha(alpha); + super.draw(renderer, viewport); + } + + /** + * The drawn box: square with a tick, or round with a dot for a radio + * button. + * @ignore + * @internal + */ + private drawBox( + renderer: Renderer, + x: number, + y: number, + size: number, + alpha: number, + ) { + const painter = this._painter; + const fill = + this._checked && this.group === undefined + ? this.checkColor + : this.hover && this._enabled + ? this.hoverOnColor + : this.boxColor; + const half = size / 2; + + if (this.group !== undefined) { + // a radio button + painter.paint(renderer, fill, alpha); + renderer.fillEllipse(x + half, y + half, half, half); + if (this.borderWidth > 0) { + const previous = renderer.lineWidth; + renderer.lineWidth = this.borderWidth; + painter.paint(renderer, this.borderColor, alpha); + const r = half - this.borderWidth / 2; + renderer.strokeEllipse(x + half, y + half, r, r); + renderer.lineWidth = previous; + } + if (this._checked) { + painter.paint(renderer, this.checkColor, alpha); + const dot = size * 0.25; + renderer.fillEllipse(x + half, y + half, dot, dot); + } + return; + } + + painter.paint(renderer, fill, alpha); + painter.rect(renderer, x, y, size, size, this.radius); + if (this.borderWidth > 0) { + painter.paint( + renderer, + this._checked ? this.checkColor : this.borderColor, + alpha, + ); + painter.border(renderer, x, y, size, size, this.radius, this.borderWidth); + } + if (this._checked) { + // a tick, in the box colour over the checked fill + const previous = renderer.lineWidth; + renderer.lineWidth = Math.max(2, size / 8); + painter.paint(renderer, this.boxColor, alpha); + renderer.strokeLine( + x + size * 0.24, + y + size * 0.52, + x + size * 0.42, + y + size * 0.7, + ); + renderer.strokeLine( + x + size * 0.42, + y + size * 0.7, + x + size * 0.76, + y + size * 0.32, + ); + renderer.lineWidth = previous; + } + } + + /** + * Hand back what it borrowed. + * @ignore + */ + override onDestroyEvent() { + this._painter.dispose(); + this._skin?.dispose(); + this.label?.destroy(); + super.onDestroyEvent(); + } +} diff --git a/packages/melonjs/src/renderable/ui/uilabel.ts b/packages/melonjs/src/renderable/ui/uilabel.ts new file mode 100644 index 000000000..9d6ec3ce6 --- /dev/null +++ b/packages/melonjs/src/renderable/ui/uilabel.ts @@ -0,0 +1,77 @@ +import { getBinary, getImage } from "../../loader/loader.js"; +import BitmapText from "../text/bitmaptext.js"; +import Text from "../text/text.js"; + +/** + * The text a widget draws: a CSS font through {@link Text}, or a loaded + * bitmap font through {@link BitmapText}. + * @category UI + */ +export type UILabel = Text | BitmapText; + +/** how a widget's label is drawn */ +export interface UILabelOptions { + /** what it says */ + text: string; + /** + * A CSS font family, or the name of a loaded bitmap font. A name with + * both a font image and font data in the loader is drawn as a bitmap + * font; anything else is a CSS font. + */ + font: string; + /** + * the height of the text, in pixels; a bitmap font is scaled to it from + * its own line height + */ + fontSize: number; + /** the text colour; a bitmap font is tinted with it */ + fillStyle: string; + /** horizontal alignment about the label's position */ + textAlign: string; + /** vertical alignment about the label's position */ + textBaseline: string; +} + +/** + * Whether a font name names a loaded bitmap font. + * @param font - the font name + * @returns true when its image and its data are both loaded + * @ignore + * @internal + */ +export function isBitmapFont(font: string): boolean { + return getImage(font) != null && getBinary(font) != null; +} + +/** + * Make a widget's label. + * @param options - see {@link UILabelOptions} + * @returns a `BitmapText` for a loaded bitmap font, a `Text` otherwise + * @ignore + * @internal + */ +export function createLabel(options: UILabelOptions): UILabel { + if (isBitmapFont(options.font)) { + const label = new BitmapText(0, 0, { + font: options.font, + fillStyle: options.fillStyle, + textAlign: options.textAlign, + textBaseline: options.textBaseline, + text: options.text, + }); + // at its authored scale, one line measures the font's line height + const lineHeight = label.measureText("Ag").height; + if (lineHeight > 0) { + label.resize(options.fontSize / lineHeight); + } + return label; + } + return new Text(0, 0, { + font: options.font, + size: options.fontSize, + fillStyle: options.fillStyle, + textAlign: options.textAlign, + textBaseline: options.textBaseline, + text: options.text, + }); +} diff --git a/packages/melonjs/src/renderable/ui/uipanel.ts b/packages/melonjs/src/renderable/ui/uipanel.ts index 3e4b09e8d..08db7c5ec 100644 --- a/packages/melonjs/src/renderable/ui/uipanel.ts +++ b/packages/melonjs/src/renderable/ui/uipanel.ts @@ -1,11 +1,59 @@ import type Camera2d from "../../camera/camera2d.ts"; +import { + registerPointerEvent, + releasePointerEvent, +} from "../../input/input.ts"; +import type Pointer from "../../input/pointer.ts"; +import { clamp } from "../../math/math.ts"; import type { Matrix3d } from "../../math/matrix3d.ts"; import type { Vector3d } from "../../math/vector3d.ts"; +import { off, on, POINTERMOVE } from "../../system/event.ts"; import type Renderer from "../../video/renderer.js"; +import Container from "../container.js"; import type Renderable from "../renderable.js"; import type { Paint } from "./panelpaint.ts"; import { PanelPainter } from "./panelpaint.ts"; import UIBaseElement from "./uibaseelement.ts"; +import type { UILabel } from "./uilabel.ts"; +import { createLabel } from "./uilabel.ts"; +import type { UISkin } from "./uiskin.ts"; +import { SkinDrawer } from "./uiskin.ts"; + +/** + * The art of a skinned {@link UIPanel}: one atlas region, drawn as a + * nine-slice over the whole panel and resized with it. + * @category UI + */ +export interface UIPanelSkin extends UISkin { + /** the background */ + region: string; +} + +/** + * How a {@link UIPanel} places its children. + * + * - `"none"`: where they are put + * - `"column"`: one above the other + * - `"row"`: side by side + * - `"grid"`: in rows of {@link UIPanelSettings.columns} equal cells + * @category UI + */ +export type UIPanelLayout = "none" | "column" | "row" | "grid"; + +/** + * Where a {@link UIPanel} lines its children up across the layout: the + * left, middle or right of a column, the top, middle or bottom of a row. + * `"stretch"` resizes each child to the full width of a column, the full + * height of a row, or the width of a grid cell. + * @category UI + */ +export type UIPanelAlign = "start" | "center" | "end" | "stretch"; + +/** + * Which ways a {@link UIPanel} scrolls. + * @category UI + */ +export type UIPanelScroll = "none" | "vertical" | "horizontal" | "both"; /** * Everything {@link UIPanel} takes. @@ -55,8 +103,96 @@ export interface UIPanelSettings { * drawn over a playfield that still has to receive clicks. */ blocking?: boolean; + /** draw the background from an atlas region, as a nine-slice */ + skin?: UIPanelSkin; + /** a title, centred at the top; the content starts below it */ + title?: string; + /** + * the title's font: a CSS font family, or the name of a loaded bitmap font + * @default "sans-serif" + */ + titleFont?: string; + /** + * the title's height, in pixels + * @default 20 + */ + titleFontSize?: number; + /** + * the title's colour + * @default "#ffffff" + */ + titleFillStyle?: string; + /** + * Let the user move the panel by dragging it. A scrolling panel is moved + * by its title and its frame, as a drag on the content scrolls it. + * @default false + */ + draggable?: boolean; + /** + * how the children are placed; see {@link UIPanelLayout} + * @default "none" + */ + layout?: UIPanelLayout; + /** + * the number of cells in a row of a `"grid"` layout + * @default 2 + */ + columns?: number; + /** + * The space between the content and the panel's frame, in pixels: inside + * the border, or inside the skin's insets, and below the title. + * @default 0 + */ + padding?: number; + /** + * the space between children in a layout, in pixels + * @default 8 + */ + gap?: number; + /** + * where children line up across the layout; see {@link UIPanelAlign} + * @default "start" + */ + align?: UIPanelAlign; + /** + * Resize the panel to its laid out content: the height of a column or a + * grid, the width of a row, and across a column or a row too unless the + * children are stretched. Ignored by a scrolling panel. + * @default false + */ + autoSize?: boolean; + /** + * Which ways the content scrolls when it does not fit: with the wheel + * over the panel, and by dragging it, with momentum. + * @default "none" + */ + scroll?: UIPanelScroll; + /** called when the content scrolls, with the new offsets */ + onScroll?: (scrollX: number, scrollY: number, panel: UIPanel) => void; + /** + * the scrollbar, shown while the content does not fit + * @default "#00000059" + */ + scrollbarColor?: Paint; +} + +/** a child's placement box, in the panel's coordinates */ +interface ChildBox { + child: Renderable; + /** the box's left and top edges, less the child's position */ + ox: number; + oy: number; + w: number; + h: number; } +/** how far a press has to move before it scrolls instead, in pixels */ +const SCROLL_THRESHOLD = 8; +/** how much speed a fling keeps per 60th of a second */ +const FRICTION = 0.95; +/** below this speed, in pixels per millisecond, a fling stops */ +const MIN_SPEED = 0.02; + /** * A rectangular surface with a background, an optional border and children. * @@ -144,6 +280,64 @@ export default class UIPanel extends UIBaseElement { */ bobPeriod: number; + /** the background art, when the panel is skinned */ + readonly skin: UIPanelSkin | undefined; + /** the title, drawn at the top, when there is one */ + readonly title: UILabel | undefined; + /** how the children are placed; see {@link UIPanelLayout} */ + layout: UIPanelLayout; + /** the number of cells in a row of a `"grid"` layout */ + columns: number; + /** the space between the content and the frame, in pixels */ + padding: number; + /** the space between children in a layout, in pixels */ + gap: number; + /** where children line up across the layout */ + align: UIPanelAlign; + /** resize the panel to its laid out content */ + autoSize: boolean; + /** which ways the content scrolls */ + scroll: UIPanelScroll; + /** called when the content scrolls, with the new offsets */ + onScroll: + | ((scrollX: number, scrollY: number, panel: UIPanel) => void) + | undefined; + /** the scrollbar, shown while the content does not fit */ + scrollbarColor: Paint; + + /** @ignore */ + private readonly _skin: SkinDrawer | undefined; + /** the title band's height, in pixels */ + private readonly _titleHeight: number; + /** how far the content is scrolled */ + private _scrollX = 0; + private _scrollY = 0; + /** how far it can be */ + private _maxScrollX = 0; + private _maxScrollY = 0; + /** set while a layout pass runs, so the resize it makes does not start another */ + private _layingOut = false; + /** a press on the content that may become a scroll */ + private _press: + | { + pointerId: number | undefined; + x: number; + y: number; + scrollX: number; + scrollY: number; + element: UIBaseElement | undefined; + scrolling: boolean; + lastX: number; + lastY: number; + lastTime: number; + } + | undefined; + /** the fling speed, in pixels per millisecond */ + private _velocityX = 0; + private _velocityY = 0; + /** whether the current press moves the panel */ + private _moving = false; + /** * The shape and colour plumbing, shared with {@link ProgressBar}. * @@ -191,6 +385,37 @@ export default class UIPanel extends UIBaseElement { this.blocking = settings.blocking ?? true; this.bobAmplitude = settings.bobAmplitude ?? 0; this.bobPeriod = settings.bobPeriod ?? 2000; + this.layout = settings.layout ?? "none"; + this.columns = Math.max(1, Math.floor(settings.columns ?? 2)); + this.padding = settings.padding ?? 0; + this.gap = settings.gap ?? 8; + this.align = settings.align ?? "start"; + this.autoSize = settings.autoSize ?? false; + this.scroll = settings.scroll ?? "none"; + this.onScroll = settings.onScroll; + this.scrollbarColor = settings.scrollbarColor ?? "#00000059"; + this.isDraggable = settings.draggable ?? false; + + this.skin = settings.skin; + if (this.skin !== undefined) { + this._skin = new SkinDrawer(this.skin); + // checked now, so a typo fails where it was written + this._skin.size(this.skin.region); + } + if (settings.title !== undefined) { + const size = settings.titleFontSize ?? 20; + this.title = createLabel({ + text: settings.title, + font: settings.titleFont ?? "sans-serif", + fontSize: size, + fillStyle: settings.titleFillStyle ?? "#ffffff", + textAlign: "center", + textBaseline: "middle", + }); + this._titleHeight = Math.ceil(size * 1.6); + } else { + this._titleHeight = 0; + } // A panel draws with PRIMITIVES, which go through a batcher that // never reads `customShader`, so a post effect on one would silently // do nothing on the fast path. This asks for the capture instead. @@ -257,6 +482,7 @@ export default class UIPanel extends UIBaseElement { // and there is nothing to gain by keeping the whole of it. this._bobTime = (this._bobTime + dt) % this.bobPeriod; } + const flinging = this.fling(dt); // `super.update` is what steps the children, so it is called either // way; the bob only ever adds a reason to redraw. const dirty = super.update(dt); @@ -265,7 +491,37 @@ export default class UIPanel extends UIBaseElement { this._bobApplied = offset; this.updateBounds(); } - return dirty || moving; + return dirty || moving || flinging; + } + + /** + * Carry a fling on, slowing it down. + * @param dt - time since the last frame, in milliseconds + * @returns whether the content is still moving + * @ignore + * @internal + */ + private fling(dt: number): boolean { + if (this._velocityX === 0 && this._velocityY === 0) { + return false; + } + const beforeX = this._scrollX; + const beforeY = this._scrollY; + this.scrollTo( + this._scrollX - this._velocityX * dt, + this._scrollY - this._velocityY * dt, + ); + const keep = FRICTION ** (dt / (1000 / 60)); + // stopped by an edge, or slowed to nothing + this._velocityX = + this._scrollX === beforeX || Math.abs(this._velocityX * keep) < MIN_SPEED + ? 0 + : this._velocityX * keep; + this._velocityY = + this._scrollY === beforeY || Math.abs(this._velocityY * keep) < MIN_SPEED + ? 0 + : this._velocityY * keep; + return true; } /** @@ -288,6 +544,16 @@ export default class UIPanel extends UIBaseElement { } const alpha = renderer.globalAlpha(); + if (this._skin !== undefined) { + this._skin.draw( + renderer, + this.skin!.region, + this.pos.x, + this.pos.y, + this.width, + this.height, + ); + } if (this.fillColor !== null) { this._painter.paint(renderer, this.fillColor, alpha); this._painter.rect( @@ -315,7 +581,63 @@ export default class UIPanel extends UIBaseElement { // does not fade the labels sitting on it renderer.setGlobalAlpha(alpha); + const fy = this.frameInsets()[1]; + if (this.title !== undefined) { + const tx = this.pos.x + this.width / 2; + const ty = this.pos.y + fy + this._titleHeight / 2; + if (this.title.pos.x !== tx || this.title.pos.y !== ty) { + this.title.setPosition(tx, ty); + } + this.title.preDraw(renderer); + this.title.draw(renderer); + this.title.postDraw(renderer); + renderer.setGlobalAlpha(alpha); + } + + if (this.scroll === "none") { + super.draw(renderer, viewport); + return; + } + + // the children, cropped to the content area. `save` and `restore` + // put back the clip and the translation `super.draw` adds. + const [vx, vy, vw, vh] = this.viewRect(); + renderer.save(); + renderer.clipRect(this.pos.x + vx, this.pos.y + vy, vw, vh); super.draw(renderer, viewport); + renderer.restore(); + renderer.setGlobalAlpha(alpha); + + // the scrollbars, over the content, while it does not fit + const bar = 4; + const inset = 3; + if (this._maxScrollY > 0 && vh > 0) { + const length = Math.max(16, (vh * vh) / (vh + this._maxScrollY)); + const at = (vh - length) * (this._scrollY / this._maxScrollY); + this._painter.paint(renderer, this.scrollbarColor, alpha); + this._painter.rect( + renderer, + this.pos.x + vx + vw - bar - inset, + this.pos.y + vy + at, + bar, + length, + bar / 2, + ); + } + if (this._maxScrollX > 0 && vw > 0) { + const length = Math.max(16, (vw * vw) / (vw + this._maxScrollX)); + const at = (vw - length) * (this._scrollX / this._maxScrollX); + this._painter.paint(renderer, this.scrollbarColor, alpha); + this._painter.rect( + renderer, + this.pos.x + vx + at, + this.pos.y + vy + vh - bar - inset, + length, + bar, + bar / 2, + ); + } + renderer.setGlobalAlpha(alpha); } /** @@ -388,6 +710,690 @@ export default class UIPanel extends UIBaseElement { return bounds; } + /** + * How far in from the edges the frame is: the skin's insets, or the + * border. + * @returns the horizontal and the vertical inset + * @ignore + * @internal + */ + private frameInsets(): [number, number] { + if (this.skin !== undefined) { + const region = this._skin!.size(this.skin.region); + return [ + this.skin.insetx ?? region.width / 4, + this.skin.insety ?? region.height / 4, + ]; + } + const border = this.borderColor !== null ? this.borderWidth : 0; + return [border, border]; + } + + /** + * The area the children show in: inside the frame and below the title. + * A scrolling panel crops them to it. + * @returns x, y, width and height, in the panel's coordinates + * @ignore + * @internal + */ + private viewRect(): [number, number, number, number] { + const [fx, fy] = this.frameInsets(); + return [ + fx, + fy + this._titleHeight, + Math.max(0, this.width - fx * 2), + Math.max(0, this.height - fy * 2 - this._titleHeight), + ]; + } + + /** + * The area a layout fills: the view, less the padding. + * @returns x, y, width and height, in the panel's coordinates + * @ignore + * @internal + */ + private contentRect(): [number, number, number, number] { + const [vx, vy, vw, vh] = this.viewRect(); + const p = this.padding; + return [vx + p, vy + p, Math.max(0, vw - p * 2), Math.max(0, vh - p * 2)]; + } + + /** + * The children in the order they were added, which is the order a layout + * places them in. + * @ignore + * @internal + */ + private orderedChildren(): Renderable[] { + return [...this.getChildren()].sort((a, b) => { + return a.depth - b.depth; + }); + } + + /** + * Where a child's box is, relative to its position, and how big it is. + * + * Read from its bounds rather than worked out from `anchorPoint`, because + * a `Text` places itself by its alignment instead. + * @ignore + * @internal + */ + private measure(child: Renderable): ChildBox { + const bounds = child.updateBounds(true); + const origin = child.getAbsolutePosition(); + return { + child, + ox: bounds.left - origin.x, + oy: bounds.top - origin.y, + w: bounds.width, + h: bounds.height, + }; + } + + /** + * Stretch a child, if it can be resized. + * @ignore + * @internal + */ + private stretch(child: Renderable, w: number, h: number) { + if ( + typeof (child as any).resize === "function" && + (child.width !== w || child.height !== h) + ) { + (child as any).resize(w, h); + } + } + + /** + * Place the children again, and work out how far the content scrolls. + * + * Runs by itself when a child is added or removed and when the panel is + * resized, not every frame. Call it when a child changes size, or after + * moving children by hand in a scrolling panel. + * @returns this panel + */ + relayout(): this { + if (this._layingOut) { + return this; + } + this._layingOut = true; + try { + if (this.layout !== "none") { + this.placeChildren(); + } + this.measureScroll(); + } finally { + this._layingOut = false; + } + return this; + } + + /** + * The layout pass. + * @ignore + * @internal + */ + private placeChildren() { + const children = this.orderedChildren(); + const gap = this.gap; + const align = this.align; + let [cx, cy, cw, ch] = this.contentRect(); + const cellWidth = + (cw - gap * (this.columns - 1)) / Math.max(1, this.columns); + + if (align === "stretch") { + for (const child of children) { + if (this.layout === "column") { + this.stretch(child, cw, child.height); + } else if (this.layout === "row") { + this.stretch(child, child.width, ch); + } else { + this.stretch(child, cellWidth, child.height); + } + } + } + const boxes = children.map((child) => { + return this.measure(child); + }); + + // how big the laid out content is + let contentW = 0; + let contentH = 0; + const rowHeights: number[] = []; + if (this.layout === "column") { + for (const box of boxes) { + contentW = Math.max(contentW, box.w); + contentH += box.h; + } + contentH += gap * Math.max(0, boxes.length - 1); + } else if (this.layout === "row") { + for (const box of boxes) { + contentH = Math.max(contentH, box.h); + contentW += box.w; + } + contentW += gap * Math.max(0, boxes.length - 1); + } else { + for (let i = 0; i < boxes.length; i++) { + const row = Math.floor(i / this.columns); + rowHeights[row] = Math.max(rowHeights[row] ?? 0, boxes[i].h); + } + for (const h of rowHeights) { + contentH += h; + } + contentH += gap * Math.max(0, rowHeights.length - 1); + contentW = cw; + } + + if (this.autoSize && this.scroll === "none") { + const [, , vw, vh] = this.viewRect(); + const frameW = this.width - vw; + const frameH = this.height - vh; + const pad = this.padding * 2; + let width = this.width; + let height = this.height; + if (this.layout === "column" || this.layout === "grid") { + height = frameH + pad + contentH; + } + if (this.layout === "row") { + width = frameW + pad + contentW; + } + if (align !== "stretch") { + if (this.layout === "column") { + width = frameW + pad + contentW; + } else if (this.layout === "row") { + height = frameH + pad + contentH; + } + } + if (width !== this.width || height !== this.height) { + this.resize(Math.ceil(width), Math.ceil(height)); + [cx, cy, cw, ch] = this.contentRect(); + } + } + + /** + * where a box goes across a span, by the alignment + * @param start - the span's start + * @param span - the span's length + * @param size - the box's size along it + * @returns the box's start + */ + const across = (start: number, span: number, size: number) => { + if (align === "center") { + return start + (span - size) / 2; + } + if (align === "end") { + return start + span - size; + } + return start; + }; + const place = (box: ChildBox, x: number, y: number) => { + const px = x - box.ox - this._scrollX; + const py = y - box.oy - this._scrollY; + if (box.child.pos.x !== px || box.child.pos.y !== py) { + box.child.setPosition(px, py); + } + }; + + if (this.layout === "column") { + let y = cy; + for (const box of boxes) { + place(box, across(cx, cw, box.w), y); + y += box.h + gap; + } + } else if (this.layout === "row") { + let x = cx; + for (const box of boxes) { + place(box, x, across(cy, ch, box.h)); + x += box.w + gap; + } + } else { + let y = cy; + for (let row = 0; row < rowHeights.length; row++) { + for (let col = 0; col < this.columns; col++) { + const box = boxes[row * this.columns + col]; + if (box === undefined) { + break; + } + const x = cx + col * (cellWidth + gap); + place( + box, + across(x, cellWidth, box.w), + across(y, rowHeights[row], box.h), + ); + } + y += rowHeights[row] + gap; + } + } + } + + /** + * Work out how far the content can scroll, from where the children are. + * @ignore + * @internal + */ + private measureScroll() { + if (this.scroll === "none") { + this._maxScrollX = this._maxScrollY = 0; + return; + } + const [vx, vy, vw, vh] = this.viewRect(); + let right = 0; + let bottom = 0; + for (const child of this.getChildren()) { + const box = this.measure(child); + // where it is with the content not scrolled + right = Math.max(right, child.pos.x + box.ox + box.w + this._scrollX); + bottom = Math.max(bottom, child.pos.y + box.oy + box.h + this._scrollY); + } + const horizontal = this.scroll === "horizontal" || this.scroll === "both"; + const vertical = this.scroll === "vertical" || this.scroll === "both"; + this._maxScrollX = horizontal + ? Math.max(0, right + this.padding - (vx + vw)) + : 0; + this._maxScrollY = vertical + ? Math.max(0, bottom + this.padding - (vy + vh)) + : 0; + // the content may have shrunk under the current offset + this.scrollTo(this._scrollX, this._scrollY); + } + + /** + * How far the content is scrolled to the right, in pixels. + * @default 0 + */ + get scrollX(): number { + return this._scrollX; + } + + set scrollX(value: number) { + this.scrollTo(value, this._scrollY); + } + + /** + * How far the content is scrolled down, in pixels. + * @default 0 + */ + get scrollY(): number { + return this._scrollY; + } + + set scrollY(value: number) { + this.scrollTo(this._scrollX, value); + } + + /** + * How far the content can scroll to the right, in pixels; 0 when it fits. + */ + get maxScrollX(): number { + return this._maxScrollX; + } + + /** + * How far the content can scroll down, in pixels; 0 when it fits. + */ + get maxScrollY(): number { + return this._maxScrollY; + } + + /** + * Scroll the content to an offset, clamped to how far it goes. + * + * Scrolling moves the children: their positions are where they are drawn. + * @param x - the horizontal offset, in pixels + * @param y - the vertical offset, in pixels + * @returns this panel + */ + scrollTo(x: number, y: number): this { + const nx = clamp(Number.isFinite(x) ? x : 0, 0, this._maxScrollX); + const ny = clamp(Number.isFinite(y) ? y : 0, 0, this._maxScrollY); + const dx = nx - this._scrollX; + const dy = ny - this._scrollY; + if (dx === 0 && dy === 0) { + return this; + } + this._scrollX = nx; + this._scrollY = ny; + for (const child of this.getChildren()) { + child.setPosition(child.pos.x - dx, child.pos.y - dy); + } + this.isDirty = true; + this.onScroll?.(nx, ny, this); + return this; + } + + /** + * Scroll just enough to bring a child fully into view. + * @param child - one of this panel's children + * @returns this panel + */ + scrollToChild(child: Renderable): this { + if (child.ancestor !== this) { + return this; + } + const [vx, vy, vw, vh] = this.viewRect(); + const box = this.measure(child); + const left = child.pos.x + box.ox; + const top = child.pos.y + box.oy; + let x = this._scrollX; + let y = this._scrollY; + if (left < vx) { + x -= vx - left; + } else if (left + box.w > vx + vw) { + x += Math.min(left - vx, left + box.w - (vx + vw)); + } + if (top < vy) { + y -= vy - top; + } else if (top + box.h > vy + vh) { + y += Math.min(top - vy, top + box.h - (vy + vh)); + } + return this.scrollTo(x, y); + } + + /** + * Whether the panel shows its children at a point: anywhere for a panel + * that does not scroll, only in its content area for one that does. + * @param x - the point, in screen pixels for a floating panel + * @param y - the point + * @returns false where the children are cropped away + */ + override clipContains(x: number, y: number): boolean { + if (this.scroll === "none") { + return super.clipContains(x, y); + } + const origin = this.getAbsolutePosition(); + const [vx, vy, vw, vh] = this.viewRect(); + return ( + x >= origin.x + vx && + x < origin.x + vx + vw && + y >= origin.y + vy && + y < origin.y + vy + vh + ); + } + + /** + * A pointer's position in the panel's coordinates. + * @ignore + * @internal + */ + private localPoint(pointer: Pointer): [number, number] { + let px = pointer.gameScreenX; + let py = pointer.gameScreenY; + if (!this.isFloating) { + const offset = this.parentApp?.world.pos; + px = pointer.gameWorldX + (offset?.x ?? 0); + py = pointer.gameWorldY + (offset?.y ?? 0); + } + const origin = this.getAbsolutePosition(); + return [px - origin.x, py - origin.y]; + } + + /** + * Whether a point, in the panel's coordinates, is in the content area. + * @ignore + * @internal + */ + private inView(x: number, y: number): boolean { + const [vx, vy, vw, vh] = this.viewRect(); + return x >= vx && x < vx + vw && y >= vy && y < vy + vh; + } + + /** + * Watch a press that may turn into a scroll. + * + * Called for a press on the content, and by a widget inside the panel + * for a press on it: see {@link UIBaseElement#cancelPress}. + * @param event - the pointer-down + * @param element - the widget pressed, if any + * @returns whether the panel took it; false when it does not scroll + * @ignore + * @internal + */ + _capturePress(event: Pointer, element?: UIBaseElement): boolean { + if (this.scroll === "none") { + return false; + } + if (this._press !== undefined) { + // the same press, offered again by the panel's own handler + return true; + } + const [x, y] = this.localPoint(event); + this._velocityX = this._velocityY = 0; + this._press = { + pointerId: event.pointerId, + x, + y, + scrollX: this._scrollX, + scrollY: this._scrollY, + element, + scrolling: false, + lastX: x, + lastY: y, + lastTime: globalThis.performance?.now() ?? Date.now(), + }; + on(POINTERMOVE, this._onPressMove); + globalThis.addEventListener?.("pointerup", this._onPressEnd); + globalThis.addEventListener?.("pointercancel", this._onPressEnd); + return true; + } + + private readonly _onPressMove = (pointer: Pointer) => { + const press = this._press; + if (press === undefined || pointer.pointerId !== press.pointerId) { + return; + } + const [x, y] = this.localPoint(pointer); + const horizontal = this._maxScrollX > 0; + const vertical = this._maxScrollY > 0; + const dx = horizontal ? x - press.x : 0; + const dy = vertical ? y - press.y : 0; + if (!press.scrolling) { + if (Math.hypot(dx, dy) < SCROLL_THRESHOLD) { + return; + } + // a scroll after all: the press on the widget is called off + press.scrolling = true; + press.element?.cancelPress(); + } + this.scrollTo(press.scrollX - dx, press.scrollY - dy); + const now = globalThis.performance?.now() ?? Date.now(); + const elapsed = now - press.lastTime; + if (elapsed > 0) { + // smoothed, so one jittery sample does not decide the fling + const vx = horizontal ? (x - press.lastX) / elapsed : 0; + const vy = vertical ? (y - press.lastY) / elapsed : 0; + this._velocityX = this._velocityX * 0.2 + vx * 0.8; + this._velocityY = this._velocityY * 0.2 + vy * 0.8; + } + press.lastX = x; + press.lastY = y; + press.lastTime = now; + }; + + private readonly _onPressEnd = (event: PointerEvent) => { + const press = this._press; + if (press === undefined || event.pointerId !== press.pointerId) { + return; + } + const now = globalThis.performance?.now() ?? Date.now(); + // a press that held still before letting go does not fling + if (!press.scrolling || now - press.lastTime > 100) { + this._velocityX = this._velocityY = 0; + } + this.endPress(); + }; + + /** + * Stop watching the press. + * @ignore + * @internal + */ + private endPress() { + this._press = undefined; + off(POINTERMOVE, this._onPressMove); + globalThis.removeEventListener?.("pointerup", this._onPressEnd); + globalThis.removeEventListener?.("pointercancel", this._onPressEnd); + } + + private readonly _onWheel = (pointer: Pointer) => { + if (this.scroll === "none") { + return; + } + const vh = this.viewRect()[3]; + // lines and pages, as some mice and settings report, into pixels + const unit = + pointer.deltaMode === 1 ? 16 : pointer.deltaMode === 2 ? vh : 1; + let dx = pointer.deltaX * unit; + let dy = pointer.deltaY * unit; + // a plain wheel scrolls a panel that only goes sideways + if (this._maxScrollY === 0 && dx === 0) { + dx = dy; + dy = 0; + } + const beforeX = this._scrollX; + const beforeY = this._scrollY; + this._velocityX = this._velocityY = 0; + this.scrollTo(this._scrollX + dx, this._scrollY + dy); + if (this._scrollX !== beforeX || this._scrollY !== beforeY) { + return false; + } + return this.blocking ? false : undefined; + }; + + /** + * Bring a draggable panel to the front when it is grabbed, and start + * watching a press on the content of a scrolling one. + * @ignore + */ + override clicked(event: Pointer): boolean | void { + if (event.button !== 0 || !this.isClickable) { + return super.clicked(event); + } + const [x, y] = this.localPoint(event); + const onContent = this.scroll !== "none" && this.inView(x, y); + this._moving = this.isDraggable && !onContent; + if (this.isDraggable && this.ancestor instanceof Container) { + this.ancestor.moveToTop(this); + } + if (onContent) { + this._capturePress(event); + } + return super.clicked(event); + } + + /** + * Move a draggable panel, unless the press began on the content of a + * scrolling one. + * @ignore + */ + override pointerMove(event: Pointer): void { + if (this._moving) { + super.pointerMove(event); + } + } + + /** + * Lay a new child out with the others. + * @ignore + */ + override addChild(child: Renderable, z?: number): Renderable { + const added = super.addChild(child, z); + this.adopt(child); + return added; + } + + /** + * Lay a new child out with the others. + * @ignore + */ + override addChildAt(child: Renderable, index: number): Renderable { + const added = super.addChildAt(child, index); + this.adopt(child); + return added; + } + + /** + * Let the layout close the gap. + * @ignore + */ + override removeChildNow(child: Renderable, keepalive?: boolean): void { + const scrolled = + this.layout === "none" && (this._scrollX !== 0 || this._scrollY !== 0); + if (scrolled && child.ancestor === this) { + // back to where it is in the content, not where the scroll put it + child.setPosition( + child.pos.x + this._scrollX, + child.pos.y + this._scrollY, + ); + } + super.removeChildNow(child, keepalive); + if (this.layout !== "none" || this.scroll !== "none") { + this.relayout(); + } + } + + /** + * Place a child just added. + * @ignore + * @internal + */ + private adopt(child: Renderable) { + if ( + this.layout === "none" && + (this._scrollX !== 0 || this._scrollY !== 0) + ) { + // placed in content coordinates: shown where the scroll has the + // content now + child.setPosition( + child.pos.x - this._scrollX, + child.pos.y - this._scrollY, + ); + } + if (this.layout !== "none" || this.scroll !== "none") { + this.relayout(); + } + } + + /** + * Resize the panel, and lay the children out again. + * @param w - the new width + * @param h - the new height + * @returns this panel + */ + override resize(w: number, h: number) { + super.resize(w, h); + // the constructor resizes before any setting is read + if (this.layout !== undefined && !this._layingOut) { + this.relayout(); + } + return this; + } + + /** + * Listen for the wheel, and for hover a blocking panel swallows. + * @ignore + */ + override onActivateEvent(): void { + super.onActivateEvent(); + registerPointerEvent("wheel", this, this._onWheel); + // `onOver` only fires on the frame the pointer crosses in; this keeps + // a blocking panel opaque to hover for as long as the pointer is on it + registerPointerEvent("pointermove", this, () => { + return !this.blocking; + }); + } + + /** + * Stop listening, and drop a press or a fling in progress. + * @ignore + */ + override onDeactivateEvent(): void { + releasePointerEvent("wheel", this); + releasePointerEvent("pointermove", this); + this.endPress(); + this._velocityX = this._velocityY = 0; + super.onDeactivateEvent(); + } + /** * Consume a click on the panel itself, unless told not to. * @@ -431,7 +1437,10 @@ export default class UIPanel extends UIBaseElement { * @ignore */ override onDestroyEvent() { + this.endPress(); this._painter.dispose(); + this._skin?.dispose(); + this.title?.destroy(); super.onDestroyEvent(); } } diff --git a/packages/melonjs/src/renderable/ui/uiskin.ts b/packages/melonjs/src/renderable/ui/uiskin.ts new file mode 100644 index 000000000..c7e2b678a --- /dev/null +++ b/packages/melonjs/src/renderable/ui/uiskin.ts @@ -0,0 +1,140 @@ +import type Renderer from "../../video/renderer.js"; +import type { TextureAtlas } from "../../video/texture/atlas.js"; +import NineSliceSprite from "../nineslicesprite.js"; + +/** + * What every widget skin shares: the atlas its regions come from, and how + * much of each region's edge is kept unscaled when it is stretched. + * + * A region drawn bigger than its art is drawn as a nine-slice: the corners + * keep their size and the edges and middle stretch, so a 190 pixel wide + * button image makes a button of any width. + * @category UI + */ +export interface UISkin { + /** the atlas the regions are read from */ + atlas: TextureAtlas; + /** + * the width of a corner kept unscaled, in pixels; a quarter of the + * region's width by default + */ + insetx?: number; + /** + * the height of a corner kept unscaled, in pixels; a quarter of the + * region's height by default + */ + insety?: number; +} + +/** the size of an atlas region */ +interface RegionSize { + width: number; + height: number; +} + +/** + * Draws named regions of a skin's atlas at any size. + * + * One `NineSliceSprite`, never added to the scene: a widget draws it inside + * its own `draw`, behind its label. As a child it would be laid out by a + * panel, hit-tested by the pointer and returned by `getChildren()`, and a + * button's background is none of those things. + * @ignore + * @internal + */ +export class SkinDrawer { + readonly atlas: TextureAtlas; + private _sprite: NineSliceSprite | undefined; + private _region = ""; + private readonly _insetx: number | undefined; + private readonly _insety: number | undefined; + + /** + * @param skin - the skin to draw from + */ + constructor(skin: UISkin) { + this.atlas = skin.atlas; + this._insetx = skin.insetx; + this._insety = skin.insety; + } + + /** + * The size a region was drawn at, or an error naming it. + * + * Checked when a widget is built, not when it is first drawn, so a typo + * in a region name fails where it was written. + * @param name - the region + * @returns its width and height + */ + size(name: string): RegionSize { + const region = this.atlas.getRegion(name) as RegionSize | undefined; + if (region === undefined) { + throw new Error(`UI skin: no region named "${name}" in the atlas`); + } + return region; + } + + /** + * Draw a region, stretched as a nine-slice to fill a rectangle. + * @param renderer - the renderer to draw with + * @param name - the region + * @param x - left edge + * @param y - top edge + * @param w - width + * @param h - height + */ + draw( + renderer: Renderer, + name: string, + x: number, + y: number, + w: number, + h: number, + ) { + if (w <= 0 || h <= 0) { + return; + } + let sprite = this._sprite; + if (sprite === undefined) { + sprite = new NineSliceSprite(0, 0, { + image: this.atlas, + region: name, + width: Math.max(w, 1), + height: Math.max(h, 1), + insetx: this._insetx, + insety: this._insety, + }); + sprite.anchorPoint.set(0, 0); + this._sprite = sprite; + this._region = name; + } else if (name !== this._region) { + sprite.setRegion(this.atlas.getRegion(name)); + this._region = name; + } + if (sprite.width !== w) { + sprite.width = w; + } + if (sprite.height !== h) { + sprite.height = h; + } + // `draw` and not `preDraw`/`draw`/`postDraw`: the widget has already + // applied its own transform and alpha, and the sprite draws at its + // `pos`, which is the top left corner here. Guarded, because every + // write to that observable recomputes the sprite's bounds. + if (sprite.pos.x !== x || sprite.pos.y !== y) { + sprite.pos.set(x, y); + } + sprite.draw(renderer); + } + + /** + * Free the sprite. Call from the owner's `onDestroyEvent`; safe twice. + */ + dispose() { + if (this._sprite !== undefined) { + this._sprite.destroy(); + this._sprite = undefined; + this._region = ""; + } + } +} diff --git a/packages/melonjs/src/renderable/ui/uislider.ts b/packages/melonjs/src/renderable/ui/uislider.ts new file mode 100644 index 000000000..5b77adc0c --- /dev/null +++ b/packages/melonjs/src/renderable/ui/uislider.ts @@ -0,0 +1,769 @@ +import type Camera2d from "../../camera/camera2d.ts"; +import { + registerPointerEvent, + releasePointerEvent, +} from "../../input/input.ts"; +import type Pointer from "../../input/pointer.ts"; +import { clamp } from "../../math/math.ts"; +import { off, on, POINTERMOVE } from "../../system/event.ts"; +import type Renderer from "../../video/renderer.js"; +import type { Paint } from "./panelpaint.ts"; +import { PanelPainter } from "./panelpaint.ts"; +import UIBaseElement from "./uibaseelement.ts"; +import type { UILabel } from "./uilabel.ts"; +import { createLabel } from "./uilabel.ts"; +import type { UISkin } from "./uiskin.ts"; +import { SkinDrawer } from "./uiskin.ts"; + +/** + * The art of a skinned {@link UISlider}. + * + * The track and the fill are drawn as nine-slices along the slider's length; + * the thumb at its own size. + * @category UI + */ +export interface UISliderSkin extends UISkin { + /** the whole track */ + track: string; + /** the part of the track up to the thumb; left out, it is not drawn */ + fill?: string; + /** the thumb */ + thumb: string; + /** the thumb under the pointer or while dragged; `thumb` if left out */ + thumbHover?: string; +} + +/** + * Everything {@link UISlider} takes. + * @category UI + */ +export interface UISliderSettings { + /** + * the lowest value + * @default 0 + */ + min?: number; + /** + * the highest value + * @default 1 + */ + max?: number; + /** + * The step values snap to; `0` for none. + * @default 0 + */ + step?: number; + /** the starting value; `min` by default */ + value?: number; + /** + * which way it slides; a vertical slider has its highest value at the top + * @default "horizontal" + */ + orientation?: "horizontal" | "vertical"; + /** + * the length of the track, in pixels + * @default 200 + */ + length?: number; + /** + * the thickness of the track, in pixels (drawn look; a skin uses its + * track region's) + * @default 6 + */ + trackThickness?: number; + /** + * the diameter of the thumb, in pixels (drawn look; a skin uses its + * thumb region's size) + * @default 20 + */ + thumbSize?: number; + /** called on every change the user makes; see {@link UISlider#onChange} */ + onChange?: (value: number, slider: UISlider) => void; + /** called once when the user lets go; see {@link UISlider#onChangeEnd} */ + onChangeEnd?: (value: number, slider: UISlider) => void; + /** + * whether it answers input + * @default true + */ + enabled?: boolean; + /** a caption, drawn above the track */ + label?: string; + /** + * show the value as text: right of the caption on a horizontal slider, + * below the track on a vertical one + * @default false + */ + showValue?: boolean; + /** how the value is written, e.g. `(v) => Math.round(v * 100) + "%"` */ + format?: (value: number) => string; + /** + * a CSS font family, or the name of a loaded bitmap font + * @default "sans-serif" + */ + font?: string; + /** + * the text height, in pixels + * @default 16 + */ + fontSize?: number; + /** + * the text colour + * @default "#000000" + */ + labelFillStyle?: string; + /** + * the track (drawn look) + * @default "#00000033" + */ + trackColor?: Paint; + /** + * the track up to the thumb (drawn look) + * @default "#2a7de1" + */ + fillColor?: Paint; + /** + * the thumb (drawn look) + * @default "#ffffff" + */ + thumbColor?: Paint; + /** + * the thumb under the pointer or while dragged (drawn look) + * @default "#e4eefb" + */ + hoverOnColor?: Paint; + /** + * the thumb's outline (drawn look) + * @default "#2a7de1" + */ + borderColor?: Paint; + /** draw it from atlas regions instead */ + skin?: UISliderSkin; +} + +/** a rectangle in the slider's own coordinates */ +interface Box { + x: number; + y: number; + w: number; + h: number; +} + +/** + * A value picked by sliding a thumb along a track: volume, sensitivity, + * brightness. + * + * Drag the thumb, or click or tap the track to jump there. The drag keeps + * following the pointer once it leaves the track. The mouse wheel over the + * slider moves it a step. Drawn with primitives by default, or from atlas + * regions through a {@link UISliderSkin}. + * + * Its value is {@link UISlider#value}. `onChange` hears every change the + * user makes, while dragging too, and `onChangeEnd` once when they let go, + * which is where to save settings or play a test sound. Neither is called + * when `value` is set from code. + * + * Inside a scrolling {@link UIPanel}, dragging the thumb moves the slider, + * not the panel. + * @category UI + * @example + * const volume = new UISlider(40, 80, { + * label: "Music", + * showValue: true, + * format: (v) => Math.round(v * 100) + "%", + * value: audio.getVolume(), + * onChange: (v) => audio.setVolume(v), + * onChangeEnd: () => saveSettings(), + * }); + * panel.addChild(volume); + */ +export default class UISlider extends UIBaseElement { + /** + * Called with the new value on every change the user makes: each drag + * step, a click on the track, a wheel step. Not when + * {@link UISlider#value} is set from code. + */ + onChange: ((value: number, slider: UISlider) => void) | undefined; + + /** + * Called once when the user lets go after changing the value, and after + * each wheel step. For work that should not run on every drag step: + * saving settings, playing a test sound. + */ + onChangeEnd: ((value: number, slider: UISlider) => void) | undefined; + + /** how the value is written */ + format: (value: number) => string; + + /** the track (drawn look) */ + trackColor: Paint; + /** the track up to the thumb (drawn look) */ + fillColor: Paint; + /** the thumb (drawn look) */ + thumbColor: Paint; + /** the thumb under the pointer or while dragged (drawn look) */ + hoverOnColor: Paint; + /** the thumb's outline (drawn look) */ + borderColor: Paint; + + /** which way it slides */ + readonly orientation: "horizontal" | "vertical"; + /** the art, when it is skinned */ + readonly skin: UISliderSkin | undefined; + /** the caption, if any */ + readonly label: UILabel | undefined; + /** the value text, when `showValue` is set */ + readonly valueText: UILabel | undefined; + + /** @ignore */ + private _min: number; + /** @ignore */ + private _max: number; + /** @ignore */ + private _step: number; + /** @ignore */ + private _value: number; + /** @ignore */ + private _enabled = true; + /** @ignore */ + private readonly _painter = new PanelPainter(); + /** @ignore */ + private readonly _skin: SkinDrawer | undefined; + /** the track, in the slider's coordinates */ + private readonly _track: Box; + /** where the thumb's centre can go, along the slider */ + private readonly _travelStart: number; + private readonly _travelLength: number; + /** the thumb's size across and along the slider */ + private readonly _thumbW: number; + private readonly _thumbH: number; + /** where the value text and the caption go, in the slider's coordinates */ + private readonly _valueAt: [number, number] = [0, 0]; + private readonly _labelAt: [number, number] = [0, 0]; + + /** whether a drag is going on */ + private _dragging = false; + /** the pointer's distance from the thumb's centre when it was grabbed */ + private _grab = 0; + /** the value when the drag began */ + private _dragFrom = 0; + /** the pointer the drag follows */ + private _dragPointer: number | undefined; + + private readonly _onDragMove = (pointer: Pointer) => { + if (this._dragging && pointer.pointerId === this._dragPointer) { + this.setFromUser(this.valueAt(pointer) - this._grab); + } + }; + + private readonly _onDragEnd = (event: PointerEvent) => { + if (this._dragging && event.pointerId === this._dragPointer) { + this.endDrag(true); + } + }; + + private readonly _onWheel = (pointer: Pointer) => { + if (!this._enabled) { + return; + } + const delta = + Math.abs(pointer.deltaY) >= Math.abs(pointer.deltaX) + ? pointer.deltaY + : pointer.deltaX; + if (delta === 0) { + return; + } + const step = this._step > 0 ? this._step : (this._max - this._min) / 100; + // wheel up, or to the right, raises the value + if (this.setFromUser(this._value - Math.sign(delta) * step)) { + this.onChangeEnd?.(this._value, this); + } + // consumed, so a scrolling panel under it does not scroll too + return false; + }; + + /** + * @param x - left edge + * @param y - top edge + * @param settings - see {@link UISliderSettings} + */ + constructor(x: number, y: number, settings: UISliderSettings = {}) { + super(x, y, 1, 1); + + // a drag on the slider is the slider's, even in a scrolling panel + this.keepsDrag = true; + + this._min = settings.min ?? 0; + this._max = settings.max ?? 1; + if (this._max < this._min) { + [this._min, this._max] = [this._max, this._min]; + } + this._step = Math.max(0, settings.step ?? 0); + this.orientation = settings.orientation ?? "horizontal"; + this.onChange = settings.onChange; + this.onChangeEnd = settings.onChangeEnd; + this.format = settings.format ?? defaultFormat(this._step); + + this.trackColor = settings.trackColor ?? "#00000033"; + this.fillColor = settings.fillColor ?? "#2a7de1"; + this.thumbColor = settings.thumbColor ?? "#ffffff"; + this.hoverOnColor = settings.hoverOnColor ?? "#e4eefb"; + this.borderColor = settings.borderColor ?? "#2a7de1"; + + const horizontal = this.orientation === "horizontal"; + const length = settings.length ?? 200; + let thickness = settings.trackThickness ?? 6; + let thumbW = settings.thumbSize ?? 20; + let thumbH = thumbW; + + this.skin = settings.skin; + if (this.skin !== undefined) { + this._skin = new SkinDrawer(this.skin); + const track = this._skin.size(this.skin.track); + const thumb = this._skin.size(this.skin.thumb); + if (this.skin.fill !== undefined) { + this._skin.size(this.skin.fill); + } + if (this.skin.thumbHover !== undefined) { + this._skin.size(this.skin.thumbHover); + } + // across the slider, the art keeps its own size + thickness = + settings.trackThickness ?? (horizontal ? track.height : track.width); + // the thumb's size along and across the slider + thumbW = horizontal ? thumb.width : thumb.height; + thumbH = horizontal ? thumb.height : thumb.width; + } + this._thumbW = thumbW; + this._thumbH = thumbH; + + const textStyle = { + font: settings.font ?? "sans-serif", + fontSize: settings.fontSize ?? 16, + fillStyle: settings.labelFillStyle ?? "#000000", + textBaseline: "middle", + }; + if (settings.label !== undefined) { + this.label = createLabel({ + ...textStyle, + text: settings.label, + textAlign: horizontal ? "left" : "center", + }); + } + if (settings.showValue === true) { + this.valueText = createLabel({ + ...textStyle, + text: this.format(this._max), + textAlign: horizontal ? "right" : "center", + }); + } + const rowHeight = + this.label !== undefined || this.valueText !== undefined + ? Math.ceil(textStyle.fontSize * 1.4) + : 0; + + // the band the thumb slides in: as thick as the thumb or the track + const band = Math.max(thumbH, thickness); + if (horizontal) { + const top = rowHeight; + this._track = { + x: 0, + y: top + (band - thickness) / 2, + w: length, + h: thickness, + }; + this._travelStart = thumbW / 2; + this._valueAt[0] = length; + this._valueAt[1] = rowHeight / 2; + this._labelAt[1] = rowHeight / 2; + this.resize(length, top + band); + } else { + const textWidth = Math.max( + this.label?.measureText().width ?? 0, + this.valueText?.measureText().width ?? 0, + ); + const width = Math.ceil(Math.max(band, textWidth)); + const top = this.label !== undefined ? rowHeight : 0; + this._track = { + x: (width - thickness) / 2, + y: top, + w: thickness, + h: length, + }; + this._travelStart = top + thumbW / 2; + this._valueAt[0] = width / 2; + this._valueAt[1] = top + length + rowHeight / 2; + this._labelAt[0] = width / 2; + this._labelAt[1] = rowHeight / 2; + this.resize(width, top + length + (this.valueText ? rowHeight : 0)); + } + this._travelLength = Math.max(0, length - thumbW); + + this._value = this.snap(settings.value ?? this._min); + this.valueText?.setText(this.format(this._value)); + this.enabled = settings.enabled ?? true; + } + + /** the lowest value */ + get min(): number { + return this._min; + } + + /** the highest value */ + get max(): number { + return this._max; + } + + /** the step values snap to; `0` for none */ + get step(): number { + return this._step; + } + + /** + * The current value, between {@link UISlider#min} and + * {@link UISlider#max}. + * + * Setting it snaps it to the step and clamps it, and does NOT call + * `onChange` or `onChangeEnd`, so restoring saved settings does not + * trigger what a user's change would. + */ + get value(): number { + return this._value; + } + + set value(v: number) { + const next = this.snap(v); + if (next !== this._value) { + this._value = next; + this.valueText?.setText(this.format(next)); + this.isDirty = true; + } + } + + /** + * Whether it answers input. + * + * A disabled slider is drawn at half opacity and ignores the pointer and + * the wheel; disabling it mid-drag ends the drag. + * @default true + */ + get enabled(): boolean { + return this._enabled; + } + + set enabled(value: boolean) { + this._enabled = value; + this.isClickable = value; + if (!value) { + this.endDrag(false); + this.cancelPress(); + } + this.isDirty = true; + } + + /** + * Whether the thumb is being dragged. + */ + get dragging(): boolean { + return this._dragging; + } + + /** + * Snap a value to the step and clamp it to the range. + * @param v - the value + * @returns the value it becomes + * @ignore + * @internal + */ + private snap(v: number): number { + if (!Number.isFinite(v)) { + return this._value ?? this._min; + } + let out = v; + if (this._step > 0) { + out = this._min + Math.round((out - this._min) / this._step) * this._step; + // 0.1 + 0.2: keep the digits the step has, and no stray ones + out = Number(out.toFixed(decimals(this._step))); + } + return clamp(out, this._min, this._max); + } + + /** + * Set the value as the user, calling `onChange` if it changed. + * @param v - the value + * @returns whether it changed + * @ignore + * @internal + */ + private setFromUser(v: number): boolean { + const before = this._value; + this.value = v; + if (this._value !== before) { + this.onChange?.(this._value, this); + return true; + } + return false; + } + + /** where the thumb's centre is, along the slider, in its coordinates */ + private thumbCenter(): number { + const span = this._max - this._min; + const ratio = span > 0 ? (this._value - this._min) / span : 0; + return this.orientation === "horizontal" + ? this._travelStart + ratio * this._travelLength + : this._travelStart + (1 - ratio) * this._travelLength; + } + + /** + * The pointer's position in the slider's coordinates. + * @ignore + * @internal + */ + private localPoint(pointer: Pointer): [number, number] { + let px = pointer.gameScreenX; + let py = pointer.gameScreenY; + if (!this.isFloating) { + const offset = this.parentApp?.world.pos; + px = pointer.gameWorldX + (offset?.x ?? 0); + py = pointer.gameWorldY + (offset?.y ?? 0); + } + const origin = this.getAbsolutePosition(); + return [px - origin.x, py - origin.y]; + } + + /** + * The value under the pointer. + * @ignore + * @internal + */ + private valueAt(pointer: Pointer): number { + const [lx, ly] = this.localPoint(pointer); + const along = this.orientation === "horizontal" ? lx : ly; + const ratio = + this._travelLength > 0 + ? clamp((along - this._travelStart) / this._travelLength, 0, 1) + : 0; + const r = this.orientation === "horizontal" ? ratio : 1 - ratio; + return this._min + r * (this._max - this._min); + } + + /** + * Start a drag on a press on the track or the thumb; a press on the + * caption is let through. + * @ignore + * @internal + */ + override clicked(event: Pointer): boolean | void { + if (event.button !== 0 || !this.isClickable || this._dragging) { + return; + } + const [lx, ly] = this.localPoint(event); + const horizontal = this.orientation === "horizontal"; + const along = horizontal ? lx : ly; + const across = horizontal ? ly : lx; + const t = this._track; + const bandCenter = horizontal ? t.y + t.h / 2 : t.x + t.w / 2; + const half = Math.max(this._thumbH, horizontal ? t.h : t.w) / 2; + const start = horizontal ? t.x : t.y; + const end = start + (horizontal ? t.w : t.h); + if (Math.abs(across - bandCenter) > half || along < start || along > end) { + return; + } + + const center = this.thumbCenter(); + const onThumb = Math.abs(along - center) <= this._thumbW / 2; + this._dragging = true; + this._dragPointer = event.pointerId; + this._dragFrom = this._value; + this.released = false; + this.isDirty = true; + on(POINTERMOVE, this._onDragMove); + globalThis.addEventListener?.("pointerup", this._onDragEnd); + globalThis.addEventListener?.("pointercancel", this._onDragEnd); + + if (onThumb) { + // grabbed where it was: the thumb does not jump under the pointer + this._grab = this.valueAt(event) - this._value; + } else { + this._grab = 0; + this.setFromUser(this.valueAt(event)); + } + this.onClick(event); + // consumed: what is under the slider does not get the press + return false; + } + + /** + * Stop dragging. + * @param notify - call `onChangeEnd` if the value moved + * @ignore + * @internal + */ + private endDrag(notify: boolean) { + if (!this._dragging) { + return; + } + this._dragging = false; + this._dragPointer = undefined; + this.released = true; + this.isDirty = true; + off(POINTERMOVE, this._onDragMove); + globalThis.removeEventListener?.("pointerup", this._onDragEnd); + globalThis.removeEventListener?.("pointercancel", this._onDragEnd); + if (notify && this._value !== this._dragFrom) { + this.onChangeEnd?.(this._value, this); + } + } + + /** + * The pointer leaving does not end a drag; letting go does. + * @ignore + */ + override release(event: Pointer): boolean | void { + if (this._dragging) { + return false; + } + return super.release(event); + } + + /** + * Listen to the wheel while in the scene. + * @ignore + */ + override onActivateEvent(): void { + super.onActivateEvent(); + registerPointerEvent("wheel", this, this._onWheel); + } + + /** + * Stop listening, and drop a drag in progress. + * @ignore + */ + override onDeactivateEvent(): void { + releasePointerEvent("wheel", this); + this.endDrag(false); + super.onDeactivateEvent(); + } + + /** + * Draw the track, the fill, the thumb, then the text. + * @param renderer - the renderer to draw with + * @param viewport - the camera being drawn through + */ + override draw(renderer: Renderer, viewport?: Camera2d) { + const alpha = renderer.globalAlpha(); + const a = this._enabled ? alpha : alpha * 0.5; + const horizontal = this.orientation === "horizontal"; + const x = this.pos.x; + const y = this.pos.y; + const t = this._track; + const center = this.thumbCenter(); + const lit = this._enabled && (this._dragging || this.hover); + + // the filled part: from the low end to the thumb + const fx = x + t.x; + const fy = horizontal ? y + t.y : y + center; + const fw = horizontal ? center - t.x : t.w; + const fh = horizontal ? t.h : t.y + t.h - center; + // the thumb, centred on its place + const tw = horizontal ? this._thumbW : this._thumbH; + const th = horizontal ? this._thumbH : this._thumbW; + const tx = horizontal ? x + center - tw / 2 : x + t.x + t.w / 2 - tw / 2; + const ty = horizontal ? y + t.y + t.h / 2 - th / 2 : y + center - th / 2; + + if (this._skin !== undefined) { + const skin = this.skin!; + renderer.setGlobalAlpha(a); + this._skin.draw(renderer, skin.track, x + t.x, y + t.y, t.w, t.h); + if (skin.fill !== undefined) { + this._skin.draw(renderer, skin.fill, fx, fy, fw, fh); + } + const thumb = + lit && skin.thumbHover !== undefined ? skin.thumbHover : skin.thumb; + this._skin.draw(renderer, thumb, tx, ty, tw, th); + } else { + const painter = this._painter; + const radius = Math.min(t.w, t.h) / 2; + painter.paint(renderer, this.trackColor, a); + painter.rect(renderer, x + t.x, y + t.y, t.w, t.h, radius); + painter.paint(renderer, this.fillColor, a); + painter.rect(renderer, fx, fy, fw, fh, radius); + const r = this._thumbW / 2; + painter.paint(renderer, lit ? this.hoverOnColor : this.thumbColor, a); + renderer.fillEllipse(tx + tw / 2, ty + th / 2, r, r); + const previous = renderer.lineWidth; + renderer.lineWidth = 2; + painter.paint(renderer, this.borderColor, a); + renderer.strokeEllipse(tx + tw / 2, ty + th / 2, r - 1, r - 1); + renderer.lineWidth = previous; + } + + renderer.setGlobalAlpha(a); + if (this.label !== undefined) { + drawText( + renderer, + this.label, + x + this._labelAt[0], + y + this._labelAt[1], + ); + } + if (this.valueText !== undefined) { + drawText( + renderer, + this.valueText, + x + this._valueAt[0], + y + this._valueAt[1], + ); + } + renderer.setGlobalAlpha(alpha); + super.draw(renderer, viewport); + } + + /** + * Hand back what it borrowed. + * @ignore + */ + override onDestroyEvent() { + this.endDrag(false); + this._painter.dispose(); + this._skin?.dispose(); + this.label?.destroy(); + this.valueText?.destroy(); + super.onDestroyEvent(); + } +} + +/** + * Draw a label at a point, which its alignment is about. + * @ignore + * @internal + */ +function drawText(renderer: Renderer, text: UILabel, x: number, y: number) { + if (text.pos.x !== x || text.pos.y !== y) { + text.setPosition(x, y); + } + text.preDraw(renderer); + text.draw(renderer); + text.postDraw(renderer); +} + +/** + * How many decimals a step has. + * @ignore + * @internal + */ +function decimals(step: number): number { + const text = String(step); + const dot = text.indexOf("."); + return dot === -1 ? 0 : text.length - dot - 1; +} + +/** + * The default value text: whole numbers for a whole step, two decimals + * otherwise. + * @ignore + * @internal + */ +function defaultFormat(step: number): (value: number) => string { + const digits = step > 0 ? decimals(step) : 2; + return (value: number) => value.toFixed(digits); +} diff --git a/packages/melonjs/src/renderable/ui/uitextbutton.ts b/packages/melonjs/src/renderable/ui/uitextbutton.ts index 193141be2..8f40c09c7 100644 --- a/packages/melonjs/src/renderable/ui/uitextbutton.ts +++ b/packages/melonjs/src/renderable/ui/uitextbutton.ts @@ -5,37 +5,127 @@ import type { Bounds } from "../../physics/bounds.ts"; import { BLUR, KEYDOWN, KEYUP, off, on } from "../../system/event.ts"; import type Renderer from "../../video/renderer.js"; import BitmapText from "../text/bitmaptext.js"; -import UIBaseElement from "./uibaseelement.ts"; +import Text from "../text/text.js"; +import UIBaseElement, { isPointerUp } from "./uibaseelement.ts"; +import type { UILabel } from "./uilabel.ts"; +import { isBitmapFont } from "./uilabel.ts"; +import type { UISkin } from "./uiskin.ts"; +import { SkinDrawer } from "./uiskin.ts"; -interface UITextButtonSettings { +/** + * The art of a skinned {@link UITextButton}: one atlas region per state. + * + * Each region is drawn as a nine-slice, so the button can be any size. + * @category UI + */ +export interface UITextButtonSkin extends UISkin { + /** the button at rest */ + normal: string; + /** + * The button held down. When it is shorter than `normal`, as pressed art + * usually is, it is drawn lowered by the difference and the label moves + * down with it. + */ + pressed?: string; + /** the button under the pointer; `normal` if left out */ + hover?: string; + /** the button while disabled; `normal` at half opacity if left out */ + disabled?: string; +} + +/** + * Everything {@link UITextButton} takes. + * @category UI + */ +export interface UITextButtonSettings { + /** + * The font: the name of a loaded bitmap font, or a CSS font family. A + * name with both a font image and font data in the loader is drawn + * through {@link BitmapText}, anything else through {@link Text}. + * @default "sans-serif" + */ font?: string; + /** + * With a bitmap font, a scale of its authored size; with a CSS font, the + * height in pixels. + */ size?: number; + /** the label */ text?: string; + /** the label's colour; a bitmap font is tinted with it */ + fillStyle?: string; /** see {@link UITextButton.bindKey} */ bindKey?: string | number; + /** + * Called once per completed click: pressed and released on the button, + * or its {@link UITextButton.bindKey} pressed and released. A press + * dragged off the button, cancelled, or turned into a scroll by a + * {@link UIPanel} does not call it. Nor does a disabled button. + */ + onClick?: (button: UITextButton) => void; + /** + * whether the button answers input + * @default true + */ + enabled?: boolean; + /** draw the button from atlas regions instead of a rounded rectangle */ + skin?: UITextButtonSkin; + /** background colour at rest (drawn look) */ hoverOffColor?: string; + /** background colour under the pointer (drawn look) */ hoverOnColor?: string; + /** background colour while held down (drawn look) */ + pressedColor?: string; + /** outline colour (drawn look) */ borderStrokeColor?: string; - fillStyle?: string; textAlign?: string; textBaseline?: string; + /** the button's width; the label's width plus 16 by default, or the skin's */ borderWidth?: number; + /** the button's height; the label's height plus 16 by default, or the skin's */ borderHeight?: number; [key: string]: any; } /** - * This is a basic base text button which you can use in your Game UI. + * A button with a label, drawn as a rounded rectangle, or from atlas art + * through a {@link UITextButtonSkin}. + * + * Give it an `onClick` for what it does. That is called on a COMPLETED + * click, so a press can still be taken back by dragging off the button, and + * a drag that starts on it inside a scrolling {@link UIPanel} scrolls the + * panel instead. + * + * The {@link UIBaseElement#onClick} and {@link UIBaseElement#onRelease} + * methods are still there for a subclass, and are called on the press and + * on the release as before. * @category UI + * @example + * // drawn, with no assets + * const play = new UITextButton(100, 200, { + * text: "Play", + * size: 24, + * onClick: () => state.change(state.PLAY), + * }); + * + * // skinned from an atlas, any width + * const accept = new UITextButton(30, 250, { + * font: "kenpixel", + * size: 12, + * text: "Accept", + * borderWidth: 160, + * skin: { atlas, normal: "green_button04", pressed: "green_button05" }, + * onClick: () => saveSettings(), + * }); */ export default class UITextButton extends UIBaseElement { /** * Keyboard shortcut: an input.KEY keycode or an existing input action name. * A press calls onClick() and its matching release calls onRelease(), both - * without a pointer event. Shortcuts are active only while the button is - * activated and never change global key bindings. The first input to press - * owns the button until released; shortcuts do not start pointer dragging - * or tap-and-hold. + * without a pointer event, then the `onClick` setting. Shortcuts are active + * only while the button is activated and never change global key bindings. + * The first input to press owns the button until released; shortcuts do + * not start pointer dragging or tap-and-hold. * * `isClickable` gates the PRESS, not the release: a button disabled while * its key is held still releases when that key comes up, so it cannot be @@ -51,6 +141,11 @@ export default class UITextButton extends UIBaseElement { */ bindKey: string | number; + /** + * What the button does: see {@link UITextButtonSettings.onClick}. + */ + clickCallback: ((button: UITextButton) => void) | undefined; + private keyboardKey: number | undefined; private keyboardActive = false; @@ -82,6 +177,7 @@ export default class UITextButton extends UIBaseElement { this.released = true; this.isDirty = true; this.onRelease(); + this.completeClick(); }; private readonly cancelKeyboardPress = () => { @@ -102,6 +198,12 @@ export default class UITextButton extends UIBaseElement { */ hoverOnColor: string; + /** + * The css value of the background while the button is held down + * @default "#008800ff" + */ + pressedColor: string; + /** * The css value of a color to be used to draw the border */ @@ -122,9 +224,10 @@ export default class UITextButton extends UIBaseElement { textBaseline: string; /** - * the bitmapText used by the UITextButton class + * The label: a {@link BitmapText} for a loaded bitmap font, a + * {@link Text} otherwise. */ - bitmapText: BitmapText; + label: UILabel; /** * the measured text dimensions @@ -136,6 +239,16 @@ export default class UITextButton extends UIBaseElement { */ border: RoundRect; + /** the art, when the button is skinned */ + readonly skin: UITextButtonSkin | undefined; + + /** @ignore */ + private _skin: SkinDrawer | undefined; + /** @ignore */ + private _enabled = true; + /** how far the label is lowered right now, for the pressed art */ + private _labelDrop = 0; + /** * A Bitmap Text Button with an outlined background border, filled with background color. * It uses a RoundRect as background and changes the background color on hovering over. @@ -143,7 +256,7 @@ export default class UITextButton extends UIBaseElement { * slightly shining through. * @param x - x pos of the button * @param y - y pos of the button - * @param settings - settings object + * @param settings - see {@link UITextButtonSettings} * @example * // Create a new Button * class PlayButton extends UITextButton { @@ -170,9 +283,11 @@ export default class UITextButton extends UIBaseElement { super(x, y); this.bindKey = settings.bindKey ?? -1; + this.clickCallback = settings.onClick; this.hoverOffColor = settings.hoverOffColor || "#00aa0080"; this.hoverOnColor = settings.hoverOnColor || "#00ff00ff"; + this.pressedColor = settings.pressedColor || "#008800ff"; this.borderStrokeColor = settings.borderStrokeColor || "#000000"; @@ -181,30 +296,131 @@ export default class UITextButton extends UIBaseElement { this.textBaseline = settings.textBaseline = settings.textBaseline || "middle"; - this.bitmapText = new BitmapText(0, 0, settings as any); + const font = settings.font ?? "sans-serif"; + if (isBitmapFont(font)) { + this.label = new BitmapText(0, 0, settings as any); + } else { + this.label = new Text(0, 0, { + font, + size: settings.size ?? 16, + fillStyle: settings.fillStyle ?? "#000000", + textAlign: this.textAlign, + textBaseline: this.textBaseline, + text: settings.text ?? "", + }); + } + + this.skin = settings.skin; + let width = settings.borderWidth; + let height = settings.borderHeight; + if (this.skin !== undefined) { + this._skin = new SkinDrawer(this.skin); + // every region named is checked now, so a typo fails here + const normal = this._skin.size(this.skin.normal); + for (const state of ["pressed", "hover", "disabled"] as const) { + const region = this.skin[state]; + if (region !== undefined) { + this._skin.size(region); + } + } + width ||= normal.width; + height ||= normal.height; + } // "detect" the button size - this.dimensions = this.bitmapText.measureText(); - settings.borderWidth = settings.borderWidth || this.dimensions.width + 16; - settings.borderHeight = - settings.borderHeight || this.dimensions.height + 16; + this.dimensions = this.label.measureText(); + width ||= this.dimensions.width + 16; + height ||= this.dimensions.height + 16; + settings.borderWidth = width; + settings.borderHeight = height; // create the round rect button - this.border = new RoundRect( - x, - y, - settings.borderWidth, - settings.borderHeight, - ); + this.border = new RoundRect(x, y, width, height); // resize the container accordingly - this.resize(this.border.getBounds().width, this.border.getBounds().height); + this.resize(width, height); + + // add the label to the UI container + this.addChild(this.label); + + this.enabled = settings.enabled ?? true; + } + + /** + * the label, under its original name + * @deprecated since 20.13.0, use {@link UITextButton#label}, which is a + * {@link Text} when the font is not a bitmap font + */ + get bitmapText(): UILabel { + return this.label; + } + + /** + * Whether the button answers input. + * + * A disabled button is drawn with its `disabled` skin region, or at half + * opacity, ignores the pointer and its key, and never calls `onClick`. + * Disabling it while it is held lets the press go without a click. + * @default true + */ + get enabled(): boolean { + return this._enabled; + } + + set enabled(value: boolean) { + this._enabled = value; + this.isClickable = value; + if (!value) { + this.cancelPress(); + this.cancelKeyboardPress(); + } + this.isDirty = true; + } - // adjust size position - this.bitmapText.pos.set(this.width / 2, this.height / 2); + /** + * Resize the button, and keep its background and label in step. + * + * A {@link UIPanel} calls this to stretch a button to its column. + * @param w - the new width + * @param h - the new height + * @returns this button + */ + override resize(w: number, h: number) { + super.resize(w, h); + // the border is created after the first resize in the constructor + // would run, so it may not be there yet + if (this.border !== undefined) { + this.border.setSize(w, h); + } + this.placeLabel(); + return this; + } + + /** + * Centre the label, lowered with the pressed art. + * @ignore + * @internal + */ + private placeLabel() { + if (this.label === undefined) { + return; + } + const x = this.width / 2; + const y = this.height / 2 + this._labelDrop; + if (this.label.pos.x !== x || this.label.pos.y !== y) { + this.label.setPosition(x, y); + } + } - // add bitmapText to the UI container - this.addChild(this.bitmapText); + /** + * Run the `onClick` setting, if the button may. + * @ignore + * @internal + */ + private completeClick() { + if (this._enabled && this.clickCallback !== undefined) { + this.clickCallback(this); + } } override onActivateEvent(): void { @@ -238,7 +454,17 @@ export default class UITextButton extends UIBaseElement { if (this.keyboardKey !== undefined) { return false; } - return super.release(event); + // Completed only when the press is still on and this is the pointer + // coming up, which only reaches the button over it. A press that left + // the button arrives here from `leave` with a move instead, and a + // cancelled one with a cancel. `hover` is no guide: a tap on a touch + // screen never sets it. + const completed = !this.released && isPointerUp(event); + const result = super.release(event); + if (completed) { + this.completeClick(); + } + return result; } override pointerMove(event: Pointer): void { @@ -247,16 +473,87 @@ export default class UITextButton extends UIBaseElement { } } + /** + * The skin region for the current state, and how far it is lowered. + * @returns the region name and its drop + * @ignore + * @internal + */ + private skinState(): [string, number] { + const skin = this.skin!; + if (!this._enabled) { + return [skin.disabled ?? skin.normal, 0]; + } + if (!this.released && skin.pressed !== undefined) { + const drop = Math.max( + 0, + this._skin!.size(skin.normal).height - + this._skin!.size(skin.pressed).height, + ); + return [skin.pressed, drop]; + } + if (this.hover && skin.hover !== undefined) { + return [skin.hover, 0]; + } + return [skin.normal, 0]; + } + override draw(renderer: Renderer, viewport?: Camera2d): void { const r = renderer as any; - if (this.hover) { - r.setColor(this.hoverOnColor); + const alpha = renderer.globalAlpha(); + let drop = 0; + if (this._skin !== undefined) { + const [region, regionDrop] = this.skinState(); + drop = regionDrop; + if (!this._enabled && this.skin!.disabled === undefined) { + renderer.setGlobalAlpha(alpha * 0.5); + } + this._skin.draw( + renderer, + region, + this.pos.x, + this.pos.y + drop, + this.width, + this.height - drop, + ); } else { - r.setColor(this.hoverOffColor); + if (!this._enabled) { + renderer.setGlobalAlpha(alpha * 0.5); + } + // kept on the button: a button placed by a panel, or moved after + // it was built, would otherwise draw its background where it was + // first put + if ( + this.border.pos.x !== this.pos.x || + this.border.pos.y !== this.pos.y + ) { + this.border.pos.set(this.pos.x, this.pos.y); + } + if (!this.released) { + r.setColor(this.pressedColor); + } else if (this.hover && this._enabled) { + r.setColor(this.hoverOnColor); + } else { + r.setColor(this.hoverOffColor); + } + r.fill(this.border); + r.setColor(this.borderStrokeColor); + r.stroke(this.border); + } + if (drop !== this._labelDrop) { + this._labelDrop = drop; + this.placeLabel(); } - r.fill(this.border); - r.setColor(this.borderStrokeColor); - r.stroke(this.border); super.draw(renderer, viewport); + renderer.setGlobalAlpha(alpha); + } + + /** + * Free the skin's sprite. + * @ignore + */ + override onDestroyEvent() { + this._skin?.dispose(); + super.onDestroyEvent(); } } diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js new file mode 100644 index 000000000..200dc0146 --- /dev/null +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -0,0 +1,957 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + Application, + boot, + Container, + event, + input, + Renderable, + TextureAtlas, + UICheckbox, + UIPanel, + UISlider, + UITextButton, + video, +} from "../src/index.js"; +import { SkinDrawer } from "../src/renderable/ui/uiskin.ts"; + +// The widgets of #1722: the skinned button, the checkbox, the slider, and +// the panel's layout and scrolling, driven through real pointer events. + +let app; +const added = []; + +/** + * dispatch a DOM event at GAME coordinates, converted through the canvas + * bounding rect so the test does not depend on how the canvas was scaled + */ +const at = (gameX, gameY) => { + const canvas = app.renderer.getCanvas(); + const rect = canvas.getBoundingClientRect(); + return { + clientX: rect.left + (gameX * rect.width) / canvas.width, + clientY: rect.top + (gameY * rect.height) / canvas.height, + }; +}; + +const pointer = (type, gameX, gameY) => { + app.renderer.getCanvas().dispatchEvent( + new PointerEvent(type, { + ...at(gameX, gameY), + pointerId: 1, + width: 1, + height: 1, + isPrimary: true, + bubbles: true, + }), + ); +}; + +const wheel = (gameX, gameY, deltaY, deltaX = 0) => { + app.renderer.getCanvas().dispatchEvent( + new WheelEvent("wheel", { + ...at(gameX, gameY), + deltaY, + deltaX, + bubbles: true, + cancelable: true, + }), + ); +}; + +/** what a Text or a BitmapText says */ +const textOf = (label) => { + return [].concat(label._text).join("\n"); +}; + +/** a click: down and up at the same place */ +const click = (x, y) => { + pointer("pointermove", x, y); + pointer("pointerdown", x, y); + pointer("pointerup", x, y); +}; + +/** a drag from one point to another, in steps */ +const drag = (x0, y0, x1, y1, steps = 5) => { + pointer("pointermove", x0, y0); + pointer("pointerdown", x0, y0); + for (let i = 1; i <= steps; i++) { + pointer( + "pointermove", + x0 + ((x1 - x0) * i) / steps, + y0 + ((y1 - y0) * i) / steps, + ); + } + pointer("pointerup", x1, y1); +}; + +const syncBroadphase = () => { + app.world.broadphase.clear(); + app.world.broadphase.insertContainer(app.world); +}; + +/** add to the world, and take it out after the test */ +const place = (widget) => { + app.world.addChild(widget); + added.push(widget); + syncBroadphase(); + return widget; +}; + +/** a 64x32 atlas of four 16x16 regions, each a different colour */ +const makeAtlas = () => { + const sheet = document.createElement("canvas"); + sheet.width = 64; + sheet.height = 32; + const ctx = sheet.getContext("2d"); + const colours = ["#f00", "#0f0", "#00f", "#ff0"]; + colours.forEach((c, i) => { + ctx.fillStyle = c; + ctx.fillRect(i * 16, 0, 16, i === 1 ? 12 : 16); + }); + const frame = (filename, x, w, h) => { + return { + filename, + frame: { x, y: 0, w, h }, + rotated: false, + trimmed: false, + spriteSourceSize: { x: 0, y: 0, w, h }, + sourceSize: { w, h }, + pivot: { x: 0.5, y: 0.5 }, + }; + }; + return new TextureAtlas( + { + frames: [ + frame("up", 0, 16, 16), + // shorter, as pressed art is + frame("down", 16, 16, 12), + frame("on", 32, 16, 16), + frame("off", 48, 16, 16), + ], + meta: { + app: "texturepacker", + image: "ui.png", + size: { w: 64, h: 32 }, + scale: "1", + }, + }, + sheet, + ); +}; + +describe("UI widgets", () => { + beforeAll(async () => { + boot(); + app = new Application(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + await app.init(); + }); + + afterEach(() => { + for (const widget of added.splice(0)) { + if (widget.ancestor !== undefined) { + app.world.removeChildNow(widget); + } + } + // let go of anything a failed test left held + pointer("pointerup", 799, 599); + vi.restoreAllMocks(); + }); + + describe("UICheckbox", () => { + it("a click toggles it and calls onChange once", () => { + const onChange = vi.fn(); + const cb = place(new UICheckbox(100, 100, { label: "Music", onChange })); + click(110, 110); + expect(cb.checked).toBe(true); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(true, cb); + click(110, 110); + expect(cb.checked).toBe(false); + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it("a tap with no hover before it still toggles it", () => { + const cb = place(new UICheckbox(100, 100, {})); + pointer("pointerdown", 110, 110); + pointer("pointerup", 110, 110); + expect(cb.checked).toBe(true); + }); + + it("setting checked from code, or toggle(), does not call onChange", () => { + const onChange = vi.fn(); + const cb = place(new UICheckbox(100, 100, { onChange })); + cb.checked = true; + expect(cb.checked).toBe(true); + expect(cb.toggle()).toBe(false); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("clicking the label toggles it too", () => { + const cb = place( + new UICheckbox(100, 100, { label: "A long label", size: 20 }), + ); + expect(cb.width).toBeGreaterThan(60); + click(100 + cb.width - 4, 110); + expect(cb.checked).toBe(true); + }); + + it("swaps the label between labelOn and labelOff, at a fixed width", () => { + const cb = place( + new UICheckbox(100, 100, { + labelOn: "Music ON", + labelOff: "Music OFF", + }), + ); + const width = cb.width; + expect(textOf(cb.label)).toBe("Music OFF"); + click(110, 110); + expect(textOf(cb.label)).toBe("Music ON"); + expect(cb.width).toBe(width); + }); + + it("ignores input while disabled", () => { + const onChange = vi.fn(); + const cb = place(new UICheckbox(100, 100, { enabled: false, onChange })); + click(110, 110); + expect(cb.checked).toBe(false); + expect(onChange).not.toHaveBeenCalled(); + cb.enabled = true; + click(110, 110); + expect(cb.checked).toBe(true); + }); + + it("a press dragged off before the release does not toggle", () => { + const cb = place(new UICheckbox(100, 100, {})); + pointer("pointermove", 110, 110); + pointer("pointerdown", 110, 110); + pointer("pointermove", 400, 400); + pointer("pointerup", 400, 400); + expect(cb.checked).toBe(false); + }); + + it("a radio group keeps exactly one checked", () => { + const onChange = vi.fn(); + const a = place( + new UICheckbox(100, 100, { group: "g1", checked: true, onChange }), + ); + const b = place(new UICheckbox(100, 150, { group: "g1", onChange })); + const c = place(new UICheckbox(100, 200, { group: "g1", onChange })); + expect(UICheckbox.checkedIn("g1")).toBe(a); + click(110, 160); + expect([a.checked, b.checked, c.checked]).toEqual([false, true, false]); + // only the one picked is told + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(true, b); + // clicking the checked one leaves it checked + click(110, 160); + expect(b.checked).toBe(true); + expect(onChange).toHaveBeenCalledTimes(1); + // from code, silently, still one at a time + c.checked = true; + expect([a.checked, b.checked, c.checked]).toEqual([false, false, true]); + expect(UICheckbox.checkedIn("g1")).toBe(c); + }); + + it("leaves its group when removed from the scene", () => { + const a = place(new UICheckbox(100, 100, { group: "g2", checked: true })); + const b = place(new UICheckbox(100, 150, { group: "g2" })); + app.world.removeChildNow(a); + b.checked = true; + // a is gone from the group, so it was not unchecked + expect(a.checked).toBe(true); + expect(UICheckbox.checkedIn("g2")).toBe(b); + }); + + it("draws the skin's on and off regions", () => { + const atlas = makeAtlas(); + const spy = vi.spyOn(SkinDrawer.prototype, "draw"); + const cb = place( + new UICheckbox(100, 100, { skin: { atlas, on: "on", off: "off" } }), + ); + expect(cb.boxSize).toBe(16); + cb.draw(app.renderer); + expect(spy.mock.calls.at(-1)[1]).toBe("off"); + cb.checked = true; + cb.draw(app.renderer); + expect(spy.mock.calls.at(-1)[1]).toBe("on"); + }); + + it("names a missing skin region when it is built", () => { + const atlas = makeAtlas(); + expect(() => { + return new UICheckbox(0, 0, { skin: { atlas, on: "on", off: "nope" } }); + }).toThrow(/nope/); + }); + }); + + describe("UISlider", () => { + // 200 long with a 20 thumb: the thumb's centre travels 10..190 + const make = (settings = {}) => { + return place(new UISlider(100, 100, settings)); + }; + + it("a click on the track jumps there and calls onChange", () => { + const onChange = vi.fn(); + const s = make({ onChange }); + click(100 + 100, 110); + expect(s.value).toBeCloseTo(0.5, 2); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("drags the thumb, calling onChangeEnd once on release", () => { + const onChange = vi.fn(); + const onChangeEnd = vi.fn(); + const s = make({ onChange, onChangeEnd }); + drag(110, 110, 160, 110); + expect(s.value).toBeCloseTo(50 / 180, 2); + expect(onChange.mock.calls.length).toBeGreaterThan(1); + expect(onChangeEnd).toHaveBeenCalledTimes(1); + expect(onChangeEnd).toHaveBeenCalledWith(s.value, s); + expect(s.dragging).toBe(false); + }); + + it("keeps following the pointer once it leaves the track", () => { + const s = make(); + drag(110, 110, 600, 400); + expect(s.value).toBe(1); + }); + + it("does not jump when the thumb is grabbed off centre", () => { + const s = make({ value: 0.5 }); + // the thumb's centre is at 200; grab it 6 pixels right of that + pointer("pointermove", 206, 110); + pointer("pointerdown", 206, 110); + expect(s.value).toBe(0.5); + pointer("pointerup", 206, 110); + }); + + it("snaps to the step and clamps to the range", () => { + const s = make({ min: 0, max: 10, step: 2 }); + s.value = 3.2; + expect(s.value).toBe(4); + s.value = 99; + expect(s.value).toBe(10); + s.value = -5; + expect(s.value).toBe(0); + const fine = make({ min: 0, max: 1, step: 0.1 }); + fine.value = 0.30000000000000004; + expect(fine.value).toBe(0.3); + }); + + it("setting value from code does not call onChange", () => { + const onChange = vi.fn(); + const onChangeEnd = vi.fn(); + const s = make({ onChange, onChangeEnd }); + s.value = 0.7; + expect(s.value).toBeCloseTo(0.7); + expect(onChange).not.toHaveBeenCalled(); + expect(onChangeEnd).not.toHaveBeenCalled(); + }); + + it("a vertical slider has its highest value at the top", () => { + const s = make({ orientation: "vertical", length: 100 }); + expect(s.height).toBe(100); + // the thumb travels 10..90 down the slider + click(110, 100 + 10); + expect(s.value).toBe(1); + click(110, 100 + 90); + expect(s.value).toBe(0); + }); + + it("the wheel moves it one step, and is consumed", () => { + const onChange = vi.fn(); + const onChangeEnd = vi.fn(); + const s = make({ + min: 0, + max: 10, + step: 1, + value: 5, + onChange, + onChangeEnd, + }); + pointer("pointermove", 150, 110); + wheel(150, 110, -100); + expect(s.value).toBe(6); + wheel(150, 110, 100); + wheel(150, 110, 100); + expect(s.value).toBe(4); + expect(onChange).toHaveBeenCalledTimes(3); + expect(onChangeEnd).toHaveBeenCalledTimes(3); + }); + + it("ignores input while disabled", () => { + const s = make({ enabled: false }); + click(200, 110); + wheel(200, 110, -100); + expect(s.value).toBe(0); + }); + + it("a press on the caption does not move it", () => { + const s = make({ label: "Volume", fontSize: 16 }); + // the caption row is above the track + click(130, 108); + expect(s.value).toBe(0); + }); + + it("writes its value with format", () => { + const s = make({ + showValue: true, + format: (v) => { + return Math.round(v * 100) + "%"; + }, + value: 0.25, + }); + expect(textOf(s.valueText)).toBe("25%"); + }); + }); + + describe("UITextButton", () => { + it("calls the onClick setting on a completed click", () => { + const onClick = vi.fn(); + const b = place( + new UITextButton(100, 100, { + text: "OK", + borderWidth: 100, + borderHeight: 40, + onClick, + }), + ); + click(150, 120); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledWith(b); + }); + + it("does not call it for a press dragged off, or while disabled", () => { + const onClick = vi.fn(); + const b = place( + new UITextButton(100, 100, { + text: "OK", + borderWidth: 100, + borderHeight: 40, + onClick, + }), + ); + pointer("pointermove", 150, 120); + pointer("pointerdown", 150, 120); + pointer("pointermove", 500, 500); + pointer("pointerup", 500, 500); + expect(onClick).not.toHaveBeenCalled(); + b.enabled = false; + click(150, 120); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("calls it for its bindKey, on the key's release", () => { + const onClick = vi.fn(); + place( + new UITextButton(100, 100, { + text: "OK", + bindKey: input.KEY.ENTER, + onClick, + }), + ); + event.emit(event.KEYDOWN, undefined, input.KEY.ENTER); + expect(onClick).not.toHaveBeenCalled(); + event.emit(event.KEYUP, undefined, input.KEY.ENTER); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("draws the pressed region lowered, and the label with it", () => { + const atlas = makeAtlas(); + const spy = vi.spyOn(SkinDrawer.prototype, "draw"); + const b = place( + new UITextButton(100, 100, { + text: "OK", + borderWidth: 80, + skin: { atlas, normal: "up", pressed: "down" }, + }), + ); + // the height comes from the normal region + expect(b.height).toBe(16); + b.draw(app.renderer); + expect(spy.mock.calls.at(-1).slice(1)).toEqual(["up", 100, 100, 80, 16]); + const labelY = b.label.pos.y; + pointer("pointermove", 120, 108); + pointer("pointerdown", 120, 108); + b.draw(app.renderer); + // 16 tall at rest, 12 pressed: lowered by 4 + expect(spy.mock.calls.at(-1).slice(1)).toEqual([ + "down", + 100, + 104, + 80, + 12, + ]); + expect(b.label.pos.y).toBe(labelY + 4); + pointer("pointerup", 120, 108); + b.draw(app.renderer); + expect(b.label.pos.y).toBe(labelY); + }); + + it("keeps its background on the button when it moves", () => { + const b = new UITextButton(10, 10, { text: "OK", borderWidth: 60 }); + b.pos.set(200, 300); + b.draw(app.renderer); + expect(b.border.pos.x).toBe(200); + expect(b.border.pos.y).toBe(300); + }); + + it("resize keeps the label centred", () => { + const b = new UITextButton(0, 0, { text: "OK", borderWidth: 60 }); + b.resize(200, 50); + expect(b.label.pos.x).toBe(100); + expect(b.label.pos.y).toBe(25); + expect(b.border.width).toBe(200); + }); + }); + + describe("UIPanel layout", () => { + /** a plain 40x20 renderable, placed by its top left corner */ + const box = (w = 40, h = 20) => { + const r = new Renderable(0, 0, w, h); + r.anchorPoint.set(0, 0); + return r; + }; + + it("stacks a column with padding and gap", () => { + const panel = place( + new UIPanel(100, 100, { + width: 200, + height: 200, + layout: "column", + padding: 10, + gap: 5, + }), + ); + const a = panel.addChild(box()); + const b = panel.addChild(box()); + const c = panel.addChild(box()); + expect([a.pos.x, a.pos.y]).toEqual([10, 10]); + expect([b.pos.x, b.pos.y]).toEqual([10, 35]); + expect([c.pos.x, c.pos.y]).toEqual([10, 60]); + }); + + it("lines a row up, centred across", () => { + const panel = place( + new UIPanel(0, 0, { + width: 300, + height: 100, + layout: "row", + padding: 10, + gap: 10, + align: "center", + }), + ); + const a = panel.addChild(box(40, 20)); + const b = panel.addChild(box(40, 40)); + expect([a.pos.x, a.pos.y]).toEqual([10, 40]); + expect([b.pos.x, b.pos.y]).toEqual([60, 30]); + }); + + it("fills a grid row by row, in equal cells", () => { + const panel = place( + new UIPanel(0, 0, { + width: 230, + height: 200, + layout: "grid", + columns: 3, + padding: 10, + gap: 10, + }), + ); + const cells = []; + for (let i = 0; i < 5; i++) { + cells.push(panel.addChild(box(30, 20))); + } + // cell width (210 - 20) / 3 + const w = 190 / 3; + expect( + cells.map((c) => { + return [c.pos.x, c.pos.y]; + }), + ).toEqual([ + [10, 10], + [10 + w + 10, 10], + [10 + 2 * (w + 10), 10], + [10, 40], + [10 + w + 10, 40], + ]); + }); + + it("lays out below the title and inside the border", () => { + const panel = place( + new UIPanel(0, 0, { + width: 200, + height: 200, + title: "OPTIONS", + titleFontSize: 20, + borderColor: "#000", + borderWidth: 2, + layout: "column", + padding: 4, + }), + ); + const a = panel.addChild(box()); + // 2 border + 32 title + 4 padding + expect([a.pos.x, a.pos.y]).toEqual([6, 38]); + }); + + it("stretches buttons to the column", () => { + const panel = place( + new UIPanel(0, 0, { + width: 220, + height: 300, + layout: "column", + padding: 10, + align: "stretch", + }), + ); + const b = panel.addChild(new UITextButton(0, 0, { text: "OK" })); + expect(b.width).toBe(200); + expect(b.label.pos.x).toBe(100); + }); + + it("autoSize fits the panel, skin included, to the content", () => { + const atlas = makeAtlas(); + const spy = vi.spyOn(SkinDrawer.prototype, "draw"); + const panel = place( + new UIPanel(0, 0, { + width: 10, + height: 10, + layout: "column", + padding: 5, + gap: 5, + autoSize: true, + skin: { atlas, region: "up", insetx: 3, insety: 3 }, + }), + ); + panel.addChild(box(40, 20)); + panel.addChild(box(60, 20)); + // 3 + 5 + content + 5 + 3 + expect(panel.width).toBe(76); + expect(panel.height).toBe(16 + 45); + panel.draw(app.renderer); + const call = spy.mock.calls.find((c) => { + return c[1] === "up"; + }); + expect(call.slice(4)).toEqual([76, 61]); + }); + + it("lays out again when a child is removed", () => { + const panel = place( + new UIPanel(0, 0, { + width: 200, + height: 200, + layout: "column", + gap: 0, + }), + ); + const a = panel.addChild(box()); + const b = panel.addChild(box()); + expect(b.pos.y).toBe(20); + panel.removeChildNow(a); + expect(b.pos.y).toBe(0); + }); + + it("leaves manual placement alone by default", () => { + const panel = place(new UIPanel(0, 0, { width: 200, height: 200 })); + const a = box(); + a.pos.set(33, 44); + panel.addChild(a); + expect([a.pos.x, a.pos.y]).toEqual([33, 44]); + }); + }); + + describe("UIPanel scrolling", () => { + /** a scrolling list: 10 buttons of 40, in a 100 tall view */ + const makeList = (settings = {}) => { + const panel = place( + new UIPanel(100, 100, { + width: 200, + height: 100, + layout: "column", + gap: 0, + scroll: "vertical", + ...settings, + }), + ); + const buttons = []; + for (let i = 0; i < 10; i++) { + buttons.push( + panel.addChild( + new UITextButton(0, 0, { + text: `Level ${i + 1}`, + borderWidth: 200, + borderHeight: 40, + }), + ), + ); + } + syncBroadphase(); + return { panel, buttons }; + }; + + it("works out how far the content scrolls", () => { + const { panel } = makeList(); + expect(panel.maxScrollY).toBe(300); + expect(panel.maxScrollX).toBe(0); + }); + + it("scrollTo moves the children and clamps", () => { + const onScroll = vi.fn(); + const { panel, buttons } = makeList({ onScroll }); + panel.scrollTo(0, 120); + expect(panel.scrollY).toBe(120); + expect(buttons[0].pos.y).toBe(-120); + expect(buttons[3].pos.y).toBe(0); + expect(onScroll).toHaveBeenCalledWith(0, 120, panel); + panel.scrollY = 9999; + expect(panel.scrollY).toBe(300); + panel.scrollY = -5; + expect(panel.scrollY).toBe(0); + }); + + it("scrollToChild brings a child into view", () => { + const { panel, buttons } = makeList(); + panel.scrollToChild(buttons[5]); + // its bottom at the bottom of the view + expect(panel.scrollY).toBe(140); + panel.scrollToChild(buttons[1]); + expect(panel.scrollY).toBe(40); + }); + + it("scrolls with the wheel over it", () => { + const { panel } = makeList(); + pointer("pointermove", 150, 150); + wheel(150, 150, 50); + expect(panel.scrollY).toBe(50); + }); + + it("does not let a child scrolled out of view be clicked", () => { + const { panel, buttons } = makeList(); + const below = vi.fn(); + const hidden = vi.fn(); + buttons[3].clickCallback = hidden; + // button 3 sits at 120..160, under the panel's bottom edge at 200 + // in screen terms: 100 + 120 = 220 + const under = place( + new UITextButton(100, 210, { + text: "below", + borderWidth: 200, + borderHeight: 40, + onClick: below, + }), + ); + app.world.moveToBottom?.(under); + syncBroadphase(); + click(150, 230); + expect(hidden).not.toHaveBeenCalled(); + expect(below).toHaveBeenCalledTimes(1); + void panel; + }); + + it("a drag that starts on a button scrolls and cancels the press", () => { + const { panel, buttons } = makeList(); + const onClick = vi.fn(); + // the third button, 80..120 in the panel: 180..220 on screen + buttons[2].clickCallback = onClick; + const onCancel = vi.spyOn(buttons[2], "onCancel"); + drag(150, 190, 150, 130); + expect(panel.scrollY).toBe(60); + expect(onClick).not.toHaveBeenCalled(); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("a short press on a button in the list still clicks it", () => { + const { buttons } = makeList(); + const onClick = vi.fn(); + buttons[1].clickCallback = onClick; + drag(150, 160, 150, 157, 1); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("a fling carries on and slows down", () => { + const { panel } = makeList(); + const now = vi.spyOn(performance, "now"); + let t = 1000; + now.mockImplementation(() => { + return t; + }); + pointer("pointermove", 150, 190); + pointer("pointerdown", 150, 190); + for (let i = 1; i <= 4; i++) { + t += 16; + pointer("pointermove", 150, 190 - i * 15); + } + pointer("pointerup", 150, 130); + const released = panel.scrollY; + panel.update(16); + const after = panel.scrollY; + expect(after).toBeGreaterThan(released); + for (let i = 0; i < 200; i++) { + panel.update(16); + } + const settled = panel.scrollY; + panel.update(16); + expect(panel.scrollY).toBe(settled); + }); + + it("a slider drag inside a scrolling panel moves the slider, not the panel", () => { + const panel = place( + new UIPanel(100, 100, { + width: 220, + height: 100, + layout: "column", + scroll: "vertical", + }), + ); + const slider = panel.addChild(new UISlider(0, 0, {})); + for (let i = 0; i < 6; i++) { + panel.addChild( + new UITextButton(0, 0, { + text: "x", + borderWidth: 200, + borderHeight: 40, + }), + ); + } + syncBroadphase(); + expect(panel.maxScrollY).toBeGreaterThan(0); + drag(110, 110, 210, 150); + expect(slider.value).toBeGreaterThan(0.4); + expect(panel.scrollY).toBe(0); + }); + + it("a clipping container hides its cropped children from the pointer", () => { + const box = place(new Container(100, 100, 100, 100)); + box.clipping = true; + const b = new UITextButton(0, 150, { + text: "x", + borderWidth: 100, + borderHeight: 40, + }); + const onClick = vi.fn(); + b.clickCallback = onClick; + box.addChild(b); + syncBroadphase(); + click(150, 270); + expect(onClick).not.toHaveBeenCalled(); + }); + }); + + describe("UIPanel dragging", () => { + it("moves by its title, and comes to the front", () => { + const back = place( + new UIPanel(100, 100, { + width: 200, + height: 150, + draggable: true, + title: "A", + }), + ); + const front = place(new UIPanel(400, 100, { width: 200, height: 150 })); + syncBroadphase(); + const moveToTop = vi.spyOn(app.world, "moveToTop"); + drag(150, 110, 250, 210); + expect(back.pos.x).toBeCloseTo(200, 6); + expect(back.pos.y).toBeCloseTo(200, 6); + expect(moveToTop).toHaveBeenCalledWith(back); + void front; + }); + + it("a scrolling one scrolls from its content instead of moving", () => { + const panel = place( + new UIPanel(100, 100, { + width: 200, + height: 100, + draggable: true, + layout: "column", + scroll: "vertical", + }), + ); + for (let i = 0; i < 6; i++) { + const r = new Renderable(0, 0, 100, 40); + r.anchorPoint.set(0, 0); + panel.addChild(r); + } + syncBroadphase(); + drag(150, 180, 150, 140); + expect([panel.pos.x, panel.pos.y]).toEqual([100, 100]); + expect(panel.scrollY).toBe(40); + }); + }); +}); + +for (const backend of [video.CANVAS, video.WEBGL]) { + describe(`UI widgets draw (${backend === video.CANVAS ? "Canvas" : "WebGL"})`, () => { + let local; + beforeAll(async () => { + boot(); + local = new Application(320, 240, { + parent: "screen", + renderer: backend, + failIfMajorPerformanceCaveat: false, + }); + await local.init(); + }); + + it("all four draw, drawn and skinned, without throwing", () => { + const atlas = makeAtlas(); + const panel = new UIPanel(0, 0, { + width: 300, + height: 120, + title: "T", + fillColor: "#123", + borderColor: "#fff", + borderWidth: 2, + radius: 6, + layout: "column", + scroll: "vertical", + padding: 4, + }); + panel.addChild(new UITextButton(0, 0, { text: "drawn" })); + panel.addChild( + new UITextButton(0, 0, { + text: "skinned", + skin: { atlas, normal: "up", pressed: "down" }, + }), + ); + panel.addChild(new UICheckbox(0, 0, { label: "box", checked: true })); + panel.addChild(new UICheckbox(0, 0, { label: "radio", group: "draw" })); + panel.addChild( + new UICheckbox(0, 0, { skin: { atlas, on: "on", off: "off" } }), + ); + panel.addChild( + new UISlider(0, 0, { label: "drawn", showValue: true, value: 0.3 }), + ); + panel.addChild( + new UISlider(0, 0, { + orientation: "vertical", + length: 60, + skin: { atlas, track: "up", fill: "on", thumb: "off" }, + }), + ); + local.world.addChild(panel); + // the whole chain a frame runs on the panel: its background, the + // title, the clip, each widget, the scrollbar + const frame = () => { + const renderer = local.renderer; + renderer.clear(); + panel.preDraw(renderer); + panel.draw(renderer); + panel.postDraw(renderer); + renderer.flush(); + }; + expect(frame).not.toThrow(); + panel.scrollTo(0, 50); + expect(panel.scrollY).toBe(50); + expect(frame).not.toThrow(); + local.world.removeChildNow(panel); + }); + }); +} diff --git a/packages/melonjs/tests/ui.spec.js b/packages/melonjs/tests/ui.spec.js index 781af0885..71cb80f34 100644 --- a/packages/melonjs/tests/ui.spec.js +++ b/packages/melonjs/tests/ui.spec.js @@ -3,6 +3,7 @@ import { Application, boot, Container, + Text, UIBaseElement, UISpriteElement, UITextButton, @@ -390,14 +391,16 @@ describe("UI", () => { }); }); - describe("construction without font data throws", () => { - it("should throw when no font binary is loaded", () => { - expect(() => { - return new UITextButton(0, 0, { - font: "nonexistent-font", - text: "Test", - }); - }).toThrow(); + describe("construction without bitmap font data", () => { + // Before 20.13 this threw: the label was always a BitmapText. A + // font that is not a loaded bitmap font is now a CSS font. + it("draws the label with a CSS font", () => { + const btn = new UITextButton(0, 0, { + font: "nonexistent-font", + text: "Test", + }); + expect(btn.label).toBeInstanceOf(Text); + expect(btn.bitmapText).toBe(btn.label); }); }); }); From 1fbc5c9e50244125556426c7c2eaca538128c4bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:52:13 +0000 Subject: [PATCH 03/43] UI: fixes from review - UIBaseElement: a draggable element pressed with no hover first, as a touch tap is, no longer throws, and drags by touch - UIPanel: a draggable panel is moved by its title or frame, not through a widget on it, so dragging it by a button no longer clicks the button - UIPanel: no fling while the pointer is still down; autoSize counts the title and frame from a zero height; a scrolling panel inside another scrolls alone; emptying or destroying a panel does not lay it out per child or call onScroll - UISlider: the wheel to the right raises it; a drag also ends on a mouse or touch release, or a release over the slider - UICheckbox: as wide as its longer label, whichever it is - UITextButton: a font given as fontData, an image, or with only its image loaded is a bitmap font as before; pressedColor defaults to hoverOnColor; bitmapText can be assigned - 10 regression tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- .../skills/melonjs-ui-and-text/SKILL.md | 5 +- .../src/renderable/ui/uibaseelement.ts | 92 +++++++-- .../melonjs/src/renderable/ui/uicheckbox.ts | 12 +- packages/melonjs/src/renderable/ui/uipanel.ts | 93 ++++++++-- .../melonjs/src/renderable/ui/uislider.ts | 38 ++-- .../melonjs/src/renderable/ui/uitextbutton.ts | 30 ++- packages/melonjs/tests/ui-widgets.spec.js | 174 ++++++++++++++++++ 7 files changed, 384 insertions(+), 60 deletions(-) diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index 760f94185..198d935b4 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -512,7 +512,10 @@ What to know: - **A slider in a scrolling panel is dragged, not the panel.** Any widget that sets `keepsDrag` keeps its drags. - Children scrolled out of view do not get pointer events. Nested scrolling - panels are not supported yet. + panels are not supported yet, and on WebGL a scrolling panel inside another + clipping container is cropped to its own area only, not to the outer one. +- A draggable panel is moved by its title or its frame. A press on a widget + on it is the widget's. ### A pause overlay: `updateWhenPaused` cascades diff --git a/packages/melonjs/src/renderable/ui/uibaseelement.ts b/packages/melonjs/src/renderable/ui/uibaseelement.ts index 632a82e94..e72a20d05 100644 --- a/packages/melonjs/src/renderable/ui/uibaseelement.ts +++ b/packages/melonjs/src/renderable/ui/uibaseelement.ts @@ -23,6 +23,35 @@ export function isPointerUp(event: Pointer | undefined): boolean { return type === "pointerup" || type === "mouseup" || type === "touchend"; } +/** + * The DOM events that can end a drag, listened for on the window so a drag + * ends wherever the pointer is let go. The mouse and touch ones cover a + * browser without pointer events. + * @ignore + * @internal + */ +export const END_EVENTS = [ + "pointerup", + "pointercancel", + "mouseup", + "touchend", + "touchcancel", +] as const; + +/** + * Whether a DOM event ends the drag of a given pointer. + * @param event - one of {@link END_EVENTS} + * @param pointerId - the pointer the drag follows + * @returns true for that pointer's release; for a mouse or touch event, + * which has no pointer id, any release + * @ignore + * @internal + */ +export function endsPointer(event: Event, pointerId: number | undefined) { + const id = (event as PointerEvent).pointerId; + return typeof id !== "number" || id === pointerId; +} + /** * Hand a press to the nearest scrolling panel above an element, if any, so * that a drag starting on the element can still scroll the panel. @@ -178,6 +207,9 @@ export default class UIBaseElement extends Container { this.released = false; } if (this.isDraggable) { + // a tap on a touch screen comes with no hover before it, so + // `enter` never set the drag up + this.listenForDrag(); this.grabOffset!.set(event.gameX, event.gameY); this.grabOffset!.sub(this.pos); } else if (!this.keepsDrag) { @@ -199,6 +231,41 @@ export default class UIBaseElement extends Container { */ keepsDrag = false; + /** whether the drag listener is on */ + private _dragListening = false; + + /** + * Follow the pointer for a drag: once, however it is reached. + * @ignore + * @internal + */ + private listenForDrag(): void { + if (!this._dragListening) { + this._dragListening = true; + // eslint-disable-next-line @typescript-eslint/unbound-method + on(POINTERMOVE, this.pointerMove, this); + } + // to memorize where we grab the object + this.grabOffset ??= vector2dPool.get(0, 0); + } + + /** + * Stop following the pointer, and give the grab offset back. + * @ignore + * @internal + */ + private stopListeningForDrag(): void { + if (this._dragListening) { + this._dragListening = false; + // eslint-disable-next-line @typescript-eslint/unbound-method + off(POINTERMOVE, this.pointerMove, this); + } + if (this.grabOffset !== undefined) { + vector2dPool.release(this.grabOffset); + this.grabOffset = undefined; + } + } + /** * Abandon a press in progress, without calling `onRelease`. * @@ -244,10 +311,7 @@ export default class UIBaseElement extends Container { this.hover = true; this.isDirty = true; if (this.isDraggable) { - // eslint-disable-next-line @typescript-eslint/unbound-method - on(POINTERMOVE, this.pointerMove, this); - // to memorize where we grab the object - this.grabOffset = vector2dPool.get(0, 0); + this.listenForDrag(); } return this.onOver(event); } @@ -258,7 +322,8 @@ export default class UIBaseElement extends Container { * @internal */ pointerMove(event: Pointer): void { - if (this.hover && !this.released) { + // not gated on `hover`: a drag by touch never had one + if (!this.released) { // follow the pointer // pos is ObservableVector3d at runtime but typed as Vector2d in Renderable (this.pos as any).set(event.gameX, event.gameY, (this.pos as any).z); @@ -304,13 +369,7 @@ export default class UIBaseElement extends Container { leave(event: Pointer): void { this.hover = false; this.isDirty = true; - if (this.isDraggable) { - // unregister on the global pointermove event - // eslint-disable-next-line @typescript-eslint/unbound-method - off(POINTERMOVE, this.pointerMove, this); - vector2dPool.release(this.grabOffset!); - this.grabOffset = undefined; - } + this.stopListeningForDrag(); this.release(event); this.onOut(event); } @@ -423,14 +482,7 @@ export default class UIBaseElement extends Container { // note: this is just a precaution, in case // the object is being remove from his parent // container before the leave function is called - if (this.isDraggable) { - // eslint-disable-next-line @typescript-eslint/unbound-method - off(POINTERMOVE, this.pointerMove, this); - if (typeof this.grabOffset !== "undefined") { - vector2dPool.release(this.grabOffset); - this.grabOffset = undefined; - } - } + this.stopListeningForDrag(); // call the parent function super.onDeactivateEvent(); diff --git a/packages/melonjs/src/renderable/ui/uicheckbox.ts b/packages/melonjs/src/renderable/ui/uicheckbox.ts index 6ff57fd96..7270dd49d 100644 --- a/packages/melonjs/src/renderable/ui/uicheckbox.ts +++ b/packages/melonjs/src/renderable/ui/uicheckbox.ts @@ -255,10 +255,14 @@ export default class UICheckbox extends UIBaseElement { }); // as wide as the longer of the two texts, so the widget does not // change size, and move what is laid out after it, as it toggles - const on = label.measureText(this._labelOn); - const off = label.measureText(this._labelOff); - width += this.labelGap + Math.max(on.width, off.width); - height = Math.max(height, on.height, off.height); + // read straight away: `measureText` hands back the same object + // each time, so a second call overwrites the first + let metrics = label.measureText(this._labelOn); + const onWidth = metrics.width; + const onHeight = metrics.height; + metrics = label.measureText(this._labelOff); + width += this.labelGap + Math.max(onWidth, metrics.width); + height = Math.max(height, onHeight, metrics.height); this.label = label; } this.resize(Math.ceil(width), Math.ceil(height)); diff --git a/packages/melonjs/src/renderable/ui/uipanel.ts b/packages/melonjs/src/renderable/ui/uipanel.ts index 08db7c5ec..ad51882a0 100644 --- a/packages/melonjs/src/renderable/ui/uipanel.ts +++ b/packages/melonjs/src/renderable/ui/uipanel.ts @@ -13,7 +13,7 @@ import Container from "../container.js"; import type Renderable from "../renderable.js"; import type { Paint } from "./panelpaint.ts"; import { PanelPainter } from "./panelpaint.ts"; -import UIBaseElement from "./uibaseelement.ts"; +import UIBaseElement, { END_EVENTS, endsPointer } from "./uibaseelement.ts"; import type { UILabel } from "./uilabel.ts"; import { createLabel } from "./uilabel.ts"; import type { UISkin } from "./uiskin.ts"; @@ -502,6 +502,10 @@ export default class UIPanel extends UIBaseElement { * @internal */ private fling(dt: number): boolean { + // the finger is still on it: the drag moves the content, not this + if (this._press !== undefined) { + return false; + } if (this._velocityX === 0 && this._velocityY === 0) { return false; } @@ -885,9 +889,11 @@ export default class UIPanel extends UIBaseElement { } if (this.autoSize && this.scroll === "none") { - const [, , vw, vh] = this.viewRect(); - const frameW = this.width - vw; - const frameH = this.height - vh; + // from the insets, not from the view: that is clamped at 0, so a + // panel started at height 0 would leave the title out + const [fx, fy] = this.frameInsets(); + const frameW = fx * 2; + const frameH = fy * 2 + this._titleHeight; const pad = this.padding * 2; let width = this.width; let height = this.height; @@ -1171,8 +1177,9 @@ export default class UIPanel extends UIBaseElement { lastTime: globalThis.performance?.now() ?? Date.now(), }; on(POINTERMOVE, this._onPressMove); - globalThis.addEventListener?.("pointerup", this._onPressEnd); - globalThis.addEventListener?.("pointercancel", this._onPressEnd); + for (const type of END_EVENTS) { + globalThis.addEventListener?.(type, this._onPressEnd); + } return true; } @@ -1209,9 +1216,9 @@ export default class UIPanel extends UIBaseElement { press.lastTime = now; }; - private readonly _onPressEnd = (event: PointerEvent) => { + private readonly _onPressEnd = (event: Event) => { const press = this._press; - if (press === undefined || event.pointerId !== press.pointerId) { + if (press === undefined || !endsPointer(event, press.pointerId)) { return; } const now = globalThis.performance?.now() ?? Date.now(); @@ -1230,8 +1237,9 @@ export default class UIPanel extends UIBaseElement { private endPress() { this._press = undefined; off(POINTERMOVE, this._onPressMove); - globalThis.removeEventListener?.("pointerup", this._onPressEnd); - globalThis.removeEventListener?.("pointercancel", this._onPressEnd); + for (const type of END_EVENTS) { + globalThis.removeEventListener?.(type, this._onPressEnd); + } } private readonly _onWheel = (pointer: Pointer) => { @@ -1270,14 +1278,53 @@ export default class UIPanel extends UIBaseElement { } const [x, y] = this.localPoint(event); const onContent = this.scroll !== "none" && this.inView(x, y); - this._moving = this.isDraggable && !onContent; + // Grabbed by its title or its frame, not through a widget on it: a + // button pressed to drag the panel would move with it, stay under + // the pointer, and be clicked on the release. + this._moving = + this.isDraggable && + !onContent && + !this.onWidget(event.gameX, event.gameY); if (this.isDraggable && this.ancestor instanceof Container) { this.ancestor.moveToTop(this); } + let captured = false; if (onContent) { - this._capturePress(event); + captured = this._capturePress(event); + } + // a press this panel scrolls with is not offered to one around it, + // or one drag would scroll both + const keeps = this.keepsDrag; + this.keepsDrag = keeps || captured; + try { + return super.clicked(event); + } finally { + this.keepsDrag = keeps; } - return super.clicked(event); + } + + /** + * Whether a point is on one of the panel's clickable widgets. + * @param x - the point, in the coordinates of the panel's bounds + * @param y - the point + * @returns true when a child that takes the pointer is under it + * @ignore + * @internal + */ + private onWidget(x: number, y: number): boolean { + if (!this.clipContains(x, y)) { + return false; + } + for (const child of this.getChildren()) { + if ( + child instanceof UIBaseElement && + child.isClickable && + child.getBounds().contains(x, y) + ) { + return true; + } + } + return false; } /** @@ -1291,6 +1338,24 @@ export default class UIPanel extends UIBaseElement { } } + /** set while the panel is emptied, so it does not lay out each step */ + private _clearing = false; + + /** + * Remove every child, without laying out after each one. + * @ignore + */ + override clearChildren(): void { + this._clearing = true; + try { + super.clearChildren(); + } finally { + this._clearing = false; + } + this._scrollX = this._scrollY = 0; + this._maxScrollX = this._maxScrollY = 0; + } + /** * Lay a new child out with the others. * @ignore @@ -1326,7 +1391,7 @@ export default class UIPanel extends UIBaseElement { ); } super.removeChildNow(child, keepalive); - if (this.layout !== "none" || this.scroll !== "none") { + if (!this._clearing && (this.layout !== "none" || this.scroll !== "none")) { this.relayout(); } } diff --git a/packages/melonjs/src/renderable/ui/uislider.ts b/packages/melonjs/src/renderable/ui/uislider.ts index 5b77adc0c..c632030d8 100644 --- a/packages/melonjs/src/renderable/ui/uislider.ts +++ b/packages/melonjs/src/renderable/ui/uislider.ts @@ -9,7 +9,11 @@ import { off, on, POINTERMOVE } from "../../system/event.ts"; import type Renderer from "../../video/renderer.js"; import type { Paint } from "./panelpaint.ts"; import { PanelPainter } from "./panelpaint.ts"; -import UIBaseElement from "./uibaseelement.ts"; +import UIBaseElement, { + END_EVENTS, + endsPointer, + isPointerUp, +} from "./uibaseelement.ts"; import type { UILabel } from "./uilabel.ts"; import { createLabel } from "./uilabel.ts"; import type { UISkin } from "./uiskin.ts"; @@ -255,8 +259,8 @@ export default class UISlider extends UIBaseElement { } }; - private readonly _onDragEnd = (event: PointerEvent) => { - if (this._dragging && event.pointerId === this._dragPointer) { + private readonly _onDragEnd = (event: Event) => { + if (this._dragging && endsPointer(event, this._dragPointer)) { this.endDrag(true); } }; @@ -265,16 +269,17 @@ export default class UISlider extends UIBaseElement { if (!this._enabled) { return; } - const delta = + // wheel up, or to the right, raises the value: up is a NEGATIVE + // deltaY, right a positive deltaX + const direction = Math.abs(pointer.deltaY) >= Math.abs(pointer.deltaX) - ? pointer.deltaY - : pointer.deltaX; - if (delta === 0) { + ? -Math.sign(pointer.deltaY) + : Math.sign(pointer.deltaX); + if (direction === 0) { return; } const step = this._step > 0 ? this._step : (this._max - this._min) / 100; - // wheel up, or to the right, raises the value - if (this.setFromUser(this._value - Math.sign(delta) * step)) { + if (this.setFromUser(this._value + direction * step)) { this.onChangeEnd?.(this._value, this); } // consumed, so a scrolling panel under it does not scroll too @@ -577,8 +582,9 @@ export default class UISlider extends UIBaseElement { this.released = false; this.isDirty = true; on(POINTERMOVE, this._onDragMove); - globalThis.addEventListener?.("pointerup", this._onDragEnd); - globalThis.addEventListener?.("pointercancel", this._onDragEnd); + for (const type of END_EVENTS) { + globalThis.addEventListener?.(type, this._onDragEnd); + } if (onThumb) { // grabbed where it was: the thumb does not jump under the pointer @@ -607,8 +613,9 @@ export default class UISlider extends UIBaseElement { this.released = true; this.isDirty = true; off(POINTERMOVE, this._onDragMove); - globalThis.removeEventListener?.("pointerup", this._onDragEnd); - globalThis.removeEventListener?.("pointercancel", this._onDragEnd); + for (const type of END_EVENTS) { + globalThis.removeEventListener?.(type, this._onDragEnd); + } if (notify && this._value !== this._dragFrom) { this.onChangeEnd?.(this._value, this); } @@ -620,6 +627,11 @@ export default class UISlider extends UIBaseElement { */ override release(event: Pointer): boolean | void { if (this._dragging) { + // let go over the slider: the window may not have heard it, in a + // frame whose pointer-up the page never sees + if (isPointerUp(event)) { + this.endDrag(true); + } return false; } return super.release(event); diff --git a/packages/melonjs/src/renderable/ui/uitextbutton.ts b/packages/melonjs/src/renderable/ui/uitextbutton.ts index 8f40c09c7..b75849a05 100644 --- a/packages/melonjs/src/renderable/ui/uitextbutton.ts +++ b/packages/melonjs/src/renderable/ui/uitextbutton.ts @@ -1,6 +1,7 @@ import type Camera2d from "../../camera/camera2d.ts"; import { RoundRect } from "../../geometries/roundrect.ts"; import type Pointer from "../../input/pointer.ts"; +import { getImage } from "../../loader/loader.js"; import type { Bounds } from "../../physics/bounds.ts"; import { BLUR, KEYDOWN, KEYUP, off, on } from "../../system/event.ts"; import type Renderer from "../../video/renderer.js"; @@ -8,7 +9,6 @@ import BitmapText from "../text/bitmaptext.js"; import Text from "../text/text.js"; import UIBaseElement, { isPointerUp } from "./uibaseelement.ts"; import type { UILabel } from "./uilabel.ts"; -import { isBitmapFont } from "./uilabel.ts"; import type { UISkin } from "./uiskin.ts"; import { SkinDrawer } from "./uiskin.ts"; @@ -40,8 +40,9 @@ export interface UITextButtonSkin extends UISkin { export interface UITextButtonSettings { /** * The font: the name of a loaded bitmap font, or a CSS font family. A - * name with both a font image and font data in the loader is drawn - * through {@link BitmapText}, anything else through {@link Text}. + * name with a font image in the loader, or a `fontData` setting, is + * drawn through {@link BitmapText}, as before; anything else through + * {@link Text}. * @default "sans-serif" */ font?: string; @@ -74,7 +75,7 @@ export interface UITextButtonSettings { hoverOffColor?: string; /** background colour under the pointer (drawn look) */ hoverOnColor?: string; - /** background colour while held down (drawn look) */ + /** background colour while held down (drawn look); `hoverOnColor` by default */ pressedColor?: string; /** outline colour (drawn look) */ borderStrokeColor?: string; @@ -199,8 +200,9 @@ export default class UITextButton extends UIBaseElement { hoverOnColor: string; /** - * The css value of the background while the button is held down - * @default "#008800ff" + * The css value of the background while the button is held down; the + * hover colour by default + * @default hoverOnColor */ pressedColor: string; @@ -287,7 +289,9 @@ export default class UITextButton extends UIBaseElement { this.hoverOffColor = settings.hoverOffColor || "#00aa0080"; this.hoverOnColor = settings.hoverOnColor || "#00ff00ff"; - this.pressedColor = settings.pressedColor || "#008800ff"; + // the hover colour by default, which is what a held button showed + // before it had a colour of its own + this.pressedColor = settings.pressedColor || this.hoverOnColor; this.borderStrokeColor = settings.borderStrokeColor || "#000000"; @@ -297,7 +301,13 @@ export default class UITextButton extends UIBaseElement { settings.textBaseline || "middle"; const font = settings.font ?? "sans-serif"; - if (isBitmapFont(font)) { + // a bitmap font as before: by name, by data given under another + // name, or as an image + if ( + settings.fontData !== undefined || + typeof font !== "string" || + getImage(font) != null + ) { this.label = new BitmapText(0, 0, settings as any); } else { this.label = new Text(0, 0, { @@ -355,6 +365,10 @@ export default class UITextButton extends UIBaseElement { return this.label; } + set bitmapText(value: UILabel) { + this.label = value; + } + /** * Whether the button answers input. * diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js index 200dc0146..5457aa684 100644 --- a/packages/melonjs/tests/ui-widgets.spec.js +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -844,6 +844,180 @@ describe("UI widgets", () => { }); }); + describe("edge cases", () => { + it("a draggable panel tapped with no hover first moves by touch", () => { + const panel = place( + new UIPanel(100, 100, { width: 200, height: 150, draggable: true }), + ); + expect(() => { + pointer("pointerdown", 150, 110); + }).not.toThrow(); + pointer("pointermove", 170, 130); + pointer("pointerup", 170, 130); + expect(panel.pos.x).toBeCloseTo(120, 6); + expect(panel.pos.y).toBeCloseTo(120, 6); + }); + + it("dragging a draggable panel by a button neither moves it nor clicks", () => { + const panel = place( + new UIPanel(100, 100, { width: 300, height: 200, draggable: true }), + ); + const onClick = vi.fn(); + panel.addChild( + new UITextButton(20, 20, { + text: "x", + borderWidth: 100, + borderHeight: 40, + onClick, + }), + ); + syncBroadphase(); + drag(150, 140, 200, 190); + expect([panel.pos.x, panel.pos.y]).toEqual([100, 100]); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("does not fling while the pointer is still down", () => { + const panel = place( + new UIPanel(100, 100, { + width: 200, + height: 100, + layout: "column", + scroll: "vertical", + }), + ); + for (let i = 0; i < 10; i++) { + const r = new Renderable(0, 0, 100, 40); + r.anchorPoint.set(0, 0); + panel.addChild(r); + } + syncBroadphase(); + pointer("pointermove", 150, 190); + pointer("pointerdown", 150, 190); + for (let i = 1; i <= 4; i++) { + pointer("pointermove", 150, 190 - i * 15); + } + const held = panel.scrollY; + for (let i = 0; i < 30; i++) { + panel.update(16); + } + expect(panel.scrollY).toBe(held); + pointer("pointerup", 150, 130); + }); + + it("autoSize counts the title and frame from a zero height", () => { + const panel = place( + new UIPanel(0, 0, { + width: 200, + height: 0, + title: "T", + titleFontSize: 20, + layout: "column", + autoSize: true, + align: "stretch", + }), + ); + const r = new Renderable(0, 0, 100, 40); + r.anchorPoint.set(0, 0); + panel.addChild(r); + expect(panel.height).toBe(32 + 40); + }); + + it("a checkbox is as wide as its longer label, whichever it is", () => { + const a = new UICheckbox(0, 0, { + labelOn: "A much much longer label", + labelOff: "Off", + }); + const b = new UICheckbox(0, 0, { + labelOn: "Off", + labelOff: "A much much longer label", + }); + expect(a.width).toBe(b.width); + expect(a.width).toBeGreaterThan(100); + }); + + it("a scrolling panel inside another scrolls alone", () => { + const outer = place( + new UIPanel(100, 100, { + width: 300, + height: 200, + layout: "column", + scroll: "vertical", + }), + ); + const inner = outer.addChild( + new UIPanel(0, 0, { + width: 280, + height: 100, + layout: "column", + scroll: "vertical", + }), + ); + for (let i = 0; i < 6; i++) { + const r = new Renderable(0, 0, 100, 40); + r.anchorPoint.set(0, 0); + inner.addChild(r); + const s = new Renderable(0, 0, 100, 40); + s.anchorPoint.set(0, 0); + outer.addChild(s); + } + outer.relayout(); + syncBroadphase(); + drag(150, 180, 150, 140); + expect(inner.scrollY).toBe(40); + expect(outer.scrollY).toBe(0); + }); + + it("emptying a scrolled panel does not report scrolls", () => { + const onScroll = vi.fn(); + const panel = place( + new UIPanel(0, 0, { + width: 200, + height: 100, + layout: "column", + scroll: "vertical", + onScroll, + }), + ); + for (let i = 0; i < 10; i++) { + const r = new Renderable(0, 0, 100, 40); + r.anchorPoint.set(0, 0); + panel.addChild(r); + } + panel.scrollTo(0, 250); + onScroll.mockClear(); + // removed without keepalive: destroyed, which empties it + app.world.removeChildNow(panel); + expect(onScroll).not.toHaveBeenCalled(); + }); + + it("a held button keeps its hover colour unless given a pressed one", () => { + const b = new UITextButton(0, 0, { text: "x", hoverOnColor: "#123456" }); + expect(b.pressedColor).toBe("#123456"); + const label = b.label; + b.bitmapText = label; + expect(b.label).toBe(label); + }); + + it("the wheel to the right raises a slider", () => { + const s = place(new UISlider(100, 100, { value: 0.5, step: 0.1 })); + pointer("pointermove", 150, 110); + wheel(150, 110, 0, 100); + expect(s.value).toBeCloseTo(0.6); + }); + + it("a drag ends on a mouseup, without pointer events", () => { + const onChangeEnd = vi.fn(); + const s = place(new UISlider(100, 100, { onChangeEnd })); + pointer("pointermove", 110, 110); + pointer("pointerdown", 110, 110); + pointer("pointermove", 200, 110); + window.dispatchEvent(new MouseEvent("mouseup")); + expect(s.dragging).toBe(false); + expect(onChangeEnd).toHaveBeenCalledTimes(1); + }); + }); + describe("UIPanel dragging", () => { it("moves by its title, and comes to the front", () => { const back = place( From 1bf6e4ba994509cac4642b3d5a95e032439e9676 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:25:53 +0000 Subject: [PATCH 04/43] Examples: a taller level list in the UI example Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/examples/src/examples/ui/ExampleUI.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/examples/src/examples/ui/ExampleUI.tsx b/packages/examples/src/examples/ui/ExampleUI.tsx index a2991ffab..1b7363ef3 100644 --- a/packages/examples/src/examples/ui/ExampleUI.tsx +++ b/packages/examples/src/examples/ui/ExampleUI.tsx @@ -288,7 +288,7 @@ class PlayScreen extends Stage { * @param app - the application */ private addLevels(app: Application) { - const frame = framed(420, 435, 340, 150, "LEVELS"); + const frame = framed(420, 375, 340, 215, "LEVELS"); const panel = new UIPanel(frame.x, frame.y, { ...frame.settings, layout: "grid", @@ -330,7 +330,7 @@ class PlayScreen extends Stage { * @param app - the application */ private addBars(app: Application) { - const frame = framed(420, 20, 340, 400, "PROGRESS"); + const frame = framed(420, 20, 340, 340, "PROGRESS"); const panel = new UIPanel(frame.x, frame.y, frame.settings); /** @@ -388,14 +388,14 @@ class PlayScreen extends Stage { // 3. vertical, filling upward, with a gradient down its length panel.addChild(caption(24, 232, "vertical")); - const ramp = app.renderer.createLinearGradient(0, 372, 0, 252); + const ramp = app.renderer.createLinearGradient(0, 322, 0, 252); ramp.addColorStop(0, "#d4553a"); ramp.addColorStop(0.5, "#e8c84a"); ramp.addColorStop(1, "#59b25b"); panel.addChild( new ProgressBar(24, 252, { width: 30, - height: 120, + height: 70, value: 0.8, direction: "bottom-to-top", trackColor: "#0000001a", @@ -416,7 +416,7 @@ class PlayScreen extends Stage { const mixed = new Color().copy(cool); const pulse = new ProgressBar(150, 252, { width: 30, - height: 120, + height: 70, value: 1, direction: "bottom-to-top", trackColor: "#0000001a", From 4cacf9417c0a8bde9d868db312f63443f77b8215 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:29:43 +0000 Subject: [PATCH 05/43] Examples: the UI example's level list at the top right, beside the bars Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- .../examples/src/examples/ui/ExampleUI.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/examples/src/examples/ui/ExampleUI.tsx b/packages/examples/src/examples/ui/ExampleUI.tsx index 1b7363ef3..efaadda94 100644 --- a/packages/examples/src/examples/ui/ExampleUI.tsx +++ b/packages/examples/src/examples/ui/ExampleUI.tsx @@ -288,17 +288,17 @@ class PlayScreen extends Stage { * @param app - the application */ private addLevels(app: Application) { - const frame = framed(420, 375, 340, 215, "LEVELS"); + const frame = framed(724, 20, 260, 400, "LEVELS"); const panel = new UIPanel(frame.x, frame.y, { ...frame.settings, layout: "grid", - columns: 5, + columns: 4, padding: 10, gap: 8, align: "center", scroll: "vertical", }); - for (let level = 1; level <= 20; level++) { + for (let level = 1; level <= 30; level++) { const pick = new UITextButton(0, 0, { font: "kenpixel", size: 16, @@ -309,8 +309,8 @@ class PlayScreen extends Stage { normal: "blue_button07", pressed: "blue_button08", }, - // the last five are locked - enabled: level <= 15, + // the last six are locked + enabled: level <= 24, onClick: () => { panel.title?.setText(`LEVEL ${level}`); }, @@ -330,7 +330,7 @@ class PlayScreen extends Stage { * @param app - the application */ private addBars(app: Application) { - const frame = framed(420, 20, 340, 340, "PROGRESS"); + const frame = framed(408, 20, 300, 400, "PROGRESS"); const panel = new UIPanel(frame.x, frame.y, frame.settings); /** @@ -355,7 +355,7 @@ class PlayScreen extends Stage { panel.addChild(caption(24, 52, "squared + label")); panel.addChild( new ProgressBar(24, 72, { - width: 290, + width: 252, height: 24, value: 0.72, trackColor: "#0000001a", @@ -374,7 +374,7 @@ class PlayScreen extends Stage { panel.addChild(caption(24, 112, "rounded")); panel.addChild( new ProgressBar(24, 132, { - width: 290, + width: 252, height: 24, value: 0.45, radius: 12, @@ -388,14 +388,14 @@ class PlayScreen extends Stage { // 3. vertical, filling upward, with a gradient down its length panel.addChild(caption(24, 232, "vertical")); - const ramp = app.renderer.createLinearGradient(0, 322, 0, 252); + const ramp = app.renderer.createLinearGradient(0, 372, 0, 252); ramp.addColorStop(0, "#d4553a"); ramp.addColorStop(0.5, "#e8c84a"); ramp.addColorStop(1, "#59b25b"); panel.addChild( new ProgressBar(24, 252, { width: 30, - height: 70, + height: 120, value: 0.8, direction: "bottom-to-top", trackColor: "#0000001a", @@ -416,7 +416,7 @@ class PlayScreen extends Stage { const mixed = new Color().copy(cool); const pulse = new ProgressBar(150, 252, { width: 30, - height: 70, + height: 120, value: 1, direction: "bottom-to-top", trackColor: "#0000001a", @@ -430,7 +430,7 @@ class PlayScreen extends Stage { // 5. right-to-left, which is the mirrored form a second player gets panel.addChild(caption(24, 172, "right to left")); const mirrored = new ProgressBar(24, 192, { - width: 290, + width: 252, height: 24, value: 0.6, direction: "right-to-left", From 32626f9eba17c09b82e7780e7c07d1405786db5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:31:38 +0000 Subject: [PATCH 06/43] Examples: the UI example's level list shows the first 16 levels Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/examples/src/examples/ui/ExampleUI.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/examples/src/examples/ui/ExampleUI.tsx b/packages/examples/src/examples/ui/ExampleUI.tsx index efaadda94..df1833503 100644 --- a/packages/examples/src/examples/ui/ExampleUI.tsx +++ b/packages/examples/src/examples/ui/ExampleUI.tsx @@ -288,7 +288,8 @@ class PlayScreen extends Stage { * @param app - the application */ private addLevels(app: Application) { - const frame = framed(724, 20, 260, 400, "LEVELS"); + // sized to show the first 16 levels: 4 rows of 49, the title, padding and frame + const frame = framed(724, 20, 260, 292, "LEVELS"); const panel = new UIPanel(frame.x, frame.y, { ...frame.settings, layout: "grid", From 2e3c8f1c9cd448facc9854684937ebff009cff3d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:42:17 +0000 Subject: [PATCH 07/43] UI: consistent widget API, and fixes from a second review API, from a consistency review against the existing UI widgets: - UITextButton: width/height, fontSize (pixels, bitmap fonts too), labelFillStyle and borderColor, the names the other widgets use; the old names still work. Settings are no longer written back. Colours take any Paint. pressedColor left undefined follows hoverOnColor. onClickCallback (was clickCallback). bitmapText keeps its BitmapText type, deprecated - UICheckbox: boxSize, boxHoverColor; labelOn/labelOff settable and refitting; group changes apply at once; skin hoverOn/hoverOff and disabledOn/disabledOff - UISlider: valueFormat(value, ratio) (was format), ratio, settable min/max/step, thumbHoverColor, thumbBorderColor, thumbBorderWidth - UIBaseElement: enabled shared by every UI element; keepsDrag public - ProgressBar: onChange also gets the bar - Paint exported; @category UI on the UI settings types Fixes: - align "stretch" only resizes buttons and panels, not a BitmapText (whose resize is a scale) or a checkbox or slider - a drag ends only on its own pointer's release, in the event family the engine listens to, not on another finger or button - a non-blocking scrolling panel inside another scrolls alone - a draggable panel is not moved through a widget at any depth, and the check no longer mixes coordinate spaces in the world - a draggable element in the world no longer jumps by the camera position - a stale scroll press is dropped; skin insets of 0 match the art - slider steps in exponent notation, and a range that is not a whole number of steps, snap correctly Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- .../examples/src/examples/ui/ExampleUI.tsx | 18 +- packages/melonjs/CHANGELOG.md | 18 +- .../skills/melonjs-ui-and-text/SKILL.md | 46 ++- packages/melonjs/src/index.ts | 1 + .../melonjs/src/renderable/ui/panelpaint.ts | 6 +- .../melonjs/src/renderable/ui/progressbar.ts | 14 +- .../src/renderable/ui/uibaseelement.ts | 84 ++++-- .../melonjs/src/renderable/ui/uicheckbox.ts | 263 ++++++++++++------ packages/melonjs/src/renderable/ui/uipanel.ts | 80 ++++-- .../melonjs/src/renderable/ui/uislider.ts | 183 ++++++++---- .../melonjs/src/renderable/ui/uitextbutton.ts | 179 +++++++----- packages/melonjs/tests/progressbar.spec.js | 4 +- packages/melonjs/tests/ui-widgets.spec.js | 217 ++++++++++++++- 13 files changed, 816 insertions(+), 297 deletions(-) diff --git a/packages/examples/src/examples/ui/ExampleUI.tsx b/packages/examples/src/examples/ui/ExampleUI.tsx index df1833503..be0b2236a 100644 --- a/packages/examples/src/examples/ui/ExampleUI.tsx +++ b/packages/examples/src/examples/ui/ExampleUI.tsx @@ -31,7 +31,7 @@ const SFX_PATH = `${import.meta.env.BASE_URL}assets/isometricDungeon/sfx/`; let texture: TextureAtlas; -/** the text style every widget here shares */ +/** the text style every widget here shares: the same names on each */ const FONT = { font: "kenpixel", fontSize: 14, labelFillStyle: "black" }; /** the options as last accepted */ @@ -68,11 +68,10 @@ const button = ( onClick: () => void, ) => { return new UITextButton(0, 0, { - font: "kenpixel", - size: 12, - fillStyle: "black", + ...FONT, + fontSize: 12, text, - borderWidth: width, + width, skin: { atlas: texture, normal: `${color}_button04`, @@ -100,8 +99,8 @@ const volume = ( ...FONT, label, showValue: true, - format: (v) => { - return `${Math.round(v * 100)}%`; + valueFormat: (v, ratio) => { + return `${Math.round(ratio * 100)}%`; }, length: 320, value, @@ -301,9 +300,8 @@ class PlayScreen extends Stage { }); for (let level = 1; level <= 30; level++) { const pick = new UITextButton(0, 0, { - font: "kenpixel", - size: 16, - fillStyle: "black", + ...FONT, + fontSize: 16, text: String(level), skin: { atlas: texture, diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index c5ebe0a75..394434aa1 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -5,15 +5,25 @@ ### Added - Particles: a `depth` setting, the spawn area's size along Z, with `width` and `height`. Under a `Camera3d`, particles are born through a volume instead of on a flat sheet - UI: `UICheckbox`, a checkbox with its label, drawn or from atlas art, that becomes a radio button with a `group` ([#1722](https://github.com/melonjs/melonJS/issues/1722)). `onChange` only fires on user input, not when `checked` is set from code -- UI: `UISlider`, a horizontal or vertical slider with `min`, `max`, `step`, drag, track clicks and the mouse wheel, an optional caption and value text, drawn or from atlas art. `onChange` fires while it moves, `onChangeEnd` once on release, neither when `value` is set from code -- UI: `UIPanel` lays its children out (`layout`: `"column"`, `"row"` or `"grid"`, with `padding`, `gap`, `align` and `autoSize`), scrolls them (`scroll`, with the wheel, dragging, momentum and a scrollbar; `scrollTo`, `scrollToChild`), and takes a nine-slice `skin`, a `title` and `draggable` -- UI: `UITextButton` takes an `onClick` setting, called on a completed click; a `skin` of atlas regions, with pressed art drawn lowered; `enabled`; and `pressedColor`. A font that is not a loaded bitmap font is drawn as a CSS font, where the button used to throw -- UI: `UIBaseElement#cancelPress()` and `onCancel()`, to call off a press without a release; a scrolling `UIPanel` uses them when a drag on a button turns into a scroll +- UI: `UISlider`, a horizontal or vertical slider with `min`, `max`, `step`, drag, track clicks and the mouse wheel, an optional caption and value text (`valueFormat`), drawn or from atlas art. `onChange` fires while it moves, `onChangeEnd` once on release, neither when `value` is set from code +- UI: `UIPanel` lays its children out (`layout`: `"column"`, `"row"` or `"grid"`, with `padding`, `gap`, `align` and `autoSize`), scrolls them (`scroll`, with the wheel, dragging, momentum and a scrollbar; `scrollTo`, `scrollToChild`), and takes a nine-slice `skin`, a `title` and `draggable`; a draggable panel comes to the front when grabbed +- UI: `UITextButton` takes an `onClick` setting, called on a completed click; a `skin` of atlas regions, with pressed art drawn lowered; `pressedColor`; and the names the other widgets use: `width` / `height` for its size, `fontSize` (pixels, also with a bitmap font), `labelFillStyle` and `borderColor`. `resize()` keeps its background and label in step. A font that is not a loaded bitmap font is drawn as a CSS font, where the button used to throw +- UI: `enabled` on every `UIBaseElement`, to turn a widget off; `cancelPress()` and `onCancel()`, to call off a press without a release, as a scrolling `UIPanel` does when a drag on a button turns into a scroll; and `keepsDrag`, for a widget of your own that is dragged inside a scrolling panel +- UI: `ProgressBar`'s `onChange` also gets the bar +- UI: the `UISkin`, `UILabel`, `Paint` and settings and skin types of every widget are exported; `Container#clipContains(x, y)` tells whether a container shows its children at a point + +### Changed +- UI: `UITextButton` no longer writes the size and alignment it worked out back into the settings object, so one object can make several buttons +- UI: `UITextButton#label` is the button's text, a `BitmapText` or, with a CSS font, a `Text` + +### Deprecated +- UI: `UITextButton#bitmapText`, for `label` ### Fixed - Input: a child cropped out of view by a container with `clipping`, or scrolled out of a `UIPanel`, no longer gets pointer events where it is not drawn - UI: a `UITextButton` moved after it was built draws its background where it is, not where it was first put - UI: a blocking `UIPanel` stays opaque to hover for as long as the pointer is on it, not only on the frame the pointer enters +- UI: a draggable `UIBaseElement` pressed with no hover first, as a touch tap is, no longer throws, and drags by touch; in the world, rather than floating, it no longer jumps by the camera position when dragged ## [20.12.0] (melonJS 2) - _2026-10-30_ diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index 198d935b4..9bd22d184 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -478,8 +478,8 @@ menu.addChild(new UITextButton(0, 0, { text: "Options", onClick: openOptions })) Children are laid out in the order they were added. It runs when a child is added or removed and when the panel is resized, **not every frame**: a child that changes size afterwards, a label whose text grew, needs -`menu.relayout()`. `"stretch"` resizes children that have a `resize` method, -which `UITextButton` and other containers do; a `Text` keeps its size. +`menu.relayout()`. `"stretch"` resizes buttons and panels, which lay +themselves out again at a new size; anything else keeps its size. A row inside a column is a second panel that draws nothing and lets clicks through: `new UIPanel(0, 0, { width, height, layout: "row", blocking: false })`. @@ -509,11 +509,12 @@ What to know: The button gets `onCancel()` instead of `onRelease()`, and its `onClick` SETTING is not called. The `onClick()` METHOD already ran on the press, so a button in a list does its work in the setting: see the next section. -- **A slider in a scrolling panel is dragged, not the panel.** Any widget - that sets `keepsDrag` keeps its drags. -- Children scrolled out of view do not get pointer events. Nested scrolling - panels are not supported yet, and on WebGL a scrolling panel inside another - clipping container is cropped to its own area only, not to the outer one. +- **A slider in a scrolling panel is dragged, not the panel.** A widget of + your own that is dragged too, a knob or a colour picker, sets + `keepsDrag = true` to keep its drags. +- Children scrolled out of view do not get pointer events. A scrolling panel + inside another scrolls alone, but nesting is not supported beyond that: on + WebGL the inner one is cropped to its own area only, not to the outer one. - A draggable panel is moved by its title or its frame. A press on a widget on it is the widget's. @@ -575,7 +576,7 @@ No subclass needed for a single action: ```js const play = new UITextButton(100, 200, { text: "Play", // a CSS font by default ("sans-serif") - size: 24, // pixels with a CSS font, a SCALE with a bitmap font + fontSize: 24, // pixels, with a CSS font or a bitmap font onClick: () => state.change(state.PLAY), }); ``` @@ -590,19 +591,33 @@ as before, for subclasses. any other name, drawn as a CSS font with `Text`. The label is `button.label` (`bitmapText` is the old name for it). +The button takes the same names as the other widgets: `width` and `height` +for its size, `fontSize` (pixels) and `labelFillStyle` for its text, +`borderColor` for its outline. The older `borderWidth` / `borderHeight` +(its SIZE, not an outline thickness), `size` (a scale with a bitmap font) +and `fillStyle` still work. One style object can then go to every widget: + +```js +const FONT = { font: "kenpixel", fontSize: 14, labelFillStyle: "black" }; +new UITextButton(0, 0, { ...FONT, text: "OK", width: 160 }); +new UICheckbox(0, 0, { ...FONT, label: "Music" }); +new UISlider(0, 0, { ...FONT, label: "Volume" }); +``` + **Skinned** from an atlas, drawn as a nine-slice so one image makes a button of any width: ```js new UITextButton(0, 0, { - font: "kenpixel", size: 12, text: "Accept", borderWidth: 160, + font: "kenpixel", fontSize: 12, text: "Accept", width: 160, skin: { atlas, normal: "green_button04", pressed: "green_button05" }, }); ``` Pressed art is usually shorter than the normal art. The button draws it lowered by the difference and moves the label down with it, so it looks -pushed in. `hover` and `disabled` regions are optional. `enabled = false` +pushed in. `hover` and `disabled` regions are optional. `enabled`, shared by +every UI element, turns a widget off: `enabled = false` draws the `disabled` region, or the button at half opacity, and ignores the pointer and the key. @@ -659,7 +674,9 @@ const music = new UICheckbox(40, 60, { The label is part of the widget: clicking it toggles the box, and removing the checkbox takes the label with it. Drawn with primitives by default, or -`skin: { atlas, on, off, disabled? }`. +`skin: { atlas, on, off, hoverOn?, hoverOff?, disabledOn?, disabledOff? }`. +`boxSize`, `boxColor`, `boxHoverColor` and `checkColor` style the drawn box, +and `labelOn` / `labelOff` can be changed later: the widget refits. **Setting `checked` from code does NOT call `onChange`**, nor does `toggle()`. Only the user does. Restoring saved settings into the widgets @@ -682,7 +699,7 @@ the scene. ```js const volume = new UISlider(40, 120, { label: "Music", showValue: true, - format: (v) => Math.round(v * 100) + "%", + valueFormat: (v, ratio) => Math.round(ratio * 100) + "%", min: 0, max: 1, step: 0.01, value: audio.getVolume(), length: 300, // orientation: "vertical" too onChange: (v) => audio.setVolume(v), // every step of the drag @@ -694,7 +711,10 @@ Drag the thumb, or click the track to jump there. The drag follows the pointer after it leaves the track, until it is released. The mouse wheel over the slider moves it one `step`. `value` snaps to `step` and clamps to the range, and setting it does not call `onChange` or `onChangeEnd`: the same rule -as the checkbox. `skin: { atlas, track, fill?, thumb, thumbHover? }` draws it +as the checkbox, and neither does changing `min`, `max` or `step`, which +re-clamp it. `ratio` reads where it is from 0 to 1, and `valueFormat` gets +both. `thumbColor`, `thumbHoverColor`, `thumbBorderColor` and +`thumbBorderWidth` style the drawn thumb. `skin: { atlas, track, fill?, thumb, thumbHover? }` draws it from art: the track and fill as nine-slices along its length, the thumb at its own size. diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index eecfb17e3..073b5551b 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -198,6 +198,7 @@ export type { InstancedMeshSettings, } from "./renderable/instanced_mesh.js"; export type { MeshSettings } from "./renderable/mesh.js"; +export type { Paint } from "./renderable/ui/panelpaint.js"; export type { ProgressBarDirection, ProgressBarSettings, diff --git a/packages/melonjs/src/renderable/ui/panelpaint.ts b/packages/melonjs/src/renderable/ui/panelpaint.ts index d6e7ccc7e..a9a387430 100644 --- a/packages/melonjs/src/renderable/ui/panelpaint.ts +++ b/packages/melonjs/src/renderable/ui/panelpaint.ts @@ -4,7 +4,11 @@ import { Color, colorPool } from "../../math/color.ts"; import type { Gradient } from "../../video/gradient.js"; import type Renderer from "../../video/renderer.js"; -/** anything {@link Renderer#setColor} will take */ +/** + * A colour a widget is painted with: a CSS string, a {@link Color} (kept + * and read every frame, so changing it animates the widget), or a gradient. + * @category UI + */ export type Paint = Color | string | Gradient; /** diff --git a/packages/melonjs/src/renderable/ui/progressbar.ts b/packages/melonjs/src/renderable/ui/progressbar.ts index 28b9d1e51..5331c4545 100644 --- a/packages/melonjs/src/renderable/ui/progressbar.ts +++ b/packages/melonjs/src/renderable/ui/progressbar.ts @@ -14,7 +14,10 @@ import { PanelPainter } from "./panelpaint.ts"; */ type EventName = Parameters[0]; -/** which way the fill grows */ +/** + * which way the fill grows + * @category UI + */ export type ProgressBarDirection = | "left-to-right" | "right-to-left" @@ -23,6 +26,7 @@ export type ProgressBarDirection = /** * Everything {@link ProgressBar} takes. + * @category UI */ export interface ProgressBarSettings { /** the bar's full width, in pixels */ @@ -53,7 +57,7 @@ export interface ProgressBarSettings { /** corner radius; `0` is square */ radius?: number; /** called whenever the value actually moves */ - onChange?: (value: number, ratio: number) => void; + onChange?: (value: number, ratio: number, bar: ProgressBar) => void; /** draw a label over the bar */ showLabel?: boolean; /** the label's font family */ @@ -153,7 +157,9 @@ export default class ProgressBar extends Renderable { /** corner radius; `0` is square */ radius: number; /** called whenever the value actually moves */ - onChange?: ((value: number, ratio: number) => void) | undefined; + onChange?: + | ((value: number, ratio: number, bar: ProgressBar) => void) + | undefined; /** what the label says */ labelFormat: (value: number, ratio: number) => string; /** the label, when `showLabel` was asked for */ @@ -258,7 +264,7 @@ export default class ProgressBar extends Renderable { if (this.label !== undefined) { this.label.setText(this.labelFormat(next, this.ratio)); } - this.onChange?.(next, this.ratio); + this.onChange?.(next, this.ratio, this); } /** diff --git a/packages/melonjs/src/renderable/ui/uibaseelement.ts b/packages/melonjs/src/renderable/ui/uibaseelement.ts index e72a20d05..662c5abdc 100644 --- a/packages/melonjs/src/renderable/ui/uibaseelement.ts +++ b/packages/melonjs/src/renderable/ui/uibaseelement.ts @@ -5,6 +5,7 @@ import { import type Pointer from "./../../input/pointer.ts"; import type { Vector2d } from "../../math/vector2d.ts"; import { vector2dPool } from "../../math/vector2d.ts"; +import * as device from "../../system/device.ts"; import { off, on, POINTERMOVE } from "../../system/event.ts"; import timer from "../../system/timer.ts"; import Container from "../container.js"; @@ -24,32 +25,45 @@ export function isPointerUp(event: Pointer | undefined): boolean { } /** - * The DOM events that can end a drag, listened for on the window so a drag - * ends wherever the pointer is let go. The mouse and touch ones cover a - * browser without pointer events. + * The DOM events that end a drag, listened for on the window so a drag ends + * wherever the pointer is let go: the same family the engine itself listens + * to, pointer events where the browser has them, mouse and touch otherwise. + * Listening to both would let a second finger, which also fires touch + * events, end a drag it has nothing to do with. * @ignore * @internal */ -export const END_EVENTS = [ - "pointerup", - "pointercancel", - "mouseup", - "touchend", - "touchcancel", -] as const; +export const END_EVENTS: readonly string[] = device.pointerEvent + ? ["pointerup", "pointercancel"] + : ["mouseup", "touchend", "touchcancel"]; /** * Whether a DOM event ends the drag of a given pointer. * @param event - one of {@link END_EVENTS} - * @param pointerId - the pointer the drag follows - * @returns true for that pointer's release; for a mouse or touch event, - * which has no pointer id, any release + * @param pointerId - the pointer the drag follows: a pointer id, a touch + * identifier, or 1 for the mouse + * @returns true for that pointer's release only * @ignore * @internal */ export function endsPointer(event: Event, pointerId: number | undefined) { - const id = (event as PointerEvent).pointerId; - return typeof id !== "number" || id === pointerId; + if ( + typeof globalThis.PointerEvent !== "undefined" && + event instanceof globalThis.PointerEvent + ) { + return event.pointerId === pointerId; + } + const touches = (event as TouchEvent).changedTouches as TouchList | undefined; + if (touches !== undefined) { + for (let i = 0; i < touches.length; i++) { + if (touches[i].identifier === pointerId) { + return true; + } + } + return false; + } + // a mouse: its main button + return (event as MouseEvent).button === 0; } /** @@ -223,14 +237,38 @@ export default class UIBaseElement extends Container { * Whether a drag that starts on this element belongs to it. * * `false` by default: inside a scrolling {@link UIPanel}, a drag that - * starts on a button scrolls the panel and cancels the press. A widget - * that is dragged itself, such as a {@link UISlider}, sets it, and so does - * anything `isDraggable`. - * @ignore - * @internal + * starts on a button scrolls the panel and cancels the press. Set it on a + * widget that is dragged itself, such as a knob or a colour picker, as + * {@link UISlider} does. Anything `isDraggable` keeps its drags anyway. + * @default false */ keepsDrag = false; + /** @ignore */ + protected _enabled = true; + + /** + * Whether the widget answers input: the switch to turn a widget off. + * + * Turning it off sets `isClickable`, calls off a press in progress + * without a release, and draws the widget disabled: its `disabled` skin + * region, or at half opacity. `isClickable` is the low-level flag + * underneath, and setting it alone changes neither the look nor this. + * @default true + */ + get enabled(): boolean { + return this._enabled; + } + + set enabled(value: boolean) { + this._enabled = value; + this.isClickable = value; + if (!value) { + this.cancelPress(); + } + this.isDirty = true; + } + /** whether the drag listener is on */ private _dragListening = false; @@ -326,7 +364,11 @@ export default class UIBaseElement extends Container { if (!this.released) { // follow the pointer // pos is ObservableVector3d at runtime but typed as Vector2d in Renderable - (this.pos as any).set(event.gameX, event.gameY, (this.pos as any).z); + // the global move carries screen coordinates; a press on an + // element in the world was measured in world ones + const x = this.isFloating ? event.gameScreenX : event.gameWorldX; + const y = this.isFloating ? event.gameScreenY : event.gameWorldY; + (this.pos as any).set(x, y, (this.pos as any).z); (this.pos as any).sub(this.grabOffset!); // mark the container for redraw this.isDirty = true; diff --git a/packages/melonjs/src/renderable/ui/uicheckbox.ts b/packages/melonjs/src/renderable/ui/uicheckbox.ts index 7270dd49d..54f072543 100644 --- a/packages/melonjs/src/renderable/ui/uicheckbox.ts +++ b/packages/melonjs/src/renderable/ui/uicheckbox.ts @@ -18,8 +18,14 @@ export interface UICheckboxSkin extends UISkin { on: string; /** the box when not checked */ off: string; - /** the box while disabled; the current one at half opacity if left out */ - disabled?: string; + /** the checked box under the pointer; `on` if left out */ + hoverOn?: string; + /** the unchecked box under the pointer; `off` if left out */ + hoverOff?: string; + /** the checked box while disabled; `on` at half opacity if left out */ + disabledOn?: string; + /** the unchecked box while disabled; `off` at half opacity if left out */ + disabledOff?: string; } /** @@ -52,7 +58,7 @@ export interface UICheckboxSettings { * the side of the box, in pixels; 24, or the height of the skin's `off` * region */ - size?: number; + boxSize?: number; /** * Make it one of a set of radio buttons: checkboxes sharing a group name * have exactly one checked. See {@link UICheckbox#group}. @@ -63,7 +69,7 @@ export interface UICheckboxSettings { * @default "sans-serif" */ font?: string; - /** the label's height, in pixels; two thirds of `size` by default */ + /** the label's height, in pixels; two thirds of `boxSize` by default */ fontSize?: number; /** * the label's colour @@ -84,7 +90,7 @@ export interface UICheckboxSettings { * the box's fill under the pointer (drawn look) * @default "#e4eefb" */ - hoverOnColor?: Paint; + boxHoverColor?: Paint; /** * the box's fill when checked, and the radio dot (drawn look) * @default "#2a7de1" @@ -164,14 +170,32 @@ export default class UICheckbox extends UIBaseElement { * Checkboxes sharing a group have exactly one checked: checking one * unchecks the others, and clicking the checked one leaves it checked. A * checkbox is in its group while it is in the scene; removing it takes it - * out. Changing this property takes effect the next time it is added. + * out. Changing it moves the checkbox to the new group straight away; + * a checked one then unchecks the others there. */ - group: string | undefined; + get group(): string | undefined { + return this._group; + } + + set group(value: string | undefined) { + if (value === this._group) { + return; + } + const active = this._active; + if (active) { + this.leaveGroup(); + } + this._group = value; + if (active) { + this.joinGroup(); + } + this.isDirty = true; + } /** the box's fill (drawn look) */ boxColor: Paint; /** the box's fill under the pointer (drawn look) */ - hoverOnColor: Paint; + boxHoverColor: Paint; /** the box's fill when checked, and the radio dot (drawn look) */ checkColor: Paint; /** the box's outline (drawn look) */ @@ -186,18 +210,26 @@ export default class UICheckbox extends UIBaseElement { /** the space between the box and the label, in pixels */ readonly labelGap: number; /** the label, absent when there is no text */ - readonly label: UILabel | undefined; + label: UILabel | undefined; /** the art, when it is skinned */ readonly skin: UICheckboxSkin | undefined; /** @ignore */ private _checked: boolean; /** @ignore */ - private _enabled = true; + private _labelOn: string; /** @ignore */ - private readonly _labelOn: string; + private _labelOff: string; + /** how a label is made, for one added after construction */ + private readonly _labelStyle: { + font: string; + fontSize: number; + fillStyle: string; + }; /** @ignore */ - private readonly _labelOff: string; + private _group: string | undefined; + /** whether it is in the scene */ + private _active = false; /** @ignore */ private readonly _painter = new PanelPainter(); /** @ignore */ @@ -214,13 +246,22 @@ export default class UICheckbox extends UIBaseElement { super(x, y, 1, 1); this.skin = settings.skin; - let size = settings.size; + let size = settings.boxSize; if (this.skin !== undefined) { this._skin = new SkinDrawer(this.skin); const off = this._skin.size(this.skin.off); - this._skin.size(this.skin.on); - if (this.skin.disabled !== undefined) { - this._skin.size(this.skin.disabled); + // every region named is checked now, so a typo fails here + for (const state of [ + "on", + "hoverOn", + "hoverOff", + "disabledOn", + "disabledOff", + ] as const) { + const region = this.skin[state]; + if (region !== undefined) { + this._skin.size(region); + } } size ??= off.height; } @@ -229,10 +270,10 @@ export default class UICheckbox extends UIBaseElement { this._checked = settings.checked ?? false; this.onChange = settings.onChange; - this.group = settings.group; + this._group = settings.group; this.boxColor = settings.boxColor ?? "#ffffff"; - this.hoverOnColor = settings.hoverOnColor ?? "#e4eefb"; + this.boxHoverColor = settings.boxHoverColor ?? "#e4eefb"; this.checkColor = settings.checkColor ?? "#2a7de1"; this.borderColor = settings.borderColor ?? "#3a3a3a"; this.borderWidth = settings.borderWidth ?? 2; @@ -240,32 +281,13 @@ export default class UICheckbox extends UIBaseElement { this._labelOn = settings.labelOn ?? settings.label ?? ""; this._labelOff = settings.labelOff ?? settings.label ?? ""; - - let width = this.boxSize; - let height = this.boxSize; - if (this._labelOn !== "" || this._labelOff !== "") { - const label = createLabel({ - text: this._checked ? this._labelOn : this._labelOff, - font: settings.font ?? "sans-serif", - fontSize: - settings.fontSize ?? Math.round(Math.max(12, this.boxSize * 0.66)), - fillStyle: settings.labelFillStyle ?? "#000000", - textAlign: "left", - textBaseline: "middle", - }); - // as wide as the longer of the two texts, so the widget does not - // change size, and move what is laid out after it, as it toggles - // read straight away: `measureText` hands back the same object - // each time, so a second call overwrites the first - let metrics = label.measureText(this._labelOn); - const onWidth = metrics.width; - const onHeight = metrics.height; - metrics = label.measureText(this._labelOff); - width += this.labelGap + Math.max(onWidth, metrics.width); - height = Math.max(height, onHeight, metrics.height); - this.label = label; - } - this.resize(Math.ceil(width), Math.ceil(height)); + this._labelStyle = { + font: settings.font ?? "sans-serif", + fontSize: + settings.fontSize ?? Math.round(Math.max(12, this.boxSize * 0.66)), + fillStyle: settings.labelFillStyle ?? "#000000", + }; + this.fitLabel(); this.enabled = settings.enabled ?? true; } @@ -294,35 +316,77 @@ export default class UICheckbox extends UIBaseElement { } /** - * Flip it, from code. Like setting {@link UICheckbox#checked}, this does - * not call `onChange`. - * @returns the new state + * The text while checked. Setting it resizes the widget to fit. */ - toggle(): boolean { - this.checked = !this._checked; - return this._checked; + get labelOn(): string { + return this._labelOn; + } + + set labelOn(value: string) { + this._labelOn = value; + this.fitLabel(); } /** - * Whether it answers input. - * - * A disabled checkbox is drawn with its `disabled` skin region, or at - * half opacity, and ignores the pointer. - * @default true + * The text while not checked. Setting it resizes the widget to fit. */ - get enabled(): boolean { - return this._enabled; + get labelOff(): string { + return this._labelOff; } - set enabled(value: boolean) { - this._enabled = value; - this.isClickable = value; - if (!value) { - this.cancelPress(); + set labelOff(value: string) { + this._labelOff = value; + this.fitLabel(); + } + + /** + * Show the current text, and size the widget to the box and the longer + * of the two texts, so it keeps its size, and does not move what is laid + * out after it, as it toggles. + * @ignore + * @internal + */ + private fitLabel() { + let width = this.boxSize; + let height = this.boxSize; + if (this._labelOn !== "" || this._labelOff !== "") { + const text = this._checked ? this._labelOn : this._labelOff; + if (this.label === undefined) { + this.label = createLabel({ + ...this._labelStyle, + text, + textAlign: "left", + textBaseline: "middle", + }); + } else { + this.label.setText(text); + } + // read straight away: `measureText` hands back the same object + // each time, so a second call overwrites the first + let metrics = this.label.measureText(this._labelOn); + const onWidth = metrics.width; + const onHeight = metrics.height; + metrics = this.label.measureText(this._labelOff); + width += this.labelGap + Math.max(onWidth, metrics.width); + height = Math.max(height, onHeight, metrics.height); + } else if (this.label !== undefined) { + this.label.destroy(); + this.label = undefined; } + this.resize(Math.ceil(width), Math.ceil(height)); this.isDirty = true; } + /** + * Flip it, from code. Like setting {@link UICheckbox#checked}, this does + * not call `onChange`. + * @returns the new state + */ + toggle(): boolean { + this.checked = !this._checked; + return this._checked; + } + /** * The checked checkbox of a radio group. * @param group - the group name @@ -386,19 +450,8 @@ export default class UICheckbox extends UIBaseElement { */ override onActivateEvent(): void { super.onActivateEvent(); - if (this.group !== undefined) { - let members = groups.get(this.group); - if (members === undefined) { - members = new Set(); - groups.set(this.group, members); - } - members.add(this); - this._joined = this.group; - // the newest checked member wins - if (this._checked) { - this.uncheckOthers(); - } - } + this._active = true; + this.joinGroup(); } /** @@ -406,17 +459,49 @@ export default class UICheckbox extends UIBaseElement { * @ignore */ override onDeactivateEvent(): void { - if (this._joined !== undefined) { - const members = groups.get(this._joined); - members?.delete(this); - if (members?.size === 0) { - groups.delete(this._joined); - } - this._joined = undefined; - } + this._active = false; + this.leaveGroup(); super.onDeactivateEvent(); } + /** + * Join the group it names, if any; the newest checked member wins. + * @ignore + * @internal + */ + private joinGroup() { + if (this._group === undefined) { + return; + } + let members = groups.get(this._group); + if (members === undefined) { + members = new Set(); + groups.set(this._group, members); + } + members.add(this); + this._joined = this._group; + if (this._checked) { + this.uncheckOthers(); + } + } + + /** + * Leave the group it is in, if any. + * @ignore + * @internal + */ + private leaveGroup() { + if (this._joined === undefined) { + return; + } + const members = groups.get(this._joined); + members?.delete(this); + if (members?.size === 0) { + groups.delete(this._joined); + } + this._joined = undefined; + } + /** * Draw the box, then the label. * @param renderer - the renderer to draw with @@ -431,13 +516,17 @@ export default class UICheckbox extends UIBaseElement { if (this._skin !== undefined) { const skin = this.skin!; - let region = this._checked ? skin.on : skin.off; + const on = this._checked; + let region = on ? skin.on : skin.off; if (!this._enabled) { - if (skin.disabled !== undefined) { - region = skin.disabled; + const disabled = on ? skin.disabledOn : skin.disabledOff; + if (disabled !== undefined) { + region = disabled; } else { renderer.setGlobalAlpha(faded); } + } else if (this.hover) { + region = (on ? skin.hoverOn : skin.hoverOff) ?? region; } this._skin.draw(renderer, region, x, y, size, size); } else { @@ -477,7 +566,7 @@ export default class UICheckbox extends UIBaseElement { this._checked && this.group === undefined ? this.checkColor : this.hover && this._enabled - ? this.hoverOnColor + ? this.boxHoverColor : this.boxColor; const half = size / 2; diff --git a/packages/melonjs/src/renderable/ui/uipanel.ts b/packages/melonjs/src/renderable/ui/uipanel.ts index ad51882a0..2a12db52a 100644 --- a/packages/melonjs/src/renderable/ui/uipanel.ts +++ b/packages/melonjs/src/renderable/ui/uipanel.ts @@ -18,6 +18,7 @@ import type { UILabel } from "./uilabel.ts"; import { createLabel } from "./uilabel.ts"; import type { UISkin } from "./uiskin.ts"; import { SkinDrawer } from "./uiskin.ts"; +import UITextButton from "./uitextbutton.ts"; /** * The art of a skinned {@link UIPanel}: one atlas region, drawn as a @@ -57,6 +58,7 @@ export type UIPanelScroll = "none" | "vertical" | "horizontal" | "both"; /** * Everything {@link UIPanel} takes. + * @category UI */ export interface UIPanelSettings { /** the panel's width, in pixels */ @@ -186,6 +188,12 @@ interface ChildBox { h: number; } +/** + * The pointer-downs a scrolling panel has taken, so a panel around it does + * not take the same one and scroll with it. + */ +const takenPresses = new WeakSet(); + /** how far a press has to move before it scrolls instead, in pixels */ const SCROLL_THRESHOLD = 8; /** how much speed a fling keeps per 60th of a second */ @@ -320,6 +328,7 @@ export default class UIPanel extends UIBaseElement { /** a press on the content that may become a scroll */ private _press: | { + dom: Event | undefined; pointerId: number | undefined; x: number; y: number; @@ -725,8 +734,10 @@ export default class UIPanel extends UIBaseElement { if (this.skin !== undefined) { const region = this._skin!.size(this.skin.region); return [ - this.skin.insetx ?? region.width / 4, - this.skin.insety ?? region.height / 4, + // `||` as `NineSliceSprite` reads them, so a 0 means the + // default for the art and the layout alike + this.skin.insetx || region.width / 4, + this.skin.insety || region.height / 4, ]; } const border = this.borderColor !== null ? this.borderWidth : 0; @@ -800,11 +811,14 @@ export default class UIPanel extends UIBaseElement { * @internal */ private stretch(child: Renderable, w: number, h: number) { + // only what lays itself out again at a new size: a `BitmapText`'s + // `resize` takes a SCALE, and a checkbox or a slider would only widen + // its hit area if ( - typeof (child as any).resize === "function" && + (child instanceof UITextButton || child instanceof UIPanel) && (child.width !== w || child.height !== h) ) { - (child as any).resize(w, h); + child.resize(w, h); } } @@ -1158,13 +1172,29 @@ export default class UIPanel extends UIBaseElement { if (this.scroll === "none") { return false; } + const dom = event.event; if (this._press !== undefined) { - // the same press, offered again by the panel's own handler + if ( + this._press.pointerId === event.pointerId && + this._press.dom === dom + ) { + // the same press, offered again by the panel's own handler + return true; + } + // a press whose release was never heard: drop it + this.endPress(); + } + if (dom !== undefined && takenPresses.has(dom)) { + // a scrolling panel inside this one has it return true; } + if (dom !== undefined) { + takenPresses.add(dom); + } const [x, y] = this.localPoint(event); this._velocityX = this._velocityY = 0; this._press = { + dom, pointerId: event.pointerId, x, y, @@ -1281,10 +1311,7 @@ export default class UIPanel extends UIBaseElement { // Grabbed by its title or its frame, not through a widget on it: a // button pressed to drag the panel would move with it, stay under // the pointer, and be clicked on the release. - this._moving = - this.isDraggable && - !onContent && - !this.onWidget(event.gameX, event.gameY); + this._moving = this.isDraggable && !onContent && !this.widgetPressed(); if (this.isDraggable && this.ancestor instanceof Container) { this.ancestor.moveToTop(this); } @@ -1304,27 +1331,28 @@ export default class UIPanel extends UIBaseElement { } /** - * Whether a point is on one of the panel's clickable widgets. - * @param x - the point, in the coordinates of the panel's bounds - * @param y - the point - * @returns true when a child that takes the pointer is under it + * Whether this press is on a widget inside the panel, at any depth. + * + * Children are asked about a press before the container holding them, so + * a widget it landed on is already pressed by the time the panel hears + * of it. + * @returns true when a widget inside is held down * @ignore * @internal */ - private onWidget(x: number, y: number): boolean { - if (!this.clipContains(x, y)) { - return false; - } - for (const child of this.getChildren()) { - if ( - child instanceof UIBaseElement && - child.isClickable && - child.getBounds().contains(x, y) - ) { - return true; + private widgetPressed(): boolean { + const pressed = (container: Container): boolean => { + for (const child of container.getChildren()) { + if (child instanceof UIBaseElement && !child.released) { + return true; + } + if (child instanceof Container && pressed(child)) { + return true; + } } - } - return false; + return false; + }; + return pressed(this); } /** diff --git a/packages/melonjs/src/renderable/ui/uislider.ts b/packages/melonjs/src/renderable/ui/uislider.ts index c632030d8..8f426f852 100644 --- a/packages/melonjs/src/renderable/ui/uislider.ts +++ b/packages/melonjs/src/renderable/ui/uislider.ts @@ -98,8 +98,12 @@ export interface UISliderSettings { * @default false */ showValue?: boolean; - /** how the value is written, e.g. `(v) => Math.round(v * 100) + "%"` */ - format?: (value: number) => string; + /** + * How the value is written, given the value and where it is between + * `min` and `max` (0 to 1), as `ProgressBar`'s `labelFormat` is: e.g. + * `(v, ratio) => Math.round(ratio * 100) + "%"` + */ + valueFormat?: (value: number, ratio: number) => string; /** * a CSS font family, or the name of a loaded bitmap font * @default "sans-serif" @@ -134,12 +138,17 @@ export interface UISliderSettings { * the thumb under the pointer or while dragged (drawn look) * @default "#e4eefb" */ - hoverOnColor?: Paint; + thumbHoverColor?: Paint; /** * the thumb's outline (drawn look) * @default "#2a7de1" */ - borderColor?: Paint; + thumbBorderColor?: Paint; + /** + * the thickness of the thumb's outline (drawn look); `0` for none + * @default 2 + */ + thumbBorderWidth?: number; /** draw it from atlas regions instead */ skin?: UISliderSkin; } @@ -173,7 +182,7 @@ interface Box { * const volume = new UISlider(40, 80, { * label: "Music", * showValue: true, - * format: (v) => Math.round(v * 100) + "%", + * valueFormat: (v, ratio) => Math.round(ratio * 100) + "%", * value: audio.getVolume(), * onChange: (v) => audio.setVolume(v), * onChangeEnd: () => saveSettings(), @@ -195,9 +204,6 @@ export default class UISlider extends UIBaseElement { */ onChangeEnd: ((value: number, slider: UISlider) => void) | undefined; - /** how the value is written */ - format: (value: number) => string; - /** the track (drawn look) */ trackColor: Paint; /** the track up to the thumb (drawn look) */ @@ -205,9 +211,11 @@ export default class UISlider extends UIBaseElement { /** the thumb (drawn look) */ thumbColor: Paint; /** the thumb under the pointer or while dragged (drawn look) */ - hoverOnColor: Paint; + thumbHoverColor: Paint; /** the thumb's outline (drawn look) */ - borderColor: Paint; + thumbBorderColor: Paint; + /** the thickness of the thumb's outline (drawn look) */ + thumbBorderWidth: number; /** which way it slides */ readonly orientation: "horizontal" | "vertical"; @@ -218,6 +226,8 @@ export default class UISlider extends UIBaseElement { /** the value text, when `showValue` is set */ readonly valueText: UILabel | undefined; + /** @ignore */ + private _valueFormat: (value: number, ratio: number) => string; /** @ignore */ private _min: number; /** @ignore */ @@ -227,8 +237,6 @@ export default class UISlider extends UIBaseElement { /** @ignore */ private _value: number; /** @ignore */ - private _enabled = true; - /** @ignore */ private readonly _painter = new PanelPainter(); /** @ignore */ private readonly _skin: SkinDrawer | undefined; @@ -306,13 +314,14 @@ export default class UISlider extends UIBaseElement { this.orientation = settings.orientation ?? "horizontal"; this.onChange = settings.onChange; this.onChangeEnd = settings.onChangeEnd; - this.format = settings.format ?? defaultFormat(this._step); + this._valueFormat = settings.valueFormat ?? defaultFormat(this._step); this.trackColor = settings.trackColor ?? "#00000033"; this.fillColor = settings.fillColor ?? "#2a7de1"; this.thumbColor = settings.thumbColor ?? "#ffffff"; - this.hoverOnColor = settings.hoverOnColor ?? "#e4eefb"; - this.borderColor = settings.borderColor ?? "#2a7de1"; + this.thumbHoverColor = settings.thumbHoverColor ?? "#e4eefb"; + this.thumbBorderColor = settings.thumbBorderColor ?? "#2a7de1"; + this.thumbBorderWidth = settings.thumbBorderWidth ?? 2; const horizontal = this.orientation === "horizontal"; const length = settings.length ?? 200; @@ -357,7 +366,7 @@ export default class UISlider extends UIBaseElement { if (settings.showValue === true) { this.valueText = createLabel({ ...textStyle, - text: this.format(this._max), + text: this._valueFormat(this._max, 1), textAlign: horizontal ? "right" : "center", }); } @@ -404,25 +413,93 @@ export default class UISlider extends UIBaseElement { this._travelLength = Math.max(0, length - thumbW); this._value = this.snap(settings.value ?? this._min); - this.valueText?.setText(this.format(this._value)); + this.refreshValueText(); this.enabled = settings.enabled ?? true; } - /** the lowest value */ + /** + * The lowest value. Setting it clamps the value into the new range, + * without calling `onChange`. + */ get min(): number { return this._min; } - /** the highest value */ + set min(v: number) { + this._min = v; + this._max = Math.max(this._max, v); + this.resnap(); + } + + /** + * The highest value. Setting it clamps the value into the new range, + * without calling `onChange`. + */ get max(): number { return this._max; } - /** the step values snap to; `0` for none */ + set max(v: number) { + this._max = v; + this._min = Math.min(this._min, v); + this.resnap(); + } + + /** + * The step values snap to; `0` for none. Setting it snaps the value to + * it, without calling `onChange`. + */ get step(): number { return this._step; } + set step(v: number) { + this._step = Math.max(0, v); + this.resnap(); + } + + /** + * Where the value is between {@link UISlider#min} and + * {@link UISlider#max}, from 0 to 1. + */ + get ratio(): number { + const span = this._max - this._min; + return span > 0 ? (this._value - this._min) / span : 0; + } + + /** + * How the value is written, given the value and the ratio. Setting it + * rewrites the value text. + */ + get valueFormat(): (value: number, ratio: number) => string { + return this._valueFormat; + } + + set valueFormat(format: (value: number, ratio: number) => string) { + this._valueFormat = format; + this.refreshValueText(); + } + + /** + * Snap and clamp the value again after the range or the step changed. + * @ignore + * @internal + */ + private resnap() { + this._value = this.snap(this._value); + this.refreshValueText(); + this.isDirty = true; + } + + /** + * Write the current value into the value text. + * @ignore + * @internal + */ + private refreshValueText() { + this.valueText?.setText(this._valueFormat(this._value, this.ratio)); + } + /** * The current value, between {@link UISlider#min} and * {@link UISlider#max}. @@ -439,30 +516,17 @@ export default class UISlider extends UIBaseElement { const next = this.snap(v); if (next !== this._value) { this._value = next; - this.valueText?.setText(this.format(next)); + this.refreshValueText(); this.isDirty = true; } } /** - * Whether it answers input. - * - * A disabled slider is drawn at half opacity and ignores the pointer and - * the wheel; disabling it mid-drag ends the drag. - * @default true + * Call off a press, and a drag in progress, without `onChangeEnd`. */ - get enabled(): boolean { - return this._enabled; - } - - set enabled(value: boolean) { - this._enabled = value; - this.isClickable = value; - if (!value) { - this.endDrag(false); - this.cancelPress(); - } - this.isDirty = true; + override cancelPress(): void { + this.endDrag(false); + super.cancelPress(); } /** @@ -486,8 +550,15 @@ export default class UISlider extends UIBaseElement { let out = v; if (this._step > 0) { out = this._min + Math.round((out - this._min) / this._step) * this._step; + // a range that is not a whole number of steps: past the end, + // take the last step inside rather than clamping off the grid + if (out > this._max) { + out -= this._step; + } // 0.1 + 0.2: keep the digits the step has, and no stray ones - out = Number(out.toFixed(decimals(this._step))); + out = Number( + out.toFixed(Math.max(decimals(this._step), decimals(this._min))), + ); } return clamp(out, this._min, this._max); } @@ -511,8 +582,7 @@ export default class UISlider extends UIBaseElement { /** where the thumb's centre is, along the slider, in its coordinates */ private thumbCenter(): number { - const span = this._max - this._min; - const ratio = span > 0 ? (this._value - this._min) / span : 0; + const ratio = this.ratio; return this.orientation === "horizontal" ? this._travelStart + ratio * this._travelLength : this._travelStart + (1 - ratio) * this._travelLength; @@ -700,13 +770,22 @@ export default class UISlider extends UIBaseElement { painter.paint(renderer, this.fillColor, a); painter.rect(renderer, fx, fy, fw, fh, radius); const r = this._thumbW / 2; - painter.paint(renderer, lit ? this.hoverOnColor : this.thumbColor, a); + painter.paint(renderer, lit ? this.thumbHoverColor : this.thumbColor, a); renderer.fillEllipse(tx + tw / 2, ty + th / 2, r, r); - const previous = renderer.lineWidth; - renderer.lineWidth = 2; - painter.paint(renderer, this.borderColor, a); - renderer.strokeEllipse(tx + tw / 2, ty + th / 2, r - 1, r - 1); - renderer.lineWidth = previous; + const bw = Math.min(this.thumbBorderWidth, r); + if (bw > 0) { + const previous = renderer.lineWidth; + renderer.lineWidth = bw; + painter.paint(renderer, this.thumbBorderColor, a); + // inset by half the width, so the outline lands inside the thumb + renderer.strokeEllipse( + tx + tw / 2, + ty + th / 2, + r - bw / 2, + r - bw / 2, + ); + renderer.lineWidth = previous; + } } renderer.setGlobalAlpha(a); @@ -764,9 +843,11 @@ function drawText(renderer: Renderer, text: UILabel, x: number, y: number) { * @internal */ function decimals(step: number): number { - const text = String(step); - const dot = text.indexOf("."); - return dot === -1 ? 0 : text.length - dot - 1; + // `String(1e-7)` is "1e-7": count the exponent too + const [mantissa, exponent] = String(step).split("e"); + const dot = mantissa.indexOf("."); + const digits = dot === -1 ? 0 : mantissa.length - dot - 1; + return Math.min(20, Math.max(0, digits - Number(exponent ?? 0))); } /** @@ -775,7 +856,7 @@ function decimals(step: number): number { * @ignore * @internal */ -function defaultFormat(step: number): (value: number) => string { +function defaultFormat(step: number): (value: number, ratio: number) => string { const digits = step > 0 ? decimals(step) : 2; return (value: number) => value.toFixed(digits); } diff --git a/packages/melonjs/src/renderable/ui/uitextbutton.ts b/packages/melonjs/src/renderable/ui/uitextbutton.ts index b75849a05..155f95441 100644 --- a/packages/melonjs/src/renderable/ui/uitextbutton.ts +++ b/packages/melonjs/src/renderable/ui/uitextbutton.ts @@ -7,6 +7,7 @@ import { BLUR, KEYDOWN, KEYUP, off, on } from "../../system/event.ts"; import type Renderer from "../../video/renderer.js"; import BitmapText from "../text/bitmaptext.js"; import Text from "../text/text.js"; +import type { Paint } from "./panelpaint.ts"; import UIBaseElement, { isPointerUp } from "./uibaseelement.ts"; import type { UILabel } from "./uilabel.ts"; import type { UISkin } from "./uiskin.ts"; @@ -46,14 +47,23 @@ export interface UITextButtonSettings { * @default "sans-serif" */ font?: string; + /** the bitmap font data, when it is loaded under another name than `font` */ + fontData?: string; /** - * With a bitmap font, a scale of its authored size; with a CSS font, the - * height in pixels. + * The label's height in pixels, for a bitmap font as for a CSS font: the + * same setting as on the other widgets. Takes over from `size`. + */ + fontSize?: number; + /** + * The older form of `fontSize`: with a bitmap font, a scale of its + * authored size; with a CSS font, the height in pixels. */ size?: number; /** the label */ text?: string; - /** the label's colour; a bitmap font is tinted with it */ + /** the label's colour, as on the other widgets; a bitmap font is tinted with it */ + labelFillStyle?: string; + /** the older name of `labelFillStyle` */ fillStyle?: string; /** see {@link UITextButton.bindKey} */ bindKey?: string | number; @@ -71,20 +81,53 @@ export interface UITextButtonSettings { enabled?: boolean; /** draw the button from atlas regions instead of a rounded rectangle */ skin?: UITextButtonSkin; - /** background colour at rest (drawn look) */ - hoverOffColor?: string; - /** background colour under the pointer (drawn look) */ - hoverOnColor?: string; + /** + * background colour at rest (drawn look) + * @default "#00aa0080" + */ + hoverOffColor?: Paint; + /** + * background colour under the pointer (drawn look) + * @default "#00ff00ff" + */ + hoverOnColor?: Paint; /** background colour while held down (drawn look); `hoverOnColor` by default */ - pressedColor?: string; - /** outline colour (drawn look) */ - borderStrokeColor?: string; + pressedColor?: Paint; + /** + * outline colour (drawn look), the name the other widgets use + * @default "#000000" + */ + borderColor?: Paint; + /** the older name of `borderColor` */ + borderStrokeColor?: Paint; + /** + * the label's alignment about the middle of the button + * @default "center" + */ textAlign?: string; + /** + * the label's baseline about the middle of the button + * @default "middle" + */ textBaseline?: string; - /** the button's width; the label's width plus 16 by default, or the skin's */ + /** + * the button's width: the label's width plus 16 by default, or the skin's + * `normal` region's + */ + width?: number; + /** + * the button's height: the label's height plus 16 by default, or the + * skin's `normal` region's + */ + height?: number; + /** + * The older name of `width`. Not an outline thickness, as `borderWidth` + * is on the other widgets. + */ borderWidth?: number; - /** the button's height; the label's height plus 16 by default, or the skin's */ + /** the older name of `height` */ borderHeight?: number; + /** passed on to the {@link BitmapText} of a bitmap font label */ [key: string]: any; } @@ -105,16 +148,16 @@ export interface UITextButtonSettings { * // drawn, with no assets * const play = new UITextButton(100, 200, { * text: "Play", - * size: 24, + * fontSize: 24, * onClick: () => state.change(state.PLAY), * }); * * // skinned from an atlas, any width * const accept = new UITextButton(30, 250, { * font: "kenpixel", - * size: 12, + * fontSize: 12, * text: "Accept", - * borderWidth: 160, + * width: 160, * skin: { atlas, normal: "green_button04", pressed: "green_button05" }, * onClick: () => saveSettings(), * }); @@ -145,7 +188,7 @@ export default class UITextButton extends UIBaseElement { /** * What the button does: see {@link UITextButtonSettings.onClick}. */ - clickCallback: ((button: UITextButton) => void) | undefined; + onClickCallback: ((button: UITextButton) => void) | undefined; private keyboardKey: number | undefined; private keyboardActive = false; @@ -192,24 +235,23 @@ export default class UITextButton extends UIBaseElement { /** * The css value of a color to be used if the pointer is not hovering over the button */ - hoverOffColor: string; + hoverOffColor: Paint; /** * The css value of a color to be used if the pointer hovers over the button */ - hoverOnColor: string; + hoverOnColor: Paint; /** - * The css value of the background while the button is held down; the - * hover colour by default - * @default hoverOnColor + * The background while the button is held down. Left undefined, the + * button shows {@link UITextButton#hoverOnColor}, whatever it is then. */ - pressedColor: string; + pressedColor: Paint | undefined; /** * The css value of a color to be used to draw the border */ - borderStrokeColor: string; + borderStrokeColor: Paint; /** * Set the default text alignment (or justification),
@@ -246,8 +288,6 @@ export default class UITextButton extends UIBaseElement { /** @ignore */ private _skin: SkinDrawer | undefined; - /** @ignore */ - private _enabled = true; /** how far the label is lowered right now, for the pressed art */ private _labelDrop = 0; @@ -281,24 +321,26 @@ export default class UITextButton extends UIBaseElement { * * world.addChild(new PlayButton(15,200)); */ - constructor(x: number, y: number, settings: UITextButtonSettings) { + constructor(x: number, y: number, settings: UITextButtonSettings = {}) { super(x, y); + // the settings are read, never written back: one object can make + // several buttons this.bindKey = settings.bindKey ?? -1; - this.clickCallback = settings.onClick; + this.onClickCallback = settings.onClick; this.hoverOffColor = settings.hoverOffColor || "#00aa0080"; this.hoverOnColor = settings.hoverOnColor || "#00ff00ff"; - // the hover colour by default, which is what a held button showed - // before it had a colour of its own - this.pressedColor = settings.pressedColor || this.hoverOnColor; + // left undefined, a held button shows the hover colour as it did + // before it had a colour of its own, and follows it if it changes + this.pressedColor = settings.pressedColor; - this.borderStrokeColor = settings.borderStrokeColor || "#000000"; + this.borderStrokeColor = + settings.borderColor || settings.borderStrokeColor || "#000000"; - this.textAlign = settings.textAlign = settings.textAlign || "center"; - - this.textBaseline = settings.textBaseline = - settings.textBaseline || "middle"; + this.textAlign = settings.textAlign || "center"; + this.textBaseline = settings.textBaseline || "middle"; + const fillStyle = settings.labelFillStyle ?? settings.fillStyle; const font = settings.font ?? "sans-serif"; // a bitmap font as before: by name, by data given under another @@ -308,12 +350,26 @@ export default class UITextButton extends UIBaseElement { typeof font !== "string" || getImage(font) != null ) { - this.label = new BitmapText(0, 0, settings as any); + const label = new BitmapText(0, 0, { + ...settings, + ...(fillStyle !== undefined ? { fillStyle } : {}), + textAlign: this.textAlign, + textBaseline: this.textBaseline, + } as any); + if (settings.fontSize !== undefined) { + // at its authored scale, one line measures its line height + label.resize(1); + const lineHeight = label.measureText("Ag").height; + if (lineHeight > 0) { + label.resize(settings.fontSize / lineHeight); + } + } + this.label = label; } else { this.label = new Text(0, 0, { font, - size: settings.size ?? 16, - fillStyle: settings.fillStyle ?? "#000000", + size: settings.fontSize ?? settings.size ?? 16, + fillStyle: fillStyle ?? "#000000", textAlign: this.textAlign, textBaseline: this.textBaseline, text: settings.text ?? "", @@ -321,8 +377,8 @@ export default class UITextButton extends UIBaseElement { } this.skin = settings.skin; - let width = settings.borderWidth; - let height = settings.borderHeight; + let width = settings.width ?? settings.borderWidth; + let height = settings.height ?? settings.borderHeight; if (this.skin !== undefined) { this._skin = new SkinDrawer(this.skin); // every region named is checked now, so a typo fails here @@ -341,8 +397,6 @@ export default class UITextButton extends UIBaseElement { this.dimensions = this.label.measureText(); width ||= this.dimensions.width + 16; height ||= this.dimensions.height + 16; - settings.borderWidth = width; - settings.borderHeight = height; // create the round rect button this.border = new RoundRect(x, y, width, height); @@ -357,38 +411,25 @@ export default class UITextButton extends UIBaseElement { } /** - * the label, under its original name - * @deprecated since 20.13.0, use {@link UITextButton#label}, which is a - * {@link Text} when the font is not a bitmap font + * The label, under its original name and type, for code written when it + * was always a {@link BitmapText}. With a CSS font it is a {@link Text} + * all the same. + * @deprecated since 20.13.0, use {@link UITextButton#label} */ - get bitmapText(): UILabel { - return this.label; + get bitmapText(): BitmapText { + return this.label as BitmapText; } - set bitmapText(value: UILabel) { + set bitmapText(value: BitmapText) { this.label = value; } /** - * Whether the button answers input. - * - * A disabled button is drawn with its `disabled` skin region, or at half - * opacity, ignores the pointer and its key, and never calls `onClick`. - * Disabling it while it is held lets the press go without a click. - * @default true + * Call off a press, from the pointer or from the key. */ - get enabled(): boolean { - return this._enabled; - } - - set enabled(value: boolean) { - this._enabled = value; - this.isClickable = value; - if (!value) { - this.cancelPress(); - this.cancelKeyboardPress(); - } - this.isDirty = true; + override cancelPress(): void { + this.cancelKeyboardPress(); + super.cancelPress(); } /** @@ -432,8 +473,8 @@ export default class UITextButton extends UIBaseElement { * @internal */ private completeClick() { - if (this._enabled && this.clickCallback !== undefined) { - this.clickCallback(this); + if (this._enabled && this.onClickCallback !== undefined) { + this.onClickCallback(this); } } @@ -544,7 +585,7 @@ export default class UITextButton extends UIBaseElement { this.border.pos.set(this.pos.x, this.pos.y); } if (!this.released) { - r.setColor(this.pressedColor); + r.setColor(this.pressedColor ?? this.hoverOnColor); } else if (this.hover && this._enabled) { r.setColor(this.hoverOnColor); } else { diff --git a/packages/melonjs/tests/progressbar.spec.js b/packages/melonjs/tests/progressbar.spec.js index 5e2ae5da8..a3bf2afcb 100644 --- a/packages/melonjs/tests/progressbar.spec.js +++ b/packages/melonjs/tests/progressbar.spec.js @@ -157,7 +157,7 @@ describe("ProgressBar", () => { }); bar.value = 50; expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith(50, 0.25); + expect(onChange).toHaveBeenCalledWith(50, 0.25, bar); }); it("does NOT fire when the value is written but does not move", () => { @@ -729,7 +729,7 @@ describe("ProgressBar", () => { onChange, }); event.emit(event.LOADER_PROGRESS, 0.25, null); - expect(onChange).toHaveBeenCalledWith(0.25, 0.25); + expect(onChange).toHaveBeenCalledWith(0.25, 0.25, bar); bar.destroy(); }); }); diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js index 5457aa684..ab6e045f9 100644 --- a/packages/melonjs/tests/ui-widgets.spec.js +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -193,7 +193,7 @@ describe("UI widgets", () => { it("clicking the label toggles it too", () => { const cb = place( - new UICheckbox(100, 100, { label: "A long label", size: 20 }), + new UICheckbox(100, 100, { label: "A long label", boxSize: 20 }), ); expect(cb.width).toBeGreaterThan(60); click(100 + cb.width - 4, 110); @@ -401,7 +401,7 @@ describe("UI widgets", () => { it("writes its value with format", () => { const s = make({ showValue: true, - format: (v) => { + valueFormat: (v) => { return Math.round(v * 100) + "%"; }, value: 0.25, @@ -736,7 +736,7 @@ describe("UI widgets", () => { const { panel, buttons } = makeList(); const below = vi.fn(); const hidden = vi.fn(); - buttons[3].clickCallback = hidden; + buttons[3].onClickCallback = hidden; // button 3 sits at 120..160, under the panel's bottom edge at 200 // in screen terms: 100 + 120 = 220 const under = place( @@ -759,7 +759,7 @@ describe("UI widgets", () => { const { panel, buttons } = makeList(); const onClick = vi.fn(); // the third button, 80..120 in the panel: 180..220 on screen - buttons[2].clickCallback = onClick; + buttons[2].onClickCallback = onClick; const onCancel = vi.spyOn(buttons[2], "onCancel"); drag(150, 190, 150, 130); expect(panel.scrollY).toBe(60); @@ -770,7 +770,7 @@ describe("UI widgets", () => { it("a short press on a button in the list still clicks it", () => { const { buttons } = makeList(); const onClick = vi.fn(); - buttons[1].clickCallback = onClick; + buttons[1].onClickCallback = onClick; drag(150, 160, 150, 157, 1); expect(onClick).toHaveBeenCalledTimes(1); }); @@ -836,7 +836,7 @@ describe("UI widgets", () => { borderHeight: 40, }); const onClick = vi.fn(); - b.clickCallback = onClick; + b.onClickCallback = onClick; box.addChild(b); syncBroadphase(); click(150, 270); @@ -993,7 +993,8 @@ describe("UI widgets", () => { it("a held button keeps its hover colour unless given a pressed one", () => { const b = new UITextButton(0, 0, { text: "x", hoverOnColor: "#123456" }); - expect(b.pressedColor).toBe("#123456"); + // left undefined, so the held button follows hoverOnColor + expect(b.pressedColor).toBeUndefined(); const label = b.label; b.bitmapText = label; expect(b.label).toBe(label); @@ -1006,16 +1007,214 @@ describe("UI widgets", () => { expect(s.value).toBeCloseTo(0.6); }); - it("a drag ends on a mouseup, without pointer events", () => { + it("a drag ends on a release outside the canvas", () => { const onChangeEnd = vi.fn(); const s = place(new UISlider(100, 100, { onChangeEnd })); pointer("pointermove", 110, 110); pointer("pointerdown", 110, 110); pointer("pointermove", 200, 110); - window.dispatchEvent(new MouseEvent("mouseup")); + window.dispatchEvent( + new PointerEvent("pointerup", { pointerId: 1, bubbles: true }), + ); expect(s.dragging).toBe(false); expect(onChangeEnd).toHaveBeenCalledTimes(1); }); + + it("another pointer, or another finger, lifting does not end a drag", () => { + const s = place(new UISlider(100, 100, {})); + pointer("pointermove", 110, 110); + pointer("pointerdown", 110, 110); + window.dispatchEvent( + new PointerEvent("pointerup", { pointerId: 2, bubbles: true }), + ); + window.dispatchEvent(new Event("touchend")); + window.dispatchEvent(new MouseEvent("mouseup", { button: 2 })); + expect(s.dragging).toBe(true); + pointer("pointerup", 110, 110); + expect(s.dragging).toBe(false); + }); + + it("stretch resizes buttons and panels, and leaves other widgets alone", () => { + const panel = place( + new UIPanel(0, 0, { + width: 300, + height: 300, + layout: "column", + align: "stretch", + }), + ); + const cb = panel.addChild(new UICheckbox(0, 0, { label: "x" })); + const width = cb.width; + const inner = panel.addChild( + new UIPanel(0, 0, { width: 10, height: 20 }), + ); + expect(cb.width).toBe(width); + expect(inner.width).toBe(300); + }); + + it("a non-blocking scrolling panel inside another still scrolls alone", () => { + const outer = place( + new UIPanel(100, 100, { + width: 300, + height: 200, + layout: "column", + scroll: "vertical", + }), + ); + const inner = outer.addChild( + new UIPanel(0, 0, { + width: 280, + height: 100, + layout: "column", + scroll: "vertical", + blocking: false, + }), + ); + for (let i = 0; i < 6; i++) { + for (const p of [inner, outer]) { + const r = new Renderable(0, 0, 100, 40); + r.anchorPoint.set(0, 0); + p.addChild(r); + } + } + syncBroadphase(); + drag(150, 180, 150, 140); + expect(inner.scrollY).toBe(40); + expect(outer.scrollY).toBe(0); + }); + + it("a draggable panel is not moved through a widget inside a container", () => { + const panel = place( + new UIPanel(100, 100, { width: 300, height: 200, draggable: true }), + ); + const group = panel.addChild(new Container(20, 20, 200, 100)); + const onClick = vi.fn(); + group.addChild( + new UITextButton(0, 0, { + text: "x", + width: 100, + height: 40, + onClick, + }), + ); + syncBroadphase(); + drag(150, 140, 200, 190); + expect([panel.pos.x, panel.pos.y]).toEqual([100, 100]); + expect(onClick).not.toHaveBeenCalled(); + }); + }); + + describe("API", () => { + it("enabled is shared by every widget, the panel included", () => { + const panel = new UIPanel(0, 0, { width: 10, height: 10 }); + expect(panel.enabled).toBe(true); + panel.enabled = false; + expect(panel.isClickable).toBe(false); + const s = new UISlider(0, 0, {}); + s.enabled = false; + expect(s.isClickable).toBe(false); + }); + + it("a button takes width, height, fontSize and labelFillStyle", () => { + const settings = { + text: "OK", + width: 120, + height: 30, + fontSize: 20, + labelFillStyle: "#ff0000", + }; + const b = new UITextButton(0, 0, settings); + expect([b.width, b.height]).toEqual([120, 30]); + expect(b.label.fontSize ?? b.label.font).toBeDefined(); + // the settings are read, not written back + expect(Object.keys(settings)).toEqual([ + "text", + "width", + "height", + "fontSize", + "labelFillStyle", + ]); + // one object, two buttons of their own sizes + const shared = { text: "A" }; + const one = new UITextButton(0, 0, shared); + const two = new UITextButton(0, 0, { + ...shared, + text: "A much longer one", + }); + expect(two.width).toBeGreaterThan(one.width); + expect(shared).toEqual({ text: "A" }); + }); + + it("slider min, max and step can change, silently re-clamping", () => { + const onChange = vi.fn(); + const s = new UISlider(0, 0, { min: 0, max: 10, value: 8, onChange }); + expect(s.ratio).toBeCloseTo(0.8); + s.max = 5; + expect(s.value).toBe(5); + s.step = 2; + expect(s.value).toBe(4); + s.min = 4.5; + expect(s.value).toBe(4.5); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("valueFormat gets the ratio, and setting it rewrites the text", () => { + const s = new UISlider(0, 0, { + showValue: true, + min: 0, + max: 200, + value: 50, + }); + s.valueFormat = (v, ratio) => { + return `${v}/${Math.round(ratio * 100)}%`; + }; + expect(textOf(s.valueText)).toBe("50/25%"); + }); + + it("a tiny step keeps its decimals", () => { + const s = new UISlider(0, 0, { min: 0, max: 1e-6, step: 1e-7 }); + s.value = 3e-7; + expect(s.value).toBeCloseTo(3e-7, 12); + }); + + it("checkbox labels can change, and the widget refits", () => { + const cb = new UICheckbox(0, 0, { label: "x" }); + const narrow = cb.width; + cb.labelOff = "A much longer label"; + expect(cb.width).toBeGreaterThan(narrow); + expect(textOf(cb.label)).toBe("A much longer label"); + }); + + it("changing a checkbox's group moves it there at once", () => { + const a = place(new UICheckbox(0, 0, { group: "ga", checked: true })); + const b = place(new UICheckbox(0, 40, { group: "gb", checked: true })); + a.group = "gb"; + expect(UICheckbox.checkedIn("ga")).toBeUndefined(); + // a joined checked, so it is the one left checked in gb + expect(a.checked).toBe(true); + expect(b.checked).toBe(false); + }); + + it("a disabled skinned checkbox shows whether it is checked", () => { + const atlas = makeAtlas(); + const spy = vi.spyOn(SkinDrawer.prototype, "draw"); + const cb = new UICheckbox(0, 0, { + skin: { + atlas, + on: "on", + off: "off", + disabledOn: "up", + disabledOff: "down", + }, + checked: true, + enabled: false, + }); + cb.draw(app.renderer); + expect(spy.mock.calls.at(-1)[1]).toBe("up"); + cb.checked = false; + cb.draw(app.renderer); + expect(spy.mock.calls.at(-1)[1]).toBe("down"); + }); }); describe("UIPanel dragging", () => { From d4a577cc4bb962889119007a7b72826afcbb0add Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:42:39 +0000 Subject: [PATCH 08/43] Examples: UI example lint Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/examples/src/examples/ui/ExampleUI.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/examples/src/examples/ui/ExampleUI.tsx b/packages/examples/src/examples/ui/ExampleUI.tsx index be0b2236a..38c503011 100644 --- a/packages/examples/src/examples/ui/ExampleUI.tsx +++ b/packages/examples/src/examples/ui/ExampleUI.tsx @@ -99,7 +99,7 @@ const volume = ( ...FONT, label, showValue: true, - valueFormat: (v, ratio) => { + valueFormat: (_value, ratio) => { return `${Math.round(ratio * 100)}%`; }, length: 320, From 99c7c51d920cc79dd8fffcc6fd9f15da95c8273d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:57:55 +0000 Subject: [PATCH 09/43] UI: drop shadows on UIPanel and ProgressBar, used in the UI example shadowColor, shadowOffsetX, shadowOffsetY and shadowBlur. The soft edge is a few stacked translucent rounded rectangles, so it looks the same on Canvas, WebGL and WebGPU; it follows the corner radius, goes under a skin too, and is drawn outside the bounds so it never takes the pointer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- .../examples/src/examples/ui/ExampleUI.tsx | 11 +++- packages/melonjs/CHANGELOG.md | 1 + .../skills/melonjs-ui-and-text/SKILL.md | 7 ++ .../melonjs/src/renderable/ui/panelpaint.ts | 64 +++++++++++++++++++ .../melonjs/src/renderable/ui/progressbar.ts | 47 ++++++++++++++ packages/melonjs/src/renderable/ui/uipanel.ts | 50 ++++++++++++++- packages/melonjs/tests/ui-widgets.spec.js | 56 ++++++++++++++++ 7 files changed, 234 insertions(+), 2 deletions(-) diff --git a/packages/examples/src/examples/ui/ExampleUI.tsx b/packages/examples/src/examples/ui/ExampleUI.tsx index 38c503011..ff91be0d6 100644 --- a/packages/examples/src/examples/ui/ExampleUI.tsx +++ b/packages/examples/src/examples/ui/ExampleUI.tsx @@ -175,6 +175,11 @@ const framed = ( insetx: 10, insety: 10, }, + // a soft drop shadow, rounded like the art's corners + shadowColor: "#00000059", + shadowOffsetY: 6, + shadowBlur: 10, + radius: 6, draggable: true, }, }; @@ -377,7 +382,11 @@ class PlayScreen extends Stage { height: 24, value: 0.45, radius: 12, - trackColor: "#0000001a", + // with a shadow, a solid track: a see-through one shows it + shadowColor: "#00000040", + shadowOffsetY: 3, + shadowBlur: 4, + trackColor: "#dcdcdc", fillColor: "#59b25b", borderColor: "black", borderWidth: 2, diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 394434aa1..7d5b95f16 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -9,6 +9,7 @@ - UI: `UIPanel` lays its children out (`layout`: `"column"`, `"row"` or `"grid"`, with `padding`, `gap`, `align` and `autoSize`), scrolls them (`scroll`, with the wheel, dragging, momentum and a scrollbar; `scrollTo`, `scrollToChild`), and takes a nine-slice `skin`, a `title` and `draggable`; a draggable panel comes to the front when grabbed - UI: `UITextButton` takes an `onClick` setting, called on a completed click; a `skin` of atlas regions, with pressed art drawn lowered; `pressedColor`; and the names the other widgets use: `width` / `height` for its size, `fontSize` (pixels, also with a bitmap font), `labelFillStyle` and `borderColor`. `resize()` keeps its background and label in step. A font that is not a loaded bitmap font is drawn as a CSS font, where the button used to throw - UI: `enabled` on every `UIBaseElement`, to turn a widget off; `cancelPress()` and `onCancel()`, to call off a press without a release, as a scrolling `UIPanel` does when a drag on a button turns into a scroll; and `keepsDrag`, for a widget of your own that is dragged inside a scrolling panel +- UI: a drop shadow under a `UIPanel` or a `ProgressBar`: `shadowColor`, `shadowOffsetX`, `shadowOffsetY` and `shadowBlur`, softened the same on Canvas, WebGL and WebGPU, rounded with the corners, and under a skin too - UI: `ProgressBar`'s `onChange` also gets the bar - UI: the `UISkin`, `UILabel`, `Paint` and settings and skin types of every widget are exported; `Container#clipContains(x, y)` tells whether a container shows its children at a point diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index 9bd22d184..c7c478440 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -453,6 +453,13 @@ const options = new UIPanel(20, 20, { }); ``` +**A drop shadow**, here or on a `ProgressBar`, takes a colour whose alpha is +how dark it gets: `shadowColor: "#00000059"`, with `shadowOffsetX` (0), +`shadowOffsetY` (4) and `shadowBlur` (8 pixels of soft edge, `0` for sharp). +It follows `radius`, which a skinned panel can set to match its art's +corners, and it is drawn outside the bounds, so it never takes the pointer. +Under a see-through fill or track it shows through; give that a solid colour. + The title sits at the top and the content starts below it. `insetx` / `insety` are the unscaled corners of the art, and also where the frame ends: layout `padding` is measured from there. diff --git a/packages/melonjs/src/renderable/ui/panelpaint.ts b/packages/melonjs/src/renderable/ui/panelpaint.ts index a9a387430..22ac0ce44 100644 --- a/packages/melonjs/src/renderable/ui/panelpaint.ts +++ b/packages/melonjs/src/renderable/ui/panelpaint.ts @@ -211,6 +211,70 @@ export class PanelPainter { } } + /** + * Draw a drop shadow under a rectangle: offset, and softened by `blur`. + * + * Not a real blur, which primitives cannot do the same on every backend: + * a few translucent rounded rectangles, from `blur` pixels larger down to + * the rectangle itself, stacked so the middle reaches the shadow's own + * alpha and the edge fades out. It costs a handful of fills, and looks + * the same on Canvas, WebGL and WebGPU. + * @param renderer - the renderer to draw with + * @param color - the shadow's colour; its alpha is how dark it gets + * @param cascade - the alpha the owning renderable inherited + * @param x - left edge of the rectangle casting it + * @param y - top edge + * @param w - width + * @param h - height + * @param radius - the rectangle's corner radius + * @param offsetX - how far right the shadow falls + * @param offsetY - how far down the shadow falls + * @param blur - how far the soft edge spreads, in pixels; `0` is sharp + */ + shadow( + renderer: Renderer, + color: Paint, + cascade: number, + x: number, + y: number, + w: number, + h: number, + radius: number, + offsetX: number, + offsetY: number, + blur: number, + ) { + if (w <= 0 || h <= 0) { + return; + } + const spread = Math.max(0, blur); + // one layer per two pixels of spread, enough for a smooth edge + const layers = spread > 0 ? Math.min(8, Math.ceil(spread / 2) + 1) : 1; + // layers of alpha `a` stacked n deep reach 1 - (1 - a)^n; solve for + // the colour's own alpha in the middle, where they all overlap + // the colour's own alpha, a string's included: `paint` parses it and + // puts it in the global alpha, which is read straight back + this.paint(renderer, color, 1); + const target = renderer.globalAlpha(); + const each = 1 - (1 - Math.min(target, 0.999)) ** (1 / layers); + const scale = target > 0 ? each / target : 0; + for (let i = 0; i < layers; i++) { + const grow = layers > 1 ? spread * (1 - i / (layers - 1)) : 0; + this.paint(renderer, color, cascade * scale); + this.rect( + renderer, + x + offsetX - grow, + y + offsetY - grow, + w + grow * 2, + h + grow * 2, + radius + grow, + ); + } + // put the cascade back: what is drawn next, a skin above all, takes + // the global alpha as it finds it + renderer.setGlobalAlpha(cascade); + } + /** * Hand back everything borrowed. Call from the owner's `onDestroyEvent`. * diff --git a/packages/melonjs/src/renderable/ui/progressbar.ts b/packages/melonjs/src/renderable/ui/progressbar.ts index 5331c4545..85d439b5d 100644 --- a/packages/melonjs/src/renderable/ui/progressbar.ts +++ b/packages/melonjs/src/renderable/ui/progressbar.ts @@ -56,6 +56,26 @@ export interface ProgressBarSettings { padding?: number; /** corner radius; `0` is square */ radius?: number; + /** + * A drop shadow under the bar; `null`, the default, for none. Its alpha + * is how dark the shadow gets, e.g. `"#00000066"`. + */ + shadowColor?: Paint | null; + /** + * how far right the shadow falls, in pixels + * @default 0 + */ + shadowOffsetX?: number; + /** + * how far down the shadow falls, in pixels + * @default 4 + */ + shadowOffsetY?: number; + /** + * how far the shadow's soft edge spreads, in pixels; `0` is sharp + * @default 8 + */ + shadowBlur?: number; /** called whenever the value actually moves */ onChange?: (value: number, ratio: number, bar: ProgressBar) => void; /** draw a label over the bar */ @@ -156,6 +176,14 @@ export default class ProgressBar extends Renderable { padding: number; /** corner radius; `0` is square */ radius: number; + /** a drop shadow under the bar; `null` for none */ + shadowColor: Paint | null; + /** how far right the shadow falls, in pixels */ + shadowOffsetX: number; + /** how far down the shadow falls, in pixels */ + shadowOffsetY: number; + /** how far the shadow's soft edge spreads, in pixels */ + shadowBlur: number; /** called whenever the value actually moves */ onChange?: | ((value: number, ratio: number, bar: ProgressBar) => void) @@ -196,6 +224,10 @@ export default class ProgressBar extends Renderable { this.borderWidth = settings.borderWidth ?? 1; this.padding = settings.padding ?? 0; this.radius = settings.radius ?? 0; + this.shadowColor = settings.shadowColor ?? null; + this.shadowOffsetX = settings.shadowOffsetX ?? 0; + this.shadowOffsetY = settings.shadowOffsetY ?? 4; + this.shadowBlur = settings.shadowBlur ?? 8; this.onChange = settings.onChange; this.labelFormat = settings.labelFormat ?? ((_v, ratio) => `${Math.round(ratio * 100)}%`); @@ -315,6 +347,21 @@ export default class ProgressBar extends Renderable { // `setColor`, while it still holds what `preDraw` put there. const alpha = renderer.globalAlpha(); + if (this.shadowColor !== null) { + this._painter.shadow( + renderer, + this.shadowColor, + alpha, + x, + y, + w, + h, + this.radius, + this.shadowOffsetX, + this.shadowOffsetY, + this.shadowBlur, + ); + } if (this.trackColor !== null) { this._painter.paint(renderer, this.trackColor, alpha); this._painter.rect(renderer, x, y, w, h, this.radius); diff --git a/packages/melonjs/src/renderable/ui/uipanel.ts b/packages/melonjs/src/renderable/ui/uipanel.ts index 2a12db52a..c8b9e771d 100644 --- a/packages/melonjs/src/renderable/ui/uipanel.ts +++ b/packages/melonjs/src/renderable/ui/uipanel.ts @@ -78,8 +78,28 @@ export interface UIPanelSettings { borderColor?: Paint | null; /** how thick that border is */ borderWidth?: number; - /** corner radius; `0` is square */ + /** corner radius; `0` is square; the shadow follows it, under a skin too */ radius?: number; + /** + * A drop shadow under the panel, behind its skin or fill; `null`, the default, for none. Its alpha + * is how dark the shadow gets, e.g. `"#00000066"`. + */ + shadowColor?: Paint | null; + /** + * how far right the shadow falls, in pixels + * @default 0 + */ + shadowOffsetX?: number; + /** + * how far down the shadow falls, in pixels + * @default 4 + */ + shadowOffsetY?: number; + /** + * how far the shadow's soft edge spreads, in pixels; `0` is sharp + * @default 8 + */ + shadowBlur?: number; /** * How far the panel drifts up and down, in pixels. `0`, the default, * leaves it still. @@ -248,6 +268,14 @@ export default class UIPanel extends UIBaseElement { borderWidth: number; /** corner radius; `0` is square */ radius: number; + /** a drop shadow under the panel; `null` for none */ + shadowColor: Paint | null; + /** how far right the shadow falls, in pixels */ + shadowOffsetX: number; + /** how far down the shadow falls, in pixels */ + shadowOffsetY: number; + /** how far the shadow's soft edge spreads, in pixels */ + shadowBlur: number; /** whether it swallows pointer events aimed at what is behind it */ blocking: boolean; /** @@ -391,6 +419,10 @@ export default class UIPanel extends UIBaseElement { this.borderColor = settings.borderColor ?? null; this.borderWidth = settings.borderWidth ?? 1; this.radius = settings.radius ?? 0; + this.shadowColor = settings.shadowColor ?? null; + this.shadowOffsetX = settings.shadowOffsetX ?? 0; + this.shadowOffsetY = settings.shadowOffsetY ?? 4; + this.shadowBlur = settings.shadowBlur ?? 8; this.blocking = settings.blocking ?? true; this.bobAmplitude = settings.bobAmplitude ?? 0; this.bobPeriod = settings.bobPeriod ?? 2000; @@ -557,6 +589,22 @@ export default class UIPanel extends UIBaseElement { } const alpha = renderer.globalAlpha(); + // drawn outside the panel's bounds, so it never takes the pointer + if (this.shadowColor !== null) { + this._painter.shadow( + renderer, + this.shadowColor, + alpha, + this.pos.x, + this.pos.y, + this.width, + this.height, + this.radius, + this.shadowOffsetX, + this.shadowOffsetY, + this.shadowBlur, + ); + } if (this._skin !== undefined) { this._skin.draw( renderer, diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js index ab6e045f9..36ca9a9cd 100644 --- a/packages/melonjs/tests/ui-widgets.spec.js +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -5,6 +5,7 @@ import { Container, event, input, + ProgressBar, Renderable, TextureAtlas, UICheckbox, @@ -1104,6 +1105,61 @@ describe("UI widgets", () => { }); }); + describe("drop shadow", () => { + it("draws under a panel, and hands the skin the full alpha", () => { + const atlas = makeAtlas(); + const renderer = app.renderer; + const fills = []; + const rect = vi.spyOn(renderer, "fillRect").mockImplementation((...a) => { + fills.push(a); + }); + let alphaAtSkin; + vi.spyOn(SkinDrawer.prototype, "draw").mockImplementation(() => { + alphaAtSkin = renderer.globalAlpha(); + }); + const panel = new UIPanel(10, 20, { + width: 100, + height: 50, + skin: { atlas, region: "up" }, + shadowColor: "#00000080", + shadowOffsetX: 2, + shadowOffsetY: 6, + shadowBlur: 0, + }); + renderer.setGlobalAlpha(1); + panel.draw(renderer); + // one sharp layer, offset, before the skin + expect(fills[0]).toEqual([12, 26, 100, 50]); + expect(alphaAtSkin).toBe(1); + rect.mockRestore(); + }); + + it("softens its edge over several layers with a blur", () => { + const renderer = app.renderer; + const rounded = []; + vi.spyOn(renderer, "fill").mockImplementation((shape) => { + rounded.push([shape.pos.x, shape.pos.y, shape.width, shape.height]); + }); + const sharp = []; + vi.spyOn(renderer, "fillRect").mockImplementation((...a) => { + sharp.push(a); + }); + const bar = new ProgressBar(0, 0, { + width: 100, + height: 10, + trackColor: null, + shadowColor: "#000000", + shadowOffsetY: 0, + shadowBlur: 8, + }); + bar.draw(renderer); + // rounded layers from 8 pixels out, then the bar's own outline + expect(rounded[0]).toEqual([-8, -8, 116, 26]); + expect(rounded.length).toBeGreaterThan(1); + expect(sharp[0]).toEqual([0, 0, 100, 10]); + }); + }); + describe("API", () => { it("enabled is shared by every widget, the panel included", () => { const panel = new UIPanel(0, 0, { width: 10, height: 10 }); From 12bedbd0719d7fe990a36b6f93df72e7afd83662 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 06:03:20 +0000 Subject: [PATCH 10/43] Gamepad plugin: keep a touch's hover events on the controls from the game (#1805) A touch fires pointerover and pointerenter before its pointerdown, and pointerout and pointerleave after its pointerup. The plugin only kept the down, move, up and cancel from the engine, so the widget under a button got onOver as the button was pressed (the Alex 4 menu arrow jumped to the line under B). The plugin now also captures the four hover events and stops them for a touch that lands on, or lifted from, a control. A mouse hovering over the controls is left alone, as its moves are. Fixes #1805 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/gamepad-plugin/CHANGELOG.md | 5 ++ packages/gamepad-plugin/src/plugin.ts | 58 +++++++++++++++++++ .../tests/gamepad-plugin.spec.ts | 32 ++++++++++ 3 files changed, 95 insertions(+) diff --git a/packages/gamepad-plugin/CHANGELOG.md b/packages/gamepad-plugin/CHANGELOG.md index 0444aef7e..471fd4102 100644 --- a/packages/gamepad-plugin/CHANGELOG.md +++ b/packages/gamepad-plugin/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [1.0.2] - _unreleased_ + +### Fixed +- A touch on the controls no longer reaches the game as a hover: the `pointerover` / `pointerenter` before it and the `pointerout` / `pointerleave` after it are kept from the engine too, so a widget under a button no longer gets `onOver` as the button is pressed ([#1805](https://github.com/melonjs/melonJS/issues/1805)). A mouse hovering over the controls is unchanged + ## [1.0.1] - _2026-10-30_ ### Changed diff --git a/packages/gamepad-plugin/src/plugin.ts b/packages/gamepad-plugin/src/plugin.ts index 0b272f84a..a5f072c0e 100644 --- a/packages/gamepad-plugin/src/plugin.ts +++ b/packages/gamepad-plugin/src/plugin.ts @@ -92,6 +92,19 @@ const POINTER_EVENTS = [ "pointercancel", ] as const; +/** + * The hover events a touch also fires: over and enter just before its + * pointer-down, out and leave just after its pointer-up. The engine listens + * to them too, so a touch on the controls has to be kept from it here as + * well, or the widget under a button lights up as it is pressed (#1805). + */ +const HOVER_EVENTS = [ + "pointerover", + "pointerenter", + "pointerout", + "pointerleave", +] as const; + /** * On-screen touch controls, reported as a gamepad: sticks and buttons drawn * over the game, read by the engine as a pad in a gamepad slot. Everything a @@ -392,6 +405,9 @@ export class GamepadPlugin extends plugin.BasePlugin { passive: false, }); } + for (const type of HOVER_EVENTS) { + this.target.addEventListener(type, this.onHover, { capture: true }); + } // a finger held on the controls starts a selection, the iOS image // menu or the context menu, none of which a pointer event can cancel this.target.addEventListener("touchstart", this.onTouchStart, { @@ -418,6 +434,10 @@ export class GamepadPlugin extends plugin.BasePlugin { capture: true, }); } + for (const type of HOVER_EVENTS) { + this.target?.removeEventListener(type, this.onHover, { capture: true }); + } + this.touchesOnControls.clear(); this.target?.removeEventListener("touchstart", this.onTouchStart, { capture: true, }); @@ -479,6 +499,7 @@ export class GamepadPlugin extends plugin.BasePlugin { return; } this.pointers.set(e.pointerId, control); + this.touchesOnControls.add(e.pointerId); if (control instanceof VirtualStick) { control.grab(e.pointerId, x, y); } else { @@ -531,6 +552,43 @@ export class GamepadPlugin extends plugin.BasePlugin { this.app.repaint(); }; + /** + * The pointers whose touch landed on the controls, kept past their + * release for the out and leave that follow it. + */ + private readonly touchesOnControls = new Set(); + + /** + * Keep a touch's hover events from the game when the touch is the + * controls': over and enter when it lands on one, out and leave once it + * lifts from one. A mouse hovering over the controls is left alone, as + * its moves are. + * @param e - the hover event + */ + private readonly onHover = (e: PointerEvent): void => { + if (!this.ready || e.pointerType === "mouse") { + return; + } + if (e.type === "pointerover" || e.type === "pointerenter") { + if (!this.visible) { + return; + } + const { x, y } = this.toViewport(e); + if (this.touchesOnControls.has(e.pointerId) || this.onControls(x, y)) { + this.touchesOnControls.add(e.pointerId); + e.stopPropagation(); + } + return; + } + // pointerout, pointerleave + if (this.touchesOnControls.has(e.pointerId)) { + e.stopPropagation(); + if (e.type === "pointerleave") { + this.touchesOnControls.delete(e.pointerId); + } + } + }; + /** * keep an event on the controls from the engine and the page * @param e - the pointer event diff --git a/packages/gamepad-plugin/tests/gamepad-plugin.spec.ts b/packages/gamepad-plugin/tests/gamepad-plugin.spec.ts index d345b19a5..396b3946d 100644 --- a/packages/gamepad-plugin/tests/gamepad-plugin.spec.ts +++ b/packages/gamepad-plugin/tests/gamepad-plugin.spec.ts @@ -422,6 +422,38 @@ describe("GamepadPlugin", () => { input.releasePointerEvent("pointerdown", app.viewport, spy); }); + it("keeps a touch's hover from the game too (#1805)", () => { + const enter = vi.fn(); + const leave = vi.fn(); + input.registerPointerEvent("pointerenter", app.viewport, enter); + input.registerPointerEvent("pointerleave", app.viewport, leave); + + // a tap on a button: over and enter before the down, out and leave + // after the up, as a touch fires them + const at = button.center; + const over = pointer("pointerover", 1, at.x, at.y); + pointer("pointerenter", 1, at.x, at.y); + down(1, at); + up(1, at); + pointer("pointerout", 1, at.x, at.y); + pointer("pointerleave", 1, at.x, at.y); + expect(enter).not.toHaveBeenCalled(); + expect(leave).not.toHaveBeenCalled(); + expect(over.defaultPrevented).toBe(false); + + // a tap on the game is the game's + const game = { x: app.viewport.width / 2, y: 20 }; + pointer("pointerenter", 2, game.x, game.y); + expect(enter).toHaveBeenCalledOnce(); + + // and a mouse over a button is left alone, as its moves are + pointer("pointerenter", 3, at.x, at.y, "mouse"); + expect(enter).toHaveBeenCalledTimes(2); + + input.releasePointerEvent("pointerenter", app.viewport, enter); + input.releasePointerEvent("pointerleave", app.viewport, leave); + }); + it("fires a locked key once per tap, however long it is held", () => { input.bindKey(KEY.X, "jump", true); down(1, button.center); From 59f6398c39cfb9f9d7e26de054185b2e80a7df66 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 06:16:00 +0000 Subject: [PATCH 11/43] UI: keyboard and gamepad focus navigation, and a hover/press bounce UIFocus (off until UIFocus.enable()): one widget has the focus; the arrows, the D-pad or the left stick move it to the nearest widget that way, edge to edge; Enter, Space or A press it as a click does; left/right step a slider; Escape or B cancel. The pad is read directly (input.readGamepads, real or virtual), so the game's pad bindings are untouched. The ring shows after keyboard or pad input and hides when the mouse moves; the first key after the mouse only shows it. A UIPanel with trapFocus keeps the focus in and restores it after, with onBack on cancel. Focused widgets in a scrolling panel scroll into view, with room for the ring. Any UIBaseElement can take part: focusable, focusNeighbors, onNavigate, onFocus/onBlur, focusPress/focusRelease. Buttons, checkboxes and sliders do. hoverScale and pressScale on UIBaseElement, and as settings on every widget: an eased bounce applied to the drawing only, so hit areas and layouts stay put. The UI example turns on both, with a hint line for the controls. Skill sections and changelog entries; 13 new tests (87 in ui-widgets). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- .../examples/src/examples/ui/ExampleUI.tsx | 23 + packages/melonjs/CHANGELOG.md | 2 + .../skills/melonjs-ui-and-text/SKILL.md | 47 ++ packages/melonjs/src/index.ts | 4 + packages/melonjs/src/input/gamepad.ts | 23 + .../src/renderable/ui/uibaseelement.ts | 195 ++++++ .../melonjs/src/renderable/ui/uicheckbox.ts | 39 +- packages/melonjs/src/renderable/ui/uifocus.ts | 613 ++++++++++++++++++ packages/melonjs/src/renderable/ui/uipanel.ts | 63 +- .../melonjs/src/renderable/ui/uislider.ts | 34 +- .../melonjs/src/renderable/ui/uitextbutton.ts | 17 +- packages/melonjs/tests/ui-widgets.spec.js | 292 ++++++++- 12 files changed, 1334 insertions(+), 18 deletions(-) create mode 100644 packages/melonjs/src/renderable/ui/uifocus.ts diff --git a/packages/examples/src/examples/ui/ExampleUI.tsx b/packages/examples/src/examples/ui/ExampleUI.tsx index ff91be0d6..9fde10821 100644 --- a/packages/examples/src/examples/ui/ExampleUI.tsx +++ b/packages/examples/src/examples/ui/ExampleUI.tsx @@ -16,6 +16,7 @@ import { Text, TextureAtlas, UICheckbox, + UIFocus, UIPanel, UISlider, UITextButton, @@ -72,6 +73,9 @@ const button = ( fontSize: 12, text, width, + // a little bounce under the pointer or the focus, and when pressed + hoverScale: 1.04, + pressScale: 0.96, skin: { atlas: texture, normal: `${color}_button04`, @@ -134,6 +138,7 @@ const checkbox = ( labelOn, labelOff, checked, + hoverScale: 1.04, skin: { atlas: texture, on: "green_boxCheckmark", @@ -193,6 +198,21 @@ class PlayScreen extends Stage { this.addOptions(app, background); this.addBars(app); this.addLevels(app); + + // the arrow keys or a pad's D-pad move between the widgets, Enter or + // the pad's A button acts on the one with the focus + UIFocus.enable(); + app.world.addChild( + new Text(20, 575, { + font: "kenpixel", + size: 13, + fillStyle: "black", + textAlign: "left", + textBaseline: "bottom", + text: "Keyboard: arrows and Enter Gamepad: D-pad and A", + }), + 1, + ); } /** @@ -307,6 +327,8 @@ class PlayScreen extends Stage { const pick = new UITextButton(0, 0, { ...FONT, fontSize: 16, + hoverScale: 1.12, + pressScale: 0.9, text: String(level), skin: { atlas: texture, @@ -479,6 +501,7 @@ class PlayScreen extends Stage { */ override onDestroyEvent() { audio.stopTrack(); + UIFocus.disable(); } } diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 7d5b95f16..e54c7eb08 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -10,6 +10,8 @@ - UI: `UITextButton` takes an `onClick` setting, called on a completed click; a `skin` of atlas regions, with pressed art drawn lowered; `pressedColor`; and the names the other widgets use: `width` / `height` for its size, `fontSize` (pixels, also with a bitmap font), `labelFillStyle` and `borderColor`. `resize()` keeps its background and label in step. A font that is not a loaded bitmap font is drawn as a CSS font, where the button used to throw - UI: `enabled` on every `UIBaseElement`, to turn a widget off; `cancelPress()` and `onCancel()`, to call off a press without a release, as a scrolling `UIPanel` does when a drag on a button turns into a scroll; and `keepsDrag`, for a widget of your own that is dragged inside a scrolling panel - UI: a drop shadow under a `UIPanel` or a `ProgressBar`: `shadowColor`, `shadowOffsetX`, `shadowOffsetY` and `shadowBlur`, softened the same on Canvas, WebGL and WebGPU, rounded with the corners, and under a skin too +- UI: keyboard and gamepad navigation, `UIFocus` (off until `UIFocus.enable()`): the arrows or the D-pad move the focus to the nearest widget, Enter or A clicks a button or toggles a checkbox, left and right step a slider, Escape or B cancels. A focus ring shows once the keyboard or a pad is used; a `UIPanel` with `trapFocus` keeps the focus inside while it is up and runs `onBack` on cancel; a focused widget in a scrolling panel is scrolled into view. Any `UIBaseElement` can take part through `focusable`, `focusNeighbors`, `onNavigate`, `onFocus` and `onBlur` +- UI: `hoverScale` and `pressScale` on every UI element: a bounce under the pointer or the focus, and while held, easing by itself and scaling only the drawing, not the hit area or the layout - UI: `ProgressBar`'s `onChange` also gets the bar - UI: the `UISkin`, `UILabel`, `Paint` and settings and skin types of every widget are exported; `Container#clipContains(x, y)` tells whether a container shows its children at a point diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index c7c478440..1dec3e7dd 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -628,6 +628,53 @@ every UI element, turns a widget off: `enabled = false` draws the `disabled` region, or the button at half opacity, and ignores the pointer and the key. +### A little bounce: `hoverScale` and `pressScale` + +Every UI element, buttons, checkboxes, sliders, panels and your own, can +grow a little under the pointer or with the focus, and shrink while held: + +```js +new UITextButton(0, 0, { text: "Play", hoverScale: 1.05, pressScale: 0.92 }); +levelButton.hoverScale = 1.12; // or set it later +``` + +Both default to 1, which is still. The scale eases there and back by itself. +Only the drawing scales, about the centre: the hit area and the layout stay +put, so nothing jitters and the widgets around it do not move. + +### Keyboard and gamepad navigation: `UIFocus` + +```js +UIFocus.enable(); // in the stage's onResetEvent +// ...and UIFocus.disable() in its onDestroyEvent +``` + +The arrow keys or a pad's D-pad and left stick move the focus to the nearest +widget that way. Enter, Space or the pad's A press the focused widget as a +click does: a button calls its `onClick` setting, a checkbox toggles. Left +and right step a slider (up and down on a vertical one). Escape or the pad's +B cancels. Nothing is set up per screen. + +- **Off until enabled**, so a game steering with the arrows is not surprised. + `UIFocus.keys` changes the keys. The pad is read directly, so the game's + own pad bindings stay as they are; `UIFocus.gamepad = false` turns it off. +- **Which widgets:** anything with `focusable` set, as buttons, checkboxes + and sliders are, that is enabled and in the scene. A wrong guess for a + direction is fixed with `focusNeighbors: { down: other }`. +- **A dialog:** a `UIPanel` with `trapFocus: true` keeps the focus inside + itself while it is up, and gives it back when it goes. Its `onBack(panel)` + runs on cancel. +- **The ring** shows only once the keyboard or a pad is used, and the mouse + moving hides it again. `UIFocus.ringColor`, `ringWidth`, `ringPadding` and + `ringRadius` style it. Hovering moves the focus too, so the keyboard picks + up where the mouse was. +- **Scrolling lists** scroll the focused widget into view. +- **A widget of your own** sets `focusable = true`, overrides + `onNavigate(action)` (return `true` for what it handles) and + `focusRelease()` for accept. `onFocus()` and `onBlur()` tell it when it + gains and loses the focus. `UIFocus.focus(widget)` picks where to start, + and `UIFocus.navigate("down")` drives it from input of your own. + ### Keyboard shortcuts on a button: `bindKey` A `UITextButton` can be activated from the keyboard as well as the pointer: diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index 073b5551b..46f8a5477 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -56,6 +56,7 @@ import Trigger from "./renderable/trigger.js"; import ProgressBar from "./renderable/ui/progressbar.ts"; import UIBaseElement from "./renderable/ui/uibaseelement.ts"; import UICheckbox from "./renderable/ui/uicheckbox.ts"; +import UIFocus from "./renderable/ui/uifocus.ts"; import UIPanel from "./renderable/ui/uipanel.ts"; import UISlider from "./renderable/ui/uislider.ts"; import UISpriteElement from "./renderable/ui/uispriteelement.ts"; @@ -203,10 +204,12 @@ export type { ProgressBarDirection, ProgressBarSettings, } from "./renderable/ui/progressbar.js"; +export type { UIJuiceSettings } from "./renderable/ui/uibaseelement.js"; export type { UICheckboxSettings, UICheckboxSkin, } from "./renderable/ui/uicheckbox.js"; +export type { UINavAction } from "./renderable/ui/uifocus.js"; export type { UILabel } from "./renderable/ui/uilabel.js"; export type { UIPanelAlign, @@ -335,6 +338,7 @@ export { timer, UIBaseElement, UICheckbox, + UIFocus, UIPanel, UISlider, UISpriteElement, diff --git a/packages/melonjs/src/input/gamepad.ts b/packages/melonjs/src/input/gamepad.ts index 9fab17d4a..121ffff17 100644 --- a/packages/melonjs/src/input/gamepad.ts +++ b/packages/melonjs/src/input/gamepad.ts @@ -295,6 +295,29 @@ function browserGamepads(): readonly (Gamepad | null)[] { return hasGamepadApi() ? globalThis.navigator.getGamepads() : []; } +/** + * Every pad slot, a real pad where there is one and the virtual one put in + * that slot otherwise, as `updateGamepads` reads them. For a reader that + * wants the raw button and axis state, such as the UI focus. + * @returns the pads, by slot + * @ignore + * @internal + */ +export function readGamepads(): readonly ( + | Pick + | VirtualGamepad + | null +)[] { + const real = browserGamepads(); + const count = Math.max(real.length, virtualGamepads.length); + const slots: (Pick | VirtualGamepad | null)[] = + []; + for (let i = 0; i < count; i++) { + slots.push(real[i] ?? virtualGamepads[i] ?? null); + } + return slots; +} + /** * Whether the browser has the Gamepad API * @ignore diff --git a/packages/melonjs/src/renderable/ui/uibaseelement.ts b/packages/melonjs/src/renderable/ui/uibaseelement.ts index 662c5abdc..fbf33fad6 100644 --- a/packages/melonjs/src/renderable/ui/uibaseelement.ts +++ b/packages/melonjs/src/renderable/ui/uibaseelement.ts @@ -8,7 +8,32 @@ import { vector2dPool } from "../../math/vector2d.ts"; import * as device from "../../system/device.ts"; import { off, on, POINTERMOVE } from "../../system/event.ts"; import timer from "../../system/timer.ts"; +import type Renderer from "../../video/renderer.js"; import Container from "../container.js"; +import { PanelPainter } from "./panelpaint.ts"; +import type { UINavAction } from "./uifocus.ts"; +import UIFocus from "./uifocus.ts"; + +/** draws the focus ring of whichever element has the focus */ +const ringPainter = new PanelPainter(); + +/** + * The bounce settings every UI widget takes: see + * {@link UIBaseElement#hoverScale}. + * @category UI + */ +export interface UIJuiceSettings { + /** + * the scale the widget grows to under the pointer or with the focus; + * `1`, the default, is still + */ + hoverScale?: number; + /** the scale it shrinks to while held down; `1`, the default, is still */ + pressScale?: number; +} + +/** how fast the bounce scale closes on its target, per millisecond */ +const JUICE_RATE = 1 / 60; /** * Whether a pointer event is the pointer coming up, rather than leaving or @@ -247,6 +272,168 @@ export default class UIBaseElement extends Container { /** @ignore */ protected _enabled = true; + /** + * The scale the widget is drawn at under the pointer, or with the focus: + * a little bounce, such as `1.05`. `1` leaves it still. + * + * Only the drawing scales, about the widget's centre: its hit area and + * its place in a layout stay as they are, so nothing jitters or moves + * the widgets around it. The scale eases there and back by itself. + * @default 1 + */ + hoverScale = 1; + + /** + * The scale the widget is drawn at while held down, such as `0.92`. + * `1` leaves it still. See {@link UIBaseElement#hoverScale}. + * @default 1 + */ + pressScale = 1; + + /** the scale drawn now, easing toward the one wanted */ + private _juice = 1; + + /** + * Whether keyboard and gamepad navigation can stop on this element: see + * {@link UIFocus}. Buttons, checkboxes and sliders set it. + * @default false + */ + focusable = false; + + /** + * Where the focus goes from here, for a direction the nearest widget is + * not the right answer to. A neighbour that cannot take the focus now is + * skipped for the search. + */ + focusNeighbors: + | Partial> + | undefined; + + /** whether this element has the focus */ + get focused(): boolean { + return UIFocus.focused === this; + } + + /** + * Give this element the focus. See {@link UIFocus.focus}. + */ + focus(): void { + UIFocus.focus(this); + } + + /** + * Called when this element gets the focus. To be extended. + */ + onFocus(): void { + // to be extended + } + + /** + * Called when this element loses the focus. To be extended. + */ + onBlur(): void { + // to be extended + } + + /** + * Answer a navigation action while this element, or one inside it, has + * the focus: return `true` to say it was handled. + * + * A slider handles left and right by stepping, so the focus stays on it; + * a panel can handle `"cancel"` to close. Unhandled, a direction moves + * the focus to the nearest widget that way. Accept is not passed here: + * it presses and releases the widget, as a click does, through + * {@link UIBaseElement#focusPress} and {@link UIBaseElement#focusRelease}. + * @param _action - what the player asked for + * @returns true when handled + */ + onNavigate(_action: UINavAction): boolean { + return false; + } + + /** + * Accept was pressed with the focus here: the widget goes down, as under + * a pointer, and `onClick` runs with no event. + */ + focusPress(): void { + if (this._enabled && this.released) { + this.released = false; + this.isDirty = true; + this.onClick(); + } + } + + /** + * Accept was let go: the widget comes back up and `onRelease` runs. A + * widget completes its action here, as on a click: a button calls its + * `onClick` setting, a checkbox toggles. + */ + focusRelease(): void { + if (!this.released) { + this.released = true; + this.isDirty = true; + this.onRelease(); + } + } + + /** + * Ease the bounce. + * @param dt - time since the last frame, in milliseconds + * @returns whether it needs redrawing + */ + override update(dt: number): boolean { + const dirty = super.update(dt); + let target = 1; + if (!this.released && this.pressScale !== 1) { + target = this.pressScale; + } else if ((this.hover || this.focused) && this._enabled) { + target = this.hoverScale; + } + if (this._juice === target) { + return dirty; + } + const step = 1 - Math.exp(-dt * JUICE_RATE * 12); + this._juice += (target - this._juice) * step; + if (Math.abs(target - this._juice) < 0.001) { + this._juice = target; + } + return true; + } + + /** + * Apply the bounce, and draw the focus ring, behind the widget. + * + * Here and not in `draw`: a container translates to its own position in + * `draw`, so from `preDraw` on, the widget and the ring share the + * parent's coordinates, whatever the widget does in its own `draw`. + * @param renderer - the renderer to draw with + */ + override preDraw(renderer: Renderer): void { + super.preDraw(renderer); + if (this._juice !== 1) { + const cx = this.pos.x + this.width / 2; + const cy = this.pos.y + this.height / 2; + renderer.translate(cx, cy); + renderer.scale(this._juice, this._juice); + renderer.translate(-cx, -cy); + } + if (UIFocus.ringVisible && UIFocus.focused === this) { + const pad = UIFocus.ringPadding + UIFocus.ringWidth; + const alpha = renderer.globalAlpha(); + ringPainter.paint(renderer, UIFocus.ringColor, alpha); + ringPainter.border( + renderer, + this.pos.x - pad, + this.pos.y - pad, + this.width + pad * 2, + this.height + pad * 2, + UIFocus.ringRadius, + UIFocus.ringWidth, + ); + renderer.setGlobalAlpha(alpha); + } + } + /** * Whether the widget answers input: the switch to turn a widget off. * @@ -347,6 +534,9 @@ export default class UIBaseElement extends Container { */ enter(event: Pointer): boolean | void { this.hover = true; + if (this.focusable) { + UIFocus._hovered(this); + } this.isDirty = true; if (this.isDraggable) { this.listenForDrag(); @@ -501,6 +691,10 @@ export default class UIBaseElement extends Container { this.leave(e); }); + if (this.focusable) { + UIFocus._add(this); + } + // call the parent function super.onActivateEvent(); } @@ -525,6 +719,7 @@ export default class UIBaseElement extends Container { // the object is being remove from his parent // container before the leave function is called this.stopListeningForDrag(); + UIFocus._remove(this); // call the parent function super.onDeactivateEvent(); diff --git a/packages/melonjs/src/renderable/ui/uicheckbox.ts b/packages/melonjs/src/renderable/ui/uicheckbox.ts index 54f072543..9c32afb95 100644 --- a/packages/melonjs/src/renderable/ui/uicheckbox.ts +++ b/packages/melonjs/src/renderable/ui/uicheckbox.ts @@ -3,6 +3,7 @@ import type Pointer from "../../input/pointer.ts"; import type Renderer from "../../video/renderer.js"; import type { Paint } from "./panelpaint.ts"; import { PanelPainter } from "./panelpaint.ts"; +import type { UIJuiceSettings } from "./uibaseelement.ts"; import UIBaseElement, { isPointerUp } from "./uibaseelement.ts"; import type { UILabel } from "./uilabel.ts"; import { createLabel } from "./uilabel.ts"; @@ -32,7 +33,7 @@ export interface UICheckboxSkin extends UISkin { * Everything {@link UICheckbox} takes. * @category UI */ -export interface UICheckboxSettings { +export interface UICheckboxSettings extends UIJuiceSettings { /** * whether it starts checked * @default false @@ -289,6 +290,9 @@ export default class UICheckbox extends UIBaseElement { }; this.fitLabel(); + this.focusable = true; + this.hoverScale = settings.hoverScale ?? 1; + this.pressScale = settings.pressScale ?? 1; this.enabled = settings.enabled ?? true; } @@ -434,16 +438,37 @@ export default class UICheckbox extends UIBaseElement { override release(event: Pointer): boolean | void { const completed = !this.released && isPointerUp(event); const result = super.release(event); - if (completed && this._enabled) { - // a radio button that is already checked stays checked - if (!(this._checked && this._joined !== undefined)) { - this.checked = !this._checked; - this.onChange?.(this._checked, this); - } + if (completed) { + this.userToggle(); } return result; } + /** + * Accept let go with the focus here: toggle, as a click does. + */ + override focusRelease(): void { + const pressed = !this.released; + super.focusRelease(); + if (pressed) { + this.userToggle(); + } + } + + /** + * Toggle as the user, telling `onChange`; a checked radio button stays + * checked. + * @ignore + * @internal + */ + private userToggle() { + if (!this._enabled || (this._checked && this._joined !== undefined)) { + return; + } + this.checked = !this._checked; + this.onChange?.(this._checked, this); + } + /** * Join its group. * @ignore diff --git a/packages/melonjs/src/renderable/ui/uifocus.ts b/packages/melonjs/src/renderable/ui/uifocus.ts new file mode 100644 index 000000000..748145ebe --- /dev/null +++ b/packages/melonjs/src/renderable/ui/uifocus.ts @@ -0,0 +1,613 @@ +import { GAMEPAD, readGamepads } from "../../input/gamepad.ts"; +import { KEY } from "../../input/key.ts"; +import { + GAME_BEFORE_UPDATE, + KEYDOWN, + KEYUP, + off, + on, + POINTERMOVE, +} from "../../system/event.ts"; +import type Renderable from "../renderable.js"; +import type UIBaseElement from "./uibaseelement.ts"; + +/** + * What the player asks of the focused widget: move the focus, or act on it. + * @category UI + */ +export type UINavAction = + | "up" + | "down" + | "left" + | "right" + | "accept" + | "cancel"; + +/** an element that keeps focus inside itself while it is up */ +interface FocusScope { + ancestor?: unknown; +} + +/** the directions, for the spatial search */ +const DIRECTIONS: Record<"up" | "down" | "left" | "right", [number, number]> = { + up: [0, -1], + down: [0, 1], + left: [-1, 0], + right: [1, 0], +}; + +/** + * Keyboard and gamepad navigation for UI elements: one widget at a time has + * the focus, the arrows or the D-pad move it to the nearest widget that way, + * and Enter or the pad's A button acts on it, as a click would. + * + * Off until {@link UIFocus.enable} is called, so a game that steers with the + * arrow keys does not find them moving a focus it never asked for. + * + * Any {@link UIBaseElement} can take part: it sets `focusable`, and answers + * the player through {@link UIBaseElement#onNavigate}. Buttons, checkboxes + * and sliders already do: a button clicks, a checkbox toggles, a slider + * steps on left and right. Nothing needs setting up per screen: + * + * - the next widget in a direction is found from where the widgets are, + * and one that should go somewhere else names it in `focusNeighbors`; + * - a {@link UIPanel} with `trapFocus` keeps the focus inside itself while + * it is up, as a dialog should, and gives it back when it goes; + * - a focused widget inside a scrolling panel is scrolled into view; + * - the focus ring only shows once the keyboard or a pad is used, and the + * mouse moving hides it again, as in a web page. + * @category UI + * @example + * UIFocus.enable(); + * // start on the first button, rather than on the first arrow press + * UIFocus.focus(playButton); + */ +// one focus per page: a class of statics, documented as one API like `state` +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export default class UIFocus { + /** + * The keys for each action, by `input.KEY` code. Change them before or + * after {@link UIFocus.enable}. + */ + static keys: Record = { + up: [KEY.UP], + down: [KEY.DOWN], + left: [KEY.LEFT], + right: [KEY.RIGHT], + accept: [KEY.ENTER, KEY.SPACE], + cancel: [KEY.ESC], + }; + + /** + * Whether a gamepad navigates too: its D-pad or left stick moves the + * focus, its A (FACE_1) button accepts and its B (FACE_2) button cancels. + * Read straight from the pad, so the game's own pad bindings are left as + * they are. + * @default true + */ + static gamepad = true; + + /** the focus ring's colour */ + static ringColor = "#ffcc33"; + /** the focus ring's thickness, in pixels */ + static ringWidth = 3; + /** how far outside the widget the ring is drawn, in pixels */ + static ringPadding = 3; + /** the focus ring's corner radius */ + static ringRadius = 6; + + /** whether navigation is on */ + private static _enabled = false; + /** the focusable elements in the scene */ + private static readonly _elements = new Set(); + /** the panels keeping the focus in, the innermost last */ + private static readonly _scopes: { + scope: FocusScope; + restore: UIBaseElement | undefined; + }[] = []; + /** the focused element */ + private static _focused: UIBaseElement | undefined; + /** whether the ring shows: after keyboard or pad input, until the mouse */ + private static _ringVisible = false; + /** whether accept is held, and by which input */ + private static _acceptHeld: string | undefined; + /** the pad's directions and buttons as last read, and the repeat clock */ + private static readonly _pad = { + held: "" as string, + next: 0, + accept: false, + cancel: false, + }; + + /** + * Turn keyboard and gamepad navigation on. + */ + static enable(): void { + if (UIFocus._enabled) { + return; + } + UIFocus._enabled = true; + on(KEYDOWN, UIFocus._onKeyDown); + on(KEYUP, UIFocus._onKeyUp); + on(POINTERMOVE, UIFocus._onPointer); + on(GAME_BEFORE_UPDATE, UIFocus._onFrame); + } + + /** + * Turn it off again, dropping the focus. + */ + static disable(): void { + if (!UIFocus._enabled) { + return; + } + UIFocus._enabled = false; + off(KEYDOWN, UIFocus._onKeyDown); + off(KEYUP, UIFocus._onKeyUp); + off(POINTERMOVE, UIFocus._onPointer); + off(GAME_BEFORE_UPDATE, UIFocus._onFrame); + UIFocus.focus(undefined); + } + + /** whether navigation is on */ + static get enabled(): boolean { + return UIFocus._enabled; + } + + /** the element with the focus, if any */ + static get focused(): UIBaseElement | undefined { + return UIFocus._focused; + } + + /** whether the focus ring is showing */ + static get ringVisible(): boolean { + return UIFocus._ringVisible; + } + + /** + * Give an element the focus, or take it away with `undefined`. + * + * The element is told through `onFocus`, the one before it through + * `onBlur`, and a scrolling panel holding it scrolls it into view. + * @param element - the element to focus + */ + static focus(element: UIBaseElement | undefined): void { + const previous = UIFocus._focused; + if (previous === element) { + return; + } + UIFocus._focused = element; + if (previous !== undefined) { + previous.isDirty = true; + previous.onBlur(); + } + if (element !== undefined) { + element.isDirty = true; + element.onFocus(); + UIFocus.scrollIntoView(element); + } + } + + /** + * Act as if the player asked for something: move the focus, accept or + * cancel. For input of the game's own, such as a touch D-pad of its own + * or a remote. + * @param action - what to do + */ + static navigate(action: UINavAction): void { + const wasVisible = UIFocus._ringVisible; + UIFocus._ringVisible = true; + let focused = UIFocus._focused; + if (focused !== undefined && !UIFocus.isCandidate(focused)) { + UIFocus.focus(undefined); + focused = undefined; + } + if (focused === undefined) { + if (action !== "cancel" && action !== "accept") { + UIFocus.focus(UIFocus.first()); + } + return; + } + // the first key after the mouse shows where the focus is, rather + // than moving it from a place the player could not see + if (!wasVisible && action in DIRECTIONS) { + return; + } + // the focused widget first, then the ones holding it, for a slider + // stepping, or a panel answering cancel + for ( + let node: Renderable | undefined = focused; + node !== undefined; + node = node.ancestor as Renderable | undefined + ) { + const element = node as unknown as Partial; + if ( + typeof element.onNavigate === "function" && + element.onNavigate(action) + ) { + return; + } + } + if (action in DIRECTIONS) { + const next = UIFocus.next(focused, action as keyof typeof DIRECTIONS); + if (next !== undefined) { + UIFocus.focus(next); + } + } + } + + /** + * Track an element joining the scene. + * @param element - the element + * @ignore + * @internal + */ + static _add(element: UIBaseElement): void { + UIFocus._elements.add(element); + } + + /** + * Track an element leaving the scene; it loses the focus. + * @param element - the element + * @ignore + * @internal + */ + static _remove(element: UIBaseElement): void { + UIFocus._elements.delete(element); + if (UIFocus._focused === element) { + UIFocus.focus(undefined); + } + } + + /** + * A panel that keeps the focus in comes up: the focus moves into it. + * @param scope - the panel + * @ignore + * @internal + */ + static _pushScope(scope: FocusScope): void { + UIFocus._scopes.push({ scope, restore: UIFocus._focused }); + if (UIFocus._focused !== undefined && !UIFocus.inScope(UIFocus._focused)) { + UIFocus.focus(UIFocus.first()); + } + } + + /** + * That panel goes: the focus goes back where it was. + * @param scope - the panel + * @ignore + * @internal + */ + static _popScope(scope: FocusScope): void { + const index = UIFocus._scopes.findIndex((entry) => { + return entry.scope === scope; + }); + if (index === -1) { + return; + } + const [entry] = UIFocus._scopes.splice(index, 1); + if ( + UIFocus._focused === undefined || + UIFocus.isInside(UIFocus._focused, scope) + ) { + const restore = entry.restore; + UIFocus.focus( + restore !== undefined && UIFocus.isCandidate(restore) + ? restore + : undefined, + ); + } + } + + /** + * Hover moves the focus along, so the keyboard picks up where the mouse + * was. The ring stays hidden. + * @param element - the element under the pointer + * @ignore + * @internal + */ + static _hovered(element: UIBaseElement): void { + if ( + UIFocus._enabled && + UIFocus._focused !== undefined && + UIFocus.isCandidate(element) + ) { + UIFocus.focus(element); + } + } + + /** + * whether an element can take the focus now + * @param element - the element + * @returns true when it is focusable, enabled, shown and in scope + */ + private static isCandidate(element: UIBaseElement): boolean { + return ( + element.focusable && + element.enabled && + element.isRenderable && + UIFocus._elements.has(element) && + UIFocus.inScope(element) + ); + } + + /** + * whether an element is inside the panel keeping the focus, if any + * @param element - the element + * @returns true when no panel keeps the focus, or it is inside that one + */ + private static inScope(element: UIBaseElement): boolean { + const top = UIFocus._scopes[UIFocus._scopes.length - 1]; + return top === undefined || UIFocus.isInside(element, top.scope); + } + + /** + * whether an element is held, at any depth, by another + * @param element - the element + * @param scope - the one that may hold it + * @returns true when `scope` is one of its ancestors + */ + private static isInside(element: Renderable, scope: FocusScope): boolean { + for ( + let node = element.ancestor as FocusScope | undefined; + node != null; + node = node.ancestor as FocusScope | undefined + ) { + if (node === scope) { + return true; + } + } + return false; + } + + /** + * the elements that can take the focus now + * @returns them, in no particular order + */ + private static candidates(): UIBaseElement[] { + const list: UIBaseElement[] = []; + for (const element of UIFocus._elements) { + if (UIFocus.isCandidate(element)) { + list.push(element); + } + } + return list; + } + + /** + * Where the focus starts: the first in reading order, row by row, left + * to right. Two widgets whose tops are within half a height of each + * other are on the same row, so a widget a few pixels higher on the + * right does not come before the one on the left. + * @returns the element, or undefined when there is none + */ + private static first(): UIBaseElement | undefined { + let first: UIBaseElement | undefined; + for (const candidate of UIFocus.candidates()) { + if (first === undefined) { + first = candidate; + continue; + } + const a = candidate.getBounds(); + const b = first.getBounds(); + const sameRow = + Math.abs(a.top - b.top) < Math.min(a.height, b.height) / 2; + if (sameRow ? a.left < b.left : a.top < b.top) { + first = candidate; + } + } + return first; + } + + /** + * The nearest candidate in a direction: a named neighbour if it can + * take the focus, else the closest one that way, edge to edge, a step + * sideways counting double. + * @param from - the focused element + * @param direction - which way + * @returns the next element, or undefined when there is none that way + */ + private static next( + from: UIBaseElement, + direction: keyof typeof DIRECTIONS, + ): UIBaseElement | undefined { + const named = from.focusNeighbors?.[direction]; + if (named !== undefined && UIFocus.isCandidate(named)) { + return named; + } + const [dx, dy] = DIRECTIONS[direction]; + const origin = from.getBounds(); + let best: UIBaseElement | undefined; + let bestScore = Infinity; + for (const candidate of UIFocus.candidates()) { + if (candidate === from) { + continue; + } + const bounds = candidate.getBounds(); + // past the focused one, centre to centre, in that direction + const ahead = + (bounds.centerX - origin.centerX) * dx + + (bounds.centerY - origin.centerY) * dy; + if (ahead <= 1) { + continue; + } + // edge to edge: the gap ahead, and how far off to the side, 0 + // when the two overlap that way, so a narrow widget right below + // a wide one beats a wide one further down + const gap = + dx !== 0 + ? Math.max( + 0, + dx > 0 ? bounds.left - origin.right : origin.left - bounds.right, + ) + : Math.max( + 0, + dy > 0 ? bounds.top - origin.bottom : origin.top - bounds.bottom, + ); + const side = + dx !== 0 + ? Math.max(0, bounds.top - origin.bottom, origin.top - bounds.bottom) + : Math.max(0, bounds.left - origin.right, origin.left - bounds.right); + // a step sideways counts double; the centres break a tie + const score = gap + side * 2 + ahead * 0.01; + if (score < bestScore) { + bestScore = score; + best = candidate; + } + } + return best; + } + + /** + * scroll each scrolling panel holding an element to show it + * @param element - the element + */ + private static scrollIntoView(element: UIBaseElement): void { + let child: Renderable = element; + for ( + let node = element.ancestor as Renderable | undefined; + node !== undefined && node !== null; + node = node.ancestor as Renderable | undefined + ) { + const panel = node as Renderable & { + scroll?: string; + scrollToChild?: (child: Renderable, margin?: number) => unknown; + }; + if ( + panel.scroll !== undefined && + panel.scroll !== "none" && + typeof panel.scrollToChild === "function" + ) { + // with room for the ring and a bounce around it + panel.scrollToChild(child, UIFocus.ringPadding + UIFocus.ringWidth + 4); + } + child = node; + } + } + + /** + * the action a key code stands for, if any + * @param keyCode - the key + * @returns the action, or undefined + */ + private static actionOf(keyCode: number): UINavAction | undefined { + for (const action of Object.keys(UIFocus.keys) as UINavAction[]) { + if (UIFocus.keys[action].includes(keyCode)) { + return action; + } + } + return undefined; + } + + /** + * press accept on the focused element: its press look, then the act + * @param source - the input pressing it, so only that one releases it + */ + private static acceptDown(source: string): void { + UIFocus._ringVisible = true; + const focused = UIFocus._focused; + if ( + UIFocus._acceptHeld !== undefined || + focused === undefined || + !UIFocus.isCandidate(focused) + ) { + return; + } + UIFocus._acceptHeld = source; + focused.focusPress(); + } + + /** + * release accept: the act completes, as a click does + * @param source - the input letting it go + */ + private static acceptUp(source: string): void { + if (UIFocus._acceptHeld !== source) { + return; + } + UIFocus._acceptHeld = undefined; + UIFocus._focused?.focusRelease(); + } + + private static readonly _onKeyDown = ( + _action: string | undefined, + keyCode: number, + ): void => { + const action = UIFocus.actionOf(keyCode); + if (action === "accept") { + UIFocus.acceptDown("key"); + } else if (action !== undefined) { + UIFocus.navigate(action); + } + }; + + private static readonly _onKeyUp = ( + _action: string | undefined, + keyCode: number, + ): void => { + if (UIFocus.actionOf(keyCode) === "accept") { + UIFocus.acceptUp("key"); + } + }; + + private static readonly _onPointer = (): void => { + UIFocus._ringVisible = false; + }; + + /** + * read the pads once a frame: directions repeat while held + * @param time - the frame time, in milliseconds + */ + private static readonly _onFrame = (time: number): void => { + if (!UIFocus.gamepad) { + return; + } + const B = GAMEPAD.BUTTONS; + const A = GAMEPAD.AXES; + let held = ""; + let accept = false; + let cancel = false; + for (const pad of readGamepads()) { + if (pad === null) { + continue; + } + const button = (code: number) => { + return pad.buttons[code]?.pressed ?? false; + }; + const x = pad.axes[A.LX] ?? 0; + const y = pad.axes[A.LY] ?? 0; + if (button(B.UP) || y < -0.5) { + held = "up"; + } else if (button(B.DOWN) || y > 0.5) { + held = "down"; + } else if (button(B.LEFT) || x < -0.5) { + held = "left"; + } else if (button(B.RIGHT) || x > 0.5) { + held = "right"; + } + accept ||= button(B.FACE_1); + cancel ||= button(B.FACE_2); + } + const pad = UIFocus._pad; + if (held !== "") { + // a step on the press, then a repeat while it is held + if (held !== pad.held) { + pad.next = time + 400; + UIFocus.navigate(held as UINavAction); + } else if (time >= pad.next) { + pad.next = time + 120; + UIFocus.navigate(held as UINavAction); + } + } + pad.held = held; + if (accept && !pad.accept) { + UIFocus.acceptDown("pad"); + } else if (!accept && pad.accept) { + UIFocus.acceptUp("pad"); + } + pad.accept = accept; + if (cancel && !pad.cancel) { + UIFocus.navigate("cancel"); + } + pad.cancel = cancel; + }; +} diff --git a/packages/melonjs/src/renderable/ui/uipanel.ts b/packages/melonjs/src/renderable/ui/uipanel.ts index c8b9e771d..74f820c7d 100644 --- a/packages/melonjs/src/renderable/ui/uipanel.ts +++ b/packages/melonjs/src/renderable/ui/uipanel.ts @@ -13,7 +13,10 @@ import Container from "../container.js"; import type Renderable from "../renderable.js"; import type { Paint } from "./panelpaint.ts"; import { PanelPainter } from "./panelpaint.ts"; +import type { UIJuiceSettings } from "./uibaseelement.ts"; import UIBaseElement, { END_EVENTS, endsPointer } from "./uibaseelement.ts"; +import type { UINavAction } from "./uifocus.ts"; +import UIFocus from "./uifocus.ts"; import type { UILabel } from "./uilabel.ts"; import { createLabel } from "./uilabel.ts"; import type { UISkin } from "./uiskin.ts"; @@ -60,7 +63,7 @@ export type UIPanelScroll = "none" | "vertical" | "horizontal" | "both"; * Everything {@link UIPanel} takes. * @category UI */ -export interface UIPanelSettings { +export interface UIPanelSettings extends UIJuiceSettings { /** the panel's width, in pixels */ width: number; /** the panel's height, in pixels */ @@ -191,6 +194,18 @@ export interface UIPanelSettings { scroll?: UIPanelScroll; /** called when the content scrolls, with the new offsets */ onScroll?: (scrollX: number, scrollY: number, panel: UIPanel) => void; + /** + * Keep the keyboard and gamepad focus inside the panel while it is in + * the scene, as a dialog should, and give it back where it was when the + * panel goes. See {@link UIFocus}. + * @default false + */ + trapFocus?: boolean; + /** + * Called when the player cancels (Escape, or the pad's B) with the focus + * inside the panel: close a dialog, go back a menu. + */ + onBack?: (panel: UIPanel) => void; /** * the scrollbar, shown while the content does not fit * @default "#00000059" @@ -334,6 +349,10 @@ export default class UIPanel extends UIBaseElement { autoSize: boolean; /** which ways the content scrolls */ scroll: UIPanelScroll; + /** keep the focus inside the panel while it is up */ + readonly trapFocus: boolean; + /** called when the player cancels with the focus inside the panel */ + onBack: ((panel: UIPanel) => void) | undefined; /** called when the content scrolls, with the new offsets */ onScroll: | ((scrollX: number, scrollY: number, panel: UIPanel) => void) @@ -426,6 +445,11 @@ export default class UIPanel extends UIBaseElement { this.blocking = settings.blocking ?? true; this.bobAmplitude = settings.bobAmplitude ?? 0; this.bobPeriod = settings.bobPeriod ?? 2000; + this.trapFocus = settings.trapFocus ?? false; + this.onBack = settings.onBack; + this.hoverScale = settings.hoverScale ?? 1; + this.pressScale = settings.pressScale ?? 1; + this.layout = settings.layout ?? "none"; this.columns = Math.max(1, Math.floor(settings.columns ?? 2)); this.padding = settings.padding ?? 0; @@ -1132,27 +1156,31 @@ export default class UIPanel extends UIBaseElement { /** * Scroll just enough to bring a child fully into view. * @param child - one of this panel's children + * @param margin - room to leave around it, in pixels, such as for a + * focus ring * @returns this panel */ - scrollToChild(child: Renderable): this { + scrollToChild(child: Renderable, margin = 0): this { if (child.ancestor !== this) { return this; } const [vx, vy, vw, vh] = this.viewRect(); const box = this.measure(child); - const left = child.pos.x + box.ox; - const top = child.pos.y + box.oy; + const left = child.pos.x + box.ox - margin; + const top = child.pos.y + box.oy - margin; + const w = box.w + margin * 2; + const h = box.h + margin * 2; let x = this._scrollX; let y = this._scrollY; if (left < vx) { x -= vx - left; - } else if (left + box.w > vx + vw) { - x += Math.min(left - vx, left + box.w - (vx + vw)); + } else if (left + w > vx + vw) { + x += Math.min(left - vx, left + w - (vx + vw)); } if (top < vy) { y -= vy - top; - } else if (top + box.h > vy + vh) { - y += Math.min(top - vy, top + box.h - (vy + vh)); + } else if (top + h > vy + vh) { + y += Math.min(top - vy, top + h - (vy + vh)); } return this.scrollTo(x, y); } @@ -1403,6 +1431,19 @@ export default class UIPanel extends UIBaseElement { return pressed(this); } + /** + * Answer cancel with `onBack`, when there is one. + * @param action - what the player asked for + * @returns true when cancel was handled + */ + override onNavigate(action: UINavAction): boolean { + if (action === "cancel" && this.onBack !== undefined) { + this.onBack(this); + return true; + } + return false; + } + /** * Move a draggable panel, unless the press began on the content of a * scrolling one. @@ -1515,6 +1556,9 @@ export default class UIPanel extends UIBaseElement { */ override onActivateEvent(): void { super.onActivateEvent(); + if (this.trapFocus) { + UIFocus._pushScope(this); + } registerPointerEvent("wheel", this, this._onWheel); // `onOver` only fires on the frame the pointer crosses in; this keeps // a blocking panel opaque to hover for as long as the pointer is on it @@ -1528,6 +1572,9 @@ export default class UIPanel extends UIBaseElement { * @ignore */ override onDeactivateEvent(): void { + if (this.trapFocus) { + UIFocus._popScope(this); + } releasePointerEvent("wheel", this); releasePointerEvent("pointermove", this); this.endPress(); diff --git a/packages/melonjs/src/renderable/ui/uislider.ts b/packages/melonjs/src/renderable/ui/uislider.ts index 8f426f852..ba7bb38b6 100644 --- a/packages/melonjs/src/renderable/ui/uislider.ts +++ b/packages/melonjs/src/renderable/ui/uislider.ts @@ -9,11 +9,13 @@ import { off, on, POINTERMOVE } from "../../system/event.ts"; import type Renderer from "../../video/renderer.js"; import type { Paint } from "./panelpaint.ts"; import { PanelPainter } from "./panelpaint.ts"; +import type { UIJuiceSettings } from "./uibaseelement.ts"; import UIBaseElement, { END_EVENTS, endsPointer, isPointerUp, } from "./uibaseelement.ts"; +import type { UINavAction } from "./uifocus.ts"; import type { UILabel } from "./uilabel.ts"; import { createLabel } from "./uilabel.ts"; import type { UISkin } from "./uiskin.ts"; @@ -41,7 +43,7 @@ export interface UISliderSkin extends UISkin { * Everything {@link UISlider} takes. * @category UI */ -export interface UISliderSettings { +export interface UISliderSettings extends UIJuiceSettings { /** * the lowest value * @default 0 @@ -414,6 +416,9 @@ export default class UISlider extends UIBaseElement { this._value = this.snap(settings.value ?? this._min); this.refreshValueText(); + this.focusable = true; + this.hoverScale = settings.hoverScale ?? 1; + this.pressScale = settings.pressScale ?? 1; this.enabled = settings.enabled ?? true; } @@ -521,6 +526,33 @@ export default class UISlider extends UIBaseElement { } } + /** + * Step with the keyboard or a pad while it has the focus: left and + * right on a horizontal slider, up and down on a vertical one. A step + * is `step`, or a twentieth of the range without one. + * @param action - what the player asked for + * @returns true for the directions along the slider + */ + override onNavigate(action: UINavAction): boolean { + const horizontal = this.orientation === "horizontal"; + const sign = + action === (horizontal ? "right" : "up") + ? 1 + : action === (horizontal ? "left" : "down") + ? -1 + : 0; + if (sign === 0) { + return false; + } + if (this._enabled) { + const step = this._step > 0 ? this._step : (this._max - this._min) / 20; + if (this.setFromUser(this._value + sign * step)) { + this.onChangeEnd?.(this._value, this); + } + } + return true; + } + /** * Call off a press, and a drag in progress, without `onChangeEnd`. */ diff --git a/packages/melonjs/src/renderable/ui/uitextbutton.ts b/packages/melonjs/src/renderable/ui/uitextbutton.ts index 155f95441..7957eb09c 100644 --- a/packages/melonjs/src/renderable/ui/uitextbutton.ts +++ b/packages/melonjs/src/renderable/ui/uitextbutton.ts @@ -8,6 +8,7 @@ import type Renderer from "../../video/renderer.js"; import BitmapText from "../text/bitmaptext.js"; import Text from "../text/text.js"; import type { Paint } from "./panelpaint.ts"; +import type { UIJuiceSettings } from "./uibaseelement.ts"; import UIBaseElement, { isPointerUp } from "./uibaseelement.ts"; import type { UILabel } from "./uilabel.ts"; import type { UISkin } from "./uiskin.ts"; @@ -38,7 +39,7 @@ export interface UITextButtonSkin extends UISkin { * Everything {@link UITextButton} takes. * @category UI */ -export interface UITextButtonSettings { +export interface UITextButtonSettings extends UIJuiceSettings { /** * The font: the name of a loaded bitmap font, or a CSS font family. A * name with a font image in the loader, or a `fontData` setting, is @@ -407,6 +408,9 @@ export default class UITextButton extends UIBaseElement { // add the label to the UI container this.addChild(this.label); + this.focusable = true; + this.hoverScale = settings.hoverScale ?? 1; + this.pressScale = settings.pressScale ?? 1; this.enabled = settings.enabled ?? true; } @@ -424,6 +428,17 @@ export default class UITextButton extends UIBaseElement { this.label = value; } + /** + * Accept let go with the focus here: a completed click. + */ + override focusRelease(): void { + const pressed = !this.released; + super.focusRelease(); + if (pressed) { + this.completeClick(); + } + } + /** * Call off a press, from the pointer or from the key. */ diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js index 36ca9a9cd..448cda853 100644 --- a/packages/melonjs/tests/ui-widgets.spec.js +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -1,4 +1,13 @@ -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import { Application, boot, @@ -9,6 +18,7 @@ import { Renderable, TextureAtlas, UICheckbox, + UIFocus, UIPanel, UISlider, UITextButton, @@ -1160,6 +1170,286 @@ describe("UI widgets", () => { }); }); + describe("juice", () => { + it("eases to hoverScale under the pointer and pressScale held", () => { + const b = place( + new UITextButton(100, 100, { + text: "OK", + width: 100, + height: 40, + hoverScale: 1.1, + pressScale: 0.9, + }), + ); + const scale = vi.spyOn(app.renderer, "scale"); + b.preDraw(app.renderer); + b.postDraw(app.renderer); + expect(scale).not.toHaveBeenCalled(); + pointer("pointermove", 150, 120); + for (let i = 0; i < 60; i++) { + b.update(16); + } + b.preDraw(app.renderer); + b.postDraw(app.renderer); + expect(scale.mock.calls.at(-1)[0]).toBeCloseTo(1.1, 2); + pointer("pointerdown", 150, 120); + for (let i = 0; i < 60; i++) { + b.update(16); + } + b.preDraw(app.renderer); + b.postDraw(app.renderer); + expect(scale.mock.calls.at(-1)[0]).toBeCloseTo(0.9, 2); + pointer("pointerup", 150, 120); + }); + + it("scales only the drawing: the hit area stays", () => { + const b = place( + new UITextButton(100, 100, { + text: "OK", + width: 100, + height: 40, + hoverScale: 1.5, + }), + ); + const before = b.getBounds().width; + pointer("pointermove", 150, 120); + for (let i = 0; i < 60; i++) { + b.update(16); + } + expect(b.getBounds().width).toBe(before); + }); + }); + + describe("UIFocus", () => { + const key = (code, up = false) => { + event.emit(up ? event.KEYUP : event.KEYDOWN, undefined, code); + }; + const press = (code) => { + key(code); + key(code, true); + }; + const column = () => { + const panel = place( + new UIPanel(100, 100, { + width: 220, + height: 300, + layout: "column", + padding: 10, + }), + ); + const a = panel.addChild( + new UITextButton(0, 0, { text: "A", width: 200 }), + ); + const b = panel.addChild( + new UITextButton(0, 0, { text: "B", width: 200 }), + ); + const c = panel.addChild(new UICheckbox(0, 0, { label: "C" })); + const d = panel.addChild( + new UISlider(0, 0, { min: 0, max: 10, step: 1 }), + ); + return { panel, a, b, c, d }; + }; + + beforeAll(() => { + UIFocus.enable(); + }); + afterAll(() => { + UIFocus.disable(); + }); + beforeEach(() => { + // as after a key press: the ring shows, so the next one moves + UIFocus.navigate("accept"); + }); + afterEach(() => { + UIFocus.focus(undefined); + }); + + it("the first arrow focuses the first widget, the next ones move", () => { + const { a, b, c } = column(); + press(input.KEY.DOWN); + expect(UIFocus.focused).toBe(a); + expect(a.focused).toBe(true); + press(input.KEY.DOWN); + expect(UIFocus.focused).toBe(b); + press(input.KEY.DOWN); + expect(UIFocus.focused).toBe(c); + press(input.KEY.UP); + expect(UIFocus.focused).toBe(b); + // nothing to the right: the focus stays + press(input.KEY.RIGHT); + expect(UIFocus.focused).toBe(b); + }); + + it("accept clicks a button and toggles a checkbox", () => { + const { a, c } = column(); + const onClick = vi.fn(); + a.onClickCallback = onClick; + const onChange = vi.fn(); + c.onChange = onChange; + a.focus(); + key(input.KEY.ENTER); + // down while held, as under a pointer + expect(a.released).toBe(false); + expect(onClick).not.toHaveBeenCalled(); + key(input.KEY.ENTER, true); + expect(onClick).toHaveBeenCalledTimes(1); + c.focus(); + press(input.KEY.SPACE); + expect(c.checked).toBe(true); + expect(onChange).toHaveBeenCalledWith(true, c); + }); + + it("left and right step a slider, which keeps the focus", () => { + const { d } = column(); + const onChange = vi.fn(); + const onChangeEnd = vi.fn(); + d.onChange = onChange; + d.onChangeEnd = onChangeEnd; + d.focus(); + press(input.KEY.RIGHT); + press(input.KEY.RIGHT); + expect(d.value).toBe(2); + press(input.KEY.LEFT); + expect(d.value).toBe(1); + expect(UIFocus.focused).toBe(d); + expect(onChange).toHaveBeenCalledTimes(3); + expect(onChangeEnd).toHaveBeenCalledTimes(3); + }); + + it("skips disabled widgets, and follows focusNeighbors", () => { + const { a, b, c, d } = column(); + b.enabled = false; + a.focus(); + press(input.KEY.DOWN); + expect(UIFocus.focused).toBe(c); + c.focusNeighbors = { down: a }; + press(input.KEY.DOWN); + expect(UIFocus.focused).toBe(a); + void d; + }); + + it("shows the ring after a key, and hides it when the mouse moves", () => { + const { a } = column(); + a.focus(); + press(input.KEY.DOWN); + expect(UIFocus.ringVisible).toBe(true); + pointer("pointermove", 700, 500); + expect(UIFocus.ringVisible).toBe(false); + }); + + it("a trapFocus panel keeps the focus in, and gives it back", () => { + const { a } = column(); + a.focus(); + const onBack = vi.fn(); + const dialog = place( + new UIPanel(400, 100, { + width: 200, + height: 120, + layout: "column", + trapFocus: true, + onBack, + }), + ); + const yes = dialog.addChild(new UITextButton(0, 0, { text: "Yes" })); + const no = dialog.addChild(new UITextButton(0, 0, { text: "No" })); + press(input.KEY.DOWN); + expect([yes, no]).toContain(UIFocus.focused); + // left would reach the column outside: it stays inside + press(input.KEY.LEFT); + expect([yes, no]).toContain(UIFocus.focused); + press(input.KEY.ESC); + expect(onBack).toHaveBeenCalledWith(dialog); + app.world.removeChildNow(dialog); + expect(UIFocus.focused).toBe(a); + }); + + it("scrolls a focused widget into view", () => { + const panel = place( + new UIPanel(100, 100, { + width: 200, + height: 100, + layout: "column", + gap: 0, + scroll: "vertical", + }), + ); + const buttons = []; + for (let i = 0; i < 8; i++) { + buttons.push( + panel.addChild( + new UITextButton(0, 0, { text: `${i}`, width: 200, height: 40 }), + ), + ); + } + buttons[0].focus(); + for (let i = 0; i < 5; i++) { + press(input.KEY.DOWN); + } + expect(UIFocus.focused).toBe(buttons[5]); + // its bottom, plus room for the ring, at the bottom of the view + expect(panel.scrollY).toBe(150); + }); + + it("a gamepad's D-pad moves the focus and A clicks", () => { + const { a, b } = column(); + const onClick = vi.fn(); + b.onClickCallback = onClick; + const buttons = []; + for (let i = 0; i < 17; i++) { + buttons.push({ pressed: false, value: 0 }); + } + const pad = { id: "test pad", buttons, axes: [0, 0, 0, 0] }; + input.addVirtualGamepad(pad, 0); + try { + a.focus(); + let t = 1000; + const frame = () => { + t += 16; + event.emit(event.GAME_BEFORE_UPDATE, t); + }; + buttons[input.GAMEPAD.BUTTONS.DOWN].pressed = true; + frame(); + buttons[input.GAMEPAD.BUTTONS.DOWN].pressed = false; + frame(); + expect(UIFocus.focused).toBe(b); + buttons[input.GAMEPAD.BUTTONS.FACE_1].pressed = true; + frame(); + buttons[input.GAMEPAD.BUTTONS.FACE_1].pressed = false; + frame(); + expect(onClick).toHaveBeenCalledTimes(1); + } finally { + input.removeVirtualGamepad(pad); + } + }); + + it("the first key after the mouse shows the ring without moving", () => { + const { a } = column(); + a.focus(); + pointer("pointermove", 700, 500); + press(input.KEY.DOWN); + expect(UIFocus.focused).toBe(a); + expect(UIFocus.ringVisible).toBe(true); + }); + + it("starts on the left of a row, even if a widget on the right sits higher", () => { + const left = place(new UITextButton(20, 110, { text: "L" })); + const right = place(new UITextButton(500, 100, { text: "R" })); + press(input.KEY.DOWN); + expect(UIFocus.focused).toBe(left); + void right; + }); + + it("is off until enabled, and drops the focus when disabled", () => { + const { a } = column(); + a.focus(); + UIFocus.disable(); + expect(UIFocus.focused).toBeUndefined(); + press(input.KEY.DOWN); + expect(UIFocus.focused).toBeUndefined(); + UIFocus.enable(); + }); + }); + describe("API", () => { it("enabled is shared by every widget, the panel included", () => { const panel = new UIPanel(0, 0, { width: 10, height: 10 }); From 6f1acf6498926beea03265a948e58081cca96c70 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 06:26:02 +0000 Subject: [PATCH 12/43] UI: keep the focus navigation to the minimum Arrows or the D-pad move to the nearest widget, Enter, Space or A press it, sliders step, a ring shows the focus, and a focused widget in a scrolling panel scrolls into view. Removed: trapFocus/onBack, cancel, focusNeighbors, onFocus/onBlur, hover moving the focus, the first-key rule, the stick with repeat, configurable keys and ring sizes. Most games use the mouse or touch; this covers a menu driven by keys or a pad. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/CHANGELOG.md | 2 +- .../skills/melonjs-ui-and-text/SKILL.md | 40 +- .../src/renderable/ui/uibaseelement.ts | 55 +-- packages/melonjs/src/renderable/ui/uifocus.ts | 399 +++++------------- packages/melonjs/src/renderable/ui/uipanel.ts | 39 -- packages/melonjs/tests/ui-widgets.spec.js | 64 +-- 6 files changed, 132 insertions(+), 467 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index e54c7eb08..f44899fcd 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -10,7 +10,7 @@ - UI: `UITextButton` takes an `onClick` setting, called on a completed click; a `skin` of atlas regions, with pressed art drawn lowered; `pressedColor`; and the names the other widgets use: `width` / `height` for its size, `fontSize` (pixels, also with a bitmap font), `labelFillStyle` and `borderColor`. `resize()` keeps its background and label in step. A font that is not a loaded bitmap font is drawn as a CSS font, where the button used to throw - UI: `enabled` on every `UIBaseElement`, to turn a widget off; `cancelPress()` and `onCancel()`, to call off a press without a release, as a scrolling `UIPanel` does when a drag on a button turns into a scroll; and `keepsDrag`, for a widget of your own that is dragged inside a scrolling panel - UI: a drop shadow under a `UIPanel` or a `ProgressBar`: `shadowColor`, `shadowOffsetX`, `shadowOffsetY` and `shadowBlur`, softened the same on Canvas, WebGL and WebGPU, rounded with the corners, and under a skin too -- UI: keyboard and gamepad navigation, `UIFocus` (off until `UIFocus.enable()`): the arrows or the D-pad move the focus to the nearest widget, Enter or A clicks a button or toggles a checkbox, left and right step a slider, Escape or B cancels. A focus ring shows once the keyboard or a pad is used; a `UIPanel` with `trapFocus` keeps the focus inside while it is up and runs `onBack` on cancel; a focused widget in a scrolling panel is scrolled into view. Any `UIBaseElement` can take part through `focusable`, `focusNeighbors`, `onNavigate`, `onFocus` and `onBlur` +- UI: keyboard and gamepad navigation, `UIFocus` (off until `UIFocus.enable()`): the arrows or the D-pad move the focus to the nearest widget, Enter or A press it as a click does, left and right step a slider; a ring shows the focused widget once a key or the pad is used, and a focused widget in a scrolling panel is scrolled into view. A widget of your own takes part through `focusable` and `onNavigate` - UI: `hoverScale` and `pressScale` on every UI element: a bounce under the pointer or the focus, and while held, easing by itself and scaling only the drawing, not the hit area or the layout - UI: `ProgressBar`'s `onChange` also gets the bar - UI: the `UISkin`, `UILabel`, `Paint` and settings and skin types of every widget are exported; `Container#clipContains(x, y)` tells whether a container shows its children at a point diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index 1dec3e7dd..0343efc92 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -644,36 +644,26 @@ put, so nothing jitters and the widgets around it do not move. ### Keyboard and gamepad navigation: `UIFocus` +For a game played without a mouse or a touch screen: + ```js UIFocus.enable(); // in the stage's onResetEvent // ...and UIFocus.disable() in its onDestroyEvent +UIFocus.focus(playButton); // optional: where to start ``` -The arrow keys or a pad's D-pad and left stick move the focus to the nearest -widget that way. Enter, Space or the pad's A press the focused widget as a -click does: a button calls its `onClick` setting, a checkbox toggles. Left -and right step a slider (up and down on a vertical one). Escape or the pad's -B cancels. Nothing is set up per screen. - -- **Off until enabled**, so a game steering with the arrows is not surprised. - `UIFocus.keys` changes the keys. The pad is read directly, so the game's - own pad bindings stay as they are; `UIFocus.gamepad = false` turns it off. -- **Which widgets:** anything with `focusable` set, as buttons, checkboxes - and sliders are, that is enabled and in the scene. A wrong guess for a - direction is fixed with `focusNeighbors: { down: other }`. -- **A dialog:** a `UIPanel` with `trapFocus: true` keeps the focus inside - itself while it is up, and gives it back when it goes. Its `onBack(panel)` - runs on cancel. -- **The ring** shows only once the keyboard or a pad is used, and the mouse - moving hides it again. `UIFocus.ringColor`, `ringWidth`, `ringPadding` and - `ringRadius` style it. Hovering moves the focus too, so the keyboard picks - up where the mouse was. -- **Scrolling lists** scroll the focused widget into view. -- **A widget of your own** sets `focusable = true`, overrides - `onNavigate(action)` (return `true` for what it handles) and - `focusRelease()` for accept. `onFocus()` and `onBlur()` tell it when it - gains and loses the focus. `UIFocus.focus(widget)` picks where to start, - and `UIFocus.navigate("down")` drives it from input of your own. +The arrow keys or a pad's D-pad move the focus to the nearest widget that +way. Enter, Space or the pad's A press the focused widget as a click does: a +button calls its `onClick` setting, a checkbox toggles. Left and right step a +slider (up and down on a vertical one). A ring (`UIFocus.ringColor`) shows the +focused widget once a key or the pad is used, and the mouse moving hides it. +A focused widget in a scrolling panel is scrolled into view. + +It is off until enabled, so a game steering with the arrows is not surprised, +and it reads the pad directly, so the game's own pad bindings are untouched. +Buttons, checkboxes and sliders take part; a widget of your own sets +`focusable = true`, and overrides `onNavigate(direction)` (return `true` for +what it handles) and `focusRelease()` for what Enter does. ### Keyboard shortcuts on a button: `bindKey` diff --git a/packages/melonjs/src/renderable/ui/uibaseelement.ts b/packages/melonjs/src/renderable/ui/uibaseelement.ts index fbf33fad6..963e2a178 100644 --- a/packages/melonjs/src/renderable/ui/uibaseelement.ts +++ b/packages/melonjs/src/renderable/ui/uibaseelement.ts @@ -300,51 +300,18 @@ export default class UIBaseElement extends Container { */ focusable = false; - /** - * Where the focus goes from here, for a direction the nearest widget is - * not the right answer to. A neighbour that cannot take the focus now is - * skipped for the search. - */ - focusNeighbors: - | Partial> - | undefined; - - /** whether this element has the focus */ + /** whether this element has the keyboard and gamepad focus */ get focused(): boolean { return UIFocus.focused === this; } /** - * Give this element the focus. See {@link UIFocus.focus}. - */ - focus(): void { - UIFocus.focus(this); - } - - /** - * Called when this element gets the focus. To be extended. - */ - onFocus(): void { - // to be extended - } - - /** - * Called when this element loses the focus. To be extended. - */ - onBlur(): void { - // to be extended - } - - /** - * Answer a navigation action while this element, or one inside it, has - * the focus: return `true` to say it was handled. - * - * A slider handles left and right by stepping, so the focus stays on it; - * a panel can handle `"cancel"` to close. Unhandled, a direction moves - * the focus to the nearest widget that way. Accept is not passed here: - * it presses and releases the widget, as a click does, through + * An arrow key or a D-pad direction while this element has the focus: + * return `true` to say it was handled, as a slider does to step, and the + * focus stays. Unhandled, it moves the focus to the nearest widget that + * way. Enter or A presses the widget instead, as a click does, through * {@link UIBaseElement#focusPress} and {@link UIBaseElement#focusRelease}. - * @param _action - what the player asked for + * @param _action - which way * @returns true when handled */ onNavigate(_action: UINavAction): boolean { @@ -418,7 +385,8 @@ export default class UIBaseElement extends Container { renderer.translate(-cx, -cy); } if (UIFocus.ringVisible && UIFocus.focused === this) { - const pad = UIFocus.ringPadding + UIFocus.ringWidth; + // 3 pixels wide, 3 pixels out from the widget + const pad = 6; const alpha = renderer.globalAlpha(); ringPainter.paint(renderer, UIFocus.ringColor, alpha); ringPainter.border( @@ -427,8 +395,8 @@ export default class UIBaseElement extends Container { this.pos.y - pad, this.width + pad * 2, this.height + pad * 2, - UIFocus.ringRadius, - UIFocus.ringWidth, + 6, + 3, ); renderer.setGlobalAlpha(alpha); } @@ -534,9 +502,6 @@ export default class UIBaseElement extends Container { */ enter(event: Pointer): boolean | void { this.hover = true; - if (this.focusable) { - UIFocus._hovered(this); - } this.isDirty = true; if (this.isDraggable) { this.listenForDrag(); diff --git a/packages/melonjs/src/renderable/ui/uifocus.ts b/packages/melonjs/src/renderable/ui/uifocus.ts index 748145ebe..dcb9420bb 100644 --- a/packages/melonjs/src/renderable/ui/uifocus.ts +++ b/packages/melonjs/src/renderable/ui/uifocus.ts @@ -12,112 +12,67 @@ import type Renderable from "../renderable.js"; import type UIBaseElement from "./uibaseelement.ts"; /** - * What the player asks of the focused widget: move the focus, or act on it. + * A direction the player moves the focus in. * @category UI */ -export type UINavAction = - | "up" - | "down" - | "left" - | "right" - | "accept" - | "cancel"; +export type UINavAction = "up" | "down" | "left" | "right"; -/** an element that keeps focus inside itself while it is up */ -interface FocusScope { - ancestor?: unknown; -} - -/** the directions, for the spatial search */ -const DIRECTIONS: Record<"up" | "down" | "left" | "right", [number, number]> = { +/** the directions, as unit steps */ +const DIRECTIONS: Record = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0], }; +/** the key for each direction */ +const KEYS: Record = { + up: KEY.UP, + down: KEY.DOWN, + left: KEY.LEFT, + right: KEY.RIGHT, +}; + +/** the keys that press the focused widget */ +const ACCEPT_KEYS: readonly number[] = [KEY.ENTER, KEY.SPACE]; + /** - * Keyboard and gamepad navigation for UI elements: one widget at a time has - * the focus, the arrows or the D-pad move it to the nearest widget that way, - * and Enter or the pad's A button acts on it, as a click would. - * - * Off until {@link UIFocus.enable} is called, so a game that steers with the - * arrow keys does not find them moving a focus it never asked for. + * Keyboard and gamepad navigation for UI elements, for a game played + * without a mouse or a touch screen. * - * Any {@link UIBaseElement} can take part: it sets `focusable`, and answers - * the player through {@link UIBaseElement#onNavigate}. Buttons, checkboxes - * and sliders already do: a button clicks, a checkbox toggles, a slider - * steps on left and right. Nothing needs setting up per screen: + * One widget has the focus. The arrow keys or a pad's D-pad move it to the + * nearest widget in that direction, Enter, Space or the pad's A button press + * it as a click does, and a slider steps on left and right. A ring shows + * which widget has the focus once a key or the pad is used, and the mouse + * moving hides it. A focused widget in a scrolling panel is scrolled into + * view. * - * - the next widget in a direction is found from where the widgets are, - * and one that should go somewhere else names it in `focusNeighbors`; - * - a {@link UIPanel} with `trapFocus` keeps the focus inside itself while - * it is up, as a dialog should, and gives it back when it goes; - * - a focused widget inside a scrolling panel is scrolled into view; - * - the focus ring only shows once the keyboard or a pad is used, and the - * mouse moving hides it again, as in a web page. + * Off until {@link UIFocus.enable} is called, so a game that steers with the + * arrow keys is not surprised. Buttons, checkboxes and sliders take part; a + * widget of your own sets `focusable`. * @category UI * @example - * UIFocus.enable(); - * // start on the first button, rather than on the first arrow press - * UIFocus.focus(playButton); + * UIFocus.enable(); // in the stage's onResetEvent + * UIFocus.disable(); // and in its onDestroyEvent */ // one focus per page: a class of statics, documented as one API like `state` // eslint-disable-next-line @typescript-eslint/no-extraneous-class export default class UIFocus { - /** - * The keys for each action, by `input.KEY` code. Change them before or - * after {@link UIFocus.enable}. - */ - static keys: Record = { - up: [KEY.UP], - down: [KEY.DOWN], - left: [KEY.LEFT], - right: [KEY.RIGHT], - accept: [KEY.ENTER, KEY.SPACE], - cancel: [KEY.ESC], - }; - - /** - * Whether a gamepad navigates too: its D-pad or left stick moves the - * focus, its A (FACE_1) button accepts and its B (FACE_2) button cancels. - * Read straight from the pad, so the game's own pad bindings are left as - * they are. - * @default true - */ - static gamepad = true; - - /** the focus ring's colour */ + /** the colour of the ring around the focused widget */ static ringColor = "#ffcc33"; - /** the focus ring's thickness, in pixels */ - static ringWidth = 3; - /** how far outside the widget the ring is drawn, in pixels */ - static ringPadding = 3; - /** the focus ring's corner radius */ - static ringRadius = 6; /** whether navigation is on */ private static _enabled = false; /** the focusable elements in the scene */ private static readonly _elements = new Set(); - /** the panels keeping the focus in, the innermost last */ - private static readonly _scopes: { - scope: FocusScope; - restore: UIBaseElement | undefined; - }[] = []; /** the focused element */ private static _focused: UIBaseElement | undefined; /** whether the ring shows: after keyboard or pad input, until the mouse */ private static _ringVisible = false; - /** whether accept is held, and by which input */ - private static _acceptHeld: string | undefined; - /** the pad's directions and buttons as last read, and the repeat clock */ - private static readonly _pad = { - held: "" as string, - next: 0, - accept: false, - cancel: false, - }; + /** the input holding accept down, if any */ + private static _accept: "key" | "pad" | undefined; + /** the pad's state on the last frame */ + private static readonly _pad = { held: "", accept: false }; /** * Turn keyboard and gamepad navigation on. @@ -164,71 +119,41 @@ export default class UIFocus { } /** - * Give an element the focus, or take it away with `undefined`. - * - * The element is told through `onFocus`, the one before it through - * `onBlur`, and a scrolling panel holding it scrolls it into view. + * Give an element the focus, or take it away with `undefined`: to choose + * where navigation starts, say. A scrolling panel holding the element + * scrolls it into view. * @param element - the element to focus */ static focus(element: UIBaseElement | undefined): void { - const previous = UIFocus._focused; - if (previous === element) { + if (UIFocus._focused === element) { return; } - UIFocus._focused = element; - if (previous !== undefined) { - previous.isDirty = true; - previous.onBlur(); + if (UIFocus._focused !== undefined) { + UIFocus._focused.isDirty = true; } + UIFocus._accept = undefined; + UIFocus._focused = element; if (element !== undefined) { element.isDirty = true; - element.onFocus(); UIFocus.scrollIntoView(element); } } /** - * Act as if the player asked for something: move the focus, accept or - * cancel. For input of the game's own, such as a touch D-pad of its own - * or a remote. - * @param action - what to do + * Move the focus as an arrow key would, for input of the game's own. The + * focused widget is asked first, through `onNavigate`, so a slider steps + * instead. With nothing focused, the first widget takes the focus. + * @param action - which way */ static navigate(action: UINavAction): void { - const wasVisible = UIFocus._ringVisible; UIFocus._ringVisible = true; - let focused = UIFocus._focused; - if (focused !== undefined && !UIFocus.isCandidate(focused)) { - UIFocus.focus(undefined); - focused = undefined; - } - if (focused === undefined) { - if (action !== "cancel" && action !== "accept") { - UIFocus.focus(UIFocus.first()); - } - return; - } - // the first key after the mouse shows where the focus is, rather - // than moving it from a place the player could not see - if (!wasVisible && action in DIRECTIONS) { + const focused = UIFocus._focused; + if (focused === undefined || !UIFocus.isCandidate(focused)) { + UIFocus.focus(UIFocus.first()); return; } - // the focused widget first, then the ones holding it, for a slider - // stepping, or a panel answering cancel - for ( - let node: Renderable | undefined = focused; - node !== undefined; - node = node.ancestor as Renderable | undefined - ) { - const element = node as unknown as Partial; - if ( - typeof element.onNavigate === "function" && - element.onNavigate(action) - ) { - return; - } - } - if (action in DIRECTIONS) { - const next = UIFocus.next(focused, action as keyof typeof DIRECTIONS); + if (!focused.onNavigate(action)) { + const next = UIFocus.next(focused, action); if (next !== undefined) { UIFocus.focus(next); } @@ -258,126 +183,34 @@ export default class UIFocus { } } - /** - * A panel that keeps the focus in comes up: the focus moves into it. - * @param scope - the panel - * @ignore - * @internal - */ - static _pushScope(scope: FocusScope): void { - UIFocus._scopes.push({ scope, restore: UIFocus._focused }); - if (UIFocus._focused !== undefined && !UIFocus.inScope(UIFocus._focused)) { - UIFocus.focus(UIFocus.first()); - } - } - - /** - * That panel goes: the focus goes back where it was. - * @param scope - the panel - * @ignore - * @internal - */ - static _popScope(scope: FocusScope): void { - const index = UIFocus._scopes.findIndex((entry) => { - return entry.scope === scope; - }); - if (index === -1) { - return; - } - const [entry] = UIFocus._scopes.splice(index, 1); - if ( - UIFocus._focused === undefined || - UIFocus.isInside(UIFocus._focused, scope) - ) { - const restore = entry.restore; - UIFocus.focus( - restore !== undefined && UIFocus.isCandidate(restore) - ? restore - : undefined, - ); - } - } - - /** - * Hover moves the focus along, so the keyboard picks up where the mouse - * was. The ring stays hidden. - * @param element - the element under the pointer - * @ignore - * @internal - */ - static _hovered(element: UIBaseElement): void { - if ( - UIFocus._enabled && - UIFocus._focused !== undefined && - UIFocus.isCandidate(element) - ) { - UIFocus.focus(element); - } - } - /** * whether an element can take the focus now * @param element - the element - * @returns true when it is focusable, enabled, shown and in scope + * @returns true when it is focusable, enabled, shown and in the scene */ private static isCandidate(element: UIBaseElement): boolean { return ( element.focusable && element.enabled && element.isRenderable && - UIFocus._elements.has(element) && - UIFocus.inScope(element) + UIFocus._elements.has(element) ); } - /** - * whether an element is inside the panel keeping the focus, if any - * @param element - the element - * @returns true when no panel keeps the focus, or it is inside that one - */ - private static inScope(element: UIBaseElement): boolean { - const top = UIFocus._scopes[UIFocus._scopes.length - 1]; - return top === undefined || UIFocus.isInside(element, top.scope); - } - - /** - * whether an element is held, at any depth, by another - * @param element - the element - * @param scope - the one that may hold it - * @returns true when `scope` is one of its ancestors - */ - private static isInside(element: Renderable, scope: FocusScope): boolean { - for ( - let node = element.ancestor as FocusScope | undefined; - node != null; - node = node.ancestor as FocusScope | undefined - ) { - if (node === scope) { - return true; - } - } - return false; - } - /** * the elements that can take the focus now * @returns them, in no particular order */ private static candidates(): UIBaseElement[] { - const list: UIBaseElement[] = []; - for (const element of UIFocus._elements) { - if (UIFocus.isCandidate(element)) { - list.push(element); - } - } - return list; + return [...UIFocus._elements].filter((element) => { + return UIFocus.isCandidate(element); + }); } /** * Where the focus starts: the first in reading order, row by row, left - * to right. Two widgets whose tops are within half a height of each - * other are on the same row, so a widget a few pixels higher on the - * right does not come before the one on the left. + * to right. Two widgets whose tops are within half a height are on the + * same row, so one a few pixels higher on the right does not come first. * @returns the element, or undefined when there is none */ private static first(): UIBaseElement | undefined { @@ -399,21 +232,17 @@ export default class UIFocus { } /** - * The nearest candidate in a direction: a named neighbour if it can - * take the focus, else the closest one that way, edge to edge, a step - * sideways counting double. + * The nearest candidate in a direction, edge to edge, a step sideways + * counting double, so a narrow widget right below a wide one beats a + * wide one further down. * @param from - the focused element * @param direction - which way * @returns the next element, or undefined when there is none that way */ private static next( from: UIBaseElement, - direction: keyof typeof DIRECTIONS, + direction: UINavAction, ): UIBaseElement | undefined { - const named = from.focusNeighbors?.[direction]; - if (named !== undefined && UIFocus.isCandidate(named)) { - return named; - } const [dx, dy] = DIRECTIONS[direction]; const origin = from.getBounds(); let best: UIBaseElement | undefined; @@ -423,16 +252,13 @@ export default class UIFocus { continue; } const bounds = candidate.getBounds(); - // past the focused one, centre to centre, in that direction + // past the focused one, centre to centre, that way const ahead = (bounds.centerX - origin.centerX) * dx + (bounds.centerY - origin.centerY) * dy; if (ahead <= 1) { continue; } - // edge to edge: the gap ahead, and how far off to the side, 0 - // when the two overlap that way, so a narrow widget right below - // a wide one beats a wide one further down const gap = dx !== 0 ? Math.max( @@ -447,7 +273,7 @@ export default class UIFocus { dx !== 0 ? Math.max(0, bounds.top - origin.bottom, origin.top - bounds.bottom) : Math.max(0, bounds.left - origin.right, origin.left - bounds.right); - // a step sideways counts double; the centres break a tie + // the centres break a tie const score = gap + side * 2 + ahead * 0.01; if (score < bestScore) { bestScore = score; @@ -458,7 +284,8 @@ export default class UIFocus { } /** - * scroll each scrolling panel holding an element to show it + * scroll each scrolling panel holding an element to show it, with room + * for the ring * @param element - the element */ private static scrollIntoView(element: UIBaseElement): void { @@ -477,66 +304,52 @@ export default class UIFocus { panel.scroll !== "none" && typeof panel.scrollToChild === "function" ) { - // with room for the ring and a bounce around it - panel.scrollToChild(child, UIFocus.ringPadding + UIFocus.ringWidth + 4); + panel.scrollToChild(child, 10); } child = node; } } /** - * the action a key code stands for, if any - * @param keyCode - the key - * @returns the action, or undefined - */ - private static actionOf(keyCode: number): UINavAction | undefined { - for (const action of Object.keys(UIFocus.keys) as UINavAction[]) { - if (UIFocus.keys[action].includes(keyCode)) { - return action; - } - } - return undefined; - } - - /** - * press accept on the focused element: its press look, then the act + * press accept on the focused element: it goes down, as under a pointer * @param source - the input pressing it, so only that one releases it */ - private static acceptDown(source: string): void { + private static acceptDown(source: "key" | "pad"): void { UIFocus._ringVisible = true; const focused = UIFocus._focused; if ( - UIFocus._acceptHeld !== undefined || - focused === undefined || - !UIFocus.isCandidate(focused) + UIFocus._accept === undefined && + focused !== undefined && + UIFocus.isCandidate(focused) ) { - return; + UIFocus._accept = source; + focused.focusPress(); } - UIFocus._acceptHeld = source; - focused.focusPress(); } /** - * release accept: the act completes, as a click does + * release accept: the widget comes up and acts, as on a click * @param source - the input letting it go */ - private static acceptUp(source: string): void { - if (UIFocus._acceptHeld !== source) { - return; + private static acceptUp(source: "key" | "pad"): void { + if (UIFocus._accept === source) { + UIFocus._accept = undefined; + UIFocus._focused?.focusRelease(); } - UIFocus._acceptHeld = undefined; - UIFocus._focused?.focusRelease(); } private static readonly _onKeyDown = ( _action: string | undefined, keyCode: number, ): void => { - const action = UIFocus.actionOf(keyCode); - if (action === "accept") { + if (ACCEPT_KEYS.includes(keyCode)) { UIFocus.acceptDown("key"); - } else if (action !== undefined) { - UIFocus.navigate(action); + return; + } + for (const action of Object.keys(KEYS) as UINavAction[]) { + if (KEYS[action] === keyCode) { + UIFocus.navigate(action); + } } }; @@ -544,7 +357,7 @@ export default class UIFocus { _action: string | undefined, keyCode: number, ): void => { - if (UIFocus.actionOf(keyCode) === "accept") { + if (ACCEPT_KEYS.includes(keyCode)) { UIFocus.acceptUp("key"); } }; @@ -554,49 +367,33 @@ export default class UIFocus { }; /** - * read the pads once a frame: directions repeat while held - * @param time - the frame time, in milliseconds + * read the pads once a frame: a D-pad press moves once, A presses */ - private static readonly _onFrame = (time: number): void => { - if (!UIFocus.gamepad) { - return; - } + private static readonly _onFrame = (): void => { const B = GAMEPAD.BUTTONS; - const A = GAMEPAD.AXES; let held = ""; let accept = false; - let cancel = false; for (const pad of readGamepads()) { if (pad === null) { continue; } - const button = (code: number) => { + const pressed = (code: number) => { return pad.buttons[code]?.pressed ?? false; }; - const x = pad.axes[A.LX] ?? 0; - const y = pad.axes[A.LY] ?? 0; - if (button(B.UP) || y < -0.5) { + if (pressed(B.UP)) { held = "up"; - } else if (button(B.DOWN) || y > 0.5) { + } else if (pressed(B.DOWN)) { held = "down"; - } else if (button(B.LEFT) || x < -0.5) { + } else if (pressed(B.LEFT)) { held = "left"; - } else if (button(B.RIGHT) || x > 0.5) { + } else if (pressed(B.RIGHT)) { held = "right"; } - accept ||= button(B.FACE_1); - cancel ||= button(B.FACE_2); + accept ||= pressed(B.FACE_1); } const pad = UIFocus._pad; - if (held !== "") { - // a step on the press, then a repeat while it is held - if (held !== pad.held) { - pad.next = time + 400; - UIFocus.navigate(held as UINavAction); - } else if (time >= pad.next) { - pad.next = time + 120; - UIFocus.navigate(held as UINavAction); - } + if (held !== "" && held !== pad.held) { + UIFocus.navigate(held as UINavAction); } pad.held = held; if (accept && !pad.accept) { @@ -605,9 +402,5 @@ export default class UIFocus { UIFocus.acceptUp("pad"); } pad.accept = accept; - if (cancel && !pad.cancel) { - UIFocus.navigate("cancel"); - } - pad.cancel = cancel; }; } diff --git a/packages/melonjs/src/renderable/ui/uipanel.ts b/packages/melonjs/src/renderable/ui/uipanel.ts index 74f820c7d..a507e3703 100644 --- a/packages/melonjs/src/renderable/ui/uipanel.ts +++ b/packages/melonjs/src/renderable/ui/uipanel.ts @@ -15,8 +15,6 @@ import type { Paint } from "./panelpaint.ts"; import { PanelPainter } from "./panelpaint.ts"; import type { UIJuiceSettings } from "./uibaseelement.ts"; import UIBaseElement, { END_EVENTS, endsPointer } from "./uibaseelement.ts"; -import type { UINavAction } from "./uifocus.ts"; -import UIFocus from "./uifocus.ts"; import type { UILabel } from "./uilabel.ts"; import { createLabel } from "./uilabel.ts"; import type { UISkin } from "./uiskin.ts"; @@ -194,18 +192,6 @@ export interface UIPanelSettings extends UIJuiceSettings { scroll?: UIPanelScroll; /** called when the content scrolls, with the new offsets */ onScroll?: (scrollX: number, scrollY: number, panel: UIPanel) => void; - /** - * Keep the keyboard and gamepad focus inside the panel while it is in - * the scene, as a dialog should, and give it back where it was when the - * panel goes. See {@link UIFocus}. - * @default false - */ - trapFocus?: boolean; - /** - * Called when the player cancels (Escape, or the pad's B) with the focus - * inside the panel: close a dialog, go back a menu. - */ - onBack?: (panel: UIPanel) => void; /** * the scrollbar, shown while the content does not fit * @default "#00000059" @@ -349,10 +335,6 @@ export default class UIPanel extends UIBaseElement { autoSize: boolean; /** which ways the content scrolls */ scroll: UIPanelScroll; - /** keep the focus inside the panel while it is up */ - readonly trapFocus: boolean; - /** called when the player cancels with the focus inside the panel */ - onBack: ((panel: UIPanel) => void) | undefined; /** called when the content scrolls, with the new offsets */ onScroll: | ((scrollX: number, scrollY: number, panel: UIPanel) => void) @@ -445,8 +427,6 @@ export default class UIPanel extends UIBaseElement { this.blocking = settings.blocking ?? true; this.bobAmplitude = settings.bobAmplitude ?? 0; this.bobPeriod = settings.bobPeriod ?? 2000; - this.trapFocus = settings.trapFocus ?? false; - this.onBack = settings.onBack; this.hoverScale = settings.hoverScale ?? 1; this.pressScale = settings.pressScale ?? 1; @@ -1431,19 +1411,6 @@ export default class UIPanel extends UIBaseElement { return pressed(this); } - /** - * Answer cancel with `onBack`, when there is one. - * @param action - what the player asked for - * @returns true when cancel was handled - */ - override onNavigate(action: UINavAction): boolean { - if (action === "cancel" && this.onBack !== undefined) { - this.onBack(this); - return true; - } - return false; - } - /** * Move a draggable panel, unless the press began on the content of a * scrolling one. @@ -1556,9 +1523,6 @@ export default class UIPanel extends UIBaseElement { */ override onActivateEvent(): void { super.onActivateEvent(); - if (this.trapFocus) { - UIFocus._pushScope(this); - } registerPointerEvent("wheel", this, this._onWheel); // `onOver` only fires on the frame the pointer crosses in; this keeps // a blocking panel opaque to hover for as long as the pointer is on it @@ -1572,9 +1536,6 @@ export default class UIPanel extends UIBaseElement { * @ignore */ override onDeactivateEvent(): void { - if (this.trapFocus) { - UIFocus._popScope(this); - } releasePointerEvent("wheel", this); releasePointerEvent("pointermove", this); this.endPress(); diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js index 448cda853..12f7bd049 100644 --- a/packages/melonjs/tests/ui-widgets.spec.js +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -2,7 +2,6 @@ import { afterAll, afterEach, beforeAll, - beforeEach, describe, expect, it, @@ -1256,10 +1255,6 @@ describe("UI widgets", () => { afterAll(() => { UIFocus.disable(); }); - beforeEach(() => { - // as after a key press: the ring shows, so the next one moves - UIFocus.navigate("accept"); - }); afterEach(() => { UIFocus.focus(undefined); }); @@ -1286,14 +1281,14 @@ describe("UI widgets", () => { a.onClickCallback = onClick; const onChange = vi.fn(); c.onChange = onChange; - a.focus(); + UIFocus.focus(a); key(input.KEY.ENTER); // down while held, as under a pointer expect(a.released).toBe(false); expect(onClick).not.toHaveBeenCalled(); key(input.KEY.ENTER, true); expect(onClick).toHaveBeenCalledTimes(1); - c.focus(); + UIFocus.focus(c); press(input.KEY.SPACE); expect(c.checked).toBe(true); expect(onChange).toHaveBeenCalledWith(true, c); @@ -1305,7 +1300,7 @@ describe("UI widgets", () => { const onChangeEnd = vi.fn(); d.onChange = onChange; d.onChangeEnd = onChangeEnd; - d.focus(); + UIFocus.focus(d); press(input.KEY.RIGHT); press(input.KEY.RIGHT); expect(d.value).toBe(2); @@ -1316,53 +1311,23 @@ describe("UI widgets", () => { expect(onChangeEnd).toHaveBeenCalledTimes(3); }); - it("skips disabled widgets, and follows focusNeighbors", () => { - const { a, b, c, d } = column(); + it("skips disabled widgets", () => { + const { a, b, c } = column(); b.enabled = false; - a.focus(); + UIFocus.focus(a); press(input.KEY.DOWN); expect(UIFocus.focused).toBe(c); - c.focusNeighbors = { down: a }; - press(input.KEY.DOWN); - expect(UIFocus.focused).toBe(a); - void d; }); it("shows the ring after a key, and hides it when the mouse moves", () => { const { a } = column(); - a.focus(); + UIFocus.focus(a); press(input.KEY.DOWN); expect(UIFocus.ringVisible).toBe(true); pointer("pointermove", 700, 500); expect(UIFocus.ringVisible).toBe(false); }); - it("a trapFocus panel keeps the focus in, and gives it back", () => { - const { a } = column(); - a.focus(); - const onBack = vi.fn(); - const dialog = place( - new UIPanel(400, 100, { - width: 200, - height: 120, - layout: "column", - trapFocus: true, - onBack, - }), - ); - const yes = dialog.addChild(new UITextButton(0, 0, { text: "Yes" })); - const no = dialog.addChild(new UITextButton(0, 0, { text: "No" })); - press(input.KEY.DOWN); - expect([yes, no]).toContain(UIFocus.focused); - // left would reach the column outside: it stays inside - press(input.KEY.LEFT); - expect([yes, no]).toContain(UIFocus.focused); - press(input.KEY.ESC); - expect(onBack).toHaveBeenCalledWith(dialog); - app.world.removeChildNow(dialog); - expect(UIFocus.focused).toBe(a); - }); - it("scrolls a focused widget into view", () => { const panel = place( new UIPanel(100, 100, { @@ -1381,7 +1346,7 @@ describe("UI widgets", () => { ), ); } - buttons[0].focus(); + UIFocus.focus(buttons[0]); for (let i = 0; i < 5; i++) { press(input.KEY.DOWN); } @@ -1401,7 +1366,7 @@ describe("UI widgets", () => { const pad = { id: "test pad", buttons, axes: [0, 0, 0, 0] }; input.addVirtualGamepad(pad, 0); try { - a.focus(); + UIFocus.focus(a); let t = 1000; const frame = () => { t += 16; @@ -1422,15 +1387,6 @@ describe("UI widgets", () => { } }); - it("the first key after the mouse shows the ring without moving", () => { - const { a } = column(); - a.focus(); - pointer("pointermove", 700, 500); - press(input.KEY.DOWN); - expect(UIFocus.focused).toBe(a); - expect(UIFocus.ringVisible).toBe(true); - }); - it("starts on the left of a row, even if a widget on the right sits higher", () => { const left = place(new UITextButton(20, 110, { text: "L" })); const right = place(new UITextButton(500, 100, { text: "R" })); @@ -1441,7 +1397,7 @@ describe("UI widgets", () => { it("is off until enabled, and drops the focus when disabled", () => { const { a } = column(); - a.focus(); + UIFocus.focus(a); UIFocus.disable(); expect(UIFocus.focused).toBeUndefined(); press(input.KEY.DOWN); From 44ee453f217e56b8510b639880596393d80c5d91 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 06:30:47 +0000 Subject: [PATCH 13/43] UI: fixes from a review of the focus navigation and the bounce - a press held by Enter or A is called off when the focus moves, the widget is removed or navigation is disabled, instead of staying down - a focused widget only bounces while the focus ring shows - the bounce eases over about 0.2 s; it snapped in a frame - Enter goes to the focused button: another bound to Enter waits while the ring shows elsewhere - a pad's A held as navigation is enabled is not a press - a widget made focusable after it was added takes part - navigation skips widgets inside a hidden or transparent container - an arrow from a focused widget disabled since moves on from where it is - docs: the bounce is on UIBaseElement; Enter and bindKey; accept calls onClick() with no event Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/CHANGELOG.md | 2 +- .../skills/melonjs-ui-and-text/SKILL.md | 10 +- .../src/renderable/ui/uibaseelement.ts | 16 +-- packages/melonjs/src/renderable/ui/uifocus.ts | 102 ++++++++++++--- .../melonjs/src/renderable/ui/uitextbutton.ts | 11 ++ packages/melonjs/tests/ui-widgets.spec.js | 116 ++++++++++++++++++ 6 files changed, 228 insertions(+), 29 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index f44899fcd..29e2377cd 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -11,7 +11,7 @@ - UI: `enabled` on every `UIBaseElement`, to turn a widget off; `cancelPress()` and `onCancel()`, to call off a press without a release, as a scrolling `UIPanel` does when a drag on a button turns into a scroll; and `keepsDrag`, for a widget of your own that is dragged inside a scrolling panel - UI: a drop shadow under a `UIPanel` or a `ProgressBar`: `shadowColor`, `shadowOffsetX`, `shadowOffsetY` and `shadowBlur`, softened the same on Canvas, WebGL and WebGPU, rounded with the corners, and under a skin too - UI: keyboard and gamepad navigation, `UIFocus` (off until `UIFocus.enable()`): the arrows or the D-pad move the focus to the nearest widget, Enter or A press it as a click does, left and right step a slider; a ring shows the focused widget once a key or the pad is used, and a focused widget in a scrolling panel is scrolled into view. A widget of your own takes part through `focusable` and `onNavigate` -- UI: `hoverScale` and `pressScale` on every UI element: a bounce under the pointer or the focus, and while held, easing by itself and scaling only the drawing, not the hit area or the layout +- UI: `hoverScale` and `pressScale` on every `UIBaseElement`: a bounce under the pointer or the focus, and while held, easing by itself and scaling only the drawing, not the hit area or the layout - UI: `ProgressBar`'s `onChange` also gets the bar - UI: the `UISkin`, `UILabel`, `Paint` and settings and skin types of every widget are exported; `Container#clipContains(x, y)` tells whether a container shows its children at a point diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index 0343efc92..b545dd1b0 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -630,8 +630,9 @@ pointer and the key. ### A little bounce: `hoverScale` and `pressScale` -Every UI element, buttons, checkboxes, sliders, panels and your own, can -grow a little under the pointer or with the focus, and shrink while held: +Every `UIBaseElement`, buttons, checkboxes, sliders, panels and your own, +can grow a little under the pointer or with the focus, and shrink while held +(`UISpriteElement` and `ProgressBar` are not `UIBaseElement`s, and do not): ```js new UITextButton(0, 0, { text: "Play", hoverScale: 1.05, pressScale: 0.92 }); @@ -665,6 +666,11 @@ Buttons, checkboxes and sliders take part; a widget of your own sets `focusable = true`, and overrides `onNavigate(direction)` (return `true` for what it handles) and `focusRelease()` for what Enter does. +Two things to know. Enter goes to the focused button, so another button +whose `bindKey` is Enter waits while the focus ring shows elsewhere. And +accept calls a subclass's `onClick()` METHOD with no pointer event, as +`bindKey` does: guard `event` there, or act in the `onClick` setting. + ### Keyboard shortcuts on a button: `bindKey` A `UITextButton` can be activated from the keyboard as well as the pointer: diff --git a/packages/melonjs/src/renderable/ui/uibaseelement.ts b/packages/melonjs/src/renderable/ui/uibaseelement.ts index 963e2a178..c6b48ff1f 100644 --- a/packages/melonjs/src/renderable/ui/uibaseelement.ts +++ b/packages/melonjs/src/renderable/ui/uibaseelement.ts @@ -32,8 +32,8 @@ export interface UIJuiceSettings { pressScale?: number; } -/** how fast the bounce scale closes on its target, per millisecond */ -const JUICE_RATE = 1 / 60; +/** the bounce's time constant, in milliseconds: about 0.2 s there */ +const JUICE_TIME = 60; /** * Whether a pointer event is the pointer coming up, rather than leaving or @@ -353,13 +353,16 @@ export default class UIBaseElement extends Container { let target = 1; if (!this.released && this.pressScale !== 1) { target = this.pressScale; - } else if ((this.hover || this.focused) && this._enabled) { + } else if ( + (this.hover || (this.focused && UIFocus.ringVisible)) && + this._enabled + ) { target = this.hoverScale; } if (this._juice === target) { return dirty; } - const step = 1 - Math.exp(-dt * JUICE_RATE * 12); + const step = 1 - Math.exp(-dt / JUICE_TIME); this._juice += (target - this._juice) * step; if (Math.abs(target - this._juice) < 0.001) { this._juice = target; @@ -656,9 +659,8 @@ export default class UIBaseElement extends Container { this.leave(e); }); - if (this.focusable) { - UIFocus._add(this); - } + // every element, so one made focusable later takes part too + UIFocus._add(this); // call the parent function super.onActivateEvent(); diff --git a/packages/melonjs/src/renderable/ui/uifocus.ts b/packages/melonjs/src/renderable/ui/uifocus.ts index dcb9420bb..573b1a1af 100644 --- a/packages/melonjs/src/renderable/ui/uifocus.ts +++ b/packages/melonjs/src/renderable/ui/uifocus.ts @@ -69,8 +69,9 @@ export default class UIFocus { private static _focused: UIBaseElement | undefined; /** whether the ring shows: after keyboard or pad input, until the mouse */ private static _ringVisible = false; - /** the input holding accept down, if any */ + /** the input holding accept down, if any, and the element it pressed */ private static _accept: "key" | "pad" | undefined; + private static _pressed: UIBaseElement | undefined; /** the pad's state on the last frame */ private static readonly _pad = { held: "", accept: false }; @@ -82,6 +83,11 @@ export default class UIFocus { return; } UIFocus._enabled = true; + // a button already held, the A that opened this menu say, is not a + // fresh press + UIFocus._pad.held = ""; + UIFocus._pad.accept = false; + UIFocus.readPad(); on(KEYDOWN, UIFocus._onKeyDown); on(KEYUP, UIFocus._onKeyUp); on(POINTERMOVE, UIFocus._onPointer); @@ -101,6 +107,7 @@ export default class UIFocus { off(POINTERMOVE, UIFocus._onPointer); off(GAME_BEFORE_UPDATE, UIFocus._onFrame); UIFocus.focus(undefined); + UIFocus._ringVisible = false; } /** whether navigation is on */ @@ -131,7 +138,9 @@ export default class UIFocus { if (UIFocus._focused !== undefined) { UIFocus._focused.isDirty = true; } - UIFocus._accept = undefined; + // a press held on the element losing the focus is called off, or it + // stays down with nothing to let it go + UIFocus.cancelAccept(); UIFocus._focused = element; if (element !== undefined) { element.isDirty = true; @@ -148,10 +157,15 @@ export default class UIFocus { static navigate(action: UINavAction): void { UIFocus._ringVisible = true; const focused = UIFocus._focused; - if (focused === undefined || !UIFocus.isCandidate(focused)) { + if (focused === undefined) { UIFocus.focus(UIFocus.first()); return; } + if (!UIFocus.isCandidate(focused)) { + // disabled or hidden since: move on from where it is + UIFocus.focus(UIFocus.next(focused, action) ?? UIFocus.first()); + return; + } if (!focused.onNavigate(action)) { const next = UIFocus.next(focused, action); if (next !== undefined) { @@ -178,23 +192,61 @@ export default class UIFocus { */ static _remove(element: UIBaseElement): void { UIFocus._elements.delete(element); + if (UIFocus._pressed === element) { + UIFocus.cancelAccept(); + } if (UIFocus._focused === element) { UIFocus.focus(undefined); } } + /** + * Whether a key is one that presses the focused element. + * @param keyCode - the key + * @returns true for Enter and Space + * @ignore + * @internal + */ + static _isAcceptKey(keyCode: number): boolean { + return ACCEPT_KEYS.includes(keyCode); + } + + /** + * Call off a press held by accept, without a click. + * @ignore + * @internal + */ + private static cancelAccept(): void { + const pressed = UIFocus._pressed; + UIFocus._accept = undefined; + UIFocus._pressed = undefined; + pressed?.cancelPress(); + } + /** * whether an element can take the focus now * @param element - the element * @returns true when it is focusable, enabled, shown and in the scene */ private static isCandidate(element: UIBaseElement): boolean { - return ( - element.focusable && - element.enabled && - element.isRenderable && - UIFocus._elements.has(element) - ); + if ( + !element.focusable || + !element.enabled || + !UIFocus._elements.has(element) + ) { + return false; + } + // shown, and so is everything holding it: not inside a hidden panel + for ( + let node: Renderable | undefined = element; + node !== undefined && node !== null; + node = node.ancestor as Renderable | undefined + ) { + if (!node.isRenderable || node.alpha <= 0) { + return false; + } + } + return true; } /** @@ -323,6 +375,7 @@ export default class UIFocus { UIFocus.isCandidate(focused) ) { UIFocus._accept = source; + UIFocus._pressed = focused; focused.focusPress(); } } @@ -333,8 +386,10 @@ export default class UIFocus { */ private static acceptUp(source: "key" | "pad"): void { if (UIFocus._accept === source) { + const pressed = UIFocus._pressed; UIFocus._accept = undefined; - UIFocus._focused?.focusRelease(); + UIFocus._pressed = undefined; + pressed?.focusRelease(); } } @@ -367,9 +422,10 @@ export default class UIFocus { }; /** - * read the pads once a frame: a D-pad press moves once, A presses + * Read the D-pad direction and the A button across every pad into + * `_pad`. */ - private static readonly _onFrame = (): void => { + private static readPad(): void { const B = GAMEPAD.BUTTONS; let held = ""; let accept = false; @@ -391,16 +447,24 @@ export default class UIFocus { } accept ||= pressed(B.FACE_1); } - const pad = UIFocus._pad; - if (held !== "" && held !== pad.held) { - UIFocus.navigate(held as UINavAction); + UIFocus._pad.held = held; + UIFocus._pad.accept = accept; + } + + /** + * read the pads once a frame: a D-pad press moves once, A presses + */ + private static readonly _onFrame = (): void => { + const before = { ...UIFocus._pad }; + UIFocus.readPad(); + const now = UIFocus._pad; + if (now.held !== "" && now.held !== before.held) { + UIFocus.navigate(now.held as UINavAction); } - pad.held = held; - if (accept && !pad.accept) { + if (now.accept && !before.accept) { UIFocus.acceptDown("pad"); - } else if (!accept && pad.accept) { + } else if (!now.accept && before.accept) { UIFocus.acceptUp("pad"); } - pad.accept = accept; }; } diff --git a/packages/melonjs/src/renderable/ui/uitextbutton.ts b/packages/melonjs/src/renderable/ui/uitextbutton.ts index 7957eb09c..8485947d4 100644 --- a/packages/melonjs/src/renderable/ui/uitextbutton.ts +++ b/packages/melonjs/src/renderable/ui/uitextbutton.ts @@ -10,6 +10,7 @@ import Text from "../text/text.js"; import type { Paint } from "./panelpaint.ts"; import type { UIJuiceSettings } from "./uibaseelement.ts"; import UIBaseElement, { isPointerUp } from "./uibaseelement.ts"; +import UIFocus from "./uifocus.ts"; import type { UILabel } from "./uilabel.ts"; import type { UISkin } from "./uiskin.ts"; import { SkinDrawer } from "./uiskin.ts"; @@ -208,6 +209,16 @@ export default class UITextButton extends UIBaseElement { ) { return; } + // Enter on another button that shows the focus is that button's: an + // OK bound to Enter does not fire with a focused Cancel + if ( + UIFocus.ringVisible && + UIFocus.focused !== undefined && + UIFocus.focused !== this && + UIFocus._isAcceptKey(keyCode) + ) { + return; + } this.keyboardKey = keyCode; this.released = false; this.isDirty = true; diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js index 12f7bd049..746999863 100644 --- a/packages/melonjs/tests/ui-widgets.spec.js +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -1201,6 +1201,25 @@ describe("UI widgets", () => { pointer("pointerup", 150, 120); }); + it("eases, rather than snapping in a frame", () => { + const b = place( + new UITextButton(100, 100, { + text: "OK", + width: 100, + height: 40, + hoverScale: 1.2, + }), + ); + const scale = vi.spyOn(app.renderer, "scale"); + pointer("pointermove", 150, 120); + b.update(16); + b.preDraw(app.renderer); + b.postDraw(app.renderer); + const first = scale.mock.calls.at(-1)[0]; + expect(first).toBeGreaterThan(1); + expect(first).toBeLessThan(1.1); + }); + it("scales only the drawing: the hit area stays", () => { const b = place( new UITextButton(100, 100, { @@ -1395,6 +1414,103 @@ describe("UI widgets", () => { void right; }); + it("a press held while the focus moves is called off, not left down", () => { + const { a, b } = column(); + const onClick = vi.fn(); + a.onClickCallback = onClick; + UIFocus.focus(a); + key(input.KEY.ENTER); + press(input.KEY.DOWN); + key(input.KEY.ENTER, true); + expect(UIFocus.focused).toBe(b); + expect(a.released).toBe(true); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("a focused widget only bounces while the ring shows", () => { + const { a } = column(); + a.hoverScale = 1.2; + const scale = vi.spyOn(app.renderer, "scale"); + UIFocus.focus(a); + pointer("pointermove", 700, 500); + for (let i = 0; i < 30; i++) { + a.update(16); + } + a.preDraw(app.renderer); + a.postDraw(app.renderer); + expect(scale).not.toHaveBeenCalled(); + }); + + it("Enter goes to the focused button, not another bound to Enter", () => { + const { a, b } = column(); + const ok = vi.fn(); + const cancel = vi.fn(); + a.bindKey = input.KEY.ENTER; + a.onClickCallback = ok; + b.onClickCallback = cancel; + UIFocus.focus(b); + press(input.KEY.UP); + press(input.KEY.DOWN); + press(input.KEY.ENTER); + expect(cancel).toHaveBeenCalledTimes(1); + expect(ok).not.toHaveBeenCalled(); + }); + + it("a pad's A already down when enabled is not a press", () => { + const { a } = column(); + const onClick = vi.fn(); + a.onClickCallback = onClick; + const buttons = []; + for (let i = 0; i < 17; i++) { + buttons.push({ pressed: false, value: 0 }); + } + const pad = { id: "held pad", buttons, axes: [0, 0, 0, 0] }; + input.addVirtualGamepad(pad, 0); + try { + UIFocus.disable(); + buttons[input.GAMEPAD.BUTTONS.FACE_1].pressed = true; + UIFocus.enable(); + UIFocus.focus(a); + event.emit(event.GAME_BEFORE_UPDATE, 1); + buttons[input.GAMEPAD.BUTTONS.FACE_1].pressed = false; + event.emit(event.GAME_BEFORE_UPDATE, 2); + expect(onClick).not.toHaveBeenCalled(); + } finally { + input.removeVirtualGamepad(pad); + } + }); + + it("takes a widget made focusable after it was added", () => { + const panel = place(new UIPanel(100, 100, { width: 200, height: 100 })); + const own = panel.addChild( + new UIPanel(10, 10, { width: 50, height: 30 }), + ); + own.focusable = true; + press(input.KEY.DOWN); + expect(UIFocus.focused).toBe(own); + }); + + it("does not go into a hidden panel", () => { + const { a } = column(); + const hidden = place(new UIPanel(100, 450, { width: 200, height: 60 })); + hidden.addChild(new UITextButton(0, 0, { text: "x" })); + hidden.isRenderable = false; + UIFocus.focus(a); + for (let i = 0; i < 5; i++) { + press(input.KEY.DOWN); + } + expect(UIFocus.focused?.ancestor).not.toBe(hidden); + }); + + it("moves on from a focused widget that was disabled since", () => { + const { a, b, c } = column(); + UIFocus.focus(b); + b.enabled = false; + press(input.KEY.DOWN); + expect(UIFocus.focused).toBe(c); + void a; + }); + it("is off until enabled, and drops the focus when disabled", () => { const { a } = column(); UIFocus.focus(a); From 5291863eb83d6472443d64a80a57d97d2294dd44 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 06:37:51 +0000 Subject: [PATCH 14/43] UI: focus lives on the widgets, with no focus manager to set up UIFocus is gone. A widget takes the keyboard and gamepad focus with focus(); from then on the arrows or the D-pad move it, Enter, Space or A press it, sliders step. Nothing listens until a widget has the focus, and the listeners go when none does (blur(), or the focused widget leaving the scene), so the keys and the pad are the game's until then. On UIBaseElement: focus(), blur(), focused, focusable, onNavigate, the static focusOwner and focusRingColor. The example focuses its first widget. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- .../examples/src/examples/ui/ExampleUI.tsx | 12 +- packages/melonjs/CHANGELOG.md | 2 +- .../skills/melonjs-ui-and-text/SKILL.md | 30 +- packages/melonjs/src/index.ts | 2 - .../src/renderable/ui/uibaseelement.ts | 70 +- packages/melonjs/src/renderable/ui/uifocus.ts | 740 +++++++++--------- .../melonjs/src/renderable/ui/uitextbutton.ts | 10 +- packages/melonjs/tests/ui-widgets.spec.js | 120 ++- packages/melonjs/tests/zz-probe-tmp.spec.js | 523 +++++++++++++ 9 files changed, 1020 insertions(+), 489 deletions(-) create mode 100644 packages/melonjs/tests/zz-probe-tmp.spec.js diff --git a/packages/examples/src/examples/ui/ExampleUI.tsx b/packages/examples/src/examples/ui/ExampleUI.tsx index 9fde10821..934a9f431 100644 --- a/packages/examples/src/examples/ui/ExampleUI.tsx +++ b/packages/examples/src/examples/ui/ExampleUI.tsx @@ -16,7 +16,6 @@ import { Text, TextureAtlas, UICheckbox, - UIFocus, UIPanel, UISlider, UITextButton, @@ -195,13 +194,13 @@ class PlayScreen extends Stage { const background = new ColorLayer("background", "rgba(248, 194, 40, 1.0)"); app.world.addChild(background, 0); - this.addOptions(app, background); + const first = this.addOptions(app, background); this.addBars(app); this.addLevels(app); - // the arrow keys or a pad's D-pad move between the widgets, Enter or - // the pad's A button acts on the one with the focus - UIFocus.enable(); + // keyboard and gamepad play: with the focus on a widget, the arrows or + // a pad's D-pad move between them, Enter or the pad's A press one + first.focus(); app.world.addChild( new Text(20, 575, { font: "kenpixel", @@ -220,6 +219,7 @@ class PlayScreen extends Stage { * out as a column by the panel itself. * @param app - the application * @param background - the backdrop the video button recolours + * @returns the first widget, where the keyboard focus starts */ private addOptions(app: Application, background: ColorLayer) { const frame = framed(20, 20, 380, 100, "OPTIONS"); @@ -303,6 +303,7 @@ class PlayScreen extends Stage { panel.addChild(video); panel.addChild(actions); app.world.addChild(panel, 1); + return music; } /** @@ -501,7 +502,6 @@ class PlayScreen extends Stage { */ override onDestroyEvent() { audio.stopTrack(); - UIFocus.disable(); } } diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 29e2377cd..fa37f15a7 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -10,7 +10,7 @@ - UI: `UITextButton` takes an `onClick` setting, called on a completed click; a `skin` of atlas regions, with pressed art drawn lowered; `pressedColor`; and the names the other widgets use: `width` / `height` for its size, `fontSize` (pixels, also with a bitmap font), `labelFillStyle` and `borderColor`. `resize()` keeps its background and label in step. A font that is not a loaded bitmap font is drawn as a CSS font, where the button used to throw - UI: `enabled` on every `UIBaseElement`, to turn a widget off; `cancelPress()` and `onCancel()`, to call off a press without a release, as a scrolling `UIPanel` does when a drag on a button turns into a scroll; and `keepsDrag`, for a widget of your own that is dragged inside a scrolling panel - UI: a drop shadow under a `UIPanel` or a `ProgressBar`: `shadowColor`, `shadowOffsetX`, `shadowOffsetY` and `shadowBlur`, softened the same on Canvas, WebGL and WebGPU, rounded with the corners, and under a skin too -- UI: keyboard and gamepad navigation, `UIFocus` (off until `UIFocus.enable()`): the arrows or the D-pad move the focus to the nearest widget, Enter or A press it as a click does, left and right step a slider; a ring shows the focused widget once a key or the pad is used, and a focused widget in a scrolling panel is scrolled into view. A widget of your own takes part through `focusable` and `onNavigate` +- UI: keyboard and gamepad navigation for a game played without a mouse or a touch screen: once a widget takes the focus with `focus()`, the arrows or the D-pad move it to the nearest widget, Enter, Space or A press it as a click does, and left and right step a slider. A ring shows the focus while the keyboard or a pad is used, and a focused widget in a scrolling panel is scrolled into view. Until a widget has the focus the keys and the pad are left to the game; `blur()` gives them back. `focusable`, `focused`, `onNavigate`, `UIBaseElement.focusOwner` and `UIBaseElement.focusRingColor` - UI: `hoverScale` and `pressScale` on every `UIBaseElement`: a bounce under the pointer or the focus, and while held, easing by itself and scaling only the drawing, not the hit area or the layout - UI: `ProgressBar`'s `onChange` also gets the bar - UI: the `UISkin`, `UILabel`, `Paint` and settings and skin types of every widget are exported; `Container#clipContains(x, y)` tells whether a container shows its children at a point diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index b545dd1b0..89ef6fbf7 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -643,26 +643,28 @@ Both default to 1, which is still. The scale eases there and back by itself. Only the drawing scales, about the centre: the hit area and the layout stay put, so nothing jitters and the widgets around it do not move. -### Keyboard and gamepad navigation: `UIFocus` +### Keyboard and gamepad navigation: `focus()` -For a game played without a mouse or a touch screen: +For a game played without a mouse or a touch screen, give a widget the focus: ```js -UIFocus.enable(); // in the stage's onResetEvent -// ...and UIFocus.disable() in its onDestroyEvent -UIFocus.focus(playButton); // optional: where to start +playButton.focus(); // where keyboard or gamepad play starts ``` -The arrow keys or a pad's D-pad move the focus to the nearest widget that -way. Enter, Space or the pad's A press the focused widget as a click does: a -button calls its `onClick` setting, a checkbox toggles. Left and right step a -slider (up and down on a vertical one). A ring (`UIFocus.ringColor`) shows the -focused widget once a key or the pad is used, and the mouse moving hides it. -A focused widget in a scrolling panel is scrolled into view. +From then on the arrow keys or a pad's D-pad move the focus to the nearest +focusable widget that way. Enter, Space or the pad's A press the focused +widget as a click does: a button calls its `onClick` setting, a checkbox +toggles. Left and right step a slider (up and down on a vertical one). A ring +(`UIBaseElement.focusRingColor`) shows the focus, and hides while the mouse +is used. A focused widget in a scrolling panel is scrolled into view. -It is off until enabled, so a game steering with the arrows is not surprised, -and it reads the pad directly, so the game's own pad bindings are untouched. -Buttons, checkboxes and sliders take part; a widget of your own sets +There is nothing to turn on. Until a widget has the focus the arrow keys and +the pad are left to the game, and `widget.blur()`, or the focused widget +leaving the scene, gives them back. `UIBaseElement.focusOwner` is the widget +with the focus. The pad is read directly, so the game's own pad bindings +are untouched. + +Buttons, checkboxes and sliders are `focusable`. A widget of your own sets `focusable = true`, and overrides `onNavigate(direction)` (return `true` for what it handles) and `focusRelease()` for what Enter does. diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index 46f8a5477..535a96656 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -56,7 +56,6 @@ import Trigger from "./renderable/trigger.js"; import ProgressBar from "./renderable/ui/progressbar.ts"; import UIBaseElement from "./renderable/ui/uibaseelement.ts"; import UICheckbox from "./renderable/ui/uicheckbox.ts"; -import UIFocus from "./renderable/ui/uifocus.ts"; import UIPanel from "./renderable/ui/uipanel.ts"; import UISlider from "./renderable/ui/uislider.ts"; import UISpriteElement from "./renderable/ui/uispriteelement.ts"; @@ -338,7 +337,6 @@ export { timer, UIBaseElement, UICheckbox, - UIFocus, UIPanel, UISlider, UISpriteElement, diff --git a/packages/melonjs/src/renderable/ui/uibaseelement.ts b/packages/melonjs/src/renderable/ui/uibaseelement.ts index c6b48ff1f..9332752ce 100644 --- a/packages/melonjs/src/renderable/ui/uibaseelement.ts +++ b/packages/melonjs/src/renderable/ui/uibaseelement.ts @@ -12,7 +12,13 @@ import type Renderer from "../../video/renderer.js"; import Container from "../container.js"; import { PanelPainter } from "./panelpaint.ts"; import type { UINavAction } from "./uifocus.ts"; -import UIFocus from "./uifocus.ts"; +import { + focusOwner, + ringVisible, + setFocus, + trackElement, + untrackElement, +} from "./uifocus.ts"; /** draws the focus ring of whichever element has the focus */ const ringPainter = new PanelPainter(); @@ -294,15 +300,61 @@ export default class UIBaseElement extends Container { private _juice = 1; /** - * Whether keyboard and gamepad navigation can stop on this element: see - * {@link UIFocus}. Buttons, checkboxes and sliders set it. + * Whether this element can take the keyboard and gamepad focus: see + * {@link UIBaseElement#focus}. Buttons, checkboxes and sliders set it. * @default false */ focusable = false; + /** + * The colour of the ring drawn around the element with the focus. + * @default "#ffcc33" + */ + static focusRingColor = "#ffcc33"; + + /** + * The element with the keyboard and gamepad focus, if any. + */ + static get focusOwner(): UIBaseElement | undefined { + return focusOwner(); + } + /** whether this element has the keyboard and gamepad focus */ get focused(): boolean { - return UIFocus.focused === this; + return focusOwner() === this; + } + + /** + * Take the keyboard and gamepad focus, for a game played without a mouse + * or a touch screen. + * + * Nothing has the focus until a game gives it to an element, and until + * then the arrow keys and the pad are left alone. From then on the arrows + * or a pad's D-pad move the focus to the nearest focusable element that + * way, and Enter, Space or the pad's A press the focused one as a click + * does; a slider steps on left and right instead. A ring shows the focus, + * and hides while the mouse is used. A focused element in a scrolling + * panel is scrolled into view. + * + * Does nothing on an element that is not `focusable`. + * @example + * // a menu played with the keyboard or a pad: start on Play + * playButton.focus(); + */ + focus(): void { + if (this.focusable) { + setFocus(this); + } + } + + /** + * Give up the focus, if this element has it. With nothing focused, the + * arrow keys and the pad are left to the game again. + */ + blur(): void { + if (this.focused) { + setFocus(undefined); + } } /** @@ -354,7 +406,7 @@ export default class UIBaseElement extends Container { if (!this.released && this.pressScale !== 1) { target = this.pressScale; } else if ( - (this.hover || (this.focused && UIFocus.ringVisible)) && + (this.hover || (this.focused && ringVisible())) && this._enabled ) { target = this.hoverScale; @@ -387,11 +439,11 @@ export default class UIBaseElement extends Container { renderer.scale(this._juice, this._juice); renderer.translate(-cx, -cy); } - if (UIFocus.ringVisible && UIFocus.focused === this) { + if (ringVisible() && focusOwner() === this) { // 3 pixels wide, 3 pixels out from the widget const pad = 6; const alpha = renderer.globalAlpha(); - ringPainter.paint(renderer, UIFocus.ringColor, alpha); + ringPainter.paint(renderer, UIBaseElement.focusRingColor, alpha); ringPainter.border( renderer, this.pos.x - pad, @@ -660,7 +712,7 @@ export default class UIBaseElement extends Container { }); // every element, so one made focusable later takes part too - UIFocus._add(this); + trackElement(this); // call the parent function super.onActivateEvent(); @@ -686,7 +738,7 @@ export default class UIBaseElement extends Container { // the object is being remove from his parent // container before the leave function is called this.stopListeningForDrag(); - UIFocus._remove(this); + untrackElement(this); // call the parent function super.onDeactivateEvent(); diff --git a/packages/melonjs/src/renderable/ui/uifocus.ts b/packages/melonjs/src/renderable/ui/uifocus.ts index 573b1a1af..8cb383540 100644 --- a/packages/melonjs/src/renderable/ui/uifocus.ts +++ b/packages/melonjs/src/renderable/ui/uifocus.ts @@ -17,6 +17,14 @@ import type UIBaseElement from "./uibaseelement.ts"; */ export type UINavAction = "up" | "down" | "left" | "right"; +/* + * The keyboard and gamepad focus of UI elements, behind + * `UIBaseElement#focus()`. Internal: a game only ever sees the methods on + * the elements. Nothing listens until an element takes the focus, and the + * listeners go when none has it, so a game that never calls `focus()` pays + * nothing and finds its arrow keys untouched. + */ + /** the directions, as unit steps */ const DIRECTIONS: Record = { up: [0, -1], @@ -33,438 +41,398 @@ const KEYS: Record = { right: KEY.RIGHT, }; -/** the keys that press the focused widget */ +/** the keys that press the focused element */ const ACCEPT_KEYS: readonly number[] = [KEY.ENTER, KEY.SPACE]; +/** the elements in the scene */ +const elements = new Set(); +/** the element with the focus */ +let owner: UIBaseElement | undefined; +/** whether the ring shows: after a key, the pad or `focus()`, until the mouse */ +let ringShown = false; +/** whether the listeners are on */ +let listening = false; +/** the input holding accept down, if any, and the element it pressed */ +let accepting: "key" | "pad" | undefined; +let pressed: UIBaseElement | undefined; +/** the pads' D-pad direction and A button on the last frame */ +const pad = { held: "", accept: false }; + /** - * Keyboard and gamepad navigation for UI elements, for a game played - * without a mouse or a touch screen. - * - * One widget has the focus. The arrow keys or a pad's D-pad move it to the - * nearest widget in that direction, Enter, Space or the pad's A button press - * it as a click does, and a slider steps on left and right. A ring shows - * which widget has the focus once a key or the pad is used, and the mouse - * moving hides it. A focused widget in a scrolling panel is scrolled into - * view. - * - * Off until {@link UIFocus.enable} is called, so a game that steers with the - * arrow keys is not surprised. Buttons, checkboxes and sliders take part; a - * widget of your own sets `focusable`. - * @category UI - * @example - * UIFocus.enable(); // in the stage's onResetEvent - * UIFocus.disable(); // and in its onDestroyEvent + * The element with the focus, if any. + * @returns the focus owner + * @ignore + * @internal */ -// one focus per page: a class of statics, documented as one API like `state` -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export default class UIFocus { - /** the colour of the ring around the focused widget */ - static ringColor = "#ffcc33"; +export function focusOwner(): UIBaseElement | undefined { + return owner; +} - /** whether navigation is on */ - private static _enabled = false; - /** the focusable elements in the scene */ - private static readonly _elements = new Set(); - /** the focused element */ - private static _focused: UIBaseElement | undefined; - /** whether the ring shows: after keyboard or pad input, until the mouse */ - private static _ringVisible = false; - /** the input holding accept down, if any, and the element it pressed */ - private static _accept: "key" | "pad" | undefined; - private static _pressed: UIBaseElement | undefined; - /** the pad's state on the last frame */ - private static readonly _pad = { held: "", accept: false }; +/** + * Whether the focus ring shows. + * @returns true after keyboard or pad input, until the mouse moves + * @ignore + * @internal + */ +export function ringVisible(): boolean { + return ringShown; +} - /** - * Turn keyboard and gamepad navigation on. - */ - static enable(): void { - if (UIFocus._enabled) { - return; - } - UIFocus._enabled = true; - // a button already held, the A that opened this menu say, is not a - // fresh press - UIFocus._pad.held = ""; - UIFocus._pad.accept = false; - UIFocus.readPad(); - on(KEYDOWN, UIFocus._onKeyDown); - on(KEYUP, UIFocus._onKeyUp); - on(POINTERMOVE, UIFocus._onPointer); - on(GAME_BEFORE_UPDATE, UIFocus._onFrame); - } +/** + * Whether a key presses the focused element. + * @param keyCode - the key + * @returns true for Enter and Space + * @ignore + * @internal + */ +export function isAcceptKey(keyCode: number): boolean { + return ACCEPT_KEYS.includes(keyCode); +} - /** - * Turn it off again, dropping the focus. - */ - static disable(): void { - if (!UIFocus._enabled) { - return; - } - UIFocus._enabled = false; - off(KEYDOWN, UIFocus._onKeyDown); - off(KEYUP, UIFocus._onKeyUp); - off(POINTERMOVE, UIFocus._onPointer); - off(GAME_BEFORE_UPDATE, UIFocus._onFrame); - UIFocus.focus(undefined); - UIFocus._ringVisible = false; +/** + * Give an element the focus, or take it away with `undefined`. The ring + * shows, and a scrolling panel holding the element scrolls it into view. + * @param element - the element + * @ignore + * @internal + */ +export function setFocus(element: UIBaseElement | undefined): void { + if (element === owner) { + ringShown = element !== undefined; + return; } - - /** whether navigation is on */ - static get enabled(): boolean { - return UIFocus._enabled; + if (owner !== undefined) { + owner.isDirty = true; } - - /** the element with the focus, if any */ - static get focused(): UIBaseElement | undefined { - return UIFocus._focused; + // a press held on the element losing the focus is called off, or it + // stays down with nothing to let it go + cancelAccept(); + owner = element; + if (element !== undefined) { + element.isDirty = true; + ringShown = true; + scrollIntoView(element); + listen(true); + } else { + ringShown = false; + listen(false); } +} - /** whether the focus ring is showing */ - static get ringVisible(): boolean { - return UIFocus._ringVisible; - } +/** + * Track an element joining the scene. + * @param element - the element + * @ignore + * @internal + */ +export function trackElement(element: UIBaseElement): void { + elements.add(element); +} - /** - * Give an element the focus, or take it away with `undefined`: to choose - * where navigation starts, say. A scrolling panel holding the element - * scrolls it into view. - * @param element - the element to focus - */ - static focus(element: UIBaseElement | undefined): void { - if (UIFocus._focused === element) { - return; - } - if (UIFocus._focused !== undefined) { - UIFocus._focused.isDirty = true; - } - // a press held on the element losing the focus is called off, or it - // stays down with nothing to let it go - UIFocus.cancelAccept(); - UIFocus._focused = element; - if (element !== undefined) { - element.isDirty = true; - UIFocus.scrollIntoView(element); - } +/** + * Track an element leaving the scene; it loses the focus. + * @param element - the element + * @ignore + * @internal + */ +export function untrackElement(element: UIBaseElement): void { + elements.delete(element); + if (pressed === element) { + cancelAccept(); } - - /** - * Move the focus as an arrow key would, for input of the game's own. The - * focused widget is asked first, through `onNavigate`, so a slider steps - * instead. With nothing focused, the first widget takes the focus. - * @param action - which way - */ - static navigate(action: UINavAction): void { - UIFocus._ringVisible = true; - const focused = UIFocus._focused; - if (focused === undefined) { - UIFocus.focus(UIFocus.first()); - return; - } - if (!UIFocus.isCandidate(focused)) { - // disabled or hidden since: move on from where it is - UIFocus.focus(UIFocus.next(focused, action) ?? UIFocus.first()); - return; - } - if (!focused.onNavigate(action)) { - const next = UIFocus.next(focused, action); - if (next !== undefined) { - UIFocus.focus(next); - } - } + if (owner === element) { + setFocus(undefined); } +} - /** - * Track an element joining the scene. - * @param element - the element - * @ignore - * @internal - */ - static _add(element: UIBaseElement): void { - UIFocus._elements.add(element); +/** + * Move the focus as an arrow key would. The focused element is asked + * first, through `onNavigate`, so a slider steps instead. + * @param action - which way + * @ignore + * @internal + */ +export function navigate(action: UINavAction): void { + const from = owner; + if (from === undefined) { + return; } - - /** - * Track an element leaving the scene; it loses the focus. - * @param element - the element - * @ignore - * @internal - */ - static _remove(element: UIBaseElement): void { - UIFocus._elements.delete(element); - if (UIFocus._pressed === element) { - UIFocus.cancelAccept(); - } - if (UIFocus._focused === element) { - UIFocus.focus(undefined); + ringShown = true; + if (!isCandidate(from)) { + // disabled or hidden since: move on from where it is + setFocus(next(from, action) ?? first()); + return; + } + if (!from.onNavigate(action)) { + const to = next(from, action); + if (to !== undefined) { + setFocus(to); } } +} - /** - * Whether a key is one that presses the focused element. - * @param keyCode - the key - * @returns true for Enter and Space - * @ignore - * @internal - */ - static _isAcceptKey(keyCode: number): boolean { - return ACCEPT_KEYS.includes(keyCode); +/** + * turn the key, pointer and pad listeners on or off + * @param value - on or off + */ +function listen(value: boolean): void { + if (value === listening) { + return; } - - /** - * Call off a press held by accept, without a click. - * @ignore - * @internal - */ - private static cancelAccept(): void { - const pressed = UIFocus._pressed; - UIFocus._accept = undefined; - UIFocus._pressed = undefined; - pressed?.cancelPress(); + listening = value; + if (value) { + // a button already held, the A that opened this menu say, is not a + // fresh press + readPad(); + on(KEYDOWN, onKeyDown); + on(KEYUP, onKeyUp); + on(POINTERMOVE, onPointer); + on(GAME_BEFORE_UPDATE, onFrame); + } else { + off(KEYDOWN, onKeyDown); + off(KEYUP, onKeyUp); + off(POINTERMOVE, onPointer); + off(GAME_BEFORE_UPDATE, onFrame); } +} - /** - * whether an element can take the focus now - * @param element - the element - * @returns true when it is focusable, enabled, shown and in the scene - */ - private static isCandidate(element: UIBaseElement): boolean { - if ( - !element.focusable || - !element.enabled || - !UIFocus._elements.has(element) - ) { +/** call off a press held by accept, without a click */ +function cancelAccept(): void { + const element = pressed; + accepting = undefined; + pressed = undefined; + element?.cancelPress(); +} + +/** + * whether an element can take the focus now + * @param element - the element + * @returns true when focusable, enabled, in the scene, and shown along with + * everything holding it + */ +function isCandidate(element: UIBaseElement): boolean { + if (!element.focusable || !element.enabled || !elements.has(element)) { + return false; + } + for ( + let node: Renderable | undefined = element; + node !== undefined && node !== null; + node = node.ancestor as Renderable | undefined + ) { + if (!node.isRenderable || node.alpha <= 0) { return false; } - // shown, and so is everything holding it: not inside a hidden panel - for ( - let node: Renderable | undefined = element; - node !== undefined && node !== null; - node = node.ancestor as Renderable | undefined - ) { - if (!node.isRenderable || node.alpha <= 0) { - return false; - } - } - return true; } + return true; +} - /** - * the elements that can take the focus now - * @returns them, in no particular order - */ - private static candidates(): UIBaseElement[] { - return [...UIFocus._elements].filter((element) => { - return UIFocus.isCandidate(element); - }); - } +/** + * the elements that can take the focus now + * @returns them, in no particular order + */ +function candidates(): UIBaseElement[] { + return [...elements].filter((element) => { + return isCandidate(element); + }); +} - /** - * Where the focus starts: the first in reading order, row by row, left - * to right. Two widgets whose tops are within half a height are on the - * same row, so one a few pixels higher on the right does not come first. - * @returns the element, or undefined when there is none - */ - private static first(): UIBaseElement | undefined { - let first: UIBaseElement | undefined; - for (const candidate of UIFocus.candidates()) { - if (first === undefined) { - first = candidate; - continue; - } - const a = candidate.getBounds(); - const b = first.getBounds(); - const sameRow = - Math.abs(a.top - b.top) < Math.min(a.height, b.height) / 2; - if (sameRow ? a.left < b.left : a.top < b.top) { - first = candidate; - } +/** + * The first candidate in reading order, row by row, left to right; two + * whose tops are within half a height are on the same row. + * @returns the element, or undefined when there is none + */ +function first(): UIBaseElement | undefined { + let best: UIBaseElement | undefined; + for (const candidate of candidates()) { + if (best === undefined) { + best = candidate; + continue; } - return first; - } - - /** - * The nearest candidate in a direction, edge to edge, a step sideways - * counting double, so a narrow widget right below a wide one beats a - * wide one further down. - * @param from - the focused element - * @param direction - which way - * @returns the next element, or undefined when there is none that way - */ - private static next( - from: UIBaseElement, - direction: UINavAction, - ): UIBaseElement | undefined { - const [dx, dy] = DIRECTIONS[direction]; - const origin = from.getBounds(); - let best: UIBaseElement | undefined; - let bestScore = Infinity; - for (const candidate of UIFocus.candidates()) { - if (candidate === from) { - continue; - } - const bounds = candidate.getBounds(); - // past the focused one, centre to centre, that way - const ahead = - (bounds.centerX - origin.centerX) * dx + - (bounds.centerY - origin.centerY) * dy; - if (ahead <= 1) { - continue; - } - const gap = - dx !== 0 - ? Math.max( - 0, - dx > 0 ? bounds.left - origin.right : origin.left - bounds.right, - ) - : Math.max( - 0, - dy > 0 ? bounds.top - origin.bottom : origin.top - bounds.bottom, - ); - const side = - dx !== 0 - ? Math.max(0, bounds.top - origin.bottom, origin.top - bounds.bottom) - : Math.max(0, bounds.left - origin.right, origin.left - bounds.right); - // the centres break a tie - const score = gap + side * 2 + ahead * 0.01; - if (score < bestScore) { - bestScore = score; - best = candidate; - } + const a = candidate.getBounds(); + const b = best.getBounds(); + const sameRow = Math.abs(a.top - b.top) < Math.min(a.height, b.height) / 2; + if (sameRow ? a.left < b.left : a.top < b.top) { + best = candidate; } - return best; } + return best; +} - /** - * scroll each scrolling panel holding an element to show it, with room - * for the ring - * @param element - the element - */ - private static scrollIntoView(element: UIBaseElement): void { - let child: Renderable = element; - for ( - let node = element.ancestor as Renderable | undefined; - node !== undefined && node !== null; - node = node.ancestor as Renderable | undefined - ) { - const panel = node as Renderable & { - scroll?: string; - scrollToChild?: (child: Renderable, margin?: number) => unknown; - }; - if ( - panel.scroll !== undefined && - panel.scroll !== "none" && - typeof panel.scrollToChild === "function" - ) { - panel.scrollToChild(child, 10); - } - child = node; +/** + * The nearest candidate in a direction, edge to edge, a step sideways + * counting double, so a narrow widget right below a wide one beats a wide + * one further down. + * @param from - the focused element + * @param direction - which way + * @returns the next element, or undefined when there is none that way + */ +function next( + from: UIBaseElement, + direction: UINavAction, +): UIBaseElement | undefined { + const [dx, dy] = DIRECTIONS[direction]; + const origin = from.getBounds(); + let best: UIBaseElement | undefined; + let bestScore = Infinity; + for (const candidate of candidates()) { + if (candidate === from) { + continue; + } + const bounds = candidate.getBounds(); + // past the focused one, centre to centre, that way + const ahead = + (bounds.centerX - origin.centerX) * dx + + (bounds.centerY - origin.centerY) * dy; + if (ahead <= 1) { + continue; + } + const gap = + dx !== 0 + ? Math.max( + 0, + dx > 0 ? bounds.left - origin.right : origin.left - bounds.right, + ) + : Math.max( + 0, + dy > 0 ? bounds.top - origin.bottom : origin.top - bounds.bottom, + ); + const side = + dx !== 0 + ? Math.max(0, bounds.top - origin.bottom, origin.top - bounds.bottom) + : Math.max(0, bounds.left - origin.right, origin.left - bounds.right); + // the centres break a tie + const score = gap + side * 2 + ahead * 0.01; + if (score < bestScore) { + bestScore = score; + best = candidate; } } + return best; +} - /** - * press accept on the focused element: it goes down, as under a pointer - * @param source - the input pressing it, so only that one releases it - */ - private static acceptDown(source: "key" | "pad"): void { - UIFocus._ringVisible = true; - const focused = UIFocus._focused; +/** + * scroll each scrolling panel holding an element to show it, with room for + * the ring + * @param element - the element + */ +function scrollIntoView(element: UIBaseElement): void { + let child: Renderable = element; + for ( + let node = element.ancestor as Renderable | undefined; + node !== undefined && node !== null; + node = node.ancestor as Renderable | undefined + ) { + const panel = node as Renderable & { + scroll?: string; + scrollToChild?: (child: Renderable, margin?: number) => unknown; + }; if ( - UIFocus._accept === undefined && - focused !== undefined && - UIFocus.isCandidate(focused) + panel.scroll !== undefined && + panel.scroll !== "none" && + typeof panel.scrollToChild === "function" ) { - UIFocus._accept = source; - UIFocus._pressed = focused; - focused.focusPress(); + panel.scrollToChild(child, 10); } + child = node; } +} - /** - * release accept: the widget comes up and acts, as on a click - * @param source - the input letting it go - */ - private static acceptUp(source: "key" | "pad"): void { - if (UIFocus._accept === source) { - const pressed = UIFocus._pressed; - UIFocus._accept = undefined; - UIFocus._pressed = undefined; - pressed?.focusRelease(); - } +/** + * press accept on the focused element: it goes down, as under a pointer + * @param source - the input pressing it, so only that one releases it + */ +function acceptDown(source: "key" | "pad"): void { + ringShown = true; + if (accepting === undefined && owner !== undefined && isCandidate(owner)) { + accepting = source; + pressed = owner; + owner.focusPress(); } +} - private static readonly _onKeyDown = ( - _action: string | undefined, - keyCode: number, - ): void => { - if (ACCEPT_KEYS.includes(keyCode)) { - UIFocus.acceptDown("key"); - return; - } - for (const action of Object.keys(KEYS) as UINavAction[]) { - if (KEYS[action] === keyCode) { - UIFocus.navigate(action); - } - } - }; +/** + * release accept: the element comes up and acts, as on a click + * @param source - the input letting it go + */ +function acceptUp(source: "key" | "pad"): void { + if (accepting === source) { + const element = pressed; + accepting = undefined; + pressed = undefined; + element?.focusRelease(); + } +} - private static readonly _onKeyUp = ( - _action: string | undefined, - keyCode: number, - ): void => { - if (ACCEPT_KEYS.includes(keyCode)) { - UIFocus.acceptUp("key"); +/** + * a key down + * @param _action - the action bound to it, unused + * @param keyCode - the key + */ +function onKeyDown(_action: string | undefined, keyCode: number): void { + if (isAcceptKey(keyCode)) { + acceptDown("key"); + return; + } + for (const action of Object.keys(KEYS) as UINavAction[]) { + if (KEYS[action] === keyCode) { + navigate(action); } - }; - - private static readonly _onPointer = (): void => { - UIFocus._ringVisible = false; - }; + } +} - /** - * Read the D-pad direction and the A button across every pad into - * `_pad`. - */ - private static readPad(): void { - const B = GAMEPAD.BUTTONS; - let held = ""; - let accept = false; - for (const pad of readGamepads()) { - if (pad === null) { - continue; - } - const pressed = (code: number) => { - return pad.buttons[code]?.pressed ?? false; - }; - if (pressed(B.UP)) { - held = "up"; - } else if (pressed(B.DOWN)) { - held = "down"; - } else if (pressed(B.LEFT)) { - held = "left"; - } else if (pressed(B.RIGHT)) { - held = "right"; - } - accept ||= pressed(B.FACE_1); - } - UIFocus._pad.held = held; - UIFocus._pad.accept = accept; +/** + * a key up + * @param _action - the action bound to it, unused + * @param keyCode - the key + */ +function onKeyUp(_action: string | undefined, keyCode: number): void { + if (isAcceptKey(keyCode)) { + acceptUp("key"); } +} + +/** the mouse moved: the ring hides until the next key */ +function onPointer(): void { + ringShown = false; +} - /** - * read the pads once a frame: a D-pad press moves once, A presses - */ - private static readonly _onFrame = (): void => { - const before = { ...UIFocus._pad }; - UIFocus.readPad(); - const now = UIFocus._pad; - if (now.held !== "" && now.held !== before.held) { - UIFocus.navigate(now.held as UINavAction); +/** read the D-pad direction and the A button, across every pad */ +function readPad(): void { + const B = GAMEPAD.BUTTONS; + let held = ""; + let accept = false; + for (const gamepad of readGamepads()) { + if (gamepad === null) { + continue; } - if (now.accept && !before.accept) { - UIFocus.acceptDown("pad"); - } else if (!now.accept && before.accept) { - UIFocus.acceptUp("pad"); + const down = (code: number) => { + return gamepad.buttons[code]?.pressed ?? false; + }; + if (down(B.UP)) { + held = "up"; + } else if (down(B.DOWN)) { + held = "down"; + } else if (down(B.LEFT)) { + held = "left"; + } else if (down(B.RIGHT)) { + held = "right"; } - }; + accept ||= down(B.FACE_1); + } + pad.held = held; + pad.accept = accept; +} + +/** once a frame: a D-pad press moves once, A presses and releases */ +function onFrame(): void { + const before = { held: pad.held, accept: pad.accept }; + readPad(); + if (pad.held !== "" && pad.held !== before.held) { + navigate(pad.held as UINavAction); + } + if (pad.accept && !before.accept) { + acceptDown("pad"); + } else if (!pad.accept && before.accept) { + acceptUp("pad"); + } } diff --git a/packages/melonjs/src/renderable/ui/uitextbutton.ts b/packages/melonjs/src/renderable/ui/uitextbutton.ts index 8485947d4..62b0a7edb 100644 --- a/packages/melonjs/src/renderable/ui/uitextbutton.ts +++ b/packages/melonjs/src/renderable/ui/uitextbutton.ts @@ -10,7 +10,7 @@ import Text from "../text/text.js"; import type { Paint } from "./panelpaint.ts"; import type { UIJuiceSettings } from "./uibaseelement.ts"; import UIBaseElement, { isPointerUp } from "./uibaseelement.ts"; -import UIFocus from "./uifocus.ts"; +import { focusOwner, isAcceptKey, ringVisible } from "./uifocus.ts"; import type { UILabel } from "./uilabel.ts"; import type { UISkin } from "./uiskin.ts"; import { SkinDrawer } from "./uiskin.ts"; @@ -212,10 +212,10 @@ export default class UITextButton extends UIBaseElement { // Enter on another button that shows the focus is that button's: an // OK bound to Enter does not fire with a focused Cancel if ( - UIFocus.ringVisible && - UIFocus.focused !== undefined && - UIFocus.focused !== this && - UIFocus._isAcceptKey(keyCode) + ringVisible() && + focusOwner() !== undefined && + focusOwner() !== this && + isAcceptKey(keyCode) ) { return; } diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js index 746999863..29251252c 100644 --- a/packages/melonjs/tests/ui-widgets.spec.js +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -1,12 +1,4 @@ -import { - afterAll, - afterEach, - beforeAll, - describe, - expect, - it, - vi, -} from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { Application, boot, @@ -16,13 +8,14 @@ import { ProgressBar, Renderable, TextureAtlas, + UIBaseElement, UICheckbox, - UIFocus, UIPanel, UISlider, UITextButton, video, } from "../src/index.js"; +import { ringVisible } from "../src/renderable/ui/uifocus.ts"; import { SkinDrawer } from "../src/renderable/ui/uiskin.ts"; // The widgets of #1722: the skinned button, the checkbox, the slider, and @@ -1238,7 +1231,7 @@ describe("UI widgets", () => { }); }); - describe("UIFocus", () => { + describe("keyboard and gamepad focus", () => { const key = (code, up = false) => { event.emit(up ? event.KEYUP : event.KEYDOWN, undefined, code); }; @@ -1268,30 +1261,30 @@ describe("UI widgets", () => { return { panel, a, b, c, d }; }; - beforeAll(() => { - UIFocus.enable(); - }); - afterAll(() => { - UIFocus.disable(); - }); afterEach(() => { - UIFocus.focus(undefined); + UIBaseElement.focusOwner?.blur(); }); - it("the first arrow focuses the first widget, the next ones move", () => { - const { a, b, c } = column(); + it("leaves the arrows alone until a widget takes the focus", () => { + column(); press(input.KEY.DOWN); - expect(UIFocus.focused).toBe(a); + expect(UIBaseElement.focusOwner).toBeUndefined(); + }); + + it("the arrows move the focus once a widget has it", () => { + const { a, b, c } = column(); + a.focus(); + expect(UIBaseElement.focusOwner).toBe(a); expect(a.focused).toBe(true); press(input.KEY.DOWN); - expect(UIFocus.focused).toBe(b); + expect(UIBaseElement.focusOwner).toBe(b); press(input.KEY.DOWN); - expect(UIFocus.focused).toBe(c); + expect(UIBaseElement.focusOwner).toBe(c); press(input.KEY.UP); - expect(UIFocus.focused).toBe(b); + expect(UIBaseElement.focusOwner).toBe(b); // nothing to the right: the focus stays press(input.KEY.RIGHT); - expect(UIFocus.focused).toBe(b); + expect(UIBaseElement.focusOwner).toBe(b); }); it("accept clicks a button and toggles a checkbox", () => { @@ -1300,14 +1293,14 @@ describe("UI widgets", () => { a.onClickCallback = onClick; const onChange = vi.fn(); c.onChange = onChange; - UIFocus.focus(a); + a.focus(); key(input.KEY.ENTER); // down while held, as under a pointer expect(a.released).toBe(false); expect(onClick).not.toHaveBeenCalled(); key(input.KEY.ENTER, true); expect(onClick).toHaveBeenCalledTimes(1); - UIFocus.focus(c); + c.focus(); press(input.KEY.SPACE); expect(c.checked).toBe(true); expect(onChange).toHaveBeenCalledWith(true, c); @@ -1319,13 +1312,13 @@ describe("UI widgets", () => { const onChangeEnd = vi.fn(); d.onChange = onChange; d.onChangeEnd = onChangeEnd; - UIFocus.focus(d); + d.focus(); press(input.KEY.RIGHT); press(input.KEY.RIGHT); expect(d.value).toBe(2); press(input.KEY.LEFT); expect(d.value).toBe(1); - expect(UIFocus.focused).toBe(d); + expect(UIBaseElement.focusOwner).toBe(d); expect(onChange).toHaveBeenCalledTimes(3); expect(onChangeEnd).toHaveBeenCalledTimes(3); }); @@ -1333,18 +1326,18 @@ describe("UI widgets", () => { it("skips disabled widgets", () => { const { a, b, c } = column(); b.enabled = false; - UIFocus.focus(a); + a.focus(); press(input.KEY.DOWN); - expect(UIFocus.focused).toBe(c); + expect(UIBaseElement.focusOwner).toBe(c); }); it("shows the ring after a key, and hides it when the mouse moves", () => { const { a } = column(); - UIFocus.focus(a); + a.focus(); press(input.KEY.DOWN); - expect(UIFocus.ringVisible).toBe(true); + expect(ringVisible()).toBe(true); pointer("pointermove", 700, 500); - expect(UIFocus.ringVisible).toBe(false); + expect(ringVisible()).toBe(false); }); it("scrolls a focused widget into view", () => { @@ -1365,11 +1358,11 @@ describe("UI widgets", () => { ), ); } - UIFocus.focus(buttons[0]); + buttons[0].focus(); for (let i = 0; i < 5; i++) { press(input.KEY.DOWN); } - expect(UIFocus.focused).toBe(buttons[5]); + expect(UIBaseElement.focusOwner).toBe(buttons[5]); // its bottom, plus room for the ring, at the bottom of the view expect(panel.scrollY).toBe(150); }); @@ -1385,7 +1378,7 @@ describe("UI widgets", () => { const pad = { id: "test pad", buttons, axes: [0, 0, 0, 0] }; input.addVirtualGamepad(pad, 0); try { - UIFocus.focus(a); + a.focus(); let t = 1000; const frame = () => { t += 16; @@ -1395,7 +1388,7 @@ describe("UI widgets", () => { frame(); buttons[input.GAMEPAD.BUTTONS.DOWN].pressed = false; frame(); - expect(UIFocus.focused).toBe(b); + expect(UIBaseElement.focusOwner).toBe(b); buttons[input.GAMEPAD.BUTTONS.FACE_1].pressed = true; frame(); buttons[input.GAMEPAD.BUTTONS.FACE_1].pressed = false; @@ -1406,23 +1399,15 @@ describe("UI widgets", () => { } }); - it("starts on the left of a row, even if a widget on the right sits higher", () => { - const left = place(new UITextButton(20, 110, { text: "L" })); - const right = place(new UITextButton(500, 100, { text: "R" })); - press(input.KEY.DOWN); - expect(UIFocus.focused).toBe(left); - void right; - }); - it("a press held while the focus moves is called off, not left down", () => { const { a, b } = column(); const onClick = vi.fn(); a.onClickCallback = onClick; - UIFocus.focus(a); + a.focus(); key(input.KEY.ENTER); press(input.KEY.DOWN); key(input.KEY.ENTER, true); - expect(UIFocus.focused).toBe(b); + expect(UIBaseElement.focusOwner).toBe(b); expect(a.released).toBe(true); expect(onClick).not.toHaveBeenCalled(); }); @@ -1431,7 +1416,7 @@ describe("UI widgets", () => { const { a } = column(); a.hoverScale = 1.2; const scale = vi.spyOn(app.renderer, "scale"); - UIFocus.focus(a); + a.focus(); pointer("pointermove", 700, 500); for (let i = 0; i < 30; i++) { a.update(16); @@ -1448,7 +1433,7 @@ describe("UI widgets", () => { a.bindKey = input.KEY.ENTER; a.onClickCallback = ok; b.onClickCallback = cancel; - UIFocus.focus(b); + b.focus(); press(input.KEY.UP); press(input.KEY.DOWN); press(input.KEY.ENTER); @@ -1456,7 +1441,7 @@ describe("UI widgets", () => { expect(ok).not.toHaveBeenCalled(); }); - it("a pad's A already down when enabled is not a press", () => { + it("a pad's A already down when the focus is given is not a press", () => { const { a } = column(); const onClick = vi.fn(); a.onClickCallback = onClick; @@ -1467,10 +1452,8 @@ describe("UI widgets", () => { const pad = { id: "held pad", buttons, axes: [0, 0, 0, 0] }; input.addVirtualGamepad(pad, 0); try { - UIFocus.disable(); buttons[input.GAMEPAD.BUTTONS.FACE_1].pressed = true; - UIFocus.enable(); - UIFocus.focus(a); + a.focus(); event.emit(event.GAME_BEFORE_UPDATE, 1); buttons[input.GAMEPAD.BUTTONS.FACE_1].pressed = false; event.emit(event.GAME_BEFORE_UPDATE, 2); @@ -1486,8 +1469,8 @@ describe("UI widgets", () => { new UIPanel(10, 10, { width: 50, height: 30 }), ); own.focusable = true; - press(input.KEY.DOWN); - expect(UIFocus.focused).toBe(own); + own.focus(); + expect(UIBaseElement.focusOwner).toBe(own); }); it("does not go into a hidden panel", () => { @@ -1495,30 +1478,35 @@ describe("UI widgets", () => { const hidden = place(new UIPanel(100, 450, { width: 200, height: 60 })); hidden.addChild(new UITextButton(0, 0, { text: "x" })); hidden.isRenderable = false; - UIFocus.focus(a); + a.focus(); for (let i = 0; i < 5; i++) { press(input.KEY.DOWN); } - expect(UIFocus.focused?.ancestor).not.toBe(hidden); + expect(UIBaseElement.focusOwner?.ancestor).not.toBe(hidden); }); it("moves on from a focused widget that was disabled since", () => { const { a, b, c } = column(); - UIFocus.focus(b); + b.focus(); b.enabled = false; press(input.KEY.DOWN); - expect(UIFocus.focused).toBe(c); + expect(UIBaseElement.focusOwner).toBe(c); void a; }); - it("is off until enabled, and drops the focus when disabled", () => { + it("blur() gives the keys back to the game", () => { const { a } = column(); - UIFocus.focus(a); - UIFocus.disable(); - expect(UIFocus.focused).toBeUndefined(); + a.focus(); + a.blur(); + expect(UIBaseElement.focusOwner).toBeUndefined(); press(input.KEY.DOWN); - expect(UIFocus.focused).toBeUndefined(); - UIFocus.enable(); + expect(UIBaseElement.focusOwner).toBeUndefined(); + }); + + it("does nothing on an element that is not focusable", () => { + const panel = place(new UIPanel(0, 0, { width: 10, height: 10 })); + panel.focus(); + expect(UIBaseElement.focusOwner).toBeUndefined(); }); }); diff --git a/packages/melonjs/tests/zz-probe-tmp.spec.js b/packages/melonjs/tests/zz-probe-tmp.spec.js new file mode 100644 index 000000000..425abaaea --- /dev/null +++ b/packages/melonjs/tests/zz-probe-tmp.spec.js @@ -0,0 +1,523 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { + Application, + boot, + Container, + event, + input, + ProgressBar, + Renderable, + TextureAtlas, + UIBaseElement, + UICheckbox, + UIPanel, + UISlider, + UITextButton, + video, +} from "../src/index.js"; +import { ringVisible } from "../src/renderable/ui/uifocus.ts"; +import { SkinDrawer } from "../src/renderable/ui/uiskin.ts"; + +// The widgets of #1722: the skinned button, the checkbox, the slider, and +// the panel's layout and scrolling, driven through real pointer events. + +let app; +const added = []; + +/** + * dispatch a DOM event at GAME coordinates, converted through the canvas + * bounding rect so the test does not depend on how the canvas was scaled + */ +const at = (gameX, gameY) => { + const canvas = app.renderer.getCanvas(); + const rect = canvas.getBoundingClientRect(); + return { + clientX: rect.left + (gameX * rect.width) / canvas.width, + clientY: rect.top + (gameY * rect.height) / canvas.height, + }; +}; + +const pointer = (type, gameX, gameY) => { + app.renderer.getCanvas().dispatchEvent( + new PointerEvent(type, { + ...at(gameX, gameY), + pointerId: 1, + width: 1, + height: 1, + isPrimary: true, + bubbles: true, + }), + ); +}; + +const wheel = (gameX, gameY, deltaY, deltaX = 0) => { + app.renderer.getCanvas().dispatchEvent( + new WheelEvent("wheel", { + ...at(gameX, gameY), + deltaY, + deltaX, + bubbles: true, + cancelable: true, + }), + ); +}; + +/** what a Text or a BitmapText says */ +const textOf = (label) => { + return [].concat(label._text).join("\n"); +}; + +/** a click: down and up at the same place */ +const click = (x, y) => { + pointer("pointermove", x, y); + pointer("pointerdown", x, y); + pointer("pointerup", x, y); +}; + +/** a drag from one point to another, in steps */ +const drag = (x0, y0, x1, y1, steps = 5) => { + pointer("pointermove", x0, y0); + pointer("pointerdown", x0, y0); + for (let i = 1; i <= steps; i++) { + pointer( + "pointermove", + x0 + ((x1 - x0) * i) / steps, + y0 + ((y1 - y0) * i) / steps, + ); + } + pointer("pointerup", x1, y1); +}; + +const syncBroadphase = () => { + app.world.broadphase.clear(); + app.world.broadphase.insertContainer(app.world); +}; + +/** add to the world, and take it out after the test */ +const place = (widget) => { + app.world.addChild(widget); + added.push(widget); + syncBroadphase(); + return widget; +}; + +/** a 64x32 atlas of four 16x16 regions, each a different colour */ +const makeAtlas = () => { + const sheet = document.createElement("canvas"); + sheet.width = 64; + sheet.height = 32; + const ctx = sheet.getContext("2d"); + const colours = ["#f00", "#0f0", "#00f", "#ff0"]; + colours.forEach((c, i) => { + ctx.fillStyle = c; + ctx.fillRect(i * 16, 0, 16, i === 1 ? 12 : 16); + }); + const frame = (filename, x, w, h) => { + return { + filename, + frame: { x, y: 0, w, h }, + rotated: false, + trimmed: false, + spriteSourceSize: { x: 0, y: 0, w, h }, + sourceSize: { w, h }, + pivot: { x: 0.5, y: 0.5 }, + }; + }; + return new TextureAtlas( + { + frames: [ + frame("up", 0, 16, 16), + // shorter, as pressed art is + frame("down", 16, 16, 12), + frame("on", 32, 16, 16), + frame("off", 48, 16, 16), + ], + meta: { + app: "texturepacker", + image: "ui.png", + size: { w: 64, h: 32 }, + scale: "1", + }, + }, + sheet, + ); +}; + +const R = []; +describe("PROBE", () => { + afterAll(() => { + throw new Error("RESULTS\n" + R.map((r) => JSON.stringify(r)).join("\n")); + }); + beforeAll(async () => { + boot(); + app = new Application(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + await app.init(); + }); + afterEach(() => { + for (const w of added.splice(0)) { + if (w.ancestor !== undefined) app.world.removeChildNow(w); + } + pointer("pointerup", 799, 599); + vi.restoreAllMocks(); + }); + const cancel = (x, y) => { + app.renderer + .getCanvas() + .dispatchEvent( + new PointerEvent("pointercancel", { + ...at(x, y), + pointerId: 1, + isPrimary: true, + bubbles: true, + }), + ); + }; + it("A slider step change keeps stale default format", () => { + const s = new UISlider(0, 0, { + showValue: true, + min: 0, + max: 10, + value: 5, + }); + const before = textOf(s.valueText); + s.step = 1; + R.push(["A before", before, "after step=1", textOf(s.valueText)]); + }); + it("B wheel deltaMode lines", () => { + const panel = place( + new UIPanel(100, 100, { + width: 200, + height: 100, + layout: "column", + gap: 0, + scroll: "vertical", + }), + ); + for (let i = 0; i < 10; i++) { + const r = new Renderable(0, 0, 100, 40); + r.anchorPoint.set(0, 0); + panel.addChild(r); + } + syncBroadphase(); + pointer("pointermove", 150, 150); + app.renderer + .getCanvas() + .dispatchEvent( + new WheelEvent("wheel", { + ...at(150, 150), + deltaY: 2, + deltaMode: 1, + bubbles: true, + cancelable: true, + }), + ); + R.push(["B lines scrollY", panel.scrollY]); + app.renderer + .getCanvas() + .dispatchEvent( + new WheelEvent("wheel", { + ...at(150, 150), + deltaY: 1, + deltaMode: 2, + bubbles: true, + cancelable: true, + }), + ); + R.push(["B pages scrollY", panel.scrollY]); + }); + it("C horizontal panel plain wheel", () => { + const panel = place( + new UIPanel(100, 100, { + width: 200, + height: 100, + layout: "row", + gap: 0, + scroll: "horizontal", + }), + ); + for (let i = 0; i < 10; i++) { + const r = new Renderable(0, 0, 40, 40); + r.anchorPoint.set(0, 0); + panel.addChild(r); + } + syncBroadphase(); + R.push(["C max", panel.maxScrollX, panel.maxScrollY]); + pointer("pointermove", 150, 150); + wheel(150, 150, 30); + R.push(["C scrollX", panel.scrollX]); + }); + it("D0 container no clip", () => { + const box = place(new Container(100, 100, 100, 100)); + const onClick = vi.fn(); + box.addChild( + new UITextButton(0, 0, { text: "x", width: 100, height: 40, onClick }), + ); + syncBroadphase(); + click(150, 120); + R.push(["D0 no clip clicks", onClick.mock.calls.length]); + const box2 = place(new Container(100, 300, 100, 100)); + box2.floating = true; + box2.clipping = true; + const onClick2 = vi.fn(); + box2.addChild( + new UITextButton(0, 0, { + text: "x", + width: 100, + height: 40, + onClick: onClick2, + }), + ); + syncBroadphase(); + click(150, 320); + R.push([ + "D1 floating clip container inside clicks", + onClick2.mock.calls.length, + ]); + const box3 = place(new Container(400, 100, 100, 100)); + box3.clipping = true; + const onClick3 = vi.fn(); + const b3 = new UITextButton(0, 0, { + text: "x", + width: 100, + height: 40, + onClick: onClick3, + }); + b3.floating = false; + box3.addChild(b3); + syncBroadphase(); + click(450, 120); + R.push([ + "D2 world clip container, non-floating button clicks", + onClick3.mock.calls.length, + ]); + }); + it("D3 existing test without clipping", () => { + const box = place(new Container(100, 100, 100, 100)); + const b = new UITextButton(0, 150, { + text: "x", + borderWidth: 100, + borderHeight: 40, + }); + const onClick = vi.fn(); + b.onClickCallback = onClick; + box.addChild(b); + syncBroadphase(); + click(150, 270); + R.push([ + "D3 same setup, clipping OFF, clicks", + onClick.mock.calls.length, + b.getBounds().left, + b.getBounds().top, + ]); + const c = new Container(0, 0, 10, 10); + c.destroy(); + try { + c.destroy(); + R.push(["D3 container twice ok"]); + } catch (e) { + R.push(["D3 container twice THROW", e.message]); + } + }); + it("D clipping container positive", () => { + const box = place(new Container(100, 100, 100, 100)); + box.clipping = true; + const onClick = vi.fn(); + box.addChild( + new UITextButton(0, 0, { text: "x", width: 100, height: 40, onClick }), + ); + syncBroadphase(); + click(150, 120); + R.push(["D inside clip clicks", onClick.mock.calls.length]); + }); + it("E pointercancel on button/checkbox/slider", () => { + const onClick = vi.fn(); + place( + new UITextButton(100, 100, { + text: "x", + width: 100, + height: 40, + onClick, + }), + ); + const cb = place(new UICheckbox(300, 100, {})); + const end = vi.fn(); + const s = place(new UISlider(100, 300, { onChangeEnd: end })); + syncBroadphase(); + pointer("pointermove", 150, 120); + pointer("pointerdown", 150, 120); + cancel(150, 120); + pointer("pointermove", 310, 110); + pointer("pointerdown", 310, 110); + cancel(310, 110); + pointer("pointermove", 110, 310); + pointer("pointerdown", 110, 310); + pointer("pointermove", 200, 310); + cancel(200, 310); + R.push([ + "E button clicks", + onClick.mock.calls.length, + "cb", + cb.checked, + "slider dragging", + s.dragging, + "onChangeEnd", + end.mock.calls.length, + ]); + }); + it("F shadow under faded parent", () => { + const r = app.renderer; + const alphas = []; + vi.spyOn(r, "fillRect").mockImplementation(() => { + alphas.push(r.globalAlpha()); + }); + const panel = new UIPanel(0, 0, { + width: 100, + height: 50, + shadowColor: "#00000080", + shadowBlur: 0, + }); + r.setGlobalAlpha(0.5); + panel.draw(r); + R.push(["F shadow alpha at 0.5 cascade", alphas, "after", r.globalAlpha()]); + r.setGlobalAlpha(1); + }); + it("G removeChild while scrolled then re-add", () => { + const panel = place( + new UIPanel(0, 0, { width: 200, height: 100, scroll: "vertical" }), + ); + const rs = []; + for (let i = 0; i < 10; i++) { + const r = new Renderable(0, i * 40, 100, 40); + r.anchorPoint.set(0, 0); + rs.push(panel.addChild(r)); + } + panel.scrollTo(0, 120); + const r5 = rs[5]; + panel.removeChildNow(r5, true); + R.push(["G removed pos", r5.pos.y]); + panel.addChild(r5); + R.push([ + "G readded pos", + r5.pos.y, + "max", + panel.maxScrollY, + "scroll", + panel.scrollY, + ]); + }); + it("H slider disabled mid-drag", () => { + const end = vi.fn(); + const s = place(new UISlider(100, 100, { onChangeEnd: end })); + pointer("pointermove", 110, 110); + pointer("pointerdown", 110, 110); + pointer("pointermove", 200, 110); + s.enabled = false; + pointer("pointermove", 250, 110); + const v = s.value; + R.push([ + "H dragging", + s.dragging, + "end", + end.mock.calls.length, + "value", + v, + ]); + }); + it("I destroy twice", () => { + const atlas = makeAtlas(); + const ws = [ + new UITextButton(0, 0, { text: "x", skin: { atlas, normal: "up" } }), + new UICheckbox(0, 0, { + label: "x", + skin: { atlas, on: "on", off: "off" }, + }), + new UISlider(0, 0, { label: "x", showValue: true }), + new UIPanel(0, 0, { + width: 10, + height: 10, + title: "T", + radius: 4, + fillColor: "#fff", + skin: { atlas, region: "up" }, + }), + ]; + for (const w of ws) { + try { + w.draw(app.renderer); + } catch (e) { + R.push(["I drawthrow", w.constructor.name, e.message]); + } + } + for (const w of ws) { + try { + w.destroy(); + R.push(["I once ok", w.constructor.name]); + w.destroy(); + R.push(["I twice ok", w.constructor.name]); + } catch (e) { + R.push(["I THROW", w.constructor.name, e.message]); + } + } + }); + it("J nested clip: scroll panel inside clipping container", () => { + const box = place(new Container(100, 100, 300, 60)); + box.clipping = true; + const panel = box.addChild( + new UIPanel(0, 0, { + width: 200, + height: 200, + layout: "column", + gap: 0, + scroll: "vertical", + }), + ); + const onClick = vi.fn(); + const bs = []; + for (let i = 0; i < 8; i++) + bs.push( + panel.addChild( + new UITextButton(0, 0, { text: "x", width: 200, height: 40 }), + ), + ); + bs[2].onClickCallback = onClick; // at 80..120 in panel → 180..220 screen; container clips at 160 + syncBroadphase(); + click(150, 190); + R.push(["J clipped by outer container clicks", onClick.mock.calls.length]); + }); + it("K step setter onNavigate no step on vertical", () => { + const s = new UISlider(0, 0, { orientation: "vertical", min: 0, max: 1 }); + R.push([ + "K up", + s.onNavigate("up"), + s.value, + "left", + s.onNavigate("left"), + s.value, + ]); + }); + it("L group setter when unchecked & checked leaves old group", () => { + const a = place(new UICheckbox(0, 0, { group: "x1", checked: true })); + a.group = undefined; + R.push(["L checkedIn x1", UICheckbox.checkedIn("x1")]); + }); + it("M labelOn to empty destroys label", () => { + const cb = new UICheckbox(0, 0, { label: "abc" }); + cb.labelOn = ""; + cb.labelOff = ""; + R.push(["M label", cb.label, cb.width, cb.height]); + cb.labelOn = "back"; + R.push(["M label back", cb.label && textOf(cb.label), cb.width]); + }); +}); From 1440ba7ce0a225c62e2811bc220b73762d8dd2bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 06:38:09 +0000 Subject: [PATCH 15/43] Remove a scratch test file committed by mistake Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/tests/zz-probe-tmp.spec.js | 523 -------------------- 1 file changed, 523 deletions(-) delete mode 100644 packages/melonjs/tests/zz-probe-tmp.spec.js diff --git a/packages/melonjs/tests/zz-probe-tmp.spec.js b/packages/melonjs/tests/zz-probe-tmp.spec.js deleted file mode 100644 index 425abaaea..000000000 --- a/packages/melonjs/tests/zz-probe-tmp.spec.js +++ /dev/null @@ -1,523 +0,0 @@ -import { - afterAll, - afterEach, - beforeAll, - describe, - expect, - it, - vi, -} from "vitest"; -import { - Application, - boot, - Container, - event, - input, - ProgressBar, - Renderable, - TextureAtlas, - UIBaseElement, - UICheckbox, - UIPanel, - UISlider, - UITextButton, - video, -} from "../src/index.js"; -import { ringVisible } from "../src/renderable/ui/uifocus.ts"; -import { SkinDrawer } from "../src/renderable/ui/uiskin.ts"; - -// The widgets of #1722: the skinned button, the checkbox, the slider, and -// the panel's layout and scrolling, driven through real pointer events. - -let app; -const added = []; - -/** - * dispatch a DOM event at GAME coordinates, converted through the canvas - * bounding rect so the test does not depend on how the canvas was scaled - */ -const at = (gameX, gameY) => { - const canvas = app.renderer.getCanvas(); - const rect = canvas.getBoundingClientRect(); - return { - clientX: rect.left + (gameX * rect.width) / canvas.width, - clientY: rect.top + (gameY * rect.height) / canvas.height, - }; -}; - -const pointer = (type, gameX, gameY) => { - app.renderer.getCanvas().dispatchEvent( - new PointerEvent(type, { - ...at(gameX, gameY), - pointerId: 1, - width: 1, - height: 1, - isPrimary: true, - bubbles: true, - }), - ); -}; - -const wheel = (gameX, gameY, deltaY, deltaX = 0) => { - app.renderer.getCanvas().dispatchEvent( - new WheelEvent("wheel", { - ...at(gameX, gameY), - deltaY, - deltaX, - bubbles: true, - cancelable: true, - }), - ); -}; - -/** what a Text or a BitmapText says */ -const textOf = (label) => { - return [].concat(label._text).join("\n"); -}; - -/** a click: down and up at the same place */ -const click = (x, y) => { - pointer("pointermove", x, y); - pointer("pointerdown", x, y); - pointer("pointerup", x, y); -}; - -/** a drag from one point to another, in steps */ -const drag = (x0, y0, x1, y1, steps = 5) => { - pointer("pointermove", x0, y0); - pointer("pointerdown", x0, y0); - for (let i = 1; i <= steps; i++) { - pointer( - "pointermove", - x0 + ((x1 - x0) * i) / steps, - y0 + ((y1 - y0) * i) / steps, - ); - } - pointer("pointerup", x1, y1); -}; - -const syncBroadphase = () => { - app.world.broadphase.clear(); - app.world.broadphase.insertContainer(app.world); -}; - -/** add to the world, and take it out after the test */ -const place = (widget) => { - app.world.addChild(widget); - added.push(widget); - syncBroadphase(); - return widget; -}; - -/** a 64x32 atlas of four 16x16 regions, each a different colour */ -const makeAtlas = () => { - const sheet = document.createElement("canvas"); - sheet.width = 64; - sheet.height = 32; - const ctx = sheet.getContext("2d"); - const colours = ["#f00", "#0f0", "#00f", "#ff0"]; - colours.forEach((c, i) => { - ctx.fillStyle = c; - ctx.fillRect(i * 16, 0, 16, i === 1 ? 12 : 16); - }); - const frame = (filename, x, w, h) => { - return { - filename, - frame: { x, y: 0, w, h }, - rotated: false, - trimmed: false, - spriteSourceSize: { x: 0, y: 0, w, h }, - sourceSize: { w, h }, - pivot: { x: 0.5, y: 0.5 }, - }; - }; - return new TextureAtlas( - { - frames: [ - frame("up", 0, 16, 16), - // shorter, as pressed art is - frame("down", 16, 16, 12), - frame("on", 32, 16, 16), - frame("off", 48, 16, 16), - ], - meta: { - app: "texturepacker", - image: "ui.png", - size: { w: 64, h: 32 }, - scale: "1", - }, - }, - sheet, - ); -}; - -const R = []; -describe("PROBE", () => { - afterAll(() => { - throw new Error("RESULTS\n" + R.map((r) => JSON.stringify(r)).join("\n")); - }); - beforeAll(async () => { - boot(); - app = new Application(800, 600, { - parent: "screen", - scale: "auto", - renderer: video.CANVAS, - }); - await app.init(); - }); - afterEach(() => { - for (const w of added.splice(0)) { - if (w.ancestor !== undefined) app.world.removeChildNow(w); - } - pointer("pointerup", 799, 599); - vi.restoreAllMocks(); - }); - const cancel = (x, y) => { - app.renderer - .getCanvas() - .dispatchEvent( - new PointerEvent("pointercancel", { - ...at(x, y), - pointerId: 1, - isPrimary: true, - bubbles: true, - }), - ); - }; - it("A slider step change keeps stale default format", () => { - const s = new UISlider(0, 0, { - showValue: true, - min: 0, - max: 10, - value: 5, - }); - const before = textOf(s.valueText); - s.step = 1; - R.push(["A before", before, "after step=1", textOf(s.valueText)]); - }); - it("B wheel deltaMode lines", () => { - const panel = place( - new UIPanel(100, 100, { - width: 200, - height: 100, - layout: "column", - gap: 0, - scroll: "vertical", - }), - ); - for (let i = 0; i < 10; i++) { - const r = new Renderable(0, 0, 100, 40); - r.anchorPoint.set(0, 0); - panel.addChild(r); - } - syncBroadphase(); - pointer("pointermove", 150, 150); - app.renderer - .getCanvas() - .dispatchEvent( - new WheelEvent("wheel", { - ...at(150, 150), - deltaY: 2, - deltaMode: 1, - bubbles: true, - cancelable: true, - }), - ); - R.push(["B lines scrollY", panel.scrollY]); - app.renderer - .getCanvas() - .dispatchEvent( - new WheelEvent("wheel", { - ...at(150, 150), - deltaY: 1, - deltaMode: 2, - bubbles: true, - cancelable: true, - }), - ); - R.push(["B pages scrollY", panel.scrollY]); - }); - it("C horizontal panel plain wheel", () => { - const panel = place( - new UIPanel(100, 100, { - width: 200, - height: 100, - layout: "row", - gap: 0, - scroll: "horizontal", - }), - ); - for (let i = 0; i < 10; i++) { - const r = new Renderable(0, 0, 40, 40); - r.anchorPoint.set(0, 0); - panel.addChild(r); - } - syncBroadphase(); - R.push(["C max", panel.maxScrollX, panel.maxScrollY]); - pointer("pointermove", 150, 150); - wheel(150, 150, 30); - R.push(["C scrollX", panel.scrollX]); - }); - it("D0 container no clip", () => { - const box = place(new Container(100, 100, 100, 100)); - const onClick = vi.fn(); - box.addChild( - new UITextButton(0, 0, { text: "x", width: 100, height: 40, onClick }), - ); - syncBroadphase(); - click(150, 120); - R.push(["D0 no clip clicks", onClick.mock.calls.length]); - const box2 = place(new Container(100, 300, 100, 100)); - box2.floating = true; - box2.clipping = true; - const onClick2 = vi.fn(); - box2.addChild( - new UITextButton(0, 0, { - text: "x", - width: 100, - height: 40, - onClick: onClick2, - }), - ); - syncBroadphase(); - click(150, 320); - R.push([ - "D1 floating clip container inside clicks", - onClick2.mock.calls.length, - ]); - const box3 = place(new Container(400, 100, 100, 100)); - box3.clipping = true; - const onClick3 = vi.fn(); - const b3 = new UITextButton(0, 0, { - text: "x", - width: 100, - height: 40, - onClick: onClick3, - }); - b3.floating = false; - box3.addChild(b3); - syncBroadphase(); - click(450, 120); - R.push([ - "D2 world clip container, non-floating button clicks", - onClick3.mock.calls.length, - ]); - }); - it("D3 existing test without clipping", () => { - const box = place(new Container(100, 100, 100, 100)); - const b = new UITextButton(0, 150, { - text: "x", - borderWidth: 100, - borderHeight: 40, - }); - const onClick = vi.fn(); - b.onClickCallback = onClick; - box.addChild(b); - syncBroadphase(); - click(150, 270); - R.push([ - "D3 same setup, clipping OFF, clicks", - onClick.mock.calls.length, - b.getBounds().left, - b.getBounds().top, - ]); - const c = new Container(0, 0, 10, 10); - c.destroy(); - try { - c.destroy(); - R.push(["D3 container twice ok"]); - } catch (e) { - R.push(["D3 container twice THROW", e.message]); - } - }); - it("D clipping container positive", () => { - const box = place(new Container(100, 100, 100, 100)); - box.clipping = true; - const onClick = vi.fn(); - box.addChild( - new UITextButton(0, 0, { text: "x", width: 100, height: 40, onClick }), - ); - syncBroadphase(); - click(150, 120); - R.push(["D inside clip clicks", onClick.mock.calls.length]); - }); - it("E pointercancel on button/checkbox/slider", () => { - const onClick = vi.fn(); - place( - new UITextButton(100, 100, { - text: "x", - width: 100, - height: 40, - onClick, - }), - ); - const cb = place(new UICheckbox(300, 100, {})); - const end = vi.fn(); - const s = place(new UISlider(100, 300, { onChangeEnd: end })); - syncBroadphase(); - pointer("pointermove", 150, 120); - pointer("pointerdown", 150, 120); - cancel(150, 120); - pointer("pointermove", 310, 110); - pointer("pointerdown", 310, 110); - cancel(310, 110); - pointer("pointermove", 110, 310); - pointer("pointerdown", 110, 310); - pointer("pointermove", 200, 310); - cancel(200, 310); - R.push([ - "E button clicks", - onClick.mock.calls.length, - "cb", - cb.checked, - "slider dragging", - s.dragging, - "onChangeEnd", - end.mock.calls.length, - ]); - }); - it("F shadow under faded parent", () => { - const r = app.renderer; - const alphas = []; - vi.spyOn(r, "fillRect").mockImplementation(() => { - alphas.push(r.globalAlpha()); - }); - const panel = new UIPanel(0, 0, { - width: 100, - height: 50, - shadowColor: "#00000080", - shadowBlur: 0, - }); - r.setGlobalAlpha(0.5); - panel.draw(r); - R.push(["F shadow alpha at 0.5 cascade", alphas, "after", r.globalAlpha()]); - r.setGlobalAlpha(1); - }); - it("G removeChild while scrolled then re-add", () => { - const panel = place( - new UIPanel(0, 0, { width: 200, height: 100, scroll: "vertical" }), - ); - const rs = []; - for (let i = 0; i < 10; i++) { - const r = new Renderable(0, i * 40, 100, 40); - r.anchorPoint.set(0, 0); - rs.push(panel.addChild(r)); - } - panel.scrollTo(0, 120); - const r5 = rs[5]; - panel.removeChildNow(r5, true); - R.push(["G removed pos", r5.pos.y]); - panel.addChild(r5); - R.push([ - "G readded pos", - r5.pos.y, - "max", - panel.maxScrollY, - "scroll", - panel.scrollY, - ]); - }); - it("H slider disabled mid-drag", () => { - const end = vi.fn(); - const s = place(new UISlider(100, 100, { onChangeEnd: end })); - pointer("pointermove", 110, 110); - pointer("pointerdown", 110, 110); - pointer("pointermove", 200, 110); - s.enabled = false; - pointer("pointermove", 250, 110); - const v = s.value; - R.push([ - "H dragging", - s.dragging, - "end", - end.mock.calls.length, - "value", - v, - ]); - }); - it("I destroy twice", () => { - const atlas = makeAtlas(); - const ws = [ - new UITextButton(0, 0, { text: "x", skin: { atlas, normal: "up" } }), - new UICheckbox(0, 0, { - label: "x", - skin: { atlas, on: "on", off: "off" }, - }), - new UISlider(0, 0, { label: "x", showValue: true }), - new UIPanel(0, 0, { - width: 10, - height: 10, - title: "T", - radius: 4, - fillColor: "#fff", - skin: { atlas, region: "up" }, - }), - ]; - for (const w of ws) { - try { - w.draw(app.renderer); - } catch (e) { - R.push(["I drawthrow", w.constructor.name, e.message]); - } - } - for (const w of ws) { - try { - w.destroy(); - R.push(["I once ok", w.constructor.name]); - w.destroy(); - R.push(["I twice ok", w.constructor.name]); - } catch (e) { - R.push(["I THROW", w.constructor.name, e.message]); - } - } - }); - it("J nested clip: scroll panel inside clipping container", () => { - const box = place(new Container(100, 100, 300, 60)); - box.clipping = true; - const panel = box.addChild( - new UIPanel(0, 0, { - width: 200, - height: 200, - layout: "column", - gap: 0, - scroll: "vertical", - }), - ); - const onClick = vi.fn(); - const bs = []; - for (let i = 0; i < 8; i++) - bs.push( - panel.addChild( - new UITextButton(0, 0, { text: "x", width: 200, height: 40 }), - ), - ); - bs[2].onClickCallback = onClick; // at 80..120 in panel → 180..220 screen; container clips at 160 - syncBroadphase(); - click(150, 190); - R.push(["J clipped by outer container clicks", onClick.mock.calls.length]); - }); - it("K step setter onNavigate no step on vertical", () => { - const s = new UISlider(0, 0, { orientation: "vertical", min: 0, max: 1 }); - R.push([ - "K up", - s.onNavigate("up"), - s.value, - "left", - s.onNavigate("left"), - s.value, - ]); - }); - it("L group setter when unchecked & checked leaves old group", () => { - const a = place(new UICheckbox(0, 0, { group: "x1", checked: true })); - a.group = undefined; - R.push(["L checkedIn x1", UICheckbox.checkedIn("x1")]); - }); - it("M labelOn to empty destroys label", () => { - const cb = new UICheckbox(0, 0, { label: "abc" }); - cb.labelOn = ""; - cb.labelOff = ""; - R.push(["M label", cb.label, cb.width, cb.height]); - cb.labelOn = "back"; - R.push(["M label back", cb.label && textOf(cb.label), cb.width]); - }); -}); From 32a4edce48b7961954d06c89a82d06a7094dc492 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 07:02:13 +0000 Subject: [PATCH 16/43] UI: fill the gaps in the widget tests, and fix what they found Three bugs the new tests caught: - a slider's default value text kept the old decimals after `step` changed - Enter on a focused button the pointer already held clicked it on release - a virtual gamepad sharing a slot with a real one was not read The stub-subclass tests now use the real button. New tests cover the shadow under a faded parent, scrollbar geometry, vertical and skinned sliders, cancelled and two-finger drags, skin regions and bitmap font sizes, radio drawing and groups, sideways and line-mode wheel scrolling, wheel pass-through at the end of a panel, fling limits, grid stretch, row autoSize, clearChildren, the focus ring, and focus teardown. The clipping test now clips through a real container. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/src/input/gamepad.ts | 24 +- .../src/renderable/ui/uibaseelement.ts | 7 +- packages/melonjs/src/renderable/ui/uifocus.ts | 8 +- .../melonjs/src/renderable/ui/uislider.ts | 8 + packages/melonjs/tests/ui-widgets.spec.js | 633 +++++++++++++++++- packages/melonjs/tests/ui.spec.js | 131 +--- 6 files changed, 683 insertions(+), 128 deletions(-) diff --git a/packages/melonjs/src/input/gamepad.ts b/packages/melonjs/src/input/gamepad.ts index 121ffff17..988d4beb8 100644 --- a/packages/melonjs/src/input/gamepad.ts +++ b/packages/melonjs/src/input/gamepad.ts @@ -296,10 +296,11 @@ function browserGamepads(): readonly (Gamepad | null)[] { } /** - * Every pad slot, a real pad where there is one and the virtual one put in - * that slot otherwise, as `updateGamepads` reads them. For a reader that - * wants the raw button and axis state, such as the UI focus. - * @returns the pads, by slot + * Every pad the game can read: the real ones, and the virtual ones, such as + * on-screen controls, each on its own even when it shares a slot with a + * real pad, as `updateGamepads` reads both. For a reader that wants the raw + * button state, such as the UI focus. + * @returns the pads, real first * @ignore * @internal */ @@ -308,14 +309,15 @@ export function readGamepads(): readonly ( | VirtualGamepad | null )[] { - const real = browserGamepads(); - const count = Math.max(real.length, virtualGamepads.length); - const slots: (Pick | VirtualGamepad | null)[] = - []; - for (let i = 0; i < count; i++) { - slots.push(real[i] ?? virtualGamepads[i] ?? null); + const pads: (Pick | VirtualGamepad | null)[] = [ + ...browserGamepads(), + ]; + for (const virtual of virtualGamepads) { + if (virtual !== undefined) { + pads.push(virtual); + } } - return slots; + return pads; } /** diff --git a/packages/melonjs/src/renderable/ui/uibaseelement.ts b/packages/melonjs/src/renderable/ui/uibaseelement.ts index 9332752ce..a128eb87c 100644 --- a/packages/melonjs/src/renderable/ui/uibaseelement.ts +++ b/packages/melonjs/src/renderable/ui/uibaseelement.ts @@ -373,13 +373,18 @@ export default class UIBaseElement extends Container { /** * Accept was pressed with the focus here: the widget goes down, as under * a pointer, and `onClick` runs with no event. + * @returns whether it went down: not when disabled, or already held */ - focusPress(): void { + focusPress(): boolean { + // not while the pointer already holds it: its release is the + // pointer's, and accept letting go must not complete that press if (this._enabled && this.released) { this.released = false; this.isDirty = true; this.onClick(); + return true; } + return false; } /** diff --git a/packages/melonjs/src/renderable/ui/uifocus.ts b/packages/melonjs/src/renderable/ui/uifocus.ts index 8cb383540..b4ac514e9 100644 --- a/packages/melonjs/src/renderable/ui/uifocus.ts +++ b/packages/melonjs/src/renderable/ui/uifocus.ts @@ -343,10 +343,14 @@ function scrollIntoView(element: UIBaseElement): void { */ function acceptDown(source: "key" | "pad"): void { ringShown = true; - if (accepting === undefined && owner !== undefined && isCandidate(owner)) { + if ( + accepting === undefined && + owner !== undefined && + isCandidate(owner) && + owner.focusPress() + ) { accepting = source; pressed = owner; - owner.focusPress(); } } diff --git a/packages/melonjs/src/renderable/ui/uislider.ts b/packages/melonjs/src/renderable/ui/uislider.ts index ba7bb38b6..42cd76bd5 100644 --- a/packages/melonjs/src/renderable/ui/uislider.ts +++ b/packages/melonjs/src/renderable/ui/uislider.ts @@ -230,6 +230,8 @@ export default class UISlider extends UIBaseElement { /** @ignore */ private _valueFormat: (value: number, ratio: number) => string; + /** whether the format is the default one, which follows the step */ + private _defaultFormat: boolean; /** @ignore */ private _min: number; /** @ignore */ @@ -317,6 +319,7 @@ export default class UISlider extends UIBaseElement { this.onChange = settings.onChange; this.onChangeEnd = settings.onChangeEnd; this._valueFormat = settings.valueFormat ?? defaultFormat(this._step); + this._defaultFormat = settings.valueFormat === undefined; this.trackColor = settings.trackColor ?? "#00000033"; this.fillColor = settings.fillColor ?? "#2a7de1"; @@ -460,6 +463,10 @@ export default class UISlider extends UIBaseElement { set step(v: number) { this._step = Math.max(0, v); + if (this._defaultFormat) { + // the default shows as many decimals as the step has + this._valueFormat = defaultFormat(this._step); + } this.resnap(); } @@ -482,6 +489,7 @@ export default class UISlider extends UIBaseElement { set valueFormat(format: (value: number, ratio: number) => string) { this._valueFormat = format; + this._defaultFormat = false; this.refreshValueText(); } diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js index 29251252c..0f0012648 100644 --- a/packages/melonjs/tests/ui-widgets.spec.js +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -1,10 +1,12 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { Application, + BitmapText, boot, Container, event, input, + loader, ProgressBar, Renderable, TextureAtlas, @@ -37,11 +39,11 @@ const at = (gameX, gameY) => { }; }; -const pointer = (type, gameX, gameY) => { +const pointer = (type, gameX, gameY, pointerId = 1) => { app.renderer.getCanvas().dispatchEvent( new PointerEvent(type, { ...at(gameX, gameY), - pointerId: 1, + pointerId, width: 1, height: 1, isPrimary: true, @@ -152,6 +154,16 @@ describe("UI widgets", () => { renderer: video.CANVAS, }); await app.init(); + await new Promise((resolve) => { + loader.preload( + [ + { name: "xolo12", type: "image", src: "/data/fnt/xolo12.png" }, + { name: "xolo12", type: "binary", src: "/data/fnt/xolo12.fnt" }, + ], + resolve, + false, + ); + }); }); afterEach(() => { @@ -832,18 +844,35 @@ describe("UI widgets", () => { it("a clipping container hides its cropped children from the pointer", () => { const box = place(new Container(100, 100, 100, 100)); + box.floating = true; box.clipping = true; - const b = new UITextButton(0, 150, { - text: "x", - borderWidth: 100, - borderHeight: 40, + const inside = new UITextButton(0, 10, { + text: "in", + width: 100, + height: 30, }); - const onClick = vi.fn(); - b.onClickCallback = onClick; - box.addChild(b); + const cropped = new UITextButton(0, 150, { + text: "out", + width: 100, + height: 40, + }); + const inClick = vi.fn(); + const outClick = vi.fn(); + inside.onClickCallback = inClick; + cropped.onClickCallback = outClick; + box.addChild(inside); + box.addChild(cropped); syncBroadphase(); + // shown: answers + click(150, 125); + expect(inClick).toHaveBeenCalledTimes(1); + // cropped away: does not click(150, 270); - expect(onClick).not.toHaveBeenCalled(); + expect(outClick).not.toHaveBeenCalled(); + // and without the clipping it would have + box.clipping = false; + click(150, 270); + expect(outClick).toHaveBeenCalledTimes(1); }); }); @@ -995,9 +1024,29 @@ describe("UI widgets", () => { }); it("a held button keeps its hover colour unless given a pressed one", () => { - const b = new UITextButton(0, 0, { text: "x", hoverOnColor: "#123456" }); - // left undefined, so the held button follows hoverOnColor + const b = place( + new UITextButton(100, 100, { + text: "x", + width: 100, + height: 40, + hoverOnColor: "#123456", + }), + ); + const colors = []; + vi.spyOn(app.renderer, "setColor").mockImplementation((c) => { + colors.push(typeof c === "string" ? c : c); + }); + pointer("pointermove", 150, 120); + pointer("pointerdown", 150, 120); + // left undefined, so the held button draws its hover colour expect(b.pressedColor).toBeUndefined(); + b.draw(app.renderer); + expect(colors[0]).toBe("#123456"); + b.pressedColor = "#ff0000"; + colors.length = 0; + b.draw(app.renderer); + expect(colors[0]).toBe("#ff0000"); + pointer("pointerup", 150, 120); const label = b.label; b.bitmapText = label; expect(b.label).toBe(label); @@ -1510,6 +1559,563 @@ describe("UI widgets", () => { }); }); + describe("coverage", () => { + /** a plain 40x20 renderable, placed by its top left corner */ + const box = (w = 40, h = 20) => { + const r = new Renderable(0, 0, w, h); + r.anchorPoint.set(0, 0); + return r; + }; + const key = (code, up = false) => { + event.emit(up ? event.KEYUP : event.KEYDOWN, undefined, code); + }; + afterEach(() => { + UIBaseElement.focusOwner?.blur(); + }); + + describe("drop shadow under a faded parent", () => { + it("fades with the parent, and hands the skin the parent's alpha", () => { + const atlas = makeAtlas(); + const renderer = app.renderer; + const alphas = []; + vi.spyOn(renderer, "fillRect").mockImplementation(() => { + alphas.push(renderer.globalAlpha()); + }); + let atSkin; + vi.spyOn(SkinDrawer.prototype, "draw").mockImplementation(() => { + atSkin = renderer.globalAlpha(); + }); + const panel = new UIPanel(0, 0, { + width: 100, + height: 50, + skin: { atlas, region: "up" }, + shadowColor: "#00000080", + shadowBlur: 0, + }); + renderer.setGlobalAlpha(0.5); + panel.draw(renderer); + renderer.setGlobalAlpha(1); + // one sharp layer: the colour's alpha times the parent's + expect(alphas[0]).toBeCloseTo(0.5 * (128 / 255), 2); + expect(atSkin).toBeCloseTo(0.5, 5); + }); + }); + + describe("scrollbar", () => { + it("runs from the top of the view to its bottom as the content scrolls", () => { + const panel = place( + new UIPanel(0, 0, { + width: 200, + height: 100, + layout: "column", + gap: 0, + scroll: "vertical", + }), + ); + for (let i = 0; i < 10; i++) { + panel.addChild(box(100, 40)); + } + const bars = []; + vi.spyOn(app.renderer, "fill").mockImplementation((shape) => { + if (shape.width === 4) { + bars.push([shape.pos.x, shape.pos.y, shape.height]); + } + }); + panel.draw(app.renderer); + // 100 / (100 + 300) of the view, at the top, 3 in from the edge + expect(bars.at(-1)).toEqual([193, 0, 25]); + panel.scrollTo(0, panel.maxScrollY); + panel.draw(app.renderer); + expect(bars.at(-1)).toEqual([193, 75, 25]); + }); + }); + + describe("UISlider", () => { + it("the default value text follows a new step", () => { + const s = new UISlider(0, 0, { + showValue: true, + min: 0, + max: 10, + value: 5, + }); + expect(textOf(s.valueText)).toBe("5.00"); + s.step = 1; + expect(textOf(s.valueText)).toBe("5"); + // a format of one's own is kept + s.valueFormat = (v) => { + return `${v}!`; + }; + s.step = 0.5; + expect(textOf(s.valueText)).toBe("5!"); + }); + + it("snaps inside a range that is not a whole number of steps, swaps min and max, ignores NaN", () => { + const s = new UISlider(0, 0, { min: 0, max: 10, step: 3, value: 10 }); + expect(s.value).toBe(9); + const swapped = new UISlider(0, 0, { min: 10, max: 0 }); + expect([swapped.min, swapped.max]).toEqual([0, 10]); + s.value = 6; + s.value = Number.NaN; + expect(s.value).toBe(6); + }); + + it("a vertical skinned slider draws its track, fill and thumb along it", () => { + const atlas = makeAtlas(); + const spy = vi.spyOn(SkinDrawer.prototype, "draw"); + const s = new UISlider(0, 0, { + orientation: "vertical", + length: 100, + value: 0.25, + // the thumb art is 16 wide and 12 tall + skin: { + atlas, + track: "up", + fill: "on", + thumb: "down", + thumbHover: "off", + }, + }); + s.draw(app.renderer); + const calls = spy.mock.calls.map((c) => { + return c.slice(1); + }); + const [track, fill, thumb] = calls; + expect(track[0]).toBe("up"); + // from the thumb's centre (6 + 0.75 * 88 = 72) down to the end + expect(fill.slice(0, 1).concat(fill.slice(2))).toEqual([ + "on", + 72, + 16, + 28, + ]); + // across the slider at its own width, along it at its height + expect(thumb[0]).toBe("down"); + expect(thumb.slice(3)).toEqual([16, 12]); + s.hover = true; + s.draw(app.renderer); + expect(spy.mock.calls.at(-1)[1]).toBe("off"); + }); + + it("a cancelled drag ends, with onChangeEnd for what moved", () => { + const onChangeEnd = vi.fn(); + const s = place(new UISlider(100, 100, { onChangeEnd })); + pointer("pointermove", 110, 110); + pointer("pointerdown", 110, 110); + pointer("pointermove", 200, 110); + window.dispatchEvent( + new PointerEvent("pointercancel", { pointerId: 1, bubbles: true }), + ); + expect(s.dragging).toBe(false); + expect(onChangeEnd).toHaveBeenCalledTimes(1); + }); + + it("a second finger does not move the thumb another one drags", () => { + const s = place(new UISlider(100, 100, {})); + pointer("pointermove", 110, 110); + pointer("pointerdown", 110, 110); + const before = s.value; + pointer("pointermove", 290, 110, 2); + expect(s.value).toBe(before); + pointer("pointerup", 110, 110); + }); + + it("removed mid-drag, it stops listening to the window", () => { + const s = place(new UISlider(100, 100, {})); + const remove = vi.spyOn(window, "removeEventListener"); + pointer("pointermove", 110, 110); + pointer("pointerdown", 110, 110); + app.world.removeChildNow(s); + expect( + remove.mock.calls.some((c) => { + return c[0] === "pointerup"; + }), + ).toBe(true); + }); + + it("a vertical slider steps on up and down, by a twentieth with no step", () => { + const s = place(new UISlider(100, 100, { orientation: "vertical" })); + s.focus(); + key(input.KEY.UP); + key(input.KEY.UP, true); + expect(s.value).toBeCloseTo(0.05); + key(input.KEY.DOWN); + key(input.KEY.DOWN, true); + expect(s.value).toBe(0); + expect(UIBaseElement.focusOwner).toBe(s); + }); + }); + + describe("UITextButton", () => { + it("draws its hover and disabled skin regions, or half alpha without one", () => { + const atlas = makeAtlas(); + const renderer = app.renderer; + const regions = []; + vi.spyOn(SkinDrawer.prototype, "draw").mockImplementation( + (_r, region) => { + regions.push([region, renderer.globalAlpha()]); + }, + ); + const b = new UITextButton(0, 0, { + text: "x", + skin: { atlas, normal: "up", hover: "on", disabled: "off" }, + }); + b.hover = true; + b.draw(renderer); + expect(regions.at(-1)[0]).toBe("on"); + b.enabled = false; + b.draw(renderer); + expect(regions.at(-1)).toEqual(["off", 1]); + const plain = new UITextButton(0, 0, { + text: "x", + skin: { atlas, normal: "up" }, + enabled: false, + }); + plain.draw(renderer); + expect(regions.at(-1)).toEqual(["up", 0.5]); + }); + + it("scales a bitmap font to fontSize, in pixels", () => { + const b = new UITextButton(0, 0, { + font: "xolo12", + text: "OK", + fontSize: 24, + }); + expect(b.label).toBeInstanceOf(BitmapText); + expect(b.label.measureText("Ag").height).toBeCloseTo(24, 0); + }); + + it("Enter while the pointer holds the focused button does not click it", () => { + const onClick = vi.fn(); + const b = place( + new UITextButton(100, 100, { + text: "x", + width: 100, + height: 40, + onClick, + }), + ); + b.focus(); + pointer("pointermove", 150, 120); + pointer("pointerdown", 150, 120); + key(input.KEY.ENTER); + key(input.KEY.ENTER, true); + pointer("pointermove", 500, 500); + pointer("pointerup", 500, 500); + expect(onClick).not.toHaveBeenCalled(); + }); + }); + + describe("UICheckbox", () => { + it("draws a box with a tick, and a radio button as a circle and a dot", () => { + const renderer = app.renderer; + const lines = vi.spyOn(renderer, "strokeLine"); + const ellipses = vi.spyOn(renderer, "fillEllipse"); + new UICheckbox(0, 0, { checked: true }).draw(renderer); + expect(lines).toHaveBeenCalledTimes(2); + new UICheckbox(0, 0, { group: "drawn-radio" }).draw(renderer); + expect(ellipses).toHaveBeenCalledTimes(1); + new UICheckbox(0, 0, { group: "drawn-radio", checked: true }).draw( + renderer, + ); + expect(ellipses).toHaveBeenCalledTimes(3); + }); + + it("draws its hover skin regions", () => { + const atlas = makeAtlas(); + const spy = vi.spyOn(SkinDrawer.prototype, "draw"); + const cb = new UICheckbox(0, 0, { + skin: { + atlas, + on: "on", + off: "off", + hoverOn: "up", + hoverOff: "down", + }, + }); + cb.hover = true; + cb.draw(app.renderer); + expect(spy.mock.calls.at(-1)[1]).toBe("down"); + cb.checked = true; + cb.draw(app.renderer); + expect(spy.mock.calls.at(-1)[1]).toBe("up"); + }); + + it("drops its label when both texts are emptied, and makes one again", () => { + const cb = new UICheckbox(0, 0, { label: "Music" }); + expect(cb.width).toBeGreaterThan(cb.boxSize); + cb.labelOn = ""; + cb.labelOff = ""; + expect(cb.label).toBeUndefined(); + expect(cb.width).toBe(cb.boxSize); + cb.labelOff = "Back"; + expect(textOf(cb.label)).toBe("Back"); + }); + + it("a bitmap font label is scaled to fontSize", () => { + const cb = new UICheckbox(0, 0, { + font: "xolo12", + label: "x", + fontSize: 24, + }); + expect(cb.label).toBeInstanceOf(BitmapText); + expect(cb.label.measureText("Ag").height).toBeCloseTo(24, 0); + }); + + it("radio groups: a checked newcomer wins, undefined leaves, Enter keeps it checked", () => { + const a = place(new UICheckbox(0, 0, { group: "g3", checked: true })); + const b = place(new UICheckbox(0, 40, { group: "g3", checked: true })); + expect([a.checked, b.checked]).toEqual([false, true]); + const onChange = vi.fn(); + b.onChange = onChange; + b.focus(); + key(input.KEY.ENTER); + key(input.KEY.ENTER, true); + expect(b.checked).toBe(true); + expect(onChange).not.toHaveBeenCalled(); + b.group = undefined; + expect(UICheckbox.checkedIn("g3")).toBeUndefined(); + }); + }); + + describe("UIPanel", () => { + it("scrolls sideways, and a plain wheel scrolls a sideways-only panel", () => { + const panel = place( + new UIPanel(100, 100, { + width: 200, + height: 60, + layout: "row", + scroll: "horizontal", + }), + ); + for (let i = 0; i < 6; i++) { + panel.addChild(box(60, 40)); + } + syncBroadphase(); + // 6 x 60 + 5 gaps of 8 = 400, in a 200 view + expect(panel.maxScrollX).toBe(200); + expect(panel.maxScrollY).toBe(0); + pointer("pointermove", 150, 130); + wheel(150, 130, 50); + expect(panel.scrollX).toBe(50); + }); + + it("scrolls both ways, and counts lines in a line-mode wheel", () => { + const panel = place( + new UIPanel(100, 100, { + width: 100, + height: 100, + layout: "grid", + columns: 2, + scroll: "both", + }), + ); + for (let i = 0; i < 6; i++) { + panel.addChild(box(80, 80)); + } + syncBroadphase(); + expect(panel.maxScrollY).toBeGreaterThan(0); + pointer("pointermove", 150, 150); + app.renderer.getCanvas().dispatchEvent( + new WheelEvent("wheel", { + ...at(150, 150), + deltaY: 3, + deltaMode: 1, + bubbles: true, + cancelable: true, + }), + ); + // 3 lines of 16 pixels + expect(panel.scrollY).toBe(48); + }); + + it("at its end, a blocking panel keeps the wheel, a non-blocking one lets it through", () => { + const behind = vi.fn(); + // a wide renderable under the panel, listening for the wheel + const under = box(800, 600); + // a kinematic renderable is left out of hit detection + under.isKinematic = false; + under.depth = -10; + place(under); + input.registerPointerEvent("wheel", under, behind); + // with nothing over it, it hears the wheel + wheel(150, 150, 50); + expect(behind).toHaveBeenCalledTimes(1); + behind.mockClear(); + try { + const make = (blocking) => { + const panel = place( + new UIPanel(100, 100, { + width: 200, + height: 100, + layout: "column", + scroll: "vertical", + blocking, + }), + ); + for (let i = 0; i < 6; i++) { + panel.addChild(box(100, 40)); + } + panel.scrollTo(0, panel.maxScrollY); + syncBroadphase(); + return panel; + }; + const blocking = make(true); + pointer("pointermove", 150, 150); + wheel(150, 150, 50); + expect(behind).not.toHaveBeenCalled(); + app.world.removeChildNow(blocking); + make(false); + syncBroadphase(); + wheel(150, 150, 50); + expect(behind).toHaveBeenCalled(); + } finally { + input.releasePointerEvent("wheel", under, behind); + } + }); + + it("a fling stops at the end of the content", () => { + const panel = place( + new UIPanel(100, 100, { + width: 200, + height: 100, + layout: "column", + gap: 0, + scroll: "vertical", + }), + ); + for (let i = 0; i < 10; i++) { + panel.addChild(box(100, 40)); + } + syncBroadphase(); + const now = vi.spyOn(performance, "now"); + let t = 1000; + now.mockImplementation(() => { + return t; + }); + pointer("pointermove", 150, 190); + pointer("pointerdown", 150, 190); + for (let i = 1; i <= 4; i++) { + t += 8; + pointer("pointermove", 150, 190 - i * 20); + } + pointer("pointerup", 150, 110); + for (let i = 0; i < 300; i++) { + panel.update(16); + } + expect(panel.scrollY).toBe(panel.maxScrollY); + }); + + it("grid stretch fills the cells, and a row grows to fit with autoSize", () => { + const grid = place( + new UIPanel(0, 0, { + width: 220, + height: 200, + layout: "grid", + columns: 2, + padding: 10, + gap: 0, + align: "stretch", + }), + ); + const b = grid.addChild(new UITextButton(0, 0, { text: "x" })); + expect(b.width).toBe(100); + const row = place( + new UIPanel(0, 0, { + width: 10, + height: 10, + layout: "row", + gap: 10, + autoSize: true, + }), + ); + row.addChild(box(40, 20)); + row.addChild(box(40, 30)); + expect([row.width, row.height]).toEqual([90, 30]); + }); + + it("clearChildren puts the scroll back to the start", () => { + const panel = place( + new UIPanel(0, 0, { + width: 200, + height: 100, + layout: "column", + scroll: "vertical", + }), + ); + for (let i = 0; i < 6; i++) { + panel.addChild(box(100, 40)); + } + panel.scrollTo(0, 100); + panel.clearChildren(); + expect([panel.scrollY, panel.maxScrollY]).toEqual([0, 0]); + }); + }); + + describe("focus", () => { + it("draws the ring while it shows, not once the mouse moves", () => { + const b = place( + new UITextButton(100, 100, { text: "x", width: 100, height: 40 }), + ); + const stroke = vi.spyOn(app.renderer, "stroke"); + b.focus(); + b.preDraw(app.renderer); + b.postDraw(app.renderer); + const ring = stroke.mock.calls.length; + expect(ring).toBeGreaterThan(0); + pointer("pointermove", 700, 500); + b.preDraw(app.renderer); + b.postDraw(app.renderer); + expect(stroke.mock.calls.length).toBe(ring); + }); + + it("the focused widget leaving the scene gives the keys back", () => { + const panel = place( + new UIPanel(0, 0, { width: 200, height: 200, layout: "column" }), + ); + const a = panel.addChild(new UITextButton(0, 0, { text: "a" })); + panel.addChild(new UITextButton(0, 0, { text: "b" })); + a.focus(); + key(input.KEY.ENTER); + app.world.removeChildNow(panel); + expect(UIBaseElement.focusOwner).toBeUndefined(); + key(input.KEY.ENTER, true); + key(input.KEY.DOWN); + expect(UIBaseElement.focusOwner).toBeUndefined(); + }); + + it("hears a virtual pad sharing a slot with a real one", () => { + const panel = place( + new UIPanel(0, 0, { width: 200, height: 200, layout: "column" }), + ); + const a = panel.addChild(new UITextButton(0, 0, { text: "a" })); + const b = panel.addChild(new UITextButton(0, 0, { text: "b" })); + const idle = () => { + const buttons = []; + for (let i = 0; i < 17; i++) { + buttons.push({ pressed: false, value: 0 }); + } + return buttons; + }; + const real = { id: "real", buttons: idle(), axes: [0, 0, 0, 0] }; + const virtual = { id: "virtual", buttons: idle(), axes: [0, 0, 0, 0] }; + const original = navigator.getGamepads; + navigator.getGamepads = () => { + return [real, null, null, null]; + }; + input.addVirtualGamepad(virtual, 0); + try { + a.focus(); + virtual.buttons[input.GAMEPAD.BUTTONS.DOWN].pressed = true; + event.emit(event.GAME_BEFORE_UPDATE, 1); + expect(UIBaseElement.focusOwner).toBe(b); + } finally { + input.removeVirtualGamepad(virtual); + navigator.getGamepads = original; + } + }); + }); + }); + describe("API", () => { it("enabled is shared by every widget, the panel included", () => { const panel = new UIPanel(0, 0, { width: 10, height: 10 }); @@ -1531,7 +2137,8 @@ describe("UI widgets", () => { }; const b = new UITextButton(0, 0, settings); expect([b.width, b.height]).toEqual([120, 30]); - expect(b.label.fontSize ?? b.label.font).toBeDefined(); + expect(b.label.fontSize).toBe(20); + expect(b.label.fillStyle.toHex().toLowerCase()).toBe("#ff0000"); // the settings are read, not written back expect(Object.keys(settings)).toEqual([ "text", diff --git a/packages/melonjs/tests/ui.spec.js b/packages/melonjs/tests/ui.spec.js index 71cb80f34..a4d871f50 100644 --- a/packages/melonjs/tests/ui.spec.js +++ b/packages/melonjs/tests/ui.spec.js @@ -273,121 +273,50 @@ describe("UI", () => { }); }); - describe("property-wiring via stub subclass", () => { - // A subclass that captures and exposes the settings before BitmapText - // is constructed, letting us verify the property assignments that - // UITextButton performs before reaching the BitmapText call. - class UITextButtonStub extends UIBaseElement { - constructor(x, y, settings) { - super(x, y); - - // Mirror the exact property-wiring from UITextButton constructor, - // stopping just before `new BitmapText(...)`. - this.bindKey = settings.bindKey || -1; - - this.hoverOffColor = - settings.hoverOffColor || settings.backgroundColor || "#00aa0080"; - this.hoverOnColor = - settings.hoverOnColor || settings.hoverColor || "#00ff00ff"; - - this.borderStrokeColor = settings.borderStrokeColor || "#000000"; - this.textAlign = settings.textAlign || "center"; - this.textBaseline = settings.textBaseline || "middle"; - } - } - - it("hoverOffColor should default to #00aa0080", () => { - const btn = new UITextButtonStub(0, 0, { font: "Arial", text: "OK" }); + describe("property wiring", () => { + // the real class: it builds without a bitmap font, as a CSS one + const make = (settings = {}) => { + return new UITextButton(0, 0, { text: "OK", ...settings }); + }; + + it("has its documented defaults", () => { + const btn = make(); expect(btn.hoverOffColor).toBe("#00aa0080"); - }); - - it("hoverOnColor should default to #00ff00ff", () => { - const btn = new UITextButtonStub(0, 0, { font: "Arial", text: "OK" }); expect(btn.hoverOnColor).toBe("#00ff00ff"); - }); - - it("borderStrokeColor should default to #000000", () => { - const btn = new UITextButtonStub(0, 0, { font: "Arial", text: "OK" }); expect(btn.borderStrokeColor).toBe("#000000"); - }); - - it("textAlign should default to center", () => { - const btn = new UITextButtonStub(0, 0, { font: "Arial", text: "OK" }); expect(btn.textAlign).toBe("center"); - }); - - it("textBaseline should default to middle", () => { - const btn = new UITextButtonStub(0, 0, { font: "Arial", text: "OK" }); expect(btn.textBaseline).toBe("middle"); - }); - - it("bindKey should default to -1 when not provided", () => { - const btn = new UITextButtonStub(0, 0, { font: "Arial", text: "OK" }); expect(btn.bindKey).toBe(-1); + expect(btn.pressedColor).toBeUndefined(); + expect(btn.floating).toBe(true); + expect(btn.isKinematic).toBe(false); + expect(btn.focusable).toBe(true); }); - it("should apply custom hoverOffColor", () => { - const btn = new UITextButtonStub(0, 0, { - font: "Arial", - text: "Hi", - hoverOffColor: "#ff000080", - }); - expect(btn.hoverOffColor).toBe("#ff000080"); - }); - - it("should apply custom hoverOnColor", () => { - const btn = new UITextButtonStub(0, 0, { - font: "Arial", - text: "Hi", - hoverOnColor: "#0000ffff", - }); - expect(btn.hoverOnColor).toBe("#0000ffff"); - }); - - it("should apply custom borderStrokeColor", () => { - const btn = new UITextButtonStub(0, 0, { - font: "Arial", - text: "Hi", - borderStrokeColor: "#ffffff", - }); - expect(btn.borderStrokeColor).toBe("#ffffff"); - }); - - it("should apply a custom bindKey", () => { - const btn = new UITextButtonStub(0, 0, { - font: "Arial", - text: "Hi", - bindKey: "ENTER", - }); - expect(btn.bindKey).toBe("ENTER"); - }); - - it("should accept deprecated backgroundColor as hoverOffColor fallback", () => { - const btn = new UITextButtonStub(0, 0, { - font: "Arial", - text: "Hi", - backgroundColor: "#abcdef80", + it("applies custom colours", () => { + const btn = make({ + hoverOffColor: "#11111180", + hoverOnColor: "#222222ff", + borderStrokeColor: "#333333", + pressedColor: "#444444", }); - expect(btn.hoverOffColor).toBe("#abcdef80"); + expect(btn.hoverOffColor).toBe("#11111180"); + expect(btn.hoverOnColor).toBe("#222222ff"); + expect(btn.borderStrokeColor).toBe("#333333"); + expect(btn.pressedColor).toBe("#444444"); }); - it("should accept deprecated hoverColor as hoverOnColor fallback", () => { - const btn = new UITextButtonStub(0, 0, { - font: "Arial", - text: "Hi", - hoverColor: "#11223344", + it("takes borderColor over the older borderStrokeColor", () => { + const btn = make({ + borderColor: "#aa0000", + borderStrokeColor: "#00aa00", }); - expect(btn.hoverOnColor).toBe("#11223344"); + expect(btn.borderStrokeColor).toBe("#aa0000"); }); - it("floating should be true (inherited from UIBaseElement)", () => { - const btn = new UITextButtonStub(0, 0, { font: "Arial", text: "OK" }); - expect(btn.floating).toBe(true); - }); - - it("isKinematic should be false", () => { - const btn = new UITextButtonStub(0, 0, { font: "Arial", text: "OK" }); - expect(btn.isKinematic).toBe(false); + it("keeps a bindKey of 0, a valid key code", () => { + expect(make({ bindKey: 0 }).bindKey).toBe(0); + expect(make({ bindKey: "jump" }).bindKey).toBe("jump"); }); }); From d706c94731b2659625d074100f622ef7c195a9c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 07:19:00 +0000 Subject: [PATCH 17/43] gamepad-plugin 1.0.2 Dates the 1.0.2 changelog section: a touch on the controls no longer reaches the game as a hover (#1805). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/gamepad-plugin/CHANGELOG.md | 2 +- packages/gamepad-plugin/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/gamepad-plugin/CHANGELOG.md b/packages/gamepad-plugin/CHANGELOG.md index 471fd4102..247067cc8 100644 --- a/packages/gamepad-plugin/CHANGELOG.md +++ b/packages/gamepad-plugin/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [1.0.2] - _unreleased_ +## [1.0.2] - _2026-10-31_ ### Fixed - A touch on the controls no longer reaches the game as a hover: the `pointerover` / `pointerenter` before it and the `pointerout` / `pointerleave` after it are kept from the engine too, so a widget under a button no longer gets `onOver` as the button is pressed ([#1805](https://github.com/melonjs/melonJS/issues/1805)). A mouse hovering over the controls is unchanged diff --git a/packages/gamepad-plugin/package.json b/packages/gamepad-plugin/package.json index 9c91fced8..f70c9a274 100644 --- a/packages/gamepad-plugin/package.json +++ b/packages/gamepad-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@melonjs/gamepad-plugin", - "version": "1.0.1", + "version": "1.0.2", "description": "melonJS virtual gamepad plugin", "homepage": "https://www.npmjs.com/package/@melonjs/gamepad-plugin", "type": "module", From b39dd705adf6ec8ed41a86887a46464c54d03a30 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 08:17:58 +0000 Subject: [PATCH 18/43] Skill: UI widgets, with a widget map and a complete menu example - a "which widget for what" table - one complete options menu: skinned panel, column layout, checkbox, slider, button, drop shadow, bounce, keyboard / gamepad focus - code for drop shadows, a scrolling level list, radio groups, skinned checkboxes and sliders, stepped and vertical sliders, and a custom focusable widget - symptom rows for focus, bindKey vs focus, and shadow corners - drops a reference to the UI example's old hand-made panel Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- .../skills/melonjs-ui-and-text/SKILL.md | 174 +++++++++++++++++- 1 file changed, 171 insertions(+), 3 deletions(-) diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index 89ef6fbf7..b6085e58e 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -290,6 +290,68 @@ There is no `UIContainer` class. Use `Container` for a bare grouping, `UIBaseElement` when you want the pointer handling and nothing drawn — it is a `Container` that already sets `floating = true` and `isKinematic = false`. +### Which widget for what + +| you want | use | notes | +|---|---|---| +| a button with a label | `UITextButton` | drawn, or skinned from an atlas; `onClick` setting | +| an image-only button or icon | `UISpriteElement` | a `Sprite` that takes clicks; override `onClick` | +| an on/off option, or one of several | `UICheckbox` | a radio button with `group` | +| a value in a range: volume, sensitivity | `UISlider` | horizontal or vertical | +| health, shield, cooldown, loading | `ProgressBar` | not clickable; `value` redraws | +| a frame, dialog or menu card | `UIPanel` | skin, title, layout, scrolling, drag, shadow | +| your own clickable widget | extend `UIBaseElement` | pointer handlers, `enabled`, focus, bounce | +| a group with nothing drawn | `Container` | set `floating` for screen space | + +### A whole options menu + +Everything below in one place: a skinned panel that lays its widgets out +as a column, a checkbox, a slider, two buttons, a drop shadow, a little +bounce, and keyboard / gamepad play. Each piece has its own section after +this. + +```js +import { + UICheckbox, UIPanel, UISlider, UITextButton, audio, state, +} from "melonjs"; + +// one style object for every widget's text +const FONT = { font: "kenpixel", fontSize: 14, labelFillStyle: "black" }; + +const menu = new UIPanel(40, 40, { + width: 360, height: 100, // autoSize grows the height to fit + skin: { atlas, region: "grey_panel", insetx: 10, insety: 10 }, + title: "OPTIONS", titleFont: "kenpixel", titleFontSize: 20, + layout: "column", padding: 16, gap: 12, align: "stretch", autoSize: true, + shadowColor: "#00000059", shadowOffsetY: 6, shadowBlur: 10, radius: 6, +}); + +const music = menu.addChild(new UICheckbox(0, 0, { + ...FONT, + labelOn: "Music ON", labelOff: "Music OFF", + checked: settings.music, + onChange: (on) => { settings.music = on; }, // user input only +})); + +menu.addChild(new UISlider(0, 0, { + ...FONT, + label: "Volume", showValue: true, + valueFormat: (v, ratio) => `${Math.round(ratio * 100)}%`, + value: audio.getVolume(), + onChange: (v) => audio.setVolume(v), // while dragging + onChangeEnd: () => saveSettings(), // once, on release +})); + +menu.addChild(new UITextButton(0, 0, { + ...FONT, text: "Back", height: 40, + hoverScale: 1.04, pressScale: 0.96, // the bounce + onClick: () => state.change(state.MENU), // a completed click +})); + +app.world.addChild(menu, 10); +music.focus(); // arrows / D-pad and Enter / A now drive the menu +``` + Three things matter here: - **`floating = true`** opts the container out of camera transforms, so it stays @@ -456,6 +518,20 @@ const options = new UIPanel(20, 20, { **A drop shadow**, here or on a `ProgressBar`, takes a colour whose alpha is how dark it gets: `shadowColor: "#00000059"`, with `shadowOffsetX` (0), `shadowOffsetY` (4) and `shadowBlur` (8 pixels of soft edge, `0` for sharp). +There is none until `shadowColor` is set. + +```js +const card = new UIPanel(100, 80, { + width: 300, height: 180, fillColor: "#f0f0f0", radius: 8, + shadowColor: "#00000059", // about 35% black + shadowOffsetY: 6, // drops it below the card + shadowBlur: 10, // pixels of soft edge +}); +const hp = new ProgressBar(20, 20, { + width: 200, height: 16, radius: 8, + shadowColor: "#00000080", shadowOffsetY: 2, shadowBlur: 4, +}); +``` It follows `radius`, which a skinned panel can set to match its art's corners, and it is drawn outside the bounds, so it never takes the pointer. Under a see-through fill or track it shows through; give that a solid colour. @@ -498,7 +574,18 @@ const levels = new UIPanel(420, 435, { width: 340, height: 150, layout: "grid", columns: 5, gap: 8, padding: 10, scroll: "vertical", // "horizontal", "both", or "none" + onScroll: (x, y, panel) => { saved.levelScroll = y; }, }); +const buttons = []; +for (let i = 1; i <= 30; i++) { + buttons[i] = levels.addChild(new UITextButton(0, 0, { + text: String(i), width: 56, height: 40, + enabled: i <= unlocked, + onClick: () => startLevel(i), + })); +} +levels.scrollTo(0, saved.levelScroll); // restore where it was +levels.scrollToChild(buttons[unlocked]); // or show the latest ``` The content is cropped to the area inside the frame and below the title, and @@ -666,7 +753,37 @@ are untouched. Buttons, checkboxes and sliders are `focusable`. A widget of your own sets `focusable = true`, and overrides `onNavigate(direction)` (return `true` for -what it handles) and `focusRelease()` for what Enter does. +what it handles) and `focusRelease()` for what Enter does: + +```js +// a left / right picker: "< Normal >"; Enter confirms the choice +class Picker extends UIBaseElement { + constructor(x, y, options, onPick) { + super(x, y, 200, 32); + this.focusable = true; + this.options = options; + this.onPick = onPick; + this.index = 0; + // draw() left out: render this.options[this.index] + } + onNavigate(direction) { + if (direction === "left" || direction === "right") { + const step = direction === "left" ? -1 : 1; + this.index = (this.index + step + this.options.length) % this.options.length; + this.isDirty = true; + return true; // handled: the focus stays here + } + return false; // up and down move the focus on + } + focusRelease() { + super.focusRelease(); // the widget comes back up, as on a click + this.onPick(this.options[this.index]); + } +} +``` + +Moving the focus from code is `other.focus()`; `UIBaseElement.focusOwner?.blur()` +hands the keys back to the game, for example when gameplay resumes. Two things to know. Enter goes to the focused button, so another button whose `bindKey` is Enter waits while the focus ring shows elsewhere. And @@ -746,6 +863,34 @@ the one the user picked gets `onChange`, as in a web page; read the group with `UICheckbox.checkedIn("difficulty")`. A checkbox is in its group while it is in the scene. +```js +for (const level of ["Easy", "Normal", "Hard"]) { + panel.addChild(new UICheckbox(0, 0, { + label: level, + group: "difficulty", + checked: level === saved.difficulty, + onChange: (on) => { if (on) { saved.difficulty = level; } }, + })); +} +// later: the checked UICheckbox of the group, or undefined +const picked = UICheckbox.checkedIn("difficulty"); +``` + +**Skinned** from an atlas (the region names are your art's; these are the +UI example's): + +```js +new UICheckbox(0, 0, { + label: "Sound FX", + skin: { + atlas, + on: "green_boxCheckmark", + off: "grey_boxCheckmark", + // optional: hoverOn, hoverOff, disabledOn, disabledOff + }, +}); +``` + ### Sliders: `UISlider` ```js @@ -773,6 +918,24 @@ own size. Put work that should not run 60 times a second, such as saving or playing a test sound, in `onChangeEnd`. +```js +// whole numbers: a step of 1 shows "7", not "7.00" +new UISlider(0, 0, { label: "Difficulty", showValue: true, min: 1, max: 10, step: 1, value: 5 }); + +// vertical, 160 pixels tall: up raises it +new UISlider(0, 0, { orientation: "vertical", length: 160, value: 0.5 }); + +// skinned: the track and fill stretch, the thumb keeps its size +new UISlider(0, 0, { + length: 240, + // optional: fill (the part before the thumb), thumbHover + skin: { atlas, track: "grey_sliderHorizontal", thumb: "blue_sliderDown" }, +}); +``` + +With no `valueFormat`, the value text shows as many decimals as `step` +has, and follows a later change of `step`. + ### A fullscreen option Three methods on the `Application`, not on `device` (they moved there in @@ -859,8 +1022,9 @@ 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: +`UIPanel`'s `skin` and `UITextButton`'s `skin` already do this for you. By +hand, from a texture atlas, the third argument of `createSpriteFromName` asks +for the 9-slice version: ```js class Panel extends UIBaseElement { @@ -970,6 +1134,10 @@ City and Instanced Forest examples park a floating sky at `-10000` or `100000`. | a button in a scrolling list fires when the user only meant to scroll | the work is in the `onClick()` METHOD, which runs on the press | use the `onClick` SETTING, called on a completed click; a scroll cancels the press | | a button scrolled out of a hand-made list still gets clicks | its container does not clip, so the pointer still finds it where it moved to | use `UIPanel`'s `scroll`, or set `clipping` on the container: since 20.13 the pointer skips children outside a clipping container | | a layout ignores a label that changed size | layout runs on add, remove and resize only | call `panel.relayout()` | +| the arrow keys or the pad do nothing in a menu | no widget has the focus: nothing listens until one does | call `firstWidget.focus()` when the menu opens | +| the arrow keys stop moving the player after a menu | a widget still has the focus | `UIBaseElement.focusOwner?.blur()`, or remove the menu: a focused widget leaving the scene gives the keys back | +| a `bindKey: ENTER` button does not fire while another widget is focused | Enter goes to the focused widget while its ring shows | focus that button, or bind another key | +| a skinned panel's shadow has square corners | the shadow follows `radius`, and a skin does not set it | set `radius` to the art's corner size | | `new UITextButton` with a font name draws in a plain font | the name is not a loaded bitmap font, so it is drawn as a CSS font | load the bitmap font (image and `.fnt`) under that name, or load the web font | ## Related skills From ec8e1a4b40d5debc06720ed044dd13ff310ad7b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 08:20:55 +0000 Subject: [PATCH 19/43] Docs: UICheckbox and UISlider, with examples Class docs in sections (look, label, state and onChange, radio groups, keyboard and gamepad; range, input, text, look), every setting with its unit and default, and examples for the common uses: a settings toggle, a label that follows the state, a radio group, skins, restoring saved settings, a volume slider, stepped and vertical sliders, percent text, a slider in a scrolling panel. Three docs that contradicted the code now match it (trackThickness with a skin, the checkbox fontSize minimum, onChangeEnd on arrow steps). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- .../melonjs/src/renderable/ui/uicheckbox.ts | 265 +++++++++++----- .../melonjs/src/renderable/ui/uislider.ts | 300 +++++++++++++----- 2 files changed, 417 insertions(+), 148 deletions(-) diff --git a/packages/melonjs/src/renderable/ui/uicheckbox.ts b/packages/melonjs/src/renderable/ui/uicheckbox.ts index 9c32afb95..9f29cb0d4 100644 --- a/packages/melonjs/src/renderable/ui/uicheckbox.ts +++ b/packages/melonjs/src/renderable/ui/uicheckbox.ts @@ -12,25 +12,45 @@ import { SkinDrawer } from "./uiskin.ts"; /** * The art of a skinned {@link UICheckbox}: one atlas region per state. + * + * Only `on` and `off` are needed. Each region is drawn as a square of + * {@link UICheckboxSettings#boxSize} pixels (the height of `off` by + * default). Every region named is looked up when the checkbox is built, so + * a misspelt name throws there. * @category UI + * @example + * // two regions: the rest fall back to them + * const skin = { atlas, on: "green_boxCheckmark", off: "grey_boxCheckmark" }; */ export interface UICheckboxSkin extends UISkin { - /** the box when checked */ + /** the region drawn when checked */ on: string; - /** the box when not checked */ + /** the region drawn when not checked */ off: string; - /** the checked box under the pointer; `on` if left out */ + /** the region drawn when checked and under the pointer; `on` if left out */ hoverOn?: string; - /** the unchecked box under the pointer; `off` if left out */ + /** + * the region drawn when not checked and under the pointer; `off` if left + * out + */ hoverOff?: string; - /** the checked box while disabled; `on` at half opacity if left out */ + /** + * the region drawn when checked and disabled; `on` at half opacity if + * left out + */ disabledOn?: string; - /** the unchecked box while disabled; `off` at half opacity if left out */ + /** + * the region drawn when not checked and disabled; `off` at half opacity + * if left out + */ disabledOff?: string; } /** - * Everything {@link UICheckbox} takes. + * The settings {@link UICheckbox} takes. All are optional. + * + * The colour settings are for the drawn look and are ignored when a `skin` + * is given. * @category UI */ export interface UICheckboxSettings extends UIJuiceSettings { @@ -39,30 +59,36 @@ export interface UICheckboxSettings extends UIJuiceSettings { * @default false */ checked?: boolean; - /** the text next to the box, whatever the state */ + /** + * the text next to the box, the same in both states; `labelOn` and + * `labelOff` replace it for one state each + */ label?: string; - /** the text while checked, in place of `label` */ + /** the text while checked; `label` if left out */ labelOn?: string; - /** the text while not checked, in place of `label` */ + /** the text while not checked; `label` if left out */ labelOff?: string; /** - * Called when the user changes it, never when `checked` is set from code. - * See {@link UICheckbox#onChange}. + * Called with the new state when the user checks or unchecks it (click, + * tap, or Enter with the focus). Never called when `checked` is set or + * `toggle()` is called from code. See {@link UICheckbox#onChange}. */ onChange?: (checked: boolean, checkbox: UICheckbox) => void; /** - * whether it answers input + * whether it answers input; a disabled checkbox is drawn faded, or with + * the skin's `disabledOn` / `disabledOff` regions * @default true */ enabled?: boolean; /** - * the side of the box, in pixels; 24, or the height of the skin's `off` - * region + * the side of the box, in pixels; with a skin, the height of its `off` + * region by default + * @default 24 */ boxSize?: number; /** - * Make it one of a set of radio buttons: checkboxes sharing a group name - * have exactly one checked. See {@link UICheckbox#group}. + * a radio group name: checkboxes sharing it have one checked at a time, + * and are drawn round. See {@link UICheckbox#group}. */ group?: string; /** @@ -70,7 +96,10 @@ export interface UICheckboxSettings extends UIJuiceSettings { * @default "sans-serif" */ font?: string; - /** the label's height, in pixels; two thirds of `boxSize` by default */ + /** + * the label's text height, in pixels; two thirds of `boxSize`, and at + * least 12, by default + */ fontSize?: number; /** * the label's colour @@ -83,36 +112,38 @@ export interface UICheckboxSettings extends UIJuiceSettings { */ labelGap?: number; /** - * the box's fill (drawn look) + * the box's fill when not checked (drawn look); also the tick's colour * @default "#ffffff" */ boxColor?: Paint; /** - * the box's fill under the pointer (drawn look) + * the box's fill when not checked and under the pointer (drawn look) * @default "#e4eefb" */ boxHoverColor?: Paint; /** - * the box's fill when checked, and the radio dot (drawn look) + * the box's fill and outline when checked, and a radio button's dot + * (drawn look) * @default "#2a7de1" */ checkColor?: Paint; /** - * the box's outline (drawn look) + * the box's outline when not checked (drawn look) * @default "#3a3a3a" */ borderColor?: Paint; /** - * the outline's thickness (drawn look) + * the outline's thickness, in pixels (drawn look); `0` for none * @default 2 */ borderWidth?: number; /** - * the box's corner radius (drawn look); a radio button is round + * the box's corner radius, in pixels (drawn look); ignored by a radio + * button, which is round * @default 4 */ radius?: number; - /** draw the box from atlas regions instead */ + /** draw the box from atlas regions instead of the drawn look */ skin?: UICheckboxSkin; } @@ -120,59 +151,125 @@ export interface UICheckboxSettings extends UIJuiceSettings { const groups = new Map>(); /** - * A box that can be checked, with a label next to it; with a `group`, a - * radio button. + * A checkbox with a text label, for an on/off option such as music, sound + * or full screen. Give several the same `group` and they become radio + * buttons: one choice out of a few, such as a difficulty level. + * + * ## Look + * - Drawn with shapes by default, so it needs no art: a square with a tick, + * or a circle with a dot for a radio button. The colour settings + * (`boxColor`, `checkColor`, `borderColor`...) change it. + * - Skinned: pass a {@link UICheckboxSkin} of atlas regions, one per state + * (`on`, `off`, and optionally `hoverOn`, `hoverOff`, `disabledOn`, + * `disabledOff`). + * + * ## Label + * `label` is the text next to the box. `labelOn` and `labelOff` give it a + * different text in each state ("Music ON" / "Music OFF"). The + * {@link UICheckbox#labelOn} and {@link UICheckbox#labelOff} properties + * change the text later, and the widget resizes to fit. It is as wide as the box + * plus the longer of the two texts, so it does not change size as it + * toggles. The label is part of the widget: clicking it toggles the box too. + * + * ## State and onChange + * The state is {@link UICheckbox#checked}. `onChange` is called only when + * the player changes it. Setting `checked`, or calling `toggle()`, from code + * never calls it: restore saved settings by setting `checked`, without + * running the code a click would (starting the music twice, saving again). * - * Drawn with primitives by default, so it needs no art, or from atlas - * regions through a {@link UICheckboxSkin}. The label is part of the - * widget: clicking it toggles the box, and removing the widget takes it - * along. + * ## Radio groups + * Checkboxes with the same {@link UICheckbox#group} name have one checked + * at a time: checking one unchecks the others, and clicking the checked one + * leaves it checked. A checkbox belongs to its group while it is in the + * scene, so {@link UICheckbox.checkedIn} only sees checkboxes that have + * been added to the world. A checked one added to a group unchecks the + * others already there. * - * Its state is {@link UICheckbox#checked}. The game hears about a change - * through `onChange`, which only user input calls: restoring saved settings - * by setting `checked` does not run the game logic attached to it. + * ## Keyboard and gamepad + * It can take the focus ({@link UIBaseElement#focus}). With the focus, + * Enter, Space or the pad's A button toggles it, as a click does, and calls + * `onChange`. + * + * Set `enabled` to `false` to turn it off: it ignores input and is drawn + * faded, or with the skin's disabled regions. * @category UI * @example - * const music = new UICheckbox(40, 60, { + * // a settings toggle, drawn, with no assets + * const subtitles = new UICheckbox(40, 60, { + * label: "Subtitles", + * checked: settings.subtitles, + * onChange: (on) => { + * settings.subtitles = on; + * }, + * }); + * panel.addChild(subtitles); + * @example + * // a label that follows the state + * const music = new UICheckbox(40, 100, { * labelOn: "Music ON", * labelOff: "Music OFF", - * checked: settings.music, + * checked: true, * onChange: (on) => { - * settings.music = on; * on ? audio.playTrack("theme") : audio.stopTrack(); * }, * }); * panel.addChild(music); - * - * // three radio buttons, one checked at a time - * for (const [i, level] of ["Easy", "Normal", "Hard"].entries()) { - * panel.addChild(new UICheckbox(40, 120 + i * 32, { + * @example + * // a radio group, and reading the choice + * const levels = new Map(); + * for (const [i, level] of ["easy", "normal", "hard"].entries()) { + * const radio = new UICheckbox(40, 140 + i * 32, { * label: level, * group: "difficulty", - * checked: level === "Normal", - * })); + * checked: level === "normal", + * onChange: () => console.log("picked", level), + * }); + * levels.set(radio, level); + * panel.addChild(radio); * } - * const chosen = UICheckbox.checkedIn("difficulty"); + * // later, once the panel is in the world (e.g. when OK is clicked) + * const difficulty = levels.get(UICheckbox.checkedIn("difficulty")); + * @example + * // skinned from an atlas, with a bitmap font + * const sfx = new UICheckbox(40, 260, { + * label: "Sound effects", + * font: "kenpixel", + * fontSize: 14, + * skin: { atlas, on: "green_boxCheckmark", off: "grey_boxCheckmark" }, + * }); + * @example + * // restoring saved settings without calling onChange + * save.add({ music: true }); + * music.checked = save.music; // the look updates, onChange is not called + * save.music ? audio.playTrack("theme") : audio.stopTrack(); // applied once */ export default class UICheckbox extends UIBaseElement { /** - * Called when the user checks or unchecks it, with the new state. + * Called with the new state when the user checks or unchecks it: a + * click or tap on the box or its label, or Enter, Space or the pad's A + * with the focus. * * Never called when {@link UICheckbox#checked} is set, or * {@link UICheckbox#toggle} is called, from code. In a radio group only - * the button the user picked is told; the one that was unchecked to make - * room is not, as with radio buttons in a web page. + * the button the user picked is called; the one unchecked to make room + * is not. Not called while the checkbox is disabled. + * @example + * sfx.onChange = (on) => { + * settings.sfx = on; + * }; */ onChange: ((checked: boolean, checkbox: UICheckbox) => void) | undefined; /** - * The radio group this checkbox belongs to, if any. + * The radio group this checkbox belongs to, or `undefined` for a plain + * checkbox. * - * Checkboxes sharing a group have exactly one checked: checking one - * unchecks the others, and clicking the checked one leaves it checked. A - * checkbox is in its group while it is in the scene; removing it takes it - * out. Changing it moves the checkbox to the new group straight away; - * a checked one then unchecks the others there. + * Checkboxes sharing a group have one checked at a time: checking one + * unchecks the others, and clicking the checked one leaves it checked. + * A radio button is drawn round, with a dot. A checkbox is in its group + * while it is in the scene; removing it takes it out. Changing `group` + * moves it to the new group straight away, and if it is checked it + * unchecks the others there. */ get group(): string | undefined { return this._group; @@ -193,26 +290,33 @@ export default class UICheckbox extends UIBaseElement { this.isDirty = true; } - /** the box's fill (drawn look) */ + /** the box's fill when not checked, and the tick's colour (drawn look) */ boxColor: Paint; - /** the box's fill under the pointer (drawn look) */ + /** the box's fill when not checked and under the pointer (drawn look) */ boxHoverColor: Paint; - /** the box's fill when checked, and the radio dot (drawn look) */ + /** + * the box's fill and outline when checked, and a radio button's dot + * (drawn look) + */ checkColor: Paint; - /** the box's outline (drawn look) */ + /** the box's outline when not checked (drawn look) */ borderColor: Paint; - /** the outline's thickness (drawn look) */ + /** the outline's thickness, in pixels (drawn look); `0` for none */ borderWidth: number; - /** the box's corner radius (drawn look) */ + /** the box's corner radius, in pixels (drawn look; not for a radio button) */ radius: number; /** the side of the box, in pixels */ readonly boxSize: number; /** the space between the box and the label, in pixels */ readonly labelGap: number; - /** the label, absent when there is no text */ + /** + * The text object drawn next to the box, or `undefined` when there is no + * text. Change the text through {@link UICheckbox#labelOn} and + * {@link UICheckbox#labelOff}, which also resize the widget. + */ label: UILabel | undefined; - /** the art, when it is skinned */ + /** the atlas regions it is drawn from, or `undefined` for the drawn look */ readonly skin: UICheckboxSkin | undefined; /** @ignore */ @@ -299,9 +403,13 @@ export default class UICheckbox extends UIBaseElement { /** * Whether it is checked. * - * Setting it updates the look WITHOUT calling `onChange`, so restoring - * saved settings does not trigger the game logic that a click would. - * Checking a radio button unchecks the others in its group. + * Setting it updates the look and the label, and does NOT call + * `onChange`: restoring saved settings this way does not run the code a + * click would. Checking a radio button unchecks the others in its group, + * silently too. + * @example + * // show the saved state when the menu opens + * music.checked = save.music; */ get checked(): boolean { return this._checked; @@ -320,7 +428,12 @@ export default class UICheckbox extends UIBaseElement { } /** - * The text while checked. Setting it resizes the widget to fit. + * The text shown while checked. Setting it updates the label and + * resizes the widget to fit; set both this and + * {@link UICheckbox#labelOff} to change a single `label`. + * @example + * music.labelOn = "Music: on"; + * music.labelOff = "Music: off"; */ get labelOn(): string { return this._labelOn; @@ -332,7 +445,8 @@ export default class UICheckbox extends UIBaseElement { } /** - * The text while not checked. Setting it resizes the widget to fit. + * The text shown while not checked. Setting it updates the label and + * resizes the widget to fit. */ get labelOff(): string { return this._labelOff; @@ -382,8 +496,9 @@ export default class UICheckbox extends UIBaseElement { } /** - * Flip it, from code. Like setting {@link UICheckbox#checked}, this does - * not call `onChange`. + * Flip it from code. Like setting {@link UICheckbox#checked}, this does + * not call `onChange`. On a checked radio button it unchecks it, which + * leaves its group with nothing checked. * @returns the new state */ toggle(): boolean { @@ -392,9 +507,16 @@ export default class UICheckbox extends UIBaseElement { } /** - * The checked checkbox of a radio group. + * The checked checkbox of a radio group: the player's choice. + * + * Only checkboxes in the scene are in a group, so this returns + * `undefined` until they (or the panel holding them) are added to the + * world. * @param group - the group name - * @returns the checked member in the scene, or undefined + * @returns the checked member, or `undefined` when none is checked + * @example + * const picked = UICheckbox.checkedIn("difficulty"); + * console.log(picked?.labelOn); // the text of the checked radio button */ static checkedIn(group: string): UICheckbox | undefined { const members = groups.get(group); @@ -445,7 +567,8 @@ export default class UICheckbox extends UIBaseElement { } /** - * Accept let go with the focus here: toggle, as a click does. + * Enter, Space or the pad's A let go with the focus here: toggle, and + * call `onChange`, as a click does. */ override focusRelease(): void { const pressed = !this.released; diff --git a/packages/melonjs/src/renderable/ui/uislider.ts b/packages/melonjs/src/renderable/ui/uislider.ts index 42cd76bd5..3635f87fc 100644 --- a/packages/melonjs/src/renderable/ui/uislider.ts +++ b/packages/melonjs/src/renderable/ui/uislider.ts @@ -22,30 +22,47 @@ import type { UISkin } from "./uiskin.ts"; import { SkinDrawer } from "./uiskin.ts"; /** - * The art of a skinned {@link UISlider}. + * The art of a skinned {@link UISlider}: atlas regions for the track, the + * filled part of the track and the thumb. * - * The track and the fill are drawn as nine-slices along the slider's length; - * the thumb at its own size. + * The track and the fill are stretched as nine-slices along the slider's + * {@link UISliderSettings#length}, and keep the region's thickness across + * it unless `trackThickness` is set. The thumb is drawn at its region's + * size. A disabled slider is drawn at half opacity. Every region named is + * looked up when the slider is built, so a misspelt name throws there. * @category UI + * @example + * // a track and a thumb; no fill + * const skin = { atlas, track: "grey_sliderHorizontal", thumb: "blue_sliderDown" }; */ export interface UISliderSkin extends UISkin { - /** the whole track */ + /** the region for the whole track */ track: string; - /** the part of the track up to the thumb; left out, it is not drawn */ + /** + * the region for the part of the track from the low end to the thumb; + * not drawn if left out + */ fill?: string; - /** the thumb */ + /** the region for the thumb */ thumb: string; - /** the thumb under the pointer or while dragged; `thumb` if left out */ + /** + * the region for the thumb under the pointer or while dragged; `thumb` + * if left out + */ thumbHover?: string; } /** - * Everything {@link UISlider} takes. + * The settings {@link UISlider} takes. All are optional. + * + * The colour settings are for the drawn look and are ignored when a `skin` + * is given. * @category UI */ export interface UISliderSettings extends UIJuiceSettings { /** - * the lowest value + * the lowest value; `min` and `max` are swapped if given the wrong way + * round * @default 0 */ min?: number; @@ -55,55 +72,73 @@ export interface UISliderSettings extends UIJuiceSettings { */ max?: number; /** - * The step values snap to; `0` for none. + * the interval values snap to, counted from `min`; `0` for no snapping * @default 0 */ step?: number; - /** the starting value; `min` by default */ + /** + * the starting value, snapped to `step` and clamped to the range; `min` + * by default + */ value?: number; /** - * which way it slides; a vertical slider has its highest value at the top + * which way it slides; a vertical slider has its highest value at the + * top. Fixed once built. * @default "horizontal" */ orientation?: "horizontal" | "vertical"; /** - * the length of the track, in pixels + * the length of the track, in pixels: its width on a horizontal slider, + * its height on a vertical one * @default 200 */ length?: number; /** - * the thickness of the track, in pixels (drawn look; a skin uses its - * track region's) + * the thickness of the track, in pixels; with a skin, the track region's + * thickness by default * @default 6 */ trackThickness?: number; /** - * the diameter of the thumb, in pixels (drawn look; a skin uses its - * thumb region's size) + * the diameter of the round thumb, in pixels (drawn look; a skin's thumb + * keeps its region's size) * @default 20 */ thumbSize?: number; - /** called on every change the user makes; see {@link UISlider#onChange} */ + /** + * Called with the new value on every change the user makes, while + * dragging too. Never called when `value` is set from code. See + * {@link UISlider#onChange}. + */ onChange?: (value: number, slider: UISlider) => void; - /** called once when the user lets go; see {@link UISlider#onChangeEnd} */ + /** + * Called with the final value once the user lets go, and after each + * wheel or key step. Never called when `value` is set from code. See + * {@link UISlider#onChangeEnd}. + */ onChangeEnd?: (value: number, slider: UISlider) => void; /** - * whether it answers input + * whether it answers input; a disabled slider is drawn at half opacity * @default true */ enabled?: boolean; - /** a caption, drawn above the track */ + /** + * a caption drawn above the track: at the left on a horizontal slider, + * centred on a vertical one + */ label?: string; /** - * show the value as text: right of the caption on a horizontal slider, - * below the track on a vertical one + * show the value as text: at the right end of the caption row on a + * horizontal slider, below the track on a vertical one * @default false */ showValue?: boolean; /** - * How the value is written, given the value and where it is between - * `min` and `max` (0 to 1), as `ProgressBar`'s `labelFormat` is: e.g. - * `(v, ratio) => Math.round(ratio * 100) + "%"` + * How the value text is written, given the value and the ratio (where + * the value is between `min` and `max`, 0 to 1). By default, the value + * with as many decimals as `step` has, or 2 decimals with no step. + * @example + * valueFormat: (value, ratio) => `${Math.round(ratio * 100)}%` */ valueFormat?: (value: number, ratio: number) => string; /** @@ -112,12 +147,12 @@ export interface UISliderSettings extends UIJuiceSettings { */ font?: string; /** - * the text height, in pixels + * the text height of the caption and the value, in pixels * @default 16 */ fontSize?: number; /** - * the text colour + * the colour of the caption and the value * @default "#000000" */ labelFillStyle?: string; @@ -127,7 +162,7 @@ export interface UISliderSettings extends UIJuiceSettings { */ trackColor?: Paint; /** - * the track up to the thumb (drawn look) + * the part of the track from the low end to the thumb (drawn look) * @default "#2a7de1" */ fillColor?: Paint; @@ -147,11 +182,12 @@ export interface UISliderSettings extends UIJuiceSettings { */ thumbBorderColor?: Paint; /** - * the thickness of the thumb's outline (drawn look); `0` for none + * the thickness of the thumb's outline, in pixels (drawn look); `0` for + * none * @default 2 */ thumbBorderWidth?: number; - /** draw it from atlas regions instead */ + /** draw it from atlas regions instead of the drawn look */ skin?: UISliderSkin; } @@ -164,51 +200,138 @@ interface Box { } /** - * A value picked by sliding a thumb along a track: volume, sensitivity, + * A slider: the player picks a number by moving a thumb along a track. Use + * it for settings with a range, such as volume, mouse sensitivity or * brightness. * - * Drag the thumb, or click or tap the track to jump there. The drag keeps - * following the pointer once it leaves the track. The mouse wheel over the - * slider moves it a step. Drawn with primitives by default, or from atlas - * regions through a {@link UISliderSkin}. + * ## Range and value + * The value is {@link UISlider#value}, between {@link UISlider#min} and + * {@link UISlider#max} (0 and 1 by default). With a {@link UISlider#step} + * it snaps to that interval; `step: 1` gives whole numbers. `min`, `max` + * and `step` can be changed later: the value is clamped and snapped again, + * silently. {@link UISlider#ratio} is where the value is in the range, from + * 0 to 1. + * + * ## Input + * - Drag the thumb. The drag keeps following the pointer when it leaves + * the slider, until the button or finger is released. + * - Click or tap the track: the thumb jumps there, and can be dragged on. + * - The mouse wheel over the slider moves it one step (a hundredth of the + * range with no `step`). + * - With the focus ({@link UIBaseElement#focus}), the arrow keys or the + * D-pad step it: left and right on a horizontal slider, up and down on a + * vertical one. A step is `step`, or a twentieth of the range without + * one. + * + * ## onChange and onChangeEnd + * `onChange` is called on every change the user makes, many times during a + * drag: use it for live feedback, such as setting the volume. `onChangeEnd` + * is called once when the user lets go, and after each wheel or key step: + * use it for work that should not run on every frame of a drag, such as + * saving settings or playing a test sound. Neither is called when `value` + * is set from code, so restoring saved settings does not trigger them. + * + * ## Text + * `label` is a caption drawn above the track. `showValue` adds the value as + * text, written by `valueFormat(value, ratio)`. The default format shows + * as many decimals as `step` has: `step: 1` shows "7", `step: 0.1` shows + * "0.7", and no step shows "0.70". * - * Its value is {@link UISlider#value}. `onChange` hears every change the - * user makes, while dragging too, and `onChangeEnd` once when they let go, - * which is where to save settings or play a test sound. Neither is called - * when `value` is set from code. + * ## Look + * Drawn with shapes by default: a rounded track, a filled part up to the + * thumb, and a round thumb, coloured by `trackColor`, `fillColor`, + * `thumbColor`, `thumbHoverColor` and `thumbBorderColor`. Or pass a + * {@link UISliderSkin} of atlas regions (`track`, `fill`, `thumb`, + * `thumbHover`). * - * Inside a scrolling {@link UIPanel}, dragging the thumb moves the slider, - * not the panel. + * ## In a scrolling panel + * Inside a scrolling {@link UIPanel}, a drag that starts on the slider + * moves the slider, not the panel. * @category UI * @example + * // a volume slider wired to the audio, saved when the user lets go + * save.add({ volume: 1 }); * const volume = new UISlider(40, 80, { - * label: "Music", - * showValue: true, - * valueFormat: (v, ratio) => Math.round(ratio * 100) + "%", - * value: audio.getVolume(), + * label: "Volume", + * value: save.volume, * onChange: (v) => audio.setVolume(v), - * onChangeEnd: () => saveSettings(), + * onChangeEnd: (v) => { + * save.volume = v; + * }, * }); * panel.addChild(volume); + * @example + * // a whole number from 1 to 10, shown as "1" ... "10" + * const sensitivity = new UISlider(40, 140, { + * label: "Sensitivity", + * min: 1, + * max: 10, + * step: 1, + * value: 5, + * showValue: true, + * }); + * @example + * // vertical: highest value at the top + * const brightness = new UISlider(300, 40, { + * orientation: "vertical", + * length: 150, + * label: "Light", + * showValue: true, + * value: 0.8, + * }); + * @example + * // skinned from an atlas + * const music = new UISlider(40, 200, { + * length: 320, + * skin: { atlas, track: "grey_sliderHorizontal", thumb: "blue_sliderDown" }, + * }); + * @example + * // the value shown as a percentage + * const sfx = new UISlider(40, 260, { + * label: "Effects", + * showValue: true, + * valueFormat: (value, ratio) => `${Math.round(ratio * 100)}%`, + * }); + * @example + * // in a scrolling panel: dragging the thumb does not scroll the panel + * const options = new UIPanel(20, 20, { + * width: 360, + * height: 200, + * layout: "column", + * padding: 12, + * scroll: "vertical", + * }); + * options.addChild(new UISlider(0, 0, { label: "Music", length: 300 })); + * options.addChild(new UISlider(0, 0, { label: "Effects", length: 300 })); */ export default class UISlider extends UIBaseElement { /** - * Called with the new value on every change the user makes: each drag - * step, a click on the track, a wheel step. Not when - * {@link UISlider#value} is set from code. + * Called with the new value on every change the user makes: each move + * of a drag, a click on the track, a wheel or key step. Use it for live + * feedback. Not called when {@link UISlider#value} is set from code, nor + * when a move leaves the value unchanged. + * @example + * volume.onChange = (v) => audio.setVolume(v); */ onChange: ((value: number, slider: UISlider) => void) | undefined; /** - * Called once when the user lets go after changing the value, and after - * each wheel step. For work that should not run on every drag step: - * saving settings, playing a test sound. + * Called with the final value once the user lets go after changing it, + * and after each wheel or key step. Use it for work that should not run + * on every move of a drag: saving settings, playing a test sound. Not + * called when {@link UISlider#value} is set from code, nor when a drag + * ends where it began. + * @example + * sfx.onChangeEnd = (v) => { + * save.sfxVolume = v; + * audio.play("coin", { volume: v }); + * }; */ onChangeEnd: ((value: number, slider: UISlider) => void) | undefined; /** the track (drawn look) */ trackColor: Paint; - /** the track up to the thumb (drawn look) */ + /** the part of the track from the low end to the thumb (drawn look) */ fillColor: Paint; /** the thumb (drawn look) */ thumbColor: Paint; @@ -216,16 +339,19 @@ export default class UISlider extends UIBaseElement { thumbHoverColor: Paint; /** the thumb's outline (drawn look) */ thumbBorderColor: Paint; - /** the thickness of the thumb's outline (drawn look) */ + /** the thickness of the thumb's outline, in pixels (drawn look) */ thumbBorderWidth: number; - /** which way it slides */ + /** which way it slides; set when it is built */ readonly orientation: "horizontal" | "vertical"; - /** the art, when it is skinned */ + /** the atlas regions it is drawn from, or `undefined` for the drawn look */ readonly skin: UISliderSkin | undefined; - /** the caption, if any */ + /** the caption's text object, or `undefined` without a `label` */ readonly label: UILabel | undefined; - /** the value text, when `showValue` is set */ + /** + * the value's text object, or `undefined` without `showValue`; kept up + * to date by the slider + */ readonly valueText: UILabel | undefined; /** @ignore */ @@ -426,8 +552,9 @@ export default class UISlider extends UIBaseElement { } /** - * The lowest value. Setting it clamps the value into the new range, - * without calling `onChange`. + * The lowest value. Setting it clamps and snaps the value into the new + * range without calling `onChange`; a `min` above `max` raises `max` to + * it. */ get min(): number { return this._min; @@ -440,8 +567,12 @@ export default class UISlider extends UIBaseElement { } /** - * The highest value. Setting it clamps the value into the new range, - * without calling `onChange`. + * The highest value. Setting it clamps and snaps the value into the new + * range without calling `onChange`; a `max` below `min` lowers `min` to + * it. + * @example + * // unlock more levels: the value keeps its place + * levelSelect.max = unlockedLevels; */ get max(): number { return this._max; @@ -454,8 +585,9 @@ export default class UISlider extends UIBaseElement { } /** - * The step values snap to; `0` for none. Setting it snaps the value to - * it, without calling `onChange`. + * The interval values snap to, counted from `min`; `0` for none. + * Setting it snaps the value to it without calling `onChange`, and, with + * the default `valueFormat`, changes the decimals shown to match. */ get step(): number { return this._step; @@ -472,7 +604,11 @@ export default class UISlider extends UIBaseElement { /** * Where the value is between {@link UISlider#min} and - * {@link UISlider#max}, from 0 to 1. + * {@link UISlider#max}, from 0 to 1 (0 when the range is empty). + * Read only. + * @example + * // a 50..150 slider at 125 has a ratio of 0.75 + * const percent = Math.round(slider.ratio * 100); */ get ratio(): number { const span = this._max - this._min; @@ -480,8 +616,12 @@ export default class UISlider extends UIBaseElement { } /** - * How the value is written, given the value and the ratio. Setting it - * rewrites the value text. + * How the value text is written, given the value and the ratio (0 to + * 1). Setting it rewrites the value text straight away. The space for + * the text on a vertical slider is measured when it is built, so a much + * longer format set later may not fit. + * @example + * slider.valueFormat = (value) => `${value} px`; */ get valueFormat(): (value: number, ratio: number) => string { return this._valueFormat; @@ -517,9 +657,13 @@ export default class UISlider extends UIBaseElement { * The current value, between {@link UISlider#min} and * {@link UISlider#max}. * - * Setting it snaps it to the step and clamps it, and does NOT call - * `onChange` or `onChangeEnd`, so restoring saved settings does not - * trigger what a user's change would. + * Setting it snaps it to the step and clamps it to the range, and does + * NOT call `onChange` or `onChangeEnd`: restoring saved settings does not + * run the code a user's change would. A value that is not a finite + * number is ignored. + * @example + * // show the saved volume when the menu opens + * volume.value = save.volume; */ get value(): number { return this._value; @@ -535,10 +679,11 @@ export default class UISlider extends UIBaseElement { } /** - * Step with the keyboard or a pad while it has the focus: left and - * right on a horizontal slider, up and down on a vertical one. A step - * is `step`, or a twentieth of the range without one. - * @param action - what the player asked for + * Step with the arrow keys or the D-pad while it has the focus: left and + * right on a horizontal slider, up and down on a vertical one. A step is + * `step`, or a twentieth of the range without one, and calls `onChange` + * and `onChangeEnd`. The other two directions move the focus away. + * @param action - the direction pressed * @returns true for the directions along the slider */ override onNavigate(action: UINavAction): boolean { @@ -562,7 +707,8 @@ export default class UISlider extends UIBaseElement { } /** - * Call off a press, and a drag in progress, without `onChangeEnd`. + * Call off a press, and a drag in progress, without calling + * `onChangeEnd`. The value stays where the drag left it. */ override cancelPress(): void { this.endDrag(false); @@ -570,7 +716,7 @@ export default class UISlider extends UIBaseElement { } /** - * Whether the thumb is being dragged. + * Whether the thumb is being dragged right now. */ get dragging(): boolean { return this._dragging; From 57109da1e1d3c6372c0ba2d171d6ae3adb6e24bf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 08:21:20 +0000 Subject: [PATCH 20/43] Docs: UIPanel and ProgressBar, with examples UIPanel's class doc is rewritten in sections (background, title, dragging, blocking, layout, scrolling, drop shadow, idle float, things to know), with examples for a drawn dialog, a skinned options menu, a scrolling level grid, a draggable window, a drop shadow and a HUD frame. Every setting has its unit and default; clearChildren is now documented. ProgressBar's doc is tidied, with examples for a loading bar, a health bar, a colour that follows the value, a vertical bar, a drop shadow, and reacting when the bar empties. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- .../melonjs/src/renderable/ui/progressbar.ts | 312 +++++++---- packages/melonjs/src/renderable/ui/uipanel.ts | 508 ++++++++++++------ 2 files changed, 558 insertions(+), 262 deletions(-) diff --git a/packages/melonjs/src/renderable/ui/progressbar.ts b/packages/melonjs/src/renderable/ui/progressbar.ts index 85d439b5d..ea492d98f 100644 --- a/packages/melonjs/src/renderable/ui/progressbar.ts +++ b/packages/melonjs/src/renderable/ui/progressbar.ts @@ -15,7 +15,9 @@ import { PanelPainter } from "./panelpaint.ts"; type EventName = Parameters[0]; /** - * which way the fill grows + * Which edge a {@link ProgressBar}'s fill grows from: `"left-to-right"` + * and `"right-to-left"` for a horizontal bar, `"top-to-bottom"` and + * `"bottom-to-top"` for a vertical one. * @category UI */ export type ProgressBarDirection = @@ -25,7 +27,8 @@ export type ProgressBarDirection = | "bottom-to-top"; /** - * Everything {@link ProgressBar} takes. + * The settings a {@link ProgressBar} takes. Only `width` and `height` are + * required. * @category UI */ export interface ProgressBarSettings { @@ -33,150 +36,261 @@ export interface ProgressBarSettings { width: number; /** the bar's full height, in pixels */ height: number; - /** the value that reads as empty */ + /** + * the value that shows as empty + * @default 0 + */ min?: number; - /** the value that reads as full */ + /** + * the value that shows as full + * @default 1 + */ max?: number; - /** where to start */ + /** + * the starting value, clamped between `min` and `max`; `min` if not given + */ value?: number; - /** which edge the fill grows from */ + /** + * which edge the fill grows from; see {@link ProgressBarDirection} + * @default "left-to-right" + */ direction?: ProgressBarDirection; - /** behind the fill; `null` leaves it hollow, showing whatever is behind */ + /** + * the colour behind the fill; `null` leaves it hollow, showing whatever + * is behind the bar + * @default null + */ trackColor?: Paint | null; /** - * the fill itself. A {@link Color} is re-read every frame, so mutating the - * one you passed animates the bar + * The colour of the fill. A {@link Color} is read again every frame, so + * changing the one you passed changes the bar. + * @default "#ffffff" */ fillColor?: Paint; - /** drawn around the track; `null` for none */ + /** + * the colour of the border drawn around the bar; `null` for none + * @default null + */ borderColor?: Paint | null; - /** how thick that border is */ + /** + * the border's thickness, in pixels, drawn inside the bar's edge + * @default 1 + */ borderWidth?: number; - /** how far the fill sits inside the track, per side */ + /** + * the gap between the track's edge and the fill on each side, in pixels + * @default 0 + */ padding?: number; - /** corner radius; `0` is square */ + /** + * the corner radius, in pixels; `0` is square. The fill's corners are + * reduced by `padding`, so they stay parallel to the track's. + * @default 0 + */ radius?: number; /** - * A drop shadow under the bar; `null`, the default, for none. Its alpha - * is how dark the shadow gets, e.g. `"#00000066"`. + * The colour of a drop shadow under the bar; `null` for none. Its alpha + * sets how dark the shadow is, e.g. `"#00000066"`. + * @default null */ shadowColor?: Paint | null; /** - * how far right the shadow falls, in pixels + * how far right the shadow falls, in pixels; negative goes left * @default 0 */ shadowOffsetX?: number; /** - * how far down the shadow falls, in pixels + * how far down the shadow falls, in pixels; negative goes up * @default 4 */ shadowOffsetY?: number; /** - * how far the shadow's soft edge spreads, in pixels; `0` is sharp + * how far the shadow's soft edge spreads, in pixels; `0` is a sharp edge * @default 8 */ shadowBlur?: number; - /** called whenever the value actually moves */ + /** + * Called each time the value changes, with the new value, the same as a + * `0..1` fraction of the way from `min` to `max`, and the bar. Not called + * when the value is set to what it already is. + */ onChange?: (value: number, ratio: number, bar: ProgressBar) => void; - /** draw a label over the bar */ + /** + * draw a text label centred over the bar + * @default false + */ showLabel?: boolean; - /** the label's font family */ + /** + * the label's CSS font family + * @default "sans-serif" + */ font?: string; - /** the label's font size, in pixels */ + /** + * the label's text height, in pixels; 70% of the bar's height (at least + * 8), rounded, if not given + */ fontSize?: number; - /** the label's colour */ + /** + * the label's colour + * @default "#ffffff" + */ labelFillStyle?: Paint; - /** what the label says; the default is a rounded percentage */ + /** + * What the label says, given the value and its `0..1` fraction. The + * default shows a rounded percentage, such as `"75%"`. + */ labelFormat?: (value: number, ratio: number) => string; /** - * an event to take the value from, instead of setting it by hand. - * - * The bar subscribes for as long as it exists and unsubscribes when it is - * destroyed, which is the point: a subscription that outlives what it - * drives is a listener writing into a freed object. + * An event to take the value from, instead of setting it by hand: each + * time the event fires, its first argument becomes the value. The bar + * stops listening when it is destroyed. */ bindEvent?: EventName; } /** - * A bar that shows one value between two bounds. + * A bar that shows one value between a minimum and a maximum: a health + * bar, a shield or stamina gauge, a cooldown, a loading bar. Set + * {@link ProgressBar#value} and it redraws. The engine's own loading screen + * uses one. + * + * It draws its track, fill, border and optional label itself, as one + * renderable, so there is nothing to keep in step. + * + * ## Look + * `trackColor` is behind the fill (`null` for a hollow frame), `fillColor` + * is the fill, `borderColor` and `borderWidth` draw an outline, `padding` + * insets the fill, and `radius` rounds the corners. `direction` sets which + * edge the fill grows from, horizontal or vertical. * - * A health bar, a shield gauge, a cooldown, a loading bar: all the same - * object, and the engine's own loading screen is built on this one. Set - * {@link ProgressBar#value} and it redraws. + * ## Value + * `min` and `max` set the range (`0` to `1` by default). A value outside it + * is clamped. {@link ProgressBar#ratio} gives the value as a `0..1` + * fraction. `onChange(value, ratio, bar)` is called whenever the value + * changes, and `bindEvent` takes the value from an event. * - * It draws the track, the fill, the border and the label itself, in that - * order, which is the point of it being ONE renderable rather than a track - * sprite with a fill sprite on top. Two renderables at the same place have to - * be told which is in front, and that question has to be answered again every - * time either one moves. + * ## Label + * `showLabel` draws text over the bar, a percentage by default; `labelFormat` + * changes what it says, and `font`, `fontSize` and `labelFillStyle` how it + * looks. * - * ## The colour can follow the value + * ## Drop shadow + * `shadowColor` draws a soft shadow under the bar; its alpha sets how dark + * it is. `shadowOffsetX` and `shadowOffsetY` move it and `shadowBlur` + * softens its edge. * - * `fillColor` is read at draw time rather than copied, so passing a - * {@link Color} you keep hold of and mutating it is all a value-driven colour - * takes. That is deliberately not built in, because every game wants a - * different rule and most want the colour somewhere else too, next to a shader - * uniform or a glow: + * ## Colour that follows the value + * `fillColor` is read every frame, not copied, so pass a {@link Color} you + * keep and change it as the value changes. To make the bar blink, use + * {@link Renderable#setOpacity}. + * @category UI + * @example + * // a loading bar, fed by the loader + * import { ProgressBar, event } from "melonjs"; + * + * const loading = new ProgressBar(0, 300, { + * width: 800, + * height: 4, + * trackColor: "black", + * fillColor: "#55aa00", + * bindEvent: event.LOADER_PROGRESS, + * }); + * app.world.addChild(loading); + * @example + * // a health bar with a label, and a range that is not 0 to 1 + * const hp = new ProgressBar(20, 20, { + * width: 200, + * height: 24, + * min: 0, + * max: 100, + * value: 100, + * trackColor: "#3a1010", + * fillColor: "#c0392b", + * radius: 6, + * showLabel: true, + * labelFormat: (value) => `${Math.round(value)} HP`, + * }); + * // when the player is hit + * hp.value -= 15; + * @example + * // a colour that follows the value, from red when empty to blue when full + * import { ProgressBar, Color } from "melonjs"; * - * ```js * const full = new Color().parseCSS("#7fe0ff"); * const empty = new Color().parseCSS("#ff2a1e"); - * const mixed = new Color(); - * - * const shield = new ProgressBar(x, y, { - * width: 190, height: 13, - * trackColor: null, // a hollow frame + * const mixed = full.clone(); + * const shield = new ProgressBar(20, 52, { + * width: 190, + * height: 13, + * value: 1, + * trackColor: null, // a hollow frame * borderColor: "rgba(122, 190, 235, 0.8)", * padding: 2, - * fillColor: mixed, // kept, not copied + * radius: 4, + * fillColor: mixed, // kept, not copied + * onChange: (value, ratio) => { + * mixed.copy(empty).lerp(full, ratio); + * }, * }); - * - * // each frame - * mixed.copy(empty).lerp(full, hp); - * shield.value = hp; - * ``` - * - * Blinking is the same story: {@link Renderable#setOpacity} already does it, - * on whatever clock the game is already keeping. - * @category UI * @example - * // a loading bar - * const bar = new ProgressBar(0, 100, { - * width: renderer.width, height: 4, - * trackColor: "black", fillColor: "#55aa00", + * // a vertical bar that fills from the bottom + * const power = new ProgressBar(740, 200, { + * width: 16, + * height: 120, + * direction: "bottom-to-top", + * trackColor: "#222222", + * fillColor: "#f1c40f", + * borderColor: "#ffffff", + * borderWidth: 2, + * padding: 3, * }); - * event.on(event.LOADER_PROGRESS, (progress) => { bar.value = progress; }); * @example - * // a label, and a value that is not a fraction - * const hp = new ProgressBar(20, 20, { - * width: 200, height: 24, - * min: 0, max: 100, value: 100, - * fillColor: "#c0392b", radius: 6, - * showLabel: true, - * labelFormat: (v) => `${Math.round(v)} HP`, + * // a drop shadow under the bar + * const xp = new ProgressBar(20, 80, { + * width: 240, + * height: 16, + * trackColor: "#1e1e2e", + * fillColor: "#9b59b6", + * radius: 8, + * shadowColor: "#00000080", // the alpha sets how dark + * shadowOffsetY: 3, + * shadowBlur: 6, + * }); + * @example + * // react when the bar is emptied + * const timer = new ProgressBar(20, 110, { + * width: 200, + * height: 8, + * value: 1, + * fillColor: "#2ecc71", + * onChange: (value, ratio, bar) => { + * if (ratio === 0) { + * bar.setOpacity(0.5); + * onTimeUp(); + * } + * }, * }); */ export default class ProgressBar extends Renderable { - /** the value that reads as empty */ + /** the value that shows as empty */ min: number; - /** the value that reads as full */ + /** the value that shows as full */ max: number; - /** which edge the fill grows from */ + /** which edge the fill grows from; see {@link ProgressBarDirection} */ direction: ProgressBarDirection; - /** behind the fill; `null` leaves it hollow */ + /** the colour behind the fill; `null` leaves it hollow */ trackColor: Paint | null; - /** the fill. A {@link Color} here is re-read every frame */ + /** the colour of the fill; a {@link Color} here is read every frame */ fillColor: Paint; - /** drawn around the track; `null` for none */ + /** the colour of the border; `null` for none */ borderColor: Paint | null; - /** how thick that border is */ + /** the border's thickness, in pixels */ borderWidth: number; - /** how far the fill sits inside the track, per side */ + /** the gap between the track's edge and the fill on each side, in pixels */ padding: number; - /** corner radius; `0` is square */ + /** the corner radius, in pixels; `0` is square */ radius: number; - /** a drop shadow under the bar; `null` for none */ + /** the colour of the drop shadow, its alpha setting how dark; `null` for none */ shadowColor: Paint | null; /** how far right the shadow falls, in pixels */ shadowOffsetX: number; @@ -184,13 +298,20 @@ export default class ProgressBar extends Renderable { shadowOffsetY: number; /** how far the shadow's soft edge spreads, in pixels */ shadowBlur: number; - /** called whenever the value actually moves */ + /** + * Called each time the value changes, with the new value, its `0..1` + * fraction from `min` to `max`, and the bar. + * @example + * bar.onChange = (value, ratio, bar) => { + * bar.setOpacity(ratio < 0.25 ? 0.6 : 1); + * }; + */ onChange?: | ((value: number, ratio: number, bar: ProgressBar) => void) | undefined; - /** what the label says */ + /** builds the label's text from the value and its `0..1` fraction */ labelFormat: (value: number, ratio: number) => string; - /** the label, when `showLabel` was asked for */ + /** the label's text object, when `showLabel` was set */ label?: Text | undefined; /** the event the value is taken from, if any */ readonly bindEvent?: EventName | undefined; @@ -274,13 +395,13 @@ export default class ProgressBar extends Renderable { } /** - * Where the bar is now, between {@link ProgressBar#min} and - * {@link ProgressBar#max}. - * - * Writing a value outside that range clamps rather than throwing, since - * the usual source is a health or a timer that has just gone past its own - * limit. Writing the value it already has does nothing at all, so - * `onChange` means the value MOVED. + * The current value, between {@link ProgressBar#min} and + * {@link ProgressBar#max}. Setting it redraws the bar and updates the + * label. A value outside the range is clamped, and setting the value it + * already has does nothing, so `onChange` only fires on a real change. + * @example + * // a cooldown that refills over 3 seconds + * cooldown.value = Math.min(1, elapsed / 3000); */ get value() { return this._value; @@ -300,11 +421,8 @@ export default class ProgressBar extends Renderable { } /** - * The value as a fraction, `0` at `min` and `1` at `max`. - * - * This is what the fill is drawn from, and what a caller usually wants for - * anything else keyed to the same number. Read only; set - * {@link ProgressBar#value}. + * The value as a fraction, `0` at `min` and `1` at `max`; how full the + * bar is drawn. Read only: set {@link ProgressBar#value} instead. */ get ratio() { const span = this.max - this.min; diff --git a/packages/melonjs/src/renderable/ui/uipanel.ts b/packages/melonjs/src/renderable/ui/uipanel.ts index a507e3703..fba988b16 100644 --- a/packages/melonjs/src/renderable/ui/uipanel.ts +++ b/packages/melonjs/src/renderable/ui/uipanel.ts @@ -23,42 +23,57 @@ import UITextButton from "./uitextbutton.ts"; /** * The art of a skinned {@link UIPanel}: one atlas region, drawn as a - * nine-slice over the whole panel and resized with it. + * nine-slice over the whole panel and stretched with it. * @category UI + * @example + * const panel = new UIPanel(100, 80, { + * width: 320, + * height: 240, + * skin: { atlas, region: "panel_blue", insetx: 16, insety: 16 }, + * }); */ export interface UIPanelSkin extends UISkin { - /** the background */ + /** the name of the atlas region drawn as the background */ region: string; } /** * How a {@link UIPanel} places its children. * - * - `"none"`: where they are put - * - `"column"`: one above the other - * - `"row"`: side by side - * - `"grid"`: in rows of {@link UIPanelSettings.columns} equal cells + * - `"none"`: each child stays where you put it + * - `"column"`: one above the other, top to bottom + * - `"row"`: side by side, left to right + * - `"grid"`: in rows of {@link UIPanelSettings.columns} equal-width cells + * + * Children are placed in the order they were added. * @category UI */ export type UIPanelLayout = "none" | "column" | "row" | "grid"; /** - * Where a {@link UIPanel} lines its children up across the layout: the - * left, middle or right of a column, the top, middle or bottom of a row. - * `"stretch"` resizes each child to the full width of a column, the full - * height of a row, or the width of a grid cell. + * Where a {@link UIPanel} lines its children up across the layout. + * + * - `"start"`: the left of a column, the top of a row, the top left of a grid cell + * - `"center"`: the middle + * - `"end"`: the right of a column, the bottom of a row + * - `"stretch"`: resize each child to the full width of a column, the full + * height of a row, or the width of a grid cell. Only a {@link UITextButton} + * or a {@link UIPanel} is resized; any other child keeps its size and lines + * up at the start. * @category UI */ export type UIPanelAlign = "start" | "center" | "end" | "stretch"; /** - * Which ways a {@link UIPanel} scrolls. + * Which ways a {@link UIPanel} scrolls when its content does not fit. * @category UI */ export type UIPanelScroll = "none" | "vertical" | "horizontal" | "both"; /** - * Everything {@link UIPanel} takes. + * The settings a {@link UIPanel} takes. Only `width` and `height` are + * required; with nothing else the panel is an invisible, blocking box that + * holds children. * @category UI */ export interface UIPanelSettings extends UIJuiceSettings { @@ -67,68 +82,78 @@ export interface UIPanelSettings extends UIJuiceSettings { /** the panel's height, in pixels */ height: number; /** - * The fill behind the children; `null` leaves it transparent. + * The background colour; `null` leaves it transparent. * - * NOT `backgroundColor`, which a `Container` already has and which does - * something else entirely: that one is handed to `clearColor` and wipes - * the whole render target, so a panel setting it would repaint the - * screen. The name here matches {@link ProgressBarSettings} instead. + * Use this, not `backgroundColor`: a `Container`'s `backgroundColor` + * clears the whole screen to that colour. + * @default null */ fillColor?: Paint | null; - /** drawn around the edge; `null` for none */ + /** + * the colour of the border drawn around the edge; `null` for none + * @default null + */ borderColor?: Paint | null; - /** how thick that border is */ + /** + * the border's thickness, in pixels, drawn inside the edge + * @default 1 + */ borderWidth?: number; - /** corner radius; `0` is square; the shadow follows it, under a skin too */ + /** + * the corner radius, in pixels; `0` is square. The fill, the border and + * the shadow all follow it. + * @default 0 + */ radius?: number; /** - * A drop shadow under the panel, behind its skin or fill; `null`, the default, for none. Its alpha - * is how dark the shadow gets, e.g. `"#00000066"`. + * The colour of a drop shadow under the panel; `null` for none. Its + * alpha sets how dark the shadow is, e.g. `"#00000066"`. + * @default null */ shadowColor?: Paint | null; /** - * how far right the shadow falls, in pixels + * how far right the shadow falls, in pixels; negative goes left * @default 0 */ shadowOffsetX?: number; /** - * how far down the shadow falls, in pixels + * how far down the shadow falls, in pixels; negative goes up * @default 4 */ shadowOffsetY?: number; /** - * how far the shadow's soft edge spreads, in pixels; `0` is sharp + * how far the shadow's soft edge spreads, in pixels; `0` is a sharp edge * @default 8 */ shadowBlur?: number; /** - * How far the panel drifts up and down, in pixels. `0`, the default, - * leaves it still. - * + * How far the panel floats up and down, in pixels; `0` leaves it still. * See {@link UIPanel#bobAmplitude}. + * @default 0 */ bobAmplitude?: number; /** - * How long one full up and down cycle takes, in milliseconds. - * + * How long one full up and down float takes, in milliseconds. * See {@link UIPanel#bobPeriod}. + * @default 2000 */ bobPeriod?: number; /** - * Whether the panel swallows pointer events aimed at what is behind it. + * Whether the panel stops pointer events from reaching what is behind + * it: clicks, releases and hover on the panel itself, and the wheel over a + * scrolling panel. Widgets inside the panel still get their events. * - * `true` by default, which is what a dialog wants: a click on the panel, - * or on the dimmed scene it covers, must not reach the game underneath. - * Children are unaffected either way, because the dispatcher asks a child - * before the container holding it. - * - * Set `false` for a panel that is only a backdrop, such as a HUD frame - * drawn over a playfield that still has to receive clicks. + * Keep it `true` for a dialog. Set it `false` for a frame that only + * decorates, such as a HUD box over a playfield that still takes clicks. + * @default true */ blocking?: boolean; - /** draw the background from an atlas region, as a nine-slice */ + /** + * draw the background from an atlas region, as a nine-slice; a + * `fillColor` set as well is drawn over it + */ skin?: UIPanelSkin; - /** a title, centred at the top; the content starts below it */ + /** a title, centred in a band at the top; the content starts below it */ title?: string; /** * the title's font: a CSS font family, or the name of a loaded bitmap font @@ -136,7 +161,7 @@ export interface UIPanelSettings extends UIJuiceSettings { */ titleFont?: string; /** - * the title's height, in pixels + * the title's text height, in pixels; the title band is 1.6 times this * @default 20 */ titleFontSize?: number; @@ -146,8 +171,10 @@ export interface UIPanelSettings extends UIJuiceSettings { */ titleFillStyle?: string; /** - * Let the user move the panel by dragging it. A scrolling panel is moved - * by its title and its frame, as a drag on the content scrolls it. + * Let the player move the panel by dragging it. It is moved by its title + * or its background, never through a widget on it; a scrolling panel is + * moved by its title and frame only, as a drag on its content scrolls. + * The panel comes to the front of its container when pressed. * @default false */ draggable?: boolean; @@ -157,13 +184,13 @@ export interface UIPanelSettings extends UIJuiceSettings { */ layout?: UIPanelLayout; /** - * the number of cells in a row of a `"grid"` layout + * the number of cells in each row of a `"grid"` layout * @default 2 */ columns?: number; /** * The space between the content and the panel's frame, in pixels: inside - * the border, or inside the skin's insets, and below the title. + * the border or the skin's insets, and below the title. * @default 0 */ padding?: number; @@ -178,22 +205,22 @@ export interface UIPanelSettings extends UIJuiceSettings { */ align?: UIPanelAlign; /** - * Resize the panel to its laid out content: the height of a column or a - * grid, the width of a row, and across a column or a row too unless the - * children are stretched. Ignored by a scrolling panel. + * Resize the panel to fit its laid out content: the height of a column or + * a grid, the width of a row, and the other side of a column or a row too + * unless `align` is `"stretch"`. Ignored by a scrolling panel. * @default false */ autoSize?: boolean; /** - * Which ways the content scrolls when it does not fit: with the wheel - * over the panel, and by dragging it, with momentum. + * Which ways the content scrolls when it does not fit: with the mouse + * wheel over the panel, and by dragging the content, with momentum. * @default "none" */ scroll?: UIPanelScroll; - /** called when the content scrolls, with the new offsets */ + /** called each time the content scrolls, with the new offsets in pixels */ onScroll?: (scrollX: number, scrollY: number, panel: UIPanel) => void; /** - * the scrollbar, shown while the content does not fit + * the colour of the scrollbar, shown while the content does not fit * @default "#00000059" */ scrollbarColor?: Paint; @@ -223,31 +250,89 @@ const FRICTION = 0.95; const MIN_SPEED = 0.02; /** - * A rectangular surface with a background, an optional border and children. + * A rectangular frame that holds other widgets: the base of a menu, a + * dialog, a pause screen or a HUD box. Add children to it with `addChild`; + * they are positioned relative to the panel's top left corner, drawn over its + * background, and move with it. + * + * ## Background + * Draw one with no assets: `fillColor`, `borderColor`, `borderWidth` and + * `radius` for rounded corners. Or give a nine-slice `skin` from an atlas, + * which stretches to any size with its corners kept sharp. With neither, the + * panel is invisible but still holds and lays out its children. + * + * ## Title + * `title` draws a line of text centred in a band at the top, styled with + * `titleFont`, `titleFontSize` and `titleFillStyle`. The content area starts + * below the band. * - * The thing every menu, dialog and HUD frame starts by writing: a rounded - * rectangle, a fill, an outline, and some labels on top. One example in this - * repository had three of them, each repeating the same half dozen renderer - * calls, and each repeating the same correction for the way `setColor` folds - * the global alpha in. That correction is the reason this is a widget rather - * than a snippet: it is easy to leave out, and leaving it out does not fail, - * it just renders a panel at the wrong opacity. + * ## Dragging + * With `draggable`, the player moves the panel by dragging its title or its + * background, never through a widget on it (a drag on the content of a + * scrolling panel scrolls it instead). A pressed panel comes to the front of + * its container, so the window last touched is on top. * - * It is a {@link UIBaseElement}, so it is a container that carries pointer - * events, and children added to it are drawn over its background and are - * asked about a click before it is. + * ## Blocking + * By default (`blocking: true`) the panel stops clicks, releases and hover + * on it from reaching whatever is behind it, and so does the wheel over a + * scrolling panel. Widgets inside it still get their events. Set + * `blocking: false` for a frame that only decorates. * - * It is floating, as every UI element is, and where it lands in the paint - * order is the depth it is added at. In a world sorted on `"depth"`, which - * is every `Camera3d` scene, that depth is read the other way round from - * the 2D habit: a floating child is ranked by `|pos.z|` there and the - * SMALLER magnitude draws on top, so a panel added at 9000 to sit above - * everything sits behind everything instead, and a dialog there wants a - * small depth, 0 or 1. + * ## Layout + * `layout` places the children for you, in the order they were added: + * `"column"`, `"row"` or `"grid"` (with `columns` per row). `padding` is the + * space inside the frame, `gap` the space between children, and `align` + * lines them up across the layout (`"start"`, `"center"`, `"end"`, or + * `"stretch"`, which resizes buttons and panels to fill the width of a column + * or a cell, or the height of a row; other children keep their size). + * `autoSize` resizes the panel to fit what it holds. The layout runs again + * when a child is added or removed and when the panel is resized; call + * {@link UIPanel#relayout} after changing a child's size. + * + * ## Scrolling + * `scroll` (`"vertical"`, `"horizontal"` or `"both"`) crops the children to + * the content area and lets the player scroll whatever does not fit: with + * the mouse wheel, or by dragging the content, which carries on with + * momentum. A scrollbar shows how far along the content is. + * - From code: {@link UIPanel#scrollTo}, {@link UIPanel#scrollToChild}, or + * set {@link UIPanel#scrollX} and {@link UIPanel#scrollY}. + * {@link UIPanel#maxScrollX} and {@link UIPanel#maxScrollY} say how far it + * goes, and `onScroll` is called on every change. + * - A drag that starts on a button scrolls the panel and calls off the + * button's press, so it is not clicked on release. A slider keeps its own + * drag. A scrolling panel inside another scrolls on its own. + * - A widget given the keyboard or gamepad focus is scrolled into view. + * - {@link UIPanel#clearChildren} empties the panel and scrolls back to the + * start, ready to be filled again. + * + * With `layout: "none"`, place children at their position in the content, as + * if it were not scrolled, and call `relayout()` after moving one by hand. + * + * ## Drop shadow + * `shadowColor` draws a soft shadow under the panel; its alpha sets how dark + * it is. `shadowOffsetX` and `shadowOffsetY` move it, `shadowBlur` softens + * its edge, and it follows `radius`. The shadow is drawn outside the panel's + * bounds and never takes the pointer. + * + * ## Idle float + * `bobAmplitude` and `bobPeriod` make the panel drift slowly up and down, + * so a screen waiting for input does not look frozen. Its widgets stay + * clickable where they are drawn. + * + * ## Things to know + * - Like every UI element, a panel is floating: it is drawn in screen + * coordinates, over the scene. With a `Camera3d`, floating children are + * sorted the other way round: the SMALLER depth draws on top, so add a + * dialog at a depth of 0 or 1, not 9000. + * - On WebGL and WebGPU, a scrolling panel inside another clipping container + * (such as another scrolling panel) is cropped to its own area only, not + * to the outer container's. + * @category UI * @example + * // a drawn dialog, with no assets * import { UIPanel, Text } from "melonjs"; * - * const panel = new UIPanel(cx - 200, cy - 120, { + * const dialog = new UIPanel(220, 140, { * width: 400, * height: 240, * fillColor: "#0a1526", @@ -255,21 +340,122 @@ const MIN_SPEED = 0.02; * borderWidth: 2, * radius: 14, * }); - * // positioned relative to the panel, not to the screen - * panel.addChild(new Text(200, 24, { text: "PAUSED", textAlign: "center" })); - * app.world.addChild(panel); - * @category UI + * // relative to the panel's top left corner, not to the screen + * dialog.addChild(new Text(200, 24, { + * font: "sans-serif", + * size: 32, + * text: "PAUSED", + * textAlign: "center", + * })); + * app.world.addChild(dialog); + * @example + * // a skinned options menu: a column of widgets, sized to fit + * import { UIPanel, UISlider, UICheckbox, UITextButton } from "melonjs"; + * + * const menu = new UIPanel(240, 100, { + * width: 320, + * height: 100, // autoSize sets the height + * skin: { atlas, region: "panel_brown" }, + * title: "Options", + * layout: "column", + * padding: 16, + * gap: 12, + * align: "stretch", + * autoSize: true, + * }); + * menu.addChild(new UISlider(0, 0, { + * label: "Music", + * value: 0.8, + * onChange: (value) => { musicVolume = value; }, + * })); + * menu.addChild(new UICheckbox(0, 0, { + * label: "Show hints", + * checked: true, + * onChange: (checked) => { showHints = checked; }, + * })); + * // stretched to the full width of the column + * menu.addChild(new UITextButton(0, 0, { + * text: "Back", + * skin: { atlas, normal: "button", pressed: "button_pressed" }, + * onClick: () => { app.world.removeChild(menu); }, + * })); + * app.world.addChild(menu); + * @example + * // a scrolling grid of level buttons + * const levels = new UIPanel(160, 60, { + * width: 480, + * height: 360, + * fillColor: "#1b2333", + * radius: 10, + * title: "Select a level", + * layout: "grid", + * columns: 4, + * padding: 12, + * gap: 10, + * align: "stretch", + * scroll: "vertical", + * }); + * const buttons = []; + * for (let i = 1; i <= 40; i++) { + * const button = new UITextButton(0, 0, { + * text: String(i), + * height: 64, + * onClick: () => { startLevel(i); }, + * }); + * buttons.push(button); + * levels.addChild(button); + * } + * app.world.addChild(levels); + * // open on the last level unlocked, with 8 pixels to spare around it + * levels.scrollToChild(buttons[lastUnlocked - 1], 8); + * @example + * // a draggable window with a title + * const inventory = new UIPanel(40, 40, { + * width: 260, + * height: 180, + * fillColor: "#2b2f3a", + * borderColor: "#8899aa", + * radius: 6, + * title: "Inventory", + * titleFontSize: 18, + * layout: "column", + * padding: 10, + * draggable: true, + * }); + * inventory.addChild(new Text(0, 0, { font: "sans-serif", size: 16, text: "3 potions" })); + * app.world.addChild(inventory); + * @example + * // a drop shadow, soft and a little below + * const card = new UIPanel(300, 200, { + * width: 200, + * height: 120, + * fillColor: "#ffffff", + * radius: 12, + * shadowColor: "#00000066", // the alpha sets how dark + * shadowOffsetX: 2, + * shadowOffsetY: 6, + * shadowBlur: 12, + * }); + * @example + * // a HUD frame that lets clicks through to the game behind it + * const hud = new UIPanel(8, 8, { + * width: 220, + * height: 48, + * fillColor: "#00000080", + * radius: 8, + * blocking: false, + * }); */ export default class UIPanel extends UIBaseElement { - /** the fill behind the children; `null` leaves it transparent */ + /** the background colour; `null` leaves it transparent */ fillColor: Paint | null; - /** drawn around the edge; `null` for none */ + /** the colour of the border around the edge; `null` for none */ borderColor: Paint | null; - /** how thick that border is */ + /** the border's thickness, in pixels */ borderWidth: number; - /** corner radius; `0` is square */ + /** the corner radius, in pixels; `0` is square */ radius: number; - /** a drop shadow under the panel; `null` for none */ + /** the colour of the drop shadow, its alpha setting how dark; `null` for none */ shadowColor: Paint | null; /** how far right the shadow falls, in pixels */ shadowOffsetX: number; @@ -277,69 +463,63 @@ export default class UIPanel extends UIBaseElement { shadowOffsetY: number; /** how far the shadow's soft edge spreads, in pixels */ shadowBlur: number; - /** whether it swallows pointer events aimed at what is behind it */ + /** + * Whether the panel stops pointer events on it from reaching what is + * behind it. Widgets inside still get theirs. + * @default true + */ blocking: boolean; /** - * How far the panel drifts up and down, in pixels. `0`, the default, - * leaves it still. + * How far the panel floats up and down, in pixels; `0` leaves it still. * - * 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. It - * is a sine, so this is the distance to either extreme and the panel - * covers twice it over a cycle; 2 to 4 is the usual range, and it starts - * by rising rather than sinking. - * - * Do not go much below 2. The offset is a whole number of pixels, so an - * amplitude of 1 has three positions to move between and the panel - * twitches rather than drifts. - * - * The panel is CLICKABLE where it is drawn: the offset goes into - * {@link UIPanel#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 deliberately left alone, so a - * caller still reads and writes the resting place rather than a value - * that moves under them. - * - * The bounds are rebuilt only on the frames the offset actually changes, - * so a panel with no bob costs nothing. + * A slow float keeps a dialog waiting for input from looking frozen. The + * panel rises this far above its position and sinks this far below it; + * 2 to 4 works well, as smaller values step a pixel at a time and look + * jerky. The panel, and every widget in it, is clickable where it is + * drawn, while `pos` keeps its resting place. * @default 0 + * @example + * dialog.bobAmplitude = 3; + * dialog.bobPeriod = 2500; */ bobAmplitude: number; /** - * How long one full up and down cycle takes, in milliseconds. - * - * Anything at or below zero turns the bob off rather than dividing by it. - * That is not politeness: the offset would be `NaN`, and one `translate` - * by `NaN` writes it into the transform, where every batched vertex under - * it inherits the poison and the GPU clips the lot away. A panel that - * silently vanished would be a long way from an obviously bad period. + * How long one full up and down float takes, in milliseconds. `0` or + * less turns the float off. * @default 2000 */ bobPeriod: number; /** the background art, when the panel is skinned */ readonly skin: UIPanelSkin | undefined; - /** the title, drawn at the top, when there is one */ + /** the title's text object, when the panel has a title */ readonly title: UILabel | undefined; - /** how the children are placed; see {@link UIPanelLayout} */ + /** how the children are placed; see {@link UIPanelLayout}. Call {@link UIPanel#relayout} after changing it. */ layout: UIPanelLayout; - /** the number of cells in a row of a `"grid"` layout */ + /** the number of cells in each row of a `"grid"` layout */ columns: number; /** the space between the content and the frame, in pixels */ padding: number; /** the space between children in a layout, in pixels */ gap: number; - /** where children line up across the layout */ + /** where children line up across the layout; see {@link UIPanelAlign} */ align: UIPanelAlign; - /** resize the panel to its laid out content */ + /** whether the panel resizes to fit its laid out content */ autoSize: boolean; - /** which ways the content scrolls */ + /** which ways the content scrolls; see {@link UIPanelScroll} */ scroll: UIPanelScroll; - /** called when the content scrolls, with the new offsets */ + /** + * Called each time the content scrolls, with the new offsets in pixels. + * @example + * // fade an arrow out once the list is scrolled to the bottom + * list.onScroll = (scrollX, scrollY, panel) => { + * moreArrow.setOpacity(scrollY < panel.maxScrollY ? 1 : 0); + * }; + */ onScroll: | ((scrollX: number, scrollY: number, panel: UIPanel) => void) | undefined; - /** the scrollbar, shown while the content does not fit */ + /** the colour of the scrollbar, shown while the content does not fit */ scrollbarColor: Paint; /** @ignore */ @@ -706,23 +886,9 @@ export default class UIPanel extends UIBaseElement { } /** - * Where the panel IS, bob included. - * - * THE hook that makes a floating panel honest. Both bounds paths funnel - * through this one method, and a child's absolute position is the sum of - * the chain above it, so adding the offset here reaches the panel's own - * hit area and every descendant's, at any depth, without any of them - * knowing a bob exists. - * - * Not `currentTransform`, which was the obvious guess: that reaches a - * renderable's OWN bounds and not its children's, so a bobbing panel's - * swallow rectangle would drift off the widgets sitting in it. And not a - * write to `pos`, which would reach everything correctly but silently - * overwrite a caller's own write on the very next frame. - * - * {@link Particle} overrides this method for the same reason, its - * placement not being in its `pos` chain either. - * @returns the absolute position, offset by the bob + * The panel's absolute position, including the idle float, so the hit + * areas of the panel and of every widget in it follow the float. + * @returns the absolute position, offset by the float */ override getAbsolutePosition(): Vector3d { const absolute = super.getAbsolutePosition(); @@ -731,11 +897,8 @@ export default class UIPanel extends UIBaseElement { } /** - * The matrix form of the same offset. - * - * Without it `getWorldTransform()` and `getAbsolutePosition()` disagree - * about a bobbing panel, and the contract on those two says that for a - * `Container` they coincide. + * The panel's local transform, including the idle float, so it agrees + * with {@link UIPanel#getAbsolutePosition}. * @param out - matrix to write into * @returns `out`, for chaining */ @@ -749,21 +912,9 @@ export default class UIPanel extends UIBaseElement { } /** - * Resize this panel's bounds, and refresh its children's. - * - * `Container` does those two jobs in ONE loop, both gated on - * `enableChildBoundsUpdate`, and a panel wants exactly one of them. The - * flag is off here so that a label overhanging the edge cannot grow the - * rectangle a blocking panel swallows clicks inside, and turning it off - * took the other job with it: a child's bounds are absolute, worked out - * once through `getAbsolutePosition()` and then cached, so moving the - * panel left every child's HIT AREA behind at the old place while the - * whole panel drew at the new one. A dialog slid or snapped into position - * after it was built had buttons that only answered where they used to - * be. - * - * This costs nothing per frame: `pos` calls `updateBounds` when it is - * written, so the walk happens when the panel actually moves. + * Update this panel's bounds, and its children's, so their hit areas + * follow the panel when it moves. The panel's own bounds are its size, + * never grown by a child hanging over the edge. * @param absolute - update in world absolute coordinates * @returns this panel's bounds */ @@ -877,10 +1028,14 @@ export default class UIPanel extends UIBaseElement { /** * Place the children again, and work out how far the content scrolls. * - * Runs by itself when a child is added or removed and when the panel is - * resized, not every frame. Call it when a child changes size, or after + * It runs by itself when a child is added or removed and when the panel + * is resized, not every frame. Call it after a child changes size, after + * changing `layout`, `columns`, `padding`, `gap` or `align`, or after * moving children by hand in a scrolling panel. * @returns this panel + * @example + * menu.gap = 4; + * menu.relayout(); */ relayout(): this { if (this._layingOut) { @@ -1070,7 +1225,8 @@ export default class UIPanel extends UIBaseElement { } /** - * How far the content is scrolled to the right, in pixels. + * How far the content is scrolled to the right, in pixels, from `0` to + * {@link UIPanel#maxScrollX}. Setting it scrolls, clamped to that range. * @default 0 */ get scrollX(): number { @@ -1082,8 +1238,12 @@ export default class UIPanel extends UIBaseElement { } /** - * How far the content is scrolled down, in pixels. + * How far the content is scrolled down, in pixels, from `0` to + * {@link UIPanel#maxScrollY}. Setting it scrolls, clamped to that range. * @default 0 + * @example + * // back to the top + * list.scrollY = 0; */ get scrollY(): number { return this._scrollY; @@ -1094,26 +1254,33 @@ export default class UIPanel extends UIBaseElement { } /** - * How far the content can scroll to the right, in pixels; 0 when it fits. + * How far the content can scroll to the right, in pixels; `0` when it + * fits or the panel does not scroll that way. Read only. */ get maxScrollX(): number { return this._maxScrollX; } /** - * How far the content can scroll down, in pixels; 0 when it fits. + * How far the content can scroll down, in pixels; `0` when it fits or + * the panel does not scroll that way. Read only. */ get maxScrollY(): number { return this._maxScrollY; } /** - * Scroll the content to an offset, clamped to how far it goes. + * Scroll the content to an offset, clamped to `0` up to + * {@link UIPanel#maxScrollX} and {@link UIPanel#maxScrollY}, with no + * animation. * - * Scrolling moves the children: their positions are where they are drawn. + * Scrolling moves the children, so a child's `pos` is where it is drawn. * @param x - the horizontal offset, in pixels * @param y - the vertical offset, in pixels * @returns this panel + * @example + * // jump to the end of a chat log + * log.scrollTo(0, log.maxScrollY); */ scrollTo(x: number, y: number): this { const nx = clamp(Number.isFinite(x) ? x : 0, 0, this._maxScrollX); @@ -1134,11 +1301,16 @@ export default class UIPanel extends UIBaseElement { } /** - * Scroll just enough to bring a child fully into view. + * Scroll just enough to bring a child fully into view; nothing moves if + * it already is. A widget that is not a direct child of this panel is + * ignored. * @param child - one of this panel's children * @param margin - room to leave around it, in pixels, such as for a * focus ring * @returns this panel + * @example + * // show the selected save slot, with 8 pixels to spare + * slots.scrollToChild(selectedSlot, 8); */ scrollToChild(child: Renderable, margin = 0): this { if (child.ancestor !== this) { @@ -1426,8 +1598,14 @@ export default class UIPanel extends UIBaseElement { private _clearing = false; /** - * Remove every child, without laying out after each one. - * @ignore + * Remove every child and scroll back to the start, ready to fill the + * panel again. + * @example + * // refill a scrolling list + * list.clearChildren(); + * for (const name of names) { + * list.addChild(new Text(0, 0, { font: "sans-serif", size: 16, text: name })); + * } */ override clearChildren(): void { this._clearing = true; From 139c957a2fd895982f9408a1b25b44ac966cc21e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 08:23:34 +0000 Subject: [PATCH 21/43] Docs: UIBaseElement, UITextButton, UISpriteElement, with examples UIBaseElement's class doc covers the pointer callbacks, enabled, dragging, the bounce and keyboard / gamepad focus, with examples for a widget of your own, a draggable token, a keyboard and pad menu, and an opaque dialog. UITextButton documents every setting with its unit and default (the old names as aliases), with examples for drawn, skinned, bindKey, disabled and shared-style buttons. UISpriteElement gets a real class doc and examples. Several docs that contradicted the code now match it (onMove, onRelease, enabled on a custom element, bindKey while disabled). Fix: a held bindKey press that is called off (the window losing focus, the button removed or disabled) now calls onCancel(), as a pointer press does; it called neither onRelease nor onCancel, so work started on the press never stopped. Test added; the skill's bindKey rules follow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- .../skills/melonjs-ui-and-text/SKILL.md | 12 +- .../src/renderable/ui/uibaseelement.ts | 425 ++++++++++++++---- .../src/renderable/ui/uispriteelement.ts | 189 +++++--- .../melonjs/src/renderable/ui/uitextbutton.ts | 346 +++++++++----- packages/melonjs/tests/ui-widgets.spec.js | 23 + 5 files changed, 728 insertions(+), 267 deletions(-) diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index b6085e58e..9a0d25523 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -822,11 +822,13 @@ Three more rules, each with a reason you can feel in a game: - **The first input to press owns the button.** A pointer cannot release a key press and a key cannot release a pointer press, so a button cannot be left half pressed by using both at once. -- **Losing focus cancels a held key WITHOUT calling `onRelease()`.** If you - use the pair as press and depress, firing while held, also stop on - `event.BLUR` or the gun is still firing when the player alt-tabs back. -- **`isClickable` gates the press, not the release.** Disabling a button while - its key is down still releases it, so it cannot stick. +- **A held key that is called off goes to `onCancel()`, not `onRelease()`.** + That happens when the window loses focus, the button is removed, or it is + disabled (`enabled = false`) while the key is down. If you use the pair as + press and depress, firing while held, stop in `onCancel()` too, or the gun + is still firing when the player alt-tabs back. +- **`isClickable` alone gates the press, not the release.** Clearing it while + the key is down still lets the key release the button, so it cannot stick. Shortcuts live only while the button is activated, and never start dragging or tap-and-hold: those stay pointer gestures. diff --git a/packages/melonjs/src/renderable/ui/uibaseelement.ts b/packages/melonjs/src/renderable/ui/uibaseelement.ts index a128eb87c..aed6f2a82 100644 --- a/packages/melonjs/src/renderable/ui/uibaseelement.ts +++ b/packages/melonjs/src/renderable/ui/uibaseelement.ts @@ -24,17 +24,31 @@ import { const ringPainter = new PanelPainter(); /** - * The bounce settings every UI widget takes: see + * The bounce settings every built-in widget takes in its settings object: + * a small grow under the pointer and a squash while held. Only the drawing + * scales; the hit area and the layout stay as they are. See * {@link UIBaseElement#hoverScale}. * @category UI + * @example + * // a button that grows 5% under the pointer and squashes while held + * const play = new UITextButton(100, 200, { + * text: "Play", + * hoverScale: 1.05, + * pressScale: 0.92, + * }); */ export interface UIJuiceSettings { /** - * the scale the widget grows to under the pointer or with the focus; - * `1`, the default, is still + * The scale the widget is drawn at under the pointer, or while it has + * the keyboard and gamepad focus, such as `1.05`. `1` turns it off. + * @default 1 */ hoverScale?: number; - /** the scale it shrinks to while held down; `1`, the default, is still */ + /** + * The scale the widget is drawn at while held down, such as `0.92`. + * `1` turns it off. + * @default 1 + */ pressScale?: number; } @@ -122,19 +136,131 @@ function offerPressToScroller(element: UIBaseElement, event: Pointer) { } /** - * This is a basic clickable and draggable container which you can use in your game UI. - * Use this for example if you want to display a panel that contains text, images or other UI elements. + * A clickable, draggable container: the base every UI widget builds on + * ({@link UITextButton}, {@link UICheckbox}, {@link UISlider}, + * {@link UIPanel}). Extend it to make a widget of your own; it can hold + * other renderables as children. + * + * It uses screen coordinates (`floating` is `true`), so it stays put when + * the camera moves. Children added to it should keep `floating` at `false`. + * + * ## Pointer callbacks + * Override these in a subclass: + * - {@link UIBaseElement#onClick}: the pointer went DOWN on the element + * (or Enter / A with the focus here). It is a press, not a completed click. + * - {@link UIBaseElement#onRelease}: the press ended: the pointer came up, + * left the element while held, or the browser cancelled it. + * - {@link UIBaseElement#onOver} / {@link UIBaseElement#onOut}: the pointer + * came in / went out. `hover` is `true` in between. + * - {@link UIBaseElement#onHold}: held for `holdThreshold` ms, when + * `isHoldable` is set. + * - {@link UIBaseElement#onCancel}: the press was called off without a + * release (see {@link UIBaseElement#cancelPress}). + * + * Elements under the pointer are asked topmost first, and the event carries + * on to the ones below until a handler returns `false`. So an element is NOT + * opaque by default: a widget it covers still gets clicks and hover. Return + * `false` from `onClick`, `onRelease` or `onOver` to stop the event there. + * + * ## Turning it off + * Set {@link UIBaseElement#enabled} to `false`: it stops answering the + * pointer, the keys and the pad, and a press in progress is called off. + * + * ## Dragging + * Set {@link UIBaseElement#isDraggable}: the element then follows the + * pointer while held. `onClick` is the pick-up, {@link UIBaseElement#onMove} + * runs on each move, and `onRelease` is the drop. Inside a scrolling + * {@link UIPanel}, a drag that starts on a widget scrolls the panel instead; + * set {@link UIBaseElement#keepsDrag} on a widget that is dragged itself. + * + * ## Bounce + * {@link UIBaseElement#hoverScale} and {@link UIBaseElement#pressScale} + * scale the drawing under the pointer and while held, easing there and back. + * The hit area and the layout do not change. * - * Regions under the pointer are asked in the order they are drawn, topmost - * first, and the walk carries on downwards until a handler returns `false`. An - * element that can overlap another one is therefore not opaque until it says - * so: by default a widget it covers still answers clicks and still lights up on - * hover. {@link UIBaseElement#onClick}, {@link UIBaseElement#onRelease} and - * {@link UIBaseElement#onOver} each consume by returning `false`. + * ## Keyboard and gamepad focus + * For a game played without a mouse or a touch screen. Nothing listens until + * a widget takes the focus with {@link UIBaseElement#focus}; until then the + * arrow keys and the pad are left to the game. With a widget focused: + * - the arrow keys or the D-pad move the focus to the nearest + * {@link UIBaseElement#focusable} widget that way, measured edge to edge; + * - Enter, Space or the pad's A button press the focused widget, as a click + * does; + * - a ring in {@link UIBaseElement.focusRingColor} shows the focus while the + * keyboard or a pad is used, and hides when the mouse moves; + * - a focused widget inside a scrolling {@link UIPanel} is scrolled into view; + * - {@link UIBaseElement#blur}, or removing the widget, gives the keys and + * the pad back to the game. + * + * The built-in widgets are focusable. A widget of your own sets `focusable` + * and can override {@link UIBaseElement#onNavigate} to use the arrows itself. * @category UI * @example + * // a widget of your own: draws itself, bounces, and acts on release + * import { UIBaseElement } from "melonjs"; + * + * class Card extends UIBaseElement { + * constructor(x, y) { + * super(x, y, 120, 160); + * this.focusable = true; // reachable with the arrow keys and the pad + * this.hoverScale = 1.05; + * this.pressScale = 0.95; + * } + * draw(renderer, viewport) { + * renderer.setColor(this.hover ? "#ffffff" : "#cccccc"); + * renderer.fillRect(this.pos.x, this.pos.y, this.width, this.height); + * super.draw(renderer, viewport); // draws the children + * } + * onRelease() { + * console.log("card picked"); + * return false; // the element below does not get it too + * } + * } + * + * app.world.addChild(new Card(50, 50)); + * @example + * // a token dragged around and snapped to a 32 pixel grid when dropped + * import { UIBaseElement, Sprite } from "melonjs"; + * + * class Token extends UIBaseElement { + * constructor(x, y) { + * super(x, y, 64, 64); + * this.isDraggable = true; + * // a sprite is centred on its position by default + * this.addChild(new Sprite(32, 32, { image: "token" })); + * } + * onMove() { + * // runs on each move while dragged: this.pos follows the pointer + * } + * onRelease() { + * this.pos.x = Math.round(this.pos.x / 32) * 32; + * this.pos.y = Math.round(this.pos.y / 32) * 32; + * return false; + * } + * } + * @example + * // a menu played with the keyboard or a pad: start on the first button + * import { UITextButton, state } from "melonjs"; + * + * const play = new UITextButton(100, 200, { + * text: "Play", + * onClick: () => state.change(state.PLAY), + * }); + * const quit = new UITextButton(100, 260, { + * text: "Quit", + * onClick: () => state.change(state.MENU), + * }); + * app.world.addChild(play); + * app.world.addChild(quit); + * // the arrows / D-pad now move between them, Enter / Space / A press; + * // the mouse still works as before + * play.focus(); + * @example * // a panel that is opaque to the pointer and comes to the front when picked - * class Panel extends UIBaseElement { + * // (UIPanel does this with its `blocking` setting) + * import { UIBaseElement, input } from "melonjs"; + * + * class Dialog extends UIBaseElement { * onClick() { * this.ancestor.moveToTop(this); * return false; @@ -147,70 +273,86 @@ function offerPressToScroller(element: UIBaseElement, event: Pointer) { * // `onOver` no longer fires * onActivateEvent() { * super.onActivateEvent(); - * me.input.registerPointerEvent("pointermove", this, () => false); + * input.registerPointerEvent("pointermove", this, () => false); * } * onDeactivateEvent() { - * me.input.releasePointerEvent("pointermove", this); + * input.releasePointerEvent("pointermove", this); * super.onDeactivateEvent(); * } * } */ export default class UIBaseElement extends Container { /** - * UI base elements use screen coordinates by default - * (Note: any child elements added to a UIBaseElement should have their floating property to false) + * UI elements use screen coordinates by default, so they stay put when + * the camera moves. Children added to a UIBaseElement should keep their + * own `floating` at `false`. * @see Renderable.floating * @default true */ override floating = true; /** - * object can be clicked or not + * Whether the pointer can press the element. The low-level flag under + * {@link UIBaseElement#enabled}: prefer `enabled`, which also calls off a + * press in progress, blocks the keys and the pad, and draws a built-in + * widget disabled. * @default true */ isClickable: boolean; /** - * object can be dragged or not + * Whether the element follows the pointer while held. `onClick` runs on + * the pick-up, {@link UIBaseElement#onMove} on each move and `onRelease` + * on the drop. * @default false */ isDraggable: boolean; /** - * Tap and hold threshold timeout in ms + * How long a press must be held before {@link UIBaseElement#onHold} + * runs, in milliseconds. Needs `isHoldable`. * @default 250 */ holdThreshold: number; /** - * object can be tap and hold + * Whether a press held for `holdThreshold` ms calls + * {@link UIBaseElement#onHold}. * @default false */ isHoldable: boolean; /** - * true if the pointer is over the object + * `true` while the pointer is over the element. A tap on a touch screen + * does not set it. * @default false */ hover: boolean; /** - * false if the pointer is down, or true when the pointer status is up + * `false` while the element is held down (by the pointer, or by Enter / + * A with the focus), `true` otherwise. * @default true */ released: boolean; - // object has been updated (clicked,etc..) + /** + * the tap-and-hold timer of a press in progress, or -1 + * @ignore + */ holdTimeout: number; - // grab offset for dragging + /** + * While dragged: where the element was grabbed, relative to its + * position. `undefined` otherwise. + */ grabOffset: Vector2d | undefined; /** - * @param x - The x position of the container - * @param y - The y position of the container - * @param w - width of the container - * @param h - height of the container + * @param x - the x position of the element, in screen pixels + * @param y - the y position of the element, in screen pixels + * @param w - the width of the element, in pixels + * @param h - the height of the element, in pixels */ constructor(x: number, y: number, w?: number, h?: number) { super(x, y, w, h); @@ -267,11 +409,20 @@ export default class UIBaseElement extends Container { /** * Whether a drag that starts on this element belongs to it. * - * `false` by default: inside a scrolling {@link UIPanel}, a drag that - * starts on a button scrolls the panel and cancels the press. Set it on a - * widget that is dragged itself, such as a knob or a colour picker, as - * {@link UISlider} does. Anything `isDraggable` keeps its drags anyway. + * When `false`, a drag that starts on the element inside a scrolling + * {@link UIPanel} scrolls the panel and calls off the press. Set it on a + * widget that handles its own drags, such as a knob or a colour picker, + * as {@link UISlider} does. An `isDraggable` element keeps its drags + * anyway. * @default false + * @example + * // a colour wheel that reads drags itself, inside a scrolling panel + * class ColourWheel extends UIBaseElement { + * constructor(x, y) { + * super(x, y, 128, 128); + * this.keepsDrag = true; + * } + * } */ keepsDrag = false; @@ -279,19 +430,23 @@ export default class UIBaseElement extends Container { protected _enabled = true; /** - * The scale the widget is drawn at under the pointer, or with the focus: - * a little bounce, such as `1.05`. `1` leaves it still. + * The scale the widget is drawn at under the pointer, or while it shows + * the focus ring: a little bounce, such as `1.05`. `1` turns it off. * - * Only the drawing scales, about the widget's centre: its hit area and - * its place in a layout stay as they are, so nothing jitters or moves - * the widgets around it. The scale eases there and back by itself. + * Only the drawing scales, about the widget's centre. Its hit area and + * its place in a layout stay the same, so the widgets around it do not + * move. The scale eases there and back by itself (about 0.2 s). A + * disabled widget does not grow. * @default 1 + * @example + * button.hoverScale = 1.05; + * button.pressScale = 0.92; */ hoverScale = 1; /** * The scale the widget is drawn at while held down, such as `0.92`. - * `1` leaves it still. See {@link UIBaseElement#hoverScale}. + * `1` turns it off. Drawing only, as {@link UIBaseElement#hoverScale}. * @default 1 */ pressScale = 1; @@ -301,45 +456,64 @@ export default class UIBaseElement extends Container { /** * Whether this element can take the keyboard and gamepad focus: see - * {@link UIBaseElement#focus}. Buttons, checkboxes and sliders set it. + * {@link UIBaseElement#focus}. The built-in buttons, checkboxes and + * sliders set it; set it on a widget of your own to make it reachable. + * A disabled or hidden element is skipped. * @default false */ focusable = false; /** - * The colour of the ring drawn around the element with the focus. + * The colour of the ring drawn around the element with the focus, for + * every element. Any CSS colour. * @default "#ffcc33" + * @example + * UIBaseElement.focusRingColor = "#00e0ff"; */ static focusRingColor = "#ffcc33"; /** - * The element with the keyboard and gamepad focus, if any. + * The element with the keyboard and gamepad focus, or `undefined` when + * none has it. + * @example + * // give the focus back to the menu if nothing has it + * if (UIBaseElement.focusOwner === undefined) { + * playButton.focus(); + * } */ static get focusOwner(): UIBaseElement | undefined { return focusOwner(); } - /** whether this element has the keyboard and gamepad focus */ + /** `true` while this element has the keyboard and gamepad focus */ get focused(): boolean { return focusOwner() === this; } /** * Take the keyboard and gamepad focus, for a game played without a mouse - * or a touch screen. + * or a touch screen. Call it once the element is in the scene. * * Nothing has the focus until a game gives it to an element, and until - * then the arrow keys and the pad are left alone. From then on the arrows - * or a pad's D-pad move the focus to the nearest focusable element that - * way, and Enter, Space or the pad's A press the focused one as a click - * does; a slider steps on left and right instead. A ring shows the focus, - * and hides while the mouse is used. A focused element in a scrolling - * panel is scrolled into view. + * then the arrow keys and the pad are left to the game. From then on: + * the arrows or the D-pad move the focus to the nearest focusable element + * that way; Enter, Space or the pad's A button press the focused one as a + * click does (a slider steps on left and right instead). The focus ring + * shows, and hides again while the mouse is used. A focused element in a + * scrolling panel is scrolled into view. * * Does nothing on an element that is not `focusable`. * @example * // a menu played with the keyboard or a pad: start on Play + * app.world.addChild(playButton); + * app.world.addChild(optionsButton); * playButton.focus(); + * @example + * // a pause menu in a panel: focus its first button when it opens + * panel.addChild(resumeButton); + * panel.addChild(quitButton); + * app.world.addChild(panel); + * resumeButton.focus(); */ focus(): void { if (this.focusable) { @@ -349,7 +523,11 @@ export default class UIBaseElement extends Container { /** * Give up the focus, if this element has it. With nothing focused, the - * arrow keys and the pad are left to the game again. + * arrow keys and the pad go back to the game. Removing the element from + * the scene does the same. + * @example + * // leaving the menu for the game: the arrows move the player again + * UIBaseElement.focusOwner?.blur(); */ blur(): void { if (this.focused) { @@ -358,21 +536,44 @@ export default class UIBaseElement extends Container { } /** - * An arrow key or a D-pad direction while this element has the focus: - * return `true` to say it was handled, as a slider does to step, and the - * focus stays. Unhandled, it moves the focus to the nearest widget that - * way. Enter or A presses the widget instead, as a click does, through + * Called for an arrow key or a D-pad direction while this element has + * the focus. Return `true` to handle it yourself (the focus stays here), + * as a slider does to step. Return `false`, the default, to move the + * focus to the nearest widget that way. + * + * Enter, Space and A do not come here: they press the widget through * {@link UIBaseElement#focusPress} and {@link UIBaseElement#focusRelease}. - * @param _action - which way - * @returns true when handled + * @param _action - which way: `"up"`, `"down"`, `"left"` or `"right"` + * @returns `true` when handled + * @example + * // a picker: left and right change the choice, up and down move on + * class Picker extends UIBaseElement { + * constructor(x, y, choices) { + * super(x, y, 160, 32); + * this.focusable = true; + * this.choices = choices; + * this.index = 0; + * } + * onNavigate(action) { + * if (action === "left" || action === "right") { + * const step = action === "right" ? 1 : -1; + * const n = this.choices.length; + * this.index = (this.index + step + n) % n; + * this.isDirty = true; + * return true; + * } + * return false; + * } + * } */ onNavigate(_action: UINavAction): boolean { return false; } /** - * Accept was pressed with the focus here: the widget goes down, as under - * a pointer, and `onClick` runs with no event. + * Called when Enter, Space or A is pressed with the focus here: the + * widget goes down, as under the pointer, and `onClick` runs with no + * event. Override it only to change what accept does. * @returns whether it went down: not when disabled, or already held */ focusPress(): boolean { @@ -388,9 +589,11 @@ export default class UIBaseElement extends Container { } /** - * Accept was let go: the widget comes back up and `onRelease` runs. A - * widget completes its action here, as on a click: a button calls its - * `onClick` setting, a checkbox toggles. + * Called when Enter, Space or A is let go after + * {@link UIBaseElement#focusPress}: the widget comes back up and + * `onRelease` runs with no event. The built-in widgets complete their + * action here, as on a click: a button calls its `onClick` setting, a + * checkbox toggles. */ focusRelease(): void { if (!this.released) { @@ -465,11 +668,20 @@ export default class UIBaseElement extends Container { /** * Whether the widget answers input: the switch to turn a widget off. * - * Turning it off sets `isClickable`, calls off a press in progress - * without a release, and draws the widget disabled: its `disabled` skin - * region, or at half opacity. `isClickable` is the low-level flag - * underneath, and setting it alone changes neither the look nor this. + * Turning it off clears `isClickable`, calls off a press in progress + * (see {@link UIBaseElement#cancelPress}), and skips it in keyboard and + * gamepad navigation. The built-in widgets draw themselves disabled: + * their `disabled` skin region, or at half opacity. A widget of your own + * reads `enabled` in its `draw`. + * + * `isClickable` is the low-level flag underneath; setting it alone does + * not change `enabled` or the look. * @default true + * @example + * // greyed out until a save exists + * continueButton.enabled = false; + * // ... later + * continueButton.enabled = true; */ get enabled(): boolean { return this._enabled; @@ -520,12 +732,14 @@ export default class UIBaseElement extends Container { } /** - * Abandon a press in progress, without calling `onRelease`. + * Call off a press in progress, without calling `onRelease`: + * {@link UIBaseElement#onCancel} runs instead. * * The element goes back to its released look, and the pointer-up that * follows does nothing. A scrolling {@link UIPanel} calls this when a drag * that started on the element turns out to be a scroll, so a button - * dragged to scroll its list is not clicked. + * dragged to scroll its list is not clicked. Setting `enabled` to `false` + * and losing the focus while Enter or A is held call it too. */ cancelPress(): void { if (!this.released) { @@ -538,17 +752,25 @@ export default class UIBaseElement extends Container { } /** - * Called when a press is abandoned rather than completed: see - * {@link UIBaseElement#cancelPress}. To be extended. + * Called when a press is called off rather than completed: see + * {@link UIBaseElement#cancelPress}. Override it to undo what `onClick` + * started, such as a charge-up effect. */ onCancel(): void { // to be extended } /** - * function called when the object is pressed (to be extended) - * @param _event - the event object - * @returns return false if we need to stop propagating the event + * Called when the element is pressed: the pointer went down on it, or + * Enter / Space / A with the focus here (then with no event). Override it + * in a subclass. + * + * This is the press, not a completed click: see + * {@link UIBaseElement#onRelease} for the end of it. + * @param _event - the pointer event, or `undefined` from the keyboard or + * the pad + * @returns `false` to stop the event here, so elements below do not get + * it too */ onClick(_event?: Pointer): boolean { @@ -593,8 +815,10 @@ export default class UIBaseElement extends Container { } /** - * function called when the pointer is moved over the object - * @param _event - the event object + * Called on each pointer move while the element is dragged (see + * {@link UIBaseElement#isDraggable}), after `pos` has followed the + * pointer. Override it in a subclass. + * @param _event - the pointer event */ onMove(_event?: Pointer): void { @@ -602,16 +826,16 @@ export default class UIBaseElement extends Container { } /** - * function called when the pointer is over the object. + * Called when the pointer comes over the element. Override it in a + * subclass. * * Fires once, on the frame the pointer crosses into the element. To stay - * opaque for as long as the pointer rests on it, an element also wants a - * `"pointermove"` callback registered through - * {@link input.registerPointerEvent} returning `false`, since by then the - * pointer is already inside and this no longer runs. - * @param _event - the event object - * @returns return false if we need to stop propagating the event, so that an - * element covered by this one does not also light up on hover + * opaque to hover for as long as the pointer rests on it, also register a + * `"pointermove"` callback through {@link input.registerPointerEvent} + * that returns `false` (see the class example). + * @param _event - the pointer event + * @returns `false` to stop the event here, so an element covered by this + * one does not also light up */ onOver(_event?: Pointer): boolean | void { @@ -632,16 +856,16 @@ export default class UIBaseElement extends Container { } /** - * function called when the pointer is leaving the object area. + * Called when the pointer leaves the element. Override it in a subclass. * - * Fires when the pointer leaves the element's bounds, and also when it is - * still inside them but something drawn above has consumed the move, since - * the pointer is then over that instead. + * Also fires when the pointer is still inside but something drawn above + * has stopped the move, since the pointer is then over that instead. A + * press in progress ends first, through `onRelease`. * - * Unlike {@link UIBaseElement#onOver} this one cannot consume the event, on - * purpose: an element that suppressed its own leave would stay in its hover - * state after the pointer had gone. - * @param _event - the event object + * Unlike {@link UIBaseElement#onOver} it cannot stop the event: an + * element that skipped its own leave would stay lit after the pointer + * had gone. + * @param _event - the pointer event */ onOut(_event?: Pointer): void { @@ -664,9 +888,18 @@ export default class UIBaseElement extends Container { } /** - * function called when the object is pressed and released (to be extended) - * @param _event - the event object - * @returns return false if we need to stop propagating the event + * Called when a press ends. Override it in a subclass. + * + * It runs when the pointer comes up, but also when the pointer leaves the + * element while held, or the browser cancels the press: it is the end + * of a press, not proof of a completed click. (For a click that only + * counts when completed, use a {@link UITextButton} and its `onClick` + * setting.) A press called off by {@link UIBaseElement#cancelPress} does + * not come here. + * @param _event - the pointer event, or `undefined` from the keyboard or + * the pad + * @returns `false` to stop the event here, so elements below do not get + * it too */ onRelease(_event?: Pointer): boolean { @@ -688,8 +921,8 @@ export default class UIBaseElement extends Container { } /** - * function called when the object is pressed and held
- * to be extended
+ * Called once a press has been held for `holdThreshold` milliseconds, + * when `isHoldable` is set. Override it in a subclass. */ onHold(): void {} diff --git a/packages/melonjs/src/renderable/ui/uispriteelement.ts b/packages/melonjs/src/renderable/ui/uispriteelement.ts index 54736e3ae..8464754d0 100644 --- a/packages/melonjs/src/renderable/ui/uispriteelement.ts +++ b/packages/melonjs/src/renderable/ui/uispriteelement.ts @@ -7,74 +7,142 @@ import timer from "./../../system/timer.ts"; import Sprite from "./../sprite.js"; /** - * This is a basic sprite based button which you can use in your Game UI. + * A {@link Sprite} that takes clicks: use it for an image-only button or a + * clickable icon, such as a pause or a sound toggle, with no label. + * + * It takes the same settings as a {@link Sprite}: a single image, a frame + * of a spritesheet, or a region of a {@link TextureAtlas}. Like a sprite it + * is centred on its position by default (`anchorPoint` 0.5, 0.5), and the + * pointer hits its bounds. It uses screen coordinates (`floating` is `true`). + * + * Override the callbacks in a subclass: {@link UISpriteElement#onClick} when + * the pointer goes down on it, {@link UISpriteElement#onRelease} when the + * press ends, {@link UISpriteElement#onOver} / {@link UISpriteElement#onOut} + * for hover (`hover` is `true` in between), and + * {@link UISpriteElement#onHold} with `isHoldable`. Unlike + * {@link UIBaseElement}, `onClick` and `onRelease` stop the event by default + * (they return `false`), so an element below does not get it too. + * + * It is a lighter element than {@link UIBaseElement}: it has no `enabled`, + * no keyboard and gamepad focus, no bounce and no dragging. For those, use + * a {@link UITextButton} with a `skin` (its `text` can be empty) or a + * {@link UIBaseElement} subclass holding a sprite. * @category UI + * @example + * // a sound toggle icon from an atlas, swapping its region on each click + * import { UISpriteElement, audio } from "melonjs"; + * + * class SoundIcon extends UISpriteElement { + * constructor(x, y) { + * super(x, y, { image: atlas, region: "sound_on" }); + * this.muted = false; + * } + * onClick() { + * this.muted = !this.muted; + * if (this.muted) { + * audio.muteAll(); + * } else { + * audio.unmuteAll(); + * } + * this.setRegion(atlas.getRegion(this.muted ? "sound_off" : "sound_on")); + * return false; + * } + * } + * + * app.world.addChild(new SoundIcon(40, 40)); + * @example + * // a pause button from a spritesheet: frame 0 at rest, frame 1 under the pointer + * import { UISpriteElement, state } from "melonjs"; + * + * class PauseButton extends UISpriteElement { + * constructor(x, y) { + * super(x, y, { image: "pause_button", framewidth: 64, frameheight: 64 }); + * this.addAnimation("idle", [0]); + * this.addAnimation("over", [1]); + * this.setCurrentAnimation("idle"); + * } + * onOver() { + * this.setCurrentAnimation("over"); + * } + * onOut() { + * this.setCurrentAnimation("idle"); + * } + * onRelease() { + * state.pause(); + * return false; + * } + * } */ export default class UISpriteElement extends Sprite { /** - * if this UISpriteElement should use screen coordinates or local coordinates - * (Note: any UISpriteElement elements added to a floating parent container should have their floating property to false) + * Whether the element uses screen coordinates, so it stays put when the + * camera moves. Set it to `false` when adding the element to a floating + * parent, such as a {@link UIPanel}. * @see Renderable.floating * @default true */ override floating = true; /** - * object can be clicked or not + * Whether the pointer can press the element. Set it to `false` to turn + * the element off (it does not change the look). * @default true */ isClickable: boolean; /** - * Tap and hold threshold timeout in ms + * How long a press must be held before {@link UISpriteElement#onHold} + * runs, in milliseconds. Needs `isHoldable`. * @default 250 */ holdThreshold: number; /** - * object can be tap and hold + * Whether a press held for `holdThreshold` ms calls + * {@link UISpriteElement#onHold}. * @default false */ isHoldable: boolean; /** - * true if the pointer is over the object + * `true` while the pointer is over the element * @default false */ hover: boolean; - // object has been updated (clicked,etc..) + /** + * the tap-and-hold timer of a press in progress, or -1 + * @ignore + */ holdTimeout: number; + + /** + * `false` while the element is held down, `true` otherwise + * @default true + */ released: boolean; /** - * @param x - the x coordinate of the UISpriteElement Object - * @param y - the y coordinate of the UISpriteElement Object - * @param settings - See {@link Sprite} - * @param settings.image - the image to use for the sprite + * @param x - the x position of the element (its centre by default), in + * screen pixels + * @param y - the y position of the element (its centre by default), in + * screen pixels + * @param settings - the {@link Sprite} settings + * @param settings.image - the image: a loader key, an image, or a + * {@link TextureAtlas} (with `region`) * @example - * // create a basic GUI Object - * class myButton extends UISpriteElement { - * constructor(x, y) { - * // call the UISpriteElement parent constructor - * super(x, y, { - * image: "button", - * framewidth: 100, - * frameheight: 50 - * }); - * } + * // a button image loaded as "button", logging each press + * class MyButton extends UISpriteElement { + * constructor(x, y) { + * super(x, y, { image: "button" }); + * } + * onClick() { + * console.log("clicked!"); + * return false; + * } + * } * - * // output something in the console - * // when the object is clicked - * onClick(event) { - * console.log("clicked!"); - * // don't propagate the event - * return false; - * } - * }); - * - * // add the object at pos (10,10) - * world.addChild(new myButton(10,10)); + * app.world.addChild(new MyButton(100, 50)); */ constructor( x: number, @@ -121,9 +189,11 @@ export default class UISpriteElement extends Sprite { } /** - * function called when the object is pressed (to be extended) - * @param _event - the event object - * @returns return false if we need to stop propagating the event + * Called when the pointer goes down on the element. Override it in a + * subclass. This is the press, not a completed click. + * @param _event - the pointer event + * @returns `false`, the default, to stop the event here; `true` to let + * elements below get it too */ onClick(_event?: Pointer): boolean { @@ -142,16 +212,16 @@ export default class UISpriteElement extends Sprite { } /** - * function called when the pointer is over the object. + * Called when the pointer comes over the element. Override it in a + * subclass. * * Fires once, on the frame the pointer crosses into the element. To stay - * opaque for as long as the pointer rests on it, an element also wants a - * `"pointermove"` callback registered through - * {@link input.registerPointerEvent} returning `false`, since by then the - * pointer is already inside and this no longer runs. - * @param _event - the event object - * @returns return false if we need to stop propagating the event, so that an - * element covered by this one does not also light up on hover + * opaque to hover for as long as the pointer rests on it, also register a + * `"pointermove"` callback through {@link input.registerPointerEvent} + * that returns `false`. + * @param _event - the pointer event + * @returns `false` to stop the event here, so an element covered by this + * one does not also light up */ onOver(_event?: Pointer): boolean | void { @@ -171,16 +241,16 @@ export default class UISpriteElement extends Sprite { } /** - * function called when the pointer is leaving the object area. + * Called when the pointer leaves the element. Override it in a subclass. * - * Fires when the pointer leaves the element's bounds, and also when it is - * still inside them but something drawn above has consumed the move, since - * the pointer is then over that instead. + * Also fires when the pointer is still inside but something drawn above + * has stopped the move, since the pointer is then over that instead. A + * press in progress ends first, through `onRelease`. * - * Unlike {@link UISpriteElement#onOver} this one cannot consume the event, on - * purpose: an element that suppressed its own leave would stay in its hover - * state after the pointer had gone. - * @param _event - the event object + * Unlike {@link UISpriteElement#onOver} it cannot stop the event: an + * element that skipped its own leave would stay lit after the pointer + * had gone. + * @param _event - the pointer event */ onOut(_event?: Pointer): void { @@ -203,9 +273,14 @@ export default class UISpriteElement extends Sprite { } /** - * function called when the object is pressed and released (to be extended) - * @param _event - the event object - * @returns return false if we need to stop propagating the event + * Called when a press ends. Override it in a subclass. + * + * It runs when the pointer comes up, but also when the pointer leaves the + * element while held, or the browser cancels the press: it is the end + * of a press, not proof of a completed click. + * @param _event - the pointer event + * @returns `false`, the default, to stop the event here; `true` to let + * elements below get it too */ onRelease(_event?: Pointer): boolean { @@ -227,8 +302,8 @@ export default class UISpriteElement extends Sprite { } /** - * function called when the object is pressed and held
- * to be extended
+ * Called once a press has been held for `holdThreshold` milliseconds, + * when `isHoldable` is set. Override it in a subclass. */ onHold(): void {} diff --git a/packages/melonjs/src/renderable/ui/uitextbutton.ts b/packages/melonjs/src/renderable/ui/uitextbutton.ts index 62b0a7edb..5bedd58be 100644 --- a/packages/melonjs/src/renderable/ui/uitextbutton.ts +++ b/packages/melonjs/src/renderable/ui/uitextbutton.ts @@ -16,145 +16,214 @@ import type { UISkin } from "./uiskin.ts"; import { SkinDrawer } from "./uiskin.ts"; /** - * The art of a skinned {@link UITextButton}: one atlas region per state. + * The art of a skinned {@link UITextButton}: one atlas region name per + * state, plus the `atlas` and optional nine-slice insets from + * {@link UISkin}. * * Each region is drawn as a nine-slice, so the button can be any size. + * Every region named is looked up when the button is built, so a typo + * throws there. * @category UI + * @example + * const skin = { + * atlas, + * normal: "green_button04", + * pressed: "green_button05", + * disabled: "grey_button04", + * }; */ export interface UITextButtonSkin extends UISkin { - /** the button at rest */ + /** the region drawn at rest; also sets the button's default size */ normal: string; /** - * The button held down. When it is shorter than `normal`, as pressed art - * usually is, it is drawn lowered by the difference and the label moves - * down with it. + * The region drawn while held down; `normal` if left out. When it is + * shorter than `normal`, as pressed art usually is, it is drawn lowered + * by the difference and the label moves down with it. */ pressed?: string; - /** the button under the pointer; `normal` if left out */ + /** the region drawn under the pointer; `normal` if left out */ hover?: string; - /** the button while disabled; `normal` at half opacity if left out */ + /** the region drawn while disabled; `normal` at half opacity if left out */ disabled?: string; } /** - * Everything {@link UITextButton} takes. + * Everything {@link UITextButton} takes. Every field is optional. The + * object is only read, so one style object can be spread into several + * buttons. * @category UI */ export interface UITextButtonSettings extends UIJuiceSettings { /** * The font: the name of a loaded bitmap font, or a CSS font family. A - * name with a font image in the loader, or a `fontData` setting, is - * drawn through {@link BitmapText}, as before; anything else through - * {@link Text}. + * name with a font image in the loader, or a `fontData` setting, is drawn + * as a {@link BitmapText}; anything else as a {@link Text} with that CSS + * font. * @default "sans-serif" */ font?: string; - /** the bitmap font data, when it is loaded under another name than `font` */ + /** + * the loader name of the bitmap font data, when it differs from `font` + */ fontData?: string; /** - * The label's height in pixels, for a bitmap font as for a CSS font: the - * same setting as on the other widgets. Takes over from `size`. + * The label's height in pixels, for a bitmap font as for a CSS font. A + * bitmap font is scaled to it. Left out: 16 for a CSS font, the authored + * size for a bitmap font. Wins over `size`. */ fontSize?: number; /** - * The older form of `fontSize`: with a bitmap font, a scale of its - * authored size; with a CSS font, the height in pixels. + * Older form of `fontSize`, still accepted. With a CSS font it is the + * height in pixels; with a bitmap font it is a scale of the authored + * size (`2` is double), not pixels. */ size?: number; - /** the label */ + /** + * the label's text + * @default "" + */ text?: string; - /** the label's colour, as on the other widgets; a bitmap font is tinted with it */ + /** + * The label's colour. A bitmap font is tinted with it; left out, a + * bitmap font keeps its own colours and a CSS font is black. + */ labelFillStyle?: string; - /** the older name of `labelFillStyle` */ + /** older name of `labelFillStyle`, still accepted */ fillStyle?: string; - /** see {@link UITextButton.bindKey} */ + /** + * A keyboard shortcut that clicks the button: an `input.KEY` key code, + * or the name of an action bound with `input.bindKey`. `-1` for none. + * See {@link UITextButton#bindKey}. + * @default -1 + */ bindKey?: string | number; /** - * Called once per completed click: pressed and released on the button, - * or its {@link UITextButton.bindKey} pressed and released. A press - * dragged off the button, cancelled, or turned into a scroll by a + * What the button does. Called once per completed click: pressed and + * released on the button, Enter / Space / A pressed and released with + * the focus on it, or its `bindKey` pressed and released. A press dragged + * off the button, called off, or turned into a scroll by a * {@link UIPanel} does not call it. Nor does a disabled button. */ onClick?: (button: UITextButton) => void; /** - * whether the button answers input + * whether the button answers input; see {@link UIBaseElement#enabled} * @default true */ enabled?: boolean; - /** draw the button from atlas regions instead of a rounded rectangle */ + /** + * Draw the button from atlas regions instead of a rounded rectangle. + * The colour settings below are then not used. + */ skin?: UITextButtonSkin; /** - * background colour at rest (drawn look) + * background colour at rest, drawn look only * @default "#00aa0080" */ hoverOffColor?: Paint; /** - * background colour under the pointer (drawn look) + * background colour under the pointer, drawn look only * @default "#00ff00ff" */ hoverOnColor?: Paint; - /** background colour while held down (drawn look); `hoverOnColor` by default */ + /** + * Background colour while held down, drawn look only. Left out, a held + * button shows `hoverOnColor`. + */ pressedColor?: Paint; /** - * outline colour (drawn look), the name the other widgets use + * outline colour, drawn look only * @default "#000000" */ borderColor?: Paint; - /** the older name of `borderColor` */ + /** older name of `borderColor`, still accepted */ borderStrokeColor?: Paint; /** - * the label's alignment about the middle of the button + * The label's horizontal alignment about the centre of the button: + * `"left"` starts the text at the centre, `"right"` ends it there. * @default "center" */ textAlign?: string; /** - * the label's baseline about the middle of the button + * the label's baseline about the vertical middle of the button * @default "middle" */ textBaseline?: string; /** - * the button's width: the label's width plus 16 by default, or the skin's - * `normal` region's + * The button's width in pixels. Left out: the skin's `normal` region + * width, or else the label's width plus 16. */ width?: number; /** - * the button's height: the label's height plus 16 by default, or the - * skin's `normal` region's + * The button's height in pixels. Left out: the skin's `normal` region + * height, or else the label's height plus 16. */ height?: number; /** - * The older name of `width`. Not an outline thickness, as `borderWidth` - * is on the other widgets. + * Older name of `width`, still accepted. It is the button's width, not an + * outline thickness as `borderWidth` is on the other widgets. */ borderWidth?: number; - /** the older name of `height` */ + /** older name of `height`, still accepted */ borderHeight?: number; - /** passed on to the {@link BitmapText} of a bitmap font label */ + /** + * Anything else is passed on to the {@link BitmapText} of a bitmap font + * label, such as `lineHeight`. + */ [key: string]: any; } /** - * A button with a label, drawn as a rounded rectangle, or from atlas art - * through a {@link UITextButtonSkin}. + * A button with a text label. Use it for menu entries, dialog choices and + * any action the player clicks, taps, or selects with the keyboard or a pad. + * + * ## Drawn or skinned + * With no assets, it is drawn as a rounded rectangle in `hoverOffColor`, + * `hoverOnColor` under the pointer and `pressedColor` while held (which + * follows `hoverOnColor` when left out), with a `borderColor` outline. Give + * it a `skin` ({@link UITextButtonSkin}) to draw it from atlas regions + * instead, stretched as a nine-slice to any `width` and `height`. + * + * ## What it does + * Pass an `onClick` setting. It is called on a COMPLETED click only: a press + * dragged off the button is taken back, and a drag that starts on it inside + * a scrolling {@link UIPanel} scrolls the panel instead. Enter, Space or A + * with the focus, and the `bindKey` shortcut, click it too. + * + * A subclass can still override {@link UIBaseElement#onClick} and + * {@link UIBaseElement#onRelease}; they run on the press and on the + * release, before the `onClick` setting. + * + * ## Text + * `font` is the name of a loaded bitmap font or, failing that, a CSS font + * family (`"sans-serif"` by default). `fontSize` is the label height in + * pixels for both kinds. `labelFillStyle` colours it. + * + * ## Turning it off + * `enabled: false` (or setting {@link UIBaseElement#enabled} later) stops + * clicks, the shortcut and the focus, and draws it with its `disabled` skin + * region or at half opacity. * - * Give it an `onClick` for what it does. That is called on a COMPLETED - * click, so a press can still be taken back by dragging off the button, and - * a drag that starts on it inside a scrolling {@link UIPanel} scrolls the - * panel instead. + * ## Older setting names + * Still accepted, as aliases: `borderWidth` / `borderHeight` for `width` / + * `height`, `size` for `fontSize` (a scale with a bitmap font), + * `fillStyle` for `labelFillStyle`, and `borderStrokeColor` for + * `borderColor`. * - * The {@link UIBaseElement#onClick} and {@link UIBaseElement#onRelease} - * methods are still there for a subclass, and are called on the press and - * on the release as before. + * Focus, bounce (`hoverScale`, `pressScale`) and the rest come from + * {@link UIBaseElement}. * @category UI * @example * // drawn, with no assets + * import { UITextButton, state } from "melonjs"; + * * const play = new UITextButton(100, 200, { * text: "Play", * fontSize: 24, * onClick: () => state.change(state.PLAY), * }); - * - * // skinned from an atlas, any width + * app.world.addChild(play); + * @example + * // skinned from an atlas, with a bitmap font 12 pixels high * const accept = new UITextButton(30, 250, { * font: "kenpixel", * fontSize: 12, @@ -163,32 +232,82 @@ export interface UITextButtonSettings extends UIJuiceSettings { * skin: { atlas, normal: "green_button04", pressed: "green_button05" }, * onClick: () => saveSettings(), * }); + * @example + * // a keyboard shortcut: Escape clicks "Back" + * import { UITextButton, input } from "melonjs"; + * + * const back = new UITextButton(20, 20, { + * text: "Back", + * bindKey: input.KEY.ESC, + * onClick: () => closeMenu(), + * }); + * @example + * // disabled until something happens + * const start = new UITextButton(100, 300, { + * text: "Start", + * enabled: false, + * onClick: () => startGame(), + * }); + * // ... once the player has picked a character + * start.enabled = true; + * @example + * // one style shared by several buttons + * const style = { + * font: "Arial", + * fontSize: 20, + * width: 200, + * labelFillStyle: "#ffffff", + * hoverOffColor: "#224488", + * hoverOnColor: "#3366cc", + * pressedColor: "#112244", + * borderColor: "#ffffff", + * hoverScale: 1.05, + * }; + * const resume = new UITextButton(100, 150, { ...style, text: "Resume", onClick: resumeGame }); + * const quit = new UITextButton(100, 210, { ...style, text: "Quit", onClick: quitGame }); */ export default class UITextButton extends UIBaseElement { /** - * Keyboard shortcut: an input.KEY keycode or an existing input action name. - * A press calls onClick() and its matching release calls onRelease(), both - * without a pointer event, then the `onClick` setting. Shortcuts are active - * only while the button is activated and never change global key bindings. - * The first input to press owns the button until released; shortcuts do - * not start pointer dragging or tap-and-hold. + * A keyboard shortcut that clicks the button: an `input.KEY` key code, or + * the name of an action bound with `input.bindKey` (so every key bound to + * it works). `-1`, or an empty string, for none. + * + * The key going down presses the button (the `onClick` method runs, with + * no event); the same key coming up releases it (`onRelease`), then the + * `onClick` setting runs. The shortcut works only while the button is in + * the scene and enabled, and it does not change any global key binding. + * Whichever input presses first, key or pointer, owns the press until it + * is released. + * + * When another button shows the focus ring, Enter and Space belong to that + * button: an "OK" bound to Enter does not fire while "Cancel" is focused. * - * `isClickable` gates the PRESS, not the release: a button disabled while - * its key is held still releases when that key comes up, so it cannot be - * left stuck down. + * Setting `enabled` to `false` while the key is held calls the press off. + * Clearing only `isClickable` blocks new presses but still lets a held key + * release, so the button is not left stuck down. * - * **Losing focus or being removed cancels a held press WITHOUT calling - * `onRelease()`.** The press was abandoned rather than completed, so the - * button returns to its released state silently. Code that treats the pair - * as press and depress, firing a weapon on click and stopping on release, - * needs to stop on {@link event.BLUR} too or it will still be firing when - * the window comes back. + * The window losing focus, or the button being removed, calls a held key + * press off: `onCancel()` is called instead of `onRelease()`, and there + * is no click. Code that starts something on the press, such as firing a + * weapon, stops it in both `onRelease()` and `onCancel()`. * @default -1 + * @example + * // Escape clicks the button + * backButton.bindKey = input.KEY.ESC; + * @example + * // every key bound to the "pause" action clicks the button + * input.bindKey(input.KEY.P, "pause"); + * pauseButton.bindKey = "pause"; */ bindKey: string | number; /** - * What the button does: see {@link UITextButtonSettings.onClick}. + * What the button does, from the `onClick` setting: called once per + * completed click, with the button. Can be set or replaced later. See + * {@link UITextButtonSettings.onClick}. + * @example + * // switch what the button does once the level is loaded + * startButton.onClickCallback = () => state.change(state.PLAY); */ onClickCallback: ((button: UITextButton) => void) | undefined; @@ -241,40 +360,49 @@ export default class UITextButton extends UIBaseElement { this.keyboardKey = undefined; this.released = true; this.isDirty = true; + // called off, as a pointer press can be: no release, no click + this.onCancel(); } }; /** - * The css value of a color to be used if the pointer is not hovering over the button + * The background colour at rest, drawn look only. + * @default "#00aa0080" */ hoverOffColor: Paint; /** - * The css value of a color to be used if the pointer hovers over the button + * The background colour under the pointer, drawn look only. Also the + * colour while held, when `pressedColor` is undefined. + * @default "#00ff00ff" */ hoverOnColor: Paint; /** - * The background while the button is held down. Left undefined, the - * button shows {@link UITextButton#hoverOnColor}, whatever it is then. + * The background colour while held down, drawn look only. Left + * undefined, the button shows {@link UITextButton#hoverOnColor}, + * following it if it changes. */ pressedColor: Paint | undefined; /** - * The css value of a color to be used to draw the border + * The outline colour, drawn look only: the `borderColor` setting (or its + * older name `borderStrokeColor`). + * @default "#000000" */ borderStrokeColor: Paint; /** - * Set the default text alignment (or justification),
- * possible values are "left", "right", and "center". + * The label's horizontal alignment about the centre of the button: + * `"left"`, `"right"` or `"center"`. Read when the button is built. * @default "center" */ textAlign: string; /** - * Set the text baseline (e.g. the Y-coordinate for the draw operation),
- * possible values are "top", "hanging", "middle", "alphabetic", "ideographic", "bottom"
+ * The label's baseline about the vertical middle of the button: `"top"`, + * `"hanging"`, `"middle"`, `"alphabetic"`, `"ideographic"` or + * `"bottom"`. Read when the button is built. * @default "middle" */ textBaseline: string; @@ -282,20 +410,23 @@ export default class UITextButton extends UIBaseElement { /** * The label: a {@link BitmapText} for a loaded bitmap font, a * {@link Text} otherwise. + * @example + * // change the text later (the button keeps its size) + * scoreButton.label.setText("Score: 120"); */ label: UILabel; /** - * the measured text dimensions + * the label's size as measured when the button was built */ dimensions: Bounds; /** - * the round rect border + * the rounded rectangle the drawn look fills and outlines */ border: RoundRect; - /** the art, when the button is skinned */ + /** the `skin` setting, or `undefined` for the drawn look */ readonly skin: UITextButtonSkin | undefined; /** @ignore */ @@ -304,34 +435,27 @@ export default class UITextButton extends UIBaseElement { private _labelDrop = 0; /** - * A Bitmap Text Button with an outlined background border, filled with background color. - * It uses a RoundRect as background and changes the background color on hovering over. - * The background will be drawn with 0.5 opacity, so that the background of the button is - * slightly shining through. - * @param x - x pos of the button - * @param y - y pos of the button + * @param x - the x position of the button's top-left corner, in screen pixels + * @param y - the y position of the button's top-left corner, in screen pixels * @param settings - see {@link UITextButtonSettings} * @example - * // Create a new Button - * class PlayButton extends UITextButton { - * constructor(x,y) { - * super(x,y, { - * font: 'my-font', - * text: 'Play', - * // if you omit the next two, size is calculated by the size of the text - * borderWidth: 200, - * borderHeight: 20, - * hoverOffColor: '#00aa0080', - * hoverOnColor: '#00ff00ff' - * }); - * } - * - * onClick(){ - * state.change(state.PLAY); - * } + * // as a subclass, acting on the press rather than the completed click + * class FireButton extends UITextButton { + * constructor(x, y) { + * super(x, y, { text: "Fire", width: 120, height: 48 }); + * } + * onClick() { + * startFiring(); + * return false; + * } + * onRelease() { + * stopFiring(); + * return false; + * } + * onCancel() { + * stopFiring(); + * } * } - * - * world.addChild(new PlayButton(15,200)); */ constructor(x: number, y: number, settings: UITextButtonSettings = {}) { super(x, y); @@ -440,7 +564,8 @@ export default class UITextButton extends UIBaseElement { } /** - * Accept let go with the focus here: a completed click. + * Called when Enter, Space or A is let go with the focus here: a + * completed click, so the `onClick` setting runs. */ override focusRelease(): void { const pressed = !this.released; @@ -451,7 +576,8 @@ export default class UITextButton extends UIBaseElement { } /** - * Call off a press, from the pointer or from the key. + * Call off a press, from the pointer or from the `bindKey`, without a + * click. See {@link UIBaseElement#cancelPress}. */ override cancelPress(): void { this.cancelKeyboardPress(); @@ -459,12 +585,14 @@ export default class UITextButton extends UIBaseElement { } /** - * Resize the button, and keep its background and label in step. + * Resize the button; the background and the centred label follow. * * A {@link UIPanel} calls this to stretch a button to its column. - * @param w - the new width - * @param h - the new height + * @param w - the new width, in pixels + * @param h - the new height, in pixels * @returns this button + * @example + * button.resize(240, 48); */ override resize(w: number, h: number) { super.resize(w, h); diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js index 0f0012648..fb0ae2230 100644 --- a/packages/melonjs/tests/ui-widgets.spec.js +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -476,6 +476,29 @@ describe("UI widgets", () => { expect(onClick).toHaveBeenCalledTimes(1); }); + it("a held bindKey press called off calls onCancel, not onRelease or onClick", () => { + const onClick = vi.fn(); + const b = place( + new UITextButton(100, 100, { + text: "Fire", + bindKey: input.KEY.SPACE, + onClick, + }), + ); + const onRelease = vi.spyOn(b, "onRelease"); + const onCancel = vi.spyOn(b, "onCancel"); + // the window losing focus while the key is held + event.emit(event.KEYDOWN, undefined, input.KEY.SPACE); + event.emit(event.BLUR); + // disabled while held + event.emit(event.KEYDOWN, undefined, input.KEY.SPACE); + b.enabled = false; + event.emit(event.KEYUP, undefined, input.KEY.SPACE); + expect(onCancel).toHaveBeenCalledTimes(2); + expect(onRelease).not.toHaveBeenCalled(); + expect(onClick).not.toHaveBeenCalled(); + }); + it("draws the pressed region lowered, and the label with it", () => { const atlas = makeAtlas(); const spy = vi.spyOn(SkinDrawer.prototype, "draw"); From 0a785e649578167ac625311d9e8c202d24a84250 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 08:24:06 +0000 Subject: [PATCH 22/43] Changelog: onCancel also covers a called-off bindKey press Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index fa37f15a7..3e60fcdc7 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -8,7 +8,7 @@ - UI: `UISlider`, a horizontal or vertical slider with `min`, `max`, `step`, drag, track clicks and the mouse wheel, an optional caption and value text (`valueFormat`), drawn or from atlas art. `onChange` fires while it moves, `onChangeEnd` once on release, neither when `value` is set from code - UI: `UIPanel` lays its children out (`layout`: `"column"`, `"row"` or `"grid"`, with `padding`, `gap`, `align` and `autoSize`), scrolls them (`scroll`, with the wheel, dragging, momentum and a scrollbar; `scrollTo`, `scrollToChild`), and takes a nine-slice `skin`, a `title` and `draggable`; a draggable panel comes to the front when grabbed - UI: `UITextButton` takes an `onClick` setting, called on a completed click; a `skin` of atlas regions, with pressed art drawn lowered; `pressedColor`; and the names the other widgets use: `width` / `height` for its size, `fontSize` (pixels, also with a bitmap font), `labelFillStyle` and `borderColor`. `resize()` keeps its background and label in step. A font that is not a loaded bitmap font is drawn as a CSS font, where the button used to throw -- UI: `enabled` on every `UIBaseElement`, to turn a widget off; `cancelPress()` and `onCancel()`, to call off a press without a release, as a scrolling `UIPanel` does when a drag on a button turns into a scroll; and `keepsDrag`, for a widget of your own that is dragged inside a scrolling panel +- UI: `enabled` on every `UIBaseElement`, to turn a widget off; `cancelPress()` and `onCancel()`, to call off a press without a release, as a scrolling `UIPanel` does when a drag on a button turns into a scroll (a `UITextButton`'s held `bindKey` called off by the window losing focus, removal or `enabled = false` calls `onCancel()` too); and `keepsDrag`, for a widget of your own that is dragged inside a scrolling panel - UI: a drop shadow under a `UIPanel` or a `ProgressBar`: `shadowColor`, `shadowOffsetX`, `shadowOffsetY` and `shadowBlur`, softened the same on Canvas, WebGL and WebGPU, rounded with the corners, and under a skin too - UI: keyboard and gamepad navigation for a game played without a mouse or a touch screen: once a widget takes the focus with `focus()`, the arrows or the D-pad move it to the nearest widget, Enter, Space or A press it as a click does, and left and right step a slider. A ring shows the focus while the keyboard or a pad is used, and a focused widget in a scrolling panel is scrolled into view. Until a widget has the focus the keys and the pad are left to the game; `blur()` gives them back. `focusable`, `focused`, `onNavigate`, `UIBaseElement.focusOwner` and `UIBaseElement.focusRingColor` - UI: `hoverScale` and `pressScale` on every `UIBaseElement`: a bounce under the pointer or the focus, and while held, easing by itself and scaling only the drawing, not the hit area or the layout From c365da3245e37f25e73b8a280e3e6392071ef610 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 11:22:36 +0000 Subject: [PATCH 23/43] UI: UISpriteElement lets events through by default, like the others Its onClick() and onRelease() returned false, stopping every press and release at the sprite, while every other UI element lets the event carry on to what is under it unless a handler returns false. They now return true. A subclass that relied on the old default returns false itself; the examples already do. onActivateEvent / onDeactivateEvent now call super. Test with real pointer events; docs, skill and changelog updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/CHANGELOG.md | 1 + .../skills/melonjs-ui-and-text/SKILL.md | 6 +++- .../src/renderable/ui/uispriteelement.ts | 17 ++++++---- packages/melonjs/tests/ui-widgets.spec.js | 32 +++++++++++++++++++ packages/melonjs/tests/ui.spec.js | 8 ++--- 5 files changed, 52 insertions(+), 12 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 3e60fcdc7..d999be627 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -18,6 +18,7 @@ ### Changed - UI: `UITextButton` no longer writes the size and alignment it worked out back into the settings object, so one object can make several buttons - UI: `UITextButton#label` is the button's text, a `BitmapText` or, with a CSS font, a `Text` +- UI: `UISpriteElement` lets a press and a release through to what is under it by default, like every other UI element: its `onClick()` and `onRelease()` now return `true`. A subclass that relied on the old default to stop the event returns `false` from them ### Deprecated - UI: `UITextButton#bitmapText`, for `label` diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index 9a0d25523..46f9355aa 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -638,7 +638,7 @@ events, so a button is made by overriding methods rather than by wiring | handler | fires | returns | |---|---|---| | `onClick(event)` | pressed | `false` to stop the event propagating | -| `onRelease(event)` | pressed and released | `false` to stop propagating | +| `onRelease(event)` | the press ends (up, left while held, or cancelled) | `false` to stop propagating | | `onOver(event)` | pointer enters | `false` to stop propagating | | `onOut(event)` | pointer leaves | — | | `onHold()` | pressed and held | — | @@ -658,6 +658,10 @@ class MuteButton extends UISpriteElement { } ``` +Like every UI element, the event carries on to whatever is under the button +unless a handler returns `false`; the defaults let it through. (Before 20.13 +a `UISpriteElement` stopped presses and releases by default.) + The pointer still has to reach it: a renderable with `isKinematic = true` — the default on a plain `Renderable` — is skipped by the broadphase and receives nothing. `UISpriteElement` and `UIBaseElement` clear it for you; anything else diff --git a/packages/melonjs/src/renderable/ui/uispriteelement.ts b/packages/melonjs/src/renderable/ui/uispriteelement.ts index 8464754d0..f6cdad576 100644 --- a/packages/melonjs/src/renderable/ui/uispriteelement.ts +++ b/packages/melonjs/src/renderable/ui/uispriteelement.ts @@ -19,9 +19,10 @@ import Sprite from "./../sprite.js"; * the pointer goes down on it, {@link UISpriteElement#onRelease} when the * press ends, {@link UISpriteElement#onOver} / {@link UISpriteElement#onOut} * for hover (`hover` is `true` in between), and - * {@link UISpriteElement#onHold} with `isHoldable`. Unlike - * {@link UIBaseElement}, `onClick` and `onRelease` stop the event by default - * (they return `false`), so an element below does not get it too. + * {@link UISpriteElement#onHold} with `isHoldable`. As with + * {@link UIBaseElement}, the event carries on to the elements below unless + * a handler returns `false`: return `false` from `onClick` or `onRelease` + * to stop it there. * * It is a lighter element than {@link UIBaseElement}: it has no `enabled`, * no keyboard and gamepad focus, no bounce and no dragging. For those, use @@ -192,12 +193,12 @@ export default class UISpriteElement extends Sprite { * Called when the pointer goes down on the element. Override it in a * subclass. This is the press, not a completed click. * @param _event - the pointer event - * @returns `false`, the default, to stop the event here; `true` to let + * @returns `false` to stop the event here; `true`, the default, to let * elements below get it too */ onClick(_event?: Pointer): boolean { - return false; + return true; } /** @@ -279,12 +280,12 @@ export default class UISpriteElement extends Sprite { * element while held, or the browser cancels the press: it is the end * of a press, not proof of a completed click. * @param _event - the pointer event - * @returns `false`, the default, to stop the event here; `true` to let + * @returns `false` to stop the event here; `true`, the default, to let * elements below get it too */ onRelease(_event?: Pointer): boolean { - return false; + return true; } /** @@ -313,6 +314,7 @@ export default class UISpriteElement extends Sprite { * @internal */ override onActivateEvent(): void { + super.onActivateEvent(); // register pointer events registerPointerEvent("pointerdown", this, (e) => { return this.clicked(e); @@ -345,5 +347,6 @@ export default class UISpriteElement extends Sprite { releasePointerEvent("pointerleave", this); timer.clearTimer(this.holdTimeout); this.holdTimeout = -1; + super.onDeactivateEvent(); } } diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js index fb0ae2230..fc5a8b852 100644 --- a/packages/melonjs/tests/ui-widgets.spec.js +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -14,6 +14,7 @@ import { UICheckbox, UIPanel, UISlider, + UISpriteElement, UITextButton, video, } from "../src/index.js"; @@ -2074,6 +2075,37 @@ describe("UI widgets", () => { }); }); + describe("UISpriteElement", () => { + it("lets a press through like the other UI elements, unless onClick returns false", () => { + const behind = vi.fn(); + const under = box(800, 600); + under.isKinematic = false; + under.depth = -10; + place(under); + input.registerPointerEvent("pointerdown", under, behind); + try { + const atlas = makeAtlas(); + const icon = new UISpriteElement(110, 110, { + image: atlas, + region: "up", + }); + place(icon); + pointer("pointermove", 110, 110); + pointer("pointerdown", 110, 110); + pointer("pointerup", 110, 110); + expect(behind).toHaveBeenCalledTimes(1); + icon.onClick = () => { + return false; + }; + pointer("pointerdown", 110, 110); + pointer("pointerup", 110, 110); + expect(behind).toHaveBeenCalledTimes(1); + } finally { + input.releasePointerEvent("pointerdown", under, behind); + } + }); + }); + describe("focus", () => { it("draws the ring while it shows, not once the mouse moves", () => { const b = place( diff --git a/packages/melonjs/tests/ui.spec.js b/packages/melonjs/tests/ui.spec.js index a4d871f50..8b359fcfe 100644 --- a/packages/melonjs/tests/ui.spec.js +++ b/packages/melonjs/tests/ui.spec.js @@ -228,24 +228,24 @@ describe("UI", () => { }); describe("click callbacks", () => { - it("onClick() should return false by default", () => { + it("onClick() should return true by default, letting the event through", () => { const canvas = Renderer.createCanvas(32, 32); const el = new UISpriteElement(0, 0, { image: canvas, framewidth: 32, frameheight: 32, }); - expect(el.onClick()).toBe(false); + expect(el.onClick()).toBe(true); }); - it("onRelease() should return false by default", () => { + it("onRelease() should return true by default, letting the event through", () => { const canvas = Renderer.createCanvas(32, 32); const el = new UISpriteElement(0, 0, { image: canvas, framewidth: 32, frameheight: 32, }); - expect(el.onRelease()).toBe(false); + expect(el.onRelease()).toBe(true); }); }); }); From 1d86e82f20c160d2f876705d34d022a482e8db76 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 14:08:57 +0000 Subject: [PATCH 24/43] UI: a press stops at a UI element by default UIBaseElement's onClick() and onRelease() now return false, as UISpriteElement's always did (the previous commit's flip of UISpriteElement is undone). A click on a button, a checkbox, a slider or a widget of your own no longer also reaches the game behind it; a handler returns true to let the press through. Hover is unchanged, and a non-blocking UIPanel still lets presses on its background through. Tests with real pointer events for buttons, checkboxes, sprite elements, a handler letting the press through, and a non-blocking panel. Docs, both skills and the changelog follow; two example buttons that returned true now return false. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- .../lineCollision/ExampleLineCollision.tsx | 2 +- .../physicsShapes/ExamplePhysicsShapes.tsx | 2 +- packages/melonjs/CHANGELOG.md | 2 +- .../melonjs/skills/melonjs-input/SKILL.md | 2 +- .../skills/melonjs-ui-and-text/SKILL.md | 20 +++-- .../src/renderable/ui/uibaseelement.ts | 24 ++--- .../src/renderable/ui/uispriteelement.ts | 16 ++-- packages/melonjs/tests/ui-widgets.spec.js | 88 +++++++++++++++---- packages/melonjs/tests/ui.spec.js | 16 ++-- 9 files changed, 118 insertions(+), 54 deletions(-) diff --git a/packages/examples/src/examples/lineCollision/ExampleLineCollision.tsx b/packages/examples/src/examples/lineCollision/ExampleLineCollision.tsx index 38a7ef7c2..39bfe043c 100644 --- a/packages/examples/src/examples/lineCollision/ExampleLineCollision.tsx +++ b/packages/examples/src/examples/lineCollision/ExampleLineCollision.tsx @@ -173,7 +173,7 @@ class BackendButton extends UIBaseElement { override onClick() { globalThis.location.search = `?physics=${this.target}`; - return true; + return false; } override draw(renderer: WebGLRenderer | CanvasRenderer) { diff --git a/packages/examples/src/examples/physicsShapes/ExamplePhysicsShapes.tsx b/packages/examples/src/examples/physicsShapes/ExamplePhysicsShapes.tsx index 0a47e0044..b7e1c78a4 100644 --- a/packages/examples/src/examples/physicsShapes/ExamplePhysicsShapes.tsx +++ b/packages/examples/src/examples/physicsShapes/ExamplePhysicsShapes.tsx @@ -242,7 +242,7 @@ class BackendButton extends UIBaseElement { // the adapter is chosen when the Application is constructed, so // switching one means booting the example again globalThis.location.search = `?physics=${this.target}`; - return true; + return false; } override draw(renderer: WebGLRenderer | CanvasRenderer) { diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index d999be627..9e6c62d91 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -18,7 +18,7 @@ ### Changed - UI: `UITextButton` no longer writes the size and alignment it worked out back into the settings object, so one object can make several buttons - UI: `UITextButton#label` is the button's text, a `BitmapText` or, with a CSS font, a `Text` -- UI: `UISpriteElement` lets a press and a release through to what is under it by default, like every other UI element: its `onClick()` and `onRelease()` now return `true`. A subclass that relied on the old default to stop the event returns `false` from them +- UI: a press stops at a UI element by default: `UIBaseElement`'s `onClick()` and `onRelease()` now return `false`, as `UISpriteElement`'s already did, so a click on a button, checkbox or widget of your own no longer also reaches the game behind it. A subclass that relied on presses passing through returns `true` from them. Hover is unchanged, and a non-blocking `UIPanel` still lets presses on its background through ### Deprecated - UI: `UITextButton#bitmapText`, for `label` diff --git a/packages/melonjs/skills/melonjs-input/SKILL.md b/packages/melonjs/skills/melonjs-input/SKILL.md index a4cfb4331..c7a5f43d9 100644 --- a/packages/melonjs/skills/melonjs-input/SKILL.md +++ b/packages/melonjs/skills/melonjs-input/SKILL.md @@ -315,7 +315,7 @@ input.triggerKeyEvent(input.KEY.LEFT, false); // release | `no action defined for keycode N` | `bindGamepad` called before `bindKey` for that keycode | | stick has to be pushed well past its `threshold` before it registers | `threshold` is ADDED to the deadzone, not used instead of it | | one half of a stick does nothing | each half is a separate `bindGamepad`, chosen by the sign of `threshold` | -| a widget under an overlapping panel still reacts | the panel consumes nothing — return `false` from its `onClick` and `onOver` | +| a widget under an overlapping panel still lights up | the panel consumes no hover: return `false` from its `onOver` (presses already stop at UI elements; `UIPanel` with `blocking` does both) | | a widget stays highlighted after the pointer moves onto a panel over it | the panel consumes no `pointermove`, so nothing takes the pointer off the widget | | a child's big `z` does not put it in front of another container | `pos.z` is container-local — use `ancestor.moveToTop()` on the container | | every click in the game stops working | a handler on `app.viewport` returning `false`, which is asked first | diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index 46f9355aa..a66974117 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -658,9 +658,13 @@ class MuteButton extends UISpriteElement { } ``` -Like every UI element, the event carries on to whatever is under the button -unless a handler returns `false`; the defaults let it through. (Before 20.13 -a `UISpriteElement` stopped presses and releases by default.) +**A press stops at a UI element by default.** `onClick` and `onRelease` +return `false` unless you override them, on `UISpriteElement` and on every +`UIBaseElement` (buttons, checkboxes, sliders, your own widgets): a click on +a HUD button does not also click the world behind it. Return `true` from +them to let the press through as well. Hover is different: `onOver` lets it +through unless it returns `false`. A non-blocking `UIPanel` lets a press on +its background through, as a frame that only decorates should. The pointer still has to reach it: a renderable with `isKinematic = true` — the default on a plain `Renderable` — is skipped by the broadphase and receives @@ -1075,14 +1079,16 @@ sprite. ### An opaque panel has to say so The hit test asks the topmost renderable first and then keeps walking down, so -consuming is what stops a widget underneath answering too. A panel that can -overlap another one therefore wants both: +consuming is what stops a widget underneath answering too. A press is consumed +by default on a UI element, but hover is not, so a panel of your own that can +overlap another one also stops hover (`UIPanel` does all of this with +`blocking`): ```js class Panel extends UIBaseElement { onClick() { this.ancestor.moveToTop(this); // and bring it to the front - return false; // consumed + return false; // consumed (the default) } onOver() { return false; // the frame the pointer crosses in @@ -1138,6 +1144,8 @@ City and Instanced Forest examples park a floating sky at `-10000` or `100000`. | loading saved settings plays the music, saves again or fires game logic | the game called its own change handler while restoring | set `checkbox.checked` / `slider.value`, which never call `onChange`, then apply the setting once yourself | | `onChange` never fires when settings are restored | by design: only user input calls it | apply the restored value yourself | | a button in a scrolling list fires when the user only meant to scroll | the work is in the `onClick()` METHOD, which runs on the press | use the `onClick` SETTING, called on a completed click; a scroll cancels the press | +| clicking a HUD button also clicks the world behind it (before 20.13) | a `UIBaseElement` let presses through by default | update: presses now stop at UI elements; a handler that returns `true` still lets them through | +| a custom widget no longer lets clicks reach what is behind it (since 20.13) | presses stop at UI elements by default | return `true` from its `onClick` / `onRelease` | | a button scrolled out of a hand-made list still gets clicks | its container does not clip, so the pointer still finds it where it moved to | use `UIPanel`'s `scroll`, or set `clipping` on the container: since 20.13 the pointer skips children outside a clipping container | | a layout ignores a label that changed size | layout runs on add, remove and resize only | call `panel.relayout()` | | the arrow keys or the pad do nothing in a menu | no widget has the focus: nothing listens until one does | call `firstWidget.focus()` when the menu opens | diff --git a/packages/melonjs/src/renderable/ui/uibaseelement.ts b/packages/melonjs/src/renderable/ui/uibaseelement.ts index aed6f2a82..e8b22a573 100644 --- a/packages/melonjs/src/renderable/ui/uibaseelement.ts +++ b/packages/melonjs/src/renderable/ui/uibaseelement.ts @@ -158,9 +158,11 @@ function offerPressToScroller(element: UIBaseElement, event: Pointer) { * release (see {@link UIBaseElement#cancelPress}). * * Elements under the pointer are asked topmost first, and the event carries - * on to the ones below until a handler returns `false`. So an element is NOT - * opaque by default: a widget it covers still gets clicks and hover. Return - * `false` from `onClick`, `onRelease` or `onOver` to stop the event there. + * on to the ones below until a handler returns `false`. `onClick` and + * `onRelease` return `false` by default, so a press on a UI element stops + * there: a button over the game does not also click the world behind it. + * Return `true` from them to let the press through as well. Hover is not + * stopped by default: return `false` from `onOver` for that. * * ## Turning it off * Set {@link UIBaseElement#enabled} to `false`: it stops answering the @@ -213,7 +215,7 @@ function offerPressToScroller(element: UIBaseElement, event: Pointer) { * } * onRelease() { * console.log("card picked"); - * return false; // the element below does not get it too + * return false; // stop the event here (the default) * } * } * @@ -256,7 +258,7 @@ function offerPressToScroller(element: UIBaseElement, event: Pointer) { * // the mouse still works as before * play.focus(); * @example - * // a panel that is opaque to the pointer and comes to the front when picked + * // a panel that is opaque to hover too, and comes to the front when picked * // (UIPanel does this with its `blocking` setting) * import { UIBaseElement, input } from "melonjs"; * @@ -769,12 +771,12 @@ export default class UIBaseElement extends Container { * {@link UIBaseElement#onRelease} for the end of it. * @param _event - the pointer event, or `undefined` from the keyboard or * the pad - * @returns `false` to stop the event here, so elements below do not get - * it too + * @returns `false`, the default, to stop the event here; `true` to let + * elements below get it too */ onClick(_event?: Pointer): boolean { - return true; + return false; } /** @@ -898,12 +900,12 @@ export default class UIBaseElement extends Container { * not come here. * @param _event - the pointer event, or `undefined` from the keyboard or * the pad - * @returns `false` to stop the event here, so elements below do not get - * it too + * @returns `false`, the default, to stop the event here; `true` to let + * elements below get it too */ onRelease(_event?: Pointer): boolean { - return true; + return false; } /** diff --git a/packages/melonjs/src/renderable/ui/uispriteelement.ts b/packages/melonjs/src/renderable/ui/uispriteelement.ts index f6cdad576..d19c448e0 100644 --- a/packages/melonjs/src/renderable/ui/uispriteelement.ts +++ b/packages/melonjs/src/renderable/ui/uispriteelement.ts @@ -19,10 +19,10 @@ import Sprite from "./../sprite.js"; * the pointer goes down on it, {@link UISpriteElement#onRelease} when the * press ends, {@link UISpriteElement#onOver} / {@link UISpriteElement#onOut} * for hover (`hover` is `true` in between), and - * {@link UISpriteElement#onHold} with `isHoldable`. As with - * {@link UIBaseElement}, the event carries on to the elements below unless - * a handler returns `false`: return `false` from `onClick` or `onRelease` - * to stop it there. + * {@link UISpriteElement#onHold} with `isHoldable`. As with every UI + * element, `onClick` and `onRelease` return `false` by default, so a press + * stops here and an element below does not get it too; return `true` from + * them to let it through. * * It is a lighter element than {@link UIBaseElement}: it has no `enabled`, * no keyboard and gamepad focus, no bounce and no dragging. For those, use @@ -193,12 +193,12 @@ export default class UISpriteElement extends Sprite { * Called when the pointer goes down on the element. Override it in a * subclass. This is the press, not a completed click. * @param _event - the pointer event - * @returns `false` to stop the event here; `true`, the default, to let + * @returns `false`, the default, to stop the event here; `true` to let * elements below get it too */ onClick(_event?: Pointer): boolean { - return true; + return false; } /** @@ -280,12 +280,12 @@ export default class UISpriteElement extends Sprite { * element while held, or the browser cancels the press: it is the end * of a press, not proof of a completed click. * @param _event - the pointer event - * @returns `false` to stop the event here; `true`, the default, to let + * @returns `false`, the default, to stop the event here; `true` to let * elements below get it too */ onRelease(_event?: Pointer): boolean { - return true; + return false; } /** diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js index fc5a8b852..517d873f7 100644 --- a/packages/melonjs/tests/ui-widgets.spec.js +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -2075,33 +2075,87 @@ describe("UI widgets", () => { }); }); - describe("UISpriteElement", () => { - it("lets a press through like the other UI elements, unless onClick returns false", () => { + describe("a press stops at a UI element", () => { + /** a wide renderable under everything, listening for presses */ + const listenBehind = () => { const behind = vi.fn(); const under = box(800, 600); under.isKinematic = false; under.depth = -10; place(under); input.registerPointerEvent("pointerdown", under, behind); + input.registerPointerEvent("pointerup", under, behind); + return { behind, under }; + }; + const release = (under) => { + input.releasePointerEvent("pointerdown", under); + input.releasePointerEvent("pointerup", under); + }; + const tap = (x, y) => { + pointer("pointermove", x, y); + pointer("pointerdown", x, y); + pointer("pointerup", x, y); + }; + + it("a button, a checkbox and a UISpriteElement keep the press from what is behind", () => { + const { behind, under } = listenBehind(); try { - const atlas = makeAtlas(); - const icon = new UISpriteElement(110, 110, { - image: atlas, - region: "up", - }); - place(icon); - pointer("pointermove", 110, 110); - pointer("pointerdown", 110, 110); - pointer("pointerup", 110, 110); - expect(behind).toHaveBeenCalledTimes(1); + const onClick = vi.fn(); + place( + new UITextButton(100, 100, { + text: "OK", + width: 80, + height: 40, + onClick, + }), + ); + const cb = place(new UICheckbox(300, 100, { label: "x" })); + place( + new UISpriteElement(510, 110, { image: makeAtlas(), region: "up" }), + ); + tap(140, 120); + tap(310, 110); + tap(510, 110); + expect(onClick).toHaveBeenCalledTimes(1); + expect(cb.checked).toBe(true); + expect(behind).not.toHaveBeenCalled(); + // with no element over it, the game gets the press + tap(700, 500); + expect(behind).toHaveBeenCalledTimes(2); + } finally { + release(under); + } + }); + + it("a handler returning true lets the press through", () => { + const { behind, under } = listenBehind(); + try { + const icon = place( + new UISpriteElement(110, 110, { image: makeAtlas(), region: "up" }), + ); icon.onClick = () => { - return false; + return true; }; - pointer("pointerdown", 110, 110); - pointer("pointerup", 110, 110); - expect(behind).toHaveBeenCalledTimes(1); + icon.onRelease = () => { + return true; + }; + tap(110, 110); + expect(behind).toHaveBeenCalledTimes(2); + } finally { + release(under); + } + }); + + it("a non-blocking panel lets a press on its background through", () => { + const { behind, under } = listenBehind(); + try { + place( + new UIPanel(100, 100, { width: 200, height: 100, blocking: false }), + ); + tap(150, 150); + expect(behind).toHaveBeenCalledTimes(2); } finally { - input.releasePointerEvent("pointerdown", under, behind); + release(under); } }); }); diff --git a/packages/melonjs/tests/ui.spec.js b/packages/melonjs/tests/ui.spec.js index 8b359fcfe..fc906a93c 100644 --- a/packages/melonjs/tests/ui.spec.js +++ b/packages/melonjs/tests/ui.spec.js @@ -134,14 +134,14 @@ describe("UI", () => { expect(el.released).toBe(true); }); - it("onClick() should return true by default", () => { + it("onClick() should return false by default, stopping the press", () => { const el = new UIBaseElement(0, 0, 100, 50); - expect(el.onClick()).toBe(true); + expect(el.onClick()).toBe(false); }); - it("onRelease() should return true by default", () => { + it("onRelease() should return false by default, stopping the release", () => { const el = new UIBaseElement(0, 0, 100, 50); - expect(el.onRelease()).toBe(true); + expect(el.onRelease()).toBe(false); }); }); }); @@ -228,24 +228,24 @@ describe("UI", () => { }); describe("click callbacks", () => { - it("onClick() should return true by default, letting the event through", () => { + it("onClick() should return false by default", () => { const canvas = Renderer.createCanvas(32, 32); const el = new UISpriteElement(0, 0, { image: canvas, framewidth: 32, frameheight: 32, }); - expect(el.onClick()).toBe(true); + expect(el.onClick()).toBe(false); }); - it("onRelease() should return true by default, letting the event through", () => { + it("onRelease() should return false by default", () => { const canvas = Renderer.createCanvas(32, 32); const el = new UISpriteElement(0, 0, { image: canvas, framewidth: 32, frameheight: 32, }); - expect(el.onRelease()).toBe(true); + expect(el.onRelease()).toBe(false); }); }); }); From 6d971f4044ef11ddce751123361cad4e20ac9c5f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 02:22:15 +0000 Subject: [PATCH 25/43] UI: UISpriteElement is a UIBaseElement holding a Sprite It was a Sprite carrying its own copy of the pointer handling, which had drifted from UIBaseElement's. It now extends UIBaseElement and holds its image as `sprite`, so it shares the one implementation and gains what the other widgets have: enabled (drawn at half opacity when off), cancelPress / onCancel, hoverScale / pressScale, dragging and keyboard / gamepad focus. Kept for existing code: (x, y) is still where the image's anchor point goes, and setRegion (which also resizes the element), addAnimation, setCurrentAnimation and isCurrentAnimation stay on the element. Other Sprite members move to `sprite`; UISpriteElementSettings is exported. The matter platformer's touch controls follow: the button passes its anchorPoint in the settings, and the joypad's knob is a child instead of being drawn by hand. Tests cover placement, clicks, disabled drawing, setRegion, the bounce, cancel and focus; docs, skill and changelog updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- .../platformer-matter/entities/controls.ts | 24 +- packages/melonjs/CHANGELOG.md | 1 + .../skills/melonjs-ui-and-text/SKILL.md | 29 +- packages/melonjs/src/index.ts | 1 + .../src/renderable/ui/uibaseelement.ts | 2 +- .../src/renderable/ui/uispriteelement.ts | 399 ++++++------------ packages/melonjs/tests/ui-widgets.spec.js | 102 +++++ 7 files changed, 274 insertions(+), 284 deletions(-) diff --git a/packages/examples/src/examples/platformer-matter/entities/controls.ts b/packages/examples/src/examples/platformer-matter/entities/controls.ts index 6ceb35bcc..7a1d5fa2c 100644 --- a/packages/examples/src/examples/platformer-matter/entities/controls.ts +++ b/packages/examples/src/examples/platformer-matter/entities/controls.ts @@ -25,9 +25,10 @@ class Button extends UISpriteElement { super(x, y, { image: gameState.texture, region: "shadedDark36.png", + // placed by its top left corner + anchorPoint: new Vector2d(0, 0), }); this.setOpacity(0.25); - this.anchorPoint.set(0, 0); } /** @@ -64,8 +65,8 @@ class Joypad extends UISpriteElement { anchorPoint: new Vector2d(0, 0), }); - // mobile part of the joypad - this.pad = new Sprite(x, y, { + // mobile part of the joypad, drawn over the back + this.pad = new Sprite(0, 0, { image: gameState.texture, region: "shadedDark01.png", anchorPoint: new Vector2d(0, 0), @@ -76,6 +77,8 @@ class Joypad extends UISpriteElement { this.width / 2 - this.pad.width / 2, this.height / 2 - this.pad.height / 2, ); + this.pad.pos.setV(this.relative); + this.addChild(this.pad); // offset by which the joypad move when pressed/moved this.joypad_offset = new Vector2d(); @@ -96,19 +99,20 @@ class Joypad extends UISpriteElement { input.registerPointerEvent( "pointermove", this, - this.pointerMove.bind(this), + this.onPointerMove.bind(this), ); } onDestroyEvent() { // release the registered pointer event input.releasePointerEvent("pointermove", this); + super.onDestroyEvent(); } /** * pointermove function */ - pointerMove(event) { + onPointerMove(event) { if (this.released === false) { const x = event.gameScreenX + event.width / 2; const y = event.gameScreenY + event.height / 2; @@ -188,13 +192,11 @@ class Joypad extends UISpriteElement { } /** - * extend the draw function + * move the pad with the joypad offset */ - draw(renderer) { - // call the super constructor - super.draw(renderer); - this.pad.pos.setV(this.pos).add(this.relative).add(this.joypad_offset); - this.pad.draw(renderer); + update(dt) { + this.pad.pos.setV(this.relative).add(this.joypad_offset); + return super.update(dt); } } diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 9e6c62d91..039babcd2 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -18,6 +18,7 @@ ### Changed - UI: `UITextButton` no longer writes the size and alignment it worked out back into the settings object, so one object can make several buttons - UI: `UITextButton#label` is the button's text, a `BitmapText` or, with a CSS font, a `Text` +- UI: `UISpriteElement` is now a `UIBaseElement` holding a `Sprite` (`sprite`), instead of a `Sprite` with its own copy of the pointer handling, so it has everything the other widgets have: `enabled` (drawn at half opacity when off), `cancelPress()` / `onCancel()`, `hoverScale` / `pressScale`, dragging and keyboard / gamepad focus. `(x, y)` is still where the image's anchor point goes, so existing layouts do not move, and `setRegion`, `addAnimation`, `setCurrentAnimation` and `isCurrentAnimation` are kept on the element. Other `Sprite` members are now on `element.sprite`; `pos` is the element's top left corner; setting `anchorPoint` after construction no longer moves the image (pass it in the settings); and a second sprite drawn in a `draw()` override becomes a child added with `addChild` - UI: a press stops at a UI element by default: `UIBaseElement`'s `onClick()` and `onRelease()` now return `false`, as `UISpriteElement`'s already did, so a click on a button, checkbox or widget of your own no longer also reaches the game behind it. A subclass that relied on presses passing through returns `true` from them. Hover is unchanged, and a non-blocking `UIPanel` still lets presses on its background through ### Deprecated diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index a66974117..1ab0f77f8 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -295,7 +295,7 @@ There is no `UIContainer` class. Use `Container` for a bare grouping, | you want | use | notes | |---|---|---| | a button with a label | `UITextButton` | drawn, or skinned from an atlas; `onClick` setting | -| an image-only button or icon | `UISpriteElement` | a `Sprite` that takes clicks; override `onClick` | +| an image-only button or icon | `UISpriteElement` | a `UIBaseElement` holding a `Sprite` (`sprite`); override `onClick` | | an on/off option, or one of several | `UICheckbox` | a radio button with `group` | | a value in a range: volume, sensitivity | `UISlider` | horizontal or vertical | | health, shield, cooldown, loading | `ProgressBar` | not clickable; `value` redraws | @@ -631,9 +631,11 @@ 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 -events, so a button is made by overriding methods rather than by wiring -`registerPointerEvent`: +Every UI element already registers itself for pointer events, so a button is +made by overriding methods rather than by wiring `registerPointerEvent`. For +an image-only button that is `UISpriteElement`: a `UIBaseElement` holding a +`Sprite`, placed like a sprite (`(x, y)` is where its anchor goes, the centre +by default): | handler | fires | returns | |---|---|---| @@ -653,14 +655,23 @@ class MuteButton extends UISpriteElement { onOut() { this.setOpacity(0.5); } onClick() { audio.muteAll(); - return false; // consumed — do not fall through to the world + this.setRegion(atlas.getRegion("speaker_off.png")); // or this.sprite.setRegion + return false; // consumed (the default) } } ``` +The image is `button.sprite`: animations, tint and regions live there +(`setRegion`, `addAnimation`, `setCurrentAnimation` and `isCurrentAnimation` +are also on the element as shortcuts). As a `UIBaseElement` it has +`enabled` (drawn at half opacity when off), `cancelPress()` / `onCancel()`, +the bounce, and focus once `focusable` is set. `pos` is the element's top +left corner, and a second sprite of your own, such as a joystick's knob, is +a child added with `addChild`, not drawn by hand in `draw()`. + **A press stops at a UI element by default.** `onClick` and `onRelease` -return `false` unless you override them, on `UISpriteElement` and on every -`UIBaseElement` (buttons, checkboxes, sliders, your own widgets): a click on +return `false` unless you override them, on every `UIBaseElement` (buttons, +checkboxes, sliders, sprite buttons, your own widgets): a click on a HUD button does not also click the world behind it. Return `true` from them to let the press through as well. Hover is different: `onOver` lets it through unless it returns `false`. A non-blocking `UIPanel` lets a press on @@ -668,7 +679,7 @@ its background through, as a frame that only decorates should. The pointer still has to reach it: a renderable with `isKinematic = true` — the default on a plain `Renderable` — is skipped by the broadphase and receives -nothing. `UISpriteElement` and `UIBaseElement` clear it for you; anything else +nothing. Every `UIBaseElement` clears it for you; anything else you make clickable has to clear it itself. ### A button that does one thing: `UITextButton` @@ -727,7 +738,7 @@ pointer and the key. Every `UIBaseElement`, buttons, checkboxes, sliders, panels and your own, can grow a little under the pointer or with the focus, and shrink while held -(`UISpriteElement` and `ProgressBar` are not `UIBaseElement`s, and do not): +(`ProgressBar` is not a `UIBaseElement`, and does not): ```js new UITextButton(0, 0, { text: "Play", hoverScale: 1.05, pressScale: 0.92 }); diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index 535a96656..ca6b24fd6 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -222,6 +222,7 @@ export type { UISliderSettings, UISliderSkin, } from "./renderable/ui/uislider.js"; +export type { UISpriteElementSettings } from "./renderable/ui/uispriteelement.js"; export type { UITextButtonSettings, UITextButtonSkin, diff --git a/packages/melonjs/src/renderable/ui/uibaseelement.ts b/packages/melonjs/src/renderable/ui/uibaseelement.ts index e8b22a573..765378108 100644 --- a/packages/melonjs/src/renderable/ui/uibaseelement.ts +++ b/packages/melonjs/src/renderable/ui/uibaseelement.ts @@ -138,7 +138,7 @@ function offerPressToScroller(element: UIBaseElement, event: Pointer) { /** * A clickable, draggable container: the base every UI widget builds on * ({@link UITextButton}, {@link UICheckbox}, {@link UISlider}, - * {@link UIPanel}). Extend it to make a widget of your own; it can hold + * {@link UIPanel}, {@link UISpriteElement}). Extend it to make a widget of your own; it can hold * other renderables as children. * * It uses screen coordinates (`floating` is `true`), so it stays put when diff --git a/packages/melonjs/src/renderable/ui/uispriteelement.ts b/packages/melonjs/src/renderable/ui/uispriteelement.ts index d19c448e0..c3e19b2b5 100644 --- a/packages/melonjs/src/renderable/ui/uispriteelement.ts +++ b/packages/melonjs/src/renderable/ui/uispriteelement.ts @@ -1,33 +1,46 @@ -import { - registerPointerEvent, - releasePointerEvent, -} from "./../../input/input.ts"; -import type Pointer from "./../../input/pointer.ts"; -import timer from "./../../system/timer.ts"; +import type Camera2d from "../../camera/camera2d.ts"; +import type Renderer from "../../video/renderer.js"; import Sprite from "./../sprite.js"; +import type { UIJuiceSettings } from "./uibaseelement.ts"; +import UIBaseElement from "./uibaseelement.ts"; /** - * A {@link Sprite} that takes clicks: use it for an image-only button or a - * clickable icon, such as a pause or a sound toggle, with no label. + * Settings for a {@link UISpriteElement}: the {@link Sprite} settings for its + * image, plus the bounce shared by every UI element. + * @category UI + */ +export type UISpriteElementSettings = { + /** + * The image: a loader key, an image, or a {@link TextureAtlas} (with + * `region`). Every other {@link Sprite} setting is accepted too: + * `region`, `framewidth`, `frameheight`, `anchorPoint`, `tint`... + */ + image: any; + /** + * Whether the element starts turned on. + * @default true + */ + enabled?: boolean; + [key: string]: any; +} & UIJuiceSettings; + +/** + * An image that takes clicks: use it for an image-only button or a clickable + * icon, such as a pause or a sound toggle, with no label. * - * It takes the same settings as a {@link Sprite}: a single image, a frame - * of a spritesheet, or a region of a {@link TextureAtlas}. Like a sprite it - * is centred on its position by default (`anchorPoint` 0.5, 0.5), and the - * pointer hits its bounds. It uses screen coordinates (`floating` is `true`). + * It is a {@link UIBaseElement} holding a {@link Sprite}, so it behaves like + * every other UI element: the same pointer callbacks, `enabled`, + * `cancelPress()` / `onCancel()`, dragging, the bounce (`hoverScale`, + * `pressScale`), and keyboard and gamepad focus once `focusable` is set. A + * press stops here by default, so what is under it does not get it too. * - * Override the callbacks in a subclass: {@link UISpriteElement#onClick} when - * the pointer goes down on it, {@link UISpriteElement#onRelease} when the - * press ends, {@link UISpriteElement#onOver} / {@link UISpriteElement#onOut} - * for hover (`hover` is `true` in between), and - * {@link UISpriteElement#onHold} with `isHoldable`. As with every UI - * element, `onClick` and `onRelease` return `false` by default, so a press - * stops here and an element below does not get it too; return `true` from - * them to let it through. + * The image is {@link UISpriteElement#sprite}: change its region, add and + * play animations or tint it there. It takes the {@link Sprite} settings, and + * as a sprite does, `(x, y)` is where its anchor point goes, the centre by + * default (`anchorPoint` 0.5, 0.5). The element covers the image, so + * `pos` is its top left corner and `width` / `height` its size. * - * It is a lighter element than {@link UIBaseElement}: it has no `enabled`, - * no keyboard and gamepad focus, no bounce and no dragging. For those, use - * a {@link UITextButton} with a `skin` (its `text` can be empty) or a - * {@link UIBaseElement} subclass holding a sprite. + * A disabled element (`enabled = false`) is drawn at half opacity. * @category UI * @example * // a sound toggle icon from an atlas, swapping its region on each click @@ -35,7 +48,7 @@ import Sprite from "./../sprite.js"; * * class SoundIcon extends UISpriteElement { * constructor(x, y) { - * super(x, y, { image: atlas, region: "sound_on" }); + * super(x, y, { image: atlas, region: "sound_on", hoverScale: 1.1 }); * this.muted = false; * } * onClick() { @@ -45,7 +58,7 @@ import Sprite from "./../sprite.js"; * } else { * audio.unmuteAll(); * } - * this.setRegion(atlas.getRegion(this.muted ? "sound_off" : "sound_on")); + * this.sprite.setRegion(atlas.getRegion(this.muted ? "sound_off" : "sound_on")); * return false; * } * } @@ -58,79 +71,45 @@ import Sprite from "./../sprite.js"; * class PauseButton extends UISpriteElement { * constructor(x, y) { * super(x, y, { image: "pause_button", framewidth: 64, frameheight: 64 }); - * this.addAnimation("idle", [0]); - * this.addAnimation("over", [1]); - * this.setCurrentAnimation("idle"); + * this.sprite.addAnimation("idle", [0]); + * this.sprite.addAnimation("over", [1]); + * this.sprite.setCurrentAnimation("idle"); * } * onOver() { - * this.setCurrentAnimation("over"); + * this.sprite.setCurrentAnimation("over"); * } * onOut() { - * this.setCurrentAnimation("idle"); + * this.sprite.setCurrentAnimation("idle"); * } * onRelease() { * state.pause(); * return false; * } * } + * @example + * // placed by its top left corner, and reachable with the arrow keys + * const icon = new UISpriteElement(16, 16, { + * image: atlas, + * region: "gear", + * anchorPoint: { x: 0, y: 0 }, + * }); + * icon.focusable = true; */ -export default class UISpriteElement extends Sprite { - /** - * Whether the element uses screen coordinates, so it stays put when the - * camera moves. Set it to `false` when adding the element to a floating - * parent, such as a {@link UIPanel}. - * @see Renderable.floating - * @default true - */ - override floating = true; - - /** - * Whether the pointer can press the element. Set it to `false` to turn - * the element off (it does not change the look). - * @default true - */ - isClickable: boolean; - - /** - * How long a press must be held before {@link UISpriteElement#onHold} - * runs, in milliseconds. Needs `isHoldable`. - * @default 250 - */ - holdThreshold: number; - +export default class UISpriteElement extends UIBaseElement { /** - * Whether a press held for `holdThreshold` ms calls - * {@link UISpriteElement#onHold}. - * @default false - */ - isHoldable: boolean; - - /** - * `true` while the pointer is over the element - * @default false - */ - hover: boolean; - - /** - * the tap-and-hold timer of a press in progress, or -1 - * @ignore - */ - holdTimeout: number; - - /** - * `false` while the element is held down, `true` otherwise - * @default true + * The image, drawn to fill the element. Change its region, animations + * or tint here. + * @example + * icon.sprite.setRegion(atlas.getRegion("sound_off")); */ - released: boolean; + readonly sprite: Sprite; /** - * @param x - the x position of the element (its centre by default), in - * screen pixels - * @param y - the y position of the element (its centre by default), in - * screen pixels - * @param settings - the {@link Sprite} settings - * @param settings.image - the image: a loader key, an image, or a - * {@link TextureAtlas} (with `region`) + * @param x - where the image's anchor point goes (its centre by + * default), in screen pixels + * @param y - where the image's anchor point goes (its centre by + * default), in screen pixels + * @param settings - the {@link Sprite} settings, and the bounce * @example * // a button image loaded as "button", logging each press * class MyButton extends UISpriteElement { @@ -145,208 +124,102 @@ export default class UISpriteElement extends Sprite { * * app.world.addChild(new MyButton(100, 50)); */ - constructor( - x: number, - y: number, - settings: { image: any; [key: string]: any }, - ) { - // call the parent constructor - super(x, y, settings); - - this.isClickable = true; - this.holdThreshold = 250; - this.isHoldable = false; - this.hover = false; - this.holdTimeout = -1; - this.released = true; - - // enable event detection - this.isKinematic = false; - } - - /** - * function callback for the pointerdown event - * @ignore - * @internal - */ - clicked(event: Pointer): boolean | void { - // Check if left mouse button is pressed - if (event.button === 0 && this.isClickable) { - this.isDirty = true; - this.released = false; - if (this.isHoldable) { - timer.clearTimer(this.holdTimeout); - this.holdTimeout = timer.setTimeout( - () => { - this.hold(); - }, - this.holdThreshold, - false, - ); - this.released = false; - } - return this.onClick(event); + constructor(x: number, y: number, settings: UISpriteElementSettings) { + // built first, for its size and anchor + const sprite = new Sprite(0, 0, settings); + const ax = sprite.anchorPoint.x; + const ay = sprite.anchorPoint.y; + super( + x - sprite.width * ax, + y - sprite.height * ay, + sprite.width, + sprite.height, + ); + // the sprite keeps its anchor, placed so it covers the element + sprite.pos.set(sprite.width * ax, sprite.height * ay); + this.sprite = sprite; + this.addChild(sprite); + + this.hoverScale = settings.hoverScale ?? 1; + this.pressScale = settings.pressScale ?? 1; + if (settings.enabled === false) { + this.enabled = false; } } /** - * Called when the pointer goes down on the element. Override it in a - * subclass. This is the press, not a completed click. - * @param _event - the pointer event - * @returns `false`, the default, to stop the event here; `true` to let - * elements below get it too - */ - - onClick(_event?: Pointer): boolean { - return false; - } - - /** - * function callback for the pointerEnter event - * @ignore - * @internal - */ - enter(event: Pointer): boolean | void { - this.hover = true; + * Show another region of the texture: a shortcut for + * `sprite.setRegion(region)`, that also resizes the element to it. + * @param region - a region from {@link TextureAtlas#getRegion} + * @returns this element + * @example + * icon.setRegion(atlas.getRegion("sound_off")); + */ + setRegion(region: object): this { + const sprite = this.sprite; + sprite.setRegion(region); + if (sprite.width !== this.width || sprite.height !== this.height) { + const ax = sprite.anchorPoint.x; + const ay = sprite.anchorPoint.y; + this.resize(sprite.width, sprite.height); + sprite.pos.set(sprite.width * ax, sprite.height * ay); + } this.isDirty = true; - return this.onOver(event); + return this; } /** - * Called when the pointer comes over the element. Override it in a - * subclass. - * - * Fires once, on the frame the pointer crosses into the element. To stay - * opaque to hover for as long as the pointer rests on it, also register a - * `"pointermove"` callback through {@link input.registerPointerEvent} - * that returns `false`. - * @param _event - the pointer event - * @returns `false` to stop the event here, so an element covered by this - * one does not also light up + * A shortcut for `sprite.addAnimation(...)`: see + * {@link Sprite#addAnimation}. + * @param name - the animation name + * @param index - the frames, by index or region name + * @param animationspeed - the time between frames, in milliseconds + * @returns the number of frames added */ - - onOver(_event?: Pointer): boolean | void { - // to be extended + addAnimation( + name: string, + index: number[] | string[] | object[], + animationspeed?: number, + ): number { + return this.sprite.addAnimation(name, index, animationspeed); } /** - * function callback for the pointerLeave event - * @ignore - * @internal + * A shortcut for `sprite.setCurrentAnimation(...)`: see + * {@link Sprite#setCurrentAnimation}. + * @param name - the animation to play + * @param resetAnim - what to do at the end, see {@link Sprite#setCurrentAnimation} + * @returns this element */ - leave(event: Pointer): void { - this.hover = false; + setCurrentAnimation(name: string, resetAnim?: any): this { + this.sprite.setCurrentAnimation(name, resetAnim); this.isDirty = true; - this.release(event); - this.onOut(event); - } - - /** - * Called when the pointer leaves the element. Override it in a subclass. - * - * Also fires when the pointer is still inside but something drawn above - * has stopped the move, since the pointer is then over that instead. A - * press in progress ends first, through `onRelease`. - * - * Unlike {@link UISpriteElement#onOver} it cannot stop the event: an - * element that skipped its own leave would stay lit after the pointer - * had gone. - * @param _event - the pointer event - */ - - onOut(_event?: Pointer): void { - // to be extended - } - - /** - * function callback for the pointerup event - * @ignore - * @internal - */ - release(event: Pointer): boolean | void { - if (!this.released) { - this.released = true; - this.isDirty = true; - timer.clearTimer(this.holdTimeout); - this.holdTimeout = -1; - return this.onRelease(event); - } + return this; } /** - * Called when a press ends. Override it in a subclass. - * - * It runs when the pointer comes up, but also when the pointer leaves the - * element while held, or the browser cancels the press: it is the end - * of a press, not proof of a completed click. - * @param _event - the pointer event - * @returns `false`, the default, to stop the event here; `true` to let - * elements below get it too + * A shortcut for `sprite.isCurrentAnimation(name)`. + * @param name - the animation name + * @returns whether it is the one playing */ - - onRelease(_event?: Pointer): boolean { - return false; + isCurrentAnimation(name: string): boolean { + return this.sprite.isCurrentAnimation(name); } /** - * function callback for the tap and hold timer event + * draw the image, at half opacity while disabled + * @param renderer - the renderer + * @param viewport - the camera being drawn through * @ignore - * @internal */ - hold(): void { - timer.clearTimer(this.holdTimeout); - this.holdTimeout = -1; - this.isDirty = true; - if (!this.released) { - this.onHold(); + override draw(renderer: Renderer, viewport?: Camera2d): void { + if (this.enabled) { + super.draw(renderer, viewport); + return; } - } - - /** - * Called once a press has been held for `holdThreshold` milliseconds, - * when `isHoldable` is set. Override it in a subclass. - */ - onHold(): void {} - - /** - * function called when added to the game world or a container - * @ignore - * @internal - */ - override onActivateEvent(): void { - super.onActivateEvent(); - // register pointer events - registerPointerEvent("pointerdown", this, (e) => { - return this.clicked(e); - }); - registerPointerEvent("pointerup", this, (e) => { - return this.release(e); - }); - registerPointerEvent("pointercancel", this, (e) => { - return this.release(e); - }); - registerPointerEvent("pointerenter", this, (e) => { - return this.enter(e); - }); - registerPointerEvent("pointerleave", this, (e) => { - this.leave(e); - }); - } - - /** - * function called when removed from the game world or a container - * @ignore - * @internal - */ - override onDeactivateEvent(): void { - // release pointer events - releasePointerEvent("pointerdown", this); - releasePointerEvent("pointerup", this); - releasePointerEvent("pointercancel", this); - releasePointerEvent("pointerenter", this); - releasePointerEvent("pointerleave", this); - timer.clearTimer(this.holdTimeout); - this.holdTimeout = -1; - super.onDeactivateEvent(); + const alpha = renderer.globalAlpha(); + renderer.setGlobalAlpha(alpha * 0.5); + super.draw(renderer, viewport); + renderer.setGlobalAlpha(alpha); } } diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js index 517d873f7..e9971b8ed 100644 --- a/packages/melonjs/tests/ui-widgets.spec.js +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -2075,6 +2075,108 @@ describe("UI widgets", () => { }); }); + describe("UISpriteElement", () => { + it("is a UIBaseElement holding its sprite, placed where the sprite's anchor goes", () => { + const atlas = makeAtlas(); + const centred = new UISpriteElement(100, 100, { + image: atlas, + region: "up", + }); + expect(centred).toBeInstanceOf(UIBaseElement); + expect(centred.children).toContain(centred.sprite); + // 16x16, centred on (100, 100) as a sprite is + expect([ + centred.pos.x, + centred.pos.y, + centred.width, + centred.height, + ]).toEqual([92, 92, 16, 16]); + const corner = new UISpriteElement(10, 20, { + image: atlas, + region: "up", + anchorPoint: { x: 0, y: 0 }, + }); + expect([corner.pos.x, corner.pos.y]).toEqual([10, 20]); + // the sprite draws over the element in both cases + for (const el of [centred, corner]) { + const b = el.sprite.getBounds(); + const e = el.getBounds(); + expect([b.left, b.top, b.width]).toEqual([e.left, e.top, e.width]); + } + }); + + it("takes clicks where it is drawn, and not while disabled", () => { + const onClick = vi.fn(); + const icon = place( + new UISpriteElement(110, 110, { image: makeAtlas(), region: "up" }), + ); + icon.onClick = onClick; + pointer("pointermove", 112, 112); + pointer("pointerdown", 112, 112); + pointer("pointerup", 112, 112); + expect(onClick).toHaveBeenCalledTimes(1); + icon.enabled = false; + pointer("pointerdown", 112, 112); + pointer("pointerup", 112, 112); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("is drawn at half opacity while disabled", () => { + const icon = new UISpriteElement(110, 110, { + image: makeAtlas(), + region: "up", + enabled: false, + }); + const renderer = app.renderer; + let seen; + vi.spyOn(icon.sprite, "draw").mockImplementation(() => { + seen = renderer.globalAlpha(); + }); + // as the update before a frame would mark it + icon.sprite.inViewport = true; + icon.draw(renderer); + expect(seen).toBeCloseTo(0.5, 5); + expect(renderer.globalAlpha()).toBe(1); + }); + + it("setRegion swaps the art and resizes the element", () => { + const atlas = makeAtlas(); + const icon = new UISpriteElement(100, 100, { + image: atlas, + region: "up", + }); + const spy = vi.spyOn(icon.sprite, "setRegion"); + expect(icon.setRegion(atlas.getRegion("on"))).toBe(icon); + expect(spy).toHaveBeenCalledTimes(1); + expect([icon.width, icon.height]).toEqual([ + icon.sprite.width, + icon.sprite.height, + ]); + }); + + it("bounces, calls a press off, and takes the focus like the other elements", () => { + const onCancel = vi.fn(); + const icon = place( + new UISpriteElement(110, 110, { + image: makeAtlas(), + region: "up", + pressScale: 0.9, + }), + ); + icon.onCancel = onCancel; + expect(icon.pressScale).toBe(0.9); + pointer("pointermove", 112, 112); + pointer("pointerdown", 112, 112); + icon.cancelPress(); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(icon.released).toBe(true); + icon.focusable = true; + icon.focus(); + expect(UIBaseElement.focusOwner).toBe(icon); + icon.blur(); + }); + }); + describe("a press stops at a UI element", () => { /** a wide renderable under everything, listening for presses */ const listenBehind = () => { From 515a59aa6a0ae1c3a184a0fe6254f9359dabe062 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 02:25:20 +0000 Subject: [PATCH 26/43] Tests: UISpriteElement's public behaviour, old and new 17 tests of what a game relies on: Sprite settings and size, placement (centred by default, or by anchorPoint in the settings), presses, releases, hover, a press ended by leaving, isClickable, removal, the press stopping at it or let through, a subclass overriding the callbacks as the demos' buttons do, and the setRegion / animation / opacity shortcuts. They only use what the Sprite-based element and the UIBaseElement-based one both offer, and pass on both, and on master's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- .../melonjs/tests/ui-spriteelement.spec.js | 391 ++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 packages/melonjs/tests/ui-spriteelement.spec.js diff --git a/packages/melonjs/tests/ui-spriteelement.spec.js b/packages/melonjs/tests/ui-spriteelement.spec.js new file mode 100644 index 000000000..42b11810f --- /dev/null +++ b/packages/melonjs/tests/ui-spriteelement.spec.js @@ -0,0 +1,391 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + Application, + boot, + input, + Renderable, + TextureAtlas, + UISpriteElement, + video, +} from "../src/index.js"; + +// UISpriteElement's public behaviour, as a game sees it: where it is, what +// the pointer does to it, and the Sprite shortcuts a subclass calls. It +// only uses what both the Sprite-based element and the UIBaseElement-based +// one offer, so it pins the behaviour existing games depend on. + +let app; +const added = []; + +/** a DOM event at game coordinates, through the canvas's on-page rect */ +const at = (gameX, gameY) => { + const canvas = app.renderer.getCanvas(); + const rect = canvas.getBoundingClientRect(); + return { + clientX: rect.left + (gameX * rect.width) / canvas.width, + clientY: rect.top + (gameY * rect.height) / canvas.height, + }; +}; + +const pointer = (type, gameX, gameY) => { + app.renderer.getCanvas().dispatchEvent( + new PointerEvent(type, { + ...at(gameX, gameY), + pointerId: 1, + width: 1, + height: 1, + isPrimary: true, + bubbles: true, + }), + ); +}; + +const syncBroadphase = () => { + app.world.broadphase.clear(); + app.world.broadphase.insertContainer(app.world); +}; + +/** add to the world, and take it out after the test */ +const place = (renderable) => { + app.world.addChild(renderable); + added.push(renderable); + syncBroadphase(); + return renderable; +}; + +/** a 64x32 atlas: "up", "on" and "off" are 16x16, "down" is 16x12 */ +const makeAtlas = () => { + const sheet = document.createElement("canvas"); + sheet.width = 64; + sheet.height = 32; + const ctx = sheet.getContext("2d"); + ["#f00", "#0f0", "#00f", "#ff0"].forEach((c, i) => { + ctx.fillStyle = c; + ctx.fillRect(i * 16, 0, 16, 16); + }); + const frame = (filename, x, w, h) => { + return { + filename, + frame: { x, y: 0, w, h }, + rotated: false, + trimmed: false, + spriteSourceSize: { x: 0, y: 0, w, h }, + sourceSize: { w, h }, + pivot: { x: 0.5, y: 0.5 }, + }; + }; + return new TextureAtlas( + { + frames: [ + frame("up", 0, 16, 16), + frame("down", 16, 16, 12), + frame("on", 32, 16, 16), + frame("off", 48, 16, 16), + ], + meta: { + app: "texturepacker", + image: "ui.png", + size: { w: 64, h: 32 }, + scale: "1", + }, + }, + sheet, + ); +}; + +/** a 16x16 element centred on (100, 100), with spies on its callbacks */ +const makeIcon = (settings = {}) => { + const icon = new UISpriteElement(100, 100, { + image: makeAtlas(), + region: "up", + ...settings, + }); + return icon; +}; + +/** a renderable under everything, listening for presses */ +const listenBehind = () => { + const behind = vi.fn(); + const under = new Renderable(0, 0, 800, 600); + under.anchorPoint.set(0, 0); + // a kinematic renderable is left out of hit detection + under.isKinematic = false; + under.depth = -10; + place(under); + input.registerPointerEvent("pointerdown", under, behind); + return { behind, under }; +}; + +describe("UISpriteElement", () => { + beforeAll(async () => { + boot(); + app = new Application(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + await app.init(); + }); + + afterEach(() => { + // end any press a test left held + pointer("pointermove", 790, 590); + pointer("pointerup", 790, 590); + for (const r of added.splice(0)) { + if (r.ancestor !== undefined) { + app.world.removeChildNow(r); + } + } + vi.restoreAllMocks(); + }); + + describe("construction", () => { + it("takes Sprite settings: an atlas region, at the region's size", () => { + const icon = makeIcon(); + expect(icon).toBeInstanceOf(UISpriteElement); + expect(icon.width).toBe(16); + expect(icon.height).toBe(16); + }); + + it("uses screen coordinates and takes part in hit detection", () => { + const icon = makeIcon(); + expect(icon.floating).toBe(true); + expect(icon.isKinematic).toBe(false); + expect(icon.isClickable).toBe(true); + }); + + it("starts released, not hovered, not holdable", () => { + const icon = makeIcon(); + expect(icon.released).toBe(true); + expect(icon.hover).toBe(false); + expect(icon.isHoldable).toBe(false); + expect(icon.holdThreshold).toBe(250); + }); + }); + + describe("placement", () => { + it("is centred on (x, y) by default, as a sprite is", () => { + const b = makeIcon().getBounds(); + expect([b.left, b.top, b.width, b.height]).toEqual([92, 92, 16, 16]); + }); + + it("with an anchorPoint of (0, 0) in the settings, (x, y) is its top left corner", () => { + const icon = new UISpriteElement(10, 20, { + image: makeAtlas(), + region: "up", + anchorPoint: { x: 0, y: 0 }, + }); + const b = icon.getBounds(); + expect([b.left, b.top, b.width, b.height]).toEqual([10, 20, 16, 16]); + }); + }); + + describe("the pointer", () => { + it("a press calls onClick with the pointer, and holds it down until the release", () => { + const icon = place(makeIcon()); + const onClick = vi.spyOn(icon, "onClick"); + const onRelease = vi.spyOn(icon, "onRelease"); + pointer("pointermove", 100, 100); + pointer("pointerdown", 100, 100); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0]).toHaveProperty("gameX"); + expect(icon.released).toBe(false); + expect(onRelease).not.toHaveBeenCalled(); + pointer("pointerup", 100, 100); + expect(onRelease).toHaveBeenCalledTimes(1); + expect(icon.released).toBe(true); + }); + + it("answers where its image is, and not just outside it", () => { + const icon = place(makeIcon()); + const onClick = vi.spyOn(icon, "onClick"); + // just inside each edge of 92..108 + for (const [x, y] of [ + [93, 100], + [107, 100], + [100, 93], + [100, 107], + ]) { + pointer("pointermove", x, y); + pointer("pointerdown", x, y); + pointer("pointerup", x, y); + } + expect(onClick).toHaveBeenCalledTimes(4); + // just outside + for (const [x, y] of [ + [89, 100], + [111, 100], + [100, 89], + [100, 111], + ]) { + pointer("pointermove", x, y); + pointer("pointerdown", x, y); + pointer("pointerup", x, y); + } + expect(onClick).toHaveBeenCalledTimes(4); + }); + + it("hover: onOver as the pointer comes in, onOut as it leaves", () => { + const icon = place(makeIcon()); + const onOver = vi.spyOn(icon, "onOver"); + const onOut = vi.spyOn(icon, "onOut"); + pointer("pointermove", 100, 100); + expect(onOver).toHaveBeenCalledTimes(1); + expect(icon.hover).toBe(true); + pointer("pointermove", 300, 300); + expect(onOut).toHaveBeenCalledTimes(1); + expect(icon.hover).toBe(false); + }); + + it("the pointer leaving while held ends the press", () => { + const icon = place(makeIcon()); + const onRelease = vi.spyOn(icon, "onRelease"); + pointer("pointermove", 100, 100); + pointer("pointerdown", 100, 100); + pointer("pointermove", 300, 300); + expect(onRelease).toHaveBeenCalledTimes(1); + expect(icon.released).toBe(true); + }); + + it("ignores the pointer while isClickable is false", () => { + const icon = place(makeIcon()); + const onClick = vi.spyOn(icon, "onClick"); + icon.isClickable = false; + pointer("pointermove", 100, 100); + pointer("pointerdown", 100, 100); + expect(onClick).not.toHaveBeenCalled(); + expect(icon.released).toBe(true); + }); + + it("hears nothing once removed from the world", () => { + const icon = place(makeIcon()); + const onClick = vi.spyOn(icon, "onClick"); + app.world.removeChildNow(icon); + syncBroadphase(); + pointer("pointermove", 100, 100); + pointer("pointerdown", 100, 100); + expect(onClick).not.toHaveBeenCalled(); + }); + }); + + describe("what is behind it", () => { + it("a press stops at it by default", () => { + const { behind, under } = listenBehind(); + try { + const icon = place(makeIcon()); + expect(icon.onClick()).toBe(false); + expect(icon.onRelease()).toBe(false); + pointer("pointermove", 100, 100); + pointer("pointerdown", 100, 100); + expect(behind).not.toHaveBeenCalled(); + // and away from it, the game gets the press + pointer("pointermove", 400, 400); + pointer("pointerdown", 400, 400); + expect(behind).toHaveBeenCalledTimes(1); + } finally { + input.releasePointerEvent("pointerdown", under); + } + }); + + it("onClick returning true lets the press through", () => { + const { behind, under } = listenBehind(); + try { + const icon = place(makeIcon()); + icon.onClick = () => { + return true; + }; + pointer("pointermove", 100, 100); + pointer("pointerdown", 100, 100); + expect(behind).toHaveBeenCalledTimes(1); + } finally { + input.releasePointerEvent("pointerdown", under); + } + }); + }); + + describe("a subclass", () => { + it("overrides the callbacks, as the demos' buttons do", () => { + const log = []; + class Toggle extends UISpriteElement { + constructor(x, y) { + super(x, y, { image: makeAtlas(), region: "off" }); + this.on = false; + this.setOpacity(0.5); + } + onOver() { + this.setOpacity(1); + log.push("over"); + } + onOut() { + this.setOpacity(0.5); + log.push("out"); + } + onClick() { + this.on = !this.on; + log.push("click"); + return false; + } + onRelease() { + log.push("release"); + return false; + } + } + const t = place(new Toggle(100, 100)); + expect(t.getOpacity()).toBe(0.5); + pointer("pointermove", 100, 100); + expect(t.getOpacity()).toBe(1); + pointer("pointerdown", 100, 100); + pointer("pointerup", 100, 100); + // a mouse moves on from where it was let go, then away + pointer("pointermove", 101, 101); + pointer("pointermove", 300, 300); + expect(t.getOpacity()).toBe(0.5); + expect(t.on).toBe(true); + expect( + log.filter((e) => { + return e !== "over"; + }), + ).toEqual(["click", "release", "out"]); + expect(log[0]).toBe("over"); + }); + }); + + describe("Sprite shortcuts", () => { + it("setRegion swaps the image, to the new region's size", () => { + const atlas = makeAtlas(); + const icon = new UISpriteElement(100, 100, { + image: atlas, + region: "up", + }); + icon.setRegion(atlas.getRegion("down")); + expect(icon.width).toBe(16); + expect(icon.height).toBe(12); + }); + + it("addAnimation, setCurrentAnimation and isCurrentAnimation", () => { + // a spritesheet of 16x16 frames + const sheet = document.createElement("canvas"); + sheet.width = 64; + sheet.height = 16; + const icon = new UISpriteElement(100, 100, { + image: sheet, + framewidth: 16, + frameheight: 16, + }); + expect([icon.width, icon.height]).toEqual([16, 16]); + icon.addAnimation("blink", [1, 2]); + icon.addAnimation("idle", [0]); + icon.setCurrentAnimation("blink"); + expect(icon.isCurrentAnimation("blink")).toBe(true); + expect(icon.isCurrentAnimation("idle")).toBe(false); + icon.setCurrentAnimation("idle"); + expect(icon.isCurrentAnimation("idle")).toBe(true); + }); + + it("setOpacity and getOpacity", () => { + const icon = makeIcon(); + icon.setOpacity(0.25); + expect(icon.getOpacity()).toBe(0.25); + }); + }); +}); From 71c91ded11af7bebd375db1004ebf16ff2eb0107 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 02:38:27 +0000 Subject: [PATCH 27/43] Input: keep the hover after a click, and end it when the pointer leaves the canvas A pointerup cleared the pointer a region was tracking, so the region forgot it was under the pointer: leaving straight after a click never called onOut (a button stayed lit until the pointer came back over it), and the next move over it called onOver a second time. The release now keeps the pointer; the press still ends as before. The browser's pointerleave, sent when the pointer leaves the canvas or a finger lifts, now releases every region holding that pointer, wherever the exit is reported. It used to reach only a region at that point, which for a mouse leaving the canvas is the edge, far from most widgets. Touch is unchanged: a tap hovers while the finger is down, not after. Tests for leaving after a click, moving on over it, repeated clicks, a tap with new pointer ids, and leaving the canvas; the UISpriteElement tests check the hover strictly again, and pass on the old and new element. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/CHANGELOG.md | 2 + packages/melonjs/src/input/pointerevent.ts | 20 +++- .../melonjs/tests/ui-spriteelement.spec.js | 9 +- packages/melonjs/tests/ui-widgets.spec.js | 95 +++++++++++++++++++ 4 files changed, 116 insertions(+), 10 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 039babcd2..8b9598a65 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -25,6 +25,8 @@ - UI: `UITextButton#bitmapText`, for `label` ### Fixed +- Input: a region stays hovered after a click while the pointer is still on it. A release used to forget the pointer, so leaving straight after a click never called `onOut` (a button stayed lit), and moving on over it called `onOver` again +- Input: the pointer leaving the canvas ends the hover of whatever it was over, not only of a region at the point where it left - Input: a child cropped out of view by a container with `clipping`, or scrolled out of a `UIPanel`, no longer gets pointer events where it is not drawn - UI: a `UITextButton` moved after it was built draws its background where it is, not where it was first put - UI: a blocking `UIPanel` stays opaque to hover for as long as the pointer is on it, not only on the frame the pointer enters diff --git a/packages/melonjs/src/input/pointerevent.ts b/packages/melonjs/src/input/pointerevent.ts index 983013925..7c6313a6e 100644 --- a/packages/melonjs/src/input/pointerevent.ts +++ b/packages/melonjs/src/input/pointerevent.ts @@ -511,16 +511,32 @@ function dispatchEvent(normalizedEvents: Pointer[]): boolean { case POINTER_UP[1]: case POINTER_UP[2]: case POINTER_UP[3]: - // pointer defined & inside of bounds: trigger the POINTER_UP callback + // pointer defined & inside of bounds: trigger the POINTER_UP callback. + // The region keeps the pointer: the press is over, but the + // pointer is still over it, so moving away still leaves it and + // moving on over it does not enter it again. if (handlers.pointerId === pointer.pointerId && eventInBounds) { // trigger the corresponding callback - if (triggerEvent(handlers, pointer.type, pointer, null)) { + if ( + triggerEvent(handlers, pointer.type, pointer, pointer.pointerId) + ) { handled = true; break; } } break; + case POINTER_LEAVE[0]: + case POINTER_LEAVE[1]: + case POINTER_LEAVE[2]: + // the pointer left the canvas, or a finger lifted: every region + // it was over is left, wherever the browser reports the exit + // (at the canvas's edge, usually far from the region) + if (handlers.pointerId === pointer.pointerId) { + triggerEvent(handlers, pointer.type, pointer, null); + } + break; + case POINTER_CANCEL[0]: case POINTER_CANCEL[1]: case POINTER_CANCEL[2]: diff --git a/packages/melonjs/tests/ui-spriteelement.spec.js b/packages/melonjs/tests/ui-spriteelement.spec.js index 42b11810f..c82083b4f 100644 --- a/packages/melonjs/tests/ui-spriteelement.spec.js +++ b/packages/melonjs/tests/ui-spriteelement.spec.js @@ -336,17 +336,10 @@ describe("UISpriteElement", () => { expect(t.getOpacity()).toBe(1); pointer("pointerdown", 100, 100); pointer("pointerup", 100, 100); - // a mouse moves on from where it was let go, then away - pointer("pointermove", 101, 101); pointer("pointermove", 300, 300); expect(t.getOpacity()).toBe(0.5); expect(t.on).toBe(true); - expect( - log.filter((e) => { - return e !== "over"; - }), - ).toEqual(["click", "release", "out"]); - expect(log[0]).toBe("over"); + expect(log).toEqual(["over", "click", "release", "out"]); }); }); diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js index e9971b8ed..933a073be 100644 --- a/packages/melonjs/tests/ui-widgets.spec.js +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -2177,6 +2177,101 @@ describe("UI widgets", () => { }); }); + describe("hover after a click", () => { + const makeButton = () => { + const b = place( + new UITextButton(100, 100, { text: "OK", width: 100, height: 40 }), + ); + return { + b, + onOver: vi.spyOn(b, "onOver"), + onOut: vi.spyOn(b, "onOut"), + }; + }; + + it("leaving straight after a click ends the hover", () => { + const { b, onOver, onOut } = makeButton(); + pointer("pointermove", 150, 120); + pointer("pointerdown", 150, 120); + pointer("pointerup", 150, 120); + expect(b.hover).toBe(true); + pointer("pointermove", 500, 400); + expect(b.hover).toBe(false); + expect(onOver).toHaveBeenCalledTimes(1); + expect(onOut).toHaveBeenCalledTimes(1); + }); + + it("moving on over it after a click does not enter it again", () => { + const { onOver, onOut } = makeButton(); + pointer("pointermove", 150, 120); + pointer("pointerdown", 150, 120); + pointer("pointerup", 150, 120); + pointer("pointermove", 152, 121); + pointer("pointermove", 154, 122); + expect(onOver).toHaveBeenCalledTimes(1); + expect(onOut).not.toHaveBeenCalled(); + }); + + it("a second click after the first still presses and releases it", () => { + const onClick = vi.fn(); + const b = place( + new UITextButton(100, 100, { + text: "OK", + width: 100, + height: 40, + onClick, + }), + ); + pointer("pointermove", 150, 120); + for (let i = 0; i < 2; i++) { + pointer("pointerdown", 150, 120); + pointer("pointerup", 150, 120); + } + expect(onClick).toHaveBeenCalledTimes(2); + expect(b.released).toBe(true); + }); + + it("a tap hovers the button while the finger is down, and not after", () => { + const onClick = vi.fn(); + const b = place( + new UITextButton(100, 100, { + text: "OK", + width: 100, + height: 40, + onClick, + }), + ); + const onOver = vi.spyOn(b, "onOver"); + const onOut = vi.spyOn(b, "onOut"); + // a touch: the browser sends enter before the press and leave + // after the release, at the finger; each touch has a new id + for (const id of [2, 3]) { + pointer("pointerenter", 150, 120, id); + expect(b.hover).toBe(true); + pointer("pointerdown", 150, 120, id); + pointer("pointerup", 150, 120, id); + pointer("pointerleave", 150, 120, id); + expect(b.hover).toBe(false); + } + expect(onClick).toHaveBeenCalledTimes(2); + expect(onOver).toHaveBeenCalledTimes(2); + expect(onOut).toHaveBeenCalledTimes(2); + }); + + it("the pointer leaving the canvas ends the hover, wherever it was", () => { + const { b, onOut } = makeButton(); + pointer("pointermove", 150, 120); + expect(b.hover).toBe(true); + // the browser reports the exit at the canvas's edge, far from the button + pointer("pointerleave", 0, 300); + expect(b.hover).toBe(false); + expect(onOut).toHaveBeenCalledTimes(1); + // and coming back over it enters it again + pointer("pointermove", 150, 120); + expect(b.hover).toBe(true); + }); + }); + describe("a press stops at a UI element", () => { /** a wide renderable under everything, listening for presses */ const listenBehind = () => { From 63bfee5ed73b21c8e2a58e611e25bc7fbef912a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 02:55:14 +0000 Subject: [PATCH 28/43] UI: fix the widget bugs found while writing the docs - UICheckbox#toggle() on a checked radio button leaves it checked, as a click does, instead of leaving its group with nothing checked - UIPanel#scrollTo() (and scrollToChild, scrollX / scrollY) stops a fling still coasting; the panel's own fling, drag, wheel and relayout scroll through a private method that leaves it alone - UISlider lays its texts out in one place, again whenever they change: a vertical slider widens for a longer value text (valueFormat, min, max or step set later), a horizontal one moves the value below the track when it would run into the caption, and the new labelText sets the caption later Tests for each, failing before the fix, and one for the wheel over a slider in a scrolling panel (it moves the slider, not the panel), which had none. Docs, skill and changelog updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/CHANGELOG.md | 3 + .../skills/melonjs-ui-and-text/SKILL.md | 11 +- .../melonjs/src/renderable/ui/uicheckbox.ts | 10 +- packages/melonjs/src/renderable/ui/uipanel.ts | 23 +- .../melonjs/src/renderable/ui/uislider.ts | 196 +++++++++++++----- packages/melonjs/tests/ui-widgets.spec.js | 146 +++++++++++++ 6 files changed, 322 insertions(+), 67 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 8b9598a65..5d858b6f7 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -25,6 +25,9 @@ - UI: `UITextButton#bitmapText`, for `label` ### Fixed +- UI: `UICheckbox#toggle()` on a checked radio button leaves it checked, as a click does, instead of leaving its group with nothing checked +- UI: `UIPanel#scrollTo()` (and `scrollToChild`, `scrollX` / `scrollY`) stops a fling that is still coasting, instead of the fling carrying on from the new offset +- UI: a `UISlider` lays its texts out again when they change: a vertical one widens for a longer value text (a new `valueFormat`, `min`, `max` or `step`), and on a horizontal one the value moves below the track when it would run into the caption, instead of overlapping it. `labelText` sets the caption later - Input: a region stays hovered after a click while the pointer is still on it. A release used to forget the pointer, so leaving straight after a click never called `onOut` (a button stayed lit), and moving on over it called `onOver` again - Input: the pointer leaving the canvas ends the hover of whatever it was over, not only of a region at the point where it left - Input: a child cropped out of view by a container with `clipping`, or scrolled out of a `UIPanel`, no longer gets pointer events where it is not drawn diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index 1ab0f77f8..f0f66f5f2 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -592,7 +592,8 @@ The content is cropped to the area inside the frame and below the title, and scrolls with the wheel over the panel and by dragging, with momentum when flung. A scrollbar shows while it does not fit. `scrollTo(x, y)`, `scrollToChild(child)`, `scrollX` / `scrollY`, `maxScrollX` / `maxScrollY` and -an `onScroll(x, y, panel)` setting are there to drive it from code. +an `onScroll(x, y, panel)` setting are there to drive it from code. Scrolling +from code stops a fling that is still coasting. What to know: @@ -879,7 +880,8 @@ applyMusic(saved.music); // the effect, once ``` **Radio buttons** are checkboxes sharing a `group`: exactly one is checked, -clicking the checked one leaves it checked, and they are drawn round. Only +clicking the checked one leaves it checked (so does `toggle()`; set +`checked = false` to clear a group on purpose), and they are drawn round. Only the one the user picked gets `onChange`, as in a web page; read the group with `UICheckbox.checkedIn("difficulty")`. A checkbox is in its group while it is in the scene. @@ -931,7 +933,10 @@ the slider moves it one `step`. `value` snaps to `step` and clamps to the range, and setting it does not call `onChange` or `onChangeEnd`: the same rule as the checkbox, and neither does changing `min`, `max` or `step`, which re-clamp it. `ratio` reads where it is from 0 to 1, and `valueFormat` gets -both. `thumbColor`, `thumbHoverColor`, `thumbBorderColor` and +both. `labelText` changes the caption later. The slider refits to its texts +(the widest value it can show, at either end of the range), and on a +horizontal one the value moves below the track when it would run into the +caption; a `UIPanel` holding it needs `relayout()` if its size changes. `thumbColor`, `thumbHoverColor`, `thumbBorderColor` and `thumbBorderWidth` style the drawn thumb. `skin: { atlas, track, fill?, thumb, thumbHover? }` draws it from art: the track and fill as nine-slices along its length, the thumb at its own size. diff --git a/packages/melonjs/src/renderable/ui/uicheckbox.ts b/packages/melonjs/src/renderable/ui/uicheckbox.ts index 9f29cb0d4..3d306f81b 100644 --- a/packages/melonjs/src/renderable/ui/uicheckbox.ts +++ b/packages/melonjs/src/renderable/ui/uicheckbox.ts @@ -496,12 +496,16 @@ export default class UICheckbox extends UIBaseElement { } /** - * Flip it from code. Like setting {@link UICheckbox#checked}, this does - * not call `onChange`. On a checked radio button it unchecks it, which - * leaves its group with nothing checked. + * Flip it from code, as a click would. Like setting + * {@link UICheckbox#checked}, this does not call `onChange`. A checked + * radio button stays checked, as it does when clicked; set `checked` to + * `false` to uncheck it on purpose. * @returns the new state */ toggle(): boolean { + if (this._checked && this._group !== undefined) { + return true; + } this.checked = !this._checked; return this._checked; } diff --git a/packages/melonjs/src/renderable/ui/uipanel.ts b/packages/melonjs/src/renderable/ui/uipanel.ts index fba988b16..2642421b8 100644 --- a/packages/melonjs/src/renderable/ui/uipanel.ts +++ b/packages/melonjs/src/renderable/ui/uipanel.ts @@ -736,7 +736,7 @@ export default class UIPanel extends UIBaseElement { } const beforeX = this._scrollX; const beforeY = this._scrollY; - this.scrollTo( + this.moveContent( this._scrollX - this._velocityX * dt, this._scrollY - this._velocityY * dt, ); @@ -1221,7 +1221,7 @@ export default class UIPanel extends UIBaseElement { ? Math.max(0, bottom + this.padding - (vy + vh)) : 0; // the content may have shrunk under the current offset - this.scrollTo(this._scrollX, this._scrollY); + this.moveContent(this._scrollX, this._scrollY); } /** @@ -1272,7 +1272,7 @@ export default class UIPanel extends UIBaseElement { /** * Scroll the content to an offset, clamped to `0` up to * {@link UIPanel#maxScrollX} and {@link UIPanel#maxScrollY}, with no - * animation. + * animation. A fling still coasting from a drag stops. * * Scrolling moves the children, so a child's `pos` is where it is drawn. * @param x - the horizontal offset, in pixels @@ -1283,6 +1283,19 @@ export default class UIPanel extends UIBaseElement { * log.scrollTo(0, log.maxScrollY); */ scrollTo(x: number, y: number): this { + this._velocityX = this._velocityY = 0; + return this.moveContent(x, y); + } + + /** + * move the content to an offset, clamped, leaving a fling alone: the + * scrolling the panel does itself (fling, drag, wheel, relayout) + * @param x - the horizontal offset, in pixels + * @param y - the vertical offset, in pixels + * @returns this panel + * @ignore + */ + private moveContent(x: number, y: number): this { const nx = clamp(Number.isFinite(x) ? x : 0, 0, this._maxScrollX); const ny = clamp(Number.isFinite(y) ? y : 0, 0, this._maxScrollY); const dx = nx - this._scrollX; @@ -1459,7 +1472,7 @@ export default class UIPanel extends UIBaseElement { press.scrolling = true; press.element?.cancelPress(); } - this.scrollTo(press.scrollX - dx, press.scrollY - dy); + this.moveContent(press.scrollX - dx, press.scrollY - dy); const now = globalThis.performance?.now() ?? Date.now(); const elapsed = now - press.lastTime; if (elapsed > 0) { @@ -1518,7 +1531,7 @@ export default class UIPanel extends UIBaseElement { const beforeX = this._scrollX; const beforeY = this._scrollY; this._velocityX = this._velocityY = 0; - this.scrollTo(this._scrollX + dx, this._scrollY + dy); + this.moveContent(this._scrollX + dx, this._scrollY + dy); if (this._scrollX !== beforeX || this._scrollY !== beforeY) { return false; } diff --git a/packages/melonjs/src/renderable/ui/uislider.ts b/packages/melonjs/src/renderable/ui/uislider.ts index 3635f87fc..8a35e6498 100644 --- a/packages/melonjs/src/renderable/ui/uislider.ts +++ b/packages/melonjs/src/renderable/ui/uislider.ts @@ -235,7 +235,10 @@ interface Box { * `label` is a caption drawn above the track. `showValue` adds the value as * text, written by `valueFormat(value, ratio)`. The default format shows * as many decimals as `step` has: `step: 1` shows "7", `step: 0.1` shows - * "0.7", and no step shows "0.70". + * "0.7", and no step shows "0.70". On a horizontal slider the caption and + * the value share the row above the track, and the value moves below the + * track when the two would run into each other. `labelText` changes the + * caption later; the slider resizes to fit its texts. * * ## Look * Drawn with shapes by default: a rounded track, a filled part up to the @@ -346,7 +349,10 @@ export default class UISlider extends UIBaseElement { readonly orientation: "horizontal" | "vertical"; /** the atlas regions it is drawn from, or `undefined` for the drawn look */ readonly skin: UISliderSkin | undefined; - /** the caption's text object, or `undefined` without a `label` */ + /** + * the caption's text object, or `undefined` without one; change the + * text through {@link UISlider#labelText} + */ readonly label: UILabel | undefined; /** * the value's text object, or `undefined` without `showValue`; kept up @@ -371,10 +377,23 @@ export default class UISlider extends UIBaseElement { /** @ignore */ private readonly _skin: SkinDrawer | undefined; /** the track, in the slider's coordinates */ - private readonly _track: Box; + private readonly _track: Box = { x: 0, y: 0, w: 0, h: 0 }; /** where the thumb's centre can go, along the slider */ - private readonly _travelStart: number; + private _travelStart = 0; private readonly _travelLength: number; + /** the length along the slider, the track's thickness, and the band the thumb slides in */ + private readonly _length: number; + private readonly _thickness: number; + private readonly _band: number; + /** the caption's text */ + private _labelText = ""; + /** the style of the caption and the value text */ + private readonly _textStyle: { + font: string; + fontSize: number; + fillStyle: string; + textBaseline: string; + }; /** the thumb's size across and along the slider */ private readonly _thumbW: number; private readonly _thumbH: number; @@ -481,70 +500,31 @@ export default class UISlider extends UIBaseElement { this._thumbW = thumbW; this._thumbH = thumbH; - const textStyle = { + this._textStyle = { font: settings.font ?? "sans-serif", fontSize: settings.fontSize ?? 16, fillStyle: settings.labelFillStyle ?? "#000000", textBaseline: "middle", }; if (settings.label !== undefined) { - this.label = createLabel({ - ...textStyle, - text: settings.label, - textAlign: horizontal ? "left" : "center", - }); + this._labelText = settings.label; + this.label = this.makeCaption(settings.label); } if (settings.showValue === true) { this.valueText = createLabel({ - ...textStyle, + ...this._textStyle, text: this._valueFormat(this._max, 1), textAlign: horizontal ? "right" : "center", }); } - const rowHeight = - this.label !== undefined || this.valueText !== undefined - ? Math.ceil(textStyle.fontSize * 1.4) - : 0; - + this._length = length; + this._thickness = thickness; // the band the thumb slides in: as thick as the thumb or the track - const band = Math.max(thumbH, thickness); - if (horizontal) { - const top = rowHeight; - this._track = { - x: 0, - y: top + (band - thickness) / 2, - w: length, - h: thickness, - }; - this._travelStart = thumbW / 2; - this._valueAt[0] = length; - this._valueAt[1] = rowHeight / 2; - this._labelAt[1] = rowHeight / 2; - this.resize(length, top + band); - } else { - const textWidth = Math.max( - this.label?.measureText().width ?? 0, - this.valueText?.measureText().width ?? 0, - ); - const width = Math.ceil(Math.max(band, textWidth)); - const top = this.label !== undefined ? rowHeight : 0; - this._track = { - x: (width - thickness) / 2, - y: top, - w: thickness, - h: length, - }; - this._travelStart = top + thumbW / 2; - this._valueAt[0] = width / 2; - this._valueAt[1] = top + length + rowHeight / 2; - this._labelAt[0] = width / 2; - this._labelAt[1] = rowHeight / 2; - this.resize(width, top + length + (this.valueText ? rowHeight : 0)); - } + this._band = Math.max(thumbH, thickness); this._travelLength = Math.max(0, length - thumbW); this._value = this.snap(settings.value ?? this._min); - this.refreshValueText(); + this.layoutText(); this.focusable = true; this.hoverScale = settings.hoverScale ?? 1; this.pressScale = settings.pressScale ?? 1; @@ -617,9 +597,8 @@ export default class UISlider extends UIBaseElement { /** * How the value text is written, given the value and the ratio (0 to - * 1). Setting it rewrites the value text straight away. The space for - * the text on a vertical slider is measured when it is built, so a much - * longer format set later may not fit. + * 1). Setting it rewrites the value text and lays the slider out again + * for the widest text it can now show. * @example * slider.valueFormat = (value) => `${value} px`; */ @@ -630,7 +609,8 @@ export default class UISlider extends UIBaseElement { set valueFormat(format: (value: number, ratio: number) => string) { this._valueFormat = format; this._defaultFormat = false; - this.refreshValueText(); + this.layoutText(); + this.isDirty = true; } /** @@ -640,10 +620,114 @@ export default class UISlider extends UIBaseElement { */ private resnap() { this._value = this.snap(this._value); - this.refreshValueText(); + // the widest value text may have changed with the range or the step + this.layoutText(); + this.isDirty = true; + } + + /** + * The caption's text, or `""` without one. Setting it makes a caption if + * there was none, and lays the slider out again for its width; a + * {@link UIPanel} holding it needs `relayout()` if the slider's size + * changes. + * @example + * volume.labelText = muted ? "Music (muted)" : "Music"; + */ + get labelText(): string { + return this._labelText; + } + + set labelText(value: string) { + this._labelText = value; + if (this.label === undefined) { + (this as { label: UILabel | undefined }).label = this.makeCaption(value); + } else { + this.label.setText(value); + } + this.layoutText(); this.isDirty = true; } + /** + * build the caption's text object + * @param text - the caption + * @returns the label + * @ignore + */ + private makeCaption(text: string): UILabel { + return createLabel({ + ...this._textStyle, + text, + textAlign: this.orientation === "horizontal" ? "left" : "center", + }); + } + + /** + * Measure the caption and the widest value text, and place them, the + * track and the thumb's travel; resize the slider to fit. A horizontal + * slider puts the caption and the value on one row above the track, or + * the value below the track when the two do not fit side by side. + * @ignore + */ + private layoutText() { + const horizontal = this.orientation === "horizontal"; + const length = this._length; + const thickness = this._thickness; + const band = this._band; + const label = this.label; + const valueText = this.valueText; + const rowHeight = + label !== undefined || valueText !== undefined + ? Math.ceil(this._textStyle.fontSize * 1.4) + : 0; + const captionW = label?.measureText().width ?? 0; + let valueW = 0; + if (valueText !== undefined) { + // the widest the value text gets, at either end of the range + for (const [v, r] of [ + [this._min, 0], + [this._max, 1], + ]) { + valueText.setText(this._valueFormat(v, r)); + valueW = Math.max(valueW, valueText.measureText().width); + } + this.refreshValueText(); + } + const t = this._track; + if (horizontal) { + const top = + label !== undefined || valueText !== undefined ? rowHeight : 0; + // the value moves below the track when it would run into the caption + const below = + label !== undefined && + valueText !== undefined && + captionW + 8 + valueW > length; + t.x = 0; + t.y = top + (band - thickness) / 2; + t.w = length; + t.h = thickness; + this._travelStart = this._thumbW / 2; + this._labelAt[0] = 0; + this._labelAt[1] = rowHeight / 2; + this._valueAt[0] = length; + this._valueAt[1] = below ? top + band + rowHeight / 2 : rowHeight / 2; + this.resize(length, top + band + (below ? rowHeight : 0)); + } else { + const width = Math.ceil(Math.max(band, captionW, valueW)); + const top = label !== undefined ? rowHeight : 0; + t.x = (width - thickness) / 2; + t.y = top; + t.w = thickness; + t.h = length; + this._travelStart = top + this._thumbW / 2; + this._labelAt[0] = width / 2; + this._labelAt[1] = rowHeight / 2; + this._valueAt[0] = width / 2; + this._valueAt[1] = top + length + rowHeight / 2; + this.resize(width, top + length + (valueText ? rowHeight : 0)); + } + } + /** * Write the current value into the value text. * @ignore diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js index 933a073be..1363c1f0c 100644 --- a/packages/melonjs/tests/ui-widgets.spec.js +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -2177,6 +2177,152 @@ describe("UI widgets", () => { }); }); + describe("fixes from the docs review", () => { + it("toggle() keeps a checked radio button checked, as a click does", () => { + const a = place(new UICheckbox(0, 0, { group: "t1", checked: true })); + const b = place(new UICheckbox(0, 40, { group: "t1" })); + expect(a.toggle()).toBe(true); + expect(UICheckbox.checkedIn("t1")).toBe(a); + // toggling the other one moves the choice + expect(b.toggle()).toBe(true); + expect([a.checked, b.checked]).toEqual([false, true]); + // unchecking on purpose still works + b.checked = false; + expect(UICheckbox.checkedIn("t1")).toBeUndefined(); + // a plain checkbox still flips both ways + const c = new UICheckbox(0, 0, { checked: true }); + expect(c.toggle()).toBe(false); + expect(c.toggle()).toBe(true); + }); + + it("scrollTo() from code stops a fling", () => { + const panel = place( + new UIPanel(100, 100, { + width: 200, + height: 100, + layout: "column", + gap: 0, + scroll: "vertical", + }), + ); + for (let i = 0; i < 10; i++) { + panel.addChild(box(100, 40)); + } + syncBroadphase(); + const now = vi.spyOn(performance, "now"); + let t = 1000; + now.mockImplementation(() => { + return t; + }); + pointer("pointermove", 150, 190); + pointer("pointerdown", 150, 190); + for (let i = 1; i <= 4; i++) { + t += 8; + pointer("pointermove", 150, 190 - i * 15); + } + pointer("pointerup", 150, 130); + panel.update(16); + // still coasting + const coasting = panel.scrollY; + panel.update(16); + expect(panel.scrollY).toBeGreaterThan(coasting); + panel.scrollTo(0, 20); + for (let i = 0; i < 30; i++) { + panel.update(16); + } + expect(panel.scrollY).toBe(20); + }); + + it("a slider's caption can be set later, and the slider refits", () => { + const s = new UISlider(0, 0, { orientation: "vertical", length: 100 }); + expect(s.label).toBeUndefined(); + const bare = [s.width, s.height]; + s.labelText = "A much longer caption"; + expect(textOf(s.label)).toBe("A much longer caption"); + expect(s.labelText).toBe("A much longer caption"); + expect(s.width).toBeGreaterThan(bare[0]); + expect(s.height).toBeGreaterThan(bare[1]); + s.labelText = "Hi"; + expect(textOf(s.label)).toBe("Hi"); + }); + + it("a vertical slider widens for a longer value text set later", () => { + const s = new UISlider(0, 0, { + orientation: "vertical", + showValue: true, + min: 0, + max: 9, + step: 1, + }); + const narrow = s.width; + s.max = 100000; + expect(s.width).toBeGreaterThan(narrow); + const wider = s.width; + s.valueFormat = (v) => { + return `${v} points scored`; + }; + expect(s.width).toBeGreaterThan(wider); + // and the value text still reads the value + s.value = 7; + expect(textOf(s.valueText)).toBe("7 points scored"); + }); + + it("a horizontal slider moves its value below the track when it would hit the caption", () => { + const short = new UISlider(0, 0, { + label: "Vol", + showValue: true, + length: 200, + }); + const crowded = new UISlider(0, 0, { + label: "A very long caption for this slider", + showValue: true, + valueFormat: (v) => { + return `${Math.round(v * 100)} percent`; + }, + length: 200, + }); + expect(crowded.width).toBe(200); + expect(crowded.height).toBeGreaterThan(short.height); + // shortening the caption brings it back up + crowded.labelText = "Vol"; + crowded.valueFormat = (v) => { + return `${Math.round(v * 100)}%`; + }; + expect(crowded.height).toBe(short.height); + }); + + it("the wheel over a slider in a scrolling panel moves the slider, not the panel", () => { + const panel = place( + new UIPanel(100, 100, { + width: 300, + height: 120, + layout: "column", + gap: 0, + scroll: "vertical", + }), + ); + const slider = panel.addChild( + new UISlider(0, 0, { length: 200, step: 0.1, value: 0.5 }), + ); + for (let i = 0; i < 6; i++) { + panel.addChild(box(100, 40)); + } + syncBroadphase(); + expect(panel.maxScrollY).toBeGreaterThan(0); + const b = slider.getBounds(); + const x = b.centerX; + const y = b.centerY; + pointer("pointermove", x, y); + wheel(x, y, 0, 50); + expect(slider.value).not.toBe(0.5); + expect(panel.scrollY).toBe(0); + // off the slider, the wheel scrolls the panel + pointer("pointermove", 380, 200); + wheel(380, 200, 50); + expect(panel.scrollY).toBeGreaterThan(0); + }); + }); + describe("hover after a click", () => { const makeButton = () => { const b = place( From 8a41b062ed5f0ca7537edbbdae04c8348bc2f76b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 02:59:07 +0000 Subject: [PATCH 29/43] UI: a radio group holds one choice from when its checkboxes are made A checkbox joined its group only once in the scene, so before a menu was added several radio buttons could be checked at once and UICheckbox.checkedIn() answered undefined. A checkbox now joins its group when made (or when its group changes) and leaves it when destroyed. Groups hold their members through WeakRef, so a checkbox dropped without being destroyed can still be collected. A checkbox taken out of the world but kept alive stays in its group. Tests for a group before the scene, and for leaving on destroy while staying in the group when kept alive; both fail before the fix. Docs, skill and changelog updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/CHANGELOG.md | 1 + .../skills/melonjs-ui-and-text/SKILL.md | 5 +- .../melonjs/src/renderable/ui/uicheckbox.ts | 105 +++++++++--------- packages/melonjs/tests/ui-widgets.spec.js | 38 +++++++ 4 files changed, 94 insertions(+), 55 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 5d858b6f7..3db911f9a 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -25,6 +25,7 @@ - UI: `UITextButton#bitmapText`, for `label` ### Fixed +- UI: a radio group holds one choice from when its checkboxes are made, not only once they are in the scene: before, several could be checked at once until they were added, and `UICheckbox.checkedIn()` answered `undefined`. A checkbox leaves its group when destroyed; groups hold their members weakly, so one dropped without being destroyed can still be collected - UI: `UICheckbox#toggle()` on a checked radio button leaves it checked, as a click does, instead of leaving its group with nothing checked - UI: `UIPanel#scrollTo()` (and `scrollToChild`, `scrollX` / `scrollY`) stops a fling that is still coasting, instead of the fling carrying on from the new offset - UI: a `UISlider` lays its texts out again when they change: a vertical one widens for a longer value text (a new `valueFormat`, `min`, `max` or `step`), and on a horizontal one the value moves below the track when it would run into the caption, instead of overlapping it. `labelText` sets the caption later diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index f0f66f5f2..0e1199755 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -883,8 +883,9 @@ applyMusic(saved.music); // the effect, once clicking the checked one leaves it checked (so does `toggle()`; set `checked = false` to clear a group on purpose), and they are drawn round. Only the one the user picked gets `onChange`, as in a web page; read the group with -`UICheckbox.checkedIn("difficulty")`. A checkbox is in its group while it is in -the scene. +`UICheckbox.checkedIn("difficulty")`. A checkbox is in its group from when it is +made until it is destroyed, so the group holds one choice, and `checkedIn` +answers, before the panel is ever added to the world. ```js for (const level of ["Easy", "Normal", "Hard"]) { diff --git a/packages/melonjs/src/renderable/ui/uicheckbox.ts b/packages/melonjs/src/renderable/ui/uicheckbox.ts index 3d306f81b..12ab37bec 100644 --- a/packages/melonjs/src/renderable/ui/uicheckbox.ts +++ b/packages/melonjs/src/renderable/ui/uicheckbox.ts @@ -147,8 +147,35 @@ export interface UICheckboxSettings extends UIJuiceSettings { skin?: UICheckboxSkin; } -/** checkboxes by group name, while they are in the scene */ -const groups = new Map>(); +/** + * checkboxes by group name, from when they are made until they are + * destroyed; held weakly, so a checkbox dropped without being destroyed can + * still be collected + */ +const groups = new Map>>(); + +/** + * the live members of a group, dropping the ones collected since + * @param name - the group name + * @yields {UICheckbox} each member still alive + */ +function* membersOf(name: string): Generator { + const refs = groups.get(name); + if (refs === undefined) { + return; + } + for (const ref of refs) { + const member = ref.deref(); + if (member === undefined) { + refs.delete(ref); + } else { + yield member; + } + } + if (refs.size === 0) { + groups.delete(name); + } +} /** * A checkbox with a text label, for an on/off option such as music, sound @@ -180,10 +207,11 @@ const groups = new Map>(); * ## Radio groups * Checkboxes with the same {@link UICheckbox#group} name have one checked * at a time: checking one unchecks the others, and clicking the checked one - * leaves it checked. A checkbox belongs to its group while it is in the - * scene, so {@link UICheckbox.checkedIn} only sees checkboxes that have - * been added to the world. A checked one added to a group unchecks the - * others already there. + * leaves it checked. A checkbox belongs to its group from the moment it is + * made until it is destroyed (removing it from the world for good destroys + * it), so the group holds one choice before it is ever shown, and + * {@link UICheckbox.checkedIn} answers straight away. A checked one joining + * a group unchecks the others already there. * * ## Keyboard and gamepad * It can take the focus ({@link UIBaseElement#focus}). With the focus, @@ -227,7 +255,7 @@ const groups = new Map>(); * levels.set(radio, level); * panel.addChild(radio); * } - * // later, once the panel is in the world (e.g. when OK is clicked) + * // later, e.g. when OK is clicked * const difficulty = levels.get(UICheckbox.checkedIn("difficulty")); * @example * // skinned from an atlas, with a bitmap font @@ -267,7 +295,7 @@ export default class UICheckbox extends UIBaseElement { * Checkboxes sharing a group have one checked at a time: checking one * unchecks the others, and clicking the checked one leaves it checked. * A radio button is drawn round, with a dot. A checkbox is in its group - * while it is in the scene; removing it takes it out. Changing `group` + * from when it is made until it is destroyed. Changing `group` * moves it to the new group straight away, and if it is checked it * unchecks the others there. */ @@ -279,14 +307,9 @@ export default class UICheckbox extends UIBaseElement { if (value === this._group) { return; } - const active = this._active; - if (active) { - this.leaveGroup(); - } + this.leaveGroup(); this._group = value; - if (active) { - this.joinGroup(); - } + this.joinGroup(); this.isDirty = true; } @@ -333,8 +356,8 @@ export default class UICheckbox extends UIBaseElement { }; /** @ignore */ private _group: string | undefined; - /** whether it is in the scene */ - private _active = false; + /** how its group holds it */ + private readonly _ref = new WeakRef(this); /** @ignore */ private readonly _painter = new PanelPainter(); /** @ignore */ @@ -376,6 +399,9 @@ export default class UICheckbox extends UIBaseElement { this._checked = settings.checked ?? false; this.onChange = settings.onChange; this._group = settings.group; + // in its group from now on, so the group holds one choice before + // it is ever added to the scene + this.joinGroup(); this.boxColor = settings.boxColor ?? "#ffffff"; this.boxHoverColor = settings.boxHoverColor ?? "#e4eefb"; @@ -513,9 +539,8 @@ export default class UICheckbox extends UIBaseElement { /** * The checked checkbox of a radio group: the player's choice. * - * Only checkboxes in the scene are in a group, so this returns - * `undefined` until they (or the panel holding them) are added to the - * world. + * It answers as soon as the checkboxes are made, before they are added + * to the world. * @param group - the group name * @returns the checked member, or `undefined` when none is checked * @example @@ -523,12 +548,9 @@ export default class UICheckbox extends UIBaseElement { * console.log(picked?.labelOn); // the text of the checked radio button */ static checkedIn(group: string): UICheckbox | undefined { - const members = groups.get(group); - if (members !== undefined) { - for (const member of members) { - if (member.checked) { - return member; - } + for (const member of membersOf(group)) { + if (member._checked) { + return member; } } return undefined; @@ -543,11 +565,7 @@ export default class UICheckbox extends UIBaseElement { if (this._joined === undefined) { return; } - const members = groups.get(this._joined); - if (members === undefined) { - return; - } - for (const member of members) { + for (const member of membersOf(this._joined)) { if (member !== this && member._checked) { member._checked = false; member.label?.setText(member._labelOff); @@ -596,26 +614,6 @@ export default class UICheckbox extends UIBaseElement { this.onChange?.(this._checked, this); } - /** - * Join its group. - * @ignore - */ - override onActivateEvent(): void { - super.onActivateEvent(); - this._active = true; - this.joinGroup(); - } - - /** - * Leave its group. - * @ignore - */ - override onDeactivateEvent(): void { - this._active = false; - this.leaveGroup(); - super.onDeactivateEvent(); - } - /** * Join the group it names, if any; the newest checked member wins. * @ignore @@ -630,7 +628,7 @@ export default class UICheckbox extends UIBaseElement { members = new Set(); groups.set(this._group, members); } - members.add(this); + members.add(this._ref); this._joined = this._group; if (this._checked) { this.uncheckOthers(); @@ -647,7 +645,7 @@ export default class UICheckbox extends UIBaseElement { return; } const members = groups.get(this._joined); - members?.delete(this); + members?.delete(this._ref); if (members?.size === 0) { groups.delete(this._joined); } @@ -778,6 +776,7 @@ export default class UICheckbox extends UIBaseElement { * @ignore */ override onDestroyEvent() { + this.leaveGroup(); this._painter.dispose(); this._skin?.dispose(); this.label?.destroy(); diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js index 1363c1f0c..bb29885c7 100644 --- a/packages/melonjs/tests/ui-widgets.spec.js +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -2195,6 +2195,44 @@ describe("UI widgets", () => { expect(c.toggle()).toBe(true); }); + it("a radio group holds one choice before it is added to the scene", () => { + const easy = new UICheckbox(0, 0, { + group: "pre1", + label: "Easy", + checked: true, + }); + const hard = new UICheckbox(0, 40, { + group: "pre1", + label: "Hard", + checked: true, + }); + // the newest checked one wins, as it would in the scene + expect([easy.checked, hard.checked]).toEqual([false, true]); + expect(UICheckbox.checkedIn("pre1")).toBe(hard); + easy.checked = true; + expect(hard.checked).toBe(false); + expect(UICheckbox.checkedIn("pre1")).toBe(easy); + }); + + it("a checkbox leaves its group when destroyed, and stays in it while kept alive out of the scene", () => { + const panel = place( + new UIPanel(0, 0, { width: 200, height: 200, layout: "column" }), + ); + const a = panel.addChild( + new UICheckbox(0, 0, { group: "pre2", checked: true }), + ); + // taken out but kept alive: still the group's choice + panel.removeChildNow(a, true); + expect(UICheckbox.checkedIn("pre2")).toBe(a); + panel.addChild(a); + // removed for good: destroyed, and out of the group + app.world.removeChildNow(panel); + expect(UICheckbox.checkedIn("pre2")).toBeUndefined(); + const b = new UICheckbox(0, 0, { group: "pre2" }); + b.checked = true; + expect(UICheckbox.checkedIn("pre2")).toBe(b); + }); + it("scrollTo() from code stops a fling", () => { const panel = place( new UIPanel(100, 100, { From 8a47072a63a07a6e7c9287e1907c3c8e85e3e6dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 03:05:09 +0000 Subject: [PATCH 30/43] Fixes from review: canvas leave reaches every hovered region; UISpriteElement follows its frame - A pointerleave (the pointer leaving the canvas, or a finger lifting) now goes to every registered region holding that pointer, not only the candidates the spatial query finds near the exit point, which once the quadtree splits misses a hovered region in another quadrant - UISpriteElement follows its sprite whenever the image's size or anchor changes: through setRegion, setCurrentAnimation, sprite.setRegion() or an animation frame of another size. The element takes the new size and the image's anchor point stays in place on screen, as a Sprite's pos does, so the hit area matches the artwork Regression tests: a crowded world with a split index and the exit in another quadrant; a region of another size keeping the anchor; a frame set on the sprite itself followed on the next update and hit where the smaller image is. Each fails before the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/CHANGELOG.md | 2 +- packages/melonjs/src/input/pointerevent.ts | 24 ++++--- .../src/renderable/ui/uispriteelement.ts | 70 ++++++++++++++++--- packages/melonjs/tests/ui-widgets.spec.js | 60 ++++++++++++++++ 4 files changed, 136 insertions(+), 20 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 3db911f9a..8afb34e35 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -18,7 +18,7 @@ ### Changed - UI: `UITextButton` no longer writes the size and alignment it worked out back into the settings object, so one object can make several buttons - UI: `UITextButton#label` is the button's text, a `BitmapText` or, with a CSS font, a `Text` -- UI: `UISpriteElement` is now a `UIBaseElement` holding a `Sprite` (`sprite`), instead of a `Sprite` with its own copy of the pointer handling, so it has everything the other widgets have: `enabled` (drawn at half opacity when off), `cancelPress()` / `onCancel()`, `hoverScale` / `pressScale`, dragging and keyboard / gamepad focus. `(x, y)` is still where the image's anchor point goes, so existing layouts do not move, and `setRegion`, `addAnimation`, `setCurrentAnimation` and `isCurrentAnimation` are kept on the element. Other `Sprite` members are now on `element.sprite`; `pos` is the element's top left corner; setting `anchorPoint` after construction no longer moves the image (pass it in the settings); and a second sprite drawn in a `draw()` override becomes a child added with `addChild` +- UI: `UISpriteElement` is now a `UIBaseElement` holding a `Sprite` (`sprite`), instead of a `Sprite` with its own copy of the pointer handling, so it has everything the other widgets have: `enabled` (drawn at half opacity when off), `cancelPress()` / `onCancel()`, `hoverScale` / `pressScale`, dragging and keyboard / gamepad focus. `(x, y)` is still where the image's anchor point goes, so existing layouts do not move, and `setRegion`, `addAnimation`, `setCurrentAnimation` and `isCurrentAnimation` are kept on the element. As a sprite does, it follows its image's size, a region or animation frame of another size included, with the image's anchor point staying in place. Other `Sprite` members are now on `element.sprite`; `pos` is the element's top left corner; setting `anchorPoint` after construction no longer moves the image (pass it in the settings); and a second sprite drawn in a `draw()` override becomes a child added with `addChild` - UI: a press stops at a UI element by default: `UIBaseElement`'s `onClick()` and `onRelease()` now return `false`, as `UISpriteElement`'s already did, so a click on a button, checkbox or widget of your own no longer also reaches the game behind it. A subclass that relied on presses passing through returns `true` from them. Hover is unchanged, and a non-blocking `UIPanel` still lets presses on its background through ### Deprecated diff --git a/packages/melonjs/src/input/pointerevent.ts b/packages/melonjs/src/input/pointerevent.ts index 7c6313a6e..c17374f13 100644 --- a/packages/melonjs/src/input/pointerevent.ts +++ b/packages/melonjs/src/input/pointerevent.ts @@ -357,6 +357,19 @@ function dispatchEvent(normalizedEvents: Pointer[]): boolean { const isPointerMove = POINTER_MOVE.includes(pointer.type); + // the pointer left the canvas, or a finger lifted: every region it + // was over is left, wherever the browser reports the exit (at the + // canvas's edge, usually far from the region, so not through the + // spatial query below, which only finds regions near that point) + if (POINTER_LEAVE.includes(pointer.type)) { + for (const handlers of eventHandlers.values()) { + if (handlers.pointerId === pointer.pointerId) { + triggerEvent(handlers, pointer.type, pointer, null); + } + } + continue; + } + // trigger a global event for pointer move if (isPointerMove) { pointer.gameX = pointer.gameLocalX = pointer.gameScreenX; @@ -526,17 +539,6 @@ function dispatchEvent(normalizedEvents: Pointer[]): boolean { } break; - case POINTER_LEAVE[0]: - case POINTER_LEAVE[1]: - case POINTER_LEAVE[2]: - // the pointer left the canvas, or a finger lifted: every region - // it was over is left, wherever the browser reports the exit - // (at the canvas's edge, usually far from the region) - if (handlers.pointerId === pointer.pointerId) { - triggerEvent(handlers, pointer.type, pointer, null); - } - break; - case POINTER_CANCEL[0]: case POINTER_CANCEL[1]: case POINTER_CANCEL[2]: diff --git a/packages/melonjs/src/renderable/ui/uispriteelement.ts b/packages/melonjs/src/renderable/ui/uispriteelement.ts index c3e19b2b5..754eb66a2 100644 --- a/packages/melonjs/src/renderable/ui/uispriteelement.ts +++ b/packages/melonjs/src/renderable/ui/uispriteelement.ts @@ -104,6 +104,9 @@ export default class UISpriteElement extends UIBaseElement { */ readonly sprite: Sprite; + /** the sprite's size and anchor the element was last fitted to */ + private readonly _fitted = { w: 0, h: 0, ax: 0, ay: 0 }; + /** * @param x - where the image's anchor point goes (its centre by * default), in screen pixels @@ -139,6 +142,7 @@ export default class UISpriteElement extends UIBaseElement { sprite.pos.set(sprite.width * ax, sprite.height * ay); this.sprite = sprite; this.addChild(sprite); + this.rememberFit(); this.hoverScale = settings.hoverScale ?? 1; this.pressScale = settings.pressScale ?? 1; @@ -149,23 +153,72 @@ export default class UISpriteElement extends UIBaseElement { /** * Show another region of the texture: a shortcut for - * `sprite.setRegion(region)`, that also resizes the element to it. + * `sprite.setRegion(region)`. The element takes the region's size, with + * the image's anchor point staying where it was, as a sprite's does. * @param region - a region from {@link TextureAtlas#getRegion} * @returns this element * @example * icon.setRegion(atlas.getRegion("sound_off")); */ setRegion(region: object): this { + this.sprite.setRegion(region); + this.fitToSprite(); + this.isDirty = true; + return this; + } + + /** + * Follow the sprite after its frame, region or anchor changed: the + * element takes the image's size, and the image's anchor point stays + * where it was on screen, as a sprite's `pos` does. Runs every update, so + * a `sprite.setRegion()` or an animation with frames of different sizes + * is followed too. + * @ignore + */ + private fitToSprite() { const sprite = this.sprite; - sprite.setRegion(region); - if (sprite.width !== this.width || sprite.height !== this.height) { - const ax = sprite.anchorPoint.x; - const ay = sprite.anchorPoint.y; - this.resize(sprite.width, sprite.height); - sprite.pos.set(sprite.width * ax, sprite.height * ay); + const f = this._fitted; + const w = sprite.width; + const h = sprite.height; + const ax = sprite.anchorPoint.x; + const ay = sprite.anchorPoint.y; + if (w === f.w && h === f.h && ax === f.ax && ay === f.ay) { + return; } + // the image's anchor point, on screen, before the change + const px = this.pos.x + f.w * f.ax; + const py = this.pos.y + f.h * f.ay; + sprite.pos.set(w * ax, h * ay); + this.pos.x = px - w * ax; + this.pos.y = py - h * ay; + this.resize(w, h); + this.updateBounds(); + this.rememberFit(); this.isDirty = true; - return this; + } + + /** + * note the sprite's size and anchor the element now fits + * @ignore + */ + private rememberFit() { + const f = this._fitted; + f.w = this.sprite.width; + f.h = this.sprite.height; + f.ax = this.sprite.anchorPoint.x; + f.ay = this.sprite.anchorPoint.y; + } + + /** + * update the sprite, its animation included, and follow its frame + * @param dt - the time since the last update, in milliseconds + * @returns whether anything changed + * @ignore + */ + override update(dt: number): boolean { + const dirty = super.update(dt); + this.fitToSprite(); + return dirty; } /** @@ -193,6 +246,7 @@ export default class UISpriteElement extends UIBaseElement { */ setCurrentAnimation(name: string, resetAnim?: any): this { this.sprite.setCurrentAnimation(name, resetAnim); + this.fitToSprite(); this.isDirty = true; return this; } diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js index bb29885c7..93ad685d6 100644 --- a/packages/melonjs/tests/ui-widgets.spec.js +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -2139,6 +2139,45 @@ describe("UI widgets", () => { expect(renderer.globalAlpha()).toBe(1); }); + it("a region of another size keeps the image's anchor point in place", () => { + const atlas = makeAtlas(); + const icon = new UISpriteElement(100, 100, { + image: atlas, + region: "up", + }); + icon.setRegion(atlas.getRegion("down")); + // 16x12, still centred on (100, 100), as a sprite would be + const b = icon.getBounds(); + expect([b.left, b.top, b.width, b.height]).toEqual([92, 94, 16, 12]); + const sb = icon.sprite.getBounds(); + expect([sb.left, sb.top, sb.width, sb.height]).toEqual([ + 92, 94, 16, 12, + ]); + }); + + it("follows a frame set on the sprite itself, on the next update", () => { + const atlas = makeAtlas(); + const icon = place( + new UISpriteElement(100, 100, { image: atlas, region: "up" }), + ); + icon.sprite.setRegion(atlas.getRegion("down")); + icon.update(16); + const b = icon.getBounds(); + expect([b.left, b.top, b.width, b.height]).toEqual([92, 94, 16, 12]); + // and is hit where the smaller image is, not where the old one was + syncBroadphase(); + const onClick = vi.fn(); + icon.onClick = onClick; + pointer("pointermove", 100, 93); + pointer("pointerdown", 100, 93); + pointer("pointerup", 100, 93); + expect(onClick).not.toHaveBeenCalled(); + pointer("pointermove", 100, 100); + pointer("pointerdown", 100, 100); + expect(onClick).toHaveBeenCalledTimes(1); + pointer("pointerup", 100, 100); + }); + it("setRegion swaps the art and resizes the element", () => { const atlas = makeAtlas(); const icon = new UISpriteElement(100, 100, { @@ -2442,6 +2481,27 @@ describe("UI widgets", () => { expect(onOut).toHaveBeenCalledTimes(2); }); + it("leaving the canvas ends the hover of a button far from the exit, in a crowded world", () => { + // enough renderables for the spatial index to split into quadrants + for (let i = 0; i < 40; i++) { + const r = box(10, 10); + r.pos.set((i % 8) * 100 + 5, Math.floor(i / 8) * 110 + 5); + // a kinematic renderable is left out of the index + r.isKinematic = false; + place(r); + } + const b = place( + new UITextButton(650, 480, { text: "OK", width: 100, height: 40 }), + ); + const onOut = vi.spyOn(b, "onOut"); + pointer("pointermove", 700, 500); + expect(b.hover).toBe(true); + // the exit is reported at the far corner of the canvas + pointer("pointerleave", 0, 0); + expect(b.hover).toBe(false); + expect(onOut).toHaveBeenCalledTimes(1); + }); + it("the pointer leaving the canvas ends the hover, wherever it was", () => { const { b, onOut } = makeButton(); pointer("pointermove", 150, 120); From 6b9b22b843214aa0935871f78c5283362cfd9a41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 03:06:07 +0000 Subject: [PATCH 31/43] UISpriteElement: setCurrentAnimation passes preserve_dt on The kept shortcut dropped Sprite's third argument, so existing calls with it no longer compiled in TypeScript, and in JavaScript reset the frame timer instead of keeping it. The compatibility test checks the argument reaches the sprite, on the old element and the new. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/src/renderable/ui/uispriteelement.ts | 9 +++++++-- packages/melonjs/tests/ui-spriteelement.spec.js | 5 +++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/melonjs/src/renderable/ui/uispriteelement.ts b/packages/melonjs/src/renderable/ui/uispriteelement.ts index 754eb66a2..05e301d08 100644 --- a/packages/melonjs/src/renderable/ui/uispriteelement.ts +++ b/packages/melonjs/src/renderable/ui/uispriteelement.ts @@ -242,10 +242,15 @@ export default class UISpriteElement extends UIBaseElement { * {@link Sprite#setCurrentAnimation}. * @param name - the animation to play * @param resetAnim - what to do at the end, see {@link Sprite#setCurrentAnimation} + * @param preserve_dt - keep the time already spent on the current frame * @returns this element */ - setCurrentAnimation(name: string, resetAnim?: any): this { - this.sprite.setCurrentAnimation(name, resetAnim); + setCurrentAnimation( + name: string, + resetAnim?: any, + preserve_dt = false, + ): this { + this.sprite.setCurrentAnimation(name, resetAnim, preserve_dt); this.fitToSprite(); this.isDirty = true; return this; diff --git a/packages/melonjs/tests/ui-spriteelement.spec.js b/packages/melonjs/tests/ui-spriteelement.spec.js index c82083b4f..8ee3cb92b 100644 --- a/packages/melonjs/tests/ui-spriteelement.spec.js +++ b/packages/melonjs/tests/ui-spriteelement.spec.js @@ -373,6 +373,11 @@ describe("UISpriteElement", () => { expect(icon.isCurrentAnimation("idle")).toBe(false); icon.setCurrentAnimation("idle"); expect(icon.isCurrentAnimation("idle")).toBe(true); + // the third argument, preserve_dt, is passed on as a sprite takes it + const target = icon.sprite ?? icon; + const spy = vi.spyOn(target._frameAnim, "setCurrentAnimation"); + icon.setCurrentAnimation("blink", undefined, true); + expect(spy.mock.calls.at(-1)[2]).toBe(true); }); it("setOpacity and getOpacity", () => { From 582b27c1dbc4202b47c281b0260a5f56d5ec9b9f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 03:09:29 +0000 Subject: [PATCH 32/43] Input: a cancelled pointer, or a lifted legacy touch, ends the hover - pointercancel cleared the region's pointer and only called the cancel callback; the browser's leave after it then no longer matched, so a cancelled touch left a button highlighted. A cancel now also leaves the region (hover cleared, onOut called). - A legacy touchend kept the touch's id like a mouse release does, but touch events send no leave after it, so the region stayed occupied and later mouse hover could not enter it. A touchend now leaves the region, as a lifted finger hovers nothing. Test for the cancel; the legacy touch path is only used where pointer events are missing, so the browser tests cannot reach it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/src/input/pointerevent.ts | 37 ++++++++++++++++++---- packages/melonjs/tests/ui-widgets.spec.js | 23 ++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/melonjs/src/input/pointerevent.ts b/packages/melonjs/src/input/pointerevent.ts index c17374f13..8165b529c 100644 --- a/packages/melonjs/src/input/pointerevent.ts +++ b/packages/melonjs/src/input/pointerevent.ts @@ -527,12 +527,27 @@ function dispatchEvent(normalizedEvents: Pointer[]): boolean { // pointer defined & inside of bounds: trigger the POINTER_UP callback. // The region keeps the pointer: the press is over, but the // pointer is still over it, so moving away still leaves it and - // moving on over it does not enter it again. + // moving on over it does not enter it again. A legacy + // `touchend` is the exception: a lifted finger is over nothing, + // and touch events send no leave after it, so it leaves here. if (handlers.pointerId === pointer.pointerId && eventInBounds) { + const lifted = pointer.type === POINTER_UP[2]; // trigger the corresponding callback - if ( - triggerEvent(handlers, pointer.type, pointer, pointer.pointerId) - ) { + const stop = triggerEvent( + handlers, + pointer.type, + pointer, + lifted ? null : pointer.pointerId, + ); + if (lifted) { + triggerEvent( + handlers, + findActiveEvent(activeEventList, POINTER_LEAVE), + pointer, + null, + ); + } + if (stop) { handled = true; break; } @@ -543,10 +558,18 @@ function dispatchEvent(normalizedEvents: Pointer[]): boolean { case POINTER_CANCEL[1]: case POINTER_CANCEL[2]: case POINTER_CANCEL[3]: - // pointer defined: trigger the POINTER_CANCEL callback + // pointer defined: trigger the POINTER_CANCEL callback, then + // leave: a cancelled pointer is gone, and the region forgets it + // here, so the browser's own leave after it would not reach it if (handlers.pointerId === pointer.pointerId) { - // trigger the corresponding callback - if (triggerEvent(handlers, pointer.type, pointer, null)) { + const stop = triggerEvent(handlers, pointer.type, pointer, null); + triggerEvent( + handlers, + findActiveEvent(activeEventList, POINTER_LEAVE), + pointer, + null, + ); + if (stop) { handled = true; break; } diff --git a/packages/melonjs/tests/ui-widgets.spec.js b/packages/melonjs/tests/ui-widgets.spec.js index 93ad685d6..18098c367 100644 --- a/packages/melonjs/tests/ui-widgets.spec.js +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -2502,6 +2502,29 @@ describe("UI widgets", () => { expect(onOut).toHaveBeenCalledTimes(1); }); + it("a cancelled pointer ends the press and the hover", () => { + const { b, onOut } = makeButton(); + const onRelease = vi.spyOn(b, "onRelease"); + pointer("pointerenter", 150, 120, 4); + pointer("pointerdown", 150, 120, 4); + expect(b.hover).toBe(true); + app.renderer.getCanvas().dispatchEvent( + new PointerEvent("pointercancel", { + ...at(150, 120), + pointerId: 4, + isPrimary: true, + bubbles: true, + }), + ); + expect(b.released).toBe(true); + expect(onRelease).toHaveBeenCalledTimes(1); + expect(b.hover).toBe(false); + expect(onOut).toHaveBeenCalledTimes(1); + // the browser's own leave after it changes nothing more + pointer("pointerleave", 150, 120, 4); + expect(onOut).toHaveBeenCalledTimes(1); + }); + it("the pointer leaving the canvas ends the hover, wherever it was", () => { const { b, onOut } = makeButton(); pointer("pointermove", 150, 120); From c70fe49b03b386d44c9bb09fd6bbad36de654f7c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 03:09:50 +0000 Subject: [PATCH 33/43] Changelog: a cancelled pointer ends the hover too Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 8afb34e35..d279866f3 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -30,7 +30,7 @@ - UI: `UIPanel#scrollTo()` (and `scrollToChild`, `scrollX` / `scrollY`) stops a fling that is still coasting, instead of the fling carrying on from the new offset - UI: a `UISlider` lays its texts out again when they change: a vertical one widens for a longer value text (a new `valueFormat`, `min`, `max` or `step`), and on a horizontal one the value moves below the track when it would run into the caption, instead of overlapping it. `labelText` sets the caption later - Input: a region stays hovered after a click while the pointer is still on it. A release used to forget the pointer, so leaving straight after a click never called `onOut` (a button stayed lit), and moving on over it called `onOver` again -- Input: the pointer leaving the canvas ends the hover of whatever it was over, not only of a region at the point where it left +- Input: the pointer leaving the canvas ends the hover of whatever it was over, not only of a region at the point where it left; a cancelled pointer, or a finger lifted on the legacy touch-event path, ends it too - Input: a child cropped out of view by a container with `clipping`, or scrolled out of a `UIPanel`, no longer gets pointer events where it is not drawn - UI: a `UITextButton` moved after it was built draws its background where it is, not where it was first put - UI: a blocking `UIPanel` stays opaque to hover for as long as the pointer is on it, not only on the frame the pointer enters From 6d4a96ad6a467abb1888155c1be4cbc2d73fd318 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 05:13:35 +0000 Subject: [PATCH 34/43] Rendering: a clip inside a clip is the overlap of the two on WebGL and WebGPU (#1816) On WebGL and WebGPU a clipRect made while another clip was active replaced it, where Canvas's context.clip() intersects with it. A clipping container nested in another, or a scrolling UIPanel in a clipping parent, drew past the outer clip wherever it stuck out of it. clipRect now intersects the new screen box with the active scissor on both GPU renderers; no overlap gives an empty scissor (nothing draws). save() / restore() already store and re-apply the scissor, so the outer clip comes back as before. A clip under a non-finite transform (a ColorLayer's infinite size) keeps the active clip instead of dropping it, as a clip can only narrow. enableScissor still sets the box outright. The clipping example gets a third panel: an inner clipping container sticking out of the outer one, its fill shown only where both overlap. Tests: nested clipping containers read back in pixels on Canvas (the reference) and WebGL, the scissor overlap, restore and an empty overlap on WebGL, and WebGPU's clipRect on a stand-in without a device; they fail before the fix. A save/restore test that pinned the old replacing behaviour now expects the overlap. The UIPanel docs and the skill drop the limitation they described. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- .../src/examples/clipping/ExampleClipping.tsx | 45 +++- packages/melonjs/CHANGELOG.md | 1 + .../skills/melonjs-ui-and-text/SKILL.md | 4 +- packages/melonjs/src/renderable/ui/uipanel.ts | 6 +- .../melonjs/src/video/webgl/webgl_renderer.js | 40 +-- .../src/video/webgpu/webgpu_renderer.js | 27 +- packages/melonjs/tests/nested-clip.spec.js | 232 ++++++++++++++++++ .../tests/renderer_save_restore.spec.js | 5 +- 8 files changed, 327 insertions(+), 33 deletions(-) create mode 100644 packages/melonjs/tests/nested-clip.spec.js diff --git a/packages/examples/src/examples/clipping/ExampleClipping.tsx b/packages/examples/src/examples/clipping/ExampleClipping.tsx index 013de5ffe..13c2e2805 100644 --- a/packages/examples/src/examples/clipping/ExampleClipping.tsx +++ b/packages/examples/src/examples/clipping/ExampleClipping.tsx @@ -21,8 +21,10 @@ import { createExampleComponent } from "../utils"; * `Container` clipping demo over a `ColorLayer` backdrop. * * Left: three nested clipping containers form concentric color rings. - * Right: a wrapper with a sinusoidal scale animates a clipping + * Middle: a wrapper with a sinusoidal scale animates a clipping * container; the overflowing fill stays cropped to the scaled bounds. + * Right: an inner clipping container sticks out of the outer one; its + * fill shows only where the two clips overlap, on every renderer. */ class OverflowingRect extends Renderable { @@ -88,10 +90,10 @@ class PartialColorLayer extends ColorLayer { class ClipOutline extends Renderable { private color = new Color(); - constructor(x: number, y: number, w: number, h: number) { + constructor(x: number, y: number, w: number, h: number, color = "#00ff88") { super(x, y, w, h); this.anchorPoint.set(0, 0); - this.color.parseCSS("#00ff88"); + this.color.parseCSS(color); } draw(renderer: Parameters[0]) { @@ -192,13 +194,48 @@ class PlayScreen extends Stage { 4, ); + // RIGHT — an inner clipping container that sticks out of the outer + // one, past its right and bottom edges. A clip inside a clip is the + // overlap of the two, so the purple fill shows only where both + // cover; the part of the inner clip outside the outer one (inside + // the pink outline, outside the green) stays empty. + const OUT_X = 500; + const OUT_Y = 80; + const OUT_W = 160; + const OUT_H = 160; + const IN_X = 70; + const IN_Y = 50; + const IN_W = 140; + const IN_H = 140; + const outer = new Container(OUT_X, OUT_Y, OUT_W, OUT_H); + outer.clipping = true; + const inner = new Container(IN_X, IN_Y, IN_W, IN_H); + inner.clipping = true; + inner.addChild(new OverflowingRect("#9b59b6")); + outer.addChild(inner); + game.world.addChild(outer, 2); + game.world.addChild(new ClipOutline(OUT_X, OUT_Y, OUT_W, OUT_H), 3); + game.world.addChild( + new ClipOutline(OUT_X + IN_X, OUT_Y + IN_Y, IN_W, IN_H, "#ff4d8d"), + 3, + ); + game.world.addChild( + new Text(OUT_X, 60, { + font: "monospace", + size: 12, + fillStyle: "#00ff88", + text: "Overlapping nested clips", + }), + 4, + ); + game.world.addChild( new Text(game.viewport.width / 2, game.viewport.height - 16, { font: "monospace", size: 11, fillStyle: "#cccccc", textAlign: "center", - text: "Green outlines = static clip bounds. Lighter band = partial ColorLayer.", + text: "Green = clip bounds. Pink = inner clip sticking out: nothing past green. Band = partial ColorLayer.", }), 4, ); diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index d279866f3..5712fead3 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -25,6 +25,7 @@ - UI: `UITextButton#bitmapText`, for `label` ### Fixed +- Rendering: on WebGL and WebGPU, a clip inside a clip is the overlap of the two, as it already was on Canvas. A clipping container nested in another, or a scrolling `UIPanel` in a clipping parent, drew past the outer clip wherever it stuck out of it, because the inner `clipRect` replaced the outer scissor ([#1816](https://github.com/melonjs/melonJS/issues/1816)). The clipping example shows the case - UI: a radio group holds one choice from when its checkboxes are made, not only once they are in the scene: before, several could be checked at once until they were added, and `UICheckbox.checkedIn()` answered `undefined`. A checkbox leaves its group when destroyed; groups hold their members weakly, so one dropped without being destroyed can still be collected - UI: `UICheckbox#toggle()` on a checked radio button leaves it checked, as a click does, instead of leaving its group with nothing checked - UI: `UIPanel#scrollTo()` (and `scrollToChild`, `scrollX` / `scrollY`) stops a fling that is still coasting, instead of the fling carrying on from the new offset diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index 0e1199755..e7aec4b66 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -608,8 +608,8 @@ What to know: your own that is dragged too, a knob or a colour picker, sets `keepsDrag = true` to keep its drags. - Children scrolled out of view do not get pointer events. A scrolling panel - inside another scrolls alone, but nesting is not supported beyond that: on - WebGL the inner one is cropped to its own area only, not to the outer one. + inside another scrolls alone, and is cropped to both: a clip inside a clip + is the overlap of the two, on Canvas, WebGL and WebGPU alike. - A draggable panel is moved by its title or its frame. A press on a widget on it is the widget's. diff --git a/packages/melonjs/src/renderable/ui/uipanel.ts b/packages/melonjs/src/renderable/ui/uipanel.ts index 2642421b8..de93eca46 100644 --- a/packages/melonjs/src/renderable/ui/uipanel.ts +++ b/packages/melonjs/src/renderable/ui/uipanel.ts @@ -324,9 +324,9 @@ const MIN_SPEED = 0.02; * coordinates, over the scene. With a `Camera3d`, floating children are * sorted the other way round: the SMALLER depth draws on top, so add a * dialog at a depth of 0 or 1, not 9000. - * - On WebGL and WebGPU, a scrolling panel inside another clipping container - * (such as another scrolling panel) is cropped to its own area only, not - * to the outer container's. + * - A scrolling panel inside another clipping container (such as another + * scrolling panel) is cropped to both: its rows show only where the two + * overlap, on every renderer. * @category UI * @example * // a drawn dialog, with no assets diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index d1f2b49e2..dbea41ac8 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -4421,16 +4421,10 @@ export default class WebGLRenderer extends Renderer { // `ColorLayer`-style hosts have `Infinity` dimensions + a 0.5 // anchor, leaving `currentTransform.tx/ty = -Infinity`. Running // transform math on that produces NaN corners and a broken - // `gl.scissor`. Treat a non-finite transform as "no clip". + // `gl.scissor`. Treat a non-finite transform as "no further clip": + // the clip already active, if any, stays, as a clip can only narrow. const m = this.currentTransform; if (!Number.isFinite(m.tx) || !Number.isFinite(m.ty)) { - if (this._scissorActive) { - // drain pending vertices under the active scissor first; - // otherwise they would flush later under no scissor. - this.flush(); - gl.disable(gl.SCISSOR_TEST); - this._scissorActive = false; - } return; } @@ -4448,15 +4442,34 @@ export default class WebGLRenderer extends Renderer { // nearest, as any draw is (see _pixelSnapBegin). Otherwise, or turned, // the box covering every pixel the clip touches const snap = this._snapsToPixels(); - const sx = snap ? Math.round(aabb.min.x) : Math.floor(aabb.min.x); - const sy = snap ? Math.round(aabb.min.y) : Math.floor(aabb.min.y); - const sw = snap ? Math.round(aabb.max.x) - sx : Math.ceil(aabb.max.x - sx); - const sh = snap ? Math.round(aabb.max.y) - sy : Math.ceil(aabb.max.y - sy); + let sx = snap ? Math.round(aabb.min.x) : Math.floor(aabb.min.x); + let sy = snap ? Math.round(aabb.min.y) : Math.floor(aabb.min.y); + let sw = snap ? Math.round(aabb.max.x) - sx : Math.ceil(aabb.max.x - sx); + let sh = snap ? Math.round(aabb.max.y) - sy : Math.ceil(aabb.max.y - sy); + + // A clip inside a clip is the overlap of the two, as Canvas's + // `context.clip()` is: a clipping container nested in another, or + // a scrolling panel in a clipping parent, stays inside both. + // `save()` / `restore()` bring the outer clip back. + const cs = this.currentScissor; + if (this._scissorActive) { + const left = Math.max(sx, cs[0]); + const top = Math.max(sy, cs[1]); + const right = Math.min(sx + sw, cs[0] + cs[2]); + const bottom = Math.min(sy + sh, cs[1] + cs[3]); + sx = left; + sy = top; + // no overlap: an empty scissor, so nothing draws + sw = Math.max(0, right - left); + sh = Math.max(0, bottom - top); + } // If the resulting screen AABB covers the whole canvas, treat as // no-clip and disable the scissor. Caller intent: "clip to the // full viewport" → no scissor needed. Works regardless of the - // transform that produced the AABB. + // transform that produced the AABB. (With a clip already active + // the overlap is inside it, so this only matches when that clip + // covered the canvas too.) if ( sx <= 0 && sy <= 0 && @@ -4473,7 +4486,6 @@ export default class WebGLRenderer extends Renderer { // `currentScissor` now stores screen-space coords directly so // `restore()` can re-apply without re-running the transform math. - const cs = this.currentScissor; if ( this._scissorActive && cs[0] === sx && diff --git a/packages/melonjs/src/video/webgpu/webgpu_renderer.js b/packages/melonjs/src/video/webgpu/webgpu_renderer.js index 4187d9a64..e32c467ac 100644 --- a/packages/melonjs/src/video/webgpu/webgpu_renderer.js +++ b/packages/melonjs/src/video/webgpu/webgpu_renderer.js @@ -3495,15 +3495,10 @@ export default class WebGPURenderer extends Renderer { */ clipRect(x, y, width, height) { const canvas = this.getCanvas(); - // treat a non-finite transform as "no clip" + // treat a non-finite transform as "no further clip": the clip + // already active, if any, stays, as a clip can only narrow const m = this.currentTransform; if (!Number.isFinite(m.tx) || !Number.isFinite(m.ty)) { - if (this.scissorActive) { - // drain pending vertices under the active scissor first - this.currentBatcher?.flush(); - this.scissorActive = false; - this.applyScissor(); - } return; } @@ -3522,7 +3517,23 @@ export default class WebGPURenderer extends Renderer { let sw = snap ? Math.round(aabb.max.x) - sx : Math.ceil(aabb.max.x - sx); let sh = snap ? Math.round(aabb.max.y) - sy : Math.ceil(aabb.max.y - sy); - // full-canvas coverage → caller intent is "no clip" + // a clip inside a clip is the overlap of the two, as Canvas's + // `context.clip()` is; `save()` / `restore()` bring the outer back + if (this.scissorActive) { + const cs = this.currentScissor; + const left = Math.max(sx, cs[0]); + const top = Math.max(sy, cs[1]); + const right = Math.min(sx + sw, cs[0] + cs[2]); + const bottom = Math.min(sy + sh, cs[1] + cs[3]); + sx = left; + sy = top; + // no overlap: an empty scissor, so nothing draws + sw = Math.max(0, right - left); + sh = Math.max(0, bottom - top); + } + + // full-canvas coverage → caller intent is "no clip" (with a clip + // already active, only when that clip covered the canvas too) if ( sx <= 0 && sy <= 0 && diff --git a/packages/melonjs/tests/nested-clip.spec.js b/packages/melonjs/tests/nested-clip.spec.js new file mode 100644 index 000000000..7f87f4d5d --- /dev/null +++ b/packages/melonjs/tests/nested-clip.spec.js @@ -0,0 +1,232 @@ +/** + * A clip inside a clip is the overlap of the two, on every renderer (#1816). + * + * Canvas's `context.clip()` intersects with the clip already set. The GPU + * renderers used to replace it with the new rectangle, so a clipping + * container nested in another drew past the outer one wherever it stuck + * out of it: a scrolling `UIPanel` in a clipping parent showed its rows + * outside the parent. + * + * The scene: an outer clipping container over the left half of the screen, + * holding an inner clipping container that starts at its middle and runs + * past its right edge, filled white over black. White should show only + * where both clips cover, from x = 16 to x = 32. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + Application, + Bounds, + boot, + Container, + Matrix2d, + Renderable, + video, +} from "../src/index.js"; +import WebGLRenderer from "../src/video/webgl/webgl_renderer.js"; +import WebGPURenderer from "../src/video/webgpu/webgpu_renderer.js"; +import { readPixel } from "./helpers/pixels.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, +} from "./helpers/webgl-context.js"; + +const SIZE = 64; + +/** a white square over the whole screen, from its parent's corner */ +class Fill extends Renderable { + constructor() { + super(0, 0, SIZE, SIZE); + this.anchorPoint.set(0, 0); + this.inViewport = true; + } + draw(renderer) { + renderer.setColor("#ffffff"); + renderer.fillRect(0, 0, SIZE, SIZE); + } +} + +/** the outer clip (0..32) holding the inner one (16..48) holding the fill */ +const nested = () => { + const outer = new Container(0, 0, SIZE / 2, SIZE); + outer.clipping = true; + const inner = new Container(SIZE / 4, 0, SIZE / 2, SIZE); + inner.clipping = true; + inner.inViewport = true; + inner.addChild(new Fill()); + outer.addChild(inner); + return outer; +}; + +/** draw a renderable over black, as the world draws its children */ +const drawOverBlack = (renderer, renderable) => { + renderer.backgroundColor.parseCSS("#000000"); + renderer.clear(); + renderer.save(); + renderer.resetTransform(); + renderable.preDraw(renderer); + renderable.draw(renderer); + renderable.postDraw(renderer); + renderer.restore(); + renderer.flush(); +}; + +/** the red channel across the middle row: outer only, both, inner only, neither */ +const across = async (renderer) => { + const out = []; + for (const x of [8, 24, 40, 56]) { + out.push((await readPixel(renderer, x, SIZE / 2))[0]); + } + return out; +}; + +describe("nested clipping containers draw inside both clips", () => { + describe("Canvas", () => { + let app; + beforeAll(async () => { + boot(); + app = new Application(SIZE, SIZE, { + parent: "screen", + renderer: video.CANVAS, + consoleHeader: false, + }); + await app.init(); + }); + afterAll(() => { + app?.destroy(); + }); + + it("white only where both clips cover", async () => { + drawOverBlack(app.renderer, nested()); + expect(await across(app.renderer)).toEqual([0, 255, 0, 0]); + }); + }); + + describe("WebGL", () => { + let renderer; + beforeAll(async () => { + boot(); + renderer = await getWebGLRenderer(SIZE, SIZE); + if (renderer instanceof WebGLRenderer) { + renderer.projectionMatrix.ortho(0, SIZE, SIZE, 0, -1000, 1000); + renderer.currentBatcher.setProjection(renderer.projectionMatrix); + } else { + renderer = undefined; + } + }); + afterAll(() => { + releaseWebGLRenderer(); + }); + + it("white only where both clips cover", async (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + return; + } + drawOverBlack(renderer, nested()); + expect(await across(renderer)).toEqual([0, 255, 0, 0]); + }); + + it("the scissor is the overlap, and restore() brings the outer clip back", (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + return; + } + renderer.save(); + renderer.resetTransform(); + renderer.clipRect(0, 0, 32, 64); + renderer.save(); + renderer.clipRect(16, 8, 32, 32); + expect(Array.from(renderer.currentScissor)).toEqual([16, 8, 16, 32]); + // clips that do not overlap leave nothing to draw in + renderer.save(); + renderer.clipRect(40, 0, 8, 8); + expect(renderer._scissorActive).toBe(true); + expect(renderer.currentScissor[2] * renderer.currentScissor[3]).toBe(0); + renderer.restore(); + renderer.restore(); + expect(Array.from(renderer.currentScissor)).toEqual([0, 0, 32, 64]); + renderer.restore(); + expect(renderer._scissorActive).toBe(false); + }); + + it("a clip under a non-finite transform keeps the clip already set", (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + return; + } + renderer.save(); + renderer.resetTransform(); + renderer.clipRect(0, 0, 32, 64); + renderer.save(); + // as a ColorLayer's infinite size and centred anchor leave it + renderer.translate(-Infinity, -Infinity); + renderer.clipRect(0, 0, 10, 10); + expect(renderer._scissorActive).toBe(true); + expect(Array.from(renderer.currentScissor)).toEqual([0, 0, 32, 64]); + renderer.restore(); + renderer.restore(); + }); + }); + + describe("WebGPU (clipRect, without a device)", () => { + /** + * The renderer's own `clipRect` and `setScissorRect`, on a stand-in + * holding only what they read: WebGPU is missing from most test + * environments, and the scissor math is all this fix touches. + */ + const host = () => { + return { + getCanvas: () => { + return { width: SIZE, height: SIZE }; + }, + currentTransform: new Matrix2d(), + clipAABB: new Bounds(), + currentScissor: new Int32Array(4), + scissorActive: false, + currentBatcher: { flush() {} }, + applyScissor() {}, + _snapsToPixels: () => { + return true; + }, + setScissorRect: WebGPURenderer.prototype.setScissorRect, + }; + }; + const clip = (h, x, y, w, hh) => { + WebGPURenderer.prototype.clipRect.call(h, x, y, w, hh); + }; + + it("a clip inside a clip is the overlap", () => { + const h = host(); + clip(h, 0, 0, 32, 64); + expect(Array.from(h.currentScissor)).toEqual([0, 0, 32, 64]); + clip(h, 16, 8, 32, 32); + expect(h.scissorActive).toBe(true); + expect(Array.from(h.currentScissor)).toEqual([16, 8, 16, 32]); + }); + + it("clips that do not overlap leave an empty scissor", () => { + const h = host(); + clip(h, 0, 0, 16, 16); + clip(h, 32, 32, 16, 16); + expect(h.scissorActive).toBe(true); + expect(h.currentScissor[2] * h.currentScissor[3]).toBe(0); + }); + + it("a full-canvas clip inside a clip keeps the clip", () => { + const h = host(); + clip(h, 8, 8, 16, 16); + clip(h, 0, 0, SIZE, SIZE); + expect(h.scissorActive).toBe(true); + expect(Array.from(h.currentScissor)).toEqual([8, 8, 16, 16]); + }); + + it("a clip under a non-finite transform keeps the clip already set", () => { + const h = host(); + clip(h, 0, 0, 32, 64); + h.currentTransform.translate(-Infinity, -Infinity); + clip(h, 0, 0, 10, 10); + expect(h.scissorActive).toBe(true); + expect(Array.from(h.currentScissor)).toEqual([0, 0, 32, 64]); + }); + }); +}); diff --git a/packages/melonjs/tests/renderer_save_restore.spec.js b/packages/melonjs/tests/renderer_save_restore.spec.js index f733c85c8..91d42bcd0 100644 --- a/packages/melonjs/tests/renderer_save_restore.spec.js +++ b/packages/melonjs/tests/renderer_save_restore.spec.js @@ -231,9 +231,10 @@ describe("Renderer save/restore", () => { const scissorBefore = Array.from(glRenderer.currentScissor); glRenderer.save(); + // partly outside the first clip: the scissor is the overlap, + // (50, 60) to (110, 100), as Canvas's clip() would be glRenderer.clipRect(10, 20, 100, 80); - expect(glRenderer.currentScissor[0]).toBe(10); - expect(glRenderer.currentScissor[1]).toBe(20); + expect(Array.from(glRenderer.currentScissor)).toEqual([50, 60, 60, 40]); glRenderer.restore(); expect(glRenderer.currentScissor[0]).toBe(scissorBefore[0]); From c0c2258f156f0a7626f27361f70038b23bf00d8f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 05:40:57 +0000 Subject: [PATCH 35/43] Camera: a fade always calls onComplete before it is removed (#1810) FadeEffect counted itself complete as soon as its colour's alpha read as the target. The alpha is stored as a 32-bit float, so on some durations a value one step short of the end already rounds to it; the camera then destroyed the effect, which stopped the tween before it called onComplete. fadeIn / fadeOut callbacks, the fade state transition and a fading level trigger could all hang on it. The fade is now complete when its tween has finished running, after the tween has called onComplete. Tests: a sweep of fade durations at 60 fps, both directions, frame by frame as the loop runs it, checks onComplete fires exactly once before the effect goes (10 durations missed it before the fix); and a fade whose alpha already reads as the target stays until its tween ends. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/CHANGELOG.md | 1 + .../melonjs/src/camera/effects/fade_effect.ts | 12 +++-- packages/melonjs/tests/camera.spec.js | 53 ++++++++++++++++++- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 5712fead3..45c703f30 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -25,6 +25,7 @@ - UI: `UITextButton#bitmapText`, for `label` ### Fixed +- Camera: `fadeIn()` / `fadeOut()` (and a `FadeEffect`, so the fade state transition and a fading level trigger too) always call `onComplete`. The fade counted as done once its colour's alpha read as the target, which a 32-bit float can do one step before the tween ends; the camera then removed the effect, stopping the tween before it called `onComplete`, so code waiting on it stayed stuck. The fade now ends when its tween does ([#1810](https://github.com/melonjs/melonJS/issues/1810)) - Rendering: on WebGL and WebGPU, a clip inside a clip is the overlap of the two, as it already was on Canvas. A clipping container nested in another, or a scrolling `UIPanel` in a clipping parent, drew past the outer clip wherever it stuck out of it, because the inner `clipRect` replaced the outer scissor ([#1816](https://github.com/melonjs/melonJS/issues/1816)). The clipping example shows the case - UI: a radio group holds one choice from when its checkboxes are made, not only once they are in the scene: before, several could be checked at once until they were added, and `UICheckbox.checkedIn()` answered `undefined`. A checkbox leaves its group when destroyed; groups hold their members weakly, so one dropped without being destroyed can still be collected - UI: `UICheckbox#toggle()` on a checked radio button leaves it checked, as a click does, instead of leaving its group with nothing checked diff --git a/packages/melonjs/src/camera/effects/fade_effect.ts b/packages/melonjs/src/camera/effects/fade_effect.ts index ccd114cd5..c3f7734cc 100644 --- a/packages/melonjs/src/camera/effects/fade_effect.ts +++ b/packages/melonjs/src/camera/effects/fade_effect.ts @@ -41,7 +41,7 @@ export default class FadeEffect extends CameraEffect { direction: "in" | "out"; /** - * target alpha value for completion check + * the alpha the fade ends on * @ignore * @internal */ @@ -103,10 +103,12 @@ export default class FadeEffect extends CameraEffect { } override update(): void { - // check if the tween has reached its target - if (this.direction === "in" && this.color.alpha >= this._targetAlpha) { - this.isComplete = true; - } else if (this.direction === "out" && this.color.alpha <= 0.0) { + // Over when the tween is: it has run its course and called + // `onComplete`. Not when the alpha reads as the target: the colour + // stores it as a 32-bit float, so a value one step short of the end + // can already round to it, and the camera then destroyed the effect, + // stopping the tween before it ever called `onComplete` (#1810). + if (this.tween !== undefined && !this.tween._isRunning) { this.isComplete = true; } } diff --git a/packages/melonjs/tests/camera.spec.js b/packages/melonjs/tests/camera.spec.js index f63dd8846..d3f955eb1 100644 --- a/packages/melonjs/tests/camera.spec.js +++ b/packages/melonjs/tests/camera.spec.js @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { Application, boot, @@ -1473,6 +1473,57 @@ describe("Camera2d", () => { expect(called).toEqual(true); }); + it("a fade calls onComplete before the camera removes it, whatever the float rounding (#1810)", () => { + // frame by frame as the game loop runs it: the tween steps, then the + // camera updates its effects. The alpha is stored as a 32-bit float, + // so on some durations it reads as the target one step before the + // tween ends; the effect must still wait for the tween + const frame = 1000 / 60; + const missed = []; + for (const direction of ["in", "out"]) { + for (let duration = 100; duration <= 1000; duration += 25) { + const { camera } = setup(); + const onComplete = vi.fn(); + const fade = new FadeEffect(camera, { + color: "#ffffff", + duration, + direction, + onComplete, + }); + camera.addCameraEffect(fade); + const tween = fade.tween; + for (let i = 0; i < 200 && camera.getCameraEffect(FadeEffect); i++) { + tween.update(frame); + camera.update(frame); + } + expect(camera.getCameraEffect(FadeEffect)).toBeUndefined(); + if (onComplete.mock.calls.length !== 1) { + missed.push(`${direction} ${duration}ms`); + } + } + } + expect(missed).toEqual([]); + }); + + it("a fade stays until its tween ends, even once its alpha reads as the target", () => { + const { camera } = setup(); + const onComplete = vi.fn(); + const fade = camera.addCameraEffect( + new FadeEffect(camera, { color: "#000", duration: 300, onComplete }), + ); + // the colour already at the target, the tween not yet over + fade.color.alpha = 1; + camera.update(16); + expect(camera.getCameraEffect(FadeEffect)).toBe(fade); + expect(onComplete).not.toHaveBeenCalled(); + // to its end exactly: a finished tween leaves the game loop, so + // it is not stepped again + advanceTween(fade.tween, 300); + camera.update(16); + expect(onComplete).toHaveBeenCalledTimes(1); + expect(camera.getCameraEffect(FadeEffect)).toBeUndefined(); + }); + it("fadeIn() convenience method should add a FadeEffect", () => { const { camera } = setup(); camera.fadeIn("#000", 500); From 927b0c93fdc8e5fb0fd18415120d00d9c0805d04 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 05:43:05 +0000 Subject: [PATCH 36/43] Tests: fadeIn / fadeOut onComplete through the game loop (#1810) viewport.fadeIn() / fadeOut() driven the way a game runs them: each frame a TICK, so the application updates (its stage removes completed camera effects) and then the tween steps. A sweep of durations at 60 fps checks onComplete fires once before the fade goes; a chain of scene changes on fadeIn callbacks, as the Alex 4 outro does, reaches its end; and the fade stays on the camera until its tween has called onComplete. Against the code before the fix, fadeIn missed its callback at 600, 800, 850, 900 and 950 ms, and the chain stopped after the first scene. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- .../melonjs/tests/fade-oncomplete.spec.js | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 packages/melonjs/tests/fade-oncomplete.spec.js diff --git a/packages/melonjs/tests/fade-oncomplete.spec.js b/packages/melonjs/tests/fade-oncomplete.spec.js new file mode 100644 index 000000000..eecfe8712 --- /dev/null +++ b/packages/melonjs/tests/fade-oncomplete.spec.js @@ -0,0 +1,117 @@ +/** + * `viewport.fadeIn()` / `fadeOut()` always call `onComplete` (#1810). + * + * Driven the way a game runs: each frame is a `TICK`, as the browser loop + * emits it. The application updates first (its stage updates the camera, + * which removes the effects that are complete), then the fade's tween steps. + * + * A fade used to count as complete once its colour's alpha read as the + * target. The alpha is a 32-bit float, so on some durations it rounds to + * the target one step before the tween ends; the camera then removed the + * fade and stopped the tween before it called `onComplete`. Code waiting on + * the callback, a scene change say, never ran. + */ +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { Application, boot, event, FadeEffect, video } from "../src/index.js"; + +const FRAME = 1000 / 60; + +let app; +// the clock the frames are stamped with, moved on by every frame +let now; + +/** run one frame of the game loop at 60 fps */ +const tick = () => { + now += FRAME; + event.emit(event.TICK, now); +}; + +/** + * run frames until the camera has no fade left, or a limit + * @returns the number of frames run + */ +const runFade = () => { + let frames = 0; + while (app.viewport.getCameraEffect(FadeEffect) && frames < 400) { + tick(); + frames++; + } + return frames; +}; + +describe("viewport.fadeIn() / fadeOut() call onComplete (#1810)", () => { + beforeAll(async () => { + boot(); + app = new Application(320, 240, { + parent: "screen", + renderer: video.CANVAS, + consoleHeader: false, + }); + await app.init(); + now = globalThis.performance.now(); + }); + + it("fadeIn calls onComplete once, then the fade is gone, at every duration", () => { + const missed = []; + for (let duration = 100; duration <= 1000; duration += 25) { + const onComplete = vi.fn(); + app.viewport.fadeIn("#000000", duration, onComplete); + runFade(); + expect(app.viewport.getCameraEffect(FadeEffect)).toBeUndefined(); + if (onComplete.mock.calls.length !== 1) { + missed.push(duration); + } + } + expect(missed).toEqual([]); + }); + + it("fadeOut calls onComplete once, then the fade is gone, at every duration", () => { + const missed = []; + for (let duration = 100; duration <= 1000; duration += 25) { + const onComplete = vi.fn(); + app.viewport.fadeOut("#000000", duration, onComplete); + runFade(); + expect(app.viewport.getCameraEffect(FadeEffect)).toBeUndefined(); + if (onComplete.mock.calls.length !== 1) { + missed.push(duration); + } + } + expect(missed).toEqual([]); + }); + + it("the issue's case: a scene change chained on fadeIn callbacks reaches its end", () => { + // as the Alex 4 outro moved from scene to scene in a fadeIn callback + const scenes = []; + const next = (n) => { + scenes.push(n); + if (n < 4) { + app.viewport.fadeIn("#000000", 300, () => { + next(n + 1); + }); + } + }; + next(1); + for (let i = 0; i < 4 && app.viewport.getCameraEffect(FadeEffect); i++) { + runFade(); + } + expect(scenes).toEqual([1, 2, 3, 4]); + }); + + it("the fade ends on the frame after its tween does, not before", () => { + const onComplete = vi.fn(); + app.viewport.fadeIn("#000000", 300, onComplete); + const fade = app.viewport.getCameraEffect(FadeEffect); + let frames = 0; + while (!onComplete.mock.calls.length && frames < 400) { + // still there for every frame before the callback + expect(app.viewport.getCameraEffect(FadeEffect)).toBe(fade); + tick(); + frames++; + } + expect(onComplete).toHaveBeenCalledTimes(1); + // 300 ms is 18 frames at 60 fps + expect(frames).toBeGreaterThanOrEqual(18); + tick(); + expect(app.viewport.getCameraEffect(FadeEffect)).toBeUndefined(); + }); +}); From 39f681be4483101e8b0569240d1046315f6e66bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 05:50:37 +0000 Subject: [PATCH 37/43] Tiled: seed object GUIDs past the map's ids when it has no nextobjectid (#1809) level.load() seeds the GUID counter with the map's nextobjectid. The TMX format makes it optional, and with it missing the counter started at 0: objects added without an id took GUIDs equal to the map objects' own (-). The builtin detector keys contacts by GUID, so two pairs shared a key and the second contact in a step was dropped as a duplicate; in Alex 4, Alex fell through the boss. Without nextobjectid, TMXTileMap now uses one past the highest object id in the map, objects in group layers at any depth included (1 for a map with none). Tests: the map's own value is kept; without it the value is one past the highest id, through nested groups, and 1 with no objects; and runtime GUIDs never meet those of the map's objects. Four fail before the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/CHANGELOG.md | 1 + .../melonjs/src/level/tiled/TMXTileMap.js | 34 +++++- .../melonjs/tests/tmx-nextobjectid.spec.js | 107 ++++++++++++++++++ 3 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 packages/melonjs/tests/tmx-nextobjectid.spec.js diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 45c703f30..6eef656a6 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -25,6 +25,7 @@ - UI: `UITextButton#bitmapText`, for `label` ### Fixed +- Tiled: a map without `nextobjectid` (optional in the TMX format, left out by hand-made or generated maps) seeds the GUID counter past the highest object id it holds, group layers included. The counter started at 0, so objects added without an id took the GUIDs of the map's own objects, and the builtin detector, which keys contacts by GUID, dropped the second contact in a step that shared a key with another: a player fell through an enemy it shared a key with ([#1809](https://github.com/melonjs/melonJS/issues/1809)) - Camera: `fadeIn()` / `fadeOut()` (and a `FadeEffect`, so the fade state transition and a fading level trigger too) always call `onComplete`. The fade counted as done once its colour's alpha read as the target, which a 32-bit float can do one step before the tween ends; the camera then removed the effect, stopping the tween before it called `onComplete`, so code waiting on it stayed stuck. The fade now ends when its tween does ([#1810](https://github.com/melonjs/melonJS/issues/1810)) - Rendering: on WebGL and WebGPU, a clip inside a clip is the overlap of the two, as it already was on Canvas. A clipping container nested in another, or a scrolling `UIPanel` in a clipping parent, drew past the outer clip wherever it stuck out of it, because the inner `clipRect` replaced the outer scissor ([#1816](https://github.com/melonjs/melonJS/issues/1816)). The clipping example shows the case - UI: a radio group holds one choice from when its checkboxes are made, not only once they are in the scene: before, several could be checked at once until they were added, and `UICheckbox.checkedIn()` answered `undefined`. A checkbox leaves its group when destroyed; groups hold their members weakly, so one dropped without being destroyed can still be collected diff --git a/packages/melonjs/src/level/tiled/TMXTileMap.js b/packages/melonjs/src/level/tiled/TMXTileMap.js index 996dfd206..c114e763a 100644 --- a/packages/melonjs/src/level/tiled/TMXTileMap.js +++ b/packages/melonjs/src/level/tiled/TMXTileMap.js @@ -152,6 +152,33 @@ function refreshAbsoluteBounds(container) { }); } +/** + * the highest object id in a map's layers, group layers included + * @param {object[]} [layers] - the map's (or a group's) layer data + * @returns {number} the highest id, or 0 when there is none + * @ignore + */ +function highestObjectId(layers) { + let highest = 0; + if (!Array.isArray(layers)) { + return highest; + } + for (const layer of layers) { + if (Array.isArray(layer.objects)) { + for (const object of layer.objects) { + const id = +object.id; + if (id > highest) { + highest = id; + } + } + } + if (Array.isArray(layer.layers)) { + highest = Math.max(highest, highestObjectId(layer.layers)); + } + } + return highest; +} + /** * a TMX Tile Map Object * Tiled QT +0.7.x format @@ -294,8 +321,11 @@ export default class TMXTileMap { // Check if map is from melon editor this.isEditor = data.editor === "melon-editor"; - // object id - this.nextobjectid = +data.nextobjectid || undefined; + // the first id free for objects added at runtime: what the map says, + // or, as Tiled maps may leave it out, one past the highest object id + // actually in it, so a GUID made from the counter cannot meet one made + // from a map object's id (#1809) + this.nextobjectid = +data.nextobjectid || highestObjectId(data.layers) + 1; // hex/iso properties this.hexsidelength = +data.hexsidelength; diff --git a/packages/melonjs/tests/tmx-nextobjectid.spec.js b/packages/melonjs/tests/tmx-nextobjectid.spec.js new file mode 100644 index 000000000..bbe07084b --- /dev/null +++ b/packages/melonjs/tests/tmx-nextobjectid.spec.js @@ -0,0 +1,107 @@ +/** + * A Tiled map without `nextobjectid` (#1809). + * + * Tiled always writes `nextobjectid` on ``, but the TMX format makes it + * optional and hand-made or generated maps leave it out. `level.load()` + * seeds the GUID counter with it, so with it missing the counter started at + * 0, and objects added without an id took GUIDs equal to the map objects' + * own (`-`). The builtin detector keys contacts by GUID, so two + * different pairs shared a key and the second contact in a step was dropped: + * in Alex 4, Alex fell through the boss. + * + * The map now seeds the counter past the highest object id it holds. + */ +import { describe, expect, it } from "vitest"; +import { TMXTileMap, utils } from "../src/index.js"; + +/** a map's JSON with the given layers, and `nextobjectid` if given */ +const mapJSON = (layers, nextobjectid) => { + const data = { + width: 10, + height: 10, + tilewidth: 32, + tileheight: 32, + orientation: "orthogonal", + renderorder: "right-down", + infinite: false, + version: "1.10", + tiledversion: "1.12.0", + tilesets: [], + layers, + }; + if (nextobjectid !== undefined) { + data.nextobjectid = nextobjectid; + } + return data; +}; + +/** an object layer holding objects with the given ids */ +const objects = (...ids) => { + return { + type: "objectgroup", + name: "objects", + visible: true, + opacity: 1, + objects: ids.map((id) => { + return { id, name: "", x: 0, y: 0, width: 16, height: 16 }; + }), + }; +}; + +describe("TMXTileMap#nextobjectid (#1809)", () => { + it("is the map's own value when it has one", () => { + const map = new TMXTileMap("with", mapJSON([objects(1, 2)], 20)); + expect(map.nextobjectid).toBe(20); + }); + + it("without one, is one past the highest object id in the map", () => { + const map = new TMXTileMap("without", mapJSON([objects(3, 47, 7)])); + expect(map.nextobjectid).toBe(48); + }); + + it("counts objects inside group layers, at any depth", () => { + const layers = [ + objects(5), + { + type: "group", + name: "boss room", + layers: [ + objects(9), + { type: "group", name: "inner", layers: [objects(31)] }, + ], + }, + ]; + const map = new TMXTileMap("groups", mapJSON(layers)); + expect(map.nextobjectid).toBe(32); + }); + + it("is 1 for a map with no objects", () => { + const tiles = { + type: "tilelayer", + name: "ground", + width: 1, + height: 1, + data: [0], + }; + expect(new TMXTileMap("empty", mapJSON([tiles])).nextobjectid).toBe(1); + }); + + it("so GUIDs of objects added at runtime never meet those of the map's objects", () => { + // the Alex 4 case: the boss and the player have no id of their own, + // the cherries and stars in the map do + const ids = [1, 2, 3, 45, 46, 47]; + const map = new TMXTileMap("level12", mapJSON([objects(...ids)])); + // as level.load() does + utils.resetGUID("level12", map.nextobjectid); + const authored = ids.map((id) => { + return utils.createGUID(id); + }); + const spawned = Array.from({ length: 60 }, () => { + return utils.createGUID(); + }); + const shared = spawned.filter((guid) => { + return authored.includes(guid); + }); + expect(shared).toEqual([]); + }); +}); From b311be09dd318f6260b423899fa800acb3d3d8bd Mon Sep 17 00:00:00 2001 From: mmporong Date: Sat, 31 Oct 2026 20:00:43 +0900 Subject: [PATCH 38/43] =?UTF-8?q?fix(line):=20=EC=84=A0=EB=B6=84=20?= =?UTF-8?q?=EC=97=B0=EC=9E=A5=EC=84=A0=EC=9D=98=20=EC=A0=90=EC=9D=84=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/geometries/line.ts | 7 ++++++- packages/melonjs/tests/line.spec.ts | 5 +++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 6eef656a6..3b458be07 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -35,6 +35,7 @@ - Input: a region stays hovered after a click while the pointer is still on it. A release used to forget the pointer, so leaving straight after a click never called `onOut` (a button stayed lit), and moving on over it called `onOver` again - Input: the pointer leaving the canvas ends the hover of whatever it was over, not only of a region at the point where it left; a cancelled pointer, or a finger lifted on the legacy touch-event path, ends it too - Input: a child cropped out of view by a container with `clipping`, or scrolled out of a `UIPanel`, no longer gets pointer events where it is not drawn +- Geometry: `Line#contains()` checks the segment endpoints as well as collinearity; a point on the line beyond either endpoint was reported as contained (thanks @mmporong) - UI: a `UITextButton` moved after it was built draws its background where it is, not where it was first put - UI: a blocking `UIPanel` stays opaque to hover for as long as the pointer is on it, not only on the frame the pointer enters - UI: a draggable `UIBaseElement` pressed with no hover first, as a touch tap is, no longer throws, and drags by touch; in the world, rather than floating, it no longer jumps by the camera position when dragged diff --git a/packages/melonjs/src/geometries/line.ts b/packages/melonjs/src/geometries/line.ts index 781cf7077..d69342996 100644 --- a/packages/melonjs/src/geometries/line.ts +++ b/packages/melonjs/src/geometries/line.ts @@ -48,7 +48,12 @@ export class Line extends Polygon { //(Cy - Ay) * (Bx - Ax) = (By - Ay) * (Cx - Ax) return ( - (_y - start.y) * (end.x - start.x) === (end.y - start.y) * (_x - start.x) + (_y - start.y) * (end.x - start.x) === + (end.y - start.y) * (_x - start.x) && + _x >= Math.min(start.x, end.x) && + _x <= Math.max(start.x, end.x) && + _y >= Math.min(start.y, end.y) && + _y <= Math.max(start.y, end.y) ); } diff --git a/packages/melonjs/tests/line.spec.ts b/packages/melonjs/tests/line.spec.ts index 1996de187..32c22a1ff 100644 --- a/packages/melonjs/tests/line.spec.ts +++ b/packages/melonjs/tests/line.spec.ts @@ -60,6 +60,11 @@ describe("Shape : Line", () => { expect(line.contains(29, 61)).toEqual(false); }); + it("does not contain collinear points beyond its endpoints", () => { + expect(line.contains(56, 120)).toEqual(false); + expect(line.contains(-28, -60)).toEqual(false); + }); + it("Line Bounding Rect width is 28", () => { expect(bounds.width).toEqual(28); }); From ec612a56ac3c127de3893600c771e8ec33e465b6 Mon Sep 17 00:00:00 2001 From: mmporong Date: Sat, 31 Oct 2026 23:34:26 +0900 Subject: [PATCH 39/43] =?UTF-8?q?fix(color):=20hex=20=EC=95=8C=ED=8C=8C=20?= =?UTF-8?q?=EC=A0=95=EB=B0=80=EB=8F=84=EB=A5=BC=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/math/color.ts | 10 ++--- packages/melonjs/tests/color.spec.ts | 55 +++++++++++++++++----------- 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 3b458be07..e84d30b8b 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -34,6 +34,7 @@ - UI: a `UISlider` lays its texts out again when they change: a vertical one widens for a longer value text (a new `valueFormat`, `min`, `max` or `step`), and on a horizontal one the value moves below the track when it would run into the caption, instead of overlapping it. `labelText` sets the caption later - Input: a region stays hovered after a click while the pointer is still on it. A release used to forget the pointer, so leaving straight after a click never called `onOut` (a button stayed lit), and moving on over it called `onOver` again - Input: the pointer leaving the canvas ends the hover of whatever it was over, not only of a region at the point where it left; a cancelled pointer, or a finger lifted on the legacy touch-event path, ends it too +- Color: `parseHex()` keeps all 8 bits of alpha instead of rounding to one decimal, and `toHex8()` rounds float alpha to the nearest byte instead of truncating it. Low alpha values no longer become fully transparent, and 4- or 8-digit hex colors round-trip without changing alpha (thanks @mmporong) - Input: a child cropped out of view by a container with `clipping`, or scrolled out of a `UIPanel`, no longer gets pointer events where it is not drawn - Geometry: `Line#contains()` checks the segment endpoints as well as collinearity; a point on the line beyond either endpoint was reported as contained (thanks @mmporong) - UI: a `UITextButton` moved after it was built draws its background where it is, not where it was first put diff --git a/packages/melonjs/src/math/color.ts b/packages/melonjs/src/math/color.ts index d1e7ede91..64f242953 100644 --- a/packages/melonjs/src/math/color.ts +++ b/packages/melonjs/src/math/color.ts @@ -703,11 +703,7 @@ export class Color { parseInt(match[!argb ? 1 : 2], 16), // r parseInt(match[!argb ? 2 : 3], 16), // g parseInt(match[!argb ? 3 : 4], 16), // b - Number( - (clamp(parseInt(match[!argb ? 4 : 1], 16), 0, 255) / 255.0).toFixed( - 1, - ), - ), // a + clamp(parseInt(match[!argb ? 4 : 1], 16), 0, 255) / 255.0, // a ); } @@ -730,7 +726,7 @@ export class Color { parseInt(r + r, 16), // r parseInt(g + g, 16), // g parseInt(b + b, 16), // b - Number((clamp(parseInt(a + a, 16), 0, 255) / 255.0).toFixed(1)), // a + clamp(parseInt(a + a, 16), 0, 255) / 255.0, // a ); } @@ -791,7 +787,7 @@ export class Color { */ toHex8(alpha = this.normalizedRGBA[3]) { const a = this.normalizedRGBA; - return `#${toHex((a[0] * 255) >> 0)}${toHex((a[1] * 255) >> 0)}${toHex((a[2] * 255) >> 0)}${toHex((alpha * 255) >> 0)}`; + return `#${toHex((a[0] * 255) >> 0)}${toHex((a[1] * 255) >> 0)}${toHex((a[2] * 255) >> 0)}${toHex(Math.round(alpha * 255))}`; } /** diff --git a/packages/melonjs/tests/color.spec.ts b/packages/melonjs/tests/color.spec.ts index 1a422ef0e..9b7167f62 100644 --- a/packages/melonjs/tests/color.spec.ts +++ b/packages/melonjs/tests/color.spec.ts @@ -78,10 +78,8 @@ describe("Color", () => { expect(blue_color.parseHex("#0000FF").toRGB()).toEqual("rgb(0,0,255)"); }); // #RGBA - it("#0F08 value is rgba(0, 255, 0, 0.5)", () => { - expect(blue_color.parseHex("#0F08").toRGBA()).toEqual( - "rgba(0,255,0,0.5)", - ); + it("#0F08 preserves its 4-bit alpha", () => { + expect(blue_color.parseHex("#0F08").alpha).toBeCloseTo(8 / 15); }); // #RRGGBB it("#FF00FF value is rgba(255, 0, 255, 1)", () => { @@ -91,9 +89,11 @@ describe("Color", () => { }); // #RRGGBBAA (finish with the blue color so that the test below passes) it("#0000FF80 value is rgba(0, 0, 255, 0.5)", () => { - expect(blue_color.parseHex("#0000FF80").toRGBA()).toEqual( - "rgba(0,0,255,0.5)", - ); + expect(blue_color.parseHex("#0000FF80").alpha).toBeCloseTo(128 / 255); + }); + + it("preserves low alpha values", () => { + expect(blue_color.parseHex("#0000FF01").alpha).toBeCloseTo(1 / 255); }); // override the alpha with a specific value @@ -291,8 +291,8 @@ describe("Color", () => { expect(red_color.toHex()).toEqual("#FF0000"); }); - it("red_color RGBA hex value is #FF0000FF", () => { - expect(red_color.toHex8()).toEqual("#FF00007F"); + it("red_color RGBA hex value is #FF000080", () => { + expect(red_color.toHex8()).toEqual("#FF000080"); }); it("red_color rgba value is rgba(255,0,0,0.5)", () => { @@ -344,8 +344,8 @@ describe("Color", () => { expect(blue_color.toRGB()).toEqual("rgb(0,0,255)"); }); - it("blue_color rgba value is rgba(0, 0, 255, 0.5)", () => { - expect(blue_color.toRGBA()).toEqual("rgba(0,0,255,0.5)"); + it("blue_color preserves its 8-bit alpha", () => { + expect(blue_color.alpha).toBeCloseTo(128 / 255); }); it("lighten blue_color hex by 0.5 value is #7F7FFF", () => { @@ -521,11 +521,11 @@ describe("Color", () => { }); it("converts half alpha", () => { - expect(new Color(0, 255, 0, 0.5).toHex8()).toEqual("#00FF007F"); + expect(new Color(0, 255, 0, 0.5).toHex8()).toEqual("#00FF0080"); }); it("converts quarter alpha", () => { - expect(new Color(0, 0, 255, 0.25).toHex8()).toEqual("#0000FF3F"); + expect(new Color(0, 0, 255, 0.25).toHex8()).toEqual("#0000FF40"); }); it("converts black with full alpha", () => { @@ -539,16 +539,29 @@ describe("Color", () => { it("accepts alpha override parameter", () => { expect(new Color(0, 0, 255, 1).toHex8(0)).toEqual("#0000FF00"); expect(new Color(0, 0, 255, 0).toHex8(1)).toEqual("#0000FFFF"); - expect(new Color(0, 0, 255, 0).toHex8(0.5)).toEqual("#0000FF7F"); + expect(new Color(0, 0, 255, 0).toHex8(0.5)).toEqual("#0000FF80"); + }); + + it("round-trips every 8-bit hex alpha", () => { + for (let alpha = 0; alpha <= 255; alpha++) { + const hexAlpha = alpha.toString(16).padStart(2, "0").toUpperCase(); + const hexColor = `#3A7BCD${hexAlpha}` as const; + expect(new Color().parseHex(hexColor).toHex8()).toEqual(hexColor); + } + }); + + it("round-trips every 4-bit hex alpha", () => { + for (const hexAlpha of "0123456789ABCDEF") { + expect(new Color().parseHex(`#ABC${hexAlpha}`).toHex8()).toEqual( + `#AABBCC${hexAlpha}${hexAlpha}`, + ); + } }); - it("is consistent with parseHex round-trip for 8-digit hex", () => { - // alpha 0xFF round-trips cleanly (1.0 float) - const c = new Color().parseHex("#3A7BCDFF"); - expect(c.toHex8()).toEqual("#3A7BCDFF"); - // alpha 0x00 round-trips cleanly (0.0 float) - const c2 = new Color().parseHex("#3A7BCD00"); - expect(c2.toHex8()).toEqual("#3A7BCD00"); + it("uses the same rounding for rgba() alpha", () => { + expect(new Color().parseRGB("rgba(0, 0, 255, 0.5)").toHex8()).toEqual( + "#0000FF80", + ); }); it("alpha override does not modify instance alpha", () => { From 8c16d0cd1321244ede9bb157c171a28ab59dfd32 Mon Sep 17 00:00:00 2001 From: mmporong Date: Sat, 31 Oct 2026 23:34:26 +0900 Subject: [PATCH 40/43] =?UTF-8?q?fix(math):=202=EC=9D=98=20=EA=B1=B0?= =?UTF-8?q?=EB=93=AD=EC=A0=9C=EA=B3=B1=20=EC=9E=85=EB=A0=A5=EC=9D=84=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/math/math.ts | 6 +++++- packages/melonjs/tests/math.spec.js | 21 +++++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index e84d30b8b..81f7f1bc5 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -35,6 +35,7 @@ - Input: a region stays hovered after a click while the pointer is still on it. A release used to forget the pointer, so leaving straight after a click never called `onOut` (a button stayed lit), and moving on over it called `onOver` again - Input: the pointer leaving the canvas ends the hover of whatever it was over, not only of a region at the point where it left; a cancelled pointer, or a finger lifted on the legacy touch-event path, ends it too - Color: `parseHex()` keeps all 8 bits of alpha instead of rounding to one decimal, and `toHex8()` rounds float alpha to the nearest byte instead of truncating it. Low alpha values no longer become fully transparent, and 4- or 8-digit hex colors round-trip without changing alpha (thanks @mmporong) +- Math: `isPowerOfTwo()` rejects zero, negative and fractional values instead of accepting them after 32-bit coercion, and handles powers and non-powers beyond the 32-bit range (thanks @mmporong) - Input: a child cropped out of view by a container with `clipping`, or scrolled out of a `UIPanel`, no longer gets pointer events where it is not drawn - Geometry: `Line#contains()` checks the segment endpoints as well as collinearity; a point on the line beyond either endpoint was reported as contained (thanks @mmporong) - UI: a `UITextButton` moved after it was built draws its background where it is, not where it was first put diff --git a/packages/melonjs/src/math/math.ts b/packages/melonjs/src/math/math.ts index 26dbdecde..fa0ff085f 100644 --- a/packages/melonjs/src/math/math.ts +++ b/packages/melonjs/src/math/math.ts @@ -34,7 +34,11 @@ export const EPSILON = 0.000001; * @returns true if number is a power of two */ export function isPowerOfTwo(val: number) { - return (val & (val - 1)) === 0; + if (!Number.isInteger(val) || val <= 0) { + return false; + } + const exponent = Math.log2(val); + return Number.isInteger(exponent) && val === 2 ** exponent; } /** diff --git a/packages/melonjs/tests/math.spec.js b/packages/melonjs/tests/math.spec.js index e54cef701..3eb362aba 100644 --- a/packages/melonjs/tests/math.spec.js +++ b/packages/melonjs/tests/math.spec.js @@ -328,6 +328,27 @@ describe("Math", () => { it("32 is a Power of 2", () => { expect(math.isPowerOfTwo(32)).toEqual(true); }); + it("0 and negative values are not Powers of 2", () => { + expect(math.isPowerOfTwo(0)).toEqual(false); + expect(math.isPowerOfTwo(-2)).toEqual(false); + }); + it("fractional values are not Powers of 2", () => { + expect(math.isPowerOfTwo(2.5)).toEqual(false); + }); + it("supports Powers of 2 outside the 32-bit range", () => { + expect(math.isPowerOfTwo(2 ** 40)).toEqual(true); + expect(math.isPowerOfTwo(3 * 2 ** 32)).toEqual(false); + }); + it("rejects large non-Powers whose logarithms round to integers", () => { + for (const value of [ + Number.MAX_SAFE_INTEGER, + 2 ** 53 + 2, + 2 ** 53 + 4, + 2 ** 54 - 2, + ]) { + expect(math.isPowerOfTwo(value)).toEqual(false); + } + }); it("1027 is not a Power of 2", () => { expect(math.isPowerOfTwo(1027)).toEqual(false); }); From 864eddd1f5dd7836659a24be7371f41dfaee2993 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 06:32:39 +0000 Subject: [PATCH 41/43] Color: keep toHex8() output as it was; the hex alpha fix is in parseHex() alone The hex alpha precision fix also made toHex8() round alpha instead of truncating it, which changes what it returns for every colour whose alpha is not a whole byte (0.5 gave #...7F and would give #...80). The fix does not need it: with parseHex() keeping the exact byte / 255, toHex8() already writes every 8-bit and 4-bit hex alpha back unchanged, which the round-trip tests check. So toHex8() is restored and its expectations with it; the public API and toHex8() output stay as they were. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/CHANGELOG.md | 2 +- packages/melonjs/src/math/color.ts | 2 +- packages/melonjs/tests/color.spec.ts | 15 ++++++++------- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 81f7f1bc5..f688c0ac8 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -34,7 +34,7 @@ - UI: a `UISlider` lays its texts out again when they change: a vertical one widens for a longer value text (a new `valueFormat`, `min`, `max` or `step`), and on a horizontal one the value moves below the track when it would run into the caption, instead of overlapping it. `labelText` sets the caption later - Input: a region stays hovered after a click while the pointer is still on it. A release used to forget the pointer, so leaving straight after a click never called `onOut` (a button stayed lit), and moving on over it called `onOver` again - Input: the pointer leaving the canvas ends the hover of whatever it was over, not only of a region at the point where it left; a cancelled pointer, or a finger lifted on the legacy touch-event path, ends it too -- Color: `parseHex()` keeps all 8 bits of alpha instead of rounding to one decimal, and `toHex8()` rounds float alpha to the nearest byte instead of truncating it. Low alpha values no longer become fully transparent, and 4- or 8-digit hex colors round-trip without changing alpha (thanks @mmporong) +- Color: `parseHex()` keeps all 8 bits of alpha instead of rounding to one decimal. Low alpha values no longer become fully transparent, and 4- or 8-digit hex colors round-trip through `toHex8()` without changing alpha (thanks @mmporong) - Math: `isPowerOfTwo()` rejects zero, negative and fractional values instead of accepting them after 32-bit coercion, and handles powers and non-powers beyond the 32-bit range (thanks @mmporong) - Input: a child cropped out of view by a container with `clipping`, or scrolled out of a `UIPanel`, no longer gets pointer events where it is not drawn - Geometry: `Line#contains()` checks the segment endpoints as well as collinearity; a point on the line beyond either endpoint was reported as contained (thanks @mmporong) diff --git a/packages/melonjs/src/math/color.ts b/packages/melonjs/src/math/color.ts index 64f242953..fb0ead517 100644 --- a/packages/melonjs/src/math/color.ts +++ b/packages/melonjs/src/math/color.ts @@ -787,7 +787,7 @@ export class Color { */ toHex8(alpha = this.normalizedRGBA[3]) { const a = this.normalizedRGBA; - return `#${toHex((a[0] * 255) >> 0)}${toHex((a[1] * 255) >> 0)}${toHex((a[2] * 255) >> 0)}${toHex(Math.round(alpha * 255))}`; + return `#${toHex((a[0] * 255) >> 0)}${toHex((a[1] * 255) >> 0)}${toHex((a[2] * 255) >> 0)}${toHex((alpha * 255) >> 0)}`; } /** diff --git a/packages/melonjs/tests/color.spec.ts b/packages/melonjs/tests/color.spec.ts index 9b7167f62..6c42384c3 100644 --- a/packages/melonjs/tests/color.spec.ts +++ b/packages/melonjs/tests/color.spec.ts @@ -291,8 +291,8 @@ describe("Color", () => { expect(red_color.toHex()).toEqual("#FF0000"); }); - it("red_color RGBA hex value is #FF000080", () => { - expect(red_color.toHex8()).toEqual("#FF000080"); + it("red_color RGBA hex value is #FF00007F", () => { + expect(red_color.toHex8()).toEqual("#FF00007F"); }); it("red_color rgba value is rgba(255,0,0,0.5)", () => { @@ -521,11 +521,11 @@ describe("Color", () => { }); it("converts half alpha", () => { - expect(new Color(0, 255, 0, 0.5).toHex8()).toEqual("#00FF0080"); + expect(new Color(0, 255, 0, 0.5).toHex8()).toEqual("#00FF007F"); }); it("converts quarter alpha", () => { - expect(new Color(0, 0, 255, 0.25).toHex8()).toEqual("#0000FF40"); + expect(new Color(0, 0, 255, 0.25).toHex8()).toEqual("#0000FF3F"); }); it("converts black with full alpha", () => { @@ -539,7 +539,7 @@ describe("Color", () => { it("accepts alpha override parameter", () => { expect(new Color(0, 0, 255, 1).toHex8(0)).toEqual("#0000FF00"); expect(new Color(0, 0, 255, 0).toHex8(1)).toEqual("#0000FFFF"); - expect(new Color(0, 0, 255, 0).toHex8(0.5)).toEqual("#0000FF80"); + expect(new Color(0, 0, 255, 0).toHex8(0.5)).toEqual("#0000FF7F"); }); it("round-trips every 8-bit hex alpha", () => { @@ -558,9 +558,10 @@ describe("Color", () => { } }); - it("uses the same rounding for rgba() alpha", () => { + it("keeps its output for an rgba() alpha that is not a whole byte", () => { + // 0.5 is 127.5 of 255: toHex8 truncates, as it always has expect(new Color().parseRGB("rgba(0, 0, 255, 0.5)").toHex8()).toEqual( - "#0000FF80", + "#0000FF7F", ); }); From 63495417333cfb99456ca3fd85d775874e1877dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 10:52:36 +0000 Subject: [PATCH 42/43] Tests: a full local run of the engine suite comes out clean (#1817) - transition-second-change: the spec steps the engine's own game loop itself, one frame at a time, instead of waiting on browser animation frames, which a busy browser can hold back for seconds. The tests still all fail on the code before #1787. - drawmesh_bench: runs only when asked for, with BENCH=1, and then with the time it needs. It measures rather than tests, and it ties up the shared GPU process for minutes, timing out the specs beside it. - text-ink-clipping: allows the one row a face's hinting can paint short of the ink box the browser reports, and checks the unstroked glyph leaves the canvas edges blank, which is what a cut leaves. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/tests/drawmesh_bench.spec.js | 22 ++-- .../melonjs/tests/text-ink-clipping.spec.js | 33 +++--- .../tests/transition-second-change.spec.js | 106 +++++++++++++++--- packages/melonjs/vitest.config.ts | 5 + 4 files changed, 130 insertions(+), 36 deletions(-) diff --git a/packages/melonjs/tests/drawmesh_bench.spec.js b/packages/melonjs/tests/drawmesh_bench.spec.js index 8cf3a6317..9a5b068f2 100644 --- a/packages/melonjs/tests/drawmesh_bench.spec.js +++ b/packages/melonjs/tests/drawmesh_bench.spec.js @@ -233,7 +233,9 @@ describe("drawMesh benchmark (baseline for #1468)", () => { ); }; - it("baseline cost across mesh counts", (ctx) => { + // Run on demand, and given the time it takes under software GL: + // BENCH=1 npx vitest run --config packages/melonjs/vitest.config.ts tests/drawmesh_bench.spec.js + it("baseline cost across mesh counts", { timeout: 300000 }, (ctx) => { // `measure()` below logs and returns when there is no WebGL, which // reports as a PASS — a benchmark that never ran then looks exactly // like one that did. Decide it once, visibly, up front. @@ -241,12 +243,18 @@ describe("drawMesh benchmark (baseline for #1468)", () => { ctx.skip("Canvas renderer — drawMesh benchmark not applicable"); return; } - // Skipped on CI, visibly rather than by quietly measuring nothing. - // The runner is a GPU-less container rasterizing through SwiftShader's - // Subzero JIT on four shared vCPUs, so these figures describe that - // environment and not the engine. The work is also heavy enough to - // starve the shared GPU process for minutes, which is what made the - // suite flaky. Run it locally to compare against #1507's numbers. + // Skipped unless asked for (`BENCH=1`), visibly rather than by quietly + // measuring nothing. On CI, the runner is a GPU-less container + // rasterizing through SwiftShader's Subzero JIT on four shared vCPUs, + // so these figures describe that environment and not the engine. The + // work is also heavy enough to starve the shared GPU process for + // minutes: in a full run, on CI or on a machine without a GPU, it + // timed out itself and the specs beside it (#1817). Run it on its own + // to compare against #1507's numbers. + if (import.meta.env.VITE_BENCH === "") { + ctx.skip("benchmark — run it with BENCH=1"); + return; + } if (import.meta.env.VITE_CI !== "") { ctx.skip("CI — a software-rasterized benchmark measures the runner"); return; diff --git a/packages/melonjs/tests/text-ink-clipping.spec.js b/packages/melonjs/tests/text-ink-clipping.spec.js index 4a41c5744..01e1dc223 100644 --- a/packages/melonjs/tests/text-ink-clipping.spec.js +++ b/packages/melonjs/tests/text-ink-clipping.spec.js @@ -102,16 +102,23 @@ describe("Text — the glyph is baked whole, not clipped", () => { return m.actualBoundingBoxAscent + m.actualBoundingBoxDescent + stroke; }; - it("bakes the whole glyph, stroke included", () => { - // The oracle is the ink box the browser reports plus the stroke, which - // no metric covers. Anything less means the bake cut the glyph off. - const text = stroked(); - + /** + * The glyph's ink is as tall as the ink box the browser reports, plus the + * stroke, which no metric covers: anything less means the bake cut it + * off. Within a row: the box is measured on the outline, and how the + * face's hinting rasterizes it can paint one row less (#1817: 58 rows for + * a reported 59 on one machine, with nothing cut). + * @param text - a Text whose offscreen canvas has been baked + */ + const expectFullHeight = (text) => { const { top, bottom } = inkRows(text); - expect(bottom - top + 1).toBeGreaterThanOrEqual( - Math.floor(expectedInk(text)), + Math.floor(expectedInk(text)) - 1, ); + }; + + it("bakes the whole glyph, stroke included", () => { + expectFullHeight(stroked()); }); it("keeps the ink inside the canvas at the bottom too", () => { @@ -126,12 +133,12 @@ describe("Text — the glyph is baked whole, not clipped", () => { // the other half of the bug: a face whose glyphs rise above the em box // overshoots with no stroke involved at all const text = stroked({ lineWidth: 0, size: 64 }); - - const { top, bottom } = inkRows(text); - - expect(bottom - top + 1).toBeGreaterThanOrEqual( - Math.floor(expectedInk(text)), - ); + expectFullHeight(text); + // and with nothing on the canvas's first or last row, where a cut + // would leave it + const { top, bottom, height } = inkRows(text); + expect(top).toBeGreaterThan(0); + expect(bottom).toBeLessThan(height - 1); }); describe("the reported layout box is unchanged", () => { diff --git a/packages/melonjs/tests/transition-second-change.spec.js b/packages/melonjs/tests/transition-second-change.spec.js index 78054eb63..adbef426d 100644 --- a/packages/melonjs/tests/transition-second-change.spec.js +++ b/packages/melonjs/tests/transition-second-change.spec.js @@ -1,4 +1,13 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import { Application, boot, @@ -24,9 +33,74 @@ import { */ const DURATION = 60; -// Generous: a covering transition advances on animation frames, and under a -// full parallel run the browser can go seconds without giving this page one. -const WAIT = { timeout: 15000 }; +const FRAME = 1000 / 60; +// how long, in real time, to keep running frames for something to happen: a +// WebGL renderer can hold a switch while its shaders build +const LIMIT = 20000; + +/* + * The game loop is run here, one frame at a time, rather than by the + * browser: `requestAnimationFrame` only keeps the engine's frame callback, + * and `frame()` calls it with a clock moved on by 1/60 s. Under a full + * parallel run the browser can go seconds without giving this page an + * animation frame, which timed these tests out (#1817); the engine's own + * loop, its timers and deferred switches run as they do in a game. + */ +let nextFrame = null; +let clock = 0; + +/** run one frame of the game loop, if it is running, then what it deferred */ +const frame = async () => { + const callback = nextFrame; + nextFrame = null; + if (callback !== null) { + clock += FRAME; + callback(clock); + } + // a turn of the event loop, for the timers it queued (a state switch) + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +}; + +/** run frames for `ms` of game time */ +const runFor = async (ms) => { + for (let t = 0; t < ms; t += FRAME) { + await frame(); + } +}; + +/** run frames until `check` passes, or fail with its last error */ +const runUntil = async (check) => { + const end = globalThis.performance.now() + LIMIT; + while (globalThis.performance.now() < end) { + try { + check(); + return; + } catch { + await frame(); + } + } + check(); +}; + +beforeAll(() => { + clock = globalThis.performance.now(); + vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation( + (callback) => { + nextFrame = callback; + return 1; + }, + ); + vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => { + nextFrame = null; + }); +}); + +afterAll(() => { + vi.restoreAllMocks(); +}); + const covers = { fade: FadeEffect, mask: MaskEffect, @@ -71,15 +145,13 @@ describe.each([ * camera effect at all, the transition's own or any other */ const settlesOn = async (id) => { - await vi.waitFor(() => { + await runUntil(() => { expect(state.isCurrent(id)).toBe(true); expect(app.viewport.cameraEffects).toHaveLength(0); - }, WAIT); + }); // and stays so: a cover left behind is persistent, and outlives // the reveal that would otherwise be the last effect to go - await new Promise((resolve) => { - setTimeout(resolve, DURATION * 3); - }); + await runFor(DURATION * 3); expect(state.isCurrent(id)).toBe(true); expect(app.viewport.cameraEffects).toHaveLength(0); }; @@ -92,6 +164,10 @@ describe.each([ start = newState("start"); state.setTransition(start, false); state.change(start, true); + // the loop running, its warm-up over + await runUntil(() => { + expect(nextFrame).not.toBeNull(); + }); resets.length = 0; state.transition( type, @@ -122,9 +198,7 @@ describe.each([ const a = newState("a"); const b = newState("b"); state.change(a); - await new Promise((resolve) => { - setTimeout(resolve, DURATION / 2); - }); + await runFor(DURATION / 2); expect(state.isCurrent(a)).toBe(false); state.change(b); expect(effects()).toHaveLength(1); @@ -162,9 +236,9 @@ describe.each([ const a = newState("a"); const b = newState("b"); state.change(a); - await vi.waitFor(() => { + await runUntil(() => { expect(state.isCurrent(a)).toBe(true); - }, WAIT); + }); // during a's reveal: a change of its own, with a cover of its own state.change(b); await settlesOn(b); @@ -289,9 +363,9 @@ describe("a transition left covering by a destroyed application", () => { try { // not taken by the dead transition: switched, directly state.change(b); - await vi.waitFor(() => { + await runUntil(() => { expect(state.isCurrent(b)).toBe(true); - }, WAIT); + }); // and with no reveal of the dead transition's added to this one expect( app.viewport.cameraEffects.filter((fx) => { diff --git a/packages/melonjs/vitest.config.ts b/packages/melonjs/vitest.config.ts index ef579555c..a4b58d428 100644 --- a/packages/melonjs/vitest.config.ts +++ b/packages/melonjs/vitest.config.ts @@ -100,6 +100,11 @@ export default defineConfig(() => // contributor's machine that genuinely has no GL). define: { "import.meta.env.VITE_CI": JSON.stringify(process.env.CI ?? ""), + // Benchmarks run only when asked for, with `BENCH=1`: they measure + // rather than test, and the heavy ones tie up the browser's shared + // GPU process long enough to time out the specs running beside + // them (#1817). + "import.meta.env.VITE_BENCH": JSON.stringify(process.env.BENCH ?? ""), }, publicDir: resolve(__dirname, "tests/public"), plugins: [ From ba0a426101e0b0c10b52308bb88b2e932507fcd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 10:57:57 +0000 Subject: [PATCH 43/43] Tests: the audio unlock test waits for the clip to start (#1817) "comes back from an idle suspension the browser never acknowledged" gave the resume and the clip a fixed 20 ms, which a full parallel run can outlast. It now waits for them: an engine wedged as in the bug never starts the clip at all, so it still fails there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru --- packages/melonjs/tests/audio-backend.spec.js | 21 ++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/melonjs/tests/audio-backend.spec.js b/packages/melonjs/tests/audio-backend.spec.js index 9cd5b807c..c8a053d6e 100644 --- a/packages/melonjs/tests/audio-backend.spec.js +++ b/packages/melonjs/tests/audio-backend.spec.js @@ -1115,14 +1115,19 @@ describe("audio backend", () => { { once: true }, ); document.dispatchEvent(new Event("click")); - await tick(); - // the hardware is running: a generated tone would be heard here - expect(ctx.state).toBe("running"); - // and so must the engine be, with the clip actually started. The - // fixture is ten milliseconds long, so `playing()` is already false - // again by now: the "play" event is the record that it started - expect(audioEngine.state).toBe("running"); - expect(played).toHaveBeenCalledTimes(1); + // waited for rather than given a fixed time, which a full parallel + // run can outlast (#1817): an engine wedged as in the bug never + // gets there at all + await vi.waitFor(() => { + // the hardware is running: a generated tone would be heard here + expect(ctx.state).toBe("running"); + // and so must the engine be, with the clip actually started. + // The fixture is ten milliseconds long, so `playing()` is + // already false again by now: the "play" event is the record + // that it started + expect(audioEngine.state).toBe("running"); + expect(played).toHaveBeenCalledTimes(1); + }); expect(played.mock.calls[0][0]).toBe(id); delete ctx.state; ctx.suspend = savedSuspend;