littlejsengine 1.18.12 → 1.18.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.18.12';
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);
@@ -425,7 +436,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
425
436
  promises.push(loadTexture(0));
426
437
 
427
438
  // load engine font image
428
- promises.push(fontImageInit());
439
+ promises.push(imageFontInit());
429
440
 
430
441
  if (showSplashScreen)
431
442
  {
@@ -650,12 +661,8 @@ function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false, s
650
661
  ASSERT(isNumber(time), 'time must be a number');
651
662
  ASSERT(isNumber(angle), 'angle must be a number');
652
663
 
653
- if (typeof size === 'number')
654
- size = vec2(size); // allow passing in floats
655
664
  if (isColor(color))
656
665
  color = color.toString();
657
- pos = pos.copy();
658
- size = size.copy();
659
666
  const timer = new Timer(time);
660
667
  debugPrimitives.push({pos:pos.copy(), size:size.copy(), color, timer, angle, fill, screenSpace});
661
668
  }
@@ -725,7 +732,7 @@ function debugPoint(pos, color, time, angle, screenSpace=false)
725
732
  * @param {number} [time]
726
733
  * @param {boolean} [screenSpace]
727
734
  * @memberof Debug */
728
- function debugLine(posA, posB, color, width=.1, time, screenSpace=false)
735
+ function debugLine(posA, posB, color, width=.1, time=0, screenSpace=false)
729
736
  {
730
737
  ASSERT(isVector2(posA), 'posA must be a vec2');
731
738
  ASSERT(isVector2(posB), 'posB must be a vec2');
@@ -763,7 +770,7 @@ function debugOverlap(posA, sizeA, posB, sizeB, color, time, screenSpace=false)
763
770
  debugRect(minPos.lerp(maxPos,.5), maxPos.subtract(minPos), color, time, 0, false, screenSpace);
764
771
  }
765
772
 
766
- /** Draw a debug axis aligned bounding box in world space
773
+ /** Draw debug text in world space
767
774
  * @param {string|number} text
768
775
  * @param {Vector2} pos
769
776
  * @param {number} [size]
@@ -1094,7 +1101,7 @@ function debugRender()
1094
1101
  continue;
1095
1102
  if (parseInt(i) < 3)
1096
1103
  mousePressed += i + ' ' ;
1097
- else if (keyIsDown(i, 0))
1104
+ else
1098
1105
  keysPressed += i + ' ' ;
1099
1106
  }
1100
1107
  mousePressed && debugContext.fillText('Mouse: ' + mousePressed, x, y += h);
@@ -1353,19 +1360,19 @@ const max = Math.max;
1353
1360
  * @param {number} x
1354
1361
  * @return {number}
1355
1362
  * @memberof Math */
1356
- const sign = Math.sign;
1363
+ const sign = (x) => Math.sign(x);
1357
1364
 
1358
1365
  /** Returns hypotenuse of values passed in
1359
1366
  * @param {...number} values
1360
1367
  * @return {number}
1361
1368
  * @memberof Math */
1362
- const hypot = Math.hypot;
1369
+ const hypot = (...values) => Math.hypot(...values);
1363
1370
 
1364
1371
  /** Returns log2 of value passed in
1365
1372
  * @param {number} x
1366
1373
  * @return {number}
1367
1374
  * @memberof Math */
1368
- const log2 = Math.log2;
1375
+ const log2 = (x) => Math.log2(x);
1369
1376
 
1370
1377
  /** Returns sin of value passed in
1371
1378
  * @param {number} x
@@ -1507,7 +1514,8 @@ function isOverlapping(posA, sizeA, posB, sizeB=vec2())
1507
1514
  const dy = (posA.y - posB.y)*2;
1508
1515
  const sx = sizeA.x + sizeB.x;
1509
1516
  const sy = sizeA.y + sizeB.y;
1510
- 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;
1511
1519
  }
1512
1520
 
1513
1521
  /** Returns true if a line segment is intersecting an axis aligned box
@@ -1596,7 +1604,7 @@ function isStringLike(s) { return s != null && typeof s?.toString() === 'string'
1596
1604
  /**
1597
1605
  * Check if object is an array
1598
1606
  * @param {any} a
1599
- * @return {boolean}
1607
+ * @return {a is Array<any>}
1600
1608
  * @memberof Math */
1601
1609
  function isArray(a) { return Array.isArray(a); }
1602
1610
 
@@ -1734,7 +1742,13 @@ function randVec2(length=1) { return new Vector2().setAngle(rand(2*PI), length);
1734
1742
  * @return {Vector2}
1735
1743
  * @memberof Random */
1736
1744
  function randInCircle(radius=1, minRadius=0)
1737
- { 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
+ }
1738
1752
 
1739
1753
  /** Returns a random color between the two passed in colors, combine components if linear
1740
1754
  * @param {Color} [colorA=WHITE]
@@ -1804,7 +1818,14 @@ class RandomGenerator
1804
1818
  * @param {number} [valueA]
1805
1819
  * @param {number} [valueB]
1806
1820
  * @return {number} */
1807
- 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
+ }
1808
1829
 
1809
1830
  /** Returns a random angle between -PI and PI
1810
1831
  * @return {number} */
@@ -2517,9 +2538,15 @@ class Timer
2517
2538
  * @return {number} */
2518
2539
  get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
2519
2540
 
2520
- /** 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).
2521
2543
  * @return {number} */
2522
- 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
+ }
2523
2550
 
2524
2551
  /** Get the time this timer was set to, returns 0 if not set
2525
2552
  * @return {number} */
@@ -2546,9 +2573,9 @@ class Timer
2546
2573
  * @memberof Utilities */
2547
2574
  function formatTime(t)
2548
2575
  {
2549
- const sign = t < 0 ? '-' : '';
2576
+ const signStr = t < 0 ? '-' : '';
2550
2577
  t = abs(t)|0;
2551
- return sign + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
2578
+ return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
2552
2579
  }
2553
2580
 
2554
2581
  /** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
@@ -2634,15 +2661,20 @@ function shareURL(title, url, callback)
2634
2661
  function readSaveData(saveName, defaultSaveData)
2635
2662
  {
2636
2663
  ASSERT(isStringLike(saveName), 'loadData requires saveName string');
2637
-
2638
- // replace undefined values with defaults; tolerate corrupt JSON
2639
- const data = localStorage[saveName];
2664
+
2665
+ // tolerate localStorage being unavailable (iOS private mode, sandboxed
2666
+ // iframes) and corrupt JSON in stored data
2640
2667
  let loadedData = {};
2641
- if (data)
2668
+ try
2642
2669
  {
2643
- try { loadedData = JSON.parse(data); }
2644
- 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
+ }
2645
2676
  }
2677
+ catch { LOG('readSaveData: localStorage unavailable — using defaults'); }
2646
2678
  return { ...defaultSaveData, ...loadedData };
2647
2679
  }
2648
2680
 
@@ -2653,7 +2685,9 @@ function readSaveData(saveName, defaultSaveData)
2653
2685
  function writeSaveData(saveName, saveData)
2654
2686
  {
2655
2687
  ASSERT(isStringLike(saveName), 'saveData requires saveName string');
2656
- 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); }
2657
2691
  }
2658
2692
 
2659
2693
  ///////////////////////////////////////////////////////////////////////////////
@@ -2748,7 +2782,7 @@ let canvasColorTiles = true;
2748
2782
 
2749
2783
  /** Color to clear the canvas to before render, does not clear if alpha is 0
2750
2784
  * @type {Color}
2751
- * @memberof Draw */
2785
+ * @memberof Settings */
2752
2786
  let canvasClearColor = CLEAR_BLACK;
