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
@@ -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 - Release Mode
@@ -720,7 +584,7 @@ const debugPhysics = 0;
720
584
  const debugParticles = 0;
721
585
  const debugRaycast = 0;
722
586
  const debugGamepads = 0;
723
- const debugMedals = 0;
587
+ const debugPointSize = .5;
724
588
 
725
589
  // debug commands are automatically removed from the final build
726
590
  function ASSERT (){}
@@ -897,7 +761,11 @@ function percentLerp(value, percentA, percentB, lerpA, lerpB)
897
761
  * @return {number}
898
762
  * @memberof Math */
899
763
  function distanceWrap(valueA, valueB, wrapSize=1)
900
- { const d = (valueA - valueB) % wrapSize; return d*2 % wrapSize - d; }
764
+ {
765
+ ASSERT(wrapSize > 0, 'distanceWrap wrapSize must be > 0');
766
+ const d = (valueA - valueB) % wrapSize;
767
+ return d*2 % wrapSize - d;
768
+ }
901
769
 
902
770
  /** Linearly interpolates between values passed in with wrapping
903
771
  * @param {number} valueA
@@ -1214,6 +1082,7 @@ class RandomGenerator
1214
1082
  * @param {number} [seed] - Starting seed or engine default seed */
1215
1083
  constructor(seed = 123456789)
1216
1084
  {
1085
+ ASSERT(seed !== 0, 'RandomGenerator seed must be non-zero (xorshift is fixed at 0)');
1217
1086
  /** @property {number} - random seed */
1218
1087
  this.seed = seed;
1219
1088
  }
@@ -1507,8 +1376,10 @@ class Vector2
1507
1376
  * @return {Vector2} */
1508
1377
  floor() { return new Vector2(floor(this.x), floor(this.y)); }
1509
1378
 
1510
- /** Returns a copy of this vector snapped to a grid
1511
- * @param {number} grid - grid size to snap to
1379
+ /** Returns a copy of this vector snapped to a grid. Note that `grid` is
1380
+ * the number of snap steps per unit (so `grid=2` snaps to halves and
1381
+ * `grid=0.5` snaps to twos), not the cell size.
1382
+ * @param {number} grid - snap steps per unit
1512
1383
  * @return {Vector2} */
1513
1384
  snap(grid)
1514
1385
  {
@@ -2078,9 +1949,14 @@ function readSaveData(saveName, defaultSaveData)
2078
1949
  {
2079
1950
  ASSERT(isString(saveName), 'loadData requires saveName string');
2080
1951
 
2081
- // replace undefined values with defaults
1952
+ // replace undefined values with defaults; tolerate corrupt JSON
2082
1953
  const data = localStorage[saveName];
2083
- const loadedData = data ? JSON.parse(data) : {};
1954
+ let loadedData = {};
1955
+ if (data)
1956
+ {
1957
+ try { loadedData = JSON.parse(data); }
1958
+ catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
1959
+ }
2084
1960
  return { ...defaultSaveData, ...loadedData };
2085
1961
  }
2086
1962
 
@@ -2120,6 +1996,18 @@ let cameraAngle = 0;
2120
1996
  * @memberof Settings */
2121
1997
  let cameraScale = 32;
2122
1998
 
1999
+ ///////////////////////////////////////////////////////////////////////////////
2000
+ // Time settings
2001
+
2002
+ /** Scale applied to engine time, can be used for slow motion or fast forward
2003
+ * - 1 is normal speed, 2 is double speed, 0.5 is half speed
2004
+ * - 0 freezes the simulation without setting the paused flag
2005
+ * - Should be >= 0; stacks multiplicatively with the debug +/- shortcut
2006
+ * @type {number}
2007
+ * @default
2008
+ * @memberof Settings */
2009
+ let timeScale = 1;
2010
+
2123
2011
  ///////////////////////////////////////////////////////////////////////////////
2124
2012
  // Display settings
2125
2013
 
@@ -2403,33 +2291,6 @@ let soundDefaultRange = 40;
2403
2291
  * @memberof Settings */
2404
2292
  let soundDefaultTaper = .7;
2405
2293
 
2406
- ///////////////////////////////////////////////////////////////////////////////
2407
- // Medals settings
2408
-
2409
- /** How long to show medals for in seconds
2410
- * @type {number}
2411
- * @default
2412
- * @memberof Settings */
2413
- let medalDisplayTime = 5;
2414
-
2415
- /** How quickly to slide on/off medals in seconds
2416
- * @type {number}
2417
- * @default
2418
- * @memberof Settings */
2419
- let medalDisplaySlideTime = .5;
2420
-
2421
- /** Size of medal display
2422
- * @type {Vector2}
2423
- * @default Vector2(640,80)
2424
- * @memberof Settings */
2425
- let medalDisplaySize = vec2(640, 80);
2426
-
2427
- /** Set to stop medals from being unlockable (like if cheats are enabled)
2428
- * @type {boolean}
2429
- * @default
2430
- * @memberof Settings */
2431
- let medalsPreventUnlock = false;
2432
-
2433
2294
  ///////////////////////////////////////////////////////////////////////////////
2434
2295
  // Setters for global variables
2435
2296
 
@@ -2448,6 +2309,11 @@ function setCameraAngle(angle) { cameraAngle = angle; }
2448
2309
  * @memberof Settings */
2449
2310
  function setCameraScale(scale) { cameraScale = scale; }
2450
2311
 
2312
+ /** Set scale applied to engine time
2313
+ * @param {number} scale
2314
+ * @memberof Settings */
2315
+ function setTimeScale(scale) { timeScale = scale; }
2316
+
2451
2317
  /** Set if tiles should be colorized when using canvas2d
2452
2318
  * This can be slower but results should look nearly identical to WebGL rendering
2453
2319
  * It can be enabled/disabled at any time
@@ -2686,26 +2552,6 @@ function setSoundDefaultRange(range) { soundDefaultRange = range; }
2686
2552
  * @memberof Settings */
2687
2553
  function setSoundDefaultTaper(taper) { soundDefaultTaper = taper; }
2688
2554
 
2689
- /** Set how long to show medals for in seconds
2690
- * @param {number} time
2691
- * @memberof Settings */
2692
- function setMedalDisplayTime(time) { medalDisplayTime = time; }
2693
-
2694
- /** Set how quickly to slide on/off medals in seconds
2695
- * @param {number} time
2696
- * @memberof Settings */
2697
- function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
2698
-
2699
- /** Set size of medal display
2700
- * @param {Vector2} size
2701
- * @memberof Settings */
2702
- function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
2703
-
2704
- /** Set to stop medals from being unlockable
2705
- * @param {boolean} preventUnlock
2706
- * @memberof Settings */
2707
- function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }
2708
-
2709
2555
  /** Set if watermark with FPS should be shown
2710
2556
  * @param {boolean} show
2711
2557
  * @memberof Debug */
@@ -2847,10 +2693,18 @@ class EngineObject
2847
2693
  const parent = this.parent;
2848
2694
  if (parent)
2849
2695
  {
2850
- // copy parent pos/angle
2696
+ // compose with parent transform inline to avoid intermediate vector allocs
2851
2697
  const mirror = parent.getMirrorSign();
2852
- this.pos = this.localPos.multiply(vec2(mirror,1)).rotate(parent.angle).add(parent.pos);
2853
- this.angle = mirror*this.localAngle + parent.angle;
2698
+ const lp = this.localPos, pp = parent.pos;
2699
+ const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
2700
+ if (pa)
2701
+ {
2702
+ const c = cos(-pa), s = sin(-pa);
2703
+ this.pos = new Vector2(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
2704
+ }
2705
+ else
2706
+ this.pos = new Vector2(lx + pp.x, ly + pp.y);
2707
+ this.angle = mirror*this.localAngle + pa;
2854
2708
  }
2855
2709
 
2856
2710
  // update children
@@ -3216,8 +3070,6 @@ class EngineObject
3216
3070
  * @return {string} */
3217
3071
  toString()
3218
3072
  {
3219
- if (!debug) return;
3220
-
3221
3073
  let text = 'type = ' + this.constructor.name;
3222
3074
  if (this.pos.x || this.pos.y)
3223
3075
  text += '\npos = ' + this.pos;
@@ -3322,6 +3174,12 @@ let textureInfos = [];
3322
3174
  * @memberof Draw */
3323
3175
  let drawCount;
3324
3176
 
3177
+ // internal predicates for tint short-circuiting in canvas2D draw paths
3178
+ // isWhite ignores alpha because alpha is applied via globalAlpha, not multiply
3179
+ // isBlack includes alpha so additive colors that only contribute alpha are not skipped
3180
+ /** @param {Color} c */ function isWhite(c) { return c.r >= 1 && c.g >= 1 && c.b >= 1; }
3181
+ /** @param {Color} c */ function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
3182
+
3325
3183
  ///////////////////////////////////////////////////////////////////////////////
3326
3184
 
3327
3185
  /**
@@ -3461,8 +3319,9 @@ class TextureInfo
3461
3319
  * Create a TextureInfo, called automatically by the engine
3462
3320
  * @param {HTMLImageElement|OffscreenCanvas} image
3463
3321
  * @param {boolean} [useWebGL] - Should use WebGL if available?
3322
+ * @param {boolean} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)?
3464
3323
  */
3465
- constructor(image, useWebGL=true)
3324
+ constructor(image, useWebGL=true, wrap=false)
3466
3325
  {
3467
3326
  /** @property {HTMLImageElement|OffscreenCanvas} - image source */
3468
3327
  this.image = image;
@@ -3472,6 +3331,8 @@ class TextureInfo
3472
3331
  this.sizeInverse = image ? vec2(1/image.width, 1/image.height) : vec2();
3473
3332
  /** @property {WebGLTexture} - WebGL texture */
3474
3333
  this.glTexture = undefined;
3334
+ /** @property {boolean} - true for REPEAT wrap mode, false for CLAMP_TO_EDGE */
3335
+ this.wrap = wrap;
3475
3336
  useWebGL && this.createWebGLTexture();
3476
3337
  }
3477
3338
 
@@ -3484,6 +3345,14 @@ class TextureInfo
3484
3345
  /** Check if the texture is webgl enabled
3485
3346
  * @return {boolean} */
3486
3347
  hasWebGL() { return !!this.glTexture; }
3348
+
3349
+ /** Set the wrap mode for this texture
3350
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE */
3351
+ setWrap(wrap=true)
3352
+ {
3353
+ this.wrap = wrap;
3354
+ glSetTextureWrap(this.glTexture, wrap);
3355
+ }
3487
3356
  }
3488
3357
 
3489
3358
  ///////////////////////////////////////////////////////////////////////////////
@@ -3545,8 +3414,13 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
3545
3414
  }
3546
3415
  else
3547
3416
  {
3548
- // if no tile info, force untextured
3549
- glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0, 0, color.rgbaInt());
3417
+ // if no tile info, force untextured by zeroing rgba (so whatever
3418
+ // texture is bound doesn't leak in) and folding color+additive
3419
+ // into the additive slot — matches the Canvas2D path's
3420
+ // color.add(additiveColor) on line ~337.
3421
+ const combined = additiveColor ? color.add(additiveColor) : color;
3422
+ glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0,
3423
+ 0, combined.rgbaInt());
3550
3424
  }
3551
3425
  }
3552
3426
  else
@@ -3651,6 +3525,98 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
3651
3525
  }
3652
3526
  }
3653
3527
 
3528
+ /** Draw a texture tiled (wrapped) across a rectangle in world space.
3529
+ * Useful for backgrounds, repeating patterns, and seamless fills.
3530
+ * The whole texture is tiled — sub-region (TileInfo) wrapping is not supported.
3531
+ * @param {Vector2} pos - Center of the rect in world space
3532
+ * @param {Vector2} size - Size of the rect in world space
3533
+ * @param {Vector2} wrapCount - How many times the texture repeats (x, y)
3534
+ * @param {TextureInfo|number} [texture=0] - TextureInfo or texture index into textureInfos
3535
+ * @param {Color} [color=WHITE] - Color to modulate with
3536
+ * @param {number} [angle=0] - Angle to rotate by
3537
+ * @param {Color} [additiveColor] - Additive color to be applied if any
3538
+ * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
3539
+ * @param {boolean} [screenSpace=false] - Are pos and size in screen space?
3540
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
3541
+ * @memberof Draw */
3542
+ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
3543
+ angle=0, additiveColor, useWebGL=glEnable, screenSpace=false, context)
3544
+ {
3545
+ ASSERT(isVector2(pos), 'pos must be a vec2');
3546
+ ASSERT(isVector2(size), 'size must be a vec2');
3547
+ ASSERT(isVector2(wrapCount), 'wrapCount must be a vec2');
3548
+ ASSERT(isColor(color), 'color is invalid');
3549
+ ASSERT(isNumber(angle), 'angle must be a number');
3550
+ ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
3551
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
3552
+ ASSERT(!(texture instanceof TileInfo),
3553
+ 'pass a TextureInfo or texture index, not a TileInfo — use tileInfo.textureInfo');
3554
+
3555
+ // short-circuit before texture lookup — textureInfos[0] is undefined in headless mode
3556
+ if (headlessMode) return;
3557
+
3558
+ // resolve texture argument: TextureInfo or index
3559
+ const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
3560
+ ASSERT(textureInfo instanceof TextureInfo, 'texture not loaded');
3561
+ ASSERT(textureInfo.size.x > 0, 'texture not loaded');
3562
+ ASSERT(textureInfo.wrap,
3563
+ 'drawTextureWrapped requires a wrap-enabled texture; call textureInfo.setWrap(true) first');
3564
+
3565
+ if (useWebGL && glEnable)
3566
+ {
3567
+ ASSERT(!!glContext, 'WebGL is not enabled!');
3568
+ if (screenSpace)
3569
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
3570
+ glSetTexture(textureInfo.glTexture);
3571
+ glDraw(pos.x, pos.y, size.x, size.y, angle,
3572
+ 0, 0, wrapCount.x, wrapCount.y,
3573
+ color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
3574
+ return;
3575
+ }
3576
+
3577
+ // Canvas2D path — increment drawCount here (WebGL batch counts via glBatchCount)
3578
+ ++drawCount;
3579
+
3580
+ if (!screenSpace)
3581
+ {
3582
+ pos = worldToScreen(pos);
3583
+ size = size.scale(cameraScale);
3584
+ angle -= cameraAngle;
3585
+ }
3586
+
3587
+ // pick image source: raw, or tinted bake. Match drawImageColor's
3588
+ // "no tint needed" predicate so behavior stays consistent.
3589
+ const noTint = !canvasColorTiles ||
3590
+ (additiveColor
3591
+ ? isWhite(color.add(additiveColor)) && additiveColor.a <= 0
3592
+ : isWhite(color));
3593
+ // alpha is baked into pixels by bakeTintedImage's additive branch;
3594
+ // in that case globalAlpha must NOT also apply color.a
3595
+ const alphaBaked = !noTint && additiveColor && !isBlack(additiveColor);
3596
+ const source = noTint
3597
+ ? textureInfo.image
3598
+ : bakeTintedImage(textureInfo.image, color, additiveColor);
3599
+
3600
+ context = context || drawContext;
3601
+ context.save();
3602
+ context.translate(pos.x + .5, pos.y + .5);
3603
+ context.rotate(angle);
3604
+ context.globalAlpha = alphaBaked ? 1 : color.a;
3605
+
3606
+ const pattern = context.createPattern(source, 'repeat');
3607
+ // map pattern-source pixels into user space so the rect contains
3608
+ // wrapCount.x × wrapCount.y repeats
3609
+ const m = new DOMMatrix()
3610
+ .translate(-size.x/2, -size.y/2)
3611
+ .scale(size.x / (wrapCount.x * source.width),
3612
+ size.y / (wrapCount.y * source.height));
3613
+ pattern.setTransform(m);
3614
+ context.fillStyle = pattern;
3615
+ context.fillRect(-size.x/2, -size.y/2, size.x, size.y);
3616
+ context.globalAlpha = 1;
3617
+ context.restore();
3618
+ }
3619
+
3654
3620
  /** Draw connected lines between a series of points
3655
3621
  * @param {Array<Vector2>} points
3656
3622
  * @param {number} [width]
@@ -4174,6 +4140,43 @@ function combineCanvases()
4174
4140
  mainContext.drawImage(workCanvas, 0, 0);
4175
4141
  }
4176
4142
 
4143
+ // Internal: bake a color/additive-color tint into workReadCanvas at the
4144
+ // image's native resolution. Returns the work canvas, suitable for
4145
+ // passing to context.createPattern. Used by drawTextureWrapped's
4146
+ // Canvas2D path. Caller is responsible for short-circuiting when no
4147
+ // tint is needed (i.e. color is white and additiveColor is black/none).
4148
+ function bakeTintedImage(image, color, additiveColor)
4149
+ {
4150
+ const w = image.width|0, h = image.height|0;
4151
+ workReadCanvas.width = w;
4152
+ workReadCanvas.height = h;
4153
+ workReadContext.drawImage(image, 0, 0);
4154
+
4155
+ const imageData = workReadContext.getImageData(0, 0, w, h);
4156
+ const data = imageData.data;
4157
+ if (additiveColor && !isBlack(additiveColor))
4158
+ {
4159
+ // multiply + additive (slower)
4160
+ const colorMultiply = [color.r, color.g, color.b, color.a];
4161
+ const colorAdd = [additiveColor.r * 255, additiveColor.g * 255,
4162
+ additiveColor.b * 255, additiveColor.a * 255];
4163
+ for (let i = 0; i < data.length; ++i)
4164
+ data[i] = data[i] * colorMultiply[i&3] + colorAdd[i&3] |0;
4165
+ }
4166
+ else
4167
+ {
4168
+ // RGB only, faster — alpha left intact for the caller
4169
+ for (let i = 0; i < data.length; i+=4)
4170
+ {
4171
+ data[i ] *= color.r;
4172
+ data[i+1] *= color.g;
4173
+ data[i+2] *= color.b;
4174
+ }
4175
+ }
4176
+ workReadContext.putImageData(imageData, 0, 0);
4177
+ return workReadCanvas;
4178
+ }
4179
+
4177
4180
  /** Helper function to draw an image with color and additive color applied
4178
4181
  * This is slower then normal drawImage when color is applied
4179
4182
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
@@ -4192,8 +4195,6 @@ function combineCanvases()
4192
4195
  * @memberof Draw */
4193
4196
  function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight, color, additiveColor, bleed=0)
