littlejsengine 1.19.3 → 1.20.0

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.
Files changed (50) hide show
  1. package/FAQ.md +841 -820
  2. package/README.md +29 -25
  3. package/dist/littlejs.d.ts +2455 -935
  4. package/dist/littlejs.esm.js +30344 -21110
  5. package/dist/littlejs.esm.min.js +4 -3
  6. package/dist/littlejs.js +30259 -21052
  7. package/dist/littlejs.min.js +2 -2
  8. package/dist/littlejs.release.js +26567 -20455
  9. package/package.json +10 -5
  10. package/plugins/audioEffects.js +28 -24
  11. package/plugins/box2d.js +2516 -2073
  12. package/plugins/drawUtilities.js +206 -31
  13. package/plugins/gltf.js +740 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -332
  16. package/plugins/math3d.js +58 -17
  17. package/plugins/medalSystem.js +326 -280
  18. package/plugins/newgrounds.js +400 -199
  19. package/plugins/pathFinder.js +208 -175
  20. package/plugins/pluginExport.js +15 -0
  21. package/plugins/postProcess.js +56 -13
  22. package/plugins/render3d.js +1643 -1577
  23. package/plugins/render3dDebug.js +109 -0
  24. package/plugins/render3dExtras.js +1754 -0
  25. package/plugins/render3dVoxels.js +502 -0
  26. package/plugins/textureSheet.js +496 -458
  27. package/plugins/threejs.js +183 -163
  28. package/plugins/tweakables.js +411 -0
  29. package/plugins/tweenSystem.js +263 -145
  30. package/plugins/uiSystem.js +2024 -1687
  31. package/plugins/zzfxm.js +159 -159
  32. package/src/engine.js +191 -75
  33. package/src/engineAudio.js +229 -125
  34. package/src/engineBuild.mjs +289 -256
  35. package/src/engineDebug.js +912 -742
  36. package/src/engineDraw.js +295 -193
  37. package/src/engineEditor.js +2431 -0
  38. package/src/engineExport.js +12 -0
  39. package/src/engineInput.js +1462 -1293
  40. package/src/engineMath.js +1274 -1252
  41. package/src/engineObject.js +194 -80
  42. package/src/engineParticles.js +583 -532
  43. package/src/engineRelease.js +67 -45
  44. package/src/engineSettings.js +77 -29
  45. package/src/engineTileLayer.js +995 -741
  46. package/src/engineUtilities.js +19 -13
  47. package/src/engineWebGL.js +239 -82
  48. package/src/jsconfig.json +17 -13
  49. package/plugins/box2d.wasm.js +0 -630
  50. package/plugins/box2d.wasm.wasm +0 -0
