littlejsengine 1.15.3 → 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.
package/dist/littlejs.js CHANGED
@@ -33,7 +33,7 @@ const engineName = 'LittleJS';
33
33
  * @type {string}
34
34
  * @default
35
35
  * @memberof Engine */
36
- const engineVersion = '1.15.3';
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
 
@@ -914,7 +914,7 @@ function debugOverlap(posA, sizeA, posB, sizeB, color, time, screenSpace=false)
914
914
  }
915
915
 
916
916
  /** Draw a debug axis aligned bounding box in world space
917
- * @param {string} text
917
+ * @param {string|number} text
918
918
  * @param {Vector2} pos
919
919
  * @param {number} [size]
920
920
  * @param {Color|string} [color]
@@ -1072,34 +1072,46 @@ function debugRender()
1072
1072
  debugTakeScreenshot = 0;
1073
1073
  }
1074
1074
 
1075
- if (debugGamepads && gamepadsEnable && navigator.getGamepads)
1075
+ if (debugGamepads && gamepadsEnable)
1076
1076
  {
1077
- // gamepad debug display
1078
- const gamepads = navigator.getGamepads();
1079
- for (let i = gamepads.length; i--;)
1077
+ // draw gamepads
1078
+ const maxGamepads = 8;
1079
+ let gamepadConnectedCount = 0;
1080
+ for (let i = 0; i < maxGamepads; i++)
1081
+ gamepadConnected(i) && gamepadConnectedCount++;
1082
+
1083
+ for (let i = 0; i < maxGamepads; i++)
1080
1084
  {
1081
- const gamepad = gamepads[i];
1082
- if (gamepad)
1085
+ if (!gamepadConnected(i))
1086
+ continue;
1087
+
1088
+ const stickScale = 1;
1089
+ const buttonScale = .2;
1090
+ const cornerPos = cameraPos.add(vec2(-stickScale*2, ((gamepadConnectedCount-1)/2-i)*stickScale*3));
1091
+ debugText(i, cornerPos.add(vec2(-stickScale, stickScale)), 1);
1092
+ if (i === gamepadPrimary)
1093
+ debugText('Main', cornerPos.add(vec2(-stickScale*2, 0)),1, '#0f0');
1094
+
1095
+ // read analog sticks
1096
+ const stickCount = gamepadStickData[i].length;
1097
+ for (let j = 0; j < stickCount; j++)
1083
1098
  {
1084
- const stickScale = 1;
1085
- const buttonScale = .2;
1086
- const centerPos = cameraPos;
1087
- const sticks = gamepadStickData[i];
1088
- for (let j = sticks.length; j--;)
1089
- {
1090
- const drawPos = centerPos.add(vec2(j*stickScale*2, i*stickScale*3));
1091
- const stickPos = drawPos.add(sticks[j].scale(stickScale));
1092
- debugCircle(drawPos, stickScale*2, '#fff7',0,true);
1093
- debugLine(drawPos, stickPos, '#f00');
1094
- debugPoint(stickPos, '#f00');
1095
- }
1096
- for (let j = gamepad.buttons.length; j--;)
1097
- {
1098
- const drawPos = centerPos.add(vec2(j*buttonScale*2, i*stickScale*3-stickScale-buttonScale));
1099
- const pressed = gamepad.buttons[j].pressed;
1100
- debugCircle(drawPos, buttonScale*2, pressed ? '#f00' : '#fff7', 0, true);
1101
- debugText(''+j, drawPos, .2);
1102
- }
1099
+ const stick = gamepadStick(j, i);
1100
+ const drawPos = cornerPos.add(vec2(j*stickScale*2, 0));
1101
+ const stickPos = drawPos.add(stick.scale(stickScale));
1102
+ debugCircle(drawPos, stickScale*2, '#fff7',0,true);
1103
+ debugLine(drawPos, stickPos, '#f00');
1104
+ debugText(j, drawPos, .3);
1105
+ debugPoint(stickPos, '#f00');
1106
+ }
1107
+
1108
+ const buttonCount = inputData[i+1].length;
1109
+ for (let j = 0; j < buttonCount; j++)
1110
+ {
1111
+ const drawPos = cornerPos.add(vec2(j*buttonScale*2, -stickScale-buttonScale*2));
1112
+ const pressed = gamepadIsDown(j, i);
1113
+ debugCircle(drawPos, buttonScale*2, pressed ? '#f00' : '#fff7', 0, true);
1114
+ debugText(j, drawPos, .3);
1103
1115
  }
1104
1116
  }
1105
1117
  }
@@ -1281,14 +1293,18 @@ function debugRender()
1281
1293
  mousePressed && overlayContext.fillText('Mouse: ' + mousePressed, x, y += h);
1282
1294
  keysPressed && overlayContext.fillText('Keys: ' + keysPressed, x, y += h);
1283
1295
 
1284
- let buttonsPressed = '';
1285
- if (inputData[1])
1286
- for (const i in inputData[1])
1296
+ // show gamepad buttons
1297
+ for (let i = 1; i < inputData.length; i++)
1287
1298
  {
1288
- if (keyIsDown(i, 1))
1289
- buttonsPressed += i + ' ' ;
1299
+ let buttonsPressed = '';
1300
+ if (inputData[i])
1301
+ for (const j in inputData[i])
1302
+ {
1303
+ if (keyIsDown(j, i))
1304
+ buttonsPressed += j + ' ' ;
1305
+ }
1306
+ buttonsPressed && overlayContext.fillText(`Gamepad ${i-1}: ` + buttonsPressed, x, y += h);
1290
1307
  }
1291
- buttonsPressed && overlayContext.fillText('Gamepad: ' + buttonsPressed, x, y += h);
1292
1308
  }
1293
1309
  else
1294
1310
  {
@@ -2044,9 +2060,15 @@ class Vector2
2044
2060
  {
2045
2061
  this.x = x;
2046
2062
  this.y = y;
2063
+ ASSERT_VECTOR2_VALID(this);
2047
2064
  return this;
2048
2065
  }
2049
2066
 
2067
+ /** Sets this vector from another vector and returns self
2068
+ * @param {Vector2} v - other vector
2069
+ * @return {Vector2} */
2070
+ setFrom(v) { return this.set(v.x, v.y); }
2071
+
2050
2072
  /** Returns a new vector that is a copy of this
2051
2073
  * @return {Vector2} */
2052
2074
  copy() { return new Vector2(this.x, this.y); }
@@ -2109,7 +2131,7 @@ class Vector2
2109
2131
  clampLength(length=1)
2110
2132
  {
2111
2133
  const l = this.length();
2112
- return l > length ? this.scale(length/l) : this;
2134
+ return l > length ? this.scale(length/l) : this.copy();
2113
2135
  }
2114
2136
 
2115
2137
  /** Returns the dot product of this and the vector passed in
@@ -2196,6 +2218,10 @@ class Vector2
2196
2218
  * @return {number} */
2197
2219
  area() { return abs(this.x * this.y); }
2198
2220
 
2221
+ /** Returns true if this vector is (0,0)
2222
+ * @return {boolean} */
2223
+ isZero() { return !this.x && !this.y; }
2224
+
2199
2225
  /** Returns a new vector that is p percent between this and the vector passed in
2200
2226
  * @param {Vector2} v - other vector
2201
2227
  * @param {number} percent
@@ -2306,9 +2332,15 @@ class Color
2306
2332
  this.g = g;
2307
2333
  this.b = b;
2308
2334
  this.a = a;
2335
+ ASSERT_COLOR_VALID(this);
2309
2336
  return this;
2310
2337
  }
2311
2338
 
2339
+ /** Sets this color from another color and returns self
2340
+ * @param {Color} c - other color
2341
+ * @return {Color} */
2342
+ setFrom(c) { return this.set(c.r, c.g, c.b, c.a); }
2343
+
2312
2344
  /** Returns a new color that is a copy of this
2313
2345
  * @return {Color} */
2314
2346
  copy() { return new Color(this.r, this.g, this.b, this.a); }
@@ -2860,17 +2892,17 @@ let touchGamepadEnable = false;
2860
2892
  * @memberof Settings */
2861
2893
  let touchGamepadCenterButton = true;
2862
2894
 
2863
- /** True if touch gamepad should be analog stick or false to use if 8 way dpad
2864
- * @type {boolean}
2895
+ /** Number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
2896
+ * @type {number}
2865
2897
  * @default
2866
2898
  * @memberof Settings */
2867
- let touchGamepadAnalog = true;
2899
+ let touchGamepadButtonCount = 4;
2868
2900
 
2869
- /** Number of buttons on touch gamepad
2870
- * @type {number}
2901
+ /** True if touch gamepad should be analog stick or false to use if 8 way dpad
2902
+ * @type {boolean}
2871
2903
  * @default
2872
2904
  * @memberof Settings */
2873
- let touchGamepadButtonCount = 4;
2905
+ let touchGamepadAnalog = true;
2874
2906
 
2875
2907
  /** Size of virtual gamepad for touch devices in pixels
2876
2908
  * @type {number}
@@ -3136,6 +3168,11 @@ function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
3136
3168
  * @memberof Settings */
3137
3169
  function setTouchGamepadCenterButton(enable) { touchGamepadCenterButton = enable; }
3138
3170
 
3171
+ /** Set number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
3172
+ * @param {number} count
3173
+ * @memberof Settings */
3174
+ function setTouchGamepadButtonCount(count) { touchGamepadButtonCount = count; }
3175
+
3139
3176
  /** Set if touch gamepad should be analog stick or 8 way dpad
3140
3177
  * @param {boolean} analog
3141
3178
  * @memberof Settings */
@@ -3344,7 +3381,7 @@ class EngineObject
3344
3381
  child.updateTransforms();
3345
3382
  }
3346
3383
 
3347
- /** Update the object physics, called automatically by engine once each frame */
3384
+ /** Update the object physics, called automatically by engine once each frame. Can be overridden to stop or change how physics works for an object. */
3348
3385
  updatePhysics()