4194
4197
  {
4195
- function isWhite(c) { return c.r >= 1 && c.g >= 1 && c.b >= 1; }
4196
- function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
4197
4198
  const sx2 = bleed;
4198
4199
  const sy2 = bleed;
4199
4200
  sWidth = max(1,sWidth|0);
@@ -4468,7 +4469,7 @@ const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
4468
4469
  * This is useful to disable for html menus so the browser can handle input normally
4469
4470
  * @param {boolean} preventDefault
4470
4471
  * @memberof Input */
4471
- function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
4472
+ function setInputPreventDefault(preventDefault=true) { inputPreventDefault = preventDefault; }
4472
4473
 
4473
4474
  /** Clears an input key state
4474
4475
  * @param {string|number} key
@@ -4662,6 +4663,32 @@ function gamepadStickCount(gamepad=gamepadPrimary)
4662
4663
  return gamepadStickData[gamepad]?.length ?? 0;
4663
4664
  }
4664
4665
 
4666
+ /** Pulse a gamepad's vibration hardware using the dual-rumble effect if it exists
4667
+ * Strong magnitude is usually the left side motor, weak magnitude is usually the right side motor
4668
+ * @param {number} [gamepad] - gamepad index
4669
+ * @param {number} [duration] - effect duration in ms
4670
+ * @param {number} [strongMagnitude] - strong (left) motor intensity, 0 to 1
4671
+ * @param {number} [weakMagnitude] - weak (right) motor intensity, 0 to 1
4672
+ * @param {number} [startDelay] - delay in ms before the effect starts
4673
+ * @memberof Input */
4674
+ function gamepadVibrate(gamepad=gamepadPrimary, duration=200, strongMagnitude=1, weakMagnitude=1, startDelay=0)
4675
+ {
4676
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
4677
+ if (!vibrateEnable || headlessMode) return;
4678
+ const pad = navigator?.getGamepads?.()[gamepad];
4679
+ pad?.vibrationActuator?.playEffect?.('dual-rumble', {duration, strongMagnitude, weakMagnitude, startDelay});
4680
+ }
4681
+
4682
+ /** Stop vibration on a gamepad
4683
+ * @memberof Input */
4684
+ function gamepadVibrateStop(gamepad=gamepadPrimary)
4685
+ {
4686
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
4687
+ if (!vibrateEnable || headlessMode) return;
4688
+ const pad = navigator?.getGamepads?.()[gamepad];
4689
+ pad?.vibrationActuator?.reset?.();
4690
+ }
4691
+
4665
4692
  ///////////////////////////////////////////////////////////////////////////////
4666
4693
 
4667
4694
  /** Pulse the vibration hardware if it exists
@@ -5238,10 +5265,12 @@ function touchGamepadButtonCenter()
5238
5265
  * @namespace Audio
5239
5266
  */
5240
5267
 
5241
- /** Audio context used by the engine
5268
+ /** Audio context used by the engine. Created lazily in audioInit() to avoid
5269
+ * browser autoplay warnings about constructing an AudioContext before any
5270
+ * user gesture.
5242
5271
  * @type {AudioContext}
5243
5272
  * @memberof Audio */
5244
- let audioContext = new AudioContext;
5273
+ let audioContext;
5245
5274
 
5246
5275
  /** Master gain node for all audio to pass through
5247
5276
  * @type {GainNode}
@@ -5257,12 +5286,13 @@ const audioDefaultSampleRate = 44100;
5257
5286
  * @return {boolean} - True if the audio context is running
5258
5287
  * @memberof Audio */
5259
5288
  function audioIsRunning()
5260
- { return audioContext.state === 'running'; }
5289
+ { return audioContext?.state === 'running'; }
5261
5290
 
5262
5291
  function audioInit()
5263
5292
  {
5264
5293
  if (!soundEnable || headlessMode) return;
5265
5294
 
5295
+ audioContext = new AudioContext;
5266
5296
  audioMasterGain = audioContext.createGain();
5267
5297
  audioMasterGain.connect(audioContext.destination);
5268
5298
  audioMasterGain.gain.value = soundVolume; // set starting value
@@ -5329,8 +5359,8 @@ class Sound
5329
5359
 
5330
5360
  if (Array.isArray(asset))
5331
5361
  {
5332
- // generate zzfx sound
5333
- const zzfxSound = asset;
5362
+ // generate zzfx sound — copy so we don't mutate the caller's array
5363
+ const zzfxSound = asset.slice();
5334
5364
 
5335
5365
  // remove randomness so it can be applied on playback
5336
5366
  const defaultRandomness = randomness ?? .05;
@@ -6928,11 +6958,14 @@ class ParticleEmitter extends EngineObject
6928
6958
  }
6929
6959
 
6930
6960
  ///////////////////////////////////////////////////////////////////////////////
6961
+ // scratch vector reused by Particle.render to avoid per-frame allocations
6962
+ const particleDrawPos = new Vector2;
6963
+
6931
6964
  /**
6932
6965
  * Particle Object - Created automatically by Particle Emitters
6933
6966
  * @memberof Particles
6934
6967
  */
6935
- class Particle
6968
+ class Particle
6936
6969
  {
6937
6970
  /**
6938
6971
  * Create a particle with the passed in settings
@@ -7107,14 +7140,14 @@ class Particle
7107
7140
  additive && setBlendMode(true);
7108
7141
 
7109
7142
  // update the position and angle for drawing
7110
- const pos = this.pos.copy();
7143
+ const pos = particleDrawPos.set(this.pos.x, this.pos.y);
7111
7144
  let angle = this.angle;
7112
7145
  if (localSpace)
7113
7146
  {
7114
7147
  // in local space of emitter
7115
7148
  const a = emitter.angle;
7116
7149
  const c = cos(a), s = sin(a);
7117
- pos.set(emitter.pos.x + pos.x*c - pos.y*s,
7150
+ pos.set(emitter.pos.x + pos.x*c - pos.y*s,
7118
7151
  emitter.pos.y + pos.x*s + pos.y*c);
7119
7152
  angle += a;
7120
7153
  }
@@ -7139,200 +7172,6 @@ class Particle
7139
7172
  debugParticles && debugRect(pos, size, '#f005', 0, angle);
7140
7173
  }
7141
7174
  }
7142
- /**
7143
- * LittleJS Medal System
7144
- * - Achievement/trophy system for games
7145
- * - Medal class with name, description, icon, and unlock tracking
7146
- * - Automatic saving to local storage
7147
- * - Visual display queue with slide-in notifications
7148
- * - Newgrounds API integration for online achievements
7149
- * - Debug mode to unlock/reset medals during development
7150
- * @namespace Medals
7151
- */
7152
-
7153
- /** List of all medals
7154
- * @type {Object}
7155
- * @memberof Medals */
7156
- const medals = {};
7157
-
7158
- // Engine internal variables not exposed to documentation
7159
- let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast;
7160
-
7161
- ///////////////////////////////////////////////////////////////////////////////
7162
-
7163
- /** Initialize medals with a save name used for storage
7164
- * - Call this after creating all medals
7165
- * - Checks if medals are unlocked
7166
- * @param {string} saveName
7167
- * @memberof Medals */
7168
- function medalsInit(saveName)
7169
- {
7170
- // check if medals are unlocked
7171
- medalsSaveName = saveName;
7172
- if (!debugMedals)
7173
- medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
7174
-
7175
- // engine automatically renders medals
7176
- engineAddPlugin(undefined, medalsRender);
7177
-
7178
- // plugin functions
7179
- function medalsRender()
7180
- {
7181
- if (!medalsDisplayQueue.length) return;
7182
-
7183
- // update first medal in queue
7184
- const medal = medalsDisplayQueue[0];
7185
- const time = timeReal - medalsDisplayTimeLast;
7186
- if (!medalsDisplayTimeLast)
7187
- medalsDisplayTimeLast = timeReal;
7188
- else if (time > medalDisplayTime)
7189
- {
7190
- medalsDisplayTimeLast = 0;
7191
- medalsDisplayQueue.shift();
7192
- }
7193
- else
7194
- {
7195
- // slide on/off medals
7196
- const slideOffTime = medalDisplayTime - medalDisplaySlideTime;
7197
- const hidePercent =
7198
- time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime :
7199
- time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0;
7200
- medal.render(hidePercent);
7201
- }
7202
- }
7203
- }
7204
-
7205
- /**
7206
- * @callback MedalCallbackFunction - Function that processes a medal
7207
- * @param {Medal} medal
7208
- * @memberof Medals
7209
- */
7210
-
7211
- /** Calls a function for each medal
7212
- * @param {MedalCallbackFunction} callback
7213
- * @memberof Medals */
7214
- function medalsForEach(callback)
7215
- { Object.values(medals).forEach(medal=>callback(medal)); }
7216
-
7217
- ///////////////////////////////////////////////////////////////////////////////
7218
-
7219
- /**
7220
- * Medal - Tracks an unlockable medal
7221
- * @memberof Medals
7222
- * @example
7223
- * // create a medal
7224
- * const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
7225
- *
7226
- * // initialize medals
7227
- * medalsInit('Example Game');
7228
- *
7229
- * // unlock the medal
7230
- * medal_example.unlock();
7231
- */
7232
- class Medal
7233
- {
7234
- /** Create a medal object and adds it to the list of medals
7235
- * @param {number} id - The unique identifier of the medal
7236
- * @param {string} name - Name of the medal
7237
- * @param {string} [description] - Description of the medal
7238
- * @param {string} [icon] - Icon for the medal
7239
- * @param {string} [src] - Image location for the medal
7240
- */
7241
- constructor(id, name, description='', icon='🏆', src)
7242
- {
7243
- ASSERT(id >= 0 && !medals[id]);
7244
-
7245
- /** @property {number} - The unique identifier of the medal */
7246
- this.id = id;
7247
-
7248
- /** @property {string} - Name of the medal */
7249
- this.name = name;
7250
-
7251
- /** @property {string} - Description of the medal */
7252
- this.description = description;
7253
-
7254
- /** @property {string} - Icon for the medal */
7255
- this.icon = icon;
7256
-
7257
- /** @property {boolean} - Is the medal unlocked? */
7258
- this.unlocked = false;
7259
-
7260
- // load the source image if provided
7261
- if (src)
7262
- (this.image = new Image).src = src;
7263
-
7264
- // add this to list of medals
7265
- medals[id] = this;
7266
- }
7267
-
7268
- /** Unlocks a medal if not already unlocked */
7269
- unlock()
7270
- {
7271
- if (medalsPreventUnlock || this.unlocked) return;
7272
-
7273
- // save the medal
7274
- ASSERT(medalsSaveName, 'save name must be set');
7275
- localStorage[this.storageKey()] = this.unlocked = true;
7276
- medalsDisplayQueue.push(this);
7277
- }
7278
-
7279
- /** Render a medal
7280
- * @param {number} [hidePercent] - How much to slide the medal off screen
7281
- */
7282
- render(hidePercent=0)
7283
- {
7284
- const context = mainContext;
7285
- const width = min(medalDisplaySize.x, mainCanvas.width);
7286
- const height = medalDisplaySize.y;
7287
- const x = mainCanvas.width - width;
7288
- const y = -height*hidePercent;
7289
- const backgroundColor = hsl(0,0,.9);
7290
-
7291
- // draw containing rect and clip to that region
7292
- context.save();
7293
- context.beginPath();
7294
- context.fillStyle = backgroundColor.toString();
7295
- context.strokeStyle = BLACK.toString();
7296
- context.lineWidth = 3;
7297
- context.rect(x, y, width, height);
7298
- context.fill();
7299
- context.stroke();
7300
- context.clip();
7301
-
7302
- // draw the icon
7303
- const gap = vec2(.1, .05).scale(height);
7304
- const medalDisplayIconSize = height - 2*gap.x;
7305
- this.renderIcon(vec2(x + gap.x + medalDisplayIconSize/2, y + height/2), medalDisplayIconSize);
7306
-
7307
- // draw the name
7308
- const nameSize = height*.5;
7309
- const descriptionSize = height*.3;
7310
- const pos = vec2(x + medalDisplayIconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
7311
- const textWidth = width - medalDisplayIconSize - 3*gap.x;
7312
- drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
7313
-
7314
- // draw the description
7315
- pos.y = y + height - gap.y*2 - descriptionSize/2;
7316
- drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
7317
- context.restore();
7318
- }
7319
-
7320
- /** Render the icon for a medal
7321
- * @param {Vector2} pos - Screen space position
7322
- * @param {number} size - Screen space size
7323
- */
7324
- renderIcon(pos, size)
7325
- {
7326
- // draw the image or icon
7327
- if (this.image)
7328
- mainContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size);
7329
- else
7330
- drawTextScreen(this.icon, pos, size*.7, BLACK);
7331
- }
7332
-
7333
- // Get local storage key used by the medal
7334
- storageKey() { return medalsSaveName + '_' + this.id; }
7335
- }
7336
7175
  /**
7337
7176
  * LittleJS WebGL Interface
7338
7177
  * - WebGL2 rendering engine for high-performance graphics
@@ -7432,7 +7271,7 @@ function glInit(rootElement)
7432
7271
  // reinit WebGL and restore textures
7433
7272
  initWebGL();
7434
7273
  for (const info of glTextureInfos)
7435
- info.glTexture = glCreateTexture(info.image);
7274
+ info.glTexture = glCreateTexture(info.image, info.wrap);
7436
7275
  pluginList.forEach(plugin=>plugin.glContextRestored?.());
7437
7276
  });
7438
7277
 
@@ -7637,9 +7476,8 @@ function glClearCanvas()
7637
7476
  /** Set the WebGL texture, called automatically if using multiple textures
7638
7477
  * - This may also flush the gl buffer resulting in more draw calls and worse performance
7639
7478
  * @param {WebGLTexture} texture
7640
- * @param {boolean} [wrap] - Should the texture wrap or clamp
7641
7479
  * @memberof WebGL */
7642
- function glSetTexture(texture, wrap=false)
7480
+ function glSetTexture(texture)
7643
7481
  {
7644
7482
  // must flush cache with the old texture to set a new one
7645
7483
  if (!glContext || texture === glActiveTexture) return;
@@ -7647,11 +7485,30 @@ function glSetTexture(texture, wrap=false)
7647
7485
  glFlush();
7648
7486
  glActiveTexture = texture;
7649
7487
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
7488
+ }
7489
+
7490
+ /** Set the wrap mode (REPEAT or CLAMP_TO_EDGE) on an existing WebGL texture
7491
+ * Flushes the current batch only if the texture is the active one
7492
+ * @param {WebGLTexture} texture
7493
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
7494
+ * @memberof WebGL */
7495
+ function glSetTextureWrap(texture, wrap=true)
7496
+ {
7497
+ if (!glContext || !texture) return;
7498
+
7499
+ // flush only if changing wrap on the currently bound texture
7500
+ const isCurrent = texture === glActiveTexture;
7501
+ if (isCurrent)
7502
+ glFlush();
7503
+ else
7504
+ glContext.bindTexture(glContext.TEXTURE_2D, texture);
7650
7505
 
7651
- // set wrap mode
7652
7506
  const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
7653
7507
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
7654
7508
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
7509
+
7510
+ if (!isCurrent && glActiveTexture)
7511
+ glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
7655
7512
  }
7656
7513
 
7657
7514
  /** Compile WebGL shader of the given type, will throw errors if in debug mode
@@ -7698,9 +7555,10 @@ function glCreateProgram(vsSource, fsSource)
7698
7555
  /** Create WebGL texture from an image and init the texture settings
7699
7556
  * Restores the active texture when done
7700
7557
  * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas} [image]
7558
+ * @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
7701
7559
  * @return {WebGLTexture}
7702
7560
  * @memberof WebGL */
7703
- function glCreateTexture(image)
7561
+ function glCreateTexture(image, wrap=false)
7704
7562
  {
7705
7563
  if (!glContext) return;
7706
7564
 
@@ -7726,6 +7584,9 @@ function glCreateTexture(image)
7726
7584
  const minFilter = mipMap ? glContext.LINEAR_MIPMAP_LINEAR : magFilter;
7727
7585
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, magFilter);
7728
7586
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, minFilter);
7587
+ const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
7588
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
7589
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
7729
7590
  if (mipMap)
7730
7591
  glContext.generateMipmap(glContext.TEXTURE_2D);
7731
7592
 
@@ -7777,7 +7638,7 @@ function glRegisterTextureInfo(textureInfo)
7777
7638
  if (textureInfo.glTexture)
7778
7639
  glSetTextureData(textureInfo.glTexture, textureInfo.image);
7779
7640
  else
7780
- textureInfo.glTexture = glCreateTexture(textureInfo.image);
7641
+ textureInfo.glTexture = glCreateTexture(textureInfo.image, textureInfo.wrap);
7781
7642
  }
7782
7643
 
7783
7644
  /** Tells WebGL to destroy the glTexture and stop tracking it
@@ -7996,6 +7857,7 @@ function glSetRenderTarget(texture, clear=false)
7996
7857
  glFlush();
7997
7858
  glRenderTarget = undefined;
7998
7859
  glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
7860
+ glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
7999
7861
  }
8000
7862
  }
8001
7863
 
@@ -8118,9 +7980,9 @@ function glPolyStrip(points)
8118
7980
  return area;
8119
7981
  }
8120
7982
 
8121
- // ensure counter-clockwise winding
7983
+ // ensure counter-clockwise winding (slice first so we don't mutate caller's array)
8122
7984
  if (signedArea(points) < 0)
8123
- points = points.reverse();
7985
+ points = points.slice().reverse();
8124
7986
 
8125
7987
  // check if point is inside triangle
8126
7988
  const e = 1e-9;
@@ -8220,47 +8082,439 @@ function glPolyStrip(points)
8220
8082
  }
8221
8083
  return strip;
8222
8084
  }
8223
- /**
8224
- * LittleJS Newgrounds Plugin
8225
- * - NewgroundsMedal extends Medal with Newgrounds API functionality
8226
- * - Call new NewgroundsPlugin(app_id) to setup Newgrounds
8227
- * - Uses CryptoJS for encryption if optional cipher is provided
8228
- * - provides functions to interact with medals scoreboards
8229
- * - Keeps connection alive and logs views
8230
- * @namespace Newgrounds
8231
- */
8232
-
8233
- /** Global Newgrounds object
8234
- * @type {NewgroundsPlugin}
8235
- * @memberof Newgrounds */
8236
- let newgrounds;
8237
-
8238
- ///////////////////////////////////////////////////////////////////////////////
8239
- /**
8240
- * Newgrounds medal auto unlocks in newgrounds API
8241
- * @extends Medal
8242
- * @memberof Newgrounds
8243
- */
8244
- class NewgroundsMedal extends Medal
8245
- {
8246
- /** Create a newgrounds medal object and adds it to the list of medals
8247
- * @param {number} id - The unique identifier of the medal
8248
- * @param {string} name - Name of the medal
8249
- * @param {string} [description] - Description of the medal
8250
- * @param {string} [icon] - Icon for the medal
8251
- * @param {string} [src] - Image location for the medal
8252
- */
8253
- constructor(id, name, description, icon, src)
8254
- { super(id, name, description, icon, src); }
8255
-
8256
- /** Unlocks a medal if not already unlocked */
8257
- unlock()
8258
- {
8259
- super.unlock();
8260
- newgrounds && newgrounds.unlockMedal(this.id);
8261
- }
8262
- }
8263
-
8085
+ /**
8086
+ * LittleJS Engine Logo
8087
+ * - Draws the LittleJS splash screen logo
8088
+ * - Used internally during engine startup
8089
+ */
8090
+
8091
+ ///////////////////////////////////////////////////////////////////////////////
8092
+ function drawEngineLogo(t)
8093
+ {
8094
+ const blackAndWhite = 0;
8095
+ const showName = 1;
8096
+
8097
+ // LittleJS Logo and Splash Screen
8098
+ const x = mainContext;
8099
+ const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
8100
+ const w = mainCanvas.width = innerWidth * dpr;
8101
+ const h = mainCanvas.height = innerHeight * dpr;
8102
+ {
8103
+ // background
8104
+ const p3 = percent(t, 1, .8);
8105
+ const p4 = percent(t, 0, .5);
8106
+ const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,hypot(w,h)*.6);
8107
+ g.addColorStop(0,hsl(0,0,lerp(0,p3/2,p4),p3).toString());
8108
+ g.addColorStop(1,hsl(0,0,0,p3).toString());
8109
+ x.save();
8110
+ x.fillStyle = g;
8111
+ x.fillRect(0,0,w,h);
8112
+ }
8113
+ const gradient = (X1,Y1,X2,Y2,C,S=1)=>
8114
+ {
8115
+ if (C >= 0)
8116
+ {
8117
+ if (blackAndWhite)
8118
+ x.fillStyle = '#fff';
8119
+ else
8120
+ {
8121
+ const g = x.fillStyle = x.createLinearGradient(X1,Y1,X2,Y2);
8122
+ g.addColorStop(0,color(C,2));
8123
+ g.addColorStop(1,color(C,1));
8124
+ }
8125
+ }
8126
+ else
8127
+ x.fillStyle = '#000';
8128
+ C >= -1 ? (x.fill(), S && x.stroke()) : x.stroke();
8129
+ }
8130
+ const circle = (X,Y,R,A=0,B=2*PI,C,S)=>
8131
+ {
8132
+ x.beginPath();
8133
+ x.arc(X,Y,R,p*A,p*B);
8134
+ gradient(X,Y-R,X,Y+R,C,S);
8135
+ }
8136
+ const rect = (X,Y,W,H,C)=>
8137
+ {
8138
+ x.beginPath();
8139
+ x.rect(X,Y,W,H*p);
8140
+ gradient(X,Y+H,X+W,Y,C);
8141
+ }
8142
+ const poly = (points,C,Y,H)=>
8143
+ {
8144
+ x.beginPath();
8145
+ for (const p of points)
8146
+ x.lineTo(p.x, p.y);
8147
+ x.closePath();
8148
+ gradient(0, Y, 0, Y+H,C);
8149
+ }
8150
+ const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
8151
+
8152
+ // center and fit to screen
8153
+ const alpha = oscillate(1,1,t);
8154
+ const p = percent(alpha, .1, .5);
8155
+ const size = min(6, min(w,h)/99);
8156
+ x.translate(w/2,h/2);
8157
+ x.scale(size,size);
8158
+ x.translate(-40,-35);
8159
+ p < 1 && x.setLineDash([99*p,99]);
8160
+ x.lineJoin = x.lineCap = 'round';
8161
+ x.lineWidth = .1 + p*1.9;
8162
+ //x.strokeStyle='#fff7';
8163
+
8164
+ if (showName)
8165
+ {
8166
+ // engine name text
8167
+ const Y = 54;
8168
+ const s = 'LittleJS';
8169
+ x.font = '900 15.5px arial';
8170
+ x.lineWidth = .1+p*3.9;
8171
+ x.textAlign = 'center';
8172
+ x.textBaseline = 'top';
8173
+ rect(11,Y+1,59,8*p,-1);
8174
+ x.beginPath();
8175
+
8176
+ let w2 = 0;
8177
+ for (let i=0;i<s.length;++i)
8178
+ w2 += x.measureText(s[i]).width;
8179
+ for (let j=2;j--;)
8180
+ for (let i=0,X=40-w2/2;i<s.length;++i)
8181
+ {
8182
+ const w = x.measureText(s[i]).width, X2 = X+w/2;
8183
+ gradient(X2,Y,X2+2,Y+13,i>5?1:0);
8184
+ x[j?'strokeText':'fillText'](s[i],X2,Y+.5,17*p);
8185
+ X += w;
8186
+ }
8187
+
8188
+ x.lineWidth = .1 + p*1.9;
8189
+ rect(3,Y,73,0); // bottom
8190
+ }
8191
+
8192
+ rect(7,15,26,-7,0); // cab top
8193
+ rect(25,15,8,25,-1); // cab front
8194
+ rect(10,40,15,-25,1); // cab back
8195
+ rect(14,21,7,9,2); // cab window
8196
+ rect(38,20,6,-6,2); // little stack
8197
+
8198
+ // big stack
8199
+ rect(49,20,10,-6,0);
8200
+ const stackPoints = [vec2(44,8),vec2(64,8),vec2(59,8+6*p),vec2(49,8+6*p)];
8201
+ poly(stackPoints,2,8,6*p);
8202
+ rect(44,8,20,-7,0);
8203
+
8204
+ // engine
8205
+ for (let i=5;i--;) circle(59-i*6*p,30,10,0,2*PI,1,0);
8206
+ circle(59,30,4,0,7,2); // light
8207
+
8208
+ // engine outline
8209
+ rect(35,20,24,0); // top
8210
+ circle(59,30,10); // front
8211
+ circle(47,30,10,PI/2,PI*3/2); // middle
8212
+ circle(35,30,10,PI/2,PI*3/2); // back
8213
+ rect(7,40,13,7,-1); // bottom back
8214
+ rect(17,40,43,14,-1); // bottom center
8215
+
8216
+ // wheels
8217
+ for (let i=3;i--;) for (let j=2;j--;) circle(17+15*i,47,j?7:1,0,2*PI,2);
8218
+
8219
+ // cowcatcher
8220
+ for (let i=2;i--;)
8221
+ {
8222
+ let w=6, s=7, o=53+w*p*i
8223
+ const points = [vec2(o+s,54),vec2(o,40),vec2(o+w*p,40),vec2(o+s+w*p,54)];
8224
+ poly(points,0,40,14);
8225
+ }
8226
+
8227
+ x.restore();
8228
+ }
8229
+
8230
+ /**
8231
+ * LittleJS Medal System
8232
+ * - Achievement/trophy system for games
8233
+ * - Medal class with name, description, icon, and unlock tracking
8234
+ * - Automatic saving to local storage
8235
+ * - Visual display queue with slide-in notifications
8236
+ * - Newgrounds API integration for online achievements
8237
+ * - Debug mode to unlock/reset medals during development
8238
+ * @namespace Medals
8239
+ */
8240
+
8241
+ let debugMedals = false;
8242
+
8243
+ ///////////////////////////////////////////////////////////////////////////////
8244
+ // Medals settings
8245
+
8246
+ /** How long to show medals for in seconds
8247
+ * @type {number}
8248
+ * @default
8249
+ * @memberof Settings */
8250
+ let medalDisplayTime = 5;
8251
+
8252
+ /** How quickly to slide on/off medals in seconds
8253
+ * @type {number}
8254
+ * @default
8255
+ * @memberof Settings */
8256
+ let medalDisplaySlideTime = .5;
8257
+
8258
+ /** Size of medal display
8259
+ * @type {Vector2}
8260
+ * @default Vector2(640,80)
8261
+ * @memberof Settings */
8262
+ let medalDisplaySize = vec2(640, 80);
8263
+
8264
+ /** Set to stop medals from being unlockable (like if cheats are enabled)
8265
+ * @type {boolean}
8266
+ * @default
8267
+ * @memberof Settings */
8268
+ let medalsPreventUnlock = false;
8269
+
8270
+ /** List of all medals
8271
+ * @type {Object}
8272
+ * @memberof Medals */
8273
+ const medals = {};
8274
+
8275
+ // Engine internal variables not exposed to documentation
8276
+ let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast;
8277
+
8278
+ ///////////////////////////////////////////////////////////////////////////////
8279
+
8280
+ /** Initialize medals with a save name used for storage
8281
+ * - Call this after creating all medals
8282
+ * - Checks if medals are unlocked
8283
+ * @param {string} saveName
8284
+ * @memberof Medals */
8285
+ function medalsInit(saveName)
8286
+ {
8287
+ // check if medals are unlocked
8288
+ medalsSaveName = saveName;
8289
+ if (!debugMedals)
8290
+ medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
8291
+
8292
+ // engine automatically renders medals
8293
+ engineAddPlugin(undefined, medalsRender);
8294
+
8295
+ // plugin functions
8296
+ function medalsRender()
8297
+ {
8298
+ if (!medalsDisplayQueue.length) return;
8299
+
8300
+ // update first medal in queue
8301
+ const medal = medalsDisplayQueue[0];
8302
+ const time = timeReal - medalsDisplayTimeLast;
8303
+ if (!medalsDisplayTimeLast)
8304
+ medalsDisplayTimeLast = timeReal;
8305
+ else if (time > medalDisplayTime)
8306
+ {
8307
+ medalsDisplayTimeLast = 0;
8308
+ medalsDisplayQueue.shift();
8309
+ }
8310
+ else
8311
+ {
8312
+ // slide on/off medals
8313
+ const slideOffTime = medalDisplayTime - medalDisplaySlideTime;
8314
+ const hidePercent =
8315
+ time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime :
8316
+ time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0;
8317
+ medal.render(hidePercent);
8318
+ }
8319
+ }
8320
+ }
8321
+
8322
+ /**
8323
+ * @callback MedalCallbackFunction - Function that processes a medal
8324
+ * @param {Medal} medal
8325
+ * @memberof Medals
8326
+ */
8327
+
8328
+ /** Calls a function for each medal
8329
+ * @param {MedalCallbackFunction} callback
8330
+ * @memberof Medals */
8331
+ function medalsForEach(callback)
8332
+ { Object.values(medals).forEach(medal=>callback(medal)); }
8333
+
8334
+ ///////////////////////////////////////////////////////////////////////////////
8335
+
8336
+ /**
8337
+ * Medal - Tracks an unlockable medal
8338
+ * @memberof Medals
8339
+ * @example
8340
+ * // create a medal
8341
+ * const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
8342
+ *
8343
+ * // initialize medals
8344
+ * medalsInit('Example Game');
8345
+ *
8346
+ * // unlock the medal
8347
+ * medal_example.unlock();
8348
+ */
8349
+ class Medal
8350
+ {
8351
+ /** Create a medal object and adds it to the list of medals
8352
+ * @param {number} id - The unique identifier of the medal
8353
+ * @param {string} name - Name of the medal
8354
+ * @param {string} [description] - Description of the medal
8355
+ * @param {string} [icon] - Icon for the medal
8356
+ * @param {string} [src] - Image location for the medal
8357
+ */
8358
+ constructor(id, name, description='', icon='🏆', src)
8359
+ {
8360
+ ASSERT(id >= 0 && !medals[id]);
8361
+
8362
+ /** @property {number} - The unique identifier of the medal */
8363
+ this.id = id;
8364
+
8365
+ /** @property {string} - Name of the medal */
8366
+ this.name = name;
8367
+
8368
+ /** @property {string} - Description of the medal */
8369
+ this.description = description;
8370
+
8371
+ /** @property {string} - Icon for the medal */
8372
+ this.icon = icon;
8373
+
8374
+ /** @property {boolean} - Is the medal unlocked? */
8375
+ this.unlocked = false;
8376
+
8377
+ // load the source image if provided
8378
+ if (src)
8379
+ (this.image = new Image).src = src;
8380
+
8381
+ // add this to list of medals
8382
+ medals[id] = this;
8383
+ }
8384
+
8385
+ /** Unlocks a medal if not already unlocked */
8386
+ unlock()
8387
+ {
8388
+ if (medalsPreventUnlock || this.unlocked) return;
8389
+
8390
+ // save the medal
8391
+ ASSERT(medalsSaveName, 'save name must be set');
8392
+ localStorage[this.storageKey()] = this.unlocked = true;
8393
+ medalsDisplayQueue.push(this);
8394
+ }
8395
+
8396
+ /** Render a medal
8397
+ * @param {number} [hidePercent] - How much to slide the medal off screen
8398
+ */
8399
+ render(hidePercent=0)
8400
+ {
8401
+ const context = mainContext;
8402
+ const width = min(medalDisplaySize.x, mainCanvas.width);
8403
+ const height = medalDisplaySize.y;
8404
+ const x = mainCanvas.width - width;
8405
+ const y = -height*hidePercent;
8406
+ const backgroundColor = hsl(0,0,.9);
8407
+
8408
+ // draw containing rect and clip to that region
8409
+ context.save();
8410
+ context.beginPath();
8411
+ context.fillStyle = backgroundColor.toString();
8412
+ context.strokeStyle = BLACK.toString();
8413
+ context.lineWidth = 3;
8414
+ context.rect(x, y, width, height);
8415
+ context.fill();
8416
+ context.stroke();
8417
+ context.clip();
8418
+
8419
+ // draw the icon
8420
+ const gap = vec2(.1, .05).scale(height);
8421
+ const medalDisplayIconSize = height - 2*gap.x;
8422
+ this.renderIcon(vec2(x + gap.x + medalDisplayIconSize/2, y + height/2), medalDisplayIconSize);
8423
+
8424
+ // draw the name
8425
+ const nameSize = height*.5;
8426
+ const descriptionSize = height*.3;
8427
+ const pos = vec2(x + medalDisplayIconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
8428
+ const textWidth = width - medalDisplayIconSize - 3*gap.x;
8429
+ drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
8430
+
8431
+ // draw the description
8432
+ pos.y = y + height - gap.y*2 - descriptionSize/2;
8433
+ drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
8434
+ context.restore();
8435
+ }
8436
+
8437
+ /** Render the icon for a medal
8438
+ * @param {Vector2} pos - Screen space position
8439
+ * @param {number} size - Screen space size
8440
+ */
8441
+ renderIcon(pos, size)
8442
+ {
8443
+ // draw the image or icon
8444
+ if (this.image)
8445
+ mainContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size);
8446
+ else
8447
+ drawTextScreen(this.icon, pos, size*.7, BLACK);
8448
+ }
8449
+
8450
+ // Get local storage key used by the medal
8451
+ storageKey() { return medalsSaveName + '_' + this.id; }
8452
+ }
8453
+
8454
+ ///////////////////////////////////////////////////////////////////////////////
8455
+ // Medals setting setters
8456
+
8457
+ /** Set how long to show medals for in seconds
8458
+ * @param {number} time
8459
+ * @memberof Settings */
8460
+ function setMedalDisplayTime(time) { medalDisplayTime = time; }
8461
+
8462
+ /** Set how quickly to slide on/off medals in seconds
8463
+ * @param {number} time
8464
+ * @memberof Settings */
8465
+ function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
8466
+
8467
+ /** Set size of medal display
8468
+ * @param {Vector2} size
8469
+ * @memberof Settings */
8470
+ function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
8471
+
8472
+ /** Set to stop medals from being unlockable
8473
+ * @param {boolean} preventUnlock
8474
+ * @memberof Settings */
8475
+ function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }
8476
+
8477
+ /**
8478
+ * LittleJS Newgrounds Plugin
8479
+ * - NewgroundsMedal extends Medal with Newgrounds API functionality
8480
+ * - Call new NewgroundsPlugin(app_id) to setup Newgrounds
8481
+ * - Uses CryptoJS for encryption if optional cipher is provided
8482
+ * - provides functions to interact with medals scoreboards
8483
+ * - Keeps connection alive and logs views
8484
+ * @namespace Newgrounds
8485
+ */
8486
+
8487
+ /** Global Newgrounds object
8488
+ * @type {NewgroundsPlugin}
8489
+ * @memberof Newgrounds */
8490
+ let newgrounds;
8491
+
8492
+ ///////////////////////////////////////////////////////////////////////////////
8493
+ /**
8494
+ * Newgrounds medal auto unlocks in newgrounds API
8495
+ * @extends Medal
8496
+ * @memberof Newgrounds
8497
+ */
8498
+ class NewgroundsMedal extends Medal
8499
+ {
8500
+ /** Create a newgrounds medal object and adds it to the list of medals
8501
+ * @param {number} id - The unique identifier of the medal
8502
+ * @param {string} name - Name of the medal
8503
+ * @param {string} [description] - Description of the medal
8504
+ * @param {string} [icon] - Icon for the medal
8505
+ * @param {string} [src] - Image location for the medal
8506
+ */
8507
+ constructor(id, name, description, icon, src)
8508
+ { super(id, name, description, icon, src); }
8509
+
8510
+ /** Unlocks a medal if not already unlocked */
8511
+ unlock()
8512
+ {
8513
+ super.unlock();
8514
+ newgrounds && newgrounds.unlockMedal(this.id);
8515
+ }
8516
+ }
8517
+
8264
8518
  ///////////////////////////////////////////////////////////////////////////////
