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
@@ -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 - Release Mode
@@ -723,7 +584,7 @@ const debugPhysics = 0;
723
584
  const debugParticles = 0;
724
585
  const debugRaycast = 0;
725
586
  const debugGamepads = 0;
726
- const debugMedals = 0;
587
+ const debugPointSize = .5;
727
588
 
728
589
  // debug commands are automatically removed from the final build
729
590
  function ASSERT (){}
@@ -900,7 +761,11 @@ function percentLerp(value, percentA, percentB, lerpA, lerpB)
900
761
  * @return {number}
901
762
  * @memberof Math */
902
763
  function distanceWrap(valueA, valueB, wrapSize=1)
903
- { const d = (valueA - valueB) % wrapSize; return d*2 % wrapSize - d; }
764
+ {
765
+ ASSERT(wrapSize > 0, 'distanceWrap wrapSize must be > 0');
766
+ const d = (valueA - valueB) % wrapSize;
767
+ return d*2 % wrapSize - d;
768
+ }
904
769
 
905
770
  /** Linearly interpolates between values passed in with wrapping
906
771
  * @param {number} valueA
@@ -1035,13 +900,15 @@ function oscillate(frequency=1, amplitude=1, t=time, offset=0, type=0)
1035
900
  function isNumber(n) { return typeof n === 'number' && !isNaN(n); }
1036
901
 
1037
902
  /**
1038
- * Check if object can be converted to a string (has a toString method)
903
+ * Check if a value is stringifiable i.e. it has a toString that returns
904
+ * a string. Use this for ASSERTs and inputs that will be coerced to text;
905
+ * use `typeof x === 'string'` inline if you need strict-string semantics.
1039
906
  * - Returns true for strings, numbers, and most objects
1040
907
  * - Returns false for null and undefined
1041
908
  * @param {any} s
1042
909
  * @return {boolean}
1043
910
  * @memberof Math */
1044
- function isString(s) { return s != null && typeof s?.toString() === 'string'; }
911
+ function isStringLike(s) { return s != null && typeof s?.toString() === 'string'; }
1045
912
 
1046
913
  /**
1047
914
  * Check if object is an array
@@ -1217,6 +1084,7 @@ class RandomGenerator
1217
1084
  * @param {number} [seed] - Starting seed or engine default seed */
1218
1085
  constructor(seed = 123456789)
1219
1086
  {
1087
+ ASSERT(seed !== 0, 'RandomGenerator seed must be non-zero (xorshift is fixed at 0)');
1220
1088
  /** @property {number} - random seed */
1221
1089
  this.seed = seed;
1222
1090
  }
@@ -1510,8 +1378,10 @@ class Vector2
1510
1378
  * @return {Vector2} */
1511
1379
  floor() { return new Vector2(floor(this.x), floor(this.y)); }
1512
1380
 
1513
- /** Returns a copy of this vector snapped to a grid
1514
- * @param {number} grid - grid size to snap to
1381
+ /** Returns a copy of this vector snapped to a grid. Note that `grid` is
1382
+ * the number of snap steps per unit (so `grid=2` snaps to halves and
1383
+ * `grid=0.5` snaps to twos), not the cell size.
1384
+ * @param {number} grid - snap steps per unit
1515
1385
  * @return {Vector2} */
1516
1386
  snap(grid)
1517
1387
  {
@@ -1782,7 +1652,7 @@ class Color
1782
1652
  * @return {Color} */
1783
1653
  setHex(hex)
1784
1654
  {
1785
- ASSERT(isString(hex), 'Color hex code must be a string');
1655
+ ASSERT(isStringLike(hex), 'Color hex code must be a string');
1786
1656
  ASSERT(hex[0] === '#', 'Color hex code must start with #');
1787
1657
  ASSERT([4,5,7,9].includes(hex.length), 'Invalid hex');
1788
1658
 
@@ -2046,8 +1916,8 @@ function saveCanvas(canvas, filename='screenshot', type='image/png')
2046
1916
  * @memberof Utilities */
2047
1917
  function saveDataURL(url, filename='download', revokeTime)
2048
1918
  {
2049
- ASSERT(isString(url), 'saveDataURL requires url string');
2050
- ASSERT(isString(filename), 'saveDataURL requires filename string');
1919
+ ASSERT(isStringLike(url), 'saveDataURL requires url string');
1920
+ ASSERT(isStringLike(filename), 'saveDataURL requires filename string');
2051
1921
 
2052
1922
  // create link for saving screenshots
2053
1923
  const link = document.createElement('a');
@@ -2065,8 +1935,8 @@ function saveDataURL(url, filename='download', revokeTime)
2065
1935
  * @memberof Utilities */
2066
1936
  function shareURL(title, url, callback)
2067
1937
  {
2068
- ASSERT(isString(title), 'shareURL requires title string');
2069
- ASSERT(isString(url), 'shareURL requires url string');
1938
+ ASSERT(isStringLike(title), 'shareURL requires title string');
1939
+ ASSERT(isStringLike(url), 'shareURL requires url string');
2070
1940
  navigator.share?.({title, url}).then(()=>callback?.());
2071
1941
  }
2072
1942
 
@@ -2079,11 +1949,16 @@ function shareURL(title, url, callback)
2079
1949
  * @memberof Utilities */
2080
1950
  function readSaveData(saveName, defaultSaveData)
2081
1951
  {
2082
- ASSERT(isString(saveName), 'loadData requires saveName string');
1952
+ ASSERT(isStringLike(saveName), 'loadData requires saveName string');
2083
1953
 
2084
- // replace undefined values with defaults
1954
+ // replace undefined values with defaults; tolerate corrupt JSON
2085
1955
  const data = localStorage[saveName];
2086
- const loadedData = data ? JSON.parse(data) : {};
1956
+ let loadedData = {};
1957
+ if (data)
1958
+ {
1959
+ try { loadedData = JSON.parse(data); }
1960
+ catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
1961
+ }
2087
1962
  return { ...defaultSaveData, ...loadedData };
2088
1963
  }
2089
1964
 
@@ -2093,7 +1968,7 @@ function readSaveData(saveName, defaultSaveData)
2093
1968
  * @memberof Utilities */
2094
1969
  function writeSaveData(saveName, saveData)
2095
1970
  {
2096
- ASSERT(isString(saveName), 'saveData requires saveName string');
1971
+ ASSERT(isStringLike(saveName), 'saveData requires saveName string');
2097
1972
  localStorage[saveName] = JSON.stringify(saveData);
2098
1973
  }
2099
1974
  /**
@@ -2418,33 +2293,6 @@ let soundDefaultRange = 40;
2418
2293
  * @memberof Settings */
2419
2294
  let soundDefaultTaper = .7;
2420
2295
 
2421
- ///////////////////////////////////////////////////////////////////////////////
2422
- // Medals settings
2423
-
2424
- /** How long to show medals for in seconds
2425
- * @type {number}
2426
- * @default
2427
- * @memberof Settings */
2428
- let medalDisplayTime = 5;
2429
-
2430
- /** How quickly to slide on/off medals in seconds
2431
- * @type {number}
2432
- * @default
2433
- * @memberof Settings */
2434
- let medalDisplaySlideTime = .5;
2435
-
2436
- /** Size of medal display
2437
- * @type {Vector2}
2438
- * @default Vector2(640,80)
2439
- * @memberof Settings */
2440
- let medalDisplaySize = vec2(640, 80);
2441
-
2442
- /** Set to stop medals from being unlockable (like if cheats are enabled)
2443
- * @type {boolean}
2444
- * @default
2445
- * @memberof Settings */
2446
- let medalsPreventUnlock = false;
2447
-
2448
2296
  ///////////////////////////////////////////////////////////////////////////////
2449
2297
  // Setters for global variables
2450
2298
 
@@ -2706,26 +2554,6 @@ function setSoundDefaultRange(range) { soundDefaultRange = range; }
2706
2554
  * @memberof Settings */
2707
2555
  function setSoundDefaultTaper(taper) { soundDefaultTaper = taper; }
2708
2556
 
2709
- /** Set how long to show medals for in seconds
2710
- * @param {number} time
2711
- * @memberof Settings */
2712
- function setMedalDisplayTime(time) { medalDisplayTime = time; }
2713
-
2714
- /** Set how quickly to slide on/off medals in seconds
2715
- * @param {number} time
2716
- * @memberof Settings */
2717
- function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
2718
-
2719
- /** Set size of medal display
2720
- * @param {Vector2} size
2721
- * @memberof Settings */
2722
- function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
2723
-
2724
- /** Set to stop medals from being unlockable
2725
- * @param {boolean} preventUnlock
2726
- * @memberof Settings */
2727
- function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }
2728
-
2729
2557
  /** Set if watermark with FPS should be shown
2730
2558
  * @param {boolean} show
2731
2559
  * @memberof Debug */
@@ -2867,10 +2695,18 @@ class EngineObject
2867
2695
  const parent = this.parent;
2868
2696
  if (parent)
2869
2697
  {
2870
- // copy parent pos/angle
2698
+ // compose with parent transform inline to avoid intermediate vector allocs
2871
2699
  const mirror = parent.getMirrorSign();
2872
- this.pos = this.localPos.multiply(vec2(mirror,1)).rotate(parent.angle).add(parent.pos);
2873
- this.angle = mirror*this.localAngle + parent.angle;
2700
+ const lp = this.localPos, pp = parent.pos;
2701
+ const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
2702
+ if (pa)
2703
+ {
2704
+ const c = cos(-pa), s = sin(-pa);
2705
+ this.pos = new Vector2(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
2706
+ }
2707
+ else
2708
+ this.pos = new Vector2(lx + pp.x, ly + pp.y);
2709
+ this.angle = mirror*this.localAngle + pa;
2874
2710
  }
2875
2711
 
2876
2712
  // update children
@@ -3236,8 +3072,6 @@ class EngineObject
3236
3072
  * @return {string} */
3237
3073
  toString()
3238
3074
  {
3239
- if (!debug) return;
3240
-
3241
3075
  let text = 'type = ' + this.constructor.name;
3242
3076
  if (this.pos.x || this.pos.y)
3243
3077
  text += '\npos = ' + this.pos;
@@ -3487,8 +3321,9 @@ class TextureInfo
3487
3321
  * Create a TextureInfo, called automatically by the engine
3488
3322
  * @param {HTMLImageElement|OffscreenCanvas} image
3489
3323
  * @param {boolean} [useWebGL] - Should use WebGL if available?
3324
+ * @param {boolean} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)?
3490
3325
  */
3491
- constructor(image, useWebGL=true)
3326
+ constructor(image, useWebGL=true, wrap=false)
3492
3327
  {
3493
3328
  /** @property {HTMLImageElement|OffscreenCanvas} - image source */
3494
3329
  this.image = image;
@@ -3498,6 +3333,8 @@ class TextureInfo
3498
3333
  this.sizeInverse = image ? vec2(1/image.width, 1/image.height) : vec2();
3499
3334
  /** @property {WebGLTexture} - WebGL texture */
3500
3335
  this.glTexture = undefined;
3336
+ /** @property {boolean} - true for REPEAT wrap mode, false for CLAMP_TO_EDGE */
3337
+ this.wrap = wrap;
3501
3338
  useWebGL && this.createWebGLTexture();
3502
3339
  }
3503
3340
 
@@ -3510,6 +3347,14 @@ class TextureInfo
3510
3347
  /** Check if the texture is webgl enabled
3511
3348
  * @return {boolean} */
3512
3349
  hasWebGL() { return !!this.glTexture; }
3350
+
3351
+ /** Set the wrap mode for this texture
3352
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE */
3353
+ setWrap(wrap=true)
3354
+ {
3355
+ this.wrap = wrap;
3356
+ glSetTextureWrap(this.glTexture, wrap);
3357
+ }
3513
3358
  }
3514
3359
 
3515
3360
  ///////////////////////////////////////////////////////////////////////////////
@@ -3571,8 +3416,13 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
3571
3416
  }
3572
3417
  else
3573
3418
  {
3574
- // if no tile info, force untextured
3575
- glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0, 0, color.rgbaInt());
3419
+ // if no tile info, force untextured by zeroing rgba (so whatever
3420
+ // texture is bound doesn't leak in) and folding color+additive
3421
+ // into the additive slot — matches the Canvas2D path's
3422
+ // color.add(additiveColor) on line ~337.
3423
+ const combined = additiveColor ? color.add(additiveColor) : color;
3424
+ glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0,
3425
+ 0, combined.rgbaInt());
3576
3426
  }
3577
3427
  }
3578
3428
  else
@@ -3711,6 +3561,8 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
3711
3561
  const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
3712
3562
  ASSERT(textureInfo instanceof TextureInfo, 'texture not loaded');
3713
3563
  ASSERT(textureInfo.size.x > 0, 'texture not loaded');
3564
+ ASSERT(textureInfo.wrap,
3565
+ 'drawTextureWrapped requires a wrap-enabled texture; call textureInfo.setWrap(true) first');
3714
3566
 
3715
3567
  if (useWebGL && glEnable)
3716
3568
  {
@@ -4062,15 +3914,15 @@ function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, f
4062
3914
  * @memberof Draw */
4063
3915
  function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
4064
3916
  {
4065
- ASSERT(isString(text), 'text must be a string');
3917
+ ASSERT(isStringLike(text), 'text must be a string');
4066
3918
  ASSERT(isVector2(pos), 'pos must be a vec2');
4067
3919
  ASSERT(isNumber(size), 'size must be a number');
4068
3920
  ASSERT(isColor(color), 'color must be a color');
4069
3921
  ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
4070
3922
  ASSERT(isColor(lineColor), 'lineColor must be a color');
4071
3923
  ASSERT(['left','center','right'].includes(textAlign), 'align must be left, center, or right');
4072
- ASSERT(isString(font), 'font must be a string');
4073
- ASSERT(isString(fontStyle), 'fontStyle must be a string');
3924
+ ASSERT(isStringLike(font), 'font must be a string');
3925
+ ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
4074
3926
  ASSERT(isNumber(angle), 'angle must be a number');
4075
3927
 
4076
3928
  context.fillStyle = color.toString();
@@ -4107,7 +3959,7 @@ async function loadTexture(textureIndex, src)
4107
3959
  {
4108
3960
  ASSERT(isNumber(textureIndex), 'textureIndex must be a number');
4109
3961
  ASSERT(!textureInfos[textureIndex], 'textureIndex is already loaded!');
4110
- ASSERT(!src || isString(src), 'image src must be a string');
3962
+ ASSERT(!src || isStringLike(src), 'image src must be a string');
4111
3963
 
4112
3964
  const image = new Image;
4113
3965
  if (src)
@@ -4495,7 +4347,7 @@ class FontImage
4495
4347
  */
4496
4348
  drawTextScreen(text, pos, size, center=true, color=WHITE, useWebGL=glEnable, context)
4497
4349
  {
4498
- ASSERT(isString(text), 'text must be a string');
4350
+ ASSERT(isStringLike(text), 'text must be a string');
4499
4351
  ASSERT(isVector2(pos), 'pos must be a vec2');
4500
4352
  ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
4501
4353
  ASSERT(isColor(color), 'color must be a color');
@@ -4619,7 +4471,7 @@ const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
4619
4471
  * This is useful to disable for html menus so the browser can handle input normally
4620
4472
  * @param {boolean} preventDefault
4621
4473
  * @memberof Input */
4622
- function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
4474
+ function setInputPreventDefault(preventDefault=true) { inputPreventDefault = preventDefault; }
4623
4475
 
4624
4476
  /** Clears an input key state
4625
4477
  * @param {string|number} key
@@ -4656,7 +4508,7 @@ function inputClear()
4656
4508
  * @memberof Input */
4657
4509
  function keyIsDown(key, device=0)
4658
4510
  {
4659
- ASSERT(isString(key), 'key must be a number or string');
4511
+ ASSERT(isStringLike(key), 'key must be a number or string');
4660
4512
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4661
4513
  return !!(inputData[device]?.[key] & 1);
4662
4514
  }
@@ -4668,7 +4520,7 @@ function keyIsDown(key, device=0)
4668
4520
  * @memberof Input */
4669
4521
  function keyWasPressed(key, device=0)
4670
4522
  {
4671
- ASSERT(isString(key), 'key must be a number or string');
4523
+ ASSERT(isStringLike(key), 'key must be a number or string');
4672
4524
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4673
4525
  return !!(inputData[device]?.[key] & 2);
4674
4526
  }
@@ -4680,7 +4532,7 @@ function keyWasPressed(key, device=0)
4680
4532
  * @memberof Input */
4681
4533
  function keyWasReleased(key, device=0)
4682
4534
  {
4683
- ASSERT(isString(key), 'key must be a number or string');
4535
+ ASSERT(isStringLike(key), 'key must be a number or string');
4684
4536
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4685
4537
  return !!(inputData[device]?.[key] & 4);
4686
4538
  }
@@ -4694,10 +4546,10 @@ function keyWasReleased(key, device=0)
4694
4546
  * @memberof Input */
4695
4547
  function keyDirection(up='ArrowUp', down='ArrowDown', left='ArrowLeft', right='ArrowRight')
4696
4548
  {
4697
- ASSERT(isString(up), 'up key must be a string');
4698
- ASSERT(isString(down), 'down key must be a string');
4699
- ASSERT(isString(left), 'left key must be a string');
4700
- ASSERT(isString(right), 'right key must be a string');
4549
+ ASSERT(isStringLike(up), 'up key must be a string');
4550
+ ASSERT(isStringLike(down), 'down key must be a string');
4551
+ ASSERT(isStringLike(left), 'left key must be a string');
4552
+ ASSERT(isStringLike(right), 'right key must be a string');
4701
4553
  const k = (key)=> keyIsDown(key) ? 1 : 0;
4702
4554
  return vec2(k(right) - k(left), k(up) - k(down));
4703
4555
  }
@@ -5485,7 +5337,7 @@ class Sound
5485
5337
  {
5486
5338
  if (!soundEnable || headlessMode) return;
5487
5339
 
5488
- ASSERT(!asset || isArray(asset) || isString(asset), 'asset must be a file name or zzfx array');
5340
+ ASSERT(!asset || isArray(asset) || isStringLike(asset), 'asset must be a file name or zzfx array');
5489
5341
  ASSERT(randomness === undefined || isNumber(randomness), 'randomness must be a number');
5490
5342
  ASSERT(randomness === undefined || randomness >= 0 && randomness <=1, 'randomness must be between 0 and 1');
5491
5343
  ASSERT(isNumber(range), 'range must be a number');
@@ -5506,8 +5358,8 @@ class Sound
5506
5358
 
5507
5359
  if (Array.isArray(asset))
5508
5360
  {
5509
- // generate zzfx sound
5510
- const zzfxSound = asset;
5361
+ // generate zzfx sound — copy so we don't mutate the caller's array
5362
+ const zzfxSound = asset.slice();
5511
5363
 
5512
5364
  // remove randomness so it can be applied on playback
5513
5365
  const defaultRandomness = randomness ?? .05;
@@ -7105,11 +6957,14 @@ class ParticleEmitter extends EngineObject
7105
6957
  }
7106
6958
 
7107
6959
  ///////////////////////////////////////////////////////////////////////////////
6960
+ // scratch vector reused by Particle.render to avoid per-frame allocations
6961
+ const particleDrawPos = new Vector2;
6962
+
7108
6963
  /**
7109
6964
  * Particle Object - Created automatically by Particle Emitters
7110
6965
  * @memberof Particles
7111
6966
  */
7112
- class Particle
6967
+ class Particle
7113
6968
  {
7114
6969
  /**
7115
6970
  * Create a particle with the passed in settings
@@ -7284,14 +7139,14 @@ class Particle
7284
7139
  additive && setBlendMode(true);
7285
7140
 
7286
7141
  // update the position and angle for drawing
7287
- const pos = this.pos.copy();
7142
+ const pos = particleDrawPos.set(this.pos.x, this.pos.y);
7288
7143
  let angle = this.angle;
7289
7144
  if (localSpace)
7290
7145
  {
7291
7146
  // in local space of emitter
7292
7147
  const a = emitter.angle;
7293
7148
  const c = cos(a), s = sin(a);
7294
- pos.set(emitter.pos.x + pos.x*c - pos.y*s,
7149
+ pos.set(emitter.pos.x + pos.x*c - pos.y*s,
7295
7150
  emitter.pos.y + pos.x*s + pos.y*c);
7296
7151
  angle += a;
7297
7152
  }
@@ -7316,200 +7171,6 @@ class Particle
7316
7171
  debugParticles && debugRect(pos, size, '#f005', 0, angle);
7317
7172
  }
7318
7173
  }
7319
- /**
7320
- * LittleJS Medal System
7321
- * - Achievement/trophy system for games
7322
- * - Medal class with name, description, icon, and unlock tracking
7323
- * - Automatic saving to local storage
7324
- * - Visual display queue with slide-in notifications
7325
- * - Newgrounds API integration for online achievements
7326
- * - Debug mode to unlock/reset medals during development
7327
- * @namespace Medals
7328
- */
7329
-
7330
- /** List of all medals
7331
- * @type {Object}
7332
- * @memberof Medals */
7333
- const medals = {};
7334
-
7335
- // Engine internal variables not exposed to documentation
7336
- let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast;
7337
-
7338
- ///////////////////////////////////////////////////////////////////////////////
7339
-
7340
- /** Initialize medals with a save name used for storage
7341
- * - Call this after creating all medals
7342
- * - Checks if medals are unlocked
7343
- * @param {string} saveName
7344
- * @memberof Medals */
7345
- function medalsInit(saveName)
7346
- {
7347
- // check if medals are unlocked
7348
- medalsSaveName = saveName;
7349
- if (!debugMedals)
7350
- medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
7351
-
7352
- // engine automatically renders medals
7353
- engineAddPlugin(undefined, medalsRender);
7354
-
7355
- // plugin functions
7356
- function medalsRender()
7357
- {
7358
- if (!medalsDisplayQueue.length) return;
7359
-
7360
- // update first medal in queue
7361
- const medal = medalsDisplayQueue[0];
7362
- const time = timeReal - medalsDisplayTimeLast;
7363
- if (!medalsDisplayTimeLast)
7364
- medalsDisplayTimeLast = timeReal;
7365
- else if (time > medalDisplayTime)
7366
- {
7367
- medalsDisplayTimeLast = 0;
7368
- medalsDisplayQueue.shift();
7369
- }
7370
- else
7371
- {
7372
- // slide on/off medals
7373
- const slideOffTime = medalDisplayTime - medalDisplaySlideTime;
7374
- const hidePercent =
7375
- time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime :
7376
- time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0;
7377
- medal.render(hidePercent);
7378
- }
7379
- }
7380
- }
7381
-
7382
- /**
7383
- * @callback MedalCallbackFunction - Function that processes a medal
7384
- * @param {Medal} medal
7385
- * @memberof Medals
7386
- */
7387
-
7388
- /** Calls a function for each medal
7389
- * @param {MedalCallbackFunction} callback
7390
- * @memberof Medals */
7391
- function medalsForEach(callback)
7392
- { Object.values(medals).forEach(medal=>callback(medal)); }
7393
-
7394
- ///////////////////////////////////////////////////////////////////////////////
7395
-
7396
- /**
7397
- * Medal - Tracks an unlockable medal
7398
- * @memberof Medals
7399
- * @example
7400
- * // create a medal
7401
- * const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
7402
- *
7403
- * // initialize medals
7404
- * medalsInit('Example Game');
7405
- *
7406
- * // unlock the medal
7407
- * medal_example.unlock();
7408
- */
7409
- class Medal
7410
- {
7411
- /** Create a medal object and adds it to the list of medals
7412
- * @param {number} id - The unique identifier of the medal
7413
- * @param {string} name - Name of the medal
7414
- * @param {string} [description] - Description of the medal
7415
- * @param {string} [icon] - Icon for the medal
7416
- * @param {string} [src] - Image location for the medal
7417
- */
7418
- constructor(id, name, description='', icon='🏆', src)
7419
- {
7420
- ASSERT(id >= 0 && !medals[id]);
7421
-
7422
- /** @property {number} - The unique identifier of the medal */
7423
- this.id = id;
7424
-
7425
- /** @property {string} - Name of the medal */
7426
- this.name = name;
7427
-
7428
- /** @property {string} - Description of the medal */
7429
- this.description = description;
7430
-
7431
- /** @property {string} - Icon for the medal */
7432
- this.icon = icon;
7433
-
7434
- /** @property {boolean} - Is the medal unlocked? */
7435
- this.unlocked = false;
7436
-
7437
- // load the source image if provided
7438
- if (src)
7439
- (this.image = new Image).src = src;
7440
-
7441
- // add this to list of medals
7442
- medals[id] = this;
7443
- }
7444
-
7445
- /** Unlocks a medal if not already unlocked */
7446
- unlock()
7447
- {
7448
- if (medalsPreventUnlock || this.unlocked) return;
7449
-
7450
- // save the medal
7451
- ASSERT(medalsSaveName, 'save name must be set');
7452
- localStorage[this.storageKey()] = this.unlocked = true;
7453
- medalsDisplayQueue.push(this);
7454
- }
7455
-
7456
- /** Render a medal
7457
- * @param {number} [hidePercent] - How much to slide the medal off screen
7458
- */
7459
- render(hidePercent=0)
7460
- {
7461
- const context = mainContext;
7462
- const width = min(medalDisplaySize.x, mainCanvas.width);
7463
- const height = medalDisplaySize.y;
7464
- const x = mainCanvas.width - width;
7465
- const y = -height*hidePercent;
7466
- const backgroundColor = hsl(0,0,.9);
7467
-
7468
- // draw containing rect and clip to that region
7469
- context.save();
7470
- context.beginPath();
7471
- context.fillStyle = backgroundColor.toString();
7472
- context.strokeStyle = BLACK.toString();
7473
- context.lineWidth = 3;
7474
- context.rect(x, y, width, height);
7475
- context.fill();
7476
- context.stroke();
7477
- context.clip();
7478
-
7479
- // draw the icon
7480
- const gap = vec2(.1, .05).scale(height);
7481
- const medalDisplayIconSize = height - 2*gap.x;
7482
- this.renderIcon(vec2(x + gap.x + medalDisplayIconSize/2, y + height/2), medalDisplayIconSize);
7483
-
7484
- // draw the name
7485
- const nameSize = height*.5;
7486
- const descriptionSize = height*.3;
7487
- const pos = vec2(x + medalDisplayIconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
7488
- const textWidth = width - medalDisplayIconSize - 3*gap.x;
7489
- drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
7490
-
7491
- // draw the description
7492
- pos.y = y + height - gap.y*2 - descriptionSize/2;
7493
- drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
7494
- context.restore();
7495
- }
7496
-
7497
- /** Render the icon for a medal
7498
- * @param {Vector2} pos - Screen space position
7499
- * @param {number} size - Screen space size
7500
- */
7501
- renderIcon(pos, size)
7502
- {
7503
- // draw the image or icon
7504
- if (this.image)
7505
- mainContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size);
7506
- else
7507
- drawTextScreen(this.icon, pos, size*.7, BLACK);
7508
- }
7509
-
7510
- // Get local storage key used by the medal
7511
- storageKey() { return medalsSaveName + '_' + this.id; }
7512
- }
7513
7174
  /**
7514
7175
  * LittleJS WebGL Interface
7515
7176
  * - WebGL2 rendering engine for high-performance graphics
@@ -7609,7 +7270,7 @@ function glInit(rootElement)
7609
7270
  // reinit WebGL and restore textures
7610
7271
  initWebGL();
7611
7272
  for (const info of glTextureInfos)
7612
- info.glTexture = glCreateTexture(info.image);
7273
+ info.glTexture = glCreateTexture(info.image, info.wrap);
7613
7274
  pluginList.forEach(plugin=>plugin.glContextRestored?.());
7614
7275
  });
7615
7276
 
@@ -7825,6 +7486,30 @@ function glSetTexture(texture)
7825
7486
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
7826
7487
  }
7827
7488
 
7489
+ /** Set the wrap mode (REPEAT or CLAMP_TO_EDGE) on an existing WebGL texture
7490
+ * Flushes the current batch only if the texture is the active one
7491
+ * @param {WebGLTexture} texture
7492
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
7493
+ * @memberof WebGL */
7494
+ function glSetTextureWrap(texture, wrap=true)
7495
+ {
7496
+ if (!glContext || !texture) return;
7497
+
7498
+ // flush only if changing wrap on the currently bound texture
7499
+ const isCurrent = texture === glActiveTexture;
7500
+ if (isCurrent)
7501
+ glFlush();
7502
+ else
7503
+ glContext.bindTexture(glContext.TEXTURE_2D, texture);
7504
+
7505
+ const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
7506
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
7507
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
7508
+
7509
+ if (!isCurrent && glActiveTexture)
7510
+ glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
7511
+ }
7512
+
7828
7513
  /** Compile WebGL shader of the given type, will throw errors if in debug mode
7829
7514
  * @param {string} source
7830
7515
  * @param {number} type
@@ -7869,9 +7554,10 @@ function glCreateProgram(vsSource, fsSource)
7869
7554
  /** Create WebGL texture from an image and init the texture settings
7870
7555
  * Restores the active texture when done
7871
7556
  * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas} [image]
7557
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
7872
7558
  * @return {WebGLTexture}
7873
7559
  * @memberof WebGL */
7874
- function glCreateTexture(image)
7560
+ function glCreateTexture(image, wrap=false)
7875
7561
  {
7876
7562
  if (!glContext) return;
7877
7563
 
@@ -7897,8 +7583,9 @@ function glCreateTexture(image)
7897
7583
  const minFilter = mipMap ? glContext.LINEAR_MIPMAP_LINEAR : magFilter;
7898
7584
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, magFilter);
7899
7585
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, minFilter);
7900
- glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, glContext.REPEAT);
7901
- glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, glContext.REPEAT);
7586
+ const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
7587
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
7588
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
7902
7589
  if (mipMap)
7903
7590
  glContext.generateMipmap(glContext.TEXTURE_2D);
7904
7591
 
@@ -7950,7 +7637,7 @@ function glRegisterTextureInfo(textureInfo)
7950
7637
  if (textureInfo.glTexture)
7951
7638
  glSetTextureData(textureInfo.glTexture, textureInfo.image);
7952
7639
  else
7953
- textureInfo.glTexture = glCreateTexture(textureInfo.image);
7640
+ textureInfo.glTexture = glCreateTexture(textureInfo.image, textureInfo.wrap);
7954
7641
  }
7955
7642
 
7956
7643
  /** Tells WebGL to destroy the glTexture and stop tracking it
@@ -8169,6 +7856,7 @@ function glSetRenderTarget(texture, clear=false)
8169
7856
  glFlush();
8170
7857
  glRenderTarget = undefined;
8171
7858
  glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
7859
+ glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
8172
7860
  }
8173
7861
  }
8174
7862
 
@@ -8291,9 +7979,9 @@ function glPolyStrip(points)
8291
7979
  return area;
8292
7980
  }
8293
7981
 
8294
- // ensure counter-clockwise winding
7982
+ // ensure counter-clockwise winding (slice first so we don't mutate caller's array)
8295
7983
  if (signedArea(points) < 0)
8296
- points = points.reverse();
7984
+ points = points.slice().reverse();
8297
7985
 
8298
7986
  // check if point is inside triangle
8299
7987
  const e = 1e-9;
@@ -8393,6 +8081,398 @@ function glPolyStrip(points)
8393
8081
  }
8394
8082
  return strip;
8395
8083
  }
8084
+ /**
8085
+ * LittleJS Engine Logo
8086
+ * - Draws the LittleJS splash screen logo
8087
+ * - Used internally during engine startup
8088
+ */
8089
+
8090
+ ///////////////////////////////////////////////////////////////////////////////
8091
+ function drawEngineLogo(t)
8092
+ {
8093
+ const blackAndWhite = 0;
8094
+ const showName = 1;
8095
+
8096
+ // LittleJS Logo and Splash Screen
8097
+ const x = mainContext;
8098
+ const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
8099
+ const w = mainCanvas.width = innerWidth * dpr;
8100
+ const h = mainCanvas.height = innerHeight * dpr;
8101
+ {
8102
+ // background
8103
+ const p3 = percent(t, 1, .8);
8104
+ const p4 = percent(t, 0, .5);
8105
+ const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,hypot(w,h)*.6);
8106
+ g.addColorStop(0,hsl(0,0,lerp(0,p3/2,p4),p3).toString());
8107
+ g.addColorStop(1,hsl(0,0,0,p3).toString());
8108
+ x.save();
8109
+ x.fillStyle = g;
8110
+ x.fillRect(0,0,w,h);
8111
+ }
8112
+ const gradient = (X1,Y1,X2,Y2,C,S=1)=>
8113
+ {
8114
+ if (C >= 0)
8115
+ {
8116
+ if (blackAndWhite)
8117
+ x.fillStyle = '#fff';
8118
+ else
8119
+ {
8120
+ const g = x.fillStyle = x.createLinearGradient(X1,Y1,X2,Y2);
8121
+ g.addColorStop(0,color(C,2));
8122
+ g.addColorStop(1,color(C,1));
8123
+ }
8124
+ }
8125
+ else
8126
+ x.fillStyle = '#000';
8127
+ C >= -1 ? (x.fill(), S && x.stroke()) : x.stroke();
8128
+ }
8129
+ const circle = (X,Y,R,A=0,B=2*PI,C,S)=>
8130
+ {
8131
+ x.beginPath();
8132
+ x.arc(X,Y,R,p*A,p*B);
8133
+ gradient(X,Y-R,X,Y+R,C,S);
8134
+ }
8135
+ const rect = (X,Y,W,H,C)=>
8136
+ {
8137
+ x.beginPath();
8138
+ x.rect(X,Y,W,H*p);
8139
+ gradient(X,Y+H,X+W,Y,C);
8140
+ }
8141
+ const poly = (points,C,Y,H)=>
8142
+ {
8143
+ x.beginPath();
8144
+ for (const p of points)
8145
+ x.lineTo(p.x, p.y);
8146
+ x.closePath();
8147
+ gradient(0, Y, 0, Y+H,C);
8148
+ }
8149
+ const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
8150
+
8151
+ // center and fit to screen
8152
+ const alpha = oscillate(1,1,t);
8153
+ const p = percent(alpha, .1, .5);
8154
+ const size = min(6, min(w,h)/99);
8155
+ x.translate(w/2,h/2);
8156
+ x.scale(size,size);
8157
+ x.translate(-40,-35);
8158
+ p < 1 && x.setLineDash([99*p,99]);
8159
+ x.lineJoin = x.lineCap = 'round';
8160
+ x.lineWidth = .1 + p*1.9;
8161
+ //x.strokeStyle='#fff7';
8162
+
8163
+ if (showName)
8164
+ {
8165
+ // engine name text
8166
+ const Y = 54;
8167
+ const s = 'LittleJS';
8168
+ x.font = '900 15.5px arial';
8169
+ x.lineWidth = .1+p*3.9;
8170
+ x.textAlign = 'center';
8171
+ x.textBaseline = 'top';
8172
+ rect(11,Y+1,59,8*p,-1);
8173
+ x.beginPath();
8174
+
8175
+ let w2 = 0;
8176
+ for (let i=0;i<s.length;++i)
8177
+ w2 += x.measureText(s[i]).width;
8178
+ for (let j=2;j--;)
8179
+ for (let i=0,X=40-w2/2;i<s.length;++i)
8180
+ {
8181
+ const w = x.measureText(s[i]).width, X2 = X+w/2;
8182
+ gradient(X2,Y,X2+2,Y+13,i>5?1:0);
8183
+ x[j?'strokeText':'fillText'](s[i],X2,Y+.5,17*p);
8184
+ X += w;
8185
+ }
8186
+
8187
+ x.lineWidth = .1 + p*1.9;
8188
+ rect(3,Y,73,0); // bottom
8189
+ }
8190
+
8191
+ rect(7,15,26,-7,0); // cab top
8192
+ rect(25,15,8,25,-1); // cab front
8193
+ rect(10,40,15,-25,1); // cab back
8194
+ rect(14,21,7,9,2); // cab window
8195
+ rect(38,20,6,-6,2); // little stack
8196
+
8197
+ // big stack
8198
+ rect(49,20,10,-6,0);
8199
+ const stackPoints = [vec2(44,8),vec2(64,8),vec2(59,8+6*p),vec2(49,8+6*p)];
8200
+ poly(stackPoints,2,8,6*p);
8201
+ rect(44,8,20,-7,0);
8202
+
8203
+ // engine
8204
+ for (let i=5;i--;) circle(59-i*6*p,30,10,0,2*PI,1,0);
8205
+ circle(59,30,4,0,7,2); // light
8206
+
8207
+ // engine outline
8208
+ rect(35,20,24,0); // top
8209
+ circle(59,30,10); // front
8210
+ circle(47,30,10,PI/2,PI*3/2); // middle
8211
+ circle(35,30,10,PI/2,PI*3/2); // back
8212
+ rect(7,40,13,7,-1); // bottom back
8213
+ rect(17,40,43,14,-1); // bottom center
8214
+
8215
+ // wheels
8216
+ for (let i=3;i--;) for (let j=2;j--;) circle(17+15*i,47,j?7:1,0,2*PI,2);
8217
+
8218
+ // cowcatcher
8219
+ for (let i=2;i--;)
8220
+ {
8221
+ let w=6, s=7, o=53+w*p*i
8222
+ const points = [vec2(o+s,54),vec2(o,40),vec2(o+w*p,40),vec2(o+s+w*p,54)];
8223
+ poly(points,0,40,14);
8224
+ }
8225
+
8226
+ x.restore();
8227
+ }
8228
+
8229
+ /**
8230
+ * LittleJS Medal System
8231
+ * - Achievement/trophy system for games
8232
+ * - Medal class with name, description, icon, and unlock tracking
8233
+ * - Automatic saving to local storage
8234
+ * - Visual display queue with slide-in notifications
8235
+ * - Newgrounds API integration for online achievements
8236
+ * - Debug mode to unlock/reset medals during development
8237
+ * @namespace Medals
8238
+ */
8239
+
8240
+ let debugMedals = false;
8241
+
8242
+ ///////////////////////////////////////////////////////////////////////////////
8243
+ // Medals settings
8244
+
8245
+ /** How long to show medals for in seconds
8246
+ * @type {number}
8247
+ * @default
8248
+ * @memberof Settings */
8249
+ let medalDisplayTime = 5;
8250
+
8251
+ /** How quickly to slide on/off medals in seconds
8252
+ * @type {number}
8253
+ * @default
8254
+ * @memberof Settings */
8255
+ let medalDisplaySlideTime = .5;
8256
+
8257
+ /** Size of medal display
8258
+ * @type {Vector2}
8259
+ * @default Vector2(640,80)
8260
+ * @memberof Settings */
8261
+ let medalDisplaySize = vec2(640, 80);
8262
+
8263
+ /** Set to stop medals from being unlockable (like if cheats are enabled)
8264
+ * @type {boolean}
8265
+ * @default
8266
+ * @memberof Settings */
8267
+ let medalsPreventUnlock = false;
8268
+
8269
+ /** List of all medals
8270
+ * @type {Object}
8271
+ * @memberof Medals */
8272
+ const medals = {};
8273
+
8274
+ // Engine internal variables not exposed to documentation
8275
+ let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast;
8276
+
8277
+ ///////////////////////////////////////////////////////////////////////////////
8278
+
8279
+ /** Initialize medals with a save name used for storage
8280
+ * - Call this after creating all medals
8281
+ * - Checks if medals are unlocked
8282
+ * @param {string} saveName
8283
+ * @memberof Medals */
8284
+ function medalsInit(saveName)
8285
+ {
8286
+ // check if medals are unlocked
8287
+ medalsSaveName = saveName;
8288
+ if (!debugMedals)
8289
+ medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
8290
+
8291
+ // engine automatically renders medals
8292
+ engineAddPlugin(undefined, medalsRender);
8293
+
8294
+ // plugin functions
8295
+ function medalsRender()
8296
+ {
8297
+ if (!medalsDisplayQueue.length) return;
8298
+
8299
+ // update first medal in queue
8300
+ const medal = medalsDisplayQueue[0];
8301
+ const time = timeReal - medalsDisplayTimeLast;
8302
+ if (!medalsDisplayTimeLast)
8303
+ medalsDisplayTimeLast = timeReal;
8304
+ else if (time > medalDisplayTime)
8305
+ {
8306
+ medalsDisplayTimeLast = 0;
8307
+ medalsDisplayQueue.shift();
8308
+ }
8309
+ else
8310
+ {
8311
+ // slide on/off medals
8312
+ const slideOffTime = medalDisplayTime - medalDisplaySlideTime;
8313
+ const hidePercent =
8314
+ time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime :
8315
+ time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0;
8316
+ medal.render(hidePercent);
8317
+ }
8318
+ }
8319
+ }
8320
+
8321
+ /**
8322
+ * @callback MedalCallbackFunction - Function that processes a medal
8323
+ * @param {Medal} medal
8324
+ * @memberof Medals
8325
+ */
8326
+
8327
+ /** Calls a function for each medal
8328
+ * @param {MedalCallbackFunction} callback
8329
+ * @memberof Medals */
8330
+ function medalsForEach(callback)
8331
+ { Object.values(medals).forEach(medal=>callback(medal)); }
8332
+
8333
+ ///////////////////////////////////////////////////////////////////////////////
8334
+
8335
+ /**
8336
+ * Medal - Tracks an unlockable medal
8337
+ * @memberof Medals
8338
+ * @example
8339
+ * // create a medal
8340
+ * const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
8341
+ *
8342
+ * // initialize medals
8343
+ * medalsInit('Example Game');
8344
+ *
8345
+ * // unlock the medal
8346
+ * medal_example.unlock();
8347
+ */
8348
+ class Medal
8349
+ {
8350
+ /** Create a medal object and adds it to the list of medals
8351
+ * @param {number} id - The unique identifier of the medal
8352
+ * @param {string} name - Name of the medal
8353
+ * @param {string} [description] - Description of the medal
8354
+ * @param {string} [icon] - Icon for the medal
8355
+ * @param {string} [src] - Image location for the medal
8356
+ */
8357
+ constructor(id, name, description='', icon='🏆', src)
8358
+ {
8359
+ ASSERT(id >= 0 && !medals[id]);
8360
+
8361
+ /** @property {number} - The unique identifier of the medal */
8362
+ this.id = id;
8363
+
8364
+ /** @property {string} - Name of the medal */
8365
+ this.name = name;
8366
+
8367
+ /** @property {string} - Description of the medal */
8368
+ this.description = description;
8369
+
8370
+ /** @property {string} - Icon for the medal */
8371
+ this.icon = icon;
8372
+
8373
+ /** @property {boolean} - Is the medal unlocked? */
8374
+ this.unlocked = false;
8375
+
8376
+ // load the source image if provided
8377
+ if (src)
8378
+ (this.image = new Image).src = src;
8379
+
8380
+ // add this to list of medals
8381
+ medals[id] = this;
8382
+ }
8383
+
8384
+ /** Unlocks a medal if not already unlocked */
8385
+ unlock()
8386
+ {
8387
+ if (medalsPreventUnlock || this.unlocked) return;
8388
+
8389
+ // save the medal
8390
+ ASSERT(medalsSaveName, 'save name must be set');
8391
+ localStorage[this.storageKey()] = this.unlocked = true;
8392
+ medalsDisplayQueue.push(this);
8393
+ }
8394
+
8395
+ /** Render a medal
8396
+ * @param {number} [hidePercent] - How much to slide the medal off screen
8397
+ */
8398
+ render(hidePercent=0)
8399
+ {
8400
+ const context = mainContext;
8401
+ const width = min(medalDisplaySize.x, mainCanvas.width);
8402
+ const height = medalDisplaySize.y;
8403
+ const x = mainCanvas.width - width;
8404
+ const y = -height*hidePercent;
8405
+ const backgroundColor = hsl(0,0,.9);
8406
+
8407
+ // draw containing rect and clip to that region
8408
+ context.save();
8409
+ context.beginPath();
8410
+ context.fillStyle = backgroundColor.toString();
8411
+ context.strokeStyle = BLACK.toString();
8412
+ context.lineWidth = 3;
8413
+ context.rect(x, y, width, height);
8414
+ context.fill();
8415
+ context.stroke();
8416
+ context.clip();
8417
+
8418
+ // draw the icon
8419
+ const gap = vec2(.1, .05).scale(height);
8420
+ const medalDisplayIconSize = height - 2*gap.x;
8421
+ this.renderIcon(vec2(x + gap.x + medalDisplayIconSize/2, y + height/2), medalDisplayIconSize);
8422
+
8423
+ // draw the name
8424
+ const nameSize = height*.5;
8425
+ const descriptionSize = height*.3;
8426
+ const pos = vec2(x + medalDisplayIconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
8427
+ const textWidth = width - medalDisplayIconSize - 3*gap.x;
8428
+ drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
8429
+
8430
+ // draw the description
8431
+ pos.y = y + height - gap.y*2 - descriptionSize/2;
8432
+ drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
8433
+ context.restore();
8434
+ }
8435
+
8436
+ /** Render the icon for a medal
8437
+ * @param {Vector2} pos - Screen space position
8438
+ * @param {number} size - Screen space size
8439
+ */
8440
+ renderIcon(pos, size)
8441
+ {
8442
+ // draw the image or icon
8443
+ if (this.image)
8444
+ mainContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size);
8445
+ else
8446
+ drawTextScreen(this.icon, pos, size*.7, BLACK);
8447
+ }
8448
+
8449
+ // Get local storage key used by the medal
8450
+ storageKey() { return medalsSaveName + '_' + this.id; }
8451
+ }
8452
+
8453
+ ///////////////////////////////////////////////////////////////////////////////
8454
+ // Medals setting setters
8455
+
8456
+ /** Set how long to show medals for in seconds
8457
+ * @param {number} time
8458
+ * @memberof Settings */
8459
+ function setMedalDisplayTime(time) { medalDisplayTime = time; }
8460
+
8461
+ /** Set how quickly to slide on/off medals in seconds
8462
+ * @param {number} time
8463
+ * @memberof Settings */
8464
+ function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
8465
+
8466
+ /** Set size of medal display
8467
+ * @param {Vector2} size
8468
+ * @memberof Settings */
8469
+ function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
8470
+
8471
+ /** Set to stop medals from being unlockable
8472
+ * @param {boolean} preventUnlock
8473
+ * @memberof Settings */
8474
+ function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }
8475
+
8396
8476
  /**
8397
8477
  * LittleJS Newgrounds Plugin
8398
8478
  * - NewgroundsMedal extends Medal with Newgrounds API functionality
@@ -9009,22 +9089,38 @@ class UISystemPlugin
9009
9089
  this.lastHoverObject = undefined;
9010
9090
  /** @property {UIObject} - Current confirm menu being shown */
9011
9091
  this.confirmDialog = undefined;
9012
- /** @property {UIObject} - Object to send keyboard input to */
9013
- this.keyInputObject = undefined;
9092
+ /** @private */
9093
+ this._keyInputObject = undefined;
9094
+ /** @private */
9095
+ this._onKeyDown = (e) => this._keyInputObject?.onKeyDown(e);
9014
9096
 
9015
9097
  engineAddPlugin(uiUpdate, uiRender);
9016
9098
 
9017
- // key down handler
9018
- function onKeyDown(e)
9019
- { uiSystem.keyInputObject?.onKeyDown(e); }
9020
- document.addEventListener('keydown', onKeyDown);
9021
-
9022
- // set object position in parent space
9099
+ // set object position based on anchor target (parent box, or canvas for roots),
9100
+ // self-pivot, and localPos offset
9023
9101
  function updateTransforms(o)
9024
9102
  {
9025
- if (!o.parent) return;
9026
- o.pos.x = o.localPos.x + o.parent.pos.x;
9027
- o.pos.y = o.localPos.y + o.parent.pos.y;
9103
+ let targetPos, targetSize;
9104
+ if (o.parent)
9105
+ {
9106
+ targetPos = o.parent.pos;
9107
+ targetSize = o.parent.size;
9108
+ }
9109
+ else
9110
+ {
9111
+ // anchor to canvas in native coords (handles nativeHeight if set)
9112
+ targetPos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
9113
+ targetSize = uiSystem.nativeHeight
9114
+ ? vec2(mainCanvasSize.x * uiSystem.nativeHeight / mainCanvasSize.y,
9115
+ uiSystem.nativeHeight)
9116
+ : mainCanvasSize;
9117
+ }
9118
+
9119
+ const a = o.anchor;
9120
+ o.pos = targetPos
9121
+ .add(targetSize.multiply(a).scale(.5)) // anchor point on target
9122
+ .subtract(o.size.multiply(a).scale(.5)) // pivot shift on self
9123
+ .add(o.localPos); // user offset
9028
9124
  }
9029
9125
 
9030
9126
  // setup recursive update and render
@@ -9360,8 +9456,23 @@ class UISystemPlugin
9360
9456
  return p;
9361
9457
  }
9362
9458
 
9459
+ /** Object to send keyboard input to (typically a UITextInput).
9460
+ * The document keydown listener is only attached while this is set,
9461
+ * so games that never use text input pay no event-handling cost.
9462
+ * @type {UIObject} */
9463
+ get keyInputObject() { return this._keyInputObject; }
9464
+ set keyInputObject(obj)
9465
+ {
9466
+ const had = !!this._keyInputObject;
9467
+ this._keyInputObject = obj;
9468
+ if (!had && obj)
9469
+ document.addEventListener('keydown', this._onKeyDown);
9470
+ else if (had && !obj)
9471
+ document.removeEventListener('keydown', this._onKeyDown);
9472
+ }
9473
+
9363
9474
  /** Destroy and remove all objects
9364
- * @memberof Engine */
9475
+ * @memberof UISystem */
9365
9476
  destroyObjects()
9366
9477
  {
9367
9478
  for (const o of this.uiObjects)
@@ -9476,9 +9587,8 @@ class UISystemPlugin
9476
9587
  // confirm menu
9477
9588
  const confirmMenu = new UIObject(vec2(), size);
9478
9589
  uiSystem.confirmDialog = confirmMenu;
9479
- confirmMenu.onRender = ()=>
9590
+ confirmMenu.onRender = ()=>
9480
9591
  {
9481
- confirmMenu.pos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
9482
9592
  const backgroundColor = hsl(0,0,0,.7);
9483
9593
  uiSystem.drawRect(vec2(), vec2(1e9), backgroundColor);
9484
9594
  }
@@ -9611,7 +9721,11 @@ class UIObject
9611
9721
  this.navigationIndex = undefined;
9612
9722
  /** @property {boolean} - Should this be auto selected by navigation? Must also have valid navigation index. */
9613
9723
  this.navigationAutoSelect = false;
9614
-
9724
+ /** @property {Vector2} - Where on parent (or canvas if no parent) this object is anchored.
9725
+ * Components in [-1, 1]: (0,0)=center, (-1,-1)=top-left, (1,1)=bottom-right.
9726
+ * Also acts as self-pivot — e.g. (1,-1) puts your top-right corner at the anchor point. */
9727
+ this.anchor = vec2();
9728
+
9615
9729
  uiSystem.uiObjects.push(this);
9616
9730
  }