2753
2787
 
2754
2788
  /** The max size of the canvas, centered if window is larger
@@ -2948,7 +2982,8 @@ let touchInputEnable = true;
2948
2982
  let touchGamepadEnable = false;
2949
2983
 
2950
2984
  /** True if touch gamepad should have start button in the center
2951
- * - 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)
2952
2987
  * - When the game is paused, any touch will press the button
2953
2988
  * - Set size to enable the center button
2954
2989
  * @type {number}
@@ -3360,7 +3395,7 @@ class EngineObject
3360
3395
  this.color = color.copy();
3361
3396
  /** @property {Color} - Additive color to apply when rendered */
3362
3397
  this.additiveColor = undefined;
3363
- /** @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. */
3364
3399
  this.mirror = false;
3365
3400
  /** @property {boolean} - Has object been destroyed? */
3366
3401
  this.destroyed = false;
@@ -3446,6 +3481,9 @@ class EngineObject
3446
3481
  // child objects do not have physics
3447
3482
  ASSERT(!this.parent);
3448
3483
 
3484
+ // bail if a collision callback destroyed us mid-frame
3485
+ if (this.destroyed) return;
3486
+
3449
3487
  if (this.clampSpeed)
3450
3488
  {
3451
3489
  // limit max speed to prevent missing collisions
@@ -3501,6 +3539,8 @@ class EngineObject
3501
3539
 
3502
3540
  // notify objects of collision and check if should be resolved
3503
3541
  const collide1 = this.collideWithObject(o);
3542
+ // callback may have destroyed us; stop resolving against more objects
3543
+ if (this.destroyed) return;
3504
3544
  const collide2 = o.collideWithObject(this);
3505
3545
  if (!collide1 || !collide2) continue;
3506
3546
 
@@ -3596,13 +3636,17 @@ class EngineObject
3596
3636
  const restitution = max(this.restitution, hitLayer.restitution);
3597
3637
  if (isBlockedX)
3598
3638
  {
3599
- // 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)
3600
3642
  const epsilon = 1e-3;
3601
- const maxMoveUp = .1;
3602
- const y = floor(oldPos.y-this.size.y/2+1) +
3603
- this.size.y/2 + epsilon;
3604
- const delta = y - this.pos.y;
3605
- 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)
3606
3650
  if (!tileCollisionTest(vec2(this.pos.x, y), this.size, this))
3607
3651
  {
3608
3652
  this.pos.y = y;
@@ -3742,6 +3786,8 @@ class EngineObject
3742
3786
  * @return {EngineObject} The child object added */
3743
3787
  addChild(child, localPos=vec2(), localAngle=0)
3744
3788
  {
3789
+ ASSERT(!this.destroyed, 'cannot add child to destroyed object');
3790
+ if (this.destroyed) return child;
3745
3791
  ASSERT(!child.parent && !this.children.includes(child));
3746
3792
  ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
3747
3793
  ASSERT(child !== this, 'cannot add self as child');
@@ -3758,10 +3804,7 @@ class EngineObject
3758
3804
  removeChild(child)
3759
3805
  {
3760
3806
  ASSERT(child.parent === this && this.children.includes(child));
3761
- ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
3762
- const index = this.children.indexOf(child);
3763
- ASSERT(index >= 0, 'child not found in children array');
3764
- index >= 0 && this.children.splice(index, 1);
3807
+ this.children.splice(this.children.indexOf(child), 1);
3765
3808
  child.parent = undefined;
3766
3809
  }
3767
3810
 
@@ -3836,7 +3879,7 @@ class EngineObject
3836
3879
  * - Optimized tile sheet sprite rendering using WebGL batching
3837
3880
  * - Primitive drawing for polygons, ellipses, and lines
3838
3881
  * - Tile-based rendering with TileInfo and TextureInfo classes
3839
- * - Text rendering with custom fonts and FontImage support
3882
+ * - Text rendering with custom fonts and ImageFont support
3840
3883
  * - Color and additive color blending for effects
3841
3884
  * - Rotation, mirroring, and scaling transformations
3842
3885
  * - Camera system with position, scale, and rotation
@@ -3932,7 +3975,7 @@ let primitiveCount;
3932
3975
  * tile(1, 16, 3) // a tile at index 1 of size 16 on texture 3
3933
3976
  * tile(vec2(4,8), vec2(30,10)) // a tile at index (4,8) with a size of (30,10)
3934
3977
  * @memberof Draw */
3935
- 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)
3936
3979
  {
3937
3980
  ASSERT(isVector2(index) || typeof index === 'number', 'index must be a vec2 or number');
3938
3981
  ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
@@ -3983,8 +4026,8 @@ class TileInfo
3983
4026
  * @param {Vector2} [pos=vec2()] - Top left corner of tile in pixels
3984
4027
  * @param {Vector2} [size] - Size of tile in pixels
3985
4028
  * @param {TextureInfo} [textureInfo] - Texture info to use
3986
- * @param {number} [padding] - How many pixels padding around tiles
3987
- * @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)
3988
4031
  */
3989
4032
  constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed)
3990
4033
  {
@@ -4016,7 +4059,7 @@ class TileInfo
4016
4059
  ASSERT(typeof frame === 'number');
4017
4060
  const w = this.size.x + this.padding*2;
4018
4061
  const x = frame*w;
4019
- 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!');
4020
4063
  return this.offset(new Vector2(x));
4021
4064
  }
4022
4065
 
@@ -4105,7 +4148,7 @@ class TextureInfo
4105
4148
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
4106
4149
  * @memberof Draw */
4107
4150
  function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
4108
- angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace, context)
4151
+ angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=false, context)
4109
4152
  {
4110
4153
  ASSERT(isVector2(pos), 'pos must be a vec2');
4111
4154
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -4148,10 +4191,8 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
4148
4191
  }
4149
4192
  else
4150
4193
  {
4151
- // untextured: glDrawUntextured picks the optimal path (poly
4152
- // tristrip if already in poly mode, otherwise instanced with
4153
- // uvs/rgba zeroed). Color+additive are folded together to match
4154
- // the Canvas2D path's color.add(additiveColor) on line ~337.
4194
+ // untextured: fold color+additive to match the Canvas2D path's
4195
+ // color.add(additiveColor) on line ~337.
4155
4196
  const combined = additiveColor ? color.add(additiveColor) : color;
4156
4197
  glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
4157
4198
  }
@@ -4364,7 +4405,7 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
4364
4405
  * @param {boolean} [screenSpace]
4365
4406
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
4366
4407
  * @memberof Draw */
