littlejsengine 1.18.12 → 1.18.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/engineDraw.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * - Optimized tile sheet sprite rendering using WebGL batching
5
5
  * - Primitive drawing for polygons, ellipses, and lines
6
6
  * - Tile-based rendering with TileInfo and TextureInfo classes
7
- * - Text rendering with custom fonts and FontImage support
7
+ * - Text rendering with custom fonts and ImageFont support
8
8
  * - Color and additive color blending for effects
9
9
  * - Rotation, mirroring, and scaling transformations
10
10
  * - Camera system with position, scale, and rotation
@@ -102,7 +102,7 @@ let primitiveCount;
102
102
  * tile(1, 16, 3) // a tile at index 1 of size 16 on texture 3
103
103
  * tile(vec2(4,8), vec2(30,10)) // a tile at index (4,8) with a size of (30,10)
104
104
  * @memberof Draw */
105
- function tile(index=new Vector2, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
105
+ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
106
106
  {
107
107
  ASSERT(isVector2(index) || typeof index === 'number', 'index must be a vec2 or number');
108
108
  ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
@@ -153,8 +153,8 @@ class TileInfo
153
153
  * @param {Vector2} [pos=vec2()] - Top left corner of tile in pixels
154
154
  * @param {Vector2} [size] - Size of tile in pixels
155
155
  * @param {TextureInfo} [textureInfo] - Texture info to use
156
- * @param {number} [padding] - How many pixels padding around tiles
157
- * @param {number} [bleed] - How many pixels smaller to draw tiles
156
+ * @param {number} [padding] - How many pixels padding around all sides of each tile (increases grid size, does not affect tile size)
157
+ * @param {number} [bleed] - How many pixels smaller to shrink UVS of tiles (does not affect grid size, only UVs)
158
158
  */
159
159
  constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed)
160
160
  {
@@ -186,7 +186,7 @@ class TileInfo
186
186
  ASSERT(typeof frame === 'number');
187
187
  const w = this.size.x + this.padding*2;
188
188
  const x = frame*w;
189
- ASSERT(x < this.textureInfo.size.x, 'frame extends beyond texture width!');
189
+ ASSERT(x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
190
190
  return this.offset(new Vector2(x));
191
191
  }
192
192
 
@@ -275,7 +275,7 @@ class TextureInfo
275
275
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
276
276
  * @memberof Draw */
277
277
  function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
278
- angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace, context)
278
+ angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=false, context)
279
279
  {
280
280
  ASSERT(isVector2(pos), 'pos must be a vec2');
281
281
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -318,10 +318,8 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
318
318
  }
319
319
  else
320
320
  {
321
- // untextured: glDrawUntextured picks the optimal path (poly
322
- // tristrip if already in poly mode, otherwise instanced with
323
- // uvs/rgba zeroed). Color+additive are folded together to match
324
- // the Canvas2D path's color.add(additiveColor) on line ~337.
321
+ // untextured: fold color+additive to match the Canvas2D path's
322
+ // color.add(additiveColor) on line ~337.
325
323
  const combined = additiveColor ? color.add(additiveColor) : color;
326
324
  glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
327
325
  }
@@ -534,7 +532,7 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
534
532
  * @param {boolean} [screenSpace]
535
533
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
536
534
  * @memberof Draw */
