littlejsengine 1.18.4 → 1.18.7

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.
Files changed (177) hide show
  1. package/COPYRIGHT.txt +38 -0
  2. package/FAQ.md +633 -0
  3. package/README.md +19 -1
  4. package/dist/littlejs.d.ts +349 -54
  5. package/dist/littlejs.esm.js +1469 -448
  6. package/dist/littlejs.esm.min.js +1 -1
  7. package/dist/littlejs.js +1438 -436
  8. package/dist/littlejs.min.js +1 -1
  9. package/dist/littlejs.release.js +1438 -436
  10. package/package.json +8 -1
  11. package/plugins/box2d.js +32 -7
  12. package/{src/engineMedals.js → plugins/medalSystem.js} +248 -196
  13. package/plugins/pathFinder.js +758 -0
  14. package/plugins/pluginExport.js +19 -0
  15. package/plugins/tween.js +1 -1
  16. package/plugins/tweenSystem.js +509 -0
  17. package/plugins/uiSystem.js +150 -11
  18. package/src/engine.js +2 -141
  19. package/src/engineAudio.js +8 -5
  20. package/src/engineBuild.mjs +4 -2
  21. package/src/engineDebug.js +1 -1
  22. package/src/engineDraw.js +21 -3
  23. package/src/engineExport.js +12 -12
  24. package/src/engineInput.js +1 -1
  25. package/src/engineLogo.js +146 -0
  26. package/src/engineMath.js +10 -3
  27. package/src/engineObject.js +11 -5
  28. package/src/engineParticles.js +6 -3
  29. package/src/engineRelease.js +1 -1
  30. package/src/engineSettings.js +0 -47
  31. package/src/engineUtilities.js +7 -2
  32. package/src/engineWebGL.js +34 -7
  33. package/src/jsconfig.json +3 -1
  34. package/.github/workflows/test.yml +0 -17
  35. package/AI.md +0 -173
  36. package/CLAUDE.md +0 -1
  37. package/examples/box2d/game.js +0 -192
  38. package/examples/box2d/gameObjects.js +0 -564
  39. package/examples/box2d/index.html +0 -9
  40. package/examples/box2d/scenes.js +0 -194
  41. package/examples/box2d/tiles.png +0 -0
  42. package/examples/breakout/game.js +0 -175
  43. package/examples/breakout/gameObjects.js +0 -147
  44. package/examples/breakout/index.html +0 -8
  45. package/examples/breakout/tiles.png +0 -0
  46. package/examples/breakoutTutorial/README.md +0 -521
  47. package/examples/breakoutTutorial/game.js +0 -191
  48. package/examples/breakoutTutorial/images/1.png +0 -0
  49. package/examples/breakoutTutorial/images/10.png +0 -0
  50. package/examples/breakoutTutorial/images/11.png +0 -0
  51. package/examples/breakoutTutorial/images/2.png +0 -0
  52. package/examples/breakoutTutorial/images/3.png +0 -0
  53. package/examples/breakoutTutorial/images/4.png +0 -0
  54. package/examples/breakoutTutorial/images/5.png +0 -0
  55. package/examples/breakoutTutorial/images/6.png +0 -0
  56. package/examples/breakoutTutorial/images/7.png +0 -0
  57. package/examples/breakoutTutorial/images/8.png +0 -0
  58. package/examples/breakoutTutorial/images/9.png +0 -0
  59. package/examples/breakoutTutorial/index.html +0 -8
  60. package/examples/electron/build.mjs +0 -127
  61. package/examples/electron/electron.js +0 -35
  62. package/examples/electron/game.js +0 -54
  63. package/examples/electron/index.html +0 -13
  64. package/examples/electron/package.json +0 -5
  65. package/examples/electron/tiles.png +0 -0
  66. package/examples/empty/game.js +0 -51
  67. package/examples/empty/index.html +0 -5
  68. package/examples/empty/tiles.png +0 -0
  69. package/examples/favicon.png +0 -0
  70. package/examples/games.jpg +0 -0
  71. package/examples/htmlMenu/game.js +0 -82
  72. package/examples/htmlMenu/index.html +0 -45
  73. package/examples/htmlMenu/tiles.png +0 -0
  74. package/examples/index.html +0 -62
  75. package/examples/logo.png +0 -0
  76. package/examples/logo2.png +0 -0
  77. package/examples/module/build.mjs +0 -124
  78. package/examples/module/game.js +0 -132
  79. package/examples/module/index.html +0 -10
  80. package/examples/module/tiles.png +0 -0
  81. package/examples/particles/index.html +0 -426
  82. package/examples/particles/tiles.png +0 -0
  83. package/examples/platformer/data/gameLevelData.tmx +0 -143
  84. package/examples/platformer/data/gameLevelData.tsx +0 -4
  85. package/examples/platformer/game.js +0 -147
  86. package/examples/platformer/gameCharacter.js +0 -309
  87. package/examples/platformer/gameEffects.js +0 -278
  88. package/examples/platformer/gameLevel.js +0 -205
  89. package/examples/platformer/gameLevelData.json +0 -171
  90. package/examples/platformer/gameObjects.js +0 -378
  91. package/examples/platformer/gamePlayer.js +0 -35
  92. package/examples/platformer/index.html +0 -9
  93. package/examples/platformer/tiles.png +0 -0
  94. package/examples/platformer/tilesLevel.png +0 -0
  95. package/examples/puzzle/game.js +0 -331
  96. package/examples/puzzle/index.html +0 -8
  97. package/examples/puzzle/tiles.png +0 -0
  98. package/examples/screenshot.jpg +0 -0
  99. package/examples/shorts/animation.js +0 -18
  100. package/examples/shorts/base.html +0 -54
  101. package/examples/shorts/blending.js +0 -14
  102. package/examples/shorts/box2d.js +0 -51
  103. package/examples/shorts/box2dCar.js +0 -59
  104. package/examples/shorts/box2dPool.js +0 -107
  105. package/examples/shorts/box2dTileLayer.js +0 -48
  106. package/examples/shorts/cameraDrag.js +0 -22
  107. package/examples/shorts/clock.js +0 -21
  108. package/examples/shorts/colors.js +0 -25
  109. package/examples/shorts/debugDraw.js +0 -37
  110. package/examples/shorts/empty.js +0 -30
  111. package/examples/shorts/flappyGame.js +0 -55
  112. package/examples/shorts/fontImage.js +0 -17
  113. package/examples/shorts/fps.js +0 -90
  114. package/examples/shorts/helloWorld.js +0 -11
  115. package/examples/shorts/hillGlideGame.js +0 -63
  116. package/examples/shorts/input.js +0 -64
  117. package/examples/shorts/landerGame.js +0 -57
  118. package/examples/shorts/maze.js +0 -48
  119. package/examples/shorts/medals.js +0 -51
  120. package/examples/shorts/music.js +0 -78
  121. package/examples/shorts/musicPlayer.js +0 -137
  122. package/examples/shorts/nineSlice.js +0 -40
  123. package/examples/shorts/parallax.js +0 -72
  124. package/examples/shorts/particles.js +0 -28
  125. package/examples/shorts/piano.js +0 -44
  126. package/examples/shorts/platformer.js +0 -45
  127. package/examples/shorts/pongGame.js +0 -41
  128. package/examples/shorts/postProcess.js +0 -62
  129. package/examples/shorts/sequencer.js +0 -124
  130. package/examples/shorts/shader.js +0 -29
  131. package/examples/shorts/shapes.js +0 -21
  132. package/examples/shorts/slidingPuzzle.js +0 -52
  133. package/examples/shorts/song.mp3 +0 -0
  134. package/examples/shorts/sound.js +0 -36
  135. package/examples/shorts/spaceGame.js +0 -58
  136. package/examples/shorts/spriteAtlas.js +0 -31
  137. package/examples/shorts/starfield.js +0 -15
  138. package/examples/shorts/texture.js +0 -16
  139. package/examples/shorts/textureWrapped.js +0 -12
  140. package/examples/shorts/tileLayer.js +0 -48
  141. package/examples/shorts/tileRaycast.js +0 -39
  142. package/examples/shorts/tiles.png +0 -0
  143. package/examples/shorts/tiltedView.js +0 -63
  144. package/examples/shorts/timers.js +0 -53
  145. package/examples/shorts/topDown.js +0 -38
  146. package/examples/shorts/tween.js +0 -24
  147. package/examples/shorts/uiSystem.js +0 -61
  148. package/examples/shorts/video.webm +0 -0
  149. package/examples/shorts/videoPlayer.js +0 -34
  150. package/examples/shorts.js +0 -743
  151. package/examples/starter/build.bat +0 -7
  152. package/examples/starter/build.mjs +0 -126
  153. package/examples/starter/game.js +0 -137
  154. package/examples/starter/index.html +0 -35
  155. package/examples/starter/tiles.png +0 -0
  156. package/examples/stress/index.html +0 -173
  157. package/examples/style.css +0 -150
  158. package/examples/tweenSystem/game.js +0 -171
  159. package/examples/tweenSystem/index.html +0 -10
  160. package/examples/tweenSystem/tiles.png +0 -0
  161. package/examples/typescript/build.mjs +0 -60
  162. package/examples/typescript/game.js +0 -100
  163. package/examples/typescript/game.ts +0 -132
  164. package/examples/typescript/index.html +0 -10
  165. package/examples/typescript/tiles.png +0 -0
  166. package/examples/typescript/tsconfig.json +0 -17
  167. package/examples/uiSystem/game.js +0 -139
  168. package/examples/uiSystem/index.html +0 -10
  169. package/examples/uiSystem/tiles.png +0 -0
  170. package/jsconfig.json +0 -12
  171. package/plugins/desktop.ini +0 -2
  172. package/reference.md +0 -448
  173. package/test/math.test.mjs +0 -774
  174. package/test/setup.mjs +0 -22
  175. package/test/smoke.test.mjs +0 -274
  176. package/test/tween.test.mjs +0 -576
  177. package/test/util.test.mjs +0 -80
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.18.4';
38
+ const engineVersion = '1.18.7';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -207,7 +207,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
207
207
  const combinedScale = timeScale * debugScale;
208
208
  frameTimeDeltaMS *= combinedScale;
209
209
  frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
210
- if (combinedScale <= 1)
210
+ if (debugScale <= 1)
211
211
  frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp min framerate
212
212
 
213
213
  let wasUpdated = false;
@@ -565,145 +565,6 @@ function engineObjectsRaycast(start, end, objects=engineObjects)
565
565
 
566
566
  debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02);
567
567
  return hitObjects;
568
- }
569
-
570
- ///////////////////////////////////////////////////////////////////////////////
571
- function drawEngineLogo(t)
572
- {
573
- const blackAndWhite = 0;
574
- const showName = 1;
575
-
576
- // LittleJS Logo and Splash Screen
577
- const x = mainContext;
578
- const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
579
- const w = mainCanvas.width = innerWidth * dpr;
580
- const h = mainCanvas.height = innerHeight * dpr;
581
- {
582
- // background
583
- const p3 = percent(t, 1, .8);
584
- const p4 = percent(t, 0, .5);
585
- const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,hypot(w,h)*.6);
586
- g.addColorStop(0,hsl(0,0,lerp(0,p3/2,p4),p3).toString());
587
- g.addColorStop(1,hsl(0,0,0,p3).toString());
588
- x.save();
589
- x.fillStyle = g;
590
- x.fillRect(0,0,w,h);
591
- }
592
- const gradient = (X1,Y1,X2,Y2,C,S=1)=>
593
- {
594
- if (C >= 0)
595
- {
596
- if (blackAndWhite)
597
- x.fillStyle = '#fff';
598
- else
599
- {
600
- const g = x.fillStyle = x.createLinearGradient(X1,Y1,X2,Y2);
601
- g.addColorStop(0,color(C,2));
602
- g.addColorStop(1,color(C,1));
603
- }
604
- }
605
- else
606
- x.fillStyle = '#000';
607
- C >= -1 ? (x.fill(), S && x.stroke()) : x.stroke();
608
- }
609
- const circle = (X,Y,R,A=0,B=2*PI,C,S)=>
610
- {
611
- x.beginPath();
612
- x.arc(X,Y,R,p*A,p*B);
613
- gradient(X,Y-R,X,Y+R,C,S);
614
- }
615
- const rect = (X,Y,W,H,C)=>
616
- {
617
- x.beginPath();
618
- x.rect(X,Y,W,H*p);
619
- gradient(X,Y+H,X+W,Y,C);
620
- }
621
- const poly = (points,C,Y,H)=>
622
- {
623
- x.beginPath();
624
- for (const p of points)
625
- x.lineTo(p.x, p.y);
626
- x.closePath();
627
- gradient(0, Y, 0, Y+H,C);
628
- }
629
- const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
630
-
631
- // center and fit tos screen
632
- const alpha = oscillate(1,1,t);
633
- const p = percent(alpha, .1, .5);
634
- const size = min(6, min(w,h)/99);
635
- x.translate(w/2,h/2);
636
- x.scale(size,size);
637
- x.translate(-40,-35);
638
- p < 1 && x.setLineDash([99*p,99]);
639
- x.lineJoin = x.lineCap = 'round';
640
- x.lineWidth = .1 + p*1.9;
641
- //x.strokeStyle='#fff7';
642
-
643
- if (showName)
644
- {
645
- // engine name text
646
- const Y = 54;
647
- const s = 'LittleJS';
648
- x.font = '900 15.5px arial';
649
- x.lineWidth = .1+p*3.9;
650
- x.textAlign = 'center';
651
- x.textBaseline = 'top';
652
- rect(11,Y+1,59,8*p,-1);
653
- x.beginPath();
654
-
655
- let w2 = 0;
656
- for (let i=0;i<s.length;++i)
657
- w2 += x.measureText(s[i]).width;
658
- for (let j=2;j--;)
659
- for (let i=0,X=40-w2/2;i<s.length;++i)
660
- {
661
- const w = x.measureText(s[i]).width, X2 = X+w/2;
662
- gradient(X2,Y,X2+2,Y+13,i>5?1:0);
663
- x[j?'strokeText':'fillText'](s[i],X2,Y+.5,17*p);
664
- X += w;
665
- }
666
-
667
- x.lineWidth = .1 + p*1.9;
668
- rect(3,Y,73,0); // bottom
669
- }
670
-
671
- rect(7,15,26,-7,0); // cab top
672
- rect(25,15,8,25,-1); // cab front
673
- rect(10,40,15,-25,1); // cab back
674
- rect(14,21,7,9,2); // cab window
675
- rect(38,20,6,-6,2); // little stack
676
-
677
- // big stack
678
- rect(49,20,10,-6,0);
679
- const stackPoints = [vec2(44,8),vec2(64,8),vec2(59,8+6*p),vec2(49,8+6*p)];
680
- poly(stackPoints,2,8,6*p);
681
- rect(44,8,20,-7,0);
682
-
683
- // engine
684
- for (let i=5;i--;) circle(59-i*6*p,30,10,0,2*PI,1,0);
685
- circle(59,30,4,0,7,2); // light
686
-
687
- // engine outline
688
- rect(35,20,24,0); // top
689
- circle(59,30,10); // front
690
- circle(47,30,10,PI/2,PI*3/2); // middle
691
- circle(35,30,10,PI/2,PI*3/2); // back
692
- rect(7,40,13,7,-1); // bottom back
693
- rect(17,40,43,14,-1); // bottom center
694
-
695
- // wheels
696
- for (let i=3;i--;) for (let j=2;j--;) circle(17+15*i,47,j?7:1,0,2*PI,2);
697
-
698
- // cowcatcher
699
- for (let i=2;i--;)
700
- {
701
- let w=6, s=7, o=53+w*p*i
702
- const points = [vec2(o+s,54),vec2(o,40),vec2(o+w*p,40),vec2(o+s+w*p,54)];
703
- poly(points,0,40,14);
704
- }
705
-
706
- x.restore();
707
568
  }
