littlejsengine 1.18.4 → 1.18.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COPYRIGHT.txt +38 -0
- package/FAQ.md +633 -0
- package/README.md +19 -1
- package/dist/littlejs.d.ts +349 -54
- package/dist/littlejs.esm.js +1469 -448
- package/dist/littlejs.esm.min.js +1 -1
- package/dist/littlejs.js +1438 -436
- package/dist/littlejs.min.js +1 -1
- package/dist/littlejs.release.js +1438 -436
- package/package.json +8 -1
- package/plugins/box2d.js +32 -7
- package/{src/engineMedals.js → plugins/medalSystem.js} +248 -196
- package/plugins/pathFinder.js +758 -0
- package/plugins/pluginExport.js +19 -0
- package/plugins/tween.js +1 -1
- package/plugins/tweenSystem.js +509 -0
- package/plugins/uiSystem.js +150 -11
- package/src/engine.js +2 -141
- package/src/engineAudio.js +8 -5
- package/src/engineBuild.mjs +4 -2
- package/src/engineDebug.js +1 -1
- package/src/engineDraw.js +21 -3
- package/src/engineExport.js +12 -12
- package/src/engineInput.js +1 -1
- package/src/engineLogo.js +146 -0
- package/src/engineMath.js +10 -3
- package/src/engineObject.js +11 -5
- package/src/engineParticles.js +6 -3
- package/src/engineRelease.js +1 -1
- package/src/engineSettings.js +0 -47
- package/src/engineUtilities.js +7 -2
- package/src/engineWebGL.js +34 -7
- package/src/jsconfig.json +3 -1
- package/.github/workflows/test.yml +0 -17
- package/AI.md +0 -173
- package/CLAUDE.md +0 -1
- package/examples/box2d/game.js +0 -192
- package/examples/box2d/gameObjects.js +0 -564
- package/examples/box2d/index.html +0 -9
- package/examples/box2d/scenes.js +0 -194
- package/examples/box2d/tiles.png +0 -0
- package/examples/breakout/game.js +0 -175
- package/examples/breakout/gameObjects.js +0 -147
- package/examples/breakout/index.html +0 -8
- package/examples/breakout/tiles.png +0 -0
- package/examples/breakoutTutorial/README.md +0 -521
- package/examples/breakoutTutorial/game.js +0 -191
- package/examples/breakoutTutorial/images/1.png +0 -0
- package/examples/breakoutTutorial/images/10.png +0 -0
- package/examples/breakoutTutorial/images/11.png +0 -0
- package/examples/breakoutTutorial/images/2.png +0 -0
- package/examples/breakoutTutorial/images/3.png +0 -0
- package/examples/breakoutTutorial/images/4.png +0 -0
- package/examples/breakoutTutorial/images/5.png +0 -0
- package/examples/breakoutTutorial/images/6.png +0 -0
- package/examples/breakoutTutorial/images/7.png +0 -0
- package/examples/breakoutTutorial/images/8.png +0 -0
- package/examples/breakoutTutorial/images/9.png +0 -0
- package/examples/breakoutTutorial/index.html +0 -8
- package/examples/electron/build.mjs +0 -127
- package/examples/electron/electron.js +0 -35
- package/examples/electron/game.js +0 -54
- package/examples/electron/index.html +0 -13
- package/examples/electron/package.json +0 -5
- package/examples/electron/tiles.png +0 -0
- package/examples/empty/game.js +0 -51
- package/examples/empty/index.html +0 -5
- package/examples/empty/tiles.png +0 -0
- package/examples/favicon.png +0 -0
- package/examples/games.jpg +0 -0
- package/examples/htmlMenu/game.js +0 -82
- package/examples/htmlMenu/index.html +0 -45
- package/examples/htmlMenu/tiles.png +0 -0
- package/examples/index.html +0 -62
- package/examples/logo.png +0 -0
- package/examples/logo2.png +0 -0
- package/examples/module/build.mjs +0 -124
- package/examples/module/game.js +0 -132
- package/examples/module/index.html +0 -10
- package/examples/module/tiles.png +0 -0
- package/examples/particles/index.html +0 -426
- package/examples/particles/tiles.png +0 -0
- package/examples/platformer/data/gameLevelData.tmx +0 -143
- package/examples/platformer/data/gameLevelData.tsx +0 -4
- package/examples/platformer/game.js +0 -147
- package/examples/platformer/gameCharacter.js +0 -309
- package/examples/platformer/gameEffects.js +0 -278
- package/examples/platformer/gameLevel.js +0 -205
- package/examples/platformer/gameLevelData.json +0 -171
- package/examples/platformer/gameObjects.js +0 -378
- package/examples/platformer/gamePlayer.js +0 -35
- package/examples/platformer/index.html +0 -9
- package/examples/platformer/tiles.png +0 -0
- package/examples/platformer/tilesLevel.png +0 -0
- package/examples/puzzle/game.js +0 -331
- package/examples/puzzle/index.html +0 -8
- package/examples/puzzle/tiles.png +0 -0
- package/examples/screenshot.jpg +0 -0
- package/examples/shorts/animation.js +0 -18
- package/examples/shorts/base.html +0 -54
- package/examples/shorts/blending.js +0 -14
- package/examples/shorts/box2d.js +0 -51
- package/examples/shorts/box2dCar.js +0 -59
- package/examples/shorts/box2dPool.js +0 -107
- package/examples/shorts/box2dTileLayer.js +0 -48
- package/examples/shorts/cameraDrag.js +0 -22
- package/examples/shorts/clock.js +0 -21
- package/examples/shorts/colors.js +0 -25
- package/examples/shorts/debugDraw.js +0 -37
- package/examples/shorts/empty.js +0 -30
- package/examples/shorts/flappyGame.js +0 -55
- package/examples/shorts/fontImage.js +0 -17
- package/examples/shorts/fps.js +0 -90
- package/examples/shorts/helloWorld.js +0 -11
- package/examples/shorts/hillGlideGame.js +0 -63
- package/examples/shorts/input.js +0 -64
- package/examples/shorts/landerGame.js +0 -57
- package/examples/shorts/maze.js +0 -48
- package/examples/shorts/medals.js +0 -51
- package/examples/shorts/music.js +0 -78
- package/examples/shorts/musicPlayer.js +0 -137
- package/examples/shorts/nineSlice.js +0 -40
- package/examples/shorts/parallax.js +0 -72
- package/examples/shorts/particles.js +0 -28
- package/examples/shorts/piano.js +0 -44
- package/examples/shorts/platformer.js +0 -45
- package/examples/shorts/pongGame.js +0 -41
- package/examples/shorts/postProcess.js +0 -62
- package/examples/shorts/sequencer.js +0 -124
- package/examples/shorts/shader.js +0 -29
- package/examples/shorts/shapes.js +0 -21
- package/examples/shorts/slidingPuzzle.js +0 -52
- package/examples/shorts/song.mp3 +0 -0
- package/examples/shorts/sound.js +0 -36
- package/examples/shorts/spaceGame.js +0 -58
- package/examples/shorts/spriteAtlas.js +0 -31
- package/examples/shorts/starfield.js +0 -15
- package/examples/shorts/texture.js +0 -16
- package/examples/shorts/textureWrapped.js +0 -12
- package/examples/shorts/tileLayer.js +0 -48
- package/examples/shorts/tileRaycast.js +0 -39
- package/examples/shorts/tiles.png +0 -0
- package/examples/shorts/tiltedView.js +0 -63
- package/examples/shorts/timers.js +0 -53
- package/examples/shorts/topDown.js +0 -38
- package/examples/shorts/tween.js +0 -24
- package/examples/shorts/uiSystem.js +0 -61
- package/examples/shorts/video.webm +0 -0
- package/examples/shorts/videoPlayer.js +0 -34
- package/examples/shorts.js +0 -743
- package/examples/starter/build.bat +0 -7
- package/examples/starter/build.mjs +0 -126
- package/examples/starter/game.js +0 -137
- package/examples/starter/index.html +0 -35
- package/examples/starter/tiles.png +0 -0
- package/examples/stress/index.html +0 -173
- package/examples/style.css +0 -150
- package/examples/tweenSystem/game.js +0 -171
- package/examples/tweenSystem/index.html +0 -10
- package/examples/tweenSystem/tiles.png +0 -0
- package/examples/typescript/build.mjs +0 -60
- package/examples/typescript/game.js +0 -100
- package/examples/typescript/game.ts +0 -132
- package/examples/typescript/index.html +0 -10
- package/examples/typescript/tiles.png +0 -0
- package/examples/typescript/tsconfig.json +0 -17
- package/examples/uiSystem/game.js +0 -139
- package/examples/uiSystem/index.html +0 -10
- package/examples/uiSystem/tiles.png +0 -0
- package/jsconfig.json +0 -12
- package/plugins/desktop.ini +0 -2
- package/reference.md +0 -448
- package/test/math.test.mjs +0 -774
- package/test/setup.mjs +0 -22
- package/test/smoke.test.mjs +0 -274
- package/test/tween.test.mjs +0 -576
- package/test/util.test.mjs +0 -80
package/dist/littlejs.esm.js
CHANGED
|
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
|
|
|
35
35
|
* @type {string}
|
|
36
36
|
* @default
|
|
37
37
|
* @memberof Engine */
|
|
38
|
-
const engineVersion = '1.18.
|
|
38
|
+
const engineVersion = '1.18.7';
|
|
39
39
|
|
|
40
40
|
/** Frames per second to update
|
|
41
41
|
* @type {number}
|
|
@@ -207,7 +207,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
|
|
|
207
207
|
const combinedScale = timeScale * debugScale;
|
|
208
208
|
frameTimeDeltaMS *= combinedScale;
|
|
209
209
|
frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
|
|
210
|
-
if (
|
|
210
|
+
if (debugScale <= 1)
|
|
211
211
|
frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp min framerate
|
|
212
212
|
|
|
213
213
|
let wasUpdated = false;
|
|
@@ -565,145 +565,6 @@ function engineObjectsRaycast(start, end, objects=engineObjects)
|
|
|
565
565
|
|
|
566
566
|
debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02);
|
|
567
567
|
return hitObjects;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
571
|
-
function drawEngineLogo(t)
|
|
572
|
-
{
|
|
573
|
-
const blackAndWhite = 0;
|
|
574
|
-
const showName = 1;
|
|
575
|
-
|
|
576
|
-
// LittleJS Logo and Splash Screen
|
|
577
|
-
const x = mainContext;
|
|
578
|
-
const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
|
|
579
|
-
const w = mainCanvas.width = innerWidth * dpr;
|
|
580
|
-
const h = mainCanvas.height = innerHeight * dpr;
|
|
581
|
-
{
|
|
582
|
-
// background
|
|
583
|
-
const p3 = percent(t, 1, .8);
|
|
584
|
-
const p4 = percent(t, 0, .5);
|
|
585
|
-
const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,hypot(w,h)*.6);
|
|
586
|
-
g.addColorStop(0,hsl(0,0,lerp(0,p3/2,p4),p3).toString());
|
|
587
|
-
g.addColorStop(1,hsl(0,0,0,p3).toString());
|
|
588
|
-
x.save();
|
|
589
|
-
x.fillStyle = g;
|
|
590
|
-
x.fillRect(0,0,w,h);
|
|
591
|
-
}
|
|
592
|
-
const gradient = (X1,Y1,X2,Y2,C,S=1)=>
|
|
593
|
-
{
|
|
594
|
-
if (C >= 0)
|
|
595
|
-
{
|
|
596
|
-
if (blackAndWhite)
|
|
597
|
-
x.fillStyle = '#fff';
|
|
598
|
-
else
|
|
599
|
-
{
|
|
600
|
-
const g = x.fillStyle = x.createLinearGradient(X1,Y1,X2,Y2);
|
|
601
|
-
g.addColorStop(0,color(C,2));
|
|
602
|
-
g.addColorStop(1,color(C,1));
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
else
|
|
606
|
-
x.fillStyle = '#000';
|
|
607
|
-
C >= -1 ? (x.fill(), S && x.stroke()) : x.stroke();
|
|
608
|
-
}
|
|
609
|
-
const circle = (X,Y,R,A=0,B=2*PI,C,S)=>
|
|
610
|
-
{
|
|
611
|
-
x.beginPath();
|
|
612
|
-
x.arc(X,Y,R,p*A,p*B);
|
|
613
|
-
gradient(X,Y-R,X,Y+R,C,S);
|
|
614
|
-
}
|
|
615
|
-
const rect = (X,Y,W,H,C)=>
|
|
616
|
-
{
|
|
617
|
-
x.beginPath();
|
|
618
|
-
x.rect(X,Y,W,H*p);
|
|
619
|
-
gradient(X,Y+H,X+W,Y,C);
|
|
620
|
-
}
|
|
621
|
-
const poly = (points,C,Y,H)=>
|
|
622
|
-
{
|
|
623
|
-
x.beginPath();
|
|
624
|
-
for (const p of points)
|
|
625
|
-
x.lineTo(p.x, p.y);
|
|
626
|
-
x.closePath();
|
|
627
|
-
gradient(0, Y, 0, Y+H,C);
|
|
628
|
-
}
|
|
629
|
-
const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
|
|
630
|
-
|
|
631
|
-
// center and fit tos screen
|
|
632
|
-
const alpha = oscillate(1,1,t);
|
|
633
|
-
const p = percent(alpha, .1, .5);
|
|
634
|
-
const size = min(6, min(w,h)/99);
|
|
635
|
-
x.translate(w/2,h/2);
|
|
636
|
-
x.scale(size,size);
|
|
637
|
-
x.translate(-40,-35);
|
|
638
|
-
p < 1 && x.setLineDash([99*p,99]);
|
|
639
|
-
x.lineJoin = x.lineCap = 'round';
|
|
640
|
-
x.lineWidth = .1 + p*1.9;
|
|
641
|
-
//x.strokeStyle='#fff7';
|
|
642
|
-
|
|
643
|
-
if (showName)
|
|
644
|
-
{
|
|
645
|
-
// engine name text
|
|
646
|
-
const Y = 54;
|
|
647
|
-
const s = 'LittleJS';
|
|
648
|
-
x.font = '900 15.5px arial';
|
|
649
|
-
x.lineWidth = .1+p*3.9;
|
|
650
|
-
x.textAlign = 'center';
|
|
651
|
-
x.textBaseline = 'top';
|
|
652
|
-
rect(11,Y+1,59,8*p,-1);
|
|
653
|
-
x.beginPath();
|
|
654
|
-
|
|
655
|
-
let w2 = 0;
|
|
656
|
-
for (let i=0;i<s.length;++i)
|
|
657
|
-
w2 += x.measureText(s[i]).width;
|
|
658
|
-
for (let j=2;j--;)
|
|
659
|
-
for (let i=0,X=40-w2/2;i<s.length;++i)
|
|
660
|
-
{
|
|
661
|
-
const w = x.measureText(s[i]).width, X2 = X+w/2;
|
|
662
|
-
gradient(X2,Y,X2+2,Y+13,i>5?1:0);
|
|
663
|
-
x[j?'strokeText':'fillText'](s[i],X2,Y+.5,17*p);
|
|
664
|
-
X += w;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
x.lineWidth = .1 + p*1.9;
|
|
668
|
-
rect(3,Y,73,0); // bottom
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
rect(7,15,26,-7,0); // cab top
|
|
672
|
-
rect(25,15,8,25,-1); // cab front
|
|
673
|
-
rect(10,40,15,-25,1); // cab back
|
|
674
|
-
rect(14,21,7,9,2); // cab window
|
|
675
|
-
rect(38,20,6,-6,2); // little stack
|
|
676
|
-
|
|
677
|
-
// big stack
|
|
678
|
-
rect(49,20,10,-6,0);
|
|
679
|
-
const stackPoints = [vec2(44,8),vec2(64,8),vec2(59,8+6*p),vec2(49,8+6*p)];
|
|
680
|
-
poly(stackPoints,2,8,6*p);
|
|
681
|
-
rect(44,8,20,-7,0);
|
|
682
|
-
|
|
683
|
-
// engine
|
|
684
|
-
for (let i=5;i--;) circle(59-i*6*p,30,10,0,2*PI,1,0);
|
|
685
|
-
circle(59,30,4,0,7,2); // light
|
|
686
|
-
|
|
687
|
-
// engine outline
|
|
688
|
-
rect(35,20,24,0); // top
|
|
689
|
-
circle(59,30,10); // front
|
|
690
|
-
circle(47,30,10,PI/2,PI*3/2); // middle
|
|
691
|
-
circle(35,30,10,PI/2,PI*3/2); // back
|
|
692
|
-
rect(7,40,13,7,-1); // bottom back
|
|
693
|
-
rect(17,40,43,14,-1); // bottom center
|
|
694
|
-
|
|
695
|
-
// wheels
|
|
696
|
-
for (let i=3;i--;) for (let j=2;j--;) circle(17+15*i,47,j?7:1,0,2*PI,2);
|
|
697
|
-
|
|
698
|
-
// cowcatcher
|
|
699
|
-
for (let i=2;i--;)
|
|
700
|
-
{
|
|
701
|
-
let w=6, s=7, o=53+w*p*i
|
|
702
|
-
const points = [vec2(o+s,54),vec2(o,40),vec2(o+w*p,40),vec2(o+s+w*p,54)];
|
|
703
|
-
poly(points,0,40,14);
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
x.restore();
|
|
707
568
|
}
|
|
708
569
|
/**
|
|
709
570
|
* LittleJS Debug System
|
|
@@ -749,7 +610,7 @@ let debugKey = 'Escape';
|
|
|
749
610
|
let debugOverlay = false;
|
|
750
611
|
|
|
751
612
|
// Engine internal variables not exposed to documentation
|
|
752
|
-
let debugPrimitives = [], debugPhysics = false, debugRaycast = false, debugParticles = false, debugGamepads = false,
|
|
613
|
+
let debugPrimitives = [], debugPhysics = false, debugRaycast = false, debugParticles = false, debugGamepads = false, debugTakeScreenshot;
|
|
753
614
|
|
|
754
615
|
///////////////////////////////////////////////////////////////////////////////
|
|
755
616
|
// Debug helper functions
|
|
@@ -1580,7 +1441,11 @@ function percentLerp(value, percentA, percentB, lerpA, lerpB)
|
|
|
1580
1441
|
* @return {number}
|
|
1581
1442
|
* @memberof Math */
|
|
1582
1443
|
function distanceWrap(valueA, valueB, wrapSize=1)
|
|
1583
|
-
{
|
|
1444
|
+
{
|
|
1445
|
+
ASSERT(wrapSize > 0, 'distanceWrap wrapSize must be > 0');
|
|
1446
|
+
const d = (valueA - valueB) % wrapSize;
|
|
1447
|
+
return d*2 % wrapSize - d;
|
|
1448
|
+
}
|
|
1584
1449
|
|
|
1585
1450
|
/** Linearly interpolates between values passed in with wrapping
|
|
1586
1451
|
* @param {number} valueA
|
|
@@ -1897,6 +1762,7 @@ class RandomGenerator
|
|
|
1897
1762
|
* @param {number} [seed] - Starting seed or engine default seed */
|
|
1898
1763
|
constructor(seed = 123456789)
|
|
1899
1764
|
{
|
|
1765
|
+
ASSERT(seed !== 0, 'RandomGenerator seed must be non-zero (xorshift is fixed at 0)');
|
|
1900
1766
|
/** @property {number} - random seed */
|
|
1901
1767
|
this.seed = seed;
|
|
1902
1768
|
}
|
|
@@ -2190,8 +2056,10 @@ class Vector2
|
|
|
2190
2056
|
* @return {Vector2} */
|
|
2191
2057
|
floor() { return new Vector2(floor(this.x), floor(this.y)); }
|
|
2192
2058
|
|
|
2193
|
-
/** Returns a copy of this vector snapped to a grid
|
|
2194
|
-
*
|
|
2059
|
+
/** Returns a copy of this vector snapped to a grid. Note that `grid` is
|
|
2060
|
+
* the number of snap steps per unit (so `grid=2` snaps to halves and
|
|
2061
|
+
* `grid=0.5` snaps to twos), not the cell size.
|
|
2062
|
+
* @param {number} grid - snap steps per unit
|
|
2195
2063
|
* @return {Vector2} */
|
|
2196
2064
|
snap(grid)
|
|
2197
2065
|
{
|
|
@@ -2761,9 +2629,14 @@ function readSaveData(saveName, defaultSaveData)
|
|
|
2761
2629
|
{
|
|
2762
2630
|
ASSERT(isString(saveName), 'loadData requires saveName string');
|
|
2763
2631
|
|
|
2764
|
-
// replace undefined values with defaults
|
|
2632
|
+
// replace undefined values with defaults; tolerate corrupt JSON
|
|
2765
2633
|
const data = localStorage[saveName];
|
|
2766
|
-
|
|
2634
|
+
let loadedData = {};
|
|
2635
|
+
if (data)
|
|
2636
|
+
{
|
|
2637
|
+
try { loadedData = JSON.parse(data); }
|
|
2638
|
+
catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
|
|
2639
|
+
}
|
|
2767
2640
|
return { ...defaultSaveData, ...loadedData };
|
|
2768
2641
|
}
|
|
2769
2642
|
|
|
@@ -3098,33 +2971,6 @@ let soundDefaultRange = 40;
|
|
|
3098
2971
|
* @memberof Settings */
|
|
3099
2972
|
let soundDefaultTaper = .7;
|
|
3100
2973
|
|
|
3101
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
3102
|
-
// Medals settings
|
|
3103
|
-
|
|
3104
|
-
/** How long to show medals for in seconds
|
|
3105
|
-
* @type {number}
|
|
3106
|
-
* @default
|
|
3107
|
-
* @memberof Settings */
|
|
3108
|
-
let medalDisplayTime = 5;
|
|
3109
|
-
|
|
3110
|
-
/** How quickly to slide on/off medals in seconds
|
|
3111
|
-
* @type {number}
|
|
3112
|
-
* @default
|
|
3113
|
-
* @memberof Settings */
|
|
3114
|
-
let medalDisplaySlideTime = .5;
|
|
3115
|
-
|
|
3116
|
-
/** Size of medal display
|
|
3117
|
-
* @type {Vector2}
|
|
3118
|
-
* @default Vector2(640,80)
|
|
3119
|
-
* @memberof Settings */
|
|
3120
|
-
let medalDisplaySize = vec2(640, 80);
|
|
3121
|
-
|
|
3122
|
-
/** Set to stop medals from being unlockable (like if cheats are enabled)
|
|
3123
|
-
* @type {boolean}
|
|
3124
|
-
* @default
|
|
3125
|
-
* @memberof Settings */
|
|
3126
|
-
let medalsPreventUnlock = false;
|
|
3127
|
-
|
|
3128
2974
|
///////////////////////////////////////////////////////////////////////////////
|
|
3129
2975
|
// Setters for global variables
|
|
3130
2976
|
|
|
@@ -3386,26 +3232,6 @@ function setSoundDefaultRange(range) { soundDefaultRange = range; }
|
|
|
3386
3232
|
* @memberof Settings */
|
|
3387
3233
|
function setSoundDefaultTaper(taper) { soundDefaultTaper = taper; }
|
|
3388
3234
|
|
|
3389
|
-
/** Set how long to show medals for in seconds
|
|
3390
|
-
* @param {number} time
|
|
3391
|
-
* @memberof Settings */
|
|
3392
|
-
function setMedalDisplayTime(time) { medalDisplayTime = time; }
|
|
3393
|
-
|
|
3394
|
-
/** Set how quickly to slide on/off medals in seconds
|
|
3395
|
-
* @param {number} time
|
|
3396
|
-
* @memberof Settings */
|
|
3397
|
-
function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
|
|
3398
|
-
|
|
3399
|
-
/** Set size of medal display
|
|
3400
|
-
* @param {Vector2} size
|
|
3401
|
-
* @memberof Settings */
|
|
3402
|
-
function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
|
|
3403
|
-
|
|
3404
|
-
/** Set to stop medals from being unlockable
|
|
3405
|
-
* @param {boolean} preventUnlock
|
|
3406
|
-
* @memberof Settings */
|
|
3407
|
-
function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }
|
|
3408
|
-
|
|
3409
3235
|
/** Set if watermark with FPS should be shown
|
|
3410
3236
|
* @param {boolean} show
|
|
3411
3237
|
* @memberof Debug */
|
|
@@ -3547,10 +3373,18 @@ class EngineObject
|
|
|
3547
3373
|
const parent = this.parent;
|
|
3548
3374
|
if (parent)
|
|
3549
3375
|
{
|
|
3550
|
-
//
|
|
3376
|
+
// compose with parent transform inline to avoid intermediate vector allocs
|
|
3551
3377
|
const mirror = parent.getMirrorSign();
|
|
3552
|
-
|
|
3553
|
-
|
|
3378
|
+
const lp = this.localPos, pp = parent.pos;
|
|
3379
|
+
const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
|
|
3380
|
+
if (pa)
|
|
3381
|
+
{
|
|
3382
|
+
const c = cos(-pa), s = sin(-pa);
|
|
3383
|
+
this.pos = new Vector2(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
|
|
3384
|
+
}
|
|
3385
|
+
else
|
|
3386
|
+
this.pos = new Vector2(lx + pp.x, ly + pp.y);
|
|
3387
|
+
this.angle = mirror*this.localAngle + pa;
|
|
3554
3388
|
}
|
|
3555
3389
|
|
|
3556
3390
|
// update children
|
|
@@ -3916,8 +3750,6 @@ class EngineObject
|
|
|
3916
3750
|
* @return {string} */
|
|
3917
3751
|
toString()
|
|
3918
3752
|
{
|
|
3919
|
-
if (!debug) return;
|
|
3920
|
-
|
|
3921
3753
|
let text = 'type = ' + this.constructor.name;
|
|
3922
3754
|
if (this.pos.x || this.pos.y)
|
|
3923
3755
|
text += '\npos = ' + this.pos;
|
|
@@ -4167,8 +3999,9 @@ class TextureInfo
|
|
|
4167
3999
|
* Create a TextureInfo, called automatically by the engine
|
|
4168
4000
|
* @param {HTMLImageElement|OffscreenCanvas} image
|
|
4169
4001
|
* @param {boolean} [useWebGL] - Should use WebGL if available?
|
|
4002
|
+
* @param {boolean} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)?
|
|
4170
4003
|
*/
|
|
4171
|
-
constructor(image, useWebGL=true)
|
|
4004
|
+
constructor(image, useWebGL=true, wrap=false)
|
|
4172
4005
|
{
|
|
4173
4006
|
/** @property {HTMLImageElement|OffscreenCanvas} - image source */
|
|
4174
4007
|
this.image = image;
|
|
@@ -4178,6 +4011,8 @@ class TextureInfo
|
|
|
4178
4011
|
this.sizeInverse = image ? vec2(1/image.width, 1/image.height) : vec2();
|
|
4179
4012
|
/** @property {WebGLTexture} - WebGL texture */
|
|
4180
4013
|
this.glTexture = undefined;
|
|
4014
|
+
/** @property {boolean} - true for REPEAT wrap mode, false for CLAMP_TO_EDGE */
|
|
4015
|
+
this.wrap = wrap;
|
|
4181
4016
|
useWebGL && this.createWebGLTexture();
|
|
4182
4017
|
}
|
|
4183
4018
|
|
|
@@ -4190,6 +4025,14 @@ class TextureInfo
|
|
|
4190
4025
|
/** Check if the texture is webgl enabled
|
|
4191
4026
|
* @return {boolean} */
|
|
4192
4027
|
hasWebGL() { return !!this.glTexture; }
|
|
4028
|
+
|
|
4029
|
+
/** Set the wrap mode for this texture
|
|
4030
|
+
* @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE */
|
|
4031
|
+
setWrap(wrap=true)
|
|
4032
|
+
{
|
|
4033
|
+
this.wrap = wrap;
|
|
4034
|
+
glSetTextureWrap(this.glTexture, wrap);
|
|
4035
|
+
}
|
|
4193
4036
|
}
|
|
4194
4037
|
|
|
4195
4038
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -4251,8 +4094,13 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
|
4251
4094
|
}
|
|
4252
4095
|
else
|
|
4253
4096
|
{
|
|
4254
|
-
// if no tile info, force untextured
|
|
4255
|
-
|
|
4097
|
+
// if no tile info, force untextured by zeroing rgba (so whatever
|
|
4098
|
+
// texture is bound doesn't leak in) and folding color+additive
|
|
4099
|
+
// into the additive slot — matches the Canvas2D path's
|
|
4100
|
+
// color.add(additiveColor) on line ~337.
|
|
4101
|
+
const combined = additiveColor ? color.add(additiveColor) : color;
|
|
4102
|
+
glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0,
|
|
4103
|
+
0, combined.rgbaInt());
|
|
4256
4104
|
}
|
|
4257
4105
|
}
|
|
4258
4106
|
else
|
|
@@ -4391,6 +4239,8 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
|
|
|
4391
4239
|
const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
|
|
4392
4240
|
ASSERT(textureInfo instanceof TextureInfo, 'texture not loaded');
|
|
4393
4241
|
ASSERT(textureInfo.size.x > 0, 'texture not loaded');
|
|
4242
|
+
ASSERT(textureInfo.wrap,
|
|
4243
|
+
'drawTextureWrapped requires a wrap-enabled texture; call textureInfo.setWrap(true) first');
|
|
4394
4244
|
|
|
4395
4245
|
if (useWebGL && glEnable)
|
|
4396
4246
|
{
|
|
@@ -5299,7 +5149,7 @@ const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
|
|
|
5299
5149
|
* This is useful to disable for html menus so the browser can handle input normally
|
|
5300
5150
|
* @param {boolean} preventDefault
|
|
5301
5151
|
* @memberof Input */
|
|
5302
|
-
function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
|
|
5152
|
+
function setInputPreventDefault(preventDefault=true) { inputPreventDefault = preventDefault; }
|
|
5303
5153
|
|
|
5304
5154
|
/** Clears an input key state
|
|
5305
5155
|
* @param {string|number} key
|
|
@@ -6095,10 +5945,12 @@ function touchGamepadButtonCenter()
|
|
|
6095
5945
|
* @namespace Audio
|
|
6096
5946
|
*/
|
|
6097
5947
|
|
|
6098
|
-
/** Audio context used by the engine
|
|
5948
|
+
/** Audio context used by the engine. Created lazily in audioInit() to avoid
|
|
5949
|
+
* browser autoplay warnings about constructing an AudioContext before any
|
|
5950
|
+
* user gesture.
|
|
6099
5951
|
* @type {AudioContext}
|
|
6100
5952
|
* @memberof Audio */
|
|
6101
|
-
let audioContext
|
|
5953
|
+
let audioContext;
|
|
6102
5954
|
|
|
6103
5955
|
/** Master gain node for all audio to pass through
|
|
6104
5956
|
* @type {GainNode}
|
|
@@ -6114,12 +5966,13 @@ const audioDefaultSampleRate = 44100;
|
|
|
6114
5966
|
* @return {boolean} - True if the audio context is running
|
|
6115
5967
|
* @memberof Audio */
|
|
6116
5968
|
function audioIsRunning()
|
|
6117
|
-
{ return audioContext
|
|
5969
|
+
{ return audioContext?.state === 'running'; }
|
|
6118
5970
|
|
|
6119
5971
|
function audioInit()
|
|
6120
5972
|
{
|
|
6121
5973
|
if (!soundEnable || headlessMode) return;
|
|
6122
5974
|
|
|
5975
|
+
audioContext = new AudioContext;
|
|
6123
5976
|
audioMasterGain = audioContext.createGain();
|
|
6124
5977
|
audioMasterGain.connect(audioContext.destination);
|
|
6125
5978
|
audioMasterGain.gain.value = soundVolume; // set starting value
|
|
@@ -6186,8 +6039,8 @@ class Sound
|
|
|
6186
6039
|
|
|
6187
6040
|
if (Array.isArray(asset))
|
|
6188
6041
|
{
|
|
6189
|
-
// generate zzfx sound
|
|
6190
|
-
const zzfxSound = asset;
|
|
6042
|
+
// generate zzfx sound — copy so we don't mutate the caller's array
|
|
6043
|
+
const zzfxSound = asset.slice();
|
|
6191
6044
|
|
|
6192
6045
|
// remove randomness so it can be applied on playback
|
|
6193
6046
|
const defaultRandomness = randomness ?? .05;
|
|
@@ -7785,11 +7638,14 @@ class ParticleEmitter extends EngineObject
|
|
|
7785
7638
|
}
|
|
7786
7639
|
|
|
7787
7640
|
///////////////////////////////////////////////////////////////////////////////
|
|
7641
|
+
// scratch vector reused by Particle.render to avoid per-frame allocations
|
|
7642
|
+
const particleDrawPos = new Vector2;
|
|
7643
|
+
|
|
7788
7644
|
/**
|
|
7789
7645
|
* Particle Object - Created automatically by Particle Emitters
|
|
7790
7646
|
* @memberof Particles
|
|
7791
7647
|
*/
|
|
7792
|
-
class Particle
|
|
7648
|
+
class Particle
|
|
7793
7649
|
{
|
|
7794
7650
|
/**
|
|
7795
7651
|
* Create a particle with the passed in settings
|
|
@@ -7964,14 +7820,14 @@ class Particle
|
|
|
7964
7820
|
additive && setBlendMode(true);
|
|
7965
7821
|
|
|
7966
7822
|
// update the position and angle for drawing
|
|
7967
|
-
const pos = this.pos.
|
|
7823
|
+
const pos = particleDrawPos.set(this.pos.x, this.pos.y);
|
|
7968
7824
|
let angle = this.angle;
|
|
7969
7825
|
if (localSpace)
|
|
7970
7826
|
{
|
|
7971
7827
|
// in local space of emitter
|
|
7972
7828
|
const a = emitter.angle;
|
|
7973
7829
|
const c = cos(a), s = sin(a);
|
|
7974
|
-
pos.set(emitter.pos.x + pos.x*c - pos.y*s,
|
|
7830
|
+
pos.set(emitter.pos.x + pos.x*c - pos.y*s,
|
|
7975
7831
|
emitter.pos.y + pos.x*s + pos.y*c);
|
|
7976
7832
|
angle += a;
|
|
7977
7833
|
}
|
|
@@ -7996,200 +7852,6 @@ class Particle
|
|
|
7996
7852
|
debugParticles && debugRect(pos, size, '#f005', 0, angle);
|
|
7997
7853
|
}
|
|
7998
7854
|
}
|
|
7999
|
-
/**
|
|
8000
|
-
* LittleJS Medal System
|
|
8001
|
-
* - Achievement/trophy system for games
|
|
8002
|
-
* - Medal class with name, description, icon, and unlock tracking
|
|
8003
|
-
* - Automatic saving to local storage
|
|
8004
|
-
* - Visual display queue with slide-in notifications
|
|
8005
|
-
* - Newgrounds API integration for online achievements
|
|
8006
|
-
* - Debug mode to unlock/reset medals during development
|
|
8007
|
-
* @namespace Medals
|
|
8008
|
-
*/
|
|
8009
|
-
|
|
8010
|
-
/** List of all medals
|
|
8011
|
-
* @type {Object}
|
|
8012
|
-
* @memberof Medals */
|
|
8013
|
-
const medals = {};
|
|
8014
|
-
|
|
8015
|
-
// Engine internal variables not exposed to documentation
|
|
8016
|
-
let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast;
|
|
8017
|
-
|
|
8018
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
8019
|
-
|
|
8020
|
-
/** Initialize medals with a save name used for storage
|
|
8021
|
-
* - Call this after creating all medals
|
|
8022
|
-
* - Checks if medals are unlocked
|
|
8023
|
-
* @param {string} saveName
|
|
8024
|
-
* @memberof Medals */
|
|
8025
|
-
function medalsInit(saveName)
|
|
8026
|
-
{
|
|
8027
|
-
// check if medals are unlocked
|
|
8028
|
-
medalsSaveName = saveName;
|
|
8029
|
-
if (!debugMedals)
|
|
8030
|
-
medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
|
|
8031
|
-
|
|
8032
|
-
// engine automatically renders medals
|
|
8033
|
-
engineAddPlugin(undefined, medalsRender);
|
|
8034
|
-
|
|
8035
|
-
// plugin functions
|
|
8036
|
-
function medalsRender()
|
|
8037
|
-
{
|
|
8038
|
-
if (!medalsDisplayQueue.length) return;
|
|
8039
|
-
|
|
8040
|
-
// update first medal in queue
|
|
8041
|
-
const medal = medalsDisplayQueue[0];
|
|
8042
|
-
const time = timeReal - medalsDisplayTimeLast;
|
|
8043
|
-
if (!medalsDisplayTimeLast)
|
|
8044
|
-
medalsDisplayTimeLast = timeReal;
|
|
8045
|
-
else if (time > medalDisplayTime)
|
|
8046
|
-
{
|
|
8047
|
-
medalsDisplayTimeLast = 0;
|
|
8048
|
-
medalsDisplayQueue.shift();
|
|
8049
|
-
}
|
|
8050
|
-
else
|
|
8051
|
-
{
|
|
8052
|
-
// slide on/off medals
|
|
8053
|
-
const slideOffTime = medalDisplayTime - medalDisplaySlideTime;
|
|
8054
|
-
const hidePercent =
|
|
8055
|
-
time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime :
|
|
8056
|
-
time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0;
|
|
8057
|
-
medal.render(hidePercent);
|
|
8058
|
-
}
|
|
8059
|
-
}
|
|
8060
|
-
}
|
|
8061
|
-
|
|
8062
|
-
/**
|
|
8063
|
-
* @callback MedalCallbackFunction - Function that processes a medal
|
|
8064
|
-
* @param {Medal} medal
|
|
8065
|
-
* @memberof Medals
|
|
8066
|
-
*/
|
|
8067
|
-
|
|
8068
|
-
/** Calls a function for each medal
|
|
8069
|
-
* @param {MedalCallbackFunction} callback
|
|
8070
|
-
* @memberof Medals */
|
|
8071
|
-
function medalsForEach(callback)
|
|
8072
|
-
{ Object.values(medals).forEach(medal=>callback(medal)); }
|
|
8073
|
-
|
|
8074
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
8075
|
-
|
|
8076
|
-
/**
|
|
8077
|
-
* Medal - Tracks an unlockable medal
|
|
8078
|
-
* @memberof Medals
|
|
8079
|
-
* @example
|
|
8080
|
-
* // create a medal
|
|
8081
|
-
* const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
|
|
8082
|
-
*
|
|
8083
|
-
* // initialize medals
|
|
8084
|
-
* medalsInit('Example Game');
|
|
8085
|
-
*
|
|
8086
|
-
* // unlock the medal
|
|
8087
|
-
* medal_example.unlock();
|
|
8088
|
-
*/
|
|
8089
|
-
class Medal
|
|
8090
|
-
{
|
|
8091
|
-
/** Create a medal object and adds it to the list of medals
|
|
8092
|
-
* @param {number} id - The unique identifier of the medal
|
|
8093
|
-
* @param {string} name - Name of the medal
|
|
8094
|
-
* @param {string} [description] - Description of the medal
|
|
8095
|
-
* @param {string} [icon] - Icon for the medal
|
|
8096
|
-
* @param {string} [src] - Image location for the medal
|
|
8097
|
-
*/
|
|
8098
|
-
constructor(id, name, description='', icon='🏆', src)
|
|
8099
|
-
{
|
|
8100
|
-
ASSERT(id >= 0 && !medals[id]);
|
|
8101
|
-
|
|
8102
|
-
/** @property {number} - The unique identifier of the medal */
|
|
8103
|
-
this.id = id;
|
|
8104
|
-
|
|
8105
|
-
/** @property {string} - Name of the medal */
|
|
8106
|
-
this.name = name;
|
|
8107
|
-
|
|
8108
|
-
/** @property {string} - Description of the medal */
|
|
8109
|
-
this.description = description;
|
|
8110
|
-
|
|
8111
|
-
/** @property {string} - Icon for the medal */
|
|
8112
|
-
this.icon = icon;
|
|
8113
|
-
|
|
8114
|
-
/** @property {boolean} - Is the medal unlocked? */
|
|
8115
|
-
this.unlocked = false;
|
|
8116
|
-
|
|
8117
|
-
// load the source image if provided
|
|
8118
|
-
if (src)
|
|
8119
|
-
(this.image = new Image).src = src;
|
|
8120
|
-
|
|
8121
|
-
// add this to list of medals
|
|
8122
|
-
medals[id] = this;
|
|
8123
|
-
}
|
|
8124
|
-
|
|
8125
|
-
/** Unlocks a medal if not already unlocked */
|
|
8126
|
-
unlock()
|
|
8127
|
-
{
|
|
8128
|
-
if (medalsPreventUnlock || this.unlocked) return;
|
|
8129
|
-
|
|
8130
|
-
// save the medal
|
|
8131
|
-
ASSERT(medalsSaveName, 'save name must be set');
|
|
8132
|
-
localStorage[this.storageKey()] = this.unlocked = true;
|
|
8133
|
-
medalsDisplayQueue.push(this);
|
|
8134
|
-
}
|
|
8135
|
-
|
|
8136
|
-
/** Render a medal
|
|
8137
|
-
* @param {number} [hidePercent] - How much to slide the medal off screen
|
|
8138
|
-
*/
|
|
8139
|
-
render(hidePercent=0)
|
|
8140
|
-
{
|
|
8141
|
-
const context = mainContext;
|
|
8142
|
-
const width = min(medalDisplaySize.x, mainCanvas.width);
|
|
8143
|
-
const height = medalDisplaySize.y;
|
|
8144
|
-
const x = mainCanvas.width - width;
|
|
8145
|
-
const y = -height*hidePercent;
|
|
8146
|
-
const backgroundColor = hsl(0,0,.9);
|
|
8147
|
-
|
|
8148
|
-
// draw containing rect and clip to that region
|
|
8149
|
-
context.save();
|
|
8150
|
-
context.beginPath();
|
|
8151
|
-
context.fillStyle = backgroundColor.toString();
|
|
8152
|
-
context.strokeStyle = BLACK.toString();
|
|
8153
|
-
context.lineWidth = 3;
|
|
8154
|
-
context.rect(x, y, width, height);
|
|
8155
|
-
context.fill();
|
|
8156
|
-
context.stroke();
|
|
8157
|
-
context.clip();
|
|
8158
|
-
|
|
8159
|
-
// draw the icon
|
|
8160
|
-
const gap = vec2(.1, .05).scale(height);
|
|
8161
|
-
const medalDisplayIconSize = height - 2*gap.x;
|
|
8162
|
-
this.renderIcon(vec2(x + gap.x + medalDisplayIconSize/2, y + height/2), medalDisplayIconSize);
|
|
8163
|
-
|
|
8164
|
-
// draw the name
|
|
8165
|
-
const nameSize = height*.5;
|
|
8166
|
-
const descriptionSize = height*.3;
|
|
8167
|
-
const pos = vec2(x + medalDisplayIconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
|
|
8168
|
-
const textWidth = width - medalDisplayIconSize - 3*gap.x;
|
|
8169
|
-
drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
|
|
8170
|
-
|
|
8171
|
-
// draw the description
|
|
8172
|
-
pos.y = y + height - gap.y*2 - descriptionSize/2;
|
|
8173
|
-
drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
|
|
8174
|
-
context.restore();
|
|
8175
|
-
}
|
|
8176
|
-
|
|
8177
|
-
/** Render the icon for a medal
|
|
8178
|
-
* @param {Vector2} pos - Screen space position
|
|
8179
|
-
* @param {number} size - Screen space size
|
|
8180
|
-
*/
|
|
8181
|
-
renderIcon(pos, size)
|
|
8182
|
-
{
|
|
8183
|
-
// draw the image or icon
|
|
8184
|
-
if (this.image)
|
|
8185
|
-
mainContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size);
|
|
8186
|
-
else
|
|
8187
|
-
drawTextScreen(this.icon, pos, size*.7, BLACK);
|
|
8188
|
-
}
|
|
8189
|
-
|
|
8190
|
-
// Get local storage key used by the medal
|
|
8191
|
-
storageKey() { return medalsSaveName + '_' + this.id; }
|
|
8192
|
-
}
|
|
8193
7855
|
/**
|
|
8194
7856
|
* LittleJS WebGL Interface
|
|
8195
7857
|
* - WebGL2 rendering engine for high-performance graphics
|
|
@@ -8289,7 +7951,7 @@ function glInit(rootElement)
|
|
|
8289
7951
|
// reinit WebGL and restore textures
|
|
8290
7952
|
initWebGL();
|
|
8291
7953
|
for (const info of glTextureInfos)
|
|
8292
|
-
info.glTexture = glCreateTexture(info.image);
|
|
7954
|
+
info.glTexture = glCreateTexture(info.image, info.wrap);
|
|
8293
7955
|
pluginList.forEach(plugin=>plugin.glContextRestored?.());
|
|
8294
7956
|
});
|
|
8295
7957
|
|
|
@@ -8505,6 +8167,30 @@ function glSetTexture(texture)
|
|
|
8505
8167
|
glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
|
|
8506
8168
|
}
|
|
8507
8169
|
|
|
8170
|
+
/** Set the wrap mode (REPEAT or CLAMP_TO_EDGE) on an existing WebGL texture
|
|
8171
|
+
* Flushes the current batch only if the texture is the active one
|
|
8172
|
+
* @param {WebGLTexture} texture
|
|
8173
|
+
* @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
|
|
8174
|
+
* @memberof WebGL */
|
|
8175
|
+
function glSetTextureWrap(texture, wrap=true)
|
|
8176
|
+
{
|
|
8177
|
+
if (!glContext || !texture) return;
|
|
8178
|
+
|
|
8179
|
+
// flush only if changing wrap on the currently bound texture
|
|
8180
|
+
const isCurrent = texture === glActiveTexture;
|
|
8181
|
+
if (isCurrent)
|
|
8182
|
+
glFlush();
|
|
8183
|
+
else
|
|
8184
|
+
glContext.bindTexture(glContext.TEXTURE_2D, texture);
|
|
8185
|
+
|
|
8186
|
+
const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
|
|
8187
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
|
|
8188
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
|
|
8189
|
+
|
|
8190
|
+
if (!isCurrent && glActiveTexture)
|
|
8191
|
+
glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
|
|
8192
|
+
}
|
|
8193
|
+
|
|
8508
8194
|
/** Compile WebGL shader of the given type, will throw errors if in debug mode
|
|
8509
8195
|
* @param {string} source
|
|
8510
8196
|
* @param {number} type
|
|
@@ -8549,9 +8235,10 @@ function glCreateProgram(vsSource, fsSource)
|
|
|
8549
8235
|
/** Create WebGL texture from an image and init the texture settings
|
|
8550
8236
|
* Restores the active texture when done
|
|
8551
8237
|
* @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas} [image]
|
|
8238
|
+
* @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
|
|
8552
8239
|
* @return {WebGLTexture}
|
|
8553
8240
|
* @memberof WebGL */
|
|
8554
|
-
function glCreateTexture(image)
|
|
8241
|
+
function glCreateTexture(image, wrap=false)
|
|
8555
8242
|
{
|
|
8556
8243
|
if (!glContext) return;
|
|
8557
8244
|
|
|
@@ -8577,8 +8264,9 @@ function glCreateTexture(image)
|
|
|
8577
8264
|
const minFilter = mipMap ? glContext.LINEAR_MIPMAP_LINEAR : magFilter;
|
|
8578
8265
|
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, magFilter);
|
|
8579
8266
|
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, minFilter);
|
|
8580
|
-
|
|
8581
|
-
glContext.texParameteri(glContext.TEXTURE_2D, glContext.
|
|
8267
|
+
const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
|
|
8268
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
|
|
8269
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
|
|
8582
8270
|
if (mipMap)
|
|
8583
8271
|
glContext.generateMipmap(glContext.TEXTURE_2D);
|
|
8584
8272
|
|
|
@@ -8630,7 +8318,7 @@ function glRegisterTextureInfo(textureInfo)
|
|
|
8630
8318
|
if (textureInfo.glTexture)
|
|
8631
8319
|
glSetTextureData(textureInfo.glTexture, textureInfo.image);
|
|
8632
8320
|
else
|
|
8633
|
-
textureInfo.glTexture = glCreateTexture(textureInfo.image);
|
|
8321
|
+
textureInfo.glTexture = glCreateTexture(textureInfo.image, textureInfo.wrap);
|
|
8634
8322
|
}
|
|
8635
8323
|
|
|
8636
8324
|
/** Tells WebGL to destroy the glTexture and stop tracking it
|
|
@@ -8849,6 +8537,7 @@ function glSetRenderTarget(texture, clear=false)
|
|
|
8849
8537
|
glFlush();
|
|
8850
8538
|
glRenderTarget = undefined;
|
|
8851
8539
|
glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
|
|
8540
|
+
glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
|
|
8852
8541
|
}
|
|
8853
8542
|
}
|
|
8854
8543
|
|
|
@@ -8971,9 +8660,9 @@ function glPolyStrip(points)
|
|
|
8971
8660
|
return area;
|
|
8972
8661
|
}
|
|
8973
8662
|
|
|
8974
|
-
// ensure counter-clockwise winding
|
|
8663
|
+
// ensure counter-clockwise winding (slice first so we don't mutate caller's array)
|
|
8975
8664
|
if (signedArea(points) < 0)
|
|
8976
|
-
points = points.reverse();
|
|
8665
|
+
points = points.slice().reverse();
|
|
8977
8666
|
|
|
8978
8667
|
// check if point is inside triangle
|
|
8979
8668
|
const e = 1e-9;
|
|
@@ -9073,6 +8762,398 @@ function glPolyStrip(points)
|
|
|
9073
8762
|
}
|
|
9074
8763
|
return strip;
|
|
9075
8764
|
}
|
|
8765
|
+
/**
|
|
8766
|
+
* LittleJS Engine Logo
|
|
8767
|
+
* - Draws the LittleJS splash screen logo
|
|
8768
|
+
* - Used internally during engine startup
|
|
8769
|
+
*/
|
|
8770
|
+
|
|
8771
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
8772
|
+
function drawEngineLogo(t)
|
|
8773
|
+
{
|
|
8774
|
+
const blackAndWhite = 0;
|
|
8775
|
+
const showName = 1;
|
|
8776
|
+
|
|
8777
|
+
// LittleJS Logo and Splash Screen
|
|
8778
|
+
const x = mainContext;
|
|
8779
|
+
const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
|
|
8780
|
+
const w = mainCanvas.width = innerWidth * dpr;
|
|
8781
|
+
const h = mainCanvas.height = innerHeight * dpr;
|
|
8782
|
+
{
|
|
8783
|
+
// background
|
|
8784
|
+
const p3 = percent(t, 1, .8);
|
|
8785
|
+
const p4 = percent(t, 0, .5);
|
|
8786
|
+
const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,hypot(w,h)*.6);
|
|
8787
|
+
g.addColorStop(0,hsl(0,0,lerp(0,p3/2,p4),p3).toString());
|
|
8788
|
+
g.addColorStop(1,hsl(0,0,0,p3).toString());
|
|
8789
|
+
x.save();
|
|
8790
|
+
x.fillStyle = g;
|
|
8791
|
+
x.fillRect(0,0,w,h);
|
|
8792
|
+
}
|
|
8793
|
+
const gradient = (X1,Y1,X2,Y2,C,S=1)=>
|
|
8794
|
+
{
|
|
8795
|
+
if (C >= 0)
|
|
8796
|
+
{
|
|
8797
|
+
if (blackAndWhite)
|
|
8798
|
+
x.fillStyle = '#fff';
|
|
8799
|
+
else
|
|
8800
|
+
{
|
|
8801
|
+
const g = x.fillStyle = x.createLinearGradient(X1,Y1,X2,Y2);
|
|
8802
|
+
g.addColorStop(0,color(C,2));
|
|
8803
|
+
g.addColorStop(1,color(C,1));
|
|
8804
|
+
}
|
|
8805
|
+
}
|
|
8806
|
+
else
|
|
8807
|
+
x.fillStyle = '#000';
|
|
8808
|
+
C >= -1 ? (x.fill(), S && x.stroke()) : x.stroke();
|
|
8809
|
+
}
|
|
8810
|
+
const circle = (X,Y,R,A=0,B=2*PI,C,S)=>
|
|
8811
|
+
{
|
|
8812
|
+
x.beginPath();
|
|
8813
|
+
x.arc(X,Y,R,p*A,p*B);
|
|
8814
|
+
gradient(X,Y-R,X,Y+R,C,S);
|
|
8815
|
+
}
|
|
8816
|
+
const rect = (X,Y,W,H,C)=>
|
|
8817
|
+
{
|
|
8818
|
+
x.beginPath();
|
|
8819
|
+
x.rect(X,Y,W,H*p);
|
|
8820
|
+
gradient(X,Y+H,X+W,Y,C);
|
|
8821
|
+
}
|
|
8822
|
+
const poly = (points,C,Y,H)=>
|
|
8823
|
+
{
|
|
8824
|
+
x.beginPath();
|
|
8825
|
+
for (const p of points)
|
|
8826
|
+
x.lineTo(p.x, p.y);
|
|
8827
|
+
x.closePath();
|
|
8828
|
+
gradient(0, Y, 0, Y+H,C);
|
|
8829
|
+
}
|
|
8830
|
+
const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
|
|
8831
|
+
|
|
8832
|
+
// center and fit to screen
|
|
8833
|
+
const alpha = oscillate(1,1,t);
|
|
8834
|
+
const p = percent(alpha, .1, .5);
|
|
8835
|
+
const size = min(6, min(w,h)/99);
|
|
8836
|
+
x.translate(w/2,h/2);
|
|
8837
|
+
x.scale(size,size);
|
|
8838
|
+
x.translate(-40,-35);
|
|
8839
|
+
p < 1 && x.setLineDash([99*p,99]);
|
|
8840
|
+
x.lineJoin = x.lineCap = 'round';
|
|
8841
|
+
x.lineWidth = .1 + p*1.9;
|
|
8842
|
+
//x.strokeStyle='#fff7';
|
|
8843
|
+
|
|
8844
|
+
if (showName)
|
|
8845
|
+
{
|
|
8846
|
+
// engine name text
|
|
8847
|
+
const Y = 54;
|
|
8848
|
+
const s = 'LittleJS';
|
|
8849
|
+
x.font = '900 15.5px arial';
|
|
8850
|
+
x.lineWidth = .1+p*3.9;
|
|
8851
|
+
x.textAlign = 'center';
|
|
8852
|
+
x.textBaseline = 'top';
|
|
8853
|
+
rect(11,Y+1,59,8*p,-1);
|
|
8854
|
+
x.beginPath();
|
|
8855
|
+
|
|
8856
|
+
let w2 = 0;
|
|
8857
|
+
for (let i=0;i<s.length;++i)
|
|
8858
|
+
w2 += x.measureText(s[i]).width;
|
|
8859
|
+
for (let j=2;j--;)
|
|
8860
|
+
for (let i=0,X=40-w2/2;i<s.length;++i)
|
|
8861
|
+
{
|
|
8862
|
+
const w = x.measureText(s[i]).width, X2 = X+w/2;
|
|
8863
|
+
gradient(X2,Y,X2+2,Y+13,i>5?1:0);
|
|
8864
|
+
x[j?'strokeText':'fillText'](s[i],X2,Y+.5,17*p);
|
|
8865
|
+
X += w;
|
|
8866
|
+
}
|
|
8867
|
+
|
|
8868
|
+
x.lineWidth = .1 + p*1.9;
|
|
8869
|
+
rect(3,Y,73,0); // bottom
|
|
8870
|
+
}
|
|
8871
|
+
|
|
8872
|
+
rect(7,15,26,-7,0); // cab top
|
|
8873
|
+
rect(25,15,8,25,-1); // cab front
|
|
8874
|
+
rect(10,40,15,-25,1); // cab back
|
|
8875
|
+
rect(14,21,7,9,2); // cab window
|
|
8876
|
+
rect(38,20,6,-6,2); // little stack
|
|
8877
|
+
|
|
8878
|
+
// big stack
|
|
8879
|
+
rect(49,20,10,-6,0);
|
|
8880
|
+
const stackPoints = [vec2(44,8),vec2(64,8),vec2(59,8+6*p),vec2(49,8+6*p)];
|
|
8881
|
+
poly(stackPoints,2,8,6*p);
|
|
8882
|
+
rect(44,8,20,-7,0);
|
|
8883
|
+
|
|
8884
|
+
// engine
|
|
8885
|
+
for (let i=5;i--;) circle(59-i*6*p,30,10,0,2*PI,1,0);
|
|
8886
|
+
circle(59,30,4,0,7,2); // light
|
|
8887
|
+
|
|
8888
|
+
// engine outline
|
|
8889
|
+
rect(35,20,24,0); // top
|
|
8890
|
+
circle(59,30,10); // front
|
|
8891
|
+
circle(47,30,10,PI/2,PI*3/2); // middle
|
|
8892
|
+
circle(35,30,10,PI/2,PI*3/2); // back
|
|
8893
|
+
rect(7,40,13,7,-1); // bottom back
|
|
8894
|
+
rect(17,40,43,14,-1); // bottom center
|
|
8895
|
+
|
|
8896
|
+
// wheels
|
|
8897
|
+
for (let i=3;i--;) for (let j=2;j--;) circle(17+15*i,47,j?7:1,0,2*PI,2);
|
|
8898
|
+
|
|
8899
|
+
// cowcatcher
|
|
8900
|
+
for (let i=2;i--;)
|
|
8901
|
+
{
|
|
8902
|
+
let w=6, s=7, o=53+w*p*i
|
|
8903
|
+
const points = [vec2(o+s,54),vec2(o,40),vec2(o+w*p,40),vec2(o+s+w*p,54)];
|
|
8904
|
+
poly(points,0,40,14);
|
|
8905
|
+
}
|
|
8906
|
+
|
|
8907
|
+
x.restore();
|
|
8908
|
+
}
|
|
8909
|
+
|
|
8910
|
+
/**
|
|
8911
|
+
* LittleJS Medal System
|
|
8912
|
+
* - Achievement/trophy system for games
|
|
8913
|
+
* - Medal class with name, description, icon, and unlock tracking
|
|
8914
|
+
* - Automatic saving to local storage
|
|
8915
|
+
* - Visual display queue with slide-in notifications
|
|
8916
|
+
* - Newgrounds API integration for online achievements
|
|
8917
|
+
* - Debug mode to unlock/reset medals during development
|
|
8918
|
+
* @namespace Medals
|
|
8919
|
+
*/
|
|
8920
|
+
|
|
8921
|
+
let debugMedals = false;
|
|
8922
|
+
|
|
8923
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
8924
|
+
// Medals settings
|
|
8925
|
+
|
|
8926
|
+
/** How long to show medals for in seconds
|
|
8927
|
+
* @type {number}
|
|
8928
|
+
* @default
|
|
8929
|
+
* @memberof Settings */
|
|
8930
|
+
let medalDisplayTime = 5;
|
|
8931
|
+
|
|
8932
|
+
/** How quickly to slide on/off medals in seconds
|
|
8933
|
+
* @type {number}
|
|
8934
|
+
* @default
|
|
8935
|
+
* @memberof Settings */
|
|
8936
|
+
let medalDisplaySlideTime = .5;
|
|
8937
|
+
|
|
8938
|
+
/** Size of medal display
|
|
8939
|
+
* @type {Vector2}
|
|
8940
|
+
* @default Vector2(640,80)
|
|
8941
|
+
* @memberof Settings */
|
|
8942
|
+
let medalDisplaySize = vec2(640, 80);
|
|
8943
|
+
|
|
8944
|
+
/** Set to stop medals from being unlockable (like if cheats are enabled)
|
|
8945
|
+
* @type {boolean}
|
|
8946
|
+
* @default
|
|
8947
|
+
* @memberof Settings */
|
|
8948
|
+
let medalsPreventUnlock = false;
|
|
8949
|
+
|
|
8950
|
+
/** List of all medals
|
|
8951
|
+
* @type {Object}
|
|
8952
|
+
* @memberof Medals */
|
|
8953
|
+
const medals = {};
|
|
8954
|
+
|
|
8955
|
+
// Engine internal variables not exposed to documentation
|
|
8956
|
+
let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast;
|
|
8957
|
+
|
|
8958
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
8959
|
+
|
|
8960
|
+
/** Initialize medals with a save name used for storage
|
|
8961
|
+
* - Call this after creating all medals
|
|
8962
|
+
* - Checks if medals are unlocked
|
|
8963
|
+
* @param {string} saveName
|
|
8964
|
+
* @memberof Medals */
|
|
8965
|
+
function medalsInit(saveName)
|
|
8966
|
+
{
|
|
8967
|
+
// check if medals are unlocked
|
|
8968
|
+
medalsSaveName = saveName;
|
|
8969
|
+
if (!debugMedals)
|
|
8970
|
+
medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
|
|
8971
|
+
|
|
8972
|
+
// engine automatically renders medals
|
|
8973
|
+
engineAddPlugin(undefined, medalsRender);
|
|
8974
|
+
|
|
8975
|
+
// plugin functions
|
|
8976
|
+
function medalsRender()
|
|
8977
|
+
{
|
|
8978
|
+
if (!medalsDisplayQueue.length) return;
|
|
8979
|
+
|
|
8980
|
+
// update first medal in queue
|
|
8981
|
+
const medal = medalsDisplayQueue[0];
|
|
8982
|
+
const time = timeReal - medalsDisplayTimeLast;
|
|
8983
|
+
if (!medalsDisplayTimeLast)
|
|
8984
|
+
medalsDisplayTimeLast = timeReal;
|
|
8985
|
+
else if (time > medalDisplayTime)
|
|
8986
|
+
{
|
|
8987
|
+
medalsDisplayTimeLast = 0;
|
|
8988
|
+
medalsDisplayQueue.shift();
|
|
8989
|
+
}
|
|
8990
|
+
else
|
|
8991
|
+
{
|
|
8992
|
+
// slide on/off medals
|
|
8993
|
+
const slideOffTime = medalDisplayTime - medalDisplaySlideTime;
|
|
8994
|
+
const hidePercent =
|
|
8995
|
+
time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime :
|
|
8996
|
+
time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0;
|
|
8997
|
+
medal.render(hidePercent);
|
|
8998
|
+
}
|
|
8999
|
+
}
|
|
9000
|
+
}
|
|
9001
|
+
|
|
9002
|
+
/**
|
|
9003
|
+
* @callback MedalCallbackFunction - Function that processes a medal
|
|
9004
|
+
* @param {Medal} medal
|
|
9005
|
+
* @memberof Medals
|
|
9006
|
+
*/
|
|
9007
|
+
|
|
9008
|
+
/** Calls a function for each medal
|
|
9009
|
+
* @param {MedalCallbackFunction} callback
|
|
9010
|
+
* @memberof Medals */
|
|
9011
|
+
function medalsForEach(callback)
|
|
9012
|
+
{ Object.values(medals).forEach(medal=>callback(medal)); }
|
|
9013
|
+
|
|
9014
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
9015
|
+
|
|
9016
|
+
/**
|
|
9017
|
+
* Medal - Tracks an unlockable medal
|
|
9018
|
+
* @memberof Medals
|
|
9019
|
+
* @example
|
|
9020
|
+
* // create a medal
|
|
9021
|
+
* const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
|
|
9022
|
+
*
|
|
9023
|
+
* // initialize medals
|
|
9024
|
+
* medalsInit('Example Game');
|
|
9025
|
+
*
|
|
9026
|
+
* // unlock the medal
|
|
9027
|
+
* medal_example.unlock();
|
|
9028
|
+
*/
|
|
9029
|
+
class Medal
|
|
9030
|
+
{
|
|
9031
|
+
/** Create a medal object and adds it to the list of medals
|
|
9032
|
+
* @param {number} id - The unique identifier of the medal
|
|
9033
|
+
* @param {string} name - Name of the medal
|
|
9034
|
+
* @param {string} [description] - Description of the medal
|
|
9035
|
+
* @param {string} [icon] - Icon for the medal
|
|
9036
|
+
* @param {string} [src] - Image location for the medal
|
|
9037
|
+
*/
|
|
9038
|
+
constructor(id, name, description='', icon='🏆', src)
|
|
9039
|
+
{
|
|
9040
|
+
ASSERT(id >= 0 && !medals[id]);
|
|
9041
|
+
|
|
9042
|
+
/** @property {number} - The unique identifier of the medal */
|
|
9043
|
+
this.id = id;
|
|
9044
|
+
|
|
9045
|
+
/** @property {string} - Name of the medal */
|
|
9046
|
+
this.name = name;
|
|
9047
|
+
|
|
9048
|
+
/** @property {string} - Description of the medal */
|
|
9049
|
+
this.description = description;
|
|
9050
|
+
|
|
9051
|
+
/** @property {string} - Icon for the medal */
|
|
9052
|
+
this.icon = icon;
|
|
9053
|
+
|
|
9054
|
+
/** @property {boolean} - Is the medal unlocked? */
|
|
9055
|
+
this.unlocked = false;
|
|
9056
|
+
|
|
9057
|
+
// load the source image if provided
|
|
9058
|
+
if (src)
|
|
9059
|
+
(this.image = new Image).src = src;
|
|
9060
|
+
|
|
9061
|
+
// add this to list of medals
|
|
9062
|
+
medals[id] = this;
|
|
9063
|
+
}
|
|
9064
|
+
|
|
9065
|
+
/** Unlocks a medal if not already unlocked */
|
|
9066
|
+
unlock()
|
|
9067
|
+
{
|
|
9068
|
+
if (medalsPreventUnlock || this.unlocked) return;
|
|
9069
|
+
|
|
9070
|
+
// save the medal
|
|
9071
|
+
ASSERT(medalsSaveName, 'save name must be set');
|
|
9072
|
+
localStorage[this.storageKey()] = this.unlocked = true;
|
|
9073
|
+
medalsDisplayQueue.push(this);
|
|
9074
|
+
}
|
|
9075
|
+
|
|
9076
|
+
/** Render a medal
|
|
9077
|
+
* @param {number} [hidePercent] - How much to slide the medal off screen
|
|
9078
|
+
*/
|
|
9079
|
+
render(hidePercent=0)
|
|
9080
|
+
{
|
|
9081
|
+
const context = mainContext;
|
|
9082
|
+
const width = min(medalDisplaySize.x, mainCanvas.width);
|
|
9083
|
+
const height = medalDisplaySize.y;
|
|
9084
|
+
const x = mainCanvas.width - width;
|
|
9085
|
+
const y = -height*hidePercent;
|
|
9086
|
+
const backgroundColor = hsl(0,0,.9);
|
|
9087
|
+
|
|
9088
|
+
// draw containing rect and clip to that region
|
|
9089
|
+
context.save();
|
|
9090
|
+
context.beginPath();
|
|
9091
|
+
context.fillStyle = backgroundColor.toString();
|
|
9092
|
+
context.strokeStyle = BLACK.toString();
|
|
9093
|
+
context.lineWidth = 3;
|
|
9094
|
+
context.rect(x, y, width, height);
|
|
9095
|
+
context.fill();
|
|
9096
|
+
context.stroke();
|
|
9097
|
+
context.clip();
|
|
9098
|
+
|
|
9099
|
+
// draw the icon
|
|
9100
|
+
const gap = vec2(.1, .05).scale(height);
|
|
9101
|
+
const medalDisplayIconSize = height - 2*gap.x;
|
|
9102
|
+
this.renderIcon(vec2(x + gap.x + medalDisplayIconSize/2, y + height/2), medalDisplayIconSize);
|
|
9103
|
+
|
|
9104
|
+
// draw the name
|
|
9105
|
+
const nameSize = height*.5;
|
|
9106
|
+
const descriptionSize = height*.3;
|
|
9107
|
+
const pos = vec2(x + medalDisplayIconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
|
|
9108
|
+
const textWidth = width - medalDisplayIconSize - 3*gap.x;
|
|
9109
|
+
drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
|
|
9110
|
+
|
|
9111
|
+
// draw the description
|
|
9112
|
+
pos.y = y + height - gap.y*2 - descriptionSize/2;
|
|
9113
|
+
drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
|
|
9114
|
+
context.restore();
|
|
9115
|
+
}
|
|
9116
|
+
|
|
9117
|
+
/** Render the icon for a medal
|
|
9118
|
+
* @param {Vector2} pos - Screen space position
|
|
9119
|
+
* @param {number} size - Screen space size
|
|
9120
|
+
*/
|
|
9121
|
+
renderIcon(pos, size)
|
|
9122
|
+
{
|
|
9123
|
+
// draw the image or icon
|
|
9124
|
+
if (this.image)
|
|
9125
|
+
mainContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size);
|
|
9126
|
+
else
|
|
9127
|
+
drawTextScreen(this.icon, pos, size*.7, BLACK);
|
|
9128
|
+
}
|
|
9129
|
+
|
|
9130
|
+
// Get local storage key used by the medal
|
|
9131
|
+
storageKey() { return medalsSaveName + '_' + this.id; }
|
|
9132
|
+
}
|
|
9133
|
+
|
|
9134
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
9135
|
+
// Medals setting setters
|
|
9136
|
+
|
|
9137
|
+
/** Set how long to show medals for in seconds
|
|
9138
|
+
* @param {number} time
|
|
9139
|
+
* @memberof Settings */
|
|
9140
|
+
function setMedalDisplayTime(time) { medalDisplayTime = time; }
|
|
9141
|
+
|
|
9142
|
+
/** Set how quickly to slide on/off medals in seconds
|
|
9143
|
+
* @param {number} time
|
|
9144
|
+
* @memberof Settings */
|
|
9145
|
+
function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
|
|
9146
|
+
|
|
9147
|
+
/** Set size of medal display
|
|
9148
|
+
* @param {Vector2} size
|
|
9149
|
+
* @memberof Settings */
|
|
9150
|
+
function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
|
|
9151
|
+
|
|
9152
|
+
/** Set to stop medals from being unlockable
|
|
9153
|
+
* @param {boolean} preventUnlock
|
|
9154
|
+
* @memberof Settings */
|
|
9155
|
+
function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }
|
|
9156
|
+
|
|
9076
9157
|
/**
|
|
9077
9158
|
* LittleJS Newgrounds Plugin
|
|
9078
9159
|
* - NewgroundsMedal extends Medal with Newgrounds API functionality
|
|
@@ -9689,16 +9770,13 @@ class UISystemPlugin
|
|
|
9689
9770
|
this.lastHoverObject = undefined;
|
|
9690
9771
|
/** @property {UIObject} - Current confirm menu being shown */
|
|
9691
9772
|
this.confirmDialog = undefined;
|
|
9692
|
-
/** @
|
|
9693
|
-
this.
|
|
9773
|
+
/** @private */
|
|
9774
|
+
this._keyInputObject = undefined;
|
|
9775
|
+
/** @private */
|
|
9776
|
+
this._onKeyDown = (e) => this._keyInputObject?.onKeyDown(e);
|
|
9694
9777
|
|
|
9695
9778
|
engineAddPlugin(uiUpdate, uiRender);
|
|
9696
9779
|
|
|
9697
|
-
// key down handler
|
|
9698
|
-
function onKeyDown(e)
|
|
9699
|
-
{ uiSystem.keyInputObject?.onKeyDown(e); }
|
|
9700
|
-
document.addEventListener('keydown', onKeyDown);
|
|
9701
|
-
|
|
9702
9780
|
// set object position in parent space
|
|
9703
9781
|
function updateTransforms(o)
|
|
9704
9782
|
{
|
|
@@ -10040,8 +10118,23 @@ class UISystemPlugin
|
|
|
10040
10118
|
return p;
|
|
10041
10119
|
}
|
|
10042
10120
|
|
|
10121
|
+
/** Object to send keyboard input to (typically a UITextInput).
|
|
10122
|
+
* The document keydown listener is only attached while this is set,
|
|
10123
|
+
* so games that never use text input pay no event-handling cost.
|
|
10124
|
+
* @type {UIObject} */
|
|
10125
|
+
get keyInputObject() { return this._keyInputObject; }
|
|
10126
|
+
set keyInputObject(obj)
|
|
10127
|
+
{
|
|
10128
|
+
const had = !!this._keyInputObject;
|
|
10129
|
+
this._keyInputObject = obj;
|
|
10130
|
+
if (!had && obj)
|
|
10131
|
+
document.addEventListener('keydown', this._onKeyDown);
|
|
10132
|
+
else if (had && !obj)
|
|
10133
|
+
document.removeEventListener('keydown', this._onKeyDown);
|
|
10134
|
+
}
|
|
10135
|
+
|
|
10043
10136
|
/** Destroy and remove all objects
|
|
10044
|
-
* @memberof
|
|
10137
|
+
* @memberof UISystem */
|
|
10045
10138
|
destroyObjects()
|
|
10046
10139
|
{
|
|
10047
10140
|
for (const o of this.uiObjects)
|
|
@@ -10464,15 +10557,13 @@ class UIObject
|
|
|
10464
10557
|
* @return {string} */
|
|
10465
10558
|
toString()
|
|
10466
10559
|
{
|
|
10467
|
-
if (!debug) return;
|
|
10468
|
-
|
|
10469
10560
|
let text = 'type = ' + this.constructor.name;
|
|
10470
10561
|
if (this.text)
|
|
10471
10562
|
text += '\ntext = ' + this.text;
|
|
10472
10563
|
if (this.pos.x || this.pos.y)
|
|
10473
10564
|
text += '\npos = ' + this.pos;
|
|
10474
10565
|
if (this.localPos.x || this.localPos.y)
|
|
10475
|
-
text += '\
|
|
10566
|
+
text += '\nlocalPos = ' + this.localPos;
|
|
10476
10567
|
if (this.size.x || this.size.y)
|
|
10477
10568
|
text += '\nsize = ' + this.size;
|
|
10478
10569
|
if (this.color)
|
|
@@ -11066,6 +11157,135 @@ class UIVideo extends UIObject
|
|
|
11066
11157
|
this.video.remove();
|
|
11067
11158
|
super.destroy();
|
|
11068
11159
|
}
|
|
11160
|
+
}
|
|
11161
|
+
|
|
11162
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
11163
|
+
/**
|
|
11164
|
+
* UILayout - A container that auto-arranges children in a vertical list, horizontal list, or grid
|
|
11165
|
+
* - Set columns to 1 for a vertical list (default)
|
|
11166
|
+
* - Set columns to children.length for a horizontal list
|
|
11167
|
+
* - Set columns to N (1 < N < children.length) for a grid with N columns
|
|
11168
|
+
* - Per-child sizing: each row's height = max child.size.y in that row, each column's width = max child.size.x in that column
|
|
11169
|
+
* - Children are positioned centered in their cell
|
|
11170
|
+
* - Container auto-sizes to fit children plus padding
|
|
11171
|
+
* @extends UIObject
|
|
11172
|
+
* @memberof UISystem
|
|
11173
|
+
*/
|
|
11174
|
+
class UILayout extends UIObject
|
|
11175
|
+
{
|
|
11176
|
+
/** Create a UILayout container that auto-arranges children
|
|
11177
|
+
* @param {Vector2} [pos]
|
|
11178
|
+
* @param {number} [columns=1] - Number of columns (1 = vertical list)
|
|
11179
|
+
* @param {number} [gap=10] - Space between children
|
|
11180
|
+
* @param {number} [padding=10] - Space between container border and children
|
|
11181
|
+
* @param {boolean} [transparent=false] - If true, draws no background, outline, or shadow
|
|
11182
|
+
*/
|
|
11183
|
+
constructor(pos, columns=1, gap=10, padding=10, transparent=false)
|
|
11184
|
+
{
|
|
11185
|
+
super(pos);
|
|
11186
|
+
|
|
11187
|
+
ASSERT(isNumber(columns) && columns >= 1, 'ui layout columns must be a number >= 1');
|
|
11188
|
+
ASSERT(isNumber(gap), 'ui layout gap must be a number');
|
|
11189
|
+
ASSERT(isNumber(padding), 'ui layout padding must be a number');
|
|
11190
|
+
|
|
11191
|
+
/** @property {number} - Number of columns in the layout */
|
|
11192
|
+
this.columns = columns;
|
|
11193
|
+
/** @property {number} - Space between children */
|
|
11194
|
+
this.gap = gap;
|
|
11195
|
+
/** @property {number} - Space between container border and children */
|
|
11196
|
+
this.padding = padding;
|
|
11197
|
+
|
|
11198
|
+
if (transparent)
|
|
11199
|
+
{
|
|
11200
|
+
// pure positioning helper - skip background, outline, and shadow
|
|
11201
|
+
this.color = CLEAR_BLACK;
|
|
11202
|
+
this.gradientColor = undefined;
|
|
11203
|
+
this.lineWidth = 0;
|
|
11204
|
+
this.shadowColor = CLEAR_BLACK;
|
|
11205
|
+
}
|
|
11206
|
+
this.relayout();
|
|
11207
|
+
}
|
|
11208
|
+
|
|
11209
|
+
/** Add a child UIObject and re-layout
|
|
11210
|
+
* @param {UIObject} child
|
|
11211
|
+
* @return {UIObject} The child object added */
|
|
11212
|
+
addChild(child)
|
|
11213
|
+
{
|
|
11214
|
+
super.addChild(child);
|
|
11215
|
+
this.relayout();
|
|
11216
|
+
return child;
|
|
11217
|
+
}
|
|
11218
|
+
|
|
11219
|
+
/** Remove a child UIObject and re-layout
|
|
11220
|
+
* @param {UIObject} child */
|
|
11221
|
+
removeChild(child)
|
|
11222
|
+
{
|
|
11223
|
+
super.removeChild(child);
|
|
11224
|
+
this.relayout();
|
|
11225
|
+
}
|
|
11226
|
+
|
|
11227
|
+
/** Recompute child positions and container size based on per-child sizes.
|
|
11228
|
+
* Called automatically by addChild and removeChild. Call manually if you
|
|
11229
|
+
* mutate a child's size or change columns, gap, or padding. */
|
|
11230
|
+
relayout()
|
|
11231
|
+
{
|
|
11232
|
+
const n = this.children.length;
|
|
11233
|
+
if (!n)
|
|
11234
|
+
{
|
|
11235
|
+
this.size = vec2(this.padding * 2);
|
|
11236
|
+
return;
|
|
11237
|
+
}
|
|
11238
|
+
|
|
11239
|
+
const cols = this.columns;
|
|
11240
|
+
const rows = ceil(n / cols);
|
|
11241
|
+
const colWidths = new Array(cols).fill(0);
|
|
11242
|
+
const rowHeights = new Array(rows).fill(0);
|
|
11243
|
+
|
|
11244
|
+
// first pass: compute column widths and row heights from child sizes
|
|
11245
|
+
for (let i = 0; i < n; ++i)
|
|
11246
|
+
{
|
|
11247
|
+
const col = i % cols;
|
|
11248
|
+
const row = floor(i / cols);
|
|
11249
|
+
const child = this.children[i];
|
|
11250
|
+
colWidths[col] = max(colWidths[col], child.size.x);
|
|
11251
|
+
rowHeights[row] = max(rowHeights[row], child.size.y);
|
|
11252
|
+
}
|
|
11253
|
+
|
|
11254
|
+
// total content size (sum of column widths/row heights plus gaps between them)
|
|
11255
|
+
let contentWidth = this.gap * (cols - 1);
|
|
11256
|
+
for (const w of colWidths) contentWidth += w;
|
|
11257
|
+
let contentHeight = this.gap * (rows - 1);
|
|
11258
|
+
for (const h of rowHeights) contentHeight += h;
|
|
11259
|
+
|
|
11260
|
+
// cumulative column/row offsets so positioning is O(n) not O(n^2)
|
|
11261
|
+
const colOffsets = new Array(cols);
|
|
11262
|
+
let xAcc = 0;
|
|
11263
|
+
for (let c = 0; c < cols; ++c)
|
|
11264
|
+
{
|
|
11265
|
+
colOffsets[c] = xAcc;
|
|
11266
|
+
xAcc += colWidths[c];
|
|
11267
|
+
}
|
|
11268
|
+
const rowOffsets = new Array(rows);
|
|
11269
|
+
let yAcc = 0;
|
|
11270
|
+
for (let r = 0; r < rows; ++r)
|
|
11271
|
+
{
|
|
11272
|
+
rowOffsets[r] = yAcc;
|
|
11273
|
+
yAcc += rowHeights[r];
|
|
11274
|
+
}
|
|
11275
|
+
|
|
11276
|
+
// second pass: position each child centered in its cell
|
|
11277
|
+
for (let i = 0; i < n; ++i)
|
|
11278
|
+
{
|
|
11279
|
+
const col = i % cols;
|
|
11280
|
+
const row = floor(i / cols);
|
|
11281
|
+
const x = -contentWidth/2 + colOffsets[col] + this.gap * col + colWidths[col] / 2;
|
|
11282
|
+
const y = -contentHeight/2 + rowOffsets[row] + this.gap * row + rowHeights[row] / 2;
|
|
11283
|
+
this.children[i].localPos = vec2(x, y);
|
|
11284
|
+
}
|
|
11285
|
+
|
|
11286
|
+
// container size = content + padding on all sides
|
|
11287
|
+
this.size = vec2(contentWidth + this.padding * 2, contentHeight + this.padding * 2);
|
|
11288
|
+
}
|
|
11069
11289
|
}
|
|
11070
11290
|
/**
|
|
11071
11291
|
* LittleJS Box2D Physics Plugin
|
|
@@ -11592,14 +11812,29 @@ class Box2dObject extends EngineObject
|
|
|
11592
11812
|
this.body.ApplyForce(box2d.vec2dTo(force), box2d.vec2dTo(pos));
|
|
11593
11813
|
}
|
|
11594
11814
|
|
|
11595
|
-
/** Apply acceleration to this object
|
|
11815
|
+
/** Apply acceleration to this object (changes velocity by acceleration,
|
|
11816
|
+
* mass-independent — matches EngineObject.applyAcceleration semantics).
|
|
11817
|
+
* Use applyImpulse if you want the mass-dependent velocity change
|
|
11818
|
+
* Δv = impulse / mass, or applyForce for a Newton-style sustained force.
|
|
11596
11819
|
* @param {Vector2} acceleration
|
|
11597
11820
|
* @param {Vector2} [pos] */
|
|
11598
11821
|
applyAcceleration(acceleration, pos)
|
|
11599
|
-
{
|
|
11822
|
+
{
|
|
11823
|
+
pos ||= this.getCenterOfMass();
|
|
11824
|
+
this.setAwake();
|
|
11825
|
+
const impulse = acceleration.scale(this.getMass());
|
|
11826
|
+
this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
|
|
11827
|
+
}
|
|
11828
|
+
|
|
11829
|
+
/** Apply an instantaneous linear impulse. Changes velocity immediately by
|
|
11830
|
+
* impulse / mass (so heavier bodies move less for the same impulse).
|
|
11831
|
+
* @param {Vector2} impulse
|
|
11832
|
+
* @param {Vector2} [pos] */
|
|
11833
|
+
applyImpulse(impulse, pos)
|
|
11834
|
+
{
|
|
11600
11835
|
pos ||= this.getCenterOfMass();
|
|
11601
11836
|
this.setAwake();
|
|
11602
|
-
this.body.ApplyLinearImpulse(box2d.vec2dTo(
|
|
11837
|
+
this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
|
|
11603
11838
|
}
|
|
11604
11839
|
|
|
11605
11840
|
/** Apply torque to this object
|
|
@@ -11609,13 +11844,23 @@ class Box2dObject extends EngineObject
|
|
|
11609
11844
|
this.setAwake();
|
|
11610
11845
|
this.body.ApplyTorque(torque);
|
|
11611
11846
|
}
|
|
11612
|
-
|
|
11613
|
-
/** Apply angular acceleration to this object
|
|
11847
|
+
|
|
11848
|
+
/** Apply angular acceleration to this object (changes angular velocity by
|
|
11849
|
+
* acceleration, mass-independent — matches EngineObject semantics).
|
|
11614
11850
|
* @param {number} acceleration */
|
|
11615
11851
|
applyAngularAcceleration(acceleration)
|
|
11616
11852
|
{
|
|
11617
11853
|
this.setAwake();
|
|
11618
|
-
this.body.ApplyAngularImpulse(acceleration);
|
|
11854
|
+
this.body.ApplyAngularImpulse(acceleration * this.getInertia());
|
|
11855
|
+
}
|
|
11856
|
+
|
|
11857
|
+
/** Apply an instantaneous angular impulse. Changes angular velocity by
|
|
11858
|
+
* impulse / inertia immediately.
|
|
11859
|
+
* @param {number} impulse */
|
|
11860
|
+
applyAngularImpulse(impulse)
|
|
11861
|
+
{
|
|
11862
|
+
this.setAwake();
|
|
11863
|
+
this.body.ApplyAngularImpulse(impulse);
|
|
11619
11864
|
}
|
|
11620
11865
|
|
|
11621
11866
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -11861,7 +12106,7 @@ class Box2dJoint
|
|
|
11861
12106
|
|
|
11862
12107
|
/** Check if the connected bodies should collide
|
|
11863
12108
|
* @return {boolean} */
|
|
11864
|
-
getCollideConnected() { return this.box2dJoint.
|
|
12109
|
+
getCollideConnected() { return this.box2dJoint.GetCollideConnected();}
|
|
11865
12110
|
|
|
11866
12111
|
/** Check if either connected body is active
|
|
11867
12112
|
* @return {boolean} */
|
|
@@ -13474,13 +13719,13 @@ const Ease =
|
|
|
13474
13719
|
* @param {number} x
|
|
13475
13720
|
* @returns {number}
|
|
13476
13721
|
* @memberof TweenSystem */
|
|
13477
|
-
SINE: (x) => 1 -
|
|
13722
|
+
SINE: (x) => 1 - cos(x * (PI / 2)),
|
|
13478
13723
|
|
|
13479
13724
|
/** Circular ease-in curve.
|
|
13480
13725
|
* @param {number} x
|
|
13481
13726
|
* @returns {number}
|
|
13482
13727
|
* @memberof TweenSystem */
|
|
13483
|
-
CIRC: (x) => 1 -
|
|
13728
|
+
CIRC: (x) => 1 - (1 - x * x)**.5,
|
|
13484
13729
|
|
|
13485
13730
|
/** Exponential ease-in curve (`2^(10x-10)`).
|
|
13486
13731
|
* @param {number} x
|
|
@@ -13499,7 +13744,7 @@ const Ease =
|
|
|
13499
13744
|
* @returns {number}
|
|
13500
13745
|
* @memberof TweenSystem */
|
|
13501
13746
|
ELASTIC: (x) =>
|
|
13502
|
-
-(2 ** (10 * x - 10)) *
|
|
13747
|
+
-(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
|
|
13503
13748
|
|
|
13504
13749
|
/** Spring-like ease-out: oscillates outward after passing the target.
|
|
13505
13750
|
* @param {number} x
|
|
@@ -13507,8 +13752,8 @@ const Ease =
|
|
|
13507
13752
|
* @memberof TweenSystem */
|
|
13508
13753
|
SPRING: (x) =>
|
|
13509
13754
|
1 -
|
|
13510
|
-
(
|
|
13511
|
-
|
|
13755
|
+
(sin(PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
|
|
13756
|
+
x ** 2.2 +
|
|
13512
13757
|
(1 - x)) *
|
|
13513
13758
|
(1.0 + 1.2 * x),
|
|
13514
13759
|
|
|
@@ -13612,7 +13857,7 @@ const Ease =
|
|
|
13612
13857
|
{
|
|
13613
13858
|
const tMid = (t0 + t1) / 2;
|
|
13614
13859
|
const [bx, by] = curve(tMid);
|
|
13615
|
-
if (
|
|
13860
|
+
if (abs(bx - x) < 1e-5) return by;
|
|
13616
13861
|
if (bx < x) t0 = tMid; else t1 = tMid;
|
|
13617
13862
|
}
|
|
13618
13863
|
return curve((t0 + t1) / 2)[1];
|
|
@@ -13744,6 +13989,763 @@ function tweenStopAll()
|
|
|
13744
13989
|
// Register with the engine so tweens auto-advance.
|
|
13745
13990
|
engineAddPlugin(tweenUpdate);
|
|
13746
13991
|
|
|
13992
|
+
/**
|
|
13993
|
+
* LittleJS PathFinder Plugin
|
|
13994
|
+
* - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths
|
|
13995
|
+
* - Works directly on a TileCollisionLayer, or override isWalkable/getCost for any grid
|
|
13996
|
+
* - Debug visualization via engine debug primitives (stripped in release builds)
|
|
13997
|
+
* - Port of frankforce.com pathFindingBase.cpp (2018)
|
|
13998
|
+
* @namespace PathFinding
|
|
13999
|
+
*/
|
|
14000
|
+
|
|
14001
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
14002
|
+
|
|
14003
|
+
// Diagonal step cost — pre-computed for the A* expansion inner loop.
|
|
14004
|
+
const PATHFINDER_DIAGONAL_COST = Math.SQRT2;
|
|
14005
|
+
|
|
14006
|
+
// Shared 1x1 size vector for per-tile debugRect calls. debugRect copies the
|
|
14007
|
+
// argument internally, so reusing one instance is safe.
|
|
14008
|
+
const PATHFINDER_TILE_VEC = vec2(1);
|
|
14009
|
+
|
|
14010
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
14011
|
+
|
|
14012
|
+
/** A single grid cell tracked by the pathfinder. Allocated once per cell at
|
|
14013
|
+
* PathFinder construction; reset (not reallocated) at the start of every
|
|
14014
|
+
* findPath call.
|
|
14015
|
+
* @memberof PathFinding */
|
|
14016
|
+
class PathFinderNode
|
|
14017
|
+
{
|
|
14018
|
+
/** @param {number} x - Tile x
|
|
14019
|
+
* @param {number} y - Tile y */
|
|
14020
|
+
constructor(x, y)
|
|
14021
|
+
{
|
|
14022
|
+
/** @property {Vector2} - Tile coords (integer) */
|
|
14023
|
+
this.pos = vec2(x, y);
|
|
14024
|
+
/** @property {Vector2} - World-space center of this tile (set by buildNodeData) */
|
|
14025
|
+
this.posWorld = vec2();
|
|
14026
|
+
/** @property {boolean} - True if this cell is passable (cleared each findPath call) */
|
|
14027
|
+
this.walkable = false;
|
|
14028
|
+
/** @property {number} - Extra cost added to A* G-score for stepping on this cell */
|
|
14029
|
+
this.cost = 0;
|
|
14030
|
+
/** @property {number} - A* G-score: actual cost from start to this node */
|
|
14031
|
+
this.g = 0;
|
|
14032
|
+
/** @property {number} - A* F-score: G + heuristic */
|
|
14033
|
+
this.f = 0;
|
|
14034
|
+
/** @property {PathFinderNode|null} - Parent for path reconstruction */
|
|
14035
|
+
this.parent = null;
|
|
14036
|
+
/** @property {boolean} - In the A* open list */
|
|
14037
|
+
this.isOpen = false;
|
|
14038
|
+
/** @property {boolean} - In the A* closed list */
|
|
14039
|
+
this.isClosed = false;
|
|
14040
|
+
}
|
|
14041
|
+
|
|
14042
|
+
/** Reset per-search state (called at the start of buildNodeData). */
|
|
14043
|
+
reset()
|
|
14044
|
+
{
|
|
14045
|
+
this.walkable = false;
|
|
14046
|
+
this.cost = 0;
|
|
14047
|
+
this.g = 0;
|
|
14048
|
+
this.f = 0;
|
|
14049
|
+
this.parent = null;
|
|
14050
|
+
this.isOpen = false;
|
|
14051
|
+
this.isClosed = false;
|
|
14052
|
+
}
|
|
14053
|
+
|
|
14054
|
+
/** True if walkable and not blocked by cost. */
|
|
14055
|
+
isClear()
|
|
14056
|
+
{
|
|
14057
|
+
return this.walkable && this.cost === 0;
|
|
14058
|
+
}
|
|
14059
|
+
}
|
|
14060
|
+
|
|
14061
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
14062
|
+
|
|
14063
|
+
/** Grid pathfinder using A* with two optional smoothing passes.
|
|
14064
|
+
* @memberof PathFinding
|
|
14065
|
+
* @example
|
|
14066
|
+
* // Tile-layer driven (most common):
|
|
14067
|
+
* const pf = new PathFinder(myTileCollisionLayer);
|
|
14068
|
+
* const path = pf.findPath(player.pos, mousePos);
|
|
14069
|
+
*
|
|
14070
|
+
* // Bare grid with custom walkability:
|
|
14071
|
+
* const pf = new PathFinder(vec2(50, 50));
|
|
14072
|
+
* pf.isWalkable = (x, y) => myGrid[y*50 + x] === 0;
|
|
14073
|
+
*/
|
|
14074
|
+
class PathFinder
|
|
14075
|
+
{
|
|
14076
|
+
/** @param {TileCollisionLayer|Vector2} source - Either a TileCollisionLayer
|
|
14077
|
+
* (size and walkability auto-derived) or a Vector2 grid size (user
|
|
14078
|
+
* overrides isWalkable). */
|
|
14079
|
+
constructor(source)
|
|
14080
|
+
{
|
|
14081
|
+
// Accept either a Vector2 size or a TileCollisionLayer (which has a .size).
|
|
14082
|
+
// We don't import TileCollisionLayer to avoid coupling; we duck-type on
|
|
14083
|
+
// .size + .getCollisionData.
|
|
14084
|
+
if (isVector2(source))
|
|
14085
|
+
{
|
|
14086
|
+
this.size = source.floor();
|
|
14087
|
+
this.tileLayer = undefined;
|
|
14088
|
+
}
|
|
14089
|
+
else
|
|
14090
|
+
{
|
|
14091
|
+
ASSERT(source && isVector2(source.size) && typeof source.getCollisionData === 'function',
|
|
14092
|
+
'PathFinder requires a Vector2 size or a TileCollisionLayer');
|
|
14093
|
+
this.size = source.size;
|
|
14094
|
+
this.tileLayer = source;
|
|
14095
|
+
}
|
|
14096
|
+
|
|
14097
|
+
// Tunables (public, freely re-assignable).
|
|
14098
|
+
this.heuristicWeight = 1;
|
|
14099
|
+
this.maxLoop = 500;
|
|
14100
|
+
this.smoothPath = true;
|
|
14101
|
+
this.debug = false;
|
|
14102
|
+
this.debugTime = 2;
|
|
14103
|
+
|
|
14104
|
+
// Pre-allocate the node array — one node per tile, reused across calls.
|
|
14105
|
+
this.nodes = new Array(this.size.x * this.size.y);
|
|
14106
|
+
for (let y = 0; y < this.size.y; ++y)
|
|
14107
|
+
for (let x = 0; x < this.size.x; ++x)
|
|
14108
|
+
this.nodes[x + y * this.size.x] = new PathFinderNode(x, y);
|
|
14109
|
+
|
|
14110
|
+
// Scratch Vector2 reused to avoid allocations in the isWalkable hot path.
|
|
14111
|
+
this.collisionScratch = vec2();
|
|
14112
|
+
}
|
|
14113
|
+
|
|
14114
|
+
/** Default walkability: if a tile layer was provided, returns true when the
|
|
14115
|
+
* cell has no solid collision data; otherwise returns true. Override on
|
|
14116
|
+
* the instance or via a subclass.
|
|
14117
|
+
* @param {number} x - Tile x
|
|
14118
|
+
* @param {number} y - Tile y
|
|
14119
|
+
* @returns {boolean} */
|
|
14120
|
+
isWalkable(x, y)
|
|
14121
|
+
{
|
|
14122
|
+
if (!this.tileLayer) return true;
|
|
14123
|
+
return !this.tileLayer.getCollisionData(this.collisionScratch.set(x, y));
|
|
14124
|
+
}
|
|
14125
|
+
|
|
14126
|
+
/** Default extra cost for stepping on a cell. Returns 0 (free) by default.
|
|
14127
|
+
* Override to add cost-weighted terrain (mud, swamp, etc).
|
|
14128
|
+
* @param {number} x - Tile x
|
|
14129
|
+
* @param {number} y - Tile y
|
|
14130
|
+
* @returns {number} */
|
|
14131
|
+
getCost(x, y)
|
|
14132
|
+
{
|
|
14133
|
+
return 0;
|
|
14134
|
+
}
|
|
14135
|
+
|
|
14136
|
+
/** Get the node at tile coords, or null if out of bounds.
|
|
14137
|
+
* @param {number} x
|
|
14138
|
+
* @param {number} y
|
|
14139
|
+
* @returns {PathFinderNode|null} */
|
|
14140
|
+
getNode(x, y)
|
|
14141
|
+
{
|
|
14142
|
+
if (x < 0 || y < 0 || x >= this.size.x || y >= this.size.y) return null;
|
|
14143
|
+
return this.nodes[x + y * this.size.x];
|
|
14144
|
+
}
|
|
14145
|
+
|
|
14146
|
+
/** Convert a world-space position to integer tile coords (no clamping).
|
|
14147
|
+
* @param {Vector2} worldPos
|
|
14148
|
+
* @returns {Vector2}
|
|
14149
|
+
* @memberof PathFinding */
|
|
14150
|
+
worldToTile(worldPos)
|
|
14151
|
+
{
|
|
14152
|
+
const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
|
|
14153
|
+
const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
|
|
14154
|
+
return vec2(floor(worldPos.x - ox), floor(worldPos.y - oy));
|
|
14155
|
+
}
|
|
14156
|
+
|
|
14157
|
+
/** Convert integer tile coords to the world-space center of that tile.
|
|
14158
|
+
* @param {number} x
|
|
14159
|
+
* @param {number} y
|
|
14160
|
+
* @returns {Vector2}
|
|
14161
|
+
* @memberof PathFinding */
|
|
14162
|
+
tileToWorld(x, y)
|
|
14163
|
+
{
|
|
14164
|
+
const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
|
|
14165
|
+
const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
|
|
14166
|
+
return vec2(x + 0.5 + ox, y + 0.5 + oy);
|
|
14167
|
+
}
|
|
14168
|
+
|
|
14169
|
+
/** Reset all nodes and re-populate walkable / cost / posWorld from the
|
|
14170
|
+
* current isWalkable / getCost overrides. Called at the start of
|
|
14171
|
+
* findPath; exposed so tests and tooling can drive it directly.
|
|
14172
|
+
* @private */
|
|
14173
|
+
buildNodeData()
|
|
14174
|
+
{
|
|
14175
|
+
const w = this.size.x;
|
|
14176
|
+
const h = this.size.y;
|
|
14177
|
+
const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
|
|
14178
|
+
const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
|
|
14179
|
+
for (let y = 0; y < h; ++y)
|
|
14180
|
+
for (let x = 0; x < w; ++x)
|
|
14181
|
+
{
|
|
14182
|
+
const node = this.nodes[x + y * w];
|
|
14183
|
+
node.reset();
|
|
14184
|
+
const walkable = !!this.isWalkable(x, y);
|
|
14185
|
+
const cost = walkable ? max(0, this.getCost(x, y)) : 0;
|
|
14186
|
+
node.walkable = walkable;
|
|
14187
|
+
node.cost = cost;
|
|
14188
|
+
node.posWorld.set(x + 0.5 + ox, y + 0.5 + oy);
|
|
14189
|
+
|
|
14190
|
+
if (this.debug && this.debugTime > 0)
|
|
14191
|
+
{
|
|
14192
|
+
if (!walkable)
|
|
14193
|
+
debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, 0.25), this.debugTime);
|
|
14194
|
+
else if (cost > 0)
|
|
14195
|
+
debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, min(0.2, cost * 0.05)), this.debugTime);
|
|
14196
|
+
}
|
|
14197
|
+
}
|
|
14198
|
+
}
|
|
14199
|
+
|
|
14200
|
+
/** Core A* search loop. Expects buildNodeData() to have been called first.
|
|
14201
|
+
* Marks node.parent for path reconstruction. Returns true if endNode was
|
|
14202
|
+
* reached; false on disconnected goal or maxLoop exhaustion.
|
|
14203
|
+
* @param {PathFinderNode} startNode
|
|
14204
|
+
* @param {PathFinderNode} endNode
|
|
14205
|
+
* @returns {boolean}
|
|
14206
|
+
* @private */
|
|
14207
|
+
aStarSearch(startNode, endNode)
|
|
14208
|
+
{
|
|
14209
|
+
ASSERT(startNode && endNode, 'aStarSearch needs both endpoints');
|
|
14210
|
+
ASSERT(startNode !== endNode, 'aStarSearch: start and end must differ — caller should handle trivial case');
|
|
14211
|
+
ASSERT(startNode.walkable && endNode.walkable, 'aStarSearch: endpoints must be walkable');
|
|
14212
|
+
|
|
14213
|
+
const openList = [startNode];
|
|
14214
|
+
startNode.isOpen = true;
|
|
14215
|
+
let loopCount = 0;
|
|
14216
|
+
|
|
14217
|
+
while (openList.length > 0)
|
|
14218
|
+
{
|
|
14219
|
+
// Find the open node with the smallest f score (linear scan).
|
|
14220
|
+
// Same as the C++ — fine up to a few thousand nodes.
|
|
14221
|
+
let bestIndex = 0;
|
|
14222
|
+
let bestF = openList[0].f;
|
|
14223
|
+
for (let i = 1; i < openList.length; ++i)
|
|
14224
|
+
{
|
|
14225
|
+
if (openList[i].f < bestF)
|
|
14226
|
+
{
|
|
14227
|
+
bestF = openList[i].f;
|
|
14228
|
+
bestIndex = i;
|
|
14229
|
+
}
|
|
14230
|
+
}
|
|
14231
|
+
const current = openList[bestIndex];
|
|
14232
|
+
|
|
14233
|
+
if (current === endNode) break;
|
|
14234
|
+
if (++loopCount > this.maxLoop) break;
|
|
14235
|
+
|
|
14236
|
+
// Move current from open to closed.
|
|
14237
|
+
current.isOpen = false;
|
|
14238
|
+
openList.splice(bestIndex, 1);
|
|
14239
|
+
current.isClosed = true;
|
|
14240
|
+
|
|
14241
|
+
if (this.debug && this.debugTime > 0)
|
|
14242
|
+
debugRect(current.posWorld, PATHFINDER_TILE_VEC, rgb(1, 1, 1, 0.05), this.debugTime);
|
|
14243
|
+
|
|
14244
|
+
// Expand all 8 neighbors.
|
|
14245
|
+
for (let dy = -1; dy <= 1; ++dy)
|
|
14246
|
+
for (let dx = -1; dx <= 1; ++dx)
|
|
14247
|
+
{
|
|
14248
|
+
if (dx === 0 && dy === 0) continue;
|
|
14249
|
+
const neighbor = this.getNode(current.pos.x + dx, current.pos.y + dy);
|
|
14250
|
+
if (!neighbor || !neighbor.walkable || neighbor.isClosed) continue;
|
|
14251
|
+
|
|
14252
|
+
let stepCost = 1;
|
|
14253
|
+
if (dx !== 0 && dy !== 0)
|
|
14254
|
+
{
|
|
14255
|
+
// Diagonal step: refuse if either cardinal neighbor is
|
|
14256
|
+
// blocked or has cost. Prevents cutting through corners.
|
|
14257
|
+
const card1 = this.getNode(current.pos.x + dx, current.pos.y);
|
|
14258
|
+
if (!card1 || card1.cost > 0 || !card1.walkable) continue;
|
|
14259
|
+
const card2 = this.getNode(current.pos.x, current.pos.y + dy);
|
|
14260
|
+
if (!card2 || card2.cost > 0 || !card2.walkable) continue;
|
|
14261
|
+
stepCost = PATHFINDER_DIAGONAL_COST;
|
|
14262
|
+
}
|
|
14263
|
+
|
|
14264
|
+
const tentativeG = current.g + stepCost + neighbor.cost;
|
|
14265
|
+
if (!neighbor.isOpen)
|
|
14266
|
+
{
|
|
14267
|
+
neighbor.isOpen = true;
|
|
14268
|
+
openList.push(neighbor);
|
|
14269
|
+
}
|
|
14270
|
+
else if (tentativeG >= neighbor.g)
|
|
14271
|
+
{
|
|
14272
|
+
continue;
|
|
14273
|
+
}
|
|
14274
|
+
|
|
14275
|
+
// Best path so far through neighbor — record it.
|
|
14276
|
+
neighbor.parent = current;
|
|
14277
|
+
neighbor.g = tentativeG;
|
|
14278
|
+
const gdx = endNode.pos.x - neighbor.pos.x;
|
|
14279
|
+
const gdy = endNode.pos.y - neighbor.pos.y;
|
|
14280
|
+
neighbor.f = neighbor.g + (gdx * gdx + gdy * gdy) * this.heuristicWeight;
|
|
14281
|
+
}
|
|
14282
|
+
}
|
|
14283
|
+
|
|
14284
|
+
return endNode.parent !== null;
|
|
14285
|
+
}
|
|
14286
|
+
|
|
14287
|
+
/** Find the clear (walkable, zero-cost) node closest to the given world
|
|
14288
|
+
* position. Spirals outward in expanding boxes until a clear node is
|
|
14289
|
+
* found or the search range is exhausted. Useful for snapping a click
|
|
14290
|
+
* or NPC spawn position to the nearest open tile.
|
|
14291
|
+
*
|
|
14292
|
+
* By default, calls `buildNodeData()` first so it works correctly on a
|
|
14293
|
+
* fresh PathFinder. If you're calling it many times in a row with
|
|
14294
|
+
* unchanged walkability, pass `rebuild=false` and call `buildNodeData()`
|
|
14295
|
+
* once externally to avoid redundant work.
|
|
14296
|
+
* @param {Vector2} worldPos
|
|
14297
|
+
* @param {number} [searchRange=10] - Max box-radius in tiles
|
|
14298
|
+
* @param {boolean} [rebuild=true] - Whether to call buildNodeData first
|
|
14299
|
+
* @returns {PathFinderNode|null}
|
|
14300
|
+
* @memberof PathFinding */
|
|
14301
|
+
getNearestClearNode(worldPos, searchRange = 10, rebuild = true)
|
|
14302
|
+
{
|
|
14303
|
+
ASSERT(isVector2(worldPos), 'worldPos must be a Vector2');
|
|
14304
|
+
if (rebuild) this.buildNodeData();
|
|
14305
|
+
|
|
14306
|
+
// Inline worldToTile to avoid a Vector2 allocation per call.
|
|
14307
|
+
const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
|
|
14308
|
+
const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
|
|
14309
|
+
const centerX = floor(worldPos.x - ox);
|
|
14310
|
+
const centerY = floor(worldPos.y - oy);
|
|
14311
|
+
|
|
14312
|
+
for (let offset = 0; offset <= searchRange; ++offset)
|
|
14313
|
+
{
|
|
14314
|
+
let nearest = null;
|
|
14315
|
+
let nearestDistSq = 0;
|
|
14316
|
+
|
|
14317
|
+
for (let dy = -offset; dy <= offset; ++dy)
|
|
14318
|
+
for (let dx = -offset; dx <= offset; ++dx)
|
|
14319
|
+
{
|
|
14320
|
+
// Only scan the perimeter of the current ring (skip the
|
|
14321
|
+
// interior we've already searched in earlier iterations).
|
|
14322
|
+
if (offset > 0 && abs(dx) !== offset && abs(dy) !== offset)
|
|
14323
|
+
continue;
|
|
14324
|
+
|
|
14325
|
+
const node = this.getNode(centerX + dx, centerY + dy);
|
|
14326
|
+
if (!node || !node.isClear()) continue;
|
|
14327
|
+
|
|
14328
|
+
const ddx = node.posWorld.x - worldPos.x;
|
|
14329
|
+
const ddy = node.posWorld.y - worldPos.y;
|
|
14330
|
+
const distSq = ddx * ddx + ddy * ddy;
|
|
14331
|
+
if (!nearest || distSq < nearestDistSq)
|
|
14332
|
+
{
|
|
14333
|
+
nearest = node;
|
|
14334
|
+
nearestDistSq = distSq;
|
|
14335
|
+
}
|
|
14336
|
+
}
|
|
14337
|
+
if (nearest) return nearest;
|
|
14338
|
+
}
|
|
14339
|
+
return null;
|
|
14340
|
+
}
|
|
14341
|
+
|
|
14342
|
+
/** Smooth a node path by removing redundant turns and tightening corners
|
|
14343
|
+
* where a grid-aligned diagonal is clear. Modifies the path in place.
|
|
14344
|
+
* Stays on the grid — does not introduce off-tile-center points.
|
|
14345
|
+
* Port of ShortenPath() in pathFinding.cpp.
|
|
14346
|
+
* @param {PathFinderNode[]} path
|
|
14347
|
+
* @private */
|
|
14348
|
+
smoothPathCorners(path)
|
|
14349
|
+
{
|
|
14350
|
+
if (path.length <= 2) return;
|
|
14351
|
+
|
|
14352
|
+
let i = 1;
|
|
14353
|
+
while (i < path.length - 1)
|
|
14354
|
+
{
|
|
14355
|
+
const prev = path[i - 1];
|
|
14356
|
+
const node = path[i];
|
|
14357
|
+
const next = path[i + 1];
|
|
14358
|
+
|
|
14359
|
+
const dx = next.pos.x - prev.pos.x;
|
|
14360
|
+
const dy = next.pos.y - prev.pos.y;
|
|
14361
|
+
const lenSq = dx * dx + dy * dy;
|
|
14362
|
+
|
|
14363
|
+
// dx,dy is the prev-to-current step direction; needed for the
|
|
14364
|
+
// 135° "mostly vertical/horizontal" disambiguation.
|
|
14365
|
+
const stepDx = node.pos.x - prev.pos.x;
|
|
14366
|
+
const stepDy = node.pos.y - prev.pos.y;
|
|
14367
|
+
const stepDxNext = next.pos.x - node.pos.x;
|
|
14368
|
+
const stepDyNext = next.pos.y - node.pos.y;
|
|
14369
|
+
|
|
14370
|
+
if (lenSq === 1)
|
|
14371
|
+
{
|
|
14372
|
+
// 45° angle — middle node is off the straight line. Drop it.
|
|
14373
|
+
if (this.debug && this.debugTime > 0)
|
|
14374
|
+
debugCircle(node.posWorld, 0.3, rgb(0.5, 0, 0.5, 0.5), this.debugTime);
|
|
14375
|
+
path.splice(i, 1);
|
|
14376
|
+
i = max(1, i - 1);
|
|
14377
|
+
continue;
|
|
14378
|
+
}
|
|
14379
|
+
else if (lenSq === 2)
|
|
14380
|
+
{
|
|
14381
|
+
// 90° corner. Check the alternative-diagonal cell.
|
|
14382
|
+
if (this.debug && this.debugTime > 0)
|
|
14383
|
+
debugCircle(node.posWorld, 0.3, rgb(1, 0, 0, 0.5), this.debugTime);
|
|
14384
|
+
|
|
14385
|
+
let sx, sy;
|
|
14386
|
+
if (prev.pos.y === node.pos.y && next.pos.x === node.pos.x)
|
|
14387
|
+
{ sx = prev.pos.x; sy = next.pos.y; }
|
|
14388
|
+
else
|
|
14389
|
+
{ sx = next.pos.x; sy = prev.pos.y; }
|
|
14390
|
+
|
|
14391
|
+
const shortcut = this.getNode(sx, sy);
|
|
14392
|
+
if (shortcut && shortcut.isClear())
|
|
14393
|
+
{
|
|
14394
|
+
path.splice(i, 1);
|
|
14395
|
+
i = max(1, i - 1);
|
|
14396
|
+
continue;
|
|
14397
|
+
}
|
|
14398
|
+
}
|
|
14399
|
+
else if (lenSq === 5)
|
|
14400
|
+
{
|
|
14401
|
+
// 135° angle (a knight's-move offset). Try to relocate the
|
|
14402
|
+
// middle node to whichever of two candidate cells is closer
|
|
14403
|
+
// to prev-of-prev, and only if the corner cut is also clear.
|
|
14404
|
+
if (this.debug && this.debugTime > 0)
|
|
14405
|
+
debugCircle(node.posWorld, 0.3, rgb(1, 1, 0, 0.5), this.debugTime);
|
|
14406
|
+
|
|
14407
|
+
const prevPrev = i >= 2 ? path[i - 2] : prev;
|
|
14408
|
+
let s1x, s1y, s2x, s2y;
|
|
14409
|
+
if (stepDx === 0 || stepDxNext === 0)
|
|
14410
|
+
{
|
|
14411
|
+
// mostly vertical
|
|
14412
|
+
s1x = next.pos.x; s1y = node.pos.y;
|
|
14413
|
+
s2x = prev.pos.x; s2y = node.pos.y;
|
|
14414
|
+
}
|
|
14415
|
+
else
|
|
14416
|
+
{
|
|
14417
|
+
// mostly horizontal
|
|
14418
|
+
s1x = node.pos.x; s1y = next.pos.y;
|
|
14419
|
+
s2x = node.pos.x; s2y = prev.pos.y;
|
|
14420
|
+
}
|
|
14421
|
+
const dd1x = s1x - prevPrev.pos.x;
|
|
14422
|
+
const dd1y = s1y - prevPrev.pos.y;
|
|
14423
|
+
const dd2x = s2x - prevPrev.pos.x;
|
|
14424
|
+
const dd2y = s2y - prevPrev.pos.y;
|
|
14425
|
+
const dist1Sq = dd1x * dd1x + dd1y * dd1y;
|
|
14426
|
+
const dist2Sq = dd2x * dd2x + dd2y * dd2y;
|
|
14427
|
+
const sx = dist1Sq < dist2Sq ? s1x : s1x === s2x && s1y === s2y ? s1x : s2x;
|
|
14428
|
+
const sy = dist1Sq < dist2Sq ? s1y : s1x === s2x && s1y === s2y ? s1y : s2y;
|
|
14429
|
+
|
|
14430
|
+
const shortcut = this.getNode(sx, sy);
|
|
14431
|
+
if (shortcut && shortcut !== node && shortcut.isClear())
|
|
14432
|
+
{
|
|
14433
|
+
// Also check the cut-corner cell is clear.
|
|
14434
|
+
const ccx = next.pos.x + s2x - s1x;
|
|
14435
|
+
const ccy = next.pos.y + s2y - s1y;
|
|
14436
|
+
const cutCorner = this.getNode(ccx, ccy);
|
|
14437
|
+
if (cutCorner && cutCorner.isClear())
|
|
14438
|
+
{
|
|
14439
|
+
path[i] = shortcut;
|
|
14440
|
+
i = max(1, i - 1);
|
|
14441
|
+
continue;
|
|
14442
|
+
}
|
|
14443
|
+
}
|
|
14444
|
+
}
|
|
14445
|
+
else if (lenSq === 4 || lenSq === 8)
|
|
14446
|
+
{
|
|
14447
|
+
// Straight line or a 1-cell bump.
|
|
14448
|
+
if (this.debug && this.debugTime > 0)
|
|
14449
|
+
debugCircle(node.posWorld, 0.3, rgb(0, 1, 0, 0.5), this.debugTime);
|
|
14450
|
+
|
|
14451
|
+
if (stepDx === stepDxNext && stepDy === stepDyNext)
|
|
14452
|
+
{
|
|
14453
|
+
// Truly straight — nothing to do, advance.
|
|
14454
|
+
++i;
|
|
14455
|
+
continue;
|
|
14456
|
+
}
|
|
14457
|
+
else
|
|
14458
|
+
{
|
|
14459
|
+
// Bump — try to flatten via the in-line cell.
|
|
14460
|
+
let sx, sy;
|
|
14461
|
+
if (prev.pos.y === next.pos.y)
|
|
14462
|
+
{ sx = node.pos.x; sy = prev.pos.y; }
|
|
14463
|
+
else
|
|
14464
|
+
{ sx = prev.pos.x; sy = node.pos.y; }
|
|
14465
|
+
const shortcut = this.getNode(sx, sy);
|
|
14466
|
+
if (shortcut && shortcut.isClear())
|
|
14467
|
+
{
|
|
14468
|
+
path[i] = shortcut;
|
|
14469
|
+
i = max(1, i - 1);
|
|
14470
|
+
continue;
|
|
14471
|
+
}
|
|
14472
|
+
}
|
|
14473
|
+
}
|
|
14474
|
+
|
|
14475
|
+
++i;
|
|
14476
|
+
}
|
|
14477
|
+
}
|
|
14478
|
+
|
|
14479
|
+
/** Smooth a node path via line-of-sight ("string pulling"). Walks the
|
|
14480
|
+
* input path collapsing runs of nodes into straight segments whenever
|
|
14481
|
+
* isLineClear permits, so the result can leave grid centers and cut
|
|
14482
|
+
* cleanly across open spaces.
|
|
14483
|
+
*
|
|
14484
|
+
* Bails (leaves the path unchanged) if any node has nonzero cost — a
|
|
14485
|
+
* straight geometric shortcut can't be trusted to be the lowest-cost
|
|
14486
|
+
* route when cost-weighted terrain is in play.
|
|
14487
|
+
*
|
|
14488
|
+
* Port of ShortenPath2() in pathFinding.cpp.
|
|
14489
|
+
* @param {PathFinderNode[]} path
|
|
14490
|
+
* @private */
|
|
14491
|
+
smoothPathStringPull(path)
|
|
14492
|
+
{
|
|
14493
|
+
if (path.length <= 2) return;
|
|
14494
|
+
for (const n of path)
|
|
14495
|
+
{
|
|
14496
|
+
if (!n.isClear()) return;
|
|
14497
|
+
}
|
|
14498
|
+
|
|
14499
|
+
const original = path.slice();
|
|
14500
|
+
path.length = 0;
|
|
14501
|
+
path.push(original[0]);
|
|
14502
|
+
let searchIndex = 0;
|
|
14503
|
+
|
|
14504
|
+
for (let i = 1; i < original.length; ++i)
|
|
14505
|
+
{
|
|
14506
|
+
const node = original[i];
|
|
14507
|
+
|
|
14508
|
+
// Skip if node is collinear with the search-window start and the
|
|
14509
|
+
// previous node — it adds no information. Note: a == b is the
|
|
14510
|
+
// degenerate i=1, searchIndex=0 case; skip the test then.
|
|
14511
|
+
{
|
|
14512
|
+
const a = original[searchIndex];
|
|
14513
|
+
const b = original[i - 1];
|
|
14514
|
+
if (a !== b)
|
|
14515
|
+
{
|
|
14516
|
+
const cross =
|
|
14517
|
+
(b.pos.x - a.pos.x) * (node.pos.y - a.pos.y) -
|
|
14518
|
+
(b.pos.y - a.pos.y) * (node.pos.x - a.pos.x);
|
|
14519
|
+
if (cross === 0) continue;
|
|
14520
|
+
}
|
|
14521
|
+
}
|
|
14522
|
+
|
|
14523
|
+
if (!this.isLineClear(node.pos, path[path.length - 1].pos))
|
|
14524
|
+
{
|
|
14525
|
+
// Look ahead — if any later node has a clear shot to the
|
|
14526
|
+
// back of our new path, skip this node and try later.
|
|
14527
|
+
let foundClearAfter = false;
|
|
14528
|
+
for (let j = i + 1; j < original.length; ++j)
|
|
14529
|
+
{
|
|
14530
|
+
if (this.isLineClear(original[j].pos, path[path.length - 1].pos))
|
|
14531
|
+
{
|
|
14532
|
+
foundClearAfter = true;
|
|
14533
|
+
break;
|
|
14534
|
+
}
|
|
14535
|
+
}
|
|
14536
|
+
if (foundClearAfter)
|
|
14537
|
+
{
|
|
14538
|
+
if (this.debug && this.debugTime > 0)
|
|
14539
|
+
debugLine(node.posWorld, path[path.length - 1].posWorld, rgb(0, 0, 1, 0.3), 0.02, this.debugTime);
|
|
14540
|
+
continue;
|
|
14541
|
+
}
|
|
14542
|
+
|
|
14543
|
+
// No clear line ahead — fall back to the last waypoint we did
|
|
14544
|
+
// have a clear line to. searchIndex tracks our scan position.
|
|
14545
|
+
for (; searchIndex < original.length; ++searchIndex)
|
|
14546
|
+
{
|
|
14547
|
+
const cand = original[searchIndex];
|
|
14548
|
+
if (this.isLineClear(node.pos, cand.pos))
|
|
14549
|
+
{
|
|
14550
|
+
path.push(cand);
|
|
14551
|
+
i = searchIndex;
|
|
14552
|
+
break;
|
|
14553
|
+
}
|
|
14554
|
+
}
|
|
14555
|
+
ASSERT(searchIndex < original.length, 'smoothPathStringPull: ran out of candidates');
|
|
14556
|
+
}
|
|
14557
|
+
}
|
|
14558
|
+
|
|
14559
|
+
path.push(original[original.length - 1]);
|
|
14560
|
+
}
|
|
14561
|
+
|
|
14562
|
+
/** Lookup helper: true when the node at tile coords (x, y) is in-bounds
|
|
14563
|
+
* and clear (walkable, zero-cost). Used by isLineClear's hot path.
|
|
14564
|
+
* @param {number} x
|
|
14565
|
+
* @param {number} y
|
|
14566
|
+
* @returns {boolean}
|
|
14567
|
+
* @private */
|
|
14568
|
+
isNodeClear(x, y)
|
|
14569
|
+
{
|
|
14570
|
+
const n = this.getNode(x, y);
|
|
14571
|
+
return n !== null && n.isClear();
|
|
14572
|
+
}
|
|
14573
|
+
|
|
14574
|
+
/** Check that the line between two tile-coord endpoints stays entirely
|
|
14575
|
+
* inside walkable, zero-cost cells. Stricter than just sampling along
|
|
14576
|
+
* the line — it also checks the diagonal-corner-adjacent cells so the
|
|
14577
|
+
* line can never "scrape past" a wall corner.
|
|
14578
|
+
*
|
|
14579
|
+
* Both endpoints must themselves be clear (asserted in debug). Port of
|
|
14580
|
+
* CheckLine() in pathFinding.cpp.
|
|
14581
|
+
* @param {Vector2} startPos - Tile coords
|
|
14582
|
+
* @param {Vector2} endPos - Tile coords
|
|
14583
|
+
* @returns {boolean}
|
|
14584
|
+
* @private */
|
|
14585
|
+
isLineClear(startPos, endPos)
|
|
14586
|
+
{
|
|
14587
|
+
ASSERT(isVector2(startPos) && isVector2(endPos), 'isLineClear needs Vector2 endpoints');
|
|
14588
|
+
ASSERT(this.isNodeClear(startPos.x, startPos.y) && this.isNodeClear(endPos.x, endPos.y),
|
|
14589
|
+
'isLineClear endpoints must be in-bounds and clear');
|
|
14590
|
+
|
|
14591
|
+
const dx = endPos.x - startPos.x;
|
|
14592
|
+
const dy = endPos.y - startPos.y;
|
|
14593
|
+
const adx = abs(dx);
|
|
14594
|
+
const ady = abs(dy);
|
|
14595
|
+
const sx = sign(dx);
|
|
14596
|
+
const sy = sign(dy);
|
|
14597
|
+
let x = startPos.x;
|
|
14598
|
+
let y = startPos.y;
|
|
14599
|
+
|
|
14600
|
+
if (ady === adx)
|
|
14601
|
+
{
|
|
14602
|
+
// Pure diagonal.
|
|
14603
|
+
while (x !== endPos.x)
|
|
14604
|
+
{
|
|
14605
|
+
if (x !== startPos.x)
|
|
14606
|
+
{
|
|
14607
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
14608
|
+
if (!this.isNodeClear(x, y - sy)) return false;
|
|
14609
|
+
}
|
|
14610
|
+
if (!this.isNodeClear(x, y + sy)) return false;
|
|
14611
|
+
x += sx;
|
|
14612
|
+
y += sy;
|
|
14613
|
+
}
|
|
14614
|
+
if (!this.isNodeClear(endPos.x, endPos.y - sy)) return false;
|
|
14615
|
+
}
|
|
14616
|
+
else if (ady < adx)
|
|
14617
|
+
{
|
|
14618
|
+
// Mostly horizontal.
|
|
14619
|
+
if (dy === 0)
|
|
14620
|
+
{
|
|
14621
|
+
// Purely horizontal.
|
|
14622
|
+
x += sx;
|
|
14623
|
+
while (x !== endPos.x)
|
|
14624
|
+
{
|
|
14625
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
14626
|
+
x += sx;
|
|
14627
|
+
}
|
|
14628
|
+
}
|
|
14629
|
+
else
|
|
14630
|
+
{
|
|
14631
|
+
let lastY = startPos.y;
|
|
14632
|
+
while (x !== endPos.x)
|
|
14633
|
+
{
|
|
14634
|
+
y = startPos.y + Math.trunc((dy * (x - startPos.x)) / dx);
|
|
14635
|
+
if (lastY !== y)
|
|
14636
|
+
{
|
|
14637
|
+
if (!this.isNodeClear(x - sx, y + sy)) return false;
|
|
14638
|
+
if (!this.isNodeClear(x, y - sy)) return false;
|
|
14639
|
+
}
|
|
14640
|
+
lastY = y;
|
|
14641
|
+
if (x !== startPos.x)
|
|
14642
|
+
{
|
|
14643
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
14644
|
+
}
|
|
14645
|
+
y += sy;
|
|
14646
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
14647
|
+
x += sx;
|
|
14648
|
+
}
|
|
14649
|
+
const finalY = endPos.y - sy;
|
|
14650
|
+
if (!this.isNodeClear(endPos.x, finalY)) return false;
|
|
14651
|
+
}
|
|
14652
|
+
}
|
|
14653
|
+
else
|
|
14654
|
+
{
|
|
14655
|
+
// Mostly vertical.
|
|
14656
|
+
if (dx === 0)
|
|
14657
|
+
{
|
|
14658
|
+
y += sy;
|
|
14659
|
+
while (y !== endPos.y)
|
|
14660
|
+
{
|
|
14661
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
14662
|
+
y += sy;
|
|
14663
|
+
}
|
|
14664
|
+
}
|
|
14665
|
+
else
|
|
14666
|
+
{
|
|
14667
|
+
let lastX = startPos.x;
|
|
14668
|
+
while (y !== endPos.y)
|
|
14669
|
+
{
|
|
14670
|
+
x = startPos.x + Math.trunc((dx * (y - startPos.y)) / dy);
|
|
14671
|
+
if (lastX !== x)
|
|
14672
|
+
{
|
|
14673
|
+
if (!this.isNodeClear(x + sx, y - sy)) return false;
|
|
14674
|
+
if (!this.isNodeClear(x - sx, y)) return false;
|
|
14675
|
+
}
|
|
14676
|
+
lastX = x;
|
|
14677
|
+
if (y !== startPos.y)
|
|
14678
|
+
{
|
|
14679
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
14680
|
+
}
|
|
14681
|
+
x += sx;
|
|
14682
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
14683
|
+
y += sy;
|
|
14684
|
+
}
|
|
14685
|
+
const finalX = endPos.x - sx;
|
|
14686
|
+
if (!this.isNodeClear(finalX, endPos.y)) return false;
|
|
14687
|
+
}
|
|
14688
|
+
}
|
|
14689
|
+
return true;
|
|
14690
|
+
}
|
|
14691
|
+
|
|
14692
|
+
/** Find a path from startPos to endPos in world space. Returns an array
|
|
14693
|
+
* of world-space Vector2 points; empty array if no path exists.
|
|
14694
|
+
*
|
|
14695
|
+
* Start and end are snapped to the nearest walkable tile via
|
|
14696
|
+
* getNearestClearNode. Intermediate points are tile centers unless the
|
|
14697
|
+
* string-pulling smoothing pass moves them off-grid.
|
|
14698
|
+
* @param {Vector2} startPos - World-space start
|
|
14699
|
+
* @param {Vector2} endPos - World-space end
|
|
14700
|
+
* @returns {Vector2[]}
|
|
14701
|
+
* @memberof PathFinding */
|
|
14702
|
+
findPath(startPos, endPos)
|
|
14703
|
+
{
|
|
14704
|
+
ASSERT(isVector2(startPos) && isVector2(endPos), 'findPath needs Vector2 endpoints');
|
|
14705
|
+
|
|
14706
|
+
this.buildNodeData();
|
|
14707
|
+
|
|
14708
|
+
// rebuild=false because we just built — avoid redundant work per snap.
|
|
14709
|
+
const startNode = this.getNearestClearNode(startPos, 10, false);
|
|
14710
|
+
const endNode = this.getNearestClearNode(endPos, 10, false);
|
|
14711
|
+
if (!startNode || !endNode) return [];
|
|
14712
|
+
|
|
14713
|
+
// Trivial case: start and end snapped to the same tile.
|
|
14714
|
+
if (startNode === endNode) return [startNode.posWorld.copy()];
|
|
14715
|
+
|
|
14716
|
+
if (!this.aStarSearch(startNode, endNode)) return [];
|
|
14717
|
+
|
|
14718
|
+
// Walk back from endNode via parent pointers, then reverse — cheaper
|
|
14719
|
+
// than unshifting on every step.
|
|
14720
|
+
const nodePath = [];
|
|
14721
|
+
for (let n = endNode; n; n = n.parent)
|
|
14722
|
+
nodePath.push(n);
|
|
14723
|
+
nodePath.reverse();
|
|
14724
|
+
|
|
14725
|
+
if (this.smoothPath)
|
|
14726
|
+
{
|
|
14727
|
+
this.smoothPathCorners(nodePath);
|
|
14728
|
+
this.smoothPathStringPull(nodePath);
|
|
14729
|
+
}
|
|
14730
|
+
|
|
14731
|
+
// Convert to world-space Vector2 path. Return copies, not live node
|
|
14732
|
+
// references — callers shouldn't be able to mutate the grid.
|
|
14733
|
+
const result = nodePath.map(n => n.posWorld.copy());
|
|
14734
|
+
|
|
14735
|
+
if (this.debug && this.debugTime > 0 && result.length > 0)
|
|
14736
|
+
{
|
|
14737
|
+
for (let i = 1; i < result.length; ++i)
|
|
14738
|
+
debugLine(result[i - 1], result[i], RED, 0.1, this.debugTime);
|
|
14739
|
+
for (const p of result)
|
|
14740
|
+
debugCircle(p, 0.5, rgb(1, 0, 0, 0.3), this.debugTime);
|
|
14741
|
+
debugCircle(result[0], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
|
|
14742
|
+
debugCircle(result[result.length - 1], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
|
|
14743
|
+
}
|
|
14744
|
+
|
|
14745
|
+
return result;
|
|
14746
|
+
}
|
|
14747
|
+
}
|
|
14748
|
+
|
|
13747
14749
|
|
|
13748
14750
|
/**
|
|
13749
14751
|
* LittleJS Module Export
|
|
@@ -13758,6 +14760,7 @@ export
|
|
|
13758
14760
|
frameRate,
|
|
13759
14761
|
timeDelta,
|
|
13760
14762
|
engineObjects,
|
|
14763
|
+
engineObjectsCollide,
|
|
13761
14764
|
frame,
|
|
13762
14765
|
time,
|
|
13763
14766
|
timeReal,
|
|
@@ -13780,6 +14783,7 @@ export
|
|
|
13780
14783
|
// Debug
|
|
13781
14784
|
ASSERT,
|
|
13782
14785
|
LOG,
|
|
14786
|
+
debugPointSize,
|
|
13783
14787
|
debugRect,
|
|
13784
14788
|
debugPoly,
|
|
13785
14789
|
debugCircle,
|
|
@@ -13824,11 +14828,14 @@ export
|
|
|
13824
14828
|
gravity,
|
|
13825
14829
|
particleEmitRateScale,
|
|
13826
14830
|
glEnable,
|
|
14831
|
+
glCircleSides,
|
|
13827
14832
|
gamepadsEnable,
|
|
13828
14833
|
gamepadDirectionEmulateStick,
|
|
13829
14834
|
inputWASDEmulateDirection,
|
|
14835
|
+
touchInputEnable,
|
|
13830
14836
|
touchGamepadEnable,
|
|
13831
14837
|
touchGamepadCenterButtonSize,
|
|
14838
|
+
touchGamepadButtonCount,
|
|
13832
14839
|
touchGamepadAnalog,
|
|
13833
14840
|
touchGamepadSize,
|
|
13834
14841
|
touchGamepadAlpha,
|
|
@@ -13838,9 +14845,6 @@ export
|
|
|
13838
14845
|
soundVolume,
|
|
13839
14846
|
soundDefaultRange,
|
|
13840
14847
|
soundDefaultTaper,
|
|
13841
|
-
medalDisplayTime,
|
|
13842
|
-
medalDisplaySlideTime,
|
|
13843
|
-
medalDisplaySize,
|
|
13844
14848
|
|
|
13845
14849
|
// Setters for globals
|
|
13846
14850
|
setCameraPos,
|
|
@@ -13872,6 +14876,7 @@ export
|
|
|
13872
14876
|
setObjectMaxSpeed,
|
|
13873
14877
|
setGravity,
|
|
13874
14878
|
setParticleEmitRateScale,
|
|
14879
|
+
setGLCircleSides,
|
|
13875
14880
|
setTouchInputEnable,
|
|
13876
14881
|
setGamepadsEnable,
|
|
13877
14882
|
setGamepadDirectionEmulateStick,
|
|
@@ -13887,10 +14892,6 @@ export
|
|
|
13887
14892
|
setSoundVolume,
|
|
13888
14893
|
setSoundDefaultRange,
|
|
13889
14894
|
setSoundDefaultTaper,
|
|
13890
|
-
setMedalDisplayTime,
|
|
13891
|
-
setMedalDisplaySlideTime,
|
|
13892
|
-
setMedalDisplaySize,
|
|
13893
|
-
setMedalsPreventUnlock,
|
|
13894
14895
|
setDebugWatermark,
|
|
13895
14896
|
setDebugKey,
|
|
13896
14897
|
|
|
@@ -13920,6 +14921,7 @@ export
|
|
|
13920
14921
|
percentLerp,
|
|
13921
14922
|
smoothStep,
|
|
13922
14923
|
nearestPowerOfTwo,
|
|
14924
|
+
isPowerOfTwo,
|
|
13923
14925
|
isOverlapping,
|
|
13924
14926
|
isIntersecting,
|
|
13925
14927
|
lineTest,
|
|
@@ -13999,6 +15001,7 @@ export
|
|
|
13999
15001
|
drawLineList,
|
|
14000
15002
|
drawLine,
|
|
14001
15003
|
drawPoly,
|
|
15004
|
+
drawRegularPoly,
|
|
14002
15005
|
drawEllipse,
|
|
14003
15006
|
drawCircle,
|
|
14004
15007
|
drawCanvas2D,
|
|
@@ -14012,6 +15015,7 @@ export
|
|
|
14012
15015
|
toggleFullscreen,
|
|
14013
15016
|
setCursor,
|
|
14014
15017
|
getCameraSize,
|
|
15018
|
+
isOnScreen,
|
|
14015
15019
|
|
|
14016
15020
|
// WebGL
|
|
14017
15021
|
glCanvas,
|
|
@@ -14019,6 +15023,7 @@ export
|
|
|
14019
15023
|
glAntialias,
|
|
14020
15024
|
glClearCanvas,
|
|
14021
15025
|
glSetTexture,
|
|
15026
|
+
glSetTextureWrap,
|
|
14022
15027
|
glCompileShader,
|
|
14023
15028
|
glCreateProgram,
|
|
14024
15029
|
glCreateTexture,
|
|
@@ -14060,6 +15065,7 @@ export
|
|
|
14060
15065
|
gamepadWasPressed,
|
|
14061
15066
|
gamepadWasReleased,
|
|
14062
15067
|
gamepadStick,
|
|
15068
|
+
gamepadStickCount,
|
|
14063
15069
|
gamepadDpad,
|
|
14064
15070
|
gamepadConnected,
|
|
14065
15071
|
gamepadVibrate,
|
|
@@ -14074,6 +15080,7 @@ export
|
|
|
14074
15080
|
audioContext,
|
|
14075
15081
|
audioMasterGain,
|
|
14076
15082
|
audioDefaultSampleRate,
|
|
15083
|
+
audioIsRunning,
|
|
14077
15084
|
Sound,
|
|
14078
15085
|
SoundInstance,
|
|
14079
15086
|
speak,
|
|
@@ -14101,11 +15108,6 @@ export
|
|
|
14101
15108
|
ParticleEmitter,
|
|
14102
15109
|
Particle,
|
|
14103
15110
|
|
|
14104
|
-
// Medals
|
|
14105
|
-
medals,
|
|
14106
|
-
medalsPreventUnlock,
|
|
14107
|
-
medalsInit,
|
|
14108
|
-
Medal,
|
|
14109
15111
|
}
|
|
14110
15112
|
/**
|
|
14111
15113
|
* LittleJS Module Plugins Export
|
|
@@ -14113,6 +15115,20 @@ export
|
|
|
14113
15115
|
|
|
14114
15116
|
export
|
|
14115
15117
|
{
|
|
15118
|
+
// Medals
|
|
15119
|
+
medals,
|
|
15120
|
+
medalsPreventUnlock,
|
|
15121
|
+
medalsInit,
|
|
15122
|
+
medalsForEach,
|
|
15123
|
+
Medal,
|
|
15124
|
+
medalDisplayTime,
|
|
15125
|
+
medalDisplaySlideTime,
|
|
15126
|
+
medalDisplaySize,
|
|
15127
|
+
setMedalDisplayTime,
|
|
15128
|
+
setMedalDisplaySlideTime,
|
|
15129
|
+
setMedalDisplaySize,
|
|
15130
|
+
setMedalsPreventUnlock,
|
|
15131
|
+
|
|
14116
15132
|
// Newgrounds
|
|
14117
15133
|
newgrounds,
|
|
14118
15134
|
NewgroundsPlugin,
|
|
@@ -14138,6 +15154,7 @@ export
|
|
|
14138
15154
|
UICheckbox,
|
|
14139
15155
|
UISlider,
|
|
14140
15156
|
UIVideo,
|
|
15157
|
+
UILayout,
|
|
14141
15158
|
|
|
14142
15159
|
// Box2D Physics
|
|
14143
15160
|
box2d,
|
|
@@ -14176,4 +15193,8 @@ export
|
|
|
14176
15193
|
tweenStopAll,
|
|
14177
15194
|
tweenUpdate,
|
|
14178
15195
|
Ease,
|
|
15196
|
+
|
|
15197
|
+
// Path Finding
|
|
15198
|
+
PathFinder,
|
|
15199
|
+
PathFinderNode,
|
|
14179
15200
|
}
|