537
- function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace, context)
535
+ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
538
536
  {
539
537
  ASSERT(isArray(points), 'points must be an array');
540
538
  ASSERT(isNumber(width), 'width must be a number');
@@ -583,7 +581,7 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
583
581
  * @param {boolean} [screenSpace]
584
582
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
585
583
  * @memberof Draw */
586
- function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, screenSpace, context)
584
+ function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
587
585
  {
588
586
  const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
589
587
  const size = vec2(width, halfDelta.length()*2);
@@ -599,9 +597,9 @@ function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, sc
599
597
  * @param {Vector2} [size=vec2(1)]
600
598
  * @param {number} [sides]
601
599
  * @param {Color} [color=WHITE]
602
- * @param {number} [angle]
603
600
  * @param {number} [lineWidth]
604
601
  * @param {Color} [lineColor=BLACK]
602
+ * @param {number} [angle]
605
603
  * @param {boolean} [useWebGL=glEnable]
606
604
  * @param {boolean} [screenSpace]
607
605
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
@@ -694,7 +692,7 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
694
692
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
695
693
 
696
694
  // clamp line width to prevent artifacts
697
- lineWidth = clamp(lineWidth, 0, Math.min(size.x, size.y));
695
+ lineWidth = clamp(lineWidth, 0, min(size.x, size.y));
698
696
 
699
697
  if (useWebGL && glEnable)
700
698
  {
@@ -736,24 +734,26 @@ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useW
736
734
  drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
737
735
  }
738
736
 
739
- /** Draw a circle filled with a radial gradient from the center to the rim
737
+ /** Draw an ellipse filled with a radial gradient from the center to the rim
740
738
  * - Best when batched with other untextured polys
741
739
  * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
742
740
  * - Stacking gradients at the exact same position may show a faint vertical artifact
743
741
  * @param {Vector2} pos
744
- * @param {number} [size=1] - Diameter
742
+ * @param {Vector2} [size=vec2(1)] - Width and height diameter
745
743
  * @param {Color} [colorInner=WHITE]
746
744
  * @param {Color} [colorOuter=CLEAR_WHITE]
745
+ * @param {number} [angle]
747
746
  * @param {boolean} [useWebGL=glEnable]
748
747
  * @param {boolean} [screenSpace]
749
748
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
750
749
  * @memberof Draw */
751
- let drawCircleGradientOffset = 0;
752
- function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
750
+ let drawEllipseGradientOffset = 0;
751
+ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
753
752
  {
754
753
  ASSERT(isVector2(pos), 'pos must be a vec2');
755
- ASSERT(isNumber(size), 'size must be a number');
754
+ ASSERT(isVector2(size), 'size must be a vec2');
756
755
  ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid');
756
+ ASSERT(isNumber(angle), 'angle must be a number');
757
757
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
758
758
 
759
759
  if (headlessMode) return;
@@ -765,26 +765,33 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
765
765
  {
766
766
  // convert to world space
767
767
  pos = screenToWorld(pos);
768
- size /= cameraScale;
768
+ size = size.scale(1/cameraScale);
769
+ angle += cameraAngle;
769
770
  }
770
771
  // fan as tristrip; rotate the boundary vertex by one slice per call
771
772
  // so back-to-back gradients at the same position have their hole
772
773
  // (from gpu edge-rule on the boundary line-degen) at different rim
773
774
  // verts and don't visibly stack
774
775
  const sides = glCircleSides;
775
- const radius = size/2;
776
+ const radiusX = size.x/2, radiusY = size.y/2;
776
777
  const innerInt = colorInner.rgbaInt();
777
778
  const outerInt = colorOuter.rgbaInt();
778
- const offset = drawCircleGradientOffset++;
779
+ const offset = drawEllipseGradientOffset++;
780
+ const c = cos(-angle), s = sin(-angle);
781
+ const rim = (a) =>
782
+ {
783
+ const lx = sin(a)*radiusX, ly = cos(a)*radiusY;
784
+ return vec2(pos.x + lx*c - ly*s, pos.y + lx*s + ly*c);
785
+ };
779
786
  const startA = (offset%sides)/sides*PI*2;
780
- const points = [vec2(pos.x + sin(startA)*radius, pos.y + cos(startA)*radius)];
787
+ const points = [rim(startA)];
781
788
  const colors = [outerInt];
782
789
  for (let i=sides; i--;)
783
790
  {
784
791
  const a = ((i+offset)%sides)/sides*PI*2;
785
792
  points.push(pos);
786
793
  colors.push(innerInt);
787
- points.push(vec2(pos.x + sin(a)*radius, pos.y + cos(a)*radius));
794
+ points.push(rim(a));
788
795
  colors.push(outerInt);
789
796
  }
790
797
  glDrawColoredPoints(points, colors);
@@ -794,7 +801,7 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
794
801
  // normal canvas 2D rendering method (slower)
795
802
  ++drawCount;
796
803
  ++primitiveCount;
797
- drawCanvas2D(pos, vec2(size), 0, false, (context)=>
804
+ drawCanvas2D(pos, size, angle, false, (context)=>
798
805
  {
799
806
  const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5);
800
807
  gradient.addColorStop(0, colorInner.toString());
@@ -807,6 +814,24 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
807
814
  }
808
815
  }
809
816
 
817
+ /** Draw a circle filled with a radial gradient from the center to the rim
818
+ * - Best when batched with other untextured polys
819
+ * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
820
+ * - Stacking gradients at the exact same position may show a faint vertical artifact
821
+ * @param {Vector2} pos
822
+ * @param {number} [size=1] - Diameter
823
+ * @param {Color} [colorInner=WHITE]
824
+ * @param {Color} [colorOuter=CLEAR_WHITE]
825
+ * @param {boolean} [useWebGL=glEnable]
826
+ * @param {boolean} [screenSpace]
827
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
828
+ * @memberof Draw */
829
+ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
830
+ {
831
+ ASSERT(isNumber(size), 'size must be a number');
832
+ drawEllipseGradient(pos, vec2(size), colorInner, colorOuter, 0, useWebGL, screenSpace, context);
833
+ }
834
+
810
835
  /**
811
836
  * @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context
812
837
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
@@ -861,7 +886,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
861
886
  * @param {number} [angle]
862
887
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
863
888
  * @memberof Draw */
864
- function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, font, fontStyle, maxWidth, angle=0, context=drawContext)
889
+ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
865
890
  {
866
891
  // convert to screen space
867
892
  pos = worldToScreen(pos);
@@ -901,16 +926,16 @@ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLA
901
926
  ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
902
927
  ASSERT(isNumber(angle), 'angle must be a number');
903
928
 
929
+ const lines = (text+'').split('\n');
930
+ const posY = pos.y - (lines.length-1) * size/2; // center vertically
931
+ // save before style mutations so caller's context state is preserved
932
+ context.save();
904
933
  context.fillStyle = color.toString();
905
934
  context.strokeStyle = lineColor.toString();
906
935
  context.lineWidth = lineWidth;
907
936
  context.textAlign = textAlign;
908
937
  context.font = fontStyle + ' ' + size + 'px '+ font;
909
938
  context.textBaseline = 'middle';
910
-
911
- const lines = (text+'').split('\n');
912
- const posY = pos.y - (lines.length-1) * size/2; // center vertically
913
- context.save();
914
939
  context.translate(pos.x, posY);
915
940
  context.rotate(-angle);
916
941
  let yOffset = 0;
@@ -1071,6 +1096,9 @@ function isOnScreen(pos, size=0)
1071
1096
  ASSERT(isVector2(pos), 'pos must be a vec2');
1072
1097
  ASSERT(isVector2(size) || isNumber(size), 'size must be a vec2 or number');
1073
1098
 
1099
+ // cameraScale of 0 collapses world coords; nothing is visible
1100
+ if (!cameraScale) return false;
1101
+
1074
1102
  // optimized circle on screen test
1075
1103
  // pos = worldToScreen(pos);
1076
1104
  let x = pos.x - cameraPos.x;
@@ -1112,7 +1140,10 @@ function combineCanvases()
1112
1140
  const w = mainCanvasSize.x, h = mainCanvasSize.y;
1113
1141
  workCanvas.width = w;
1114
1142
  workCanvas.height = h;
1115
- workContext.fillRect(0,0,w,h); // remove background alpha
1143
+ // remove background alpha — explicit fillStyle so a previous caller
1144
+ // leaving workContext.fillStyle transparent can't silently no-op this
1145
+ workContext.fillStyle = '#000';
1146
+ workContext.fillRect(0,0,w,h);
1116
1147
  glCopyToContext(workContext);
1117
1148
  workContext.drawImage(mainCanvas, 0, 0);
1118
1149
  mainContext.drawImage(workCanvas, 0, 0);
@@ -1255,33 +1286,33 @@ function setCursor(cursorStyle = 'auto')
1255
1286
  ///////////////////////////////////////////////////////////////////////////////
1256
1287
 
1257
1288
  /** Engine font image, 8x8 font provided by the engine
1258
- * @type {FontImage}
1289
+ * @type {ImageFont}
1259
1290
  * @memberof Draw */
1260
- let engineFontImage;
1291
+ let engineImageFont;
1261
1292
 
1262
1293
  /**
1263
- * Font Image Object - Draw text by using tiles in an image
1294
+ * Image Font Object - Draw text by using tiles in an image
1264
1295
  * - 96 characters (from space to tilde) are stored in an image
1265
1296
  * - A 8x8 default engine font is supplied for general use
1266
1297
  * - This system is WebGL enabled for fast text rendering
1267
1298
  * - Fonts can also be colored and scaled along each axis
1268
- *
1299
+ *
1269
1300
  * @memberof Draw
1270
1301
  * @example
1271
1302
  * // use built in font
1272
- * const font = engineFontImage;
1303
+ * const font = engineImageFont;
1273
1304
  *
1274
1305
  * // draw text
1275
1306
  * font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50));
1276
1307
  */
1277
- class FontImage
1308
+ class ImageFont
1278
1309
  {
1279
1310
  /** Create an image font
1280
1311
  * @param {TileInfo} tileInfo - Tile info of first character in font
1281
1312
  */
1282
1313
  constructor(tileInfo)
1283
1314
  {
1284
- ASSERT(!!tileInfo, 'tileInfo is required for FontImage');
1315
+ ASSERT(!!tileInfo, 'tileInfo is required for ImageFont');
1285
1316
 
1286
1317
  /** @property {TileInfo} - Tile info for the font */
1287
1318
  this.tileInfo = tileInfo.frame(0);
@@ -1366,7 +1397,7 @@ class FontImage
1366
1397
  }
1367
1398
 
1368
1399
  // load engine font, called automatically on startup
1369
- async function fontImageInit()
1400
+ async function imageFontInit()
1370
1401
  {
1371
1402
  const image = new Image;
1372
1403
  await new Promise(resolve =>
@@ -1379,5 +1410,5 @@ async function fontImageInit()
1379
1410
  const tilePos=vec2(), tileSize=vec2(8), padding=1, bleed=0;
1380
1411
  const textureInfo = new TextureInfo(image);
1381
1412
  const tileInfo = new TileInfo(tilePos, tileSize, textureInfo, padding, bleed);
1382
- engineFontImage = new FontImage(tileInfo);
1413
+ engineImageFont = new ImageFont(tileInfo);
1383
1414
  }
@@ -138,6 +138,7 @@ export
138
138
  setTouchGamepadAnalog,
139
139
  setTouchGamepadSize,
140
140
  setTouchGamepadAlpha,
141
+ setTouchGamepadDisplayTime,
141
142
  setVibrateEnable,
142
143
  setSoundEnable,
143
144
  setSoundVolume,
@@ -258,14 +259,15 @@ export
258
259
  drawRegularPoly,
259
260
  drawEllipse,
260
261
  drawCircle,
262
+ drawEllipseGradient,
261
263
  drawCircleGradient,
262
264
  drawCanvas2D,
263
265
  drawText,
264
266
  drawTextScreen,
265
267
  setBlendMode,
266
268
  combineCanvases,
267
- engineFontImage,
268
- FontImage,
269
+ engineImageFont,
270
+ ImageFont,
269
271
  isFullscreen,
270
272
  toggleFullscreen,
271
273
  setCursor,
@@ -447,9 +447,11 @@ function inputInit()
447
447
  mouseDeltaScreen = mouseDeltaScreen.add(movement);
448
448
  }
449
449
  function onMouseLeave() { mouseInWindow = false; } // mouse moved off window
450
- function onMouseWheel(e)
451
- {
452
- mouseWheel = e.ctrlKey ? 0 : sign(e.deltaY);
450
+ function onMouseWheel(e)
451
+ {
452
+ // accumulate so multiple wheel events in one frame are not lost
453
+ if (!e.ctrlKey)
454
+ mouseWheel += sign(e.deltaY);
453
455
  if (inputPreventDefault && e.cancelable && document.hasFocus())
454
456
  e.preventDefault(); // prevent page scrolling
455
457
  }
@@ -574,9 +576,14 @@ function inputInit()
574
576
  if (button < touchGamepadButtonCount)
575
577
  touchGamepadButtons[button] = 1;
576
578
  }
577
- else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize)
579
+ else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize &&
580
+ stickCenter.distance(touchPos) >= 2 * touchGamepadSize &&
581
+ buttonCenter.distance(touchPos) >= 2 * touchGamepadSize)
578
582
  {
579
583
  // virtual start button in center
584
+ // require a fat-finger buffer of touchGamepadSize beyond the
585
+ // edge of the stick/buttons so drift off those controls can't
586
+ // accidentally fire start
580
587
  touchGamepadButtons[9] = 1;
581
588
  }
582
589
  }