708
569
  /**
709
570
  * LittleJS Debug System
@@ -749,7 +610,7 @@ let debugKey = 'Escape';
749
610
  let debugOverlay = false;
750
611
 
751
612
  // Engine internal variables not exposed to documentation
752
- let debugPrimitives = [], debugPhysics = false, debugRaycast = false, debugParticles = false, debugGamepads = false, debugMedals = false, debugTakeScreenshot;
613
+ let debugPrimitives = [], debugPhysics = false, debugRaycast = false, debugParticles = false, debugGamepads = false, debugTakeScreenshot;
753
614
 
754
615
  ///////////////////////////////////////////////////////////////////////////////
755
616
  // Debug helper functions
@@ -1580,7 +1441,11 @@ function percentLerp(value, percentA, percentB, lerpA, lerpB)
1580
1441
  * @return {number}
1581
1442
  * @memberof Math */
1582
1443
  function distanceWrap(valueA, valueB, wrapSize=1)
1583
- { const d = (valueA - valueB) % wrapSize; return d*2 % wrapSize - d; }
1444
+ {
1445
+ ASSERT(wrapSize > 0, 'distanceWrap wrapSize must be > 0');
1446
+ const d = (valueA - valueB) % wrapSize;
1447
+ return d*2 % wrapSize - d;
1448
+ }
1584
1449
 
1585
1450
  /** Linearly interpolates between values passed in with wrapping
1586
1451
  * @param {number} valueA
@@ -1897,6 +1762,7 @@ class RandomGenerator
1897
1762
  * @param {number} [seed] - Starting seed or engine default seed */
1898
1763
  constructor(seed = 123456789)
1899
1764
  {
1765
+ ASSERT(seed !== 0, 'RandomGenerator seed must be non-zero (xorshift is fixed at 0)');
1900
1766
  /** @property {number} - random seed */
1901
1767
  this.seed = seed;
1902
1768
  }
@@ -2190,8 +2056,10 @@ class Vector2
2190
2056
  * @return {Vector2} */
2191
2057
  floor() { return new Vector2(floor(this.x), floor(this.y)); }
2192
2058
 
2193
- /** Returns a copy of this vector snapped to a grid
2194
- * @param {number} grid - grid size to snap to
2059
+ /** Returns a copy of this vector snapped to a grid. Note that `grid` is
2060
+ * the number of snap steps per unit (so `grid=2` snaps to halves and
2061
+ * `grid=0.5` snaps to twos), not the cell size.
2062
+ * @param {number} grid - snap steps per unit
2195
2063
  * @return {Vector2} */
2196
2064
  snap(grid)
2197
2065
  {
@@ -2761,9 +2629,14 @@ function readSaveData(saveName, defaultSaveData)
2761
2629
  {
2762
2630
  ASSERT(isString(saveName), 'loadData requires saveName string');
2763
2631
 
2764
- // replace undefined values with defaults
2632
+ // replace undefined values with defaults; tolerate corrupt JSON
2765
2633
  const data = localStorage[saveName];
2766
- const loadedData = data ? JSON.parse(data) : {};
2634
+ let loadedData = {};
2635
+ if (data)
2636
+ {
2637
+ try { loadedData = JSON.parse(data); }
2638
+ catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
2639
+ }
2767
2640
  return { ...defaultSaveData, ...loadedData };
2768
2641
  }
2769
2642
 
@@ -3098,33 +2971,6 @@ let soundDefaultRange = 40;
3098
2971
  * @memberof Settings */
3099
2972
  let soundDefaultTaper = .7;
3100
2973
 
3101
- ///////////////////////////////////////////////////////////////////////////////
3102
- // Medals settings
3103
-
3104
- /** How long to show medals for in seconds
3105
- * @type {number}
3106
- * @default
3107
- * @memberof Settings */
3108
- let medalDisplayTime = 5;
3109
-
3110
- /** How quickly to slide on/off medals in seconds
3111
- * @type {number}
3112
- * @default
3113
- * @memberof Settings */
3114
- let medalDisplaySlideTime = .5;
3115
-
3116
- /** Size of medal display
3117
- * @type {Vector2}
3118
- * @default Vector2(640,80)
3119
- * @memberof Settings */
3120
- let medalDisplaySize = vec2(640, 80);
3121
-
3122
- /** Set to stop medals from being unlockable (like if cheats are enabled)
3123
- * @type {boolean}
3124
- * @default
3125
- * @memberof Settings */
3126
- let medalsPreventUnlock = false;
3127
-
3128
2974
  ///////////////////////////////////////////////////////////////////////////////
3129
2975
  // Setters for global variables
3130
2976
 
@@ -3386,26 +3232,6 @@ function setSoundDefaultRange(range) { soundDefaultRange = range; }
3386
3232
  * @memberof Settings */
3387
3233
  function setSoundDefaultTaper(taper) { soundDefaultTaper = taper; }
3388
3234
 
3389
- /** Set how long to show medals for in seconds
3390
- * @param {number} time
3391
- * @memberof Settings */
3392
- function setMedalDisplayTime(time) { medalDisplayTime = time; }
3393
-
3394
- /** Set how quickly to slide on/off medals in seconds
3395
- * @param {number} time
3396
- * @memberof Settings */
3397
- function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
3398
-
3399
- /** Set size of medal display
3400
- * @param {Vector2} size
3401
- * @memberof Settings */
3402
- function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
3403
-
3404
- /** Set to stop medals from being unlockable
3405
- * @param {boolean} preventUnlock
3406
- * @memberof Settings */
3407
- function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }
3408
-
3409
3235
  /** Set if watermark with FPS should be shown
3410
3236
  * @param {boolean} show
3411
3237
  * @memberof Debug */
@@ -3547,10 +3373,18 @@ class EngineObject
3547
3373
  const parent = this.parent;
3548
3374
  if (parent)
3549
3375
  {
3550
- // copy parent pos/angle
3376
+ // compose with parent transform inline to avoid intermediate vector allocs
3551
3377
  const mirror = parent.getMirrorSign();
3552
- this.pos = this.localPos.multiply(vec2(mirror,1)).rotate(parent.angle).add(parent.pos);
3553
- this.angle = mirror*this.localAngle + parent.angle;
3378
+ const lp = this.localPos, pp = parent.pos;
3379
+ const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
3380
+ if (pa)
3381
+ {
3382
+ const c = cos(-pa), s = sin(-pa);
3383
+ this.pos = new Vector2(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
3384
+ }
3385
+ else
3386
+ this.pos = new Vector2(lx + pp.x, ly + pp.y);
3387
+ this.angle = mirror*this.localAngle + pa;
3554
3388
  }
3555
3389
 
3556
3390
  // update children
@@ -3916,8 +3750,6 @@ class EngineObject
3916
3750
  * @return {string} */
3917
3751
  toString()
3918
3752
  {
3919
- if (!debug) return;
3920
-
3921
3753
  let text = 'type = ' + this.constructor.name;
3922
3754
  if (this.pos.x || this.pos.y)
3923
3755
  text += '\npos = ' + this.pos;
@@ -4167,8 +3999,9 @@ class TextureInfo
4167
3999
  * Create a TextureInfo, called automatically by the engine
4168
4000
  * @param {HTMLImageElement|OffscreenCanvas} image
4169
4001
  * @param {boolean} [useWebGL] - Should use WebGL if available?
4002
+ * @param {boolean} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)?
4170
4003
  */
4171
- constructor(image, useWebGL=true)
4004
+ constructor(image, useWebGL=true, wrap=false)
4172
4005
  {
4173
4006
  /** @property {HTMLImageElement|OffscreenCanvas} - image source */
4174
4007
  this.image = image;
@@ -4178,6 +4011,8 @@ class TextureInfo
4178
4011
  this.sizeInverse = image ? vec2(1/image.width, 1/image.height) : vec2();
4179
4012
  /** @property {WebGLTexture} - WebGL texture */
4180
4013
  this.glTexture = undefined;
4014
+ /** @property {boolean} - true for REPEAT wrap mode, false for CLAMP_TO_EDGE */
4015
+ this.wrap = wrap;
4181
4016
  useWebGL && this.createWebGLTexture();
4182
4017
  }
4183
4018
 
@@ -4190,6 +4025,14 @@ class TextureInfo
4190
4025
  /** Check if the texture is webgl enabled
4191
4026
  * @return {boolean} */
4192
4027
  hasWebGL() { return !!this.glTexture; }
4028
+
4029
+ /** Set the wrap mode for this texture
4030
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE */
4031
+ setWrap(wrap=true)
4032
+ {
4033
+ this.wrap = wrap;
4034
+ glSetTextureWrap(this.glTexture, wrap);
4035
+ }
4193
4036
  }
4194
4037
 
4195
4038
  ///////////////////////////////////////////////////////////////////////////////
@@ -4251,8 +4094,13 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
4251
4094
  }
4252
4095
  else
4253
4096
  {
4254
- // if no tile info, force untextured
4255
- glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0, 0, color.rgbaInt());
4097
+ // if no tile info, force untextured by zeroing rgba (so whatever
4098
+ // texture is bound doesn't leak in) and folding color+additive
4099
+ // into the additive slot — matches the Canvas2D path's
4100
+ // color.add(additiveColor) on line ~337.
4101
+ const combined = additiveColor ? color.add(additiveColor) : color;
4102
+ glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0,
4103
+ 0, combined.rgbaInt());
4256
4104
  }
4257
4105
  }
4258
4106
  else
@@ -4391,6 +4239,8 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
4391
4239
  const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
4392
4240
  ASSERT(textureInfo instanceof TextureInfo, 'texture not loaded');
4393
4241
  ASSERT(textureInfo.size.x > 0, 'texture not loaded');
4242
+ ASSERT(textureInfo.wrap,
4243
+ 'drawTextureWrapped requires a wrap-enabled texture; call textureInfo.setWrap(true) first');
4394
4244
 
4395
4245
  if (useWebGL && glEnable)
