diff --git a/packages/examples/src/examples/ui/ExampleUI.tsx b/packages/examples/src/examples/ui/ExampleUI.tsx
index 8dfc17610..34a42eca6 100644
--- a/packages/examples/src/examples/ui/ExampleUI.tsx
+++ b/packages/examples/src/examples/ui/ExampleUI.tsx
@@ -6,8 +6,12 @@
import {
Application as App,
type Application,
+ Color,
ColorLayer,
+ Container,
+ input,
loader,
+ ProgressBar,
Stage,
state,
Text,
@@ -180,6 +184,60 @@ class UIContainer extends UIBaseElement {
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.
+ */
+ override onActivateEvent() {
+ super.onActivateEvent();
+ input.registerPointerEvent("pointermove", this, () => false);
+ }
+
+ override onDeactivateEvent() {
+ input.releasePointerEvent("pointermove", this);
+ super.onDeactivateEvent();
+ }
}
class PlayScreen extends Stage {
@@ -232,6 +290,155 @@ class PlayScreen extends Stage {
panel.addChild(btn3.label);
app.world.addChild(panel, 1);
+ this.addBars(app);
+ }
+
+ /**
+ * `ProgressBar`, in the shapes it tends to be wanted in.
+ *
+ * In a panel of its own, like the options on the left: a bar is a UI
+ * widget, and showing it loose on the background would say otherwise.
+ *
+ * All of them are drawn with primitives and no artwork. The track and the
+ * fill are `fillRect`; a square border is four more rects, so it lands
+ * exactly inside the bar and keeps its corners; a rounded one goes through
+ * the `RoundRect` shape path instead.
+ * @param app - the application
+ */
+ private addBars(app: Application) {
+ const panel = new UIContainer(600, 100, 340, 400, "PROGRESS");
+
+ /**
+ * one caption, in the panel's own coordinates
+ * @param x - left edge
+ * @param y - top edge
+ * @param text - what it says
+ * @returns the label
+ */
+ const caption = (x: number, y: number, text: string) => {
+ return new Text(x, y, {
+ font: "kenpixel",
+ size: 13,
+ fillStyle: "black",
+ textAlign: "left",
+ textBaseline: "top",
+ text,
+ });
+ };
+
+ // 1. square, bordered, with the built-in percentage label
+ panel.addChild(caption(24, 52, "squared + label"));
+ panel.addChild(
+ new ProgressBar(24, 72, {
+ width: 290,
+ height: 24,
+ value: 0.72,
+ trackColor: "#0000001a",
+ fillColor: "#4a8fd4",
+ borderColor: "black",
+ borderWidth: 2,
+ padding: 2,
+ showLabel: true,
+ font: "kenpixel",
+ fontSize: 12,
+ labelFillStyle: "white",
+ }),
+ );
+
+ // 2. ROUNDED, which takes the shape path rather than `fillRect`
+ panel.addChild(caption(24, 112, "rounded"));
+ panel.addChild(
+ new ProgressBar(24, 132, {
+ width: 290,
+ height: 24,
+ value: 0.45,
+ radius: 12,
+ trackColor: "#0000001a",
+ fillColor: "#59b25b",
+ borderColor: "black",
+ borderWidth: 2,
+ padding: 3,
+ }),
+ );
+
+ // 3. vertical, filling upward, with a gradient down its length
+ panel.addChild(caption(24, 232, "vertical"));
+ const ramp = app.renderer.createLinearGradient(0, 372, 0, 252);
+ ramp.addColorStop(0, "#d4553a");
+ ramp.addColorStop(0.5, "#e8c84a");
+ ramp.addColorStop(1, "#59b25b");
+ panel.addChild(
+ new ProgressBar(24, 252, {
+ width: 30,
+ height: 120,
+ value: 0.8,
+ direction: "bottom-to-top",
+ trackColor: "#0000001a",
+ fillColor: ramp,
+ borderColor: "black",
+ borderWidth: 2,
+ padding: 3,
+ radius: 8,
+ }),
+ );
+
+ // 4. a value-driven colour. The bar re-reads `fillColor` every frame,
+ // so a `Color` the caller keeps and mutates is all an animated one
+ // takes; no rule about it lives in the bar.
+ panel.addChild(caption(150, 232, "live colour"));
+ const hot = new Color().parseCSS("#d4553a");
+ const cool = new Color().parseCSS("#4a8fd4");
+ const mixed = new Color().copy(cool);
+ const pulse = new ProgressBar(150, 252, {
+ width: 30,
+ height: 120,
+ value: 1,
+ direction: "bottom-to-top",
+ trackColor: "#0000001a",
+ fillColor: mixed,
+ borderColor: "black",
+ borderWidth: 2,
+ padding: 3,
+ });
+ panel.addChild(pulse);
+
+ // 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,
+ height: 24,
+ value: 0.6,
+ direction: "right-to-left",
+ trackColor: "#0000001a",
+ fillColor: "#b25b9b",
+ borderColor: "black",
+ borderWidth: 2,
+ padding: 2,
+ });
+ panel.addChild(mirrored);
+
+ app.world.addChild(panel, 1);
+
+ let t = 0;
+ this.pulseUpdate = (dt: number) => {
+ t = (t + dt / 2200) % 1;
+ const v = 0.5 - 0.5 * Math.cos(t * Math.PI * 2);
+ pulse.value = v;
+ mixed.copy(hot).lerp(cool, v);
+ };
+ }
+
+ /** drives the live-colour bar; see {@link PlayScreen.addBars} */
+ private pulseUpdate?: (dt: number) => void;
+
+ /**
+ * @param dt - milliseconds since the last frame
+ * @returns true, since the pulsing bar always has something to redraw
+ */
+ override update(dt: number) {
+ super.update(dt);
+ this.pulseUpdate?.(dt);
+ return true;
}
}
diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md
index f683f8497..6df6990c4 100644
--- a/packages/melonjs/CHANGELOG.md
+++ b/packages/melonjs/CHANGELOG.md
@@ -3,6 +3,8 @@
## [20.8.0] (melonJS 2) - _unreleased_
### Added
+- UI: `onOver` can return `false` to consume the pointer, the way `onClick` and `onRelease` already could. Returning anything else propagates, so nothing existing moves. Without it a UI element had no way to be opaque to hover: it could swallow a click on whatever it covered but not stop that thing lighting up underneath it. `onOver` only runs on the frame the pointer crosses in, so an element that has to stay opaque for as long as the pointer rests on it wants a `pointermove` callback registered through `input.registerPointerEvent` returning `false` as well. `onOut` stays `void` on purpose, since suppressing a leave would strand the element lit
+- `ProgressBar` is a renderable: a track, a fill sized by a value, an optional border and an optional label, for a health bar, a shield gauge, a cooldown or a loading bar. Set `value` and it redraws. `min` and `max` default to `0` and `1`, so a fraction works without stating either, and `ratio` reads back the normalized form. Four fill directions, horizontal or vertical, each growing from its own edge. Drawn with primitives and no artwork, so `trackColor`, `fillColor` and `borderColor` each take a colour, a css string or a `Gradient`; a `null` track leaves the bar hollow and a `radius` rounds it. The colour is re-read every frame, which is what makes a value-driven one a matter of mutating the `Color` you passed rather than anything the bar has to know about. `bindEvent` names an event to take the value from, and the subscription then lives exactly as long as the bar does, which a listener held anywhere else does not. The engine's own loading screen is built on it
- `Mesh#depthTest` decides whether geometry in front of a mesh hides it, `true` by default so nothing existing moves. Depth TEST, not depth write: the transparent pass already turns writing off for everything blended, and that is policy, while being occluded at all is an authoring choice. `false` is for a mesh standing in for a screen-space effect, an additive glow carrying a world position only so it can sort and move with what it belongs to. Left depth tested, a flat billboard is sliced along a hard straight line the moment any geometry is nearer at some pixel, worst around something round where the near surface bulges further toward the camera than any offset would clear. Honoured in the transparent pass on both GPU backends; the Canvas renderer has no depth buffer and ignores it
- Physics: `Sphere` is a collision shape. A body can carry one, and the builtin 3D narrowphase resolves it against another `Sphere`, a `Box3d`, or any planar shape through its own XY silhouette. It has no orientation to get wrong, which is what a `Box3d` cannot say and what anything tumbling or laid out over a curved surface needs. It joins `Box3d` in the portable `BodyShape` union
- Physics: `raycast3d` reports the surface of a sphere body, measured as that sphere rather than as a bounding sphere derived from the renderable's 2D bounds
@@ -17,6 +19,9 @@
- `BloomEffect`: the bright parts of a frame bleed light into the pixels around them, which is what makes emitters, neon and specular highlights read as light rather than as bright paint. `threshold`, `intensity` and `radius` are settable live, and it sizes itself from the renderer. One gather pass with a soft knee, so a light fading through the threshold ramps in rather than popping. `GlowEffect` is an outline drawn outside a sprite's silhouette and returns early on an opaque fragment, so it never was the screen bloom people reached for it as
### Fixed
+- Input: a region covered by something that consumed the pointer is told it lost it, instead of being left in its hover state. A widget only ever got its leave by the pointer going outside its own bounds, so a button half covered by a panel stayed lit when the pointer slid off its exposed part and onto the panel, which never takes it out of the button's bounds. A consumed move now carries on down the candidate list, not to offer the event to anything underneath but to take it away from whatever still holds it. A consumed press, release or wheel does not, since none of those says where the pointer is
+- Input: the pointer hit test asks the renderable that is drawn on top first. `pos.z` is container-local, because `autoDepth` numbers each container's own children from 1, and `Container#draw` never compares across containers: it recurses, so a child's z is only ever weighed against its siblings. The hit test sorted one flat list of broadphase candidates on raw z instead, so a button at local z 8 inside a low panel outranked an entire panel stacked on top of it and **a covered widget answered clicks and lit up on hover right through whatever was drawn over it**. Each pair is now resolved where `draw` resolves it, between the two siblings whose order decides which subtree paints last, with a child ahead of the container holding it and equal sibling z falling back to child order. Ordering between siblings of one container is unchanged, which is every case a game with a single container has
+- The loading screen's progress bar is the public `ProgressBar`, and the loader subscription moved out of it. It used to call `on(LOADER_PROGRESS, ...)` from its own constructor, which is what kept it private: a renderable that subscribes to the loader can only ever show loading. It also stored its fill as a pixel count rather than a ratio, so a viewport resize part way through a load left the fill at the old scale until the next asset happened to land. Output is unchanged but for the fill's leading edge, which no longer truncates to a whole pixel
- Typing: much of the public API reached TypeScript as `any`, because the generated declarations named types they never imported. `renderable.body`, `tint`, `shader`, `getBounds()`, the post-effect methods, `loader.getShader()` and the `a` and `b` of every collision response are typed now, so a cast written to work around one of them can go. Code that leaned on those `any`s may surface errors it was never shown before. Method chaining works on a subclass again, since `transform`, `rotate`, `scale`, `scaleV` and `translate` return the type they were called on rather than a bare `Renderable`, so `sprite.scale(2).setCurrentAnimation("walk")` type checks
- `Sprite3d#scale()` sizes a billboarding card, and so does `meshScale`. A billboard takes its orientation from the camera, so it cannot apply `currentTransform` wholesale, and the scale sitting in that matrix was being discarded along with the rotation: `scale()` was silently a no-op on the one renderable whose size a game most often animates, leaving a growing shockwave or a shrinking pickup to be rebuilt at a new size instead. The scale is now read out of the transform and applied in the card's own plane on both GPU backends and the CPU path alike, and the frustum-cull bounds follow it, so a card scaled up is no longer culled while a third of it is still on screen. A rotation is still ignored, because a card turned to face the camera has no free rotation left to give; use `billboard: false` and orient the quad yourself for a streak or an exhaust. An identity transform multiplies by exactly 1, so a card that was never scaled is unchanged to the bit
- `Sprite3d#isFlippedX` and `isFlippedY` are getters, as they are on every other renderable. They were methods, which shadowed the ones `Renderable` defines with a member of a different kind: reading `sprite.isFlippedX` handed back the function, which is always truthy, so `if (thing.isFlippedX)` written against the common renderable API was silently true for a `Sprite3d` and only for a `Sprite3d`. It also stopped the class satisfying `Renderable` structurally, so TypeScript rejected `renderable === sprite3d` as a comparison with no overlap. The setters `flipX()` and `flipY()` were already right and are unchanged
diff --git a/packages/melonjs/skills/melonjs-input/SKILL.md b/packages/melonjs/skills/melonjs-input/SKILL.md
index 56b0b58e1..358bd3c7b 100644
--- a/packages/melonjs/skills/melonjs-input/SKILL.md
+++ b/packages/melonjs/skills/melonjs-input/SKILL.md
@@ -1,6 +1,6 @@
---
name: melonjs-input
-description: "Use this skill for keyboard, pointer, mouse, touch and gamepad input in melonJS. Covers the bindKey action indirection, registerPointerEvent on regions, the isKinematic requirement that silently blocks pointer events, world vs screen pointer coordinates, and gamepad mapping onto the same actions. Triggers on: input, bindKey, isKeyPressed, KEY, registerPointerEvent, releasePointerEvent, pointerdown, pointerup, POINTERMOVE, gamepad, bindGamepad, triggerKeyEvent, click, touch, drag."
+description: "Use this skill for keyboard, pointer, mouse, touch and gamepad input in melonJS. Covers the bindKey action indirection, registerPointerEvent on regions, the isKinematic requirement that silently blocks pointer events, which of several overlapping objects receives an event and how to consume it, world vs screen pointer coordinates, and gamepad mapping onto the same actions. Triggers on: input, bindKey, isKeyPressed, KEY, registerPointerEvent, releasePointerEvent, pointerdown, pointerup, pointermove, POINTERMOVE, gamepad, bindGamepad, triggerKeyEvent, click, touch, drag, hit test, event propagation, stop propagating, overlapping, which object gets the click."
license: MIT
---
@@ -64,9 +64,11 @@ subclasses with no body are the ones that catch people.
Register in `onActivateEvent` and release in `onDeactivateEvent`, not in the
constructor — pooled objects are reused and would otherwise accumulate handlers.
-`app.viewport` works as a whole-screen region when you want global clicks: the
-dispatcher appends it to the candidate list on every event, so it is reachable
-even when nothing else is under the pointer.
+`app.viewport` works as a whole-screen region when you want global clicks. Note
+it is asked **before** everything else, not as a fallback after nothing matched:
+the dispatcher appends it to the candidate list and walks that list from the
+end. So a viewport handler must not return `false` unless it really means to
+swallow every pointer event in the game.
The accepted event names are exactly `"pointerdown"`, `"pointerup"`,
`"pointermove"`, `"pointercancel"`, `"pointerenter"`, `"pointerover"`,
@@ -76,6 +78,44 @@ throw `invalid event type`. melonJS maps the canonical name onto whichever
mouse/touch events the device actually supports, so you always register the
pointer name.
+## Which object gets the event
+
+The dispatcher collects the regions near the pointer from the broadphase,
+bounds-checks each one, orders them **the way they are drawn** and asks the
+topmost first, walking down until something consumes.
+
+A handler consumes by returning `false`. This is the DOM sense and it reads
+backwards: `false` means handled, and anything else, `undefined` included, lets
+the walk carry on to whatever is underneath.
+
+```js
+input.registerPointerEvent("pointerdown", this, () => {
+ this.fire();
+ return false; // nothing under this gets the click
+});
+```
+
+Three things decide the order, and only the first is obvious:
+
+- **`pos.z`, among siblings of one container.** Higher is on top.
+- **Across containers, the common ancestor decides.** `pos.z` is
+ container-local, since `autoDepth` numbers each container's children from 1,
+ so a button at `z = 8` inside one panel does not outrank a second panel at
+ `z = 2`. The pair is resolved by walking both up to their shared parent and
+ comparing the two siblings there, which is exactly how drawing resolves it.
+ Raise a whole panel with `ancestor.moveToTop(panel)`, not by inflating a
+ child's `z`.
+- **A child is asked before the container holding it**, since it draws over it.
+
+Equal `z` between siblings falls back to child order, lower index on top. Two
+callbacks registered on the **same** region for the same event run
+last-registered-first.
+
+Consuming a **move** also takes the pointer away from the regions below: each
+one still holding it gets its `pointerleave`, because it is covered now and
+being inside its own bounds no longer decides. Consuming a press, a release or a
+wheel does not, since none of those says where the pointer is.
+
## World coordinates versus screen coordinates
The pointer carries several, and picking the wrong one produces a feedback loop:
@@ -159,6 +199,10 @@ input.triggerKeyEvent(input.KEY.LEFT, false); // release
| jump fires every frame while held | `bindKey` without its third `lock` argument |
| `invalid event type` thrown at registration | non-pointer name (`"click"`, `"mousedown"`, …) passed to `registerPointerEvent` |
| `no action defined for keycode N` | `bindGamepad` called before `bindKey` for that keycode |
+| a widget under an overlapping panel still reacts | the panel consumes nothing — return `false` from its `onClick` and `onOver` |
+| a widget stays highlighted after the pointer moves onto a panel over it | the panel consumes no `pointermove`, so nothing takes the pointer off the widget |
+| a child's big `z` does not put it in front of another container | `pos.z` is container-local — use `ancestor.moveToTop()` on the container |
+| every click in the game stops working | a handler on `app.viewport` returning `false`, which is asked first |
## Related skills
diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md
index 616260796..4d6b69721 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 and on-screen text in melonJS. Covers UIBaseElement/UISpriteElement/UITextButton, Draggable and DropTarget, the floating screen-space container pattern, Text and BitmapText, web font loading, and NineSliceSprite panels. Triggers on: UI, HUD, button, UIBaseElement, UISpriteElement, UITextButton, Draggable, DropTarget, menu, dialogue, Text, BitmapText, 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, 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, Text, BitmapText, font, fontface, wordWrapWidth, NineSliceSprite, score display, floating."
license: MIT
---
@@ -129,6 +129,79 @@ Three things matter here:
real accessor is `depth` (an alias for `pos.z`); `addChild(child, z)` sets it
for you.
+### Bars and gauges: `ProgressBar`
+
+A health bar, a shield gauge, a cooldown and a loading bar are one object. Set
+`value`; it redraws.
+
+```js
+const hp = new ProgressBar(20, 20, {
+ width: 200, height: 24,
+ min: 0, max: 100, value: 100, // min/max default to 0 and 1
+ trackColor: "#0000001a",
+ fillColor: "#c0392b",
+ borderColor: "black", borderWidth: 2,
+ padding: 2, // inset of the fill inside the track
+ radius: 6, // 0 is square
+});
+hud.addChild(hp);
+
+hp.value -= 15; // clamps; `ratio` reads back 0..1
+```
+
+`direction` takes `"left-to-right"` (the default), `"right-to-left"`,
+`"top-to-bottom"` or `"bottom-to-top"`, each growing from its own edge.
+
+**The colour follows the value by mutating the `Color` you passed.** This is
+the one non-obvious part, and it is deliberate: every game wants a different
+rule, and most want the colour somewhere else too, next to a shader uniform or
+a glow. The bar re-reads `fillColor` every frame, so it never has to know:
+
+```js
+const full = new Color().parseCSS("#7fe0ff");
+const empty = new Color().parseCSS("#ff2a1e");
+const mixed = new Color();
+
+const shield = new ProgressBar(x, y, {
+ width: 190, height: 13,
+ trackColor: null, // hollow: the scene shows through
+ borderColor: "rgba(122, 190, 235, 0.8)",
+ padding: 2,
+ fillColor: mixed, // KEPT, not copied
+});
+
+// each frame
+mixed.copy(empty).lerp(full, hp); // the bar follows, and so can a shader
+shield.value = hp;
+```
+
+Blinking is the same story: `setOpacity()` already does it, on whatever clock
+the game is already keeping. Neither is built in.
+
+**`bindEvent` takes the value from an event instead**, and the subscription
+then lives exactly as long as the bar:
+
+```js
+const bar = new ProgressBar(0, y, {
+ width: renderer.width, height: 4,
+ trackColor: "black", fillColor: "#55aa00",
+ bindEvent: event.LOADER_PROGRESS,
+});
+```
+
+Hold that subscription anywhere else and it outlives the bar, firing into a
+renderable whose `pos` has already been released. The engine's own loading
+screen is built on exactly this.
+
+Two more worth knowing:
+
+- **The border is four filled rects, not a stroke** (unless the bar is
+ rounded). A stroke is centred on the path, so half of it falls outside and a
+ bordered bar paints over its neighbour; and `strokeRect` adds corner joins
+ only above a line width of 1, so at exactly 1 a corner pixel goes missing.
+- **`trackColor: null` leaves the bar hollow**, which is what a HUD over a busy
+ scene usually wants: the border is then the only chrome.
+
### Buttons: extend the handlers, do not bind listeners
`UISpriteElement` is a `Sprite` that already registers itself for pointer
@@ -139,7 +212,7 @@ events, so a button is made by overriding methods rather than by wiring
|---|---|---|
| `onClick(event)` | pressed | `false` to stop the event propagating |
| `onRelease(event)` | pressed and released | `false` to stop propagating |
-| `onOver(event)` | pointer enters | — |
+| `onOver(event)` | pointer enters | `false` to stop propagating |
| `onOut(event)` | pointer leaves | — |
| `onHold()` | pressed and held | — |
@@ -163,6 +236,59 @@ 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.
+### `z` is container-local
+
+`addChild(child, z)` writes a depth that means something **only among that
+container's own children**. `autoDepth` numbers them from 1, so a button at
+`z = 8` inside one panel and a whole second panel at `z = 2` are not comparable
+numbers, and the second panel is still the one on top. Drawing and hit-testing
+both resolve a cross-container pair the same way: walk up to the common parent
+and compare the two siblings there.
+
+So a panel is raised by raising **the panel**, not its contents:
+
+```js
+this.ancestor.moveToTop(panel); // reorders, and sets z past its neighbour
+```
+
+Giving a child a huge `z` to "put it in front" lifts it only within its own
+panel. This is also why a widget's `z` never has to be coordinated with
+anything outside its own container.
+
+### An opaque panel has to say so
+
+The hit test asks the topmost renderable first and then keeps walking down, so
+consuming is what stops a widget underneath answering too. A panel that can
+overlap another one therefore wants both:
+
+```js
+class Panel extends UIBaseElement {
+ onClick() {
+ this.ancestor.moveToTop(this); // and bring it to the front
+ return false; // consumed
+ }
+ onOver() {
+ return false; // the frame the pointer crosses in
+ }
+ onActivateEvent() {
+ super.onActivateEvent();
+ // and every frame after that, when the pointer is ALREADY inside and
+ // `onOver` no longer fires
+ input.registerPointerEvent("pointermove", this, () => false);
+ }
+ onDeactivateEvent() {
+ input.releasePointerEvent("pointermove", this);
+ super.onDeactivateEvent();
+ }
+}
+```
+
+Leave `onOut` alone: an element that suppresses its own leave stays lit after
+the pointer has gone. It fires for the covered element too, not only when the
+pointer leaves its bounds, so a button half under the panel goes dark as soon as
+the pointer slides onto the panel, which never takes it out of the button's
+bounds.
+
### In a 3D scene, a HUD needs a SMALL depth
`floating` opts a renderable out of the camera transform. It does **not** opt it
diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts
index a8da84ad3..4dd872dac 100644
--- a/packages/melonjs/src/index.ts
+++ b/packages/melonjs/src/index.ts
@@ -51,6 +51,7 @@ import BitmapTextData from "./renderable/text/bitmaptextdata.ts";
import Text from "./renderable/text/text.js";
import Trail from "./renderable/trail.js";
import Trigger from "./renderable/trigger.js";
+import ProgressBar from "./renderable/ui/progressbar.ts";
import UIBaseElement from "./renderable/ui/uibaseelement.ts";
import UISpriteElement from "./renderable/ui/uispriteelement.ts";
import UITextButton from "./renderable/ui/uitextbutton.ts";
@@ -180,6 +181,10 @@ export type {
InstancedMeshSettings,
} from "./renderable/instanced_mesh.js";
export type { MeshSettings } from "./renderable/mesh.js";
+export type {
+ ProgressBarDirection,
+ ProgressBarSettings,
+} from "./renderable/ui/progressbar.js";
export * as device from "./system/device.js";
export * as event from "./system/event.ts";
export * as utils from "./utils/utils.ts";
@@ -243,6 +248,7 @@ export {
PORTABLE_TOPOLOGIES,
Pointer,
PrimitiveBatcher,
+ ProgressBar,
plugins,
pool,
QuadBatcher,
diff --git a/packages/melonjs/src/input/pointerevent.ts b/packages/melonjs/src/input/pointerevent.ts
index bed281948..6210c4eaa 100644
--- a/packages/melonjs/src/input/pointerevent.ts
+++ b/packages/melonjs/src/input/pointerevent.ts
@@ -333,8 +333,10 @@ function dispatchEvent(normalizedEvents: Pointer[]): boolean {
currentPointer.pos.set(pointer.gameWorldX, pointer.gameWorldY);
currentPointer.setSize(pointer.width, pointer.height);
+ const isPointerMove = POINTER_MOVE.includes(pointer.type);
+
// trigger a global event for pointer move
- if (POINTER_MOVE.includes(pointer.type)) {
+ if (isPointerMove) {
pointer.gameX = pointer.gameLocalX = pointer.gameScreenX;
pointer.gameY = pointer.gameLocalY = pointer.gameScreenY;
emit(POINTERMOVE, pointer);
@@ -375,6 +377,10 @@ function dispatchEvent(normalizedEvents: Pointer[]): boolean {
// add the main game viewport to the list of candidates
candidates = candidates.concat([_app.viewport]);
+ // set once something above has consumed a move: everything left in the
+ // walk is drawn under it, so the pointer is no longer theirs
+ let occluded = false;
+
for (
let c = candidates.length, candidate;
c--, (candidate = candidates[c]);
@@ -411,6 +417,25 @@ function dispatchEvent(normalizedEvents: Pointer[]): boolean {
? bounds.contains(pointer.gameX, pointer.gameY)
: bounds.contains(absoluteWorldX, absoluteWorldY);
+ // Covered by whatever consumed the move above. Being inside its
+ // own bounds is no longer what decides it, so release the
+ // pointer the same way moving out of bounds would: a region
+ // left holding one it cannot see stays in its hover state until
+ // the pointer happens to leave its bounds entirely, which is
+ // how a button half under a panel stayed lit while the pointer
+ // sat on the panel.
+ if (occluded) {
+ if (handlers.pointerId === pointer.pointerId) {
+ triggerEvent(
+ handlers,
+ findActiveEvent(activeEventList, POINTER_LEAVE),
+ pointer,
+ null,
+ );
+ }
+ continue;
+ }
+
switch (pointer.type) {
case POINTER_MOVE[0]:
case POINTER_MOVE[1]:
@@ -503,8 +528,13 @@ function dispatchEvent(normalizedEvents: Pointer[]): boolean {
}
}
if (handled) {
- // stop iterating through this list of candidates
- break;
+ if (!isPointerMove) {
+ // stop iterating through this list of candidates
+ break;
+ }
+ // a move still has to reach the regions underneath, not to
+ // offer them the event but to take it away from them
+ occluded = true;
}
}
}
@@ -728,6 +758,14 @@ export function unbindPointer(button?: number): void {
/**
* allows registration of event listeners on the object target.
* melonJS will pass a me.Pointer object to the defined callback.
+ *
+ * Regions under the pointer are asked in the order they are drawn, topmost
+ * first, and the walk continues downwards until a callback returns `false`.
+ * Depth is resolved per container, so a child's `pos.z` orders it among its
+ * siblings only and a cross-container pair is decided at the common ancestor,
+ * matching what is drawn on top. `app.viewport` is asked BEFORE all of them
+ * rather than as a fallback, so a callback registered on it should not return
+ * `false` unless it means to swallow every pointer event in the game.
* @see Pointer
* @see {@link http://www.w3.org/TR/pointerevents/#list-of-pointer-events | W3C Pointer Event list}
* @param eventType - The event type for which the object is registering
diff --git a/packages/melonjs/src/loader/loadingscreen.js b/packages/melonjs/src/loader/loadingscreen.js
index d704c7fd7..302ae6d80 100644
--- a/packages/melonjs/src/loader/loadingscreen.js
+++ b/packages/melonjs/src/loader/loadingscreen.js
@@ -1,84 +1,16 @@
import Camera2d from "./../camera/camera2d.ts";
-import Renderable from "./../renderable/renderable.js";
import Sprite from "./../renderable/sprite.js";
+import ProgressBar from "./../renderable/ui/progressbar.ts";
import Stage from "./../state/stage.ts";
import {
LOADER_COMPLETE,
LOADER_PROGRESS,
off,
- on,
once,
- VIEWPORT_ONRESIZE,
} from "../system/event.ts";
import { load, unload } from "./loader.js";
import logo_url from "./melonjs_logo.png";
-// a basic progress bar object
-class ProgressBar extends Renderable {
- /**
- * @ignore
- * @internal
- */
- constructor(x, y, w, h) {
- super(x, y, w, h);
-
- this.barHeight = h;
- this.anchorPoint.set(0, 0);
-
- on(LOADER_PROGRESS, this.onProgressUpdate, this);
- on(VIEWPORT_ONRESIZE, this.resize, this);
-
- this.anchorPoint.set(0, 0);
-
- // store current progress
- this.progress = 0;
- }
-
- /**
- * make sure the screen is refreshed every frame
- * @ignore
- * @internal
- */
- onProgressUpdate(progress) {
- this.progress = ~~(progress * this.width);
- this.isDirty = true;
- }
-
- /**
- * draw function
- * @ignore
- * @internal
- */
- draw(renderer, viewport) {
- // draw the progress bar
- renderer.setColor("black");
- renderer.fillRect(
- this.pos.x,
- viewport.centerY,
- renderer.width,
- this.barHeight / 2,
- );
-
- renderer.setColor("#55aa00");
- renderer.fillRect(
- this.pos.x,
- viewport.centerY,
- this.progress,
- this.barHeight / 2,
- );
- }
-
- /**
- * Called by engine before deleting the object
- * @ignore
- * @internal
- */
- onDestroyEvent() {
- off(LOADER_PROGRESS, this.onProgressUpdate, this);
- off(VIEWPORT_ONRESIZE, this.resize, this);
- }
-}
-
/**
* a default loading screen
* @ignore
@@ -147,8 +79,30 @@ class DefaultLoadingScreen extends Stage {
const { width, height } = app.renderer;
- // progress bar
- this.progressBar = new ProgressBar(0, height / 2, width, barHeight);
+ // The progress bar, which is the public `ProgressBar` renderable: the
+ // loading screen is the first consumer of it, and being a consumer
+ // rather than carrying a private copy is what keeps the two honest.
+ //
+ // `barHeight / 2` because the private bar this replaces was built at 8
+ // and drew at half that, so the look is preserved exactly.
+ this.progressBar = new ProgressBar(0, height / 2, {
+ width,
+ height: barHeight / 2,
+ trackColor: "black",
+ fillColor: "#55aa00",
+ borderColor: null,
+ // Bound rather than driven, and bound BY THE BAR so the listener
+ // cannot outlive it. The private bar this replaces hard-coded this
+ // same subscription in its own constructor, which is what made it
+ // useless to a game; naming the event in the settings keeps the
+ // lifetime guarantee and gives the choice back.
+ //
+ // The bar holds a RATIO rather than a pixel count, which fixes a
+ // bug on the way past: the old one multiplied by its width on
+ // arrival, so a viewport resize part way through a load left the
+ // fill at the old scale until the next asset happened to land.
+ bindEvent: LOADER_PROGRESS,
+ });
app.world.addChild(this.progressBar, 1);
// Latch "the preloader is done" — and ONLY that.
diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js
index 59b3a2cc6..67566e252 100644
--- a/packages/melonjs/src/renderable/container.js
+++ b/packages/melonjs/src/renderable/container.js
@@ -443,7 +443,13 @@ export default class Container extends Renderable {
* if the given child implements an onActivateEvent method, that method will be called
* once the child is added to this container.
* @param {Renderable|Entity|Sprite|Collectable|Trigger|Draggable|DropTarget|NineSliceSprite|ImageLayer|ColorLayer|Light2d|UIBaseElement|UISpriteElement|UITextButton|Text|BitmapText} child - Child to be added
- * @param {number} [z] - forces the z index of the child to the specified value
+ * @param {number} [z] - forces the z index of the child to the specified
+ * value. This depth is scoped to THIS container: it orders the child among
+ * its siblings only, and says nothing about children of another container.
+ * Both drawing and pointer hit-testing resolve a cross-container pair at
+ * the common ancestor instead, so to put a whole subtree in front use
+ * {@link Container#moveToTop} on the container rather than a large `z` on
+ * something inside it. Left out, `autoDepth` numbers children from 1.
* @returns {Renderable} the added child
*/
addChild(child, z) {
@@ -1031,6 +1037,11 @@ export default class Container extends Renderable {
/**
* Move the specified child to the top(z depth).
+ *
+ * Moves the whole subtree: the child's own children keep their depths among
+ * themselves and come with it, because depth is resolved per container. This
+ * is how one overlapping panel is brought in front of another, for drawing
+ * and for pointer hit-testing alike.
* @param {Renderable|Entity|Sprite|Collectable|Trigger|Draggable|DropTarget|NineSliceSprite|ImageLayer|ColorLayer|Light2d|UIBaseElement|UISpriteElement|UITextButton|Text|BitmapText} child - Child to be moved
*/
moveToTop(child) {
@@ -1155,12 +1166,66 @@ export default class Container extends Renderable {
}
/**
- * Reverse Z Sorting function
+ * Hit-test ordering function: orders renderables the way
+ * {@link Container#draw} paints them, topmost last.
+ *
+ * `pos.z` is **container-local** - `autoDepth` numbers each
+ * container's own children from 1, so the z of two children of
+ * different containers are unrelated numbers. `draw` never compares
+ * them: it recurses, so a child's z is only ever weighed against its
+ * siblings. The pointer hit-test sorts one flat list of broadphase
+ * candidates instead, so comparing raw z there let a button nested in
+ * a low panel sort above an entire panel stacked on top of it, and
+ * the covered button still answered the pointer.
+ *
+ * Resolve each pair at the depth where `draw` decides it: walk both
+ * up to their common parent and compare the two siblings whose order
+ * picks which subtree paints last. When one is an ancestor of the
+ * other the descendant wins, since a child paints after its parent.
+ * Equal sibling z falls back to child order, because the sort in
+ * `draw` is stable and its reverse walk paints the lower index last.
* @ignore
* @internal
*/
_sortReverseZ(a, b) {
- return a.pos.z - b.pos.z;
+ let nodeA = a;
+ let nodeB = b;
+
+ if (nodeA.ancestor !== nodeB.ancestor) {
+ let depthA = 0;
+ for (let n = nodeA.ancestor; n; n = n.ancestor) {
+ depthA++;
+ }
+ let depthB = 0;
+ for (let n = nodeB.ancestor; n; n = n.ancestor) {
+ depthB++;
+ }
+ // keep the original difference: if the two turn out to be on
+ // the same ancestor chain, the deeper one paints later
+ const deeper = depthA - depthB;
+ while (depthA > depthB) {
+ nodeA = nodeA.ancestor;
+ depthA--;
+ }
+ while (depthB > depthA) {
+ nodeB = nodeB.ancestor;
+ depthB--;
+ }
+ // both at equal depth now, rise in step until they are siblings
+ while (nodeA.ancestor !== nodeB.ancestor) {
+ nodeA = nodeA.ancestor;
+ nodeB = nodeB.ancestor;
+ }
+ if (nodeA === nodeB) {
+ return deeper;
+ }
+ }
+
+ const parent = nodeA.ancestor;
+ return (
+ nodeA.pos.z - nodeB.pos.z ||
+ (parent ? parent.getChildIndex(nodeB) - parent.getChildIndex(nodeA) : 0)
+ );
}
/**
diff --git a/packages/melonjs/src/renderable/ui/progressbar.ts b/packages/melonjs/src/renderable/ui/progressbar.ts
new file mode 100644
index 000000000..f14397d75
--- /dev/null
+++ b/packages/melonjs/src/renderable/ui/progressbar.ts
@@ -0,0 +1,499 @@
+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";
+
+/**
+ * The name of any event the engine knows about.
+ *
+ * Derived from `on` rather than naming the `Events` map, which is internal to
+ * the event module; this gets the same set without widening that surface.
+ */
+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"
+ | "right-to-left"
+ | "top-to-bottom"
+ | "bottom-to-top";
+
+/**
+ * Everything {@link ProgressBar} takes.
+ */
+export interface ProgressBarSettings {
+ /** the bar's full width, in pixels */
+ width: number;
+ /** the bar's full height, in pixels */
+ height: number;
+ /** the value that reads as empty */
+ min?: number;
+ /** the value that reads as full */
+ max?: number;
+ /** where to start */
+ value?: number;
+ /** which edge the fill grows from */
+ direction?: ProgressBarDirection;
+ /** behind the fill; `null` leaves it hollow, showing whatever is behind */
+ trackColor?: Paint | null;
+ /**
+ * the fill itself. A {@link Color} is re-read every frame, so mutating the
+ * one you passed animates the bar
+ */
+ fillColor?: Paint;
+ /** drawn around the track; `null` for none */
+ borderColor?: Paint | null;
+ /** how thick that border is */
+ borderWidth?: number;
+ /** how far the fill sits inside the track, per side */
+ padding?: number;
+ /** corner radius; `0` is square */
+ radius?: number;
+ /** called whenever the value actually moves */
+ onChange?: (value: number, ratio: number) => void;
+ /** draw a label over the bar */
+ showLabel?: boolean;
+ /** the label's font family */
+ font?: string;
+ /** the label's font size, in pixels */
+ fontSize?: number;
+ /** the label's colour */
+ labelFillStyle?: Paint;
+ /** what the label says; the default is a rounded percentage */
+ labelFormat?: (value: number, ratio: number) => string;
+ /**
+ * an event to take the value from, instead of setting it by hand.
+ *
+ * The bar subscribes for as long as it exists and unsubscribes when it is
+ * destroyed, which is the point: a subscription that outlives what it
+ * drives is a listener writing into a freed object.
+ */
+ bindEvent?: EventName;
+}
+
+/**
+ * A bar that shows one value between two bounds.
+ *
+ * A health bar, a shield gauge, a cooldown, a loading bar: all the same
+ * object, and the engine's own loading screen is built on this one. Set
+ * {@link ProgressBar#value} and it redraws.
+ *
+ * It draws the track, the fill, the border and the label itself, in that
+ * order, which is the point of it being ONE renderable rather than a track
+ * sprite with a fill sprite on top. Two renderables at the same place have to
+ * be told which is in front, and that question has to be answered again every
+ * time either one moves.
+ *
+ * ## The colour can follow the value
+ *
+ * `fillColor` is read at draw time rather than copied, so passing a
+ * {@link Color} you keep hold of and mutating it is all a value-driven colour
+ * takes. That is deliberately not built in, because every game wants a
+ * different rule and most want the colour somewhere else too, next to a shader
+ * uniform or a glow:
+ *
+ * ```js
+ * const full = new Color().parseCSS("#7fe0ff");
+ * const empty = new Color().parseCSS("#ff2a1e");
+ * const mixed = new Color();
+ *
+ * const shield = new ProgressBar(x, y, {
+ * width: 190, height: 13,
+ * trackColor: null, // a hollow frame
+ * borderColor: "rgba(122, 190, 235, 0.8)",
+ * padding: 2,
+ * fillColor: mixed, // kept, not copied
+ * });
+ *
+ * // each frame
+ * mixed.copy(empty).lerp(full, hp);
+ * shield.value = hp;
+ * ```
+ *
+ * Blinking is the same story: {@link Renderable#setOpacity} already does it,
+ * on whatever clock the game is already keeping.
+ * @category UI
+ * @example
+ * // a loading bar
+ * const bar = new ProgressBar(0, 100, {
+ * width: renderer.width, height: 4,
+ * trackColor: "black", fillColor: "#55aa00",
+ * });
+ * event.on(event.LOADER_PROGRESS, (progress) => { bar.value = progress; });
+ * @example
+ * // a label, and a value that is not a fraction
+ * const hp = new ProgressBar(20, 20, {
+ * width: 200, height: 24,
+ * min: 0, max: 100, value: 100,
+ * fillColor: "#c0392b", radius: 6,
+ * showLabel: true,
+ * labelFormat: (v) => `${Math.round(v)} HP`,
+ * });
+ */
+export default class ProgressBar extends Renderable {
+ /** the value that reads as empty */
+ min: number;
+ /** the value that reads as full */
+ max: number;
+ /** which edge the fill grows from */
+ direction: ProgressBarDirection;
+ /** behind the fill; `null` leaves it hollow */
+ trackColor: Paint | null;
+ /** the fill. A {@link Color} here is re-read every frame */
+ fillColor: Paint;
+ /** drawn around the track; `null` for none */
+ borderColor: Paint | null;
+ /** how thick that border is */
+ borderWidth: number;
+ /** how far the fill sits inside the track, per side */
+ padding: number;
+ /** corner radius; `0` is square */
+ radius: number;
+ /** called whenever the value actually moves */
+ onChange?: ((value: number, ratio: number) => void) | undefined;
+ /** what the label says */
+ labelFormat: (value: number, ratio: number) => string;
+ /** the label, when `showLabel` was asked for */
+ label?: Text | undefined;
+ /** the event the value is taken from, if any */
+ 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 = "";
+
+ /**
+ * @param x - position of the bar's left edge
+ * @param y - position of its top edge
+ * @param settings - see {@link ProgressBarSettings}
+ */
+ constructor(x: number, y: number, settings: ProgressBarSettings) {
+ super(x, y, settings.width, settings.height);
+
+ this.min = settings.min ?? 0;
+ this.max = settings.max ?? 1;
+ this.direction = settings.direction ?? "left-to-right";
+ this.trackColor = settings.trackColor ?? null;
+ this.fillColor = settings.fillColor ?? "#ffffff";
+ this.borderColor = settings.borderColor ?? null;
+ this.borderWidth = settings.borderWidth ?? 1;
+ this.padding = settings.padding ?? 0;
+ this.radius = settings.radius ?? 0;
+ this.onChange = settings.onChange;
+ this.labelFormat =
+ settings.labelFormat ?? ((_v, ratio) => `${Math.round(ratio * 100)}%`);
+
+ this._value = clamp(settings.value ?? this.min, this.min, this.max);
+
+ // the bar draws itself from `pos`, and `preDraw` applies the anchor
+ // without translating to `pos`, so a centred anchor would offset
+ // everything by half the bar
+ this.anchorPoint.set(0, 0);
+
+ // Bound for exactly as long as this bar exists. Tying the two together
+ // is what stops the listener outliving the thing it writes into: a
+ // subscription held somewhere else goes on firing after the bar is
+ // destroyed, and lands on a renderable whose `pos` has already been
+ // released.
+ this.bindEvent = settings.bindEvent;
+ if (this.bindEvent !== undefined) {
+ on(this.bindEvent, this._onBound, this);
+ }
+
+ if (settings.showLabel === true) {
+ this.label = new Text(0, 0, {
+ font: settings.font ?? "sans-serif",
+ // Rounded, because the default is derived: a 24 tall bar gives
+ // 16.799999999999997, which reads back out of `label.font` as
+ // that, and a fractional size renders softer than a whole one.
+ // Only the DEFAULT is rounded; an explicit `fontSize` is used
+ // exactly as given, fraction and all.
+ size:
+ settings.fontSize ?? Math.round(Math.max(8, settings.height * 0.7)),
+ fillStyle: settings.labelFillStyle ?? "#ffffff",
+ textAlign: "center",
+ textBaseline: "middle",
+ text: this.labelFormat(this._value, this.ratio),
+ });
+ }
+ }
+
+ /**
+ * Where the bar is now, between {@link ProgressBar#min} and
+ * {@link ProgressBar#max}.
+ *
+ * Writing a value outside that range clamps rather than throwing, since
+ * the usual source is a health or a timer that has just gone past its own
+ * limit. Writing the value it already has does nothing at all, so
+ * `onChange` means the value MOVED.
+ */
+ get value() {
+ return this._value;
+ }
+
+ set value(v: number) {
+ const next = clamp(v, this.min, this.max);
+ if (next === this._value) {
+ return;
+ }
+ this._value = next;
+ this.isDirty = true;
+ if (this.label !== undefined) {
+ this.label.setText(this.labelFormat(next, this.ratio));
+ }
+ this.onChange?.(next, this.ratio);
+ }
+
+ /**
+ * The value as a fraction, `0` at `min` and `1` at `max`.
+ *
+ * This is what the fill is drawn from, and what a caller usually wants for
+ * anything else keyed to the same number. Read only; set
+ * {@link ProgressBar#value}.
+ */
+ get ratio() {
+ const span = this.max - this.min;
+ return span === 0 ? 0 : (this._value - this.min) / span;
+ }
+
+ /**
+ * Take the value from the bound event.
+ * @param value - whatever that event reports
+ * @ignore
+ * @internal
+ */
+ private _onBound = (value: number) => {
+ this.value = value;
+ };
+
+ /**
+ * Set the value, for chaining.
+ * @param v - the new value, clamped to the bar's bounds
+ * @returns this bar
+ */
+ setValue(v: number) {
+ this.value = v;
+ 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
+ */
+ override draw(renderer: Renderer) {
+ const x = this.pos.x;
+ const y = this.pos.y;
+ const w = this.width;
+ const h = this.height;
+
+ // 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.
+ const alpha = renderer.globalAlpha();
+
+ if (this.trackColor !== null) {
+ this._paint(renderer, this.trackColor, alpha);
+ this._rect(renderer, x, y, w, h);
+ }
+
+ // the fill grows from one edge, inside the padding
+ const p = this.padding;
+ const iw = w - p * 2;
+ const ih = h - p * 2;
+ const ratio = this.ratio;
+ if (ratio > 0 && iw > 0 && ih > 0) {
+ const fw = this.horizontal ? iw * ratio : iw;
+ const fh = this.horizontal ? ih : ih * ratio;
+ // right-to-left and bottom-to-top keep the far edge pinned, so the
+ // 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);
+ // 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));
+ }
+
+ 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
+ }
+ }
+
+ if (this.label !== undefined) {
+ // `setPosition` rather than `pos.set(x, y)`, which would write a
+ // zero depth: `pos` is a 3D vector typed as a 2D one. Guarded,
+ // because every write to that observable recomputes the label's
+ // bounds and a bar that has not moved asks for the same point
+ // every frame.
+ const cx = x + w / 2;
+ const cy = y + h / 2;
+ if (this.label.pos.x !== cx || this.label.pos.y !== cy) {
+ this.label.setPosition(cx, cy);
+ }
+ renderer.setGlobalAlpha(alpha);
+ this.label.preDraw(renderer);
+ this.label.draw(renderer);
+ this.label.postDraw(renderer);
+ }
+ }
+
+ /**
+ * Whether the fill grows along x.
+ * @ignore
+ * @internal
+ */
+ private get horizontal() {
+ return (
+ this.direction === "left-to-right" || this.direction === "right-to-left"
+ );
+ }
+
+ /**
+ * Release the label, if there is one.
+ * @ignore
+ * @internal
+ */
+ override onDestroyEvent() {
+ if (this.bindEvent !== undefined) {
+ off(this.bindEvent, this._onBound, this);
+ }
+ this.label?.destroy();
+ this.label = undefined;
+ if (this._round !== undefined) {
+ roundedRectanglePool.release(this._round);
+ this._round = undefined;
+ }
+ colorPool.release(this._css);
+ }
+}
diff --git a/packages/melonjs/src/renderable/ui/uibaseelement.ts b/packages/melonjs/src/renderable/ui/uibaseelement.ts
index a5171ee50..cc831f2b9 100644
--- a/packages/melonjs/src/renderable/ui/uibaseelement.ts
+++ b/packages/melonjs/src/renderable/ui/uibaseelement.ts
@@ -12,7 +12,36 @@ import Container from "../container.js";
/**
* This is a basic clickable and draggable container which you can use in your game UI.
* Use this for example if you want to display a panel that contains text, images or other UI elements.
+ *
+ * Regions under the pointer are asked in the order they are drawn, topmost
+ * first, and the walk carries on downwards until a handler returns `false`. An
+ * element that can overlap another one is therefore not opaque until it says
+ * so: by default a widget it covers still answers clicks and still lights up on
+ * hover. {@link UIBaseElement#onClick}, {@link UIBaseElement#onRelease} and
+ * {@link UIBaseElement#onOver} each consume by returning `false`.
* @category UI
+ * @example
+ * // a panel that is opaque to the pointer and comes to the front when picked
+ * class Panel extends UIBaseElement {
+ * onClick() {
+ * this.ancestor.moveToTop(this);
+ * return false;
+ * }
+ * // the frame the pointer crosses in
+ * onOver() {
+ * return false;
+ * }
+ * // and every frame after that, when the pointer is already inside and
+ * // `onOver` no longer fires
+ * onActivateEvent() {
+ * super.onActivateEvent();
+ * me.input.registerPointerEvent("pointermove", this, () => false);
+ * }
+ * onDeactivateEvent() {
+ * me.input.releasePointerEvent("pointermove", this);
+ * super.onDeactivateEvent();
+ * }
+ * }
*/
export default class UIBaseElement extends Container {
/**
@@ -133,7 +162,7 @@ export default class UIBaseElement extends Container {
* @ignore
* @internal
*/
- enter(event: Pointer): void {
+ enter(event: Pointer): boolean | void {
this.hover = true;
this.isDirty = true;
if (this.isDraggable) {
@@ -142,7 +171,7 @@ export default class UIBaseElement extends Container {
// to memorize where we grab the object
this.grabOffset = vector2dPool.get(0, 0);
}
- this.onOver(event);
+ return this.onOver(event);
}
/**
@@ -173,11 +202,19 @@ export default class UIBaseElement extends Container {
}
/**
- * function called when the pointer is over the object
+ * function called when the pointer is over the object.
+ *
+ * Fires once, on the frame the pointer crosses into the element. To stay
+ * opaque for as long as the pointer rests on it, an element also wants a
+ * `"pointermove"` callback registered through
+ * {@link input.registerPointerEvent} returning `false`, since by then the
+ * pointer is already inside and this no longer runs.
* @param _event - the event object
+ * @returns return false if we need to stop propagating the event, so that an
+ * element covered by this one does not also light up on hover
*/
- onOver(_event?: Pointer): void {
+ onOver(_event?: Pointer): boolean | void {
// to be extended
}
@@ -201,7 +238,15 @@ export default class UIBaseElement extends Container {
}
/**
- * function called when the pointer is leaving the object area
+ * function called when the pointer is leaving the object area.
+ *
+ * Fires when the pointer leaves the element's bounds, and also when it is
+ * still inside them but something drawn above has consumed the move, since
+ * the pointer is then over that instead.
+ *
+ * Unlike {@link UIBaseElement#onOver} this one cannot consume the event, on
+ * purpose: an element that suppressed its own leave would stay in its hover
+ * state after the pointer had gone.
* @param _event - the event object
*/
@@ -271,7 +316,7 @@ export default class UIBaseElement extends Container {
return this.release(e);
});
registerPointerEvent("pointerenter", this, (e) => {
- this.enter(e);
+ return this.enter(e);
});
registerPointerEvent("pointerleave", this, (e) => {
this.leave(e);
diff --git a/packages/melonjs/src/renderable/ui/uispriteelement.ts b/packages/melonjs/src/renderable/ui/uispriteelement.ts
index c20fcd0c7..54736e3ae 100644
--- a/packages/melonjs/src/renderable/ui/uispriteelement.ts
+++ b/packages/melonjs/src/renderable/ui/uispriteelement.ts
@@ -135,18 +135,26 @@ export default class UISpriteElement extends Sprite {
* @ignore
* @internal
*/
- enter(event: Pointer): void {
+ enter(event: Pointer): boolean | void {
this.hover = true;
this.isDirty = true;
- this.onOver(event);
+ return this.onOver(event);
}
/**
- * function called when the pointer is over the object
+ * function called when the pointer is over the object.
+ *
+ * Fires once, on the frame the pointer crosses into the element. To stay
+ * opaque for as long as the pointer rests on it, an element also wants a
+ * `"pointermove"` callback registered through
+ * {@link input.registerPointerEvent} returning `false`, since by then the
+ * pointer is already inside and this no longer runs.
* @param _event - the event object
+ * @returns return false if we need to stop propagating the event, so that an
+ * element covered by this one does not also light up on hover
*/
- onOver(_event?: Pointer): void {
+ onOver(_event?: Pointer): boolean | void {
// to be extended
}
@@ -163,7 +171,15 @@ export default class UISpriteElement extends Sprite {
}
/**
- * function called when the pointer is leaving the object area
+ * function called when the pointer is leaving the object area.
+ *
+ * Fires when the pointer leaves the element's bounds, and also when it is
+ * still inside them but something drawn above has consumed the move, since
+ * the pointer is then over that instead.
+ *
+ * Unlike {@link UISpriteElement#onOver} this one cannot consume the event, on
+ * purpose: an element that suppressed its own leave would stay in its hover
+ * state after the pointer had gone.
* @param _event - the event object
*/
@@ -233,7 +249,7 @@ export default class UISpriteElement extends Sprite {
return this.release(e);
});
registerPointerEvent("pointerenter", this, (e) => {
- this.enter(e);
+ return this.enter(e);
});
registerPointerEvent("pointerleave", this, (e) => {
this.leave(e);
diff --git a/packages/melonjs/tests/container.spec.js b/packages/melonjs/tests/container.spec.js
index 88e073e52..30ced0bcb 100644
--- a/packages/melonjs/tests/container.spec.js
+++ b/packages/melonjs/tests/container.spec.js
@@ -690,6 +690,98 @@ describe("Container", () => {
expect(container._sortReverseZ(b, a)).toBeGreaterThan(0); // a still first
});
+ // `_sortReverseZ` orders the FLAT list of pointer hit-test candidates,
+ // so unlike `_sortZ` it has to cope with pairs from different
+ // containers. `pos.z` is container-local, so raw z says nothing about
+ // such a pair and it has to be resolved where `draw` resolves it:
+ // between the two siblings whose order picks the subtree painted last.
+ describe("_sortReverseZ across containers", () => {
+ // root
+ // +- low (z 1) -+- buried (z 9)
+ // +- high (z 2) -+- shallow (z 1)
+ const build = () => {
+ const root = new Container(0, 0, 100, 100, true);
+ root.autoSort = false;
+ const low = new Container(0, 0, 50, 50);
+ const high = new Container(0, 0, 50, 50);
+ root.addChild(low, 1);
+ root.addChild(high, 2);
+ const buried = new Renderable(0, 0, 1, 1);
+ const shallow = new Renderable(0, 0, 1, 1);
+ low.addChild(buried, 9);
+ high.addChild(shallow, 1);
+ return { root, low, high, buried, shallow };
+ };
+
+ it("a high container outranks a high-z child of a low one", () => {
+ const { high, buried } = build();
+ // raw z would put `buried` (9) above `high` (2)
+ expect(high.pos.z).toBeLessThan(buried.pos.z);
+ expect(container._sortReverseZ(buried, high)).toBeLessThan(0);
+ expect(container._sortReverseZ(high, buried)).toBeGreaterThan(0);
+ });
+
+ it("a child of the high container outranks a child of the low one", () => {
+ const { buried, shallow } = build();
+ expect(container._sortReverseZ(buried, shallow)).toBeLessThan(0);
+ expect(container._sortReverseZ(shallow, buried)).toBeGreaterThan(0);
+ });
+
+ it("a child outranks the container holding it", () => {
+ const { low, buried } = build();
+ expect(container._sortReverseZ(low, buried)).toBeLessThan(0);
+ expect(container._sortReverseZ(buried, low)).toBeGreaterThan(0);
+ });
+
+ it("equal sibling z resolves by child order, lower index on top", () => {
+ // `draw` sorts stably and walks the result backwards, so the
+ // lower index is painted LAST and must be hit FIRST
+ const { root, low, high, buried, shallow } = build();
+ high.pos.z = low.pos.z;
+ expect(root.getChildIndex(low)).toBeLessThan(root.getChildIndex(high));
+ expect(container._sortReverseZ(high, low)).toBeLessThan(0);
+ expect(container._sortReverseZ(low, high)).toBeGreaterThan(0);
+ // and it carries through to their children
+ expect(container._sortReverseZ(shallow, buried)).toBeLessThan(0);
+ expect(container._sortReverseZ(buried, shallow)).toBeGreaterThan(0);
+ });
+
+ it("does not walk off the top for a renderable with no ancestor", () => {
+ const { buried } = build();
+ const orphan = new Renderable(0, 0, 1, 1);
+ orphan.pos.z = 4;
+ expect(() => {
+ return container._sortReverseZ(orphan, buried);
+ }).not.toThrow();
+ expect(() => {
+ return container._sortReverseZ(buried, orphan);
+ }).not.toThrow();
+ });
+
+ it("stays a consistent total order over the whole tree", () => {
+ const parts = build();
+ const nodes = [parts.low, parts.high, parts.buried, parts.shallow];
+ for (const x of nodes) {
+ for (const y of nodes) {
+ // summed rather than negated: -Math.sign(0) is -0, which
+ // `toBe` (Object.is) would not match against 0
+ expect(
+ Math.sign(container._sortReverseZ(x, y)) +
+ Math.sign(container._sortReverseZ(y, x)),
+ ).toBe(0);
+ for (const z of nodes) {
+ if (
+ container._sortReverseZ(x, y) < 0 &&
+ container._sortReverseZ(y, z) < 0
+ ) {
+ expect(container._sortReverseZ(x, z)).toBeLessThan(0);
+ }
+ }
+ }
+ }
+ });
+ });
+
it("_sortX should sort by z first, then by x", () => {
const a = new Renderable(100, 0, 1, 1);
const b = new Renderable(200, 0, 1, 1);
diff --git a/packages/melonjs/tests/progressbar.spec.js b/packages/melonjs/tests/progressbar.spec.js
new file mode 100644
index 000000000..e7947059d
--- /dev/null
+++ b/packages/melonjs/tests/progressbar.spec.js
@@ -0,0 +1,783 @@
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+import { roundedRectanglePool } from "../src/geometries/roundrect.ts";
+import {
+ Application,
+ boot,
+ Color,
+ event,
+ ProgressBar,
+ video,
+} from "../src/index.js";
+import { colorPool } from "../src/math/color.ts";
+
+/**
+ * A recording renderer.
+ *
+ * The same approach `nineslicesprite.spec.js` takes, and for the same reason:
+ * what matters about a bar is the RECTANGLES it asks for, so capture the calls
+ * and assert the geometry rather than reading pixels back. There is no shared
+ * recorder helper in the suite, so this is a local one.
+ * @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 whole point: a bar that then forced the cascade back over it
+ // would render a deliberately translucent colour as opaque.
+ _alpha: 1,
+ globalAlpha() {
+ return this._alpha;
+ },
+ setGlobalAlpha(a) {
+ this._alpha = a;
+ calls.push({ op: "setGlobalAlpha", a });
+ },
+ setColor(color) {
+ // SNAPSHOT it: the bar resolves css strings into one cached
+ // `Color` that it reuses, which is safe because both real
+ // renderers `copy()` what they are handed — but it does mean a
+ // recorder must not hold the reference.
+ calls.push({
+ op: "setColor",
+ color,
+ 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) {
+ // radius snapshotted: the bar reuses ONE RoundRect across the
+ // track and the fill, so the object cannot be read afterwards
+ calls.push({ op: "fill", shape, radius: shape.radius });
+ },
+ stroke(shape) {
+ calls.push({ op: "stroke", shape });
+ },
+ };
+};
+
+/**
+ * Draw a bar and hand back only the rectangles.
+ * @param bar - the bar to draw
+ * @returns every fillRect/strokeRect it asked for, in order
+ */
+const rectsOf = (bar) => {
+ const r = makeRecorder();
+ bar.draw(r);
+ return r.calls.filter((c) => {
+ return c.op === "fillRect" || c.op === "strokeRect";
+ });
+};
+
+describe("ProgressBar", () => {
+ describe("value and ratio", () => {
+ it("starts at min when no value is given", () => {
+ const bar = new ProgressBar(0, 0, { width: 100, height: 10 });
+ expect(bar.value).toBe(0);
+ expect(bar.ratio).toBe(0);
+ });
+
+ it("clamps at both ends rather than throwing", () => {
+ // the usual source is a health or a timer that has just gone past
+ // its own limit, so clamping is the useful behaviour
+ const bar = new ProgressBar(0, 0, { width: 100, height: 10 });
+ bar.value = 5;
+ expect(bar.value).toBe(1);
+ bar.value = -5;
+ expect(bar.value).toBe(0);
+ });
+
+ it("derives ratio against a non-default min and max", () => {
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 10,
+ min: 20,
+ max: 120,
+ value: 45,
+ });
+ expect(bar.ratio).toBeCloseTo(0.25, 6);
+ });
+
+ it("reports a zero ratio when min and max are the same", () => {
+ // nothing sensible to divide by; the alternative is NaN reaching
+ // the fill width
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 10,
+ min: 7,
+ max: 7,
+ });
+ expect(bar.ratio).toBe(0);
+ expect(Number.isNaN(bar.ratio)).toBe(false);
+ });
+
+ it("clamps the initial value too", () => {
+ const bar = new ProgressBar(0, 0, { width: 100, height: 10, value: 9 });
+ expect(bar.value).toBe(1);
+ });
+
+ it("setValue chains", () => {
+ const bar = new ProgressBar(0, 0, { width: 100, height: 10 });
+ expect(bar.setValue(0.5)).toBe(bar);
+ expect(bar.value).toBe(0.5);
+ });
+ });
+
+ describe("onChange", () => {
+ it("fires when the value moves, with the value and the ratio", () => {
+ const onChange = vi.fn();
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 10,
+ min: 0,
+ max: 200,
+ onChange,
+ });
+ bar.value = 50;
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange).toHaveBeenCalledWith(50, 0.25);
+ });
+
+ it("does NOT fire when the value is written but does not move", () => {
+ const onChange = vi.fn();
+ const bar = new ProgressBar(0, 0, { width: 100, height: 10, onChange });
+ bar.value = 0.5;
+ bar.value = 0.5;
+ expect(onChange).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not fire when a write clamps to the value already held", () => {
+ const onChange = vi.fn();
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 10,
+ value: 1,
+ onChange,
+ });
+ bar.value = 99;
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it("marks the bar dirty on a real change", () => {
+ const bar = new ProgressBar(0, 0, { width: 100, height: 10 });
+ bar.isDirty = false;
+ bar.value = 0.3;
+ expect(bar.isDirty).toBe(true);
+ });
+ });
+
+ describe("fill geometry", () => {
+ it("grows the fill along x, proportional to the ratio", () => {
+ const bar = new ProgressBar(10, 20, {
+ width: 200,
+ height: 16,
+ value: 0.25,
+ fillColor: "#fff",
+ });
+ const [fill] = rectsOf(bar);
+ expect(fill).toMatchObject({ x: 10, y: 20, width: 50, height: 16 });
+ });
+
+ it("insets the fill by the padding, on every side", () => {
+ const bar = new ProgressBar(10, 20, {
+ width: 200,
+ height: 16,
+ value: 1,
+ padding: 3,
+ });
+ const [fill] = rectsOf(bar);
+ expect(fill).toMatchObject({
+ x: 13,
+ y: 23,
+ width: 194,
+ height: 10,
+ });
+ });
+
+ it("pins the far edge when filling right to left", () => {
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 10,
+ value: 0.25,
+ direction: "right-to-left",
+ });
+ const [fill] = rectsOf(bar);
+ // 25 wide, hard against the right edge
+ expect(fill.width).toBe(25);
+ expect(fill.x).toBe(75);
+ });
+
+ it("fills downward for top-to-bottom", () => {
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 40,
+ value: 0.5,
+ direction: "top-to-bottom",
+ });
+ const [fill] = rectsOf(bar);
+ expect(fill).toMatchObject({ x: 0, y: 0, width: 100, height: 20 });
+ });
+
+ it("pins the bottom edge for bottom-to-top", () => {
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 40,
+ value: 0.5,
+ direction: "bottom-to-top",
+ });
+ const [fill] = rectsOf(bar);
+ expect(fill.height).toBe(20);
+ expect(fill.y).toBe(20);
+ });
+
+ it("draws no fill at all at zero", () => {
+ const bar = new ProgressBar(0, 0, { width: 100, height: 10, value: 0 });
+ expect(rectsOf(bar)).toHaveLength(0);
+ });
+
+ it("draws no fill when the padding swallows the bar", () => {
+ // a 4px bar with 3px of padding per side has nothing left
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 4,
+ value: 1,
+ padding: 3,
+ });
+ expect(rectsOf(bar)).toHaveLength(0);
+ });
+ });
+
+ describe("track, border and draw order", () => {
+ it("draws the track behind the fill, full size", () => {
+ const bar = new ProgressBar(5, 5, {
+ width: 100,
+ height: 10,
+ value: 0.5,
+ trackColor: "black",
+ });
+ const rects = rectsOf(bar);
+ expect(rects).toHaveLength(2);
+ // track first, at full width; fill second, at half
+ expect(rects[0]).toMatchObject({ x: 5, y: 5, width: 100, height: 10 });
+ expect(rects[1]).toMatchObject({ width: 50 });
+ });
+
+ it("leaves the track out entirely when it is null", () => {
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 10,
+ value: 0.5,
+ trackColor: null,
+ });
+ // a hollow bar is one rectangle, the fill
+ expect(rectsOf(bar)).toHaveLength(1);
+ });
+
+ it("draws a square border as four rects, fully INSIDE the bar", () => {
+ // not `strokeRect`: a stroke is centred on the path, so half of it
+ // falls outside the bar's own rectangle, and at a line width of 1
+ // the backend adds no corner joins and drops a corner pixel
+ const bar = new ProgressBar(10, 20, {
+ width: 100,
+ height: 40,
+ value: 0,
+ borderColor: "#fff",
+ borderWidth: 2,
+ });
+ const rects = rectsOf(bar);
+ expect(
+ rects.every((r) => {
+ return r.op === "fillRect";
+ }),
+ ).toBe(true);
+ expect(rects).toHaveLength(4);
+ const [top, bottom, left, right] = rects;
+ expect(top).toMatchObject({ x: 10, y: 20, width: 100, height: 2 });
+ expect(bottom).toMatchObject({ x: 10, y: 58, width: 100, height: 2 });
+ expect(left).toMatchObject({ x: 10, y: 22, width: 2, height: 36 });
+ expect(right).toMatchObject({ x: 108, y: 22, width: 2, height: 36 });
+ // every edge inside the bar's own rectangle
+ for (const r of rects) {
+ expect(r.x).toBeGreaterThanOrEqual(10);
+ expect(r.y).toBeGreaterThanOrEqual(20);
+ expect(r.x + r.width).toBeLessThanOrEqual(110);
+ expect(r.y + r.height).toBeLessThanOrEqual(60);
+ }
+ });
+
+ it("covers all four corners, which a 1px stroke does not", () => {
+ // the reported defect: the top-left corner pixel went missing
+ const bar = new ProgressBar(0, 0, {
+ width: 50,
+ height: 20,
+ value: 0,
+ borderColor: "#fff",
+ borderWidth: 1,
+ });
+ const rects = rectsOf(bar);
+ const covers = (px, py) => {
+ return rects.some((r) => {
+ return (
+ px >= r.x && px < r.x + r.width && py >= r.y && py < r.y + r.height
+ );
+ });
+ };
+ expect(covers(0, 0)).toBe(true); // top-left
+ expect(covers(49, 0)).toBe(true); // top-right
+ expect(covers(0, 19)).toBe(true); // bottom-left
+ expect(covers(49, 19)).toBe(true); // bottom-right
+ });
+
+ it("still strokes when the bar is rounded, since arcs have no rect form", () => {
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 20,
+ value: 0,
+ radius: 6,
+ borderColor: "#fff",
+ });
+ const r = makeRecorder();
+ bar.draw(r);
+ expect(
+ r.calls.some((c) => {
+ return c.op === "stroke";
+ }),
+ ).toBe(true);
+ });
+
+ it("draws the border LAST, over the track and the fill", () => {
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 10,
+ value: 0.5,
+ trackColor: "black",
+ fillColor: "#4a8fd4",
+ borderColor: "#fff",
+ borderWidth: 1,
+ });
+ const r = makeRecorder();
+ bar.draw(r);
+ const colors = r.calls
+ .filter((c) => {
+ return c.op === "setColor";
+ })
+ .map((c) => {
+ return c.hex.toLowerCase();
+ });
+ expect(colors).toEqual(["#000000", "#4a8fd4", "#ffffff"]);
+ });
+
+ it("restores the renderer's lineWidth after stroking", () => {
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 10,
+ borderColor: "#fff",
+ borderWidth: 7,
+ });
+ const r = makeRecorder();
+ r.lineWidth = 3;
+ bar.draw(r);
+ expect(r.lineWidth).toBe(3);
+ });
+
+ it("multiplies the colour's own alpha into the cascade", () => {
+ // `setColor` overwrites the global alpha with the colour's, so a
+ // bar that then forced the cascaded value back would render a
+ // deliberately translucent track as solid
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 10,
+ value: 1,
+ trackColor: "rgba(0, 0, 0, 0.2)",
+ });
+ const r = makeRecorder();
+ r._alpha = 0.5; // what a half-faded parent cascaded down
+ bar.draw(r);
+ const applied = r.calls
+ .filter((c) => {
+ return c.op === "setGlobalAlpha";
+ })
+ .map((c) => {
+ return c.a;
+ });
+ // 0.2 from the colour, 0.5 from the cascade
+ expect(applied[0]).toBeCloseTo(0.1, 6);
+ });
+
+ it("does not leak one colour's alpha into the next", () => {
+ // a renderer holds one current colour, so a six digit hex does not
+ // reset its alpha. Reading the alpha back after `setColor` to
+ // compute the next one therefore carried the track's transparency
+ // into the fill, and every bar with a translucent track rendered
+ // washed out.
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 10,
+ value: 1,
+ trackColor: "rgba(0, 0, 0, 0.2)", // translucent
+ fillColor: "#4a8fd4", // fully opaque
+ borderColor: "rgba(255, 255, 255, 0.5)",
+ });
+ const r = makeRecorder();
+ bar.draw(r);
+ const applied = r.calls
+ .filter((c) => {
+ return c.op === "setGlobalAlpha";
+ })
+ .map((c) => {
+ return c.a;
+ });
+ expect(applied[0]).toBeCloseTo(0.2, 6); // track
+ expect(applied[1]).toBeCloseTo(1, 6); // fill, NOT 0.2
+ expect(applied[2]).toBeCloseTo(0.5, 6); // border
+ });
+
+ it("re-applies the alpha after every setColor, not just the first", () => {
+ // one `setGlobalAlpha` per `setColor`, or the second shape inherits
+ // the first colour's alpha
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 10,
+ value: 0.5,
+ trackColor: "black",
+ borderColor: "#fff",
+ });
+ const r = makeRecorder();
+ r._alpha = 0.4;
+ bar.draw(r);
+ const colors = r.calls.filter((c) => {
+ return c.op === "setColor";
+ }).length;
+ const alphas = r.calls.filter((c) => {
+ return c.op === "setGlobalAlpha";
+ });
+ expect(colors).toBe(3);
+ expect(alphas).toHaveLength(3);
+ // opaque colours, so each one lands back on the cascade itself
+ for (const a of alphas) {
+ expect(a.a).toBeCloseTo(0.4, 6);
+ }
+ });
+
+ it("reads fillColor at draw time, so a mutated Color animates it", () => {
+ // this is what lets a caller drive the colour from the value
+ // without the bar knowing any rule about it
+ const live = new Color().parseCSS("#ff0000");
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 10,
+ value: 1,
+ fillColor: live,
+ });
+ let r = makeRecorder();
+ bar.draw(r);
+ expect(
+ r.calls.find((c) => {
+ return c.op === "setColor";
+ }).color.r,
+ ).toBe(255);
+
+ live.parseCSS("#0000ff");
+ r = makeRecorder();
+ bar.draw(r);
+ const used = r.calls.find((c) => {
+ return c.op === "setColor";
+ }).color;
+ expect(used.r).toBe(0);
+ expect(used.b).toBe(255);
+ });
+ });
+
+ describe("rounded corners", () => {
+ it("goes through the shape path instead of fillRect", () => {
+ // `fillRoundRect` lives on the concrete renderers, so the portable
+ // route is the shape dispatch
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 20,
+ value: 1,
+ radius: 6,
+ });
+ const r = makeRecorder();
+ bar.draw(r);
+ expect(
+ r.calls.some((c) => {
+ return c.op === "fill";
+ }),
+ ).toBe(true);
+ expect(
+ r.calls.some((c) => {
+ return c.op === "fillRect";
+ }),
+ ).toBe(false);
+ });
+
+ it("gives the fill a tighter radius, so it is concentric with the track", () => {
+ // a shape inset by `padding` has to lose `padding` from its corner
+ // radius too, or it reads as proportionally rounder than the thing
+ // it sits inside
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 20,
+ value: 1,
+ radius: 6,
+ padding: 2,
+ trackColor: "black",
+ });
+ const r = makeRecorder();
+ bar.draw(r);
+ const radii = r.calls
+ .filter((c) => {
+ return c.op === "fill";
+ })
+ .map((c) => {
+ return c.radius;
+ });
+ expect(radii[0]).toBe(6); // the track, at the bar's own radius
+ expect(radii[1]).toBe(4); // the fill, tighter by the padding
+ });
+
+ it("never gives the fill a negative radius", () => {
+ // padding deeper than the radius would otherwise go through zero
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 40,
+ value: 1,
+ radius: 2,
+ padding: 8,
+ });
+ const r = makeRecorder();
+ bar.draw(r);
+ const fill = r.calls.find((c) => {
+ return c.op === "fill" || c.op === "fillRect";
+ });
+ // falls back to a square fill rather than an inverted curve
+ expect(fill.op).toBe("fillRect");
+ });
+
+ it("reuses one RoundRect across draws rather than allocating", () => {
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 20,
+ value: 1,
+ radius: 6,
+ trackColor: "black",
+ });
+ const r = makeRecorder();
+ bar.draw(r);
+ const shapes = r.calls
+ .filter((c) => {
+ return c.op === "fill";
+ })
+ .map((c) => {
+ return c.shape;
+ });
+ expect(shapes).toHaveLength(2);
+ expect(shapes[0]).toBe(shapes[1]);
+ });
+ });
+
+ describe("bindEvent", () => {
+ it("takes its value from the event it was bound to", () => {
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 10,
+ bindEvent: event.LOADER_PROGRESS,
+ });
+ event.emit(event.LOADER_PROGRESS, 0.42, null);
+ expect(bar.value).toBeCloseTo(0.42, 6);
+ bar.destroy();
+ });
+
+ it("STOPS listening once destroyed", () => {
+ // the whole reason the subscription lives on the bar rather than
+ // on whoever made it: a listener that outlives its target goes on
+ // writing into a renderable whose `pos` has already been released,
+ // which is a TypeError somewhere far from the cause
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 10,
+ bindEvent: event.LOADER_PROGRESS,
+ });
+ event.emit(event.LOADER_PROGRESS, 0.5, null);
+ expect(bar.value).toBe(0.5);
+
+ bar.destroy();
+ expect(() => {
+ event.emit(event.LOADER_PROGRESS, 0.9, null);
+ }).not.toThrow();
+ expect(bar.value).toBe(0.5);
+ });
+
+ it("is inert when no event was named", () => {
+ const bar = new ProgressBar(0, 0, { width: 100, height: 10 });
+ event.emit(event.LOADER_PROGRESS, 0.7, null);
+ expect(bar.value).toBe(0);
+ bar.destroy();
+ });
+
+ it("still reports through onChange when driven by an event", () => {
+ const onChange = vi.fn();
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 10,
+ bindEvent: event.LOADER_PROGRESS,
+ onChange,
+ });
+ event.emit(event.LOADER_PROGRESS, 0.25, null);
+ expect(onChange).toHaveBeenCalledWith(0.25, 0.25);
+ bar.destroy();
+ });
+ });
+
+ describe("the optional label", () => {
+ // a label is a `Text`, and `Text` resolves a renderer at construction,
+ // so these need a live application where the geometry tests do not
+ let app;
+ let bar;
+ beforeAll(async () => {
+ boot();
+ app = new Application(800, 600, {
+ parent: "screen",
+ scale: "auto",
+ renderer: video.CANVAS,
+ });
+ await app.init();
+ bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 20,
+ min: 0,
+ max: 50,
+ value: 25,
+ showLabel: true,
+ });
+ });
+
+ afterAll(() => {
+ app?.destroy();
+ });
+
+ it("defaults to a rounded percentage", () => {
+ expect(bar.label._text.join("\n")).toBe("50%");
+ });
+
+ it("follows the value", () => {
+ bar.value = 10;
+ expect(bar.label._text.join("\n")).toBe("20%");
+ });
+
+ it("sizes itself from the bar's height, as a whole number", () => {
+ // 24 * 0.7 is 16.799999999999997, and a fractional size renders
+ // softer than a whole one as well as reading back oddly
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 24,
+ showLabel: true,
+ });
+ expect(bar.label.fontSize).toBe(17);
+ expect(bar.label.font).toBe("17px sans-serif");
+ });
+
+ it("never goes below 8, however short the bar", () => {
+ const tiny = new ProgressBar(0, 0, {
+ width: 100,
+ height: 4,
+ showLabel: true,
+ });
+ expect(tiny.label.fontSize).toBe(8);
+ });
+
+ it("uses an explicit fontSize exactly as given", () => {
+ const bar = new ProgressBar(0, 0, {
+ width: 100,
+ height: 24,
+ showLabel: true,
+ fontSize: 11.5,
+ });
+ expect(bar.label.fontSize).toBe(11.5);
+ });
+
+ it("is absent unless asked for", () => {
+ const plain = new ProgressBar(0, 0, { width: 10, height: 10 });
+ expect(plain.label).toBeUndefined();
+ });
+
+ it("takes a custom format, given the value and the ratio", () => {
+ const hp = new ProgressBar(0, 0, {
+ width: 100,
+ height: 20,
+ min: 0,
+ max: 120,
+ value: 90,
+ showLabel: true,
+ labelFormat: (v, ratio) => {
+ return `${v} of 120 (${Math.round(ratio * 100)}%)`;
+ },
+ });
+ expect(hp.label._text.join("\n")).toBe("90 of 120 (75%)");
+ });
+ });
+
+ // A bar borrows a scratch Color and, when it is rounded, a RoundRect, and
+ // holds both for its lifetime rather than fetching one per draw. Holding
+ // them is only correct if it also gives them back.
+ describe("pooled borrowings", () => {
+ it("gives its scratch colour back on destroy", () => {
+ // two go out, not one: `Renderable` borrows its own `tint` as well,
+ // so what is asserted is the round trip rather than the count
+ const before = colorPool.used();
+ const bar = new ProgressBar(0, 0, { width: 40, height: 8 });
+ expect(colorPool.used()).toBeGreaterThan(before);
+ bar.draw(makeRecorder());
+ bar.destroy();
+ expect(colorPool.used()).toBe(before);
+ });
+
+ it("gives its rounded path back on destroy", () => {
+ const bar = new ProgressBar(0, 0, {
+ width: 40,
+ height: 8,
+ radius: 3,
+ value: 0.5,
+ });
+ const before = roundedRectanglePool.used();
+ bar.draw(makeRecorder());
+ expect(roundedRectanglePool.used()).toBe(before + 1);
+ bar.destroy();
+ expect(roundedRectanglePool.used()).toBe(before);
+ });
+
+ it("borrows no shape at all when it is square", () => {
+ const bar = new ProgressBar(0, 0, { width: 40, height: 8, value: 0.5 });
+ const before = roundedRectanglePool.used();
+ bar.draw(makeRecorder());
+ expect(roundedRectanglePool.used()).toBe(before);
+ bar.destroy();
+ });
+
+ it("borrows one shape however many frames it draws", () => {
+ const bar = new ProgressBar(0, 0, {
+ width: 40,
+ height: 8,
+ radius: 3,
+ value: 0.5,
+ });
+ const before = roundedRectanglePool.used();
+ for (let i = 0; i < 10; i++) {
+ bar.value = i / 10;
+ bar.draw(makeRecorder());
+ }
+ expect(roundedRectanglePool.used()).toBe(before + 1);
+ bar.destroy();
+ expect(roundedRectanglePool.used()).toBe(before);
+ });
+ });
+});
diff --git a/packages/melonjs/tests/ui-interaction.spec.js b/packages/melonjs/tests/ui-interaction.spec.js
index 4443994f7..4759355d9 100644
--- a/packages/melonjs/tests/ui-interaction.spec.js
+++ b/packages/melonjs/tests/ui-interaction.spec.js
@@ -5,7 +5,9 @@ import {
Draggable,
DropTarget,
event,
+ input,
loader,
+ UIBaseElement,
UISpriteElement,
UITextButton,
video,
@@ -179,6 +181,256 @@ describe("UI pointer interaction", () => {
});
});
+ // A panel stacked over another panel's button used to let the covered
+ // button answer the pointer: `pos.z` is container-local (`autoDepth`
+ // numbers each container's children from 1), but the hit-test sorted one
+ // FLAT list of broadphase candidates on raw z, so a button at local z=8
+ // inside a low panel outranked an entire panel drawn on top of it.
+ describe("hit-test ordering across containers", () => {
+ // panel A (100,100)-(300,300) holding a button at (120,120)-(180,160);
+ // panel B (150,150)-(350,350) covers the button's top-left corner, so
+ // (165, 155) is inside BOTH the button and panel B
+ const makeOverlap = (zA, zB) => {
+ const panelA = new UIBaseElement(100, 100, 200, 200);
+ panelA.anchorPoint.set(0, 0);
+ const button = new UIBaseElement(20, 20, 60, 40);
+ button.anchorPoint.set(0, 0);
+ // an explicit local z, exactly as `addChild(child, z)` is meant to
+ // be used — and higher than any z panel B can be given
+ panelA.addChild(button, 8);
+ const panelB = new UIBaseElement(150, 150, 200, 200);
+ panelB.anchorPoint.set(0, 0);
+ app.world.addChild(panelA, zA);
+ app.world.addChild(panelB, zB);
+ syncBroadphase();
+ return { panelA, panelB, button };
+ };
+
+ const teardown = ({ panelA, panelB }) => {
+ app.world.removeChildNow(panelA);
+ app.world.removeChildNow(panelB);
+ syncBroadphase();
+ };
+
+ it("a click over the covering panel does not reach the covered button", () => {
+ const parts = makeOverlap(1, 2);
+ const panelSpy = vi.spyOn(parts.panelB, "onClick").mockReturnValue(false);
+ const buttonSpy = vi.spyOn(parts.button, "onClick");
+ try {
+ dispatchPointer("pointerdown", 165, 155);
+ expect(panelSpy).toHaveBeenCalled();
+ expect(buttonSpy).not.toHaveBeenCalled();
+ } finally {
+ panelSpy.mockRestore();
+ buttonSpy.mockRestore();
+ teardown(parts);
+ }
+ });
+
+ it("the same button still answers where the covering panel is not over it", () => {
+ const parts = makeOverlap(1, 2);
+ const buttonSpy = vi.spyOn(parts.button, "onClick");
+ try {
+ // (130, 130) is inside the button and outside panel B
+ dispatchPointer("pointerdown", 130, 130);
+ expect(buttonSpy).toHaveBeenCalled();
+ } finally {
+ buttonSpy.mockRestore();
+ teardown(parts);
+ }
+ });
+
+ it("moveToTop lifts a panel above the other panel's button", () => {
+ const parts = makeOverlap(1, 1);
+ const buttonSpy = vi.spyOn(parts.button, "onClick");
+ const bSpy = vi.spyOn(parts.panelB, "onClick").mockReturnValue(false);
+ const aSpy = vi.spyOn(parts.panelA, "onClick").mockReturnValue(false);
+ try {
+ app.world.moveToTop(parts.panelB);
+ syncBroadphase();
+ dispatchPointer("pointerdown", 165, 155);
+ expect(bSpy).toHaveBeenCalled();
+ expect(buttonSpy).not.toHaveBeenCalled();
+ } finally {
+ aSpy.mockRestore();
+ bSpy.mockRestore();
+ buttonSpy.mockRestore();
+ teardown(parts);
+ }
+ });
+
+ it("two panels sharing a z resolve by child order, as draw does", () => {
+ // equal z is not a draw-order coin toss: the sort in `Container#draw`
+ // is stable and its reverse walk paints the LOWER index last, so the
+ // first-added panel is the one on top. Panel A is therefore still
+ // the top panel here, and its button is what the pointer should find.
+ const parts = makeOverlap(1, 1);
+ const buttonSpy = vi
+ .spyOn(parts.button, "onClick")
+ .mockReturnValue(false);
+ const bSpy = vi.spyOn(parts.panelB, "onClick").mockReturnValue(false);
+ try {
+ dispatchPointer("pointerdown", 165, 155);
+ expect(buttonSpy).toHaveBeenCalled();
+ expect(bSpy).not.toHaveBeenCalled();
+ } finally {
+ bSpy.mockRestore();
+ buttonSpy.mockRestore();
+ teardown(parts);
+ }
+ });
+
+ it("a child is hit before the container that holds it", () => {
+ const parts = makeOverlap(1, 2);
+ const panelSpy = vi.spyOn(parts.panelA, "onClick").mockReturnValue(false);
+ const buttonSpy = vi
+ .spyOn(parts.button, "onClick")
+ .mockReturnValue(false);
+ try {
+ dispatchPointer("pointerdown", 130, 130);
+ expect(buttonSpy).toHaveBeenCalled();
+ expect(panelSpy).not.toHaveBeenCalled();
+ } finally {
+ panelSpy.mockRestore();
+ buttonSpy.mockRestore();
+ teardown(parts);
+ }
+ });
+
+ it("hovering the covering panel does not light up the covered button", () => {
+ const parts = makeOverlap(1, 2);
+ const overSpy = vi.spyOn(parts.panelB, "onOver").mockReturnValue(false);
+ try {
+ dispatchPointer("pointermove", 165, 155);
+ expect(overSpy).toHaveBeenCalled();
+ expect(parts.button.hover).toBe(false);
+ } finally {
+ overSpy.mockRestore();
+ teardown(parts);
+ }
+ });
+
+ it("a pointermove callback keeps it unlit once the pointer is already inside", () => {
+ const parts = makeOverlap(1, 2);
+ const overSpy = vi.spyOn(parts.panelB, "onOver").mockReturnValue(false);
+ // `onOver` only runs on the frame the pointer crosses in; after
+ // that the dispatcher reaches the plain `pointermove` callbacks
+ input.registerPointerEvent("pointermove", parts.panelB, () => {
+ return false;
+ });
+ try {
+ dispatchPointer("pointermove", 165, 155);
+ dispatchPointer("pointermove", 166, 156);
+ expect(parts.button.hover).toBe(false);
+ } finally {
+ input.releasePointerEvent("pointermove", parts.panelB);
+ overSpy.mockRestore();
+ teardown(parts);
+ }
+ });
+
+ it("a lit button goes dark when the pointer moves onto the panel covering it", () => {
+ // panel B covers the button's bottom-right corner, so the pointer
+ // can go from the exposed part straight onto the panel while never
+ // leaving the button's own bounds. Nothing was then telling the
+ // button it had lost the pointer, and it stayed lit under B.
+ const parts = makeOverlap(1, 2);
+ const overSpy = vi.spyOn(parts.panelB, "onOver").mockReturnValue(false);
+ input.registerPointerEvent("pointermove", parts.panelB, () => {
+ return false;
+ });
+ try {
+ // the button is (120,120)-(180,160) and panel B starts at
+ // (150,150), so (135, 135) is in the button and clear of B
+ dispatchPointer("pointermove", 135, 135);
+ expect(parts.button.hover).toBe(true);
+
+ // (165, 155) is in the button and INSIDE panel B
+ dispatchPointer("pointermove", 165, 155);
+ expect(parts.button.hover).toBe(false);
+ } finally {
+ input.releasePointerEvent("pointermove", parts.panelB);
+ overSpy.mockRestore();
+ teardown(parts);
+ }
+ });
+
+ it("onOut fires once for that, not on every frame the pointer stays there", () => {
+ const parts = makeOverlap(1, 2);
+ const overSpy = vi.spyOn(parts.panelB, "onOver").mockReturnValue(false);
+ const outSpy = vi.spyOn(parts.button, "onOut");
+ input.registerPointerEvent("pointermove", parts.panelB, () => {
+ return false;
+ });
+ try {
+ dispatchPointer("pointermove", 135, 135);
+ dispatchPointer("pointermove", 165, 155);
+ expect(outSpy).toHaveBeenCalledTimes(1);
+ dispatchPointer("pointermove", 166, 156);
+ dispatchPointer("pointermove", 167, 157);
+ expect(outSpy).toHaveBeenCalledTimes(1);
+ } finally {
+ input.releasePointerEvent("pointermove", parts.panelB);
+ outSpy.mockRestore();
+ overSpy.mockRestore();
+ teardown(parts);
+ }
+ });
+
+ it("a consumed click does not change what is hovered", () => {
+ // only a MOVE reassigns the pointer. A press, a release or a wheel
+ // consumed by the panel says nothing about where the pointer is, so
+ // it must not take the hover off whatever is under the panel.
+ const parts = makeOverlap(1, 2);
+ const clickSpy = vi.spyOn(parts.panelB, "onClick").mockReturnValue(false);
+ try {
+ dispatchPointer("pointermove", 135, 135);
+ expect(parts.button.hover).toBe(true);
+ // straight to a press over the covered corner, no move first
+ dispatchPointer("pointerdown", 165, 155);
+ expect(clickSpy).toHaveBeenCalled();
+ expect(parts.button.hover).toBe(true);
+ } finally {
+ clickSpy.mockRestore();
+ teardown(parts);
+ }
+ });
+
+ it("an uncovered sibling keeps its hover when something else consumes", () => {
+ // the sweep must only take the pointer away from what is actually
+ // under the consumer, not from everything left in the walk
+ const parts = makeOverlap(1, 2);
+ const far = new UIBaseElement(500, 400, 60, 40);
+ far.anchorPoint.set(0, 0);
+ app.world.addChild(far, 3);
+ syncBroadphase();
+ try {
+ dispatchPointer("pointermove", 520, 420);
+ expect(far.hover).toBe(true);
+ // a move far away: `far` is out of bounds and leaves on its own
+ dispatchPointer("pointermove", 165, 155);
+ expect(far.hover).toBe(false);
+ } finally {
+ app.world.removeChildNow(far);
+ teardown(parts);
+ }
+ });
+
+ it("without consuming the enter, the covered button still lights up", () => {
+ // the ordering fix alone does not make a panel opaque: it decides
+ // WHO is asked first, and an element that consumes nothing still
+ // lets the walk carry on to whatever is underneath
+ const parts = makeOverlap(1, 2);
+ try {
+ dispatchPointer("pointermove", 165, 155);
+ expect(parts.panelB.hover).toBe(true);
+ expect(parts.button.hover).toBe(true);
+ } finally {
+ teardown(parts);
+ }
+ });
+ });
+
describe("DropTarget", () => {
it("drop() fires when a draggable is released overlapping the target", () => {
const target = new DropTarget(100, 100, 50, 50);