littlejsengine 1.18.8 → 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.
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.18.8';
38
+ const engineVersion = '1.18.15';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -164,12 +164,20 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored)
164
164
  * ['tiles.png', 'tilesLevel.png'] // images to load
165
165
  * );
166
166
  * @memberof Engine */
167
- async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement=document.body)
167
+ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement)
168
168
  {
169
169
  showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
170
170
  ASSERT(!mainContext, 'engine already initialized');
171
+ // runtime guard so release builds (where the assert is stripped) don't
172
+ // double-register listeners / double-add canvases on a second call
173
+ if (mainContext) return;
171
174
  ASSERT(isArray(imageSources), 'pass in images as array');
172
175
 
176
+ // ensure body exists for minimal HTML where the script runs before <body> is parsed
177
+ if (!document.body)
178
+ document.documentElement.appendChild(document.createElement('body'));
179
+ rootElement ||= document.body;
180
+
173
181
  // allow passing in empty functions
174
182
  gameInit ||= ()=>{};
175
183
  gameUpdate ||= ()=>{};
@@ -195,6 +203,9 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
195
203
  {
196
204
  // update time keeping
197
205
  let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS;
206
+ // skip delta on the very first frame so timeReal doesn't jump
207
+ // by ~page-load-time when RAF starts handing real timestamps
208
+ if (!frameTimeLastMS) frameTimeDeltaMS = 0;
198
209
  frameTimeLastMS = frameTimeMS;
199
210
  if (debug || debugWatermark)
200
211
  averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
@@ -207,7 +218,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
207
218
  const combinedScale = timeScale * debugScale;
208
219
  frameTimeDeltaMS *= combinedScale;
209
220
  frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
210
- if (debugScale <= 1)
221
+ if (combinedScale <= 1)
211
222
  frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp min framerate
212
223
 
213
224
  let wasUpdated = false;
@@ -294,6 +305,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
294
305
  glFlush();
295
306
  debugRenderPost();
296
307
  drawCount = 0;
308
+ primitiveCount = 0;
297
309
  }
298
310
  }
299
311
 
@@ -424,7 +436,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
424
436
  promises.push(loadTexture(0));
425
437
 
426
438
  // load engine font image
427
- promises.push(fontImageInit());
439
+ promises.push(imageFontInit());
428
440
 
429
441
  if (showSplashScreen)
430
442
  {
@@ -649,12 +661,8 @@ function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false, s
649
661
  ASSERT(isNumber(time), 'time must be a number');
650
662
  ASSERT(isNumber(angle), 'angle must be a number');
651
663
 
652
- if (typeof size === 'number')
653
- size = vec2(size); // allow passing in floats
654
664
  if (isColor(color))
655
665
  color = color.toString();
656
- pos = pos.copy();
657
- size = size.copy();
658
666
  const timer = new Timer(time);
659
667
  debugPrimitives.push({pos:pos.copy(), size:size.copy(), color, timer, angle, fill, screenSpace});
660
668
  }
@@ -724,7 +732,7 @@ function debugPoint(pos, color, time, angle, screenSpace=false)
724
732
  * @param {number} [time]
725
733
  * @param {boolean} [screenSpace]
726
734
  * @memberof Debug */
727
- function debugLine(posA, posB, color, width=.1, time, screenSpace=false)
735
+ function debugLine(posA, posB, color, width=.1, time=0, screenSpace=false)
728
736
  {
729
737
  ASSERT(isVector2(posA), 'posA must be a vec2');
730
738
  ASSERT(isVector2(posB), 'posB must be a vec2');
@@ -762,7 +770,7 @@ function debugOverlap(posA, sizeA, posB, sizeB, color, time, screenSpace=false)
762
770
  debugRect(minPos.lerp(maxPos,.5), maxPos.subtract(minPos), color, time, 0, false, screenSpace);
763
771
  }
764
772
 
765
- /** Draw a debug axis aligned bounding box in world space
773
+ /** Draw debug text in world space
766
774
  * @param {string|number} text
767
775
  * @param {Vector2} pos
768
776
  * @param {number} [size]
@@ -1068,7 +1076,8 @@ function debugRender()
1068
1076
  debugContext.fillText('FPS: ' + averageFPS.toFixed(1) + (glEnable?' WebGL':' Canvas2D'),
1069
1077
  x, y += h);
1070
1078
  debugContext.fillText('Objects: ' + engineObjects.length, x, y += h);
1071
- debugContext.fillText('Draw Count: ' + drawCount, x, y += h);
1079
+ debugContext.fillText('Draw Calls: ' + drawCount, x, y += h);
1080
+ debugContext.fillText('Primitives: ' + primitiveCount, x, y += h);
1072
1081
  debugContext.fillText('---------', x, y += h);
1073
1082
  debugContext.fillStyle = '#f00';
1074
1083
  debugContext.fillText('ESC: Debug Overlay', x, y += h);
@@ -1092,7 +1101,7 @@ function debugRender()
1092
1101
  continue;
1093
1102
  if (parseInt(i) < 3)
1094
1103
  mousePressed += i + ' ' ;
1095
- else if (keyIsDown(i, 0))
1104
+ else
1096
1105
  keysPressed += i + ' ' ;
1097
1106
  }
1098
1107
  mousePressed && debugContext.fillText('Mouse: ' + mousePressed, x, y += h);
@@ -1139,7 +1148,8 @@ function debugRenderPost()
1139
1148
  mainContext.font = '1em monospace';
1140
1149
  mainContext.fillStyle = '#000';
1141
1150
  const text = engineName + ' v' + engineVersion + ' / '
1142
- + drawCount + ' / ' + engineObjects.length + ' / ' + averageFPS.toFixed(1)
1151
+ + drawCount + ' / ' + primitiveCount + ' / '
1152
+ + engineObjects.length + ' / ' + averageFPS.toFixed(1)
1143
1153
  + (glEnable ? ' GL' : ' 2D') ;
1144
1154
  mainContext.fillText(text, mainCanvas.width-3, 3);
1145
1155
  mainContext.fillStyle = '#fff';
@@ -1350,19 +1360,19 @@ const max = Math.max;
1350
1360
  * @param {number} x
1351
1361
  * @return {number}
1352
1362
  * @memberof Math */
1353
- const sign = Math.sign;
1363
+ const sign = (x) => Math.sign(x);
1354
1364
 
1355
1365
  /** Returns hypotenuse of values passed in
1356
1366
  * @param {...number} values
1357
1367
  * @return {number}
1358
1368
  * @memberof Math */
1359
- const hypot = Math.hypot;
1369
+ const hypot = (...values) => Math.hypot(...values);
1360
1370
 
1361
1371
  /** Returns log2 of value passed in
1362
1372
  * @param {number} x
1363
1373
  * @return {number}
1364
1374
  * @memberof Math */
1365
- const log2 = Math.log2;
1375
+ const log2 = (x) => Math.log2(x);
1366
1376
 
1367
1377
  /** Returns sin of value passed in
1368
1378
  * @param {number} x
@@ -1504,7 +1514,8 @@ function isOverlapping(posA, sizeA, posB, sizeB=vec2())
1504
1514
  const dy = (posA.y - posB.y)*2;
1505
1515
  const sx = sizeA.x + sizeB.x;
1506
1516
  const sy = sizeA.y + sizeB.y;
1507
- return dx >= -sx && dx < sx && dy >= -sy && dy < sy;
1517
+ // symmetric so isOverlapping(A,B) === isOverlapping(B,A) at touching edges
1518
+ return abs(dx) < sx && abs(dy) < sy;
1508
1519
  }
1509
1520
 
1510
1521
  /** Returns true if a line segment is intersecting an axis aligned box
@@ -1593,7 +1604,7 @@ function isStringLike(s) { return s != null && typeof s?.toString() === 'string'
1593
1604
  /**
1594
1605
  * Check if object is an array
1595
1606
  * @param {any} a
1596
- * @return {boolean}
1607
+ * @return {a is Array<any>}
1597
1608
  * @memberof Math */
1598
1609
  function isArray(a) { return Array.isArray(a); }
1599
1610
 
@@ -1731,7 +1742,13 @@ function randVec2(length=1) { return new Vector2().setAngle(rand(2*PI), length);
1731
1742
  * @return {Vector2}
1732
1743
  * @memberof Random */
1733
1744
  function randInCircle(radius=1, minRadius=0)
1734
- { return radius > 0 ? randVec2(radius * rand(minRadius / radius, 1)**.5) : new Vector2; }
1745
+ {
1746
+ // r is uniform in area ⇒ r² uniform in [minRadius², radius²]
1747
+ // (the squared inner bound is what makes minRadius the actual exclusion edge)
1748
+ if (radius <= 0) return new Vector2;
1749
+ const ratio = clamp(minRadius / radius);
1750
+ return randVec2(radius * rand(ratio*ratio, 1)**.5);
1751
+ }
1735
1752
 
1736
1753
  /** Returns a random color between the two passed in colors, combine components if linear
1737
1754
  * @param {Color} [colorA=WHITE]
@@ -1801,7 +1818,14 @@ class RandomGenerator
1801
1818
  * @param {number} [valueA]
1802
1819
  * @param {number} [valueB]
1803
1820
  * @return {number} */
1804
- floatSign(valueA=1, valueB=0) { return this.float(valueA, valueB) * this.sign(); }
1821
+ floatSign(valueA=1, valueB=0)
1822
+ {
1823
+ const lo = min(valueA, valueB);
1824
+ const hi = max(valueA, valueB);
1825
+ const d = hi - lo;
1826
+ const e = this.float(d*2);
1827
+ return e < d ? lo + e : d - lo - e;
1828
+ }
1805
1829
 
1806
1830
  /** Returns a random angle between -PI and PI
1807
1831
  * @return {number} */
@@ -2449,6 +2473,7 @@ const MAGENTA = debugProtectConstant(rgb(1,0,1));
2449
2473
  * - File saving (text, canvas, data URLs)
2450
2474
  * - Native share dialog support
2451
2475
  * - Local storage save data management
2476
+ * - Gradient noise (1D and 2D)
2452
2477
  * @namespace Utilities
2453
2478
  */
2454
2479
 
@@ -2513,9 +2538,15 @@ class Timer
2513
2538
  * @return {number} */
2514
2539
  get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
2515
2540
 
2516
- /** Get percentage elapsed based on time it was set to, returns 0 if not set
2541
+ /** Get percentage elapsed based on time it was set to, returns 0 if not set.
2542
+ * Zero-duration timers report 1 (already elapsed).
2517
2543
  * @return {number} */
2518
- getPercent() { return this.isSet()? 1-percent(this.time - this.getGlobalTime(), 0, this.setTime) : 0; }
2544
+ getPercent()
2545
+ {
2546
+ if (!this.isSet()) return 0;
2547
+ if (!this.setTime) return 1;
2548
+ return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime);
2549
+ }
2519
2550
 
2520
2551
  /** Get the time this timer was set to, returns 0 if not set
2521
2552
  * @return {number} */
@@ -2542,9 +2573,9 @@ class Timer
2542
2573
  * @memberof Utilities */
2543
2574
  function formatTime(t)
2544
2575
  {
2545
- const sign = t < 0 ? '-' : '';
2576
+ const signStr = t < 0 ? '-' : '';
2546
2577
  t = abs(t)|0;
2547
- return sign + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
2578
+ return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
2548
2579
  }
2549
2580
 
2550
2581
  /** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
@@ -2630,15 +2661,20 @@ function shareURL(title, url, callback)
2630
2661
  function readSaveData(saveName, defaultSaveData)
2631
2662
  {
2632
2663
  ASSERT(isStringLike(saveName), 'loadData requires saveName string');
2633
-
2634
- // replace undefined values with defaults; tolerate corrupt JSON
2635
- const data = localStorage[saveName];
2664
+
2665
+ // tolerate localStorage being unavailable (iOS private mode, sandboxed
2666
+ // iframes) and corrupt JSON in stored data
2636
2667
  let loadedData = {};
2637
- if (data)
2668
+ try
2638
2669
  {
2639
- try { loadedData = JSON.parse(data); }
2640
- catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
2670
+ const data = localStorage[saveName];
2671
+ if (data)
2672
+ {
2673
+ try { loadedData = JSON.parse(data); }
2674
+ catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
2675
+ }
2641
2676
  }
2677
+ catch { LOG('readSaveData: localStorage unavailable — using defaults'); }
2642
2678
  return { ...defaultSaveData, ...loadedData };
2643
2679
  }
2644
2680
 
@@ -2649,7 +2685,51 @@ function readSaveData(saveName, defaultSaveData)
2649
2685
  function writeSaveData(saveName, saveData)
2650
2686
  {
2651
2687
  ASSERT(isStringLike(saveName), 'saveData requires saveName string');
2652
- localStorage[saveName] = JSON.stringify(saveData);
2688
+ // tolerate localStorage being unavailable or quota exceeded
2689
+ try { localStorage[saveName] = JSON.stringify(saveData); }
2690
+ catch { LOG('writeSaveData: failed to write', saveName); }
2691
+ }
2692
+
2693
+ ///////////////////////////////////////////////////////////////////////////////
2694
+
2695
+ // Deterministic well-distributed hash of an integer lattice index to [0, 1).
2696
+ // Murmur3 finalizer — adjacent integers produce uncorrelated outputs.
2697
+ function noiseHash(i)
2698
+ {
2699
+ let h = (i | 0) ^ 0x9e3779b9;
2700
+ h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
2701
+ h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
2702
+ h ^= h >>> 16;
2703
+ return (h >>> 0) / 2**32;
2704
+ }
2705
+
2706
+ /** 1D gradient noise — returns a smooth value in [0, 1] for any real x.
2707
+ * Integer inputs land on deterministic lattice values; non-integer inputs
2708
+ * are interpolated with smoothStep for C1 continuity.
2709
+ * @param {number} x
2710
+ * @return {number}
2711
+ * @memberof Utilities */
2712
+ function noise1D(x)
2713
+ {
2714
+ const i = floor(x);
2715
+ return lerp(noiseHash(i), noiseHash(i + 1), smoothStep(x - i));
2716
+ }
2717
+
2718
+ /** 2D gradient noise — returns a smooth value in [0, 1] for any real (x, y).
2719
+ * @param {number} x
2720
+ * @param {number} y
2721
+ * @return {number}
2722
+ * @memberof Utilities */
2723
+ function noise2D(x, y)
2724
+ {
2725
+ const ix = floor(x), iy = floor(y);
2726
+ const fx = smoothStep(x - ix), fy = smoothStep(y - iy);
2727
+ // large prime decorrelates neighboring rows
2728
+ const h = (a, b) => noiseHash(a + b * 374761393);
2729
+ return lerp(
2730
+ lerp(h(ix, iy ), h(ix + 1, iy ), fx),
2731
+ lerp(h(ix, iy + 1), h(ix + 1, iy + 1), fx),
2732
+ fy);
2653
2733
  }
2654
2734
  /**
2655
2735
  * LittleJS Engine Settings
@@ -2702,7 +2782,7 @@ let canvasColorTiles = true;
2702
2782
 
2703
2783
  /** Color to clear the canvas to before render, does not clear if alpha is 0
2704
2784
  * @type {Color}
2705
- * @memberof Draw */
2785
+ * @memberof Settings */
2706
2786
  let canvasClearColor = CLEAR_BLACK;
2707
2787
 
2708
2788
  /** The max size of the canvas, centered if window is larger
@@ -2902,7 +2982,8 @@ let touchInputEnable = true;
2902
2982
  let touchGamepadEnable = false;
2903
2983
 
2904
2984
  /** True if touch gamepad should have start button in the center
2905
- * - Prevents activating if overlappng with virtual stick or buttons if they are enabled
2985
+ * - Prevents activating within 2*touchGamepadSize of the virtual stick or face buttons
2986
+ * (one radius for the visible control + one radius of buffer beyond its edge)
2906
2987
  * - When the game is paused, any touch will press the button
2907
2988
  * - Set size to enable the center button
2908
2989
  * @type {number}
@@ -3314,7 +3395,7 @@ class EngineObject
3314
3395
  this.color = color.copy();
3315
3396
  /** @property {Color} - Additive color to apply when rendered */
3316
3397
  this.additiveColor = undefined;
3317
- /** @property {boolean} - Should it flip along y axis when rendered */
3398
+ /** @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. */
3318
3399
  this.mirror = false;
3319
3400
  /** @property {boolean} - Has object been destroyed? */
3320
3401
  this.destroyed = false;
@@ -3400,6 +3481,9 @@ class EngineObject
3400
3481
  // child objects do not have physics
3401
3482
  ASSERT(!this.parent);
3402
3483
 
3484
+ // bail if a collision callback destroyed us mid-frame
3485
+ if (this.destroyed) return;
3486
+
3403
3487
  if (this.clampSpeed)