8265
8519
  /**
8266
8520
  * Newgrounds API object
@@ -8836,16 +9090,13 @@ class UISystemPlugin
8836
9090
  this.lastHoverObject = undefined;
8837
9091
  /** @property {UIObject} - Current confirm menu being shown */
8838
9092
  this.confirmDialog = undefined;
8839
- /** @property {UIObject} - Object to send keyboard input to */
8840
- this.keyInputObject = undefined;
9093
+ /** @private */
9094
+ this._keyInputObject = undefined;
9095
+ /** @private */
9096
+ this._onKeyDown = (e) => this._keyInputObject?.onKeyDown(e);
8841
9097
 
8842
9098
  engineAddPlugin(uiUpdate, uiRender);
8843
9099
 
8844
- // key down handler
8845
- function onKeyDown(e)
8846
- { uiSystem.keyInputObject?.onKeyDown(e); }
8847
- document.addEventListener('keydown', onKeyDown);
8848
-
8849
9100
  // set object position in parent space
8850
9101
  function updateTransforms(o)
8851
9102
  {
@@ -9187,8 +9438,23 @@ class UISystemPlugin
9187
9438
  return p;
9188
9439
  }
9189
9440
 
9441
+ /** Object to send keyboard input to (typically a UITextInput).
9442
+ * The document keydown listener is only attached while this is set,
9443
+ * so games that never use text input pay no event-handling cost.
9444
+ * @type {UIObject} */
9445
+ get keyInputObject() { return this._keyInputObject; }
9446
+ set keyInputObject(obj)
9447
+ {
9448
+ const had = !!this._keyInputObject;
9449
+ this._keyInputObject = obj;
9450
+ if (!had && obj)
9451
+ document.addEventListener('keydown', this._onKeyDown);
9452
+ else if (had && !obj)
9453
+ document.removeEventListener('keydown', this._onKeyDown);
9454
+ }
9455
+
9190
9456
  /** Destroy and remove all objects
9191
- * @memberof Engine */
9457
+ * @memberof UISystem */
9192
9458
  destroyObjects()
