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
  {
@@ -670,19 +682,19 @@ const max = Math.max;
670
682
  * @param {number} x
671
683
  * @return {number}
672
684
  * @memberof Math */
673
- const sign = Math.sign;
685
+ const sign = (x) => Math.sign(x);
674
686
 
675
687
  /** Returns hypotenuse of values passed in
676
688
  * @param {...number} values
677
689
  * @return {number}
678
690
  * @memberof Math */
679
- const hypot = Math.hypot;
691
+ const hypot = (...values) => Math.hypot(...values);
680
692
 
681
693
  /** Returns log2 of value passed in
682
694
  * @param {number} x
683
695
  * @return {number}
684
696
  * @memberof Math */
685
- const log2 = Math.log2;
697
+ const log2 = (x) => Math.log2(x);
686
698
 
687
699
  /** Returns sin of value passed in
688
700
  * @param {number} x
@@ -824,7 +836,8 @@ function isOverlapping(posA, sizeA, posB, sizeB=vec2())
824
836
  const dy = (posA.y - posB.y)*2;
825
837
  const sx = sizeA.x + sizeB.x;
826
838
  const sy = sizeA.y + sizeB.y;
827
- return dx >= -sx && dx < sx && dy >= -sy && dy < sy;
839
+ // symmetric so isOverlapping(A,B) === isOverlapping(B,A) at touching edges
840
+ return abs(dx) < sx && abs(dy) < sy;
828
841
  }
829
842
 
830
843
  /** Returns true if a line segment is intersecting an axis aligned box
@@ -913,7 +926,7 @@ function isStringLike(s) { return s != null && typeof s?.toString() === 'string'
913
926
  /**
914
927
  * Check if object is an array
915
928
  * @param {any} a
916
- * @return {boolean}
929
+ * @return {a is Array<any>}
917
930
  * @memberof Math */
918
931
  function isArray(a) { return Array.isArray(a); }
919
932
 
@@ -1051,7 +1064,13 @@ function randVec2(length=1) { return new Vector2().setAngle(rand(2*PI), length);
1051
1064
  * @return {Vector2}
1052
1065
  * @memberof Random */
1053
1066
  function randInCircle(radius=1, minRadius=0)
1054
- { return radius > 0 ? randVec2(radius * rand(minRadius / radius, 1)**.5) : new Vector2; }
1067
+ {
1068
+ // r is uniform in area ⇒ r² uniform in [minRadius², radius²]
1069
+ // (the squared inner bound is what makes minRadius the actual exclusion edge)
1070
+ if (radius <= 0) return new Vector2;
1071
+ const ratio = clamp(minRadius / radius);
1072
+ return randVec2(radius * rand(ratio*ratio, 1)**.5);
1073
+ }
1055
1074
 
1056
1075
  /** Returns a random color between the two passed in colors, combine components if linear
1057
1076
  * @param {Color} [colorA=WHITE]
@@ -1121,7 +1140,14 @@ class RandomGenerator
1121
1140
  * @param {number} [valueA]
1122
1141
  * @param {number} [valueB]
1123
1142
  * @return {number} */
1124
- floatSign(valueA=1, valueB=0) { return this.float(valueA, valueB) * this.sign(); }
1143
+ floatSign(valueA=1, valueB=0)
1144
+ {
1145
+ const lo = min(valueA, valueB);
1146
+ const hi = max(valueA, valueB);
1147
+ const d = hi - lo;
1148
+ const e = this.float(d*2);
1149
+ return e < d ? lo + e : d - lo - e;
1150
+ }
1125
1151
 
1126
1152
  /** Returns a random angle between -PI and PI
1127
1153
  * @return {number} */
@@ -1769,6 +1795,7 @@ const MAGENTA = debugProtectConstant(rgb(1,0,1));
1769
1795
  * - File saving (text, canvas, data URLs)
1770
1796
  * - Native share dialog support
1771
1797
  * - Local storage save data management
1798
+ * - Gradient noise (1D and 2D)
1772
1799
  * @namespace Utilities
1773
1800
  */
1774
1801
 
@@ -1833,9 +1860,15 @@ class Timer
1833
1860
  * @return {number} */
1834
1861
  get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
1835
1862
 
1836
- /** Get percentage elapsed based on time it was set to, returns 0 if not set
1863
+ /** Get percentage elapsed based on time it was set to, returns 0 if not set.
1864
+ * Zero-duration timers report 1 (already elapsed).
1837
1865
  * @return {number} */
1838
- getPercent() { return this.isSet()? 1-percent(this.time - this.getGlobalTime(), 0, this.setTime) : 0; }
1866
+ getPercent()
1867
+ {
1868
+ if (!this.isSet()) return 0;
1869
+ if (!this.setTime) return 1;
1870
+ return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime);
1871
+ }
1839
1872
 
1840
1873
  /** Get the time this timer was set to, returns 0 if not set
1841
1874
  * @return {number} */
@@ -1862,9 +1895,9 @@ class Timer
1862
1895
  * @memberof Utilities */
1863
1896
  function formatTime(t)
1864
1897
  {
1865
- const sign = t < 0 ? '-' : '';
1898
+ const signStr = t < 0 ? '-' : '';
1866
1899
  t = abs(t)|0;
1867
- return sign + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
1900
+ return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
1868
1901
  }
1869
1902
 
1870
1903
  /** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
@@ -1950,15 +1983,20 @@ function shareURL(title, url, callback)
1950
1983
  function readSaveData(saveName, defaultSaveData)
1951
1984
  {
1952
1985
  ASSERT(isStringLike(saveName), 'loadData requires saveName string');
1953
-
1954
- // replace undefined values with defaults; tolerate corrupt JSON
1955
- const data = localStorage[saveName];
1986
+
1987
+ // tolerate localStorage being unavailable (iOS private mode, sandboxed
1988
+ // iframes) and corrupt JSON in stored data
1956
1989
  let loadedData = {};
1957
- if (data)
1990
+ try
1958
1991
  {
1959
- try { loadedData = JSON.parse(data); }
1960
- catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
1992
+ const data = localStorage[saveName];
1993
+ if (data)
1994
+ {
1995
+ try { loadedData = JSON.parse(data); }
1996
+ catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
1997
+ }
1961
1998
  }
1999
+ catch { LOG('readSaveData: localStorage unavailable — using defaults'); }
1962
2000
  return { ...defaultSaveData, ...loadedData };
1963
2001
  }
1964
2002
 
@@ -1969,7 +2007,51 @@ function readSaveData(saveName, defaultSaveData)
1969
2007
  function writeSaveData(saveName, saveData)
1970
2008
  {
1971
2009
  ASSERT(isStringLike(saveName), 'saveData requires saveName string');
1972
- localStorage[saveName] = JSON.stringify(saveData);
2010
+ // tolerate localStorage being unavailable or quota exceeded
2011
+ try { localStorage[saveName] = JSON.stringify(saveData); }
2012
+ catch { LOG('writeSaveData: failed to write', saveName); }
2013
+ }
2014
+
2015
+ ///////////////////////////////////////////////////////////////////////////////
2016
+
2017
+ // Deterministic well-distributed hash of an integer lattice index to [0, 1).
2018
+ // Murmur3 finalizer — adjacent integers produce uncorrelated outputs.
2019
+ function noiseHash(i)
2020
+ {
2021
+ let h = (i | 0) ^ 0x9e3779b9;
2022
+ h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
2023
+ h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
2024
+ h ^= h >>> 16;
2025
+ return (h >>> 0) / 2**32;
2026
+ }
2027
+
2028
+ /** 1D gradient noise — returns a smooth value in [0, 1] for any real x.
2029
+ * Integer inputs land on deterministic lattice values; non-integer inputs
2030
+ * are interpolated with smoothStep for C1 continuity.
2031
+ * @param {number} x
2032
+ * @return {number}
2033
+ * @memberof Utilities */
2034
+ function noise1D(x)
2035
+ {
2036
+ const i = floor(x);
2037
+ return lerp(noiseHash(i), noiseHash(i + 1), smoothStep(x - i));
2038
+ }
2039
+
2040
+ /** 2D gradient noise — returns a smooth value in [0, 1] for any real (x, y).
2041
+ * @param {number} x
2042
+ * @param {number} y
2043
+ * @return {number}
2044
+ * @memberof Utilities */
2045
+ function noise2D(x, y)
2046
+ {
2047
+ const ix = floor(x), iy = floor(y);
2048
+ const fx = smoothStep(x - ix), fy = smoothStep(y - iy);
2049
+ // large prime decorrelates neighboring rows
2050
+ const h = (a, b) => noiseHash(a + b * 374761393);
2051
+ return lerp(
2052
+ lerp(h(ix, iy ), h(ix + 1, iy ), fx),
2053
+ lerp(h(ix, iy + 1), h(ix + 1, iy + 1), fx),
2054
+ fy);
1973
2055
  }
1974
2056
  /**
1975
2057
  * LittleJS Engine Settings
@@ -2022,7 +2104,7 @@ let canvasColorTiles = true;
2022
2104
 
2023
2105
  /** Color to clear the canvas to before render, does not clear if alpha is 0
2024
2106
  * @type {Color}
2025
- * @memberof Draw */
2107
+ * @memberof Settings */
2026
2108
  let canvasClearColor = CLEAR_BLACK;
2027
2109
 
2028
2110
  /** The max size of the canvas, centered if window is larger
@@ -2222,7 +2304,8 @@ let touchInputEnable = true;
2222
2304
  let touchGamepadEnable = false;
2223
2305
 
2224
2306
  /** True if touch gamepad should have start button in the center
2225
- * - Prevents activating if overlappng with virtual stick or buttons if they are enabled
2307
+ * - Prevents activating within 2*touchGamepadSize of the virtual stick or face buttons
2308
+ * (one radius for the visible control + one radius of buffer beyond its edge)
2226
2309
  * - When the game is paused, any touch will press the button
2227
2310
  * - Set size to enable the center button
2228
2311
  * @type {number}
@@ -2634,7 +2717,7 @@ class EngineObject
2634
2717
  this.color = color.copy();
2635
2718
  /** @property {Color} - Additive color to apply when rendered */
2636
2719
  this.additiveColor = undefined;
2637
- /** @property {boolean} - Should it flip along y axis when rendered */
2720
+ /** @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. */
2638
2721
  this.mirror = false;
2639
2722
  /** @property {boolean} - Has object been destroyed? */
2640
2723
  this.destroyed = false;
@@ -2720,6 +2803,9 @@ class EngineObject
2720
2803
  // child objects do not have physics
2721
2804
  ASSERT(!this.parent);
2722
2805
 
2806
+ // bail if a collision callback destroyed us mid-frame
2807
+ if (this.destroyed) return;
2808
+
2723
2809
  if (this.clampSpeed)
2724
2810
  {
2725
2811
  // limit max speed to prevent missing collisions
@@ -2775,6 +2861,8 @@ class EngineObject
2775
2861
 
2776
2862
  // notify objects of collision and check if should be resolved
2777
2863
  const collide1 = this.collideWithObject(o);
2864
+ // callback may have destroyed us; stop resolving against more objects
2865
+ if (this.destroyed) return;
2778
2866
  const collide2 = o.collideWithObject(this);
2779
2867
  if (!collide1 || !collide2) continue;
2780
2868
 
@@ -2870,13 +2958,17 @@ class EngineObject
2870
2958
  const restitution = max(this.restitution, hitLayer.restitution);
2871
2959
  if (isBlockedX)
2872
2960
  {
2873
- // try to move up a tiny bit
2961
+ // try to step over a 1-tile bump (direction follows gravity sign
2962
+ // so inverted gravity steps down off a ceiling bump instead of up;
2963
+ // zero gravity defaults to the normal-gravity step-up direction)
2874
2964
  const epsilon = 1e-3;
2875
- const maxMoveUp = .1;
2876
- const y = floor(oldPos.y-this.size.y/2+1) +
2877
- this.size.y/2 + epsilon;
2878
- const delta = y - this.pos.y;
2879
- if (delta < maxMoveUp)
2965
+ const maxMove = .1;
2966
+ const gravitySign = gravity.y > 0 ? -1 : 1;
2967
+ const y = gravitySign > 0 ?
2968
+ floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon :
2969
+ ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon;
2970
+ const delta = abs(y - this.pos.y);
2971
+ if (delta < maxMove)
2880
2972
  if (!tileCollisionTest(vec2(this.pos.x, y), this.size, this))
2881
2973
  {
2882
2974
  this.pos.y = y;
@@ -3016,6 +3108,8 @@ class EngineObject
3016
3108
  * @return {EngineObject} The child object added */
3017
3109
  addChild(child, localPos=vec2(), localAngle=0)
3018
3110
  {
3111
+ ASSERT(!this.destroyed, 'cannot add child to destroyed object');
3112
+ if (this.destroyed) return child;
3019
3113
  ASSERT(!child.parent && !this.children.includes(child));
3020
3114
  ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
3021
3115
  ASSERT(child !== this, 'cannot add self as child');
@@ -3032,10 +3126,7 @@ class EngineObject
3032
3126
  removeChild(child)
3033
3127
  {
3034
3128
  ASSERT(child.parent === this && this.children.includes(child));
3035
- ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
3036
- const index = this.children.indexOf(child);
3037
- ASSERT(index >= 0, 'child not found in children array');
3038
- index >= 0 && this.children.splice(index, 1);
3129
+ this.children.splice(this.children.indexOf(child), 1);
3039
3130
  child.parent = undefined;
3040
3131
  }
3041
3132
 
@@ -3110,7 +3201,7 @@ class EngineObject
3110
3201
  * - Optimized tile sheet sprite rendering using WebGL batching
3111
3202
  * - Primitive drawing for polygons, ellipses, and lines
3112
3203
  * - Tile-based rendering with TileInfo and TextureInfo classes
3113
- * - Text rendering with custom fonts and FontImage support
3204
+ * - Text rendering with custom fonts and ImageFont support
3114
3205
  * - Color and additive color blending for effects
3115
3206
  * - Rotation, mirroring, and scaling transformations
3116
3207
  * - Camera system with position, scale, and rotation
@@ -3176,6 +3267,12 @@ let textureInfos = [];
3176
3267
  * @memberof Draw */
3177
3268
  let drawCount;
3178
3269
 
3270
+ /** Keeps track of how many primitives were drawn each frame for debugging
3271
+ * A single draw call can render many primitives (e.g. a WebGL sprite batch).
3272
+ * @type {number}
3273
+ * @memberof Draw */
3274
+ let primitiveCount;
3275
+
3179
3276
  // internal predicates for tint short-circuiting in canvas2D draw paths
3180
3277
  // isWhite ignores alpha because alpha is applied via globalAlpha, not multiply
3181
3278
  // isBlack includes alpha so additive colors that only contribute alpha are not skipped
@@ -3200,7 +3297,7 @@ let drawCount;
3200
3297
  * tile(1, 16, 3) // a tile at index 1 of size 16 on texture 3
3201
3298
  * tile(vec2(4,8), vec2(30,10)) // a tile at index (4,8) with a size of (30,10)
3202
3299
  * @memberof Draw */
3203
- function tile(index=new Vector2, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
3300
+ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
3204
3301
  {
3205
3302
  ASSERT(isVector2(index) || typeof index === 'number', 'index must be a vec2 or number');
3206
3303
  ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
@@ -3251,8 +3348,8 @@ class TileInfo
3251
3348
  * @param {Vector2} [pos=vec2()] - Top left corner of tile in pixels
3252
3349
  * @param {Vector2} [size] - Size of tile in pixels
3253
3350
  * @param {TextureInfo} [textureInfo] - Texture info to use
3254
- * @param {number} [padding] - How many pixels padding around tiles
3255
- * @param {number} [bleed] - How many pixels smaller to draw tiles
3351
+ * @param {number} [padding] - How many pixels padding around all sides of each tile (increases grid size, does not affect tile size)
3352
+ * @param {number} [bleed] - How many pixels smaller to shrink UVS of tiles (does not affect grid size, only UVs)
3256
3353
  */
3257
3354
  constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed)
3258
3355
  {
@@ -3284,7 +3381,7 @@ class TileInfo
3284
3381
  ASSERT(typeof frame === 'number');
3285
3382
  const w = this.size.x + this.padding*2;
3286
3383
  const x = frame*w;
3287
- ASSERT(x < this.textureInfo.size.x, 'frame extends beyond texture width!');
3384
+ ASSERT(x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
3288
3385
  return this.offset(new Vector2(x));
3289
3386
  }
3290
3387
 
@@ -3373,7 +3470,7 @@ class TextureInfo
3373
3470
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
3374
3471
  * @memberof Draw */
3375
3472
  function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
3376
- angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace, context)
3473
+ angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=false, context)
3377
3474
  {
3378
3475
  ASSERT(isVector2(pos), 'pos must be a vec2');
3379
3476
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -3416,19 +3513,17 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
3416
3513
  }
3417
3514
  else
3418
3515
  {
3419
- // if no tile info, force untextured by zeroing rgba (so whatever
3420
- // texture is bound doesn't leak in) and folding color+additive
3421
- // into the additive slot — matches the Canvas2D path's
3516
+ // untextured: fold color+additive to match the Canvas2D path's
3422
3517
  // color.add(additiveColor) on line ~337.
3423
3518
  const combined = additiveColor ? color.add(additiveColor) : color;
3424
- glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0,
3425
- 0, combined.rgbaInt());
3519
+ glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
3426
3520
  }
3427
3521
  }
3428
3522
  else
3429
3523
  {
3430
3524
  // normal canvas 2D rendering method (slower)
3431
3525
  ++drawCount;
3526
+ ++primitiveCount;
3432
3527
  size = new Vector2(size.x, -size.y); // flip upside down sprites
3433
3528
  drawCanvas2D(pos, size, angle, mirror, (context)=>
3434
3529
  {
@@ -3468,13 +3563,13 @@ function drawRect(pos, size, color, angle, useWebGL, screenSpace, context)
3468
3563
  * @param {Vector2} pos
3469
3564
  * @param {Vector2} [size=vec2(1)]
3470
3565
  * @param {Color} [colorTop=WHITE]
3471
- * @param {Color} [colorBottom=BLACK]
3566
+ * @param {Color} [colorBottom=CLEAR_WHITE]
3472
3567
  * @param {number} [angle]
3473
3568
  * @param {boolean} [useWebGL=glEnable]
3474
3569
  * @param {boolean} [screenSpace]
3475
3570
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
3476
3571
  * @memberof Draw */
3477
- function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0, useWebGL=glEnable, screenSpace=false, context)
3572
+ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
3478
3573
  {
3479
3574
  ASSERT(isVector2(pos), 'pos must be a vec2');
3480
3575
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -3514,6 +3609,7 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
3514
3609
  {
3515
3610
  // normal canvas 2D rendering method (slower)
3516
3611
  ++drawCount;
3612
+ ++primitiveCount;
3517
3613
  size = new Vector2(size.x, -size.y); // fix upside down sprites
3518
3614
  drawCanvas2D(pos, size, angle, false, (context)=>
3519
3615
  {
@@ -3576,8 +3672,9 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
3576
3672
  return;
3577
3673
  }
3578
3674
 
3579
- // Canvas2D path — increment drawCount here (WebGL batch counts via glBatchCount)
3675
+ // Canvas2D path — increment counts here (WebGL counts via glFlush)
3580
3676
  ++drawCount;
3677
+ ++primitiveCount;
3581
3678
 
3582
3679
  if (!screenSpace)
3583
3680
  {
@@ -3630,7 +3727,7 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
3630
3727
  * @param {boolean} [screenSpace]
3631
3728
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
3632
3729
  * @memberof Draw */
3633
- function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace, context)
3730
+ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
3634
3731
  {
3635
3732
  ASSERT(isArray(points), 'points must be an array');
3636
3733
  ASSERT(isNumber(width), 'width must be a number');
@@ -3651,6 +3748,7 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
3651
3748
  {
3652
3749
  // normal canvas 2D rendering method (slower)
3653
3750
  ++drawCount;
3751
+ ++primitiveCount;
3654
3752
  drawCanvas2D(pos, vec2(1), angle, false, (context)=>
3655
3753
  {
3656
3754
  context.strokeStyle = color.toString();
@@ -3678,7 +3776,7 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
3678
3776
  * @param {boolean} [screenSpace]
3679
3777
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
3680
3778
  * @memberof Draw */
3681
- function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, screenSpace, context)
3779
+ function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
3682
3780
  {
3683
3781
  const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
3684
3782
  const size = vec2(width, halfDelta.length()*2);
@@ -3694,9 +3792,9 @@ function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, sc
3694
3792
  * @param {Vector2} [size=vec2(1)]
3695
3793
  * @param {number} [sides]
3696
3794
  * @param {Color} [color=WHITE]
3697
- * @param {number} [angle]
3698
3795
  * @param {number} [lineWidth]
3699
3796
  * @param {Color} [lineColor=BLACK]
3797
+ * @param {number} [angle]
3700
3798
  * @param {boolean} [useWebGL=glEnable]
3701
3799
  * @param {boolean} [screenSpace]
3702
3800
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
@@ -3789,7 +3887,7 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
3789
3887
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
3790
3888
 
3791
3889
  // clamp line width to prevent artifacts
3792
- lineWidth = clamp(lineWidth, 0, Math.min(size.x, size.y));
3890
+ lineWidth = clamp(lineWidth, 0, min(size.x, size.y));
3793
3891
 
3794
3892
  if (useWebGL && glEnable)
3795
3893
  {
@@ -3831,6 +3929,104 @@ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useW
3831
3929
  drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
3832
3930
  }
3833
3931
 
3932
+ /** Draw an ellipse filled with a radial gradient from the center to the rim
3933
+ * - Best when batched with other untextured polys
3934
+ * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
3935
+ * - Stacking gradients at the exact same position may show a faint vertical artifact
3936
+ * @param {Vector2} pos
3937
+ * @param {Vector2} [size=vec2(1)] - Width and height diameter
3938
+ * @param {Color} [colorInner=WHITE]
3939
+ * @param {Color} [colorOuter=CLEAR_WHITE]
3940
+ * @param {number} [angle]
3941
+ * @param {boolean} [useWebGL=glEnable]
3942
+ * @param {boolean} [screenSpace]
3943
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
3944
+ * @memberof Draw */
3945
+ let drawEllipseGradientOffset = 0;
3946
+ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
3947
+ {
3948
+ ASSERT(isVector2(pos), 'pos must be a vec2');
3949
+ ASSERT(isVector2(size), 'size must be a vec2');
3950
+ ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid');
3951
+ ASSERT(isNumber(angle), 'angle must be a number');
3952
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
3953
+
3954
+ if (headlessMode) return;
3955
+
3956
+ if (useWebGL && glEnable)
3957
+ {
3958
+ ASSERT(!!glContext, 'WebGL is not enabled!');
3959
+ if (screenSpace)
3960
+ {
3961
+ // convert to world space
3962
+ pos = screenToWorld(pos);
3963
+ size = size.scale(1/cameraScale);
3964
+ angle += cameraAngle;
3965
+ }
3966
+ // fan as tristrip; rotate the boundary vertex by one slice per call
3967
+ // so back-to-back gradients at the same position have their hole
3968
+ // (from gpu edge-rule on the boundary line-degen) at different rim
3969
+ // verts and don't visibly stack
3970
+ const sides = glCircleSides;
3971
+ const radiusX = size.x/2, radiusY = size.y/2;
3972
+ const innerInt = colorInner.rgbaInt();
3973
+ const outerInt = colorOuter.rgbaInt();
3974
+ const offset = drawEllipseGradientOffset++;
3975
+ const c = cos(-angle), s = sin(-angle);
3976
+ const rim = (a) =>
3977
+ {
3978
+ const lx = sin(a)*radiusX, ly = cos(a)*radiusY;
3979
+ return vec2(pos.x + lx*c - ly*s, pos.y + lx*s + ly*c);
3980
+ };
3981
+ const startA = (offset%sides)/sides*PI*2;
3982
+ const points = [rim(startA)];
3983
+ const colors = [outerInt];
3984
+ for (let i=sides; i--;)
3985
+ {
3986
+ const a = ((i+offset)%sides)/sides*PI*2;
3987
+ points.push(pos);
3988
+ colors.push(innerInt);
3989
+ points.push(rim(a));
3990
+ colors.push(outerInt);
3991
+ }
3992
+ glDrawColoredPoints(points, colors);
3993
+ }
3994
+ else
3995
+ {
3996
+ // normal canvas 2D rendering method (slower)
3997
+ ++drawCount;
3998
+ ++primitiveCount;
3999
+ drawCanvas2D(pos, size, angle, false, (context)=>
4000
+ {
4001
+ const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5);
4002
+ gradient.addColorStop(0, colorInner.toString());
4003
+ gradient.addColorStop(1, colorOuter.toString());
4004
+ context.fillStyle = gradient;
4005
+ context.beginPath();
4006
+ context.ellipse(0, 0, .5, .5, 0, 0, 9);
4007
+ context.fill();
4008
+ }, screenSpace, context);
4009
+ }
4010
+ }
4011
+
4012
+ /** Draw a circle filled with a radial gradient from the center to the rim
4013
+ * - Best when batched with other untextured polys
4014
+ * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
4015
+ * - Stacking gradients at the exact same position may show a faint vertical artifact
4016
+ * @param {Vector2} pos
4017
+ * @param {number} [size=1] - Diameter
4018
+ * @param {Color} [colorInner=WHITE]
4019
+ * @param {Color} [colorOuter=CLEAR_WHITE]
4020
+ * @param {boolean} [useWebGL=glEnable]
4021
+ * @param {boolean} [screenSpace]
4022
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
4023
+ * @memberof Draw */
4024
+ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
4025
+ {
4026
+ ASSERT(isNumber(size), 'size must be a number');
4027
+ drawEllipseGradient(pos, vec2(size), colorInner, colorOuter, 0, useWebGL, screenSpace, context);
4028
+ }
4029
+
3834
4030
  /**
3835
4031
  * @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context
3836
4032
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
@@ -3885,7 +4081,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
3885
4081
  * @param {number} [angle]
3886
4082
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
3887
4083
  * @memberof Draw */
3888
- function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, font, fontStyle, maxWidth, angle=0, context=drawContext)
4084
+ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
3889
4085
  {
3890
4086
  // convert to screen space
3891
4087
  pos = worldToScreen(pos);
@@ -3925,16 +4121,16 @@ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLA
3925
4121
  ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
3926
4122
  ASSERT(isNumber(angle), 'angle must be a number');
3927
4123
 
4124
+ const lines = (text+'').split('\n');
4125
+ const posY = pos.y - (lines.length-1) * size/2; // center vertically
4126
+ // save before style mutations so caller's context state is preserved
4127
+ context.save();
3928
4128
  context.fillStyle = color.toString();
3929
4129
  context.strokeStyle = lineColor.toString();
3930
4130
  context.lineWidth = lineWidth;
3931
4131
  context.textAlign = textAlign;
3932
4132
  context.font = fontStyle + ' ' + size + 'px '+ font;
3933
4133
  context.textBaseline = 'middle';
3934
-
3935
- const lines = (text+'').split('\n');
3936
- const posY = pos.y - (lines.length-1) * size/2; // center vertically
3937
- context.save();
3938
4134
  context.translate(pos.x, posY);
3939
4135
  context.rotate(-angle);
3940
4136
  let yOffset = 0;
@@ -4095,6 +4291,9 @@ function isOnScreen(pos, size=0)
4095
4291
  ASSERT(isVector2(pos), 'pos must be a vec2');
4096
4292
  ASSERT(isVector2(size) || isNumber(size), 'size must be a vec2 or number');
4097
4293
 
4294
+ // cameraScale of 0 collapses world coords; nothing is visible
4295
+ if (!cameraScale) return false;
4296
+
4098
4297
  // optimized circle on screen test
4099
4298
  // pos = worldToScreen(pos);
4100
4299
  let x = pos.x - cameraPos.x;
@@ -4136,7 +4335,10 @@ function combineCanvases()
4136
4335
  const w = mainCanvasSize.x, h = mainCanvasSize.y;
4137
4336
  workCanvas.width = w;
4138
4337
  workCanvas.height = h;
4139
- workContext.fillRect(0,0,w,h); // remove background alpha
4338
+ // remove background alpha — explicit fillStyle so a previous caller
4339
+ // leaving workContext.fillStyle transparent can't silently no-op this
4340
+ workContext.fillStyle = '#000';
4341
+ workContext.fillRect(0,0,w,h);
4140
4342
  glCopyToContext(workContext);
4141
4343
  workContext.drawImage(mainCanvas, 0, 0);
4142
4344
  mainContext.drawImage(workCanvas, 0, 0);
@@ -4279,33 +4481,33 @@ function setCursor(cursorStyle = 'auto')
4279
4481
  ///////////////////////////////////////////////////////////////////////////////
4280
4482
 
4281
4483
  /** Engine font image, 8x8 font provided by the engine
4282
- * @type {FontImage}
4484
+ * @type {ImageFont}
4283
4485
  * @memberof Draw */
4284
- let engineFontImage;
4486
+ let engineImageFont;
4285
4487
 
4286
4488
  /**
4287
- * Font Image Object - Draw text by using tiles in an image
4489
+ * Image Font Object - Draw text by using tiles in an image
4288
4490
  * - 96 characters (from space to tilde) are stored in an image
4289
4491
  * - A 8x8 default engine font is supplied for general use
4290
4492
  * - This system is WebGL enabled for fast text rendering
4291
4493
  * - Fonts can also be colored and scaled along each axis
4292
- *
4494
+ *
4293
4495
  * @memberof Draw
4294
4496
  * @example
4295
4497
  * // use built in font
4296
- * const font = engineFontImage;
4498
+ * const font = engineImageFont;
4297
4499
  *
4298
4500
  * // draw text
4299
4501
  * font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50));
4300
4502
  */
4301
- class FontImage
4503
+ class ImageFont
4302
4504
  {
4303
4505
  /** Create an image font
4304
4506
  * @param {TileInfo} tileInfo - Tile info of first character in font
4305
4507
  */
4306
4508
  constructor(tileInfo)
4307
4509
  {
4308
- ASSERT(!!tileInfo, 'tileInfo is required for FontImage');
4510
+ ASSERT(!!tileInfo, 'tileInfo is required for ImageFont');
4309
4511
 
4310
4512
  /** @property {TileInfo} - Tile info for the font */
4311
4513
  this.tileInfo = tileInfo.frame(0);
@@ -4390,7 +4592,7 @@ class FontImage
4390
4592
  }
4391
4593
 
4392
4594
  // load engine font, called automatically on startup
4393
- async function fontImageInit()
4595
+ async function imageFontInit()
4394
4596
  {
4395
4597
  const image = new Image;
4396
4598
  await new Promise(resolve =>
@@ -4403,7 +4605,7 @@ async function fontImageInit()
4403
4605
  const tilePos=vec2(), tileSize=vec2(8), padding=1, bleed=0;
4404
4606
  const textureInfo = new TextureInfo(image);
4405
4607
  const tileInfo = new TileInfo(tilePos, tileSize, textureInfo, padding, bleed);
4406
- engineFontImage = new FontImage(tileInfo);
4608
+ engineImageFont = new ImageFont(tileInfo);
4407
4609
  }
4408
4610
  /**
4409
4611
  * LittleJS Input System
@@ -4852,9 +5054,11 @@ function inputInit()
4852
5054
  mouseDeltaScreen = mouseDeltaScreen.add(movement);
4853
5055
  }
4854
5056
  function onMouseLeave() { mouseInWindow = false; } // mouse moved off window
4855
- function onMouseWheel(e)
4856
- {
4857
- mouseWheel = e.ctrlKey ? 0 : sign(e.deltaY);
5057
+ function onMouseWheel(e)
5058
+ {
5059
+ // accumulate so multiple wheel events in one frame are not lost
5060
+ if (!e.ctrlKey)
5061
+ mouseWheel += sign(e.deltaY);
4858
5062
  if (inputPreventDefault && e.cancelable && document.hasFocus())
4859
5063
  e.preventDefault(); // prevent page scrolling
4860
5064
  }
@@ -4979,9 +5183,14 @@ function inputInit()
4979
5183
  if (button < touchGamepadButtonCount)
4980
5184
  touchGamepadButtons[button] = 1;
4981
5185
  }
4982
- else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize)
5186
+ else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize &&
5187
+ stickCenter.distance(touchPos) >= 2 * touchGamepadSize &&
5188
+ buttonCenter.distance(touchPos) >= 2 * touchGamepadSize)
4983
5189
  {
4984
5190
  // virtual start button in center
5191
+ // require a fat-finger buffer of touchGamepadSize beyond the
5192
+ // edge of the stick/buttons so drift off those controls can't
5193
+ // accidentally fire start
4985
5194
  touchGamepadButtons[9] = 1;
4986
5195
  }
4987
5196
  }
@@ -5023,7 +5232,7 @@ function inputUpdate()
5023
5232
  v > min ? percent(v, min, max) :
5024
5233
  v < -min ? -percent(-v, min, max) : 0;
5025
5234
  return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
5026
- }
5235
+ };
5027
5236
 
5028
5237
  // update touch gamepad if enabled
5029
5238
  if (touchGamepadEnable && isTouchDevice)
@@ -5037,7 +5246,12 @@ function inputUpdate()
5037
5246
  debugCircle(stickCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
5038
5247
  debugCircle(buttonCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
5039
5248
  if (touchGamepadCenterButtonSize)
5249
+ {
5040
5250
  debugCircle(startCenter, 2*touchGamepadCenterButtonSize, 'cyan', 0, false, true);
5251
+ // exclusion bubbles around controls (where start is blocked)
5252
+ debugCircle(stickCenter, 4*touchGamepadSize, 'magenta', 0, false, true);
5253
+ debugCircle(buttonCenter, 4*touchGamepadSize, 'magenta', 0, false, true);
5254
+ }
5041
5255
  }
5042
5256
 
5043
5257
  if (!touchGamepadTimer.isSet()) return;
@@ -5144,13 +5358,6 @@ function inputUpdate()
5144
5358
  (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
5145
5359
  (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
5146
5360
  }
5147
- else if (gamepad.axes && gamepad.axes.length >= 2)
5148
- {
5149
- // digital style dpad from axes
5150
- const x = clamp(round(gamepad.axes[0]), -1, 1);
5151
- const y = clamp(round(gamepad.axes[1]), -1, 1);
5152
- dpad.set(x, -y);
5153
- }
5154
5361
 
5155
5362
  // copy dpad to left analog stick when pressed
5156
5363
  if (gamepadDirectionEmulateStick && (dpad.x || dpad.y))
@@ -5356,7 +5563,7 @@ class Sound
5356
5563
  /** @property {SoundLoadCallback} - function to call when sound is loaded */
5357
5564
  this.onloadCallback = onloadCallback;
5358
5565
 
5359
- if (Array.isArray(asset))
5566
+ if (isArray(asset))
5360
5567
  {
5361
5568
  // generate zzfx sound — copy so we don't mutate the caller's array
5362
5569
  const zzfxSound = asset.slice();
@@ -5448,7 +5655,7 @@ class Sound
5448
5655
  }
5449
5656
 
5450
5657
  /** Get how long this sound is in seconds
5451
- * @return {number} - How long the sound is in seconds (undefined if loading)
5658
+ * @return {number} - How long the sound is in seconds (0 if loading)
5452
5659
  */
5453
5660
  getDuration()
5454
5661
  { return this.sampleChannels?.[0]?.length / this.sampleRate || 0; }
@@ -5605,10 +5812,14 @@ class SoundInstance
5605
5812
  {
5606
5813
  if (fadeTime)
5607
5814
  {
5608
- // ramp off gain
5815
+ // ramp off gain from current volume (not 1, or low-volume
5816
+ // instances would jump back up before fading);
5817
+ // cancel any prior scheduling so stacked stop calls don't
5818
+ // re-anchor partway through a previous fade
5609
5819
  const startFade = audioContext.currentTime;
5610
5820
  const endFade = startFade + fadeTime;
5611
- this.gainNode.gain.linearRampToValueAtTime(1, startFade);
5821
+ this.gainNode.gain.cancelScheduledValues(startFade);
5822
+ this.gainNode.gain.setValueAtTime(this.volume, startFade);
5612
5823
  this.gainNode.gain.linearRampToValueAtTime(0, endFade);
5613
5824
  this.source.stop(endFade);
5614
5825
  }
@@ -5656,13 +5867,14 @@ class SoundInstance
5656
5867
  */
5657
5868
  getCurrentTime()
5658
5869
  {
5659
- const deltaTime = mod(audioContext.currentTime - this.startTime,
5660
- this.getDuration());
5661
- return this.isPlaying() ? deltaTime : this.pausedTime;
5870
+ if (!this.isPlaying()) return this.pausedTime;
5871
+ const duration = this.getDuration();
5872
+ // guard mod against 0 duration (rate=0 or sound not loaded)
5873
+ return duration ? mod(audioContext.currentTime - this.startTime, duration) : 0;
5662
5874
  }
5663
5875
 
5664
5876
  /** Get the total duration of this sound
5665
- * @return {number} - Total duration in seconds
5877
+ * @return {number} - Total duration in seconds (0 if loading)
5666
5878
  */
5667
5879
  getDuration() { return this.rate ? this.sound.getDuration() / this.rate : 0; }
5668
5880
 
@@ -5676,16 +5888,17 @@ class SoundInstance
5676
5888
 
5677
5889
  /** Speak text with passed in settings
5678
5890
  * @param {string} text - The text to speak
5679
- * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
5680
5891
  * @param {number} [volume] - How much to scale volume by
5681
5892
  * @param {number} [rate] - How quickly to speak
5682
5893
  * @param {number} [pitch] - How much to change the pitch by
5894
+ * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
5683
5895
  * @return {SpeechSynthesisUtterance} - The utterance that was spoken
5684
5896
  * @memberof Audio */
5685
- function speak(text, language='', volume=1, rate=1, pitch=1)
5897
+ function speak(text, volume=1, rate=1, pitch=1, language='')
5686
5898
  {
5899
+ ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch');
5687
5900
  if (!soundEnable || headlessMode) return;
5688
- if (!speechSynthesis) return;
5901
+ if (typeof speechSynthesis === 'undefined') return;
5689
5902
 
5690
5903
  // common languages (not supported by all browsers)
5691
5904
  // en - english, it - italian, fr - french, de - german, es - spanish
@@ -5703,7 +5916,11 @@ function speak(text, language='', volume=1, rate=1, pitch=1)
5703
5916
 
5704
5917
  /** Stop all queued speech
5705
5918
  * @memberof Audio */
5706
- function speakStop() {speechSynthesis?.cancel();}
5919
+ function speakStop()
5920
+ {
5921
+ if (typeof speechSynthesis !== 'undefined')
5922
+ speechSynthesis.cancel();
5923
+ }
5707
5924
 
5708
5925
  /** Get frequency of a note on a musical scale
5709
5926
  * @param {number} semitoneOffset - How many semitones away from the root note
@@ -5765,9 +5982,14 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
5765
5982
  const pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)});
5766
5983
  source.connect(pannerNode).connect(gainNode);
5767
5984
 
5768
- // callback when the sound ends
5769
- if (onended)
5770
- source.addEventListener('ended', ()=> onended(source));
5985
+ // disconnect nodes when the sound ends so the audio graph doesn't grow
5986
+ // unbounded across many play() calls (source.stop() also fires 'ended')
5987
+ source.addEventListener('ended', ()=>
5988
+ {
5989
+ gainNode.disconnect();
5990
+ pannerNode.disconnect();
5991
+ if (onended) onended(source);
5992
+ });
5771
5993
 
5772
5994
  // play and return sound
5773
5995
  const startOffset = offset * rate;
@@ -6004,14 +6226,29 @@ function tileCollisionTest(pos, size=vec2(), callbackObject, solidOnly=true)
6004
6226
  * @memberof TileLayers */
6005
6227
  function tileCollisionRaycast(posStart, posEnd, callbackObject, normal, solidOnly=true)
6006
6228
  {
6229
+ // check every layer and keep the closest hit so a far hit in an
6230
+ // earlier-registered layer doesn't shadow a closer hit in a later one
6231
+ let closestHit, closestDistSq, closestNormal;
6232
+ const scratchNormal = normal && vec2();
6007
6233
  for (const layer of tileCollisionLayers)
6008
6234
  {
6009
6235
  if (!solidOnly || layer.isSolid)
6010
6236
  {
6011
- const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, normal)
6012
- if (hitPos) return hitPos;
6237
+ const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, scratchNormal);
6238
+ if (hitPos)
6239
+ {
6240
+ const d = posStart.distanceSquared(hitPos);
6241
+ if (closestHit === undefined || d < closestDistSq)
6242
+ {
6243
+ closestHit = hitPos;
6244
+ closestDistSq = d;
6245
+ if (normal) closestNormal = scratchNormal.copy();
6246
+ }
6247
+ }
6013
6248
  }
6014
6249
  }
6250
+ if (closestHit && normal) normal.setFrom(closestNormal);
6251
+ return closestHit;
6015
6252
  }
6016
6253
 
6017
6254
  ///////////////////////////////////////////////////////////////////////////////
@@ -6185,38 +6422,6 @@ class CanvasLayer extends EngineObject
6185
6422
  drawTile(pos, size, tileInfo, color, angle, mirror, additiveColor, useWebGL, screenSpace, context);
6186
6423
  }
6187
6424
 
6188
- /** Draw a tile onto the layer canvas in world space
6189
- * @param {Vector2} pos
6190
- * @param {Vector2} [size=vec2(1)]
6191
- * @param {TileInfo} [tileInfo]
6192
- * @param {Color} [color=WHITE]
6193
- * @param {number} [angle]
6194
- * @param {boolean} [mirror] */
6195
- drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
6196
- {
6197
- pos = pos.subtract(this.pos).multiply(this.tileInfo.size);
6198
- size = size.multiply(this.tileInfo.size);
6199
- pos.y = this.canvas.height - pos.y;
6200
-
6201
- // draw the tile onto the layer canvas
6202
- const oldMainCanvasSize = mainCanvasSize;
6203
- mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
6204
- const useWebGL = this.hasWebGL();
6205
- useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
6206
- const drawContext = useWebGL ? undefined : this.context;
6207
- drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
6208
- useWebGL && glSetRenderTarget();
6209
- mainCanvasSize = oldMainCanvasSize;
6210
- }
6211
-
6212
- /** Draw a rectangle onto the layer canvas in world space
6213
- * @param {Vector2} pos
6214
- * @param {Vector2} [size=vec2(1)]
6215
- * @param {Color} [color=WHITE]
6216
- * @param {number} [angle] */
6217
- drawRect(pos, size, color, angle)
6218
- { this.drawTile(pos, size, undefined, color, angle); }
6219
-
6220
6425
  /** Create WebGL texture if necessary and copy layer canvas to it */
6221
6426
  updateWebGL()
6222
6427
  { this.textureInfo.createWebGLTexture(); }
@@ -6272,6 +6477,8 @@ class TileLayer extends CanvasLayer
6272
6477
  this.redrawTileData = ()=> {};
6273
6478
  this.drawLayerTile = ()=> {};
6274
6479
  this.drawLayerRect = ()=> {};
6480
+ this.drawTile = ()=> {};
6481
+ this.drawRect = ()=> {};
6275
6482
  this.clearLayerRect = ()=> {};
6276
6483
  return;
6277
6484
  }
@@ -6299,7 +6506,7 @@ class TileLayer extends CanvasLayer
6299
6506
  ASSERT(data instanceof TileLayerData, 'data must be a TileLayerData');
6300
6507
 
6301
6508
  if (!layerPos.arrayCheck(this.size)) return;
6302
- this.data[(layerPos.y|0)*this.size.x+layerPos.x|0] = data;
6509
+ this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] = data;
6303
6510
 
6304
6511
  if (!redraw) return;
6305
6512
  const isRedraw = drawContext === this.context;
@@ -6314,11 +6521,11 @@ class TileLayer extends CanvasLayer
6314
6521
 
6315
6522
  /** Get data at a given position in the array
6316
6523
  * @param {Vector2} layerPos - Local position in array
6317
- * @return {TileLayerData} */
6524
+ * @return {TileLayerData|undefined} */
6318
6525
  getData(layerPos)
6319
- {
6526
+ {
6320
6527
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
6321
- return layerPos.arrayCheck(this.size) && this.data[(layerPos.y|0)*this.size.x+layerPos.x|0];
6528
+ return layerPos.arrayCheck(this.size) ? this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] : undefined;
6322
6529
  }
6323
6530
 
6324
6531
  // Update the tile layer, refresh texture if needed
@@ -6426,7 +6633,7 @@ class TileLayer extends CanvasLayer
6426
6633
 
6427
6634
  // draw the tile if it has layer data
6428
6635
  const d = this.getData(layerPos);
6429
- if (!d.tile) return;
6636
+ if (!d || !d.tile) return;
6430
6637
 
6431
6638
  const tileInfo = this.tileInfo && this.tileInfo.tile(d.tile);
6432
6639
  this.drawLayerTile(drawPos, drawSize, tileInfo, d.color, d.direction*PI/2, d.mirror);
@@ -6472,6 +6679,38 @@ class TileLayer extends CanvasLayer
6472
6679
  drawLayerRect(pos, size, color, angle=0)
6473
6680
  { this.drawLayerTile(pos, size, undefined, color, angle); }
6474
6681
 
6682
+ /** Draw a tile onto the layer canvas in world space
6683
+ * @param {Vector2} pos
6684
+ * @param {Vector2} [size=vec2(1)]
6685
+ * @param {TileInfo} [tileInfo]
6686
+ * @param {Color} [color=WHITE]
6687
+ * @param {number} [angle]
6688
+ * @param {boolean} [mirror] */
6689
+ drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
6690
+ {
6691
+ pos = pos.subtract(this.pos).multiply(this.tileInfo.size);
6692
+ size = size.multiply(this.tileInfo.size);
6693
+ pos.y = this.canvas.height - pos.y;
6694
+
6695
+ // draw the tile onto the layer canvas
6696
+ const oldMainCanvasSize = mainCanvasSize;
6697
+ mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
6698
+ const useWebGL = this.hasWebGL();
6699
+ useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
6700
+ const drawContext = useWebGL ? undefined : this.context;
6701
+ drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
6702
+ useWebGL && glSetRenderTarget();
6703
+ mainCanvasSize = oldMainCanvasSize;
6704
+ }
6705
+
6706
+ /** Draw a rectangle onto the layer canvas in world space
6707
+ * @param {Vector2} pos
6708
+ * @param {Vector2} [size=vec2(1)]
6709
+ * @param {Color} [color=WHITE]
6710
+ * @param {number} [angle] */
6711
+ drawRect(pos, size, color, angle)
6712
+ { this.drawTile(pos, size, undefined, color, angle); }
6713
+
6475
6714
  /** Clear a rectangle in layer space
6476
6715
  * @param {Vector2} pos - position in pixel coordinates
6477
6716
  * @param {Vector2} size
@@ -6550,7 +6789,7 @@ class TileCollisionLayer extends TileLayer
6550
6789
  setCollisionData(layerPos, data=1)
6551
6790
  {
6552
6791
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
6553
- const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
6792
+ const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
6554
6793
  layerPos.arrayCheck(this.size) && (this.collisionData[i] = data);
6555
6794
  }
6556
6795
 
@@ -6565,7 +6804,7 @@ class TileCollisionLayer extends TileLayer
6565
6804
  getCollisionData(layerPos)
6566
6805
  {
6567
6806
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
6568
- const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
6807
+ const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
6569
6808
  return layerPos.arrayCheck(this.size) ? this.collisionData[i] : 0;
6570
6809
  }
6571
6810
 
@@ -6590,8 +6829,10 @@ class TileCollisionLayer extends TileLayer
6590
6829
  const posY = pos.y - this.pos.y;
6591
6830
  const minX = max(posX - size.x/2|0, 0);
6592
6831
  const minY = max(posY - size.y/2|0, 0);
6593
- const maxX = min(posX + size.x/2, this.size.x);
6594
- const maxY = min(posY + size.y/2, this.size.y);
6832
+ // ensure at least one cell is visited even when size is 0 and pos
6833
+ // lands exactly on an integer boundary (documented point-test mode)
6834
+ const maxX = min(max(posX + size.x/2, minX + 1), this.size.x);
6835
+ const maxY = min(max(posY + size.y/2, minY + 1), this.size.y);
6595
6836
  const hitPos = new Vector2;
6596
6837
  for (let y = minY; y < maxY; ++y)
6597
6838
  for (let x = minX; x < maxX; ++x)
@@ -6710,7 +6951,7 @@ class ParticleEmitter extends EngineObject
6710
6951
  * @param {number} [angleDamping] - How much to dampen particle angular speed
6711
6952
  * @param {number} [gravityScale] - How much gravity effect particles
6712
6953
  * @param {number} [particleConeAngle] - Cone for start particle angle
6713
- * @param {number} [fadeRate] - How quick to fade particles at start/end in percent of life
6954
+ * @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
6714
6955
  * @param {number} [randomness] - Apply extra randomness percent
6715
6956
  * @param {boolean} [collideTiles] - Do particles collide against tiles
6716
6957
  * @param {boolean} [additive] - Should particles use additive blend
@@ -6794,7 +7035,7 @@ class ParticleEmitter extends EngineObject
6794
7035
  this.gravityScale = gravityScale;
6795
7036
  /** @property {number} - Cone for start particle angle */
6796
7037
  this.particleConeAngle = particleConeAngle;
6797
- /** @property {number} - How quick to fade in particles at start/end in percent of life */
7038
+ /** @property {number} - Fraction of life spent fading, split half at start and half at end (e.g. .2 = 10% fade-in + 10% fade-out) */
6798
7039
  this.fadeRate = fadeRate;
6799
7040
  /** @property {number} - Apply extra randomness percent */
6800
7041
  this.randomness = randomness;
@@ -7072,33 +7313,32 @@ class Particle
7072
7313
  const hitLayer = tileCollisionTest(this.pos);
7073
7314
  if (!testCollision(oldPos))
7074
7315
  {
7075
- if (!collideCallback || collideCallback?.(this, hitLayer))
7316
+ // testCollision already invoked collideCallback with the
7317
+ // correct (this, data, pos) args; no need to re-check here.
7318
+ // test which side we bounced off (or both if a corner)
7319
+ const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
7320
+ const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
7321
+ const hitRestitution = max(restitution, hitLayer.restitution);
7322
+ const hitFriction = max(friction, hitLayer.friction);
7323
+ if (isBlockedX)
7076
7324
  {
7077
- // test which side we bounced off (or both if a corner)
7078
- const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
7079
- const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
7080
- const hitRestitution = max(restitution, hitLayer.restitution);
7081
- const hitFriction = max(friction, hitLayer.friction);
7082
- if (isBlockedX)
7083
- {
7084
- // move to previous X position and bounce
7085
- this.pos.x = oldPos.x;
7086
- this.velocity.x *= -hitRestitution;
7087
- this.velocity.y *= hitFriction;
7088
- }
7089
- if (isBlockedY || !isBlockedX)
7090
- {
7091
- const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
7092
- if (wasFalling)
7093
- this.groundObject = hitLayer;
7094
-
7095
- // move to previous Y position and bounce
7096
- this.pos.y = oldPos.y;
7097
- this.velocity.y *= -hitRestitution;
7098
- this.velocity.x *= hitFriction;
7099
- }
7100
- debugPhysics && debugRect(this.pos, this.size, '#f00');
7325
+ // move to previous X position and bounce
7326
+ this.pos.x = oldPos.x;
7327
+ this.velocity.x *= -hitRestitution;
7328
+ this.velocity.y *= hitFriction;
7101
7329
  }
7330
+ if (isBlockedY || !isBlockedX)
7331
+ {
7332
+ const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
7333
+ if (wasFalling)
7334
+ this.groundObject = hitLayer;
7335
+
7336
+ // move to previous Y position and bounce
7337
+ this.pos.y = oldPos.y;
7338
+ this.velocity.y *= -hitRestitution;
7339
+ this.velocity.x *= hitFriction;
7340
+ }
7341
+ debugPhysics && debugRect(this.pos, this.size, '#f00');
7102
7342
  }
7103
7343
  }
7104
7344
  }
@@ -7259,6 +7499,10 @@ function glInit(rootElement)
7259
7499
  for (const info of glTextureInfos)
7260
7500
  info.glTexture = undefined;
7261
7501
  glActiveTexture = undefined;
7502
+ // drop any partially-filled batch so the next glFlush doesn't
7503
+ // upload stale glBatchCount against fresh empty buffers on restore
7504
+ glBatchCount = 0;
7505
+ glPolyMode = false;
7262
7506
  pluginList.forEach(plugin=>plugin.glContextLost?.());
7263
7507
  });
7264
7508
  glCanvas.addEventListener('webglcontextrestored', ()=>
@@ -7617,6 +7861,10 @@ function glSetTextureData(texture, image)
7617
7861
  glContext.bindTexture(glContext.TEXTURE_2D, texture);
7618
7862
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, image);
7619
7863
 
7864
+ // keep mipmaps in sync with new level 0 data (same condition as glCreateTexture)
7865
+ if (!tilesPixelated && isPowerOfTwo(image.width) && isPowerOfTwo(image.height))
7866
+ glContext.generateMipmap(glContext.TEXTURE_2D);
7867
+
7620
7868
  // rebind active texture
7621
7869
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
7622
7870
  }
@@ -7662,7 +7910,7 @@ function glFlush()
7662
7910
  {
7663
7911
  if (glEnable && glContext && glBatchCount)
7664
7912
  {
7665
- // set bend mode
7913
+ // set blend mode
7666
7914
  const destBlend = glBatchAdditive ? glContext.ONE : glContext.ONE_MINUS_SRC_ALPHA;
7667
7915
  glContext.blendFuncSeparate(glContext.SRC_ALPHA, destBlend, glContext.ONE, destBlend);
7668
7916
  glContext.enable(glContext.BLEND);
@@ -7676,7 +7924,8 @@ function glFlush()
7676
7924
  glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, glBatchCount);
7677
7925
  else
7678
7926
  glContext.drawArraysInstanced(glContext.TRIANGLE_STRIP, 0, 4, glBatchCount);
7679
- drawCount += glBatchCount;
7927
+ ++drawCount;
7928
+ primitiveCount += glBatchCount;
7680
7929
  glBatchCount = 0;
7681
7930
  }
7682
7931
  glBatchAdditive = glAdditive;
@@ -7737,6 +7986,22 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
7737
7986
  glPositionData[offset++] = angle;
7738
7987
  }
7739
7988
 
7989
+ /** Add an untextured rect to the gl draw list
7990
+ * Zeroes the uvs and rgba so the texture contribution multiplies to 0,
7991
+ * then carries the real color in the additive slot. Works regardless of
7992
+ * which texture is currently bound.
7993
+ * @param {number} x
7994
+ * @param {number} y
7995
+ * @param {number} sizeX
7996
+ * @param {number} sizeY
7997
+ * @param {number} angle
7998
+ * @param {number} rgba - color as 32-bit integer
7999
+ * @memberof WebGL */
8000
+ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
8001
+ {
8002
+ glDraw(x, y, sizeX, sizeY, angle, 0, 0, 0, 0, 0, rgba);
8003
+ }
8004
+
7740
8005
  /** Transform and add a polygon to the gl draw list
7741
8006
  * @param {Array<Vector2>} points - Array of Vector2 points
7742
8007
  * @param {number} rgba - Color of the polygon as a 32-bit integer
@@ -7750,13 +8015,13 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
7750
8015
  function glDrawPointsTransform(points, rgba, x, y, sx, sy, angle, tristrip=true)
7751
8016
  {
7752
8017
  const pointsOut = [];
8018
+ const sa = sin(-angle);
8019
+ const ca = cos(-angle);
7753
8020
  for (const p of points)
7754
8021
  {
7755
8022
  // transform the point
7756
8023
  const px = p.x*sx;
7757
8024
  const py = p.y*sy;
7758
- const sa = sin(-angle);
7759
- const ca = cos(-angle);
7760
8025
  pointsOut.push(vec2(x + ca*px - sa*py, y + sa*px + ca*py));
7761
8026
  }
7762
8027
  const drawPoints = tristrip ? glPolyStrip(pointsOut) : pointsOut;
@@ -7788,11 +8053,13 @@ function glDrawPoints(points, rgba)
7788
8053
  {
7789
8054
  if (!glEnable || points.length < 3)
7790
8055
  return; // needs at least 3 points to have area
7791
-
8056
+
7792
8057
  // flush if there is not enough room or if different blend mode
7793
8058
  const vertCount = points.length + 2;
7794
8059
  if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
7795
8060
  glFlush();
8061
+ ASSERT(vertCount < gl_MAX_POLY_VERTEXES, 'poly exceeds max batch size');
8062
+ if (vertCount >= gl_MAX_POLY_VERTEXES) return; // release-build safety net
7796
8063
  glSetPolyMode();
7797
8064
 
7798
8065
  // setup triangle strip with degenerate verts at start and end
@@ -7816,11 +8083,13 @@ function glDrawColoredPoints(points, pointColors)
7816
8083
  {
7817
8084
  if (!glEnable || points.length < 3)
7818
8085
  return; // needs at least 3 points to have area
7819
-
8086
+
7820
8087
  // flush if there is not enough room or if different blend mode
7821
8088
  const vertCount = points.length + 2;
7822
8089
  if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
7823
8090
  glFlush();
8091
+ ASSERT(vertCount < gl_MAX_POLY_VERTEXES, 'poly exceeds max batch size');
8092
+ if (vertCount >= gl_MAX_POLY_VERTEXES) return; // release-build safety net
7824
8093
  glSetPolyMode();
7825
8094
 
7826
8095
  // setup triangle strip with degenerate verts at start and end
@@ -7890,7 +8159,8 @@ function glMakeOutline(points, width, wrap=true)
7890
8159
  const strip = [];
7891
8160
  const n = points.length;
7892
8161
  const e = 1e-6;
7893
- const miterLimit = width*100;
8162
+ // miter ratio cap (dimensionless, matches SVG/Canvas2D convention)
8163
+ const miterLimit = 10;
7894
8164
  for (let i = 0; i < n; i++)
7895
8165
  {
7896
8166
  // for each vertex, calculate normal based on adjacent edges
@@ -8146,7 +8416,7 @@ function drawEngineLogo(t)
8146
8416
  x.closePath();
8147
8417
  gradient(0, Y, 0, Y+H,C);
8148
8418
  }
8149
- const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
8419
+ const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%)`:'#000';
8150
8420
 
8151
8421
  // center and fit to screen
8152
8422
  const alpha = oscillate(1,1,t);
@@ -8373,7 +8643,8 @@ class Medal
8373
8643
  /** @property {boolean} - Is the medal unlocked? */
8374
8644
  this.unlocked = false;
8375
8645
 
8376
- // load the source image if provided
8646
+ /** @property {HTMLImageElement|undefined} - Source image for the medal icon, if any */
8647
+ this.image = undefined;
8377
8648
  if (src)
8378
8649
  (this.image = new Image).src = src;
8379
8650
 
@@ -8536,13 +8807,18 @@ class NewgroundsPlugin
8536
8807
  ASSERT(!cipher || cryptoJS, 'must provide cryptojs if there is a cipher');
8537
8808
 
8538
8809
  newgrounds = this; // set global newgrounds object
8810
+ /** @property {string} - The newgrounds App ID */
8539
8811
  this.app_id = app_id;
8812
+ /** @property {string|undefined} - AES-128/Base64 encryption key, if any */
8540
8813
  this.cipher = cipher;
8814
+ /** @property {Object|undefined} - CryptoJS instance used when cipher is set */
8541
8815
  this.cryptoJS = cryptoJS;
8816
+ /** @property {string} - Hostname used when logging views */
8542
8817
  this.host = location ? location.hostname : '';
8543
8818
 
8544
8819
  // get session id from url search params
8545
8820
  const url = new URL(location.href);
8821
+ /** @property {string|null} - Newgrounds session id from the URL (null when not logged in) */
8546
8822
  this.session_id = url.searchParams.get('ngio_session_id');
8547
8823
 
8548
8824
  if (!this.session_id)
@@ -8550,7 +8826,20 @@ class NewgroundsPlugin
8550
8826
 
8551
8827
  // get medals
8552
8828
  const medalsResult = this.call('Medal.getList');
8553
- this.medals = medalsResult ? medalsResult.result.data['medals'] : [];
8829
+
8830
+ // bail early if the first call failed (offline / bad session /
8831
+ // server error) so we don't block the main thread on more sync
8832
+ // XHRs that are guaranteed to also fail
8833
+ if (!medalsResult || !medalsResult.result || medalsResult.result.error)
8834
+ {
8835
+ debugMedals && LOG('Newgrounds session unavailable; skipping plugin init');
8836
+ this.medals = [];
8837
+ this.scoreboards = [];
8838
+ return;
8839
+ }
8840
+
8841
+ /** @property {Array} - Medals fetched from Newgrounds (empty until session is active) */
8842
+ this.medals = medalsResult.result.data?.['medals'] || [];
8554
8843
  debugMedals && LOG(this.medals);
8555
8844
  for (const newgroundsMedal of this.medals)
8556
8845
  {
@@ -8570,10 +8859,11 @@ class NewgroundsPlugin
8570
8859
  medal.description = medal.description + ` (${ medal.value })`;
8571
8860
  }
8572
8861
  }
8573
-
8862
+
8574
8863
  // get scoreboards
8575
8864
  const scoreboardResult = this.call('ScoreBoard.getBoards');
8576
- this.scoreboards = scoreboardResult ? scoreboardResult.result.data.scoreboards : [];
8865
+ /** @property {Array} - Scoreboards fetched from Newgrounds */
8866
+ this.scoreboards = scoreboardResult?.result?.data?.scoreboards || [];
8577
8867
  debugMedals && LOG(this.scoreboards);
8578
8868
 
8579
8869
  // keep the session alive with a ping every minute
@@ -8757,10 +9047,14 @@ class PostProcessPlugin
8757
9047
  function postProcessRender()
8758
9048
  {
8759
9049
  if (headlessMode || !glEnable) return;
8760
-
9050
+
8761
9051
  // clear out the buffer
8762
9052
  glFlush();
8763
9053
 
9054
+ // ensure we render to the default framebuffer (in case any earlier
9055
+ // caller this frame left a render target bound)
9056
+ glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
9057
+
8764
9058
  // setup shader program to draw a quad
8765
9059
  glContext.useProgram(postProcess.shader);
8766
9060
  glContext.bindVertexArray(postProcess.vao);
@@ -8801,6 +9095,9 @@ class PostProcessPlugin
8801
9095
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, glCanvas);
8802
9096
  }
8803
9097
 
9098
+ // restore default so subsequent dynamic texture uploads aren't flipped
9099
+ glContext.pixelStorei(glContext.UNPACK_FLIP_Y_WEBGL, false);
9100
+
8804
9101
  // force it to set instanced mode
8805
9102
  glSetInstancedMode(true);
8806
9103
  }
@@ -9427,11 +9724,17 @@ class UISystemPlugin
9427
9724
  * @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */
9428
9725
  setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver)