9617
9731
 
@@ -9784,15 +9898,13 @@ class UIObject
9784
9898
  * @return {string} */
9785
9899
  toString()
9786
9900
  {
9787
- if (!debug) return;
9788
-
9789
9901
  let text = 'type = ' + this.constructor.name;
9790
9902
  if (this.text)
9791
9903
  text += '\ntext = ' + this.text;
9792
9904
  if (this.pos.x || this.pos.y)
9793
9905
  text += '\npos = ' + this.pos;
9794
9906
  if (this.localPos.x || this.localPos.y)
9795
- text += '\localPos = ' + this.localPos;
9907
+ text += '\nlocalPos = ' + this.localPos;
9796
9908
  if (this.size.x || this.size.y)
9797
9909
  text += '\nsize = ' + this.size;
9798
9910
  if (this.color)
@@ -9866,9 +9978,9 @@ class UIText extends UIObject
9866
9978
  {
9867
9979
  super(pos, size);
9868
9980
 
9869
- ASSERT(isString(text), 'ui text must be a string');
9981
+ ASSERT(isStringLike(text), 'ui text must be a string');
9870
9982
  ASSERT(['left','center','right'].includes(align), 'ui text align must be left, center, or right');
9871
- ASSERT(isString(font), 'ui text font must be a string');
9983
+ ASSERT(isStringLike(font), 'ui text font must be a string');
9872
9984
 
9873
9985
  // set properties
9874
9986
  this.text = text;
@@ -9916,7 +10028,7 @@ class UITextInput extends UIObject
9916
10028
  {
9917
10029
  super(pos, size);
9918
10030
 
9919
- ASSERT(isString(text), 'ui text must be a string');
10031
+ ASSERT(isStringLike(text), 'ui text must be a string');
9920
10032
 
9921
10033
  /** @property {number} - Max length of input (0 = no limit) */
9922
10034
  this.maxLength = 0;
@@ -10053,7 +10165,7 @@ class UIButton extends UIObject
10053
10165
  {
10054
10166
  super(pos, size);
10055
10167
 
10056
- ASSERT(isString(text), 'ui button must be a string');
10168
+ ASSERT(isStringLike(text), 'ui button must be a string');
10057
10169
  ASSERT(isColor(color), 'ui button color must be a color');
10058
10170
 
10059
10171
  /** @property {Vector2} - Text offset for the button */
@@ -10094,7 +10206,7 @@ class UICheckbox extends UIObject
10094
10206
  {
10095
10207
  super(pos, size);
10096
10208
 
10097
- ASSERT(isString(text), 'ui checkbox must be a string');
10209
+ ASSERT(isStringLike(text), 'ui checkbox must be a string');
10098
10210
  ASSERT(isColor(color), 'ui checkbox color must be a color');
10099
10211
 
10100
10212
  /** @property {boolean} - Current percentage value of this slider 0-1 */
@@ -10151,7 +10263,7 @@ class UISlider extends UIObject
10151
10263
  super(pos, size);
10152
10264
 
10153
10265
  ASSERT(isNumber(value), 'ui slider value must be a number');
10154
- ASSERT(isString(text), 'ui slider must be a string');
10266
+ ASSERT(isStringLike(text), 'ui slider must be a string');
10155
10267
  ASSERT(isColor(color), 'ui slider color must be a color');
10156
10268
  ASSERT(isColor(handleColor), 'ui slider handleColor must be a color');
10157
10269
 
@@ -10269,7 +10381,7 @@ class UIVideo extends UIObject
10269
10381
  {
10270
10382
  super(pos, size || vec2());
10271
10383
 
10272
- ASSERT(isString(src), 'video src must be a string');
10384
+ ASSERT(isStringLike(src), 'video src must be a string');
10273
10385
  ASSERT(isNumber(volume), 'video volume must be a number');
10274
10386
 
10275
10387
  this.color = BLACK; // default to black background
@@ -10386,6 +10498,135 @@ class UIVideo extends UIObject
10386
10498
  this.video.remove();
10387
10499
  super.destroy();
10388
10500
  }
10501
+ }
10502
+
10503
+ ///////////////////////////////////////////////////////////////////////////////
10504
+ /**
10505
+ * UILayout - A container that auto-arranges children in a vertical list, horizontal list, or grid
10506
+ * - Set columns to 1 for a vertical list (default)
10507
+ * - Set columns to children.length for a horizontal list
10508
+ * - Set columns to N (1 < N < children.length) for a grid with N columns
10509
+ * - 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
10510
+ * - Children are positioned centered in their cell
10511
+ * - Container auto-sizes to fit children plus padding
10512
+ * @extends UIObject
10513
+ * @memberof UISystem
10514
+ */
10515
+ class UILayout extends UIObject
10516
+ {
10517
+ /** Create a UILayout container that auto-arranges children
10518
+ * @param {Vector2} [pos]
10519
+ * @param {number} [columns=1] - Number of columns (1 = vertical list)
10520
+ * @param {number} [gap=10] - Space between children
10521
+ * @param {number} [padding=10] - Space between container border and children
10522
+ * @param {boolean} [transparent=false] - If true, draws no background, outline, or shadow
10523
+ */
10524
+ constructor(pos, columns=1, gap=10, padding=10, transparent=false)
10525
+ {
10526
+ super(pos);
10527
+
10528
+ ASSERT(isNumber(columns) && columns >= 1, 'ui layout columns must be a number >= 1');
10529
+ ASSERT(isNumber(gap), 'ui layout gap must be a number');
10530
+ ASSERT(isNumber(padding), 'ui layout padding must be a number');
10531
+
10532
+ /** @property {number} - Number of columns in the layout */
10533
+ this.columns = columns;
10534
+ /** @property {number} - Space between children */
10535
+ this.gap = gap;
10536
+ /** @property {number} - Space between container border and children */
10537
+ this.padding = padding;
10538
+
10539
+ if (transparent)
10540
+ {
10541
+ // pure positioning helper - skip background, outline, and shadow
10542
+ this.color = CLEAR_BLACK;
10543
+ this.gradientColor = undefined;
10544
+ this.lineWidth = 0;
10545
+ this.shadowColor = CLEAR_BLACK;
10546
+ }
10547
+ this.relayout();
10548
+ }
10549
+
10550
+ /** Add a child UIObject and re-layout
10551
+ * @param {UIObject} child
10552
+ * @return {UIObject} The child object added */
10553
+ addChild(child)
10554
+ {
10555
+ super.addChild(child);
10556
+ this.relayout();
10557
+ return child;
10558
+ }
10559
+
10560
+ /** Remove a child UIObject and re-layout
10561
+ * @param {UIObject} child */
10562
+ removeChild(child)
10563
+ {
10564
+ super.removeChild(child);
10565
+ this.relayout();
10566
+ }
10567
+
10568
+ /** Recompute child positions and container size based on per-child sizes.
10569
+ * Called automatically by addChild and removeChild. Call manually if you
10570
+ * mutate a child's size or change columns, gap, or padding. */
10571
+ relayout()
10572
+ {
10573
+ const n = this.children.length;
10574
+ if (!n)
10575
+ {
10576
+ this.size = vec2(this.padding * 2);
10577
+ return;
10578
+ }
10579
+
10580
+ const cols = this.columns;
10581
+ const rows = ceil(n / cols);
10582
+ const colWidths = new Array(cols).fill(0);
10583
+ const rowHeights = new Array(rows).fill(0);
10584
+
10585
+ // first pass: compute column widths and row heights from child sizes
10586
+ for (let i = 0; i < n; ++i)
10587
+ {
10588
+ const col = i % cols;
10589
+ const row = floor(i / cols);
10590
+ const child = this.children[i];
10591
+ colWidths[col] = max(colWidths[col], child.size.x);
10592
+ rowHeights[row] = max(rowHeights[row], child.size.y);
10593
+ }
10594
+
10595
+ // total content size (sum of column widths/row heights plus gaps between them)
10596
+ let contentWidth = this.gap * (cols - 1);
10597
+ for (const w of colWidths) contentWidth += w;
10598
+ let contentHeight = this.gap * (rows - 1);
10599
+ for (const h of rowHeights) contentHeight += h;
10600
+
10601
+ // cumulative column/row offsets so positioning is O(n) not O(n^2)
10602
+ const colOffsets = new Array(cols);
10603
+ let xAcc = 0;
10604
+ for (let c = 0; c < cols; ++c)
10605
+ {
10606
+ colOffsets[c] = xAcc;
10607
+ xAcc += colWidths[c];
10608
+ }
10609
+ const rowOffsets = new Array(rows);
10610
+ let yAcc = 0;
10611
+ for (let r = 0; r < rows; ++r)
10612
+ {
10613
+ rowOffsets[r] = yAcc;
10614
+ yAcc += rowHeights[r];
10615
+ }
10616
+
10617
+ // second pass: position each child centered in its cell
10618
+ for (let i = 0; i < n; ++i)
10619
+ {
10620
+ const col = i % cols;
10621
+ const row = floor(i / cols);
10622
+ const x = -contentWidth/2 + colOffsets[col] + this.gap * col + colWidths[col] / 2;
10623
+ const y = -contentHeight/2 + rowOffsets[row] + this.gap * row + rowHeights[row] / 2;
10624
+ this.children[i].localPos = vec2(x, y);
10625
+ }
10626
+
10627
+ // container size = content + padding on all sides
10628
+ this.size = vec2(contentWidth + this.padding * 2, contentHeight + this.padding * 2);
10629
+ }
10389
10630
  }
10390
10631
  /**
10391
10632
  * LittleJS Box2D Physics Plugin
@@ -10912,14 +11153,29 @@ class Box2dObject extends EngineObject
10912
11153
  this.body.ApplyForce(box2d.vec2dTo(force), box2d.vec2dTo(pos));
10913
11154
  }
10914
11155
 
10915
- /** Apply acceleration to this object
11156
+ /** Apply acceleration to this object (changes velocity by acceleration,
11157
+ * mass-independent — matches EngineObject.applyAcceleration semantics).
11158
+ * Use applyImpulse if you want the mass-dependent velocity change
11159
+ * Δv = impulse / mass, or applyForce for a Newton-style sustained force.
10916
11160
  * @param {Vector2} acceleration
10917
11161
  * @param {Vector2} [pos] */
10918
11162
  applyAcceleration(acceleration, pos)
10919
- {
11163
+ {
10920
11164
  pos ||= this.getCenterOfMass();
10921
11165
  this.setAwake();
10922
- this.body.ApplyLinearImpulse(box2d.vec2dTo(acceleration), box2d.vec2dTo(pos));
11166
+ const impulse = acceleration.scale(this.getMass());
11167
+ this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
11168
+ }
11169
+
11170
+ /** Apply an instantaneous linear impulse. Changes velocity immediately by
11171
+ * impulse / mass (so heavier bodies move less for the same impulse).
11172
+ * @param {Vector2} impulse
11173
+ * @param {Vector2} [pos] */
11174
+ applyImpulse(impulse, pos)
11175
+ {
11176
+ pos ||= this.getCenterOfMass();
11177
+ this.setAwake();
11178
+ this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
10923
11179
  }
10924
11180
 
10925
11181
  /** Apply torque to this object
@@ -10929,13 +11185,23 @@ class Box2dObject extends EngineObject
10929
11185
  this.setAwake();
10930
11186
  this.body.ApplyTorque(torque);
10931
11187
  }
10932
-
10933
- /** Apply angular acceleration to this object
11188
+
11189
+ /** Apply angular acceleration to this object (changes angular velocity by
11190
+ * acceleration, mass-independent — matches EngineObject semantics).
10934
11191
  * @param {number} acceleration */
10935
11192
  applyAngularAcceleration(acceleration)
10936
11193
  {
10937
11194
  this.setAwake();
10938
- this.body.ApplyAngularImpulse(acceleration);
11195
+ this.body.ApplyAngularImpulse(acceleration * this.getInertia());
11196
+ }
11197
+
11198
+ /** Apply an instantaneous angular impulse. Changes angular velocity by
11199
+ * impulse / inertia immediately.
11200
+ * @param {number} impulse */
11201
+ applyAngularImpulse(impulse)
11202
+ {
11203
+ this.setAwake();
11204
+ this.body.ApplyAngularImpulse(impulse);
10939
11205
  }
10940
11206
 
10941
11207
  ///////////////////////////////////////////////////////////////////////////////
@@ -11181,7 +11447,7 @@ class Box2dJoint
11181
11447
 
11182
11448
  /** Check if the connected bodies should collide
11183
11449
  * @return {boolean} */
11184
- getCollideConnected() { return this.box2dJoint.getCollideConnected();}
11450
+ getCollideConnected() { return this.box2dJoint.GetCollideConnected();}
11185
11451
 
11186
11452
  /** Check if either connected body is active
11187
11453
  * @return {boolean} */
@@ -12794,13 +13060,13 @@ const Ease =
12794
13060
  * @param {number} x
12795
13061
  * @returns {number}
12796
13062
  * @memberof TweenSystem */
12797
- SINE: (x) => 1 - Math.cos(x * (Math.PI / 2)),
13063
+ SINE: (x) => 1 - cos(x * (PI / 2)),
12798
13064
 
12799
13065
  /** Circular ease-in curve.
12800
13066
  * @param {number} x
12801
13067
  * @returns {number}
12802
13068
  * @memberof TweenSystem */
12803
- CIRC: (x) => 1 - Math.sqrt(1 - x * x),
13069
+ CIRC: (x) => 1 - (1 - x * x)**.5,
12804
13070
 
12805
13071
  /** Exponential ease-in curve (`2^(10x-10)`).
12806
13072
  * @param {number} x
@@ -12819,7 +13085,7 @@ const Ease =
12819
13085
  * @returns {number}
12820
13086
  * @memberof TweenSystem */
12821
13087
  ELASTIC: (x) =>
12822
- -(2 ** (10 * x - 10)) * Math.sin(((37 - 40 * x) * Math.PI) / 6),
13088
+ -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
12823
13089
 
12824
13090
  /** Spring-like ease-out: oscillates outward after passing the target.
12825
13091
  * @param {number} x
@@ -12827,8 +13093,8 @@ const Ease =
12827
13093
  * @memberof TweenSystem */
12828
13094
  SPRING: (x) =>
12829
13095
  1 -
12830
- (Math.sin(Math.PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
12831
- Math.pow(x, 2.2) +
13096
+ (sin(PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
13097
+ x ** 2.2 +
12832
13098
  (1 - x)) *
12833
13099
  (1.0 + 1.2 * x),
12834
13100
 
@@ -12932,7 +13198,7 @@ const Ease =
12932
13198
  {
12933
13199
  const tMid = (t0 + t1) / 2;
12934
13200
  const [bx, by] = curve(tMid);
12935
- if (Math.abs(bx - x) < 1e-5) return by;
13201
+ if (abs(bx - x) < 1e-5) return by;
12936
13202
  if (bx < x) t0 = tMid; else t1 = tMid;
12937
13203
  }
12938
13204
  return curve((t0 + t1) / 2)[1];
@@ -12965,7 +13231,7 @@ const Ease =
12965
13231
  function tweenProperty(target, propertyPath, start, end, duration = 1, options = {})
12966
13232
  {
12967
13233
  ASSERT(target != null && typeof target === 'object', 'tweenProperty target must be an object');
12968
- ASSERT(isString(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string');
13234
+ ASSERT(isStringLike(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string');
12969
13235
 
12970
13236
  const parts = propertyPath.split('.');
12971
13237
  const lastKey = parts.pop();
@@ -13064,3 +13330,760 @@ function tweenStopAll()
13064
13330
  // Register with the engine so tweens auto-advance.
13065
13331
  engineAddPlugin(tweenUpdate);
13066
13332
 
13333
+ /**
13334
+ * LittleJS PathFinder Plugin
13335
+ * - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths
13336
+ * - Works directly on a TileCollisionLayer, or override isWalkable/getCost for any grid
13337
+ * - Debug visualization via engine debug primitives (stripped in release builds)
13338
+ * - Port of frankforce.com pathFindingBase.cpp (2018)
13339
+ * @namespace PathFinding
13340
+ */
13341
+
13342
+ ///////////////////////////////////////////////////////////////////////////////
13343
+
13344
+ // Diagonal step cost — pre-computed for the A* expansion inner loop.
13345
+ const PATHFINDER_DIAGONAL_COST = Math.SQRT2;
13346
+
13347
+ // Shared 1x1 size vector for per-tile debugRect calls. debugRect copies the
13348
+ // argument internally, so reusing one instance is safe.
13349
+ const PATHFINDER_TILE_VEC = vec2(1);
13350
+
13351
+ ///////////////////////////////////////////////////////////////////////////////
13352
+
13353
+ /** A single grid cell tracked by the pathfinder. Allocated once per cell at
13354
+ * PathFinder construction; reset (not reallocated) at the start of every
13355
+ * findPath call.
13356
+ * @memberof PathFinding */
13357
+ class PathFinderNode
13358
+ {
13359
+ /** @param {number} x - Tile x
13360
+ * @param {number} y - Tile y */
13361
+ constructor(x, y)
13362
+ {
13363
+ /** @property {Vector2} - Tile coords (integer) */
13364
+ this.pos = vec2(x, y);
13365
+ /** @property {Vector2} - World-space center of this tile (set by buildNodeData) */
13366
+ this.posWorld = vec2();
13367
+ /** @property {boolean} - True if this cell is passable (cleared each findPath call) */
13368
+ this.walkable = false;
13369
+ /** @property {number} - Extra cost added to A* G-score for stepping on this cell */
13370
+ this.cost = 0;
13371
+ /** @property {number} - A* G-score: actual cost from start to this node */
13372
+ this.g = 0;
13373
+ /** @property {number} - A* F-score: G + heuristic */
13374
+ this.f = 0;
13375
+ /** @property {PathFinderNode|null} - Parent for path reconstruction */
13376
+ this.parent = null;
13377
+ /** @property {boolean} - In the A* open list */
13378
+ this.isOpen = false;
13379
+ /** @property {boolean} - In the A* closed list */
13380
+ this.isClosed = false;
13381
+ }
13382
+
13383
+ /** Reset per-search state (called at the start of buildNodeData). */
13384
+ reset()
13385
+ {
13386
+ this.walkable = false;
13387
+ this.cost = 0;
13388
+ this.g = 0;
13389
+ this.f = 0;
13390
+ this.parent = null;
13391
+ this.isOpen = false;
13392
+ this.isClosed = false;
13393
+ }
13394
+
13395
+ /** True if walkable and not blocked by cost. */
13396
+ isClear()
13397
+ {
13398
+ return this.walkable && this.cost === 0;
13399
+ }
13400
+ }
13401
+
13402
+ ///////////////////////////////////////////////////////////////////////////////
13403
+
13404
+ /** Grid pathfinder using A* with two optional smoothing passes.
13405
+ * @memberof PathFinding
13406
+ * @example
13407
+ * // Tile-layer driven (most common):
13408
+ * const pf = new PathFinder(myTileCollisionLayer);
13409
+ * const path = pf.findPath(player.pos, mousePos);
13410
+ *
13411
+ * // Bare grid with custom walkability:
13412
+ * const pf = new PathFinder(vec2(50, 50));
13413
+ * pf.isWalkable = (x, y) => myGrid[y*50 + x] === 0;
13414
+ */
13415
+ class PathFinder
13416
+ {
13417
+ /** @param {TileCollisionLayer|Vector2} source - Either a TileCollisionLayer
13418
+ * (size and walkability auto-derived) or a Vector2 grid size (user
13419
+ * overrides isWalkable). */
13420
+ constructor(source)
13421
+ {
13422
+ // Accept either a Vector2 size or a TileCollisionLayer (which has a .size).
13423
+ // We don't import TileCollisionLayer to avoid coupling; we duck-type on
13424
+ // .size + .getCollisionData.
13425
+ if (isVector2(source))
13426
+ {
13427
+ this.size = source.floor();
13428
+ this.tileLayer = undefined;
13429
+ }
13430
+ else
13431
+ {
13432
+ ASSERT(source && isVector2(source.size) && typeof source.getCollisionData === 'function',
13433
+ 'PathFinder requires a Vector2 size or a TileCollisionLayer');
13434
+ this.size = source.size;
13435
+ this.tileLayer = source;
13436
+ }
13437
+
13438
+ // Tunables (public, freely re-assignable).
13439
+ this.heuristicWeight = 1;
13440
+ this.maxLoop = 500;
13441
+ this.smoothPath = true;
13442
+ this.debug = false;
13443
+ this.debugTime = 2;
13444
+
13445
+ // Pre-allocate the node array — one node per tile, reused across calls.
13446
+ this.nodes = new Array(this.size.x * this.size.y);
13447
+ for (let y = 0; y < this.size.y; ++y)
13448
+ for (let x = 0; x < this.size.x; ++x)
13449
+ this.nodes[x + y * this.size.x] = new PathFinderNode(x, y);
13450
+
13451
+ // Scratch Vector2 reused to avoid allocations in the isWalkable hot path.
13452
+ this.collisionScratch = vec2();
13453
+ }
13454
+
13455
+ /** Default walkability: if a tile layer was provided, returns true when the
13456
+ * cell has no solid collision data; otherwise returns true. Override on
13457
+ * the instance or via a subclass.
13458
+ * @param {number} x - Tile x
13459
+ * @param {number} y - Tile y
13460
+ * @returns {boolean} */
13461
+ isWalkable(x, y)
13462
+ {
13463
+ if (!this.tileLayer) return true;
13464
+ return !this.tileLayer.getCollisionData(this.collisionScratch.set(x, y));
13465
+ }
13466
+
13467
+ /** Default extra cost for stepping on a cell. Returns 0 (free) by default.
13468
+ * Override to add cost-weighted terrain (mud, swamp, etc).
13469
+ * @param {number} x - Tile x
13470
+ * @param {number} y - Tile y
13471
+ * @returns {number} */
13472
+ getCost(x, y)
13473
+ {
13474
+ return 0;
13475
+ }
13476
+
13477
+ /** Get the node at tile coords, or null if out of bounds.
13478
+ * @param {number} x
13479
+ * @param {number} y
13480
+ * @returns {PathFinderNode|null} */
13481
+ getNode(x, y)
13482
+ {
13483
+ if (x < 0 || y < 0 || x >= this.size.x || y >= this.size.y) return null;
13484
+ return this.nodes[x + y * this.size.x];
13485
+ }
13486
+
13487
+ /** Convert a world-space position to integer tile coords (no clamping).
13488
+ * @param {Vector2} worldPos
13489
+ * @returns {Vector2}
13490
+ * @memberof PathFinding */
13491
+ worldToTile(worldPos)
13492
+ {
13493
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
13494
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
13495
+ return vec2(floor(worldPos.x - ox), floor(worldPos.y - oy));
13496
+ }
13497
+
13498
+ /** Convert integer tile coords to the world-space center of that tile.
13499
+ * @param {number} x
13500
+ * @param {number} y
13501
+ * @returns {Vector2}
13502
+ * @memberof PathFinding */
13503
+ tileToWorld(x, y)
13504
+ {
13505
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
13506
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
13507
+ return vec2(x + 0.5 + ox, y + 0.5 + oy);
13508
+ }
13509
+
13510
+ /** Reset all nodes and re-populate walkable / cost / posWorld from the
13511
+ * current isWalkable / getCost overrides. Called at the start of
13512
+ * findPath; exposed so tests and tooling can drive it directly.
13513
+ * @private */
13514
+ buildNodeData()
13515
+ {
13516
+ const w = this.size.x;
13517
+ const h = this.size.y;
13518
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
13519
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
13520
+ for (let y = 0; y < h; ++y)
13521
+ for (let x = 0; x < w; ++x)
13522
+ {
13523
+ const node = this.nodes[x + y * w];
13524
+ node.reset();
13525
+ const walkable = !!this.isWalkable(x, y);
13526
+ const cost = walkable ? max(0, this.getCost(x, y)) : 0;
13527
+ node.walkable = walkable;
13528
+ node.cost = cost;
13529
+ node.posWorld.set(x + 0.5 + ox, y + 0.5 + oy);
13530
+
13531
+ if (this.debug && this.debugTime > 0)
13532
+ {
13533
+ if (!walkable)
13534
+ debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, 0.25), this.debugTime);
13535
+ else if (cost > 0)
13536
+ debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, min(0.2, cost * 0.05)), this.debugTime);
13537
+ }
13538
+ }
13539
+ }
13540
+
13541
+ /** Core A* search loop. Expects buildNodeData() to have been called first.
13542
+ * Marks node.parent for path reconstruction. Returns true if endNode was
13543
+ * reached; false on disconnected goal or maxLoop exhaustion.
13544
+ * @param {PathFinderNode} startNode
13545
+ * @param {PathFinderNode} endNode
13546
+ * @returns {boolean}
13547
+ * @private */
13548
+ aStarSearch(startNode, endNode)
13549
+ {
13550
+ ASSERT(startNode && endNode, 'aStarSearch needs both endpoints');
13551
+ ASSERT(startNode !== endNode, 'aStarSearch: start and end must differ — caller should handle trivial case');
13552
+ ASSERT(startNode.walkable && endNode.walkable, 'aStarSearch: endpoints must be walkable');
13553
+
13554
+ const openList = [startNode];
13555
+ startNode.isOpen = true;
13556
+ let loopCount = 0;
13557
+
13558
+ while (openList.length > 0)
13559
+ {
13560
+ // Find the open node with the smallest f score (linear scan).
13561
+ // Same as the C++ — fine up to a few thousand nodes.
13562
+ let bestIndex = 0;
13563
+ let bestF = openList[0].f;
13564
+ for (let i = 1; i < openList.length; ++i)
13565
+ {
13566
+ if (openList[i].f < bestF)
13567
+ {
13568
+ bestF = openList[i].f;
13569
+ bestIndex = i;
13570
+ }
13571
+ }
13572
+ const current = openList[bestIndex];
13573
+
13574
+ if (current === endNode) break;
13575
+ if (++loopCount > this.maxLoop) break;
13576
+
13577
+ // Move current from open to closed.
13578
+ current.isOpen = false;
13579
+ openList.splice(bestIndex, 1);
13580
+ current.isClosed = true;
13581
+
13582
+ if (this.debug && this.debugTime > 0)
13583
+ debugRect(current.posWorld, PATHFINDER_TILE_VEC, rgb(1, 1, 1, 0.05), this.debugTime);
13584
+
13585
+ // Expand all 8 neighbors.
13586
+ for (let dy = -1; dy <= 1; ++dy)
13587
+ for (let dx = -1; dx <= 1; ++dx)
13588
+ {
13589
+ if (dx === 0 && dy === 0) continue;
13590
+ const neighbor = this.getNode(current.pos.x + dx, current.pos.y + dy);
13591
+ if (!neighbor || !neighbor.walkable || neighbor.isClosed) continue;
13592
+
13593
+ let stepCost = 1;
13594
+ if (dx !== 0 && dy !== 0)
13595
+ {
13596
+ // Diagonal step: refuse if either cardinal neighbor is
13597
+ // blocked or has cost. Prevents cutting through corners.
13598
+ const card1 = this.getNode(current.pos.x + dx, current.pos.y);
13599
+ if (!card1 || card1.cost > 0 || !card1.walkable) continue;
13600
+ const card2 = this.getNode(current.pos.x, current.pos.y + dy);
13601
+ if (!card2 || card2.cost > 0 || !card2.walkable) continue;
13602
+ stepCost = PATHFINDER_DIAGONAL_COST;
13603
+ }
13604
+
13605
+ const tentativeG = current.g + stepCost + neighbor.cost;
13606
+ if (!neighbor.isOpen)
13607
+ {
13608
+ neighbor.isOpen = true;
13609
+ openList.push(neighbor);
13610
+ }
13611
+ else if (tentativeG >= neighbor.g)
13612
+ {
13613
+ continue;
13614
+ }
13615
+
13616
+ // Best path so far through neighbor — record it.
13617
+ neighbor.parent = current;
13618
+ neighbor.g = tentativeG;
13619
+ const gdx = endNode.pos.x - neighbor.pos.x;
13620
+ const gdy = endNode.pos.y - neighbor.pos.y;
13621
+ neighbor.f = neighbor.g + (gdx * gdx + gdy * gdy) * this.heuristicWeight;
13622
+ }
13623
+ }
13624
+
13625
+ return endNode.parent !== null;
13626
+ }
13627
+
13628
+ /** Find the clear (walkable, zero-cost) node closest to the given world
13629
+ * position. Spirals outward in expanding boxes until a clear node is
13630
+ * found or the search range is exhausted. Useful for snapping a click
13631
+ * or NPC spawn position to the nearest open tile.
13632
+ *
13633
+ * By default, calls `buildNodeData()` first so it works correctly on a
13634
+ * fresh PathFinder. If you're calling it many times in a row with
13635
+ * unchanged walkability, pass `rebuild=false` and call `buildNodeData()`
13636
+ * once externally to avoid redundant work.
13637
+ * @param {Vector2} worldPos
13638
+ * @param {number} [searchRange=10] - Max box-radius in tiles
13639
+ * @param {boolean} [rebuild=true] - Whether to call buildNodeData first
13640
+ * @returns {PathFinderNode|null}
13641
+ * @memberof PathFinding */
13642
+ getNearestClearNode(worldPos, searchRange = 10, rebuild = true)
13643
+ {
13644
+ ASSERT(isVector2(worldPos), 'worldPos must be a Vector2');
13645
+ if (rebuild) this.buildNodeData();
13646
+
13647
+ // Inline worldToTile to avoid a Vector2 allocation per call.
13648
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
13649
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
13650
+ const centerX = floor(worldPos.x - ox);
13651
+ const centerY = floor(worldPos.y - oy);
13652
+
13653
+ for (let offset = 0; offset <= searchRange; ++offset)
13654
+ {
13655
+ let nearest = null;
13656
+ let nearestDistSq = 0;
13657
+
13658
+ for (let dy = -offset; dy <= offset; ++dy)
13659
+ for (let dx = -offset; dx <= offset; ++dx)
13660
+ {
13661
+ // Only scan the perimeter of the current ring (skip the
13662
+ // interior we've already searched in earlier iterations).
13663
+ if (offset > 0 && abs(dx) !== offset && abs(dy) !== offset)
13664
+ continue;
13665
+
13666
+ const node = this.getNode(centerX + dx, centerY + dy);
13667
+ if (!node || !node.isClear()) continue;
13668
+
13669
+ const ddx = node.posWorld.x - worldPos.x;
13670
+ const ddy = node.posWorld.y - worldPos.y;
13671
+ const distSq = ddx * ddx + ddy * ddy;
13672
+ if (!nearest || distSq < nearestDistSq)
13673
+ {
13674
+ nearest = node;
13675
+ nearestDistSq = distSq;
13676
+ }
13677
+ }
13678
+ if (nearest) return nearest;
13679
+ }
13680
+ return null;
13681
+ }
13682
+
13683
+ /** Smooth a node path by removing redundant turns and tightening corners
13684
+ * where a grid-aligned diagonal is clear. Modifies the path in place.
13685
+ * Stays on the grid — does not introduce off-tile-center points.
13686
+ * Port of ShortenPath() in pathFinding.cpp.
13687
+ * @param {PathFinderNode[]} path
13688
+ * @private */
13689
+ smoothPathCorners(path)
13690
+ {
13691
+ if (path.length <= 2) return;
13692
+
13693
+ let i = 1;
13694
+ while (i < path.length - 1)
13695
+ {
13696
+ const prev = path[i - 1];
13697
+ const node = path[i];
13698
+ const next = path[i + 1];
13699
+
13700
+ const dx = next.pos.x - prev.pos.x;
13701
+ const dy = next.pos.y - prev.pos.y;
13702
+ const lenSq = dx * dx + dy * dy;
13703
+
13704
+ // dx,dy is the prev-to-current step direction; needed for the
13705
+ // 135° "mostly vertical/horizontal" disambiguation.
13706
+ const stepDx = node.pos.x - prev.pos.x;
13707
+ const stepDy = node.pos.y - prev.pos.y;
13708
+ const stepDxNext = next.pos.x - node.pos.x;
13709
+ const stepDyNext = next.pos.y - node.pos.y;
13710
+
13711
+ if (lenSq === 1)
13712
+ {
13713
+ // 45° angle — middle node is off the straight line. Drop it.
13714
+ if (this.debug && this.debugTime > 0)
13715
+ debugCircle(node.posWorld, 0.3, rgb(0.5, 0, 0.5, 0.5), this.debugTime);
13716
+ path.splice(i, 1);
13717
+ i = max(1, i - 1);
13718
+ continue;
13719
+ }
13720
+ else if (lenSq === 2)
13721
+ {
13722
+ // 90° corner. Check the alternative-diagonal cell.
13723
+ if (this.debug && this.debugTime > 0)
13724
+ debugCircle(node.posWorld, 0.3, rgb(1, 0, 0, 0.5), this.debugTime);
13725
+
13726
+ let sx, sy;
13727
+ if (prev.pos.y === node.pos.y && next.pos.x === node.pos.x)
13728
+ { sx = prev.pos.x; sy = next.pos.y; }
13729
+ else
13730
+ { sx = next.pos.x; sy = prev.pos.y; }
13731
+
13732
+ const shortcut = this.getNode(sx, sy);
13733
+ if (shortcut && shortcut.isClear())
13734
+ {
13735
+ path.splice(i, 1);
13736
+ i = max(1, i - 1);
13737
+ continue;
13738
+ }
13739
+ }
13740
+ else if (lenSq === 5)
13741
+ {
13742
+ // 135° angle (a knight's-move offset). Try to relocate the
13743
+ // middle node to whichever of two candidate cells is closer
13744
+ // to prev-of-prev, and only if the corner cut is also clear.
13745
+ if (this.debug && this.debugTime > 0)
13746
+ debugCircle(node.posWorld, 0.3, rgb(1, 1, 0, 0.5), this.debugTime);
13747
+
13748
+ const prevPrev = i >= 2 ? path[i - 2] : prev;
13749
+ let s1x, s1y, s2x, s2y;
13750
+ if (stepDx === 0 || stepDxNext === 0)
13751
+ {
13752
+ // mostly vertical
13753
+ s1x = next.pos.x; s1y = node.pos.y;
13754
+ s2x = prev.pos.x; s2y = node.pos.y;
13755
+ }
13756
+ else
13757
+ {
13758
+ // mostly horizontal
13759
+ s1x = node.pos.x; s1y = next.pos.y;
13760
+ s2x = node.pos.x; s2y = prev.pos.y;
13761
+ }
13762
+ const dd1x = s1x - prevPrev.pos.x;
13763
+ const dd1y = s1y - prevPrev.pos.y;
13764
+ const dd2x = s2x - prevPrev.pos.x;
13765
+ const dd2y = s2y - prevPrev.pos.y;
13766
+ const dist1Sq = dd1x * dd1x + dd1y * dd1y;
13767
+ const dist2Sq = dd2x * dd2x + dd2y * dd2y;
13768
+ const sx = dist1Sq < dist2Sq ? s1x : s1x === s2x && s1y === s2y ? s1x : s2x;
13769
+ const sy = dist1Sq < dist2Sq ? s1y : s1x === s2x && s1y === s2y ? s1y : s2y;
13770
+
13771
+ const shortcut = this.getNode(sx, sy);
13772
+ if (shortcut && shortcut !== node && shortcut.isClear())
13773
+ {
13774
+ // Also check the cut-corner cell is clear.
13775
+ const ccx = next.pos.x + s2x - s1x;
13776
+ const ccy = next.pos.y + s2y - s1y;
13777
+ const cutCorner = this.getNode(ccx, ccy);
13778
+ if (cutCorner && cutCorner.isClear())
13779
+ {
13780
+ path[i] = shortcut;
13781
+ i = max(1, i - 1);
13782
+ continue;
13783
+ }
13784
+ }
13785
+ }
13786
+ else if (lenSq === 4 || lenSq === 8)
13787
+ {
13788
+ // Straight line or a 1-cell bump.
13789
+ if (this.debug && this.debugTime > 0)
13790
+ debugCircle(node.posWorld, 0.3, rgb(0, 1, 0, 0.5), this.debugTime);
13791
+
13792
+ if (stepDx === stepDxNext && stepDy === stepDyNext)
13793
+ {
13794
+ // Truly straight — nothing to do, advance.
13795
+ ++i;
13796
+ continue;
13797
+ }
13798
+ else
13799
+ {
13800
+ // Bump — try to flatten via the in-line cell.
13801
+ let sx, sy;
13802
+ if (prev.pos.y === next.pos.y)
13803
+ { sx = node.pos.x; sy = prev.pos.y; }
13804
+ else
13805
+ { sx = prev.pos.x; sy = node.pos.y; }
13806
+ const shortcut = this.getNode(sx, sy);
13807
+ if (shortcut && shortcut.isClear())
13808
+ {
13809
+ path[i] = shortcut;
13810
+ i = max(1, i - 1);
13811
+ continue;
13812
+ }
13813
+ }
13814
+ }
13815
+
13816
+ ++i;
13817
+ }
13818
+ }
13819
+
13820
+ /** Smooth a node path via line-of-sight ("string pulling"). Walks the
13821
+ * input path collapsing runs of nodes into straight segments whenever
13822
+ * isLineClear permits, so the result can leave grid centers and cut
13823
+ * cleanly across open spaces.
13824
+ *
13825
+ * Bails (leaves the path unchanged) if any node has nonzero cost — a
13826
+ * straight geometric shortcut can't be trusted to be the lowest-cost
13827
+ * route when cost-weighted terrain is in play.
13828
+ *
13829
+ * Port of ShortenPath2() in pathFinding.cpp.
13830
+ * @param {PathFinderNode[]} path
13831
+ * @private */
13832
+ smoothPathStringPull(path)
13833
+ {
13834
+ if (path.length <= 2) return;
13835
+ for (const n of path)
13836
+ {
13837
+ if (!n.isClear()) return;
13838
+ }
13839
+
13840
+ const original = path.slice();
13841
+ path.length = 0;
13842
+ path.push(original[0]);
13843
+ let searchIndex = 0;
13844
+
13845
+ for (let i = 1; i < original.length; ++i)
13846
+ {
13847
+ const node = original[i];
13848
+
13849
+ // Skip if node is collinear with the search-window start and the
13850
+ // previous node — it adds no information. Note: a == b is the
13851
+ // degenerate i=1, searchIndex=0 case; skip the test then.
13852
+ {
13853
+ const a = original[searchIndex];
13854
+ const b = original[i - 1];
13855
+ if (a !== b)
13856
+ {
13857
+ const cross =
13858
+ (b.pos.x - a.pos.x) * (node.pos.y - a.pos.y) -
13859
+ (b.pos.y - a.pos.y) * (node.pos.x - a.pos.x);
13860
+ if (cross === 0) continue;
13861
+ }
13862
+ }
13863
+
13864
+ if (!this.isLineClear(node.pos, path[path.length - 1].pos))
13865
+ {
13866
+ // Look ahead — if any later node has a clear shot to the
13867
+ // back of our new path, skip this node and try later.
13868
+ let foundClearAfter = false;
13869
+ for (let j = i + 1; j < original.length; ++j)
13870
+ {
13871
+ if (this.isLineClear(original[j].pos, path[path.length - 1].pos))
13872
+ {
13873
+ foundClearAfter = true;
13874
+ break;
13875
+ }
13876
+ }
13877
+ if (foundClearAfter)
13878
+ {
13879
+ if (this.debug && this.debugTime > 0)
13880
+ debugLine(node.posWorld, path[path.length - 1].posWorld, rgb(0, 0, 1, 0.3), 0.02, this.debugTime);
13881
+ continue;
13882
+ }
13883
+
13884
+ // No clear line ahead — fall back to the last waypoint we did
13885
+ // have a clear line to. searchIndex tracks our scan position.
13886
+ for (; searchIndex < original.length; ++searchIndex)
13887
+ {
13888
+ const cand = original[searchIndex];
13889
+ if (this.isLineClear(node.pos, cand.pos))
13890
+ {
13891
+ path.push(cand);
13892
+ i = searchIndex;
13893
+ break;
13894
+ }
13895
+ }
13896
+ ASSERT(searchIndex < original.length, 'smoothPathStringPull: ran out of candidates');
13897
+ }
13898
+ }
13899
+
13900
+ path.push(original[original.length - 1]);
13901
+ }
13902
+
13903
+ /** Lookup helper: true when the node at tile coords (x, y) is in-bounds
13904
+ * and clear (walkable, zero-cost). Used by isLineClear's hot path.
13905
+ * @param {number} x
13906
+ * @param {number} y
13907
+ * @returns {boolean}
13908
+ * @private */
13909
+ isNodeClear(x, y)
13910
+ {
13911
+ const n = this.getNode(x, y);
13912
+ return n !== null && n.isClear();
13913
+ }
13914
+
13915
+ /** Check that the line between two tile-coord endpoints stays entirely
13916
+ * inside walkable, zero-cost cells. Stricter than just sampling along
13917
+ * the line — it also checks the diagonal-corner-adjacent cells so the
13918
+ * line can never "scrape past" a wall corner.
13919
+ *
13920
+ * Both endpoints must themselves be clear (asserted in debug). Port of
13921
+ * CheckLine() in pathFinding.cpp.
13922
+ * @param {Vector2} startPos - Tile coords
13923
+ * @param {Vector2} endPos - Tile coords
13924
+ * @returns {boolean}
13925
+ * @private */
13926
+ isLineClear(startPos, endPos)
13927
+ {
13928
+ ASSERT(isVector2(startPos) && isVector2(endPos), 'isLineClear needs Vector2 endpoints');
13929
+ ASSERT(this.isNodeClear(startPos.x, startPos.y) && this.isNodeClear(endPos.x, endPos.y),
13930
+ 'isLineClear endpoints must be in-bounds and clear');
13931
+
13932
+ const dx = endPos.x - startPos.x;
13933
+ const dy = endPos.y - startPos.y;
13934
+ const adx = abs(dx);
13935
+ const ady = abs(dy);
13936
+ const sx = sign(dx);
13937
+ const sy = sign(dy);
13938
+ let x = startPos.x;
13939
+ let y = startPos.y;
13940
+
13941
+ if (ady === adx)
13942
+ {
13943
+ // Pure diagonal.
13944
+ while (x !== endPos.x)
13945
+ {
13946
+ if (x !== startPos.x)
13947
+ {
13948
+ if (!this.isNodeClear(x, y)) return false;
13949
+ if (!this.isNodeClear(x, y - sy)) return false;
13950
+ }
13951
+ if (!this.isNodeClear(x, y + sy)) return false;
13952
+ x += sx;
13953
+ y += sy;
13954
+ }
13955
+ if (!this.isNodeClear(endPos.x, endPos.y - sy)) return false;
13956
+ }
13957
+ else if (ady < adx)
13958
+ {
13959
+ // Mostly horizontal.
13960
+ if (dy === 0)
13961
+ {
13962
+ // Purely horizontal.
13963
+ x += sx;
13964
+ while (x !== endPos.x)
13965
+ {
13966
+ if (!this.isNodeClear(x, y)) return false;
13967
+ x += sx;
13968
+ }
13969
+ }
13970
+ else
13971
+ {
13972
+ let lastY = startPos.y;
13973
+ while (x !== endPos.x)
13974
+ {
13975
+ y = startPos.y + Math.trunc((dy * (x - startPos.x)) / dx);
13976
+ if (lastY !== y)
13977
+ {
13978
+ if (!this.isNodeClear(x - sx, y + sy)) return false;
13979
+ if (!this.isNodeClear(x, y - sy)) return false;
13980
+ }
13981
+ lastY = y;
13982
+ if (x !== startPos.x)
13983
+ {
13984
+ if (!this.isNodeClear(x, y)) return false;
13985
+ }
13986
+ y += sy;
13987
+ if (!this.isNodeClear(x, y)) return false;
13988
+ x += sx;
13989
+ }
13990
+ const finalY = endPos.y - sy;
13991
+ if (!this.isNodeClear(endPos.x, finalY)) return false;
13992
+ }
13993
+ }
13994
+ else
13995
+ {
13996
+ // Mostly vertical.
13997
+ if (dx === 0)
13998
+ {
13999
+ y += sy;
14000
+ while (y !== endPos.y)
14001
+ {
14002
+ if (!this.isNodeClear(x, y)) return false;
14003
+ y += sy;
14004
+ }
14005
+ }
14006
+ else
14007
+ {
14008
+ let lastX = startPos.x;
14009
+ while (y !== endPos.y)
14010
+ {
14011
+ x = startPos.x + Math.trunc((dx * (y - startPos.y)) / dy);
14012
+ if (lastX !== x)
14013
+ {
14014
+ if (!this.isNodeClear(x + sx, y - sy)) return false;
14015
+ if (!this.isNodeClear(x - sx, y)) return false;
14016
+ }
14017
+ lastX = x;
14018
+ if (y !== startPos.y)
14019
+ {
14020
+ if (!this.isNodeClear(x, y)) return false;
14021
+ }
14022
+ x += sx;
14023
+ if (!this.isNodeClear(x, y)) return false;
14024
+ y += sy;
14025
+ }
14026
+ const finalX = endPos.x - sx;
14027
+ if (!this.isNodeClear(finalX, endPos.y)) return false;
14028
+ }
14029
+ }
14030
+ return true;
14031
+ }
14032
+
14033
+ /** Find a path from startPos to endPos in world space. Returns an array
14034
+ * of world-space Vector2 points; empty array if no path exists.
14035
+ *
14036
+ * Start and end are snapped to the nearest walkable tile via
14037
+ * getNearestClearNode. Intermediate points are tile centers unless the
14038
+ * string-pulling smoothing pass moves them off-grid.
14039
+ * @param {Vector2} startPos - World-space start
14040
+ * @param {Vector2} endPos - World-space end
14041
+ * @returns {Vector2[]}
14042
+ * @memberof PathFinding */
14043
+ findPath(startPos, endPos)
14044
+ {
14045
+ ASSERT(isVector2(startPos) && isVector2(endPos), 'findPath needs Vector2 endpoints');
14046
+
14047
+ this.buildNodeData();
14048
+
14049
+ // rebuild=false because we just built — avoid redundant work per snap.
14050
+ const startNode = this.getNearestClearNode(startPos, 10, false);
14051
+ const endNode = this.getNearestClearNode(endPos, 10, false);
14052
+ if (!startNode || !endNode) return [];
14053
+
14054
+ // Trivial case: start and end snapped to the same tile.
14055
+ if (startNode === endNode) return [startNode.posWorld.copy()];
14056
+
14057
+ if (!this.aStarSearch(startNode, endNode)) return [];
14058
+
14059
+ // Walk back from endNode via parent pointers, then reverse — cheaper
14060
+ // than unshifting on every step.
14061
+ const nodePath = [];
14062
+ for (let n = endNode; n; n = n.parent)
14063
+ nodePath.push(n);
14064
+ nodePath.reverse();
14065
+
14066
+ if (this.smoothPath)
14067
+ {
14068
+ this.smoothPathCorners(nodePath);
14069
+ this.smoothPathStringPull(nodePath);
14070
+ }
14071
+
14072
+ // Convert to world-space Vector2 path. Return copies, not live node
14073
+ // references — callers shouldn't be able to mutate the grid.
14074
+ const result = nodePath.map(n => n.posWorld.copy());
14075
+
14076
+ if (this.debug && this.debugTime > 0 && result.length > 0)
14077
+ {
14078
+ for (let i = 1; i < result.length; ++i)
14079
+ debugLine(result[i - 1], result[i], RED, 0.1, this.debugTime);
14080
+ for (const p of result)
14081
+ debugCircle(p, 0.5, rgb(1, 0, 0, 0.3), this.debugTime);
14082
+ debugCircle(result[0], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
14083
+ debugCircle(result[result.length - 1], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
14084
+ }
14085
+
14086
+ return result;
14087
+ }
14088
+ }
14089
+