littlejsengine 1.15.2 → 1.15.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/littlejs.d.ts +190 -63
  2. package/dist/littlejs.esm.js +1267 -605
  3. package/dist/littlejs.esm.min.js +1 -1
  4. package/dist/littlejs.js +1261 -604
  5. package/dist/littlejs.min.js +1 -1
  6. package/dist/littlejs.release.js +1179 -552
  7. package/examples/electron/index.html +2 -2
  8. package/examples/index.html +4 -3
  9. package/examples/particles/index.html +22 -6
  10. package/examples/shorts/base.html +2 -1
  11. package/examples/shorts/box2dCar.js +1 -1
  12. package/examples/shorts/box2dPool.js +9 -10
  13. package/examples/shorts/empty.js +1 -1
  14. package/examples/shorts/input.js +30 -25
  15. package/examples/shorts/music.js +1 -0
  16. package/examples/shorts/musicPlayer.js +1 -0
  17. package/examples/shorts/sequencer.js +2 -0
  18. package/examples/shorts/sound.js +1 -0
  19. package/examples/shorts/tileLayer.js +2 -2
  20. package/examples/shorts/tiles.png +0 -0
  21. package/examples/shorts/timers.js +1 -0
  22. package/examples/shorts/uiSystem.js +26 -14
  23. package/examples/shorts/videoPlayer.js +2 -1
  24. package/examples/starter/index.html +3 -2
  25. package/examples/uiSystem/game.js +28 -15
  26. package/package.json +1 -1
  27. package/plugins/box2d.js +1 -1
  28. package/plugins/desktop.ini +2 -0
  29. package/plugins/pluginExport.js +2 -0
  30. package/plugins/uiSystem.js +503 -91
  31. package/src/engine.js +2 -169
  32. package/src/engineAudio.js +0 -1
  33. package/src/engineBuild.js +1 -0
  34. package/src/engineDebug.js +84 -54
  35. package/src/engineDraw.js +81 -40
  36. package/src/engineExport.js +4 -1
  37. package/src/engineInput.js +526 -381
  38. package/src/engineObject.js +27 -23
  39. package/src/engineSettings.js +11 -6
  40. package/src/engineSplash.js +173 -0
  41. package/src/engineTileLayer.js +3 -3
  42. package/src/engineUtilities.js +17 -1
@@ -33,7 +33,7 @@ const engineName = 'LittleJS';
33
33
  * @type {string}
34
34
  * @default
35
35
  * @memberof Engine */
36
- const engineVersion = '1.15.2';
36
+ const engineVersion = '1.15.8';
37
37
 
38
38
  /** Frames per second to update
39
39
  * @type {number}
@@ -279,7 +279,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
279
279
  o.destroyed || o.render();
280
280
  gameRenderPost();
281
281
  pluginList.forEach(plugin=>plugin.render?.());
282
- touchGamepadRender();
282
+ inputRender();
283
283
  debugRender();
284
284
  glFlush();
285
285
  debugVideoCaptureUpdate();
@@ -600,7 +600,7 @@ function drawEngineSplashScreen(t)
600
600
  C ? x.fill() : x.stroke();
601
601
  };
602
602
  const color = (c=0, l=0) =>
603
- hsl([.98,.3,.57,.14][c%4]-10,.8,[0,.3,.5,.8,.9][l]).toString();
603
+ hsl([.98,.3,.57,.14][c%4],.8,[0,.3,.5,.8,.9][l]).toString();
604
604
  const alpha = wave(1,1,t);
605
605
  const p = percent(alpha, .1, .5);
606
606
 
@@ -790,8 +790,9 @@ function LOG(...output) { console.log(...output); }
790
790
  * @param {number} [time]
791
791
  * @param {number} [angle]
792
792
  * @param {boolean} [fill]
793
+ * @param {boolean} [screenSpace]
793
794
  * @memberof Debug */
794
- function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false)
795
+ function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false, screenSpace=false)
795
796
  {
796
797
  ASSERT(isVector2(pos), 'pos must be a vec2');
797
798
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -806,7 +807,7 @@ function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false)
806
807
  pos = pos.copy();
807
808
  size = size.copy();
808
809
  const timer = new Timer(time);
809
- debugPrimitives.push({pos:pos.copy(), size:size.copy(), color, timer, angle, fill});
810
+ debugPrimitives.push({pos:pos.copy(), size:size.copy(), color, timer, angle, fill, screenSpace});
810
811
  }
811
812
 
812
813
  /** Draw a debug poly in world space
@@ -816,8 +817,9 @@ function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false)
816
817
  * @param {number} [time]
817
818
  * @param {number} [angle]
818
819
  * @param {boolean} [fill]
820
+ * @param {boolean} [screenSpace]
819
821
  * @memberof Debug */
820
- function debugPoly(pos, points, color=WHITE, time=0, angle=0, fill=false)
822
+ function debugPoly(pos, points, color=WHITE, time=0, angle=0, fill=false, screenSpace=false)
821
823
  {
822
824
  ASSERT(isVector2(pos), 'pos must be a vec2');
823
825
  ASSERT(isArray(points), 'points must be an array');
@@ -830,7 +832,7 @@ function debugPoly(pos, points, color=WHITE, time=0, angle=0, fill=false)
830
832
  pos = pos.copy();
831
833
  points = points.map(p=>p.copy());
832
834
  const timer = new Timer(time);
833
- debugPrimitives.push({pos, points, color, timer, angle, fill});
835
+ debugPrimitives.push({pos, points, color, timer, angle, fill, screenSpace});
834
836
  }
835
837
 
836
838
  /** Draw a debug circle in world space
@@ -839,8 +841,9 @@ function debugPoly(pos, points, color=WHITE, time=0, angle=0, fill=false)
839
841
  * @param {Color|string} [color]
840
842
  * @param {number} [time]
841
843
  * @param {boolean} [fill]
844
+ * @param {boolean} [screenSpace]
842
845
  * @memberof Debug */
843
- function debugCircle(pos, size=0, color=WHITE, time=0, fill=false)
846
+ function debugCircle(pos, size=0, color=WHITE, time=0, fill=false, screenSpace=false)
844
847
  {
845
848
  ASSERT(isVector2(pos), 'pos must be a vec2');
846
849
  ASSERT(isNumber(size), 'size must be a number');
@@ -851,7 +854,7 @@ function debugCircle(pos, size=0, color=WHITE, time=0, fill=false)
851
854
  color = color.toString();
852
855
  pos = pos.copy();
853
856
  const timer = new Timer(time);
854
- debugPrimitives.push({pos, size, color, timer, angle:0, fill});
857
+ debugPrimitives.push({pos, size, color, timer, angle:0, fill, screenSpace});
855
858
  }
856
859
 
857
860
  /** Draw a debug point in world space
@@ -859,9 +862,10 @@ function debugCircle(pos, size=0, color=WHITE, time=0, fill=false)
859
862
  * @param {Color|string} [color]
860
863
  * @param {number} [time]
861
864
  * @param {number} [angle]
865
+ * @param {boolean} [screenSpace]
862
866
  * @memberof Debug */
863
- function debugPoint(pos, color, time, angle)
864
- { debugRect(pos, undefined, color, time, angle); }
867
+ function debugPoint(pos, color, time, angle, screenSpace=false)
868
+ { debugRect(pos, undefined, color, time, angle, false, screenSpace); }
865
869
 
866
870
  /** Draw a debug line in world space
867
871
  * @param {Vector2} posA
@@ -869,8 +873,9 @@ function debugPoint(pos, color, time, angle)
869
873
  * @param {Color|string} [color]
870
874
  * @param {number} [width]
871
875
  * @param {number} [time]
876
+ * @param {boolean} [screenSpace]
872
877
  * @memberof Debug */
873
- function debugLine(posA, posB, color, width=.1, time)
878
+ function debugLine(posA, posB, color, width=.1, time, screenSpace=false)
874
879
  {
875
880
  ASSERT(isVector2(posA), 'posA must be a vec2');
876
881
  ASSERT(isVector2(posB), 'posB must be sa vec2');
@@ -878,7 +883,7 @@ function debugLine(posA, posB, color, width=.1, time)
878
883
 
879
884
  const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
880
885
  const size = vec2(width, halfDelta.length()*2);
881
- debugRect(posA.add(halfDelta), size, color, time, halfDelta.angle(), true);
886
+ debugRect(posA.add(halfDelta), size, color, time, halfDelta.angle(), true, screenSpace);
882
887
  }
883
888
 
884
889
  /** Draw a debug combined axis aligned bounding box in world space
@@ -887,8 +892,10 @@ function debugLine(posA, posB, color, width=.1, time)
887
892
  * @param {Vector2} posB
888
893
  * @param {Vector2} sizeB
889
894
  * @param {Color|string} [color]
895
+ * @param {number} [time]
896
+ * @param {boolean} [screenSpace]
890
897
  * @memberof Debug */
891
- function debugOverlap(posA, sizeA, posB, sizeB, color)
898
+ function debugOverlap(posA, sizeA, posB, sizeB, color, time, screenSpace=false)
892
899
  {
893
900
  ASSERT(isVector2(posA), 'posA must be a vec2');
894
901
  ASSERT(isVector2(posB), 'posB must be a vec2');
@@ -903,19 +910,20 @@ function debugOverlap(posA, sizeA, posB, sizeB, color)
903
910
  max(posA.x + sizeA.x/2, posB.x + sizeB.x/2),
904
911
  max(posA.y + sizeA.y/2, posB.y + sizeB.y/2)
905
912
  );
906
- debugRect(minPos.lerp(maxPos,.5), maxPos.subtract(minPos), color);
913
+ debugRect(minPos.lerp(maxPos,.5), maxPos.subtract(minPos), color, time, 0, false, screenSpace);
907
914
  }
908
915
 
909
916
  /** Draw a debug axis aligned bounding box in world space
910
- * @param {string} text
917
+ * @param {string|number} text
911
918
  * @param {Vector2} pos
912
919
  * @param {number} [size]
913
920
  * @param {Color|string} [color]
914
921
  * @param {number} [time]
915
922
  * @param {number} [angle]
916
923
  * @param {string} [font]
924
+ * @param {boolean} [screenSpace]
917
925
  * @memberof Debug */