4367
- 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)
4368
4409
  {
4369
4410
  ASSERT(isArray(points), 'points must be an array');
4370
4411
  ASSERT(isNumber(width), 'width must be a number');
@@ -4413,7 +4454,7 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
4413
4454
  * @param {boolean} [screenSpace]
4414
4455
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
4415
4456
  * @memberof Draw */
4416
- 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)
4417
4458
  {
4418
4459
  const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
4419
4460
  const size = vec2(width, halfDelta.length()*2);
@@ -4429,9 +4470,9 @@ function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, sc
4429
4470
  * @param {Vector2} [size=vec2(1)]
4430
4471
  * @param {number} [sides]
4431
4472
  * @param {Color} [color=WHITE]
4432
- * @param {number} [angle]
4433
4473
  * @param {number} [lineWidth]
4434
4474
  * @param {Color} [lineColor=BLACK]
4475
+ * @param {number} [angle]
4435
4476
  * @param {boolean} [useWebGL=glEnable]
4436
4477
  * @param {boolean} [screenSpace]
4437
4478
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
@@ -4524,7 +4565,7 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
4524
4565
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
4525
4566
 
4526
4567
  // clamp line width to prevent artifacts
4527
- lineWidth = clamp(lineWidth, 0, Math.min(size.x, size.y));
4568
+ lineWidth = clamp(lineWidth, 0, min(size.x, size.y));
4528
4569
 
4529
4570
  if (useWebGL && glEnable)
4530
4571
  {
@@ -4566,24 +4607,26 @@ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useW
4566
4607
  drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
4567
4608
  }
4568
4609
 
4569
- /** Draw a circle filled with a radial gradient from the center to the rim
4610
+ /** Draw an ellipse filled with a radial gradient from the center to the rim
4570
4611
  * - Best when batched with other untextured polys
4571
4612
  * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
4572
4613
  * - Stacking gradients at the exact same position may show a faint vertical artifact
4573
4614
  * @param {Vector2} pos
4574
- * @param {number} [size=1] - Diameter
4615
+ * @param {Vector2} [size=vec2(1)] - Width and height diameter
4575
4616
  * @param {Color} [colorInner=WHITE]
4576
4617
  * @param {Color} [colorOuter=CLEAR_WHITE]
4618
+ * @param {number} [angle]
4577
4619
  * @param {boolean} [useWebGL=glEnable]
4578
4620
  * @param {boolean} [screenSpace]
4579
4621
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
4580
4622
  * @memberof Draw */
4581
- let drawCircleGradientOffset = 0;
4582
- function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
4623
+ let drawEllipseGradientOffset = 0;
4624
+ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
4583
4625
  {
4584
4626
  ASSERT(isVector2(pos), 'pos must be a vec2');
4585
- ASSERT(isNumber(size), 'size must be a number');
4627
+ ASSERT(isVector2(size), 'size must be a vec2');
4586
4628
  ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid');
4629
+ ASSERT(isNumber(angle), 'angle must be a number');
4587
4630
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
4588
4631
 
4589
4632
  if (headlessMode) return;
@@ -4595,26 +4638,33 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
4595
4638
  {
4596
4639
  // convert to world space
4597
4640
  pos = screenToWorld(pos);
4598
- size /= cameraScale;
4641
+ size = size.scale(1/cameraScale);
4642
+ angle += cameraAngle;
4599
4643
  }
4600
4644
  // fan as tristrip; rotate the boundary vertex by one slice per call
4601
4645
  // so back-to-back gradients at the same position have their hole
4602
4646
  // (from gpu edge-rule on the boundary line-degen) at different rim
4603
4647
  // verts and don't visibly stack
4604
4648
  const sides = glCircleSides;
4605
- const radius = size/2;
4649
+ const radiusX = size.x/2, radiusY = size.y/2;
4606
4650
  const innerInt = colorInner.rgbaInt();
4607
4651
  const outerInt = colorOuter.rgbaInt();
4608
- const offset = drawCircleGradientOffset++;
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
+ };
4609
4659
  const startA = (offset%sides)/sides*PI*2;
4610
- const points = [vec2(pos.x + sin(startA)*radius, pos.y + cos(startA)*radius)];
4660
+ const points = [rim(startA)];
4611
4661
  const colors = [outerInt];
4612
4662
  for (let i=sides; i--;)
4613
4663
  {
4614
4664
  const a = ((i+offset)%sides)/sides*PI*2;
4615
4665
  points.push(pos);
4616
4666
  colors.push(innerInt);
4617
- points.push(vec2(pos.x + sin(a)*radius, pos.y + cos(a)*radius));
4667
+ points.push(rim(a));
4618
4668
  colors.push(outerInt);
4619
4669
  }
4620
4670
  glDrawColoredPoints(points, colors);
@@ -4624,7 +4674,7 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
4624
4674
  // normal canvas 2D rendering method (slower)
4625
4675
  ++drawCount;
4626
4676
  ++primitiveCount;
4627
- drawCanvas2D(pos, vec2(size), 0, false, (context)=>
4677
+ drawCanvas2D(pos, size, angle, false, (context)=>
4628
4678
  {
4629
4679
  const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5);
4630
4680
  gradient.addColorStop(0, colorInner.toString());
@@ -4637,6 +4687,24 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
4637
4687
  }
4638
4688
  }
4639
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
+
4640
4708
  /**
4641
4709
  * @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context
4642
4710
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
@@ -4691,7 +4759,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
4691
4759
  * @param {number} [angle]
4692
4760
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
4693
4761
  * @memberof Draw */
4694
- 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)
4695
4763
  {
4696
4764
  // convert to screen space
4697
4765
  pos = worldToScreen(pos);
@@ -4731,16 +4799,16 @@ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLA
4731
4799
  ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
4732
4800
  ASSERT(isNumber(angle), 'angle must be a number');
4733
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();
4734
4806
  context.fillStyle = color.toString();
4735
4807
  context.strokeStyle = lineColor.toString();
4736
4808
  context.lineWidth = lineWidth;
4737
4809
  context.textAlign = textAlign;
4738
4810
  context.font = fontStyle + ' ' + size + 'px '+ font;
4739
4811
  context.textBaseline = 'middle';
4740
-
4741
- const lines = (text+'').split('\n');
4742
- const posY = pos.y - (lines.length-1) * size/2; // center vertically
4743
- context.save();
4744
4812
  context.translate(pos.x, posY);
4745
4813
  context.rotate(-angle);
4746
4814
  let yOffset = 0;
@@ -4901,6 +4969,9 @@ function isOnScreen(pos, size=0)
4901
4969
  ASSERT(isVector2(pos), 'pos must be a vec2');
4902
4970
  ASSERT(isVector2(size) || isNumber(size), 'size must be a vec2 or number');
4903
4971
 
4972
+ // cameraScale of 0 collapses world coords; nothing is visible
4973
+ if (!cameraScale) return false;
4974
+
4904
4975
  // optimized circle on screen test
4905
4976
  // pos = worldToScreen(pos);
4906
4977
  let x = pos.x - cameraPos.x;
@@ -4942,7 +5013,10 @@ function combineCanvases()
4942
5013
  const w = mainCanvasSize.x, h = mainCanvasSize.y;
4943
5014
  workCanvas.width = w;
4944
5015
  workCanvas.height = h;
4945
- 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);
4946
5020
  glCopyToContext(workContext);
4947
5021
  workContext.drawImage(mainCanvas, 0, 0);
4948
5022
  mainContext.drawImage(workCanvas, 0, 0);
@@ -5085,33 +5159,33 @@ function setCursor(cursorStyle = 'auto')
5085
5159
  ///////////////////////////////////////////////////////////////////////////////
5086
5160
 
5087
5161
  /** Engine font image, 8x8 font provided by the engine
5088
- * @type {FontImage}
5162
+ * @type {ImageFont}
5089
5163
  * @memberof Draw */
5090
- let engineFontImage;
5164
+ let engineImageFont;
5091
5165
 
