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