918
- function debugText(text, pos, size=1, color=WHITE, time=0, angle=0, font='monospace')
926
+ function debugText(text, pos, size=1, color=WHITE, time=0, angle=0, font='monospace', screenSpace=false)
919
927
  {
920
928
  ASSERT(isString(text), 'text must be a string');
921
929
  ASSERT(isVector2(pos), 'pos must be a vec2');
@@ -929,7 +937,7 @@ function debugText(text, pos, size=1, color=WHITE, time=0, angle=0, font='monosp
929
937
  color = color.toString();
930
938
  pos = pos.copy();
931
939
  const timer = new Timer(time);
932
- debugPrimitives.push({text, pos, size, color, timer, angle, font});
940
+ debugPrimitives.push({text, pos, size, color, timer, angle, font, screenSpace});
933
941
  }
934
942
 
935
943
  /** Clear all debug primitives in the list
@@ -1064,34 +1072,46 @@ function debugRender()
1064
1072
  debugTakeScreenshot = 0;
1065
1073
  }
1066
1074
 
1067
- if (debugGamepads && gamepadsEnable && navigator.getGamepads)
1075
+ if (debugGamepads && gamepadsEnable)
1068
1076
  {
1069
- // gamepad debug display
1070
- const gamepads = navigator.getGamepads();
1071
- 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++)
1072
1084
  {
1073
- const gamepad = gamepads[i];
1074
- 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++)
1075
1098
  {
1076
- const stickScale = 1;
1077
- const buttonScale = .2;
1078
- const centerPos = cameraPos;
1079
- const sticks = gamepadStickData[i];
1080
- for (let j = sticks.length; j--;)
1081
- {
1082
- const drawPos = centerPos.add(vec2(j*stickScale*2, i*stickScale*3));
1083
- const stickPos = drawPos.add(sticks[j].scale(stickScale));
1084
- debugCircle(drawPos, stickScale*2, '#fff7',0,true);
1085
- debugLine(drawPos, stickPos, '#f00');
1086
- debugPoint(stickPos, '#f00');
1087
- }
1088
- for (let j = gamepad.buttons.length; j--;)
1089
- {
1090
- const drawPos = centerPos.add(vec2(j*buttonScale*2, i*stickScale*3-stickScale-buttonScale));
1091
- const pressed = gamepad.buttons[j].pressed;
1092
- debugCircle(drawPos, buttonScale*2, pressed ? '#f00' : '#fff7', 0, true);
1093
- debugText(''+j, drawPos, .2);
1094
- }
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);
1095
1115
  }
1096
1116
  }
1097
1117
  }
@@ -1139,21 +1159,26 @@ function debugRender()
1139
1159
  {
1140
1160
  // draw debug primitives
1141
1161
  overlayContext.lineWidth = 2;
1142
- const pointSize = debugPointSize * cameraScale;
1143
1162
  debugPrimitives.forEach(p=>
1144
1163
  {
1145
1164
  overlayContext.save();
1146
1165
 
1147
1166
  // create canvas transform from world space to screen space
1148
- const pos = worldToScreen(p.pos);
1167
+ // without scaling because we want consistent pixel sizes
1168
+ let pos = p.pos, scale = 1, angle = p.angle;
1169
+ if (!p.screenSpace)
1170
+ {
1171
+ pos = worldToScreen(p.pos);
1172
+ scale = cameraScale;
1173
+ angle -= cameraAngle;
1174
+ }
1149
1175
  overlayContext.translate(pos.x|0, pos.y|0);
1150
- overlayContext.rotate(p.angle);
1176
+ overlayContext.rotate(angle);
1151
1177
  overlayContext.scale(1, p.text ? 1 : -1);
1152
1178
  overlayContext.fillStyle = overlayContext.strokeStyle = p.color;
1153
-
1154
1179
  if (p.text !== undefined)
1155
1180
  {
1156
- overlayContext.font = p.size*cameraScale + 'px '+ p.font;
1181
+ overlayContext.font = p.size*scale + 'px '+ p.font;
1157
1182
  overlayContext.textAlign = 'center';
1158
1183
  overlayContext.textBaseline = 'middle';
1159
1184
  overlayContext.fillText(p.text, 0, 0);
@@ -1164,7 +1189,7 @@ function debugRender()
1164
1189
  overlayContext.beginPath();
1165
1190
  for (const point of p.points)
1166
1191
  {
1167
- const p2 = point.scale(cameraScale).floor();
1192
+ const p2 = point.scale(scale).floor();
1168
1193
  overlayContext.lineTo(p2.x, p2.y);
1169
1194
  }
1170
1195
  overlayContext.closePath();
@@ -1174,13 +1199,14 @@ function debugRender()
1174
1199
  else if (p.size === 0 || (p.size.x === 0 && p.size.y === 0))
1175
1200
  {
1176
1201
  // point
1202
+ const pointSize = debugPointSize * scale;
1177
1203
  overlayContext.fillRect(-pointSize/2, -1, pointSize, 3);
1178
1204
  overlayContext.fillRect(-1, -pointSize/2, 3, pointSize);
1179
1205
  }
1180
1206
  else if (p.size.x !== undefined)
1181
1207
  {
1182
1208
  // rect
1183
- const s = p.size.scale(cameraScale).floor();
1209
+ const s = p.size.scale(scale).floor();
1184
1210
  const w = s.x, h = s.y;
1185
1211
  p.fill && overlayContext.fillRect(-w/2|0, -h/2|0, w, h);
1186
1212
  overlayContext.strokeRect(-w/2|0, -h/2|0, w, h);
@@ -1189,7 +1215,7 @@ function debugRender()
1189
1215
  {
1190
1216
  // circle
1191
1217
  overlayContext.beginPath();
1192
- overlayContext.arc(0, 0, p.size*cameraScale/2, 0, 9);
1218
+ overlayContext.arc(0, 0, p.size*scale/2, 0, 9);
1193
1219
  p.fill && overlayContext.fill();
1194
1220
  overlayContext.stroke();
1195
1221
  }
@@ -1267,14 +1293,18 @@ function debugRender()
1267
1293
  mousePressed && overlayContext.fillText('Mouse: ' + mousePressed, x, y += h);
1268
1294
  keysPressed && overlayContext.fillText('Keys: ' + keysPressed, x, y += h);
1269
1295
 
1270
- let buttonsPressed = '';
1271
- if (inputData[1])
1272
- for (const i in inputData[1])
1296
+ // show gamepad buttons
1297
+ for (let i = 1; i < inputData.length; i++)
1273
1298
  {
1274
- if (keyIsDown(i, 1))
1275
- 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);
1276
1307
  }
1277
- buttonsPressed && overlayContext.fillText('Gamepad: ' + buttonsPressed, x, y += h);
1278
1308
  }
1279
1309
  else
1280
1310
  {
@@ -2030,9 +2060,15 @@ class Vector2
2030
2060
  {
2031
2061
  this.x = x;
2032
2062
  this.y = y;
2063
+ ASSERT_VECTOR2_VALID(this);
2033
2064
  return this;
2034
2065
  }
2035
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
+
2036
2072
  /** Returns a new vector that is a copy of this
2037
2073
  * @return {Vector2} */
2038
2074
  copy() { return new Vector2(this.x, this.y); }
@@ -2095,7 +2131,7 @@ class Vector2
2095
2131
  clampLength(length=1)
2096
2132
  {
2097
2133
  const l = this.length();
2098
- return l > length ? this.scale(length/l) : this;
2134
+ return l > length ? this.scale(length/l) : this.copy();
2099
2135
  }
2100
2136
 
2101
2137
  /** Returns the dot product of this and the vector passed in
@@ -2182,6 +2218,10 @@ class Vector2
2182
2218
  * @return {number} */
2183
2219
  area() { return abs(this.x * this.y); }
2184
2220
 
2221
+ /** Returns true if this vector is (0,0)
2222
+ * @return {boolean} */
2223
+ isZero() { return !this.x && !this.y; }
2224
+
2185
2225
  /** Returns a new vector that is p percent between this and the vector passed in
2186
2226
  * @param {Vector2} v - other vector
2187
2227
  * @param {number} percent
@@ -2292,9 +2332,15 @@ class Color
2292
2332
  this.g = g;
2293
2333
  this.b = b;
2294
2334
  this.a = a;
2335
+ ASSERT_COLOR_VALID(this);
2295
2336
  return this;
2296
2337
  }
2297
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
+
2298
2344
  /** Returns a new color that is a copy of this
2299
2345
  * @return {Color} */
2300
2346
  copy() { return new Color(this.r, this.g, this.b, this.a); }
@@ -2846,17 +2892,17 @@ let touchGamepadEnable = false;
2846
2892
  * @memberof Settings */
2847
2893
  let touchGamepadCenterButton = true;
2848
2894
 
2849
- /** True if touch gamepad should be analog stick or false to use if 8 way dpad
2850
- * @type {boolean}
2895
+ /** Number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
2896
+ * @type {number}
2851
2897
  * @default
2852
2898
  * @memberof Settings */
2853
- let touchGamepadAnalog = true;
2899
+ let touchGamepadButtonCount = 4;
2854
2900
 
2855
- /** Number of buttons on touch gamepad
2856
- * @type {number}
2901
+ /** True if touch gamepad should be analog stick or false to use if 8 way dpad
2902
+ * @type {boolean}
2857
2903
  * @default
2858
2904
  * @memberof Settings */
2859
- let touchGamepadButtonCount = 4;
2905
+ let touchGamepadAnalog = true;
2860
2906
 
2861
2907
  /** Size of virtual gamepad for touch devices in pixels
2862
2908
  * @type {number}
@@ -3122,6 +3168,11 @@ function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
3122
3168
  * @memberof Settings */
3123
3169
  function setTouchGamepadCenterButton(enable) { touchGamepadCenterButton = enable; }
3124
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
+
3125
3176
  /** Set if touch gamepad should be analog stick or 8 way dpad
3126
3177
  * @param {boolean} analog
3127
3178
  * @memberof Settings */
@@ -3330,7 +3381,7 @@ class EngineObject
3330
3381
  child.updateTransforms();
3331
3382
  }
3332
3383
 
3333
- /** 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. */
3334
3385
  updatePhysics()
