Skip to content

Particles: the constructor centres the spawn area, say so - #1835

Merged
obiot merged 73 commits into
masterfrom
claude/modest-johnson-ao5snz
Nov 2, 2026
Merged

obiot merged 73 commits into
masterfrom
claude/modest-johnson-ao5snz

Conversation

@obiot

@obiot obiot commented Nov 2, 2026

Copy link
Copy Markdown
Member

Description

This replaces #1832: the same commit, unchanged, plus a two-line lint fix. CI's lint step had failed on #1832, so its tests never ran, and the fix couldn't be pushed to that PR's branch from here.

The particles skill said a ParticleEmitter's spawn area is measured from the position given to the constructor, and told games to offset by half:

new ParticleEmitter(x - area / 2, y - area / 2, { width: area, height: area });

The constructor actually centres the area on (x, y) (centerOn(x, y)), so following that advice puts every effect up and to the left by half its size. Only pos set after construction is the area's top-left corner.

  • Skill (melonjs-particles-and-trails): pass the centre to the constructor; offset by half only when moving pos later (re-aiming one emitter per burst). Symptom rows for both mistakes.
  • TSDoc: the constructor's x / y and the width setting now say where the area goes.
  • Tests (tests/particle-spawn-area.spec.js): the constructor centres the area, and moving pos places its top-left corner.
  • Lint fix: braces around the test's two one-line .map((p) => p.pos.x) callbacks, which ESLint's arrow-body-style rule rejected.

No engine behaviour changes, so no changelog entry.