9193
9459
  {
9194
9460
  for (const o of this.uiObjects)
@@ -9611,15 +9877,13 @@ class UIObject
9611
9877
  * @return {string} */
9612
9878
  toString()
9613
9879
  {
9614
- if (!debug) return;
9615
-
9616
9880
  let text = 'type = ' + this.constructor.name;
9617
9881
  if (this.text)
9618
9882
  text += '\ntext = ' + this.text;
9619
9883
  if (this.pos.x || this.pos.y)
9620
9884
  text += '\npos = ' + this.pos;
9621
9885
  if (this.localPos.x || this.localPos.y)
9622
- text += '\localPos = ' + this.localPos;
9886
+ text += '\nlocalPos = ' + this.localPos;
9623
9887
  if (this.size.x || this.size.y)
9624
9888
  text += '\nsize = ' + this.size;
9625
9889
  if (this.color)
@@ -9642,7 +9906,7 @@ class UIObject
9642
9906
 
9643
9907
  /** Internal function called when object is clicked
9644
9908
  * @param {boolean} [playSound] */
9645
- click(playSound)
9909
+ click(playSound=true)
9646
9910
  {
9647
9911
  this.onClick();
9648
9912
  if (playSound && this.soundClick)
@@ -10213,6 +10477,135 @@ class UIVideo extends UIObject
10213
10477
  this.video.remove();
10214
10478
  super.destroy();
10215
10479
  }
10480
+ }
10481
+
10482
+ ///////////////////////////////////////////////////////////////////////////////
10483
+ /**
10484
+ * UILayout - A container that auto-arranges children in a vertical list, horizontal list, or grid
10485
+ * - Set columns to 1 for a vertical list (default)
10486
+ * - Set columns to children.length for a horizontal list
10487
+ * - Set columns to N (1 < N < children.length) for a grid with N columns
10488
+ * - Per-child sizing: each row's height = max child.size.y in that row, each column's width = max child.size.x in that column
10489
+ * - Children are positioned centered in their cell
10490
+ * - Container auto-sizes to fit children plus padding
10491
+ * @extends UIObject
10492
+ * @memberof UISystem
10493
+ */
10494
+ class UILayout extends UIObject
10495
+ {
10496
+ /** Create a UILayout container that auto-arranges children
10497
+ * @param {Vector2} [pos]
10498
+ * @param {number} [columns=1] - Number of columns (1 = vertical list)
10499
+ * @param {number} [gap=10] - Space between children
10500
+ * @param {number} [padding=10] - Space between container border and children
10501
+ * @param {boolean} [transparent=false] - If true, draws no background, outline, or shadow
10502
+ */
10503
+ constructor(pos, columns=1, gap=10, padding=10, transparent=false)
10504
+ {
10505
+ super(pos);
10506
+
10507
+ ASSERT(isNumber(columns) && columns >= 1, 'ui layout columns must be a number >= 1');
10508
+ ASSERT(isNumber(gap), 'ui layout gap must be a number');
10509
+ ASSERT(isNumber(padding), 'ui layout padding must be a number');
10510
+
10511
+ /** @property {number} - Number of columns in the layout */
10512
+ this.columns = columns;
10513
+ /** @property {number} - Space between children */
10514
+ this.gap = gap;
10515
+ /** @property {number} - Space between container border and children */
10516
+ this.padding = padding;
10517
+
10518
+ if (transparent)
10519
+ {
10520
+ // pure positioning helper - skip background, outline, and shadow
10521
+ this.color = CLEAR_BLACK;
10522
+ this.gradientColor = undefined;
10523
+ this.lineWidth = 0;
10524
+ this.shadowColor = CLEAR_BLACK;
10525
+ }
10526
+ this.relayout();
10527
+ }
10528
+
10529
+ /** Add a child UIObject and re-layout
10530
+ * @param {UIObject} child
10531
+ * @return {UIObject} The child object added */
10532
+ addChild(child)
10533
+ {
10534
+ super.addChild(child);
10535
+ this.relayout();
10536
+ return child;
10537
+ }
10538
+
10539
+ /** Remove a child UIObject and re-layout
10540
+ * @param {UIObject} child */
10541
+ removeChild(child)
10542
+ {
10543
+ super.removeChild(child);
10544
+ this.relayout();
10545
+ }
10546
+
10547
+ /** Recompute child positions and container size based on per-child sizes.
10548
+ * Called automatically by addChild and removeChild. Call manually if you
10549
+ * mutate a child's size or change columns, gap, or padding. */
10550
+ relayout()
10551
+ {
10552
+ const n = this.children.length;
10553
+ if (!n)
10554
+ {
10555
+ this.size = vec2(this.padding * 2);
10556
+ return;
10557
+ }
10558
+
10559
+ const cols = this.columns;
10560
+ const rows = ceil(n / cols);
10561
+ const colWidths = new Array(cols).fill(0);
10562
+ const rowHeights = new Array(rows).fill(0);
10563
+
10564
+ // first pass: compute column widths and row heights from child sizes
10565
+ for (let i = 0; i < n; ++i)
10566
+ {
10567
+ const col = i % cols;
10568
+ const row = floor(i / cols);
10569
+ const child = this.children[i];
10570
+ colWidths[col] = max(colWidths[col], child.size.x);
10571
+ rowHeights[row] = max(rowHeights[row], child.size.y);
10572
+ }
10573
+
10574
+ // total content size (sum of column widths/row heights plus gaps between them)
10575
+ let contentWidth = this.gap * (cols - 1);
10576
+ for (const w of colWidths) contentWidth += w;
10577
+ let contentHeight = this.gap * (rows - 1);
10578
+ for (const h of rowHeights) contentHeight += h;
10579
+
10580
+ // cumulative column/row offsets so positioning is O(n) not O(n^2)
10581
+ const colOffsets = new Array(cols);
10582
+ let xAcc = 0;
10583
+ for (let c = 0; c < cols; ++c)
10584
+ {
10585
+ colOffsets[c] = xAcc;
10586
+ xAcc += colWidths[c];
10587
+ }
10588
+ const rowOffsets = new Array(rows);
10589
+ let yAcc = 0;
10590
+ for (let r = 0; r < rows; ++r)
10591
+ {
10592
+ rowOffsets[r] = yAcc;
10593
+ yAcc += rowHeights[r];
10594
+ }
10595
+
10596
+ // second pass: position each child centered in its cell
10597
+ for (let i = 0; i < n; ++i)
10598
+ {
10599
+ const col = i % cols;
10600
+ const row = floor(i / cols);
10601
+ const x = -contentWidth/2 + colOffsets[col] + this.gap * col + colWidths[col] / 2;
10602
+ const y = -contentHeight/2 + rowOffsets[row] + this.gap * row + rowHeights[row] / 2;
10603
+ this.children[i].localPos = vec2(x, y);
10604
+ }
10605
+
10606
+ // container size = content + padding on all sides
10607
+ this.size = vec2(contentWidth + this.padding * 2, contentHeight + this.padding * 2);
10608
+ }
10216
10609
  }
10217
10610
  /**
10218
10611
  * LittleJS Box2D Physics Plugin
@@ -10615,7 +11008,8 @@ class Box2dObject extends EngineObject
10615
11008
  {
10616
11009
  this.pos = pos;
10617
11010
  this.angle = angle;
10618
- this.body.SetTransform(box2d.vec2dTo(pos), angle);
11011
+ // box2d uses reverse angle
11012
+ this.body.SetTransform(box2d.vec2dTo(pos), -angle);
10619
11013
  }
10620
11014
 
10621
11015
  /** Sets the position
@@ -10626,7 +11020,7 @@ class Box2dObject extends EngineObject
10626
11020
  /** Sets the angle
10627
11021
  * @param {number} angle */
10628
11022
  setAngle(angle)
10629
- { this.setTransform(box2d.vec2From(this.body.GetPosition()), -angle); }
11023
+ { this.setTransform(box2d.vec2From(this.body.GetPosition()), angle); }
10630
11024
 
10631
11025
  /** Sets the linear velocity
10632
11026
  * @param {Vector2} velocity */
@@ -10738,14 +11132,29 @@ class Box2dObject extends EngineObject
10738
11132
  this.body.ApplyForce(box2d.vec2dTo(force), box2d.vec2dTo(pos));
10739
11133
  }