9429
9726
  {
9430
- function setCallback(callback, listenerType)
9727
+ // remove any prior listeners so repeated setup calls don't stack
9728
+ if (this._dragListeners)
9729
+ for (const [type, listener] of this._dragListeners)
9730
+ document.removeEventListener(type, listener);
9731
+ this._dragListeners = [];
9732
+ const setCallback = (callback, listenerType)=>
9431
9733
  {
9432
- function listener(e) { e.preventDefault(); callback && callback(e); }
9734
+ const listener = (e)=> { e.preventDefault(); callback && callback(e); };
9433
9735
  document.addEventListener(listenerType, listener);
9434
- }
9736
+ this._dragListeners.push([listenerType, listener]);
9737
+ };
9435
9738
  setCallback(onDrop, 'drop');
9436
9739
  setCallback(onDragEnter, 'dragenter');
9437
9740
  setCallback(onDragLeave, 'dragleave');
@@ -9755,6 +10058,15 @@ class UIObject
9755
10058
  if (this.destroyed)
9756
10059
  return;
9757
10060
 
10061
+ // clear ui-system references that point at this object so events
10062
+ // don't keep firing against a destroyed target (especially the
10063
+ // keydown listener attached for keyInputObject)
10064
+ if (uiSystem.activeObject === this) uiSystem.activeObject = undefined;
10065
+ if (uiSystem.hoverObject === this) uiSystem.hoverObject = undefined;
10066
+ if (uiSystem.lastHoverObject === this) uiSystem.lastHoverObject = undefined;
10067
+ if (uiSystem.navigationObject === this) uiSystem.navigationObject = undefined;
10068
+ if (uiSystem.keyInputObject === this) uiSystem.keyInputObject = undefined;
10069
+
9758
10070
  // disconnect from parent and destroy children
