From 7c90e781cc81d7dc8c827ebaf23fe33213b3b22d Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Fri, 16 Oct 2026 10:03:51 +0800 Subject: [PATCH] UI: a panel widget, and a border that stays inside the box it was given `UIPanel` is a rectangular surface with a background, an optional border and children: the thing every menu, dialog and HUD frame starts by writing by hand. One example in this repository had three of them, each repeating the same half dozen renderer calls and the same correction for the way `setColor` folds a colour's own alpha into the global one. That correction is why this is a widget rather than a snippet: leaving it out does not fail, it renders the panel at the wrong opacity. It is a `UIBaseElement`, so children are positioned relative to it and are asked about a click before it is, and `blocking` decides whether a click that misses every child is swallowed rather than reaching the game beneath. `bobAmplitude` / `bobPeriod` give it the slow float an idle dialogue or pause panel usually has. The offset goes into `getAbsolutePosition()`, which is the one hook both bounds paths funnel through, so the panel is clickable WHERE IT IS DRAWN and so is every widget inside it, at any depth. `pos` is left alone, so a caller still reads and writes the resting place rather than a value that moves under them. `currentTransform` was the obvious route and is a dead end: it reaches a renderable's own bounds but not its children's, so a bobbing panel's swallow rectangle would drift off the widgets sitting in it. `ProgressBar` and `UIPanel` now share the shape and colour plumbing, which is how the border came to be drawn two different ways in two widgets that look the same. Two shipped `ProgressBar` defects go with it: - Its border was centred on the bar's own edge, so half of it fell outside the width and height the bar was given and painted over its neighbour, while `getBounds()` knew nothing about those pixels. A square border is four fills tiling the frame exactly; a rounded one is stroked on a path inset by half the border width with its radius pulled in to match. This also fixes a square border at the default width of 1 losing all four corners, since the renderers only generate corner joins above a line width of one. - Destroying one twice threw `Instance is already in pool.` It handed its cached colour back without clearing the handle, and a pool refuses a double release. The bob offset is rounded, which is not cosmetic: the renderers snap a translation with `|0`, truncating toward zero, so an amplitude of 3 moved 4px instead of 6 and spent half the cycle parked at the extremes. Handing the renderer a whole number makes its own snap a no-op, and makes the drawn position and the hit area agree exactly. Closes part 4 of #1722. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/melonjs/CHANGELOG.md | 3 + .../skills/melonjs-ui-and-text/SKILL.md | 91 ++- packages/melonjs/src/index.ts | 3 + .../melonjs/src/renderable/ui/panelpaint.ts | 231 +++++++ .../melonjs/src/renderable/ui/progressbar.ts | 167 +---- packages/melonjs/src/renderable/ui/uipanel.ts | 440 +++++++++++++ packages/melonjs/tests/progressbar.spec.js | 98 ++- packages/melonjs/tests/ui-interaction.spec.js | 131 ++++ packages/melonjs/tests/uipanel.spec.js | 577 ++++++++++++++++++ 9 files changed, 1598 insertions(+), 143 deletions(-) create mode 100644 packages/melonjs/src/renderable/ui/panelpaint.ts create mode 100644 packages/melonjs/src/renderable/ui/uipanel.ts create mode 100644 packages/melonjs/tests/uipanel.spec.js diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 21d0ae374..91eafdf71 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -18,6 +18,7 @@ - **`getPool("text")` and `getPool("colorLayer")`**: typed pools for the two renderables whose only recycling route was the legacy name-keyed pool. `getPool("text").get(x, y, settings)` hands back a label with its measurement cache already allocated, and `release()` gives it back - **Gamepad vibration**: `input.vibrateGamepad(index, options)` plays a rumble effect, `input.stopGamepadVibration(index)` cuts a long one short, and `input.hasGamepadVibration(index)` reports whether a pad has motors at all, for an options screen that should not offer a setting the hardware cannot honour. Safe to call blind: an empty slot, a pad without motors and a browser with no Gamepad API all resolve `false` and do nothing, so game code never branches on support. Magnitudes default to FULL, unlike the underlying web API where every parameter defaults to zero and a bare `playEffect` is a silent no-op. A second call preempts the first on the same pad, which is what makes a rapid series of hits read as a rapid series of hits rather than a queue - **`input.activeGamepad()`**: the index of the pad the player is actually holding, which is not something the browser reports. Slots are handed out in plug order, so index 0 is a guess: a wheel, a spare pad or a keyboard-shaped device left plugged in can hold slot 0 for a whole session while the player presses buttons on slot 1. This follows the last pad to produce a BOUND input, falling back to the first connected pad and then to -1 when there is none, and it is what a one-player game should pass to `vibrateGamepad`, since rumbling the wrong pad is both useless and impossible to notice in testing. It follows bound inputs only, which is what makes it an answer about the player rather than about electrical noise: a worn stick at rest reports movement for ever and would otherwise claim to be the active pad. Bindings are still per index, so a game that wants any pad to drive it binds the slots it will accept to the same actions, and a bound index with no pad in it costs nothing in the poll +- **`UIPanel`**: a rectangular surface with a background, an optional border and children, which is what every dialog, menu card and HUD frame starts by writing by hand. One example in this repository had three of them, each repeating the same half dozen renderer calls and the same correction for the way `setColor` folds a colour's own alpha into the global one, which is the part that is easy to leave out and does not fail when you do, it just renders the panel at the wrong opacity. It is a `UIBaseElement`, so children are positioned relative to the panel and are asked about a click before it is, and `blocking` (on by default) decides whether a click that misses every child is swallowed rather than reaching the game underneath. `bobAmplitude` and `bobPeriod` give it the slow float an idle dialogue or pause panel has in a lot of games, which is there so a screen waiting for input does not look frozen: the panel is clickable where it is drawn, because the offset goes into `getAbsolutePosition()`, which is what the bounds pipeline reads, so the hit area of the panel and of every widget inside it follows the float at any depth, while `pos` is left alone so a caller still reads and writes the resting place rather than a value that moves under them. It is fed from `update(dt)` rather than a wall clock, so it obeys the pause like everything else, and a panel that should keep moving while the game is paused, which is what a pause menu is, sets `updateWhenPaused`. Where it lands in the paint order is the depth it is added at, and in a world sorted on `"depth"`, which is every `Camera3d` scene, a floating child is ranked by the magnitude of its `pos.z` with the SMALLER one drawn on top, so a dialog there wants a depth of 0 or 1 rather than the 2D habit of 9000 ([#1722](https://github.com/melonjs/melonJS/issues/1722)) - **`createPool` is exported**, along with the `Pool` and `CreatePoolOptions` types it names, so a pool for one of your own classes no longer needs a deep import and a field holding one can be declared. `getPool()` only reaches the pools the engine ships, so this is what a game moving off `pool.register(name, Class, true)` needs ### Changed @@ -40,6 +41,8 @@ - Input: the gamepad axis `threshold` is now documented as what it is, a value ADDED to the deadzone rather than one used instead of it. The test is `abs(value) >= deadzone + abs(threshold)`, so under the default deadzone of 0.1 the figure of `0.5` in the documented example does not fire until 0.6 of full travel, and raising `setGamepadDeadzone` moves every axis binding with it. The behaviour is unchanged: what a game should pass is the point it wants the stick to break at, minus the deadzone - Audio: a page left idle for thirty seconds before its first click could lose every loaded clip for the life of the page while `tone()` and `noise()` still played. The idle power-saving suspend waited for the browser to confirm it before the engine would resume anything, and WebKit never confirms a suspend asked for outside a user gesture on a context the page was never allowed to start. The first click then started the hardware through the unlock path, which is why generated sound was heard, but the engine still believed its suspend was in flight and parked each clip's playback waiting for a resume that could not come. A resume requested while the suspend is in flight is now issued at once, and the browser orders it after the suspend. A user gesture or the tab coming back to the front also revives playback whenever the engine and the context disagree about whether it is running - Audio: a sound's `onend` callback could fire for a DIFFERENT sound. Stopping a voice clears the callbacks bound to it, but the clear removed only the first of them and left any others attached; playback ids are handed to the next voice to take the slot, so a leftover callback outlived its own sound and then ran when an unrelated one finished. Most visible on a clip played many times with a per-play `onend`, where the callbacks accumulated +- UI: a `ProgressBar` border was drawn centred on the bar's own edge, so half of it fell outside the width and height the bar was given and painted over whatever sat next to it, and the bar's `getBounds()` did not account for those pixels. A square border is now four fills that tile the frame exactly, and a rounded one is stroked on a path pulled in by half the border width with its radius pulled in to match, so the border lands inside the rectangle either way. This also fixes a square border at the default width of 1 coming up short at all four corners, since the renderers only generate corner joins above a line width of one +- UI: destroying a `ProgressBar` twice threw `Instance is already in pool.` instead of doing nothing the second time. It handed its cached colour back to the pool without clearing the handle, and a pool refuses a double release; a renderable's `destroy` is reached more than once often enough that this was not a fault at the call site - UI: `UITextButton.bindKey` now activates the button from an `input.KEY` keycode or an existing action name. Held keys click once, only the initiating key releases the button, and blur or removal clears the keyboard press without changing global bindings. Two things to know before binding a key to a button that already exists: the handlers fire with NO pointer, so an `onClick(event)` written when only a pointer could press it throws the first time someone uses the shortcut, and losing focus cancels a held key without calling `onRelease()`, so a button that fires while held has to stop on `event.BLUR` as well ([#1722](https://github.com/melonjs/melonJS/issues/1722), thanks @snowyukitty) - Application: coming back to the tab resumed a game that had paused ITSELF. The focus handler undid the pause whatever had applied it, so switching away from an open pause menu and back left the run playing underneath it. Only a pause this application applied on blur is now reversed on focus, which is the rule `unmuteOnFocus` already followed. The flag recording it is raised and never cleared on the way out, because a tab switch emits `BLUR` twice, once from the window and once from `visibilitychange`, and the second one finds the state already paused by the first: clearing it there threw away the record of the pause just applied and the game never came back at all - Camera / state: a camera effect's `onComplete` ran on every frame after the effect finished, not once when it finished. It sat in the completed branch of `update()` with nothing to say it had already fired, which went unnoticed while every completed effect was swept away on the frame it completed. A persistent one is kept for its owner, and a state transition's hide half is exactly that: its callback defers the switch and the effect is only taken away once that switch lands, so every frame in between queued another switch into the state being entered. Nothing refuses a switch to the state already current at that point, so the second one tore down the stage it had just built and built it again, which for a game means the whole scene constructed twice and every `onResetEvent` run twice. `MaskEffect` and `ShakeEffect` now fire on the transition into complete diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index 832434f6c..d2df9fed7 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, Draggable and DropTarget, the floating screen-space container pattern, which of two overlapping panels gets the pointer, Text and BitmapText, web font loading, and NineSliceSprite panels. Triggers on: UI, HUD, button, UIBaseElement, UISpriteElement, UITextButton, ProgressBar, progress bar, health bar, gauge, Draggable, DropTarget, menu, dialogue, overlapping panels, moveToTop, onOver, onClick, bindKey, keyboard shortcut, button keyboard, Tween, easing, animate a label, wavy text, per-glyph effect, glyphEffect, shake text, rainbow text, Text, BitmapText, fillStyle, fillGradient, gradient text, tint, font, fontface, wordWrapWidth, NineSliceSprite, score display, floating." +description: "Use this skill for HUDs, buttons, menus, dialogue panels, progress bars and on-screen text in melonJS. Covers UIBaseElement/UISpriteElement/UITextButton, ProgressBar, UIPanel, Draggable and DropTarget, the floating screen-space container pattern, which of two overlapping panels gets the pointer, Text and BitmapText, web font loading, and NineSliceSprite panels. Triggers on: UI, HUD, button, UIBaseElement, UISpriteElement, UITextButton, ProgressBar, progress bar, health bar, gauge, Draggable, DropTarget, menu, dialogue, overlapping panels, moveToTop, onOver, onClick, bindKey, keyboard shortcut, button keyboard, Tween, easing, animate a label, wavy text, per-glyph effect, glyphEffect, shake text, rainbow text, Text, BitmapText, fillStyle, fillGradient, gradient text, tint, font, fontface, wordWrapWidth, NineSliceSprite, score display, floating." license: MIT --- @@ -204,9 +204,10 @@ class HUD extends Container { app.world.addChild(new HUD(app), 100); // explicit z — not `.z = Infinity` ``` -There is no `UIContainer` class. Use `Container`, or `UIBaseElement` when the -panel itself must react to the pointer — it is a `Container` that already sets -`floating = true` and `isKinematic = false`. +There is no `UIContainer` class. Use `Container` for a bare grouping, +`UIPanel` for anything with a background or a border (see below), or +`UIBaseElement` when you want the pointer handling and nothing drawn — it is a +`Container` that already sets `floating = true` and `isKinematic = false`. Three things matter here: @@ -294,6 +295,88 @@ Two more worth knowing: - **`trackColor: null` leaves the bar hollow**, which is what a HUD over a busy scene usually wants: the border is then the only chrome. +### Panels and cards: `UIPanel` + +A dialog, a menu card, a HUD frame: a rectangle with a fill, an optional +border and children on top. `UIPanel` is a `UIBaseElement`, so it is a +container that carries pointer events. + +```js +import { UIPanel, Text } from "melonjs"; + +const card = new UIPanel(cx - 200, cy - 120, { + width: 400, height: 240, + fillColor: "#0a1526", // null leaves it transparent + borderColor: "rgba(130, 200, 240, 0.55)", // null for none + borderWidth: 2, + radius: 14, +}); +card.addChild(new Text(200, 24, { text: "PAUSED", textAlign: "center" })); +app.world.addChild(card, -150); // small depth: see the 3D section below +``` + +**`fillColor`, not `backgroundColor`.** `Container` already has a +`backgroundColor` and it does something else entirely: it is handed to +`clearColor` and wipes the whole render target, so a panel setting it repaints +the screen. The names here match `ProgressBarSettings`. + +**The border is drawn INSIDE the box.** `width` and `height` are the outer +size, border included, so a panel never paints past the rectangle you gave it +and its `getBounds()`, which is what the pointer dispatcher hit-tests, stays +that rectangle. One thing to know with a `radius`: keep `borderWidth` at 2 or +more. A rounded border has to be stroked, and a stroke at exactly 1 goes out as +plain lines, which drops a pixel where each arc meets its straight edge. A +square border is four fills and is fine at 1. + +**`blocking` (default `true`)** makes the panel consume clicks and hovers that +miss its children, which is what a dialog wants — a click on the card, or on +the dimmed scene behind it, must not reach the game. Children are unaffected: +the dispatcher asks a child before the container holding it. Set it `false` for +a frame drawn over a playfield that still has to receive clicks. + +A full-screen panel with a translucent `fillColor` and `borderColor: null` is +the veil behind a modal, and it blocks for free. + +**`bobAmplitude` / `bobPeriod`** give the panel the slow float an idle +dialogue or pause panel usually has, so a screen waiting for input does not +look frozen: + +```js +card.bobAmplitude = 3; // pixels either side; 2 to 4 is the usual range +card.bobPeriod = 2500; // ms for a full up-and-down cycle +``` + +Three things to know. **The panel is clickable where it is drawn**: the offset +goes into `getAbsolutePosition()`, which is what the bounds pipeline reads, so +the hit area of the panel and of every widget inside it follows the float at +any depth. `pos` is left alone, so it still reads and writes the resting place. +It is fed from `update(dt)`, so it stops with the rest of the game on +`state.pause()` unless the panel sets `updateWhenPaused`, which a pause menu +does anyway. And do not go below an amplitude of 2: the offset is rounded to whole +pixels, so an amplitude of 1 has three positions to move through and twitches +instead of drifting. + +One consequence worth knowing: `getBounds()` on a bobbing panel answers +differently each frame, so do not cache it or compare two children's bounds +while a bob is running. + +### A pause overlay: `updateWhenPaused` cascades + +`state.pause()` stops every world child stepping while rendering carries on, so +the overlay that reads the key to UNPAUSE has to opt out: + +```js +const overlay = new UIPanel(0, 0, { width: w, height: h, fillColor: "rgba(4,8,16,0.72)" }); +overlay.updateWhenPaused = true; // the whole subtree, not just this one +``` + +The flag covers the container's **entire subtree**, so nested labels, buttons +and the card inside the veil all keep stepping without being flagged one by +one. That matters for more than their `update`: `inViewport` is computed in the +same loop that honours the pause, so a skipped child keeps whatever it had — +`false` for one built while paused — and `Container.draw` then declines to draw +it, silently. A panel whose labels never appear while paused is this. + ### Buttons: extend the handlers, do not bind listeners `UISpriteElement` is a `Sprite` that already registers itself for pointer diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index 99969ac98..047f187b8 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -54,6 +54,7 @@ import Trail3d from "./renderable/trail3d.js"; import Trigger from "./renderable/trigger.js"; import ProgressBar from "./renderable/ui/progressbar.ts"; import UIBaseElement from "./renderable/ui/uibaseelement.ts"; +import UIPanel from "./renderable/ui/uipanel.ts"; import UISpriteElement from "./renderable/ui/uispriteelement.ts"; import UITextButton from "./renderable/ui/uitextbutton.ts"; import Stage from "./state/stage.ts"; @@ -194,6 +195,7 @@ export type { ProgressBarDirection, ProgressBarSettings, } from "./renderable/ui/progressbar.js"; +export type { UIPanelSettings } from "./renderable/ui/uipanel.js"; export * as device from "./system/device.js"; export * as event from "./system/event.ts"; // and the two types it names, so a game can declare a field holding one @@ -301,6 +303,7 @@ export { Tween, timer, UIBaseElement, + UIPanel, UISpriteElement, UITextButton, VignetteEffect, diff --git a/packages/melonjs/src/renderable/ui/panelpaint.ts b/packages/melonjs/src/renderable/ui/panelpaint.ts new file mode 100644 index 000000000..d6e7ccc7e --- /dev/null +++ b/packages/melonjs/src/renderable/ui/panelpaint.ts @@ -0,0 +1,231 @@ +import type { RoundRect } from "../../geometries/roundrect.ts"; +import { roundedRectanglePool } from "../../geometries/roundrect.ts"; +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 */ +export type Paint = Color | string | Gradient; + +/** + * The shape and colour plumbing behind the widgets that draw a filled, + * optionally bordered rectangle. + * + * Internal, and deliberately not a renderable: {@link ProgressBar} extends + * `Renderable` while {@link UIPanel} extends `Container` through + * `UIBaseElement`, so there is no class the two could share. They share this + * instead, as a field. + * + * Both of them had their own copy of all three methods below, which is how the + * border ended up drawn two different ways in two widgets that look the same. + * @ignore + * @internal + */ +export class PanelPainter { + /** + * The last CSS string parsed, and what it parsed to. + * + * Cached because a widget's colours almost never change between frames and + * parsing a string per shape per frame is pure waste. + */ + private _css: Color | undefined; + private _lastCss = ""; + /** + * The rounded rectangle borrowed to draw through, kept for the owner's + * lifetime and given back in {@link PanelPainter.dispose}. + * + * Not fetched and released per draw: `get` and `release` are a handful of + * Set and Map operations each, and this runs for up to three shapes every + * frame a widget is up. + */ + private _round: RoundRect | undefined; + + /** + * Set the draw colour, with its own alpha folded into the cascaded one. + * + * Not simply `setColor` then `setGlobalAlpha(cascade)`: that discards + * whatever alpha the colour asked for, so a background given `#00000033` + * comes out solid. Nor `setColor` then reading the alpha back, which is + * worse: a renderer keeps ONE current colour, and parsing a six digit hex + * into it leaves the alpha from the colour before, so a translucent + * background silently leaks into the border drawn after it. + * @param renderer - the renderer to set the colour on + * @param color - what to paint with + * @param cascade - the alpha the owning renderable inherited + */ + paint(renderer: Renderer, color: Paint, cascade: number) { + if (typeof color === "string") { + // Borrowed on FIRST use, not in the constructor: a widget handed + // `Color` instances never needs one at all, and a widget that is + // built and thrown away without drawing never touches the pool. + let css = this._css; + if (css === undefined) { + css = colorPool.get(); + this._css = css; + } + if (color !== this._lastCss) { + css.parseCSS(color); + this._lastCss = color; + } + renderer.setColor(css); + renderer.setGlobalAlpha(css.alpha * cascade); + } else if (color instanceof Color) { + renderer.setColor(color); + renderer.setGlobalAlpha(color.alpha * cascade); + } else { + renderer.setColor(color); + renderer.setGlobalAlpha(cascade); + } + } + + /** + * Fill or stroke a rectangle, rounded if it has a radius. + * @param renderer - the renderer to draw with + * @param x - left edge + * @param y - top edge + * @param w - width + * @param h - height + * @param radius - corner radius; `0` is square + * @param stroke - outline it instead of filling it + */ + rect( + renderer: Renderer, + x: number, + y: number, + w: number, + h: number, + radius = 0, + stroke = false, + ) { + if (w <= 0 || h <= 0) { + return; + } + if (radius > 0) { + // through the shape dispatch, which is the only portable route to + // a rounded rectangle: `fillRoundRect` is on the concrete + // renderers and not on the base class + if (this._round === undefined) { + this._round = roundedRectanglePool.get(x, y, w, h, radius); + } else { + this._round.pos.set(x, y); + this._round.setSize(w, h); + this._round.radius = radius; + } + if (stroke) { + renderer.stroke(this._round); + } else { + renderer.fill(this._round); + } + } else if (stroke) { + renderer.strokeRect(x, y, w, h); + } else { + renderer.fillRect(x, y, w, h); + } + } + + /** + * Draw a border around the rectangle, entirely INSIDE it. + * + * Inside is the whole point, and it is not what a stroke does on its own. + * Every backend expands a line symmetrically about its path, WebGPU + * literally so (`primitive.wgsl`: `aVertex.xy + aNormal * lineWidth * + * 0.5`), so a plain `strokeRect` on the widget's own rectangle puts half + * the border outside it. A widget would then paint `borderWidth / 2` past + * the `width` and `height` it was given, over whatever sits next to it, and + * `getBounds()` would not know about those pixels. That matters beyond + * looks for a {@link UIPanel}, whose bounds are what the pointer + * dispatcher hit-tests. + * + * The two cases get there differently: + * + * - **Square** corners are drawn as four fills that tile the frame exactly. + * They land where they are put at any width, and have no joins to miss: + * `strokeRect` only generates corner joins ABOVE a line width of one + * (`webgl_renderer.js`, `primitive_batcher.js`), so at exactly 1, the + * common case for a HUD frame, a stroked square border comes up short at + * all four corners. + * - **Rounded** corners have no four-rect form, so they are stroked. The + * path is inset by half the border width, with the radius pulled in to + * match, which puts the centred stroke inside the rectangle after all. + * + * The four fills do not overlap, which the insets on the left and right + * pieces are there for. Four full-length pieces would also cover the + * frame, but {@link PanelPainter.paint} puts a colour's own alpha into + * `globalAlpha`, so a translucent border would blend twice where they + * crossed and the corners would come out darker than the sides. + * @param renderer - the renderer to draw with + * @param x - left edge of the rectangle being bordered + * @param y - top edge + * @param w - width + * @param h - height + * @param radius - corner radius; `0` is square + * @param borderWidth - how thick, clamped so it cannot cross itself + */ + border( + renderer: Renderer, + x: number, + y: number, + w: number, + h: number, + radius: number, + borderWidth: number, + ) { + if (w <= 0 || h <= 0) { + return; + } + // Clamped so a border thicker than the widget is half of cannot run + // past the far edge, which would leave the pieces overlapping and, on + // the rounded path, invert the inset rectangle. + const bw = Math.min(borderWidth, w / 2, h / 2); + if (bw <= 0) { + return; + } + if (radius > 0) { + // Put `lineWidth` back afterwards, because `RenderState.save()` + // does not stack it: it keeps colour, tint, transform, scissor, + // gradient, line DASH, blend mode, shader and depth, and + // `lineWidth` is a plain field on the renderers. Left alone, a + // widget with a 2px border makes everything stroked after it in + // the frame 2px too. + const previousWidth = renderer.lineWidth; + renderer.lineWidth = bw; + this.rect( + renderer, + x + bw / 2, + y + bw / 2, + w - bw, + h - bw, + Math.max(0, radius - bw / 2), + true, + ); + renderer.lineWidth = previousWidth; + } else { + renderer.fillRect(x, y, w, bw); // top + renderer.fillRect(x, y + h - bw, w, bw); // bottom + renderer.fillRect(x, y + bw, bw, h - bw * 2); // left + renderer.fillRect(x + w - bw, y + bw, bw, h - bw * 2); // right + } + } + + /** + * Hand back everything borrowed. Call from the owner's `onDestroyEvent`. + * + * Safe to call twice, which is not a nicety: `Pool#release` THROWS on a + * double release, and a renderable's `destroy` is reached more than once + * often enough that it cannot be treated as a bug in the caller. Each + * handle is cleared as it goes back, so a second call has nothing to give. + */ + dispose() { + if (this._round !== undefined) { + roundedRectanglePool.release(this._round); + this._round = undefined; + } + if (this._css !== undefined) { + colorPool.release(this._css); + this._css = undefined; + // so a redraw after a dispose re-parses into the fresh instance + // rather than trusting a cache that outlived its colour + this._lastCss = ""; + } + } +} diff --git a/packages/melonjs/src/renderable/ui/progressbar.ts b/packages/melonjs/src/renderable/ui/progressbar.ts index 2168b5f00..28b9d1e51 100644 --- a/packages/melonjs/src/renderable/ui/progressbar.ts +++ b/packages/melonjs/src/renderable/ui/progressbar.ts @@ -1,12 +1,10 @@ -import type { RoundRect } from "../../geometries/roundrect.ts"; -import { roundedRectanglePool } from "../../geometries/roundrect.ts"; -import { Color, colorPool } from "../../math/color.ts"; import { clamp } from "../../math/math.ts"; import { off, on } from "../../system/event.ts"; -import type { Gradient } from "../../video/gradient.js"; import type Renderer from "../../video/renderer.js"; import Renderable from "../renderable.js"; import Text from "../text/text.js"; +import type { Paint } from "./panelpaint.ts"; +import { PanelPainter } from "./panelpaint.ts"; /** * The name of any event the engine knows about. @@ -16,9 +14,6 @@ import Text from "../text/text.js"; */ type EventName = Parameters[0]; -/** anything {@link Renderer#setColor} will take */ -type Paint = Color | string | Gradient; - /** which way the fill grows */ export type ProgressBarDirection = | "left-to-right" @@ -167,11 +162,16 @@ export default class ProgressBar extends Renderable { readonly bindEvent?: EventName | undefined; private _value: number; - /** reused for the rounded path, so a rounded bar allocates nothing */ - private _round?: RoundRect | undefined; - /** a css colour string resolved once and kept; see {@link ProgressBar#_paint} */ - private _css = colorPool.get(); - private _lastCss = ""; + /** + * The shape and colour plumbing, shared with {@link UIPanel}. + * + * A field and not a base class: this widget extends `Renderable` to stay + * cheap, while a panel is a `Container`, so there is no class the two + * could share. + * @ignore + * @internal + */ + private _painter = new PanelPainter(); /** * @param x - position of the bar's left edge @@ -293,95 +293,6 @@ export default class ProgressBar extends Renderable { return this; } - /** - * Set the draw colour, with its own alpha folded into the cascaded one. - * - * Not simply `setColor` then `setGlobalAlpha(cascade)`: that discards - * whatever alpha the colour asked for, so a track given `#00000033` comes - * out solid. Nor `setColor` then reading the alpha back, which is worse — - * a renderer keeps ONE current colour, and parsing a six digit hex into it - * leaves the alpha from the colour before, so the track's transparency - * silently leaks into the fill drawn after it. - * - * So the alpha is resolved HERE, from the value handed in, and nothing is - * read back. A gradient carries alpha in its own stops and has none of its - * own, so it just takes the cascade. - * @param renderer - the renderer to set the colour on - * @param color - what to paint with - * @param cascade - the alpha this renderable inherited - * @ignore - * @internal - */ - private _paint(renderer: Renderer, color: Paint, cascade: number) { - if (typeof color === "string") { - // cached, because this runs per shape per frame and the string is - // almost always the same one as last time - if (color !== this._lastCss) { - this._css.parseCSS(color); - this._lastCss = color; - } - renderer.setColor(this._css); - renderer.setGlobalAlpha(this._css.alpha * cascade); - } else if (color instanceof Color) { - renderer.setColor(color); - renderer.setGlobalAlpha(color.alpha * cascade); - } else { - renderer.setColor(color); - renderer.setGlobalAlpha(cascade); - } - } - - /** - * Paint one rectangle, rounded if this bar is. - * @param renderer - the renderer to draw with - * @param x - left edge - * @param y - top edge - * @param w - width - * @param h - height - * @param stroke - outline it instead of filling it - * @param radius - corner radius; defaults to the bar's own - * @ignore - * @internal - */ - private _rect( - renderer: Renderer, - x: number, - y: number, - w: number, - h: number, - stroke = false, - radius = this.radius, - ) { - if (w <= 0 || h <= 0) { - return; - } - if (radius > 0) { - // through the shape dispatch, which is the only portable route to - // a rounded rectangle: `fillRoundRect` is on the concrete - // renderers and not on the base class - // Borrowed once and kept for this bar's lifetime, then given back - // in `onDestroyEvent`. Not fetched and released per draw: `get` - // and `release` are a handful of Set and Map operations each, and - // this runs for up to three shapes every frame. - if (this._round === undefined) { - this._round = roundedRectanglePool.get(x, y, w, h, radius); - } else { - this._round.pos.set(x, y); - this._round.setSize(w, h); - this._round.radius = radius; - } - if (stroke) { - renderer.stroke(this._round); - } else { - renderer.fill(this._round); - } - } else if (stroke) { - renderer.strokeRect(x, y, w, h); - } else { - renderer.fillRect(x, y, w, h); - } - } - /** * Draw the track, the fill, the border and the label, in that order. * @param renderer - the renderer to draw with @@ -399,8 +310,8 @@ export default class ProgressBar extends Renderable { const alpha = renderer.globalAlpha(); if (this.trackColor !== null) { - this._paint(renderer, this.trackColor, alpha); - this._rect(renderer, x, y, w, h); + this._painter.paint(renderer, this.trackColor, alpha); + this._painter.rect(renderer, x, y, w, h, this.radius); } // the fill grows from one edge, inside the padding @@ -415,45 +326,29 @@ export default class ProgressBar extends Renderable { // fill retreats toward the edge it grew from const fx = this.direction === "right-to-left" ? x + p + iw - fw : x + p; const fy = this.direction === "bottom-to-top" ? y + p + ih - fh : y + p; - this._paint(renderer, this.fillColor, alpha); + this._painter.paint(renderer, this.fillColor, alpha); // Concentric with the track, not the same radius as it. A shape // inset by `padding` has to lose `padding` from its corner radius // too, or it comes out proportionally rounder than the thing it // sits inside: at a radius of 4 with 2 of padding, a 13 tall bar // got a 9 tall fill that was very nearly a capsule inside a frame // that plainly was not. - this._rect(renderer, fx, fy, fw, fh, false, Math.max(0, this.radius - p)); + this._painter.rect( + renderer, + fx, + fy, + fw, + fh, + Math.max(0, this.radius - p), + ); } if (this.borderColor !== null && this.borderWidth > 0) { - this._paint(renderer, this.borderColor, alpha); - const bw = Math.min(this.borderWidth, w / 2, h / 2); - if (this.radius > 0) { - // a rounded border has to be a stroke; there is no four-rect - // equivalent of a corner arc - const previous = renderer.lineWidth; - renderer.lineWidth = this.borderWidth; - this._rect(renderer, x, y, w, h, true); - renderer.lineWidth = previous; - } else { - // FOUR FILLED RECTS, not `strokeRect`, and for two reasons. - // - // A stroke is centred on the path, so half of it falls outside - // the bar: a bordered bar would be `borderWidth` wider than it - // says it is, and would paint over whatever it sits next to. - // - // And `strokeRect` only adds corner joins above a line width - // of one, so at exactly 1 the corners drop a pixel. That is - // visible on any dark HUD and is what a hand-baked frame used - // to avoid with a half-pixel offset. - // - // Four rects have no joins to miss and land exactly where they - // are put, at any width. - renderer.fillRect(x, y, w, bw); // top - renderer.fillRect(x, y + h - bw, w, bw); // bottom - renderer.fillRect(x, y + bw, bw, h - bw * 2); // left - renderer.fillRect(x + w - bw, y + bw, bw, h - bw * 2); // right - } + this._painter.paint(renderer, this.borderColor, alpha); + // Drawn INSIDE the bar: four fills when square, an inset stroke + // when rounded. See {@link PanelPainter.border} for why neither + // one is a plain `strokeRect`. + this._painter.border(renderer, x, y, w, h, this.radius, this.borderWidth); } if (this.label !== undefined) { @@ -496,10 +391,6 @@ export default class ProgressBar extends Renderable { } this.label?.destroy(); this.label = undefined; - if (this._round !== undefined) { - roundedRectanglePool.release(this._round); - this._round = undefined; - } - colorPool.release(this._css); + this._painter.dispose(); } } diff --git a/packages/melonjs/src/renderable/ui/uipanel.ts b/packages/melonjs/src/renderable/ui/uipanel.ts new file mode 100644 index 000000000..a7eea9ad5 --- /dev/null +++ b/packages/melonjs/src/renderable/ui/uipanel.ts @@ -0,0 +1,440 @@ +import type Camera2d from "../../camera/camera2d.ts"; +import type { Matrix3d } from "../../math/matrix3d.ts"; +import type { Vector3d } from "../../math/vector3d.ts"; +import type Renderer from "../../video/renderer.js"; +import type Renderable from "../renderable.js"; +import type { Paint } from "./panelpaint.ts"; +import { PanelPainter } from "./panelpaint.ts"; +import UIBaseElement from "./uibaseelement.ts"; + +/** + * Everything {@link UIPanel} takes. + */ +export interface UIPanelSettings { + /** the panel's width, in pixels */ + width: number; + /** the panel's height, in pixels */ + height: number; + /** + * The fill behind the children; `null` leaves it transparent. + * + * NOT `backgroundColor`, which a `Container` already has and which does + * something else entirely: that one is handed to `clearColor` and wipes + * the whole render target, so a panel setting it would repaint the + * screen. The name here matches {@link ProgressBarSettings} instead. + */ + fillColor?: Paint | null; + /** drawn around the edge; `null` for none */ + borderColor?: Paint | null; + /** how thick that border is */ + borderWidth?: number; + /** corner radius; `0` is square */ + radius?: number; + /** + * How far the panel drifts up and down, in pixels. `0`, the default, + * leaves it still. + * + * See {@link UIPanel#bobAmplitude}. + */ + bobAmplitude?: number; + /** + * How long one full up and down cycle takes, in milliseconds. + * + * See {@link UIPanel#bobPeriod}. + */ + bobPeriod?: number; + /** + * Whether the panel swallows pointer events aimed at what is behind it. + * + * `true` by default, which is what a dialog wants: a click on the panel, + * or on the dimmed scene it covers, must not reach the game underneath. + * Children are unaffected either way, because the dispatcher asks a child + * before the container holding it. + * + * Set `false` for a panel that is only a backdrop, such as a HUD frame + * drawn over a playfield that still has to receive clicks. + */ + blocking?: boolean; +} + +/** + * A rectangular surface with a background, an optional border and children. + * + * The thing every menu, dialog and HUD frame starts by writing: a rounded + * rectangle, a fill, an outline, and some labels on top. One example in this + * repository had three of them, each repeating the same half dozen renderer + * calls, and each repeating the same correction for the way `setColor` folds + * the global alpha in. That correction is the reason this is a widget rather + * than a snippet: it is easy to leave out, and leaving it out does not fail, + * it just renders a panel at the wrong opacity. + * + * It is a {@link UIBaseElement}, so it is a container that carries pointer + * events, and children added to it are drawn over its background and are + * asked about a click before it is. + * + * It is floating, as every UI element is, and where it lands in the paint + * order is the depth it is added at. In a world sorted on `"depth"`, which + * is every `Camera3d` scene, that depth is read the other way round from + * the 2D habit: a floating child is ranked by `|pos.z|` there and the + * SMALLER magnitude draws on top, so a panel added at 9000 to sit above + * everything sits behind everything instead, and a dialog there wants a + * small depth, 0 or 1. + * @example + * import { UIPanel, Text } from "melonjs"; + * + * const panel = new UIPanel(cx - 200, cy - 120, { + * width: 400, + * height: 240, + * fillColor: "#0a1526", + * borderColor: "rgba(130, 200, 240, 0.55)", + * borderWidth: 2, + * radius: 14, + * }); + * // positioned relative to the panel, not to the screen + * panel.addChild(new Text(200, 24, { text: "PAUSED", textAlign: "center" })); + * app.world.addChild(panel); + * @category UI + */ +export default class UIPanel extends UIBaseElement { + /** the fill behind the children; `null` leaves it transparent */ + fillColor: Paint | null; + /** drawn around the edge; `null` for none */ + borderColor: Paint | null; + /** how thick that border is */ + borderWidth: number; + /** corner radius; `0` is square */ + radius: number; + /** whether it swallows pointer events aimed at what is behind it */ + blocking: boolean; + /** + * How far the panel drifts up and down, in pixels. `0`, the default, + * leaves it still. + * + * The slow float an idle dialogue or pause panel has in a lot of games, + * which is there so a screen waiting for input does not look frozen. It + * is a sine, so this is the distance to either extreme and the panel + * covers twice it over a cycle; 2 to 4 is the usual range, and it starts + * by rising rather than sinking. + * + * Do not go much below 2. The renderer snaps a translation to whole + * pixels, truncating, so an amplitude of 1 spends most of the cycle + * reading as 0 and the panel twitches rather than drifts. + * + * The panel is CLICKABLE where it is drawn: the offset goes into + * {@link UIPanel#getAbsolutePosition}, which is what the bounds pipeline + * reads, so the hit area of the panel and of every widget inside it + * follows the float at any depth. `pos` is deliberately left alone, so a + * caller still reads and writes the resting place rather than a value + * that moves under them. + * + * The bounds are rebuilt only on the frames the offset actually changes, + * so a panel with no bob costs nothing. + * @default 0 + */ + bobAmplitude: number; + /** + * How long one full up and down cycle takes, in milliseconds. + * + * Anything at or below zero turns the bob off rather than dividing by it. + * That is not politeness: the offset would be `NaN`, and one `translate` + * by `NaN` writes it into the transform, where every batched vertex under + * it inherits the poison and the GPU clips the lot away. A panel that + * silently vanished would be a long way from an obviously bad period. + * @default 2000 + */ + bobPeriod: number; + + /** + * The shape and colour plumbing, shared with {@link ProgressBar}. + * + * A field and not a base class: that widget extends `Renderable` while + * this one is a `Container` through {@link UIBaseElement}, so there is no + * class the two could share. + * @ignore + * @internal + */ + private _painter = new PanelPainter(); + /** + * How far through the bob cycle the panel is, in milliseconds. + * + * Fed from `update(dt)` and not from a wall clock, so the bob obeys the + * same pause every other animation does. A panel that should keep moving + * while the game is paused, which is exactly what a pause menu is, sets + * `updateWhenPaused` and gets it for the whole subtree. + * @ignore + * @internal + */ + private _bobTime = 0; + /** + * The offset the bounds were last rebuilt with. + * + * The bounds walk is gated on this CHANGING rather than on the bob being + * on, which buys two things: a still panel pays nothing at all, and a + * panel whose bob is switched off mid-cycle gets one last walk instead of + * leaving its hit area parked wherever the sine happened to stop. + * @ignore + * @internal + */ + private _bobApplied = 0; + + /** + * @param x - position of the panel's left edge + * @param y - position of the panel's top edge + * @param settings - see {@link UIPanelSettings} + */ + constructor(x: number, y: number, settings: UIPanelSettings) { + super(x, y, settings.width, settings.height); + this.fillColor = settings.fillColor ?? null; + this.borderColor = settings.borderColor ?? null; + this.borderWidth = settings.borderWidth ?? 1; + this.radius = settings.radius ?? 0; + this.blocking = settings.blocking ?? true; + this.bobAmplitude = settings.bobAmplitude ?? 0; + this.bobPeriod = settings.bobPeriod ?? 2000; + // 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. + this.postEffectNeedsCapture = true; + // A panel's size is given, so its bounds are known and must not be + // grown by what is inside it. `getBounds()` is what the pointer + // dispatcher hit-tests, so with this left on, a label overhanging the + // edge makes a blocking panel swallow clicks OUTSIDE the rectangle it + // drew. `UITextButton` wants the opposite, because its label defines + // it. + // + // Turning it off also took away the refresh of the CHILDREN's own + // bounds, which they are not the same job despite sharing this flag; + // `updateBounds` below puts that half back. + this.enableChildBoundsUpdate = false; + // No `anchorPoint` to set: `Container` already pins it to (0, 0) and + // positions children from the top left, which is what a panel wants. + // {@link ProgressBar} has to set it, but only because it extends + // `Renderable`, where the default is centred and `preDraw` applies the + // anchor without ever translating to `pos`. + } + + /** + * Where the bob has the panel this frame, in pixels down from its + * position. + * + * Negative first: the panel RISES out of its resting place and comes + * back, which is what reads as floating. Starting with a sink reads as + * the panel dropping. + * @returns the vertical offset, `0` when the bob is off + * @ignore + * @internal + */ + private _bobOffset(): number { + if (this.bobAmplitude === 0 || this.bobPeriod <= 0) { + return 0; + } + // ROUNDED, and it is not a detail. The renderers snap a translation + // to the pixel grid with `|0`, which truncates TOWARD ZERO rather + // than rounding, and that does two things to a small bob. It clips + // the travel, since `trunc(3 * sin)` only reaches 3 at the single + // instant `sin` is exactly 1, so an amplitude of 3 moves 4px and not + // 6. And it piles the dwell at the extremes: the panel sits at 2 for + // about half the cycle, then hurries through the middle, which reads + // as a jerk rather than a float. Handing the renderer a whole number + // makes its own snap a no-op and restores even, full-amplitude steps. + // + // It also makes the drawn position and the hit area agree EXACTLY + // rather than to within a pixel, since `getAbsolutePosition()` reads + // this same value and is not snapped by anything. + return Math.round( + -Math.sin((this._bobTime / this.bobPeriod) * Math.PI * 2) * + this.bobAmplitude, + ); + } + + /** + * Advance the bob. + * @param dt - time since the last frame, in milliseconds + * @returns whether the panel needs redrawing + */ + override update(dt: number): boolean { + const moving = this.bobAmplitude !== 0 && this.bobPeriod > 0; + if (moving) { + // Wrapped rather than left to grow. A sine does not care, but a + // clock that only ever climbs loses precision on a long session + // and there is nothing to gain by keeping the whole of it. + this._bobTime = (this._bobTime + dt) % this.bobPeriod; + } + // `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); + const offset = this._bobOffset(); + if (offset !== this._bobApplied) { + this._bobApplied = offset; + this.updateBounds(); + } + return dirty || moving; + } + + /** + * Draw the fill, the border, and then the children over both. + * @param renderer - the renderer to draw with + * @param viewport - the camera being drawn through, passed to the children + */ + override draw(renderer: Renderer, viewport?: Camera2d) { + // The cascaded alpha this renderable arrived with. `globalAlpha()` and + // not `getGlobalAlpha()`: the latter is only on the concrete + // renderers, while this is on the base class. Read BEFORE the first + // `setColor`, while it still holds what `preDraw` put there. + // The bob, applied to EVERYTHING: the background below, and the + // children through `super.draw`, which translates again on top of + // this one. `preDraw` has already saved the transform and `postDraw` + // restores it, so this is not put back by hand. + const bob = this._bobOffset(); + if (bob !== 0) { + renderer.translate(0, bob); + } + + const alpha = renderer.globalAlpha(); + if (this.fillColor !== null) { + this._painter.paint(renderer, this.fillColor, alpha); + this._painter.rect( + renderer, + this.pos.x, + this.pos.y, + this.width, + this.height, + this.radius, + ); + } + if (this.borderColor !== null && this.borderWidth > 0) { + this._painter.paint(renderer, this.borderColor, alpha); + this._painter.border( + renderer, + this.pos.x, + this.pos.y, + this.width, + this.height, + this.radius, + this.borderWidth, + ); + } + // put back what the children inherit, so a translucent background + // does not fade the labels sitting on it + renderer.setGlobalAlpha(alpha); + + super.draw(renderer, viewport); + } + + /** + * Where the panel IS, bob included. + * + * THE hook that makes a floating panel honest. Both bounds paths funnel + * through this one method, and a child's absolute position is the sum of + * the chain above it, so adding the offset here reaches the panel's own + * hit area and every descendant's, at any depth, without any of them + * knowing a bob exists. + * + * Not `currentTransform`, which was the obvious guess: that reaches a + * renderable's OWN bounds and not its children's, so a bobbing panel's + * swallow rectangle would drift off the widgets sitting in it. And not a + * write to `pos`, which would reach everything correctly but silently + * overwrite a caller's own write on the very next frame. + * + * {@link Particle} overrides this method for the same reason, its + * placement not being in its `pos` chain either. + * @returns the absolute position, offset by the bob + */ + override getAbsolutePosition(): Vector3d { + const absolute = super.getAbsolutePosition(); + absolute.y += this._bobOffset(); + return absolute; + } + + /** + * The matrix form of the same offset. + * + * Without it `getWorldTransform()` and `getAbsolutePosition()` disagree + * about a bobbing panel, and the contract on those two says that for a + * `Container` they coincide. + * @param out - matrix to write into + * @returns `out`, for chaining + */ + protected override getLocalTransform(out: Matrix3d): Matrix3d { + super.getLocalTransform(out); + const offset = this._bobOffset(); + if (offset !== 0) { + out.translate(0, offset); + } + return out; + } + + /** + * Resize this panel's bounds, and refresh its children's. + * + * `Container` does those two jobs in ONE loop, both gated on + * `enableChildBoundsUpdate`, and a panel wants exactly one of them. The + * flag is off here so that a label overhanging the edge cannot grow the + * rectangle a blocking panel swallows clicks inside, and turning it off + * took the other job with it: a child's bounds are absolute, worked out + * once through `getAbsolutePosition()` and then cached, so moving the + * panel left every child's HIT AREA behind at the old place while the + * whole panel drew at the new one. A dialog slid or snapped into position + * after it was built had buttons that only answered where they used to + * be. + * + * This costs nothing per frame: `pos` calls `updateBounds` when it is + * written, so the walk happens when the panel actually moves. + * @param absolute - update in world absolute coordinates + * @returns this panel's bounds + */ + override updateBounds(absolute = true) { + const bounds = super.updateBounds(absolute); + this.forEach((child: Renderable) => { + child.updateBounds(absolute); + }); + return bounds; + } + + /** + * Consume a click on the panel itself, unless told not to. + * + * Returning `false` is how {@link UIBaseElement} consumes. A child is + * asked before the container holding it, so this only ever sees a click + * that missed every one of them, which for a dialog means a click on its + * own background and should go no further. + * @returns false while {@link UIPanel#blocking} + */ + override onClick(): boolean { + return !this.blocking; + } + + /** + * Consume the release that follows a click on the panel, unless told not + * to. + * + * The three handlers consume independently. Taking the press and leaving + * the release is half a block: the pointer-up still reaches whatever is + * under the dialog, so a button there completes an interaction it never + * began. + * @returns false while {@link UIPanel#blocking} + */ + override onRelease(): boolean { + return !this.blocking; + } + + /** + * Consume a hover on the panel itself, unless told not to. + * + * Without this a widget UNDER a dialog keeps lighting up as the pointer + * crosses the panel covering it. + * @returns false while {@link UIPanel#blocking} + */ + override onOver(): boolean { + return !this.blocking; + } + + /** + * Hand back what the painter borrowed. + * @ignore + */ + override onDestroyEvent() { + this._painter.dispose(); + super.onDestroyEvent(); + } +} diff --git a/packages/melonjs/tests/progressbar.spec.js b/packages/melonjs/tests/progressbar.spec.js index e7947059d..5e2ae5da8 100644 --- a/packages/melonjs/tests/progressbar.spec.js +++ b/packages/melonjs/tests/progressbar.spec.js @@ -59,7 +59,21 @@ const makeRecorder = () => { calls.push({ op: "fill", shape, radius: shape.radius }); }, stroke(shape) { - calls.push({ op: "stroke", shape }); + // Snapshotted, every field of it, for the same reason `fill` + // snapshots the radius: ONE RoundRect is reused for the track, the + // fill and the border, so the object read after the draw holds + // whatever the LAST caller left in it. `lineWidth` too, because + // the border puts it back before the draw returns, so reading it + // afterwards can only ever see the value from before. + calls.push({ + op: "stroke", + x: shape.pos.x, + y: shape.pos.y, + width: shape.width, + height: shape.height, + radius: shape.radius, + lineWidth: this.lineWidth, + }); }, }; }; @@ -353,6 +367,84 @@ describe("ProgressBar", () => { ).toBe(true); }); + it("insets a rounded border by half its width, so it lands INSIDE", () => { + // The reason this is not simply `stroke(0, 0, 100, 20)`: every + // backend expands a line symmetrically about its path, so a stroke + // on the bar's own rectangle would put half the border outside the + // width and height the bar was given. Pulling the path in by + // `borderWidth / 2` is what cancels that. + const bar = new ProgressBar(0, 0, { + width: 100, + height: 20, + value: 0, + radius: 6, + borderColor: "#fff", + borderWidth: 4, + }); + const r = makeRecorder(); + bar.draw(r); + const border = r.calls.find((c) => { + return c.op === "stroke"; + }); + expect(border).toMatchObject({ + x: 2, + y: 2, + width: 96, + height: 16, + lineWidth: 4, + }); + // and the radius comes in with it, or the border would be a + // different curve from the edge it is tracing + expect(border.radius).toBe(4); + }); + + it("keeps a rounded border within the declared box at every width", () => { + // The property the inset exists for, checked rather than + // hard-coded: whatever the width, the OUTER edge of the stroke + // (path + half the line) may not pass the bar's own rectangle. + for (const borderWidth of [1, 2, 3, 7, 40]) { + const bar = new ProgressBar(10, 20, { + width: 100, + height: 30, + value: 0, + radius: 8, + borderColor: "#fff", + borderWidth, + }); + const r = makeRecorder(); + bar.draw(r); + const b = r.calls.find((c) => { + return c.op === "stroke"; + }); + const half = b.lineWidth / 2; + expect(b.x - half).toBeGreaterThanOrEqual(10); + expect(b.y - half).toBeGreaterThanOrEqual(20); + expect(b.x + b.width + half).toBeLessThanOrEqual(110); + expect(b.y + b.height + half).toBeLessThanOrEqual(50); + } + }); + + it("clamps a border thicker than half the bar", () => { + // Unclamped, the inset rectangle would invert: a 40px border on a + // 30px tall bar gives a height of -10, which is a shape no + // renderer can do anything sensible with. + const bar = new ProgressBar(0, 0, { + width: 100, + height: 30, + value: 0, + radius: 8, + borderColor: "#fff", + borderWidth: 40, + }); + const r = makeRecorder(); + bar.draw(r); + const b = r.calls.find((c) => { + return c.op === "stroke"; + }); + expect(b.lineWidth).toBe(15); // half the height, not 40 + expect(b.height).toBeGreaterThan(0); + }); + it("draws the border LAST, over the track and the fill", () => { const bar = new ProgressBar(0, 0, { width: 100, @@ -376,9 +468,13 @@ describe("ProgressBar", () => { }); it("restores the renderer's lineWidth after stroking", () => { + // ROUNDED on purpose. A square border is four fills and never + // touches `lineWidth` at all, so this test passed against a + // version that leaked it. const bar = new ProgressBar(0, 0, { width: 100, height: 10, + radius: 4, borderColor: "#fff", borderWidth: 7, }); diff --git a/packages/melonjs/tests/ui-interaction.spec.js b/packages/melonjs/tests/ui-interaction.spec.js index 4759355d9..d8a4508a4 100644 --- a/packages/melonjs/tests/ui-interaction.spec.js +++ b/packages/melonjs/tests/ui-interaction.spec.js @@ -8,6 +8,7 @@ import { input, loader, UIBaseElement, + UIPanel, UISpriteElement, UITextButton, video, @@ -470,4 +471,134 @@ describe("UI pointer interaction", () => { } }); }); + + describe("a button inside a UIPanel", () => { + /** + * Build a panel with one element in it, in the world. + * @param settings - extra panel settings + * @returns the panel, the child and a spy on its onClick + */ + const panelWithChild = (settings = {}) => { + const panel = new UIPanel(100, 100, { + width: 300, + height: 200, + fillColor: "#123456", + ...settings, + }); + const child = new UIBaseElement(50, 50, 120, 40); + const spy = vi.spyOn(child, "onClick"); + panel.addChild(child); + app.world.addChild(panel); + syncBroadphase(); + return { panel, child, spy }; + }; + + it("fires, nested inside the panel", () => { + // The baseline the rest of this block depends on: a child of a + // panel is reachable at all. It sits at (150, 150) absolute, which + // is the panel's position plus its own. + const { panel, spy } = panelWithChild(); + dispatchPointer("pointerdown", 210, 170); + expect(spy).toHaveBeenCalledTimes(1); + app.world.removeChild(panel); + }); + + it("still fires while the panel is bobbing", () => { + // The question this block exists for. A bob is a draw offset, so + // nothing about it should reach the hit area in either direction: + // the button must not stop working, and it must not start + // answering somewhere else. + const { panel, spy } = panelWithChild({ + bobAmplitude: 8, + bobPeriod: 1000, + }); + panel.update(250); // the top of the cycle, 8px up + syncBroadphase(); + dispatchPointer("pointerdown", 210, 170); + expect(spy).toHaveBeenCalledTimes(1); + + spy.mockClear(); + panel.update(500); // the bottom of it, 8px down + syncBroadphase(); + dispatchPointer("pointerdown", 210, 170); + expect(spy).toHaveBeenCalledTimes(1); + app.world.removeChild(panel); + }); + + it("answers where the bob DRAWS it, not where it rests", () => { + // The whole point of routing the offset through + // `getAbsolutePosition()`: you click the button you can see. + const { panel, spy } = panelWithChild({ + bobAmplitude: 60, + bobPeriod: 1000, + }); + panel.update(250); // 60px up, far outside its own 40px height + // DRAWN as well as stepped, which is what proves the offset is + // not coming from a `pos` write. + panel.preDraw(app.renderer); + panel.draw(app.renderer); + panel.postDraw(app.renderer); + syncBroadphase(); + dispatchPointer("pointerdown", 210, 110); // where it is DRAWN + expect(spy).toHaveBeenCalledTimes(1); + spy.mockClear(); + dispatchPointer("pointerdown", 210, 170); // where it rests + expect(spy).not.toHaveBeenCalled(); + // and `pos` is still the resting place the caller set + expect(panel.pos.y).toBe(100); + app.world.removeChild(panel); + }); + + it("carries a GRANDCHILD's hit area too", () => { + // `getAbsolutePosition()` sums the whole chain, so the offset + // reaches any depth without the levels in between knowing. + const panel = new UIPanel(100, 100, { + width: 300, + height: 200, + bobAmplitude: 60, + bobPeriod: 1000, + }); + const group = new UIBaseElement(20, 20, 200, 100); + const deep = new UIBaseElement(30, 30, 120, 40); + const spy = vi.spyOn(deep, "onClick"); + group.addChild(deep); + panel.addChild(group); + app.world.addChild(panel); + panel.update(250); + syncBroadphase(); + // rests at 100+20+30 = 150, drawn 60 up at 90 + dispatchPointer("pointerdown", 210, 110); + expect(spy).toHaveBeenCalledTimes(1); + app.world.removeChild(panel); + }); + + it("carries the child's hit area when the panel MOVES", () => { + // Not the same thing as the bob, and the one that is a real bug. + // A child's bounds are absolute and cached, and `UIPanel` turns + // off the container flag that refreshes them, so a panel slid into + // place after it was built used to leave every button answering at + // the old spot. + const { panel, spy } = panelWithChild(); + panel.pos.y += 60; + syncBroadphase(); + dispatchPointer("pointerdown", 210, 170); // where it used to be + expect(spy).not.toHaveBeenCalled(); + dispatchPointer("pointerdown", 210, 230); // where it is now + expect(spy).toHaveBeenCalledTimes(1); + app.world.removeChild(panel); + }); + + it("keeps its own bounds off its children while doing that", () => { + // The reason the container flag is off in the first place: an + // overhanging child must not grow the rectangle a blocking panel + // swallows clicks inside. + const panel = new UIPanel(100, 100, { width: 300, height: 200 }); + panel.addChild(new UIBaseElement(0, 0, 900, 900)); + app.world.addChild(panel); + panel.pos.y += 10; + expect(panel.getBounds().width).toBe(300); + expect(panel.getBounds().height).toBe(200); + app.world.removeChild(panel); + }); + }); }); diff --git a/packages/melonjs/tests/uipanel.spec.js b/packages/melonjs/tests/uipanel.spec.js new file mode 100644 index 000000000..76f882644 --- /dev/null +++ b/packages/melonjs/tests/uipanel.spec.js @@ -0,0 +1,577 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { Application, boot, Renderable, UIPanel, video } from "../src/index.js"; + +/** + * A recording renderer. + * + * The same approach `progressbar.spec.js` takes, and for the same reason: what + * matters about a panel is the RECTANGLES it asks for, so capture the calls and + * assert the geometry rather than reading pixels back. + * @returns the stub, with every call it received + */ +const makeRecorder = () => { + const calls = []; + return { + calls, + lineWidth: 1, + // The real renderers keep ONE current colour, and its alpha IS the + // global alpha, so `setColor` overwrites the alpha. Modelling that is + // the point: a panel that forced the cascade back over it would render + // a deliberately translucent fill as opaque. + _alpha: 1, + globalAlpha() { + return this._alpha; + }, + setGlobalAlpha(a) { + this._alpha = a; + calls.push({ op: "setGlobalAlpha", a }); + }, + setColor(color) { + calls.push({ + op: "setColor", + hex: typeof color === "string" ? color : (color.toHex?.() ?? ""), + }); + }, + fillRect(x, y, width, height) { + calls.push({ op: "fillRect", x, y, width, height }); + }, + strokeRect(x, y, width, height) { + calls.push({ op: "strokeRect", x, y, width, height }); + }, + fill(shape) { + calls.push({ + op: "fill", + x: shape.pos.x, + y: shape.pos.y, + width: shape.width, + height: shape.height, + radius: shape.radius, + }); + }, + stroke(shape) { + // Snapshotted field by field: ONE RoundRect is reused for the + // background and the border, so the object read after the draw + // holds whatever the LAST caller left in it. `lineWidth` too, + // because the border puts it back before the draw returns. + calls.push({ + op: "stroke", + x: shape.pos.x, + y: shape.pos.y, + width: shape.width, + height: shape.height, + radius: shape.radius, + lineWidth: this.lineWidth, + }); + }, + // what `Container.draw` touches on the way to the children + translate(x, y) { + calls.push({ op: "translate", x, y }); + }, + clipRect() {}, + clearColor() {}, + }; +}; + +/** + * Draw a panel and hand back every call it made. + * @param panel - the panel to draw + * @param cascade - the inherited global alpha + * @returns the recorder + */ +const drawn = (panel, cascade = 1) => { + const r = makeRecorder(); + r._alpha = cascade; + panel.draw(r); + return r; +}; + +describe("UIPanel", () => { + describe("placement", () => { + it("anchors top LEFT, so a panel lands where it was put", () => { + // Inherited from `Container`, which pins the anchor itself, rather + // than set here: this asserts the CONTRACT a panel relies on, so + // it would catch a panel reparented onto `Renderable`, where the + // default is centred and a screen-sized panel centred on the + // screen would land in the corner. The behaviour it guards is + // pinned separately by "fills its rectangle at its own position". + const panel = new UIPanel(10, 20, { width: 100, height: 40 }); + expect(panel.anchorPoint.x).toBe(0); + expect(panel.anchorPoint.y).toBe(0); + }); + + it("takes its size from the settings, not from its children", () => { + // `getBounds()` is what the pointer dispatcher hit-tests, so with + // child bounds folded in, a label overhanging the edge would make + // a blocking panel swallow clicks OUTSIDE the rectangle it drew. + const panel = new UIPanel(0, 0, { width: 100, height: 40 }); + expect(panel.enableChildBoundsUpdate).toBe(false); + + const overhang = new Renderable(0, 0, 400, 400); + panel.addChild(overhang); + panel.updateBounds?.(true); + expect(panel.getBounds().width).toBe(100); + expect(panel.getBounds().height).toBe(40); + }); + + it("asks for a capture when a post effect is on it", () => { + // a panel draws with PRIMITIVES, which go through a batcher that + // never reads `customShader`, so a post effect would silently do + // nothing on the fast path + const panel = new UIPanel(0, 0, { width: 10, height: 10 }); + expect(panel.postEffectNeedsCapture).toBe(true); + }); + }); + + describe("background and border", () => { + it("fills its rectangle at its own position", () => { + const panel = new UIPanel(10, 20, { + width: 100, + height: 40, + fillColor: "#123456", + }); + const fills = drawn(panel).calls.filter((c) => { + return c.op === "fillRect"; + }); + expect(fills).toEqual([ + { op: "fillRect", x: 10, y: 20, width: 100, height: 40 }, + ]); + }); + + it("draws nothing for a null fill", () => { + const panel = new UIPanel(0, 0, { + width: 100, + height: 40, + fillColor: null, + borderColor: null, + }); + const painted = drawn(panel).calls.filter((c) => { + return c.op === "fillRect" || c.op === "fill"; + }); + expect(painted).toHaveLength(0); + }); + + it("draws a square border as four fills that tile the frame", () => { + // not `strokeRect`: a stroke is centred on the path, so half of it + // would fall outside the panel, and the renderers only generate + // corner joins ABOVE a line width of one + const panel = new UIPanel(0, 0, { + width: 100, + height: 20, + fillColor: null, + borderColor: "#fff", + borderWidth: 2, + }); + const r = drawn(panel); + expect( + r.calls.some((c) => { + return c.op === "strokeRect"; + }), + ).toBe(false); + const fills = r.calls.filter((c) => { + return c.op === "fillRect"; + }); + expect( + fills.map(({ x, y, width, height }) => { + return { x, y, width, height }; + }), + ).toEqual([ + { x: 0, y: 0, width: 100, height: 2 }, // top + { x: 0, y: 18, width: 100, height: 2 }, // bottom + { x: 0, y: 2, width: 2, height: 16 }, // left + { x: 98, y: 2, width: 2, height: 16 }, // right + ]); + }); + + it("covers all four corners exactly once", () => { + // Once, not twice: `setColor` puts a colour's own alpha into the + // global alpha, so overlapping pieces would blend twice and a + // translucent border would come out darker at the corners than + // along the sides. + const panel = new UIPanel(0, 0, { + width: 50, + height: 20, + fillColor: null, + borderColor: "#fff", + borderWidth: 1, + }); + const fills = drawn(panel).calls.filter((c) => { + return c.op === "fillRect"; + }); + const covering = (px, py) => { + return fills.filter((f) => { + return ( + px >= f.x && px < f.x + f.width && py >= f.y && py < f.y + f.height + ); + }).length; + }; + for (const [px, py] of [ + [0, 0], + [49, 0], + [0, 19], + [49, 19], + ]) { + expect(covering(px, py)).toBe(1); + } + }); + + it("insets a rounded border by half its width, so it lands INSIDE", () => { + const panel = new UIPanel(0, 0, { + width: 100, + height: 20, + fillColor: null, + borderColor: "#fff", + borderWidth: 4, + radius: 6, + }); + const border = drawn(panel).calls.find((c) => { + return c.op === "stroke"; + }); + expect(border).toMatchObject({ + x: 2, + y: 2, + width: 96, + height: 16, + radius: 4, + lineWidth: 4, + }); + }); + + it("restores the renderer's lineWidth after stroking", () => { + // `RenderState.save()` does not stack `lineWidth`, so a panel that + // left it set would make everything stroked after it in the frame + // as thick as its own border. + const panel = new UIPanel(0, 0, { + width: 100, + height: 20, + borderColor: "#fff", + borderWidth: 5, + radius: 6, + }); + const r = makeRecorder(); + r.lineWidth = 3; + panel.draw(r); + expect(r.lineWidth).toBe(3); + }); + + it("draws the border over the fill", () => { + const panel = new UIPanel(0, 0, { + width: 100, + height: 20, + fillColor: "#000000", + borderColor: "#ffffff", + }); + const colors = drawn(panel) + .calls.filter((c) => { + return c.op === "setColor"; + }) + .map((c) => { + return c.hex.toLowerCase(); + }); + expect(colors).toEqual(["#000000", "#ffffff"]); + }); + }); + + describe("alpha", () => { + it("multiplies a colour's own alpha into the cascade", () => { + const panel = new UIPanel(0, 0, { + width: 100, + height: 20, + fillColor: "rgba(0, 0, 0, 0.5)", + borderColor: null, + }); + const applied = drawn(panel, 0.5) + .calls.filter((c) => { + return c.op === "setGlobalAlpha"; + }) + .map((c) => { + return c.a; + }); + // 0.5 asked for, 0.5 inherited + expect(applied[0]).toBeCloseTo(0.25, 6); + }); + + it("hands the children the cascade, not the fill's alpha", () => { + // Otherwise a label on a translucent card is faded by the card. + const panel = new UIPanel(0, 0, { + width: 100, + height: 20, + fillColor: "rgba(0, 0, 0, 0.2)", + borderColor: null, + }); + const r = drawn(panel, 0.8); + const alphas = r.calls.filter((c) => { + return c.op === "setGlobalAlpha"; + }); + expect(alphas[alphas.length - 1].a).toBeCloseTo(0.8, 6); + // and it is put back BEFORE the children are reached + const lastAlpha = r.calls.lastIndexOf(alphas[alphas.length - 1]); + const toChildren = r.calls.findIndex((c) => { + return c.op === "translate"; + }); + expect(lastAlpha).toBeLessThan(toChildren); + }); + }); + + describe("bob", () => { + // `Container.update` reads `state.current().cameras`, so stepping a + // panel needs a live application where drawing one does not + let app; + beforeAll(async () => { + boot(); + app = new Application(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + await app.init(); + }); + + afterAll(() => { + app?.destroy(); + }); + + /** + * Step a panel and report where the bob put it. + * @param panel - the panel to step + * @param dt - milliseconds to advance + * @returns the y the panel drew its background at + */ + const stepTo = (panel, dt) => { + panel.update(dt); + const r = drawn(panel); + const t = r.calls.find((c) => { + return c.op === "translate"; + }); + return t === undefined ? 0 : t.y; + }; + + it("is off by default, and costs a still panel no translate", () => { + const panel = new UIPanel(0, 0, { + width: 100, + height: 40, + fillColor: "#fff", + }); + expect(panel.bobAmplitude).toBe(0); + // the ONLY translate a still panel makes is the container's own, + // which carries its position, so a bob of 0 adds nothing + const translates = drawn(panel).calls.filter((c) => { + return c.op === "translate"; + }); + expect(translates).toHaveLength(1); + expect(translates[0]).toMatchObject({ x: 0, y: 0 }); + }); + + it("rises before it sinks", () => { + // A panel that drops first reads as falling, not floating. + const panel = new UIPanel(0, 0, { + width: 100, + height: 40, + bobAmplitude: 4, + bobPeriod: 1000, + }); + expect(stepTo(panel, 100)).toBeLessThan(0); + }); + + it("reaches the amplitude at a quarter cycle and comes back", () => { + const panel = new UIPanel(0, 0, { + width: 100, + height: 40, + bobAmplitude: 4, + bobPeriod: 1000, + }); + expect(stepTo(panel, 250)).toBeCloseTo(-4, 6); // top + expect(stepTo(panel, 250)).toBeCloseTo(0, 6); // through the middle + expect(stepTo(panel, 250)).toBeCloseTo(4, 6); // bottom + expect(stepTo(panel, 250)).toBeCloseTo(0, 6); // home + }); + + it("takes the children with it", () => { + // The translate goes in BEFORE the container translates to its own + // position, so everything inside moves as one. + const panel = new UIPanel(0, 0, { + width: 100, + height: 40, + fillColor: "#fff", + bobAmplitude: 4, + bobPeriod: 1000, + }); + panel.update(250); + const translates = drawn(panel).calls.filter((c) => { + return c.op === "translate"; + }); + expect(translates).toHaveLength(2); + expect(translates[0].y).toBeCloseTo(-4, 6); // the bob + expect(translates[1]).toMatchObject({ x: 0, y: 0 }); // to the children + }); + + it("moves the hit area with the bob, and leaves pos alone", () => { + // The two halves of the bargain this widget settled on. `pos` is + // the resting place a caller set and must stay readable; the + // BOUNDS are what the pointer dispatcher hit-tests, so they have + // to track what is on screen or the panel answers somewhere it is + // not. + const panel = new UIPanel(10, 20, { + width: 100, + height: 40, + bobAmplitude: 4, + bobPeriod: 1000, + }); + panel.update(250); // the top of the cycle + expect(panel.pos.y).toBe(20); + expect(panel.getBounds().top).toBe(16); + // and the size is still the size + expect(panel.getBounds().width).toBe(100); + expect(panel.getBounds().height).toBe(40); + }); + + it("survives a caller moving it while the bob runs", () => { + // A bob implemented by writing `pos` eats this: the next frame + // reassigns from its own stale rest value and the write is gone. + const panel = new UIPanel(10, 20, { + width: 100, + height: 40, + bobAmplitude: 4, + bobPeriod: 1000, + }); + panel.update(250); + panel.pos.y = 300; + panel.update(16); + expect(panel.pos.y).toBe(300); + }); + + it("puts the bounds back when the bob is switched off", () => { + // Otherwise the hit area stays parked wherever the sine stopped. + const panel = new UIPanel(10, 20, { + width: 100, + height: 40, + bobAmplitude: 4, + bobPeriod: 1000, + }); + panel.update(250); + expect(panel.getBounds().top).toBe(16); + panel.bobAmplitude = 0; + panel.update(16); + expect(panel.getBounds().top).toBe(20); + }); + + it("costs a still panel no bounds work at all", () => { + // The walk is gated on the OFFSET changing, not on the bob being + // configured, so a panel without one never pays. + const panel = new UIPanel(0, 0, { width: 100, height: 40 }); + const spy = vi.spyOn(panel, "updateBounds"); + for (let i = 0; i < 100; i++) { + panel.update(16); + } + expect(spy).not.toHaveBeenCalled(); + }); + + it("refuses to divide by a period of zero", () => { + // NaN here is not a cosmetic bug: one `translate` by NaN writes it + // into the transform and the GPU clips away every vertex under it, + // so the panel would silently vanish. + for (const bobPeriod of [0, -1000]) { + const panel = new UIPanel(0, 0, { + width: 100, + height: 40, + bobAmplitude: 4, + bobPeriod, + }); + const y = stepTo(panel, 250); + expect(Number.isNaN(y)).toBe(false); + expect(y).toBe(0); + } + }); + + it("keeps its clock bounded over a long session", () => { + const panel = new UIPanel(0, 0, { + width: 100, + height: 40, + bobAmplitude: 4, + bobPeriod: 1000, + }); + for (let i = 0; i < 2000; i++) { + panel.update(16); + } + // The CLOCK, not the offset it produces: a sine is bounded + // whether or not the clock behind it wraps, so asserting on the + // offset here would pass against a counter climbing for ever. + expect(panel._bobTime).toBeLessThan(1000); + expect(panel._bobTime).toBeGreaterThanOrEqual(0); + expect(Math.abs(stepTo(panel, 16))).toBeLessThanOrEqual(4); + }); + + it("reports itself dirty while it is moving", () => { + // Otherwise a panel on an otherwise static screen would never be + // redrawn, and the bob would not be visible at all. + const still = new UIPanel(0, 0, { width: 10, height: 10 }); + const bobbing = new UIPanel(0, 0, { + width: 10, + height: 10, + bobAmplitude: 4, + }); + // Cleared first: a renderable is born dirty, because it has not + // been drawn yet, and `postDraw` is what settles it. What is being + // asserted is that a bob keeps ASKING to be redrawn where a still + // panel stops. + still.isDirty = false; + bobbing.isDirty = false; + expect(still.update(16)).toBe(false); + expect(bobbing.update(16)).toBe(true); + }); + }); + + describe("pointer events", () => { + it("swallows clicks by default, so a dialog masks the game", () => { + // returning false is how `UIBaseElement` consumes + const panel = new UIPanel(0, 0, { width: 10, height: 10 }); + expect(panel.blocking).toBe(true); + expect(panel.onClick()).toBe(false); + expect(panel.onRelease()).toBe(false); + expect(panel.onOver()).toBe(false); + }); + + it("lets them through when told to", () => { + const panel = new UIPanel(0, 0, { + width: 10, + height: 10, + blocking: false, + }); + expect(panel.onClick()).toBe(true); + expect(panel.onRelease()).toBe(true); + expect(panel.onOver()).toBe(true); + }); + + it("consumes the release as well as the press", () => { + // Taking the press and leaving the release is half a block: the + // pointer-up still reaches whatever is under the dialog, so a + // button there completes an interaction it never began. + const panel = new UIPanel(0, 0, { width: 10, height: 10 }); + expect(panel.onRelease()).toBe(false); + }); + + it("is not kinematic, so it receives events at all", () => { + const panel = new UIPanel(0, 0, { width: 10, height: 10 }); + expect(panel.isKinematic).toBe(false); + }); + }); + + describe("teardown", () => { + it("survives a destroy with a rounded shape borrowed", () => { + const panel = new UIPanel(0, 0, { + width: 20, + height: 20, + fillColor: "#fff", + radius: 4, + }); + drawn(panel); + expect(() => { + panel.destroy(); + }).not.toThrow(); + // and again: `destroy` is reached twice when a parent destroys a + // child it is also removing + expect(() => { + panel.onDestroyEvent(); + }).not.toThrow(); + }); + }); +});