3335
3386
  {
3336
3387
  // child objects do not have physics
@@ -3363,7 +3414,7 @@ class EngineObject
3363
3414
  if (!enablePhysicsSolver || !this.mass) // don't do collision for static objects
3364
3415
  return;
3365
3416
 
3366
- const wasMovingDown = this.velocity.y < 0;
3417
+ const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
3367
3418
  if (this.groundObject)
3368
3419
  {
3369
3420
  // apply friction in local space of ground object
@@ -3419,10 +3470,10 @@ class EngineObject
3419
3470
  {
3420
3471
  // push outside object collision
3421
3472
  this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y);
3422
- if ((o.groundObject && wasMovingDown) || !o.mass)
3473
+ if ((o.groundObject && wasFalling) || !o.mass)
3423
3474
  {
3424
3475
  // set ground object if landed on something
3425
- if (wasMovingDown)
3476
+ if (wasFalling)
3426
3477
  this.groundObject = o;
3427
3478
 
3428
3479
  // bounce if other object is fixed or grounded
@@ -3509,12 +3560,15 @@ class EngineObject
3509
3560
  const restitution = max(this.restitution, hitLayer.restitution);
3510
3561
  this.velocity.y *= -restitution;
3511
3562
 
3512
- if (wasMovingDown)
3563
+ if (wasFalling)
3513
3564
  {
3514
- // adjust position to slightly above nearest tile boundary
3565
+ // adjust position to slightly away from nearest tile
3515
3566
  // this prevents gap between object and ground
3516
3567
  const epsilon = .0001;
3517
- 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;
3518
3572
 
3519
3573
  // set ground object for tile collision
3520
3574
  this.groundObject = hitLayer;
@@ -3644,7 +3698,9 @@ class EngineObject
3644
3698
  {
3645
3699
  ASSERT(child.parent === this && this.children.includes(child));
3646
3700
  ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
3647
- this.children.splice(this.children.indexOf(child), 1);
3701
+ const index = this.children.indexOf(child);
3702
+ ASSERT(index >= 0, 'child not found in children array');
3703
+ index >= 0 && this.children.splice(index, 1);
3648
3704
  child.parent = 0;
3649
3705
  }
3650
3706
 
@@ -3681,21 +3737,20 @@ class EngineObject
3681
3737
  * @return {string} */
3682
3738
  toString()
3683
3739
  {
3684
- if (debug)
3685
- {
3686
- let text = 'type = ' + this.constructor.name;
3687
- if (this.pos.x || this.pos.y)
3688
- text += '\npos = ' + this.pos;
3689
- if (this.velocity.x || this.velocity.y)
3690
- text += '\nvelocity = ' + this.velocity;
3691
- if (this.size.x || this.size.y)
3692
- text += '\nsize = ' + this.size;
3693
- if (this.angle)
3694
- text += '\nangle = ' + this.angle.toFixed(3);
3695
- if (this.color)
3696
- text += '\ncolor = ' + this.color;
3697
- return text;
3698
- }
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;
3699
3754
  }
3700
3755
 
3701
3756
  /** Render debug info for this object */
@@ -3864,7 +3919,7 @@ class TileInfo
3864
3919
  this.padding = padding;
3865
3920
  /** @property {TextureInfo} - The texture info for this tile */
3866
3921
  this.textureInfo = textureInfos[this.textureIndex];
3867
- /** @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 */
3868
3923
  this.bleedScale = bleedScale;
3869
3924
  }
3870
3925
 
@@ -3963,15 +4018,11 @@ function drawTile(pos, size=new Vector2(1), tileInfo, color=WHITE,
3963
4018
 
3964
4019
  const textureInfo = tileInfo && tileInfo.textureInfo;
3965
4020
  const bleedScale = tileInfo ? tileInfo.bleedScale : 0;
3966
- if (useWebGL)
4021
+ if (useWebGL && glEnable)
3967
4022
  {
3968
4023
  ASSERT(!!glContext, 'WebGL is not enabled!');
3969
4024
  if (screenSpace)
3970
- {
3971
- // convert to world space
3972
- pos = screenToWorld(pos);
3973
- size = size.scale(1/cameraScale);
3974
- }
4025
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
3975
4026
  if (textureInfo)
3976
4027
  {
3977
4028
  // calculate uvs and render
@@ -4059,7 +4110,8 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
4059
4110
  ASSERT(isColor(colorTop) && isColor(colorBottom), 'color is invalid');
4060
4111
  ASSERT(isNumber(angle), 'angle must be a number');
4061
4112
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
4062
- if (useWebGL)
4113
+
4114
+ if (useWebGL && glEnable)
4063
4115
  {
4064
4116
  ASSERT(!!glContext, 'WebGL is not enabled!');
4065
4117
  if (screenSpace)
@@ -4067,6 +4119,7 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
4067
4119
  // convert to world space
4068
4120
  pos = screenToWorld(pos);
4069
4121
  size = size.scale(1/cameraScale);
4122
+ angle += cameraAngle;
4070
4123
  }
4071
4124
  // build 4 corner points for the rectangle
4072
4125
  const points = [], colors = [];
@@ -4122,17 +4175,14 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
4122
4175
  ASSERT(isVector2(pos), 'pos must be a vec2');
4123
4176
  ASSERT(isNumber(angle), 'angle must be a number');
4124
4177
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
4125
- if (useWebGL)
4178
+
4179
+ if (useWebGL && glEnable)
4126
4180
  {
4127
4181
  ASSERT(!!glContext, 'WebGL is not enabled!');
4128
- let scale = 1;
4182
+ let size = vec2(1);
4129
4183
  if (screenSpace)
4130
- {
4131
- // convert to world space
4132
- pos = screenToWorld(pos);
4133
- scale = 1/cameraScale;
4134
- }
4135
- glDrawOutlineTransform(points, color.rgbaInt(), width, pos.x, pos.y, scale, scale, angle, wrap);
4184
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
4185
+ glDrawOutlineTransform(points, color.rgbaInt(), width, pos.x, pos.y, size.x, size.y, angle, wrap);
4136
4186
  }
4137
4187
  else
4138
4188
  {
@@ -4228,19 +4278,15 @@ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(),
4228
4278
  ASSERT(isNumber(angle), 'angle must be a number');
4229
4279
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
4230
4280
 
4231
- if (useWebGL)
4281
+ if (useWebGL && glEnable)
4232
4282
  {
4233
4283
  ASSERT(!!glContext, 'WebGL is not enabled!');
4234
- let scale = 1;
4284
+ let size = vec2(1);
4235
4285
  if (screenSpace)
4236
- {
4237
- // convert to world space
4238
- pos = screenToWorld(pos);
4239
- scale = 1/cameraScale;
4240
- }
4241
- glDrawPointsTransform(points, color.rgbaInt(), pos.x, pos.y, scale, scale, angle);
4286
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
4287
+ glDrawPointsTransform(points, color.rgbaInt(), pos.x, pos.y, size.x, size.y, angle);
4242
4288
  if (lineWidth > 0)
4243
- glDrawOutlineTransform(points, lineColor.rgbaInt(), lineWidth, pos.x, pos.y, scale, scale, angle);
4289
+ glDrawOutlineTransform(points, lineColor.rgbaInt(), lineWidth, pos.x, pos.y, size.x, size.y, angle);
4244
4290
  }
4245
4291
  else
4246
4292
  {
@@ -4283,7 +4329,7 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
4283
4329
  ASSERT(lineWidth >= 0 && lineWidth < size.x && lineWidth < size.y, 'invalid lineWidth');
4284
4330
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
4285
4331
 
4286
- if (useWebGL)
4332
+ if (useWebGL && glEnable)
4287
4333
  {
4288
4334
  // draw as a regular polygon
4289
4335
  const sides = glCircleSides;
@@ -4346,14 +4392,10 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
4346
4392
  ASSERT(typeof drawFunction === 'function', 'drawFunction must be a function');
4347
4393
 
4348
4394
  if (!screenSpace)
4349
- {
4350
- // transform from world space to screen space
4351
- pos = worldToScreen(pos);
4352
- size = size.scale(cameraScale);
4353
- }
4395
+ [pos, size, angle] = worldToScreenTransform(pos, size, angle);
4354
4396
  context.save();
4355
4397
  context.translate(pos.x+.5, pos.y+.5);
4356
- context.rotate(angle-cameraAngle);
4398
+ context.rotate(angle);
4357
4399
  context.scale(mirror ? -size.x : size.x, -size.y);
4358
4400
  drawFunction(context);
4359
4401
  context.restore();
@@ -4364,7 +4406,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
4364
4406
 
4365
4407
  /** Draw text on main canvas in world space
4366
4408
  * Automatically splits new lines into rows
4367
- * @param {string} text
4409
+ * @param {string|number} text
4368
4410
  * @param {Vector2} pos
4369
4411
  * @param {number} [size]
4370
4412
  * @param {Color} [color=(1,1,1,1)]
@@ -4379,12 +4421,19 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
4379
4421
  * @memberof Draw */
4380
4422
  function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, font, fontStyle, maxWidth, angle=0, context=drawContext)
4381
4423
  {
4382
- drawTextScreen(text, worldToScreen(pos), size*cameraScale, color, lineWidth*cameraScale, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
4424
+ // convert to screen space
4425
+ pos = worldToScreen(pos);
4426
+ size *= cameraScale;
4427
+ lineWidth *= cameraScale;
4428
+ angle -= cameraAngle;
4429
+ angle *= -1;
4430
+
4431
+ drawTextScreen(text, pos, size, color, lineWidth, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
4383
4432
  }
4384
4433
 
4385
4434
  /** Draw text on overlay canvas in world space
4386
4435
  * Automatically splits new lines into rows
4387
- * @param {string} text
4436
+ * @param {string|number} text
4388
4437
  * @param {Vector2} pos
4389
4438
  * @param {number} [size]
4390
4439
  * @param {Color} [color=(1,1,1,1)]
@@ -4403,7 +4452,7 @@ function drawTextOverlay(text, pos, size=1, color, lineWidth=0, lineColor, textA
4403
4452
 
4404
4453
  /** Draw text on overlay canvas in screen space
4405
4454
  * Automatically splits new lines into rows
4406
- * @param {string} text
4455
+ * @param {string|number} text
4407
4456
  * @param {Vector2} pos
4408
4457
  * @param {number} [size]
4409
4458
  * @param {Color} [color=(1,1,1,1)]
@@ -4538,11 +4587,58 @@ function worldToScreenDelta(worldDelta)
4538
4587
  return new Vector2(x * cameraScale, y * -cameraScale);
4539
4588
  }
4540
4589
 
4541
- /** Get the camera's visible area in world space
4590
+ /** Convert screen space transform to world space
4591
+ * @param {Vector2} screenPos
4592
+ * @param {Vector2} screenSize
4593
+ * @param {number} [screenAngle]
4594
+ * @return {[Vector2, Vector2, number]} - [pos, size, angle]
4595
+ * @memberof Draw */
4596
+ function screenToWorldTransform(screenPos, screenSize, screenAngle=0)
4597
+ {
4598
+ return [
4599
+ screenToWorld(screenPos),
4600
+ screenSize.scale(1/cameraScale),
4601
+ screenAngle + cameraAngle
4602
+ ];
4603
+ }
4604
+
4605
+ /** Convert world space transform to screen space
4606
+ * @param {Vector2} worldPos
4607
+ * @param {Vector2} worldSize
4608
+ * @param {number} [worldAngle]
4609
+ * @return {[Vector2, Vector2, number]} - [pos, size, angle]
4610
+ * @memberof Draw */
4611
+ function worldToScreenTransform(worldPos, worldSize, worldAngle=0)
4612
+ {
4613
+ return [
4614
+ worldToScreen(worldPos),
4615
+ worldSize.scale(cameraScale),
4616
+ worldAngle - cameraAngle
4617
+ ];
4618
+ }
4619
+
4620
+ /** Get the size of the camera window in world space
4542
4621
  * @return {Vector2}
4543
4622
  * @memberof Draw */
4544
4623
  function getCameraSize() { return mainCanvasSize.scale(1/cameraScale); }
4545
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
+
4546
4642
  /** Enable normal or additive blend mode
4547
4643
  * @param {boolean} [additive]
4548
4644
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
@@ -4706,7 +4802,7 @@ class FontImage
4706
4802
  }
4707
4803
 
4708
4804
  /** Draw text in world space using the image font
4709
- * @param {string} text
4805
+ * @param {string|number} text
4710
4806
  * @param {Vector2} pos
4711
4807
  * @param {number} [scale=.25]
4712
4808
  * @param {boolean} [center]
@@ -4718,7 +4814,7 @@ class FontImage
4718
4814
  }
4719
4815
 
4720
4816
  /** Draw text on overlay canvas in world space using the image font
4721
- * @param {string} text
4817
+ * @param {string|number} text
4722
4818
  * @param {Vector2} pos
4723
4819
  * @param {number} [scale]
4724
4820
  * @param {boolean} [center]
@@ -4727,7 +4823,7 @@ class FontImage
4727
4823
  { this.drawText(text, pos, scale, center, overlayContext); }
4728
4824
 
4729
4825
  /** Draw text on overlay canvas in screen space using the image font
4730
- * @param {string} text
4826
+ * @param {string|number} text
4731
4827
  * @param {Vector2} pos
4732
4828
  * @param {number} [scale]
4733
4829
  * @param {boolean} [center]
@@ -4771,6 +4867,85 @@ class FontImage
4771
4867
  * @namespace Input
4772
4868
  */
4773
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
+
4774
4949
  /** Returns true if device key is down
4775
4950
  * @param {string|number} key
4776
4951
  * @param {number} [device]
@@ -4778,9 +4953,9 @@ class FontImage
4778
4953
  * @memberof Input */
4779
4954
  function keyIsDown(key, device=0)
4780
4955
  {
4781
- ASSERT(key !== undefined, 'key is undefined');
4956
+ ASSERT(isString(key), 'key must be a number or string');
4782
4957
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4783
- return inputData[device] && !!(inputData[device][key] & 1);
4958
+ return !!(inputData[device]?.[key] & 1);
4784
4959
  }
4785
4960
 
4786
4961
  /** Returns true if device key was pressed this frame
@@ -4790,9 +4965,9 @@ function keyIsDown(key, device=0)
4790
4965
  * @memberof Input */
4791
4966
  function keyWasPressed(key, device=0)
4792
4967
  {
4793
- ASSERT(key !== undefined, 'key is undefined');
4968
+ ASSERT(isString(key), 'key must be a number or string');
4794
4969
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4795
- return inputData[device] && !!(inputData[device][key] & 2);
4970
+ return !!(inputData[device]?.[key] & 2);
4796
4971
  }
4797
4972
 
4798
4973
  /** Returns true if device key was released this frame
@@ -4802,172 +4977,182 @@ function keyWasPressed(key, device=0)
4802
4977
  * @memberof Input */
4803
4978
  function keyWasReleased(key, device=0)
4804
4979
  {
4805
- ASSERT(key !== undefined, 'key is undefined');
4980
+ ASSERT(isString(key), 'key must be a number or string');
4806
4981
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4807
- return inputData[device] && !!(inputData[device][key] & 4);
4982
+ return !!(inputData[device]?.[key] & 4);
4808
4983
  }
4809
4984
 
4810
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]
4811
4990
  * @return {Vector2}
4812
4991
  * @memberof Input */
4813
4992
  function keyDirection(up='ArrowUp', down='ArrowDown', left='ArrowLeft', right='ArrowRight')
4814
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');
4815
4998
  const k = (key)=> keyIsDown(key) ? 1 : 0;
4816
4999
  return vec2(k(right) - k(left), k(up) - k(down));
4817
5000
  }
4818
5001
 
4819
- /** Clears all input
4820
- * @memberof Input */
4821
- function inputClear() { inputData = [[]]; touchGamepadButtons = []; }
4822
-
4823
- /** Clears an input key state
4824
- * @param {string|number} key
4825
- * @param {number} [device]
4826
- * @param {boolean} [clearDown=true]
4827
- * @param {boolean} [clearPressed=true]
4828
- * @param {boolean} [clearReleased=true]
4829
- * @memberof Input */
4830
- function inputClearKey(key, device=0, clearDown=true, clearPressed=true, clearReleased=true)
4831
- {
4832
- if (!inputData[device])
4833
- return;
4834
- inputData[device][key] &= ~((clearDown?1:0)|(clearPressed?2:0)|(clearReleased?4:0));
4835
- }
4836
-
4837
5002
  /** Returns true if mouse button is down
4838
5003
  * @function
4839
5004
  * @param {number} button
4840
5005
  * @return {boolean}
4841
5006
  * @memberof Input */
4842
- 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
+ }
4843
5012
 
4844
5013
  /** Returns true if mouse button was pressed
4845
5014
  * @function
4846
5015
  * @param {number} button
4847
5016
  * @return {boolean}
4848
5017
  * @memberof Input */
4849
- 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
+ }
4850
5023
 
4851
5024
  /** Returns true if mouse button was released
4852
5025
  * @function
4853
5026
  * @param {number} button
4854
5027
  * @return {boolean}
4855
5028
  * @memberof Input */
4856
- function mouseWasReleased(button) { return keyWasReleased(button); }
4857
-
4858
- /** Mouse pos in world space
4859
- * @type {Vector2}
4860
- * @memberof Input */
4861
- let mousePos = vec2();
4862
-
4863
- /** Mouse pos in screen space
4864
- * @type {Vector2}
4865
- * @memberof Input */
4866
- let mousePosScreen = vec2();
4867
-
4868
- /** Mouse movement delta in world space
4869
- * @type {Vector2}
4870
- * @memberof Input */
4871
- let mouseDelta = vec2();
4872
-
4873
- /** Mouse movement delta in screen space
4874
- * @type {Vector2}
4875
- * @memberof Input */
4876
- let mouseDeltaScreen = vec2();
4877
-
4878
- /** Mouse wheel delta this frame
4879
- * @type {number}
4880
- * @memberof Input */
4881
- let mouseWheel = 0;
4882
-
4883
- /** True if mouse was inside the document window, set to false when mouse leaves
4884
- * @type {boolean}
4885
- * @memberof Input */
4886
- let mouseInWindow = true;
4887
-
4888
- /** Returns true if user is using gamepad (has more recently pressed a gamepad button)
4889
- * @type {boolean}
4890
- * @memberof Input */
4891
- let isUsingGamepad = false;
4892
-
4893
- /** Prevents input continuing to the default browser handling (true by default)
4894
- * @type {boolean}
4895
- * @memberof Input */
4896
- let inputPreventDefault = true;
4897
-
4898
- /** Prevents input continuing to the default browser handling
4899
- * This is useful to disable for html menus so the browser can handle input normally
4900
- * @param {boolean} preventDefault
4901
- * @memberof Input */
4902
- 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
+ }
4903
5034
 
4904
5035
  /** Returns true if gamepad button is down
4905
5036
  * @param {number} button
4906
5037
  * @param {number} [gamepad]
4907
5038
  * @return {boolean}
4908
5039
  * @memberof Input */
4909
- function gamepadIsDown(button, gamepad=0)
4910
- { 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
+ }
4911
5046
 
4912
5047
  /** Returns true if gamepad button was pressed
4913
5048
  * @param {number} button
4914
5049
  * @param {number} [gamepad]
4915
5050
  * @return {boolean}
4916
5051
  * @memberof Input */
4917
- function gamepadWasPressed(button, gamepad=0)
4918
- { 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
+ }
4919
5058
 
4920
5059
  /** Returns true if gamepad button was released
4921
5060
  * @param {number} button
4922
5061
  * @param {number} [gamepad]
4923
5062
  * @return {boolean}
4924
5063
  * @memberof Input */
4925
- function gamepadWasReleased(button, gamepad=0)
4926
- { 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
+ }
4927
5070
 
4928
5071
  /** Returns gamepad stick value
4929
5072
  * @param {number} stick
4930
5073
  * @param {number} [gamepad]
4931
5074
  * @return {Vector2}
4932
5075
  * @memberof Input */
4933
- function gamepadStick(stick, gamepad=0)
4934
- { return gamepadStickData[gamepad] ? gamepadStickData[gamepad][stick] || vec2() : vec2(); }
4935
-
4936
- ///////////////////////////////////////////////////////////////////////////////
4937
- // 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
+ }
4938
5082
 
4939
- // input is stored as a bit field for each key: 1 = isDown, 2 = wasPressed, 4 = wasReleased
4940
- // mouse and keyboard are stored together in device 0, gamepads are in devices > 0
4941
- 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
+ }
4942
5092
 
4943
- 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)
4944
5098
  {
4945
- if (headlessMode) return;
5099
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
5100
+ return !!inputData[gamepad+1];
5101
+ }
4946
5102
 
4947
- // clear input when lost focus (prevent stuck keys)
4948
- if(!(touchInputEnable && isTouchDevice) && !document.hasFocus())
4949
- inputClear();
5103
+ /** True if a touch device has been detected
5104
+ * @memberof Input */
5105
+ const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
4950
5106
 
4951
- // update mouse world space position and delta
4952
- mousePos = screenToWorld(mousePosScreen);
4953
- mouseDelta = screenToWorldDelta(mouseDeltaScreen);
5107
+ ///////////////////////////////////////////////////////////////////////////////
4954
5108
 
4955
- // update gamepads if enabled
4956
- 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);
4957
5116
  }
