Skip to content

Examples: the platformer's touch controls come from the gamepad plugin - #1800

Merged
obiot merged 17 commits into
masterfrom
claude/modest-johnson-ao5snz
Oct 30, 2026
Merged

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

Conversation

@obiot

@obiot obiot commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Description

The platformer example now gets its touch controls from @melonjs/gamepad-plugin (#1799) and drops its own entities/controls.ts (240 lines).

  • createGame.ts registers the plugin with a stick and an A button
  • the player's existing gamepad bindings drive it unchanged: the stick walks (D-pad buttons and LX), A jumps (FACE_1)
  • the controls show on touch screens, and on a desktop while the debug panel is open, as the old joypad did (play.ts sets enabled)
  • the stick now goes up and down as well as left and right, works with a second finger on A, and touches on the controls no longer reach the game

Wiring:

  • packages/examples/package.json: the plugin as a workspace dependency, like the other plugins
  • packages/examples/vite.config.ts: the plugin kept out of Vite's pre-bundling, like the debug and tiled-inflate plugins, so a rebuild is picked up
  • .github/workflows/docs.yml: the plugin built before the examples site

Plugin changes found while trying it on a phone (packages/gamepad-plugin):

  • No selection on a long press: a finger held on the stick or a button started a text selection over the canvas (and the image or context menu), which cancelling pointer events does not stop. While the controls listen, the canvas gets user-select: none, -webkit-touch-callout: none and a transparent tap highlight (put back on destroy()), a touchstart that lands on a control is cancelled, and the context menu is blocked while the controls show
  • The stick answers at half way: it read against its whole base while the thumb drawn under the finger travels about half that, so the D-pad only pressed with the finger most of the way to the rim. It now reads against the thumb's travel (reach), the thumb is drawn under the finger, and a new stick option dpadThreshold (0.5 by default: half the thumb's travel) sets when the D-pad presses
  • A light tick on a button press: new option haptics (10 ms by default, 0 for none) vibrates the phone on each button press, where the browser supports it. It is skipped while the game plays a rumble, and before the page's first completed tap, when browsers refuse it

Tests cover each (45 plugin tests).

Checked on the examples dev server in headless Chromium emulating a phone, with real touch events: the controls show over the level and not in the minimap; the thumb a little past half way walks the player; holding the stick while tapping A jumps him over the crate and the water; letting go off the stick, or a cancelled touch, stops him; the sound button still gets its tap; each A press asks for one 10 ms vibration. No console errors. Checked on a phone by hand: long press, rotation, stick + repeated A taps, and the button vibration.

The diff against master is these files. The commit list also shows #1799's commits: the branch was reused, and they were recorded with an ours merge that adds no content (that branch tip and the squash commit have identical trees).

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): for the plugin changes; the example change is checked by hand as above
  • The build succeeds (pnpm build)

Related issues

Part of #1719. Still to do: the isometric dungeon and alex4 examples, and platformer-matter, which has its own copy of controls.ts.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru

claude added 11 commits October 30, 2026 00:27
`input.addVirtualGamepad(pad, index)` puts a pad the game drives itself,
such as on-screen touch controls, in a gamepad slot. The poll reads it with
the pad the browser reports in that slot, so everything bound with
`bindGamepad` answers to either, and `activeGamepad` / vibration see the
virtual pad when the slot holds no real one. No GAMEPAD_CONNECTED is sent
for it, so a game can still use that event to hide its touch controls when
a real pad shows up. This is what the gamepad plugin will build on.

Two fixes in the same loop, both reproduced on master first:
- a pad gone while a button or the stick is held now lets go of its keys
  (a slot with no pad was skipped, leaving them pressed); taking out a
  virtual pad relies on this
- a stick bound on one side lets go of its key when it comes to rest just
  past the centre on the unbound side (the poll returned before the
  release)

