littlejsengine 1.18.12 → 1.18.17

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.
@@ -90,6 +90,7 @@ function inputClear()
90
90
  inputData[0] = [];
91
91
  touchGamepadButtons.length = 0;
92
92
  touchGamepadSticks.length = 0;
93
+ touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor
93
94
  gamepadStickData.length = 0;
94
95
  gamepadDpadData.length = 0;
95
96
  }
@@ -332,6 +333,14 @@ const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
332
333
 
333
334
  // touch gamepad internal variables
334
335
  const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
336
+ // floating stick anchors (stage-local CSS pixels) and owning pointer ids, indexed by stick (0=left, 1=right)
337
+ const touchGamepadStickAnchors = [], touchGamepadStickPointerId = [];
338
+ // pointerId -> control role ('stick0', 'stick1', 'face<n>', or 'start')
339
+ const touchGamepadPointerRole = new Map();
340
+ // overlay DOM elements (created lazily on touch devices) and cached SVG shapes
341
+ let touchGamepadOverlay, touchGamepadStage, touchGamepadSvg, touchGamepadSvgEls;
342
+ let touchGamepadSideZones = [], touchGamepadZoneC;
343
+ let touchGamepadNeedRelayout = true, touchGamepadLastLayout;
335
344
 
336
345
  ///////////////////////////////////////////////////////////////////////////////
337
346
  // Input system functions used by engine
@@ -447,14 +456,24 @@ function inputInit()
447
456
  mouseDeltaScreen = mouseDeltaScreen.add(movement);
448
457
  }
449
458
  function onMouseLeave() { mouseInWindow = false; } // mouse moved off window
450
- function onMouseWheel(e)
451
- {
452
- mouseWheel = e.ctrlKey ? 0 : sign(e.deltaY);
459
+ function onMouseWheel(e)
460
+ {
461
+ // accumulate so multiple wheel events in one frame are not lost
462
+ if (!e.ctrlKey)
463
+ mouseWheel += sign(e.deltaY);
453
464
  if (inputPreventDefault && e.cancelable && document.hasFocus())
454
465
  e.preventDefault(); // prevent page scrolling
455
466
  }
456
467
  function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
457
- function onBlur() { inputClear(); } // reset input when focus is lost
468
+ function onBlur()
469
+ {
470
+ inputClear();
471
+ // release any held virtual gamepad controls so they don't stick
472
+ touchGamepadPointerRole.clear();
473
+ touchGamepadButtons.length = 0;
474
+ touchGamepadSticks.length = 0;
475
+ touchGamepadStickPointerId.length = 0;
476
+ }
458
477
 
459
478
  // enable touch input mouse passthrough
460
479
  function touchInputInit()
@@ -470,36 +489,46 @@ function inputInit()
470
489
  {
471
490
  if (!touchInputEnable) return;
472
491
 
473
- // route touch to gamepad
474
- if (touchGamepadEnable)
475
- handleTouchGamepad(e);
476
-
477
492
  // fix stalled audio requiring user interaction
478
493
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
479
494
  audioContext.resume();
480
495
 
481
- // check if touching and pass to mouse events
482
- const touching = e.touches.length;
483
- const button = 0; // all touches are left mouse button
484
- if (touching)
496
+ // when the touch gamepad is enabled it owns touch input: suppress the
497
+ // touch->mouse passthrough entirely unless touchGamepadPassthrough is set
498
+ // (its own zones drive gameplay via pointer events)
499
+ if (!touchGamepadEnable || touchGamepadPassthrough)
485
500
  {
486
- // set event pos and pass it along
487
- const pos = vec2(e.touches[0].clientX, e.touches[0].clientY);
488
- const mousePosScreenLast = mousePosScreen;
489
- mousePosScreen = mouseEventToScreen(pos);
490
- if (wasTouching)
501
+ // touches that landed on a virtual gamepad zone are owned by the gamepad
502
+ // (handled by its own pointer listeners) and must not drive the game mouse
503
+ const isGamepadTouch = (t)=>
504
+ touchGamepadSideZones.includes(t.target) || t.target === touchGamepadZoneC;
505
+ const gameTouches = [];
506
+ for (const t of e.touches)
507
+ if (!isGamepadTouch(t)) gameTouches.push(t);
508
+
509
+ // check if touching and pass to mouse events
510
+ const touching = gameTouches.length;
511
+ const button = 0; // all touches are left mouse button
512
+ if (touching)
491
513
  {
492
- mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
493
- isUsingGamepad = touchGamepadEnable;
514
+ // set event pos and pass it along
515
+ const pos = vec2(gameTouches[0].clientX, gameTouches[0].clientY);
516
+ const mousePosScreenLast = mousePosScreen;
517
+ mousePosScreen = mouseEventToScreen(pos);
518
+ if (wasTouching)
519
+ mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
520
+ else
521
+ {
522
+ inputData[0][button] = 3;
523
+ isUsingGamepad = false; // a passthrough tap is mouse-style input
524
+ }
494
525
  }
495
- else
496
- inputData[0][button] = 3;
497
- }
498
- else if (wasTouching)
499
- inputData[0][button] = inputData[0][button] & 2 | 4;
526
+ else if (wasTouching)
527
+ inputData[0][button] = inputData[0][button] & 2 | 4;
500
528
 
501
- // set was touching
502
- wasTouching = touching;
529
+ // set was touching
530
+ wasTouching = touching;
531
+ }
503
532
 
504
533
  // prevent default handling like copy, magnifier lens, and scrolling
505
534
  if (inputPreventDefault && e.cancelable && document.hasFocus())
@@ -509,78 +538,6 @@ function inputInit()
509
538
  return true;
510
539
  }