3349
3386
  {
3350
3387
  // child objects do not have physics
@@ -3377,7 +3414,7 @@ class EngineObject
3377
3414
  if (!enablePhysicsSolver || !this.mass) // don't do collision for static objects
3378
3415
  return;
3379
3416
 
3380
- const wasMovingDown = this.velocity.y < 0;
3417
+ const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
3381
3418
  if (this.groundObject)
3382
3419
  {
3383
3420
  // apply friction in local space of ground object
@@ -3433,10 +3470,10 @@ class EngineObject
3433
3470
  {
3434
3471
  // push outside object collision
3435
3472
  this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y);
3436
- if ((o.groundObject && wasMovingDown) || !o.mass)
3473
+ if ((o.groundObject && wasFalling) || !o.mass)
3437
3474
  {
3438
3475
  // set ground object if landed on something
3439
- if (wasMovingDown)
3476
+ if (wasFalling)
3440
3477
  this.groundObject = o;
3441
3478
 
3442
3479
  // bounce if other object is fixed or grounded
@@ -3523,12 +3560,15 @@ class EngineObject
3523
3560
  const restitution = max(this.restitution, hitLayer.restitution);
3524
3561
  this.velocity.y *= -restitution;
3525
3562
 
3526
- if (wasMovingDown)
3563
+ if (wasFalling)
3527
3564
  {
3528
- // adjust position to slightly above nearest tile boundary
3565
+ // adjust position to slightly away from nearest tile
3529
3566
  // this prevents gap between object and ground
3530
3567
  const epsilon = .0001;
3531
- this.pos.y = (oldPos.y-this.size.y/2|0)+this.size.y/2+epsilon;
3568
+ const offset = this.size.y/2 + epsilon;
3569
+ this.pos.y = gravity.y < 0 ?
3570
+ floor(oldPos.y-this.size.y/2) + offset :
3571
+ ceil( oldPos.y+this.size.y/2) - offset;
3532
3572
 
3533
3573
  // set ground object for tile collision
3534
3574
  this.groundObject = hitLayer;
@@ -3697,21 +3737,20 @@ class EngineObject
3697
3737
  * @return {string} */
3698
3738
  toString()
3699
3739
  {
3700
- if (debug)
3701
- {
3702
- let text = 'type = ' + this.constructor.name;
3703
- if (this.pos.x || this.pos.y)
3704
- text += '\npos = ' + this.pos;
3705
- if (this.velocity.x || this.velocity.y)
3706
- text += '\nvelocity = ' + this.velocity;
3707
- if (this.size.x || this.size.y)
3708
- text += '\nsize = ' + this.size;
3709
- if (this.angle)
3710
- text += '\nangle = ' + this.angle.toFixed(3);
3711
- if (this.color)
3712
- text += '\ncolor = ' + this.color;
3713
- return text;
3714
- }
3740
+ if (!debug) return;
3741
+
3742
+ let text = 'type = ' + this.constructor.name;
3743
+ if (this.pos.x || this.pos.y)
3744
+ text += '\npos = ' + this.pos;
3745
+ if (this.velocity.x || this.velocity.y)
3746
+ text += '\nvelocity = ' + this.velocity;
3747
+ if (this.size.x || this.size.y)
3748
+ text += '\nsize = ' + this.size;
3749
+ if (this.angle)
3750
+ text += '\nangle = ' + this.angle.toFixed(3);
3751
+ if (this.color)
3752
+ text += '\ncolor = ' + this.color;
3753
+ return text;
3715
3754
  }
3716
3755
 
3717
3756
  /** Render debug info for this object */
@@ -3880,7 +3919,7 @@ class TileInfo
3880
3919
  this.padding = padding;
3881
3920
  /** @property {TextureInfo} - The texture info for this tile */
3882
3921
  this.textureInfo = textureInfos[this.textureIndex];
3883
- /** @property {float} - Shrinks tile by this many pixels to prevent neighbors bleeding */
3922
+ /** @property {number} - Shrinks tile by this many pixels to prevent neighbors bleeding */
3884
3923
  this.bleedScale = bleedScale;
3885
3924
  }
3886
3925
 
@@ -4367,7 +4406,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
4367
4406
 
4368
4407
  /** Draw text on main canvas in world space
4369
4408
  * Automatically splits new lines into rows
4370
- * @param {string} text
4409
+ * @param {string|number} text
4371
4410
  * @param {Vector2} pos
4372
4411
  * @param {number} [size]
4373
4412
  * @param {Color} [color=(1,1,1,1)]
@@ -4394,7 +4433,7 @@ function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, f
4394
4433
 
4395
4434
  /** Draw text on overlay canvas in world space
4396
4435
  * Automatically splits new lines into rows
4397
- * @param {string} text
4436
+ * @param {string|number} text
4398
4437
  * @param {Vector2} pos
4399
4438
  * @param {number} [size]
4400
4439
  * @param {Color} [color=(1,1,1,1)]
@@ -4413,7 +4452,7 @@ function drawTextOverlay(text, pos, size=1, color, lineWidth=0, lineColor, textA
4413
4452
 
4414
4453
  /** Draw text on overlay canvas in screen space
4415
4454
  * Automatically splits new lines into rows
4416
- * @param {string} text
4455
+ * @param {string|number} text
4417
4456
  * @param {Vector2} pos
4418
4457
  * @param {number} [size]
4419
4458
  * @param {Color} [color=(1,1,1,1)]
@@ -4578,11 +4617,28 @@ function worldToScreenTransform(worldPos, worldSize, worldAngle=0)
4578
4617
  ];
4579
4618
  }
4580
4619
 
4581
- /** Get the camera's visible area in world space
4620
+ /** Get the size of the camera window in world space
4582
4621
  * @return {Vector2}
4583
4622
  * @memberof Draw */
4584
4623
  function getCameraSize() { return mainCanvasSize.scale(1/cameraScale); }
4585
4624
 
4625
+ /** Check if a point or circle is on screen
4626
+ * If size is a Vector2, uses the largest dimension as diameter
4627
+ * This can be used to cull offscreen objects from render or update
4628
+ * @param {Vector2} pos - world space position
4629
+ * @param {Vector2|number} size - world space size or diameter
4630
+ * @return {boolean}
4631
+ * @memberof Draw */
4632
+ function isOnScreen(pos, size=0)
4633
+ {
4634
+ pos = worldToScreen(pos);
4635
+ if (size instanceof Vector2)
4636
+ size = max(size.x, size.y); // use largest dimension
4637
+ size *= cameraScale/2;
4638
+ return pos.x + size > 0 && pos.x - size < mainCanvasSize.x &&
4639
+ pos.y + size > 0 && pos.y - size < mainCanvasSize.y;
4640
+ }
4641
+
4586
4642
  /** Enable normal or additive blend mode
4587
4643
  * @param {boolean} [additive]
4588
4644
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
@@ -4746,7 +4802,7 @@ class FontImage
4746
4802
  }
4747
4803
 
4748
4804
  /** Draw text in world space using the image font
4749
- * @param {string} text
4805
+ * @param {string|number} text
4750
4806
  * @param {Vector2} pos
4751
4807
  * @param {number} [scale=.25]
4752
4808
  * @param {boolean} [center]
@@ -4758,7 +4814,7 @@ class FontImage
4758
4814
  }
4759
4815
 
4760
4816
  /** Draw text on overlay canvas in world space using the image font
4761
- * @param {string} text
4817
+ * @param {string|number} text
4762
4818
  * @param {Vector2} pos
4763
4819
  * @param {number} [scale]
4764
4820
  * @param {boolean} [center]
@@ -4767,7 +4823,7 @@ class FontImage
4767
4823
  { this.drawText(text, pos, scale, center, overlayContext); }
4768
4824
 
4769
4825
  /** Draw text on overlay canvas in screen space using the image font
4770
- * @param {string} text
4826
+ * @param {string|number} text
4771
4827
  * @param {Vector2} pos
4772
4828
  * @param {number} [scale]
4773
4829
  * @param {boolean} [center]
@@ -4811,6 +4867,85 @@ class FontImage
4811
4867
  * @namespace Input
4812
4868
  */
4813
4869
 
4870
+ /** Mouse pos in world space
4871
+ * @type {Vector2}
4872
+ * @memberof Input */
4873
+ let mousePos = vec2();
4874
+
4875
+ /** Mouse pos in screen space
4876
+ * @type {Vector2}
4877
+ * @memberof Input */
4878
+ let mousePosScreen = vec2();
4879
+
4880
+ /** Mouse movement delta in world space
4881
+ * @type {Vector2}
4882
+ * @memberof Input */
4883
+ let mouseDelta = vec2();
4884
+
4885
+ /** Mouse movement delta in screen space
4886
+ * @type {Vector2}
4887
+ * @memberof Input */
4888
+ let mouseDeltaScreen = vec2();
4889
+
4890
+ /** Mouse wheel delta this frame
4891
+ * @type {number}
4892
+ * @memberof Input */
4893
+ let mouseWheel = 0;
4894
+
4895
+ /** True if mouse was inside the document window, set to false when mouse leaves
4896
+ * @type {boolean}
4897
+ * @memberof Input */
4898
+ let mouseInWindow = true;
4899
+
4900
+ /** Returns true if user is using gamepad (has more recently pressed a gamepad button)
4901
+ * @type {boolean}
4902
+ * @memberof Input */
4903
+ let isUsingGamepad = false;
4904
+
4905
+ /** Prevents input continuing to the default browser handling (true by default)
4906
+ * @type {boolean}
4907
+ * @memberof Input */
4908
+ let inputPreventDefault = true;
4909
+
4910
+ /** Primary gamepad index, automatically set to first gamepad with input
4911
+ * @type {number}
4912
+ * @memberof Input */
4913
+ let gamepadPrimary = 0;
4914
+
4915
+ /** Prevents input continuing to the default browser handling
4916
+ * This is useful to disable for html menus so the browser can handle input normally
4917
+ * @param {boolean} preventDefault
4918
+ * @memberof Input */
4919
+ function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
4920
+
4921
+ /** Clears an input key state
4922
+ * @param {string|number} key
4923
+ * @param {number} [device]
4924
+ * @param {boolean} [clearDown=true]
4925
+ * @param {boolean} [clearPressed=true]
4926
+ * @param {boolean} [clearReleased=true]
4927
+ * @memberof Input */
4928
+ function inputClearKey(key, device=0, clearDown=true, clearPressed=true, clearReleased=true)
4929
+ {
4930
+ if (!inputData[device])
4931
+ return;
4932
+ inputData[device][key] &= ~((clearDown?1:0)|(clearPressed?2:0)|(clearReleased?4:0));
4933
+ }
4934
+
4935
+ /** Clears all input
4936
+ * @memberof Input */
4937
+ function inputClear()
4938
+ {
4939
+ inputData.length = 0;
4940
+ inputData[0] = [];
4941
+ touchGamepadButtons.length = 0;
4942
+ touchGamepadSticks.length = 0;
4943
+ gamepadStickData.length = 0;
4944
+ gamepadDpadData.length = 0;
4945
+ }
4946
+
4947
+ ///////////////////////////////////////////////////////////////////////////////
4948
+
4814
4949
  /** Returns true if device key is down
4815
4950
  * @param {string|number} key
4816
4951
  * @param {number} [device]
@@ -4818,9 +4953,9 @@ class FontImage
4818
4953
  * @memberof Input */
4819
4954
  function keyIsDown(key, device=0)
4820
4955
  {
4821
- ASSERT(key !== undefined, 'key is undefined');
4956
+ ASSERT(isString(key), 'key must be a number or string');
4822
4957
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4823
- return inputData[device] && !!(inputData[device][key] & 1);
4958
+ return !!(inputData[device]?.[key] & 1);
4824
4959
  }
4825
4960
 
4826
4961
  /** Returns true if device key was pressed this frame
@@ -4830,9 +4965,9 @@ function keyIsDown(key, device=0)
4830
4965
  * @memberof Input */
4831
4966
  function keyWasPressed(key, device=0)
4832
4967
  {
4833
- ASSERT(key !== undefined, 'key is undefined');
4968
+ ASSERT(isString(key), 'key must be a number or string');
4834
4969
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4835
- return inputData[device] && !!(inputData[device][key] & 2);
4970
+ return !!(inputData[device]?.[key] & 2);
4836
4971
  }
4837
4972
 
4838
4973
  /** Returns true if device key was released this frame
@@ -4842,172 +4977,182 @@ function keyWasPressed(key, device=0)
4842
4977
  * @memberof Input */
4843
4978
  function keyWasReleased(key, device=0)
4844
4979
  {
4845
- ASSERT(key !== undefined, 'key is undefined');
4980
+ ASSERT(isString(key), 'key must be a number or string');
4846
4981
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4847
- return inputData[device] && !!(inputData[device][key] & 4);
4982
+ return !!(inputData[device]?.[key] & 4);
4848
4983
  }
4849
4984
 
4850
4985
  /** Returns input vector from arrow keys or WASD if enabled
4986
+ * @param {string} [up]
4987
+ * @param {string} [down]
4988
+ * @param {string} [left]
4989
+ * @param {string} [right]
4851
4990
  * @return {Vector2}
4852
4991
  * @memberof Input */
4853
4992
  function keyDirection(up='ArrowUp', down='ArrowDown', left='ArrowLeft', right='ArrowRight')
4854
4993
  {
4994
+ ASSERT(isString(up), 'up key must be a string');
4995
+ ASSERT(isString(down), 'down key must be a string');
4996
+ ASSERT(isString(left), 'left key must be a string');
4997
+ ASSERT(isString(right), 'right key must be a string');
4855
4998
  const k = (key)=> keyIsDown(key) ? 1 : 0;
4856
4999
  return vec2(k(right) - k(left), k(up) - k(down));
4857
5000
  }
4858
5001
 
4859
- /** Clears all input
4860
- * @memberof Input */
4861
- function inputClear() { inputData = [[]]; touchGamepadButtons = []; }
4862
-
4863
- /** Clears an input key state
4864
- * @param {string|number} key
4865
- * @param {number} [device]
4866
- * @param {boolean} [clearDown=true]
4867
- * @param {boolean} [clearPressed=true]
4868
- * @param {boolean} [clearReleased=true]
4869
- * @memberof Input */
4870
- function inputClearKey(key, device=0, clearDown=true, clearPressed=true, clearReleased=true)
4871
- {
4872
- if (!inputData[device])
4873
- return;
4874
- inputData[device][key] &= ~((clearDown?1:0)|(clearPressed?2:0)|(clearReleased?4:0));
4875
- }
4876
-
4877
5002
  /** Returns true if mouse button is down
4878
5003
  * @function
4879
5004
  * @param {number} button
4880
5005
  * @return {boolean}
4881
5006
  * @memberof Input */
4882
- function mouseIsDown(button) { return keyIsDown(button); }
5007
+ function mouseIsDown(button)
5008
+ {
5009
+ ASSERT(isNumber(button), 'mouse button must be a number');
5010
+ return keyIsDown(button);
5011
+ }
4883
5012
 
4884
5013
  /** Returns true if mouse button was pressed
4885
5014
  * @function
4886
5015
  * @param {number} button
4887
5016
  * @return {boolean}
4888
5017
  * @memberof Input */
4889
- function mouseWasPressed(button) { return keyWasPressed(button); }
5018
+ function mouseWasPressed(button)
5019
+ {
5020
+ ASSERT(isNumber(button), 'mouse button must be a number');
5021
+ return keyWasPressed(button);
5022
+ }
4890
5023
 
4891
5024
  /** Returns true if mouse button was released
4892
5025
  * @function
4893
5026
  * @param {number} button
4894
5027
  * @return {boolean}
4895
5028
  * @memberof Input */
4896
- function mouseWasReleased(button) { return keyWasReleased(button); }
4897
-
4898
- /** Mouse pos in world space
4899
- * @type {Vector2}
4900
- * @memberof Input */
4901
- let mousePos = vec2();
4902
-
4903
- /** Mouse pos in screen space
4904
- * @type {Vector2}
4905
- * @memberof Input */
4906
- let mousePosScreen = vec2();
4907
-
4908
- /** Mouse movement delta in world space
4909
- * @type {Vector2}
4910
- * @memberof Input */
4911
- let mouseDelta = vec2();
4912
-
4913
- /** Mouse movement delta in screen space
4914
- * @type {Vector2}
4915
- * @memberof Input */
4916
- let mouseDeltaScreen = vec2();
4917
-
4918
- /** Mouse wheel delta this frame
4919
- * @type {number}
4920
- * @memberof Input */
4921
- let mouseWheel = 0;
4922
-
4923
- /** True if mouse was inside the document window, set to false when mouse leaves
4924
- * @type {boolean}
4925
- * @memberof Input */
4926
- let mouseInWindow = true;
4927
-
4928
- /** Returns true if user is using gamepad (has more recently pressed a gamepad button)
4929
- * @type {boolean}
4930
- * @memberof Input */
4931
- let isUsingGamepad = false;
4932
-
4933
- /** Prevents input continuing to the default browser handling (true by default)
4934
- * @type {boolean}
4935
- * @memberof Input */
4936
- let inputPreventDefault = true;
4937
-
4938
- /** Prevents input continuing to the default browser handling
4939
- * This is useful to disable for html menus so the browser can handle input normally
4940
- * @param {boolean} preventDefault
4941
- * @memberof Input */
4942
- function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
5029
+ function mouseWasReleased(button)
5030
+ {
5031
+ ASSERT(isNumber(button), 'mouse button must be a number');
5032
+ return keyWasReleased(button);
5033
+ }
4943
5034
 
4944
5035
  /** Returns true if gamepad button is down
4945
5036
  * @param {number} button
4946
5037
  * @param {number} [gamepad]
4947
5038
  * @return {boolean}
4948
5039
  * @memberof Input */
4949
- function gamepadIsDown(button, gamepad=0)
4950
- { return keyIsDown(button, gamepad+1); }
5040
+ function gamepadIsDown(button, gamepad=gamepadPrimary)
5041
+ {
5042
+ ASSERT(isNumber(button), 'button must be a number');
5043
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
5044
+ return keyIsDown(button, gamepad+1);
5045
+ }
4951
5046
 
4952
5047
  /** Returns true if gamepad button was pressed
4953
5048
  * @param {number} button
4954
5049
  * @param {number} [gamepad]
4955
5050
  * @return {boolean}
4956
5051
  * @memberof Input */
4957
- function gamepadWasPressed(button, gamepad=0)
4958
- { return keyWasPressed(button, gamepad+1); }
5052
+ function gamepadWasPressed(button, gamepad=gamepadPrimary)
5053
+ {
5054
+ ASSERT(isNumber(button), 'button must be a number');
5055
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
5056
+ return keyWasPressed(button, gamepad+1);
5057
+ }
4959
5058
 
4960
5059
  /** Returns true if gamepad button was released
4961
5060
  * @param {number} button
4962
5061
  * @param {number} [gamepad]
4963
5062
  * @return {boolean}
4964
5063
  * @memberof Input */
4965
- function gamepadWasReleased(button, gamepad=0)
4966
- { return keyWasReleased(button, gamepad+1); }
5064
+ function gamepadWasReleased(button, gamepad=gamepadPrimary)
5065
+ {
5066
+ ASSERT(isNumber(button), 'button must be a number');
5067
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
5068
+ return keyWasReleased(button, gamepad+1);
5069
+ }
4967
5070
 
4968
5071
  /** Returns gamepad stick value
4969
5072
  * @param {number} stick
4970
5073
  * @param {number} [gamepad]
4971
5074
  * @return {Vector2}
4972
5075
  * @memberof Input */
4973
- function gamepadStick(stick, gamepad=0)
4974
- { return gamepadStickData[gamepad] ? gamepadStickData[gamepad][stick] || vec2() : vec2(); }
4975
-
4976
- ///////////////////////////////////////////////////////////////////////////////
4977
- // Input system functions called automatically by engine
5076
+ function gamepadStick(stick, gamepad=gamepadPrimary)
5077
+ {
5078
+ ASSERT(isNumber(stick), 'stick must be a number');
5079
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
5080
+ return gamepadStickData[gamepad]?.[stick] ?? vec2();
5081
+ }
4978
5082
 
4979
- // input is stored as a bit field for each key: 1 = isDown, 2 = wasPressed, 4 = wasReleased
4980
- // mouse and keyboard are stored together in device 0, gamepads are in devices > 0
4981
- let inputData = [[]];
5083
+ /** Returns gamepad dpad value
5084
+ * @param {number} [gamepad]
5085
+ * @return {Vector2}
5086
+ * @memberof Input */
5087
+ function gamepadDpad(gamepad=gamepadPrimary)
5088
+ {
5089
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
5090
+ return gamepadDpadData[gamepad] ?? vec2();
5091
+ }
4982
5092
 
4983
- function inputUpdate()
5093
+ /** Returns true if passed in gamepad is connected
5094
+ * @param {number} [gamepad]
5095
+ * @return {boolean}
5096
+ * @memberof Input */
5097
+ function gamepadConnected(gamepad=gamepadPrimary)
4984
5098
  {
4985
- if (headlessMode) return;
5099
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
5100
+ return !!inputData[gamepad+1];
5101
+ }
4986
5102
 
4987
- // clear input when lost focus (prevent stuck keys)
4988
- if (!(touchInputEnable && isTouchDevice) && !document.hasFocus())
4989
- inputClear();
5103
+ /** True if a touch device has been detected
5104
+ * @memberof Input */
5105
+ const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
4990
5106
 
4991
- // update mouse world space position and delta
4992
- mousePos = screenToWorld(mousePosScreen);
4993
- mouseDelta = screenToWorldDelta(mouseDeltaScreen);
5107
+ ///////////////////////////////////////////////////////////////////////////////
4994
5108
 
4995
- // update gamepads if enabled
4996
- gamepadsUpdate();
5109
+ /** Pulse the vibration hardware if it exists
5110
+ * @param {number|Array} [pattern] - single value in ms or vibration interval array
5111
+ * @memberof Input */
5112
+ function vibrate(pattern=100)
5113
+ {
5114
+ ASSERT(isNumber(pattern) || isArray(pattern), 'pattern must be a number or array');
5115
+ vibrateEnable && !headlessMode && navigator && navigator.vibrate && navigator.vibrate(pattern);
4997
5116
  }
4998
5117
 
4999
- function inputUpdatePost()
5000
- {
5001
- if (headlessMode) return;
5118
+ /** Cancel any ongoing vibration
5119
+ * @memberof Input */
5120
+ function vibrateStop() { vibrate(0); }
5002
5121
 
5003
- // clear input to prepare for next frame
5004
- for (const deviceInputData of inputData)
5005
- for (const i in deviceInputData)
5006
- deviceInputData[i] &= 1;
5007
- mouseWheel = 0;
5008
- mouseDelta = vec2();
5009
- mouseDeltaScreen = vec2();
5010
- }
5122
+ ///////////////////////////////////////////////////////////////////////////////
5123
+ // Pointer Lock
5124
+
5125
+ /** Request to lock the pointer, does not work on touch devices
5126
+ * @memberof Input */
5127
+ function pointerLockRequest()
5128
+ { !isTouchDevice && mainCanvas.requestPointerLock?.(); }
5129
+
5130
+ /** Request to unlock the pointer
5131
+ * @memberof Input */
5132
+ function pointerLockExit()
5133
+ { document.exitPointerLock?.(); }
5134
+
5135
+ /** Check if pointer is locked (true if locked)
5136
+ * @return {boolean}
5137
+ * @memberof Input */
5138
+ function pointerLockIsActive()
5139
+ { return document.pointerLockElement === mainCanvas; }
5140
+
5141
+ ///////////////////////////////////////////////////////////////////////////////
5142
+ // Input variables used by engine
5143
+
5144
+ // input uses bit field for each key: 1=isDown, 2=wasPressed, 4=wasReleased
5145
+ // mouse and keyboard stored in device 0, gamepads stored in devices > 0
5146
+ const inputData = [[]];
5147
+
5148
+ // gamepad internal variables
5149
+ const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
5150
+
5151
+ // touch gamepad internal variables
5152
+ const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
5153
+
5154
+ ///////////////////////////////////////////////////////////////////////////////
5155
+ // Input system functions used by engine
5011
5156
 
5012
5157
  function inputInit()
5013
5158
  {
@@ -5037,6 +5182,18 @@ function inputInit()
5037
5182
  if (inputWASDEmulateDirection)
5038
5183
  inputData[0][remapKey(e.code)] = 3;
5039
5184
  }
5185
+
5186
+ // prevent arrow key from moving the page
5187
+ const preventDefaultKeys =
5188
+ [
5189
+ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', // scrolling
5190
+ 'Space', // page down scroll
5191
+ 'Tab', // focus navigation
5192
+ 'Backspace', // browser back
5193
+ ];
5194
+ if (preventDefaultKeys.includes(e.code))
5195
+ if (inputPreventDefault && document.hasFocus() && e.cancelable)
5196
+ e.preventDefault();
5040
5197
  }
5041
5198
  function onKeyUp(e)
5042
5199
  {
@@ -5068,7 +5225,9 @@ function inputInit()
5068
5225
  const mousePosScreenLast = mousePosScreen;
5069
5226
  mousePosScreen = mouseEventToScreen(vec2(e.x,e.y));
5070
5227
  mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
5071
- inputPreventDefault && e.button && e.preventDefault();
5228
+
5229
+ if (inputPreventDefault && document.hasFocus() && e.cancelable)
5230
+ e.preventDefault();
5072
5231
  }
5073
5232
  function onMouseUp(e)
5074
5233
  {
@@ -5092,344 +5251,386 @@ function inputInit()
5092
5251
  function onMouseWheel(e) { mouseWheel = e.ctrlKey ? 0 : sign(e.deltaY); }
5093
5252
  function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
5094
5253
  function onBlur() { inputClear(); } // reset input when focus is lost
5095
- }
5096
5254
 
5097
- // convert a mouse or touch event position to screen space
5098
- function mouseEventToScreen(mousePos)
5099
- {
5100
- const rect = mainCanvas.getBoundingClientRect();
5101
- const px = percent(mousePos.x, rect.left, rect.right);
5102
- const py = percent(mousePos.y, rect.top, rect.bottom);
5103
- return vec2(px*mainCanvas.width, py*mainCanvas.height);
5104
- }
5255
+ // enable touch input mouse passthrough
5256
+ function touchInputInit()
5257
+ {
5258
+ // add non passive touch event listeners
5259
+ document.addEventListener('touchstart', (e) => handleTouch(e), { passive: false });
5260
+ document.addEventListener('touchmove', (e) => handleTouch(e), { passive: false });
5261
+ document.addEventListener('touchend', (e) => handleTouch(e), { passive: false });
5105
5262
 
5106
- ///////////////////////////////////////////////////////////////////////////////
5107
- // Gamepad input
5263
+ // handle all touch events the same way
5264
+ let wasTouching;
5265
+ function handleTouch(e)
5266
+ {
5267
+ if (!touchInputEnable)
5268
+ return;
5108
5269
 
5109
- // gamepad internal variables
5110
- const gamepadStickData = [];
5270
+ // route touch to gamepad
5271
+ if (touchGamepadEnable)
5272
+ handleTouchGamepad(e);
5111
5273
 
5112
- // gamepads are updated by engine every frame automatically
5113
- function gamepadsUpdate()
5114
- {
5115
- const applyDeadZones = (v)=>
5116
- {
5117
- const min=.3, max=.8;
5118
- const deadZone = (v)=>
5119
- v > min ? percent(v, min, max) :
5120
- v < -min ? -percent(-v, min, max) : 0;
5121
- return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
5122
- }
5274
+ // fix stalled audio requiring user interaction
5275
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
5276
+ audioContext.resume();
5123
5277
 
5124
- // update touch gamepad if enabled
5125
- if (touchGamepadEnable && isTouchDevice)
5126
- {
5127
- if (!touchGamepadTimer.isSet())
5128
- return;
5278
+ // check if touching and pass to mouse events
5279
+ const touching = e.touches.length;
5280
+ const button = 0; // all touches are left mouse button
5281
+ if (touching)
5282
+ {
5283
+ // set event pos and pass it along
5284
+ const pos = vec2(e.touches[0].clientX, e.touches[0].clientY);
5285
+ const mousePosScreenLast = mousePosScreen;
5286
+ mousePosScreen = mouseEventToScreen(pos);
5287
+ if (wasTouching)
5288
+ {
5289
+ mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
5290
+ isUsingGamepad = touchGamepadEnable;
5291
+ }
5292
+ else
5293
+ inputData[0][button] = 3;
5294
+ }
5295
+ else if (wasTouching)
5296
+ inputData[0][button] = inputData[0][button] & 2 | 4;
5129
5297
 
5130
- // read virtual analog stick
5131
- const sticks = gamepadStickData[0] || (gamepadStickData[0] = []);
5132
- sticks[0] = vec2();
5133
- if (touchGamepadAnalog)
5134
- sticks[0] = applyDeadZones(touchGamepadStick);
5135
- else if (touchGamepadStick.lengthSquared() > .3)
5136
- {
5137
- // convert to 8 way dpad
5138
- sticks[0].x = round(touchGamepadStick.x);
5139
- sticks[0].y = -round(touchGamepadStick.y);
5140
- sticks[0] = sticks[0].clampLength();
5298
+ // set was touching
5299
+ wasTouching = touching;
5300
+
5301
+ // prevent default handling like copy, magnifier lens, and scrolling
5302
+ if (inputPreventDefault && document.hasFocus() && e.cancelable)
5303
+ e.preventDefault();
5304
+
5305
+ // must return true so the document will get focus
5306
+ return true;
5141
5307
  }
5142
5308
 
5143
- // read virtual gamepad buttons
5144
- const data = inputData[1] || (inputData[1] = []);
5145
- for (let i=10; i--;)
5309
+ // special handling for virtual gamepad mode
5310
+ function handleTouchGamepad(e)
5146
5311
  {
5147
- const wasDown = gamepadIsDown(i,0);
5148
- data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
5312
+ // clear touch gamepad input
5313
+ touchGamepadSticks.length = 0;
5314
+ touchGamepadSticks[0] = vec2();
5315
+ touchGamepadSticks[1] = vec2();
5316
+ touchGamepadButtons.length = 0;
5317
+ isUsingGamepad = true;
5318
+
5319
+ const touching = e.touches.length;
5320
+ if (touching)
5321
+ {
5322
+ touchGamepadTimer.set();
5323
+ if (touchGamepadCenterButton && !wasTouching && paused)
5324
+ {
5325
+ // touch anywhere to press start when paused
5326
+ touchGamepadButtons[9] = 1;
5327
+ return;
5328
+ }
5329
+ }
5330
+
5331
+ // don't process touch gamepad if paused
5332
+ if (paused)
5333
+ return;
5334
+
5335
+ // get center of left and right sides
5336
+ const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5337
+ const buttonCenter = touchGamepadButtonCenter();
5338
+ const startCenter = mainCanvasSize.scale(.5);
5339
+
5340
+ // check each touch point
5341
+ for (const touch of e.touches)
5342
+ {
5343
+ const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
5344
+ if (stickCenter.distance(touchPos) < touchGamepadSize)
5345
+ {
5346
+ // virtual analog stick
5347
+ const delta = touchPos.subtract(stickCenter);
5348
+ touchGamepadSticks[0] = delta.scale(2/touchGamepadSize).clampLength();
5349
+ }
5350
+ else if (buttonCenter.distance(touchPos) < touchGamepadSize)
5351
+ {
5352
+ if (touchGamepadButtonCount === 1)
5353
+ {
5354
+ // virtual right analog stick
5355
+ const delta = touchPos.subtract(buttonCenter);
5356
+ touchGamepadSticks[1] = delta.scale(2/touchGamepadSize).clampLength();
5357
+ }
5358
+ // virtual face buttons
5359
+ let button = buttonCenter.subtract(touchPos).direction();
5360
+ button = mod(button+2, 4);
5361
+ if (touchGamepadButtonCount === 1)
5362
+ button = 0;
5363
+ else if (touchGamepadButtonCount === 2)
5364
+ {
5365
+ const delta = buttonCenter.subtract(touchPos);
5366
+ button = -delta.x < delta.y ? 1 : 0;
5367
+ }
5368
+ // fix button locations (swap 2 and 3 to match gamepad layout)
5369
+ button = button === 3 ? 2 : button === 2 ? 3 : button;
5370
+ if (button < touchGamepadButtonCount)
5371
+ touchGamepadButtons[button] = 1;
5372
+ }
5373
+ else if (touchGamepadCenterButton &&
5374
+ startCenter.distance(touchPos) < touchGamepadSize)
5375
+ {
5376
+ // virtual start button in center
5377
+ touchGamepadButtons[9] = 1;
5378
+ }
5379
+ }
5149
5380
  }
5381
+ }
5150
5382
 
5151
- // disable normal gamepads when touch gamepad is active
5152
- return;
5383
+ // convert a mouse or touch event position to screen space
5384
+ function mouseEventToScreen(mousePos)
5385
+ {
5386
+ const rect = mainCanvas.getBoundingClientRect();
5387
+ const px = percent(mousePos.x, rect.left, rect.right);
5388
+ const py = percent(mousePos.y, rect.top, rect.bottom);
5389
+ return vec2(px*mainCanvas.width, py*mainCanvas.height);
5153
5390
  }
5391
+ }
5154
5392
 
5155
- // return if gamepads are disabled or not supported
5156
- if (!gamepadsEnable || !navigator || !navigator.getGamepads)
5157
- return;
5393
+ function inputUpdate()
5394
+ {
5395
+ if (headlessMode) return;
5158
5396
 
5159
- // only poll gamepads when focused or in debug mode
5160
- if (!debug && !document.hasFocus())
5161
- return;
5397
+ // clear input when lost focus (prevent stuck keys)
5398
+ if (!(touchInputEnable && isTouchDevice) && !document.hasFocus())
5399
+ inputClear();
5400
+
5401
+ // update mouse world space position and delta
5402
+ mousePos = screenToWorld(mousePosScreen);
5403
+ mouseDelta = screenToWorldDelta(mouseDeltaScreen);
5162
5404
 
5163
- // poll gamepads
5164
- const gamepads = navigator.getGamepads();
5165
- for (let i = gamepads.length; i--;)
5405
+ // update gamepads if enabled
5406
+ gamepadsUpdate();
5407
+
5408
+ // gamepads are updated by engine every frame automatically
5409
+ function gamepadsUpdate()
5166
5410
  {
5167
- // get or create gamepad data
5168
- const gamepad = gamepads[i];
5169
- const data = inputData[i+1] || (inputData[i+1] = []);
5170
- const sticks = gamepadStickData[i] || (gamepadStickData[i] = []);
5411
+ const applyDeadZones = (v)=>
5412
+ {
5413
+ const min=.3, max=.8;
5414
+ const deadZone = (v)=>
5415
+ v > min ? percent(v, min, max) :
5416
+ v < -min ? -percent(-v, min, max) : 0;
5417
+ return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
5418
+ }
5171
5419
 
5172
- if (gamepad)
5420
+ // update touch gamepad if enabled
5421
+ if (touchGamepadEnable && isTouchDevice)
5173
5422
  {
5423
+ if (!touchGamepadTimer.isSet())
5424
+ return;
5425
+
5426
+ // read virtual analog stick
5427
+ gamepadPrimary = 0; // touch gamepad uses index 0
5428
+ const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
5429
+ const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
5430
+ sticks[0] = vec2();
5431
+ dpad.set();
5432
+ const leftTouchStick = touchGamepadSticks[0] ?? vec2();
5433
+ if (touchGamepadAnalog)
5434
+ sticks[0] = applyDeadZones(leftTouchStick);
5435
+ else if (leftTouchStick.lengthSquared() > .3)
5436
+ {
5437
+ // convert to 8 way dpad
5438
+ const x = clamp(round(leftTouchStick.x), -1, 1);
5439
+ const y = clamp(round(leftTouchStick.y), -1, 1);
5440
+ dpad.set(x, -y);
5441
+ sticks[0] = dpad.clampLength(); // clamp to circle
5442
+ }
5443
+ const rightTouchStick = touchGamepadSticks[1] ?? vec2();
5444
+ if (touchGamepadButtonCount === 1)
5445
+ sticks[1] = applyDeadZones(rightTouchStick);
5446
+
5447
+ // read virtual gamepad buttons
5448
+ const data = inputData[1] ?? (inputData[1] = []);
5449
+ for (let i=10; i--;)
5450
+ {
5451
+ const wasDown = gamepadIsDown(i,0);
5452
+ data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
5453
+ }
5454
+
5455
+ // disable normal gamepads when touch gamepad is active
5456
+ return;
5457
+ }
5458
+
5459
+ // return if gamepads are disabled or not supported
5460
+ if (!gamepadsEnable || !navigator || !navigator.getGamepads)
5461
+ return;
5462
+
5463
+ // only poll gamepads when focused or in debug mode
5464
+ if (!debug && !document.hasFocus())
5465
+ return;
5466
+
5467
+ // poll gamepads
5468
+ const maxGamepads = 8;
5469
+ const gamepads = navigator.getGamepads();
5470
+ const gamepadCount = min(maxGamepads, gamepads.length)
5471
+ for (let i=0; i<gamepadCount; ++i)
5472
+ {
5473
+ // get or create gamepad data
5474
+ const gamepad = gamepads[i];
5475
+ if (!gamepad)
5476
+ {
5477
+ // clear gamepad data if not connected
5478
+ inputData[i+1] = undefined;
5479
+ gamepadStickData[i] = undefined;
5480
+ gamepadDpadData[i] = undefined;
5481
+ gamepadHadInput[i] = undefined;
5482
+ continue;
5483
+ }
5484
+
5485
+ const data = inputData[i+1] ?? (inputData[i+1] = []);
5486
+ const sticks = gamepadStickData[i] ?? (gamepadStickData[i] = []);
5487
+ const dpad = gamepadDpadData[i] ?? (gamepadDpadData[i] = vec2());
5488
+
5174
5489
  // read analog sticks
5175
5490
  for (let j = 0; j < gamepad.axes.length-1; j+=2)
5176
5491
  sticks[j>>1] = applyDeadZones(vec2(gamepad.axes[j],gamepad.axes[j+1]));
5177
5492
 
5178
5493
  // read buttons
5494
+ let hadInput = false;
5179
5495
  for (let j = gamepad.buttons.length; j--;)
5180
5496
  {
5181
5497
  const button = gamepad.buttons[j];
5182
5498
  const wasDown = gamepadIsDown(j,i);
5183
5499
  data[j] = button.pressed ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
5184
- if (!button.value || button.value > .9) // must be a full press
5185
- if (!i && button.pressed)
5186
- isUsingGamepad = true;
5500
+
5501
+ // check for any input on this gamepad, analog must be full press
5502
+ if (button.pressed)
5503
+ if (!button.value || button.value > .9)
5504
+ hadInput = true;
5505
+ }
5506
+
5507
+ // set new primary gamepad if current is not connected
5508
+ if (hadInput)
5509
+ {
5510
+ gamepadHadInput[i] = true;
5511
+ if (!gamepadHadInput[gamepadPrimary])
5512
+ gamepadPrimary = i;
5513
+ isUsingGamepad ||= (gamepadPrimary === i);
5187
5514
  }
5188
5515
 
5189
- if (gamepadDirectionEmulateStick)
5516
+ if (gamepad.mapping === 'standard')
5190
5517
  {
5191
- // copy dpad to left analog stick when pressed
5192
- const dpad = vec2(
5518
+ // get dpad buttons (standard mapping)
5519
+ dpad.set(
5193
5520
  (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
5194
5521
  (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
5195
- if (dpad.lengthSquared())
5196
- sticks[0] = dpad.clampLength();
5522
+ }
5523
+ else if (gamepad.axes && gamepad.axes.length >= 2)
5524
+ {
5525
+ // digital style dpad from axes
5526
+ const x = clamp(round(gamepad.axes[0]), -1, 1);
5527
+ const y = clamp(round(gamepad.axes[1]), -1, 1);
5528
+ dpad.set(x, -y);
5197
5529
  }
5198
5530
 
5199
- // disable touch gamepad if using real gamepad
5200
- touchGamepadEnable && isUsingGamepad && touchGamepadTimer.unset();
5531
+ // copy dpad to left analog stick when pressed
5532
+ if (gamepadDirectionEmulateStick && !dpad.isZero())
5533
+ sticks[0] = dpad.clampLength();
5201
5534
  }
5535
+
5536
+ // disable touch gamepad if using real gamepad
5537
+ touchGamepadEnable && isUsingGamepad && touchGamepadTimer.unset();
5202
5538
  }
5203
5539
  }
5204
5540
 
5205
- ///////////////////////////////////////////////////////////////////////////////
5206
-
5207
- /** Pulse the vibration hardware if it exists
5208
- * @param {number|Array} [pattern] - single value in ms or vibration interval array
5209
- * @memberof Input */
5210
- function vibrate(pattern=100)
5211
- { vibrateEnable && !headlessMode && navigator && navigator.vibrate && navigator.vibrate(pattern); }
5212
-
5213
- /** Cancel any ongoing vibration
5214
- * @memberof Input */
5215
- function vibrateStop() { vibrate(0); }
5216
-
5217
- ///////////////////////////////////////////////////////////////////////////////
5218
- // Touch input & virtual on screen gamepad
5219
-
5220
- /** True if a touch device has been detected
5221
- * @memberof Input */
5222
- const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
5223
-
5224
- // touch gamepad internal variables
5225
- let touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadStick = vec2();
5226
-
5227
- function touchGamepadButtonCenter()
5541
+ function inputUpdatePost()
5228
5542
  {
5229
- // draw right face buttons
5230
- const center = vec2(mainCanvasSize.x-touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5231
- if (touchGamepadButtonCount <= 2)
5232
- center.x += touchGamepadSize/2;
5233
- return center;
5543
+ if (headlessMode) return;
5544
+
5545
+ // clear input to prepare for next frame
5546
+ for (const deviceInputData of inputData)
5547
+ for (const i in deviceInputData)
5548
+ deviceInputData[i] &= 1;
5549
+ mouseWheel = 0;
5550
+ mouseDelta = vec2();
5551
+ mouseDeltaScreen = vec2();
5234
5552
  }
5235
5553
 
5236
- // enable touch input mouse passthrough
5237
- function touchInputInit()
5554
+ function inputRender()
5238
5555
  {
5239
- // add non passive touch event listeners
5240
- document.addEventListener('touchstart', (e) => handleTouch(e), { passive: false });
5241
- document.addEventListener('touchmove', (e) => handleTouch(e), { passive: false });
5242
- document.addEventListener('touchend', (e) => handleTouch(e), { passive: false });
5556
+ touchGamepadRender();
5243
5557
 
5244
- // handle all touch events the same way
5245
- let wasTouching;
5246
- function handleTouch(e)
5558
+ function touchGamepadRender()
5247
5559
  {
5248
- if (!touchInputEnable)
5560
+ if (!touchInputEnable || !isTouchDevice || headlessMode) return;
5561
+ if (!touchGamepadEnable || !touchGamepadTimer.isSet())
5249
5562
  return;
5250
5563
 
5251
- // route touch to gamepad
5252
- if (touchGamepadEnable)
5253
- handleTouchGamepad(e);
5564
+ // fade off when not touching or paused
5565
+ const alpha = percent(touchGamepadTimer.get(), 4, 3);
5566
+ if (!alpha || paused)
5567
+ return;
5254
5568
 
5255
- // fix stalled audio requiring user interaction
5256
- if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
5257
- audioContext.resume();
5569
+ // setup the canvas
5570
+ const context = overlayContext;
5571
+ context.save();
5572
+ context.globalAlpha = alpha*touchGamepadAlpha;
5573
+ context.strokeStyle = '#fff';
5574
+ context.lineWidth = 3;
5258
5575
 
5259
- // check if touching and pass to mouse events
5260
- const touching = e.touches.length;
5261
- const button = 0; // all touches are left mouse button
5262
- if (touching)
5576
+ // draw left analog stick
5577
+ const leftTouchStick = touchGamepadSticks[0] ?? vec2();
5578
+ context.fillStyle = leftTouchStick.lengthSquared() > 0 ? '#fff' : '#000';
5579
+ context.beginPath();
5580
+ const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5581
+ if (touchGamepadAnalog)
5263
5582
  {
5264
- // set event pos and pass it along
5265
- const pos = vec2(e.touches[0].clientX, e.touches[0].clientY);
5266
- const mousePosScreenLast = mousePosScreen;
5267
- mousePosScreen = mouseEventToScreen(pos);
5268
- if (wasTouching)
5269
- {
5270
- mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
5271
- isUsingGamepad = touchGamepadEnable;
5272
- }
5273
- else
5274
- inputData[0][button] = 3;
5583
+ // draw circle shaped gamepad
5584
+ context.arc(stickCenter.x, stickCenter.y, touchGamepadSize/2, 0, 9);
5275
5585
  }
5276
- else if (wasTouching)
5277
- inputData[0][button] = inputData[0][button] & 2 | 4;
5278
-
5279
- // set was touching
5280
- wasTouching = touching;
5281
-
5282
- // prevent default handling like copy, magnifier lens, and scrolling
5283
- if (inputPreventDefault && document.hasFocus() && e.cancelable)
5284
- e.preventDefault();
5285
-
5286
- // must return true so the document will get focus
5287
- return true;
5288
- }
5289
-
5290
- // special handling for virtual gamepad mode
5291
- function handleTouchGamepad(e)
5292
- {
5293
- // clear touch gamepad input
5294
- touchGamepadStick = vec2();
5295
- touchGamepadButtons = [];
5296
- isUsingGamepad = true;
5297
-
5298
- const touching = e.touches.length;
5299
- if (touching)
5586
+ else
5300
5587
  {
5301
- touchGamepadTimer.set();
5302
- if (touchGamepadCenterButton && !wasTouching && paused)
5588
+ // draw cross shaped gamepad
5589
+ for (let i=10; --i;)
5303
5590
  {
5304
- // touch anywhere to press start when paused
5305
- touchGamepadButtons[9] = 1;
5306
- return;
5591
+ const angle = i*PI/4;
5592
+ context.arc(stickCenter.x, stickCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8);
5593
+ i%2 && context.arc(stickCenter.x, stickCenter.y, touchGamepadSize*.33, angle, angle);
5307
5594
  }
5308
5595
  }
5596
+ context.fill();
5597
+ context.stroke();
5309
5598
 
5310
- // get center of left and right sides
5311
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5312
- const buttonCenter = touchGamepadButtonCenter();
5313
- const startCenter = mainCanvasSize.scale(.5);
5314
-
5315
- // check each touch point
5316
- for (const touch of e.touches)
5599
+ // draw right face buttons
5317
5600
  {
5318
- const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
5319
- if (stickCenter.distance(touchPos) < touchGamepadSize)
5601
+ const buttonCenter = touchGamepadButtonCenter();
5602
+ const buttonSize = touchGamepadButtonCount > 1 ?
5603
+ touchGamepadSize/4 : touchGamepadSize/2;
5604
+ for (let i=0; i<touchGamepadButtonCount; i++)
5320
5605
  {
5321
- // virtual analog stick
5322
- touchGamepadStick = touchPos.subtract(stickCenter).scale(2/touchGamepadSize).clampLength();
5323
- }
5324
- else if (buttonCenter.distance(touchPos) < touchGamepadSize)
5325
- {
5326
- // virtual face buttons
5327
- let button = buttonCenter.subtract(touchPos).direction();
5328
- button = mod(button+2, 4);
5329
- if (touchGamepadButtonCount === 1)
5330
- button = 0;
5331
- else if (touchGamepadButtonCount === 2)
5332
- {
5333
- const delta = buttonCenter.subtract(touchPos);
5334
- button = -delta.x < delta.y ? 1 : 0;
5335
- }
5606
+ const j = mod(i-1, 4);
5607
+ let button = touchGamepadButtonCount > 2 ?
5608
+ j : min(j, touchGamepadButtonCount-1);
5336
5609
  // fix button locations (swap 2 and 3 to match gamepad layout)
5337
5610
  button = button === 3 ? 2 : button === 2 ? 3 : button;
5338
- if (button < touchGamepadButtonCount)
5339
- touchGamepadButtons[button] = 1;
5340
- }
5341
- else if (touchGamepadCenterButton && !wasTouching &&
5342
- startCenter.distance(touchPos) < touchGamepadSize)
5343
- {
5344
- // virtual start button in center
5345
- touchGamepadButtons[9] = 1;
5611
+ const pos = touchGamepadButtonCount < 2 ? buttonCenter :
5612
+ buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
5613
+ context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
5614
+ context.beginPath();
5615
+ context.arc(pos.x, pos.y, buttonSize, 0,9);
5616
+ context.fill();
5617
+ context.stroke();
5346
5618
  }
5347
5619
  }
5348
- }
5349
- }
5350
-
5351
- // render the touch gamepad, called automatically by the engine
5352
- function touchGamepadRender()
5353
- {
5354
- if (!touchInputEnable || !isTouchDevice || headlessMode) return;
5355
- if (!touchGamepadEnable || !touchGamepadTimer.isSet())
5356
- return;
5357
-
5358
- // fade off when not touching or paused
5359
- const alpha = percent(touchGamepadTimer.get(), 4, 3);
5360
- if (!alpha || paused)
5361
- return;
5362
-
5363
- // setup the canvas
5364
- const context = overlayContext;
5365
- context.save();
5366
- context.globalAlpha = alpha*touchGamepadAlpha;
5367
- context.strokeStyle = '#fff';
5368
- context.lineWidth = 3;
5369
-
5370
- // draw left analog stick
5371
- context.fillStyle = touchGamepadStick.lengthSquared() > 0 ? '#fff' : '#000';
5372
- context.beginPath();
5373
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5374
- if (touchGamepadAnalog) // draw circle shaped gamepad
5375
- {
5376
- context.arc(stickCenter.x, stickCenter.y, touchGamepadSize/2, 0, 9);
5377
- context.fill();
5378
- context.stroke();
5379
- }
5380
- else // draw cross shaped gamepad
5381
- {
5382
- for (let i=10; i--;)
5383
- {
5384
- const angle = i*PI/4;
5385
- context.arc(stickCenter.x, stickCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8);
5386
- i%2 && context.arc(stickCenter.x, stickCenter.y, touchGamepadSize*.33, angle, angle);
5387
- i===1 && context.fill();
5388
- }
5389
- context.stroke();
5390
- }
5391
5620
 
5392
- // draw right face buttons
5393
- const buttonCenter = touchGamepadButtonCenter();
5394
- const buttonSize = touchGamepadButtonCount > 1 ? touchGamepadSize/4 : touchGamepadSize/2;
5395
- for (let i=0; i<touchGamepadButtonCount; i++)
5396
- {
5397
- const j = mod(i-1, 4);
5398
- let button = touchGamepadButtonCount > 2 ?
5399
- j : min(j, touchGamepadButtonCount-1);
5400
- // fix button locations (swap 2 and 3 to match gamepad layout)
5401
- button = button === 3 ? 2 : button === 2 ? 3 : button;
5402
- const pos = buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
5403
- context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
5404
- context.beginPath();
5405
- context.arc(pos.x, pos.y, buttonSize, 0,9);
5406
- context.fill();
5407
- context.stroke();
5621
+ // set canvas back to normal
5622
+ context.restore();
5408
5623
  }
5409
-
5410
- // set canvas back to normal
5411
- context.restore();
5412
5624
  }
5413
5625
 
5414
- ///////////////////////////////////////////////////////////////////////////////
5415
- // Pointer Lock
5416
-
5417
- /** Request to lock the pointer, does not work on touch devices
5418
- * @memberof Input */
5419
- function pointerLockRequest()
5626
+ // center position for right tocuh pad face buttons
5627
+ function touchGamepadButtonCenter()
5420
5628
  {
5421
- if (!isTouchDevice)
5422
- mainCanvas.requestPointerLock && mainCanvas.requestPointerLock();
5423
- }
5424
-
5425
- /** Request to unlock the pointer
5426
- * @memberof Input */
5427
- function pointerLockExit() { document.exitPointerLock && document.exitPointerLock(); }
5428
-
5429
- /** Check if pointer is locked (true if locked)
5430
- * @return {boolean}
5431
- * @memberof Input */
5432
- function pointerLockIsActive() { return document.pointerLockElement === mainCanvas; }
5629
+ const center = mainCanvasSize.subtract(vec2(touchGamepadSize));
5630
+ if (touchGamepadButtonCount === 2)
5631
+ center.x += touchGamepadSize/2;
5632
+ return center;
5633
+ }
5433
5634
  /**
5434
5635
  * LittleJS Audio System
5435
5636
  * - <a href=https://killedbyapixel.github.io/ZzFX/>ZzFX Sound Effects</a> - ZzFX Sound Effect Generator
@@ -6169,7 +6370,7 @@ function tileCollisionTest(pos, size=vec2(), object, solidOnly=true)
6169
6370
  }
6170
6371
  }
6171
6372
 
6172
- /** Return the exact position of the boudnary of first tile hit, undefined if nothing was hit.
6373
+ /** Return the exact position of the boundary of first tile hit, undefined if nothing was hit.
6173
6374
  * The point will be inside the colliding tile if it hits (may have a tiny shift)
6174
6375
  * @param {Vector2} posStart
6175
6376
  * @param {Vector2} posEnd
@@ -6743,7 +6944,7 @@ class TileCollisionLayer extends TileLayer
6743
6944
  return false;
6744
6945
  }
6745
6946
 
6746
- /** Return the exact position of the boudnary of first tile hit, undefined if nothing was hit.
6947
+ /** Return the exact position of the boundary of first tile hit, undefined if nothing was hit.
6747
6948
  * The point will be inside the colliding tile if it hits (may have a tiny shift)
6748
6949
  * @param {Vector2} posStart
6749
6950
  * @param {Vector2} posEnd
@@ -8688,11 +8889,14 @@ function zzfxM(instruments, patterns, sequence, BPM = 125)
8688
8889
  /**
8689
8890
  * LittleJS User Interface Plugin
8690
8891
  * - call new UISystemPlugin() to setup the UI system
8892
+ * - Gamepad and keyboard navigation support
8691
8893
  * - Nested Menus
8692
8894
  * - Text
8693
8895
  * - Buttons
8694
8896
  * - Checkboxes
8695
8897
  * - Images
8898
+ * - Scrollbars
8899
+ * - Video
8696
8900
  * @namespace UISystem
8697
8901
  */
8698
8902
 
@@ -8703,6 +8907,20 @@ function zzfxM(instruments, patterns, sequence, BPM = 125)
8703
8907
  * @memberof UISystem */
8704
8908
  let uiSystem;
8705
8909
 
8910
+ /** Enable UI system debug drawing
8911
+ * 0=off, 1=normal, 2=show invisible
8912
+ * @type {number}
8913
+ * @default
8914
+ * @memberof UISystem */
8915
+ let uiDebug = 0;
8916
+
8917
+ /** Enable UI system debug drawing
8918
+ * 0=off, 1=normal, 2=show invisible
8919
+ * @param {number|boolean} enable
8920
+ * @memberof UISystem */
8921
+ function uiSetDebug(debugMode)
8922
+ { uiDebug = typeof debugMode === 'boolean' ? (debugMode ? 1 : 0) : debugMode; }
8923
+
8706
8924
  ///////////////////////////////////////////////////////////////////////////////
8707
8925
  /**
8708
8926
  * UI System Global Object
@@ -8741,7 +8959,7 @@ class UISystemPlugin
8741
8959
  /** @property {number} - Default rounded rect corner radius for UI elements */
8742
8960
  this.defaultCornerRadius = 0;
8743
8961
  /** @property {number} - Default scale to use for fitting text to object */
8744
- this.defaultTextScale = .8;
8962
+ this.defaultTextFitScale = .8;
8745
8963
  /** @property {string} - Default font for UI elements */
8746
8964
  this.defaultFont = fontDefault;
8747
8965
  /** @property {Sound} - Default sound when interactive UI element is pressed */
@@ -8756,6 +8974,23 @@ class UISystemPlugin
8756
8974
  this.defaultShadowBlur = 5;
8757
8975
  /** @property {Vector2} - Offset of shadow blur */
8758
8976
  this.defaultShadowOffset = vec2(5);
8977
+ /** @property {number} - If set ui coords will be renormalized to this canvas height */
8978
+ this.nativeHeight = 0;
8979
+
8980
+ // navigation properties
8981
+ /** @property {UIObject} - Object currently selected by navigation (gamepad or keyboard) */
8982
+ this.navigationObject = undefined;
8983
+ /** @property {number} - Gamepad index to use for UI navigation */
8984
+ this.navigationGamepadIndex = 0;
8985
+ /** @property {Timer} - Cooldown timer for navigation inputs */
8986
+ this.navigationTimer = new Timer(undefined, true);
8987
+ /** @property {number} - Time between navigation inputs in seconds */
8988
+ this.navigationDelay = .2;
8989
+ /** @property {boolean} - should the navigation be horizontal, vertical, or both? */
8990
+ this.navigationDirection = 1;
8991
+ /** @property {boolean} - True if user last used navigation instead of mouse */
8992
+ this.navigationMode = false;
8993
+
8759
8994
  // system state
8760
8995
  /** @property {Array<UIObject>} - List of all UI elements */
8761
8996
  this.uiObjects = [];
@@ -8767,50 +9002,118 @@ class UISystemPlugin
8767
9002
  this.hoverObject = undefined;
8768
9003
  /** @property {UIObject} - Hover object at start of update */
8769
9004
  this.lastHoverObject = undefined;
8770
- /** @property {number} - If set ui coords will be renormalized to this canvas height */
8771
- this.nativeHeight = 0;
9005
+ /** @property {UIObject} - Current confirm menu being shown */
9006
+ this.confirmDialog = undefined;
8772
9007
 
8773
9008
  engineAddPlugin(uiUpdate, uiRender);
8774
9009
 
9010
+ // set object position in parent space
9011
+ function updateTransforms(o)
9012
+ {
9013
+ if (!o.parent) return;
9014
+ o.pos.x = o.localPos.x + o.parent.pos.x;
9015
+ o.pos.y = o.localPos.y + o.parent.pos.y;
9016
+ }
9017
+
8775
9018
  // setup recursive update and render
8776
9019
  // update in reverse order to detect mouse enter/leave
8777
9020
  function uiUpdate()
8778
9021
  {
8779
- function updateInvisibleObject(o)
9022
+ if (uiSystem.activeObject && !uiSystem.activeObject.visible)
9023
+ uiSystem.activeObject = undefined;
9024
+
9025
+ // reset hover object at start of update
9026
+ uiSystem.lastHoverObject = uiSystem.hoverObject;
9027
+ uiSystem.hoverObject = undefined;
9028
+
9029
+ if (mouseWasPressed(0))
8780
9030
  {
8781
- // update invisible objects
8782
- for (const c of o.children)
8783
- updateInvisibleObject(c);
8784
- o.updateInvisible();
9031
+ uiSystem.navigationMode = false;
9032
+ uiSystem.navigationObject = undefined;
8785
9033
  }
8786
- function updateObject(o)
9034
+
9035
+ // navigation with gamepad/keyboard
9036
+ const navigableObjects = uiSystem.getNavigableObjects();
9037
+ if (!navigableObjects.length)
9038
+ uiSystem.navigationObject = undefined;
9039
+ else
8787
9040
  {
8788
- if (o.visible)
9041
+ // unselect object if it is no longer navigable
9042
+ if (!navigableObjects.includes(uiSystem.navigationObject))
9043
+ uiSystem.navigationObject = undefined;
9044
+
9045
+ if (!isTouchDevice)
9046
+ if (uiSystem.navigationMode && !uiSystem.navigationObject)
8789
9047
  {
8790
- // set position in parent space
8791
- if (o.parent)
8792
- o.pos = o.localPos.add(o.parent.pos);
8793
- // update in reverse order to detect mouse enter/leave
8794
- for (let i=o.children.length; i--;)
8795
- updateObject(o.children[i]);
8796
- o.update();
9048
+ // select first auto focus object
9049
+ uiSystem.navigationObject = navigableObjects.find(o=>o.navigationAutoSelect);
8797
9050
  }
8798
- else
8799
- updateInvisibleObject(o);
9051
+
9052
+ // navigate with dpad or left stick
9053
+ if (!uiSystem.navigationTimer.active())
9054
+ {
9055
+ // navigate through list with gamepad or keyboard
9056
+ const direction = sign(uiSystem.getNavigationDirection());
9057
+ if (direction)
9058
+ {
9059
+ let newNavigationObject;
9060
+ if (!uiSystem.navigationObject)
9061
+ {
9062
+ // use auto select object
9063
+ newNavigationObject = navigableObjects.find(o=>o.navigationAutoSelect);
9064
+
9065
+ if (!newNavigationObject)
9066
+ {
9067
+ // try first or last object
9068
+ const newIndex = direction > 0 ? 0 : navigableObjects.length-1;
9069
+ newNavigationObject = navigableObjects[newIndex];
9070
+ }
9071
+ }
9072
+ else
9073
+ {
9074
+ const currentIndex = navigableObjects.indexOf(uiSystem.navigationObject);
9075
+ const newIndex = mod(currentIndex + direction, navigableObjects.length);
9076
+ newNavigationObject = navigableObjects[newIndex];
9077
+ }
9078
+
9079
+ if (uiSystem.navigationObject !== newNavigationObject)
9080
+ {
9081
+ uiSystem.navigationMode = true;
9082
+ uiSystem.hoverObject = undefined;
9083
+ uiSystem.navigationObject = newNavigationObject;
9084
+ uiSystem.navigationTimer.set(uiSystem.navigationDelay);
9085
+ newNavigationObject.soundPress &&
9086
+ newNavigationObject.soundPress.play();
9087
+ }
9088
+ }
9089
+ }
9090
+
9091
+ // activate the navigation object when pressed
9092
+ if (uiSystem.navigationObject)
9093
+ if (uiSystem.getNavigationWasPressed())
9094
+ uiSystem.navigationObject.navigatePressed();
8800
9095
  }
8801
- // reset hover object at start of update
8802
- uiSystem.lastHoverObject = uiSystem.hoverObject;
8803
- uiSystem.hoverObject = undefined;
8804
9096
 
8805
9097
  // update in reverse order so topmost objects get priority
8806
9098
  for (let i = uiSystem.uiObjects.length; i--;)
8807
9099
  {
8808
9100
  const o = uiSystem.uiObjects[i];
8809
- o.parent || updateObject(o)
9101
+ o.parent || updateObject(o);
8810
9102
  }
8811
9103
 
8812
9104
  // remove destroyed objects
8813
9105
  uiSystem.uiObjects = uiSystem.uiObjects.filter(o=>!o.destroyed);
9106
+
9107
+ function updateObject(o)
9108
+ {
9109
+ if (!o.visible) return;
9110
+
9111
+ // update in reverse order to detect mouse enter/leave
9112
+ updateTransforms(o);
9113
+ for (let i=o.children.length; i--;)
9114
+ updateObject(o.children[i]);
9115
+ o.update();
9116
+ }
8814
9117
  }
8815
9118
  function uiRender()
8816
9119
  {
@@ -8827,15 +9130,29 @@ class UISystemPlugin
8827
9130
 
8828
9131
  function renderObject(o)
8829
9132
  {
8830
- if (!o.visible)
8831
- return;
8832
- if (o.parent)
8833
- o.pos = o.localPos.add(o.parent.pos);
9133
+ if (!o.visible) return;
9134
+
9135
+ // render object and children
9136
+ updateTransforms(o);
8834
9137
  o.render();
8835
9138
  for (const c of o.children)
8836
9139
  renderObject(c);
8837
9140
  }
8838
9141
  uiSystem.uiObjects.forEach(o=> o.parent || renderObject(o));
9142
+
9143
+ if (uiDebug > 0)
9144
+ {
9145
+ // debug render all objects
9146
+ function renderDebug(o, visible=true)
9147
+ {
9148
+ visible &&= !!o.visible;
9149
+ updateTransforms(o);
9150
+ o.renderDebug(visible);
9151
+ for (const c of o.children)
9152
+ renderDebug(c, visible);
9153
+ }
9154
+ uiSystem.uiObjects.forEach(o=> o.parent || renderDebug(o));
9155
+ }
8839
9156
  context.restore();
8840
9157
  }
8841
9158
  }
@@ -8888,8 +9205,8 @@ class UISystemPlugin
8888
9205
  else
8889
9206
  context.rect(pos.x-size.x/2, pos.y-size.y/2, size.x, size.y);
8890
9207
  context.fill();
8891
- context.shadowColor = '#0000'
8892
- if (lineWidth)
9208
+ context.shadowColor = '#0000';
9209
+ if (lineWidth && lineColor.a > 0)
8893
9210
  {
8894
9211
  context.strokeStyle = lineColor.toString();
8895
9212
  context.lineWidth = lineWidth;
@@ -8924,10 +9241,24 @@ class UISystemPlugin
8924
9241
  * @param {TileInfo} tileInfo
8925
9242
  * @param {Color} [color=uiSystem.defaultColor]
8926
9243
  * @param {number} [angle]
8927
- * @param {boolean} [mirror] */
8928
- drawTile(pos, size, tileInfo, color=uiSystem.defaultColor, angle=0, mirror=false)
9244
+ * @param {boolean} [mirror]
9245
+ * @param {Color} [shadowColor]
9246
+ * @param {number} [shadowBlur]
9247
+ * @param {Color} [shadowOffset] */
9248
+ drawTile(pos, size, tileInfo, color=uiSystem.defaultColor, angle=0, mirror=false, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2())
8929
9249
  {
8930
- drawTile(pos, size, tileInfo, color, angle, mirror, CLEAR_BLACK, false, true, uiSystem.uiContext);
9250
+ const context = uiSystem.uiContext;
9251
+ if (shadowBlur || shadowOffset.x || shadowOffset.y)
9252
+ if (shadowColor.a > 0)
9253
+ {
9254
+ // setup shadow
9255
+ context.shadowColor = shadowColor.toString();
9256
+ context.shadowBlur = shadowBlur;
9257
+ context.shadowOffsetX = shadowOffset.x;
9258
+ context.shadowOffsetY = shadowOffset.y;
9259
+ }
9260
+ drawTile(pos, size, tileInfo, color, angle, mirror, CLEAR_BLACK, false, true, context);
9261
+ context.shadowColor = '#0000';
8931
9262
  }
8932
9263
 
8933
9264
  /** Draw text to the UI context
@@ -8942,12 +9273,27 @@ class UISystemPlugin
8942
9273
  * @param {string} [fontStyle]
8943
9274
  * @param {boolean} [applyMaxWidth=true]
8944
9275
  * @param {Vector2} [textShadow]
8945
- */
8946
- drawText(text, pos, size, color=uiSystem.defaultColor, lineWidth=uiSystem.defaultLineWidth, lineColor=uiSystem.defaultLineColor, align='center', font=uiSystem.defaultFont, fontStyle='', applyMaxWidth=true, textShadow=undefined)
9276
+ * @param {Color} [shadowColor]
9277
+ * @param {number} [shadowBlur]
9278
+ * @param {Color} [shadowOffset] */
9279
+ 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())
8947
9280
  {
8948
- if (textShadow)
8949
- drawTextScreen(text, pos.add(textShadow), size.y, BLACK, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, uiSystem.uiContext);
8950
- drawTextScreen(text, pos, size.y, color, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, uiSystem.uiContext);
9281
+ const context = uiSystem.uiContext;
9282
+ if (shadowColor.a > 0)
9283
+ {
9284
+ if (textShadow)
9285
+ drawTextScreen(text, pos.add(textShadow), size.y, shadowColor, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, context);
9286
+ if (shadowBlur || shadowOffset.x || shadowOffset.y)
9287
+ {
9288
+ // setup shadow
9289
+ context.shadowColor = shadowColor.toString();
9290
+ context.shadowBlur = shadowBlur;
9291
+ context.shadowOffsetX = shadowOffset.x;
9292
+ context.shadowOffsetY = shadowOffset.y;
9293
+ }
9294
+ }
9295
+ drawTextScreen(text, pos, size.y, color, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, context);
9296
+ context.shadowColor = '#0000';
8951
9297
  }
8952
9298
 
8953
9299
  /**
@@ -8961,7 +9307,7 @@ class UISystemPlugin
8961
9307
  * @param {DragAndDropCallback} [onDrop] - when a file is dropped
8962
9308
  * @param {DragAndDropCallback} [onDragEnter] - when a file is dragged onto the window
8963
9309
  * @param {DragAndDropCallback} [onDragLeave] - when a file is dragged off the window
8964
- * @param {DragAndDropCallback} [onDragOver] - continously when dragging over */
9310
+ * @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */
8965
9311
  setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver)
8966
9312
  {
8967
9313
  function setCallback(callback, listenerType)
@@ -8977,8 +9323,7 @@ class UISystemPlugin
8977
9323
 
8978
9324
  /** Convert a screen space position to native UI position
8979
9325
  * @param {Vector2} pos
8980
- * @return {Vector2}
8981
- */
9326
+ * @return {Vector2} */
8982
9327
  screenToNative(pos)
8983
9328
  {
8984
9329
  if (!uiSystem.nativeHeight)
@@ -9001,6 +9346,160 @@ class UISystemPlugin
9001
9346
  for (const o of this.uiObjects)
9002
9347
  o.parent || o.destroy();
9003
9348
  this.uiObjects = this.uiObjects.filter(o=>!o.destroyed);
9349
+ this.activeObject = undefined;
9350
+ this.hoverObject = undefined;
9351
+ this.lastHoverObject = undefined;
9352
+ }
9353
+
9354
+ /** Get all navigable UI objects sorted by navigationIndex
9355
+ * @return {Array<UIObject>} */
9356
+ getNavigableObjects()
9357
+ {
9358
+ function getNavigableRecursive(o)
9359
+ {
9360
+ if (!o.visible || o.disabled)
9361
+ return; // skip children if parent is invisible or disabled
9362
+
9363
+ if (o.isInteractive() && o.navigationIndex !== undefined)
9364
+ objects.push(o);
9365
+ for (let i=o.children.length; i--;)
9366
+ getNavigableRecursive(o.children[i]);
9367
+ }
9368
+
9369
+ // get all the valid navigable objects recursively
9370
+ let objects = [];
9371
+ for (let i = uiSystem.uiObjects.length; i--;)
9372
+ {
9373
+ const o = uiSystem.uiObjects[i];
9374
+ if (uiSystem.confirmDialog && o !== uiSystem.confirmDialog)
9375
+ continue;
9376
+ o.parent || getNavigableRecursive(o);
9377
+ }
9378
+
9379
+ // sort by navigationIndex (lower numbers first)
9380
+ objects.sort((a, b)=> a.navigationIndex - b.navigationIndex);
9381
+ return objects;
9382
+ }
9383
+
9384
+ /** Get navigation direction from gamepad or keyboard
9385
+ * @return {number} */
9386
+ getNavigationDirection()
9387
+ {
9388
+ const vertical = uiSystem.navigationDirection === 1;
9389
+ const both = uiSystem.navigationDirection === 2;
9390
+ if (isUsingGamepad)
9391
+ {
9392
+ const gamepad = this.navigationGamepadIndex;
9393
+ const stick = gamepadStick(0, gamepad);
9394
+ const dpad = gamepadDpad(gamepad);
9395
+ if (both)
9396
+ return -(stick.y || dpad.y) || (stick.x || dpad.x);
9397
+ return vertical ? -(stick.y || dpad.y) : (stick.x || dpad.x);
9398
+ }
9399
+ const up = 'ArrowUp', down = 'ArrowDown', left = 'ArrowLeft', right = 'ArrowRight';
9400
+ if (both)
9401
+ {
9402
+ return keyIsDown(up) || keyIsDown(left) ? -1 :
9403
+ keyIsDown(down) || keyIsDown(right) ? 1 : 0;
9404
+ }
9405
+ const back = vertical ? up : left;
9406
+ const forward = vertical ? down : right;
9407
+ return keyIsDown(back) ? -1 : keyIsDown(forward) ? 1 : 0;
9408
+ }
9409
+
9410
+ /** Get other axis navigation direction from gamepad or keyboard
9411
+ * @return {Vector2} */
9412
+ getNavigationOtherDirection()
9413
+ {
9414
+ if (uiSystem.navigationDirection === 2)
9415
+ return 0; // other direction disabled
9416
+
9417
+ const vertical = uiSystem.navigationDirection === 1;
9418
+ if (isUsingGamepad)
9419
+ {
9420
+ const gamepad = this.navigationGamepadIndex;
9421
+ const stick = gamepadStick(0, gamepad);
9422
+ const dpad = gamepadDpad(gamepad);
9423
+ return !vertical ? (stick.y || dpad.y) : (stick.x || dpad.x);
9424
+ }
9425
+ const back = !vertical ? 'ArrowUp' : 'ArrowLeft';
9426
+ const forward = !vertical ? 'ArrowDown' : 'ArrowRight';
9427
+ return keyIsDown(back) ? -1 : keyIsDown(forward) ? 1 : 0;
9428
+ }
9429
+
9430
+ /** Get if navigation button was pressed from gamepad or keyboard
9431
+ * @return {boolean} */
9432
+ getNavigationWasPressed()
9433
+ {
9434
+ const gamepad = this.navigationGamepadIndex;
9435
+ return isUsingGamepad ? gamepadWasPressed(0, gamepad) :
9436
+ keyWasPressed('Space') || keyWasPressed('Enter');
9437
+ }
9438
+
9439
+ /** Show a confirmation dialog with Yes/No buttons
9440
+ * Centers the dialog on the screen with darkened background
9441
+ * @param {string} [text] - The message to display
9442
+ * @param {Function} [yesCallback] - Called when Yes is clicked
9443
+ * @param {Function} [noCallback] - Called when No is clicked
9444
+ * @param {Vector2} [size] - Size of the confirmation dialog
9445
+ * @param {string} [exitKey] - Key that can exit the menu
9446
+ * @return {UIObject} The confirmation menu object
9447
+ */
9448
+ showConfirmDialog(text='Are you sure?', yesCallback, noCallback, size=vec2(500,250), exitKey='Escape')
9449
+ {
9450
+ ASSERT(!uiSystem.confirmDialog);
9451
+
9452
+ const savedNavigationDirection = uiSystem.navigationDirection;
9453
+
9454
+ // allow both axies for navigation
9455
+ uiSystem.navigationDirection = 2;
9456
+
9457
+ // confirm menu
9458
+ const confirmMenu = new UIObject(vec2(), size);
9459
+ uiSystem.confirmDialog = confirmMenu;
9460
+ confirmMenu.onRender = ()=>
9461
+ {
9462
+ confirmMenu.pos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
9463
+ const backgroundColor = hsl(0,0,0,.7);
9464
+ uiSystem.drawRect(vec2(), vec2(1e9), backgroundColor);
9465
+ }
9466
+ confirmMenu.onUpdate = ()=>
9467
+ {
9468
+ if (keyWasPressed(exitKey))
9469
+ closeMenu();
9470
+ }
9471
+ confirmMenu.isMouseOverlapping = ()=> true; // always hover
9472
+
9473
+ // title text
9474
+ const gap = 50;
9475
+ const textTitle = new UIText(vec2(0,-50), vec2(size.x-gap,70), text);
9476
+ confirmMenu.addChild(textTitle);
9477
+
9478
+ // yes button
9479
+ const buttonYes = new UIButton(vec2(-80,50), vec2(120,70), 'Yes');
9480
+ buttonYes.textHeight = 40;
9481
+ buttonYes.navigationIndex = 1;
9482
+ buttonYes.hoverColor = hsl(0,1,.5);
9483
+ buttonYes.onClick = ()=> { closeMenu(); yesCallback && yesCallback(); };
9484
+ confirmMenu.addChild(buttonYes);
9485
+
9486
+ // no button
9487
+ const buttonNo = new UIButton(vec2(80,50), vec2(120,70), 'No');
9488
+ buttonNo.textHeight = 40;
9489
+ buttonNo.navigationIndex = 2;
9490
+ buttonNo.navigationAutoSelect = true;
9491
+ buttonNo.onClick = ()=> { closeMenu(); noCallback && noCallback(); };
9492
+ confirmMenu.addChild(buttonNo);
9493
+
9494
+ // close menu and return to normal navigation
9495
+ function closeMenu()
9496
+ {
9497
+ ASSERT(uiSystem.confirmDialog === confirmMenu);
9498
+ confirmMenu.destroy();
9499
+ uiSystem.confirmDialog = undefined;
9500
+ uiSystem.navigationDirection = savedNavigationDirection;
9501
+ inputClear();
9502
+ }
9004
9503
  }
9005
9504
  }
9006
9505
 
@@ -9056,7 +9555,13 @@ class UIObject
9056
9555
  /** @property {number} - Override for text height */
9057
9556
  this.textHeight = undefined;
9058
9557
  /** @property {number} - Scale text to fit in the object */
9059
- this.textScale = uiSystem.defaultTextScale;
9558
+ this.textFitScale = uiSystem.defaultTextFitScale;
9559
+ /** @property {Vector2} - How much to offset the text shadow or undefined */
9560
+ this.textShadow = undefined;
9561
+ /** @property {number} - Color for text line drawing */
9562
+ this.textLineColor = uiSystem.defaultLineColor.copy();
9563
+ /** @property {number} - Width for text line drawing */
9564
+ this.textLineWidth = 0;
9060
9565
  /** @property {boolean} - Should this object be drawn */
9061
9566
  this.visible = true;
9062
9567
  /** @property {Array<UIObject>} - A list of this object's children */
@@ -9082,16 +9587,17 @@ class UIObject
9082
9587
  /** @property {number} - Size of shadow blur */
9083
9588
  this.shadowBlur = uiSystem.defaultShadowBlur;
9084
9589
  /** @property {Vector2} - Offset of shadow blur */
9085
- this.shadowOffset = uiSystem.defaultShadowOffset.copy();
9086
- uiSystem.uiObjects.push(this);
9590
+ this.shadowOffset = uiSystem.defaultShadowOffset?.copy();
9591
+ /** @property {number} - Optional navigation order index, lower values are selected first */
9592
+ this.navigationIndex = undefined;
9593
+ /** @property {boolean} - Should this be auto selected by navigation? Must also have valid navigation index. */
9594
+ this.navigationAutoSelect = false;
9087
9595
 
9088
- /** @property {Vector2} - How much to offset the text shadow or undefined */
9089
- this.textShadow = undefined;
9596
+ uiSystem.uiObjects.push(this);
9090
9597
  }
9091
9598
 
9092
9599
  /** Add a child UIObject to this object
9093
- * @param {UIObject} child
9094
- */
9600
+ * @param {UIObject} child */
9095
9601
  addChild(child)
9096
9602
  {
9097
9603
  ASSERT(!child.parent && !this.children.includes(child));
@@ -9100,8 +9606,7 @@ class UIObject
9100
9606
  }
9101
9607
 
9102
9608
  /** Remove a child UIObject from this object
9103
- * @param {UIObject} child
9104
- */
9609
+ * @param {UIObject} child */
9105
9610
  removeChild(child)
9106
9611
  {
9107
9612
  ASSERT(child.parent === this && this.children.includes(child));
@@ -9109,7 +9614,6 @@ class UIObject
9109
9614
  child.parent = undefined;
9110
9615
  }
9111
9616
 
9112
-
9113
9617
  /** Destroy this object, destroy its children, detach its parent, and mark it for removal */
9114
9618
  destroy()
9115
9619
  {
@@ -9125,9 +9629,9 @@ class UIObject
9125
9629
  child.destroy();
9126
9630
  }
9127
9631
  }
9632
+
9128
9633
  /** Check if the mouse is overlapping a box in screen space
9129
- * @return {boolean} - True if overlapping
9130
- */
9634
+ * @return {boolean} - True if overlapping */
9131
9635
  isMouseOverlapping()
9132
9636
  {
9133
9637
  if (!mouseInWindow) return false;
@@ -9144,11 +9648,16 @@ class UIObject
9144
9648
  // call the custom update callback
9145
9649
  this.onUpdate();
9146
9650
 
9651
+ // unset active if disabled
9652
+ if (this.disabled && this == uiSystem.activeObject)
9653
+ uiSystem.activeObject = undefined;
9654
+
9147
9655
  const wasHover = uiSystem.lastHoverObject === this;
9148
9656
  const isActive = this.isActiveObject();
9149
9657
  const mouseDown = mouseIsDown(0);
9150
9658
  const mousePress = this.dragActivate ? mouseDown : mouseWasPressed(0);
9151
9659
  if (this.canBeHover)
9660
+ if (!uiSystem.navigationMode) // no mouse hover in navigation mode
9152
9661
  if (mousePress || isActive || (!mouseDown && !isTouchDevice))
9153
9662
  if (!uiSystem.hoverObject && this.isMouseOverlapping())
9154
9663
  uiSystem.hoverObject = this;
@@ -9162,8 +9671,7 @@ class UIObject
9162
9671
  {
9163
9672
  if (!this.dragActivate || (!wasHover || mouseWasPressed(0)))
9164
9673
  this.onPress();
9165
- if (this.soundPress)
9166
- this.soundPress.play();
9674
+ this.soundPress && this.soundPress.play();
9167
9675
  if (uiSystem.activeObject && !isActive)
9168
9676
  uiSystem.activeObject.onRelease();
9169
9677
  uiSystem.activeObject = this;
@@ -9172,10 +9680,10 @@ class UIObject
9172
9680
  if (!mouseDown && this.isActiveObject() && this.interactive)
9173
9681
  {
9174
9682
  this.onClick();
9175
- if (this.soundClick)
9176
- this.soundClick.play();
9683
+ this.soundClick && this.soundClick.play();
9177
9684
  }
9178
9685
  }
9686
+
9179
9687
  // clear mouse was pressed state even when disabled
9180
9688
  mousePress && inputClearKey(0,0,0,1,0);
9181
9689
  }
@@ -9183,8 +9691,7 @@ class UIObject
9183
9691
  if (!mouseDown || (this.dragActivate && !this.isHoverObject()))
9184
9692
  {
9185
9693
  this.onRelease();
9186
- if (this.soundRelease)
9187
- this.soundRelease.play();
9694
+ this.soundRelease && this.soundRelease.play();
9188
9695
  uiSystem.activeObject = undefined;
9189
9696
  }
9190
9697
 
@@ -9196,29 +9703,40 @@ class UIObject
9196
9703
  /** Render the object, called automatically by plugin once each frame */
9197
9704
  render()
9198
9705
  {
9199
- if (!this.size.x || !this.size.y) return;
9706
+ // call the custom render callback
9707
+ this.onRender();
9200
9708
 
9201
- const lineColor = this.interactive && this.isActiveObject() && !this.disabled ? this.color : this.lineColor;
9202
- const color = this.disabled ? this.disabledColor : this.interactive ? this.isActiveObject() ? this.activeColor || this.color : this.isHoverObject() ? this.hoverColor : this.color : this.color;
9203
- uiSystem.drawRect(this.pos, this.size, color, this.lineWidth, lineColor, this.cornerRadius, this.gradientColor, this.shadowColor, this.shadowBlur, this.shadowOffset);
9204
- }
9709
+ if (!this.size.x || !this.size.y) return;
9205
9710
 
9206
- /** Special update when object is not visible */
9207
- updateInvisible()
9208
- {
9209
- // reset input state when not visible
9210
- if (this.isActiveObject())
9211
- uiSystem.activeObject = undefined;
9711
+ const isNavigationObject = this.isNavigationObject();
9712
+ const lineColor = isNavigationObject ? this.color :
9713
+ this.interactive && this.isActiveObject() && !this.disabled ?
9714
+ this.color : this.lineColor;
9715
+ const color = isNavigationObject ? this.hoverColor :
9716
+ this.disabled ? this.disabledColor :
9717
+ this.interactive ?
9718
+ this.isHoverObject() ? this.hoverColor :
9719
+ this.isActiveObject() ? this.activeColor || this.color :
9720
+ this.color : this.color;
9721
+ const lineWidth = this.lineWidth * (isNavigationObject ? 1.5 : 1);
9722
+
9723
+ uiSystem.drawRect(this.pos, this.size, color, lineWidth, lineColor, this.cornerRadius, this.gradientColor, this.shadowColor, this.shadowBlur, this.shadowOffset);
9212
9724
  }
9213
9725
 
9214
9726
  /** Get the size for text with overrides and scale
9215
- * @return {Vector2}
9216
- */
9727
+ * @return {Vector2} */
9217
9728
  getTextSize()
9218
9729
  {
9219
9730
  return vec2(
9220
- this.textWidth || this.textScale * this.size.x,
9221
- this.textHeight || this.textScale * this.size.y);
9731
+ this.textWidth || this.textFitScale * this.size.x,
9732
+ this.textHeight || this.textFitScale * this.size.y);
9733
+ }
9734
+
9735
+ /** Called when the navigation button is pressed on this object */
9736
+ navigatePressed()
9737
+ {
9738
+ this.onClick();
9739
+ this.soundClick && this.soundClick.play();
9222
9740
  }
9223
9741
 
9224
9742
  /** @return {boolean} - Is the mouse hovering over this element */
@@ -9227,9 +9745,51 @@ class UIObject
9227
9745
  /** @return {boolean} - Is the mouse held onto this element */
9228
9746
  isActiveObject() { return uiSystem.activeObject === this; }
9229
9747
 
9230
- /** Called each frame when object updates */
9748
+ /** @return {boolean} - Is the gamepad or keyboard navigation object */
9749
+ isNavigationObject() { return uiSystem.navigationObject === this; }
9750
+
9751
+ /** @return {boolean} - Can it be interacted with */
9752
+ isInteractive() { return this.interactive && this.visible && !this.disabled;}
9753
+
9754
+ /** Returns string containing info about this object for debugging
9755
+ * @return {string} */
9756
+ toString()
9757
+ {
9758
+ if (!debug) return;
9759
+
9760
+ let text = 'type = ' + this.constructor.name;
9761
+ if (this.text)
9762
+ text += '\ntext = ' + this.text;
9763
+ if (this.pos.x || this.pos.y)
9764
+ text += '\npos = ' + this.pos;
9765
+ if (this.localPos.x || this.localPos.y)
9766
+ text += '\localPos = ' + this.localPos;
9767
+ if (this.size.x || this.size.y)
9768
+ text += '\nsize = ' + this.size;
9769
+ if (this.color)
9770
+ text += '\ncolor = ' + this.color;
9771
+ return text;
9772
+ }
9773
+
9774
+ /** Called if uiDebug is enabled
9775
+ * @param {boolean} visible */
9776
+ renderDebug(visible=true)
9777
+ {
9778
+ // apply color based on state
9779
+ const color =
9780
+ !visible ? GREEN :
9781
+ this.isHoverObject() ? YELLOW :
9782
+ this.disabled ? PURPLE :
9783
+ this.interactive ? RED : BLUE;
9784
+ uiSystem.drawRect(this.pos, this.size, CLEAR_BLACK, 4, color);
9785
+ }
9786
+
9787
+ /** Called each frame before object updates */
9231
9788
  onUpdate() {}
9232
9789
 
9790
+ /** Called each frame before object renders */
9791
+ onRender() {}
9792
+
9233
9793
  /** Called when the mouse enters the object */
9234
9794
  onEnter() {}
9235
9795
 
@@ -9277,16 +9837,25 @@ class UIText extends UIObject
9277
9837
  this.align = align;
9278
9838
  this.font = font;
9279
9839
 
9280
- // make text not outlined by default
9281
- this.lineWidth = 0;
9282
9840
  // text can not be a hover object by default
9283
9841
  this.canBeHover = false;
9842
+
9843
+ // no background by default
9844
+ this.color = CLEAR_BLACK;
9845
+ this.shadowColor = CLEAR_BLACK;
9846
+ this.gradientColor = undefined;
9847
+ this.lineWidth = 0;
9848
+
9849
+ // use max fit scale by default
9850
+ this.textFitScale = 1;
9284
9851
  }
9285
9852
  render()
9286
9853
  {
9287
- // only render the text
9854
+ super.render();
9855
+
9856
+ // render the text
9288
9857
  const textSize = this.getTextSize();
9289
- uiSystem.drawText(this.text, this.pos, textSize, this.textColor, this.lineWidth, this.lineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
9858
+ 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);
9290
9859
  }
9291
9860
  }
9292
9861
 
@@ -9322,10 +9891,13 @@ class UITile extends UIObject
9322
9891
  this.mirror = mirror;
9323
9892
  // set properties
9324
9893
  this.color = color.copy();
9894
+
9895
+ // no shadow by default
9896
+ this.shadowColor = CLEAR_BLACK;
9325
9897
  }