3404
3488
  {
3405
3489
  // limit max speed to prevent missing collisions
@@ -3455,6 +3539,8 @@ class EngineObject
3455
3539
 
3456
3540
  // notify objects of collision and check if should be resolved
3457
3541
  const collide1 = this.collideWithObject(o);
3542
+ // callback may have destroyed us; stop resolving against more objects
3543
+ if (this.destroyed) return;
3458
3544
  const collide2 = o.collideWithObject(this);
3459
3545
  if (!collide1 || !collide2) continue;
3460
3546
 
@@ -3550,13 +3636,17 @@ class EngineObject
3550
3636
  const restitution = max(this.restitution, hitLayer.restitution);
3551
3637
  if (isBlockedX)
3552
3638
  {
3553
- // try to move up a tiny bit
3639
+ // try to step over a 1-tile bump (direction follows gravity sign
3640
+ // so inverted gravity steps down off a ceiling bump instead of up;
3641
+ // zero gravity defaults to the normal-gravity step-up direction)
3554
3642
  const epsilon = 1e-3;
3555
- const maxMoveUp = .1;
3556
- const y = floor(oldPos.y-this.size.y/2+1) +
3557
- this.size.y/2 + epsilon;
3558
- const delta = y - this.pos.y;
3559
- if (delta < maxMoveUp)
3643
+ const maxMove = .1;
3644
+ const gravitySign = gravity.y > 0 ? -1 : 1;
3645
+ const y = gravitySign > 0 ?
3646
+ floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon :
3647
+ ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon;
3648
+ const delta = abs(y - this.pos.y);
3649
+ if (delta < maxMove)
3560
3650
  if (!tileCollisionTest(vec2(this.pos.x, y), this.size, this))
3561
3651
  {
3562
3652
  this.pos.y = y;
@@ -3696,6 +3786,8 @@ class EngineObject
3696
3786
  * @return {EngineObject} The child object added */
3697
3787
  addChild(child, localPos=vec2(), localAngle=0)
3698
3788
  {
3789
+ ASSERT(!this.destroyed, 'cannot add child to destroyed object');
3790
+ if (this.destroyed) return child;
3699
3791
  ASSERT(!child.parent && !this.children.includes(child));
3700
3792
  ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
3701
3793
  ASSERT(child !== this, 'cannot add self as child');
@@ -3712,10 +3804,7 @@ class EngineObject
3712
3804
  removeChild(child)
3713
3805
  {
3714
3806
  ASSERT(child.parent === this && this.children.includes(child));
3715
- ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
3716
- const index = this.children.indexOf(child);
3717
- ASSERT(index >= 0, 'child not found in children array');
3718
- index >= 0 && this.children.splice(index, 1);
3807
+ this.children.splice(this.children.indexOf(child), 1);
3719
3808
  child.parent = undefined;
3720
3809
  }
3721
3810
 
@@ -3790,7 +3879,7 @@ class EngineObject
3790
3879
  * - Optimized tile sheet sprite rendering using WebGL batching
3791
3880
  * - Primitive drawing for polygons, ellipses, and lines
3792
3881
  * - Tile-based rendering with TileInfo and TextureInfo classes
3793
- * - Text rendering with custom fonts and FontImage support
3882
+ * - Text rendering with custom fonts and ImageFont support
3794
3883
  * - Color and additive color blending for effects
3795
3884
  * - Rotation, mirroring, and scaling transformations
3796
3885
  * - Camera system with position, scale, and rotation
@@ -3856,6 +3945,12 @@ let textureInfos = [];
3856
3945
  * @memberof Draw */
3857
3946
  let drawCount;
3858
3947
 
3948
+ /** Keeps track of how many primitives were drawn each frame for debugging
3949
+ * A single draw call can render many primitives (e.g. a WebGL sprite batch).
3950
+ * @type {number}
3951
+ * @memberof Draw */
3952
+ let primitiveCount;
3953
+
3859
3954
  // internal predicates for tint short-circuiting in canvas2D draw paths
3860
3955
  // isWhite ignores alpha because alpha is applied via globalAlpha, not multiply
3861
3956
  // isBlack includes alpha so additive colors that only contribute alpha are not skipped
@@ -3880,7 +3975,7 @@ let drawCount;
3880
3975
  * tile(1, 16, 3) // a tile at index 1 of size 16 on texture 3
3881
3976
  * tile(vec2(4,8), vec2(30,10)) // a tile at index (4,8) with a size of (30,10)
3882
3977
  * @memberof Draw */
3883
- function tile(index=new Vector2, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
3978
+ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
3884
3979
  {
3885
3980
  ASSERT(isVector2(index) || typeof index === 'number', 'index must be a vec2 or number');
3886
3981
  ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
@@ -3931,8 +4026,8 @@ class TileInfo
3931
4026
  * @param {Vector2} [pos=vec2()] - Top left corner of tile in pixels
3932
4027
  * @param {Vector2} [size] - Size of tile in pixels
3933
4028
  * @param {TextureInfo} [textureInfo] - Texture info to use
3934
- * @param {number} [padding] - How many pixels padding around tiles
3935
- * @param {number} [bleed] - How many pixels smaller to draw tiles
4029
+ * @param {number} [padding] - How many pixels padding around all sides of each tile (increases grid size, does not affect tile size)
4030
+ * @param {number} [bleed] - How many pixels smaller to shrink UVS of tiles (does not affect grid size, only UVs)
3936
4031
  */
3937
4032
  constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed)
3938
4033
  {
@@ -3964,7 +4059,7 @@ class TileInfo
3964
4059
  ASSERT(typeof frame === 'number');
3965
4060
  const w = this.size.x + this.padding*2;
3966
4061
  const x = frame*w;
3967
- ASSERT(x < this.textureInfo.size.x, 'frame extends beyond texture width!');
4062
+ ASSERT(x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
3968
4063
  return this.offset(new Vector2(x));
3969
4064
  }
3970
4065
 
@@ -4053,7 +4148,7 @@ class TextureInfo
4053
4148
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
4054
4149
  * @memberof Draw */
4055
4150
  function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
4056
- angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace, context)
4151
+ angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=false, context)
4057
4152
  {
4058
4153
  ASSERT(isVector2(pos), 'pos must be a vec2');
4059
4154
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -4096,19 +4191,17 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
4096
4191
  }
4097
4192
  else
4098
4193
  {
4099
- // if no tile info, force untextured by zeroing rgba (so whatever
4100
- // texture is bound doesn't leak in) and folding color+additive
4101
- // into the additive slot — matches the Canvas2D path's
4194
+ // untextured: fold color+additive to match the Canvas2D path's
4102
4195
  // color.add(additiveColor) on line ~337.
4103
4196
  const combined = additiveColor ? color.add(additiveColor) : color;
4104
- glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0,
4105
- 0, combined.rgbaInt());
4197
+ glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
4106
4198
  }
4107
4199
  }
4108
4200
  else
4109
4201
  {
4110
4202
  // normal canvas 2D rendering method (slower)
4111
4203
  ++drawCount;
4204
+ ++primitiveCount;
4112
4205
  size = new Vector2(size.x, -size.y); // flip upside down sprites
4113
4206
  drawCanvas2D(pos, size, angle, mirror, (context)=>
4114
4207
  {
@@ -4148,13 +4241,13 @@ function drawRect(pos, size, color, angle, useWebGL, screenSpace, context)
4148
4241
  * @param {Vector2} pos
4149
4242
  * @param {Vector2} [size=vec2(1)]
4150
4243
  * @param {Color} [colorTop=WHITE]
4151
- * @param {Color} [colorBottom=BLACK]
4244
+ * @param {Color} [colorBottom=CLEAR_WHITE]
4152
4245
  * @param {number} [angle]
4153
4246
  * @param {boolean} [useWebGL=glEnable]
4154
4247
  * @param {boolean} [screenSpace]
4155
4248
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
4156
4249
  * @memberof Draw */
4157
- function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0, useWebGL=glEnable, screenSpace=false, context)
4250
+ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
4158
4251
  {
4159
4252
  ASSERT(isVector2(pos), 'pos must be a vec2');
4160
4253
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -4194,6 +4287,7 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
4194
4287
  {
4195
4288
  // normal canvas 2D rendering method (slower)
4196
4289
  ++drawCount;
4290
+ ++primitiveCount;
4197
4291
  size = new Vector2(size.x, -size.y); // fix upside down sprites
4198
4292
  drawCanvas2D(pos, size, angle, false, (context)=>
4199
4293
  {
@@ -4256,8 +4350,9 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
4256
4350
  return;
4257
4351
  }
4258
4352
 
4259
- // Canvas2D path — increment drawCount here (WebGL batch counts via glBatchCount)
4353
+ // Canvas2D path — increment counts here (WebGL counts via glFlush)
4260
4354
  ++drawCount;
4355
+ ++primitiveCount;
4261
4356
 
4262
4357
  if (!screenSpace)
4263
4358
  {
@@ -4310,7 +4405,7 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
4310
4405
  * @param {boolean} [screenSpace]
4311
4406
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
4312
4407
  * @memberof Draw */
4313
- function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace, context)
4408
+ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
4314
4409
  {
4315
4410
  ASSERT(isArray(points), 'points must be an array');
4316
4411
  ASSERT(isNumber(width), 'width must be a number');
@@ -4331,6 +4426,7 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
4331
4426
  {
4332
4427
  // normal canvas 2D rendering method (slower)
4333
4428
  ++drawCount;
4429
+ ++primitiveCount;
4334
4430
  drawCanvas2D(pos, vec2(1), angle, false, (context)=>
4335
4431
  {
4336
4432
  context.strokeStyle = color.toString();
@@ -4358,7 +4454,7 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
4358
4454
  * @param {boolean} [screenSpace]
4359
4455
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
4360
4456
  * @memberof Draw */
4361
- function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, screenSpace, context)
4457
+ function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
4362
4458
  {
4363
4459
  const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
4364
4460
  const size = vec2(width, halfDelta.length()*2);
@@ -4374,9 +4470,9 @@ function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, sc
4374
4470
  * @param {Vector2} [size=vec2(1)]
4375
4471
  * @param {number} [sides]
4376
4472
  * @param {Color} [color=WHITE]
4377
- * @param {number} [angle]
4378
4473
  * @param {number} [lineWidth]
4379
4474
  * @param {Color} [lineColor=BLACK]
4475
+ * @param {number} [angle]
4380
4476
  * @param {boolean} [useWebGL=glEnable]
4381
4477
  * @param {boolean} [screenSpace]
4382
4478
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
@@ -4469,7 +4565,7 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
4469
4565
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
4470
4566
 
4471
4567
  // clamp line width to prevent artifacts
4472
- lineWidth = clamp(lineWidth, 0, Math.min(size.x, size.y));
4568
+ lineWidth = clamp(lineWidth, 0, min(size.x, size.y));
4473
4569
 
4474
4570
  if (useWebGL && glEnable)
4475
4571
  {
@@ -4511,6 +4607,104 @@ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useW
4511
4607
  drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
4512
4608
  }
4513
4609
 
4610
+ /** Draw an ellipse filled with a radial gradient from the center to the rim
4611
+ * - Best when batched with other untextured polys
4612
+ * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
4613
+ * - Stacking gradients at the exact same position may show a faint vertical artifact
4614
+ * @param {Vector2} pos
4615
+ * @param {Vector2} [size=vec2(1)] - Width and height diameter
4616
+ * @param {Color} [colorInner=WHITE]
4617
+ * @param {Color} [colorOuter=CLEAR_WHITE]
4618
+ * @param {number} [angle]
4619
+ * @param {boolean} [useWebGL=glEnable]
4620
+ * @param {boolean} [screenSpace]
4621
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
4622
+ * @memberof Draw */
4623
+ let drawEllipseGradientOffset = 0;
4624
+ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
4625
+ {
4626
+ ASSERT(isVector2(pos), 'pos must be a vec2');
4627
+ ASSERT(isVector2(size), 'size must be a vec2');
4628
+ ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid');
4629
+ ASSERT(isNumber(angle), 'angle must be a number');
4630
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
4631
+
4632
+ if (headlessMode) return;
4633
+
4634
+ if (useWebGL && glEnable)
4635
+ {
4636
+ ASSERT(!!glContext, 'WebGL is not enabled!');
4637
+ if (screenSpace)
4638
+ {
4639
+ // convert to world space
4640
+ pos = screenToWorld(pos);
4641
+ size = size.scale(1/cameraScale);
4642
+ angle += cameraAngle;
4643
+ }
4644
+ // fan as tristrip; rotate the boundary vertex by one slice per call
4645
+ // so back-to-back gradients at the same position have their hole
4646
+ // (from gpu edge-rule on the boundary line-degen) at different rim
4647
+ // verts and don't visibly stack
4648
+ const sides = glCircleSides;
4649
+ const radiusX = size.x/2, radiusY = size.y/2;
4650
+ const innerInt = colorInner.rgbaInt();
4651
+ const outerInt = colorOuter.rgbaInt();
4652
+ const offset = drawEllipseGradientOffset++;
4653
+ const c = cos(-angle), s = sin(-angle);
4654
+ const rim = (a) =>
4655
+ {
4656
+ const lx = sin(a)*radiusX, ly = cos(a)*radiusY;
4657
+ return vec2(pos.x + lx*c - ly*s, pos.y + lx*s + ly*c);
4658
+ };
4659
+ const startA = (offset%sides)/sides*PI*2;
4660
+ const points = [rim(startA)];
4661
+ const colors = [outerInt];
4662
+ for (let i=sides; i--;)
4663
+ {
4664
+ const a = ((i+offset)%sides)/sides*PI*2;
4665
+ points.push(pos);
4666
+ colors.push(innerInt);
4667
+ points.push(rim(a));
4668
+ colors.push(outerInt);
4669
+ }
4670
+ glDrawColoredPoints(points, colors);
4671
+ }
4672
+ else
4673
+ {
4674
+ // normal canvas 2D rendering method (slower)
4675
+ ++drawCount;
4676
+ ++primitiveCount;
4677
+ drawCanvas2D(pos, size, angle, false, (context)=>
4678
+ {
4679
+ const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5);
4680
+ gradient.addColorStop(0, colorInner.toString());
4681
+ gradient.addColorStop(1, colorOuter.toString());
4682
+ context.fillStyle = gradient;
4683
+ context.beginPath();
4684
+ context.ellipse(0, 0, .5, .5, 0, 0, 9);
4685
+ context.fill();
4686
+ }, screenSpace, context);
4687
+ }
4688
+ }
4689
+
4690
+ /** Draw a circle filled with a radial gradient from the center to the rim
4691
+ * - Best when batched with other untextured polys
4692
+ * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
4693
+ * - Stacking gradients at the exact same position may show a faint vertical artifact
4694
+ * @param {Vector2} pos
4695
+ * @param {number} [size=1] - Diameter
4696
+ * @param {Color} [colorInner=WHITE]
4697
+ * @param {Color} [colorOuter=CLEAR_WHITE]
4698
+ * @param {boolean} [useWebGL=glEnable]
4699
+ * @param {boolean} [screenSpace]
4700
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
4701
+ * @memberof Draw */
4702
+ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
4703
+ {
4704
+ ASSERT(isNumber(size), 'size must be a number');
4705
+ drawEllipseGradient(pos, vec2(size), colorInner, colorOuter, 0, useWebGL, screenSpace, context);
4706
+ }
4707
+
4514
4708
  /**
4515
4709
  * @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context
4516
4710
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
@@ -4565,7 +4759,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
4565
4759
  * @param {number} [angle]
4566
4760
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
4567
4761
  * @memberof Draw */
4568
- function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, font, fontStyle, maxWidth, angle=0, context=drawContext)
4762
+ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
4569
4763
  {
4570
4764
  // convert to screen space
4571
4765
  pos = worldToScreen(pos);
@@ -4605,16 +4799,16 @@ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLA
4605
4799
  ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
4606
4800
  ASSERT(isNumber(angle), 'angle must be a number');
4607
4801
 
4802
+ const lines = (text+'').split('\n');
4803
+ const posY = pos.y - (lines.length-1) * size/2; // center vertically
4804
+ // save before style mutations so caller's context state is preserved
4805
+ context.save();
4608
4806
  context.fillStyle = color.toString();
4609
4807
  context.strokeStyle = lineColor.toString();
4610
4808
  context.lineWidth = lineWidth;
4611
4809
  context.textAlign = textAlign;
4612
4810
  context.font = fontStyle + ' ' + size + 'px '+ font;
4613
4811
  context.textBaseline = 'middle';
4614
-
4615
- const lines = (text+'').split('\n');
4616
- const posY = pos.y - (lines.length-1) * size/2; // center vertically
4617
- context.save();
4618
4812
  context.translate(pos.x, posY);
4619
4813
  context.rotate(-angle);
4620
4814
  let yOffset = 0;
@@ -4775,6 +4969,9 @@ function isOnScreen(pos, size=0)
4775
4969
  ASSERT(isVector2(pos), 'pos must be a vec2');
4776
4970
  ASSERT(isVector2(size) || isNumber(size), 'size must be a vec2 or number');
4777
4971
 
4972
+ // cameraScale of 0 collapses world coords; nothing is visible
4973
+ if (!cameraScale) return false;
4974
+
4778
4975
  // optimized circle on screen test
4779
4976
  // pos = worldToScreen(pos);
4780
4977
  let x = pos.x - cameraPos.x;
@@ -4816,7 +5013,10 @@ function combineCanvases()
4816
5013
  const w = mainCanvasSize.x, h = mainCanvasSize.y;
4817
5014
  workCanvas.width = w;
4818
5015
  workCanvas.height = h;
4819
- workContext.fillRect(0,0,w,h); // remove background alpha
5016
+ // remove background alpha — explicit fillStyle so a previous caller
5017
+ // leaving workContext.fillStyle transparent can't silently no-op this
5018
+ workContext.fillStyle = '#000';
5019
+ workContext.fillRect(0,0,w,h);
4820
5020
  glCopyToContext(workContext);
4821
5021
  workContext.drawImage(mainCanvas, 0, 0);
4822
5022
  mainContext.drawImage(workCanvas, 0, 0);
@@ -4959,33 +5159,33 @@ function setCursor(cursorStyle = 'auto')
4959
5159
  ///////////////////////////////////////////////////////////////////////////////
4960
5160
 
4961
5161
  /** Engine font image, 8x8 font provided by the engine
4962
- * @type {FontImage}
5162
+ * @type {ImageFont}
4963
5163
  * @memberof Draw */
4964
- let engineFontImage;
5164
+ let engineImageFont;
4965
5165
 
4966
5166
  /**
4967
- * Font Image Object - Draw text by using tiles in an image
5167
+ * Image Font Object - Draw text by using tiles in an image
4968
5168
  * - 96 characters (from space to tilde) are stored in an image
4969
5169
  * - A 8x8 default engine font is supplied for general use
4970
5170
  * - This system is WebGL enabled for fast text rendering
4971
5171
  * - Fonts can also be colored and scaled along each axis
4972
- *
5172
+ *
4973
5173
  * @memberof Draw
4974
5174
  * @example
4975
5175
  * // use built in font
4976
- * const font = engineFontImage;
5176
+ * const font = engineImageFont;
4977
5177
  *
4978
5178
  * // draw text
4979
5179
  * font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50));
4980
5180
  */
4981
- class FontImage
5181
+ class ImageFont
4982
5182
  {
4983
5183
  /** Create an image font
4984
5184
  * @param {TileInfo} tileInfo - Tile info of first character in font
4985
5185
  */
4986
5186
  constructor(tileInfo)
4987
5187
  {
4988
- ASSERT(!!tileInfo, 'tileInfo is required for FontImage');
5188
+ ASSERT(!!tileInfo, 'tileInfo is required for ImageFont');
4989
5189
 
4990
5190
  /** @property {TileInfo} - Tile info for the font */
4991
5191
  this.tileInfo = tileInfo.frame(0);
@@ -5070,7 +5270,7 @@ class FontImage
5070
5270
  }
5071
5271
 
5072
5272
  // load engine font, called automatically on startup
5073
- async function fontImageInit()
5273
+ async function imageFontInit()
5074
5274
  {
5075
5275
  const image = new Image;
5076
5276
  await new Promise(resolve =>
@@ -5083,7 +5283,7 @@ async function fontImageInit()
5083
5283
  const tilePos=vec2(), tileSize=vec2(8), padding=1, bleed=0;
5084
5284
  const textureInfo = new TextureInfo(image);
5085
5285
  const tileInfo = new TileInfo(tilePos, tileSize, textureInfo, padding, bleed);
5086
- engineFontImage = new FontImage(tileInfo);
5286
+ engineImageFont = new ImageFont(tileInfo);
5087
5287
  }
5088
5288
  /**
5089
5289
  * LittleJS Input System
@@ -5532,9 +5732,11 @@ function inputInit()
5532
5732
  mouseDeltaScreen = mouseDeltaScreen.add(movement);
5533
5733
  }
5534
5734
  function onMouseLeave() { mouseInWindow = false; } // mouse moved off window
5535
- function onMouseWheel(e)
5536
- {
5537
- mouseWheel = e.ctrlKey ? 0 : sign(e.deltaY);
5735
+ function onMouseWheel(e)
5736
+ {
5737
+ // accumulate so multiple wheel events in one frame are not lost
5738
+ if (!e.ctrlKey)
5739
+ mouseWheel += sign(e.deltaY);
5538
5740
  if (inputPreventDefault && e.cancelable && document.hasFocus())
5539
5741
  e.preventDefault(); // prevent page scrolling
5540
5742
  }
@@ -5659,9 +5861,14 @@ function inputInit()
5659
5861
  if (button < touchGamepadButtonCount)
5660
5862
  touchGamepadButtons[button] = 1;
5661
5863
  }
5662
- else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize)
5864
+ else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize &&
5865
+ stickCenter.distance(touchPos) >= 2 * touchGamepadSize &&
5866
+ buttonCenter.distance(touchPos) >= 2 * touchGamepadSize)
5663
5867
  {
5664
5868
  // virtual start button in center
5869
+ // require a fat-finger buffer of touchGamepadSize beyond the
5870
+ // edge of the stick/buttons so drift off those controls can't
5871
+ // accidentally fire start
5665
5872
  touchGamepadButtons[9] = 1;
5666
5873
  }
5667
5874
  }
@@ -5703,7 +5910,7 @@ function inputUpdate()
5703
5910
  v > min ? percent(v, min, max) :
5704
5911
  v < -min ? -percent(-v, min, max) : 0;
5705
5912
  return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
5706
- }
5913
+ };
5707
5914
 
5708
5915
  // update touch gamepad if enabled
5709
5916
  if (touchGamepadEnable && isTouchDevice)
@@ -5717,7 +5924,12 @@ function inputUpdate()
5717
5924
  debugCircle(stickCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
5718
5925
  debugCircle(buttonCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
5719
5926
  if (touchGamepadCenterButtonSize)
5927
+ {
5720
5928
  debugCircle(startCenter, 2*touchGamepadCenterButtonSize, 'cyan', 0, false, true);
5929
+ // exclusion bubbles around controls (where start is blocked)
5930
+ debugCircle(stickCenter, 4*touchGamepadSize, 'magenta', 0, false, true);
5931
+ debugCircle(buttonCenter, 4*touchGamepadSize, 'magenta', 0, false, true);
5932
+ }
5721
5933
  }
5722
5934
 
5723
5935
  if (!touchGamepadTimer.isSet()) return;
@@ -5824,13 +6036,6 @@ function inputUpdate()
5824
6036
  (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
5825
6037
  (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
5826
6038
  }
5827
- else if (gamepad.axes && gamepad.axes.length >= 2)
5828
- {
5829
- // digital style dpad from axes
5830
- const x = clamp(round(gamepad.axes[0]), -1, 1);
5831
- const y = clamp(round(gamepad.axes[1]), -1, 1);
5832
- dpad.set(x, -y);
5833
- }
5834
6039
 
5835
6040
  // copy dpad to left analog stick when pressed
5836
6041
  if (gamepadDirectionEmulateStick && (dpad.x || dpad.y))
@@ -6036,7 +6241,7 @@ class Sound
6036
6241
  /** @property {SoundLoadCallback} - function to call when sound is loaded */
6037
6242
  this.onloadCallback = onloadCallback;
6038
6243
 
6039
- if (Array.isArray(asset))
6244
+ if (isArray(asset))
6040
6245
  {
6041
6246
  // generate zzfx sound — copy so we don't mutate the caller's array
6042
6247
  const zzfxSound = asset.slice();
@@ -6128,7 +6333,7 @@ class Sound
6128
6333
  }
6129
6334
 
6130
6335
  /** Get how long this sound is in seconds
6131
- * @return {number} - How long the sound is in seconds (undefined if loading)
6336
+ * @return {number} - How long the sound is in seconds (0 if loading)
6132
6337
  */
6133
6338
  getDuration()
6134
6339
  { return this.sampleChannels?.[0]?.length / this.sampleRate || 0; }
@@ -6285,10 +6490,14 @@ class SoundInstance
6285
6490
  {
6286
6491
  if (fadeTime)
6287
6492
  {
6288
- // ramp off gain
6493
+ // ramp off gain from current volume (not 1, or low-volume
6494
+ // instances would jump back up before fading);
6495
+ // cancel any prior scheduling so stacked stop calls don't
6496
+ // re-anchor partway through a previous fade
6289
6497
  const startFade = audioContext.currentTime;
6290
6498
  const endFade = startFade + fadeTime;
6291
- this.gainNode.gain.linearRampToValueAtTime(1, startFade);
6499
+ this.gainNode.gain.cancelScheduledValues(startFade);
6500
+ this.gainNode.gain.setValueAtTime(this.volume, startFade);
6292
6501
  this.gainNode.gain.linearRampToValueAtTime(0, endFade);
6293
6502
  this.source.stop(endFade);
6294
6503
  }
@@ -6336,13 +6545,14 @@ class SoundInstance
6336
6545
  */
6337
6546
  getCurrentTime()
6338
6547
  {
6339
- const deltaTime = mod(audioContext.currentTime - this.startTime,
6340
- this.getDuration());
6341
- return this.isPlaying() ? deltaTime : this.pausedTime;
6548
+ if (!this.isPlaying()) return this.pausedTime;
6549
+ const duration = this.getDuration();
6550
+ // guard mod against 0 duration (rate=0 or sound not loaded)
6551
+ return duration ? mod(audioContext.currentTime - this.startTime, duration) : 0;
6342
6552
  }
6343
6553
 
6344
6554
  /** Get the total duration of this sound
6345
- * @return {number} - Total duration in seconds
6555
+ * @return {number} - Total duration in seconds (0 if loading)
6346
6556
  */
6347
6557
  getDuration() { return this.rate ? this.sound.getDuration() / this.rate : 0; }
6348
6558
 
@@ -6356,16 +6566,17 @@ class SoundInstance
6356
6566
 
6357
6567
  /** Speak text with passed in settings
6358
6568
  * @param {string} text - The text to speak
6359
- * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
6360
6569
  * @param {number} [volume] - How much to scale volume by
6361
6570
  * @param {number} [rate] - How quickly to speak
6362
6571
  * @param {number} [pitch] - How much to change the pitch by
6572
+ * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
6363
6573
  * @return {SpeechSynthesisUtterance} - The utterance that was spoken
6364
6574
  * @memberof Audio */
6365
- function speak(text, language='', volume=1, rate=1, pitch=1)
6575
+ function speak(text, volume=1, rate=1, pitch=1, language='')
6366
6576
  {
6577
+ ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch');
6367
6578
  if (!soundEnable || headlessMode) return;
6368
- if (!speechSynthesis) return;
6579
+ if (typeof speechSynthesis === 'undefined') return;
6369
6580
 
6370
6581
  // common languages (not supported by all browsers)
6371
6582
  // en - english, it - italian, fr - french, de - german, es - spanish
@@ -6383,7 +6594,11 @@ function speak(text, language='', volume=1, rate=1, pitch=1)
6383
6594
 
6384
6595
  /** Stop all queued speech
6385
6596
  * @memberof Audio */
6386
- function speakStop() {speechSynthesis?.cancel();}
6597
+ function speakStop()
6598
+ {
6599
+ if (typeof speechSynthesis !== 'undefined')
6600
+ speechSynthesis.cancel();
6601
+ }
6387
6602
 
6388
6603
  /** Get frequency of a note on a musical scale
6389
6604
  * @param {number} semitoneOffset - How many semitones away from the root note
@@ -6445,9 +6660,14 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
6445
6660
  const pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)});
6446
6661
  source.connect(pannerNode).connect(gainNode);
6447
6662
 
6448
- // callback when the sound ends
6449
- if (onended)
6450
- source.addEventListener('ended', ()=> onended(source));
6663
+ // disconnect nodes when the sound ends so the audio graph doesn't grow
6664
+ // unbounded across many play() calls (source.stop() also fires 'ended')
6665
+ source.addEventListener('ended', ()=>
6666
+ {
6667
+ gainNode.disconnect();
6668
+ pannerNode.disconnect();
6669
+ if (onended) onended(source);
6670
+ });
6451
6671
 
6452
6672
  // play and return sound
6453
6673
  const startOffset = offset * rate;
@@ -6684,14 +6904,29 @@ function tileCollisionTest(pos, size=vec2(), callbackObject, solidOnly=true)
6684
6904
  * @memberof TileLayers */
6685
6905
  function tileCollisionRaycast(posStart, posEnd, callbackObject, normal, solidOnly=true)
6686
6906
  {
6907
+ // check every layer and keep the closest hit so a far hit in an
6908
+ // earlier-registered layer doesn't shadow a closer hit in a later one
6909
+ let closestHit, closestDistSq, closestNormal;
6910
+ const scratchNormal = normal && vec2();
6687
6911
  for (const layer of tileCollisionLayers)
6688
6912
  {
6689
6913
  if (!solidOnly || layer.isSolid)
6690
6914
  {
6691
- const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, normal)
6692
- if (hitPos) return hitPos;
6915
+ const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, scratchNormal);
6916
+ if (hitPos)
6917
+ {
6918
+ const d = posStart.distanceSquared(hitPos);
6919
+ if (closestHit === undefined || d < closestDistSq)
6920
+ {
6921
+ closestHit = hitPos;
6922
+ closestDistSq = d;
6923
+ if (normal) closestNormal = scratchNormal.copy();
6924
+ }
6925
+ }
6693
6926
  }
6694
6927
  }
6928
+ if (closestHit && normal) normal.setFrom(closestNormal);
6929
+ return closestHit;
6695
6930
  }
6696
6931
 
6697
6932
  ///////////////////////////////////////////////////////////////////////////////
@@ -6865,38 +7100,6 @@ class CanvasLayer extends EngineObject
6865
7100
  drawTile(pos, size, tileInfo, color, angle, mirror, additiveColor, useWebGL, screenSpace, context);
6866
7101
  }
6867
7102
 
6868
- /** Draw a tile onto the layer canvas in world space
6869
- * @param {Vector2} pos
6870
- * @param {Vector2} [size=vec2(1)]
6871
- * @param {TileInfo} [tileInfo]
6872
- * @param {Color} [color=WHITE]
6873
- * @param {number} [angle]
6874
- * @param {boolean} [mirror] */
6875
- drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
6876
- {
6877
- pos = pos.subtract(this.pos).multiply(this.tileInfo.size);
6878
- size = size.multiply(this.tileInfo.size);
6879
- pos.y = this.canvas.height - pos.y;
6880
-
6881
- // draw the tile onto the layer canvas
6882
- const oldMainCanvasSize = mainCanvasSize;
6883
- mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
6884
- const useWebGL = this.hasWebGL();
6885
- useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
6886
- const drawContext = useWebGL ? undefined : this.context;
6887
- drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
6888
- useWebGL && glSetRenderTarget();
6889
- mainCanvasSize = oldMainCanvasSize;
6890
- }
6891
-
6892
- /** Draw a rectangle onto the layer canvas in world space
6893
- * @param {Vector2} pos
6894
- * @param {Vector2} [size=vec2(1)]
6895
- * @param {Color} [color=WHITE]
6896
- * @param {number} [angle] */
6897
- drawRect(pos, size, color, angle)
6898
- { this.drawTile(pos, size, undefined, color, angle); }
6899
-
6900
7103
  /** Create WebGL texture if necessary and copy layer canvas to it */
6901
7104
  updateWebGL()
6902
7105
  { this.textureInfo.createWebGLTexture(); }
@@ -6952,6 +7155,8 @@ class TileLayer extends CanvasLayer
6952
7155
  this.redrawTileData = ()=> {};
6953
7156
  this.drawLayerTile = ()=> {};
6954
7157
  this.drawLayerRect = ()=> {};
7158
+ this.drawTile = ()=> {};
7159
+ this.drawRect = ()=> {};
6955
7160
  this.clearLayerRect = ()=> {};
6956
7161
  return;
6957
7162
  }
@@ -6979,7 +7184,7 @@ class TileLayer extends CanvasLayer
6979
7184
  ASSERT(data instanceof TileLayerData, 'data must be a TileLayerData');
6980
7185
 
6981
7186
  if (!layerPos.arrayCheck(this.size)) return;
6982
- this.data[(layerPos.y|0)*this.size.x+layerPos.x|0] = data;
7187
+ this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] = data;
6983
7188
 
6984
7189
  if (!redraw) return;
6985
7190
  const isRedraw = drawContext === this.context;
@@ -6994,11 +7199,11 @@ class TileLayer extends CanvasLayer
6994
7199
 
6995
7200
  /** Get data at a given position in the array
6996
7201
  * @param {Vector2} layerPos - Local position in array
6997
- * @return {TileLayerData} */
7202
+ * @return {TileLayerData|undefined} */
6998
7203
  getData(layerPos)
6999
- {
7204
+ {
7000
7205
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
7001
- return layerPos.arrayCheck(this.size) && this.data[(layerPos.y|0)*this.size.x+layerPos.x|0];
7206
+ return layerPos.arrayCheck(this.size) ? this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] : undefined;
7002
7207
  }
7003
7208
 
7004
7209
  // Update the tile layer, refresh texture if needed
@@ -7106,7 +7311,7 @@ class TileLayer extends CanvasLayer
7106
7311
 
7107
7312
  // draw the tile if it has layer data
7108
7313
  const d = this.getData(layerPos);
7109
- if (!d.tile) return;
7314
+ if (!d || !d.tile) return;
7110
7315
 
7111
7316
  const tileInfo = this.tileInfo && this.tileInfo.tile(d.tile);
7112
7317
  this.drawLayerTile(drawPos, drawSize, tileInfo, d.color, d.direction*PI/2, d.mirror);
@@ -7152,6 +7357,38 @@ class TileLayer extends CanvasLayer
7152
7357
  drawLayerRect(pos, size, color, angle=0)
7153
7358
  { this.drawLayerTile(pos, size, undefined, color, angle); }
7154
7359
 
7360
+ /** Draw a tile onto the layer canvas in world space
7361
+ * @param {Vector2} pos
7362
+ * @param {Vector2} [size=vec2(1)]
7363
+ * @param {TileInfo} [tileInfo]
7364
+ * @param {Color} [color=WHITE]
7365
+ * @param {number} [angle]
7366
+ * @param {boolean} [mirror] */
7367
+ drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
7368
+ {
7369
+ pos = pos.subtract(this.pos).multiply(this.tileInfo.size);
7370
+ size = size.multiply(this.tileInfo.size);
7371
+ pos.y = this.canvas.height - pos.y;
7372
+
7373
+ // draw the tile onto the layer canvas
7374
+ const oldMainCanvasSize = mainCanvasSize;
7375
+ mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
7376
+ const useWebGL = this.hasWebGL();
7377
+ useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
7378
+ const drawContext = useWebGL ? undefined : this.context;
7379
+ drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
7380
+ useWebGL && glSetRenderTarget();
7381
+ mainCanvasSize = oldMainCanvasSize;
7382
+ }
7383
+
7384
+ /** Draw a rectangle onto the layer canvas in world space
7385
+ * @param {Vector2} pos
7386
+ * @param {Vector2} [size=vec2(1)]
7387
+ * @param {Color} [color=WHITE]
7388
+ * @param {number} [angle] */
7389
+ drawRect(pos, size, color, angle)
7390
+ { this.drawTile(pos, size, undefined, color, angle); }
7391
+
7155
7392
  /** Clear a rectangle in layer space
7156
7393
  * @param {Vector2} pos - position in pixel coordinates
7157
7394
  * @param {Vector2} size
@@ -7230,7 +7467,7 @@ class TileCollisionLayer extends TileLayer
7230
7467
  setCollisionData(layerPos, data=1)
7231
7468
  {
7232
7469
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
7233
- const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
7470
+ const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
7234
7471
  layerPos.arrayCheck(this.size) && (this.collisionData[i] = data);
7235
7472
  }
7236
7473
 
@@ -7245,7 +7482,7 @@ class TileCollisionLayer extends TileLayer
7245
7482
  getCollisionData(layerPos)
7246
7483
  {
7247
7484
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
7248
- const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
7485
+ const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
7249
7486
  return layerPos.arrayCheck(this.size) ? this.collisionData[i] : 0;
7250
7487
  }
7251
7488
 
@@ -7270,8 +7507,10 @@ class TileCollisionLayer extends TileLayer
7270
7507
  const posY = pos.y - this.pos.y;
7271
7508
  const minX = max(posX - size.x/2|0, 0);
7272
7509
  const minY = max(posY - size.y/2|0, 0);
7273
- const maxX = min(posX + size.x/2, this.size.x);
7274
- const maxY = min(posY + size.y/2, this.size.y);
7510
+ // ensure at least one cell is visited even when size is 0 and pos
7511
+ // lands exactly on an integer boundary (documented point-test mode)
7512
+ const maxX = min(max(posX + size.x/2, minX + 1), this.size.x);
7513
+ const maxY = min(max(posY + size.y/2, minY + 1), this.size.y);
7275
7514
  const hitPos = new Vector2;
7276
7515
  for (let y = minY; y < maxY; ++y)
7277
7516
  for (let x = minX; x < maxX; ++x)
@@ -7390,7 +7629,7 @@ class ParticleEmitter extends EngineObject
7390
7629
  * @param {number} [angleDamping] - How much to dampen particle angular speed
7391
7630
  * @param {number} [gravityScale] - How much gravity effect particles
7392
7631
  * @param {number} [particleConeAngle] - Cone for start particle angle
7393
- * @param {number} [fadeRate] - How quick to fade particles at start/end in percent of life
7632
+ * @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
7394
7633
  * @param {number} [randomness] - Apply extra randomness percent
7395
7634
  * @param {boolean} [collideTiles] - Do particles collide against tiles
7396
7635
  * @param {boolean} [additive] - Should particles use additive blend
@@ -7474,7 +7713,7 @@ class ParticleEmitter extends EngineObject
7474
7713
  this.gravityScale = gravityScale;
7475
7714
  /** @property {number} - Cone for start particle angle */
7476
7715
  this.particleConeAngle = particleConeAngle;
7477
- /** @property {number} - How quick to fade in particles at start/end in percent of life */
7716
+ /** @property {number} - Fraction of life spent fading, split half at start and half at end (e.g. .2 = 10% fade-in + 10% fade-out) */
7478
7717
  this.fadeRate = fadeRate;
7479
7718
  /** @property {number} - Apply extra randomness percent */
7480
7719
  this.randomness = randomness;
@@ -7752,33 +7991,32 @@ class Particle
7752
7991
  const hitLayer = tileCollisionTest(this.pos);
7753
7992
  if (!testCollision(oldPos))
7754
7993
  {
7755
- if (!collideCallback || collideCallback?.(this, hitLayer))
7994
+ // testCollision already invoked collideCallback with the
7995
+ // correct (this, data, pos) args; no need to re-check here.
7996
+ // test which side we bounced off (or both if a corner)
7997
+ const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
7998
+ const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
7999
+ const hitRestitution = max(restitution, hitLayer.restitution);
8000
+ const hitFriction = max(friction, hitLayer.friction);
8001
+ if (isBlockedX)
7756
8002
  {
7757
- // test which side we bounced off (or both if a corner)
7758
- const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
7759
- const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
7760
- const hitRestitution = max(restitution, hitLayer.restitution);
7761
- const hitFriction = max(friction, hitLayer.friction);
7762
- if (isBlockedX)
7763
- {
7764
- // move to previous X position and bounce
7765
- this.pos.x = oldPos.x;
7766
- this.velocity.x *= -hitRestitution;
7767
- this.velocity.y *= hitFriction;
7768
- }
7769
- if (isBlockedY || !isBlockedX)
7770
- {
7771
- const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
7772
- if (wasFalling)
7773
- this.groundObject = hitLayer;
7774
-
7775
- // move to previous Y position and bounce
7776
- this.pos.y = oldPos.y;
7777
- this.velocity.y *= -hitRestitution;
7778
- this.velocity.x *= hitFriction;
7779
- }
7780
- debugPhysics && debugRect(this.pos, this.size, '#f00');
8003
+ // move to previous X position and bounce
8004
+ this.pos.x = oldPos.x;
8005
+ this.velocity.x *= -hitRestitution;
8006
+ this.velocity.y *= hitFriction;
8007
+ }
8008
+ if (isBlockedY || !isBlockedX)
8009
+ {
8010
+ const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
8011
+ if (wasFalling)
8012
+ this.groundObject = hitLayer;
8013
+
8014
+ // move to previous Y position and bounce
8015
+ this.pos.y = oldPos.y;
8016
+ this.velocity.y *= -hitRestitution;
8017
+ this.velocity.x *= hitFriction;
7781
8018
  }
8019
+ debugPhysics && debugRect(this.pos, this.size, '#f00');
7782
8020
  }
7783
8021
  }
7784
8022
  }
@@ -7939,6 +8177,10 @@ function glInit(rootElement)
7939
8177
  for (const info of glTextureInfos)
7940
8178
  info.glTexture = undefined;
7941
8179
  glActiveTexture = undefined;
8180
+ // drop any partially-filled batch so the next glFlush doesn't
8181
+ // upload stale glBatchCount against fresh empty buffers on restore
8182
+ glBatchCount = 0;
8183
+ glPolyMode = false;
7942
8184
  pluginList.forEach(plugin=>plugin.glContextLost?.());
7943
8185
  });
7944
8186
  glCanvas.addEventListener('webglcontextrestored', ()=>
@@ -8297,6 +8539,10 @@ function glSetTextureData(texture, image)
8297
8539
  glContext.bindTexture(glContext.TEXTURE_2D, texture);
8298
8540
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, image);
8299
8541
 
8542
+ // keep mipmaps in sync with new level 0 data (same condition as glCreateTexture)
8543
+ if (!tilesPixelated && isPowerOfTwo(image.width) && isPowerOfTwo(image.height))
8544
+ glContext.generateMipmap(glContext.TEXTURE_2D);
8545
+
8300
8546
  // rebind active texture
8301
8547
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
8302
8548
  }
@@ -8342,7 +8588,7 @@ function glFlush()
8342
8588
  {
8343
8589
  if (glEnable && glContext && glBatchCount)
8344
8590
  {
8345
- // set bend mode
8591
+ // set blend mode
8346
8592
  const destBlend = glBatchAdditive ? glContext.ONE : glContext.ONE_MINUS_SRC_ALPHA;
8347
8593
  glContext.blendFuncSeparate(glContext.SRC_ALPHA, destBlend, glContext.ONE, destBlend);
8348
8594
  glContext.enable(glContext.BLEND);
@@ -8356,7 +8602,8 @@ function glFlush()
8356
8602
  glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, glBatchCount);
8357
8603
  else
8358
8604
  glContext.drawArraysInstanced(glContext.TRIANGLE_STRIP, 0, 4, glBatchCount);
8359
- drawCount += glBatchCount;
8605
+ ++drawCount;
8606
+ primitiveCount += glBatchCount;
8360
8607
  glBatchCount = 0;
8361
8608
  }
8362
8609
  glBatchAdditive = glAdditive;
@@ -8417,6 +8664,22 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
8417
8664
  glPositionData[offset++] = angle;
8418
8665
  }
8419
8666
 
8667
+ /** Add an untextured rect to the gl draw list
8668
+ * Zeroes the uvs and rgba so the texture contribution multiplies to 0,
8669
+ * then carries the real color in the additive slot. Works regardless of
8670
+ * which texture is currently bound.
8671
+ * @param {number} x
8672
+ * @param {number} y
8673
+ * @param {number} sizeX
8674
+ * @param {number} sizeY
8675
+ * @param {number} angle
8676
+ * @param {number} rgba - color as 32-bit integer
8677
+ * @memberof WebGL */
8678
+ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
8679
+ {
8680
+ glDraw(x, y, sizeX, sizeY, angle, 0, 0, 0, 0, 0, rgba);
8681
+ }
8682
+
8420
8683
  /** Transform and add a polygon to the gl draw list
8421
8684
  * @param {Array<Vector2>} points - Array of Vector2 points
8422
8685
  * @param {number} rgba - Color of the polygon as a 32-bit integer
@@ -8430,13 +8693,13 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
8430
8693
  function glDrawPointsTransform(points, rgba, x, y, sx, sy, angle, tristrip=true)
8431
8694
  {
8432
8695
  const pointsOut = [];
8696
+ const sa = sin(-angle);
8697
+ const ca = cos(-angle);
8433
8698
  for (const p of points)
8434
8699
  {
8435
8700
  // transform the point
8436
8701
  const px = p.x*sx;
8437
8702
  const py = p.y*sy;
8438
- const sa = sin(-angle);
8439
- const ca = cos(-angle);
8440
8703
  pointsOut.push(vec2(x + ca*px - sa*py, y + sa*px + ca*py));
8441
8704
  }
8442
8705
  const drawPoints = tristrip ? glPolyStrip(pointsOut) : pointsOut;
@@ -8468,11 +8731,13 @@ function glDrawPoints(points, rgba)
8468
8731
  {
8469
8732
  if (!glEnable || points.length < 3)
8470
8733
  return; // needs at least 3 points to have area
8471
-
8734
+
8472
8735
  // flush if there is not enough room or if different blend mode
8473
8736
  const vertCount = points.length + 2;
8474
8737
  if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
8475
8738
  glFlush();
8739
+ ASSERT(vertCount < gl_MAX_POLY_VERTEXES, 'poly exceeds max batch size');
8740
+ if (vertCount >= gl_MAX_POLY_VERTEXES) return; // release-build safety net
8476
8741
  glSetPolyMode();
8477
8742
 
8478
8743
  // setup triangle strip with degenerate verts at start and end
@@ -8496,11 +8761,13 @@ function glDrawColoredPoints(points, pointColors)
8496
8761
  {
8497
8762
  if (!glEnable || points.length < 3)
8498
8763
  return; // needs at least 3 points to have area
8499
-
8764
+
8500
8765
  // flush if there is not enough room or if different blend mode
8501
8766
  const vertCount = points.length + 2;
8502
8767
  if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
8503
8768
  glFlush();
8769
+ ASSERT(vertCount < gl_MAX_POLY_VERTEXES, 'poly exceeds max batch size');
8770
+ if (vertCount >= gl_MAX_POLY_VERTEXES) return; // release-build safety net
8504
8771
  glSetPolyMode();
8505
8772
 
8506
8773
  // setup triangle strip with degenerate verts at start and end
@@ -8570,7 +8837,8 @@ function glMakeOutline(points, width, wrap=true)
8570
8837
  const strip = [];
8571
8838
  const n = points.length;
8572
8839
  const e = 1e-6;
8573
- const miterLimit = width*100;
8840
+ // miter ratio cap (dimensionless, matches SVG/Canvas2D convention)
8841
+ const miterLimit = 10;
8574
8842
  for (let i = 0; i < n; i++)
8575
8843
  {
8576
8844
  // for each vertex, calculate normal based on adjacent edges
@@ -8826,7 +9094,7 @@ function drawEngineLogo(t)
8826
9094
  x.closePath();
8827
9095
  gradient(0, Y, 0, Y+H,C);
8828
9096
  }
8829
- const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
9097
+ const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%)`:'#000';
8830
9098
 
8831
9099
  // center and fit to screen
8832
9100
  const alpha = oscillate(1,1,t);
@@ -9053,7 +9321,8 @@ class Medal
9053
9321
  /** @property {boolean} - Is the medal unlocked? */
9054
9322
  this.unlocked = false;
9055
9323
 
9056
- // load the source image if provided
9324
+ /** @property {HTMLImageElement|undefined} - Source image for the medal icon, if any */
9325
+ this.image = undefined;
9057
9326
  if (src)
9058
9327
  (this.image = new Image).src = src;
9059
9328
 
@@ -9216,13 +9485,18 @@ class NewgroundsPlugin
9216
9485
  ASSERT(!cipher || cryptoJS, 'must provide cryptojs if there is a cipher');
9217
9486
 
9218
9487
  newgrounds = this; // set global newgrounds object
9488
+ /** @property {string} - The newgrounds App ID */
9219
9489
  this.app_id = app_id;
9490
+ /** @property {string|undefined} - AES-128/Base64 encryption key, if any */
9220
9491
  this.cipher = cipher;
9492
+ /** @property {Object|undefined} - CryptoJS instance used when cipher is set */
9221
9493
  this.cryptoJS = cryptoJS;
9494
+ /** @property {string} - Hostname used when logging views */
9222
9495
  this.host = location ? location.hostname : '';
9223
9496
 
9224
9497
  // get session id from url search params
9225
9498
  const url = new URL(location.href);
9499
+ /** @property {string|null} - Newgrounds session id from the URL (null when not logged in) */
9226
9500
  this.session_id = url.searchParams.get('ngio_session_id');
9227
9501
 
9228
9502
  if (!this.session_id)
@@ -9230,7 +9504,20 @@ class NewgroundsPlugin
9230
9504
 
9231
9505
  // get medals
9232
9506
  const medalsResult = this.call('Medal.getList');
9233
- this.medals = medalsResult ? medalsResult.result.data['medals'] : [];
9507
+
9508
+ // bail early if the first call failed (offline / bad session /
9509
+ // server error) so we don't block the main thread on more sync
9510
+ // XHRs that are guaranteed to also fail
9511
+ if (!medalsResult || !medalsResult.result || medalsResult.result.error)
9512
+ {
9513
+ debugMedals && LOG('Newgrounds session unavailable; skipping plugin init');
9514
+ this.medals = [];
9515
+ this.scoreboards = [];
9516
+ return;
9517
+ }
9518
+
9519
+ /** @property {Array} - Medals fetched from Newgrounds (empty until session is active) */
9520
+ this.medals = medalsResult.result.data?.['medals'] || [];
9234
9521
  debugMedals && LOG(this.medals);
9235
9522
  for (const newgroundsMedal of this.medals)
9236
9523
  {
@@ -9250,10 +9537,11 @@ class NewgroundsPlugin
9250
9537
  medal.description = medal.description + ` (${ medal.value })`;
9251
9538
  }
9252
9539
  }
9253
-
9540
+
9254
9541
  // get scoreboards
9255
9542
  const scoreboardResult = this.call('ScoreBoard.getBoards');
9256
- this.scoreboards = scoreboardResult ? scoreboardResult.result.data.scoreboards : [];
9543
+ /** @property {Array} - Scoreboards fetched from Newgrounds */
9544
+ this.scoreboards = scoreboardResult?.result?.data?.scoreboards || [];
9257
9545
  debugMedals && LOG(this.scoreboards);
9258
9546
 
9259
9547
  // keep the session alive with a ping every minute
@@ -9437,10 +9725,14 @@ class PostProcessPlugin
9437
9725
  function postProcessRender()
9438
9726
  {
9439
9727
  if (headlessMode || !glEnable) return;
9440
-
9728
+
9441
9729
  // clear out the buffer
9442
9730
  glFlush();
9443
9731
 
9732
+ // ensure we render to the default framebuffer (in case any earlier
9733
+ // caller this frame left a render target bound)
9734
+ glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
9735
+
9444
9736
  // setup shader program to draw a quad
9445
9737
  glContext.useProgram(postProcess.shader);
9446
9738
  glContext.bindVertexArray(postProcess.vao);
@@ -9481,6 +9773,9 @@ class PostProcessPlugin
9481
9773
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, glCanvas);
9482
9774
  }
9483
9775
 
9776
+ // restore default so subsequent dynamic texture uploads aren't flipped
9777
+ glContext.pixelStorei(glContext.UNPACK_FLIP_Y_WEBGL, false);
9778
+
9484
9779
  // force it to set instanced mode
9485
9780
  glSetInstancedMode(true);
9486
9781
  }
