-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinput-manager.js
More file actions
955 lines (869 loc) · 40.7 KB
/
Copy pathinput-manager.js
File metadata and controls
955 lines (869 loc) · 40.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
// ============================================================
// INPUT MANAGER — keyboard + touch + device motion + gamepad
// ============================================================
import * as THREE from 'three';
import { isMobile, TUNE } from './config.js';
import * as analytics from './analytics.js';
import { addHapticSource, removeHapticSource } from './haptics.js';
import { ControllerRegistry } from '../shared/drivers/controller-registry.js';
/**
* DualSense input-source preference (Auto / Steam Input / WebHID) — see
* project_dualsense_input_source_toggle.md. Read once at boot, applied to
* the next session. Stored in localStorage under 'tandemonium_dualsense_source'.
*/
export function readDualSenseSourcePref() {
try {
const v = localStorage.getItem('tandemonium_dualsense_source');
if (v === 'steam-input' || v === 'webhid') return v;
} catch (e) {}
return 'auto';
}
/**
* Controller-gyro roll source (Issue #314):
* 'gravity' — roll from the gravity-corrected down vector (DEFAULT).
* Drift-free and bounded (no ±180° wrap), so it can't exhibit
* the "fuses to one side then bounces to the other" failure.
* 'euler' — legacy roll from Euler-Z of the integrated orientation; kept
* as a fallback via the Options toggle (relies on the drift-EMA
* compensation in _applyTilt).
* Stored under 'tandemonium_gyro_roll_mode'; applied live. Defaults to 'gravity'.
*/
export function readGyroRollMode() {
try {
const v = localStorage.getItem('tandemonium_gyro_roll_mode');
if (v === 'euler') return v;
} catch (e) {}
return 'gravity';
}
// Controller state (gamepad binding, WebHID, sensor fusion, synthetic
// gamepad for BT-silent DualSense) now lives on a ControllerManager slot.
// See shared/manager.js. InputManager is a thin consumer that
// reads the slot's effective gamepad + fusion orientation each frame and
// turns them into tilt/lean/trigger state for the game loop.
export class InputManager {
/**
* @param {Object} [options]
* @param {import('../shared/manager.js').Slot|null} [options.slot=null]
* — ControllerManager slot this InputManager consumes for gamepad +
* gyro state. Null means no controller bound yet; the InputManager
* still serves keyboard/touch/motion. Call `attachSlot(slot)` later
* to bind after construction.
* @param {boolean} [options.enableKeyboard=true]
* @param {boolean} [options.enableTouch=true]
* @param {boolean} [options.enableMotion=true]
*/
constructor(options = {}) {
const {
slot = null,
enableKeyboard = true,
enableTouch = true,
enableMotion = true,
} = options;
this._slot = slot;
this._slotUnsubscribe = null;
this.keyboardActive = enableKeyboard;
this.keys = {};
this.touchLeft = false;
this.touchRight = false;
this._leftTapped = false; // buffered tap: survives until consumeTaps()
this._rightTapped = false;
this.motionLean = 0;
this.motionEnabled = false;
this.motionReady = false;
this.onMotionEnabled = null; // callback when motionEnabled first becomes true
this.rawGamma = 0;
this.motionOffset = null;
this.motionRawRelative = 0;
this._smoothedLean = 0;
this._prevLeanRaw = 0; // for asymmetric smoothing direction detection
this._calibBuf = [];
this._calibrating = false;
this._warmupCount = 0;
// Time-based filtering for deviceorientation events
this._lastOrientTime = 0;
this._filteredRawTilt = null;
this._lastApplyTiltTime = 0;
// Velocity-dependent sensitivity: set by game loop each frame
this.bikeSpeed = 0;
this.bikeMaxSpeed = 19;
// Drift compensation (mobile tilt only)
this._driftEma = null;
this._driftRate = 0.015;
this._driftWindowK = 0.005;
// Derived gamepad state (updated by pollGamepad each frame from slot)
this.gamepadLean = 0;
this._gpTriggerLeftVal = 0;
this._gpTriggerRightVal = 0;
this._gpTriggerLeftPressed = false;
this._gpTriggerRightPressed = false;
this.suppressGamepadBadge = false;
this.suppressGamepadLean = false;
// Quirk flag: Cyclone A/B swap. Set by attachSlot() / _onSlotChange.
this._gpSwapAB = false;
// Scratch for extracting lean from slot.fusion.orientation each frame.
this._tmpEuler = new THREE.Euler();
// Diagnostic properties (read by test/input.html + in-game HUD)
this._gpRawStickX = 0;
this._gpLB = false;
this._gpRB = false;
this._gyroRollAccum = 0;
this._accelRoll = 0;
this._lastApplyGyroTime = 0;
// Track last-seen connection state for the 'connected'/'disconnected'
// DOM badge updates and haptic source registration.
this._lastGamepadConnected = false;
this._lastHidBound = false;
// Held-detection: timestamp of the last user action on this controller
// (button press, trigger, stick past deadzone, or keyboard). Used by
// haptics + local-MP lean merge to identify a controller sitting idle
// on the desk during one-human local multiplayer. Seeded to "now" so
// controllers default to active before any input arrives.
this._lastActivityMs = performance.now();
// Edge-detect flag for "calibration just finished" auto-arm of
// motionEnabled. Set while fusion.calibrating, cleared after arm.
this._wasFusionCalibrating = false;
// Steam Input gyro override: when steamworks.input has captured a
// controller, its `Steer` analog action (gyro→joystick_move in the VDF)
// becomes the gyro source for that pad, replacing WebHID fusion. We
// track the active flag here so callers and the lobby UI can tell that
// Steam Input is the gyro source. `_steamInputType` mirrors Steam's
// InputType enum (e.g. 'SteamDeckController', 'PS5Controller').
this._steamInputActive = false;
this._steamInputType = null;
// Latest Steam Input snapshot from main, refreshed at top of pollGamepad.
// Used by both the gyro path and the synthetic-Gamepad fallback in
// getGamepadState() — when Steam Input intercepts the DualSense, Electron's
// navigator.getGamepads() doesn't always surface the virtual XInput pad,
// so the snapshot is the only source of button/stick state.
this._steamInputSnapshot = [];
this._steamInputSyntheticGp = null;
// User preference (Auto / Steam Input / WebHID) — sampled once at
// construction; takes effect on next session boot.
this._dualsenseSource = readDualSenseSourcePref();
// Controller-gyro roll source (#314). Read at construction; updated live
// via setGyroRollMode() so the Options toggle takes effect immediately.
this._gyroRollMode = readGyroRollMode();
if (enableKeyboard) this._setupKeyboard();
if (isMobile) {
if (enableTouch) this._setupTouch();
if (enableMotion) this._setupMotion();
if (enableTouch || enableMotion) this._setupCalibration();
}
if (this._slot) this.attachSlot(this._slot);
}
// ── Slot accessors ──
// Read-only getters that delegate to the attached slot. ControllerManager
// owns the lifecycle; InputManager is a pure consumer.
get gamepadConnected() { return this._slot?.state === 'claimed' || this._steamInputActive; }
get gamepadIndex() { return this._slot?.gamepadIndex ?? null; }
// Steam Input counts as a gyro source — it owns Steer for any captured pad
// and replaces the WebHID fusion path. The lobby reads this to decide
// whether to surface the gyro toggle.
get gyroConnected() { return !!(this._slot?.fusion) || this._steamInputActive; }
get gyroDevice() { return this._slot?.hidDevice ?? null; }
get _gpName() { return this._slot?.controllerLabel ?? ''; }
get _syntheticGamepad() { return this._slot?.synthetic ?? null; }
get _controllerDriver() { return this._slot?.driver ?? null; }
get _gyroConnType() { return this._slot?.driver?.connectionType ?? null; }
/**
* Bind (or rebind) a ControllerManager slot. Updates DOM badge + haptic
* registration + quirk flag to match the slot's current state. Safe to
* call with `null` to unbind.
*/
attachSlot(slot) {
if (this._slotUnsubscribe) { this._slotUnsubscribe(); this._slotUnsubscribe = null; }
this._slot = slot || null;
if (!slot) {
this._onSlotChange(null, 'detached');
return;
}
this._slotUnsubscribe = slot.on((s, reason) => this._onSlotChange(s, reason));
// Prime from current state.
if (slot.state === 'claimed') this._onSlotChange(slot, 'claimed');
}
_onSlotChange(slot, reason) {
const connected = !!slot && slot.state === 'claimed';
const hidBound = !!slot && !!slot._hidEntry;
// Update Cyclone A/B quirk from the claimed controller label.
if (connected && slot.controllerLabel) {
this._gpSwapAB = !!ControllerRegistry.getGamepadQuirks(slot.controllerLabel).swapAB;
} else if (!connected) {
this._gpSwapAB = false;
}
// Badge / pedal-bar visibility — only for the primary input (!suppressGamepadBadge).
if (connected !== this._lastGamepadConnected) {
this._lastGamepadConnected = connected;
if (!this.suppressGamepadBadge) {
const badge = document.getElementById('gamepad-badge');
if (badge) badge.style.display = connected ? 'block' : 'none';
const pedalBar = document.getElementById('pedal-bar');
if (pedalBar) pedalBar.classList.toggle('gamepad-active', connected);
}
if (connected) {
const info = slot.controllerLabel ? ControllerRegistry.identifyFromGamepadId(slot.controllerLabel) : null;
analytics.setController(info ? info.driverName : (slot.controllerLabel || 'Gamepad'), 'standard');
} else {
this.gamepadLean = 0;
this._gpTriggerLeftPressed = false;
this._gpTriggerRightPressed = false;
}
}
// Haptic source registration follows HID binding (DualSense WebHID
// rumble path) — register whenever HID attaches, unregister on detach.
if (hidBound !== this._lastHidBound) {
this._lastHidBound = hidBound;
if (hidBound) addHapticSource(this);
else removeHapticSource(this);
}
}
_setupKeyboard() {
window.addEventListener('keydown', (e) => {
const tag = document.activeElement && document.activeElement.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (['ArrowLeft','ArrowRight','KeyA','KeyD'].includes(e.code)) e.preventDefault();
this.keys[e.code] = true;
// Only count keyboard activity when this InputManager actually owns
// the keyboard (in local MP, P1's keyboard is released to P2 by
// setting keyboardActive=false — without this gate, P2's typing
// would falsely mark P1 as held).
if (this.keyboardActive) this._markActive();
});
window.addEventListener('keyup', (e) => {
const tag = document.activeElement && document.activeElement.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
this.keys[e.code] = false;
});
}
_setupTouch() {
const pedalBar = document.getElementById('pedal-bar');
// Track which touch identifiers are on each pedal
this._leftTouchId = null;
this._rightTouchId = null;
this._pedalMidX = 0;
// Use the full pedal-bar as the touch zone so the 10px gap between
// buttons isn't a dead spot. Left/right is split at the midpoint.
pedalBar.style.pointerEvents = 'auto';
const assignTouch = (t) => {
if (t.clientX < this._pedalMidX) {
this._leftTouchId = t.identifier;
this.touchLeft = true;
this._leftTapped = true; // buffered: persists until game loop reads it
} else {
this._rightTouchId = t.identifier;
this.touchRight = true;
this._rightTapped = true;
}
this._markActive();
};
pedalBar.addEventListener('touchstart', (e) => {
// Cache midpoint each touchstart (handles orientation changes)
const rect = pedalBar.getBoundingClientRect();
this._pedalMidX = rect.left + rect.width / 2;
for (let i = 0; i < e.changedTouches.length; i++) {
assignTouch(e.changedTouches[i]);
}
}, { passive: true });
// Finger slides between pedals — reassign the touch to the new side
pedalBar.addEventListener('touchmove', (e) => {
for (let i = 0; i < e.changedTouches.length; i++) {
const t = e.changedTouches[i];
const isLeft = t.clientX < this._pedalMidX;
if (isLeft && t.identifier === this._rightTouchId) {
this.touchRight = false; this._rightTouchId = null;
this._leftTouchId = t.identifier;
this.touchLeft = true; this._leftTapped = true;
} else if (!isLeft && t.identifier === this._leftTouchId) {
this.touchLeft = false; this._leftTouchId = null;
this._rightTouchId = t.identifier;
this.touchRight = true; this._rightTapped = true;
}
}
}, { passive: true });
// Global touchend — catches releases even if finger drifted off the button
const resetIfEmpty = (e) => {
for (let i = 0; i < e.changedTouches.length; i++) {
const id = e.changedTouches[i].identifier;
if (id === this._leftTouchId) { this.touchLeft = false; this._leftTouchId = null; }
if (id === this._rightTouchId) { this.touchRight = false; this._rightTouchId = null; }
}
// Safety: when no fingers remain on screen, clear any stuck state
if (e.touches.length === 0) {
this.touchLeft = false;
this.touchRight = false;
this._leftTouchId = null;
this._rightTouchId = null;
}
};
window.addEventListener('touchend', resetIfEmpty, { passive: true });
window.addEventListener('touchcancel', resetIfEmpty, { passive: true });
}
_setupMotion() {
// iOS 13+ requires a user-gesture-gated requestPermission() call
if (typeof DeviceMotionEvent !== 'undefined' &&
typeof DeviceMotionEvent.requestPermission === 'function') {
this.needsMotionPermission = true;
} else if (typeof DeviceOrientationEvent !== 'undefined' &&
typeof DeviceOrientationEvent.requestPermission === 'function') {
this.needsMotionPermission = true;
} else if (typeof DeviceOrientationEvent !== 'undefined' || typeof DeviceMotionEvent !== 'undefined') {
this._startMotionListening();
}
}
async requestMotionPermission() {
if (this.motionEnabled) return;
// NOTE: do NOT clear needsMotionPermission up front. iOS requires
// requestPermission() to be called from a user gesture; a non-gesture call
// (e.g. the lobby's auto-join) rejects without prompting. Clearing the flag
// eagerly would then permanently suppress the real, gesture-driven prompt
// (since the lobby and game share one InputManager). Only clear on a grant.
// iOS: DeviceMotionEvent.requestPermission() grants access to BOTH
// motion and orientation events — call it first (proven iOS API).
if (typeof DeviceMotionEvent !== 'undefined' &&
typeof DeviceMotionEvent.requestPermission === 'function') {
try {
const response = await DeviceMotionEvent.requestPermission();
if (response === 'granted') { this.needsMotionPermission = false; this._startMotionListening(); }
} catch (e) {
console.warn('Motion permission error:', e);
}
}
// Also request orientation permission if available and not yet listening
if (!this.motionReady &&
typeof DeviceOrientationEvent !== 'undefined' &&
typeof DeviceOrientationEvent.requestPermission === 'function') {
try {
const response = await DeviceOrientationEvent.requestPermission();
if (response === 'granted') { this.needsMotionPermission = false; this._startMotionListening(); }
} catch (e) {
console.warn('Orientation permission error:', e);
}
}
}
// Attach motion listeners directly when permission was already granted
// elsewhere on this origin (e.g. the lobby's InputManager requested it via a
// user gesture). iOS grants motion permission per page, so a second
// InputManager only needs to attach its listeners — no new prompt/gesture.
// Used so an invited player gets tilt even if the captain starts the
// countdown before they tap "tap to start". Safe to call when permission was
// NOT granted: listeners simply stay inert until/unless events fire.
ensureMotionListening() {
if (this.motionReady) return;
if (typeof DeviceOrientationEvent !== 'undefined' || typeof DeviceMotionEvent !== 'undefined') {
this._startMotionListening();
}
}
_startMotionListening() {
if (this.motionReady) return; // prevent duplicate listeners
this.motionReady = true;
this._useOrientation = false;
this._gx = 0; this._gy = 0; this._gz = 0;
this._gravityInit = false;
// Primary: deviceorientation (browser sensor fusion — smoother)
window.addEventListener('deviceorientation', (e) => {
const orient = screen.orientation ? screen.orientation.angle : (window.orientation || 0);
let rawTilt;
if (orient === 90) rawTilt = e.beta;
else if (orient === 270 || orient === -90) rawTilt = -e.beta;
else {
// When phone is tilted past vertical (|beta| > 90°, e.g. lying in bed
// with screen facing down at user), gamma's left-right direction inverts.
// Use a smooth blend zone (80°–100°) to avoid jitter at the boundary.
const absBeta = Math.abs(e.beta || 0);
if (absBeta > 100) {
rawTilt = -e.gamma;
} else if (absBeta > 80) {
const t = (absBeta - 80) / 20;
rawTilt = e.gamma * (1 - 2 * t);
} else {
rawTilt = e.gamma;
}
}
if (rawTilt != null) {
this._useOrientation = true;
if (!this.motionEnabled && this.onMotionEnabled) this.onMotionEnabled();
this.motionEnabled = true;
// Time-based low-pass pre-filter on raw gamma.
// This makes TUNE.lowPassK functional on the deviceorientation path.
// Without this, lowPassK only applied to the devicemotion fallback,
// which Android never uses (since deviceorientation events fire).
// Uses the same frame-rate-independent formula as the devicemotion
// fallback (line ~254) so behavior is consistent at any event rate.
const now = performance.now();
if (this._filteredRawTilt === null) {
this._filteredRawTilt = rawTilt;
this._lastOrientTime = now;
} else {
const dtSec = Math.min((now - this._lastOrientTime) / 1000, 0.1);
const k = 1 - Math.pow(1 - TUNE.lowPassK, dtSec * 60);
this._filteredRawTilt += (rawTilt - this._filteredRawTilt) * k;
this._lastOrientTime = now;
}
this._applyTilt(this._filteredRawTilt);
}
});
// Fallback: devicemotion (only if orientation events don't fire)
window.addEventListener('devicemotion', (e) => {
if (this._useOrientation) return;
const a = e.accelerationIncludingGravity;
if (!a || a.x == null) return;
if (!this.motionEnabled && this.onMotionEnabled) this.onMotionEnabled();
this.motionEnabled = true;
const dtMs = e.interval || 16; // event.interval is in ms; fallback 16ms ≈ 60Hz
const dt = dtMs / 1000;
const k = 1 - Math.pow(1 - TUNE.lowPassK, dt * 60);
if (!this._gravityInit) {
this._gx = a.x; this._gy = a.y; this._gz = a.z;
this._gravityInit = true;
} else {
this._gx += (a.x - this._gx) * k;
this._gy += (a.y - this._gy) * k;
this._gz += (a.z - this._gz) * k;
}
const orient = screen.orientation ? screen.orientation.angle : (window.orientation || 0);
let rollRad;
if (orient === 90) rollRad = Math.atan2(this._gy, -this._gx);
else if (orient === 270 || orient === -90) rollRad = Math.atan2(-this._gy, this._gx);
else rollRad = Math.atan2(this._gx, this._gy);
this._applyTilt(-rollRad * 180 / Math.PI);
});
}
startTiltCalibration() {
this._calibrating = true;
this._calibBuf = [];
this._warmupCount = 5; // already warmed up if explicitly called
}
_applyTilt(rawTilt, isGyro = false) {
this.rawGamma = rawTilt;
// Track time between calls for rate-independent output smoothing
const now = performance.now();
const dtSec = this._lastApplyTiltTime
? Math.min((now - this._lastApplyTiltTime) / 1000, 0.1)
: 1 / 60; // assume 60Hz on first call
this._lastApplyTiltTime = now;
if (this.motionOffset === null && !this._calibrating) {
this._warmupCount++;
if (this._warmupCount >= 5) {
this.startTiltCalibration();
}
return;
}
if (this._calibrating) {
this._calibBuf.push(this.rawGamma);
if (this._calibBuf.length >= TUNE.calibSamples) {
const sum = this._calibBuf.reduce((a, b) => a + b, 0);
this.motionOffset = sum / this._calibBuf.length;
this._calibrating = false;
this._calibBuf = [];
}
return;
}
// Drift compensation: nudge motionOffset toward the long-term average
// rawGamma so slow sensor drift can't accumulate into a one-way pull.
// Mobile tilt needs this for slow bias; gyro needs it MORE — the
// integrated orientation drifts continuously (especially over Bluetooth),
// and with no correction it pulls one way until steering saturates and the
// joystick can't bring it back (the DS4-BT "gradual pull" report). The EMA
// tracks the running center; we only chase it while leans are small, so an
// intentional turn is never cancelled, and the rate is slow enough to
// follow drift over seconds without fighting quick corrections. Gyro uses
// a tighter gate + slower rate for extra safety (L3 still hard-recenters).
if (this._driftEma === null) {
this._driftEma = this.rawGamma;
} else if (isGyro) {
// Gyro: taper drift learning + correction by lean magnitude instead of a
// hard on/off gate. Full strength near center; fades to zero by a large
// lean. This keeps GENTLY correcting through sustained moderate leans —
// where drift was creeping toward "fusing to one side" — while never
// re-centering a big intentional turn. A brief sharp turn spikes the
// lean, so the taper ≈ 0 at its peak and it can't poison the neutral
// estimate (the bug that made it fuse fast on sharp turns).
const FULL_BELOW = 0.2, ZERO_AT = 0.7; // tuning knobs (lean fraction)
const L = Math.abs(this._smoothedLean);
const taper = L <= FULL_BELOW ? 1 : L >= ZERO_AT ? 0 : (ZERO_AT - L) / (ZERO_AT - FULL_BELOW);
if (taper > 0) {
this._driftEma += (this.rawGamma - this._driftEma) * this._driftWindowK * taper;
this.motionOffset += (this._driftEma - this.motionOffset) * this._driftRate * taper;
}
} else {
// Mobile tilt (gravity-absolute): original always-on EMA + hard 0.3 gate.
this._driftEma += (this.rawGamma - this._driftEma) * this._driftWindowK;
if (Math.abs(this._smoothedLean) < 0.3) {
this.motionOffset += (this._driftEma - this.motionOffset) * this._driftRate;
}
}
let relative = this.rawGamma - this.motionOffset;
if (relative > 180) relative -= 360;
else if (relative < -180) relative += 360;
this.motionRawRelative = relative;
// Select tuning parameters based on input source
const sensitivity = isGyro ? TUNE.gyroSensitivity : TUNE.sensitivity;
const deadzone = isGyro ? TUNE.gyroDeadzone : TUNE.deadzone;
const outputSmoothing = isGyro ? TUNE.gyroOutputSmoothing : TUNE.outputSmoothing;
const absRel = Math.abs(relative);
let lean;
// Piecewise response curve: linear zone near deadzone edge for fine control,
// then power curve beyond for aggressive large corrections
const linearZoneFrac = 0.15; // 15% of range is linear
if (isGyro) {
const norm = absRel < deadzone ? 0 : Math.min((absRel - deadzone) / (sensitivity - deadzone), 1.0);
if (norm <= linearZoneFrac) {
lean = Math.sign(relative) * (norm / linearZoneFrac) * linearZoneFrac;
} else {
const curved = (norm - linearZoneFrac) / (1 - linearZoneFrac);
lean = Math.sign(relative) * (linearZoneFrac + (1 - linearZoneFrac) * Math.pow(curved, TUNE.gyroResponseCurve));
}
} else {
// Mobile tilt: same piecewise approach
if (absRel < deadzone) {
lean = 0;
} else {
const reduced = absRel - deadzone;
const range = sensitivity - deadzone;
const norm = Math.min(reduced / range, 1.0);
if (norm <= linearZoneFrac) {
lean = Math.sign(relative) * (norm / linearZoneFrac) * linearZoneFrac;
} else {
const curved = (norm - linearZoneFrac) / (1 - linearZoneFrac);
lean = Math.sign(relative) * (linearZoneFrac + (1 - linearZoneFrac) * Math.pow(curved, TUNE.responseCurve));
}
}
}
// Velocity-dependent sensitivity: scale down lean at high speed for stability
const speedFrac = Math.min(this.bikeSpeed / this.bikeMaxSpeed, 1.0);
const velocityScale = 1.0 - speedFrac * 0.4; // 1.0 at rest → 0.6 at max speed
lean *= velocityScale;
// Asymmetric smoothing: less smoothing when initiating a turn (responsive),
// more smoothing when returning to center (stable)
const initiating = Math.abs(lean) > Math.abs(this._prevLeanRaw) && Math.abs(lean) > 0.05;
const baseSmooth = initiating ? Math.min(outputSmoothing * 1.6, 0.9) : outputSmoothing * 0.7;
this._prevLeanRaw = lean;
// Frame-rate-independent EMA: normalize so smoothing converges at the
// same wall-clock rate regardless of event frequency (60Hz vs 200Hz).
// At 60Hz, dtSec * 60 ≈ 1.0, so smoothK ≈ baseSmooth (unchanged).
// At 120Hz, dtSec * 60 ≈ 0.5, so smoothK is smaller per event but
// the per-second convergence rate is identical.
const smoothK = 1 - Math.pow(1 - baseSmooth, dtSec * 60);
this._smoothedLean += (lean - this._smoothedLean) * smoothK;
this.motionLean = this._smoothedLean;
}
_setupCalibration() {
const gauge = document.getElementById('phone-gauge');
const flash = document.getElementById('calibrate-flash');
const doCalibrate = (e) => {
e.preventDefault();
e.stopPropagation();
this.startTiltCalibration();
if (flash) { flash.style.display = 'block'; setTimeout(() => { flash.style.display = 'none'; }, 800); }
};
gauge.addEventListener('touchstart', doCalibrate, { passive: false });
gauge.addEventListener('click', doCalibrate);
}
/**
* Called once per frame by the game/lobby raf loop. Reads the current
* effective gamepad from the attached slot, derives stick + trigger
* state, and (when the slot has an active sensor fusion) runs the
* orientation → lean projection that used to live in _handleGyroReport.
*
* Assumes the caller has already run manager.ingestFrame() for the
* current frame so the slot's state reflects the latest pads.
*/
pollGamepad() {
// Snapshot Steam Input state first so getGamepadState()'s synthetic
// fallback and the gyro path below see the same data this frame.
this._refreshSteamInputSnapshot();
const gp = this.getGamepadState();
if (gp) {
// Left stick X — deadzone 0.08
const rawX = gp.axes[0] || 0;
this._gpRawStickX = rawX;
this.gamepadLean = this.suppressGamepadLean ? 0 : (Math.abs(rawX) < 0.08 ? 0 : rawX);
// Pedal buttons: LB/RB (buttons[4]/[5]) or LT/RT (buttons[6]/[7])
const THRESHOLD = 0.5;
this._gpLB = !!(gp.buttons[4] && gp.buttons[4].pressed);
this._gpRB = !!(gp.buttons[5] && gp.buttons[5].pressed);
this._gpTriggerLeftVal = gp.buttons[6] ? gp.buttons[6].value : 0;
this._gpTriggerRightVal = gp.buttons[7] ? gp.buttons[7].value : 0;
this._gpTriggerLeftPressed = this._gpLB || this._gpTriggerLeftVal >= THRESHOLD;
this._gpTriggerRightPressed = this._gpRB || this._gpTriggerRightVal >= THRESHOLD;
// Held-detection: stick/trigger/shoulder activity = human is touching
// this controller. Gyro motion is intentionally NOT used here — rumble
// shakes a resting controller's gyro and would falsely mark it held.
if (Math.abs(rawX) >= 0.08 ||
this._gpTriggerLeftPressed || this._gpTriggerRightPressed) {
this._markActive();
}
}
// Steam Input gyro path — when Steam Input has captured ANY controller,
// its Steer analog action wins over the WebHID fusion pipeline for the
// gyro channel. Steam's per-controller config owns sensitivity/deadzone/
// response-curve tuning; we just route the resulting scalar into
// motionLean and let the existing BalanceController sum it with the
// joystick stick. The renderer reads a snapshot pushed by main at
// ~60Hz via 'steam:input:tick' — no per-frame IPC round-trip.
// _refreshSteamInputSnapshot() (called at top of pollGamepad) has already
// applied the DualSense Input Source preference to the cached snapshot
// and updated this._steamInputActive. _steamInputPrevActive is the prior
// frame's value so the edge-detect below still fires correctly.
const steamInputData = this._steamInputSnapshot;
const hadSteamInput = this._steamInputPrevActive;
if (this._steamInputActive) {
// One-shot auto-arm on first capture, mirroring the fusion-calibration
// arm. After this, the user's lobby motion toggle controls the channel.
if (!hadSteamInput && !this.motionEnabled) {
this.motionEnabled = true;
if (this.onMotionEnabled) this.onMotionEnabled();
}
this._wasFusionCalibrating = false;
if (!this.motionEnabled) return;
// For now: map first Steam Input controller to this InputManager.
// Local-MP multi-pad mapping is a follow-up — see project memory.
const primary = steamInputData[0];
this._steamInputType = primary.type;
this.motionLean = primary.steerX;
this._smoothedLean = primary.steerX;
this._prevLeanRaw = primary.steerX;
// Diagnostic mirror for HUD / test/input.html (no real roll angle
// available — Steam SDK only exposes the post-mapping vector).
this._gyroRollAccum = -primary.steerX * 90;
this._accelRoll = 0;
if (Math.abs(primary.steerX) > 0.05) this._markActive();
return;
}
this._steamInputType = null;
// Orientation → tilt projection. The slot's HidEntry ingests gyro at
// HID-report frequency (100–250Hz) independently; we read the output
// quaternion once per frame at raf rate. `_applyTilt` uses
// rate-independent EMA smoothing so cadence doesn't affect feel.
const fusion = this._slot?.fusion;
if (!fusion) { this._wasFusionCalibrating = false; return; }
// Honor 'steam-input' preference for DualSense: if the user explicitly
// picked Steam Input but Steam isn't intercepting this session, don't
// fall back to WebHID fusion for the DualSense — leave gyro silent so
// the toggle's behavior is deterministic. Non-DualSense drivers are
// unaffected.
if (this._dualsenseSource === 'steam-input') {
const proto = this._slot?.driver?.entry?.protocol || '';
if (proto === 'dualsense') { this._wasFusionCalibrating = false; return; }
}
if (fusion.calibrating) { this._wasFusionCalibrating = true; return; }
// Auto-arm motion pipeline ONCE when calibration transitions from
// active → done (matching the old `_finishGyroCalibration` edge).
// Arming on every frame while !motionEnabled would fight the user's
// "turn motion off" toggle — lobby sets input.motionEnabled=false,
// next frame pollGamepad would clobber it back to true.
if (this._wasFusionCalibrating) {
this._wasFusionCalibrating = false;
this.motionEnabled = true;
if (this.onMotionEnabled) this.onMotionEnabled();
}
if (!this.motionEnabled) return;
// Euler-Z roll from the integrated orientation (current default). Prone to
// drift ("fuses to one side") and a ±180° wrap / gimbal-lock flip ("bounces
// to the other side") — see #314.
this._tmpEuler.setFromQuaternion(fusion.orientation, 'XYZ');
const eulerLeanDeg = -this._tmpEuler.z * (180 / Math.PI);
// Gravity-vector roll: derived from the gravity-corrected down vector, so it
// can't drift and is naturally bounded (no ±180° runaway). Experimental
// A/B toggle (#314); fusion.gravityRollRadians() documents the convention.
const gravLeanDeg = (typeof fusion.gravityRollRadians === 'function')
? fusion.gravityRollRadians() * (180 / Math.PI)
: eulerLeanDeg;
const leanDeg = this._gyroRollMode === 'gravity' ? gravLeanDeg : eulerLeanDeg;
// Lightweight diagnostic (off unless window.__gyroDebug is set). Lets us
// capture the fuse/bounce live and compare Euler vs gravity roll (#314).
if (typeof window !== 'undefined' && window.__gyroDebug) {
window.__gyroDebugState = {
mode: this._gyroRollMode, eulerLeanDeg, gravLeanDeg,
motionOffset: this.motionOffset, relative: this.motionRawRelative,
lean: this._smoothedLean,
};
const t = performance.now();
if (!this._gyroDbgT || t - this._gyroDbgT > 250) {
this._gyroDbgT = t;
console.log('[gyro]', this._gyroRollMode,
'euler=' + eulerLeanDeg.toFixed(1), 'grav=' + gravLeanDeg.toFixed(1),
'rel=' + (this.motionRawRelative || 0).toFixed(1), 'lean=' + this._smoothedLean.toFixed(2));
}
}
// Do NOT clamp before passing to _applyTilt — clamping the fusion input
// prevents gravity correction from tracking through extreme angles,
// causing the "gyro goes wild" feedback loop on noisy BT connections.
// _applyTilt's sensitivity/response-curve naturally bounds steering output.
this._gyroRollAccum = -leanDeg;
this._accelRoll = leanDeg;
this._applyTilt(leanDeg, true);
}
// Switch the controller-gyro roll source live (Options toggle, #314).
setGyroRollMode(mode) {
this._gyroRollMode = (mode === 'gravity') ? 'gravity' : 'euler';
}
/**
* Refresh the cached Steam Input snapshot. Called at the top of
* pollGamepad() so subsequent reads (getGamepadState, gyro path) see the
* same data within a frame. Applies the DualSense Input Source preference.
*/
_refreshSteamInputSnapshot() {
const raw = (typeof window !== 'undefined' && window.steam && window.steam.input)
? window.steam.input.getLatest()
: null;
const filtered = (raw && this._dualsenseSource === 'webhid')
? raw.filter(c => !(c.type || '').toString().toLowerCase().includes('ps5'))
: raw;
this._steamInputSnapshot = filtered || [];
// Save the prior-frame active flag so the gyro-section can edge-detect
// the "Steam Input just became active" transition; then update.
this._steamInputPrevActive = this._steamInputActive;
this._steamInputActive = this._steamInputSnapshot.length > 0;
}
/**
* Build a Gamepad-shaped object from the first Steam Input controller in
* the snapshot. Returned object follows the standard 18-button layout so
* existing consumers (lobby nav, pollGamepad stick/trigger reads) work
* unchanged. Pedal actions are duplicated across bumper (4/5) and trigger
* (6/7) slots since the game accepts either as the "pedal" input.
*
* @returns {Gamepad|null}
*/
_buildSteamInputGamepad() {
if (!this._steamInputSnapshot.length) return null;
const c = this._steamInputSnapshot[0];
const d = c.digital || {};
const btn = (pressed) => ({ pressed: !!pressed, touched: !!pressed, value: pressed ? 1 : 0 });
const buttons = [
btn(d.Confirm), // 0 Cross / A — Confirm
btn(d.Cancel), // 1 Circle / B — Cancel
btn(false), // 2 Square / X — unbound
btn(false), // 3 Triangle / Y — unbound
btn(d.PedalLeft), // 4 L1 — left pedal
btn(d.PedalRight), // 5 R1 — right pedal
btn(d.PedalLeft), // 6 L2 (digital pedal)
btn(d.PedalRight), // 7 R2 (digital pedal)
btn(false), // 8 Share — unbound
btn(d.Pause), // 9 Options / Start — Pause
btn(false), // 10 L3
btn(false), // 11 R3
btn(d.MenuUp), // 12 D-pad up
btn(d.MenuDown), // 13 D-pad down
btn(d.MenuLeft), // 14 D-pad left
btn(d.MenuRight), // 15 D-pad right
btn(false), // 16 PS / Home
btn(false), // 17 Touchpad
];
return {
id: `DualSense via Steam Input (${c.type || 'unknown'} Vendor: 054c Product: 0ce6)`,
index: -1,
mapping: 'standard',
connected: true,
timestamp: performance.now(),
buttons,
axes: [c.steerX || 0, c.steerY || 0, 0, 0],
_isSyntheticSteamInput: true,
};
}
/**
* Return the current gamepad state — HID-synthetic when the slot's
* driver emits buttons (BT DualSense case), else the Gamepad API pad.
* Falls back to a synthesized Steam-Input gamepad when Steam Input has
* captured a controller but no real Gamepad API pad exists (typical when
* Steam Input intercepts the DualSense on Electron, where the virtual
* XInput device doesn't surface via navigator.getGamepads()).
*
* @returns {Gamepad|null}
*/
getGamepadState() {
if (this._slot) {
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
const slotPad = this._slot.effectiveGamepad(pads);
if (slotPad) return slotPad;
}
if (this._steamInputActive) return this._buildSteamInputGamepad();
return null;
}
getGamepadLean() {
if (this.suppressGamepadLean) return 0;
return this.gamepadConnected ? this.gamepadLean : 0;
}
isPressed(code) {
// keyboardActive gates the raw key state so an InputManager instance can
// "release" the keyboard to another instance (local MP: P1 on gamepad
// stops reading keys when P2 is on keyboard).
const keyDown = this.keyboardActive && !!this.keys[code];
if (code === 'ArrowLeft') return keyDown || this.touchLeft || this._leftTapped || this._gpTriggerLeftPressed;
if (code === 'ArrowRight') return keyDown || this.touchRight || this._rightTapped || this._gpTriggerRightPressed;
return keyDown;
}
/** Clear buffered tap flags — call once per frame after all input reading. */
consumeTaps() {
this._leftTapped = false;
this._rightTapped = false;
}
/** Restart the attached slot's initial bias-capture calibration. */
calibrateGyro() {
this._slot?.startGyroCalibration();
}
/**
* Recenter semantic: "whatever I'm holding right now = zero lean."
* Captures the current accel-derived roll as motionOffset so the tilt
* pipeline sees zero relative lean, then resets the slot's fusion so
* orientation re-converges from identity.
*/
recenterGyro() {
if (this._accelRoll != null) {
this.motionOffset = -this._accelRoll;
} else {
this.motionOffset = 0;
}
console.log('Gyro recentered: rollAccum=' + this._gyroRollAccum.toFixed(1) +
' accelRoll=' + (this._accelRoll != null ? this._accelRoll.toFixed(1) : 'null') +
' offset=' + (this.motionOffset != null ? this.motionOffset.toFixed(1) : 'null') +
' conn=' + (this._gyroConnType || 'unknown'));
this._gyroRollAccum = 0;
this._smoothedLean = 0;
this.motionLean = 0;
// Re-seed drift compensation so the freshly-set center isn't immediately
// yanked by a stale long-term average.
this._driftEma = null;
this._slot?.fusion?.reset();
}
/** Full lean-input reset for tutorial/demo restarts. */
resetLeanState() {
this._smoothedLean = 0;
this._prevLeanRaw = 0;
this.motionLean = 0;
if (this.gyroConnected && this._accelRoll != null) {
this.motionOffset = -this._accelRoll;
}
this._gyroRollAccum = 0;
this._slot?.fusion?.reset();
this._driftEma = null;
}
getMotionLean() {
return this.motionEnabled ? this.motionLean : 0;
}
/** Stamp this controller as "in use right now". */
_markActive() {
this._lastActivityMs = performance.now();
}
/**
* True if this controller has had user input within the last `timeoutMs`.
* Sources: keyboard, touch, gamepad stick (past deadzone), triggers,
* shoulder buttons. Gyro motion is deliberately excluded so rumble
* vibrations on a resting controller don't keep it falsely flagged as
* held. The default 10s window easily spans normal pedaling cadence
* (tandem MP requires constant pedal taps).
*/
isActive(timeoutMs = 10000) {
return (performance.now() - this._lastActivityMs) < timeoutMs;
}
/**
* Called by the haptics module just before rumble fires on this
* controller. Suppresses the in-motion sensor-fusion bias-refinement
* calibration for the rumble duration plus a short settle margin, so
* rumble-induced accel noise can't corrupt the bias estimate. The
* initial one-shot and continuous stillness calibrations are
* naturally gated by their own thresholds.
*/
onRumbleWillFire(durationMs) {
this._slot?.fusion?.suppressCalibrationFor(durationMs + 200);
}
}