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/examples/src/examples/ui/ExampleUI.tsx b/packages/examples/src/examples/ui/ExampleUI.tsx index 34a42eca6..934a9f431 100644 --- a/packages/examples/src/examples/ui/ExampleUI.tsx +++ b/packages/examples/src/examples/ui/ExampleUI.tsx @@ -6,299 +6,350 @@ 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: the same names on each */ +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, - }); - } - - override onOver() { - this.setOpacity(1.0); - } - - override onOut() { - this.setOpacity(0.5); - } - - override onClick() { - this.translate( - 0, - this.height - (this.clicked_region as { height: number }).height, - ); - this.setRegion(this.clicked_region); - return false; - } +/** 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 onRelease() { - this.setRegion(this.unclicked_region); - this.translate( - 0, - -(this.height - (this.clicked_region as { height: number }).height), - ); - return false; +/** + * 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(); } -} - -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, - }); +}; - 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, - }); - } +/** + * 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, + 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`, + pressed: `${color}_button05`, + }, + onClick, + }); +}; - override onOver() { - this.setOpacity(1.0); - } +/** + * 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, + valueFormat: (_value, ratio) => { + return `${Math.round(ratio * 100)}%`; + }, + length: 320, + value, + step: 0.01, + skin: { + atlas: texture, + track: "grey_sliderHorizontal", + thumb: "blue_sliderDown", + }, + onChange, + onChangeEnd, + }); +}; - override onOut() { - this.setOpacity(0.5); - } +/** + * 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, + hoverScale: 1.04, + skin: { + atlas: texture, + on: "green_boxCheckmark", + off: "grey_boxCheckmark", + }, + onChange, + }); +}; - 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); - } - } +/** + * 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, + }, + // a soft drop shadow, rounded like the art's corners + shadowColor: "#00000059", + shadowOffsetY: 6, + shadowBlur: 10, + radius: 6, + draggable: true, + }, + }; +}; - override onClick() { - this.setSelected(!this.isSelected); - return false; - } -} +class PlayScreen extends Stage { + override onResetEvent(app: Application) { + const background = new ColorLayer("background", "rgba(248, 194, 40, 1.0)"); + app.world.addChild(background, 0); -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, - ), - ); + const first = this.addOptions(app, background); + this.addBars(app); + this.addLevels(app); - this.addChild( - new Text(this.width / 2, 16, { + // 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", - size: 20, + size: 13, fillStyle: "black", - textAlign: "center", - textBaseline: "top", - text: label, + textAlign: "left", + textBaseline: "bottom", + text: "Keyboard: arrows and Enter Gamepad: D-pad and A", }), + 1, ); - - this.isHoldable = true; - this.isDraggable = true; - } - - /** - * 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. - */ - override onClick() { - if (this.ancestor instanceof Container) { - this.ancestor.moveToTop(this); - } - 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. + * 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 + * @returns the first widget, where the keyboard focus starts */ - override onActivateEvent() { - super.onActivateEvent(); - input.registerPointerEvent("pointermove", this, () => false); - } - - override onDeactivateEvent() { - input.releasePointerEvent("pointermove", this); - super.onDeactivateEvent(); - } -} + 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, + }); -class PlayScreen extends Stage { - override onResetEvent(app: Application) { - app.world.addChild( - new ColorLayer("background", "rgba(248, 194, 40, 1.0)"), - 0, + 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); + } + }, ); - const panel = new UIContainer(100, 100, 450, 325, "OPTIONS"); - - const cbPanel = new UIBaseElement(125, 75, 100, 100); + 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]); + }); - const cb1 = new CheckBoxUI( - 0, - 0, - texture, - "green_boxCheckmark", - "grey_boxCheckmark", - "Music ON", - "Music OFF", + // 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); + }), ); - cbPanel.addChild(cb1); - cbPanel.addChild(cb1.label); - - const cb2 = new CheckBoxUI( - 0, - 50, - texture, - "green_boxCheckmark", - "grey_boxCheckmark", - "Sound FX ON", - "Sound FX OFF", + 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(); + }), ); - 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); + panel.addChild(music); + panel.addChild(musicVolume); + panel.addChild(sfx); + panel.addChild(sfxVolume); + panel.addChild(video); + panel.addChild(actions); + app.world.addChild(panel, 1); + return music; + } + /** + * 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 + */ + private addLevels(app: Application) { + // 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", + columns: 4, + padding: 10, + gap: 8, + align: "center", + scroll: "vertical", + }); + for (let level = 1; level <= 30; level++) { + const pick = new UITextButton(0, 0, { + ...FONT, + fontSize: 16, + hoverScale: 1.12, + pressScale: 0.9, + text: String(level), + skin: { + atlas: texture, + normal: "blue_button07", + pressed: "blue_button08", + }, + // the last six are locked + enabled: level <= 24, + onClick: () => { + panel.title?.setText(`LEVEL ${level}`); + }, + }); + panel.addChild(pick); + } 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 +357,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(408, 20, 300, 400, "PROGRESS"); + const panel = new UIPanel(frame.x, frame.y, frame.settings); /** * one caption, in the panel's own coordinates @@ -330,7 +382,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", @@ -349,11 +401,15 @@ 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, - 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, @@ -405,7 +461,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", @@ -440,6 +496,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 +512,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 +526,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/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); diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 7beb8e48b..fa37f15a7 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -4,6 +4,29 @@ ### 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 (`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: a drop shadow under a `UIPanel` or a `ProgressBar`: `shadowColor`, `shadowOffsetX`, `shadowOffsetY` and `shadowBlur`, softened the same on Canvas, WebGL and WebGPU, rounded with the corners, and under a skin too +- UI: keyboard and gamepad navigation for a game played without a mouse or a touch screen: once a widget takes the focus with `focus()`, the arrows or the D-pad move it to the nearest widget, Enter, Space or A press it as a click does, and left and right step a slider. A ring shows the focus while the keyboard or a pad is used, and a focused widget in a scrolling panel is scrolled into view. Until a widget has the focus the keys and the pad are left to the game; `blur()` gives them back. `focusable`, `focused`, `onNavigate`, `UIBaseElement.focusOwner` and `UIBaseElement.focusRingColor` +- UI: `hoverScale` and `pressScale` on every `UIBaseElement`: a bounce under the pointer or the focus, and while held, easing by itself and scaling only the drawing, not the hit area or the layout +- 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/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", diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index efcc62c05..89ef6fbf7 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,90 @@ 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 +}); +``` + +**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. + +### 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 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 })`. + +### 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.** 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. + ### A pause overlay: `updateWhenPaused` cascades `state.pause()` stops every world child stepping while rendering carries on, so @@ -492,6 +576,103 @@ 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") + fontSize: 24, // pixels, with a CSS font or 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). + +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", 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`, 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. + +### A little bounce: `hoverScale` and `pressScale` + +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 }); +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: `focus()` + +For a game played without a mouse or a touch screen, give a widget the focus: + +```js +playButton.focus(); // where keyboard or gamepad play starts +``` + +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. + +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. + +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: @@ -533,6 +714,65 @@ 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, 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 +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, + 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 + 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, 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. + +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 +961,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..535a96656 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"; @@ -196,11 +198,34 @@ 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, } from "./renderable/ui/progressbar.js"; -export type { UIPanelSettings } from "./renderable/ui/uipanel.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, + 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 +336,9 @@ export { Tween, timer, UIBaseElement, + UICheckbox, UIPanel, + UISlider, UISpriteElement, UITextButton, VignetteEffect, diff --git a/packages/melonjs/src/input/gamepad.ts b/packages/melonjs/src/input/gamepad.ts index 9fab17d4a..988d4beb8 100644 --- a/packages/melonjs/src/input/gamepad.ts +++ b/packages/melonjs/src/input/gamepad.ts @@ -295,6 +295,31 @@ function browserGamepads(): readonly (Gamepad | null)[] { return hasGamepadApi() ? globalThis.navigator.getGamepads() : []; } +/** + * 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 + */ +export function readGamepads(): readonly ( + | Pick + | VirtualGamepad + | null +)[] { + const pads: (Pick | VirtualGamepad | null)[] = [ + ...browserGamepads(), + ]; + for (const virtual of virtualGamepads) { + if (virtual !== undefined) { + pads.push(virtual); + } + } + return pads; +} + /** * Whether the browser has the Gamepad API * @ignore 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/panelpaint.ts b/packages/melonjs/src/renderable/ui/panelpaint.ts index d6e7ccc7e..22ac0ce44 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; /** @@ -207,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 28b9d1e51..85d439b5d 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 */ @@ -52,8 +56,28 @@ 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) => void; + onChange?: (value: number, ratio: number, bar: ProgressBar) => void; /** draw a label over the bar */ showLabel?: boolean; /** the label's font family */ @@ -152,8 +176,18 @@ 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) => 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 */ @@ -190,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)}%`); @@ -258,7 +296,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); } /** @@ -309,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/uibaseelement.ts b/packages/melonjs/src/renderable/ui/uibaseelement.ts index cc831f2b9..a128eb87c 100644 --- a/packages/melonjs/src/renderable/ui/uibaseelement.ts +++ b/packages/melonjs/src/renderable/ui/uibaseelement.ts @@ -5,9 +5,121 @@ 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 type Renderer from "../../video/renderer.js"; import Container from "../container.js"; +import { PanelPainter } from "./panelpaint.ts"; +import type { UINavAction } 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(); + +/** + * 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; +} + +/** 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 + * 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"; +} + +/** + * 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: 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: 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) { + 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; +} + +/** + * 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. @@ -140,13 +252,299 @@ 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) { + 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. 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; + + /** + * 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 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 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); + } + } + + /** + * 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 - which way + * @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. + * @returns whether it went down: not when disabled, or already held + */ + 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; + } + + /** + * 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 && ringVisible())) && + this._enabled + ) { + target = this.hoverScale; + } + if (this._juice === target) { + return dirty; + } + 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; + } + 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 (ringVisible() && focusOwner() === this) { + // 3 pixels wide, 3 pixels out from the widget + const pad = 6; + const alpha = renderer.globalAlpha(); + ringPainter.paint(renderer, UIBaseElement.focusRingColor, alpha); + ringPainter.border( + renderer, + this.pos.x - pad, + this.pos.y - pad, + this.width + pad * 2, + this.height + pad * 2, + 6, + 3, + ); + renderer.setGlobalAlpha(alpha); + } + } + + /** + * 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; + + /** + * 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`. + * + * 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 @@ -166,10 +564,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); } @@ -180,10 +575,15 @@ 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); + // 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; @@ -226,13 +626,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); } @@ -322,6 +716,9 @@ export default class UIBaseElement extends Container { this.leave(e); }); + // every element, so one made focusable later takes part too + trackElement(this); + // call the parent function super.onActivateEvent(); } @@ -345,14 +742,8 @@ 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(); + untrackElement(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 new file mode 100644 index 000000000..9c32afb95 --- /dev/null +++ b/packages/melonjs/src/renderable/ui/uicheckbox.ts @@ -0,0 +1,659 @@ +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 type { UIJuiceSettings } from "./uibaseelement.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 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; +} + +/** + * Everything {@link UICheckbox} takes. + * @category UI + */ +export interface UICheckboxSettings extends UIJuiceSettings { + /** + * 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 + */ + boxSize?: 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 `boxSize` 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" + */ + boxHoverColor?: 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 it moves the checkbox to the new group straight away; + * a checked one then unchecks the others there. + */ + 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) */ + boxHoverColor: 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 */ + label: UILabel | undefined; + /** the art, when it is skinned */ + readonly skin: UICheckboxSkin | undefined; + + /** @ignore */ + private _checked: boolean; + /** @ignore */ + private _labelOn: string; + /** @ignore */ + private _labelOff: string; + /** how a label is made, for one added after construction */ + private readonly _labelStyle: { + font: string; + fontSize: number; + fillStyle: string; + }; + /** @ignore */ + private _group: string | undefined; + /** whether it is in the scene */ + private _active = false; + /** @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.boxSize; + if (this.skin !== undefined) { + this._skin = new SkinDrawer(this.skin); + const off = this._skin.size(this.skin.off); + // 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; + } + 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.boxHoverColor = settings.boxHoverColor ?? "#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 ?? ""; + 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.focusable = true; + this.hoverScale = settings.hoverScale ?? 1; + this.pressScale = settings.pressScale ?? 1; + 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; + } + + /** + * The text while checked. Setting it resizes the widget to fit. + */ + get labelOn(): string { + return this._labelOn; + } + + set labelOn(value: string) { + this._labelOn = value; + this.fitLabel(); + } + + /** + * The text while not checked. Setting it resizes the widget to fit. + */ + get labelOff(): string { + return this._labelOff; + } + + 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 + * @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.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 + */ + override onActivateEvent(): void { + super.onActivateEvent(); + this._active = true; + this.joinGroup(); + } + + /** + * Leave its group. + * @ignore + */ + override onDeactivateEvent(): void { + this._active = false; + this.leaveGroup(); + super.onDeactivateEvent(); + } + + /** + * Join the group it names, if any; the newest checked member wins. + * @ignore + * @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 + * @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!; + const on = this._checked; + let region = on ? skin.on : skin.off; + if (!this._enabled) { + 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 { + 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.boxHoverColor + : 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/uifocus.ts b/packages/melonjs/src/renderable/ui/uifocus.ts new file mode 100644 index 000000000..b4ac514e9 --- /dev/null +++ b/packages/melonjs/src/renderable/ui/uifocus.ts @@ -0,0 +1,442 @@ +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"; + +/** + * A direction the player moves the focus in. + * @category UI + */ +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], + 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 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 }; + +/** + * The element with the focus, if any. + * @returns the focus owner + * @ignore + * @internal + */ +export function focusOwner(): UIBaseElement | undefined { + return owner; +} + +/** + * Whether the focus ring shows. + * @returns true after keyboard or pad input, until the mouse moves + * @ignore + * @internal + */ +export function ringVisible(): boolean { + return ringShown; +} + +/** + * 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); +} + +/** + * 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; + } + if (owner !== undefined) { + owner.isDirty = true; + } + // 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); + } +} + +/** + * Track an element joining the scene. + * @param element - the element + * @ignore + * @internal + */ +export function trackElement(element: UIBaseElement): void { + elements.add(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(); + } + if (owner === element) { + setFocus(undefined); + } +} + +/** + * 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; + } + 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); + } + } +} + +/** + * turn the key, pointer and pad listeners on or off + * @param value - on or off + */ +function listen(value: boolean): void { + if (value === listening) { + return; + } + 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); + } +} + +/** 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; + } + } + return true; +} + +/** + * the elements that can take the focus now + * @returns them, in no particular order + */ +function candidates(): UIBaseElement[] { + return [...elements].filter((element) => { + return isCandidate(element); + }); +} + +/** + * 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; + } + 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; +} + +/** + * 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; +} + +/** + * 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 ( + panel.scroll !== undefined && + panel.scroll !== "none" && + typeof panel.scrollToChild === "function" + ) { + panel.scrollToChild(child, 10); + } + child = node; + } +} + +/** + * 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) && + owner.focusPress() + ) { + accepting = source; + pressed = owner; + } +} + +/** + * 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(); + } +} + +/** + * 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); + } + } +} + +/** + * 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 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; + } + 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/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..a507e3703 100644 --- a/packages/melonjs/src/renderable/ui/uipanel.ts +++ b/packages/melonjs/src/renderable/ui/uipanel.ts @@ -1,16 +1,67 @@ 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 { UIJuiceSettings } 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"; +import { SkinDrawer } from "./uiskin.ts"; +import UITextButton from "./uitextbutton.ts"; + +/** + * The art of a skinned {@link UIPanel}: one atlas region, drawn as a + * nine-slice over the whole panel and resized with it. + * @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. + * @category UI */ -export interface UIPanelSettings { +export interface UIPanelSettings extends UIJuiceSettings { /** the panel's width, in pixels */ width: number; /** the panel's height, in pixels */ @@ -28,8 +79,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. @@ -55,8 +126,102 @@ 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; } +/** + * 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 */ +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. * @@ -104,6 +269,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; /** @@ -144,6 +317,65 @@ 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: + | { + dom: Event | undefined; + 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}. * @@ -188,9 +420,47 @@ 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; + 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; + 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 +527,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 +536,41 @@ 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 { + // 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; + } + 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 +593,32 @@ 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, + 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 +646,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 +775,774 @@ 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 [ + // `||` 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; + 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) { + // 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 ( + (child instanceof UITextButton || child instanceof UIPanel) && + (child.width !== w || child.height !== h) + ) { + child.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") { + // 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; + 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 + * @param margin - room to leave around it, in pixels, such as for a + * focus ring + * @returns this panel + */ + 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 - 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 + w > vx + vw) { + x += Math.min(left - vx, left + w - (vx + vw)); + } + if (top < vy) { + y -= vy - top; + } else if (top + h > vy + vh) { + y += Math.min(top - vy, top + 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; + } + const dom = event.event; + if (this._press !== undefined) { + 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, + scrollX: this._scrollX, + scrollY: this._scrollY, + element, + scrolling: false, + lastX: x, + lastY: y, + lastTime: globalThis.performance?.now() ?? Date.now(), + }; + on(POINTERMOVE, this._onPressMove); + for (const type of END_EVENTS) { + globalThis.addEventListener?.(type, 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: Event) => { + const press = this._press; + if (press === undefined || !endsPointer(event, 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); + for (const type of END_EVENTS) { + globalThis.removeEventListener?.(type, 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); + // 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.widgetPressed(); + if (this.isDraggable && this.ancestor instanceof Container) { + this.ancestor.moveToTop(this); + } + let captured = false; + if (onContent) { + 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; + } + } + + /** + * 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 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 pressed(this); + } + + /** + * 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); + } + } + + /** 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 + */ + 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._clearing && (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 +1586,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..42cd76bd5 --- /dev/null +++ b/packages/melonjs/src/renderable/ui/uislider.ts @@ -0,0 +1,902 @@ +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 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"; +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 extends UIJuiceSettings { + /** + * 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, 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" + */ + 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" + */ + thumbHoverColor?: Paint; + /** + * the thumb's outline (drawn look) + * @default "#2a7de1" + */ + 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; +} + +/** 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, + * valueFormat: (v, ratio) => Math.round(ratio * 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; + + /** 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) */ + thumbHoverColor: Paint; + /** the thumb's outline (drawn look) */ + thumbBorderColor: Paint; + /** the thickness of the thumb's outline (drawn look) */ + thumbBorderWidth: number; + + /** 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 _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 */ + private _max: number; + /** @ignore */ + private _step: number; + /** @ignore */ + private _value: number; + /** @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: Event) => { + if (this._dragging && endsPointer(event, this._dragPointer)) { + this.endDrag(true); + } + }; + + private readonly _onWheel = (pointer: Pointer) => { + if (!this._enabled) { + return; + } + // 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) + ? -Math.sign(pointer.deltaY) + : Math.sign(pointer.deltaX); + if (direction === 0) { + return; + } + const step = this._step > 0 ? this._step : (this._max - this._min) / 100; + if (this.setFromUser(this._value + direction * 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._valueFormat = settings.valueFormat ?? defaultFormat(this._step); + this._defaultFormat = settings.valueFormat === undefined; + + this.trackColor = settings.trackColor ?? "#00000033"; + this.fillColor = settings.fillColor ?? "#2a7de1"; + this.thumbColor = settings.thumbColor ?? "#ffffff"; + 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; + 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._valueFormat(this._max, 1), + 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.refreshValueText(); + this.focusable = true; + this.hoverScale = settings.hoverScale ?? 1; + this.pressScale = settings.pressScale ?? 1; + this.enabled = settings.enabled ?? true; + } + + /** + * The lowest value. Setting it clamps the value into the new range, + * without calling `onChange`. + */ + get min(): number { + return this._min; + } + + 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; + } + + 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); + if (this._defaultFormat) { + // the default shows as many decimals as the step has + this._valueFormat = defaultFormat(this._step); + } + 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._defaultFormat = false; + 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}. + * + * 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.refreshValueText(); + this.isDirty = true; + } + } + + /** + * 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`. + */ + override cancelPress(): void { + this.endDrag(false); + super.cancelPress(); + } + + /** + * 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; + // 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(Math.max(decimals(this._step), decimals(this._min))), + ); + } + 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 ratio = this.ratio; + 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); + 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 + 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); + for (const type of END_EVENTS) { + globalThis.removeEventListener?.(type, 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) { + // 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); + } + + /** + * 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.thumbHoverColor : this.thumbColor, a); + renderer.fillEllipse(tx + tw / 2, ty + th / 2, r, r); + 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); + 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 { + // `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))); +} + +/** + * The default value text: whole numbers for a whole step, two decimals + * otherwise. + * @ignore + * @internal + */ +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 193141be2..62b0a7edb 100644 --- a/packages/melonjs/src/renderable/ui/uitextbutton.ts +++ b/packages/melonjs/src/renderable/ui/uitextbutton.ts @@ -1,41 +1,177 @@ 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"; import BitmapText from "../text/bitmaptext.js"; -import UIBaseElement from "./uibaseelement.ts"; +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 { focusOwner, isAcceptKey, ringVisible } from "./uifocus.ts"; +import type { UILabel } 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 extends UIJuiceSettings { + /** + * The font: the name of a loaded bitmap font, or a CSS font family. A + * name with a font image in the loader, or a `fontData` setting, is + * drawn through {@link BitmapText}, as before; anything else through + * {@link Text}. + * @default "sans-serif" + */ font?: string; + /** the bitmap font data, when it is loaded under another name than `font` */ + fontData?: string; + /** + * The label's height in pixels, for a bitmap font as for a CSS font: the + * same setting as on the other widgets. Takes over from `size`. + */ + 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, 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; - hoverOffColor?: string; - hoverOnColor?: string; - borderStrokeColor?: string; - fillStyle?: string; + /** + * 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) + * @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?: 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 + * `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 older name of `height` */ borderHeight?: number; + /** passed on to the {@link BitmapText} of a bitmap font label */ [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", + * fontSize: 24, + * onClick: () => state.change(state.PLAY), + * }); + * + * // skinned from an atlas, any width + * const accept = new UITextButton(30, 250, { + * font: "kenpixel", + * fontSize: 12, + * text: "Accept", + * width: 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 +187,11 @@ export default class UITextButton extends UIBaseElement { */ bindKey: string | number; + /** + * What the button does: see {@link UITextButtonSettings.onClick}. + */ + onClickCallback: ((button: UITextButton) => void) | undefined; + private keyboardKey: number | undefined; private keyboardActive = false; @@ -68,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 ( + ringVisible() && + focusOwner() !== undefined && + focusOwner() !== this && + isAcceptKey(keyCode) + ) { + return; + } this.keyboardKey = keyCode; this.released = false; this.isDirty = true; @@ -82,6 +233,7 @@ export default class UITextButton extends UIBaseElement { this.released = true; this.isDirty = true; this.onRelease(); + this.completeClick(); }; private readonly cancelKeyboardPress = () => { @@ -95,17 +247,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 background while the button is held down. Left undefined, the + * button shows {@link UITextButton#hoverOnColor}, whatever it is then. + */ + 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),
@@ -122,9 +280,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 +295,14 @@ export default class UITextButton extends UIBaseElement { */ border: RoundRect; + /** the art, when the button is skinned */ + readonly skin: UITextButtonSkin | undefined; + + /** @ignore */ + private _skin: SkinDrawer | undefined; + /** 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 +310,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 { @@ -166,45 +333,175 @@ 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.onClickCallback = settings.onClick; this.hoverOffColor = settings.hoverOffColor || "#00aa0080"; this.hoverOnColor = settings.hoverOnColor || "#00ff00ff"; + // 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.textAlign = settings.textAlign || "center"; + this.textBaseline = settings.textBaseline || "middle"; + const fillStyle = settings.labelFillStyle ?? settings.fillStyle; - this.textBaseline = settings.textBaseline = - settings.textBaseline || "middle"; + const font = settings.font ?? "sans-serif"; + // 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 + ) { + 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.fontSize ?? settings.size ?? 16, + fillStyle: fillStyle ?? "#000000", + textAlign: this.textAlign, + textBaseline: this.textBaseline, + text: settings.text ?? "", + }); + } - this.bitmapText = new BitmapText(0, 0, settings as any); + this.skin = settings.skin; + 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 + 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; // 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.focusable = true; + this.hoverScale = settings.hoverScale ?? 1; + this.pressScale = settings.pressScale ?? 1; + this.enabled = settings.enabled ?? true; + } + + /** + * 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(): BitmapText { + return this.label as BitmapText; + } + + set bitmapText(value: BitmapText) { + 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. + */ + override cancelPress(): void { + this.cancelKeyboardPress(); + super.cancelPress(); + } + + /** + * 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; + } - // adjust size position - this.bitmapText.pos.set(this.width / 2, this.height / 2); + /** + * 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.onClickCallback !== undefined) { + this.onClickCallback(this); + } } override onActivateEvent(): void { @@ -238,7 +535,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 +554,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 ?? this.hoverOnColor); + } 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/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 new file mode 100644 index 000000000..0f0012648 --- /dev/null +++ b/packages/melonjs/tests/ui-widgets.spec.js @@ -0,0 +1,2343 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + Application, + BitmapText, + boot, + Container, + event, + input, + loader, + 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, pointerId = 1) => { + app.renderer.getCanvas().dispatchEvent( + new PointerEvent(type, { + ...at(gameX, gameY), + pointerId, + 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(); + 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(() => { + 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", boxSize: 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, + valueFormat: (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].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( + 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].onClickCallback = 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].onClickCallback = 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.floating = true; + box.clipping = true; + const inside = new UITextButton(0, 10, { + text: "in", + width: 100, + height: 30, + }); + 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(outClick).not.toHaveBeenCalled(); + // and without the clipping it would have + box.clipping = false; + click(150, 270); + expect(outClick).toHaveBeenCalledTimes(1); + }); + }); + + 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 = 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); + }); + + 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 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 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("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("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("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, { + 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("keyboard and gamepad focus", () => { + 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 }; + }; + + afterEach(() => { + UIBaseElement.focusOwner?.blur(); + }); + + it("leaves the arrows alone until a widget takes the focus", () => { + column(); + press(input.KEY.DOWN); + 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(UIBaseElement.focusOwner).toBe(b); + press(input.KEY.DOWN); + expect(UIBaseElement.focusOwner).toBe(c); + press(input.KEY.UP); + expect(UIBaseElement.focusOwner).toBe(b); + // nothing to the right: the focus stays + press(input.KEY.RIGHT); + expect(UIBaseElement.focusOwner).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(UIBaseElement.focusOwner).toBe(d); + expect(onChange).toHaveBeenCalledTimes(3); + expect(onChangeEnd).toHaveBeenCalledTimes(3); + }); + + it("skips disabled widgets", () => { + const { a, b, c } = column(); + b.enabled = false; + a.focus(); + press(input.KEY.DOWN); + expect(UIBaseElement.focusOwner).toBe(c); + }); + + it("shows the ring after a key, and hides it when the mouse moves", () => { + const { a } = column(); + a.focus(); + press(input.KEY.DOWN); + expect(ringVisible()).toBe(true); + pointer("pointermove", 700, 500); + expect(ringVisible()).toBe(false); + }); + + 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(UIBaseElement.focusOwner).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(UIBaseElement.focusOwner).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("a press held while the focus moves is called off, not left down", () => { + const { a, b } = column(); + const onClick = vi.fn(); + a.onClickCallback = onClick; + a.focus(); + key(input.KEY.ENTER); + press(input.KEY.DOWN); + key(input.KEY.ENTER, true); + expect(UIBaseElement.focusOwner).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"); + a.focus(); + 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; + b.focus(); + 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 the focus is given 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 { + buttons[input.GAMEPAD.BUTTONS.FACE_1].pressed = true; + a.focus(); + 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; + own.focus(); + expect(UIBaseElement.focusOwner).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; + a.focus(); + for (let i = 0; i < 5; i++) { + press(input.KEY.DOWN); + } + expect(UIBaseElement.focusOwner?.ancestor).not.toBe(hidden); + }); + + it("moves on from a focused widget that was disabled since", () => { + const { a, b, c } = column(); + b.focus(); + b.enabled = false; + press(input.KEY.DOWN); + expect(UIBaseElement.focusOwner).toBe(c); + void a; + }); + + it("blur() gives the keys back to the game", () => { + const { a } = column(); + a.focus(); + a.blur(); + expect(UIBaseElement.focusOwner).toBeUndefined(); + press(input.KEY.DOWN); + 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(); + }); + }); + + 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 }); + 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).toBe(20); + expect(b.label.fillStyle.toHex().toLowerCase()).toBe("#ff0000"); + // 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", () => { + 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..a4d871f50 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, @@ -272,132 +273,63 @@ 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"; - } - } + 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("hoverOffColor should default to #00aa0080", () => { - const btn = new UITextButtonStub(0, 0, { font: "Arial", text: "OK" }); + 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"); }); }); - 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); }); }); });