4958
5117
 
4959
- function inputUpdatePost()
4960
- {
4961
- if (headlessMode) return;
5118
+ /** Cancel any ongoing vibration
5119
+ * @memberof Input */
5120
+ function vibrateStop() { vibrate(0); }
4962
5121
 
4963
- // clear input to prepare for next frame
4964
- for (const deviceInputData of inputData)
4965
- for (const i in deviceInputData)
4966
- deviceInputData[i] &= 1;
4967
- mouseWheel = 0;
4968
- mouseDelta = vec2();
4969
- mouseDeltaScreen = vec2();
4970
- }
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
4971
5156
 
4972
5157
  function inputInit()
4973
5158
  {
@@ -4997,6 +5182,18 @@ function inputInit()
4997
5182
  if (inputWASDEmulateDirection)
4998
5183
  inputData[0][remapKey(e.code)] = 3;
4999
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();
5000
5197
  }
5001
5198
  function onKeyUp(e)
5002
5199
  {
@@ -5028,7 +5225,9 @@ function inputInit()
5028
5225
  const mousePosScreenLast = mousePosScreen;
5029
5226
  mousePosScreen = mouseEventToScreen(vec2(e.x,e.y));
5030
5227
  mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
5031
- inputPreventDefault && e.button && e.preventDefault();
5228
+
5229
+ if (inputPreventDefault && document.hasFocus() && e.cancelable)
5230
+ e.preventDefault();
5032
5231
  }