@@ -10107,11 +10402,17 @@ class UISystemPlugin
10107
10402
  * @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */
10108
10403
  setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver)
10109
10404
  {
10110
- function setCallback(callback, listenerType)
10405
+ // remove any prior listeners so repeated setup calls don't stack
10406
+ if (this._dragListeners)
10407
+ for (const [type, listener] of this._dragListeners)
10408
+ document.removeEventListener(type, listener);
10409
+ this._dragListeners = [];
10410
+ const setCallback = (callback, listenerType)=>
10111
10411
  {
10112
- function listener(e) { e.preventDefault(); callback && callback(e); }
10412
+ const listener = (e)=> { e.preventDefault(); callback && callback(e); };
10113
10413
  document.addEventListener(listenerType, listener);
10114
- }
10414
+ this._dragListeners.push([listenerType, listener]);
10415
+ };
10115
10416
  setCallback(onDrop, 'drop');
10116
10417
  setCallback(onDragEnter, 'dragenter');
10117
10418
  setCallback(onDragLeave, 'dragleave');
@@ -10435,6 +10736,15 @@ class UIObject
10435
10736
  if (this.destroyed)
10436
10737
  return;
10437
10738
 
10739
+ // clear ui-system references that point at this object so events
10740
+ // don't keep firing against a destroyed target (especially the
10741
+ // keydown listener attached for keyInputObject)
10742
+ if (uiSystem.activeObject === this) uiSystem.activeObject = undefined;
10743
+ if (uiSystem.hoverObject === this) uiSystem.hoverObject = undefined;
10744
+ if (uiSystem.lastHoverObject === this) uiSystem.lastHoverObject = undefined;
10745
+ if (uiSystem.navigationObject === this) uiSystem.navigationObject = undefined;
10746
+ if (uiSystem.keyInputObject === this) uiSystem.keyInputObject = undefined;
10747
+
10438
10748
  // disconnect from parent and destroy children
