littlejsengine 1.15.2 → 1.15.8

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 (42) hide show
  1. package/dist/littlejs.d.ts +190 -63
  2. package/dist/littlejs.esm.js +1267 -605
  3. package/dist/littlejs.esm.min.js +1 -1
  4. package/dist/littlejs.js +1261 -604
  5. package/dist/littlejs.min.js +1 -1
  6. package/dist/littlejs.release.js +1179 -552
  7. package/examples/electron/index.html +2 -2
  8. package/examples/index.html +4 -3
  9. package/examples/particles/index.html +22 -6
  10. package/examples/shorts/base.html +2 -1
  11. package/examples/shorts/box2dCar.js +1 -1
  12. package/examples/shorts/box2dPool.js +9 -10
  13. package/examples/shorts/empty.js +1 -1
  14. package/examples/shorts/input.js +30 -25
  15. package/examples/shorts/music.js +1 -0
  16. package/examples/shorts/musicPlayer.js +1 -0
  17. package/examples/shorts/sequencer.js +2 -0
  18. package/examples/shorts/sound.js +1 -0
  19. package/examples/shorts/tileLayer.js +2 -2
  20. package/examples/shorts/tiles.png +0 -0
  21. package/examples/shorts/timers.js +1 -0
  22. package/examples/shorts/uiSystem.js +26 -14
  23. package/examples/shorts/videoPlayer.js +2 -1
  24. package/examples/starter/index.html +3 -2
  25. package/examples/uiSystem/game.js +28 -15
  26. package/package.json +1 -1
  27. package/plugins/box2d.js +1 -1
  28. package/plugins/desktop.ini +2 -0
  29. package/plugins/pluginExport.js +2 -0
  30. package/plugins/uiSystem.js +503 -91
  31. package/src/engine.js +2 -169
  32. package/src/engineAudio.js +0 -1
  33. package/src/engineBuild.js +1 -0
  34. package/src/engineDebug.js +84 -54
  35. package/src/engineDraw.js +81 -40
  36. package/src/engineExport.js +4 -1
  37. package/src/engineInput.js +526 -381
  38. package/src/engineObject.js +27 -23
  39. package/src/engineSettings.js +11 -6
  40. package/src/engineSplash.js +173 -0
  41. package/src/engineTileLayer.js +3 -3
  42. package/src/engineUtilities.js +17 -1
@@ -33,7 +33,7 @@ const engineName = 'LittleJS';
33
33
  * @type {string}
34
34
  * @default
35
35
  * @memberof Engine */
36
- const engineVersion = '1.15.2';
36
+ const engineVersion = '1.15.8';
37
37
 
38
38
  /** Frames per second to update
39
39
  * @type {number}
@@ -279,7 +279,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
279
279
  o.destroyed || o.render();
280
280
  gameRenderPost();
281
281
  pluginList.forEach(plugin=>plugin.render?.());
282
- touchGamepadRender();
282
+ inputRender();
283
283
  debugRender();
284
284
  glFlush();
285
285
  debugVideoCaptureUpdate();
@@ -600,7 +600,7 @@ function drawEngineSplashScreen(t)
600
600
  C ? x.fill() : x.stroke();
601
601
  };
602
602
  const color = (c=0, l=0) =>
603
- hsl([.98,.3,.57,.14][c%4]-10,.8,[0,.3,.5,.8,.9][l]).toString();
603
+ hsl([.98,.3,.57,.14][c%4],.8,[0,.3,.5,.8,.9][l]).toString();
604
604
  const alpha = wave(1,1,t);
605
605
  const p = percent(alpha, .1, .5);
606
606
 
@@ -1383,9 +1383,15 @@ class Vector2
1383
1383
  {
1384
1384
  this.x = x;
1385
1385
  this.y = y;
1386
+ ASSERT_VECTOR2_VALID(this);
1386
1387
  return this;
1387
1388
  }
1388
1389
 
1390
+ /** Sets this vector from another vector and returns self
1391
+ * @param {Vector2} v - other vector
1392
+ * @return {Vector2} */
1393
+ setFrom(v) { return this.set(v.x, v.y); }
1394
+
1389
1395
  /** Returns a new vector that is a copy of this
1390
1396
  * @return {Vector2} */
1391
1397
  copy() { return new Vector2(this.x, this.y); }
@@ -1448,7 +1454,7 @@ class Vector2
1448
1454
  clampLength(length=1)
1449
1455
  {
1450
1456
  const l = this.length();
1451
- return l > length ? this.scale(length/l) : this;
1457
+ return l > length ? this.scale(length/l) : this.copy();
1452
1458
  }
1453
1459
 
1454
1460
  /** Returns the dot product of this and the vector passed in
@@ -1535,6 +1541,10 @@ class Vector2
1535
1541
  * @return {number} */
1536
1542
  area() { return abs(this.x * this.y); }
1537
1543
 
1544
+ /** Returns true if this vector is (0,0)
1545
+ * @return {boolean} */
1546
+ isZero() { return !this.x && !this.y; }
1547
+
1538
1548
  /** Returns a new vector that is p percent between this and the vector passed in
1539
1549
  * @param {Vector2} v - other vector
1540
1550
  * @param {number} percent
@@ -1645,9 +1655,15 @@ class Color
1645
1655
  this.g = g;
1646
1656
  this.b = b;
1647
1657
  this.a = a;
1658
+ ASSERT_COLOR_VALID(this);
1648
1659
  return this;
1649
1660
  }
1650
1661
 
1662
+ /** Sets this color from another color and returns self
1663
+ * @param {Color} c - other color
1664
+ * @return {Color} */
1665
+ setFrom(c) { return this.set(c.r, c.g, c.b, c.a); }
1666
+
1651
1667
  /** Returns a new color that is a copy of this
1652
1668
  * @return {Color} */
1653
1669
  copy() { return new Color(this.r, this.g, this.b, this.a); }
@@ -2199,17 +2215,17 @@ let touchGamepadEnable = false;
2199
2215
  * @memberof Settings */
2200
2216
  let touchGamepadCenterButton = true;
2201
2217
 
2202
- /** True if touch gamepad should be analog stick or false to use if 8 way dpad
2203
- * @type {boolean}
2218
+ /** Number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
2219
+ * @type {number}
2204
2220
  * @default
2205
2221
  * @memberof Settings */
2206
- let touchGamepadAnalog = true;
2222
+ let touchGamepadButtonCount = 4;
2207
2223
 
2208
- /** Number of buttons on touch gamepad
2209
- * @type {number}
2224
+ /** True if touch gamepad should be analog stick or false to use if 8 way dpad
2225
+ * @type {boolean}
2210
2226
  * @default
2211
2227
  * @memberof Settings */
2212
- let touchGamepadButtonCount = 4;
2228
+ let touchGamepadAnalog = true;
2213
2229
 
2214
2230
  /** Size of virtual gamepad for touch devices in pixels
2215
2231
  * @type {number}
@@ -2475,6 +2491,11 @@ function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
2475
2491
  * @memberof Settings */
2476
2492
  function setTouchGamepadCenterButton(enable) { touchGamepadCenterButton = enable; }
2477
2493
 
2494
+ /** Set number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
2495
+ * @param {number} count
2496
+ * @memberof Settings */
2497
+ function setTouchGamepadButtonCount(count) { touchGamepadButtonCount = count; }
2498
+
2478
2499
  /** Set if touch gamepad should be analog stick or 8 way dpad
2479
2500
  * @param {boolean} analog
2480
2501
  * @memberof Settings */
@@ -2683,7 +2704,7 @@ class EngineObject
2683
2704
  child.updateTransforms();
2684
2705
  }
2685
2706
 
2686
- /** Update the object physics, called automatically by engine once each frame */
2707
+ /** Update the object physics, called automatically by engine once each frame. Can be overridden to stop or change how physics works for an object. */
2687
2708
  updatePhysics()