A key is released with the same source it was pressed with, now kept on
the binding, as a slot can change between its real and virtual pad.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
A new package, @melonjs/gamepad-plugin, built from the plugin template
and following the in-repo plugins. It draws sticks and buttons and puts
a virtual pad in a gamepad slot (input.addVirtualGamepad), so a game's
bindGamepad bindings answer to the touch controls as to a real pad.

- one or two analog sticks with a deadzone; the left one presses the
  D-pad in 8 directions. Fixed or floating (centered where the thumb
  lands). Keys can be pressed instead, for keyboard-only games
- any number of buttons, FACE_1, FACE_2... by default or a key; a game
  can relabel, dim or hide them at run time
- each finger followed by its own pointer id from DOM pointer events,
  caught in the capture phase on the canvas's parent: a touch starting
  on the controls never reaches the engine or the game, and a stick
  keeps its finger when the thumb drifts off its base
- drawn after the stage, over every camera and every renderable,
  whatever their z, and so never inside a minimap; nothing to load,
  only the renderer's shapes; kept across stage changes
- `enabled` (device.touch by default) and `visible`; hidden while a
  real pad is in use (hideOnGamepad), back when it is unplugged or the
  screen is touched
- input.vibrateGamepad vibrates the phone through navigator.vibrate
- the minimum engine version comes from peerDependencies (>= 20.12.0,
  the release with addVirtualGamepad)

33 browser tests on the Canvas renderer, including that touches are
kept from the game and the controls are drawn over a full-screen
overlay; checked by hand on WebGL. Also: README, CHANGELOG, the API
docs entry, the melonjs-input skill (with symptom entries), CI test
and publish wiring, and the root README plugin list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
`pnpm -F @melonjs/gamepad-plugin visual` serves visual/, a page with the
controls over a small scene, to try on a desktop or a phone (Vite serves
it on the network too). Not part of the published package.

- a dot the left stick moves (analog through LX/LY, digital through the
  D-pad), bound as a game binds a real pad
- a readout of the plugin's state and of what the engine receives: the
  axes on GAMEPAD_UPDATE, the actions held, the active slot, the real
  pads, and how many taps reached the game (a tap off the controls
  leaves a ring)
- toggles for enabled, two sticks, floating, hide on pad, dim / hide /
  relabel a button, a shade at a very high z, a minimap camera, phone
  vibration, and the renderer (?renderer=canvas|webgl|webgpu)

Checked in headless Chromium with real touch events on Canvas and WebGL:
two fingers on the stick and A at once reach the engine, the game gets
none of those touches but gets a tap in the open, the controls stay over
the shade, and the minimap draws the world without them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
Plugin
- touches are placed against this application's canvas from clientX/Y,
  so they land right on a scrolled page (globalToLocal takes page
  coordinates) and on a page with several applications
- hidden for a real pad again when the player goes back to it after
  touching the screen: the pads are watched each frame while one is
  connected, not only on GAMEPAD_CONNECTED
- the version, an initialized app and a free slot are checked before
  anything is hooked; a second set of controls on a slot throws even
  while switched off
- `ready` checks isInitialized and the viewport (a destroyed app keeps
  both), instead of a guard that was always true
- alpha and deadzone clamped with math.clamp: Canvas ignored an alpha
  above 1 when opacity was over 0.5
- a label keeps one Text, resized with setFont on layout and relabel
- a new vibration effect stops the one still running
- the control classes are exported as types; trimmed comments, a split
  CHANGELOG, README rows; the test page lets #hash win over ?renderer=

Engine
- one virtual pad cannot go in two slots; @throws lists the RangeError
- the connect listeners reuse hasGamepadApi(); reworded comments and
  CHANGELOG entries (a one-sided stick also stuck when it came back to
  exactly 0)
- tests: a negative-side stick back to 0, a keyboard key and a pad on
  one action, a remapped pad unplugged mid-press next to a virtual pad

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
…he README