Checked locally on top of the current master (#1831 and #1833 merged): the commit applies cleanly, ESLint and Biome are clean, and the spawn-area, built-in-square and camera specs pass (229 tests).

Type of change

  • Bug fix
  • New feature
  • Documentation update
  • Performance improvement
  • Refactoring (no functional changes)

Checklist

  • I have read the Contributing Guide
  • My code follows the existing code style (pnpm lint passes)
  • I have tested my changes locally (pnpm test passes)
  • I have added tests that cover my changes (if applicable)
  • The build succeeds (pnpm build): left to CI, as only comments changed in src/

Related issues

Replaces #1832.


Generated by Claude Code

claude added 30 commits October 31, 2026 03:09
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
- UICheckbox: a checkbox with its label, drawn or skinned, a radio button
  with a `group`; `onChange` only on user input
- UISlider: horizontal or vertical, drag, track click, wheel, step, caption
  and value text, drawn or skinned; `onChange` and `onChangeEnd`
- UITextButton: `onClick` setting on a completed click, atlas `skin` with
  lowered pressed art, `enabled`, `pressedColor`, CSS fonts
- UIPanel: nine-slice `skin`, `title`, `draggable`, `layout`
  (column/row/grid, padding, gap, align, autoSize) and `scroll` (wheel,
  drag with momentum, scrollbar, scrollTo/scrollToChild)
- UIBaseElement: cancelPress/onCancel, so a drag on a button in a
  scrolling panel scrolls instead of clicking
- Input: children cropped by a clipping container, or scrolled out of a
  panel, no longer get pointer events there
- The UI example uses the widgets, with volume sliders wired to audio and
  a scrolling level list; skill sections and changelog entries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
- UIBaseElement: a draggable element pressed with no hover first, as a
  touch tap is, no longer throws, and drags by touch
- UIPanel: a draggable panel is moved by its title or frame, not through
  a widget on it, so dragging it by a button no longer clicks the button
- UIPanel: no fling while the pointer is still down; autoSize counts the
  title and frame from a zero height; a scrolling panel inside another
  scrolls alone; emptying or destroying a panel does not lay it out per
  child or call onScroll
- UISlider: the wheel to the right raises it; a drag also ends on a
  mouse or touch release, or a release over the slider
- UICheckbox: as wide as its longer label, whichever it is
- UITextButton: a font given as fontData, an image, or with only its
  image loaded is a bitmap font as before; pressedColor defaults to
  hoverOnColor; bitmapText can be assigned
- 10 regression tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
API, from a consistency review against the existing UI widgets:
- UITextButton: width/height, fontSize (pixels, bitmap fonts too),
  labelFillStyle and borderColor, the names the other widgets use; the old
  names still work. Settings are no longer written back. Colours take any
  Paint. pressedColor left undefined follows hoverOnColor. onClickCallback
  (was clickCallback). bitmapText keeps its BitmapText type, deprecated
- UICheckbox: boxSize, boxHoverColor; labelOn/labelOff settable and
  refitting; group changes apply at once; skin hoverOn/hoverOff and
  disabledOn/disabledOff
- UISlider: valueFormat(value, ratio) (was format), ratio, settable
  min/max/step, thumbHoverColor, thumbBorderColor, thumbBorderWidth
- UIBaseElement: enabled shared by every UI element; keepsDrag public
- ProgressBar: onChange also gets the bar
- Paint exported; @category UI on the UI settings types

Fixes:
- align "stretch" only resizes buttons and panels, not a BitmapText
  (whose resize is a scale) or a checkbox or slider
- a drag ends only on its own pointer's release, in the event family the
  engine listens to, not on another finger or button
- a non-blocking scrolling panel inside another scrolls alone
- a draggable panel is not moved through a widget at any depth, and the
  check no longer mixes coordinate spaces in the world
- a draggable element in the world no longer jumps by the camera position
- a stale scroll press is dropped; skin insets of 0 match the art
- slider steps in exponent notation, and a range that is not a whole
  number of steps, snap correctly

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
shadowColor, shadowOffsetX, shadowOffsetY and shadowBlur. The soft edge is
a few stacked translucent rounded rectangles, so it looks the same on
Canvas, WebGL and WebGPU; it follows the corner radius, goes under a skin
too, and is drawn outside the bounds so it never takes the pointer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
…game (#1805)

A touch fires pointerover and pointerenter before its pointerdown, and
pointerout and pointerleave after its pointerup. The plugin only kept the
down, move, up and cancel from the engine, so the widget under a button
got onOver as the button was pressed (the Alex 4 menu arrow jumped to the
line under B). The plugin now also captures the four hover events and
stops them for a touch that lands on, or lifted from, a control. A mouse
hovering over the controls is left alone, as its moves are.

Fixes #1805

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
UIFocus (off until UIFocus.enable()): one widget has the focus; the arrows,
the D-pad or the left stick move it to the nearest widget that way, edge to
edge; Enter, Space or A press it as a click does; left/right step a slider;
Escape or B cancel. The pad is read directly (input.readGamepads, real or
virtual), so the game's pad bindings are untouched. The ring shows after
keyboard or pad input and hides when the mouse moves; the first key after
the mouse only shows it. A UIPanel with trapFocus keeps the focus in and
restores it after, with onBack on cancel. Focused widgets in a scrolling
panel scroll into view, with room for the ring. Any UIBaseElement can take
part: focusable, focusNeighbors, onNavigate, onFocus/onBlur,
focusPress/focusRelease. Buttons, checkboxes and sliders do.

hoverScale and pressScale on UIBaseElement, and as settings on every
widget: an eased bounce applied to the drawing only, so hit areas and
layouts stay put.

The UI example turns on both, with a hint line for the controls. Skill
sections and changelog entries; 13 new tests (87 in ui-widgets).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
Arrows or the D-pad move to the nearest widget, Enter, Space or A press
it, sliders step, a ring shows the focus, and a focused widget in a
scrolling panel scrolls into view. Removed: trapFocus/onBack, cancel,
focusNeighbors, onFocus/onBlur, hover moving the focus, the first-key
rule, the stick with repeat, configurable keys and ring sizes. Most games
use the mouse or touch; this covers a menu driven by keys or a pad.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
- a press held by Enter or A is called off when the focus moves, the
  widget is removed or navigation is disabled, instead of staying down
- a focused widget only bounces while the focus ring shows
- the bounce eases over about 0.2 s; it snapped in a frame
- Enter goes to the focused button: another bound to Enter waits while the
  ring shows elsewhere
- a pad's A held as navigation is enabled is not a press
- a widget made focusable after it was added takes part
- navigation skips widgets inside a hidden or transparent container
- an arrow from a focused widget disabled since moves on from where it is
- docs: the bounce is on UIBaseElement; Enter and bindKey; accept calls
  onClick() with no event

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
UIFocus is gone. A widget takes the keyboard and gamepad focus with
focus(); from then on the arrows or the D-pad move it, Enter, Space or A
press it, sliders step. Nothing listens until a widget has the focus, and
the listeners go when none does (blur(), or the focused widget leaving the
scene), so the keys and the pad are the game's until then. On
UIBaseElement: focus(), blur(), focused, focusable, onNavigate, the static
focusOwner and focusRingColor. The example focuses its first widget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
Three bugs the new tests caught:
- a slider's default value text kept the old decimals after `step` changed
- Enter on a focused button the pointer already held clicked it on release
- a virtual gamepad sharing a slot with a real one was not read

The stub-subclass tests now use the real button. New tests cover the
shadow under a faded parent, scrollbar geometry, vertical and skinned
sliders, cancelled and two-finger drags, skin regions and bitmap font
sizes, radio drawing and groups, sideways and line-mode wheel scrolling,
wheel pass-through at the end of a panel, fling limits, grid stretch,
row autoSize, clearChildren, the focus ring, and focus teardown. The
clipping test now clips through a real container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
Dates the 1.0.2 changelog section: a touch on the controls no longer
reaches the game as a hover (#1805).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
- a "which widget for what" table
- one complete options menu: skinned panel, column layout, checkbox,
  slider, button, drop shadow, bounce, keyboard / gamepad focus
- code for drop shadows, a scrolling level list, radio groups, skinned
  checkboxes and sliders, stepped and vertical sliders, and a custom
  focusable widget
- symptom rows for focus, bindKey vs focus, and shadow corners
- drops a reference to the UI example's old hand-made panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
Class docs in sections (look, label, state and onChange, radio groups,
keyboard and gamepad; range, input, text, look), every setting with its
unit and default, and examples for the common uses: a settings toggle, a
label that follows the state, a radio group, skins, restoring saved
settings, a volume slider, stepped and vertical sliders, percent text, a
slider in a scrolling panel. Three docs that contradicted the code now
match it (trackThickness with a skin, the checkbox fontSize minimum,
onChangeEnd on arrow steps).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
UIPanel's class doc is rewritten in sections (background, title,
dragging, blocking, layout, scrolling, drop shadow, idle float, things to
know), with examples for a drawn dialog, a skinned options menu, a
scrolling level grid, a draggable window, a drop shadow and a HUD frame.
Every setting has its unit and default; clearChildren is now documented.
ProgressBar's doc is tidied, with examples for a loading bar, a health
bar, a colour that follows the value, a vertical bar, a drop shadow, and
reacting when the bar empties.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
UIBaseElement's class doc covers the pointer callbacks, enabled,
dragging, the bounce and keyboard / gamepad focus, with examples for a
widget of your own, a draggable token, a keyboard and pad menu, and an
opaque dialog. UITextButton documents every setting with its unit and
default (the old names as aliases), with examples for drawn, skinned,
bindKey, disabled and shared-style buttons. UISpriteElement gets a real
class doc and examples. Several docs that contradicted the code now match
it (onMove, onRelease, enabled on a custom element, bindKey while
disabled).

Fix: a held bindKey press that is called off (the window losing focus,
the button removed or disabled) now calls onCancel(), as a pointer press
does; it called neither onRelease nor onCancel, so work started on the
press never stopped. Test added; the skill's bindKey rules follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
Its onClick() and onRelease() returned false, stopping every press and
release at the sprite, while every other UI element lets the event carry
on to what is under it unless a handler returns false. They now return
true. A subclass that relied on the old default returns false itself; the
examples already do. onActivateEvent / onDeactivateEvent now call super.

Test with real pointer events; docs, skill and changelog updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
UIBaseElement's onClick() and onRelease() now return false, as
UISpriteElement's always did (the previous commit's flip of
UISpriteElement is undone). A click on a button, a checkbox, a slider or
a widget of your own no longer also reaches the game behind it; a handler
returns true to let the press through. Hover is unchanged, and a
non-blocking UIPanel still lets presses on its background through.

Tests with real pointer events for buttons, checkboxes, sprite elements,
a handler letting the press through, and a non-blocking panel. Docs, both
skills and the changelog follow; two example buttons that returned true
now return false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
It was a Sprite carrying its own copy of the pointer handling, which had
drifted from UIBaseElement's. It now extends UIBaseElement and holds its
image as `sprite`, so it shares the one implementation and gains what the
other widgets have: enabled (drawn at half opacity when off),
cancelPress / onCancel, hoverScale / pressScale, dragging and keyboard /
gamepad focus.

Kept for existing code: (x, y) is still where the image's anchor point
goes, and setRegion (which also resizes the element), addAnimation,
setCurrentAnimation and isCurrentAnimation stay on the element. Other
Sprite members move to `sprite`; UISpriteElementSettings is exported.

The matter platformer's touch controls follow: the button passes its
anchorPoint in the settings, and the joypad's knob is a child instead of
being drawn by hand. Tests cover placement, clicks, disabled drawing,
setRegion, the bounce, cancel and focus; docs, skill and changelog
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
17 tests of what a game relies on: Sprite settings and size, placement
(centred by default, or by anchorPoint in the settings), presses,
releases, hover, a press ended by leaving, isClickable, removal, the
press stopping at it or let through, a subclass overriding the callbacks
as the demos' buttons do, and the setRegion / animation / opacity
shortcuts. They only use what the Sprite-based element and the
UIBaseElement-based one both offer, and pass on both, and on master's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
…es the canvas

A pointerup cleared the pointer a region was tracking, so the region
forgot it was under the pointer: leaving straight after a click never
called onOut (a button stayed lit until the pointer came back over it),
and the next move over it called onOver a second time. The release now
keeps the pointer; the press still ends as before.

The browser's pointerleave, sent when the pointer leaves the canvas or a
finger lifts, now releases every region holding that pointer, wherever
the exit is reported. It used to reach only a region at that point,
which for a mouse leaving the canvas is the edge, far from most widgets.
Touch is unchanged: a tap hovers while the finger is down, not after.

Tests for leaving after a click, moving on over it, repeated clicks, a
tap with new pointer ids, and leaving the canvas; the UISpriteElement
tests check the hover strictly again, and pass on the old and new
element.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
- UICheckbox#toggle() on a checked radio button leaves it checked, as a
  click does, instead of leaving its group with nothing checked
- UIPanel#scrollTo() (and scrollToChild, scrollX / scrollY) stops a
  fling still coasting; the panel's own fling, drag, wheel and relayout
  scroll through a private method that leaves it alone
- UISlider lays its texts out in one place, again whenever they change:
  a vertical slider widens for a longer value text (valueFormat, min, max
  or step set later), a horizontal one moves the value below the track
  when it would run into the caption, and the new labelText sets the
  caption later

Tests for each, failing before the fix, and one for the wheel over a
slider in a scrolling panel (it moves the slider, not the panel), which
had none. Docs, skill and changelog updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
claude added 25 commits November 1, 2026 10:52
- transition-second-change: the spec steps the engine's own game loop
  itself, one frame at a time, instead of waiting on browser animation
  frames, which a busy browser can hold back for seconds. The tests still
  all fail on the code before #1787.
- drawmesh_bench: runs only when asked for, with BENCH=1, and then with
  the time it needs. It measures rather than tests, and it ties up the
  shared GPU process for minutes, timing out the specs beside it.
- text-ink-clipping: allows the one row a face's hinting can paint short
  of the ink box the browser reports, and checks the unstroked glyph
  leaves the canvas edges blank, which is what a cut leaves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
"comes back from an idle suspension the browser never acknowledged" gave
the resume and the clip a fixed 20 ms, which a full parallel run can
outlast. It now waits for them: an engine wedged as in the bug never
starts the clip at all, so it still fails there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
… cut outline

An outlined Text was stroked with the canvas's pointed ("miter") corners,
which can reach up to miterLimit (10) half line widths past a glyph. The
bake left room for half a line width, so the tips of sharp glyphs such as
"A", "V" or "M" were cut flat at the top, and it left nothing to the sides,
so the outline on the left of the first glyph and past the end of a line
was cut too.

- Outlines have round corners by default, the same width all around.
- `lineJoin` ("round", "bevel", "miter") and `miterLimit` settings choose
  others; a limit the canvas would refuse falls back to its default, 10.
- The bake leaves room for the outline on all four sides, for whichever
  corners are set (`inkPadLeft` / `inkPadRight` beside the existing top and
  bottom), and draws back by the same amount: the glyphs land on the same
  screen pixels and the bounds are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
…d reuse

- the Text constructor gets an example of every corner option, and
  miterLimit one of a live change; both properties say a change shows from
  the next setText()
- lineJoin is typed as its three values rather than any string
- the skill lists the options in a table, with an example of each
- tests: corners changed on a live label, the room each style takes, an
  invisible outline taking none, and a recycled label not keeping the
  corners of the one before

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
An outline is centred on the glyph's edge, so half of it lies inside the
glyph. Text painted it over the fill, as fillText then strokeText does, so
its inner half covered the letters: a thick outline, or a thin face, left
them solid outline colour.

- a `paintOrder` setting, as the CSS and SVG property names it: "stroke"
  (the default) paints the outline first, behind the glyphs, so they stay
  whole and it shows only around them; "fill" keeps it over them
- the outline is painted under the glyphs already baked, so a gradient
  fill's re-colouring pass never touches it
- the outline reaches as far out in either order: the label's size,
  bounds and texture are unchanged
- docs with an example of both orders, the text skill, CHANGELOG, and
  tests: the colour at the middle of a stem under a thick outline, with a
  flat and a gradient fill, and both orders against a plain canvas

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
…er by

Painting the outline under the glyphs blends what painting it first does,
but the two round differently and browsers rasterize the edges a little
differently: on CI one pixel came out more than 3 levels off. The wrong
order is off on about 40% of the inked pixels, by far more, so the check
now allows up to 16 levels on at most 1% of them, which still tells the
two orders apart by a factor of 40, and still fails on the old code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
fetchData built every request with credentials "omit" unless
withCredentials was set, so even requests to the page's own origin went
out with no cookies or HTTP auth. A game behind a login or a session
cookie loaded its script, which the browser sends the cookie with, then
got a 403 for every image, JSON, map, binary file and audio clip.

Requests now default to "same-origin", as every browser request does;
withCredentials: true still sends credentials to other origins too
("include"). Requests to other origins are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
…ocked

fetchData made "no-cors" requests unless crossOrigin was "anonymous". A
"no-cors" request to another origin comes back opaque (status 0, an empty
body), which fetchData took for a success, so the asset broke later with an
unrelated error ("Unexpected end of input", "The source image could not be
decoded"), even from a server that allows the request.

- every fetch is made in CORS mode: an asset on another origin loads when
  its server sends Access-Control-Allow-Origin, without crossOrigin
- a blocked or failed request rejects at once, with an error naming the
  asset and the headers its server needs, the browser's error as cause
- a status 0 response is no longer a success (the file: and data:
  transports set ok themselves)
- crossOrigin "use-credentials" sends credentials, as withCredentials does;
  crossOrigin otherwise sets the attribute of the video and script elements
  and glTF images the loader creates, as before
- docs, CHANGELOG, and 14 tests, 8 failing on the old code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
A Vitest global setup (tests/helpers/cors-servers.js) starts three servers
on other origins, on free ports handed to the tests through provide /
inject: one sends Access-Control-Allow-Origin: *, one no CORS headers, one
allows credentials from the requesting origin. Every scenario of the
loader's options against the game's own origin and each of them is loaded
through the loader, as a JSON file and as an image, checking the asset
loads with its real content or fails at the request with the error naming
it. The 24 scenarios this change alters fail on the old code; the 13 it
keeps pass on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
It copied the request's Origin header into Access-Control-Allow-Origin
with Access-Control-Allow-Credentials, which would let any site read
credentials (CodeQL: CORS misconfiguration for credentials transfer). It
now allows only the origin Vitest serves the test page from, read from
Vitest's own server when a request comes in, so the header value is never
taken from the request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
The skills still said crossOrigin: "anonymous" is needed for remote assets
and that assets behind a login need withCredentials, both untrue since
this change and #1801.

- melonjs-loading-assets: what loads with no option (own origin with its
  cookies, other origins when their server allows CORS), a table of every
  case with and without withCredentials, what crossOrigin still sets, and
  how it behaved before 20.13; the symptom table names the new error
- melonjs-getting-started, melonjs-audio: no crossOrigin line for remote
  assets; own-origin assets and audio need no withCredentials
- melonjs-tilemaps and the Tiled transparent-color warning: an image the
  loader fetched can always be read back; only one made without CORS, or
  a compressed texture, cannot
- examples: the platformer, matter platformer and pool examples no longer
  set crossOrigin, which did nothing for their own assets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
- each entry says what changed in a sentence or two
- the UI entries sit together in each section, the pointer and hover
  fixes with them
- fixes to what this release adds (UICheckbox radio groups, UISlider
  texts, UIPanel scrolling) are folded into their Added entries, and the
  two Text outline changes are one entry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
"a fade stays until its tween ends, even once its alpha reads as the
target" stepped the tween 20 times by 15ms to land on its 300ms end. The
tween's clock starts at the game time, and from some start times (about 1
in 500) those steps add up to a hair under 300, leaving the tween
unfinished and onComplete uncalled: it failed on CI on a commit touching
only the changelog. Reproduced with the clock pinned to such a start time.
It now steps 305ms, past the end on the last step only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
Changed now holds only what can break a game written for 20.12: presses
stopping at UI elements, and UISpriteElement's Sprite members moving to
element.sprite. The loader's CORS requests and the Text outline defaults
move to Fixed, merged into their fix entries, and UITextButton#label to
Added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
…#1807)

An emitter without an image painted a white square canvas of its own,
rebuilt on every reset() and never freed from the renderer: on WebGL and
WebGPU each one stayed uploaded and in renderer.cache, and on Canvas each
tinted copy of it stayed in the tint cache. A reset() per tweak, or an
emitter per explosion, leaked one texture each time.

Every such emitter now draws the renderer's shared white pixel
(Renderer.getWhitePixel()), stretched to textureSize: one texture,
uploaded once per renderer, so nothing is left to leak, and a hundred
emitters draw one texture. Particles are sized by the emitter (a sheet
cell, the image, or textureSize), and the built-in square's frames draw
the whole pixel at the cell's size.

- tests: the built-in square draws exactly the pixels of a real white
  square of the same size (sizes 1 to 32, tinted, off whole pixels,
  rotated, with and without filtering, on Canvas and WebGL); no texture,
  upload or tinted copy added over 40 resets or 40 short-lived emitters;
  one texture for 25 emitters; destroying one leaves the others drawing;
  an emitter's own image untouched; pooled particles, textureSize changes
  and frames
- docs: image and textureSize, the particles skill, and the changelog
- the Particle Editor no longer hands the emitter a square of its own

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
… pixel

Five emitters (a fire with tintOverLife, smoke, sparks, a screen-blended
glow and an additive sheet of frames) stream side by side through 30
frames, randomness seeded, and must draw what the same emitters do given
white squares of their own: exactly on WebGL, and on Canvas within one
level on under 1% of the drawn pixels, the rounding Canvas has between a
stretched 1x1 image and a same-size one where faded particles overlap
(measured: 3 pixels, by 1). A guard renders the scene twice to show it is
deterministic, and the particle pool is filled first, since a new
particle draws more random numbers than a recycled one. A tint cache that
ignores the colour fails it by 255.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
…t spawn

From review: putting the shared white pixel into settings.image broke the
pattern games use to tweak an emitter, reset({ ...emitter.settings, ... }).
The pixel came back in as an image of the game's own, so particles were
drawn 1x1, and with sprite-sheet cells the reset threw, the image being 1
pixel wide. Setting settings.image directly was also no longer honoured,
particles being sized at reset() rather than at spawn.

settings.image now stays as the game gave it, undefined for the built-in
square, and each particle reads the emitter's live settings when it
spawns, as settings.image always was read: the image or the shared white
pixel, and its size, a whole number of pixels as the square canvas was.
The emitter keeps no state for it, and the docs no longer need to warn
against drawing on settings.image.

Tests: spreading the settings back into reset() keeps the square in the
new tint, also with cells; an image set on settings directly is drawn from
the next particle and taken off again; a fractional textureSize gives
whole pixels. All fail on the previous commit. The frames test checks each
cell index, the recycling test resets the same instance as the pool does,
and resets no longer sidestep the pattern with image: undefined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
The particles skill said the spawn area is measured from the position given
to the constructor, and told games to offset by half. The constructor
centres the area on (x, y), so that advice moved every effect up and left by
half its size. Only `pos` set afterwards is the area's top left corner.

- skill: pass the centre to the constructor; offset by half only when moving
  `pos` later; symptom rows for both mistakes
- TSDoc for the constructor and the `width` setting
- tests pinning both behaviours down

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA2BZwXkRJLpNxCXBNt
ESLint's arrow-body-style rule failed CI's lint step on the two
one-line `.map((p) => p.pos.x)` callbacks, so the tests never ran
(#1832).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
Copilot AI balanced review requested due to automatic review settings November 2, 2026 09:02

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 was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@obiot
obiot merged commit 84a0921 into master Nov 2, 2026
6 checks passed
@obiot
obiot deleted the claude/modest-johnson-ao5snz branch November 2, 2026 09:09
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.

4 participants