10740
11134
 
10741
- /** Apply acceleration to this object
11135
+ /** Apply acceleration to this object (changes velocity by acceleration,
11136
+ * mass-independent — matches EngineObject.applyAcceleration semantics).
11137
+ * Use applyImpulse if you want the mass-dependent velocity change
11138
+ * Δv = impulse / mass, or applyForce for a Newton-style sustained force.
10742
11139
  * @param {Vector2} acceleration
10743
11140
  * @param {Vector2} [pos] */
10744
11141
  applyAcceleration(acceleration, pos)
10745
- {
11142
+ {
10746
11143
  pos ||= this.getCenterOfMass();
10747
11144
  this.setAwake();
10748
- this.body.ApplyLinearImpulse(box2d.vec2dTo(acceleration), box2d.vec2dTo(pos));
11145
+ const impulse = acceleration.scale(this.getMass());
11146
+ this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
11147
+ }
11148
+
11149
+ /** Apply an instantaneous linear impulse. Changes velocity immediately by
11150
+ * impulse / mass (so heavier bodies move less for the same impulse).
11151
+ * @param {Vector2} impulse
11152
+ * @param {Vector2} [pos] */
11153
+ applyImpulse(impulse, pos)
11154
+ {
11155
+ pos ||= this.getCenterOfMass();
11156
+ this.setAwake();
11157
+ this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
10749
11158
  }
10750
11159
 
10751
11160
  /** Apply torque to this object
@@ -10755,13 +11164,23 @@ class Box2dObject extends EngineObject
10755
11164
  this.setAwake();
10756
11165
  this.body.ApplyTorque(torque);
10757
11166
  }
10758
-
10759
- /** Apply angular acceleration to this object
11167
+
11168
+ /** Apply angular acceleration to this object (changes angular velocity by
11169
+ * acceleration, mass-independent — matches EngineObject semantics).
10760
11170
  * @param {number} acceleration */
10761
11171
  applyAngularAcceleration(acceleration)
10762
11172
  {
10763
11173
  this.setAwake();
10764
- this.body.ApplyAngularImpulse(acceleration);
11174
+ this.body.ApplyAngularImpulse(acceleration * this.getInertia());
11175
+ }
11176
+
11177
+ /** Apply an instantaneous angular impulse. Changes angular velocity by
11178
+ * impulse / inertia immediately.
11179
+ * @param {number} impulse */
11180
+ applyAngularImpulse(impulse)
11181
+ {
11182
+ this.setAwake();
11183
+ this.body.ApplyAngularImpulse(impulse);
10765
11184
  }
10766
11185
 
10767
11186
  ///////////////////////////////////////////////////////////////////////////////
@@ -11007,7 +11426,7 @@ class Box2dJoint
11007
11426
 
11008
11427
  /** Check if the connected bodies should collide
11009
11428
  * @return {boolean} */
11010
- getCollideConnected() { return this.box2dJoint.getCollideConnected();}
11429
+ getCollideConnected() { return this.box2dJoint.GetCollideConnected();}
11011
11430
 
11012
11431
  /** Check if either connected body is active
11013
11432
  * @return {boolean} */
@@ -12171,6 +12590,7 @@ async function box2dInit()
12171
12590
  {
12172
12591
  if (o.body)
12173
12592
  {
12593
+ // box2d uses reverse angle
12174
12594
  o.pos = box2d.vec2From(o.body.GetPosition());
12175
12595
  o.angle = -o.body.GetAngle();
12176
12596
  }
@@ -12381,3 +12801,1268 @@ function drawThreeSlice(pos, size, startTile, color, borderSize=1, additiveColor
12381
12801
  drawTile(pos.add(cornerPos.rotate(rotateAngle)), cornerSize, cornerTile, color, a, false, additiveColor, useWebGL, screenSpace, context);
12382
12802
  }
12383
12803
  }
12804
+ /**
12805
+ * LittleJS Tween System Plugin
12806
+ * - Lightweight tweens for numbers, Vector2, Color, or any .lerp-able type
12807
+ * - Chainable easing, looping, and ping-pong
12808
+ * - Property-path helper for the common case of animating an object field
12809
+ * - Auto-updates via engineAddPlugin; pauses with the game by default
12810
+ * @namespace TweenSystem
12811
+ */
12812
+
12813
+ ///////////////////////////////////////////////////////////////////////////////
12814
+
12815
+ // Module-private list of tweens currently running.
12816
+ const tweenActive = [];
12817
+
12818
+ // Time tracking for delta computation between engine plugin calls.
12819
+ let lastTime = 0;
12820
+ let lastTimeReal = 0;
12821
+
12822
+ // True if the value is an instance of a class that exposes a numeric-percent
12823
+ // `lerp(other, percent)` method (Vector2, Color, or any future class).
12824
+ function isLerpable(v) { return v && typeof v.lerp === 'function'; }
12825
+
12826
+ ///////////////////////////////////////////////////////////////////////////////
12827
+
12828
+ /** A numeric tween: drives a callback with a value interpolated between
12829
+ * `start` and `end` over `duration` seconds. Pauses with the game by default.
12830
+ * @memberof TweenSystem
12831
+ * @example
12832
+ * // Animate a fade-out over 2 seconds with an ease-out sine curve.
12833
+ * new Tween((v) => obj.alpha = v, 1, 0, 2, { ease: Ease.OUT(Ease.SINE) });
12834
+ */
12835
+ class Tween
12836
+ {
12837
+ /** Create a new tween. The callback fires immediately with `start` so the
12838
+ * target snaps to the start value on the same frame the tween is created.
12839
+ *
12840
+ * `start` and `end` may be numbers, Vector2 instances, Color instances, or
12841
+ * any object exposing a `lerp(other, percent) => sameType` method. The
12842
+ * callback receives the interpolated value (a number, or a fresh instance
12843
+ * for lerp-able types). Both endpoints must be the same type.
12844
+ * @param {function(number|Vector2|Color):void} callback - Called with the interpolated value each frame
12845
+ * @param {number|Vector2|Color} [start=0] - Starting value
12846
+ * @param {number|Vector2|Color} [end=1] - Ending value
12847
+ * @param {number} [duration=1] - Duration in seconds
12848
+ * @param {Object} [options]
12849
+ * @param {function(number):number} [options.ease] - Easing function (defaults to LINEAR)
12850
+ * @param {boolean} [options.useRealTime=false] - Advance even when the game is paused (matches Timer's useRealTime)
12851
+ * @param {boolean} [options.paused=false] - Start in paused state */
12852
+ constructor(callback, start = 0, end = 1, duration = 1, options = {})
12853
+ {
12854
+ ASSERT(typeof callback === 'function', 'Tween callback must be a function');
12855
+ if (isLerpable(start))
12856
+ {
12857
+ ASSERT(start.constructor === end.constructor,
12858
+ 'Tween start and end must be the same type');
12859
+ }
12860
+ else
12861
+ {
12862
+ ASSERT(isNumber(start), 'Tween start must be a number or have a .lerp method');
12863
+ ASSERT(isNumber(end), 'Tween end must be a number when start is a number');
12864
+ }
12865
+ ASSERT(isNumber(duration) && duration > 0, 'Tween duration must be > 0');
12866
+
12867
+ this.callback = callback;
12868
+ this.start = start;
12869
+ this.end = end;
12870
+ this.duration = duration;
12871
+ this.life = duration;
12872
+ this.ease = options.ease || Ease.LINEAR;
12873
+ this.useRealTime = !!options.useRealTime;
12874
+ this.paused = !!options.paused;
12875
+
12876
+ /** @private completion callback set by then(), loop(), pingPong(). */
12877
+ this.thenCallback = undefined;
12878
+ /** @private remaining iterations including the current run (loop/pingPong only). */
12879
+ this.loopRemaining = 0;
12880
+
12881
+ tweenActive.push(this);
12882
+ // Snap target to start immediately.
12883
+ callback(this.interp(duration));
12884
+ }
12885
+
12886
+ /** Set the easing curve and return this for chaining.
12887
+ * @param {function(number):number} easeFn
12888
+ * @returns {Tween}
12889
+ * @memberof TweenSystem */
12890
+ setEase(easeFn)
12891
+ {
12892
+ this.ease = easeFn;
12893
+ return this;
12894
+ }
12895
+
12896
+ /** Set a single completion callback. Calling `then` again replaces the
12897
+ * previous callback. Returns this for chaining.
12898
+ *
12899
+ * Calling `then` after `loop` or `pingPong` overrides the loop chain
12900
+ * (last call wins).
12901
+ * @param {function():void} callback
12902
+ * @returns {Tween}
12903
+ * @memberof TweenSystem */
12904
+ then(callback)
12905
+ {
12906
+ this.thenCallback = callback;
12907
+ this.loopRemaining = 0;
12908
+ return this;
12909
+ }
12910
+
12911
+ /** Repeat this tween `n` total times. After each iteration finishes, a
12912
+ * fresh tween with the same parameters takes over via the `then` slot.
12913
+ * `loop()` with no argument loops forever.
12914
+ *
12915
+ * Mutually exclusive with `pingPong`; calling either replaces the other,
12916
+ * and calling `then` after either clears the loop (last call wins).
12917
+ * @param {number} [count=Infinity]
12918
+ * @returns {Tween}
12919
+ * @memberof TweenSystem */
12920
+ loop(count = Infinity)
12921
+ {
12922
+ this.loopRemaining = count;
12923
+ this.thenCallback = () => loopContinuation(this);
12924
+ return this;
12925
+ }
12926
+
12927
+ /** Like `loop`, but swap `start` and `end` between iterations so the value
12928
+ * bounces back and forth. `pingPong()` with no argument bounces forever.
12929
+ *
12930
+ * Mutually exclusive with `loop`; calling either replaces the other, and
12931
+ * calling `then` after either clears the loop (last call wins).
12932
+ * @param {number} [count=Infinity]
12933
+ * @returns {Tween}
12934
+ * @memberof TweenSystem */
12935
+ pingPong(count = Infinity)
12936
+ {
12937
+ this.loopRemaining = count;
12938
+ this.thenCallback = () => pingPongContinuation(this);
12939
+ return this;
12940
+ }
12941
+
12942
+ /** Pause this tween. While paused, tweenUpdate skips it.
12943
+ * @memberof TweenSystem */
12944
+ pause() { this.paused = true; }
12945
+
12946
+ /** Resume a paused tween.
12947
+ * @memberof TweenSystem */
12948
+ resume() { this.paused = false; }
12949
+
12950
+ /** Reset this tween to the start: life back to duration, pause cleared,
12951
+ * re-added to the active list if previously stopped, and the callback
12952
+ * re-fired with the start value.
12953
+ * @memberof TweenSystem */
12954
+ restart()
12955
+ {
12956
+ this.life = this.duration;
12957
+ this.paused = false;
12958
+ if (tweenActive.indexOf(this) < 0) tweenActive.push(this);
12959
+ this.callback(this.interp(this.duration));
12960
+ }
12961
+
12962
+ /** True if this tween is in the active list and not paused.
12963
+ * @returns {boolean}
12964
+ * @memberof TweenSystem */
12965
+ isActive()
12966
+ {
12967
+ return !this.paused && tweenActive.indexOf(this) >= 0;
12968
+ }
12969
+
12970
+ /** Get how far this tween has progressed, from 0 (just started) to 1
12971
+ * (completed). Clamped — overshoot past completion still reads 1.
12972
+ * @returns {number}
12973
+ * @memberof TweenSystem */
12974
+ getPercent()
12975
+ {
12976
+ return percent(this.duration - this.life, 0, this.duration);
12977
+ }
12978
+
12979
+ /** Get the current interpolated value (the value most recently passed to
12980
+ * the callback). Returns a number, Vector2, or Color depending on the
12981
+ * tween's start/end types.
12982
+ * @returns {number|Vector2|Color}
12983
+ * @memberof TweenSystem */
12984
+ getValue()
12985
+ {
12986
+ return this.interp(this.life);
12987
+ }
12988
+
12989
+ /** Compute the interpolated value at the given remaining `life`.
12990
+ * At life === duration the result is `start`; at life === 0 it is `end`.
12991
+ * @param {number} life
12992
+ * @returns {number}
12993
+ * @memberof TweenSystem */
12994
+ interp(life)
12995
+ {
12996
+ const x = this.ease((this.duration - life) / this.duration);
12997
+ if (isLerpable(this.start))
12998
+ return this.start.lerp(this.end, x);
12999
+ return this.start + (this.end - this.start) * x;
13000
+ }
13001
+
13002
+ /** Remove this tween from the active list and prevent any pending then-callback.
13003
+ * @memberof TweenSystem */
13004
+ stop()
13005
+ {
13006
+ const i = tweenActive.indexOf(this);
13007
+ if (i >= 0) tweenActive.splice(i, 1);
13008
+ this.thenCallback = undefined;
13009
+ }
13010
+ }
13011
+
13012
+ /** Library of named easing curves and direction modifiers.
13013
+ * All curves accept `x` in [0,1] and return [0,1] (with possible overshoot
13014
+ * for ELASTIC/BACK/SPRING/BOUNCE). Curves are values you pass to `setEase`
13015
+ * or compose via the IN/OUT/IN_OUT/PIECEWISE/BEZIER modifiers.
13016
+ * @memberof TweenSystem
13017
+ * @example
13018
+ * // Use a basic curve
13019
+ * new Tween(callback, 0, 10, 1).setEase(Ease.SINE);
13020
+ * // Use a modifier on a curve
13021
+ * new Tween(callback, 0, 10, 1).setEase(Ease.OUT(Ease.BACK));
13022
+ */
13023
+ const Ease =
13024
+ {
13025
+ /** Linear (identity) curve.
13026
+ * @param {number} x
13027
+ * @returns {number}
13028
+ * @memberof TweenSystem */
13029
+ LINEAR: (x) => x,
13030
+
13031
+ /** Power curve factory: `Ease.POWER(n)` returns `x => x**n`.
13032
+ * Use n=2 for quadratic, n=3 for cubic, etc.
13033
+ * @param {number} n
13034
+ * @returns {function(number):number}
13035
+ * @memberof TweenSystem */
13036
+ POWER: (n) => (x) => x ** n,
13037
+
13038
+ /** Sine ease-in curve: starts slow, ends fast.
13039
+ * @param {number} x
13040
+ * @returns {number}
13041
+ * @memberof TweenSystem */
13042
+ SINE: (x) => 1 - cos(x * (PI / 2)),
13043
+
13044
+ /** Circular ease-in curve.
13045
+ * @param {number} x
13046
+ * @returns {number}
13047
+ * @memberof TweenSystem */
13048
+ CIRC: (x) => 1 - (1 - x * x)**.5,
13049
+
13050
+ /** Exponential ease-in curve (`2^(10x-10)`).
13051
+ * @param {number} x
13052
+ * @returns {number}
13053
+ * @memberof TweenSystem */
13054
+ EXPO: (x) => 2 ** (10 * x - 10),
13055
+
13056
+ /** Back ease-in: overshoots backward at the start before snapping forward.
13057
+ * @param {number} x
13058
+ * @returns {number}
13059
+ * @memberof TweenSystem */
13060
+ BACK: (x) => x * x * (2.70158 * x - 1.70158),
13061
+
13062
+ /** Elastic ease-in: oscillates with decreasing amplitude.
13063
+ * @param {number} x
13064
+ * @returns {number}
13065
+ * @memberof TweenSystem */
13066
+ ELASTIC: (x) =>
13067
+ -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
13068
+
13069
+ /** Spring-like ease-out: oscillates outward after passing the target.
13070
+ * @param {number} x
13071
+ * @returns {number}
13072
+ * @memberof TweenSystem */
13073
+ SPRING: (x) =>
13074
+ 1 -
13075
+ (sin(PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
13076
+ x ** 2.2 +
13077
+ (1 - x)) *
13078
+ (1.0 + 1.2 * x),
13079
+
13080
+ /** Bouncing ease-in: slow ramp with bouncing impacts near the end.
13081
+ * Symmetric with the other base curves, which are all ease-in. To get the
13082
+ * classic "object falls and hits the ground" shape (bounces near x=1),
13083
+ * wrap with `Ease.OUT`: `Ease.OUT(Ease.BOUNCE)`.
13084
+ * @param {number} x
13085
+ * @returns {number}
13086
+ * @memberof TweenSystem
13087
+ * @example
13088
+ * Ease.BOUNCE // ease-in bounce (slow, then bouncy at end)
13089
+ * Ease.OUT(Ease.BOUNCE) // ease-out bounce (object hits ground)
13090
+ * Ease.IN_OUT(Ease.BOUNCE) // bounces at both ends
13091
+ */
13092
+ BOUNCE: (x) =>
13093
+ {
13094
+ // Inverted form of the standard easeOutBounce: 1 - bounceOut(1 - x).
13095
+ let t = 1 - x, f;
13096
+ if (t < 4 / 11) f = 7.5625 * t * t;
13097
+ else if (t < 8 / 11) f = 7.5625 * (t -= 6 / 11) * t + 0.75;
13098
+ else if (t < 10 / 11) f = 7.5625 * (t -= 9 / 11) * t + 0.9375;
13099
+ else f = 7.5625 * (t -= 10.5 / 11) * t + 0.984375;
13100
+ return 1 - f;
13101
+ },
13102
+
13103
+ /** Ease-in direction modifier: returns the curve unchanged. Symmetric
13104
+ * with `OUT` and `IN_OUT`. Base curves are already ease-in by
13105
+ * convention, so wrapping a curve in `IN` is a no-op — useful when
13106
+ * picking the direction programmatically.
13107
+ * @param {function(number):number} f - Curve to use as ease-in (returned unchanged)
13108
+ * @returns {function(number):number}
13109
+ * @memberof TweenSystem
13110
+ * @example
13111
+ * // Pick direction at runtime
13112
+ * const dir = bouncyMode ? Ease.OUT : Ease.IN;
13113
+ * new Tween(cb, 0, 10, 1).setEase(dir(Ease.BACK));
13114
+ */
13115
+ IN: (f) => f,
13116
+
13117
+ /** Reverse a curve so it eases out instead of in: `x => 1 - f(1 - x)`.
13118
+ * @param {function(number):number} f
13119
+ * @returns {function(number):number}
13120
+ * @memberof TweenSystem
13121
+ * @example
13122
+ * Ease.OUT(Ease.POWER(2)) // ease-out quadratic
13123
+ */
13124
+ OUT: (f) => (x) => 1 - f(1 - x),
13125
+
13126
+ /** Combine the first half of `f` with `Ease.OUT(f)` for a symmetric curve.
13127
+ * Bug-fix vs the original library: the original referenced an undefined
13128
+ * global `Piecewise`; this implementation routes through `Ease.PIECEWISE`.
13129
+ * @param {function(number):number} f
13130
+ * @returns {function(number):number}
13131
+ * @memberof TweenSystem */
13132
+ IN_OUT: (f) => Ease.PIECEWISE(f, Ease.OUT(f)),
13133
+
13134
+ /** Split [0,1] into N equal sections and run a different curve in each.
13135
+ * Each curve is mapped to its section: section i runs over [i/n, (i+1)/n]
13136
+ * and its output is mapped to [i/n, (i+1)/n] of the overall range.
13137
+ * @param {...function(number):number} fns
13138
+ * @returns {function(number):number}
13139
+ * @memberof TweenSystem */
13140
+ PIECEWISE: (...fns) =>
13141
+ {
13142
+ const n = fns.length;
13143
+ return (x) =>
13144
+ {
13145
+ const i = (x * n - 1e-9) >> 0;
13146
+ return (fns[i]((x - i / n) * n) + i) / n;
13147
+ };
13148
+ },
13149
+
13150
+ /** Cubic Bezier curve solver in the style of CSS `cubic-bezier`.
13151
+ * Control points (0,0), (x1,y1), (x2,y2), (1,1).
13152
+ * @param {number} x1
13153
+ * @param {number} y1
13154
+ * @param {number} x2
13155
+ * @param {number} y2
13156
+ * @returns {function(number):number}
13157
+ * @memberof TweenSystem
13158
+ * @example
13159
+ * Ease.BEZIER(0.25, 0.1, 0.25, 1) // CSS "ease"
13160
+ */
13161
+ BEZIER: (x1, y1, x2, y2) =>
13162
+ {
13163
+ // Parametric cubic Bezier with implicit (0,0) and (1,1) endpoints.
13164
+ const curve = (t) =>
13165
+ {
13166
+ const u = 1 - t;
13167
+ const c1 = 3 * u * u * t;
13168
+ const c2 = 3 * u * t * t;
13169
+ const t3 = t ** 3;
13170
+ return [c1 * x1 + c2 * x2 + t3, c1 * y1 + c2 * y2 + t3];
13171
+ };
13172
+ return (x) =>
13173
+ {
13174
+ // Binary search for t such that curve(t).x ≈ x, then return curve(t).y.
13175
+ let t0 = 0, t1 = 1;
13176
+ for (let i = 0; i < 128; i++)
13177
+ {
13178
+ const tMid = (t0 + t1) / 2;
13179
+ const [bx, by] = curve(tMid);
13180
+ if (abs(bx - x) < 1e-5) return by;
13181
+ if (bx < x) t0 = tMid; else t1 = tMid;
13182
+ }
13183
+ return curve((t0 + t1) / 2)[1];
13184
+ };
13185
+ },
13186
+ };
13187
+
13188
+ /** Tween a property on an object by dot-path. Returns the underlying Tween
13189
+ * so all chaining methods (`setEase`, `then`, `loop`, `pingPong`, etc.)
13190
+ * remain available.
13191
+ *
13192
+ * `start` and `end` may be numbers, Vector2 instances, Color instances, or
13193
+ * any object with a `lerp(other, percent) => sameType` method.
13194
+ * @param {Object} target - The object whose property is being animated
13195
+ * @param {string} propertyPath - Dot-separated path, e.g. `'pos.x'` or `'color'`
13196
+ * @param {number|Vector2|Color} start - Starting value
13197
+ * @param {number|Vector2|Color} end - Ending value
13198
+ * @param {number} [duration=1] - Duration in seconds
13199
+ * @param {Object} [options] - Same options as the Tween constructor
13200
+ * @returns {Tween}
13201
+ * @memberof TweenSystem
13202
+ * @example
13203
+ * // Numeric: slide an object's x with an ease-out sine curve
13204
+ * tweenProperty(player, 'pos.x', 0, 10, 2).setEase(Ease.OUT(Ease.SINE));
13205
+ * // Vector2: animate a position diagonally
13206
+ * tweenProperty(player, 'pos', vec2(-5, 0), vec2(5, 3), 2);
13207
+ * // Color: pulse between two colors
13208
+ * tweenProperty(sprite, 'color', RED, BLUE, 1).pingPong();
13209
+ */
13210
+ function tweenProperty(target, propertyPath, start, end, duration = 1, options = {})
13211
+ {
13212
+ ASSERT(target != null && typeof target === 'object', 'tweenProperty target must be an object');
13213
+ ASSERT(isString(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string');
13214
+
13215
+ const parts = propertyPath.split('.');
13216
+ const lastKey = parts.pop();
13217
+ const callback = (value) =>
13218
+ {
13219
+ let obj = target;
13220
+ for (const k of parts) obj = obj[k];
13221
+ obj[lastKey] = value;
13222
+ };
13223
+ return new Tween(callback, start, end, duration, options);
13224
+ }
13225
+
13226
+ // Continuation that schedules the next loop iteration when one finishes.
13227
+ // Called from the completed tween's `then` slot. Decrements the counter and
13228
+ // only spawns a new tween if more iterations remain.
13229
+ function loopContinuation(prev)
13230
+ {
13231
+ if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
13232
+ const next = new Tween(prev.callback, prev.start, prev.end, prev.duration,
13233
+ { ease: prev.ease, useRealTime: prev.useRealTime });
13234
+ next.loopRemaining = prev.loopRemaining === Infinity
13235
+ ? Infinity
13236
+ : prev.loopRemaining - 1;
13237
+ next.thenCallback = () => loopContinuation(next);
13238
+ }
13239
+
13240
+ // Continuation for pingPong: spawns a new tween with start and end swapped.
13241
+ function pingPongContinuation(prev)
13242
+ {
13243
+ if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
13244
+ const next = new Tween(prev.callback, prev.end, prev.start, prev.duration,
13245
+ { ease: prev.ease, useRealTime: prev.useRealTime });
13246
+ next.loopRemaining = prev.loopRemaining === Infinity
13247
+ ? Infinity
13248
+ : prev.loopRemaining - 1;
13249
+ next.thenCallback = () => pingPongContinuation(next);
13250
+ }
13251
+
13252
+ /** Engine plugin hook: advance every active tween by the appropriate delta.
13253
+ * Called once per render frame by the engine (no arguments). May also be
13254
+ * called explicitly with `(gameDelta, realDelta)` to drive tweens manually
13255
+ * — useful for headless tests or custom replay/scrubbing systems.
13256
+ * @param {number} [gameDelta] - Game-time delta in seconds; default: time - lastTime
13257
+ * @param {number} [realDelta] - Real-time delta in seconds; default: timeReal - lastTimeReal
13258
+ * @memberof TweenSystem */
13259
+ function tweenUpdate(gameDelta, realDelta)
13260
+ {
13261
+ if (gameDelta === undefined)
13262
+ {
13263
+ // Engine path: compute deltas from engine time globals.
13264
+ gameDelta = time - lastTime;
13265
+ realDelta = timeReal - lastTimeReal;
13266
+ lastTime = time;
13267
+ lastTimeReal = timeReal;
13268
+ }
13269
+ else if (realDelta === undefined)
13270
+ {
13271
+ // Manual path with one arg: real and game advance together.
13272
+ realDelta = gameDelta;
13273
+ }
13274
+
13275
+ // Iterate in reverse so removals don't disturb iteration.
13276
+ for (let i = tweenActive.length; i--;)
13277
+ {
13278
+ const t = tweenActive[i];
13279
+ if (t.paused) continue;
13280
+ const dt = t.useRealTime ? realDelta : gameDelta;
13281
+ if (dt <= 0) continue;
13282
+
13283
+ t.life -= dt;
13284
+ if (t.life > 0)
13285
+ {
13286
+ t.callback(t.interp(t.life));
13287
+ }
13288
+ else
13289
+ {
13290
+ // Completion: fire end value, remove from active, fire then-callback.
13291
+ t.callback(t.interp(0));
13292
+ tweenActive.splice(i, 1);
13293
+ const cb = t.thenCallback;
13294
+ t.thenCallback = undefined;
13295
+ if (cb) cb();
13296
+ }
13297
+ }
13298
+ }
13299
+
13300
+ /** Stop every active tween and clear their then-callbacks. Useful for resets
13301
+ * on level transitions or when changing scenes.
13302
+ * @memberof TweenSystem */
13303
+ function tweenStopAll()
13304
+ {
13305
+ for (const t of tweenActive) t.thenCallback = undefined;
13306
+ tweenActive.length = 0;
13307
+ }
13308
+
13309
+ // Register with the engine so tweens auto-advance.
13310
+ engineAddPlugin(tweenUpdate);
13311
+
13312
+ /**
13313
+ * LittleJS PathFinder Plugin
13314
+ * - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths
13315
+ * - Works directly on a TileCollisionLayer, or override isWalkable/getCost for any grid
13316
+ * - Debug visualization via engine debug primitives (stripped in release builds)
13317
+ * - Port of frankforce.com pathFindingBase.cpp (2018)
13318
+ * @namespace PathFinding
13319
+ */
13320
+
13321
+ ///////////////////////////////////////////////////////////////////////////////
13322
+
13323
+ // Diagonal step cost — pre-computed for the A* expansion inner loop.
13324
+ const PATHFINDER_DIAGONAL_COST = Math.SQRT2;
13325
+
13326
+ // Shared 1x1 size vector for per-tile debugRect calls. debugRect copies the
13327
+ // argument internally, so reusing one instance is safe.
13328
+ const PATHFINDER_TILE_VEC = vec2(1);
13329
+
13330
+ ///////////////////////////////////////////////////////////////////////////////
13331
+
13332
+ /** A single grid cell tracked by the pathfinder. Allocated once per cell at
13333
+ * PathFinder construction; reset (not reallocated) at the start of every
13334
+ * findPath call.
13335
+ * @memberof PathFinding */
13336
+ class PathFinderNode
13337
+ {
13338
+ /** @param {number} x - Tile x
13339
+ * @param {number} y - Tile y */
13340
+ constructor(x, y)
13341
+ {
13342
+ /** @property {Vector2} - Tile coords (integer) */
13343
+ this.pos = vec2(x, y);
13344
+ /** @property {Vector2} - World-space center of this tile (set by buildNodeData) */
13345
+ this.posWorld = vec2();
13346
+ /** @property {boolean} - True if this cell is passable (cleared each findPath call) */
13347
+ this.walkable = false;
13348
+ /** @property {number} - Extra cost added to A* G-score for stepping on this cell */
13349
+ this.cost = 0;
13350
+ /** @property {number} - A* G-score: actual cost from start to this node */
13351
+ this.g = 0;
13352
+ /** @property {number} - A* F-score: G + heuristic */
13353
+ this.f = 0;
13354
+ /** @property {PathFinderNode|null} - Parent for path reconstruction */
13355
+ this.parent = null;
13356
+ /** @property {boolean} - In the A* open list */
13357
+ this.isOpen = false;
13358
+ /** @property {boolean} - In the A* closed list */
13359
+ this.isClosed = false;
13360
+ }
13361
+
13362
+ /** Reset per-search state (called at the start of buildNodeData). */
13363
+ reset()
13364
+ {
13365
+ this.walkable = false;
13366
+ this.cost = 0;
13367
+ this.g = 0;
13368
+ this.f = 0;
13369
+ this.parent = null;
13370
+ this.isOpen = false;
13371
+ this.isClosed = false;
13372
+ }
13373
+
13374
+ /** True if walkable and not blocked by cost. */
13375
+ isClear()
13376
+ {
13377
+ return this.walkable && this.cost === 0;
13378
+ }
13379
+ }
13380
+
13381
+ ///////////////////////////////////////////////////////////////////////////////
13382
+
13383
+ /** Grid pathfinder using A* with two optional smoothing passes.
13384
+ * @memberof PathFinding
13385
+ * @example
13386
+ * // Tile-layer driven (most common):
13387
+ * const pf = new PathFinder(myTileCollisionLayer);
13388
+ * const path = pf.findPath(player.pos, mousePos);
13389
+ *
13390
+ * // Bare grid with custom walkability:
13391
+ * const pf = new PathFinder(vec2(50, 50));
13392
+ * pf.isWalkable = (x, y) => myGrid[y*50 + x] === 0;
13393
+ */
13394
+ class PathFinder
13395
+ {
13396
+ /** @param {TileCollisionLayer|Vector2} source - Either a TileCollisionLayer
13397
+ * (size and walkability auto-derived) or a Vector2 grid size (user
13398
+ * overrides isWalkable). */
13399
+ constructor(source)
13400
+ {
13401
+ // Accept either a Vector2 size or a TileCollisionLayer (which has a .size).
13402
+ // We don't import TileCollisionLayer to avoid coupling; we duck-type on
13403
+ // .size + .getCollisionData.
13404
+ if (isVector2(source))
13405
+ {
13406
+ this.size = source.floor();
13407
+ this.tileLayer = undefined;
13408
+ }
13409
+ else
13410
+ {
13411
+ ASSERT(source && isVector2(source.size) && typeof source.getCollisionData === 'function',
13412
+ 'PathFinder requires a Vector2 size or a TileCollisionLayer');
13413
+ this.size = source.size;
13414
+ this.tileLayer = source;
13415
+ }
13416
+
13417
+ // Tunables (public, freely re-assignable).
13418
+ this.heuristicWeight = 1;
13419
+ this.maxLoop = 500;
13420
+ this.smoothPath = true;
13421
+ this.debug = false;
13422
+ this.debugTime = 2;
13423
+
13424
+ // Pre-allocate the node array — one node per tile, reused across calls.
13425
+ this.nodes = new Array(this.size.x * this.size.y);
13426
+ for (let y = 0; y < this.size.y; ++y)
13427
+ for (let x = 0; x < this.size.x; ++x)
13428
+ this.nodes[x + y * this.size.x] = new PathFinderNode(x, y);
13429
+
13430
+ // Scratch Vector2 reused to avoid allocations in the isWalkable hot path.
13431
+ this.collisionScratch = vec2();
13432
+ }
13433
+
13434
+ /** Default walkability: if a tile layer was provided, returns true when the
13435
+ * cell has no solid collision data; otherwise returns true. Override on
13436
+ * the instance or via a subclass.
13437
+ * @param {number} x - Tile x
13438
+ * @param {number} y - Tile y
13439
+ * @returns {boolean} */
13440
+ isWalkable(x, y)
13441
+ {
13442
+ if (!this.tileLayer) return true;
13443
+ return !this.tileLayer.getCollisionData(this.collisionScratch.set(x, y));
13444
+ }
13445
+
13446
+ /** Default extra cost for stepping on a cell. Returns 0 (free) by default.
13447
+ * Override to add cost-weighted terrain (mud, swamp, etc).
13448
+ * @param {number} x - Tile x
13449
+ * @param {number} y - Tile y
13450
+ * @returns {number} */
13451
+ getCost(x, y)
13452
+ {
13453
+ return 0;
13454
+ }
13455
+
13456
+ /** Get the node at tile coords, or null if out of bounds.
13457
+ * @param {number} x
13458
+ * @param {number} y
13459
+ * @returns {PathFinderNode|null} */
13460
+ getNode(x, y)
13461
+ {
13462
+ if (x < 0 || y < 0 || x >= this.size.x || y >= this.size.y) return null;
13463
+ return this.nodes[x + y * this.size.x];
13464
+ }
13465
+
13466
+ /** Convert a world-space position to integer tile coords (no clamping).
13467
+ * @param {Vector2} worldPos
13468
+ * @returns {Vector2}
13469
+ * @memberof PathFinding */
13470
+ worldToTile(worldPos)
13471
+ {
13472
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
13473
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
13474
+ return vec2(floor(worldPos.x - ox), floor(worldPos.y - oy));
13475
+ }
13476
+
13477
+ /** Convert integer tile coords to the world-space center of that tile.
13478
+ * @param {number} x
13479
+ * @param {number} y
13480
+ * @returns {Vector2}
13481
+ * @memberof PathFinding */
13482
+ tileToWorld(x, y)
13483
+ {
13484
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
13485
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
13486
+ return vec2(x + 0.5 + ox, y + 0.5 + oy);
13487
+ }
13488
+
13489
+ /** Reset all nodes and re-populate walkable / cost / posWorld from the
13490
+ * current isWalkable / getCost overrides. Called at the start of
13491
+ * findPath; exposed so tests and tooling can drive it directly.
13492
+ * @private */
13493
+ buildNodeData()
13494
+ {
13495
+ const w = this.size.x;
13496
+ const h = this.size.y;
13497
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
13498
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
13499
+ for (let y = 0; y < h; ++y)
13500
+ for (let x = 0; x < w; ++x)
13501
+ {
13502
+ const node = this.nodes[x + y * w];
13503
+ node.reset();
13504
+ const walkable = !!this.isWalkable(x, y);
13505
+ const cost = walkable ? max(0, this.getCost(x, y)) : 0;
13506
+ node.walkable = walkable;
13507
+ node.cost = cost;
13508
+ node.posWorld.set(x + 0.5 + ox, y + 0.5 + oy);
13509
+
13510
+ if (this.debug && this.debugTime > 0)
13511
+ {
13512
+ if (!walkable)
13513
+ debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, 0.25), this.debugTime);
13514
+ else if (cost > 0)
13515
+ debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, min(0.2, cost * 0.05)), this.debugTime);
13516
+ }
13517
+ }
13518
+ }
13519
+
13520
+ /** Core A* search loop. Expects buildNodeData() to have been called first.
13521
+ * Marks node.parent for path reconstruction. Returns true if endNode was
13522
+ * reached; false on disconnected goal or maxLoop exhaustion.
13523
+ * @param {PathFinderNode} startNode
13524
+ * @param {PathFinderNode} endNode
13525
+ * @returns {boolean}
13526
+ * @private */
13527
+ aStarSearch(startNode, endNode)
13528
+ {
13529
+ ASSERT(startNode && endNode, 'aStarSearch needs both endpoints');
13530
+ ASSERT(startNode !== endNode, 'aStarSearch: start and end must differ — caller should handle trivial case');
13531
+ ASSERT(startNode.walkable && endNode.walkable, 'aStarSearch: endpoints must be walkable');
13532
+
13533
+ const openList = [startNode];
13534
+ startNode.isOpen = true;
13535
+ let loopCount = 0;
13536
+
13537
+ while (openList.length > 0)
13538
+ {
13539
+ // Find the open node with the smallest f score (linear scan).
13540
+ // Same as the C++ — fine up to a few thousand nodes.
13541
+ let bestIndex = 0;
13542
+ let bestF = openList[0].f;
13543
+ for (let i = 1; i < openList.length; ++i)
13544
+ {
13545
+ if (openList[i].f < bestF)
13546
+ {
13547
+ bestF = openList[i].f;
13548
+ bestIndex = i;
13549
+ }
13550
+ }
13551
+ const current = openList[bestIndex];
13552
+
13553
+ if (current === endNode) break;
13554
+ if (++loopCount > this.maxLoop) break;
13555
+
13556
+ // Move current from open to closed.
13557
+ current.isOpen = false;
13558
+ openList.splice(bestIndex, 1);
13559
+ current.isClosed = true;
13560
+
13561
+ if (this.debug && this.debugTime > 0)
13562
+ debugRect(current.posWorld, PATHFINDER_TILE_VEC, rgb(1, 1, 1, 0.05), this.debugTime);
13563
+
13564
+ // Expand all 8 neighbors.
13565
+ for (let dy = -1; dy <= 1; ++dy)
13566
+ for (let dx = -1; dx <= 1; ++dx)
13567
+ {
13568
+ if (dx === 0 && dy === 0) continue;
13569
+ const neighbor = this.getNode(current.pos.x + dx, current.pos.y + dy);
13570
+ if (!neighbor || !neighbor.walkable || neighbor.isClosed) continue;
13571
+
13572
+ let stepCost = 1;
13573
+ if (dx !== 0 && dy !== 0)
13574
+ {
13575
+ // Diagonal step: refuse if either cardinal neighbor is
13576
+ // blocked or has cost. Prevents cutting through corners.
13577
+ const card1 = this.getNode(current.pos.x + dx, current.pos.y);
13578
+ if (!card1 || card1.cost > 0 || !card1.walkable) continue;
13579
+ const card2 = this.getNode(current.pos.x, current.pos.y + dy);
13580
+ if (!card2 || card2.cost > 0 || !card2.walkable) continue;
13581
+ stepCost = PATHFINDER_DIAGONAL_COST;
13582
+ }
13583
+
13584
+ const tentativeG = current.g + stepCost + neighbor.cost;
13585
+ if (!neighbor.isOpen)
13586
+ {
13587
+ neighbor.isOpen = true;
13588
+ openList.push(neighbor);
13589
+ }
13590
+ else if (tentativeG >= neighbor.g)
13591
+ {
13592
+ continue;
13593
+ }
13594
+
13595
+ // Best path so far through neighbor — record it.
13596
+ neighbor.parent = current;
13597
+ neighbor.g = tentativeG;
13598
+ const gdx = endNode.pos.x - neighbor.pos.x;
13599
+ const gdy = endNode.pos.y - neighbor.pos.y;
13600
+ neighbor.f = neighbor.g + (gdx * gdx + gdy * gdy) * this.heuristicWeight;
13601
+ }
13602
+ }
13603
+
13604
+ return endNode.parent !== null;
13605
+ }
13606
+
13607
+ /** Find the clear (walkable, zero-cost) node closest to the given world
13608
+ * position. Spirals outward in expanding boxes until a clear node is
13609
+ * found or the search range is exhausted. Useful for snapping a click
13610
+ * or NPC spawn position to the nearest open tile.
13611
+ *
13612
+ * By default, calls `buildNodeData()` first so it works correctly on a
13613
+ * fresh PathFinder. If you're calling it many times in a row with
13614
+ * unchanged walkability, pass `rebuild=false` and call `buildNodeData()`
13615
+ * once externally to avoid redundant work.
13616
+ * @param {Vector2} worldPos
13617
+ * @param {number} [searchRange=10] - Max box-radius in tiles
13618
+ * @param {boolean} [rebuild=true] - Whether to call buildNodeData first
13619
+ * @returns {PathFinderNode|null}
13620
+ * @memberof PathFinding */
13621
+ getNearestClearNode(worldPos, searchRange = 10, rebuild = true)
13622
+ {
13623
+ ASSERT(isVector2(worldPos), 'worldPos must be a Vector2');
13624
+ if (rebuild) this.buildNodeData();
13625
+
13626
+ // Inline worldToTile to avoid a Vector2 allocation per call.
13627
+ const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
13628
+ const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
13629
+ const centerX = floor(worldPos.x - ox);
13630
+ const centerY = floor(worldPos.y - oy);
13631
+
13632
+ for (let offset = 0; offset <= searchRange; ++offset)
13633
+ {
13634
+ let nearest = null;
13635
+ let nearestDistSq = 0;
13636
+
13637
+ for (let dy = -offset; dy <= offset; ++dy)
13638
+ for (let dx = -offset; dx <= offset; ++dx)
13639
+ {
13640
+ // Only scan the perimeter of the current ring (skip the
13641
+ // interior we've already searched in earlier iterations).
13642
+ if (offset > 0 && abs(dx) !== offset && abs(dy) !== offset)
13643
+ continue;
13644
+
13645
+ const node = this.getNode(centerX + dx, centerY + dy);
13646
+ if (!node || !node.isClear()) continue;
13647
+
13648
+ const ddx = node.posWorld.x - worldPos.x;
13649
+ const ddy = node.posWorld.y - worldPos.y;
13650
+ const distSq = ddx * ddx + ddy * ddy;
13651
+ if (!nearest || distSq < nearestDistSq)
13652
+ {
13653
+ nearest = node;
13654
+ nearestDistSq = distSq;
13655
+ }
13656
+ }
13657
+ if (nearest) return nearest;
13658
+ }
13659
+ return null;
13660
+ }
13661
+
13662
+ /** Smooth a node path by removing redundant turns and tightening corners
13663
+ * where a grid-aligned diagonal is clear. Modifies the path in place.
13664
+ * Stays on the grid — does not introduce off-tile-center points.
13665
+ * Port of ShortenPath() in pathFinding.cpp.
13666
+ * @param {PathFinderNode[]} path
13667
+ * @private */
13668
+ smoothPathCorners(path)
13669
+ {
13670
+ if (path.length <= 2) return;
13671
+
13672
+ let i = 1;
13673
+ while (i < path.length - 1)
13674
+ {
13675
+ const prev = path[i - 1];
13676
+ const node = path[i];
13677
+ const next = path[i + 1];
13678
+
13679
+ const dx = next.pos.x - prev.pos.x;
13680
+ const dy = next.pos.y - prev.pos.y;
13681
+ const lenSq = dx * dx + dy * dy;
13682
+
13683
+ // dx,dy is the prev-to-current step direction; needed for the
13684
+ // 135° "mostly vertical/horizontal" disambiguation.
13685
+ const stepDx = node.pos.x - prev.pos.x;
13686
+ const stepDy = node.pos.y - prev.pos.y;
13687
+ const stepDxNext = next.pos.x - node.pos.x;
13688
+ const stepDyNext = next.pos.y - node.pos.y;
13689
+
13690
+ if (lenSq === 1)
13691
+ {
13692
+ // 45° angle — middle node is off the straight line. Drop it.
13693
+ if (this.debug && this.debugTime > 0)
13694
+ debugCircle(node.posWorld, 0.3, rgb(0.5, 0, 0.5, 0.5), this.debugTime);
13695
+ path.splice(i, 1);
13696
+ i = max(1, i - 1);
13697
+ continue;
13698
+ }
13699
+ else if (lenSq === 2)
13700
+ {
13701
+ // 90° corner. Check the alternative-diagonal cell.
13702
+ if (this.debug && this.debugTime > 0)
13703
+ debugCircle(node.posWorld, 0.3, rgb(1, 0, 0, 0.5), this.debugTime);
13704
+
13705
+ let sx, sy;
13706
+ if (prev.pos.y === node.pos.y && next.pos.x === node.pos.x)
13707
+ { sx = prev.pos.x; sy = next.pos.y; }
13708
+ else
13709
+ { sx = next.pos.x; sy = prev.pos.y; }
13710
+
13711
+ const shortcut = this.getNode(sx, sy);
13712
+ if (shortcut && shortcut.isClear())
13713
+ {
13714
+ path.splice(i, 1);
13715
+ i = max(1, i - 1);
13716
+ continue;
13717
+ }
13718
+ }
13719
+ else if (lenSq === 5)
13720
+ {
13721
+ // 135° angle (a knight's-move offset). Try to relocate the
13722
+ // middle node to whichever of two candidate cells is closer
13723
+ // to prev-of-prev, and only if the corner cut is also clear.
13724
+ if (this.debug && this.debugTime > 0)
13725
+ debugCircle(node.posWorld, 0.3, rgb(1, 1, 0, 0.5), this.debugTime);
13726
+
13727
+ const prevPrev = i >= 2 ? path[i - 2] : prev;
13728
+ let s1x, s1y, s2x, s2y;
13729
+ if (stepDx === 0 || stepDxNext === 0)
13730
+ {
13731
+ // mostly vertical
13732
+ s1x = next.pos.x; s1y = node.pos.y;
13733
+ s2x = prev.pos.x; s2y = node.pos.y;
13734
+ }
13735
+ else
13736
+ {
13737
+ // mostly horizontal
13738
+ s1x = node.pos.x; s1y = next.pos.y;
13739
+ s2x = node.pos.x; s2y = prev.pos.y;
13740
+ }
13741
+ const dd1x = s1x - prevPrev.pos.x;
13742
+ const dd1y = s1y - prevPrev.pos.y;
13743
+ const dd2x = s2x - prevPrev.pos.x;
13744
+ const dd2y = s2y - prevPrev.pos.y;
13745
+ const dist1Sq = dd1x * dd1x + dd1y * dd1y;
13746
+ const dist2Sq = dd2x * dd2x + dd2y * dd2y;
13747
+ const sx = dist1Sq < dist2Sq ? s1x : s1x === s2x && s1y === s2y ? s1x : s2x;
13748
+ const sy = dist1Sq < dist2Sq ? s1y : s1x === s2x && s1y === s2y ? s1y : s2y;
13749
+
13750
+ const shortcut = this.getNode(sx, sy);
13751
+ if (shortcut && shortcut !== node && shortcut.isClear())
13752
+ {
13753
+ // Also check the cut-corner cell is clear.
13754
+ const ccx = next.pos.x + s2x - s1x;
13755
+ const ccy = next.pos.y + s2y - s1y;
13756
+ const cutCorner = this.getNode(ccx, ccy);
13757
+ if (cutCorner && cutCorner.isClear())
13758
+ {
13759
+ path[i] = shortcut;
13760
+ i = max(1, i - 1);
13761
+ continue;
13762
+ }
13763
+ }
13764
+ }
13765
+ else if (lenSq === 4 || lenSq === 8)
13766
+ {
13767
+ // Straight line or a 1-cell bump.
13768
+ if (this.debug && this.debugTime > 0)
13769
+ debugCircle(node.posWorld, 0.3, rgb(0, 1, 0, 0.5), this.debugTime);
13770
+
13771
+ if (stepDx === stepDxNext && stepDy === stepDyNext)
13772
+ {
13773
+ // Truly straight — nothing to do, advance.
13774
+ ++i;
13775
+ continue;
13776
+ }
13777
+ else
13778
+ {
13779
+ // Bump — try to flatten via the in-line cell.
13780
+ let sx, sy;
13781
+ if (prev.pos.y === next.pos.y)
13782
+ { sx = node.pos.x; sy = prev.pos.y; }
13783
+ else
13784
+ { sx = prev.pos.x; sy = node.pos.y; }
13785
+ const shortcut = this.getNode(sx, sy);
13786
+ if (shortcut && shortcut.isClear())
13787
+ {
13788
+ path[i] = shortcut;
13789
+ i = max(1, i - 1);
13790
+ continue;
13791
+ }
13792
+ }
13793
+ }
13794
+
13795
+ ++i;
13796
+ }
13797
+ }
13798
+
13799
+ /** Smooth a node path via line-of-sight ("string pulling"). Walks the
13800
+ * input path collapsing runs of nodes into straight segments whenever
13801
+ * isLineClear permits, so the result can leave grid centers and cut
13802
+ * cleanly across open spaces.
13803
+ *
13804
+ * Bails (leaves the path unchanged) if any node has nonzero cost — a
13805
+ * straight geometric shortcut can't be trusted to be the lowest-cost
13806
+ * route when cost-weighted terrain is in play.
13807
+ *
13808
+ * Port of ShortenPath2() in pathFinding.cpp.
13809
+ * @param {PathFinderNode[]} path
13810
+ * @private */
13811
+ smoothPathStringPull(path)
13812
+ {
13813
+ if (path.length <= 2) return;
13814
+ for (const n of path)
13815
+ {
13816
+ if (!n.isClear()) return;
13817
+ }
13818
+
13819
+ const original = path.slice();
13820
+ path.length = 0;
13821
+ path.push(original[0]);
13822
+ let searchIndex = 0;
13823
+
13824
+ for (let i = 1; i < original.length; ++i)
13825
+ {
13826
+ const node = original[i];
13827
+
13828
+ // Skip if node is collinear with the search-window start and the
13829
+ // previous node — it adds no information. Note: a == b is the
13830
+ // degenerate i=1, searchIndex=0 case; skip the test then.
13831
+ {
13832
+ const a = original[searchIndex];
13833
+ const b = original[i - 1];
13834
+ if (a !== b)
13835
+ {
13836
+ const cross =
13837
+ (b.pos.x - a.pos.x) * (node.pos.y - a.pos.y) -
13838
+ (b.pos.y - a.pos.y) * (node.pos.x - a.pos.x);
13839
+ if (cross === 0) continue;
13840
+ }
13841
+ }
13842
+
13843
+ if (!this.isLineClear(node.pos, path[path.length - 1].pos))
13844
+ {
13845
+ // Look ahead — if any later node has a clear shot to the
13846
+ // back of our new path, skip this node and try later.
13847
+ let foundClearAfter = false;
13848
+ for (let j = i + 1; j < original.length; ++j)
13849
+ {
13850
+ if (this.isLineClear(original[j].pos, path[path.length - 1].pos))
13851
+ {
13852
+ foundClearAfter = true;
13853
+ break;
13854
+ }
13855
+ }
13856
+ if (foundClearAfter)
13857
+ {
13858
+ if (this.debug && this.debugTime > 0)
13859
+ debugLine(node.posWorld, path[path.length - 1].posWorld, rgb(0, 0, 1, 0.3), 0.02, this.debugTime);
13860
+ continue;
13861
+ }
13862
+
13863
+ // No clear line ahead — fall back to the last waypoint we did
13864
+ // have a clear line to. searchIndex tracks our scan position.
13865
+ for (; searchIndex < original.length; ++searchIndex)
13866
+ {
13867
+ const cand = original[searchIndex];
13868
+ if (this.isLineClear(node.pos, cand.pos))
13869
+ {
13870
+ path.push(cand);
13871
+ i = searchIndex;
13872
+ break;
13873
+ }
13874
+ }
13875
+ ASSERT(searchIndex < original.length, 'smoothPathStringPull: ran out of candidates');
13876
+ }
13877
+ }
13878
+
13879
+ path.push(original[original.length - 1]);
13880
+ }
13881
+
13882
+ /** Lookup helper: true when the node at tile coords (x, y) is in-bounds
13883
+ * and clear (walkable, zero-cost). Used by isLineClear's hot path.
13884
+ * @param {number} x
13885
+ * @param {number} y
13886
+ * @returns {boolean}
13887
+ * @private */
13888
+ isNodeClear(x, y)
13889
+ {
13890
+ const n = this.getNode(x, y);
13891
+ return n !== null && n.isClear();
13892
+ }
13893
+
13894
+ /** Check that the line between two tile-coord endpoints stays entirely
13895
+ * inside walkable, zero-cost cells. Stricter than just sampling along
13896
+ * the line — it also checks the diagonal-corner-adjacent cells so the
13897
+ * line can never "scrape past" a wall corner.
13898
+ *
13899
+ * Both endpoints must themselves be clear (asserted in debug). Port of
13900
+ * CheckLine() in pathFinding.cpp.
13901
+ * @param {Vector2} startPos - Tile coords
13902
+ * @param {Vector2} endPos - Tile coords
13903
+ * @returns {boolean}
13904
+ * @private */
13905
+ isLineClear(startPos, endPos)
13906
+ {
13907
+ ASSERT(isVector2(startPos) && isVector2(endPos), 'isLineClear needs Vector2 endpoints');
13908
+ ASSERT(this.isNodeClear(startPos.x, startPos.y) && this.isNodeClear(endPos.x, endPos.y),
13909
+ 'isLineClear endpoints must be in-bounds and clear');
13910
+
13911
+ const dx = endPos.x - startPos.x;
13912
+ const dy = endPos.y - startPos.y;
13913
+ const adx = abs(dx);
13914
+ const ady = abs(dy);
13915
+ const sx = sign(dx);
13916
+ const sy = sign(dy);
13917
+ let x = startPos.x;
13918
+ let y = startPos.y;
13919
+
13920
+ if (ady === adx)
13921
+ {
13922
+ // Pure diagonal.
13923
+ while (x !== endPos.x)
13924
+ {
13925
+ if (x !== startPos.x)
13926
+ {
13927
+ if (!this.isNodeClear(x, y)) return false;
13928
+ if (!this.isNodeClear(x, y - sy)) return false;
13929
+ }
13930
+ if (!this.isNodeClear(x, y + sy)) return false;
13931
+ x += sx;
13932
+ y += sy;
13933
+ }
13934
+ if (!this.isNodeClear(endPos.x, endPos.y - sy)) return false;
13935
+ }
13936
+ else if (ady < adx)
13937
+ {
13938
+ // Mostly horizontal.
13939
+ if (dy === 0)
13940
+ {
13941
+ // Purely horizontal.
13942
+ x += sx;
13943
+ while (x !== endPos.x)
13944
+ {
13945
+ if (!this.isNodeClear(x, y)) return false;
13946
+ x += sx;
13947
+ }
13948
+ }
13949
+ else
13950
+ {
13951
+ let lastY = startPos.y;
13952
+ while (x !== endPos.x)
13953
+ {
13954
+ y = startPos.y + Math.trunc((dy * (x - startPos.x)) / dx);
13955
+ if (lastY !== y)
13956
+ {
13957
+ if (!this.isNodeClear(x - sx, y + sy)) return false;
13958
+ if (!this.isNodeClear(x, y - sy)) return false;
13959
+ }
13960
+ lastY = y;
13961
+ if (x !== startPos.x)
13962
+ {
13963
+ if (!this.isNodeClear(x, y)) return false;
13964
+ }
13965
+ y += sy;
13966
+ if (!this.isNodeClear(x, y)) return false;
13967
+ x += sx;
13968
+ }
13969
+ const finalY = endPos.y - sy;
13970
+ if (!this.isNodeClear(endPos.x, finalY)) return false;
13971
+ }
13972
+ }
13973
+ else
13974
+ {
13975
+ // Mostly vertical.
13976
+ if (dx === 0)
13977
+ {
13978
+ y += sy;
13979
+ while (y !== endPos.y)
13980
+ {
13981
+ if (!this.isNodeClear(x, y)) return false;
13982
+ y += sy;
13983
+ }
13984
+ }
13985
+ else
13986
+ {
13987
+ let lastX = startPos.x;
13988
+ while (y !== endPos.y)
13989
+ {
13990
+ x = startPos.x + Math.trunc((dx * (y - startPos.y)) / dy);
13991
+ if (lastX !== x)
13992
+ {
13993
+ if (!this.isNodeClear(x + sx, y - sy)) return false;
13994
+ if (!this.isNodeClear(x - sx, y)) return false;
13995
+ }
13996
+ lastX = x;
13997
+ if (y !== startPos.y)
13998
+ {
13999
+ if (!this.isNodeClear(x, y)) return false;
14000
+ }
14001
+ x += sx;
14002
+ if (!this.isNodeClear(x, y)) return false;
14003
+ y += sy;
14004
+ }
14005
+ const finalX = endPos.x - sx;
14006
+ if (!this.isNodeClear(finalX, endPos.y)) return false;
14007
+ }
14008
+ }
14009
+ return true;
14010
+ }
14011
+
14012
+ /** Find a path from startPos to endPos in world space. Returns an array
14013
+ * of world-space Vector2 points; empty array if no path exists.
14014
+ *
14015
+ * Start and end are snapped to the nearest walkable tile via
14016
+ * getNearestClearNode. Intermediate points are tile centers unless the
14017
+ * string-pulling smoothing pass moves them off-grid.
14018
+ * @param {Vector2} startPos - World-space start
14019
+ * @param {Vector2} endPos - World-space end
14020
+ * @returns {Vector2[]}
14021
+ * @memberof PathFinding */
14022
+ findPath(startPos, endPos)
14023
+ {
14024
+ ASSERT(isVector2(startPos) && isVector2(endPos), 'findPath needs Vector2 endpoints');
14025
+
14026
+ this.buildNodeData();
14027
+
14028
+ // rebuild=false because we just built — avoid redundant work per snap.
14029
+ const startNode = this.getNearestClearNode(startPos, 10, false);
14030
+ const endNode = this.getNearestClearNode(endPos, 10, false);
14031
+ if (!startNode || !endNode) return [];
14032
+
14033
+ // Trivial case: start and end snapped to the same tile.
14034
+ if (startNode === endNode) return [startNode.posWorld.copy()];
14035
+
14036
+ if (!this.aStarSearch(startNode, endNode)) return [];
14037
+
14038
+ // Walk back from endNode via parent pointers, then reverse — cheaper
14039
+ // than unshifting on every step.
14040
+ const nodePath = [];
14041
+ for (let n = endNode; n; n = n.parent)
14042
+ nodePath.push(n);
14043
+ nodePath.reverse();
14044
+
14045
+ if (this.smoothPath)
14046
+ {
14047
+ this.smoothPathCorners(nodePath);
14048
+ this.smoothPathStringPull(nodePath);
14049
+ }
14050
+
14051
+ // Convert to world-space Vector2 path. Return copies, not live node
14052
+ // references — callers shouldn't be able to mutate the grid.
14053
+ const result = nodePath.map(n => n.posWorld.copy());
14054
+
14055
+ if (this.debug && this.debugTime > 0 && result.length > 0)
14056
+ {
14057
+ for (let i = 1; i < result.length; ++i)
14058
+ debugLine(result[i - 1], result[i], RED, 0.1, this.debugTime);
14059
+ for (const p of result)
14060
+ debugCircle(p, 0.5, rgb(1, 0, 0, 0.3), this.debugTime);
14061
+ debugCircle(result[0], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
14062
+ debugCircle(result[result.length - 1], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
14063
+ }
14064
+
14065
+ return result;
14066
+ }
14067
+ }
14068
+