9759
10071
  this.destroyed = 1;
9760
10072
  this.parent?.removeChild(this);
@@ -9763,6 +10075,8 @@ class UIObject
9763
10075
  child.parent = undefined;
9764
10076
  child.destroy();
9765
10077
  }
10078
+ // clear references so destroyed children can be GC'd
10079
+ this.children.length = 0;
9766
10080
  }
9767
10081
 
9768
10082
  /** Check if the mouse is overlapping this ui object
@@ -10352,6 +10666,7 @@ class UISlider extends UIObject
10352
10666
  {
10353
10667
  // toggle value between 0 and 1
10354
10668
  this.value = this.value ? 0 : 1;
10669
+ this.onChange();
10355
10670
  this.onRelease();
10356
10671
  super.navigatePressed();
10357
10672
  }
@@ -10711,6 +11026,11 @@ class Box2dObject extends EngineObject
10711
11026
  // destroy physics body, fixtures, and joints
10712
11027
  ASSERT(this.body, 'Box2dObject has no body to destroy');
10713
11028
  box2d.world.DestroyBody(this.body);
11029
+
11030
+ // remove from tracked list so paused / headless sessions don't leak
11031
+ const i = box2d.objects.indexOf(this);
11032
+ if (i >= 0)
11033
+ box2d.objects.splice(i, 1);
10714
11034
  super.destroy();
10715
11035
  }
10716
11036
 
@@ -10799,7 +11119,9 @@ class Box2dObject extends EngineObject
10799
11119
  /** Add a box shape to the body
10800
11120
  * @param {Vector2} [size]
10801
11121
  * @param {Vector2} [offset]
10802
- * @param {number} [angle]
11122
+ * @param {number} [angle] - LittleJS convention (clockwise positive).
11123
+ * Negated internally to match Box2D's CCW-positive convention so the
11124
+ * fixture aligns with the same angle passed to drawRect/drawTile.
10803
11125
  * @param {number} [density]
10804
11126
  * @param {number} [friction]
10805
11127
  * @param {number} [restitution]
@@ -10812,7 +11134,7 @@ class Box2dObject extends EngineObject
10812
11134
  ASSERT(isNumber(angle), 'angle must be a number');
10813
11135
 
10814
11136
  const shape = new box2d.instance.b2PolygonShape();
10815
- shape.SetAsBox(size.x/2, size.y/2, box2d.vec2dTo(offset), angle);
11137
+ shape.SetAsBox(size.x/2, size.y/2, box2d.vec2dTo(offset), -angle);
10816
11138
  return this.addShape(shape, density, friction, restitution, isSensor);
10817
11139
  }
10818
11140
 
@@ -11114,9 +11436,10 @@ class Box2dObject extends EngineObject
11114
11436
  {
11115
11437
  const data = new box2d.instance.b2MassData();
11116
11438
  this.body.GetMassData(data);
11117
- localCenter && data.set_center(box2d.vec2dTo(localCenter));
11118
- mass && data.set_mass(mass);
11119
- momentOfInertia && data.set_I(momentOfInertia);
11439
+ // use !== undefined so setMass(0) (static-equivalent) isn't silently ignored
11440
+ if (localCenter !== undefined) data.set_center(box2d.vec2dTo(localCenter));
11441
+ if (mass !== undefined) data.set_mass(mass);
11442
+ if (momentOfInertia !== undefined) data.set_I(momentOfInertia);
11120
11443
  this.body.SetMassData(data);
11121
11444
  }
11122
11445
 
@@ -12288,6 +12611,8 @@ class Box2dPlugin
12288
12611
  const fixtureB = contact.GetFixtureB();
12289
12612
  const objectA = fixtureA.GetBody().object;
12290
12613
  const objectB = fixtureB.GetBody().object;
12614
+ // raw user-created b2Bodies may have no .object — skip those
12615
+ if (!objectA || !objectB) return;
12291
12616
  objectA.beginContact(objectB);
12292
12617
  objectB.beginContact(objectA);
12293
12618
  }
@@ -12298,6 +12623,7 @@ class Box2dPlugin
12298
12623
  const fixtureB = contact.GetFixtureB();
12299
12624
  const objectA = fixtureA.GetBody().object;
12300
12625
  const objectB = fixtureB.GetBody().object;
12626
+ if (!objectA || !objectB) return;
12301
12627
  objectA.endContact(objectB);
12302
12628
  objectB.endContact(objectA);
12303
12629
  };
@@ -12676,7 +13002,7 @@ async function box2dInit()
12676
13002
  debugDraw.DrawTransform = function(transform)
12677
13003
  {
12678
13004
  transform = box2d.instance.wrapPointer(transform, box2d.instance.b2Transform);
12679
- const pos = vec2(transform.get_p());
13005
+ const pos = box2d.vec2From(transform.get_p());
12680
13006
  const angle = -transform.get_q().GetAngle();
12681
13007
  const p1 = vec2(1,0), c1 = rgb(.75,0,0,.8);
12682
13008
  const p2 = vec2(0,1), c2 = rgb(0,.75,0,.8);
@@ -12885,13 +13211,21 @@ class Tween
12885
13211
  }
12886
13212
  ASSERT(isNumber(duration) && duration > 0, 'Tween duration must be > 0');
12887
13213
 
13214
+ /** @property {function(number|Vector2|Color):void} - Called with the interpolated value each frame */
12888
13215
  this.callback = callback;
13216
+ /** @property {number|Vector2|Color} - Starting value */
12889
13217
  this.start = start;
13218
+ /** @property {number|Vector2|Color} - Ending value */
12890
13219
  this.end = end;
13220
+ /** @property {number} - Total duration in seconds */
12891
13221
  this.duration = duration;
13222
+ /** @property {number} - Remaining time in seconds (counts down from duration to 0) */
12892
13223
  this.life = duration;
13224
+ /** @property {function(number):number} - Easing curve mapping [0,1] -> [0,1] */
12893
13225
  this.ease = options.ease || Ease.LINEAR;
13226
+ /** @property {boolean} - If true, advance even when the game is paused */
12894
13227
  this.useRealTime = !!options.useRealTime;
13228
+ /** @property {boolean} - If true, stop advancing until cleared */
12895
13229
  this.paused = !!options.paused;
12896
13230
 
12897
13231
  /** @private completion callback set by then(), loop(), pingPong(). */
@@ -13072,7 +13406,7 @@ const Ease =
13072
13406
  * @param {number} x
13073
13407
  * @returns {number}
13074
13408
  * @memberof TweenSystem */
13075
- EXPO: (x) => 2 ** (10 * x - 10),
13409
+ EXPO: (x) => x === 0 ? 0 : 2 ** (10 * x - 10),
13076
13410
 
13077
13411
  /** Back ease-in: overshoots backward at the start before snapping forward.
13078
13412
  * @param {number} x
@@ -13085,6 +13419,8 @@ const Ease =
13085
13419
  * @returns {number}
13086
13420
  * @memberof TweenSystem */
13087
13421
  ELASTIC: (x) =>
13422
+ x === 0 ? 0 :
13423
+ x === 1 ? 1 :
13088
13424
  -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
13089
13425
 
13090
13426
  /** Spring-like ease-out: oscillates outward after passing the target.
@@ -13245,29 +13581,32 @@ function tweenProperty(target, propertyPath, start, end, duration = 1, options =
13245
13581
  }
13246
13582
 
13247
13583
  // Continuation that schedules the next loop iteration when one finishes.
13248
- // Called from the completed tween's `then` slot. Decrements the counter and
13249
- // only spawns a new tween if more iterations remain.
13250
- function loopContinuation(prev)
13251
- {
13252
- if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
13253
- const next = new Tween(prev.callback, prev.start, prev.end, prev.duration,
13254
- { ease: prev.ease, useRealTime: prev.useRealTime });
13255
- next.loopRemaining = prev.loopRemaining === Infinity
13256
- ? Infinity
13257
- : prev.loopRemaining - 1;
13258
- next.thenCallback = () => loopContinuation(next);
13259
- }
13260
-
13261
- // Continuation for pingPong: spawns a new tween with start and end swapped.
13262
- function pingPongContinuation(prev)
13263
- {
13264
- if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
13265
- const next = new Tween(prev.callback, prev.end, prev.start, prev.duration,
13266
- { ease: prev.ease, useRealTime: prev.useRealTime });
13267
- next.loopRemaining = prev.loopRemaining === Infinity
13268
- ? Infinity
13269
- : prev.loopRemaining - 1;
13270
- next.thenCallback = () => pingPongContinuation(next);
13584
+ // Reuses the same Tween object across iterations so the user's handle
13585
+ // from `.loop()` keeps working calling `.stop()` mid-loop now cancels
13586
+ // the entire chain instead of just the current iteration.
13587
+ function loopContinuation(tween)
13588
+ {
13589
+ if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
13590
+ if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
13591
+ tween.life = tween.duration;
13592
+ tween.thenCallback = () => loopContinuation(tween);
13593
+ tweenActive.push(tween);
13594
+ // snap to start for the new iteration (matches Tween constructor behavior)
13595
+ tween.callback(tween.interp(tween.duration));
13596
+ }
13597
+
13598
+ // Continuation for pingPong: swaps start and end on the same tween each iteration.
13599
+ function pingPongContinuation(tween)
13600
+ {
13601
+ if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
13602
+ if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
13603
+ const tmp = tween.start;
13604
+ tween.start = tween.end;
13605
+ tween.end = tmp;
13606
+ tween.life = tween.duration;
13607
+ tween.thenCallback = () => pingPongContinuation(tween);
13608
+ tweenActive.push(tween);
13609
+ tween.callback(tween.interp(tween.duration));
13271
13610
  }
13272
13611
 
13273
13612
  /** Engine plugin hook: advance every active tween by the appropriate delta.
@@ -13424,7 +13763,9 @@ class PathFinder
13424
13763
  // .size + .getCollisionData.
13425
13764
  if (isVector2(source))
13426
13765
  {
13766
+ /** @property {Vector2} - Grid dimensions in tiles */
13427
13767
  this.size = source.floor();