4396
4246
  {
@@ -5299,7 +5149,7 @@ const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
5299
5149
  * This is useful to disable for html menus so the browser can handle input normally
5300
5150
  * @param {boolean} preventDefault
5301
5151
  * @memberof Input */
5302
- function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
5152
+ function setInputPreventDefault(preventDefault=true) { inputPreventDefault = preventDefault; }
5303
5153
 
5304
5154
  /** Clears an input key state
5305
5155
  * @param {string|number} key
@@ -6095,10 +5945,12 @@ function touchGamepadButtonCenter()
6095
5945
  * @namespace Audio
6096
5946
  */
6097
5947
 
6098
- /** Audio context used by the engine
5948
+ /** Audio context used by the engine. Created lazily in audioInit() to avoid
5949
+ * browser autoplay warnings about constructing an AudioContext before any
5950
+ * user gesture.
6099
5951
  * @type {AudioContext}
6100
5952
  * @memberof Audio */
6101
- let audioContext = new AudioContext;
5953
+ let audioContext;
6102
5954
 
6103
5955
  /** Master gain node for all audio to pass through
6104
5956
  * @type {GainNode}
@@ -6114,12 +5966,13 @@ const audioDefaultSampleRate = 44100;
6114
5966
  * @return {boolean} - True if the audio context is running
6115
5967
  * @memberof Audio */
6116
5968
  function audioIsRunning()
6117
- { return audioContext.state === 'running'; }
5969
+ { return audioContext?.state === 'running'; }
6118
5970
 
6119
5971
  function audioInit()
6120
5972
  {
6121
5973
  if (!soundEnable || headlessMode) return;
6122
5974
 
5975
+ audioContext = new AudioContext;
6123
5976
  audioMasterGain = audioContext.createGain();
6124
5977
  audioMasterGain.connect(audioContext.destination);
6125
5978
  audioMasterGain.gain.value = soundVolume; // set starting value
@@ -6186,8 +6039,8 @@ class Sound
6186
6039
 
6187
6040
  if (Array.isArray(asset))
6188
6041
  {
6189
- // generate zzfx sound
6190
- const zzfxSound = asset;
6042
+ // generate zzfx sound — copy so we don't mutate the caller's array
6043
+ const zzfxSound = asset.slice();
6191
6044
 
6192
6045
  // remove randomness so it can be applied on playback
6193
6046
  const defaultRandomness = randomness ?? .05;
@@ -7785,11 +7638,14 @@ class ParticleEmitter extends EngineObject
7785
7638
  }
7786
7639
 
7787
7640
  ///////////////////////////////////////////////////////////////////////////////
7641
+ // scratch vector reused by Particle.render to avoid per-frame allocations
7642
+ const particleDrawPos = new Vector2;
7643
+
7788
7644
  /**
7789
7645
  * Particle Object - Created automatically by Particle Emitters
7790
7646
  * @memberof Particles
7791
7647
  */
7792
- class Particle
7648
+ class Particle
7793
7649
  {
7794
7650
  /**
7795
7651
  * Create a particle with the passed in settings
@@ -7964,14 +7820,14 @@ class Particle
7964
7820
  additive && setBlendMode(true);
7965
7821
 
7966
7822
  // update the position and angle for drawing
7967
- const pos = this.pos.copy();
7823
+ const pos = particleDrawPos.set(this.pos.x, this.pos.y);
7968
7824
  let angle = this.angle;
7969
7825
  if (localSpace)
7970
7826
  {
7971
7827
  // in local space of emitter
7972
7828
  const a = emitter.angle;
7973
7829
  const c = cos(a), s = sin(a);
7974
- pos.set(emitter.pos.x + pos.x*c - pos.y*s,
7830
+ pos.set(emitter.pos.x + pos.x*c - pos.y*s,
7975
7831
  emitter.pos.y + pos.x*s + pos.y*c);
7976
7832
  angle += a;
7977
7833
  }
@@ -7996,200 +7852,6 @@ class Particle
7996
7852
  debugParticles && debugRect(pos, size, '#f005', 0, angle);
7997
7853
  }
7998
7854
  }
7999
- /**
8000
- * LittleJS Medal System
8001
- * - Achievement/trophy system for games
8002
- * - Medal class with name, description, icon, and unlock tracking
8003
- * - Automatic saving to local storage
8004
- * - Visual display queue with slide-in notifications
8005
- * - Newgrounds API integration for online achievements
8006
- * - Debug mode to unlock/reset medals during development
8007
- * @namespace Medals
8008
- */
8009
-
8010
- /** List of all medals
8011
- * @type {Object}
8012
- * @memberof Medals */
8013
- const medals = {};
8014
-
8015
- // Engine internal variables not exposed to documentation
8016
- let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast;
8017
-
8018
- ///////////////////////////////////////////////////////////////////////////////
8019
-
8020
- /** Initialize medals with a save name used for storage
8021
- * - Call this after creating all medals
8022
- * - Checks if medals are unlocked
8023
- * @param {string} saveName
8024
- * @memberof Medals */
8025
- function medalsInit(saveName)
8026
- {
8027
- // check if medals are unlocked
8028
- medalsSaveName = saveName;
8029
- if (!debugMedals)
8030
- medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
8031
-
8032
- // engine automatically renders medals
8033
- engineAddPlugin(undefined, medalsRender);
8034
-
8035
- // plugin functions
8036
- function medalsRender()
8037
- {
8038
- if (!medalsDisplayQueue.length) return;
8039
-
8040
- // update first medal in queue
8041
- const medal = medalsDisplayQueue[0];
8042
- const time = timeReal - medalsDisplayTimeLast;
8043
- if (!medalsDisplayTimeLast)
8044
- medalsDisplayTimeLast = timeReal;
8045
- else if (time > medalDisplayTime)
8046
- {
8047
- medalsDisplayTimeLast = 0;
8048
- medalsDisplayQueue.shift();
8049
- }
8050
- else
8051
- {
8052
- // slide on/off medals
8053
- const slideOffTime = medalDisplayTime - medalDisplaySlideTime;
8054
- const hidePercent =
8055
- time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime :
8056
- time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0;
8057
- medal.render(hidePercent);
8058
- }
8059
- }
8060
- }
8061
-
8062
- /**
8063
- * @callback MedalCallbackFunction - Function that processes a medal
8064
- * @param {Medal} medal
8065
- * @memberof Medals
8066
- */
8067
-
8068
- /** Calls a function for each medal
8069
- * @param {MedalCallbackFunction} callback
8070
- * @memberof Medals */
8071
- function medalsForEach(callback)
8072
- { Object.values(medals).forEach(medal=>callback(medal)); }
8073
-
8074
- ///////////////////////////////////////////////////////////////////////////////
8075
-
8076
- /**
8077
- * Medal - Tracks an unlockable medal
8078
- * @memberof Medals
8079
- * @example
8080
- * // create a medal
8081
- * const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
8082
- *
8083
- * // initialize medals
8084
- * medalsInit('Example Game');
8085
- *
8086
- * // unlock the medal
8087
- * medal_example.unlock();
8088
- */
8089
- class Medal
8090
- {
8091
- /** Create a medal object and adds it to the list of medals
8092
- * @param {number} id - The unique identifier of the medal
8093
- * @param {string} name - Name of the medal
8094
- * @param {string} [description] - Description of the medal
8095
- * @param {string} [icon] - Icon for the medal
8096
- * @param {string} [src] - Image location for the medal
8097
- */
8098
- constructor(id, name, description='', icon='🏆', src)
8099
- {
8100
- ASSERT(id >= 0 && !medals[id]);
8101
-
8102
- /** @property {number} - The unique identifier of the medal */
8103
- this.id = id;
8104
-
8105
- /** @property {string} - Name of the medal */
8106
- this.name = name;
8107
-
8108
- /** @property {string} - Description of the medal */
8109
- this.description = description;
8110
-
8111
- /** @property {string} - Icon for the medal */
8112
- this.icon = icon;
8113
-
8114
- /** @property {boolean} - Is the medal unlocked? */
8115
- this.unlocked = false;
8116
-
8117
- // load the source image if provided
8118
- if (src)
8119
- (this.image = new Image).src = src;
8120
-
8121
- // add this to list of medals
8122
- medals[id] = this;
8123
- }
8124
-
8125
- /** Unlocks a medal if not already unlocked */
8126
- unlock()
8127
- {
8128
- if (medalsPreventUnlock || this.unlocked) return;
8129
-
8130
- // save the medal
8131
- ASSERT(medalsSaveName, 'save name must be set');
8132
- localStorage[this.storageKey()] = this.unlocked = true;
8133
- medalsDisplayQueue.push(this);
8134
- }
8135
-
8136
- /** Render a medal
8137
- * @param {number} [hidePercent] - How much to slide the medal off screen
8138
- */
8139
- render(hidePercent=0)
8140
- {
8141
- const context = mainContext;
8142
- const width = min(medalDisplaySize.x, mainCanvas.width);
8143
- const height = medalDisplaySize.y;
8144
- const x = mainCanvas.width - width;
8145
- const y = -height*hidePercent;
8146
- const backgroundColor = hsl(0,0,.9);
8147
-
8148
- // draw containing rect and clip to that region
8149
- context.save();
8150
- context.beginPath();
8151
- context.fillStyle = backgroundColor.toString();
8152
- context.strokeStyle = BLACK.toString();
8153
- context.lineWidth = 3;
8154
- context.rect(x, y, width, height);
8155
- context.fill();
8156
- context.stroke();
8157
- context.clip();
8158
-
8159
- // draw the icon
8160
- const gap = vec2(.1, .05).scale(height);
8161
- const medalDisplayIconSize = height - 2*gap.x;
8162
- this.renderIcon(vec2(x + gap.x + medalDisplayIconSize/2, y + height/2), medalDisplayIconSize);
8163
-
8164
- // draw the name
8165
- const nameSize = height*.5;
8166
- const descriptionSize = height*.3;
8167
- const pos = vec2(x + medalDisplayIconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
8168
- const textWidth = width - medalDisplayIconSize - 3*gap.x;
8169
- drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
8170
-
8171
- // draw the description
8172
- pos.y = y + height - gap.y*2 - descriptionSize/2;
8173
- drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
8174
- context.restore();
8175
- }
8176
-
8177
- /** Render the icon for a medal
8178
- * @param {Vector2} pos - Screen space position
8179
- * @param {number} size - Screen space size
8180
- */
8181
- renderIcon(pos, size)
8182
- {
8183
- // draw the image or icon
8184
- if (this.image)
8185
- mainContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size);
8186
- else
8187
- drawTextScreen(this.icon, pos, size*.7, BLACK);
8188
- }
8189
-
8190
- // Get local storage key used by the medal
8191
- storageKey() { return medalsSaveName + '_' + this.id; }
8192
- }
8193
7855
  /**
8194
7856
  * LittleJS WebGL Interface
8195
7857
  * - WebGL2 rendering engine for high-performance graphics
@@ -8289,7 +7951,7 @@ function glInit(rootElement)
8289
7951
  // reinit WebGL and restore textures
8290
7952
  initWebGL();
8291
7953
  for (const info of glTextureInfos)
8292
- info.glTexture = glCreateTexture(info.image);
7954
+ info.glTexture = glCreateTexture(info.image, info.wrap);
8293
7955
  pluginList.forEach(plugin=>plugin.glContextRestored?.());
8294
7956
  });
8295
7957
 
@@ -8505,6 +8167,30 @@ function glSetTexture(texture)
8505
8167
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
8506
8168
  }
8507
8169
 
8170
+ /** Set the wrap mode (REPEAT or CLAMP_TO_EDGE) on an existing WebGL texture
8171
+ * Flushes the current batch only if the texture is the active one
8172
+ * @param {WebGLTexture} texture
8173
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
8174
+ * @memberof WebGL */
8175
+ function glSetTextureWrap(texture, wrap=true)
8176
+ {
8177
+ if (!glContext || !texture) return;
8178
+
8179
+ // flush only if changing wrap on the currently bound texture
8180
+ const isCurrent = texture === glActiveTexture;
8181
+ if (isCurrent)
8182
+ glFlush();
8183
+ else
8184
+ glContext.bindTexture(glContext.TEXTURE_2D, texture);
8185
+
8186
+ const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
8187
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
8188
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
8189
+
8190
+ if (!isCurrent && glActiveTexture)
8191
+ glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
8192
+ }
8193
+
8508
8194
  /** Compile WebGL shader of the given type, will throw errors if in debug mode
8509
8195
  * @param {string} source
8510
8196
  * @param {number} type
@@ -8549,9 +8235,10 @@ function glCreateProgram(vsSource, fsSource)
8549
8235
  /** Create WebGL texture from an image and init the texture settings
8550
8236
  * Restores the active texture when done
8551
8237
  * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas} [image]
8238
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
8552
8239
  * @return {WebGLTexture}
8553
8240
  * @memberof WebGL */
8554
- function glCreateTexture(image)
8241
+ function glCreateTexture(image, wrap=false)
8555
8242
  {
8556
8243
  if (!glContext) return;
8557
8244
 
@@ -8577,8 +8264,9 @@ function glCreateTexture(image)
8577
8264
  const minFilter = mipMap ? glContext.LINEAR_MIPMAP_LINEAR : magFilter;
8578
8265
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, magFilter);
8579
8266
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, minFilter);
8580
- glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, glContext.REPEAT);
8581
- glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, glContext.REPEAT);
8267
+ const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
8268
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
8269
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
8582
8270
  if (mipMap)
8583
8271
  glContext.generateMipmap(glContext.TEXTURE_2D);
8584
8272
 
@@ -8630,7 +8318,7 @@ function glRegisterTextureInfo(textureInfo)
8630
8318
  if (textureInfo.glTexture)
8631
8319
  glSetTextureData(textureInfo.glTexture, textureInfo.image);
8632
8320
  else
8633
- textureInfo.glTexture = glCreateTexture(textureInfo.image);
8321
+ textureInfo.glTexture = glCreateTexture(textureInfo.image, textureInfo.wrap);
8634
8322
  }
8635
8323
 