5033
5232
  function onMouseUp(e)
5034
5233
  {
@@ -5052,344 +5251,386 @@ function inputInit()
5052
5251
  function onMouseWheel(e) { mouseWheel = e.ctrlKey ? 0 : sign(e.deltaY); }
5053
5252
  function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
5054
5253
  function onBlur() { inputClear(); } // reset input when focus is lost
5055
- }
5056
5254
 
5057
- // convert a mouse or touch event position to screen space
5058
- function mouseEventToScreen(mousePos)
5059
- {
5060
- const rect = mainCanvas.getBoundingClientRect();
5061
- const px = percent(mousePos.x, rect.left, rect.right);
5062
- const py = percent(mousePos.y, rect.top, rect.bottom);
5063
- return vec2(px*mainCanvas.width, py*mainCanvas.height);
5064
- }
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 });
5065
5262
 
5066
- ///////////////////////////////////////////////////////////////////////////////
5067
- // Gamepad input
5263
+ // handle all touch events the same way
5264
+ let wasTouching;
5265
+ function handleTouch(e)
5266
+ {
5267
+ if (!touchInputEnable)
5268
+ return;
5068
5269
 
5069
- // gamepad internal variables
5070
- const gamepadStickData = [];
5270
+ // route touch to gamepad
5271
+ if (touchGamepadEnable)
5272
+ handleTouchGamepad(e);
5071
5273
 
5072
- // gamepads are updated by engine every frame automatically
5073
- function gamepadsUpdate()
5074
- {
5075
- const applyDeadZones = (v)=>
5076
- {
5077
- const min=.3, max=.8;
5078
- const deadZone = (v)=>
5079
- v > min ? percent(v, min, max) :
5080
- v < -min ? -percent(-v, min, max) : 0;
5081
- return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
5082
- }
5274
+ // fix stalled audio requiring user interaction
5275
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
5276
+ audioContext.resume();
5083
5277
 
5084
- // update touch gamepad if enabled
5085
- if (touchGamepadEnable && isTouchDevice)
5086
- {
5087
- if (!touchGamepadTimer.isSet())
5088
- 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;
5089
5297
 
5090
- // read virtual analog stick
5091
- const sticks = gamepadStickData[0] || (gamepadStickData[0] = []);
5092
- sticks[0] = vec2();
5093
- if (touchGamepadAnalog)
5094
- sticks[0] = applyDeadZones(touchGamepadStick);
5095
- else if (touchGamepadStick.lengthSquared() > .3)
5096
- {
5097
- // convert to 8 way dpad
5098
- sticks[0].x = round(touchGamepadStick.x);
5099
- sticks[0].y = -round(touchGamepadStick.y);
5100
- 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;
5101
5307
  }
5102
5308
 
5103
- // read virtual gamepad buttons
5104
- const data = inputData[1] || (inputData[1] = []);
5105
- for (let i=10; i--;)
5309
+ // special handling for virtual gamepad mode
5310
+ function handleTouchGamepad(e)
5106
5311
  {
5107
- const wasDown = gamepadIsDown(i,0);
5108
- 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
+ }
5109
5380
  }
5381
+ }
5110
5382
 
5111
- // disable normal gamepads when touch gamepad is active
5112
- 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);
5113
5390
  }
5391
+ }
5114
5392
 
5115
- // return if gamepads are disabled or not supported
5116
- if (!gamepadsEnable || !navigator || !navigator.getGamepads)
5117
- return;
5393
+ function inputUpdate()
5394
+ {
5395
+ if (headlessMode) return;
5118
5396
 
5119
- // only poll gamepads when focused or in debug mode
5120
- if (!debug && !document.hasFocus())
5121
- return;
5397
+ // clear input when lost focus (prevent stuck keys)
5398
+ if (!(touchInputEnable && isTouchDevice) && !document.hasFocus())
5399
+ inputClear();
5122
5400
 
5123
- // poll gamepads
5124
- const gamepads = navigator.getGamepads();
5125
- for (let i = gamepads.length; i--;)
5401
+ // update mouse world space position and delta
5402
+ mousePos = screenToWorld(mousePosScreen);
5403
+ mouseDelta = screenToWorldDelta(mouseDeltaScreen);
5404
+
5405
+ // update gamepads if enabled
5406
+ gamepadsUpdate();
5407
+
5408
+ // gamepads are updated by engine every frame automatically
5409
+ function gamepadsUpdate()
5126
5410
  {
5127
- // get or create gamepad data
5128
- const gamepad = gamepads[i];
5129
- const data = inputData[i+1] || (inputData[i+1] = []);
5130
- 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
+ }
5419
+
5420
+ // update touch gamepad if enabled
5421
+ if (touchGamepadEnable && isTouchDevice)
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;
5131
5462
 
5132
- if (gamepad)
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)
5133
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
+
5134
5489
  // read analog sticks
5135
5490
  for (let j = 0; j < gamepad.axes.length-1; j+=2)
5136
5491
  sticks[j>>1] = applyDeadZones(vec2(gamepad.axes[j],gamepad.axes[j+1]));
5137
5492
 
5138
5493
  // read buttons
5494
+ let hadInput = false;
5139
5495
  for (let j = gamepad.buttons.length; j--;)
5140
5496
  {
5141
5497
  const button = gamepad.buttons[j];
5142
5498
  const wasDown = gamepadIsDown(j,i);
5143
5499
  data[j] = button.pressed ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
5144
- if (!button.value || button.value > .9) // must be a full press
5145
- if (!i && button.pressed)
5146
- 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);
5147
5514
  }
5148
5515
 
5149
- if (gamepadDirectionEmulateStick)
5516
+ if (gamepad.mapping === 'standard')
5150
5517
  {
5151
- // copy dpad to left analog stick when pressed
5152
- const dpad = vec2(
5518
+ // get dpad buttons (standard mapping)
5519
+ dpad.set(
5153
5520
  (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
5154
5521
  (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
5155
- if (dpad.lengthSquared())
5156
- 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);
5157
5529
  }
5158
5530
 
5159
- // disable touch gamepad if using real gamepad
5160
- touchGamepadEnable && isUsingGamepad && touchGamepadTimer.unset();
5531
+ // copy dpad to left analog stick when pressed
5532
+ if (gamepadDirectionEmulateStick && !dpad.isZero())
5533
+ sticks[0] = dpad.clampLength();
5161
5534
  }
5162
- }
5163
- }
5164
-
5165
- ///////////////////////////////////////////////////////////////////////////////
5166
-
5167
- /** Pulse the vibration hardware if it exists
5168
- * @param {number|Array} [pattern] - single value in ms or vibration interval array
5169
- * @memberof Input */
5170
- function vibrate(pattern=100)
5171
- { vibrateEnable && !headlessMode && navigator && navigator.vibrate && navigator.vibrate(pattern); }
5172
5535
 
5173
- /** Cancel any ongoing vibration
5174
- * @memberof Input */
5175
- function vibrateStop() { vibrate(0); }
5176
-
5177
- ///////////////////////////////////////////////////////////////////////////////
5178
- // Touch input & virtual on screen gamepad
5179
-
5180
- /** True if a touch device has been detected
5181
- * @memberof Input */
5182
- const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
5183
-
5184
- // touch gamepad internal variables
5185
- let touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadStick = vec2();
5536
+ // disable touch gamepad if using real gamepad
5537
+ touchGamepadEnable && isUsingGamepad && touchGamepadTimer.unset();
5538
+ }
5539
+ }
5186
5540
 