13768
+ /** @property {TileCollisionLayer|undefined} - Tile layer driving walkability, if any */
13428
13769
  this.tileLayer = undefined;
13429
13770
  }
13430
13771
  else
@@ -13436,13 +13777,18 @@ class PathFinder
13436
13777
  }
13437
13778
 
13438
13779
  // Tunables (public, freely re-assignable).
13780
+ /** @property {number} - A* heuristic multiplier (1 = admissible, higher = greedier) */
13439
13781
  this.heuristicWeight = 1;
13440
- this.maxLoop = 500;
13782
+ /** @property {number} - Maximum A* expansions before giving up */
13783
+ this.maxLoop = 1e3;
13784
+ /** @property {boolean} - If true, post-process paths with two-pass smoothing */
13441
13785
  this.smoothPath = true;
13786
+ /** @property {boolean} - If true, draw debug visualization during findPath */
13442
13787
  this.debug = false;
13443
- this.debugTime = 2;
13788
+ /** @property {number} - Debug primitive lifetime in seconds (0 disables drawing) */
13789
+ this.debugTime = 1;
13444
13790
 
13445
- // Pre-allocate the node array one node per tile, reused across calls.
13791
+ /** @property {Array<PathFinderNode>} - Flat row-major array of size.x*size.y nodes */
13446
13792
  this.nodes = new Array(this.size.x * this.size.y);
