Skip to content

UI: a public ProgressBar, and a hit test that asks the topmost renderable first - #1707

Merged
obiot merged 1 commit into
masterfrom
feat/progressbar-and-hit-test-ordering
Oct 3, 2026
Merged

obiot merged 1 commit into
masterfrom
feat/progressbar-and-hit-test-ordering

Conversation

@obiot

@obiot obiot commented Oct 3, 2026

Copy link
Copy Markdown
Member

Two UI changes that share the example and the skill, so they travel together.

ProgressBar

Five 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/max default to 0/1, so a fraction works without stating either;
    ratio reads back the normalized form
  • four fill directions, each growing from its own edge
  • trackColor, fillColor and borderColor each take a Color, a css
    string or a Gradient. A null track leaves it hollow, radius rounds it
  • the colour is re-read every frame, so a value-driven bar is a matter of
    mutating the Color you passed. That is deliberately not built in, because
    the five existing sites each derive colour differently
  • bindEvent names an event to take the value from, and the subscription
    lives exactly as long as the bar does

The engine's own loading screen is rebuilt on it. That moves the
LOADER_PROGRESS subscription out of the bar, which is what kept the old one
private: 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 than strokeRect, for two reasons
in the code: a stroke is centred on the path so half falls outside the bar,
and strokeRect only generates corner joins above lineWidth > 1, so at
exactly 1 the corners drop a pixel.

The hit test asks what is drawn on top

pos.z is container-local — autoDepth numbers each container's own
children from 1 — and Container#draw never 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 = 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: walk both up to their
common 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 its
reverse walk paints the lower index last.

Verified against the real paint order over 261 random trees with moveToTop
churn: 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:

  • a region covered by something that consumed the pointer is now told it lost
    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
  • onOver can return false to consume, as onClick and onRelease already
    could. onOut stays void on purpose: an element that suppressed its own
    leave 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.js pins the comparator directly, including that it
stays a consistent total order. tests/ui-interaction.spec.js drives real
pointer events at two overlapping panels. tests/progressbar.spec.js is 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 fails
1.

Full suite green, eslint 0 errors, biome clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Medium severity

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;
@obiot
obiot merged commit 0864abf into master Oct 3, 2026
7 checks passed
@obiot
obiot deleted the feat/progressbar-and-hit-test-ordering branch October 3, 2026 07:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants