littlejsengine 1.18.15 → 1.18.18

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.
@@ -43,11 +43,30 @@ let mouseWheel = 0;
43
43
  * @memberof Input */
44
44
  let mouseInWindow = true;
45
45
 
46
- /** Returns true if user is using gamepad (has more recently pressed a gamepad button)
46
+ /** True if a gamepad is the most recently used input device.
47
+ * Equivalent to usingGamepadInput(); derived from lastInputDevice each frame.
47
48
  * @type {boolean}
48
49
  * @memberof Input */
49
50
  let isUsingGamepad = false;
50
51
 
52
+ /** The most recently used input device: 'mouse' | 'keyboard' | 'gamepad'.
53
+ * Sticky: it holds its value while every device is idle, so a mouse-follow
54
+ * control (e.g. paddle = mousePos) won't snap back the instant the stick/keys
55
+ * are released. With several devices in play at once (e.g. keyboard to move +
56
+ * mouse to aim) it tracks whichever was touched last each frame, so it may
57
+ * alternate — that's intended; use it to pick which control drives a shared
58
+ * action. Updated every frame by inputUpdate().
59
+ * @type {string}
60
+ * @memberof Input */
61
+ let lastInputDevice = 'mouse';
62
+
63
+ /** Screen-pixel mouse movement per frame that counts as "using the mouse"
64
+ * (so sub-pixel hand jitter doesn't steal focus from the keyboard/gamepad).
65
+ * @type {number}
66
+ * @default
67
+ * @memberof Input */
68
+ let inputMouseMoveThreshold = 6;
69
+
51
70
  /** Prevents input continuing to the default browser handling (true by default)
52
71
  * @type {boolean}
53
72
  * @memberof Input */
@@ -68,6 +87,18 @@ const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
68
87
  * @memberof Input */
69
88
  function setInputPreventDefault(preventDefault=true) { inputPreventDefault = preventDefault; }
70
89
 
90
+ /** Set the screen-pixel mouse movement per frame that counts as using the mouse
91
+ * @param {number} threshold
92
+ * @memberof Input */
93
+ function setInputMouseMoveThreshold(threshold) { inputMouseMoveThreshold = threshold; }
94
+
95
+ /** @return {boolean} - Is the mouse the most recently used input device? @memberof Input */
96
+ function usingMouseInput() { return lastInputDevice === 'mouse'; }
97
+ /** @return {boolean} - Is the keyboard the most recently used input device? @memberof Input */
98
+ function usingKeyboardInput() { return lastInputDevice === 'keyboard'; }
99
+ /** @return {boolean} - Is a gamepad the most recently used input device? @memberof Input */
100
+ function usingGamepadInput() { return lastInputDevice === 'gamepad'; }
101
+
71
102
  /** Clears an input key state
72
103
  * @param {string|number} key
73
104
  * @param {number} [device]
@@ -90,6 +121,7 @@ function inputClear()
90
121
  inputData[0] = [];
91
122
  touchGamepadButtons.length = 0;
92
123
  touchGamepadSticks.length = 0;
124
+ touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor
93
125
  gamepadStickData.length = 0;
94
126
  gamepadDpadData.length = 0;
95
127
  }
@@ -332,6 +364,14 @@ const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
332
364
 
333
365
  // touch gamepad internal variables
334
366
  const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
367
+ // floating stick anchors (stage-local CSS pixels) and owning pointer ids, indexed by stick (0=left, 1=right)
368
+ const touchGamepadStickAnchors = [], touchGamepadStickPointerId = [];
369
+ // pointerId -> control role ('stick0', 'stick1', 'face<n>', or 'start')
370
+ const touchGamepadPointerRole = new Map();
371
+ // overlay DOM elements (created lazily on touch devices) and cached SVG shapes
372
+ let touchGamepadOverlay, touchGamepadStage, touchGamepadSvg, touchGamepadSvgEls;
373
+ let touchGamepadSideZones = [], touchGamepadZoneC;
374
+ let touchGamepadNeedRelayout = true, touchGamepadLastLayout;
335
375
 
336
376
  ///////////////////////////////////////////////////////////////////////////////
337
377
  // Input system functions used by engine
@@ -359,7 +399,6 @@ function inputInit()
359
399
  {
360
400
  if (!e.repeat)
361
401
  {
362
- isUsingGamepad = false;
363
402
  inputData[0][e.code] = 3;
364
403
  if (inputWASDEmulateDirection)
365
404
  inputData[0][remapKey(e.code)] = 3;
@@ -418,7 +457,6 @@ function inputInit()
418
457
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
419
458
  audioContext.resume();
420
459
 
421
- isUsingGamepad = false;
422
460
  inputData[0][e.button] = 3;
423
461
 
424
462
  const mousePosScreenLast = mousePosScreen;
@@ -456,7 +494,15 @@ function inputInit()
456
494
  e.preventDefault(); // prevent page scrolling
457
495
  }
458
496
  function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
459
- function onBlur() { inputClear(); } // reset input when focus is lost
497
+ function onBlur()
498
+ {
499
+ inputClear();
500
+ // release any held virtual gamepad controls so they don't stick
501
+ touchGamepadPointerRole.clear();
502
+ touchGamepadButtons.length = 0;
503
+ touchGamepadSticks.length = 0;
504
+ touchGamepadStickPointerId.length = 0;
505
+ }
460
506
 
461
507
  // enable touch input mouse passthrough
462
508
  function touchInputInit()
@@ -472,36 +518,43 @@ function inputInit()
472
518
  {
473
519
  if (!touchInputEnable) return;
474
520
 
475
- // route touch to gamepad
476
- if (touchGamepadEnable)
477
- handleTouchGamepad(e);
478
-
479
521
  // fix stalled audio requiring user interaction
480
522
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
481
523
  audioContext.resume();
482
524
 
483
- // check if touching and pass to mouse events
484
- const touching = e.touches.length;
485
- const button = 0; // all touches are left mouse button
486
- if (touching)
525
+ // when the touch gamepad is enabled it owns touch input: suppress the
526
+ // touch->mouse passthrough entirely unless touchGamepadPassthrough is set
527
+ // (its own zones drive gameplay via pointer events)
528
+ if (!touchGamepadEnable || touchGamepadPassthrough)
487
529
  {
488
- // set event pos and pass it along
489
- const pos = vec2(e.touches[0].clientX, e.touches[0].clientY);
490
- const mousePosScreenLast = mousePosScreen;
491
- mousePosScreen = mouseEventToScreen(pos);
492
- if (wasTouching)
530
+ // touches that landed on a virtual gamepad zone are owned by the gamepad
531
+ // (handled by its own pointer listeners) and must not drive the game mouse
532
+ const isGamepadTouch = (t)=>
533
+ touchGamepadSideZones.includes(t.target) || t.target === touchGamepadZoneC;
534
+ const gameTouches = [];
535
+ for (const t of e.touches)
536
+ if (!isGamepadTouch(t)) gameTouches.push(t);
537
+
538
+ // check if touching and pass to mouse events
539
+ const touching = gameTouches.length;
540
+ const button = 0; // all touches are left mouse button
541
+ if (touching)
493
542
  {
494
- mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
495
- isUsingGamepad = touchGamepadEnable;
543
+ // set event pos and pass it along
544
+ const pos = vec2(gameTouches[0].clientX, gameTouches[0].clientY);
545
+ const mousePosScreenLast = mousePosScreen;
546
+ mousePosScreen = mouseEventToScreen(pos);
547
+ if (wasTouching)
548
+ mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
549
+ else
550
+ inputData[0][button] = 3;
496
551
  }
497
- else
498
- inputData[0][button] = 3;
499
- }
500
- else if (wasTouching)
501
- inputData[0][button] = inputData[0][button] & 2 | 4;
552
+ else if (wasTouching)
553
+ inputData[0][button] = inputData[0][button] & 2 | 4;
502
554
 
503
- // set was touching
504
- wasTouching = touching;
555
+ // set was touching
556
+ wasTouching = touching;
557
+ }
505
558
 
506
559
  // prevent default handling like copy, magnifier lens, and scrolling
507
560
  if (inputPreventDefault && e.cancelable && document.hasFocus())
@@ -511,83 +564,6 @@ function inputInit()
511
564
  return true;
512
565
  }
513
566
 
514
- // special handling for virtual gamepad mode
515
- function handleTouchGamepad(e)
516
- {
517
- // clear touch gamepad input
518
- touchGamepadSticks.length = 0;
519
- touchGamepadSticks[0] = vec2();
520
- touchGamepadSticks[1] = vec2();
521
- touchGamepadButtons.length = 0;
522
- isUsingGamepad = true;
523
-
524
- const touching = e.touches.length;
525
- if (touching)
526
- {
527
- touchGamepadTimer.set();
528
- if (touchGamepadCenterButtonSize && !wasTouching && paused)
529
- {
530
- // touch anywhere to press start when paused
531
- touchGamepadButtons[9] = 1;
532
- return;
533
- }
534
- }
535
-
536
- // don't process touch gamepad if paused
537
- if (paused) return;
538
-
539
- // get center of left and right sides
540
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
541
- const buttonCenter = touchGamepadButtonCenter();
542
- const startCenter = mainCanvasSize.scale(.5);
543
-
544
- // check each touch point
545
- for (const touch of e.touches)
546
- {
547
- const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
548
- if (stickCenter.distance(touchPos) < touchGamepadSize)
549
- {
550
- // virtual analog stick
551
- const delta = touchPos.subtract(stickCenter);
552
- touchGamepadSticks[0] = delta.scale(2/touchGamepadSize).clampLength();
553
- touchGamepadButtons[10] = 1; // also press a button when touching stick
554
- }
555
- else if (buttonCenter.distance(touchPos) < touchGamepadSize)
556
- {
557
- if (touchGamepadButtonCount === 1)
558
- {
559
- // virtual right analog stick
560
- const delta = touchPos.subtract(buttonCenter);
561
- touchGamepadSticks[1] = delta.scale(2/touchGamepadSize).clampLength();
562
- touchGamepadButtons[11] = 1; // also press a button when touching right stick
563
- }
564
- // virtual face buttons
565
- let button = buttonCenter.subtract(touchPos).direction();
566
- button = mod(button+2, 4);
567
- if (touchGamepadButtonCount === 1)
568
- button = 0;
569
- else if (touchGamepadButtonCount === 2)
570
- {
571
- const delta = buttonCenter.subtract(touchPos);
572
- button = -delta.x < delta.y ? 1 : 0;
573
- }
574
- // fix button locations (swap 2 and 3 to match gamepad layout)
575
- button = button === 3 ? 2 : button === 2 ? 3 : button;
576
- if (button < touchGamepadButtonCount)
577
- touchGamepadButtons[button] = 1;
578
- }
579
- else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize &&
580
- stickCenter.distance(touchPos) >= 2 * touchGamepadSize &&
581
- buttonCenter.distance(touchPos) >= 2 * touchGamepadSize)
582
- {
583
- // virtual start button in center
584
- // require a fat-finger buffer of touchGamepadSize beyond the
585
- // edge of the stick/buttons so drift off those controls can't
586
- // accidentally fire start
587
- touchGamepadButtons[9] = 1;
588
- }
589
- }
590
- }
591
567
  }
592
568
 
593
569
  // convert a mouse or touch event position to screen space
@@ -612,9 +588,48 @@ function inputUpdate()
612
588
  mousePos = screenToWorld(mousePosScreen);
613
589
  mouseDelta = screenToWorldDelta(mouseDeltaScreen);
614
590
 
591
+ // build the touch gamepad overlay lazily once enabled on a touch device
592
+ touchGamepadInit();
593
+
615
594
  // update gamepads if enabled
616
595
  gamepadsUpdate();
617
-
596
+
597
+ // update most recently used input device
598
+ updateLastInputDevice();
599
+
600
+ function updateLastInputDevice()
601
+ {
602
+ // mouse: any button held or moved
603
+ const mouseActive = mouseIsDown(0) || mouseIsDown(1) || mouseIsDown(2) || mouseDeltaScreen.length() > inputMouseMoveThreshold;
604
+
605
+ // gamepad: any button held or stick moved
606
+ let gamepadActive = false;
607
+ for (let s = gamepadStickCount(); s-- && !gamepadActive;)
608
+ gamepadActive = gamepadStick(s).lengthSquared() > .04;
609
+ for (let b = 17; b-- && !gamepadActive;)
610
+ gamepadActive = gamepadIsDown(b);
611
+
612
+ // keyboard: any non-mouse key down
613
+ let keyboardActive = false;
614
+ for (const k in inputData[0])
615
+ if (isNaN(+k) && (inputData[0][k] & 1))
616
+ {
617
+ keyboardActive = true;
618
+ break;
619
+ }
620
+
621
+ // update the last input
622
+ if (gamepadActive)
623
+ lastInputDevice = 'gamepad';
624
+ else if (mouseActive)
625
+ lastInputDevice = 'mouse';
626
+ else if (keyboardActive)
627
+ lastInputDevice = 'keyboard';
628
+
629
+ // set flag if gamepad is last device
630
+ isUsingGamepad = lastInputDevice === 'gamepad';
631
+ }
632
+
618
633
  // gamepads are updated by engine every frame automatically
619
634
  function gamepadsUpdate()
620
635
  {
@@ -630,22 +645,11 @@ function inputUpdate()
630
645
  // update touch gamepad if enabled
631
646
  if (touchGamepadEnable && isTouchDevice)
632
647
  {
633
- if (debugGamepads)
634
- {
635
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
636
- const buttonCenter = touchGamepadButtonCenter();
637
- const startCenter = mainCanvasSize.scale(.5);
638
-
639
- debugCircle(stickCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
640
- debugCircle(buttonCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
641
- if (touchGamepadCenterButtonSize)
642
- {
643
- debugCircle(startCenter, 2*touchGamepadCenterButtonSize, 'cyan', 0, false, true);
644
- // exclusion bubbles around controls (where start is blocked)
645
- debugCircle(stickCenter, 4*touchGamepadSize, 'magenta', 0, false, true);
646
- debugCircle(buttonCenter, 4*touchGamepadSize, 'magenta', 0, false, true);
647
- }
648
- }
648
+ // a side is either a stick or buttons - setting both is ambiguous
649
+ ASSERT(!touchGamepadLeftStick || !touchGamepadLeftButtonCount,
650
+ 'set touchGamepadLeftStick or touchGamepadLeftButtonCount, not both');
651
+ ASSERT(!touchGamepadRightStick || !touchGamepadButtonCount,
652
+ 'set touchGamepadRightStick or touchGamepadButtonCount, not both');
649
653
 
650
654
  if (!touchGamepadTimer.isSet()) return;
651
655
 
@@ -653,23 +657,24 @@ function inputUpdate()
653
657
  gamepadPrimary = 0; // touch gamepad uses index 0
654
658
  const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
655
659
  const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
656
- sticks[0] = vec2();
660
+ sticks.length = 0; // only report sticks that are enabled
657
661
  dpad.set();
658
- const leftTouchStick = touchGamepadSticks[0] ?? vec2();
659
- if (touchGamepadAnalog)
660
- sticks[0] = applyDeadZones(leftTouchStick);
661
- else if (leftTouchStick.lengthSquared() > .3)
662
- {
663
- // convert to 8 way dpad
664
- const x = clamp(round(leftTouchStick.x), -1, 1);
665
- const y = clamp(round(leftTouchStick.y), -1, 1);
666
- dpad.set(x, -y);
667
- sticks[0] = dpad.clampLength(); // clamp to circle
668
- }
669
- if (touchGamepadButtonCount === 1)
662
+ // read each side's directional stick (analog, or quantized to an 8 way dpad)
663
+ for (let side = 0; side < 2; side++)
670
664
  {
671
- const rightTouchStick = touchGamepadSticks[1] ?? vec2();
672
- sticks[1] = applyDeadZones(rightTouchStick);
665
+ if (!touchGamepadSideStick(side)) continue;
666
+ const out = touchGamepadStickOut(side);
667
+ sticks[out] = vec2();
668
+ const touchStick = touchGamepadSticks[side] ?? vec2();
669
+ if (touchGamepadAnalog)
670
+ sticks[out] = applyDeadZones(touchStick);
671
+ else if (touchStick.lengthSquared() > .3)
672
+ {
673
+ const x = clamp(round(touchStick.x), -1, 1);
674
+ const y = clamp(round(touchStick.y), -1, 1);
675
+ sticks[out] = vec2(x, -y).clampLength(); // clamp to circle
676
+ if (!out) dpad.set(x, -y); // the primary (stick 0) also drives the dpad vector
677
+ }
673
678
  }
674
679
 
675
680
  // read virtual gamepad buttons
@@ -678,6 +683,12 @@ function inputUpdate()
678
683
  {
679
684
  const wasDown = gamepadIsDown(i,0);
680
685
  data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
686
+
687
+ // haptic tap when a face button or start button is first pressed (3 = newly down)
688
+ // skip stick touches (10, 11) so movement doesn't buzz
689
+ if (touchGamepadVibration && data[i] === 3 &&
690
+ (i === 9 || touchGamepadIsFaceButton(i)))
691
+ vibrate(touchGamepadVibration);
681
692
  }
682
693
 
683
694
  // disable normal gamepads when touch gamepad is active
@@ -741,7 +752,6 @@ function inputUpdate()
741
752
  gamepadHadInput[i] = true;
742
753
  if (!gamepadHadInput[gamepadPrimary])
743
754
  gamepadPrimary = i;
744
- isUsingGamepad ||= (gamepadPrimary === i);
745
755
  }
746
756
 
747
757
  if (gamepad.mapping === 'standard')
@@ -778,78 +788,474 @@ function inputUpdatePost()
778
788
  function inputRender()
779
789
  {
780
790
  touchGamepadRender();
791
+ }
781
792
 
782
- function touchGamepadRender()
793
+ ///////////////////////////////////////////////////////////////////////////////
794
+ // Touch gamepad - full-viewport HTML/SVG overlay driven by Pointer Events
795
+
796
+ const touchGamepadSvgNS = 'http://www.w3.org/2000/svg';
797
+
798
+ // build the overlay DOM once; no-op if already built, disabled, headless, or non-touch
799
+ function touchGamepadInit()
800
+ {
801
+ if (touchGamepadOverlay || !touchGamepadEnable || !isTouchDevice || headlessMode ||
802
+ !document.body) // body may not exist yet; retry on a later frame
803
+ return;
804
+
805
+ // full-viewport overlay; only the input zones receive pointer events. The
806
+ // env() padding insets the stage out of notches / home indicators natively.
807
+ const overlay = touchGamepadOverlay = document.createElement('div');
808
+ overlay.style.cssText =
809
+ 'position:fixed;inset:0;z-index:50;pointer-events:none;opacity:0;' +
810
+ 'touch-action:none;user-select:none;-webkit-user-select:none;' +
811
+ '-webkit-touch-callout:none;transition:opacity .2s;box-sizing:border-box;' +
812
+ 'padding:env(safe-area-inset-top) env(safe-area-inset-right) ' +
813
+ 'env(safe-area-inset-bottom) env(safe-area-inset-left)';
814
+
815
+ // stage fills the padded (safe-area) content box; all controls live inside it
816
+ const stage = touchGamepadStage = document.createElement('div');
817
+ stage.style.cssText = 'position:relative;width:100%;height:100%;pointer-events:none';
818
+ overlay.appendChild(stage);
819
+
820
+ // svg draws every visual and never blocks input
821
+ const svg = touchGamepadSvg = document.createElementNS(touchGamepadSvgNS, 'svg');
822
+ svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;' +
823
+ 'pointer-events:none;overflow:visible;fill:none;stroke:#fff;stroke-width:3';
824
+ stage.appendChild(svg);
825
+
826
+ // invisible input zones (left stick, right buttons/stick, center start)
827
+ const makeZone = ()=>
828
+ {
829
+ const z = document.createElement('div');
830
+ z.style.cssText = 'position:absolute;pointer-events:auto;touch-action:none';
831
+ z.addEventListener('pointerdown', e=> touchGamepadPointerDown(e, z));
832
+ z.addEventListener('pointermove', e=> touchGamepadPointerMove(e));
833
+ z.addEventListener('pointerup', e=> touchGamepadPointerUp(e));
834
+ z.addEventListener('pointercancel', e=> touchGamepadPointerUp(e));
835
+ stage.appendChild(z);
836
+ return z;
837
+ };
838
+ touchGamepadSideZones[0] = makeZone(); // left
839
+ touchGamepadSideZones[1] = makeZone(); // right
840
+ touchGamepadZoneC = makeZone(); // center/start, appended last so it sits above the sides
841
+
842
+ addEventListener('resize', ()=> touchGamepadNeedRelayout = true);
843
+ document.body.appendChild(overlay);
844
+ touchGamepadNeedRelayout = true;
845
+ }
846
+
847
+ // stage-local size in CSS pixels (excludes safe-area insets)
848
+ function touchGamepadStageRect() { return touchGamepadStage.getBoundingClientRect(); }
849
+
850
+ // per-side touch gamepad config (side 0 = left, 1 = right) - the left and right
851
+ // sides behave identically, differing only in position and gamepad button indices
852
+ function touchGamepadSideStick(side)
853
+ { return side ? touchGamepadRightStick : touchGamepadLeftStick; }
854
+ function touchGamepadSideButtonCount(side)
855
+ { return side ? touchGamepadButtonCount : touchGamepadLeftButtonCount; }
856
+ // gamepad button index a side's buttons start at (right 0-3, left 4-7)
857
+ function touchGamepadSideButtonBase(side)
858
+ { return side ? 0 : 4; }
859
+ // output stick index for a side: the right stick uses stick 0 when there is no left stick
860
+ function touchGamepadStickOut(side)
861
+ { return side && touchGamepadLeftStick ? 1 : 0; }
862
+ // true if the side has any control (a stick or at least one button)
863
+ function touchGamepadSideHasControl(side)
864
+ { return touchGamepadSideStick(side) || touchGamepadSideButtonCount(side) > 0; }
865
+
866
+ // true if gamepad button index i is an active touch gamepad face/single button
867
+ function touchGamepadIsFaceButton(i)
868
+ {
869
+ for (let side = 0; side < 2; side++)
783
870
  {
784
- if (!touchInputEnable || !isTouchDevice || headlessMode) return;
785
- if (!touchGamepadEnable || !touchGamepadTimer.isSet() && touchGamepadDisplayTime) return;
786
-
787
- // fade off when not touching or paused
788
- const alpha = touchGamepadDisplayTime ? percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
789
- if (!alpha || paused) return;
790
-
791
- // setup the canvas
792
- const context = mainContext;
793
- context.save();
794
- context.globalAlpha = alpha*touchGamepadAlpha;
795
- context.strokeStyle = '#fff';
796
- context.lineWidth = 3;
797
-
798
- // draw left analog stick
799
- const leftTouchStick = touchGamepadSticks[0] ?? vec2();
800
- context.fillStyle = leftTouchStick.lengthSquared() > 0 ? '#fff' : '#000';
801
- context.beginPath();
802
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
803
- if (touchGamepadAnalog)
871
+ const base = touchGamepadSideButtonBase(side);
872
+ if (!touchGamepadSideStick(side) &&
873
+ i >= base && i < base + touchGamepadSideButtonCount(side))
874
+ return true;
875
+ }
876
+ return false;
877
+ }
878
+
879
+ // center of a side's controls in stage-local CSS pixels (stick rest / button cluster)
880
+ // returns the floating stick anchor when that side is an active floating stick
881
+ function touchGamepadSideCenter(side, W, H)
882
+ {
883
+ if (touchGamepadFloating && touchGamepadSideStick(side) && touchGamepadStickAnchors[side])
884
+ return touchGamepadStickAnchors[side];
885
+ let y = H - touchGamepadSize;
886
+ const count = touchGamepadSideButtonCount(side);
887
+ if (!touchGamepadSideStick(side) && (count === 2 || count === 3))
888
+ y -= touchGamepadSize/4; // nudge a 2/3 button cluster up a bit
889
+ return vec2(side ? W - touchGamepadSize : touchGamepadSize, y);
890
+ }
891
+
892
+ // position the input zones for the current mode and rebuild the SVG visuals
893
+ function touchGamepadRelayout()
894
+ {
895
+ if (!touchGamepadOverlay) return;
896
+ const r = touchGamepadStageRect();
897
+ const W = r.width, H = r.height, S = touchGamepadSize;
898
+ const setZone = (z, css)=> z.style.cssText =
899
+ 'position:absolute;pointer-events:auto;touch-action:none;' + css;
900
+
901
+ if (paused)
902
+ {
903
+ // the gamepad is hidden while paused, so its side zones must not capture
904
+ // touches - otherwise they silently steal taps from menus and dialogs
905
+ for (const zone of touchGamepadSideZones) zone.style.display = 'none';
906
+ if (touchGamepadCenterButtonSize)
804
907
  {
805
- // draw circle shaped gamepad
806
- context.arc(stickCenter.x, stickCenter.y, touchGamepadSize/2, 0, 9);
908
+ // any touch presses start
909
+ setZone(touchGamepadZoneC, 'inset:0');
910
+ touchGamepadZoneC.style.display = '';
807
911
  }
808
912
  else
913
+ touchGamepadZoneC.style.display = 'none';
914
+ }
915
+ else
916
+ {
917
+ // position each side zone (left/right differ only by which edge they hug)
918
+ for (let side = 0; side < 2; side++)
809
919
  {
810
- // draw cross shaped gamepad
811
- for (let i=10; --i;)
920
+ const zone = touchGamepadSideZones[side], edge = side ? 'right' : 'left';
921
+ zone.style.display = touchGamepadSideHasControl(side) ? '' : 'none';
922
+ if (touchGamepadFloating)
812
923
  {
813
- const angle = i*PI/4;
814
- context.arc(stickCenter.x, stickCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8);
815
- i%2 && context.arc(stickCenter.x, stickCenter.y, touchGamepadSize*.33, angle, angle);
924
+ // bottom 60% grabs the control; the top 40% passes through. A side with no
925
+ // control on the other side uses the full width (matching the hit-test)
926
+ const width = touchGamepadSideHasControl(side ? 0 : 1) ? '50%' : '100%';
927
+ setZone(zone, `${edge}:0;bottom:0;width:${width};height:60%`);
816
928
  }
929
+ else // fixed: a compact box hugging the corner control
930
+ setZone(zone, `${edge}:0;bottom:0;width:${3*S}px;height:${3*S}px`);
931
+ }
932
+ touchGamepadZoneC.style.display = touchGamepadCenterButtonSize ? '' : 'none';
933
+ const c = touchGamepadCenterButtonSize;
934
+ setZone(touchGamepadZoneC,
935
+ `left:50%;top:50%;width:${2*c}px;height:${2*c}px;transform:translate(-50%,-50%)`);
936
+ }
937
+
938
+ touchGamepadBuildSvg(W, H);
939
+ touchGamepadNeedRelayout = false;
940
+ }
941
+
942
+ // (re)build the SVG shapes for the current layout; dynamic bits update per-frame
943
+ function touchGamepadBuildSvg(W, H)
944
+ {
945
+ const svg = touchGamepadSvg;
946
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
947
+ const els = touchGamepadSvgEls = { face: [], thumb: [] };
948
+ const S = touchGamepadSize;
949
+ const circle = (cx, cy, rr, fill)=>
950
+ {
951
+ const c = document.createElementNS(touchGamepadSvgNS, 'circle');
952
+ c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', rr);
953
+ if (fill) c.setAttribute('fill', fill);
954
+ svg.appendChild(c);
955
+ return c;
956
+ };
957
+ const cross = (ctr)=>
958
+ {
959
+ // plus-shaped dpad outline centered at ctr
960
+ const a = S*.18, b = S*.5, x = ctr.x, y = ctr.y;
961
+ const p = document.createElementNS(touchGamepadSvgNS, 'path');
962
+ p.setAttribute('d',
963
+ `M ${x-a} ${y-b} H ${x+a} V ${y-a} H ${x+b} V ${y+a} H ${x+a} ` +
964
+ `V ${y+b} H ${x-a} V ${y+a} H ${x-b} V ${y-a} H ${x-a} Z`);
965
+ svg.appendChild(p);
966
+ };
967
+
968
+ // draw each side: a directional stick, a single large button, or face buttons
969
+ for (let side = 0; side < 2; side++)
970
+ {
971
+ const count = touchGamepadSideButtonCount(side);
972
+ const base = touchGamepadSideButtonBase(side);
973
+ const ctr = touchGamepadSideCenter(side, W, H);
974
+ if (touchGamepadSideStick(side))
975
+ {
976
+ // directional stick (circle or cross) with a thumb dot that moves per-frame
977
+ if (touchGamepadAnalog) circle(ctr.x, ctr.y, S/2); else cross(ctr);
978
+ els.thumb[side] = circle(ctr.x, ctr.y, S/4, '#fff');
979
+ }
980
+ else if (count === 1)
981
+ els.face[base] = circle(ctr.x, ctr.y, S/2, '#000'); // single large button
982
+ else for (let i = 0; i < count; i++)
983
+ {
984
+ const j = mod(i-1, 4);
985
+ let button = count > 2 ? j : min(j, count-1);
986
+ button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
987
+ const offset = vec2().setDirection(j, S/2);
988
+ if (count === 2) offset.x *= -1;
989
+ // left side mirrors the right layout's positions, keeping indices in order
990
+ // (e.g. 2 buttons -> button 4 at bottom, button 5 at left)
991
+ if (!side) offset.x *= -1;
992
+ const pos = ctr.add(offset);
993
+ els.face[base + button] = circle(pos.x, pos.y, S/4, '#000');
817
994
  }
818
- context.fill();
819
- context.stroke();
995
+ }
996
+
997
+ // debug: draw the proximity hit regions the hit-test actually uses
998
+ if (debug && debugGamepads) touchGamepadBuildDebug(W, H);
999
+ }
820
1000
 
821
- // draw right face buttons
1001
+ // draw debug outlines of the touch control hit regions into the overlay svg
1002
+ function touchGamepadBuildDebug(W, H)
1003
+ {
1004
+ const S = touchGamepadSize, svg = touchGamepadSvg;
1005
+ const shape = (tag, attrs, stroke)=>
1006
+ {
1007
+ const el = document.createElementNS(touchGamepadSvgNS, tag);
1008
+ for (const k in attrs) el.setAttribute(k, attrs[k]);
1009
+ el.setAttribute('stroke', stroke);
1010
+ el.setAttribute('stroke-width', 2);
1011
+ el.setAttribute('fill', 'none');
1012
+ svg.appendChild(el);
1013
+ };
1014
+ const ring = (c, rr, stroke)=> shape('circle', {cx:c.x, cy:c.y, r:rr}, stroke);
1015
+
1016
+ // green line: the left/right split that assigns a stick press to a side
1017
+ shape('line', {x1:W/2, y1:0, x2:W/2, y2:H}, '#0f0');
1018
+
1019
+ // cyan: where each side's control can be grabbed
1020
+ for (let side = 0; side < 2; side++)
1021
+ {
1022
+ if (touchGamepadSideStick(side))
822
1023
  {
823
- const buttonCenter = touchGamepadButtonCenter();
824
- const buttonSize = touchGamepadButtonCount > 1 ?
825
- touchGamepadSize/4 : touchGamepadSize/2;
826
- for (let i=0; i<touchGamepadButtonCount; i++)
1024
+ if (touchGamepadFloating)
827
1025
  {
828
- const j = mod(i-1, 4);
829
- let button = touchGamepadButtonCount > 2 ?
830
- j : min(j, touchGamepadButtonCount-1);
831
- // fix button locations (swap 2 and 3 to match gamepad layout)
832
- button = button === 3 ? 2 : button === 2 ? 3 : button;
833
- const pos = touchGamepadButtonCount < 2 ? buttonCenter :
834
- buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
835
- context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
836
- context.beginPath();
837
- context.arc(pos.x, pos.y, buttonSize, 0,9);
838
- context.fill();
839
- context.stroke();
1026
+ // grab region: this side's half (or the full width if the other side is empty)
1027
+ const top = H*.4, full = !touchGamepadSideHasControl(side ? 0 : 1);
1028
+ const x = full ? 0 : (side ? W/2 : 0);
1029
+ shape('rect', {x, y:top, width:full ? W : W/2, height:H-top}, '#0ff');
840
1030
  }
1031
+ else
1032
+ ring(touchGamepadSideCenter(side, W, H), 2*S, '#0ff');
841
1033
  }
1034
+ else if (touchGamepadSideButtonCount(side) >= 1)
1035
+ ring(touchGamepadSideCenter(side, W, H), S, '#0ff'); // face / single-button radius
1036
+ }
1037
+
1038
+ // yellow: start button radius; magenta: where start is blocked (near a control)
1039
+ if (touchGamepadCenterButtonSize)
1040
+ {
1041
+ ring(vec2(W/2, H/2), touchGamepadCenterButtonSize, '#ff0');
1042
+ for (let side = 0; side < 2; side++)
1043
+ if (touchGamepadSideHasControl(side))
1044
+ ring(touchGamepadSideCenter(side, W, H), 2*S, '#f0f');
1045
+ }
1046
+ }
1047
+
1048
+ // per-frame: fade the overlay and move the thumbs / set pressed states
1049
+ function touchGamepadRender()
1050
+ {
1051
+ if (!touchGamepadOverlay || headlessMode) return;
842
1052
 
843
- // set canvas back to normal
844
- context.restore();
1053
+ // hide and bail if disabled at runtime (overlay stays in the DOM for reuse)
1054
+ // display:none also takes the input zones out of hit-testing so touches are
1055
+ // not silently captured away from the game while disabled
1056
+ if (!touchGamepadEnable || !isTouchDevice)
1057
+ {
1058
+ if (touchGamepadOverlay.style.display !== 'none')
1059
+ {
1060
+ // just disabled: hide the overlay and release any held controls
1061
+ touchGamepadOverlay.style.display = 'none';
1062
+ touchGamepadPointerRole.clear();
1063
+ touchGamepadButtons.length = 0;
1064
+ touchGamepadSticks.length = 0;
1065
+ touchGamepadStickPointerId.length = 0;
1066
+ }
1067
+ return;
1068
+ }
1069
+ touchGamepadOverlay.style.display = '';
1070
+
1071
+ // relayout when the paused state, a layout setting, or the debug view changes
1072
+ const dbg = debug && debugGamepads;
1073
+ const layout = [touchGamepadButtonCount, touchGamepadLeftButtonCount, touchGamepadLeftStick,
1074
+ touchGamepadRightStick, touchGamepadAnalog, touchGamepadSize, touchGamepadFloating,
1075
+ touchGamepadCenterButtonSize, paused, dbg].join();
1076
+ if (layout !== touchGamepadLastLayout)
1077
+ {
1078
+ touchGamepadLastLayout = layout;
1079
+ touchGamepadNeedRelayout = true;
845
1080
  }
1081
+ // relayout before the visibility bail-out so the paused full-screen start zone applies
1082
+ if (touchGamepadNeedRelayout) touchGamepadRelayout();
1083
+
1084
+ // fade out when idle (always show when displayTime is 0, or while debugging)
1085
+ const fade = touchGamepadDisplayTime ?
1086
+ percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
1087
+ const visible = dbg || (touchGamepadTimer.isSet() && fade > 0 && !paused);
1088
+ touchGamepadOverlay.style.opacity = !visible ? 0 : dbg ? 1 : fade*touchGamepadAlpha;
1089
+ if (!visible) return;
1090
+
1091
+ const r = touchGamepadStageRect();
1092
+ const W = r.width, H = r.height, S = touchGamepadSize;
1093
+ const els = touchGamepadSvgEls;
1094
+ if (!els) return;
1095
+
1096
+ for (let side = 0; side < 2; side++)
1097
+ if (touchGamepadSideStick(side) && els.thumb[side])
1098
+ {
1099
+ const ctr = touchGamepadSideCenter(side, W, H);
1100
+ const t = ctr.add((touchGamepadSticks[side] ?? vec2()).scale(S/2));
1101
+ els.thumb[side].setAttribute('cx', t.x);
1102
+ els.thumb[side].setAttribute('cy', t.y);
1103
+ }
1104
+ for (let i = 0; i < els.face.length; i++)
1105
+ if (els.face[i])
1106
+ els.face[i].setAttribute('fill', touchGamepadButtons[i] ? '#fff' : '#000');
1107
+ }
1108
+
1109
+ // convert a pointer event to stage-local CSS pixels
1110
+ function touchGamepadEventPos(e)
1111
+ {
1112
+ const r = touchGamepadStageRect();
1113
+ return vec2(e.clientX - r.left, e.clientY - r.top);
846
1114
  }
847
1115
 
848
- // center position for right touch pad face buttons
849
- function touchGamepadButtonCenter()
1116
+ // set a directional stick from a stage-local point and flag its stick-touch button
1117
+ // (stick 0 press = button 10, stick 1 press = button 11, following the output index)
1118
+ function touchGamepadApplyStick(side, p)
850
1119
  {
851
- const center = mainCanvasSize.subtract(vec2(touchGamepadSize));
852
- if (touchGamepadButtonCount === 2)
853
- center.x += touchGamepadSize/2;
854
- return center;
1120
+ const delta = p.subtract(touchGamepadStickAnchors[side]);
1121
+ touchGamepadSticks[side] = delta.scale(2/touchGamepadSize).clampLength();
1122
+ touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10] = 1;
1123
+ }
1124
+
1125
+ // pick a side's gamepad button index from a stage-local point, or -1 if outside the cluster
1126
+ function touchGamepadFaceButtonAt(side, p, W, H)
1127
+ {
1128
+ const count = touchGamepadSideButtonCount(side);
1129
+ const base = touchGamepadSideButtonBase(side);
1130
+ const bc = touchGamepadSideCenter(side, W, H);
1131
+ if (bc.distance(p) >= touchGamepadSize) return -1;
1132
+ if (count === 1) return base; // single large button
1133
+ const d = bc.subtract(p);
1134
+ if (!side) d.x *= -1; // left side mirrors the right layout's positions horizontally
1135
+ let button = count === 2 ? (d.x < d.y ? 1 : 0) : mod(d.direction()+2, 4);
1136
+ button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
1137
+ return button < count ? base + button : -1;
1138
+ }
1139
+
1140
+ // pick which control a stage-local press activates, by priority then proximity,
1141
+ // independent of which zone element captured it - so overlapping zones on small
1142
+ // screens resolve to the nearest control instead of whichever zone is topmost
1143
+ // returns {role:'stick', side} or {role:'face', btn} or {role:'start'} or undefined
1144
+ function touchGamepadControlAt(p, W, H)
1145
+ {
1146
+ const S = touchGamepadSize;
1147
+ const leftHalf = p.x < W/2;
1148
+ const floatTop = H*.4; // floating grab region is the bottom 60% of the screen
1149
+
1150
+ // check each side (left first for priority); a side is a stick or buttons
1151
+ for (let side = 0; side < 2; side++)
1152
+ {
1153
+ const onHalf = side ? !leftHalf : leftHalf;
1154
+ if (touchGamepadSideStick(side))
1155
+ {
1156
+ // a side with no control on the other side uses the full width
1157
+ const otherControl = touchGamepadSideHasControl(side ? 0 : 1);
1158
+ const grab = touchGamepadFloating ?
1159
+ (!otherControl || onHalf) && p.y > floatTop :
1160
+ onHalf && touchGamepadSideCenter(side, W, H).distance(p) < 2*S;
1161
+ if (grab) return {role:'stick', side};
1162
+ }
1163
+ else if (touchGamepadSideButtonCount(side) >= 1)
1164
+ {
1165
+ const btn = touchGamepadFaceButtonAt(side, p, W, H);
1166
+ if (btn >= 0) return {role:'face', btn};
1167
+ }
1168
+ }
1169
+
1170
+ // center start button, blocked within 2*size of a control so drift off a
1171
+ // control can't accidentally fire start (matches the original exclusion logic)
1172
+ if (touchGamepadCenterButtonSize)
1173
+ {
1174
+ for (let side = 0; side < 2; side++)
1175
+ if (touchGamepadSideHasControl(side) &&
1176
+ touchGamepadSideCenter(side, W, H).distance(p) < 2*S)
1177
+ return;
1178
+ if (vec2(W/2, H/2).distance(p) < touchGamepadCenterButtonSize)
1179
+ return {role:'start'};
1180
+ }
1181
+ }
1182
+
1183
+ function touchGamepadPointerDown(e, zone)
1184
+ {
1185
+ if (!touchGamepadEnable) return;
1186
+ e.preventDefault();
1187
+ zone.setPointerCapture(e.pointerId);
1188
+ touchGamepadTimer.set();
1189
+
1190
+ // resume audio on first interaction
1191
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
1192
+ audioContext.resume();
1193
+
1194
+ // while paused, any touch is the start button
1195
+ if (paused)
1196
+ {
1197
+ if (touchGamepadCenterButtonSize)
1198
+ {
1199
+ touchGamepadButtons[9] = 1;
1200
+ touchGamepadPointerRole.set(e.pointerId, 'start');
1201
+ }
1202
+ return;
1203
+ }
1204
+
1205
+ const r = touchGamepadStageRect();
1206
+ const W = r.width, H = r.height;
1207
+ const p = vec2(e.clientX - r.left, e.clientY - r.top);
1208
+
1209
+ // choose the control by proximity/priority, not by which zone captured the touch
1210
+ const hit = touchGamepadControlAt(p, W, H);
1211
+ if (!hit) return;
1212
+ if (hit.role === 'stick')
1213
+ {
1214
+ const side = hit.side;
1215
+ touchGamepadStickAnchors[side] = touchGamepadFloating ? p : touchGamepadSideCenter(side, W, H);
1216
+ touchGamepadStickPointerId[side] = e.pointerId;
1217
+ touchGamepadPointerRole.set(e.pointerId, 'stick'+side);
1218
+ touchGamepadNeedRelayout = true; // base may have re-anchored
1219
+ touchGamepadApplyStick(side, p);
1220
+ }
1221
+ else if (hit.role === 'face')
1222
+ {
1223
+ touchGamepadButtons[hit.btn] = 1;
1224
+ touchGamepadPointerRole.set(e.pointerId, 'face'+hit.btn);
1225
+ }
1226
+ else // 'start'
1227
+ {
1228
+ touchGamepadButtons[9] = 1;
1229
+ touchGamepadPointerRole.set(e.pointerId, 'start');
1230
+ }
1231
+ }
1232
+
1233
+ function touchGamepadPointerMove(e)
1234
+ {
1235
+ const role = touchGamepadPointerRole.get(e.pointerId);
1236
+ if (!role) return;
1237
+ e.preventDefault();
1238
+ const p = touchGamepadEventPos(e);
1239
+ if (role === 'stick0' || role === 'stick1')
1240
+ touchGamepadApplyStick(role === 'stick1' ? 1 : 0, p);
1241
+ // face buttons & start are held until release (no slide-between this pass)
1242
+ }
1243
+
1244
+ function touchGamepadPointerUp(e)
1245
+ {
1246
+ const role = touchGamepadPointerRole.get(e.pointerId);
1247
+ if (!role) return;
1248
+ touchGamepadPointerRole.delete(e.pointerId);
1249
+ if (role === 'stick0' || role === 'stick1')
1250
+ {
1251
+ const side = role === 'stick1' ? 1 : 0;
1252
+ touchGamepadStickPointerId[side] = undefined;
1253
+ touchGamepadSticks[side] = vec2();
1254
+ delete touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10];
1255
+ }
1256
+ else if (role === 'start')
1257
+ delete touchGamepadButtons[9];
1258
+ else // 'face<n>'
1259
+ delete touchGamepadButtons[+role.slice(4)];
1260
+ touchGamepadTimer.set();
855
1261
  }