13447
13793
  for (let y = 0; y < this.size.y; ++y)
13448
13794
  for (let x = 0; x < this.size.x; ++x)
@@ -13594,11 +13940,13 @@ class PathFinder
13594
13940
  if (dx !== 0 && dy !== 0)
13595
13941
  {
13596
13942
  // Diagonal step: refuse if either cardinal neighbor is
13597
- // blocked or has cost. Prevents cutting through corners.
13943
+ // blocked. Prevents cutting through walls at corners.
13944
+ // (Costed-but-walkable cardinals do not block — diagonal
13945
+ // movement around expensive terrain is standard A*.)
13598
13946
  const card1 = this.getNode(current.pos.x + dx, current.pos.y);
13599
- if (!card1 || card1.cost > 0 || !card1.walkable) continue;
13947
+ if (!card1 || !card1.walkable) continue;
13600
13948
  const card2 = this.getNode(current.pos.x, current.pos.y + dy);
13601
- if (!card2 || card2.cost > 0 || !card2.walkable) continue;
13949
+ if (!card2 || !card2.walkable) continue;
13602
13950
  stepCost = PATHFINDER_DIAGONAL_COST;
13603
13951
  }
13604
13952
 
@@ -13616,9 +13964,12 @@ class PathFinder
13616
13964
  // Best path so far through neighbor — record it.
