three-gamepad-controls 0.12.0 → 0.13.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/README.md CHANGED
@@ -4,54 +4,58 @@ Gamepad support for [Three.js](https://threejs.org) controls, built on top of [W
4
4
 
5
5
  ## Architecture
6
6
 
7
- ```mermaid
8
- flowchart TD
9
- GamepadManager["GamepadManager<br/>(internal polling and active device lifecycle)"]
10
- GamepadInput["GamepadInput<br/>(public low-level input state)"]
11
- GamepadControls["GamepadControls<br/>(abstract Three.js wrapper base)"]
12
- Wrappers["Specific wrappers<br/>(Orbit, Map, Fly, Transform, etc.)"]
13
-
14
- GamepadManager --> GamepadInput
15
- GamepadInput --> GamepadControls
16
- GamepadControls --> Wrappers
17
- ```
7
+ ![Architecture diagram showing GamepadManager used by GamepadInput, GamepadInput used by GamepadControls, and specific wrappers extending GamepadControls.](./assets/architecture-diagram.webp "Three.js Gamepad Controls architecture")
18
8
 
19
9
  ## 📦 Installation
20
10
 
21
11
  npm:
22
12
 
23
13
  ```bash
24
- npm i three-gamepad-controls
14
+ npm i three three-gamepad-controls
15
+ npm i -D @types/three # optional: for TypeScript projects
25
16
  ```
26
17
 
27
18
  pnpm:
28
19
 
29
20
  ```bash
30
- pnpm add three-gamepad-controls
21
+ pnpm add three three-gamepad-controls
22
+ pnpm add -D @types/three # optional: for TypeScript projects
31
23
  ```
32
24
 
33
25
  Yarn:
34
26
 
35
27
  ```bash
36
- yarn add three-gamepad-controls
28
+ yarn add three three-gamepad-controls
29
+ yarn add -D @types/three # optional: for TypeScript projects
37
30
  ```
38
31
 
39
32
  Deno:
40
33
 
34
+ When using `deno.json`, Deno stores dependencies in `imports` and does not separate `devDependencies`; `-D` only applies when writing to `package.json`.
35
+
36
+ ```bash
37
+ # deno.json
38
+ deno add three @types/three three-gamepad-controls
39
+ ```
40
+
41
41
  ```bash
42
- deno add npm:three-gamepad-controls
42
+ # package.json
43
+ deno add --package-json three three-gamepad-controls
44
+ deno add --package-json -D @types/three # optional: for TypeScript projects
43
45
  ```
44
46
 
45
47
  Bun:
46
48
 
47
49
  ```bash
48
- bun add three-gamepad-controls
50
+ bun add three three-gamepad-controls
51
+ bun add -d @types/three # optional: for TypeScript projects
49
52
  ```
50
53
 
51
54
  ## 📖 Documentation
52
55
 
53
56
  - [Core](./docs/core.md) — The fundamental building blocks.
54
57
  - [GamepadInput](./docs/gamepad-input.md) - Low-level reader for gamepad buttons, axes, sticks, and transitions.
58
+ - [Multiple Gamepads](./docs/multiple-gamepads.md) - Assign different gamepads to controls, players, and gameplay inputs.
55
59
  - [GamepadControls](./docs/gamepad-controls.md) — Abstract base class for custom gamepad controls.
56
60
  - [GamepadArcballControls](./docs/gamepad-arcball-controls.md) - Gamepad support for `ArcballControls`.
57
61
  - [GamepadDragControls](./docs/gamepad-drag-controls.md) - Gamepad support for `DragControls`.
@@ -1,4 +1,4 @@
1
- import { GamepadControls } from "./gamepad-controls.js";
1
+ import { GamepadControls, GamepadControlsOptions } from "./gamepad-controls.js";
2
2
  import { ArcballControls } from "three/addons/controls/ArcballControls.js";
3
3
 
4
4
  //#region src/gamepad-arcball-controls.d.ts
@@ -7,7 +7,7 @@ import { ArcballControls } from "three/addons/controls/ArcballControls.js";
7
7
  *
8
8
  * Every property has a sensible default, so you only need to pass the properties you want to override.
9
9
  */
10
- type GamepadArcballControlsOptions = {
10
+ type GamepadArcballControlsOptions = GamepadControlsOptions & {
11
11
  /**
12
12
  * Multiplier on `ArcballControls.rotateSpeed` for rotation.
13
13
  * @default 1.0
@@ -2,9 +2,6 @@ import { GAMEPAD_AXIS, GAMEPAD_BUTTON } from "./core.js";
2
2
  import { GamepadControls } from "./gamepad-controls.js";
3
3
  import { Vector2, Vector3 } from "three";
4
4
  //#region src/gamepad-arcball-controls.ts
5
- /**
6
- * Default options merged in the constructor when no explicit configuration is provided.
7
- */
8
5
  const DEFAULT_ARCBALL_OPTIONS = {
9
6
  rotateSpeed: 1,
10
7
  panSpeed: 1,
@@ -46,7 +43,7 @@ var GamepadArcballControls = class extends GamepadControls {
46
43
  * Any property not provided falls back to its default value.
47
44
  */
48
45
  constructor(controls, options) {
49
- super();
46
+ super(options);
50
47
  this.#controls = controls;
51
48
  this.#options = {
52
49
  ...DEFAULT_ARCBALL_OPTIONS,
@@ -234,9 +231,6 @@ var GamepadArcballControls = class extends GamepadControls {
234
231
  if (!this.gamepadInput.wasPressed(buttonFocus) || !controls.enabled || !controls.enablePan || !controls.enableFocus || controls.scene === null) return null;
235
232
  return controls.unprojectOnObj(this.#centerNdc, controls.object);
236
233
  }
237
- /**
238
- * Dispatches Arcball's `end` event when an active gamepad interaction stops.
239
- */
240
234
  #endInteraction() {
241
235
  if (!this.#wasInteracting) return;
242
236
  this.#controls.dispatchEvent({ type: "end" });
@@ -1,4 +1,4 @@
1
- import { GamepadInput } from "./gamepad-input.js";
1
+ import { GamepadInput, GamepadInputOptions } from "./gamepad-input.js";
2
2
  import { EventDispatcher } from "three";
3
3
 
4
4
  //#region src/gamepad-controls.d.ts
@@ -19,7 +19,7 @@ type GamepadControlsEventMap = {
19
19
  gamepad: Gamepad;
20
20
  };
21
21
  /**
22
- * Fired when the active gamepad is disconnected.
22
+ * Fired when the active gamepad is disconnected or replaced in its slot.
23
23
  */
24
24
  disconnected: {
25
25
  /**
@@ -28,6 +28,10 @@ type GamepadControlsEventMap = {
28
28
  gamepad: Gamepad;
29
29
  };
30
30
  };
31
+ /**
32
+ * Shared configuration for Three.js gamepad control wrappers.
33
+ */
34
+ type GamepadControlsOptions = Pick<GamepadInputOptions, "gamepadIndex">;
31
35
  /**
32
36
  * Abstract base class for Three.js gamepad controls.
33
37
  *
@@ -47,8 +51,12 @@ declare abstract class GamepadControls extends EventDispatcher<GamepadControlsEv
47
51
  gamepad: Gamepad | null;
48
52
  /**
49
53
  * Creates the base input reader and attaches lifecycle listeners.
54
+ *
55
+ * @param options - Shared gamepad selection options.
56
+ * @throws {RangeError} When `gamepadIndex` is not an integer in the inclusive
57
+ * range `[0, 2147483647]`.
50
58
  */
51
- constructor();
59
+ constructor(options?: GamepadControlsOptions);
52
60
  /**
53
61
  * Low-level gamepad input reader used by subclasses.
54
62
  *
@@ -80,7 +88,8 @@ declare abstract class GamepadControls extends EventDispatcher<GamepadControlsEv
80
88
  */
81
89
  protected onGamepadConnected(gamepad: Gamepad): void;
82
90
  /**
83
- * Called when the active gamepad disconnects through the shared input reader.
91
+ * Called when the active gamepad disconnects or is replaced through the
92
+ * shared input reader.
84
93
  *
85
94
  * The default dispatches a `disconnected` event.
86
95
  *
@@ -89,4 +98,4 @@ declare abstract class GamepadControls extends EventDispatcher<GamepadControlsEv
89
98
  protected onGamepadDisconnected(gamepad: Gamepad): void;
90
99
  }
91
100
  //#endregion
92
- export { GamepadControls, GamepadControlsEventMap };
101
+ export { GamepadControls, GamepadControlsEventMap, GamepadControlsOptions };
@@ -18,20 +18,18 @@ var GamepadControls = class extends EventDispatcher {
18
18
  */
19
19
  gamepad = null;
20
20
  #gamepadInput;
21
- /**
22
- * Bound input connection listener kept so it can be removed in {@link dispose}.
23
- */
24
21
  #onGamepadConnected;
25
- /**
26
- * Bound input disconnection listener kept so it can be removed in {@link dispose}.
27
- */
28
22
  #onGamepadDisconnected;
29
23
  /**
30
24
  * Creates the base input reader and attaches lifecycle listeners.
25
+ *
26
+ * @param options - Shared gamepad selection options.
27
+ * @throws {RangeError} When `gamepadIndex` is not an integer in the inclusive
28
+ * range `[0, 2147483647]`.
31
29
  */
32
- constructor() {
30
+ constructor(options) {
33
31
  super();
34
- this.#gamepadInput = new GamepadInput();
32
+ this.#gamepadInput = new GamepadInput(options);
35
33
  this.#onGamepadConnected = this.#handleGamepadConnected.bind(this);
36
34
  this.#onGamepadDisconnected = this.#handleGamepadDisconnected.bind(this);
37
35
  this.#gamepadInput.addEventListener("connected", this.#onGamepadConnected);
@@ -99,7 +97,8 @@ var GamepadControls = class extends EventDispatcher {
99
97
  });
100
98
  }
101
99
  /**
102
- * Called when the active gamepad disconnects through the shared input reader.
100
+ * Called when the active gamepad disconnects or is replaced through the
101
+ * shared input reader.
103
102
  *
104
103
  * The default dispatches a `disconnected` event.
105
104
  *
@@ -1,4 +1,4 @@
1
- import { GamepadControls } from "./gamepad-controls.js";
1
+ import { GamepadControls, GamepadControlsOptions } from "./gamepad-controls.js";
2
2
  import { DragControls } from "three/addons/controls/DragControls.js";
3
3
 
4
4
  //#region src/gamepad-drag-controls.d.ts
@@ -7,7 +7,7 @@ import { DragControls } from "three/addons/controls/DragControls.js";
7
7
  *
8
8
  * Every property has a sensible default, so you only need to pass the properties you want to override.
9
9
  */
10
- type GamepadDragControlsOptions = {
10
+ type GamepadDragControlsOptions = GamepadControlsOptions & {
11
11
  /**
12
12
  * Screen-relative translation speed multiplier.
13
13
  * @default 1.0
@@ -2,9 +2,6 @@ import { GAMEPAD_AXIS, GAMEPAD_BUTTON } from "./core.js";
2
2
  import { GamepadControls } from "./gamepad-controls.js";
3
3
  import { Matrix4, Vector2, Vector3 } from "three";
4
4
  //#region src/gamepad-drag-controls.ts
5
- /**
6
- * Default options merged in the constructor when no explicit configuration is provided.
7
- */
8
5
  const DEFAULT_DRAG_OPTIONS = {
9
6
  dragSpeed: 1,
10
7
  rotateSpeed: 1,
@@ -43,7 +40,7 @@ var GamepadDragControls = class extends GamepadControls {
43
40
  * Any property not provided falls back to its default value.
44
41
  */
45
42
  constructor(controls, options) {
46
- super();
43
+ super(options);
47
44
  this.#controls = controls;
48
45
  this.#options = {
49
46
  ...DEFAULT_DRAG_OPTIONS,
@@ -191,9 +188,6 @@ var GamepadDragControls = class extends GamepadControls {
191
188
  object
192
189
  });
193
190
  }
194
- /**
195
- * Clears the current hover object and dispatches `hoveroff` when needed.
196
- */
197
191
  #clearHover() {
198
192
  if (this.#hovered === null) return;
199
193
  const object = this.#hovered;
@@ -218,9 +212,6 @@ var GamepadDragControls = class extends GamepadControls {
218
212
  object: selected
219
213
  });
220
214
  }
221
- /**
222
- * Releases the selected object and dispatches DragControls `dragend`.
223
- */
224
215
  #releaseSelected() {
225
216
  if (this.#selected === null) return;
226
217
  const selected = this.#selected;
@@ -255,9 +246,6 @@ var GamepadDragControls = class extends GamepadControls {
255
246
  }
256
247
  return group;
257
248
  }
258
- /**
259
- * Writes the accumulated world-space selected position back to the object.
260
- */
261
249
  #applySelectedWorldPosition() {
262
250
  const selected = this.#selected;
263
251
  if (selected === null) return;
@@ -272,18 +260,12 @@ var GamepadDragControls = class extends GamepadControls {
272
260
  selected.position.copy(this.#selectedLocalPosition);
273
261
  selected.updateMatrixWorld();
274
262
  }
275
- /**
276
- * Refreshes camera-relative axes used for dragging and rotation.
277
- */
278
263
  #updateCameraAxes() {
279
264
  const camera = this.#controls.object;
280
265
  this.#cameraRight.set(1, 0, 0).applyQuaternion(camera.quaternion).normalize();
281
266
  this.#cameraUp.set(0, 1, 0).applyQuaternion(camera.quaternion).normalize();
282
267
  camera.getWorldDirection(this.#cameraForward).normalize();
283
268
  }
284
- /**
285
- * Computes the world-space viewport size at the selected object's depth.
286
- */
287
269
  #updateViewSizeAtSelectedDepth() {
288
270
  const camera = this.#controls.object;
289
271
  if (this.#isOrthographicCamera(camera)) {
@@ -1,4 +1,4 @@
1
- import { GamepadControls } from "./gamepad-controls.js";
1
+ import { GamepadControls, GamepadControlsOptions } from "./gamepad-controls.js";
2
2
  import { FirstPersonControls } from "three/addons/controls/FirstPersonControls.js";
3
3
 
4
4
  //#region src/gamepad-first-person-controls.d.ts
@@ -7,7 +7,7 @@ import { FirstPersonControls } from "three/addons/controls/FirstPersonControls.j
7
7
  *
8
8
  * Every property has a sensible default, so you only need to pass the properties you want to override.
9
9
  */
10
- type GamepadFirstPersonControlsOptions = {
10
+ type GamepadFirstPersonControlsOptions = GamepadControlsOptions & {
11
11
  /**
12
12
  * Multiplier on `FirstPersonControls.movementSpeed` for translation.
13
13
  * @default 1.0
@@ -2,9 +2,6 @@ import { GAMEPAD_AXIS, GAMEPAD_BUTTON } from "./core.js";
2
2
  import { GamepadControls } from "./gamepad-controls.js";
3
3
  import { MathUtils, Spherical, Vector3 } from "three";
4
4
  //#region src/gamepad-first-person-controls.ts
5
- /**
6
- * Default options merged in the constructor when no explicit configuration is provided.
7
- */
8
5
  const DEFAULT_FIRST_PERSON_OPTIONS = {
9
6
  moveSpeed: 1,
10
7
  lookSpeed: 1,
@@ -36,7 +33,7 @@ var GamepadFirstPersonControls = class extends GamepadControls {
36
33
  * Any property not provided falls back to its default value.
37
34
  */
38
35
  constructor(controls, options) {
39
- super();
36
+ super(options);
40
37
  this.#controls = controls;
41
38
  this.#options = {
42
39
  ...DEFAULT_FIRST_PERSON_OPTIONS,
@@ -1,4 +1,4 @@
1
- import { GamepadControls } from "./gamepad-controls.js";
1
+ import { GamepadControls, GamepadControlsOptions } from "./gamepad-controls.js";
2
2
  import { FlyControls } from "three/addons/controls/FlyControls.js";
3
3
 
4
4
  //#region src/gamepad-fly-controls.d.ts
@@ -7,7 +7,7 @@ import { FlyControls } from "three/addons/controls/FlyControls.js";
7
7
  *
8
8
  * Every property has a sensible default, so you only need to pass the properties you want to override.
9
9
  */
10
- type GamepadFlyControlsOptions = {
10
+ type GamepadFlyControlsOptions = GamepadControlsOptions & {
11
11
  /**
12
12
  * Multiplier on `FlyControls.movementSpeed` for translation.
13
13
  * @default 1.0
@@ -2,9 +2,6 @@ import { GAMEPAD_AXIS, GAMEPAD_BUTTON } from "./core.js";
2
2
  import { GamepadControls } from "./gamepad-controls.js";
3
3
  import { Quaternion } from "three";
4
4
  //#region src/gamepad-fly-controls.ts
5
- /**
6
- * Default options merged in the constructor when no explicit configuration is provided.
7
- */
8
5
  const DEFAULT_FLY_OPTIONS = {
9
6
  moveSpeed: 1,
10
7
  rotateSpeed: 1,
@@ -35,7 +32,7 @@ var GamepadFlyControls = class extends GamepadControls {
35
32
  * Any property not provided falls back to its default value.
36
33
  */
37
34
  constructor(controls, options) {
38
- super();
35
+ super(options);
39
36
  this.#controls = controls;
40
37
  this.#options = {
41
38
  ...DEFAULT_FLY_OPTIONS,
@@ -15,7 +15,7 @@ type GamepadInputEventMap = {
15
15
  gamepad: Gamepad;
16
16
  };
17
17
  /**
18
- * Fired when the active gamepad is disconnected.
18
+ * Fired when the active gamepad is disconnected or replaced in its slot.
19
19
  */
20
20
  disconnected: {
21
21
  /**
@@ -33,6 +33,15 @@ type GamepadInputOptions = {
33
33
  * @default 0.1
34
34
  */
35
35
  deadzone: number;
36
+ /**
37
+ * Browser-assigned gamepad slot to use.
38
+ *
39
+ * When omitted, the connected gamepad with the lowest index is selected.
40
+ * The index must be an integer in the inclusive range `[0, 2147483647]`.
41
+ * A valid but empty slot keeps this input disconnected without falling back
42
+ * to another gamepad.
43
+ */
44
+ gamepadIndex?: number;
36
45
  };
37
46
  /**
38
47
  * Options for axis and stick reads.
@@ -72,6 +81,8 @@ declare class GamepadInput extends EventDispatcher<GamepadInputEventMap> {
72
81
  * Creates a gamepad input reader.
73
82
  *
74
83
  * @param options - Optional overrides for the default input behavior.
84
+ * @throws {RangeError} When `gamepadIndex` is not an integer in the inclusive
85
+ * range `[0, 2147483647]`.
75
86
  */
76
87
  constructor(options?: Partial<GamepadInputOptions>);
77
88
  /**
@@ -58,27 +58,23 @@ var GamepadInput = class extends EventDispatcher {
58
58
  #options;
59
59
  #pressedButtons;
60
60
  #previousPressedButtons;
61
- /**
62
- * Bound browser connection listener kept so it can be removed in {@link dispose}.
63
- */
64
61
  #onGamepadConnected;
65
- /**
66
- * Bound browser disconnection listener kept so it can be removed in {@link dispose}.
67
- */
68
62
  #onGamepadDisconnected;
69
63
  #gamepad = null;
70
64
  /**
71
65
  * Creates a gamepad input reader.
72
66
  *
73
67
  * @param options - Optional overrides for the default input behavior.
68
+ * @throws {RangeError} When `gamepadIndex` is not an integer in the inclusive
69
+ * range `[0, 2147483647]`.
74
70
  */
75
71
  constructor(options) {
76
72
  super();
77
- this.#manager = new GamepadManager();
78
73
  this.#options = {
79
74
  ...DEFAULT_GAMEPAD_INPUT_OPTIONS,
80
75
  ...options
81
76
  };
77
+ this.#manager = new GamepadManager({ gamepadIndex: this.#options.gamepadIndex });
82
78
  this.#pressedButtons = /* @__PURE__ */ new Set();
83
79
  this.#previousPressedButtons = /* @__PURE__ */ new Set();
84
80
  this.#onGamepadConnected = this.#handleGamepadConnectedEvent.bind(this);
@@ -242,12 +238,13 @@ var GamepadInput = class extends EventDispatcher {
242
238
  * @param gamepad - Browser-provided connected gamepad snapshot.
243
239
  */
244
240
  #handleGamepadConnected(gamepad) {
245
- if (!this.#manager.connect(gamepad)) return;
246
- this.#gamepad = this.#manager.activeGamepad;
241
+ const connectedGamepad = this.#manager.connect(gamepad);
242
+ if (connectedGamepad === null) return;
243
+ this.#gamepad = connectedGamepad;
247
244
  this.#syncButtonState({ seedPrevious: true });
248
245
  this.dispatchEvent({
249
246
  type: "connected",
250
- gamepad
247
+ gamepad: connectedGamepad
251
248
  });
252
249
  }
253
250
  /**
@@ -289,9 +286,6 @@ var GamepadInput = class extends EventDispatcher {
289
286
  this.#previousPressedButtons.clear();
290
287
  for (const button of this.#pressedButtons) this.#previousPressedButtons.add(button);
291
288
  }
292
- /**
293
- * Clears all stored button state.
294
- */
295
289
  #clearButtonState() {
296
290
  this.#pressedButtons.clear();
297
291
  this.#previousPressedButtons.clear();
@@ -1,4 +1,5 @@
1
1
  //#region src/gamepad-manager.ts
2
+ const MAX_GAMEPAD_INDEX = 2147483647;
2
3
  const EMPTY_UPDATE_RESULT = {
3
4
  gamepad: null,
4
5
  connected: null,
@@ -7,54 +8,74 @@ const EMPTY_UPDATE_RESULT = {
7
8
  /**
8
9
  * Internal input core that owns active gamepad polling and snapshot refresh.
9
10
  *
10
- * This class intentionally tracks only one active gamepad. Higher-level
11
- * multi-gamepad selection should be built on top of this lifecycle in a
12
- * later phase.
11
+ * Each manager instance tracks one active gamepad, optionally selected by its
12
+ * browser-assigned `Gamepad.index`.
13
13
  *
14
14
  * @internal
15
15
  */
16
16
  var GamepadManager = class {
17
+ activeGamepad = null;
18
+ #connectionDeferredUntilUpdate = false;
19
+ #selection;
17
20
  /**
18
- * The active gamepad snapshot, or `null` when none is active.
21
+ * Creates an active-gamepad manager.
22
+ *
23
+ * @param options - Optional active gamepad selection options.
24
+ * @throws {RangeError} When `gamepadIndex` is outside the valid Web IDL
25
+ * `long` range for a gamepad index.
19
26
  */
20
- activeGamepad = null;
27
+ constructor(options) {
28
+ this.#selection = this.#resolveSelection(options?.gamepadIndex);
29
+ }
21
30
  /**
22
- * Accepts a gamepad as active when no active gamepad exists.
31
+ * Uses a connection event to resolve and activate the configured gamepad.
23
32
  *
24
- * Additional connected gamepads are ignored so the current controls keep
25
- * using the first active device by default.
33
+ * In first-available mode, the event is only a signal to inspect the complete
34
+ * Gamepad API state. The connected gamepad with the lowest index is adopted,
35
+ * which may differ from the gamepad carried by the event.
36
+ * Connection events are deferred after losing an active gamepad so its
37
+ * replacement can only be adopted by the next {@link update}.
26
38
  *
27
- * @param gamepad - Gamepad snapshot to activate.
28
- * @returns `true` when the gamepad became active, otherwise `false`.
39
+ * @param gamepad - Gamepad snapshot carried by the connection event.
40
+ * @returns The gamepad that became active, or `null` when none was adopted.
29
41
  */
30
42
  connect(gamepad) {
31
- if (this.activeGamepad !== null || !gamepad.connected) return false;
32
- this.activeGamepad = gamepad;
33
- return true;
43
+ if (this.activeGamepad !== null || this.#connectionDeferredUntilUpdate || !gamepad.connected || !this.#matchesSelection(gamepad)) return null;
44
+ const selectedGamepad = this.#getSelectableGamepad();
45
+ if (selectedGamepad === null) return null;
46
+ this.activeGamepad = selectedGamepad;
47
+ return selectedGamepad;
34
48
  }
35
49
  /**
36
- * Clears the active gamepad when it matches the disconnecting gamepad index.
50
+ * Clears the active gamepad when it is the disconnecting gamepad instance.
51
+ *
52
+ * Identity is intentionally checked in addition to the browser-assigned
53
+ * index. Indices may be reused, so a late event from a previous device must
54
+ * not disconnect a replacement that now occupies the same slot.
55
+ * A matching disconnection defers any replacement until the next update.
37
56
  *
38
57
  * @param gamepad - Gamepad snapshot that disconnected.
39
58
  * @returns The previously active gamepad when it was cleared, otherwise `null`.
40
59
  */
41
60
  disconnect(gamepad) {
42
- if (this.activeGamepad?.index !== gamepad.index) return null;
61
+ if (this.activeGamepad !== gamepad) return null;
43
62
  const disconnectedGamepad = this.activeGamepad;
44
63
  this.activeGamepad = null;
64
+ this.#connectionDeferredUntilUpdate = true;
45
65
  return disconnectedGamepad;
46
66
  }
47
67
  /**
48
68
  * Polls the Gamepad API and refreshes the active gamepad snapshot.
49
69
  *
50
- * The browser exposes gamepad state as snapshots, so polling must replace
51
- * the stored reference before controls read axes or buttons.
70
+ * Polling re-resolves the browser slot so a disconnected device, a reused
71
+ * index, or an updated active device is observed before controls read input.
52
72
  *
53
73
  * @returns The active gamepad and any connect/disconnect transition found.
54
74
  */
55
75
  update() {
56
76
  if (this.activeGamepad === null) {
57
- const connectedGamepad = this.#getFirstConnectedGamepad();
77
+ this.#connectionDeferredUntilUpdate = false;
78
+ const connectedGamepad = this.#getSelectableGamepad();
58
79
  if (connectedGamepad === null) return EMPTY_UPDATE_RESULT;
59
80
  this.activeGamepad = connectedGamepad;
60
81
  return {
@@ -65,8 +86,9 @@ var GamepadManager = class {
65
86
  }
66
87
  const previousGamepad = this.activeGamepad;
67
88
  const nextGamepad = this.#getGamepadByIndex(previousGamepad.index);
68
- if (nextGamepad === null) {
89
+ if (!previousGamepad.connected || nextGamepad === null || nextGamepad !== previousGamepad) {
69
90
  this.activeGamepad = null;
91
+ this.#connectionDeferredUntilUpdate = true;
70
92
  return {
71
93
  gamepad: null,
72
94
  connected: null,
@@ -81,6 +103,15 @@ var GamepadManager = class {
81
103
  };
82
104
  }
83
105
  /**
106
+ * Finds the connected gamepad matching the configured selection.
107
+ *
108
+ * @returns A selectable gamepad snapshot, or `null` if none is available.
109
+ */
110
+ #getSelectableGamepad() {
111
+ if (this.#selection.type === "index") return this.#getGamepadByIndex(this.#selection.index);
112
+ return this.#getFirstAvailableGamepad();
113
+ }
114
+ /**
84
115
  * Reads the latest connected gamepad snapshot at a known index.
85
116
  *
86
117
  * @param index - Browser-assigned gamepad index to refresh.
@@ -91,13 +122,43 @@ var GamepadManager = class {
91
122
  return gamepad?.connected === true ? gamepad : null;
92
123
  }
93
124
  /**
94
- * Finds the first currently connected gamepad snapshot.
125
+ * Finds the connected gamepad with the lowest browser-assigned index.
126
+ *
127
+ * The array is normally sparse and ordered by index, but comparing the
128
+ * reported indices keeps the selection deterministic for API mocks as well.
129
+ *
130
+ * @returns The lowest-index connected gamepad, or `null` if none exist.
131
+ */
132
+ #getFirstAvailableGamepad() {
133
+ let firstAvailableGamepad = null;
134
+ for (const gamepad of navigator.getGamepads()) if (gamepad?.connected === true && (firstAvailableGamepad === null || gamepad.index < firstAvailableGamepad.index)) firstAvailableGamepad = gamepad;
135
+ return firstAvailableGamepad;
136
+ }
137
+ /**
138
+ * Checks whether a connection event is relevant to this manager.
95
139
  *
96
- * @returns The first connected gamepad snapshot, or `null` if none exist.
140
+ * @param gamepad - Gamepad snapshot carried by the event.
141
+ * @returns `true` when the event may trigger configured selection.
97
142
  */
98
- #getFirstConnectedGamepad() {
99
- for (const gamepad of navigator.getGamepads()) if (gamepad?.connected === true) return gamepad;
100
- return null;
143
+ #matchesSelection(gamepad) {
144
+ if (this.#selection.type === "index") return gamepad.index === this.#selection.index;
145
+ return true;
146
+ }
147
+ /**
148
+ * Resolves a public index option to an immutable internal selection mode.
149
+ *
150
+ * @param gamepadIndex - Browser-assigned gamepad index option.
151
+ * @returns Internal active-gamepad selection mode.
152
+ * @throws {RangeError} When the explicit index is not an integer in the
153
+ * inclusive range `[0, 2147483647]`.
154
+ */
155
+ #resolveSelection(gamepadIndex) {
156
+ if (gamepadIndex === void 0) return { type: "first-available" };
157
+ if (!Number.isInteger(gamepadIndex) || gamepadIndex < 0 || gamepadIndex > MAX_GAMEPAD_INDEX) throw new RangeError(`gamepadIndex must be an integer between 0 and ${MAX_GAMEPAD_INDEX}.`);
158
+ return {
159
+ type: "index",
160
+ index: gamepadIndex
161
+ };
101
162
  }
102
163
  };
103
164
  //#endregion
@@ -1,9 +1,6 @@
1
1
  import { GAMEPAD_AXIS } from "./core.js";
2
2
  import { GamepadOrbitControls } from "./gamepad-orbit-controls.js";
3
3
  //#region src/gamepad-map-controls.ts
4
- /**
5
- * Axis overrides for {@link GamepadMapControls}: left stick pans, right stick orbits.
6
- */
7
4
  const DEFAULT_MAP_OPTIONS = {
8
5
  axisPanX: GAMEPAD_AXIS.LeftX,
9
6
  axisPanY: GAMEPAD_AXIS.LeftY,
@@ -1,4 +1,4 @@
1
- import { GamepadControls } from "./gamepad-controls.js";
1
+ import { GamepadControls, GamepadControlsOptions } from "./gamepad-controls.js";
2
2
  import { OrbitControls } from "three/addons/controls/OrbitControls.js";
3
3
 
4
4
  //#region src/gamepad-orbit-controls.d.ts
@@ -7,7 +7,7 @@ import { OrbitControls } from "three/addons/controls/OrbitControls.js";
7
7
  *
8
8
  * Every property has a sensible default, so you only need to pass the properties you want to override.
9
9
  */
10
- type GamepadOrbitControlsOptions = {
10
+ type GamepadOrbitControlsOptions = GamepadControlsOptions & {
11
11
  /**
12
12
  * Multiplier on orbit rotation speed.
13
13
  * @default 1.0
@@ -1,9 +1,6 @@
1
1
  import { GAMEPAD_AXIS, GAMEPAD_BUTTON } from "./core.js";
2
2
  import { GamepadControls } from "./gamepad-controls.js";
3
3
  //#region src/gamepad-orbit-controls.ts
4
- /**
5
- * Default options merged in the constructor when no explicit configuration is provided.
6
- */
7
4
  const DEFAULT_ORBIT_OPTIONS = {
8
5
  rotateSpeed: 1,
9
6
  panSpeed: 1,
@@ -31,7 +28,7 @@ var GamepadOrbitControls = class extends GamepadControls {
31
28
  * Any property not provided falls back to its default value.
32
29
  */
33
30
  constructor(controls, options) {
34
- super();
31
+ super(options);
35
32
  this.#controls = controls;
36
33
  this.#options = {
37
34
  ...DEFAULT_ORBIT_OPTIONS,
@@ -1,4 +1,4 @@
1
- import { GamepadControls } from "./gamepad-controls.js";
1
+ import { GamepadControls, GamepadControlsOptions } from "./gamepad-controls.js";
2
2
  import { PointerLockControls } from "three/addons/controls/PointerLockControls.js";
3
3
 
4
4
  //#region src/gamepad-pointer-lock-controls.d.ts
@@ -7,7 +7,7 @@ import { PointerLockControls } from "three/addons/controls/PointerLockControls.j
7
7
  *
8
8
  * Every property has a sensible default, so you only need to pass the properties you want to override.
9
9
  */
10
- type GamepadPointerLockControlsOptions = {
10
+ type GamepadPointerLockControlsOptions = GamepadControlsOptions & {
11
11
  /**
12
12
  * Camera movement speed in world units per second at full stick deflection.
13
13
  * @default 5.0
@@ -2,9 +2,6 @@ import { GAMEPAD_AXIS } from "./core.js";
2
2
  import { GamepadControls } from "./gamepad-controls.js";
3
3
  import { Euler } from "three";
4
4
  //#region src/gamepad-pointer-lock-controls.ts
5
- /**
6
- * Default options merged in the constructor when no explicit configuration is provided.
7
- */
8
5
  const DEFAULT_POINTER_LOCK_OPTIONS = {
9
6
  moveSpeed: 5,
10
7
  lookSpeed: 1,
@@ -31,7 +28,7 @@ var GamepadPointerLockControls = class extends GamepadControls {
31
28
  * Any property not provided falls back to its default value.
32
29
  */
33
30
  constructor(controls, options) {
34
- super();
31
+ super(options);
35
32
  this.#controls = controls;
36
33
  this.#options = {
37
34
  ...DEFAULT_POINTER_LOCK_OPTIONS,
@@ -1,4 +1,4 @@
1
- import { GamepadControls } from "./gamepad-controls.js";
1
+ import { GamepadControls, GamepadControlsOptions } from "./gamepad-controls.js";
2
2
  import { TrackballControls } from "three/addons/controls/TrackballControls.js";
3
3
 
4
4
  //#region src/gamepad-trackball-controls.d.ts
@@ -7,7 +7,7 @@ import { TrackballControls } from "three/addons/controls/TrackballControls.js";
7
7
  *
8
8
  * Every property has a sensible default, so you only need to pass the properties you want to override.
9
9
  */
10
- type GamepadTrackballControlsOptions = {
10
+ type GamepadTrackballControlsOptions = GamepadControlsOptions & {
11
11
  /**
12
12
  * Multiplier on `TrackballControls.rotateSpeed` for rotation.
13
13
  * @default 1.0
@@ -1,9 +1,6 @@
1
1
  import { GAMEPAD_AXIS, GAMEPAD_BUTTON } from "./core.js";
2
2
  import { GamepadControls } from "./gamepad-controls.js";
3
3
  //#region src/gamepad-trackball-controls.ts
4
- /**
5
- * Default options merged in the constructor when no explicit configuration is provided.
6
- */
7
4
  const DEFAULT_TRACKBALL_OPTIONS = {
8
5
  rotateSpeed: 1,
9
6
  panSpeed: 1,
@@ -31,7 +28,7 @@ var GamepadTrackballControls = class extends GamepadControls {
31
28
  * Any property not provided falls back to its default value.
32
29
  */
33
30
  constructor(controls, options) {
34
- super();
31
+ super(options);
35
32
  this.#controls = controls;
36
33
  this.#options = {
37
34
  ...DEFAULT_TRACKBALL_OPTIONS,
@@ -1,4 +1,4 @@
1
- import { GamepadControls } from "./gamepad-controls.js";
1
+ import { GamepadControls, GamepadControlsOptions } from "./gamepad-controls.js";
2
2
  import { TransformControls } from "three/addons/controls/TransformControls.js";
3
3
 
4
4
  //#region src/gamepad-transform-controls.d.ts
@@ -7,7 +7,7 @@ import { TransformControls } from "three/addons/controls/TransformControls.js";
7
7
  *
8
8
  * Every property has a sensible default, so you only need to pass the properties you want to override.
9
9
  */
10
- type GamepadTransformControlsOptions = {
10
+ type GamepadTransformControlsOptions = GamepadControlsOptions & {
11
11
  /**
12
12
  * Screen-relative translation speed multiplier.
13
13
  * @default 1.0
@@ -2,9 +2,6 @@ import { GAMEPAD_AXIS, GAMEPAD_BUTTON } from "./core.js";
2
2
  import { GamepadControls } from "./gamepad-controls.js";
3
3
  import { Matrix4, Quaternion, Vector2, Vector3 } from "three";
4
4
  //#region src/gamepad-transform-controls.ts
5
- /**
6
- * Default options merged in the constructor when no explicit configuration is provided.
7
- */
8
5
  const DEFAULT_TRANSFORM_OPTIONS = {
9
6
  translateSpeed: 1,
10
7
  rotateSpeed: 1,
@@ -109,7 +106,7 @@ var GamepadTransformControls = class extends GamepadControls {
109
106
  * Any property not provided falls back to its default value.
110
107
  */
111
108
  constructor(controls, options) {
112
- super();
109
+ super(options);
113
110
  this.#controls = controls;
114
111
  this.#options = {
115
112
  ...DEFAULT_TRANSFORM_OPTIONS,
@@ -226,9 +223,6 @@ var GamepadTransformControls = class extends GamepadControls {
226
223
  this.#controls.setMode(mode);
227
224
  this.#ensureValidAxis();
228
225
  }
229
- /**
230
- * Toggles TransformControls between local and world transform space.
231
- */
232
226
  #toggleSpace() {
233
227
  const nextSpace = this.#controls.space === "world" ? "local" : "world";
234
228
  this.#endTransform(false);
@@ -245,9 +239,6 @@ var GamepadTransformControls = class extends GamepadControls {
245
239
  this.#activeAxisByMode[this.#controls.mode] = axis;
246
240
  this.#ensureValidAxis();
247
241
  }
248
- /**
249
- * Cycles through composite axes available in the current mode.
250
- */
251
242
  #cycleCompositeAxis() {
252
243
  const validAxes = this.#getVisibleAxes(COMPOSITE_AXES[this.#controls.mode]);
253
244
  this.#cycleThroughAxes(validAxes, 1);
@@ -386,9 +377,6 @@ var GamepadTransformControls = class extends GamepadControls {
386
377
  }
387
378
  if (clearAxis) this.#setActiveAxis(null);
388
379
  }
389
- /**
390
- * Resets the active object to TransformControls' captured drag start state.
391
- */
392
380
  #resetActiveTransform() {
393
381
  const object = this.#controls.object;
394
382
  if (!this.#isTransforming || object === void 0) return;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { GAMEPAD_AXIS, GAMEPAD_BUTTON, GamepadAxisKey, GamepadAxisValue, GamepadButtonKey, GamepadButtonValue } from "./core.js";
2
2
  import { GamepadAxisOptions, GamepadInput, GamepadInputEventMap, GamepadInputOptions, GamepadStick } from "./gamepad-input.js";
3
- import { GamepadControls, GamepadControlsEventMap } from "./gamepad-controls.js";
3
+ import { GamepadControls, GamepadControlsEventMap, GamepadControlsOptions } from "./gamepad-controls.js";
4
4
  import { GamepadArcballControls, GamepadArcballControlsOptions } from "./gamepad-arcball-controls.js";
5
5
  import { GamepadDragControls, GamepadDragControlsOptions } from "./gamepad-drag-controls.js";
6
6
  import { GamepadFirstPersonControls, GamepadFirstPersonControlsOptions } from "./gamepad-first-person-controls.js";
@@ -10,4 +10,4 @@ import { GamepadMapControls } from "./gamepad-map-controls.js";
10
10
  import { GamepadPointerLockControls, GamepadPointerLockControlsOptions } from "./gamepad-pointer-lock-controls.js";
11
11
  import { GamepadTrackballControls, GamepadTrackballControlsOptions } from "./gamepad-trackball-controls.js";
12
12
  import { GamepadTransformControls, GamepadTransformControlsOptions } from "./gamepad-transform-controls.js";
13
- export { GAMEPAD_AXIS, GAMEPAD_BUTTON, GamepadArcballControls, GamepadArcballControlsOptions, GamepadAxisKey, GamepadAxisOptions, GamepadAxisValue, GamepadButtonKey, GamepadButtonValue, GamepadControls, GamepadControlsEventMap, GamepadDragControls, GamepadDragControlsOptions, GamepadFirstPersonControls, GamepadFirstPersonControlsOptions, GamepadFlyControls, GamepadFlyControlsOptions, GamepadInput, GamepadInputEventMap, GamepadInputOptions, GamepadMapControls, GamepadOrbitControls, GamepadOrbitControlsOptions, GamepadPointerLockControls, GamepadPointerLockControlsOptions, GamepadStick, GamepadTrackballControls, GamepadTrackballControlsOptions, GamepadTransformControls, GamepadTransformControlsOptions };
13
+ export { GAMEPAD_AXIS, GAMEPAD_BUTTON, GamepadArcballControls, GamepadArcballControlsOptions, GamepadAxisKey, GamepadAxisOptions, GamepadAxisValue, GamepadButtonKey, GamepadButtonValue, GamepadControls, GamepadControlsEventMap, GamepadControlsOptions, GamepadDragControls, GamepadDragControlsOptions, GamepadFirstPersonControls, GamepadFirstPersonControlsOptions, GamepadFlyControls, GamepadFlyControlsOptions, GamepadInput, GamepadInputEventMap, GamepadInputOptions, GamepadMapControls, GamepadOrbitControls, GamepadOrbitControlsOptions, GamepadPointerLockControls, GamepadPointerLockControlsOptions, GamepadStick, GamepadTrackballControls, GamepadTrackballControlsOptions, GamepadTransformControls, GamepadTransformControlsOptions };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "three-gamepad-controls",
3
3
  "description": "Gamepad support for Three.js controls.",
4
- "version": "0.12.0",
4
+ "version": "0.13.0",
5
5
  "homepage": "https://github.com/luckasnix/three-gamepad-controls#readme",
6
6
  "author": {
7
7
  "name": "Kasnix",
@@ -46,15 +46,15 @@
46
46
  }
47
47
  },
48
48
  "devDependencies": {
49
- "@biomejs/biome": "2.5.0",
50
- "@commitlint/cli": "21.0.2",
51
- "@commitlint/config-conventional": "21.0.2",
52
- "@commitlint/types": "21.0.1",
49
+ "@biomejs/biome": "2.5.1",
50
+ "@commitlint/cli": "21.2.0",
51
+ "@commitlint/config-conventional": "21.2.0",
52
+ "@commitlint/types": "21.2.0",
53
53
  "@types/node": "24.13.2",
54
54
  "@types/three": "0.184.0",
55
55
  "husky": "9.1.7",
56
56
  "three": "0.184.0",
57
- "tsdown": "0.22.2",
57
+ "tsdown": "0.22.3",
58
58
  "typescript": "6.0.3"
59
59
  },
60
60
  "peerDependencies": {