littlejsengine 1.18.4 → 1.18.8

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 (176) 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 +354 -55
  5. package/dist/littlejs.esm.js +1531 -489
  6. package/dist/littlejs.esm.min.js +1 -1
  7. package/dist/littlejs.js +1499 -476
  8. package/dist/littlejs.min.js +1 -1
  9. package/dist/littlejs.release.js +1493 -470
  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 → tweenSystem.js} +7 -7
  16. package/plugins/uiSystem.js +186 -25
  17. package/src/engine.js +2 -141
  18. package/src/engineAudio.js +3 -3
  19. package/src/engineBuild.mjs +4 -2
  20. package/src/engineDebug.js +7 -7
  21. package/src/engineDraw.js +26 -8
  22. package/src/engineExport.js +13 -13
  23. package/src/engineInput.js +8 -8
  24. package/src/engineLogo.js +146 -0
  25. package/src/engineMath.js +15 -6
  26. package/src/engineObject.js +11 -5
  27. package/src/engineParticles.js +6 -3
  28. package/src/engineRelease.js +1 -1
  29. package/src/engineSettings.js +0 -47
  30. package/src/engineUtilities.js +13 -8
  31. package/src/engineWebGL.js +34 -7
  32. package/src/jsconfig.json +3 -1
  33. package/.github/workflows/test.yml +0 -17
  34. package/AI.md +0 -173
  35. package/CLAUDE.md +0 -1
  36. package/examples/box2d/game.js +0 -192
  37. package/examples/box2d/gameObjects.js +0 -564
  38. package/examples/box2d/index.html +0 -9
  39. package/examples/box2d/scenes.js +0 -194
  40. package/examples/box2d/tiles.png +0 -0
  41. package/examples/breakout/game.js +0 -175
  42. package/examples/breakout/gameObjects.js +0 -147
  43. package/examples/breakout/index.html +0 -8
  44. package/examples/breakout/tiles.png +0 -0
  45. package/examples/breakoutTutorial/README.md +0 -521
  46. package/examples/breakoutTutorial/game.js +0 -191
  47. package/examples/breakoutTutorial/images/1.png +0 -0
  48. package/examples/breakoutTutorial/images/10.png +0 -0
  49. package/examples/breakoutTutorial/images/11.png +0 -0
  50. package/examples/breakoutTutorial/images/2.png +0 -0
  51. package/examples/breakoutTutorial/images/3.png +0 -0
  52. package/examples/breakoutTutorial/images/4.png +0 -0
  53. package/examples/breakoutTutorial/images/5.png +0 -0
  54. package/examples/breakoutTutorial/images/6.png +0 -0
  55. package/examples/breakoutTutorial/images/7.png +0 -0
  56. package/examples/breakoutTutorial/images/8.png +0 -0
  57. package/examples/breakoutTutorial/images/9.png +0 -0
  58. package/examples/breakoutTutorial/index.html +0 -8
  59. package/examples/electron/build.mjs +0 -127
  60. package/examples/electron/electron.js +0 -35
  61. package/examples/electron/game.js +0 -54
  62. package/examples/electron/index.html +0 -13
  63. package/examples/electron/package.json +0 -5
  64. package/examples/electron/tiles.png +0 -0
  65. package/examples/empty/game.js +0 -51
  66. package/examples/empty/index.html +0 -5
  67. package/examples/empty/tiles.png +0 -0
  68. package/examples/favicon.png +0 -0
  69. package/examples/games.jpg +0 -0
  70. package/examples/htmlMenu/game.js +0 -82
  71. package/examples/htmlMenu/index.html +0 -45
  72. package/examples/htmlMenu/tiles.png +0 -0
  73. package/examples/index.html +0 -62
  74. package/examples/logo.png +0 -0
  75. package/examples/logo2.png +0 -0
  76. package/examples/module/build.mjs +0 -124
  77. package/examples/module/game.js +0 -132
  78. package/examples/module/index.html +0 -10
  79. package/examples/module/tiles.png +0 -0
  80. package/examples/particles/index.html +0 -426
  81. package/examples/particles/tiles.png +0 -0
  82. package/examples/platformer/data/gameLevelData.tmx +0 -143
  83. package/examples/platformer/data/gameLevelData.tsx +0 -4
  84. package/examples/platformer/game.js +0 -147
  85. package/examples/platformer/gameCharacter.js +0 -309
  86. package/examples/platformer/gameEffects.js +0 -278
  87. package/examples/platformer/gameLevel.js +0 -205
  88. package/examples/platformer/gameLevelData.json +0 -171
  89. package/examples/platformer/gameObjects.js +0 -378
  90. package/examples/platformer/gamePlayer.js +0 -35
  91. package/examples/platformer/index.html +0 -9
  92. package/examples/platformer/tiles.png +0 -0
  93. package/examples/platformer/tilesLevel.png +0 -0
  94. package/examples/puzzle/game.js +0 -331
  95. package/examples/puzzle/index.html +0 -8
  96. package/examples/puzzle/tiles.png +0 -0
  97. package/examples/screenshot.jpg +0 -0
  98. package/examples/shorts/animation.js +0 -18
  99. package/examples/shorts/base.html +0 -54
  100. package/examples/shorts/blending.js +0 -14
  101. package/examples/shorts/box2d.js +0 -51
  102. package/examples/shorts/box2dCar.js +0 -59
  103. package/examples/shorts/box2dPool.js +0 -107
  104. package/examples/shorts/box2dTileLayer.js +0 -48
  105. package/examples/shorts/cameraDrag.js +0 -22
  106. package/examples/shorts/clock.js +0 -21
  107. package/examples/shorts/colors.js +0 -25
  108. package/examples/shorts/debugDraw.js +0 -37
  109. package/examples/shorts/empty.js +0 -30
  110. package/examples/shorts/flappyGame.js +0 -55
  111. package/examples/shorts/fontImage.js +0 -17
  112. package/examples/shorts/fps.js +0 -90
  113. package/examples/shorts/helloWorld.js +0 -11
  114. package/examples/shorts/hillGlideGame.js +0 -63
  115. package/examples/shorts/input.js +0 -64
  116. package/examples/shorts/landerGame.js +0 -57
  117. package/examples/shorts/maze.js +0 -48
  118. package/examples/shorts/medals.js +0 -51
  119. package/examples/shorts/music.js +0 -78
  120. package/examples/shorts/musicPlayer.js +0 -137
  121. package/examples/shorts/nineSlice.js +0 -40
  122. package/examples/shorts/parallax.js +0 -72
  123. package/examples/shorts/particles.js +0 -28
  124. package/examples/shorts/piano.js +0 -44
  125. package/examples/shorts/platformer.js +0 -45
  126. package/examples/shorts/pongGame.js +0 -41
  127. package/examples/shorts/postProcess.js +0 -62
  128. package/examples/shorts/sequencer.js +0 -124
  129. package/examples/shorts/shader.js +0 -29
  130. package/examples/shorts/shapes.js +0 -21
  131. package/examples/shorts/slidingPuzzle.js +0 -52
  132. package/examples/shorts/song.mp3 +0 -0
  133. package/examples/shorts/sound.js +0 -36
  134. package/examples/shorts/spaceGame.js +0 -58
  135. package/examples/shorts/spriteAtlas.js +0 -31
  136. package/examples/shorts/starfield.js +0 -15
  137. package/examples/shorts/texture.js +0 -16
  138. package/examples/shorts/textureWrapped.js +0 -12
  139. package/examples/shorts/tileLayer.js +0 -48
  140. package/examples/shorts/tileRaycast.js +0 -39
  141. package/examples/shorts/tiles.png +0 -0
  142. package/examples/shorts/tiltedView.js +0 -63
  143. package/examples/shorts/timers.js +0 -53
  144. package/examples/shorts/topDown.js +0 -38
  145. package/examples/shorts/tween.js +0 -24
  146. package/examples/shorts/uiSystem.js +0 -61
  147. package/examples/shorts/video.webm +0 -0
  148. package/examples/shorts/videoPlayer.js +0 -34
  149. package/examples/shorts.js +0 -743
  150. package/examples/starter/build.bat +0 -7
  151. package/examples/starter/build.mjs +0 -126
  152. package/examples/starter/game.js +0 -137
  153. package/examples/starter/index.html +0 -35
  154. package/examples/starter/tiles.png +0 -0
  155. package/examples/stress/index.html +0 -173
  156. package/examples/style.css +0 -150
  157. package/examples/tweenSystem/game.js +0 -171
  158. package/examples/tweenSystem/index.html +0 -10
  159. package/examples/tweenSystem/tiles.png +0 -0
  160. package/examples/typescript/build.mjs +0 -60
  161. package/examples/typescript/game.js +0 -100
  162. package/examples/typescript/game.ts +0 -132
  163. package/examples/typescript/index.html +0 -10
  164. package/examples/typescript/tiles.png +0 -0
  165. package/examples/typescript/tsconfig.json +0 -17
  166. package/examples/uiSystem/game.js +0 -139
  167. package/examples/uiSystem/index.html +0 -10
  168. package/examples/uiSystem/tiles.png +0 -0
  169. package/jsconfig.json +0 -12
  170. package/plugins/desktop.ini +0 -2
  171. package/reference.md +0 -448
  172. package/test/math.test.mjs +0 -774
  173. package/test/setup.mjs +0 -22
  174. package/test/smoke.test.mjs +0 -274
  175. package/test/tween.test.mjs +0 -576
  176. package/test/util.test.mjs +0 -80
package/dist/littlejs.js CHANGED
@@ -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.8';
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
@@ -784,7 +645,7 @@ function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false, s
784
645
  {
785
646
  ASSERT(isVector2(pos), 'pos must be a vec2');
786
647
  ASSERT(isVector2(size), 'size must be a vec2');
787
- ASSERT(isString(color) || isColor(color), 'color is invalid');
648
+ ASSERT(isStringLike(color) || isColor(color), 'color is invalid');
788
649
  ASSERT(isNumber(time), 'time must be a number');
789
650
  ASSERT(isNumber(angle), 'angle must be a number');
790
651
 
@@ -811,7 +672,7 @@ function debugPoly(pos, points, color=WHITE, time=0, angle=0, fill=false, screen
811
672
  {
812
673
  ASSERT(isVector2(pos), 'pos must be a vec2');
813
674
  ASSERT(isArray(points), 'points must be an array');
814
- ASSERT(isString(color) || isColor(color), 'color is invalid');
675
+ ASSERT(isStringLike(color) || isColor(color), 'color is invalid');
815
676
  ASSERT(isNumber(time), 'time must be a number');
816
677
  ASSERT(isNumber(angle), 'angle must be a number');
817
678
 
@@ -835,7 +696,7 @@ function debugCircle(pos, size=0, color=WHITE, time=0, fill=false, screenSpace=f
835
696
  {
836
697
  ASSERT(isVector2(pos), 'pos must be a vec2');
837
698
  ASSERT(isNumber(size), 'size must be a number');
838
- ASSERT(isString(color) || isColor(color), 'color is invalid');
699
+ ASSERT(isStringLike(color) || isColor(color), 'color is invalid');
839
700
  ASSERT(isNumber(time), 'time must be a number');
840
701
 
841
702
  if (isColor(color))
@@ -913,13 +774,13 @@ function debugOverlap(posA, sizeA, posB, sizeB, color, time, screenSpace=false)
913
774
  * @memberof Debug */
914
775
  function debugText(text, pos, size=1, color=WHITE, time=0, angle=0, font='monospace', screenSpace=false)
915
776
  {
916
- ASSERT(isString(text), 'text must be a string');
777
+ ASSERT(isStringLike(text), 'text must be a string');
917
778
  ASSERT(isVector2(pos), 'pos must be a vec2');
918
779
  ASSERT(isNumber(size), 'size must be a number');
919
- ASSERT(isString(color) || isColor(color), 'color is invalid');
780
+ ASSERT(isStringLike(color) || isColor(color), 'color is invalid');
920
781
  ASSERT(isNumber(time), 'time must be a number');
921
782
  ASSERT(isNumber(angle), 'angle must be a number');
922
- ASSERT(isString(font), 'font must be a string');
783
+ ASSERT(isStringLike(font), 'font must be a string');
923
784
 
924
785
  if (isColor(color))
925
786
  color = color.toString();
@@ -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
@@ -1715,13 +1580,15 @@ function oscillate(frequency=1, amplitude=1, t=time, offset=0, type=0)
1715
1580
  function isNumber(n) { return typeof n === 'number' && !isNaN(n); }
1716
1581
 
1717
1582
  /**
1718
- * Check if object can be converted to a string (has a toString method)
1583
+ * Check if a value is stringifiable i.e. it has a toString that returns
1584
+ * a string. Use this for ASSERTs and inputs that will be coerced to text;
1585
+ * use `typeof x === 'string'` inline if you need strict-string semantics.
1719
1586
  * - Returns true for strings, numbers, and most objects
1720
1587
  * - Returns false for null and undefined
1721
1588
  * @param {any} s
1722
1589
  * @return {boolean}
1723
1590
  * @memberof Math */
1724
- function isString(s) { return s != null && typeof s?.toString() === 'string'; }
1591
+ function isStringLike(s) { return s != null && typeof s?.toString() === 'string'; }
1725
1592
 
1726
1593
  /**
1727
1594
  * Check if object is an array
@@ -1897,6 +1764,7 @@ class RandomGenerator
1897
1764
  * @param {number} [seed] - Starting seed or engine default seed */
1898
1765
  constructor(seed = 123456789)
1899
1766
  {
1767
+ ASSERT(seed !== 0, 'RandomGenerator seed must be non-zero (xorshift is fixed at 0)');
1900
1768
  /** @property {number} - random seed */
1901
1769
  this.seed = seed;
1902
1770
  }
@@ -2190,8 +2058,10 @@ class Vector2
2190
2058
  * @return {Vector2} */
2191
2059
  floor() { return new Vector2(floor(this.x), floor(this.y)); }
2192
2060
 
2193
- /** Returns a copy of this vector snapped to a grid
2194
- * @param {number} grid - grid size to snap to
2061
+ /** Returns a copy of this vector snapped to a grid. Note that `grid` is
2062
+ * the number of snap steps per unit (so `grid=2` snaps to halves and
2063
+ * `grid=0.5` snaps to twos), not the cell size.
2064
+ * @param {number} grid - snap steps per unit
2195
2065
  * @return {Vector2} */
2196
2066
  snap(grid)
2197
2067
  {
@@ -2462,7 +2332,7 @@ class Color
2462
2332
  * @return {Color} */
2463
2333
  setHex(hex)
2464
2334
  {
2465
- ASSERT(isString(hex), 'Color hex code must be a string');
2335
+ ASSERT(isStringLike(hex), 'Color hex code must be a string');
2466
2336
  ASSERT(hex[0] === '#', 'Color hex code must start with #');
2467
2337
  ASSERT([4,5,7,9].includes(hex.length), 'Invalid hex');
2468
2338
 
@@ -2726,8 +2596,8 @@ function saveCanvas(canvas, filename='screenshot', type='image/png')
2726
2596
  * @memberof Utilities */
2727
2597
  function saveDataURL(url, filename='download', revokeTime)
2728
2598
  {
2729
- ASSERT(isString(url), 'saveDataURL requires url string');
2730
- ASSERT(isString(filename), 'saveDataURL requires filename string');
2599
+ ASSERT(isStringLike(url), 'saveDataURL requires url string');
2600
+ ASSERT(isStringLike(filename), 'saveDataURL requires filename string');
2731
2601
 
2732
2602
  // create link for saving screenshots
2733
2603
  const link = document.createElement('a');
@@ -2745,8 +2615,8 @@ function saveDataURL(url, filename='download', revokeTime)
2745
2615
  * @memberof Utilities */
2746
2616
  function shareURL(title, url, callback)
2747
2617
  {
2748
- ASSERT(isString(title), 'shareURL requires title string');
2749
- ASSERT(isString(url), 'shareURL requires url string');
2618
+ ASSERT(isStringLike(title), 'shareURL requires title string');
2619
+ ASSERT(isStringLike(url), 'shareURL requires url string');
2750
2620
  navigator.share?.({title, url}).then(()=>callback?.());
2751
2621
  }
2752
2622
 
@@ -2759,11 +2629,16 @@ function shareURL(title, url, callback)
2759
2629
  * @memberof Utilities */
2760
2630
  function readSaveData(saveName, defaultSaveData)
2761
2631
  {
2762
- ASSERT(isString(saveName), 'loadData requires saveName string');
2632
+ ASSERT(isStringLike(saveName), 'loadData requires saveName string');
2763
2633
 
2764
- // replace undefined values with defaults
2634
+ // replace undefined values with defaults; tolerate corrupt JSON
2765
2635
  const data = localStorage[saveName];
2766
- const loadedData = data ? JSON.parse(data) : {};
2636
+ let loadedData = {};
2637
+ if (data)
2638
+ {
2639
+ try { loadedData = JSON.parse(data); }
2640
+ catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
2641
+ }
2767
2642
  return { ...defaultSaveData, ...loadedData };
2768
2643
  }
2769
2644
 
@@ -2773,7 +2648,7 @@ function readSaveData(saveName, defaultSaveData)
2773
2648
  * @memberof Utilities */
2774
2649
  function writeSaveData(saveName, saveData)
2775
2650
  {
2776
- ASSERT(isString(saveName), 'saveData requires saveName string');
2651
+ ASSERT(isStringLike(saveName), 'saveData requires saveName string');
2777
2652
  localStorage[saveName] = JSON.stringify(saveData);
2778
2653
  }
2779
2654
  /**
@@ -3098,33 +2973,6 @@ let soundDefaultRange = 40;
3098
2973
  * @memberof Settings */
3099
2974
  let soundDefaultTaper = .7;
3100
2975
 
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
2976
  ///////////////////////////////////////////////////////////////////////////////
3129
2977
  // Setters for global variables
3130
2978
 
@@ -3386,26 +3234,6 @@ function setSoundDefaultRange(range) { soundDefaultRange = range; }
3386
3234
  * @memberof Settings */
3387
3235
  function setSoundDefaultTaper(taper) { soundDefaultTaper = taper; }
3388
3236
 
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
3237
  /** Set if watermark with FPS should be shown
3410
3238
  * @param {boolean} show
3411
3239
  * @memberof Debug */
@@ -3547,10 +3375,18 @@ class EngineObject
3547
3375
  const parent = this.parent;
3548
3376
  if (parent)
3549
3377
  {
3550
- // copy parent pos/angle
3378
+ // compose with parent transform inline to avoid intermediate vector allocs
3551
3379
  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;
3380
+ const lp = this.localPos, pp = parent.pos;
3381
+ const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
3382
+ if (pa)
3383
+ {
3384
+ const c = cos(-pa), s = sin(-pa);
3385
+ this.pos = new Vector2(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
3386
+ }
3387
+ else
3388
+ this.pos = new Vector2(lx + pp.x, ly + pp.y);
3389
+ this.angle = mirror*this.localAngle + pa;
3554
3390
  }
3555
3391
 
3556
3392
  // update children
@@ -3916,8 +3752,6 @@ class EngineObject
3916
3752
  * @return {string} */
3917
3753
  toString()
3918
3754
  {
3919
- if (!debug) return;
3920
-
3921
3755
  let text = 'type = ' + this.constructor.name;
3922
3756
  if (this.pos.x || this.pos.y)
3923
3757
  text += '\npos = ' + this.pos;
@@ -4167,8 +4001,9 @@ class TextureInfo
4167
4001
  * Create a TextureInfo, called automatically by the engine
4168
4002
  * @param {HTMLImageElement|OffscreenCanvas} image
4169
4003
  * @param {boolean} [useWebGL] - Should use WebGL if available?
4004
+ * @param {boolean} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)?
4170
4005
  */
4171
- constructor(image, useWebGL=true)
4006
+ constructor(image, useWebGL=true, wrap=false)
4172
4007
  {
4173
4008
  /** @property {HTMLImageElement|OffscreenCanvas} - image source */
4174
4009
  this.image = image;
@@ -4178,6 +4013,8 @@ class TextureInfo
4178
4013
  this.sizeInverse = image ? vec2(1/image.width, 1/image.height) : vec2();
4179
4014
  /** @property {WebGLTexture} - WebGL texture */
4180
4015
  this.glTexture = undefined;
4016
+ /** @property {boolean} - true for REPEAT wrap mode, false for CLAMP_TO_EDGE */
4017
+ this.wrap = wrap;
4181
4018
  useWebGL && this.createWebGLTexture();
4182
4019
  }
4183
4020
 
@@ -4190,6 +4027,14 @@ class TextureInfo
4190
4027
  /** Check if the texture is webgl enabled
4191
4028
  * @return {boolean} */
4192
4029
  hasWebGL() { return !!this.glTexture; }
4030
+
4031
+ /** Set the wrap mode for this texture
4032
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE */
4033
+ setWrap(wrap=true)
4034
+ {
4035
+ this.wrap = wrap;
4036
+ glSetTextureWrap(this.glTexture, wrap);
4037
+ }
4193
4038
  }
4194
4039
 
4195
4040
  ///////////////////////////////////////////////////////////////////////////////
@@ -4251,8 +4096,13 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
4251
4096
  }
4252
4097
  else
4253
4098
  {
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());
4099
+ // if no tile info, force untextured by zeroing rgba (so whatever
4100
+ // texture is bound doesn't leak in) and folding color+additive
4101
+ // into the additive slot — matches the Canvas2D path's
4102
+ // color.add(additiveColor) on line ~337.
4103
+ const combined = additiveColor ? color.add(additiveColor) : color;
4104
+ glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0,
4105
+ 0, combined.rgbaInt());
4256
4106
  }
4257
4107
  }
4258
4108
  else
@@ -4391,6 +4241,8 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
4391
4241
  const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
4392
4242
  ASSERT(textureInfo instanceof TextureInfo, 'texture not loaded');
4393
4243
  ASSERT(textureInfo.size.x > 0, 'texture not loaded');
4244
+ ASSERT(textureInfo.wrap,
4245
+ 'drawTextureWrapped requires a wrap-enabled texture; call textureInfo.setWrap(true) first');
4394
4246
 
4395
4247
  if (useWebGL && glEnable)
4396
4248
  {
@@ -4742,15 +4594,15 @@ function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, f
4742
4594
  * @memberof Draw */
4743
4595
  function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
4744
4596
  {
4745
- ASSERT(isString(text), 'text must be a string');
4597
+ ASSERT(isStringLike(text), 'text must be a string');
4746
4598
  ASSERT(isVector2(pos), 'pos must be a vec2');
4747
4599
  ASSERT(isNumber(size), 'size must be a number');
4748
4600
  ASSERT(isColor(color), 'color must be a color');
4749
4601
  ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
4750
4602
  ASSERT(isColor(lineColor), 'lineColor must be a color');
4751
4603
  ASSERT(['left','center','right'].includes(textAlign), 'align must be left, center, or right');
4752
- ASSERT(isString(font), 'font must be a string');
4753
- ASSERT(isString(fontStyle), 'fontStyle must be a string');
4604
+ ASSERT(isStringLike(font), 'font must be a string');
4605
+ ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
4754
4606
  ASSERT(isNumber(angle), 'angle must be a number');
4755
4607
 
4756
4608
  context.fillStyle = color.toString();
@@ -4787,7 +4639,7 @@ async function loadTexture(textureIndex, src)
4787
4639
  {
4788
4640
  ASSERT(isNumber(textureIndex), 'textureIndex must be a number');
4789
4641
  ASSERT(!textureInfos[textureIndex], 'textureIndex is already loaded!');
4790
- ASSERT(!src || isString(src), 'image src must be a string');
4642
+ ASSERT(!src || isStringLike(src), 'image src must be a string');
4791
4643
 
4792
4644
  const image = new Image;
4793
4645
  if (src)
@@ -5175,7 +5027,7 @@ class FontImage
5175
5027
  */
5176
5028
  drawTextScreen(text, pos, size, center=true, color=WHITE, useWebGL=glEnable, context)
5177
5029
  {
5178
- ASSERT(isString(text), 'text must be a string');
5030
+ ASSERT(isStringLike(text), 'text must be a string');
5179
5031
  ASSERT(isVector2(pos), 'pos must be a vec2');
5180
5032
  ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
5181
5033
  ASSERT(isColor(color), 'color must be a color');
@@ -5299,7 +5151,7 @@ const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
5299
5151
  * This is useful to disable for html menus so the browser can handle input normally
5300
5152
  * @param {boolean} preventDefault
5301
5153
  * @memberof Input */
5302
- function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
5154
+ function setInputPreventDefault(preventDefault=true) { inputPreventDefault = preventDefault; }
5303
5155
 
5304
5156
  /** Clears an input key state
5305
5157
  * @param {string|number} key
@@ -5336,7 +5188,7 @@ function inputClear()
5336
5188
  * @memberof Input */
5337
5189
  function keyIsDown(key, device=0)
5338
5190
  {
5339
- ASSERT(isString(key), 'key must be a number or string');
5191
+ ASSERT(isStringLike(key), 'key must be a number or string');
5340
5192
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
5341
5193
  return !!(inputData[device]?.[key] & 1);
5342
5194
  }
@@ -5348,7 +5200,7 @@ function keyIsDown(key, device=0)
5348
5200
  * @memberof Input */
5349
5201
  function keyWasPressed(key, device=0)
5350
5202
  {
5351
- ASSERT(isString(key), 'key must be a number or string');
5203
+ ASSERT(isStringLike(key), 'key must be a number or string');
5352
5204
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
5353
5205
  return !!(inputData[device]?.[key] & 2);
5354
5206
  }
@@ -5360,7 +5212,7 @@ function keyWasPressed(key, device=0)
5360
5212
  * @memberof Input */
5361
5213
  function keyWasReleased(key, device=0)
5362
5214
  {
5363
- ASSERT(isString(key), 'key must be a number or string');
5215
+ ASSERT(isStringLike(key), 'key must be a number or string');
5364
5216
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
5365
5217
  return !!(inputData[device]?.[key] & 4);
5366
5218
  }
@@ -5374,10 +5226,10 @@ function keyWasReleased(key, device=0)
5374
5226
  * @memberof Input */
5375
5227
  function keyDirection(up='ArrowUp', down='ArrowDown', left='ArrowLeft', right='ArrowRight')
5376
5228
  {
5377
- ASSERT(isString(up), 'up key must be a string');
5378
- ASSERT(isString(down), 'down key must be a string');
5379
- ASSERT(isString(left), 'left key must be a string');
5380
- ASSERT(isString(right), 'right key must be a string');
5229
+ ASSERT(isStringLike(up), 'up key must be a string');
5230
+ ASSERT(isStringLike(down), 'down key must be a string');
5231
+ ASSERT(isStringLike(left), 'left key must be a string');
5232
+ ASSERT(isStringLike(right), 'right key must be a string');
5381
5233
  const k = (key)=> keyIsDown(key) ? 1 : 0;
5382
5234
  return vec2(k(right) - k(left), k(up) - k(down));
5383
5235
  }
@@ -6165,7 +6017,7 @@ class Sound
6165
6017
  {
6166
6018
  if (!soundEnable || headlessMode) return;
6167
6019
 
6168
- ASSERT(!asset || isArray(asset) || isString(asset), 'asset must be a file name or zzfx array');
6020
+ ASSERT(!asset || isArray(asset) || isStringLike(asset), 'asset must be a file name or zzfx array');
6169
6021
  ASSERT(randomness === undefined || isNumber(randomness), 'randomness must be a number');
6170
6022
  ASSERT(randomness === undefined || randomness >= 0 && randomness <=1, 'randomness must be between 0 and 1');
6171
6023
  ASSERT(isNumber(range), 'range must be a number');
@@ -6186,8 +6038,8 @@ class Sound
6186
6038
 
6187
6039
  if (Array.isArray(asset))
6188
6040
  {
6189
- // generate zzfx sound
6190
- const zzfxSound = asset;
6041
+ // generate zzfx sound — copy so we don't mutate the caller's array
6042
+ const zzfxSound = asset.slice();
6191
6043
 
6192
6044
  // remove randomness so it can be applied on playback
6193
6045
  const defaultRandomness = randomness ?? .05;
@@ -7785,11 +7637,14 @@ class ParticleEmitter extends EngineObject
7785
7637
  }
7786
7638
 
7787
7639
  ///////////////////////////////////////////////////////////////////////////////
7640
+ // scratch vector reused by Particle.render to avoid per-frame allocations
7641
+ const particleDrawPos = new Vector2;
7642
+
7788
7643
  /**
7789
7644
  * Particle Object - Created automatically by Particle Emitters
7790
7645
  * @memberof Particles
7791
7646
  */
7792
- class Particle
7647
+ class Particle
7793
7648
  {
7794
7649
  /**
7795
7650
  * Create a particle with the passed in settings
@@ -7964,14 +7819,14 @@ class Particle
7964
7819
  additive && setBlendMode(true);
7965
7820
 
7966
7821
  // update the position and angle for drawing
7967
- const pos = this.pos.copy();
7822
+ const pos = particleDrawPos.set(this.pos.x, this.pos.y);
7968
7823
  let angle = this.angle;
7969
7824
  if (localSpace)
7970
7825
  {
7971
7826
  // in local space of emitter
7972
7827
  const a = emitter.angle;
7973
7828
  const c = cos(a), s = sin(a);
7974
- pos.set(emitter.pos.x + pos.x*c - pos.y*s,
7829
+ pos.set(emitter.pos.x + pos.x*c - pos.y*s,
7975
7830
  emitter.pos.y + pos.x*s + pos.y*c);
7976
7831
  angle += a;
7977
7832
  }
@@ -7996,200 +7851,6 @@ class Particle
7996
7851
  debugParticles && debugRect(pos, size, '#f005', 0, angle);
7997
7852
  }
7998
7853
  }
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
7854
  /**
8194
7855
  * LittleJS WebGL Interface
8195
7856
  * - WebGL2 rendering engine for high-performance graphics
@@ -8289,7 +7950,7 @@ function glInit(rootElement)
8289
7950
  // reinit WebGL and restore textures
8290
7951
  initWebGL();
8291
7952
  for (const info of glTextureInfos)
8292
- info.glTexture = glCreateTexture(info.image);
7953
+ info.glTexture = glCreateTexture(info.image, info.wrap);
8293
7954
  pluginList.forEach(plugin=>plugin.glContextRestored?.());
8294
7955
  });
8295
7956
 
@@ -8505,6 +8166,30 @@ function glSetTexture(texture)
8505
8166
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
8506
8167
  }
8507
8168
 
8169
+ /** Set the wrap mode (REPEAT or CLAMP_TO_EDGE) on an existing WebGL texture
8170
+ * Flushes the current batch only if the texture is the active one
8171
+ * @param {WebGLTexture} texture
8172
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
8173
+ * @memberof WebGL */
8174
+ function glSetTextureWrap(texture, wrap=true)
8175
+ {
8176
+ if (!glContext || !texture) return;
8177
+
8178
+ // flush only if changing wrap on the currently bound texture
8179
+ const isCurrent = texture === glActiveTexture;
8180
+ if (isCurrent)
8181
+ glFlush();
8182
+ else
8183
+ glContext.bindTexture(glContext.TEXTURE_2D, texture);
8184
+
8185
+ const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
8186
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
8187
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
8188
+
8189
+ if (!isCurrent && glActiveTexture)
8190
+ glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
8191
+ }
8192
+
8508
8193
  /** Compile WebGL shader of the given type, will throw errors if in debug mode
8509
8194
  * @param {string} source
8510
8195
  * @param {number} type
@@ -8549,9 +8234,10 @@ function glCreateProgram(vsSource, fsSource)
8549
8234
  /** Create WebGL texture from an image and init the texture settings
8550
8235
  * Restores the active texture when done
8551
8236
  * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas} [image]
8237
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
8552
8238
  * @return {WebGLTexture}
8553
8239
  * @memberof WebGL */
8554
- function glCreateTexture(image)
8240
+ function glCreateTexture(image, wrap=false)
8555
8241
  {
8556
8242
  if (!glContext) return;
8557
8243
 
@@ -8577,8 +8263,9 @@ function glCreateTexture(image)
8577
8263
  const minFilter = mipMap ? glContext.LINEAR_MIPMAP_LINEAR : magFilter;
8578
8264
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, magFilter);
8579
8265
  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);
8266
+ const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
8267
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
8268
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
8582
8269
  if (mipMap)
8583
8270
  glContext.generateMipmap(glContext.TEXTURE_2D);
8584
8271
 
@@ -8630,7 +8317,7 @@ function glRegisterTextureInfo(textureInfo)
8630
8317
  if (textureInfo.glTexture)
8631
8318
  glSetTextureData(textureInfo.glTexture, textureInfo.image);
8632
8319
  else
8633
- textureInfo.glTexture = glCreateTexture(textureInfo.image);
8320
+ textureInfo.glTexture = glCreateTexture(textureInfo.image, textureInfo.wrap);
8634
8321
  }
8635
8322
 
8636
8323
  /** Tells WebGL to destroy the glTexture and stop tracking it
@@ -8849,6 +8536,7 @@ function glSetRenderTarget(texture, clear=false)
8849
8536
  glFlush();
8850
8537
  glRenderTarget = undefined;
8851
8538
  glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
8539
+ glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
8852
8540
  }
8853
8541
  }
8854
8542
 
@@ -8971,9 +8659,9 @@ function glPolyStrip(points)
8971
8659
  return area;
8972
8660
  }
8973
8661
 
8974
- // ensure counter-clockwise winding
8662
+ // ensure counter-clockwise winding (slice first so we don't mutate caller's array)
8975
8663
  if (signedArea(points) < 0)
8976
- points = points.reverse();
8664
+ points = points.slice().reverse();
8977
8665
 
8978
8666
  // check if point is inside triangle
8979
8667
  const e = 1e-9;
@@ -9073,6 +8761,398 @@ function glPolyStrip(points)
9073
8761
  }
9074
8762
  return strip;
9075
8763
  }
8764
+ /**
8765
+ * LittleJS Engine Logo
8766
+ * - Draws the LittleJS splash screen logo
8767
+ * - Used internally during engine startup
8768
+ */
8769
+
8770
+ ///////////////////////////////////////////////////////////////////////////////
8771
+ function drawEngineLogo(t)
8772
+ {
8773
+ const blackAndWhite = 0;
8774
+ const showName = 1;
8775
+
8776
+ // LittleJS Logo and Splash Screen
8777
+ const x = mainContext;
8778
+ const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
8779
+ const w = mainCanvas.width = innerWidth * dpr;
8780
+ const h = mainCanvas.height = innerHeight * dpr;
8781
+ {
8782
+ // background
8783
+ const p3 = percent(t, 1, .8);
8784
+ const p4 = percent(t, 0, .5);
8785
+ const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,hypot(w,h)*.6);
8786
+ g.addColorStop(0,hsl(0,0,lerp(0,p3/2,p4),p3).toString());
8787
+ g.addColorStop(1,hsl(0,0,0,p3).toString());
8788
+ x.save();
8789
+ x.fillStyle = g;
8790
+ x.fillRect(0,0,w,h);
8791
+ }
8792
+ const gradient = (X1,Y1,X2,Y2,C,S=1)=>
8793
+ {
8794
+ if (C >= 0)
8795
+ {
8796
+ if (blackAndWhite)
8797
+ x.fillStyle = '#fff';
8798
+ else
8799
+ {
8800
+ const g = x.fillStyle = x.createLinearGradient(X1,Y1,X2,Y2);
8801
+ g.addColorStop(0,color(C,2));
8802
+ g.addColorStop(1,color(C,1));
8803
+ }
8804
+ }
8805
+ else
8806
+ x.fillStyle = '#000';
8807
+ C >= -1 ? (x.fill(), S && x.stroke()) : x.stroke();
8808
+ }
8809
+ const circle = (X,Y,R,A=0,B=2*PI,C,S)=>
8810
+ {
8811
+ x.beginPath();
8812
+ x.arc(X,Y,R,p*A,p*B);
8813
+ gradient(X,Y-R,X,Y+R,C,S);
8814
+ }
8815
+ const rect = (X,Y,W,H,C)=>
8816
+ {
8817
+ x.beginPath();
8818
+ x.rect(X,Y,W,H*p);
8819
+ gradient(X,Y+H,X+W,Y,C);
8820
+ }
8821
+ const poly = (points,C,Y,H)=>
8822
+ {
8823
+ x.beginPath();
8824
+ for (const p of points)
8825
+ x.lineTo(p.x, p.y);
8826
+ x.closePath();
8827
+ gradient(0, Y, 0, Y+H,C);
8828
+ }
8829
+ const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
8830
+
8831
+ // center and fit to screen
8832
+ const alpha = oscillate(1,1,t);
8833
+ const p = percent(alpha, .1, .5);
8834
+ const size = min(6, min(w,h)/99);
8835
+ x.translate(w/2,h/2);
8836
+ x.scale(size,size);
8837
+ x.translate(-40,-35);
8838
+ p < 1 && x.setLineDash([99*p,99]);
8839
+ x.lineJoin = x.lineCap = 'round';
8840
+ x.lineWidth = .1 + p*1.9;
8841
+ //x.strokeStyle='#fff7';
8842
+
8843
+ if (showName)
8844
+ {
8845
+ // engine name text
8846
+ const Y = 54;
8847
+ const s = 'LittleJS';
8848
+ x.font = '900 15.5px arial';
8849
+ x.lineWidth = .1+p*3.9;
8850
+ x.textAlign = 'center';
8851
+ x.textBaseline = 'top';
8852
+ rect(11,Y+1,59,8*p,-1);
8853
+ x.beginPath();
8854
+
8855
+ let w2 = 0;
8856
+ for (let i=0;i<s.length;++i)
8857
+ w2 += x.measureText(s[i]).width;
8858
+ for (let j=2;j--;)
8859
+ for (let i=0,X=40-w2/2;i<s.length;++i)
8860
+ {
8861
+ const w = x.measureText(s[i]).width, X2 = X+w/2;
8862
+ gradient(X2,Y,X2+2,Y+13,i>5?1:0);
8863
+ x[j?'strokeText':'fillText'](s[i],X2,Y+.5,17*p);
8864
+ X += w;
8865
+ }
8866
+
8867
+ x.lineWidth = .1 + p*1.9;
8868
+ rect(3,Y,73,0); // bottom
8869
+ }
8870
+
8871
+ rect(7,15,26,-7,0); // cab top
8872
+ rect(25,15,8,25,-1); // cab front
8873
+ rect(10,40,15,-25,1); // cab back
8874
+ rect(14,21,7,9,2); // cab window
8875
+ rect(38,20,6,-6,2); // little stack
8876
+
8877
+ // big stack
8878
+ rect(49,20,10,-6,0);
8879
+ const stackPoints = [vec2(44,8),vec2(64,8),vec2(59,8+6*p),vec2(49,8+6*p)];
8880
+ poly(stackPoints,2,8,6*p);
8881
+ rect(44,8,20,-7,0);
8882
+
8883
+ // engine
8884
+ for (let i=5;i--;) circle(59-i*6*p,30,10,0,2*PI,1,0);
8885
+ circle(59,30,4,0,7,2); // light
8886
+
8887
+ // engine outline
8888
+ rect(35,20,24,0); // top
8889
+ circle(59,30,10); // front
8890
+ circle(47,30,10,PI/2,PI*3/2); // middle
8891
+ circle(35,30,10,PI/2,PI*3/2); // back
8892
+ rect(7,40,13,7,-1); // bottom back
8893
+ rect(17,40,43,14,-1); // bottom center
8894
+
8895
+ // wheels
8896
+ for (let i=3;i--;) for (let j=2;j--;) circle(17+15*i,47,j?7:1,0,2*PI,2);
8897
+
8898
+ // cowcatcher
8899
+ for (let i=2;i--;)
8900
+ {
8901
+ let w=6, s=7, o=53+w*p*i
8902
+ const points = [vec2(o+s,54),vec2(o,40),vec2(o+w*p,40),vec2(o+s+w*p,54)];
8903
+ poly(points,0,40,14);
8904
+ }
8905
+
8906
+ x.restore();
8907
+ }
8908
+
8909
+ /**
8910
+ * LittleJS Medal System
8911
+ * - Achievement/trophy system for games
8912
+ * - Medal class with name, description, icon, and unlock tracking
8913
+ * - Automatic saving to local storage
8914
+ * - Visual display queue with slide-in notifications
8915
+ * - Newgrounds API integration for online achievements
8916
+ * - Debug mode to unlock/reset medals during development
8917
+ * @namespace Medals
8918
+ */
8919
+
8920
+ let debugMedals = false;
8921
+
8922
+ ///////////////////////////////////////////////////////////////////////////////
8923
+ // Medals settings
8924
+
8925
+ /** How long to show medals for in seconds
8926
+ * @type {number}
8927
+ * @default
8928
+ * @memberof Settings */
8929
+ let medalDisplayTime = 5;
8930
+
8931
+ /** How quickly to slide on/off medals in seconds
8932
+ * @type {number}
8933
+ * @default
8934
+ * @memberof Settings */
8935
+ let medalDisplaySlideTime = .5;
8936
+
8937
+ /** Size of medal display
8938
+ * @type {Vector2}
8939
+ * @default Vector2(640,80)
8940
+ * @memberof Settings */
8941
+ let medalDisplaySize = vec2(640, 80);
8942
+
8943
+ /** Set to stop medals from being unlockable (like if cheats are enabled)
8944
+ * @type {boolean}
8945
+ * @default
8946
+ * @memberof Settings */
8947
+ let medalsPreventUnlock = false;
8948
+
8949
+ /** List of all medals
8950
+ * @type {Object}
8951
+ * @memberof Medals */
8952
+ const medals = {};
8953
+
8954
+ // Engine internal variables not exposed to documentation
8955
+ let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast;
8956
+
8957
+ ///////////////////////////////////////////////////////////////////////////////
8958
+
8959
+ /** Initialize medals with a save name used for storage
8960
+ * - Call this after creating all medals
8961
+ * - Checks if medals are unlocked
8962
+ * @param {string} saveName
8963
+ * @memberof Medals */
8964
+ function medalsInit(saveName)
8965
+ {
8966
+ // check if medals are unlocked
8967
+ medalsSaveName = saveName;
8968
+ if (!debugMedals)
8969
+ medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
8970
+
8971
+ // engine automatically renders medals
8972
+ engineAddPlugin(undefined, medalsRender);
8973
+
8974
+ // plugin functions
8975
+ function medalsRender()
8976
+ {
8977
+ if (!medalsDisplayQueue.length) return;
8978
+
8979
+ // update first medal in queue
8980
+ const medal = medalsDisplayQueue[0];
8981
+ const time = timeReal - medalsDisplayTimeLast;
8982
+ if (!medalsDisplayTimeLast)
8983
+ medalsDisplayTimeLast = timeReal;
8984
+ else if (time > medalDisplayTime)
8985
+ {
8986
+ medalsDisplayTimeLast = 0;
8987
+ medalsDisplayQueue.shift();
8988
+ }
8989
+ else
8990
+ {
8991
+ // slide on/off medals
8992
+ const slideOffTime = medalDisplayTime - medalDisplaySlideTime;
8993
+ const hidePercent =
8994
+ time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime :
8995
+ time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0;
8996
+ medal.render(hidePercent);
8997
+ }
8998
+ }
8999
+ }
9000
+
9001
+ /**
9002
+ * @callback MedalCallbackFunction - Function that processes a medal
9003
+ * @param {Medal} medal
9004
+ * @memberof Medals
9005
+ */
9006
+
9007
+ /** Calls a function for each medal
9008
+ * @param {MedalCallbackFunction} callback
9009
+ * @memberof Medals */
9010
+ function medalsForEach(callback)
9011
+ { Object.values(medals).forEach(medal=>callback(medal)); }
9012
+
9013
+ ///////////////////////////////////////////////////////////////////////////////
9014
+
9015
+ /**
9016
+ * Medal - Tracks an unlockable medal
9017
+ * @memberof Medals
9018
+ * @example
9019
+ * // create a medal
9020
+ * const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
9021
+ *
9022
+ * // initialize medals
9023
+ * medalsInit('Example Game');
9024
+ *
9025
+ * // unlock the medal
9026
+ * medal_example.unlock();
9027
+ */
9028
+ class Medal
9029
+ {
9030
+ /** Create a medal object and adds it to the list of medals
9031
+ * @param {number} id - The unique identifier of the medal
9032
+ * @param {string} name - Name of the medal
9033
+ * @param {string} [description] - Description of the medal
9034
+ * @param {string} [icon] - Icon for the medal
9035
+ * @param {string} [src] - Image location for the medal
9036
+ */
9037
+ constructor(id, name, description='', icon='🏆', src)
9038
+ {
9039
+ ASSERT(id >= 0 && !medals[id]);
9040
+
9041
+ /** @property {number} - The unique identifier of the medal */
9042
+ this.id = id;
9043
+
9044
+ /** @property {string} - Name of the medal */
9045
+ this.name = name;
9046
+
9047
+ /** @property {string} - Description of the medal */
9048
+ this.description = description;
9049
+
9050
+ /** @property {string} - Icon for the medal */
9051
+ this.icon = icon;
9052
+
9053
+ /** @property {boolean} - Is the medal unlocked? */
9054
+ this.unlocked = false;
9055
+
9056
+ // load the source image if provided
9057
+ if (src)
9058
+ (this.image = new Image).src = src;
9059
+
9060
+ // add this to list of medals
9061
+ medals[id] = this;
9062
+ }
9063
+
9064
+ /** Unlocks a medal if not already unlocked */
9065
+ unlock()
9066
+ {
9067
+ if (medalsPreventUnlock || this.unlocked) return;
9068
+
9069
+ // save the medal
9070
+ ASSERT(medalsSaveName, 'save name must be set');
9071
+ localStorage[this.storageKey()] = this.unlocked = true;
9072
+ medalsDisplayQueue.push(this);
9073
+ }
9074
+
9075
+ /** Render a medal
9076
+ * @param {number} [hidePercent] - How much to slide the medal off screen
9077
+ */
9078
+ render(hidePercent=0)
9079
+ {
9080
+ const context = mainContext;
9081
+ const width = min(medalDisplaySize.x, mainCanvas.width);
9082
+ const height = medalDisplaySize.y;
9083
+ const x = mainCanvas.width - width;
9084
+ const y = -height*hidePercent;
9085
+ const backgroundColor = hsl(0,0,.9);
9086
+
9087
+ // draw containing rect and clip to that region
9088
+ context.save();
9089
+ context.beginPath();
9090
+ context.fillStyle = backgroundColor.toString();
9091
+ context.strokeStyle = BLACK.toString();
9092
+ context.lineWidth = 3;
9093
+ context.rect(x, y, width, height);
9094
+ context.fill();
9095
+ context.stroke();
9096
+ context.clip();
9097
+
9098
+ // draw the icon
9099
+ const gap = vec2(.1, .05).scale(height);
9100
+ const medalDisplayIconSize = height - 2*gap.x;
9101
+ this.renderIcon(vec2(x + gap.x + medalDisplayIconSize/2, y + height/2), medalDisplayIconSize);
9102
+
9103
+ // draw the name
9104
+ const nameSize = height*.5;
9105
+ const descriptionSize = height*.3;
9106
+ const pos = vec2(x + medalDisplayIconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
9107
+ const textWidth = width - medalDisplayIconSize - 3*gap.x;
9108
+ drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
9109
+
9110
+ // draw the description
9111
+ pos.y = y + height - gap.y*2 - descriptionSize/2;
9112
+ drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
9113
+ context.restore();
9114
+ }
9115
+
9116
+ /** Render the icon for a medal
9117
+ * @param {Vector2} pos - Screen space position
9118
+ * @param {number} size - Screen space size
9119
+ */
9120
+ renderIcon(pos, size)
9121
+ {
9122
+ // draw the image or icon
9123
+ if (this.image)
9124
+ mainContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size);
9125
+ else
9126
+ drawTextScreen(this.icon, pos, size*.7, BLACK);
9127
+ }
9128
+
9129
+ // Get local storage key used by the medal
9130
+ storageKey() { return medalsSaveName + '_' + this.id; }
9131
+ }
9132
+
9133
+ ///////////////////////////////////////////////////////////////////////////////
9134
+ // Medals setting setters
9135
+
9136
+ /** Set how long to show medals for in seconds
9137
+ * @param {number} time
9138
+ * @memberof Settings */
9139
+ function setMedalDisplayTime(time) { medalDisplayTime = time; }
9140
+
9141
+ /** Set how quickly to slide on/off medals in seconds
9142
+ * @param {number} time
9143
+ * @memberof Settings */
9144
+ function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
9145
+
9146
+ /** Set size of medal display
9147
+ * @param {Vector2} size
9148
+ * @memberof Settings */
9149
+ function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
9150
+
9151
+ /** Set to stop medals from being unlockable
9152
+ * @param {boolean} preventUnlock
9153
+ * @memberof Settings */
9154
+ function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }
9155
+
9076
9156
  /**
9077
9157
  * LittleJS Newgrounds Plugin
9078
9158
  * - NewgroundsMedal extends Medal with Newgrounds API functionality
@@ -9689,22 +9769,38 @@ class UISystemPlugin
9689
9769
  this.lastHoverObject = undefined;
9690
9770
  /** @property {UIObject} - Current confirm menu being shown */
9691
9771
  this.confirmDialog = undefined;
9692
- /** @property {UIObject} - Object to send keyboard input to */
9693
- this.keyInputObject = undefined;
9772
+ /** @private */
9773
+ this._keyInputObject = undefined;
9774
+ /** @private */
9775
+ this._onKeyDown = (e) => this._keyInputObject?.onKeyDown(e);
9694
9776
 
9695
9777
  engineAddPlugin(uiUpdate, uiRender);
9696
9778
 
9697
- // key down handler
9698
- function onKeyDown(e)
9699
- { uiSystem.keyInputObject?.onKeyDown(e); }
9700
- document.addEventListener('keydown', onKeyDown);
9701
-
9702
- // set object position in parent space
9779
+ // set object position based on anchor target (parent box, or canvas for roots),
9780
+ // self-pivot, and localPos offset
9703
9781
  function updateTransforms(o)
9704
9782
  {
9705
- if (!o.parent) return;
9706
- o.pos.x = o.localPos.x + o.parent.pos.x;
9707
- o.pos.y = o.localPos.y + o.parent.pos.y;
9783
+ let targetPos, targetSize;
9784
+ if (o.parent)
9785
+ {
9786
+ targetPos = o.parent.pos;
9787
+ targetSize = o.parent.size;
9788
+ }
9789
+ else
9790
+ {
9791
+ // anchor to canvas in native coords (handles nativeHeight if set)
9792
+ targetPos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
9793
+ targetSize = uiSystem.nativeHeight
9794
+ ? vec2(mainCanvasSize.x * uiSystem.nativeHeight / mainCanvasSize.y,
9795
+ uiSystem.nativeHeight)
9796
+ : mainCanvasSize;
9797
+ }
9798
+
9799
+ const a = o.anchor;
9800
+ o.pos = targetPos
9801
+ .add(targetSize.multiply(a).scale(.5)) // anchor point on target
9802
+ .subtract(o.size.multiply(a).scale(.5)) // pivot shift on self
9803
+ .add(o.localPos); // user offset
9708
9804
  }
9709
9805
 
9710
9806
  // setup recursive update and render
@@ -10040,8 +10136,23 @@ class UISystemPlugin
10040
10136
  return p;
10041
10137
  }
10042
10138
 
10139
+ /** Object to send keyboard input to (typically a UITextInput).
10140
+ * The document keydown listener is only attached while this is set,
10141
+ * so games that never use text input pay no event-handling cost.
10142
+ * @type {UIObject} */
10143
+ get keyInputObject() { return this._keyInputObject; }
10144
+ set keyInputObject(obj)
10145
+ {
10146
+ const had = !!this._keyInputObject;
10147
+ this._keyInputObject = obj;
10148
+ if (!had && obj)
10149
+ document.addEventListener('keydown', this._onKeyDown);
10150
+ else if (had && !obj)
10151
+ document.removeEventListener('keydown', this._onKeyDown);
10152
+ }
10153
+
10043
10154
  /** Destroy and remove all objects
10044
- * @memberof Engine */
10155
+ * @memberof UISystem */
10045
10156
  destroyObjects()
10046
10157
  {
10047
10158
  for (const o of this.uiObjects)
@@ -10156,9 +10267,8 @@ class UISystemPlugin
10156
10267
  // confirm menu
10157
10268
  const confirmMenu = new UIObject(vec2(), size);
10158
10269
  uiSystem.confirmDialog = confirmMenu;
10159
- confirmMenu.onRender = ()=>
10270
+ confirmMenu.onRender = ()=>
10160
10271
  {
10161
- confirmMenu.pos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
10162
10272
  const backgroundColor = hsl(0,0,0,.7);
10163
10273
  uiSystem.drawRect(vec2(), vec2(1e9), backgroundColor);
10164
10274
  }
@@ -10291,7 +10401,11 @@ class UIObject
10291
10401
  this.navigationIndex = undefined;
10292
10402
  /** @property {boolean} - Should this be auto selected by navigation? Must also have valid navigation index. */
10293
10403
  this.navigationAutoSelect = false;
10294
-
10404
+ /** @property {Vector2} - Where on parent (or canvas if no parent) this object is anchored.
10405
+ * Components in [-1, 1]: (0,0)=center, (-1,-1)=top-left, (1,1)=bottom-right.
10406
+ * Also acts as self-pivot — e.g. (1,-1) puts your top-right corner at the anchor point. */
10407
+ this.anchor = vec2();
10408
+
10295
10409
  uiSystem.uiObjects.push(this);
10296
10410
  }
10297
10411
 
@@ -10464,15 +10578,13 @@ class UIObject
10464
10578
  * @return {string} */
10465
10579
  toString()
10466
10580
  {
10467
- if (!debug) return;
10468
-
10469
10581
  let text = 'type = ' + this.constructor.name;
10470
10582
  if (this.text)
10471
10583
  text += '\ntext = ' + this.text;
10472
10584
  if (this.pos.x || this.pos.y)
10473
10585
  text += '\npos = ' + this.pos;
10474
10586
  if (this.localPos.x || this.localPos.y)
10475
- text += '\localPos = ' + this.localPos;
10587
+ text += '\nlocalPos = ' + this.localPos;
10476
10588
  if (this.size.x || this.size.y)
10477
10589
  text += '\nsize = ' + this.size;
10478
10590
  if (this.color)
@@ -10546,9 +10658,9 @@ class UIText extends UIObject
10546
10658
  {
10547
10659
  super(pos, size);
10548
10660
 
10549
- ASSERT(isString(text), 'ui text must be a string');
10661
+ ASSERT(isStringLike(text), 'ui text must be a string');
10550
10662
  ASSERT(['left','center','right'].includes(align), 'ui text align must be left, center, or right');
10551
- ASSERT(isString(font), 'ui text font must be a string');
10663
+ ASSERT(isStringLike(font), 'ui text font must be a string');
10552
10664
 
10553
10665
  // set properties
10554
10666
  this.text = text;
@@ -10596,7 +10708,7 @@ class UITextInput extends UIObject
10596
10708
  {
10597
10709
  super(pos, size);
10598
10710
 
10599
- ASSERT(isString(text), 'ui text must be a string');
10711
+ ASSERT(isStringLike(text), 'ui text must be a string');
10600
10712
 
10601
10713
  /** @property {number} - Max length of input (0 = no limit) */
10602
10714
  this.maxLength = 0;
@@ -10733,7 +10845,7 @@ class UIButton extends UIObject
10733
10845
  {
10734
10846
  super(pos, size);
10735
10847
 
10736
- ASSERT(isString(text), 'ui button must be a string');
10848
+ ASSERT(isStringLike(text), 'ui button must be a string');
10737
10849
  ASSERT(isColor(color), 'ui button color must be a color');
10738
10850
 
10739
10851
  /** @property {Vector2} - Text offset for the button */
@@ -10774,7 +10886,7 @@ class UICheckbox extends UIObject
10774
10886
  {
10775
10887
  super(pos, size);
10776
10888
 
10777
- ASSERT(isString(text), 'ui checkbox must be a string');
10889
+ ASSERT(isStringLike(text), 'ui checkbox must be a string');
10778
10890
  ASSERT(isColor(color), 'ui checkbox color must be a color');
10779
10891
 
10780
10892
  /** @property {boolean} - Current percentage value of this slider 0-1 */
@@ -10831,7 +10943,7 @@ class UISlider extends UIObject
10831
10943
  super(pos, size);
10832
10944
 
10833
10945
  ASSERT(isNumber(value), 'ui slider value must be a number');
10834
- ASSERT(isString(text), 'ui slider must be a string');
10946
+ ASSERT(isStringLike(text), 'ui slider must be a string');
10835
10947
  ASSERT(isColor(color), 'ui slider color must be a color');
10836
10948
  ASSERT(isColor(handleColor), 'ui slider handleColor must be a color');
10837
10949
 
@@ -10949,7 +11061,7 @@ class UIVideo extends UIObject
10949
11061
  {
10950
11062
  super(pos, size || vec2());
10951
11063
 
10952
- ASSERT(isString(src), 'video src must be a string');
11064
+ ASSERT(isStringLike(src), 'video src must be a string');
10953
11065
  ASSERT(isNumber(volume), 'video volume must be a number');
10954
11066
 
10955
11067
  this.color = BLACK; // default to black background
@@ -11066,6 +11178,135 @@ class UIVideo extends UIObject
11066
11178
  this.video.remove();
11067
11179
  super.destroy();
11068
11180
  }
11181
+ }
11182
+
11183
+ ///////////////////////////////////////////////////////////////////////////////
11184
+ /**
11185
+ * UILayout - A container that auto-arranges children in a vertical list, horizontal list, or grid
11186
+ * - Set columns to 1 for a vertical list (default)
11187
+ * - Set columns to children.length for a horizontal list
11188
+ * - Set columns to N (1 < N < children.length) for a grid with N columns
11189
+ * - 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
11190
+ * - Children are positioned centered in their cell
11191
+ * - Container auto-sizes to fit children plus padding
11192
+ * @extends UIObject
11193
+ * @memberof UISystem
11194
+ */
11195
+ class UILayout extends UIObject
11196
+ {
11197
+ /** Create a UILayout container that auto-arranges children
11198
+ * @param {Vector2} [pos]
11199
+ * @param {number} [columns=1] - Number of columns (1 = vertical list)
11200
+ * @param {number} [gap=10] - Space between children
11201
+ * @param {number} [padding=10] - Space between container border and children
11202
+ * @param {boolean} [transparent=false] - If true, draws no background, outline, or shadow
11203
+ */
11204
+ constructor(pos, columns=1, gap=10, padding=10, transparent=false)
11205
+ {
11206
+ super(pos);
11207
+
11208
+ ASSERT(isNumber(columns) && columns >= 1, 'ui layout columns must be a number >= 1');
11209
+ ASSERT(isNumber(gap), 'ui layout gap must be a number');
11210
+ ASSERT(isNumber(padding), 'ui layout padding must be a number');
11211
+
11212
+ /** @property {number} - Number of columns in the layout */
11213
+ this.columns = columns;
11214
+ /** @property {number} - Space between children */
11215
+ this.gap = gap;
11216
+ /** @property {number} - Space between container border and children */
11217
+ this.padding = padding;
11218
+
11219
+ if (transparent)
11220
+ {
11221
+ // pure positioning helper - skip background, outline, and shadow
11222
+ this.color = CLEAR_BLACK;
11223
+ this.gradientColor = undefined;
11224
+ this.lineWidth = 0;
11225
+ this.shadowColor = CLEAR_BLACK;
11226
+ }
11227
+ this.relayout();
11228
+ }
11229
+
11230
+ /** Add a child UIObject and re-layout
11231
+ * @param {UIObject} child
11232
+ * @return {UIObject} The child object added */
11233
+ addChild(child)
11234
+ {
11235
+ super.addChild(child);
11236
+ this.relayout();
11237
+ return child;
11238
+ }
11239
+
11240
+ /** Remove a child UIObject and re-layout
11241
+ * @param {UIObject} child */
11242
+ removeChild(child)
11243
+ {
11244
+ super.removeChild(child);
11245
+ this.relayout();
11246
+ }
11247
+
11248
+ /** Recompute child positions and container size based on per-child sizes.
11249
+ * Called automatically by addChild and removeChild. Call manually if you
11250
+ * mutate a child's size or change columns, gap, or padding. */
11251
+ relayout()
11252
+ {
11253
+ const n = this.children.length;
11254
+ if (!n)
11255
+ {
11256
+ this.size = vec2(this.padding * 2);
11257
+ return;
11258
+ }
11259
+
11260
+ const cols = this.columns;
11261
+ const rows = ceil(n / cols);
11262
+ const colWidths = new Array(cols).fill(0);
11263
+ const rowHeights = new Array(rows).fill(0);
11264
+
11265
+ // first pass: compute column widths and row heights from child sizes
11266
+ for (let i = 0; i < n; ++i)
11267
+ {
11268
+ const col = i % cols;
11269
+ const row = floor(i / cols);
11270
+ const child = this.children[i];
11271
+ colWidths[col] = max(colWidths[col], child.size.x);
11272
+ rowHeights[row] = max(rowHeights[row], child.size.y);
11273
+ }
11274
+
11275
+ // total content size (sum of column widths/row heights plus gaps between them)
11276
+ let contentWidth = this.gap * (cols - 1);
11277
+ for (const w of colWidths) contentWidth += w;
11278
+ let contentHeight = this.gap * (rows - 1);
11279
+ for (const h of rowHeights) contentHeight += h;
11280
+
11281
+ // cumulative column/row offsets so positioning is O(n) not O(n^2)
11282
+ const colOffsets = new Array(cols);
11283
+ let xAcc = 0;
11284
+ for (let c = 0; c < cols; ++c)
11285
+ {
11286
+ colOffsets[c] = xAcc;
11287
+ xAcc += colWidths[c];
11288
+ }
11289
+ const rowOffsets = new Array(rows);
11290
+ let yAcc = 0;
11291
+ for (let r = 0; r < rows; ++r)
11292
+ {
11293
+ rowOffsets[r] = yAcc;
11294
+ yAcc += rowHeights[r];
11295
+ }
11296
+
11297
+ // second pass: position each child centered in its cell
11298
+ for (let i = 0; i < n; ++i)
11299
+ {
11300
+ const col = i % cols;
11301
+ const row = floor(i / cols);
11302
+ const x = -contentWidth/2 + colOffsets[col] + this.gap * col + colWidths[col] / 2;
11303
+ const y = -contentHeight/2 + rowOffsets[row] + this.gap * row + rowHeights[row] / 2;
11304
+ this.children[i].localPos = vec2(x, y);
11305
+ }
11306
+
11307
+ // container size = content + padding on all sides
11308
+ this.size = vec2(contentWidth + this.padding * 2, contentHeight + this.padding * 2);
11309
+ }
11069
11310
  }
11070
11311
  /**
11071
11312
  * LittleJS Box2D Physics Plugin
@@ -11592,14 +11833,29 @@ class Box2dObject extends EngineObject
11592
11833
  this.body.ApplyForce(box2d.vec2dTo(force), box2d.vec2dTo(pos));
11593
11834
  }
11594
11835
 
11595
- /** Apply acceleration to this object
11836
+ /** Apply acceleration to this object (changes velocity by acceleration,
11837
+ * mass-independent — matches EngineObject.applyAcceleration semantics).
11838
+ * Use applyImpulse if you want the mass-dependent velocity change
11839
+ * Δv = impulse / mass, or applyForce for a Newton-style sustained force.
11596
11840
  * @param {Vector2} acceleration
11597
11841
  * @param {Vector2} [pos] */
11598
11842
  applyAcceleration(acceleration, pos)
11599
- {
11843
+ {
11600
11844
  pos ||= this.getCenterOfMass();
11601
11845
  this.setAwake();
11602
- this.body.ApplyLinearImpulse(box2d.vec2dTo(acceleration), box2d.vec2dTo(pos));
11846
+ const impulse = acceleration.scale(this.getMass());
11847
+ this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
11848
+ }
11849
+
11850
+ /** Apply an instantaneous linear impulse. Changes velocity immediately by
11851
+ * impulse / mass (so heavier bodies move less for the same impulse).
11852
+ * @param {Vector2} impulse
11853
+ * @param {Vector2} [pos] */
11854
+ applyImpulse(impulse, pos)
11855
+ {
11856
+ pos ||= this.getCenterOfMass();
11857
+ this.setAwake();
11858
+ this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
11603
11859
  }
11604
11860
 
11605
11861
  /** Apply torque to this object
@@ -11609,13 +11865,23 @@ class Box2dObject extends EngineObject
11609
11865
  this.setAwake();
11610
11866
  this.body.ApplyTorque(torque);
11611
11867
  }
11612
-
11613
- /** Apply angular acceleration to this object
11868
+
11869
+ /** Apply angular acceleration to this object (changes angular velocity by
11870
+ * acceleration, mass-independent — matches EngineObject semantics).
11614
11871
  * @param {number} acceleration */
11615
11872
  applyAngularAcceleration(acceleration)
11616
11873
  {
11617
11874
  this.setAwake();
11618
- this.body.ApplyAngularImpulse(acceleration);
11875
+ this.body.ApplyAngularImpulse(acceleration * this.getInertia());
11876
+ }
11877
+
11878
+ /** Apply an instantaneous angular impulse. Changes angular velocity by
11879
+ * impulse / inertia immediately.
11880
+ * @param {number} impulse */
11881
+ applyAngularImpulse(impulse)
11882
+ {
11883
+ this.setAwake();
11884
+ this.body.ApplyAngularImpulse(impulse);
11619
11885
  }
11620
11886
 
11621
11887
  ///////////////////////////////////////////////////////////////////////////////
@@ -11861,7 +12127,7 @@ class Box2dJoint
11861
12127
 
11862
12128
  /** Check if the connected bodies should collide
11863
12129
  * @return {boolean} */
11864
- getCollideConnected() { return this.box2dJoint.getCollideConnected();}
12130
+ getCollideConnected() { return this.box2dJoint.GetCollideConnected();}
11865
12131
 
11866
12132
  /** Check if either connected body is active
11867
12133
  * @return {boolean} */
@@ -13474,13 +13740,13 @@ const Ease =
13474
13740
  * @param {number} x
13475
13741
  * @returns {number}
13476
13742
  * @memberof TweenSystem */
13477
- SINE: (x) => 1 - Math.cos(x * (Math.PI / 2)),
13743
+ SINE: (x) => 1 - cos(x * (PI / 2)),
13478
13744
 
13479
13745
  /** Circular ease-in curve.
13480
13746
  * @param {number} x
13481
13747
  * @returns {number}
13482
13748
  * @memberof TweenSystem */
13483
- CIRC: (x) => 1 - Math.sqrt(1 - x * x),
13749
+ CIRC: (x) => 1 - (1 - x * x)**.5,
13484
13750
 
13485
13751
  /** Exponential ease-in curve (`2^(10x-10)`).
13486
13752
  * @param {number} x
@@ -13499,7 +13765,7 @@ const Ease =
13499
13765
  * @returns {number}
13500
13766
  * @memberof TweenSystem */
13501
13767
  ELASTIC: (x) =>
13502
- -(2 ** (10 * x - 10)) * Math.sin(((37 - 40 * x) * Math.PI) / 6),
13768
+ -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
13503
13769
 
13504
13770
  /** Spring-like ease-out: oscillates outward after passing the target.
13505
13771
  * @param {number} x
@@ -13507,8 +13773,8 @@ const Ease =
13507
13773
  * @memberof TweenSystem */
13508
13774
  SPRING: (x) =>
13509
13775
  1 -
13510
- (Math.sin(Math.PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
13511
- Math.pow(x, 2.2) +
13776
+ (sin(PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
13777
+ x ** 2.2 +
13512
13778
  (1 - x)) *
13513
13779
  (1.0 + 1.2 * x),
13514
13780
 
@@ -13612,7 +13878,7 @@ const Ease =
13612
13878
  {
13613
13879
  const tMid = (t0 + t1) / 2;
13614
13880
  const [bx, by] = curve(tMid);
13615
- if (Math.abs(bx - x) < 1e-5) return by;
13881
+ if (abs(bx - x) < 1e-5) return by;
13616
13882
  if (bx < x) t0 = tMid; else t1 = tMid;
13617
13883
  }
13618
13884
  return curve((t0 + t1) / 2)[1];
@@ -13645,7 +13911,7 @@ const Ease =
13645
13911
  function tweenProperty(target, propertyPath, start, end, duration = 1, options = {})
13646
13912
  {
13647
13913
  ASSERT(target != null && typeof target === 'object', 'tweenProperty target must be an object');
13648
- ASSERT(isString(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string');
13914
+ ASSERT(isStringLike(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string');
13649
13915
 
13650
13916
  const parts = propertyPath.split('.');
13651
13917
  const lastKey = parts.pop();
@@ -13744,3 +14010,760 @@ function tweenStopAll()
13744
14010
  // Register with the engine so tweens auto-advance.
13745
14011
  engineAddPlugin(tweenUpdate);
13746
14012
 
14013
+ /**
14014
+ * LittleJS PathFinder Plugin
14015
+ * - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths
14016
+ * - Works directly on a TileCollisionLayer, or override isWalkable/getCost for any grid
14017
+ * - Debug visualization via engine debug primitives (stripped in release builds)
14018
+ * - Port of frankforce.com pathFindingBase.cpp (2018)
14019
+ * @namespace PathFinding
14020
+ */
14021
+
14022
+ ///////////////////////////////////////////////////////////////////////////////
14023
+
14024
+ // Diagonal step cost — pre-computed for the A* expansion inner loop.
14025
+ const PATHFINDER_DIAGONAL_COST = Math.SQRT2;
14026
+
14027
+ // Shared 1x1 size vector for per-tile debugRect calls. debugRect copies the
14028
+ // argument internally, so reusing one instance is safe.
14029
+ const PATHFINDER_TILE_VEC = vec2(1);
14030
+
14031
+ ///////////////////////////////////////////////////////////////////////////////
14032
+
14033
+ /** A single grid cell tracked by the pathfinder. Allocated once per cell at
14034
+ * PathFinder construction; reset (not reallocated) at the start of every
14035
+ * findPath call.
14036
+ * @memberof PathFinding */
14037
+ class PathFinderNode
14038
+ {
14039
+ /** @param {number} x - Tile x
14040
+ * @param {number} y - Tile y */
14041
+ constructor(x, y)
14042
+ {
14043
+ /** @property {Vector2} - Tile coords (integer) */
14044
+ this.pos = vec2(x, y);
14045
+ /** @property {Vector2} - World-space center of this tile (set by buildNodeData) */
14046
+ this.posWorld = vec2();
14047
+ /** @property {boolean} - True if this cell is passable (cleared each findPath call) */
14048
+ this.walkable = false;
14049
+ /** @property {number} - Extra cost added to A* G-score for stepping on this cell */
14050
+ this.cost = 0;
14051
+ /** @property {number} - A* G-score: actual cost from start to this node */
14052
+ this.g = 0;
14053
+ /** @property {number} - A* F-score: G + heuristic */
14054
+ this.f = 0;
14055
+ /** @property {PathFinderNode|null} - Parent for path reconstruction */
14056
+ this.parent = null;
14057
+ /** @property {boolean} - In the A* open list */
14058
+ this.isOpen = false;
14059
+ /** @property {boolean} - In the A* closed list */
14060
+ this.isClosed = false;
14061
+ }
14062
+
14063
+ /** Reset per-search state (called at the start of buildNodeData). */
14064
+ reset()
14065
+ {
14066
+ this.walkable = false;
14067
+ this.cost = 0;
14068
+ this.g = 0;
14069
+ this.f = 0;
14070
+ this.parent = null;
14071
+ this.isOpen = false;
14072
+ this.isClosed = false;
14073
+ }
14074
+
14075
+ /** True if walkable and not blocked by cost. */
14076
+ isClear()
14077
+ {
14078
+ return this.walkable && this.cost === 0;
14079
+ }
14080
+ }
14081
+
14082
+ ///////////////////////////////////////////////////////////////////////////////
14083
+
14084
+ /** Grid pathfinder using A* with two optional smoothing passes.
14085
+ * @memberof PathFinding
14086
+ * @example
14087
+ * // Tile-layer driven (most common):
14088
+ * const pf = new PathFinder(myTileCollisionLayer);
14089
+ * const path = pf.findPath(player.pos, mousePos);
14090
+ *
14091
+ * // Bare grid with custom walkability:
14092
+ * const pf = new PathFinder(vec2(50, 50));
14093
+ * pf.isWalkable = (x, y) => myGrid[y*50 + x] === 0;
14094
+ */
14095
+ class PathFinder
14096
+ {
14097
+ /** @param {TileCollisionLayer|Vector2} source - Either a TileCollisionLayer
14098
+ * (size and walkability auto-derived) or a Vector2 grid size (user
14099
+ * overrides isWalkable). */
14100
+ constructor(source)
14101
+ {
14102
+ // Accept either a Vector2 size or a TileCollisionLayer (which has a .size).
14103
+ // We don't import TileCollisionLayer to avoid coupling; we duck-type on
14104
+ // .size + .getCollisionData.
14105
+ if (isVector2(source))
14106
+ {
14107
+ this.size = source.floor();
14108
+ this.tileLayer = undefined;
14109
+ }
14110
+ else
14111
+ {
14112
+ ASSERT(source && isVector2(source.size) && typeof source.getCollisionData === 'function',
14113
+ 'PathFinder requires a Vector2 size or a TileCollisionLayer');
14114
+ this.size = source.size;
14115
+ this.tileLayer = source;
14116
+ }
14117
+
14118
+ // Tunables (public, freely re-assignable).
14119
+ this.heuristicWeight = 1;
14120
+ this.maxLoop = 500;
14121
+ this.smoothPath = true;
14122
+ this.debug = false;
14123
+ this.debugTime = 2;
14124
+
14125
+ // Pre-allocate the node array — one node per tile, reused across calls.
14126
+ this.nodes = new Array(this.size.x * this.size.y);
14127
+ for (let y = 0; y < this.size.y; ++y)
14128
+ for (let x = 0; x < this.size.x; ++x)
14129
+ this.nodes[x + y * this.size.x] = new PathFinderNode(x, y);
14130
+
14131
+ // Scratch Vector2 reused to avoid allocations in the isWalkable hot path.
14132
+ this.collisionScratch = vec2();
14133
+ }
14134
+
14135
+ /** Default walkability: if a tile layer was provided, returns true when the
14136
+ * cell has no solid collision data; otherwise returns true. Override on
14137
+ * the instance or via a subclass.
14138
+ * @param {number} x - Tile x
14139
+ * @param {number} y - Tile y
14140
+ * @returns {boolean} */
14141
+ isWalkable(x, y)
14142
+ {
14143
+ if (!this.tileLayer) return true;
14144
+ return !this.tileLayer.getCollisionData(this.collisionScratch.set(x, y));
14145
+ }
14146
+
14147
+ /** Default extra cost for stepping on a cell. Returns 0 (free) by default.
14148
+ * Override to add cost-weighted terrain (mud, swamp, etc).
14149
+ * @param {number} x - Tile x
14150
+ * @param {number} y - Tile y
14151
+ * @returns {number} */
14152
+ getCost(x, y)
14153
+ {
14154
+ return 0;
14155
+ }
14156
+
14157
+ /** Get the node at tile coords, or null if out of bounds.
14158
+ * @param {number} x
14159
+ * @param {number} y
14160
+ * @returns {PathFinderNode|null} */
14161
+ getNode(x, y)
14162
+ {
14163
+ if (x < 0 || y < 0 || x >= this.size.x || y >= this.size.y) return null;
14164
+ return this.nodes[x + y * this.size.x];
14165
+ }
14166
+
14167
+ /** Convert a world-space position to integer tile coords (no clamping).
14168
+ * @param {Vector2} worldPos
14169
+ * @returns {Vector2}
14170
+ * @memberof PathFinding */
14171
+ worldToTile(worldPos)
14172
+ {
14173
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
14174
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
14175
+ return vec2(floor(worldPos.x - ox), floor(worldPos.y - oy));
14176
+ }
14177
+
14178
+ /** Convert integer tile coords to the world-space center of that tile.
14179
+ * @param {number} x
14180
+ * @param {number} y
14181
+ * @returns {Vector2}
14182
+ * @memberof PathFinding */
14183
+ tileToWorld(x, y)
14184
+ {
14185
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
14186
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
14187
+ return vec2(x + 0.5 + ox, y + 0.5 + oy);
14188
+ }
14189
+
14190
+ /** Reset all nodes and re-populate walkable / cost / posWorld from the
14191
+ * current isWalkable / getCost overrides. Called at the start of
14192
+ * findPath; exposed so tests and tooling can drive it directly.
14193
+ * @private */
14194
+ buildNodeData()
14195
+ {
14196
+ const w = this.size.x;
14197
+ const h = this.size.y;
14198
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
14199
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
14200
+ for (let y = 0; y < h; ++y)
14201
+ for (let x = 0; x < w; ++x)
14202
+ {
14203
+ const node = this.nodes[x + y * w];
14204
+ node.reset();
14205
+ const walkable = !!this.isWalkable(x, y);
14206
+ const cost = walkable ? max(0, this.getCost(x, y)) : 0;
14207
+ node.walkable = walkable;
14208
+ node.cost = cost;
14209
+ node.posWorld.set(x + 0.5 + ox, y + 0.5 + oy);
14210
+
14211
+ if (this.debug && this.debugTime > 0)
14212
+ {
14213
+ if (!walkable)
14214
+ debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, 0.25), this.debugTime);
14215
+ else if (cost > 0)
14216
+ debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, min(0.2, cost * 0.05)), this.debugTime);
14217
+ }
14218
+ }
14219
+ }
14220
+
14221
+ /** Core A* search loop. Expects buildNodeData() to have been called first.
14222
+ * Marks node.parent for path reconstruction. Returns true if endNode was
14223
+ * reached; false on disconnected goal or maxLoop exhaustion.
14224
+ * @param {PathFinderNode} startNode
14225
+ * @param {PathFinderNode} endNode
14226
+ * @returns {boolean}
14227
+ * @private */
14228
+ aStarSearch(startNode, endNode)
14229
+ {
14230
+ ASSERT(startNode && endNode, 'aStarSearch needs both endpoints');
14231
+ ASSERT(startNode !== endNode, 'aStarSearch: start and end must differ — caller should handle trivial case');
14232
+ ASSERT(startNode.walkable && endNode.walkable, 'aStarSearch: endpoints must be walkable');
14233
+
14234
+ const openList = [startNode];
14235
+ startNode.isOpen = true;
14236
+ let loopCount = 0;
14237
+
14238
+ while (openList.length > 0)
14239
+ {
14240
+ // Find the open node with the smallest f score (linear scan).
14241
+ // Same as the C++ — fine up to a few thousand nodes.
14242
+ let bestIndex = 0;
14243
+ let bestF = openList[0].f;
14244
+ for (let i = 1; i < openList.length; ++i)
14245
+ {
14246
+ if (openList[i].f < bestF)
14247
+ {
14248
+ bestF = openList[i].f;
14249
+ bestIndex = i;
14250
+ }
14251
+ }
14252
+ const current = openList[bestIndex];
14253
+
14254
+ if (current === endNode) break;
14255
+ if (++loopCount > this.maxLoop) break;
14256
+
14257
+ // Move current from open to closed.
14258
+ current.isOpen = false;
14259
+ openList.splice(bestIndex, 1);
14260
+ current.isClosed = true;
14261
+
14262
+ if (this.debug && this.debugTime > 0)
14263
+ debugRect(current.posWorld, PATHFINDER_TILE_VEC, rgb(1, 1, 1, 0.05), this.debugTime);
14264
+
14265
+ // Expand all 8 neighbors.
14266
+ for (let dy = -1; dy <= 1; ++dy)
14267
+ for (let dx = -1; dx <= 1; ++dx)
14268
+ {
14269
+ if (dx === 0 && dy === 0) continue;
14270
+ const neighbor = this.getNode(current.pos.x + dx, current.pos.y + dy);
14271
+ if (!neighbor || !neighbor.walkable || neighbor.isClosed) continue;
14272
+
14273
+ let stepCost = 1;
14274
+ if (dx !== 0 && dy !== 0)
14275
+ {
14276
+ // Diagonal step: refuse if either cardinal neighbor is
14277
+ // blocked or has cost. Prevents cutting through corners.
14278
+ const card1 = this.getNode(current.pos.x + dx, current.pos.y);
14279
+ if (!card1 || card1.cost > 0 || !card1.walkable) continue;
14280
+ const card2 = this.getNode(current.pos.x, current.pos.y + dy);
14281
+ if (!card2 || card2.cost > 0 || !card2.walkable) continue;
14282
+ stepCost = PATHFINDER_DIAGONAL_COST;
14283
+ }
14284
+
14285
+ const tentativeG = current.g + stepCost + neighbor.cost;
14286
+ if (!neighbor.isOpen)
14287
+ {
14288
+ neighbor.isOpen = true;
14289
+ openList.push(neighbor);
14290
+ }
14291
+ else if (tentativeG >= neighbor.g)
14292
+ {
14293
+ continue;
14294
+ }
14295
+
14296
+ // Best path so far through neighbor — record it.
14297
+ neighbor.parent = current;
14298
+ neighbor.g = tentativeG;
14299
+ const gdx = endNode.pos.x - neighbor.pos.x;
14300
+ const gdy = endNode.pos.y - neighbor.pos.y;
14301
+ neighbor.f = neighbor.g + (gdx * gdx + gdy * gdy) * this.heuristicWeight;
14302
+ }
14303
+ }
14304
+
14305
+ return endNode.parent !== null;
14306
+ }
14307
+
14308
+ /** Find the clear (walkable, zero-cost) node closest to the given world
14309
+ * position. Spirals outward in expanding boxes until a clear node is
14310
+ * found or the search range is exhausted. Useful for snapping a click
14311
+ * or NPC spawn position to the nearest open tile.
14312
+ *
14313
+ * By default, calls `buildNodeData()` first so it works correctly on a
14314
+ * fresh PathFinder. If you're calling it many times in a row with
14315
+ * unchanged walkability, pass `rebuild=false` and call `buildNodeData()`
14316
+ * once externally to avoid redundant work.
14317
+ * @param {Vector2} worldPos
14318
+ * @param {number} [searchRange=10] - Max box-radius in tiles
14319
+ * @param {boolean} [rebuild=true] - Whether to call buildNodeData first
14320
+ * @returns {PathFinderNode|null}
14321
+ * @memberof PathFinding */
14322
+ getNearestClearNode(worldPos, searchRange = 10, rebuild = true)
14323
+ {
14324
+ ASSERT(isVector2(worldPos), 'worldPos must be a Vector2');
14325
+ if (rebuild) this.buildNodeData();
14326
+
14327
+ // Inline worldToTile to avoid a Vector2 allocation per call.
14328
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
14329
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
14330
+ const centerX = floor(worldPos.x - ox);
14331
+ const centerY = floor(worldPos.y - oy);
14332
+
14333
+ for (let offset = 0; offset <= searchRange; ++offset)
14334
+ {
14335
+ let nearest = null;
14336
+ let nearestDistSq = 0;
14337
+
14338
+ for (let dy = -offset; dy <= offset; ++dy)
14339
+ for (let dx = -offset; dx <= offset; ++dx)
14340
+ {
14341
+ // Only scan the perimeter of the current ring (skip the
14342
+ // interior we've already searched in earlier iterations).
14343
+ if (offset > 0 && abs(dx) !== offset && abs(dy) !== offset)
14344
+ continue;
14345
+
14346
+ const node = this.getNode(centerX + dx, centerY + dy);
14347
+ if (!node || !node.isClear()) continue;
14348
+
14349
+ const ddx = node.posWorld.x - worldPos.x;
14350
+ const ddy = node.posWorld.y - worldPos.y;
14351
+ const distSq = ddx * ddx + ddy * ddy;
14352
+ if (!nearest || distSq < nearestDistSq)
14353
+ {
14354
+ nearest = node;
14355
+ nearestDistSq = distSq;
14356
+ }
14357
+ }
14358
+ if (nearest) return nearest;
14359
+ }
14360
+ return null;
14361
+ }
14362
+
14363
+ /** Smooth a node path by removing redundant turns and tightening corners
14364
+ * where a grid-aligned diagonal is clear. Modifies the path in place.
14365
+ * Stays on the grid — does not introduce off-tile-center points.
14366
+ * Port of ShortenPath() in pathFinding.cpp.
14367
+ * @param {PathFinderNode[]} path
14368
+ * @private */
14369
+ smoothPathCorners(path)
14370
+ {
14371
+ if (path.length <= 2) return;
14372
+
14373
+ let i = 1;
14374
+ while (i < path.length - 1)
14375
+ {
14376
+ const prev = path[i - 1];
14377
+ const node = path[i];
14378
+ const next = path[i + 1];
14379
+
14380
+ const dx = next.pos.x - prev.pos.x;
14381
+ const dy = next.pos.y - prev.pos.y;
14382
+ const lenSq = dx * dx + dy * dy;
14383
+
14384
+ // dx,dy is the prev-to-current step direction; needed for the
14385
+ // 135° "mostly vertical/horizontal" disambiguation.
14386
+ const stepDx = node.pos.x - prev.pos.x;
14387
+ const stepDy = node.pos.y - prev.pos.y;
14388
+ const stepDxNext = next.pos.x - node.pos.x;
14389
+ const stepDyNext = next.pos.y - node.pos.y;
14390
+
14391
+ if (lenSq === 1)
14392
+ {
14393
+ // 45° angle — middle node is off the straight line. Drop it.
14394
+ if (this.debug && this.debugTime > 0)
14395
+ debugCircle(node.posWorld, 0.3, rgb(0.5, 0, 0.5, 0.5), this.debugTime);
14396
+ path.splice(i, 1);
14397
+ i = max(1, i - 1);
14398
+ continue;
14399
+ }
14400
+ else if (lenSq === 2)
14401
+ {
14402
+ // 90° corner. Check the alternative-diagonal cell.
14403
+ if (this.debug && this.debugTime > 0)
14404
+ debugCircle(node.posWorld, 0.3, rgb(1, 0, 0, 0.5), this.debugTime);
14405
+
14406
+ let sx, sy;
14407
+ if (prev.pos.y === node.pos.y && next.pos.x === node.pos.x)
14408
+ { sx = prev.pos.x; sy = next.pos.y; }
14409
+ else
14410
+ { sx = next.pos.x; sy = prev.pos.y; }
14411
+
14412
+ const shortcut = this.getNode(sx, sy);
14413
+ if (shortcut && shortcut.isClear())
14414
+ {
14415
+ path.splice(i, 1);
14416
+ i = max(1, i - 1);
14417
+ continue;
14418
+ }
14419
+ }
14420
+ else if (lenSq === 5)
14421
+ {
14422
+ // 135° angle (a knight's-move offset). Try to relocate the
14423
+ // middle node to whichever of two candidate cells is closer
14424
+ // to prev-of-prev, and only if the corner cut is also clear.
14425
+ if (this.debug && this.debugTime > 0)
14426
+ debugCircle(node.posWorld, 0.3, rgb(1, 1, 0, 0.5), this.debugTime);
14427
+
14428
+ const prevPrev = i >= 2 ? path[i - 2] : prev;
14429
+ let s1x, s1y, s2x, s2y;
14430
+ if (stepDx === 0 || stepDxNext === 0)
14431
+ {
14432
+ // mostly vertical
14433
+ s1x = next.pos.x; s1y = node.pos.y;
14434
+ s2x = prev.pos.x; s2y = node.pos.y;
14435
+ }
14436
+ else
14437
+ {
14438
+ // mostly horizontal
14439
+ s1x = node.pos.x; s1y = next.pos.y;
14440
+ s2x = node.pos.x; s2y = prev.pos.y;
14441
+ }
14442
+ const dd1x = s1x - prevPrev.pos.x;
14443
+ const dd1y = s1y - prevPrev.pos.y;
14444
+ const dd2x = s2x - prevPrev.pos.x;
14445
+ const dd2y = s2y - prevPrev.pos.y;
14446
+ const dist1Sq = dd1x * dd1x + dd1y * dd1y;
14447
+ const dist2Sq = dd2x * dd2x + dd2y * dd2y;
14448
+ const sx = dist1Sq < dist2Sq ? s1x : s1x === s2x && s1y === s2y ? s1x : s2x;
14449
+ const sy = dist1Sq < dist2Sq ? s1y : s1x === s2x && s1y === s2y ? s1y : s2y;
14450
+
14451
+ const shortcut = this.getNode(sx, sy);
14452
+ if (shortcut && shortcut !== node && shortcut.isClear())
14453
+ {
14454
+ // Also check the cut-corner cell is clear.
14455
+ const ccx = next.pos.x + s2x - s1x;
14456
+ const ccy = next.pos.y + s2y - s1y;
14457
+ const cutCorner = this.getNode(ccx, ccy);
14458
+ if (cutCorner && cutCorner.isClear())
14459
+ {
14460
+ path[i] = shortcut;
14461
+ i = max(1, i - 1);
14462
+ continue;
14463
+ }
14464
+ }
14465
+ }
14466
+ else if (lenSq === 4 || lenSq === 8)
14467
+ {
14468
+ // Straight line or a 1-cell bump.
14469
+ if (this.debug && this.debugTime > 0)
14470
+ debugCircle(node.posWorld, 0.3, rgb(0, 1, 0, 0.5), this.debugTime);
14471
+
14472
+ if (stepDx === stepDxNext && stepDy === stepDyNext)
14473
+ {
14474
+ // Truly straight — nothing to do, advance.
14475
+ ++i;
14476
+ continue;
14477
+ }
14478
+ else
14479
+ {
14480
+ // Bump — try to flatten via the in-line cell.
14481
+ let sx, sy;
14482
+ if (prev.pos.y === next.pos.y)
14483
+ { sx = node.pos.x; sy = prev.pos.y; }
14484
+ else
14485
+ { sx = prev.pos.x; sy = node.pos.y; }
14486
+ const shortcut = this.getNode(sx, sy);
14487
+ if (shortcut && shortcut.isClear())
14488
+ {
14489
+ path[i] = shortcut;
14490
+ i = max(1, i - 1);
14491
+ continue;
14492
+ }
14493
+ }
14494
+ }
14495
+
14496
+ ++i;
14497
+ }
14498
+ }
14499
+
14500
+ /** Smooth a node path via line-of-sight ("string pulling"). Walks the
14501
+ * input path collapsing runs of nodes into straight segments whenever
14502
+ * isLineClear permits, so the result can leave grid centers and cut
14503
+ * cleanly across open spaces.
14504
+ *
14505
+ * Bails (leaves the path unchanged) if any node has nonzero cost — a
14506
+ * straight geometric shortcut can't be trusted to be the lowest-cost
14507
+ * route when cost-weighted terrain is in play.
14508
+ *
14509
+ * Port of ShortenPath2() in pathFinding.cpp.
14510
+ * @param {PathFinderNode[]} path
14511
+ * @private */
14512
+ smoothPathStringPull(path)
14513
+ {
14514
+ if (path.length <= 2) return;
14515
+ for (const n of path)
14516
+ {
14517
+ if (!n.isClear()) return;
14518
+ }
14519
+
14520
+ const original = path.slice();
14521
+ path.length = 0;
14522
+ path.push(original[0]);
14523
+ let searchIndex = 0;
14524
+
14525
+ for (let i = 1; i < original.length; ++i)
14526
+ {
14527
+ const node = original[i];
14528
+
14529
+ // Skip if node is collinear with the search-window start and the
14530
+ // previous node — it adds no information. Note: a == b is the
14531
+ // degenerate i=1, searchIndex=0 case; skip the test then.
14532
+ {
14533
+ const a = original[searchIndex];
14534
+ const b = original[i - 1];
14535
+ if (a !== b)
14536
+ {
14537
+ const cross =
14538
+ (b.pos.x - a.pos.x) * (node.pos.y - a.pos.y) -
14539
+ (b.pos.y - a.pos.y) * (node.pos.x - a.pos.x);
14540
+ if (cross === 0) continue;
14541
+ }
14542
+ }
14543
+
14544
+ if (!this.isLineClear(node.pos, path[path.length - 1].pos))
14545
+ {
14546
+ // Look ahead — if any later node has a clear shot to the
14547
+ // back of our new path, skip this node and try later.
14548
+ let foundClearAfter = false;
14549
+ for (let j = i + 1; j < original.length; ++j)
14550
+ {
14551
+ if (this.isLineClear(original[j].pos, path[path.length - 1].pos))
14552
+ {
14553
+ foundClearAfter = true;
14554
+ break;
14555
+ }
14556
+ }
14557
+ if (foundClearAfter)
14558
+ {
14559
+ if (this.debug && this.debugTime > 0)
14560
+ debugLine(node.posWorld, path[path.length - 1].posWorld, rgb(0, 0, 1, 0.3), 0.02, this.debugTime);
14561
+ continue;
14562
+ }
14563
+
14564
+ // No clear line ahead — fall back to the last waypoint we did
14565
+ // have a clear line to. searchIndex tracks our scan position.
14566
+ for (; searchIndex < original.length; ++searchIndex)
14567
+ {
14568
+ const cand = original[searchIndex];
14569
+ if (this.isLineClear(node.pos, cand.pos))
14570
+ {
14571
+ path.push(cand);
14572
+ i = searchIndex;
14573
+ break;
14574
+ }
14575
+ }
14576
+ ASSERT(searchIndex < original.length, 'smoothPathStringPull: ran out of candidates');
14577
+ }
14578
+ }
14579
+
14580
+ path.push(original[original.length - 1]);
14581
+ }
14582
+
14583
+ /** Lookup helper: true when the node at tile coords (x, y) is in-bounds
14584
+ * and clear (walkable, zero-cost). Used by isLineClear's hot path.
14585
+ * @param {number} x
14586
+ * @param {number} y
14587
+ * @returns {boolean}
14588
+ * @private */
14589
+ isNodeClear(x, y)
14590
+ {
14591
+ const n = this.getNode(x, y);
14592
+ return n !== null && n.isClear();
14593
+ }
14594
+
14595
+ /** Check that the line between two tile-coord endpoints stays entirely
14596
+ * inside walkable, zero-cost cells. Stricter than just sampling along
14597
+ * the line — it also checks the diagonal-corner-adjacent cells so the
14598
+ * line can never "scrape past" a wall corner.
14599
+ *
14600
+ * Both endpoints must themselves be clear (asserted in debug). Port of
14601
+ * CheckLine() in pathFinding.cpp.
14602
+ * @param {Vector2} startPos - Tile coords
14603
+ * @param {Vector2} endPos - Tile coords
14604
+ * @returns {boolean}
14605
+ * @private */
14606
+ isLineClear(startPos, endPos)
14607
+ {
14608
+ ASSERT(isVector2(startPos) && isVector2(endPos), 'isLineClear needs Vector2 endpoints');
14609
+ ASSERT(this.isNodeClear(startPos.x, startPos.y) && this.isNodeClear(endPos.x, endPos.y),
14610
+ 'isLineClear endpoints must be in-bounds and clear');
14611
+
14612
+ const dx = endPos.x - startPos.x;
14613
+ const dy = endPos.y - startPos.y;
14614
+ const adx = abs(dx);
14615
+ const ady = abs(dy);
14616
+ const sx = sign(dx);
14617
+ const sy = sign(dy);
14618
+ let x = startPos.x;
14619
+ let y = startPos.y;
14620
+
14621
+ if (ady === adx)
14622
+ {
14623
+ // Pure diagonal.
14624
+ while (x !== endPos.x)
14625
+ {
14626
+ if (x !== startPos.x)
14627
+ {
14628
+ if (!this.isNodeClear(x, y)) return false;
14629
+ if (!this.isNodeClear(x, y - sy)) return false;
14630
+ }
14631
+ if (!this.isNodeClear(x, y + sy)) return false;
14632
+ x += sx;
14633
+ y += sy;
14634
+ }
14635
+ if (!this.isNodeClear(endPos.x, endPos.y - sy)) return false;
14636
+ }
14637
+ else if (ady < adx)
14638
+ {
14639
+ // Mostly horizontal.
14640
+ if (dy === 0)
14641
+ {
14642
+ // Purely horizontal.
14643
+ x += sx;
14644
+ while (x !== endPos.x)
14645
+ {
14646
+ if (!this.isNodeClear(x, y)) return false;
14647
+ x += sx;
14648
+ }
14649
+ }
14650
+ else
14651
+ {
14652
+ let lastY = startPos.y;
14653
+ while (x !== endPos.x)
14654
+ {
14655
+ y = startPos.y + Math.trunc((dy * (x - startPos.x)) / dx);
14656
+ if (lastY !== y)
14657
+ {
14658
+ if (!this.isNodeClear(x - sx, y + sy)) return false;
14659
+ if (!this.isNodeClear(x, y - sy)) return false;
14660
+ }
14661
+ lastY = y;
14662
+ if (x !== startPos.x)
14663
+ {
14664
+ if (!this.isNodeClear(x, y)) return false;
14665
+ }
14666
+ y += sy;
14667
+ if (!this.isNodeClear(x, y)) return false;
14668
+ x += sx;
14669
+ }
14670
+ const finalY = endPos.y - sy;
14671
+ if (!this.isNodeClear(endPos.x, finalY)) return false;
14672
+ }
14673
+ }
14674
+ else
14675
+ {
14676
+ // Mostly vertical.
14677
+ if (dx === 0)
14678
+ {
14679
+ y += sy;
14680
+ while (y !== endPos.y)
14681
+ {
14682
+ if (!this.isNodeClear(x, y)) return false;
14683
+ y += sy;
14684
+ }
14685
+ }
14686
+ else
14687
+ {
14688
+ let lastX = startPos.x;
14689
+ while (y !== endPos.y)
14690
+ {
14691
+ x = startPos.x + Math.trunc((dx * (y - startPos.y)) / dy);
14692
+ if (lastX !== x)
14693
+ {
14694
+ if (!this.isNodeClear(x + sx, y - sy)) return false;
14695
+ if (!this.isNodeClear(x - sx, y)) return false;
14696
+ }
14697
+ lastX = x;
14698
+ if (y !== startPos.y)
14699
+ {
14700
+ if (!this.isNodeClear(x, y)) return false;
14701
+ }
14702
+ x += sx;
14703
+ if (!this.isNodeClear(x, y)) return false;
14704
+ y += sy;
14705
+ }
14706
+ const finalX = endPos.x - sx;
14707
+ if (!this.isNodeClear(finalX, endPos.y)) return false;
14708
+ }
14709
+ }
14710
+ return true;
14711
+ }
14712
+
14713
+ /** Find a path from startPos to endPos in world space. Returns an array
14714
+ * of world-space Vector2 points; empty array if no path exists.
14715
+ *
14716
+ * Start and end are snapped to the nearest walkable tile via
14717
+ * getNearestClearNode. Intermediate points are tile centers unless the
14718
+ * string-pulling smoothing pass moves them off-grid.
14719
+ * @param {Vector2} startPos - World-space start
14720
+ * @param {Vector2} endPos - World-space end
14721
+ * @returns {Vector2[]}
14722
+ * @memberof PathFinding */
14723
+ findPath(startPos, endPos)
14724
+ {
14725
+ ASSERT(isVector2(startPos) && isVector2(endPos), 'findPath needs Vector2 endpoints');
14726
+
14727
+ this.buildNodeData();
14728
+
14729
+ // rebuild=false because we just built — avoid redundant work per snap.
14730
+ const startNode = this.getNearestClearNode(startPos, 10, false);
14731
+ const endNode = this.getNearestClearNode(endPos, 10, false);
14732
+ if (!startNode || !endNode) return [];
14733
+
14734
+ // Trivial case: start and end snapped to the same tile.
14735
+ if (startNode === endNode) return [startNode.posWorld.copy()];
14736
+
14737
+ if (!this.aStarSearch(startNode, endNode)) return [];
14738
+
14739
+ // Walk back from endNode via parent pointers, then reverse — cheaper
14740
+ // than unshifting on every step.
14741
+ const nodePath = [];
14742
+ for (let n = endNode; n; n = n.parent)
14743
+ nodePath.push(n);
14744
+ nodePath.reverse();
14745
+
14746
+ if (this.smoothPath)
14747
+ {
14748
+ this.smoothPathCorners(nodePath);
14749
+ this.smoothPathStringPull(nodePath);
14750
+ }
14751
+
14752
+ // Convert to world-space Vector2 path. Return copies, not live node
14753
+ // references — callers shouldn't be able to mutate the grid.
14754
+ const result = nodePath.map(n => n.posWorld.copy());
14755
+
14756
+ if (this.debug && this.debugTime > 0 && result.length > 0)
14757
+ {
14758
+ for (let i = 1; i < result.length; ++i)
14759
+ debugLine(result[i - 1], result[i], RED, 0.1, this.debugTime);
14760
+ for (const p of result)
14761
+ debugCircle(p, 0.5, rgb(1, 0, 0, 0.3), this.debugTime);
14762
+ debugCircle(result[0], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
14763
+ debugCircle(result[result.length - 1], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
14764
+ }
14765
+
14766
+ return result;
14767
+ }
14768
+ }
14769
+