three-gamepad-controls 0.24.0 → 0.24.2

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.
@@ -99,7 +99,18 @@ var GamepadTransformControls = class extends GamepadControls {
99
99
  #rotationQuaternion;
100
100
  #rotationQuaternion2;
101
101
  #tempQuaternion;
102
- #isTransforming = false;
102
+ #segment = null;
103
+ #updating = false;
104
+ #ending = false;
105
+ #disposed = false;
106
+ #interrupted = false;
107
+ #needsNeutral = false;
108
+ #pointerRevision = 0;
109
+ #mouseDownEvent = {
110
+ type: "mouseDown",
111
+ mode: "translate"
112
+ };
113
+ #onNativeMouseDown;
103
114
  #rotationAmount = 0;
104
115
  #freeRotationX = 0;
105
116
  #freeRotationY = 0;
@@ -150,6 +161,10 @@ var GamepadTransformControls = class extends GamepadControls {
150
161
  this.#rotationQuaternion = new Quaternion();
151
162
  this.#rotationQuaternion2 = new Quaternion();
152
163
  this.#tempQuaternion = new Quaternion();
164
+ this.#onNativeMouseDown = (event) => {
165
+ if (event !== this.#mouseDownEvent) this.#pointerRevision += 1;
166
+ };
167
+ controls.addEventListener("mouseDown", this.#onNativeMouseDown);
153
168
  }
154
169
  /**
155
170
  * Maps the current gamepad state to `TransformControls` mode, axis,
@@ -158,38 +173,83 @@ var GamepadTransformControls = class extends GamepadControls {
158
173
  * @param deltaTime - Seconds since the last frame.
159
174
  */
160
175
  onUpdate(deltaTime) {
176
+ if (this.#updating || this.#ending) return;
177
+ this.#updating = true;
178
+ this.#interrupted = false;
179
+ try {
180
+ this.#updateTransform(deltaTime);
181
+ } finally {
182
+ this.#updating = false;
183
+ }
184
+ }
185
+ /**
186
+ * Processes one frame of selection, reset, and movement while respecting
187
+ * segment ownership and the neutral input required before reacquisition.
188
+ *
189
+ * @param deltaTime - Seconds since the last frame.
190
+ */
191
+ #updateTransform(deltaTime) {
192
+ const controls = this.#controls;
193
+ const { transformStick } = this.#options;
194
+ const transform = this.gamepadInput.stick(transformStick.xAxis, transformStick.yAxis, transformStick.pipeline);
195
+ const neutral = transform.x === 0 && transform.y === 0;
196
+ this.#reconcileSegment();
197
+ if (neutral) this.#needsNeutral = false;
198
+ if (!this.#canApplyInput()) return;
199
+ if (controls.dragging && this.#segment === null) {
200
+ this.#needsNeutral = !neutral;
201
+ return;
202
+ }
161
203
  const startedButtons = this.#getStartedButtons();
162
204
  this.#handleModeAndAxisButtons(startedButtons);
205
+ if (!this.#canApplyInput()) return;
163
206
  if (startedButtons.has(this.#options.buttonReset)) this.#resetActiveTransform();
164
- const controls = this.#controls;
207
+ if (!this.#canApplyInput()) return;
165
208
  const object = controls.object;
166
- if (!controls.enabled || object === void 0) {
209
+ if (object === void 0) {
167
210
  this.#endTransform(true);
168
211
  return;
169
212
  }
170
- const axis = this.#ensureValidAxis();
171
- if (axis === null) {
172
- this.#endTransform(true);
173
- return;
174
- }
175
- const { transformStick } = this.#options;
176
- const transform = this.gamepadInput.stick(transformStick.xAxis, transformStick.yAxis, transformStick.pipeline);
177
- if (transform.x === 0 && transform.y === 0) {
213
+ if (neutral) {
178
214
  this.#endTransform(false);
179
215
  return;
180
216
  }
181
- if (!this.#isTransforming) this.#startTransform(object);
217
+ if (this.#needsNeutral) return;
218
+ const axis = this.#resolveAxis();
219
+ this.#setActiveAxis(axis);
220
+ if (!this.#canApplyInput()) return;
221
+ if (axis === null) return;
222
+ if (this.#segment === null) this.#startTransform(object, axis);
223
+ if (!this.#canApplyInput()) return;
224
+ const segment = this.#segment;
225
+ if (!segment.started) {
226
+ segment.started = true;
227
+ const context = this.#readContext();
228
+ this.#mouseDownEvent.mode = segment.mode;
229
+ controls.dispatchEvent(this.#mouseDownEvent);
230
+ this.#afterCallback(context);
231
+ }
232
+ if (!this.#canApplyInput()) return;
182
233
  if (this.#applyCurrentTransform(object, axis, deltaTime, transform.x, transform.y)) {
234
+ const context = this.#readContext();
183
235
  controls.dispatchEvent({ type: "change" });
236
+ if (!this.#afterCallback(context)) return;
184
237
  controls.dispatchEvent({ type: "objectChange" });
238
+ this.#afterCallback(context);
185
239
  }
186
240
  }
187
241
  /**
188
- * Ends any active transform before disposing the gamepad lifecycle listeners.
242
+ * Disables gamepad updates and ends only this wrapper's active transform.
189
243
  */
190
244
  dispose() {
191
- this.#endTransform(true);
192
245
  super.dispose();
246
+ this.#disposed = true;
247
+ if (this.#ending) return;
248
+ try {
249
+ this.#endTransform(true);
250
+ } finally {
251
+ this.#controls.removeEventListener("mouseDown", this.#onNativeMouseDown);
252
+ }
193
253
  }
194
254
  /**
195
255
  * Ends any active transform if the active gamepad disconnects mid-drag.
@@ -201,22 +261,108 @@ var GamepadTransformControls = class extends GamepadControls {
201
261
  super.onGamepadDisconnected(gamepad);
202
262
  }
203
263
  /**
264
+ * Checks whether this update can continue applying gamepad input.
265
+ * Native disable ends the owned segment; a wrapper pause retains it.
266
+ *
267
+ * @returns `true` when input remains enabled, a gamepad is available,
268
+ * and no callback has interrupted this update.
269
+ */
270
+ #canApplyInput() {
271
+ if (!this.#controls.enabled) {
272
+ this.#endTransform(true);
273
+ return false;
274
+ }
275
+ return !this.#interrupted && this.enabled && this.gamepad !== null;
276
+ }
277
+ /**
278
+ * Captures the native context and pointer acquisition revision without
279
+ * modifying the control or copying the attached object's transform.
280
+ *
281
+ * @returns A context snapshot for segment and callback validation.
282
+ */
283
+ #readContext() {
284
+ const { object, mode, space, axis, dragging } = this.#controls;
285
+ return {
286
+ object,
287
+ mode,
288
+ space,
289
+ axis,
290
+ dragging,
291
+ pointerRevision: this.#pointerRevision
292
+ };
293
+ }
294
+ /**
295
+ * Compares the current native context and pointer revision with a snapshot.
296
+ *
297
+ * @param context - Expected object, selection, dragging state, and pointer revision.
298
+ * @returns `true` when every captured context field still matches.
299
+ */
300
+ #matchesContext(context) {
301
+ const controls = this.#controls;
302
+ return controls.object === context.object && controls.mode === context.mode && controls.space === context.space && controls.axis === context.axis && controls.dragging === context.dragging && this.#pointerRevision === context.pointerRevision;
303
+ }
304
+ /**
305
+ * Ends the owned segment when its context changes or its axis is disallowed.
306
+ * A change of attached object also requires neutral input before reacquisition.
307
+ */
308
+ #reconcileSegment() {
309
+ const segment = this.#segment;
310
+ if (segment === null) return;
311
+ if (!this.#matchesContext(segment) || !this.#isAxisAllowed(segment.mode, segment.axis)) {
312
+ if (this.#controls.object !== segment.object) this.#needsNeutral = true;
313
+ this.#endTransform(false);
314
+ }
315
+ }
316
+ /**
317
+ * Revalidates context, segment ownership, and permissions after synchronous
318
+ * callbacks, interrupting this update if its context or segment was invalidated.
319
+ *
320
+ * @param context - Context expected after the operation that invoked callbacks.
321
+ * @returns `true` when this update may continue applying gamepad input.
322
+ */
323
+ #afterCallback(context) {
324
+ const segment = this.#segment;
325
+ if (!this.#matchesContext(context)) {
326
+ this.#interrupted = true;
327
+ if (this.#controls.object !== context.object) this.#needsNeutral = true;
328
+ }
329
+ this.#reconcileSegment();
330
+ if (segment !== null && this.#segment !== segment) this.#interrupted = true;
331
+ return this.#canApplyInput();
332
+ }
333
+ /**
334
+ * Writes a native property and revalidates the context after its synchronous
335
+ * notifications, accounting for the intended property change.
336
+ *
337
+ * @param key - Native selection or dragging property to update.
338
+ * @param value - Value to assign to the selected property.
339
+ */
340
+ #writeProperty(key, value) {
341
+ const context = {
342
+ ...this.#readContext(),
343
+ [key]: value
344
+ };
345
+ const controls = this.#controls;
346
+ controls[key] = value;
347
+ this.#afterCallback(context);
348
+ }
349
+ /**
204
350
  * Applies mode, space, and axis button transitions from the current frame.
205
351
  *
206
352
  * @param startedButtons - Button indices that transitioned to pressed.
207
353
  */
208
354
  #handleModeAndAxisButtons(startedButtons) {
209
355
  const { buttonTranslate, buttonRotate, buttonScale, buttonToggleSpace, buttonAxisX, buttonAxisY, buttonAxisZ, buttonAxisComposite, buttonAxisPrevious, buttonAxisNext } = this.#options;
210
- if (startedButtons.has(buttonTranslate)) this.#setMode("translate");
211
- if (startedButtons.has(buttonRotate)) this.#setMode("rotate");
212
- if (startedButtons.has(buttonScale)) this.#setMode("scale");
213
- if (startedButtons.has(buttonToggleSpace)) this.#toggleSpace();
214
- if (startedButtons.has(buttonAxisX)) this.#selectAxis("X");
215
- if (startedButtons.has(buttonAxisY)) this.#selectAxis("Y");
216
- if (startedButtons.has(buttonAxisZ)) this.#selectAxis("Z");
217
- if (startedButtons.has(buttonAxisComposite)) this.#cycleCompositeAxis();
218
- if (startedButtons.has(buttonAxisPrevious)) this.#cycleAxis(-1);
219
- if (startedButtons.has(buttonAxisNext)) this.#cycleAxis(1);
356
+ if (startedButtons.has(buttonTranslate) && this.#canApplyInput()) this.#setMode("translate");
357
+ if (startedButtons.has(buttonRotate) && this.#canApplyInput()) this.#setMode("rotate");
358
+ if (startedButtons.has(buttonScale) && this.#canApplyInput()) this.#setMode("scale");
359
+ if (startedButtons.has(buttonToggleSpace) && this.#canApplyInput()) this.#toggleSpace();
360
+ if (startedButtons.has(buttonAxisX) && this.#canApplyInput()) this.#selectAxis("X");
361
+ if (startedButtons.has(buttonAxisY) && this.#canApplyInput()) this.#selectAxis("Y");
362
+ if (startedButtons.has(buttonAxisZ) && this.#canApplyInput()) this.#selectAxis("Z");
363
+ if (startedButtons.has(buttonAxisComposite) && this.#canApplyInput()) this.#cycleCompositeAxis();
364
+ if (startedButtons.has(buttonAxisPrevious) && this.#canApplyInput()) this.#cycleAxis(-1);
365
+ if (startedButtons.has(buttonAxisNext) && this.#canApplyInput()) this.#cycleAxis(1);
220
366
  }
221
367
  /**
222
368
  * Switches TransformControls mode and refreshes the active axis.
@@ -226,13 +372,28 @@ var GamepadTransformControls = class extends GamepadControls {
226
372
  #setMode(mode) {
227
373
  if (this.#controls.mode === mode) return;
228
374
  this.#endTransform(false);
375
+ if (!this.#canApplyInput()) return;
376
+ const context = {
377
+ ...this.#readContext(),
378
+ mode
379
+ };
229
380
  this.#controls.setMode(mode);
230
- this.#ensureValidAxis();
381
+ if (this.#afterCallback(context)) this.#setActiveAxis(this.#resolveAxis(null));
231
382
  }
383
+ /**
384
+ * Ends the owned segment and toggles between local and world transform space
385
+ * if input remains permitted after the end notification.
386
+ */
232
387
  #toggleSpace() {
233
388
  const nextSpace = this.#controls.space === "world" ? "local" : "world";
234
389
  this.#endTransform(false);
390
+ if (!this.#canApplyInput()) return;
391
+ const context = {
392
+ ...this.#readContext(),
393
+ space: nextSpace
394
+ };
235
395
  this.#controls.setSpace(nextSpace);
396
+ this.#afterCallback(context);
236
397
  }
237
398
  /**
238
399
  * Selects an explicit axis when it is valid for the current mode.
@@ -242,9 +403,12 @@ var GamepadTransformControls = class extends GamepadControls {
242
403
  #selectAxis(axis) {
243
404
  if (!this.#isAxisAllowed(this.#controls.mode, axis)) return;
244
405
  this.#endTransform(false);
245
- this.#activeAxisByMode[this.#controls.mode] = axis;
246
- this.#ensureValidAxis();
406
+ if (!this.#canApplyInput()) return;
407
+ this.#setActiveAxis(axis);
247
408
  }
409
+ /**
410
+ * Selects the next visible composite axis available in the current mode.
411
+ */
248
412
  #cycleCompositeAxis() {
249
413
  const validAxes = this.#getVisibleAxes(COMPOSITE_AXES[this.#controls.mode]);
250
414
  this.#cycleThroughAxes(validAxes, 1);
@@ -264,30 +428,30 @@ var GamepadTransformControls = class extends GamepadControls {
264
428
  * @param direction - `1` for next axis, `-1` for previous axis.
265
429
  */
266
430
  #cycleThroughAxes(axes, direction) {
431
+ this.#endTransform(false);
432
+ if (!this.#canApplyInput()) return;
267
433
  if (axes.length === 0) {
268
434
  this.#setActiveAxis(null);
269
435
  return;
270
436
  }
271
- this.#endTransform(false);
272
- const current = this.#activeAxisByMode[this.#controls.mode];
273
- const currentIndex = current === null ? -1 : axes.indexOf(current);
437
+ const current = this.#resolveAxis();
438
+ const currentIndex = axes.indexOf(current);
274
439
  const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + axes.length) % axes.length;
275
- this.#activeAxisByMode[this.#controls.mode] = axes[nextIndex];
276
- this.#ensureValidAxis();
440
+ this.#setActiveAxis(axes[nextIndex]);
277
441
  }
278
442
  /**
279
- * Ensures the highlighted TransformControls axis is valid and visible.
443
+ * Resolves selection without writing to the native control or axis memory.
280
444
  *
445
+ * @param nativeAxis - Preferred native axis, defaulting to the current selection.
446
+ * Pass `null` to use mode memory before the first allowed axis.
281
447
  * @returns The active valid axis, or `null` when no axis is available.
282
448
  */
283
- #ensureValidAxis() {
449
+ #resolveAxis(nativeAxis = this.#controls.axis) {
284
450
  const mode = this.#controls.mode;
451
+ if (nativeAxis !== null && this.#isAxisAllowed(mode, nativeAxis)) return nativeAxis;
285
452
  const current = this.#activeAxisByMode[mode];
286
453
  const validAxes = this.#getValidAxes(mode);
287
- const nextAxis = current !== null && validAxes.includes(current) ? current : validAxes[0] ?? null;
288
- this.#activeAxisByMode[mode] = nextAxis;
289
- if (this.#controls.axis !== nextAxis) this.#controls.axis = nextAxis;
290
- return nextAxis;
454
+ return current !== null && validAxes.includes(current) ? current : validAxes[0] ?? null;
291
455
  }
292
456
  /**
293
457
  * Updates both the remembered axis for the current mode and the control axis.
@@ -296,7 +460,7 @@ var GamepadTransformControls = class extends GamepadControls {
296
460
  */
297
461
  #setActiveAxis(axis) {
298
462
  this.#activeAxisByMode[this.#controls.mode] = axis;
299
- if (this.#controls.axis !== axis) this.#controls.axis = axis;
463
+ if (this.#controls.axis !== axis) this.#writeProperty("axis", axis);
300
464
  }
301
465
  /**
302
466
  * Returns all visible axes supported by a TransformControls mode.
@@ -349,43 +513,71 @@ var GamepadTransformControls = class extends GamepadControls {
349
513
  }
350
514
  }
351
515
  /**
352
- * Starts a TransformControls drag interaction for the active object and axis.
516
+ * Captures a segment's transform origin and claims dragging ownership before
517
+ * notifying native property listeners. The update publishes `mouseDown` later.
353
518
  *
354
519
  * @param object - Object attached to TransformControls for this update.
520
+ * @param axis - Valid axis acquired for the new segment.
355
521
  */
356
- #startTransform(object) {
357
- const controls = this.#controls;
522
+ #startTransform(object, axis) {
358
523
  this.#captureTransformStart(object);
359
- controls.dragging = true;
360
- this.#isTransforming = true;
361
- controls.dispatchEvent({
362
- type: "mouseDown",
363
- mode: controls.mode
364
- });
524
+ this.#segment = {
525
+ ...this.#readContext(),
526
+ object,
527
+ axis,
528
+ dragging: true,
529
+ started: false
530
+ };
531
+ this.#writeProperty("dragging", true);
365
532
  }
366
533
  /**
367
- * Ends an active TransformControls drag interaction.
534
+ * Releases the owned segment and ends its published interaction once,
535
+ * preserving pointer ownership and context changes made by callbacks.
368
536
  *
369
- * @param clearAxis - Whether to clear the highlighted axis after ending.
537
+ * @param clearAxis - Whether to clear the highlighted axis if ownership
538
+ * and context still permit it after end notifications.
370
539
  */
371
540
  #endTransform(clearAxis) {
541
+ const segment = this.#segment;
542
+ if (segment === null) return;
372
543
  const controls = this.#controls;
373
- if (this.#isTransforming) {
374
- this.#isTransforming = false;
375
- controls.dispatchEvent({
544
+ const context = this.#readContext();
545
+ this.#segment = null;
546
+ this.#ending = true;
547
+ try {
548
+ if (segment.started) controls.dispatchEvent({
376
549
  type: "mouseUp",
377
- mode: controls.mode
550
+ mode: segment.mode
378
551
  });
379
- controls.dragging = false;
552
+ const unchanged = this.#matchesContext(context);
553
+ if (!unchanged) {
554
+ this.#interrupted = true;
555
+ if (controls.object !== context.object) this.#needsNeutral = true;
556
+ }
557
+ const ownsDragging = this.#pointerRevision === segment.pointerRevision;
558
+ if (ownsDragging && controls.dragging === context.dragging) this.#writeProperty("dragging", false);
559
+ if (clearAxis && ownsDragging && unchanged && this.#matchesContext({
560
+ ...context,
561
+ dragging: false
562
+ }) && controls.axis === segment.axis) this.#setActiveAxis(null);
563
+ } finally {
564
+ this.#ending = false;
565
+ if (this.#disposed) controls.removeEventListener("mouseDown", this.#onNativeMouseDown);
380
566
  }
381
- if (clearAxis) this.#setActiveAxis(null);
382
567
  }
568
+ /**
569
+ * Restores the owned segment's transform origin through native reset and
570
+ * resets its accumulators if callbacks leave the same segment active.
571
+ */
383
572
  #resetActiveTransform() {
384
- const object = this.#controls.object;
385
- if (!this.#isTransforming || object === void 0) return;
573
+ const segment = this.#segment;
574
+ if (segment === null) return;
575
+ const context = this.#readContext();
386
576
  this.#controls.reset();
387
- this.#accumulatedPosition.copy(object.position);
388
- this.#accumulatedScale.copy(object.scale);
577
+ this.#afterCallback(context);
578
+ if (this.#segment !== segment) return;
579
+ this.#accumulatedPosition.copy(segment.object.position);
580
+ this.#accumulatedScale.copy(segment.object.scale);
389
581
  this.#rotationAmount = 0;
390
582
  this.#freeRotationX = 0;
391
583
  this.#freeRotationY = 0;
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.0",
4
+ "version": "0.24.2",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/luckasnix/three-gamepad-controls#readme",
7
7
  "author": {
@@ -46,19 +46,20 @@
46
46
  }
47
47
  },
48
48
  "devDependencies": {
49
- "@biomejs/biome": "2.5.11",
50
- "@commitlint/cli": "21.2.2",
51
- "@commitlint/config-conventional": "21.2.2",
52
- "@commitlint/types": "21.2.0",
49
+ "@biomejs/biome": "2.5.14",
50
+ "@commitlint/cli": "21.2.3",
51
+ "@commitlint/config-conventional": "21.2.3",
52
+ "@commitlint/types": "21.2.3",
53
53
  "@types/node": "24.13.3",
54
54
  "@types/three": "0.186.0",
55
- "@vitest/coverage-v8": "5.0.0",
55
+ "@vitest/browser-playwright": "5.0.2",
56
+ "@vitest/coverage-v8": "5.0.2",
56
57
  "husky": "9.1.7",
57
- "jsdom": "29.1.1",
58
+ "playwright": "1.63.0",
58
59
  "three": "0.186.0",
59
- "tsdown": "0.22.14",
60
+ "tsdown": "0.23.0",
60
61
  "typescript": "7.0.2",
61
- "vitest": "5.0.0"
62
+ "vitest": "5.0.2"
62
63
  },
63
64
  "peerDependencies": {
64
65
  "@types/three": "~0.186.0",