three-gamepad-controls 0.24.2 → 0.25.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
@@ -92,7 +92,7 @@ pn test:coverage
92
92
  | --- | --- |
93
93
  | `0.184.x` | `<=0.22.x` |
94
94
  | `0.185.x` | `0.23.x` |
95
- | `0.186.x` | `0.24.x` |
95
+ | `0.186.x` | `0.24.x`, `0.25.x` |
96
96
 
97
97
  ## 📖 Documentation
98
98
 
@@ -75,6 +75,8 @@ export type GamepadArcballControlsOptions = GamepadControlsOptions & {
75
75
  * Call `update()` inside the render loop to poll gamepad input and apply
76
76
  * Arcball transformations. The wrapped `ArcballControls.update()` is only
77
77
  * needed after manual camera or target changes, matching Arcball's native API.
78
+ * Gamepad `start`, `change`, and `end` events are dispatched on the native controls.
79
+ * The balanced session belongs to this wrapper, including isolated focus commands.
78
80
  */
79
81
  export declare class GamepadArcballControls extends GamepadControls {
80
82
  #private;
@@ -85,13 +87,31 @@ export declare class GamepadArcballControls extends GamepadControls {
85
87
  */
86
88
  constructor(controls: ArcballControls, options?: Partial<GamepadArcballControlsOptions>);
87
89
  /**
88
- * Maps the current gamepad state to `ArcballControls` rotation, pan, zoom,
89
- * z-rotation, and center focus.
90
+ * Polls and applies gamepad input, ignoring updates from synchronous native listeners.
91
+ * Pausing through `enabled` retains the owned interaction until input resumes,
92
+ * the gamepad disconnects, or the wrapper is disposed.
90
93
  *
91
94
  * @param deltaTime - Seconds since the last frame.
92
95
  */
96
+ update(deltaTime: number): void;
97
+ /**
98
+ * Reads each binding once and applies accepted Arcball transforms in one session.
99
+ * Revalidates the cached frame after `start` and `change`; an isolated focus
100
+ * command ends immediately, while continuous input keeps the session open.
101
+ *
102
+ * @param deltaTime - Seconds elapsed for this input frame.
103
+ */
93
104
  protected onUpdate(deltaTime: number): void;
105
+ /**
106
+ * Removes gamepad listeners and ends only this wrapper's active interaction.
107
+ * Repeated calls are safe; the native Arcball instance is not disposed.
108
+ */
94
109
  dispose(): void;
110
+ /**
111
+ * Ends the owned interaction before forwarding the active gamepad's disconnection.
112
+ *
113
+ * @param gamepad - The gamepad that just disconnected.
114
+ */
95
115
  protected onGamepadDisconnected(gamepad: Gamepad): void;
96
116
  }
97
117
  //#endregion
@@ -32,6 +32,8 @@ const ZOOM_NOTCHES_PER_SECOND = 8;
32
32
  * Call `update()` inside the render loop to poll gamepad input and apply
33
33
  * Arcball transformations. The wrapped `ArcballControls.update()` is only
34
34
  * needed after manual camera or target changes, matching Arcball's native API.
35
+ * Gamepad `start`, `change`, and `end` events are dispatched on the native controls.
36
+ * The balanced session belongs to this wrapper, including isolated focus commands.
35
37
  */
36
38
  var GamepadArcballControls = class extends GamepadControls {
37
39
  #controls;
@@ -43,7 +45,12 @@ var GamepadArcballControls = class extends GamepadControls {
43
45
  #cameraForward;
44
46
  #cameraRight;
45
47
  #previousUp;
48
+ /** Whether this wrapper owns an active gamepad interaction. */
46
49
  #wasInteracting = false;
50
+ /** Blocks recursive updates while a frame is being processed. */
51
+ #updating = false;
52
+ /** Blocks recursive updates while the owned interaction is ending. */
53
+ #ending = false;
47
54
  /**
48
55
  * @param controls - A Three.js `ArcballControls` instance.
49
56
  * @param options - Optional overrides for the default behavior.
@@ -67,64 +74,134 @@ var GamepadArcballControls = class extends GamepadControls {
67
74
  this.#previousUp = new Vector3();
68
75
  }
69
76
  /**
70
- * Maps the current gamepad state to `ArcballControls` rotation, pan, zoom,
71
- * z-rotation, and center focus.
77
+ * Polls and applies gamepad input, ignoring updates from synchronous native listeners.
78
+ * Pausing through `enabled` retains the owned interaction until input resumes,
79
+ * the gamepad disconnects, or the wrapper is disposed.
72
80
  *
73
81
  * @param deltaTime - Seconds since the last frame.
74
82
  */
83
+ update(deltaTime) {
84
+ if (this.#updating || this.#ending) return;
85
+ this.#updating = true;
86
+ try {
87
+ super.update(deltaTime);
88
+ } finally {
89
+ this.#updating = false;
90
+ }
91
+ }
92
+ /**
93
+ * Reads each binding once and applies accepted Arcball transforms in one session.
94
+ * Revalidates the cached frame after `start` and `change`; an isolated focus
95
+ * command ends immediately, while continuous input keeps the session open.
96
+ *
97
+ * @param deltaTime - Seconds elapsed for this input frame.
98
+ */
75
99
  onUpdate(deltaTime) {
76
- const controls = this.#controls;
77
- const { rotateSpeed, panSpeed, zoomSpeed, zRotateSpeed, rotateStick, panStick, buttonDeadzone, buttonZoomIn, buttonZoomOut, buttonZRotateLeft, buttonZRotateRight, buttonFocus } = this.#options;
78
- const input = this.gamepadInput;
79
- const focusPoint = this.#consumeFocusPoint(buttonFocus);
80
100
  if (!this.#canApplyInput()) return;
81
- let rotateX = 0;
82
- let rotateY = 0;
83
- if (controls.enableRotate) {
84
- const rotate = input.stick(rotateStick.xAxis, rotateStick.yAxis, rotateStick.pipeline);
85
- rotateX = rotate.x;
86
- rotateY = rotate.y;
87
- }
88
- let panX = 0;
89
- let panY = 0;
90
- if (controls.enablePan) {
91
- const pan = input.stick(panStick.xAxis, panStick.yAxis, panStick.pipeline);
92
- panX = pan.x;
93
- panY = pan.y;
94
- }
95
- const zoom = controls.enableZoom ? input.buttonValue(buttonZoomIn) - input.buttonValue(buttonZoomOut) : 0;
96
- const zRotation = controls.enableRotate ? input.buttonValue(buttonZRotateLeft) - input.buttonValue(buttonZRotateRight) : 0;
97
- const activeInput = rotateX !== 0 || rotateY !== 0 || panX !== 0 || panY !== 0 || Math.abs(zoom) > buttonDeadzone || Math.abs(zRotation) > buttonDeadzone;
98
- if (!activeInput && focusPoint === null) {
99
- this.#endInteraction();
100
- return;
101
- }
101
+ const { rotateStick, panStick, buttonZoomIn, buttonZoomOut, buttonZRotateLeft, buttonZRotateRight, buttonFocus } = this.#options;
102
+ const input = this.gamepadInput;
103
+ const rotate = input.stick(rotateStick.xAxis, rotateStick.yAxis, rotateStick.pipeline);
104
+ const pan = input.stick(panStick.xAxis, panStick.yAxis, panStick.pipeline);
105
+ const frame = {
106
+ rotateX: rotate.x,
107
+ rotateY: rotate.y,
108
+ panX: pan.x,
109
+ panY: pan.y,
110
+ zoom: input.buttonValue(buttonZoomIn) - input.buttonValue(buttonZoomOut),
111
+ zRotation: input.buttonValue(buttonZRotateLeft) - input.buttonValue(buttonZRotateRight),
112
+ focus: this.#consumeFocusPoint(buttonFocus)
113
+ };
114
+ let actions = this.#acceptActions(frame, deltaTime);
115
+ if (actions === null) return;
116
+ const controls = this.#controls;
102
117
  if (!this.#wasInteracting) {
103
118
  this.#wasInteracting = true;
104
119
  controls.dispatchEvent({ type: "start" });
120
+ actions = this.#acceptActions(frame, deltaTime);
121
+ if (actions === null) return;
105
122
  }
106
- if (!this.#canApplyInput()) return;
107
- let changed = false;
108
- if (controls.enableRotate) changed = this.#applyRotation(deltaTime, rotateX, rotateY, rotateSpeed) || changed;
109
- if (controls.enablePan) changed = this.#applyPan(deltaTime, panX, panY, panSpeed) || changed;
110
- if (controls.enableZoom) changed = this.#applyZoom(deltaTime, zoom, zoomSpeed, buttonDeadzone) || changed;
111
- if (controls.enableRotate) changed = this.#applyZRotation(deltaTime, zRotation, zRotateSpeed, buttonDeadzone) || changed;
112
- if (controls.enablePan && controls.enableFocus) changed = this.#applyFocus(focusPoint) || changed;
123
+ let changed = this.#applyRotation(actions.rotateX, actions.rotateY);
124
+ changed = this.#applyPan(actions.panX, actions.panY) || changed;
125
+ changed = this.#applyZoom(actions.zoomSize) || changed;
126
+ changed = this.#applyZRotation(actions.zRotation) || changed;
127
+ changed = this.#applyFocus(actions.focus) || changed;
113
128
  if (changed) {
114
129
  controls.update();
115
130
  controls.updateMatrixState();
116
131
  controls.dispatchEvent({ type: "change" });
117
132
  }
118
- if (!activeInput || !controls.enabled) this.#endInteraction();
133
+ actions = this.#acceptActions(frame, deltaTime);
134
+ if (actions !== null && !this.#hasContinuousInput(actions)) this.#endInteraction();
135
+ }
136
+ /**
137
+ * Resolves a cached frame against current permissions, gains, and zoom validity.
138
+ * Zero deltas and invalid or neutral zoom factors do not sustain an interaction.
139
+ * Ends the owned session when neither continuous input nor focus remains accepted.
140
+ *
141
+ * @param frame - Already processed input and the focus point resolved for this frame.
142
+ * @param delta - Frame duration in seconds.
143
+ * @returns Accepted deltas and focus, or `null` when input application must stop.
144
+ */
145
+ #acceptActions(frame, delta) {
146
+ if (!this.#canApplyInput()) return null;
147
+ const controls = this.#controls;
148
+ const options = this.#options;
149
+ const rotate = controls.enableRotate ? controls.rotateSpeed * options.rotateSpeed * delta * Math.PI : 0;
150
+ const pan = controls.enablePan ? controls._tbRadius * options.panSpeed * delta : 0;
151
+ const zRotation = controls.enableRotate && Math.abs(frame.zRotation) > options.buttonDeadzone ? frame.zRotation * options.zRotateSpeed * delta * Math.PI : 0;
152
+ let zoomSize = 1;
153
+ if (controls.enableZoom && Math.abs(frame.zoom) > options.buttonDeadzone && controls.scaleFactor > 0) {
154
+ const size = controls.scaleFactor ** (frame.zoom * options.zoomSpeed * delta * ZOOM_NOTCHES_PER_SECOND);
155
+ if (Number.isFinite(size) && size > 0) zoomSize = size;
156
+ }
157
+ const actions = {
158
+ rotateX: frame.rotateX * rotate,
159
+ rotateY: frame.rotateY * rotate,
160
+ panX: frame.panX * pan,
161
+ panY: frame.panY * pan,
162
+ zoomSize,
163
+ zRotation,
164
+ focus: controls.enablePan && controls.enableFocus ? frame.focus : null
165
+ };
166
+ if (!this.#hasContinuousInput(actions) && actions.focus === null) {
167
+ this.#endInteraction();
168
+ return null;
169
+ }
170
+ return actions;
119
171
  }
172
+ /**
173
+ * Checks whether accepted movement can sustain a session, excluding one-shot focus.
174
+ *
175
+ * @param actions - Frame deltas already resolved against current permissions and gains.
176
+ * @returns `true` when any rotation, pan, or zoom delta is non-neutral.
177
+ */
178
+ #hasContinuousInput(actions) {
179
+ return actions.rotateX !== 0 || actions.rotateY !== 0 || actions.panX !== 0 || actions.panY !== 0 || actions.zoomSize !== 1 || actions.zRotation !== 0;
180
+ }
181
+ /**
182
+ * Removes gamepad listeners and ends only this wrapper's active interaction.
183
+ * Repeated calls are safe; the native Arcball instance is not disposed.
184
+ */
120
185
  dispose() {
121
186
  super.dispose();
122
187
  this.#endInteraction();
123
188
  }
189
+ /**
190
+ * Ends the owned interaction before forwarding the active gamepad's disconnection.
191
+ *
192
+ * @param gamepad - The gamepad that just disconnected.
193
+ */
124
194
  onGamepadDisconnected(gamepad) {
125
195
  this.#endInteraction();
126
196
  super.onGamepadDisconnected(gamepad);
127
197
  }
198
+ /**
199
+ * Checks whether input remains applicable after a synchronous native callback.
200
+ * Native disable ends the owned session; a wrapper pause retains it until resume,
201
+ * disconnection, or disposal.
202
+ *
203
+ * @returns `true` when the wrapper and native controls are enabled and a gamepad is available.
204
+ */
128
205
  #canApplyInput() {
129
206
  if (!this.#controls.enabled) {
130
207
  this.#endInteraction();
@@ -133,27 +210,23 @@ var GamepadArcballControls = class extends GamepadControls {
133
210
  return this.enabled && this.gamepad !== null;
134
211
  }
135
212
  /**
136
- * Applies gamepad stick rotation through Arcball's runtime rotation helper.
213
+ * Applies accepted angular deltas through native rotation helpers.
137
214
  *
138
- * @param deltaTime - Seconds since the last frame.
139
- * @param rotateX - Horizontal rotation input after dead zone processing.
140
- * @param rotateY - Vertical rotation input after dead zone processing.
141
- * @param rotateSpeed - User-configured rotation speed multiplier.
142
- * @returns `true` when a rotation was applied.
215
+ * @param rotateX - Horizontal rotation delta in radians.
216
+ * @param rotateY - Vertical rotation delta in radians.
217
+ * @returns `true` when at least one rotation transform was applied.
143
218
  */
144
- #applyRotation(deltaTime, rotateX, rotateY, rotateSpeed) {
145
- if (rotateX === 0 && rotateY === 0) return false;
219
+ #applyRotation(rotateX, rotateY) {
146
220
  const controls = this.#controls;
147
- const amount = controls.rotateSpeed * rotateSpeed * deltaTime * Math.PI;
148
221
  let changed = false;
149
222
  if (rotateX !== 0) {
150
223
  this.#rotationAxis.copy(controls.object.up).normalize();
151
- changed = this.#applyRotationAroundAxis(this.#rotationAxis, rotateX * amount) || changed;
224
+ changed = this.#applyRotationAroundAxis(this.#rotationAxis, rotateX);
152
225
  }
153
226
  if (rotateY !== 0) {
154
227
  controls.object.getWorldDirection(this.#cameraForward);
155
228
  this.#cameraRight.crossVectors(this.#cameraForward, controls.object.up).normalize();
156
- changed = this.#applyRotationAroundAxis(this.#cameraRight, -rotateY * amount) || changed;
229
+ changed = this.#applyRotationAroundAxis(this.#cameraRight, -rotateY) || changed;
157
230
  }
158
231
  return changed;
159
232
  }
@@ -174,38 +247,30 @@ var GamepadArcballControls = class extends GamepadControls {
174
247
  return true;
175
248
  }
176
249
  /**
177
- * Applies gamepad pan by converting stick input to Arcball trackball points.
250
+ * Applies accepted pan deltas between two virtual trackball points.
178
251
  *
179
- * @param deltaTime - Seconds since the last frame.
180
- * @param panX - Horizontal pan input after dead zone processing.
181
- * @param panY - Vertical pan input after dead zone processing.
182
- * @param panSpeed - User-configured pan speed multiplier.
183
- * @returns `true` when a pan transform was applied.
252
+ * @param panX - Horizontal displacement in Arcball's virtual trackball space.
253
+ * @param panY - Vertical displacement in Arcball's virtual trackball space.
254
+ * @returns `true` when a nonzero pan transform was applied.
184
255
  */
185
- #applyPan(deltaTime, panX, panY, panSpeed) {
256
+ #applyPan(panX, panY) {
186
257
  if (panX === 0 && panY === 0) return false;
187
258
  const controls = this.#controls;
188
- const distance = controls._tbRadius * panSpeed * deltaTime;
189
259
  controls.updateMatrixState();
190
260
  this.#panStart.set(0, 0, 0);
191
- this.#panEnd.set(panX * distance, panY * distance, 0);
261
+ this.#panEnd.set(panX, panY, 0);
192
262
  this.#applyTransform(controls.pan(this.#panStart, this.#panEnd));
193
263
  return true;
194
264
  }
195
265
  /**
196
- * Applies trigger-driven zoom around Arcball's gizmo center.
266
+ * Applies an accepted zoom factor around the native gizmo center.
197
267
  *
198
- * @param deltaTime - Seconds since the last frame.
199
- * @param zoom - Signed zoom input from the configured trigger pair.
200
- * @param zoomSpeed - User-configured zoom speed multiplier.
201
- * @param buttonDeadzone - Trigger dead zone threshold.
202
- * @returns `true` when a zoom transform was applied.
268
+ * @param size - Positive finite scale factor; `1` leaves zoom unchanged.
269
+ * @returns `true` when the factor is non-neutral and the native helper returns a transform.
203
270
  */
204
- #applyZoom(deltaTime, zoom, zoomSpeed, buttonDeadzone) {
205
- if (Math.abs(zoom) <= buttonDeadzone || this.#controls.scaleFactor <= 0) return false;
271
+ #applyZoom(size) {
272
+ if (size === 1) return false;
206
273
  const controls = this.#controls;
207
- const size = controls.scaleFactor ** (zoom * zoomSpeed * deltaTime * ZOOM_NOTCHES_PER_SECOND);
208
- if (!Number.isFinite(size) || size <= 0 || size === 1) return false;
209
274
  controls.updateMatrixState();
210
275
  const transformation = controls.scale(size, controls._gizmos.position);
211
276
  if (transformation === void 0) return false;
@@ -213,18 +278,14 @@ var GamepadArcballControls = class extends GamepadControls {
213
278
  return true;
214
279
  }
215
280
  /**
216
- * Applies shoulder-button rotation around the current camera view axis.
281
+ * Applies accepted rotation around the view axis and updates the camera's up vector.
217
282
  *
218
- * @param deltaTime - Seconds since the last frame.
219
- * @param zRotation - Signed z-rotation input from the configured buttons.
220
- * @param zRotateSpeed - User-configured z-rotation speed multiplier.
221
- * @param buttonDeadzone - Button value dead zone threshold.
222
- * @returns `true` when a z-rotation transform was applied.
283
+ * @param angle - Rotation delta in radians.
284
+ * @returns `true` when a nonzero rotation transform was applied.
223
285
  */
224
- #applyZRotation(deltaTime, zRotation, zRotateSpeed, buttonDeadzone) {
225
- if (Math.abs(zRotation) <= buttonDeadzone) return false;
286
+ #applyZRotation(angle) {
287
+ if (angle === 0) return false;
226
288
  const controls = this.#controls;
227
- const angle = zRotation * zRotateSpeed * deltaTime * Math.PI;
228
289
  controls.updateMatrixState();
229
290
  controls.object.getWorldDirection(controls._rotationAxis);
230
291
  this.#previousUp.copy(controls.object.up);
@@ -266,10 +327,19 @@ var GamepadArcballControls = class extends GamepadControls {
266
327
  if (!this.gamepadInput.wasPressed(buttonFocus) || !controls.enabled || !controls.enablePan || !controls.enableFocus || controls.scene === null) return null;
267
328
  return controls.unprojectOnObj(this.#centerNdc, controls.object);
268
329
  }
330
+ /**
331
+ * Releases session ownership before dispatching the native `end` event once.
332
+ * Blocks recursive updates during finalization, including disconnection and disposal.
333
+ */
269
334
  #endInteraction() {
270
335
  if (!this.#wasInteracting) return;
271
336
  this.#wasInteracting = false;
272
- this.#controls.dispatchEvent({ type: "end" });
337
+ this.#ending = true;
338
+ try {
339
+ this.#controls.dispatchEvent({ type: "end" });
340
+ } finally {
341
+ this.#ending = false;
342
+ }
273
343
  }
274
344
  };
275
345
  //#endregion
@@ -63,7 +63,7 @@ var GamepadControls = class extends EventDispatcher {
63
63
  */
64
64
  #handleGamepadConnected(event) {
65
65
  this.gamepad = this.#gamepadInput.gamepad;
66
- this.onGamepadConnected(event.gamepad);
66
+ this.onGamepadConnected(event.detail.gamepad);
67
67
  }
68
68
  /**
69
69
  * Forwards an input disconnection event to the overridable lifecycle hook.
@@ -72,7 +72,7 @@ var GamepadControls = class extends EventDispatcher {
72
72
  */
73
73
  #handleGamepadDisconnected(event) {
74
74
  this.gamepad = this.#gamepadInput.gamepad;
75
- this.onGamepadDisconnected(event.gamepad);
75
+ this.onGamepadDisconnected(event.detail.gamepad);
76
76
  }
77
77
  /**
78
78
  * Advances the controller by one frame. Call this inside your render loop.
@@ -1,30 +1,33 @@
1
1
  import { GamepadStick, GamepadStickPipeline } from "./gamepad-stick-processing.js";
2
- import { EventDispatcher } from "three";
3
2
  //#region src/gamepad-input.d.ts
4
3
  /**
5
- * Event map for {@link GamepadInput}.
4
+ * Native custom event map for {@link GamepadInput}.
5
+ * Gamepad snapshots are available through `event.detail.gamepad`.
6
6
  */
7
7
  export type GamepadInputEventMap = {
8
8
  /**
9
9
  * Fired when a gamepad is connected and becomes active.
10
10
  */
11
- connected: {
11
+ connected: CustomEvent<{
12
12
  /**
13
13
  * Gamepad snapshot that became active.
14
14
  */
15
15
  gamepad: Gamepad;
16
- };
16
+ }>;
17
17
  /**
18
18
  * Fired on a matching browser disconnection event or when polling observes
19
19
  * the active slot missing or disconnected. Continuous snapshots in the same
20
20
  * slot do not establish a physical-device identity or signal replacement.
21
21
  */
22
- disconnected: {
22
+ disconnected: CustomEvent<{
23
23
  /**
24
24
  * Gamepad snapshot that was active before disconnection.
25
25
  */
26
26
  gamepad: Gamepad;
27
- };
27
+ }>;
28
+ };
29
+ type GamepadInputEventListener<K extends keyof GamepadInputEventMap> = ((this: GamepadInput, event: GamepadInputEventMap[K]) => void) | {
30
+ handleEvent(event: GamepadInputEventMap[K]): void;
28
31
  };
29
32
  /**
30
33
  * Configuration for {@link GamepadInput}.
@@ -66,8 +69,13 @@ export type GamepadAxisOptions = {
66
69
  * Gamepad input state reader for gameplay, menus, and custom actions.
67
70
  *
68
71
  * Call {@link update} once per frame before reading button transitions or axes.
72
+ * Connection notifications are synchronous native `CustomEvent` objects with
73
+ * the gamepad snapshot in `event.detail.gamepad`. Input state is updated before
74
+ * listeners run. Inherited `dispatchEvent()` requires an `Event` instance;
75
+ * listener exceptions are reported by the browser instead of propagating to
76
+ * the dispatch caller.
69
77
  */
70
- export declare class GamepadInput extends EventDispatcher<GamepadInputEventMap> {
78
+ export declare class GamepadInput extends EventTarget {
71
79
  #private;
72
80
  /**
73
81
  * When `false`, polling through `update()` is paused and the last observed
@@ -84,6 +92,67 @@ export declare class GamepadInput extends EventDispatcher<GamepadInputEventMap>
84
92
  * `MIN_GAMEPAD_INDEX` through `MAX_GAMEPAD_INDEX`.
85
93
  */
86
94
  constructor(options?: Partial<GamepadInputOptions>);
95
+ /**
96
+ * Adds a native event listener with typed connection event details.
97
+ *
98
+ * Connection callbacks receive a `CustomEvent` with `detail.gamepad`.
99
+ * Supports callback functions and objects with `handleEvent`. Registering
100
+ * the same event type, listener, and capture flag again does not duplicate it.
101
+ * A regular callback's `this` is this input; a listener object's `this` is
102
+ * the listener object. Manage subscriptions separately from {@link dispose}.
103
+ *
104
+ * @param type - Event name to observe.
105
+ * @param listener - Callback or listener object, or `null` for a no-op.
106
+ * @param options - Capture flag (default `false`) or native options including
107
+ * `capture`, `once`, `passive`, and `signal`.
108
+ * @example
109
+ * ```ts
110
+ * const subscriptions = new AbortController();
111
+ * input.addEventListener("connected", (event) => {
112
+ * console.log(event.detail.gamepad.id);
113
+ * }, { signal: subscriptions.signal });
114
+ * subscriptions.abort();
115
+ * ```
116
+ */
117
+ addEventListener<K extends keyof GamepadInputEventMap>(type: K, listener: GamepadInputEventListener<K> | null, options?: boolean | AddEventListenerOptions): void;
118
+ /**
119
+ * Adds a listener using standard `EventTarget` types for any event name.
120
+ *
121
+ * @param type - Event name to observe.
122
+ * @param listener - Native callback or listener object, or `null` for a no-op.
123
+ * @param options - Capture flag or native event listener options.
124
+ */
125
+ addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void;
126
+ /**
127
+ * Removes a native event listener with the matching capture option.
128
+ *
129
+ * The event type, callback or object identity, and capture flag must match
130
+ * registration. Other registration options do not affect removal. A missing
131
+ * listener or `null` is a no-op. Removal during dispatch prevents a pending
132
+ * invocation of that listener.
133
+ *
134
+ * @param type - Event name being observed.
135
+ * @param listener - Previously registered callback or listener object, or `null`.
136
+ * @param options - Capture flag (default `false`) or an object with `capture`.
137
+ * @example
138
+ * ```ts
139
+ * const onConnected = (event: GamepadInputEventMap["connected"]) => {
140
+ * console.log(event.detail.gamepad.id);
141
+ * };
142
+ * input.addEventListener("connected", onConnected);
143
+ * input.removeEventListener("connected", onConnected);
144
+ * ```
145
+ */
146
+ removeEventListener<K extends keyof GamepadInputEventMap>(type: K, listener: GamepadInputEventListener<K> | null, options?: boolean | EventListenerOptions): void;
147
+ /**
148
+ * Removes a listener using standard `EventTarget` types for any event name.
149
+ *
150
+ * @param type - Event name being observed.
151
+ * @param listener - Previously registered native callback or listener object,
152
+ * or `null` for a no-op.
153
+ * @param options - Capture flag or an object with the matching `capture` flag.
154
+ */
155
+ removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions): void;
87
156
  /**
88
157
  * The currently active gamepad, or `null` if no gamepad is connected.
89
158
  *
@@ -1,7 +1,6 @@
1
1
  import { isGamepadVibrationSupported, playGamepadVibrationEffect, resetGamepadVibration } from "./gamepad-haptics.js";
2
2
  import { GamepadManager } from "./gamepad-manager.js";
3
3
  import { DEFAULT_GAMEPAD_STICK_PIPELINE } from "./gamepad-stick-processing.js";
4
- import { EventDispatcher } from "three";
5
4
  //#region src/gamepad-input.ts
6
5
  const DEFAULT_GAMEPAD_INPUT_OPTIONS = {
7
6
  axisDeadzone: .1,
@@ -49,8 +48,13 @@ const getGamepadButtonValue = (gamepad, button) => {
49
48
  * Gamepad input state reader for gameplay, menus, and custom actions.
50
49
  *
51
50
  * Call {@link update} once per frame before reading button transitions or axes.
51
+ * Connection notifications are synchronous native `CustomEvent` objects with
52
+ * the gamepad snapshot in `event.detail.gamepad`. Input state is updated before
53
+ * listeners run. Inherited `dispatchEvent()` requires an `Event` instance;
54
+ * listener exceptions are reported by the browser instead of propagating to
55
+ * the dispatch caller.
52
56
  */
53
- var GamepadInput = class extends EventDispatcher {
57
+ var GamepadInput = class extends EventTarget {
54
58
  /**
55
59
  * When `false`, polling through `update()` is paused and the last observed
56
60
  * state, including button transitions, is retained. Browser listeners remain
@@ -86,6 +90,12 @@ var GamepadInput = class extends EventDispatcher {
86
90
  window.addEventListener("gamepadconnected", this.#onGamepadConnected);
87
91
  window.addEventListener("gamepaddisconnected", this.#onGamepadDisconnected);
88
92
  }
93
+ addEventListener(type, listener, options) {
94
+ super.addEventListener(type, listener, options);
95
+ }
96
+ removeEventListener(type, listener, options) {
97
+ super.removeEventListener(type, listener, options);
98
+ }
89
99
  /**
90
100
  * Forwards a browser connection event to the active-gamepad adoption logic.
91
101
  *
@@ -156,19 +166,13 @@ var GamepadInput = class extends EventDispatcher {
156
166
  if (connected !== null) {
157
167
  this.#gamepad = gamepad;
158
168
  this.#syncButtonState({ seedPrevious: true });
159
- this.dispatchEvent({
160
- type: "connected",
161
- gamepad: connected
162
- });
169
+ this.dispatchEvent(new CustomEvent("connected", { detail: { gamepad: connected } }));
163
170
  return;
164
171
  }
165
172
  if (disconnected !== null) {
166
173
  this.#gamepad = null;
167
174
  this.#clearButtonState();
168
- this.dispatchEvent({
169
- type: "disconnected",
170
- gamepad: disconnected
171
- });
175
+ this.dispatchEvent(new CustomEvent("disconnected", { detail: { gamepad: disconnected } }));
172
176
  return;
173
177
  }
174
178
  this.#gamepad = gamepad;
@@ -291,10 +295,7 @@ var GamepadInput = class extends EventDispatcher {
291
295
  if (connectedGamepad === null) return;
292
296
  this.#gamepad = connectedGamepad;
293
297
  this.#syncButtonState({ seedPrevious: true });
294
- this.dispatchEvent({
295
- type: "connected",
296
- gamepad: connectedGamepad
297
- });
298
+ this.dispatchEvent(new CustomEvent("connected", { detail: { gamepad: connectedGamepad } }));
298
299
  }
299
300
  /**
300
301
  * Handles a browser disconnection event for the active gamepad.
@@ -309,10 +310,7 @@ var GamepadInput = class extends EventDispatcher {
309
310
  if (disconnectedGamepad === null) return;
310
311
  this.#gamepad = null;
311
312
  this.#clearButtonState();
312
- this.dispatchEvent({
313
- type: "disconnected",
314
- gamepad: disconnectedGamepad
315
- });
313
+ this.dispatchEvent(new CustomEvent("disconnected", { detail: { gamepad: disconnectedGamepad } }));
316
314
  }
317
315
  /**
318
316
  * Refreshes current and previous pressed-button sets from the active snapshot.
@@ -8,6 +8,8 @@ import { MapControls } from "three/addons/controls/MapControls.js";
8
8
  * mouse conventions. All options from {@link GamepadOrbitControlsOptions} are available.
9
9
  *
10
10
  * Call `update()` inside the render loop **before** `MapControls.update()`.
11
+ * Gamepad interaction events, pause semantics, and combined native/wrapper speeds
12
+ * are inherited from {@link GamepadOrbitControls}.
11
13
  */
12
14
  export declare class GamepadMapControls extends GamepadOrbitControls {
13
15
  /**
@@ -18,6 +18,8 @@ const DEFAULT_MAP_OPTIONS = {
18
18
  * mouse conventions. All options from {@link GamepadOrbitControlsOptions} are available.
19
19
  *
20
20
  * Call `update()` inside the render loop **before** `MapControls.update()`.
21
+ * Gamepad interaction events, pause semantics, and combined native/wrapper speeds
22
+ * are inherited from {@link GamepadOrbitControls}.
21
23
  */
22
24
  var GamepadMapControls = class extends GamepadOrbitControls {
23
25
  /**
@@ -9,17 +9,17 @@ import { OrbitControls } from "three/addons/controls/OrbitControls.js";
9
9
  */
10
10
  export type GamepadOrbitControlsOptions = GamepadControlsOptions & {
11
11
  /**
12
- * Multiplier on orbit rotation speed.
12
+ * Multiplier on `OrbitControls.rotateSpeed`.
13
13
  * @default 1.0
14
14
  */
15
15
  rotateSpeed: number;
16
16
  /**
17
- * Multiplier on pan speed.
17
+ * Multiplier on `OrbitControls.panSpeed`.
18
18
  * @default 1.0
19
19
  */
20
20
  panSpeed: number;
21
21
  /**
22
- * Multiplier on zoom (dolly) speed.
22
+ * Multiplier on `OrbitControls.zoomSpeed`.
23
23
  * @default 1.0
24
24
  */
25
25
  zoomSpeed: number;
@@ -34,7 +34,7 @@ export type GamepadOrbitControlsOptions = GamepadControlsOptions & {
34
34
  */
35
35
  panStick: GamepadStickBindingOptions;
36
36
  /**
37
- * Dead zone threshold for analog trigger values.
37
+ * Each analog trigger must be strictly above this threshold to drive dolly.
38
38
  * @default 0.1
39
39
  */
40
40
  buttonDeadzone: number;
@@ -54,6 +54,8 @@ export type GamepadOrbitControlsOptions = GamepadControlsOptions & {
54
54
  *
55
55
  * Call `update()` inside the render loop **before** `OrbitControls.update()`.
56
56
  * Bindings and speeds are configurable via {@link GamepadOrbitControlsOptions}.
57
+ * The wrapper dispatches balanced gamepad `start` and `end` events on the native
58
+ * controls; native operations and damping remain responsible for `change`.
57
59
  */
58
60
  export declare class GamepadOrbitControls extends GamepadControls {
59
61
  #private;
@@ -64,10 +66,30 @@ export declare class GamepadOrbitControls extends GamepadControls {
64
66
  */
65
67
  constructor(controls: OrbitControls, options?: Partial<GamepadOrbitControlsOptions>);
66
68
  /**
67
- * Maps the current gamepad state to `OrbitControls` rotation, pan, and dolly.
69
+ * Polls the gamepad and applies input, ignoring updates from synchronous listeners.
70
+ * Pausing through `enabled` retains the owned interaction until input resumes,
71
+ * the gamepad disconnects, or the wrapper is disposed.
68
72
  *
69
73
  * @param deltaTime - Seconds since the last frame.
70
74
  */
75
+ update(deltaTime: number): void;
76
+ /**
77
+ * Reads each binding once, manages the gamepad session, and applies native operations.
78
+ * Revalidates the cached frame after synchronous events can change permissions.
79
+ *
80
+ * @param deltaTime - Seconds elapsed for this input frame.
81
+ */
71
82
  protected onUpdate(deltaTime: number): void;
83
+ /**
84
+ * Removes gamepad listeners and ends only this wrapper's active interaction.
85
+ * Repeated calls are safe; the native controls and their damping are preserved.
86
+ */
87
+ dispose(): void;
88
+ /**
89
+ * Ends the owned interaction before forwarding the active gamepad's disconnection.
90
+ *
91
+ * @param gamepad - The gamepad that just disconnected.
92
+ */
93
+ protected onGamepadDisconnected(gamepad: Gamepad): void;
72
94
  }
73
95
  //#endregion
@@ -25,10 +25,18 @@ const DEFAULT_ORBIT_OPTIONS = {
25
25
  *
26
26
  * Call `update()` inside the render loop **before** `OrbitControls.update()`.
27
27
  * Bindings and speeds are configurable via {@link GamepadOrbitControlsOptions}.
28
+ * The wrapper dispatches balanced gamepad `start` and `end` events on the native
29
+ * controls; native operations and damping remain responsible for `change`.
28
30
  */
29
31
  var GamepadOrbitControls = class extends GamepadControls {
30
32
  #controls;
31
33
  #options;
34
+ /** Whether this wrapper owns an active gamepad interaction. */
35
+ #interacting = false;
36
+ /** Blocks recursive updates while a frame is being processed. */
37
+ #updating = false;
38
+ /** Blocks recursive updates while the owned interaction is ending. */
39
+ #ending = false;
32
40
  /**
33
41
  * @param controls - A Three.js `OrbitControls` instance.
34
42
  * @param options - Optional overrides for the default behavior.
@@ -45,39 +53,143 @@ var GamepadOrbitControls = class extends GamepadControls {
45
53
  };
46
54
  }
47
55
  /**
48
- * Maps the current gamepad state to `OrbitControls` rotation, pan, and dolly.
56
+ * Polls the gamepad and applies input, ignoring updates from synchronous listeners.
57
+ * Pausing through `enabled` retains the owned interaction until input resumes,
58
+ * the gamepad disconnects, or the wrapper is disposed.
49
59
  *
50
60
  * @param deltaTime - Seconds since the last frame.
51
61
  */
62
+ update(deltaTime) {
63
+ if (this.#updating || this.#ending) return;
64
+ this.#updating = true;
65
+ try {
66
+ super.update(deltaTime);
67
+ } finally {
68
+ this.#updating = false;
69
+ }
70
+ }
71
+ /**
72
+ * Reads each binding once, manages the gamepad session, and applies native operations.
73
+ * Revalidates the cached frame after synchronous events can change permissions.
74
+ *
75
+ * @param deltaTime - Seconds elapsed for this input frame.
76
+ */
52
77
  onUpdate(deltaTime) {
53
- if (!this.#canApplyInput()) return;
54
- const controls = this.#controls;
55
- const { rotateSpeed, panSpeed, zoomSpeed, rotateStick, panStick, buttonDeadzone, buttonDollyIn, buttonDollyOut } = this.#options;
78
+ if (!this.#controls.enabled) {
79
+ this.#endInteraction();
80
+ return;
81
+ }
82
+ const { rotateStick, panStick, buttonDeadzone, buttonDollyIn, buttonDollyOut } = this.#options;
56
83
  const input = this.gamepadInput;
57
84
  const rotate = input.stick(rotateStick.xAxis, rotateStick.yAxis, rotateStick.pipeline);
58
- if (controls.enableRotate && rotate.x !== 0) {
59
- controls.rotateLeft(rotate.x * rotateSpeed * deltaTime * Math.PI);
60
- if (!this.#canApplyInput()) return;
61
- }
62
- if (controls.enableRotate && rotate.y !== 0) {
63
- controls.rotateUp(rotate.y * rotateSpeed * deltaTime * Math.PI);
64
- if (!this.#canApplyInput()) return;
65
- }
66
85
  const pan = input.stick(panStick.xAxis, panStick.yAxis, panStick.pipeline);
67
- if (controls.enablePan && (pan.x !== 0 || pan.y !== 0)) {
68
- controls.pan(pan.x * panSpeed * deltaTime * 500, pan.y * panSpeed * deltaTime * 500);
69
- if (!this.#canApplyInput()) return;
70
- }
71
86
  const triggerIn = input.buttonValue(buttonDollyIn);
72
87
  const triggerOut = input.buttonValue(buttonDollyOut);
73
- if (controls.enableZoom && triggerIn > buttonDeadzone) {
74
- controls.dollyIn(1 / (1 + zoomSpeed * triggerIn * deltaTime));
75
- if (!this.#canApplyInput()) return;
88
+ const frame = {
89
+ rotateX: rotate.x,
90
+ rotateY: rotate.y,
91
+ panX: pan.x,
92
+ panY: pan.y,
93
+ dollyIn: triggerIn > buttonDeadzone ? triggerIn : 0,
94
+ dollyOut: triggerOut > buttonDeadzone ? triggerOut : 0
95
+ };
96
+ let actions = this.#acceptActions(frame, deltaTime);
97
+ if (actions === null) return;
98
+ if (!this.#interacting) {
99
+ this.#interacting = true;
100
+ this.#controls.dispatchEvent({ type: "start" });
101
+ actions = this.#acceptActions(frame, deltaTime);
102
+ if (actions === null) return;
103
+ }
104
+ const controls = this.#controls;
105
+ if (actions.rotateX !== 0) {
106
+ controls.rotateLeft(actions.rotateX);
107
+ actions = this.#acceptActions(frame, deltaTime);
108
+ if (actions === null) return;
109
+ }
110
+ if (actions.rotateY !== 0) {
111
+ controls.rotateUp(actions.rotateY);
112
+ actions = this.#acceptActions(frame, deltaTime);
113
+ if (actions === null) return;
114
+ }
115
+ if (actions.panX !== 0 || actions.panY !== 0) {
116
+ controls.pan(actions.panX, actions.panY);
117
+ actions = this.#acceptActions(frame, deltaTime);
118
+ if (actions === null) return;
119
+ }
120
+ if (actions.dollyIn !== 0) {
121
+ controls.dollyIn(1 / (1 + actions.dollyIn));
122
+ actions = this.#acceptActions(frame, deltaTime);
123
+ if (actions === null) return;
124
+ }
125
+ if (actions.dollyOut !== 0) {
126
+ controls.dollyOut(1 / (1 + actions.dollyOut));
127
+ this.#acceptActions(frame, deltaTime);
76
128
  }
77
- if (controls.enableZoom && triggerOut > buttonDeadzone) controls.dollyOut(1 / (1 + zoomSpeed * triggerOut * deltaTime));
78
129
  }
79
- #canApplyInput() {
80
- return this.enabled && this.#controls.enabled && this.gamepad !== null;
130
+ /**
131
+ * Removes gamepad listeners and ends only this wrapper's active interaction.
132
+ * Repeated calls are safe; the native controls and their damping are preserved.
133
+ */
134
+ dispose() {
135
+ super.dispose();
136
+ this.#endInteraction();
137
+ }
138
+ /**
139
+ * Ends the owned interaction before forwarding the active gamepad's disconnection.
140
+ *
141
+ * @param gamepad - The gamepad that just disconnected.
142
+ */
143
+ onGamepadDisconnected(gamepad) {
144
+ this.#endInteraction();
145
+ super.onGamepadDisconnected(gamepad);
146
+ }
147
+ /**
148
+ * Resolves a cached frame against current permissions and native/wrapper speeds.
149
+ * Native disable or lack of actionable input ends the owned session; a wrapper
150
+ * pause stops application while retaining it. Geometric limits do not erase intent.
151
+ *
152
+ * @param input - Already processed sticks and filtered triggers for this frame.
153
+ * @param delta - Frame duration in seconds.
154
+ * @returns Accepted frame deltas, or `null` when input application must stop.
155
+ */
156
+ #acceptActions(input, delta) {
157
+ const controls = this.#controls;
158
+ if (!controls.enabled) {
159
+ this.#endInteraction();
160
+ return null;
161
+ }
162
+ if (!this.enabled || this.gamepad === null) return null;
163
+ const rotate = controls.enableRotate ? controls.rotateSpeed * this.#options.rotateSpeed * delta * Math.PI : 0;
164
+ const pan = controls.enablePan ? controls.panSpeed * this.#options.panSpeed * delta * 500 : 0;
165
+ const zoom = controls.enableZoom ? controls.zoomSpeed * this.#options.zoomSpeed * delta : 0;
166
+ const actions = {
167
+ rotateX: input.rotateX * rotate,
168
+ rotateY: input.rotateY * rotate,
169
+ panX: input.panX * pan,
170
+ panY: input.panY * pan,
171
+ dollyIn: input.dollyIn * zoom,
172
+ dollyOut: input.dollyOut * zoom
173
+ };
174
+ if (!Object.values(actions).some((value) => value !== 0)) {
175
+ this.#endInteraction();
176
+ return null;
177
+ }
178
+ return actions;
179
+ }
180
+ /**
181
+ * Releases session ownership before dispatching the native `end` event once.
182
+ * Blocks recursive updates during finalization without clearing native input or damping.
183
+ */
184
+ #endInteraction() {
185
+ if (!this.#interacting) return;
186
+ this.#interacting = false;
187
+ this.#ending = true;
188
+ try {
189
+ this.#controls.dispatchEvent({ type: "end" });
190
+ } finally {
191
+ this.#ending = false;
192
+ }
81
193
  }
82
194
  };
83
195
  //#endregion
@@ -35,6 +35,8 @@ export type GamepadPointerLockControlsOptions = GamepadControlsOptions & {
35
35
  * Gamepad input is fully independent of pointer lock state. When the pointer
36
36
  * IS locked, mouse and gamepad look inputs are additive.
37
37
  * Bindings and speeds are configurable via {@link GamepadPointerLockControlsOptions}.
38
+ * Look dispatches `change` on the native controls after an actual orientation
39
+ * change; movement does not dispatch it or alter pointer lock state.
38
40
  */
39
41
  export declare class GamepadPointerLockControls extends GamepadControls {
40
42
  #private;
@@ -44,8 +46,19 @@ export declare class GamepadPointerLockControls extends GamepadControls {
44
46
  * Any property not provided falls back to its default value.
45
47
  */
46
48
  constructor(controls: PointerLockControls, options?: Partial<GamepadPointerLockControlsOptions>);
49
+ /**
50
+ * Polls and applies movement and look, ignoring updates from synchronous listeners.
51
+ * Gamepad input remains independent of the native pointer lock state.
52
+ *
53
+ * @param deltaTime - Seconds since the last frame.
54
+ */
55
+ update(deltaTime: number): void;
47
56
  /**
48
57
  * Maps the current gamepad state to `PointerLockControls` movement and look.
58
+ * Dispatches at most one native `change` after look changes orientation by more
59
+ * than `1e-7` radians, treating opposite quaternion signs as equivalent.
60
+ * Translation, zero look gain, and pitch-only input held at a clamp do not
61
+ * dispatch `change`; permitted yaw can still change orientation at a pitch limit.
49
62
  *
50
63
  * @param deltaTime - Seconds since the last frame.
51
64
  */
@@ -1,7 +1,7 @@
1
1
  import { GAMEPAD_AXIS } from "./core.js";
2
2
  import { DEFAULT_GAMEPAD_STICK_PIPELINE, resolveGamepadStickBinding } from "./gamepad-stick-processing.js";
3
3
  import { GamepadControls } from "./gamepad-controls.js";
4
- import { Euler } from "three";
4
+ import { Euler, Quaternion } from "three";
5
5
  //#region src/gamepad-pointer-lock-controls.ts
6
6
  const DEFAULT_POINTER_LOCK_OPTIONS = {
7
7
  moveSpeed: 5,
@@ -23,11 +23,17 @@ const DEFAULT_POINTER_LOCK_OPTIONS = {
23
23
  * Gamepad input is fully independent of pointer lock state. When the pointer
24
24
  * IS locked, mouse and gamepad look inputs are additive.
25
25
  * Bindings and speeds are configurable via {@link GamepadPointerLockControlsOptions}.
26
+ * Look dispatches `change` on the native controls after an actual orientation
27
+ * change; movement does not dispatch it or alter pointer lock state.
26
28
  */
27
29
  var GamepadPointerLockControls = class extends GamepadControls {
28
30
  #controls;
29
31
  #options;
30
32
  #euler;
33
+ /** Reusable orientation snapshot for sign-independent angular change detection. */
34
+ #previousQuaternion = new Quaternion();
35
+ /** Blocks recursive input application from native `change` listeners. */
36
+ #updating = false;
31
37
  /**
32
38
  * @param controls - A Three.js `PointerLockControls` instance.
33
39
  * @param options - Optional overrides for the default behavior.
@@ -45,7 +51,26 @@ var GamepadPointerLockControls = class extends GamepadControls {
45
51
  this.#euler = new Euler(0, 0, 0, "YXZ");
46
52
  }
47
53
  /**
54
+ * Polls and applies movement and look, ignoring updates from synchronous listeners.
55
+ * Gamepad input remains independent of the native pointer lock state.
56
+ *
57
+ * @param deltaTime - Seconds since the last frame.
58
+ */
59
+ update(deltaTime) {
60
+ if (this.#updating) return;
61
+ this.#updating = true;
62
+ try {
63
+ super.update(deltaTime);
64
+ } finally {
65
+ this.#updating = false;
66
+ }
67
+ }
68
+ /**
48
69
  * Maps the current gamepad state to `PointerLockControls` movement and look.
70
+ * Dispatches at most one native `change` after look changes orientation by more
71
+ * than `1e-7` radians, treating opposite quaternion signs as equivalent.
72
+ * Translation, zero look gain, and pitch-only input held at a clamp do not
73
+ * dispatch `change`; permitted yaw can still change orientation at a pitch limit.
49
74
  *
50
75
  * @param deltaTime - Seconds since the last frame.
51
76
  */
@@ -57,14 +82,16 @@ var GamepadPointerLockControls = class extends GamepadControls {
57
82
  if (move.y !== 0) this.#controls.moveForward(-move.y * moveSpeed * deltaTime);
58
83
  if (move.x !== 0) this.#controls.moveRight(move.x * moveSpeed * deltaTime);
59
84
  const look = input.stick(lookStick.xAxis, lookStick.yAxis, lookStick.pipeline);
60
- if (look.x !== 0 || look.y !== 0) {
85
+ const scale = lookSpeed * this.#controls.pointerSpeed * deltaTime * Math.PI;
86
+ if ((look.x !== 0 || look.y !== 0) && scale !== 0) {
61
87
  const camera = this.#controls.object;
62
- const scale = lookSpeed * this.#controls.pointerSpeed * deltaTime * Math.PI;
88
+ this.#previousQuaternion.copy(camera.quaternion);
63
89
  this.#euler.setFromQuaternion(camera.quaternion);
64
90
  this.#euler.y -= look.x * scale;
65
91
  this.#euler.x -= look.y * scale;
66
92
  this.#euler.x = Math.max(Math.PI / 2 - this.#controls.maxPolarAngle, Math.min(Math.PI / 2 - this.#controls.minPolarAngle, this.#euler.x));
67
93
  camera.quaternion.setFromEuler(this.#euler);
94
+ if (this.#previousQuaternion.angleTo(camera.quaternion) > 1e-7) this.#controls.dispatchEvent({ type: "change" });
68
95
  }
69
96
  }
70
97
  };
@@ -34,7 +34,7 @@ export type GamepadTrackballControlsOptions = GamepadControlsOptions & {
34
34
  */
35
35
  panStick: GamepadStickBindingOptions;
36
36
  /**
37
- * Dead zone threshold for analog trigger values.
37
+ * Each analog trigger must be strictly above this threshold before subtraction.
38
38
  * @default 0.1
39
39
  */
40
40
  buttonDeadzone: number;
@@ -54,6 +54,8 @@ export type GamepadTrackballControlsOptions = GamepadControlsOptions & {
54
54
  *
55
55
  * Call `update()` inside the render loop **before** `TrackballControls.update()`.
56
56
  * Bindings and speed multipliers are configurable via {@link GamepadTrackballControlsOptions}.
57
+ * The wrapper dispatches balanced gamepad `start` and `end` events on the native
58
+ * controls; the native update applies speeds and damping and dispatches `change`.
57
59
  */
58
60
  export declare class GamepadTrackballControls extends GamepadControls {
59
61
  #private;
@@ -64,10 +66,30 @@ export declare class GamepadTrackballControls extends GamepadControls {
64
66
  */
65
67
  constructor(controls: TrackballControls, options?: Partial<GamepadTrackballControlsOptions>);
66
68
  /**
67
- * Maps the current gamepad state to `TrackballControls` rotation, pan, and zoom.
69
+ * Polls the gamepad and queues input, ignoring updates from synchronous listeners.
70
+ * Pausing through `enabled` retains the owned interaction until input resumes,
71
+ * the gamepad disconnects, or the wrapper is disposed.
68
72
  *
69
73
  * @param deltaTime - Seconds since the last frame.
70
74
  */
75
+ update(deltaTime: number): void;
76
+ /**
77
+ * Reads each binding once, manages the gamepad session, and adds accepted deltas
78
+ * to native pointer vectors. Revalidates permissions after the `start` event.
79
+ *
80
+ * @param deltaTime - Seconds elapsed for this input frame.
81
+ */
71
82
  protected onUpdate(deltaTime: number): void;
83
+ /**
84
+ * Removes gamepad listeners and ends only this wrapper's active interaction.
85
+ * Repeated calls are safe; native pointer vectors and damping are preserved.
86
+ */
87
+ dispose(): void;
88
+ /**
89
+ * Ends the owned interaction before forwarding the active gamepad's disconnection.
90
+ *
91
+ * @param gamepad - The gamepad that just disconnected.
92
+ */
93
+ protected onGamepadDisconnected(gamepad: Gamepad): void;
72
94
  }
73
95
  //#endregion
@@ -25,10 +25,18 @@ const DEFAULT_TRACKBALL_OPTIONS = {
25
25
  *
26
26
  * Call `update()` inside the render loop **before** `TrackballControls.update()`.
27
27
  * Bindings and speed multipliers are configurable via {@link GamepadTrackballControlsOptions}.
28
+ * The wrapper dispatches balanced gamepad `start` and `end` events on the native
29
+ * controls; the native update applies speeds and damping and dispatches `change`.
28
30
  */
29
31
  var GamepadTrackballControls = class extends GamepadControls {
30
32
  #controls;
31
33
  #options;
34
+ /** Whether this wrapper owns an active gamepad interaction. */
35
+ #interacting = false;
36
+ /** Blocks recursive updates while a frame is being processed. */
37
+ #updating = false;
38
+ /** Blocks recursive updates while the owned interaction is ending. */
39
+ #ending = false;
32
40
  /**
33
41
  * @param controls - A Three.js `TrackballControls` instance.
34
42
  * @param options - Optional overrides for the default behavior.
@@ -45,76 +53,125 @@ var GamepadTrackballControls = class extends GamepadControls {
45
53
  };
46
54
  }
47
55
  /**
48
- * Maps the current gamepad state to `TrackballControls` rotation, pan, and zoom.
56
+ * Polls the gamepad and queues input, ignoring updates from synchronous listeners.
57
+ * Pausing through `enabled` retains the owned interaction until input resumes,
58
+ * the gamepad disconnects, or the wrapper is disposed.
49
59
  *
50
60
  * @param deltaTime - Seconds since the last frame.
51
61
  */
52
- onUpdate(deltaTime) {
53
- if (!this.#controls.enabled) return;
54
- const { rotateSpeed, panSpeed, zoomSpeed, rotateStick, panStick, buttonDeadzone, buttonZoomIn, buttonZoomOut } = this.#options;
55
- this.#queueRotation(deltaTime, rotateSpeed, rotateStick);
56
- this.#queuePan(deltaTime, panSpeed, panStick);
57
- this.#queueZoom(deltaTime, zoomSpeed, buttonDeadzone, buttonZoomIn, buttonZoomOut);
62
+ update(deltaTime) {
63
+ if (this.#updating || this.#ending) return;
64
+ this.#updating = true;
65
+ try {
66
+ super.update(deltaTime);
67
+ } finally {
68
+ this.#updating = false;
69
+ }
58
70
  }
59
71
  /**
60
- * Queues rotation input into TrackballControls' normalized move state.
72
+ * Reads each binding once, manages the gamepad session, and adds accepted deltas
73
+ * to native pointer vectors. Revalidates permissions after the `start` event.
61
74
  *
62
- * @param deltaTime - Seconds since the last frame.
63
- * @param rotateSpeed - User-configured rotation speed multiplier.
64
- * @param rotateStick - Resolved stick binding for rotation.
75
+ * @param deltaTime - Seconds elapsed for this input frame.
65
76
  */
66
- #queueRotation(deltaTime, rotateSpeed, rotateStick) {
77
+ onUpdate(deltaTime) {
78
+ if (!this.#controls.enabled) {
79
+ this.#endInteraction();
80
+ return;
81
+ }
82
+ const { rotateStick, panStick, buttonDeadzone, buttonZoomIn, buttonZoomOut } = this.#options;
83
+ const input = this.gamepadInput;
84
+ const rotate = input.stick(rotateStick.xAxis, rotateStick.yAxis, rotateStick.pipeline);
85
+ const pan = input.stick(panStick.xAxis, panStick.yAxis, panStick.pipeline);
86
+ const triggerIn = input.buttonValue(buttonZoomIn);
87
+ const triggerOut = input.buttonValue(buttonZoomOut);
88
+ const zoom = (triggerOut > buttonDeadzone ? triggerOut : 0) - (triggerIn > buttonDeadzone ? triggerIn : 0);
89
+ const frame = {
90
+ rotateX: rotate.x,
91
+ rotateY: rotate.y,
92
+ panX: pan.x,
93
+ panY: pan.y,
94
+ zoom
95
+ };
96
+ let actions = this.#acceptActions(frame, deltaTime);
97
+ if (actions === null) return;
98
+ if (!this.#interacting) {
99
+ this.#interacting = true;
100
+ this.#controls.dispatchEvent({ type: "start" });
101
+ actions = this.#acceptActions(frame, deltaTime);
102
+ if (actions === null) return;
103
+ }
67
104
  const controls = this.#controls;
68
- if (controls.noRotate) return;
69
- const rotate = this.gamepadInput.stick(rotateStick.xAxis, rotateStick.yAxis, rotateStick.pipeline);
70
- if (rotate.x === 0 && rotate.y === 0) return;
71
- const scale = rotateSpeed * deltaTime * Math.PI;
72
- controls._moveCurr.x += rotate.x * scale;
73
- controls._moveCurr.y += -rotate.y * scale;
105
+ controls._moveCurr.x += actions.rotateX;
106
+ controls._moveCurr.y -= actions.rotateY;
107
+ controls._panEnd.x += actions.panX;
108
+ controls._panEnd.y += actions.panY;
109
+ controls._zoomEnd.y += actions.zoom;
74
110
  }
75
111
  /**
76
- * Queues pan input into TrackballControls' normalized pan state.
77
- *
78
- * @param deltaTime - Seconds since the last frame.
79
- * @param panSpeed - User-configured pan speed multiplier.
80
- * @param panStick - Resolved stick binding for panning.
112
+ * Removes gamepad listeners and ends only this wrapper's active interaction.
113
+ * Repeated calls are safe; native pointer vectors and damping are preserved.
81
114
  */
82
- #queuePan(deltaTime, panSpeed, panStick) {
83
- const controls = this.#controls;
84
- if (controls.noPan) return;
85
- const pan = this.gamepadInput.stick(panStick.xAxis, panStick.yAxis, panStick.pipeline);
86
- if (pan.x === 0 && pan.y === 0) return;
87
- const scale = panSpeed * deltaTime * this.#getInputDampingFactor();
88
- controls._panEnd.x += pan.x * scale;
89
- controls._panEnd.y += pan.y * scale;
115
+ dispose() {
116
+ super.dispose();
117
+ this.#endInteraction();
90
118
  }
91
119
  /**
92
- * Queues trigger zoom input into TrackballControls' normalized zoom state.
120
+ * Ends the owned interaction before forwarding the active gamepad's disconnection.
93
121
  *
94
- * @param deltaTime - Seconds since the last frame.
95
- * @param zoomSpeed - User-configured zoom speed multiplier.
96
- * @param buttonDeadzone - Trigger dead zone threshold.
97
- * @param buttonZoomIn - Button index for zooming in.
98
- * @param buttonZoomOut - Button index for zooming out.
122
+ * @param gamepad - The gamepad that just disconnected.
99
123
  */
100
- #queueZoom(deltaTime, zoomSpeed, buttonDeadzone, buttonZoomIn, buttonZoomOut) {
101
- const controls = this.#controls;
102
- if (controls.noZoom) return;
103
- const input = this.gamepadInput;
104
- const triggerIn = input.buttonValue(buttonZoomIn);
105
- const triggerOut = input.buttonValue(buttonZoomOut);
106
- if (triggerIn <= buttonDeadzone && triggerOut <= buttonDeadzone) return;
107
- controls._zoomEnd.y += (triggerOut - triggerIn) * zoomSpeed * deltaTime * this.#getInputDampingFactor();
124
+ onGamepadDisconnected(gamepad) {
125
+ this.#endInteraction();
126
+ super.onGamepadDisconnected(gamepad);
108
127
  }
109
128
  /**
110
- * Compensates for TrackballControls reapplying queued pan and zoom deltas
111
- * while their input state catches up through damping.
129
+ * Resolves a cached frame against current permissions, wrapper speeds, and damping.
130
+ * Native speeds only gate acceptance here; Trackball applies them during its update.
131
+ * Native disable or lack of actionable input ends the owned session; a wrapper
132
+ * pause stops application while retaining it. Geometric limits do not erase intent.
112
133
  *
113
- * @returns Multiplier that matches TrackballControls' damping mode.
134
+ * @param input - Already processed sticks and independently filtered trigger difference.
135
+ * @param delta - Frame duration in seconds.
136
+ * @returns Accepted pointer-coordinate deltas, or `null` when application must stop.
114
137
  */
115
- #getInputDampingFactor() {
138
+ #acceptActions(input, delta) {
116
139
  const controls = this.#controls;
117
- return controls.staticMoving ? 1 : controls.dynamicDampingFactor;
140
+ if (!controls.enabled) {
141
+ this.#endInteraction();
142
+ return null;
143
+ }
144
+ if (!this.enabled || this.gamepad === null) return null;
145
+ const damping = controls.staticMoving ? 1 : controls.dynamicDampingFactor;
146
+ const rotate = !controls.noRotate && controls.rotateSpeed !== 0 ? this.#options.rotateSpeed * delta * Math.PI : 0;
147
+ const pan = !controls.noPan && controls.panSpeed !== 0 ? this.#options.panSpeed * delta * damping : 0;
148
+ const zoom = !controls.noZoom && controls.zoomSpeed !== 0 ? this.#options.zoomSpeed * delta * damping : 0;
149
+ const actions = {
150
+ rotateX: input.rotateX * rotate,
151
+ rotateY: input.rotateY * rotate,
152
+ panX: input.panX * pan,
153
+ panY: input.panY * pan,
154
+ zoom: input.zoom * zoom
155
+ };
156
+ if (!Object.values(actions).some((value) => value !== 0)) {
157
+ this.#endInteraction();
158
+ return null;
159
+ }
160
+ return actions;
161
+ }
162
+ /**
163
+ * Releases session ownership before dispatching the native `end` event once.
164
+ * Blocks recursive updates during finalization without clearing native pointer vectors.
165
+ */
166
+ #endInteraction() {
167
+ if (!this.#interacting) return;
168
+ this.#interacting = false;
169
+ this.#ending = true;
170
+ try {
171
+ this.#controls.dispatchEvent({ type: "end" });
172
+ } finally {
173
+ this.#ending = false;
174
+ }
118
175
  }
119
176
  };
120
177
  //#endregion
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.24.2",
4
+ "version": "0.25.0",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/luckasnix/three-gamepad-controls#readme",
7
7
  "author": {