three-gamepad-controls 0.24.1 → 0.24.3

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.
@@ -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
@@ -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
  */