8636
8324
  /** Tells WebGL to destroy the glTexture and stop tracking it
@@ -8849,6 +8537,7 @@ function glSetRenderTarget(texture, clear=false)
8849
8537
  glFlush();
8850
8538
  glRenderTarget = undefined;
8851
8539
  glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
8540
+ glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
8852
8541
  }
8853
8542
  }
8854
8543
 
@@ -8971,9 +8660,9 @@ function glPolyStrip(points)
8971
8660
  return area;
8972
8661
  }
8973
8662
 
8974
- // ensure counter-clockwise winding
8663
+ // ensure counter-clockwise winding (slice first so we don't mutate caller's array)
8975
8664
  if (signedArea(points) < 0)
8976
- points = points.reverse();
8665
+ points = points.slice().reverse();
8977
8666
 
8978
8667
  // check if point is inside triangle
8979
8668
  const e = 1e-9;
@@ -9073,6 +8762,398 @@ function glPolyStrip(points)
9073
8762
  }
9074
8763
  return strip;
9075
8764
  }
8765
+ /**
8766
+ * LittleJS Engine Logo
8767
+ * - Draws the LittleJS splash screen logo
8768
+ * - Used internally during engine startup
8769
+ */
8770
+
8771
+ ///////////////////////////////////////////////////////////////////////////////
8772
+ function drawEngineLogo(t)
8773
+ {
8774
+ const blackAndWhite = 0;
8775
+ const showName = 1;
8776
+
8777
+ // LittleJS Logo and Splash Screen
8778
+ const x = mainContext;
8779
+ const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
8780
+ const w = mainCanvas.width = innerWidth * dpr;
8781
+ const h = mainCanvas.height = innerHeight * dpr;
8782
+ {
8783
+ // background
8784
+ const p3 = percent(t, 1, .8);
8785
+ const p4 = percent(t, 0, .5);
8786
+ const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,hypot(w,h)*.6);
8787
+ g.addColorStop(0,hsl(0,0,lerp(0,p3/2,p4),p3).toString());
8788
+ g.addColorStop(1,hsl(0,0,0,p3).toString());
8789
+ x.save();
8790
+ x.fillStyle = g;
8791
+ x.fillRect(0,0,w,h);
8792
+ }
8793
+ const gradient = (X1,Y1,X2,Y2,C,S=1)=>
8794
+ {
8795
+ if (C >= 0)
8796
+ {
8797
+ if (blackAndWhite)
8798
+ x.fillStyle = '#fff';
8799
+ else
8800
+ {
8801
+ const g = x.fillStyle = x.createLinearGradient(X1,Y1,X2,Y2);
8802
+ g.addColorStop(0,color(C,2));
8803
+ g.addColorStop(1,color(C,1));
8804
+ }
8805
+ }
8806
+ else
8807
+ x.fillStyle = '#000';
8808
+ C >= -1 ? (x.fill(), S && x.stroke()) : x.stroke();
8809
+ }
8810
+ const circle = (X,Y,R,A=0,B=2*PI,C,S)=>
8811
+ {
8812
+ x.beginPath();
8813
+ x.arc(X,Y,R,p*A,p*B);
8814
+ gradient(X,Y-R,X,Y+R,C,S);
8815
+ }
8816
+ const rect = (X,Y,W,H,C)=>
8817
+ {
8818
+ x.beginPath();
8819
+ x.rect(X,Y,W,H*p);
8820
+ gradient(X,Y+H,X+W,Y,C);
8821
+ }
8822
+ const poly = (points,C,Y,H)=>
8823
+ {
8824
+ x.beginPath();
8825
+ for (const p of points)
8826
+ x.lineTo(p.x, p.y);
8827
+ x.closePath();
8828
+ gradient(0, Y, 0, Y+H,C);
8829
+ }
8830
+ const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
8831
+
8832
+ // center and fit to screen
8833
+ const alpha = oscillate(1,1,t);
8834
+ const p = percent(alpha, .1, .5);
8835
+ const size = min(6, min(w,h)/99);
8836
+ x.translate(w/2,h/2);
8837
+ x.scale(size,size);
8838
+ x.translate(-40,-35);
8839
+ p < 1 && x.setLineDash([99*p,99]);
8840
+ x.lineJoin = x.lineCap = 'round';
8841
+ x.lineWidth = .1 + p*1.9;
8842
+ //x.strokeStyle='#fff7';
8843
+
8844
+ if (showName)
8845
+ {
8846
+ // engine name text
8847
+ const Y = 54;
8848
+ const s = 'LittleJS';
8849
+ x.font = '900 15.5px arial';
8850
+ x.lineWidth = .1+p*3.9;
8851
+ x.textAlign = 'center';
8852
+ x.textBaseline = 'top';
8853
+ rect(11,Y+1,59,8*p,-1);
8854
+ x.beginPath();
8855
+
8856
+ let w2 = 0;
8857
+ for (let i=0;i<s.length;++i)
8858
+ w2 += x.measureText(s[i]).width;
8859
+ for (let j=2;j--;)
8860
+ for (let i=0,X=40-w2/2;i<s.length;++i)
8861
+ {
8862
+ const w = x.measureText(s[i]).width, X2 = X+w/2;
8863
+ gradient(X2,Y,X2+2,Y+13,i>5?1:0);
8864
+ x[j?'strokeText':'fillText'](s[i],X2,Y+.5,17*p);
8865
+ X += w;
8866
+ }
8867
+
8868
+ x.lineWidth = .1 + p*1.9;
8869
+ rect(3,Y,73,0); // bottom
8870
+ }
8871
+
8872
+ rect(7,15,26,-7,0); // cab top
8873
+ rect(25,15,8,25,-1); // cab front
8874
+ rect(10,40,15,-25,1); // cab back
8875
+ rect(14,21,7,9,2); // cab window
8876
+ rect(38,20,6,-6,2); // little stack
8877
+
8878
+ // big stack
8879
+ rect(49,20,10,-6,0);
8880
+ const stackPoints = [vec2(44,8),vec2(64,8),vec2(59,8+6*p),vec2(49,8+6*p)];
8881
+ poly(stackPoints,2,8,6*p);
8882
+ rect(44,8,20,-7,0);
8883
+
8884
+ // engine
8885
+ for (let i=5;i--;) circle(59-i*6*p,30,10,0,2*PI,1,0);
8886
+ circle(59,30,4,0,7,2); // light
8887
+
8888
+ // engine outline
8889
+ rect(35,20,24,0); // top
8890
+ circle(59,30,10); // front
8891
+ circle(47,30,10,PI/2,PI*3/2); // middle
8892
+ circle(35,30,10,PI/2,PI*3/2); // back
8893
+ rect(7,40,13,7,-1); // bottom back
8894
+ rect(17,40,43,14,-1); // bottom center
8895
+
8896
+ // wheels
8897
+ for (let i=3;i--;) for (let j=2;j--;) circle(17+15*i,47,j?7:1,0,2*PI,2);
8898
+
8899
+ // cowcatcher
8900
+ for (let i=2;i--;)
8901
+ {
8902
+ let w=6, s=7, o=53+w*p*i
8903
+ const points = [vec2(o+s,54),vec2(o,40),vec2(o+w*p,40),vec2(o+s+w*p,54)];
8904
+ poly(points,0,40,14);
8905
+ }
8906
+
8907
+ x.restore();
8908
+ }
8909
+
8910
+ /**
8911
+ * LittleJS Medal System
8912
+ * - Achievement/trophy system for games
8913
+ * - Medal class with name, description, icon, and unlock tracking
8914
+ * - Automatic saving to local storage
8915
+ * - Visual display queue with slide-in notifications
8916
+ * - Newgrounds API integration for online achievements
8917
+ * - Debug mode to unlock/reset medals during development
8918
+ * @namespace Medals
8919
+ */
8920
+
8921
+ let debugMedals = false;
8922
+
8923
+ ///////////////////////////////////////////////////////////////////////////////
8924
+ // Medals settings
8925
+
8926
+ /** How long to show medals for in seconds
8927
+ * @type {number}
8928
+ * @default
8929
+ * @memberof Settings */
8930
+ let medalDisplayTime = 5;
8931
+
8932
+ /** How quickly to slide on/off medals in seconds
8933
+ * @type {number}
8934
+ * @default
8935
+ * @memberof Settings */
8936
+ let medalDisplaySlideTime = .5;
8937
+
8938
+ /** Size of medal display
8939
+ * @type {Vector2}
8940
+ * @default Vector2(640,80)
8941
+ * @memberof Settings */
8942
+ let medalDisplaySize = vec2(640, 80);
8943
+
8944
+ /** Set to stop medals from being unlockable (like if cheats are enabled)
8945
+ * @type {boolean}
8946
+ * @default
8947
+ * @memberof Settings */
8948
+ let medalsPreventUnlock = false;
8949
+
8950
+ /** List of all medals
8951
+ * @type {Object}
8952
+ * @memberof Medals */
8953
+ const medals = {};
8954
+
8955
+ // Engine internal variables not exposed to documentation
8956
+ let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast;
8957
+
8958
+ ///////////////////////////////////////////////////////////////////////////////
8959
+
8960
+ /** Initialize medals with a save name used for storage
8961
+ * - Call this after creating all medals
8962
+ * - Checks if medals are unlocked
8963
+ * @param {string} saveName
8964
+ * @memberof Medals */
8965
+ function medalsInit(saveName)
8966
+ {
8967
+ // check if medals are unlocked
8968
+ medalsSaveName = saveName;
8969
+ if (!debugMedals)
8970
+ medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
8971
+
8972
+ // engine automatically renders medals
8973
+ engineAddPlugin(undefined, medalsRender);
8974
+
8975
+ // plugin functions
8976
+ function medalsRender()
8977
+ {
8978
+ if (!medalsDisplayQueue.length) return;
8979
+
8980
+ // update first medal in queue
8981
+ const medal = medalsDisplayQueue[0];
8982
+ const time = timeReal - medalsDisplayTimeLast;
8983
+ if (!medalsDisplayTimeLast)
8984
+ medalsDisplayTimeLast = timeReal;
8985
+ else if (time > medalDisplayTime)
8986
+ {
8987
+ medalsDisplayTimeLast = 0;
8988
+ medalsDisplayQueue.shift();
8989
+ }
8990
+ else
8991
+ {
8992
+ // slide on/off medals
8993
+ const slideOffTime = medalDisplayTime - medalDisplaySlideTime;
8994
+ const hidePercent =
8995
+ time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime :
8996
+ time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0;
8997
+ medal.render(hidePercent);
8998
+ }
8999
+ }
9000
+ }
9001
+
9002
+ /**
9003
+ * @callback MedalCallbackFunction - Function that processes a medal
9004
+ * @param {Medal} medal
9005
+ * @memberof Medals
9006
+ */
9007
+
9008
+ /** Calls a function for each medal
9009
+ * @param {MedalCallbackFunction} callback
9010
+ * @memberof Medals */
9011
+ function medalsForEach(callback)
9012
+ { Object.values(medals).forEach(medal=>callback(medal)); }
9013
+
9014
+ ///////////////////////////////////////////////////////////////////////////////
9015
+
9016
+ /**
9017
+ * Medal - Tracks an unlockable medal
9018
+ * @memberof Medals
9019
+ * @example
9020
+ * // create a medal
9021
+ * const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
9022
+ *
9023
+ * // initialize medals
9024
+ * medalsInit('Example Game');
9025
+ *
9026
+ * // unlock the medal
9027
+ * medal_example.unlock();
9028
+ */
9029
+ class Medal
9030
+ {
9031
+ /** Create a medal object and adds it to the list of medals
9032
+ * @param {number} id - The unique identifier of the medal
9033
+ * @param {string} name - Name of the medal
9034
+ * @param {string} [description] - Description of the medal
9035
+ * @param {string} [icon] - Icon for the medal
9036
+ * @param {string} [src] - Image location for the medal
9037
+ */
9038
+ constructor(id, name, description='', icon='🏆', src)
9039
+ {
9040
+ ASSERT(id >= 0 && !medals[id]);
9041
+
9042
+ /** @property {number} - The unique identifier of the medal */
9043
+ this.id = id;
9044
+
9045
+ /** @property {string} - Name of the medal */
9046
+ this.name = name;
9047
+
9048
+ /** @property {string} - Description of the medal */
9049
+ this.description = description;
9050
+
9051
+ /** @property {string} - Icon for the medal */
9052
+ this.icon = icon;
9053
+
9054
+ /** @property {boolean} - Is the medal unlocked? */
9055
+ this.unlocked = false;
9056
+
9057
+ // load the source image if provided
9058
+ if (src)
9059
+ (this.image = new Image).src = src;
9060
+
9061
+ // add this to list of medals
9062
+ medals[id] = this;
9063
+ }
9064
+
9065
+ /** Unlocks a medal if not already unlocked */
9066
+ unlock()
9067
+ {
9068
+ if (medalsPreventUnlock || this.unlocked) return;
9069
+
9070
+ // save the medal
9071
+ ASSERT(medalsSaveName, 'save name must be set');
9072
+ localStorage[this.storageKey()] = this.unlocked = true;
9073
+ medalsDisplayQueue.push(this);
9074
+ }
9075
+
9076
+ /** Render a medal
9077
+ * @param {number} [hidePercent] - How much to slide the medal off screen
9078
+ */
9079
+ render(hidePercent=0)
9080
+ {
9081
+ const context = mainContext;
9082
+ const width = min(medalDisplaySize.x, mainCanvas.width);
9083
+ const height = medalDisplaySize.y;
9084
+ const x = mainCanvas.width - width;
9085
+ const y = -height*hidePercent;
9086
+ const backgroundColor = hsl(0,0,.9);
9087
+
9088
+ // draw containing rect and clip to that region
9089
+ context.save();
9090
+ context.beginPath();
9091
+ context.fillStyle = backgroundColor.toString();
9092
+ context.strokeStyle = BLACK.toString();
9093
+ context.lineWidth = 3;
9094
+ context.rect(x, y, width, height);
9095
+ context.fill();
9096
+ context.stroke();
9097
+ context.clip();
9098
+
9099
+ // draw the icon
9100
+ const gap = vec2(.1, .05).scale(height);
9101
+ const medalDisplayIconSize = height - 2*gap.x;
9102
+ this.renderIcon(vec2(x + gap.x + medalDisplayIconSize/2, y + height/2), medalDisplayIconSize);
9103
+
9104
+ // draw the name
9105
+ const nameSize = height*.5;
9106
+ const descriptionSize = height*.3;
9107
+ const pos = vec2(x + medalDisplayIconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
9108
+ const textWidth = width - medalDisplayIconSize - 3*gap.x;
9109
+ drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
9110
+
9111
+ // draw the description
9112
+ pos.y = y + height - gap.y*2 - descriptionSize/2;
9113
+ drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
9114
+ context.restore();
9115
+ }
9116
+
9117
+ /** Render the icon for a medal
9118
+ * @param {Vector2} pos - Screen space position
9119
+ * @param {number} size - Screen space size
9120
+ */
9121
+ renderIcon(pos, size)
9122
+ {
9123
+ // draw the image or icon
9124
+ if (this.image)
9125
+ mainContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size);
9126
+ else
9127
+ drawTextScreen(this.icon, pos, size*.7, BLACK);
9128
+ }
9129
+
9130
+ // Get local storage key used by the medal
9131
+ storageKey() { return medalsSaveName + '_' + this.id; }
9132
+ }
9133
+
9134
+ ///////////////////////////////////////////////////////////////////////////////
9135
+ // Medals setting setters
9136
+
9137
+ /** Set how long to show medals for in seconds
9138
+ * @param {number} time
9139
+ * @memberof Settings */
9140
+ function setMedalDisplayTime(time) { medalDisplayTime = time; }
9141
+
9142
+ /** Set how quickly to slide on/off medals in seconds
9143
+ * @param {number} time
9144
+ * @memberof Settings */
9145
+ function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
9146
+
9147
+ /** Set size of medal display
9148
+ * @param {Vector2} size
9149
+ * @memberof Settings */
9150
+ function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
9151
+
9152
+ /** Set to stop medals from being unlockable
9153
+ * @param {boolean} preventUnlock
9154
+ * @memberof Settings */
9155
+ function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }
9156
+
9076
9157
  /**
9077
9158
  * LittleJS Newgrounds Plugin
9078
9159
  * - NewgroundsMedal extends Medal with Newgrounds API functionality
@@ -9689,16 +9770,13 @@ class UISystemPlugin
9689
9770
  this.lastHoverObject = undefined;
9690
9771
  /** @property {UIObject} - Current confirm menu being shown */