9326
9898
  render()
9327
9899
  {
9328
- uiSystem.drawTile(this.pos, this.size, this.tileInfo, this.color, this.angle, this.mirror);
9900
+ uiSystem.drawTile(this.pos, this.size, this.tileInfo, this.color, this.angle, this.mirror, this.shadowColor, this.shadowBlur, this.shadowOffset);
9329
9901
  }
9330
9902
  }
9331
9903
 
@@ -9350,6 +9922,9 @@ class UIButton extends UIObject
9350
9922
  ASSERT(isString(text), 'ui button must be a string');
9351
9923
  ASSERT(isColor(color), 'ui button color must be a color');
9352
9924
 
9925
+ /** @property {Vector2} - Text offset for the button */
9926
+ this.textOffset = vec2();
9927
+
9353
9928
  // set properties
9354
9929
  this.text = text;
9355
9930
  this.color = color.copy();
@@ -9361,8 +9936,8 @@ class UIButton extends UIObject
9361
9936
 
9362
9937
  // draw the text scaled to fit
9363
9938
  const textSize = this.getTextSize();
9364
- uiSystem.drawText(this.text, this.pos, textSize,
9365
- this.textColor, 0, undefined, this.align, this.font, this.fontStyle, true, this.textShadow);
9939
+ uiSystem.drawText(this.text, this.pos.add(this.textOffset), textSize,
9940
+ this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
9366
9941
  }
