littlejsengine 1.18.2 → 1.18.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/COPYRIGHT.txt +38 -0
  2. package/FAQ.md +633 -0
  3. package/LICENSE +5 -27
  4. package/README.md +19 -1
  5. package/dist/littlejs.d.ts +550 -57
  6. package/dist/littlejs.esm.js +2208 -492
  7. package/dist/littlejs.esm.min.js +1 -1
  8. package/dist/littlejs.js +2165 -480
  9. package/dist/littlejs.min.js +1 -1
  10. package/dist/littlejs.release.js +2166 -481
  11. package/package.json +8 -1
  12. package/plugins/box2d.js +36 -9
  13. package/{src/engineMedals.js → plugins/medalSystem.js} +248 -196
  14. package/plugins/pathFinder.js +758 -0
  15. package/plugins/pluginExport.js +26 -0
  16. package/plugins/tween.js +509 -0
  17. package/plugins/tweenSystem.js +509 -0
  18. package/plugins/uiSystem.js +151 -12
  19. package/src/engine.js +9 -145
  20. package/src/engineAudio.js +8 -5
  21. package/src/engineBuild.mjs +4 -1
  22. package/src/engineDebug.js +1 -1
  23. package/src/engineDraw.js +154 -5
  24. package/src/engineExport.js +17 -12
  25. package/src/engineInput.js +27 -1
  26. package/src/engineLogo.js +146 -0
  27. package/src/engineMath.js +10 -3
  28. package/src/engineObject.js +11 -5
  29. package/src/engineParticles.js +6 -3
  30. package/src/engineRelease.js +1 -1
  31. package/src/engineSettings.js +17 -47
  32. package/src/engineUtilities.js +7 -2
  33. package/src/engineWebGL.js +31 -8
  34. package/src/jsconfig.json +3 -1
  35. package/.claude/settings.local.json +0 -7
  36. package/.github/workflows/test.yml +0 -17
  37. package/AI.md +0 -172
  38. package/CLAUDE.md +0 -1
  39. package/examples/box2d/game.js +0 -192
  40. package/examples/box2d/gameObjects.js +0 -564
  41. package/examples/box2d/index.html +0 -9
  42. package/examples/box2d/scenes.js +0 -194
  43. package/examples/box2d/tiles.png +0 -0
  44. package/examples/breakout/game.js +0 -175
  45. package/examples/breakout/gameObjects.js +0 -147
  46. package/examples/breakout/index.html +0 -8
  47. package/examples/breakout/tiles.png +0 -0
  48. package/examples/breakoutTutorial/README.md +0 -521
  49. package/examples/breakoutTutorial/game.js +0 -191
  50. package/examples/breakoutTutorial/images/1.png +0 -0
  51. package/examples/breakoutTutorial/images/10.png +0 -0
  52. package/examples/breakoutTutorial/images/11.png +0 -0
  53. package/examples/breakoutTutorial/images/2.png +0 -0
  54. package/examples/breakoutTutorial/images/3.png +0 -0
  55. package/examples/breakoutTutorial/images/4.png +0 -0
  56. package/examples/breakoutTutorial/images/5.png +0 -0
  57. package/examples/breakoutTutorial/images/6.png +0 -0
  58. package/examples/breakoutTutorial/images/7.png +0 -0
  59. package/examples/breakoutTutorial/images/8.png +0 -0
  60. package/examples/breakoutTutorial/images/9.png +0 -0
  61. package/examples/breakoutTutorial/index.html +0 -8
  62. package/examples/electron/build.mjs +0 -127
  63. package/examples/electron/electron.js +0 -35
  64. package/examples/electron/game.js +0 -54
  65. package/examples/electron/index.html +0 -13
  66. package/examples/electron/package.json +0 -5
  67. package/examples/electron/tiles.png +0 -0
  68. package/examples/empty/game.js +0 -51
  69. package/examples/empty/index.html +0 -5
  70. package/examples/empty/tiles.png +0 -0
  71. package/examples/favicon.png +0 -0
  72. package/examples/games.jpg +0 -0
  73. package/examples/htmlMenu/game.js +0 -82
  74. package/examples/htmlMenu/index.html +0 -45
  75. package/examples/htmlMenu/tiles.png +0 -0
  76. package/examples/index.html +0 -800
  77. package/examples/logo.png +0 -0
  78. package/examples/logo2.png +0 -0
  79. package/examples/module/build.mjs +0 -124
  80. package/examples/module/game.js +0 -132
  81. package/examples/module/index.html +0 -10
  82. package/examples/module/tiles.png +0 -0
  83. package/examples/particles/index.html +0 -426
  84. package/examples/particles/tiles.png +0 -0
  85. package/examples/platformer/data/gameLevelData.tmx +0 -143
  86. package/examples/platformer/data/gameLevelData.tsx +0 -4
  87. package/examples/platformer/game.js +0 -147
  88. package/examples/platformer/gameCharacter.js +0 -309
  89. package/examples/platformer/gameEffects.js +0 -278
  90. package/examples/platformer/gameLevel.js +0 -205
  91. package/examples/platformer/gameLevelData.json +0 -171
  92. package/examples/platformer/gameObjects.js +0 -378
  93. package/examples/platformer/gamePlayer.js +0 -35
  94. package/examples/platformer/index.html +0 -9
  95. package/examples/platformer/tiles.png +0 -0
  96. package/examples/platformer/tilesLevel.png +0 -0
  97. package/examples/puzzle/game.js +0 -331
  98. package/examples/puzzle/index.html +0 -8
  99. package/examples/puzzle/tiles.png +0 -0
  100. package/examples/screenshot.jpg +0 -0
  101. package/examples/shorts/animation.js +0 -18
  102. package/examples/shorts/base.html +0 -54
  103. package/examples/shorts/blending.js +0 -14
  104. package/examples/shorts/box2d.js +0 -51
  105. package/examples/shorts/box2dCar.js +0 -59
  106. package/examples/shorts/box2dPool.js +0 -107
  107. package/examples/shorts/box2dTileLayer.js +0 -48
  108. package/examples/shorts/cameraDrag.js +0 -22
  109. package/examples/shorts/clock.js +0 -21
  110. package/examples/shorts/colors.js +0 -25
  111. package/examples/shorts/debugDraw.js +0 -37
  112. package/examples/shorts/empty.js +0 -30
  113. package/examples/shorts/flappyGame.js +0 -55
  114. package/examples/shorts/fontImage.js +0 -17
  115. package/examples/shorts/fps.js +0 -90
  116. package/examples/shorts/helloWorld.js +0 -11
  117. package/examples/shorts/hillGlideGame.js +0 -63
  118. package/examples/shorts/input.js +0 -64
  119. package/examples/shorts/landerGame.js +0 -57
  120. package/examples/shorts/maze.js +0 -48
  121. package/examples/shorts/medals.js +0 -51
  122. package/examples/shorts/music.js +0 -78
  123. package/examples/shorts/musicPlayer.js +0 -137
  124. package/examples/shorts/nineSlice.js +0 -40
  125. package/examples/shorts/parallax.js +0 -72
  126. package/examples/shorts/particles.js +0 -28
  127. package/examples/shorts/piano.js +0 -44
  128. package/examples/shorts/platformer.js +0 -45
  129. package/examples/shorts/pongGame.js +0 -41
  130. package/examples/shorts/postProcess.js +0 -62
  131. package/examples/shorts/sequencer.js +0 -124
  132. package/examples/shorts/shader.js +0 -29
  133. package/examples/shorts/shapes.js +0 -21
  134. package/examples/shorts/slidingPuzzle.js +0 -52
  135. package/examples/shorts/song.mp3 +0 -0
  136. package/examples/shorts/sound.js +0 -36
  137. package/examples/shorts/spaceGame.js +0 -58
  138. package/examples/shorts/spriteAtlas.js +0 -31
  139. package/examples/shorts/starfield.js +0 -15
  140. package/examples/shorts/texture.js +0 -16
  141. package/examples/shorts/tileLayer.js +0 -48
  142. package/examples/shorts/tileRaycast.js +0 -39
  143. package/examples/shorts/tiles.png +0 -0
  144. package/examples/shorts/tiltedView.js +0 -63
  145. package/examples/shorts/timers.js +0 -53
  146. package/examples/shorts/topDown.js +0 -38
  147. package/examples/shorts/uiSystem.js +0 -61
  148. package/examples/shorts/video.webm +0 -0
  149. package/examples/shorts/videoPlayer.js +0 -34
  150. package/examples/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/typescript/build.mjs +0 -60
  158. package/examples/typescript/game.js +0 -100
  159. package/examples/typescript/game.ts +0 -132
  160. package/examples/typescript/index.html +0 -10
  161. package/examples/typescript/tiles.png +0 -0
  162. package/examples/typescript/tsconfig.json +0 -17
  163. package/examples/uiSystem/game.js +0 -139
  164. package/examples/uiSystem/index.html +0 -10
  165. package/examples/uiSystem/tiles.png +0 -0
  166. package/jsconfig.json +0 -12
  167. package/plugins/desktop.ini +0 -2
  168. package/reference.md +0 -447
  169. package/test/math.test.mjs +0 -774
  170. package/test/setup.mjs +0 -22
  171. package/test/smoke.test.mjs +0 -258
  172. package/test/util.test.mjs +0 -80
package/dist/littlejs.js CHANGED
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.18.2';
38
+ const engineVersion = '1.18.7';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -69,7 +69,7 @@ let frame = 0;
69
69
  * @memberof Engine */
70
70
  let time = 0;
71
71
 
72
- /** Actual clock time since start in seconds (not affected by pause or frame rate clamping)
72
+ /** Actual clock time since start in seconds (not affected by pause, timescale, or frame rate clamping)
73
73
  * @type {number}
74
74
  * @memberof Engine */
75
75
  let timeReal = 0;
@@ -200,11 +200,14 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
200
200
  averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
201
201
  const debugSpeedUp = debug && keyIsDown('Equal'); // +
202
202
  const debugSpeedDown = debug && keyIsDown('Minus'); // -
203
- if (debug) // +/- to speed/slow time
204
- frameTimeDeltaMS *= debugSpeedUp ? 10 : debugSpeedDown ? .1 : 1;
205
- timeReal += frameTimeDeltaMS / 1e3;
203
+ const debugScale = debugSpeedUp ? 10 : debugSpeedDown ? .1 : 1;
204
+
205
+ // apply time deltas
206
+ timeReal += frameTimeDeltaMS * debugScale / 1e3;
207
+ const combinedScale = timeScale * debugScale;
208
+ frameTimeDeltaMS *= combinedScale;
206
209
  frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
207
- if (!debugSpeedUp)
210
+ if (debugScale <= 1)
208
211
  frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp min framerate
209
212
 
210
213
  let wasUpdated = false;
@@ -562,145 +565,6 @@ function engineObjectsRaycast(start, end, objects=engineObjects)
562
565
 
563
566
  debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02);
564
567
  return hitObjects;
565
- }
566
-
567
- ///////////////////////////////////////////////////////////////////////////////
568
- function drawEngineLogo(t)
569
- {
570
- const blackAndWhite = 0;
571
- const showName = 1;
572
-
573
- // LittleJS Logo and Splash Screen
574
- const x = mainContext;
575
- const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
576
- const w = mainCanvas.width = innerWidth * dpr;
577
- const h = mainCanvas.height = innerHeight * dpr;
578
- {
579
- // background
580
- const p3 = percent(t, 1, .8);
581
- const p4 = percent(t, 0, .5);
582
- const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,hypot(w,h)*.6);
583
- g.addColorStop(0,hsl(0,0,lerp(0,p3/2,p4),p3).toString());
584
- g.addColorStop(1,hsl(0,0,0,p3).toString());
585
- x.save();
586
- x.fillStyle = g;
587
- x.fillRect(0,0,w,h);
588
- }
589
- const gradient = (X1,Y1,X2,Y2,C,S=1)=>
590
- {
591
- if (C >= 0)
592
- {
593
- if (blackAndWhite)
594
- x.fillStyle = '#fff';
595
- else
596
- {
597
- const g = x.fillStyle = x.createLinearGradient(X1,Y1,X2,Y2);
598
- g.addColorStop(0,color(C,2));
599
- g.addColorStop(1,color(C,1));
600
- }
601
- }
602
- else
603
- x.fillStyle = '#000';
604
- C >= -1 ? (x.fill(), S && x.stroke()) : x.stroke();
605
- }
606
- const circle = (X,Y,R,A=0,B=2*PI,C,S)=>
607
- {
608
- x.beginPath();
609
- x.arc(X,Y,R,p*A,p*B);
610
- gradient(X,Y-R,X,Y+R,C,S);
611
- }
612
- const rect = (X,Y,W,H,C)=>
613
- {
614
- x.beginPath();
615
- x.rect(X,Y,W,H*p);
616
- gradient(X,Y+H,X+W,Y,C);
617
- }
618
- const poly = (points,C,Y,H)=>
619
- {
620
- x.beginPath();
621
- for (const p of points)
622
- x.lineTo(p.x, p.y);
623
- x.closePath();
624
- gradient(0, Y, 0, Y+H,C);
625
- }
626
- const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
627
-
628
- // center and fit tos screen
629
- const alpha = oscillate(1,1,t);
630
- const p = percent(alpha, .1, .5);
631
- const size = min(6, min(w,h)/99);
632
- x.translate(w/2,h/2);
633
- x.scale(size,size);
634
- x.translate(-40,-35);
635
- p < 1 && x.setLineDash([99*p,99]);
636
- x.lineJoin = x.lineCap = 'round';
637
- x.lineWidth = .1 + p*1.9;
638
- //x.strokeStyle='#fff7';
639
-
640
- if (showName)
641
- {
642
- // engine name text
643
- const Y = 54;
644
- const s = 'LittleJS';
645
- x.font = '900 15.5px arial';
646
- x.lineWidth = .1+p*3.9;
647
- x.textAlign = 'center';
648
- x.textBaseline = 'top';
649
- rect(11,Y+1,59,8*p,-1);
650
- x.beginPath();
651
-
652
- let w2 = 0;
653
- for (let i=0;i<s.length;++i)
654
- w2 += x.measureText(s[i]).width;
655
- for (let j=2;j--;)
656
- for (let i=0,X=40-w2/2;i<s.length;++i)
657
- {
658
- const w = x.measureText(s[i]).width, X2 = X+w/2;
659
- gradient(X2,Y,X2+2,Y+13,i>5?1:0);
660
- x[j?'strokeText':'fillText'](s[i],X2,Y+.5,17*p);
661
- X += w;
662
- }
663
-
664
- x.lineWidth = .1 + p*1.9;
665
- rect(3,Y,73,0); // bottom
666
- }
667
-
668
- rect(7,15,26,-7,0); // cab top
669
- rect(25,15,8,25,-1); // cab front
670
- rect(10,40,15,-25,1); // cab back
671
- rect(14,21,7,9,2); // cab window
672
- rect(38,20,6,-6,2); // little stack
673
-
674
- // big stack
675
- rect(49,20,10,-6,0);
676
- const stackPoints = [vec2(44,8),vec2(64,8),vec2(59,8+6*p),vec2(49,8+6*p)];
677
- poly(stackPoints,2,8,6*p);
678
- rect(44,8,20,-7,0);
679
-
680
- // engine
681
- for (let i=5;i--;) circle(59-i*6*p,30,10,0,2*PI,1,0);
682
- circle(59,30,4,0,7,2); // light
683
-
684
- // engine outline
685
- rect(35,20,24,0); // top
686
- circle(59,30,10); // front
687
- circle(47,30,10,PI/2,PI*3/2); // middle
688
- circle(35,30,10,PI/2,PI*3/2); // back
689
- rect(7,40,13,7,-1); // bottom back
690
- rect(17,40,43,14,-1); // bottom center
691
-
692
- // wheels
693
- for (let i=3;i--;) for (let j=2;j--;) circle(17+15*i,47,j?7:1,0,2*PI,2);
694
-
695
- // cowcatcher
696
- for (let i=2;i--;)
697
- {
698
- let w=6, s=7, o=53+w*p*i
699
- const points = [vec2(o+s,54),vec2(o,40),vec2(o+w*p,40),vec2(o+s+w*p,54)];
700
- poly(points,0,40,14);
701
- }
702
-
703
- x.restore();
704
568
  }
705
569
  /**
706
570
  * LittleJS Debug System
@@ -746,7 +610,7 @@ let debugKey = 'Escape';
746
610
  let debugOverlay = false;
747
611
 
748
612
  // Engine internal variables not exposed to documentation
749
- let debugPrimitives = [], debugPhysics = false, debugRaycast = false, debugParticles = false, debugGamepads = false, debugMedals = false, debugTakeScreenshot;
613
+ let debugPrimitives = [], debugPhysics = false, debugRaycast = false, debugParticles = false, debugGamepads = false, debugTakeScreenshot;
750
614
 
751
615
  ///////////////////////////////////////////////////////////////////////////////
752
616
  // Debug helper functions
@@ -1577,7 +1441,11 @@ function percentLerp(value, percentA, percentB, lerpA, lerpB)
1577
1441
  * @return {number}
1578
1442
  * @memberof Math */
1579
1443
  function distanceWrap(valueA, valueB, wrapSize=1)
1580
- { const d = (valueA - valueB) % wrapSize; return d*2 % wrapSize - d; }
1444
+ {
1445
+ ASSERT(wrapSize > 0, 'distanceWrap wrapSize must be > 0');
1446
+ const d = (valueA - valueB) % wrapSize;
1447
+ return d*2 % wrapSize - d;
1448
+ }
1581
1449
 
