Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions packages/examples/src/examples/ui/ExampleUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@
import {
Application as App,
type Application,
Color,
ColorLayer,
Container,
input,
loader,
ProgressBar,
Stage,
state,
Text,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}

Expand Down
5 changes: 5 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
52 changes: 48 additions & 4 deletions packages/melonjs/skills/melonjs-input/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
---

Expand Down Expand Up @@ -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"`,
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading