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 - Release Mode
@@ -723,7 +584,7 @@ const debugPhysics = 0;
723
584
  const debugParticles = 0;
724
585
  const debugRaycast = 0;
725
586
  const debugGamepads = 0;
726
- const debugMedals = 0;
587
+ const debugPointSize = .5;
727
588
 
728
589
  // debug commands are automatically removed from the final build
729
590
  function ASSERT (){}
@@ -900,7 +761,11 @@ function percentLerp(value, percentA, percentB, lerpA, lerpB)
900
761
  * @return {number}
901
762
  * @memberof Math */
902
763
  function distanceWrap(valueA, valueB, wrapSize=1)
903
- { const d = (valueA - valueB) % wrapSize; return d*2 % wrapSize - d; }
764
+ {
765
+ ASSERT(wrapSize > 0, 'distanceWrap wrapSize must be > 0');
766
+ const d = (valueA - valueB) % wrapSize;
767
+ return d*2 % wrapSize - d;
768
+ }
904
769
 
905
770
  /** Linearly interpolates between values passed in with wrapping
906
771
  * @param {number} valueA
@@ -1217,6 +1082,7 @@ class RandomGenerator
1217
1082
  * @param {number} [seed] - Starting seed or engine default seed */
1218
1083
  constructor(seed = 123456789)
1219
1084
  {
1085
+ ASSERT(seed !== 0, 'RandomGenerator seed must be non-zero (xorshift is fixed at 0)');
1220
1086
  /** @property {number} - random seed */
1221
1087
  this.seed = seed;
1222
1088
  }
@@ -1510,8 +1376,10 @@ class Vector2
1510
1376
  * @return {Vector2} */
1511
1377
  floor() { return new Vector2(floor(this.x), floor(this.y)); }
1512
1378
 
1513
- /** Returns a copy of this vector snapped to a grid
1514
- * @param {number} grid - grid size to snap to
1379
+ /** Returns a copy of this vector snapped to a grid. Note that `grid` is
1380
+ * the number of snap steps per unit (so `grid=2` snaps to halves and
1381
+ * `grid=0.5` snaps to twos), not the cell size.
1382
+ * @param {number} grid - snap steps per unit
1515
1383
  * @return {Vector2} */
1516
1384
  snap(grid)
1517
1385
  {
@@ -2081,9 +1949,14 @@ function readSaveData(saveName, defaultSaveData)
2081
1949
  {
2082
1950
  ASSERT(isString(saveName), 'loadData requires saveName string');
2083
1951
 
2084
- // replace undefined values with defaults
1952
+ // replace undefined values with defaults; tolerate corrupt JSON
2085
1953
  const data = localStorage[saveName];
2086
- const loadedData = data ? JSON.parse(data) : {};
1954
+ let loadedData = {};
1955
+ if (data)
1956
+ {
1957
+ try { loadedData = JSON.parse(data); }
1958
+ catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
1959
+ }
2087
1960
  return { ...defaultSaveData, ...loadedData };
2088
1961
  }
2089
1962
 
@@ -2418,33 +2291,6 @@ let soundDefaultRange = 40;
2418
2291
  * @memberof Settings */
2419
2292
  let soundDefaultTaper = .7;
2420
2293
 
2421
- ///////////////////////////////////////////////////////////////////////////////
2422
- // Medals settings
2423
-
2424
- /** How long to show medals for in seconds
2425
- * @type {number}
2426
- * @default
2427
- * @memberof Settings */
2428
- let medalDisplayTime = 5;
2429
-
2430
- /** How quickly to slide on/off medals in seconds
2431
- * @type {number}
2432
- * @default
2433
- * @memberof Settings */
2434
- let medalDisplaySlideTime = .5;
2435
-
2436
- /** Size of medal display
2437
- * @type {Vector2}
2438
- * @default Vector2(640,80)
2439
- * @memberof Settings */
2440
- let medalDisplaySize = vec2(640, 80);
2441
-
2442
- /** Set to stop medals from being unlockable (like if cheats are enabled)
2443
- * @type {boolean}
2444
- * @default
2445
- * @memberof Settings */
2446
- let medalsPreventUnlock = false;
2447
-
2448
2294
  ///////////////////////////////////////////////////////////////////////////////
2449
2295
  // Setters for global variables
2450
2296
 
@@ -2706,26 +2552,6 @@ function setSoundDefaultRange(range) { soundDefaultRange = range; }
2706
2552
  * @memberof Settings */
2707
2553
  function setSoundDefaultTaper(taper) { soundDefaultTaper = taper; }
2708
2554
 
2709
- /** Set how long to show medals for in seconds
2710
- * @param {number} time
2711
- * @memberof Settings */
2712
- function setMedalDisplayTime(time) { medalDisplayTime = time; }
2713
-
2714
- /** Set how quickly to slide on/off medals in seconds
2715
- * @param {number} time
2716
- * @memberof Settings */
2717
- function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
2718
-
2719
- /** Set size of medal display
2720
- * @param {Vector2} size
2721
- * @memberof Settings */
2722
- function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
2723
-
2724
- /** Set to stop medals from being unlockable
2725
- * @param {boolean} preventUnlock
2726
- * @memberof Settings */
2727
- function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }
2728
-
2729
2555
  /** Set if watermark with FPS should be shown
2730
2556
  * @param {boolean} show
2731
2557
  * @memberof Debug */
@@ -2867,10 +2693,18 @@ class EngineObject
2867
2693
  const parent = this.parent;
2868
2694
  if (parent)
2869
2695
  {
2870
- // copy parent pos/angle
2696
+ // compose with parent transform inline to avoid intermediate vector allocs
2871
2697
  const mirror = parent.getMirrorSign();
2872
- this.pos = this.localPos.multiply(vec2(mirror,1)).rotate(parent.angle).add(parent.pos);
2873
- this.angle = mirror*this.localAngle + parent.angle;
2698
+ const lp = this.localPos, pp = parent.pos;
2699
+ const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
2700
+ if (pa)
2701
+ {
2702
+ const c = cos(-pa), s = sin(-pa);
2703
+ this.pos = new Vector2(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
2704
+ }
2705
+ else
2706
+ this.pos = new Vector2(lx + pp.x, ly + pp.y);
2707
+ this.angle = mirror*this.localAngle + pa;
2874
2708
  }
2875
2709
 
2876
2710
  // update children
@@ -3236,8 +3070,6 @@ class EngineObject
3236
3070
  * @return {string} */
3237
3071
  toString()
3238
3072
  {
3239
- if (!debug) return;
3240
-
3241
3073
  let text = 'type = ' + this.constructor.name;
3242
3074
  if (this.pos.x || this.pos.y)
3243
3075
  text += '\npos = ' + this.pos;
@@ -3487,8 +3319,9 @@ class TextureInfo
3487
3319
  * Create a TextureInfo, called automatically by the engine
3488
3320
  * @param {HTMLImageElement|OffscreenCanvas} image
3489
3321
  * @param {boolean} [useWebGL] - Should use WebGL if available?
3322
+ * @param {boolean} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)?
3490
3323
  */
3491
- constructor(image, useWebGL=true)
3324
+ constructor(image, useWebGL=true, wrap=false)
3492
3325
  {
3493
3326
  /** @property {HTMLImageElement|OffscreenCanvas} - image source */
3494
3327
  this.image = image;
@@ -3498,6 +3331,8 @@ class TextureInfo
3498
3331
  this.sizeInverse = image ? vec2(1/image.width, 1/image.height) : vec2();
3499
3332
  /** @property {WebGLTexture} - WebGL texture */
3500
3333
  this.glTexture = undefined;
3334
+ /** @property {boolean} - true for REPEAT wrap mode, false for CLAMP_TO_EDGE */
3335
+ this.wrap = wrap;
3501
3336
  useWebGL && this.createWebGLTexture();
3502
3337
  }
3503
3338
 
@@ -3510,6 +3345,14 @@ class TextureInfo
3510
3345
  /** Check if the texture is webgl enabled
3511
3346
  * @return {boolean} */
3512
3347
  hasWebGL() { return !!this.glTexture; }
3348
+
3349
+ /** Set the wrap mode for this texture
3350
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE */
3351
+ setWrap(wrap=true)
3352
+ {
3353
+ this.wrap = wrap;
3354
+ glSetTextureWrap(this.glTexture, wrap);
3355
+ }
3513
3356
  }
3514
3357
 
3515
3358
  ///////////////////////////////////////////////////////////////////////////////
@@ -3571,8 +3414,13 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
3571
3414
  }
3572
3415
  else
3573
3416
  {
3574
- // if no tile info, force untextured
3575
- glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0, 0, color.rgbaInt());
3417
+ // if no tile info, force untextured by zeroing rgba (so whatever
3418
+ // texture is bound doesn't leak in) and folding color+additive
3419
+ // into the additive slot — matches the Canvas2D path's
3420
+ // color.add(additiveColor) on line ~337.
3421
+ const combined = additiveColor ? color.add(additiveColor) : color;
3422
+ glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0,
3423
+ 0, combined.rgbaInt());
3576
3424
  }
3577
3425
  }
3578
3426
  else
@@ -3711,6 +3559,8 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
3711
3559
  const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
3712
3560
  ASSERT(textureInfo instanceof TextureInfo, 'texture not loaded');
3713
3561
  ASSERT(textureInfo.size.x > 0, 'texture not loaded');
3562
+ ASSERT(textureInfo.wrap,
3563
+ 'drawTextureWrapped requires a wrap-enabled texture; call textureInfo.setWrap(true) first');
3714
3564
 
3715
3565
  if (useWebGL && glEnable)
3716
3566
  {
@@ -4619,7 +4469,7 @@ const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
4619
4469
  * This is useful to disable for html menus so the browser can handle input normally
4620
4470
  * @param {boolean} preventDefault
4621
4471
  * @memberof Input */
4622
- function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
4472
+ function setInputPreventDefault(preventDefault=true) { inputPreventDefault = preventDefault; }
4623
4473
 
4624
4474
  /** Clears an input key state
4625
4475
  * @param {string|number} key
@@ -5415,10 +5265,12 @@ function touchGamepadButtonCenter()
5415
5265
  * @namespace Audio
5416
5266
  */
5417
5267
 
5418
- /** Audio context used by the engine
5268
+ /** Audio context used by the engine. Created lazily in audioInit() to avoid
5269
+ * browser autoplay warnings about constructing an AudioContext before any
5270
+ * user gesture.
5419
5271
  * @type {AudioContext}
5420
5272
  * @memberof Audio */
5421
- let audioContext = new AudioContext;
5273
+ let audioContext;
5422
5274
 
5423
5275
  /** Master gain node for all audio to pass through
5424
5276
  * @type {GainNode}
@@ -5434,12 +5286,13 @@ const audioDefaultSampleRate = 44100;
5434
5286
  * @return {boolean} - True if the audio context is running
5435
5287
  * @memberof Audio */
5436
5288
  function audioIsRunning()
5437
- { return audioContext.state === 'running'; }
5289
+ { return audioContext?.state === 'running'; }
5438
5290
 
5439
5291
  function audioInit()
5440
5292
  {
5441
5293
  if (!soundEnable || headlessMode) return;
5442
5294
 
5295
+ audioContext = new AudioContext;
5443
5296
  audioMasterGain = audioContext.createGain();
5444
5297
  audioMasterGain.connect(audioContext.destination);
5445
5298
  audioMasterGain.gain.value = soundVolume; // set starting value
@@ -5506,8 +5359,8 @@ class Sound
5506
5359
 
5507
5360
  if (Array.isArray(asset))
5508
5361
  {
5509
- // generate zzfx sound
5510
- const zzfxSound = asset;
5362
+ // generate zzfx sound — copy so we don't mutate the caller's array
5363
+ const zzfxSound = asset.slice();
5511
5364
 
5512
5365
  // remove randomness so it can be applied on playback
5513
5366
  const defaultRandomness = randomness ?? .05;
@@ -7105,11 +6958,14 @@ class ParticleEmitter extends EngineObject
7105
6958
  }
7106
6959
 
7107
6960
  ///////////////////////////////////////////////////////////////////////////////
6961
+ // scratch vector reused by Particle.render to avoid per-frame allocations
6962
+ const particleDrawPos = new Vector2;
6963
+
7108
6964
  /**
7109
6965
  * Particle Object - Created automatically by Particle Emitters
7110
6966
  * @memberof Particles
7111
6967
  */
7112
- class Particle
6968
+ class Particle
7113
6969
  {
7114
6970
  /**
7115
6971
  * Create a particle with the passed in settings
@@ -7284,14 +7140,14 @@ class Particle
7284
7140
  additive && setBlendMode(true);
7285
7141
 
7286
7142
  // update the position and angle for drawing
7287
- const pos = this.pos.copy();
7143
+ const pos = particleDrawPos.set(this.pos.x, this.pos.y);
7288
7144
  let angle = this.angle;
7289
7145
  if (localSpace)
7290
7146
  {
7291
7147
  // in local space of emitter
7292
7148
  const a = emitter.angle;
7293
7149
  const c = cos(a), s = sin(a);
7294
- pos.set(emitter.pos.x + pos.x*c - pos.y*s,
7150
+ pos.set(emitter.pos.x + pos.x*c - pos.y*s,
7295
7151
  emitter.pos.y + pos.x*s + pos.y*c);
7296
7152
  angle += a;
7297
7153
  }
@@ -7316,200 +7172,6 @@ class Particle
7316
7172
  debugParticles && debugRect(pos, size, '#f005', 0, angle);
7317
7173
  }
7318
7174
  }
7319
- /**
7320
- * LittleJS Medal System
7321
- * - Achievement/trophy system for games
7322
- * - Medal class with name, description, icon, and unlock tracking
7323
- * - Automatic saving to local storage
7324
- * - Visual display queue with slide-in notifications
7325
- * - Newgrounds API integration for online achievements
7326
- * - Debug mode to unlock/reset medals during development
7327
- * @namespace Medals
7328
- */
7329
-
7330
- /** List of all medals
7331
- * @type {Object}
7332
- * @memberof Medals */
7333
- const medals = {};
7334
-
7335
- // Engine internal variables not exposed to documentation
7336
- let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast;
7337
-
7338
- ///////////////////////////////////////////////////////////////////////////////
7339
-
7340
- /** Initialize medals with a save name used for storage
7341
- * - Call this after creating all medals
7342
- * - Checks if medals are unlocked
7343
- * @param {string} saveName
7344
- * @memberof Medals */
7345
- function medalsInit(saveName)
7346
- {
7347
- // check if medals are unlocked
7348
- medalsSaveName = saveName;
7349
- if (!debugMedals)
7350
- medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
7351
-
7352
- // engine automatically renders medals
7353
- engineAddPlugin(undefined, medalsRender);
7354
-
7355
- // plugin functions
7356
- function medalsRender()
7357
- {
7358
- if (!medalsDisplayQueue.length) return;
7359
-
7360
- // update first medal in queue
7361
- const medal = medalsDisplayQueue[0];
7362
- const time = timeReal - medalsDisplayTimeLast;
7363
- if (!medalsDisplayTimeLast)
7364
- medalsDisplayTimeLast = timeReal;
7365
- else if (time > medalDisplayTime)
7366
- {
7367
- medalsDisplayTimeLast = 0;
7368
- medalsDisplayQueue.shift();
7369
- }
7370
- else
7371
- {
7372
- // slide on/off medals
7373
- const slideOffTime = medalDisplayTime - medalDisplaySlideTime;
7374
- const hidePercent =
7375
- time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime :
7376
- time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0;
7377
- medal.render(hidePercent);
7378
- }
7379
- }
7380
- }
7381
-
7382
- /**
7383
- * @callback MedalCallbackFunction - Function that processes a medal
7384
- * @param {Medal} medal
7385
- * @memberof Medals
7386
- */
7387
-
7388
- /** Calls a function for each medal
7389
- * @param {MedalCallbackFunction} callback
7390
- * @memberof Medals */
7391
- function medalsForEach(callback)
7392
- { Object.values(medals).forEach(medal=>callback(medal)); }
7393
-
7394
- ///////////////////////////////////////////////////////////////////////////////
7395
-
7396
- /**
7397
- * Medal - Tracks an unlockable medal
7398
- * @memberof Medals
7399
- * @example
7400
- * // create a medal
7401
- * const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
7402
- *
7403
- * // initialize medals
7404
- * medalsInit('Example Game');
7405
- *
7406
- * // unlock the medal
7407
- * medal_example.unlock();
7408
- */
7409
- class Medal
7410
- {
7411
- /** Create a medal object and adds it to the list of medals
7412
- * @param {number} id - The unique identifier of the medal
7413
- * @param {string} name - Name of the medal
7414
- * @param {string} [description] - Description of the medal
7415
- * @param {string} [icon] - Icon for the medal
7416
- * @param {string} [src] - Image location for the medal
7417
- */
7418
- constructor(id, name, description='', icon='🏆', src)
7419
- {
7420
- ASSERT(id >= 0 && !medals[id]);
7421
-
7422
- /** @property {number} - The unique identifier of the medal */
7423
- this.id = id;
7424
-
7425
- /** @property {string} - Name of the medal */
7426
- this.name = name;
7427
-
7428
- /** @property {string} - Description of the medal */
7429
- this.description = description;
7430
-
7431
- /** @property {string} - Icon for the medal */
7432
- this.icon = icon;
7433
-
7434
- /** @property {boolean} - Is the medal unlocked? */
7435
- this.unlocked = false;
7436
-
7437
- // load the source image if provided
7438
- if (src)
7439
- (this.image = new Image).src = src;
7440
-
7441
- // add this to list of medals
7442
- medals[id] = this;
7443
- }
7444
-
7445
- /** Unlocks a medal if not already unlocked */
7446
- unlock()
7447
- {
7448
- if (medalsPreventUnlock || this.unlocked) return;
7449
-
7450
- // save the medal
7451
- ASSERT(medalsSaveName, 'save name must be set');
7452
- localStorage[this.storageKey()] = this.unlocked = true;
7453
- medalsDisplayQueue.push(this);
7454
- }
7455
-
7456
- /** Render a medal
7457
- * @param {number} [hidePercent] - How much to slide the medal off screen
7458
- */
7459
- render(hidePercent=0)
7460
- {
7461
- const context = mainContext;
7462
- const width = min(medalDisplaySize.x, mainCanvas.width);
7463
- const height = medalDisplaySize.y;
7464
- const x = mainCanvas.width - width;
7465
- const y = -height*hidePercent;
7466
- const backgroundColor = hsl(0,0,.9);
7467
-
7468
- // draw containing rect and clip to that region
7469
- context.save();
7470
- context.beginPath();
7471
- context.fillStyle = backgroundColor.toString();
7472
- context.strokeStyle = BLACK.toString();
7473
- context.lineWidth = 3;
7474
- context.rect(x, y, width, height);
7475
- context.fill();
7476
- context.stroke();
7477
- context.clip();
7478
-
7479
- // draw the icon
7480
- const gap = vec2(.1, .05).scale(height);
7481
- const medalDisplayIconSize = height - 2*gap.x;
7482
- this.renderIcon(vec2(x + gap.x + medalDisplayIconSize/2, y + height/2), medalDisplayIconSize);
7483
-
7484
- // draw the name
7485
- const nameSize = height*.5;
7486
- const descriptionSize = height*.3;
7487
- const pos = vec2(x + medalDisplayIconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
7488
- const textWidth = width - medalDisplayIconSize - 3*gap.x;
7489
- drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
7490
-
7491
- // draw the description
7492
- pos.y = y + height - gap.y*2 - descriptionSize/2;
7493
- drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
7494
- context.restore();
7495
- }
7496
-
7497
- /** Render the icon for a medal
7498
- * @param {Vector2} pos - Screen space position
7499
- * @param {number} size - Screen space size
7500
- */
7501
- renderIcon(pos, size)
7502
- {
7503
- // draw the image or icon
7504
- if (this.image)
7505
- mainContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size);
7506
- else
7507
- drawTextScreen(this.icon, pos, size*.7, BLACK);
7508
- }
7509
-
7510
- // Get local storage key used by the medal
7511
- storageKey() { return medalsSaveName + '_' + this.id; }
7512
- }
7513
7175
  /**
7514
7176
  * LittleJS WebGL Interface
7515
7177
  * - WebGL2 rendering engine for high-performance graphics
@@ -7609,7 +7271,7 @@ function glInit(rootElement)
7609
7271
  // reinit WebGL and restore textures
7610
7272
  initWebGL();
7611
7273
  for (const info of glTextureInfos)
7612
- info.glTexture = glCreateTexture(info.image);
7274
+ info.glTexture = glCreateTexture(info.image, info.wrap);
7613
7275
  pluginList.forEach(plugin=>plugin.glContextRestored?.());
7614
7276
  });
7615
7277
 
@@ -7825,6 +7487,30 @@ function glSetTexture(texture)
7825
7487
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
7826
7488
  }
7827
7489
 
7490
+ /** Set the wrap mode (REPEAT or CLAMP_TO_EDGE) on an existing WebGL texture
7491
+ * Flushes the current batch only if the texture is the active one
7492
+ * @param {WebGLTexture} texture
7493
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
7494
+ * @memberof WebGL */
7495
+ function glSetTextureWrap(texture, wrap=true)
7496
+ {
7497
+ if (!glContext || !texture) return;
7498
+
7499
+ // flush only if changing wrap on the currently bound texture
7500
+ const isCurrent = texture === glActiveTexture;
7501
+ if (isCurrent)
7502
+ glFlush();
7503
+ else
7504
+ glContext.bindTexture(glContext.TEXTURE_2D, texture);
7505
+
7506
+ const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
7507
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
7508
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
7509
+
7510
+ if (!isCurrent && glActiveTexture)
7511
+ glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
7512
+ }
7513
+
7828
7514
  /** Compile WebGL shader of the given type, will throw errors if in debug mode
7829
7515
  * @param {string} source
7830
7516
  * @param {number} type
@@ -7869,9 +7555,10 @@ function glCreateProgram(vsSource, fsSource)
7869
7555
  /** Create WebGL texture from an image and init the texture settings
7870
7556
  * Restores the active texture when done
7871
7557
  * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas} [image]
7558
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
7872
7559
  * @return {WebGLTexture}
7873
7560
  * @memberof WebGL */
7874
- function glCreateTexture(image)
7561
+ function glCreateTexture(image, wrap=false)
7875
7562
  {
7876
7563
  if (!glContext) return;
7877
7564
 
@@ -7897,8 +7584,9 @@ function glCreateTexture(image)
7897
7584
  const minFilter = mipMap ? glContext.LINEAR_MIPMAP_LINEAR : magFilter;
7898
7585
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, magFilter);
7899
7586
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, minFilter);
7900
- glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, glContext.REPEAT);
7901
- glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, glContext.REPEAT);
7587
+ const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
7588
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
7589
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
7902
7590
  if (mipMap)
7903
7591
  glContext.generateMipmap(glContext.TEXTURE_2D);
7904
7592
 
@@ -7950,7 +7638,7 @@ function glRegisterTextureInfo(textureInfo)
7950
7638
  if (textureInfo.glTexture)
7951
7639
  glSetTextureData(textureInfo.glTexture, textureInfo.image);
7952
7640
  else
7953
- textureInfo.glTexture = glCreateTexture(textureInfo.image);
7641
+ textureInfo.glTexture = glCreateTexture(textureInfo.image, textureInfo.wrap);
7954
7642
  }
7955
7643
 
7956
7644
  /** Tells WebGL to destroy the glTexture and stop tracking it
@@ -8169,6 +7857,7 @@ function glSetRenderTarget(texture, clear=false)
8169
7857
  glFlush();
8170
7858
  glRenderTarget = undefined;
8171
7859
  glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
7860
+ glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
8172
7861
  }
8173
7862
  }
8174
7863
 
@@ -8291,9 +7980,9 @@ function glPolyStrip(points)
8291
7980
  return area;
8292
7981
  }
8293
7982
 
8294
- // ensure counter-clockwise winding
7983
+ // ensure counter-clockwise winding (slice first so we don't mutate caller's array)
8295
7984
  if (signedArea(points) < 0)
8296
- points = points.reverse();
7985
+ points = points.slice().reverse();
8297
7986
 
8298
7987
  // check if point is inside triangle
8299
7988
  const e = 1e-9;
@@ -8393,6 +8082,398 @@ function glPolyStrip(points)
8393
8082
  }
8394
8083
  return strip;
8395
8084
  }
8085
+ /**
8086
+ * LittleJS Engine Logo
8087
+ * - Draws the LittleJS splash screen logo
8088
+ * - Used internally during engine startup
8089
+ */
8090
+
8091
+ ///////////////////////////////////////////////////////////////////////////////
8092
+ function drawEngineLogo(t)
8093
+ {
8094
+ const blackAndWhite = 0;
8095
+ const showName = 1;
8096
+
8097
+ // LittleJS Logo and Splash Screen
8098
+ const x = mainContext;
8099
+ const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
8100
+ const w = mainCanvas.width = innerWidth * dpr;
8101
+ const h = mainCanvas.height = innerHeight * dpr;
8102
+ {
8103
+ // background
8104
+ const p3 = percent(t, 1, .8);
8105
+ const p4 = percent(t, 0, .5);
8106
+ const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,hypot(w,h)*.6);
8107
+ g.addColorStop(0,hsl(0,0,lerp(0,p3/2,p4),p3).toString());
8108
+ g.addColorStop(1,hsl(0,0,0,p3).toString());
8109
+ x.save();
8110
+ x.fillStyle = g;
8111
+ x.fillRect(0,0,w,h);
8112
+ }
8113
+ const gradient = (X1,Y1,X2,Y2,C,S=1)=>
8114
+ {
8115
+ if (C >= 0)
8116
+ {
8117
+ if (blackAndWhite)
8118
+ x.fillStyle = '#fff';
8119
+ else
8120
+ {
8121
+ const g = x.fillStyle = x.createLinearGradient(X1,Y1,X2,Y2);
8122
+ g.addColorStop(0,color(C,2));
8123
+ g.addColorStop(1,color(C,1));
8124
+ }
8125
+ }
8126
+ else
8127
+ x.fillStyle = '#000';
8128
+ C >= -1 ? (x.fill(), S && x.stroke()) : x.stroke();
8129
+ }
8130
+ const circle = (X,Y,R,A=0,B=2*PI,C,S)=>
8131
+ {
8132
+ x.beginPath();
8133
+ x.arc(X,Y,R,p*A,p*B);
8134
+ gradient(X,Y-R,X,Y+R,C,S);
8135
+ }
8136
+ const rect = (X,Y,W,H,C)=>
8137
+ {
8138
+ x.beginPath();
8139
+ x.rect(X,Y,W,H*p);
8140
+ gradient(X,Y+H,X+W,Y,C);
8141
+ }
8142
+ const poly = (points,C,Y,H)=>
8143
+ {
8144
+ x.beginPath();
8145
+ for (const p of points)
8146
+ x.lineTo(p.x, p.y);
8147
+ x.closePath();
8148
+ gradient(0, Y, 0, Y+H,C);
8149
+ }
8150
+ const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
8151
+
8152
+ // center and fit to screen
8153
+ const alpha = oscillate(1,1,t);
8154
+ const p = percent(alpha, .1, .5);
8155
+ const size = min(6, min(w,h)/99);
8156
+ x.translate(w/2,h/2);
8157
+ x.scale(size,size);
8158
+ x.translate(-40,-35);
8159
+ p < 1 && x.setLineDash([99*p,99]);
8160
+ x.lineJoin = x.lineCap = 'round';
8161
+ x.lineWidth = .1 + p*1.9;
8162
+ //x.strokeStyle='#fff7';
8163
+
8164
+ if (showName)
8165
+ {
8166
+ // engine name text
8167
+ const Y = 54;
8168
+ const s = 'LittleJS';
8169
+ x.font = '900 15.5px arial';
8170
+ x.lineWidth = .1+p*3.9;
8171
+ x.textAlign = 'center';
8172
+ x.textBaseline = 'top';
8173
+ rect(11,Y+1,59,8*p,-1);
8174
+ x.beginPath();
8175
+
8176
+ let w2 = 0;
8177
+ for (let i=0;i<s.length;++i)
8178
+ w2 += x.measureText(s[i]).width;
8179
+ for (let j=2;j--;)
8180
+ for (let i=0,X=40-w2/2;i<s.length;++i)
8181
+ {
8182
+ const w = x.measureText(s[i]).width, X2 = X+w/2;
8183
+ gradient(X2,Y,X2+2,Y+13,i>5?1:0);
8184
+ x[j?'strokeText':'fillText'](s[i],X2,Y+.5,17*p);
8185
+ X += w;
8186
+ }
8187
+
8188
+ x.lineWidth = .1 + p*1.9;
8189
+ rect(3,Y,73,0); // bottom
8190
+ }
8191
+
8192
+ rect(7,15,26,-7,0); // cab top
8193
+ rect(25,15,8,25,-1); // cab front
8194
+ rect(10,40,15,-25,1); // cab back
8195
+ rect(14,21,7,9,2); // cab window
8196
+ rect(38,20,6,-6,2); // little stack
8197
+
8198
+ // big stack
8199
+ rect(49,20,10,-6,0);
8200
+ const stackPoints = [vec2(44,8),vec2(64,8),vec2(59,8+6*p),vec2(49,8+6*p)];
8201
+ poly(stackPoints,2,8,6*p);
8202
+ rect(44,8,20,-7,0);
8203
+
8204
+ // engine
8205
+ for (let i=5;i--;) circle(59-i*6*p,30,10,0,2*PI,1,0);
8206
+ circle(59,30,4,0,7,2); // light
8207
+
8208
+ // engine outline
8209
+ rect(35,20,24,0); // top
8210
+ circle(59,30,10); // front
8211
+ circle(47,30,10,PI/2,PI*3/2); // middle
8212
+ circle(35,30,10,PI/2,PI*3/2); // back
8213
+ rect(7,40,13,7,-1); // bottom back
8214
+ rect(17,40,43,14,-1); // bottom center
8215
+
8216
+ // wheels
8217
+ for (let i=3;i--;) for (let j=2;j--;) circle(17+15*i,47,j?7:1,0,2*PI,2);
8218
+
8219
+ // cowcatcher
8220
+ for (let i=2;i--;)
8221
+ {
8222
+ let w=6, s=7, o=53+w*p*i
8223
+ const points = [vec2(o+s,54),vec2(o,40),vec2(o+w*p,40),vec2(o+s+w*p,54)];
8224
+ poly(points,0,40,14);
8225
+ }
8226
+
8227
+ x.restore();
8228
+ }
8229
+
8230
+ /**
8231
+ * LittleJS Medal System
8232
+ * - Achievement/trophy system for games
8233
+ * - Medal class with name, description, icon, and unlock tracking
8234
+ * - Automatic saving to local storage
8235
+ * - Visual display queue with slide-in notifications
8236
+ * - Newgrounds API integration for online achievements
8237
+ * - Debug mode to unlock/reset medals during development
8238
+ * @namespace Medals
8239
+ */
8240
+
8241
+ let debugMedals = false;
8242
+
8243
+ ///////////////////////////////////////////////////////////////////////////////
8244
+ // Medals settings
8245
+
8246
+ /** How long to show medals for in seconds
8247
+ * @type {number}
8248
+ * @default
8249
+ * @memberof Settings */
8250
+ let medalDisplayTime = 5;
8251
+
8252
+ /** How quickly to slide on/off medals in seconds
8253
+ * @type {number}
8254
+ * @default
8255
+ * @memberof Settings */
8256
+ let medalDisplaySlideTime = .5;
8257
+
8258
+ /** Size of medal display
8259
+ * @type {Vector2}
8260
+ * @default Vector2(640,80)
8261
+ * @memberof Settings */
8262
+ let medalDisplaySize = vec2(640, 80);
8263
+
8264
+ /** Set to stop medals from being unlockable (like if cheats are enabled)
8265
+ * @type {boolean}
8266
+ * @default
8267
+ * @memberof Settings */
8268
+ let medalsPreventUnlock = false;
8269
+
8270
+ /** List of all medals
8271
+ * @type {Object}
8272
+ * @memberof Medals */
8273
+ const medals = {};
8274
+
8275
+ // Engine internal variables not exposed to documentation
8276
+ let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast;
8277
+
8278
+ ///////////////////////////////////////////////////////////////////////////////
8279
+
8280
+ /** Initialize medals with a save name used for storage
8281
+ * - Call this after creating all medals
8282
+ * - Checks if medals are unlocked
8283
+ * @param {string} saveName
8284
+ * @memberof Medals */
8285
+ function medalsInit(saveName)
8286
+ {
8287
+ // check if medals are unlocked
8288
+ medalsSaveName = saveName;
8289
+ if (!debugMedals)
8290
+ medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
8291
+
8292
+ // engine automatically renders medals
8293
+ engineAddPlugin(undefined, medalsRender);
8294
+
8295
+ // plugin functions
8296
+ function medalsRender()
8297
+ {
8298
+ if (!medalsDisplayQueue.length) return;
8299
+
8300
+ // update first medal in queue
8301
+ const medal = medalsDisplayQueue[0];
8302
+ const time = timeReal - medalsDisplayTimeLast;
8303
+ if (!medalsDisplayTimeLast)
8304
+ medalsDisplayTimeLast = timeReal;
8305
+ else if (time > medalDisplayTime)
8306
+ {
8307
+ medalsDisplayTimeLast = 0;
8308
+ medalsDisplayQueue.shift();
8309
+ }
8310
+ else
8311
+ {
8312
+ // slide on/off medals
8313
+ const slideOffTime = medalDisplayTime - medalDisplaySlideTime;
8314
+ const hidePercent =
8315
+ time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime :
8316
+ time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0;
8317
+ medal.render(hidePercent);
8318
+ }
8319
+ }
8320
+ }
8321
+
8322
+ /**
8323
+ * @callback MedalCallbackFunction - Function that processes a medal
8324
+ * @param {Medal} medal
8325
+ * @memberof Medals
8326
+ */
8327
+
8328
+ /** Calls a function for each medal
8329
+ * @param {MedalCallbackFunction} callback
8330
+ * @memberof Medals */
8331
+ function medalsForEach(callback)
8332
+ { Object.values(medals).forEach(medal=>callback(medal)); }
8333
+
8334
+ ///////////////////////////////////////////////////////////////////////////////
8335
+
8336
+ /**
8337
+ * Medal - Tracks an unlockable medal
8338
+ * @memberof Medals
8339
+ * @example
8340
+ * // create a medal
8341
+ * const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
8342
+ *
8343
+ * // initialize medals
8344
+ * medalsInit('Example Game');
8345
+ *
8346
+ * // unlock the medal
8347
+ * medal_example.unlock();
8348
+ */
8349
+ class Medal
8350
+ {
8351
+ /** Create a medal object and adds it to the list of medals
8352
+ * @param {number} id - The unique identifier of the medal
8353
+ * @param {string} name - Name of the medal
8354
+ * @param {string} [description] - Description of the medal
8355
+ * @param {string} [icon] - Icon for the medal
8356
+ * @param {string} [src] - Image location for the medal
8357
+ */
8358
+ constructor(id, name, description='', icon='🏆', src)
8359
+ {
8360
+ ASSERT(id >= 0 && !medals[id]);
8361
+
8362
+ /** @property {number} - The unique identifier of the medal */
8363
+ this.id = id;
8364
+
8365
+ /** @property {string} - Name of the medal */
8366
+ this.name = name;
8367
+
8368
+ /** @property {string} - Description of the medal */
8369
+ this.description = description;
8370
+
8371
+ /** @property {string} - Icon for the medal */
8372
+ this.icon = icon;
8373
+
8374
+ /** @property {boolean} - Is the medal unlocked? */
8375
+ this.unlocked = false;
8376
+
8377
+ // load the source image if provided
8378
+ if (src)
8379
+ (this.image = new Image).src = src;
8380
+
8381
+ // add this to list of medals
8382
+ medals[id] = this;
8383
+ }
8384
+
8385
+ /** Unlocks a medal if not already unlocked */
8386
+ unlock()
8387
+ {
8388
+ if (medalsPreventUnlock || this.unlocked) return;
8389
+
8390
+ // save the medal
8391
+ ASSERT(medalsSaveName, 'save name must be set');
8392
+ localStorage[this.storageKey()] = this.unlocked = true;
8393
+ medalsDisplayQueue.push(this);
8394
+ }
8395
+
8396
+ /** Render a medal
8397
+ * @param {number} [hidePercent] - How much to slide the medal off screen
8398
+ */
8399
+ render(hidePercent=0)
8400
+ {
8401
+ const context = mainContext;
8402
+ const width = min(medalDisplaySize.x, mainCanvas.width);
8403
+ const height = medalDisplaySize.y;
8404
+ const x = mainCanvas.width - width;
8405
+ const y = -height*hidePercent;
8406
+ const backgroundColor = hsl(0,0,.9);
8407
+
8408
+ // draw containing rect and clip to that region
8409
+ context.save();
8410
+ context.beginPath();
8411
+ context.fillStyle = backgroundColor.toString();
8412
+ context.strokeStyle = BLACK.toString();
8413
+ context.lineWidth = 3;
8414
+ context.rect(x, y, width, height);
8415
+ context.fill();
8416
+ context.stroke();
8417
+ context.clip();
8418
+
8419
+ // draw the icon
8420
+ const gap = vec2(.1, .05).scale(height);
8421
+ const medalDisplayIconSize = height - 2*gap.x;
8422
+ this.renderIcon(vec2(x + gap.x + medalDisplayIconSize/2, y + height/2), medalDisplayIconSize);
8423
+
8424
+ // draw the name
8425
+ const nameSize = height*.5;
8426
+ const descriptionSize = height*.3;
8427
+ const pos = vec2(x + medalDisplayIconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
8428
+ const textWidth = width - medalDisplayIconSize - 3*gap.x;
8429
+ drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
8430
+
8431
+ // draw the description
8432
+ pos.y = y + height - gap.y*2 - descriptionSize/2;
8433
+ drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
8434
+ context.restore();
8435
+ }
8436
+
8437
+ /** Render the icon for a medal
8438
+ * @param {Vector2} pos - Screen space position
8439
+ * @param {number} size - Screen space size
8440
+ */
8441
+ renderIcon(pos, size)
8442
+ {
8443
+ // draw the image or icon
8444
+ if (this.image)
8445
+ mainContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size);
8446
+ else
8447
+ drawTextScreen(this.icon, pos, size*.7, BLACK);
8448
+ }
8449
+
8450
+ // Get local storage key used by the medal
8451
+ storageKey() { return medalsSaveName + '_' + this.id; }
8452
+ }
8453
+
8454
+ ///////////////////////////////////////////////////////////////////////////////
8455
+ // Medals setting setters
8456
+
8457
+ /** Set how long to show medals for in seconds
8458
+ * @param {number} time
8459
+ * @memberof Settings */
8460
+ function setMedalDisplayTime(time) { medalDisplayTime = time; }
8461
+
8462
+ /** Set how quickly to slide on/off medals in seconds
8463
+ * @param {number} time
8464
+ * @memberof Settings */
8465
+ function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
8466
+
8467
+ /** Set size of medal display
8468
+ * @param {Vector2} size
8469
+ * @memberof Settings */
8470
+ function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
8471
+
8472
+ /** Set to stop medals from being unlockable
8473
+ * @param {boolean} preventUnlock
8474
+ * @memberof Settings */
8475
+ function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }
8476
+
8396
8477
  /**
8397
8478
  * LittleJS Newgrounds Plugin
8398
8479
  * - NewgroundsMedal extends Medal with Newgrounds API functionality
@@ -9009,16 +9090,13 @@ class UISystemPlugin
9009
9090
  this.lastHoverObject = undefined;
9010
9091
  /** @property {UIObject} - Current confirm menu being shown */
9011
9092
  this.confirmDialog = undefined;
9012
- /** @property {UIObject} - Object to send keyboard input to */
9013
- this.keyInputObject = undefined;
9093
+ /** @private */
9094
+ this._keyInputObject = undefined;
9095
+ /** @private */
9096
+ this._onKeyDown = (e) => this._keyInputObject?.onKeyDown(e);
9014
9097
 
9015
9098
  engineAddPlugin(uiUpdate, uiRender);
9016
9099
 
9017
- // key down handler
9018
- function onKeyDown(e)
9019
- { uiSystem.keyInputObject?.onKeyDown(e); }
9020
- document.addEventListener('keydown', onKeyDown);
9021
-
9022
9100
  // set object position in parent space
9023
9101
  function updateTransforms(o)
9024
9102
  {
@@ -9360,8 +9438,23 @@ class UISystemPlugin
9360
9438
  return p;
9361
9439
  }
9362
9440
 
9441
+ /** Object to send keyboard input to (typically a UITextInput).
9442
+ * The document keydown listener is only attached while this is set,
9443
+ * so games that never use text input pay no event-handling cost.
9444
+ * @type {UIObject} */
9445
+ get keyInputObject() { return this._keyInputObject; }
9446
+ set keyInputObject(obj)
9447
+ {
9448
+ const had = !!this._keyInputObject;
9449
+ this._keyInputObject = obj;
9450
+ if (!had && obj)
9451
+ document.addEventListener('keydown', this._onKeyDown);
9452
+ else if (had && !obj)
9453
+ document.removeEventListener('keydown', this._onKeyDown);
9454
+ }
9455
+
9363
9456
  /** Destroy and remove all objects
9364
- * @memberof Engine */
9457
+ * @memberof UISystem */
9365
9458
  destroyObjects()
9366
9459
  {
9367
9460
  for (const o of this.uiObjects)
@@ -9784,15 +9877,13 @@ class UIObject
9784
9877
  * @return {string} */
9785
9878
  toString()
9786
9879
  {
9787
- if (!debug) return;
9788
-
9789
9880
  let text = 'type = ' + this.constructor.name;
9790
9881
  if (this.text)
9791
9882
  text += '\ntext = ' + this.text;
9792
9883
  if (this.pos.x || this.pos.y)
9793
9884
  text += '\npos = ' + this.pos;
9794
9885
  if (this.localPos.x || this.localPos.y)
9795
- text += '\localPos = ' + this.localPos;
9886
+ text += '\nlocalPos = ' + this.localPos;
9796
9887
  if (this.size.x || this.size.y)
9797
9888
  text += '\nsize = ' + this.size;
9798
9889
  if (this.color)
@@ -10386,6 +10477,135 @@ class UIVideo extends UIObject
10386
10477
  this.video.remove();
10387
10478
  super.destroy();
10388
10479
  }
10480
+ }
10481
+
10482
+ ///////////////////////////////////////////////////////////////////////////////
10483
+ /**
10484
+ * UILayout - A container that auto-arranges children in a vertical list, horizontal list, or grid
10485
+ * - Set columns to 1 for a vertical list (default)
10486
+ * - Set columns to children.length for a horizontal list
10487
+ * - Set columns to N (1 < N < children.length) for a grid with N columns
10488
+ * - 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
10489
+ * - Children are positioned centered in their cell
10490
+ * - Container auto-sizes to fit children plus padding
10491
+ * @extends UIObject
10492
+ * @memberof UISystem
10493
+ */
10494
+ class UILayout extends UIObject
10495
+ {
10496
+ /** Create a UILayout container that auto-arranges children
10497
+ * @param {Vector2} [pos]
10498
+ * @param {number} [columns=1] - Number of columns (1 = vertical list)
10499
+ * @param {number} [gap=10] - Space between children
10500
+ * @param {number} [padding=10] - Space between container border and children
10501
+ * @param {boolean} [transparent=false] - If true, draws no background, outline, or shadow
10502
+ */
10503
+ constructor(pos, columns=1, gap=10, padding=10, transparent=false)
10504
+ {
10505
+ super(pos);
10506
+
10507
+ ASSERT(isNumber(columns) && columns >= 1, 'ui layout columns must be a number >= 1');
10508
+ ASSERT(isNumber(gap), 'ui layout gap must be a number');
10509
+ ASSERT(isNumber(padding), 'ui layout padding must be a number');
10510
+
10511
+ /** @property {number} - Number of columns in the layout */
10512
+ this.columns = columns;
10513
+ /** @property {number} - Space between children */
10514
+ this.gap = gap;
10515
+ /** @property {number} - Space between container border and children */
10516
+ this.padding = padding;
10517
+
10518
+ if (transparent)
10519
+ {
10520
+ // pure positioning helper - skip background, outline, and shadow
10521
+ this.color = CLEAR_BLACK;
10522
+ this.gradientColor = undefined;
10523
+ this.lineWidth = 0;
10524
+ this.shadowColor = CLEAR_BLACK;
10525
+ }
10526
+ this.relayout();
10527
+ }
10528
+
10529
+ /** Add a child UIObject and re-layout
10530
+ * @param {UIObject} child
10531
+ * @return {UIObject} The child object added */
10532
+ addChild(child)
10533
+ {
10534
+ super.addChild(child);
10535
+ this.relayout();
10536
+ return child;
10537
+ }
10538
+
10539
+ /** Remove a child UIObject and re-layout
10540
+ * @param {UIObject} child */
10541
+ removeChild(child)
10542
+ {
10543
+ super.removeChild(child);
10544
+ this.relayout();
10545
+ }
10546
+
10547
+ /** Recompute child positions and container size based on per-child sizes.
10548
+ * Called automatically by addChild and removeChild. Call manually if you
10549
+ * mutate a child's size or change columns, gap, or padding. */
10550
+ relayout()
10551
+ {
10552
+ const n = this.children.length;
10553
+ if (!n)
10554
+ {
10555
+ this.size = vec2(this.padding * 2);
10556
+ return;
10557
+ }
10558
+
10559
+ const cols = this.columns;
10560
+ const rows = ceil(n / cols);
10561
+ const colWidths = new Array(cols).fill(0);
10562
+ const rowHeights = new Array(rows).fill(0);
10563
+
10564
+ // first pass: compute column widths and row heights from child sizes
10565
+ for (let i = 0; i < n; ++i)
10566
+ {
10567
+ const col = i % cols;
10568
+ const row = floor(i / cols);
10569
+ const child = this.children[i];
10570
+ colWidths[col] = max(colWidths[col], child.size.x);
10571
+ rowHeights[row] = max(rowHeights[row], child.size.y);
10572
+ }
10573
+
10574
+ // total content size (sum of column widths/row heights plus gaps between them)
10575
+ let contentWidth = this.gap * (cols - 1);
10576
+ for (const w of colWidths) contentWidth += w;
10577
+ let contentHeight = this.gap * (rows - 1);
10578
+ for (const h of rowHeights) contentHeight += h;
10579
+
10580
+ // cumulative column/row offsets so positioning is O(n) not O(n^2)
10581
+ const colOffsets = new Array(cols);
10582
+ let xAcc = 0;
10583
+ for (let c = 0; c < cols; ++c)
10584
+ {
10585
+ colOffsets[c] = xAcc;
10586
+ xAcc += colWidths[c];
10587
+ }
10588
+ const rowOffsets = new Array(rows);
10589
+ let yAcc = 0;
10590
+ for (let r = 0; r < rows; ++r)
10591
+ {
10592
+ rowOffsets[r] = yAcc;
10593
+ yAcc += rowHeights[r];
10594
+ }
10595
+
10596
+ // second pass: position each child centered in its cell
10597
+ for (let i = 0; i < n; ++i)
10598
+ {
10599
+ const col = i % cols;
10600
+ const row = floor(i / cols);
10601
+ const x = -contentWidth/2 + colOffsets[col] + this.gap * col + colWidths[col] / 2;
10602
+ const y = -contentHeight/2 + rowOffsets[row] + this.gap * row + rowHeights[row] / 2;
10603
+ this.children[i].localPos = vec2(x, y);
10604
+ }
10605
+
10606
+ // container size = content + padding on all sides
10607
+ this.size = vec2(contentWidth + this.padding * 2, contentHeight + this.padding * 2);
10608
+ }
10389
10609
  }
10390
10610
  /**
10391
10611
  * LittleJS Box2D Physics Plugin
@@ -10912,14 +11132,29 @@ class Box2dObject extends EngineObject
10912
11132
  this.body.ApplyForce(box2d.vec2dTo(force), box2d.vec2dTo(pos));
10913
11133
  }
10914
11134
 
10915
- /** Apply acceleration to this object
11135
+ /** Apply acceleration to this object (changes velocity by acceleration,
11136
+ * mass-independent — matches EngineObject.applyAcceleration semantics).
11137
+ * Use applyImpulse if you want the mass-dependent velocity change
11138
+ * Δv = impulse / mass, or applyForce for a Newton-style sustained force.
10916
11139
  * @param {Vector2} acceleration
10917
11140
  * @param {Vector2} [pos] */
10918
11141
  applyAcceleration(acceleration, pos)
10919
- {
11142
+ {
10920
11143
  pos ||= this.getCenterOfMass();
10921
11144
  this.setAwake();
10922
- this.body.ApplyLinearImpulse(box2d.vec2dTo(acceleration), box2d.vec2dTo(pos));
11145
+ const impulse = acceleration.scale(this.getMass());
11146
+ this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
11147
+ }
11148
+
11149
+ /** Apply an instantaneous linear impulse. Changes velocity immediately by
11150
+ * impulse / mass (so heavier bodies move less for the same impulse).
11151
+ * @param {Vector2} impulse
11152
+ * @param {Vector2} [pos] */
11153
+ applyImpulse(impulse, pos)
11154
+ {
11155
+ pos ||= this.getCenterOfMass();
11156
+ this.setAwake();
11157
+ this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
10923
11158
  }
10924
11159
 
10925
11160
  /** Apply torque to this object
@@ -10929,13 +11164,23 @@ class Box2dObject extends EngineObject
10929
11164
  this.setAwake();
10930
11165
  this.body.ApplyTorque(torque);
10931
11166
  }
10932
-
10933
- /** Apply angular acceleration to this object
11167
+
11168
+ /** Apply angular acceleration to this object (changes angular velocity by
11169
+ * acceleration, mass-independent — matches EngineObject semantics).
10934
11170
  * @param {number} acceleration */
10935
11171
  applyAngularAcceleration(acceleration)
10936
11172
  {
10937
11173
  this.setAwake();
10938
- this.body.ApplyAngularImpulse(acceleration);
11174
+ this.body.ApplyAngularImpulse(acceleration * this.getInertia());
11175
+ }
11176
+
11177
+ /** Apply an instantaneous angular impulse. Changes angular velocity by
11178
+ * impulse / inertia immediately.
11179
+ * @param {number} impulse */
11180
+ applyAngularImpulse(impulse)
11181
+ {
11182
+ this.setAwake();
11183
+ this.body.ApplyAngularImpulse(impulse);
10939
11184
  }
10940
11185
 
10941
11186
  ///////////////////////////////////////////////////////////////////////////////
@@ -11181,7 +11426,7 @@ class Box2dJoint
11181
11426
 
11182
11427
  /** Check if the connected bodies should collide
11183
11428
  * @return {boolean} */
11184
- getCollideConnected() { return this.box2dJoint.getCollideConnected();}
11429
+ getCollideConnected() { return this.box2dJoint.GetCollideConnected();}
11185
11430
 
11186
11431
  /** Check if either connected body is active
11187
11432
  * @return {boolean} */
@@ -12794,13 +13039,13 @@ const Ease =
12794
13039
  * @param {number} x
12795
13040
  * @returns {number}
12796
13041
  * @memberof TweenSystem */
12797
- SINE: (x) => 1 - Math.cos(x * (Math.PI / 2)),
13042
+ SINE: (x) => 1 - cos(x * (PI / 2)),
12798
13043
 
12799
13044
  /** Circular ease-in curve.
12800
13045
  * @param {number} x
12801
13046
  * @returns {number}
12802
13047
  * @memberof TweenSystem */
12803
- CIRC: (x) => 1 - Math.sqrt(1 - x * x),
13048
+ CIRC: (x) => 1 - (1 - x * x)**.5,
12804
13049
 
12805
13050
  /** Exponential ease-in curve (`2^(10x-10)`).
12806
13051
  * @param {number} x
@@ -12819,7 +13064,7 @@ const Ease =
12819
13064
  * @returns {number}
12820
13065
  * @memberof TweenSystem */
12821
13066
  ELASTIC: (x) =>
12822
- -(2 ** (10 * x - 10)) * Math.sin(((37 - 40 * x) * Math.PI) / 6),
13067
+ -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
12823
13068
 
12824
13069
  /** Spring-like ease-out: oscillates outward after passing the target.
12825
13070
  * @param {number} x
@@ -12827,8 +13072,8 @@ const Ease =
12827
13072
  * @memberof TweenSystem */
12828
13073
  SPRING: (x) =>
12829
13074
  1 -
12830
- (Math.sin(Math.PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
12831
- Math.pow(x, 2.2) +
13075
+ (sin(PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
13076
+ x ** 2.2 +
12832
13077
  (1 - x)) *
12833
13078
  (1.0 + 1.2 * x),
12834
13079
 
@@ -12932,7 +13177,7 @@ const Ease =
12932
13177
  {
12933
13178
  const tMid = (t0 + t1) / 2;
12934
13179
  const [bx, by] = curve(tMid);
12935
- if (Math.abs(bx - x) < 1e-5) return by;
13180
+ if (abs(bx - x) < 1e-5) return by;
12936
13181
  if (bx < x) t0 = tMid; else t1 = tMid;
12937
13182
  }
12938
13183
  return curve((t0 + t1) / 2)[1];
@@ -13064,3 +13309,760 @@ function tweenStopAll()
13064
13309
  // Register with the engine so tweens auto-advance.
13065
13310
  engineAddPlugin(tweenUpdate);
13066
13311
 
13312
+ /**
13313
+ * LittleJS PathFinder Plugin
13314
+ * - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths
13315
+ * - Works directly on a TileCollisionLayer, or override isWalkable/getCost for any grid
13316
+ * - Debug visualization via engine debug primitives (stripped in release builds)
13317
+ * - Port of frankforce.com pathFindingBase.cpp (2018)
13318
+ * @namespace PathFinding
13319
+ */
13320
+
13321
+ ///////////////////////////////////////////////////////////////////////////////
13322
+
13323
+ // Diagonal step cost — pre-computed for the A* expansion inner loop.
13324
+ const PATHFINDER_DIAGONAL_COST = Math.SQRT2;
13325
+
13326
+ // Shared 1x1 size vector for per-tile debugRect calls. debugRect copies the
13327
+ // argument internally, so reusing one instance is safe.
13328
+ const PATHFINDER_TILE_VEC = vec2(1);
13329
+
13330
+ ///////////////////////////////////////////////////////////////////////////////
13331
+
13332
+ /** A single grid cell tracked by the pathfinder. Allocated once per cell at
13333
+ * PathFinder construction; reset (not reallocated) at the start of every
13334
+ * findPath call.
13335
+ * @memberof PathFinding */
13336
+ class PathFinderNode
13337
+ {
13338
+ /** @param {number} x - Tile x
13339
+ * @param {number} y - Tile y */
13340
+ constructor(x, y)
13341
+ {
13342
+ /** @property {Vector2} - Tile coords (integer) */
13343
+ this.pos = vec2(x, y);
13344
+ /** @property {Vector2} - World-space center of this tile (set by buildNodeData) */
13345
+ this.posWorld = vec2();
13346
+ /** @property {boolean} - True if this cell is passable (cleared each findPath call) */
13347
+ this.walkable = false;
13348
+ /** @property {number} - Extra cost added to A* G-score for stepping on this cell */
13349
+ this.cost = 0;
13350
+ /** @property {number} - A* G-score: actual cost from start to this node */
13351
+ this.g = 0;
13352
+ /** @property {number} - A* F-score: G + heuristic */
13353
+ this.f = 0;
13354
+ /** @property {PathFinderNode|null} - Parent for path reconstruction */
13355
+ this.parent = null;
13356
+ /** @property {boolean} - In the A* open list */
13357
+ this.isOpen = false;
13358
+ /** @property {boolean} - In the A* closed list */
13359
+ this.isClosed = false;
13360
+ }
13361
+
13362
+ /** Reset per-search state (called at the start of buildNodeData). */
13363
+ reset()
13364
+ {
13365
+ this.walkable = false;
13366
+ this.cost = 0;
13367
+ this.g = 0;
13368
+ this.f = 0;
13369
+ this.parent = null;
13370
+ this.isOpen = false;
13371
+ this.isClosed = false;
13372
+ }
13373
+
13374
+ /** True if walkable and not blocked by cost. */
13375
+ isClear()
13376
+ {
13377
+ return this.walkable && this.cost === 0;
13378
+ }
13379
+ }
13380
+
13381
+ ///////////////////////////////////////////////////////////////////////////////
13382
+
13383
+ /** Grid pathfinder using A* with two optional smoothing passes.
13384
+ * @memberof PathFinding
13385
+ * @example
13386
+ * // Tile-layer driven (most common):
13387
+ * const pf = new PathFinder(myTileCollisionLayer);
13388
+ * const path = pf.findPath(player.pos, mousePos);
13389
+ *
13390
+ * // Bare grid with custom walkability:
13391
+ * const pf = new PathFinder(vec2(50, 50));
13392
+ * pf.isWalkable = (x, y) => myGrid[y*50 + x] === 0;
13393
+ */
13394
+ class PathFinder
13395
+ {
13396
+ /** @param {TileCollisionLayer|Vector2} source - Either a TileCollisionLayer
13397
+ * (size and walkability auto-derived) or a Vector2 grid size (user
13398
+ * overrides isWalkable). */
13399
+ constructor(source)
13400
+ {
13401
+ // Accept either a Vector2 size or a TileCollisionLayer (which has a .size).
13402
+ // We don't import TileCollisionLayer to avoid coupling; we duck-type on
13403
+ // .size + .getCollisionData.
13404
+ if (isVector2(source))
13405
+ {
13406
+ this.size = source.floor();
13407
+ this.tileLayer = undefined;
13408
+ }
13409
+ else
13410
+ {
13411
+ ASSERT(source && isVector2(source.size) && typeof source.getCollisionData === 'function',
13412
+ 'PathFinder requires a Vector2 size or a TileCollisionLayer');
13413
+ this.size = source.size;
13414
+ this.tileLayer = source;
13415
+ }
13416
+
13417
+ // Tunables (public, freely re-assignable).
13418
+ this.heuristicWeight = 1;
13419
+ this.maxLoop = 500;
13420
+ this.smoothPath = true;
13421
+ this.debug = false;
13422
+ this.debugTime = 2;
13423
+
13424
+ // Pre-allocate the node array — one node per tile, reused across calls.
13425
+ this.nodes = new Array(this.size.x * this.size.y);
13426
+ for (let y = 0; y < this.size.y; ++y)
13427
+ for (let x = 0; x < this.size.x; ++x)
13428
+ this.nodes[x + y * this.size.x] = new PathFinderNode(x, y);
13429
+
13430
+ // Scratch Vector2 reused to avoid allocations in the isWalkable hot path.
13431
+ this.collisionScratch = vec2();
13432
+ }
13433
+
13434
+ /** Default walkability: if a tile layer was provided, returns true when the
13435
+ * cell has no solid collision data; otherwise returns true. Override on
13436
+ * the instance or via a subclass.
13437
+ * @param {number} x - Tile x
13438
+ * @param {number} y - Tile y
13439
+ * @returns {boolean} */
13440
+ isWalkable(x, y)
13441
+ {
13442
+ if (!this.tileLayer) return true;
13443
+ return !this.tileLayer.getCollisionData(this.collisionScratch.set(x, y));
13444
+ }
13445
+
13446
+ /** Default extra cost for stepping on a cell. Returns 0 (free) by default.
13447
+ * Override to add cost-weighted terrain (mud, swamp, etc).
13448
+ * @param {number} x - Tile x
13449
+ * @param {number} y - Tile y
13450
+ * @returns {number} */
13451
+ getCost(x, y)
13452
+ {
13453
+ return 0;
13454
+ }
13455
+
13456
+ /** Get the node at tile coords, or null if out of bounds.
13457
+ * @param {number} x
13458
+ * @param {number} y
13459
+ * @returns {PathFinderNode|null} */
13460
+ getNode(x, y)
13461
+ {
13462
+ if (x < 0 || y < 0 || x >= this.size.x || y >= this.size.y) return null;
13463
+ return this.nodes[x + y * this.size.x];
13464
+ }
13465
+
13466
+ /** Convert a world-space position to integer tile coords (no clamping).
13467
+ * @param {Vector2} worldPos
13468
+ * @returns {Vector2}
13469
+ * @memberof PathFinding */
13470
+ worldToTile(worldPos)
13471
+ {
13472
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
13473
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
13474
+ return vec2(floor(worldPos.x - ox), floor(worldPos.y - oy));
13475
+ }
13476
+
13477
+ /** Convert integer tile coords to the world-space center of that tile.
13478
+ * @param {number} x
13479
+ * @param {number} y
13480
+ * @returns {Vector2}
13481
+ * @memberof PathFinding */
13482
+ tileToWorld(x, y)
13483
+ {
13484
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
13485
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
13486
+ return vec2(x + 0.5 + ox, y + 0.5 + oy);
13487
+ }
13488
+
13489
+ /** Reset all nodes and re-populate walkable / cost / posWorld from the
13490
+ * current isWalkable / getCost overrides. Called at the start of
13491
+ * findPath; exposed so tests and tooling can drive it directly.
13492
+ * @private */
13493
+ buildNodeData()
13494
+ {
13495
+ const w = this.size.x;
13496
+ const h = this.size.y;
13497
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
13498
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
13499
+ for (let y = 0; y < h; ++y)
13500
+ for (let x = 0; x < w; ++x)
13501
+ {
13502
+ const node = this.nodes[x + y * w];
13503
+ node.reset();
13504
+ const walkable = !!this.isWalkable(x, y);
13505
+ const cost = walkable ? max(0, this.getCost(x, y)) : 0;
13506
+ node.walkable = walkable;
13507
+ node.cost = cost;
13508
+ node.posWorld.set(x + 0.5 + ox, y + 0.5 + oy);
13509
+
13510
+ if (this.debug && this.debugTime > 0)
13511
+ {
13512
+ if (!walkable)
13513
+ debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, 0.25), this.debugTime);
13514
+ else if (cost > 0)
13515
+ debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, min(0.2, cost * 0.05)), this.debugTime);
13516
+ }
13517
+ }
13518
+ }
13519
+
13520
+ /** Core A* search loop. Expects buildNodeData() to have been called first.
13521
+ * Marks node.parent for path reconstruction. Returns true if endNode was
13522
+ * reached; false on disconnected goal or maxLoop exhaustion.
13523
+ * @param {PathFinderNode} startNode
13524
+ * @param {PathFinderNode} endNode
13525
+ * @returns {boolean}
13526
+ * @private */
13527
+ aStarSearch(startNode, endNode)
13528
+ {
13529
+ ASSERT(startNode && endNode, 'aStarSearch needs both endpoints');
13530
+ ASSERT(startNode !== endNode, 'aStarSearch: start and end must differ — caller should handle trivial case');
13531
+ ASSERT(startNode.walkable && endNode.walkable, 'aStarSearch: endpoints must be walkable');
13532
+
13533
+ const openList = [startNode];
13534
+ startNode.isOpen = true;
13535
+ let loopCount = 0;
13536
+
13537
+ while (openList.length > 0)
13538
+ {
13539
+ // Find the open node with the smallest f score (linear scan).
13540
+ // Same as the C++ — fine up to a few thousand nodes.
13541
+ let bestIndex = 0;
13542
+ let bestF = openList[0].f;
13543
+ for (let i = 1; i < openList.length; ++i)
13544
+ {
13545
+ if (openList[i].f < bestF)
13546
+ {
13547
+ bestF = openList[i].f;
13548
+ bestIndex = i;
13549
+ }
13550
+ }
13551
+ const current = openList[bestIndex];
13552
+
13553
+ if (current === endNode) break;
13554
+ if (++loopCount > this.maxLoop) break;
13555
+
13556
+ // Move current from open to closed.
13557
+ current.isOpen = false;
13558
+ openList.splice(bestIndex, 1);
13559
+ current.isClosed = true;
13560
+
13561
+ if (this.debug && this.debugTime > 0)
13562
+ debugRect(current.posWorld, PATHFINDER_TILE_VEC, rgb(1, 1, 1, 0.05), this.debugTime);
13563
+
13564
+ // Expand all 8 neighbors.
13565
+ for (let dy = -1; dy <= 1; ++dy)
13566
+ for (let dx = -1; dx <= 1; ++dx)
13567
+ {
13568
+ if (dx === 0 && dy === 0) continue;
13569
+ const neighbor = this.getNode(current.pos.x + dx, current.pos.y + dy);
13570
+ if (!neighbor || !neighbor.walkable || neighbor.isClosed) continue;
13571
+
13572
+ let stepCost = 1;
13573
+ if (dx !== 0 && dy !== 0)
13574
+ {
13575
+ // Diagonal step: refuse if either cardinal neighbor is
13576
+ // blocked or has cost. Prevents cutting through corners.
13577
+ const card1 = this.getNode(current.pos.x + dx, current.pos.y);
13578
+ if (!card1 || card1.cost > 0 || !card1.walkable) continue;
13579
+ const card2 = this.getNode(current.pos.x, current.pos.y + dy);
13580
+ if (!card2 || card2.cost > 0 || !card2.walkable) continue;
13581
+ stepCost = PATHFINDER_DIAGONAL_COST;
13582
+ }
13583
+
13584
+ const tentativeG = current.g + stepCost + neighbor.cost;
13585
+ if (!neighbor.isOpen)
13586
+ {
13587
+ neighbor.isOpen = true;
13588
+ openList.push(neighbor);
13589
+ }
13590
+ else if (tentativeG >= neighbor.g)
13591
+ {
13592
+ continue;
13593
+ }
13594
+
13595
+ // Best path so far through neighbor — record it.
13596
+ neighbor.parent = current;
13597
+ neighbor.g = tentativeG;
13598
+ const gdx = endNode.pos.x - neighbor.pos.x;
13599
+ const gdy = endNode.pos.y - neighbor.pos.y;
13600
+ neighbor.f = neighbor.g + (gdx * gdx + gdy * gdy) * this.heuristicWeight;
13601
+ }
13602
+ }
13603
+
13604
+ return endNode.parent !== null;
13605
+ }
13606
+
13607
+ /** Find the clear (walkable, zero-cost) node closest to the given world
13608
+ * position. Spirals outward in expanding boxes until a clear node is
13609
+ * found or the search range is exhausted. Useful for snapping a click
13610
+ * or NPC spawn position to the nearest open tile.
13611
+ *
13612
+ * By default, calls `buildNodeData()` first so it works correctly on a
13613
+ * fresh PathFinder. If you're calling it many times in a row with
13614
+ * unchanged walkability, pass `rebuild=false` and call `buildNodeData()`
13615
+ * once externally to avoid redundant work.
13616
+ * @param {Vector2} worldPos
13617
+ * @param {number} [searchRange=10] - Max box-radius in tiles
13618
+ * @param {boolean} [rebuild=true] - Whether to call buildNodeData first
13619
+ * @returns {PathFinderNode|null}
13620
+ * @memberof PathFinding */
13621
+ getNearestClearNode(worldPos, searchRange = 10, rebuild = true)
13622
+ {
13623
+ ASSERT(isVector2(worldPos), 'worldPos must be a Vector2');
13624
+ if (rebuild) this.buildNodeData();
13625
+
13626
+ // Inline worldToTile to avoid a Vector2 allocation per call.
13627
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
13628
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
13629
+ const centerX = floor(worldPos.x - ox);
13630
+ const centerY = floor(worldPos.y - oy);
13631
+
13632
+ for (let offset = 0; offset <= searchRange; ++offset)
13633
+ {
13634
+ let nearest = null;
13635
+ let nearestDistSq = 0;
13636
+
13637
+ for (let dy = -offset; dy <= offset; ++dy)
13638
+ for (let dx = -offset; dx <= offset; ++dx)
13639
+ {
13640
+ // Only scan the perimeter of the current ring (skip the
13641
+ // interior we've already searched in earlier iterations).
13642
+ if (offset > 0 && abs(dx) !== offset && abs(dy) !== offset)
13643
+ continue;
13644
+
13645
+ const node = this.getNode(centerX + dx, centerY + dy);
13646
+ if (!node || !node.isClear()) continue;
13647
+
13648
+ const ddx = node.posWorld.x - worldPos.x;
13649
+ const ddy = node.posWorld.y - worldPos.y;
13650
+ const distSq = ddx * ddx + ddy * ddy;
13651
+ if (!nearest || distSq < nearestDistSq)
13652
+ {
13653
+ nearest = node;
13654
+ nearestDistSq = distSq;
13655
+ }
13656
+ }
13657
+ if (nearest) return nearest;
13658
+ }
13659
+ return null;
13660
+ }
13661
+
13662
+ /** Smooth a node path by removing redundant turns and tightening corners
13663
+ * where a grid-aligned diagonal is clear. Modifies the path in place.
13664
+ * Stays on the grid — does not introduce off-tile-center points.
13665
+ * Port of ShortenPath() in pathFinding.cpp.
13666
+ * @param {PathFinderNode[]} path
13667
+ * @private */
13668
+ smoothPathCorners(path)
13669
+ {
13670
+ if (path.length <= 2) return;
13671
+
13672
+ let i = 1;
13673
+ while (i < path.length - 1)
13674
+ {
13675
+ const prev = path[i - 1];
13676
+ const node = path[i];
13677
+ const next = path[i + 1];
13678
+
13679
+ const dx = next.pos.x - prev.pos.x;
13680
+ const dy = next.pos.y - prev.pos.y;
13681
+ const lenSq = dx * dx + dy * dy;
13682
+
13683
+ // dx,dy is the prev-to-current step direction; needed for the
13684
+ // 135° "mostly vertical/horizontal" disambiguation.
13685
+ const stepDx = node.pos.x - prev.pos.x;
13686
+ const stepDy = node.pos.y - prev.pos.y;
13687
+ const stepDxNext = next.pos.x - node.pos.x;
13688
+ const stepDyNext = next.pos.y - node.pos.y;
13689
+
13690
+ if (lenSq === 1)
13691
+ {
13692
+ // 45° angle — middle node is off the straight line. Drop it.
13693
+ if (this.debug && this.debugTime > 0)
13694
+ debugCircle(node.posWorld, 0.3, rgb(0.5, 0, 0.5, 0.5), this.debugTime);
13695
+ path.splice(i, 1);
13696
+ i = max(1, i - 1);
13697
+ continue;
13698
+ }
13699
+ else if (lenSq === 2)
13700
+ {
13701
+ // 90° corner. Check the alternative-diagonal cell.
13702
+ if (this.debug && this.debugTime > 0)
13703
+ debugCircle(node.posWorld, 0.3, rgb(1, 0, 0, 0.5), this.debugTime);
13704
+
13705
+ let sx, sy;
13706
+ if (prev.pos.y === node.pos.y && next.pos.x === node.pos.x)
13707
+ { sx = prev.pos.x; sy = next.pos.y; }
13708
+ else
13709
+ { sx = next.pos.x; sy = prev.pos.y; }
13710
+
13711
+ const shortcut = this.getNode(sx, sy);
13712
+ if (shortcut && shortcut.isClear())
13713
+ {
13714
+ path.splice(i, 1);
13715
+ i = max(1, i - 1);
13716
+ continue;
13717
+ }
13718
+ }
13719
+ else if (lenSq === 5)
13720
+ {
13721
+ // 135° angle (a knight's-move offset). Try to relocate the
13722
+ // middle node to whichever of two candidate cells is closer
13723
+ // to prev-of-prev, and only if the corner cut is also clear.
13724
+ if (this.debug && this.debugTime > 0)
13725
+ debugCircle(node.posWorld, 0.3, rgb(1, 1, 0, 0.5), this.debugTime);
13726
+
13727
+ const prevPrev = i >= 2 ? path[i - 2] : prev;
13728
+ let s1x, s1y, s2x, s2y;
13729
+ if (stepDx === 0 || stepDxNext === 0)
13730
+ {
13731
+ // mostly vertical
13732
+ s1x = next.pos.x; s1y = node.pos.y;
13733
+ s2x = prev.pos.x; s2y = node.pos.y;
13734
+ }
13735
+ else
13736
+ {
13737
+ // mostly horizontal
13738
+ s1x = node.pos.x; s1y = next.pos.y;
13739
+ s2x = node.pos.x; s2y = prev.pos.y;
13740
+ }
13741
+ const dd1x = s1x - prevPrev.pos.x;
13742
+ const dd1y = s1y - prevPrev.pos.y;
13743
+ const dd2x = s2x - prevPrev.pos.x;
13744
+ const dd2y = s2y - prevPrev.pos.y;
13745
+ const dist1Sq = dd1x * dd1x + dd1y * dd1y;
13746
+ const dist2Sq = dd2x * dd2x + dd2y * dd2y;
13747
+ const sx = dist1Sq < dist2Sq ? s1x : s1x === s2x && s1y === s2y ? s1x : s2x;
13748
+ const sy = dist1Sq < dist2Sq ? s1y : s1x === s2x && s1y === s2y ? s1y : s2y;
13749
+
13750
+ const shortcut = this.getNode(sx, sy);
13751
+ if (shortcut && shortcut !== node && shortcut.isClear())
13752
+ {
13753
+ // Also check the cut-corner cell is clear.
13754
+ const ccx = next.pos.x + s2x - s1x;
13755
+ const ccy = next.pos.y + s2y - s1y;
13756
+ const cutCorner = this.getNode(ccx, ccy);
13757
+ if (cutCorner && cutCorner.isClear())
13758
+ {
13759
+ path[i] = shortcut;
13760
+ i = max(1, i - 1);
13761
+ continue;
13762
+ }
13763
+ }
13764
+ }
13765
+ else if (lenSq === 4 || lenSq === 8)
13766
+ {
13767
+ // Straight line or a 1-cell bump.
13768
+ if (this.debug && this.debugTime > 0)
13769
+ debugCircle(node.posWorld, 0.3, rgb(0, 1, 0, 0.5), this.debugTime);
13770
+
13771
+ if (stepDx === stepDxNext && stepDy === stepDyNext)
13772
+ {
13773
+ // Truly straight — nothing to do, advance.
13774
+ ++i;
13775
+ continue;
13776
+ }
13777
+ else
13778
+ {
13779
+ // Bump — try to flatten via the in-line cell.
13780
+ let sx, sy;
13781
+ if (prev.pos.y === next.pos.y)
13782
+ { sx = node.pos.x; sy = prev.pos.y; }
13783
+ else
13784
+ { sx = prev.pos.x; sy = node.pos.y; }
13785
+ const shortcut = this.getNode(sx, sy);
13786
+ if (shortcut && shortcut.isClear())
13787
+ {
13788
+ path[i] = shortcut;
13789
+ i = max(1, i - 1);
13790
+ continue;
13791
+ }
13792
+ }
13793
+ }
13794
+
13795
+ ++i;
13796
+ }
13797
+ }
13798
+
13799
+ /** Smooth a node path via line-of-sight ("string pulling"). Walks the
13800
+ * input path collapsing runs of nodes into straight segments whenever
13801
+ * isLineClear permits, so the result can leave grid centers and cut
13802
+ * cleanly across open spaces.
13803
+ *
13804
+ * Bails (leaves the path unchanged) if any node has nonzero cost — a
13805
+ * straight geometric shortcut can't be trusted to be the lowest-cost
13806
+ * route when cost-weighted terrain is in play.
13807
+ *
13808
+ * Port of ShortenPath2() in pathFinding.cpp.
13809
+ * @param {PathFinderNode[]} path
13810
+ * @private */
13811
+ smoothPathStringPull(path)
13812
+ {
13813
+ if (path.length <= 2) return;
13814
+ for (const n of path)
13815
+ {
13816
+ if (!n.isClear()) return;
13817
+ }
13818
+
13819
+ const original = path.slice();
13820
+ path.length = 0;
13821
+ path.push(original[0]);
13822
+ let searchIndex = 0;
13823
+
13824
+ for (let i = 1; i < original.length; ++i)
13825
+ {
13826
+ const node = original[i];
13827
+
13828
+ // Skip if node is collinear with the search-window start and the
13829
+ // previous node — it adds no information. Note: a == b is the
13830
+ // degenerate i=1, searchIndex=0 case; skip the test then.
13831
+ {
13832
+ const a = original[searchIndex];
13833
+ const b = original[i - 1];
13834
+ if (a !== b)
13835
+ {
13836
+ const cross =
13837
+ (b.pos.x - a.pos.x) * (node.pos.y - a.pos.y) -
13838
+ (b.pos.y - a.pos.y) * (node.pos.x - a.pos.x);
13839
+ if (cross === 0) continue;
13840
+ }
13841
+ }
13842
+
13843
+ if (!this.isLineClear(node.pos, path[path.length - 1].pos))
13844
+ {
13845
+ // Look ahead — if any later node has a clear shot to the
13846
+ // back of our new path, skip this node and try later.
13847
+ let foundClearAfter = false;
13848
+ for (let j = i + 1; j < original.length; ++j)
13849
+ {
13850
+ if (this.isLineClear(original[j].pos, path[path.length - 1].pos))
13851
+ {
13852
+ foundClearAfter = true;
13853
+ break;
13854
+ }
13855
+ }
13856
+ if (foundClearAfter)
13857
+ {
13858
+ if (this.debug && this.debugTime > 0)
13859
+ debugLine(node.posWorld, path[path.length - 1].posWorld, rgb(0, 0, 1, 0.3), 0.02, this.debugTime);
13860
+ continue;
13861
+ }
13862
+
13863
+ // No clear line ahead — fall back to the last waypoint we did
13864
+ // have a clear line to. searchIndex tracks our scan position.
13865
+ for (; searchIndex < original.length; ++searchIndex)
13866
+ {
13867
+ const cand = original[searchIndex];
13868
+ if (this.isLineClear(node.pos, cand.pos))
13869
+ {
13870
+ path.push(cand);
13871
+ i = searchIndex;
13872
+ break;
13873
+ }
13874
+ }
13875
+ ASSERT(searchIndex < original.length, 'smoothPathStringPull: ran out of candidates');
13876
+ }
13877
+ }
13878
+
13879
+ path.push(original[original.length - 1]);
13880
+ }
13881
+
13882
+ /** Lookup helper: true when the node at tile coords (x, y) is in-bounds
13883
+ * and clear (walkable, zero-cost). Used by isLineClear's hot path.
13884
+ * @param {number} x
13885
+ * @param {number} y
13886
+ * @returns {boolean}
13887
+ * @private */
13888
+ isNodeClear(x, y)
13889
+ {
13890
+ const n = this.getNode(x, y);
13891
+ return n !== null && n.isClear();
13892
+ }
13893
+
13894
+ /** Check that the line between two tile-coord endpoints stays entirely
13895
+ * inside walkable, zero-cost cells. Stricter than just sampling along
13896
+ * the line — it also checks the diagonal-corner-adjacent cells so the
13897
+ * line can never "scrape past" a wall corner.
13898
+ *
13899
+ * Both endpoints must themselves be clear (asserted in debug). Port of
13900
+ * CheckLine() in pathFinding.cpp.
13901
+ * @param {Vector2} startPos - Tile coords
13902
+ * @param {Vector2} endPos - Tile coords
13903
+ * @returns {boolean}
13904
+ * @private */
13905
+ isLineClear(startPos, endPos)
13906
+ {
13907
+ ASSERT(isVector2(startPos) && isVector2(endPos), 'isLineClear needs Vector2 endpoints');
13908
+ ASSERT(this.isNodeClear(startPos.x, startPos.y) && this.isNodeClear(endPos.x, endPos.y),
13909
+ 'isLineClear endpoints must be in-bounds and clear');
13910
+
13911
+ const dx = endPos.x - startPos.x;
13912
+ const dy = endPos.y - startPos.y;
13913
+ const adx = abs(dx);
13914
+ const ady = abs(dy);
13915
+ const sx = sign(dx);
13916
+ const sy = sign(dy);
13917
+ let x = startPos.x;
13918
+ let y = startPos.y;
13919
+
13920
+ if (ady === adx)
13921
+ {
13922
+ // Pure diagonal.
13923
+ while (x !== endPos.x)
13924
+ {
13925
+ if (x !== startPos.x)
13926
+ {
13927
+ if (!this.isNodeClear(x, y)) return false;
13928
+ if (!this.isNodeClear(x, y - sy)) return false;
13929
+ }
13930
+ if (!this.isNodeClear(x, y + sy)) return false;
13931
+ x += sx;
13932
+ y += sy;
13933
+ }
13934
+ if (!this.isNodeClear(endPos.x, endPos.y - sy)) return false;
13935
+ }
13936
+ else if (ady < adx)
13937
+ {
13938
+ // Mostly horizontal.
13939
+ if (dy === 0)
13940
+ {
13941
+ // Purely horizontal.
13942
+ x += sx;
13943
+ while (x !== endPos.x)
13944
+ {
13945
+ if (!this.isNodeClear(x, y)) return false;
13946
+ x += sx;
13947
+ }
13948
+ }
13949
+ else
13950
+ {
13951
+ let lastY = startPos.y;
13952
+ while (x !== endPos.x)
13953
+ {
13954
+ y = startPos.y + Math.trunc((dy * (x - startPos.x)) / dx);
13955
+ if (lastY !== y)
13956
+ {
13957
+ if (!this.isNodeClear(x - sx, y + sy)) return false;
13958
+ if (!this.isNodeClear(x, y - sy)) return false;
13959
+ }
13960
+ lastY = y;
13961
+ if (x !== startPos.x)
13962
+ {
13963
+ if (!this.isNodeClear(x, y)) return false;
13964
+ }
13965
+ y += sy;
13966
+ if (!this.isNodeClear(x, y)) return false;
13967
+ x += sx;
13968
+ }
13969
+ const finalY = endPos.y - sy;
13970
+ if (!this.isNodeClear(endPos.x, finalY)) return false;
13971
+ }
13972
+ }
13973
+ else
13974
+ {
13975
+ // Mostly vertical.
13976
+ if (dx === 0)
13977
+ {
13978
+ y += sy;
13979
+ while (y !== endPos.y)
13980
+ {
13981
+ if (!this.isNodeClear(x, y)) return false;
13982
+ y += sy;
13983
+ }
13984
+ }
13985
+ else
13986
+ {
13987
+ let lastX = startPos.x;
13988
+ while (y !== endPos.y)
13989
+ {
13990
+ x = startPos.x + Math.trunc((dx * (y - startPos.y)) / dy);
13991
+ if (lastX !== x)
13992
+ {
13993
+ if (!this.isNodeClear(x + sx, y - sy)) return false;
13994
+ if (!this.isNodeClear(x - sx, y)) return false;
13995
+ }
13996
+ lastX = x;
13997
+ if (y !== startPos.y)
13998
+ {
13999
+ if (!this.isNodeClear(x, y)) return false;
14000
+ }
14001
+ x += sx;
14002
+ if (!this.isNodeClear(x, y)) return false;
14003
+ y += sy;
14004
+ }
14005
+ const finalX = endPos.x - sx;
14006
+ if (!this.isNodeClear(finalX, endPos.y)) return false;
14007
+ }
14008
+ }
14009
+ return true;
14010
+ }
14011
+
14012
+ /** Find a path from startPos to endPos in world space. Returns an array
14013
+ * of world-space Vector2 points; empty array if no path exists.
14014
+ *
14015
+ * Start and end are snapped to the nearest walkable tile via
14016
+ * getNearestClearNode. Intermediate points are tile centers unless the
14017
+ * string-pulling smoothing pass moves them off-grid.
14018
+ * @param {Vector2} startPos - World-space start
14019
+ * @param {Vector2} endPos - World-space end
14020
+ * @returns {Vector2[]}
14021
+ * @memberof PathFinding */
14022
+ findPath(startPos, endPos)
14023
+ {
14024
+ ASSERT(isVector2(startPos) && isVector2(endPos), 'findPath needs Vector2 endpoints');
14025
+
14026
+ this.buildNodeData();
14027
+
14028
+ // rebuild=false because we just built — avoid redundant work per snap.
14029
+ const startNode = this.getNearestClearNode(startPos, 10, false);
14030
+ const endNode = this.getNearestClearNode(endPos, 10, false);
14031
+ if (!startNode || !endNode) return [];
14032
+
14033
+ // Trivial case: start and end snapped to the same tile.
14034
+ if (startNode === endNode) return [startNode.posWorld.copy()];
14035
+
14036
+ if (!this.aStarSearch(startNode, endNode)) return [];
14037
+
14038
+ // Walk back from endNode via parent pointers, then reverse — cheaper
14039
+ // than unshifting on every step.
14040
+ const nodePath = [];
14041
+ for (let n = endNode; n; n = n.parent)
14042
+ nodePath.push(n);
14043
+ nodePath.reverse();
14044
+
14045
+ if (this.smoothPath)
14046
+ {
14047
+ this.smoothPathCorners(nodePath);
14048
+ this.smoothPathStringPull(nodePath);
14049
+ }
14050
+
14051
+ // Convert to world-space Vector2 path. Return copies, not live node
14052
+ // references — callers shouldn't be able to mutate the grid.
14053
+ const result = nodePath.map(n => n.posWorld.copy());
14054
+
14055
+ if (this.debug && this.debugTime > 0 && result.length > 0)
14056
+ {
14057
+ for (let i = 1; i < result.length; ++i)
14058
+ debugLine(result[i - 1], result[i], RED, 0.1, this.debugTime);
14059
+ for (const p of result)
14060
+ debugCircle(p, 0.5, rgb(1, 0, 0, 0.3), this.debugTime);
14061
+ debugCircle(result[0], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
14062
+ debugCircle(result[result.length - 1], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
14063
+ }
14064
+
14065
+ return result;
14066
+ }
14067
+ }
14068
+