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.
- package/README.md +4 -4
- package/dist/littlejs.d.ts +277 -32
- package/dist/littlejs.esm.js +1395 -304
- package/dist/littlejs.esm.min.js +1 -1
- package/dist/littlejs.js +1367 -303
- package/dist/littlejs.min.js +1 -1
- package/dist/littlejs.release.js +1341 -287
- package/package.json +1 -1
- package/plugins/box2d.js +9 -13
- package/plugins/drawUtilities.js +84 -10
- package/plugins/lightSystem.js +330 -0
- package/plugins/medalSystem.js +36 -5
- package/plugins/pluginExport.js +8 -0
- package/plugins/uiSystem.js +35 -25
- package/src/engine.js +1 -1
- package/src/engineAudio.js +21 -3
- package/src/engineBuild.mjs +1 -0
- package/src/engineDebug.js +27 -16
- package/src/engineDraw.js +68 -10
- package/src/engineExport.js +20 -1
- package/src/engineInput.js +597 -191
- package/src/engineMath.js +15 -0
- package/src/engineObject.js +5 -2
- package/src/engineParticles.js +23 -19
- package/src/engineRelease.js +1 -0
- package/src/engineSettings.js +114 -10
- package/src/engineTileLayer.js +5 -0
package/src/engineInput.js
CHANGED
|
@@ -43,11 +43,30 @@ let mouseWheel = 0;
|
|
|
43
43
|
* @memberof Input */
|
|
44
44
|
let mouseInWindow = true;
|
|
45
45
|
|
|
46
|
-
/**
|
|
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()
|
|
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
|
-
//
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
if (
|
|
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
|
-
//
|
|
489
|
-
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
495
|
-
|
|
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] =
|
|
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
|
-
|
|
504
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
|
660
|
+
sticks.length = 0; // only report sticks that are enabled
|
|
657
661
|
dpad.set();
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
672
|
-
|
|
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
|
-
|
|
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
|
-
|
|
785
|
-
if (!
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
//
|
|
806
|
-
|
|
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
|
-
|
|
811
|
-
|
|
920
|
+
const zone = touchGamepadSideZones[side], edge = side ? 'right' : 'left';
|
|
921
|
+
zone.style.display = touchGamepadSideHasControl(side) ? '' : 'none';
|
|
922
|
+
if (touchGamepadFloating)
|
|
812
923
|
{
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
819
|
-
|
|
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
|
-
|
|
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
|
-
|
|
824
|
-
const buttonSize = touchGamepadButtonCount > 1 ?
|
|
825
|
-
touchGamepadSize/4 : touchGamepadSize/2;
|
|
826
|
-
for (let i=0; i<touchGamepadButtonCount; i++)
|
|
1024
|
+
if (touchGamepadFloating)
|
|
827
1025
|
{
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
844
|
-
|
|
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
|
-
//
|
|
849
|
-
|
|
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
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
}
|