9367
9942
  }
9368
9943
 
@@ -9416,7 +9991,7 @@ class UICheckbox extends UIObject
9416
9991
  const textSize = this.getTextSize();
9417
9992
  const pos = this.pos.add(vec2(this.size.x,0));
9418
9993
  uiSystem.drawText(this.text, pos, textSize,
9419
- this.textColor, 0, undefined, 'left', this.font, this.fontStyle, false, this.textShadow);
9994
+ this.textColor, this.textLineWidth, this.textLineColor, 'left', this.font, this.fontStyle, false, this.textShadow);
9420
9995
  }
9421
9996
  }
9422
9997
 
@@ -9458,7 +10033,11 @@ class UIScrollbar extends UIObject
9458
10033
  update()
9459
10034
  {
9460
10035
  super.update();
9461
- if (this.isActiveObject() && this.interactive)
10036
+ if (!this.interactive)
10037
+ return;
10038
+
10039
+ const oldValue = this.value;
10040
+ if (this.isActiveObject())
9462
10041
  {
9463
10042
  // handle horizontal or vertical scrollbar
9464
10043
  const isHorizontal = this.size.x > this.size.y;
@@ -9470,14 +10049,19 @@ class UIScrollbar extends UIObject
9470
10049
  const handleWidth = barSize - handleSize;
9471
10050
  const p1 = centerPos - handleWidth/2;
9472
10051
  const p2 = centerPos + handleWidth/2;
9473
- const oldValue = this.value;
9474
-
9475
10052
  const p = uiSystem.screenToNative(mousePosScreen);
9476
10053
  this.value = isHorizontal ?
9477
10054
  percent(p.x, p1, p2) :
9478
10055
  percent(p.y, p2, p1);
9479
- this.value === oldValue || this.onChange();
9480
10056
  }
10057
+ else if (this.isNavigationObject())
10058
+ {
10059
+ // gamepad/keyboard navigation adjustment
10060
+ const direction = uiSystem.getNavigationOtherDirection();
10061
+ if (!uiSystem.navigationTimer.active())
10062
+ this.value = clamp(this.value + direction*.01);
10063
+ }
10064
+ this.value === oldValue || this.onChange();
9481
10065
  }