1582
1450
  /** Linearly interpolates between values passed in with wrapping
1583
1451
  * @param {number} valueA
@@ -1894,6 +1762,7 @@ class RandomGenerator
1894
1762
  * @param {number} [seed] - Starting seed or engine default seed */
1895
1763
  constructor(seed = 123456789)
1896
1764
  {
1765
+ ASSERT(seed !== 0, 'RandomGenerator seed must be non-zero (xorshift is fixed at 0)');
1897
1766
  /** @property {number} - random seed */
1898
1767
  this.seed = seed;
1899
1768
  }
@@ -2187,8 +2056,10 @@ class Vector2
2187
2056
  * @return {Vector2} */
2188
2057
  floor() { return new Vector2(floor(this.x), floor(this.y)); }
2189
2058
 
2190
- /** Returns a copy of this vector snapped to a grid
2191
- * @param {number} grid - grid size to snap to
2059
+ /** Returns a copy of this vector snapped to a grid. Note that `grid` is
2060
+ * the number of snap steps per unit (so `grid=2` snaps to halves and
2061
+ * `grid=0.5` snaps to twos), not the cell size.
2062
+ * @param {number} grid - snap steps per unit
2192
2063
  * @return {Vector2} */
2193
2064
  snap(grid)
2194
2065
  {
@@ -2758,9 +2629,14 @@ function readSaveData(saveName, defaultSaveData)
2758
2629
  {
2759
2630
  ASSERT(isString(saveName), 'loadData requires saveName string');
2760
2631
 
2761
- // replace undefined values with defaults
2632
+ // replace undefined values with defaults; tolerate corrupt JSON
2762
2633
  const data = localStorage[saveName];
2763
- const loadedData = data ? JSON.parse(data) : {};
2634
+ let loadedData = {};
2635
+ if (data)
2636
+ {
2637
+ try { loadedData = JSON.parse(data); }
2638
+ catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
2639
+ }
2764
2640
  return { ...defaultSaveData, ...loadedData };
2765
2641
  }
2766
2642
 
@@ -2800,6 +2676,18 @@ let cameraAngle = 0;
2800
2676
  * @memberof Settings */
2801
2677
  let cameraScale = 32;
2802
2678
 
2679
+ ///////////////////////////////////////////////////////////////////////////////
2680
+ // Time settings
2681
+
2682
+ /** Scale applied to engine time, can be used for slow motion or fast forward
2683
+ * - 1 is normal speed, 2 is double speed, 0.5 is half speed
2684
+ * - 0 freezes the simulation without setting the paused flag
2685
+ * - Should be >= 0; stacks multiplicatively with the debug +/- shortcut
2686
+ * @type {number}
2687
+ * @default
2688
+ * @memberof Settings */
2689
+ let timeScale = 1;
2690
+
2803
2691
  ///////////////////////////////////////////////////////////////////////////////
2804
2692
  // Display settings
2805
2693
 
@@ -3083,33 +2971,6 @@ let soundDefaultRange = 40;
3083
2971
  * @memberof Settings */
3084
2972
  let soundDefaultTaper = .7;
3085
2973
 
3086
- ///////////////////////////////////////////////////////////////////////////////
3087
- // Medals settings
3088
-
3089
- /** How long to show medals for in seconds
3090
- * @type {number}
3091
- * @default
3092
- * @memberof Settings */
3093
- let medalDisplayTime = 5;
3094
-
3095
- /** How quickly to slide on/off medals in seconds
3096
- * @type {number}
3097
- * @default
3098
- * @memberof Settings */
3099
- let medalDisplaySlideTime = .5;
3100
-
3101
- /** Size of medal display
3102
- * @type {Vector2}
3103
- * @default Vector2(640,80)
3104
- * @memberof Settings */
3105
- let medalDisplaySize = vec2(640, 80);
3106
-
3107
- /** Set to stop medals from being unlockable (like if cheats are enabled)
3108
- * @type {boolean}
3109
- * @default
3110
- * @memberof Settings */
3111
- let medalsPreventUnlock = false;
3112
-
3113
2974
  ///////////////////////////////////////////////////////////////////////////////
3114
2975
  // Setters for global variables
3115
2976
 
@@ -3128,6 +2989,11 @@ function setCameraAngle(angle) { cameraAngle = angle; }
3128
2989
  * @memberof Settings */
3129
2990
  function setCameraScale(scale) { cameraScale = scale; }
3130
2991
 
2992
+ /** Set scale applied to engine time
2993
+ * @param {number} scale
2994
+ * @memberof Settings */
2995
+ function setTimeScale(scale) { timeScale = scale; }
2996
+
3131
2997
  /** Set if tiles should be colorized when using canvas2d
3132
2998
  * This can be slower but results should look nearly identical to WebGL rendering
3133
2999
  * It can be enabled/disabled at any time
@@ -3366,26 +3232,6 @@ function setSoundDefaultRange(range) { soundDefaultRange = range; }
3366
3232
  * @memberof Settings */
3367
3233
  function setSoundDefaultTaper(taper) { soundDefaultTaper = taper; }
3368
3234
 
3369
- /** Set how long to show medals for in seconds
3370
- * @param {number} time
3371
- * @memberof Settings */
3372
- function setMedalDisplayTime(time) { medalDisplayTime = time; }
3373
-
3374
- /** Set how quickly to slide on/off medals in seconds
3375
- * @param {number} time
3376
- * @memberof Settings */
3377
- function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
3378
-
3379
- /** Set size of medal display
3380
- * @param {Vector2} size
3381
- * @memberof Settings */
3382
- function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
3383
-
3384
- /** Set to stop medals from being unlockable
3385
- * @param {boolean} preventUnlock
3386
- * @memberof Settings */
3387
- function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }
3388
-
3389
3235
  /** Set if watermark with FPS should be shown
3390
3236
  * @param {boolean} show
3391
3237
  * @memberof Debug */
@@ -3527,10 +3373,18 @@ class EngineObject
3527
3373
  const parent = this.parent;
3528
3374
  if (parent)
3529
3375
  {
3530
- // copy parent pos/angle
3376
+ // compose with parent transform inline to avoid intermediate vector allocs
3531
3377
  const mirror = parent.getMirrorSign();
3532
- this.pos = this.localPos.multiply(vec2(mirror,1)).rotate(parent.angle).add(parent.pos);
3533
- this.angle = mirror*this.localAngle + parent.angle;
3378
+ const lp = this.localPos, pp = parent.pos;
3379
+ const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
3380
+ if (pa)
3381
+ {
3382
+ const c = cos(-pa), s = sin(-pa);
3383
+ this.pos = new Vector2(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
3384
+ }
3385
+ else
3386
+ this.pos = new Vector2(lx + pp.x, ly + pp.y);
3387
+ this.angle = mirror*this.localAngle + pa;
3534
3388
  }
3535
3389
 
3536
3390
  // update children
@@ -3896,8 +3750,6 @@ class EngineObject
3896
3750
  * @return {string} */
3897
3751
  toString()
3898
3752
  {
3899
- if (!debug) return;
3900
-
3901
3753
  let text = 'type = ' + this.constructor.name;
3902
3754
  if (this.pos.x || this.pos.y)
3903
3755
  text += '\npos = ' + this.pos;
@@ -4002,6 +3854,12 @@ let textureInfos = [];
4002
3854
  * @memberof Draw */
4003
3855
  let drawCount;
4004
3856
 
3857
+ // internal predicates for tint short-circuiting in canvas2D draw paths
3858
+ // isWhite ignores alpha because alpha is applied via globalAlpha, not multiply
3859
+ // isBlack includes alpha so additive colors that only contribute alpha are not skipped
3860
+ /** @param {Color} c */ function isWhite(c) { return c.r >= 1 && c.g >= 1 && c.b >= 1; }
3861
+ /** @param {Color} c */ function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
3862
+
4005
3863
  ///////////////////////////////////////////////////////////////////////////////
4006
3864
 
4007
3865
  /**
@@ -4141,8 +3999,9 @@ class TextureInfo
4141
3999
  * Create a TextureInfo, called automatically by the engine
4142
4000
  * @param {HTMLImageElement|OffscreenCanvas} image
4143
4001
  * @param {boolean} [useWebGL] - Should use WebGL if available?
4002
+ * @param {boolean} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)?
4144
4003
  */
4145
- constructor(image, useWebGL=true)
4004
+ constructor(image, useWebGL=true, wrap=false)
4146
4005
  {
4147
4006
  /** @property {HTMLImageElement|OffscreenCanvas} - image source */
4148
4007
  this.image = image;
@@ -4152,6 +4011,8 @@ class TextureInfo
4152
4011
  this.sizeInverse = image ? vec2(1/image.width, 1/image.height) : vec2();
4153
4012
  /** @property {WebGLTexture} - WebGL texture */
4154
4013
  this.glTexture = undefined;
4014
+ /** @property {boolean} - true for REPEAT wrap mode, false for CLAMP_TO_EDGE */
4015
+ this.wrap = wrap;
4155
4016
  useWebGL && this.createWebGLTexture();
4156
4017
  }
4157
4018
 
@@ -4164,6 +4025,14 @@ class TextureInfo
4164
4025
  /** Check if the texture is webgl enabled
4165
4026
  * @return {boolean} */
4166
4027
  hasWebGL() { return !!this.glTexture; }
4028
+
4029
+ /** Set the wrap mode for this texture
4030
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE */
4031
+ setWrap(wrap=true)
4032
+ {
4033
+ this.wrap = wrap;
4034
+ glSetTextureWrap(this.glTexture, wrap);
4035
+ }
4167
4036
  }
4168
4037
 
4169
4038
  ///////////////////////////////////////////////////////////////////////////////
@@ -4225,8 +4094,13 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
4225
4094
  }
4226
4095
  else
4227
4096
  {
4228
- // if no tile info, force untextured
4229
- glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0, 0, color.rgbaInt());
4097
+ // if no tile info, force untextured by zeroing rgba (so whatever
4098
+ // texture is bound doesn't leak in) and folding color+additive
4099
+ // into the additive slot — matches the Canvas2D path's
4100
+ // color.add(additiveColor) on line ~337.
4101
+ const combined = additiveColor ? color.add(additiveColor) : color;
4102
+ glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0,
4103
+ 0, combined.rgbaInt());
4230
4104
  }
4231
4105
  }
4232
4106
  else
@@ -4331,6 +4205,98 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
4331
4205
  }
4332
4206
  }
4333
4207
 
4208
+ /** Draw a texture tiled (wrapped) across a rectangle in world space.
4209
+ * Useful for backgrounds, repeating patterns, and seamless fills.
4210
+ * The whole texture is tiled — sub-region (TileInfo) wrapping is not supported.
4211
+ * @param {Vector2} pos - Center of the rect in world space
4212
+ * @param {Vector2} size - Size of the rect in world space
4213
+ * @param {Vector2} wrapCount - How many times the texture repeats (x, y)
4214
+ * @param {TextureInfo|number} [texture=0] - TextureInfo or texture index into textureInfos
4215
+ * @param {Color} [color=WHITE] - Color to modulate with
4216
+ * @param {number} [angle=0] - Angle to rotate by
4217
+ * @param {Color} [additiveColor] - Additive color to be applied if any
4218
+ * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
4219
+ * @param {boolean} [screenSpace=false] - Are pos and size in screen space?
4220
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
4221
+ * @memberof Draw */
4222
+ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
4223
+ angle=0, additiveColor, useWebGL=glEnable, screenSpace=false, context)
4224
+ {
4225
+ ASSERT(isVector2(pos), 'pos must be a vec2');
4226
+ ASSERT(isVector2(size), 'size must be a vec2');
4227
+ ASSERT(isVector2(wrapCount), 'wrapCount must be a vec2');
4228
+ ASSERT(isColor(color), 'color is invalid');
4229
+ ASSERT(isNumber(angle), 'angle must be a number');
4230
+ ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
4231
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
4232
+ ASSERT(!(texture instanceof TileInfo),
4233
+ 'pass a TextureInfo or texture index, not a TileInfo — use tileInfo.textureInfo');
4234
+
4235
+ // short-circuit before texture lookup — textureInfos[0] is undefined in headless mode
4236
+ if (headlessMode) return;
4237
+
4238
+ // resolve texture argument: TextureInfo or index
4239
+ const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
4240
+ ASSERT(textureInfo instanceof TextureInfo, 'texture not loaded');
4241
+ ASSERT(textureInfo.size.x > 0, 'texture not loaded');
4242
+ ASSERT(textureInfo.wrap,
4243
+ 'drawTextureWrapped requires a wrap-enabled texture; call textureInfo.setWrap(true) first');
4244
+
4245
+ if (useWebGL && glEnable)
4246
+ {
4247
+ ASSERT(!!glContext, 'WebGL is not enabled!');
4248
+ if (screenSpace)
4249
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
4250
+ glSetTexture(textureInfo.glTexture);
4251
+ glDraw(pos.x, pos.y, size.x, size.y, angle,
4252
+ 0, 0, wrapCount.x, wrapCount.y,
4253
+ color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
4254
+ return;
4255
+ }
4256
+
4257
+ // Canvas2D path — increment drawCount here (WebGL batch counts via glBatchCount)
4258
+ ++drawCount;
4259
+
4260
+ if (!screenSpace)
4261
+ {
4262
+ pos = worldToScreen(pos);
4263
+ size = size.scale(cameraScale);
4264
+ angle -= cameraAngle;
4265
+ }
4266
+
4267
+ // pick image source: raw, or tinted bake. Match drawImageColor's
4268
+ // "no tint needed" predicate so behavior stays consistent.
4269
+ const noTint = !canvasColorTiles ||
4270
+ (additiveColor
4271
+ ? isWhite(color.add(additiveColor)) && additiveColor.a <= 0
4272
+ : isWhite(color));
4273
+ // alpha is baked into pixels by bakeTintedImage's additive branch;
4274
+ // in that case globalAlpha must NOT also apply color.a
4275
+ const alphaBaked = !noTint && additiveColor && !isBlack(additiveColor);
4276
+ const source = noTint
4277
+ ? textureInfo.image
4278
+ : bakeTintedImage(textureInfo.image, color, additiveColor);
4279
+
4280
+ context = context || drawContext;
4281
+ context.save();
4282
+ context.translate(pos.x + .5, pos.y + .5);
4283
+ context.rotate(angle);
4284
+ context.globalAlpha = alphaBaked ? 1 : color.a;
4285
+
4286
+ const pattern = context.createPattern(source, 'repeat');
4287
+ // map pattern-source pixels into user space so the rect contains
4288
+ // wrapCount.x × wrapCount.y repeats
4289
+ const m = new DOMMatrix()
4290
+ .translate(-size.x/2, -size.y/2)
4291
+ .scale(size.x / (wrapCount.x * source.width),
4292
+ size.y / (wrapCount.y * source.height));
4293
+ pattern.setTransform(m);
4294
+ context.fillStyle = pattern;
4295
+ context.fillRect(-size.x/2, -size.y/2, size.x, size.y);
4296
+ context.globalAlpha = 1;
4297
+ context.restore();
4298
+ }
4299
+
4334
4300
  /** Draw connected lines between a series of points
4335
4301
  * @param {Array<Vector2>} points
4336
4302
  * @param {number} [width]
@@ -4854,6 +4820,43 @@ function combineCanvases()
4854
4820
  mainContext.drawImage(workCanvas, 0, 0);
4855
4821
  }
4856
4822
 
4823
+ // Internal: bake a color/additive-color tint into workReadCanvas at the
4824
+ // image's native resolution. Returns the work canvas, suitable for
4825
+ // passing to context.createPattern. Used by drawTextureWrapped's
4826
+ // Canvas2D path. Caller is responsible for short-circuiting when no
4827
+ // tint is needed (i.e. color is white and additiveColor is black/none).
4828
+ function bakeTintedImage(image, color, additiveColor)
4829
+ {
4830
+ const w = image.width|0, h = image.height|0;
4831
+ workReadCanvas.width = w;
4832
+ workReadCanvas.height = h;
4833
+ workReadContext.drawImage(image, 0, 0);
4834
+
4835
+ const imageData = workReadContext.getImageData(0, 0, w, h);
4836
+ const data = imageData.data;
4837
+ if (additiveColor && !isBlack(additiveColor))
4838
+ {
4839
+ // multiply + additive (slower)
4840
+ const colorMultiply = [color.r, color.g, color.b, color.a];
4841
+ const colorAdd = [additiveColor.r * 255, additiveColor.g * 255,
4842
+ additiveColor.b * 255, additiveColor.a * 255];
4843
+ for (let i = 0; i < data.length; ++i)
4844
+ data[i] = data[i] * colorMultiply[i&3] + colorAdd[i&3] |0;
4845
+ }
4846
+ else
4847
+ {
4848
+ // RGB only, faster — alpha left intact for the caller
4849
+ for (let i = 0; i < data.length; i+=4)
4850
+ {
4851
+ data[i ] *= color.r;
4852
+ data[i+1] *= color.g;
4853
+ data[i+2] *= color.b;
4854
+ }
4855
+ }
4856
+ workReadContext.putImageData(imageData, 0, 0);
4857
+ return workReadCanvas;
4858
+ }
4859
+
4857
4860
  /** Helper function to draw an image with color and additive color applied
4858
4861
  * This is slower then normal drawImage when color is applied
4859
4862
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
@@ -4872,8 +4875,6 @@ function combineCanvases()
4872
4875
  * @memberof Draw */
4873
4876
  function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight, color, additiveColor, bleed=0)