10439
10749
  this.destroyed = 1;
10440
10750
  this.parent?.removeChild(this);
@@ -10443,6 +10753,8 @@ class UIObject
10443
10753
  child.parent = undefined;
10444
10754
  child.destroy();
10445
10755
  }
10756
+ // clear references so destroyed children can be GC'd
10757
+ this.children.length = 0;
10446
10758
  }
10447
10759
 
10448
10760
  /** Check if the mouse is overlapping this ui object
@@ -11032,6 +11344,7 @@ class UISlider extends UIObject
11032
11344
  {
11033
11345
  // toggle value between 0 and 1
11034
11346
  this.value = this.value ? 0 : 1;
11347
+ this.onChange();
11035
11348
  this.onRelease();
11036
11349
  super.navigatePressed();
11037
11350
  }
@@ -11391,6 +11704,11 @@ class Box2dObject extends EngineObject
11391
11704
  // destroy physics body, fixtures, and joints
11392
11705
  ASSERT(this.body, 'Box2dObject has no body to destroy');
11393
11706
  box2d.world.DestroyBody(this.body);
11707
+
11708
+ // remove from tracked list so paused / headless sessions don't leak
11709
+ const i = box2d.objects.indexOf(this);
11710
+ if (i >= 0)
11711
+ box2d.objects.splice(i, 1);
11394
11712
  super.destroy();
11395
11713
  }
11396
11714
 
@@ -11479,7 +11797,9 @@ class Box2dObject extends EngineObject
11479
11797
  /** Add a box shape to the body
11480
11798
  * @param {Vector2} [size]
11481
11799
  * @param {Vector2} [offset]
11482
- * @param {number} [angle]
11800
+ * @param {number} [angle] - LittleJS convention (clockwise positive).
11801
+ * Negated internally to match Box2D's CCW-positive convention so the
11802
+ * fixture aligns with the same angle passed to drawRect/drawTile.
11483
11803
  * @param {number} [density]
11484
11804
  * @param {number} [friction]
11485
11805
  * @param {number} [restitution]
@@ -11492,7 +11812,7 @@ class Box2dObject extends EngineObject
11492
11812
  ASSERT(isNumber(angle), 'angle must be a number');
11493
11813
 
11494
11814
  const shape = new box2d.instance.b2PolygonShape();
11495
- shape.SetAsBox(size.x/2, size.y/2, box2d.vec2dTo(offset), angle);
11815
+ shape.SetAsBox(size.x/2, size.y/2, box2d.vec2dTo(offset), -angle);
11496
11816
  return this.addShape(shape, density, friction, restitution, isSensor);
11497
11817
  }
11498
11818
 
@@ -11794,9 +12114,10 @@ class Box2dObject extends EngineObject
11794
12114
  {
11795
12115
  const data = new box2d.instance.b2MassData();
11796
12116
  this.body.GetMassData(data);
11797
- localCenter && data.set_center(box2d.vec2dTo(localCenter));
11798
- mass && data.set_mass(mass);
11799
- momentOfInertia && data.set_I(momentOfInertia);
12117
+ // use !== undefined so setMass(0) (static-equivalent) isn't silently ignored
12118
+ if (localCenter !== undefined) data.set_center(box2d.vec2dTo(localCenter));
12119
+ if (mass !== undefined) data.set_mass(mass);
12120
+ if (momentOfInertia !== undefined) data.set_I(momentOfInertia);
11800
12121
  this.body.SetMassData(data);
11801
12122
  }
11802
12123
 
@@ -12968,6 +13289,8 @@ class Box2dPlugin
12968
13289
  const fixtureB = contact.GetFixtureB();
12969
13290
  const objectA = fixtureA.GetBody().object;
12970
13291
  const objectB = fixtureB.GetBody().object;
13292
+ // raw user-created b2Bodies may have no .object — skip those
13293
+ if (!objectA || !objectB) return;
12971
13294
  objectA.beginContact(objectB);
12972
13295
  objectB.beginContact(objectA);
12973
13296
  }
@@ -12978,6 +13301,7 @@ class Box2dPlugin
12978
13301
  const fixtureB = contact.GetFixtureB();
12979
13302
  const objectA = fixtureA.GetBody().object;
12980
13303
  const objectB = fixtureB.GetBody().object;
13304
+ if (!objectA || !objectB) return;
12981
13305
  objectA.endContact(objectB);
12982
13306
  objectB.endContact(objectA);
12983
13307
  };
@@ -13356,7 +13680,7 @@ async function box2dInit()
13356
13680
  debugDraw.DrawTransform = function(transform)
13357
13681
  {
13358
13682
  transform = box2d.instance.wrapPointer(transform, box2d.instance.b2Transform);
13359
- const pos = vec2(transform.get_p());
13683
+ const pos = box2d.vec2From(transform.get_p());
13360
13684
  const angle = -transform.get_q().GetAngle();
13361
13685
  const p1 = vec2(1,0), c1 = rgb(.75,0,0,.8);
13362
13686
  const p2 = vec2(0,1), c2 = rgb(0,.75,0,.8);
@@ -13565,13 +13889,21 @@ class Tween
13565
13889
  }
13566
13890
  ASSERT(isNumber(duration) && duration > 0, 'Tween duration must be > 0');
13567
13891
 
13892
+ /** @property {function(number|Vector2|Color):void} - Called with the interpolated value each frame */
13568
13893
  this.callback = callback;
