From 12218f406f930df55084866758e9377d72b90dbc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:09:39 +0000 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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",