@@ -1,1294 +1,1463 @@
1
- /**
2
- * LittleJS Input System
3
- * - Keyboard input with key down, pressed, and released states
4
- * - Mouse input with position (world and screen space), buttons, and wheel
5
- * - Gamepad support for multiple controllers with analog sticks and buttons
6
- * - Touch input mapped to mouse position and buttons
7
- * - Virtual on-screen gamepad for mobile devices
8
- * - Automatic gamepad vs keyboard/mouse detection
9
- * - Input event prevention for canvas focus
10
- * - Clipboard copy/paste support
11
- * @namespace Input
12
- */
13
-
14
- 'use strict';
15
-
16
- /** Mouse pos in world space
17
- * @type {Vector2}
18
- * @memberof Input */
19
- let mousePos = vec2();
20
-
21
- /** Mouse pos in screen space
22
- * @type {Vector2}
23
- * @memberof Input */
24
- let mousePosScreen = vec2();
25
-
26
- /** Mouse movement delta in world space
27
- * @type {Vector2}
28
- * @memberof Input */
29
- let mouseDelta = vec2();
30
-
31
- /** Mouse movement delta in screen space
32
- * @type {Vector2}
33
- * @memberof Input */
34
- let mouseDeltaScreen = vec2();
35
-
36
- /** Mouse wheel delta this frame
37
- * @type {number}
38
- * @memberof Input */
39
- let mouseWheel = 0;
40
-
41
- /** True if mouse was inside the document window, set to false when mouse leaves
42
- * @type {boolean}
43
- * @memberof Input */
44
- let mouseInWindow = true;
45
-
46
- /** True if a gamepad is the most recently used input device.
47
- * Equivalent to usingGamepadInput(); derived from lastInputDevice each frame.
48
- * @type {boolean}
49
- * @memberof Input */
50
- let isUsingGamepad = false;
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
-
70
- /** Prevents input continuing to the default browser handling (true by default)
71
- * @type {boolean}
72
- * @memberof Input */
73
- let inputPreventDefault = true;
74
-
75
- /** Primary gamepad index, automatically set to first gamepad with input
76
- * @type {number}
77
- * @memberof Input */
78
- let gamepadPrimary = 0;
79
-
80
- /** True if a touch device has been detected
81
- * @memberof Input */
82
- const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
83
-
84
- /** Prevents input continuing to the default browser handling
85
- * This is useful to disable for html menus so the browser can handle input normally
86
- * @param {boolean} preventDefault
87
- * @memberof Input */
88
- function setInputPreventDefault(preventDefault=true) { inputPreventDefault = preventDefault; }
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
-
102
- /** Clears an input key state
103
- * @param {string|number} key
104
- * @param {number} [device]
105
- * @param {boolean} [clearDown=true]
106
- * @param {boolean} [clearPressed=true]
107
- * @param {boolean} [clearReleased=true]
108
- * @memberof Input */
109
- function inputClearKey(key, device=0, clearDown=true, clearPressed=true, clearReleased=true)
110
- {
111
- if (!inputData[device])
112
- return;
113
- inputData[device][key] &= ~((clearDown?1:0)|(clearPressed?2:0)|(clearReleased?4:0));
114
- }
115
-
116
- /** Clears all input
117
- * @memberof Input */
118
- function inputClear()
119
- {
120
- inputData.length = 0;
121
- inputData[0] = [];
122
- touchGamepadButtons.length = 0;
123
- touchGamepadSticks.length = 0;
124
- touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor
125
- gamepadStickData.length = 0;
126
- gamepadDpadData.length = 0;
127
- gamepadAxisCentered.length = 0;
128
- }
129
-
130
- ///////////////////////////////////////////////////////////////////////////////
131
-
132
- /** Returns true if device key is down
133
- * @param {string|number} key
134
- * @param {number} [device]
135
- * @return {boolean}
136
- * @memberof Input */
137
- function keyIsDown(key, device=0)
138
- {
139
- ASSERT(isStringLike(key), 'key must be a number or string');
140
- ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
141
- return !!(inputData[device]?.[key] & 1);
142
- }
143
-
144
- /** Returns true if device key was pressed this frame
145
- * @param {string|number} key
146
- * @param {number} [device]
147
- * @return {boolean}
148
- * @memberof Input */
149
- function keyWasPressed(key, device=0)
150
- {
151
- ASSERT(isStringLike(key), 'key must be a number or string');
152
- ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
153
- return !!(inputData[device]?.[key] & 2);
154
- }
155
-
156
- /** Returns true if device key was released this frame
157
- * @param {string|number} key
158
- * @param {number} [device]
159
- * @return {boolean}
160
- * @memberof Input */
161
- function keyWasReleased(key, device=0)
162
- {
163
- ASSERT(isStringLike(key), 'key must be a number or string');
164
- ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
165
- return !!(inputData[device]?.[key] & 4);
166
- }
167
-
168
- /** Returns input vector from arrow keys or WASD if enabled
169
- * @param {string} [up]
170
- * @param {string} [down]
171
- * @param {string} [left]
172
- * @param {string} [right]
173
- * @return {Vector2}
174
- * @memberof Input */
175
- function keyDirection(up='ArrowUp', down='ArrowDown', left='ArrowLeft', right='ArrowRight')
176
- {
177
- ASSERT(isStringLike(up), 'up key must be a string');
178
- ASSERT(isStringLike(down), 'down key must be a string');
179
- ASSERT(isStringLike(left), 'left key must be a string');
180
- ASSERT(isStringLike(right), 'right key must be a string');
181
- const k = (key)=> keyIsDown(key) ? 1 : 0;
182
- return vec2(k(right) - k(left), k(up) - k(down));
183
- }
184
-
185
- /** Returns true if mouse button is down
186
- * @function
187
- * @param {number} button
188
- * @return {boolean}
189
- * @memberof Input */
190
- function mouseIsDown(button)
191
- {
192
- ASSERT(isNumber(button), 'mouse button must be a number');
193
- return keyIsDown(button);
194
- }
195
-
196
- /** Returns true if mouse button was pressed
197
- * @function
198
- * @param {number} button
199
- * @return {boolean}
200
- * @memberof Input */
201
- function mouseWasPressed(button)
202
- {
203
- ASSERT(isNumber(button), 'mouse button must be a number');
204
- return keyWasPressed(button);
205
- }
206
-
207
- /** Returns true if mouse button was released
208
- * @function
209
- * @param {number} button
210
- * @return {boolean}
211
- * @memberof Input */
212
- function mouseWasReleased(button)
213
- {
214
- ASSERT(isNumber(button), 'mouse button must be a number');
215
- return keyWasReleased(button);
216
- }
217
-
218
- /** Returns true if gamepad button is down
219
- * @param {number} button
220
- * @param {number} [gamepad]
221
- * @return {boolean}
222
- * @memberof Input */
223
- function gamepadIsDown(button, gamepad=gamepadPrimary)
224
- {
225
- ASSERT(isNumber(button), 'button must be a number');
226
- ASSERT(isNumber(gamepad), 'gamepad must be a number');
227
- return keyIsDown(button, gamepad+1);
228
- }
229
-
230
- /** Returns true if gamepad button was pressed
231
- * @param {number} button
232
- * @param {number} [gamepad]
233
- * @return {boolean}
234
- * @memberof Input */
235
- function gamepadWasPressed(button, gamepad=gamepadPrimary)
236
- {
237
- ASSERT(isNumber(button), 'button must be a number');
238
- ASSERT(isNumber(gamepad), 'gamepad must be a number');
239
- return keyWasPressed(button, gamepad+1);
240
- }
241
-
242
- /** Returns true if gamepad button was released
243
- * @param {number} button
244
- * @param {number} [gamepad]
245
- * @return {boolean}
246
- * @memberof Input */
247
- function gamepadWasReleased(button, gamepad=gamepadPrimary)
248
- {
249
- ASSERT(isNumber(button), 'button must be a number');
250
- ASSERT(isNumber(gamepad), 'gamepad must be a number');
251
- return keyWasReleased(button, gamepad+1);
252
- }
253
-
254
- /** Returns gamepad stick value
255
- * @param {number} stick
256
- * @param {number} [gamepad]
257
- * @return {Vector2}
258
- * @memberof Input */
259
- function gamepadStick(stick, gamepad=gamepadPrimary)
260
- {
261
- ASSERT(isNumber(stick), 'stick must be a number');
262
- ASSERT(isNumber(gamepad), 'gamepad must be a number');
263
- return gamepadStickData[gamepad]?.[stick] ?? vec2();
264
- }
265
-
266
- /** Returns gamepad dpad value
267
- * @param {number} [gamepad]
268
- * @return {Vector2}
269
- * @memberof Input */
270
- function gamepadDpad(gamepad=gamepadPrimary)
271
- {
272
- ASSERT(isNumber(gamepad), 'gamepad must be a number');
273
- return gamepadDpadData[gamepad] ?? vec2();
274
- }
275
-
276
- /** Returns true if passed in gamepad is connected
277
- * @param {number} [gamepad]
278
- * @return {boolean}
279
- * @memberof Input */
280
- function gamepadConnected(gamepad=gamepadPrimary)
281
- {
282
- ASSERT(isNumber(gamepad), 'gamepad must be a number');
283
- return !!inputData[gamepad+1];
284
- }
285
-
286
- /** Returns how many control sticks the passed in gamepad has
287
- * @param {number} [gamepad]
288
- * @return {number}
289
- * @memberof Input */
290
- function gamepadStickCount(gamepad=gamepadPrimary)
291
- {
292
- ASSERT(isNumber(gamepad), 'gamepad must be a number');
293
- return gamepadStickData[gamepad]?.length ?? 0;
294
- }
295
-
296
- /** Pulse a gamepad's vibration hardware using the dual-rumble effect if it exists
297
- * Strong magnitude is usually the left side motor, weak magnitude is usually the right side motor
298
- * @param {number} [gamepad] - gamepad index
299
- * @param {number} [duration] - effect duration in ms
300
- * @param {number} [strongMagnitude] - strong (left) motor intensity, 0 to 1
301
- * @param {number} [weakMagnitude] - weak (right) motor intensity, 0 to 1
302
- * @param {number} [startDelay] - delay in ms before the effect starts
303
- * @memberof Input */
304
- function gamepadVibrate(gamepad=gamepadPrimary, duration=200, strongMagnitude=1, weakMagnitude=1, startDelay=0)
305
- {
306
- ASSERT(isNumber(gamepad), 'gamepad must be a number');
307
- if (!vibrateEnable || headlessMode) return;
308
- const pad = navigator?.getGamepads?.()[gamepad];
309
- pad?.vibrationActuator?.playEffect?.('dual-rumble', {duration, strongMagnitude, weakMagnitude, startDelay});
310
- }
311
-
312
- /** Stop vibration on a gamepad
313
- * @memberof Input */
314
- function gamepadVibrateStop(gamepad=gamepadPrimary)
315
- {
316
- ASSERT(isNumber(gamepad), 'gamepad must be a number');
317
- if (!vibrateEnable || headlessMode) return;
318
- const pad = navigator?.getGamepads?.()[gamepad];
319
- pad?.vibrationActuator?.reset?.();
320
- }
321
-
322
- ///////////////////////////////////////////////////////////////////////////////
323
-
324
- /** Pulse the vibration hardware if it exists
325
- * @param {number|Array} [pattern] - single value in ms or vibration interval array
326
- * @memberof Input */
327
- function vibrate(pattern=100)
328
- {
329
- ASSERT(isNumber(pattern) || isArray(pattern), 'pattern must be a number or array');
330
- vibrateEnable && !headlessMode && navigator?.vibrate?.(pattern);
331
- }
332
-
333
- /** Cancel any ongoing vibration
334
- * @memberof Input */
335
- function vibrateStop() { vibrate(0); }
336
-
337
- ///////////////////////////////////////////////////////////////////////////////
338
- // Pointer Lock
339
-
340
- /** Request to lock the pointer, does not work on touch devices
341
- * @memberof Input */
342
- function pointerLockRequest()
343
- { !isTouchDevice && mainCanvas.requestPointerLock?.(); }
344
-
345
- /** Request to unlock the pointer
346
- * @memberof Input */
347
- function pointerLockExit()
348
- { document.exitPointerLock?.(); }
349
-
350
- /** Check if pointer is locked (true if locked)
351
- * @return {boolean}
352
- * @memberof Input */
353
- function pointerLockIsActive()
354
- { return document.pointerLockElement === mainCanvas; }
355
-
356
- ///////////////////////////////////////////////////////////////////////////////
357
- // Input variables used by engine
358
-
359
- // input uses bit field for each key: 1=isDown, 2=wasPressed, 4=wasReleased
360
- // mouse and keyboard stored in device 0, gamepads stored in devices > 0
361
- const inputData = [[]];
362
-
363
- // gamepad internal variables
364
- const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
365
- // per gamepad, how many consecutive frames each axis has rested inside the
366
- // dead zone, used to tell stick axes from axes that rest at full deflection
367
- const gamepadAxisCentered = [];
368
- // how long an axis must rest inside the dead zone before it counts as a stick
369
- const gamepadAxisCenteredFrames = 15;
370
-
371
- // touch gamepad internal variables
372
- const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
373
- // floating stick anchors (stage-local CSS pixels) and owning pointer ids, indexed by stick (0=left, 1=right)
374
- const touchGamepadStickAnchors = [], touchGamepadStickPointerId = [];
375
- // pointerId -> control role ('stick0', 'stick1', 'face<n>', or 'start')
376
- const touchGamepadPointerRole = new Map();
377
- // overlay DOM elements (created lazily on touch devices) and cached SVG shapes
378
- let touchGamepadOverlay, touchGamepadStage, touchGamepadSvg, touchGamepadSvgEls;
379
- let touchGamepadSideZones = [], touchGamepadZoneC;
380
- let touchGamepadNeedRelayout = true, touchGamepadLastLayout;
381
-
382
- ///////////////////////////////////////////////////////////////////////////////
383
- // Input system functions used by engine
384
-
385
- function inputInit()
386
- {
387
- if (headlessMode) return;
388
-
389
- // add event listeners
390
- document.addEventListener('keydown', onKeyDown);
391
- document.addEventListener('keyup', onKeyUp);
392
- document.addEventListener('mousedown', onMouseDown);
393
- document.addEventListener('mouseup', onMouseUp);
394
- document.addEventListener('mousemove', onMouseMove);
395
- document.addEventListener('mouseleave', onMouseLeave);
396
- document.addEventListener('wheel', onMouseWheel, { passive: false });
397
- document.addEventListener('contextmenu', onContextMenu);
398
- document.addEventListener('blur', onBlur);
399
-
400
- // init touch input
401
- if (isTouchDevice && touchInputEnable)
402
- touchInputInit();
403
-
404
- function onKeyDown(e)
405
- {
406
- if (!e.repeat)
407
- {
408
- inputData[0][e.code] = 3;
409
- if (inputWASDEmulateDirection)
410
- inputData[0][remapKey(e.code)] = 3;
411
- }
412
-
413
- // try to prevent default browser handling of input
414
- if (!inputPreventDefault || !e.cancelable || !document.hasFocus()) return;
415
-
416
- // don't break browser shortcuts
417
- if (e.ctrlKey || e.metaKey || e.altKey) return;
418
-
419
- // don't interfere with user typing into UI fields
420
- if (isTextInput(e.target) || isTextInput(document.activeElement)) return;
421
-
422
- // fix browser setting "Search for text when you start typing"
423
- const printable = typeof e.key === 'string' && e.key.length === 1;
424
-
425
- // prevent arrow key and other default keys from messing with stuff
426
- const preventDefaultKeys =
427
- [
428
- 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', // scrolling
429
- 'Space', // page down scroll
430
- 'Tab', // focus navigation
431
- 'Backspace', // browser back
432
- ];
433
- if (preventDefaultKeys.includes(e.code) || printable)
434
- e.preventDefault();
435
-
436
- function isTextInput(element)
437
- {
438
- const tag = element?.tagName;
439
- const editable = element?.isContentEditable;
440
- return editable || ['INPUT','TEXTAREA','SELECT'].includes(tag);
441
- }
442
- }
443
- function onKeyUp(e)
444
- {
445
- inputData[0][e.code] = (inputData[0][e.code]&2) | 4;
446
- if (inputWASDEmulateDirection)
447
- {
448
- const remap = remapKey(e.code);
449
- inputData[0][remap] = (inputData[0][remap]&2) | 4;
450
- }
451
- }
452
- function remapKey(k)
453
- {
454
- // handle remapping wasd keys to directions
455
- return inputWASDEmulateDirection ?
456
- k === 'KeyW' ? 'ArrowUp' :
457
- k === 'KeyS' ? 'ArrowDown' :
458
- k === 'KeyA' ? 'ArrowLeft' :
459
- k === 'KeyD' ? 'ArrowRight' : k : k;
460
- }
461
- function onMouseDown(e)
462
- {
463
- if (isTouchDevice && touchInputEnable) return;
464
-
465
- // fix stalled audio requiring user interaction
466
- if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
467
- audioContext.resume();
468
-
469
- inputData[0][e.button] = 3;
470
-
471
- const mousePosScreenLast = mousePosScreen;
472
- mousePosScreen = mouseEventToScreen(vec2(e.x,e.y));
473
- mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
474
-
475
- if (inputPreventDefault && e.cancelable && document.hasFocus())
476
- e.preventDefault();
477
- }
478
- function onMouseUp(e)
479
- {
480
- if (isTouchDevice && touchInputEnable) return;
481
-
482
- inputData[0][e.button] = (inputData[0][e.button]&2) | 4;
483
- }
484
- function onMouseMove(e)
485
- {
486
- mouseInWindow = true;
487
- const mousePosScreenLast = mousePosScreen;
488
- mousePosScreen = mouseEventToScreen(vec2(e.x,e.y));
489
-
490
- // when pointer is locked use movementX/Y for delta
491
- const movement = pointerLockIsActive() ?
492
- vec2(e.movementX, e.movementY) :
493
- mousePosScreen.subtract(mousePosScreenLast);
494
- mouseDeltaScreen = mouseDeltaScreen.add(movement);
495
- }
496
- function onMouseLeave() { mouseInWindow = false; } // mouse moved off window
497
- function onMouseWheel(e)
498
- {
499
- // accumulate so multiple wheel events in one frame are not lost
500
- if (!e.ctrlKey)
501
- mouseWheel += sign(e.deltaY);
502
- if (inputPreventDefault && e.cancelable && document.hasFocus())
503
- e.preventDefault(); // prevent page scrolling
504
- }
505
- function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
506
- function onBlur()
507
- {
508
- inputClear();
509
- // release any held virtual gamepad controls so they don't stick
510
- touchGamepadPointerRole.clear();
511
- touchGamepadButtons.length = 0;
512
- touchGamepadSticks.length = 0;
513
- touchGamepadStickPointerId.length = 0;
514
- }
515
-
516
- // enable touch input mouse passthrough
517
- function touchInputInit()
518
- {
519
- // add non passive touch event listeners
520
- document.addEventListener('touchstart', (e)=> handleTouch(e), { passive: false });
521
- document.addEventListener('touchmove', (e)=> handleTouch(e), { passive: false });
522
- document.addEventListener('touchend', (e)=> handleTouch(e), { passive: false });
523
-
524
- // handle all touch events the same way
525
- let wasTouching, touchIdentifier;
526
- function handleTouch(e)
527
- {
528
- if (!touchInputEnable) return;
529
-
530
- // fix stalled audio requiring user interaction
531
- if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
532
- audioContext.resume();
533
-
534
- // when the touch gamepad is enabled it owns touch input: suppress the
535
- // touch->mouse passthrough entirely unless touchGamepadPassthrough is set
536
- // (its own zones drive gameplay via pointer events)
537
- if (!touchGamepadEnable || touchGamepadPassthrough)
538
- {
539
- // touches that landed on a virtual gamepad zone are owned by the gamepad
540
- // (handled by its own pointer listeners) and must not drive the game mouse
541
- const isGamepadTouch = (t)=>
542
- touchGamepadSideZones.includes(t.target) || t.target === touchGamepadZoneC;
543
- const gameTouches = [];
544
- for (const t of e.touches)
545
- if (!isGamepadTouch(t)) gameTouches.push(t);
546
-
547
- // check if touching and pass to mouse events
548
- const touching = gameTouches.length;
549
- const button = 0; // all touches are left mouse button
550
- if (touching)
551
- {
552
- // set event pos and pass it along
553
- const pos = vec2(gameTouches[0].clientX, gameTouches[0].clientY);
554
- const mousePosScreenLast = mousePosScreen;
555
- mousePosScreen = mouseEventToScreen(pos);
556
- if (wasTouching && gameTouches[0].identifier === touchIdentifier)
557
- mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
558
- else if (!wasTouching)
559
- inputData[0][button] = 3;
560
- touchIdentifier = gameTouches[0].identifier;
561
- }
562
- else if (wasTouching)
563
- inputData[0][button] = inputData[0][button] & 2 | 4;
564
-
565
- // set was touching
566
- wasTouching = touching;
567
- }
568
-
569
- // prevent default handling like copy, magnifier lens, and scrolling
570
- if (inputPreventDefault && e.cancelable && document.hasFocus())
571
- e.preventDefault();
572
-
573
- // must return true so the document will get focus
574
- return true;
575
- }
576
-
577
- }
578
-
579
- // convert a mouse or touch event position to screen space
580
- function mouseEventToScreen(mousePos)
581
- {
582
- const rect = mainCanvas.getBoundingClientRect();
583
- const px = percent(mousePos.x, rect.left, rect.right);
584
- const py = percent(mousePos.y, rect.top, rect.bottom);
585
- return vec2(px*mainCanvasSize.x, py*mainCanvasSize.y);
586
- }
587
- }
588
-
589
- function inputUpdate()
590
- {
591
- if (headlessMode) return;
592
-
593
- // clear input when lost focus (prevent stuck keys)
594
- if (!(touchInputEnable && isTouchDevice) && !document.hasFocus())
595
- inputClear();
596
-
597
- // update mouse world space position and delta
598
- mousePos = screenToWorld(mousePosScreen);
599
- mouseDelta = screenToWorldDelta(mouseDeltaScreen);
600
-
601
- // build the touch gamepad overlay lazily once enabled on a touch device
602
- touchGamepadInit();
603
-
604
- // update gamepads if enabled
605
- gamepadsUpdate();
606
-
607
- // update most recently used input device
608
- updateLastInputDevice();
609
-
610
- function updateLastInputDevice()
611
- {
612
- // mouse: any button held or moved
613
- const mouseActive = mouseIsDown(0) || mouseIsDown(1) || mouseIsDown(2) || mouseDeltaScreen.length() > inputMouseMoveThreshold;
614
-
615
- // gamepad: any button held or stick moved
616
- let gamepadActive = false;
617
- for (let s = gamepadStickCount(); s-- && !gamepadActive;)
618
- gamepadActive = gamepadStick(s).lengthSquared() > .2;
619
- for (let b = 17; b-- && !gamepadActive;)
620
- gamepadActive = gamepadIsDown(b);
621
-
622
- // keyboard: any non-mouse key down
623
- let keyboardActive = false;
624
- for (const k in inputData[0])
625
- if (isNaN(+k) && (inputData[0][k] & 1))
626
- {
627
- keyboardActive = true;
628
- break;
629
- }
630
-
631
- // update the last input
632
- if (gamepadActive)
633
- lastInputDevice = 'gamepad';
634
- else if (mouseActive)
635
- lastInputDevice = 'mouse';
636
- else if (keyboardActive)
637
- lastInputDevice = 'keyboard';
638
-
639
- // set flag if gamepad is last device
640
- isUsingGamepad = lastInputDevice === 'gamepad';
641
- }
642
-
643
- // gamepads are updated by engine every frame automatically
644
- function gamepadsUpdate()
645
- {
646
- const deadZoneMin=.3, deadZoneMax=.8;
647
- const applyDeadZones = (v)=>
648
- {
649
- const deadZone = (v)=>
650
- v > deadZoneMin ? percent(v, deadZoneMin, deadZoneMax) :
651
- v < -deadZoneMin ? -percent(-v, deadZoneMin, deadZoneMax) : 0;
652
- return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
653
- };
654
-
655
- // update touch gamepad if enabled
656
- if (touchGamepadEnable && isTouchDevice)
657
- {
658
- // a side is either a stick or buttons - setting both is ambiguous
659
- ASSERT(!touchGamepadLeftStick || !touchGamepadLeftButtonCount,
660
- 'set touchGamepadLeftStick or touchGamepadLeftButtonCount, not both');
661
- ASSERT(!touchGamepadRightStick || !touchGamepadButtonCount,
662
- 'set touchGamepadRightStick or touchGamepadButtonCount, not both');
663
-
664
- if (!touchGamepadTimer.isSet()) return;
665
-
666
- // read virtual analog stick
667
- gamepadPrimary = 0; // touch gamepad uses index 0
668
- const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
669
- const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
670
- sticks.length = 0; // only report sticks that are enabled
671
- dpad.set();
672
- // read each side's directional stick (analog, or quantized to an 8 way dpad)
673
- for (let side = 0; side < 2; side++)
674
- {
675
- if (!touchGamepadSideStick(side)) continue;
676
- const out = touchGamepadStickOut(side);
677
- sticks[out] = vec2();
678
- const touchStick = touchGamepadSticks[side] ?? vec2();
679
- if (touchGamepadAnalog)
680
- sticks[out] = applyDeadZones(touchStick);
681
- else if (touchStick.lengthSquared() > .3)
682
- {
683
- const x = clamp(round(touchStick.x), -1, 1);
684
- const y = clamp(round(touchStick.y), -1, 1);
685
- sticks[out] = vec2(x, -y).clampLength(); // clamp to circle
686
- if (!out) dpad.set(x, -y); // the primary (stick 0) also drives the dpad vector
687
- }
688
- }
689
-
690
- // read virtual gamepad buttons
691
- const data = inputData[1] ?? (inputData[1] = []);
692
- for (let i=12; i--;)
693
- {
694
- const wasDown = gamepadIsDown(i,0);
695
- data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
696
-
697
- // haptic tap when a face button or start button is first pressed (3 = newly down)
698
- // skip stick touches (10, 11) so movement doesn't buzz
699
- if (touchGamepadVibration && data[i] === 3 &&
700
- (i === 9 || touchGamepadIsFaceButton(i)))
701
- vibrate(touchGamepadVibration);
702
- }
703
-
704
- // disable normal gamepads when touch gamepad is active
705
- return;
706
- }
707
-
708
- // return if gamepads are disabled or not supported
709
- try {
710
- // protect against getGamepads disallowed security error
711
- if (!gamepadsEnable || !navigator?.getGamepads)
712
- return;
713
- } catch(e) {
714
- return;
715
- }
716
-
717
- // only poll gamepads when focused or in debug mode
718
- if (!debug && !document.hasFocus()) return;
719
-
720
- // poll gamepads
721
- const maxGamepads = 8;
722
- const gamepads = navigator.getGamepads();
723
- const gamepadCount = min(maxGamepads, gamepads.length);
724
- for (let i=0; i<gamepadCount; ++i)
725
- {
726
- // get or create gamepad data
727
- const gamepad = gamepads[i];
728
- if (!gamepad)
729
- {
730
- // clear gamepad data if not connected
731
- inputData[i+1] = undefined;
732
- gamepadStickData[i] = undefined;
733
- gamepadDpadData[i] = undefined;
734
- gamepadHadInput[i] = undefined;
735
- gamepadAxisCentered[i] = undefined;
736
- continue;
737
- }
738
-
739
- const data = inputData[i+1] ?? (inputData[i+1] = []);
740
- const sticks = gamepadStickData[i] ?? (gamepadStickData[i] = []);
741
- const dpad = gamepadDpadData[i] ?? (gamepadDpadData[i] = vec2());
742
-
743
- // read analog sticks
744
- // gamepads without standard mapping (steering wheels, flight sticks)
745
- // can report axes that rest at full deflection instead of center,
746
- // which would otherwise read as a stick held down forever, so only
747
- // trust an axis once it has rested inside the dead zone for a moment
748
- const isStandard = gamepad.mapping === 'standard';
749
- const centered = gamepadAxisCentered[i] ?? (gamepadAxisCentered[i] = []);
750
- const readAxis = (j)=>
751
- {
752
- const v = gamepad.axes[j];
753
- if (isStandard && j < 4)
754
- return v; // spec guarantees axes 0-3 are the two sticks
755
- if (!gamepadAxisFilterEnable)
756
- return v;
757
-
758
- // once an axis has proven it rests at center it stays trusted,
759
- // otherwise moving it would immediately disqualify it again
760
- const frames = centered[j] | 0;
761
- if (frames > gamepadAxisCenteredFrames)
762
- return v;
763
- centered[j] = abs(v) < deadZoneMin ? frames + 1 : 0;
764
- return 0;
765
- };
766
- for (let j = 0; j < gamepad.axes.length-1; j+=2)
767
- sticks[j>>1] = applyDeadZones(vec2(readAxis(j), readAxis(j+1)));
768
-
769
- // read buttons
770
- let hadInput = false;
771
- for (let j = gamepad.buttons.length; j--;)
772
- {
773
- const button = gamepad.buttons[j];
774
- const wasDown = gamepadIsDown(j,i);
775
- data[j] = button.pressed ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
776
-
777
- // check for any input on this gamepad, analog must be full press
778
- if (button.pressed && (!button.value || button.value > .9))
779
- hadInput = true;
780
- }
781
-
782
- // set new primary gamepad if current is not connected
783
- if (hadInput)
784
- {
785
- gamepadHadInput[i] = true;
786
- if (!gamepadHadInput[gamepadPrimary])
787
- gamepadPrimary = i;
788
- }
789
-
790
- if (gamepad.mapping === 'standard')
791
- {
792
- // get dpad buttons (standard mapping)
793
- dpad.set(
794
- (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
795
- (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
796
- }
797
-
798
- // copy dpad to left analog stick when pressed
799
- if (gamepadDirectionEmulateStick && (dpad.x || dpad.y))
800
- sticks[0] = dpad.clampLength();
801
- }
802
-
803
- // disable touch gamepad if using real gamepad
804
- touchGamepadEnable && isUsingGamepad && touchGamepadTimer.unset();
805
- }
806
- }
807
-
808
- function inputUpdatePost()
809
- {
810
- if (headlessMode) return;
811
-
812
- // clear input to prepare for next frame
813
- for (const deviceInputData of inputData)
814
- for (const i in deviceInputData)
815
- deviceInputData[i] &= 1;
816
- mouseWheel = 0;
817
- mouseDelta = vec2();
818
- mouseDeltaScreen = vec2();
819
- }
820
-
821
- function inputRender()
822
- {
823
- touchGamepadRender();
824
- }
825
-
826
- ///////////////////////////////////////////////////////////////////////////////
827
- // Touch gamepad - full-viewport HTML/SVG overlay driven by Pointer Events
828
-
829
- const touchGamepadSvgNS = 'http://www.w3.org/2000/svg';
830
-
831
- // build the overlay DOM once; no-op if already built, disabled, headless, or non-touch
832
- function touchGamepadInit()
833
- {
834
- if (touchGamepadOverlay || !touchGamepadEnable || !isTouchDevice || headlessMode ||
835
- !document.body) // body may not exist yet; retry on a later frame
836
- return;
837
-
838
- // full-viewport overlay; only the input zones receive pointer events. The
839
- // env() padding insets the stage out of notches / home indicators natively.
840
- const overlay = touchGamepadOverlay = document.createElement('div');
841
- overlay.style.cssText =
842
- 'position:fixed;inset:0;z-index:50;pointer-events:none;opacity:0;' +
843
- 'touch-action:none;user-select:none;-webkit-user-select:none;' +
844
- '-webkit-touch-callout:none;transition:opacity .2s;box-sizing:border-box;' +
845
- 'padding:env(safe-area-inset-top) env(safe-area-inset-right) ' +
846
- 'env(safe-area-inset-bottom) env(safe-area-inset-left)';
847
-
848
- // stage fills the padded (safe-area) content box; all controls live inside it
849
- const stage = touchGamepadStage = document.createElement('div');
850
- stage.style.cssText = 'position:relative;width:100%;height:100%;pointer-events:none';
851
- overlay.appendChild(stage);
852
-
853
- // svg draws every visual and never blocks input
854
- const svg = touchGamepadSvg = document.createElementNS(touchGamepadSvgNS, 'svg');
855
- svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;' +
856
- 'pointer-events:none;overflow:visible;fill:none;stroke:#fff;stroke-width:3';
857
- stage.appendChild(svg);
858
-
859
- // invisible input zones (left stick, right buttons/stick, center start)
860
- const makeZone = ()=>
861
- {
862
- const z = document.createElement('div');
863
- z.style.cssText = 'position:absolute;pointer-events:auto;touch-action:none';
864
- z.addEventListener('pointerdown', e=> touchGamepadPointerDown(e, z));
865
- z.addEventListener('pointermove', e=> touchGamepadPointerMove(e));
866
- z.addEventListener('pointerup', e=> touchGamepadPointerUp(e));
867
- z.addEventListener('pointercancel', e=> touchGamepadPointerUp(e));
868
- stage.appendChild(z);
869
- return z;
870
- };
871
- touchGamepadSideZones[0] = makeZone(); // left
872
- touchGamepadSideZones[1] = makeZone(); // right
873
- touchGamepadZoneC = makeZone(); // center/start, appended last so it sits above the sides
874
-
875
- addEventListener('resize', ()=> touchGamepadNeedRelayout = true);
876
- document.body.appendChild(overlay);
877
- touchGamepadNeedRelayout = true;
878
- }
879
-
880
- // stage-local size in CSS pixels (excludes safe-area insets)
881
- function touchGamepadStageRect() { return touchGamepadStage.getBoundingClientRect(); }
882
-
883
- // per-side touch gamepad config (side 0 = left, 1 = right) - the left and right
884
- // sides behave identically, differing only in position and gamepad button indices
885
- function touchGamepadSideStick(side)
886
- { return side ? touchGamepadRightStick : touchGamepadLeftStick; }
887
- function touchGamepadSideButtonCount(side)
888
- { return side ? touchGamepadButtonCount : touchGamepadLeftButtonCount; }
889
- // gamepad button index a side's buttons start at (right 0-3, left 4-7)
890
- function touchGamepadSideButtonBase(side)
891
- { return side ? 0 : 4; }
892
- // output stick index for a side: the right stick uses stick 0 when there is no left stick
893
- function touchGamepadStickOut(side)
894
- { return side && touchGamepadLeftStick ? 1 : 0; }
895
- // true if the side has any control (a stick or at least one button)
896
- function touchGamepadSideHasControl(side)
897
- { return touchGamepadSideStick(side) || touchGamepadSideButtonCount(side) > 0; }
898
-
899
- // true if gamepad button index i is an active touch gamepad face/single button
900
- function touchGamepadIsFaceButton(i)
901
- {
902
- for (let side = 0; side < 2; side++)
903
- {
904
- const base = touchGamepadSideButtonBase(side);
905
- if (!touchGamepadSideStick(side) &&
906
- i >= base && i < base + touchGamepadSideButtonCount(side))
907
- return true;
908
- }
909
- return false;
910
- }
911
-
912
- // center of a side's controls in stage-local CSS pixels (stick rest / button cluster)
913
- // returns the floating stick anchor when that side is an active floating stick
914
- function touchGamepadSideCenter(side, W, H)
915
- {
916
- if (touchGamepadFloating && touchGamepadSideStick(side) && touchGamepadStickAnchors[side])
917
- return touchGamepadStickAnchors[side];
918
- let y = H - touchGamepadSize;
919
- const count = touchGamepadSideButtonCount(side);
920
- if (!touchGamepadSideStick(side) && (count === 2 || count === 3))
921
- y -= touchGamepadSize/4; // nudge a 2/3 button cluster up a bit
922
- return vec2(side ? W - touchGamepadSize : touchGamepadSize, y);
923
- }
924
-
925
- // position the input zones for the current mode and rebuild the SVG visuals
926
- function touchGamepadRelayout()
927
- {
928
- if (!touchGamepadOverlay) return;
929
- const r = touchGamepadStageRect();
930
- const W = r.width, H = r.height, S = touchGamepadSize;
931
- const setZone = (z, css)=> z.style.cssText =
932
- 'position:absolute;pointer-events:auto;touch-action:none;' + css;
933
-
934
- if (paused)
935
- {
936
- // the gamepad is hidden while paused, so its side zones must not capture
937
- // touches - otherwise they silently steal taps from menus and dialogs
938
- for (const zone of touchGamepadSideZones) zone.style.display = 'none';
939
- if (touchGamepadCenterButtonSize)
940
- {
941
- // any touch presses start
942
- setZone(touchGamepadZoneC, 'inset:0');
943
- touchGamepadZoneC.style.display = '';
944
- }
945
- else
946
- touchGamepadZoneC.style.display = 'none';
947
- }
948
- else
949
- {
950
- // position each side zone (left/right differ only by which edge they hug)
951
- for (let side = 0; side < 2; side++)
952
- {
953
- const zone = touchGamepadSideZones[side], edge = side ? 'right' : 'left';
954
- zone.style.display = touchGamepadSideHasControl(side) ? '' : 'none';
955
- if (touchGamepadFloating)
956
- {
957
- // bottom 60% grabs the control; the top 40% passes through. A side with no
958
- // control on the other side uses the full width (matching the hit-test)
959
- const width = touchGamepadSideHasControl(side ? 0 : 1) ? '50%' : '100%';
960
- setZone(zone, `${edge}:0;bottom:0;width:${width};height:60%`);
961
- }
962
- else // fixed: a compact box hugging the corner control
963
- setZone(zone, `${edge}:0;bottom:0;width:${3*S}px;height:${3*S}px`);
964
- }
965
- touchGamepadZoneC.style.display = touchGamepadCenterButtonSize ? '' : 'none';
966
- const c = touchGamepadCenterButtonSize;
967
- setZone(touchGamepadZoneC,
968
- `left:50%;top:50%;width:${2*c}px;height:${2*c}px;transform:translate(-50%,-50%)`);
969
- }
970
-
971
- touchGamepadBuildSvg(W, H);
972
- touchGamepadNeedRelayout = false;
973
- }
974
-
975
- // (re)build the SVG shapes for the current layout; dynamic bits update per-frame
976
- function touchGamepadBuildSvg(W, H)
977
- {
978
- const svg = touchGamepadSvg;
979
- while (svg.firstChild) svg.removeChild(svg.firstChild);
980
- const els = touchGamepadSvgEls = { face: [], thumb: [] };
981
- const S = touchGamepadSize;
982
- const circle = (cx, cy, rr, fill)=>
983
- {
984
- const c = document.createElementNS(touchGamepadSvgNS, 'circle');
985
- c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', rr);
986
- if (fill) c.setAttribute('fill', fill);
987
- svg.appendChild(c);
988
- return c;
989
- };
990
- const cross = (ctr)=>
991
- {
992
- // plus-shaped dpad outline centered at ctr
993
- const a = S*.18, b = S*.5, x = ctr.x, y = ctr.y;
994
- const p = document.createElementNS(touchGamepadSvgNS, 'path');
995
- p.setAttribute('d',
996
- `M ${x-a} ${y-b} H ${x+a} V ${y-a} H ${x+b} V ${y+a} H ${x+a} ` +
997
- `V ${y+b} H ${x-a} V ${y+a} H ${x-b} V ${y-a} H ${x-a} Z`);
998
- svg.appendChild(p);
999
- };
1000
-
1001
- // draw each side: a directional stick, a single large button, or face buttons
1002
- for (let side = 0; side < 2; side++)
1003
- {
1004
- const count = touchGamepadSideButtonCount(side);
1005
- const base = touchGamepadSideButtonBase(side);
1006
- const ctr = touchGamepadSideCenter(side, W, H);
1007
- if (touchGamepadSideStick(side))
1008
- {
1009
- // directional stick (circle or cross) with a thumb dot that moves per-frame
1010
- if (touchGamepadAnalog) circle(ctr.x, ctr.y, S/2); else cross(ctr);
1011
- els.thumb[side] = circle(ctr.x, ctr.y, S/4, '#fff');
1012
- }
1013
- else if (count === 1)
1014
- els.face[base] = circle(ctr.x, ctr.y, S/2, '#000'); // single large button
1015
- else for (let i = 0; i < count; i++)
1016
- {
1017
- const j = mod(i-1, 4);
1018
- let button = count > 2 ? j : min(j, count-1);
1019
- button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
1020
- const offset = vec2().setDirection(j, S/2);
1021
- if (count === 2) offset.x *= -1;
1022
- // left side mirrors the right layout's positions, keeping indices in order
1023
- // (e.g. 2 buttons -> button 4 at bottom, button 5 at left)
1024
- if (!side) offset.x *= -1;
1025
- const pos = ctr.add(offset);
1026
- els.face[base + button] = circle(pos.x, pos.y, S/4, '#000');
1027
- }
1028
- }
1029
-
1030
- // debug: draw the proximity hit regions the hit-test actually uses
1031
- if (debug && debugGamepads) touchGamepadBuildDebug(W, H);
1032
- }
1033
-
1034
- // draw debug outlines of the touch control hit regions into the overlay svg
1035
- function touchGamepadBuildDebug(W, H)
1036
- {
1037
- const S = touchGamepadSize, svg = touchGamepadSvg;
1038
- const shape = (tag, attrs, stroke)=>
1039
- {
1040
- const el = document.createElementNS(touchGamepadSvgNS, tag);
1041
- for (const k in attrs) el.setAttribute(k, attrs[k]);
1042
- el.setAttribute('stroke', stroke);
1043
- el.setAttribute('stroke-width', 2);
1044
- el.setAttribute('fill', 'none');
1045
- svg.appendChild(el);
1046
- };
1047
- const ring = (c, rr, stroke)=> shape('circle', {cx:c.x, cy:c.y, r:rr}, stroke);
1048
-
1049
- // green line: the left/right split that assigns a stick press to a side
1050
- shape('line', {x1:W/2, y1:0, x2:W/2, y2:H}, '#0f0');
1051
-
1052
- // cyan: where each side's control can be grabbed
1053
- for (let side = 0; side < 2; side++)
1054
- {
1055
- if (touchGamepadSideStick(side))
1056
- {
1057
- if (touchGamepadFloating)
1058
- {
1059
- // grab region: this side's half (or the full width if the other side is empty)
1060
- const top = H*.4, full = !touchGamepadSideHasControl(side ? 0 : 1);
1061
- const x = full ? 0 : (side ? W/2 : 0);
1062
- shape('rect', {x, y:top, width:full ? W : W/2, height:H-top}, '#0ff');
1063
- }
1064
- else
1065
- ring(touchGamepadSideCenter(side, W, H), 2*S, '#0ff');
1066
- }
1067
- else if (touchGamepadSideButtonCount(side) >= 1)
1068
- ring(touchGamepadSideCenter(side, W, H), S, '#0ff'); // face / single-button radius
1069
- }
1070
-
1071
- // yellow: start button radius; magenta: where start is blocked (near a control)
1072
- if (touchGamepadCenterButtonSize)
1073
- {
1074
- ring(vec2(W/2, H/2), touchGamepadCenterButtonSize, '#ff0');
1075
- for (let side = 0; side < 2; side++)
1076
- if (touchGamepadSideHasControl(side))
1077
- ring(touchGamepadSideCenter(side, W, H), 2*S, '#f0f');
1078
- }
1079
- }
1080
-
1081
- // per-frame: fade the overlay and move the thumbs / set pressed states
1082
- function touchGamepadRender()
1083
- {
1084
- if (!touchGamepadOverlay || headlessMode) return;
1085
-
1086
- // hide and bail if disabled at runtime (overlay stays in the DOM for reuse)
1087
- // display:none also takes the input zones out of hit-testing so touches are
1088
- // not silently captured away from the game while disabled
1089
- if (!touchGamepadEnable || !isTouchDevice)
1090
- {
1091
- if (touchGamepadOverlay.style.display !== 'none')
1092
- {
1093
- // just disabled: hide the overlay and release any held controls
1094
- touchGamepadOverlay.style.display = 'none';
1095
- touchGamepadPointerRole.clear();
1096
- touchGamepadButtons.length = 0;
1097
- touchGamepadSticks.length = 0;
1098
- touchGamepadStickPointerId.length = 0;
1099
- }
1100
- return;
1101
- }
1102
- touchGamepadOverlay.style.display = '';
1103
-
1104
- // relayout when the paused state, a layout setting, or the debug view changes
1105
- const dbg = debug && debugGamepads;
1106
- const layout = [touchGamepadButtonCount, touchGamepadLeftButtonCount, touchGamepadLeftStick,
1107
- touchGamepadRightStick, touchGamepadAnalog, touchGamepadSize, touchGamepadFloating,
1108
- touchGamepadCenterButtonSize, paused, dbg].join();
1109
- if (layout !== touchGamepadLastLayout)
1110
- {
1111
- touchGamepadLastLayout = layout;
1112
- touchGamepadNeedRelayout = true;
1113
- }
1114
- // relayout before the visibility bail-out so the paused full-screen start zone applies
1115
- if (touchGamepadNeedRelayout) touchGamepadRelayout();
1116
-
1117
- // fade out when idle (always show when displayTime is 0, or while debugging)
1118
- const fade = touchGamepadDisplayTime ?
1119
- percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
1120
- const visible = dbg || (touchGamepadTimer.isSet() && fade > 0 && !paused);
1121
- touchGamepadOverlay.style.opacity = !visible ? 0 : dbg ? 1 : fade*touchGamepadAlpha;
1122
- if (!visible) return;
1123
-
1124
- const r = touchGamepadStageRect();
1125
- const W = r.width, H = r.height, S = touchGamepadSize;
1126
- const els = touchGamepadSvgEls;
1127
- if (!els) return;
1128
-
1129
- for (let side = 0; side < 2; side++)
1130
- if (touchGamepadSideStick(side) && els.thumb[side])
1131
- {
1132
- const ctr = touchGamepadSideCenter(side, W, H);
1133
- const t = ctr.add((touchGamepadSticks[side] ?? vec2()).scale(S/2));
1134
- els.thumb[side].setAttribute('cx', t.x);
1135
- els.thumb[side].setAttribute('cy', t.y);
1136
- }
1137
- for (let i = 0; i < els.face.length; i++)
1138
- if (els.face[i])
1139
- els.face[i].setAttribute('fill', touchGamepadButtons[i] ? '#fff' : '#000');
1140
- }
1141
-
1142
- // convert a pointer event to stage-local CSS pixels
1143
- function touchGamepadEventPos(e)
1144
- {
1145
- const r = touchGamepadStageRect();
1146
- return vec2(e.clientX - r.left, e.clientY - r.top);
1147
- }
1148
-
1149
- // set a directional stick from a stage-local point and flag its stick-touch button
1150
- // (stick 0 press = button 10, stick 1 press = button 11, following the output index)
1151
- function touchGamepadApplyStick(side, p)
1152
- {
1153
- const delta = p.subtract(touchGamepadStickAnchors[side]);
1154
- touchGamepadSticks[side] = delta.scale(2/touchGamepadSize).clampLength();
1155
- touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10] = 1;
1156
- }
1157
-
1158
- // pick a side's gamepad button index from a stage-local point, or -1 if outside the cluster
1159
- function touchGamepadFaceButtonAt(side, p, W, H)
1160
- {
1161
- const count = touchGamepadSideButtonCount(side);
1162
- const base = touchGamepadSideButtonBase(side);
1163
- const bc = touchGamepadSideCenter(side, W, H);
1164
- if (bc.distance(p) >= touchGamepadSize) return -1;
1165
- if (count === 1) return base; // single large button
1166
- const d = bc.subtract(p);
1167
- if (!side) d.x *= -1; // left side mirrors the right layout's positions horizontally
1168
- let button = count === 2 ? (d.x < d.y ? 1 : 0) : mod(d.direction()+2, 4);
1169
- button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
1170
- return button < count ? base + button : -1;
1171
- }
1172
-
1173
- // pick which control a stage-local press activates, by priority then proximity,
1174
- // independent of which zone element captured it - so overlapping zones on small
1175
- // screens resolve to the nearest control instead of whichever zone is topmost
1176
- // returns {role:'stick', side} or {role:'face', btn} or {role:'start'} or undefined
1177
- function touchGamepadControlAt(p, W, H)
1178
- {
1179
- const S = touchGamepadSize;
1180
- const leftHalf = p.x < W/2;
1181
- const floatTop = H*.4; // floating grab region is the bottom 60% of the screen
1182
-
1183
- // check each side (left first for priority); a side is a stick or buttons
1184
- for (let side = 0; side < 2; side++)
1185
- {
1186
- const onHalf = side ? !leftHalf : leftHalf;
1187
- if (touchGamepadSideStick(side))
1188
- {
1189
- // a side with no control on the other side uses the full width
1190
- const otherControl = touchGamepadSideHasControl(side ? 0 : 1);
1191
- const grab = touchGamepadFloating ?
1192
- (!otherControl || onHalf) && p.y > floatTop :
1193
- onHalf && touchGamepadSideCenter(side, W, H).distance(p) < 2*S;
1194
- if (grab) return {role:'stick', side};
1195
- }
1196
- else if (touchGamepadSideButtonCount(side) >= 1)
1197
- {
1198
- const btn = touchGamepadFaceButtonAt(side, p, W, H);
1199
- if (btn >= 0) return {role:'face', btn};
1200
- }
1201
- }
1202
-
1203
- // center start button, blocked within 2*size of a control so drift off a
1204
- // control can't accidentally fire start (matches the original exclusion logic)
1205
- if (touchGamepadCenterButtonSize)
1206
- {
1207
- for (let side = 0; side < 2; side++)
1208
- if (touchGamepadSideHasControl(side) &&
1209
- touchGamepadSideCenter(side, W, H).distance(p) < 2*S)
1210
- return;
1211
- if (vec2(W/2, H/2).distance(p) < touchGamepadCenterButtonSize)
1212
- return {role:'start'};
1213
- }
1214
- }
1215
-
1216
- function touchGamepadPointerDown(e, zone)
1217
- {
1218
- if (!touchGamepadEnable) return;
1219
- e.preventDefault();
1220
- zone.setPointerCapture(e.pointerId);
1221
- touchGamepadTimer.set();
1222
-
1223
- // resume audio on first interaction
1224
- if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
1225
- audioContext.resume();
1226
-
1227
- // while paused, any touch is the start button
1228
- if (paused)
1229
- {
1230
- if (touchGamepadCenterButtonSize)
1231
- {
1232
- touchGamepadButtons[9] = 1;
1233
- touchGamepadPointerRole.set(e.pointerId, 'start');
1234
- }
1235
- return;
1236
- }
1237
-
1238
- const r = touchGamepadStageRect();
1239
- const W = r.width, H = r.height;
1240
- const p = vec2(e.clientX - r.left, e.clientY - r.top);
1241
-
1242
- // choose the control by proximity/priority, not by which zone captured the touch
1243
- const hit = touchGamepadControlAt(p, W, H);
1244
- if (!hit) return;
1245
- if (hit.role === 'stick')
1246
- {
1247
- const side = hit.side;
1248
- touchGamepadStickAnchors[side] = touchGamepadFloating ? p : touchGamepadSideCenter(side, W, H);
1249
- touchGamepadStickPointerId[side] = e.pointerId;
1250
- touchGamepadPointerRole.set(e.pointerId, 'stick'+side);
1251
- touchGamepadNeedRelayout = true; // base may have re-anchored
1252
- touchGamepadApplyStick(side, p);
1253
- }
1254
- else if (hit.role === 'face')
1255
- {
1256
- touchGamepadButtons[hit.btn] = 1;
1257
- touchGamepadPointerRole.set(e.pointerId, 'face'+hit.btn);
1258
- }
1259
- else // 'start'
1260
- {
1261
- touchGamepadButtons[9] = 1;
1262
- touchGamepadPointerRole.set(e.pointerId, 'start');
1263
- }
1264
- }
1265
-
1266
- function touchGamepadPointerMove(e)
1267
- {
1268
- const role = touchGamepadPointerRole.get(e.pointerId);
1269
- if (!role) return;
1270
- e.preventDefault();
1271
- const p = touchGamepadEventPos(e);
1272
- if (role === 'stick0' || role === 'stick1')
1273
- touchGamepadApplyStick(role === 'stick1' ? 1 : 0, p);
1274
- // face buttons & start are held until release (no slide-between this pass)
1275
- }
1276
-
1277
- function touchGamepadPointerUp(e)
1278
- {
1279
- const role = touchGamepadPointerRole.get(e.pointerId);
1280
- if (!role) return;
1281
- touchGamepadPointerRole.delete(e.pointerId);
1282
- if (role === 'stick0' || role === 'stick1')
1283
- {
1284
- const side = role === 'stick1' ? 1 : 0;
1285
- touchGamepadStickPointerId[side] = undefined;
1286
- touchGamepadSticks[side] = vec2();
1287
- delete touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10];
1288
- }
1289
- else if (role === 'start')
1290
- delete touchGamepadButtons[9];
1291
- else // 'face<n>'
1292
- delete touchGamepadButtons[+role.slice(4)];
1293
- touchGamepadTimer.set();
1
+ /**
2
+ * LittleJS Input System
3
+ * - Keyboard input with key down, pressed, and released states
4
+ * - Mouse input with position (world and screen space), buttons, and wheel
5
+ * - Gamepad support for multiple controllers with analog sticks and buttons
6
+ * - Touch input mapped to mouse position and buttons
7
+ * - Virtual on-screen gamepad for mobile devices
8
+ * - Automatic gamepad vs keyboard/mouse detection
9
+ * - Input event prevention for canvas focus
10
+ * - Clipboard copy/paste support
11
+ * @namespace Input
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ /** Mouse pos in world space
17
+ * @type {Vector2}
18
+ * @memberof Input */
19
+ let mousePos = vec2();
20
+
21
+ /** Mouse pos in screen space
22
+ * @type {Vector2}
23
+ * @memberof Input */
24
+ let mousePosScreen = vec2();
25
+
26
+ /** Mouse movement delta in world space
27
+ * @type {Vector2}
28
+ * @memberof Input */
29
+ let mouseDelta = vec2();
30
+
31
+ /** Mouse movement delta in screen space
32
+ * @type {Vector2}
33
+ * @memberof Input */
34
+ let mouseDeltaScreen = vec2();
35
+
36
+ /** Mouse wheel delta this frame
37
+ * @type {number}
38
+ * @memberof Input */
39
+ let mouseWheel = 0;
40
+
41
+ /** True if mouse was inside the document window, set to false when mouse leaves
42
+ * @type {boolean}
43
+ * @memberof Input */
44
+ let mouseInWindow = true;
45
+
46
+ /** True if a gamepad is the most recently used input device.
47
+ * Equivalent to usingGamepadInput(); derived from lastInputDevice each frame.
48
+ * @type {boolean}
49
+ * @memberof Input */
50
+ let isUsingGamepad = false;
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 {'mouse'|'keyboard'|'gamepad'}
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
+
70
+ /** Prevents input continuing to the default browser handling (true by default)
71
+ * @type {boolean}
72
+ * @memberof Input */
73
+ let inputPreventDefault = true;
74
+
75
+ /** Primary gamepad index, automatically set to first gamepad with input
76
+ * @type {number}
77
+ * @memberof Input */
78
+ let gamepadPrimary = 0;
79
+
80
+ /** True if a touch device has been detected
81
+ * @memberof Input */
82
+ const isTouchDevice = !headlessMode && typeof window != 'undefined' && window.ontouchstart !== undefined;
83
+
84
+ /** Prevents input continuing to the default browser handling
85
+ * This is useful to disable for html menus so the browser can handle input normally,
86
+ * the right click menu included; over an html text field that menu always shows
87
+ * - While on, the mouse's back and forward buttons don't leave the page, a game can read them as mouse 3 and 4
88
+ * @param {boolean} [preventDefault]
89
+ * @memberof Input */
90
+ function setInputPreventDefault(preventDefault=true) { inputPreventDefault = preventDefault; }
91
+
92
+ /** Set the screen-pixel mouse movement per frame that counts as using the mouse
93
+ * @param {number} threshold
94
+ * @memberof Input */
95
+ function setInputMouseMoveThreshold(threshold) { inputMouseMoveThreshold = threshold; }
96
+
97
+ /** Is the mouse the most recently used input device?
98
+ * @return {boolean}
99
+ * @memberof Input */
100
+ function usingMouseInput() { return lastInputDevice === 'mouse'; }
101
+ /** Is the keyboard the most recently used input device?
102
+ * @return {boolean}
103
+ * @memberof Input */
104
+ function usingKeyboardInput() { return lastInputDevice === 'keyboard'; }
105
+ /** Is a gamepad the most recently used input device?
106
+ * @return {boolean}
107
+ * @memberof Input */
108
+ function usingGamepadInput() { return lastInputDevice === 'gamepad'; }
109
+
110
+ /** Clear a key's state, so it stops reading as down, pressed or released this frame; a key still held is not
111
+ * pressed again until it is let go
112
+ * @param {string|number} key - A key code like 'Space', or a mouse or gamepad button number
113
+ * @param {number} [device] - 0 for the keyboard and mouse, gamepad n is device n + 1
114
+ * @param {boolean} [clearDown] - Clear the held state
115
+ * @param {boolean} [clearPressed] - Clear the pressed this frame state
116
+ * @param {boolean} [clearReleased] - Clear the released this frame state
117
+ * @memberof Input */
118
+ function inputClearKey(key, device=0, clearDown=true, clearPressed=true, clearReleased=true)
119
+ {
120
+ if (!inputData[device])
121
+ return;
122
+ inputData[device][key] &= ~((clearDown?1:0)|(clearPressed?2:0)|(clearReleased?4:0));
123
+ }
124
+
125
+ // the WASD keys and the arrows they stand in for when inputWASDEmulateDirection is on, and the arrows' aliases
126
+ const inputWASDToArrow = {KeyW:'ArrowUp', KeyS:'ArrowDown', KeyA:'ArrowLeft', KeyD:'ArrowRight'};
127
+ const inputArrowToWASD = {ArrowUp:'KeyW', ArrowDown:'KeyS', ArrowLeft:'KeyA', ArrowRight:'KeyD'};
128
+ const inputKeysHeld = new Set; // the keys physically down, since an arrow's slot is shared with its alias
129
+ let inputWasTouching = 0, inputTouchIdentifier; // the touch driving the mouse, cleared with the input so only a new touch presses
130
+ let inputLastTouchTime = -1e9; // when a touch event last came, the mouse events a browser makes from a tap follow it
131
+
132
+ // is this mouse event one the browser made from a touch, which the touch input already handled
133
+ function inputIsTouchMouseEvent(e)
134
+ { return e.sourceCapabilities?.firesTouchEvents || performance.now() - inputLastTouchTime < 500; }
135
+
136
+ // is a real gamepad being used, a button down or a standard stick pushed, only read to hand over from the touch
137
+ // gamepad, a non standard pad's axes may rest at full deflection so they are not trusted here
138
+ function inputRealGamepadUsed()
139
+ {
140
+ for (const gamepad of inputGetGamepads())
141
+ {
142
+ if (!gamepad) continue;
143
+ for (const button of gamepad.buttons)
144
+ if (button.pressed) return true;
145
+ if (gamepad.mapping === 'standard')
146
+ for (let j = 0; j < 4 && j < gamepad.axes.length; ++j)
147
+ if (abs(gamepad.axes[j]) > .5) return true;
148
+ }
149
+ return false;
150
+ }
151
+
152
+ // let go of every keyboard key, for when something else takes the keyboard, like a text field
153
+ function inputClearKeyboard()
154
+ {
155
+ const keys = inputData[0];
156
+ for (const key in keys)
157
+ isNaN(+key) && (keys[key] = 0); // mouse buttons are numbers, keys are codes
158
+ inputKeysHeld.clear();
159
+ }
160
+
161
+ /** Clears all input
162
+ * @memberof Input */
163
+ function inputClear()
164
+ {
165
+ // empty every device but keep which gamepad slots exist, so gamepadConnected still reads true through a
166
+ // clear, like the one every frame while the window is unfocused; a disconnect clears its slot in the poll
167
+ for (let i = inputData.length; i--;)
168
+ if (!i || inputData[i]) inputData[i] = [];
169
+ inputKeysHeld.clear();
170
+ inputWasTouching = 0;
171
+ // release the touch gamepad, a finger still on it has to lift before it can take a control again
172
+ touchGamepadPointerRole.clear();
173
+ touchGamepadButtons.length = 0;
174
+ touchGamepadButtonsPressed.length = 0;
175
+ touchGamepadSticks.length = 0;
176
+ touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor
177
+ gamepadStickData.length = 0;
178
+ gamepadDpadData.length = 0;
179
+ // gamepadButtonsLast and gamepadAxisCentered describe the pads, not input, so they are kept:
180
+ // a button held through the clear reads as down, not newly pressed, and axes stay trusted
181
+ }
182
+
183
+ ///////////////////////////////////////////////////////////////////////////////
184
+
185
+ /** Returns true if device key is down
186
+ * @param {string|number} key
187
+ * @param {number} [device] - 0 for the keyboard and mouse, gamepad n is device n + 1
188
+ * @return {boolean}
189
+ * @memberof Input */
190
+ function keyIsDown(key, device=0)
191
+ {
192
+ ASSERT(isStringLike(key), 'key must be a number or string');
193
+ ASSERT(typeof key !== 'string' || key.length > 1, "keys are codes like 'KeyW' or 'Space', not characters");
194
+ ASSERT(device > 0 || typeof key !== 'number' || key < 5, 'use code string for keyboard');
195
+ return !!(inputData[device]?.[key] & 1);
196
+ }
197
+
198
+ /** Returns true if device key was pressed this frame
199
+ * @param {string|number} key
200
+ * @param {number} [device] - 0 for the keyboard and mouse, gamepad n is device n + 1
201
+ * @return {boolean}
202
+ * @memberof Input */
203
+ function keyWasPressed(key, device=0)
204
+ {
205
+ ASSERT(isStringLike(key), 'key must be a number or string');
206
+ ASSERT(typeof key !== 'string' || key.length > 1, "keys are codes like 'KeyW' or 'Space', not characters");
207
+ ASSERT(device > 0 || typeof key !== 'number' || key < 5, 'use code string for keyboard');
208
+ return !!(inputData[device]?.[key] & 2);
209
+ }
210
+
211
+ /** Returns true if device key was released this frame
212
+ * @param {string|number} key
213
+ * @param {number} [device] - 0 for the keyboard and mouse, gamepad n is device n + 1
214
+ * @return {boolean}
215
+ * @memberof Input */
216
+ function keyWasReleased(key, device=0)
217
+ {
218
+ ASSERT(isStringLike(key), 'key must be a number or string');
219
+ ASSERT(typeof key !== 'string' || key.length > 1, "keys are codes like 'KeyW' or 'Space', not characters");
220
+ ASSERT(device > 0 || typeof key !== 'number' || key < 5, 'use code string for keyboard');
221
+ return !!(inputData[device]?.[key] & 4);
222
+ }
223
+
224
+ /** Returns input vector from arrow keys or WASD if enabled
225
+ * @param {string} [up]
226
+ * @param {string} [down]
227
+ * @param {string} [left]
228
+ * @param {string} [right]
229
+ * @return {Vector2}
230
+ * @memberof Input */
231
+ function keyDirection(up='ArrowUp', down='ArrowDown', left='ArrowLeft', right='ArrowRight')
232
+ {
233
+ ASSERT(isStringLike(up), 'up key must be a string');
234
+ ASSERT(isStringLike(down), 'down key must be a string');
235
+ ASSERT(isStringLike(left), 'left key must be a string');
236
+ ASSERT(isStringLike(right), 'right key must be a string');
237
+ const k = (key)=> keyIsDown(key) ? 1 : 0;
238
+ return vec2(k(right) - k(left), k(up) - k(down));
239
+ }
240
+
241
+ /** Returns true if mouse button is down
242
+ * @function
243
+ * @param {number} button
244
+ * @return {boolean}
245
+ * @memberof Input */
246
+ function mouseIsDown(button)
247
+ {
248
+ ASSERT(isNumber(button), 'mouse button must be a number');
249
+ return keyIsDown(button);
250
+ }
251
+
252
+ /** Returns true if mouse button was pressed
253
+ * @function
254
+ * @param {number} button
255
+ * @return {boolean}
256
+ * @memberof Input */
257
+ function mouseWasPressed(button)
258
+ {
259
+ ASSERT(isNumber(button), 'mouse button must be a number');
260
+ return keyWasPressed(button);
261
+ }
262
+
263
+ /** Returns true if mouse button was released
264
+ * @function
265
+ * @param {number} button
266
+ * @return {boolean}
267
+ * @memberof Input */
268
+ function mouseWasReleased(button)
269
+ {
270
+ ASSERT(isNumber(button), 'mouse button must be a number');
271
+ return keyWasReleased(button);
272
+ }
273
+
274
+ /** Returns true if gamepad button is down
275
+ * @param {number} button
276
+ * @param {number} [gamepad]
277
+ * @return {boolean}
278
+ * @memberof Input */
279
+ function gamepadIsDown(button, gamepad=gamepadPrimary)
280
+ {
281
+ ASSERT(isNumber(button), 'button must be a number');
282
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
283
+ return keyIsDown(button, gamepad+1);
284
+ }
285
+
286
+ /** Returns true if gamepad button was pressed
287
+ * @param {number} button
288
+ * @param {number} [gamepad]
289
+ * @return {boolean}
290
+ * @memberof Input */
291
+ function gamepadWasPressed(button, gamepad=gamepadPrimary)
292
+ {
293
+ ASSERT(isNumber(button), 'button must be a number');
294
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
295
+ return keyWasPressed(button, gamepad+1);
296
+ }
297
+
298
+ /** Returns true if gamepad button was released
299
+ * @param {number} button
300
+ * @param {number} [gamepad]
301
+ * @return {boolean}
302
+ * @memberof Input */
303
+ function gamepadWasReleased(button, gamepad=gamepadPrimary)
304
+ {
305
+ ASSERT(isNumber(button), 'button must be a number');
306
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
307
+ return keyWasReleased(button, gamepad+1);
308
+ }
309
+
310
+ /** Returns gamepad stick value
311
+ * @param {number} stick
312
+ * @param {number} [gamepad]
313
+ * @return {Vector2} - Where the stick points, y up, length 0 to 1 past the dead zone, zero when there is no such stick
314
+ * @memberof Input */
315
+ function gamepadStick(stick, gamepad=gamepadPrimary)
316
+ {
317
+ ASSERT(isNumber(stick), 'stick must be a number');
318
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
319
+ return gamepadStickData[gamepad]?.[stick] ?? vec2();
320
+ }
321
+
322
+ /** Returns gamepad dpad value
323
+ * @param {number} [gamepad]
324
+ * @return {Vector2}
325
+ * @memberof Input */
326
+ function gamepadDpad(gamepad=gamepadPrimary)
327
+ {
328
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
329
+ return gamepadDpadData[gamepad]?.copy() ?? vec2(); // a copy, the poller keeps its own
330
+ }
331
+
332
+ /** Returns true if passed in gamepad is connected
333
+ * @param {number} [gamepad]
334
+ * @return {boolean}
335
+ * @memberof Input */
336
+ function gamepadConnected(gamepad=gamepadPrimary)
337
+ {
338
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
339
+ return !!inputData[gamepad+1];
340
+ }
341
+
342
+ /** Returns how many control sticks the passed in gamepad has
343
+ * @param {number} [gamepad]
344
+ * @return {number}
345
+ * @memberof Input */
346
+ function gamepadStickCount(gamepad=gamepadPrimary)
347
+ {
348
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
349
+ return gamepadStickData[gamepad]?.length ?? 0;
350
+ }
351
+
352
+ /** Pulse a gamepad's vibration hardware using the dual-rumble effect if it exists
353
+ * Strong magnitude is usually the left side motor, weak magnitude is usually the right side motor
354
+ * @param {number} [gamepad] - gamepad index
355
+ * @param {number} [duration] - effect duration in ms, browsers limit it and the delay to 5 seconds together
356
+ * @param {number} [strongMagnitude] - strong (left) motor intensity, 0 to 1
357
+ * @param {number} [weakMagnitude] - weak (right) motor intensity, 0 to 1
358
+ * @param {number} [startDelay] - delay in ms before the effect starts
359
+ * @memberof Input */
360
+ function gamepadVibrate(gamepad=gamepadPrimary, duration=200, strongMagnitude=1, weakMagnitude=1, startDelay=0)
361
+ {
362
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
363
+ ASSERT(strongMagnitude >= 0 && strongMagnitude <= 1 && weakMagnitude >= 0 && weakMagnitude <= 1,
364
+ 'rumble magnitudes must be 0 to 1');
365
+ ASSERT(duration >= 0 && startDelay >= 0 && duration + startDelay <= 5e3,
366
+ 'browsers limit a rumble and its delay to 5 seconds');
367
+ if (!vibrateEnable || headlessMode) return;
368
+ const pad = inputGetGamepads()[gamepad];
369
+ // a browser refuses a rumble it can not play by rejecting, which is nothing to report
370
+ pad?.vibrationActuator?.playEffect?.('dual-rumble', {duration, strongMagnitude, weakMagnitude, startDelay})
371
+ ?.catch?.(()=> {});
372
+ }
373
+
374
+ /** Stop vibration on a gamepad
375
+ * @param {number} [gamepad] - gamepad index
376
+ * @memberof Input */
377
+ function gamepadVibrateStop(gamepad=gamepadPrimary)
378
+ {
379
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
380
+ if (!vibrateEnable || headlessMode) return;
381
+ const pad = inputGetGamepads()[gamepad];
382
+ pad?.vibrationActuator?.reset?.();
383
+ }
384
+
385
+ // the gamepads the browser reports, none when it has no gamepad support or refuses the page them, which it
386
+ // does by throwing a SecurityError from the call, as the spec says, for a page not allowed gamepads
387
+ function inputGetGamepads()
388
+ {
389
+ try { return navigator?.getGamepads?.() || []; }
390
+ catch (e) { return []; }
391
+ }
392
+
393
+ ///////////////////////////////////////////////////////////////////////////////
394
+
395
+ /** Pulse the vibration hardware if it exists
396
+ * @param {number|Array} [pattern] - single value in ms or vibration interval array
397
+ * @memberof Input */
398
+ function vibrate(pattern=100)
399
+ {
400
+ ASSERT(isNumber(pattern) || isArray(pattern), 'pattern must be a number or array');
401
+ vibrateEnable && !headlessMode && navigator?.vibrate?.(pattern);
402
+ }
403
+
404
+ /** Cancel any ongoing vibration
405
+ * @memberof Input */
406
+ function vibrateStop() { vibrate(0); }
407
+
408
+ ///////////////////////////////////////////////////////////////////////////////
409
+ // Pointer Lock
410
+
411
+ /** Request to lock the pointer, for a mouse; a device with only touch refuses it
412
+ * @memberof Input */
413
+ function pointerLockRequest()
414
+ {
415
+ // newer browsers return a promise that rejects when the lock is refused, like just after Esc left it,
416
+ // or on a phone; a touchscreen laptop's mouse can still lock
417
+ try { /** @type {any} */ (mainCanvas.requestPointerLock?.())?.catch?.(()=>{}); }
418
+ catch { }
419
+ }
420
+
421
+ /** Request to unlock the pointer
422
+ * @memberof Input */
423
+ function pointerLockExit()
424
+ { document.exitPointerLock?.(); }
425
+
426
+ /** Check if pointer is locked (true if locked)
427
+ * @return {boolean}
428
+ * @memberof Input */
429
+ function pointerLockIsActive()
430
+ { return document.pointerLockElement === mainCanvas; }
431
+
432
+ ///////////////////////////////////////////////////////////////////////////////
433
+ // Input variables used by engine
434
+
435
+ // input uses bit field for each key: 1=isDown, 2=wasPressed, 4=wasReleased
436
+ // mouse and keyboard stored in device 0, gamepads stored in devices > 0
437
+ const inputData = [[]];
438
+
439
+ // gamepad internal variables
440
+ const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
441
+ // per gamepad, each button's raw pressed state from the last poll, kept through inputClear and cleared on
442
+ // disconnect, so a button held through a clear reads as down and not newly pressed
443
+ const gamepadButtonsLast = [];
444
+ // per gamepad, how many consecutive frames each axis has rested inside the
445
+ // dead zone, used to tell stick axes from axes that rest at full deflection
446
+ const gamepadAxisCentered = [];
447
+ // how long an axis must rest inside the dead zone before it counts as a stick
448
+ const gamepadAxisCenteredFrames = 15;
449
+
450
+ // touch gamepad internal variables
451
+ const touchGamepadTimer = new Timer(undefined, true), touchGamepadButtons = [], touchGamepadSticks = [];
452
+ // buttons pressed since the last poll, so a tap that lifts before the poll still counts
453
+ const touchGamepadButtonsPressed = [];
454
+ // floating stick anchors (stage-local CSS pixels) and owning pointer ids, indexed by stick (0=left, 1=right)
455
+ const touchGamepadStickAnchors = [], touchGamepadStickPointerId = [];
456
+ // pointerId -> control role ('stick0', 'stick1', 'face<n>', or 'start')
457
+ const touchGamepadPointerRole = new Map();
458
+ // overlay DOM elements (created lazily on touch devices) and cached SVG shapes
459
+ let touchGamepadOverlay, touchGamepadStage, touchGamepadSvg, touchGamepadSvgEls;
460
+ let touchGamepadSideZones = [], touchGamepadZoneC;
461
+ let touchGamepadNeedRelayout = true, touchGamepadLastLayout;
462
+
463
+ ///////////////////////////////////////////////////////////////////////////////
464
+ // Input system functions used by engine
465
+
466
+ function inputInit()
467
+ {
468
+ if (headlessMode) return;
469
+
470
+ // add event listeners
471
+ document.addEventListener('keydown', onKeyDown);
472
+ document.addEventListener('keyup', onKeyUp);
473
+ document.addEventListener('mousedown', onMouseDown);
474
+ document.addEventListener('mouseup', onMouseUp);
475
+ document.addEventListener('mousemove', onMouseMove);
476
+ document.addEventListener('mouseleave', onMouseLeave);
477
+ document.addEventListener('wheel', onMouseWheel, { passive: false });
478
+ document.addEventListener('contextmenu', onContextMenu);
479
+ addEventListener('blur', onBlur); // the window's, the browser fires blur there and it does not bubble to the document
480
+
481
+ // init touch input, its handler checks touchInputEnable itself, so turning it on later works too
482
+ if (isTouchDevice)
483
+ touchInputInit();
484
+
485
+ function onKeyDown(e)
486
+ {
487
+ // fix stalled audio requiring user interaction, a keyboard only game has no other gesture
488
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
489
+ audioContext.resume();
490
+
491
+ // keys typed into an html text field are the player's typing, not game input;
492
+ // a key already down still releases on keyup, which only lets go of keys that are down
493
+ const typing = isTextInput(e.target) || isTextInput(document.activeElement);
494
+ if (!e.repeat && !typing)
495
+ {
496
+ inputKeysHeld.add(e.code);
497
+ // an arrow its alias already holds down is not pressed again, like its release waits for both
498
+ if (!(inputWASDEmulateDirection && inputKeysHeld.has(inputArrowToWASD[e.code]) && inputData[0][e.code] & 1))
499
+ inputData[0][e.code] = 3;
500
+ // an alias presses its arrow's slot too, unless the arrow itself already holds it down
501
+ const remap = remapKey(e.code);
502
+ if (remap !== e.code && !(inputData[0][remap] & 1))
503
+ inputData[0][remap] = 3;
504
+ }
505
+
506
+ // try to prevent default browser handling of input
507
+ if (!inputPreventDefault || !e.cancelable || !document.hasFocus()) return;
508
+
509
+ // don't break browser shortcuts
510
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
511
+
512
+ // don't interfere with user typing into UI fields
513
+ if (typing) return;
514
+
515
+ // fix browser setting "Search for text when you start typing"
516
+ const printable = typeof e.key === 'string' && e.key.length === 1;
517
+
518
+ // prevent arrow key and other default keys from messing with stuff
519
+ const preventDefaultKeys =
520
+ [
521
+ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', // scrolling
522
+ 'Space', // page down scroll
523
+ 'Tab', // focus navigation
524
+ 'Backspace', // browser back
525
+ ];
526
+ if (preventDefaultKeys.includes(e.code) || printable)
527
+ e.preventDefault();
528
+ }
529
+ // true if an event is on an HTML control on the page, which handles it itself
530
+ function isOnControl(e)
531
+ {
532
+ const target = /** @type {HTMLElement} */ (e.target);
533
+ return !!target?.closest?.('input,textarea,select,button,a,label,[contenteditable]');
534
+ }
535
+ function isTextInput(element)
536
+ {
537
+ // a field that takes typing or arrow keys, not an input that is really a button, checkbox or slider,
538
+ // which would otherwise keep every key from the game after it was clicked
539
+ const tag = element?.tagName;
540
+ if (tag === 'INPUT')
541
+ return !['button','checkbox','color','file','image','radio','range','reset','submit'].includes(element.type);
542
+ return !!element?.isContentEditable || tag === 'TEXTAREA' || tag === 'SELECT';
543
+ }
544
+ function onKeyUp(e)
545
+ {
546
+ inputKeysHeld.delete(e.code);
547
+ // the key's own slot and the arrow slot an alias shares, each released only once nothing holds it:
548
+ // an arrow held with its alias stays down until both are let go; a slot not down was never pressed as far
549
+ // as the game knows (held since before focus or through a clear), so it is not released either
550
+ // the alias is released whatever the setting is now, emulation turned off while W is held still lets go of
551
+ // the ArrowUp it pressed
552
+ const remap = inputWASDToArrow[e.code] || e.code;
553
+ for (const key of remap === e.code ? [e.code] : [e.code, remap])
554
+ if (!inputKeysHeld.has(key) && !(inputWASDEmulateDirection && inputKeysHeld.has(inputArrowToWASD[key])))
555
+ if (inputData[0][key] & 1)
556
+ inputData[0][key] = (inputData[0][key]&2) | 4;
557
+
558
+ // a Mac sends no keyup for a key let go while Cmd is held, so letting go of Cmd lets go of every key
559
+ if (e.key === 'Meta')
560
+ for (const code of [...inputKeysHeld])
561
+ onKeyUp({code});
562
+ }
563
+ function remapKey(k)
564
+ {
565
+ // handle remapping wasd keys to directions
566
+ return inputWASDEmulateDirection && inputWASDToArrow[k] || k;
567
+ }
568
+ function onMouseDown(e)
569
+ {
570
+ // a mouse on a touchscreen laptop works, only the mouse events a tap makes are left to the touch input
571
+ if (touchInputEnable && inputIsTouchMouseEvent(e)) return;
572
+
573
+ // fix stalled audio requiring user interaction
574
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
575
+ audioContext.resume();
576
+
577
+ inputData[0][e.button] = 3;
578
+
579
+ const mousePosScreenLast = mousePosScreen;
580
+ mousePosScreen = mouseEventToScreen(vec2(e.x,e.y));
581
+ mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
582
+
583
+ // a click on an HTML form control on the page is left to it, so it can take focus, place the caret or drag
584
+ if (inputPreventDefault && e.cancelable && document.hasFocus() && !isOnControl(e))
585
+ {
586
+ // this keeps focus where it is, so a click outside a text field lets it go, or it keeps the keys
587
+ const active = /** @type {HTMLElement} */ (document.activeElement);
588
+ isTextInput(active) && !active.contains(/** @type {Node} */ (e.target)) && active.blur();
589
+ e.preventDefault();
590
+ }
591
+ }
592
+ function onMouseUp(e)
593
+ {
594
+ if (touchInputEnable && inputIsTouchMouseEvent(e)) return;
595
+
596
+ // released only if it was pressed, like a key or a touch
597
+ if (inputData[0][e.button] & 1)
598
+ inputData[0][e.button] = (inputData[0][e.button]&2) | 4;
599
+
600
+ // the mouse's back and forward buttons would leave the page, like Backspace would
601
+ if (inputPreventDefault && e.cancelable && (e.button === 3 || e.button === 4))
602
+ e.preventDefault();
603
+ }
604
+ function onMouseMove(e)
605
+ {
606
+ mouseInWindow = true;
607
+ const mousePosScreenLast = mousePosScreen;
608
+ mousePosScreen = mouseEventToScreen(vec2(e.x,e.y));
609
+
610
+ // when pointer is locked use movementX/Y for delta, css pixels scaled to canvas units the way
611
+ // mouseEventToScreen maps positions, so the delta matches the unlocked one with canvasFixedSize
612
+ let movement;
613
+ if (pointerLockIsActive())
614
+ {
615
+ const rect = mainCanvas.getBoundingClientRect();
616
+ movement = vec2(e.movementX * mainCanvasSize.x / rect.width, e.movementY * mainCanvasSize.y / rect.height);
617
+ }
618
+ else
619
+ movement = mousePosScreen.subtract(mousePosScreenLast);
620
+ mouseDeltaScreen = mouseDeltaScreen.add(movement);
621
+ }
622
+ function onMouseLeave() { mouseInWindow = false; } // mouse moved off window
623
+ function onMouseWheel(e)
624
+ {
625
+ // accumulate so multiple wheel events in one frame are not lost
626
+ // a text area, list or field on the page scrolls itself, and the game does not take that wheel
627
+ const target = /** @type {HTMLElement} */ (e.target);
628
+ if (target?.closest?.('input,textarea,select,[contenteditable]'))
629
+ return;
630
+ if (!e.ctrlKey)
631
+ mouseWheel += sign(e.deltaY);
632
+ if (inputPreventDefault && e.cancelable && document.hasFocus())
633
+ e.preventDefault(); // prevent page scrolling
634
+ }
635
+ function onContextMenu(e)
636
+ {
637
+ // prevent right click menu, but a text field keeps its copy and paste menu
638
+ if (inputPreventDefault && !isTextInput(e.target))
639
+ e.preventDefault();
640
+ }
641
+ function onBlur()
642
+ {
643
+ // inputClear also releases any held virtual gamepad controls so they don't stick
644
+ inputClear();
645
+ }
646
+
647
+ // enable touch input mouse passthrough
648
+ function touchInputInit()
649
+ {
650
+ // add non passive touch event listeners
651
+ document.addEventListener('touchstart', (e)=> handleTouch(e), { passive: false });
652
+ document.addEventListener('touchmove', (e)=> handleTouch(e), { passive: false });
653
+ document.addEventListener('touchend', (e)=> handleTouch(e), { passive: false });
654
+ document.addEventListener('touchcancel', (e)=> handleTouch(e), { passive: false }); // the browser ended it
655
+
656
+ // handle all touch events the same way
657
+ function handleTouch(e)
658
+ {
659
+ if (!touchInputEnable)
660
+ {
661
+ // turned off mid touch, the finger that drove the mouse lets go of it
662
+ if (inputWasTouching && (inputData[0][0] & 1))
663
+ inputData[0][0] = inputData[0][0] & 2 | 4;
664
+ inputWasTouching = 0;
665
+ return;
666
+ }
667
+ inputLastTouchTime = performance.now();
668
+
669
+ // fix stalled audio requiring user interaction
670
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
671
+ audioContext.resume();
672
+
673
+ // when the touch gamepad is enabled it owns touch input: suppress the
674
+ // touch->mouse passthrough entirely unless touchGamepadPassthrough is set
675
+ // (its own zones drive gameplay via pointer events)
676
+ if (!touchGamepadEnable || touchGamepadPassthrough)
677
+ {
678
+ // touches that landed on a virtual gamepad zone are owned by the gamepad
679
+ // (handled by its own pointer listeners) and must not drive the game mouse
680
+ const isGamepadTouch = (t)=>
681
+ touchGamepadSideZones.includes(t.target) || t.target === touchGamepadZoneC;
682
+ const gameTouches = [];
683
+ for (const t of e.touches)
684
+ if (!isGamepadTouch(t)) gameTouches.push(t);
685
+
686
+ // check if touching and pass to mouse events
687
+ const touching = gameTouches.length;
688
+ const button = 0; // all touches are left mouse button
689
+ if (touching)
690
+ {
691
+ // only a game finger coming down presses, not one held through a clear, and it drives the mouse
692
+ const pressTouch = !inputWasTouching && e.type == 'touchstart' &&
693
+ [...e.changedTouches].find(t=> !isGamepadTouch(t));
694
+ const touch = pressTouch || gameTouches[0];
695
+
696
+ // set event pos and pass it along
697
+ const pos = vec2(touch.clientX, touch.clientY);
698
+ const mousePosScreenLast = mousePosScreen;
699
+ mousePosScreen = mouseEventToScreen(pos);
700
+ if (inputWasTouching && touch.identifier === inputTouchIdentifier)
701
+ mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
702
+ else if (pressTouch)
703
+ inputData[0][button] = 3;
704
+ inputTouchIdentifier = touch.identifier;
705
+ }
706
+ else if (inputWasTouching && (inputData[0][button] & 1))
707
+ inputData[0][button] = inputData[0][button] & 2 | 4; // released only if it was pressed
708
+
709
+ // set was touching
710
+ inputWasTouching = touching;
711
+ }
712
+
713
+ // prevent default handling like copy, magnifier lens, and scrolling, but a tap on an HTML control is
714
+ // left to it, cancelling a touch would also take away a button's click or a slider's drag
715
+ if (inputPreventDefault && e.cancelable && document.hasFocus() && !isOnControl(e))
716
+ {
717
+ // like a mouse click, a tap outside a focused text field lets it go, the cancel keeps focus where it is
718
+ const active = /** @type {HTMLElement} */ (document.activeElement);
719
+ e.type == 'touchstart' && isTextInput(active) && !active.contains(/** @type {Node} */ (e.target)) && active.blur();
720
+ e.preventDefault();
721
+ }
722
+
723
+ // must return true so the document will get focus
724
+ return true;
725
+ }
726
+
727
+ }
728
+
729
+ // convert a mouse or touch event position to screen space
730
+ function mouseEventToScreen(mousePos)
731
+ {
732
+ const rect = mainCanvas.getBoundingClientRect();
733
+ const px = percent(mousePos.x, rect.left, rect.right);
734
+ const py = percent(mousePos.y, rect.top, rect.bottom);
735
+ return vec2(px*mainCanvasSize.x, py*mainCanvasSize.y);
736
+ }
737
+ }
738
+
739
+ function inputUpdate()
740
+ {
741
+ if (headlessMode) return;
742
+
743
+ // clear input when lost focus (prevent stuck keys)
744
+ if (!(touchInputEnable && isTouchDevice) && !document.hasFocus())
745
+ inputClear();
746
+
747
+ // update mouse world space position and delta
748
+ mousePos = screenToWorld(mousePosScreen);
749
+ mouseDelta = screenToWorldDelta(mouseDeltaScreen);
750
+
751
+ // build the touch gamepad overlay lazily once enabled on a touch device
752
+ touchGamepadInit();
753
+
754
+ // update gamepads if enabled
755
+ gamepadsUpdate();
756
+
757
+ // update most recently used input device
758
+ updateLastInputDevice();
759
+
760
+ function updateLastInputDevice()
761
+ {
762
+ // mouse: any button held or moved
763
+ const mouseActive = mouseIsDown(0) || mouseIsDown(1) || mouseIsDown(2) || mouseDeltaScreen.length() > inputMouseMoveThreshold;
764
+
765
+ // gamepad: any button held or stick moved
766
+ let gamepadActive = false;
767
+ for (let s = gamepadStickCount(); s-- && !gamepadActive;)
768
+ gamepadActive = gamepadStick(s).lengthSquared() > .2;
769
+ for (let b = 17; b-- && !gamepadActive;)
770
+ gamepadActive = gamepadIsDown(b);
771
+
772
+ // keyboard: any non-mouse key down
773
+ let keyboardActive = false;
774
+ for (const k in inputData[0])
775
+ if (isNaN(+k) && (inputData[0][k] & 1))
776
+ {
777
+ keyboardActive = true;
778
+ break;
779
+ }
780
+
781
+ // update the last input
782
+ if (gamepadActive)
783
+ lastInputDevice = 'gamepad';
784
+ else if (mouseActive)
785
+ lastInputDevice = 'mouse';
786
+ else if (keyboardActive)
787
+ lastInputDevice = 'keyboard';
788
+
789
+ // set flag if gamepad is last device
790
+ isUsingGamepad = lastInputDevice === 'gamepad';
791
+ }
792
+
793
+ // gamepads are updated by engine every frame automatically
794
+ function gamepadsUpdate()
795
+ {
796
+ const deadZoneMin=.3, deadZoneMax=.8;
797
+ const applyDeadZones = (v)=>
798
+ {
799
+ const deadZone = (v)=>
800
+ v > deadZoneMin ? percent(v, deadZoneMin, deadZoneMax) :
801
+ v < -deadZoneMin ? -percent(-v, deadZoneMin, deadZoneMax) : 0;
802
+ return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
803
+ };
804
+
805
+ // the touch gamepad owns gamepad 0 once touched, until a real gamepad is used: that one takes over and the
806
+ // touch gamepad hides until the screen is touched again
807
+ if (touchGamepadEnable && isTouchDevice && touchGamepadTimer.isSet() && gamepadsEnable && inputRealGamepadUsed())
808
+ touchGamepadTimer.unset();
809
+ if (touchGamepadEnable && isTouchDevice && touchGamepadTimer.isSet())
810
+ {
811
+ // read virtual analog stick
812
+ gamepadPrimary = 0; // touch gamepad uses index 0
813
+ const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
814
+ const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
815
+ sticks.length = 0; // only report sticks that are enabled
816
+ dpad.set();
817
+ // read each side's directional stick (analog, or quantized to an 8 way dpad)
818
+ for (let side = 0; side < 2; side++)
819
+ {
820
+ if (!touchGamepadSideStick(side)) continue;
821
+ const out = touchGamepadStickOut(side);
822
+ sticks[out] = vec2();
823
+ const touchStick = touchGamepadSticks[side] ?? vec2();
824
+ if (touchGamepadAnalog)
825
+ sticks[out] = applyDeadZones(touchStick);
826
+ else if (touchStick.lengthSquared() > .3)
827
+ {
828
+ const x = clamp(round(touchStick.x), -1, 1);
829
+ const y = clamp(round(touchStick.y), -1, 1);
830
+ sticks[out] = vec2(x, -y).clampLength(); // clamp to circle
831
+ if (!out) dpad.set(x, -y); // the primary (stick 0) also drives the dpad vector
832
+ }
833
+ }
834
+
835
+ // read virtual gamepad buttons
836
+ // a press latched since the last poll counts even when the finger already lifted,
837
+ // then it reads pressed and released at once, like a quick key tap
838
+ const data = inputData[1] ?? (inputData[1] = []);
839
+ for (let i=12; i--;)
840
+ {
841
+ const wasDown = gamepadIsDown(i,0), isDown = touchGamepadButtons[i];
842
+ const pressed = touchGamepadButtonsPressed[i] || isDown && !wasDown;
843
+ data[i] = isDown ? pressed ? 3 : 1 : pressed ? 6 : wasDown ? 4 : 0;
844
+
845
+ // haptic tap when a face button or start button is first pressed
846
+ // skip stick touches (10, 11) so movement doesn't buzz
847
+ if (touchGamepadVibration && (data[i] & 2) &&
848
+ (i === 9 || touchGamepadIsFaceButton(i)))
849
+ vibrate(touchGamepadVibration);
850
+ }
851
+ touchGamepadButtonsPressed.length = 0;
852
+ gamepadButtonsLast[0] = undefined; // a real gamepad 0 starts fresh when it takes over again
853
+
854
+ // real gamepads are not read while the touch gamepad is in use
855
+ return;
856
+ }
857
+
858
+ // only poll gamepads when focused or in debug mode;
859
+ // what the last poll saw is forgotten meanwhile, a button let go while away is not released on return
860
+ if (gamepadsEnable && !debug && !document.hasFocus())
861
+ return void (gamepadButtonsLast.length = 0);
862
+
863
+ // poll gamepads; every slot is visited and a slot with no gamepad is cleared, so a refused or
864
+ // vanished gamepad does not leave its buttons held, even past the end of a shorter array
865
+ // like the one Firefox returns after the highest numbered gamepad disconnects
866
+ const maxGamepads = 8;
867
+ const gamepads = gamepadsEnable ? inputGetGamepads() : []; // disabled lets go of them like unplugged
868
+ for (let i=0; i<maxGamepads; ++i)
869
+ {
870
+ // get or create gamepad data
871
+ const gamepad = gamepads[i];
872
+ if (!gamepad)
873
+ {
874
+ // clear gamepad data if not connected
875
+ inputData[i+1] = undefined;
876
+ gamepadStickData[i] = undefined;
877
+ gamepadDpadData[i] = undefined;
878
+ gamepadHadInput[i] = undefined;
879
+ gamepadAxisCentered[i] = undefined;
880
+ gamepadButtonsLast[i] = undefined;
881
+ continue;
882
+ }
883
+
884
+ const data = inputData[i+1] ?? (inputData[i+1] = []);
885
+ const buttonsLast = gamepadButtonsLast[i] ?? (gamepadButtonsLast[i] = []);
886
+ const sticks = gamepadStickData[i] ?? (gamepadStickData[i] = []);
887
+ const dpad = gamepadDpadData[i] ?? (gamepadDpadData[i] = vec2());
888
+
889
+ // read analog sticks
890
+ // gamepads without standard mapping (steering wheels, flight sticks)
891
+ // can report axes that rest at full deflection instead of center,
892
+ // which would otherwise read as a stick held down forever, so only
893
+ // trust an axis once it has rested inside the dead zone for a moment
894
+ const isStandard = gamepad.mapping === 'standard';
895
+ const centered = gamepadAxisCentered[i] ?? (gamepadAxisCentered[i] = []);
896
+ const readAxis = (j)=>
897
+ {
898
+ const v = gamepad.axes[j];
899
+ if (isStandard && j < 4)
900
+ return v; // spec guarantees axes 0-3 are the two sticks
901
+ if (!gamepadAxisFilterEnable)
902
+ return v;
903
+
904
+ // once an axis has proven it rests at center it stays trusted,
905
+ // otherwise moving it would immediately disqualify it again
906
+ const frames = centered[j] | 0;
907
+ if (frames > gamepadAxisCenteredFrames)
908
+ return v;
909
+ centered[j] = abs(v) < deadZoneMin ? frames + 1 : 0;
910
+ return 0;
911
+ };
912
+ for (let j = 0; j < gamepad.axes.length-1; j+=2)
913
+ sticks[j>>1] = applyDeadZones(vec2(readAxis(j), readAxis(j+1)));
914
+
915
+ // read buttons; a stick pushed past halfway counts as input too, after the dead zone and the
916
+ // axis filter, so a controller can become the primary one by moving and a noisy axis cannot
917
+ let hadInput = sticks.some(stick=> stick.lengthSquared() > .25);
918
+ for (let j = gamepad.buttons.length; j--;)
919
+ {
920
+ // compare with the raw state of the last poll, not the input data an inputClear may have emptied
921
+ const button = gamepad.buttons[j];
922
+ const wasDown = buttonsLast[j];
923
+ buttonsLast[j] = button.pressed;
924
+ data[j] = button.pressed ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
925
+
926
+ // check for any input on this gamepad, analog must be full press
927
+ if (button.pressed && (!button.value || button.value > .9))
928
+ hadInput = true;
929
+ }
930
+
931
+ // set new primary gamepad if current is not connected
932
+ if (hadInput)
933
+ {
934
+ gamepadHadInput[i] = true;
935
+ if (!gamepadHadInput[gamepadPrimary])
936
+ gamepadPrimary = i;
937
+ }
938
+
939
+ if (gamepad.mapping === 'standard')
940
+ {
941
+ // get dpad buttons (standard mapping)
942
+ dpad.set(
943
+ (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
944
+ (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
945
+ }
946
+
947
+ // copy dpad to left analog stick when pressed
948
+ if (gamepadDirectionEmulateStick && (dpad.x || dpad.y))
949
+ sticks[0] = dpad.clampLength();
950
+ }
951
+
952
+ // disable touch gamepad if using real gamepad
953
+ touchGamepadEnable && isUsingGamepad && touchGamepadTimer.unset();
954
+ }
955
+ }
956
+
957
+ function inputUpdatePost()
958
+ {
959
+ if (headlessMode) return;
960
+
961
+ // clear input to prepare for next frame
962
+ for (const deviceInputData of inputData)
963
+ for (const i in deviceInputData)
964
+ deviceInputData[i] &= 1;
965
+ mouseWheel = 0;
966
+ mouseDelta = vec2();
967
+ mouseDeltaScreen = vec2();
968
+ }
969
+
970
+ function inputRender()
971
+ {
972
+ touchGamepadRender();
973
+ }
974
+
975
+ ///////////////////////////////////////////////////////////////////////////////
976
+ // Touch gamepad - full-viewport HTML/SVG overlay driven by Pointer Events
977
+
978
+ const touchGamepadSvgNS = 'http://www.w3.org/2000/svg';
979
+
980
+ // build the overlay DOM once; no-op if already built, disabled, headless, or non-touch
981
+ function touchGamepadInit()
982
+ {
983
+ if (touchGamepadOverlay || !touchGamepadEnable || !isTouchDevice || headlessMode ||
984
+ !document.body) // body may not exist yet; retry on a later frame
985
+ return;
986
+
987
+ // full-viewport overlay; only the input zones receive pointer events. The
988
+ // env() padding insets the stage out of notches / home indicators natively.
989
+ const overlay = touchGamepadOverlay = document.createElement('div');
990
+ overlay.style.cssText =
991
+ 'position:fixed;inset:0;z-index:50;pointer-events:none;opacity:0;' +
992
+ 'touch-action:none;user-select:none;-webkit-user-select:none;' +
993
+ '-webkit-touch-callout:none;transition:opacity .2s;box-sizing:border-box;' +
994
+ 'padding:env(safe-area-inset-top) env(safe-area-inset-right) ' +
995
+ 'env(safe-area-inset-bottom) env(safe-area-inset-left)';
996
+
997
+ // stage fills the padded (safe-area) content box; all controls live inside it
998
+ const stage = touchGamepadStage = document.createElement('div');
999
+ stage.style.cssText = 'position:relative;width:100%;height:100%;pointer-events:none';
1000
+ overlay.appendChild(stage);
1001
+
1002
+ // svg draws every visual and never blocks input
1003
+ const svg = touchGamepadSvg = document.createElementNS(touchGamepadSvgNS, 'svg');
1004
+ svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;' +
1005
+ 'pointer-events:none;overflow:visible;fill:none;stroke:#fff;stroke-width:3';
1006
+ stage.appendChild(svg);
1007
+
1008
+ // invisible input zones (left stick, right buttons/stick, center start)
1009
+ const makeZone = ()=>
1010
+ {
1011
+ const z = document.createElement('div');
1012
+ z.style.cssText = 'position:absolute;pointer-events:auto;touch-action:none';
1013
+ z.addEventListener('pointerdown', e=> touchGamepadPointerDown(e, z));
1014
+ z.addEventListener('pointermove', e=> touchGamepadPointerMove(e));
1015
+ z.addEventListener('pointerup', e=> touchGamepadPointerUp(e));
1016
+ z.addEventListener('pointercancel', e=> touchGamepadPointerUp(e));
1017
+ stage.appendChild(z);
1018
+ return z;
1019
+ };
1020
+ touchGamepadSideZones[0] = makeZone(); // left
1021
+ touchGamepadSideZones[1] = makeZone(); // right
1022
+ touchGamepadZoneC = makeZone(); // center/start, appended last so it sits above the sides
1023
+
1024
+ addEventListener('resize', ()=> touchGamepadNeedRelayout = true);
1025
+ document.body.appendChild(overlay);
1026
+ touchGamepadNeedRelayout = true;
1027
+ }
1028
+
1029
+ // stage-local size in CSS pixels (excludes safe-area insets)
1030
+ function touchGamepadStageRect() { return touchGamepadStage.getBoundingClientRect(); }
1031
+
1032
+ // per-side touch gamepad config (side 0 = left, 1 = right) - the left and right
1033
+ // sides behave identically, differing only in position and gamepad button indices
1034
+ function touchGamepadSideStick(side)
1035
+ { return side ? touchGamepadRightStick : touchGamepadLeftStick; }
1036
+ function touchGamepadSideButtonCount(side)
1037
+ { return side ? touchGamepadButtonCount : touchGamepadLeftButtonCount; }
1038
+ // gamepad button index a side's buttons start at (right 0-3, left 4-7)
1039
+ function touchGamepadSideButtonBase(side)
1040
+ { return side ? 0 : 4; }
1041
+ // output stick index for a side: the right stick uses stick 0 when there is no left stick
1042
+ function touchGamepadStickOut(side)
1043
+ { return side && touchGamepadLeftStick ? 1 : 0; }
1044
+ // the button a stick's touch presses, 10 for stick 0 and 11 for stick 1, following the output index
1045
+ function touchGamepadStickButton(side) { return touchGamepadStickOut(side) ? 11 : 10; }
1046
+ // a face button's place around the cluster to its gamepad index, 2 and 3 swap to match the gamepad layout
1047
+ function touchGamepadFaceIndex(b) { return b === 3 ? 2 : b === 2 ? 3 : b; }
1048
+ // true if the side has any control (a stick or at least one button)
1049
+ function touchGamepadSideHasControl(side)
1050
+ { return touchGamepadSideStick(side) || touchGamepadSideButtonCount(side) > 0; }
1051
+
1052
+ // true if gamepad button index i is an active touch gamepad face/single button
1053
+ function touchGamepadIsFaceButton(i)
1054
+ {
1055
+ for (let side = 0; side < 2; side++)
1056
+ {
1057
+ const base = touchGamepadSideButtonBase(side);
1058
+ if (!touchGamepadSideStick(side) &&
1059
+ i >= base && i < base + touchGamepadSideButtonCount(side))
1060
+ return true;
1061
+ }
1062
+ return false;
1063
+ }
1064
+
1065
+ // center of a side's controls in stage-local CSS pixels (stick rest / button cluster)
1066
+ // returns the floating stick anchor when that side is an active floating stick
1067
+ function touchGamepadSideCenter(side, W, H)
1068
+ {
1069
+ if (touchGamepadFloating && touchGamepadSideStick(side) && touchGamepadStickAnchors[side])
1070
+ return touchGamepadStickAnchors[side];
1071
+ let y = H - touchGamepadSize;
1072
+ const count = touchGamepadSideButtonCount(side);
1073
+ if (!touchGamepadSideStick(side) && (count === 2 || count === 3))
1074
+ y -= touchGamepadSize/4; // nudge a 2/3 button cluster up a bit
1075
+ return vec2(side ? W - touchGamepadSize : touchGamepadSize, y);
1076
+ }
1077
+
1078
+ // position the input zones for the current mode and rebuild the SVG visuals
1079
+ function touchGamepadRelayout()
1080
+ {
1081
+ if (!touchGamepadOverlay) return;
1082
+ const r = touchGamepadStageRect();
1083
+ const W = r.width, H = r.height, S = touchGamepadSize;
1084
+ const setZone = (z, css)=> z.style.cssText =
1085
+ 'position:absolute;pointer-events:auto;touch-action:none;' + css;
1086
+
1087
+ if (paused)
1088
+ {
1089
+ // the gamepad is hidden while paused, so its side zones must not capture
1090
+ // touches - otherwise they silently steal taps from menus and dialogs
1091
+ for (const zone of touchGamepadSideZones) zone.style.display = 'none';
1092
+ if (touchGamepadCenterButtonSize)
1093
+ {
1094
+ // any touch presses start
1095
+ setZone(touchGamepadZoneC, 'inset:0');
1096
+ touchGamepadZoneC.style.display = '';
1097
+ }
1098
+ else
1099
+ touchGamepadZoneC.style.display = 'none';
1100
+ }
1101
+ else
1102
+ {
1103
+ // position each side zone (left/right differ only by which edge they hug)
1104
+ for (let side = 0; side < 2; side++)
1105
+ {
1106
+ const zone = touchGamepadSideZones[side], edge = side ? 'right' : 'left';
1107
+ zone.style.display = touchGamepadSideHasControl(side) ? '' : 'none';
1108
+ if (touchGamepadFloating && touchGamepadSideStick(side))
1109
+ {
1110
+ // bottom 60% grabs the stick; the top 40% passes through. A side with no
1111
+ // control on the other side uses the full width (matching the hit-test)
1112
+ const width = touchGamepadSideHasControl(side ? 0 : 1) ? '50%' : '100%';
1113
+ setZone(zone, `${edge}:0;bottom:0;width:${width};height:60%`);
1114
+ }
1115
+ else // fixed, and face buttons which never float: a compact box hugging the corner control
1116
+ setZone(zone, `${edge}:0;bottom:0;width:${3*S}px;height:${3*S}px`);
1117
+ }
1118
+ touchGamepadZoneC.style.display = touchGamepadCenterButtonSize ? '' : 'none';
1119
+ const c = touchGamepadCenterButtonSize;
1120
+ setZone(touchGamepadZoneC,
1121
+ `left:50%;top:50%;width:${2*c}px;height:${2*c}px;transform:translate(-50%,-50%)`);
1122
+ }
1123
+
1124
+ touchGamepadBuildSvg(W, H);
1125
+ touchGamepadNeedRelayout = false;
1126
+ }
1127
+
1128
+ // (re)build the SVG shapes for the current layout; dynamic bits update per-frame
1129
+ function touchGamepadBuildSvg(W, H)
1130
+ {
1131
+ const svg = touchGamepadSvg;
1132
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
1133
+ const els = touchGamepadSvgEls = { face: [], thumb: [] };
1134
+ const S = touchGamepadSize;
1135
+ const circle = (cx, cy, rr, fill)=>
1136
+ {
1137
+ const c = document.createElementNS(touchGamepadSvgNS, 'circle');
1138
+ c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', rr);
1139
+ if (fill) c.setAttribute('fill', fill);
1140
+ svg.appendChild(c);
1141
+ return c;
1142
+ };
1143
+ const cross = (ctr)=>
1144
+ {
1145
+ // plus-shaped dpad outline centered at ctr
1146
+ const a = S*.18, b = S*.5, x = ctr.x, y = ctr.y;
1147
+ const p = document.createElementNS(touchGamepadSvgNS, 'path');
1148
+ p.setAttribute('d',
1149
+ `M ${x-a} ${y-b} H ${x+a} V ${y-a} H ${x+b} V ${y+a} H ${x+a} ` +
1150
+ `V ${y+b} H ${x-a} V ${y+a} H ${x-b} V ${y-a} H ${x-a} Z`);
1151
+ svg.appendChild(p);
1152
+ };
1153
+
1154
+ // draw each side: a directional stick, a single large button, or face buttons
1155
+ for (let side = 0; side < 2; side++)
1156
+ {
1157
+ const count = touchGamepadSideButtonCount(side);
1158
+ const base = touchGamepadSideButtonBase(side);
1159
+ const ctr = touchGamepadSideCenter(side, W, H);
1160
+ if (touchGamepadSideStick(side))
1161
+ {
1162
+ // directional stick (circle or cross) with a thumb dot that moves per-frame
1163
+ if (touchGamepadAnalog) circle(ctr.x, ctr.y, S/2); else cross(ctr);
1164
+ els.thumb[side] = circle(ctr.x, ctr.y, S/4, '#fff');
1165
+ }
1166
+ else if (count === 1)
1167
+ els.face[base] = circle(ctr.x, ctr.y, S/2, '#000'); // single large button
1168
+ else for (let i = 0; i < count; i++)
1169
+ {
1170
+ const j = mod(i-1, 4);
1171
+ let button = count > 2 ? j : min(j, count-1);
1172
+ button = touchGamepadFaceIndex(button);
1173
+ const offset = vec2().setDirection(j, S/2);
1174
+ if (count === 2) offset.x *= -1;
1175
+ // left side mirrors the right layout's positions, keeping indices in order
1176
+ // (e.g. 2 buttons -> button 4 at bottom, button 5 at left)
1177
+ if (!side) offset.x *= -1;
1178
+ const pos = ctr.add(offset);
1179
+ els.face[base + button] = circle(pos.x, pos.y, S/4, '#000');
1180
+ }
1181
+ }
1182
+
1183
+ // debug: draw the proximity hit regions the hit-test actually uses
1184
+ if (debug && debugGamepads) touchGamepadBuildDebug(W, H);
1185
+ }
1186
+
1187
+ // draw debug outlines of the touch control hit regions into the overlay svg
1188
+ function touchGamepadBuildDebug(W, H)
1189
+ {
1190
+ const S = touchGamepadSize, svg = touchGamepadSvg;
1191
+ const shape = (tag, attrs, stroke)=>
1192
+ {
1193
+ const el = document.createElementNS(touchGamepadSvgNS, tag);
1194
+ for (const k in attrs) el.setAttribute(k, attrs[k]);
1195
+ el.setAttribute('stroke', stroke);
1196
+ el.setAttribute('stroke-width', 2);
1197
+ el.setAttribute('fill', 'none');
1198
+ svg.appendChild(el);
1199
+ };
1200
+ const ring = (c, rr, stroke)=> shape('circle', {cx:c.x, cy:c.y, r:rr}, stroke);
1201
+
1202
+ // green line: the left/right split that assigns a stick press to a side
1203
+ shape('line', {x1:W/2, y1:0, x2:W/2, y2:H}, '#0f0');
1204
+
1205
+ // cyan: where each side's control can be grabbed
1206
+ for (let side = 0; side < 2; side++)
1207
+ {
1208
+ if (touchGamepadSideStick(side))
1209
+ {
1210
+ if (touchGamepadFloating)
1211
+ {
1212
+ // grab region: this side's half (or the full width if the other side is empty)
1213
+ const top = H*.4, full = !touchGamepadSideHasControl(side ? 0 : 1);
1214
+ const x = full ? 0 : (side ? W/2 : 0);
1215
+ shape('rect', {x, y:top, width:full ? W : W/2, height:H-top}, '#0ff');
1216
+ }
1217
+ else
1218
+ ring(touchGamepadSideCenter(side, W, H), 2*S, '#0ff');
1219
+ }
1220
+ else if (touchGamepadSideButtonCount(side) >= 1)
1221
+ ring(touchGamepadSideCenter(side, W, H), S, '#0ff'); // face / single-button radius
1222
+ }
1223
+
1224
+ // yellow: start button radius; magenta: where start is blocked (near a control)
1225
+ if (touchGamepadCenterButtonSize)
1226
+ {
1227
+ ring(vec2(W/2, H/2), touchGamepadCenterButtonSize, '#ff0');
1228
+ for (let side = 0; side < 2; side++)
1229
+ if (touchGamepadStartBlockSide(side))
1230
+ ring(touchGamepadSideCenter(side, W, H), 2*S, '#f0f');
1231
+ }
1232
+ }
1233
+
1234
+ // per-frame: fade the overlay and move the thumbs / set pressed states
1235
+ function touchGamepadRender()
1236
+ {
1237
+ if (!touchGamepadOverlay || headlessMode) return;
1238
+
1239
+ // hide and bail if disabled at runtime (overlay stays in the DOM for reuse)
1240
+ // display:none also takes the input zones out of hit-testing so touches are
1241
+ // not silently captured away from the game while disabled
1242
+ if (!touchGamepadEnable || !isTouchDevice)
1243
+ {
1244
+ if (touchGamepadOverlay.style.display !== 'none')
1245
+ {
1246
+ // just disabled: hide the overlay and release any held controls, and the gamepad slot they
1247
+ // fed, which nothing else rewrites while real gamepads are off
1248
+ touchGamepadOverlay.style.display = 'none';
1249
+ touchGamepadPointerRole.clear();
1250
+ touchGamepadButtons.length = 0;
1251
+ touchGamepadButtonsPressed.length = 0;
1252
+ touchGamepadSticks.length = 0;
1253
+ touchGamepadStickPointerId.length = 0;
1254
+ inputData[1] = undefined;
1255
+ gamepadStickData[0] = gamepadDpadData[0] = undefined;
1256
+ }
1257
+ return;
1258
+ }
1259
+ touchGamepadOverlay.style.display = '';
1260
+
1261
+ // relayout when the paused state, a layout setting, or the debug view changes
1262
+ const dbg = debug && debugGamepads;
1263
+ const layout = [touchGamepadButtonCount, touchGamepadLeftButtonCount, touchGamepadLeftStick,
1264
+ touchGamepadRightStick, touchGamepadAnalog, touchGamepadSize, touchGamepadFloating,
1265
+ touchGamepadCenterButtonSize, paused, dbg].join();
1266
+ if (layout !== touchGamepadLastLayout)
1267
+ {
1268
+ touchGamepadLastLayout = layout;
1269
+ touchGamepadNeedRelayout = true;
1270
+ }
1271
+ // relayout before the visibility bail-out so the paused full-screen start zone applies
1272
+ if (touchGamepadNeedRelayout) touchGamepadRelayout();
1273
+
1274
+ // fade out when idle (always show when displayTime is 0, or while debugging), a control held is in use,
1275
+ // its release sets the timer the fade counts from
1276
+ const fade = touchGamepadDisplayTime && !touchGamepadPointerRole.size ?
1277
+ percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
1278
+ const visible = dbg || (touchGamepadTimer.isSet() && fade > 0 && !paused);
1279
+ touchGamepadOverlay.style.opacity = !visible ? 0 : dbg ? 1 : fade*touchGamepadAlpha;
1280
+ if (!visible) return;
1281
+
1282
+ const r = touchGamepadStageRect();
1283
+ const W = r.width, H = r.height, S = touchGamepadSize;
1284
+ const els = touchGamepadSvgEls;
1285
+ if (!els) return;
1286
+
1287
+ for (let side = 0; side < 2; side++)
1288
+ if (touchGamepadSideStick(side) && els.thumb[side])
1289
+ {
1290
+ const ctr = touchGamepadSideCenter(side, W, H);
1291
+ const t = ctr.add((touchGamepadSticks[side] ?? vec2()).scale(S/2));
1292
+ els.thumb[side].setAttribute('cx', t.x);
1293
+ els.thumb[side].setAttribute('cy', t.y);
1294
+ }
1295
+ for (let i = 0; i < els.face.length; i++)
1296
+ if (els.face[i])
1297
+ els.face[i].setAttribute('fill', touchGamepadButtons[i] ? '#fff' : '#000');
1298
+ }
1299
+
1300
+ // convert a pointer event to stage-local CSS pixels
1301
+ function touchGamepadEventPos(e)
1302
+ {
1303
+ const r = touchGamepadStageRect();
1304
+ return vec2(e.clientX - r.left, e.clientY - r.top);
1305
+ }
1306
+
1307
+ // set a directional stick from a stage-local point and flag its stick-touch button
1308
+ // (stick 0 press = button 10, stick 1 press = button 11, following the output index)
1309
+ function touchGamepadApplyStick(side, p)
1310
+ {
1311
+ const delta = p.subtract(touchGamepadStickAnchors[side]);
1312
+ touchGamepadSticks[side] = delta.scale(2/touchGamepadSize).clampLength();
1313
+ touchGamepadButtons[touchGamepadStickButton(side)] = 1;
1314
+ }
1315
+
1316
+ // pick a side's gamepad button index from a stage-local point, or -1 if outside the cluster
1317
+ function touchGamepadFaceButtonAt(side, p, W, H)
1318
+ {
1319
+ const count = touchGamepadSideButtonCount(side);
1320
+ const base = touchGamepadSideButtonBase(side);
1321
+ const bc = touchGamepadSideCenter(side, W, H);
1322
+ if (bc.distance(p) >= touchGamepadSize) return -1;
1323
+ if (count === 1) return base; // single large button
1324
+ const d = bc.subtract(p);
1325
+ if (!side) d.x *= -1; // left side mirrors the right layout's positions horizontally
1326
+ let button = count === 2 ? (d.x < d.y ? 1 : 0) : mod(d.direction()+2, 4);
1327
+ button = touchGamepadFaceIndex(button);
1328
+ return button < count ? base + button : -1;
1329
+ }
1330
+
1331
+ // pick which control a stage-local press activates, by priority then proximity,
1332
+ // independent of which zone element captured it - so overlapping zones on small
1333
+ // screens resolve to the nearest control instead of whichever zone is topmost
1334
+ // returns {role:'stick', side} or {role:'face', btn} or {role:'start'} or undefined
1335
+ function touchGamepadControlAt(p, W, H)
1336
+ {
1337
+ const S = touchGamepadSize;
1338
+ const leftHalf = p.x < W/2;
1339
+ const floatTop = H*.4; // floating grab region is the bottom 60% of the screen
1340
+
1341
+ // center start button first, a floating stick's region always covers it; blocked within 2*size of a fixed
1342
+ // control so drift off a control can't accidentally fire start (matches the original exclusion logic)
1343
+ if (touchGamepadCenterButtonSize && vec2(W/2, H/2).distance(p) < touchGamepadCenterButtonSize &&
1344
+ !touchGamepadStartBlockedAt(p, W, H))
1345
+ return {role:'start'};
1346
+
1347
+ // check each side (left first for priority); a side is a stick or buttons
1348
+ for (let side = 0; side < 2; side++)
1349
+ {
1350
+ const onHalf = side ? !leftHalf : leftHalf;
1351
+ if (touchGamepadSideStick(side))
1352
+ {
1353
+ // a side with no control on the other side uses the full width
1354
+ const otherControl = touchGamepadSideHasControl(side ? 0 : 1);
1355
+ const grab = touchGamepadFloating ?
1356
+ (!otherControl || onHalf) && p.y > floatTop :
1357
+ onHalf && touchGamepadSideCenter(side, W, H).distance(p) < 2*S;
1358
+ if (grab) return {role:'stick', side};
1359
+ }
1360
+ else if (touchGamepadSideButtonCount(side) >= 1)
1361
+ {
1362
+ const btn = touchGamepadFaceButtonAt(side, p, W, H);
1363
+ if (btn >= 0) return {role:'face', btn};
1364
+ }
1365
+ }
1366
+ }
1367
+
1368
+ // true if a press is too near a fixed control for the start button, a floating stick has no fixed place
1369
+ function touchGamepadStartBlockedAt(p, W, H)
1370
+ {
1371
+ for (let side = 0; side < 2; side++)
1372
+ if (touchGamepadStartBlockSide(side) && touchGamepadSideCenter(side, W, H).distance(p) < 2*touchGamepadSize)
1373
+ return true;
1374
+ return false;
1375
+ }
1376
+ function touchGamepadStartBlockSide(side)
1377
+ { return touchGamepadSideHasControl(side) && !(touchGamepadFloating && touchGamepadSideStick(side)); }
1378
+
1379
+ function touchGamepadPointerDown(e, zone)
1380
+ {
1381
+ // a mouse on a touchscreen laptop clicks the game, the zones are there even while the gamepad is hidden
1382
+ if (!touchGamepadEnable || e.pointerType === 'mouse') return;
1383
+ e.preventDefault();
1384
+ zone.setPointerCapture(e.pointerId);
1385
+ touchGamepadTimer.set();
1386
+
1387
+ // resume audio on first interaction
1388
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
1389
+ audioContext.resume();
1390
+
1391
+ // while paused, any touch is the start button; a control belongs to the first finger on it until that
1392
+ // finger lifts, so a second finger landing on the same one neither takes it over nor lets it go
1393
+ if (paused)
1394
+ {
1395
+ if (touchGamepadCenterButtonSize && !touchGamepadButtons[9])
1396
+ {
1397
+ touchGamepadButtons[9] = touchGamepadButtonsPressed[9] = 1;
1398
+ touchGamepadPointerRole.set(e.pointerId, 'start');
1399
+ }
1400
+ return;
1401
+ }
1402
+
1403
+ const r = touchGamepadStageRect();
1404
+ const W = r.width, H = r.height;
1405
+ const p = vec2(e.clientX - r.left, e.clientY - r.top);
1406
+
1407
+ // choose the control by proximity/priority, not by which zone captured the touch
1408
+ const hit = touchGamepadControlAt(p, W, H);
1409
+ if (!hit) return;
1410
+ if (hit.role === 'stick')
1411
+ {
1412
+ const side = hit.side;
1413
+ if (touchGamepadStickPointerId[side] !== undefined) return; // another finger has the stick
1414
+ touchGamepadStickAnchors[side] = touchGamepadFloating ? p : touchGamepadSideCenter(side, W, H);
1415
+ touchGamepadStickPointerId[side] = e.pointerId;
1416
+ touchGamepadPointerRole.set(e.pointerId, 'stick'+side);
1417
+ touchGamepadNeedRelayout = true; // base may have re-anchored
1418
+ touchGamepadApplyStick(side, p);
1419
+ touchGamepadButtonsPressed[touchGamepadStickButton(side)] = 1; // the stick's own press, latched
1420
+ }
1421
+ else if (hit.role === 'face')
1422
+ {
1423
+ if (touchGamepadButtons[hit.btn]) return; // another finger holds the button
1424
+ touchGamepadButtons[hit.btn] = touchGamepadButtonsPressed[hit.btn] = 1;
1425
+ touchGamepadPointerRole.set(e.pointerId, 'face'+hit.btn);
1426
+ }
1427
+ else // 'start'
1428
+ {
1429
+ if (touchGamepadButtons[9]) return;
1430
+ touchGamepadButtons[9] = touchGamepadButtonsPressed[9] = 1;
1431
+ touchGamepadPointerRole.set(e.pointerId, 'start');
1432
+ }
1433
+ }
1434
+
1435
+ function touchGamepadPointerMove(e)
1436
+ {
1437
+ const role = touchGamepadPointerRole.get(e.pointerId);
1438
+ if (!role) return;
1439
+ e.preventDefault();
1440
+ const p = touchGamepadEventPos(e);
1441
+ if (role === 'stick0' || role === 'stick1')
1442
+ touchGamepadApplyStick(role === 'stick1' ? 1 : 0, p);
1443
+ // face buttons & start are held until release (no slide-between this pass)
1444
+ }
1445
+
1446
+ function touchGamepadPointerUp(e)
1447
+ {
1448
+ const role = touchGamepadPointerRole.get(e.pointerId);
1449
+ if (!role) return;
1450
+ touchGamepadPointerRole.delete(e.pointerId);
1451
+ if (role === 'stick0' || role === 'stick1')
1452
+ {
1453
+ const side = role === 'stick1' ? 1 : 0;
1454
+ touchGamepadStickPointerId[side] = undefined;
1455
+ touchGamepadSticks[side] = vec2();
1456
+ delete touchGamepadButtons[touchGamepadStickButton(side)];
1457
+ }
1458
+ else if (role === 'start')
1459
+ delete touchGamepadButtons[9];
1460
+ else // 'face<n>'
1461
+ delete touchGamepadButtons[+role.slice(4)];
1462
+ touchGamepadTimer.set();
1294
1463
  }