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
  {
@@ -585,6 +596,7 @@ const debugPhysics = 0;
585
596
  const debugParticles = 0;
586
597
  const debugRaycast = 0;
587
598
  const debugGamepads = 0;
599
+ const debugSound = 0;
588
600
  const debugPointSize = .5;
589
601
 
590
602
  // debug commands are automatically removed from the final build
@@ -671,19 +683,19 @@ const max = Math.max;
671
683
  * @param {number} x
672
684
  * @return {number}
673
685
  * @memberof Math */
674
- const sign = Math.sign;
686
+ const sign = (x) => Math.sign(x);
675
687
 
676
688
  /** Returns hypotenuse of values passed in
677
689
  * @param {...number} values
678
690
  * @return {number}
679
691
  * @memberof Math */
680
- const hypot = Math.hypot;
692
+ const hypot = (...values) => Math.hypot(...values);
681
693
 
682
694
  /** Returns log2 of value passed in
683
695
  * @param {number} x
684
696
  * @return {number}
685
697
  * @memberof Math */
686
- const log2 = Math.log2;
698
+ const log2 = (x) => Math.log2(x);
687
699
 
688
700
  /** Returns sin of value passed in
689
701
  * @param {number} x
@@ -825,7 +837,8 @@ function isOverlapping(posA, sizeA, posB, sizeB=vec2())
825
837
  const dy = (posA.y - posB.y)*2;
826
838
  const sx = sizeA.x + sizeB.x;
827
839
  const sy = sizeA.y + sizeB.y;
828
- return dx >= -sx && dx < sx && dy >= -sy && dy < sy;
840
+ // symmetric so isOverlapping(A,B) === isOverlapping(B,A) at touching edges
841
+ return abs(dx) < sx && abs(dy) < sy;
829
842
  }
830
843
 
831
844
  /** Returns true if a line segment is intersecting an axis aligned box
@@ -914,7 +927,7 @@ function isStringLike(s) { return s != null && typeof s?.toString() === 'string'
914
927
  /**
915
928
  * Check if object is an array
916
929
  * @param {any} a
917
- * @return {boolean}
930
+ * @return {a is Array<any>}
918
931
  * @memberof Math */
919
932
  function isArray(a) { return Array.isArray(a); }
920
933
 
@@ -1052,7 +1065,13 @@ function randVec2(length=1) { return new Vector2().setAngle(rand(2*PI), length);
1052
1065
  * @return {Vector2}
1053
1066
  * @memberof Random */
1054
1067
  function randInCircle(radius=1, minRadius=0)
1055
- { return radius > 0 ? randVec2(radius * rand(minRadius / radius, 1)**.5) : new Vector2; }
1068
+ {
1069
+ // r is uniform in area ⇒ r² uniform in [minRadius², radius²]
1070
+ // (the squared inner bound is what makes minRadius the actual exclusion edge)
1071
+ if (radius <= 0) return new Vector2;
1072
+ const ratio = clamp(minRadius / radius);
1073
+ return randVec2(radius * rand(ratio*ratio, 1)**.5);
1074
+ }
1056
1075
 
1057
1076
  /** Returns a random color between the two passed in colors, combine components if linear
1058
1077
  * @param {Color} [colorA=WHITE]
@@ -1122,7 +1141,14 @@ class RandomGenerator
1122
1141
  * @param {number} [valueA]
1123
1142
  * @param {number} [valueB]
1124
1143
  * @return {number} */
1125
- floatSign(valueA=1, valueB=0) { return this.float(valueA, valueB) * this.sign(); }
1144
+ floatSign(valueA=1, valueB=0)
1145
+ {
1146
+ const lo = min(valueA, valueB);
1147
+ const hi = max(valueA, valueB);
1148
+ const d = hi - lo;
1149
+ const e = this.float(d*2);
1150
+ return e < d ? lo + e : d - lo - e;
1151
+ }
1126
1152
 
1127
1153
  /** Returns a random angle between -PI and PI
1128
1154
  * @return {number} */
@@ -1835,9 +1861,15 @@ class Timer
1835
1861
  * @return {number} */
1836
1862
  get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
1837
1863
 
1838
- /** Get percentage elapsed based on time it was set to, returns 0 if not set
1864
+ /** Get percentage elapsed based on time it was set to, returns 0 if not set.
1865
+ * Zero-duration timers report 1 (already elapsed).
1839
1866
  * @return {number} */
1840
- getPercent() { return this.isSet()? 1-percent(this.time - this.getGlobalTime(), 0, this.setTime) : 0; }
1867
+ getPercent()
1868
+ {
1869
+ if (!this.isSet()) return 0;
1870
+ if (!this.setTime) return 1;
1871
+ return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime);
1872
+ }
1841
1873
 
1842
1874
  /** Get the time this timer was set to, returns 0 if not set
1843
1875
  * @return {number} */
@@ -1864,9 +1896,9 @@ class Timer
1864
1896
  * @memberof Utilities */
1865
1897
  function formatTime(t)
1866
1898
  {
1867
- const sign = t < 0 ? '-' : '';
1899
+ const signStr = t < 0 ? '-' : '';
1868
1900
  t = abs(t)|0;
1869
- return sign + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
1901
+ return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
1870
1902
  }
1871
1903
 
1872
1904
  /** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
@@ -1952,15 +1984,20 @@ function shareURL(title, url, callback)
1952
1984
  function readSaveData(saveName, defaultSaveData)
1953
1985
  {
1954
1986
  ASSERT(isStringLike(saveName), 'loadData requires saveName string');
1955
-
1956
- // replace undefined values with defaults; tolerate corrupt JSON
1957
- const data = localStorage[saveName];
1987
+
1988
+ // tolerate localStorage being unavailable (iOS private mode, sandboxed
1989
+ // iframes) and corrupt JSON in stored data
1958
1990
  let loadedData = {};
1959
- if (data)
1991
+ try
1960
1992
  {
1961
- try { loadedData = JSON.parse(data); }
1962
- catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
1993
+ const data = localStorage[saveName];
1994
+ if (data)
1995
+ {
1996
+ try { loadedData = JSON.parse(data); }
1997
+ catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
1998
+ }
1963
1999
  }
2000
+ catch { LOG('readSaveData: localStorage unavailable — using defaults'); }
1964
2001
  return { ...defaultSaveData, ...loadedData };
1965
2002
  }
1966
2003
 
@@ -1971,7 +2008,9 @@ function readSaveData(saveName, defaultSaveData)
1971
2008
  function writeSaveData(saveName, saveData)
1972
2009
  {
1973
2010
  ASSERT(isStringLike(saveName), 'saveData requires saveName string');
1974
- localStorage[saveName] = JSON.stringify(saveData);
2011
+ // tolerate localStorage being unavailable or quota exceeded
2012
+ try { localStorage[saveName] = JSON.stringify(saveData); }
2013
+ catch { LOG('writeSaveData: failed to write', saveName); }
1975
2014
  }
1976
2015
 
1977
2016
  ///////////////////////////////////////////////////////////////////////////////
@@ -2066,7 +2105,7 @@ let canvasColorTiles = true;
2066
2105
 
2067
2106
  /** Color to clear the canvas to before render, does not clear if alpha is 0
2068
2107
  * @type {Color}
2069
- * @memberof Draw */
2108
+ * @memberof Settings */
2070
2109
  let canvasClearColor = CLEAR_BLACK;
2071
2110
 
2072
2111
  /** The max size of the canvas, centered if window is larger
@@ -2259,34 +2298,78 @@ let touchInputEnable = true;
2259
2298
  * - Supports left analog stick, 4 face buttons and start button (button 9)
2260
2299
  * - setTouchGamepadButtonCount(1) to use face buttons as right analog stick
2261
2300
  * - Analog stick buttons 10 and 11 are also activated when virtual sticks are touched
2262
-
2301
+ * - Rendered as a full-viewport HTML/SVG overlay, so controls may sit outside the game canvas
2263
2302
  * @type {boolean}
2264
2303
  * @default
2265
2304
  * @memberof Settings */
2266
2305
  let touchGamepadEnable = false;
2267
2306
 
2268
- /** True if touch gamepad should have start button in the center
2269
- * - Prevents activating if overlappng with virtual stick or buttons if they are enabled
2307
+ /** True if touches outside the gamepad controls should still drive mouse/touch input
2308
+ * - When false (the default), enabling the touch gamepad suppresses touch-to-mouse input entirely
2309
+ * - Set true to also pass touches outside the controls through to the game as mouse/touch input
2310
+ * - Touches on the gamepad controls never drive the mouse regardless of this setting
2311
+ * @type {boolean}
2312
+ * @default
2313
+ * @memberof Settings */
2314
+ let touchGamepadPassthrough = false;
2315
+
2316
+ /** Size of center button if touch gamepad should have start button in the center
2317
+ * - Prevents activating when pressed near virtual stick or face buttons
2270
2318
  * - When the game is paused, any touch will press the button
2271
- * - Set size to enable the center button
2319
+ * - Measured in viewport CSS pixels
2272
2320
  * @type {number}
2273
2321
  * @default
2274
2322
  * @memberof Settings */
2275
- let touchGamepadCenterButtonSize = 300;
2323
+ let touchGamepadCenterButtonSize = 0;
2276
2324
 
2277
- /** Number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
2325
+ /** Number of buttons on the right side of the touch gamepad (0-4), using gamepad buttons 0-3
2326
+ * - A count of 1 is a single large button (the size of a stick)
2327
+ * - Ignored when touchGamepadRightStick is set (the right side is a stick instead)
2278
2328
  * @type {number}
2279
2329
  * @default
2280
2330
  * @memberof Settings */
2281
2331
  let touchGamepadButtonCount = 4;
2282
2332
 
2333
+ /** True if the touch gamepad should have a left analog stick (or dpad)
2334
+ * - When false, the left side is face buttons (touchGamepadLeftButtonCount) or nothing
2335
+ * @type {boolean}
2336
+ * @default
2337
+ * @memberof Settings */
2338
+ let touchGamepadLeftStick = true;
2339
+
2340
+ /** Number of buttons on the left side of the touch gamepad (0-4), using gamepad buttons 4-7
2341
+ * - Only used when touchGamepadLeftStick is false (otherwise the left side is a stick)
2342
+ * - A count of 1 is a single large button (the size of a stick)
2343
+ * @type {number}
2344
+ * @default
2345
+ * @memberof Settings */
2346
+ let touchGamepadLeftButtonCount = 0;
2347
+
2348
+ /** True if the touch gamepad right side should be an analog stick (or dpad) instead of face buttons
2349
+ * - When set, touchGamepadButtonCount is ignored and the right side is a stick
2350
+ * - Uses an analog stick when touchGamepadAnalog is true, otherwise an 8 way dpad
2351
+ * @type {boolean}
2352
+ * @default
2353
+ * @memberof Settings */
2354
+ let touchGamepadRightStick = false;
2355
+
2283
2356
  /** True if touch gamepad should be analog stick or false to use if 8 way dpad
2284
2357
  * @type {boolean}
2285
2358
  * @default
2286
2359
  * @memberof Settings */
2287
2360
  let touchGamepadAnalog = true;
2288
2361
 
2289
- /** Size of virtual gamepad for touch devices in pixels
2362
+ /** True if touch gamepad directional controls should float to where you press
2363
+ * - Only affects analog sticks and dpads, not face buttons
2364
+ * - Directional controls re-anchor to where you press within the bottom ~60% of their screen half; the top ~40% passes through to the game
2365
+ * - The right side floats only when it acts as the right analog stick (touchGamepadRightStick is set)
2366
+ * - A center button (touchGamepadCenterButtonSize) still works since it ignores touches near the sticks
2367
+ * @type {boolean}
2368
+ * @default
2369
+ * @memberof Settings */
2370
+ let touchGamepadFloating = false;
2371
+
2372
+ /** Size of virtual gamepad for touch devices in viewport CSS pixels
2290
2373
  * @type {number}
2291
2374
  * @default
2292
2375
  * @memberof Settings */
@@ -2304,6 +2387,13 @@ let touchGamepadAlpha = .3;
2304
2387
  * @memberof Settings */
2305
2388
  let touchGamepadDisplayTime = 3;
2306
2389
 
2390
+ /** Duration in ms to vibrate when a touch gamepad face button or start button is pressed
2391
+ * - Set to 0 to disable, also requires vibrateEnable and hardware support (ignored on iOS)
2392
+ * @type {number}
2393
+ * @default
2394
+ * @memberof Settings */
2395
+ let touchGamepadVibration = 0;
2396
+
2307
2397
  /** Allow vibration hardware if it exists
2308
2398
  * @type {boolean}
2309
2399
  * @default
@@ -2536,6 +2626,11 @@ function setTouchInputEnable(enable) { touchInputEnable = enable; }
2536
2626
  * @memberof Settings */
2537
2627
  function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
2538
2628
 
2629
+ /** Set if touches outside the gamepad controls should still drive mouse/touch input
2630
+ * @param {boolean} passthrough
2631
+ * @memberof Settings */
2632
+ function setTouchGamepadPassthrough(passthrough) { touchGamepadPassthrough = passthrough; }
2633
+
2539
2634
  /** Set if touch gamepad should have start button in the center
2540
2635
  * - Set size to enable the center button
2541
2636
  * - When the game is paused, any touch will press the button
@@ -2543,16 +2638,57 @@ function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
2543
2638
  * @memberof Settings */
2544
2639
  function setTouchGamepadCenterButtonSize(size) { touchGamepadCenterButtonSize = size; }
2545
2640
 
2546
- /** Set number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
2641
+ /** Set number of buttons on the right side of the touch gamepad (0-4, gamepad buttons 0-3)
2547
2642
  * @param {number} count
2548
2643
  * @memberof Settings */
2549
- function setTouchGamepadButtonCount(count) { touchGamepadButtonCount = count; }
2644
+ function setTouchGamepadButtonCount(count)
2645
+ {
2646
+ touchGamepadButtonCount = count;
2647
+ if (count > 0)
2648
+ touchGamepadRightStick = false;
2649
+ }
2650
+
2651
+ /** Set if the touch gamepad should have a left analog stick (or dpad)
2652
+ * @param {boolean} enable
2653
+ * @memberof Settings */
2654
+ function setTouchGamepadLeftStick(enable)
2655
+ {
2656
+ touchGamepadLeftStick = enable;
2657
+ if (enable)
2658
+ touchGamepadLeftButtonCount = 0;
2659
+ }
2660
+
2661
+ /** Set number of buttons on the left side of the touch gamepad (0-4, gamepad buttons 4-7)
2662
+ * - Only used when touchGamepadLeftStick is false
2663
+ * @param {number} count
2664
+ * @memberof Settings */
2665
+ function setTouchGamepadLeftButtonCount(count)
2666
+ {
2667
+ touchGamepadLeftButtonCount = count;
2668
+ if (count > 0)
2669
+ touchGamepadLeftStick = false;
2670
+ }
2671
+
2672
+ /** Set if the touch gamepad right side is an analog stick (or dpad) instead of face buttons
2673
+ * @param {boolean} rightStick
2674
+ * @memberof Settings */
2675
+ function setTouchGamepadRightStick(rightStick)
2676
+ {
2677
+ touchGamepadRightStick = rightStick;
2678
+ if (rightStick)
2679
+ touchGamepadButtonCount = 0;
2680
+ }
2550
2681
 
2551
2682
  /** Set if touch gamepad should be analog stick or 8 way dpad
2552
2683
  * @param {boolean} analog
2553
2684
  * @memberof Settings */
2554
2685
  function setTouchGamepadAnalog(analog) { touchGamepadAnalog = analog; }
2555
2686
 
2687
+ /** Set if touch gamepad directional controls should float to where you press
2688
+ * @param {boolean} floating
2689
+ * @memberof Settings */
2690
+ function setTouchGamepadFloating(floating) { touchGamepadFloating = floating; }
2691
+
2556
2692
  /** Set size of virtual gamepad for touch devices in pixels
2557
2693
  * @param {number} size
2558
2694
  * @memberof Settings */
@@ -2568,6 +2704,11 @@ function setTouchGamepadAlpha(alpha) { touchGamepadAlpha = alpha; }
2568
2704
  * @memberof Settings */
2569
2705
  function setTouchGamepadDisplayTime(time) { touchGamepadDisplayTime = time; }
2570
2706
 
2707
+ /** Set duration in ms to vibrate when a touch gamepad face or start button is pressed (0 disables)
2708
+ * @param {number} ms
2709
+ * @memberof Settings */
2710
+ function setTouchGamepadVibration(ms) { touchGamepadVibration = ms; }
2711
+
2571
2712
  /** Set to allow vibration hardware if it exists
2572
2713
  * @param {boolean} enable
2573
2714
  * @memberof Settings */
@@ -2678,7 +2819,7 @@ class EngineObject
2678
2819
  this.color = color.copy();
2679
2820
  /** @property {Color} - Additive color to apply when rendered */
2680
2821
  this.additiveColor = undefined;
2681
- /** @property {boolean} - Should it flip along y axis when rendered */
2822
+ /** @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. */
2682
2823
  this.mirror = false;
2683
2824
  /** @property {boolean} - Has object been destroyed? */
2684
2825
  this.destroyed = false;
@@ -2746,10 +2887,10 @@ class EngineObject
2746
2887
  if (pa)
2747
2888
  {
2748
2889
  const c = cos(-pa), s = sin(-pa);
2749
- this.pos = new Vector2(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
2890
+ this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
2750
2891
  }
2751
2892
  else
2752
- this.pos = new Vector2(lx + pp.x, ly + pp.y);
2893
+ this.pos.set(lx + pp.x, ly + pp.y);
2753
2894
  this.angle = mirror*this.localAngle + pa;
2754
2895
  }
2755
2896
 
@@ -2764,6 +2905,9 @@ class EngineObject
2764
2905
  // child objects do not have physics
2765
2906
  ASSERT(!this.parent);
2766
2907
 
2908
+ // bail if a collision callback destroyed us mid-frame
2909
+ if (this.destroyed) return;
2910
+
2767
2911
  if (this.clampSpeed)
2768
2912
  {
2769
2913
  // limit max speed to prevent missing collisions
@@ -2819,6 +2963,8 @@ class EngineObject
2819
2963
 
2820
2964
  // notify objects of collision and check if should be resolved
2821
2965
  const collide1 = this.collideWithObject(o);
2966
+ // callback may have destroyed us; stop resolving against more objects
2967
+ if (this.destroyed) return;
2822
2968
  const collide2 = o.collideWithObject(this);
2823
2969
  if (!collide1 || !collide2) continue;
2824
2970
 
@@ -2914,13 +3060,17 @@ class EngineObject
2914
3060
  const restitution = max(this.restitution, hitLayer.restitution);
2915
3061
  if (isBlockedX)
2916
3062
  {
2917
- // try to move up a tiny bit
3063
+ // try to step over a 1-tile bump (direction follows gravity sign
3064
+ // so inverted gravity steps down off a ceiling bump instead of up;
3065
+ // zero gravity defaults to the normal-gravity step-up direction)
2918
3066
  const epsilon = 1e-3;
2919
- const maxMoveUp = .1;
2920
- const y = floor(oldPos.y-this.size.y/2+1) +
2921
- this.size.y/2 + epsilon;
2922
- const delta = y - this.pos.y;
2923
- if (delta < maxMoveUp)
3067
+ const maxMove = .1;
3068
+ const gravitySign = gravity.y > 0 ? -1 : 1;
3069
+ const y = gravitySign > 0 ?
3070
+ floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon :
3071
+ ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon;
3072
+ const delta = abs(y - this.pos.y);
3073
+ if (delta < maxMove)
2924
3074
  if (!tileCollisionTest(vec2(this.pos.x, y), this.size, this))
2925
3075
  {
2926
3076
  this.pos.y = y;
@@ -2972,6 +3122,9 @@ class EngineObject
2972
3122
  drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor);
2973
3123
  }
2974
3124
 
3125
+ /** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
3126
+ renderLight() {}
3127
+
2975
3128
  /** Destroy this object, destroy its children, detach its parent, and mark it for removal
2976
3129
  * @param {boolean} [immediate] - should attached effects be allowed to die off? */
2977
3130
  destroy(immediate=false)
@@ -3060,6 +3213,8 @@ class EngineObject
3060
3213
  * @return {EngineObject} The child object added */
3061
3214
  addChild(child, localPos=vec2(), localAngle=0)
3062
3215
  {
3216
+ ASSERT(!this.destroyed, 'cannot add child to destroyed object');
3217
+ if (this.destroyed) return child;
3063
3218
  ASSERT(!child.parent && !this.children.includes(child));
3064
3219
  ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
3065
3220
  ASSERT(child !== this, 'cannot add self as child');
@@ -3076,10 +3231,7 @@ class EngineObject
3076
3231
  removeChild(child)
3077
3232
  {
3078
3233
  ASSERT(child.parent === this && this.children.includes(child));
3079
- ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
3080
- const index = this.children.indexOf(child);
3081
- ASSERT(index >= 0, 'child not found in children array');
3082
- index >= 0 && this.children.splice(index, 1);
3234
+ this.children.splice(this.children.indexOf(child), 1);
3083
3235
  child.parent = undefined;
3084
3236
  }
3085
3237
 
@@ -3154,7 +3306,7 @@ class EngineObject
3154
3306
  * - Optimized tile sheet sprite rendering using WebGL batching
3155
3307
  * - Primitive drawing for polygons, ellipses, and lines
3156
3308
  * - Tile-based rendering with TileInfo and TextureInfo classes
3157
- * - Text rendering with custom fonts and FontImage support
3309
+ * - Text rendering with custom fonts and ImageFont support
3158
3310
  * - Color and additive color blending for effects
3159
3311
  * - Rotation, mirroring, and scaling transformations
3160
3312
  * - Camera system with position, scale, and rotation
@@ -3250,7 +3402,7 @@ let primitiveCount;
3250
3402
  * tile(1, 16, 3) // a tile at index 1 of size 16 on texture 3
3251
3403
  * tile(vec2(4,8), vec2(30,10)) // a tile at index (4,8) with a size of (30,10)
3252
3404
  * @memberof Draw */
3253
- function tile(index=new Vector2, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
3405
+ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
3254
3406
  {
3255
3407
  ASSERT(isVector2(index) || typeof index === 'number', 'index must be a vec2 or number');
3256
3408
  ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
@@ -3301,8 +3453,8 @@ class TileInfo
3301
3453
  * @param {Vector2} [pos=vec2()] - Top left corner of tile in pixels
3302
3454
  * @param {Vector2} [size] - Size of tile in pixels
3303
3455
  * @param {TextureInfo} [textureInfo] - Texture info to use
3304
- * @param {number} [padding] - How many pixels padding around tiles
3305
- * @param {number} [bleed] - How many pixels smaller to draw tiles
3456
+ * @param {number} [padding] - How many pixels padding around all sides of each tile (increases grid size, does not affect tile size)
3457
+ * @param {number} [bleed] - How many pixels smaller to shrink UVS of tiles (does not affect grid size, only UVs)
3306
3458
  */
3307
3459
  constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed)
3308
3460
  {
@@ -3334,7 +3486,7 @@ class TileInfo
3334
3486
  ASSERT(typeof frame === 'number');
3335
3487
  const w = this.size.x + this.padding*2;
3336
3488
  const x = frame*w;
3337
- ASSERT(x < this.textureInfo.size.x, 'frame extends beyond texture width!');
3489
+ ASSERT(x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
3338
3490
  return this.offset(new Vector2(x));
3339
3491
  }
3340
3492
 
@@ -3423,7 +3575,7 @@ class TextureInfo
3423
3575
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
3424
3576
  * @memberof Draw */
3425
3577
  function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
3426
- angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace, context)
3578
+ angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=false, context)
3427
3579
  {
3428
3580
  ASSERT(isVector2(pos), 'pos must be a vec2');
3429
3581
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -3466,10 +3618,8 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
3466
3618
  }
3467
3619
  else
3468
3620
  {
3469
- // untextured: glDrawUntextured picks the optimal path (poly
3470
- // tristrip if already in poly mode, otherwise instanced with
3471
- // uvs/rgba zeroed). Color+additive are folded together to match
3472
- // the Canvas2D path's color.add(additiveColor) on line ~337.
3621
+ // untextured: fold color+additive to match the Canvas2D path's
3622
+ // color.add(additiveColor) on line ~337.
3473
3623
  const combined = additiveColor ? color.add(additiveColor) : color;
3474
3624
  glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
3475
3625
  }
@@ -3479,11 +3629,12 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
3479
3629
  // normal canvas 2D rendering method (slower)
3480
3630
  ++drawCount;
3481
3631
  ++primitiveCount;
3482
- size = new Vector2(size.x, -size.y); // flip upside down sprites
3483
3632
  drawCanvas2D(pos, size, angle, mirror, (context)=>
3484
3633
  {
3485
3634
  if (textureInfo)
3486
3635
  {
3636
+ // un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
3637
+ context.scale(1, -1);
3487
3638
  // calculate uvs and render
3488
3639
  const x = tileInfo.pos.x, y = tileInfo.pos.y;
3489
3640
  const w = tileInfo.size.x, h = tileInfo.size.y;
@@ -3491,7 +3642,7 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
3491
3642
  }
3492
3643
  else
3493
3644
  {
3494
- // if no tile info, use untextured rect
3645
+ // if no tile info, use untextured rect (Y-symmetric, no compensation needed)
3495
3646
  const c = additiveColor ? color.add(additiveColor) : color;
3496
3647
  context.fillStyle = c.toString();
3497
3648
  context.fillRect(-.5, -.5, 1, 1);
@@ -3565,11 +3716,10 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, an
3565
3716
  // normal canvas 2D rendering method (slower)
3566
3717
  ++drawCount;
3567
3718
  ++primitiveCount;
3568
- size = new Vector2(size.x, -size.y); // fix upside down sprites
3569
3719
  drawCanvas2D(pos, size, angle, false, (context)=>
3570
3720
  {
3571
- // if no tile info, use untextured rect
3572
- const gradient = context.createLinearGradient(0, -.5, 0, .5);
3721
+ // gradient endpoints are flipped to match the Y flip inside drawCanvas2D
3722
+ const gradient = context.createLinearGradient(0, .5, 0, -.5);
3573
3723
  gradient.addColorStop(0, colorTop.toString());
3574
3724
  gradient.addColorStop(1, colorBottom.toString());
3575
3725
  context.fillStyle = gradient;
@@ -3682,7 +3832,7 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
3682
3832
  * @param {boolean} [screenSpace]
3683
3833
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
3684
3834
  * @memberof Draw */
3685
- function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace, context)
3835
+ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
3686
3836
  {
3687
3837
  ASSERT(isArray(points), 'points must be an array');
3688
3838
  ASSERT(isNumber(width), 'width must be a number');
@@ -3731,7 +3881,7 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
3731
3881
  * @param {boolean} [screenSpace]
3732
3882
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
3733
3883
  * @memberof Draw */
3734
- function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, screenSpace, context)
3884
+ function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
3735
3885
  {
3736
3886
  const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
3737
3887
  const size = vec2(width, halfDelta.length()*2);
@@ -3747,9 +3897,9 @@ function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, sc
3747
3897
  * @param {Vector2} [size=vec2(1)]
3748
3898
  * @param {number} [sides]
3749
3899
  * @param {Color} [color=WHITE]
3750
- * @param {number} [angle]
3751
3900
  * @param {number} [lineWidth]
3752
3901
  * @param {Color} [lineColor=BLACK]
3902
+ * @param {number} [angle]
3753
3903
  * @param {boolean} [useWebGL=glEnable]
3754
3904
  * @param {boolean} [screenSpace]
3755
3905
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
@@ -3842,7 +3992,7 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
3842
3992
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
3843
3993
 
3844
3994
  // clamp line width to prevent artifacts
3845
- lineWidth = clamp(lineWidth, 0, Math.min(size.x, size.y));
3995
+ lineWidth = clamp(lineWidth, 0, min(size.x, size.y));
3846
3996
 
3847
3997
  if (useWebGL && glEnable)
3848
3998
  {
@@ -3884,24 +4034,26 @@ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useW
3884
4034
  drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
3885
4035
  }
3886
4036
 
3887
- /** Draw a circle filled with a radial gradient from the center to the rim
4037
+ /** Draw an ellipse filled with a radial gradient from the center to the rim
3888
4038
  * - Best when batched with other untextured polys
3889
4039
  * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
3890
4040
  * - Stacking gradients at the exact same position may show a faint vertical artifact
3891
4041
  * @param {Vector2} pos
3892
- * @param {number} [size=1] - Diameter
4042
+ * @param {Vector2} [size=vec2(1)] - Width and height diameter
3893
4043
  * @param {Color} [colorInner=WHITE]
3894
4044
  * @param {Color} [colorOuter=CLEAR_WHITE]
4045
+ * @param {number} [angle]
3895
4046
  * @param {boolean} [useWebGL=glEnable]
3896
4047
  * @param {boolean} [screenSpace]
3897
4048
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
3898
4049
  * @memberof Draw */
3899
- let drawCircleGradientOffset = 0;
3900
- function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
4050
+ let drawEllipseGradientOffset = 0;
4051
+ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
3901
4052
  {
3902
4053
  ASSERT(isVector2(pos), 'pos must be a vec2');
3903
- ASSERT(isNumber(size), 'size must be a number');
4054
+ ASSERT(isVector2(size), 'size must be a vec2');
3904
4055
  ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid');
4056
+ ASSERT(isNumber(angle), 'angle must be a number');
3905
4057
  ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
3906
4058
 
3907
4059
  if (headlessMode) return;
@@ -3913,26 +4065,33 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
3913
4065
  {
3914
4066
  // convert to world space
3915
4067
  pos = screenToWorld(pos);
3916
- size /= cameraScale;
4068
+ size = size.scale(1/cameraScale);
4069
+ angle += cameraAngle;
3917
4070
  }
3918
4071
  // fan as tristrip; rotate the boundary vertex by one slice per call
3919
4072
  // so back-to-back gradients at the same position have their hole
3920
4073
  // (from gpu edge-rule on the boundary line-degen) at different rim
3921
4074
  // verts and don't visibly stack
3922
4075
  const sides = glCircleSides;
3923
- const radius = size/2;
4076
+ const radiusX = size.x/2, radiusY = size.y/2;
3924
4077
  const innerInt = colorInner.rgbaInt();
3925
4078
  const outerInt = colorOuter.rgbaInt();
3926
- const offset = drawCircleGradientOffset++;
4079
+ const offset = drawEllipseGradientOffset++;
4080
+ const c = cos(-angle), s = sin(-angle);
4081
+ const rim = (a) =>
4082
+ {
4083
+ const lx = sin(a)*radiusX, ly = cos(a)*radiusY;
4084
+ return vec2(pos.x + lx*c - ly*s, pos.y + lx*s + ly*c);
4085
+ };
3927
4086
  const startA = (offset%sides)/sides*PI*2;
3928
- const points = [vec2(pos.x + sin(startA)*radius, pos.y + cos(startA)*radius)];
4087
+ const points = [rim(startA)];
3929
4088
  const colors = [outerInt];
3930
4089
  for (let i=sides; i--;)
3931
4090
  {
3932
4091
  const a = ((i+offset)%sides)/sides*PI*2;
3933
4092
  points.push(pos);
3934
4093
  colors.push(innerInt);
3935
- points.push(vec2(pos.x + sin(a)*radius, pos.y + cos(a)*radius));
4094
+ points.push(rim(a));
3936
4095
  colors.push(outerInt);
3937
4096
  }
3938
4097
  glDrawColoredPoints(points, colors);
@@ -3942,7 +4101,7 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
3942
4101
  // normal canvas 2D rendering method (slower)
3943
4102
  ++drawCount;
3944
4103
  ++primitiveCount;
3945
- drawCanvas2D(pos, vec2(size), 0, false, (context)=>
4104
+ drawCanvas2D(pos, size, angle, false, (context)=>
3946
4105
  {
3947
4106
  const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5);
3948
4107
  gradient.addColorStop(0, colorInner.toString());
@@ -3955,13 +4114,34 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
3955
4114
  }
3956
4115
  }
3957
4116
 
4117
+ /** Draw a circle filled with a radial gradient from the center to the rim
4118
+ * - Best when batched with other untextured polys
4119
+ * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
4120
+ * - Stacking gradients at the exact same position may show a faint vertical artifact
4121
+ * @param {Vector2} pos
4122
+ * @param {number} [size=1] - Diameter
4123
+ * @param {Color} [colorInner=WHITE]
4124
+ * @param {Color} [colorOuter=CLEAR_WHITE]
4125
+ * @param {boolean} [useWebGL=glEnable]
4126
+ * @param {boolean} [screenSpace]
4127
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
4128
+ * @memberof Draw */
4129
+ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
4130
+ {
4131
+ ASSERT(isNumber(size), 'size must be a number');
4132
+ drawEllipseGradient(pos, vec2(size), colorInner, colorOuter, 0, useWebGL, screenSpace, context);
4133
+ }
4134
+
3958
4135
  /**
3959
4136
  * @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context
3960
4137
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
3961
4138
  * @memberof Draw
3962
4139
  */
3963
4140
 
3964
- /** Draw directly to a 2d canvas context in world space
4141
+ /** Draw directly to a 2d canvas context in world space.
4142
+ * The Y axis is flipped so world-Y-up coordinates render right-side up
4143
+ * (matches the WebGL path). Callers whose drawing depends on Y direction
4144
+ * (e.g. linear gradients) should flip their own Y endpoints accordingly.
3965
4145
  * @param {Vector2} pos
3966
4146
  * @param {Vector2} size
3967
4147
  * @param {number} angle
@@ -4009,7 +4189,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
4009
4189
  * @param {number} [angle]
4010
4190
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
4011
4191
  * @memberof Draw */
4012
- function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, font, fontStyle, maxWidth, angle=0, context=drawContext)
4192
+ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
4013
4193
  {
4014
4194
  // convert to screen space
4015
4195
  pos = worldToScreen(pos);
@@ -4049,16 +4229,16 @@ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLA
4049
4229
  ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
4050
4230
  ASSERT(isNumber(angle), 'angle must be a number');
4051
4231
 
4232
+ const lines = (text+'').split('\n');
4233
+ const posY = pos.y - (lines.length-1) * size/2; // center vertically
4234
+ // save before style mutations so caller's context state is preserved
4235
+ context.save();
4052
4236
  context.fillStyle = color.toString();
4053
4237
  context.strokeStyle = lineColor.toString();
4054
4238
  context.lineWidth = lineWidth;
4055
4239
  context.textAlign = textAlign;
4056
4240
  context.font = fontStyle + ' ' + size + 'px '+ font;
4057
4241
  context.textBaseline = 'middle';
4058
-
4059
- const lines = (text+'').split('\n');
4060
- const posY = pos.y - (lines.length-1) * size/2; // center vertically
4061
- context.save();
4062
4242
  context.translate(pos.x, posY);
4063
4243
  context.rotate(-angle);
4064
4244
  let yOffset = 0;
@@ -4219,6 +4399,9 @@ function isOnScreen(pos, size=0)
4219
4399
  ASSERT(isVector2(pos), 'pos must be a vec2');
4220
4400
  ASSERT(isVector2(size) || isNumber(size), 'size must be a vec2 or number');
4221
4401
 
4402
+ // cameraScale of 0 collapses world coords; nothing is visible
4403
+ if (!cameraScale) return false;
4404
+
4222
4405
  // optimized circle on screen test
4223
4406
  // pos = worldToScreen(pos);
4224
4407
  let x = pos.x - cameraPos.x;
@@ -4260,7 +4443,10 @@ function combineCanvases()
4260
4443
  const w = mainCanvasSize.x, h = mainCanvasSize.y;
4261
4444
  workCanvas.width = w;
4262
4445
  workCanvas.height = h;
4263
- workContext.fillRect(0,0,w,h); // remove background alpha
4446
+ // remove background alpha — explicit fillStyle so a previous caller
4447
+ // leaving workContext.fillStyle transparent can't silently no-op this
4448
+ workContext.fillStyle = '#000';
4449
+ workContext.fillRect(0,0,w,h);
4264
4450
  glCopyToContext(workContext);
4265
4451
  workContext.drawImage(mainCanvas, 0, 0);
4266
4452
  mainContext.drawImage(workCanvas, 0, 0);
@@ -4403,33 +4589,33 @@ function setCursor(cursorStyle = 'auto')
4403
4589
  ///////////////////////////////////////////////////////////////////////////////
4404
4590
 
4405
4591
  /** Engine font image, 8x8 font provided by the engine
4406
- * @type {FontImage}
4592
+ * @type {ImageFont}
4407
4593
  * @memberof Draw */
4408
- let engineFontImage;
4594
+ let engineImageFont;
4409
4595
 
4410
4596
  /**
4411
- * Font Image Object - Draw text by using tiles in an image
4597
+ * Image Font Object - Draw text by using tiles in an image
4412
4598
  * - 96 characters (from space to tilde) are stored in an image
4413
4599
  * - A 8x8 default engine font is supplied for general use
4414
4600
  * - This system is WebGL enabled for fast text rendering
4415
4601
  * - Fonts can also be colored and scaled along each axis
4416
- *
4602
+ *
4417
4603
  * @memberof Draw
4418
4604
  * @example
4419
4605
  * // use built in font
4420
- * const font = engineFontImage;
4606
+ * const font = engineImageFont;
4421
4607
  *
4422
4608
  * // draw text
4423
4609
  * font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50));
4424
4610
  */
4425
- class FontImage
4611
+ class ImageFont
4426
4612
  {
4427
4613
  /** Create an image font
4428
4614
  * @param {TileInfo} tileInfo - Tile info of first character in font
4429
4615
  */
4430
4616
  constructor(tileInfo)
4431
4617
  {
4432
- ASSERT(!!tileInfo, 'tileInfo is required for FontImage');
4618
+ ASSERT(!!tileInfo, 'tileInfo is required for ImageFont');
4433
4619
 
4434
4620
  /** @property {TileInfo} - Tile info for the font */
4435
4621
  this.tileInfo = tileInfo.frame(0);
@@ -4514,7 +4700,7 @@ class FontImage
4514
4700
  }
4515
4701
 
4516
4702
  // load engine font, called automatically on startup
4517
- async function fontImageInit()
4703
+ async function imageFontInit()
4518
4704
  {
4519
4705
  const image = new Image;
4520
4706
  await new Promise(resolve =>
@@ -4527,7 +4713,7 @@ async function fontImageInit()
4527
4713
  const tilePos=vec2(), tileSize=vec2(8), padding=1, bleed=0;
4528
4714
  const textureInfo = new TextureInfo(image);
4529
4715
  const tileInfo = new TileInfo(tilePos, tileSize, textureInfo, padding, bleed);
4530
- engineFontImage = new FontImage(tileInfo);
4716
+ engineImageFont = new ImageFont(tileInfo);
4531
4717
  }
4532
4718
  /**
4533
4719
  * LittleJS Input System
@@ -4619,6 +4805,7 @@ function inputClear()
4619
4805
  inputData[0] = [];
4620
4806
  touchGamepadButtons.length = 0;
4621
4807
  touchGamepadSticks.length = 0;
4808
+ touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor
4622
4809
  gamepadStickData.length = 0;
4623
4810
  gamepadDpadData.length = 0;
4624
4811
  }
@@ -4861,6 +5048,14 @@ const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
4861
5048
 
4862
5049
  // touch gamepad internal variables
4863
5050
  const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
5051
+ // floating stick anchors (stage-local CSS pixels) and owning pointer ids, indexed by stick (0=left, 1=right)
5052
+ const touchGamepadStickAnchors = [], touchGamepadStickPointerId = [];
5053
+ // pointerId -> control role ('stick0', 'stick1', 'face<n>', or 'start')
5054
+ const touchGamepadPointerRole = new Map();
5055
+ // overlay DOM elements (created lazily on touch devices) and cached SVG shapes
5056
+ let touchGamepadOverlay, touchGamepadStage, touchGamepadSvg, touchGamepadSvgEls;
5057
+ let touchGamepadSideZones = [], touchGamepadZoneC;
5058
+ let touchGamepadNeedRelayout = true, touchGamepadLastLayout;
4864
5059
 
4865
5060
  ///////////////////////////////////////////////////////////////////////////////
4866
5061
  // Input system functions used by engine
@@ -4976,14 +5171,24 @@ function inputInit()
4976
5171
  mouseDeltaScreen = mouseDeltaScreen.add(movement);
4977
5172
  }
4978
5173
  function onMouseLeave() { mouseInWindow = false; } // mouse moved off window
4979
- function onMouseWheel(e)
4980
- {
4981
- mouseWheel = e.ctrlKey ? 0 : sign(e.deltaY);
5174
+ function onMouseWheel(e)
5175
+ {
5176
+ // accumulate so multiple wheel events in one frame are not lost
5177
+ if (!e.ctrlKey)
5178
+ mouseWheel += sign(e.deltaY);
4982
5179
  if (inputPreventDefault && e.cancelable && document.hasFocus())
4983
5180
  e.preventDefault(); // prevent page scrolling
4984
5181
  }
4985
5182
  function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
4986
- function onBlur() { inputClear(); } // reset input when focus is lost
5183
+ function onBlur()
5184
+ {
5185
+ inputClear();
5186
+ // release any held virtual gamepad controls so they don't stick
5187
+ touchGamepadPointerRole.clear();
5188
+ touchGamepadButtons.length = 0;
5189
+ touchGamepadSticks.length = 0;
5190
+ touchGamepadStickPointerId.length = 0;
5191
+ }
4987
5192
 
4988
5193
  // enable touch input mouse passthrough
4989
5194
  function touchInputInit()
@@ -4999,36 +5204,46 @@ function inputInit()
4999
5204
  {
5000
5205
  if (!touchInputEnable) return;
5001
5206
 
5002
- // route touch to gamepad
5003
- if (touchGamepadEnable)
5004
- handleTouchGamepad(e);
5005
-
5006
5207
  // fix stalled audio requiring user interaction
5007
5208
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
5008
5209
  audioContext.resume();
5009
5210
 
5010
- // check if touching and pass to mouse events
5011
- const touching = e.touches.length;
5012
- const button = 0; // all touches are left mouse button
5013
- if (touching)
5211
+ // when the touch gamepad is enabled it owns touch input: suppress the
5212
+ // touch->mouse passthrough entirely unless touchGamepadPassthrough is set
5213
+ // (its own zones drive gameplay via pointer events)
5214
+ if (!touchGamepadEnable || touchGamepadPassthrough)
5014
5215
  {
5015
- // set event pos and pass it along
5016
- const pos = vec2(e.touches[0].clientX, e.touches[0].clientY);
5017
- const mousePosScreenLast = mousePosScreen;
5018
- mousePosScreen = mouseEventToScreen(pos);
5019
- if (wasTouching)
5216
+ // touches that landed on a virtual gamepad zone are owned by the gamepad
5217
+ // (handled by its own pointer listeners) and must not drive the game mouse
5218
+ const isGamepadTouch = (t)=>
5219
+ touchGamepadSideZones.includes(t.target) || t.target === touchGamepadZoneC;
5220
+ const gameTouches = [];
5221
+ for (const t of e.touches)
5222
+ if (!isGamepadTouch(t)) gameTouches.push(t);
5223
+
5224
+ // check if touching and pass to mouse events
5225
+ const touching = gameTouches.length;
5226
+ const button = 0; // all touches are left mouse button
5227
+ if (touching)
5020
5228
  {
5021
- mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
5022
- isUsingGamepad = touchGamepadEnable;
5229
+ // set event pos and pass it along
5230
+ const pos = vec2(gameTouches[0].clientX, gameTouches[0].clientY);
5231
+ const mousePosScreenLast = mousePosScreen;
5232
+ mousePosScreen = mouseEventToScreen(pos);
5233
+ if (wasTouching)
5234
+ mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
5235
+ else
5236
+ {
5237
+ inputData[0][button] = 3;
5238
+ isUsingGamepad = false; // a passthrough tap is mouse-style input
5239
+ }
5023
5240
  }
5024
- else
5025
- inputData[0][button] = 3;
5026
- }
5027
- else if (wasTouching)
5028
- inputData[0][button] = inputData[0][button] & 2 | 4;
5241
+ else if (wasTouching)
5242
+ inputData[0][button] = inputData[0][button] & 2 | 4;
5029
5243
 
5030
- // set was touching
5031
- wasTouching = touching;
5244
+ // set was touching
5245
+ wasTouching = touching;
5246
+ }
5032
5247
 
5033
5248
  // prevent default handling like copy, magnifier lens, and scrolling
5034
5249
  if (inputPreventDefault && e.cancelable && document.hasFocus())
@@ -5038,78 +5253,6 @@ function inputInit()
5038
5253
  return true;
5039
5254
  }
5040
5255
 
5041
- // special handling for virtual gamepad mode
5042
- function handleTouchGamepad(e)
5043
- {
5044
- // clear touch gamepad input
5045
- touchGamepadSticks.length = 0;
5046
- touchGamepadSticks[0] = vec2();
5047
- touchGamepadSticks[1] = vec2();
5048
- touchGamepadButtons.length = 0;
5049
- isUsingGamepad = true;
5050
-
5051
- const touching = e.touches.length;
5052
- if (touching)
5053
- {
5054
- touchGamepadTimer.set();
5055
- if (touchGamepadCenterButtonSize && !wasTouching && paused)
5056
- {
5057
- // touch anywhere to press start when paused
5058
- touchGamepadButtons[9] = 1;
5059
- return;
5060
- }
5061
- }
5062
-
5063
- // don't process touch gamepad if paused
5064
- if (paused) return;
5065
-
5066
- // get center of left and right sides
5067
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5068
- const buttonCenter = touchGamepadButtonCenter();
5069
- const startCenter = mainCanvasSize.scale(.5);
5070
-
5071
- // check each touch point
5072
- for (const touch of e.touches)
5073
- {
5074
- const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
5075
- if (stickCenter.distance(touchPos) < touchGamepadSize)
5076
- {
5077
- // virtual analog stick
5078
- const delta = touchPos.subtract(stickCenter);
5079
- touchGamepadSticks[0] = delta.scale(2/touchGamepadSize).clampLength();
5080
- touchGamepadButtons[10] = 1; // also press a button when touching stick
5081
- }
5082
- else if (buttonCenter.distance(touchPos) < touchGamepadSize)
5083
- {
5084
- if (touchGamepadButtonCount === 1)
5085
- {
5086
- // virtual right analog stick
5087
- const delta = touchPos.subtract(buttonCenter);
5088
- touchGamepadSticks[1] = delta.scale(2/touchGamepadSize).clampLength();
5089
- touchGamepadButtons[11] = 1; // also press a button when touching right stick
5090
- }
5091
- // virtual face buttons
5092
- let button = buttonCenter.subtract(touchPos).direction();
5093
- button = mod(button+2, 4);
5094
- if (touchGamepadButtonCount === 1)
5095
- button = 0;
5096
- else if (touchGamepadButtonCount === 2)
5097
- {
5098
- const delta = buttonCenter.subtract(touchPos);
5099
- button = -delta.x < delta.y ? 1 : 0;
5100
- }
5101
- // fix button locations (swap 2 and 3 to match gamepad layout)
5102
- button = button === 3 ? 2 : button === 2 ? 3 : button;
5103
- if (button < touchGamepadButtonCount)
5104
- touchGamepadButtons[button] = 1;
5105
- }
5106
- else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize)
5107
- {
5108
- // virtual start button in center
5109
- touchGamepadButtons[9] = 1;
5110
- }
5111
- }
5112
- }
5113
5256
  }
5114
5257
 
5115
5258
  // convert a mouse or touch event position to screen space
@@ -5134,6 +5277,9 @@ function inputUpdate()
5134
5277
  mousePos = screenToWorld(mousePosScreen);
5135
5278
  mouseDelta = screenToWorldDelta(mouseDeltaScreen);
5136
5279
 
5280
+ // build the touch gamepad overlay lazily once enabled on a touch device
5281
+ touchGamepadInit();
5282
+
5137
5283
  // update gamepads if enabled
5138
5284
  gamepadsUpdate();
5139
5285
 
@@ -5147,22 +5293,16 @@ function inputUpdate()
5147
5293
  v > min ? percent(v, min, max) :
5148
5294
  v < -min ? -percent(-v, min, max) : 0;
5149
5295
  return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
5150
- }
5296
+ };
5151
5297
 
5152
5298
  // update touch gamepad if enabled
5153
5299
  if (touchGamepadEnable && isTouchDevice)
5154
5300
  {
5155
- if (debugGamepads)
5156
- {
5157
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5158
- const buttonCenter = touchGamepadButtonCenter();
5159
- const startCenter = mainCanvasSize.scale(.5);
5160
-
5161
- debugCircle(stickCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
5162
- debugCircle(buttonCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
5163
- if (touchGamepadCenterButtonSize)
5164
- debugCircle(startCenter, 2*touchGamepadCenterButtonSize, 'cyan', 0, false, true);
5165
- }
5301
+ // a side is either a stick or buttons - setting both is ambiguous
5302
+ ASSERT(!touchGamepadLeftStick || !touchGamepadLeftButtonCount,
5303
+ 'set touchGamepadLeftStick or touchGamepadLeftButtonCount, not both');
5304
+ ASSERT(!touchGamepadRightStick || !touchGamepadButtonCount,
5305
+ 'set touchGamepadRightStick or touchGamepadButtonCount, not both');
5166
5306
 
5167
5307
  if (!touchGamepadTimer.isSet()) return;
5168
5308
 
@@ -5170,23 +5310,24 @@ function inputUpdate()
5170
5310
  gamepadPrimary = 0; // touch gamepad uses index 0
5171
5311
  const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
5172
5312
  const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
5173
- sticks[0] = vec2();
5313
+ sticks.length = 0; // only report sticks that are enabled
5174
5314
  dpad.set();
5175
- const leftTouchStick = touchGamepadSticks[0] ?? vec2();
5176
- if (touchGamepadAnalog)
5177
- sticks[0] = applyDeadZones(leftTouchStick);
5178
- else if (leftTouchStick.lengthSquared() > .3)
5315
+ // read each side's directional stick (analog, or quantized to an 8 way dpad)
5316
+ for (let side = 0; side < 2; side++)
5179
5317
  {
5180
- // convert to 8 way dpad
5181
- const x = clamp(round(leftTouchStick.x), -1, 1);
5182
- const y = clamp(round(leftTouchStick.y), -1, 1);
5183
- dpad.set(x, -y);
5184
- sticks[0] = dpad.clampLength(); // clamp to circle
5185
- }
5186
- if (touchGamepadButtonCount === 1)
5187
- {
5188
- const rightTouchStick = touchGamepadSticks[1] ?? vec2();
5189
- sticks[1] = applyDeadZones(rightTouchStick);
5318
+ if (!touchGamepadSideStick(side)) continue;
5319
+ const out = touchGamepadStickOut(side);
5320
+ sticks[out] = vec2();
5321
+ const touchStick = touchGamepadSticks[side] ?? vec2();
5322
+ if (touchGamepadAnalog)
5323
+ sticks[out] = applyDeadZones(touchStick);
5324
+ else if (touchStick.lengthSquared() > .3)
5325
+ {
5326
+ const x = clamp(round(touchStick.x), -1, 1);
5327
+ const y = clamp(round(touchStick.y), -1, 1);
5328
+ sticks[out] = vec2(x, -y).clampLength(); // clamp to circle
5329
+ if (!out) dpad.set(x, -y); // the primary (stick 0) also drives the dpad vector
5330
+ }
5190
5331
  }
5191
5332
 
5192
5333
  // read virtual gamepad buttons
@@ -5195,6 +5336,12 @@ function inputUpdate()
5195
5336
  {
5196
5337
  const wasDown = gamepadIsDown(i,0);
5197
5338
  data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
5339
+
5340
+ // haptic tap when a face button or start button is first pressed (3 = newly down)
5341
+ // skip stick touches (10, 11) so movement doesn't buzz
5342
+ if (touchGamepadVibration && data[i] === 3 &&
5343
+ (i === 9 || touchGamepadIsFaceButton(i)))
5344
+ vibrate(touchGamepadVibration);
5198
5345
  }
5199
5346
 
5200
5347
  // disable normal gamepads when touch gamepad is active
@@ -5268,13 +5415,6 @@ function inputUpdate()
5268
5415
  (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
5269
5416
  (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
5270
5417
  }
5271
- else if (gamepad.axes && gamepad.axes.length >= 2)
5272
- {
5273
- // digital style dpad from axes
5274
- const x = clamp(round(gamepad.axes[0]), -1, 1);
5275
- const y = clamp(round(gamepad.axes[1]), -1, 1);
5276
- dpad.set(x, -y);
5277
- }
5278
5418
 
5279
5419
  // copy dpad to left analog stick when pressed
5280
5420
  if (gamepadDirectionEmulateStick && (dpad.x || dpad.y))
@@ -5302,80 +5442,470 @@ function inputUpdatePost()
5302
5442
  function inputRender()
5303
5443
  {
5304
5444
  touchGamepadRender();
5445
+ }
5305
5446
 
5306
- function touchGamepadRender()
5447
+ ///////////////////////////////////////////////////////////////////////////////
5448
+ // Touch gamepad - full-viewport HTML/SVG overlay driven by Pointer Events
5449
+
5450
+ const touchGamepadSvgNS = 'http://www.w3.org/2000/svg';
5451
+
5452
+ // build the overlay DOM once; no-op if already built, disabled, headless, or non-touch
5453
+ function touchGamepadInit()
5454
+ {
5455
+ if (touchGamepadOverlay || !touchGamepadEnable || !isTouchDevice || headlessMode ||
5456
+ !document.body) // body may not exist yet; retry on a later frame
5457
+ return;
5458
+
5459
+ // full-viewport overlay; only the input zones receive pointer events. The
5460
+ // env() padding insets the stage out of notches / home indicators natively.
5461
+ const overlay = touchGamepadOverlay = document.createElement('div');
5462
+ overlay.style.cssText =
5463
+ 'position:fixed;inset:0;z-index:50;pointer-events:none;opacity:0;' +
5464
+ 'touch-action:none;user-select:none;-webkit-user-select:none;' +
5465
+ '-webkit-touch-callout:none;transition:opacity .2s;box-sizing:border-box;' +
5466
+ 'padding:env(safe-area-inset-top) env(safe-area-inset-right) ' +
5467
+ 'env(safe-area-inset-bottom) env(safe-area-inset-left)';
5468
+
5469
+ // stage fills the padded (safe-area) content box; all controls live inside it
5470
+ const stage = touchGamepadStage = document.createElement('div');
5471
+ stage.style.cssText = 'position:relative;width:100%;height:100%;pointer-events:none';
5472
+ overlay.appendChild(stage);
5473
+
5474
+ // svg draws every visual and never blocks input
5475
+ const svg = touchGamepadSvg = document.createElementNS(touchGamepadSvgNS, 'svg');
5476
+ svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;' +
5477
+ 'pointer-events:none;overflow:visible;fill:none;stroke:#fff;stroke-width:3';
5478
+ stage.appendChild(svg);
5479
+
5480
+ // invisible input zones (left stick, right buttons/stick, center start)
5481
+ const makeZone = ()=>
5482
+ {
5483
+ const z = document.createElement('div');
5484
+ z.style.cssText = 'position:absolute;pointer-events:auto;touch-action:none';
5485
+ z.addEventListener('pointerdown', e=> touchGamepadPointerDown(e, z));
5486
+ z.addEventListener('pointermove', e=> touchGamepadPointerMove(e));
5487
+ z.addEventListener('pointerup', e=> touchGamepadPointerUp(e));
5488
+ z.addEventListener('pointercancel', e=> touchGamepadPointerUp(e));
5489
+ stage.appendChild(z);
5490
+ return z;
5491
+ };
5492
+ touchGamepadSideZones[0] = makeZone(); // left
5493
+ touchGamepadSideZones[1] = makeZone(); // right
5494
+ touchGamepadZoneC = makeZone(); // center/start, appended last so it sits above the sides
5495
+
5496
+ addEventListener('resize', ()=> touchGamepadNeedRelayout = true);
5497
+ document.body.appendChild(overlay);
5498
+ touchGamepadNeedRelayout = true;
5499
+ }
5500
+
5501
+ // stage-local size in CSS pixels (excludes safe-area insets)
5502
+ function touchGamepadStageRect() { return touchGamepadStage.getBoundingClientRect(); }
5503
+
5504
+ // per-side touch gamepad config (side 0 = left, 1 = right) - the left and right
5505
+ // sides behave identically, differing only in position and gamepad button indices
5506
+ function touchGamepadSideStick(side)
5507
+ { return side ? touchGamepadRightStick : touchGamepadLeftStick; }
5508
+ function touchGamepadSideButtonCount(side)
5509
+ { return side ? touchGamepadButtonCount : touchGamepadLeftButtonCount; }
5510
+ // gamepad button index a side's buttons start at (right 0-3, left 4-7)
5511
+ function touchGamepadSideButtonBase(side)
5512
+ { return side ? 0 : 4; }
5513
+ // output stick index for a side: the right stick uses stick 0 when there is no left stick
5514
+ function touchGamepadStickOut(side)
5515
+ { return side && touchGamepadLeftStick ? 1 : 0; }
5516
+ // true if the side has any control (a stick or at least one button)
5517
+ function touchGamepadSideHasControl(side)
5518
+ { return touchGamepadSideStick(side) || touchGamepadSideButtonCount(side) > 0; }
5519
+
5520
+ // true if gamepad button index i is an active touch gamepad face/single button
5521
+ function touchGamepadIsFaceButton(i)
5522
+ {
5523
+ for (let side = 0; side < 2; side++)
5524
+ {
5525
+ const base = touchGamepadSideButtonBase(side);
5526
+ if (!touchGamepadSideStick(side) &&
5527
+ i >= base && i < base + touchGamepadSideButtonCount(side))
5528
+ return true;
5529
+ }
5530
+ return false;
5531
+ }
5532
+
5533
+ // center of a side's controls in stage-local CSS pixels (stick rest / button cluster)
5534
+ // returns the floating stick anchor when that side is an active floating stick
5535
+ function touchGamepadSideCenter(side, W, H)
5536
+ {
5537
+ if (touchGamepadFloating && touchGamepadSideStick(side) && touchGamepadStickAnchors[side])
5538
+ return touchGamepadStickAnchors[side];
5539
+ let y = H - touchGamepadSize;
5540
+ const count = touchGamepadSideButtonCount(side);
5541
+ if (!touchGamepadSideStick(side) && (count === 2 || count === 3))
5542
+ y -= touchGamepadSize/4; // nudge a 2/3 button cluster up a bit
5543
+ return vec2(side ? W - touchGamepadSize : touchGamepadSize, y);
5544
+ }
5545
+
5546
+ // position the input zones for the current mode and rebuild the SVG visuals
5547
+ function touchGamepadRelayout()
5548
+ {
5549
+ if (!touchGamepadOverlay) return;
5550
+ const r = touchGamepadStageRect();
5551
+ const W = r.width, H = r.height, S = touchGamepadSize;
5552
+ const setZone = (z, css)=> z.style.cssText =
5553
+ 'position:absolute;pointer-events:auto;touch-action:none;' + css;
5554
+
5555
+ if (paused && touchGamepadCenterButtonSize)
5556
+ {
5557
+ // while paused, any touch presses start
5558
+ setZone(touchGamepadZoneC, 'inset:0');
5559
+ for (const zone of touchGamepadSideZones) zone.style.display = 'none';
5560
+ touchGamepadZoneC.style.display = '';
5561
+ }
5562
+ else
5307
5563
  {
5308
- if (!touchInputEnable || !isTouchDevice || headlessMode) return;
5309
- if (!touchGamepadEnable || !touchGamepadTimer.isSet() && touchGamepadDisplayTime) return;
5564
+ // position each side zone (left/right differ only by which edge they hug)
5565
+ for (let side = 0; side < 2; side++)
5566
+ {
5567
+ const zone = touchGamepadSideZones[side], edge = side ? 'right' : 'left';
5568
+ zone.style.display = touchGamepadSideHasControl(side) ? '' : 'none';
5569
+ if (touchGamepadFloating)
5570
+ {
5571
+ // bottom 60% grabs the control; the top 40% passes through. A side with no
5572
+ // control on the other side uses the full width (matching the hit-test)
5573
+ const width = touchGamepadSideHasControl(side ? 0 : 1) ? '50%' : '100%';
5574
+ setZone(zone, `${edge}:0;bottom:0;width:${width};height:60%`);
5575
+ }
5576
+ else // fixed: a compact box hugging the corner control
5577
+ setZone(zone, `${edge}:0;bottom:0;width:${3*S}px;height:${3*S}px`);
5578
+ }
5579
+ touchGamepadZoneC.style.display = touchGamepadCenterButtonSize ? '' : 'none';
5580
+ const c = touchGamepadCenterButtonSize;
5581
+ setZone(touchGamepadZoneC,
5582
+ `left:50%;top:50%;width:${2*c}px;height:${2*c}px;transform:translate(-50%,-50%)`);
5583
+ }
5310
5584
 
5311
- // fade off when not touching or paused
5312
- const alpha = touchGamepadDisplayTime ? percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
5313
- if (!alpha || paused) return;
5585
+ touchGamepadBuildSvg(W, H);
5586
+ touchGamepadNeedRelayout = false;
5587
+ }
5314
5588
 
5315
- // setup the canvas
5316
- const context = mainContext;
5317
- context.save();
5318
- context.globalAlpha = alpha*touchGamepadAlpha;
5319
- context.strokeStyle = '#fff';
5320
- context.lineWidth = 3;
5589
+ // (re)build the SVG shapes for the current layout; dynamic bits update per-frame
5590
+ function touchGamepadBuildSvg(W, H)
5591
+ {
5592
+ const svg = touchGamepadSvg;
5593
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
5594
+ const els = touchGamepadSvgEls = { face: [], thumb: [] };
5595
+ const S = touchGamepadSize;
5596
+ const circle = (cx, cy, rr, fill)=>
5597
+ {
5598
+ const c = document.createElementNS(touchGamepadSvgNS, 'circle');
5599
+ c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', rr);
5600
+ if (fill) c.setAttribute('fill', fill);
5601
+ svg.appendChild(c);
5602
+ return c;
5603
+ };
5604
+ const cross = (ctr)=>
5605
+ {
5606
+ // plus-shaped dpad outline centered at ctr
5607
+ const a = S*.18, b = S*.5, x = ctr.x, y = ctr.y;
5608
+ const p = document.createElementNS(touchGamepadSvgNS, 'path');
5609
+ p.setAttribute('d',
5610
+ `M ${x-a} ${y-b} H ${x+a} V ${y-a} H ${x+b} V ${y+a} H ${x+a} ` +
5611
+ `V ${y+b} H ${x-a} V ${y+a} H ${x-b} V ${y-a} H ${x-a} Z`);
5612
+ svg.appendChild(p);
5613
+ };
5321
5614
 
5322
- // draw left analog stick
5323
- const leftTouchStick = touchGamepadSticks[0] ?? vec2();
5324
- context.fillStyle = leftTouchStick.lengthSquared() > 0 ? '#fff' : '#000';
5325
- context.beginPath();
5326
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5327
- if (touchGamepadAnalog)
5615
+ // draw each side: a directional stick, a single large button, or face buttons
5616
+ for (let side = 0; side < 2; side++)
5617
+ {
5618
+ const count = touchGamepadSideButtonCount(side);
5619
+ const base = touchGamepadSideButtonBase(side);
5620
+ const ctr = touchGamepadSideCenter(side, W, H);
5621
+ if (touchGamepadSideStick(side))
5328
5622
  {
5329
- // draw circle shaped gamepad
5330
- context.arc(stickCenter.x, stickCenter.y, touchGamepadSize/2, 0, 9);
5623
+ // directional stick (circle or cross) with a thumb dot that moves per-frame
5624
+ if (touchGamepadAnalog) circle(ctr.x, ctr.y, S/2); else cross(ctr);
5625
+ els.thumb[side] = circle(ctr.x, ctr.y, S/4, '#fff');
5331
5626
  }
5332
- else
5627
+ else if (count === 1)
5628
+ els.face[base] = circle(ctr.x, ctr.y, S/2, '#000'); // single large button
5629
+ else for (let i = 0; i < count; i++)
5333
5630
  {
5334
- // draw cross shaped gamepad
5335
- for (let i=10; --i;)
5336
- {
5337
- const angle = i*PI/4;
5338
- context.arc(stickCenter.x, stickCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8);
5339
- i%2 && context.arc(stickCenter.x, stickCenter.y, touchGamepadSize*.33, angle, angle);
5340
- }
5631
+ const j = mod(i-1, 4);
5632
+ let button = count > 2 ? j : min(j, count-1);
5633
+ button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
5634
+ const offset = vec2().setDirection(j, S/2);
5635
+ if (count === 2) offset.x *= -1;
5636
+ // left side mirrors the right layout's positions, keeping indices in order
5637
+ // (e.g. 2 buttons -> button 4 at bottom, button 5 at left)
5638
+ if (!side) offset.x *= -1;
5639
+ const pos = ctr.add(offset);
5640
+ els.face[base + button] = circle(pos.x, pos.y, S/4, '#000');
5341
5641
  }
5342
- context.fill();
5343
- context.stroke();
5642
+ }
5643
+
5644
+ // debug: draw the proximity hit regions the hit-test actually uses
5645
+ if (debug && debugGamepads) touchGamepadBuildDebug(W, H);
5646
+ }
5647
+
5648
+ // draw debug outlines of the touch control hit regions into the overlay svg
5649
+ function touchGamepadBuildDebug(W, H)
5650
+ {
5651
+ const S = touchGamepadSize, svg = touchGamepadSvg;
5652
+ const shape = (tag, attrs, stroke)=>
5653
+ {
5654
+ const el = document.createElementNS(touchGamepadSvgNS, tag);
5655
+ for (const k in attrs) el.setAttribute(k, attrs[k]);
5656
+ el.setAttribute('stroke', stroke);
5657
+ el.setAttribute('stroke-width', 2);
5658
+ el.setAttribute('fill', 'none');
5659
+ svg.appendChild(el);
5660
+ };
5661
+ const ring = (c, rr, stroke)=> shape('circle', {cx:c.x, cy:c.y, r:rr}, stroke);
5662
+
5663
+ // green line: the left/right split that assigns a stick press to a side
5664
+ shape('line', {x1:W/2, y1:0, x2:W/2, y2:H}, '#0f0');
5344
5665
 
5345
- // draw right face buttons
5666
+ // cyan: where each side's control can be grabbed
5667
+ for (let side = 0; side < 2; side++)
5668
+ {
5669
+ if (touchGamepadSideStick(side))
5346
5670
  {
5347
- const buttonCenter = touchGamepadButtonCenter();
5348
- const buttonSize = touchGamepadButtonCount > 1 ?
5349
- touchGamepadSize/4 : touchGamepadSize/2;
5350
- for (let i=0; i<touchGamepadButtonCount; i++)
5671
+ if (touchGamepadFloating)
5351
5672
  {
5352
- const j = mod(i-1, 4);
5353
- let button = touchGamepadButtonCount > 2 ?
5354
- j : min(j, touchGamepadButtonCount-1);
5355
- // fix button locations (swap 2 and 3 to match gamepad layout)
5356
- button = button === 3 ? 2 : button === 2 ? 3 : button;
5357
- const pos = touchGamepadButtonCount < 2 ? buttonCenter :
5358
- buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
5359
- context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
5360
- context.beginPath();
5361
- context.arc(pos.x, pos.y, buttonSize, 0,9);
5362
- context.fill();
5363
- context.stroke();
5673
+ // grab region: this side's half (or the full width if the other side is empty)
5674
+ const top = H*.4, full = !touchGamepadSideHasControl(side ? 0 : 1);
5675
+ const x = full ? 0 : (side ? W/2 : 0);
5676
+ shape('rect', {x, y:top, width:full ? W : W/2, height:H-top}, '#0ff');
5364
5677
  }
5678
+ else
5679
+ ring(touchGamepadSideCenter(side, W, H), 2*S, '#0ff');
5365
5680
  }
5681
+ else if (touchGamepadSideButtonCount(side) >= 1)
5682
+ ring(touchGamepadSideCenter(side, W, H), S, '#0ff'); // face / single-button radius
5683
+ }
5366
5684
 
5367
- // set canvas back to normal
5368
- context.restore();
5685
+ // yellow: start button radius; magenta: where start is blocked (near a control)
5686
+ if (touchGamepadCenterButtonSize)
5687
+ {
5688
+ ring(vec2(W/2, H/2), touchGamepadCenterButtonSize, '#ff0');
5689
+ for (let side = 0; side < 2; side++)
5690
+ if (touchGamepadSideHasControl(side))
5691
+ ring(touchGamepadSideCenter(side, W, H), 2*S, '#f0f');
5369
5692
  }
5370
5693
  }
5371
5694
 
5372
- // center position for right touch pad face buttons
5373
- function touchGamepadButtonCenter()
5695
+ // per-frame: fade the overlay and move the thumbs / set pressed states
5696
+ function touchGamepadRender()
5374
5697
  {
5375
- const center = mainCanvasSize.subtract(vec2(touchGamepadSize));
5376
- if (touchGamepadButtonCount === 2)
5377
- center.x += touchGamepadSize/2;
5378
- return center;
5698
+ if (!touchGamepadOverlay || headlessMode) return;
5699
+
5700
+ // hide and bail if disabled at runtime (overlay stays in the DOM for reuse)
5701
+ // display:none also takes the input zones out of hit-testing so touches are
5702
+ // not silently captured away from the game while disabled
5703
+ if (!touchGamepadEnable || !isTouchDevice)
5704
+ {
5705
+ if (touchGamepadOverlay.style.display !== 'none')
5706
+ {
5707
+ // just disabled: hide the overlay and release any held controls
5708
+ touchGamepadOverlay.style.display = 'none';
5709
+ touchGamepadPointerRole.clear();
5710
+ touchGamepadButtons.length = 0;
5711
+ touchGamepadSticks.length = 0;
5712
+ touchGamepadStickPointerId.length = 0;
5713
+ }
5714
+ return;
5715
+ }
5716
+ touchGamepadOverlay.style.display = '';
5717
+
5718
+ // relayout when the paused state, a layout setting, or the debug view changes
5719
+ const dbg = debug && debugGamepads;
5720
+ const layout = [touchGamepadButtonCount, touchGamepadLeftButtonCount, touchGamepadLeftStick,
5721
+ touchGamepadRightStick, touchGamepadAnalog, touchGamepadSize, touchGamepadFloating,
5722
+ touchGamepadCenterButtonSize, paused, dbg].join();
5723
+ if (layout !== touchGamepadLastLayout)
5724
+ {
5725
+ touchGamepadLastLayout = layout;
5726
+ touchGamepadNeedRelayout = true;
5727
+ }
5728
+ // relayout before the visibility bail-out so the paused full-screen start zone applies
5729
+ if (touchGamepadNeedRelayout) touchGamepadRelayout();
5730
+
5731
+ // fade out when idle (always show when displayTime is 0, or while debugging)
5732
+ const fade = touchGamepadDisplayTime ?
5733
+ percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
5734
+ const visible = dbg || (touchGamepadTimer.isSet() && fade > 0 && !paused);
5735
+ touchGamepadOverlay.style.opacity = !visible ? 0 : dbg ? 1 : fade*touchGamepadAlpha;
5736
+ if (!visible) return;
5737
+
5738
+ const r = touchGamepadStageRect();
5739
+ const W = r.width, H = r.height, S = touchGamepadSize;
5740
+ const els = touchGamepadSvgEls;
5741
+ if (!els) return;
5742
+
5743
+ for (let side = 0; side < 2; side++)
5744
+ if (touchGamepadSideStick(side) && els.thumb[side])
5745
+ {
5746
+ const ctr = touchGamepadSideCenter(side, W, H);
5747
+ const t = ctr.add((touchGamepadSticks[side] ?? vec2()).scale(S/2));
5748
+ els.thumb[side].setAttribute('cx', t.x);
5749
+ els.thumb[side].setAttribute('cy', t.y);
5750
+ }
5751
+ for (let i = 0; i < els.face.length; i++)
5752
+ if (els.face[i])
5753
+ els.face[i].setAttribute('fill', touchGamepadButtons[i] ? '#fff' : '#000');
5754
+ }
5755
+
5756
+ // convert a pointer event to stage-local CSS pixels
5757
+ function touchGamepadEventPos(e)
5758
+ {
5759
+ const r = touchGamepadStageRect();
5760
+ return vec2(e.clientX - r.left, e.clientY - r.top);
5761
+ }
5762
+
5763
+ // set a directional stick from a stage-local point and flag its stick-touch button
5764
+ // (stick 0 press = button 10, stick 1 press = button 11, following the output index)
5765
+ function touchGamepadApplyStick(side, p)
5766
+ {
5767
+ const delta = p.subtract(touchGamepadStickAnchors[side]);
5768
+ touchGamepadSticks[side] = delta.scale(2/touchGamepadSize).clampLength();
5769
+ touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10] = 1;
5770
+ }
5771
+
5772
+ // pick a side's gamepad button index from a stage-local point, or -1 if outside the cluster
5773
+ function touchGamepadFaceButtonAt(side, p, W, H)
5774
+ {
5775
+ const count = touchGamepadSideButtonCount(side);
5776
+ const base = touchGamepadSideButtonBase(side);
5777
+ const bc = touchGamepadSideCenter(side, W, H);
5778
+ if (bc.distance(p) >= touchGamepadSize) return -1;
5779
+ if (count === 1) return base; // single large button
5780
+ const d = bc.subtract(p);
5781
+ if (!side) d.x *= -1; // left side mirrors the right layout's positions horizontally
5782
+ let button = count === 2 ? (d.x < d.y ? 1 : 0) : mod(d.direction()+2, 4);
5783
+ button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
5784
+ return button < count ? base + button : -1;
5785
+ }
5786
+
5787
+ // pick which control a stage-local press activates, by priority then proximity,
5788
+ // independent of which zone element captured it - so overlapping zones on small
5789
+ // screens resolve to the nearest control instead of whichever zone is topmost
5790
+ // returns {role:'stick', side} or {role:'face', btn} or {role:'start'} or undefined
5791
+ function touchGamepadControlAt(p, W, H)
5792
+ {
5793
+ const S = touchGamepadSize;
5794
+ const leftHalf = p.x < W/2;
5795
+ const floatTop = H*.4; // floating grab region is the bottom 60% of the screen
5796
+
5797
+ // check each side (left first for priority); a side is a stick or buttons
5798
+ for (let side = 0; side < 2; side++)
5799
+ {
5800
+ const onHalf = side ? !leftHalf : leftHalf;
5801
+ if (touchGamepadSideStick(side))
5802
+ {
5803
+ // a side with no control on the other side uses the full width
5804
+ const otherControl = touchGamepadSideHasControl(side ? 0 : 1);
5805
+ const grab = touchGamepadFloating ?
5806
+ (!otherControl || onHalf) && p.y > floatTop :
5807
+ onHalf && touchGamepadSideCenter(side, W, H).distance(p) < 2*S;
5808
+ if (grab) return {role:'stick', side};
5809
+ }
5810
+ else if (touchGamepadSideButtonCount(side) >= 1)
5811
+ {
5812
+ const btn = touchGamepadFaceButtonAt(side, p, W, H);
5813
+ if (btn >= 0) return {role:'face', btn};
5814
+ }
5815
+ }
5816
+
5817
+ // center start button, blocked within 2*size of a control so drift off a
5818
+ // control can't accidentally fire start (matches the original exclusion logic)
5819
+ if (touchGamepadCenterButtonSize)
5820
+ {
5821
+ for (let side = 0; side < 2; side++)
5822
+ if (touchGamepadSideHasControl(side) &&
5823
+ touchGamepadSideCenter(side, W, H).distance(p) < 2*S)
5824
+ return;
5825
+ if (vec2(W/2, H/2).distance(p) < touchGamepadCenterButtonSize)
5826
+ return {role:'start'};
5827
+ }
5828
+ }
5829
+
5830
+ function touchGamepadPointerDown(e, zone)
5831
+ {
5832
+ if (!touchGamepadEnable) return;
5833
+ e.preventDefault();
5834
+ zone.setPointerCapture(e.pointerId);
5835
+ touchGamepadTimer.set();
5836
+ isUsingGamepad = true;
5837
+
5838
+ // resume audio on first interaction
5839
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
5840
+ audioContext.resume();
5841
+
5842
+ // while paused, any touch is the start button
5843
+ if (paused)
5844
+ {
5845
+ if (touchGamepadCenterButtonSize)
5846
+ {
5847
+ touchGamepadButtons[9] = 1;
5848
+ touchGamepadPointerRole.set(e.pointerId, 'start');
5849
+ }
5850
+ return;
5851
+ }
5852
+
5853
+ const r = touchGamepadStageRect();
5854
+ const W = r.width, H = r.height;
5855
+ const p = vec2(e.clientX - r.left, e.clientY - r.top);
5856
+
5857
+ // choose the control by proximity/priority, not by which zone captured the touch
5858
+ const hit = touchGamepadControlAt(p, W, H);
5859
+ if (!hit) return;
5860
+ if (hit.role === 'stick')
5861
+ {
5862
+ const side = hit.side;
5863
+ touchGamepadStickAnchors[side] = touchGamepadFloating ? p : touchGamepadSideCenter(side, W, H);
5864
+ touchGamepadStickPointerId[side] = e.pointerId;
5865
+ touchGamepadPointerRole.set(e.pointerId, 'stick'+side);
5866
+ touchGamepadNeedRelayout = true; // base may have re-anchored
5867
+ touchGamepadApplyStick(side, p);
5868
+ }
5869
+ else if (hit.role === 'face')
5870
+ {
5871
+ touchGamepadButtons[hit.btn] = 1;
5872
+ touchGamepadPointerRole.set(e.pointerId, 'face'+hit.btn);
5873
+ }
5874
+ else // 'start'
5875
+ {
5876
+ touchGamepadButtons[9] = 1;
5877
+ touchGamepadPointerRole.set(e.pointerId, 'start');
5878
+ }
5879
+ }
5880
+
5881
+ function touchGamepadPointerMove(e)
5882
+ {
5883
+ const role = touchGamepadPointerRole.get(e.pointerId);
5884
+ if (!role) return;
5885
+ e.preventDefault();
5886
+ const p = touchGamepadEventPos(e);
5887
+ if (role === 'stick0' || role === 'stick1')
5888
+ touchGamepadApplyStick(role === 'stick1' ? 1 : 0, p);
5889
+ // face buttons & start are held until release (no slide-between this pass)
5890
+ }
5891
+
5892
+ function touchGamepadPointerUp(e)
5893
+ {
5894
+ const role = touchGamepadPointerRole.get(e.pointerId);
5895
+ if (!role) return;
5896
+ touchGamepadPointerRole.delete(e.pointerId);
5897
+ if (role === 'stick0' || role === 'stick1')
5898
+ {
5899
+ const side = role === 'stick1' ? 1 : 0;
5900
+ touchGamepadStickPointerId[side] = undefined;
5901
+ touchGamepadSticks[side] = vec2();
5902
+ delete touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10];
5903
+ }
5904
+ else if (role === 'start')
5905
+ delete touchGamepadButtons[9];
5906
+ else // 'face<n>'
5907
+ delete touchGamepadButtons[+role.slice(4)];
5908
+ touchGamepadTimer.set();
5379
5909
  }
5380
5910
  /**
5381
5911
  * LittleJS Audio System
@@ -5480,7 +6010,7 @@ class Sound
5480
6010
  /** @property {SoundLoadCallback} - function to call when sound is loaded */
5481
6011
  this.onloadCallback = onloadCallback;
5482
6012
 
5483
- if (Array.isArray(asset))
6013
+ if (isArray(asset))
5484
6014
  {
5485
6015
  // generate zzfx sound — copy so we don't mutate the caller's array
5486
6016
  const zzfxSound = asset.slice();
@@ -5512,7 +6042,7 @@ class Sound
5512
6042
  * @param {number} [randomnessScale] - How much to scale pitch randomness
5513
6043
  * @param {boolean} [loop] - Should the sound loop?
5514
6044
  * @param {boolean} [paused] - Should the sound start paused
5515
- * @return {SoundInstance} - The audio source node
6045
+ * @return {SoundInstance} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode
5516
6046
  */
5517
6047
  play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
5518
6048
  {
@@ -5542,10 +6072,24 @@ class Sound
5542
6072
  // get pan from screen space coords
5543
6073
  pan = worldToScreen(pos).x * 2/mainCanvas.width - 1;
5544
6074
  }
5545
-
5546
- // Create and return sound instance
5547
- const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
5548
- return new SoundInstance(this, volume, rate, pan, loop, paused);
6075
+
6076
+ // Create sound instance
6077
+ const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
6078
+ const instance = new SoundInstance(this, volume, rate, pan, loop, paused);
6079
+
6080
+ if (debug && debugSound && pos)
6081
+ {
6082
+ // visualize where positioned sounds play and their falloff range
6083
+ debugCircle(pos, .5, '#0ff', .5, true);
6084
+ if (this.range)
6085
+ {
6086
+ debugCircle(pos, 2*this.range, '#0ff', .5); // silent radius
6087
+ debugCircle(pos, 2*this.range*this.taper, '#0ff', .5); // full volume radius
6088
+ }
6089
+ debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5);
6090
+ }
6091
+
6092
+ return instance;
5549
6093
  }
5550
6094
 
5551
6095
  /** Play a music track that loops by default
@@ -5572,7 +6116,7 @@ class Sound
5572
6116
  }
5573
6117
 
5574
6118
  /** Get how long this sound is in seconds
5575
- * @return {number} - How long the sound is in seconds (undefined if loading)
6119
+ * @return {number} - How long the sound is in seconds (0 if loading)
5576
6120
  */
5577
6121
  getDuration()
5578
6122
  { return this.sampleChannels?.[0]?.length / this.sampleRate || 0; }
@@ -5729,10 +6273,14 @@ class SoundInstance
5729
6273
  {
5730
6274
  if (fadeTime)
5731
6275
  {
5732
- // ramp off gain
6276
+ // ramp off gain from current volume (not 1, or low-volume
6277
+ // instances would jump back up before fading);
6278
+ // cancel any prior scheduling so stacked stop calls don't
6279
+ // re-anchor partway through a previous fade
5733
6280
  const startFade = audioContext.currentTime;
5734
6281
  const endFade = startFade + fadeTime;
5735
- this.gainNode.gain.linearRampToValueAtTime(1, startFade);
6282
+ this.gainNode.gain.cancelScheduledValues(startFade);
6283
+ this.gainNode.gain.setValueAtTime(this.volume, startFade);
5736
6284
  this.gainNode.gain.linearRampToValueAtTime(0, endFade);
5737
6285
  this.source.stop(endFade);
5738
6286
  }
@@ -5780,13 +6328,14 @@ class SoundInstance
5780
6328
  */
5781
6329
  getCurrentTime()
5782
6330
  {
5783
- const deltaTime = mod(audioContext.currentTime - this.startTime,
5784
- this.getDuration());
5785
- return this.isPlaying() ? deltaTime : this.pausedTime;
6331
+ if (!this.isPlaying()) return this.pausedTime;
6332
+ const duration = this.getDuration();
6333
+ // guard mod against 0 duration (rate=0 or sound not loaded)
6334
+ return duration ? mod(audioContext.currentTime - this.startTime, duration) : 0;
5786
6335
  }
5787
6336
 
5788
6337
  /** Get the total duration of this sound
5789
- * @return {number} - Total duration in seconds
6338
+ * @return {number} - Total duration in seconds (0 if loading)
5790
6339
  */
5791
6340
  getDuration() { return this.rate ? this.sound.getDuration() / this.rate : 0; }
5792
6341
 
@@ -5810,7 +6359,7 @@ function speak(text, volume=1, rate=1, pitch=1, language='')
5810
6359
  {
5811
6360
  ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch');
5812
6361
  if (!soundEnable || headlessMode) return;
5813
- if (!speechSynthesis) return;
6362
+ if (typeof speechSynthesis === 'undefined') return;
5814
6363
 
5815
6364
  // common languages (not supported by all browsers)
5816
6365
  // en - english, it - italian, fr - french, de - german, es - spanish
@@ -5828,7 +6377,11 @@ function speak(text, volume=1, rate=1, pitch=1, language='')
5828
6377
 
5829
6378
  /** Stop all queued speech
5830
6379
  * @memberof Audio */
5831
- function speakStop() {speechSynthesis?.cancel();}
6380
+ function speakStop()
6381
+ {
6382
+ if (typeof speechSynthesis !== 'undefined')
6383
+ speechSynthesis.cancel();
6384
+ }
5832
6385
 
5833
6386
  /** Get frequency of a note on a musical scale
5834
6387
  * @param {number} semitoneOffset - How many semitones away from the root note
@@ -5890,13 +6443,22 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
5890
6443
  const pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)});
5891
6444
  source.connect(pannerNode).connect(gainNode);
5892
6445
 
5893
- // callback when the sound ends
5894
- if (onended)
5895
- source.addEventListener('ended', ()=> onended(source));
6446
+ // disconnect nodes when the sound ends so the audio graph doesn't grow
6447
+ // unbounded across many play() calls (source.stop() also fires 'ended')
6448
+ source.addEventListener('ended', ()=>
6449
+ {
6450
+ gainNode.disconnect();
6451
+ pannerNode.disconnect();
6452
+ if (onended) onended(source);
6453
+ });
5896
6454
 
5897
6455
  // play and return sound
5898
6456
  const startOffset = offset * rate;
5899
6457
  source.start(0, startOffset);
6458
+
6459
+ if (debug && debugSound)
6460
+ LOG('sound', 'vol', volume.toFixed(2), 'rate', rate.toFixed(2), 'pan', pan.toFixed(2), loop ? 'loop' : '');
6461
+
5900
6462
  return source;
5901
6463
  }
5902
6464
 
@@ -6129,14 +6691,29 @@ function tileCollisionTest(pos, size=vec2(), callbackObject, solidOnly=true)
6129
6691
  * @memberof TileLayers */
6130
6692
  function tileCollisionRaycast(posStart, posEnd, callbackObject, normal, solidOnly=true)
6131
6693
  {
6694
+ // check every layer and keep the closest hit so a far hit in an
6695
+ // earlier-registered layer doesn't shadow a closer hit in a later one
6696
+ let closestHit, closestDistSq, closestNormal;
6697
+ const scratchNormal = normal && vec2();
6132
6698
  for (const layer of tileCollisionLayers)
6133
6699
  {
6134
6700
  if (!solidOnly || layer.isSolid)
6135
6701
  {
6136
- const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, normal)
6137
- if (hitPos) return hitPos;
6702
+ const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, scratchNormal);
6703
+ if (hitPos)
6704
+ {
6705
+ const d = posStart.distanceSquared(hitPos);
6706
+ if (closestHit === undefined || d < closestDistSq)
6707
+ {
6708
+ closestHit = hitPos;
6709
+ closestDistSq = d;
6710
+ if (normal) closestNormal = scratchNormal.copy();
6711
+ }
6712
+ }
6138
6713
  }
6139
6714
  }
6715
+ if (closestHit && normal) normal.setFrom(closestNormal);
6716
+ return closestHit;
6140
6717
  }
6141
6718
 
6142
6719
  ///////////////////////////////////////////////////////////////////////////////
@@ -6310,38 +6887,6 @@ class CanvasLayer extends EngineObject
6310
6887
  drawTile(pos, size, tileInfo, color, angle, mirror, additiveColor, useWebGL, screenSpace, context);
6311
6888
  }
6312
6889
 
6313
- /** Draw a tile onto the layer canvas in world space
6314
- * @param {Vector2} pos
6315
- * @param {Vector2} [size=vec2(1)]
6316
- * @param {TileInfo} [tileInfo]
6317
- * @param {Color} [color=WHITE]
6318
- * @param {number} [angle]
6319
- * @param {boolean} [mirror] */
6320
- drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
6321
- {
6322
- pos = pos.subtract(this.pos).multiply(this.tileInfo.size);
6323
- size = size.multiply(this.tileInfo.size);
6324
- pos.y = this.canvas.height - pos.y;
6325
-
6326
- // draw the tile onto the layer canvas
6327
- const oldMainCanvasSize = mainCanvasSize;
6328
- mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
6329
- const useWebGL = this.hasWebGL();
6330
- useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
6331
- const drawContext = useWebGL ? undefined : this.context;
6332
- drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
6333
- useWebGL && glSetRenderTarget();
6334
- mainCanvasSize = oldMainCanvasSize;
6335
- }
6336
-
6337
- /** Draw a rectangle onto the layer canvas in world space
6338
- * @param {Vector2} pos
6339
- * @param {Vector2} [size=vec2(1)]
6340
- * @param {Color} [color=WHITE]
6341
- * @param {number} [angle] */
6342
- drawRect(pos, size, color, angle)
6343
- { this.drawTile(pos, size, undefined, color, angle); }
6344
-
6345
6890
  /** Create WebGL texture if necessary and copy layer canvas to it */
6346
6891
  updateWebGL()
6347
6892
  { this.textureInfo.createWebGLTexture(); }
@@ -6397,6 +6942,8 @@ class TileLayer extends CanvasLayer
6397
6942
  this.redrawTileData = ()=> {};
6398
6943
  this.drawLayerTile = ()=> {};
6399
6944
  this.drawLayerRect = ()=> {};
6945
+ this.drawTile = ()=> {};
6946
+ this.drawRect = ()=> {};
6400
6947
  this.clearLayerRect = ()=> {};
6401
6948
  return;
6402
6949
  }
@@ -6424,7 +6971,7 @@ class TileLayer extends CanvasLayer
6424
6971
  ASSERT(data instanceof TileLayerData, 'data must be a TileLayerData');
6425
6972
 
6426
6973
  if (!layerPos.arrayCheck(this.size)) return;
6427
- this.data[(layerPos.y|0)*this.size.x+layerPos.x|0] = data;
6974
+ this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] = data;
6428
6975
 
6429
6976
  if (!redraw) return;
6430
6977
  const isRedraw = drawContext === this.context;
@@ -6439,11 +6986,11 @@ class TileLayer extends CanvasLayer
6439
6986
 
6440
6987
  /** Get data at a given position in the array
6441
6988
  * @param {Vector2} layerPos - Local position in array
6442
- * @return {TileLayerData} */
6989
+ * @return {TileLayerData|undefined} */
6443
6990
  getData(layerPos)
6444
- {
6991
+ {
6445
6992
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
6446
- return layerPos.arrayCheck(this.size) && this.data[(layerPos.y|0)*this.size.x+layerPos.x|0];
6993
+ return layerPos.arrayCheck(this.size) ? this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] : undefined;
6447
6994
  }
6448
6995
 
6449
6996
  // Update the tile layer, refresh texture if needed
@@ -6551,7 +7098,7 @@ class TileLayer extends CanvasLayer
6551
7098
 
6552
7099
  // draw the tile if it has layer data
6553
7100
  const d = this.getData(layerPos);
6554
- if (!d.tile) return;
7101
+ if (!d || !d.tile) return;
6555
7102
 
6556
7103
  const tileInfo = this.tileInfo && this.tileInfo.tile(d.tile);
6557
7104
  this.drawLayerTile(drawPos, drawSize, tileInfo, d.color, d.direction*PI/2, d.mirror);
@@ -6597,6 +7144,38 @@ class TileLayer extends CanvasLayer
6597
7144
  drawLayerRect(pos, size, color, angle=0)
6598
7145
  { this.drawLayerTile(pos, size, undefined, color, angle); }
6599
7146
 
7147
+ /** Draw a tile onto the layer canvas in world space
7148
+ * @param {Vector2} pos
7149
+ * @param {Vector2} [size=vec2(1)]
7150
+ * @param {TileInfo} [tileInfo]
7151
+ * @param {Color} [color=WHITE]
7152
+ * @param {number} [angle]
7153
+ * @param {boolean} [mirror] */
7154
+ drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
7155
+ {
7156
+ pos = pos.subtract(this.pos).multiply(this.tileInfo.size);
7157
+ size = size.multiply(this.tileInfo.size);
7158
+ pos.y = this.canvas.height - pos.y;
7159
+
7160
+ // draw the tile onto the layer canvas
7161
+ const oldMainCanvasSize = mainCanvasSize;
7162
+ mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
7163
+ const useWebGL = this.hasWebGL();
7164
+ useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
7165
+ const drawContext = useWebGL ? undefined : this.context;
7166
+ drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
7167
+ useWebGL && glSetRenderTarget();
7168
+ mainCanvasSize = oldMainCanvasSize;
7169
+ }
7170
+
7171
+ /** Draw a rectangle onto the layer canvas in world space
7172
+ * @param {Vector2} pos
7173
+ * @param {Vector2} [size=vec2(1)]
7174
+ * @param {Color} [color=WHITE]
7175
+ * @param {number} [angle] */
7176
+ drawRect(pos, size, color, angle)
7177
+ { this.drawTile(pos, size, undefined, color, angle); }
7178
+
6600
7179
  /** Clear a rectangle in layer space
6601
7180
  * @param {Vector2} pos - position in pixel coordinates
6602
7181
  * @param {Vector2} size
@@ -6675,7 +7254,7 @@ class TileCollisionLayer extends TileLayer
6675
7254
  setCollisionData(layerPos, data=1)
6676
7255
  {
6677
7256
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
6678
- const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
7257
+ const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
6679
7258
  layerPos.arrayCheck(this.size) && (this.collisionData[i] = data);
6680
7259
  }
6681
7260
 
@@ -6690,7 +7269,7 @@ class TileCollisionLayer extends TileLayer
6690
7269
  getCollisionData(layerPos)
6691
7270
  {
6692
7271
  ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
6693
- const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
7272
+ const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
6694
7273
  return layerPos.arrayCheck(this.size) ? this.collisionData[i] : 0;
6695
7274
  }
6696
7275
 
@@ -6713,10 +7292,17 @@ class TileCollisionLayer extends TileLayer
6713
7292
  // check any tiles in the area for collision
6714
7293
  const posX = pos.x - this.pos.x;
6715
7294
  const posY = pos.y - this.pos.y;
7295
+ // reject AABBs entirely past either edge; without this, the negative
7296
+ // side leaks into row/col 0 because minX/minY clamp to 0 and the
7297
+ // point-test floor below forces maxX/maxY up to 1
7298
+ if (posX + size.x/2 < 0 || posX - size.x/2 > this.size.x) return false;
7299
+ if (posY + size.y/2 < 0 || posY - size.y/2 > this.size.y) return false;
6716
7300
  const minX = max(posX - size.x/2|0, 0);
6717
7301
  const minY = max(posY - size.y/2|0, 0);
6718
- const maxX = min(posX + size.x/2, this.size.x);
6719
- const maxY = min(posY + size.y/2, this.size.y);
7302
+ // ensure at least one cell is visited even when size is 0 and pos
7303
+ // lands exactly on an integer boundary (documented point-test mode)
7304
+ const maxX = min(max(posX + size.x/2, minX + 1), this.size.x);
7305
+ const maxY = min(max(posY + size.y/2, minY + 1), this.size.y);
6720
7306
  const hitPos = new Vector2;
6721
7307
  for (let y = minY; y < maxY; ++y)
6722
7308
  for (let x = minX; x < maxX; ++x)
@@ -6829,13 +7415,13 @@ class ParticleEmitter extends EngineObject
6829
7415
  * @param {number} [particleTime] - How long particles live
6830
7416
  * @param {number} [sizeStart] - How big are particles at start
6831
7417
  * @param {number} [sizeEnd] - How big are particles at end
6832
- * @param {number} [speed] - How fast are particles when spawned
6833
- * @param {number} [angleSpeed] - How fast are particles rotating
6834
- * @param {number} [damping] - How much to dampen particle speed
6835
- * @param {number} [angleDamping] - How much to dampen particle angular speed
7418
+ * @param {number} [speed] - How fast are particles when spawned, in world units per frame (at 60fps, so multiply units/sec by 1/60)
7419
+ * @param {number} [angleSpeed] - How fast are particles rotating, in radians per frame (at 60fps)
7420
+ * @param {number} [damping] - How much to dampen particle speed, per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame)
7421
+ * @param {number} [angleDamping] - How much to dampen particle angular speed, per-frame multiplier (1 = no damping)
6836
7422
  * @param {number} [gravityScale] - How much gravity effect particles
6837
7423
  * @param {number} [particleConeAngle] - Cone for start particle angle
6838
- * @param {number} [fadeRate] - How quick to fade particles at start/end in percent of life
7424
+ * @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
6839
7425
  * @param {number} [randomness] - Apply extra randomness percent
6840
7426
  * @param {boolean} [collideTiles] - Do particles collide against tiles
6841
7427
  * @param {boolean} [additive] - Should particles use additive blend
@@ -6907,19 +7493,19 @@ class ParticleEmitter extends EngineObject
6907
7493
  this.sizeStart = sizeStart;
6908
7494
  /** @property {number} - How big are particles at end */
6909
7495
  this.sizeEnd = sizeEnd;
6910
- /** @property {number} - How fast are particles when spawned */
7496
+ /** @property {number} - Particle speed when spawned, in world units per frame (at 60fps) */
6911
7497
  this.speed = speed;
6912
- /** @property {number} - How fast are particles rotating */
7498
+ /** @property {number} - Particle angular speed when spawned, in radians per frame (at 60fps) */
6913
7499
  this.angleSpeed = angleSpeed;
6914
- /** @property {number} - How much to dampen particle speed */
7500
+ /** @property {number} - Per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame) */
6915
7501
  this.damping = damping;
6916
- /** @property {number} - How much to dampen particle angular speed */
7502
+ /** @property {number} - Per-frame angular velocity multiplier (1 = no damping) */
6917
7503
  this.angleDamping = angleDamping;
6918
7504
  /** @property {number} - How much gravity affects particles */
6919
7505
  this.gravityScale = gravityScale;
6920
7506
  /** @property {number} - Cone for start particle angle */
6921
7507
  this.particleConeAngle = particleConeAngle;
6922
- /** @property {number} - How quick to fade in particles at start/end in percent of life */
7508
+ /** @property {number} - Fraction of life spent fading, split half at start and half at end (e.g. .2 = 10% fade-in + 10% fade-out) */
6923
7509
  this.fadeRate = fadeRate;
6924
7510
  /** @property {number} - Apply extra randomness percent */
6925
7511
  this.randomness = randomness;
@@ -6982,12 +7568,16 @@ class ParticleEmitter extends EngineObject
6982
7568
  else if (this.particles.length === 0)
6983
7569
  this.destroy(true);
6984
7570
 
6985
- // update and remove destroyed particles
6986
- this.particles = this.particles.filter((p)=>
7571
+ // update and remove destroyed particles in place to avoid per-frame array allocation
7572
+ const particles = this.particles;
7573
+ let alive = 0;
7574
+ for (let i = 0; i < particles.length; ++i)
6987
7575
  {
7576
+ const p = particles[i];
6988
7577
  p.update();
6989
- return !p.destroyed;
6990
- });
7578
+ if (!p.destroyed) particles[alive++] = p;
7579
+ }
7580
+ particles.length = alive;
6991
7581
 
6992
7582
  if (debugParticles)
6993
7583
  {
@@ -7197,33 +7787,32 @@ class Particle
7197
7787
  const hitLayer = tileCollisionTest(this.pos);
7198
7788
  if (!testCollision(oldPos))
7199
7789
  {
7200
- if (!collideCallback || collideCallback?.(this, hitLayer))
7790
+ // testCollision already invoked collideCallback with the
7791
+ // correct (this, data, pos) args; no need to re-check here.
7792
+ // test which side we bounced off (or both if a corner)
7793
+ const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
7794
+ const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
7795
+ const hitRestitution = max(restitution, hitLayer.restitution);
7796
+ const hitFriction = max(friction, hitLayer.friction);
7797
+ if (isBlockedX)
7201
7798
  {
7202
- // test which side we bounced off (or both if a corner)
7203
- const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
7204
- const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
7205
- const hitRestitution = max(restitution, hitLayer.restitution);
7206
- const hitFriction = max(friction, hitLayer.friction);
7207
- if (isBlockedX)
7208
- {
7209
- // move to previous X position and bounce
7210
- this.pos.x = oldPos.x;
7211
- this.velocity.x *= -hitRestitution;
7212
- this.velocity.y *= hitFriction;
7213
- }
7214
- if (isBlockedY || !isBlockedX)
7215
- {
7216
- const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
7217
- if (wasFalling)
7218
- this.groundObject = hitLayer;
7219
-
7220
- // move to previous Y position and bounce
7221
- this.pos.y = oldPos.y;
7222
- this.velocity.y *= -hitRestitution;
7223
- this.velocity.x *= hitFriction;
7224
- }
7225
- debugPhysics && debugRect(this.pos, this.size, '#f00');
7799
+ // move to previous X position and bounce
7800
+ this.pos.x = oldPos.x;
7801
+ this.velocity.x *= -hitRestitution;
7802
+ this.velocity.y *= hitFriction;
7803
+ }
7804
+ if (isBlockedY || !isBlockedX)
7805
+ {
7806
+ const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
7807
+ if (wasFalling)
7808
+ this.groundObject = hitLayer;
7809
+
7810
+ // move to previous Y position and bounce
7811
+ this.pos.y = oldPos.y;
7812
+ this.velocity.y *= -hitRestitution;
7813
+ this.velocity.x *= hitFriction;
7226
7814
  }
7815
+ debugPhysics && debugRect(this.pos, this.size, '#f00');
7227
7816
  }
7228
7817
  }
7229
7818
  }
@@ -7270,7 +7859,7 @@ class Particle
7270
7859
  {
7271
7860
  // in local space of emitter
7272
7861
  const a = emitter.angle;
7273
- const c = cos(a), s = sin(a);
7862
+ const c = cos(-a), s = sin(-a);
7274
7863
  pos.set(emitter.pos.x + pos.x*c - pos.y*s,
7275
7864
  emitter.pos.y + pos.x*s + pos.y*c);
7276
7865
  angle += a;
@@ -7278,8 +7867,8 @@ class Particle
7278
7867
  if (trailScale)
7279
7868
  {
7280
7869
  // trail style particles
7281
- const velocity = localSpace ?
7282
- this.velocity.rotate(-emitter.angle) : this.velocity;
7870
+ const velocity = localSpace ?
7871
+ this.velocity.rotate(emitter.angle) : this.velocity;
7283
7872
  const speed = velocity.length();
7284
7873
  if (speed)
7285
7874
  {
@@ -7384,6 +7973,10 @@ function glInit(rootElement)
7384
7973
  for (const info of glTextureInfos)
7385
7974
  info.glTexture = undefined;
7386
7975
  glActiveTexture = undefined;
7976
+ // drop any partially-filled batch so the next glFlush doesn't
7977
+ // upload stale glBatchCount against fresh empty buffers on restore
7978
+ glBatchCount = 0;
7979
+ glPolyMode = false;
7387
7980
  pluginList.forEach(plugin=>plugin.glContextLost?.());
7388
7981
  });
7389
7982
  glCanvas.addEventListener('webglcontextrestored', ()=>
@@ -7742,6 +8335,10 @@ function glSetTextureData(texture, image)
7742
8335
  glContext.bindTexture(glContext.TEXTURE_2D, texture);
7743
8336
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, image);
7744
8337
 
8338
+ // keep mipmaps in sync with new level 0 data (same condition as glCreateTexture)
8339
+ if (!tilesPixelated && isPowerOfTwo(image.width) && isPowerOfTwo(image.height))
8340
+ glContext.generateMipmap(glContext.TEXTURE_2D);
8341
+
7745
8342
  // rebind active texture
7746
8343
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
7747
8344
  }
@@ -7787,7 +8384,7 @@ function glFlush()
7787
8384
  {
7788
8385
  if (glEnable && glContext && glBatchCount)
7789
8386
  {
7790
- // set bend mode
8387
+ // set blend mode
7791
8388
  const destBlend = glBatchAdditive ? glContext.ONE : glContext.ONE_MINUS_SRC_ALPHA;
7792
8389
  glContext.blendFuncSeparate(glContext.SRC_ALPHA, destBlend, glContext.ONE, destBlend);
7793
8390
  glContext.enable(glContext.BLEND);
@@ -7864,9 +8461,9 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
7864
8461
  }
7865
8462
 
7866
8463
  /** Add an untextured rect to the gl draw list
7867
- * Picks the optimal path: if already in poly mode, emits a tristrip rect
7868
- * so it batches with surrounding polys; otherwise uses the instanced path
7869
- * with uvs and rgba zeroed so the color falls through the additive slot.
8464
+ * Zeroes the uvs and rgba so the texture contribution multiplies to 0,
8465
+ * then carries the real color in the additive slot. Works regardless of
8466
+ * which texture is currently bound.
7870
8467
  * @param {number} x
7871
8468
  * @param {number} y
7872
8469
  * @param {number} sizeX
@@ -7876,52 +8473,7 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
7876
8473
  * @memberof WebGL */
7877
8474
  function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
7878
8475
  {
7879
- if (glPolyMode)
7880
- {
7881
- // batch with surrounding polys as a 4-vertex tristrip rect
7882
- const vertCount = 6; // 4 corners + 2 degenerate verts
7883
- if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
7884
- glFlush();
7885
-
7886
- // compute rotated corners in world space (matches glDrawPointsTransform rotation)
7887
- const hx = sizeX*.5, hy = sizeY*.5;
7888
- const c = cos(angle), s = sin(angle);
7889
- const chx = c*hx, shx = s*hx, chy = c*hy, shy = s*hy;
7890
- const x0 = x - chx - shy, y0 = y + shx - chy; // (-hx,-hy)
7891
- const x1 = x + chx - shy, y1 = y - shx - chy; // ( hx,-hy)
7892
- const x2 = x - chx + shy, y2 = y + shx + chy; // (-hx, hy)
7893
- const x3 = x + chx + shy, y3 = y - shx + chy; // ( hx, hy)
7894
-
7895
- // write tristrip with leading/trailing degenerate verts
7896
- let offset = glBatchCount * gl_INDICES_PER_POLY_VERTEX;
7897
- glPositionData[offset++] = x0; glPositionData[offset++] = y0; glColorData[offset++] = rgba;
7898
- glPositionData[offset++] = x0; glPositionData[offset++] = y0; glColorData[offset++] = rgba;
7899
- glPositionData[offset++] = x1; glPositionData[offset++] = y1; glColorData[offset++] = rgba;
7900
- glPositionData[offset++] = x2; glPositionData[offset++] = y2; glColorData[offset++] = rgba;
7901
- glPositionData[offset++] = x3; glPositionData[offset++] = y3; glColorData[offset++] = rgba;
7902
- glPositionData[offset++] = x3; glPositionData[offset++] = y3; glColorData[offset++] = rgba;
7903
- glBatchCount += vertCount;
7904
- return;
7905
- }
7906
-
7907
- // instanced path: zero uvs and rgba so the texture contribution is killed,
7908
- // then carry the real color in the additive slot
7909
- if (glBatchCount >= gl_MAX_INSTANCES || glBatchAdditive !== glAdditive)
7910
- glFlush();
7911
- glSetInstancedMode();
7912
-
7913
- let offset = glBatchCount++ * gl_INDICES_PER_INSTANCE;
7914
- glPositionData[offset++] = x;
7915
- glPositionData[offset++] = y;
7916
- glPositionData[offset++] = sizeX;
7917
- glPositionData[offset++] = sizeY;
7918
- glPositionData[offset++] = 0;
7919
- glPositionData[offset++] = 0;
7920
- glPositionData[offset++] = 0;
7921
- glPositionData[offset++] = 0;
7922
- glColorData[offset++] = 0;
7923
- glColorData[offset++] = rgba;
7924
- glPositionData[offset++] = angle;
8476
+ glDraw(x, y, sizeX, sizeY, angle, 0, 0, 0, 0, 0, rgba);
7925
8477
  }
7926
8478
 
7927
8479
  /** Transform and add a polygon to the gl draw list
@@ -7937,13 +8489,13 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
7937
8489
  function glDrawPointsTransform(points, rgba, x, y, sx, sy, angle, tristrip=true)
7938
8490
  {
7939
8491
  const pointsOut = [];
8492
+ const sa = sin(-angle);
8493
+ const ca = cos(-angle);
7940
8494
  for (const p of points)
7941
8495
  {
7942
8496
  // transform the point
7943
8497
  const px = p.x*sx;
7944
8498
  const py = p.y*sy;
7945
- const sa = sin(-angle);
7946
- const ca = cos(-angle);
7947
8499
  pointsOut.push(vec2(x + ca*px - sa*py, y + sa*px + ca*py));
7948
8500
  }
7949
8501
  const drawPoints = tristrip ? glPolyStrip(pointsOut) : pointsOut;
@@ -7975,11 +8527,13 @@ function glDrawPoints(points, rgba)
7975
8527
  {
7976
8528
  if (!glEnable || points.length < 3)
7977
8529
  return; // needs at least 3 points to have area
7978
-
8530
+
7979
8531
  // flush if there is not enough room or if different blend mode
7980
8532
  const vertCount = points.length + 2;
7981
8533
  if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
7982
8534
  glFlush();
8535
+ ASSERT(vertCount < gl_MAX_POLY_VERTEXES, 'poly exceeds max batch size');
8536
+ if (vertCount >= gl_MAX_POLY_VERTEXES) return; // release-build safety net
7983
8537
  glSetPolyMode();
7984
8538
 
7985
8539
  // setup triangle strip with degenerate verts at start and end
@@ -8003,11 +8557,13 @@ function glDrawColoredPoints(points, pointColors)
8003
8557
  {
8004
8558
  if (!glEnable || points.length < 3)
8005
8559
  return; // needs at least 3 points to have area
8006
-
8560
+
8007
8561
  // flush if there is not enough room or if different blend mode
8008
8562
  const vertCount = points.length + 2;
8009
8563
  if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
8010
8564
  glFlush();
8565
+ ASSERT(vertCount < gl_MAX_POLY_VERTEXES, 'poly exceeds max batch size');
8566
+ if (vertCount >= gl_MAX_POLY_VERTEXES) return; // release-build safety net
8011
8567
  glSetPolyMode();
8012
8568
 
8013
8569
  // setup triangle strip with degenerate verts at start and end
@@ -8077,7 +8633,8 @@ function glMakeOutline(points, width, wrap=true)
8077
8633
  const strip = [];
8078
8634
  const n = points.length;
8079
8635
  const e = 1e-6;
8080
- const miterLimit = width*100;
8636
+ // miter ratio cap (dimensionless, matches SVG/Canvas2D convention)
8637
+ const miterLimit = 10;
8081
8638
  for (let i = 0; i < n; i++)
8082
8639
  {
8083
8640
  // for each vertex, calculate normal based on adjacent edges
@@ -8333,7 +8890,7 @@ function drawEngineLogo(t)
8333
8890
  x.closePath();
8334
8891
  gradient(0, Y, 0, Y+H,C);
8335
8892
  }
8336
- const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
8893
+ const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%)`:'#000';
8337
8894
 
8338
8895
  // center and fit to screen
8339
8896
  const alpha = oscillate(1,1,t);
@@ -8473,7 +9030,15 @@ function medalsInit(saveName)
8473
9030
  // check if medals are unlocked
8474
9031
  medalsSaveName = saveName;
8475
9032
  if (!debugMedals)
8476
- medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
9033
+ {
9034
+ let saved = {};
9035
+ try { saved = JSON.parse(localStorage[saveName] || '{}'); }
9036
+ catch (e) { saved = {}; }
9037
+ medalsForEach(medal => {
9038
+ medal.unlocked = !!(saved[medal.id] && saved[medal.id].unlocked);
9039
+ });
9040
+ medalsSave();
9041
+ }
8477
9042
 
8478
9043
  // engine automatically renders medals
8479
9044
  engineAddPlugin(undefined, medalsRender);
@@ -8517,6 +9082,31 @@ function medalsInit(saveName)
8517
9082
  function medalsForEach(callback)
8518
9083
  { Object.values(medals).forEach(medal=>callback(medal)); }
8519
9084
 
9085
+ /** Reset all medals to locked and persist the cleared catalog
9086
+ * @memberof Medals */
9087
+ function medalsReset()
9088
+ {
9089
+ medalsForEach(medal => medal.unlocked = false);
9090
+ medalsSave();
9091
+ }
9092
+
9093
+ function medalsSave()
9094
+ {
9095
+ if (!medalsSaveName) return;
9096
+ const data = {};
9097
+ medalsForEach(medal => {
9098
+ const entry = {
9099
+ name: medal.name,
9100
+ description: medal.description,
9101
+ icon: medal.icon,
9102
+ unlocked: medal.unlocked,
9103
+ };
9104
+ if (medal.image) entry.src = medal.image.src;
9105
+ data[medal.id] = entry;
9106
+ });
9107
+ localStorage[medalsSaveName] = JSON.stringify(data);
9108
+ }
9109
+
8520
9110
  ///////////////////////////////////////////////////////////////////////////////
8521
9111
 
8522
9112
  /**
@@ -8574,9 +9164,9 @@ class Medal
8574
9164
  {
8575
9165
  if (medalsPreventUnlock || this.unlocked) return;
8576
9166
 
8577
- // save the medal
8578
9167
  ASSERT(medalsSaveName, 'save name must be set');
8579
- localStorage[this.storageKey()] = this.unlocked = true;
9168
+ this.unlocked = true;
9169
+ medalsSave();
8580
9170
  medalsDisplayQueue.push(this);
8581
9171
  }
8582
9172
 
@@ -8634,8 +9224,6 @@ class Medal
8634
9224
  drawTextScreen(this.icon, pos, size*.7, BLACK);
8635
9225
  }
8636
9226
 
8637
- // Get local storage key used by the medal
8638
- storageKey() { return medalsSaveName + '_' + this.id; }
8639
9227
  }
8640
9228
 
8641
9229
  ///////////////////////////////////////////////////////////////////////////////
@@ -8743,8 +9331,20 @@ class NewgroundsPlugin
8743
9331
 
8744
9332
  // get medals
8745
9333
  const medalsResult = this.call('Medal.getList');
9334
+
9335
+ // bail early if the first call failed (offline / bad session /
9336
+ // server error) so we don't block the main thread on more sync
9337
+ // XHRs that are guaranteed to also fail
9338
+ if (!medalsResult || !medalsResult.result || medalsResult.result.error)
9339
+ {
9340
+ debugMedals && LOG('Newgrounds session unavailable; skipping plugin init');
9341
+ this.medals = [];
9342
+ this.scoreboards = [];
9343
+ return;
9344
+ }
9345
+
8746
9346
  /** @property {Array} - Medals fetched from Newgrounds (empty until session is active) */
8747
- this.medals = medalsResult ? medalsResult.result.data['medals'] : [];
9347
+ this.medals = medalsResult.result.data?.['medals'] || [];
8748
9348
  debugMedals && LOG(this.medals);
8749
9349
  for (const newgroundsMedal of this.medals)
8750
9350
  {
@@ -8764,11 +9364,11 @@ class NewgroundsPlugin
8764
9364
  medal.description = medal.description + ` (${ medal.value })`;
8765
9365
  }
8766
9366
  }
8767
-
9367
+
8768
9368
  // get scoreboards
8769
9369
  const scoreboardResult = this.call('ScoreBoard.getBoards');
8770
9370
  /** @property {Array} - Scoreboards fetched from Newgrounds */
8771
- this.scoreboards = scoreboardResult ? scoreboardResult.result.data.scoreboards : [];
9371
+ this.scoreboards = scoreboardResult?.result?.data?.scoreboards || [];
8772
9372
  debugMedals && LOG(this.scoreboards);
8773
9373
 
8774
9374
  // keep the session alive with a ping every minute
@@ -8952,10 +9552,14 @@ class PostProcessPlugin
8952
9552
  function postProcessRender()
8953
9553
  {
8954
9554
  if (headlessMode || !glEnable) return;
8955
-
9555
+
8956
9556
  // clear out the buffer
8957
9557
  glFlush();
8958
9558
 
9559
+ // ensure we render to the default framebuffer (in case any earlier
9560
+ // caller this frame left a render target bound)
9561
+ glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
9562
+
8959
9563
  // setup shader program to draw a quad
8960
9564
  glContext.useProgram(postProcess.shader);
8961
9565
  glContext.bindVertexArray(postProcess.vao);
@@ -8996,11 +9600,343 @@ class PostProcessPlugin
8996
9600
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, glCanvas);
8997
9601
  }
8998
9602
 
9603
+ // restore default so subsequent dynamic texture uploads aren't flipped
9604
+ glContext.pixelStorei(glContext.UNPACK_FLIP_Y_WEBGL, false);
9605
+
8999
9606
  // force it to set instanced mode
9000
9607
  glSetInstancedMode(true);
9001
9608
  }
9002
9609
  }
9003
9610
  }
9611
+ /**
9612
+ * LittleJS Light System Plugin
9613
+ * - Adds 2D dynamic lighting to the scene
9614
+ * - Lights are first-class EngineObjects (the Light class)
9615
+ * - Each Light draws a soft falloff blob of its color into a shared lightmap
9616
+ * - Lights accumulate ADDITIVELY in the lightmap (red + blue = magenta)
9617
+ * - The lightmap is then MULTIPLIED with the scene during composite, so unlit
9618
+ * areas go to the ambient color and lit areas show the scene tinted by the
9619
+ * accumulated light color
9620
+ * - Draw the world at full brightness — the lightmap does the darkening
9621
+ * - Any EngineObject may override renderLight() to additively contribute to the
9622
+ * lightmap (e.g. emissive lava tiles, weapon flashes, glowing crystals)
9623
+ * - Must be constructed BEFORE PostProcessPlugin so post-process sees lit pixels
9624
+ * @namespace LightSystem
9625
+ */
9626
+
9627
+ ///////////////////////////////////////////////////////////////////////////////
9628
+
9629
+ /** Global Light System plugin object
9630
+ * @type {LightSystemPlugin}
9631
+ * @memberof LightSystem */
9632
+ let lightSystem;
9633
+
9634
+ ///////////////////////////////////////////////////////////////////////////////
9635
+
9636
+ /**
9637
+ * LightSystemPlugin
9638
+ * - Owns the offscreen lightmap texture, falloff/composite shaders, and the
9639
+ * per-frame render pass that multiplies the lightmap onto the WebGL scene
9640
+ * - The composite is MULTIPLICATIVE: unlit areas get the ambient color, lit
9641
+ * areas show the scene tinted by the accumulated light color. So you should
9642
+ * draw your world at full brightness — the lightmap handles the darkening.
9643
+ * @memberof LightSystem
9644
+ */
9645
+ class LightSystemPlugin
9646
+ {
9647
+ /** Create the global light system plugin.
9648
+ * @param {Vector2} [textureSize] - Size of the lightmap texture (defaults to mainCanvasSize)
9649
+ * @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.
9650
+ * @example
9651
+ * // simplest usage
9652
+ * new LightSystemPlugin();
9653
+ */
9654
+ constructor(textureSize, ambientColor)
9655
+ {
9656
+ ASSERT(!lightSystem, 'LightSystemPlugin already initialized');
9657
+ ASSERT(!postProcess, 'LightSystemPlugin must be created before PostProcessPlugin');
9658
+ lightSystem = this;
9659
+
9660
+ /** @property {boolean} - When false, the render pass is skipped entirely */
9661
+ this.enabled = true;
9662
+ /** @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. */
9663
+ this.ambientColor = (ambientColor || BLACK).copy();
9664
+ /** @property {Vector2} - Size of the lightmap texture (set at construction; falls back to mainCanvasSize at init time) */
9665
+ this.textureSize = textureSize ? textureSize.copy() : undefined;
9666
+
9667
+ /** @property {WebGLTexture} - The lightmap texture */
9668
+ this.texture = undefined;
9669
+ /** @property {WebGLProgram} - Shader for drawing per-Light falloff blobs into the lightmap */
9670
+ this.lightShader = undefined;
9671
+ /** @property {WebGLProgram} - Shader for compositing the lightmap over the main scene */
9672
+ this.compositeShader = undefined;
9673
+ /** @property {WebGLVertexArrayObject} - Vertex array object for the light shader */
9674
+ this.lightVAO = undefined;
9675
+ /** @property {WebGLVertexArrayObject} - Vertex array object for the composite shader */
9676
+ this.compositeVAO = undefined;
9677
+
9678
+ initLightSystem();
9679
+ engineAddPlugin(undefined, lightSystemRender,
9680
+ lightSystemContextLost, lightSystemContextRestored);
9681
+
9682
+ function initLightSystem()
9683
+ {
9684
+ if (headlessMode) return;
9685
+ if (!glEnable)
9686
+ {
9687
+ console.warn('LightSystemPlugin: WebGL not enabled!');
9688
+ return;
9689
+ }
9690
+
9691
+ // resolve texture size default at init time (mainCanvasSize may
9692
+ // not be set yet at the moment the constructor first ran)
9693
+ if (!lightSystem.textureSize)
9694
+ lightSystem.textureSize = mainCanvasSize.copy();
9695
+
9696
+ // allocate the lightmap texture with null data at textureSize
9697
+ lightSystem.texture = glContext.createTexture();
9698
+ glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
9699
+ glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA,
9700
+ lightSystem.textureSize.x, lightSystem.textureSize.y, 0,
9701
+ glContext.RGBA, glContext.UNSIGNED_BYTE, null);
9702
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, glContext.LINEAR);
9703
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, glContext.LINEAR);
9704
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, glContext.CLAMP_TO_EDGE);
9705
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, glContext.CLAMP_TO_EDGE);
9706
+
9707
+ // light falloff shader: one quad per Light, fragment computes radial falloff
9708
+ lightSystem.lightShader = glCreateProgram(
9709
+ '#version 300 es\n' +
9710
+ 'precision highp float;'+
9711
+ 'uniform mat4 m;'+
9712
+ 'uniform vec2 lightPos;'+
9713
+ 'uniform float radius;'+
9714
+ 'in vec2 g;'+ // unit quad geometry [0..1]
9715
+ 'out vec2 vWorldPos;'+
9716
+ 'void main(){'+
9717
+ 'vec2 worldP=lightPos+(g-.5)*2.*radius;'+
9718
+ 'gl_Position=m*vec4(worldP,1,1);'+
9719
+ 'vWorldPos=worldP;'+
9720
+ '}'
9721
+ ,
9722
+ '#version 300 es\n' +
9723
+ 'precision highp float;'+
9724
+ 'uniform vec2 lightPos;'+
9725
+ 'uniform float radius;'+
9726
+ 'uniform float fadeRange;'+
9727
+ 'uniform vec4 color;'+
9728
+ 'in vec2 vWorldPos;'+
9729
+ 'out vec4 c;'+
9730
+ 'void main(){'+
9731
+ 'float dist=distance(vWorldPos,lightPos);'+
9732
+ 'float t=clamp((radius-dist)/max(fadeRange,1e-6),0.,1.);'+
9733
+ 'c=vec4(color.rgb*t*color.a,1.);'+
9734
+ '}'
9735
+ );
9736
+
9737
+ // composite shader: fullscreen quad, samples the lightmap
9738
+ lightSystem.compositeShader = glCreateProgram(
9739
+ '#version 300 es\n' +
9740
+ 'precision highp float;'+
9741
+ 'in vec2 p;'+
9742
+ 'void main(){'+
9743
+ 'gl_Position=vec4(p+p-1.,1,1);'+
9744
+ '}'
9745
+ ,
9746
+ '#version 300 es\n' +
9747
+ 'precision highp float;'+
9748
+ 'uniform sampler2D s;'+
9749
+ 'uniform vec3 iResolution;'+
9750
+ 'out vec4 c;'+
9751
+ 'void main(){'+
9752
+ 'vec2 uv=gl_FragCoord.xy/iResolution.xy;'+
9753
+ 'c=vec4(texture(s,uv).rgb,1.);'+
9754
+ '}'
9755
+ );
9756
+
9757
+ // VAO for the per-Light quad — reuses the engine unit triangle-strip
9758
+ lightSystem.lightVAO = glContext.createVertexArray();
9759
+ glContext.bindVertexArray(lightSystem.lightVAO);
9760
+ glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer);
9761
+ const gLight = glContext.getAttribLocation(lightSystem.lightShader, 'g');
9762
+ glContext.enableVertexAttribArray(gLight);
9763
+ glContext.vertexAttribPointer(gLight, 2, glContext.FLOAT, false, 8, 0);
9764
+
9765
+ // VAO for the composite fullscreen quad — same buffer, attribute named 'p'
9766
+ lightSystem.compositeVAO = glContext.createVertexArray();
9767
+ glContext.bindVertexArray(lightSystem.compositeVAO);
9768
+ glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer);
9769
+ const pComp = glContext.getAttribLocation(lightSystem.compositeShader, 'p');
9770
+ glContext.enableVertexAttribArray(pComp);
9771
+ glContext.vertexAttribPointer(pComp, 2, glContext.FLOAT, false, 8, 0);
9772
+ }
9773
+ function lightSystemRender()
9774
+ {
9775
+ if (headlessMode || !glEnable) return;
9776
+ if (!lightSystem.enabled) return;
9777
+ if (!lightSystem.texture) return; // init failed or context lost
9778
+
9779
+ // 1. flush any in-flight sprite batch from earlier render passes
9780
+ glFlush();
9781
+ const prevAdditive = glAdditive;
9782
+
9783
+ // 2. bind lightmap as render target, clear to ambientColor
9784
+ const ac = lightSystem.ambientColor;
9785
+ glContext.bindFramebuffer(glContext.FRAMEBUFFER, glFramebuffer);
9786
+ glContext.framebufferTexture2D(glContext.FRAMEBUFFER,
9787
+ glContext.COLOR_ATTACHMENT0, glContext.TEXTURE_2D, lightSystem.texture, 0);
9788
+ glContext.viewport(0, 0, lightSystem.textureSize.x, lightSystem.textureSize.y);
9789
+ glContext.clearColor(ac.r, ac.g, ac.b, ac.a);
9790
+ glContext.clear(glContext.COLOR_BUFFER_BIT);
9791
+
9792
+ // 3. walk engineObjects calling renderLight() — additive blend
9793
+ // (lightmap accumulates raw additive color contributions)
9794
+ setBlendMode(true);
9795
+ glContext.enable(glContext.BLEND);
9796
+ glContext.blendFunc(glContext.ONE, glContext.ONE);
9797
+
9798
+ for (const o of engineObjects)
9799
+ o.destroyed || o.renderLight();
9800
+
9801
+ // 4. drain any sprite-batched draws (e.g. drawTile inside a
9802
+ // custom renderLight override) so they hit the FBO, not the
9803
+ // canvas after we unbind
9804
+ glFlush();
9805
+ glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
9806
+ glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
9807
+
9808
+ // 5. composite: fullscreen quad, multiplicative blend onto glCanvas
9809
+ // (scene * lightmap — unlit areas go to black, lit areas are
9810
+ // the scene tinted by the accumulated light color)
9811
+ glContext.useProgram(lightSystem.compositeShader);
9812
+ glContext.bindVertexArray(lightSystem.compositeVAO);
9813
+ glContext.activeTexture(glContext.TEXTURE0);
9814
+ glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
9815
+ const cs = lightSystem.compositeShader;
9816
+ glContext.uniform1i(glContext.getUniformLocation(cs, 's'), 0);
9817
+ glContext.uniform3f(glContext.getUniformLocation(cs, 'iResolution'),
9818
+ mainCanvas.width, mainCanvas.height, 1);
9819
+ glContext.blendFunc(glContext.DST_COLOR, glContext.ZERO);
9820
+ glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
9821
+
9822
+ // 6. restore engine state so subsequent draws use the engine's
9823
+ // tracked texture binding (otherwise glSetTexture would think
9824
+ // the prior texture was still bound when actually the lightmap
9825
+ // is, and any debug text / future draw could sample the lightmap)
9826
+ if (glActiveTexture)
9827
+ glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
9828
+ setBlendMode(prevAdditive);
9829
+ glSetInstancedMode(true);
9830
+ }
9831
+ function lightSystemContextLost()
9832
+ {
9833
+ lightSystem.texture = undefined;
9834
+ lightSystem.lightShader = undefined;
9835
+ lightSystem.compositeShader = undefined;
9836
+ lightSystem.lightVAO = undefined;
9837
+ lightSystem.compositeVAO = undefined;
9838
+ LOG('LightSystemPlugin: WebGL context lost');
9839
+ }
9840
+ function lightSystemContextRestored()
9841
+ {
9842
+ initLightSystem();
9843
+ LOG('LightSystemPlugin: WebGL context restored');
9844
+ }
9845
+ }
9846
+
9847
+ /** Draw a single Light's falloff blob into the currently bound lightmap.
9848
+ * Called by Light.renderLight() during the plugin's render pass.
9849
+ * @param {Light} light */
9850
+ drawLight(light)
9851
+ {
9852
+ if (headlessMode || !glEnable || !this.lightShader) return;
9853
+
9854
+ // drain any sprite-batched draws queued by a previous custom
9855
+ // renderLight() override (e.g. drawRect inside a LavaTile). They were
9856
+ // queued in the engine's instanced-vertex format and must flush with
9857
+ // the engine's shader+VAO bound — NOT this plugin's light shader.
9858
+ glFlush();
9859
+
9860
+ glContext.useProgram(this.lightShader);
9861
+ glContext.bindVertexArray(this.lightVAO);
9862
+
9863
+ // re-apply the engine camera transform onto this shader. Divide by
9864
+ // mainCanvasSize (not textureSize) so world→NDC matches the main
9865
+ // pass; the viewport handles the lightmap's actual resolution.
9866
+ // No y-flip here: the composite samples this FBO with
9867
+ // gl_FragCoord/iResolution (origin bottom-left), so storing world
9868
+ // +Y at the top of the texture lines up with the canvas convention.
9869
+ const s = vec2(2*cameraScale).divide(mainCanvasSize);
9870
+ const rotatedCam = cameraPos.rotate(-cameraAngle);
9871
+ const p = vec2(-1).subtract(rotatedCam.multiply(s));
9872
+ const ca = cos(cameraAngle);
9873
+ const sa = sin(cameraAngle);
9874
+ const transform = [
9875
+ s.x * ca, s.y * sa, 0, 0,
9876
+ -s.x * sa, s.y * ca, 0, 0,
9877
+ 1, 1, 1, 0,
9878
+ p.x, p.y, 0, 1];
9879
+
9880
+ const ls = this.lightShader;
9881
+ glContext.uniformMatrix4fv(glContext.getUniformLocation(ls, 'm'), false, transform);
9882
+ glContext.uniform2f(glContext.getUniformLocation(ls, 'lightPos'), light.pos.x, light.pos.y);
9883
+ glContext.uniform1f(glContext.getUniformLocation(ls, 'radius'), light.radius);
9884
+ glContext.uniform1f(glContext.getUniformLocation(ls, 'fadeRange'), light.fadeRange);
9885
+ const c = light.color;
9886
+ glContext.uniform4f(glContext.getUniformLocation(ls, 'color'), c.r, c.g, c.b, c.a);
9887
+
9888
+ glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
9889
+
9890
+ // restore engine's instanced shader+VAO so subsequent renderLight()
9891
+ // overrides that batch through drawRect/drawTile work correctly
9892
+ glSetInstancedMode(true);
9893
+ }
9894
+ }
9895
+
9896
+ ///////////////////////////////////////////////////////////////////////////////
9897
+
9898
+ /**
9899
+ * A Light is an EngineObject that contributes a soft additive blob of color
9900
+ * to the LightSystem plugin's lightmap.
9901
+ * @extends EngineObject
9902
+ * @memberof LightSystem
9903
+ * @example
9904
+ * new Light(vec2(5, 5), 4, rgb(1, 0.5, 0)); // orange light, full soft blob
9905
+ * new Light(vec2(0, 0), 8, rgb(1, 1, 1), 2); // white core with 2-unit soft halo
9906
+ */
9907
+ class Light extends EngineObject
9908
+ {
9909
+ /** Create a light object and add it to the engine object list
9910
+ * @param {Vector2} pos - World space position
9911
+ * @param {number} radius - Total extent of the light in world units
9912
+ * @param {Color} [color] - Color of the light; alpha modulates intensity
9913
+ * @param {number} [fadeRange] - Width of the soft edge in world units (defaults to radius) */
9914
+ constructor(pos, radius, color, fadeRange)
9915
+ {
9916
+ super(pos, vec2(1), undefined, 0, color);
9917
+ ASSERT(isNumber(radius) && radius >= 0, 'Light radius must be a non-negative number');
9918
+ ASSERT(fadeRange === undefined || (isNumber(fadeRange) && fadeRange >= 0),
9919
+ 'Light fadeRange must be a non-negative number when provided');
9920
+
9921
+ /** @property {number} - Total extent of the light in world units */
9922
+ this.radius = radius;
9923
+ /** @property {number} - Width of the soft edge in world units */
9924
+ this.fadeRange = fadeRange === undefined ? radius : fadeRange;
9925
+ }
9926
+
9927
+ /** Lights are invisible in the main render pass — they only contribute
9928
+ * to the lightmap via renderLight(). */
9929
+ render() {}
9930
+
9931
+ /** Draw this light's falloff blob into the lightmap.
9932
+ * Called by LightSystemPlugin during its render pass. No-op when the
9933
+ * plugin or WebGL is unavailable. */
9934
+ renderLight()
9935
+ {
9936
+ lightSystem && lightSystem.drawLight(this);
9937
+ }
9938
+ }
9939
+
9004
9940
  /**
9005
9941
  * LittleJS ZzFXM Plugin
9006
9942
  * @namespace ZzFXM
@@ -9622,11 +10558,17 @@ class UISystemPlugin
9622
10558
  * @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */
9623
10559
  setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver)
9624
10560
  {
9625
- function setCallback(callback, listenerType)
10561
+ // remove any prior listeners so repeated setup calls don't stack
10562
+ if (this._dragListeners)
10563
+ for (const [type, listener] of this._dragListeners)
10564
+ document.removeEventListener(type, listener);
10565
+ this._dragListeners = [];
10566
+ const setCallback = (callback, listenerType)=>
9626
10567
  {
9627
- function listener(e) { e.preventDefault(); callback && callback(e); }
10568
+ const listener = (e)=> { e.preventDefault(); callback && callback(e); };
9628
10569
  document.addEventListener(listenerType, listener);
9629
- }
10570
+ this._dragListeners.push([listenerType, listener]);
10571
+ };
9630
10572
  setCallback(onDrop, 'drop');
9631
10573
  setCallback(onDragEnter, 'dragenter');
9632
10574
  setCallback(onDragLeave, 'dragleave');
@@ -9950,6 +10892,15 @@ class UIObject
9950
10892
  if (this.destroyed)
9951
10893
  return;
9952
10894
 
10895
+ // clear ui-system references that point at this object so events
10896
+ // don't keep firing against a destroyed target (especially the
10897
+ // keydown listener attached for keyInputObject)
10898
+ if (uiSystem.activeObject === this) uiSystem.activeObject = undefined;
10899
+ if (uiSystem.hoverObject === this) uiSystem.hoverObject = undefined;
10900
+ if (uiSystem.lastHoverObject === this) uiSystem.lastHoverObject = undefined;
10901
+ if (uiSystem.navigationObject === this) uiSystem.navigationObject = undefined;
10902
+ if (uiSystem.keyInputObject === this) uiSystem.keyInputObject = undefined;
10903
+
9953
10904
  // disconnect from parent and destroy children
