three-gamepad-controls 0.10.6 → 0.12.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
@@ -2,6 +2,20 @@
2
2
 
3
3
  Gamepad support for [Three.js](https://threejs.org) controls, built on top of [Web Gamepad API](https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API).
4
4
 
5
+ ## Architecture
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
+ ```
18
+
5
19
  ## 📦 Installation
6
20
 
7
21
  npm:
@@ -37,6 +51,7 @@ bun add three-gamepad-controls
37
51
  ## 📖 Documentation
38
52
 
39
53
  - [Core](./docs/core.md) — The fundamental building blocks.
54
+ - [GamepadInput](./docs/gamepad-input.md) - Low-level reader for gamepad buttons, axes, sticks, and transitions.
40
55
  - [GamepadControls](./docs/gamepad-controls.md) — Abstract base class for custom gamepad controls.
41
56
  - [GamepadArcballControls](./docs/gamepad-arcball-controls.md) - Gamepad support for `ArcballControls`.
42
57
  - [GamepadDragControls](./docs/gamepad-drag-controls.md) - Gamepad support for `DragControls`.
@@ -99,9 +99,8 @@ declare class GamepadArcballControls extends GamepadControls {
99
99
  * z-rotation, and center focus.
100
100
  *
101
101
  * @param deltaTime - Seconds since the last frame.
102
- * @param gamepad - Fresh gamepad snapshot provided by the base class.
103
102
  */
104
- protected onUpdate(deltaTime: number, gamepad: Gamepad): void;
103
+ protected onUpdate(deltaTime: number): void;
105
104
  }
106
105
  //#endregion
107
106
  export { GamepadArcballControls, GamepadArcballControlsOptions };
@@ -1,6 +1,5 @@
1
1
  import { GAMEPAD_AXIS, GAMEPAD_BUTTON } from "./core.js";
2
2
  import { GamepadControls } from "./gamepad-controls.js";
3
- import { applyGamepadDeadzone, getGamepadButtonPressed, getGamepadButtonValue } from "./utils.js";
4
3
  import { Vector2, Vector3 } from "three";
5
4
  //#region src/gamepad-arcball-controls.ts
6
5
  /**
@@ -40,7 +39,6 @@ var GamepadArcballControls = class extends GamepadControls {
40
39
  #cameraForward;
41
40
  #cameraRight;
42
41
  #previousUp;
43
- #focusButtonPressed = false;
44
42
  #wasInteracting = false;
45
43
  /**
46
44
  * @param controls - A Three.js `ArcballControls` instance.
@@ -67,22 +65,22 @@ var GamepadArcballControls = class extends GamepadControls {
67
65
  * z-rotation, and center focus.
68
66
  *
69
67
  * @param deltaTime - Seconds since the last frame.
70
- * @param gamepad - Fresh gamepad snapshot provided by the base class.
71
68
  */
72
- onUpdate(deltaTime, gamepad) {
69
+ onUpdate(deltaTime) {
73
70
  const controls = this.#controls;
74
71
  const { rotateSpeed, panSpeed, zoomSpeed, zRotateSpeed, deadzone, axisRotateX, axisRotateY, axisPanX, axisPanY, buttonZoomIn, buttonZoomOut, buttonZRotateLeft, buttonZRotateRight, buttonFocus } = this.#options;
75
- const focusPoint = this.#consumeFocusPoint(gamepad, buttonFocus);
72
+ const input = this.gamepadInput;
73
+ const focusPoint = this.#consumeFocusPoint(buttonFocus);
76
74
  if (!controls.enabled) {
77
75
  this.#endInteraction();
78
76
  return;
79
77
  }
80
- const rotateX = controls.enableRotate ? applyGamepadDeadzone(gamepad.axes[axisRotateX] ?? 0, deadzone) : 0;
81
- const rotateY = controls.enableRotate ? applyGamepadDeadzone(gamepad.axes[axisRotateY] ?? 0, deadzone) : 0;
82
- const panX = controls.enablePan ? applyGamepadDeadzone(gamepad.axes[axisPanX] ?? 0, deadzone) : 0;
83
- const panY = controls.enablePan ? applyGamepadDeadzone(gamepad.axes[axisPanY] ?? 0, deadzone) : 0;
84
- const zoom = controls.enableZoom ? getGamepadButtonValue(gamepad, buttonZoomIn) - getGamepadButtonValue(gamepad, buttonZoomOut) : 0;
85
- const zRotation = controls.enableRotate ? getGamepadButtonValue(gamepad, buttonZRotateLeft) - getGamepadButtonValue(gamepad, buttonZRotateRight) : 0;
78
+ const rotateX = controls.enableRotate ? input.axis(axisRotateX, { deadzone }) : 0;
79
+ const rotateY = controls.enableRotate ? input.axis(axisRotateY, { deadzone }) : 0;
80
+ const panX = controls.enablePan ? input.axis(axisPanX, { deadzone }) : 0;
81
+ const panY = controls.enablePan ? input.axis(axisPanY, { deadzone }) : 0;
82
+ const zoom = controls.enableZoom ? input.buttonValue(buttonZoomIn) - input.buttonValue(buttonZoomOut) : 0;
83
+ const zRotation = controls.enableRotate ? input.buttonValue(buttonZRotateLeft) - input.buttonValue(buttonZRotateRight) : 0;
86
84
  const activeInput = rotateX !== 0 || rotateY !== 0 || panX !== 0 || panY !== 0 || Math.abs(zoom) > deadzone || Math.abs(zRotation) > deadzone;
87
85
  if (!activeInput && focusPoint === null) {
88
86
  this.#endInteraction();
@@ -103,6 +101,15 @@ var GamepadArcballControls = class extends GamepadControls {
103
101
  this.#wasInteracting = activeInput;
104
102
  if (!activeInput) this.#endInteraction();
105
103
  }
104
+ /**
105
+ * Applies gamepad stick rotation through Arcball's runtime rotation helper.
106
+ *
107
+ * @param deltaTime - Seconds since the last frame.
108
+ * @param rotateX - Horizontal rotation input after dead zone processing.
109
+ * @param rotateY - Vertical rotation input after dead zone processing.
110
+ * @param rotateSpeed - User-configured rotation speed multiplier.
111
+ * @returns `true` when a rotation was applied.
112
+ */
106
113
  #applyRotation(deltaTime, rotateX, rotateY, rotateSpeed) {
107
114
  if (rotateX === 0 && rotateY === 0) return false;
108
115
  const controls = this.#controls;
@@ -119,6 +126,13 @@ var GamepadArcballControls = class extends GamepadControls {
119
126
  }
120
127
  return changed;
121
128
  }
129
+ /**
130
+ * Applies an Arcball rotation around a specific world axis.
131
+ *
132
+ * @param axis - World axis to rotate around.
133
+ * @param angle - Rotation amount in radians.
134
+ * @returns `true` when ArcballControls produced and applied a transform.
135
+ */
122
136
  #applyRotationAroundAxis(axis, angle) {
123
137
  if (axis.lengthSq() === 0 || angle === 0) return false;
124
138
  const controls = this.#controls;
@@ -128,6 +142,15 @@ var GamepadArcballControls = class extends GamepadControls {
128
142
  if (changed) controls.object.up.copy(this.#previousUp).applyAxisAngle(axis, -angle);
129
143
  return changed;
130
144
  }
145
+ /**
146
+ * Applies gamepad pan by converting stick input to Arcball trackball points.
147
+ *
148
+ * @param deltaTime - Seconds since the last frame.
149
+ * @param panX - Horizontal pan input after dead zone processing.
150
+ * @param panY - Vertical pan input after dead zone processing.
151
+ * @param panSpeed - User-configured pan speed multiplier.
152
+ * @returns `true` when a pan transform was applied.
153
+ */
131
154
  #applyPan(deltaTime, panX, panY, panSpeed) {
132
155
  if (panX === 0 && panY === 0) return false;
133
156
  const controls = this.#controls;
@@ -137,6 +160,15 @@ var GamepadArcballControls = class extends GamepadControls {
137
160
  this.#panEnd.set(panX * distance, panY * distance, 0);
138
161
  return this.#applyTransform(controls.pan(this.#panStart, this.#panEnd));
139
162
  }
163
+ /**
164
+ * Applies trigger-driven zoom around Arcball's gizmo center.
165
+ *
166
+ * @param deltaTime - Seconds since the last frame.
167
+ * @param zoom - Signed zoom input from the configured trigger pair.
168
+ * @param zoomSpeed - User-configured zoom speed multiplier.
169
+ * @param deadzone - Trigger dead zone threshold.
170
+ * @returns `true` when a zoom transform was applied.
171
+ */
140
172
  #applyZoom(deltaTime, zoom, zoomSpeed, deadzone) {
141
173
  if (Math.abs(zoom) <= deadzone || this.#controls.scaleFactor <= 0) return false;
142
174
  const controls = this.#controls;
@@ -145,6 +177,15 @@ var GamepadArcballControls = class extends GamepadControls {
145
177
  controls.updateMatrixState();
146
178
  return this.#applyTransform(controls.scale(size, controls._gizmos.position));
147
179
  }
180
+ /**
181
+ * Applies shoulder-button rotation around the current camera view axis.
182
+ *
183
+ * @param deltaTime - Seconds since the last frame.
184
+ * @param zRotation - Signed z-rotation input from the configured buttons.
185
+ * @param zRotateSpeed - User-configured z-rotation speed multiplier.
186
+ * @param deadzone - Button value dead zone threshold.
187
+ * @returns `true` when a z-rotation transform was applied.
188
+ */
148
189
  #applyZRotation(deltaTime, zRotation, zRotateSpeed, deadzone) {
149
190
  if (Math.abs(zRotation) <= deadzone) return false;
150
191
  const controls = this.#controls;
@@ -156,6 +197,12 @@ var GamepadArcballControls = class extends GamepadControls {
156
197
  if (changed) controls.object.up.copy(this.#previousUp).applyAxisAngle(controls._rotationAxis, angle);
157
198
  return changed;
158
199
  }
200
+ /**
201
+ * Focuses ArcballControls on the given point when one was consumed.
202
+ *
203
+ * @param point - World-space focus point, or `null` when no focus is pending.
204
+ * @returns `true` when focus was applied.
205
+ */
159
206
  #applyFocus(point) {
160
207
  if (point === null) return false;
161
208
  const controls = this.#controls;
@@ -164,20 +211,32 @@ var GamepadArcballControls = class extends GamepadControls {
164
211
  controls.updateMatrixState();
165
212
  return true;
166
213
  }
214
+ /**
215
+ * Applies a transformation returned by an Arcball runtime helper.
216
+ *
217
+ * @param transformation - Arcball transformation matrices, if any.
218
+ * @returns `true` when a transformation was applied.
219
+ */
167
220
  #applyTransform(transformation) {
168
221
  if (transformation === void 0) return false;
169
222
  this.#controls.applyTransformMatrix(transformation);
170
223
  this.#controls.updateMatrixState();
171
224
  return true;
172
225
  }
173
- #consumeFocusPoint(gamepad, buttonFocus) {
226
+ /**
227
+ * Consumes a focus-button press and resolves the viewport center hit point.
228
+ *
229
+ * @param buttonFocus - Button index configured for focus.
230
+ * @returns The center hit point, or `null` when focus should not run.
231
+ */
232
+ #consumeFocusPoint(buttonFocus) {
174
233
  const controls = this.#controls;
175
- const focusPressed = getGamepadButtonPressed(gamepad, buttonFocus);
176
- const shouldFocus = focusPressed && !this.#focusButtonPressed;
177
- this.#focusButtonPressed = focusPressed;
178
- if (!shouldFocus || !controls.enabled || !controls.enablePan || !controls.enableFocus || controls.scene === null) return null;
234
+ if (!this.gamepadInput.wasPressed(buttonFocus) || !controls.enabled || !controls.enablePan || !controls.enableFocus || controls.scene === null) return null;
179
235
  return controls.unprojectOnObj(this.#centerNdc, controls.object);
180
236
  }
237
+ /**
238
+ * Dispatches Arcball's `end` event when an active gamepad interaction stops.
239
+ */
181
240
  #endInteraction() {
182
241
  if (!this.#wasInteracting) return;
183
242
  this.#controls.dispatchEvent({ type: "end" });
@@ -1,3 +1,4 @@
1
+ import { GamepadInput } from "./gamepad-input.js";
1
2
  import { EventDispatcher } from "three";
2
3
 
3
4
  //#region src/gamepad-controls.d.ts
@@ -12,20 +13,26 @@ type GamepadControlsEventMap = {
12
13
  * Fired when a gamepad is connected and set as the active gamepad.
13
14
  */
14
15
  connected: {
16
+ /**
17
+ * Gamepad snapshot that became active.
18
+ */
15
19
  gamepad: Gamepad;
16
20
  };
17
21
  /**
18
22
  * Fired when the active gamepad is disconnected.
19
23
  */
20
24
  disconnected: {
25
+ /**
26
+ * Gamepad snapshot that was active before disconnection.
27
+ */
21
28
  gamepad: Gamepad;
22
29
  };
23
30
  };
24
31
  /**
25
32
  * Abstract base class for Three.js gamepad controls.
26
33
  *
27
- * Handles the gamepad connection lifecycle and input polling so subclasses
28
- * only need to implement {@link onUpdate}.
34
+ * Delegates gamepad connection lifecycle and input polling to {@link GamepadInput}
35
+ * so subclasses can focus on mapping input to the wrapped Three.js control.
29
36
  */
30
37
  declare abstract class GamepadControls extends EventDispatcher<GamepadControlsEventMap> {
31
38
  #private;
@@ -38,7 +45,16 @@ declare abstract class GamepadControls extends EventDispatcher<GamepadControlsEv
38
45
  * The currently active gamepad, or `null` if no gamepad is connected.
39
46
  */
40
47
  gamepad: Gamepad | null;
48
+ /**
49
+ * Creates the base input reader and attaches lifecycle listeners.
50
+ */
41
51
  constructor();
52
+ /**
53
+ * Low-level gamepad input reader used by subclasses.
54
+ *
55
+ * @returns The shared input reader for the active gamepad.
56
+ */
57
+ protected get gamepadInput(): GamepadInput;
42
58
  /**
43
59
  * Advances the controller by one frame. Call this inside your render loop.
44
60
  *
@@ -53,25 +69,22 @@ declare abstract class GamepadControls extends EventDispatcher<GamepadControlsEv
53
69
  * Called every frame when a gamepad is available and `enabled` is `true`.
54
70
  *
55
71
  * @param deltaTime - Seconds since the last frame.
56
- * @param gamepad - A fresh snapshot of the currently active gamepad.
57
72
  */
58
- protected abstract onUpdate(deltaTime: number, gamepad: Gamepad): void;
73
+ protected abstract onUpdate(deltaTime: number): void;
59
74
  /**
60
- * Called when any gamepad fires a `gamepadconnected` event.
75
+ * Called when a gamepad becomes active through the shared input reader.
61
76
  *
62
- * The default accepts the first gamepad that connects and dispatches `connected`.
63
- * Override to customize selection behavior.
77
+ * The default dispatches a `connected` event.
64
78
  *
65
- * @param gamepad - The gamepad that just connected.
79
+ * @param gamepad - The gamepad that became active.
66
80
  */
67
81
  protected onGamepadConnected(gamepad: Gamepad): void;
68
82
  /**
69
- * Called when any gamepad fires a `gamepaddisconnected` event.
83
+ * Called when the active gamepad disconnects through the shared input reader.
70
84
  *
71
- * The default clears `this.gamepad` and dispatches `disconnected` if the
72
- * disconnecting gamepad was the active one. Override to add custom cleanup.
85
+ * The default dispatches a `disconnected` event.
73
86
  *
74
- * @param gamepad - The gamepad that just disconnected.
87
+ * @param gamepad - The gamepad that was active before disconnection.
75
88
  */
76
89
  protected onGamepadDisconnected(gamepad: Gamepad): void;
77
90
  }
@@ -1,11 +1,11 @@
1
- import { GamepadManager } from "./gamepad-manager.js";
1
+ import { GamepadInput } from "./gamepad-input.js";
2
2
  import { EventDispatcher } from "three";
3
3
  //#region src/gamepad-controls.ts
4
4
  /**
5
5
  * Abstract base class for Three.js gamepad controls.
6
6
  *
7
- * Handles the gamepad connection lifecycle and input polling so subclasses
8
- * only need to implement {@link onUpdate}.
7
+ * Delegates gamepad connection lifecycle and input polling to {@link GamepadInput}
8
+ * so subclasses can focus on mapping input to the wrapped Three.js control.
9
9
  */
10
10
  var GamepadControls = class extends EventDispatcher {
11
11
  /**
@@ -17,20 +17,51 @@ var GamepadControls = class extends EventDispatcher {
17
17
  * The currently active gamepad, or `null` if no gamepad is connected.
18
18
  */
19
19
  gamepad = null;
20
- #manager;
20
+ #gamepadInput;
21
+ /**
22
+ * Bound input connection listener kept so it can be removed in {@link dispose}.
23
+ */
21
24
  #onGamepadConnected;
25
+ /**
26
+ * Bound input disconnection listener kept so it can be removed in {@link dispose}.
27
+ */
22
28
  #onGamepadDisconnected;
29
+ /**
30
+ * Creates the base input reader and attaches lifecycle listeners.
31
+ */
23
32
  constructor() {
24
33
  super();
25
- this.#manager = new GamepadManager();
26
- this.#onGamepadConnected = (event) => {
27
- this.onGamepadConnected(event.gamepad);
28
- };
29
- this.#onGamepadDisconnected = (event) => {
30
- this.onGamepadDisconnected(event.gamepad);
31
- };
32
- window.addEventListener("gamepadconnected", this.#onGamepadConnected);
33
- window.addEventListener("gamepaddisconnected", this.#onGamepadDisconnected);
34
+ this.#gamepadInput = new GamepadInput();
35
+ this.#onGamepadConnected = this.#handleGamepadConnected.bind(this);
36
+ this.#onGamepadDisconnected = this.#handleGamepadDisconnected.bind(this);
37
+ this.#gamepadInput.addEventListener("connected", this.#onGamepadConnected);
38
+ this.#gamepadInput.addEventListener("disconnected", this.#onGamepadDisconnected);
39
+ }
40
+ /**
41
+ * Low-level gamepad input reader used by subclasses.
42
+ *
43
+ * @returns The shared input reader for the active gamepad.
44
+ */
45
+ get gamepadInput() {
46
+ return this.#gamepadInput;
47
+ }
48
+ /**
49
+ * Forwards an input connection event to the overridable lifecycle hook.
50
+ *
51
+ * @param event - Input event containing the connected gamepad snapshot.
52
+ */
53
+ #handleGamepadConnected(event) {
54
+ this.gamepad = this.#gamepadInput.gamepad;
55
+ this.onGamepadConnected(event.gamepad);
56
+ }
57
+ /**
58
+ * Forwards an input disconnection event to the overridable lifecycle hook.
59
+ *
60
+ * @param event - Input event containing the disconnected gamepad snapshot.
61
+ */
62
+ #handleGamepadDisconnected(event) {
63
+ this.gamepad = this.#gamepadInput.gamepad;
64
+ this.onGamepadDisconnected(event.gamepad);
34
65
  }
35
66
  /**
36
67
  * Advances the controller by one frame. Call this inside your render loop.
@@ -39,69 +70,45 @@ var GamepadControls = class extends EventDispatcher {
39
70
  */
40
71
  update(deltaTime) {
41
72
  if (!this.enabled) return;
42
- this.#manager.activeGamepad = this.gamepad;
43
- const { gamepad, connected, disconnected } = this.#manager.update();
44
- if (connected !== null) {
45
- this.#manager.activeGamepad = this.gamepad;
46
- this.onGamepadConnected(connected);
47
- this.#manager.activeGamepad = this.gamepad;
48
- } else if (disconnected !== null) {
49
- this.#manager.activeGamepad = this.gamepad;
50
- this.onGamepadDisconnected(disconnected);
51
- this.#manager.activeGamepad = this.gamepad;
52
- return;
53
- } else {
54
- this.gamepad = gamepad;
55
- this.#manager.activeGamepad = this.gamepad;
56
- }
73
+ this.#gamepadInput.update();
74
+ this.gamepad = this.#gamepadInput.gamepad;
57
75
  if (this.gamepad === null) return;
58
- this.onUpdate(deltaTime, this.gamepad);
76
+ this.onUpdate(deltaTime);
59
77
  }
60
78
  /**
61
79
  * Removes all event listeners attached by this controller. Call when no longer needed.
62
80
  */
63
81
  dispose() {
64
- window.removeEventListener("gamepadconnected", this.#onGamepadConnected);
65
- window.removeEventListener("gamepaddisconnected", this.#onGamepadDisconnected);
82
+ this.#gamepadInput.removeEventListener("connected", this.#onGamepadConnected);
83
+ this.#gamepadInput.removeEventListener("disconnected", this.#onGamepadDisconnected);
84
+ this.#gamepadInput.dispose();
66
85
  this.gamepad = null;
67
- this.#manager.activeGamepad = null;
68
86
  this.enabled = false;
69
87
  }
70
88
  /**
71
- * Called when any gamepad fires a `gamepadconnected` event.
89
+ * Called when a gamepad becomes active through the shared input reader.
72
90
  *
73
- * The default accepts the first gamepad that connects and dispatches `connected`.
74
- * Override to customize selection behavior.
91
+ * The default dispatches a `connected` event.
75
92
  *
76
- * @param gamepad - The gamepad that just connected.
93
+ * @param gamepad - The gamepad that became active.
77
94
  */
78
95
  onGamepadConnected(gamepad) {
79
- this.#manager.activeGamepad = this.gamepad;
80
- if (!this.#manager.connect(gamepad)) return;
81
- const connectedGamepad = this.#manager.activeGamepad;
82
- if (connectedGamepad === null) return;
83
- this.gamepad = connectedGamepad;
84
96
  this.dispatchEvent({
85
97
  type: "connected",
86
- gamepad: connectedGamepad
98
+ gamepad
87
99
  });
88
100
  }
89
101
  /**
90
- * Called when any gamepad fires a `gamepaddisconnected` event.
102
+ * Called when the active gamepad disconnects through the shared input reader.
91
103
  *
92
- * The default clears `this.gamepad` and dispatches `disconnected` if the
93
- * disconnecting gamepad was the active one. Override to add custom cleanup.
104
+ * The default dispatches a `disconnected` event.
94
105
  *
95
- * @param gamepad - The gamepad that just disconnected.
106
+ * @param gamepad - The gamepad that was active before disconnection.
96
107
  */
97
108
  onGamepadDisconnected(gamepad) {
98
- this.#manager.activeGamepad = this.gamepad;
99
- const disconnectedGamepad = this.#manager.disconnect(gamepad);
100
- if (disconnectedGamepad === null) return;
101
- this.gamepad = this.#manager.activeGamepad;
102
109
  this.dispatchEvent({
103
110
  type: "disconnected",
104
- gamepad: disconnectedGamepad
111
+ gamepad
105
112
  });
106
113
  }
107
114
  };
@@ -68,9 +68,8 @@ declare class GamepadDragControls extends GamepadControls {
68
68
  * and rotate behavior.
69
69
  *
70
70
  * @param deltaTime - Seconds since the last frame.
71
- * @param gamepad - Fresh gamepad snapshot provided by the base class.
72
71
  */
73
- protected onUpdate(deltaTime: number, gamepad: Gamepad): void;
72
+ protected onUpdate(deltaTime: number): void;
74
73
  /**
75
74
  * Releases any selected object and removes hover state before disposing the
76
75
  * gamepad lifecycle listeners.