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.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 Debug System
|
|
@@ -749,7 +610,7 @@ let debugKey = 'Escape';
|
|
|
749
610
|
let debugOverlay = false;
|
|
750
611
|
|
|
751
612
|
// Engine internal variables not exposed to documentation
|
|
752
|
-
let debugPrimitives = [], debugPhysics = false, debugRaycast = false, debugParticles = false, debugGamepads = false,
|
|
613
|
+
let debugPrimitives = [], debugPhysics = false, debugRaycast = false, debugParticles = false, debugGamepads = false, debugTakeScreenshot;
|
|
753
614
|
|
|
754
615
|
///////////////////////////////////////////////////////////////////////////////
|
|
755
616
|
// Debug helper functions
|
|
@@ -784,7 +645,7 @@ function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false, s
|
|
|
784
645
|
{
|
|
785
646
|
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
786
647
|
ASSERT(isVector2(size), 'size must be a vec2');
|
|
787
|
-
ASSERT(
|
|
648
|
+
ASSERT(isStringLike(color) || isColor(color), 'color is invalid');
|
|
788
649
|
ASSERT(isNumber(time), 'time must be a number');
|
|
789
650
|
ASSERT(isNumber(angle), 'angle must be a number');
|
|
790
651
|
|
|
@@ -811,7 +672,7 @@ function debugPoly(pos, points, color=WHITE, time=0, angle=0, fill=false, screen
|
|
|
811
672
|
{
|
|
812
673
|
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
813
674
|
ASSERT(isArray(points), 'points must be an array');
|
|
814
|
-
ASSERT(
|
|
675
|
+
ASSERT(isStringLike(color) || isColor(color), 'color is invalid');
|
|
815
676
|
ASSERT(isNumber(time), 'time must be a number');
|
|
816
677
|
ASSERT(isNumber(angle), 'angle must be a number');
|
|
817
678
|
|
|
@@ -835,7 +696,7 @@ function debugCircle(pos, size=0, color=WHITE, time=0, fill=false, screenSpace=f
|
|
|
835
696
|
{
|
|
836
697
|
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
837
698
|
ASSERT(isNumber(size), 'size must be a number');
|
|
838
|
-
ASSERT(
|
|
699
|
+
ASSERT(isStringLike(color) || isColor(color), 'color is invalid');
|
|
839
700
|
ASSERT(isNumber(time), 'time must be a number');
|
|
840
701
|
|
|
841
702
|
if (isColor(color))
|
|
@@ -913,13 +774,13 @@ function debugOverlap(posA, sizeA, posB, sizeB, color, time, screenSpace=false)
|
|
|
913
774
|
* @memberof Debug */
|
|
914
775
|
function debugText(text, pos, size=1, color=WHITE, time=0, angle=0, font='monospace', screenSpace=false)
|
|
915
776
|
{
|
|
916
|
-
ASSERT(
|
|
777
|
+
ASSERT(isStringLike(text), 'text must be a string');
|
|
917
778
|
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
918
779
|
ASSERT(isNumber(size), 'size must be a number');
|
|
919
|
-
ASSERT(
|
|
780
|
+
ASSERT(isStringLike(color) || isColor(color), 'color is invalid');
|
|
920
781
|
ASSERT(isNumber(time), 'time must be a number');
|
|
921
782
|
ASSERT(isNumber(angle), 'angle must be a number');
|
|
922
|
-
ASSERT(
|
|
783
|
+
ASSERT(isStringLike(font), 'font must be a string');
|
|
923
784
|
|
|
924
785
|
if (isColor(color))
|
|
925
786
|
color = color.toString();
|
|
@@ -1580,7 +1441,11 @@ function percentLerp(value, percentA, percentB, lerpA, lerpB)
|
|
|
1580
1441
|
* @return {number}
|
|
1581
1442
|
* @memberof Math */
|
|
1582
1443
|
function distanceWrap(valueA, valueB, wrapSize=1)
|
|
1583
|
-
{
|
|
1444
|
+
{
|
|
1445
|
+
ASSERT(wrapSize > 0, 'distanceWrap wrapSize must be > 0');
|
|
1446
|
+
const d = (valueA - valueB) % wrapSize;
|
|
1447
|
+
return d*2 % wrapSize - d;
|
|
1448
|
+
}
|
|
1584
1449
|
|
|
1585
1450
|
/** Linearly interpolates between values passed in with wrapping
|
|
1586
1451
|
* @param {number} valueA
|
|
@@ -1715,13 +1580,15 @@ function oscillate(frequency=1, amplitude=1, t=time, offset=0, type=0)
|
|
|
1715
1580
|
function isNumber(n) { return typeof n === 'number' && !isNaN(n); }
|
|
1716
1581
|
|
|
1717
1582
|
/**
|
|
1718
|
-
* Check if
|
|
1583
|
+
* Check if a value is stringifiable — i.e. it has a toString that returns
|
|
1584
|
+
* a string. Use this for ASSERTs and inputs that will be coerced to text;
|
|
1585
|
+
* use `typeof x === 'string'` inline if you need strict-string semantics.
|
|
1719
1586
|
* - Returns true for strings, numbers, and most objects
|
|
1720
1587
|
* - Returns false for null and undefined
|
|
1721
1588
|
* @param {any} s
|
|
1722
1589
|
* @return {boolean}
|
|
1723
1590
|
* @memberof Math */
|
|
1724
|
-
function
|
|
1591
|
+
function isStringLike(s) { return s != null && typeof s?.toString() === 'string'; }
|
|
1725
1592
|
|
|
1726
1593
|
/**
|
|
1727
1594
|
* Check if object is an array
|
|
@@ -1897,6 +1764,7 @@ class RandomGenerator
|
|
|
1897
1764
|
* @param {number} [seed] - Starting seed or engine default seed */
|
|
1898
1765
|
constructor(seed = 123456789)
|
|
1899
1766
|
{
|
|
1767
|
+
ASSERT(seed !== 0, 'RandomGenerator seed must be non-zero (xorshift is fixed at 0)');
|
|
1900
1768
|
/** @property {number} - random seed */
|
|
1901
1769
|
this.seed = seed;
|
|
1902
1770
|
}
|
|
@@ -2190,8 +2058,10 @@ class Vector2
|
|
|
2190
2058
|
* @return {Vector2} */
|
|
2191
2059
|
floor() { return new Vector2(floor(this.x), floor(this.y)); }
|
|
2192
2060
|
|
|
2193
|
-
/** Returns a copy of this vector snapped to a grid
|
|
2194
|
-
*
|
|
2061
|
+
/** Returns a copy of this vector snapped to a grid. Note that `grid` is
|
|
2062
|
+
* the number of snap steps per unit (so `grid=2` snaps to halves and
|
|
2063
|
+
* `grid=0.5` snaps to twos), not the cell size.
|
|
2064
|
+
* @param {number} grid - snap steps per unit
|
|
2195
2065
|
* @return {Vector2} */
|
|
2196
2066
|
snap(grid)
|
|
2197
2067
|
{
|
|
@@ -2462,7 +2332,7 @@ class Color
|
|
|
2462
2332
|
* @return {Color} */
|
|
2463
2333
|
setHex(hex)
|
|
2464
2334
|
{
|
|
2465
|
-
ASSERT(
|
|
2335
|
+
ASSERT(isStringLike(hex), 'Color hex code must be a string');
|
|
2466
2336
|
ASSERT(hex[0] === '#', 'Color hex code must start with #');
|
|
2467
2337
|
ASSERT([4,5,7,9].includes(hex.length), 'Invalid hex');
|
|
2468
2338
|
|
|
@@ -2726,8 +2596,8 @@ function saveCanvas(canvas, filename='screenshot', type='image/png')
|
|
|
2726
2596
|
* @memberof Utilities */
|
|
2727
2597
|
function saveDataURL(url, filename='download', revokeTime)
|
|
2728
2598
|
{
|
|
2729
|
-
ASSERT(
|
|
2730
|
-
ASSERT(
|
|
2599
|
+
ASSERT(isStringLike(url), 'saveDataURL requires url string');
|
|
2600
|
+
ASSERT(isStringLike(filename), 'saveDataURL requires filename string');
|
|
2731
2601
|
|
|
2732
2602
|
// create link for saving screenshots
|
|
2733
2603
|
const link = document.createElement('a');
|
|
@@ -2745,8 +2615,8 @@ function saveDataURL(url, filename='download', revokeTime)
|
|
|
2745
2615
|
* @memberof Utilities */
|
|
2746
2616
|
function shareURL(title, url, callback)
|
|
2747
2617
|
{
|
|
2748
|
-
ASSERT(
|
|
2749
|
-
ASSERT(
|
|
2618
|
+
ASSERT(isStringLike(title), 'shareURL requires title string');
|
|
2619
|
+
ASSERT(isStringLike(url), 'shareURL requires url string');
|
|
2750
2620
|
navigator.share?.({title, url}).then(()=>callback?.());
|
|
2751
2621
|
}
|
|
2752
2622
|
|
|
@@ -2759,11 +2629,16 @@ function shareURL(title, url, callback)
|
|
|
2759
2629
|
* @memberof Utilities */
|
|
2760
2630
|
function readSaveData(saveName, defaultSaveData)
|
|
2761
2631
|
{
|
|
2762
|
-
ASSERT(
|
|
2632
|
+
ASSERT(isStringLike(saveName), 'loadData requires saveName string');
|
|
2763
2633
|
|
|
2764
|
-
// replace undefined values with defaults
|
|
2634
|
+
// replace undefined values with defaults; tolerate corrupt JSON
|
|
2765
2635
|
const data = localStorage[saveName];
|
|
2766
|
-
|
|
2636
|
+
let loadedData = {};
|
|
2637
|
+
if (data)
|
|
2638
|
+
{
|
|
2639
|
+
try { loadedData = JSON.parse(data); }
|
|
2640
|
+
catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
|
|
2641
|
+
}
|
|
2767
2642
|
return { ...defaultSaveData, ...loadedData };
|
|
2768
2643
|
}
|
|
2769
2644
|
|
|
@@ -2773,7 +2648,7 @@ function readSaveData(saveName, defaultSaveData)
|
|
|
2773
2648
|
* @memberof Utilities */
|
|
2774
2649
|
function writeSaveData(saveName, saveData)
|
|
2775
2650
|
{
|
|
2776
|
-
ASSERT(
|
|
2651
|
+
ASSERT(isStringLike(saveName), 'saveData requires saveName string');
|
|
2777
2652
|
localStorage[saveName] = JSON.stringify(saveData);
|
|
2778
2653
|
}
|
|
2779
2654
|
/**
|
|
@@ -3098,33 +2973,6 @@ let soundDefaultRange = 40;
|
|
|
3098
2973
|
* @memberof Settings */
|
|
3099
2974
|
let soundDefaultTaper = .7;
|
|
3100
2975
|
|
|
3101
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
3102
|
-
// Medals settings
|
|
3103
|
-
|
|
3104
|
-
/** How long to show medals for in seconds
|
|
3105
|
-
* @type {number}
|
|
3106
|
-
* @default
|
|
3107
|
-
* @memberof Settings */
|
|
3108
|
-
let medalDisplayTime = 5;
|
|
3109
|
-
|
|
3110
|
-
/** How quickly to slide on/off medals in seconds
|
|
3111
|
-
* @type {number}
|
|
3112
|
-
* @default
|
|
3113
|
-
* @memberof Settings */
|
|
3114
|
-
let medalDisplaySlideTime = .5;
|
|
3115
|
-
|
|
3116
|
-
/** Size of medal display
|
|
3117
|
-
* @type {Vector2}
|
|
3118
|
-
* @default Vector2(640,80)
|
|
3119
|
-
* @memberof Settings */
|
|
3120
|
-
let medalDisplaySize = vec2(640, 80);
|
|
3121
|
-
|
|
3122
|
-
/** Set to stop medals from being unlockable (like if cheats are enabled)
|
|
3123
|
-
* @type {boolean}
|
|
3124
|
-
* @default
|
|
3125
|
-
* @memberof Settings */
|
|
3126
|
-
let medalsPreventUnlock = false;
|
|
3127
|
-
|
|
3128
2976
|
///////////////////////////////////////////////////////////////////////////////
|
|
3129
2977
|
// Setters for global variables
|
|
3130
2978
|
|
|
@@ -3386,26 +3234,6 @@ function setSoundDefaultRange(range) { soundDefaultRange = range; }
|
|
|
3386
3234
|
* @memberof Settings */
|
|
3387
3235
|
function setSoundDefaultTaper(taper) { soundDefaultTaper = taper; }
|
|
3388
3236
|
|
|
3389
|
-
/** Set how long to show medals for in seconds
|
|
3390
|
-
* @param {number} time
|
|
3391
|
-
* @memberof Settings */
|
|
3392
|
-
function setMedalDisplayTime(time) { medalDisplayTime = time; }
|
|
3393
|
-
|
|
3394
|
-
/** Set how quickly to slide on/off medals in seconds
|
|
3395
|
-
* @param {number} time
|
|
3396
|
-
* @memberof Settings */
|
|
3397
|
-
function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
|
|
3398
|
-
|
|
3399
|
-
/** Set size of medal display
|
|
3400
|
-
* @param {Vector2} size
|
|
3401
|
-
* @memberof Settings */
|
|
3402
|
-
function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
|
|
3403
|
-
|
|
3404
|
-
/** Set to stop medals from being unlockable
|
|
3405
|
-
* @param {boolean} preventUnlock
|
|
3406
|
-
* @memberof Settings */
|
|
3407
|
-
function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }
|
|
3408
|
-
|
|
3409
3237
|
/** Set if watermark with FPS should be shown
|
|
3410
3238
|
* @param {boolean} show
|
|
3411
3239
|
* @memberof Debug */
|
|
@@ -3547,10 +3375,18 @@ class EngineObject
|
|
|
3547
3375
|
const parent = this.parent;
|
|
3548
3376
|
if (parent)
|
|
3549
3377
|
{
|
|
3550
|
-
//
|
|
3378
|
+
// compose with parent transform inline to avoid intermediate vector allocs
|
|
3551
3379
|
const mirror = parent.getMirrorSign();
|
|
3552
|
-
|
|
3553
|
-
|
|
3380
|
+
const lp = this.localPos, pp = parent.pos;
|
|
3381
|
+
const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
|
|
3382
|
+
if (pa)
|
|
3383
|
+
{
|
|
3384
|
+
const c = cos(-pa), s = sin(-pa);
|
|
3385
|
+
this.pos = new Vector2(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
|
|
3386
|
+
}
|
|
3387
|
+
else
|
|
3388
|
+
this.pos = new Vector2(lx + pp.x, ly + pp.y);
|
|
3389
|
+
this.angle = mirror*this.localAngle + pa;
|
|
3554
3390
|
}
|
|
3555
3391
|
|
|
3556
3392
|
// update children
|
|
@@ -3916,8 +3752,6 @@ class EngineObject
|
|
|
3916
3752
|
* @return {string} */
|
|
3917
3753
|
toString()
|
|
3918
3754
|
{
|
|
3919
|
-
if (!debug) return;
|
|
3920
|
-
|
|
3921
3755
|
let text = 'type = ' + this.constructor.name;
|
|
3922
3756
|
if (this.pos.x || this.pos.y)
|
|
3923
3757
|
text += '\npos = ' + this.pos;
|
|
@@ -4167,8 +4001,9 @@ class TextureInfo
|
|
|
4167
4001
|
* Create a TextureInfo, called automatically by the engine
|
|
4168
4002
|
* @param {HTMLImageElement|OffscreenCanvas} image
|
|
4169
4003
|
* @param {boolean} [useWebGL] - Should use WebGL if available?
|
|
4004
|
+
* @param {boolean} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)?
|
|
4170
4005
|
*/
|
|
4171
|
-
constructor(image, useWebGL=true)
|
|
4006
|
+
constructor(image, useWebGL=true, wrap=false)
|
|
4172
4007
|
{
|
|
4173
4008
|
/** @property {HTMLImageElement|OffscreenCanvas} - image source */
|
|
4174
4009
|
this.image = image;
|
|
@@ -4178,6 +4013,8 @@ class TextureInfo
|
|
|
4178
4013
|
this.sizeInverse = image ? vec2(1/image.width, 1/image.height) : vec2();
|
|
4179
4014
|
/** @property {WebGLTexture} - WebGL texture */
|
|
4180
4015
|
this.glTexture = undefined;
|
|
4016
|
+
/** @property {boolean} - true for REPEAT wrap mode, false for CLAMP_TO_EDGE */
|
|
4017
|
+
this.wrap = wrap;
|
|
4181
4018
|
useWebGL && this.createWebGLTexture();
|
|
4182
4019
|
}
|
|
4183
4020
|
|
|
@@ -4190,6 +4027,14 @@ class TextureInfo
|
|
|
4190
4027
|
/** Check if the texture is webgl enabled
|
|
4191
4028
|
* @return {boolean} */
|
|
4192
4029
|
hasWebGL() { return !!this.glTexture; }
|
|
4030
|
+
|
|
4031
|
+
/** Set the wrap mode for this texture
|
|
4032
|
+
* @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE */
|
|
4033
|
+
setWrap(wrap=true)
|
|
4034
|
+
{
|
|
4035
|
+
this.wrap = wrap;
|
|
4036
|
+
glSetTextureWrap(this.glTexture, wrap);
|
|
4037
|
+
}
|
|
4193
4038
|
}
|
|
4194
4039
|
|
|
4195
4040
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -4251,8 +4096,13 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
|
4251
4096
|
}
|
|
4252
4097
|
else
|
|
4253
4098
|
{
|
|
4254
|
-
// if no tile info, force untextured
|
|
4255
|
-
|
|
4099
|
+
// if no tile info, force untextured by zeroing rgba (so whatever
|
|
4100
|
+
// texture is bound doesn't leak in) and folding color+additive
|
|
4101
|
+
// into the additive slot — matches the Canvas2D path's
|
|
4102
|
+
// color.add(additiveColor) on line ~337.
|
|
4103
|
+
const combined = additiveColor ? color.add(additiveColor) : color;
|
|
4104
|
+
glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0,
|
|
4105
|
+
0, combined.rgbaInt());
|
|
4256
4106
|
}
|
|
4257
4107
|
}
|
|
4258
4108
|
else
|
|
@@ -4391,6 +4241,8 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
|
|
|
4391
4241
|
const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
|
|
4392
4242
|
ASSERT(textureInfo instanceof TextureInfo, 'texture not loaded');
|
|
4393
4243
|
ASSERT(textureInfo.size.x > 0, 'texture not loaded');
|
|
4244
|
+
ASSERT(textureInfo.wrap,
|
|
4245
|
+
'drawTextureWrapped requires a wrap-enabled texture; call textureInfo.setWrap(true) first');
|
|
4394
4246
|
|
|
4395
4247
|
if (useWebGL && glEnable)
|
|
4396
4248
|
{
|
|
@@ -4742,15 +4594,15 @@ function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, f
|
|
|
4742
4594
|
* @memberof Draw */
|
|
4743
4595
|
function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
|
|
4744
4596
|
{
|
|
4745
|
-
ASSERT(
|
|
4597
|
+
ASSERT(isStringLike(text), 'text must be a string');
|
|
4746
4598
|
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
4747
4599
|
ASSERT(isNumber(size), 'size must be a number');
|
|
4748
4600
|
ASSERT(isColor(color), 'color must be a color');
|
|
4749
4601
|
ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
|
|
4750
4602
|
ASSERT(isColor(lineColor), 'lineColor must be a color');
|
|
4751
4603
|
ASSERT(['left','center','right'].includes(textAlign), 'align must be left, center, or right');
|
|
4752
|
-
ASSERT(
|
|
4753
|
-
ASSERT(
|
|
4604
|
+
ASSERT(isStringLike(font), 'font must be a string');
|
|
4605
|
+
ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
|
|
4754
4606
|
ASSERT(isNumber(angle), 'angle must be a number');
|
|
4755
4607
|
|
|
4756
4608
|
context.fillStyle = color.toString();
|
|
@@ -4787,7 +4639,7 @@ async function loadTexture(textureIndex, src)
|
|
|
4787
4639
|
{
|
|
4788
4640
|
ASSERT(isNumber(textureIndex), 'textureIndex must be a number');
|
|
4789
4641
|
ASSERT(!textureInfos[textureIndex], 'textureIndex is already loaded!');
|
|
4790
|
-
ASSERT(!src ||
|
|
4642
|
+
ASSERT(!src || isStringLike(src), 'image src must be a string');
|
|
4791
4643
|
|
|
4792
4644
|
const image = new Image;
|
|
4793
4645
|
if (src)
|
|
@@ -5175,7 +5027,7 @@ class FontImage
|
|
|
5175
5027
|
*/
|
|
5176
5028
|
drawTextScreen(text, pos, size, center=true, color=WHITE, useWebGL=glEnable, context)
|
|
5177
5029
|
{
|
|
5178
|
-
ASSERT(
|
|
5030
|
+
ASSERT(isStringLike(text), 'text must be a string');
|
|
5179
5031
|
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
5180
5032
|
ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
|
|
5181
5033
|
ASSERT(isColor(color), 'color must be a color');
|
|
@@ -5299,7 +5151,7 @@ const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
|
|
|
5299
5151
|
* This is useful to disable for html menus so the browser can handle input normally
|
|
5300
5152
|
* @param {boolean} preventDefault
|
|
5301
5153
|
* @memberof Input */
|
|
5302
|
-
function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
|
|
5154
|
+
function setInputPreventDefault(preventDefault=true) { inputPreventDefault = preventDefault; }
|
|
5303
5155
|
|
|
5304
5156
|
/** Clears an input key state
|
|
5305
5157
|
* @param {string|number} key
|
|
@@ -5336,7 +5188,7 @@ function inputClear()
|
|
|
5336
5188
|
* @memberof Input */
|
|
5337
5189
|
function keyIsDown(key, device=0)
|
|
5338
5190
|
{
|
|
5339
|
-
ASSERT(
|
|
5191
|
+
ASSERT(isStringLike(key), 'key must be a number or string');
|
|
5340
5192
|
ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
|
|
5341
5193
|
return !!(inputData[device]?.[key] & 1);
|
|
5342
5194
|
}
|
|
@@ -5348,7 +5200,7 @@ function keyIsDown(key, device=0)
|
|
|
5348
5200
|
* @memberof Input */
|
|
5349
5201
|
function keyWasPressed(key, device=0)
|
|
5350
5202
|
{
|
|
5351
|
-
ASSERT(
|
|
5203
|
+
ASSERT(isStringLike(key), 'key must be a number or string');
|
|
5352
5204
|
ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
|
|
5353
5205
|
return !!(inputData[device]?.[key] & 2);
|
|
5354
5206
|
}
|
|
@@ -5360,7 +5212,7 @@ function keyWasPressed(key, device=0)
|
|
|
5360
5212
|
* @memberof Input */
|
|
5361
5213
|
function keyWasReleased(key, device=0)
|
|
5362
5214
|
{
|
|
5363
|
-
ASSERT(
|
|
5215
|
+
ASSERT(isStringLike(key), 'key must be a number or string');
|
|
5364
5216
|
ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
|
|
5365
5217
|
return !!(inputData[device]?.[key] & 4);
|
|
5366
5218
|
}
|
|
@@ -5374,10 +5226,10 @@ function keyWasReleased(key, device=0)
|
|
|
5374
5226
|
* @memberof Input */
|
|
5375
5227
|
function keyDirection(up='ArrowUp', down='ArrowDown', left='ArrowLeft', right='ArrowRight')
|
|
5376
5228
|
{
|
|
5377
|
-
ASSERT(
|
|
5378
|
-
ASSERT(
|
|
5379
|
-
ASSERT(
|
|
5380
|
-
ASSERT(
|
|
5229
|
+
ASSERT(isStringLike(up), 'up key must be a string');
|
|
5230
|
+
ASSERT(isStringLike(down), 'down key must be a string');
|
|
5231
|
+
ASSERT(isStringLike(left), 'left key must be a string');
|
|
5232
|
+
ASSERT(isStringLike(right), 'right key must be a string');
|
|
5381
5233
|
const k = (key)=> keyIsDown(key) ? 1 : 0;
|
|
5382
5234
|
return vec2(k(right) - k(left), k(up) - k(down));
|
|
5383
5235
|
}
|
|
@@ -6165,7 +6017,7 @@ class Sound
|
|
|
6165
6017
|
{
|
|
6166
6018
|
if (!soundEnable || headlessMode) return;
|
|
6167
6019
|
|
|
6168
|
-
ASSERT(!asset || isArray(asset) ||
|
|
6020
|
+
ASSERT(!asset || isArray(asset) || isStringLike(asset), 'asset must be a file name or zzfx array');
|
|
6169
6021
|
ASSERT(randomness === undefined || isNumber(randomness), 'randomness must be a number');
|
|
6170
6022
|
ASSERT(randomness === undefined || randomness >= 0 && randomness <=1, 'randomness must be between 0 and 1');
|
|
6171
6023
|
ASSERT(isNumber(range), 'range must be a number');
|
|
@@ -6186,8 +6038,8 @@ class Sound
|
|
|
6186
6038
|
|
|
6187
6039
|
if (Array.isArray(asset))
|
|
6188
6040
|
{
|
|
6189
|
-
// generate zzfx sound
|
|
6190
|
-
const zzfxSound = asset;
|
|
6041
|
+
// generate zzfx sound — copy so we don't mutate the caller's array
|
|
6042
|
+
const zzfxSound = asset.slice();
|
|
6191
6043
|
|
|
6192
6044
|
// remove randomness so it can be applied on playback
|
|
6193
6045
|
const defaultRandomness = randomness ?? .05;
|
|
@@ -7785,11 +7637,14 @@ class ParticleEmitter extends EngineObject
|
|
|
7785
7637
|
}
|
|
7786
7638
|
|
|
7787
7639
|
///////////////////////////////////////////////////////////////////////////////
|
|
7640
|
+
// scratch vector reused by Particle.render to avoid per-frame allocations
|
|
7641
|
+
const particleDrawPos = new Vector2;
|
|
7642
|
+
|
|
7788
7643
|
/**
|
|
7789
7644
|
* Particle Object - Created automatically by Particle Emitters
|
|
7790
7645
|
* @memberof Particles
|
|
7791
7646
|
*/
|
|
7792
|
-
class Particle
|
|
7647
|
+
class Particle
|
|
7793
7648
|
{
|
|
7794
7649
|
/**
|
|
7795
7650
|
* Create a particle with the passed in settings
|
|
@@ -7964,14 +7819,14 @@ class Particle
|
|
|
7964
7819
|
additive && setBlendMode(true);
|
|
7965
7820
|
|
|
7966
7821
|
// update the position and angle for drawing
|
|
7967
|
-
const pos = this.pos.
|
|
7822
|
+
const pos = particleDrawPos.set(this.pos.x, this.pos.y);
|
|
7968
7823
|
let angle = this.angle;
|
|
7969
7824
|
if (localSpace)
|
|
7970
7825
|
{
|
|
7971
7826
|
// in local space of emitter
|
|
7972
7827
|
const a = emitter.angle;
|
|
7973
7828
|
const c = cos(a), s = sin(a);
|
|
7974
|
-
pos.set(emitter.pos.x + pos.x*c - pos.y*s,
|
|
7829
|
+
pos.set(emitter.pos.x + pos.x*c - pos.y*s,
|
|
7975
7830
|
emitter.pos.y + pos.x*s + pos.y*c);
|
|
7976
7831
|
angle += a;
|
|
7977
7832
|
}
|
|
@@ -7996,200 +7851,6 @@ class Particle
|
|
|
7996
7851
|
debugParticles && debugRect(pos, size, '#f005', 0, angle);
|
|
7997
7852
|
}
|
|
7998
7853
|
}
|
|
7999
|
-
/**
|
|
8000
|
-
* LittleJS Medal System
|
|
8001
|
-
* - Achievement/trophy system for games
|
|
8002
|
-
* - Medal class with name, description, icon, and unlock tracking
|
|
8003
|
-
* - Automatic saving to local storage
|
|
8004
|
-
* - Visual display queue with slide-in notifications
|
|
8005
|
-
* - Newgrounds API integration for online achievements
|
|
8006
|
-
* - Debug mode to unlock/reset medals during development
|
|
8007
|
-
* @namespace Medals
|
|
8008
|
-
*/
|
|
8009
|
-
|
|
8010
|
-
/** List of all medals
|
|
8011
|
-
* @type {Object}
|
|
8012
|
-
* @memberof Medals */
|
|
8013
|
-
const medals = {};
|
|
8014
|
-
|
|
8015
|
-
// Engine internal variables not exposed to documentation
|
|
8016
|
-
let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast;
|
|
8017
|
-
|
|
8018
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
8019
|
-
|
|
8020
|
-
/** Initialize medals with a save name used for storage
|
|
8021
|
-
* - Call this after creating all medals
|
|
8022
|
-
* - Checks if medals are unlocked
|
|
8023
|
-
* @param {string} saveName
|
|
8024
|
-
* @memberof Medals */
|
|
8025
|
-
function medalsInit(saveName)
|
|
8026
|
-
{
|
|
8027
|
-
// check if medals are unlocked
|
|
8028
|
-
medalsSaveName = saveName;
|
|
8029
|
-
if (!debugMedals)
|
|
8030
|
-
medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
|
|
8031
|
-
|
|
8032
|
-
// engine automatically renders medals
|
|
8033
|
-
engineAddPlugin(undefined, medalsRender);
|
|
8034
|
-
|
|
8035
|
-
// plugin functions
|
|
8036
|
-
function medalsRender()
|
|
8037
|
-
{
|
|
8038
|
-
if (!medalsDisplayQueue.length) return;
|
|
8039
|
-
|
|
8040
|
-
// update first medal in queue
|
|
8041
|
-
const medal = medalsDisplayQueue[0];
|
|
8042
|
-
const time = timeReal - medalsDisplayTimeLast;
|
|
8043
|
-
if (!medalsDisplayTimeLast)
|
|
8044
|
-
medalsDisplayTimeLast = timeReal;
|
|
8045
|
-
else if (time > medalDisplayTime)
|
|
8046
|
-
{
|
|
8047
|
-
medalsDisplayTimeLast = 0;
|
|
8048
|
-
medalsDisplayQueue.shift();
|
|
8049
|
-
}
|
|
8050
|
-
else
|
|
8051
|
-
{
|
|
8052
|
-
// slide on/off medals
|
|
8053
|
-
const slideOffTime = medalDisplayTime - medalDisplaySlideTime;
|
|
8054
|
-
const hidePercent =
|
|
8055
|
-
time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime :
|
|
8056
|
-
time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0;
|
|
8057
|
-
medal.render(hidePercent);
|
|
8058
|
-
}
|
|
8059
|
-
}
|
|
8060
|
-
}
|
|
8061
|
-
|
|
8062
|
-
/**
|
|
8063
|
-
* @callback MedalCallbackFunction - Function that processes a medal
|
|
8064
|
-
* @param {Medal} medal
|
|
8065
|
-
* @memberof Medals
|
|
8066
|
-
*/
|
|
8067
|
-
|
|
8068
|
-
/** Calls a function for each medal
|
|
8069
|
-
* @param {MedalCallbackFunction} callback
|
|
8070
|
-
* @memberof Medals */
|
|
8071
|
-
function medalsForEach(callback)
|
|
8072
|
-
{ Object.values(medals).forEach(medal=>callback(medal)); }
|
|
8073
|
-
|
|
8074
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
8075
|
-
|
|
8076
|
-
/**
|
|
8077
|
-
* Medal - Tracks an unlockable medal
|
|
8078
|
-
* @memberof Medals
|
|
8079
|
-
* @example
|
|
8080
|
-
* // create a medal
|
|
8081
|
-
* const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
|
|
8082
|
-
*
|
|
8083
|
-
* // initialize medals
|
|
8084
|
-
* medalsInit('Example Game');
|
|
8085
|
-
*
|
|
8086
|
-
* // unlock the medal
|
|
8087
|
-
* medal_example.unlock();
|
|
8088
|
-
*/
|
|
8089
|
-
class Medal
|
|
8090
|
-
{
|
|
8091
|
-
/** Create a medal object and adds it to the list of medals
|
|
8092
|
-
* @param {number} id - The unique identifier of the medal
|
|
8093
|
-
* @param {string} name - Name of the medal
|
|
8094
|
-
* @param {string} [description] - Description of the medal
|
|
8095
|
-
* @param {string} [icon] - Icon for the medal
|
|
8096
|
-
* @param {string} [src] - Image location for the medal
|
|
8097
|
-
*/
|
|
8098
|
-
constructor(id, name, description='', icon='🏆', src)
|
|
8099
|
-
{
|
|
8100
|
-
ASSERT(id >= 0 && !medals[id]);
|
|
8101
|
-
|
|
8102
|
-
/** @property {number} - The unique identifier of the medal */
|
|
8103
|
-
this.id = id;
|
|
8104
|
-
|
|
8105
|
-
/** @property {string} - Name of the medal */
|
|
8106
|
-
this.name = name;
|
|
8107
|
-
|
|
8108
|
-
/** @property {string} - Description of the medal */
|
|
8109
|
-
this.description = description;
|
|
8110
|
-
|
|
8111
|
-
/** @property {string} - Icon for the medal */
|
|
8112
|
-
this.icon = icon;
|
|
8113
|
-
|
|
8114
|
-
/** @property {boolean} - Is the medal unlocked? */
|
|
8115
|
-
this.unlocked = false;
|
|
8116
|
-
|
|
8117
|
-
// load the source image if provided
|
|
8118
|
-
if (src)
|
|
8119
|
-
(this.image = new Image).src = src;
|
|
8120
|
-
|
|
8121
|
-
// add this to list of medals
|
|
8122
|
-
medals[id] = this;
|
|
8123
|
-
}
|
|
8124
|
-
|
|
8125
|
-
/** Unlocks a medal if not already unlocked */
|
|
8126
|
-
unlock()
|
|
8127
|
-
{
|
|
8128
|
-
if (medalsPreventUnlock || this.unlocked) return;
|
|
8129
|
-
|
|
8130
|
-
// save the medal
|
|
8131
|
-
ASSERT(medalsSaveName, 'save name must be set');
|
|
8132
|
-
localStorage[this.storageKey()] = this.unlocked = true;
|
|
8133
|
-
medalsDisplayQueue.push(this);
|
|
8134
|
-
}
|
|
8135
|
-
|
|
8136
|
-
/** Render a medal
|
|
8137
|
-
* @param {number} [hidePercent] - How much to slide the medal off screen
|
|
8138
|
-
*/
|
|
8139
|
-
render(hidePercent=0)
|
|
8140
|
-
{
|
|
8141
|
-
const context = mainContext;
|
|
8142
|
-
const width = min(medalDisplaySize.x, mainCanvas.width);
|
|
8143
|
-
const height = medalDisplaySize.y;
|
|
8144
|
-
const x = mainCanvas.width - width;
|
|
8145
|
-
const y = -height*hidePercent;
|
|
8146
|
-
const backgroundColor = hsl(0,0,.9);
|
|
8147
|
-
|
|
8148
|
-
// draw containing rect and clip to that region
|
|
8149
|
-
context.save();
|
|
8150
|
-
context.beginPath();
|
|
8151
|
-
context.fillStyle = backgroundColor.toString();
|
|
8152
|
-
context.strokeStyle = BLACK.toString();
|
|
8153
|
-
context.lineWidth = 3;
|
|
8154
|
-
context.rect(x, y, width, height);
|
|
8155
|
-
context.fill();
|
|
8156
|
-
context.stroke();
|
|
8157
|
-
context.clip();
|
|
8158
|
-
|
|
8159
|
-
// draw the icon
|
|
8160
|
-
const gap = vec2(.1, .05).scale(height);
|
|
8161
|
-
const medalDisplayIconSize = height - 2*gap.x;
|
|
8162
|
-
this.renderIcon(vec2(x + gap.x + medalDisplayIconSize/2, y + height/2), medalDisplayIconSize);
|
|
8163
|
-
|
|
8164
|
-
// draw the name
|
|
8165
|
-
const nameSize = height*.5;
|
|
8166
|
-
const descriptionSize = height*.3;
|
|
8167
|
-
const pos = vec2(x + medalDisplayIconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
|
|
8168
|
-
const textWidth = width - medalDisplayIconSize - 3*gap.x;
|
|
8169
|
-
drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
|
|
8170
|
-
|
|
8171
|
-
// draw the description
|
|
8172
|
-
pos.y = y + height - gap.y*2 - descriptionSize/2;
|
|
8173
|
-
drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
|
|
8174
|
-
context.restore();
|
|
8175
|
-
}
|
|
8176
|
-
|
|
8177
|
-
/** Render the icon for a medal
|
|
8178
|
-
* @param {Vector2} pos - Screen space position
|
|
8179
|
-
* @param {number} size - Screen space size
|
|
8180
|
-
*/
|
|
8181
|
-
renderIcon(pos, size)
|
|
8182
|
-
{
|
|
8183
|
-
// draw the image or icon
|
|
8184
|
-
if (this.image)
|
|
8185
|
-
mainContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size);
|
|
8186
|
-
else
|
|
8187
|
-
drawTextScreen(this.icon, pos, size*.7, BLACK);
|
|
8188
|
-
}
|
|
8189
|
-
|
|
8190
|
-
// Get local storage key used by the medal
|
|
8191
|
-
storageKey() { return medalsSaveName + '_' + this.id; }
|
|
8192
|
-
}
|
|
8193
7854
|
/**
|
|
8194
7855
|
* LittleJS WebGL Interface
|
|
8195
7856
|
* - WebGL2 rendering engine for high-performance graphics
|
|
@@ -8289,7 +7950,7 @@ function glInit(rootElement)
|
|
|
8289
7950
|
// reinit WebGL and restore textures
|
|
8290
7951
|
initWebGL();
|
|
8291
7952
|
for (const info of glTextureInfos)
|
|
8292
|
-
info.glTexture = glCreateTexture(info.image);
|
|
7953
|
+
info.glTexture = glCreateTexture(info.image, info.wrap);
|
|
8293
7954
|
pluginList.forEach(plugin=>plugin.glContextRestored?.());
|
|
8294
7955
|
});
|
|
8295
7956
|
|
|
@@ -8505,6 +8166,30 @@ function glSetTexture(texture)
|
|
|
8505
8166
|
glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
|
|
8506
8167
|
}
|
|
8507
8168
|
|
|
8169
|
+
/** Set the wrap mode (REPEAT or CLAMP_TO_EDGE) on an existing WebGL texture
|
|
8170
|
+
* Flushes the current batch only if the texture is the active one
|
|
8171
|
+
* @param {WebGLTexture} texture
|
|
8172
|
+
* @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
|
|
8173
|
+
* @memberof WebGL */
|
|
8174
|
+
function glSetTextureWrap(texture, wrap=true)
|
|
8175
|
+
{
|
|
8176
|
+
if (!glContext || !texture) return;
|
|
8177
|
+
|
|
8178
|
+
// flush only if changing wrap on the currently bound texture
|
|
8179
|
+
const isCurrent = texture === glActiveTexture;
|
|
8180
|
+
if (isCurrent)
|
|
8181
|
+
glFlush();
|
|
8182
|
+
else
|
|
8183
|
+
glContext.bindTexture(glContext.TEXTURE_2D, texture);
|
|
8184
|
+
|
|
8185
|
+
const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
|
|
8186
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
|
|
8187
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
|
|
8188
|
+
|
|
8189
|
+
if (!isCurrent && glActiveTexture)
|
|
8190
|
+
glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
|
|
8191
|
+
}
|
|
8192
|
+
|
|
8508
8193
|
/** Compile WebGL shader of the given type, will throw errors if in debug mode
|
|
8509
8194
|
* @param {string} source
|
|
8510
8195
|
* @param {number} type
|
|
@@ -8549,9 +8234,10 @@ function glCreateProgram(vsSource, fsSource)
|
|
|
8549
8234
|
/** Create WebGL texture from an image and init the texture settings
|
|
8550
8235
|
* Restores the active texture when done
|
|
8551
8236
|
* @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas} [image]
|
|
8237
|
+
* @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
|
|
8552
8238
|
* @return {WebGLTexture}
|
|
8553
8239
|
* @memberof WebGL */
|
|
8554
|
-
function glCreateTexture(image)
|
|
8240
|
+
function glCreateTexture(image, wrap=false)
|
|
8555
8241
|
{
|
|
8556
8242
|
if (!glContext) return;
|
|
8557
8243
|
|
|
@@ -8577,8 +8263,9 @@ function glCreateTexture(image)
|
|
|
8577
8263
|
const minFilter = mipMap ? glContext.LINEAR_MIPMAP_LINEAR : magFilter;
|
|
8578
8264
|
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, magFilter);
|
|
8579
8265
|
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, minFilter);
|
|
8580
|
-
|
|
8581
|
-
glContext.texParameteri(glContext.TEXTURE_2D, glContext.
|
|
8266
|
+
const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
|
|
8267
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
|
|
8268
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
|
|
8582
8269
|
if (mipMap)
|
|
8583
8270
|
glContext.generateMipmap(glContext.TEXTURE_2D);
|
|
8584
8271
|
|
|
@@ -8630,7 +8317,7 @@ function glRegisterTextureInfo(textureInfo)
|
|
|
8630
8317
|
if (textureInfo.glTexture)
|
|
8631
8318
|
glSetTextureData(textureInfo.glTexture, textureInfo.image);
|
|
8632
8319
|
else
|
|
8633
|
-
textureInfo.glTexture = glCreateTexture(textureInfo.image);
|
|
8320
|
+
textureInfo.glTexture = glCreateTexture(textureInfo.image, textureInfo.wrap);
|
|
8634
8321
|
}
|
|
8635
8322
|
|
|
8636
8323
|
/** Tells WebGL to destroy the glTexture and stop tracking it
|
|
@@ -8849,6 +8536,7 @@ function glSetRenderTarget(texture, clear=false)
|
|
|
8849
8536
|
glFlush();
|
|
8850
8537
|
glRenderTarget = undefined;
|
|
8851
8538
|
glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
|
|
8539
|
+
glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
|
|
8852
8540
|
}
|
|
8853
8541
|
}
|
|
8854
8542
|
|
|
@@ -8971,9 +8659,9 @@ function glPolyStrip(points)
|
|
|
8971
8659
|
return area;
|
|
8972
8660
|
}
|
|
8973
8661
|
|
|
8974
|
-
// ensure counter-clockwise winding
|
|
8662
|
+
// ensure counter-clockwise winding (slice first so we don't mutate caller's array)
|
|
8975
8663
|
if (signedArea(points) < 0)
|
|
8976
|
-
points = points.reverse();
|
|
8664
|
+
points = points.slice().reverse();
|
|
8977
8665
|
|
|
8978
8666
|
// check if point is inside triangle
|
|
8979
8667
|
const e = 1e-9;
|
|
@@ -9073,6 +8761,398 @@ function glPolyStrip(points)
|
|
|
9073
8761
|
}
|
|
9074
8762
|
return strip;
|
|
9075
8763
|
}
|
|
8764
|
+
/**
|
|
8765
|
+
* LittleJS Engine Logo
|
|
8766
|
+
* - Draws the LittleJS splash screen logo
|
|
8767
|
+
* - Used internally during engine startup
|
|
8768
|
+
*/
|
|
8769
|
+
|
|
8770
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
8771
|
+
function drawEngineLogo(t)
|
|
8772
|
+
{
|
|
8773
|
+
const blackAndWhite = 0;
|
|
8774
|
+
const showName = 1;
|
|
8775
|
+
|
|
8776
|
+
// LittleJS Logo and Splash Screen
|
|
8777
|
+
const x = mainContext;
|
|
8778
|
+
const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
|
|
8779
|
+
const w = mainCanvas.width = innerWidth * dpr;
|
|
8780
|
+
const h = mainCanvas.height = innerHeight * dpr;
|
|
8781
|
+
{
|
|
8782
|
+
// background
|
|
8783
|
+
const p3 = percent(t, 1, .8);
|
|
8784
|
+
const p4 = percent(t, 0, .5);
|
|
8785
|
+
const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,hypot(w,h)*.6);
|
|
8786
|
+
g.addColorStop(0,hsl(0,0,lerp(0,p3/2,p4),p3).toString());
|
|
8787
|
+
g.addColorStop(1,hsl(0,0,0,p3).toString());
|
|
8788
|
+
x.save();
|
|
8789
|
+
x.fillStyle = g;
|
|
8790
|
+
x.fillRect(0,0,w,h);
|
|
8791
|
+
}
|
|
8792
|
+
const gradient = (X1,Y1,X2,Y2,C,S=1)=>
|
|
8793
|
+
{
|
|
8794
|
+
if (C >= 0)
|
|
8795
|
+
{
|
|
8796
|
+
if (blackAndWhite)
|
|
8797
|
+
x.fillStyle = '#fff';
|
|
8798
|
+
else
|
|
8799
|
+
{
|
|
8800
|
+
const g = x.fillStyle = x.createLinearGradient(X1,Y1,X2,Y2);
|
|
8801
|
+
g.addColorStop(0,color(C,2));
|
|
8802
|
+
g.addColorStop(1,color(C,1));
|
|
8803
|
+
}
|
|
8804
|
+
}
|
|
8805
|
+
else
|
|
8806
|
+
x.fillStyle = '#000';
|
|
8807
|
+
C >= -1 ? (x.fill(), S && x.stroke()) : x.stroke();
|
|
8808
|
+
}
|
|
8809
|
+
const circle = (X,Y,R,A=0,B=2*PI,C,S)=>
|
|
8810
|
+
{
|
|
8811
|
+
x.beginPath();
|
|
8812
|
+
x.arc(X,Y,R,p*A,p*B);
|
|
8813
|
+
gradient(X,Y-R,X,Y+R,C,S);
|
|
8814
|
+
}
|
|
8815
|
+
const rect = (X,Y,W,H,C)=>
|
|
8816
|
+
{
|
|
8817
|
+
x.beginPath();
|
|
8818
|
+
x.rect(X,Y,W,H*p);
|
|
8819
|
+
gradient(X,Y+H,X+W,Y,C);
|
|
8820
|
+
}
|
|
8821
|
+
const poly = (points,C,Y,H)=>
|
|
8822
|
+
{
|
|
8823
|
+
x.beginPath();
|
|
8824
|
+
for (const p of points)
|
|
8825
|
+
x.lineTo(p.x, p.y);
|
|
8826
|
+
x.closePath();
|
|
8827
|
+
gradient(0, Y, 0, Y+H,C);
|
|
8828
|
+
}
|
|
8829
|
+
const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
|
|
8830
|
+
|
|
8831
|
+
// center and fit to screen
|
|
8832
|
+
const alpha = oscillate(1,1,t);
|
|
8833
|
+
const p = percent(alpha, .1, .5);
|
|
8834
|
+
const size = min(6, min(w,h)/99);
|
|
8835
|
+
x.translate(w/2,h/2);
|
|
8836
|
+
x.scale(size,size);
|
|
8837
|
+
x.translate(-40,-35);
|
|
8838
|
+
p < 1 && x.setLineDash([99*p,99]);
|
|
8839
|
+
x.lineJoin = x.lineCap = 'round';
|
|
8840
|
+
x.lineWidth = .1 + p*1.9;
|
|
8841
|
+
//x.strokeStyle='#fff7';
|
|
8842
|
+
|
|
8843
|
+
if (showName)
|
|
8844
|
+
{
|
|
8845
|
+
// engine name text
|
|
8846
|
+
const Y = 54;
|
|
8847
|
+
const s = 'LittleJS';
|
|
8848
|
+
x.font = '900 15.5px arial';
|
|
8849
|
+
x.lineWidth = .1+p*3.9;
|
|
8850
|
+
x.textAlign = 'center';
|
|
8851
|
+
x.textBaseline = 'top';
|
|
8852
|
+
rect(11,Y+1,59,8*p,-1);
|
|
8853
|
+
x.beginPath();
|
|
8854
|
+
|
|
8855
|
+
let w2 = 0;
|
|
8856
|
+
for (let i=0;i<s.length;++i)
|
|
8857
|
+
w2 += x.measureText(s[i]).width;
|
|
8858
|
+
for (let j=2;j--;)
|
|
8859
|
+
for (let i=0,X=40-w2/2;i<s.length;++i)
|
|
8860
|
+
{
|
|
8861
|
+
const w = x.measureText(s[i]).width, X2 = X+w/2;
|
|
8862
|
+
gradient(X2,Y,X2+2,Y+13,i>5?1:0);
|
|
8863
|
+
x[j?'strokeText':'fillText'](s[i],X2,Y+.5,17*p);
|
|
8864
|
+
X += w;
|
|
8865
|
+
}
|
|
8866
|
+
|
|
8867
|
+
x.lineWidth = .1 + p*1.9;
|
|
8868
|
+
rect(3,Y,73,0); // bottom
|
|
8869
|
+
}
|
|
8870
|
+
|
|
8871
|
+
rect(7,15,26,-7,0); // cab top
|
|
8872
|
+
rect(25,15,8,25,-1); // cab front
|
|
8873
|
+
rect(10,40,15,-25,1); // cab back
|
|
8874
|
+
rect(14,21,7,9,2); // cab window
|
|
8875
|
+
rect(38,20,6,-6,2); // little stack
|
|
8876
|
+
|
|
8877
|
+
// big stack
|
|
8878
|
+
rect(49,20,10,-6,0);
|
|
8879
|
+
const stackPoints = [vec2(44,8),vec2(64,8),vec2(59,8+6*p),vec2(49,8+6*p)];
|
|
8880
|
+
poly(stackPoints,2,8,6*p);
|
|
8881
|
+
rect(44,8,20,-7,0);
|
|
8882
|
+
|
|
8883
|
+
// engine
|
|
8884
|
+
for (let i=5;i--;) circle(59-i*6*p,30,10,0,2*PI,1,0);
|
|
8885
|
+
circle(59,30,4,0,7,2); // light
|
|
8886
|
+
|
|
8887
|
+
// engine outline
|
|
8888
|
+
rect(35,20,24,0); // top
|
|
8889
|
+
circle(59,30,10); // front
|
|
8890
|
+
circle(47,30,10,PI/2,PI*3/2); // middle
|
|
8891
|
+
circle(35,30,10,PI/2,PI*3/2); // back
|
|
8892
|
+
rect(7,40,13,7,-1); // bottom back
|
|
8893
|
+
rect(17,40,43,14,-1); // bottom center
|
|
8894
|
+
|
|
8895
|
+
// wheels
|
|
8896
|
+
for (let i=3;i--;) for (let j=2;j--;) circle(17+15*i,47,j?7:1,0,2*PI,2);
|
|
8897
|
+
|
|
8898
|
+
// cowcatcher
|
|
8899
|
+
for (let i=2;i--;)
|
|
8900
|
+
{
|
|
8901
|
+
let w=6, s=7, o=53+w*p*i
|
|
8902
|
+
const points = [vec2(o+s,54),vec2(o,40),vec2(o+w*p,40),vec2(o+s+w*p,54)];
|
|
8903
|
+
poly(points,0,40,14);
|
|
8904
|
+
}
|
|
8905
|
+
|
|
8906
|
+
x.restore();
|
|
8907
|
+
}
|
|
8908
|
+
|
|
8909
|
+
/**
|
|
8910
|
+
* LittleJS Medal System
|
|
8911
|
+
* - Achievement/trophy system for games
|
|
8912
|
+
* - Medal class with name, description, icon, and unlock tracking
|
|
8913
|
+
* - Automatic saving to local storage
|
|
8914
|
+
* - Visual display queue with slide-in notifications
|
|
8915
|
+
* - Newgrounds API integration for online achievements
|
|
8916
|
+
* - Debug mode to unlock/reset medals during development
|
|
8917
|
+
* @namespace Medals
|
|
8918
|
+
*/
|
|
8919
|
+
|
|
8920
|
+
let debugMedals = false;
|
|
8921
|
+
|
|
8922
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
8923
|
+
// Medals settings
|
|
8924
|
+
|
|
8925
|
+
/** How long to show medals for in seconds
|
|
8926
|
+
* @type {number}
|
|
8927
|
+
* @default
|
|
8928
|
+
* @memberof Settings */
|
|
8929
|
+
let medalDisplayTime = 5;
|
|
8930
|
+
|
|
8931
|
+
/** How quickly to slide on/off medals in seconds
|
|
8932
|
+
* @type {number}
|
|
8933
|
+
* @default
|
|
8934
|
+
* @memberof Settings */
|
|
8935
|
+
let medalDisplaySlideTime = .5;
|
|
8936
|
+
|
|
8937
|
+
/** Size of medal display
|
|
8938
|
+
* @type {Vector2}
|
|
8939
|
+
* @default Vector2(640,80)
|
|
8940
|
+
* @memberof Settings */
|
|
8941
|
+
let medalDisplaySize = vec2(640, 80);
|
|
8942
|
+
|
|
8943
|
+
/** Set to stop medals from being unlockable (like if cheats are enabled)
|
|
8944
|
+
* @type {boolean}
|
|
8945
|
+
* @default
|
|
8946
|
+
* @memberof Settings */
|
|
8947
|
+
let medalsPreventUnlock = false;
|
|
8948
|
+
|
|
8949
|
+
/** List of all medals
|
|
8950
|
+
* @type {Object}
|
|
8951
|
+
* @memberof Medals */
|
|
8952
|
+
const medals = {};
|
|
8953
|
+
|
|
8954
|
+
// Engine internal variables not exposed to documentation
|
|
8955
|
+
let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast;
|
|
8956
|
+
|
|
8957
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
8958
|
+
|
|
8959
|
+
/** Initialize medals with a save name used for storage
|
|
8960
|
+
* - Call this after creating all medals
|
|
8961
|
+
* - Checks if medals are unlocked
|
|
8962
|
+
* @param {string} saveName
|
|
8963
|
+
* @memberof Medals */
|
|
8964
|
+
function medalsInit(saveName)
|
|
8965
|
+
{
|
|
8966
|
+
// check if medals are unlocked
|
|
8967
|
+
medalsSaveName = saveName;
|
|
8968
|
+
if (!debugMedals)
|
|
8969
|
+
medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
|
|
8970
|
+
|
|
8971
|
+
// engine automatically renders medals
|
|
8972
|
+
engineAddPlugin(undefined, medalsRender);
|
|
8973
|
+
|
|
8974
|
+
// plugin functions
|
|
8975
|
+
function medalsRender()
|
|
8976
|
+
{
|
|
8977
|
+
if (!medalsDisplayQueue.length) return;
|
|
8978
|
+
|
|
8979
|
+
// update first medal in queue
|
|
8980
|
+
const medal = medalsDisplayQueue[0];
|
|
8981
|
+
const time = timeReal - medalsDisplayTimeLast;
|
|
8982
|
+
if (!medalsDisplayTimeLast)
|
|
8983
|
+
medalsDisplayTimeLast = timeReal;
|
|
8984
|
+
else if (time > medalDisplayTime)
|
|
8985
|
+
{
|
|
8986
|
+
medalsDisplayTimeLast = 0;
|
|
8987
|
+
medalsDisplayQueue.shift();
|
|
8988
|
+
}
|
|
8989
|
+
else
|
|
8990
|
+
{
|
|
8991
|
+
// slide on/off medals
|
|
8992
|
+
const slideOffTime = medalDisplayTime - medalDisplaySlideTime;
|
|
8993
|
+
const hidePercent =
|
|
8994
|
+
time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime :
|
|
8995
|
+
time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0;
|
|
8996
|
+
medal.render(hidePercent);
|
|
8997
|
+
}
|
|
8998
|
+
}
|
|
8999
|
+
}
|
|
9000
|
+
|
|
9001
|
+
/**
|
|
9002
|
+
* @callback MedalCallbackFunction - Function that processes a medal
|
|
9003
|
+
* @param {Medal} medal
|
|
9004
|
+
* @memberof Medals
|
|
9005
|
+
*/
|
|
9006
|
+
|
|
9007
|
+
/** Calls a function for each medal
|
|
9008
|
+
* @param {MedalCallbackFunction} callback
|
|
9009
|
+
* @memberof Medals */
|
|
9010
|
+
function medalsForEach(callback)
|
|
9011
|
+
{ Object.values(medals).forEach(medal=>callback(medal)); }
|
|
9012
|
+
|
|
9013
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
9014
|
+
|
|
9015
|
+
/**
|
|
9016
|
+
* Medal - Tracks an unlockable medal
|
|
9017
|
+
* @memberof Medals
|
|
9018
|
+
* @example
|
|
9019
|
+
* // create a medal
|
|
9020
|
+
* const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
|
|
9021
|
+
*
|
|
9022
|
+
* // initialize medals
|
|
9023
|
+
* medalsInit('Example Game');
|
|
9024
|
+
*
|
|
9025
|
+
* // unlock the medal
|
|
9026
|
+
* medal_example.unlock();
|
|
9027
|
+
*/
|
|
9028
|
+
class Medal
|
|
9029
|
+
{
|
|
9030
|
+
/** Create a medal object and adds it to the list of medals
|
|
9031
|
+
* @param {number} id - The unique identifier of the medal
|
|
9032
|
+
* @param {string} name - Name of the medal
|
|
9033
|
+
* @param {string} [description] - Description of the medal
|
|
9034
|
+
* @param {string} [icon] - Icon for the medal
|
|
9035
|
+
* @param {string} [src] - Image location for the medal
|
|
9036
|
+
*/
|
|
9037
|
+
constructor(id, name, description='', icon='🏆', src)
|
|
9038
|
+
{
|
|
9039
|
+
ASSERT(id >= 0 && !medals[id]);
|
|
9040
|
+
|
|
9041
|
+
/** @property {number} - The unique identifier of the medal */
|
|
9042
|
+
this.id = id;
|
|
9043
|
+
|
|
9044
|
+
/** @property {string} - Name of the medal */
|
|
9045
|
+
this.name = name;
|
|
9046
|
+
|
|
9047
|
+
/** @property {string} - Description of the medal */
|
|
9048
|
+
this.description = description;
|
|
9049
|
+
|
|
9050
|
+
/** @property {string} - Icon for the medal */
|
|
9051
|
+
this.icon = icon;
|
|
9052
|
+
|
|
9053
|
+
/** @property {boolean} - Is the medal unlocked? */
|
|
9054
|
+
this.unlocked = false;
|
|
9055
|
+
|
|
9056
|
+
// load the source image if provided
|
|
9057
|
+
if (src)
|
|
9058
|
+
(this.image = new Image).src = src;
|
|
9059
|
+
|
|
9060
|
+
// add this to list of medals
|
|
9061
|
+
medals[id] = this;
|
|
9062
|
+
}
|
|
9063
|
+
|
|
9064
|
+
/** Unlocks a medal if not already unlocked */
|
|
9065
|
+
unlock()
|
|
9066
|
+
{
|
|
9067
|
+
if (medalsPreventUnlock || this.unlocked) return;
|
|
9068
|
+
|
|
9069
|
+
// save the medal
|
|
9070
|
+
ASSERT(medalsSaveName, 'save name must be set');
|
|
9071
|
+
localStorage[this.storageKey()] = this.unlocked = true;
|
|
9072
|
+
medalsDisplayQueue.push(this);
|
|
9073
|
+
}
|
|
9074
|
+
|
|
9075
|
+
/** Render a medal
|
|
9076
|
+
* @param {number} [hidePercent] - How much to slide the medal off screen
|
|
9077
|
+
*/
|
|
9078
|
+
render(hidePercent=0)
|
|
9079
|
+
{
|
|
9080
|
+
const context = mainContext;
|
|
9081
|
+
const width = min(medalDisplaySize.x, mainCanvas.width);
|
|
9082
|
+
const height = medalDisplaySize.y;
|
|
9083
|
+
const x = mainCanvas.width - width;
|
|
9084
|
+
const y = -height*hidePercent;
|
|
9085
|
+
const backgroundColor = hsl(0,0,.9);
|
|
9086
|
+
|
|
9087
|
+
// draw containing rect and clip to that region
|
|
9088
|
+
context.save();
|
|
9089
|
+
context.beginPath();
|
|
9090
|
+
context.fillStyle = backgroundColor.toString();
|
|
9091
|
+
context.strokeStyle = BLACK.toString();
|
|
9092
|
+
context.lineWidth = 3;
|
|
9093
|
+
context.rect(x, y, width, height);
|
|
9094
|
+
context.fill();
|
|
9095
|
+
context.stroke();
|
|
9096
|
+
context.clip();
|
|
9097
|
+
|
|
9098
|
+
// draw the icon
|
|
9099
|
+
const gap = vec2(.1, .05).scale(height);
|
|
9100
|
+
const medalDisplayIconSize = height - 2*gap.x;
|
|
9101
|
+
this.renderIcon(vec2(x + gap.x + medalDisplayIconSize/2, y + height/2), medalDisplayIconSize);
|
|
9102
|
+
|
|
9103
|
+
// draw the name
|
|
9104
|
+
const nameSize = height*.5;
|
|
9105
|
+
const descriptionSize = height*.3;
|
|
9106
|
+
const pos = vec2(x + medalDisplayIconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
|
|
9107
|
+
const textWidth = width - medalDisplayIconSize - 3*gap.x;
|
|
9108
|
+
drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
|
|
9109
|
+
|
|
9110
|
+
// draw the description
|
|
9111
|
+
pos.y = y + height - gap.y*2 - descriptionSize/2;
|
|
9112
|
+
drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
|
|
9113
|
+
context.restore();
|
|
9114
|
+
}
|
|
9115
|
+
|
|
9116
|
+
/** Render the icon for a medal
|
|
9117
|
+
* @param {Vector2} pos - Screen space position
|
|
9118
|
+
* @param {number} size - Screen space size
|
|
9119
|
+
*/
|
|
9120
|
+
renderIcon(pos, size)
|
|
9121
|
+
{
|
|
9122
|
+
// draw the image or icon
|
|
9123
|
+
if (this.image)
|
|
9124
|
+
mainContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size);
|
|
9125
|
+
else
|
|
9126
|
+
drawTextScreen(this.icon, pos, size*.7, BLACK);
|
|
9127
|
+
}
|
|
9128
|
+
|
|
9129
|
+
// Get local storage key used by the medal
|
|
9130
|
+
storageKey() { return medalsSaveName + '_' + this.id; }
|
|
9131
|
+
}
|
|
9132
|
+
|
|
9133
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
9134
|
+
// Medals setting setters
|
|
9135
|
+
|
|
9136
|
+
/** Set how long to show medals for in seconds
|
|
9137
|
+
* @param {number} time
|
|
9138
|
+
* @memberof Settings */
|
|
9139
|
+
function setMedalDisplayTime(time) { medalDisplayTime = time; }
|
|
9140
|
+
|
|
9141
|
+
/** Set how quickly to slide on/off medals in seconds
|
|
9142
|
+
* @param {number} time
|
|
9143
|
+
* @memberof Settings */
|
|
9144
|
+
function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
|
|
9145
|
+
|
|
9146
|
+
/** Set size of medal display
|
|
9147
|
+
* @param {Vector2} size
|
|
9148
|
+
* @memberof Settings */
|
|
9149
|
+
function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
|
|
9150
|
+
|
|
9151
|
+
/** Set to stop medals from being unlockable
|
|
9152
|
+
* @param {boolean} preventUnlock
|
|
9153
|
+
* @memberof Settings */
|
|
9154
|
+
function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }
|
|
9155
|
+
|
|
9076
9156
|
/**
|
|
9077
9157
|
* LittleJS Newgrounds Plugin
|
|
9078
9158
|
* - NewgroundsMedal extends Medal with Newgrounds API functionality
|
|
@@ -9689,22 +9769,38 @@ class UISystemPlugin
|
|
|
9689
9769
|
this.lastHoverObject = undefined;
|
|
9690
9770
|
/** @property {UIObject} - Current confirm menu being shown */
|
|
9691
9771
|
this.confirmDialog = undefined;
|
|
9692
|
-
/** @
|
|
9693
|
-
this.
|
|
9772
|
+
/** @private */
|
|
9773
|
+
this._keyInputObject = undefined;
|
|
9774
|
+
/** @private */
|
|
9775
|
+
this._onKeyDown = (e) => this._keyInputObject?.onKeyDown(e);
|
|
9694
9776
|
|
|
9695
9777
|
engineAddPlugin(uiUpdate, uiRender);
|
|
9696
9778
|
|
|
9697
|
-
//
|
|
9698
|
-
|
|
9699
|
-
{ uiSystem.keyInputObject?.onKeyDown(e); }
|
|
9700
|
-
document.addEventListener('keydown', onKeyDown);
|
|
9701
|
-
|
|
9702
|
-
// set object position in parent space
|
|
9779
|
+
// set object position based on anchor target (parent box, or canvas for roots),
|
|
9780
|
+
// self-pivot, and localPos offset
|
|
9703
9781
|
function updateTransforms(o)
|
|
9704
9782
|
{
|
|
9705
|
-
|
|
9706
|
-
|
|
9707
|
-
|
|
9783
|
+
let targetPos, targetSize;
|
|
9784
|
+
if (o.parent)
|
|
9785
|
+
{
|
|
9786
|
+
targetPos = o.parent.pos;
|
|
9787
|
+
targetSize = o.parent.size;
|
|
9788
|
+
}
|
|
9789
|
+
else
|
|
9790
|
+
{
|
|
9791
|
+
// anchor to canvas in native coords (handles nativeHeight if set)
|
|
9792
|
+
targetPos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
|
|
9793
|
+
targetSize = uiSystem.nativeHeight
|
|
9794
|
+
? vec2(mainCanvasSize.x * uiSystem.nativeHeight / mainCanvasSize.y,
|
|
9795
|
+
uiSystem.nativeHeight)
|
|
9796
|
+
: mainCanvasSize;
|
|
9797
|
+
}
|
|
9798
|
+
|
|
9799
|
+
const a = o.anchor;
|
|
9800
|
+
o.pos = targetPos
|
|
9801
|
+
.add(targetSize.multiply(a).scale(.5)) // anchor point on target
|
|
9802
|
+
.subtract(o.size.multiply(a).scale(.5)) // pivot shift on self
|
|
9803
|
+
.add(o.localPos); // user offset
|
|
9708
9804
|
}
|
|
9709
9805
|
|
|
9710
9806
|
// setup recursive update and render
|
|
@@ -10040,8 +10136,23 @@ class UISystemPlugin
|
|
|
10040
10136
|
return p;
|
|
10041
10137
|
}
|
|
10042
10138
|
|
|
10139
|
+
/** Object to send keyboard input to (typically a UITextInput).
|
|
10140
|
+
* The document keydown listener is only attached while this is set,
|
|
10141
|
+
* so games that never use text input pay no event-handling cost.
|
|
10142
|
+
* @type {UIObject} */
|
|
10143
|
+
get keyInputObject() { return this._keyInputObject; }
|
|
10144
|
+
set keyInputObject(obj)
|
|
10145
|
+
{
|
|
10146
|
+
const had = !!this._keyInputObject;
|
|
10147
|
+
this._keyInputObject = obj;
|
|
10148
|
+
if (!had && obj)
|
|
10149
|
+
document.addEventListener('keydown', this._onKeyDown);
|
|
10150
|
+
else if (had && !obj)
|
|
10151
|
+
document.removeEventListener('keydown', this._onKeyDown);
|
|
10152
|
+
}
|
|
10153
|
+
|
|
10043
10154
|
/** Destroy and remove all objects
|
|
10044
|
-
* @memberof
|
|
10155
|
+
* @memberof UISystem */
|
|
10045
10156
|
destroyObjects()
|
|
10046
10157
|
{
|
|
10047
10158
|
for (const o of this.uiObjects)
|
|
@@ -10156,9 +10267,8 @@ class UISystemPlugin
|
|
|
10156
10267
|
// confirm menu
|
|
10157
10268
|
const confirmMenu = new UIObject(vec2(), size);
|
|
10158
10269
|
uiSystem.confirmDialog = confirmMenu;
|
|
10159
|
-
confirmMenu.onRender = ()=>
|
|
10270
|
+
confirmMenu.onRender = ()=>
|
|
10160
10271
|
{
|
|
10161
|
-
confirmMenu.pos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
|
|
10162
10272
|
const backgroundColor = hsl(0,0,0,.7);
|
|
10163
10273
|
uiSystem.drawRect(vec2(), vec2(1e9), backgroundColor);
|
|
10164
10274
|
}
|
|
@@ -10291,7 +10401,11 @@ class UIObject
|
|
|
10291
10401
|
this.navigationIndex = undefined;
|
|
10292
10402
|
/** @property {boolean} - Should this be auto selected by navigation? Must also have valid navigation index. */
|
|
10293
10403
|
this.navigationAutoSelect = false;
|
|
10294
|
-
|
|
10404
|
+
/** @property {Vector2} - Where on parent (or canvas if no parent) this object is anchored.
|
|
10405
|
+
* Components in [-1, 1]: (0,0)=center, (-1,-1)=top-left, (1,1)=bottom-right.
|
|
10406
|
+
* Also acts as self-pivot — e.g. (1,-1) puts your top-right corner at the anchor point. */
|
|
10407
|
+
this.anchor = vec2();
|
|
10408
|
+
|
|
10295
10409
|
uiSystem.uiObjects.push(this);
|
|
10296
10410
|
}
|
|
10297
10411
|
|
|
@@ -10464,15 +10578,13 @@ class UIObject
|
|
|
10464
10578
|
* @return {string} */
|
|
10465
10579
|
toString()
|
|
10466
10580
|
{
|
|
10467
|
-
if (!debug) return;
|
|
10468
|
-
|
|
10469
10581
|
let text = 'type = ' + this.constructor.name;
|
|
10470
10582
|
if (this.text)
|
|
10471
10583
|
text += '\ntext = ' + this.text;
|
|
10472
10584
|
if (this.pos.x || this.pos.y)
|
|
10473
10585
|
text += '\npos = ' + this.pos;
|
|
10474
10586
|
if (this.localPos.x || this.localPos.y)
|
|
10475
|
-
text += '\
|
|
10587
|
+
text += '\nlocalPos = ' + this.localPos;
|
|
10476
10588
|
if (this.size.x || this.size.y)
|
|
10477
10589
|
text += '\nsize = ' + this.size;
|
|
10478
10590
|
if (this.color)
|
|
@@ -10546,9 +10658,9 @@ class UIText extends UIObject
|
|
|
10546
10658
|
{
|
|
10547
10659
|
super(pos, size);
|
|
10548
10660
|
|
|
10549
|
-
ASSERT(
|
|
10661
|
+
ASSERT(isStringLike(text), 'ui text must be a string');
|
|
10550
10662
|
ASSERT(['left','center','right'].includes(align), 'ui text align must be left, center, or right');
|
|
10551
|
-
ASSERT(
|
|
10663
|
+
ASSERT(isStringLike(font), 'ui text font must be a string');
|
|
10552
10664
|
|
|
10553
10665
|
// set properties
|
|
10554
10666
|
this.text = text;
|
|
@@ -10596,7 +10708,7 @@ class UITextInput extends UIObject
|
|
|
10596
10708
|
{
|
|
10597
10709
|
super(pos, size);
|
|
10598
10710
|
|
|
10599
|
-
ASSERT(
|
|
10711
|
+
ASSERT(isStringLike(text), 'ui text must be a string');
|
|
10600
10712
|
|
|
10601
10713
|
/** @property {number} - Max length of input (0 = no limit) */
|
|
10602
10714
|
this.maxLength = 0;
|
|
@@ -10733,7 +10845,7 @@ class UIButton extends UIObject
|
|
|
10733
10845
|
{
|
|
10734
10846
|
super(pos, size);
|
|
10735
10847
|
|
|
10736
|
-
ASSERT(
|
|
10848
|
+
ASSERT(isStringLike(text), 'ui button must be a string');
|
|
10737
10849
|
ASSERT(isColor(color), 'ui button color must be a color');
|
|
10738
10850
|
|
|
10739
10851
|
/** @property {Vector2} - Text offset for the button */
|
|
@@ -10774,7 +10886,7 @@ class UICheckbox extends UIObject
|
|
|
10774
10886
|
{
|
|
10775
10887
|
super(pos, size);
|
|
10776
10888
|
|
|
10777
|
-
ASSERT(
|
|
10889
|
+
ASSERT(isStringLike(text), 'ui checkbox must be a string');
|
|
10778
10890
|
ASSERT(isColor(color), 'ui checkbox color must be a color');
|
|
10779
10891
|
|
|
10780
10892
|
/** @property {boolean} - Current percentage value of this slider 0-1 */
|
|
@@ -10831,7 +10943,7 @@ class UISlider extends UIObject
|
|
|
10831
10943
|
super(pos, size);
|
|
10832
10944
|
|
|
10833
10945
|
ASSERT(isNumber(value), 'ui slider value must be a number');
|
|
10834
|
-
ASSERT(
|
|
10946
|
+
ASSERT(isStringLike(text), 'ui slider must be a string');
|
|
10835
10947
|
ASSERT(isColor(color), 'ui slider color must be a color');
|
|
10836
10948
|
ASSERT(isColor(handleColor), 'ui slider handleColor must be a color');
|
|
10837
10949
|
|
|
@@ -10949,7 +11061,7 @@ class UIVideo extends UIObject
|
|
|
10949
11061
|
{
|
|
10950
11062
|
super(pos, size || vec2());
|
|
10951
11063
|
|
|
10952
|
-
ASSERT(
|
|
11064
|
+
ASSERT(isStringLike(src), 'video src must be a string');
|
|
10953
11065
|
ASSERT(isNumber(volume), 'video volume must be a number');
|
|
10954
11066
|
|
|
10955
11067
|
this.color = BLACK; // default to black background
|
|
@@ -11066,6 +11178,135 @@ class UIVideo extends UIObject
|
|
|
11066
11178
|
this.video.remove();
|
|
11067
11179
|
super.destroy();
|
|
11068
11180
|
}
|
|
11181
|
+
}
|
|
11182
|
+
|
|
11183
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
11184
|
+
/**
|
|
11185
|
+
* UILayout - A container that auto-arranges children in a vertical list, horizontal list, or grid
|
|
11186
|
+
* - Set columns to 1 for a vertical list (default)
|
|
11187
|
+
* - Set columns to children.length for a horizontal list
|
|
11188
|
+
* - Set columns to N (1 < N < children.length) for a grid with N columns
|
|
11189
|
+
* - 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
|
|
11190
|
+
* - Children are positioned centered in their cell
|
|
11191
|
+
* - Container auto-sizes to fit children plus padding
|
|
11192
|
+
* @extends UIObject
|
|
11193
|
+
* @memberof UISystem
|
|
11194
|
+
*/
|
|
11195
|
+
class UILayout extends UIObject
|
|
11196
|
+
{
|
|
11197
|
+
/** Create a UILayout container that auto-arranges children
|
|
11198
|
+
* @param {Vector2} [pos]
|
|
11199
|
+
* @param {number} [columns=1] - Number of columns (1 = vertical list)
|
|
11200
|
+
* @param {number} [gap=10] - Space between children
|
|
11201
|
+
* @param {number} [padding=10] - Space between container border and children
|
|
11202
|
+
* @param {boolean} [transparent=false] - If true, draws no background, outline, or shadow
|
|
11203
|
+
*/
|
|
11204
|
+
constructor(pos, columns=1, gap=10, padding=10, transparent=false)
|
|
11205
|
+
{
|
|
11206
|
+
super(pos);
|
|
11207
|
+
|
|
11208
|
+
ASSERT(isNumber(columns) && columns >= 1, 'ui layout columns must be a number >= 1');
|
|
11209
|
+
ASSERT(isNumber(gap), 'ui layout gap must be a number');
|
|
11210
|
+
ASSERT(isNumber(padding), 'ui layout padding must be a number');
|
|
11211
|
+
|
|
11212
|
+
/** @property {number} - Number of columns in the layout */
|
|
11213
|
+
this.columns = columns;
|
|
11214
|
+
/** @property {number} - Space between children */
|
|
11215
|
+
this.gap = gap;
|
|
11216
|
+
/** @property {number} - Space between container border and children */
|
|
11217
|
+
this.padding = padding;
|
|
11218
|
+
|
|
11219
|
+
if (transparent)
|
|
11220
|
+
{
|
|
11221
|
+
// pure positioning helper - skip background, outline, and shadow
|
|
11222
|
+
this.color = CLEAR_BLACK;
|
|
11223
|
+
this.gradientColor = undefined;
|
|
11224
|
+
this.lineWidth = 0;
|
|
11225
|
+
this.shadowColor = CLEAR_BLACK;
|
|
11226
|
+
}
|
|
11227
|
+
this.relayout();
|
|
11228
|
+
}
|
|
11229
|
+
|
|
11230
|
+
/** Add a child UIObject and re-layout
|
|
11231
|
+
* @param {UIObject} child
|
|
11232
|
+
* @return {UIObject} The child object added */
|
|
11233
|
+
addChild(child)
|
|
11234
|
+
{
|
|
11235
|
+
super.addChild(child);
|
|
11236
|
+
this.relayout();
|
|
11237
|
+
return child;
|
|
11238
|
+
}
|
|
11239
|
+
|
|
11240
|
+
/** Remove a child UIObject and re-layout
|
|
11241
|
+
* @param {UIObject} child */
|
|
11242
|
+
removeChild(child)
|
|
11243
|
+
{
|
|
11244
|
+
super.removeChild(child);
|
|
11245
|
+
this.relayout();
|
|
11246
|
+
}
|
|
11247
|
+
|
|
11248
|
+
/** Recompute child positions and container size based on per-child sizes.
|
|
11249
|
+
* Called automatically by addChild and removeChild. Call manually if you
|
|
11250
|
+
* mutate a child's size or change columns, gap, or padding. */
|
|
11251
|
+
relayout()
|
|
11252
|
+
{
|
|
11253
|
+
const n = this.children.length;
|
|
11254
|
+
if (!n)
|
|
11255
|
+
{
|
|
11256
|
+
this.size = vec2(this.padding * 2);
|
|
11257
|
+
return;
|
|
11258
|
+
}
|
|
11259
|
+
|
|
11260
|
+
const cols = this.columns;
|
|
11261
|
+
const rows = ceil(n / cols);
|
|
11262
|
+
const colWidths = new Array(cols).fill(0);
|
|
11263
|
+
const rowHeights = new Array(rows).fill(0);
|
|
11264
|
+
|
|
11265
|
+
// first pass: compute column widths and row heights from child sizes
|
|
11266
|
+
for (let i = 0; i < n; ++i)
|
|
11267
|
+
{
|
|
11268
|
+
const col = i % cols;
|
|
11269
|
+
const row = floor(i / cols);
|
|
11270
|
+
const child = this.children[i];
|
|
11271
|
+
colWidths[col] = max(colWidths[col], child.size.x);
|
|
11272
|
+
rowHeights[row] = max(rowHeights[row], child.size.y);
|
|
11273
|
+
}
|
|
11274
|
+
|
|
11275
|
+
// total content size (sum of column widths/row heights plus gaps between them)
|
|
11276
|
+
let contentWidth = this.gap * (cols - 1);
|
|
11277
|
+
for (const w of colWidths) contentWidth += w;
|
|
11278
|
+
let contentHeight = this.gap * (rows - 1);
|
|
11279
|
+
for (const h of rowHeights) contentHeight += h;
|
|
11280
|
+
|
|
11281
|
+
// cumulative column/row offsets so positioning is O(n) not O(n^2)
|
|
11282
|
+
const colOffsets = new Array(cols);
|
|
11283
|
+
let xAcc = 0;
|
|
11284
|
+
for (let c = 0; c < cols; ++c)
|
|
11285
|
+
{
|
|
11286
|
+
colOffsets[c] = xAcc;
|
|
11287
|
+
xAcc += colWidths[c];
|
|
11288
|
+
}
|
|
11289
|
+
const rowOffsets = new Array(rows);
|
|
11290
|
+
let yAcc = 0;
|
|
11291
|
+
for (let r = 0; r < rows; ++r)
|
|
11292
|
+
{
|
|
11293
|
+
rowOffsets[r] = yAcc;
|
|
11294
|
+
yAcc += rowHeights[r];
|
|
11295
|
+
}
|
|
11296
|
+
|
|
11297
|
+
// second pass: position each child centered in its cell
|
|
11298
|
+
for (let i = 0; i < n; ++i)
|
|
11299
|
+
{
|
|
11300
|
+
const col = i % cols;
|
|
11301
|
+
const row = floor(i / cols);
|
|
11302
|
+
const x = -contentWidth/2 + colOffsets[col] + this.gap * col + colWidths[col] / 2;
|
|
11303
|
+
const y = -contentHeight/2 + rowOffsets[row] + this.gap * row + rowHeights[row] / 2;
|
|
11304
|
+
this.children[i].localPos = vec2(x, y);
|
|
11305
|
+
}
|
|
11306
|
+
|
|
11307
|
+
// container size = content + padding on all sides
|
|
11308
|
+
this.size = vec2(contentWidth + this.padding * 2, contentHeight + this.padding * 2);
|
|
11309
|
+
}
|
|
11069
11310
|
}
|
|
11070
11311
|
/**
|
|
11071
11312
|
* LittleJS Box2D Physics Plugin
|
|
@@ -11592,14 +11833,29 @@ class Box2dObject extends EngineObject
|
|
|
11592
11833
|
this.body.ApplyForce(box2d.vec2dTo(force), box2d.vec2dTo(pos));
|
|
11593
11834
|
}
|
|
11594
11835
|
|
|
11595
|
-
/** Apply acceleration to this object
|
|
11836
|
+
/** Apply acceleration to this object (changes velocity by acceleration,
|
|
11837
|
+
* mass-independent — matches EngineObject.applyAcceleration semantics).
|
|
11838
|
+
* Use applyImpulse if you want the mass-dependent velocity change
|
|
11839
|
+
* Δv = impulse / mass, or applyForce for a Newton-style sustained force.
|
|
11596
11840
|
* @param {Vector2} acceleration
|
|
11597
11841
|
* @param {Vector2} [pos] */
|
|
11598
11842
|
applyAcceleration(acceleration, pos)
|
|
11599
|
-
{
|
|
11843
|
+
{
|
|
11600
11844
|
pos ||= this.getCenterOfMass();
|
|
11601
11845
|
this.setAwake();
|
|
11602
|
-
|
|
11846
|
+
const impulse = acceleration.scale(this.getMass());
|
|
11847
|
+
this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
|
|
11848
|
+
}
|
|
11849
|
+
|
|
11850
|
+
/** Apply an instantaneous linear impulse. Changes velocity immediately by
|
|
11851
|
+
* impulse / mass (so heavier bodies move less for the same impulse).
|
|
11852
|
+
* @param {Vector2} impulse
|
|
11853
|
+
* @param {Vector2} [pos] */
|
|
11854
|
+
applyImpulse(impulse, pos)
|
|
11855
|
+
{
|
|
11856
|
+
pos ||= this.getCenterOfMass();
|
|
11857
|
+
this.setAwake();
|
|
11858
|
+
this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
|
|
11603
11859
|
}
|
|
11604
11860
|
|
|
11605
11861
|
/** Apply torque to this object
|
|
@@ -11609,13 +11865,23 @@ class Box2dObject extends EngineObject
|
|
|
11609
11865
|
this.setAwake();
|
|
11610
11866
|
this.body.ApplyTorque(torque);
|
|
11611
11867
|
}
|
|
11612
|
-
|
|
11613
|
-
/** Apply angular acceleration to this object
|
|
11868
|
+
|
|
11869
|
+
/** Apply angular acceleration to this object (changes angular velocity by
|
|
11870
|
+
* acceleration, mass-independent — matches EngineObject semantics).
|
|
11614
11871
|
* @param {number} acceleration */
|
|
11615
11872
|
applyAngularAcceleration(acceleration)
|
|
11616
11873
|
{
|
|
11617
11874
|
this.setAwake();
|
|
11618
|
-
this.body.ApplyAngularImpulse(acceleration);
|
|
11875
|
+
this.body.ApplyAngularImpulse(acceleration * this.getInertia());
|
|
11876
|
+
}
|
|
11877
|
+
|
|
11878
|
+
/** Apply an instantaneous angular impulse. Changes angular velocity by
|
|
11879
|
+
* impulse / inertia immediately.
|
|
11880
|
+
* @param {number} impulse */
|
|
11881
|
+
applyAngularImpulse(impulse)
|
|
11882
|
+
{
|
|
11883
|
+
this.setAwake();
|
|
11884
|
+
this.body.ApplyAngularImpulse(impulse);
|
|
11619
11885
|
}
|
|
11620
11886
|
|
|
11621
11887
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -11861,7 +12127,7 @@ class Box2dJoint
|
|
|
11861
12127
|
|
|
11862
12128
|
/** Check if the connected bodies should collide
|
|
11863
12129
|
* @return {boolean} */
|
|
11864
|
-
getCollideConnected() { return this.box2dJoint.
|
|
12130
|
+
getCollideConnected() { return this.box2dJoint.GetCollideConnected();}
|
|
11865
12131
|
|
|
11866
12132
|
/** Check if either connected body is active
|
|
11867
12133
|
* @return {boolean} */
|
|
@@ -13474,13 +13740,13 @@ const Ease =
|
|
|
13474
13740
|
* @param {number} x
|
|
13475
13741
|
* @returns {number}
|
|
13476
13742
|
* @memberof TweenSystem */
|
|
13477
|
-
SINE: (x) => 1 -
|
|
13743
|
+
SINE: (x) => 1 - cos(x * (PI / 2)),
|
|
13478
13744
|
|
|
13479
13745
|
/** Circular ease-in curve.
|
|
13480
13746
|
* @param {number} x
|
|
13481
13747
|
* @returns {number}
|
|
13482
13748
|
* @memberof TweenSystem */
|
|
13483
|
-
CIRC: (x) => 1 -
|
|
13749
|
+
CIRC: (x) => 1 - (1 - x * x)**.5,
|
|
13484
13750
|
|
|
13485
13751
|
/** Exponential ease-in curve (`2^(10x-10)`).
|
|
13486
13752
|
* @param {number} x
|
|
@@ -13499,7 +13765,7 @@ const Ease =
|
|
|
13499
13765
|
* @returns {number}
|
|
13500
13766
|
* @memberof TweenSystem */
|
|
13501
13767
|
ELASTIC: (x) =>
|
|
13502
|
-
-(2 ** (10 * x - 10)) *
|
|
13768
|
+
-(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
|
|
13503
13769
|
|
|
13504
13770
|
/** Spring-like ease-out: oscillates outward after passing the target.
|
|
13505
13771
|
* @param {number} x
|
|
@@ -13507,8 +13773,8 @@ const Ease =
|
|
|
13507
13773
|
* @memberof TweenSystem */
|
|
13508
13774
|
SPRING: (x) =>
|
|
13509
13775
|
1 -
|
|
13510
|
-
(
|
|
13511
|
-
|
|
13776
|
+
(sin(PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
|
|
13777
|
+
x ** 2.2 +
|
|
13512
13778
|
(1 - x)) *
|
|
13513
13779
|
(1.0 + 1.2 * x),
|
|
13514
13780
|
|
|
@@ -13612,7 +13878,7 @@ const Ease =
|
|
|
13612
13878
|
{
|
|
13613
13879
|
const tMid = (t0 + t1) / 2;
|
|
13614
13880
|
const [bx, by] = curve(tMid);
|
|
13615
|
-
if (
|
|
13881
|
+
if (abs(bx - x) < 1e-5) return by;
|
|
13616
13882
|
if (bx < x) t0 = tMid; else t1 = tMid;
|
|
13617
13883
|
}
|
|
13618
13884
|
return curve((t0 + t1) / 2)[1];
|
|
@@ -13645,7 +13911,7 @@ const Ease =
|
|
|
13645
13911
|
function tweenProperty(target, propertyPath, start, end, duration = 1, options = {})
|
|
13646
13912
|
{
|
|
13647
13913
|
ASSERT(target != null && typeof target === 'object', 'tweenProperty target must be an object');
|
|
13648
|
-
ASSERT(
|
|
13914
|
+
ASSERT(isStringLike(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string');
|
|
13649
13915
|
|
|
13650
13916
|
const parts = propertyPath.split('.');
|
|
13651
13917
|
const lastKey = parts.pop();
|
|
@@ -13744,3 +14010,760 @@ function tweenStopAll()
|
|
|
13744
14010
|
// Register with the engine so tweens auto-advance.
|
|
13745
14011
|
engineAddPlugin(tweenUpdate);
|
|
13746
14012
|
|
|
14013
|
+
/**
|
|
14014
|
+
* LittleJS PathFinder Plugin
|
|
14015
|
+
* - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths
|
|
14016
|
+
* - Works directly on a TileCollisionLayer, or override isWalkable/getCost for any grid
|
|
14017
|
+
* - Debug visualization via engine debug primitives (stripped in release builds)
|
|
14018
|
+
* - Port of frankforce.com pathFindingBase.cpp (2018)
|
|
14019
|
+
* @namespace PathFinding
|
|
14020
|
+
*/
|
|
14021
|
+
|
|
14022
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
14023
|
+
|
|
14024
|
+
// Diagonal step cost — pre-computed for the A* expansion inner loop.
|
|
14025
|
+
const PATHFINDER_DIAGONAL_COST = Math.SQRT2;
|
|
14026
|
+
|
|
14027
|
+
// Shared 1x1 size vector for per-tile debugRect calls. debugRect copies the
|
|
14028
|
+
// argument internally, so reusing one instance is safe.
|
|
14029
|
+
const PATHFINDER_TILE_VEC = vec2(1);
|
|
14030
|
+
|
|
14031
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
14032
|
+
|
|
14033
|
+
/** A single grid cell tracked by the pathfinder. Allocated once per cell at
|
|
14034
|
+
* PathFinder construction; reset (not reallocated) at the start of every
|
|
14035
|
+
* findPath call.
|
|
14036
|
+
* @memberof PathFinding */
|
|
14037
|
+
class PathFinderNode
|
|
14038
|
+
{
|
|
14039
|
+
/** @param {number} x - Tile x
|
|
14040
|
+
* @param {number} y - Tile y */
|
|
14041
|
+
constructor(x, y)
|
|
14042
|
+
{
|
|
14043
|
+
/** @property {Vector2} - Tile coords (integer) */
|
|
14044
|
+
this.pos = vec2(x, y);
|
|
14045
|
+
/** @property {Vector2} - World-space center of this tile (set by buildNodeData) */
|
|
14046
|
+
this.posWorld = vec2();
|
|
14047
|
+
/** @property {boolean} - True if this cell is passable (cleared each findPath call) */
|
|
14048
|
+
this.walkable = false;
|
|
14049
|
+
/** @property {number} - Extra cost added to A* G-score for stepping on this cell */
|
|
14050
|
+
this.cost = 0;
|
|
14051
|
+
/** @property {number} - A* G-score: actual cost from start to this node */
|
|
14052
|
+
this.g = 0;
|
|
14053
|
+
/** @property {number} - A* F-score: G + heuristic */
|
|
14054
|
+
this.f = 0;
|
|
14055
|
+
/** @property {PathFinderNode|null} - Parent for path reconstruction */
|
|
14056
|
+
this.parent = null;
|
|
14057
|
+
/** @property {boolean} - In the A* open list */
|
|
14058
|
+
this.isOpen = false;
|
|
14059
|
+
/** @property {boolean} - In the A* closed list */
|
|
14060
|
+
this.isClosed = false;
|
|
14061
|
+
}
|
|
14062
|
+
|
|
14063
|
+
/** Reset per-search state (called at the start of buildNodeData). */
|
|
14064
|
+
reset()
|
|
14065
|
+
{
|
|
14066
|
+
this.walkable = false;
|
|
14067
|
+
this.cost = 0;
|
|
14068
|
+
this.g = 0;
|
|
14069
|
+
this.f = 0;
|
|
14070
|
+
this.parent = null;
|
|
14071
|
+
this.isOpen = false;
|
|
14072
|
+
this.isClosed = false;
|
|
14073
|
+
}
|
|
14074
|
+
|
|
14075
|
+
/** True if walkable and not blocked by cost. */
|
|
14076
|
+
isClear()
|
|
14077
|
+
{
|
|
14078
|
+
return this.walkable && this.cost === 0;
|
|
14079
|
+
}
|
|
14080
|
+
}
|
|
14081
|
+
|
|
14082
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
14083
|
+
|
|
14084
|
+
/** Grid pathfinder using A* with two optional smoothing passes.
|
|
14085
|
+
* @memberof PathFinding
|
|
14086
|
+
* @example
|
|
14087
|
+
* // Tile-layer driven (most common):
|
|
14088
|
+
* const pf = new PathFinder(myTileCollisionLayer);
|
|
14089
|
+
* const path = pf.findPath(player.pos, mousePos);
|
|
14090
|
+
*
|
|
14091
|
+
* // Bare grid with custom walkability:
|
|
14092
|
+
* const pf = new PathFinder(vec2(50, 50));
|
|
14093
|
+
* pf.isWalkable = (x, y) => myGrid[y*50 + x] === 0;
|
|
14094
|
+
*/
|
|
14095
|
+
class PathFinder
|
|
14096
|
+
{
|
|
14097
|
+
/** @param {TileCollisionLayer|Vector2} source - Either a TileCollisionLayer
|
|
14098
|
+
* (size and walkability auto-derived) or a Vector2 grid size (user
|
|
14099
|
+
* overrides isWalkable). */
|
|
14100
|
+
constructor(source)
|
|
14101
|
+
{
|
|
14102
|
+
// Accept either a Vector2 size or a TileCollisionLayer (which has a .size).
|
|
14103
|
+
// We don't import TileCollisionLayer to avoid coupling; we duck-type on
|
|
14104
|
+
// .size + .getCollisionData.
|
|
14105
|
+
if (isVector2(source))
|
|
14106
|
+
{
|
|
14107
|
+
this.size = source.floor();
|
|
14108
|
+
this.tileLayer = undefined;
|
|
14109
|
+
}
|
|
14110
|
+
else
|
|
14111
|
+
{
|
|
14112
|
+
ASSERT(source && isVector2(source.size) && typeof source.getCollisionData === 'function',
|
|
14113
|
+
'PathFinder requires a Vector2 size or a TileCollisionLayer');
|
|
14114
|
+
this.size = source.size;
|
|
14115
|
+
this.tileLayer = source;
|
|
14116
|
+
}
|
|
14117
|
+
|
|
14118
|
+
// Tunables (public, freely re-assignable).
|
|
14119
|
+
this.heuristicWeight = 1;
|
|
14120
|
+
this.maxLoop = 500;
|
|
14121
|
+
this.smoothPath = true;
|
|
14122
|
+
this.debug = false;
|
|
14123
|
+
this.debugTime = 2;
|
|
14124
|
+
|
|
14125
|
+
// Pre-allocate the node array — one node per tile, reused across calls.
|
|
14126
|
+
this.nodes = new Array(this.size.x * this.size.y);
|
|
14127
|
+
for (let y = 0; y < this.size.y; ++y)
|
|
14128
|
+
for (let x = 0; x < this.size.x; ++x)
|
|
14129
|
+
this.nodes[x + y * this.size.x] = new PathFinderNode(x, y);
|
|
14130
|
+
|
|
14131
|
+
// Scratch Vector2 reused to avoid allocations in the isWalkable hot path.
|
|
14132
|
+
this.collisionScratch = vec2();
|
|
14133
|
+
}
|
|
14134
|
+
|
|
14135
|
+
/** Default walkability: if a tile layer was provided, returns true when the
|
|
14136
|
+
* cell has no solid collision data; otherwise returns true. Override on
|
|
14137
|
+
* the instance or via a subclass.
|
|
14138
|
+
* @param {number} x - Tile x
|
|
14139
|
+
* @param {number} y - Tile y
|
|
14140
|
+
* @returns {boolean} */
|
|
14141
|
+
isWalkable(x, y)
|
|
14142
|
+
{
|
|
14143
|
+
if (!this.tileLayer) return true;
|
|
14144
|
+
return !this.tileLayer.getCollisionData(this.collisionScratch.set(x, y));
|
|
14145
|
+
}
|
|
14146
|
+
|
|
14147
|
+
/** Default extra cost for stepping on a cell. Returns 0 (free) by default.
|
|
14148
|
+
* Override to add cost-weighted terrain (mud, swamp, etc).
|
|
14149
|
+
* @param {number} x - Tile x
|
|
14150
|
+
* @param {number} y - Tile y
|
|
14151
|
+
* @returns {number} */
|
|
14152
|
+
getCost(x, y)
|
|
14153
|
+
{
|
|
14154
|
+
return 0;
|
|
14155
|
+
}
|
|
14156
|
+
|
|
14157
|
+
/** Get the node at tile coords, or null if out of bounds.
|
|
14158
|
+
* @param {number} x
|
|
14159
|
+
* @param {number} y
|
|
14160
|
+
* @returns {PathFinderNode|null} */
|
|
14161
|
+
getNode(x, y)
|
|
14162
|
+
{
|
|
14163
|
+
if (x < 0 || y < 0 || x >= this.size.x || y >= this.size.y) return null;
|
|
14164
|
+
return this.nodes[x + y * this.size.x];
|
|
14165
|
+
}
|
|
14166
|
+
|
|
14167
|
+
/** Convert a world-space position to integer tile coords (no clamping).
|
|
14168
|
+
* @param {Vector2} worldPos
|
|
14169
|
+
* @returns {Vector2}
|
|
14170
|
+
* @memberof PathFinding */
|
|
14171
|
+
worldToTile(worldPos)
|
|
14172
|
+
{
|
|
14173
|
+
const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
|
|
14174
|
+
const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
|
|
14175
|
+
return vec2(floor(worldPos.x - ox), floor(worldPos.y - oy));
|
|
14176
|
+
}
|
|
14177
|
+
|
|
14178
|
+
/** Convert integer tile coords to the world-space center of that tile.
|
|
14179
|
+
* @param {number} x
|
|
14180
|
+
* @param {number} y
|
|
14181
|
+
* @returns {Vector2}
|
|
14182
|
+
* @memberof PathFinding */
|
|
14183
|
+
tileToWorld(x, y)
|
|
14184
|
+
{
|
|
14185
|
+
const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
|
|
14186
|
+
const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
|
|
14187
|
+
return vec2(x + 0.5 + ox, y + 0.5 + oy);
|
|
14188
|
+
}
|
|
14189
|
+
|
|
14190
|
+
/** Reset all nodes and re-populate walkable / cost / posWorld from the
|
|
14191
|
+
* current isWalkable / getCost overrides. Called at the start of
|
|
14192
|
+
* findPath; exposed so tests and tooling can drive it directly.
|
|
14193
|
+
* @private */
|
|
14194
|
+
buildNodeData()
|
|
14195
|
+
{
|
|
14196
|
+
const w = this.size.x;
|
|
14197
|
+
const h = this.size.y;
|
|
14198
|
+
const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
|
|
14199
|
+
const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
|
|
14200
|
+
for (let y = 0; y < h; ++y)
|
|
14201
|
+
for (let x = 0; x < w; ++x)
|
|
14202
|
+
{
|
|
14203
|
+
const node = this.nodes[x + y * w];
|
|
14204
|
+
node.reset();
|
|
14205
|
+
const walkable = !!this.isWalkable(x, y);
|
|
14206
|
+
const cost = walkable ? max(0, this.getCost(x, y)) : 0;
|
|
14207
|
+
node.walkable = walkable;
|
|
14208
|
+
node.cost = cost;
|
|
14209
|
+
node.posWorld.set(x + 0.5 + ox, y + 0.5 + oy);
|
|
14210
|
+
|
|
14211
|
+
if (this.debug && this.debugTime > 0)
|
|
14212
|
+
{
|
|
14213
|
+
if (!walkable)
|
|
14214
|
+
debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, 0.25), this.debugTime);
|
|
14215
|
+
else if (cost > 0)
|
|
14216
|
+
debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, min(0.2, cost * 0.05)), this.debugTime);
|
|
14217
|
+
}
|
|
14218
|
+
}
|
|
14219
|
+
}
|
|
14220
|
+
|
|
14221
|
+
/** Core A* search loop. Expects buildNodeData() to have been called first.
|
|
14222
|
+
* Marks node.parent for path reconstruction. Returns true if endNode was
|
|
14223
|
+
* reached; false on disconnected goal or maxLoop exhaustion.
|
|
14224
|
+
* @param {PathFinderNode} startNode
|
|
14225
|
+
* @param {PathFinderNode} endNode
|
|
14226
|
+
* @returns {boolean}
|
|
14227
|
+
* @private */
|
|
14228
|
+
aStarSearch(startNode, endNode)
|
|
14229
|
+
{
|
|
14230
|
+
ASSERT(startNode && endNode, 'aStarSearch needs both endpoints');
|
|
14231
|
+
ASSERT(startNode !== endNode, 'aStarSearch: start and end must differ — caller should handle trivial case');
|
|
14232
|
+
ASSERT(startNode.walkable && endNode.walkable, 'aStarSearch: endpoints must be walkable');
|
|
14233
|
+
|
|
14234
|
+
const openList = [startNode];
|
|
14235
|
+
startNode.isOpen = true;
|
|
14236
|
+
let loopCount = 0;
|
|
14237
|
+
|
|
14238
|
+
while (openList.length > 0)
|
|
14239
|
+
{
|
|
14240
|
+
// Find the open node with the smallest f score (linear scan).
|
|
14241
|
+
// Same as the C++ — fine up to a few thousand nodes.
|
|
14242
|
+
let bestIndex = 0;
|
|
14243
|
+
let bestF = openList[0].f;
|
|
14244
|
+
for (let i = 1; i < openList.length; ++i)
|
|
14245
|
+
{
|
|
14246
|
+
if (openList[i].f < bestF)
|
|
14247
|
+
{
|
|
14248
|
+
bestF = openList[i].f;
|
|
14249
|
+
bestIndex = i;
|
|
14250
|
+
}
|
|
14251
|
+
}
|
|
14252
|
+
const current = openList[bestIndex];
|
|
14253
|
+
|
|
14254
|
+
if (current === endNode) break;
|
|
14255
|
+
if (++loopCount > this.maxLoop) break;
|
|
14256
|
+
|
|
14257
|
+
// Move current from open to closed.
|
|
14258
|
+
current.isOpen = false;
|
|
14259
|
+
openList.splice(bestIndex, 1);
|
|
14260
|
+
current.isClosed = true;
|
|
14261
|
+
|
|
14262
|
+
if (this.debug && this.debugTime > 0)
|
|
14263
|
+
debugRect(current.posWorld, PATHFINDER_TILE_VEC, rgb(1, 1, 1, 0.05), this.debugTime);
|
|
14264
|
+
|
|
14265
|
+
// Expand all 8 neighbors.
|
|
14266
|
+
for (let dy = -1; dy <= 1; ++dy)
|
|
14267
|
+
for (let dx = -1; dx <= 1; ++dx)
|
|
14268
|
+
{
|
|
14269
|
+
if (dx === 0 && dy === 0) continue;
|
|
14270
|
+
const neighbor = this.getNode(current.pos.x + dx, current.pos.y + dy);
|
|
14271
|
+
if (!neighbor || !neighbor.walkable || neighbor.isClosed) continue;
|
|
14272
|
+
|
|
14273
|
+
let stepCost = 1;
|
|
14274
|
+
if (dx !== 0 && dy !== 0)
|
|
14275
|
+
{
|
|
14276
|
+
// Diagonal step: refuse if either cardinal neighbor is
|
|
14277
|
+
// blocked or has cost. Prevents cutting through corners.
|
|
14278
|
+
const card1 = this.getNode(current.pos.x + dx, current.pos.y);
|
|
14279
|
+
if (!card1 || card1.cost > 0 || !card1.walkable) continue;
|
|
14280
|
+
const card2 = this.getNode(current.pos.x, current.pos.y + dy);
|
|
14281
|
+
if (!card2 || card2.cost > 0 || !card2.walkable) continue;
|
|
14282
|
+
stepCost = PATHFINDER_DIAGONAL_COST;
|
|
14283
|
+
}
|
|
14284
|
+
|
|
14285
|
+
const tentativeG = current.g + stepCost + neighbor.cost;
|
|
14286
|
+
if (!neighbor.isOpen)
|
|
14287
|
+
{
|
|
14288
|
+
neighbor.isOpen = true;
|
|
14289
|
+
openList.push(neighbor);
|
|
14290
|
+
}
|
|
14291
|
+
else if (tentativeG >= neighbor.g)
|
|
14292
|
+
{
|
|
14293
|
+
continue;
|
|
14294
|
+
}
|
|
14295
|
+
|
|
14296
|
+
// Best path so far through neighbor — record it.
|
|
14297
|
+
neighbor.parent = current;
|
|
14298
|
+
neighbor.g = tentativeG;
|
|
14299
|
+
const gdx = endNode.pos.x - neighbor.pos.x;
|
|
14300
|
+
const gdy = endNode.pos.y - neighbor.pos.y;
|
|
14301
|
+
neighbor.f = neighbor.g + (gdx * gdx + gdy * gdy) * this.heuristicWeight;
|
|
14302
|
+
}
|
|
14303
|
+
}
|
|
14304
|
+
|
|
14305
|
+
return endNode.parent !== null;
|
|
14306
|
+
}
|
|
14307
|
+
|
|
14308
|
+
/** Find the clear (walkable, zero-cost) node closest to the given world
|
|
14309
|
+
* position. Spirals outward in expanding boxes until a clear node is
|
|
14310
|
+
* found or the search range is exhausted. Useful for snapping a click
|
|
14311
|
+
* or NPC spawn position to the nearest open tile.
|
|
14312
|
+
*
|
|
14313
|
+
* By default, calls `buildNodeData()` first so it works correctly on a
|
|
14314
|
+
* fresh PathFinder. If you're calling it many times in a row with
|
|
14315
|
+
* unchanged walkability, pass `rebuild=false` and call `buildNodeData()`
|
|
14316
|
+
* once externally to avoid redundant work.
|
|
14317
|
+
* @param {Vector2} worldPos
|
|
14318
|
+
* @param {number} [searchRange=10] - Max box-radius in tiles
|
|
14319
|
+
* @param {boolean} [rebuild=true] - Whether to call buildNodeData first
|
|
14320
|
+
* @returns {PathFinderNode|null}
|
|
14321
|
+
* @memberof PathFinding */
|
|
14322
|
+
getNearestClearNode(worldPos, searchRange = 10, rebuild = true)
|
|
14323
|
+
{
|
|
14324
|
+
ASSERT(isVector2(worldPos), 'worldPos must be a Vector2');
|
|
14325
|
+
if (rebuild) this.buildNodeData();
|
|
14326
|
+
|
|
14327
|
+
// Inline worldToTile to avoid a Vector2 allocation per call.
|
|
14328
|
+
const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
|
|
14329
|
+
const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
|
|
14330
|
+
const centerX = floor(worldPos.x - ox);
|
|
14331
|
+
const centerY = floor(worldPos.y - oy);
|
|
14332
|
+
|
|
14333
|
+
for (let offset = 0; offset <= searchRange; ++offset)
|
|
14334
|
+
{
|
|
14335
|
+
let nearest = null;
|
|
14336
|
+
let nearestDistSq = 0;
|
|
14337
|
+
|
|
14338
|
+
for (let dy = -offset; dy <= offset; ++dy)
|
|
14339
|
+
for (let dx = -offset; dx <= offset; ++dx)
|
|
14340
|
+
{
|
|
14341
|
+
// Only scan the perimeter of the current ring (skip the
|
|
14342
|
+
// interior we've already searched in earlier iterations).
|
|
14343
|
+
if (offset > 0 && abs(dx) !== offset && abs(dy) !== offset)
|
|
14344
|
+
continue;
|
|
14345
|
+
|
|
14346
|
+
const node = this.getNode(centerX + dx, centerY + dy);
|
|
14347
|
+
if (!node || !node.isClear()) continue;
|
|
14348
|
+
|
|
14349
|
+
const ddx = node.posWorld.x - worldPos.x;
|
|
14350
|
+
const ddy = node.posWorld.y - worldPos.y;
|
|
14351
|
+
const distSq = ddx * ddx + ddy * ddy;
|
|
14352
|
+
if (!nearest || distSq < nearestDistSq)
|
|
14353
|
+
{
|
|
14354
|
+
nearest = node;
|
|
14355
|
+
nearestDistSq = distSq;
|
|
14356
|
+
}
|
|
14357
|
+
}
|
|
14358
|
+
if (nearest) return nearest;
|
|
14359
|
+
}
|
|
14360
|
+
return null;
|
|
14361
|
+
}
|
|
14362
|
+
|
|
14363
|
+
/** Smooth a node path by removing redundant turns and tightening corners
|
|
14364
|
+
* where a grid-aligned diagonal is clear. Modifies the path in place.
|
|
14365
|
+
* Stays on the grid — does not introduce off-tile-center points.
|
|
14366
|
+
* Port of ShortenPath() in pathFinding.cpp.
|
|
14367
|
+
* @param {PathFinderNode[]} path
|
|
14368
|
+
* @private */
|
|
14369
|
+
smoothPathCorners(path)
|
|
14370
|
+
{
|
|
14371
|
+
if (path.length <= 2) return;
|
|
14372
|
+
|
|
14373
|
+
let i = 1;
|
|
14374
|
+
while (i < path.length - 1)
|
|
14375
|
+
{
|
|
14376
|
+
const prev = path[i - 1];
|
|
14377
|
+
const node = path[i];
|
|
14378
|
+
const next = path[i + 1];
|
|
14379
|
+
|
|
14380
|
+
const dx = next.pos.x - prev.pos.x;
|
|
14381
|
+
const dy = next.pos.y - prev.pos.y;
|
|
14382
|
+
const lenSq = dx * dx + dy * dy;
|
|
14383
|
+
|
|
14384
|
+
// dx,dy is the prev-to-current step direction; needed for the
|
|
14385
|
+
// 135° "mostly vertical/horizontal" disambiguation.
|
|
14386
|
+
const stepDx = node.pos.x - prev.pos.x;
|
|
14387
|
+
const stepDy = node.pos.y - prev.pos.y;
|
|
14388
|
+
const stepDxNext = next.pos.x - node.pos.x;
|
|
14389
|
+
const stepDyNext = next.pos.y - node.pos.y;
|
|
14390
|
+
|
|
14391
|
+
if (lenSq === 1)
|
|
14392
|
+
{
|
|
14393
|
+
// 45° angle — middle node is off the straight line. Drop it.
|
|
14394
|
+
if (this.debug && this.debugTime > 0)
|
|
14395
|
+
debugCircle(node.posWorld, 0.3, rgb(0.5, 0, 0.5, 0.5), this.debugTime);
|
|
14396
|
+
path.splice(i, 1);
|
|
14397
|
+
i = max(1, i - 1);
|
|
14398
|
+
continue;
|
|
14399
|
+
}
|
|
14400
|
+
else if (lenSq === 2)
|
|
14401
|
+
{
|
|
14402
|
+
// 90° corner. Check the alternative-diagonal cell.
|
|
14403
|
+
if (this.debug && this.debugTime > 0)
|
|
14404
|
+
debugCircle(node.posWorld, 0.3, rgb(1, 0, 0, 0.5), this.debugTime);
|
|
14405
|
+
|
|
14406
|
+
let sx, sy;
|
|
14407
|
+
if (prev.pos.y === node.pos.y && next.pos.x === node.pos.x)
|
|
14408
|
+
{ sx = prev.pos.x; sy = next.pos.y; }
|
|
14409
|
+
else
|
|
14410
|
+
{ sx = next.pos.x; sy = prev.pos.y; }
|
|
14411
|
+
|
|
14412
|
+
const shortcut = this.getNode(sx, sy);
|
|
14413
|
+
if (shortcut && shortcut.isClear())
|
|
14414
|
+
{
|
|
14415
|
+
path.splice(i, 1);
|
|
14416
|
+
i = max(1, i - 1);
|
|
14417
|
+
continue;
|
|
14418
|
+
}
|
|
14419
|
+
}
|
|
14420
|
+
else if (lenSq === 5)
|
|
14421
|
+
{
|
|
14422
|
+
// 135° angle (a knight's-move offset). Try to relocate the
|
|
14423
|
+
// middle node to whichever of two candidate cells is closer
|
|
14424
|
+
// to prev-of-prev, and only if the corner cut is also clear.
|
|
14425
|
+
if (this.debug && this.debugTime > 0)
|
|
14426
|
+
debugCircle(node.posWorld, 0.3, rgb(1, 1, 0, 0.5), this.debugTime);
|
|
14427
|
+
|
|
14428
|
+
const prevPrev = i >= 2 ? path[i - 2] : prev;
|
|
14429
|
+
let s1x, s1y, s2x, s2y;
|
|
14430
|
+
if (stepDx === 0 || stepDxNext === 0)
|
|
14431
|
+
{
|
|
14432
|
+
// mostly vertical
|
|
14433
|
+
s1x = next.pos.x; s1y = node.pos.y;
|
|
14434
|
+
s2x = prev.pos.x; s2y = node.pos.y;
|
|
14435
|
+
}
|
|
14436
|
+
else
|
|
14437
|
+
{
|
|
14438
|
+
// mostly horizontal
|
|
14439
|
+
s1x = node.pos.x; s1y = next.pos.y;
|
|
14440
|
+
s2x = node.pos.x; s2y = prev.pos.y;
|
|
14441
|
+
}
|
|
14442
|
+
const dd1x = s1x - prevPrev.pos.x;
|
|
14443
|
+
const dd1y = s1y - prevPrev.pos.y;
|
|
14444
|
+
const dd2x = s2x - prevPrev.pos.x;
|
|
14445
|
+
const dd2y = s2y - prevPrev.pos.y;
|
|
14446
|
+
const dist1Sq = dd1x * dd1x + dd1y * dd1y;
|
|
14447
|
+
const dist2Sq = dd2x * dd2x + dd2y * dd2y;
|
|
14448
|
+
const sx = dist1Sq < dist2Sq ? s1x : s1x === s2x && s1y === s2y ? s1x : s2x;
|
|
14449
|
+
const sy = dist1Sq < dist2Sq ? s1y : s1x === s2x && s1y === s2y ? s1y : s2y;
|
|
14450
|
+
|
|
14451
|
+
const shortcut = this.getNode(sx, sy);
|
|
14452
|
+
if (shortcut && shortcut !== node && shortcut.isClear())
|
|
14453
|
+
{
|
|
14454
|
+
// Also check the cut-corner cell is clear.
|
|
14455
|
+
const ccx = next.pos.x + s2x - s1x;
|
|
14456
|
+
const ccy = next.pos.y + s2y - s1y;
|
|
14457
|
+
const cutCorner = this.getNode(ccx, ccy);
|
|
14458
|
+
if (cutCorner && cutCorner.isClear())
|
|
14459
|
+
{
|
|
14460
|
+
path[i] = shortcut;
|
|
14461
|
+
i = max(1, i - 1);
|
|
14462
|
+
continue;
|
|
14463
|
+
}
|
|
14464
|
+
}
|
|
14465
|
+
}
|
|
14466
|
+
else if (lenSq === 4 || lenSq === 8)
|
|
14467
|
+
{
|
|
14468
|
+
// Straight line or a 1-cell bump.
|
|
14469
|
+
if (this.debug && this.debugTime > 0)
|
|
14470
|
+
debugCircle(node.posWorld, 0.3, rgb(0, 1, 0, 0.5), this.debugTime);
|
|
14471
|
+
|
|
14472
|
+
if (stepDx === stepDxNext && stepDy === stepDyNext)
|
|
14473
|
+
{
|
|
14474
|
+
// Truly straight — nothing to do, advance.
|
|
14475
|
+
++i;
|
|
14476
|
+
continue;
|
|
14477
|
+
}
|
|
14478
|
+
else
|
|
14479
|
+
{
|
|
14480
|
+
// Bump — try to flatten via the in-line cell.
|
|
14481
|
+
let sx, sy;
|
|
14482
|
+
if (prev.pos.y === next.pos.y)
|
|
14483
|
+
{ sx = node.pos.x; sy = prev.pos.y; }
|
|
14484
|
+
else
|
|
14485
|
+
{ sx = prev.pos.x; sy = node.pos.y; }
|
|
14486
|
+
const shortcut = this.getNode(sx, sy);
|
|
14487
|
+
if (shortcut && shortcut.isClear())
|
|
14488
|
+
{
|
|
14489
|
+
path[i] = shortcut;
|
|
14490
|
+
i = max(1, i - 1);
|
|
14491
|
+
continue;
|
|
14492
|
+
}
|
|
14493
|
+
}
|
|
14494
|
+
}
|
|
14495
|
+
|
|
14496
|
+
++i;
|
|
14497
|
+
}
|
|
14498
|
+
}
|
|
14499
|
+
|
|
14500
|
+
/** Smooth a node path via line-of-sight ("string pulling"). Walks the
|
|
14501
|
+
* input path collapsing runs of nodes into straight segments whenever
|
|
14502
|
+
* isLineClear permits, so the result can leave grid centers and cut
|
|
14503
|
+
* cleanly across open spaces.
|
|
14504
|
+
*
|
|
14505
|
+
* Bails (leaves the path unchanged) if any node has nonzero cost — a
|
|
14506
|
+
* straight geometric shortcut can't be trusted to be the lowest-cost
|
|
14507
|
+
* route when cost-weighted terrain is in play.
|
|
14508
|
+
*
|
|
14509
|
+
* Port of ShortenPath2() in pathFinding.cpp.
|
|
14510
|
+
* @param {PathFinderNode[]} path
|
|
14511
|
+
* @private */
|
|
14512
|
+
smoothPathStringPull(path)
|
|
14513
|
+
{
|
|
14514
|
+
if (path.length <= 2) return;
|
|
14515
|
+
for (const n of path)
|
|
14516
|
+
{
|
|
14517
|
+
if (!n.isClear()) return;
|
|
14518
|
+
}
|
|
14519
|
+
|
|
14520
|
+
const original = path.slice();
|
|
14521
|
+
path.length = 0;
|
|
14522
|
+
path.push(original[0]);
|
|
14523
|
+
let searchIndex = 0;
|
|
14524
|
+
|
|
14525
|
+
for (let i = 1; i < original.length; ++i)
|
|
14526
|
+
{
|
|
14527
|
+
const node = original[i];
|
|
14528
|
+
|
|
14529
|
+
// Skip if node is collinear with the search-window start and the
|
|
14530
|
+
// previous node — it adds no information. Note: a == b is the
|
|
14531
|
+
// degenerate i=1, searchIndex=0 case; skip the test then.
|
|
14532
|
+
{
|
|
14533
|
+
const a = original[searchIndex];
|
|
14534
|
+
const b = original[i - 1];
|
|
14535
|
+
if (a !== b)
|
|
14536
|
+
{
|
|
14537
|
+
const cross =
|
|
14538
|
+
(b.pos.x - a.pos.x) * (node.pos.y - a.pos.y) -
|
|
14539
|
+
(b.pos.y - a.pos.y) * (node.pos.x - a.pos.x);
|
|
14540
|
+
if (cross === 0) continue;
|
|
14541
|
+
}
|
|
14542
|
+
}
|
|
14543
|
+
|
|
14544
|
+
if (!this.isLineClear(node.pos, path[path.length - 1].pos))
|
|
14545
|
+
{
|
|
14546
|
+
// Look ahead — if any later node has a clear shot to the
|
|
14547
|
+
// back of our new path, skip this node and try later.
|
|
14548
|
+
let foundClearAfter = false;
|
|
14549
|
+
for (let j = i + 1; j < original.length; ++j)
|
|
14550
|
+
{
|
|
14551
|
+
if (this.isLineClear(original[j].pos, path[path.length - 1].pos))
|
|
14552
|
+
{
|
|
14553
|
+
foundClearAfter = true;
|
|
14554
|
+
break;
|
|
14555
|
+
}
|
|
14556
|
+
}
|
|
14557
|
+
if (foundClearAfter)
|
|
14558
|
+
{
|
|
14559
|
+
if (this.debug && this.debugTime > 0)
|
|
14560
|
+
debugLine(node.posWorld, path[path.length - 1].posWorld, rgb(0, 0, 1, 0.3), 0.02, this.debugTime);
|
|
14561
|
+
continue;
|
|
14562
|
+
}
|
|
14563
|
+
|
|
14564
|
+
// No clear line ahead — fall back to the last waypoint we did
|
|
14565
|
+
// have a clear line to. searchIndex tracks our scan position.
|
|
14566
|
+
for (; searchIndex < original.length; ++searchIndex)
|
|
14567
|
+
{
|
|
14568
|
+
const cand = original[searchIndex];
|
|
14569
|
+
if (this.isLineClear(node.pos, cand.pos))
|
|
14570
|
+
{
|
|
14571
|
+
path.push(cand);
|
|
14572
|
+
i = searchIndex;
|
|
14573
|
+
break;
|
|
14574
|
+
}
|
|
14575
|
+
}
|
|
14576
|
+
ASSERT(searchIndex < original.length, 'smoothPathStringPull: ran out of candidates');
|
|
14577
|
+
}
|
|
14578
|
+
}
|
|
14579
|
+
|
|
14580
|
+
path.push(original[original.length - 1]);
|
|
14581
|
+
}
|
|
14582
|
+
|
|
14583
|
+
/** Lookup helper: true when the node at tile coords (x, y) is in-bounds
|
|
14584
|
+
* and clear (walkable, zero-cost). Used by isLineClear's hot path.
|
|
14585
|
+
* @param {number} x
|
|
14586
|
+
* @param {number} y
|
|
14587
|
+
* @returns {boolean}
|
|
14588
|
+
* @private */
|
|
14589
|
+
isNodeClear(x, y)
|
|
14590
|
+
{
|
|
14591
|
+
const n = this.getNode(x, y);
|
|
14592
|
+
return n !== null && n.isClear();
|
|
14593
|
+
}
|
|
14594
|
+
|
|
14595
|
+
/** Check that the line between two tile-coord endpoints stays entirely
|
|
14596
|
+
* inside walkable, zero-cost cells. Stricter than just sampling along
|
|
14597
|
+
* the line — it also checks the diagonal-corner-adjacent cells so the
|
|
14598
|
+
* line can never "scrape past" a wall corner.
|
|
14599
|
+
*
|
|
14600
|
+
* Both endpoints must themselves be clear (asserted in debug). Port of
|
|
14601
|
+
* CheckLine() in pathFinding.cpp.
|
|
14602
|
+
* @param {Vector2} startPos - Tile coords
|
|
14603
|
+
* @param {Vector2} endPos - Tile coords
|
|
14604
|
+
* @returns {boolean}
|
|
14605
|
+
* @private */
|
|
14606
|
+
isLineClear(startPos, endPos)
|
|
14607
|
+
{
|
|
14608
|
+
ASSERT(isVector2(startPos) && isVector2(endPos), 'isLineClear needs Vector2 endpoints');
|
|
14609
|
+
ASSERT(this.isNodeClear(startPos.x, startPos.y) && this.isNodeClear(endPos.x, endPos.y),
|
|
14610
|
+
'isLineClear endpoints must be in-bounds and clear');
|
|
14611
|
+
|
|
14612
|
+
const dx = endPos.x - startPos.x;
|
|
14613
|
+
const dy = endPos.y - startPos.y;
|
|
14614
|
+
const adx = abs(dx);
|
|
14615
|
+
const ady = abs(dy);
|
|
14616
|
+
const sx = sign(dx);
|
|
14617
|
+
const sy = sign(dy);
|
|
14618
|
+
let x = startPos.x;
|
|
14619
|
+
let y = startPos.y;
|
|
14620
|
+
|
|
14621
|
+
if (ady === adx)
|
|
14622
|
+
{
|
|
14623
|
+
// Pure diagonal.
|
|
14624
|
+
while (x !== endPos.x)
|
|
14625
|
+
{
|
|
14626
|
+
if (x !== startPos.x)
|
|
14627
|
+
{
|
|
14628
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
14629
|
+
if (!this.isNodeClear(x, y - sy)) return false;
|
|
14630
|
+
}
|
|
14631
|
+
if (!this.isNodeClear(x, y + sy)) return false;
|
|
14632
|
+
x += sx;
|
|
14633
|
+
y += sy;
|
|
14634
|
+
}
|
|
14635
|
+
if (!this.isNodeClear(endPos.x, endPos.y - sy)) return false;
|
|
14636
|
+
}
|
|
14637
|
+
else if (ady < adx)
|
|
14638
|
+
{
|
|
14639
|
+
// Mostly horizontal.
|
|
14640
|
+
if (dy === 0)
|
|
14641
|
+
{
|
|
14642
|
+
// Purely horizontal.
|
|
14643
|
+
x += sx;
|
|
14644
|
+
while (x !== endPos.x)
|
|
14645
|
+
{
|
|
14646
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
14647
|
+
x += sx;
|
|
14648
|
+
}
|
|
14649
|
+
}
|
|
14650
|
+
else
|
|
14651
|
+
{
|
|
14652
|
+
let lastY = startPos.y;
|
|
14653
|
+
while (x !== endPos.x)
|
|
14654
|
+
{
|
|
14655
|
+
y = startPos.y + Math.trunc((dy * (x - startPos.x)) / dx);
|
|
14656
|
+
if (lastY !== y)
|
|
14657
|
+
{
|
|
14658
|
+
if (!this.isNodeClear(x - sx, y + sy)) return false;
|
|
14659
|
+
if (!this.isNodeClear(x, y - sy)) return false;
|
|
14660
|
+
}
|
|
14661
|
+
lastY = y;
|
|
14662
|
+
if (x !== startPos.x)
|
|
14663
|
+
{
|
|
14664
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
14665
|
+
}
|
|
14666
|
+
y += sy;
|
|
14667
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
14668
|
+
x += sx;
|
|
14669
|
+
}
|
|
14670
|
+
const finalY = endPos.y - sy;
|
|
14671
|
+
if (!this.isNodeClear(endPos.x, finalY)) return false;
|
|
14672
|
+
}
|
|
14673
|
+
}
|
|
14674
|
+
else
|
|
14675
|
+
{
|
|
14676
|
+
// Mostly vertical.
|
|
14677
|
+
if (dx === 0)
|
|
14678
|
+
{
|
|
14679
|
+
y += sy;
|
|
14680
|
+
while (y !== endPos.y)
|
|
14681
|
+
{
|
|
14682
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
14683
|
+
y += sy;
|
|
14684
|
+
}
|
|
14685
|
+
}
|
|
14686
|
+
else
|
|
14687
|
+
{
|
|
14688
|
+
let lastX = startPos.x;
|
|
14689
|
+
while (y !== endPos.y)
|
|
14690
|
+
{
|
|
14691
|
+
x = startPos.x + Math.trunc((dx * (y - startPos.y)) / dy);
|
|
14692
|
+
if (lastX !== x)
|
|
14693
|
+
{
|
|
14694
|
+
if (!this.isNodeClear(x + sx, y - sy)) return false;
|
|
14695
|
+
if (!this.isNodeClear(x - sx, y)) return false;
|
|
14696
|
+
}
|
|
14697
|
+
lastX = x;
|
|
14698
|
+
if (y !== startPos.y)
|
|
14699
|
+
{
|
|
14700
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
14701
|
+
}
|
|
14702
|
+
x += sx;
|
|
14703
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
14704
|
+
y += sy;
|
|
14705
|
+
}
|
|
14706
|
+
const finalX = endPos.x - sx;
|
|
14707
|
+
if (!this.isNodeClear(finalX, endPos.y)) return false;
|
|
14708
|
+
}
|
|
14709
|
+
}
|
|
14710
|
+
return true;
|
|
14711
|
+
}
|
|
14712
|
+
|
|
14713
|
+
/** Find a path from startPos to endPos in world space. Returns an array
|
|
14714
|
+
* of world-space Vector2 points; empty array if no path exists.
|
|
14715
|
+
*
|
|
14716
|
+
* Start and end are snapped to the nearest walkable tile via
|
|
14717
|
+
* getNearestClearNode. Intermediate points are tile centers unless the
|
|
14718
|
+
* string-pulling smoothing pass moves them off-grid.
|
|
14719
|
+
* @param {Vector2} startPos - World-space start
|
|
14720
|
+
* @param {Vector2} endPos - World-space end
|
|
14721
|
+
* @returns {Vector2[]}
|
|
14722
|
+
* @memberof PathFinding */
|
|
14723
|
+
findPath(startPos, endPos)
|
|
14724
|
+
{
|
|
14725
|
+
ASSERT(isVector2(startPos) && isVector2(endPos), 'findPath needs Vector2 endpoints');
|
|
14726
|
+
|
|
14727
|
+
this.buildNodeData();
|
|
14728
|
+
|
|
14729
|
+
// rebuild=false because we just built — avoid redundant work per snap.
|
|
14730
|
+
const startNode = this.getNearestClearNode(startPos, 10, false);
|
|
14731
|
+
const endNode = this.getNearestClearNode(endPos, 10, false);
|
|
14732
|
+
if (!startNode || !endNode) return [];
|
|
14733
|
+
|
|
14734
|
+
// Trivial case: start and end snapped to the same tile.
|
|
14735
|
+
if (startNode === endNode) return [startNode.posWorld.copy()];
|
|
14736
|
+
|
|
14737
|
+
if (!this.aStarSearch(startNode, endNode)) return [];
|
|
14738
|
+
|
|
14739
|
+
// Walk back from endNode via parent pointers, then reverse — cheaper
|
|
14740
|
+
// than unshifting on every step.
|
|
14741
|
+
const nodePath = [];
|
|
14742
|
+
for (let n = endNode; n; n = n.parent)
|
|
14743
|
+
nodePath.push(n);
|
|
14744
|
+
nodePath.reverse();
|
|
14745
|
+
|
|
14746
|
+
if (this.smoothPath)
|
|
14747
|
+
{
|
|
14748
|
+
this.smoothPathCorners(nodePath);
|
|
14749
|
+
this.smoothPathStringPull(nodePath);
|
|
14750
|
+
}
|
|
14751
|
+
|
|
14752
|
+
// Convert to world-space Vector2 path. Return copies, not live node
|
|
14753
|
+
// references — callers shouldn't be able to mutate the grid.
|
|
14754
|
+
const result = nodePath.map(n => n.posWorld.copy());
|
|
14755
|
+
|
|
14756
|
+
if (this.debug && this.debugTime > 0 && result.length > 0)
|
|
14757
|
+
{
|
|
14758
|
+
for (let i = 1; i < result.length; ++i)
|
|
14759
|
+
debugLine(result[i - 1], result[i], RED, 0.1, this.debugTime);
|
|
14760
|
+
for (const p of result)
|
|
14761
|
+
debugCircle(p, 0.5, rgb(1, 0, 0, 0.3), this.debugTime);
|
|
14762
|
+
debugCircle(result[0], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
|
|
14763
|
+
debugCircle(result[result.length - 1], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
|
|
14764
|
+
}
|
|
14765
|
+
|
|
14766
|
+
return result;
|
|
14767
|
+
}
|
|
14768
|
+
}
|
|
14769
|
+
|