13617
13965
  neighbor.parent = current;
13618
13966
  neighbor.g = tentativeG;
13619
- const gdx = endNode.pos.x - neighbor.pos.x;
13620
- const gdy = endNode.pos.y - neighbor.pos.y;
13621
- neighbor.f = neighbor.g + (gdx * gdx + gdy * gdy) * this.heuristicWeight;
13967
+ // Octile heuristic tightest admissible distance for an
13968
+ // 8-connected grid with cardinal cost 1 and diagonal cost √2.
13969
+ const adx = abs(endNode.pos.x - neighbor.pos.x);
13970
+ const ady = abs(endNode.pos.y - neighbor.pos.y);
13971
+ const h = max(adx, ady) + (Math.SQRT2 - 1) * min(adx, ady);
13972
+ neighbor.f = neighbor.g + h * this.heuristicWeight;
13622
13973
  }
13623
13974
  }
13624
13975
 
@@ -13900,6 +14251,24 @@ class PathFinder
13900
14251
  path.push(original[original.length - 1]);
13901
14252
  }
13902
14253
 
14254
+ /** Drop any middle node that lies exactly on the line through its two
14255
+ * neighbors. Backstop for the smoothing passes — the corners pass
14256
+ * intentionally keeps truly-straight runs, and the string-pulling pass
14257
+ * checks collinearity against the original path, not the in-progress
14258
+ * result, so it can leave 3+ collinear nodes in some edge cases.
14259
+ * @param {PathFinderNode[]} path
14260
+ * @private */
14261
+ dropCollinearNodes(path)
14262
+ {
14263
+ for (let i = path.length - 2; i >= 1; --i)
14264
+ {
14265
+ const a = path[i - 1], b = path[i], c = path[i + 1];
14266
+ if ((b.pos.x - a.pos.x) * (c.pos.y - a.pos.y) ===
14267
+ (b.pos.y - a.pos.y) * (c.pos.x - a.pos.x))
14268
+ path.splice(i, 1);
14269
+ }
14270
+ }
14271
+
13903
14272
  /** Lookup helper: true when the node at tile coords (x, y) is in-bounds
13904
14273
  * and clear (walkable, zero-cost). Used by isLineClear's hot path.
13905
14274
  * @param {number} x
@@ -14067,6 +14436,7 @@ class PathFinder
14067
14436
  {
14068
14437
  this.smoothPathCorners(nodePath);
14069
14438
  this.smoothPathStringPull(nodePath);
14439
+ this.dropCollinearNodes(nodePath);
14070
14440
  }
14071
14441
 
14072
14442
  // Convert to world-space Vector2 path. Return copies, not live node