9691
9772
  this.confirmDialog = undefined;
9692
- /** @property {UIObject} - Object to send keyboard input to */
9693
- this.keyInputObject = undefined;
9773
+ /** @private */
9774
+ this._keyInputObject = undefined;
9775
+ /** @private */
9776
+ this._onKeyDown = (e) => this._keyInputObject?.onKeyDown(e);
9694
9777
 
9695
9778
  engineAddPlugin(uiUpdate, uiRender);
9696
9779
 
9697
- // key down handler
9698
- function onKeyDown(e)
9699
- { uiSystem.keyInputObject?.onKeyDown(e); }
9700
- document.addEventListener('keydown', onKeyDown);
9701
-
9702
9780
  // set object position in parent space
9703
9781
  function updateTransforms(o)
9704
9782
  {
@@ -10040,8 +10118,23 @@ class UISystemPlugin
10040
10118
  return p;
10041
10119
  }
10042
10120
 
10121
+ /** Object to send keyboard input to (typically a UITextInput).
10122
+ * The document keydown listener is only attached while this is set,
10123
+ * so games that never use text input pay no event-handling cost.
10124
+ * @type {UIObject} */
10125
+ get keyInputObject() { return this._keyInputObject; }
10126
+ set keyInputObject(obj)
10127
+ {
10128
+ const had = !!this._keyInputObject;
10129
+ this._keyInputObject = obj;
10130
+ if (!had && obj)
10131
+ document.addEventListener('keydown', this._onKeyDown);
10132
+ else if (had && !obj)
10133
+ document.removeEventListener('keydown', this._onKeyDown);
10134
+ }
10135
+
10043
10136
  /** Destroy and remove all objects
10044
- * @memberof Engine */
10137
+ * @memberof UISystem */
10045
10138
  destroyObjects()
10046
10139
  {
10047
10140
  for (const o of this.uiObjects)
@@ -10464,15 +10557,13 @@ class UIObject
10464
10557
  * @return {string} */
10465
10558
  toString()
10466
10559
  {
10467
- if (!debug) return;
10468
-
10469
10560
  let text = 'type = ' + this.constructor.name;
10470
10561
  if (this.text)
10471
10562
  text += '\ntext = ' + this.text;
10472
10563
  if (this.pos.x || this.pos.y)
10473
10564
  text += '\npos = ' + this.pos;
10474
10565
  if (this.localPos.x || this.localPos.y)
10475
- text += '\localPos = ' + this.localPos;
10566
+ text += '\nlocalPos = ' + this.localPos;
10476
10567
  if (this.size.x || this.size.y)
10477
10568
  text += '\nsize = ' + this.size;
10478
10569
  if (this.color)
@@ -11066,6 +11157,135 @@ class UIVideo extends UIObject
11066
11157
  this.video.remove();
11067
11158
  super.destroy();
11068
11159
  }
11160
+ }
11161
+
11162
+ ///////////////////////////////////////////////////////////////////////////////
11163
+ /**
11164
+ * UILayout - A container that auto-arranges children in a vertical list, horizontal list, or grid
11165
+ * - Set columns to 1 for a vertical list (default)
11166
+ * - Set columns to children.length for a horizontal list
11167
+ * - Set columns to N (1 < N < children.length) for a grid with N columns
11168
+ * - Per-child sizing: each row's height = max child.size.y in that row, each column's width = max child.size.x in that column
11169
+ * - Children are positioned centered in their cell
11170
+ * - Container auto-sizes to fit children plus padding
11171
+ * @extends UIObject
11172
+ * @memberof UISystem
11173
+ */
11174
+ class UILayout extends UIObject
11175
+ {
11176
+ /** Create a UILayout container that auto-arranges children
11177
+ * @param {Vector2} [pos]
11178
+ * @param {number} [columns=1] - Number of columns (1 = vertical list)
11179
+ * @param {number} [gap=10] - Space between children
11180
+ * @param {number} [padding=10] - Space between container border and children
11181
+ * @param {boolean} [transparent=false] - If true, draws no background, outline, or shadow
11182
+ */
11183
+ constructor(pos, columns=1, gap=10, padding=10, transparent=false)
11184
+ {
11185
+ super(pos);
11186
+
11187
+ ASSERT(isNumber(columns) && columns >= 1, 'ui layout columns must be a number >= 1');
11188
+ ASSERT(isNumber(gap), 'ui layout gap must be a number');
11189
+ ASSERT(isNumber(padding), 'ui layout padding must be a number');
11190
+
11191
+ /** @property {number} - Number of columns in the layout */
11192
+ this.columns = columns;
11193
+ /** @property {number} - Space between children */
11194
+ this.gap = gap;
11195
+ /** @property {number} - Space between container border and children */
11196
+ this.padding = padding;
11197
+
11198
+ if (transparent)
11199
+ {
11200
+ // pure positioning helper - skip background, outline, and shadow
11201
+ this.color = CLEAR_BLACK;
11202
+ this.gradientColor = undefined;
11203
+ this.lineWidth = 0;
11204
+ this.shadowColor = CLEAR_BLACK;
11205
+ }
11206
+ this.relayout();
11207
+ }
11208
+
11209
+ /** Add a child UIObject and re-layout
11210
+ * @param {UIObject} child
11211
+ * @return {UIObject} The child object added */
11212
+ addChild(child)
11213
+ {
11214
+ super.addChild(child);
11215
+ this.relayout();
11216
+ return child;
11217
+ }
11218
+
11219
+ /** Remove a child UIObject and re-layout
11220
+ * @param {UIObject} child */
11221
+ removeChild(child)
11222
+ {
11223
+ super.removeChild(child);
11224
+ this.relayout();
11225
+ }
11226
+
11227
+ /** Recompute child positions and container size based on per-child sizes.
11228
+ * Called automatically by addChild and removeChild. Call manually if you
11229
+ * mutate a child's size or change columns, gap, or padding. */
11230
+ relayout()
11231
+ {
11232
+ const n = this.children.length;
11233
+ if (!n)
11234
+ {
11235
+ this.size = vec2(this.padding * 2);
11236
+ return;
11237
+ }
11238
+
11239
+ const cols = this.columns;
11240
+ const rows = ceil(n / cols);
11241
+ const colWidths = new Array(cols).fill(0);
11242
+ const rowHeights = new Array(rows).fill(0);
11243
+
11244
+ // first pass: compute column widths and row heights from child sizes
11245
+ for (let i = 0; i < n; ++i)
11246
+ {
11247
+ const col = i % cols;
11248
+ const row = floor(i / cols);
11249
+ const child = this.children[i];
11250
+ colWidths[col] = max(colWidths[col], child.size.x);
11251
+ rowHeights[row] = max(rowHeights[row], child.size.y);
11252
+ }
11253
+
11254
+ // total content size (sum of column widths/row heights plus gaps between them)
11255
+ let contentWidth = this.gap * (cols - 1);
11256
+ for (const w of colWidths) contentWidth += w;
11257
+ let contentHeight = this.gap * (rows - 1);
11258
+ for (const h of rowHeights) contentHeight += h;
11259
+
11260
+ // cumulative column/row offsets so positioning is O(n) not O(n^2)
11261
+ const colOffsets = new Array(cols);
11262
+ let xAcc = 0;
11263
+ for (let c = 0; c < cols; ++c)
11264
+ {
11265
+ colOffsets[c] = xAcc;
11266
+ xAcc += colWidths[c];
11267
+ }
11268
+ const rowOffsets = new Array(rows);
11269
+ let yAcc = 0;
11270
+ for (let r = 0; r < rows; ++r)
11271
+ {
11272
+ rowOffsets[r] = yAcc;
11273
+ yAcc += rowHeights[r];
11274
+ }
11275
+
11276
+ // second pass: position each child centered in its cell
11277
+ for (let i = 0; i < n; ++i)
11278
+ {
11279
+ const col = i % cols;
11280
+ const row = floor(i / cols);
11281
+ const x = -contentWidth/2 + colOffsets[col] + this.gap * col + colWidths[col] / 2;
11282
+ const y = -contentHeight/2 + rowOffsets[row] + this.gap * row + rowHeights[row] / 2;
11283
+ this.children[i].localPos = vec2(x, y);
11284
+ }
11285
+
11286
+ // container size = content + padding on all sides
11287
+ this.size = vec2(contentWidth + this.padding * 2, contentHeight + this.padding * 2);
11288
+ }
11069
11289
  }
11070
11290
  /**
11071
11291
  * LittleJS Box2D Physics Plugin
@@ -11592,14 +11812,29 @@ class Box2dObject extends EngineObject
11592
11812
  this.body.ApplyForce(box2d.vec2dTo(force), box2d.vec2dTo(pos));
11593
11813
  }
11594
11814
 
11595
- /** Apply acceleration to this object
11815
+ /** Apply acceleration to this object (changes velocity by acceleration,
11816
+ * mass-independent — matches EngineObject.applyAcceleration semantics).
11817
+ * Use applyImpulse if you want the mass-dependent velocity change
11818
+ * Δv = impulse / mass, or applyForce for a Newton-style sustained force.
11596
11819
  * @param {Vector2} acceleration
11597
11820
  * @param {Vector2} [pos] */
11598
11821
  applyAcceleration(acceleration, pos)