5092
5166
  /**
5093
- * Font Image Object - Draw text by using tiles in an image
5167
+ * Image Font Object - Draw text by using tiles in an image
5094
5168
  * - 96 characters (from space to tilde) are stored in an image
5095
5169
  * - A 8x8 default engine font is supplied for general use
5096
5170
  * - This system is WebGL enabled for fast text rendering
5097
5171
  * - Fonts can also be colored and scaled along each axis
5098
- *
5172
+ *
5099
5173
  * @memberof Draw
5100
5174
  * @example
5101
5175
  * // use built in font
5102
- * const font = engineFontImage;
5176
+ * const font = engineImageFont;
5103
5177
  *
5104
5178
  * // draw text
5105
5179
  * font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50));
5106
5180
  */
5107
- class FontImage
5181
+ class ImageFont
5108
5182
  {
5109
5183
  /** Create an image font
5110
5184
  * @param {TileInfo} tileInfo - Tile info of first character in font
5111
5185
  */
5112
5186
  constructor(tileInfo)
5113
5187
  {
5114
- ASSERT(!!tileInfo, 'tileInfo is required for FontImage');
5188
+ ASSERT(!!tileInfo, 'tileInfo is required for ImageFont');
5115
5189
 
5116
5190
  /** @property {TileInfo} - Tile info for the font */
5117
5191
  this.tileInfo = tileInfo.frame(0);
@@ -5196,7 +5270,7 @@ class FontImage
5196
5270
  }
5197
5271
 
5198
5272
  // load engine font, called automatically on startup
5199
- async function fontImageInit()
5273
+ async function imageFontInit()
5200
5274
  {
5201
5275
  const image = new Image;
5202
5276
  await new Promise(resolve =>
@@ -5209,7 +5283,7 @@ async function fontImageInit()
5209
5283
  const tilePos=vec2(), tileSize=vec2(8), padding=1, bleed=0;
5210
5284
  const textureInfo = new TextureInfo(image);
5211
5285
  const tileInfo = new TileInfo(tilePos, tileSize, textureInfo, padding, bleed);
5212
- engineFontImage = new FontImage(tileInfo);
5286
+ engineImageFont = new ImageFont(tileInfo);
5213
5287
  }
5214
5288
  /**
5215
5289
  * LittleJS Input System
@@ -5658,9 +5732,11 @@ function inputInit()
5658
5732
  mouseDeltaScreen = mouseDeltaScreen.add(movement);
5659
5733
  }
5660
5734
  function onMouseLeave() { mouseInWindow = false; } // mouse moved off window
5661
- function onMouseWheel(e)
5662
- {
5663
- 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);
5664
5740
  if (inputPreventDefault && e.cancelable && document.hasFocus())
5665
5741
  e.preventDefault(); // prevent page scrolling
5666
5742
  }
@@ -5785,9 +5861,14 @@ function inputInit()
5785
5861
  if (button < touchGamepadButtonCount)
5786
5862
  touchGamepadButtons[button] = 1;
5787
5863
  }
5788
- 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)
5789
5867
  {
5790
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
5791
5872
  touchGamepadButtons[9] = 1;
5792
5873
  }
5793
5874
  }
@@ -5829,7 +5910,7 @@ function inputUpdate()
5829
5910
  v > min ? percent(v, min, max) :
5830
5911
  v < -min ? -percent(-v, min, max) : 0;
5831
5912
  return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
5832
- }
5913
+ };
5833
5914
 
5834
5915
  // update touch gamepad if enabled
5835
5916
  if (touchGamepadEnable && isTouchDevice)
@@ -5843,7 +5924,12 @@ function inputUpdate()
5843
5924
  debugCircle(stickCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
5844
5925
  debugCircle(buttonCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
5845
5926
  if (touchGamepadCenterButtonSize)
5927
+ {
5846
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
+ }
5847
5933
  }
5848
5934
 
5849
5935
  if (!touchGamepadTimer.isSet()) return;
@@ -5950,13 +6036,6 @@ function inputUpdate()
5950
6036
  (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
5951
6037
  (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
5952
6038
  }
5953
- else if (gamepad.axes && gamepad.axes.length >= 2)
5954
- {
5955
- // digital style dpad from axes
5956
- const x = clamp(round(gamepad.axes[0]), -1, 1);
5957
- const y = clamp(round(gamepad.axes[1]), -1, 1);
5958
- dpad.set(x, -y);
5959
- }
5960
6039
 
5961
6040
  // copy dpad to left analog stick when pressed
5962
6041
  if (gamepadDirectionEmulateStick && (dpad.x || dpad.y))
@@ -6162,7 +6241,7 @@ class Sound
6162
6241
  /** @property {SoundLoadCallback} - function to call when sound is loaded */
6163
6242
  this.onloadCallback = onloadCallback;
6164
6243
 
6165
- if (Array.isArray(asset))
6244
+ if (isArray(asset))
6166
6245
  {
6167
6246
  // generate zzfx sound — copy so we don't mutate the caller's array
6168
6247
  const zzfxSound = asset.slice();
@@ -6254,7 +6333,7 @@ class Sound
6254
6333
  }
6255
6334
 
6256
6335
  /** Get how long this sound is in seconds
6257
- * @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)
6258
6337
  */
6259
6338
  getDuration()
6260
6339
  { return this.sampleChannels?.[0]?.length / this.sampleRate || 0; }
@@ -6411,10 +6490,14 @@ class SoundInstance
6411
6490
  {
6412
6491
  if (fadeTime)
6413
6492
  {
6414
- // 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
6415
6497
  const startFade = audioContext.currentTime;
6416
6498
  const endFade = startFade + fadeTime;
6417
- this.gainNode.gain.linearRampToValueAtTime(1, startFade);
6499
+ this.gainNode.gain.cancelScheduledValues(startFade);
6500
+ this.gainNode.gain.setValueAtTime(this.volume, startFade);
6418
6501
  this.gainNode.gain.linearRampToValueAtTime(0, endFade);
6419
6502
  this.source.stop(endFade);
6420
6503
  }
@@ -6462,13 +6545,14 @@ class SoundInstance
6462
6545
  */
6463
6546
  getCurrentTime()
6464
6547
  {
6465
- const deltaTime = mod(audioContext.currentTime - this.startTime,
6466
- this.getDuration());
6467
- 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;
6468
6552
  }
6469
6553
 
6470
6554
  /** Get the total duration of this sound
6471
- * @return {number} - Total duration in seconds
6555
+ * @return {number} - Total duration in seconds (0 if loading)
6472
6556
  */
6473
6557
  getDuration() { return this.rate ? this.sound.getDuration() / this.rate : 0; }
6474
6558
 
@@ -6492,7 +6576,7 @@ function speak(text, volume=1, rate=1, pitch=1, language='')
6492
6576
  {
6493
6577
  ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch');
6494
6578
  if (!soundEnable || headlessMode) return;
6495
- if (!speechSynthesis) return;
6579
+ if (typeof speechSynthesis === 'undefined') return;
6496
6580
 
6497
6581
  // common languages (not supported by all browsers)
6498
6582
  // en - english, it - italian, fr - french, de - german, es - spanish
@@ -6510,7 +6594,11 @@ function speak(text, volume=1, rate=1, pitch=1, language='')
6510
6594
 
6511
6595
  /** Stop all queued speech
6512
6596
  * @memberof Audio */
6513
- function speakStop() {speechSynthesis?.cancel();}
6597
+ function speakStop()
6598
+ {
6599
+ if (typeof speechSynthesis !== 'undefined')
6600
+ speechSynthesis.cancel();
6601
+ }
6514
6602
 
6515
6603
  /** Get frequency of a note on a musical scale
6516
6604
  * @param {number} semitoneOffset - How many semitones away from the root note
@@ -6572,9 +6660,14 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
6572
6660
  const pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)});
6573
6661
  source.connect(pannerNode).connect(gainNode);
6574
6662
 
6575
- // callback when the sound ends
6576
- if (onended)
6577
- 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
+ });
6578
6671
 
6579
6672
  // play and return sound
6580
6673
  const startOffset = offset * rate;
@@ -6811,14 +6904,29 @@ function tileCollisionTest(pos, size=vec2(), callbackObject, solidOnly=true)
6811
6904
  * @memberof TileLayers */
6812
6905
  function tileCollisionRaycast(posStart, posEnd, callbackObject, normal, solidOnly=true)
6813
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();
6814
6911
  for (const layer of tileCollisionLayers)
6815
6912
  {
6816
6913
  if (!solidOnly || layer.isSolid)
6817
6914
  {
6818
- const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, normal)
6819
- 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
+ }
6820
6926
  }
6821
6927
  }
6928
+ if (closestHit && normal) normal.setFrom(closestNormal);
6929
+ return closestHit;
6822
6930
  }
6823
6931
 
6824
6932
  ///////////////////////////////////////////////////////////////////////////////
@@ -6992,38 +7100,6 @@ class CanvasLayer extends EngineObject
6992
7100
  drawTile(pos, size, tileInfo, color, angle, mirror, additiveColor, useWebGL, screenSpace, context);
6993
7101
  }
6994
7102
 
6995
- /** Draw a tile onto the layer canvas in world space
6996
- * @param {Vector2} pos
6997
- * @param {Vector2} [size=vec2(1)]
6998
- * @param {TileInfo} [tileInfo]
6999
- * @param {Color} [color=WHITE]
7000
- * @param {number} [angle]
7001
- * @param {boolean} [mirror] */
7002
- drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
7003
- {
7004
- pos = pos.subtract(this.pos).multiply(this.tileInfo.size);
7005
- size = size.multiply(this.tileInfo.size);
7006
- pos.y = this.canvas.height - pos.y;
7007
-
7008
- // draw the tile onto the layer canvas
7009
- const oldMainCanvasSize = mainCanvasSize;
7010
- mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
7011
- const useWebGL = this.hasWebGL();
7012
- useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
7013
- const drawContext = useWebGL ? undefined : this.context;
7014
- drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
7015
- useWebGL && glSetRenderTarget();
7016
- mainCanvasSize = oldMainCanvasSize;
7017
- }
7018
-
7019
- /** Draw a rectangle onto the layer canvas in world space
7020
- * @param {Vector2} pos
7021
- * @param {Vector2} [size=vec2(1)]
7022
- * @param {Color} [color=WHITE]
7023
- * @param {number} [angle] */
7024
- drawRect(pos, size, color, angle)
7025
- { this.drawTile(pos, size, undefined, color, angle); }
7026
-
7027
7103
  /** Create WebGL texture if necessary and copy layer canvas to it */
7028
7104
  updateWebGL()
7029
7105
  { this.textureInfo.createWebGLTexture(); }
@@ -7079,6 +7155,8 @@ class TileLayer extends CanvasLayer
7079
7155
  this.redrawTileData = ()=> {};
7080
7156
  this.drawLayerTile = ()=> {};
7081
7157
  this.drawLayerRect = ()=> {};
7158
+ this.drawTile = ()=> {};
7159
+ this.drawRect = ()=> {};
7082
7160
  this.clearLayerRect = ()=> {};
7083
7161
  return;
7084
7162
  }
@@ -7106,7 +7184,7 @@ class TileLayer extends CanvasLayer
7106
7184
  ASSERT(data instanceof TileLayerData, 'data must be a TileLayerData');
7107
7185
 
7108
7186
  if (!layerPos.arrayCheck(this.size)) return;
7109
- 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;
7110
7188
 
7111
7189
  if (!redraw) return;
7112
7190
  const isRedraw = drawContext === this.context;
@@ -7121,11 +7199,11 @@ class TileLayer extends CanvasLayer
7121
7199
 
7122
7200
  /** Get data at a given position in the array
7123
7201
  * @param {Vector2} layerPos - Local position in array
7124
- * @return {TileLayerData} */
7202
+ * @return {TileLayerData|undefined} */
7125
7203
  getData(layerPos)
7126
- {
7204
+ {
7127
7205
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
7128
- 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;
7129
7207
  }
7130
7208
 
7131
7209
  // Update the tile layer, refresh texture if needed
@@ -7233,7 +7311,7 @@ class TileLayer extends CanvasLayer
7233
7311
 
7234
7312
  // draw the tile if it has layer data
7235
7313
  const d = this.getData(layerPos);
7236
- if (!d.tile) return;
7314
+ if (!d || !d.tile) return;
7237
7315
 
7238
7316
  const tileInfo = this.tileInfo && this.tileInfo.tile(d.tile);
7239
7317
  this.drawLayerTile(drawPos, drawSize, tileInfo, d.color, d.direction*PI/2, d.mirror);
@@ -7279,6 +7357,38 @@ class TileLayer extends CanvasLayer
7279
7357
  drawLayerRect(pos, size, color, angle=0)
7280
7358
  { this.drawLayerTile(pos, size, undefined, color, angle); }
7281
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
+
7282
7392
  /** Clear a rectangle in layer space
7283
7393
  * @param {Vector2} pos - position in pixel coordinates
7284
7394
  * @param {Vector2} size
@@ -7357,7 +7467,7 @@ class TileCollisionLayer extends TileLayer
7357
7467
  setCollisionData(layerPos, data=1)
7358
7468
  {
7359
7469
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
7360
- const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
7470
+ const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
7361
7471
  layerPos.arrayCheck(this.size) && (this.collisionData[i] = data);
7362
7472
  }
7363
7473
 
@@ -7372,7 +7482,7 @@ class TileCollisionLayer extends TileLayer
7372
7482
  getCollisionData(layerPos)
7373
7483
  {
7374
7484
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
7375
- const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
7485
+ const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
7376
7486
  return layerPos.arrayCheck(this.size) ? this.collisionData[i] : 0;
7377
7487
  }
7378
7488
 
@@ -7397,8 +7507,10 @@ class TileCollisionLayer extends TileLayer
7397
7507
  const posY = pos.y - this.pos.y;
7398
7508
  const minX = max(posX - size.x/2|0, 0);
7399
7509
  const minY = max(posY - size.y/2|0, 0);
7400
- const maxX = min(posX + size.x/2, this.size.x);
7401
- 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);
7402
7514
  const hitPos = new Vector2;
7403
7515
  for (let y = minY; y < maxY; ++y)
7404
7516
  for (let x = minX; x < maxX; ++x)
@@ -7517,7 +7629,7 @@ class ParticleEmitter extends EngineObject
7517
7629
  * @param {number} [angleDamping] - How much to dampen particle angular speed
7518
7630
  * @param {number} [gravityScale] - How much gravity effect particles
7519
7631
  * @param {number} [particleConeAngle] - Cone for start particle angle
7520
- * @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
7521
7633
  * @param {number} [randomness] - Apply extra randomness percent
7522
7634
  * @param {boolean} [collideTiles] - Do particles collide against tiles
7523
7635
  * @param {boolean} [additive] - Should particles use additive blend
@@ -7601,7 +7713,7 @@ class ParticleEmitter extends EngineObject
7601
7713
  this.gravityScale = gravityScale;
7602
7714
  /** @property {number} - Cone for start particle angle */
7603
7715
  this.particleConeAngle = particleConeAngle;
7604
- /** @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) */
7605
7717
  this.fadeRate = fadeRate;
7606
7718
  /** @property {number} - Apply extra randomness percent */
7607
7719
  this.randomness = randomness;
@@ -7879,33 +7991,32 @@ class Particle
7879
7991
  const hitLayer = tileCollisionTest(this.pos);
7880
7992
  if (!testCollision(oldPos))
7881
7993
  {
7882
- 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)
7883
8002
  {
7884
- // test which side we bounced off (or both if a corner)
7885
- const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
7886
- const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
7887
- const hitRestitution = max(restitution, hitLayer.restitution);
7888
- const hitFriction = max(friction, hitLayer.friction);
7889
- if (isBlockedX)
7890
- {
7891
- // move to previous X position and bounce
7892
- this.pos.x = oldPos.x;
7893
- this.velocity.x *= -hitRestitution;
7894
- this.velocity.y *= hitFriction;
7895
- }
7896
- if (isBlockedY || !isBlockedX)
7897
- {
7898
- const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
7899
- if (wasFalling)
7900
- this.groundObject = hitLayer;
7901
-
7902
- // move to previous Y position and bounce
7903
- this.pos.y = oldPos.y;
7904
- this.velocity.y *= -hitRestitution;
7905
- this.velocity.x *= hitFriction;
7906
- }
7907
- 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;
7908
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;
8018
+ }
8019
+ debugPhysics && debugRect(this.pos, this.size, '#f00');
7909
8020
  }
7910
8021
  }
7911
8022
  }
@@ -8066,6 +8177,10 @@ function glInit(rootElement)
8066
8177
  for (const info of glTextureInfos)
8067
8178
  info.glTexture = undefined;
8068
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;
8069
8184
  pluginList.forEach(plugin=>plugin.glContextLost?.());
8070
8185
  });
8071
8186
  glCanvas.addEventListener('webglcontextrestored', ()=>
@@ -8424,6 +8539,10 @@ function glSetTextureData(texture, image)
8424
8539
  glContext.bindTexture(glContext.TEXTURE_2D, texture);
8425
8540
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, image);
8426
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
+
8427
8546
  // rebind active texture
8428
8547
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
8429
8548
  }
@@ -8469,7 +8588,7 @@ function glFlush()
8469
8588
  {
8470
8589
  if (glEnable && glContext && glBatchCount)
8471
8590
  {
8472
- // set bend mode
8591
+ // set blend mode
8473
8592
  const destBlend = glBatchAdditive ? glContext.ONE : glContext.ONE_MINUS_SRC_ALPHA;
8474
8593
  glContext.blendFuncSeparate(glContext.SRC_ALPHA, destBlend, glContext.ONE, destBlend);
8475
8594
  glContext.enable(glContext.BLEND);
@@ -8546,9 +8665,9 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
8546
8665
  }
8547
8666
 
8548
8667
  /** Add an untextured rect to the gl draw list
8549
- * Picks the optimal path: if already in poly mode, emits a tristrip rect
8550
- * so it batches with surrounding polys; otherwise uses the instanced path
8551
- * with uvs and rgba zeroed so the color falls through the additive slot.
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.
8552
8671
  * @param {number} x
8553
8672
  * @param {number} y
8554
8673
  * @param {number} sizeX
@@ -8558,52 +8677,7 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
8558
8677
  * @memberof WebGL */
8559
8678
  function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
8560
8679
  {
8561
- if (glPolyMode)
8562
- {
8563
- // batch with surrounding polys as a 4-vertex tristrip rect
8564
- const vertCount = 6; // 4 corners + 2 degenerate verts
8565
- if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
8566
- glFlush();
8567
-
8568
- // compute rotated corners in world space (matches glDrawPointsTransform rotation)
8569
- const hx = sizeX*.5, hy = sizeY*.5;
8570
- const c = cos(angle), s = sin(angle);
8571
- const chx = c*hx, shx = s*hx, chy = c*hy, shy = s*hy;
8572
- const x0 = x - chx - shy, y0 = y + shx - chy; // (-hx,-hy)
8573
- const x1 = x + chx - shy, y1 = y - shx - chy; // ( hx,-hy)
8574
- const x2 = x - chx + shy, y2 = y + shx + chy; // (-hx, hy)
8575
- const x3 = x + chx + shy, y3 = y - shx + chy; // ( hx, hy)
8576
-
8577
- // write tristrip with leading/trailing degenerate verts
8578
- let offset = glBatchCount * gl_INDICES_PER_POLY_VERTEX;
8579
- glPositionData[offset++] = x0; glPositionData[offset++] = y0; glColorData[offset++] = rgba;
8580
- glPositionData[offset++] = x0; glPositionData[offset++] = y0; glColorData[offset++] = rgba;
8581
- glPositionData[offset++] = x1; glPositionData[offset++] = y1; glColorData[offset++] = rgba;
8582
- glPositionData[offset++] = x2; glPositionData[offset++] = y2; glColorData[offset++] = rgba;
8583
- glPositionData[offset++] = x3; glPositionData[offset++] = y3; glColorData[offset++] = rgba;
8584
- glPositionData[offset++] = x3; glPositionData[offset++] = y3; glColorData[offset++] = rgba;
8585
- glBatchCount += vertCount;
8586
- return;
8587
- }
8588
-
8589
- // instanced path: zero uvs and rgba so the texture contribution is killed,
8590
- // then carry the real color in the additive slot
8591
- if (glBatchCount >= gl_MAX_INSTANCES || glBatchAdditive !== glAdditive)
8592
- glFlush();
8593
- glSetInstancedMode();
8594
-
8595
- let offset = glBatchCount++ * gl_INDICES_PER_INSTANCE;
8596
- glPositionData[offset++] = x;
8597
- glPositionData[offset++] = y;
8598
- glPositionData[offset++] = sizeX;
8599
- glPositionData[offset++] = sizeY;
8600
- glPositionData[offset++] = 0;
8601
- glPositionData[offset++] = 0;
8602
- glPositionData[offset++] = 0;
8603
- glPositionData[offset++] = 0;
8604
- glColorData[offset++] = 0;
8605
- glColorData[offset++] = rgba;
8606
- glPositionData[offset++] = angle;
8680
+ glDraw(x, y, sizeX, sizeY, angle, 0, 0, 0, 0, 0, rgba);
8607
8681
  }
8608
8682
 
8609
8683
  /** Transform and add a polygon to the gl draw list
@@ -8619,13 +8693,13 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
8619
8693
  function glDrawPointsTransform(points, rgba, x, y, sx, sy, angle, tristrip=true)
8620
8694
  {
8621
8695
  const pointsOut = [];
8696
+ const sa = sin(-angle);
8697
+ const ca = cos(-angle);
8622
8698
  for (const p of points)
8623
8699
  {
8624
8700
  // transform the point
8625
8701
  const px = p.x*sx;
8626
8702
  const py = p.y*sy;
8627
- const sa = sin(-angle);
8628
- const ca = cos(-angle);
8629
8703
  pointsOut.push(vec2(x + ca*px - sa*py, y + sa*px + ca*py));
8630
8704
  }
8631
8705
  const drawPoints = tristrip ? glPolyStrip(pointsOut) : pointsOut;
@@ -8657,11 +8731,13 @@ function glDrawPoints(points, rgba)
8657
8731
  {
8658
8732
  if (!glEnable || points.length < 3)
8659
8733
  return; // needs at least 3 points to have area
8660
-
8734
+
8661
8735
  // flush if there is not enough room or if different blend mode
8662
8736
  const vertCount = points.length + 2;
8663
8737
  if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
8664
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
8665
8741
  glSetPolyMode();
8666
8742
 
8667
8743
  // setup triangle strip with degenerate verts at start and end
@@ -8685,11 +8761,13 @@ function glDrawColoredPoints(points, pointColors)
8685
8761
  {
8686
8762
  if (!glEnable || points.length < 3)
8687
8763
  return; // needs at least 3 points to have area
8688
-
8764
+
8689
8765
  // flush if there is not enough room or if different blend mode
8690
8766
  const vertCount = points.length + 2;
8691
8767
  if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
8692
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
8693
8771
  glSetPolyMode();
8694
8772
 
8695
8773
  // setup triangle strip with degenerate verts at start and end
@@ -8759,7 +8837,8 @@ function glMakeOutline(points, width, wrap=true)
8759
8837
  const strip = [];
8760
8838
  const n = points.length;
8761
8839
  const e = 1e-6;
8762
- const miterLimit = width*100;
8840
+ // miter ratio cap (dimensionless, matches SVG/Canvas2D convention)
8841
+ const miterLimit = 10;
8763
8842
  for (let i = 0; i < n; i++)
8764
8843
  {
8765
8844
  // for each vertex, calculate normal based on adjacent edges
@@ -9015,7 +9094,7 @@ function drawEngineLogo(t)
9015
9094
  x.closePath();
9016
9095
  gradient(0, Y, 0, Y+H,C);
9017
9096
  }
9018
- 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';
9019
9098
 
9020
9099
  // center and fit to screen
9021
9100
  const alpha = oscillate(1,1,t);
@@ -9425,8 +9504,20 @@ class NewgroundsPlugin
9425
9504
 
9426
9505
  // get medals
9427
9506
  const medalsResult = this.call('Medal.getList');
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
+
9428
9519
  /** @property {Array} - Medals fetched from Newgrounds (empty until session is active) */
9429
- this.medals = medalsResult ? medalsResult.result.data['medals'] : [];
9520
+ this.medals = medalsResult.result.data?.['medals'] || [];
9430
9521
  debugMedals && LOG(this.medals);
9431
9522
  for (const newgroundsMedal of this.medals)
9432
9523
  {
@@ -9446,11 +9537,11 @@ class NewgroundsPlugin
9446
9537
  medal.description = medal.description + ` (${ medal.value })`;
9447
9538
  }
9448
9539
  }
9449
-
9540
+
9450
9541
  // get scoreboards
9451
9542
  const scoreboardResult = this.call('ScoreBoard.getBoards');
9452
9543
  /** @property {Array} - Scoreboards fetched from Newgrounds */
9453
- this.scoreboards = scoreboardResult ? scoreboardResult.result.data.scoreboards : [];
9544
+ this.scoreboards = scoreboardResult?.result?.data?.scoreboards || [];
9454
9545
  debugMedals && LOG(this.scoreboards);
9455
9546
 
9456
9547
  // keep the session alive with a ping every minute
@@ -9634,10 +9725,14 @@ class PostProcessPlugin
9634
9725
  function postProcessRender()
9635
9726
  {
9636
9727
  if (headlessMode || !glEnable) return;
9637
-
9728
+
9638
9729
  // clear out the buffer
9639
9730
  glFlush();
9640
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
+
9641
9736
  // setup shader program to draw a quad
9642
9737
  glContext.useProgram(postProcess.shader);
9643
9738
  glContext.bindVertexArray(postProcess.vao);
@@ -9678,6 +9773,9 @@ class PostProcessPlugin
9678
9773
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, glCanvas);
9679
9774
  }
9680
9775
 
9776
+ // restore default so subsequent dynamic texture uploads aren't flipped
9777
+ glContext.pixelStorei(glContext.UNPACK_FLIP_Y_WEBGL, false);
9778
+
9681
9779
  // force it to set instanced mode
9682
9780
  glSetInstancedMode(true);
9683
9781
  }
@@ -10304,11 +10402,17 @@ class UISystemPlugin
10304
10402
  * @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */
10305
10403
  setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver)
10306
10404
  {
10307
- 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)=>
10308
10411
  {
10309
- function listener(e) { e.preventDefault(); callback && callback(e); }
10412
+ const listener = (e)=> { e.preventDefault(); callback && callback(e); };
10310
10413
  document.addEventListener(listenerType, listener);
10311
- }
10414
+ this._dragListeners.push([listenerType, listener]);
10415
+ };
10312
10416
  setCallback(onDrop, 'drop');
10313
10417
  setCallback(onDragEnter, 'dragenter');
10314
10418
  setCallback(onDragLeave, 'dragleave');
@@ -10632,6 +10736,15 @@ class UIObject
10632
10736
  if (this.destroyed)
10633
10737
  return;
10634
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
+
10635
10748
  // disconnect from parent and destroy children
10636
10749
  this.destroyed = 1;
10637
10750
  this.parent?.removeChild(this);
@@ -10640,6 +10753,8 @@ class UIObject
10640
10753
  child.parent = undefined;
10641
10754
  child.destroy();
10642
10755
  }
10756
+ // clear references so destroyed children can be GC'd
10757
+ this.children.length = 0;
10643
10758
  }
10644
10759
 
10645
10760
  /** Check if the mouse is overlapping this ui object
@@ -11229,6 +11344,7 @@ class UISlider extends UIObject
11229
11344
  {
11230
11345
  // toggle value between 0 and 1
11231
11346
  this.value = this.value ? 0 : 1;
11347
+ this.onChange();
11232
11348
  this.onRelease();
11233
11349
  super.navigatePressed();
11234
11350
  }
@@ -11588,6 +11704,11 @@ class Box2dObject extends EngineObject
11588
11704
  // destroy physics body, fixtures, and joints
11589
11705
  ASSERT(this.body, 'Box2dObject has no body to destroy');
11590
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);
11591
11712
  super.destroy();
11592
11713
  }
11593
11714
 
@@ -11676,7 +11797,9 @@ class Box2dObject extends EngineObject
11676
11797
  /** Add a box shape to the body
11677
11798
  * @param {Vector2} [size]
11678
11799
  * @param {Vector2} [offset]
11679
- * @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.
11680
11803
  * @param {number} [density]
11681
11804
  * @param {number} [friction]
11682
11805
  * @param {number} [restitution]
@@ -11689,7 +11812,7 @@ class Box2dObject extends EngineObject
11689
11812
  ASSERT(isNumber(angle), 'angle must be a number');
11690
11813
 
11691
11814
  const shape = new box2d.instance.b2PolygonShape();
11692
- 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);
11693
11816
  return this.addShape(shape, density, friction, restitution, isSensor);
11694
11817
  }
11695
11818
 
@@ -11991,9 +12114,10 @@ class Box2dObject extends EngineObject
11991
12114
  {
11992
12115
  const data = new box2d.instance.b2MassData();
11993
12116
  this.body.GetMassData(data);
11994
- localCenter && data.set_center(box2d.vec2dTo(localCenter));
11995
- mass && data.set_mass(mass);
11996
- 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);
11997
12121
  this.body.SetMassData(data);
11998
12122
  }
11999
12123
 
@@ -13165,6 +13289,8 @@ class Box2dPlugin
13165
13289
  const fixtureB = contact.GetFixtureB();
13166
13290
  const objectA = fixtureA.GetBody().object;
13167
13291
  const objectB = fixtureB.GetBody().object;
13292
+ // raw user-created b2Bodies may have no .object — skip those
13293
+ if (!objectA || !objectB) return;
13168
13294
  objectA.beginContact(objectB);
13169
13295
  objectB.beginContact(objectA);
13170
13296
  }
@@ -13175,6 +13301,7 @@ class Box2dPlugin
13175
13301
  const fixtureB = contact.GetFixtureB();
13176
13302
  const objectA = fixtureA.GetBody().object;
13177
13303
  const objectB = fixtureB.GetBody().object;
13304
+ if (!objectA || !objectB) return;
13178
13305
  objectA.endContact(objectB);
13179
13306
  objectB.endContact(objectA);
13180
13307
  };
@@ -13553,7 +13680,7 @@ async function box2dInit()
13553
13680
  debugDraw.DrawTransform = function(transform)
13554
13681
  {
13555
13682
  transform = box2d.instance.wrapPointer(transform, box2d.instance.b2Transform);
13556
- const pos = vec2(transform.get_p());
13683
+ const pos = box2d.vec2From(transform.get_p());
13557
13684
  const angle = -transform.get_q().GetAngle();
13558
13685
  const p1 = vec2(1,0), c1 = rgb(.75,0,0,.8);
13559
13686
  const p2 = vec2(0,1), c2 = rgb(0,.75,0,.8);
@@ -13957,7 +14084,7 @@ const Ease =
13957
14084
  * @param {number} x
13958
14085
  * @returns {number}
13959
14086
  * @memberof TweenSystem */
13960
- EXPO: (x) => 2 ** (10 * x - 10),
14087
+ EXPO: (x) => x === 0 ? 0 : 2 ** (10 * x - 10),
13961
14088
 
13962
14089
  /** Back ease-in: overshoots backward at the start before snapping forward.
13963
14090
  * @param {number} x
@@ -13970,6 +14097,8 @@ const Ease =
13970
14097
  * @returns {number}
13971
14098
  * @memberof TweenSystem */
13972
14099
  ELASTIC: (x) =>
14100
+ x === 0 ? 0 :
14101
+ x === 1 ? 1 :
13973
14102
  -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
13974
14103
 
13975
14104
  /** Spring-like ease-out: oscillates outward after passing the target.
@@ -14130,29 +14259,32 @@ function tweenProperty(target, propertyPath, start, end, duration = 1, options =
14130
14259
  }
14131
14260
 
14132
14261
  // Continuation that schedules the next loop iteration when one finishes.
14133
- // Called from the completed tween's `then` slot. Decrements the counter and
14134
- // only spawns a new tween if more iterations remain.
14135
- function loopContinuation(prev)
14136
- {
14137
- if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
14138
- const next = new Tween(prev.callback, prev.start, prev.end, prev.duration,
14139
- { ease: prev.ease, useRealTime: prev.useRealTime });
14140
- next.loopRemaining = prev.loopRemaining === Infinity
14141
- ? Infinity
14142
- : prev.loopRemaining - 1;
14143
- next.thenCallback = () => loopContinuation(next);
14144
- }
14145
-
14146
- // Continuation for pingPong: spawns a new tween with start and end swapped.
14147
- function pingPongContinuation(prev)
14148
- {
14149
- if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
14150
- const next = new Tween(prev.callback, prev.end, prev.start, prev.duration,
14151
- { ease: prev.ease, useRealTime: prev.useRealTime });
14152
- next.loopRemaining = prev.loopRemaining === Infinity
14153
- ? Infinity
14154
- : prev.loopRemaining - 1;
14155
- 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));
14156
14288
  }
14157
14289
 
14158
14290
  /** Engine plugin hook: advance every active tween by the appropriate delta.
@@ -14486,11 +14618,13 @@ class PathFinder
14486
14618
  if (dx !== 0 && dy !== 0)
14487
14619
  {
14488
14620
  // Diagonal step: refuse if either cardinal neighbor is
14489
- // 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*.)
14490
14624
  const card1 = this.getNode(current.pos.x + dx, current.pos.y);
14491
- if (!card1 || card1.cost > 0 || !card1.walkable) continue;
14625
+ if (!card1 || !card1.walkable) continue;
14492
14626
  const card2 = this.getNode(current.pos.x, current.pos.y + dy);
14493
- if (!card2 || card2.cost > 0 || !card2.walkable) continue;
14627
+ if (!card2 || !card2.walkable) continue;
14494
14628
  stepCost = PATHFINDER_DIAGONAL_COST;
14495
14629
  }
14496
14630
 
@@ -15142,6 +15276,7 @@ export
15142
15276
  setTouchGamepadAnalog,
15143
15277
  setTouchGamepadSize,
15144
15278
  setTouchGamepadAlpha,
15279
+ setTouchGamepadDisplayTime,
15145
15280
  setVibrateEnable,
15146
15281
  setSoundEnable,
15147
15282
  setSoundVolume,
@@ -15262,14 +15397,15 @@ export
15262
15397
  drawRegularPoly,
15263
15398
  drawEllipse,
15264
15399
  drawCircle,
15400
+ drawEllipseGradient,
15265
15401
  drawCircleGradient,
15266
15402
  drawCanvas2D,
15267
15403
  drawText,
15268
15404
  drawTextScreen,
15269
15405
  setBlendMode,
15270
15406
  combineCanvases,
15271
- engineFontImage,
15272
- FontImage,
15407
+ engineImageFont,
15408
+ ImageFont,
15273
15409
  isFullscreen,
15274
15410
  toggleFullscreen,
15275
15411
  setCursor,
@@ -15378,16 +15514,16 @@ export
15378
15514
  // Medals
15379
15515
  medals,
15380
15516
  medalsPreventUnlock,
15381
- medalsInit,
15382
- medalsForEach,
15383
- Medal,
15384
15517
  medalDisplayTime,
15385
15518
  medalDisplaySlideTime,
15386
15519
  medalDisplaySize,
15520
+ medalsInit,
15521
+ medalsForEach,
15387
15522
  setMedalDisplayTime,
15388
15523
  setMedalDisplaySlideTime,
15389
15524
  setMedalDisplaySize,
15390
15525
  setMedalsPreventUnlock,
15526
+ Medal,
15391
15527
 
15392
15528
  // Newgrounds
15393
15529
  newgrounds,
@@ -15400,6 +15536,7 @@ export
15400
15536
 
15401
15537
  // ZzFXMusic
15402
15538
  ZzFXMusic,
15539
+ zzfxM,
15403
15540
 
15404
15541
  // UI System
15405
15542
  uiSystem,