4874
4877
  {
4875
- function isWhite(c) { return c.r >= 1 && c.g >= 1 && c.b >= 1; }
4876
- function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
4877
4878
  const sx2 = bleed;
4878
4879
  const sy2 = bleed;
4879
4880
  sWidth = max(1,sWidth|0);
@@ -5148,7 +5149,7 @@ const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
5148
5149
  * This is useful to disable for html menus so the browser can handle input normally
5149
5150
  * @param {boolean} preventDefault
5150
5151
  * @memberof Input */
5151
- function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
5152
+ function setInputPreventDefault(preventDefault=true) { inputPreventDefault = preventDefault; }
5152
5153
 
5153
5154
  /** Clears an input key state
5154
5155
  * @param {string|number} key
@@ -5342,6 +5343,32 @@ function gamepadStickCount(gamepad=gamepadPrimary)
5342
5343
  return gamepadStickData[gamepad]?.length ?? 0;
5343
5344
  }
5344
5345
 
5346
+ /** Pulse a gamepad's vibration hardware using the dual-rumble effect if it exists
5347
+ * Strong magnitude is usually the left side motor, weak magnitude is usually the right side motor
5348
+ * @param {number} [gamepad] - gamepad index
5349
+ * @param {number} [duration] - effect duration in ms
5350
+ * @param {number} [strongMagnitude] - strong (left) motor intensity, 0 to 1
5351
+ * @param {number} [weakMagnitude] - weak (right) motor intensity, 0 to 1
5352
+ * @param {number} [startDelay] - delay in ms before the effect starts
5353
+ * @memberof Input */
5354
+ function gamepadVibrate(gamepad=gamepadPrimary, duration=200, strongMagnitude=1, weakMagnitude=1, startDelay=0)
5355
+ {
5356
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
5357
+ if (!vibrateEnable || headlessMode) return;
5358
+ const pad = navigator?.getGamepads?.()[gamepad];
5359
+ pad?.vibrationActuator?.playEffect?.('dual-rumble', {duration, strongMagnitude, weakMagnitude, startDelay});
5360
+ }
5361
+
5362
+ /** Stop vibration on a gamepad
5363
+ * @memberof Input */
5364
+ function gamepadVibrateStop(gamepad=gamepadPrimary)
5365
+ {
5366
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
5367
+ if (!vibrateEnable || headlessMode) return;
5368
+ const pad = navigator?.getGamepads?.()[gamepad];
5369
+ pad?.vibrationActuator?.reset?.();
5370
+ }
5371
+
5345
5372
  ///////////////////////////////////////////////////////////////////////////////
5346
5373
 
5347
5374
  /** Pulse the vibration hardware if it exists
@@ -5918,10 +5945,12 @@ function touchGamepadButtonCenter()
5918
5945
  * @namespace Audio
5919
5946
  */
5920
5947
 
5921
- /** Audio context used by the engine
5948
+ /** Audio context used by the engine. Created lazily in audioInit() to avoid
5949
+ * browser autoplay warnings about constructing an AudioContext before any
5950
+ * user gesture.
5922
5951
  * @type {AudioContext}
5923
5952
  * @memberof Audio */
5924
- let audioContext = new AudioContext;
5953
+ let audioContext;
5925
5954
 
5926
5955
  /** Master gain node for all audio to pass through
5927
5956
  * @type {GainNode}
@@ -5937,12 +5966,13 @@ const audioDefaultSampleRate = 44100;
5937
5966
  * @return {boolean} - True if the audio context is running
5938
5967
  * @memberof Audio */
5939
5968
  function audioIsRunning()
5940
- { return audioContext.state === 'running'; }
5969
+ { return audioContext?.state === 'running'; }
5941
5970
 
5942
5971
  function audioInit()
5943
5972
  {
5944
5973
  if (!soundEnable || headlessMode) return;
5945
5974
 
5975
+ audioContext = new AudioContext;
5946
5976
  audioMasterGain = audioContext.createGain();
5947
5977
  audioMasterGain.connect(audioContext.destination);
5948
5978
  audioMasterGain.gain.value = soundVolume; // set starting value
@@ -6009,8 +6039,8 @@ class Sound
6009
6039
 
6010
6040
  if (Array.isArray(asset))
6011
6041
  {
6012
- // generate zzfx sound
6013
- const zzfxSound = asset;
6042
+ // generate zzfx sound — copy so we don't mutate the caller's array
6043
+ const zzfxSound = asset.slice();
6014
6044
 
6015
6045
  // remove randomness so it can be applied on playback
6016
6046
  const defaultRandomness = randomness ?? .05;
@@ -7608,11 +7638,14 @@ class ParticleEmitter extends EngineObject
7608
7638
  }
7609
7639
 
7610
7640
  ///////////////////////////////////////////////////////////////////////////////
7641
+ // scratch vector reused by Particle.render to avoid per-frame allocations
7642
+ const particleDrawPos = new Vector2;
7643
+
7611
7644
  /**
7612
7645
  * Particle Object - Created automatically by Particle Emitters
7613
7646
  * @memberof Particles
7614
7647
  */
7615
- class Particle
7648
+ class Particle
7616
7649
  {
7617
7650
  /**
7618
7651
  * Create a particle with the passed in settings
@@ -7787,14 +7820,14 @@ class Particle
7787
7820
  additive && setBlendMode(true);
7788
7821
 
7789
7822
  // update the position and angle for drawing
7790
- const pos = this.pos.copy();
7823
+ const pos = particleDrawPos.set(this.pos.x, this.pos.y);
7791
7824
  let angle = this.angle;
7792
7825
  if (localSpace)
7793
7826
  {
7794
7827
  // in local space of emitter
7795
7828
  const a = emitter.angle;
7796
7829
  const c = cos(a), s = sin(a);
7797
- pos.set(emitter.pos.x + pos.x*c - pos.y*s,
7830
+ pos.set(emitter.pos.x + pos.x*c - pos.y*s,
7798
7831
  emitter.pos.y + pos.x*s + pos.y*c);
7799
7832
  angle += a;
7800
7833
  }
@@ -7819,200 +7852,6 @@ class Particle
7819
7852
  debugParticles && debugRect(pos, size, '#f005', 0, angle);
7820
7853
  }
7821
7854
  }
7822
- /**
7823
- * LittleJS Medal System
7824
- * - Achievement/trophy system for games
7825
- * - Medal class with name, description, icon, and unlock tracking
7826
- * - Automatic saving to local storage
7827
- * - Visual display queue with slide-in notifications
7828
- * - Newgrounds API integration for online achievements
7829
- * - Debug mode to unlock/reset medals during development
7830
- * @namespace Medals
7831
- */
7832
-
7833
- /** List of all medals
7834
- * @type {Object}
7835
- * @memberof Medals */
7836
- const medals = {};
7837
-
7838
- // Engine internal variables not exposed to documentation
7839
- let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast;
7840
-
7841
- ///////////////////////////////////////////////////////////////////////////////
7842
-
7843
- /** Initialize medals with a save name used for storage
7844
- * - Call this after creating all medals
7845
- * - Checks if medals are unlocked
7846
- * @param {string} saveName
7847
- * @memberof Medals */
7848
- function medalsInit(saveName)
7849
- {
7850
- // check if medals are unlocked
7851
- medalsSaveName = saveName;
7852
- if (!debugMedals)
7853
- medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
7854
-
7855
- // engine automatically renders medals
7856
- engineAddPlugin(undefined, medalsRender);
7857
-
7858
- // plugin functions
7859
- function medalsRender()
7860
- {
7861
- if (!medalsDisplayQueue.length) return;
7862
-
7863
- // update first medal in queue
7864
- const medal = medalsDisplayQueue[0];
7865
- const time = timeReal - medalsDisplayTimeLast;
7866
- if (!medalsDisplayTimeLast)
7867
- medalsDisplayTimeLast = timeReal;
7868
- else if (time > medalDisplayTime)
7869
- {
7870
- medalsDisplayTimeLast = 0;
7871
- medalsDisplayQueue.shift();
7872
- }
7873
- else
7874
- {
7875
- // slide on/off medals
7876
- const slideOffTime = medalDisplayTime - medalDisplaySlideTime;
7877
- const hidePercent =
7878
- time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime :
7879
- time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0;
7880
- medal.render(hidePercent);
7881
- }
7882
- }
7883
- }
7884
-
7885
- /**
7886
- * @callback MedalCallbackFunction - Function that processes a medal
7887
- * @param {Medal} medal
7888
- * @memberof Medals
7889
- */
7890
-
7891
- /** Calls a function for each medal
7892
- * @param {MedalCallbackFunction} callback
7893
- * @memberof Medals */
7894
- function medalsForEach(callback)
7895
- { Object.values(medals).forEach(medal=>callback(medal)); }
7896
-
7897
- ///////////////////////////////////////////////////////////////////////////////
7898
-
7899
- /**
7900
- * Medal - Tracks an unlockable medal
7901
- * @memberof Medals
7902
- * @example
7903
- * // create a medal
7904
- * const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
7905
- *
7906
- * // initialize medals
7907
- * medalsInit('Example Game');
7908
- *
7909
- * // unlock the medal
7910
- * medal_example.unlock();
7911
- */
7912
- class Medal
7913
- {
7914
- /** Create a medal object and adds it to the list of medals
7915
- * @param {number} id - The unique identifier of the medal
7916
- * @param {string} name - Name of the medal
7917
- * @param {string} [description] - Description of the medal
7918
- * @param {string} [icon] - Icon for the medal
7919
- * @param {string} [src] - Image location for the medal
7920
- */
7921
- constructor(id, name, description='', icon='🏆', src)
7922
- {
7923
- ASSERT(id >= 0 && !medals[id]);
7924
-
7925
- /** @property {number} - The unique identifier of the medal */
7926
- this.id = id;
7927
-
7928
- /** @property {string} - Name of the medal */
7929
- this.name = name;
7930
-
7931
- /** @property {string} - Description of the medal */
7932
- this.description = description;
7933
-
7934
- /** @property {string} - Icon for the medal */
7935
- this.icon = icon;
7936
-
7937
- /** @property {boolean} - Is the medal unlocked? */
7938
- this.unlocked = false;
7939
-
7940
- // load the source image if provided
7941
- if (src)
7942
- (this.image = new Image).src = src;
7943
-
7944
- // add this to list of medals
7945
- medals[id] = this;
7946
- }
7947
-
7948
- /** Unlocks a medal if not already unlocked */
7949
- unlock()
7950
- {
7951
- if (medalsPreventUnlock || this.unlocked) return;
7952
-
7953
- // save the medal
7954
- ASSERT(medalsSaveName, 'save name must be set');
7955
- localStorage[this.storageKey()] = this.unlocked = true;
7956
- medalsDisplayQueue.push(this);
7957
- }
7958
-
7959
- /** Render a medal
7960
- * @param {number} [hidePercent] - How much to slide the medal off screen
7961
- */
7962
- render(hidePercent=0)
7963
- {
7964
- const context = mainContext;
7965
- const width = min(medalDisplaySize.x, mainCanvas.width);
7966
- const height = medalDisplaySize.y;
7967
- const x = mainCanvas.width - width;
7968
- const y = -height*hidePercent;
7969
- const backgroundColor = hsl(0,0,.9);
7970
-
7971
- // draw containing rect and clip to that region
7972
- context.save();
7973
- context.beginPath();
7974
- context.fillStyle = backgroundColor.toString();
7975
- context.strokeStyle = BLACK.toString();
7976
- context.lineWidth = 3;
7977
- context.rect(x, y, width, height);
7978
- context.fill();
7979
- context.stroke();
7980
- context.clip();
7981
-
7982
- // draw the icon
7983
- const gap = vec2(.1, .05).scale(height);
7984
- const medalDisplayIconSize = height - 2*gap.x;
7985
- this.renderIcon(vec2(x + gap.x + medalDisplayIconSize/2, y + height/2), medalDisplayIconSize);
7986
-
7987
- // draw the name
7988
- const nameSize = height*.5;
7989
- const descriptionSize = height*.3;
7990
- const pos = vec2(x + medalDisplayIconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
7991
- const textWidth = width - medalDisplayIconSize - 3*gap.x;
7992
- drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
7993
-
7994
- // draw the description
7995
- pos.y = y + height - gap.y*2 - descriptionSize/2;
7996
- drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
7997
- context.restore();
7998
- }
7999
-
8000
- /** Render the icon for a medal
8001
- * @param {Vector2} pos - Screen space position
8002
- * @param {number} size - Screen space size
8003
- */
8004
- renderIcon(pos, size)
8005
- {
8006
- // draw the image or icon
8007
- if (this.image)
8008
- mainContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size);
8009
- else
8010
- drawTextScreen(this.icon, pos, size*.7, BLACK);
8011
- }
8012
-
8013
- // Get local storage key used by the medal
8014
- storageKey() { return medalsSaveName + '_' + this.id; }
8015
- }
8016
7855
  /**
8017
7856
  * LittleJS WebGL Interface
8018
7857
  * - WebGL2 rendering engine for high-performance graphics
@@ -8112,7 +7951,7 @@ function glInit(rootElement)
8112
7951
  // reinit WebGL and restore textures
8113
7952
  initWebGL();
8114
7953
  for (const info of glTextureInfos)
8115
- info.glTexture = glCreateTexture(info.image);
7954
+ info.glTexture = glCreateTexture(info.image, info.wrap);
8116
7955
  pluginList.forEach(plugin=>plugin.glContextRestored?.());
8117
7956
  });
8118
7957
 
@@ -8317,9 +8156,8 @@ function glClearCanvas()
8317
8156
  /** Set the WebGL texture, called automatically if using multiple textures
8318
8157
  * - This may also flush the gl buffer resulting in more draw calls and worse performance
8319
8158
  * @param {WebGLTexture} texture
8320
- * @param {boolean} [wrap] - Should the texture wrap or clamp
8321
8159
  * @memberof WebGL */
8322
- function glSetTexture(texture, wrap=false)
8160
+ function glSetTexture(texture)
8323
8161
  {
8324
8162
  // must flush cache with the old texture to set a new one
8325
8163
  if (!glContext || texture === glActiveTexture) return;
@@ -8327,11 +8165,30 @@ function glSetTexture(texture, wrap=false)
8327
8165
  glFlush();
8328
8166
  glActiveTexture = texture;
8329
8167
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
8168
+ }
8169
+
8170
+ /** Set the wrap mode (REPEAT or CLAMP_TO_EDGE) on an existing WebGL texture
8171
+ * Flushes the current batch only if the texture is the active one
8172
+ * @param {WebGLTexture} texture
8173
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
8174
+ * @memberof WebGL */
8175
+ function glSetTextureWrap(texture, wrap=true)
8176
+ {
8177
+ if (!glContext || !texture) return;
8178
+
8179
+ // flush only if changing wrap on the currently bound texture
8180
+ const isCurrent = texture === glActiveTexture;
8181
+ if (isCurrent)
8182
+ glFlush();
8183
+ else
8184
+ glContext.bindTexture(glContext.TEXTURE_2D, texture);
8330
8185
 
8331
- // set wrap mode
8332
8186
  const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
8333
8187
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
8334
8188
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
8189
+
8190
+ if (!isCurrent && glActiveTexture)
8191
+ glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
8335
8192
  }
8336
8193
 
8337
8194
  /** Compile WebGL shader of the given type, will throw errors if in debug mode
@@ -8378,9 +8235,10 @@ function glCreateProgram(vsSource, fsSource)
8378
8235
  /** Create WebGL texture from an image and init the texture settings
8379
8236
  * Restores the active texture when done
8380
8237
  * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas} [image]
8238
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
8381
8239
  * @return {WebGLTexture}
8382
8240
  * @memberof WebGL */
8383
- function glCreateTexture(image)
8241
+ function glCreateTexture(image, wrap=false)
8384
8242
  {
8385
8243
  if (!glContext) return;
8386
8244
 
@@ -8406,6 +8264,9 @@ function glCreateTexture(image)
8406
8264
  const minFilter = mipMap ? glContext.LINEAR_MIPMAP_LINEAR : magFilter;
8407
8265
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, magFilter);
8408
8266
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, minFilter);
8267
+ const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
8268
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
8269
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
8409
8270
  if (mipMap)
8410
8271
  glContext.generateMipmap(glContext.TEXTURE_2D);
8411
8272
 
@@ -8457,7 +8318,7 @@ function glRegisterTextureInfo(textureInfo)
8457
8318
  if (textureInfo.glTexture)
8458
8319
  glSetTextureData(textureInfo.glTexture, textureInfo.image);
8459
8320
  else
8460
- textureInfo.glTexture = glCreateTexture(textureInfo.image);
8321
+ textureInfo.glTexture = glCreateTexture(textureInfo.image, textureInfo.wrap);
8461
8322
  }
8462
8323
 
8463
8324
  /** Tells WebGL to destroy the glTexture and stop tracking it
@@ -8676,6 +8537,7 @@ function glSetRenderTarget(texture, clear=false)
8676
8537
  glFlush();
8677
8538
  glRenderTarget = undefined;
8678
8539
  glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
8540
+ glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
8679
8541
  }
8680
8542
  }
8681
8543
 
@@ -8798,9 +8660,9 @@ function glPolyStrip(points)
8798
8660
  return area;
8799
8661
  }
8800
8662
 
8801
- // ensure counter-clockwise winding
8663
+ // ensure counter-clockwise winding (slice first so we don't mutate caller's array)
8802
8664
  if (signedArea(points) < 0)
8803
- points = points.reverse();
8665
+ points = points.slice().reverse();
8804
8666
 
8805
8667
  // check if point is inside triangle
8806
8668
  const e = 1e-9;
@@ -8900,46 +8762,438 @@ function glPolyStrip(points)
8900
8762
  }
8901
8763
  return strip;
8902
8764
  }
8903
- /**
8904
- * LittleJS Newgrounds Plugin
8905
- * - NewgroundsMedal extends Medal with Newgrounds API functionality
8906
- * - Call new NewgroundsPlugin(app_id) to setup Newgrounds
8907
- * - Uses CryptoJS for encryption if optional cipher is provided
8908
- * - provides functions to interact with medals scoreboards
8909
- * - Keeps connection alive and logs views
8910
- * @namespace Newgrounds
8911
- */
8912
-
8913
- /** Global Newgrounds object
8914
- * @type {NewgroundsPlugin}
8915
- * @memberof Newgrounds */
8916
- let newgrounds;
8917
-
8918
- ///////////////////////////////////////////////////////////////////////////////
8919
- /**
8920
- * Newgrounds medal auto unlocks in newgrounds API
8921
- * @extends Medal
8922
- * @memberof Newgrounds
8923
- */
8924
- class NewgroundsMedal extends Medal
8925
- {
8926
- /** Create a newgrounds medal object and adds it to the list of medals
8927
- * @param {number} id - The unique identifier of the medal
8928
- * @param {string} name - Name of the medal
8929
- * @param {string} [description] - Description of the medal
8930
- * @param {string} [icon] - Icon for the medal
8931
- * @param {string} [src] - Image location for the medal
8932
- */
8933
- constructor(id, name, description, icon, src)
8934
- { super(id, name, description, icon, src); }
8935
-
8936
- /** Unlocks a medal if not already unlocked */
8937
- unlock()
8938
- {
8939
- super.unlock();
8940
- newgrounds && newgrounds.unlockMedal(this.id);
8941
- }
8942
- }
8765
+ /**
8766
+ * LittleJS Engine Logo
8767
+ * - Draws the LittleJS splash screen logo
8768
+ * - Used internally during engine startup
8769
+ */
8770
+
8771
+ ///////////////////////////////////////////////////////////////////////////////
8772
+ function drawEngineLogo(t)
8773
+ {
8774
+ const blackAndWhite = 0;
8775
+ const showName = 1;
8776
+
8777
+ // LittleJS Logo and Splash Screen
8778
+ const x = mainContext;
8779
+ const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
8780
+ const w = mainCanvas.width = innerWidth * dpr;
8781
+ const h = mainCanvas.height = innerHeight * dpr;
8782
+ {
8783
+ // background
8784
+ const p3 = percent(t, 1, .8);
8785
+ const p4 = percent(t, 0, .5);
8786
+ const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,hypot(w,h)*.6);
8787
+ g.addColorStop(0,hsl(0,0,lerp(0,p3/2,p4),p3).toString());
8788
+ g.addColorStop(1,hsl(0,0,0,p3).toString());
8789
+ x.save();
8790
+ x.fillStyle = g;
8791
+ x.fillRect(0,0,w,h);
8792
+ }
8793
+ const gradient = (X1,Y1,X2,Y2,C,S=1)=>
8794
+ {
8795
+ if (C >= 0)
8796
+ {
8797
+ if (blackAndWhite)
8798
+ x.fillStyle = '#fff';
8799
+ else
8800
+ {
8801
+ const g = x.fillStyle = x.createLinearGradient(X1,Y1,X2,Y2);
8802
+ g.addColorStop(0,color(C,2));
8803
+ g.addColorStop(1,color(C,1));
8804
+ }
8805
+ }
8806
+ else
8807
+ x.fillStyle = '#000';
8808
+ C >= -1 ? (x.fill(), S && x.stroke()) : x.stroke();
8809
+ }
8810
+ const circle = (X,Y,R,A=0,B=2*PI,C,S)=>
8811
+ {
8812
+ x.beginPath();
8813
+ x.arc(X,Y,R,p*A,p*B);
8814
+ gradient(X,Y-R,X,Y+R,C,S);
8815
+ }
8816
+ const rect = (X,Y,W,H,C)=>
8817
+ {
8818
+ x.beginPath();
8819
+ x.rect(X,Y,W,H*p);
8820
+ gradient(X,Y+H,X+W,Y,C);
8821
+ }
8822
+ const poly = (points,C,Y,H)=>
8823
+ {
8824
+ x.beginPath();
8825
+ for (const p of points)
8826
+ x.lineTo(p.x, p.y);
8827
+ x.closePath();
8828
+ gradient(0, Y, 0, Y+H,C);
8829
+ }
8830
+ const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
8831
+
8832
+ // center and fit to screen
8833
+ const alpha = oscillate(1,1,t);
8834
+ const p = percent(alpha, .1, .5);
8835
+ const size = min(6, min(w,h)/99);
8836
+ x.translate(w/2,h/2);
8837
+ x.scale(size,size);
8838
+ x.translate(-40,-35);
8839
+ p < 1 && x.setLineDash([99*p,99]);
8840
+ x.lineJoin = x.lineCap = 'round';
8841
+ x.lineWidth = .1 + p*1.9;
8842
+ //x.strokeStyle='#fff7';
8843
+
8844
+ if (showName)
8845
+ {
8846
+ // engine name text
8847
+ const Y = 54;
8848
+ const s = 'LittleJS';
8849
+ x.font = '900 15.5px arial';
8850
+ x.lineWidth = .1+p*3.9;
8851
+ x.textAlign = 'center';
8852
+ x.textBaseline = 'top';
8853
+ rect(11,Y+1,59,8*p,-1);
8854
+ x.beginPath();
8855
+
8856
+ let w2 = 0;
8857
+ for (let i=0;i<s.length;++i)
8858
+ w2 += x.measureText(s[i]).width;
8859
+ for (let j=2;j--;)
8860
+ for (let i=0,X=40-w2/2;i<s.length;++i)
8861
+ {
8862
+ const w = x.measureText(s[i]).width, X2 = X+w/2;
8863
+ gradient(X2,Y,X2+2,Y+13,i>5?1:0);
8864
+ x[j?'strokeText':'fillText'](s[i],X2,Y+.5,17*p);
8865
+ X += w;
8866
+ }
8867
+
8868
+ x.lineWidth = .1 + p*1.9;
8869
+ rect(3,Y,73,0); // bottom
8870
+ }
8871
+
8872
+ rect(7,15,26,-7,0); // cab top
8873
+ rect(25,15,8,25,-1); // cab front
8874
+ rect(10,40,15,-25,1); // cab back
8875
+ rect(14,21,7,9,2); // cab window
8876
+ rect(38,20,6,-6,2); // little stack
8877
+
8878
+ // big stack
8879
+ rect(49,20,10,-6,0);
8880
+ const stackPoints = [vec2(44,8),vec2(64,8),vec2(59,8+6*p),vec2(49,8+6*p)];
8881
+ poly(stackPoints,2,8,6*p);
8882
+ rect(44,8,20,-7,0);
8883
+
8884
+ // engine
8885
+ for (let i=5;i--;) circle(59-i*6*p,30,10,0,2*PI,1,0);
8886
+ circle(59,30,4,0,7,2); // light
8887
+
8888
+ // engine outline
8889
+ rect(35,20,24,0); // top
8890
+ circle(59,30,10); // front
8891
+ circle(47,30,10,PI/2,PI*3/2); // middle
8892
+ circle(35,30,10,PI/2,PI*3/2); // back
8893
+ rect(7,40,13,7,-1); // bottom back
8894
+ rect(17,40,43,14,-1); // bottom center
8895
+
8896
+ // wheels
8897
+ for (let i=3;i--;) for (let j=2;j--;) circle(17+15*i,47,j?7:1,0,2*PI,2);
8898
+
8899
+ // cowcatcher
8900
+ for (let i=2;i--;)
8901
+ {
8902
+ let w=6, s=7, o=53+w*p*i
8903
+ const points = [vec2(o+s,54),vec2(o,40),vec2(o+w*p,40),vec2(o+s+w*p,54)];
8904
+ poly(points,0,40,14);
8905
+ }
8906
+
8907
+ x.restore();
8908
+ }
8909
+
8910
+ /**
8911
+ * LittleJS Medal System
8912
+ * - Achievement/trophy system for games
8913
+ * - Medal class with name, description, icon, and unlock tracking
8914
+ * - Automatic saving to local storage
8915
+ * - Visual display queue with slide-in notifications
8916
+ * - Newgrounds API integration for online achievements
8917
+ * - Debug mode to unlock/reset medals during development
8918
+ * @namespace Medals
8919
+ */
8920
+
8921
+ let debugMedals = false;
8922
+
8923
+ ///////////////////////////////////////////////////////////////////////////////
8924
+ // Medals settings
8925
+
8926
+ /** How long to show medals for in seconds
8927
+ * @type {number}
8928
+ * @default
8929
+ * @memberof Settings */
8930
+ let medalDisplayTime = 5;
8931
+
8932
+ /** How quickly to slide on/off medals in seconds
8933
+ * @type {number}
8934
+ * @default
8935
+ * @memberof Settings */
8936
+ let medalDisplaySlideTime = .5;
8937
+
8938
+ /** Size of medal display
8939
+ * @type {Vector2}
8940
+ * @default Vector2(640,80)
8941
+ * @memberof Settings */
8942
+ let medalDisplaySize = vec2(640, 80);
8943
+
8944
+ /** Set to stop medals from being unlockable (like if cheats are enabled)
8945
+ * @type {boolean}
8946
+ * @default
8947
+ * @memberof Settings */
8948
+ let medalsPreventUnlock = false;
8949
+
8950
+ /** List of all medals
8951
+ * @type {Object}
8952
+ * @memberof Medals */
8953
+ const medals = {};
8954
+
8955
+ // Engine internal variables not exposed to documentation
8956
+ let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast;
8957
+
8958
+ ///////////////////////////////////////////////////////////////////////////////
8959
+
8960
+ /** Initialize medals with a save name used for storage
8961
+ * - Call this after creating all medals
8962
+ * - Checks if medals are unlocked
8963
+ * @param {string} saveName
8964
+ * @memberof Medals */
8965
+ function medalsInit(saveName)
8966
+ {
8967
+ // check if medals are unlocked
8968
+ medalsSaveName = saveName;
8969
+ if (!debugMedals)
8970
+ medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
8971
+
8972
+ // engine automatically renders medals
8973
+ engineAddPlugin(undefined, medalsRender);
8974
+
8975
+ // plugin functions
8976
+ function medalsRender()
8977
+ {
8978
+ if (!medalsDisplayQueue.length) return;
8979
+
8980
+ // update first medal in queue
8981
+ const medal = medalsDisplayQueue[0];
8982
+ const time = timeReal - medalsDisplayTimeLast;
8983
+ if (!medalsDisplayTimeLast)
8984
+ medalsDisplayTimeLast = timeReal;
8985
+ else if (time > medalDisplayTime)
8986
+ {
8987
+ medalsDisplayTimeLast = 0;
8988
+ medalsDisplayQueue.shift();
8989
+ }
8990
+ else
8991
+ {
8992
+ // slide on/off medals
8993
+ const slideOffTime = medalDisplayTime - medalDisplaySlideTime;
8994
+ const hidePercent =
8995
+ time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime :
8996
+ time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0;
8997
+ medal.render(hidePercent);
8998
+ }
8999
+ }
9000
+ }
9001
+
9002
+ /**
9003
+ * @callback MedalCallbackFunction - Function that processes a medal
9004
+ * @param {Medal} medal
9005
+ * @memberof Medals
9006
+ */
9007
+
9008
+ /** Calls a function for each medal
9009
+ * @param {MedalCallbackFunction} callback
9010
+ * @memberof Medals */
9011
+ function medalsForEach(callback)
9012
+ { Object.values(medals).forEach(medal=>callback(medal)); }
9013
+
9014
+ ///////////////////////////////////////////////////////////////////////////////
9015
+
9016
+ /**
9017
+ * Medal - Tracks an unlockable medal
9018
+ * @memberof Medals
9019
+ * @example
9020
+ * // create a medal
9021
+ * const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
9022
+ *
9023
+ * // initialize medals
9024
+ * medalsInit('Example Game');
9025
+ *
9026
+ * // unlock the medal
9027
+ * medal_example.unlock();
9028
+ */
9029
+ class Medal
9030
+ {
9031
+ /** Create a medal object and adds it to the list of medals
9032
+ * @param {number} id - The unique identifier of the medal
9033
+ * @param {string} name - Name of the medal
9034
+ * @param {string} [description] - Description of the medal
9035
+ * @param {string} [icon] - Icon for the medal
9036
+ * @param {string} [src] - Image location for the medal
9037
+ */
9038
+ constructor(id, name, description='', icon='🏆', src)
9039
+ {
9040
+ ASSERT(id >= 0 && !medals[id]);
9041
+
9042
+ /** @property {number} - The unique identifier of the medal */
9043
+ this.id = id;
9044
+
9045
+ /** @property {string} - Name of the medal */
9046
+ this.name = name;
9047
+
9048
+ /** @property {string} - Description of the medal */
9049
+ this.description = description;
9050
+
9051
+ /** @property {string} - Icon for the medal */
9052
+ this.icon = icon;
9053
+
9054
+ /** @property {boolean} - Is the medal unlocked? */
9055
+ this.unlocked = false;
9056
+
9057
+ // load the source image if provided
9058
+ if (src)
9059
+ (this.image = new Image).src = src;
9060
+
9061
+ // add this to list of medals
9062
+ medals[id] = this;
9063
+ }
9064
+
9065
+ /** Unlocks a medal if not already unlocked */
9066
+ unlock()
9067
+ {
9068
+ if (medalsPreventUnlock || this.unlocked) return;
9069
+
9070
+ // save the medal
9071
+ ASSERT(medalsSaveName, 'save name must be set');
9072
+ localStorage[this.storageKey()] = this.unlocked = true;
9073
+ medalsDisplayQueue.push(this);
9074
+ }
9075
+
9076
+ /** Render a medal
9077
+ * @param {number} [hidePercent] - How much to slide the medal off screen
9078
+ */
9079
+ render(hidePercent=0)
9080
+ {
9081
+ const context = mainContext;
9082
+ const width = min(medalDisplaySize.x, mainCanvas.width);
9083
+ const height = medalDisplaySize.y;
9084
+ const x = mainCanvas.width - width;
9085
+ const y = -height*hidePercent;
9086
+ const backgroundColor = hsl(0,0,.9);
9087
+
9088
+ // draw containing rect and clip to that region
9089
+ context.save();
9090
+ context.beginPath();
9091
+ context.fillStyle = backgroundColor.toString();
9092
+ context.strokeStyle = BLACK.toString();
9093
+ context.lineWidth = 3;
9094
+ context.rect(x, y, width, height);
9095
+ context.fill();
9096
+ context.stroke();
9097
+ context.clip();
9098
+
9099
+ // draw the icon
9100
+ const gap = vec2(.1, .05).scale(height);
9101
+ const medalDisplayIconSize = height - 2*gap.x;
9102
+ this.renderIcon(vec2(x + gap.x + medalDisplayIconSize/2, y + height/2), medalDisplayIconSize);
9103
+
9104
+ // draw the name
9105
+ const nameSize = height*.5;
9106
+ const descriptionSize = height*.3;
9107
+ const pos = vec2(x + medalDisplayIconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
9108
+ const textWidth = width - medalDisplayIconSize - 3*gap.x;
9109
+ drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
9110
+
9111
+ // draw the description
9112
+ pos.y = y + height - gap.y*2 - descriptionSize/2;
9113
+ drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
9114
+ context.restore();
9115
+ }
9116
+
9117
+ /** Render the icon for a medal
9118
+ * @param {Vector2} pos - Screen space position
9119
+ * @param {number} size - Screen space size
9120
+ */
9121
+ renderIcon(pos, size)
9122
+ {
9123
+ // draw the image or icon
9124
+ if (this.image)
9125
+ mainContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size);
9126
+ else
9127
+ drawTextScreen(this.icon, pos, size*.7, BLACK);
9128
+ }
9129
+
9130
+ // Get local storage key used by the medal
9131
+ storageKey() { return medalsSaveName + '_' + this.id; }
9132
+ }
9133
+
9134
+ ///////////////////////////////////////////////////////////////////////////////
9135
+ // Medals setting setters
9136
+
9137
+ /** Set how long to show medals for in seconds
9138
+ * @param {number} time
9139
+ * @memberof Settings */
9140
+ function setMedalDisplayTime(time) { medalDisplayTime = time; }
9141
+
9142
+ /** Set how quickly to slide on/off medals in seconds
9143
+ * @param {number} time
9144
+ * @memberof Settings */
9145
+ function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
9146
+
9147
+ /** Set size of medal display
9148
+ * @param {Vector2} size
9149
+ * @memberof Settings */
9150
+ function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
9151
+
9152
+ /** Set to stop medals from being unlockable
9153
+ * @param {boolean} preventUnlock
9154
+ * @memberof Settings */
9155
+ function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }
9156
+
9157
+ /**
9158
+ * LittleJS Newgrounds Plugin
9159
+ * - NewgroundsMedal extends Medal with Newgrounds API functionality
9160
+ * - Call new NewgroundsPlugin(app_id) to setup Newgrounds
9161
+ * - Uses CryptoJS for encryption if optional cipher is provided
9162
+ * - provides functions to interact with medals scoreboards
9163
+ * - Keeps connection alive and logs views
9164
+ * @namespace Newgrounds
9165
+ */
9166
+
9167
+ /** Global Newgrounds object
9168
+ * @type {NewgroundsPlugin}
9169
+ * @memberof Newgrounds */
9170
+ let newgrounds;
9171
+
9172
+ ///////////////////////////////////////////////////////////////////////////////
9173
+ /**
9174
+ * Newgrounds medal auto unlocks in newgrounds API
9175
+ * @extends Medal
9176
+ * @memberof Newgrounds
9177
+ */
9178
+ class NewgroundsMedal extends Medal
9179
+ {
9180
+ /** Create a newgrounds medal object and adds it to the list of medals
9181
+ * @param {number} id - The unique identifier of the medal
9182
+ * @param {string} name - Name of the medal
9183
+ * @param {string} [description] - Description of the medal
9184
+ * @param {string} [icon] - Icon for the medal
9185
+ * @param {string} [src] - Image location for the medal
9186
+ */
9187
+ constructor(id, name, description, icon, src)
9188
+ { super(id, name, description, icon, src); }
9189
+
9190
+ /** Unlocks a medal if not already unlocked */
9191
+ unlock()
9192
+ {
9193
+ super.unlock();
9194
+ newgrounds && newgrounds.unlockMedal(this.id);
9195
+ }
9196
+ }
8943
9197
 
8944
9198
  ///////////////////////////////////////////////////////////////////////////////
8945
9199
  /**
@@ -9516,16 +9770,13 @@ class UISystemPlugin
9516
9770
  this.lastHoverObject = undefined;
9517
9771
  /** @property {UIObject} - Current confirm menu being shown */
9518
9772
  this.confirmDialog = undefined;
9519
- /** @property {UIObject} - Object to send keyboard input to */
9520
- this.keyInputObject = undefined;
9773
+ /** @private */
9774
+ this._keyInputObject = undefined;
9775
+ /** @private */
9776
+ this._onKeyDown = (e) => this._keyInputObject?.onKeyDown(e);
9521
9777
 
9522
9778
  engineAddPlugin(uiUpdate, uiRender);
9523
9779
 
9524
- // key down handler
9525
- function onKeyDown(e)
9526
- { uiSystem.keyInputObject?.onKeyDown(e); }
9527
- document.addEventListener('keydown', onKeyDown);
9528
-
9529
9780
  // set object position in parent space
9530
9781
  function updateTransforms(o)
9531
9782
  {
@@ -9867,8 +10118,23 @@ class UISystemPlugin
9867
10118
  return p;
9868
10119
  }
9869
10120
 
10121
+ /** Object to send keyboard input to (typically a UITextInput).
10122
+ * The document keydown listener is only attached while this is set,
10123
+ * so games that never use text input pay no event-handling cost.
10124
+ * @type {UIObject} */
10125
+ get keyInputObject() { return this._keyInputObject; }
10126
+ set keyInputObject(obj)
10127
+ {
10128
+ const had = !!this._keyInputObject;
10129
+ this._keyInputObject = obj;
10130
+ if (!had && obj)
10131
+ document.addEventListener('keydown', this._onKeyDown);
10132
+ else if (had && !obj)
10133
+ document.removeEventListener('keydown', this._onKeyDown);
10134
+ }
10135
+
9870
10136
  /** Destroy and remove all objects
9871
- * @memberof Engine */
10137
+ * @memberof UISystem */
9872
10138
  destroyObjects()
