Skip to content

Commit 024378d

Browse files
obiotclaude
andauthored
Input: gamepad haptics, an active-pad answer, and a trim of dead mappings (#1738)
Adds `vibrateGamepad`, `stopGamepadVibration` and `hasGamepadVibration`, built so game code never branches on support: an empty slot, a pad without motors and a browser with no Gamepad API all resolve false and do nothing. Magnitudes default to full, because the web API defaults every parameter to zero and a bare `playEffect` is a silent no-op. Adds `activeGamepad()`, because the index a game should rumble is not something the browser reports. Slots are plug order, so index 0 is a guess: a wheel or a spare pad left plugged in can hold it for a whole session while the player presses buttons on slot 1. It follows the last pad to produce a bound input, carrying the pad's id alongside the index so a slot refilled by a different device is not mistaken for the original. Removes the OUYA and Firefox Xbox 360 remappings with their two normalizers. A remapping is consulted only for a pad the browser does not report as "standard". Both normalizers read `this.GAMEPAD`, while `normalize_fn` is invoked off the mapping object, so they would have thrown on the hardware they were written for. `unbindGamepad` takes the table the binding is in and deletes it rather than blanking it. An axis could not be unbound at all before, since the call only reached the button table and the two code sets collide at 0; a blanked entry also stayed in the poll with no keycode. Corrects the axis `threshold` documentation: it is ADDED to the deadzone rather than used instead of it, so the documented 0.5 does not fire until 0.6 of full travel. Behaviour unchanged. 27 tests, the first for this module. Every behaviour claimed above is mutation-tested. Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent db9352a commit 024378d

4 files changed

Lines changed: 869 additions & 86 deletions

File tree

‎packages/melonjs/CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,28 @@
1616
- **A movable listener**: `audio.setListener(target)` puts the ear on a renderable and keeps it there, while `audio.listener(x, y, z)` and `audio.listenerOrientation(fx, fy, fz, ux, uy, uz)` place it by hand and read it back when called with no arguments. Source positions become absolute world coordinates instead of offsets from the player, and a `Camera3d` target contributes orientation as well, taken from its own basis. It is opt-in end to end: until a game asks for a listener no frame handler is installed, and every existing `position` / `stereo` / `panner` call behaves exactly as it did
1717
- **Panner defaults shaped for pixels**: a placed sound now starts from `refDistance: 240`, `maxDistance: 10000`, `distanceModel: "inverse"` and `panningModel: "equalpower"`, so it carries across a screen instead of going near silent a few tiles out. `audio.setSpatialDefaults()` and `audio.getSpatialDefaults()` change what new voices inherit
1818
- **`getPool("text")` and `getPool("colorLayer")`**: typed pools for the two renderables whose only recycling route was the legacy name-keyed pool. `getPool("text").get(x, y, settings)` hands back a label with its measurement cache already allocated, and `release()` gives it back
19+
- **Gamepad vibration**: `input.vibrateGamepad(index, options)` plays a rumble effect, `input.stopGamepadVibration(index)` cuts a long one short, and `input.hasGamepadVibration(index)` reports whether a pad has motors at all, for an options screen that should not offer a setting the hardware cannot honour. Safe to call blind: an empty slot, a pad without motors and a browser with no Gamepad API all resolve `false` and do nothing, so game code never branches on support. Magnitudes default to FULL, unlike the underlying web API where every parameter defaults to zero and a bare `playEffect` is a silent no-op. A second call preempts the first on the same pad, which is what makes a rapid series of hits read as a rapid series of hits rather than a queue
20+
- **`input.activeGamepad()`**: the index of the pad the player is actually holding, which is not something the browser reports. Slots are handed out in plug order, so index 0 is a guess: a wheel, a spare pad or a keyboard-shaped device left plugged in can hold slot 0 for a whole session while the player presses buttons on slot 1. This follows the last pad to produce a BOUND input, falling back to the first connected pad and then to -1 when there is none, and it is what a one-player game should pass to `vibrateGamepad`, since rumbling the wrong pad is both useless and impossible to notice in testing. It follows bound inputs only, which is what makes it an answer about the player rather than about electrical noise: a worn stick at rest reports movement for ever and would otherwise claim to be the active pad. Bindings are still per index, so a game that wants any pad to drive it binds the slots it will accept to the same actions, and a bound index with no pad in it costs nothing in the poll
1921
- **`createPool` is exported**, along with the `Pool` and `CreatePoolOptions` types it names, so a pool for one of your own classes no longer needs a deep import and a field holding one can be declared. `getPool()` only reaches the pools the engine ships, so this is what a game moving off `pool.register(name, Class, true)` needs
2022

23+
### Changed
24+
- `input.unbindGamepad(index, button, type)` takes the table the binding is in, `"buttons"` by default, and removes the binding outright rather than blanking it. An axis could not be unbound at all before: the call only ever reached the button table, so asking for one by its code left the axis live and removed whatever button happened to share the number, and the codes do share numbers, `GAMEPAD.AXES.LX` and `GAMEPAD.BUTTONS.FACE_1` both being 0. Blanking also left a binding behind that still answered the poll with no keycode, which is what let an unbound button go on reporting its pad as the active one
25+
2126
### Changed
2227
- `audio.playTrack()` stops the track it replaces. It only ever assigned the current-track slot and played, so the previous track kept looping underneath and, with the slot already moved on, `stopTrack()`, `pauseTrack()` and `resumeTrack()` could no longer reach it: two beds playing at once, and the older one stuck for the life of the page. There is one current track, and now only one is ever playing. Calling it with the track already playing restarts that track rather than stacking a second copy of it, which is what `play()` does for every other clip; a scene that wants the bed to carry on across a screen change should not call it again. An unknown clip name still throws, and now throws before anything is stopped, so a typo cannot leave the game silent
2328
- `pool.push(obj)` reports a refusal by returning `false` rather than throwing. It threw by default for any class that was never registered, or registered without recycling, and nothing at the call site made that visible: the exception aborted whatever was running, which in both of the engine's own uses removed in 20.7.0 was a `destroy()` that had already recycled other state, leaving a half torn down object to die later somewhere unrelated. Pass `pool.push(obj, true)` where a missed registration is a bug you want to hear about immediately ([#1688](https://github.com/melonjs/melonJS/issues/1688))
2429
- Tiled tile images, atlas sprites and Tiled image layers are built from their classes directly rather than looked up by name. `TextureAtlas#createSpriteFromName` and the Tiled image layer loader used to resolve their class through `pool.pull("me.Sprite")` and `pool.pull("ImageLayer")`, which never pooled anything: neither class is registered for recycling, so the call was a plain construction behind a string key. Re-registering one of those BUILT-IN names to substitute your own class, as in `pool.register("Sprite", MySprite)`, therefore no longer changes what they produce. Registering your own names is unaffected, and so is substituting a built-in for Tiled OBJECTS, which goes through the Tiled object factory ([#1688](https://github.com/melonjs/melonJS/issues/1688))
2530
- The engine no longer registers its own classes in the legacy object pool, so `pool.pull("Sprite")`, `pool.pull("Text")`, `pool.pull("Tween")`, `pool.pull("Particle")` and the other nine built-in names throw instead of handing back an instance. Nine of the thirteen had recycling switched off, which made the call a plain construction behind a string key; the four that had it on all have a typed pool, `getPool("tween")`, `getPool("particle")`, `getPool("text")` and `getPool("colorLayer")`. Call the constructor, or the typed pool where you want the recycling ([#1688](https://github.com/melonjs/melonJS/issues/1688))
2631
- A child removed from a container goes back to the typed pool that built it, and is destroyed when no pool did. An object carries its own pool now rather than being matched by name against a global registry, so `pool.register(name, Class, true)` no longer makes a class recyclable on removal, and `pool.push()` on a Tiled object declines it rather than pooling it, since nothing stamps the name it was looked up under any more. A built-in `Text` or `ColorLayer` removed from a container is therefore destroyed where it used to be pushed to the legacy pool: pass `keepalive` to `removeChild()` if you mean to add it back ([#1688](https://github.com/melonjs/melonJS/issues/1688))
2732

33+
### Removed
34+
- The built-in gamepad remappings for the OUYA controller and for the wired Xbox 360 controller under Firefox, along with the two axis normalizers they needed. A remapping is consulted only when the browser reports a `mapping` other than `"standard"`, which is the case these two entries were written for and not one a current browser puts a common pad in; the OUYA itself was discontinued in 2015 and its services closed in 2019. The PlayStation 3 and PlayStation 4 entries are unchanged, and `input.setGamepadMapping(id, mapping)` still takes a mapping for anything else
35+
2836
### Deprecated
2937
- `pool`, the legacy name-keyed object pool. `register()`, `pull()` and `push()` each print a notice once, naming both replacements, because the class was doing two unrelated jobs and a caller only ever wanted one of them: `getPool()` and `createPool()` pool instances, and `registerTiledObjectClass()` lets a Tiled map name a class. Nothing in the engine calls it any more ([#1688](https://github.com/melonjs/melonJS/issues/1688))
3038

3139
### Fixed
40+
- Input: the gamepad axis `threshold` is now documented as what it is, a value ADDED to the deadzone rather than one used instead of it. The test is `abs(value) >= deadzone + abs(threshold)`, so under the default deadzone of 0.1 the figure of `0.5` in the documented example does not fire until 0.6 of full travel, and raising `setGamepadDeadzone` moves every axis binding with it. The behaviour is unchanged: what a game should pass is the point it wants the stick to break at, minus the deadzone
3241
- Audio: a page left idle for thirty seconds before its first click could lose every loaded clip for the life of the page while `tone()` and `noise()` still played. The idle power-saving suspend waited for the browser to confirm it before the engine would resume anything, and WebKit never confirms a suspend asked for outside a user gesture on a context the page was never allowed to start. The first click then started the hardware through the unlock path, which is why generated sound was heard, but the engine still believed its suspend was in flight and parked each clip's playback waiting for a resume that could not come. A resume requested while the suspend is in flight is now issued at once, and the browser orders it after the suspend. A user gesture or the tab coming back to the front also revives playback whenever the engine and the context disagree about whether it is running
3342
- Audio: a sound's `onend` callback could fire for a DIFFERENT sound. Stopping a voice clears the callbacks bound to it, but the clear removed only the first of them and left any others attached; playback ids are handed to the next voice to take the slot, so a leftover callback outlived its own sound and then ran when an unrelated one finished. Most visible on a clip played many times with a per-play `onend`, where the callbacks accumulated
3443
- UI: `UITextButton.bindKey` now activates the button from an `input.KEY` keycode or an existing action name. Held keys click once, only the initiating key releases the button, and blur or removal clears the keyboard press without changing global bindings. Two things to know before binding a key to a button that already exists: the handlers fire with NO pointer, so an `onClick(event)` written when only a pointer could press it throws the first time someone uses the shortcut, and losing focus cancels a held key without calling `onRelease()`, so a button that fires while held has to stop on `event.BLUR` as well ([#1722](https://github.com/melonjs/melonJS/issues/1722), thanks @snowyukitty)

‎packages/melonjs/skills/melonjs-input/SKILL.md‎

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,80 @@ input.bindGamepad(0,
197197
```
198198

199199
**Order matters:** `bindGamepad` throws `no action defined for keycode N` if the
200-
keycode has not already been passed to `bindKey`. Axes use the same call with a
201-
threshold: `{ type: "axes", code: input.GAMEPAD.AXES.LX, threshold: -0.5 }`.
202-
`unbindGamepad(index, button)`, `setGamepadDeadzone(value)` and
200+
keycode has not already been passed to `bindKey`.
201+
202+
Axes use the same call with a threshold, and bind **one half of the axis each**
203+
— the sign selects which half:
204+
205+
```js
206+
input.bindGamepad(0, { type: "axes", code: input.GAMEPAD.AXES.LX, threshold: -0.15 }, input.KEY.LEFT);
207+
input.bindGamepad(0, { type: "axes", code: input.GAMEPAD.AXES.LX, threshold: 0.15 }, input.KEY.RIGHT);
208+
```
209+
210+
**`threshold` is added to the deadzone, it does not replace it.** The test is
211+
`abs(value) >= deadzone + abs(threshold)`, so with the default deadzone of 0.1
212+
a threshold of `0.5` does not fire until 0.6 of full travel, and `0.15` fires at
213+
0.25. Pick the number you want the stick to break at and subtract the deadzone.
214+
Raising `setGamepadDeadzone` moves every axis binding with it.
215+
216+
`unbindGamepad(index, button, type?)`, `setGamepadDeadzone(value)` and
203217
`setGamepadMapping(id, mapping)` complete the surface.
204218

219+
### Which index to bind
220+
221+
Bindings are per pad **index**, and the index is plug order, not identity —
222+
nothing reports which pad is "the" controller. Slot 0 is a guess: a wheel, a
223+
spare pad or a keyboard-shaped device left plugged in can hold it for a whole
224+
session while the player presses buttons on slot 1.
225+
226+
So a one-player game binds **every slot it will accept** to the same actions,
227+
and lets `activeGamepad()` say which one is in use when something has to go
228+
back the other way:
229+
230+
```js
231+
for (let pad = 0; pad < 4; pad++) {
232+
input.bindGamepad(pad, { type: "buttons", code: input.GAMEPAD.BUTTONS.FACE_1 }, input.KEY.X);
233+
}
234+
input.activeGamepad(); // the last pad to produce a bound input, else the
235+
// first connected one, else -1
236+
```
237+
238+
Binding spare slots costs nothing: the poll skips a bound index with no pad in
239+
it. `activeGamepad()` follows **bound inputs only**, which is what makes it an
240+
answer about the player rather than about electrical noise — a worn stick at
241+
rest reports movement for ever and would otherwise claim to be the active pad.
242+
243+
### Rumble
244+
245+
```js
246+
input.vibrateGamepad(input.activeGamepad(), {
247+
duration: 120, strongMagnitude: 0.6, weakMagnitude: 0.9,
248+
});
249+
input.stopGamepadVibration(index); // cut a long effect short
250+
input.hasGamepadVibration(index); // for an options-screen toggle
251+
```
252+
253+
Safe to call blind: no pad, no motors, or no Gamepad API at all resolves
254+
`false` and does nothing, so game code never branches on support. Magnitudes
255+
default to **full** — the underlying web API defaults every parameter to zero,
256+
which makes a bare `playEffect` a silent no-op. A second call preempts the
257+
first on the same pad, so a rapid series of hits reads as a rapid series of
258+
hits rather than as a queue. For a SUSTAINED rumble issue one effect with a
259+
long `duration` instead of calling every frame.
260+
261+
Hot-plugging needs no polling of your own — the engine listens for the browser's
262+
own events and republishes them:
263+
264+
```js
265+
import { event } from "melonjs";
266+
267+
event.on(event.GAMEPAD_CONNECTED, (gamepad) => { /* gamepad.id, .index */ });
268+
event.on(event.GAMEPAD_DISCONNECTED, (gamepad) => { /* pause, show a prompt */ });
269+
```
270+
271+
Bindings survive a disconnect: they are attached to the index, not to the
272+
device, so a pad unplugged and plugged back into the same slot keeps working.
273+
205274
## Virtual controls
206275

207276
On-screen buttons synthesise key events, again reaching the same actions:
@@ -224,6 +293,8 @@ input.triggerKeyEvent(input.KEY.LEFT, false); // release
224293
| jump fires every frame while held | `bindKey` without its third `lock` argument |
225294
| `invalid event type` thrown at registration | non-pointer name (`"click"`, `"mousedown"`, …) passed to `registerPointerEvent` |
226295
| `no action defined for keycode N` | `bindGamepad` called before `bindKey` for that keycode |
296+
| stick has to be pushed well past its `threshold` before it registers | `threshold` is ADDED to the deadzone, not used instead of it |
297+
| one half of a stick does nothing | each half is a separate `bindGamepad`, chosen by the sign of `threshold` |
227298
| a widget under an overlapping panel still reacts | the panel consumes nothing — return `false` from its `onClick` and `onOver` |
228299
| 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 |
229300
| 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 |

0 commit comments

Comments
 (0)