5187
- function touchGamepadButtonCenter()
5541
+ function inputUpdatePost()
5188
5542
  {
5189
- // draw right face buttons
5190
- const center = vec2(mainCanvasSize.x-touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5191
- if (touchGamepadButtonCount <= 2)
5192
- center.x += touchGamepadSize/2;
5193
- 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();
5194
5552
  }
5195
5553
 
5196
- // enable touch input mouse passthrough
5197
- function touchInputInit()
5554
+ function inputRender()
5198
5555
  {
5199
- // add non passive touch event listeners
5200
- document.addEventListener('touchstart', (e) => handleTouch(e), { passive: false });
5201
- document.addEventListener('touchmove', (e) => handleTouch(e), { passive: false });
5202
- document.addEventListener('touchend', (e) => handleTouch(e), { passive: false });
5556
+ touchGamepadRender();
5203
5557
 
5204
- // handle all touch events the same way
5205
- let wasTouching;
5206
- function handleTouch(e)
5558
+ function touchGamepadRender()
5207
5559
  {
5208
- if (!touchInputEnable)
5560
+ if (!touchInputEnable || !isTouchDevice || headlessMode) return;
5561
+ if (!touchGamepadEnable || !touchGamepadTimer.isSet())
5209
5562
  return;
5210
5563
 
5211
- // route touch to gamepad
5212
- if (touchGamepadEnable)
5213
- handleTouchGamepad(e);
5564
+ // fade off when not touching or paused
5565
+ const alpha = percent(touchGamepadTimer.get(), 4, 3);
5566
+ if (!alpha || paused)
5567
+ return;
5214
5568
 
5215
- // fix stalled audio requiring user interaction
5216
- if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
5217
- 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;
5218
5575
 
5219
- // check if touching and pass to mouse events
5220
- const touching = e.touches.length;
5221
- const button = 0; // all touches are left mouse button
5222
- 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)
5223
5582
  {
5224
- // set event pos and pass it along
5225
- const pos = vec2(e.touches[0].clientX, e.touches[0].clientY);
5226
- const mousePosScreenLast = mousePosScreen;
5227
- mousePosScreen = mouseEventToScreen(pos);
5228
- if (wasTouching)
5229
- {
5230
- mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
5231
- isUsingGamepad = touchGamepadEnable;
5232
- }
5233
- else
5234
- inputData[0][button] = 3;
5583
+ // draw circle shaped gamepad
5584
+ context.arc(stickCenter.x, stickCenter.y, touchGamepadSize/2, 0, 9);
5235
5585
  }
5236
- else if (wasTouching)
5237
- inputData[0][button] = inputData[0][button] & 2 | 4;
5238
-
5239
- // set was touching
5240
- wasTouching = touching;
5241
-
5242
- // prevent default handling like copy, magnifier lens, and scrolling
5243
- if (inputPreventDefault && document.hasFocus() && e.cancelable)
5244
- e.preventDefault();
5245
-
5246
- // must return true so the document will get focus
5247
- return true;
5248
- }
5249
-
5250
- // special handling for virtual gamepad mode
5251
- function handleTouchGamepad(e)
5252
- {
5253
- // clear touch gamepad input
5254
- touchGamepadStick = vec2();
5255
- touchGamepadButtons = [];
5256
- isUsingGamepad = true;
5257
-
5258
- const touching = e.touches.length;
5259
- if (touching)
5586
+ else
5260
5587
  {
5261
- touchGamepadTimer.set();
5262
- if (touchGamepadCenterButton && !wasTouching && paused)
5588
+ // draw cross shaped gamepad
5589
+ for (let i=10; --i;)
5263
5590
  {
5264
- // touch anywhere to press start when paused
5265
- touchGamepadButtons[9] = 1;
5266
- 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);
5267
5594
  }
5268
5595
  }
5596
+ context.fill();
5597
+ context.stroke();
5269
5598
 
5270
- // get center of left and right sides
5271
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5272
- const buttonCenter = touchGamepadButtonCenter();
5273
- const startCenter = mainCanvasSize.scale(.5);
5274
-
5275
- // check each touch point
5276
- for (const touch of e.touches)
5599
+ // draw right face buttons
5277
5600
  {
5278
- const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
5279
- if (stickCenter.distance(touchPos) < touchGamepadSize)
5280
- {
5281
- // virtual analog stick
5282
- touchGamepadStick = touchPos.subtract(stickCenter).scale(2/touchGamepadSize).clampLength();
5283
- }
5284
- else if (buttonCenter.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++)
5285
5605
  {
5286
- // virtual face buttons
5287
- let button = buttonCenter.subtract(touchPos).direction();
5288
- button = mod(button+2, 4);
5289
- if (touchGamepadButtonCount === 1)
5290
- button = 0;
5291
- else if (touchGamepadButtonCount === 2)
5292
- {
5293
- const delta = buttonCenter.subtract(touchPos);
5294
- button = -delta.x < delta.y ? 1 : 0;
5295
- }
5606
+ const j = mod(i-1, 4);
5607
+ let button = touchGamepadButtonCount > 2 ?
5608
+ j : min(j, touchGamepadButtonCount-1);
5296
5609
  // fix button locations (swap 2 and 3 to match gamepad layout)
5297
5610
  button = button === 3 ? 2 : button === 2 ? 3 : button;
5298
- if (button < touchGamepadButtonCount)
5299
- touchGamepadButtons[button] = 1;
5300
- }
5301
- else if (touchGamepadCenterButton && !wasTouching &&
5302
- startCenter.distance(touchPos) < touchGamepadSize)
5303
- {
5304
- // virtual start button in center
5305
- 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();
5306
5618
  }
5307
5619
  }
5308
- }
5309
- }
5310
-
5311
- // render the touch gamepad, called automatically by the engine
5312
- function touchGamepadRender()
5313
- {
5314
- if (!touchInputEnable || !isTouchDevice || headlessMode) return;
5315
- if (!touchGamepadEnable || !touchGamepadTimer.isSet())
5316
- return;
5317
-
5318
- // fade off when not touching or paused
5319
- const alpha = percent(touchGamepadTimer.get(), 4, 3);
5320
- if (!alpha || paused)
5321
- return;
5322
-
5323
- // setup the canvas
5324
- const context = overlayContext;
5325
- context.save();
5326
- context.globalAlpha = alpha*touchGamepadAlpha;
5327
- context.strokeStyle = '#fff';
5328
- context.lineWidth = 3;
5329
-
5330
- // draw left analog stick
5331
- context.fillStyle = touchGamepadStick.lengthSquared() > 0 ? '#fff' : '#000';
5332
- context.beginPath();
5333
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5334
- if (touchGamepadAnalog) // draw circle shaped gamepad
5335
- {
5336
- context.arc(stickCenter.x, stickCenter.y, touchGamepadSize/2, 0, 9);
5337
- context.fill();
5338
- context.stroke();
5339
- }
5340
- else // draw cross shaped gamepad
5341
- {
5342
- for (let i=10; i--;)
5343
- {
5344
- const angle = i*PI/4;
5345
- context.arc(stickCenter.x, stickCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8);
5346
- i%2 && context.arc(stickCenter.x, stickCenter.y, touchGamepadSize*.33, angle, angle);
5347
- i===1 && context.fill();
5348
- }
5349
- context.stroke();
5350
- }
5351
5620
 
5352
- // draw right face buttons
5353
- const buttonCenter = touchGamepadButtonCenter();
5354
- const buttonSize = touchGamepadButtonCount > 1 ? touchGamepadSize/4 : touchGamepadSize/2;
5355
- for (let i=0; i<touchGamepadButtonCount; i++)
5356
- {
5357
- const j = mod(i-1, 4);
5358
- let button = touchGamepadButtonCount > 2 ?
5359
- j : min(j, touchGamepadButtonCount-1);
5360
- // fix button locations (swap 2 and 3 to match gamepad layout)
5361
- button = button === 3 ? 2 : button === 2 ? 3 : button;
5362
- const pos = buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
5363
- context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
5364
- context.beginPath();
5365
- context.arc(pos.x, pos.y, buttonSize, 0,9);
5366
- context.fill();
5367
- context.stroke();
5621
+ // set canvas back to normal
5622
+ context.restore();
5368
5623
  }
5369
-
5370
- // set canvas back to normal
5371
- context.restore();
5372
5624
  }
5373
5625
 
5374
- ///////////////////////////////////////////////////////////////////////////////
5375
- // Pointer Lock
5376
-
5377
- /** Request to lock the pointer, does not work on touch devices
5378
- * @memberof Input */
5379
- function pointerLockRequest()
5626
+ // center position for right tocuh pad face buttons
5627
+ function touchGamepadButtonCenter()
5380
5628
  {
5381
- if (!isTouchDevice)
5382
- mainCanvas.requestPointerLock && mainCanvas.requestPointerLock();
5383
- }
5384
-
5385
- /** Request to unlock the pointer
5386
- * @memberof Input */
5387
- function pointerLockExit() { document.exitPointerLock && document.exitPointerLock(); }
5388
-
5389
- /** Check if pointer is locked (true if locked)
5390
- * @return {boolean}
5391
- * @memberof Input */
5392
- 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
+ }
5393
5634
  /**
5394
5635
  * LittleJS Audio System
5395
5636
  * - <a href=https://killedbyapixel.github.io/ZzFX/>ZzFX Sound Effects</a> - ZzFX Sound Effect Generator
@@ -5503,7 +5744,6 @@ class Sound
5503
5744
  ASSERT(isNumber(pitch), 'pitch must be a number');
5504
5745
  ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number');
5505
5746
 
5506
-
5507
5747
  if (!soundEnable || headlessMode) return;
5508
5748
  if (!this.sampleChannels) return;
5509
5749
 
@@ -6130,7 +6370,7 @@ function tileCollisionTest(pos, size=vec2(), object, solidOnly=true)
6130
6370
  }
6131
6371
  }
6132
6372
 
6133
- /** 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.
6134
6374
  * The point will be inside the colliding tile if it hits (may have a tiny shift)
6135
6375
  * @param {Vector2} posStart
6136
6376
  * @param {Vector2} posEnd
@@ -6636,7 +6876,7 @@ class TileCollisionLayer extends TileLayer
6636
6876
  // remove from collision layers array and destroy
6637
6877
  const index = tileCollisionLayers.indexOf(this);
6638
6878
  ASSERT(index >= 0, 'tile collision layer not found in array');
6639
- tileCollisionLayers.splice(index, 1);
6879
+ index >= 0 && tileCollisionLayers.splice(index, 1);
6640
6880
  super.destroy();
6641
6881
  }
6642
6882
 
@@ -6704,7 +6944,7 @@ class TileCollisionLayer extends TileLayer
6704
6944
  return false;
6705
6945
  }
6706
6946
 
6707
- /** 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.
6708
6948
  * The point will be inside the colliding tile if it hits (may have a tiny shift)
6709
6949
  * @param {Vector2} posStart
6710
6950
  * @param {Vector2} posEnd
@@ -8649,11 +8889,14 @@ function zzfxM(instruments, patterns, sequence, BPM = 125)
8649
8889
  /**
8650
8890
  * LittleJS User Interface Plugin
8651
8891
  * - call new UISystemPlugin() to setup the UI system
8892
+ * - Gamepad and keyboard navigation support
8652
8893
  * - Nested Menus
8653
8894
  * - Text
8654
8895
  * - Buttons
8655
8896
  * - Checkboxes
8656
8897
  * - Images
8898
+ * - Scrollbars
8899
+ * - Video
8657
8900
  * @namespace UISystem
8658
8901
  */
8659
8902
 
@@ -8664,6 +8907,20 @@ function zzfxM(instruments, patterns, sequence, BPM = 125)
8664
8907
  * @memberof UISystem */
8665
8908
  let uiSystem;
8666
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
+
8667
8924
  ///////////////////////////////////////////////////////////////////////////////
8668
8925
  /**
8669
8926
  * UI System Global Object
@@ -8682,6 +8939,7 @@ class UISystemPlugin
8682
8939
  ASSERT(!uiSystem, 'UI system already initialized');
8683
8940
  uiSystem = this;
8684
8941
 
8942
+ // default settings
8685
8943
  /** @property {Color} - Default fill color for UI elements */
8686
8944
  this.defaultColor = WHITE;
8687
8945
  /** @property {Color} - Default outline color for UI elements */
@@ -8701,7 +8959,7 @@ class UISystemPlugin
8701
8959
  /** @property {number} - Default rounded rect corner radius for UI elements */
8702
8960
  this.defaultCornerRadius = 0;
8703
8961
  /** @property {number} - Default scale to use for fitting text to object */
8704
- this.defaultTextScale = .8;
8962
+ this.defaultTextFitScale = .8;
8705
8963
  /** @property {string} - Default font for UI elements */
8706
8964
  this.defaultFont = fontDefault;
8707
8965
  /** @property {Sound} - Default sound when interactive UI element is pressed */
@@ -8710,6 +8968,30 @@ class UISystemPlugin
8710
8968
  this.defaultSoundRelease = undefined;
8711
8969
  /** @property {Sound} - Default sound when interactive UI element is clicked */
8712
8970
  this.defaultSoundClick = undefined;
8971
+ /** @property {Color} - Color for shadow */
8972
+ this.defaultShadowColor = CLEAR_BLACK;
8973
+ /** @property {number} - Size of shadow blur */
8974
+ this.defaultShadowBlur = 5;
8975
+ /** @property {Vector2} - Offset of shadow blur */
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
+
8994
+ // system state
8713
8995
  /** @property {Array<UIObject>} - List of all UI elements */
8714
8996
  this.uiObjects = [];
8715
8997
  /** @property {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} - Context to render UI elements to */
@@ -8720,50 +9002,118 @@ class UISystemPlugin
8720
9002
  this.hoverObject = undefined;
8721
9003
  /** @property {UIObject} - Hover object at start of update */