9482
10066
  render()
9483
10067
  {
@@ -9502,7 +10086,14 @@ class UIScrollbar extends UIObject
9502
10086
  // draw the text scaled to fit on the scrollbar
9503
10087
  const textSize = this.getTextSize();
9504
10088
  uiSystem.drawText(this.text, this.pos, textSize,
9505
- this.textColor, 0, undefined, this.align, this.font, this.fontStyle, true, this.textShadow);
10089
+ this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
10090
+ }
10091
+ navigatePressed()
10092
+ {
10093
+ // toggle value between 0 and 1
10094
+ this.value = this.value ? 0 : 1;
10095
+ this.onRelease();
10096
+ super.navigatePressed();
9506
10097
  }
9507
10098
  }
9508
10099
 
@@ -9536,7 +10127,7 @@ class UIVideo extends UIObject
9536
10127
  this.color = BLACK; // default to black background
9537
10128
  this.cornerRadius = 0; // default to no corner radius
9538
10129
 
9539
- /** @property {float} - The video volume */
10130
+ /** @property {number} - The video volume */
9540
10131
  this.volume = volume;
9541
10132
 
9542
10133
  // create video element
@@ -9569,7 +10160,7 @@ class UIVideo extends UIObject
9569
10160
 
9570
10161
  /** Check if video is currently loading
9571
10162
  * @return {boolean} */