As the debug plugin's README: the banner and badges under the title, and
a screenshot of the controls (a stick held and leaning, A pressed), taken
from the visual test page and shipped in the package.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
addVirtualGamepad, removeVirtualGamepad and VirtualGamepad stay in
input/gamepad.ts but are @ignore @internal: out of the docs and the
published types, there for the gamepad plugin only. The plugin declares
the two calls itself and reaches them through one cast. Their changelog
entry and skill paragraph go; the two gamepad fixes stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
In the API docs home page, llms.txt, and the melonjs, melonjs-plugins
(with its own section and trigger words) and AGENTS skills.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
…on-ao5snz

# Conflicts:
#	packages/melonjs/CHANGELOG.md
The platformer registers @melonjs/gamepad-plugin (a stick and an A
button) and drops its own entities/controls.ts. The player's existing
gamepad bindings drive it: the stick walks (D-pad and LX), A jumps
(FACE_1). It shows on touch screens, and on a desktop while the debug
panel is open, as the old joypad did.

The examples depend on the plugin through the workspace, keep it out
of Vite's pre-bundling like the other plugins, and the docs workflow
builds it before building the examples site.

Part of #1719.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
Copilot AI balanced review requested due to automatic review settings October 30, 2026 02:27

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.

…anvas

On a phone, a long press on the stick or a button started a text
selection over the canvas, the iOS image menu or the Android context
menu: cancelling the pointer events does not stop those.

While the controls listen, the canvas gets user-select: none,
-webkit-touch-callout: none and a transparent tap highlight (put back
on destroy, in reverse as user-select and its -webkit- form alias), a
touchstart that lands on a control is cancelled, and the context menu
is blocked while the controls show. Touches elsewhere are left alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
Copilot AI balanced review requested due to automatic review settings October 30, 2026 03: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.

The stick read against its whole base, while the thumb drawn under the
finger travels about half that, so the thumb lagged the finger and the
D-pad (past half the reading) only pressed with the finger most of the
way to the rim. It now reads against the thumb's travel (`reach`), and
the thumb is drawn under the finger as far as it goes.

New stick option `dpadThreshold` (0.5 by default) sets how far the
stick leans before it presses the D-pad and its keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
Copilot AI balanced review requested due to automatic review settings October 30, 2026 03:08

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.

…ress

- `dpadThreshold` is measured on how far the thumb has travelled, so the
  default 0.5 presses the D-pad with the thumb half way out; it was
  measured after the deadzone, which put it at about 55%
- `haptics` (10 ms by default, 0 for none): a button press, or a finger
  sliding onto the next button, gives a short vibration for the feel of
  a real button, where navigator.vibrate exists (Android, not iOS). It
  is skipped while the game plays a rumble, which it would cut short

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
Copilot AI balanced review requested due to automatic review settings October 30, 2026 03:12

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.

A touch only counts as the player's own once it ends, and browsers
refuse a vibration before that, logging an error. The tick is skipped
while navigator.userActivation says no tap has happened yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
Copilot AI balanced review requested due to automatic review settings October 30, 2026 03:16

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.

Any browser with navigator.vibrate: Android browsers, and apps that
provide it from their web view (the Claude iOS app does); not Safari or
the other iPhone browsers, which all use WebKit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru
Copilot AI balanced review requested due to automatic review settings October 30, 2026 03:24

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.

Copilot AI balanced review requested due to automatic review settings October 30, 2026 03:48

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.

@obiot
obiot merged commit 78ca9b1 into master Oct 30, 2026
6 checks passed
@obiot
obiot deleted the claude/modest-johnson-ao5snz branch October 30, 2026 03:55
obiot added a commit that referenced this pull request Oct 30, 2026
Also lists what came in #1800: dpadThreshold, no selection on a long
press, and the haptics option.


Claude-Session: https://claude.ai/code/session_01JnzwA4xBNBw7cEZzG1bUru