13894
+ /** @property {number|Vector2|Color} - Starting value */
13569
13895
  this.start = start;
13896
+ /** @property {number|Vector2|Color} - Ending value */
13570
13897
  this.end = end;
13898
+ /** @property {number} - Total duration in seconds */
13571
13899
  this.duration = duration;
13900
+ /** @property {number} - Remaining time in seconds (counts down from duration to 0) */
13572
13901
  this.life = duration;
13902
+ /** @property {function(number):number} - Easing curve mapping [0,1] -> [0,1] */
13573
13903
  this.ease = options.ease || Ease.LINEAR;
13904
+ /** @property {boolean} - If true, advance even when the game is paused */
13574
13905
  this.useRealTime = !!options.useRealTime;
13906
+ /** @property {boolean} - If true, stop advancing until cleared */
13575
13907
  this.paused = !!options.paused;
13576
13908
 
13577
13909
  /** @private completion callback set by then(), loop(), pingPong(). */
@@ -13752,7 +14084,7 @@ const Ease =
13752
14084
  * @param {number} x
13753
14085
  * @returns {number}
13754
14086
  * @memberof TweenSystem */
13755
- EXPO: (x) => 2 ** (10 * x - 10),
14087
+ EXPO: (x) => x === 0 ? 0 : 2 ** (10 * x - 10),
13756
14088
 