2688
2709
  {
2689
2710
  // child objects do not have physics
@@ -2716,7 +2737,7 @@ class EngineObject
2716
2737
  if (!enablePhysicsSolver || !this.mass) // don't do collision for static objects
2717
2738
  return;
2718
2739
 
2719
- const wasMovingDown = this.velocity.y < 0;
2740
+ const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
2720
2741
  if (this.groundObject)
2721
2742
  {
2722
2743
  // apply friction in local space of ground object
@@ -2772,10 +2793,10 @@ class EngineObject
2772
2793
  {
2773
2794
  // push outside object collision
2774
2795
  this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y);
2775
- if ((o.groundObject && wasMovingDown) || !o.mass)
2796
+ if ((o.groundObject && wasFalling) || !o.mass)
2776
2797
  {
2777
2798
  // set ground object if landed on something
2778
- if (wasMovingDown)
2799
+ if (wasFalling)
2779
2800
  this.groundObject = o;
2780
2801
 
2781
2802
  // bounce if other object is fixed or grounded
@@ -2862,12 +2883,15 @@ class EngineObject
2862
2883
  const restitution = max(this.restitution, hitLayer.restitution);
2863
2884
  this.velocity.y *= -restitution;
2864
2885
 
2865
- if (wasMovingDown)
2886
+ if (wasFalling)
2866
2887
  {
2867
- // adjust position to slightly above nearest tile boundary
2888
+ // adjust position to slightly away from nearest tile
2868
2889
  // this prevents gap between object and ground
2869
2890
  const epsilon = .0001;
2870
- this.pos.y = (oldPos.y-this.size.y/2|0)+this.size.y/2+epsilon;
2891
+ const offset = this.size.y/2 + epsilon;
2892
+ this.pos.y = gravity.y < 0 ?
2893
+ floor(oldPos.y-this.size.y/2) + offset :
2894
+ ceil( oldPos.y+this.size.y/2) - offset;
2871
2895
 
2872
2896
  // set ground object for tile collision
2873
2897
  this.groundObject = hitLayer;
@@ -2997,7 +3021,9 @@ class EngineObject
2997
3021
  {
2998
3022
  ASSERT(child.parent === this && this.children.includes(child));
2999
3023
  ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
3000
- this.children.splice(this.children.indexOf(child), 1);
3024
+ const index = this.children.indexOf(child);
3025
+ ASSERT(index >= 0, 'child not found in children array');
3026
+ index >= 0 && this.children.splice(index, 1);
3001
3027
  child.parent = 0;
3002
3028
  }
3003
3029
 
@@ -3034,21 +3060,20 @@ class EngineObject
3034
3060
  * @return {string} */
3035
3061
  toString()
3036
3062
  {
3037
- if (debug)
3038
- {
3039
- let text = 'type = ' + this.constructor.name;
3040
- if (this.pos.x || this.pos.y)
3041
- text += '\npos = ' + this.pos;
3042
- if (this.velocity.x || this.velocity.y)
3043
- text += '\nvelocity = ' + this.velocity;
3044
- if (this.size.x || this.size.y)
3045
- text += '\nsize = ' + this.size;
3046
- if (this.angle)
3047
- text += '\nangle = ' + this.angle.toFixed(3);
3048
- if (this.color)
3049
- text += '\ncolor = ' + this.color;
3050
- return text;
3051
- }
3063
+ if (!debug) return;
3064
+
3065
+ let text = 'type = ' + this.constructor.name;
3066
+ if (this.pos.x || this.pos.y)
3067
+ text += '\npos = ' + this.pos;
3068
+ if (this.velocity.x || this.velocity.y)
3069
+ text += '\nvelocity = ' + this.velocity;
3070
+ if (this.size.x || this.size.y)
3071
+ text += '\nsize = ' + this.size;
3072
+ if (this.angle)
3073
+ text += '\nangle = ' + this.angle.toFixed(3);
3074
+ if (this.color)
3075
+ text += '\ncolor = ' + this.color;
3076
+ return text;
3052
3077
  }
3053
3078
 
3054
3079
  /** Render debug info for this object */
@@ -3217,7 +3242,7 @@ class TileInfo
3217
3242
  this.padding = padding;
3218
3243
  /** @property {TextureInfo} - The texture info for this tile */
3219
3244
  this.textureInfo = textureInfos[this.textureIndex];
3220
- /** @property {float} - Shrinks tile by this many pixels to prevent neighbors bleeding */
3245
+ /** @property {number} - Shrinks tile by this many pixels to prevent neighbors bleeding */
3221
3246
  this.bleedScale = bleedScale;
3222
3247
  }
3223
3248
 
@@ -3316,15 +3341,11 @@ function drawTile(pos, size=new Vector2(1), tileInfo, color=WHITE,
3316
3341
 
3317
3342
  const textureInfo = tileInfo && tileInfo.textureInfo;
3318
3343
  const bleedScale = tileInfo ? tileInfo.bleedScale : 0;
3319
- if (useWebGL)
3344
+ if (useWebGL && glEnable)
3320
3345
  {
3321
3346
  ASSERT(!!glContext, 'WebGL is not enabled!');
3322
3347
  if (screenSpace)
3323
- {
3324
- // convert to world space
3325
- pos = screenToWorld(pos);
3326
- size = size.scale(1/cameraScale);
3327
- }
3348
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
3328
3349
  if (textureInfo)
3329
3350
  {
3330
3351
  // calculate uvs and render
@@ -3412,7 +3433,8 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
3412
3433
  ASSERT(isColor(colorTop) && isColor(colorBottom), 'color is invalid');
3413
3434
  ASSERT(isNumber(angle), 'angle must be a number');
3414
3435
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
3415
- if (useWebGL)
3436
+
3437
+ if (useWebGL && glEnable)
3416
3438
  {
3417
3439
  ASSERT(!!glContext, 'WebGL is not enabled!');
3418
3440
  if (screenSpace)
@@ -3420,6 +3442,7 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
3420
3442
  // convert to world space
3421
3443
  pos = screenToWorld(pos);
3422
3444
  size = size.scale(1/cameraScale);
3445
+ angle += cameraAngle;
3423
3446
  }
3424
3447
  // build 4 corner points for the rectangle
3425
3448
  const points = [], colors = [];
@@ -3475,17 +3498,14 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
3475
3498
  ASSERT(isVector2(pos), 'pos must be a vec2');
3476
3499
  ASSERT(isNumber(angle), 'angle must be a number');
3477
3500
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
3478
- if (useWebGL)
3501
+
3502
+ if (useWebGL && glEnable)
3479
3503
  {
3480
3504
  ASSERT(!!glContext, 'WebGL is not enabled!');
3481
- let scale = 1;
3505
+ let size = vec2(1);
3482
3506
  if (screenSpace)
3483
- {
3484
- // convert to world space
3485
- pos = screenToWorld(pos);
3486
- scale = 1/cameraScale;
3487
- }
3488
- glDrawOutlineTransform(points, color.rgbaInt(), width, pos.x, pos.y, scale, scale, angle, wrap);
3507
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
3508
+ glDrawOutlineTransform(points, color.rgbaInt(), width, pos.x, pos.y, size.x, size.y, angle, wrap);
3489
3509
  }
3490
3510
  else
3491
3511
  {
@@ -3581,19 +3601,15 @@ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(),
3581
3601
  ASSERT(isNumber(angle), 'angle must be a number');
3582
3602
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
3583
3603
 
3584
- if (useWebGL)
3604
+ if (useWebGL && glEnable)
3585
3605
  {
3586
3606
  ASSERT(!!glContext, 'WebGL is not enabled!');
3587
- let scale = 1;
3607
+ let size = vec2(1);
3588
3608
  if (screenSpace)
3589
- {
3590
- // convert to world space
3591
- pos = screenToWorld(pos);
3592
- scale = 1/cameraScale;
3593
- }
3594
- glDrawPointsTransform(points, color.rgbaInt(), pos.x, pos.y, scale, scale, angle);
3609
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
3610
+ glDrawPointsTransform(points, color.rgbaInt(), pos.x, pos.y, size.x, size.y, angle);
3595
3611
  if (lineWidth > 0)
3596
- glDrawOutlineTransform(points, lineColor.rgbaInt(), lineWidth, pos.x, pos.y, scale, scale, angle);
3612
+ glDrawOutlineTransform(points, lineColor.rgbaInt(), lineWidth, pos.x, pos.y, size.x, size.y, angle);
3597
3613
  }
3598
3614
  else
3599
3615
  {
@@ -3636,7 +3652,7 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
3636
3652
  ASSERT(lineWidth >= 0 && lineWidth < size.x && lineWidth < size.y, 'invalid lineWidth');
3637
3653
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
3638
3654
 
3639
- if (useWebGL)
3655
+ if (useWebGL && glEnable)
3640
3656
  {
3641
3657
  // draw as a regular polygon
3642
3658
  const sides = glCircleSides;
@@ -3699,14 +3715,10 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
3699
3715
  ASSERT(typeof drawFunction === 'function', 'drawFunction must be a function');
3700
3716
 
3701
3717
  if (!screenSpace)
3702
- {
3703
- // transform from world space to screen space
3704
- pos = worldToScreen(pos);
3705
- size = size.scale(cameraScale);
3706
- }
3718
+ [pos, size, angle] = worldToScreenTransform(pos, size, angle);
3707
3719
  context.save();
3708
3720
  context.translate(pos.x+.5, pos.y+.5);
3709
- context.rotate(angle-cameraAngle);
3721
+ context.rotate(angle);
3710
3722
  context.scale(mirror ? -size.x : size.x, -size.y);
3711
3723
  drawFunction(context);
3712
3724
  context.restore();
@@ -3717,7 +3729,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
3717
3729
 
3718
3730
  /** Draw text on main canvas in world space
3719
3731
  * Automatically splits new lines into rows
3720
- * @param {string} text
3732
+ * @param {string|number} text
3721
3733
  * @param {Vector2} pos
3722
3734
  * @param {number} [size]
3723
3735
  * @param {Color} [color=(1,1,1,1)]
@@ -3732,12 +3744,19 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
3732
3744
  * @memberof Draw */
3733
3745
  function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, font, fontStyle, maxWidth, angle=0, context=drawContext)
3734
3746
  {
3735
- drawTextScreen(text, worldToScreen(pos), size*cameraScale, color, lineWidth*cameraScale, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
3747
+ // convert to screen space
3748
+ pos = worldToScreen(pos);
3749
+ size *= cameraScale;
3750
+ lineWidth *= cameraScale;
3751
+ angle -= cameraAngle;
3752
+ angle *= -1;
3753
+
3754
+ drawTextScreen(text, pos, size, color, lineWidth, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
3736
3755
  }
3737
3756
 
3738
3757
  /** Draw text on overlay canvas in world space
3739
3758
  * Automatically splits new lines into rows
3740
- * @param {string} text
3759
+ * @param {string|number} text
3741
3760
  * @param {Vector2} pos
3742
3761
  * @param {number} [size]
3743
3762
  * @param {Color} [color=(1,1,1,1)]
@@ -3756,7 +3775,7 @@ function drawTextOverlay(text, pos, size=1, color, lineWidth=0, lineColor, textA
3756
3775
 
3757
3776
  /** Draw text on overlay canvas in screen space
3758
3777
  * Automatically splits new lines into rows
3759
- * @param {string} text
3778
+ * @param {string|number} text
3760
3779
  * @param {Vector2} pos
3761
3780
  * @param {number} [size]
3762
3781
  * @param {Color} [color=(1,1,1,1)]
@@ -3891,11 +3910,58 @@ function worldToScreenDelta(worldDelta)
3891
3910
  return new Vector2(x * cameraScale, y * -cameraScale);
3892
3911
  }
3893
3912
 
3894
- /** Get the camera's visible area in world space
3913
+ /** Convert screen space transform to world space
3914
+ * @param {Vector2} screenPos
3915
+ * @param {Vector2} screenSize
3916
+ * @param {number} [screenAngle]
3917
+ * @return {[Vector2, Vector2, number]} - [pos, size, angle]
3918
+ * @memberof Draw */
3919
+ function screenToWorldTransform(screenPos, screenSize, screenAngle=0)
3920
+ {
3921
+ return [
3922
+ screenToWorld(screenPos),
3923
+ screenSize.scale(1/cameraScale),
3924
+ screenAngle + cameraAngle
3925
+ ];
3926
+ }
3927
+
3928
+ /** Convert world space transform to screen space
3929
+ * @param {Vector2} worldPos
3930
+ * @param {Vector2} worldSize
3931
+ * @param {number} [worldAngle]
3932
+ * @return {[Vector2, Vector2, number]} - [pos, size, angle]
3933
+ * @memberof Draw */
3934
+ function worldToScreenTransform(worldPos, worldSize, worldAngle=0)
3935
+ {
3936
+ return [
3937
+ worldToScreen(worldPos),
3938
+ worldSize.scale(cameraScale),
3939
+ worldAngle - cameraAngle
3940
+ ];
3941
+ }
3942
+
3943
+ /** Get the size of the camera window in world space
3895
3944
  * @return {Vector2}
3896
3945
  * @memberof Draw */
3897
3946
  function getCameraSize() { return mainCanvasSize.scale(1/cameraScale); }
3898
3947
 
3948
+ /** Check if a point or circle is on screen
3949
+ * If size is a Vector2, uses the largest dimension as diameter
3950
+ * This can be used to cull offscreen objects from render or update
3951
+ * @param {Vector2} pos - world space position
3952
+ * @param {Vector2|number} size - world space size or diameter
3953
+ * @return {boolean}
3954
+ * @memberof Draw */
3955
+ function isOnScreen(pos, size=0)
3956
+ {
3957
+ pos = worldToScreen(pos);
3958
+ if (size instanceof Vector2)
3959
+ size = max(size.x, size.y); // use largest dimension
3960
+ size *= cameraScale/2;
3961
+ return pos.x + size > 0 && pos.x - size < mainCanvasSize.x &&
3962
+ pos.y + size > 0 && pos.y - size < mainCanvasSize.y;
3963
+ }
3964
+
3899
3965
  /** Enable normal or additive blend mode
3900
3966
  * @param {boolean} [additive]
3901
3967
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
@@ -4059,7 +4125,7 @@ class FontImage
4059
4125
  }
4060
4126
 
4061
4127
  /** Draw text in world space using the image font
4062
- * @param {string} text
4128
+ * @param {string|number} text
4063
4129
  * @param {Vector2} pos
4064
4130
  * @param {number} [scale=.25]
4065
4131
  * @param {boolean} [center]
@@ -4071,7 +4137,7 @@ class FontImage
4071
4137
  }
4072
4138
 
4073
4139
  /** Draw text on overlay canvas in world space using the image font
4074
- * @param {string} text
4140
+ * @param {string|number} text
4075
4141
  * @param {Vector2} pos
4076
4142
  * @param {number} [scale]
4077
4143
  * @param {boolean} [center]
@@ -4080,7 +4146,7 @@ class FontImage
4080
4146
  { this.drawText(text, pos, scale, center, overlayContext); }
4081
4147
 
4082
4148
  /** Draw text on overlay canvas in screen space using the image font
4083
- * @param {string} text
4149
+ * @param {string|number} text
4084
4150
  * @param {Vector2} pos
4085
4151
  * @param {number} [scale]
4086
4152
  * @param {boolean} [center]
@@ -4124,6 +4190,85 @@ class FontImage
4124
4190
  * @namespace Input
4125
4191
  */
4126
4192
 
4193
+ /** Mouse pos in world space
4194
+ * @type {Vector2}
4195
+ * @memberof Input */
4196
+ let mousePos = vec2();
4197
+
4198
+ /** Mouse pos in screen space
4199
+ * @type {Vector2}
4200
+ * @memberof Input */
4201
+ let mousePosScreen = vec2();
4202
+
4203
+ /** Mouse movement delta in world space
4204
+ * @type {Vector2}
4205
+ * @memberof Input */
4206
+ let mouseDelta = vec2();
4207
+
4208
+ /** Mouse movement delta in screen space
4209
+ * @type {Vector2}
4210
+ * @memberof Input */
4211
+ let mouseDeltaScreen = vec2();
4212
+
4213
+ /** Mouse wheel delta this frame
4214
+ * @type {number}
4215
+ * @memberof Input */
4216
+ let mouseWheel = 0;
4217
+
4218
+ /** True if mouse was inside the document window, set to false when mouse leaves
4219
+ * @type {boolean}
4220
+ * @memberof Input */
4221
+ let mouseInWindow = true;
4222
+
4223
+ /** Returns true if user is using gamepad (has more recently pressed a gamepad button)
4224
+ * @type {boolean}
4225
+ * @memberof Input */
4226
+ let isUsingGamepad = false;
4227
+
4228
+ /** Prevents input continuing to the default browser handling (true by default)
4229
+ * @type {boolean}
4230
+ * @memberof Input */
4231
+ let inputPreventDefault = true;
4232
+
4233
+ /** Primary gamepad index, automatically set to first gamepad with input
4234
+ * @type {number}
4235
+ * @memberof Input */
4236
+ let gamepadPrimary = 0;
4237
+
4238
+ /** Prevents input continuing to the default browser handling
4239
+ * This is useful to disable for html menus so the browser can handle input normally
4240
+ * @param {boolean} preventDefault
4241
+ * @memberof Input */
4242
+ function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
4243
+
4244
+ /** Clears an input key state
4245
+ * @param {string|number} key
4246
+ * @param {number} [device]
4247
+ * @param {boolean} [clearDown=true]
4248
+ * @param {boolean} [clearPressed=true]
4249
+ * @param {boolean} [clearReleased=true]
4250
+ * @memberof Input */
4251
+ function inputClearKey(key, device=0, clearDown=true, clearPressed=true, clearReleased=true)
4252
+ {
4253
+ if (!inputData[device])
4254
+ return;
4255
+ inputData[device][key] &= ~((clearDown?1:0)|(clearPressed?2:0)|(clearReleased?4:0));
4256
+ }
4257
+
4258
+ /** Clears all input
4259
+ * @memberof Input */
4260
+ function inputClear()
4261
+ {
4262
+ inputData.length = 0;
4263
+ inputData[0] = [];
4264
+ touchGamepadButtons.length = 0;
4265
+ touchGamepadSticks.length = 0;
4266
+ gamepadStickData.length = 0;
4267
+ gamepadDpadData.length = 0;
4268
+ }
4269
+
4270
+ ///////////////////////////////////////////////////////////////////////////////
4271
+
4127
4272
  /** Returns true if device key is down
4128
4273
  * @param {string|number} key
4129
4274
  * @param {number} [device]
@@ -4131,9 +4276,9 @@ class FontImage
4131
4276
  * @memberof Input */
4132
4277
  function keyIsDown(key, device=0)
4133
4278
  {
4134
- ASSERT(key !== undefined, 'key is undefined');
4279
+ ASSERT(isString(key), 'key must be a number or string');
4135
4280
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4136
- return inputData[device] && !!(inputData[device][key] & 1);
4281
+ return !!(inputData[device]?.[key] & 1);
4137
4282
  }
4138
4283
 
4139
4284
  /** Returns true if device key was pressed this frame
@@ -4143,9 +4288,9 @@ function keyIsDown(key, device=0)
4143
4288
  * @memberof Input */
4144
4289
  function keyWasPressed(key, device=0)
4145
4290
  {
4146
- ASSERT(key !== undefined, 'key is undefined');
4291
+ ASSERT(isString(key), 'key must be a number or string');
4147
4292
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4148
- return inputData[device] && !!(inputData[device][key] & 2);
4293
+ return !!(inputData[device]?.[key] & 2);
4149
4294
  }
4150
4295
 
4151
4296
  /** Returns true if device key was released this frame
@@ -4155,172 +4300,182 @@ function keyWasPressed(key, device=0)
4155
4300
  * @memberof Input */
4156
4301
  function keyWasReleased(key, device=0)
4157
4302
  {
4158
- ASSERT(key !== undefined, 'key is undefined');
4303
+ ASSERT(isString(key), 'key must be a number or string');
4159
4304
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4160
- return inputData[device] && !!(inputData[device][key] & 4);
4305
+ return !!(inputData[device]?.[key] & 4);
4161
4306
  }
4162
4307
 
4163
4308
  /** Returns input vector from arrow keys or WASD if enabled
4309
+ * @param {string} [up]
4310
+ * @param {string} [down]
4311
+ * @param {string} [left]
4312
+ * @param {string} [right]
4164
4313
  * @return {Vector2}
4165
4314
  * @memberof Input */
4166
4315
  function keyDirection(up='ArrowUp', down='ArrowDown', left='ArrowLeft', right='ArrowRight')
4167
4316
  {
4317
+ ASSERT(isString(up), 'up key must be a string');
4318
+ ASSERT(isString(down), 'down key must be a string');
4319
+ ASSERT(isString(left), 'left key must be a string');
4320
+ ASSERT(isString(right), 'right key must be a string');
4168
4321
  const k = (key)=> keyIsDown(key) ? 1 : 0;
4169
4322
  return vec2(k(right) - k(left), k(up) - k(down));
4170
4323
  }
4171
4324
 
4172
- /** Clears all input
4173
- * @memberof Input */
4174
- function inputClear() { inputData = [[]]; touchGamepadButtons = []; }
4175
-
4176
- /** Clears an input key state
4177
- * @param {string|number} key
4178
- * @param {number} [device]
4179
- * @param {boolean} [clearDown=true]
4180
- * @param {boolean} [clearPressed=true]
4181
- * @param {boolean} [clearReleased=true]
4182
- * @memberof Input */
4183
- function inputClearKey(key, device=0, clearDown=true, clearPressed=true, clearReleased=true)
4184
- {
4185
- if (!inputData[device])
4186
- return;
4187
- inputData[device][key] &= ~((clearDown?1:0)|(clearPressed?2:0)|(clearReleased?4:0));
4188
- }
4189
-
4190
4325
  /** Returns true if mouse button is down
4191
4326
  * @function
4192
4327
  * @param {number} button
4193
4328
  * @return {boolean}
4194
4329
  * @memberof Input */
4195
- function mouseIsDown(button) { return keyIsDown(button); }
4330
+ function mouseIsDown(button)
4331
+ {
4332
+ ASSERT(isNumber(button), 'mouse button must be a number');
4333
+ return keyIsDown(button);
4334
+ }
4196
4335
 
4197
4336
  /** Returns true if mouse button was pressed
4198
4337
  * @function
4199
4338
  * @param {number} button
4200
4339
  * @return {boolean}
4201
4340
  * @memberof Input */
4202
- function mouseWasPressed(button) { return keyWasPressed(button); }
4341
+ function mouseWasPressed(button)
4342
+ {
4343
+ ASSERT(isNumber(button), 'mouse button must be a number');
4344
+ return keyWasPressed(button);
4345
+ }
4203
4346
 
4204
4347
  /** Returns true if mouse button was released
4205
4348
  * @function
4206
4349
  * @param {number} button
4207
4350
  * @return {boolean}
4208
4351
  * @memberof Input */
4209
- function mouseWasReleased(button) { return keyWasReleased(button); }
4210
-
4211
- /** Mouse pos in world space
4212
- * @type {Vector2}
4213
- * @memberof Input */
4214
- let mousePos = vec2();
4215
-
4216
- /** Mouse pos in screen space
4217
- * @type {Vector2}
4218
- * @memberof Input */
4219
- let mousePosScreen = vec2();
4220
-
4221
- /** Mouse movement delta in world space
4222
- * @type {Vector2}
4223
- * @memberof Input */
4224
- let mouseDelta = vec2();
4225
-
4226
- /** Mouse movement delta in screen space
4227
- * @type {Vector2}
4228
- * @memberof Input */
4229
- let mouseDeltaScreen = vec2();
4230
-
4231
- /** Mouse wheel delta this frame
4232
- * @type {number}
4233
- * @memberof Input */
4234
- let mouseWheel = 0;
4235
-
4236
- /** True if mouse was inside the document window, set to false when mouse leaves
4237
- * @type {boolean}
4238
- * @memberof Input */
4239
- let mouseInWindow = true;
4240
-
4241
- /** Returns true if user is using gamepad (has more recently pressed a gamepad button)
4242
- * @type {boolean}
4243
- * @memberof Input */
4244
- let isUsingGamepad = false;
4245
-
4246
- /** Prevents input continuing to the default browser handling (true by default)
4247
- * @type {boolean}
4248
- * @memberof Input */
4249
- let inputPreventDefault = true;
4250
-
4251
- /** Prevents input continuing to the default browser handling
4252
- * This is useful to disable for html menus so the browser can handle input normally
4253
- * @param {boolean} preventDefault
4254
- * @memberof Input */
4255
- function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
4352
+ function mouseWasReleased(button)
4353
+ {
4354
+ ASSERT(isNumber(button), 'mouse button must be a number');
4355
+ return keyWasReleased(button);
4356
+ }
4256
4357
 
4257
4358
  /** Returns true if gamepad button is down
4258
4359
  * @param {number} button
4259
4360
  * @param {number} [gamepad]
4260
4361
  * @return {boolean}
4261
4362
  * @memberof Input */
4262
- function gamepadIsDown(button, gamepad=0)
4263
- { return keyIsDown(button, gamepad+1); }
4363
+ function gamepadIsDown(button, gamepad=gamepadPrimary)
4364
+ {
4365
+ ASSERT(isNumber(button), 'button must be a number');
4366
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
4367
+ return keyIsDown(button, gamepad+1);
4368
+ }
4264
4369
 
4265
4370
  /** Returns true if gamepad button was pressed
4266
4371
  * @param {number} button
4267
4372
  * @param {number} [gamepad]
4268
4373
  * @return {boolean}
4269
4374
  * @memberof Input */
4270
- function gamepadWasPressed(button, gamepad=0)
4271
- { return keyWasPressed(button, gamepad+1); }
4375
+ function gamepadWasPressed(button, gamepad=gamepadPrimary)
4376
+ {
4377
+ ASSERT(isNumber(button), 'button must be a number');
4378
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
4379
+ return keyWasPressed(button, gamepad+1);
4380
+ }
4272
4381
 
4273
4382
  /** Returns true if gamepad button was released
4274
4383
  * @param {number} button
4275
4384
  * @param {number} [gamepad]
4276
4385
  * @return {boolean}
4277
4386
  * @memberof Input */
4278
- function gamepadWasReleased(button, gamepad=0)
4279
- { return keyWasReleased(button, gamepad+1); }
4387
+ function gamepadWasReleased(button, gamepad=gamepadPrimary)
4388
+ {
4389
+ ASSERT(isNumber(button), 'button must be a number');
4390
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
4391
+ return keyWasReleased(button, gamepad+1);
4392
+ }
4280
4393
 
4281
4394
  /** Returns gamepad stick value
4282
4395
  * @param {number} stick
4283
4396
  * @param {number} [gamepad]
4284
4397
  * @return {Vector2}
4285
4398
  * @memberof Input */
4286
- function gamepadStick(stick, gamepad=0)
4287
- { return gamepadStickData[gamepad] ? gamepadStickData[gamepad][stick] || vec2() : vec2(); }
4288
-
4289
- ///////////////////////////////////////////////////////////////////////////////
4290
- // Input system functions called automatically by engine
4399
+ function gamepadStick(stick, gamepad=gamepadPrimary)
4400
+ {
4401
+ ASSERT(isNumber(stick), 'stick must be a number');
4402
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
4403
+ return gamepadStickData[gamepad]?.[stick] ?? vec2();
4404
+ }
4291
4405
 
4292
- // input is stored as a bit field for each key: 1 = isDown, 2 = wasPressed, 4 = wasReleased
4293
- // mouse and keyboard are stored together in device 0, gamepads are in devices > 0
4294
- let inputData = [[]];
4406
+ /** Returns gamepad dpad value
4407
+ * @param {number} [gamepad]
4408
+ * @return {Vector2}
4409
+ * @memberof Input */
4410
+ function gamepadDpad(gamepad=gamepadPrimary)
4411
+ {
4412
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
4413
+ return gamepadDpadData[gamepad] ?? vec2();
4414
+ }
4295
4415
 
4296
- function inputUpdate()
4416
+ /** Returns true if passed in gamepad is connected
4417
+ * @param {number} [gamepad]
4418
+ * @return {boolean}
4419
+ * @memberof Input */
4420
+ function gamepadConnected(gamepad=gamepadPrimary)
4297
4421
  {
4298
- if (headlessMode) return;
4422
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
4423
+ return !!inputData[gamepad+1];
4424
+ }
4299
4425
 
4300
- // clear input when lost focus (prevent stuck keys)
4301
- if(!(touchInputEnable && isTouchDevice) && !document.hasFocus())
4302
- inputClear();
4426
+ /** True if a touch device has been detected
4427
+ * @memberof Input */
4428
+ const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
4303
4429
 
4304
- // update mouse world space position and delta
4305
- mousePos = screenToWorld(mousePosScreen);
4306
- mouseDelta = screenToWorldDelta(mouseDeltaScreen);
4430
+ ///////////////////////////////////////////////////////////////////////////////
4307
4431
 
4308
- // update gamepads if enabled
4309
- gamepadsUpdate();
4432
+ /** Pulse the vibration hardware if it exists
4433
+ * @param {number|Array} [pattern] - single value in ms or vibration interval array
4434
+ * @memberof Input */
4435
+ function vibrate(pattern=100)
4436
+ {
4437
+ ASSERT(isNumber(pattern) || isArray(pattern), 'pattern must be a number or array');
4438
+ vibrateEnable && !headlessMode && navigator && navigator.vibrate && navigator.vibrate(pattern);
4310
4439
  }
4311
4440
 
4312
- function inputUpdatePost()
4313
- {
4314
- if (headlessMode) return;
4441
+ /** Cancel any ongoing vibration
4442
+ * @memberof Input */
4443
+ function vibrateStop() { vibrate(0); }
4315
4444
 
4316
- // clear input to prepare for next frame
4317
- for (const deviceInputData of inputData)
4318
- for (const i in deviceInputData)
4319
- deviceInputData[i] &= 1;
4320
- mouseWheel = 0;
4321
- mouseDelta = vec2();
4322
- mouseDeltaScreen = vec2();
4323
- }
4445
+ ///////////////////////////////////////////////////////////////////////////////
4446
+ // Pointer Lock
4447
+
4448
+ /** Request to lock the pointer, does not work on touch devices
4449
+ * @memberof Input */
4450
+ function pointerLockRequest()
4451
+ { !isTouchDevice && mainCanvas.requestPointerLock?.(); }
4452
+
4453
+ /** Request to unlock the pointer
4454
+ * @memberof Input */
4455
+ function pointerLockExit()
4456
+ { document.exitPointerLock?.(); }
4457
+
4458
+ /** Check if pointer is locked (true if locked)
4459
+ * @return {boolean}
4460
+ * @memberof Input */
4461
+ function pointerLockIsActive()
4462
+ { return document.pointerLockElement === mainCanvas; }
4463
+
4464
+ ///////////////////////////////////////////////////////////////////////////////
4465
+ // Input variables used by engine
4466
+
4467
+ // input uses bit field for each key: 1=isDown, 2=wasPressed, 4=wasReleased
4468
+ // mouse and keyboard stored in device 0, gamepads stored in devices > 0
4469
+ const inputData = [[]];
4470
+
4471
+ // gamepad internal variables
4472
+ const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
4473
+
4474
+ // touch gamepad internal variables
4475
+ const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
4476
+
4477
+ ///////////////////////////////////////////////////////////////////////////////
4478
+ // Input system functions used by engine
4324
4479
 
4325
4480
  function inputInit()
4326
4481
  {
@@ -4350,6 +4505,18 @@ function inputInit()
4350
4505
  if (inputWASDEmulateDirection)
4351
4506
  inputData[0][remapKey(e.code)] = 3;
4352
4507
  }
4508
+
4509
+ // prevent arrow key from moving the page
4510
+ const preventDefaultKeys =
4511
+ [
4512
+ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', // scrolling
4513
+ 'Space', // page down scroll
4514
+ 'Tab', // focus navigation
4515
+ 'Backspace', // browser back
4516
+ ];
4517
+ if (preventDefaultKeys.includes(e.code))
4518
+ if (inputPreventDefault && document.hasFocus() && e.cancelable)
4519
+ e.preventDefault();
4353
4520
  }
4354
4521
  function onKeyUp(e)
4355
4522
  {
@@ -4381,7 +4548,9 @@ function inputInit()
4381
4548
  const mousePosScreenLast = mousePosScreen;
4382
4549
  mousePosScreen = mouseEventToScreen(vec2(e.x,e.y));
4383
4550
  mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
4384
- inputPreventDefault && e.button && e.preventDefault();
4551
+
4552
+ if (inputPreventDefault && document.hasFocus() && e.cancelable)
4553
+ e.preventDefault();
4385
4554
  }
4386
4555
  function onMouseUp(e)
4387
4556
  {
@@ -4405,344 +4574,386 @@ function inputInit()
4405
4574
  function onMouseWheel(e) { mouseWheel = e.ctrlKey ? 0 : sign(e.deltaY); }
4406
4575
  function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
4407
4576
  function onBlur() { inputClear(); } // reset input when focus is lost
4408
- }
4409
-
4410
- // convert a mouse or touch event position to screen space
4411
- function mouseEventToScreen(mousePos)
4412
- {
4413
- const rect = mainCanvas.getBoundingClientRect();
4414
- const px = percent(mousePos.x, rect.left, rect.right);
4415
- const py = percent(mousePos.y, rect.top, rect.bottom);
4416
- return vec2(px*mainCanvas.width, py*mainCanvas.height);
4417
- }
4418
-
4419
- ///////////////////////////////////////////////////////////////////////////////
4420
- // Gamepad input
4421
4577
 
4422
- // gamepad internal variables
4423
- const gamepadStickData = [];
4424
-
4425
- // gamepads are updated by engine every frame automatically
4426
- function gamepadsUpdate()
4427
- {
4428
- const applyDeadZones = (v)=>
4429
- {
4430
- const min=.3, max=.8;
4431
- const deadZone = (v)=>
4432
- v > min ? percent(v, min, max) :
4433
- v < -min ? -percent(-v, min, max) : 0;
4434
- return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
4435
- }
4436
-
4437
- // update touch gamepad if enabled
4438
- if (touchGamepadEnable && isTouchDevice)
4578
+ // enable touch input mouse passthrough
4579
+ function touchInputInit()
4439
4580
  {
4440
- if (!touchGamepadTimer.isSet())
4441
- return;
4581
+ // add non passive touch event listeners
4582
+ document.addEventListener('touchstart', (e) => handleTouch(e), { passive: false });
4583
+ document.addEventListener('touchmove', (e) => handleTouch(e), { passive: false });
4584
+ document.addEventListener('touchend', (e) => handleTouch(e), { passive: false });
4442
4585
 
4443
- // read virtual analog stick
4444
- const sticks = gamepadStickData[0] || (gamepadStickData[0] = []);
4445
- sticks[0] = vec2();
4446
- if (touchGamepadAnalog)
4447
- sticks[0] = applyDeadZones(touchGamepadStick);
4448
- else if (touchGamepadStick.lengthSquared() > .3)
4586
+ // handle all touch events the same way
4587
+ let wasTouching;
4588
+ function handleTouch(e)
4449
4589
  {
4450
- // convert to 8 way dpad
4451
- sticks[0].x = round(touchGamepadStick.x);
4452
- sticks[0].y = -round(touchGamepadStick.y);
4453
- sticks[0] = sticks[0].clampLength();
4454
- }
4590
+ if (!touchInputEnable)
4591
+ return;
4455
4592
 
4456
- // read virtual gamepad buttons
4457
- const data = inputData[1] || (inputData[1] = []);
4458
- for (let i=10; i--;)
4459
- {
4460
- const wasDown = gamepadIsDown(i,0);
4461
- data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
4462
- }
4593
+ // route touch to gamepad
4594
+ if (touchGamepadEnable)
4595
+ handleTouchGamepad(e);
4463
4596
 
4464
- // disable normal gamepads when touch gamepad is active
4465
- return;
4466
- }
4597
+ // fix stalled audio requiring user interaction
4598
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
4599
+ audioContext.resume();
4467
4600
 
4468
- // return if gamepads are disabled or not supported
4469
- if (!gamepadsEnable || !navigator || !navigator.getGamepads)
4470
- return;
4601
+ // check if touching and pass to mouse events
4602
+ const touching = e.touches.length;
4603
+ const button = 0; // all touches are left mouse button
4604
+ if (touching)
4605
+ {
4606
+ // set event pos and pass it along
4607
+ const pos = vec2(e.touches[0].clientX, e.touches[0].clientY);
4608
+ const mousePosScreenLast = mousePosScreen;
4609
+ mousePosScreen = mouseEventToScreen(pos);
4610
+ if (wasTouching)
4611
+ {
4612
+ mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
4613
+ isUsingGamepad = touchGamepadEnable;
4614
+ }
4615
+ else
4616
+ inputData[0][button] = 3;
4617
+ }
4618
+ else if (wasTouching)
4619
+ inputData[0][button] = inputData[0][button] & 2 | 4;
4471
4620
 
4472
- // only poll gamepads when focused or in debug mode
4473
- if (!debug && !document.hasFocus())
4474
- return;
4621
+ // set was touching
4622
+ wasTouching = touching;
4475
4623
 
4476
- // poll gamepads
4477
- const gamepads = navigator.getGamepads();
4478
- for (let i = gamepads.length; i--;)
4479
- {
4480
- // get or create gamepad data
4481
- const gamepad = gamepads[i];
4482
- const data = inputData[i+1] || (inputData[i+1] = []);
4483
- const sticks = gamepadStickData[i] || (gamepadStickData[i] = []);
4624
+ // prevent default handling like copy, magnifier lens, and scrolling
4625
+ if (inputPreventDefault && document.hasFocus() && e.cancelable)
4626
+ e.preventDefault();
4484
4627
 
4485
- if (gamepad)
4628
+ // must return true so the document will get focus
4629
+ return true;
4630
+ }
4631
+
4632
+ // special handling for virtual gamepad mode
4633
+ function handleTouchGamepad(e)
4486
4634
  {
4487
- // read analog sticks
4488
- for (let j = 0; j < gamepad.axes.length-1; j+=2)
4489
- sticks[j>>1] = applyDeadZones(vec2(gamepad.axes[j],gamepad.axes[j+1]));
4635
+ // clear touch gamepad input
4636
+ touchGamepadSticks.length = 0;
4637
+ touchGamepadSticks[0] = vec2();
4638
+ touchGamepadSticks[1] = vec2();
4639
+ touchGamepadButtons.length = 0;
4640
+ isUsingGamepad = true;
4490
4641
 
4491
- // read buttons
4492
- for (let j = gamepad.buttons.length; j--;)
4642
+ const touching = e.touches.length;
4643
+ if (touching)
4493
4644
  {
4494
- const button = gamepad.buttons[j];
4495
- const wasDown = gamepadIsDown(j,i);
4496
- data[j] = button.pressed ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
4497
- if (!button.value || button.value > .9) // must be a full press
4498
- if (!i && button.pressed)
4499
- isUsingGamepad = true;
4645
+ touchGamepadTimer.set();
4646
+ if (touchGamepadCenterButton && !wasTouching && paused)
4647
+ {
4648
+ // touch anywhere to press start when paused
4649
+ touchGamepadButtons[9] = 1;
4650
+ return;
4651
+ }
4500
4652
  }
4501
4653
 
4502
- if (gamepadDirectionEmulateStick)
4654
+ // don't process touch gamepad if paused
4655
+ if (paused)
4656
+ return;
4657
+
4658
+ // get center of left and right sides
4659
+ const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
4660
+ const buttonCenter = touchGamepadButtonCenter();
4661
+ const startCenter = mainCanvasSize.scale(.5);
4662
+
4663
+ // check each touch point
4664
+ for (const touch of e.touches)
4503
4665
  {
4504
- // copy dpad to left analog stick when pressed
4505
- const dpad = vec2(
4506
- (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
4507
- (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
4508
- if (dpad.lengthSquared())
4509
- sticks[0] = dpad.clampLength();
4666
+ const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
4667
+ if (stickCenter.distance(touchPos) < touchGamepadSize)
4668
+ {
4669
+ // virtual analog stick
4670
+ const delta = touchPos.subtract(stickCenter);
4671
+ touchGamepadSticks[0] = delta.scale(2/touchGamepadSize).clampLength();
4672
+ }
4673
+ else if (buttonCenter.distance(touchPos) < touchGamepadSize)
4674
+ {
4675
+ if (touchGamepadButtonCount === 1)
4676
+ {
4677
+ // virtual right analog stick
4678
+ const delta = touchPos.subtract(buttonCenter);
4679
+ touchGamepadSticks[1] = delta.scale(2/touchGamepadSize).clampLength();
4680
+ }
4681
+ // virtual face buttons
4682
+ let button = buttonCenter.subtract(touchPos).direction();
4683
+ button = mod(button+2, 4);
4684
+ if (touchGamepadButtonCount === 1)
4685
+ button = 0;
4686
+ else if (touchGamepadButtonCount === 2)
4687
+ {
4688
+ const delta = buttonCenter.subtract(touchPos);
4689
+ button = -delta.x < delta.y ? 1 : 0;
4690
+ }
4691
+ // fix button locations (swap 2 and 3 to match gamepad layout)
4692
+ button = button === 3 ? 2 : button === 2 ? 3 : button;
4693
+ if (button < touchGamepadButtonCount)
4694
+ touchGamepadButtons[button] = 1;
4695
+ }
4696
+ else if (touchGamepadCenterButton &&
4697
+ startCenter.distance(touchPos) < touchGamepadSize)
4698
+ {
4699
+ // virtual start button in center
4700
+ touchGamepadButtons[9] = 1;
4701
+ }
4510
4702
  }
4511
-
4512
- // disable touch gamepad if using real gamepad
4513
- touchGamepadEnable && isUsingGamepad && touchGamepadTimer.unset();
4514
4703
  }
4515
4704
  }
4516
- }
4517
-
4518
- ///////////////////////////////////////////////////////////////////////////////
4519
-
4520
- /** Pulse the vibration hardware if it exists
4521
- * @param {number|Array} [pattern] - single value in ms or vibration interval array
4522
- * @memberof Input */
4523
- function vibrate(pattern=100)
4524
- { vibrateEnable && !headlessMode && navigator && navigator.vibrate && navigator.vibrate(pattern); }
4525
4705
 
4526
- /** Cancel any ongoing vibration
4527
- * @memberof Input */
4528
- function vibrateStop() { vibrate(0); }
4529
-
4530
- ///////////////////////////////////////////////////////////////////////////////
4531
- // Touch input & virtual on screen gamepad
4532
-
4533
- /** True if a touch device has been detected
4534
- * @memberof Input */
4535
- const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
4536
-
4537
- // touch gamepad internal variables
4538
- let touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadStick = vec2();
4539
-
4540
- function touchGamepadButtonCenter()
4541
- {
4542
- // draw right face buttons
4543
- const center = vec2(mainCanvasSize.x-touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
4544
- if (touchGamepadButtonCount <= 2)
4545
- center.x += touchGamepadSize/2;
4546
- return center;
4706
+ // convert a mouse or touch event position to screen space
4707
+ function mouseEventToScreen(mousePos)
4708
+ {
4709
+ const rect = mainCanvas.getBoundingClientRect();
4710
+ const px = percent(mousePos.x, rect.left, rect.right);
4711
+ const py = percent(mousePos.y, rect.top, rect.bottom);
4712
+ return vec2(px*mainCanvas.width, py*mainCanvas.height);
4713
+ }
4547
4714
  }
4548
4715
 
4549
- // enable touch input mouse passthrough
4550
- function touchInputInit()
4716
+ function inputUpdate()
4551
4717
  {
4552
- // add non passive touch event listeners
4553
- document.addEventListener('touchstart', (e) => handleTouch(e), { passive: false });
4554
- document.addEventListener('touchmove', (e) => handleTouch(e), { passive: false });
4555
- document.addEventListener('touchend', (e) => handleTouch(e), { passive: false });
4718
+ if (headlessMode) return;
4556
4719
 
4557
- // handle all touch events the same way
4558
- let wasTouching;
4559
- function handleTouch(e)
4560
- {
4561
- if (!touchInputEnable)
4562
- return;
4720
+ // clear input when lost focus (prevent stuck keys)
4721
+ if (!(touchInputEnable && isTouchDevice) && !document.hasFocus())
4722
+ inputClear();
4563
4723
 
4564
- // route touch to gamepad
4565
- if (touchGamepadEnable)
4566
- handleTouchGamepad(e);
4724
+ // update mouse world space position and delta
4725
+ mousePos = screenToWorld(mousePosScreen);
4726
+ mouseDelta = screenToWorldDelta(mouseDeltaScreen);
4567
4727
 
4568
- // fix stalled audio requiring user interaction
4569
- if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
4570
- audioContext.resume();
4728
+ // update gamepads if enabled
4729
+ gamepadsUpdate();
4730
+
4731
+ // gamepads are updated by engine every frame automatically
4732
+ function gamepadsUpdate()
4733
+ {
4734
+ const applyDeadZones = (v)=>
4735
+ {
4736
+ const min=.3, max=.8;
4737
+ const deadZone = (v)=>
4738
+ v > min ? percent(v, min, max) :
4739
+ v < -min ? -percent(-v, min, max) : 0;
4740
+ return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
4741
+ }
4571
4742
 
4572
- // check if touching and pass to mouse events
4573
- const touching = e.touches.length;
4574
- const button = 0; // all touches are left mouse button
4575
- if (touching)
4743
+ // update touch gamepad if enabled
4744
+ if (touchGamepadEnable && isTouchDevice)
4576
4745
  {
4577
- // set event pos and pass it along
4578
- const pos = vec2(e.touches[0].clientX, e.touches[0].clientY);
4579
- const mousePosScreenLast = mousePosScreen;
4580
- mousePosScreen = mouseEventToScreen(pos);
4581
- if (wasTouching)
4746
+ if (!touchGamepadTimer.isSet())
4747
+ return;
4748
+
4749
+ // read virtual analog stick
4750
+ gamepadPrimary = 0; // touch gamepad uses index 0
4751
+ const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
4752
+ const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
4753
+ sticks[0] = vec2();
4754
+ dpad.set();
4755
+ const leftTouchStick = touchGamepadSticks[0] ?? vec2();
4756
+ if (touchGamepadAnalog)
4757
+ sticks[0] = applyDeadZones(leftTouchStick);
4758
+ else if (leftTouchStick.lengthSquared() > .3)
4582
4759
  {
4583
- mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
4584
- isUsingGamepad = touchGamepadEnable;
4760
+ // convert to 8 way dpad
4761
+ const x = clamp(round(leftTouchStick.x), -1, 1);
4762
+ const y = clamp(round(leftTouchStick.y), -1, 1);
4763
+ dpad.set(x, -y);
4764
+ sticks[0] = dpad.clampLength(); // clamp to circle
4585
4765
  }
4586
- else
4587
- inputData[0][button] = 3;
4588
- }
4589
- else if (wasTouching)
4590
- inputData[0][button] = inputData[0][button] & 2 | 4;
4766
+ const rightTouchStick = touchGamepadSticks[1] ?? vec2();
4767
+ if (touchGamepadButtonCount === 1)
4768
+ sticks[1] = applyDeadZones(rightTouchStick);
4591
4769
 
4592
- // set was touching
4593
- wasTouching = touching;
4770
+ // read virtual gamepad buttons
4771
+ const data = inputData[1] ?? (inputData[1] = []);
4772
+ for (let i=10; i--;)
4773
+ {
4774
+ const wasDown = gamepadIsDown(i,0);
4775
+ data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
4776
+ }
4594
4777
 
4595
- // prevent default handling like copy, magnifier lens, and scrolling
4596
- if (inputPreventDefault && document.hasFocus() && e.cancelable)
4597
- e.preventDefault();
4778
+ // disable normal gamepads when touch gamepad is active
4779
+ return;
4780
+ }
4598
4781
 
4599
- // must return true so the document will get focus
4600
- return true;
4601
- }
4782
+ // return if gamepads are disabled or not supported
4783
+ if (!gamepadsEnable || !navigator || !navigator.getGamepads)
4784
+ return;
4602
4785
 
4603
- // special handling for virtual gamepad mode
4604
- function handleTouchGamepad(e)
4605
- {
4606
- // clear touch gamepad input
4607
- touchGamepadStick = vec2();
4608
- touchGamepadButtons = [];
4609
- isUsingGamepad = true;
4786
+ // only poll gamepads when focused or in debug mode
4787
+ if (!debug && !document.hasFocus())
4788
+ return;
4610
4789
 
4611
- const touching = e.touches.length;
4612
- if (touching)
4790
+ // poll gamepads
4791
+ const maxGamepads = 8;
4792
+ const gamepads = navigator.getGamepads();
4793
+ const gamepadCount = min(maxGamepads, gamepads.length)
4794
+ for (let i=0; i<gamepadCount; ++i)
4613
4795
  {
4614
- touchGamepadTimer.set();
4615
- if (touchGamepadCenterButton && !wasTouching && paused)
4796
+ // get or create gamepad data
4797
+ const gamepad = gamepads[i];
4798
+ if (!gamepad)
4616
4799
  {
4617
- // touch anywhere to press start when paused
4618
- touchGamepadButtons[9] = 1;
4619
- return;
4800
+ // clear gamepad data if not connected
4801
+ inputData[i+1] = undefined;
4802
+ gamepadStickData[i] = undefined;
4803
+ gamepadDpadData[i] = undefined;
4804
+ gamepadHadInput[i] = undefined;
4805
+ continue;
4620
4806
  }
4621
- }
4622
4807
 
4623
- // get center of left and right sides
4624
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
4625
- const buttonCenter = touchGamepadButtonCenter();
4626
- const startCenter = mainCanvasSize.scale(.5);
4808
+ const data = inputData[i+1] ?? (inputData[i+1] = []);
4809
+ const sticks = gamepadStickData[i] ?? (gamepadStickData[i] = []);
4810
+ const dpad = gamepadDpadData[i] ?? (gamepadDpadData[i] = vec2());
4627
4811
 
4628
- // check each touch point
4629
- for (const touch of e.touches)
4630
- {
4631
- const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
4632
- if (stickCenter.distance(touchPos) < touchGamepadSize)
4812
+ // read analog sticks
4813
+ for (let j = 0; j < gamepad.axes.length-1; j+=2)
4814
+ sticks[j>>1] = applyDeadZones(vec2(gamepad.axes[j],gamepad.axes[j+1]));
4815
+
4816
+ // read buttons
4817
+ let hadInput = false;
4818
+ for (let j = gamepad.buttons.length; j--;)
4819
+ {
4820
+ const button = gamepad.buttons[j];
4821
+ const wasDown = gamepadIsDown(j,i);
4822
+ data[j] = button.pressed ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
4823
+
4824
+ // check for any input on this gamepad, analog must be full press
4825
+ if (button.pressed)
4826
+ if (!button.value || button.value > .9)
4827
+ hadInput = true;
4828
+ }
4829
+
4830
+ // set new primary gamepad if current is not connected
4831
+ if (hadInput)
4633
4832
  {
4634
- // virtual analog stick
4635
- touchGamepadStick = touchPos.subtract(stickCenter).scale(2/touchGamepadSize).clampLength();
4833
+ gamepadHadInput[i] = true;
4834
+ if (!gamepadHadInput[gamepadPrimary])
4835
+ gamepadPrimary = i;
4836
+ isUsingGamepad ||= (gamepadPrimary === i);
4636
4837
  }
4637
- else if (buttonCenter.distance(touchPos) < touchGamepadSize)
4838
+
4839
+ if (gamepad.mapping === 'standard')
4638
4840
  {
4639
- // virtual face buttons
4640
- let button = buttonCenter.subtract(touchPos).direction();
4641
- button = mod(button+2, 4);
4642
- if (touchGamepadButtonCount === 1)
4643
- button = 0;
4644
- else if (touchGamepadButtonCount === 2)
4645
- {
4646
- const delta = buttonCenter.subtract(touchPos);
4647
- button = -delta.x < delta.y ? 1 : 0;
4648
- }
4649
- // fix button locations (swap 2 and 3 to match gamepad layout)
4650
- button = button === 3 ? 2 : button === 2 ? 3 : button;
4651
- if (button < touchGamepadButtonCount)
4652
- touchGamepadButtons[button] = 1;
4841
+ // get dpad buttons (standard mapping)
4842
+ dpad.set(
4843
+ (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
4844
+ (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
4653
4845
  }
4654
- else if (touchGamepadCenterButton && !wasTouching &&
4655
- startCenter.distance(touchPos) < touchGamepadSize)
4846
+ else if (gamepad.axes && gamepad.axes.length >= 2)
4656
4847
  {
4657
- // virtual start button in center
4658
- touchGamepadButtons[9] = 1;
4848
+ // digital style dpad from axes
4849
+ const x = clamp(round(gamepad.axes[0]), -1, 1);
4850
+ const y = clamp(round(gamepad.axes[1]), -1, 1);
4851
+ dpad.set(x, -y);
4659
4852
  }
4853
+
4854
+ // copy dpad to left analog stick when pressed
4855
+ if (gamepadDirectionEmulateStick && !dpad.isZero())
4856
+ sticks[0] = dpad.clampLength();
4660
4857
  }
4858
+
4859
+ // disable touch gamepad if using real gamepad
4860
+ touchGamepadEnable && isUsingGamepad && touchGamepadTimer.unset();
4661
4861
  }
4662
4862
  }
4663
4863
 
4664
- // render the touch gamepad, called automatically by the engine
4665
- function touchGamepadRender()
4864
+ function inputUpdatePost()
4666
4865
  {
4667
- if (!touchInputEnable || !isTouchDevice || headlessMode) return;
4668
- if (!touchGamepadEnable || !touchGamepadTimer.isSet())
4669
- return;
4866
+ if (headlessMode) return;
4670
4867
 
4671
- // fade off when not touching or paused
4672
- const alpha = percent(touchGamepadTimer.get(), 4, 3);
4673
- if (!alpha || paused)
4674
- return;
4868
+ // clear input to prepare for next frame
4869
+ for (const deviceInputData of inputData)
4870
+ for (const i in deviceInputData)
4871
+ deviceInputData[i] &= 1;
4872
+ mouseWheel = 0;
4873
+ mouseDelta = vec2();
4874
+ mouseDeltaScreen = vec2();
4875
+ }
4675
4876
 
4676
- // setup the canvas
4677
- const context = overlayContext;
4678
- context.save();
4679
- context.globalAlpha = alpha*touchGamepadAlpha;
4680
- context.strokeStyle = '#fff';
4681
- context.lineWidth = 3;
4877
+ function inputRender()
4878
+ {
4879
+ touchGamepadRender();
4682
4880
 
4683
- // draw left analog stick
4684
- context.fillStyle = touchGamepadStick.lengthSquared() > 0 ? '#fff' : '#000';
4685
- context.beginPath();
4686
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
4687
- if (touchGamepadAnalog) // draw circle shaped gamepad
4881
+ function touchGamepadRender()
4688
4882
  {
4689
- context.arc(stickCenter.x, stickCenter.y, touchGamepadSize/2, 0, 9);
4883
+ if (!touchInputEnable || !isTouchDevice || headlessMode) return;
4884
+ if (!touchGamepadEnable || !touchGamepadTimer.isSet())
4885
+ return;
4886
+
4887
+ // fade off when not touching or paused
4888
+ const alpha = percent(touchGamepadTimer.get(), 4, 3);
4889
+ if (!alpha || paused)
4890
+ return;
4891
+
4892
+ // setup the canvas
4893
+ const context = overlayContext;
4894
+ context.save();
4895
+ context.globalAlpha = alpha*touchGamepadAlpha;
4896
+ context.strokeStyle = '#fff';
4897
+ context.lineWidth = 3;
4898
+
4899
+ // draw left analog stick
4900
+ const leftTouchStick = touchGamepadSticks[0] ?? vec2();
4901
+ context.fillStyle = leftTouchStick.lengthSquared() > 0 ? '#fff' : '#000';
4902
+ context.beginPath();
4903
+ const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
4904
+ if (touchGamepadAnalog)
4905
+ {
4906
+ // draw circle shaped gamepad
4907
+ context.arc(stickCenter.x, stickCenter.y, touchGamepadSize/2, 0, 9);
4908
+ }
4909
+ else
4910
+ {
4911
+ // draw cross shaped gamepad
4912
+ for (let i=10; --i;)
4913
+ {
4914
+ const angle = i*PI/4;
4915
+ context.arc(stickCenter.x, stickCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8);
4916
+ i%2 && context.arc(stickCenter.x, stickCenter.y, touchGamepadSize*.33, angle, angle);
4917
+ }
4918
+ }
4690
4919
  context.fill();
4691
4920
  context.stroke();
4692
- }
4693
- else // draw cross shaped gamepad
4694
- {
4695
- for (let i=10; i--;)
4921
+
4922
+ // draw right face buttons
4696
4923
  {
4697
- const angle = i*PI/4;
4698
- context.arc(stickCenter.x, stickCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8);
4699
- i%2 && context.arc(stickCenter.x, stickCenter.y, touchGamepadSize*.33, angle, angle);
4700
- i===1 && context.fill();
4924
+ const buttonCenter = touchGamepadButtonCenter();
4925
+ const buttonSize = touchGamepadButtonCount > 1 ?
4926
+ touchGamepadSize/4 : touchGamepadSize/2;
4927
+ for (let i=0; i<touchGamepadButtonCount; i++)
4928
+ {
4929
+ const j = mod(i-1, 4);
4930
+ let button = touchGamepadButtonCount > 2 ?
4931
+ j : min(j, touchGamepadButtonCount-1);
4932
+ // fix button locations (swap 2 and 3 to match gamepad layout)
4933
+ button = button === 3 ? 2 : button === 2 ? 3 : button;
4934
+ const pos = touchGamepadButtonCount < 2 ? buttonCenter :
4935
+ buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
4936
+ context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
4937
+ context.beginPath();
4938
+ context.arc(pos.x, pos.y, buttonSize, 0,9);
4939
+ context.fill();
4940
+ context.stroke();
4941
+ }
4701
4942
  }
4702
- context.stroke();
4703
- }
4704
4943
 
4705
- // draw right face buttons
4706
- const buttonCenter = touchGamepadButtonCenter();
4707
- const buttonSize = touchGamepadButtonCount > 1 ? touchGamepadSize/4 : touchGamepadSize/2;
4708
- for (let i=0; i<touchGamepadButtonCount; i++)
4709
- {
4710
- const j = mod(i-1, 4);
4711
- let button = touchGamepadButtonCount > 2 ?
4712
- j : min(j, touchGamepadButtonCount-1);
4713
- // fix button locations (swap 2 and 3 to match gamepad layout)
4714
- button = button === 3 ? 2 : button === 2 ? 3 : button;
4715
- const pos = buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
4716
- context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
4717
- context.beginPath();
4718
- context.arc(pos.x, pos.y, buttonSize, 0,9);
4719
- context.fill();
4720
- context.stroke();
4944
+ // set canvas back to normal
4945
+ context.restore();
4721
4946
  }
4722
-
4723
- // set canvas back to normal
4724
- context.restore();
4725
4947
  }
4726
4948
 
4727
- ///////////////////////////////////////////////////////////////////////////////
4728
- // Pointer Lock
4729
-
4730
- /** Request to lock the pointer, does not work on touch devices
4731
- * @memberof Input */
4732
- function pointerLockRequest()
4949
+ // center position for right tocuh pad face buttons
4950
+ function touchGamepadButtonCenter()
4733
4951
  {
4734
- if (!isTouchDevice)
4735
- mainCanvas.requestPointerLock && mainCanvas.requestPointerLock();
4736
- }
4737
-
4738
- /** Request to unlock the pointer
4739
- * @memberof Input */
4740
- function pointerLockExit() { document.exitPointerLock && document.exitPointerLock(); }
4741
-
4742
- /** Check if pointer is locked (true if locked)
4743
- * @return {boolean}
4744
- * @memberof Input */
4745
- function pointerLockIsActive() { return document.pointerLockElement === mainCanvas; }
4952
+ const center = mainCanvasSize.subtract(vec2(touchGamepadSize));
4953
+ if (touchGamepadButtonCount === 2)
4954
+ center.x += touchGamepadSize/2;
4955
+ return center;
4956
+ }
4746
4957
  /**
4747
4958
  * LittleJS Audio System
4748
4959
  * - <a href=https://killedbyapixel.github.io/ZzFX/>ZzFX Sound Effects</a> - ZzFX Sound Effect Generator
@@ -4856,7 +5067,6 @@ class Sound
4856
5067
  ASSERT(isNumber(pitch), 'pitch must be a number');
4857
5068
  ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number');
4858
5069
 
4859
-
4860
5070
  if (!soundEnable || headlessMode) return;
4861
5071
  if (!this.sampleChannels) return;
4862
5072
 
@@ -5483,7 +5693,7 @@ function tileCollisionTest(pos, size=vec2(), object, solidOnly=true)
5483
5693
  }
5484
5694
  }
5485
5695
 
5486
- /** Return the exact position of the boudnary of first tile hit, undefined if nothing was hit.
5696
+ /** Return the exact position of the boundary of first tile hit, undefined if nothing was hit.
5487
5697
  * The point will be inside the colliding tile if it hits (may have a tiny shift)
5488
5698
  * @param {Vector2} posStart
5489
5699
  * @param {Vector2} posEnd
@@ -5989,7 +6199,7 @@ class TileCollisionLayer extends TileLayer
5989
6199
  // remove from collision layers array and destroy
5990
6200
  const index = tileCollisionLayers.indexOf(this);
5991
6201
  ASSERT(index >= 0, 'tile collision layer not found in array');
5992
- tileCollisionLayers.splice(index, 1);
6202
+ index >= 0 && tileCollisionLayers.splice(index, 1);
5993
6203
  super.destroy();
5994
6204
  }
5995
6205
 
@@ -6057,7 +6267,7 @@ class TileCollisionLayer extends TileLayer
6057
6267
  return false;
6058
6268
  }
6059
6269
 
6060
- /** Return the exact position of the boudnary of first tile hit, undefined if nothing was hit.
6270
+ /** Return the exact position of the boundary of first tile hit, undefined if nothing was hit.
6061
6271
  * The point will be inside the colliding tile if it hits (may have a tiny shift)
6062
6272
  * @param {Vector2} posStart
6063
6273
  * @param {Vector2} posEnd
@@ -8002,11 +8212,14 @@ function zzfxM(instruments, patterns, sequence, BPM = 125)
8002
8212
  /**
8003
8213
  * LittleJS User Interface Plugin
8004
8214
  * - call new UISystemPlugin() to setup the UI system
8215
+ * - Gamepad and keyboard navigation support
8005
8216
  * - Nested Menus
8006
8217
  * - Text
8007
8218
  * - Buttons
8008
8219
  * - Checkboxes
8009
8220
  * - Images
8221
+ * - Scrollbars
8222
+ * - Video
8010
8223
  * @namespace UISystem
8011
8224
  */
8012
8225
 
@@ -8017,6 +8230,20 @@ function zzfxM(instruments, patterns, sequence, BPM = 125)
8017
8230
  * @memberof UISystem */
8018
8231
  let uiSystem;
8019
8232
 
8233
+ /** Enable UI system debug drawing
8234
+ * 0=off, 1=normal, 2=show invisible
8235
+ * @type {number}
8236
+ * @default
8237
+ * @memberof UISystem */
8238
+ let uiDebug = 0;
8239
+
8240
+ /** Enable UI system debug drawing
8241
+ * 0=off, 1=normal, 2=show invisible
8242
+ * @param {number|boolean} enable
8243
+ * @memberof UISystem */
8244
+ function uiSetDebug(debugMode)
8245
+ { uiDebug = typeof debugMode === 'boolean' ? (debugMode ? 1 : 0) : debugMode; }
8246
+
8020
8247
  ///////////////////////////////////////////////////////////////////////////////
8021
8248
  /**
8022
8249
  * UI System Global Object
@@ -8035,6 +8262,7 @@ class UISystemPlugin
8035
8262
  ASSERT(!uiSystem, 'UI system already initialized');
8036
8263
  uiSystem = this;
8037
8264
 
8265
+ // default settings
8038
8266
  /** @property {Color} - Default fill color for UI elements */
8039
8267
  this.defaultColor = WHITE;
8040
8268
  /** @property {Color} - Default outline color for UI elements */
@@ -8054,7 +8282,7 @@ class UISystemPlugin
8054
8282
  /** @property {number} - Default rounded rect corner radius for UI elements */
8055
8283
  this.defaultCornerRadius = 0;
8056
8284
  /** @property {number} - Default scale to use for fitting text to object */
8057
- this.defaultTextScale = .8;
8285
+ this.defaultTextFitScale = .8;
8058
8286
  /** @property {string} - Default font for UI elements */
8059
8287
  this.defaultFont = fontDefault;
8060
8288
  /** @property {Sound} - Default sound when interactive UI element is pressed */
@@ -8063,6 +8291,30 @@ class UISystemPlugin
8063
8291
  this.defaultSoundRelease = undefined;
8064
8292
  /** @property {Sound} - Default sound when interactive UI element is clicked */
8065
8293
  this.defaultSoundClick = undefined;
8294
+ /** @property {Color} - Color for shadow */
8295
+ this.defaultShadowColor = CLEAR_BLACK;
8296
+ /** @property {number} - Size of shadow blur */
8297
+ this.defaultShadowBlur = 5;
8298
+ /** @property {Vector2} - Offset of shadow blur */
8299
+ this.defaultShadowOffset = vec2(5);
8300
+ /** @property {number} - If set ui coords will be renormalized to this canvas height */
8301
+ this.nativeHeight = 0;
8302
+
8303
+ // navigation properties
8304
+ /** @property {UIObject} - Object currently selected by navigation (gamepad or keyboard) */
8305
+ this.navigationObject = undefined;
8306
+ /** @property {number} - Gamepad index to use for UI navigation */
8307
+ this.navigationGamepadIndex = 0;
8308
+ /** @property {Timer} - Cooldown timer for navigation inputs */
8309
+ this.navigationTimer = new Timer(undefined, true);
8310
+ /** @property {number} - Time between navigation inputs in seconds */
8311
+ this.navigationDelay = .2;
8312
+ /** @property {boolean} - should the navigation be horizontal, vertical, or both? */
8313
+ this.navigationDirection = 1;
8314
+ /** @property {boolean} - True if user last used navigation instead of mouse */
8315
+ this.navigationMode = false;
8316
+
8317
+ // system state
8066
8318
  /** @property {Array<UIObject>} - List of all UI elements */
8067
8319
  this.uiObjects = [];
8068
8320
  /** @property {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} - Context to render UI elements to */
@@ -8073,50 +8325,118 @@ class UISystemPlugin
8073
8325
  this.hoverObject = undefined;
8074
8326
  /** @property {UIObject} - Hover object at start of update */
8075
8327
  this.lastHoverObject = undefined;
8076
- /** @property {number} - If set ui coords will be renormalized to this canvas height */
8077
- this.nativeHeight = 0;
8328
+ /** @property {UIObject} - Current confirm menu being shown */
8329
+ this.confirmDialog = undefined;
8078
8330
 
8079
8331
  engineAddPlugin(uiUpdate, uiRender);
8080
8332
 
8333
+ // set object position in parent space
8334
+ function updateTransforms(o)
8335
+ {
8336
+ if (!o.parent) return;
8337
+ o.pos.x = o.localPos.x + o.parent.pos.x;
8338
+ o.pos.y = o.localPos.y + o.parent.pos.y;
8339
+ }
8340
+
8081
8341
  // setup recursive update and render
8082
8342
  // update in reverse order to detect mouse enter/leave
8083
8343
  function uiUpdate()
8084
8344
  {
8085
- function updateInvisibleObject(o)
8345
+ if (uiSystem.activeObject && !uiSystem.activeObject.visible)
8346
+ uiSystem.activeObject = undefined;
8347
+
8348
+ // reset hover object at start of update
8349
+ uiSystem.lastHoverObject = uiSystem.hoverObject;
8350
+ uiSystem.hoverObject = undefined;
8351
+
8352
+ if (mouseWasPressed(0))
8086
8353
  {
8087
- // update invisible objects
8088
- for (const c of o.children)
8089
- updateInvisibleObject(c);
8090
- o.updateInvisible();
8354
+ uiSystem.navigationMode = false;
8355
+ uiSystem.navigationObject = undefined;
8091
8356
  }
8092
- function updateObject(o)
8357
+
8358
+ // navigation with gamepad/keyboard
8359
+ const navigableObjects = uiSystem.getNavigableObjects();
8360
+ if (!navigableObjects.length)
8361
+ uiSystem.navigationObject = undefined;
8362
+ else
8093
8363
  {
8094
- if (o.visible)
8364
+ // unselect object if it is no longer navigable
8365
+ if (!navigableObjects.includes(uiSystem.navigationObject))
8366
+ uiSystem.navigationObject = undefined;
8367
+
8368
+ if (!isTouchDevice)
8369
+ if (uiSystem.navigationMode && !uiSystem.navigationObject)
8095
8370
  {
8096
- // set position in parent space
8097
- if (o.parent)
8098
- o.pos = o.localPos.add(o.parent.pos);
8099
- // update in reverse order to detect mouse enter/leave
8100
- for (let i=o.children.length; i--;)
8101
- updateObject(o.children[i]);
8102
- o.update();
8371
+ // select first auto focus object
8372
+ uiSystem.navigationObject = navigableObjects.find(o=>o.navigationAutoSelect);
8103
8373
  }
8104
- else
8105
- updateInvisibleObject(o);
8374
+
8375
+ // navigate with dpad or left stick
8376
+ if (!uiSystem.navigationTimer.active())
8377
+ {
8378
+ // navigate through list with gamepad or keyboard
8379
+ const direction = sign(uiSystem.getNavigationDirection());
8380
+ if (direction)
8381
+ {
8382
+ let newNavigationObject;
8383
+ if (!uiSystem.navigationObject)
8384
+ {
8385
+ // use auto select object
8386
+ newNavigationObject = navigableObjects.find(o=>o.navigationAutoSelect);
8387
+
8388
+ if (!newNavigationObject)
8389
+ {
8390
+ // try first or last object
8391
+ const newIndex = direction > 0 ? 0 : navigableObjects.length-1;
8392
+ newNavigationObject = navigableObjects[newIndex];
8393
+ }
8394
+ }
8395
+ else
8396
+ {
8397
+ const currentIndex = navigableObjects.indexOf(uiSystem.navigationObject);
8398
+ const newIndex = mod(currentIndex + direction, navigableObjects.length);
8399
+ newNavigationObject = navigableObjects[newIndex];
8400
+ }
8401
+
8402
+ if (uiSystem.navigationObject !== newNavigationObject)
8403
+ {
8404
+ uiSystem.navigationMode = true;
8405
+ uiSystem.hoverObject = undefined;
8406
+ uiSystem.navigationObject = newNavigationObject;
8407
+ uiSystem.navigationTimer.set(uiSystem.navigationDelay);
8408
+ newNavigationObject.soundPress &&
8409
+ newNavigationObject.soundPress.play();
8410
+ }
8411
+ }
8412
+ }
8413
+
8414
+ // activate the navigation object when pressed
8415
+ if (uiSystem.navigationObject)
8416
+ if (uiSystem.getNavigationWasPressed())
8417
+ uiSystem.navigationObject.navigatePressed();
8106
8418
  }
8107
- // reset hover object at start of update
8108
- uiSystem.lastHoverObject = uiSystem.hoverObject;
8109
- uiSystem.hoverObject = undefined;
8110
8419
 
8111
8420
  // update in reverse order so topmost objects get priority
8112
8421
  for (let i = uiSystem.uiObjects.length; i--;)
8113
8422
  {
8114
8423
  const o = uiSystem.uiObjects[i];
8115
- o.parent || updateObject(o)
8424
+ o.parent || updateObject(o);
8116
8425
  }
8117
8426
 
8118
8427
  // remove destroyed objects
8119
8428
  uiSystem.uiObjects = uiSystem.uiObjects.filter(o=>!o.destroyed);
8429
+
8430
+ function updateObject(o)
8431
+ {
8432
+ if (!o.visible) return;
8433
+
8434
+ // update in reverse order to detect mouse enter/leave
8435
+ updateTransforms(o);
8436
+ for (let i=o.children.length; i--;)
8437
+ updateObject(o.children[i]);
8438
+ o.update();
8439
+ }
8120
8440
  }
8121
8441
  function uiRender()
8122
8442
  {
@@ -8133,15 +8453,29 @@ class UISystemPlugin
8133
8453
 
8134
8454
  function renderObject(o)
8135
8455
  {
8136
- if (!o.visible)
8137
- return;
8138
- if (o.parent)
8139
- o.pos = o.localPos.add(o.parent.pos);
8456
+ if (!o.visible) return;
8457
+
8458
+ // render object and children
8459
+ updateTransforms(o);
8140
8460
  o.render();
8141
8461
  for (const c of o.children)
8142
8462
  renderObject(c);
8143
8463
  }
8144
8464
  uiSystem.uiObjects.forEach(o=> o.parent || renderObject(o));
8465
+
8466
+ if (uiDebug > 0)
8467
+ {
8468
+ // debug render all objects
8469
+ function renderDebug(o, visible=true)
8470
+ {
8471
+ visible &&= !!o.visible;
8472
+ updateTransforms(o);
8473
+ o.renderDebug(visible);
8474
+ for (const c of o.children)
8475
+ renderDebug(c, visible);
8476
+ }
8477
+ uiSystem.uiObjects.forEach(o=> o.parent || renderDebug(o));
8478
+ }
8145
8479
  context.restore();
8146
8480
  }
8147
8481
  }
@@ -8153,8 +8487,11 @@ class UISystemPlugin
8153
8487
  * @param {number} [lineWidth]
8154
8488
  * @param {Color} [lineColor]
8155
8489
  * @param {number} [cornerRadius]
8156
- * @param {Color} [gradientColor] */
8157
- drawRect(pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, cornerRadius=0, gradientColor)
8490
+ * @param {Color} [gradientColor]
8491
+ * @param {Color} [shadowColor]
8492
+ * @param {number} [shadowBlur]
8493
+ * @param {Color} [shadowOffset] */
8494
+ drawRect(pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, cornerRadius=0, gradientColor, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2())
8158
8495
  {
8159
8496
  ASSERT(isVector2(pos), 'pos must be a vec2');
8160
8497
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -8176,13 +8513,23 @@ class UISystemPlugin
8176
8513
  }
8177
8514
  else
8178
8515
  context.fillStyle = color.toString();
8516
+ if (shadowBlur || shadowOffset.x || shadowOffset.y)
8517
+ if (shadowColor.a > 0)
8518
+ {
8519
+ // setup shadow
8520
+ context.shadowColor = shadowColor.toString();
8521
+ context.shadowBlur = shadowBlur;
8522
+ context.shadowOffsetX = shadowOffset.x;
8523
+ context.shadowOffsetY = shadowOffset.y;
8524
+ }
8179
8525
  context.beginPath();
8180
8526
  if (cornerRadius && context['roundRect'])
8181
8527
  context['roundRect'](pos.x-size.x/2, pos.y-size.y/2, size.x, size.y, cornerRadius);
8182
8528
  else
8183
8529
  context.rect(pos.x-size.x/2, pos.y-size.y/2, size.x, size.y);
8184
8530
  context.fill();
8185
- if (lineWidth)
8531
+ context.shadowColor = '#0000';
8532
+ if (lineWidth && lineColor.a > 0)
8186
8533
  {
8187
8534
  context.strokeStyle = lineColor.toString();
8188
8535
  context.lineWidth = lineWidth;
@@ -8217,10 +8564,24 @@ class UISystemPlugin
8217
8564
  * @param {TileInfo} tileInfo
8218
8565
  * @param {Color} [color=uiSystem.defaultColor]
8219
8566
  * @param {number} [angle]
8220
- * @param {boolean} [mirror] */
8221
- drawTile(pos, size, tileInfo, color=uiSystem.defaultColor, angle=0, mirror=false)
8567
+ * @param {boolean} [mirror]
8568
+ * @param {Color} [shadowColor]
8569
+ * @param {number} [shadowBlur]
8570
+ * @param {Color} [shadowOffset] */
8571
+ drawTile(pos, size, tileInfo, color=uiSystem.defaultColor, angle=0, mirror=false, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2())
8222
8572
  {
8223
- drawTile(pos, size, tileInfo, color, angle, mirror, CLEAR_BLACK, false, true, uiSystem.uiContext);
8573
+ const context = uiSystem.uiContext;
8574
+ if (shadowBlur || shadowOffset.x || shadowOffset.y)
8575
+ if (shadowColor.a > 0)
8576
+ {
8577
+ // setup shadow
8578
+ context.shadowColor = shadowColor.toString();
8579
+ context.shadowBlur = shadowBlur;
8580
+ context.shadowOffsetX = shadowOffset.x;
8581
+ context.shadowOffsetY = shadowOffset.y;
8582
+ }
8583
+ drawTile(pos, size, tileInfo, color, angle, mirror, CLEAR_BLACK, false, true, context);
8584
+ context.shadowColor = '#0000';
8224
8585
  }
8225
8586
 
8226
8587
  /** Draw text to the UI context
@@ -8235,12 +8596,27 @@ class UISystemPlugin
8235
8596
  * @param {string} [fontStyle]
8236
8597
  * @param {boolean} [applyMaxWidth=true]
8237
8598
  * @param {Vector2} [textShadow]
8238
- */
8239
- drawText(text, pos, size, color=uiSystem.defaultColor, lineWidth=uiSystem.defaultLineWidth, lineColor=uiSystem.defaultLineColor, align='center', font=uiSystem.defaultFont, fontStyle='', applyMaxWidth=true, textShadow=undefined)
8599
+ * @param {Color} [shadowColor]
8600
+ * @param {number} [shadowBlur]
8601
+ * @param {Color} [shadowOffset] */
8602
+ drawText(text, pos, size, color=uiSystem.defaultColor, lineWidth=uiSystem.defaultLineWidth, lineColor=uiSystem.defaultLineColor, align='center', font=uiSystem.defaultFont, fontStyle='', applyMaxWidth=true, textShadow=undefined, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2())
8240
8603
  {
8241
- if (textShadow)
8242
- drawTextScreen(text, pos.add(textShadow), size.y, BLACK, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, uiSystem.uiContext);
8243
- drawTextScreen(text, pos, size.y, color, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, uiSystem.uiContext);
8604
+ const context = uiSystem.uiContext;
8605
+ if (shadowColor.a > 0)
8606
+ {
8607
+ if (textShadow)
8608
+ drawTextScreen(text, pos.add(textShadow), size.y, shadowColor, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, context);
8609
+ if (shadowBlur || shadowOffset.x || shadowOffset.y)
8610
+ {
8611
+ // setup shadow
8612
+ context.shadowColor = shadowColor.toString();
8613
+ context.shadowBlur = shadowBlur;
8614
+ context.shadowOffsetX = shadowOffset.x;
8615
+ context.shadowOffsetY = shadowOffset.y;
8616
+ }
8617
+ }
8618
+ drawTextScreen(text, pos, size.y, color, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, context);
8619
+ context.shadowColor = '#0000';
8244
8620
  }
8245
8621
 
8246
8622
  /**
@@ -8254,7 +8630,7 @@ class UISystemPlugin
8254
8630
  * @param {DragAndDropCallback} [onDrop] - when a file is dropped
8255
8631
  * @param {DragAndDropCallback} [onDragEnter] - when a file is dragged onto the window
8256
8632
  * @param {DragAndDropCallback} [onDragLeave] - when a file is dragged off the window
8257
- * @param {DragAndDropCallback} [onDragOver] - continously when dragging over */
8633
+ * @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */
8258
8634
  setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver)
8259
8635
  {
8260
8636
  function setCallback(callback, listenerType)
@@ -8270,8 +8646,7 @@ class UISystemPlugin
8270
8646
 
8271
8647
  /** Convert a screen space position to native UI position
8272
8648
  * @param {Vector2} pos
8273
- * @return {Vector2}
8274
- */
8649
+ * @return {Vector2} */
8275
8650
  screenToNative(pos)
8276
8651
  {
8277
8652
  if (!uiSystem.nativeHeight)
@@ -8294,6 +8669,160 @@ class UISystemPlugin
8294
8669
  for (const o of this.uiObjects)
8295
8670
  o.parent || o.destroy();
8296
8671
  this.uiObjects = this.uiObjects.filter(o=>!o.destroyed);
8672
+ this.activeObject = undefined;
8673
+ this.hoverObject = undefined;
8674
+ this.lastHoverObject = undefined;
8675
+ }
8676
+
8677
+ /** Get all navigable UI objects sorted by navigationIndex
8678
+ * @return {Array<UIObject>} */
8679
+ getNavigableObjects()
8680
+ {
8681
+ function getNavigableRecursive(o)
8682
+ {
8683
+ if (!o.visible || o.disabled)
8684
+ return; // skip children if parent is invisible or disabled
8685
+
8686
+ if (o.isInteractive() && o.navigationIndex !== undefined)
8687
+ objects.push(o);
8688
+ for (let i=o.children.length; i--;)
8689
+ getNavigableRecursive(o.children[i]);
8690
+ }
8691
+
8692
+ // get all the valid navigable objects recursively
8693
+ let objects = [];
8694
+ for (let i = uiSystem.uiObjects.length; i--;)
8695
+ {
8696
+ const o = uiSystem.uiObjects[i];
8697
+ if (uiSystem.confirmDialog && o !== uiSystem.confirmDialog)
8698
+ continue;
8699
+ o.parent || getNavigableRecursive(o);
8700
+ }
8701
+
8702
+ // sort by navigationIndex (lower numbers first)
8703
+ objects.sort((a, b)=> a.navigationIndex - b.navigationIndex);
8704
+ return objects;
8705
+ }
8706
+
8707
+ /** Get navigation direction from gamepad or keyboard
8708
+ * @return {number} */
8709
+ getNavigationDirection()
8710
+ {
8711
+ const vertical = uiSystem.navigationDirection === 1;
8712
+ const both = uiSystem.navigationDirection === 2;
8713
+ if (isUsingGamepad)
8714
+ {
8715
+ const gamepad = this.navigationGamepadIndex;
8716
+ const stick = gamepadStick(0, gamepad);
8717
+ const dpad = gamepadDpad(gamepad);
8718
+ if (both)
8719
+ return -(stick.y || dpad.y) || (stick.x || dpad.x);
8720
+ return vertical ? -(stick.y || dpad.y) : (stick.x || dpad.x);
8721
+ }
8722
+ const up = 'ArrowUp', down = 'ArrowDown', left = 'ArrowLeft', right = 'ArrowRight';
8723
+ if (both)
8724
+ {
8725
+ return keyIsDown(up) || keyIsDown(left) ? -1 :
8726
+ keyIsDown(down) || keyIsDown(right) ? 1 : 0;
8727
+ }
8728
+ const back = vertical ? up : left;
8729
+ const forward = vertical ? down : right;
8730
+ return keyIsDown(back) ? -1 : keyIsDown(forward) ? 1 : 0;
8731
+ }
8732
+
8733
+ /** Get other axis navigation direction from gamepad or keyboard
8734
+ * @return {Vector2} */
8735
+ getNavigationOtherDirection()
8736
+ {
8737
+ if (uiSystem.navigationDirection === 2)
8738
+ return 0; // other direction disabled
8739
+
8740
+ const vertical = uiSystem.navigationDirection === 1;
8741
+ if (isUsingGamepad)
8742
+ {
8743
+ const gamepad = this.navigationGamepadIndex;
8744
+ const stick = gamepadStick(0, gamepad);
8745
+ const dpad = gamepadDpad(gamepad);
8746
+ return !vertical ? (stick.y || dpad.y) : (stick.x || dpad.x);
8747
+ }
8748
+ const back = !vertical ? 'ArrowUp' : 'ArrowLeft';
8749
+ const forward = !vertical ? 'ArrowDown' : 'ArrowRight';
8750
+ return keyIsDown(back) ? -1 : keyIsDown(forward) ? 1 : 0;
8751
+ }
8752
+
8753
+ /** Get if navigation button was pressed from gamepad or keyboard
8754
+ * @return {boolean} */
8755
+ getNavigationWasPressed()
8756
+ {
8757
+ const gamepad = this.navigationGamepadIndex;
8758
+ return isUsingGamepad ? gamepadWasPressed(0, gamepad) :
8759
+ keyWasPressed('Space') || keyWasPressed('Enter');
8760
+ }
8761
+
8762
+ /** Show a confirmation dialog with Yes/No buttons
8763
+ * Centers the dialog on the screen with darkened background
8764
+ * @param {string} [text] - The message to display
8765
+ * @param {Function} [yesCallback] - Called when Yes is clicked
8766
+ * @param {Function} [noCallback] - Called when No is clicked
8767
+ * @param {Vector2} [size] - Size of the confirmation dialog
8768
+ * @param {string} [exitKey] - Key that can exit the menu
8769
+ * @return {UIObject} The confirmation menu object
8770
+ */
8771
+ showConfirmDialog(text='Are you sure?', yesCallback, noCallback, size=vec2(500,250), exitKey='Escape')
8772
+ {
8773
+ ASSERT(!uiSystem.confirmDialog);
8774
+
8775
+ const savedNavigationDirection = uiSystem.navigationDirection;
8776
+
8777
+ // allow both axies for navigation
8778
+ uiSystem.navigationDirection = 2;
8779
+
8780
+ // confirm menu
8781
+ const confirmMenu = new UIObject(vec2(), size);
8782
+ uiSystem.confirmDialog = confirmMenu;
8783
+ confirmMenu.onRender = ()=>
8784
+ {
8785
+ confirmMenu.pos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
8786
+ const backgroundColor = hsl(0,0,0,.7);
8787
+ uiSystem.drawRect(vec2(), vec2(1e9), backgroundColor);
8788
+ }
8789
+ confirmMenu.onUpdate = ()=>
8790
+ {
8791
+ if (keyWasPressed(exitKey))
8792
+ closeMenu();
8793
+ }
8794
+ confirmMenu.isMouseOverlapping = ()=> true; // always hover
8795
+
8796
+ // title text
8797
+ const gap = 50;
8798
+ const textTitle = new UIText(vec2(0,-50), vec2(size.x-gap,70), text);
8799
+ confirmMenu.addChild(textTitle);
8800
+
8801
+ // yes button
8802
+ const buttonYes = new UIButton(vec2(-80,50), vec2(120,70), 'Yes');
8803
+ buttonYes.textHeight = 40;
8804
+ buttonYes.navigationIndex = 1;
8805
+ buttonYes.hoverColor = hsl(0,1,.5);
8806
+ buttonYes.onClick = ()=> { closeMenu(); yesCallback && yesCallback(); };
8807
+ confirmMenu.addChild(buttonYes);
8808
+
8809
+ // no button
8810
+ const buttonNo = new UIButton(vec2(80,50), vec2(120,70), 'No');
8811
+ buttonNo.textHeight = 40;
8812
+ buttonNo.navigationIndex = 2;
8813
+ buttonNo.navigationAutoSelect = true;
8814
+ buttonNo.onClick = ()=> { closeMenu(); noCallback && noCallback(); };
8815
+ confirmMenu.addChild(buttonNo);
8816
+
8817
+ // close menu and return to normal navigation
8818
+ function closeMenu()
8819
+ {
8820
+ ASSERT(uiSystem.confirmDialog === confirmMenu);
8821
+ confirmMenu.destroy();
8822
+ uiSystem.confirmDialog = undefined;
8823
+ uiSystem.navigationDirection = savedNavigationDirection;
8824
+ inputClear();
8825
+ }
8297
8826
  }
8298
8827
  }
8299
8828
 
@@ -8349,7 +8878,13 @@ class UIObject
8349
8878
  /** @property {number} - Override for text height */
8350
8879
  this.textHeight = undefined;
8351
8880
  /** @property {number} - Scale text to fit in the object */
8352
- this.textScale = uiSystem.defaultTextScale;
8881
+ this.textFitScale = uiSystem.defaultTextFitScale;
8882
+ /** @property {Vector2} - How much to offset the text shadow or undefined */
8883
+ this.textShadow = undefined;
8884
+ /** @property {number} - Color for text line drawing */
8885
+ this.textLineColor = uiSystem.defaultLineColor.copy();
8886
+ /** @property {number} - Width for text line drawing */
8887
+ this.textLineWidth = 0;
8353
8888
  /** @property {boolean} - Should this object be drawn */
8354
8889
  this.visible = true;
8355
8890
  /** @property {Array<UIObject>} - A list of this object's children */
@@ -8370,15 +8905,22 @@ class UIObject
8370
8905
  this.dragActivate = false;
8371
8906
  /** @property {boolean} - True if this can be a hover object */
8372
8907
  this.canBeHover = true;
8373
- uiSystem.uiObjects.push(this);
8908
+ /** @property {Color} - Color for shadow, undefined if no shadow */
8909
+ this.shadowColor = uiSystem.defaultShadowColor?.copy();
8910
+ /** @property {number} - Size of shadow blur */
8911
+ this.shadowBlur = uiSystem.defaultShadowBlur;
8912
+ /** @property {Vector2} - Offset of shadow blur */
8913
+ this.shadowOffset = uiSystem.defaultShadowOffset?.copy();
8914
+ /** @property {number} - Optional navigation order index, lower values are selected first */
8915
+ this.navigationIndex = undefined;
8916
+ /** @property {boolean} - Should this be auto selected by navigation? Must also have valid navigation index. */
8917
+ this.navigationAutoSelect = false;
8374
8918
 
8375
- /** @property {Vector2} - How much to offset the text shadow or undefined */
8376
- this.textShadow = undefined;
8919
+ uiSystem.uiObjects.push(this);
8377
8920
  }
8378
8921
 
8379
8922
  /** Add a child UIObject to this object
8380
- * @param {UIObject} child
8381
- */
8923
+ * @param {UIObject} child */
8382
8924
  addChild(child)
8383
8925
  {
8384
8926
  ASSERT(!child.parent && !this.children.includes(child));
@@ -8387,8 +8929,7 @@ class UIObject
8387
8929
  }
8388
8930
 
8389
8931
  /** Remove a child UIObject from this object
8390
- * @param {UIObject} child
8391
- */
8932
+ * @param {UIObject} child */
8392
8933
  removeChild(child)
8393
8934
  {
8394
8935
  ASSERT(child.parent === this && this.children.includes(child));
@@ -8396,7 +8937,6 @@ class UIObject
8396
8937
  child.parent = undefined;
8397
8938
  }
8398
8939
 
8399
-
8400
8940
  /** Destroy this object, destroy its children, detach its parent, and mark it for removal */
8401
8941
  destroy()
8402
8942
  {
@@ -8412,9 +8952,9 @@ class UIObject
8412
8952
  child.destroy();
8413
8953
  }
8414
8954
  }
8955
+
8415
8956
  /** Check if the mouse is overlapping a box in screen space
8416
- * @return {boolean} - True if overlapping
8417
- */
8957
+ * @return {boolean} - True if overlapping */
8418
8958
  isMouseOverlapping()
8419
8959
  {
8420
8960
  if (!mouseInWindow) return false;
@@ -8431,11 +8971,16 @@ class UIObject
8431
8971
  // call the custom update callback
8432
8972
  this.onUpdate();
8433
8973
 
8974
+ // unset active if disabled
8975
+ if (this.disabled && this == uiSystem.activeObject)
8976
+ uiSystem.activeObject = undefined;
8977
+
8434
8978
  const wasHover = uiSystem.lastHoverObject === this;
8435
8979
  const isActive = this.isActiveObject();
8436
8980
  const mouseDown = mouseIsDown(0);
8437
8981
  const mousePress = this.dragActivate ? mouseDown : mouseWasPressed(0);
8438
8982
  if (this.canBeHover)
8983
+ if (!uiSystem.navigationMode) // no mouse hover in navigation mode
8439
8984
  if (mousePress || isActive || (!mouseDown && !isTouchDevice))
8440
8985
  if (!uiSystem.hoverObject && this.isMouseOverlapping())
8441
8986
  uiSystem.hoverObject = this;
@@ -8449,8 +8994,7 @@ class UIObject
8449
8994
  {
8450
8995
  if (!this.dragActivate || (!wasHover || mouseWasPressed(0)))
8451
8996
  this.onPress();
8452
- if (this.soundPress)
8453
- this.soundPress.play();
8997
+ this.soundPress && this.soundPress.play();
8454
8998
  if (uiSystem.activeObject && !isActive)
8455
8999
  uiSystem.activeObject.onRelease();
8456
9000
  uiSystem.activeObject = this;
@@ -8459,10 +9003,10 @@ class UIObject
8459
9003
  if (!mouseDown && this.isActiveObject() && this.interactive)
8460
9004
  {
8461
9005
  this.onClick();
8462
- if (this.soundClick)
8463
- this.soundClick.play();
9006
+ this.soundClick && this.soundClick.play();
8464
9007
  }
8465
9008
  }
9009
+
8466
9010
  // clear mouse was pressed state even when disabled
8467
9011
  mousePress && inputClearKey(0,0,0,1,0);
8468
9012
  }
@@ -8470,8 +9014,7 @@ class UIObject
8470
9014
  if (!mouseDown || (this.dragActivate && !this.isHoverObject()))
8471
9015
  {
8472
9016
  this.onRelease();
8473
- if (this.soundRelease)
8474
- this.soundRelease.play();
9017
+ this.soundRelease && this.soundRelease.play();
8475
9018
  uiSystem.activeObject = undefined;
8476
9019
  }
8477
9020
 
@@ -8483,29 +9026,40 @@ class UIObject
8483
9026
  /** Render the object, called automatically by plugin once each frame */
8484
9027
  render()
8485
9028
  {
8486
- if (!this.size.x || !this.size.y) return;
9029
+ // call the custom render callback
9030
+ this.onRender();
8487
9031
 
8488
- const lineColor = this.interactive && this.isActiveObject() && !this.disabled ? this.color : this.lineColor;
8489
- const color = this.disabled ? this.disabledColor : this.interactive ? this.isActiveObject() ? this.activeColor || this.color : this.isHoverObject() ? this.hoverColor : this.color : this.color;
8490
- uiSystem.drawRect(this.pos, this.size, color, this.lineWidth, lineColor, this.cornerRadius, this.gradientColor);
8491
- }
9032
+ if (!this.size.x || !this.size.y) return;
8492
9033
 
8493
- /** Special update when object is not visible */
8494
- updateInvisible()
8495
- {
8496
- // reset input state when not visible
8497
- if (this.isActiveObject())
8498
- uiSystem.activeObject = undefined;
9034
+ const isNavigationObject = this.isNavigationObject();
9035
+ const lineColor = isNavigationObject ? this.color :
9036
+ this.interactive && this.isActiveObject() && !this.disabled ?
9037
+ this.color : this.lineColor;
9038
+ const color = isNavigationObject ? this.hoverColor :
9039
+ this.disabled ? this.disabledColor :
9040
+ this.interactive ?
9041
+ this.isHoverObject() ? this.hoverColor :
9042
+ this.isActiveObject() ? this.activeColor || this.color :
9043
+ this.color : this.color;
9044
+ const lineWidth = this.lineWidth * (isNavigationObject ? 1.5 : 1);
9045
+
9046
+ uiSystem.drawRect(this.pos, this.size, color, lineWidth, lineColor, this.cornerRadius, this.gradientColor, this.shadowColor, this.shadowBlur, this.shadowOffset);
8499
9047
  }
8500
9048
 
8501
9049
  /** Get the size for text with overrides and scale
8502
- * @return {Vector2}
8503
- */
9050
+ * @return {Vector2} */
8504
9051
  getTextSize()
8505
9052
  {
8506
9053
  return vec2(
8507
- this.textWidth || this.textScale * this.size.x,
8508
- this.textHeight || this.textScale * this.size.y);
9054
+ this.textWidth || this.textFitScale * this.size.x,
9055
+ this.textHeight || this.textFitScale * this.size.y);
9056
+ }
9057
+
9058
+ /** Called when the navigation button is pressed on this object */
9059
+ navigatePressed()
9060
+ {
9061
+ this.onClick();
9062
+ this.soundClick && this.soundClick.play();
8509
9063
  }
8510
9064
 
8511
9065
  /** @return {boolean} - Is the mouse hovering over this element */
@@ -8514,9 +9068,51 @@ class UIObject
8514
9068
  /** @return {boolean} - Is the mouse held onto this element */
8515
9069
  isActiveObject() { return uiSystem.activeObject === this; }
8516
9070
 
8517
- /** Called each frame when object updates */
9071
+ /** @return {boolean} - Is the gamepad or keyboard navigation object */
9072
+ isNavigationObject() { return uiSystem.navigationObject === this; }
9073
+
9074
+ /** @return {boolean} - Can it be interacted with */
9075
+ isInteractive() { return this.interactive && this.visible && !this.disabled;}
9076
+
9077
+ /** Returns string containing info about this object for debugging
9078
+ * @return {string} */
9079
+ toString()
9080
+ {
9081
+ if (!debug) return;
9082
+
9083
+ let text = 'type = ' + this.constructor.name;
9084
+ if (this.text)
9085
+ text += '\ntext = ' + this.text;
9086
+ if (this.pos.x || this.pos.y)
9087
+ text += '\npos = ' + this.pos;
9088
+ if (this.localPos.x || this.localPos.y)
9089
+ text += '\localPos = ' + this.localPos;
9090
+ if (this.size.x || this.size.y)
9091
+ text += '\nsize = ' + this.size;
9092
+ if (this.color)
9093
+ text += '\ncolor = ' + this.color;
9094
+ return text;
9095
+ }
9096
+
9097
+ /** Called if uiDebug is enabled
9098
+ * @param {boolean} visible */
9099
+ renderDebug(visible=true)
9100
+ {
9101
+ // apply color based on state
9102
+ const color =
9103
+ !visible ? GREEN :
9104
+ this.isHoverObject() ? YELLOW :
9105
+ this.disabled ? PURPLE :
9106
+ this.interactive ? RED : BLUE;
9107
+ uiSystem.drawRect(this.pos, this.size, CLEAR_BLACK, 4, color);
9108
+ }
9109
+
9110
+ /** Called each frame before object updates */
8518
9111
  onUpdate() {}
8519
9112
 
9113
+ /** Called each frame before object renders */
9114
+ onRender() {}
9115
+
8520
9116
  /** Called when the mouse enters the object */
8521
9117
  onEnter() {}
8522
9118
 
@@ -8564,16 +9160,25 @@ class UIText extends UIObject
8564
9160
  this.align = align;
8565
9161
  this.font = font;
8566
9162
 
8567
- // make text not outlined by default
8568
- this.lineWidth = 0;
8569
9163
  // text can not be a hover object by default
8570
9164
  this.canBeHover = false;
9165
+
9166
+ // no background by default
9167
+ this.color = CLEAR_BLACK;
9168
+ this.shadowColor = CLEAR_BLACK;
9169
+ this.gradientColor = undefined;
9170
+ this.lineWidth = 0;
9171
+
9172
+ // use max fit scale by default
9173
+ this.textFitScale = 1;
8571
9174
  }
8572
9175
  render()
8573
9176
  {
8574
- // only render the text
9177
+ super.render();
9178
+
9179
+ // render the text
8575
9180
  const textSize = this.getTextSize();
8576
- uiSystem.drawText(this.text, this.pos, textSize, this.textColor, this.lineWidth, this.lineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
9181
+ uiSystem.drawText(this.text, this.pos, textSize, this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow, this.shadowColor, this.shadowBlur, this.shadowOffset);
8577
9182
  }
8578
9183
  }
8579
9184
 
@@ -8609,10 +9214,13 @@ class UITile extends UIObject
8609
9214
  this.mirror = mirror;
8610
9215
  // set properties
8611
9216
  this.color = color.copy();
9217
+
9218
+ // no shadow by default
9219
+ this.shadowColor = CLEAR_BLACK;
8612
9220
  }
8613
9221
  render()
8614
9222
  {
8615
- uiSystem.drawTile(this.pos, this.size, this.tileInfo, this.color, this.angle, this.mirror);
9223
+ uiSystem.drawTile(this.pos, this.size, this.tileInfo, this.color, this.angle, this.mirror, this.shadowColor, this.shadowBlur, this.shadowOffset);
8616
9224
  }
8617
9225
  }
8618
9226
 
@@ -8637,6 +9245,9 @@ class UIButton extends UIObject
8637
9245
  ASSERT(isString(text), 'ui button must be a string');
8638
9246
  ASSERT(isColor(color), 'ui button color must be a color');
8639
9247
 
9248
+ /** @property {Vector2} - Text offset for the button */
9249
+ this.textOffset = vec2();
9250
+
8640
9251
  // set properties
8641
9252
  this.text = text;
8642
9253
  this.color = color.copy();
@@ -8648,8 +9259,8 @@ class UIButton extends UIObject
8648
9259
 
8649
9260
  // draw the text scaled to fit
8650
9261
  const textSize = this.getTextSize();
8651
- uiSystem.drawText(this.text, this.pos, textSize,
8652
- this.textColor, 0, undefined, this.align, this.font, this.fontStyle, true, this.textShadow);
9262
+ uiSystem.drawText(this.text, this.pos.add(this.textOffset), textSize,
9263
+ this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
8653
9264
  }
8654
9265
  }
8655
9266
 
@@ -8703,7 +9314,7 @@ class UICheckbox extends UIObject
8703
9314
  const textSize = this.getTextSize();
8704
9315
  const pos = this.pos.add(vec2(this.size.x,0));
8705
9316
  uiSystem.drawText(this.text, pos, textSize,
8706
- this.textColor, 0, undefined, 'left', this.font, this.fontStyle, false, this.textShadow);
9317
+ this.textColor, this.textLineWidth, this.textLineColor, 'left', this.font, this.fontStyle, false, this.textShadow);
8707
9318
  }
8708
9319
  }
8709
9320
 
@@ -8745,7 +9356,11 @@ class UIScrollbar extends UIObject
8745
9356
  update()
8746
9357
  {
8747
9358
  super.update();
8748
- if (this.isActiveObject() && this.interactive)
9359
+ if (!this.interactive)
9360
+ return;
9361
+
9362
+ const oldValue = this.value;
9363
+ if (this.isActiveObject())
8749
9364
  {
8750
9365
  // handle horizontal or vertical scrollbar
8751
9366
  const isHorizontal = this.size.x > this.size.y;
@@ -8757,14 +9372,19 @@ class UIScrollbar extends UIObject
8757
9372
  const handleWidth = barSize - handleSize;
8758
9373
  const p1 = centerPos - handleWidth/2;
8759
9374
  const p2 = centerPos + handleWidth/2;
8760
- const oldValue = this.value;
8761
-
8762
9375
  const p = uiSystem.screenToNative(mousePosScreen);
8763
9376
  this.value = isHorizontal ?
8764
9377
  percent(p.x, p1, p2) :
8765
9378
  percent(p.y, p2, p1);
8766
- this.value === oldValue || this.onChange();
8767
9379
  }
9380
+ else if (this.isNavigationObject())
9381
+ {
9382
+ // gamepad/keyboard navigation adjustment
9383
+ const direction = uiSystem.getNavigationOtherDirection();
9384
+ if (!uiSystem.navigationTimer.active())
9385
+ this.value = clamp(this.value + direction*.01);
9386
+ }
9387
+ this.value === oldValue || this.onChange();
8768
9388
  }
8769
9389
  render()
8770
9390
  {
@@ -8789,7 +9409,14 @@ class UIScrollbar extends UIObject
8789
9409
  // draw the text scaled to fit on the scrollbar
8790
9410
  const textSize = this.getTextSize();
8791
9411
  uiSystem.drawText(this.text, this.pos, textSize,
8792
- this.textColor, 0, undefined, this.align, this.font, this.fontStyle, true, this.textShadow);
9412
+ this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
9413
+ }
9414
+ navigatePressed()
9415
+ {
9416
+ // toggle value between 0 and 1
9417
+ this.value = this.value ? 0 : 1;
9418
+ this.onRelease();
9419
+ super.navigatePressed();
8793
9420
  }
8794
9421
  }
8795
9422
 
@@ -8823,7 +9450,7 @@ class UIVideo extends UIObject
8823
9450
  this.color = BLACK; // default to black background
8824
9451
  this.cornerRadius = 0; // default to no corner radius
8825
9452
 
8826
- /** @property {float} - The video volume */
9453
+ /** @property {number} - The video volume */
8827
9454
  this.volume = volume;
8828
9455
 
8829
9456
  // create video element
@@ -8856,7 +9483,7 @@ class UIVideo extends UIObject
8856
9483
 
8857
9484
  /** Check if video is currently loading
8858
9485
  * @return {boolean} */
8859
- isLoadng()
9486
+ isLoading()
8860
9487
  { return this.video.readyState < this.video.HAVE_CURRENT_DATA; }
8861
9488
 
8862
9489
  /** Check if video is currently paused
@@ -8866,7 +9493,7 @@ class UIVideo extends UIObject
8866
9493
  /** Check if video is currently playing
8867
9494
  * @return {boolean} */
8868
9495
  isPlaying()
8869
- { return !this.isPaused() && !this.hasEnded() && !this.isLoadng(); }
9496
+ { return !this.isPaused() && !this.hasEnded() && !this.isLoading(); }
8870
9497
 
8871
9498
  /** Check if video has ended playing
8872
9499
  * @return {boolean} */
@@ -8915,7 +9542,7 @@ class UIVideo extends UIObject
8915
9542
  {
8916
9543
  super.render();
8917
9544
 
8918
- if (this.isLoadng())
9545
+ if (this.isLoading())
8919
9546
  return;
8920
9547
  const context = uiSystem.uiContext;
8921
9548
  const s = this.size;
@@ -10699,7 +11326,7 @@ class Box2dPlugin
10699
11326
  * @param {Vector2} v */
10700
11327
  vec2dTo(v)
10701
11328
  {
10702
- ASSERT(v instanceof Vector2);
11329
+ ASSERT(isVector2(v));
10703
11330
  return new box2d.instance.b2Vec2(v.x, v.y);
10704
11331
  }
10705
11332