UI: a public ProgressBar, and a hit test that asks the topmost renderable first - #1707
Merged
Merged
Conversation
…able first Two UI changes that share the example and the skill. ProgressBar is a new renderable: a track, a fill sized by a value, an optional border and an optional label, drawn with primitives and no artwork. The engine's own loading screen is rebuilt on it, which moves the LOADER_PROGRESS subscription out of the bar and makes it usable for a health bar or a shield gauge. It also stored its fill as a pixel count rather than a ratio, so a resize mid-load left it at the old scale. The pointer hit test now resolves depth the way Container#draw does. `pos.z` is container-local, but the hit test sorted one flat list of broadphase candidates on raw z, so a button nested in a low panel outranked an entire panel stacked over it and answered clicks through it. A covered region is also told when it loses the pointer, which it previously only learned by the pointer leaving its own bounds. `onOver` can return false to consume, as onClick and onRelease already could. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Contributor
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Canvas alpha composition, loading-screen resizing, rounded-border clamping, and event binding types have unresolved correctness issues.
Review effort: Balanced
Findings: 4
Open (4)
What changed in this PR
Adds a reusable ProgressBar and aligns pointer hit-testing with render order across nested containers.
Changes:
- Adds and exports
ProgressBar, adopting it in the loading screen. - Corrects nested-container pointer ordering and hover occlusion.
- Expands tests, examples, documentation, and skills guidance.
| File | Description |
|---|---|
packages/melonjs/src/renderable/ui/progressbar.ts |
Implements ProgressBar. |
packages/melonjs/src/index.ts |
Exports the new API. |
packages/melonjs/src/loader/loadingscreen.js |
Uses the public progress bar. |
packages/melonjs/src/renderable/container.js |
Adds hierarchy-aware hit ordering. |
packages/melonjs/src/input/pointerevent.ts |
Handles move-event occlusion. |
packages/melonjs/src/renderable/ui/uibaseelement.ts |
Makes onOver consumable. |
packages/melonjs/src/renderable/ui/uispriteelement.ts |
Makes onOver consumable. |
packages/examples/src/examples/ui/ExampleUI.tsx |
Demonstrates bars and overlapping panels. |
packages/melonjs/tests/progressbar.spec.js |
Tests progress-bar behavior. |
packages/melonjs/tests/container.spec.js |
Tests hierarchical ordering. |
packages/melonjs/tests/ui-interaction.spec.js |
Tests overlapping UI interactions. |
packages/melonjs/skills/melonjs-ui-and-text/SKILL.md |
Documents UI changes. |
packages/melonjs/skills/melonjs-input/SKILL.md |
Documents pointer ordering. |
packages/melonjs/CHANGELOG.md |
Records the public changes. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+88
to
+90
| this.progressBar = new ProgressBar(0, height / 2, { | ||
| width, | ||
| height: barHeight / 2, |
Comment on lines
+73
to
+79
| * 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; |
Comment on lines
+317
to
+321
| renderer.setColor(this._css); | ||
| renderer.setGlobalAlpha(this._css.alpha * cascade); | ||
| } else if (color instanceof Color) { | ||
| renderer.setColor(color); | ||
| renderer.setGlobalAlpha(color.alpha * cascade); |
| // 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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Two UI changes that share the example and the skill, so they travel together.
ProgressBarFive places in the repo hand-rolled the same single-value gauge, each
re-solving a track, a fill sized by
value × size, and an inset between them.A sixth declined a bar because none existed. This is that renderable:
extends Renderable, drawn with primitives and no artwork.min/maxdefault to0/1, so a fraction works without stating either;ratioreads back the normalized formtrackColor,fillColorandborderColoreach take aColor, a cssstring or a
Gradient. Anulltrack leaves it hollow,radiusrounds itmutating the
Coloryou passed. That is deliberately not built in, becausethe five existing sites each derive colour differently
bindEventnames an event to take the value from, and the subscriptionlives exactly as long as the bar does
The engine's own loading screen is rebuilt on it. That moves the
LOADER_PROGRESSsubscription out of the bar, which is what kept the old oneprivate: a renderable that subscribes to the loader can only ever show
loading. The old one 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 landed.
A square border is four
fillRects rather thanstrokeRect, for two reasonsin the code: a stroke is centred on the path so half falls outside the bar,
and
strokeRectonly generates corner joins abovelineWidth > 1, so atexactly 1 the corners drop a pixel.
The hit test asks what is drawn on top
pos.zis container-local —autoDepthnumbers each container's ownchildren from 1 — and
Container#drawnever compares across containers,because it recurses. The pointer hit test sorted one flat list of broadphase
candidates on raw z instead, so a button at local
z = 8inside a low paneloutranked 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
drawresolves it: walk both up to theircommon parent and compare the two siblings whose order decides which subtree
paints last. A child comes ahead of the container holding it, and equal
sibling z falls back to child order, because
draw's sort is stable and itsreverse walk paints the lower index last.
Verified against the real paint order over 261 random trees with
moveToTopchurn: the new comparator disagrees 0 times, the old one 245. With
deliberate z ties, 0 versus 277 — which is the case the UI example actually
hits, since both its panels are added at
z = 1.Two further pieces, both needed for a panel to be opaque:
it. 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 onto the panel, which never leaves the button's bounds.
A consumed move 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
onOvercan returnfalseto consume, asonClickandonReleasealreadycould.
onOutstaysvoidon purpose: an element that suppressed its ownleave would stay lit after the pointer had gone
Ordering between siblings of one container is unchanged, which is every case a
game with a single container has.
Tests
tests/container.spec.jspins the comparator directly, including that itstays a consistent total order.
tests/ui-interaction.spec.jsdrives realpointer events at two overlapping panels.
tests/progressbar.spec.jsis new:geometry per direction through a recording renderer, alpha composition, the
four-rect border, concentric radii, pool borrowings and the label.
Every part is mutation-checked — reverting the comparator fails 7 tests,
removing just the tie-break fails 1, removing just the ancestor branch fails
1, dropping the occlusion sweep fails 2, and dropping
onOver's return fails1.
Full suite green, eslint 0 errors, biome clean.
🤖 Generated with Claude Code
https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t