13757
14089
  /** Back ease-in: overshoots backward at the start before snapping forward.
13758
14090
  * @param {number} x
@@ -13765,6 +14097,8 @@ const Ease =
13765
14097
  * @returns {number}
13766
14098
  * @memberof TweenSystem */
13767
14099
  ELASTIC: (x) =>
14100
+ x === 0 ? 0 :
14101
+ x === 1 ? 1 :
13768
14102
  -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
13769
14103
 
13770
14104
  /** Spring-like ease-out: oscillates outward after passing the target.
@@ -13925,29 +14259,32 @@ function tweenProperty(target, propertyPath, start, end, duration = 1, options =
13925
14259
  }
13926
14260
 
13927
14261
  // Continuation that schedules the next loop iteration when one finishes.
13928
- // Called from the completed tween's `then` slot. Decrements the counter and
13929
- // only spawns a new tween if more iterations remain.
13930
- function loopContinuation(prev)
13931
- {
13932
- if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
13933
- const next = new Tween(prev.callback, prev.start, prev.end, prev.duration,
13934
- { ease: prev.ease, useRealTime: prev.useRealTime });
13935
- next.loopRemaining = prev.loopRemaining === Infinity
13936
- ? Infinity
13937
- : prev.loopRemaining - 1;
13938
- next.thenCallback = () => loopContinuation(next);
13939
- }
13940
-
13941
- // Continuation for pingPong: spawns a new tween with start and end swapped.
13942
- function pingPongContinuation(prev)
13943
- {
13944
- if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
13945
- const next = new Tween(prev.callback, prev.end, prev.start, prev.duration,
13946
- { ease: prev.ease, useRealTime: prev.useRealTime });
13947
- next.loopRemaining = prev.loopRemaining === Infinity
13948
- ? Infinity
13949
- : prev.loopRemaining - 1;
13950
- next.thenCallback = () => pingPongContinuation(next);
14262
+ // Reuses the same Tween object across iterations so the user's handle
14263
+ // from `.loop()` keeps working calling `.stop()` mid-loop now cancels
14264
+ // the entire chain instead of just the current iteration.
14265
+ function loopContinuation(tween)
14266
+ {
14267
+ if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
14268
+ if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
14269
+ tween.life = tween.duration;
14270
+ tween.thenCallback = () => loopContinuation(tween);
14271
+ tweenActive.push(tween);
14272
+ // snap to start for the new iteration (matches Tween constructor behavior)
14273
+ tween.callback(tween.interp(tween.duration));
14274
+ }
14275
+
14276
+ // Continuation for pingPong: swaps start and end on the same tween each iteration.
14277
+ function pingPongContinuation(tween)
14278
+ {
14279
+ if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
14280
+ if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
14281
+ const tmp = tween.start;
14282
+ tween.start = tween.end;
14283
+ tween.end = tmp;
14284
+ tween.life = tween.duration;
14285
+ tween.thenCallback = () => pingPongContinuation(tween);
14286
+ tweenActive.push(tween);
14287
+ tween.callback(tween.interp(tween.duration));
13951
14288
  }
13952
14289
 
13953
14290
  /** Engine plugin hook: advance every active tween by the appropriate delta.
@@ -14104,7 +14441,9 @@ class PathFinder
14104
14441
  // .size + .getCollisionData.
14105
14442
  if (isVector2(source))
14106
14443
  {
14444
+ /** @property {Vector2} - Grid dimensions in tiles */
14107
14445
  this.size = source.floor();