511
540
 
512
- // special handling for virtual gamepad mode
513
- function handleTouchGamepad(e)
514
- {
515
- // clear touch gamepad input
516
- touchGamepadSticks.length = 0;
517
- touchGamepadSticks[0] = vec2();
518
- touchGamepadSticks[1] = vec2();
519
- touchGamepadButtons.length = 0;
520
- isUsingGamepad = true;
521
-
522
- const touching = e.touches.length;
523
- if (touching)
524
- {
525
- touchGamepadTimer.set();
526
- if (touchGamepadCenterButtonSize && !wasTouching && paused)
527
- {
528
- // touch anywhere to press start when paused
529
- touchGamepadButtons[9] = 1;
530
- return;
531
- }
532
- }
533
-
534
- // don't process touch gamepad if paused
535
- if (paused) return;
536
-
537
- // get center of left and right sides
538
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
539
- const buttonCenter = touchGamepadButtonCenter();
540
- const startCenter = mainCanvasSize.scale(.5);
541
-
542
- // check each touch point
543
- for (const touch of e.touches)
544
- {
545
- const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
546
- if (stickCenter.distance(touchPos) < touchGamepadSize)
547
- {
548
- // virtual analog stick
549
- const delta = touchPos.subtract(stickCenter);
550
- touchGamepadSticks[0] = delta.scale(2/touchGamepadSize).clampLength();
551
- touchGamepadButtons[10] = 1; // also press a button when touching stick
552
- }
553
- else if (buttonCenter.distance(touchPos) < touchGamepadSize)
554
- {
555
- if (touchGamepadButtonCount === 1)
556
- {
557
- // virtual right analog stick
558
- const delta = touchPos.subtract(buttonCenter);
559
- touchGamepadSticks[1] = delta.scale(2/touchGamepadSize).clampLength();
560
- touchGamepadButtons[11] = 1; // also press a button when touching right stick
561
- }
562
- // virtual face buttons
563
- let button = buttonCenter.subtract(touchPos).direction();
564
- button = mod(button+2, 4);
565
- if (touchGamepadButtonCount === 1)
566
- button = 0;
567
- else if (touchGamepadButtonCount === 2)
568
- {
569
- const delta = buttonCenter.subtract(touchPos);
570
- button = -delta.x < delta.y ? 1 : 0;
571
- }
572
- // fix button locations (swap 2 and 3 to match gamepad layout)
573
- button = button === 3 ? 2 : button === 2 ? 3 : button;
574
- if (button < touchGamepadButtonCount)
575
- touchGamepadButtons[button] = 1;
576
- }
577
- else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize)
578
- {
579
- // virtual start button in center
580
- touchGamepadButtons[9] = 1;
581
- }
582
- }
583
- }
584
541
  }
585
542
 
586
543
  // convert a mouse or touch event position to screen space
@@ -605,6 +562,9 @@ function inputUpdate()
605
562
  mousePos = screenToWorld(mousePosScreen);
606
563
  mouseDelta = screenToWorldDelta(mouseDeltaScreen);
607
564
 
565
+ // build the touch gamepad overlay lazily once enabled on a touch device
566
+ touchGamepadInit();
567
+
608
568
  // update gamepads if enabled
609
569
  gamepadsUpdate();
610
570
 
@@ -618,22 +578,16 @@ function inputUpdate()
618
578
  v > min ? percent(v, min, max) :
619
579
  v < -min ? -percent(-v, min, max) : 0;
620
580
  return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
621
- }
581
+ };
622
582
 
623
583
  // update touch gamepad if enabled
624
584
  if (touchGamepadEnable && isTouchDevice)