11599
- {
11822
+ {
11823
+ pos ||= this.getCenterOfMass();
11824
+ this.setAwake();
11825
+ const impulse = acceleration.scale(this.getMass());
11826
+ this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
11827
+ }
11828
+
11829
+ /** Apply an instantaneous linear impulse. Changes velocity immediately by
11830
+ * impulse / mass (so heavier bodies move less for the same impulse).
11831
+ * @param {Vector2} impulse
11832
+ * @param {Vector2} [pos] */
11833
+ applyImpulse(impulse, pos)
11834
+ {
11600
11835
  pos ||= this.getCenterOfMass();
11601
11836
  this.setAwake();
11602
- this.body.ApplyLinearImpulse(box2d.vec2dTo(acceleration), box2d.vec2dTo(pos));
11837
+ this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
11603
11838
  }
11604
11839
 
11605
11840
  /** Apply torque to this object
@@ -11609,13 +11844,23 @@ class Box2dObject extends EngineObject
11609
11844
  this.setAwake();
11610
11845
  this.body.ApplyTorque(torque);
11611
11846
  }
11612
-
11613
- /** Apply angular acceleration to this object
11847
+
11848
+ /** Apply angular acceleration to this object (changes angular velocity by
11849
+ * acceleration, mass-independent — matches EngineObject semantics).
11614
11850
  * @param {number} acceleration */
11615
11851
  applyAngularAcceleration(acceleration)
11616
11852
  {
11617
11853
  this.setAwake();
11618
- this.body.ApplyAngularImpulse(acceleration);
11854
+ this.body.ApplyAngularImpulse(acceleration * this.getInertia());
11855
+ }
11856
+
11857
+ /** Apply an instantaneous angular impulse. Changes angular velocity by
11858
+ * impulse / inertia immediately.
11859
+ * @param {number} impulse */
11860
+ applyAngularImpulse(impulse)
11861
+ {
11862
+ this.setAwake();
11863
+ this.body.ApplyAngularImpulse(impulse);
11619
11864
  }
11620
11865
 
11621
11866
  ///////////////////////////////////////////////////////////////////////////////
@@ -11861,7 +12106,7 @@ class Box2dJoint
11861
12106
 
11862
12107
  /** Check if the connected bodies should collide
11863
12108
  * @return {boolean} */
11864
- getCollideConnected() { return this.box2dJoint.getCollideConnected();}
12109
+ getCollideConnected() { return this.box2dJoint.GetCollideConnected();}
11865
12110
 
11866
12111
  /** Check if either connected body is active
11867
12112
  * @return {boolean} */
@@ -13474,13 +13719,13 @@ const Ease =
13474
13719
  * @param {number} x
13475
13720
  * @returns {number}
13476
13721
  * @memberof TweenSystem */
13477
- SINE: (x) => 1 - Math.cos(x * (Math.PI / 2)),
13722
+ SINE: (x) => 1 - cos(x * (PI / 2)),
13478
13723
 
13479
13724
  /** Circular ease-in curve.
13480
13725
  * @param {number} x
13481
13726
  * @returns {number}
13482
13727
  * @memberof TweenSystem */
13483
- CIRC: (x) => 1 - Math.sqrt(1 - x * x),
13728
+ CIRC: (x) => 1 - (1 - x * x)**.5,
13484
13729
 
13485
13730
  /** Exponential ease-in curve (`2^(10x-10)`).
13486
13731
  * @param {number} x
@@ -13499,7 +13744,7 @@ const Ease =
13499
13744
  * @returns {number}
13500
13745
  * @memberof TweenSystem */
13501
13746
  ELASTIC: (x) =>
13502
- -(2 ** (10 * x - 10)) * Math.sin(((37 - 40 * x) * Math.PI) / 6),
13747
+ -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
13503
13748
 
13504
13749
  /** Spring-like ease-out: oscillates outward after passing the target.
13505
13750
  * @param {number} x
@@ -13507,8 +13752,8 @@ const Ease =
13507
13752
  * @memberof TweenSystem */
13508
13753
  SPRING: (x) =>
13509
13754
  1 -
13510
- (Math.sin(Math.PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
13511
- Math.pow(x, 2.2) +
13755
+ (sin(PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
13756
+ x ** 2.2 +
13512
13757
  (1 - x)) *
13513
13758
  (1.0 + 1.2 * x),
13514
13759
 
@@ -13612,7 +13857,7 @@ const Ease =
13612
13857
  {
13613
13858
  const tMid = (t0 + t1) / 2;
13614
13859
  const [bx, by] = curve(tMid);
13615
- if (Math.abs(bx - x) < 1e-5) return by;
13860
+ if (abs(bx - x) < 1e-5) return by;
13616
13861
  if (bx < x) t0 = tMid; else t1 = tMid;
13617
13862
  }
13618
13863
  return curve((t0 + t1) / 2)[1];
@@ -13744,6 +13989,763 @@ function tweenStopAll()
13744
13989
  // Register with the engine so tweens auto-advance.
13745
13990
  engineAddPlugin(tweenUpdate);
13746
13991
 
13992
+ /**
13993
+ * LittleJS PathFinder Plugin
13994
+ * - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths
13995
+ * - Works directly on a TileCollisionLayer, or override isWalkable/getCost for any grid
13996
+ * - Debug visualization via engine debug primitives (stripped in release builds)
13997
+ * - Port of frankforce.com pathFindingBase.cpp (2018)
13998
+ * @namespace PathFinding
13999
+ */
14000
+
14001
+ ///////////////////////////////////////////////////////////////////////////////
14002
+
14003
+ // Diagonal step cost — pre-computed for the A* expansion inner loop.
14004
+ const PATHFINDER_DIAGONAL_COST = Math.SQRT2;
14005
+
14006
+ // Shared 1x1 size vector for per-tile debugRect calls. debugRect copies the
14007
+ // argument internally, so reusing one instance is safe.
14008
+ const PATHFINDER_TILE_VEC = vec2(1);
14009
+
14010
+ ///////////////////////////////////////////////////////////////////////////////
14011
+
14012
+ /** A single grid cell tracked by the pathfinder. Allocated once per cell at
14013
+ * PathFinder construction; reset (not reallocated) at the start of every
14014
+ * findPath call.
14015
+ * @memberof PathFinding */
14016
+ class PathFinderNode
14017
+ {
14018
+ /** @param {number} x - Tile x
14019
+ * @param {number} y - Tile y */
14020
+ constructor(x, y)
14021
+ {
14022
+ /** @property {Vector2} - Tile coords (integer) */
14023
+ this.pos = vec2(x, y);
14024
+ /** @property {Vector2} - World-space center of this tile (set by buildNodeData) */
14025
+ this.posWorld = vec2();
14026
+ /** @property {boolean} - True if this cell is passable (cleared each findPath call) */
14027
+ this.walkable = false;
14028
+ /** @property {number} - Extra cost added to A* G-score for stepping on this cell */
14029
+ this.cost = 0;
14030
+ /** @property {number} - A* G-score: actual cost from start to this node */
14031
+ this.g = 0;
14032
+ /** @property {number} - A* F-score: G + heuristic */
14033
+ this.f = 0;
14034
+ /** @property {PathFinderNode|null} - Parent for path reconstruction */
14035
+ this.parent = null;
14036
+ /** @property {boolean} - In the A* open list */
14037
+ this.isOpen = false;
14038
+ /** @property {boolean} - In the A* closed list */
14039
+ this.isClosed = false;
14040
+ }
14041
+
14042
+ /** Reset per-search state (called at the start of buildNodeData). */
14043
+ reset()
14044
+ {
14045
+ this.walkable = false;
14046
+ this.cost = 0;
14047
+ this.g = 0;
14048
+ this.f = 0;
14049
+ this.parent = null;
14050
+ this.isOpen = false;
14051
+ this.isClosed = false;
14052
+ }
14053
+
14054
+ /** True if walkable and not blocked by cost. */
14055
+ isClear()
14056
+ {
14057
+ return this.walkable && this.cost === 0;
14058
+ }
14059
+ }
14060
+
14061
+ ///////////////////////////////////////////////////////////////////////////////
14062
+
14063
+ /** Grid pathfinder using A* with two optional smoothing passes.
14064
+ * @memberof PathFinding
14065
+ * @example
14066
+ * // Tile-layer driven (most common):
14067
+ * const pf = new PathFinder(myTileCollisionLayer);
14068
+ * const path = pf.findPath(player.pos, mousePos);
14069
+ *
14070
+ * // Bare grid with custom walkability:
14071
+ * const pf = new PathFinder(vec2(50, 50));
14072
+ * pf.isWalkable = (x, y) => myGrid[y*50 + x] === 0;
14073
+ */
14074
+ class PathFinder
14075
+ {
14076
+ /** @param {TileCollisionLayer|Vector2} source - Either a TileCollisionLayer
14077
+ * (size and walkability auto-derived) or a Vector2 grid size (user
14078
+ * overrides isWalkable). */
14079
+ constructor(source)
14080
+ {
14081
+ // Accept either a Vector2 size or a TileCollisionLayer (which has a .size).
14082
+ // We don't import TileCollisionLayer to avoid coupling; we duck-type on
14083
+ // .size + .getCollisionData.
14084
+ if (isVector2(source))
14085
+ {
14086
+ this.size = source.floor();
14087
+ this.tileLayer = undefined;
14088
+ }
14089
+ else
14090
+ {
14091
+ ASSERT(source && isVector2(source.size) && typeof source.getCollisionData === 'function',
14092
+ 'PathFinder requires a Vector2 size or a TileCollisionLayer');
14093
+ this.size = source.size;
14094
+ this.tileLayer = source;
14095
+ }
14096
+
14097
+ // Tunables (public, freely re-assignable).
14098
+ this.heuristicWeight = 1;
14099
+ this.maxLoop = 500;
14100
+ this.smoothPath = true;
14101
+ this.debug = false;
14102
+ this.debugTime = 2;
14103
+
14104
+ // Pre-allocate the node array — one node per tile, reused across calls.
14105
+ this.nodes = new Array(this.size.x * this.size.y);
14106
+ for (let y = 0; y < this.size.y; ++y)
14107
+ for (let x = 0; x < this.size.x; ++x)
14108
+ this.nodes[x + y * this.size.x] = new PathFinderNode(x, y);
14109
+
14110
+ // Scratch Vector2 reused to avoid allocations in the isWalkable hot path.
14111
+ this.collisionScratch = vec2();
14112
+ }
14113
+
14114
+ /** Default walkability: if a tile layer was provided, returns true when the
14115
+ * cell has no solid collision data; otherwise returns true. Override on
14116
+ * the instance or via a subclass.
14117
+ * @param {number} x - Tile x
14118
+ * @param {number} y - Tile y
14119
+ * @returns {boolean} */
14120
+ isWalkable(x, y)
14121
+ {
14122
+ if (!this.tileLayer) return true;
14123
+ return !this.tileLayer.getCollisionData(this.collisionScratch.set(x, y));
14124
+ }
14125
+
14126
+ /** Default extra cost for stepping on a cell. Returns 0 (free) by default.
14127
+ * Override to add cost-weighted terrain (mud, swamp, etc).
14128
+ * @param {number} x - Tile x
14129
+ * @param {number} y - Tile y
14130
+ * @returns {number} */
14131
+ getCost(x, y)
14132
+ {
14133
+ return 0;
14134
+ }
14135
+
14136
+ /** Get the node at tile coords, or null if out of bounds.
14137
+ * @param {number} x
14138
+ * @param {number} y
14139
+ * @returns {PathFinderNode|null} */
14140
+ getNode(x, y)
14141
+ {
14142
+ if (x < 0 || y < 0 || x >= this.size.x || y >= this.size.y) return null;
14143
+ return this.nodes[x + y * this.size.x];
14144
+ }
14145
+
14146
+ /** Convert a world-space position to integer tile coords (no clamping).
14147
+ * @param {Vector2} worldPos
14148
+ * @returns {Vector2}
14149
+ * @memberof PathFinding */
14150
+ worldToTile(worldPos)
14151
+ {
14152
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
14153
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
14154
+ return vec2(floor(worldPos.x - ox), floor(worldPos.y - oy));
14155
+ }
14156
+
14157
+ /** Convert integer tile coords to the world-space center of that tile.
14158
+ * @param {number} x
14159
+ * @param {number} y
14160
+ * @returns {Vector2}
14161
+ * @memberof PathFinding */
14162
+ tileToWorld(x, y)
14163
+ {
14164
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
14165
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
14166
+ return vec2(x + 0.5 + ox, y + 0.5 + oy);
14167
+ }
14168
+
14169
+ /** Reset all nodes and re-populate walkable / cost / posWorld from the
14170
+ * current isWalkable / getCost overrides. Called at the start of
14171
+ * findPath; exposed so tests and tooling can drive it directly.
14172
+ * @private */
14173
+ buildNodeData()
14174
+ {
14175
+ const w = this.size.x;
14176
+ const h = this.size.y;
14177
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
14178
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
14179
+ for (let y = 0; y < h; ++y)
14180
+ for (let x = 0; x < w; ++x)
14181
+ {
14182
+ const node = this.nodes[x + y * w];
14183
+ node.reset();
14184
+ const walkable = !!this.isWalkable(x, y);
14185
+ const cost = walkable ? max(0, this.getCost(x, y)) : 0;
14186
+ node.walkable = walkable;
14187
+ node.cost = cost;
14188
+ node.posWorld.set(x + 0.5 + ox, y + 0.5 + oy);
14189
+
14190
+ if (this.debug && this.debugTime > 0)
14191
+ {
14192
+ if (!walkable)
14193
+ debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, 0.25), this.debugTime);
14194
+ else if (cost > 0)
14195
+ debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, min(0.2, cost * 0.05)), this.debugTime);
14196
+ }
14197
+ }
14198
+ }
14199
+
14200
+ /** Core A* search loop. Expects buildNodeData() to have been called first.
14201
+ * Marks node.parent for path reconstruction. Returns true if endNode was
14202
+ * reached; false on disconnected goal or maxLoop exhaustion.
14203
+ * @param {PathFinderNode} startNode
14204
+ * @param {PathFinderNode} endNode
14205
+ * @returns {boolean}
14206
+ * @private */
14207
+ aStarSearch(startNode, endNode)
14208
+ {
14209
+ ASSERT(startNode && endNode, 'aStarSearch needs both endpoints');
14210
+ ASSERT(startNode !== endNode, 'aStarSearch: start and end must differ — caller should handle trivial case');
14211
+ ASSERT(startNode.walkable && endNode.walkable, 'aStarSearch: endpoints must be walkable');
14212
+
14213
+ const openList = [startNode];
14214
+ startNode.isOpen = true;
14215
+ let loopCount = 0;
14216
+
14217
+ while (openList.length > 0)
14218
+ {
14219
+ // Find the open node with the smallest f score (linear scan).
14220
+ // Same as the C++ — fine up to a few thousand nodes.
14221
+ let bestIndex = 0;
14222
+ let bestF = openList[0].f;
14223
+ for (let i = 1; i < openList.length; ++i)
14224
+ {
14225
+ if (openList[i].f < bestF)
14226
+ {
14227
+ bestF = openList[i].f;
14228
+ bestIndex = i;
14229
+ }
14230
+ }
14231
+ const current = openList[bestIndex];
14232
+
14233
+ if (current === endNode) break;
14234
+ if (++loopCount > this.maxLoop) break;
14235
+
14236
+ // Move current from open to closed.
14237
+ current.isOpen = false;
14238
+ openList.splice(bestIndex, 1);
14239
+ current.isClosed = true;
14240
+
14241
+ if (this.debug && this.debugTime > 0)
14242
+ debugRect(current.posWorld, PATHFINDER_TILE_VEC, rgb(1, 1, 1, 0.05), this.debugTime);
14243
+
14244
+ // Expand all 8 neighbors.
14245
+ for (let dy = -1; dy <= 1; ++dy)
14246
+ for (let dx = -1; dx <= 1; ++dx)
14247
+ {
14248
+ if (dx === 0 && dy === 0) continue;
14249
+ const neighbor = this.getNode(current.pos.x + dx, current.pos.y + dy);
14250
+ if (!neighbor || !neighbor.walkable || neighbor.isClosed) continue;
14251
+
14252
+ let stepCost = 1;
14253
+ if (dx !== 0 && dy !== 0)
14254
+ {
14255
+ // Diagonal step: refuse if either cardinal neighbor is
14256
+ // blocked or has cost. Prevents cutting through corners.
14257
+ const card1 = this.getNode(current.pos.x + dx, current.pos.y);
14258
+ if (!card1 || card1.cost > 0 || !card1.walkable) continue;
14259
+ const card2 = this.getNode(current.pos.x, current.pos.y + dy);
14260
+ if (!card2 || card2.cost > 0 || !card2.walkable) continue;
14261
+ stepCost = PATHFINDER_DIAGONAL_COST;
14262
+ }
14263
+
14264
+ const tentativeG = current.g + stepCost + neighbor.cost;
14265
+ if (!neighbor.isOpen)
14266
+ {
14267
+ neighbor.isOpen = true;
14268
+ openList.push(neighbor);
14269
+ }
14270
+ else if (tentativeG >= neighbor.g)
14271
+ {
14272
+ continue;
14273
+ }
14274
+
14275
+ // Best path so far through neighbor — record it.
14276
+ neighbor.parent = current;
14277
+ neighbor.g = tentativeG;
14278
+ const gdx = endNode.pos.x - neighbor.pos.x;
14279
+ const gdy = endNode.pos.y - neighbor.pos.y;
14280
+ neighbor.f = neighbor.g + (gdx * gdx + gdy * gdy) * this.heuristicWeight;
14281
+ }
14282
+ }
14283
+
14284
+ return endNode.parent !== null;
14285
+ }
14286
+
14287
+ /** Find the clear (walkable, zero-cost) node closest to the given world
14288
+ * position. Spirals outward in expanding boxes until a clear node is
14289
+ * found or the search range is exhausted. Useful for snapping a click
14290
+ * or NPC spawn position to the nearest open tile.
14291
+ *
14292
+ * By default, calls `buildNodeData()` first so it works correctly on a
14293
+ * fresh PathFinder. If you're calling it many times in a row with
14294
+ * unchanged walkability, pass `rebuild=false` and call `buildNodeData()`
14295
+ * once externally to avoid redundant work.
14296
+ * @param {Vector2} worldPos
14297
+ * @param {number} [searchRange=10] - Max box-radius in tiles
14298
+ * @param {boolean} [rebuild=true] - Whether to call buildNodeData first
14299
+ * @returns {PathFinderNode|null}
14300
+ * @memberof PathFinding */
14301
+ getNearestClearNode(worldPos, searchRange = 10, rebuild = true)
14302
+ {
14303
+ ASSERT(isVector2(worldPos), 'worldPos must be a Vector2');
14304
+ if (rebuild) this.buildNodeData();
14305
+
14306
+ // Inline worldToTile to avoid a Vector2 allocation per call.
14307
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
14308
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
14309
+ const centerX = floor(worldPos.x - ox);
14310
+ const centerY = floor(worldPos.y - oy);
14311
+
14312
+ for (let offset = 0; offset <= searchRange; ++offset)
14313
+ {
14314
+ let nearest = null;
14315
+ let nearestDistSq = 0;
14316
+
14317
+ for (let dy = -offset; dy <= offset; ++dy)
14318
+ for (let dx = -offset; dx <= offset; ++dx)
14319
+ {
14320
+ // Only scan the perimeter of the current ring (skip the
14321
+ // interior we've already searched in earlier iterations).
14322
+ if (offset > 0 && abs(dx) !== offset && abs(dy) !== offset)
14323
+ continue;
14324
+
14325
+ const node = this.getNode(centerX + dx, centerY + dy);
14326
+ if (!node || !node.isClear()) continue;
14327
+
14328
+ const ddx = node.posWorld.x - worldPos.x;
14329
+ const ddy = node.posWorld.y - worldPos.y;
14330
+ const distSq = ddx * ddx + ddy * ddy;
14331
+ if (!nearest || distSq < nearestDistSq)
14332
+ {
14333
+ nearest = node;
14334
+ nearestDistSq = distSq;
14335
+ }
14336
+ }
14337
+ if (nearest) return nearest;
14338
+ }
14339
+ return null;
14340
+ }
14341
+
14342
+ /** Smooth a node path by removing redundant turns and tightening corners
14343
+ * where a grid-aligned diagonal is clear. Modifies the path in place.
14344
+ * Stays on the grid — does not introduce off-tile-center points.
14345
+ * Port of ShortenPath() in pathFinding.cpp.
14346
+ * @param {PathFinderNode[]} path
14347
+ * @private */
14348
+ smoothPathCorners(path)
14349
+ {
14350
+ if (path.length <= 2) return;
14351
+
14352
+ let i = 1;
14353
+ while (i < path.length - 1)
14354
+ {
14355
+ const prev = path[i - 1];
14356
+ const node = path[i];
14357
+ const next = path[i + 1];
14358
+
14359
+ const dx = next.pos.x - prev.pos.x;
14360
+ const dy = next.pos.y - prev.pos.y;
14361
+ const lenSq = dx * dx + dy * dy;
14362
+
14363
+ // dx,dy is the prev-to-current step direction; needed for the
14364
+ // 135° "mostly vertical/horizontal" disambiguation.
14365
+ const stepDx = node.pos.x - prev.pos.x;
14366
+ const stepDy = node.pos.y - prev.pos.y;
14367
+ const stepDxNext = next.pos.x - node.pos.x;
14368
+ const stepDyNext = next.pos.y - node.pos.y;
14369
+
14370
+ if (lenSq === 1)
14371
+ {
14372
+ // 45° angle — middle node is off the straight line. Drop it.
14373
+ if (this.debug && this.debugTime > 0)
14374
+ debugCircle(node.posWorld, 0.3, rgb(0.5, 0, 0.5, 0.5), this.debugTime);
14375
+ path.splice(i, 1);
14376
+ i = max(1, i - 1);
14377
+ continue;
14378
+ }
14379
+ else if (lenSq === 2)
14380
+ {
14381
+ // 90° corner. Check the alternative-diagonal cell.
14382
+ if (this.debug && this.debugTime > 0)
14383
+ debugCircle(node.posWorld, 0.3, rgb(1, 0, 0, 0.5), this.debugTime);
14384
+
14385
+ let sx, sy;
14386
+ if (prev.pos.y === node.pos.y && next.pos.x === node.pos.x)
14387
+ { sx = prev.pos.x; sy = next.pos.y; }
14388
+ else
14389
+ { sx = next.pos.x; sy = prev.pos.y; }
14390
+
14391
+ const shortcut = this.getNode(sx, sy);
14392
+ if (shortcut && shortcut.isClear())
14393
+ {
14394
+ path.splice(i, 1);
14395
+ i = max(1, i - 1);
14396
+ continue;
14397
+ }
14398
+ }
14399
+ else if (lenSq === 5)
14400
+ {
14401
+ // 135° angle (a knight's-move offset). Try to relocate the
14402
+ // middle node to whichever of two candidate cells is closer
14403
+ // to prev-of-prev, and only if the corner cut is also clear.
14404
+ if (this.debug && this.debugTime > 0)
14405
+ debugCircle(node.posWorld, 0.3, rgb(1, 1, 0, 0.5), this.debugTime);
14406
+
14407
+ const prevPrev = i >= 2 ? path[i - 2] : prev;
14408
+ let s1x, s1y, s2x, s2y;
14409
+ if (stepDx === 0 || stepDxNext === 0)
14410
+ {
14411
+ // mostly vertical
14412
+ s1x = next.pos.x; s1y = node.pos.y;
14413
+ s2x = prev.pos.x; s2y = node.pos.y;
14414
+ }
14415
+ else
14416
+ {
14417
+ // mostly horizontal
14418
+ s1x = node.pos.x; s1y = next.pos.y;
14419
+ s2x = node.pos.x; s2y = prev.pos.y;
14420
+ }
14421
+ const dd1x = s1x - prevPrev.pos.x;
14422
+ const dd1y = s1y - prevPrev.pos.y;
14423
+ const dd2x = s2x - prevPrev.pos.x;
14424
+ const dd2y = s2y - prevPrev.pos.y;
14425
+ const dist1Sq = dd1x * dd1x + dd1y * dd1y;
14426
+ const dist2Sq = dd2x * dd2x + dd2y * dd2y;
14427
+ const sx = dist1Sq < dist2Sq ? s1x : s1x === s2x && s1y === s2y ? s1x : s2x;
14428
+ const sy = dist1Sq < dist2Sq ? s1y : s1x === s2x && s1y === s2y ? s1y : s2y;
14429
+
14430
+ const shortcut = this.getNode(sx, sy);
14431
+ if (shortcut && shortcut !== node && shortcut.isClear())
14432
+ {
14433
+ // Also check the cut-corner cell is clear.
14434
+ const ccx = next.pos.x + s2x - s1x;
14435
+ const ccy = next.pos.y + s2y - s1y;
14436
+ const cutCorner = this.getNode(ccx, ccy);
14437
+ if (cutCorner && cutCorner.isClear())
14438
+ {
14439
+ path[i] = shortcut;
14440
+ i = max(1, i - 1);
14441
+ continue;
14442
+ }
14443
+ }
14444
+ }
14445
+ else if (lenSq === 4 || lenSq === 8)
14446
+ {
14447
+ // Straight line or a 1-cell bump.
14448
+ if (this.debug && this.debugTime > 0)
14449
+ debugCircle(node.posWorld, 0.3, rgb(0, 1, 0, 0.5), this.debugTime);
14450
+
14451
+ if (stepDx === stepDxNext && stepDy === stepDyNext)
14452
+ {
14453
+ // Truly straight — nothing to do, advance.
14454
+ ++i;
14455
+ continue;
14456
+ }
14457
+ else
14458
+ {
14459
+ // Bump — try to flatten via the in-line cell.
14460
+ let sx, sy;
14461
+ if (prev.pos.y === next.pos.y)
14462
+ { sx = node.pos.x; sy = prev.pos.y; }
14463
+ else
14464
+ { sx = prev.pos.x; sy = node.pos.y; }
14465
+ const shortcut = this.getNode(sx, sy);
14466
+ if (shortcut && shortcut.isClear())
14467
+ {
14468
+ path[i] = shortcut;
14469
+ i = max(1, i - 1);
14470
+ continue;
14471
+ }
14472
+ }
14473
+ }
14474
+
14475
+ ++i;
14476
+ }
14477
+ }
14478
+
14479
+ /** Smooth a node path via line-of-sight ("string pulling"). Walks the
14480
+ * input path collapsing runs of nodes into straight segments whenever
14481
+ * isLineClear permits, so the result can leave grid centers and cut
14482
+ * cleanly across open spaces.
14483
+ *
14484
+ * Bails (leaves the path unchanged) if any node has nonzero cost — a
14485
+ * straight geometric shortcut can't be trusted to be the lowest-cost
14486
+ * route when cost-weighted terrain is in play.
14487
+ *
14488
+ * Port of ShortenPath2() in pathFinding.cpp.
14489
+ * @param {PathFinderNode[]} path
14490
+ * @private */
14491
+ smoothPathStringPull(path)
14492
+ {
14493
+ if (path.length <= 2) return;
14494
+ for (const n of path)
14495
+ {
14496
+ if (!n.isClear()) return;
14497
+ }
14498
+
14499
+ const original = path.slice();
14500
+ path.length = 0;
14501
+ path.push(original[0]);
14502
+ let searchIndex = 0;
14503
+
14504
+ for (let i = 1; i < original.length; ++i)
14505
+ {
14506
+ const node = original[i];
14507
+
14508
+ // Skip if node is collinear with the search-window start and the
14509
+ // previous node — it adds no information. Note: a == b is the
14510
+ // degenerate i=1, searchIndex=0 case; skip the test then.
14511
+ {
14512
+ const a = original[searchIndex];
14513
+ const b = original[i - 1];
14514
+ if (a !== b)
14515
+ {
14516
+ const cross =
14517
+ (b.pos.x - a.pos.x) * (node.pos.y - a.pos.y) -
14518
+ (b.pos.y - a.pos.y) * (node.pos.x - a.pos.x);
14519
+ if (cross === 0) continue;
14520
+ }
14521
+ }
14522
+
14523
+ if (!this.isLineClear(node.pos, path[path.length - 1].pos))
14524
+ {
14525
+ // Look ahead — if any later node has a clear shot to the
14526
+ // back of our new path, skip this node and try later.
14527
+ let foundClearAfter = false;
14528
+ for (let j = i + 1; j < original.length; ++j)
14529
+ {
14530
+ if (this.isLineClear(original[j].pos, path[path.length - 1].pos))
14531
+ {
14532
+ foundClearAfter = true;
14533
+ break;
14534
+ }
14535
+ }
14536
+ if (foundClearAfter)
14537
+ {
14538
+ if (this.debug && this.debugTime > 0)
14539
+ debugLine(node.posWorld, path[path.length - 1].posWorld, rgb(0, 0, 1, 0.3), 0.02, this.debugTime);
14540
+ continue;
14541
+ }
14542
+
14543
+ // No clear line ahead — fall back to the last waypoint we did
14544
+ // have a clear line to. searchIndex tracks our scan position.
14545
+ for (; searchIndex < original.length; ++searchIndex)
14546
+ {
14547
+ const cand = original[searchIndex];
14548
+ if (this.isLineClear(node.pos, cand.pos))
14549
+ {
14550
+ path.push(cand);
14551
+ i = searchIndex;
14552
+ break;
14553
+ }
14554
+ }
14555
+ ASSERT(searchIndex < original.length, 'smoothPathStringPull: ran out of candidates');
14556
+ }
14557
+ }
14558
+
14559
+ path.push(original[original.length - 1]);
14560
+ }
14561
+
14562
+ /** Lookup helper: true when the node at tile coords (x, y) is in-bounds
14563
+ * and clear (walkable, zero-cost). Used by isLineClear's hot path.
14564
+ * @param {number} x
14565
+ * @param {number} y
14566
+ * @returns {boolean}
14567
+ * @private */
14568
+ isNodeClear(x, y)
14569
+ {
14570
+ const n = this.getNode(x, y);
14571
+ return n !== null && n.isClear();
14572
+ }
14573
+
14574
+ /** Check that the line between two tile-coord endpoints stays entirely
14575
+ * inside walkable, zero-cost cells. Stricter than just sampling along
14576
+ * the line — it also checks the diagonal-corner-adjacent cells so the
14577
+ * line can never "scrape past" a wall corner.
14578
+ *
14579
+ * Both endpoints must themselves be clear (asserted in debug). Port of
14580
+ * CheckLine() in pathFinding.cpp.
14581
+ * @param {Vector2} startPos - Tile coords
14582
+ * @param {Vector2} endPos - Tile coords
14583
+ * @returns {boolean}
14584
+ * @private */
14585
+ isLineClear(startPos, endPos)
14586
+ {
14587
+ ASSERT(isVector2(startPos) && isVector2(endPos), 'isLineClear needs Vector2 endpoints');
14588
+ ASSERT(this.isNodeClear(startPos.x, startPos.y) && this.isNodeClear(endPos.x, endPos.y),
14589
+ 'isLineClear endpoints must be in-bounds and clear');
14590
+
14591
+ const dx = endPos.x - startPos.x;
14592
+ const dy = endPos.y - startPos.y;
14593
+ const adx = abs(dx);
14594
+ const ady = abs(dy);
14595
+ const sx = sign(dx);
14596
+ const sy = sign(dy);
14597
+ let x = startPos.x;
14598
+ let y = startPos.y;
14599
+
14600
+ if (ady === adx)
14601
+ {
14602
+ // Pure diagonal.
14603
+ while (x !== endPos.x)
14604
+ {
14605
+ if (x !== startPos.x)
14606
+ {
14607
+ if (!this.isNodeClear(x, y)) return false;
14608
+ if (!this.isNodeClear(x, y - sy)) return false;
14609
+ }
14610
+ if (!this.isNodeClear(x, y + sy)) return false;
14611
+ x += sx;
14612
+ y += sy;
14613
+ }
14614
+ if (!this.isNodeClear(endPos.x, endPos.y - sy)) return false;
14615
+ }
14616
+ else if (ady < adx)
14617
+ {
14618
+ // Mostly horizontal.
14619
+ if (dy === 0)
14620
+ {
14621
+ // Purely horizontal.
14622
+ x += sx;
14623
+ while (x !== endPos.x)
14624
+ {
14625
+ if (!this.isNodeClear(x, y)) return false;
14626
+ x += sx;
14627
+ }
14628
+ }
14629
+ else
14630
+ {
14631
+ let lastY = startPos.y;
14632
+ while (x !== endPos.x)
14633
+ {
14634
+ y = startPos.y + Math.trunc((dy * (x - startPos.x)) / dx);
14635
+ if (lastY !== y)
14636
+ {
14637
+ if (!this.isNodeClear(x - sx, y + sy)) return false;
14638
+ if (!this.isNodeClear(x, y - sy)) return false;
14639
+ }
14640
+ lastY = y;
14641
+ if (x !== startPos.x)
14642
+ {
14643
+ if (!this.isNodeClear(x, y)) return false;
14644
+ }
14645
+ y += sy;
14646
+ if (!this.isNodeClear(x, y)) return false;
14647
+ x += sx;
14648
+ }
14649
+ const finalY = endPos.y - sy;
14650
+ if (!this.isNodeClear(endPos.x, finalY)) return false;
14651
+ }
14652
+ }
14653
+ else
14654
+ {
14655
+ // Mostly vertical.
14656
+ if (dx === 0)
14657
+ {
14658
+ y += sy;
14659
+ while (y !== endPos.y)
14660
+ {
14661
+ if (!this.isNodeClear(x, y)) return false;
14662
+ y += sy;
14663
+ }
14664
+ }
14665
+ else
14666
+ {
14667
+ let lastX = startPos.x;
14668
+ while (y !== endPos.y)
14669
+ {
14670
+ x = startPos.x + Math.trunc((dx * (y - startPos.y)) / dy);
14671
+ if (lastX !== x)
14672
+ {
14673
+ if (!this.isNodeClear(x + sx, y - sy)) return false;
14674
+ if (!this.isNodeClear(x - sx, y)) return false;
14675
+ }
14676
+ lastX = x;
14677
+ if (y !== startPos.y)
14678
+ {
14679
+ if (!this.isNodeClear(x, y)) return false;
14680
+ }
14681
+ x += sx;
14682
+ if (!this.isNodeClear(x, y)) return false;
14683
+ y += sy;
14684
+ }
14685
+ const finalX = endPos.x - sx;
14686
+ if (!this.isNodeClear(finalX, endPos.y)) return false;
14687
+ }
14688
+ }
14689
+ return true;
14690
+ }
14691
+
14692
+ /** Find a path from startPos to endPos in world space. Returns an array
14693
+ * of world-space Vector2 points; empty array if no path exists.
14694
+ *
14695
+ * Start and end are snapped to the nearest walkable tile via
14696
+ * getNearestClearNode. Intermediate points are tile centers unless the
14697
+ * string-pulling smoothing pass moves them off-grid.
14698
+ * @param {Vector2} startPos - World-space start
14699
+ * @param {Vector2} endPos - World-space end
14700
+ * @returns {Vector2[]}
14701
+ * @memberof PathFinding */
14702
+ findPath(startPos, endPos)
14703
+ {
14704
+ ASSERT(isVector2(startPos) && isVector2(endPos), 'findPath needs Vector2 endpoints');
14705
+
14706
+ this.buildNodeData();
14707
+
14708
+ // rebuild=false because we just built — avoid redundant work per snap.
14709
+ const startNode = this.getNearestClearNode(startPos, 10, false);
14710
+ const endNode = this.getNearestClearNode(endPos, 10, false);
14711
+ if (!startNode || !endNode) return [];
14712
+
14713
+ // Trivial case: start and end snapped to the same tile.
14714
+ if (startNode === endNode) return [startNode.posWorld.copy()];
14715
+
14716
+ if (!this.aStarSearch(startNode, endNode)) return [];
14717
+
14718
+ // Walk back from endNode via parent pointers, then reverse — cheaper
14719
+ // than unshifting on every step.
14720
+ const nodePath = [];
14721
+ for (let n = endNode; n; n = n.parent)
14722
+ nodePath.push(n);
14723
+ nodePath.reverse();
14724
+
14725
+ if (this.smoothPath)
14726
+ {
14727
+ this.smoothPathCorners(nodePath);
14728
+ this.smoothPathStringPull(nodePath);
14729
+ }
14730
+
14731
+ // Convert to world-space Vector2 path. Return copies, not live node
14732
+ // references — callers shouldn't be able to mutate the grid.
14733
+ const result = nodePath.map(n => n.posWorld.copy());
14734
+
14735
+ if (this.debug && this.debugTime > 0 && result.length > 0)
14736
+ {
14737
+ for (let i = 1; i < result.length; ++i)
14738
+ debugLine(result[i - 1], result[i], RED, 0.1, this.debugTime);
14739
+ for (const p of result)
14740
+ debugCircle(p, 0.5, rgb(1, 0, 0, 0.3), this.debugTime);
14741
+ debugCircle(result[0], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
14742
+ debugCircle(result[result.length - 1], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
14743
+ }
14744
+
14745
+ return result;
14746
+ }
14747
+ }
14748
+
13747
14749
 
13748
14750
  /**
13749
14751
  * LittleJS Module Export
@@ -13758,6 +14760,7 @@ export
13758
14760
  frameRate,
13759
14761
  timeDelta,
13760
14762
  engineObjects,
14763
+ engineObjectsCollide,
13761
14764
  frame,
13762
14765
  time,
13763
14766
  timeReal,
@@ -13780,6 +14783,7 @@ export
13780
14783
  // Debug
13781
14784
  ASSERT,
13782
14785
  LOG,
14786
+ debugPointSize,
13783
14787
  debugRect,
13784
14788
  debugPoly,
13785
14789
  debugCircle,
@@ -13824,11 +14828,14 @@ export
13824
14828
  gravity,
13825
14829
  particleEmitRateScale,
13826
14830
  glEnable,
14831
+ glCircleSides,
13827
14832
  gamepadsEnable,
13828
14833
  gamepadDirectionEmulateStick,
13829
14834
  inputWASDEmulateDirection,
14835
+ touchInputEnable,
13830
14836
  touchGamepadEnable,
13831
14837
  touchGamepadCenterButtonSize,
14838
+ touchGamepadButtonCount,
13832
14839
  touchGamepadAnalog,
13833
14840
  touchGamepadSize,
13834
14841
  touchGamepadAlpha,
@@ -13838,9 +14845,6 @@ export
13838
14845
  soundVolume,
13839
14846
  soundDefaultRange,
13840
14847
  soundDefaultTaper,
13841
- medalDisplayTime,
13842
- medalDisplaySlideTime,
13843
- medalDisplaySize,
13844
14848
 
13845
14849
  // Setters for globals
13846
14850
  setCameraPos,
@@ -13872,6 +14876,7 @@ export
13872
14876
  setObjectMaxSpeed,
13873
14877
  setGravity,
13874
14878
  setParticleEmitRateScale,
14879
+ setGLCircleSides,
13875
14880
  setTouchInputEnable,
13876
14881
  setGamepadsEnable,
13877
14882
  setGamepadDirectionEmulateStick,
@@ -13887,10 +14892,6 @@ export
13887
14892
  setSoundVolume,
13888
14893
  setSoundDefaultRange,
13889
14894
  setSoundDefaultTaper,
13890
- setMedalDisplayTime,
13891
- setMedalDisplaySlideTime,
13892
- setMedalDisplaySize,
13893
- setMedalsPreventUnlock,
13894
14895
  setDebugWatermark,
13895
14896
  setDebugKey,
13896
14897
 
@@ -13920,6 +14921,7 @@ export
13920
14921
  percentLerp,
13921
14922
  smoothStep,
13922
14923
  nearestPowerOfTwo,
14924
+ isPowerOfTwo,
13923
14925
  isOverlapping,
13924
14926
  isIntersecting,
13925
14927
  lineTest,
@@ -13999,6 +15001,7 @@ export
13999
15001
  drawLineList,
14000
15002
  drawLine,
14001
15003
  drawPoly,
15004
+ drawRegularPoly,
14002
15005
  drawEllipse,
14003
15006
  drawCircle,
14004
15007
  drawCanvas2D,
@@ -14012,6 +15015,7 @@ export
14012
15015
  toggleFullscreen,
14013
15016
  setCursor,
14014
15017
  getCameraSize,
15018
+ isOnScreen,
14015
15019
 
14016
15020
  // WebGL
14017
15021
  glCanvas,
@@ -14019,6 +15023,7 @@ export
14019
15023
  glAntialias,
14020
15024
  glClearCanvas,
14021
15025
  glSetTexture,
15026
+ glSetTextureWrap,
14022
15027
  glCompileShader,
14023
15028
  glCreateProgram,
14024
15029
  glCreateTexture,
@@ -14060,6 +15065,7 @@ export
14060
15065
  gamepadWasPressed,
14061
15066
  gamepadWasReleased,
14062
15067
  gamepadStick,
15068
+ gamepadStickCount,
14063
15069
  gamepadDpad,
14064
15070
  gamepadConnected,
14065
15071
  gamepadVibrate,
@@ -14074,6 +15080,7 @@ export
14074
15080
  audioContext,
14075
15081
  audioMasterGain,
14076
15082
  audioDefaultSampleRate,
15083
+ audioIsRunning,
14077
15084
  Sound,
14078
15085
  SoundInstance,
14079
15086
  speak,
@@ -14101,11 +15108,6 @@ export
14101
15108
  ParticleEmitter,
14102
15109
  Particle,
14103
15110
 
14104
- // Medals
14105
- medals,
14106
- medalsPreventUnlock,
14107
- medalsInit,
14108
- Medal,
14109
15111
  }
14110
15112
  /**
14111
15113
  * LittleJS Module Plugins Export
@@ -14113,6 +15115,20 @@ export
14113
15115
 
14114
15116
  export
14115
15117
  {
15118
+ // Medals
15119
+ medals,
15120
+ medalsPreventUnlock,
15121
+ medalsInit,
15122
+ medalsForEach,
15123
+ Medal,
15124
+ medalDisplayTime,
15125
+ medalDisplaySlideTime,
15126
+ medalDisplaySize,
15127
+ setMedalDisplayTime,
15128
+ setMedalDisplaySlideTime,
15129
+ setMedalDisplaySize,
15130
+ setMedalsPreventUnlock,
15131
+
14116
15132
  // Newgrounds
14117
15133
  newgrounds,
14118
15134
  NewgroundsPlugin,
@@ -14138,6 +15154,7 @@ export
14138
15154
  UICheckbox,
14139
15155
  UISlider,
14140
15156
  UIVideo,
15157
+ UILayout,
14141
15158
 
14142
15159
  // Box2D Physics
14143
15160
  box2d,
@@ -14176,4 +15193,8 @@ export
14176
15193
  tweenStopAll,
14177
15194
  tweenUpdate,
14178
15195
  Ease,
15196
+
15197
+ // Path Finding
15198
+ PathFinder,
15199
+ PathFinderNode,
14179
15200
  }