8722
9004
  this.lastHoverObject = undefined;
8723
- /** @property {number} - If set ui coords will be renormalized to this canvas height */
8724
- this.nativeHeight = 0;
9005
+ /** @property {UIObject} - Current confirm menu being shown */
9006
+ this.confirmDialog = undefined;
8725
9007
 
8726
9008
  engineAddPlugin(uiUpdate, uiRender);
8727
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
+
8728
9018
  // setup recursive update and render
8729
9019
  // update in reverse order to detect mouse enter/leave
8730
9020
  function uiUpdate()
8731
9021
  {
8732
- 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))
8733
9030
  {
8734
- // update invisible objects
8735
- for (const c of o.children)
8736
- updateInvisibleObject(c);
8737
- o.updateInvisible();
9031
+ uiSystem.navigationMode = false;
9032
+ uiSystem.navigationObject = undefined;
8738
9033
  }
8739
- function updateObject(o)
9034
+
9035
+ // navigation with gamepad/keyboard
9036
+ const navigableObjects = uiSystem.getNavigableObjects();
9037
+ if (!navigableObjects.length)
9038
+ uiSystem.navigationObject = undefined;
9039
+ else
8740
9040
  {
8741
- 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)
8742
9047
  {
8743
- // set position in parent space
8744
- if (o.parent)
8745
- o.pos = o.localPos.add(o.parent.pos);
8746
- // update in reverse order to detect mouse enter/leave
8747
- for (let i=o.children.length; i--;)
8748
- updateObject(o.children[i]);
8749
- o.update();
9048
+ // select first auto focus object
9049
+ uiSystem.navigationObject = navigableObjects.find(o=>o.navigationAutoSelect);
8750
9050
  }
8751
- else
8752
- 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();
8753
9095
  }
8754
- // reset hover object at start of update
8755
- uiSystem.lastHoverObject = uiSystem.hoverObject;
8756
- uiSystem.hoverObject = undefined;
8757
9096
 
8758
9097
  // update in reverse order so topmost objects get priority
8759
9098
  for (let i = uiSystem.uiObjects.length; i--;)
8760
9099
  {
8761
9100
  const o = uiSystem.uiObjects[i];
8762
- o.parent || updateObject(o)
9101
+ o.parent || updateObject(o);
8763
9102
  }
8764
9103
 
8765
9104
  // remove destroyed objects
8766
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
+ }
8767
9117
  }
8768
9118
  function uiRender()
8769
9119
  {
@@ -8780,15 +9130,29 @@ class UISystemPlugin
8780
9130
 
8781
9131
  function renderObject(o)
8782
9132
  {
8783
- if (!o.visible)
8784
- return;
8785
- if (o.parent)
8786
- o.pos = o.localPos.add(o.parent.pos);
9133
+ if (!o.visible) return;
9134
+
9135
+ // render object and children
9136
+ updateTransforms(o);
8787
9137
  o.render();
8788
9138
  for (const c of o.children)
8789
9139
  renderObject(c);
8790
9140
  }
8791
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
+ }
8792
9156
  context.restore();
8793
9157
  }
8794
9158
  }
@@ -8800,8 +9164,11 @@ class UISystemPlugin
8800
9164
  * @param {number} [lineWidth]
8801
9165
  * @param {Color} [lineColor]
8802
9166
  * @param {number} [cornerRadius]
8803
- * @param {Color} [gradientColor] */
8804
- drawRect(pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, cornerRadius=0, gradientColor)
9167
+ * @param {Color} [gradientColor]
9168
+ * @param {Color} [shadowColor]
9169
+ * @param {number} [shadowBlur]
9170
+ * @param {Color} [shadowOffset] */
9171
+ drawRect(pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, cornerRadius=0, gradientColor, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2())
8805
9172
  {
8806
9173
  ASSERT(isVector2(pos), 'pos must be a vec2');
8807
9174
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -8823,13 +9190,23 @@ class UISystemPlugin
8823
9190
  }
8824
9191
  else
8825
9192
  context.fillStyle = color.toString();
9193
+ if (shadowBlur || shadowOffset.x || shadowOffset.y)
9194
+ if (shadowColor.a > 0)
9195
+ {
9196
+ // setup shadow
9197
+ context.shadowColor = shadowColor.toString();
9198
+ context.shadowBlur = shadowBlur;
9199
+ context.shadowOffsetX = shadowOffset.x;
9200
+ context.shadowOffsetY = shadowOffset.y;
9201
+ }
8826
9202
  context.beginPath();
8827
9203
  if (cornerRadius && context['roundRect'])
8828
9204
  context['roundRect'](pos.x-size.x/2, pos.y-size.y/2, size.x, size.y, cornerRadius);
8829
9205
  else
8830
9206
  context.rect(pos.x-size.x/2, pos.y-size.y/2, size.x, size.y);
8831
9207
  context.fill();
8832
- if (lineWidth)
9208
+ context.shadowColor = '#0000';
9209
+ if (lineWidth && lineColor.a > 0)
8833
9210
  {
8834
9211
  context.strokeStyle = lineColor.toString();
8835
9212
  context.lineWidth = lineWidth;
@@ -8864,10 +9241,24 @@ class UISystemPlugin
8864
9241
  * @param {TileInfo} tileInfo
8865
9242
  * @param {Color} [color=uiSystem.defaultColor]
8866
9243
  * @param {number} [angle]
8867
- * @param {boolean} [mirror] */
8868
- 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())
8869
9249
  {
8870
- 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';
8871
9262
  }
8872
9263
 
8873
9264
  /** Draw text to the UI context
@@ -8882,12 +9273,27 @@ class UISystemPlugin
8882
9273
  * @param {string} [fontStyle]
8883
9274
  * @param {boolean} [applyMaxWidth=true]
8884
9275
  * @param {Vector2} [textShadow]
8885
- */
8886
- 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())
8887
9280
  {
8888
- if (textShadow)
8889
- drawTextScreen(text, pos.add(textShadow), size.y, BLACK, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, uiSystem.uiContext);
8890
- 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';
8891
9297
  }
8892
9298
 
8893
9299
  /**
@@ -8901,7 +9307,7 @@ class UISystemPlugin
8901
9307
  * @param {DragAndDropCallback} [onDrop] - when a file is dropped
8902
9308
  * @param {DragAndDropCallback} [onDragEnter] - when a file is dragged onto the window
8903
9309
  * @param {DragAndDropCallback} [onDragLeave] - when a file is dragged off the window
8904
- * @param {DragAndDropCallback} [onDragOver] - continously when dragging over */
9310
+ * @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */
8905
9311
  setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver)
8906
9312
  {
8907
9313
  function setCallback(callback, listenerType)
@@ -8917,8 +9323,7 @@ class UISystemPlugin
8917
9323
 
8918
9324
  /** Convert a screen space position to native UI position
8919
9325
  * @param {Vector2} pos
8920
- * @return {Vector2}
8921
- */
9326
+ * @return {Vector2} */
8922
9327
  screenToNative(pos)
8923
9328
  {
8924
9329
  if (!uiSystem.nativeHeight)
@@ -8941,6 +9346,160 @@ class UISystemPlugin
8941
9346
  for (const o of this.uiObjects)
8942
9347
  o.parent || o.destroy();
8943
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
+ }
8944
9503
  }
8945
9504
  }
8946
9505
 
@@ -8996,7 +9555,13 @@ class UIObject
8996
9555
  /** @property {number} - Override for text height */
8997
9556
  this.textHeight = undefined;
8998
9557
  /** @property {number} - Scale text to fit in the object */
8999
- 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;
9000
9565
  /** @property {boolean} - Should this object be drawn */
9001
9566
  this.visible = true;
9002
9567
  /** @property {Array<UIObject>} - A list of this object's children */
@@ -9017,15 +9582,22 @@ class UIObject
9017
9582
  this.dragActivate = false;
9018
9583
  /** @property {boolean} - True if this can be a hover object */
9019
9584
  this.canBeHover = true;
9020
- uiSystem.uiObjects.push(this);
9585
+ /** @property {Color} - Color for shadow, undefined if no shadow */
9586
+ this.shadowColor = uiSystem.defaultShadowColor?.copy();
9587
+ /** @property {number} - Size of shadow blur */
9588
+ this.shadowBlur = uiSystem.defaultShadowBlur;
9589
+ /** @property {Vector2} - Offset of shadow blur */
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;
9021
9595
 
9022
- /** @property {Vector2} - How much to offset the text shadow or undefined */
9023
- this.textShadow = undefined;
9596
+ uiSystem.uiObjects.push(this);
9024
9597
  }
9025
9598
 
9026
9599
  /** Add a child UIObject to this object
9027
- * @param {UIObject} child
9028
- */
9600
+ * @param {UIObject} child */
9029
9601
  addChild(child)
9030
9602
  {
9031
9603
  ASSERT(!child.parent && !this.children.includes(child));
@@ -9034,8 +9606,7 @@ class UIObject
9034
9606
  }
9035
9607
 
9036
9608
  /** Remove a child UIObject from this object
9037
- * @param {UIObject} child
9038
- */
9609
+ * @param {UIObject} child */
9039
9610
  removeChild(child)
9040
9611
  {
9041
9612
  ASSERT(child.parent === this && this.children.includes(child));
@@ -9043,7 +9614,6 @@ class UIObject
9043
9614
  child.parent = undefined;
9044
9615
  }
9045
9616
 
9046
-
9047
9617
  /** Destroy this object, destroy its children, detach its parent, and mark it for removal */
9048
9618
  destroy()
9049
9619
  {
@@ -9059,9 +9629,9 @@ class UIObject
9059
9629
  child.destroy();
9060
9630
  }
9061
9631
  }
9632
+
9062
9633
  /** Check if the mouse is overlapping a box in screen space
9063
- * @return {boolean} - True if overlapping
9064
- */
9634
+ * @return {boolean} - True if overlapping */
9065
9635
  isMouseOverlapping()