625
585
  {
626
- if (debugGamepads)
627
- {
628
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
629
- const buttonCenter = touchGamepadButtonCenter();
630
- const startCenter = mainCanvasSize.scale(.5);
631
-
632
- debugCircle(stickCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
633
- debugCircle(buttonCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
634
- if (touchGamepadCenterButtonSize)
635
- debugCircle(startCenter, 2*touchGamepadCenterButtonSize, 'cyan', 0, false, true);
636
- }
586
+ // a side is either a stick or buttons - setting both is ambiguous
587
+ ASSERT(!touchGamepadLeftStick || !touchGamepadLeftButtonCount,
588
+ 'set touchGamepadLeftStick or touchGamepadLeftButtonCount, not both');
589
+ ASSERT(!touchGamepadRightStick || !touchGamepadButtonCount,
590
+ 'set touchGamepadRightStick or touchGamepadButtonCount, not both');
637
591
 
638
592
  if (!touchGamepadTimer.isSet()) return;
639
593
 
@@ -641,23 +595,24 @@ function inputUpdate()
641
595
  gamepadPrimary = 0; // touch gamepad uses index 0
642
596
  const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
643
597
  const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
644
- sticks[0] = vec2();
598
+ sticks.length = 0; // only report sticks that are enabled
645
599
  dpad.set();
646
- const leftTouchStick = touchGamepadSticks[0] ?? vec2();
647
- if (touchGamepadAnalog)
648
- sticks[0] = applyDeadZones(leftTouchStick);
649
- else if (leftTouchStick.lengthSquared() > .3)
600
+ // read each side's directional stick (analog, or quantized to an 8 way dpad)
601
+ for (let side = 0; side < 2; side++)
650
602
  {
651
- // convert to 8 way dpad
652
- const x = clamp(round(leftTouchStick.x), -1, 1);
653
- const y = clamp(round(leftTouchStick.y), -1, 1);
654
- dpad.set(x, -y);
655
- sticks[0] = dpad.clampLength(); // clamp to circle
656
- }
657
- if (touchGamepadButtonCount === 1)
658
- {
659
- const rightTouchStick = touchGamepadSticks[1] ?? vec2();
660
- sticks[1] = applyDeadZones(rightTouchStick);
603
+ if (!touchGamepadSideStick(side)) continue;
604
+ const out = touchGamepadStickOut(side);
605
+ sticks[out] = vec2();
606
+ const touchStick = touchGamepadSticks[side] ?? vec2();
607
+ if (touchGamepadAnalog)
608
+ sticks[out] = applyDeadZones(touchStick);
609
+ else if (touchStick.lengthSquared() > .3)
610
+ {
611
+ const x = clamp(round(touchStick.x), -1, 1);
612
+ const y = clamp(round(touchStick.y), -1, 1);
613
+ sticks[out] = vec2(x, -y).clampLength(); // clamp to circle
614
+ if (!out) dpad.set(x, -y); // the primary (stick 0) also drives the dpad vector
615
+ }
661
616
  }
662
617
 
663
618
  // read virtual gamepad buttons
@@ -666,6 +621,12 @@ function inputUpdate()
666
621
  {
667
622
  const wasDown = gamepadIsDown(i,0);
668
623
  data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
624
+
625
+ // haptic tap when a face button or start button is first pressed (3 = newly down)
626
+ // skip stick touches (10, 11) so movement doesn't buzz
627
+ if (touchGamepadVibration && data[i] === 3 &&
628
+ (i === 9 || touchGamepadIsFaceButton(i)))
629
+ vibrate(touchGamepadVibration);
669
630
  }
670
631
 
671
632
  // disable normal gamepads when touch gamepad is active
@@ -739,13 +700,6 @@ function inputUpdate()
739
700
  (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
740
701
  (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
741
702
  }
742
- else if (gamepad.axes && gamepad.axes.length >= 2)
743
- {
744
- // digital style dpad from axes
745
- const x = clamp(round(gamepad.axes[0]), -1, 1);
746
- const y = clamp(round(gamepad.axes[1]), -1, 1);
747
- dpad.set(x, -y);
748
- }
749
703
 
750
704
  // copy dpad to left analog stick when pressed
751
705
  if (gamepadDirectionEmulateStick && (dpad.x || dpad.y))
@@ -773,78 +727,468 @@ function inputUpdatePost()
773
727
  function inputRender()
774
728
  {
775
729
  touchGamepadRender();
730
+ }
731
+
732
+ ///////////////////////////////////////////////////////////////////////////////
733
+ // Touch gamepad - full-viewport HTML/SVG overlay driven by Pointer Events
776
734
 
777
- function touchGamepadRender()
735
+ const touchGamepadSvgNS = 'http://www.w3.org/2000/svg';
736
+
737
+ // build the overlay DOM once; no-op if already built, disabled, headless, or non-touch
738
+ function touchGamepadInit()
739
+ {
740
+ if (touchGamepadOverlay || !touchGamepadEnable || !isTouchDevice || headlessMode ||
741
+ !document.body) // body may not exist yet; retry on a later frame
742
+ return;
743
+
744
+ // full-viewport overlay; only the input zones receive pointer events. The
745
+ // env() padding insets the stage out of notches / home indicators natively.
746
+ const overlay = touchGamepadOverlay = document.createElement('div');
747
+ overlay.style.cssText =
748
+ 'position:fixed;inset:0;z-index:50;pointer-events:none;opacity:0;' +
749
+ 'touch-action:none;user-select:none;-webkit-user-select:none;' +
750
+ '-webkit-touch-callout:none;transition:opacity .2s;box-sizing:border-box;' +
751
+ 'padding:env(safe-area-inset-top) env(safe-area-inset-right) ' +
752
+ 'env(safe-area-inset-bottom) env(safe-area-inset-left)';
753
+
754
+ // stage fills the padded (safe-area) content box; all controls live inside it
755
+ const stage = touchGamepadStage = document.createElement('div');
756
+ stage.style.cssText = 'position:relative;width:100%;height:100%;pointer-events:none';
757
+ overlay.appendChild(stage);
758
+
759
+ // svg draws every visual and never blocks input
760
+ const svg = touchGamepadSvg = document.createElementNS(touchGamepadSvgNS, 'svg');
761
+ svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;' +
762
+ 'pointer-events:none;overflow:visible;fill:none;stroke:#fff;stroke-width:3';
763
+ stage.appendChild(svg);
764
+
765
+ // invisible input zones (left stick, right buttons/stick, center start)
766
+ const makeZone = ()=>
778
767
  {
779
- if (!touchInputEnable || !isTouchDevice || headlessMode) return;
780
- if (!touchGamepadEnable || !touchGamepadTimer.isSet() && touchGamepadDisplayTime) return;
781
-
782
- // fade off when not touching or paused
783
- const alpha = touchGamepadDisplayTime ? percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
784
- if (!alpha || paused) return;
785
-
786
- // setup the canvas
787
- const context = mainContext;
788
- context.save();
789
- context.globalAlpha = alpha*touchGamepadAlpha;
790
- context.strokeStyle = '#fff';
791
- context.lineWidth = 3;
792
-
793
- // draw left analog stick
794
- const leftTouchStick = touchGamepadSticks[0] ?? vec2();
795
- context.fillStyle = leftTouchStick.lengthSquared() > 0 ? '#fff' : '#000';
796
- context.beginPath();
797
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
798
- if (touchGamepadAnalog)
799
- {
800
- // draw circle shaped gamepad
801
- context.arc(stickCenter.x, stickCenter.y, touchGamepadSize/2, 0, 9);
802
- }
803
- else
768
+ const z = document.createElement('div');
769
+ z.style.cssText = 'position:absolute;pointer-events:auto;touch-action:none';
770
+ z.addEventListener('pointerdown', e=> touchGamepadPointerDown(e, z));
771
+ z.addEventListener('pointermove', e=> touchGamepadPointerMove(e));
772
+ z.addEventListener('pointerup', e=> touchGamepadPointerUp(e));
773
+ z.addEventListener('pointercancel', e=> touchGamepadPointerUp(e));
774
+ stage.appendChild(z);
775
+ return z;
776
+ };
777
+ touchGamepadSideZones[0] = makeZone(); // left
778
+ touchGamepadSideZones[1] = makeZone(); // right
779
+ touchGamepadZoneC = makeZone(); // center/start, appended last so it sits above the sides
780
+
781
+ addEventListener('resize', ()=> touchGamepadNeedRelayout = true);
782
+ document.body.appendChild(overlay);
783
+ touchGamepadNeedRelayout = true;
784
+ }
785
+
786
+ // stage-local size in CSS pixels (excludes safe-area insets)
787
+ function touchGamepadStageRect() { return touchGamepadStage.getBoundingClientRect(); }
788
+
789
+ // per-side touch gamepad config (side 0 = left, 1 = right) - the left and right
790
+ // sides behave identically, differing only in position and gamepad button indices
791
+ function touchGamepadSideStick(side)
792
+ { return side ? touchGamepadRightStick : touchGamepadLeftStick; }
793
+ function touchGamepadSideButtonCount(side)
794
+ { return side ? touchGamepadButtonCount : touchGamepadLeftButtonCount; }
795
+ // gamepad button index a side's buttons start at (right 0-3, left 4-7)
796
+ function touchGamepadSideButtonBase(side)
797
+ { return side ? 0 : 4; }
798
+ // output stick index for a side: the right stick uses stick 0 when there is no left stick
799
+ function touchGamepadStickOut(side)
800
+ { return side && touchGamepadLeftStick ? 1 : 0; }
801
+ // true if the side has any control (a stick or at least one button)
802
+ function touchGamepadSideHasControl(side)
803
+ { return touchGamepadSideStick(side) || touchGamepadSideButtonCount(side) > 0; }
804
+
805
+ // true if gamepad button index i is an active touch gamepad face/single button
806
+ function touchGamepadIsFaceButton(i)
807
+ {
808
+ for (let side = 0; side < 2; side++)
809
+ {
810
+ const base = touchGamepadSideButtonBase(side);
811
+ if (!touchGamepadSideStick(side) &&
812
+ i >= base && i < base + touchGamepadSideButtonCount(side))
813
+ return true;
814
+ }
815
+ return false;
816
+ }
817
+
818
+ // center of a side's controls in stage-local CSS pixels (stick rest / button cluster)
819
+ // returns the floating stick anchor when that side is an active floating stick
820
+ function touchGamepadSideCenter(side, W, H)
821
+ {
822
+ if (touchGamepadFloating && touchGamepadSideStick(side) && touchGamepadStickAnchors[side])
823
+ return touchGamepadStickAnchors[side];
824
+ let y = H - touchGamepadSize;
825
+ const count = touchGamepadSideButtonCount(side);
826
+ if (!touchGamepadSideStick(side) && (count === 2 || count === 3))
827
+ y -= touchGamepadSize/4; // nudge a 2/3 button cluster up a bit
828
+ return vec2(side ? W - touchGamepadSize : touchGamepadSize, y);
829
+ }
830
+
831
+ // position the input zones for the current mode and rebuild the SVG visuals
832
+ function touchGamepadRelayout()
833
+ {
834
+ if (!touchGamepadOverlay) return;
835
+ const r = touchGamepadStageRect();
836
+ const W = r.width, H = r.height, S = touchGamepadSize;
837
+ const setZone = (z, css)=> z.style.cssText =
838
+ 'position:absolute;pointer-events:auto;touch-action:none;' + css;
839
+
840
+ if (paused && touchGamepadCenterButtonSize)
841
+ {
842
+ // while paused, any touch presses start
843
+ setZone(touchGamepadZoneC, 'inset:0');
844
+ for (const zone of touchGamepadSideZones) zone.style.display = 'none';
845
+ touchGamepadZoneC.style.display = '';
846
+ }
847
+ else
848
+ {
849
+ // position each side zone (left/right differ only by which edge they hug)
850
+ for (let side = 0; side < 2; side++)
804
851
  {
805
- // draw cross shaped gamepad
806
- for (let i=10; --i;)
852
+ const zone = touchGamepadSideZones[side], edge = side ? 'right' : 'left';
853
+ zone.style.display = touchGamepadSideHasControl(side) ? '' : 'none';
854
+ if (touchGamepadFloating)
807
855
  {
808
- const angle = i*PI/4;
809
- context.arc(stickCenter.x, stickCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8);
810
- i%2 && context.arc(stickCenter.x, stickCenter.y, touchGamepadSize*.33, angle, angle);
856
+ // bottom 60% grabs the control; the top 40% passes through. A side with no
857
+ // control on the other side uses the full width (matching the hit-test)
858
+ const width = touchGamepadSideHasControl(side ? 0 : 1) ? '50%' : '100%';
859
+ setZone(zone, `${edge}:0;bottom:0;width:${width};height:60%`);
811
860
  }
861
+ else // fixed: a compact box hugging the corner control
862
+ setZone(zone, `${edge}:0;bottom:0;width:${3*S}px;height:${3*S}px`);
863
+ }
864
+ touchGamepadZoneC.style.display = touchGamepadCenterButtonSize ? '' : 'none';
865
+ const c = touchGamepadCenterButtonSize;
866
+ setZone(touchGamepadZoneC,
867
+ `left:50%;top:50%;width:${2*c}px;height:${2*c}px;transform:translate(-50%,-50%)`);
868
+ }
869
+
870
+ touchGamepadBuildSvg(W, H);
871
+ touchGamepadNeedRelayout = false;
872
+ }
873
+
874
+ // (re)build the SVG shapes for the current layout; dynamic bits update per-frame
875
+ function touchGamepadBuildSvg(W, H)
876
+ {
877
+ const svg = touchGamepadSvg;
878
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
879
+ const els = touchGamepadSvgEls = { face: [], thumb: [] };
880
+ const S = touchGamepadSize;
881
+ const circle = (cx, cy, rr, fill)=>
882
+ {
883
+ const c = document.createElementNS(touchGamepadSvgNS, 'circle');
884
+ c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', rr);
885
+ if (fill) c.setAttribute('fill', fill);
886
+ svg.appendChild(c);
887
+ return c;
888
+ };
889
+ const cross = (ctr)=>
890
+ {
891
+ // plus-shaped dpad outline centered at ctr
892
+ const a = S*.18, b = S*.5, x = ctr.x, y = ctr.y;
893
+ const p = document.createElementNS(touchGamepadSvgNS, 'path');
894
+ p.setAttribute('d',
895
+ `M ${x-a} ${y-b} H ${x+a} V ${y-a} H ${x+b} V ${y+a} H ${x+a} ` +
896
+ `V ${y+b} H ${x-a} V ${y+a} H ${x-b} V ${y-a} H ${x-a} Z`);
897
+ svg.appendChild(p);
898
+ };
899
+
900
+ // draw each side: a directional stick, a single large button, or face buttons
901
+ for (let side = 0; side < 2; side++)
902
+ {
903
+ const count = touchGamepadSideButtonCount(side);
904
+ const base = touchGamepadSideButtonBase(side);
905
+ const ctr = touchGamepadSideCenter(side, W, H);
906
+ if (touchGamepadSideStick(side))
907
+ {
908
+ // directional stick (circle or cross) with a thumb dot that moves per-frame
909
+ if (touchGamepadAnalog) circle(ctr.x, ctr.y, S/2); else cross(ctr);
910
+ els.thumb[side] = circle(ctr.x, ctr.y, S/4, '#fff');
812
911
  }
813
- context.fill();
814
- context.stroke();
912
+ else if (count === 1)
913
+ els.face[base] = circle(ctr.x, ctr.y, S/2, '#000'); // single large button
914
+ else for (let i = 0; i < count; i++)
915
+ {
916
+ const j = mod(i-1, 4);
917
+ let button = count > 2 ? j : min(j, count-1);
918
+ button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
919
+ const offset = vec2().setDirection(j, S/2);
920
+ if (count === 2) offset.x *= -1;
921
+ // left side mirrors the right layout's positions, keeping indices in order
922
+ // (e.g. 2 buttons -> button 4 at bottom, button 5 at left)
923
+ if (!side) offset.x *= -1;
924
+ const pos = ctr.add(offset);
925
+ els.face[base + button] = circle(pos.x, pos.y, S/4, '#000');
926
+ }
927
+ }
928
+
929
+ // debug: draw the proximity hit regions the hit-test actually uses
930
+ if (debug && debugGamepads) touchGamepadBuildDebug(W, H);
931
+ }
815
932
 
816
- // draw right face buttons
933
+ // draw debug outlines of the touch control hit regions into the overlay svg
934
+ function touchGamepadBuildDebug(W, H)
935
+ {
936
+ const S = touchGamepadSize, svg = touchGamepadSvg;
937
+ const shape = (tag, attrs, stroke)=>
938
+ {
939
+ const el = document.createElementNS(touchGamepadSvgNS, tag);
940
+ for (const k in attrs) el.setAttribute(k, attrs[k]);
941
+ el.setAttribute('stroke', stroke);
942
+ el.setAttribute('stroke-width', 2);
943
+ el.setAttribute('fill', 'none');
944
+ svg.appendChild(el);
945
+ };
946
+ const ring = (c, rr, stroke)=> shape('circle', {cx:c.x, cy:c.y, r:rr}, stroke);
947
+
948
+ // green line: the left/right split that assigns a stick press to a side
949
+ shape('line', {x1:W/2, y1:0, x2:W/2, y2:H}, '#0f0');
950
+
951
+ // cyan: where each side's control can be grabbed
952
+ for (let side = 0; side < 2; side++)
953
+ {
954
+ if (touchGamepadSideStick(side))
817
955
  {
818
- const buttonCenter = touchGamepadButtonCenter();
819
- const buttonSize = touchGamepadButtonCount > 1 ?
820
- touchGamepadSize/4 : touchGamepadSize/2;
821
- for (let i=0; i<touchGamepadButtonCount; i++)
956
+ if (touchGamepadFloating)
822
957
  {
823
- const j = mod(i-1, 4);
824
- let button = touchGamepadButtonCount > 2 ?
825
- j : min(j, touchGamepadButtonCount-1);
826
- // fix button locations (swap 2 and 3 to match gamepad layout)
827
- button = button === 3 ? 2 : button === 2 ? 3 : button;
828
- const pos = touchGamepadButtonCount < 2 ? buttonCenter :
829
- buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
830
- context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
831
- context.beginPath();
832
- context.arc(pos.x, pos.y, buttonSize, 0,9);
833
- context.fill();
834
- context.stroke();
958
+ // grab region: this side's half (or the full width if the other side is empty)
959
+ const top = H*.4, full = !touchGamepadSideHasControl(side ? 0 : 1);
960
+ const x = full ? 0 : (side ? W/2 : 0);
961
+ shape('rect', {x, y:top, width:full ? W : W/2, height:H-top}, '#0ff');
835
962
  }
963
+ else
964
+ ring(touchGamepadSideCenter(side, W, H), 2*S, '#0ff');
965
+ }
966
+ else if (touchGamepadSideButtonCount(side) >= 1)
967
+ ring(touchGamepadSideCenter(side, W, H), S, '#0ff'); // face / single-button radius
968
+ }
969
+
970
+ // yellow: start button radius; magenta: where start is blocked (near a control)
971
+ if (touchGamepadCenterButtonSize)
972
+ {
973
+ ring(vec2(W/2, H/2), touchGamepadCenterButtonSize, '#ff0');
974
+ for (let side = 0; side < 2; side++)
975
+ if (touchGamepadSideHasControl(side))
976
+ ring(touchGamepadSideCenter(side, W, H), 2*S, '#f0f');
977
+ }
978
+ }
979
+
980
+ // per-frame: fade the overlay and move the thumbs / set pressed states
981
+ function touchGamepadRender()
982
+ {
983
+ if (!touchGamepadOverlay || headlessMode) return;
984
+
985
+ // hide and bail if disabled at runtime (overlay stays in the DOM for reuse)
986
+ // display:none also takes the input zones out of hit-testing so touches are
987
+ // not silently captured away from the game while disabled
988
+ if (!touchGamepadEnable || !isTouchDevice)
989
+ {
990
+ if (touchGamepadOverlay.style.display !== 'none')
991
+ {
992
+ // just disabled: hide the overlay and release any held controls
993
+ touchGamepadOverlay.style.display = 'none';
994
+ touchGamepadPointerRole.clear();
995
+ touchGamepadButtons.length = 0;
996
+ touchGamepadSticks.length = 0;
997
+ touchGamepadStickPointerId.length = 0;
998
+ }
999
+ return;
1000
+ }
1001
+ touchGamepadOverlay.style.display = '';
1002
+
1003
+ // relayout when the paused state, a layout setting, or the debug view changes
1004
+ const dbg = debug && debugGamepads;
1005
+ const layout = [touchGamepadButtonCount, touchGamepadLeftButtonCount, touchGamepadLeftStick,
1006
+ touchGamepadRightStick, touchGamepadAnalog, touchGamepadSize, touchGamepadFloating,
1007
+ touchGamepadCenterButtonSize, paused, dbg].join();
1008
+ if (layout !== touchGamepadLastLayout)
1009
+ {
1010
+ touchGamepadLastLayout = layout;
1011
+ touchGamepadNeedRelayout = true;
1012
+ }
1013
+ // relayout before the visibility bail-out so the paused full-screen start zone applies
1014
+ if (touchGamepadNeedRelayout) touchGamepadRelayout();
1015
+
1016
+ // fade out when idle (always show when displayTime is 0, or while debugging)
1017
+ const fade = touchGamepadDisplayTime ?
1018
+ percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
1019
+ const visible = dbg || (touchGamepadTimer.isSet() && fade > 0 && !paused);
1020
+ touchGamepadOverlay.style.opacity = !visible ? 0 : dbg ? 1 : fade*touchGamepadAlpha;
1021
+ if (!visible) return;
1022
+
1023
+ const r = touchGamepadStageRect();
1024
+ const W = r.width, H = r.height, S = touchGamepadSize;
1025
+ const els = touchGamepadSvgEls;
1026
+ if (!els) return;
1027
+
1028
+ for (let side = 0; side < 2; side++)
1029
+ if (touchGamepadSideStick(side) && els.thumb[side])
1030
+ {
1031
+ const ctr = touchGamepadSideCenter(side, W, H);
1032
+ const t = ctr.add((touchGamepadSticks[side] ?? vec2()).scale(S/2));
1033
+ els.thumb[side].setAttribute('cx', t.x);
1034
+ els.thumb[side].setAttribute('cy', t.y);
1035
+ }
1036
+ for (let i = 0; i < els.face.length; i++)
1037
+ if (els.face[i])
1038
+ els.face[i].setAttribute('fill', touchGamepadButtons[i] ? '#fff' : '#000');
1039
+ }
1040
+
1041
+ // convert a pointer event to stage-local CSS pixels
1042
+ function touchGamepadEventPos(e)
1043
+ {
1044
+ const r = touchGamepadStageRect();
1045
+ return vec2(e.clientX - r.left, e.clientY - r.top);
1046
+ }
1047
+
1048
+ // set a directional stick from a stage-local point and flag its stick-touch button
1049
+ // (stick 0 press = button 10, stick 1 press = button 11, following the output index)
1050
+ function touchGamepadApplyStick(side, p)
1051
+ {
1052
+ const delta = p.subtract(touchGamepadStickAnchors[side]);
1053
+ touchGamepadSticks[side] = delta.scale(2/touchGamepadSize).clampLength();
1054
+ touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10] = 1;
1055
+ }
1056
+
1057
+ // pick a side's gamepad button index from a stage-local point, or -1 if outside the cluster
1058
+ function touchGamepadFaceButtonAt(side, p, W, H)
1059
+ {
1060
+ const count = touchGamepadSideButtonCount(side);
1061
+ const base = touchGamepadSideButtonBase(side);
1062
+ const bc = touchGamepadSideCenter(side, W, H);
1063
+ if (bc.distance(p) >= touchGamepadSize) return -1;
1064
+ if (count === 1) return base; // single large button
1065
+ const d = bc.subtract(p);
1066
+ if (!side) d.x *= -1; // left side mirrors the right layout's positions horizontally
1067
+ let button = count === 2 ? (d.x < d.y ? 1 : 0) : mod(d.direction()+2, 4);
1068
+ button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
1069
+ return button < count ? base + button : -1;
1070
+ }
1071
+
1072
+ // pick which control a stage-local press activates, by priority then proximity,
1073
+ // independent of which zone element captured it - so overlapping zones on small
1074
+ // screens resolve to the nearest control instead of whichever zone is topmost
1075
+ // returns {role:'stick', side} or {role:'face', btn} or {role:'start'} or undefined
1076
+ function touchGamepadControlAt(p, W, H)
1077
+ {
1078
+ const S = touchGamepadSize;
1079
+ const leftHalf = p.x < W/2;
1080
+ const floatTop = H*.4; // floating grab region is the bottom 60% of the screen
1081
+
1082
+ // check each side (left first for priority); a side is a stick or buttons
1083
+ for (let side = 0; side < 2; side++)
1084
+ {
1085
+ const onHalf = side ? !leftHalf : leftHalf;
1086
+ if (touchGamepadSideStick(side))
1087
+ {
1088
+ // a side with no control on the other side uses the full width
1089
+ const otherControl = touchGamepadSideHasControl(side ? 0 : 1);
1090
+ const grab = touchGamepadFloating ?
1091
+ (!otherControl || onHalf) && p.y > floatTop :
1092
+ onHalf && touchGamepadSideCenter(side, W, H).distance(p) < 2*S;
1093
+ if (grab) return {role:'stick', side};
1094
+ }
1095
+ else if (touchGamepadSideButtonCount(side) >= 1)
1096
+ {
1097
+ const btn = touchGamepadFaceButtonAt(side, p, W, H);
1098
+ if (btn >= 0) return {role:'face', btn};
836
1099
  }
1100
+ }
837
1101
 
838
- // set canvas back to normal
839
- context.restore();
1102
+ // center start button, blocked within 2*size of a control so drift off a
1103
+ // control can't accidentally fire start (matches the original exclusion logic)
1104
+ if (touchGamepadCenterButtonSize)
1105
+ {
1106
+ for (let side = 0; side < 2; side++)
1107
+ if (touchGamepadSideHasControl(side) &&
1108
+ touchGamepadSideCenter(side, W, H).distance(p) < 2*S)
1109
+ return;
1110
+ if (vec2(W/2, H/2).distance(p) < touchGamepadCenterButtonSize)
1111
+ return {role:'start'};
840
1112
  }
841
1113
  }
842
1114
 
843
- // center position for right touch pad face buttons
844
- function touchGamepadButtonCenter()
1115
+ function touchGamepadPointerDown(e, zone)
845
1116
  {
846
- const center = mainCanvasSize.subtract(vec2(touchGamepadSize));
847
- if (touchGamepadButtonCount === 2)
848
- center.x += touchGamepadSize/2;
849
- return center;
1117
+ if (!touchGamepadEnable) return;
1118
+ e.preventDefault();
1119
+ zone.setPointerCapture(e.pointerId);
1120
+ touchGamepadTimer.set();
1121
+ isUsingGamepad = true;
1122
+
1123
+ // resume audio on first interaction
1124
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
1125
+ audioContext.resume();
1126
+
1127
+ // while paused, any touch is the start button
1128
+ if (paused)
1129
+ {
1130
+ if (touchGamepadCenterButtonSize)
1131
+ {
1132
+ touchGamepadButtons[9] = 1;
1133
+ touchGamepadPointerRole.set(e.pointerId, 'start');
1134
+ }
1135
+ return;
1136
+ }
1137
+
1138
+ const r = touchGamepadStageRect();
1139
+ const W = r.width, H = r.height;
1140
+ const p = vec2(e.clientX - r.left, e.clientY - r.top);
1141
+
1142
+ // choose the control by proximity/priority, not by which zone captured the touch
1143
+ const hit = touchGamepadControlAt(p, W, H);
1144
+ if (!hit) return;
1145
+ if (hit.role === 'stick')
1146
+ {
1147
+ const side = hit.side;
1148
+ touchGamepadStickAnchors[side] = touchGamepadFloating ? p : touchGamepadSideCenter(side, W, H);
1149
+ touchGamepadStickPointerId[side] = e.pointerId;
1150
+ touchGamepadPointerRole.set(e.pointerId, 'stick'+side);
1151
+ touchGamepadNeedRelayout = true; // base may have re-anchored
1152
+ touchGamepadApplyStick(side, p);
1153
+ }
1154
+ else if (hit.role === 'face')
1155
+ {
1156
+ touchGamepadButtons[hit.btn] = 1;
1157
+ touchGamepadPointerRole.set(e.pointerId, 'face'+hit.btn);
1158
+ }
1159
+ else // 'start'
1160
+ {
1161
+ touchGamepadButtons[9] = 1;
1162
+ touchGamepadPointerRole.set(e.pointerId, 'start');
1163
+ }
1164
+ }
1165
+
1166
+ function touchGamepadPointerMove(e)
1167
+ {
1168
+ const role = touchGamepadPointerRole.get(e.pointerId);
1169
+ if (!role) return;
1170
+ e.preventDefault();
1171
+ const p = touchGamepadEventPos(e);
1172
+ if (role === 'stick0' || role === 'stick1')
1173
+ touchGamepadApplyStick(role === 'stick1' ? 1 : 0, p);
1174
+ // face buttons & start are held until release (no slide-between this pass)
1175
+ }
1176
+
1177
+ function touchGamepadPointerUp(e)
1178
+ {
1179
+ const role = touchGamepadPointerRole.get(e.pointerId);
1180
+ if (!role) return;
1181
+ touchGamepadPointerRole.delete(e.pointerId);
1182
+ if (role === 'stick0' || role === 'stick1')
1183
+ {
1184
+ const side = role === 'stick1' ? 1 : 0;
1185
+ touchGamepadStickPointerId[side] = undefined;
1186
+ touchGamepadSticks[side] = vec2();
1187
+ delete touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10];
1188
+ }
1189
+ else if (role === 'start')
1190
+ delete touchGamepadButtons[9];
1191
+ else // 'face<n>'
1192
+ delete touchGamepadButtons[+role.slice(4)];
1193
+ touchGamepadTimer.set();
850
1194
  }