@yagejs/input 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -34,6 +34,42 @@ var import_api = require("@yagejs/debug/api");
34
34
 
35
35
  // src/InputManager.ts
36
36
  var import_core = require("@yagejs/core");
37
+ var MOUSE_BUTTON_CODES = ["MouseLeft", "MouseMiddle", "MouseRight"];
38
+ var STANDARD_BUTTON_CODES = [
39
+ "GamepadA",
40
+ "GamepadB",
41
+ "GamepadX",
42
+ "GamepadY",
43
+ "GamepadLB",
44
+ "GamepadRB",
45
+ "GamepadLT",
46
+ "GamepadRT",
47
+ "GamepadSelect",
48
+ "GamepadStart",
49
+ "GamepadLeftStick",
50
+ "GamepadRightStick",
51
+ "GamepadDPadUp",
52
+ "GamepadDPadDown",
53
+ "GamepadDPadLeft",
54
+ "GamepadDPadRight",
55
+ "GamepadHome"
56
+ ];
57
+ var TRIGGER_LEFT_INDEX = 6;
58
+ var TRIGGER_RIGHT_INDEX = 7;
59
+ var STICK_AXIS_KEYS = {
60
+ left: { x: "leftX", y: "leftY" },
61
+ right: { x: "rightX", y: "rightY" }
62
+ };
63
+ var TRIGGER_AXIS_KEYS = {
64
+ left: "leftTrigger",
65
+ right: "rightTrigger"
66
+ };
67
+ var STANDARD_AXIS_KEYS = [
68
+ "leftX",
69
+ "leftY",
70
+ "rightX",
71
+ "rightY"
72
+ ];
37
73
  var InputManager = class {
38
74
  static {
39
75
  __name(this, "InputManager");
@@ -49,11 +85,64 @@ var InputManager = class {
49
85
  groups = /* @__PURE__ */ new Map();
50
86
  actionGroups = /* @__PURE__ */ new Map();
51
87
  disabledGroups = /* @__PURE__ */ new Set();
52
- pointerScreenPos = import_core.Vec2.ZERO;
53
- pointerDownState = false;
54
- pressedMouseButtons = /* @__PURE__ */ new Set();
55
- gamepadButtons = /* @__PURE__ */ new Map();
56
- gamepadAxes = /* @__PURE__ */ new Map();
88
+ /** Tracked pointers keyed by `pointerId`. Mouse persists; touch/pen removed on up/cancel. */
89
+ pointers = /* @__PURE__ */ new Map();
90
+ /** Id of the pointer the browser last marked `isPrimary`, or `null` when none are tracked. */
91
+ primaryPointerId = null;
92
+ /**
93
+ * Aggregate "any pointer has this button held" cache. The action-map codes
94
+ * `MouseLeft`/`MouseMiddle`/`MouseRight` are driven from edges into/out of this
95
+ * set so two simultaneous taps holding button 0 do not double-fire.
96
+ * Consumed pointers are excluded from the aggregate so UI-claimed presses
97
+ * never propagate to gameplay actions.
98
+ */
99
+ mouseButtonAggregate = /* @__PURE__ */ new Set();
100
+ /**
101
+ * Pointers marked as "claimed" via {@link consumePointer} (or auto-claimed by
102
+ * the renderer's UI hit-test fallback). Lifetime is per-pointer-event-cycle:
103
+ * cleared when the pointer's last button releases (drained `pointerUp`) or on
104
+ * `pointercancel`.
105
+ */
106
+ consumedPointers = /* @__PURE__ */ new Set();
107
+ /** Wheel-edge gate flipped by {@link consumeWheel}. Cleared at end of frame. */
108
+ consumedWheelThisFrame = false;
109
+ /** Buffered DOM-originated events awaiting drain at `Phase.EarlyUpdate`. */
110
+ inputQueue = [];
111
+ /**
112
+ * Renderer reference for the optional `hitTestUI(x, y)` lookup. Stashed by
113
+ * {@link _setRenderer} during `InputPlugin.install` so the drain step can
114
+ * read it cheaply each frame.
115
+ */
116
+ renderer = null;
117
+ pointerDownListeners = [];
118
+ pointerUpListeners = [];
119
+ pointerMoveListeners = [];
120
+ keyDownListenersAny = [];
121
+ keyUpListenersAny = [];
122
+ keyDownListeners = /* @__PURE__ */ new Map();
123
+ keyUpListeners = /* @__PURE__ */ new Map();
124
+ actionListeners = /* @__PURE__ */ new Map();
125
+ actionReleasedListeners = /* @__PURE__ */ new Map();
126
+ wheelListeners = [];
127
+ /** Real-pad axis values keyed by `${padIndex}:${axisKey}`. */
128
+ gamepadAxisState = /* @__PURE__ */ new Map();
129
+ /** Synthetic axis values for fireGamepadAxis injection (test path). */
130
+ syntheticAxisState = /* @__PURE__ */ new Map();
131
+ /** "Any pad" aggregate of currently-pressed gamepad codes. */
132
+ lastButtonState = /* @__PURE__ */ new Map();
133
+ /** Per-pad "anything happening" flag, used to detect rising-edge activity for active-pad promotion. */
134
+ lastPadActivity = /* @__PURE__ */ new Map();
135
+ /** Pads currently known to the engine (populated via events or polling). */
136
+ connectedPads = /* @__PURE__ */ new Map();
137
+ /** Index of the pad whose analog input is read by default. `null` when no pad is connected. */
138
+ activePadIndex = null;
139
+ gamepadConnectListeners = [];
140
+ gamepadDisconnectListeners = [];
141
+ activePadListeners = [];
142
+ stickDeadzone = 0.15;
143
+ triggerDeadzone = 0.05;
144
+ triggerThreshold = 0.5;
145
+ pollingEnabled = true;
57
146
  camera = null;
58
147
  elapsedMs = 0;
59
148
  listenResolve = null;
@@ -118,24 +207,216 @@ var InputManager = class {
118
207
  return new import_core.Vec2(x, y);
119
208
  }
120
209
  // -- Pointer --
121
- /** Pointer position in world coordinates (via Camera), or screen coords if no camera. */
210
+ /**
211
+ * Primary pointer's position in world coordinates (via Camera), or screen
212
+ * coords if no camera. Returns `Vec2.ZERO` when no pointer is tracked.
213
+ *
214
+ * For multi-pointer access (touch UIs etc.) iterate {@link getPointers} and
215
+ * convert each `screenPos` via the camera as needed.
216
+ */
122
217
  getPointerPosition() {
218
+ const primary = this.getPrimaryPointer();
219
+ if (!primary) return import_core.Vec2.ZERO;
123
220
  if (this.camera) {
124
- const w = this.camera.screenToWorld(
125
- this.pointerScreenPos.x,
126
- this.pointerScreenPos.y
127
- );
221
+ const w = this.camera.screenToWorld(primary.screenPos.x, primary.screenPos.y);
128
222
  return new import_core.Vec2(w.x, w.y);
129
223
  }
130
- return this.pointerScreenPos;
224
+ return primary.screenPos;
131
225
  }
132
- /** Raw pointer position in screen coordinates. */
226
+ /** Primary pointer's raw position in screen coordinates, or `Vec2.ZERO` when no pointer is tracked. */
133
227
  getPointerScreenPosition() {
134
- return this.pointerScreenPos;
228
+ const primary = this.getPrimaryPointer();
229
+ return primary ? primary.screenPos : import_core.Vec2.ZERO;
135
230
  }
136
- /** Whether any pointer button is currently held. */
231
+ /** Whether the primary pointer has any button held. */
137
232
  isPointerDown() {
138
- return this.pointerDownState;
233
+ const primary = this.getPrimaryPointer();
234
+ return primary ? primary.isDown : false;
235
+ }
236
+ /** All currently-tracked pointers (one per active mouse, pen, or finger). */
237
+ getPointers() {
238
+ const out = [];
239
+ for (const p of this.pointers.values()) {
240
+ out.push(this.toPointerInfo(p));
241
+ }
242
+ return out;
243
+ }
244
+ /** Direct lookup by `pointerId`, or `undefined` if no pointer with that id is tracked. */
245
+ getPointer(id) {
246
+ const p = this.pointers.get(id);
247
+ return p ? this.toPointerInfo(p) : void 0;
248
+ }
249
+ /**
250
+ * Defensive snapshot of a tracked pointer. The runtime `MutablePointerInfo`
251
+ * holds a real `Set` for `buttons` — even though the `PointerInfo` type
252
+ * declares `ReadonlySet`, JS doesn't enforce that at runtime, so we copy the
253
+ * set on every public read. `Vec2` is convention-immutable across YAGE, so
254
+ * we share the same instance.
255
+ */
256
+ toPointerInfo(pointer) {
257
+ return {
258
+ id: pointer.id,
259
+ screenPos: pointer.screenPos,
260
+ type: pointer.type,
261
+ isPrimary: pointer.isPrimary,
262
+ buttons: new Set(pointer.buttons),
263
+ isDown: pointer.isDown
264
+ };
265
+ }
266
+ /**
267
+ * Subscribe to pointer-down events (button transitions from up → down on a
268
+ * tracked pointer). Returns a disposer that detaches the listener.
269
+ */
270
+ onPointerDown(fn) {
271
+ this.pointerDownListeners.push(fn);
272
+ return () => {
273
+ const idx = this.pointerDownListeners.indexOf(fn);
274
+ if (idx !== -1) this.pointerDownListeners.splice(idx, 1);
275
+ };
276
+ }
277
+ /**
278
+ * Subscribe to pointer-up events (button transitions from down → up, plus
279
+ * touch / pen lifecycle ends and `pointercancel`). Returns a disposer.
280
+ */
281
+ onPointerUp(fn) {
282
+ this.pointerUpListeners.push(fn);
283
+ return () => {
284
+ const idx = this.pointerUpListeners.indexOf(fn);
285
+ if (idx !== -1) this.pointerUpListeners.splice(idx, 1);
286
+ };
287
+ }
288
+ /** Subscribe to pointer-move events. Returns a disposer. */
289
+ onPointerMove(fn) {
290
+ this.pointerMoveListeners.push(fn);
291
+ return () => {
292
+ const idx = this.pointerMoveListeners.indexOf(fn);
293
+ if (idx !== -1) this.pointerMoveListeners.splice(idx, 1);
294
+ };
295
+ }
296
+ // -- Consume primitives --
297
+ /**
298
+ * Mark a pointer as claimed for the rest of its event cycle (down → up).
299
+ * Subsequent action-map edges for this pointer (e.g. the `MouseLeft` edge a
300
+ * `pointerdown` would normally fire) are suppressed; `onPointerDown/Up/Move`
301
+ * listeners still fire because they are explicit user opt-ins.
302
+ *
303
+ * The mark clears automatically when the pointer's last button releases or
304
+ * on `pointercancel`. Call from a Pixi `pointerdown` handler that wants to
305
+ * own the event: `manager.consumePointer(e.pointerId)`.
306
+ */
307
+ consumePointer(id) {
308
+ this.consumedPointers.add(id);
309
+ }
310
+ /** Whether the pointer is currently marked consumed. */
311
+ isPointerConsumed(id) {
312
+ return this.consumedPointers.has(id);
313
+ }
314
+ /**
315
+ * Suppress wheel action-map edges (`WheelUp/Down/Left/Right`) for the rest
316
+ * of the current frame. `onWheel` listeners still fire.
317
+ */
318
+ consumeWheel() {
319
+ this.consumedWheelThisFrame = true;
320
+ }
321
+ // -- Listener parity (keys, actions, wheel) --
322
+ /**
323
+ * Subscribe to key-down events. Pass a code (e.g. `"Space"`, `"GamepadA"`)
324
+ * to filter, or `"*"` for all keys. The listener fires on the same edge
325
+ * `isJustPressed` reports — for DOM-originated events that's the next
326
+ * `Phase.EarlyUpdate` after the browser dispatches; for synthetic injection
327
+ * (`fireKeyDown`) it's synchronous. Returns a disposer.
328
+ */
329
+ onKeyDown(code, fn) {
330
+ if (code === "*") {
331
+ this.keyDownListenersAny.push(fn);
332
+ return () => {
333
+ const idx = this.keyDownListenersAny.indexOf(fn);
334
+ if (idx !== -1) this.keyDownListenersAny.splice(idx, 1);
335
+ };
336
+ }
337
+ let arr = this.keyDownListeners.get(code);
338
+ if (!arr) {
339
+ arr = [];
340
+ this.keyDownListeners.set(code, arr);
341
+ }
342
+ arr.push(fn);
343
+ return () => {
344
+ const list = this.keyDownListeners.get(code);
345
+ if (!list) return;
346
+ const idx = list.indexOf(fn);
347
+ if (idx !== -1) list.splice(idx, 1);
348
+ };
349
+ }
350
+ /** Subscribe to key-up events. See {@link onKeyDown}. */
351
+ onKeyUp(code, fn) {
352
+ if (code === "*") {
353
+ this.keyUpListenersAny.push(fn);
354
+ return () => {
355
+ const idx = this.keyUpListenersAny.indexOf(fn);
356
+ if (idx !== -1) this.keyUpListenersAny.splice(idx, 1);
357
+ };
358
+ }
359
+ let arr = this.keyUpListeners.get(code);
360
+ if (!arr) {
361
+ arr = [];
362
+ this.keyUpListeners.set(code, arr);
363
+ }
364
+ arr.push(fn);
365
+ return () => {
366
+ const list = this.keyUpListeners.get(code);
367
+ if (!list) return;
368
+ const idx = list.indexOf(fn);
369
+ if (idx !== -1) list.splice(idx, 1);
370
+ };
371
+ }
372
+ /**
373
+ * Subscribe to action press edges (rising edge of any key bound to the
374
+ * action). Fires once per press. Returns a disposer.
375
+ */
376
+ onAction(name, fn) {
377
+ let arr = this.actionListeners.get(name);
378
+ if (!arr) {
379
+ arr = [];
380
+ this.actionListeners.set(name, arr);
381
+ }
382
+ arr.push(fn);
383
+ return () => {
384
+ const list = this.actionListeners.get(name);
385
+ if (!list) return;
386
+ const idx = list.indexOf(fn);
387
+ if (idx !== -1) list.splice(idx, 1);
388
+ };
389
+ }
390
+ /** Subscribe to action release edges. Returns a disposer. */
391
+ onActionReleased(name, fn) {
392
+ let arr = this.actionReleasedListeners.get(name);
393
+ if (!arr) {
394
+ arr = [];
395
+ this.actionReleasedListeners.set(name, arr);
396
+ }
397
+ arr.push(fn);
398
+ return () => {
399
+ const list = this.actionReleasedListeners.get(name);
400
+ if (!list) return;
401
+ const idx = list.indexOf(fn);
402
+ if (idx !== -1) list.splice(idx, 1);
403
+ };
404
+ }
405
+ /**
406
+ * Subscribe to scroll-wheel events. Receives raw `deltaX`/`deltaY` (already
407
+ * sign-flipped by `InputConfig.wheelInvertY` if set). Fires regardless of
408
+ * {@link consumeWheel} — it only gates action edges. Returns a disposer.
409
+ */
410
+ onWheel(fn) {
411
+ this.wheelListeners.push(fn);
412
+ return () => {
413
+ const idx = this.wheelListeners.indexOf(fn);
414
+ if (idx !== -1) this.wheelListeners.splice(idx, 1);
415
+ };
416
+ }
417
+ getPrimaryPointer() {
418
+ if (this.primaryPointerId === null) return null;
419
+ return this.pointers.get(this.primaryPointerId) ?? null;
139
420
  }
140
421
  // -- Runtime action map management --
141
422
  /** Replace the entire action map and store it as the default for {@link resetBindings}. */
@@ -331,44 +612,432 @@ var InputManager = class {
331
612
  resolve(null);
332
613
  }
333
614
  }
334
- /** Public wrapper for synthetic key-down injection. */
615
+ /** Public wrapper for synthetic key-down injection. Applies sync. */
335
616
  fireKeyDown(code) {
336
- this._onKeyDown(code);
617
+ this._applyKeyDown(code);
337
618
  }
338
- /** Public wrapper for synthetic key-up injection. */
619
+ /** Public wrapper for synthetic key-up injection. Applies sync. */
339
620
  fireKeyUp(code) {
340
- this._onKeyUp(code);
621
+ this._applyKeyUp(code);
341
622
  }
342
- /** Public wrapper for synthetic pointer movement. */
343
- firePointerMove(screenX, screenY) {
344
- this._onPointerMove(screenX, screenY);
623
+ /**
624
+ * Public wrapper for synthetic pointer movement. Defaults to the primary
625
+ * mouse pointer (`id: 1`, `type: "mouse"`); pass `opts` to drive a specific
626
+ * touch / pen pointer.
627
+ */
628
+ firePointerMove(screenX, screenY, opts) {
629
+ this._applyPointerMove(this.makeSyntheticInfo(screenX, screenY, -1, opts));
345
630
  }
346
- /** Public wrapper for synthetic pointer-button presses. */
347
- firePointerDown(button = 0) {
348
- this._onPointerDown();
349
- this.pressedMouseButtons.add(button);
350
- if (button === 0) this._onKeyDown("MouseLeft");
351
- if (button === 1) this._onKeyDown("MouseMiddle");
352
- if (button === 2) this._onKeyDown("MouseRight");
631
+ /**
632
+ * Public wrapper for synthetic pointer-button presses. Defaults to button 0
633
+ * on the primary mouse pointer. Pass `opts` for touch / pen / non-primary
634
+ * pointers (e.g. `{ id: 5, type: "touch", isPrimary: false }`).
635
+ */
636
+ firePointerDown(button = 0, opts) {
637
+ const id = opts?.id ?? 1;
638
+ const existing = this.pointers.get(id);
639
+ this._applyPointerDown(
640
+ this.makeSyntheticInfo(
641
+ existing?.screenPos.x ?? 0,
642
+ existing?.screenPos.y ?? 0,
643
+ button,
644
+ opts
645
+ )
646
+ );
353
647
  }
354
648
  /** Public wrapper for synthetic pointer-button releases. */
355
- firePointerUp(button = 0) {
356
- this.pressedMouseButtons.delete(button);
357
- this.pointerDownState = this.pressedMouseButtons.size > 0;
358
- if (!this.pointerDownState) {
359
- this._onPointerUp();
649
+ firePointerUp(button = 0, opts) {
650
+ const id = opts?.id ?? 1;
651
+ const existing = this.pointers.get(id);
652
+ const info = {
653
+ id,
654
+ screenX: existing?.screenPos.x ?? 0,
655
+ screenY: existing?.screenPos.y ?? 0,
656
+ type: existing?.type ?? "mouse",
657
+ isPrimary: existing?.isPrimary ?? id === 1,
658
+ button
659
+ };
660
+ this._applyPointerUp(info);
661
+ }
662
+ /** Public wrapper for synthetic wheel input. Applies sync, including
663
+ * action edges and `onWheel` listener notification — matching the DOM path
664
+ * so tests and inspector probes drive the full surface. */
665
+ fireWheel(dx, dy) {
666
+ for (const fn of [...this.wheelListeners]) fn(dx, dy);
667
+ this.applyWheelEdges(dx, dy);
668
+ }
669
+ makeSyntheticInfo(screenX, screenY, button, opts) {
670
+ const id = opts?.id ?? 1;
671
+ return {
672
+ id,
673
+ screenX,
674
+ screenY,
675
+ type: opts?.type ?? "mouse",
676
+ isPrimary: opts?.isPrimary ?? id === 1,
677
+ button
678
+ };
679
+ }
680
+ /**
681
+ * Inject a synthetic gamepad button edge. Routes through the same internal
682
+ * path as real polling, so action queries (`isPressed`, `isJustPressed`),
683
+ * `listenForNextKey`, and rebinding all see the synthetic input.
684
+ *
685
+ * `code` should be a gamepad code string (e.g. `"GamepadA"`, `"GamepadLT"`).
686
+ * Used by inspector probes / deterministic tests in lieu of real polling.
687
+ */
688
+ fireGamepadButton(code, pressed) {
689
+ const wasPressed = this.lastButtonState.get(code) ?? false;
690
+ if (pressed && !wasPressed) {
691
+ this._applyKeyDown(code);
692
+ this.lastButtonState.set(code, true);
693
+ } else if (!pressed && wasPressed) {
694
+ this._applyKeyUp(code);
695
+ this.lastButtonState.delete(code);
696
+ }
697
+ }
698
+ /**
699
+ * Inject a synthetic gamepad axis value. Stored separately from real-pad
700
+ * axis state and consulted by `getStick` / `getTrigger` only when no real
701
+ * pad is active — matching how a test fixture would use the API.
702
+ *
703
+ * Trigger axes additionally emit `GamepadLT`/`GamepadRT` button edges when
704
+ * crossing `triggerThreshold`, mirroring real-pad polling so synthetic
705
+ * inspector probes drive `isPressed` the same way as physical hardware.
706
+ */
707
+ fireGamepadAxis(side, value) {
708
+ const safe = Number.isFinite(value) ? value : 0;
709
+ this.syntheticAxisState.set(side, safe);
710
+ if (side === "leftTrigger") {
711
+ this.fireGamepadButton("GamepadLT", safe >= this.triggerThreshold);
712
+ } else if (side === "rightTrigger") {
713
+ this.fireGamepadButton("GamepadRT", safe >= this.triggerThreshold);
714
+ }
715
+ }
716
+ // -- Gamepad analog API --
717
+ /**
718
+ * Returns the deadzoned, magnitude-clamped stick vector for the given side.
719
+ *
720
+ * By default reads from the active pad (the most recently used controller,
721
+ * or the first connected one if nothing has been used yet). Pass
722
+ * `{ pad: index }` to read from a specific pad — useful for couch-co-op
723
+ * where each player's controller is addressed explicitly.
724
+ *
725
+ * Falls back to synthetic injection (`fireGamepadAxis`) when no pad is
726
+ * active — that's the test/probe path.
727
+ */
728
+ getStick(side, opts) {
729
+ const { x: xKey, y: yKey } = STICK_AXIS_KEYS[side];
730
+ const padIdx = opts?.pad !== void 0 ? opts.pad : this.activePadIndex;
731
+ let x;
732
+ let y;
733
+ if (padIdx !== null) {
734
+ x = this.gamepadAxisState.get(`${padIdx}:${xKey}`) ?? 0;
735
+ y = this.gamepadAxisState.get(`${padIdx}:${yKey}`) ?? 0;
736
+ } else {
737
+ x = this.syntheticAxisState.get(xKey) ?? 0;
738
+ y = this.syntheticAxisState.get(yKey) ?? 0;
360
739
  }
361
- if (button === 0) this._onKeyUp("MouseLeft");
362
- if (button === 1) this._onKeyUp("MouseMiddle");
363
- if (button === 2) this._onKeyUp("MouseRight");
740
+ const mag = Math.hypot(x, y);
741
+ if (mag < this.stickDeadzone) return import_core.Vec2.ZERO;
742
+ if (mag === 0) return import_core.Vec2.ZERO;
743
+ const adjustedMag = Math.min(
744
+ 1,
745
+ (mag - this.stickDeadzone) / (1 - this.stickDeadzone)
746
+ );
747
+ return new import_core.Vec2(x / mag * adjustedMag, y / mag * adjustedMag);
364
748
  }
365
- /** Store synthetic gamepad button state. */
366
- fireGamepadButton(idx, pressed) {
367
- this.gamepadButtons.set(idx, pressed);
749
+ /**
750
+ * Returns the deadzoned trigger value (0..1) for the given side.
751
+ * Reads from the active pad by default; use `{ pad: index }` for explicit
752
+ * per-pad reads. Falls back to synthetic state when no pad is active.
753
+ */
754
+ getTrigger(side, opts) {
755
+ const key = TRIGGER_AXIS_KEYS[side];
756
+ const padIdx = opts?.pad !== void 0 ? opts.pad : this.activePadIndex;
757
+ const v = padIdx !== null ? this.gamepadAxisState.get(`${padIdx}:${key}`) ?? 0 : this.syntheticAxisState.get(key) ?? 0;
758
+ if (v < this.triggerDeadzone) return 0;
759
+ return Math.min(1, (v - this.triggerDeadzone) / (1 - this.triggerDeadzone));
760
+ }
761
+ // -- Gamepad enumeration / events --
762
+ /**
763
+ * Synchronously poll `navigator.getGamepads()` for currently-connected pads.
764
+ * Use this rather than the cached event-driven list when you need ground
765
+ * truth — `gamepadconnected` doesn't fire until the user presses a button.
766
+ */
767
+ gamepads() {
768
+ if (typeof navigator === "undefined" || typeof navigator.getGamepads !== "function") {
769
+ return [];
770
+ }
771
+ const result = [];
772
+ for (const pad of navigator.getGamepads()) {
773
+ if (pad) result.push({ index: pad.index, id: pad.id });
774
+ }
775
+ return result;
776
+ }
777
+ /**
778
+ * Subscribe to gamepad-connected events. Replays currently-known pads
779
+ * synchronously so callers don't need a separate `gamepads()` call.
780
+ * Returns a disposer.
781
+ */
782
+ onGamepadConnected(fn) {
783
+ this.gamepadConnectListeners.push(fn);
784
+ for (const info of this.connectedPads.values()) fn(info);
785
+ return () => {
786
+ const idx = this.gamepadConnectListeners.indexOf(fn);
787
+ if (idx !== -1) this.gamepadConnectListeners.splice(idx, 1);
788
+ };
789
+ }
790
+ /** Subscribe to gamepad-disconnected events. Returns a disposer. */
791
+ onGamepadDisconnected(fn) {
792
+ this.gamepadDisconnectListeners.push(fn);
793
+ return () => {
794
+ const idx = this.gamepadDisconnectListeners.indexOf(fn);
795
+ if (idx !== -1) this.gamepadDisconnectListeners.splice(idx, 1);
796
+ };
797
+ }
798
+ // -- Active pad --
799
+ /**
800
+ * The pad whose analog input is read by default. Auto-promotes on input
801
+ * activity (button press or stick/trigger above deadzone) and on first
802
+ * connect. Returns `null` when no pad is connected.
803
+ */
804
+ getActivePad() {
805
+ if (this.activePadIndex === null) return null;
806
+ return this.connectedPads.get(this.activePadIndex) ?? null;
807
+ }
808
+ /**
809
+ * Manually set the active pad. Index must match a currently connected pad
810
+ * — pass an unknown index and the call is a no-op. Pass `null` to clear
811
+ * (analog reads will fall back to synthetic state if any).
812
+ */
813
+ setActivePad(index) {
814
+ if (index !== null && !this.connectedPads.has(index)) return;
815
+ this.setActivePadInternal(index);
816
+ }
817
+ /**
818
+ * Subscribe to active-pad changes. Replays the current active pad
819
+ * synchronously on subscribe so callers get the present state without a
820
+ * separate `getActivePad()` call. Returns a disposer.
821
+ */
822
+ onActivePadChanged(fn) {
823
+ this.activePadListeners.push(fn);
824
+ fn(this.getActivePad());
825
+ return () => {
826
+ const idx = this.activePadListeners.indexOf(fn);
827
+ if (idx !== -1) this.activePadListeners.splice(idx, 1);
828
+ };
829
+ }
830
+ setActivePadInternal(index) {
831
+ if (this.activePadIndex === index) return;
832
+ this.activePadIndex = index;
833
+ const info = this.getActivePad();
834
+ for (const fn of this.activePadListeners) fn(info);
835
+ }
836
+ // -- Gamepad runtime config --
837
+ /** Enable or disable real gamepad polling. Synthetic injection still works when disabled. */
838
+ setPollingEnabled(enabled) {
839
+ this.pollingEnabled = enabled;
840
+ }
841
+ /** Whether real gamepad polling is currently enabled. */
842
+ isPollingEnabled() {
843
+ return this.pollingEnabled;
844
+ }
845
+ /**
846
+ * Update analog deadzones at runtime. Either field may be omitted.
847
+ * Values are clamped to `[0, 0.999]` — capping below 1 keeps the rescaling
848
+ * denominator non-zero. Non-finite values are ignored.
849
+ */
850
+ setDeadzones(opts) {
851
+ if (opts.stick !== void 0 && Number.isFinite(opts.stick)) {
852
+ this.stickDeadzone = Math.max(0, Math.min(0.999, opts.stick));
853
+ }
854
+ if (opts.trigger !== void 0 && Number.isFinite(opts.trigger)) {
855
+ this.triggerDeadzone = Math.max(0, Math.min(0.999, opts.trigger));
856
+ }
857
+ }
858
+ /**
859
+ * Set the trigger button-edge threshold (default 0.5). Clamped to `[0, 1]`;
860
+ * non-finite values are ignored.
861
+ */
862
+ setTriggerThreshold(value) {
863
+ if (!Number.isFinite(value)) return;
864
+ this.triggerThreshold = Math.max(0, Math.min(1, value));
865
+ }
866
+ // -- Internal: polling and connect/disconnect plumbing --
867
+ /**
868
+ * @internal Force-release held gamepad buttons and clear real-pad analog
869
+ * snapshots. Used on tab-hide (where `navigator.getGamepads()` returns
870
+ * stale data) and on disconnect when polling is paused. Synthetic axes
871
+ * live in their own field, so they're untouched.
872
+ */
873
+ _releaseAllGamepadState() {
874
+ for (const code of [...this.lastButtonState.keys()]) {
875
+ this._applyKeyUp(code);
876
+ }
877
+ this.lastButtonState.clear();
878
+ this.gamepadAxisState.clear();
879
+ this.lastPadActivity.clear();
880
+ }
881
+ /** @internal Called by InputPlugin from `gamepadconnected` event or by
882
+ * polling when discovering a previously-unknown pad. Idempotent. */
883
+ _onGamepadConnected(info) {
884
+ if (this.connectedPads.has(info.index)) return;
885
+ this.connectedPads.set(info.index, info);
886
+ if (this.activePadIndex === null) {
887
+ this.setActivePadInternal(info.index);
888
+ }
889
+ for (const fn of this.gamepadConnectListeners) fn(info);
890
+ }
891
+ /** @internal Called by InputPlugin from `gamepaddisconnected` event or by
892
+ * polling when a pad vanishes silently. Idempotent. */
893
+ _onGamepadDisconnected(info) {
894
+ if (!this.connectedPads.has(info.index)) return;
895
+ this.connectedPads.delete(info.index);
896
+ for (const key of [...this.gamepadAxisState.keys()]) {
897
+ if (key.startsWith(`${info.index}:`)) this.gamepadAxisState.delete(key);
898
+ }
899
+ this.lastPadActivity.delete(info.index);
900
+ if (this.activePadIndex === info.index) {
901
+ const next = this.connectedPads.keys().next();
902
+ this.setActivePadInternal(next.done ? null : next.value);
903
+ }
904
+ if (this.pollingEnabled && typeof navigator !== "undefined" && typeof navigator.getGamepads === "function") {
905
+ this.reconcileButtonStateAcrossPads(navigator.getGamepads());
906
+ } else {
907
+ this._releaseAllGamepadState();
908
+ }
909
+ for (const fn of this.gamepadDisconnectListeners) fn(info);
910
+ }
911
+ /**
912
+ * @internal Poll real gamepads via `navigator.getGamepads()` and emit
913
+ * key-down/key-up edges for any aggregate state changes. Called by
914
+ * `InputPollSystem` once per frame.
915
+ */
916
+ _pollGamepads() {
917
+ if (typeof navigator === "undefined" || typeof navigator.getGamepads !== "function") {
918
+ return;
919
+ }
920
+ const pads = navigator.getGamepads();
921
+ const liveIndices = /* @__PURE__ */ new Set();
922
+ for (const pad of pads) {
923
+ if (!pad) continue;
924
+ liveIndices.add(pad.index);
925
+ if (!this.connectedPads.has(pad.index)) {
926
+ this._onGamepadConnected({ index: pad.index, id: pad.id });
927
+ }
928
+ }
929
+ for (const [, info] of [...this.connectedPads]) {
930
+ if (!liveIndices.has(info.index)) {
931
+ this._onGamepadDisconnected(info);
932
+ }
933
+ }
934
+ for (const key of [...this.gamepadAxisState.keys()]) {
935
+ const colon = key.indexOf(":");
936
+ if (colon === -1) continue;
937
+ const idx = Number.parseInt(key.slice(0, colon), 10);
938
+ if (!liveIndices.has(idx)) this.gamepadAxisState.delete(key);
939
+ }
940
+ const currentActivity = /* @__PURE__ */ new Map();
941
+ for (const pad of pads) {
942
+ if (!pad) continue;
943
+ const standard = pad.mapping === "standard";
944
+ if (standard) {
945
+ for (let axIdx = 0; axIdx < STANDARD_AXIS_KEYS.length; axIdx++) {
946
+ const axisKey = STANDARD_AXIS_KEYS[axIdx];
947
+ if (!axisKey) continue;
948
+ const raw = pad.axes[axIdx] ?? 0;
949
+ this.gamepadAxisState.set(
950
+ `${pad.index}:${axisKey}`,
951
+ Number.isFinite(raw) ? raw : 0
952
+ );
953
+ }
954
+ const lt = pad.buttons[TRIGGER_LEFT_INDEX]?.value ?? 0;
955
+ const rt = pad.buttons[TRIGGER_RIGHT_INDEX]?.value ?? 0;
956
+ this.gamepadAxisState.set(
957
+ `${pad.index}:leftTrigger`,
958
+ Number.isFinite(lt) ? lt : 0
959
+ );
960
+ this.gamepadAxisState.set(
961
+ `${pad.index}:rightTrigger`,
962
+ Number.isFinite(rt) ? rt : 0
963
+ );
964
+ }
965
+ currentActivity.set(pad.index, this.padHasActivity(pad));
966
+ }
967
+ const activeStillActive = this.activePadIndex !== null && (currentActivity.get(this.activePadIndex) ?? false);
968
+ if (!activeStillActive) {
969
+ for (const [padIdx, isActive] of currentActivity) {
970
+ const wasActive = this.lastPadActivity.get(padIdx) ?? false;
971
+ if (isActive && !wasActive && padIdx !== this.activePadIndex) {
972
+ this.setActivePadInternal(padIdx);
973
+ break;
974
+ }
975
+ }
976
+ }
977
+ for (const [padIdx, isActive] of currentActivity) {
978
+ this.lastPadActivity.set(padIdx, isActive);
979
+ }
980
+ this.reconcileButtonStateAcrossPads(pads);
981
+ }
982
+ /** Whether a pad has any input that should claim active-pad ownership. */
983
+ padHasActivity(pad) {
984
+ for (const btn of pad.buttons) {
985
+ if (btn?.pressed) return true;
986
+ }
987
+ if (pad.mapping === "standard") {
988
+ const lx = pad.axes[0] ?? 0;
989
+ const ly = pad.axes[1] ?? 0;
990
+ const rx = pad.axes[2] ?? 0;
991
+ const ry = pad.axes[3] ?? 0;
992
+ if (Math.hypot(lx, ly) > this.stickDeadzone) return true;
993
+ if (Math.hypot(rx, ry) > this.stickDeadzone) return true;
994
+ const lt = pad.buttons[TRIGGER_LEFT_INDEX]?.value ?? 0;
995
+ const rt = pad.buttons[TRIGGER_RIGHT_INDEX]?.value ?? 0;
996
+ if (lt > this.triggerDeadzone) return true;
997
+ if (rt > this.triggerDeadzone) return true;
998
+ }
999
+ return false;
368
1000
  }
369
- /** Store synthetic gamepad axis state. */
370
- fireGamepadAxis(idx, value) {
371
- this.gamepadAxes.set(idx, value);
1001
+ /**
1002
+ * Aggregate "any pad pressed" per code across the supplied pad list and
1003
+ * emit `_applyKeyDown`/`_applyKeyUp` edges. `lastButtonState` is updated
1004
+ * unconditionally so listen-mode interception doesn't cause held-button
1005
+ * re-fires on subsequent frames.
1006
+ */
1007
+ reconcileButtonStateAcrossPads(pads) {
1008
+ const codePressed = /* @__PURE__ */ new Map();
1009
+ for (const pad of pads) {
1010
+ if (!pad) continue;
1011
+ const standard = pad.mapping === "standard";
1012
+ const buttons = pad.buttons;
1013
+ for (let btnIdx = 0; btnIdx < buttons.length; btnIdx++) {
1014
+ const btn = buttons[btnIdx];
1015
+ if (!btn) continue;
1016
+ const standardCode = standard && btnIdx < STANDARD_BUTTON_CODES.length ? STANDARD_BUTTON_CODES[btnIdx] : void 0;
1017
+ const code = standardCode ?? `GamepadButton${btnIdx}`;
1018
+ const isTrigger = standard && (btnIdx === TRIGGER_LEFT_INDEX || btnIdx === TRIGGER_RIGHT_INDEX);
1019
+ const isDown = isTrigger ? btn.value >= this.triggerThreshold : btn.pressed;
1020
+ if (isDown) codePressed.set(code, true);
1021
+ }
1022
+ }
1023
+ const allCodes = /* @__PURE__ */ new Set([
1024
+ ...this.lastButtonState.keys(),
1025
+ ...codePressed.keys()
1026
+ ]);
1027
+ for (const code of allCodes) {
1028
+ const wasPressed = this.lastButtonState.get(code) ?? false;
1029
+ const isPressed = codePressed.get(code) ?? false;
1030
+ if (isPressed && !wasPressed) {
1031
+ this._applyKeyDown(code);
1032
+ } else if (!isPressed && wasPressed) {
1033
+ this._applyKeyUp(code);
1034
+ }
1035
+ if (isPressed) {
1036
+ this.lastButtonState.set(code, true);
1037
+ } else {
1038
+ this.lastButtonState.delete(code);
1039
+ }
1040
+ }
372
1041
  }
373
1042
  /** Inject a one-frame synthetic action pulse. */
374
1043
  fireAction(name) {
@@ -377,58 +1046,268 @@ var InputManager = class {
377
1046
  }
378
1047
  this.syntheticPressedActions.add(name);
379
1048
  this.syntheticActionStarts.set(name, this.elapsedMs);
1049
+ this.notifyActionListeners(this.actionListeners, name);
380
1050
  }
381
1051
  /** Release all synthetic and physical input state. */
382
1052
  clearAll() {
383
1053
  for (const code of [...this.pressedKeys]) {
384
- this._onKeyUp(code);
1054
+ this._applyKeyUp(code);
385
1055
  }
386
1056
  this.justPressedKeys.clear();
387
1057
  this.justReleasedKeys.clear();
388
1058
  this.holdStart.clear();
389
1059
  this.syntheticPressedActions.clear();
390
1060
  this.syntheticActionStarts.clear();
391
- this.pressedMouseButtons.clear();
392
- this.pointerDownState = false;
393
- this.gamepadButtons.clear();
394
- this.gamepadAxes.clear();
1061
+ this.pointers.clear();
1062
+ this.primaryPointerId = null;
1063
+ this.mouseButtonAggregate.clear();
1064
+ this.consumedPointers.clear();
1065
+ this.consumedWheelThisFrame = false;
1066
+ this.inputQueue.length = 0;
1067
+ this.lastButtonState.clear();
1068
+ this.gamepadAxisState.clear();
1069
+ this.syntheticAxisState.clear();
1070
+ this.lastPadActivity.clear();
395
1071
  }
396
- /** Release any pressed pointer buttons without touching keyboard state. */
1072
+ /**
1073
+ * Drop all tracked pointers and release the aggregate `MouseLeft/Middle/Right`
1074
+ * codes without touching keyboard or gamepad state. Useful for window-blur
1075
+ * / page-hide handling.
1076
+ */
397
1077
  clearPointerButtons() {
398
- for (const button of [...this.pressedMouseButtons]) {
399
- if (button === 0) this._onKeyUp("MouseLeft");
400
- if (button === 1) this._onKeyUp("MouseMiddle");
401
- if (button === 2) this._onKeyUp("MouseRight");
1078
+ for (const button of [...this.mouseButtonAggregate]) {
1079
+ const code = MOUSE_BUTTON_CODES[button];
1080
+ if (code) this._applyKeyUp(code);
402
1081
  }
403
- this.pressedMouseButtons.clear();
404
- this.pointerDownState = false;
1082
+ this.mouseButtonAggregate.clear();
1083
+ this.pointers.clear();
1084
+ this.primaryPointerId = null;
1085
+ this.consumedPointers.clear();
1086
+ this.inputQueue = this.inputQueue.filter(
1087
+ (e) => e.kind !== "pointerDown" && e.kind !== "pointerUp" && e.kind !== "pointerCancel"
1088
+ );
405
1089
  }
406
1090
  /** Snapshot of current held input state for inspector tooling. */
407
1091
  snapshotState() {
408
1092
  const cmp = /* @__PURE__ */ __name((a, b) => a < b ? -1 : a > b ? 1 : 0, "cmp");
409
1093
  const keys = [...this.pressedKeys].sort(cmp);
1094
+ const nonGamepadKeys = keys.filter((k) => !k.startsWith("Gamepad"));
1095
+ const gamepadButtons = keys.filter((k) => k.startsWith("Gamepad"));
410
1096
  const actions = this.getActionNames().filter((action) => this.isPressed(action)).sort(cmp);
411
- const buttons = [...this.pressedMouseButtons].sort((a, b) => a - b);
412
- const pressedButtons = [...this.gamepadButtons.entries()].filter(([, pressed]) => pressed).map(([idx]) => idx).sort((a, b) => a - b);
413
- const axes = [...this.gamepadAxes.entries()].filter(([, value]) => value !== 0).sort(([a], [b]) => a - b).map(([index, value]) => ({ index, value }));
1097
+ const aggregateButtons = [...this.mouseButtonAggregate].sort((a, b) => a - b);
1098
+ const pointers = [...this.pointers.values()].sort((a, b) => a.id - b.id).map((p) => ({
1099
+ id: p.id,
1100
+ x: p.screenPos.x,
1101
+ y: p.screenPos.y,
1102
+ type: p.type,
1103
+ isPrimary: p.isPrimary,
1104
+ buttons: [...p.buttons].sort((a, b) => a - b),
1105
+ down: p.isDown
1106
+ }));
1107
+ const primary = this.getPrimaryPointer();
1108
+ const realAxes = [...this.gamepadAxisState.entries()].filter(([, value]) => Math.abs(value) > 1e-3).map(([key, value]) => ({ key, value }));
1109
+ const syntheticAxes = [...this.syntheticAxisState.entries()].filter(([, value]) => Math.abs(value) > 1e-3).map(([key, value]) => ({ key: `synthetic:${key}`, value }));
1110
+ const axes = [...realAxes, ...syntheticAxes].sort((a, b) => cmp(a.key, b.key));
414
1111
  return {
415
- keys,
1112
+ keys: nonGamepadKeys,
416
1113
  actions,
417
1114
  mouse: {
418
- x: this.pointerScreenPos.x,
419
- y: this.pointerScreenPos.y,
420
- buttons,
421
- down: this.pointerDownState
1115
+ x: primary?.screenPos.x ?? 0,
1116
+ y: primary?.screenPos.y ?? 0,
1117
+ buttons: aggregateButtons,
1118
+ down: this.mouseButtonAggregate.size > 0
422
1119
  },
1120
+ pointers,
423
1121
  gamepad: {
424
- buttons: pressedButtons,
1122
+ buttons: gamepadButtons,
425
1123
  axes
426
1124
  }
427
1125
  };
428
1126
  }
429
- // -- Internal methods (called by InputPlugin / Systems) --
1127
+ // -- Internal: DOM-handler enqueue path --
1128
+ /**
1129
+ * @internal Stash the renderer adapter so the drain step can call its
1130
+ * optional `hitTestUI(x, y)` for the auto-consume fallback. Called by
1131
+ * `InputPlugin.install`.
1132
+ */
1133
+ _setRenderer(renderer) {
1134
+ this.renderer = renderer;
1135
+ }
430
1136
  /** @internal */
431
- _onKeyDown(code) {
1137
+ _enqueueKeyDown(code) {
1138
+ this.inputQueue.push({ kind: "keyDown", code });
1139
+ }
1140
+ /** @internal */
1141
+ _enqueueKeyUp(code) {
1142
+ this.inputQueue.push({ kind: "keyUp", code });
1143
+ }
1144
+ /**
1145
+ * @internal Sync portion: upsert the pointer entry (existence, screenPos,
1146
+ * type, isPrimary, primaryPointerId) and notify pointerMoveListeners so
1147
+ * pointer-tracking UIs see live cursor positions. Move events do not carry
1148
+ * action-map edges, so they are not queued.
1149
+ */
1150
+ _enqueuePointerMove(info) {
1151
+ const pointer = this.upsertPointer(info);
1152
+ pointer.screenPos = new import_core.Vec2(info.screenX, info.screenY);
1153
+ this.notifyPointerListeners(this.pointerMoveListeners, pointer);
1154
+ }
1155
+ /**
1156
+ * @internal Sync portion: upsert pointer (existence, screenPos, type,
1157
+ * isPrimary, primaryPointerId) and notify pointerDownListeners. Button
1158
+ * mutation, action-map edges, and mouse-aggregate emit are deferred to the
1159
+ * next drain at `Phase.EarlyUpdate` so {@link consumePointer} (or the
1160
+ * renderer's UI hit-test) can suppress them, AND so a same-frame
1161
+ * down+up that arrives before drain still produces the correct
1162
+ * `MouseLeft` press/release edges (recomputing aggregate from live state
1163
+ * after sync mutation would silently drop the transient transition).
1164
+ *
1165
+ * Listeners therefore observe `pointer.buttons` BEFORE this event's edge is
1166
+ * applied. That's a documented tradeoff: the canonical event-button info
1167
+ * is in the `FederatedPointerEvent` / `PointerEvent` the user's Pixi
1168
+ * handler already receives, so the lossy `info.buttons` view rarely
1169
+ * matters in practice.
1170
+ */
1171
+ _enqueuePointerDown(info) {
1172
+ const pointer = this.upsertPointer(info);
1173
+ pointer.screenPos = new import_core.Vec2(info.screenX, info.screenY);
1174
+ this.notifyPointerListeners(this.pointerDownListeners, pointer);
1175
+ this.inputQueue.push({ kind: "pointerDown", info });
1176
+ }
1177
+ /** @internal */
1178
+ _enqueuePointerUp(info) {
1179
+ const pointer = this.upsertPointer(info);
1180
+ pointer.screenPos = new import_core.Vec2(info.screenX, info.screenY);
1181
+ this.notifyPointerListeners(this.pointerUpListeners, pointer);
1182
+ this.inputQueue.push({ kind: "pointerUp", info });
1183
+ }
1184
+ /** @internal */
1185
+ _enqueuePointerCancel(id) {
1186
+ const pointer = this.pointers.get(id);
1187
+ if (pointer) {
1188
+ this.notifyPointerListeners(this.pointerUpListeners, pointer);
1189
+ }
1190
+ this.inputQueue.push({ kind: "pointerCancel", id });
1191
+ }
1192
+ /** @internal */
1193
+ _enqueueWheel(dx, dy) {
1194
+ for (const fn of [...this.wheelListeners]) fn(dx, dy);
1195
+ this.inputQueue.push({ kind: "wheel", dx, dy });
1196
+ }
1197
+ /**
1198
+ * @internal Drain queued DOM events at `Phase.EarlyUpdate`. Each event
1199
+ * applies its deferred state (button mutations, action-map edges,
1200
+ * mouse-aggregate transitions). Consumed pointers are excluded from the
1201
+ * mouse aggregate so UI-claimed presses do not propagate to gameplay
1202
+ * actions. The renderer's optional `hitTestUI(x, y)` auto-claims a pointer
1203
+ * whose `pointerdown` lands on a UI-marked container.
1204
+ */
1205
+ _drainInputQueue() {
1206
+ if (this.inputQueue.length === 0) return;
1207
+ const queue = this.inputQueue;
1208
+ this.inputQueue = [];
1209
+ for (const event of queue) {
1210
+ switch (event.kind) {
1211
+ case "keyDown":
1212
+ this._applyKeyDown(event.code);
1213
+ break;
1214
+ case "keyUp":
1215
+ this._applyKeyUp(event.code);
1216
+ break;
1217
+ case "pointerDown":
1218
+ this.drainPointerDown(event.info);
1219
+ break;
1220
+ case "pointerUp":
1221
+ this.drainPointerUp(event.info);
1222
+ break;
1223
+ case "pointerCancel":
1224
+ this.drainPointerCancel(event.id);
1225
+ break;
1226
+ case "wheel":
1227
+ this.applyWheelEdges(event.dx, event.dy);
1228
+ break;
1229
+ }
1230
+ }
1231
+ }
1232
+ drainPointerDown(info) {
1233
+ if (!this.consumedPointers.has(info.id) && this.renderer?.hitTestUI?.(info.screenX, info.screenY)) {
1234
+ this.consumedPointers.add(info.id);
1235
+ }
1236
+ const pointer = this.pointers.get(info.id);
1237
+ if (!pointer) return;
1238
+ if (info.button >= 0 && info.button <= 2) {
1239
+ pointer.buttons.add(info.button);
1240
+ pointer.isDown = true;
1241
+ this.recomputeMouseAggregate(info.button);
1242
+ } else {
1243
+ pointer.isDown = pointer.buttons.size > 0;
1244
+ }
1245
+ }
1246
+ drainPointerUp(info) {
1247
+ const pointer = this.pointers.get(info.id);
1248
+ if (!pointer) return;
1249
+ if (info.button >= 0 && info.button <= 2) {
1250
+ pointer.buttons.delete(info.button);
1251
+ this.recomputeMouseAggregate(info.button);
1252
+ }
1253
+ pointer.isDown = pointer.buttons.size > 0;
1254
+ if (!pointer.isDown) {
1255
+ this.consumedPointers.delete(info.id);
1256
+ if (pointer.type !== "mouse") {
1257
+ this.removePointer(pointer.id);
1258
+ }
1259
+ }
1260
+ }
1261
+ drainPointerCancel(id) {
1262
+ const pointer = this.pointers.get(id);
1263
+ if (!pointer) return;
1264
+ const heldButtons = [...pointer.buttons];
1265
+ pointer.buttons.clear();
1266
+ pointer.isDown = false;
1267
+ for (const button of heldButtons) {
1268
+ this.recomputeMouseAggregate(button);
1269
+ }
1270
+ this.consumedPointers.delete(id);
1271
+ if (pointer.type !== "mouse") {
1272
+ this.removePointer(id);
1273
+ }
1274
+ }
1275
+ applyWheelEdges(dx, dy) {
1276
+ if (this.consumedWheelThisFrame) return;
1277
+ if (Math.abs(dy) > 1e-3) {
1278
+ const code = dy < 0 ? "WheelUp" : "WheelDown";
1279
+ this.fireOneFrameEdge(code);
1280
+ }
1281
+ if (Math.abs(dx) > 1e-3) {
1282
+ const code = dx < 0 ? "WheelLeft" : "WheelRight";
1283
+ this.fireOneFrameEdge(code);
1284
+ }
1285
+ }
1286
+ /**
1287
+ * Add a code to `justPressedKeys` without entering `pressedKeys`. Used for
1288
+ * discrete edges (wheel ticks) that are never "held". Listeners and
1289
+ * `listenForNextKey` still fire as usual.
1290
+ */
1291
+ fireOneFrameEdge(code) {
1292
+ if (this.listenResolve) {
1293
+ const resolve = this.listenResolve;
1294
+ this.listenResolve = null;
1295
+ resolve(code);
1296
+ return;
1297
+ }
1298
+ this.justPressedKeys.add(code);
1299
+ this.notifyKeyListeners(this.keyDownListeners, this.keyDownListenersAny, code);
1300
+ for (const action of this.actionsForCode(code)) {
1301
+ this.notifyActionListeners(this.actionListeners, action);
1302
+ }
1303
+ }
1304
+ // -- Internal: synthetic / sync apply path --
1305
+ /**
1306
+ * @internal Synthetic key-down. DOM-originated events must use
1307
+ * {@link _enqueueKeyDown} so `consumePointer` and the UI hit-test fallback
1308
+ * have a chance to run before action edges fire.
1309
+ */
1310
+ _applyKeyDown(code) {
432
1311
  if (this.listenResolve) {
433
1312
  const resolve = this.listenResolve;
434
1313
  this.listenResolve = null;
@@ -439,27 +1318,190 @@ var InputManager = class {
439
1318
  this.pressedKeys.add(code);
440
1319
  this.justPressedKeys.add(code);
441
1320
  this.holdStart.set(code, this.elapsedMs);
1321
+ this.notifyKeyListeners(this.keyDownListeners, this.keyDownListenersAny, code);
1322
+ for (const action of this.actionsForCode(code)) {
1323
+ this.notifyActionListeners(this.actionListeners, action);
1324
+ }
442
1325
  }
443
1326
  }
444
- /** @internal */
445
- _onKeyUp(code) {
1327
+ /**
1328
+ * @internal Synthetic key-up. DOM-originated events must use
1329
+ * {@link _enqueueKeyUp}.
1330
+ */
1331
+ _applyKeyUp(code) {
446
1332
  if (this.pressedKeys.has(code)) {
447
1333
  this.pressedKeys.delete(code);
448
1334
  this.justReleasedKeys.add(code);
449
1335
  this.holdStart.delete(code);
1336
+ this.notifyKeyListeners(this.keyUpListeners, this.keyUpListenersAny, code);
1337
+ for (const action of this.actionsForCode(code)) {
1338
+ this.notifyActionListeners(this.actionReleasedListeners, action);
1339
+ }
450
1340
  }
451
1341
  }
452
- /** @internal */
453
- _onPointerMove(screenX, screenY) {
454
- this.pointerScreenPos = new import_core.Vec2(screenX, screenY);
1342
+ /**
1343
+ * @internal Synthetic pointer move. DOM-originated events must use
1344
+ * {@link _enqueuePointerMove}.
1345
+ */
1346
+ _applyPointerMove(info) {
1347
+ const pointer = this.upsertPointer(info);
1348
+ pointer.screenPos = new import_core.Vec2(info.screenX, info.screenY);
1349
+ this.notifyPointerListeners(this.pointerMoveListeners, pointer);
455
1350
  }
456
- /** @internal */
457
- _onPointerDown() {
458
- this.pointerDownState = true;
1351
+ /**
1352
+ * @internal Synthetic pointer down. DOM-originated events must use
1353
+ * {@link _enqueuePointerDown}. This applies all state (button mutation,
1354
+ * mouse-aggregate emit, listener notify) synchronously.
1355
+ */
1356
+ _applyPointerDown(info) {
1357
+ const pointer = this.upsertPointer(info);
1358
+ pointer.screenPos = new import_core.Vec2(info.screenX, info.screenY);
1359
+ if (info.button >= 0 && info.button <= 2) {
1360
+ pointer.buttons.add(info.button);
1361
+ pointer.isDown = true;
1362
+ this.recomputeMouseAggregate(info.button);
1363
+ } else {
1364
+ pointer.isDown = pointer.buttons.size > 0;
1365
+ }
1366
+ this.notifyPointerListeners(this.pointerDownListeners, pointer);
459
1367
  }
460
- /** @internal */
461
- _onPointerUp() {
462
- this.pointerDownState = false;
1368
+ /**
1369
+ * @internal Synthetic pointer up. DOM-originated events must use
1370
+ * {@link _enqueuePointerUp}.
1371
+ */
1372
+ _applyPointerUp(info) {
1373
+ const pointer = this.upsertPointer(info);
1374
+ pointer.screenPos = new import_core.Vec2(info.screenX, info.screenY);
1375
+ if (info.button >= 0 && info.button <= 2) {
1376
+ pointer.buttons.delete(info.button);
1377
+ this.recomputeMouseAggregate(info.button);
1378
+ }
1379
+ pointer.isDown = pointer.buttons.size > 0;
1380
+ this.notifyPointerListeners(this.pointerUpListeners, pointer);
1381
+ if (!pointer.isDown) {
1382
+ this.consumedPointers.delete(info.id);
1383
+ if (pointer.type !== "mouse") {
1384
+ this.removePointer(pointer.id);
1385
+ }
1386
+ }
1387
+ }
1388
+ /**
1389
+ * @internal Synthetic pointer cancel. Clears all buttons on the pointer,
1390
+ * fires up-listeners, and drops the entry (unless it's a mouse). Mirrors
1391
+ * the drain-time {@link drainPointerCancel} logic.
1392
+ */
1393
+ _applyPointerCancel(id) {
1394
+ const pointer = this.pointers.get(id);
1395
+ if (!pointer) return;
1396
+ const heldButtons = [...pointer.buttons];
1397
+ pointer.buttons.clear();
1398
+ pointer.isDown = false;
1399
+ for (const button of heldButtons) {
1400
+ this.recomputeMouseAggregate(button);
1401
+ }
1402
+ this.notifyPointerListeners(this.pointerUpListeners, pointer);
1403
+ this.consumedPointers.delete(id);
1404
+ if (pointer.type !== "mouse") {
1405
+ this.removePointer(id);
1406
+ }
1407
+ }
1408
+ upsertPointer(info) {
1409
+ let pointer = this.pointers.get(info.id);
1410
+ if (!pointer) {
1411
+ pointer = {
1412
+ id: info.id,
1413
+ screenPos: new import_core.Vec2(info.screenX, info.screenY),
1414
+ type: info.type,
1415
+ isPrimary: info.isPrimary,
1416
+ buttons: /* @__PURE__ */ new Set(),
1417
+ isDown: false
1418
+ };
1419
+ this.pointers.set(info.id, pointer);
1420
+ } else {
1421
+ pointer.type = info.type;
1422
+ pointer.isPrimary = info.isPrimary;
1423
+ }
1424
+ if (info.isPrimary) {
1425
+ this.primaryPointerId = info.id;
1426
+ } else if (this.primaryPointerId === null) {
1427
+ this.primaryPointerId = info.id;
1428
+ }
1429
+ return pointer;
1430
+ }
1431
+ removePointer(id) {
1432
+ this.pointers.delete(id);
1433
+ if (this.primaryPointerId === id) {
1434
+ let next = null;
1435
+ for (const p of this.pointers.values()) {
1436
+ if (p.isPrimary) {
1437
+ next = p.id;
1438
+ break;
1439
+ }
1440
+ if (next === null) next = p.id;
1441
+ }
1442
+ this.primaryPointerId = next;
1443
+ }
1444
+ }
1445
+ /**
1446
+ * Recompute the `MouseLeft/Middle/Right` aggregate edge for `button`.
1447
+ * Consumed pointers are excluded so a UI-claimed press never propagates to
1448
+ * gameplay actions, even if a second non-UI pointer simultaneously holds
1449
+ * the same button.
1450
+ */
1451
+ recomputeMouseAggregate(button) {
1452
+ const code = MOUSE_BUTTON_CODES[button];
1453
+ if (!code) return;
1454
+ let nowAny = false;
1455
+ for (const p of this.pointers.values()) {
1456
+ if (this.consumedPointers.has(p.id)) continue;
1457
+ if (p.buttons.has(button)) {
1458
+ nowAny = true;
1459
+ break;
1460
+ }
1461
+ }
1462
+ const wasAny = this.mouseButtonAggregate.has(button);
1463
+ if (nowAny && !wasAny) {
1464
+ this.mouseButtonAggregate.add(button);
1465
+ this._applyKeyDown(code);
1466
+ } else if (!nowAny && wasAny) {
1467
+ this.mouseButtonAggregate.delete(button);
1468
+ this._applyKeyUp(code);
1469
+ }
1470
+ }
1471
+ notifyPointerListeners(listeners, pointer) {
1472
+ if (listeners.length === 0) return;
1473
+ const info = this.toPointerInfo(pointer);
1474
+ for (const fn of [...listeners]) {
1475
+ fn(info);
1476
+ }
1477
+ }
1478
+ notifyKeyListeners(perCode, anyList, code) {
1479
+ const list = perCode.get(code);
1480
+ if (list) {
1481
+ for (const fn of [...list]) fn(code);
1482
+ }
1483
+ if (anyList.length > 0) {
1484
+ for (const fn of [...anyList]) fn(code);
1485
+ }
1486
+ }
1487
+ notifyActionListeners(perAction, name) {
1488
+ const list = perAction.get(name);
1489
+ if (!list) return;
1490
+ for (const fn of [...list]) fn(name);
1491
+ }
1492
+ /**
1493
+ * Action names that include `code` in their bindings AND whose group is
1494
+ * currently enabled. Used for `onAction` / `onActionReleased` listener
1495
+ * fan-out so disabled-group suppression matches `isPressed` behavior.
1496
+ */
1497
+ actionsForCode(code) {
1498
+ const result = [];
1499
+ for (const [action, keys] of this.actionMap) {
1500
+ if (keys.includes(code) && this.isActionEnabled(action)) {
1501
+ result.push(action);
1502
+ }
1503
+ }
1504
+ return result;
463
1505
  }
464
1506
  /** @internal Clear per-frame justPressed/justReleased flags. */
465
1507
  _clearFrameState() {
@@ -467,6 +1509,7 @@ var InputManager = class {
467
1509
  this.justReleasedKeys.clear();
468
1510
  this.syntheticPressedActions.clear();
469
1511
  this.syntheticActionStarts.clear();
1512
+ this.consumedWheelThisFrame = false;
470
1513
  }
471
1514
  /** Set camera for pointer world-coord conversion. */
472
1515
  setCamera(camera) {
@@ -484,6 +1527,31 @@ var InputManager = class {
484
1527
  _advanceTime(dtMs) {
485
1528
  this.elapsedMs += dtMs;
486
1529
  }
1530
+ // -- Internal: sync-path aliases (back-compat with pre-0.5.x test callers) --
1531
+ /** @internal Sync alias — see {@link _applyKeyDown}. */
1532
+ _onKeyDown(code) {
1533
+ this._applyKeyDown(code);
1534
+ }
1535
+ /** @internal Sync alias — see {@link _applyKeyUp}. */
1536
+ _onKeyUp(code) {
1537
+ this._applyKeyUp(code);
1538
+ }
1539
+ /** @internal Sync alias — see {@link _applyPointerMove}. */
1540
+ _onPointerMove(info) {
1541
+ this._applyPointerMove(info);
1542
+ }
1543
+ /** @internal Sync alias — see {@link _applyPointerDown}. */
1544
+ _onPointerDown(info) {
1545
+ this._applyPointerDown(info);
1546
+ }
1547
+ /** @internal Sync alias — see {@link _applyPointerUp}. */
1548
+ _onPointerUp(info) {
1549
+ this._applyPointerUp(info);
1550
+ }
1551
+ /** @internal Sync alias — see {@link _applyPointerCancel}. */
1552
+ _onPointerCancel(id) {
1553
+ this._applyPointerCancel(id);
1554
+ }
487
1555
  };
488
1556
 
489
1557
  // src/types.ts
@@ -499,7 +1567,12 @@ var InputPollSystem = class extends import_core3.System {
499
1567
  phase = import_core3.Phase.EarlyUpdate;
500
1568
  priority = -100;
501
1569
  update(dt) {
502
- this.use(InputManagerKey)._advanceTime(dt);
1570
+ const manager = this.use(InputManagerKey);
1571
+ manager._drainInputQueue();
1572
+ manager._advanceTime(dt);
1573
+ if (manager.isPollingEnabled()) {
1574
+ manager._pollGamepads();
1575
+ }
503
1576
  }
504
1577
  };
505
1578
 
@@ -554,11 +1627,6 @@ var InputDebugContributor = class {
554
1627
  };
555
1628
 
556
1629
  // src/InputPlugin.ts
557
- var MOUSE_BUTTON_MAP = {
558
- 0: "MouseLeft",
559
- 1: "MouseMiddle",
560
- 2: "MouseRight"
561
- };
562
1630
  var InputPlugin = class {
563
1631
  static {
564
1632
  __name(this, "InputPlugin");
@@ -581,9 +1649,19 @@ var InputPlugin = class {
581
1649
  if (this.config.groups) {
582
1650
  this.manager.setGroups(this.config.groups);
583
1651
  }
1652
+ if (this.config.deadzones) {
1653
+ this.manager.setDeadzones(this.config.deadzones);
1654
+ }
1655
+ if (this.config.triggerThreshold !== void 0) {
1656
+ this.manager.setTriggerThreshold(this.config.triggerThreshold);
1657
+ }
1658
+ if (this.config.pollGamepads === false) {
1659
+ this.manager.setPollingEnabled(false);
1660
+ }
584
1661
  const rendererKey = this.config.rendererKey ?? import_core5.RendererAdapterKey;
585
1662
  const renderer = context.tryResolve(rendererKey);
586
1663
  const pointerTarget = this.config.target ?? renderer?.canvas ?? document;
1664
+ this.manager._setRenderer(renderer ?? null);
587
1665
  const coordinateElement = renderer?.canvas ?? this.config.target ?? null;
588
1666
  const mapPointer = /* @__PURE__ */ __name((cssX, cssY) => renderer?.canvasToVirtual?.(cssX, cssY) ?? { x: cssX, y: cssY }, "mapPointer");
589
1667
  const preventSet = new Set(this.config.preventDefaultKeys ?? []);
@@ -591,12 +1669,12 @@ var InputPlugin = class {
591
1669
  const ke = e;
592
1670
  if (ke.repeat) return;
593
1671
  if (preventSet.has(ke.code)) ke.preventDefault();
594
- this.manager._onKeyDown(ke.code);
1672
+ this.manager._enqueueKeyDown(ke.code);
595
1673
  }, "onKeyDown");
596
1674
  const onKeyUp = /* @__PURE__ */ __name((e) => {
597
1675
  const ke = e;
598
1676
  if (preventSet.has(ke.code)) ke.preventDefault();
599
- this.manager._onKeyUp(ke.code);
1677
+ this.manager._enqueueKeyUp(ke.code);
600
1678
  }, "onKeyUp");
601
1679
  window.addEventListener("keydown", onKeyDown);
602
1680
  window.addEventListener("keyup", onKeyUp);
@@ -604,8 +1682,7 @@ var InputPlugin = class {
604
1682
  () => window.removeEventListener("keydown", onKeyDown),
605
1683
  () => window.removeEventListener("keyup", onKeyUp)
606
1684
  );
607
- const onPointerMove = /* @__PURE__ */ __name((e) => {
608
- const pe = e;
1685
+ const buildInfo = /* @__PURE__ */ __name((pe) => {
609
1686
  let cssX;
610
1687
  let cssY;
611
1688
  if (coordinateElement) {
@@ -617,39 +1694,96 @@ var InputPlugin = class {
617
1694
  cssY = pe.clientY;
618
1695
  }
619
1696
  const mapped = mapPointer(cssX, cssY);
620
- this.manager._onPointerMove(mapped.x, mapped.y);
1697
+ const rawType = pe.pointerType;
1698
+ const type = rawType === "touch" || rawType === "pen" ? rawType : "mouse";
1699
+ return {
1700
+ id: pe.pointerId,
1701
+ screenX: mapped.x,
1702
+ screenY: mapped.y,
1703
+ type,
1704
+ isPrimary: pe.isPrimary,
1705
+ button: pe.button
1706
+ };
1707
+ }, "buildInfo");
1708
+ const onPointerMove = /* @__PURE__ */ __name((e) => {
1709
+ this.manager._enqueuePointerMove(buildInfo(e));
621
1710
  }, "onPointerMove");
622
1711
  const onPointerDown = /* @__PURE__ */ __name((e) => {
623
- const pe = e;
624
- const button = pe.button;
625
- if (button in MOUSE_BUTTON_MAP) {
626
- this.manager.firePointerDown(button);
627
- } else {
628
- this.manager._onPointerDown();
629
- }
1712
+ this.manager._enqueuePointerDown(buildInfo(e));
630
1713
  }, "onPointerDown");
631
1714
  const onPointerUp = /* @__PURE__ */ __name((e) => {
632
- const pe = e;
633
- const button = pe.button;
634
- if (button in MOUSE_BUTTON_MAP) {
635
- this.manager.firePointerUp(button);
636
- } else {
637
- this.manager._onPointerUp();
638
- }
1715
+ this.manager._enqueuePointerUp(buildInfo(e));
639
1716
  }, "onPointerUp");
640
- const onPointerCancel = /* @__PURE__ */ __name(() => {
641
- this.manager.clearPointerButtons();
1717
+ const onPointerCancel = /* @__PURE__ */ __name((e) => {
1718
+ const pe = e;
1719
+ this.manager._enqueuePointerCancel(pe.pointerId);
642
1720
  }, "onPointerCancel");
1721
+ const onPointerLeave = /* @__PURE__ */ __name((e) => {
1722
+ const pe = e;
1723
+ this.manager._enqueuePointerCancel(pe.pointerId);
1724
+ }, "onPointerLeave");
643
1725
  pointerTarget.addEventListener("pointerdown", onPointerDown);
644
1726
  window.addEventListener("pointermove", onPointerMove);
645
1727
  window.addEventListener("pointerup", onPointerUp);
646
1728
  window.addEventListener("pointercancel", onPointerCancel);
1729
+ pointerTarget.addEventListener("pointerleave", onPointerLeave);
647
1730
  this.cleanupFns.push(
648
1731
  () => pointerTarget.removeEventListener("pointerdown", onPointerDown),
649
1732
  () => window.removeEventListener("pointermove", onPointerMove),
650
1733
  () => window.removeEventListener("pointerup", onPointerUp),
651
- () => window.removeEventListener("pointercancel", onPointerCancel)
1734
+ () => window.removeEventListener("pointercancel", onPointerCancel),
1735
+ () => pointerTarget.removeEventListener("pointerleave", onPointerLeave)
1736
+ );
1737
+ const wheelInvertY = this.config.wheelInvertY === true;
1738
+ const preventDefaultWheel = this.config.preventDefaultWheel === true;
1739
+ const onWheel = /* @__PURE__ */ __name((e) => {
1740
+ const we = e;
1741
+ if (preventDefaultWheel) we.preventDefault();
1742
+ const dy = wheelInvertY ? -we.deltaY : we.deltaY;
1743
+ this.manager._enqueueWheel(we.deltaX, dy);
1744
+ }, "onWheel");
1745
+ pointerTarget.addEventListener(
1746
+ "wheel",
1747
+ onWheel,
1748
+ preventDefaultWheel ? { passive: false } : void 0
652
1749
  );
1750
+ this.cleanupFns.push(
1751
+ () => pointerTarget.removeEventListener("wheel", onWheel)
1752
+ );
1753
+ const onGamepadConnected = /* @__PURE__ */ __name((e) => {
1754
+ const ge = e;
1755
+ if (!ge.gamepad) return;
1756
+ this.manager._onGamepadConnected({
1757
+ index: ge.gamepad.index,
1758
+ id: ge.gamepad.id
1759
+ });
1760
+ }, "onGamepadConnected");
1761
+ const onGamepadDisconnected = /* @__PURE__ */ __name((e) => {
1762
+ const ge = e;
1763
+ if (!ge.gamepad) return;
1764
+ this.manager._onGamepadDisconnected({
1765
+ index: ge.gamepad.index,
1766
+ id: ge.gamepad.id
1767
+ });
1768
+ }, "onGamepadDisconnected");
1769
+ window.addEventListener("gamepadconnected", onGamepadConnected);
1770
+ window.addEventListener("gamepaddisconnected", onGamepadDisconnected);
1771
+ this.cleanupFns.push(
1772
+ () => window.removeEventListener("gamepadconnected", onGamepadConnected),
1773
+ () => window.removeEventListener("gamepaddisconnected", onGamepadDisconnected)
1774
+ );
1775
+ if (typeof document !== "undefined") {
1776
+ const onVisibilityChange = /* @__PURE__ */ __name(() => {
1777
+ if (document.visibilityState === "hidden") {
1778
+ this.manager._releaseAllGamepadState();
1779
+ this.manager.clearPointerButtons();
1780
+ }
1781
+ }, "onVisibilityChange");
1782
+ document.addEventListener("visibilitychange", onVisibilityChange);
1783
+ this.cleanupFns.push(
1784
+ () => document.removeEventListener("visibilitychange", onVisibilityChange)
1785
+ );
1786
+ }
653
1787
  context.register(InputManagerKey, this.manager);
654
1788
  }
655
1789
  registerSystems(scheduler) {
@@ -785,10 +1919,33 @@ var KEY_DISPLAY_NAMES = {
785
1919
  // Mouse buttons (synthetic codes from InputPlugin)
786
1920
  MouseLeft: "Left Click",
787
1921
  MouseMiddle: "Middle Click",
788
- MouseRight: "Right Click"
1922
+ MouseRight: "Right Click",
1923
+ // Gamepad buttons (synthetic codes from InputPollSystem; standard mapping)
1924
+ GamepadA: "A",
1925
+ GamepadB: "B",
1926
+ GamepadX: "X",
1927
+ GamepadY: "Y",
1928
+ GamepadLB: "LB",
1929
+ GamepadRB: "RB",
1930
+ GamepadLT: "LT",
1931
+ GamepadRT: "RT",
1932
+ GamepadSelect: "Select",
1933
+ GamepadStart: "Start",
1934
+ GamepadLeftStick: "Left Stick",
1935
+ GamepadRightStick: "Right Stick",
1936
+ GamepadDPadUp: "D-Pad Up",
1937
+ GamepadDPadDown: "D-Pad Down",
1938
+ GamepadDPadLeft: "D-Pad Left",
1939
+ GamepadDPadRight: "D-Pad Right",
1940
+ GamepadHome: "Home"
789
1941
  };
1942
+ var GAMEPAD_BUTTON_FALLBACK = /^GamepadButton(\d+)$/;
790
1943
  function getKeyDisplayName(code) {
791
- return KEY_DISPLAY_NAMES[code] ?? code;
1944
+ const direct = KEY_DISPLAY_NAMES[code];
1945
+ if (direct !== void 0) return direct;
1946
+ const fallback = GAMEPAD_BUTTON_FALLBACK.exec(code);
1947
+ if (fallback) return `Gamepad Button ${fallback[1]}`;
1948
+ return code;
792
1949
  }
793
1950
  __name(getKeyDisplayName, "getKeyDisplayName");
794
1951
  // Annotate the CommonJS export names for ESM import in node: