littlejsengine 1.18.12 → 1.18.17

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.17';
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
  {
@@ -611,7 +622,7 @@ let debugKey = 'Escape';
611
622
  let debugOverlay = false;
612
623
 
613
624
  // Engine internal variables not exposed to documentation
614
- let debugPrimitives = [], debugPhysics = false, debugRaycast = false, debugParticles = false, debugGamepads = false, debugTakeScreenshot;
625
+ let debugPrimitives = [], debugPhysics = false, debugRaycast = false, debugParticles = false, debugGamepads = false, debugSound = false, debugTakeScreenshot;
615
626
 
616
627
  ///////////////////////////////////////////////////////////////////////////////
617
628
  // Debug helper functions
@@ -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]
@@ -855,6 +862,8 @@ function debugUpdate()
855
862
  debugRaycast = !debugRaycast;
856
863
  if (keyWasPressed('Digit5'))
857
864
  debugScreenshot();
865
+ if (keyWasPressed('Digit7'))
866
+ debugSound = !debugSound;
858
867
  }
859
868
  if (debugVideoCaptureIsActive())
860
869
  {
@@ -874,6 +883,9 @@ function debugRender()
874
883
  // flush any gl sprites before drawing debug info
875
884
  glFlush();
876
885
 
886
+ const savedDrawCount = drawCount;
887
+ const savedPrimitiveCount = primitiveCount;
888
+
877
889
  if (debugTakeScreenshot)
878
890
  {
879
891
  // combine canvases, remove alpha and save
@@ -907,6 +919,8 @@ function debugRender()
907
919
  const stickCount = gamepadStickData[i].length;
908
920
  for (let j = 0; j < stickCount; j++)
909
921
  {
922
+ if (!(j in gamepadStickData[i]))
923
+ continue; // skip sticks that are not present (eg a disabled touch left stick)
910
924
  const stick = gamepadStick(j, i);
911
925
  const drawPos = cornerPos.add(vec2(j*stickScale*2, 0));
912
926
  const stickPos = drawPos.add(stick.scale(stickScale));
@@ -1085,6 +1099,8 @@ function debugRender()
1085
1099
  debugContext.fillStyle = '#fff';
1086
1100
  debugContext.fillText('5: Save Screenshot', x, y += h);
1087
1101
  debugContext.fillText('6: Toggle Video Capture', x, y += h);
1102
+ debugContext.fillStyle = debugSound ? '#f00' : '#fff';
1103
+ debugContext.fillText('7: Debug Sound', x, y += h);
1088
1104
 
1089
1105
  let keysPressed = '';
1090
1106
  let mousePressed = '';
@@ -1094,7 +1110,7 @@ function debugRender()
1094
1110
  continue;
1095
1111
  if (parseInt(i) < 3)
1096
1112
  mousePressed += i + ' ' ;
1097
- else if (keyIsDown(i, 0))
1113
+ else
1098
1114
  keysPressed += i + ' ' ;
1099
1115
  }
1100
1116
  mousePressed && debugContext.fillText('Mouse: ' + mousePressed, x, y += h);
@@ -1119,10 +1135,27 @@ function debugRender()
1119
1135
  debugContext.fillText(debugParticles ? 'Debug Particles' : '', x, y += h);
1120
1136
  debugContext.fillText(debugRaycast ? 'Debug Raycasts' : '', x, y += h);
1121
1137
  debugContext.fillText(debugGamepads ? 'Debug Gamepads' : '', x, y += h);
1138
+ debugContext.fillText(debugSound ? 'Debug Sound' : '', x, y += h);
1122
1139
  }
1123
1140
 
1124
1141
  debugContext.restore();
1125
1142
  }
1143
+
1144
+ if (debugWatermark || debugOverlay)
1145
+ {
1146
+ // show fps stats display
1147
+ mainContext.textAlign = 'right';
1148
+ mainContext.textBaseline = 'top';
1149
+ mainContext.font = '1em monospace';
1150
+ mainContext.fillStyle = '#000';
1151
+ const text = engineName + ' v' + engineVersion + ' / '
1152
+ + savedDrawCount + ' / ' + savedPrimitiveCount + ' / '
1153
+ + engineObjects.length + ' / ' + averageFPS.toFixed(1)
1154
+ + (glEnable ? ' GL' : ' 2D') ;
1155
+ mainContext.fillText(text, mainCanvas.width-3, 3);
1156
+ mainContext.fillStyle = '#fff';
1157
+ mainContext.fillText(text, mainCanvas.width-2, 2);
1158
+ }
1126
1159
  }
1127
1160
 
1128
1161
  function debugRenderPost()
@@ -1132,21 +1165,6 @@ function debugRenderPost()
1132
1165
  debugVideoCaptureUpdate();
1133
1166
  return;
1134
1167
  }
1135
-
1136
- if (!debugWatermark && !debugOverlay) return;
1137
-
1138
- // update fps display
1139
- mainContext.textAlign = 'right';
1140
- mainContext.textBaseline = 'top';
1141
- mainContext.font = '1em monospace';
1142
- mainContext.fillStyle = '#000';
1143
- const text = engineName + ' v' + engineVersion + ' / '
1144
- + drawCount + ' / ' + primitiveCount + ' / '
1145
- + engineObjects.length + ' / ' + averageFPS.toFixed(1)
1146
- + (glEnable ? ' GL' : ' 2D') ;
1147
- mainContext.fillText(text, mainCanvas.width-3, 3);
1148
- mainContext.fillStyle = '#fff';
1149
- mainContext.fillText(text, mainCanvas.width-2, 2);
1150
1168
  }
1151
1169
 
1152
1170
  ///////////////////////////////////////////////////////////////////////////////
@@ -1353,19 +1371,19 @@ const max = Math.max;
1353
1371
  * @param {number} x
1354
1372
  * @return {number}
1355
1373
  * @memberof Math */
1356
- const sign = Math.sign;
1374
+ const sign = (x) => Math.sign(x);
1357
1375
 
1358
1376
  /** Returns hypotenuse of values passed in
1359
1377
  * @param {...number} values
1360
1378
  * @return {number}
1361
1379
  * @memberof Math */
1362
- const hypot = Math.hypot;
1380
+ const hypot = (...values) => Math.hypot(...values);
1363
1381
 
1364
1382
  /** Returns log2 of value passed in
1365
1383
  * @param {number} x
1366
1384
  * @return {number}
1367
1385
  * @memberof Math */
1368
- const log2 = Math.log2;
1386
+ const log2 = (x) => Math.log2(x);
1369
1387
 
1370
1388
  /** Returns sin of value passed in
1371
1389
  * @param {number} x
@@ -1507,7 +1525,8 @@ function isOverlapping(posA, sizeA, posB, sizeB=vec2())
1507
1525
  const dy = (posA.y - posB.y)*2;
1508
1526
  const sx = sizeA.x + sizeB.x;
1509
1527
  const sy = sizeA.y + sizeB.y;
1510
- return dx >= -sx && dx < sx && dy >= -sy && dy < sy;
1528
+ // symmetric so isOverlapping(A,B) === isOverlapping(B,A) at touching edges
1529
+ return abs(dx) < sx && abs(dy) < sy;
1511
1530
  }
1512
1531
 
1513
1532
  /** Returns true if a line segment is intersecting an axis aligned box
@@ -1596,7 +1615,7 @@ function isStringLike(s) { return s != null && typeof s?.toString() === 'string'
1596
1615
  /**
1597
1616
  * Check if object is an array
1598
1617
  * @param {any} a
1599
- * @return {boolean}
1618
+ * @return {a is Array<any>}
1600
1619
  * @memberof Math */
1601
1620
  function isArray(a) { return Array.isArray(a); }
1602
1621
 
@@ -1734,7 +1753,13 @@ function randVec2(length=1) { return new Vector2().setAngle(rand(2*PI), length);
1734
1753
  * @return {Vector2}
1735
1754
  * @memberof Random */
1736
1755
  function randInCircle(radius=1, minRadius=0)
1737
- { return radius > 0 ? randVec2(radius * rand(minRadius / radius, 1)**.5) : new Vector2; }
1756
+ {
1757
+ // r is uniform in area ⇒ r² uniform in [minRadius², radius²]
1758
+ // (the squared inner bound is what makes minRadius the actual exclusion edge)
1759
+ if (radius <= 0) return new Vector2;
1760
+ const ratio = clamp(minRadius / radius);
1761
+ return randVec2(radius * rand(ratio*ratio, 1)**.5);
1762
+ }
1738
1763
 
1739
1764
  /** Returns a random color between the two passed in colors, combine components if linear
1740
1765
  * @param {Color} [colorA=WHITE]
@@ -1804,7 +1829,14 @@ class RandomGenerator
1804
1829
  * @param {number} [valueA]
1805
1830
  * @param {number} [valueB]
1806
1831
  * @return {number} */
1807
- floatSign(valueA=1, valueB=0) { return this.float(valueA, valueB) * this.sign(); }
1832
+ floatSign(valueA=1, valueB=0)
1833
+ {
1834
+ const lo = min(valueA, valueB);
1835
+ const hi = max(valueA, valueB);
1836
+ const d = hi - lo;
1837
+ const e = this.float(d*2);
1838
+ return e < d ? lo + e : d - lo - e;
1839
+ }
1808
1840
 
1809
1841
  /** Returns a random angle between -PI and PI
1810
1842
  * @return {number} */
@@ -2517,9 +2549,15 @@ class Timer
2517
2549
  * @return {number} */
2518
2550
  get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
2519
2551
 
2520
- /** Get percentage elapsed based on time it was set to, returns 0 if not set
2552
+ /** Get percentage elapsed based on time it was set to, returns 0 if not set.
2553
+ * Zero-duration timers report 1 (already elapsed).
2521
2554
  * @return {number} */
2522
- getPercent() { return this.isSet()? 1-percent(this.time - this.getGlobalTime(), 0, this.setTime) : 0; }
2555
+ getPercent()
2556
+ {
2557
+ if (!this.isSet()) return 0;
2558
+ if (!this.setTime) return 1;
2559
+ return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime);
2560
+ }
2523
2561
 
2524
2562
  /** Get the time this timer was set to, returns 0 if not set
2525
2563
  * @return {number} */
@@ -2546,9 +2584,9 @@ class Timer
2546
2584
  * @memberof Utilities */
2547
2585
  function formatTime(t)
2548
2586
  {
2549
- const sign = t < 0 ? '-' : '';
2587
+ const signStr = t < 0 ? '-' : '';
2550
2588
  t = abs(t)|0;
2551
- return sign + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
2589
+ return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
2552
2590
  }
2553
2591
 
2554
2592
  /** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
@@ -2634,15 +2672,20 @@ function shareURL(title, url, callback)
2634
2672
  function readSaveData(saveName, defaultSaveData)
2635
2673
  {
2636
2674
  ASSERT(isStringLike(saveName), 'loadData requires saveName string');
2637
-
2638
- // replace undefined values with defaults; tolerate corrupt JSON
2639
- const data = localStorage[saveName];
2675
+
2676
+ // tolerate localStorage being unavailable (iOS private mode, sandboxed
2677
+ // iframes) and corrupt JSON in stored data
2640
2678
  let loadedData = {};
2641
- if (data)
2679
+ try
2642
2680
  {
2643
- try { loadedData = JSON.parse(data); }
2644
- catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
2681
+ const data = localStorage[saveName];
2682
+ if (data)
2683
+ {
2684
+ try { loadedData = JSON.parse(data); }
2685
+ catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
2686
+ }
2645
2687
  }
2688
+ catch { LOG('readSaveData: localStorage unavailable — using defaults'); }
2646
2689
  return { ...defaultSaveData, ...loadedData };
2647
2690
  }
2648
2691
 
@@ -2653,7 +2696,9 @@ function readSaveData(saveName, defaultSaveData)
2653
2696
  function writeSaveData(saveName, saveData)
2654
2697
  {
2655
2698
  ASSERT(isStringLike(saveName), 'saveData requires saveName string');
2656
- localStorage[saveName] = JSON.stringify(saveData);
2699
+ // tolerate localStorage being unavailable or quota exceeded
2700
+ try { localStorage[saveName] = JSON.stringify(saveData); }
2701
+ catch { LOG('writeSaveData: failed to write', saveName); }
2657
2702
  }
2658
2703
 
2659
2704
  ///////////////////////////////////////////////////////////////////////////////
@@ -2748,7 +2793,7 @@ let canvasColorTiles = true;
2748
2793
 
2749
2794
  /** Color to clear the canvas to before render, does not clear if alpha is 0
2750
2795
  * @type {Color}
2751
- * @memberof Draw */
2796
+ * @memberof Settings */
2752
2797
  let canvasClearColor = CLEAR_BLACK;
2753
2798
 
2754
2799
  /** The max size of the canvas, centered if window is larger
@@ -2941,34 +2986,78 @@ let touchInputEnable = true;
2941
2986
  * - Supports left analog stick, 4 face buttons and start button (button 9)
2942
2987
  * - setTouchGamepadButtonCount(1) to use face buttons as right analog stick
2943
2988
  * - Analog stick buttons 10 and 11 are also activated when virtual sticks are touched
2944
-
2989
+ * - Rendered as a full-viewport HTML/SVG overlay, so controls may sit outside the game canvas
2945
2990
  * @type {boolean}
2946
2991
  * @default
2947
2992
  * @memberof Settings */
2948
2993
  let touchGamepadEnable = false;
2949
2994
 
2950
- /** 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
2995
+ /** True if touches outside the gamepad controls should still drive mouse/touch input
2996
+ * - When false (the default), enabling the touch gamepad suppresses touch-to-mouse input entirely
2997
+ * - Set true to also pass touches outside the controls through to the game as mouse/touch input
2998
+ * - Touches on the gamepad controls never drive the mouse regardless of this setting
2999
+ * @type {boolean}
3000
+ * @default
3001
+ * @memberof Settings */
3002
+ let touchGamepadPassthrough = false;
3003
+
3004
+ /** Size of center button if touch gamepad should have start button in the center
3005
+ * - Prevents activating when pressed near virtual stick or face buttons
2952
3006
  * - When the game is paused, any touch will press the button
2953
- * - Set size to enable the center button
3007
+ * - Measured in viewport CSS pixels
2954
3008
  * @type {number}
2955
3009
  * @default
2956
3010
  * @memberof Settings */
2957
- let touchGamepadCenterButtonSize = 300;
3011
+ let touchGamepadCenterButtonSize = 0;
2958
3012
 
2959
- /** Number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
3013
+ /** Number of buttons on the right side of the touch gamepad (0-4), using gamepad buttons 0-3
3014
+ * - A count of 1 is a single large button (the size of a stick)
3015
+ * - Ignored when touchGamepadRightStick is set (the right side is a stick instead)
2960
3016
  * @type {number}
2961
3017
  * @default
2962
3018
  * @memberof Settings */
2963
3019
  let touchGamepadButtonCount = 4;
2964
3020
 
3021
+ /** True if the touch gamepad should have a left analog stick (or dpad)
3022
+ * - When false, the left side is face buttons (touchGamepadLeftButtonCount) or nothing
3023
+ * @type {boolean}
3024
+ * @default
3025
+ * @memberof Settings */
3026
+ let touchGamepadLeftStick = true;
3027
+
3028
+ /** Number of buttons on the left side of the touch gamepad (0-4), using gamepad buttons 4-7
3029
+ * - Only used when touchGamepadLeftStick is false (otherwise the left side is a stick)
3030
+ * - A count of 1 is a single large button (the size of a stick)
3031
+ * @type {number}
3032
+ * @default
3033
+ * @memberof Settings */
3034
+ let touchGamepadLeftButtonCount = 0;
3035
+
3036
+ /** True if the touch gamepad right side should be an analog stick (or dpad) instead of face buttons
3037
+ * - When set, touchGamepadButtonCount is ignored and the right side is a stick
3038
+ * - Uses an analog stick when touchGamepadAnalog is true, otherwise an 8 way dpad
3039
+ * @type {boolean}
3040
+ * @default
3041
+ * @memberof Settings */
3042
+ let touchGamepadRightStick = false;
3043
+
2965
3044
  /** True if touch gamepad should be analog stick or false to use if 8 way dpad
2966
3045
  * @type {boolean}
2967
3046
  * @default
2968
3047
  * @memberof Settings */
2969
3048
  let touchGamepadAnalog = true;
2970
3049
 
2971
- /** Size of virtual gamepad for touch devices in pixels
3050
+ /** True if touch gamepad directional controls should float to where you press
3051
+ * - Only affects analog sticks and dpads, not face buttons
3052
+ * - Directional controls re-anchor to where you press within the bottom ~60% of their screen half; the top ~40% passes through to the game
3053
+ * - The right side floats only when it acts as the right analog stick (touchGamepadRightStick is set)
3054
+ * - A center button (touchGamepadCenterButtonSize) still works since it ignores touches near the sticks
3055
+ * @type {boolean}
3056
+ * @default
3057
+ * @memberof Settings */
3058
+ let touchGamepadFloating = false;
3059
+
3060
+ /** Size of virtual gamepad for touch devices in viewport CSS pixels
2972
3061
  * @type {number}
2973
3062
  * @default
2974
3063
  * @memberof Settings */
@@ -2986,6 +3075,13 @@ let touchGamepadAlpha = .3;
2986
3075
  * @memberof Settings */
2987
3076
  let touchGamepadDisplayTime = 3;
2988
3077
 
3078
+ /** Duration in ms to vibrate when a touch gamepad face button or start button is pressed
3079
+ * - Set to 0 to disable, also requires vibrateEnable and hardware support (ignored on iOS)
3080
+ * @type {number}
3081
+ * @default
3082
+ * @memberof Settings */
3083
+ let touchGamepadVibration = 0;
3084
+
2989
3085
  /** Allow vibration hardware if it exists
2990
3086
  * @type {boolean}
2991
3087
  * @default
@@ -3218,6 +3314,11 @@ function setTouchInputEnable(enable) { touchInputEnable = enable; }
3218
3314
  * @memberof Settings */
3219
3315
  function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
3220
3316
 
3317
+ /** Set if touches outside the gamepad controls should still drive mouse/touch input
3318
+ * @param {boolean} passthrough
3319
+ * @memberof Settings */
3320
+ function setTouchGamepadPassthrough(passthrough) { touchGamepadPassthrough = passthrough; }
3321
+
3221
3322
  /** Set if touch gamepad should have start button in the center
3222
3323
  * - Set size to enable the center button
3223
3324
  * - When the game is paused, any touch will press the button
@@ -3225,16 +3326,57 @@ function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
3225
3326
  * @memberof Settings */
3226
3327
  function setTouchGamepadCenterButtonSize(size) { touchGamepadCenterButtonSize = size; }
3227
3328
 
3228
- /** Set number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
3329
+ /** Set number of buttons on the right side of the touch gamepad (0-4, gamepad buttons 0-3)
3330
+ * @param {number} count
3331
+ * @memberof Settings */
3332
+ function setTouchGamepadButtonCount(count)
3333
+ {
3334
+ touchGamepadButtonCount = count;
3335
+ if (count > 0)
3336
+ touchGamepadRightStick = false;
3337
+ }
3338
+
3339
+ /** Set if the touch gamepad should have a left analog stick (or dpad)
3340
+ * @param {boolean} enable
3341
+ * @memberof Settings */
3342
+ function setTouchGamepadLeftStick(enable)
3343
+ {
3344
+ touchGamepadLeftStick = enable;
3345
+ if (enable)
3346
+ touchGamepadLeftButtonCount = 0;
3347
+ }
3348
+
3349
+ /** Set number of buttons on the left side of the touch gamepad (0-4, gamepad buttons 4-7)
3350
+ * - Only used when touchGamepadLeftStick is false
3229
3351
  * @param {number} count
3230
3352
  * @memberof Settings */
3231
- function setTouchGamepadButtonCount(count) { touchGamepadButtonCount = count; }
3353
+ function setTouchGamepadLeftButtonCount(count)
3354
+ {
3355
+ touchGamepadLeftButtonCount = count;
3356
+ if (count > 0)
3357
+ touchGamepadLeftStick = false;
3358
+ }
3359
+
3360
+ /** Set if the touch gamepad right side is an analog stick (or dpad) instead of face buttons
3361
+ * @param {boolean} rightStick
3362
+ * @memberof Settings */
3363
+ function setTouchGamepadRightStick(rightStick)
3364
+ {
3365
+ touchGamepadRightStick = rightStick;
3366
+ if (rightStick)
3367
+ touchGamepadButtonCount = 0;
3368
+ }
3232
3369
 
3233
3370
  /** Set if touch gamepad should be analog stick or 8 way dpad
3234
3371
  * @param {boolean} analog
3235
3372
  * @memberof Settings */
3236
3373
  function setTouchGamepadAnalog(analog) { touchGamepadAnalog = analog; }
3237
3374
 
3375
+ /** Set if touch gamepad directional controls should float to where you press
3376
+ * @param {boolean} floating
3377
+ * @memberof Settings */
3378
+ function setTouchGamepadFloating(floating) { touchGamepadFloating = floating; }
3379
+
3238
3380
  /** Set size of virtual gamepad for touch devices in pixels
3239
3381
  * @param {number} size
3240
3382
  * @memberof Settings */
@@ -3250,6 +3392,11 @@ function setTouchGamepadAlpha(alpha) { touchGamepadAlpha = alpha; }
3250
3392
  * @memberof Settings */
3251
3393
  function setTouchGamepadDisplayTime(time) { touchGamepadDisplayTime = time; }
3252
3394
 
3395
+ /** Set duration in ms to vibrate when a touch gamepad face or start button is pressed (0 disables)
3396
+ * @param {number} ms
3397
+ * @memberof Settings */
3398
+ function setTouchGamepadVibration(ms) { touchGamepadVibration = ms; }
3399
+
3253
3400
  /** Set to allow vibration hardware if it exists
3254
3401
  * @param {boolean} enable
3255
3402
  * @memberof Settings */
@@ -3360,7 +3507,7 @@ class EngineObject
3360
3507
  this.color = color.copy();
3361
3508
  /** @property {Color} - Additive color to apply when rendered */
3362
3509
  this.additiveColor = undefined;
3363
- /** @property {boolean} - Should it flip along y axis when rendered */
3510
+ /** @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
3511
  this.mirror = false;
3365
3512
  /** @property {boolean} - Has object been destroyed? */
3366
3513
  this.destroyed = false;
@@ -3428,10 +3575,10 @@ class EngineObject
3428
3575
  if (pa)
3429
3576
  {
3430
3577
  const c = cos(-pa), s = sin(-pa);
3431
- this.pos = new Vector2(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
3578
+ this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
3432
3579
  }
3433
3580
  else
3434
- this.pos = new Vector2(lx + pp.x, ly + pp.y);
3581
+ this.pos.set(lx + pp.x, ly + pp.y);
3435
3582
  this.angle = mirror*this.localAngle + pa;
3436
3583
  }
3437
3584
 
@@ -3446,6 +3593,9 @@ class EngineObject
3446
3593
  // child objects do not have physics
3447
3594
  ASSERT(!this.parent);
3448
3595
 
3596
+ // bail if a collision callback destroyed us mid-frame
3597
+ if (this.destroyed) return;
3598
+
3449
3599
  if (this.clampSpeed)
3450
3600
  {
3451
3601
  // limit max speed to prevent missing collisions
@@ -3501,6 +3651,8 @@ class EngineObject
3501
3651
 
3502
3652
  // notify objects of collision and check if should be resolved
3503
3653
  const collide1 = this.collideWithObject(o);
3654
+ // callback may have destroyed us; stop resolving against more objects
3655
+ if (this.destroyed) return;
3504
3656
  const collide2 = o.collideWithObject(this);
3505
3657
  if (!collide1 || !collide2) continue;
3506
3658
 
@@ -3596,13 +3748,17 @@ class EngineObject
3596
3748
  const restitution = max(this.restitution, hitLayer.restitution);
3597
3749
  if (isBlockedX)
3598
3750
  {
3599
- // try to move up a tiny bit
3751
+ // try to step over a 1-tile bump (direction follows gravity sign
3752
+ // so inverted gravity steps down off a ceiling bump instead of up;
3753
+ // zero gravity defaults to the normal-gravity step-up direction)
3600
3754
  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)
3755
+ const maxMove = .1;
3756
+ const gravitySign = gravity.y > 0 ? -1 : 1;
3757
+ const y = gravitySign > 0 ?
3758
+ floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon :
3759
+ ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon;
3760
+ const delta = abs(y - this.pos.y);
3761
+ if (delta < maxMove)
3606
3762
  if (!tileCollisionTest(vec2(this.pos.x, y), this.size, this))
3607
3763
  {
3608
3764
  this.pos.y = y;
@@ -3654,6 +3810,9 @@ class EngineObject
3654
3810
  drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor);
3655
3811
  }
3656
3812
 
3813
+ /** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
3814
+ renderLight() {}
3815
+
3657
3816
  /** Destroy this object, destroy its children, detach its parent, and mark it for removal
3658
3817
  * @param {boolean} [immediate] - should attached effects be allowed to die off? */
3659
3818
  destroy(immediate=false)
@@ -3742,6 +3901,8 @@ class EngineObject
3742
3901
  * @return {EngineObject} The child object added */
3743
3902
  addChild(child, localPos=vec2(), localAngle=0)
3744
3903
  {
3904
+ ASSERT(!this.destroyed, 'cannot add child to destroyed object');
3905
+ if (this.destroyed) return child;
3745
3906
  ASSERT(!child.parent && !this.children.includes(child));
3746
3907
  ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
3747
3908
  ASSERT(child !== this, 'cannot add self as child');
@@ -3758,10 +3919,7 @@ class EngineObject
3758
3919
  removeChild(child)
3759
3920
  {
3760
3921
  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);
3922
+ this.children.splice(this.children.indexOf(child), 1);
3765
3923
  child.parent = undefined;
3766
3924
  }
3767
3925
 
@@ -3836,7 +3994,7 @@ class EngineObject
3836
3994
  * - Optimized tile sheet sprite rendering using WebGL batching
3837
3995
  * - Primitive drawing for polygons, ellipses, and lines
3838
3996
  * - Tile-based rendering with TileInfo and TextureInfo classes
3839
- * - Text rendering with custom fonts and FontImage support
3997
+ * - Text rendering with custom fonts and ImageFont support
3840
3998
  * - Color and additive color blending for effects
3841
3999
  * - Rotation, mirroring, and scaling transformations
3842
4000
  * - Camera system with position, scale, and rotation
@@ -3932,7 +4090,7 @@ let primitiveCount;
3932
4090
  * tile(1, 16, 3) // a tile at index 1 of size 16 on texture 3
3933
4091
  * tile(vec2(4,8), vec2(30,10)) // a tile at index (4,8) with a size of (30,10)
3934
4092
  * @memberof Draw */
3935
- function tile(index=new Vector2, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
4093
+ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
3936
4094
  {
3937
4095
  ASSERT(isVector2(index) || typeof index === 'number', 'index must be a vec2 or number');
3938
4096
  ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
@@ -3983,8 +4141,8 @@ class TileInfo
3983
4141
  * @param {Vector2} [pos=vec2()] - Top left corner of tile in pixels
3984
4142
  * @param {Vector2} [size] - Size of tile in pixels
3985
4143
  * @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
4144
+ * @param {number} [padding] - How many pixels padding around all sides of each tile (increases grid size, does not affect tile size)
4145
+ * @param {number} [bleed] - How many pixels smaller to shrink UVS of tiles (does not affect grid size, only UVs)
3988
4146
  */
3989
4147
  constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed)
3990
4148
  {
@@ -4016,7 +4174,7 @@ class TileInfo
4016
4174
  ASSERT(typeof frame === 'number');
4017
4175
  const w = this.size.x + this.padding*2;
4018
4176
  const x = frame*w;
4019
- ASSERT(x < this.textureInfo.size.x, 'frame extends beyond texture width!');
4177
+ ASSERT(x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
4020
4178
  return this.offset(new Vector2(x));
4021
4179
  }
4022
4180
 
@@ -4105,7 +4263,7 @@ class TextureInfo
4105
4263
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
4106
4264
  * @memberof Draw */
4107
4265
  function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
4108
- angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace, context)
4266
+ angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=false, context)
4109
4267
  {
4110
4268
  ASSERT(isVector2(pos), 'pos must be a vec2');
4111
4269
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -4148,10 +4306,8 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
4148
4306
  }
4149
4307
  else
4150
4308
  {
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.
4309
+ // untextured: fold color+additive to match the Canvas2D path's
4310
+ // color.add(additiveColor) on line ~337.
4155
4311
  const combined = additiveColor ? color.add(additiveColor) : color;
4156
4312
  glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
4157
4313
  }
@@ -4161,11 +4317,12 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
4161
4317
  // normal canvas 2D rendering method (slower)
4162
4318
  ++drawCount;
4163
4319
  ++primitiveCount;
4164
- size = new Vector2(size.x, -size.y); // flip upside down sprites
4165
4320
  drawCanvas2D(pos, size, angle, mirror, (context)=>
4166
4321
  {
4167
4322
  if (textureInfo)
4168
4323
  {
4324
+ // un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
4325
+ context.scale(1, -1);
4169
4326
  // calculate uvs and render
4170
4327
  const x = tileInfo.pos.x, y = tileInfo.pos.y;
4171
4328
  const w = tileInfo.size.x, h = tileInfo.size.y;
@@ -4173,7 +4330,7 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
4173
4330
  }
4174
4331
  else
4175
4332
  {
4176
- // if no tile info, use untextured rect
4333
+ // if no tile info, use untextured rect (Y-symmetric, no compensation needed)
4177
4334
  const c = additiveColor ? color.add(additiveColor) : color;
4178
4335
  context.fillStyle = c.toString();
4179
4336
  context.fillRect(-.5, -.5, 1, 1);
@@ -4247,11 +4404,10 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, an
4247
4404
  // normal canvas 2D rendering method (slower)
4248
4405
  ++drawCount;
4249
4406
  ++primitiveCount;
4250
- size = new Vector2(size.x, -size.y); // fix upside down sprites
4251
4407
  drawCanvas2D(pos, size, angle, false, (context)=>
4252
4408
  {
4253
- // if no tile info, use untextured rect
4254
- const gradient = context.createLinearGradient(0, -.5, 0, .5);
4409
+ // gradient endpoints are flipped to match the Y flip inside drawCanvas2D
4410
+ const gradient = context.createLinearGradient(0, .5, 0, -.5);
4255
4411
  gradient.addColorStop(0, colorTop.toString());
4256
4412
  gradient.addColorStop(1, colorBottom.toString());
4257
4413
  context.fillStyle = gradient;
@@ -4364,7 +4520,7 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
4364
4520
  * @param {boolean} [screenSpace]
4365
4521
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
4366
4522
  * @memberof Draw */
4367
- function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace, context)
4523
+ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
4368
4524
  {
4369
4525
  ASSERT(isArray(points), 'points must be an array');
4370
4526
  ASSERT(isNumber(width), 'width must be a number');
@@ -4413,7 +4569,7 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
4413
4569
  * @param {boolean} [screenSpace]
4414
4570
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
4415
4571
  * @memberof Draw */
4416
- function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, screenSpace, context)
4572
+ function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
4417
4573
  {
4418
4574
  const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
4419
4575
  const size = vec2(width, halfDelta.length()*2);
@@ -4429,9 +4585,9 @@ function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, sc
4429
4585
  * @param {Vector2} [size=vec2(1)]
4430
4586
  * @param {number} [sides]
4431
4587
  * @param {Color} [color=WHITE]
4432
- * @param {number} [angle]
4433
4588
  * @param {number} [lineWidth]
4434
4589
  * @param {Color} [lineColor=BLACK]
4590
+ * @param {number} [angle]
4435
4591
  * @param {boolean} [useWebGL=glEnable]
4436
4592
  * @param {boolean} [screenSpace]
4437
4593
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
@@ -4524,7 +4680,7 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
4524
4680
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
4525
4681
 
4526
4682
  // clamp line width to prevent artifacts
4527
- lineWidth = clamp(lineWidth, 0, Math.min(size.x, size.y));
4683
+ lineWidth = clamp(lineWidth, 0, min(size.x, size.y));
4528
4684
 
4529
4685
  if (useWebGL && glEnable)
4530
4686
  {
@@ -4566,24 +4722,26 @@ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useW
4566
4722
  drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
4567
4723
  }
4568
4724
 
4569
- /** Draw a circle filled with a radial gradient from the center to the rim
4725
+ /** Draw an ellipse filled with a radial gradient from the center to the rim
4570
4726
  * - Best when batched with other untextured polys
4571
4727
  * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
4572
4728
  * - Stacking gradients at the exact same position may show a faint vertical artifact
4573
4729
  * @param {Vector2} pos
4574
- * @param {number} [size=1] - Diameter
4730
+ * @param {Vector2} [size=vec2(1)] - Width and height diameter
4575
4731
  * @param {Color} [colorInner=WHITE]
4576
4732
  * @param {Color} [colorOuter=CLEAR_WHITE]
4733
+ * @param {number} [angle]
4577
4734
  * @param {boolean} [useWebGL=glEnable]
4578
4735
  * @param {boolean} [screenSpace]
4579
4736
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
4580
4737
  * @memberof Draw */
4581
- let drawCircleGradientOffset = 0;
4582
- function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
4738
+ let drawEllipseGradientOffset = 0;
4739
+ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
4583
4740
  {
4584
4741
  ASSERT(isVector2(pos), 'pos must be a vec2');
4585
- ASSERT(isNumber(size), 'size must be a number');
4742
+ ASSERT(isVector2(size), 'size must be a vec2');
4586
4743
  ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid');
4744
+ ASSERT(isNumber(angle), 'angle must be a number');
4587
4745
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
4588
4746
 
4589
4747
  if (headlessMode) return;
@@ -4595,26 +4753,33 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
4595
4753
  {
4596
4754
  // convert to world space
4597
4755
  pos = screenToWorld(pos);
4598
- size /= cameraScale;
4756
+ size = size.scale(1/cameraScale);
4757
+ angle += cameraAngle;
4599
4758
  }
4600
4759
  // fan as tristrip; rotate the boundary vertex by one slice per call
4601
4760
  // so back-to-back gradients at the same position have their hole
4602
4761
  // (from gpu edge-rule on the boundary line-degen) at different rim
4603
4762
  // verts and don't visibly stack
4604
4763
  const sides = glCircleSides;
4605
- const radius = size/2;
4764
+ const radiusX = size.x/2, radiusY = size.y/2;
4606
4765
  const innerInt = colorInner.rgbaInt();
4607
4766
  const outerInt = colorOuter.rgbaInt();
4608
- const offset = drawCircleGradientOffset++;
4767
+ const offset = drawEllipseGradientOffset++;
4768
+ const c = cos(-angle), s = sin(-angle);
4769
+ const rim = (a) =>
4770
+ {
4771
+ const lx = sin(a)*radiusX, ly = cos(a)*radiusY;
4772
+ return vec2(pos.x + lx*c - ly*s, pos.y + lx*s + ly*c);
4773
+ };
4609
4774
  const startA = (offset%sides)/sides*PI*2;
4610
- const points = [vec2(pos.x + sin(startA)*radius, pos.y + cos(startA)*radius)];
4775
+ const points = [rim(startA)];
4611
4776
  const colors = [outerInt];
4612
4777
  for (let i=sides; i--;)
4613
4778
  {
4614
4779
  const a = ((i+offset)%sides)/sides*PI*2;
4615
4780
  points.push(pos);
4616
4781
  colors.push(innerInt);
4617
- points.push(vec2(pos.x + sin(a)*radius, pos.y + cos(a)*radius));
4782
+ points.push(rim(a));
4618
4783
  colors.push(outerInt);
4619
4784
  }
4620
4785
  glDrawColoredPoints(points, colors);
@@ -4624,7 +4789,7 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
4624
4789
  // normal canvas 2D rendering method (slower)
4625
4790
  ++drawCount;
4626
4791
  ++primitiveCount;
4627
- drawCanvas2D(pos, vec2(size), 0, false, (context)=>
4792
+ drawCanvas2D(pos, size, angle, false, (context)=>
4628
4793
  {
4629
4794
  const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5);
4630
4795
  gradient.addColorStop(0, colorInner.toString());
@@ -4637,13 +4802,34 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
4637
4802
  }
4638
4803
  }
4639
4804
 
4805
+ /** Draw a circle filled with a radial gradient from the center to the rim
4806
+ * - Best when batched with other untextured polys
4807
+ * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
4808
+ * - Stacking gradients at the exact same position may show a faint vertical artifact
4809
+ * @param {Vector2} pos
4810
+ * @param {number} [size=1] - Diameter
4811
+ * @param {Color} [colorInner=WHITE]
4812
+ * @param {Color} [colorOuter=CLEAR_WHITE]
4813
+ * @param {boolean} [useWebGL=glEnable]
4814
+ * @param {boolean} [screenSpace]
4815
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
4816
+ * @memberof Draw */
4817
+ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
4818
+ {
4819
+ ASSERT(isNumber(size), 'size must be a number');
4820
+ drawEllipseGradient(pos, vec2(size), colorInner, colorOuter, 0, useWebGL, screenSpace, context);
4821
+ }
4822
+
4640
4823
  /**
4641
4824
  * @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context
4642
4825
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
4643
4826
  * @memberof Draw
4644
4827
  */
4645
4828
 
4646
- /** Draw directly to a 2d canvas context in world space
4829
+ /** Draw directly to a 2d canvas context in world space.
4830
+ * The Y axis is flipped so world-Y-up coordinates render right-side up
4831
+ * (matches the WebGL path). Callers whose drawing depends on Y direction
4832
+ * (e.g. linear gradients) should flip their own Y endpoints accordingly.
4647
4833
  * @param {Vector2} pos
4648
4834
  * @param {Vector2} size
4649
4835
  * @param {number} angle
@@ -4691,7 +4877,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
4691
4877
  * @param {number} [angle]
4692
4878
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
4693
4879
  * @memberof Draw */
4694
- function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, font, fontStyle, maxWidth, angle=0, context=drawContext)
4880
+ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
4695
4881
  {
4696
4882
  // convert to screen space
4697
4883
  pos = worldToScreen(pos);
@@ -4731,16 +4917,16 @@ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLA
4731
4917
  ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
4732
4918
  ASSERT(isNumber(angle), 'angle must be a number');
4733
4919
 
4920
+ const lines = (text+'').split('\n');
4921
+ const posY = pos.y - (lines.length-1) * size/2; // center vertically
4922
+ // save before style mutations so caller's context state is preserved
4923
+ context.save();
4734
4924
  context.fillStyle = color.toString();
4735
4925
  context.strokeStyle = lineColor.toString();
4736
4926
  context.lineWidth = lineWidth;
4737
4927
  context.textAlign = textAlign;
4738
4928
  context.font = fontStyle + ' ' + size + 'px '+ font;
4739
4929
  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
4930
  context.translate(pos.x, posY);
4745
4931
  context.rotate(-angle);
4746
4932
  let yOffset = 0;
@@ -4901,6 +5087,9 @@ function isOnScreen(pos, size=0)
4901
5087
  ASSERT(isVector2(pos), 'pos must be a vec2');
4902
5088
  ASSERT(isVector2(size) || isNumber(size), 'size must be a vec2 or number');
4903
5089
 
5090
+ // cameraScale of 0 collapses world coords; nothing is visible
5091
+ if (!cameraScale) return false;
5092
+
4904
5093
  // optimized circle on screen test
4905
5094
  // pos = worldToScreen(pos);
4906
5095
  let x = pos.x - cameraPos.x;
@@ -4942,7 +5131,10 @@ function combineCanvases()
4942
5131
  const w = mainCanvasSize.x, h = mainCanvasSize.y;
4943
5132
  workCanvas.width = w;
4944
5133
  workCanvas.height = h;
4945
- workContext.fillRect(0,0,w,h); // remove background alpha
5134
+ // remove background alpha — explicit fillStyle so a previous caller
5135
+ // leaving workContext.fillStyle transparent can't silently no-op this
5136
+ workContext.fillStyle = '#000';
5137
+ workContext.fillRect(0,0,w,h);
4946
5138
  glCopyToContext(workContext);
4947
5139
  workContext.drawImage(mainCanvas, 0, 0);
4948
5140
  mainContext.drawImage(workCanvas, 0, 0);
@@ -5085,33 +5277,33 @@ function setCursor(cursorStyle = 'auto')
5085
5277
  ///////////////////////////////////////////////////////////////////////////////
5086
5278
 
5087
5279
  /** Engine font image, 8x8 font provided by the engine
5088
- * @type {FontImage}
5280
+ * @type {ImageFont}
5089
5281
  * @memberof Draw */
5090
- let engineFontImage;
5282
+ let engineImageFont;
5091
5283
 
5092
5284
  /**
5093
- * Font Image Object - Draw text by using tiles in an image
5285
+ * Image Font Object - Draw text by using tiles in an image
5094
5286
  * - 96 characters (from space to tilde) are stored in an image
5095
5287
  * - A 8x8 default engine font is supplied for general use
5096
5288
  * - This system is WebGL enabled for fast text rendering
5097
5289
  * - Fonts can also be colored and scaled along each axis
5098
- *
5290
+ *
5099
5291
  * @memberof Draw
5100
5292
  * @example
5101
5293
  * // use built in font
5102
- * const font = engineFontImage;
5294
+ * const font = engineImageFont;
5103
5295
  *
5104
5296
  * // draw text
5105
5297
  * font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50));
5106
5298
  */
5107
- class FontImage
5299
+ class ImageFont
5108
5300
  {
5109
5301
  /** Create an image font
5110
5302
  * @param {TileInfo} tileInfo - Tile info of first character in font
5111
5303
  */
5112
5304
  constructor(tileInfo)
5113
5305
  {
5114
- ASSERT(!!tileInfo, 'tileInfo is required for FontImage');
5306
+ ASSERT(!!tileInfo, 'tileInfo is required for ImageFont');
5115
5307
 
5116
5308
  /** @property {TileInfo} - Tile info for the font */
5117
5309
  this.tileInfo = tileInfo.frame(0);
@@ -5196,7 +5388,7 @@ class FontImage
5196
5388
  }
5197
5389
 
5198
5390
  // load engine font, called automatically on startup
5199
- async function fontImageInit()
5391
+ async function imageFontInit()
5200
5392
  {
5201
5393
  const image = new Image;
5202
5394
  await new Promise(resolve =>
@@ -5209,7 +5401,7 @@ async function fontImageInit()
5209
5401
  const tilePos=vec2(), tileSize=vec2(8), padding=1, bleed=0;
5210
5402
  const textureInfo = new TextureInfo(image);
5211
5403
  const tileInfo = new TileInfo(tilePos, tileSize, textureInfo, padding, bleed);
5212
- engineFontImage = new FontImage(tileInfo);
5404
+ engineImageFont = new ImageFont(tileInfo);
5213
5405
  }
5214
5406
  /**
5215
5407
  * LittleJS Input System
@@ -5301,6 +5493,7 @@ function inputClear()
5301
5493
  inputData[0] = [];
5302
5494
  touchGamepadButtons.length = 0;
5303
5495
  touchGamepadSticks.length = 0;
5496
+ touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor
5304
5497
  gamepadStickData.length = 0;
5305
5498
  gamepadDpadData.length = 0;
5306
5499
  }
@@ -5543,6 +5736,14 @@ const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
5543
5736
 
5544
5737
  // touch gamepad internal variables
5545
5738
  const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
5739
+ // floating stick anchors (stage-local CSS pixels) and owning pointer ids, indexed by stick (0=left, 1=right)
5740
+ const touchGamepadStickAnchors = [], touchGamepadStickPointerId = [];
5741
+ // pointerId -> control role ('stick0', 'stick1', 'face<n>', or 'start')
5742
+ const touchGamepadPointerRole = new Map();
5743
+ // overlay DOM elements (created lazily on touch devices) and cached SVG shapes
5744
+ let touchGamepadOverlay, touchGamepadStage, touchGamepadSvg, touchGamepadSvgEls;
5745
+ let touchGamepadSideZones = [], touchGamepadZoneC;
5746
+ let touchGamepadNeedRelayout = true, touchGamepadLastLayout;
5546
5747
 
5547
5748
  ///////////////////////////////////////////////////////////////////////////////
5548
5749
  // Input system functions used by engine
@@ -5658,14 +5859,24 @@ function inputInit()
5658
5859
  mouseDeltaScreen = mouseDeltaScreen.add(movement);
5659
5860
  }
5660
5861
  function onMouseLeave() { mouseInWindow = false; } // mouse moved off window
5661
- function onMouseWheel(e)
5662
- {
5663
- mouseWheel = e.ctrlKey ? 0 : sign(e.deltaY);
5862
+ function onMouseWheel(e)
5863
+ {
5864
+ // accumulate so multiple wheel events in one frame are not lost
5865
+ if (!e.ctrlKey)
5866
+ mouseWheel += sign(e.deltaY);
5664
5867
  if (inputPreventDefault && e.cancelable && document.hasFocus())
5665
5868
  e.preventDefault(); // prevent page scrolling
5666
5869
  }
5667
5870
  function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
5668
- function onBlur() { inputClear(); } // reset input when focus is lost
5871
+ function onBlur()
5872
+ {
5873
+ inputClear();
5874
+ // release any held virtual gamepad controls so they don't stick
5875
+ touchGamepadPointerRole.clear();
5876
+ touchGamepadButtons.length = 0;
5877
+ touchGamepadSticks.length = 0;
5878
+ touchGamepadStickPointerId.length = 0;
5879
+ }
5669
5880
 
5670
5881
  // enable touch input mouse passthrough
5671
5882
  function touchInputInit()
@@ -5681,36 +5892,46 @@ function inputInit()
5681
5892
  {
5682
5893
  if (!touchInputEnable) return;
5683
5894
 
5684
- // route touch to gamepad
5685
- if (touchGamepadEnable)
5686
- handleTouchGamepad(e);
5687
-
5688
5895
  // fix stalled audio requiring user interaction
5689
5896
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
5690
5897
  audioContext.resume();
5691
5898
 
5692
- // check if touching and pass to mouse events
5693
- const touching = e.touches.length;
5694
- const button = 0; // all touches are left mouse button
5695
- if (touching)
5899
+ // when the touch gamepad is enabled it owns touch input: suppress the
5900
+ // touch->mouse passthrough entirely unless touchGamepadPassthrough is set
5901
+ // (its own zones drive gameplay via pointer events)
5902
+ if (!touchGamepadEnable || touchGamepadPassthrough)
5696
5903
  {
5697
- // set event pos and pass it along
5698
- const pos = vec2(e.touches[0].clientX, e.touches[0].clientY);
5699
- const mousePosScreenLast = mousePosScreen;
5700
- mousePosScreen = mouseEventToScreen(pos);
5701
- if (wasTouching)
5904
+ // touches that landed on a virtual gamepad zone are owned by the gamepad
5905
+ // (handled by its own pointer listeners) and must not drive the game mouse
5906
+ const isGamepadTouch = (t)=>
5907
+ touchGamepadSideZones.includes(t.target) || t.target === touchGamepadZoneC;
5908
+ const gameTouches = [];
5909
+ for (const t of e.touches)
5910
+ if (!isGamepadTouch(t)) gameTouches.push(t);
5911
+
5912
+ // check if touching and pass to mouse events
5913
+ const touching = gameTouches.length;
5914
+ const button = 0; // all touches are left mouse button
5915
+ if (touching)
5702
5916
  {
5703
- mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
5704
- isUsingGamepad = touchGamepadEnable;
5917
+ // set event pos and pass it along
5918
+ const pos = vec2(gameTouches[0].clientX, gameTouches[0].clientY);
5919
+ const mousePosScreenLast = mousePosScreen;
5920
+ mousePosScreen = mouseEventToScreen(pos);
5921
+ if (wasTouching)
5922
+ mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
5923
+ else
5924
+ {
5925
+ inputData[0][button] = 3;
5926
+ isUsingGamepad = false; // a passthrough tap is mouse-style input
5927
+ }
5705
5928
  }
5706
- else
5707
- inputData[0][button] = 3;
5708
- }
5709
- else if (wasTouching)
5710
- inputData[0][button] = inputData[0][button] & 2 | 4;
5929
+ else if (wasTouching)
5930
+ inputData[0][button] = inputData[0][button] & 2 | 4;
5711
5931
 
5712
- // set was touching
5713
- wasTouching = touching;
5932
+ // set was touching
5933
+ wasTouching = touching;
5934
+ }
5714
5935
 
5715
5936
  // prevent default handling like copy, magnifier lens, and scrolling
5716
5937
  if (inputPreventDefault && e.cancelable && document.hasFocus())
@@ -5720,78 +5941,6 @@ function inputInit()
5720
5941
  return true;
5721
5942
  }
5722
5943
 
5723
- // special handling for virtual gamepad mode
5724
- function handleTouchGamepad(e)
5725
- {
5726
- // clear touch gamepad input
5727
- touchGamepadSticks.length = 0;
5728
- touchGamepadSticks[0] = vec2();
5729
- touchGamepadSticks[1] = vec2();
5730
- touchGamepadButtons.length = 0;
5731
- isUsingGamepad = true;
5732
-
5733
- const touching = e.touches.length;
5734
- if (touching)
5735
- {
5736
- touchGamepadTimer.set();
5737
- if (touchGamepadCenterButtonSize && !wasTouching && paused)
5738
- {
5739
- // touch anywhere to press start when paused
5740
- touchGamepadButtons[9] = 1;
5741
- return;
5742
- }
5743
- }
5744
-
5745
- // don't process touch gamepad if paused
5746
- if (paused) return;
5747
-
5748
- // get center of left and right sides
5749
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5750
- const buttonCenter = touchGamepadButtonCenter();
5751
- const startCenter = mainCanvasSize.scale(.5);
5752
-
5753
- // check each touch point
5754
- for (const touch of e.touches)
5755
- {
5756
- const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
5757
- if (stickCenter.distance(touchPos) < touchGamepadSize)
5758
- {
5759
- // virtual analog stick
5760
- const delta = touchPos.subtract(stickCenter);
5761
- touchGamepadSticks[0] = delta.scale(2/touchGamepadSize).clampLength();
5762
- touchGamepadButtons[10] = 1; // also press a button when touching stick
5763
- }
5764
- else if (buttonCenter.distance(touchPos) < touchGamepadSize)
5765
- {
5766
- if (touchGamepadButtonCount === 1)
5767
- {
5768
- // virtual right analog stick
5769
- const delta = touchPos.subtract(buttonCenter);
5770
- touchGamepadSticks[1] = delta.scale(2/touchGamepadSize).clampLength();
5771
- touchGamepadButtons[11] = 1; // also press a button when touching right stick
5772
- }
5773
- // virtual face buttons
5774
- let button = buttonCenter.subtract(touchPos).direction();
5775
- button = mod(button+2, 4);
5776
- if (touchGamepadButtonCount === 1)
5777
- button = 0;
5778
- else if (touchGamepadButtonCount === 2)
5779
- {
5780
- const delta = buttonCenter.subtract(touchPos);
5781
- button = -delta.x < delta.y ? 1 : 0;
5782
- }
5783
- // fix button locations (swap 2 and 3 to match gamepad layout)
5784
- button = button === 3 ? 2 : button === 2 ? 3 : button;
5785
- if (button < touchGamepadButtonCount)
5786
- touchGamepadButtons[button] = 1;
5787
- }
5788
- else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize)
5789
- {
5790
- // virtual start button in center
5791
- touchGamepadButtons[9] = 1;
5792
- }
5793
- }
5794
- }
5795
5944
  }
5796
5945
 
5797
5946
  // convert a mouse or touch event position to screen space
@@ -5816,6 +5965,9 @@ function inputUpdate()
5816
5965
  mousePos = screenToWorld(mousePosScreen);
5817
5966
  mouseDelta = screenToWorldDelta(mouseDeltaScreen);
5818
5967
 
5968
+ // build the touch gamepad overlay lazily once enabled on a touch device
5969
+ touchGamepadInit();
5970
+
5819
5971
  // update gamepads if enabled
5820
5972
  gamepadsUpdate();
5821
5973
 
@@ -5829,22 +5981,16 @@ function inputUpdate()
5829
5981
  v > min ? percent(v, min, max) :
5830
5982
  v < -min ? -percent(-v, min, max) : 0;
5831
5983
  return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
5832
- }
5984
+ };
5833
5985
 
5834
5986
  // update touch gamepad if enabled
5835
5987
  if (touchGamepadEnable && isTouchDevice)
5836
5988
  {
5837
- if (debugGamepads)
5838
- {
5839
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5840
- const buttonCenter = touchGamepadButtonCenter();
5841
- const startCenter = mainCanvasSize.scale(.5);
5842
-
5843
- debugCircle(stickCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
5844
- debugCircle(buttonCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
5845
- if (touchGamepadCenterButtonSize)
5846
- debugCircle(startCenter, 2*touchGamepadCenterButtonSize, 'cyan', 0, false, true);
5847
- }
5989
+ // a side is either a stick or buttons - setting both is ambiguous
5990
+ ASSERT(!touchGamepadLeftStick || !touchGamepadLeftButtonCount,
5991
+ 'set touchGamepadLeftStick or touchGamepadLeftButtonCount, not both');
5992
+ ASSERT(!touchGamepadRightStick || !touchGamepadButtonCount,
5993
+ 'set touchGamepadRightStick or touchGamepadButtonCount, not both');
5848
5994
 
5849
5995
  if (!touchGamepadTimer.isSet()) return;
5850
5996
 
@@ -5852,23 +5998,24 @@ function inputUpdate()
5852
5998
  gamepadPrimary = 0; // touch gamepad uses index 0
5853
5999
  const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
5854
6000
  const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
5855
- sticks[0] = vec2();
6001
+ sticks.length = 0; // only report sticks that are enabled
5856
6002
  dpad.set();
5857
- const leftTouchStick = touchGamepadSticks[0] ?? vec2();
5858
- if (touchGamepadAnalog)
5859
- sticks[0] = applyDeadZones(leftTouchStick);
5860
- else if (leftTouchStick.lengthSquared() > .3)
6003
+ // read each side's directional stick (analog, or quantized to an 8 way dpad)
6004
+ for (let side = 0; side < 2; side++)
5861
6005
  {
5862
- // convert to 8 way dpad
5863
- const x = clamp(round(leftTouchStick.x), -1, 1);
5864
- const y = clamp(round(leftTouchStick.y), -1, 1);
5865
- dpad.set(x, -y);
5866
- sticks[0] = dpad.clampLength(); // clamp to circle
5867
- }
5868
- if (touchGamepadButtonCount === 1)
5869
- {
5870
- const rightTouchStick = touchGamepadSticks[1] ?? vec2();
5871
- sticks[1] = applyDeadZones(rightTouchStick);
6006
+ if (!touchGamepadSideStick(side)) continue;
6007
+ const out = touchGamepadStickOut(side);
6008
+ sticks[out] = vec2();
6009
+ const touchStick = touchGamepadSticks[side] ?? vec2();
6010
+ if (touchGamepadAnalog)
6011
+ sticks[out] = applyDeadZones(touchStick);
6012
+ else if (touchStick.lengthSquared() > .3)
6013
+ {
6014
+ const x = clamp(round(touchStick.x), -1, 1);
6015
+ const y = clamp(round(touchStick.y), -1, 1);
6016
+ sticks[out] = vec2(x, -y).clampLength(); // clamp to circle
6017
+ if (!out) dpad.set(x, -y); // the primary (stick 0) also drives the dpad vector
6018
+ }
5872
6019
  }
5873
6020
 
5874
6021
  // read virtual gamepad buttons
@@ -5877,6 +6024,12 @@ function inputUpdate()
5877
6024
  {
5878
6025
  const wasDown = gamepadIsDown(i,0);
5879
6026
  data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
6027
+
6028
+ // haptic tap when a face button or start button is first pressed (3 = newly down)
6029
+ // skip stick touches (10, 11) so movement doesn't buzz
6030
+ if (touchGamepadVibration && data[i] === 3 &&
6031
+ (i === 9 || touchGamepadIsFaceButton(i)))
6032
+ vibrate(touchGamepadVibration);
5880
6033
  }
5881
6034
 
5882
6035
  // disable normal gamepads when touch gamepad is active
@@ -5950,13 +6103,6 @@ function inputUpdate()
5950
6103
  (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
5951
6104
  (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
5952
6105
  }
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
6106
 
5961
6107
  // copy dpad to left analog stick when pressed
5962
6108
  if (gamepadDirectionEmulateStick && (dpad.x || dpad.y))
@@ -5984,80 +6130,470 @@ function inputUpdatePost()
5984
6130
  function inputRender()
5985
6131
  {
5986
6132
  touchGamepadRender();
6133
+ }
6134
+
6135
+ ///////////////////////////////////////////////////////////////////////////////
6136
+ // Touch gamepad - full-viewport HTML/SVG overlay driven by Pointer Events
6137
+
6138
+ const touchGamepadSvgNS = 'http://www.w3.org/2000/svg';
6139
+
6140
+ // build the overlay DOM once; no-op if already built, disabled, headless, or non-touch
6141
+ function touchGamepadInit()
6142
+ {
6143
+ if (touchGamepadOverlay || !touchGamepadEnable || !isTouchDevice || headlessMode ||
6144
+ !document.body) // body may not exist yet; retry on a later frame
6145
+ return;
6146
+
6147
+ // full-viewport overlay; only the input zones receive pointer events. The
6148
+ // env() padding insets the stage out of notches / home indicators natively.
6149
+ const overlay = touchGamepadOverlay = document.createElement('div');
6150
+ overlay.style.cssText =
6151
+ 'position:fixed;inset:0;z-index:50;pointer-events:none;opacity:0;' +
6152
+ 'touch-action:none;user-select:none;-webkit-user-select:none;' +
6153
+ '-webkit-touch-callout:none;transition:opacity .2s;box-sizing:border-box;' +
6154
+ 'padding:env(safe-area-inset-top) env(safe-area-inset-right) ' +
6155
+ 'env(safe-area-inset-bottom) env(safe-area-inset-left)';
6156
+
6157
+ // stage fills the padded (safe-area) content box; all controls live inside it
6158
+ const stage = touchGamepadStage = document.createElement('div');
6159
+ stage.style.cssText = 'position:relative;width:100%;height:100%;pointer-events:none';
6160
+ overlay.appendChild(stage);
6161
+
6162
+ // svg draws every visual and never blocks input
6163
+ const svg = touchGamepadSvg = document.createElementNS(touchGamepadSvgNS, 'svg');
6164
+ svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;' +
6165
+ 'pointer-events:none;overflow:visible;fill:none;stroke:#fff;stroke-width:3';
6166
+ stage.appendChild(svg);
6167
+
6168
+ // invisible input zones (left stick, right buttons/stick, center start)
6169
+ const makeZone = ()=>
6170
+ {
6171
+ const z = document.createElement('div');
6172
+ z.style.cssText = 'position:absolute;pointer-events:auto;touch-action:none';
6173
+ z.addEventListener('pointerdown', e=> touchGamepadPointerDown(e, z));
6174
+ z.addEventListener('pointermove', e=> touchGamepadPointerMove(e));
6175
+ z.addEventListener('pointerup', e=> touchGamepadPointerUp(e));
6176
+ z.addEventListener('pointercancel', e=> touchGamepadPointerUp(e));
6177
+ stage.appendChild(z);
6178
+ return z;
6179
+ };
6180
+ touchGamepadSideZones[0] = makeZone(); // left
6181
+ touchGamepadSideZones[1] = makeZone(); // right
6182
+ touchGamepadZoneC = makeZone(); // center/start, appended last so it sits above the sides
6183
+
6184
+ addEventListener('resize', ()=> touchGamepadNeedRelayout = true);
6185
+ document.body.appendChild(overlay);
6186
+ touchGamepadNeedRelayout = true;
6187
+ }
6188
+
6189
+ // stage-local size in CSS pixels (excludes safe-area insets)
6190
+ function touchGamepadStageRect() { return touchGamepadStage.getBoundingClientRect(); }
6191
+
6192
+ // per-side touch gamepad config (side 0 = left, 1 = right) - the left and right
6193
+ // sides behave identically, differing only in position and gamepad button indices
6194
+ function touchGamepadSideStick(side)
6195
+ { return side ? touchGamepadRightStick : touchGamepadLeftStick; }
6196
+ function touchGamepadSideButtonCount(side)
6197
+ { return side ? touchGamepadButtonCount : touchGamepadLeftButtonCount; }
6198
+ // gamepad button index a side's buttons start at (right 0-3, left 4-7)
6199
+ function touchGamepadSideButtonBase(side)
6200
+ { return side ? 0 : 4; }
6201
+ // output stick index for a side: the right stick uses stick 0 when there is no left stick
6202
+ function touchGamepadStickOut(side)
6203
+ { return side && touchGamepadLeftStick ? 1 : 0; }
6204
+ // true if the side has any control (a stick or at least one button)
6205
+ function touchGamepadSideHasControl(side)
6206
+ { return touchGamepadSideStick(side) || touchGamepadSideButtonCount(side) > 0; }
6207
+
6208
+ // true if gamepad button index i is an active touch gamepad face/single button
6209
+ function touchGamepadIsFaceButton(i)
6210
+ {
6211
+ for (let side = 0; side < 2; side++)
6212
+ {
6213
+ const base = touchGamepadSideButtonBase(side);
6214
+ if (!touchGamepadSideStick(side) &&
6215
+ i >= base && i < base + touchGamepadSideButtonCount(side))
6216
+ return true;
6217
+ }
6218
+ return false;
6219
+ }
5987
6220
 
5988
- function touchGamepadRender()
6221
+ // center of a side's controls in stage-local CSS pixels (stick rest / button cluster)
6222
+ // returns the floating stick anchor when that side is an active floating stick
6223
+ function touchGamepadSideCenter(side, W, H)
6224
+ {
6225
+ if (touchGamepadFloating && touchGamepadSideStick(side) && touchGamepadStickAnchors[side])
6226
+ return touchGamepadStickAnchors[side];
6227
+ let y = H - touchGamepadSize;
6228
+ const count = touchGamepadSideButtonCount(side);
6229
+ if (!touchGamepadSideStick(side) && (count === 2 || count === 3))
6230
+ y -= touchGamepadSize/4; // nudge a 2/3 button cluster up a bit
6231
+ return vec2(side ? W - touchGamepadSize : touchGamepadSize, y);
6232
+ }
6233
+
6234
+ // position the input zones for the current mode and rebuild the SVG visuals
6235
+ function touchGamepadRelayout()
6236
+ {
6237
+ if (!touchGamepadOverlay) return;
6238
+ const r = touchGamepadStageRect();
6239
+ const W = r.width, H = r.height, S = touchGamepadSize;
6240
+ const setZone = (z, css)=> z.style.cssText =
6241
+ 'position:absolute;pointer-events:auto;touch-action:none;' + css;
6242
+
6243
+ if (paused && touchGamepadCenterButtonSize)
6244
+ {
6245
+ // while paused, any touch presses start
6246
+ setZone(touchGamepadZoneC, 'inset:0');
6247
+ for (const zone of touchGamepadSideZones) zone.style.display = 'none';
6248
+ touchGamepadZoneC.style.display = '';
6249
+ }
6250
+ else
5989
6251
  {
5990
- if (!touchInputEnable || !isTouchDevice || headlessMode) return;
5991
- if (!touchGamepadEnable || !touchGamepadTimer.isSet() && touchGamepadDisplayTime) return;
6252
+ // position each side zone (left/right differ only by which edge they hug)
6253
+ for (let side = 0; side < 2; side++)
6254
+ {
6255
+ const zone = touchGamepadSideZones[side], edge = side ? 'right' : 'left';
6256
+ zone.style.display = touchGamepadSideHasControl(side) ? '' : 'none';
6257
+ if (touchGamepadFloating)
6258
+ {
6259
+ // bottom 60% grabs the control; the top 40% passes through. A side with no
6260
+ // control on the other side uses the full width (matching the hit-test)
6261
+ const width = touchGamepadSideHasControl(side ? 0 : 1) ? '50%' : '100%';
6262
+ setZone(zone, `${edge}:0;bottom:0;width:${width};height:60%`);
6263
+ }
6264
+ else // fixed: a compact box hugging the corner control
6265
+ setZone(zone, `${edge}:0;bottom:0;width:${3*S}px;height:${3*S}px`);
6266
+ }
6267
+ touchGamepadZoneC.style.display = touchGamepadCenterButtonSize ? '' : 'none';
6268
+ const c = touchGamepadCenterButtonSize;
6269
+ setZone(touchGamepadZoneC,
6270
+ `left:50%;top:50%;width:${2*c}px;height:${2*c}px;transform:translate(-50%,-50%)`);
6271
+ }
5992
6272
 
5993
- // fade off when not touching or paused
5994
- const alpha = touchGamepadDisplayTime ? percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
5995
- if (!alpha || paused) return;
6273
+ touchGamepadBuildSvg(W, H);
6274
+ touchGamepadNeedRelayout = false;
6275
+ }
5996
6276
 
5997
- // setup the canvas
5998
- const context = mainContext;
5999
- context.save();
6000
- context.globalAlpha = alpha*touchGamepadAlpha;
6001
- context.strokeStyle = '#fff';
6002
- context.lineWidth = 3;
6277
+ // (re)build the SVG shapes for the current layout; dynamic bits update per-frame
6278
+ function touchGamepadBuildSvg(W, H)
6279
+ {
6280
+ const svg = touchGamepadSvg;
6281
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
6282
+ const els = touchGamepadSvgEls = { face: [], thumb: [] };
6283
+ const S = touchGamepadSize;
6284
+ const circle = (cx, cy, rr, fill)=>
6285
+ {
6286
+ const c = document.createElementNS(touchGamepadSvgNS, 'circle');
6287
+ c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', rr);
6288
+ if (fill) c.setAttribute('fill', fill);
6289
+ svg.appendChild(c);
6290
+ return c;
6291
+ };
6292
+ const cross = (ctr)=>
6293
+ {
6294
+ // plus-shaped dpad outline centered at ctr
6295
+ const a = S*.18, b = S*.5, x = ctr.x, y = ctr.y;
6296
+ const p = document.createElementNS(touchGamepadSvgNS, 'path');
6297
+ p.setAttribute('d',
6298
+ `M ${x-a} ${y-b} H ${x+a} V ${y-a} H ${x+b} V ${y+a} H ${x+a} ` +
6299
+ `V ${y+b} H ${x-a} V ${y+a} H ${x-b} V ${y-a} H ${x-a} Z`);
6300
+ svg.appendChild(p);
6301
+ };
6003
6302
 
6004
- // draw left analog stick
6005
- const leftTouchStick = touchGamepadSticks[0] ?? vec2();
6006
- context.fillStyle = leftTouchStick.lengthSquared() > 0 ? '#fff' : '#000';
6007
- context.beginPath();
6008
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
6009
- if (touchGamepadAnalog)
6303
+ // draw each side: a directional stick, a single large button, or face buttons
6304
+ for (let side = 0; side < 2; side++)
6305
+ {
6306
+ const count = touchGamepadSideButtonCount(side);
6307
+ const base = touchGamepadSideButtonBase(side);
6308
+ const ctr = touchGamepadSideCenter(side, W, H);
6309
+ if (touchGamepadSideStick(side))
6010
6310
  {
6011
- // draw circle shaped gamepad
6012
- context.arc(stickCenter.x, stickCenter.y, touchGamepadSize/2, 0, 9);
6311
+ // directional stick (circle or cross) with a thumb dot that moves per-frame
6312
+ if (touchGamepadAnalog) circle(ctr.x, ctr.y, S/2); else cross(ctr);
6313
+ els.thumb[side] = circle(ctr.x, ctr.y, S/4, '#fff');
6013
6314
  }
6014
- else
6315
+ else if (count === 1)
6316
+ els.face[base] = circle(ctr.x, ctr.y, S/2, '#000'); // single large button
6317
+ else for (let i = 0; i < count; i++)
6015
6318
  {
6016
- // draw cross shaped gamepad
6017
- for (let i=10; --i;)
6018
- {
6019
- const angle = i*PI/4;
6020
- context.arc(stickCenter.x, stickCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8);
6021
- i%2 && context.arc(stickCenter.x, stickCenter.y, touchGamepadSize*.33, angle, angle);
6022
- }
6319
+ const j = mod(i-1, 4);
6320
+ let button = count > 2 ? j : min(j, count-1);
6321
+ button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
6322
+ const offset = vec2().setDirection(j, S/2);
6323
+ if (count === 2) offset.x *= -1;
6324
+ // left side mirrors the right layout's positions, keeping indices in order
6325
+ // (e.g. 2 buttons -> button 4 at bottom, button 5 at left)
6326
+ if (!side) offset.x *= -1;
6327
+ const pos = ctr.add(offset);
6328
+ els.face[base + button] = circle(pos.x, pos.y, S/4, '#000');
6023
6329
  }
6024
- context.fill();
6025
- context.stroke();
6330
+ }
6026
6331
 
6027
- // draw right face buttons
6332
+ // debug: draw the proximity hit regions the hit-test actually uses
6333
+ if (debug && debugGamepads) touchGamepadBuildDebug(W, H);
6334
+ }
6335
+
6336
+ // draw debug outlines of the touch control hit regions into the overlay svg
6337
+ function touchGamepadBuildDebug(W, H)
6338
+ {
6339
+ const S = touchGamepadSize, svg = touchGamepadSvg;
6340
+ const shape = (tag, attrs, stroke)=>
6341
+ {
6342
+ const el = document.createElementNS(touchGamepadSvgNS, tag);
6343
+ for (const k in attrs) el.setAttribute(k, attrs[k]);
6344
+ el.setAttribute('stroke', stroke);
6345
+ el.setAttribute('stroke-width', 2);
6346
+ el.setAttribute('fill', 'none');
6347
+ svg.appendChild(el);
6348
+ };
6349
+ const ring = (c, rr, stroke)=> shape('circle', {cx:c.x, cy:c.y, r:rr}, stroke);
6350
+
6351
+ // green line: the left/right split that assigns a stick press to a side
6352
+ shape('line', {x1:W/2, y1:0, x2:W/2, y2:H}, '#0f0');
6353
+
6354
+ // cyan: where each side's control can be grabbed
6355
+ for (let side = 0; side < 2; side++)
6356
+ {
6357
+ if (touchGamepadSideStick(side))
6028
6358
  {
6029
- const buttonCenter = touchGamepadButtonCenter();
6030
- const buttonSize = touchGamepadButtonCount > 1 ?
6031
- touchGamepadSize/4 : touchGamepadSize/2;
6032
- for (let i=0; i<touchGamepadButtonCount; i++)
6359
+ if (touchGamepadFloating)
6033
6360
  {
6034
- const j = mod(i-1, 4);
6035
- let button = touchGamepadButtonCount > 2 ?
6036
- j : min(j, touchGamepadButtonCount-1);
6037
- // fix button locations (swap 2 and 3 to match gamepad layout)
6038
- button = button === 3 ? 2 : button === 2 ? 3 : button;
6039
- const pos = touchGamepadButtonCount < 2 ? buttonCenter :
6040
- buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
6041
- context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
6042
- context.beginPath();
6043
- context.arc(pos.x, pos.y, buttonSize, 0,9);
6044
- context.fill();
6045
- context.stroke();
6361
+ // grab region: this side's half (or the full width if the other side is empty)
6362
+ const top = H*.4, full = !touchGamepadSideHasControl(side ? 0 : 1);
6363
+ const x = full ? 0 : (side ? W/2 : 0);
6364
+ shape('rect', {x, y:top, width:full ? W : W/2, height:H-top}, '#0ff');
6046
6365
  }
6366
+ else
6367
+ ring(touchGamepadSideCenter(side, W, H), 2*S, '#0ff');
6047
6368
  }
6369
+ else if (touchGamepadSideButtonCount(side) >= 1)
6370
+ ring(touchGamepadSideCenter(side, W, H), S, '#0ff'); // face / single-button radius
6371
+ }
6048
6372
 
6049
- // set canvas back to normal
6050
- context.restore();
6373
+ // yellow: start button radius; magenta: where start is blocked (near a control)
6374
+ if (touchGamepadCenterButtonSize)
6375
+ {
6376
+ ring(vec2(W/2, H/2), touchGamepadCenterButtonSize, '#ff0');
6377
+ for (let side = 0; side < 2; side++)
6378
+ if (touchGamepadSideHasControl(side))
6379
+ ring(touchGamepadSideCenter(side, W, H), 2*S, '#f0f');
6380
+ }
6381
+ }
6382
+
6383
+ // per-frame: fade the overlay and move the thumbs / set pressed states
6384
+ function touchGamepadRender()
6385
+ {
6386
+ if (!touchGamepadOverlay || headlessMode) return;
6387
+
6388
+ // hide and bail if disabled at runtime (overlay stays in the DOM for reuse)
6389
+ // display:none also takes the input zones out of hit-testing so touches are
6390
+ // not silently captured away from the game while disabled
6391
+ if (!touchGamepadEnable || !isTouchDevice)
6392
+ {
6393
+ if (touchGamepadOverlay.style.display !== 'none')
6394
+ {
6395
+ // just disabled: hide the overlay and release any held controls
6396
+ touchGamepadOverlay.style.display = 'none';
6397
+ touchGamepadPointerRole.clear();
6398
+ touchGamepadButtons.length = 0;
6399
+ touchGamepadSticks.length = 0;
6400
+ touchGamepadStickPointerId.length = 0;
6401
+ }
6402
+ return;
6403
+ }
6404
+ touchGamepadOverlay.style.display = '';
6405
+
6406
+ // relayout when the paused state, a layout setting, or the debug view changes
6407
+ const dbg = debug && debugGamepads;
6408
+ const layout = [touchGamepadButtonCount, touchGamepadLeftButtonCount, touchGamepadLeftStick,
6409
+ touchGamepadRightStick, touchGamepadAnalog, touchGamepadSize, touchGamepadFloating,
6410
+ touchGamepadCenterButtonSize, paused, dbg].join();
6411
+ if (layout !== touchGamepadLastLayout)
6412
+ {
6413
+ touchGamepadLastLayout = layout;
6414
+ touchGamepadNeedRelayout = true;
6415
+ }
6416
+ // relayout before the visibility bail-out so the paused full-screen start zone applies
6417
+ if (touchGamepadNeedRelayout) touchGamepadRelayout();
6418
+
6419
+ // fade out when idle (always show when displayTime is 0, or while debugging)
6420
+ const fade = touchGamepadDisplayTime ?
6421
+ percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
6422
+ const visible = dbg || (touchGamepadTimer.isSet() && fade > 0 && !paused);
6423
+ touchGamepadOverlay.style.opacity = !visible ? 0 : dbg ? 1 : fade*touchGamepadAlpha;
6424
+ if (!visible) return;
6425
+
6426
+ const r = touchGamepadStageRect();
6427
+ const W = r.width, H = r.height, S = touchGamepadSize;
6428
+ const els = touchGamepadSvgEls;
6429
+ if (!els) return;
6430
+
6431
+ for (let side = 0; side < 2; side++)
6432
+ if (touchGamepadSideStick(side) && els.thumb[side])
6433
+ {
6434
+ const ctr = touchGamepadSideCenter(side, W, H);
6435
+ const t = ctr.add((touchGamepadSticks[side] ?? vec2()).scale(S/2));
6436
+ els.thumb[side].setAttribute('cx', t.x);
6437
+ els.thumb[side].setAttribute('cy', t.y);
6438
+ }
6439
+ for (let i = 0; i < els.face.length; i++)
6440
+ if (els.face[i])
6441
+ els.face[i].setAttribute('fill', touchGamepadButtons[i] ? '#fff' : '#000');
6442
+ }
6443
+
6444
+ // convert a pointer event to stage-local CSS pixels
6445
+ function touchGamepadEventPos(e)
6446
+ {
6447
+ const r = touchGamepadStageRect();
6448
+ return vec2(e.clientX - r.left, e.clientY - r.top);
6449
+ }
6450
+
6451
+ // set a directional stick from a stage-local point and flag its stick-touch button
6452
+ // (stick 0 press = button 10, stick 1 press = button 11, following the output index)
6453
+ function touchGamepadApplyStick(side, p)
6454
+ {
6455
+ const delta = p.subtract(touchGamepadStickAnchors[side]);
6456
+ touchGamepadSticks[side] = delta.scale(2/touchGamepadSize).clampLength();
6457
+ touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10] = 1;
6458
+ }
6459
+
6460
+ // pick a side's gamepad button index from a stage-local point, or -1 if outside the cluster
6461
+ function touchGamepadFaceButtonAt(side, p, W, H)
6462
+ {
6463
+ const count = touchGamepadSideButtonCount(side);
6464
+ const base = touchGamepadSideButtonBase(side);
6465
+ const bc = touchGamepadSideCenter(side, W, H);
6466
+ if (bc.distance(p) >= touchGamepadSize) return -1;
6467
+ if (count === 1) return base; // single large button
6468
+ const d = bc.subtract(p);
6469
+ if (!side) d.x *= -1; // left side mirrors the right layout's positions horizontally
6470
+ let button = count === 2 ? (d.x < d.y ? 1 : 0) : mod(d.direction()+2, 4);
6471
+ button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
6472
+ return button < count ? base + button : -1;
6473
+ }
6474
+
6475
+ // pick which control a stage-local press activates, by priority then proximity,
6476
+ // independent of which zone element captured it - so overlapping zones on small
6477
+ // screens resolve to the nearest control instead of whichever zone is topmost
6478
+ // returns {role:'stick', side} or {role:'face', btn} or {role:'start'} or undefined
6479
+ function touchGamepadControlAt(p, W, H)
6480
+ {
6481
+ const S = touchGamepadSize;
6482
+ const leftHalf = p.x < W/2;
6483
+ const floatTop = H*.4; // floating grab region is the bottom 60% of the screen
6484
+
6485
+ // check each side (left first for priority); a side is a stick or buttons
6486
+ for (let side = 0; side < 2; side++)
6487
+ {
6488
+ const onHalf = side ? !leftHalf : leftHalf;
6489
+ if (touchGamepadSideStick(side))
6490
+ {
6491
+ // a side with no control on the other side uses the full width
6492
+ const otherControl = touchGamepadSideHasControl(side ? 0 : 1);
6493
+ const grab = touchGamepadFloating ?
6494
+ (!otherControl || onHalf) && p.y > floatTop :
6495
+ onHalf && touchGamepadSideCenter(side, W, H).distance(p) < 2*S;
6496
+ if (grab) return {role:'stick', side};
6497
+ }
6498
+ else if (touchGamepadSideButtonCount(side) >= 1)
6499
+ {
6500
+ const btn = touchGamepadFaceButtonAt(side, p, W, H);
6501
+ if (btn >= 0) return {role:'face', btn};
6502
+ }
6503
+ }
6504
+
6505
+ // center start button, blocked within 2*size of a control so drift off a
6506
+ // control can't accidentally fire start (matches the original exclusion logic)
6507
+ if (touchGamepadCenterButtonSize)
6508
+ {
6509
+ for (let side = 0; side < 2; side++)
6510
+ if (touchGamepadSideHasControl(side) &&
6511
+ touchGamepadSideCenter(side, W, H).distance(p) < 2*S)
6512
+ return;
6513
+ if (vec2(W/2, H/2).distance(p) < touchGamepadCenterButtonSize)
6514
+ return {role:'start'};
6515
+ }
6516
+ }
6517
+
6518
+ function touchGamepadPointerDown(e, zone)
6519
+ {
6520
+ if (!touchGamepadEnable) return;
6521
+ e.preventDefault();
6522
+ zone.setPointerCapture(e.pointerId);
6523
+ touchGamepadTimer.set();
6524
+ isUsingGamepad = true;
6525
+
6526
+ // resume audio on first interaction
6527
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
6528
+ audioContext.resume();
6529
+
6530
+ // while paused, any touch is the start button
6531
+ if (paused)
6532
+ {
6533
+ if (touchGamepadCenterButtonSize)
6534
+ {
6535
+ touchGamepadButtons[9] = 1;
6536
+ touchGamepadPointerRole.set(e.pointerId, 'start');
6537
+ }
6538
+ return;
6539
+ }
6540
+
6541
+ const r = touchGamepadStageRect();
6542
+ const W = r.width, H = r.height;
6543
+ const p = vec2(e.clientX - r.left, e.clientY - r.top);
6544
+
6545
+ // choose the control by proximity/priority, not by which zone captured the touch
6546
+ const hit = touchGamepadControlAt(p, W, H);
6547
+ if (!hit) return;
6548
+ if (hit.role === 'stick')
6549
+ {
6550
+ const side = hit.side;
6551
+ touchGamepadStickAnchors[side] = touchGamepadFloating ? p : touchGamepadSideCenter(side, W, H);
6552
+ touchGamepadStickPointerId[side] = e.pointerId;
6553
+ touchGamepadPointerRole.set(e.pointerId, 'stick'+side);
6554
+ touchGamepadNeedRelayout = true; // base may have re-anchored
6555
+ touchGamepadApplyStick(side, p);
6556
+ }
6557
+ else if (hit.role === 'face')
6558
+ {
6559
+ touchGamepadButtons[hit.btn] = 1;
6560
+ touchGamepadPointerRole.set(e.pointerId, 'face'+hit.btn);
6561
+ }
6562
+ else // 'start'
6563
+ {
6564
+ touchGamepadButtons[9] = 1;
6565
+ touchGamepadPointerRole.set(e.pointerId, 'start');
6051
6566
  }
6052
6567
  }
6053
6568
 
6054
- // center position for right touch pad face buttons
6055
- function touchGamepadButtonCenter()
6569
+ function touchGamepadPointerMove(e)
6056
6570
  {
6057
- const center = mainCanvasSize.subtract(vec2(touchGamepadSize));
6058
- if (touchGamepadButtonCount === 2)
6059
- center.x += touchGamepadSize/2;
6060
- return center;
6571
+ const role = touchGamepadPointerRole.get(e.pointerId);
6572
+ if (!role) return;
6573
+ e.preventDefault();
6574
+ const p = touchGamepadEventPos(e);
6575
+ if (role === 'stick0' || role === 'stick1')
6576
+ touchGamepadApplyStick(role === 'stick1' ? 1 : 0, p);
6577
+ // face buttons & start are held until release (no slide-between this pass)
6578
+ }
6579
+
6580
+ function touchGamepadPointerUp(e)
6581
+ {
6582
+ const role = touchGamepadPointerRole.get(e.pointerId);
6583
+ if (!role) return;
6584
+ touchGamepadPointerRole.delete(e.pointerId);
6585
+ if (role === 'stick0' || role === 'stick1')
6586
+ {
6587
+ const side = role === 'stick1' ? 1 : 0;
6588
+ touchGamepadStickPointerId[side] = undefined;
6589
+ touchGamepadSticks[side] = vec2();
6590
+ delete touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10];
6591
+ }
6592
+ else if (role === 'start')
6593
+ delete touchGamepadButtons[9];
6594
+ else // 'face<n>'
6595
+ delete touchGamepadButtons[+role.slice(4)];
6596
+ touchGamepadTimer.set();
6061
6597
  }
6062
6598
  /**
6063
6599
  * LittleJS Audio System
@@ -6162,7 +6698,7 @@ class Sound
6162
6698
  /** @property {SoundLoadCallback} - function to call when sound is loaded */
6163
6699
  this.onloadCallback = onloadCallback;
6164
6700
 
6165
- if (Array.isArray(asset))
6701
+ if (isArray(asset))
6166
6702
  {
6167
6703
  // generate zzfx sound — copy so we don't mutate the caller's array
6168
6704
  const zzfxSound = asset.slice();
@@ -6194,7 +6730,7 @@ class Sound
6194
6730
  * @param {number} [randomnessScale] - How much to scale pitch randomness
6195
6731
  * @param {boolean} [loop] - Should the sound loop?
6196
6732
  * @param {boolean} [paused] - Should the sound start paused
6197
- * @return {SoundInstance} - The audio source node
6733
+ * @return {SoundInstance} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode
6198
6734
  */
6199
6735
  play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
6200
6736
  {
@@ -6224,10 +6760,24 @@ class Sound
6224
6760
  // get pan from screen space coords
6225
6761
  pan = worldToScreen(pos).x * 2/mainCanvas.width - 1;
6226
6762
  }
6227
-
6228
- // Create and return sound instance
6229
- const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
6230
- return new SoundInstance(this, volume, rate, pan, loop, paused);
6763
+
6764
+ // Create sound instance
6765
+ const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
6766
+ const instance = new SoundInstance(this, volume, rate, pan, loop, paused);
6767
+
6768
+ if (debug && debugSound && pos)
6769
+ {
6770
+ // visualize where positioned sounds play and their falloff range
6771
+ debugCircle(pos, .5, '#0ff', .5, true);
6772
+ if (this.range)
6773
+ {
6774
+ debugCircle(pos, 2*this.range, '#0ff', .5); // silent radius
6775
+ debugCircle(pos, 2*this.range*this.taper, '#0ff', .5); // full volume radius
6776
+ }
6777
+ debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5);
6778
+ }
6779
+
6780
+ return instance;
6231
6781
  }
6232
6782
 
6233
6783
  /** Play a music track that loops by default
@@ -6254,7 +6804,7 @@ class Sound
6254
6804
  }
6255
6805
 
6256
6806
  /** Get how long this sound is in seconds
6257
- * @return {number} - How long the sound is in seconds (undefined if loading)
6807
+ * @return {number} - How long the sound is in seconds (0 if loading)
6258
6808
  */
6259
6809
  getDuration()
6260
6810
  { return this.sampleChannels?.[0]?.length / this.sampleRate || 0; }
@@ -6411,10 +6961,14 @@ class SoundInstance
6411
6961
  {
6412
6962
  if (fadeTime)
6413
6963
  {
6414
- // ramp off gain
6964
+ // ramp off gain from current volume (not 1, or low-volume
6965
+ // instances would jump back up before fading);
6966
+ // cancel any prior scheduling so stacked stop calls don't
6967
+ // re-anchor partway through a previous fade
6415
6968
  const startFade = audioContext.currentTime;
6416
6969
  const endFade = startFade + fadeTime;
6417
- this.gainNode.gain.linearRampToValueAtTime(1, startFade);
6970
+ this.gainNode.gain.cancelScheduledValues(startFade);
6971
+ this.gainNode.gain.setValueAtTime(this.volume, startFade);
6418
6972
  this.gainNode.gain.linearRampToValueAtTime(0, endFade);
6419
6973
  this.source.stop(endFade);
6420
6974
  }
@@ -6462,13 +7016,14 @@ class SoundInstance
6462
7016
  */
6463
7017
  getCurrentTime()
6464
7018
  {
6465
- const deltaTime = mod(audioContext.currentTime - this.startTime,
6466
- this.getDuration());
6467
- return this.isPlaying() ? deltaTime : this.pausedTime;
7019
+ if (!this.isPlaying()) return this.pausedTime;
7020
+ const duration = this.getDuration();
7021
+ // guard mod against 0 duration (rate=0 or sound not loaded)
7022
+ return duration ? mod(audioContext.currentTime - this.startTime, duration) : 0;
6468
7023
  }
6469
7024
 
6470
7025
  /** Get the total duration of this sound
6471
- * @return {number} - Total duration in seconds
7026
+ * @return {number} - Total duration in seconds (0 if loading)
6472
7027
  */
6473
7028
  getDuration() { return this.rate ? this.sound.getDuration() / this.rate : 0; }
6474
7029
 
@@ -6492,7 +7047,7 @@ function speak(text, volume=1, rate=1, pitch=1, language='')
6492
7047
  {
6493
7048
  ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch');
6494
7049
  if (!soundEnable || headlessMode) return;
6495
- if (!speechSynthesis) return;
7050
+ if (typeof speechSynthesis === 'undefined') return;
6496
7051
 
6497
7052
  // common languages (not supported by all browsers)
6498
7053
  // en - english, it - italian, fr - french, de - german, es - spanish
@@ -6510,7 +7065,11 @@ function speak(text, volume=1, rate=1, pitch=1, language='')
6510
7065
 
6511
7066
  /** Stop all queued speech
6512
7067
  * @memberof Audio */
6513
- function speakStop() {speechSynthesis?.cancel();}
7068
+ function speakStop()
7069
+ {
7070
+ if (typeof speechSynthesis !== 'undefined')
7071
+ speechSynthesis.cancel();
7072
+ }
6514
7073
 
6515
7074
  /** Get frequency of a note on a musical scale
6516
7075
  * @param {number} semitoneOffset - How many semitones away from the root note
@@ -6572,13 +7131,22 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
6572
7131
  const pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)});
6573
7132
  source.connect(pannerNode).connect(gainNode);
6574
7133
 
6575
- // callback when the sound ends
6576
- if (onended)
6577
- source.addEventListener('ended', ()=> onended(source));
7134
+ // disconnect nodes when the sound ends so the audio graph doesn't grow
7135
+ // unbounded across many play() calls (source.stop() also fires 'ended')
7136
+ source.addEventListener('ended', ()=>
7137
+ {
7138
+ gainNode.disconnect();
7139
+ pannerNode.disconnect();
7140
+ if (onended) onended(source);
7141
+ });
6578
7142
 
6579
7143
  // play and return sound
6580
7144
  const startOffset = offset * rate;
6581
7145
  source.start(0, startOffset);
7146
+
7147
+ if (debug && debugSound)
7148
+ LOG('sound', 'vol', volume.toFixed(2), 'rate', rate.toFixed(2), 'pan', pan.toFixed(2), loop ? 'loop' : '');
7149
+
6582
7150
  return source;
6583
7151
  }
6584
7152
 
@@ -6811,14 +7379,29 @@ function tileCollisionTest(pos, size=vec2(), callbackObject, solidOnly=true)
6811
7379
  * @memberof TileLayers */
6812
7380
  function tileCollisionRaycast(posStart, posEnd, callbackObject, normal, solidOnly=true)
6813
7381
  {
7382
+ // check every layer and keep the closest hit so a far hit in an
7383
+ // earlier-registered layer doesn't shadow a closer hit in a later one
7384
+ let closestHit, closestDistSq, closestNormal;
7385
+ const scratchNormal = normal && vec2();
6814
7386
  for (const layer of tileCollisionLayers)
6815
7387
  {
6816
7388
  if (!solidOnly || layer.isSolid)
6817
7389
  {
6818
- const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, normal)
6819
- if (hitPos) return hitPos;
7390
+ const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, scratchNormal);
7391
+ if (hitPos)
7392
+ {
7393
+ const d = posStart.distanceSquared(hitPos);
7394
+ if (closestHit === undefined || d < closestDistSq)
7395
+ {
7396
+ closestHit = hitPos;
7397
+ closestDistSq = d;
7398
+ if (normal) closestNormal = scratchNormal.copy();
7399
+ }
7400
+ }
6820
7401
  }
6821
7402
  }
7403
+ if (closestHit && normal) normal.setFrom(closestNormal);
7404
+ return closestHit;
6822
7405
  }
6823
7406
 
6824
7407
  ///////////////////////////////////////////////////////////////////////////////
@@ -6992,38 +7575,6 @@ class CanvasLayer extends EngineObject
6992
7575
  drawTile(pos, size, tileInfo, color, angle, mirror, additiveColor, useWebGL, screenSpace, context);
6993
7576
  }
6994
7577
 
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
7578
  /** Create WebGL texture if necessary and copy layer canvas to it */
7028
7579
  updateWebGL()
7029
7580
  { this.textureInfo.createWebGLTexture(); }
@@ -7079,6 +7630,8 @@ class TileLayer extends CanvasLayer
7079
7630
  this.redrawTileData = ()=> {};
7080
7631
  this.drawLayerTile = ()=> {};
7081
7632
  this.drawLayerRect = ()=> {};
7633
+ this.drawTile = ()=> {};
7634
+ this.drawRect = ()=> {};
7082
7635
  this.clearLayerRect = ()=> {};
7083
7636
  return;
7084
7637
  }
@@ -7106,7 +7659,7 @@ class TileLayer extends CanvasLayer
7106
7659
  ASSERT(data instanceof TileLayerData, 'data must be a TileLayerData');
7107
7660
 
7108
7661
  if (!layerPos.arrayCheck(this.size)) return;
7109
- this.data[(layerPos.y|0)*this.size.x+layerPos.x|0] = data;
7662
+ this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] = data;
7110
7663
 
7111
7664
  if (!redraw) return;
7112
7665
  const isRedraw = drawContext === this.context;
@@ -7121,11 +7674,11 @@ class TileLayer extends CanvasLayer
7121
7674
 
7122
7675
  /** Get data at a given position in the array
7123
7676
  * @param {Vector2} layerPos - Local position in array
7124
- * @return {TileLayerData} */
7677
+ * @return {TileLayerData|undefined} */
7125
7678
  getData(layerPos)
7126
- {
7679
+ {
7127
7680
  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];
7681
+ return layerPos.arrayCheck(this.size) ? this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] : undefined;
7129
7682
  }
7130
7683
 
7131
7684
  // Update the tile layer, refresh texture if needed
@@ -7233,7 +7786,7 @@ class TileLayer extends CanvasLayer
7233
7786
 
7234
7787
  // draw the tile if it has layer data
7235
7788
  const d = this.getData(layerPos);
7236
- if (!d.tile) return;
7789
+ if (!d || !d.tile) return;
7237
7790
 
7238
7791
  const tileInfo = this.tileInfo && this.tileInfo.tile(d.tile);
7239
7792
  this.drawLayerTile(drawPos, drawSize, tileInfo, d.color, d.direction*PI/2, d.mirror);
@@ -7279,6 +7832,38 @@ class TileLayer extends CanvasLayer
7279
7832
  drawLayerRect(pos, size, color, angle=0)
7280
7833
  { this.drawLayerTile(pos, size, undefined, color, angle); }
7281
7834
 
7835
+ /** Draw a tile onto the layer canvas in world space
7836
+ * @param {Vector2} pos
7837
+ * @param {Vector2} [size=vec2(1)]
7838
+ * @param {TileInfo} [tileInfo]
7839
+ * @param {Color} [color=WHITE]
7840
+ * @param {number} [angle]
7841
+ * @param {boolean} [mirror] */
7842
+ drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
7843
+ {
7844
+ pos = pos.subtract(this.pos).multiply(this.tileInfo.size);
7845
+ size = size.multiply(this.tileInfo.size);
7846
+ pos.y = this.canvas.height - pos.y;
7847
+
7848
+ // draw the tile onto the layer canvas
7849
+ const oldMainCanvasSize = mainCanvasSize;
7850
+ mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
7851
+ const useWebGL = this.hasWebGL();
7852
+ useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
7853
+ const drawContext = useWebGL ? undefined : this.context;
7854
+ drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
7855
+ useWebGL && glSetRenderTarget();
7856
+ mainCanvasSize = oldMainCanvasSize;
7857
+ }
7858
+
7859
+ /** Draw a rectangle onto the layer canvas in world space
7860
+ * @param {Vector2} pos
7861
+ * @param {Vector2} [size=vec2(1)]
7862
+ * @param {Color} [color=WHITE]
7863
+ * @param {number} [angle] */
7864
+ drawRect(pos, size, color, angle)
7865
+ { this.drawTile(pos, size, undefined, color, angle); }
7866
+
7282
7867
  /** Clear a rectangle in layer space
7283
7868
  * @param {Vector2} pos - position in pixel coordinates
7284
7869
  * @param {Vector2} size
@@ -7357,7 +7942,7 @@ class TileCollisionLayer extends TileLayer
7357
7942
  setCollisionData(layerPos, data=1)
7358
7943
  {
7359
7944
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
7360
- const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
7945
+ const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
7361
7946
  layerPos.arrayCheck(this.size) && (this.collisionData[i] = data);
7362
7947
  }
7363
7948
 
@@ -7372,7 +7957,7 @@ class TileCollisionLayer extends TileLayer
7372
7957
  getCollisionData(layerPos)
7373
7958
  {
7374
7959
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
7375
- const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
7960
+ const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
7376
7961
  return layerPos.arrayCheck(this.size) ? this.collisionData[i] : 0;
7377
7962
  }
7378
7963
 
@@ -7395,10 +7980,17 @@ class TileCollisionLayer extends TileLayer
7395
7980
  // check any tiles in the area for collision
7396
7981
  const posX = pos.x - this.pos.x;
7397
7982
  const posY = pos.y - this.pos.y;
7983
+ // reject AABBs entirely past either edge; without this, the negative
7984
+ // side leaks into row/col 0 because minX/minY clamp to 0 and the
7985
+ // point-test floor below forces maxX/maxY up to 1
7986
+ if (posX + size.x/2 < 0 || posX - size.x/2 > this.size.x) return false;
7987
+ if (posY + size.y/2 < 0 || posY - size.y/2 > this.size.y) return false;
7398
7988
  const minX = max(posX - size.x/2|0, 0);
7399
7989
  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);
7990
+ // ensure at least one cell is visited even when size is 0 and pos
7991
+ // lands exactly on an integer boundary (documented point-test mode)
7992
+ const maxX = min(max(posX + size.x/2, minX + 1), this.size.x);
7993
+ const maxY = min(max(posY + size.y/2, minY + 1), this.size.y);
7402
7994
  const hitPos = new Vector2;
7403
7995
  for (let y = minY; y < maxY; ++y)
7404
7996
  for (let x = minX; x < maxX; ++x)
@@ -7511,13 +8103,13 @@ class ParticleEmitter extends EngineObject
7511
8103
  * @param {number} [particleTime] - How long particles live
7512
8104
  * @param {number} [sizeStart] - How big are particles at start
7513
8105
  * @param {number} [sizeEnd] - How big are particles at end
7514
- * @param {number} [speed] - How fast are particles when spawned
7515
- * @param {number} [angleSpeed] - How fast are particles rotating
7516
- * @param {number} [damping] - How much to dampen particle speed
7517
- * @param {number} [angleDamping] - How much to dampen particle angular speed
8106
+ * @param {number} [speed] - How fast are particles when spawned, in world units per frame (at 60fps, so multiply units/sec by 1/60)
8107
+ * @param {number} [angleSpeed] - How fast are particles rotating, in radians per frame (at 60fps)
8108
+ * @param {number} [damping] - How much to dampen particle speed, per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame)
8109
+ * @param {number} [angleDamping] - How much to dampen particle angular speed, per-frame multiplier (1 = no damping)
7518
8110
  * @param {number} [gravityScale] - How much gravity effect particles
7519
8111
  * @param {number} [particleConeAngle] - Cone for start particle angle
7520
- * @param {number} [fadeRate] - How quick to fade particles at start/end in percent of life
8112
+ * @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
8113
  * @param {number} [randomness] - Apply extra randomness percent
7522
8114
  * @param {boolean} [collideTiles] - Do particles collide against tiles
7523
8115
  * @param {boolean} [additive] - Should particles use additive blend
@@ -7589,19 +8181,19 @@ class ParticleEmitter extends EngineObject
7589
8181
  this.sizeStart = sizeStart;
7590
8182
  /** @property {number} - How big are particles at end */
7591
8183
  this.sizeEnd = sizeEnd;
7592
- /** @property {number} - How fast are particles when spawned */
8184
+ /** @property {number} - Particle speed when spawned, in world units per frame (at 60fps) */
7593
8185
  this.speed = speed;
7594
- /** @property {number} - How fast are particles rotating */
8186
+ /** @property {number} - Particle angular speed when spawned, in radians per frame (at 60fps) */
7595
8187
  this.angleSpeed = angleSpeed;
7596
- /** @property {number} - How much to dampen particle speed */
8188
+ /** @property {number} - Per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame) */
7597
8189
  this.damping = damping;
7598
- /** @property {number} - How much to dampen particle angular speed */
8190
+ /** @property {number} - Per-frame angular velocity multiplier (1 = no damping) */
7599
8191
  this.angleDamping = angleDamping;
7600
8192
  /** @property {number} - How much gravity affects particles */
7601
8193
  this.gravityScale = gravityScale;
7602
8194
  /** @property {number} - Cone for start particle angle */
7603
8195
  this.particleConeAngle = particleConeAngle;
7604
- /** @property {number} - How quick to fade in particles at start/end in percent of life */
8196
+ /** @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
8197
  this.fadeRate = fadeRate;
7606
8198
  /** @property {number} - Apply extra randomness percent */
7607
8199
  this.randomness = randomness;
@@ -7664,12 +8256,16 @@ class ParticleEmitter extends EngineObject
7664
8256
  else if (this.particles.length === 0)
7665
8257
  this.destroy(true);
7666
8258
 
7667
- // update and remove destroyed particles
7668
- this.particles = this.particles.filter((p)=>
8259
+ // update and remove destroyed particles in place to avoid per-frame array allocation
8260
+ const particles = this.particles;
8261
+ let alive = 0;
8262
+ for (let i = 0; i < particles.length; ++i)
7669
8263
  {
8264
+ const p = particles[i];
7670
8265
  p.update();
7671
- return !p.destroyed;
7672
- });
8266
+ if (!p.destroyed) particles[alive++] = p;
8267
+ }
8268
+ particles.length = alive;
7673
8269
 
7674
8270
  if (debugParticles)
7675
8271
  {
@@ -7879,33 +8475,32 @@ class Particle
7879
8475
  const hitLayer = tileCollisionTest(this.pos);
7880
8476
  if (!testCollision(oldPos))
7881
8477
  {
7882
- if (!collideCallback || collideCallback?.(this, hitLayer))
8478
+ // testCollision already invoked collideCallback with the
8479
+ // correct (this, data, pos) args; no need to re-check here.
8480
+ // test which side we bounced off (or both if a corner)
8481
+ const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
8482
+ const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
8483
+ const hitRestitution = max(restitution, hitLayer.restitution);
8484
+ const hitFriction = max(friction, hitLayer.friction);
8485
+ if (isBlockedX)
7883
8486
  {
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');
8487
+ // move to previous X position and bounce
8488
+ this.pos.x = oldPos.x;
8489
+ this.velocity.x *= -hitRestitution;
8490
+ this.velocity.y *= hitFriction;
8491
+ }
8492
+ if (isBlockedY || !isBlockedX)
8493
+ {
8494
+ const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
8495
+ if (wasFalling)
8496
+ this.groundObject = hitLayer;
8497
+
8498
+ // move to previous Y position and bounce
8499
+ this.pos.y = oldPos.y;
8500
+ this.velocity.y *= -hitRestitution;
8501
+ this.velocity.x *= hitFriction;
7908
8502
  }
8503
+ debugPhysics && debugRect(this.pos, this.size, '#f00');
7909
8504
  }
7910
8505
  }
7911
8506
  }
@@ -7952,7 +8547,7 @@ class Particle
7952
8547
  {
7953
8548
  // in local space of emitter
7954
8549
  const a = emitter.angle;
7955
- const c = cos(a), s = sin(a);
8550
+ const c = cos(-a), s = sin(-a);
7956
8551
  pos.set(emitter.pos.x + pos.x*c - pos.y*s,
7957
8552
  emitter.pos.y + pos.x*s + pos.y*c);
7958
8553
  angle += a;
@@ -7960,8 +8555,8 @@ class Particle
7960
8555
  if (trailScale)
7961
8556
  {
7962
8557
  // trail style particles
7963
- const velocity = localSpace ?
7964
- this.velocity.rotate(-emitter.angle) : this.velocity;
8558
+ const velocity = localSpace ?
8559
+ this.velocity.rotate(emitter.angle) : this.velocity;
7965
8560
  const speed = velocity.length();
7966
8561
  if (speed)
7967
8562
  {
@@ -8066,6 +8661,10 @@ function glInit(rootElement)
8066
8661
  for (const info of glTextureInfos)
8067
8662
  info.glTexture = undefined;
8068
8663
  glActiveTexture = undefined;
8664
+ // drop any partially-filled batch so the next glFlush doesn't
8665
+ // upload stale glBatchCount against fresh empty buffers on restore
8666
+ glBatchCount = 0;
8667
+ glPolyMode = false;
8069
8668
  pluginList.forEach(plugin=>plugin.glContextLost?.());
8070
8669
  });
8071
8670
  glCanvas.addEventListener('webglcontextrestored', ()=>
@@ -8424,6 +9023,10 @@ function glSetTextureData(texture, image)
8424
9023
  glContext.bindTexture(glContext.TEXTURE_2D, texture);
8425
9024
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, image);
8426
9025
 
9026
+ // keep mipmaps in sync with new level 0 data (same condition as glCreateTexture)
9027
+ if (!tilesPixelated && isPowerOfTwo(image.width) && isPowerOfTwo(image.height))
9028
+ glContext.generateMipmap(glContext.TEXTURE_2D);
9029
+
8427
9030
  // rebind active texture
8428
9031
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
8429
9032
  }
@@ -8469,7 +9072,7 @@ function glFlush()
8469
9072
  {
8470
9073
  if (glEnable && glContext && glBatchCount)
8471
9074
  {
8472
- // set bend mode
9075
+ // set blend mode
8473
9076
  const destBlend = glBatchAdditive ? glContext.ONE : glContext.ONE_MINUS_SRC_ALPHA;
8474
9077
  glContext.blendFuncSeparate(glContext.SRC_ALPHA, destBlend, glContext.ONE, destBlend);
8475
9078
  glContext.enable(glContext.BLEND);
@@ -8546,9 +9149,9 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
8546
9149
  }
8547
9150
 
8548
9151
  /** 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.
9152
+ * Zeroes the uvs and rgba so the texture contribution multiplies to 0,
9153
+ * then carries the real color in the additive slot. Works regardless of
9154
+ * which texture is currently bound.
8552
9155
  * @param {number} x
8553
9156
  * @param {number} y
8554
9157
  * @param {number} sizeX
@@ -8558,52 +9161,7 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
8558
9161
  * @memberof WebGL */
8559
9162
  function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
8560
9163
  {
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;
9164
+ glDraw(x, y, sizeX, sizeY, angle, 0, 0, 0, 0, 0, rgba);
8607
9165
  }
8608
9166
 
8609
9167
  /** Transform and add a polygon to the gl draw list
@@ -8619,13 +9177,13 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
8619
9177
  function glDrawPointsTransform(points, rgba, x, y, sx, sy, angle, tristrip=true)
8620
9178
  {
8621
9179
  const pointsOut = [];
9180
+ const sa = sin(-angle);
9181
+ const ca = cos(-angle);
8622
9182
  for (const p of points)
8623
9183
  {
8624
9184
  // transform the point
8625
9185
  const px = p.x*sx;
8626
9186
  const py = p.y*sy;
8627
- const sa = sin(-angle);
8628
- const ca = cos(-angle);
8629
9187
  pointsOut.push(vec2(x + ca*px - sa*py, y + sa*px + ca*py));
8630
9188
  }
8631
9189
  const drawPoints = tristrip ? glPolyStrip(pointsOut) : pointsOut;
@@ -8657,11 +9215,13 @@ function glDrawPoints(points, rgba)
8657
9215
  {
8658
9216
  if (!glEnable || points.length < 3)
8659
9217
  return; // needs at least 3 points to have area
8660
-
9218
+
8661
9219
  // flush if there is not enough room or if different blend mode
8662
9220
  const vertCount = points.length + 2;
8663
9221
  if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
8664
9222
  glFlush();
9223
+ ASSERT(vertCount < gl_MAX_POLY_VERTEXES, 'poly exceeds max batch size');
9224
+ if (vertCount >= gl_MAX_POLY_VERTEXES) return; // release-build safety net
8665
9225
  glSetPolyMode();
8666
9226
 
8667
9227
  // setup triangle strip with degenerate verts at start and end
@@ -8685,11 +9245,13 @@ function glDrawColoredPoints(points, pointColors)
8685
9245
  {
8686
9246
  if (!glEnable || points.length < 3)
8687
9247
  return; // needs at least 3 points to have area
8688
-
9248
+
8689
9249
  // flush if there is not enough room or if different blend mode
8690
9250
  const vertCount = points.length + 2;
8691
9251
  if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
8692
9252
  glFlush();
9253
+ ASSERT(vertCount < gl_MAX_POLY_VERTEXES, 'poly exceeds max batch size');
9254
+ if (vertCount >= gl_MAX_POLY_VERTEXES) return; // release-build safety net
8693
9255
  glSetPolyMode();
8694
9256
 
8695
9257
  // setup triangle strip with degenerate verts at start and end
@@ -8759,7 +9321,8 @@ function glMakeOutline(points, width, wrap=true)
8759
9321
  const strip = [];
8760
9322
  const n = points.length;
8761
9323
  const e = 1e-6;
8762
- const miterLimit = width*100;
9324
+ // miter ratio cap (dimensionless, matches SVG/Canvas2D convention)
9325
+ const miterLimit = 10;
8763
9326
  for (let i = 0; i < n; i++)
8764
9327
  {
8765
9328
  // for each vertex, calculate normal based on adjacent edges
@@ -9015,7 +9578,7 @@ function drawEngineLogo(t)
9015
9578
  x.closePath();
9016
9579
  gradient(0, Y, 0, Y+H,C);
9017
9580
  }
9018
- const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
9581
+ const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%)`:'#000';
9019
9582
 
9020
9583
  // center and fit to screen
9021
9584
  const alpha = oscillate(1,1,t);
@@ -9155,7 +9718,15 @@ function medalsInit(saveName)
9155
9718
  // check if medals are unlocked
9156
9719
  medalsSaveName = saveName;
9157
9720
  if (!debugMedals)
9158
- medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
9721
+ {
9722
+ let saved = {};
9723
+ try { saved = JSON.parse(localStorage[saveName] || '{}'); }
9724
+ catch (e) { saved = {}; }
9725
+ medalsForEach(medal => {
9726
+ medal.unlocked = !!(saved[medal.id] && saved[medal.id].unlocked);
9727
+ });
9728
+ medalsSave();
9729
+ }
9159
9730
 
9160
9731
  // engine automatically renders medals
9161
9732
  engineAddPlugin(undefined, medalsRender);
@@ -9199,6 +9770,31 @@ function medalsInit(saveName)
9199
9770
  function medalsForEach(callback)
9200
9771
  { Object.values(medals).forEach(medal=>callback(medal)); }
9201
9772
 
9773
+ /** Reset all medals to locked and persist the cleared catalog
9774
+ * @memberof Medals */
9775
+ function medalsReset()
9776
+ {
9777
+ medalsForEach(medal => medal.unlocked = false);
9778
+ medalsSave();
9779
+ }
9780
+
9781
+ function medalsSave()
9782
+ {
9783
+ if (!medalsSaveName) return;
9784
+ const data = {};
9785
+ medalsForEach(medal => {
9786
+ const entry = {
9787
+ name: medal.name,
9788
+ description: medal.description,
9789
+ icon: medal.icon,
9790
+ unlocked: medal.unlocked,
9791
+ };
9792
+ if (medal.image) entry.src = medal.image.src;
9793
+ data[medal.id] = entry;
9794
+ });
9795
+ localStorage[medalsSaveName] = JSON.stringify(data);
9796
+ }
9797
+
9202
9798
  ///////////////////////////////////////////////////////////////////////////////
9203
9799
 
9204
9800
  /**
@@ -9256,9 +9852,9 @@ class Medal
9256
9852
  {
9257
9853
  if (medalsPreventUnlock || this.unlocked) return;
9258
9854
 
9259
- // save the medal
9260
9855
  ASSERT(medalsSaveName, 'save name must be set');
9261
- localStorage[this.storageKey()] = this.unlocked = true;
9856
+ this.unlocked = true;
9857
+ medalsSave();
9262
9858
  medalsDisplayQueue.push(this);
9263
9859
  }
9264
9860
 
@@ -9316,8 +9912,6 @@ class Medal
9316
9912
  drawTextScreen(this.icon, pos, size*.7, BLACK);
9317
9913
  }
9318
9914
 
9319
- // Get local storage key used by the medal
9320
- storageKey() { return medalsSaveName + '_' + this.id; }
9321
9915
  }
9322
9916
 
9323
9917
  ///////////////////////////////////////////////////////////////////////////////
@@ -9425,8 +10019,20 @@ class NewgroundsPlugin
9425
10019
 
9426
10020
  // get medals
9427
10021
  const medalsResult = this.call('Medal.getList');
10022
+
10023
+ // bail early if the first call failed (offline / bad session /
10024
+ // server error) so we don't block the main thread on more sync
10025
+ // XHRs that are guaranteed to also fail
10026
+ if (!medalsResult || !medalsResult.result || medalsResult.result.error)
10027
+ {
10028
+ debugMedals && LOG('Newgrounds session unavailable; skipping plugin init');
10029
+ this.medals = [];
10030
+ this.scoreboards = [];
10031
+ return;
10032
+ }
10033
+
9428
10034
  /** @property {Array} - Medals fetched from Newgrounds (empty until session is active) */
9429
- this.medals = medalsResult ? medalsResult.result.data['medals'] : [];
10035
+ this.medals = medalsResult.result.data?.['medals'] || [];
9430
10036
  debugMedals && LOG(this.medals);
9431
10037
  for (const newgroundsMedal of this.medals)
9432
10038
  {
@@ -9446,11 +10052,11 @@ class NewgroundsPlugin
9446
10052
  medal.description = medal.description + ` (${ medal.value })`;
9447
10053
  }
9448
10054
  }
9449
-
10055
+
9450
10056
  // get scoreboards
9451
10057
  const scoreboardResult = this.call('ScoreBoard.getBoards');
9452
10058
  /** @property {Array} - Scoreboards fetched from Newgrounds */
9453
- this.scoreboards = scoreboardResult ? scoreboardResult.result.data.scoreboards : [];
10059
+ this.scoreboards = scoreboardResult?.result?.data?.scoreboards || [];
9454
10060
  debugMedals && LOG(this.scoreboards);
9455
10061
 
9456
10062
  // keep the session alive with a ping every minute
@@ -9634,10 +10240,14 @@ class PostProcessPlugin
9634
10240
  function postProcessRender()
9635
10241
  {
9636
10242
  if (headlessMode || !glEnable) return;
9637
-
10243
+
9638
10244
  // clear out the buffer
9639
10245
  glFlush();
9640
10246
 
10247
+ // ensure we render to the default framebuffer (in case any earlier
10248
+ // caller this frame left a render target bound)
10249
+ glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
10250
+
9641
10251
  // setup shader program to draw a quad
9642
10252
  glContext.useProgram(postProcess.shader);
9643
10253
  glContext.bindVertexArray(postProcess.vao);
@@ -9678,11 +10288,343 @@ class PostProcessPlugin
9678
10288
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, glCanvas);
9679
10289
  }
9680
10290
 
10291
+ // restore default so subsequent dynamic texture uploads aren't flipped
10292
+ glContext.pixelStorei(glContext.UNPACK_FLIP_Y_WEBGL, false);
10293
+
9681
10294
  // force it to set instanced mode
9682
10295
  glSetInstancedMode(true);
9683
10296
  }
9684
10297
  }
9685
10298
  }
10299
+ /**
10300
+ * LittleJS Light System Plugin
10301
+ * - Adds 2D dynamic lighting to the scene
10302
+ * - Lights are first-class EngineObjects (the Light class)
10303
+ * - Each Light draws a soft falloff blob of its color into a shared lightmap
10304
+ * - Lights accumulate ADDITIVELY in the lightmap (red + blue = magenta)
10305
+ * - The lightmap is then MULTIPLIED with the scene during composite, so unlit
10306
+ * areas go to the ambient color and lit areas show the scene tinted by the
10307
+ * accumulated light color
10308
+ * - Draw the world at full brightness — the lightmap does the darkening
10309
+ * - Any EngineObject may override renderLight() to additively contribute to the
10310
+ * lightmap (e.g. emissive lava tiles, weapon flashes, glowing crystals)
10311
+ * - Must be constructed BEFORE PostProcessPlugin so post-process sees lit pixels
10312
+ * @namespace LightSystem
10313
+ */
10314
+
10315
+ ///////////////////////////////////////////////////////////////////////////////
10316
+
10317
+ /** Global Light System plugin object
10318
+ * @type {LightSystemPlugin}
10319
+ * @memberof LightSystem */
10320
+ let lightSystem;
10321
+
10322
+ ///////////////////////////////////////////////////////////////////////////////
10323
+
10324
+ /**
10325
+ * LightSystemPlugin
10326
+ * - Owns the offscreen lightmap texture, falloff/composite shaders, and the
10327
+ * per-frame render pass that multiplies the lightmap onto the WebGL scene
10328
+ * - The composite is MULTIPLICATIVE: unlit areas get the ambient color, lit
10329
+ * areas show the scene tinted by the accumulated light color. So you should
10330
+ * draw your world at full brightness — the lightmap handles the darkening.
10331
+ * @memberof LightSystem
10332
+ */
10333
+ class LightSystemPlugin
10334
+ {
10335
+ /** Create the global light system plugin.
10336
+ * @param {Vector2} [textureSize] - Size of the lightmap texture (defaults to mainCanvasSize)
10337
+ * @param {Color} [ambientColor] - Color applied to unlit areas of the scene (defaults to BLACK = pitch dark). Set a small RGB like rgb(0.1,0.1,0.15) for a faint "moonlight" baseline so unlit areas aren't fully black.
10338
+ * @example
10339
+ * // simplest usage
10340
+ * new LightSystemPlugin();
10341
+ */
10342
+ constructor(textureSize, ambientColor)
10343
+ {
10344
+ ASSERT(!lightSystem, 'LightSystemPlugin already initialized');
10345
+ ASSERT(!postProcess, 'LightSystemPlugin must be created before PostProcessPlugin');
10346
+ lightSystem = this;
10347
+
10348
+ /** @property {boolean} - When false, the render pass is skipped entirely */
10349
+ this.enabled = true;
10350
+ /** @property {Color} - Baseline color applied to unlit areas of the scene. Defaults to BLACK (pitch dark). Set to a small RGB for a faint ambient. The lightmap is cleared to this color each frame, then lights add on top, then the result multiplies the scene. */
10351
+ this.ambientColor = (ambientColor || BLACK).copy();
10352
+ /** @property {Vector2} - Size of the lightmap texture (set at construction; falls back to mainCanvasSize at init time) */
10353
+ this.textureSize = textureSize ? textureSize.copy() : undefined;
10354
+
10355
+ /** @property {WebGLTexture} - The lightmap texture */
10356
+ this.texture = undefined;
10357
+ /** @property {WebGLProgram} - Shader for drawing per-Light falloff blobs into the lightmap */
10358
+ this.lightShader = undefined;
10359
+ /** @property {WebGLProgram} - Shader for compositing the lightmap over the main scene */
10360
+ this.compositeShader = undefined;
10361
+ /** @property {WebGLVertexArrayObject} - Vertex array object for the light shader */
10362
+ this.lightVAO = undefined;
10363
+ /** @property {WebGLVertexArrayObject} - Vertex array object for the composite shader */
10364
+ this.compositeVAO = undefined;
10365
+
10366
+ initLightSystem();
10367
+ engineAddPlugin(undefined, lightSystemRender,
10368
+ lightSystemContextLost, lightSystemContextRestored);
10369
+
10370
+ function initLightSystem()
10371
+ {
10372
+ if (headlessMode) return;
10373
+ if (!glEnable)
10374
+ {
10375
+ console.warn('LightSystemPlugin: WebGL not enabled!');
10376
+ return;
10377
+ }
10378
+
10379
+ // resolve texture size default at init time (mainCanvasSize may
10380
+ // not be set yet at the moment the constructor first ran)
10381
+ if (!lightSystem.textureSize)
10382
+ lightSystem.textureSize = mainCanvasSize.copy();
10383
+
10384
+ // allocate the lightmap texture with null data at textureSize
10385
+ lightSystem.texture = glContext.createTexture();
10386
+ glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
10387
+ glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA,
10388
+ lightSystem.textureSize.x, lightSystem.textureSize.y, 0,
10389
+ glContext.RGBA, glContext.UNSIGNED_BYTE, null);
10390
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, glContext.LINEAR);
10391
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, glContext.LINEAR);
10392
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, glContext.CLAMP_TO_EDGE);
10393
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, glContext.CLAMP_TO_EDGE);
10394
+
10395
+ // light falloff shader: one quad per Light, fragment computes radial falloff
10396
+ lightSystem.lightShader = glCreateProgram(
10397
+ '#version 300 es\n' +
10398
+ 'precision highp float;'+
10399
+ 'uniform mat4 m;'+
10400
+ 'uniform vec2 lightPos;'+
10401
+ 'uniform float radius;'+
10402
+ 'in vec2 g;'+ // unit quad geometry [0..1]
10403
+ 'out vec2 vWorldPos;'+
10404
+ 'void main(){'+
10405
+ 'vec2 worldP=lightPos+(g-.5)*2.*radius;'+
10406
+ 'gl_Position=m*vec4(worldP,1,1);'+
10407
+ 'vWorldPos=worldP;'+
10408
+ '}'
10409
+ ,
10410
+ '#version 300 es\n' +
10411
+ 'precision highp float;'+
10412
+ 'uniform vec2 lightPos;'+
10413
+ 'uniform float radius;'+
10414
+ 'uniform float fadeRange;'+
10415
+ 'uniform vec4 color;'+
10416
+ 'in vec2 vWorldPos;'+
10417
+ 'out vec4 c;'+
10418
+ 'void main(){'+
10419
+ 'float dist=distance(vWorldPos,lightPos);'+
10420
+ 'float t=clamp((radius-dist)/max(fadeRange,1e-6),0.,1.);'+
10421
+ 'c=vec4(color.rgb*t*color.a,1.);'+
10422
+ '}'
10423
+ );
10424
+
10425
+ // composite shader: fullscreen quad, samples the lightmap
10426
+ lightSystem.compositeShader = glCreateProgram(
10427
+ '#version 300 es\n' +
10428
+ 'precision highp float;'+
10429
+ 'in vec2 p;'+
10430
+ 'void main(){'+
10431
+ 'gl_Position=vec4(p+p-1.,1,1);'+
10432
+ '}'
10433
+ ,
10434
+ '#version 300 es\n' +
10435
+ 'precision highp float;'+
10436
+ 'uniform sampler2D s;'+
10437
+ 'uniform vec3 iResolution;'+
10438
+ 'out vec4 c;'+
10439
+ 'void main(){'+
10440
+ 'vec2 uv=gl_FragCoord.xy/iResolution.xy;'+
10441
+ 'c=vec4(texture(s,uv).rgb,1.);'+
10442
+ '}'
10443
+ );
10444
+
10445
+ // VAO for the per-Light quad — reuses the engine unit triangle-strip
10446
+ lightSystem.lightVAO = glContext.createVertexArray();
10447
+ glContext.bindVertexArray(lightSystem.lightVAO);
10448
+ glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer);
10449
+ const gLight = glContext.getAttribLocation(lightSystem.lightShader, 'g');
10450
+ glContext.enableVertexAttribArray(gLight);
10451
+ glContext.vertexAttribPointer(gLight, 2, glContext.FLOAT, false, 8, 0);
10452
+
10453
+ // VAO for the composite fullscreen quad — same buffer, attribute named 'p'
10454
+ lightSystem.compositeVAO = glContext.createVertexArray();
10455
+ glContext.bindVertexArray(lightSystem.compositeVAO);
10456
+ glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer);
10457
+ const pComp = glContext.getAttribLocation(lightSystem.compositeShader, 'p');
10458
+ glContext.enableVertexAttribArray(pComp);
10459
+ glContext.vertexAttribPointer(pComp, 2, glContext.FLOAT, false, 8, 0);
10460
+ }
10461
+ function lightSystemRender()
10462
+ {
10463
+ if (headlessMode || !glEnable) return;
10464
+ if (!lightSystem.enabled) return;
10465
+ if (!lightSystem.texture) return; // init failed or context lost
10466
+
10467
+ // 1. flush any in-flight sprite batch from earlier render passes
10468
+ glFlush();
10469
+ const prevAdditive = glAdditive;
10470
+
10471
+ // 2. bind lightmap as render target, clear to ambientColor
10472
+ const ac = lightSystem.ambientColor;
10473
+ glContext.bindFramebuffer(glContext.FRAMEBUFFER, glFramebuffer);
10474
+ glContext.framebufferTexture2D(glContext.FRAMEBUFFER,
10475
+ glContext.COLOR_ATTACHMENT0, glContext.TEXTURE_2D, lightSystem.texture, 0);
10476
+ glContext.viewport(0, 0, lightSystem.textureSize.x, lightSystem.textureSize.y);
10477
+ glContext.clearColor(ac.r, ac.g, ac.b, ac.a);
10478
+ glContext.clear(glContext.COLOR_BUFFER_BIT);
10479
+
10480
+ // 3. walk engineObjects calling renderLight() — additive blend
10481
+ // (lightmap accumulates raw additive color contributions)
10482
+ setBlendMode(true);
10483
+ glContext.enable(glContext.BLEND);
10484
+ glContext.blendFunc(glContext.ONE, glContext.ONE);
10485
+
10486
+ for (const o of engineObjects)
10487
+ o.destroyed || o.renderLight();
10488
+
10489
+ // 4. drain any sprite-batched draws (e.g. drawTile inside a
10490
+ // custom renderLight override) so they hit the FBO, not the
10491
+ // canvas after we unbind
10492
+ glFlush();
10493
+ glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
10494
+ glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
10495
+
10496
+ // 5. composite: fullscreen quad, multiplicative blend onto glCanvas
10497
+ // (scene * lightmap — unlit areas go to black, lit areas are
10498
+ // the scene tinted by the accumulated light color)
10499
+ glContext.useProgram(lightSystem.compositeShader);
10500
+ glContext.bindVertexArray(lightSystem.compositeVAO);
10501
+ glContext.activeTexture(glContext.TEXTURE0);
10502
+ glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
10503
+ const cs = lightSystem.compositeShader;
10504
+ glContext.uniform1i(glContext.getUniformLocation(cs, 's'), 0);
10505
+ glContext.uniform3f(glContext.getUniformLocation(cs, 'iResolution'),
10506
+ mainCanvas.width, mainCanvas.height, 1);
10507
+ glContext.blendFunc(glContext.DST_COLOR, glContext.ZERO);
10508
+ glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
10509
+
10510
+ // 6. restore engine state so subsequent draws use the engine's
10511
+ // tracked texture binding (otherwise glSetTexture would think
10512
+ // the prior texture was still bound when actually the lightmap
10513
+ // is, and any debug text / future draw could sample the lightmap)
10514
+ if (glActiveTexture)
10515
+ glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
10516
+ setBlendMode(prevAdditive);
10517
+ glSetInstancedMode(true);
10518
+ }
10519
+ function lightSystemContextLost()
10520
+ {
10521
+ lightSystem.texture = undefined;
10522
+ lightSystem.lightShader = undefined;
10523
+ lightSystem.compositeShader = undefined;
10524
+ lightSystem.lightVAO = undefined;
10525
+ lightSystem.compositeVAO = undefined;
10526
+ LOG('LightSystemPlugin: WebGL context lost');
10527
+ }
10528
+ function lightSystemContextRestored()
10529
+ {
10530
+ initLightSystem();
10531
+ LOG('LightSystemPlugin: WebGL context restored');
10532
+ }
10533
+ }
10534
+
10535
+ /** Draw a single Light's falloff blob into the currently bound lightmap.
10536
+ * Called by Light.renderLight() during the plugin's render pass.
10537
+ * @param {Light} light */
10538
+ drawLight(light)
10539
+ {
10540
+ if (headlessMode || !glEnable || !this.lightShader) return;
10541
+
10542
+ // drain any sprite-batched draws queued by a previous custom
10543
+ // renderLight() override (e.g. drawRect inside a LavaTile). They were
10544
+ // queued in the engine's instanced-vertex format and must flush with
10545
+ // the engine's shader+VAO bound — NOT this plugin's light shader.
10546
+ glFlush();
10547
+
10548
+ glContext.useProgram(this.lightShader);
10549
+ glContext.bindVertexArray(this.lightVAO);
10550
+
10551
+ // re-apply the engine camera transform onto this shader. Divide by
10552
+ // mainCanvasSize (not textureSize) so world→NDC matches the main
10553
+ // pass; the viewport handles the lightmap's actual resolution.
10554
+ // No y-flip here: the composite samples this FBO with
10555
+ // gl_FragCoord/iResolution (origin bottom-left), so storing world
10556
+ // +Y at the top of the texture lines up with the canvas convention.
10557
+ const s = vec2(2*cameraScale).divide(mainCanvasSize);
10558
+ const rotatedCam = cameraPos.rotate(-cameraAngle);
10559
+ const p = vec2(-1).subtract(rotatedCam.multiply(s));
10560
+ const ca = cos(cameraAngle);
10561
+ const sa = sin(cameraAngle);
10562
+ const transform = [
10563
+ s.x * ca, s.y * sa, 0, 0,
10564
+ -s.x * sa, s.y * ca, 0, 0,
10565
+ 1, 1, 1, 0,
10566
+ p.x, p.y, 0, 1];
10567
+
10568
+ const ls = this.lightShader;
10569
+ glContext.uniformMatrix4fv(glContext.getUniformLocation(ls, 'm'), false, transform);
10570
+ glContext.uniform2f(glContext.getUniformLocation(ls, 'lightPos'), light.pos.x, light.pos.y);
10571
+ glContext.uniform1f(glContext.getUniformLocation(ls, 'radius'), light.radius);
10572
+ glContext.uniform1f(glContext.getUniformLocation(ls, 'fadeRange'), light.fadeRange);
10573
+ const c = light.color;
10574
+ glContext.uniform4f(glContext.getUniformLocation(ls, 'color'), c.r, c.g, c.b, c.a);
10575
+
10576
+ glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
10577
+
10578
+ // restore engine's instanced shader+VAO so subsequent renderLight()
10579
+ // overrides that batch through drawRect/drawTile work correctly
10580
+ glSetInstancedMode(true);
10581
+ }
10582
+ }
10583
+
10584
+ ///////////////////////////////////////////////////////////////////////////////
10585
+
10586
+ /**
10587
+ * A Light is an EngineObject that contributes a soft additive blob of color
10588
+ * to the LightSystem plugin's lightmap.
10589
+ * @extends EngineObject
10590
+ * @memberof LightSystem
10591
+ * @example
10592
+ * new Light(vec2(5, 5), 4, rgb(1, 0.5, 0)); // orange light, full soft blob
10593
+ * new Light(vec2(0, 0), 8, rgb(1, 1, 1), 2); // white core with 2-unit soft halo
10594
+ */
10595
+ class Light extends EngineObject
10596
+ {
10597
+ /** Create a light object and add it to the engine object list
10598
+ * @param {Vector2} pos - World space position
10599
+ * @param {number} radius - Total extent of the light in world units
10600
+ * @param {Color} [color] - Color of the light; alpha modulates intensity
10601
+ * @param {number} [fadeRange] - Width of the soft edge in world units (defaults to radius) */
10602
+ constructor(pos, radius, color, fadeRange)
10603
+ {
10604
+ super(pos, vec2(1), undefined, 0, color);
10605
+ ASSERT(isNumber(radius) && radius >= 0, 'Light radius must be a non-negative number');
10606
+ ASSERT(fadeRange === undefined || (isNumber(fadeRange) && fadeRange >= 0),
10607
+ 'Light fadeRange must be a non-negative number when provided');
10608
+
10609
+ /** @property {number} - Total extent of the light in world units */
10610
+ this.radius = radius;
10611
+ /** @property {number} - Width of the soft edge in world units */
10612
+ this.fadeRange = fadeRange === undefined ? radius : fadeRange;
10613
+ }
10614
+
10615
+ /** Lights are invisible in the main render pass — they only contribute
10616
+ * to the lightmap via renderLight(). */
10617
+ render() {}
10618
+
10619
+ /** Draw this light's falloff blob into the lightmap.
10620
+ * Called by LightSystemPlugin during its render pass. No-op when the
10621
+ * plugin or WebGL is unavailable. */
10622
+ renderLight()
10623
+ {
10624
+ lightSystem && lightSystem.drawLight(this);
10625
+ }
10626
+ }
10627
+
9686
10628
  /**
9687
10629
  * LittleJS ZzFXM Plugin
9688
10630
  * @namespace ZzFXM
@@ -10304,11 +11246,17 @@ class UISystemPlugin
10304
11246
  * @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */
10305
11247
  setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver)
10306
11248
  {
10307
- function setCallback(callback, listenerType)
11249
+ // remove any prior listeners so repeated setup calls don't stack
11250
+ if (this._dragListeners)
11251
+ for (const [type, listener] of this._dragListeners)
11252
+ document.removeEventListener(type, listener);
11253
+ this._dragListeners = [];
11254
+ const setCallback = (callback, listenerType)=>
10308
11255
  {
10309
- function listener(e) { e.preventDefault(); callback && callback(e); }
11256
+ const listener = (e)=> { e.preventDefault(); callback && callback(e); };
10310
11257
  document.addEventListener(listenerType, listener);
10311
- }
11258
+ this._dragListeners.push([listenerType, listener]);
11259
+ };
10312
11260
  setCallback(onDrop, 'drop');
10313
11261
  setCallback(onDragEnter, 'dragenter');
10314
11262
  setCallback(onDragLeave, 'dragleave');
@@ -10632,6 +11580,15 @@ class UIObject
10632
11580
  if (this.destroyed)
10633
11581
  return;
10634
11582
 
11583
+ // clear ui-system references that point at this object so events
11584
+ // don't keep firing against a destroyed target (especially the
11585
+ // keydown listener attached for keyInputObject)
11586
+ if (uiSystem.activeObject === this) uiSystem.activeObject = undefined;
11587
+ if (uiSystem.hoverObject === this) uiSystem.hoverObject = undefined;
11588
+ if (uiSystem.lastHoverObject === this) uiSystem.lastHoverObject = undefined;
11589
+ if (uiSystem.navigationObject === this) uiSystem.navigationObject = undefined;
11590
+ if (uiSystem.keyInputObject === this) uiSystem.keyInputObject = undefined;
11591
+
10635
11592
  // disconnect from parent and destroy children
10636
11593
  this.destroyed = 1;
10637
11594
  this.parent?.removeChild(this);
@@ -10640,6 +11597,8 @@ class UIObject
10640
11597
  child.parent = undefined;
10641
11598
  child.destroy();
10642
11599
  }
11600
+ // clear references so destroyed children can be GC'd
11601
+ this.children.length = 0;
10643
11602
  }
10644
11603
 
10645
11604
  /** Check if the mouse is overlapping this ui object
@@ -11229,6 +12188,7 @@ class UISlider extends UIObject
11229
12188
  {
11230
12189
  // toggle value between 0 and 1
11231
12190
  this.value = this.value ? 0 : 1;
12191
+ this.onChange();
11232
12192
  this.onRelease();
11233
12193
  super.navigatePressed();
11234
12194
  }
@@ -11588,6 +12548,11 @@ class Box2dObject extends EngineObject
11588
12548
  // destroy physics body, fixtures, and joints
11589
12549
  ASSERT(this.body, 'Box2dObject has no body to destroy');
11590
12550
  box2d.world.DestroyBody(this.body);
12551
+
12552
+ // remove from tracked list so paused / headless sessions don't leak
12553
+ const i = box2d.objects.indexOf(this);
12554
+ if (i >= 0)
12555
+ box2d.objects.splice(i, 1);
11591
12556
  super.destroy();
11592
12557
  }
11593
12558
 
@@ -11676,7 +12641,9 @@ class Box2dObject extends EngineObject
11676
12641
  /** Add a box shape to the body
11677
12642
  * @param {Vector2} [size]
11678
12643
  * @param {Vector2} [offset]
11679
- * @param {number} [angle]
12644
+ * @param {number} [angle] - LittleJS convention (clockwise positive).
12645
+ * Negated internally to match Box2D's CCW-positive convention so the
12646
+ * fixture aligns with the same angle passed to drawRect/drawTile.
11680
12647
  * @param {number} [density]
11681
12648
  * @param {number} [friction]
11682
12649
  * @param {number} [restitution]
@@ -11689,7 +12656,7 @@ class Box2dObject extends EngineObject
11689
12656
  ASSERT(isNumber(angle), 'angle must be a number');
11690
12657
 
11691
12658
  const shape = new box2d.instance.b2PolygonShape();
11692
- shape.SetAsBox(size.x/2, size.y/2, box2d.vec2dTo(offset), angle);
12659
+ shape.SetAsBox(size.x/2, size.y/2, box2d.vec2dTo(offset), -angle);
11693
12660
  return this.addShape(shape, density, friction, restitution, isSensor);
11694
12661
  }
11695
12662
 
@@ -11705,23 +12672,19 @@ class Box2dObject extends EngineObject
11705
12672
 
11706
12673
  function box2dCreatePolygonShape(points)
11707
12674
  {
11708
- function box2dCreatePointList(points)
12675
+ ASSERT(3 <= points.length && points.length <= 8);
12676
+ const buffer = box2d.instance._malloc(points.length * 8);
12677
+ for (let i=0, offset=0; i<points.length; ++i)
11709
12678
  {
11710
- const buffer = box2d.instance._malloc(points.length * 8);
11711
- for (let i=0, offset=0; i<points.length; ++i)
11712
- {
11713
- box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].x;
11714
- offset += 4;
11715
- box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].y;
11716
- offset += 4;
11717
- }
11718
- return box2d.instance.wrapPointer(buffer, box2d.instance.b2Vec2);
12679
+ box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].x;
12680
+ offset += 4;
12681
+ box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].y;
12682
+ offset += 4;
11719
12683
  }
11720
-
11721
- ASSERT(3 <= points.length && points.length <= 8);
12684
+ const box2dPoints = box2d.instance.wrapPointer(buffer, box2d.instance.b2Vec2);
11722
12685
  const shape = new box2d.instance.b2PolygonShape();
11723
- const box2dPoints = box2dCreatePointList(points);
11724
12686
  shape.Set(box2dPoints, points.length);
12687
+ box2d.instance._free(buffer);
11725
12688
  return shape;
11726
12689
  }
11727
12690
 
@@ -11991,9 +12954,10 @@ class Box2dObject extends EngineObject
11991
12954
  {
11992
12955
  const data = new box2d.instance.b2MassData();
11993
12956
  this.body.GetMassData(data);
11994
- localCenter && data.set_center(box2d.vec2dTo(localCenter));
11995
- mass && data.set_mass(mass);
11996
- momentOfInertia && data.set_I(momentOfInertia);
12957
+ // use !== undefined so setMass(0) (static-equivalent) isn't silently ignored
12958
+ if (localCenter !== undefined) data.set_center(box2d.vec2dTo(localCenter));
12959
+ if (mass !== undefined) data.set_mass(mass);
12960
+ if (momentOfInertia !== undefined) data.set_I(momentOfInertia);
11997
12961
  this.body.SetMassData(data);
11998
12962
  }
11999
12963
 
@@ -13165,6 +14129,8 @@ class Box2dPlugin
13165
14129
  const fixtureB = contact.GetFixtureB();
13166
14130
  const objectA = fixtureA.GetBody().object;
13167
14131
  const objectB = fixtureB.GetBody().object;
14132
+ // raw user-created b2Bodies may have no .object — skip those
14133
+ if (!objectA || !objectB) return;
13168
14134
  objectA.beginContact(objectB);
13169
14135
  objectB.beginContact(objectA);
13170
14136
  }
@@ -13175,6 +14141,7 @@ class Box2dPlugin
13175
14141
  const fixtureB = contact.GetFixtureB();
13176
14142
  const objectA = fixtureA.GetBody().object;
13177
14143
  const objectB = fixtureB.GetBody().object;
14144
+ if (!objectA || !objectB) return;
13178
14145
  objectA.endContact(objectB);
13179
14146
  objectB.endContact(objectA);
13180
14147
  };
@@ -13553,7 +14520,7 @@ async function box2dInit()
13553
14520
  debugDraw.DrawTransform = function(transform)
13554
14521
  {
13555
14522
  transform = box2d.instance.wrapPointer(transform, box2d.instance.b2Transform);
13556
- const pos = vec2(transform.get_p());
14523
+ const pos = box2d.vec2From(transform.get_p());
13557
14524
  const angle = -transform.get_q().GetAngle();
13558
14525
  const p1 = vec2(1,0), c1 = rgb(.75,0,0,.8);
13559
14526
  const p2 = vec2(0,1), c2 = rgb(0,.75,0,.8);
@@ -13698,6 +14665,69 @@ function drawThreeSlice(pos, size, startTile, color, borderSize=1, additiveColor
13698
14665
  const cornerPos = cornerOffset.multiply(vec2(flipX?-1:1, flipY?-flip:flip));
13699
14666
  drawTile(pos.add(cornerPos.rotate(rotateAngle)), cornerSize, cornerTile, color, a, false, additiveColor, useWebGL, screenSpace, context);
13700
14667
  }
14668
+ }
14669
+
14670
+ /** Draw a crescent / moon-phase shape built from a polygon
14671
+ * Routes through drawPoly, so it supports WebGL, screen space, color, and outlines
14672
+ * @param {Vector2} pos - Center position
14673
+ * @param {number} [size] - Diameter
14674
+ * @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
14675
+ * @param {Color} [color] - Fill color
14676
+ * @param {number} [angle] - Angle to rotate by
14677
+ * @param {boolean} [invert] - Flip which side is illuminated
14678
+ * @param {number} [lineWidth] - Outline width, 0 for no outline
14679
+ * @param {Color} [lineColor] - Outline color
14680
+ * @param {boolean} [useWebGL=glEnable] - Use WebGL for rendering
14681
+ * @param {boolean} [screenSpace] - Use screen space coordinates
14682
+ * @param {CanvasRenderingContext2D} [context] - Canvas context to use
14683
+ * @memberof DrawUtilities */
14684
+ function drawCrescent(pos, size=1, percent=0, color=WHITE, angle=0, invert=false, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
14685
+ {
14686
+ const points = getCrescentPoints(pos, size, percent, angle, invert);
14687
+ drawPoly(points, color, lineWidth, lineColor, vec2(), 0, useWebGL, screenSpace, context);
14688
+ }
14689
+
14690
+ /** Get the list of points that make up a crescent / moon-phase shape
14691
+ * Returns world-space points with pos and angle baked in, ready for drawPoly or other use
14692
+ * @param {Vector2} pos - Center position
14693
+ * @param {number} [size] - Diameter
14694
+ * @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
14695
+ * @param {number} [angle] - Angle to rotate by
14696
+ * @param {boolean} [invert] - Flip which side is illuminated
14697
+ * @param {number} [sides=glCircleSides] - Number of sides for a full circle (halved per arc)
14698
+ * @return {Array<Vector2>} - List of points making up the crescent
14699
+ * @memberof DrawUtilities */
14700
+ function getCrescentPoints(pos, size=1, percent=0, angle=0, invert=false, sides=glCircleSides)
14701
+ {
14702
+ ASSERT(isVector2(pos), 'pos must be a vec2');
14703
+ ASSERT(isNumber(size) && isNumber(percent), 'size and percent must be numbers');
14704
+
14705
+ // map phase to a signed terminator curve: -1 new, 0 half, 1 full
14706
+ let p = mod(percent*4, 4); // quarter phase 0..4
14707
+ if (p >= 2) // second half of cycle flips orientation
14708
+ angle += PI;
14709
+ p = p <= 2 ? p-1 : 3-p;
14710
+ if (invert) // flip the illuminated side
14711
+ {
14712
+ p = -p;
14713
+ angle += PI;
14714
+ }
14715
+
14716
+ // build the crescent: outer semicircle, then inner half-ellipse traced back
14717
+ const points = [];
14718
+ const segs = max(3, sides>>1);
14719
+ const radius = size/2;
14720
+ for (let i=0; i<=segs; i++)
14721
+ {
14722
+ const t = i/segs*PI;
14723
+ points.push(vec2(radius*cos(t), radius*sin(t)).rotate(angle).add(pos));
14724
+ }
14725
+ for (let i=segs; i>=0; i--)
14726
+ {
14727
+ const t = i/segs*PI;
14728
+ points.push(vec2(radius*cos(t), -radius*p*sin(t)).rotate(angle).add(pos));
14729
+ }
14730
+ return points;
13701
14731
  }
13702
14732
  /**
13703
14733
  * LittleJS Tween System Plugin
@@ -13957,7 +14987,7 @@ const Ease =
13957
14987
  * @param {number} x
13958
14988
  * @returns {number}
13959
14989
  * @memberof TweenSystem */
13960
- EXPO: (x) => 2 ** (10 * x - 10),
14990
+ EXPO: (x) => x === 0 ? 0 : 2 ** (10 * x - 10),
13961
14991
 
13962
14992
  /** Back ease-in: overshoots backward at the start before snapping forward.
13963
14993
  * @param {number} x
@@ -13970,6 +15000,8 @@ const Ease =
13970
15000
  * @returns {number}
13971
15001
  * @memberof TweenSystem */
13972
15002
  ELASTIC: (x) =>
15003
+ x === 0 ? 0 :
15004
+ x === 1 ? 1 :
13973
15005
  -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
13974
15006
 
13975
15007
  /** Spring-like ease-out: oscillates outward after passing the target.
@@ -14130,29 +15162,32 @@ function tweenProperty(target, propertyPath, start, end, duration = 1, options =
14130
15162
  }
14131
15163
 
14132
15164
  // 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)
15165
+ // Reuses the same Tween object across iterations so the user's handle
15166
+ // from `.loop()` keeps working — calling `.stop()` mid-loop now cancels
15167
+ // the entire chain instead of just the current iteration.
15168
+ function loopContinuation(tween)
14136
15169
  {
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);
15170
+ if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
15171
+ if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
15172
+ tween.life = tween.duration;
15173
+ tween.thenCallback = () => loopContinuation(tween);
15174
+ tweenActive.push(tween);
15175
+ // snap to start for the new iteration (matches Tween constructor behavior)
15176
+ tween.callback(tween.interp(tween.duration));
14144
15177
  }
14145
15178
 
14146
- // Continuation for pingPong: spawns a new tween with start and end swapped.
14147
- function pingPongContinuation(prev)
15179
+ // Continuation for pingPong: swaps start and end on the same tween each iteration.
15180
+ function pingPongContinuation(tween)
14148
15181
  {
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);
15182
+ if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
15183
+ if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
15184
+ const tmp = tween.start;
15185
+ tween.start = tween.end;
15186
+ tween.end = tmp;
15187
+ tween.life = tween.duration;
15188
+ tween.thenCallback = () => pingPongContinuation(tween);
15189
+ tweenActive.push(tween);
15190
+ tween.callback(tween.interp(tween.duration));
14156
15191
  }
14157
15192
 
14158
15193
  /** Engine plugin hook: advance every active tween by the appropriate delta.
@@ -14486,11 +15521,13 @@ class PathFinder
14486
15521
  if (dx !== 0 && dy !== 0)
14487
15522
  {
14488
15523
  // Diagonal step: refuse if either cardinal neighbor is
14489
- // blocked or has cost. Prevents cutting through corners.
15524
+ // blocked. Prevents cutting through walls at corners.
15525
+ // (Costed-but-walkable cardinals do not block — diagonal
15526
+ // movement around expensive terrain is standard A*.)
14490
15527
  const card1 = this.getNode(current.pos.x + dx, current.pos.y);
14491
- if (!card1 || card1.cost > 0 || !card1.walkable) continue;
15528
+ if (!card1 || !card1.walkable) continue;
14492
15529
  const card2 = this.getNode(current.pos.x, current.pos.y + dy);
14493
- if (!card2 || card2.cost > 0 || !card2.walkable) continue;
15530
+ if (!card2 || !card2.walkable) continue;
14494
15531
  stepCost = PATHFINDER_DIAGONAL_COST;
14495
15532
  }
14496
15533
 
@@ -15089,12 +16126,18 @@ export
15089
16126
  inputWASDEmulateDirection,
15090
16127
  touchInputEnable,
15091
16128
  touchGamepadEnable,
16129
+ touchGamepadPassthrough,
15092
16130
  touchGamepadCenterButtonSize,
15093
16131
  touchGamepadButtonCount,
16132
+ touchGamepadLeftStick,
16133
+ touchGamepadLeftButtonCount,
16134
+ touchGamepadRightStick,
15094
16135
  touchGamepadAnalog,
16136
+ touchGamepadFloating,
15095
16137
  touchGamepadSize,
15096
16138
  touchGamepadAlpha,
15097
16139
  touchGamepadDisplayTime,
16140
+ touchGamepadVibration,
15098
16141
  vibrateEnable,
15099
16142
  soundEnable,
15100
16143
  soundVolume,
@@ -15137,11 +16180,18 @@ export
15137
16180
  setGamepadDirectionEmulateStick,
15138
16181
  setInputWASDEmulateDirection,
15139
16182
  setTouchGamepadEnable,
16183
+ setTouchGamepadPassthrough,
15140
16184
  setTouchGamepadCenterButtonSize,
15141
16185
  setTouchGamepadButtonCount,
16186
+ setTouchGamepadLeftStick,
16187
+ setTouchGamepadLeftButtonCount,
16188
+ setTouchGamepadRightStick,
15142
16189
  setTouchGamepadAnalog,
16190
+ setTouchGamepadFloating,
15143
16191
  setTouchGamepadSize,
15144
16192
  setTouchGamepadAlpha,
16193
+ setTouchGamepadDisplayTime,
16194
+ setTouchGamepadVibration,
15145
16195
  setVibrateEnable,
15146
16196
  setSoundEnable,
15147
16197
  setSoundVolume,
@@ -15262,14 +16312,15 @@ export
15262
16312
  drawRegularPoly,
15263
16313
  drawEllipse,
15264
16314
  drawCircle,
16315
+ drawEllipseGradient,
15265
16316
  drawCircleGradient,
15266
16317
  drawCanvas2D,
15267
16318
  drawText,
15268
16319
  drawTextScreen,
15269
16320
  setBlendMode,
15270
16321
  combineCanvases,
15271
- engineFontImage,
15272
- FontImage,
16322
+ engineImageFont,
16323
+ ImageFont,
15273
16324
  isFullscreen,
15274
16325
  toggleFullscreen,
15275
16326
  setCursor,
@@ -15378,16 +16429,17 @@ export
15378
16429
  // Medals
15379
16430
  medals,
15380
16431
  medalsPreventUnlock,
15381
- medalsInit,
15382
- medalsForEach,
15383
- Medal,
15384
16432
  medalDisplayTime,
15385
16433
  medalDisplaySlideTime,
15386
16434
  medalDisplaySize,
16435
+ medalsInit,
16436
+ medalsForEach,
16437
+ medalsReset,
15387
16438
  setMedalDisplayTime,
15388
16439
  setMedalDisplaySlideTime,
15389
16440
  setMedalDisplaySize,
15390
16441
  setMedalsPreventUnlock,
16442
+ Medal,
15391
16443
 
15392
16444
  // Newgrounds
15393
16445
  newgrounds,
@@ -15398,8 +16450,14 @@ export
15398
16450
  postProcess,
15399
16451
  PostProcessPlugin,
15400
16452
 
16453
+ // Light System
16454
+ lightSystem,
16455
+ LightSystemPlugin,
16456
+ Light,
16457
+
15401
16458
  // ZzFXMusic
15402
16459
  ZzFXMusic,
16460
+ zzfxM,
15403
16461
 
15404
16462
  // UI System
15405
16463
  uiSystem,
@@ -15446,6 +16504,8 @@ export
15446
16504
  drawNineSliceScreen,
15447
16505
  drawThreeSlice,
15448
16506
  drawThreeSliceScreen,
16507
+ drawCrescent,
16508
+ getCrescentPoints,
15449
16509
 
15450
16510
  // Tween System
15451
16511
  Tween,