Co-authored-by: Claude <[email protected]>
obiot added a commit that referenced this pull request Nov 12, 2026
…nvas, proportional minimap (#1855)

* Canvas meshes: textures, depth order, palette shading, no seams

The Canvas renderer's drawMesh, measured against WebGL on the examples:

- A mesh with baked material colors (vertexColors, from an MTL) was
  always solid-filled, so a textured OBJ + MTL model came out as a white
  silhouette. The texture is drawn, multiplied by each triangle's
  color and the tint, as the GPU renderers do; only a mesh with no
  texture (the 1x1 white fallback) is filled.
- The painter's sort took the largest projected Z as the farthest, but
  projectVertices puts the near plane at +zScale: meshes drew their far
  side over their near side. It now sorts on each triangle's farthest
  point, smallest Z first. The back-face test was right, and is now
  covered.
- Palette models (all 568 triangles of a Kenney animal) map each face
  onto a line of one texture. Such a triangle was filled with the one
  texel under its first vertex; it is now filled with a gradient of the
  texels along its UV line, as the GPU interpolates it, and a point is
  one color. A UV area under a quarter texel counts as degenerate,
  instead of exactly zero, so near-zero ones no longer smear.
- Opaque triangles also stroke their edge in their own fill, covering
  the faint seam two antialiased neighbours leave.
- Texture pixels are read once per image and cached, instead of one
  getImageData readback per triangle per frame, which froze the ground
  shadows example on Canvas.

Tests: canvas-mesh.spec.js draws and reads back, including an OBJ + MTL
palette model through the loader and a colored cube through the real
projection; each fix fails its test when reverted. Skills updated where
they described the old Canvas behaviour.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx

* Examples: 2D-camera meshes run on Canvas; Camera3d ones say they cannot

- The 3D Mesh and Multi-material OBJ examples draw their meshes under a
  2D camera, which the Canvas renderer supports, at about 60 fps; they
  no longer refuse it.
- Billboard, Instanced Forest, glTF Scene, glTF Animated Model, Ground
  Shadows and Night City need Camera3d, which needs a depth buffer.
  On Canvas they showed an empty sky (and Instanced Forest ran at about
  1 fps); they now stop with their existing "needs WebGL" message, as
  AfterBurner, Earth Defender, Jungle Rabbit and Material Textures do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx

* Examples: proportional minimap; the matter platformer uses the gamepad plugin

- Both platformers' minimap is 18% of the canvas width (about the old
  180 px at the default size), zoomed to the whole level, and resized
  through Camera2d#setViewport on CANVAS_ONRESIZE, which an orientation
  change also emits. In a portrait window it no longer covers the audio
  and fullscreen buttons.
- The matter platformer's touch controls come from
  @melonjs/gamepad-plugin, as the built-in platformer's have since
  #1800: its own VirtualJoypad (controls.ts, removed) was laid out for
  landscape and covered the jump button in portrait. The player's
  gamepad bindings drive it unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx

* Docs: meshes target the GPU backends, Canvas is a last resort

- Mesh, InstancedMesh, CanvasRenderer#drawMesh, the video.CANVAS setting,
  the README, DOC_README and the 3D and renderer-backends skills now say
  it the same way: 3D is built for WebGL 2 and WebGPU; a mesh under a 2D
  camera still draws on Canvas as a last-resort fallback (CPU-projected,
  unlit, one texture, slower with every triangle), and a Camera3d scene
  does not draw there at all. Mesh and the 3D skill gain an example of
  checking supportsDepthBuffer after init().
- Two Mesh comments still described the old Canvas solid fill.
- Seven class descriptions were not on the API site: a helper or a
  typedef had been inserted between each doc comment and its class
  (Mesh since #1662), so TypeDoc showed Mesh, Light3d and Trigger with
  Renderable's description and InstancedMesh, Color, Noise and
  ShaderEffect with none. Each comment is back on its class; TypeDoc's
  warnings are unchanged (208 before and after).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
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.

3 participants