9873
10139
  {
9874
10140
  for (const o of this.uiObjects)
@@ -10291,15 +10557,13 @@ class UIObject
10291
10557
  * @return {string} */
10292
10558
  toString()
10293
10559
  {
10294
- if (!debug) return;
10295
-
10296
10560
  let text = 'type = ' + this.constructor.name;
10297
10561
  if (this.text)
10298
10562
  text += '\ntext = ' + this.text;
10299
10563
  if (this.pos.x || this.pos.y)
10300
10564
  text += '\npos = ' + this.pos;
10301
10565
  if (this.localPos.x || this.localPos.y)
10302
- text += '\localPos = ' + this.localPos;
10566
+ text += '\nlocalPos = ' + this.localPos;
10303
10567
  if (this.size.x || this.size.y)
10304
10568
  text += '\nsize = ' + this.size;
10305
10569
  if (this.color)
@@ -10322,7 +10586,7 @@ class UIObject
10322
10586
 
10323
10587
  /** Internal function called when object is clicked
10324
10588
  * @param {boolean} [playSound] */
10325
- click(playSound)
10589
+ click(playSound=true)
10326
10590
  {
10327
10591
  this.onClick();
10328
10592
  if (playSound && this.soundClick)
@@ -10893,6 +11157,135 @@ class UIVideo extends UIObject
10893
11157
  this.video.remove();
10894
11158
  super.destroy();
10895
11159
  }
11160
+ }
11161
+
11162
+ ///////////////////////////////////////////////////////////////////////////////
11163
+ /**
11164
+ * UILayout - A container that auto-arranges children in a vertical list, horizontal list, or grid
11165
+ * - Set columns to 1 for a vertical list (default)
11166
+ * - Set columns to children.length for a horizontal list
11167
+ * - Set columns to N (1 < N < children.length) for a grid with N columns
11168
+ * - Per-child sizing: each row's height = max child.size.y in that row, each column's width = max child.size.x in that column
11169
+ * - Children are positioned centered in their cell
11170
+ * - Container auto-sizes to fit children plus padding
11171
+ * @extends UIObject
11172
+ * @memberof UISystem
11173
+ */
11174
+ class UILayout extends UIObject
11175
+ {
11176
+ /** Create a UILayout container that auto-arranges children
11177
+ * @param {Vector2} [pos]
11178
+ * @param {number} [columns=1] - Number of columns (1 = vertical list)
11179
+ * @param {number} [gap=10] - Space between children
11180
+ * @param {number} [padding=10] - Space between container border and children
11181
+ * @param {boolean} [transparent=false] - If true, draws no background, outline, or shadow
11182
+ */
11183
+ constructor(pos, columns=1, gap=10, padding=10, transparent=false)
11184
+ {
11185
+ super(pos);
11186
+
11187
+ ASSERT(isNumber(columns) && columns >= 1, 'ui layout columns must be a number >= 1');
11188
+ ASSERT(isNumber(gap), 'ui layout gap must be a number');
11189
+ ASSERT(isNumber(padding), 'ui layout padding must be a number');
11190
+
11191
+ /** @property {number} - Number of columns in the layout */
11192
+ this.columns = columns;
11193
+ /** @property {number} - Space between children */
11194
+ this.gap = gap;
11195
+ /** @property {number} - Space between container border and children */
11196
+ this.padding = padding;
11197
+
11198
+ if (transparent)
11199
+ {
11200
+ // pure positioning helper - skip background, outline, and shadow
11201
+ this.color = CLEAR_BLACK;
11202
+ this.gradientColor = undefined;
11203
+ this.lineWidth = 0;
11204
+ this.shadowColor = CLEAR_BLACK;
11205
+ }
11206
+ this.relayout();
11207
+ }
11208
+
11209
+ /** Add a child UIObject and re-layout
11210
+ * @param {UIObject} child
11211
+ * @return {UIObject} The child object added */
11212
+ addChild(child)
11213
+ {
11214
+ super.addChild(child);
11215
+ this.relayout();
11216
+ return child;
11217
+ }
11218
+
11219
+ /** Remove a child UIObject and re-layout
11220
+ * @param {UIObject} child */
11221
+ removeChild(child)
11222
+ {
11223
+ super.removeChild(child);
11224
+ this.relayout();
11225
+ }
11226
+
11227
+ /** Recompute child positions and container size based on per-child sizes.
11228
+ * Called automatically by addChild and removeChild. Call manually if you
11229
+ * mutate a child's size or change columns, gap, or padding. */
11230
+ relayout()
11231
+ {
11232
+ const n = this.children.length;
11233
+ if (!n)
11234
+ {
11235
+ this.size = vec2(this.padding * 2);
11236
+ return;
11237
+ }
11238
+
11239
+ const cols = this.columns;
11240
+ const rows = ceil(n / cols);
11241
+ const colWidths = new Array(cols).fill(0);
11242
+ const rowHeights = new Array(rows).fill(0);
11243
+
11244
+ // first pass: compute column widths and row heights from child sizes
11245
+ for (let i = 0; i < n; ++i)
11246
+ {
11247
+ const col = i % cols;
11248
+ const row = floor(i / cols);
11249
+ const child = this.children[i];
11250
+ colWidths[col] = max(colWidths[col], child.size.x);
11251
+ rowHeights[row] = max(rowHeights[row], child.size.y);
11252
+ }
11253
+
11254
+ // total content size (sum of column widths/row heights plus gaps between them)
11255
+ let contentWidth = this.gap * (cols - 1);
11256
+ for (const w of colWidths) contentWidth += w;
11257
+ let contentHeight = this.gap * (rows - 1);
11258
+ for (const h of rowHeights) contentHeight += h;
11259
+
11260
+ // cumulative column/row offsets so positioning is O(n) not O(n^2)
11261
+ const colOffsets = new Array(cols);
11262
+ let xAcc = 0;
11263
+ for (let c = 0; c < cols; ++c)
11264
+ {
11265
+ colOffsets[c] = xAcc;
11266
+ xAcc += colWidths[c];
11267
+ }
11268
+ const rowOffsets = new Array(rows);
11269
+ let yAcc = 0;
11270
+ for (let r = 0; r < rows; ++r)
11271
+ {
11272
+ rowOffsets[r] = yAcc;
11273
+ yAcc += rowHeights[r];
11274
+ }
11275
+
11276
+ // second pass: position each child centered in its cell
11277
+ for (let i = 0; i < n; ++i)
11278
+ {
11279
+ const col = i % cols;
11280
+ const row = floor(i / cols);
11281
+ const x = -contentWidth/2 + colOffsets[col] + this.gap * col + colWidths[col] / 2;
11282
+ const y = -contentHeight/2 + rowOffsets[row] + this.gap * row + rowHeights[row] / 2;
11283
+ this.children[i].localPos = vec2(x, y);
11284
+ }
11285
+
11286
+ // container size = content + padding on all sides
11287
+ this.size = vec2(contentWidth + this.padding * 2, contentHeight + this.padding * 2);
11288
+ }
10896
11289
  }
10897
11290
  /**
10898
11291
  * LittleJS Box2D Physics Plugin
@@ -11295,7 +11688,8 @@ class Box2dObject extends EngineObject
11295
11688
  {
11296
11689
  this.pos = pos;
11297
11690
  this.angle = angle;
11298
- this.body.SetTransform(box2d.vec2dTo(pos), angle);
11691
+ // box2d uses reverse angle
11692
+ this.body.SetTransform(box2d.vec2dTo(pos), -angle);
11299
11693
  }
11300
11694
 
11301
11695
  /** Sets the position
@@ -11306,7 +11700,7 @@ class Box2dObject extends EngineObject
11306
11700
  /** Sets the angle
11307
11701
  * @param {number} angle */
11308
11702
  setAngle(angle)
11309
- { this.setTransform(box2d.vec2From(this.body.GetPosition()), -angle); }
11703
+ { this.setTransform(box2d.vec2From(this.body.GetPosition()), angle); }
11310
11704
 
11311
11705
  /** Sets the linear velocity
11312
11706
  * @param {Vector2} velocity */
@@ -11418,14 +11812,29 @@ class Box2dObject extends EngineObject
11418
11812
  this.body.ApplyForce(box2d.vec2dTo(force), box2d.vec2dTo(pos));
11419
11813
  }
11420
11814
 
11421
- /** Apply acceleration to this object
11815
+ /** Apply acceleration to this object (changes velocity by acceleration,
11816
+ * mass-independent — matches EngineObject.applyAcceleration semantics).
11817
+ * Use applyImpulse if you want the mass-dependent velocity change
11818
+ * Δv = impulse / mass, or applyForce for a Newton-style sustained force.
11422
11819
  * @param {Vector2} acceleration
11423
11820
  * @param {Vector2} [pos] */
11424
11821
  applyAcceleration(acceleration, pos)