9066
9636
  {
9067
9637
  if (!mouseInWindow) return false;
@@ -9078,11 +9648,16 @@ class UIObject
9078
9648
  // call the custom update callback
9079
9649
  this.onUpdate();
9080
9650
 
9651
+ // unset active if disabled
9652
+ if (this.disabled && this == uiSystem.activeObject)
9653
+ uiSystem.activeObject = undefined;
9654
+
9081
9655
  const wasHover = uiSystem.lastHoverObject === this;
9082
9656
  const isActive = this.isActiveObject();
9083
9657
  const mouseDown = mouseIsDown(0);
9084
9658
  const mousePress = this.dragActivate ? mouseDown : mouseWasPressed(0);
9085
9659
  if (this.canBeHover)
9660
+ if (!uiSystem.navigationMode) // no mouse hover in navigation mode
9086
9661
  if (mousePress || isActive || (!mouseDown && !isTouchDevice))
9087
9662
  if (!uiSystem.hoverObject && this.isMouseOverlapping())
9088
9663
  uiSystem.hoverObject = this;
@@ -9096,8 +9671,7 @@ class UIObject
9096
9671
  {
9097
9672
  if (!this.dragActivate || (!wasHover || mouseWasPressed(0)))
9098
9673
  this.onPress();
9099
- if (this.soundPress)
9100
- this.soundPress.play();
9674
+ this.soundPress && this.soundPress.play();
9101
9675
  if (uiSystem.activeObject && !isActive)
9102
9676
  uiSystem.activeObject.onRelease();
9103
9677
  uiSystem.activeObject = this;
@@ -9106,10 +9680,10 @@ class UIObject
9106
9680
  if (!mouseDown && this.isActiveObject() && this.interactive)
9107
9681
  {
9108
9682
  this.onClick();
9109
- if (this.soundClick)
9110
- this.soundClick.play();
9683
+ this.soundClick && this.soundClick.play();
9111
9684
  }
9112
9685
  }
9686
+
9113
9687
  // clear mouse was pressed state even when disabled
9114
9688
  mousePress && inputClearKey(0,0,0,1,0);
9115
9689
  }
@@ -9117,8 +9691,7 @@ class UIObject
9117
9691
  if (!mouseDown || (this.dragActivate && !this.isHoverObject()))
9118
9692
  {
9119
9693
  this.onRelease();
9120
- if (this.soundRelease)
9121
- this.soundRelease.play();
9694
+ this.soundRelease && this.soundRelease.play();
9122
9695
  uiSystem.activeObject = undefined;
9123
9696
  }
9124
9697
 
@@ -9130,29 +9703,40 @@ class UIObject
9130
9703
  /** Render the object, called automatically by plugin once each frame */
9131
9704
  render()
9132
9705
  {
9133
- if (!this.size.x || !this.size.y) return;
9706
+ // call the custom render callback
9707
+ this.onRender();
9134
9708
 
9135
- const lineColor = this.interactive && this.isActiveObject() && !this.disabled ? this.color : this.lineColor;
9136
- const color = this.disabled ? this.disabledColor : this.interactive ? this.isActiveObject() ? this.activeColor || this.color : this.isHoverObject() ? this.hoverColor : this.color : this.color;
9137
- uiSystem.drawRect(this.pos, this.size, color, this.lineWidth, lineColor, this.cornerRadius, this.gradientColor);
9138
- }
9709
+ if (!this.size.x || !this.size.y) return;
9139
9710
 
9140
- /** Special update when object is not visible */
9141
- updateInvisible()
9142
- {
9143
- // reset input state when not visible
9144
- if (this.isActiveObject())
9145
- 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);
9146
9724
  }
9147
9725
 
9148
9726
  /** Get the size for text with overrides and scale
9149
- * @return {Vector2}
9150
- */
9727
+ * @return {Vector2} */
9151
9728
  getTextSize()
9152
9729
  {
9153
9730
  return vec2(
9154
- this.textWidth || this.textScale * this.size.x,
9155
- 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();
9156
9740
  }
9157
9741
 
9158
9742
  /** @return {boolean} - Is the mouse hovering over this element */
@@ -9161,9 +9745,51 @@ class UIObject
9161
9745
  /** @return {boolean} - Is the mouse held onto this element */
9162
9746
  isActiveObject() { return uiSystem.activeObject === this; }
9163
9747
 
9164
- /** 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 */
9165
9788
  onUpdate() {}
9166
9789
 
9790
+ /** Called each frame before object renders */
9791
+ onRender() {}
9792
+
9167
9793
  /** Called when the mouse enters the object */
9168
9794
  onEnter() {}
9169
9795
 
@@ -9211,16 +9837,25 @@ class UIText extends UIObject
9211
9837
  this.align = align;
9212
9838
  this.font = font;
9213
9839
 
9214
- // make text not outlined by default
9215
- this.lineWidth = 0;
9216
9840
  // text can not be a hover object by default
9217
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;
9218
9851
  }
9219
9852
  render()
9220
9853
  {
9221
- // only render the text
9854
+ super.render();
9855
+
9856
+ // render the text
9222
9857
  const textSize = this.getTextSize();
9223
- 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);
9224
9859
  }
9225
9860
  }
9226
9861
 
@@ -9256,10 +9891,13 @@ class UITile extends UIObject
9256
9891
  this.mirror = mirror;
9257
9892
  // set properties
9258
9893
  this.color = color.copy();
9894
+
9895
+ // no shadow by default
9896
+ this.shadowColor = CLEAR_BLACK;
9259
9897
  }
9260
9898
  render()
9261
9899
  {
9262
- 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);
9263
9901
  }
9264
9902
  }
9265
9903
 
@@ -9284,6 +9922,9 @@ class UIButton extends UIObject
9284
9922
  ASSERT(isString(text), 'ui button must be a string');
9285
9923
  ASSERT(isColor(color), 'ui button color must be a color');
9286
9924
 
9925
+ /** @property {Vector2} - Text offset for the button */
9926
+ this.textOffset = vec2();
9927
+
9287
9928
  // set properties
9288
9929
  this.text = text;
9289
9930
  this.color = color.copy();
@@ -9295,8 +9936,8 @@ class UIButton extends UIObject
9295
9936
 
9296
9937
  // draw the text scaled to fit
9297
9938
  const textSize = this.getTextSize();
9298
- uiSystem.drawText(this.text, this.pos, textSize,
9299
- 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);
9300
9941
  }
9301
9942
  }
9302
9943
 
@@ -9350,7 +9991,7 @@ class UICheckbox extends UIObject
9350
9991
  const textSize = this.getTextSize();
9351
9992
  const pos = this.pos.add(vec2(this.size.x,0));
9352
9993
  uiSystem.drawText(this.text, pos, textSize,
9353
- 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);
9354
9995
  }
9355
9996
  }
9356
9997
 
@@ -9392,7 +10033,11 @@ class UIScrollbar extends UIObject
9392
10033
  update()
9393
10034
  {
9394
10035
  super.update();
9395
- if (this.isActiveObject() && this.interactive)
10036
+ if (!this.interactive)
10037
+ return;
10038
+
10039
+ const oldValue = this.value;
10040
+ if (this.isActiveObject())
9396
10041
  {
9397
10042
  // handle horizontal or vertical scrollbar
9398
10043
  const isHorizontal = this.size.x > this.size.y;
@@ -9404,14 +10049,19 @@ class UIScrollbar extends UIObject
9404
10049
  const handleWidth = barSize - handleSize;
9405
10050
  const p1 = centerPos - handleWidth/2;
9406
10051
  const p2 = centerPos + handleWidth/2;
9407
- const oldValue = this.value;
9408
-
9409
10052
  const p = uiSystem.screenToNative(mousePosScreen);
9410
10053
  this.value = isHorizontal ?
9411
10054
  percent(p.x, p1, p2) :
9412
10055
  percent(p.y, p2, p1);
9413
- this.value === oldValue || this.onChange();
9414
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();
9415
10065
  }
9416
10066
  render()
9417
10067
  {
@@ -9436,7 +10086,14 @@ class UIScrollbar extends UIObject
9436
10086
  // draw the text scaled to fit on the scrollbar
9437
10087
  const textSize = this.getTextSize();
9438
10088
  uiSystem.drawText(this.text, this.pos, textSize,
9439
- 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();
9440
10097
  }
9441
10098
  }
9442
10099
 
@@ -9470,7 +10127,7 @@ class UIVideo extends UIObject
9470
10127
  this.color = BLACK; // default to black background
9471
10128
  this.cornerRadius = 0; // default to no corner radius
9472
10129
 
9473
- /** @property {float} - The video volume */
10130
+ /** @property {number} - The video volume */
9474
10131
  this.volume = volume;
9475
10132
 
9476
10133
  // create video element
@@ -9503,7 +10160,7 @@ class UIVideo extends UIObject
9503
10160
 
9504
10161
  /** Check if video is currently loading
9505
10162
  * @return {boolean} */
9506
- isLoadng()
10163
+ isLoading()
9507
10164
  { return this.video.readyState < this.video.HAVE_CURRENT_DATA; }
9508
10165
 
9509
10166
  /** Check if video is currently paused
@@ -9513,7 +10170,7 @@ class UIVideo extends UIObject
9513
10170
  /** Check if video is currently playing
9514
10171
  * @return {boolean} */
9515
10172
  isPlaying()
9516
- { return !this.isPaused() && !this.hasEnded() && !this.isLoadng(); }
10173
+ { return !this.isPaused() && !this.hasEnded() && !this.isLoading(); }
9517
10174
 
9518
10175
  /** Check if video has ended playing
9519
10176
  * @return {boolean} */
@@ -9562,7 +10219,7 @@ class UIVideo extends UIObject
9562
10219
  {
9563
10220
  super.render();
9564
10221
 
9565
- if (this.isLoadng())
10222
+ if (this.isLoading())
9566
10223
  return;
9567
10224
  const context = uiSystem.uiContext;
9568
10225
  const s = this.size;
@@ -11346,7 +12003,7 @@ class Box2dPlugin
11346
12003
  * @param {Vector2} v */
11347
12004
  vec2dTo(v)
11348
12005
  {
11349
- ASSERT(v instanceof Vector2);
12006
+ ASSERT(isVector2(v));
11350
12007
  return new box2d.instance.b2Vec2(v.x, v.y);
11351
12008
  }
11352
12009
 
@@ -11763,6 +12420,7 @@ export
11763
12420
  setInputWASDEmulateDirection,
11764
12421
  setTouchGamepadEnable,
11765
12422
  setTouchGamepadCenterButton,
12423
+ setTouchGamepadButtonCount,
11766
12424
  setTouchGamepadAnalog,
11767
12425
  setTouchGamepadSize,
11768
12426
  setTouchGamepadAlpha,
@@ -11934,12 +12592,14 @@ export
11934
12592
  mouseInWindow,
11935
12593
  isUsingGamepad,
11936
12594
  inputPreventDefault,
12595
+ gamepadPrimary,
11937
12596
  setInputPreventDefault,
11938
12597
  gamepadIsDown,
11939
12598
  gamepadWasPressed,
11940
12599
  gamepadWasReleased,
11941
12600
  gamepadStick,
11942
- gamepadsUpdate,
12601
+ gamepadDpad,
12602
+ gamepadConnected,
11943
12603
  vibrate,
11944
12604
  vibrateStop,
11945
12605
  isTouchDevice,
@@ -12005,6 +12665,8 @@ export
12005
12665
 
12006
12666
  // UI System
12007
12667
  uiSystem,
12668
+ uiDebug,
12669
+ uiSetDebug,
12008
12670
  UISystemPlugin,
12009
12671
  UIObject,
12010
12672
  UIText,