@@ -618,7 +625,7 @@ function inputUpdate()
618
625
  v > min ? percent(v, min, max) :
619
626
  v < -min ? -percent(-v, min, max) : 0;
620
627
  return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
621
- }
628
+ };
622
629
 
623
630
  // update touch gamepad if enabled
624
631
  if (touchGamepadEnable && isTouchDevice)
@@ -632,7 +639,12 @@ function inputUpdate()
632
639
  debugCircle(stickCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
633
640
  debugCircle(buttonCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
634
641
  if (touchGamepadCenterButtonSize)
642
+ {
635
643
  debugCircle(startCenter, 2*touchGamepadCenterButtonSize, 'cyan', 0, false, true);
644
+ // exclusion bubbles around controls (where start is blocked)
645
+ debugCircle(stickCenter, 4*touchGamepadSize, 'magenta', 0, false, true);
646
+ debugCircle(buttonCenter, 4*touchGamepadSize, 'magenta', 0, false, true);
647
+ }
636
648
  }
637
649
 
638
650
  if (!touchGamepadTimer.isSet()) return;
@@ -739,13 +751,6 @@ function inputUpdate()
739
751
  (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
740
752
  (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
741
753
  }
742
- else if (gamepad.axes && gamepad.axes.length >= 2)
743
- {
744
- // digital style dpad from axes
745
- const x = clamp(round(gamepad.axes[0]), -1, 1);
746
- const y = clamp(round(gamepad.axes[1]), -1, 1);
747
- dpad.set(x, -y);
748
- }
749
754
 
750
755
  // copy dpad to left analog stick when pressed
751
756
  if (gamepadDirectionEmulateStick && (dpad.x || dpad.y))
package/src/engineLogo.js CHANGED
@@ -65,7 +65,7 @@ function drawEngineLogo(t)
65
65
  x.closePath();
66
66
  gradient(0, Y, 0, Y+H,C);
67
67
  }
68
- const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
68
+ const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%)`:'#000';
69
69
 
70
70
  // center and fit to screen
71
71
  const alpha = oscillate(1,1,t);
package/src/engineMath.js CHANGED
@@ -62,19 +62,19 @@ const max = Math.max;
62
62
  * @param {number} x
63
63
  * @return {number}
64
64
  * @memberof Math */
65
- const sign = Math.sign;
65
+ const sign = (x) => Math.sign(x);
66
66
 
67
67
  /** Returns hypotenuse of values passed in
68
68
  * @param {...number} values
69
69
  * @return {number}
70
70
  * @memberof Math */
71
- const hypot = Math.hypot;
71
+ const hypot = (...values) => Math.hypot(...values);
72
72
 
73
73
  /** Returns log2 of value passed in
74
74
  * @param {number} x
75
75
  * @return {number}
76
76
  * @memberof Math */
77
- const log2 = Math.log2;
77
+ const log2 = (x) => Math.log2(x);
78
78
 
79
79
  /** Returns sin of value passed in
80
80
  * @param {number} x
@@ -216,7 +216,8 @@ function isOverlapping(posA, sizeA, posB, sizeB=vec2())
216
216
  const dy = (posA.y - posB.y)*2;
217
217
  const sx = sizeA.x + sizeB.x;
218
218
  const sy = sizeA.y + sizeB.y;
219
- return dx >= -sx && dx < sx && dy >= -sy && dy < sy;
219
+ // symmetric so isOverlapping(A,B) === isOverlapping(B,A) at touching edges
220
+ return abs(dx) < sx && abs(dy) < sy;
220
221
  }
221
222
 
222
223
  /** Returns true if a line segment is intersecting an axis aligned box
@@ -305,7 +306,7 @@ function isStringLike(s) { return s != null && typeof s?.toString() === 'string'
305
306
  /**
306
307
  * Check if object is an array
307
308
  * @param {any} a
308
- * @return {boolean}
309
+ * @return {a is Array<any>}
309
310
  * @memberof Math */
310
311
  function isArray(a) { return Array.isArray(a); }
311
312
 
@@ -443,7 +444,13 @@ function randVec2(length=1) { return new Vector2().setAngle(rand(2*PI), length);
443
444
  * @return {Vector2}
444
445
  * @memberof Random */
445
446
  function randInCircle(radius=1, minRadius=0)
446
- { return radius > 0 ? randVec2(radius * rand(minRadius / radius, 1)**.5) : new Vector2; }
447
+ {
448
+ // r is uniform in area ⇒ r² uniform in [minRadius², radius²]
449
+ // (the squared inner bound is what makes minRadius the actual exclusion edge)
450
+ if (radius <= 0) return new Vector2;
451
+ const ratio = clamp(minRadius / radius);
452
+ return randVec2(radius * rand(ratio*ratio, 1)**.5);
453
+ }
447
454
 
448
455
  /** Returns a random color between the two passed in colors, combine components if linear
449
456
  * @param {Color} [colorA=WHITE]
@@ -513,7 +520,14 @@ class RandomGenerator
513
520
  * @param {number} [valueA]
514
521
  * @param {number} [valueB]
515
522
  * @return {number} */
516
- floatSign(valueA=1, valueB=0) { return this.float(valueA, valueB) * this.sign(); }
523
+ floatSign(valueA=1, valueB=0)
524
+ {
525
+ const lo = min(valueA, valueB);
526
+ const hi = max(valueA, valueB);
527
+ const d = hi - lo;
528
+ const e = this.float(d*2);
529
+ return e < d ? lo + e : d - lo - e;
530
+ }
517
531
 
518
532
  /** Returns a random angle between -PI and PI
519
533
  * @return {number} */
@@ -71,7 +71,7 @@ class EngineObject
71
71
  this.color = color.copy();
72
72
  /** @property {Color} - Additive color to apply when rendered */
73
73
  this.additiveColor = undefined;
74
- /** @property {boolean} - Should it flip along y axis when rendered */
74
+ /** @property {boolean} - Should the rendered tile flip along the y axis. Affects rendering and the local→world transform of attached children (a mirrored parent flips its children's localPos.x and localAngle). Does not affect this object's own physics, collision, or localToWorld/worldToLocal. */
75
75
  this.mirror = false;
76
76
  /** @property {boolean} - Has object been destroyed? */
77
77
  this.destroyed = false;
@@ -157,6 +157,9 @@ class EngineObject
157
157
  // child objects do not have physics
158
158
  ASSERT(!this.parent);
159
159
 
160
+ // bail if a collision callback destroyed us mid-frame
161
+ if (this.destroyed) return;
162
+
160
163
  if (this.clampSpeed)
161
164
  {
162
165
  // limit max speed to prevent missing collisions
@@ -212,6 +215,8 @@ class EngineObject
212
215
 
213
216
  // notify objects of collision and check if should be resolved
214
217
  const collide1 = this.collideWithObject(o);
218
+ // callback may have destroyed us; stop resolving against more objects
219
+ if (this.destroyed) return;
215
220
  const collide2 = o.collideWithObject(this);
216
221
  if (!collide1 || !collide2) continue;
217
222
 
@@ -307,13 +312,17 @@ class EngineObject
307
312
  const restitution = max(this.restitution, hitLayer.restitution);
308
313
  if (isBlockedX)
309
314
  {
310
- // try to move up a tiny bit
315
+ // try to step over a 1-tile bump (direction follows gravity sign
316
+ // so inverted gravity steps down off a ceiling bump instead of up;
317
+ // zero gravity defaults to the normal-gravity step-up direction)
311
318
  const epsilon = 1e-3;
312
- const maxMoveUp = .1;
313
- const y = floor(oldPos.y-this.size.y/2+1) +
314
- this.size.y/2 + epsilon;
315
- const delta = y - this.pos.y;
316
- if (delta < maxMoveUp)
319
+ const maxMove = .1;
320
+ const gravitySign = gravity.y > 0 ? -1 : 1;
321
+ const y = gravitySign > 0 ?
322
+ floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon :
323
+ ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon;
324
+ const delta = abs(y - this.pos.y);
325
+ if (delta < maxMove)
317
326
  if (!tileCollisionTest(vec2(this.pos.x, y), this.size, this))
318
327
  {
319
328
  this.pos.y = y;
@@ -453,6 +462,8 @@ class EngineObject
453
462
  * @return {EngineObject} The child object added */
454
463
  addChild(child, localPos=vec2(), localAngle=0)
455
464
  {
465
+ ASSERT(!this.destroyed, 'cannot add child to destroyed object');
466
+ if (this.destroyed) return child;
456
467
  ASSERT(!child.parent && !this.children.includes(child));
457
468
  ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
458
469
  ASSERT(child !== this, 'cannot add self as child');
@@ -469,10 +480,7 @@ class EngineObject
469
480
  removeChild(child)
470
481
  {
471
482
  ASSERT(child.parent === this && this.children.includes(child));
472
- ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
473
- const index = this.children.indexOf(child);
474
- ASSERT(index >= 0, 'child not found in children array');
475
- index >= 0 && this.children.splice(index, 1);
483
+ this.children.splice(this.children.indexOf(child), 1);
476
484
  child.parent = undefined;
477
485
  }
478
486
 
@@ -68,7 +68,7 @@ class ParticleEmitter extends EngineObject
68
68
  * @param {number} [angleDamping] - How much to dampen particle angular speed
69
69
  * @param {number} [gravityScale] - How much gravity effect particles
70
70
  * @param {number} [particleConeAngle] - Cone for start particle angle
71
- * @param {number} [fadeRate] - How quick to fade particles at start/end in percent of life
71
+ * @param {number} [fadeRate] - Fraction of life spent fading: half at fade-in (start), half at fade-out (end). e.g. .2 = 10% fade-in, 80% full opacity, 10% fade-out
72
72
  * @param {number} [randomness] - Apply extra randomness percent
73
73
  * @param {boolean} [collideTiles] - Do particles collide against tiles
74
74
  * @param {boolean} [additive] - Should particles use additive blend
@@ -152,7 +152,7 @@ class ParticleEmitter extends EngineObject
152
152
  this.gravityScale = gravityScale;
153
153
  /** @property {number} - Cone for start particle angle */
154
154
  this.particleConeAngle = particleConeAngle;
155
- /** @property {number} - How quick to fade in particles at start/end in percent of life */
155
+ /** @property {number} - Fraction of life spent fading, split half at start and half at end (e.g. .2 = 10% fade-in + 10% fade-out) */
156
156
  this.fadeRate = fadeRate;
157
157
  /** @property {number} - Apply extra randomness percent */
158
158
  this.randomness = randomness;
@@ -430,33 +430,32 @@ class Particle
430
430
  const hitLayer = tileCollisionTest(this.pos);
431
431
  if (!testCollision(oldPos))
432
432
  {
433
- if (!collideCallback || collideCallback?.(this, hitLayer))
433
+ // testCollision already invoked collideCallback with the
434
+ // correct (this, data, pos) args; no need to re-check here.
435
+ // test which side we bounced off (or both if a corner)
436
+ const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
437
+ const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
438
+ const hitRestitution = max(restitution, hitLayer.restitution);
439
+ const hitFriction = max(friction, hitLayer.friction);
440
+ if (isBlockedX)
434
441
  {
435
- // test which side we bounced off (or both if a corner)
436
- const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
437
- const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
438
- const hitRestitution = max(restitution, hitLayer.restitution);
439
- const hitFriction = max(friction, hitLayer.friction);
440
- if (isBlockedX)
441
- {
442
- // move to previous X position and bounce
443
- this.pos.x = oldPos.x;
444
- this.velocity.x *= -hitRestitution;
445
- this.velocity.y *= hitFriction;
446
- }
447
- if (isBlockedY || !isBlockedX)
448
- {
449
- const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
450
- if (wasFalling)
451
- this.groundObject = hitLayer;
452
-
453
- // move to previous Y position and bounce
454
- this.pos.y = oldPos.y;
455
- this.velocity.y *= -hitRestitution;
456
- this.velocity.x *= hitFriction;
457
- }
458
- debugPhysics && debugRect(this.pos, this.size, '#f00');
442
+ // move to previous X position and bounce
443
+ this.pos.x = oldPos.x;
444
+ this.velocity.x *= -hitRestitution;
445
+ this.velocity.y *= hitFriction;
459
446
  }
447
+ if (isBlockedY || !isBlockedX)
448
+ {
449
+ const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
450
+ if (wasFalling)
451
+ this.groundObject = hitLayer;
452
+
453
+ // move to previous Y position and bounce
454
+ this.pos.y = oldPos.y;
455
+ this.velocity.y *= -hitRestitution;
456
+ this.velocity.x *= hitFriction;
457
+ }
458
+ debugPhysics && debugRect(this.pos, this.size, '#f00');
460
459
  }
461
460
  }
462
461
  }
@@ -51,7 +51,7 @@ let canvasColorTiles = true;
51
51
 
52
52
  /** Color to clear the canvas to before render, does not clear if alpha is 0
53
53
  * @type {Color}
54
- * @memberof Draw */
54
+ * @memberof Settings */
55
55
  let canvasClearColor = CLEAR_BLACK;
56
56
 
57
57
  /** The max size of the canvas, centered if window is larger
@@ -251,7 +251,8 @@ let touchInputEnable = true;
251
251
  let touchGamepadEnable = false;
252
252
 
253
253
  /** True if touch gamepad should have start button in the center
254
- * - Prevents activating if overlappng with virtual stick or buttons if they are enabled
254
+ * - Prevents activating within 2*touchGamepadSize of the virtual stick or face buttons
255
+ * (one radius for the visible control + one radius of buffer beyond its edge)
255
256
  * - When the game is paused, any touch will press the button
256
257
  * - Set size to enable the center button
257
258
  * @type {number}