11425
- {
11822
+ {
11426
11823
  pos ||= this.getCenterOfMass();
11427
11824
  this.setAwake();
11428
- this.body.ApplyLinearImpulse(box2d.vec2dTo(acceleration), box2d.vec2dTo(pos));
11825
+ const impulse = acceleration.scale(this.getMass());
11826
+ this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
11827
+ }
11828
+
11829
+ /** Apply an instantaneous linear impulse. Changes velocity immediately by
11830
+ * impulse / mass (so heavier bodies move less for the same impulse).
11831
+ * @param {Vector2} impulse
11832
+ * @param {Vector2} [pos] */
11833
+ applyImpulse(impulse, pos)
11834
+ {
11835
+ pos ||= this.getCenterOfMass();
11836
+ this.setAwake();
11837
+ this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
11429
11838
  }
11430
11839
 
11431
11840
  /** Apply torque to this object
@@ -11435,13 +11844,23 @@ class Box2dObject extends EngineObject
11435
11844
  this.setAwake();
11436
11845
  this.body.ApplyTorque(torque);
11437
11846
  }
11438
-
11439
- /** Apply angular acceleration to this object
11847
+
11848
+ /** Apply angular acceleration to this object (changes angular velocity by
11849
+ * acceleration, mass-independent — matches EngineObject semantics).
11440
11850
  * @param {number} acceleration */
11441
11851
  applyAngularAcceleration(acceleration)
11442
11852
  {
11443
11853
  this.setAwake();
11444
- this.body.ApplyAngularImpulse(acceleration);
11854
+ this.body.ApplyAngularImpulse(acceleration * this.getInertia());
11855
+ }
11856
+
11857
+ /** Apply an instantaneous angular impulse. Changes angular velocity by
11858
+ * impulse / inertia immediately.
11859
+ * @param {number} impulse */
11860
+ applyAngularImpulse(impulse)
11861
+ {
11862
+ this.setAwake();
11863
+ this.body.ApplyAngularImpulse(impulse);
11445
11864
  }
11446
11865
 
11447
11866
  ///////////////////////////////////////////////////////////////////////////////
@@ -11687,7 +12106,7 @@ class Box2dJoint
11687
12106
 
11688
12107
  /** Check if the connected bodies should collide
11689
12108
  * @return {boolean} */
11690
- getCollideConnected() { return this.box2dJoint.getCollideConnected();}
12109
+ getCollideConnected() { return this.box2dJoint.GetCollideConnected();}
11691
12110
 
11692
12111
  /** Check if either connected body is active
11693
12112
  * @return {boolean} */
@@ -12851,6 +13270,7 @@ async function box2dInit()
12851
13270
  {
12852
13271
  if (o.body)
12853
13272
  {
13273
+ // box2d uses reverse angle
12854
13274
  o.pos = box2d.vec2From(o.body.GetPosition());
12855
13275
  o.angle = -o.body.GetAngle();
12856
13276
  }
@@ -13061,3 +13481,1268 @@ function drawThreeSlice(pos, size, startTile, color, borderSize=1, additiveColor
13061
13481
  drawTile(pos.add(cornerPos.rotate(rotateAngle)), cornerSize, cornerTile, color, a, false, additiveColor, useWebGL, screenSpace, context);
13062
13482
  }
13063
13483
  }
13484
+ /**
13485
+ * LittleJS Tween System Plugin
13486
+ * - Lightweight tweens for numbers, Vector2, Color, or any .lerp-able type
13487
+ * - Chainable easing, looping, and ping-pong
13488
+ * - Property-path helper for the common case of animating an object field
13489
+ * - Auto-updates via engineAddPlugin; pauses with the game by default
13490
+ * @namespace TweenSystem
13491
+ */
13492
+
13493
+ ///////////////////////////////////////////////////////////////////////////////
13494
+
13495
+ // Module-private list of tweens currently running.
13496
+ const tweenActive = [];
13497
+
13498
+ // Time tracking for delta computation between engine plugin calls.
13499
+ let lastTime = 0;
13500
+ let lastTimeReal = 0;
13501
+
13502
+ // True if the value is an instance of a class that exposes a numeric-percent
13503
+ // `lerp(other, percent)` method (Vector2, Color, or any future class).
13504
+ function isLerpable(v) { return v && typeof v.lerp === 'function'; }
13505
+
13506
+ ///////////////////////////////////////////////////////////////////////////////
13507
+
13508
+ /** A numeric tween: drives a callback with a value interpolated between
13509
+ * `start` and `end` over `duration` seconds. Pauses with the game by default.
13510
+ * @memberof TweenSystem
13511
+ * @example
13512
+ * // Animate a fade-out over 2 seconds with an ease-out sine curve.
13513
+ * new Tween((v) => obj.alpha = v, 1, 0, 2, { ease: Ease.OUT(Ease.SINE) });
13514
+ */
13515
+ class Tween
13516
+ {
13517
+ /** Create a new tween. The callback fires immediately with `start` so the
13518
+ * target snaps to the start value on the same frame the tween is created.
13519
+ *
13520
+ * `start` and `end` may be numbers, Vector2 instances, Color instances, or
13521
+ * any object exposing a `lerp(other, percent) => sameType` method. The
13522
+ * callback receives the interpolated value (a number, or a fresh instance
13523
+ * for lerp-able types). Both endpoints must be the same type.
13524
+ * @param {function(number|Vector2|Color):void} callback - Called with the interpolated value each frame
13525
+ * @param {number|Vector2|Color} [start=0] - Starting value
13526
+ * @param {number|Vector2|Color} [end=1] - Ending value
13527
+ * @param {number} [duration=1] - Duration in seconds
13528
+ * @param {Object} [options]
13529
+ * @param {function(number):number} [options.ease] - Easing function (defaults to LINEAR)
13530
+ * @param {boolean} [options.useRealTime=false] - Advance even when the game is paused (matches Timer's useRealTime)
13531
+ * @param {boolean} [options.paused=false] - Start in paused state */
13532
+ constructor(callback, start = 0, end = 1, duration = 1, options = {})
13533
+ {
13534
+ ASSERT(typeof callback === 'function', 'Tween callback must be a function');
13535
+ if (isLerpable(start))
13536
+ {
13537
+ ASSERT(start.constructor === end.constructor,
13538
+ 'Tween start and end must be the same type');
13539
+ }
13540
+ else
13541
+ {
13542
+ ASSERT(isNumber(start), 'Tween start must be a number or have a .lerp method');
13543
+ ASSERT(isNumber(end), 'Tween end must be a number when start is a number');
13544
+ }
13545
+ ASSERT(isNumber(duration) && duration > 0, 'Tween duration must be > 0');
13546
+
13547
+ this.callback = callback;
13548
+ this.start = start;
13549
+ this.end = end;
13550
+ this.duration = duration;
13551
+ this.life = duration;
13552
+ this.ease = options.ease || Ease.LINEAR;
13553
+ this.useRealTime = !!options.useRealTime;
13554
+ this.paused = !!options.paused;
13555
+
13556
+ /** @private completion callback set by then(), loop(), pingPong(). */
13557
+ this.thenCallback = undefined;
13558
+ /** @private remaining iterations including the current run (loop/pingPong only). */
13559
+ this.loopRemaining = 0;
13560
+
13561
+ tweenActive.push(this);
13562
+ // Snap target to start immediately.
13563
+ callback(this.interp(duration));
13564
+ }
13565
+
13566
+ /** Set the easing curve and return this for chaining.
13567
+ * @param {function(number):number} easeFn
13568
+ * @returns {Tween}
13569
+ * @memberof TweenSystem */
13570
+ setEase(easeFn)
13571
+ {
13572
+ this.ease = easeFn;
13573
+ return this;
13574
+ }
13575
+
13576
+ /** Set a single completion callback. Calling `then` again replaces the
13577
+ * previous callback. Returns this for chaining.
13578
+ *
13579
+ * Calling `then` after `loop` or `pingPong` overrides the loop chain
13580
+ * (last call wins).
13581
+ * @param {function():void} callback
13582
+ * @returns {Tween}
13583
+ * @memberof TweenSystem */
13584
+ then(callback)
13585
+ {
13586
+ this.thenCallback = callback;
13587
+ this.loopRemaining = 0;
13588
+ return this;
13589
+ }
13590
+
13591
+ /** Repeat this tween `n` total times. After each iteration finishes, a
13592
+ * fresh tween with the same parameters takes over via the `then` slot.
13593
+ * `loop()` with no argument loops forever.
13594
+ *
13595
+ * Mutually exclusive with `pingPong`; calling either replaces the other,
13596
+ * and calling `then` after either clears the loop (last call wins).
13597
+ * @param {number} [count=Infinity]
13598
+ * @returns {Tween}
13599
+ * @memberof TweenSystem */
13600
+ loop(count = Infinity)
13601
+ {
13602
+ this.loopRemaining = count;
13603
+ this.thenCallback = () => loopContinuation(this);
13604
+ return this;
13605
+ }
13606
+
13607
+ /** Like `loop`, but swap `start` and `end` between iterations so the value
13608
+ * bounces back and forth. `pingPong()` with no argument bounces forever.
13609
+ *
13610
+ * Mutually exclusive with `loop`; calling either replaces the other, and
13611
+ * calling `then` after either clears the loop (last call wins).
13612
+ * @param {number} [count=Infinity]
13613
+ * @returns {Tween}
13614
+ * @memberof TweenSystem */
13615
+ pingPong(count = Infinity)
13616
+ {
13617
+ this.loopRemaining = count;
13618
+ this.thenCallback = () => pingPongContinuation(this);
13619
+ return this;
13620
+ }
13621
+
13622
+ /** Pause this tween. While paused, tweenUpdate skips it.
13623
+ * @memberof TweenSystem */
13624
+ pause() { this.paused = true; }
13625
+
13626
+ /** Resume a paused tween.
13627
+ * @memberof TweenSystem */
13628
+ resume() { this.paused = false; }
13629
+
13630
+ /** Reset this tween to the start: life back to duration, pause cleared,
13631
+ * re-added to the active list if previously stopped, and the callback
13632
+ * re-fired with the start value.
13633
+ * @memberof TweenSystem */
13634
+ restart()
13635
+ {
13636
+ this.life = this.duration;
13637
+ this.paused = false;
13638
+ if (tweenActive.indexOf(this) < 0) tweenActive.push(this);
13639
+ this.callback(this.interp(this.duration));
13640
+ }
13641
+
13642
+ /** True if this tween is in the active list and not paused.
13643
+ * @returns {boolean}
13644
+ * @memberof TweenSystem */
13645
+ isActive()
13646
+ {
13647
+ return !this.paused && tweenActive.indexOf(this) >= 0;
13648
+ }
13649
+
13650
+ /** Get how far this tween has progressed, from 0 (just started) to 1
13651
+ * (completed). Clamped — overshoot past completion still reads 1.
13652
+ * @returns {number}
13653
+ * @memberof TweenSystem */
13654
+ getPercent()
13655
+ {
13656
+ return percent(this.duration - this.life, 0, this.duration);
13657
+ }
13658
+
13659
+ /** Get the current interpolated value (the value most recently passed to
13660
+ * the callback). Returns a number, Vector2, or Color depending on the
13661
+ * tween's start/end types.
13662
+ * @returns {number|Vector2|Color}
13663
+ * @memberof TweenSystem */
13664
+ getValue()
13665
+ {
13666
+ return this.interp(this.life);
13667
+ }
13668
+
13669
+ /** Compute the interpolated value at the given remaining `life`.
13670
+ * At life === duration the result is `start`; at life === 0 it is `end`.
13671
+ * @param {number} life
13672
+ * @returns {number}
13673
+ * @memberof TweenSystem */
13674
+ interp(life)
13675
+ {
13676
+ const x = this.ease((this.duration - life) / this.duration);
13677
+ if (isLerpable(this.start))
13678
+ return this.start.lerp(this.end, x);
13679
+ return this.start + (this.end - this.start) * x;
13680
+ }
13681
+
13682
+ /** Remove this tween from the active list and prevent any pending then-callback.
13683
+ * @memberof TweenSystem */
13684
+ stop()
13685
+ {
13686
+ const i = tweenActive.indexOf(this);
13687
+ if (i >= 0) tweenActive.splice(i, 1);
13688
+ this.thenCallback = undefined;
13689
+ }
13690
+ }
13691
+
13692
+ /** Library of named easing curves and direction modifiers.
13693
+ * All curves accept `x` in [0,1] and return [0,1] (with possible overshoot
13694
+ * for ELASTIC/BACK/SPRING/BOUNCE). Curves are values you pass to `setEase`
13695
+ * or compose via the IN/OUT/IN_OUT/PIECEWISE/BEZIER modifiers.
13696
+ * @memberof TweenSystem
13697
+ * @example
13698
+ * // Use a basic curve
13699
+ * new Tween(callback, 0, 10, 1).setEase(Ease.SINE);
13700
+ * // Use a modifier on a curve
13701
+ * new Tween(callback, 0, 10, 1).setEase(Ease.OUT(Ease.BACK));
13702
+ */
13703
+ const Ease =
13704
+ {
13705
+ /** Linear (identity) curve.
13706
+ * @param {number} x
13707
+ * @returns {number}
13708
+ * @memberof TweenSystem */
13709
+ LINEAR: (x) => x,
13710
+
13711
+ /** Power curve factory: `Ease.POWER(n)` returns `x => x**n`.
13712
+ * Use n=2 for quadratic, n=3 for cubic, etc.
13713
+ * @param {number} n
13714
+ * @returns {function(number):number}
13715
+ * @memberof TweenSystem */
13716
+ POWER: (n) => (x) => x ** n,
13717
+
13718
+ /** Sine ease-in curve: starts slow, ends fast.
13719
+ * @param {number} x
13720
+ * @returns {number}
13721
+ * @memberof TweenSystem */
13722
+ SINE: (x) => 1 - cos(x * (PI / 2)),
13723
+
13724
+ /** Circular ease-in curve.
13725
+ * @param {number} x
13726
+ * @returns {number}
13727
+ * @memberof TweenSystem */
13728
+ CIRC: (x) => 1 - (1 - x * x)**.5,
13729
+
13730
+ /** Exponential ease-in curve (`2^(10x-10)`).
13731
+ * @param {number} x
13732
+ * @returns {number}
13733
+ * @memberof TweenSystem */
13734
+ EXPO: (x) => 2 ** (10 * x - 10),
13735
+
13736
+ /** Back ease-in: overshoots backward at the start before snapping forward.
13737
+ * @param {number} x
13738
+ * @returns {number}
13739
+ * @memberof TweenSystem */
13740
+ BACK: (x) => x * x * (2.70158 * x - 1.70158),
13741
+
13742
+ /** Elastic ease-in: oscillates with decreasing amplitude.
13743
+ * @param {number} x
13744
+ * @returns {number}
13745
+ * @memberof TweenSystem */
13746
+ ELASTIC: (x) =>
13747
+ -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
13748
+
13749
+ /** Spring-like ease-out: oscillates outward after passing the target.
13750
+ * @param {number} x
13751
+ * @returns {number}
13752
+ * @memberof TweenSystem */
13753
+ SPRING: (x) =>
13754
+ 1 -
13755
+ (sin(PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
13756
+ x ** 2.2 +
13757
+ (1 - x)) *
13758
+ (1.0 + 1.2 * x),
13759
+
13760
+ /** Bouncing ease-in: slow ramp with bouncing impacts near the end.
13761
+ * Symmetric with the other base curves, which are all ease-in. To get the
13762
+ * classic "object falls and hits the ground" shape (bounces near x=1),
13763
+ * wrap with `Ease.OUT`: `Ease.OUT(Ease.BOUNCE)`.
13764
+ * @param {number} x
13765
+ * @returns {number}
13766
+ * @memberof TweenSystem
13767
+ * @example
13768
+ * Ease.BOUNCE // ease-in bounce (slow, then bouncy at end)
13769
+ * Ease.OUT(Ease.BOUNCE) // ease-out bounce (object hits ground)
13770
+ * Ease.IN_OUT(Ease.BOUNCE) // bounces at both ends
13771
+ */
13772
+ BOUNCE: (x) =>
13773
+ {
13774
+ // Inverted form of the standard easeOutBounce: 1 - bounceOut(1 - x).
13775
+ let t = 1 - x, f;
13776
+ if (t < 4 / 11) f = 7.5625 * t * t;
13777
+ else if (t < 8 / 11) f = 7.5625 * (t -= 6 / 11) * t + 0.75;
13778
+ else if (t < 10 / 11) f = 7.5625 * (t -= 9 / 11) * t + 0.9375;
13779
+ else f = 7.5625 * (t -= 10.5 / 11) * t + 0.984375;
13780
+ return 1 - f;
13781
+ },
13782
+
13783
+ /** Ease-in direction modifier: returns the curve unchanged. Symmetric
13784
+ * with `OUT` and `IN_OUT`. Base curves are already ease-in by
13785
+ * convention, so wrapping a curve in `IN` is a no-op — useful when
13786
+ * picking the direction programmatically.
13787
+ * @param {function(number):number} f - Curve to use as ease-in (returned unchanged)
13788
+ * @returns {function(number):number}
13789
+ * @memberof TweenSystem
13790
+ * @example
13791
+ * // Pick direction at runtime
13792
+ * const dir = bouncyMode ? Ease.OUT : Ease.IN;
13793
+ * new Tween(cb, 0, 10, 1).setEase(dir(Ease.BACK));
13794
+ */
13795
+ IN: (f) => f,
13796
+
13797
+ /** Reverse a curve so it eases out instead of in: `x => 1 - f(1 - x)`.
13798
+ * @param {function(number):number} f
13799
+ * @returns {function(number):number}
13800
+ * @memberof TweenSystem
13801
+ * @example
13802
+ * Ease.OUT(Ease.POWER(2)) // ease-out quadratic
13803
+ */
13804
+ OUT: (f) => (x) => 1 - f(1 - x),
13805
+
13806
+ /** Combine the first half of `f` with `Ease.OUT(f)` for a symmetric curve.
13807
+ * Bug-fix vs the original library: the original referenced an undefined
13808
+ * global `Piecewise`; this implementation routes through `Ease.PIECEWISE`.
13809
+ * @param {function(number):number} f
13810
+ * @returns {function(number):number}
13811
+ * @memberof TweenSystem */
13812
+ IN_OUT: (f) => Ease.PIECEWISE(f, Ease.OUT(f)),
13813
+
13814
+ /** Split [0,1] into N equal sections and run a different curve in each.
13815
+ * Each curve is mapped to its section: section i runs over [i/n, (i+1)/n]
13816
+ * and its output is mapped to [i/n, (i+1)/n] of the overall range.
13817
+ * @param {...function(number):number} fns
13818
+ * @returns {function(number):number}
13819
+ * @memberof TweenSystem */
13820
+ PIECEWISE: (...fns) =>
13821
+ {
13822
+ const n = fns.length;
13823
+ return (x) =>
13824
+ {
13825
+ const i = (x * n - 1e-9) >> 0;
13826
+ return (fns[i]((x - i / n) * n) + i) / n;
13827
+ };
13828
+ },
13829
+
13830
+ /** Cubic Bezier curve solver in the style of CSS `cubic-bezier`.
13831
+ * Control points (0,0), (x1,y1), (x2,y2), (1,1).
13832
+ * @param {number} x1
13833
+ * @param {number} y1
13834
+ * @param {number} x2
13835
+ * @param {number} y2
13836
+ * @returns {function(number):number}
13837
+ * @memberof TweenSystem
13838
+ * @example
13839
+ * Ease.BEZIER(0.25, 0.1, 0.25, 1) // CSS "ease"
13840
+ */
13841
+ BEZIER: (x1, y1, x2, y2) =>
13842
+ {
13843
+ // Parametric cubic Bezier with implicit (0,0) and (1,1) endpoints.
13844
+ const curve = (t) =>
13845
+ {
13846
+ const u = 1 - t;
13847
+ const c1 = 3 * u * u * t;
13848
+ const c2 = 3 * u * t * t;
13849
+ const t3 = t ** 3;
13850
+ return [c1 * x1 + c2 * x2 + t3, c1 * y1 + c2 * y2 + t3];
13851
+ };
13852
+ return (x) =>
13853
+ {
13854
+ // Binary search for t such that curve(t).x ≈ x, then return curve(t).y.
13855
+ let t0 = 0, t1 = 1;
13856
+ for (let i = 0; i < 128; i++)
13857
+ {
13858
+ const tMid = (t0 + t1) / 2;
13859
+ const [bx, by] = curve(tMid);
13860
+ if (abs(bx - x) < 1e-5) return by;
13861
+ if (bx < x) t0 = tMid; else t1 = tMid;
13862
+ }
13863
+ return curve((t0 + t1) / 2)[1];
13864
+ };
13865
+ },
13866
+ };
13867
+
13868
+ /** Tween a property on an object by dot-path. Returns the underlying Tween
13869
+ * so all chaining methods (`setEase`, `then`, `loop`, `pingPong`, etc.)
13870
+ * remain available.
13871
+ *
13872
+ * `start` and `end` may be numbers, Vector2 instances, Color instances, or
13873
+ * any object with a `lerp(other, percent) => sameType` method.
13874
+ * @param {Object} target - The object whose property is being animated
13875
+ * @param {string} propertyPath - Dot-separated path, e.g. `'pos.x'` or `'color'`
13876
+ * @param {number|Vector2|Color} start - Starting value
13877
+ * @param {number|Vector2|Color} end - Ending value
13878
+ * @param {number} [duration=1] - Duration in seconds
13879
+ * @param {Object} [options] - Same options as the Tween constructor
13880
+ * @returns {Tween}
13881
+ * @memberof TweenSystem
13882
+ * @example
13883
+ * // Numeric: slide an object's x with an ease-out sine curve
13884
+ * tweenProperty(player, 'pos.x', 0, 10, 2).setEase(Ease.OUT(Ease.SINE));
13885
+ * // Vector2: animate a position diagonally
13886
+ * tweenProperty(player, 'pos', vec2(-5, 0), vec2(5, 3), 2);
13887
+ * // Color: pulse between two colors
13888
+ * tweenProperty(sprite, 'color', RED, BLUE, 1).pingPong();
13889
+ */
13890
+ function tweenProperty(target, propertyPath, start, end, duration = 1, options = {})
13891
+ {
13892
+ ASSERT(target != null && typeof target === 'object', 'tweenProperty target must be an object');
13893
+ ASSERT(isString(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string');
13894
+
13895
+ const parts = propertyPath.split('.');
13896
+ const lastKey = parts.pop();
13897
+ const callback = (value) =>
13898
+ {
13899
+ let obj = target;
13900
+ for (const k of parts) obj = obj[k];
13901
+ obj[lastKey] = value;
13902
+ };
13903
+ return new Tween(callback, start, end, duration, options);
13904
+ }
13905
+
13906
+ // Continuation that schedules the next loop iteration when one finishes.
13907
+ // Called from the completed tween's `then` slot. Decrements the counter and
13908
+ // only spawns a new tween if more iterations remain.
13909
+ function loopContinuation(prev)
13910
+ {
13911
+ if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
13912
+ const next = new Tween(prev.callback, prev.start, prev.end, prev.duration,
13913
+ { ease: prev.ease, useRealTime: prev.useRealTime });
13914
+ next.loopRemaining = prev.loopRemaining === Infinity
13915
+ ? Infinity
13916
+ : prev.loopRemaining - 1;
13917
+ next.thenCallback = () => loopContinuation(next);
13918
+ }
13919
+
13920
+ // Continuation for pingPong: spawns a new tween with start and end swapped.
13921
+ function pingPongContinuation(prev)
13922
+ {
13923
+ if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
13924
+ const next = new Tween(prev.callback, prev.end, prev.start, prev.duration,
13925
+ { ease: prev.ease, useRealTime: prev.useRealTime });
13926
+ next.loopRemaining = prev.loopRemaining === Infinity
13927
+ ? Infinity
13928
+ : prev.loopRemaining - 1;
13929
+ next.thenCallback = () => pingPongContinuation(next);
13930
+ }
13931
+
13932
+ /** Engine plugin hook: advance every active tween by the appropriate delta.
13933
+ * Called once per render frame by the engine (no arguments). May also be
13934
+ * called explicitly with `(gameDelta, realDelta)` to drive tweens manually
13935
+ * — useful for headless tests or custom replay/scrubbing systems.
13936
+ * @param {number} [gameDelta] - Game-time delta in seconds; default: time - lastTime
13937
+ * @param {number} [realDelta] - Real-time delta in seconds; default: timeReal - lastTimeReal
13938
+ * @memberof TweenSystem */
13939
+ function tweenUpdate(gameDelta, realDelta)
13940
+ {
13941
+ if (gameDelta === undefined)
13942
+ {
13943
+ // Engine path: compute deltas from engine time globals.
13944
+ gameDelta = time - lastTime;
13945
+ realDelta = timeReal - lastTimeReal;
13946
+ lastTime = time;
13947
+ lastTimeReal = timeReal;
13948
+ }
13949
+ else if (realDelta === undefined)
13950
+ {
13951
+ // Manual path with one arg: real and game advance together.
13952
+ realDelta = gameDelta;
13953
+ }
13954
+
13955
+ // Iterate in reverse so removals don't disturb iteration.
13956
+ for (let i = tweenActive.length; i--;)
13957
+ {
13958
+ const t = tweenActive[i];
13959
+ if (t.paused) continue;
13960
+ const dt = t.useRealTime ? realDelta : gameDelta;
13961
+ if (dt <= 0) continue;
13962
+
13963
+ t.life -= dt;
13964
+ if (t.life > 0)
13965
+ {
13966
+ t.callback(t.interp(t.life));
13967
+ }
13968
+ else
13969
+ {
13970
+ // Completion: fire end value, remove from active, fire then-callback.
13971
+ t.callback(t.interp(0));
13972
+ tweenActive.splice(i, 1);
13973
+ const cb = t.thenCallback;
13974
+ t.thenCallback = undefined;
13975
+ if (cb) cb();
13976
+ }
13977
+ }
13978
+ }
13979
+
13980
+ /** Stop every active tween and clear their then-callbacks. Useful for resets
13981
+ * on level transitions or when changing scenes.
13982
+ * @memberof TweenSystem */
13983
+ function tweenStopAll()
13984
+ {
13985
+ for (const t of tweenActive) t.thenCallback = undefined;
13986
+ tweenActive.length = 0;
13987
+ }
13988
+
13989
+ // Register with the engine so tweens auto-advance.
13990
+ engineAddPlugin(tweenUpdate);
13991
+
13992
+ /**
13993
+ * LittleJS PathFinder Plugin
13994
+ * - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths
13995
+ * - Works directly on a TileCollisionLayer, or override isWalkable/getCost for any grid
13996
+ * - Debug visualization via engine debug primitives (stripped in release builds)
13997
+ * - Port of frankforce.com pathFindingBase.cpp (2018)
13998
+ * @namespace PathFinding
13999
+ */
14000
+
14001
+ ///////////////////////////////////////////////////////////////////////////////
14002
+
14003
+ // Diagonal step cost — pre-computed for the A* expansion inner loop.
14004
+ const PATHFINDER_DIAGONAL_COST = Math.SQRT2;
14005
+
14006
+ // Shared 1x1 size vector for per-tile debugRect calls. debugRect copies the
14007
+ // argument internally, so reusing one instance is safe.
14008
+ const PATHFINDER_TILE_VEC = vec2(1);
14009
+
14010
+ ///////////////////////////////////////////////////////////////////////////////
14011
+
14012
+ /** A single grid cell tracked by the pathfinder. Allocated once per cell at
14013
+ * PathFinder construction; reset (not reallocated) at the start of every
14014
+ * findPath call.
14015
+ * @memberof PathFinding */
14016
+ class PathFinderNode
14017
+ {
14018
+ /** @param {number} x - Tile x
14019
+ * @param {number} y - Tile y */
14020
+ constructor(x, y)
14021
+ {
14022
+ /** @property {Vector2} - Tile coords (integer) */
14023
+ this.pos = vec2(x, y);
14024
+ /** @property {Vector2} - World-space center of this tile (set by buildNodeData) */
14025
+ this.posWorld = vec2();
14026
+ /** @property {boolean} - True if this cell is passable (cleared each findPath call) */
14027
+ this.walkable = false;
14028
+ /** @property {number} - Extra cost added to A* G-score for stepping on this cell */
14029
+ this.cost = 0;
14030
+ /** @property {number} - A* G-score: actual cost from start to this node */
14031
+ this.g = 0;
14032
+ /** @property {number} - A* F-score: G + heuristic */
14033
+ this.f = 0;
14034
+ /** @property {PathFinderNode|null} - Parent for path reconstruction */
14035
+ this.parent = null;
14036
+ /** @property {boolean} - In the A* open list */
14037
+ this.isOpen = false;
14038
+ /** @property {boolean} - In the A* closed list */
14039
+ this.isClosed = false;
14040
+ }
14041
+
14042
+ /** Reset per-search state (called at the start of buildNodeData). */
14043
+ reset()
14044
+ {
14045
+ this.walkable = false;
14046
+ this.cost = 0;
14047
+ this.g = 0;
14048
+ this.f = 0;
14049
+ this.parent = null;
14050
+ this.isOpen = false;
14051
+ this.isClosed = false;
14052
+ }
14053
+
14054
+ /** True if walkable and not blocked by cost. */
14055
+ isClear()
14056
+ {
14057
+ return this.walkable && this.cost === 0;
14058
+ }
14059
+ }
14060
+
14061
+ ///////////////////////////////////////////////////////////////////////////////
14062
+
14063
+ /** Grid pathfinder using A* with two optional smoothing passes.
14064
+ * @memberof PathFinding
14065
+ * @example
14066
+ * // Tile-layer driven (most common):
14067
+ * const pf = new PathFinder(myTileCollisionLayer);
14068
+ * const path = pf.findPath(player.pos, mousePos);
14069
+ *
14070
+ * // Bare grid with custom walkability:
14071
+ * const pf = new PathFinder(vec2(50, 50));
14072
+ * pf.isWalkable = (x, y) => myGrid[y*50 + x] === 0;
14073
+ */
14074
+ class PathFinder
14075
+ {
14076
+ /** @param {TileCollisionLayer|Vector2} source - Either a TileCollisionLayer
14077
+ * (size and walkability auto-derived) or a Vector2 grid size (user
14078
+ * overrides isWalkable). */
14079
+ constructor(source)
14080
+ {
14081
+ // Accept either a Vector2 size or a TileCollisionLayer (which has a .size).
14082
+ // We don't import TileCollisionLayer to avoid coupling; we duck-type on
14083
+ // .size + .getCollisionData.
14084
+ if (isVector2(source))
14085
+ {
14086
+ this.size = source.floor();
14087
+ this.tileLayer = undefined;
14088
+ }
14089
+ else
14090
+ {
14091
+ ASSERT(source && isVector2(source.size) && typeof source.getCollisionData === 'function',
14092
+ 'PathFinder requires a Vector2 size or a TileCollisionLayer');
14093
+ this.size = source.size;
14094
+ this.tileLayer = source;
14095
+ }
14096
+
14097
+ // Tunables (public, freely re-assignable).
14098
+ this.heuristicWeight = 1;
14099
+ this.maxLoop = 500;
14100
+ this.smoothPath = true;
14101
+ this.debug = false;
14102
+ this.debugTime = 2;
14103
+
14104
+ // Pre-allocate the node array — one node per tile, reused across calls.
14105
+ this.nodes = new Array(this.size.x * this.size.y);
14106
+ for (let y = 0; y < this.size.y; ++y)
14107
+ for (let x = 0; x < this.size.x; ++x)
14108
+ this.nodes[x + y * this.size.x] = new PathFinderNode(x, y);
14109
+
14110
+ // Scratch Vector2 reused to avoid allocations in the isWalkable hot path.
14111
+ this.collisionScratch = vec2();
14112
+ }
14113
+
14114
+ /** Default walkability: if a tile layer was provided, returns true when the
14115
+ * cell has no solid collision data; otherwise returns true. Override on
14116
+ * the instance or via a subclass.
14117
+ * @param {number} x - Tile x
14118
+ * @param {number} y - Tile y
14119
+ * @returns {boolean} */
14120
+ isWalkable(x, y)
14121
+ {
14122
+ if (!this.tileLayer) return true;
14123
+ return !this.tileLayer.getCollisionData(this.collisionScratch.set(x, y));
14124
+ }
14125
+
14126
+ /** Default extra cost for stepping on a cell. Returns 0 (free) by default.
14127
+ * Override to add cost-weighted terrain (mud, swamp, etc).
14128
+ * @param {number} x - Tile x
14129
+ * @param {number} y - Tile y
14130
+ * @returns {number} */
14131
+ getCost(x, y)
14132
+ {
14133
+ return 0;
14134
+ }
14135
+
14136
+ /** Get the node at tile coords, or null if out of bounds.
14137
+ * @param {number} x
14138
+ * @param {number} y
14139
+ * @returns {PathFinderNode|null} */
14140
+ getNode(x, y)
14141
+ {
14142
+ if (x < 0 || y < 0 || x >= this.size.x || y >= this.size.y) return null;
14143
+ return this.nodes[x + y * this.size.x];
14144
+ }
14145
+
14146
+ /** Convert a world-space position to integer tile coords (no clamping).
14147
+ * @param {Vector2} worldPos
14148
+ * @returns {Vector2}
14149
+ * @memberof PathFinding */
14150
+ worldToTile(worldPos)
14151
+ {
14152
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
14153
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
14154
+ return vec2(floor(worldPos.x - ox), floor(worldPos.y - oy));
14155
+ }
14156
+
14157
+ /** Convert integer tile coords to the world-space center of that tile.
14158
+ * @param {number} x
14159
+ * @param {number} y
14160
+ * @returns {Vector2}
14161
+ * @memberof PathFinding */
14162
+ tileToWorld(x, y)
14163
+ {
14164
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
14165
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
14166
+ return vec2(x + 0.5 + ox, y + 0.5 + oy);
14167
+ }
14168
+
14169
+ /** Reset all nodes and re-populate walkable / cost / posWorld from the
14170
+ * current isWalkable / getCost overrides. Called at the start of
14171
+ * findPath; exposed so tests and tooling can drive it directly.
14172
+ * @private */
14173
+ buildNodeData()
14174
+ {
14175
+ const w = this.size.x;
14176
+ const h = this.size.y;
14177
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
14178
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
14179
+ for (let y = 0; y < h; ++y)
14180
+ for (let x = 0; x < w; ++x)
14181
+ {
14182
+ const node = this.nodes[x + y * w];
14183
+ node.reset();
14184
+ const walkable = !!this.isWalkable(x, y);
14185
+ const cost = walkable ? max(0, this.getCost(x, y)) : 0;
14186
+ node.walkable = walkable;
14187
+ node.cost = cost;
14188
+ node.posWorld.set(x + 0.5 + ox, y + 0.5 + oy);
14189
+
14190
+ if (this.debug && this.debugTime > 0)
14191
+ {
14192
+ if (!walkable)
14193
+ debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, 0.25), this.debugTime);
14194
+ else if (cost > 0)
14195
+ debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, min(0.2, cost * 0.05)), this.debugTime);
14196
+ }
14197
+ }
14198
+ }
14199
+
14200
+ /** Core A* search loop. Expects buildNodeData() to have been called first.
14201
+ * Marks node.parent for path reconstruction. Returns true if endNode was
14202
+ * reached; false on disconnected goal or maxLoop exhaustion.
14203
+ * @param {PathFinderNode} startNode
14204
+ * @param {PathFinderNode} endNode
14205
+ * @returns {boolean}
14206
+ * @private */
14207
+ aStarSearch(startNode, endNode)
14208
+ {
14209
+ ASSERT(startNode && endNode, 'aStarSearch needs both endpoints');
14210
+ ASSERT(startNode !== endNode, 'aStarSearch: start and end must differ — caller should handle trivial case');
14211
+ ASSERT(startNode.walkable && endNode.walkable, 'aStarSearch: endpoints must be walkable');
14212
+
14213
+ const openList = [startNode];
14214
+ startNode.isOpen = true;
14215
+ let loopCount = 0;
14216
+
14217
+ while (openList.length > 0)
14218
+ {
14219
+ // Find the open node with the smallest f score (linear scan).
14220
+ // Same as the C++ — fine up to a few thousand nodes.
14221
+ let bestIndex = 0;
14222
+ let bestF = openList[0].f;
14223
+ for (let i = 1; i < openList.length; ++i)
14224
+ {
14225
+ if (openList[i].f < bestF)
14226
+ {
14227
+ bestF = openList[i].f;
14228
+ bestIndex = i;
14229
+ }
14230
+ }
14231
+ const current = openList[bestIndex];
14232
+
14233
+ if (current === endNode) break;
14234
+ if (++loopCount > this.maxLoop) break;
14235
+
14236
+ // Move current from open to closed.
14237
+ current.isOpen = false;
14238
+ openList.splice(bestIndex, 1);
14239
+ current.isClosed = true;
14240
+
14241
+ if (this.debug && this.debugTime > 0)
14242
+ debugRect(current.posWorld, PATHFINDER_TILE_VEC, rgb(1, 1, 1, 0.05), this.debugTime);
14243
+
14244
+ // Expand all 8 neighbors.
14245
+ for (let dy = -1; dy <= 1; ++dy)
14246
+ for (let dx = -1; dx <= 1; ++dx)
14247
+ {
14248
+ if (dx === 0 && dy === 0) continue;
14249
+ const neighbor = this.getNode(current.pos.x + dx, current.pos.y + dy);
14250
+ if (!neighbor || !neighbor.walkable || neighbor.isClosed) continue;
14251
+
14252
+ let stepCost = 1;
14253
+ if (dx !== 0 && dy !== 0)
14254
+ {
14255
+ // Diagonal step: refuse if either cardinal neighbor is
14256
+ // blocked or has cost. Prevents cutting through corners.
14257
+ const card1 = this.getNode(current.pos.x + dx, current.pos.y);
14258
+ if (!card1 || card1.cost > 0 || !card1.walkable) continue;
14259
+ const card2 = this.getNode(current.pos.x, current.pos.y + dy);
14260
+ if (!card2 || card2.cost > 0 || !card2.walkable) continue;
14261
+ stepCost = PATHFINDER_DIAGONAL_COST;
14262
+ }
14263
+
14264
+ const tentativeG = current.g + stepCost + neighbor.cost;
14265
+ if (!neighbor.isOpen)
14266
+ {
14267
+ neighbor.isOpen = true;
14268
+ openList.push(neighbor);
14269
+ }
14270
+ else if (tentativeG >= neighbor.g)
14271
+ {
14272
+ continue;
14273
+ }
14274
+
14275
+ // Best path so far through neighbor — record it.
14276
+ neighbor.parent = current;
14277
+ neighbor.g = tentativeG;
14278
+ const gdx = endNode.pos.x - neighbor.pos.x;
14279
+ const gdy = endNode.pos.y - neighbor.pos.y;
14280
+ neighbor.f = neighbor.g + (gdx * gdx + gdy * gdy) * this.heuristicWeight;
14281
+ }
14282
+ }
14283
+
14284
+ return endNode.parent !== null;
14285
+ }
14286
+
14287
+ /** Find the clear (walkable, zero-cost) node closest to the given world
14288
+ * position. Spirals outward in expanding boxes until a clear node is
14289
+ * found or the search range is exhausted. Useful for snapping a click
14290
+ * or NPC spawn position to the nearest open tile.
14291
+ *
14292
+ * By default, calls `buildNodeData()` first so it works correctly on a
14293
+ * fresh PathFinder. If you're calling it many times in a row with
14294
+ * unchanged walkability, pass `rebuild=false` and call `buildNodeData()`
14295
+ * once externally to avoid redundant work.
14296
+ * @param {Vector2} worldPos
14297
+ * @param {number} [searchRange=10] - Max box-radius in tiles
14298
+ * @param {boolean} [rebuild=true] - Whether to call buildNodeData first
14299
+ * @returns {PathFinderNode|null}
14300
+ * @memberof PathFinding */
14301
+ getNearestClearNode(worldPos, searchRange = 10, rebuild = true)
14302
+ {
14303
+ ASSERT(isVector2(worldPos), 'worldPos must be a Vector2');
14304
+ if (rebuild) this.buildNodeData();
14305
+
14306
+ // Inline worldToTile to avoid a Vector2 allocation per call.
14307
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
14308
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
14309
+ const centerX = floor(worldPos.x - ox);
14310
+ const centerY = floor(worldPos.y - oy);
14311
+
14312
+ for (let offset = 0; offset <= searchRange; ++offset)
14313
+ {
14314
+ let nearest = null;
14315
+ let nearestDistSq = 0;
14316
+
14317
+ for (let dy = -offset; dy <= offset; ++dy)
14318
+ for (let dx = -offset; dx <= offset; ++dx)
14319
+ {
14320
+ // Only scan the perimeter of the current ring (skip the
14321
+ // interior we've already searched in earlier iterations).
14322
+ if (offset > 0 && abs(dx) !== offset && abs(dy) !== offset)
14323
+ continue;
14324
+
14325
+ const node = this.getNode(centerX + dx, centerY + dy);
14326
+ if (!node || !node.isClear()) continue;
14327
+
14328
+ const ddx = node.posWorld.x - worldPos.x;
14329
+ const ddy = node.posWorld.y - worldPos.y;
14330
+ const distSq = ddx * ddx + ddy * ddy;
14331
+ if (!nearest || distSq < nearestDistSq)
14332
+ {
14333
+ nearest = node;
14334
+ nearestDistSq = distSq;
14335
+ }
14336
+ }
14337
+ if (nearest) return nearest;
14338
+ }
14339
+ return null;
14340
+ }
14341
+
14342
+ /** Smooth a node path by removing redundant turns and tightening corners
14343
+ * where a grid-aligned diagonal is clear. Modifies the path in place.
14344
+ * Stays on the grid — does not introduce off-tile-center points.
14345
+ * Port of ShortenPath() in pathFinding.cpp.
14346
+ * @param {PathFinderNode[]} path
14347
+ * @private */
14348
+ smoothPathCorners(path)
14349
+ {
14350
+ if (path.length <= 2) return;
14351
+
14352
+ let i = 1;
14353
+ while (i < path.length - 1)
14354
+ {
14355
+ const prev = path[i - 1];
14356
+ const node = path[i];
14357
+ const next = path[i + 1];
14358
+
14359
+ const dx = next.pos.x - prev.pos.x;
14360
+ const dy = next.pos.y - prev.pos.y;
14361
+ const lenSq = dx * dx + dy * dy;
14362
+
14363
+ // dx,dy is the prev-to-current step direction; needed for the
14364
+ // 135° "mostly vertical/horizontal" disambiguation.
14365
+ const stepDx = node.pos.x - prev.pos.x;
14366
+ const stepDy = node.pos.y - prev.pos.y;
14367
+ const stepDxNext = next.pos.x - node.pos.x;
14368
+ const stepDyNext = next.pos.y - node.pos.y;
14369
+
14370
+ if (lenSq === 1)
14371
+ {
14372
+ // 45° angle — middle node is off the straight line. Drop it.
14373
+ if (this.debug && this.debugTime > 0)
14374
+ debugCircle(node.posWorld, 0.3, rgb(0.5, 0, 0.5, 0.5), this.debugTime);
14375
+ path.splice(i, 1);
14376
+ i = max(1, i - 1);
14377
+ continue;
14378
+ }
14379
+ else if (lenSq === 2)
14380
+ {
14381
+ // 90° corner. Check the alternative-diagonal cell.
14382
+ if (this.debug && this.debugTime > 0)
14383
+ debugCircle(node.posWorld, 0.3, rgb(1, 0, 0, 0.5), this.debugTime);
14384
+
14385
+ let sx, sy;
14386
+ if (prev.pos.y === node.pos.y && next.pos.x === node.pos.x)
14387
+ { sx = prev.pos.x; sy = next.pos.y; }
14388
+ else
14389
+ { sx = next.pos.x; sy = prev.pos.y; }
14390
+
14391
+ const shortcut = this.getNode(sx, sy);
14392
+ if (shortcut && shortcut.isClear())
14393
+ {
14394
+ path.splice(i, 1);
14395
+ i = max(1, i - 1);
14396
+ continue;
14397
+ }
14398
+ }
14399
+ else if (lenSq === 5)
14400
+ {
14401
+ // 135° angle (a knight's-move offset). Try to relocate the
14402
+ // middle node to whichever of two candidate cells is closer
14403
+ // to prev-of-prev, and only if the corner cut is also clear.
14404
+ if (this.debug && this.debugTime > 0)
14405
+ debugCircle(node.posWorld, 0.3, rgb(1, 1, 0, 0.5), this.debugTime);
14406
+
14407
+ const prevPrev = i >= 2 ? path[i - 2] : prev;
14408
+ let s1x, s1y, s2x, s2y;
14409
+ if (stepDx === 0 || stepDxNext === 0)
14410
+ {
14411
+ // mostly vertical
14412
+ s1x = next.pos.x; s1y = node.pos.y;
14413
+ s2x = prev.pos.x; s2y = node.pos.y;
14414
+ }
14415
+ else
14416
+ {
14417
+ // mostly horizontal
14418
+ s1x = node.pos.x; s1y = next.pos.y;
14419
+ s2x = node.pos.x; s2y = prev.pos.y;
14420
+ }
14421
+ const dd1x = s1x - prevPrev.pos.x;
14422
+ const dd1y = s1y - prevPrev.pos.y;
14423
+ const dd2x = s2x - prevPrev.pos.x;
14424
+ const dd2y = s2y - prevPrev.pos.y;
14425
+ const dist1Sq = dd1x * dd1x + dd1y * dd1y;
14426
+ const dist2Sq = dd2x * dd2x + dd2y * dd2y;
14427
+ const sx = dist1Sq < dist2Sq ? s1x : s1x === s2x && s1y === s2y ? s1x : s2x;
14428
+ const sy = dist1Sq < dist2Sq ? s1y : s1x === s2x && s1y === s2y ? s1y : s2y;
14429
+
14430
+ const shortcut = this.getNode(sx, sy);
14431
+ if (shortcut && shortcut !== node && shortcut.isClear())
14432
+ {
14433
+ // Also check the cut-corner cell is clear.
14434
+ const ccx = next.pos.x + s2x - s1x;
14435
+ const ccy = next.pos.y + s2y - s1y;
14436
+ const cutCorner = this.getNode(ccx, ccy);
14437
+ if (cutCorner && cutCorner.isClear())
14438
+ {
14439
+ path[i] = shortcut;
14440
+ i = max(1, i - 1);
14441
+ continue;
14442
+ }
14443
+ }
14444
+ }
14445
+ else if (lenSq === 4 || lenSq === 8)
14446
+ {
14447
+ // Straight line or a 1-cell bump.
14448
+ if (this.debug && this.debugTime > 0)
14449
+ debugCircle(node.posWorld, 0.3, rgb(0, 1, 0, 0.5), this.debugTime);
14450
+
14451
+ if (stepDx === stepDxNext && stepDy === stepDyNext)
14452
+ {
14453
+ // Truly straight — nothing to do, advance.
14454
+ ++i;
14455
+ continue;
14456
+ }
14457
+ else
14458
+ {
14459
+ // Bump — try to flatten via the in-line cell.
14460
+ let sx, sy;
14461
+ if (prev.pos.y === next.pos.y)
14462
+ { sx = node.pos.x; sy = prev.pos.y; }
14463
+ else
14464
+ { sx = prev.pos.x; sy = node.pos.y; }
14465
+ const shortcut = this.getNode(sx, sy);
14466
+ if (shortcut && shortcut.isClear())
14467
+ {
14468
+ path[i] = shortcut;
14469
+ i = max(1, i - 1);
14470
+ continue;
14471
+ }
14472
+ }
14473
+ }
14474
+
14475
+ ++i;
14476
+ }
14477
+ }
14478
+
14479
+ /** Smooth a node path via line-of-sight ("string pulling"). Walks the
14480
+ * input path collapsing runs of nodes into straight segments whenever
14481
+ * isLineClear permits, so the result can leave grid centers and cut
14482
+ * cleanly across open spaces.
14483
+ *
14484
+ * Bails (leaves the path unchanged) if any node has nonzero cost — a
14485
+ * straight geometric shortcut can't be trusted to be the lowest-cost
14486
+ * route when cost-weighted terrain is in play.
14487
+ *
14488
+ * Port of ShortenPath2() in pathFinding.cpp.
14489
+ * @param {PathFinderNode[]} path
14490
+ * @private */
14491
+ smoothPathStringPull(path)
14492
+ {
14493
+ if (path.length <= 2) return;
14494
+ for (const n of path)
14495
+ {
14496
+ if (!n.isClear()) return;
14497
+ }
14498
+
14499
+ const original = path.slice();
14500
+ path.length = 0;
14501
+ path.push(original[0]);
14502
+ let searchIndex = 0;
14503
+
14504
+ for (let i = 1; i < original.length; ++i)
14505
+ {
14506
+ const node = original[i];
14507
+
14508
+ // Skip if node is collinear with the search-window start and the
14509
+ // previous node — it adds no information. Note: a == b is the
14510
+ // degenerate i=1, searchIndex=0 case; skip the test then.
14511
+ {
14512
+ const a = original[searchIndex];
14513
+ const b = original[i - 1];
14514
+ if (a !== b)
14515
+ {
14516
+ const cross =
14517
+ (b.pos.x - a.pos.x) * (node.pos.y - a.pos.y) -
14518
+ (b.pos.y - a.pos.y) * (node.pos.x - a.pos.x);
14519
+ if (cross === 0) continue;
14520
+ }
14521
+ }
14522
+
14523
+ if (!this.isLineClear(node.pos, path[path.length - 1].pos))
14524
+ {
14525
+ // Look ahead — if any later node has a clear shot to the
14526
+ // back of our new path, skip this node and try later.
14527
+ let foundClearAfter = false;
14528
+ for (let j = i + 1; j < original.length; ++j)
14529
+ {
14530
+ if (this.isLineClear(original[j].pos, path[path.length - 1].pos))
14531
+ {
14532
+ foundClearAfter = true;
14533
+ break;
14534
+ }
14535
+ }
14536
+ if (foundClearAfter)
14537
+ {
14538
+ if (this.debug && this.debugTime > 0)
14539
+ debugLine(node.posWorld, path[path.length - 1].posWorld, rgb(0, 0, 1, 0.3), 0.02, this.debugTime);
14540
+ continue;
14541
+ }
14542
+
14543
+ // No clear line ahead — fall back to the last waypoint we did
14544
+ // have a clear line to. searchIndex tracks our scan position.
14545
+ for (; searchIndex < original.length; ++searchIndex)
14546
+ {
14547
+ const cand = original[searchIndex];
14548
+ if (this.isLineClear(node.pos, cand.pos))
14549
+ {
14550
+ path.push(cand);
14551
+ i = searchIndex;
14552
+ break;
14553
+ }
14554
+ }
14555
+ ASSERT(searchIndex < original.length, 'smoothPathStringPull: ran out of candidates');
14556
+ }
14557
+ }
14558
+
14559
+ path.push(original[original.length - 1]);
14560
+ }
14561
+
14562
+ /** Lookup helper: true when the node at tile coords (x, y) is in-bounds
14563
+ * and clear (walkable, zero-cost). Used by isLineClear's hot path.
14564
+ * @param {number} x
14565
+ * @param {number} y
14566
+ * @returns {boolean}
14567
+ * @private */
14568
+ isNodeClear(x, y)
14569
+ {
14570
+ const n = this.getNode(x, y);
14571
+ return n !== null && n.isClear();
14572
+ }
14573
+
14574
+ /** Check that the line between two tile-coord endpoints stays entirely
14575
+ * inside walkable, zero-cost cells. Stricter than just sampling along
14576
+ * the line — it also checks the diagonal-corner-adjacent cells so the
14577
+ * line can never "scrape past" a wall corner.
14578
+ *
14579
+ * Both endpoints must themselves be clear (asserted in debug). Port of
14580
+ * CheckLine() in pathFinding.cpp.
14581
+ * @param {Vector2} startPos - Tile coords
14582
+ * @param {Vector2} endPos - Tile coords
14583
+ * @returns {boolean}
14584
+ * @private */
14585
+ isLineClear(startPos, endPos)
14586
+ {
14587
+ ASSERT(isVector2(startPos) && isVector2(endPos), 'isLineClear needs Vector2 endpoints');
14588
+ ASSERT(this.isNodeClear(startPos.x, startPos.y) && this.isNodeClear(endPos.x, endPos.y),
14589
+ 'isLineClear endpoints must be in-bounds and clear');
14590
+
14591
+ const dx = endPos.x - startPos.x;
14592
+ const dy = endPos.y - startPos.y;
14593
+ const adx = abs(dx);
14594
+ const ady = abs(dy);
14595
+ const sx = sign(dx);
14596
+ const sy = sign(dy);
14597
+ let x = startPos.x;
14598
+ let y = startPos.y;
14599
+
14600
+ if (ady === adx)
14601
+ {
14602
+ // Pure diagonal.
14603
+ while (x !== endPos.x)
14604
+ {
14605
+ if (x !== startPos.x)
14606
+ {
14607
+ if (!this.isNodeClear(x, y)) return false;
14608
+ if (!this.isNodeClear(x, y - sy)) return false;
14609
+ }
14610
+ if (!this.isNodeClear(x, y + sy)) return false;
14611
+ x += sx;
14612
+ y += sy;
14613
+ }
14614
+ if (!this.isNodeClear(endPos.x, endPos.y - sy)) return false;
14615
+ }
14616
+ else if (ady < adx)
14617
+ {
14618
+ // Mostly horizontal.
14619
+ if (dy === 0)
14620
+ {
14621
+ // Purely horizontal.
14622
+ x += sx;
14623
+ while (x !== endPos.x)
14624
+ {
14625
+ if (!this.isNodeClear(x, y)) return false;
14626
+ x += sx;
14627
+ }
14628
+ }
14629
+ else
14630
+ {
14631
+ let lastY = startPos.y;
14632
+ while (x !== endPos.x)
14633
+ {
14634
+ y = startPos.y + Math.trunc((dy * (x - startPos.x)) / dx);
14635
+ if (lastY !== y)
14636
+ {
14637
+ if (!this.isNodeClear(x - sx, y + sy)) return false;
14638
+ if (!this.isNodeClear(x, y - sy)) return false;
14639
+ }
14640
+ lastY = y;
14641
+ if (x !== startPos.x)
14642
+ {
14643
+ if (!this.isNodeClear(x, y)) return false;
14644
+ }
14645
+ y += sy;
14646
+ if (!this.isNodeClear(x, y)) return false;
14647
+ x += sx;
14648
+ }
14649
+ const finalY = endPos.y - sy;
14650
+ if (!this.isNodeClear(endPos.x, finalY)) return false;
14651
+ }
14652
+ }
14653
+ else
14654
+ {
14655
+ // Mostly vertical.
14656
+ if (dx === 0)
14657
+ {
14658
+ y += sy;
14659
+ while (y !== endPos.y)
14660
+ {
14661
+ if (!this.isNodeClear(x, y)) return false;
14662
+ y += sy;
14663
+ }
14664
+ }
14665
+ else
14666
+ {
14667
+ let lastX = startPos.x;
14668
+ while (y !== endPos.y)
14669
+ {
14670
+ x = startPos.x + Math.trunc((dx * (y - startPos.y)) / dy);
14671
+ if (lastX !== x)
14672
+ {
14673
+ if (!this.isNodeClear(x + sx, y - sy)) return false;
14674
+ if (!this.isNodeClear(x - sx, y)) return false;
14675
+ }
14676
+ lastX = x;
14677
+ if (y !== startPos.y)
14678
+ {
14679
+ if (!this.isNodeClear(x, y)) return false;
14680
+ }
14681
+ x += sx;
14682
+ if (!this.isNodeClear(x, y)) return false;
14683
+ y += sy;
14684
+ }
14685
+ const finalX = endPos.x - sx;
14686
+ if (!this.isNodeClear(finalX, endPos.y)) return false;
14687
+ }
14688
+ }
14689
+ return true;
14690
+ }
14691
+
14692
+ /** Find a path from startPos to endPos in world space. Returns an array
14693
+ * of world-space Vector2 points; empty array if no path exists.
14694
+ *
14695
+ * Start and end are snapped to the nearest walkable tile via
14696
+ * getNearestClearNode. Intermediate points are tile centers unless the
14697
+ * string-pulling smoothing pass moves them off-grid.
14698
+ * @param {Vector2} startPos - World-space start
14699
+ * @param {Vector2} endPos - World-space end
14700
+ * @returns {Vector2[]}
14701
+ * @memberof PathFinding */
14702
+ findPath(startPos, endPos)
14703
+ {
14704
+ ASSERT(isVector2(startPos) && isVector2(endPos), 'findPath needs Vector2 endpoints');
14705
+
14706
+ this.buildNodeData();
14707
+
14708
+ // rebuild=false because we just built — avoid redundant work per snap.
14709
+ const startNode = this.getNearestClearNode(startPos, 10, false);
14710
+ const endNode = this.getNearestClearNode(endPos, 10, false);
14711
+ if (!startNode || !endNode) return [];
14712
+
14713
+ // Trivial case: start and end snapped to the same tile.
14714
+ if (startNode === endNode) return [startNode.posWorld.copy()];
14715
+
14716
+ if (!this.aStarSearch(startNode, endNode)) return [];
14717
+
14718
+ // Walk back from endNode via parent pointers, then reverse — cheaper
14719
+ // than unshifting on every step.
14720
+ const nodePath = [];
14721
+ for (let n = endNode; n; n = n.parent)
14722
+ nodePath.push(n);
14723
+ nodePath.reverse();
14724
+
14725
+ if (this.smoothPath)
14726
+ {
14727
+ this.smoothPathCorners(nodePath);
14728
+ this.smoothPathStringPull(nodePath);
14729
+ }
14730
+
14731
+ // Convert to world-space Vector2 path. Return copies, not live node
14732
+ // references — callers shouldn't be able to mutate the grid.
14733
+ const result = nodePath.map(n => n.posWorld.copy());
14734
+
14735
+ if (this.debug && this.debugTime > 0 && result.length > 0)
14736
+ {
14737
+ for (let i = 1; i < result.length; ++i)
14738
+ debugLine(result[i - 1], result[i], RED, 0.1, this.debugTime);
14739
+ for (const p of result)
14740
+ debugCircle(p, 0.5, rgb(1, 0, 0, 0.3), this.debugTime);
14741
+ debugCircle(result[0], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
14742
+ debugCircle(result[result.length - 1], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
14743
+ }
14744
+
14745
+ return result;
14746
+ }
14747
+ }
14748
+