9954
10905
  this.destroyed = 1;
9955
10906
  this.parent?.removeChild(this);
@@ -9958,6 +10909,8 @@ class UIObject
9958
10909
  child.parent = undefined;
9959
10910
  child.destroy();
9960
10911
  }
10912
+ // clear references so destroyed children can be GC'd
10913
+ this.children.length = 0;
9961
10914
  }
9962
10915
 
9963
10916
  /** Check if the mouse is overlapping this ui object
@@ -10547,6 +11500,7 @@ class UISlider extends UIObject
10547
11500
  {
10548
11501
  // toggle value between 0 and 1
10549
11502
  this.value = this.value ? 0 : 1;
11503
+ this.onChange();
10550
11504
  this.onRelease();
10551
11505
  super.navigatePressed();
10552
11506
  }
@@ -10906,6 +11860,11 @@ class Box2dObject extends EngineObject
10906
11860
  // destroy physics body, fixtures, and joints
10907
11861
  ASSERT(this.body, 'Box2dObject has no body to destroy');
10908
11862
  box2d.world.DestroyBody(this.body);
11863
+
11864
+ // remove from tracked list so paused / headless sessions don't leak
11865
+ const i = box2d.objects.indexOf(this);
11866
+ if (i >= 0)
11867
+ box2d.objects.splice(i, 1);
10909
11868
  super.destroy();
10910
11869
  }
10911
11870
 
@@ -10994,7 +11953,9 @@ class Box2dObject extends EngineObject
10994
11953
  /** Add a box shape to the body
10995
11954
  * @param {Vector2} [size]
10996
11955
  * @param {Vector2} [offset]
10997
- * @param {number} [angle]
11956
+ * @param {number} [angle] - LittleJS convention (clockwise positive).
11957
+ * Negated internally to match Box2D's CCW-positive convention so the
11958
+ * fixture aligns with the same angle passed to drawRect/drawTile.
10998
11959
  * @param {number} [density]
10999
11960
  * @param {number} [friction]
11000
11961
  * @param {number} [restitution]
@@ -11007,7 +11968,7 @@ class Box2dObject extends EngineObject
11007
11968
  ASSERT(isNumber(angle), 'angle must be a number');
11008
11969
 
11009
11970
  const shape = new box2d.instance.b2PolygonShape();
11010
- shape.SetAsBox(size.x/2, size.y/2, box2d.vec2dTo(offset), angle);
11971
+ shape.SetAsBox(size.x/2, size.y/2, box2d.vec2dTo(offset), -angle);
11011
11972
  return this.addShape(shape, density, friction, restitution, isSensor);
11012
11973
  }
11013
11974
 
@@ -11023,23 +11984,19 @@ class Box2dObject extends EngineObject
11023
11984
 
11024
11985
  function box2dCreatePolygonShape(points)
11025
11986
  {
11026
- function box2dCreatePointList(points)
11987
+ ASSERT(3 <= points.length && points.length <= 8);
11988
+ const buffer = box2d.instance._malloc(points.length * 8);
11989
+ for (let i=0, offset=0; i<points.length; ++i)
11027
11990
  {
11028
- const buffer = box2d.instance._malloc(points.length * 8);
11029
- for (let i=0, offset=0; i<points.length; ++i)
11030
- {
11031
- box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].x;
11032
- offset += 4;
11033
- box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].y;
11034
- offset += 4;
11035
- }
11036
- return box2d.instance.wrapPointer(buffer, box2d.instance.b2Vec2);
11991
+ box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].x;
11992
+ offset += 4;
11993
+ box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].y;
11994
+ offset += 4;
11037
11995
  }
11038
-
11039
- ASSERT(3 <= points.length && points.length <= 8);
11996
+ const box2dPoints = box2d.instance.wrapPointer(buffer, box2d.instance.b2Vec2);
11040
11997
  const shape = new box2d.instance.b2PolygonShape();
11041
- const box2dPoints = box2dCreatePointList(points);
11042
11998
  shape.Set(box2dPoints, points.length);
11999
+ box2d.instance._free(buffer);
11043
12000
  return shape;
11044
12001
  }
11045
12002
 
@@ -11309,9 +12266,10 @@ class Box2dObject extends EngineObject
11309
12266
  {
11310
12267
  const data = new box2d.instance.b2MassData();
11311
12268
  this.body.GetMassData(data);
11312
- localCenter && data.set_center(box2d.vec2dTo(localCenter));
11313
- mass && data.set_mass(mass);
11314
- momentOfInertia && data.set_I(momentOfInertia);
12269
+ // use !== undefined so setMass(0) (static-equivalent) isn't silently ignored
12270
+ if (localCenter !== undefined) data.set_center(box2d.vec2dTo(localCenter));
12271
+ if (mass !== undefined) data.set_mass(mass);
12272
+ if (momentOfInertia !== undefined) data.set_I(momentOfInertia);
11315
12273
  this.body.SetMassData(data);
11316
12274
  }
11317
12275
 
@@ -12483,6 +13441,8 @@ class Box2dPlugin
12483
13441
  const fixtureB = contact.GetFixtureB();
12484
13442
  const objectA = fixtureA.GetBody().object;
12485
13443
  const objectB = fixtureB.GetBody().object;
13444
+ // raw user-created b2Bodies may have no .object — skip those
13445
+ if (!objectA || !objectB) return;
12486
13446
  objectA.beginContact(objectB);
12487
13447
  objectB.beginContact(objectA);
12488
13448
  }
@@ -12493,6 +13453,7 @@ class Box2dPlugin
12493
13453
  const fixtureB = contact.GetFixtureB();
12494
13454
  const objectA = fixtureA.GetBody().object;
12495
13455
  const objectB = fixtureB.GetBody().object;
13456
+ if (!objectA || !objectB) return;
12496
13457
  objectA.endContact(objectB);
12497
13458
  objectB.endContact(objectA);
12498
13459
  };
@@ -12871,7 +13832,7 @@ async function box2dInit()
12871
13832
  debugDraw.DrawTransform = function(transform)
12872
13833
  {
12873
13834
  transform = box2d.instance.wrapPointer(transform, box2d.instance.b2Transform);
12874
- const pos = vec2(transform.get_p());
13835
+ const pos = box2d.vec2From(transform.get_p());
12875
13836
  const angle = -transform.get_q().GetAngle();
12876
13837
  const p1 = vec2(1,0), c1 = rgb(.75,0,0,.8);
12877
13838
  const p2 = vec2(0,1), c2 = rgb(0,.75,0,.8);
@@ -13016,6 +13977,69 @@ function drawThreeSlice(pos, size, startTile, color, borderSize=1, additiveColor
13016
13977
  const cornerPos = cornerOffset.multiply(vec2(flipX?-1:1, flipY?-flip:flip));
13017
13978
  drawTile(pos.add(cornerPos.rotate(rotateAngle)), cornerSize, cornerTile, color, a, false, additiveColor, useWebGL, screenSpace, context);
13018
13979
  }
13980
+ }
13981
+
13982
+ /** Draw a crescent / moon-phase shape built from a polygon
13983
+ * Routes through drawPoly, so it supports WebGL, screen space, color, and outlines
13984
+ * @param {Vector2} pos - Center position
13985
+ * @param {number} [size] - Diameter
13986
+ * @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
13987
+ * @param {Color} [color] - Fill color
13988
+ * @param {number} [angle] - Angle to rotate by
13989
+ * @param {boolean} [invert] - Flip which side is illuminated
13990
+ * @param {number} [lineWidth] - Outline width, 0 for no outline
13991
+ * @param {Color} [lineColor] - Outline color
13992
+ * @param {boolean} [useWebGL=glEnable] - Use WebGL for rendering
13993
+ * @param {boolean} [screenSpace] - Use screen space coordinates
13994
+ * @param {CanvasRenderingContext2D} [context] - Canvas context to use
13995
+ * @memberof DrawUtilities */
13996
+ function drawCrescent(pos, size=1, percent=0, color=WHITE, angle=0, invert=false, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
13997
+ {
13998
+ const points = getCrescentPoints(pos, size, percent, angle, invert);
13999
+ drawPoly(points, color, lineWidth, lineColor, vec2(), 0, useWebGL, screenSpace, context);
14000
+ }
14001
+
14002
+ /** Get the list of points that make up a crescent / moon-phase shape
14003
+ * Returns world-space points with pos and angle baked in, ready for drawPoly or other use
14004
+ * @param {Vector2} pos - Center position
14005
+ * @param {number} [size] - Diameter
14006
+ * @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
14007
+ * @param {number} [angle] - Angle to rotate by
14008
+ * @param {boolean} [invert] - Flip which side is illuminated
14009
+ * @param {number} [sides=glCircleSides] - Number of sides for a full circle (halved per arc)
14010
+ * @return {Array<Vector2>} - List of points making up the crescent
14011
+ * @memberof DrawUtilities */
14012
+ function getCrescentPoints(pos, size=1, percent=0, angle=0, invert=false, sides=glCircleSides)
14013
+ {
14014
+ ASSERT(isVector2(pos), 'pos must be a vec2');
14015
+ ASSERT(isNumber(size) && isNumber(percent), 'size and percent must be numbers');
14016
+
14017
+ // map phase to a signed terminator curve: -1 new, 0 half, 1 full
14018
+ let p = mod(percent*4, 4); // quarter phase 0..4
14019
+ if (p >= 2) // second half of cycle flips orientation
14020
+ angle += PI;
14021
+ p = p <= 2 ? p-1 : 3-p;
14022
+ if (invert) // flip the illuminated side
14023
+ {
14024
+ p = -p;
14025
+ angle += PI;
14026
+ }
14027
+
14028
+ // build the crescent: outer semicircle, then inner half-ellipse traced back
14029
+ const points = [];
14030
+ const segs = max(3, sides>>1);
14031
+ const radius = size/2;
14032
+ for (let i=0; i<=segs; i++)
14033
+ {
14034
+ const t = i/segs*PI;
14035
+ points.push(vec2(radius*cos(t), radius*sin(t)).rotate(angle).add(pos));
14036
+ }
14037
+ for (let i=segs; i>=0; i--)
14038
+ {
14039
+ const t = i/segs*PI;
14040
+ points.push(vec2(radius*cos(t), -radius*p*sin(t)).rotate(angle).add(pos));
14041
+ }
14042
+ return points;
13019
14043
  }
13020
14044
  /**
13021
14045
  * LittleJS Tween System Plugin
@@ -13275,7 +14299,7 @@ const Ease =
13275
14299
  * @param {number} x
13276
14300
  * @returns {number}
13277
14301
  * @memberof TweenSystem */
13278
- EXPO: (x) => 2 ** (10 * x - 10),
14302
+ EXPO: (x) => x === 0 ? 0 : 2 ** (10 * x - 10),
13279
14303
 
13280
14304
  /** Back ease-in: overshoots backward at the start before snapping forward.
13281
14305
  * @param {number} x
@@ -13288,6 +14312,8 @@ const Ease =
13288
14312
  * @returns {number}
13289
14313
  * @memberof TweenSystem */
13290
14314
  ELASTIC: (x) =>
14315
+ x === 0 ? 0 :
14316
+ x === 1 ? 1 :
13291
14317
  -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
13292
14318
 
13293
14319
  /** Spring-like ease-out: oscillates outward after passing the target.
@@ -13448,29 +14474,32 @@ function tweenProperty(target, propertyPath, start, end, duration = 1, options =
13448
14474
  }
13449
14475
 
13450
14476
  // Continuation that schedules the next loop iteration when one finishes.
13451
- // Called from the completed tween's `then` slot. Decrements the counter and
13452
- // only spawns a new tween if more iterations remain.
13453
- function loopContinuation(prev)
13454
- {
13455
- if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
13456
- const next = new Tween(prev.callback, prev.start, prev.end, prev.duration,
13457
- { ease: prev.ease, useRealTime: prev.useRealTime });
13458
- next.loopRemaining = prev.loopRemaining === Infinity
13459
- ? Infinity
13460
- : prev.loopRemaining - 1;
13461
- next.thenCallback = () => loopContinuation(next);
13462
- }
13463
-
13464
- // Continuation for pingPong: spawns a new tween with start and end swapped.
13465
- function pingPongContinuation(prev)
13466
- {
13467
- if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
13468
- const next = new Tween(prev.callback, prev.end, prev.start, prev.duration,
13469
- { ease: prev.ease, useRealTime: prev.useRealTime });
13470
- next.loopRemaining = prev.loopRemaining === Infinity
13471
- ? Infinity
13472
- : prev.loopRemaining - 1;
13473
- next.thenCallback = () => pingPongContinuation(next);
14477
+ // Reuses the same Tween object across iterations so the user's handle
14478
+ // from `.loop()` keeps working — calling `.stop()` mid-loop now cancels
14479
+ // the entire chain instead of just the current iteration.
14480
+ function loopContinuation(tween)
14481
+ {
14482
+ if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
14483
+ if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
14484
+ tween.life = tween.duration;
14485
+ tween.thenCallback = () => loopContinuation(tween);
14486
+ tweenActive.push(tween);
14487
+ // snap to start for the new iteration (matches Tween constructor behavior)
14488
+ tween.callback(tween.interp(tween.duration));
14489
+ }
14490
+
14491
+ // Continuation for pingPong: swaps start and end on the same tween each iteration.
14492
+ function pingPongContinuation(tween)
14493
+ {
14494
+ if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
14495
+ if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
14496
+ const tmp = tween.start;
14497
+ tween.start = tween.end;
14498
+ tween.end = tmp;
14499
+ tween.life = tween.duration;
14500
+ tween.thenCallback = () => pingPongContinuation(tween);
14501
+ tweenActive.push(tween);
14502
+ tween.callback(tween.interp(tween.duration));
13474
14503
  }
13475
14504
 
13476
14505
  /** Engine plugin hook: advance every active tween by the appropriate delta.
@@ -13804,11 +14833,13 @@ class PathFinder
13804
14833
  if (dx !== 0 && dy !== 0)
13805
14834
  {
13806
14835
  // Diagonal step: refuse if either cardinal neighbor is
13807
- // blocked or has cost. Prevents cutting through corners.
14836
+ // blocked. Prevents cutting through walls at corners.
14837
+ // (Costed-but-walkable cardinals do not block — diagonal
14838
+ // movement around expensive terrain is standard A*.)
13808
14839
  const card1 = this.getNode(current.pos.x + dx, current.pos.y);
13809
- if (!card1 || card1.cost > 0 || !card1.walkable) continue;
14840
+ if (!card1 || !card1.walkable) continue;
13810
14841
  const card2 = this.getNode(current.pos.x, current.pos.y + dy);
13811
- if (!card2 || card2.cost > 0 || !card2.walkable) continue;
14842
+ if (!card2 || !card2.walkable) continue;
13812
14843
  stepCost = PATHFINDER_DIAGONAL_COST;
13813
14844
  }
13814
14845