9572
- isLoadng()
10163
+ isLoading()
9573
10164
  { return this.video.readyState < this.video.HAVE_CURRENT_DATA; }
9574
10165
 
9575
10166
  /** Check if video is currently paused
@@ -9579,7 +10170,7 @@ class UIVideo extends UIObject
9579
10170
  /** Check if video is currently playing
9580
10171
  * @return {boolean} */
9581
10172
  isPlaying()
9582
- { return !this.isPaused() && !this.hasEnded() && !this.isLoadng(); }
10173
+ { return !this.isPaused() && !this.hasEnded() && !this.isLoading(); }
9583
10174
 
9584
10175
  /** Check if video has ended playing
9585
10176
  * @return {boolean} */
@@ -9628,7 +10219,7 @@ class UIVideo extends UIObject
9628
10219
  {
9629
10220
  super.render();
9630
10221
 
9631
- if (this.isLoadng())
10222
+ if (this.isLoading())
9632
10223
  return;
9633
10224
  const context = uiSystem.uiContext;
9634
10225
  const s = this.size;
@@ -11412,7 +12003,7 @@ class Box2dPlugin
11412
12003
  * @param {Vector2} v */
11413
12004
  vec2dTo(v)
11414
12005
  {
11415
- ASSERT(v instanceof Vector2);
12006
+ ASSERT(isVector2(v));
11416
12007
  return new box2d.instance.b2Vec2(v.x, v.y);
11417
12008
  }
11418
12009