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