14446
+ /** @property {TileCollisionLayer|undefined} - Tile layer driving walkability, if any */
14108
14447
  this.tileLayer = undefined;
14109
14448
  }
14110
14449
  else
@@ -14116,13 +14455,18 @@ class PathFinder
14116
14455
  }
14117
14456
 
14118
14457
  // Tunables (public, freely re-assignable).
14458
+ /** @property {number} - A* heuristic multiplier (1 = admissible, higher = greedier) */
14119
14459
  this.heuristicWeight = 1;
14120
- this.maxLoop = 500;
14460
+ /** @property {number} - Maximum A* expansions before giving up */
14461
+ this.maxLoop = 1e3;
14462
+ /** @property {boolean} - If true, post-process paths with two-pass smoothing */
14121
14463
  this.smoothPath = true;
14464
+ /** @property {boolean} - If true, draw debug visualization during findPath */
14122
14465
  this.debug = false;
14123
- this.debugTime = 2;
14466
+ /** @property {number} - Debug primitive lifetime in seconds (0 disables drawing) */
14467
+ this.debugTime = 1;
14124
14468
 
14125
- // Pre-allocate the node array one node per tile, reused across calls.
14469
+ /** @property {Array<PathFinderNode>} - Flat row-major array of size.x*size.y nodes */
14126
14470
  this.nodes = new Array(this.size.x * this.size.y);
14127
14471
  for (let y = 0; y < this.size.y; ++y)
14128
14472
  for (let x = 0; x < this.size.x; ++x)
@@ -14274,11 +14618,13 @@ class PathFinder
14274
14618
  if (dx !== 0 && dy !== 0)
14275
14619
  {
14276
14620
  // Diagonal step: refuse if either cardinal neighbor is
14277
- // blocked or has cost. Prevents cutting through corners.
14621
+ // blocked. Prevents cutting through walls at corners.
14622
+ // (Costed-but-walkable cardinals do not block — diagonal
14623
+ // movement around expensive terrain is standard A*.)
14278
14624
  const card1 = this.getNode(current.pos.x + dx, current.pos.y);
14279
- if (!card1 || card1.cost > 0 || !card1.walkable) continue;
14625
+ if (!card1 || !card1.walkable) continue;
14280
14626
  const card2 = this.getNode(current.pos.x, current.pos.y + dy);
14281
- if (!card2 || card2.cost > 0 || !card2.walkable) continue;
14627
+ if (!card2 || !card2.walkable) continue;
14282
14628
  stepCost = PATHFINDER_DIAGONAL_COST;
14283
14629
  }
14284
14630
 
@@ -14296,9 +14642,12 @@ class PathFinder
14296
14642
  // Best path so far through neighbor — record it.
14297
14643
  neighbor.parent = current;
14298
14644
  neighbor.g = tentativeG;
14299
- const gdx = endNode.pos.x - neighbor.pos.x;
14300
- const gdy = endNode.pos.y - neighbor.pos.y;
14301
- neighbor.f = neighbor.g + (gdx * gdx + gdy * gdy) * this.heuristicWeight;
14645
+ // Octile heuristic tightest admissible distance for an
14646
+ // 8-connected grid with cardinal cost 1 and diagonal cost √2.
14647
+ const adx = abs(endNode.pos.x - neighbor.pos.x);
14648
+ const ady = abs(endNode.pos.y - neighbor.pos.y);
14649
+ const h = max(adx, ady) + (Math.SQRT2 - 1) * min(adx, ady);
14650
+ neighbor.f = neighbor.g + h * this.heuristicWeight;
14302
14651
  }
14303
14652
  }
14304
14653
 
@@ -14580,6 +14929,24 @@ class PathFinder
14580
14929
  path.push(original[original.length - 1]);
14581
14930
  }
14582
14931
 
14932
+ /** Drop any middle node that lies exactly on the line through its two
14933
+ * neighbors. Backstop for the smoothing passes — the corners pass
14934
+ * intentionally keeps truly-straight runs, and the string-pulling pass
14935
+ * checks collinearity against the original path, not the in-progress
14936
+ * result, so it can leave 3+ collinear nodes in some edge cases.
14937
+ * @param {PathFinderNode[]} path
14938
+ * @private */
14939
+ dropCollinearNodes(path)
14940
+ {
14941
+ for (let i = path.length - 2; i >= 1; --i)
14942
+ {
14943
+ const a = path[i - 1], b = path[i], c = path[i + 1];
14944
+ if ((b.pos.x - a.pos.x) * (c.pos.y - a.pos.y) ===
14945
+ (b.pos.y - a.pos.y) * (c.pos.x - a.pos.x))
14946
+ path.splice(i, 1);
14947
+ }
14948
+ }
14949
+
14583
14950
  /** Lookup helper: true when the node at tile coords (x, y) is in-bounds
14584
14951
  * and clear (walkable, zero-cost). Used by isLineClear's hot path.
14585
14952
  * @param {number} x
@@ -14747,6 +15114,7 @@ class PathFinder
14747
15114
  {
14748
15115
  this.smoothPathCorners(nodePath);
14749
15116
  this.smoothPathStringPull(nodePath);
15117
+ this.dropCollinearNodes(nodePath);
14750
15118
  }
14751
15119
 
14752
15120
  // Convert to world-space Vector2 path. Return copies, not live node
@@ -14908,6 +15276,7 @@ export
14908
15276
  setTouchGamepadAnalog,
14909
15277
  setTouchGamepadSize,
14910
15278
  setTouchGamepadAlpha,
15279
+ setTouchGamepadDisplayTime,
14911
15280
  setVibrateEnable,
14912
15281
  setSoundEnable,
14913
15282
  setSoundVolume,
@@ -14957,6 +15326,8 @@ export
14957
15326
  shareURL,
14958
15327
  readSaveData,
14959
15328
  writeSaveData,
15329
+ noise1D,
15330
+ noise2D,
14960
15331
 
14961
15332
  // Random
14962
15333
  rand,
@@ -15010,6 +15381,7 @@ export
15010
15381
  mainCanvasSize,
15011
15382
  textureInfos,
15012
15383
  drawCount,
15384
+ primitiveCount,
15013
15385
  screenToWorld,
15014
15386
  worldToScreen,
15015
15387
  screenToWorldDelta,
@@ -15025,13 +15397,15 @@ export
15025
15397
  drawRegularPoly,
15026
15398
  drawEllipse,
15027
15399
  drawCircle,
15400
+ drawEllipseGradient,
15401
+ drawCircleGradient,
15028
15402
  drawCanvas2D,
15029
15403
  drawText,
15030
15404
  drawTextScreen,
15031
15405
  setBlendMode,
15032
15406
  combineCanvases,
15033
- engineFontImage,
15034
- FontImage,
15407
+ engineImageFont,
15408
+ ImageFont,
15035
15409
  isFullscreen,
15036
15410
  toggleFullscreen,
15037
15411
  setCursor,
@@ -15054,6 +15428,7 @@ export
15054
15428
  glCopyToContext,
15055
15429
  glSetAntialias,
15056
15430
  glDraw,
15431
+ glDrawUntextured,
15057
15432
  glDrawPointsTransform,
15058
15433
  glDrawOutlineTransform,
15059
15434
  glDrawPoints,
@@ -15139,16 +15514,16 @@ export
15139
15514
  // Medals
15140
15515
  medals,
15141
15516
  medalsPreventUnlock,
15142
- medalsInit,
15143
- medalsForEach,
15144
- Medal,
15145
15517
  medalDisplayTime,
15146
15518
  medalDisplaySlideTime,
15147
15519
  medalDisplaySize,
15520
+ medalsInit,
15521
+ medalsForEach,
15148
15522
  setMedalDisplayTime,
15149
15523
  setMedalDisplaySlideTime,
15150
15524
  setMedalDisplaySize,
15151
15525
  setMedalsPreventUnlock,
15526
+ Medal,
15152
15527
 
15153
15528
  // Newgrounds
15154
15529
  newgrounds,
@@ -15161,6 +15536,7 @@ export
15161
15536
 
15162
15537
  // ZzFXMusic
15163
15538
  ZzFXMusic,
15539
+ zzfxM,
15164
15540
 
15165
15541
  // UI System
15166
15542
  uiSystem,