littlejsengine 1.18.4 → 1.18.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COPYRIGHT.txt +38 -0
- package/FAQ.md +633 -0
- package/README.md +19 -1
- package/dist/littlejs.d.ts +349 -54
- package/dist/littlejs.esm.js +1469 -448
- package/dist/littlejs.esm.min.js +1 -1
- package/dist/littlejs.js +1438 -436
- package/dist/littlejs.min.js +1 -1
- package/dist/littlejs.release.js +1438 -436
- package/package.json +8 -1
- package/plugins/box2d.js +32 -7
- package/{src/engineMedals.js → plugins/medalSystem.js} +248 -196
- package/plugins/pathFinder.js +758 -0
- package/plugins/pluginExport.js +19 -0
- package/plugins/tween.js +1 -1
- package/plugins/tweenSystem.js +509 -0
- package/plugins/uiSystem.js +150 -11
- package/src/engine.js +2 -141
- package/src/engineAudio.js +8 -5
- package/src/engineBuild.mjs +4 -2
- package/src/engineDebug.js +1 -1
- package/src/engineDraw.js +21 -3
- package/src/engineExport.js +12 -12
- package/src/engineInput.js +1 -1
- package/src/engineLogo.js +146 -0
- package/src/engineMath.js +10 -3
- package/src/engineObject.js +11 -5
- package/src/engineParticles.js +6 -3
- package/src/engineRelease.js +1 -1
- package/src/engineSettings.js +0 -47
- package/src/engineUtilities.js +7 -2
- package/src/engineWebGL.js +34 -7
- package/src/jsconfig.json +3 -1
- package/.github/workflows/test.yml +0 -17
- package/AI.md +0 -173
- package/CLAUDE.md +0 -1
- package/examples/box2d/game.js +0 -192
- package/examples/box2d/gameObjects.js +0 -564
- package/examples/box2d/index.html +0 -9
- package/examples/box2d/scenes.js +0 -194
- package/examples/box2d/tiles.png +0 -0
- package/examples/breakout/game.js +0 -175
- package/examples/breakout/gameObjects.js +0 -147
- package/examples/breakout/index.html +0 -8
- package/examples/breakout/tiles.png +0 -0
- package/examples/breakoutTutorial/README.md +0 -521
- package/examples/breakoutTutorial/game.js +0 -191
- package/examples/breakoutTutorial/images/1.png +0 -0
- package/examples/breakoutTutorial/images/10.png +0 -0
- package/examples/breakoutTutorial/images/11.png +0 -0
- package/examples/breakoutTutorial/images/2.png +0 -0
- package/examples/breakoutTutorial/images/3.png +0 -0
- package/examples/breakoutTutorial/images/4.png +0 -0
- package/examples/breakoutTutorial/images/5.png +0 -0
- package/examples/breakoutTutorial/images/6.png +0 -0
- package/examples/breakoutTutorial/images/7.png +0 -0
- package/examples/breakoutTutorial/images/8.png +0 -0
- package/examples/breakoutTutorial/images/9.png +0 -0
- package/examples/breakoutTutorial/index.html +0 -8
- package/examples/electron/build.mjs +0 -127
- package/examples/electron/electron.js +0 -35
- package/examples/electron/game.js +0 -54
- package/examples/electron/index.html +0 -13
- package/examples/electron/package.json +0 -5
- package/examples/electron/tiles.png +0 -0
- package/examples/empty/game.js +0 -51
- package/examples/empty/index.html +0 -5
- package/examples/empty/tiles.png +0 -0
- package/examples/favicon.png +0 -0
- package/examples/games.jpg +0 -0
- package/examples/htmlMenu/game.js +0 -82
- package/examples/htmlMenu/index.html +0 -45
- package/examples/htmlMenu/tiles.png +0 -0
- package/examples/index.html +0 -62
- package/examples/logo.png +0 -0
- package/examples/logo2.png +0 -0
- package/examples/module/build.mjs +0 -124
- package/examples/module/game.js +0 -132
- package/examples/module/index.html +0 -10
- package/examples/module/tiles.png +0 -0
- package/examples/particles/index.html +0 -426
- package/examples/particles/tiles.png +0 -0
- package/examples/platformer/data/gameLevelData.tmx +0 -143
- package/examples/platformer/data/gameLevelData.tsx +0 -4
- package/examples/platformer/game.js +0 -147
- package/examples/platformer/gameCharacter.js +0 -309
- package/examples/platformer/gameEffects.js +0 -278
- package/examples/platformer/gameLevel.js +0 -205
- package/examples/platformer/gameLevelData.json +0 -171
- package/examples/platformer/gameObjects.js +0 -378
- package/examples/platformer/gamePlayer.js +0 -35
- package/examples/platformer/index.html +0 -9
- package/examples/platformer/tiles.png +0 -0
- package/examples/platformer/tilesLevel.png +0 -0
- package/examples/puzzle/game.js +0 -331
- package/examples/puzzle/index.html +0 -8
- package/examples/puzzle/tiles.png +0 -0
- package/examples/screenshot.jpg +0 -0
- package/examples/shorts/animation.js +0 -18
- package/examples/shorts/base.html +0 -54
- package/examples/shorts/blending.js +0 -14
- package/examples/shorts/box2d.js +0 -51
- package/examples/shorts/box2dCar.js +0 -59
- package/examples/shorts/box2dPool.js +0 -107
- package/examples/shorts/box2dTileLayer.js +0 -48
- package/examples/shorts/cameraDrag.js +0 -22
- package/examples/shorts/clock.js +0 -21
- package/examples/shorts/colors.js +0 -25
- package/examples/shorts/debugDraw.js +0 -37
- package/examples/shorts/empty.js +0 -30
- package/examples/shorts/flappyGame.js +0 -55
- package/examples/shorts/fontImage.js +0 -17
- package/examples/shorts/fps.js +0 -90
- package/examples/shorts/helloWorld.js +0 -11
- package/examples/shorts/hillGlideGame.js +0 -63
- package/examples/shorts/input.js +0 -64
- package/examples/shorts/landerGame.js +0 -57
- package/examples/shorts/maze.js +0 -48
- package/examples/shorts/medals.js +0 -51
- package/examples/shorts/music.js +0 -78
- package/examples/shorts/musicPlayer.js +0 -137
- package/examples/shorts/nineSlice.js +0 -40
- package/examples/shorts/parallax.js +0 -72
- package/examples/shorts/particles.js +0 -28
- package/examples/shorts/piano.js +0 -44
- package/examples/shorts/platformer.js +0 -45
- package/examples/shorts/pongGame.js +0 -41
- package/examples/shorts/postProcess.js +0 -62
- package/examples/shorts/sequencer.js +0 -124
- package/examples/shorts/shader.js +0 -29
- package/examples/shorts/shapes.js +0 -21
- package/examples/shorts/slidingPuzzle.js +0 -52
- package/examples/shorts/song.mp3 +0 -0
- package/examples/shorts/sound.js +0 -36
- package/examples/shorts/spaceGame.js +0 -58
- package/examples/shorts/spriteAtlas.js +0 -31
- package/examples/shorts/starfield.js +0 -15
- package/examples/shorts/texture.js +0 -16
- package/examples/shorts/textureWrapped.js +0 -12
- package/examples/shorts/tileLayer.js +0 -48
- package/examples/shorts/tileRaycast.js +0 -39
- package/examples/shorts/tiles.png +0 -0
- package/examples/shorts/tiltedView.js +0 -63
- package/examples/shorts/timers.js +0 -53
- package/examples/shorts/topDown.js +0 -38
- package/examples/shorts/tween.js +0 -24
- package/examples/shorts/uiSystem.js +0 -61
- package/examples/shorts/video.webm +0 -0
- package/examples/shorts/videoPlayer.js +0 -34
- package/examples/shorts.js +0 -743
- package/examples/starter/build.bat +0 -7
- package/examples/starter/build.mjs +0 -126
- package/examples/starter/game.js +0 -137
- package/examples/starter/index.html +0 -35
- package/examples/starter/tiles.png +0 -0
- package/examples/stress/index.html +0 -173
- package/examples/style.css +0 -150
- package/examples/tweenSystem/game.js +0 -171
- package/examples/tweenSystem/index.html +0 -10
- package/examples/tweenSystem/tiles.png +0 -0
- package/examples/typescript/build.mjs +0 -60
- package/examples/typescript/game.js +0 -100
- package/examples/typescript/game.ts +0 -132
- package/examples/typescript/index.html +0 -10
- package/examples/typescript/tiles.png +0 -0
- package/examples/typescript/tsconfig.json +0 -17
- package/examples/uiSystem/game.js +0 -139
- package/examples/uiSystem/index.html +0 -10
- package/examples/uiSystem/tiles.png +0 -0
- package/jsconfig.json +0 -12
- package/plugins/desktop.ini +0 -2
- package/reference.md +0 -448
- package/test/math.test.mjs +0 -774
- package/test/setup.mjs +0 -22
- package/test/smoke.test.mjs +0 -274
- package/test/tween.test.mjs +0 -576
- package/test/util.test.mjs +0 -80
package/dist/littlejs.release.js
CHANGED
|
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
|
|
|
35
35
|
* @type {string}
|
|
36
36
|
* @default
|
|
37
37
|
* @memberof Engine */
|
|
38
|
-
const engineVersion = '1.18.
|
|
38
|
+
const engineVersion = '1.18.7';
|
|
39
39
|
|
|
40
40
|
/** Frames per second to update
|
|
41
41
|
* @type {number}
|
|
@@ -207,7 +207,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
|
|
|
207
207
|
const combinedScale = timeScale * debugScale;
|
|
208
208
|
frameTimeDeltaMS *= combinedScale;
|
|
209
209
|
frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
|
|
210
|
-
if (
|
|
210
|
+
if (debugScale <= 1)
|
|
211
211
|
frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp min framerate
|
|
212
212
|
|
|
213
213
|
let wasUpdated = false;
|
|
@@ -565,145 +565,6 @@ function engineObjectsRaycast(start, end, objects=engineObjects)
|
|
|
565
565
|
|
|
566
566
|
debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02);
|
|
567
567
|
return hitObjects;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
571
|
-
function drawEngineLogo(t)
|
|
572
|
-
{
|
|
573
|
-
const blackAndWhite = 0;
|
|
574
|
-
const showName = 1;
|
|
575
|
-
|
|
576
|
-
// LittleJS Logo and Splash Screen
|
|
577
|
-
const x = mainContext;
|
|
578
|
-
const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
|
|
579
|
-
const w = mainCanvas.width = innerWidth * dpr;
|
|
580
|
-
const h = mainCanvas.height = innerHeight * dpr;
|
|
581
|
-
{
|
|
582
|
-
// background
|
|
583
|
-
const p3 = percent(t, 1, .8);
|
|
584
|
-
const p4 = percent(t, 0, .5);
|
|
585
|
-
const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,hypot(w,h)*.6);
|
|
586
|
-
g.addColorStop(0,hsl(0,0,lerp(0,p3/2,p4),p3).toString());
|
|
587
|
-
g.addColorStop(1,hsl(0,0,0,p3).toString());
|
|
588
|
-
x.save();
|
|
589
|
-
x.fillStyle = g;
|
|
590
|
-
x.fillRect(0,0,w,h);
|
|
591
|
-
}
|
|
592
|
-
const gradient = (X1,Y1,X2,Y2,C,S=1)=>
|
|
593
|
-
{
|
|
594
|
-
if (C >= 0)
|
|
595
|
-
{
|
|
596
|
-
if (blackAndWhite)
|
|
597
|
-
x.fillStyle = '#fff';
|
|
598
|
-
else
|
|
599
|
-
{
|
|
600
|
-
const g = x.fillStyle = x.createLinearGradient(X1,Y1,X2,Y2);
|
|
601
|
-
g.addColorStop(0,color(C,2));
|
|
602
|
-
g.addColorStop(1,color(C,1));
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
else
|
|
606
|
-
x.fillStyle = '#000';
|
|
607
|
-
C >= -1 ? (x.fill(), S && x.stroke()) : x.stroke();
|
|
608
|
-
}
|
|
609
|
-
const circle = (X,Y,R,A=0,B=2*PI,C,S)=>
|
|
610
|
-
{
|
|
611
|
-
x.beginPath();
|
|
612
|
-
x.arc(X,Y,R,p*A,p*B);
|
|
613
|
-
gradient(X,Y-R,X,Y+R,C,S);
|
|
614
|
-
}
|
|
615
|
-
const rect = (X,Y,W,H,C)=>
|
|
616
|
-
{
|
|
617
|
-
x.beginPath();
|
|
618
|
-
x.rect(X,Y,W,H*p);
|
|
619
|
-
gradient(X,Y+H,X+W,Y,C);
|
|
620
|
-
}
|
|
621
|
-
const poly = (points,C,Y,H)=>
|
|
622
|
-
{
|
|
623
|
-
x.beginPath();
|
|
624
|
-
for (const p of points)
|
|
625
|
-
x.lineTo(p.x, p.y);
|
|
626
|
-
x.closePath();
|
|
627
|
-
gradient(0, Y, 0, Y+H,C);
|
|
628
|
-
}
|
|
629
|
-
const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
|
|
630
|
-
|
|
631
|
-
// center and fit tos screen
|
|
632
|
-
const alpha = oscillate(1,1,t);
|
|
633
|
-
const p = percent(alpha, .1, .5);
|
|
634
|
-
const size = min(6, min(w,h)/99);
|
|
635
|
-
x.translate(w/2,h/2);
|
|
636
|
-
x.scale(size,size);
|
|
637
|
-
x.translate(-40,-35);
|
|
638
|
-
p < 1 && x.setLineDash([99*p,99]);
|
|
639
|
-
x.lineJoin = x.lineCap = 'round';
|
|
640
|
-
x.lineWidth = .1 + p*1.9;
|
|
641
|
-
//x.strokeStyle='#fff7';
|
|
642
|
-
|
|
643
|
-
if (showName)
|
|
644
|
-
{
|
|
645
|
-
// engine name text
|
|
646
|
-
const Y = 54;
|
|
647
|
-
const s = 'LittleJS';
|
|
648
|
-
x.font = '900 15.5px arial';
|
|
649
|
-
x.lineWidth = .1+p*3.9;
|
|
650
|
-
x.textAlign = 'center';
|
|
651
|
-
x.textBaseline = 'top';
|
|
652
|
-
rect(11,Y+1,59,8*p,-1);
|
|
653
|
-
x.beginPath();
|
|
654
|
-
|
|
655
|
-
let w2 = 0;
|
|
656
|
-
for (let i=0;i<s.length;++i)
|
|
657
|
-
w2 += x.measureText(s[i]).width;
|
|
658
|
-
for (let j=2;j--;)
|
|
659
|
-
for (let i=0,X=40-w2/2;i<s.length;++i)
|
|
660
|
-
{
|
|
661
|
-
const w = x.measureText(s[i]).width, X2 = X+w/2;
|
|
662
|
-
gradient(X2,Y,X2+2,Y+13,i>5?1:0);
|
|
663
|
-
x[j?'strokeText':'fillText'](s[i],X2,Y+.5,17*p);
|
|
664
|
-
X += w;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
x.lineWidth = .1 + p*1.9;
|
|
668
|
-
rect(3,Y,73,0); // bottom
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
rect(7,15,26,-7,0); // cab top
|
|
672
|
-
rect(25,15,8,25,-1); // cab front
|
|
673
|
-
rect(10,40,15,-25,1); // cab back
|
|
674
|
-
rect(14,21,7,9,2); // cab window
|
|
675
|
-
rect(38,20,6,-6,2); // little stack
|
|
676
|
-
|
|
677
|
-
// big stack
|
|
678
|
-
rect(49,20,10,-6,0);
|
|
679
|
-
const stackPoints = [vec2(44,8),vec2(64,8),vec2(59,8+6*p),vec2(49,8+6*p)];
|
|
680
|
-
poly(stackPoints,2,8,6*p);
|
|
681
|
-
rect(44,8,20,-7,0);
|
|
682
|
-
|
|
683
|
-
// engine
|
|
684
|
-
for (let i=5;i--;) circle(59-i*6*p,30,10,0,2*PI,1,0);
|
|
685
|
-
circle(59,30,4,0,7,2); // light
|
|
686
|
-
|
|
687
|
-
// engine outline
|
|
688
|
-
rect(35,20,24,0); // top
|
|
689
|
-
circle(59,30,10); // front
|
|
690
|
-
circle(47,30,10,PI/2,PI*3/2); // middle
|
|
691
|
-
circle(35,30,10,PI/2,PI*3/2); // back
|
|
692
|
-
rect(7,40,13,7,-1); // bottom back
|
|
693
|
-
rect(17,40,43,14,-1); // bottom center
|
|
694
|
-
|
|
695
|
-
// wheels
|
|
696
|
-
for (let i=3;i--;) for (let j=2;j--;) circle(17+15*i,47,j?7:1,0,2*PI,2);
|
|
697
|
-
|
|
698
|
-
// cowcatcher
|
|
699
|
-
for (let i=2;i--;)
|
|
700
|
-
{
|
|
701
|
-
let w=6, s=7, o=53+w*p*i
|
|
702
|
-
const points = [vec2(o+s,54),vec2(o,40),vec2(o+w*p,40),vec2(o+s+w*p,54)];
|
|
703
|
-
poly(points,0,40,14);
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
x.restore();
|
|
707
568
|
}
|
|
708
569
|
/**
|
|
709
570
|
* LittleJS - Release Mode
|
|
@@ -723,7 +584,7 @@ const debugPhysics = 0;
|
|
|
723
584
|
const debugParticles = 0;
|
|
724
585
|
const debugRaycast = 0;
|
|
725
586
|
const debugGamepads = 0;
|
|
726
|
-
const
|
|
587
|
+
const debugPointSize = .5;
|
|
727
588
|
|
|
728
589
|
// debug commands are automatically removed from the final build
|
|
729
590
|
function ASSERT (){}
|
|
@@ -900,7 +761,11 @@ function percentLerp(value, percentA, percentB, lerpA, lerpB)
|
|
|
900
761
|
* @return {number}
|
|
901
762
|
* @memberof Math */
|
|
902
763
|
function distanceWrap(valueA, valueB, wrapSize=1)
|
|
903
|
-
{
|
|
764
|
+
{
|
|
765
|
+
ASSERT(wrapSize > 0, 'distanceWrap wrapSize must be > 0');
|
|
766
|
+
const d = (valueA - valueB) % wrapSize;
|
|
767
|
+
return d*2 % wrapSize - d;
|
|
768
|
+
}
|
|
904
769
|
|
|
905
770
|
/** Linearly interpolates between values passed in with wrapping
|
|
906
771
|
* @param {number} valueA
|
|
@@ -1217,6 +1082,7 @@ class RandomGenerator
|
|
|
1217
1082
|
* @param {number} [seed] - Starting seed or engine default seed */
|
|
1218
1083
|
constructor(seed = 123456789)
|
|
1219
1084
|
{
|
|
1085
|
+
ASSERT(seed !== 0, 'RandomGenerator seed must be non-zero (xorshift is fixed at 0)');
|
|
1220
1086
|
/** @property {number} - random seed */
|
|
1221
1087
|
this.seed = seed;
|
|
1222
1088
|
}
|
|
@@ -1510,8 +1376,10 @@ class Vector2
|
|
|
1510
1376
|
* @return {Vector2} */
|
|
1511
1377
|
floor() { return new Vector2(floor(this.x), floor(this.y)); }
|
|
1512
1378
|
|
|
1513
|
-
/** Returns a copy of this vector snapped to a grid
|
|
1514
|
-
*
|
|
1379
|
+
/** Returns a copy of this vector snapped to a grid. Note that `grid` is
|
|
1380
|
+
* the number of snap steps per unit (so `grid=2` snaps to halves and
|
|
1381
|
+
* `grid=0.5` snaps to twos), not the cell size.
|
|
1382
|
+
* @param {number} grid - snap steps per unit
|
|
1515
1383
|
* @return {Vector2} */
|
|
1516
1384
|
snap(grid)
|
|
1517
1385
|
{
|
|
@@ -2081,9 +1949,14 @@ function readSaveData(saveName, defaultSaveData)
|
|
|
2081
1949
|
{
|
|
2082
1950
|
ASSERT(isString(saveName), 'loadData requires saveName string');
|
|
2083
1951
|
|
|
2084
|
-
// replace undefined values with defaults
|
|
1952
|
+
// replace undefined values with defaults; tolerate corrupt JSON
|
|
2085
1953
|
const data = localStorage[saveName];
|
|
2086
|
-
|
|
1954
|
+
let loadedData = {};
|
|
1955
|
+
if (data)
|
|
1956
|
+
{
|
|
1957
|
+
try { loadedData = JSON.parse(data); }
|
|
1958
|
+
catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
|
|
1959
|
+
}
|
|
2087
1960
|
return { ...defaultSaveData, ...loadedData };
|
|
2088
1961
|
}
|
|
2089
1962
|
|
|
@@ -2418,33 +2291,6 @@ let soundDefaultRange = 40;
|
|
|
2418
2291
|
* @memberof Settings */
|
|
2419
2292
|
let soundDefaultTaper = .7;
|
|
2420
2293
|
|
|
2421
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2422
|
-
// Medals settings
|
|
2423
|
-
|
|
2424
|
-
/** How long to show medals for in seconds
|
|
2425
|
-
* @type {number}
|
|
2426
|
-
* @default
|
|
2427
|
-
* @memberof Settings */
|
|
2428
|
-
let medalDisplayTime = 5;
|
|
2429
|
-
|
|
2430
|
-
/** How quickly to slide on/off medals in seconds
|
|
2431
|
-
* @type {number}
|
|
2432
|
-
* @default
|
|
2433
|
-
* @memberof Settings */
|
|
2434
|
-
let medalDisplaySlideTime = .5;
|
|
2435
|
-
|
|
2436
|
-
/** Size of medal display
|
|
2437
|
-
* @type {Vector2}
|
|
2438
|
-
* @default Vector2(640,80)
|
|
2439
|
-
* @memberof Settings */
|
|
2440
|
-
let medalDisplaySize = vec2(640, 80);
|
|
2441
|
-
|
|
2442
|
-
/** Set to stop medals from being unlockable (like if cheats are enabled)
|
|
2443
|
-
* @type {boolean}
|
|
2444
|
-
* @default
|
|
2445
|
-
* @memberof Settings */
|
|
2446
|
-
let medalsPreventUnlock = false;
|
|
2447
|
-
|
|
2448
2294
|
///////////////////////////////////////////////////////////////////////////////
|
|
2449
2295
|
// Setters for global variables
|
|
2450
2296
|
|
|
@@ -2706,26 +2552,6 @@ function setSoundDefaultRange(range) { soundDefaultRange = range; }
|
|
|
2706
2552
|
* @memberof Settings */
|
|
2707
2553
|
function setSoundDefaultTaper(taper) { soundDefaultTaper = taper; }
|
|
2708
2554
|
|
|
2709
|
-
/** Set how long to show medals for in seconds
|
|
2710
|
-
* @param {number} time
|
|
2711
|
-
* @memberof Settings */
|
|
2712
|
-
function setMedalDisplayTime(time) { medalDisplayTime = time; }
|
|
2713
|
-
|
|
2714
|
-
/** Set how quickly to slide on/off medals in seconds
|
|
2715
|
-
* @param {number} time
|
|
2716
|
-
* @memberof Settings */
|
|
2717
|
-
function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
|
|
2718
|
-
|
|
2719
|
-
/** Set size of medal display
|
|
2720
|
-
* @param {Vector2} size
|
|
2721
|
-
* @memberof Settings */
|
|
2722
|
-
function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
|
|
2723
|
-
|
|
2724
|
-
/** Set to stop medals from being unlockable
|
|
2725
|
-
* @param {boolean} preventUnlock
|
|
2726
|
-
* @memberof Settings */
|
|
2727
|
-
function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }
|
|
2728
|
-
|
|
2729
2555
|
/** Set if watermark with FPS should be shown
|
|
2730
2556
|
* @param {boolean} show
|
|
2731
2557
|
* @memberof Debug */
|
|
@@ -2867,10 +2693,18 @@ class EngineObject
|
|
|
2867
2693
|
const parent = this.parent;
|
|
2868
2694
|
if (parent)
|
|
2869
2695
|
{
|
|
2870
|
-
//
|
|
2696
|
+
// compose with parent transform inline to avoid intermediate vector allocs
|
|
2871
2697
|
const mirror = parent.getMirrorSign();
|
|
2872
|
-
|
|
2873
|
-
|
|
2698
|
+
const lp = this.localPos, pp = parent.pos;
|
|
2699
|
+
const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
|
|
2700
|
+
if (pa)
|
|
2701
|
+
{
|
|
2702
|
+
const c = cos(-pa), s = sin(-pa);
|
|
2703
|
+
this.pos = new Vector2(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
|
|
2704
|
+
}
|
|
2705
|
+
else
|
|
2706
|
+
this.pos = new Vector2(lx + pp.x, ly + pp.y);
|
|
2707
|
+
this.angle = mirror*this.localAngle + pa;
|
|
2874
2708
|
}
|
|
2875
2709
|
|
|
2876
2710
|
// update children
|
|
@@ -3236,8 +3070,6 @@ class EngineObject
|
|
|
3236
3070
|
* @return {string} */
|
|
3237
3071
|
toString()
|
|
3238
3072
|
{
|
|
3239
|
-
if (!debug) return;
|
|
3240
|
-
|
|
3241
3073
|
let text = 'type = ' + this.constructor.name;
|
|
3242
3074
|
if (this.pos.x || this.pos.y)
|
|
3243
3075
|
text += '\npos = ' + this.pos;
|
|
@@ -3487,8 +3319,9 @@ class TextureInfo
|
|
|
3487
3319
|
* Create a TextureInfo, called automatically by the engine
|
|
3488
3320
|
* @param {HTMLImageElement|OffscreenCanvas} image
|
|
3489
3321
|
* @param {boolean} [useWebGL] - Should use WebGL if available?
|
|
3322
|
+
* @param {boolean} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)?
|
|
3490
3323
|
*/
|
|
3491
|
-
constructor(image, useWebGL=true)
|
|
3324
|
+
constructor(image, useWebGL=true, wrap=false)
|
|
3492
3325
|
{
|
|
3493
3326
|
/** @property {HTMLImageElement|OffscreenCanvas} - image source */
|
|
3494
3327
|
this.image = image;
|
|
@@ -3498,6 +3331,8 @@ class TextureInfo
|
|
|
3498
3331
|
this.sizeInverse = image ? vec2(1/image.width, 1/image.height) : vec2();
|
|
3499
3332
|
/** @property {WebGLTexture} - WebGL texture */
|
|
3500
3333
|
this.glTexture = undefined;
|
|
3334
|
+
/** @property {boolean} - true for REPEAT wrap mode, false for CLAMP_TO_EDGE */
|
|
3335
|
+
this.wrap = wrap;
|
|
3501
3336
|
useWebGL && this.createWebGLTexture();
|
|
3502
3337
|
}
|
|
3503
3338
|
|
|
@@ -3510,6 +3345,14 @@ class TextureInfo
|
|
|
3510
3345
|
/** Check if the texture is webgl enabled
|
|
3511
3346
|
* @return {boolean} */
|
|
3512
3347
|
hasWebGL() { return !!this.glTexture; }
|
|
3348
|
+
|
|
3349
|
+
/** Set the wrap mode for this texture
|
|
3350
|
+
* @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE */
|
|
3351
|
+
setWrap(wrap=true)
|
|
3352
|
+
{
|
|
3353
|
+
this.wrap = wrap;
|
|
3354
|
+
glSetTextureWrap(this.glTexture, wrap);
|
|
3355
|
+
}
|
|
3513
3356
|
}
|
|
3514
3357
|
|
|
3515
3358
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -3571,8 +3414,13 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
|
3571
3414
|
}
|
|
3572
3415
|
else
|
|
3573
3416
|
{
|
|
3574
|
-
// if no tile info, force untextured
|
|
3575
|
-
|
|
3417
|
+
// if no tile info, force untextured by zeroing rgba (so whatever
|
|
3418
|
+
// texture is bound doesn't leak in) and folding color+additive
|
|
3419
|
+
// into the additive slot — matches the Canvas2D path's
|
|
3420
|
+
// color.add(additiveColor) on line ~337.
|
|
3421
|
+
const combined = additiveColor ? color.add(additiveColor) : color;
|
|
3422
|
+
glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0,
|
|
3423
|
+
0, combined.rgbaInt());
|
|
3576
3424
|
}
|
|
3577
3425
|
}
|
|
3578
3426
|
else
|
|
@@ -3711,6 +3559,8 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
|
|
|
3711
3559
|
const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
|
|
3712
3560
|
ASSERT(textureInfo instanceof TextureInfo, 'texture not loaded');
|
|
3713
3561
|
ASSERT(textureInfo.size.x > 0, 'texture not loaded');
|
|
3562
|
+
ASSERT(textureInfo.wrap,
|
|
3563
|
+
'drawTextureWrapped requires a wrap-enabled texture; call textureInfo.setWrap(true) first');
|
|
3714
3564
|
|
|
3715
3565
|
if (useWebGL && glEnable)
|
|
3716
3566
|
{
|
|
@@ -4619,7 +4469,7 @@ const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
|
|
|
4619
4469
|
* This is useful to disable for html menus so the browser can handle input normally
|
|
4620
4470
|
* @param {boolean} preventDefault
|
|
4621
4471
|
* @memberof Input */
|
|
4622
|
-
function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
|
|
4472
|
+
function setInputPreventDefault(preventDefault=true) { inputPreventDefault = preventDefault; }
|
|
4623
4473
|
|
|
4624
4474
|
/** Clears an input key state
|
|
4625
4475
|
* @param {string|number} key
|
|
@@ -5415,10 +5265,12 @@ function touchGamepadButtonCenter()
|
|
|
5415
5265
|
* @namespace Audio
|
|
5416
5266
|
*/
|
|
5417
5267
|
|
|
5418
|
-
/** Audio context used by the engine
|
|
5268
|
+
/** Audio context used by the engine. Created lazily in audioInit() to avoid
|
|
5269
|
+
* browser autoplay warnings about constructing an AudioContext before any
|
|
5270
|
+
* user gesture.
|
|
5419
5271
|
* @type {AudioContext}
|
|
5420
5272
|
* @memberof Audio */
|
|
5421
|
-
let audioContext
|
|
5273
|
+
let audioContext;
|
|
5422
5274
|
|
|
5423
5275
|
/** Master gain node for all audio to pass through
|
|
5424
5276
|
* @type {GainNode}
|
|
@@ -5434,12 +5286,13 @@ const audioDefaultSampleRate = 44100;
|
|
|
5434
5286
|
* @return {boolean} - True if the audio context is running
|
|
5435
5287
|
* @memberof Audio */
|
|
5436
5288
|
function audioIsRunning()
|
|
5437
|
-
{ return audioContext
|
|
5289
|
+
{ return audioContext?.state === 'running'; }
|
|
5438
5290
|
|
|
5439
5291
|
function audioInit()
|
|
5440
5292
|
{
|
|
5441
5293
|
if (!soundEnable || headlessMode) return;
|
|
5442
5294
|
|
|
5295
|
+
audioContext = new AudioContext;
|
|
5443
5296
|
audioMasterGain = audioContext.createGain();
|
|
5444
5297
|
audioMasterGain.connect(audioContext.destination);
|
|
5445
5298
|
audioMasterGain.gain.value = soundVolume; // set starting value
|
|
@@ -5506,8 +5359,8 @@ class Sound
|
|
|
5506
5359
|
|
|
5507
5360
|
if (Array.isArray(asset))
|
|
5508
5361
|
{
|
|
5509
|
-
// generate zzfx sound
|
|
5510
|
-
const zzfxSound = asset;
|
|
5362
|
+
// generate zzfx sound — copy so we don't mutate the caller's array
|
|
5363
|
+
const zzfxSound = asset.slice();
|
|
5511
5364
|
|
|
5512
5365
|
// remove randomness so it can be applied on playback
|
|
5513
5366
|
const defaultRandomness = randomness ?? .05;
|
|
@@ -7105,11 +6958,14 @@ class ParticleEmitter extends EngineObject
|
|
|
7105
6958
|
}
|
|
7106
6959
|
|
|
7107
6960
|
///////////////////////////////////////////////////////////////////////////////
|
|
6961
|
+
// scratch vector reused by Particle.render to avoid per-frame allocations
|
|
6962
|
+
const particleDrawPos = new Vector2;
|
|
6963
|
+
|
|
7108
6964
|
/**
|
|
7109
6965
|
* Particle Object - Created automatically by Particle Emitters
|
|
7110
6966
|
* @memberof Particles
|
|
7111
6967
|
*/
|
|
7112
|
-
class Particle
|
|
6968
|
+
class Particle
|
|
7113
6969
|
{
|
|
7114
6970
|
/**
|
|
7115
6971
|
* Create a particle with the passed in settings
|
|
@@ -7284,14 +7140,14 @@ class Particle
|
|
|
7284
7140
|
additive && setBlendMode(true);
|
|
7285
7141
|
|
|
7286
7142
|
// update the position and angle for drawing
|
|
7287
|
-
const pos = this.pos.
|
|
7143
|
+
const pos = particleDrawPos.set(this.pos.x, this.pos.y);
|
|
7288
7144
|
let angle = this.angle;
|
|
7289
7145
|
if (localSpace)
|
|
7290
7146
|
{
|
|
7291
7147
|
// in local space of emitter
|
|
7292
7148
|
const a = emitter.angle;
|
|
7293
7149
|
const c = cos(a), s = sin(a);
|
|
7294
|
-
pos.set(emitter.pos.x + pos.x*c - pos.y*s,
|
|
7150
|
+
pos.set(emitter.pos.x + pos.x*c - pos.y*s,
|
|
7295
7151
|
emitter.pos.y + pos.x*s + pos.y*c);
|
|
7296
7152
|
angle += a;
|
|
7297
7153
|
}
|
|
@@ -7316,200 +7172,6 @@ class Particle
|
|
|
7316
7172
|
debugParticles && debugRect(pos, size, '#f005', 0, angle);
|
|
7317
7173
|
}
|
|
7318
7174
|
}
|
|
7319
|
-
/**
|
|
7320
|
-
* LittleJS Medal System
|
|
7321
|
-
* - Achievement/trophy system for games
|
|
7322
|
-
* - Medal class with name, description, icon, and unlock tracking
|
|
7323
|
-
* - Automatic saving to local storage
|
|
7324
|
-
* - Visual display queue with slide-in notifications
|
|
7325
|
-
* - Newgrounds API integration for online achievements
|
|
7326
|
-
* - Debug mode to unlock/reset medals during development
|
|
7327
|
-
* @namespace Medals
|
|
7328
|
-
*/
|
|
7329
|
-
|
|
7330
|
-
/** List of all medals
|
|
7331
|
-
* @type {Object}
|
|
7332
|
-
* @memberof Medals */
|
|
7333
|
-
const medals = {};
|
|
7334
|
-
|
|
7335
|
-
// Engine internal variables not exposed to documentation
|
|
7336
|
-
let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast;
|
|
7337
|
-
|
|
7338
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
7339
|
-
|
|
7340
|
-
/** Initialize medals with a save name used for storage
|
|
7341
|
-
* - Call this after creating all medals
|
|
7342
|
-
* - Checks if medals are unlocked
|
|
7343
|
-
* @param {string} saveName
|
|
7344
|
-
* @memberof Medals */
|
|
7345
|
-
function medalsInit(saveName)
|
|
7346
|
-
{
|
|
7347
|
-
// check if medals are unlocked
|
|
7348
|
-
medalsSaveName = saveName;
|
|
7349
|
-
if (!debugMedals)
|
|
7350
|
-
medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
|
|
7351
|
-
|
|
7352
|
-
// engine automatically renders medals
|
|
7353
|
-
engineAddPlugin(undefined, medalsRender);
|
|
7354
|
-
|
|
7355
|
-
// plugin functions
|
|
7356
|
-
function medalsRender()
|
|
7357
|
-
{
|
|
7358
|
-
if (!medalsDisplayQueue.length) return;
|
|
7359
|
-
|
|
7360
|
-
// update first medal in queue
|
|
7361
|
-
const medal = medalsDisplayQueue[0];
|
|
7362
|
-
const time = timeReal - medalsDisplayTimeLast;
|
|
7363
|
-
if (!medalsDisplayTimeLast)
|
|
7364
|
-
medalsDisplayTimeLast = timeReal;
|
|
7365
|
-
else if (time > medalDisplayTime)
|
|
7366
|
-
{
|
|
7367
|
-
medalsDisplayTimeLast = 0;
|
|
7368
|
-
medalsDisplayQueue.shift();
|
|
7369
|
-
}
|
|
7370
|
-
else
|
|
7371
|
-
{
|
|
7372
|
-
// slide on/off medals
|
|
7373
|
-
const slideOffTime = medalDisplayTime - medalDisplaySlideTime;
|
|
7374
|
-
const hidePercent =
|
|
7375
|
-
time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime :
|
|
7376
|
-
time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0;
|
|
7377
|
-
medal.render(hidePercent);
|
|
7378
|
-
}
|
|
7379
|
-
}
|
|
7380
|
-
}
|
|
7381
|
-
|
|
7382
|
-
/**
|
|
7383
|
-
* @callback MedalCallbackFunction - Function that processes a medal
|
|
7384
|
-
* @param {Medal} medal
|
|
7385
|
-
* @memberof Medals
|
|
7386
|
-
*/
|
|
7387
|
-
|
|
7388
|
-
/** Calls a function for each medal
|
|
7389
|
-
* @param {MedalCallbackFunction} callback
|
|
7390
|
-
* @memberof Medals */
|
|
7391
|
-
function medalsForEach(callback)
|
|
7392
|
-
{ Object.values(medals).forEach(medal=>callback(medal)); }
|
|
7393
|
-
|
|
7394
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
7395
|
-
|
|
7396
|
-
/**
|
|
7397
|
-
* Medal - Tracks an unlockable medal
|
|
7398
|
-
* @memberof Medals
|
|
7399
|
-
* @example
|
|
7400
|
-
* // create a medal
|
|
7401
|
-
* const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
|
|
7402
|
-
*
|
|
7403
|
-
* // initialize medals
|
|
7404
|
-
* medalsInit('Example Game');
|
|
7405
|
-
*
|
|
7406
|
-
* // unlock the medal
|
|
7407
|
-
* medal_example.unlock();
|
|
7408
|
-
*/
|
|
7409
|
-
class Medal
|
|
7410
|
-
{
|
|
7411
|
-
/** Create a medal object and adds it to the list of medals
|
|
7412
|
-
* @param {number} id - The unique identifier of the medal
|
|
7413
|
-
* @param {string} name - Name of the medal
|
|
7414
|
-
* @param {string} [description] - Description of the medal
|
|
7415
|
-
* @param {string} [icon] - Icon for the medal
|
|
7416
|
-
* @param {string} [src] - Image location for the medal
|
|
7417
|
-
*/
|
|
7418
|
-
constructor(id, name, description='', icon='🏆', src)
|
|
7419
|
-
{
|
|
7420
|
-
ASSERT(id >= 0 && !medals[id]);
|
|
7421
|
-
|
|
7422
|
-
/** @property {number} - The unique identifier of the medal */
|
|
7423
|
-
this.id = id;
|
|
7424
|
-
|
|
7425
|
-
/** @property {string} - Name of the medal */
|
|
7426
|
-
this.name = name;
|
|
7427
|
-
|
|
7428
|
-
/** @property {string} - Description of the medal */
|
|
7429
|
-
this.description = description;
|
|
7430
|
-
|
|
7431
|
-
/** @property {string} - Icon for the medal */
|
|
7432
|
-
this.icon = icon;
|
|
7433
|
-
|
|
7434
|
-
/** @property {boolean} - Is the medal unlocked? */
|
|
7435
|
-
this.unlocked = false;
|
|
7436
|
-
|
|
7437
|
-
// load the source image if provided
|
|
7438
|
-
if (src)
|
|
7439
|
-
(this.image = new Image).src = src;
|
|
7440
|
-
|
|
7441
|
-
// add this to list of medals
|
|
7442
|
-
medals[id] = this;
|
|
7443
|
-
}
|
|
7444
|
-
|
|
7445
|
-
/** Unlocks a medal if not already unlocked */
|
|
7446
|
-
unlock()
|
|
7447
|
-
{
|
|
7448
|
-
if (medalsPreventUnlock || this.unlocked) return;
|
|
7449
|
-
|
|
7450
|
-
// save the medal
|
|
7451
|
-
ASSERT(medalsSaveName, 'save name must be set');
|
|
7452
|
-
localStorage[this.storageKey()] = this.unlocked = true;
|
|
7453
|
-
medalsDisplayQueue.push(this);
|
|
7454
|
-
}
|
|
7455
|
-
|
|
7456
|
-
/** Render a medal
|
|
7457
|
-
* @param {number} [hidePercent] - How much to slide the medal off screen
|
|
7458
|
-
*/
|
|
7459
|
-
render(hidePercent=0)
|
|
7460
|
-
{
|
|
7461
|
-
const context = mainContext;
|
|
7462
|
-
const width = min(medalDisplaySize.x, mainCanvas.width);
|
|
7463
|
-
const height = medalDisplaySize.y;
|
|
7464
|
-
const x = mainCanvas.width - width;
|
|
7465
|
-
const y = -height*hidePercent;
|
|
7466
|
-
const backgroundColor = hsl(0,0,.9);
|
|
7467
|
-
|
|
7468
|
-
// draw containing rect and clip to that region
|
|
7469
|
-
context.save();
|
|
7470
|
-
context.beginPath();
|
|
7471
|
-
context.fillStyle = backgroundColor.toString();
|
|
7472
|
-
context.strokeStyle = BLACK.toString();
|
|
7473
|
-
context.lineWidth = 3;
|
|
7474
|
-
context.rect(x, y, width, height);
|
|
7475
|
-
context.fill();
|
|
7476
|
-
context.stroke();
|
|
7477
|
-
context.clip();
|
|
7478
|
-
|
|
7479
|
-
// draw the icon
|
|
7480
|
-
const gap = vec2(.1, .05).scale(height);
|
|
7481
|
-
const medalDisplayIconSize = height - 2*gap.x;
|
|
7482
|
-
this.renderIcon(vec2(x + gap.x + medalDisplayIconSize/2, y + height/2), medalDisplayIconSize);
|
|
7483
|
-
|
|
7484
|
-
// draw the name
|
|
7485
|
-
const nameSize = height*.5;
|
|
7486
|
-
const descriptionSize = height*.3;
|
|
7487
|
-
const pos = vec2(x + medalDisplayIconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
|
|
7488
|
-
const textWidth = width - medalDisplayIconSize - 3*gap.x;
|
|
7489
|
-
drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
|
|
7490
|
-
|
|
7491
|
-
// draw the description
|
|
7492
|
-
pos.y = y + height - gap.y*2 - descriptionSize/2;
|
|
7493
|
-
drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
|
|
7494
|
-
context.restore();
|
|
7495
|
-
}
|
|
7496
|
-
|
|
7497
|
-
/** Render the icon for a medal
|
|
7498
|
-
* @param {Vector2} pos - Screen space position
|
|
7499
|
-
* @param {number} size - Screen space size
|
|
7500
|
-
*/
|
|
7501
|
-
renderIcon(pos, size)
|
|
7502
|
-
{
|
|
7503
|
-
// draw the image or icon
|
|
7504
|
-
if (this.image)
|
|
7505
|
-
mainContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size);
|
|
7506
|
-
else
|
|
7507
|
-
drawTextScreen(this.icon, pos, size*.7, BLACK);
|
|
7508
|
-
}
|
|
7509
|
-
|
|
7510
|
-
// Get local storage key used by the medal
|
|
7511
|
-
storageKey() { return medalsSaveName + '_' + this.id; }
|
|
7512
|
-
}
|
|
7513
7175
|
/**
|
|
7514
7176
|
* LittleJS WebGL Interface
|
|
7515
7177
|
* - WebGL2 rendering engine for high-performance graphics
|
|
@@ -7609,7 +7271,7 @@ function glInit(rootElement)
|
|
|
7609
7271
|
// reinit WebGL and restore textures
|
|
7610
7272
|
initWebGL();
|
|
7611
7273
|
for (const info of glTextureInfos)
|
|
7612
|
-
info.glTexture = glCreateTexture(info.image);
|
|
7274
|
+
info.glTexture = glCreateTexture(info.image, info.wrap);
|
|
7613
7275
|
pluginList.forEach(plugin=>plugin.glContextRestored?.());
|
|
7614
7276
|
});
|
|
7615
7277
|
|
|
@@ -7825,6 +7487,30 @@ function glSetTexture(texture)
|
|
|
7825
7487
|
glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
|
|
7826
7488
|
}
|
|
7827
7489
|
|
|
7490
|
+
/** Set the wrap mode (REPEAT or CLAMP_TO_EDGE) on an existing WebGL texture
|
|
7491
|
+
* Flushes the current batch only if the texture is the active one
|
|
7492
|
+
* @param {WebGLTexture} texture
|
|
7493
|
+
* @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
|
|
7494
|
+
* @memberof WebGL */
|
|
7495
|
+
function glSetTextureWrap(texture, wrap=true)
|
|
7496
|
+
{
|
|
7497
|
+
if (!glContext || !texture) return;
|
|
7498
|
+
|
|
7499
|
+
// flush only if changing wrap on the currently bound texture
|
|
7500
|
+
const isCurrent = texture === glActiveTexture;
|
|
7501
|
+
if (isCurrent)
|
|
7502
|
+
glFlush();
|
|
7503
|
+
else
|
|
7504
|
+
glContext.bindTexture(glContext.TEXTURE_2D, texture);
|
|
7505
|
+
|
|
7506
|
+
const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
|
|
7507
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
|
|
7508
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
|
|
7509
|
+
|
|
7510
|
+
if (!isCurrent && glActiveTexture)
|
|
7511
|
+
glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
|
|
7512
|
+
}
|
|
7513
|
+
|
|
7828
7514
|
/** Compile WebGL shader of the given type, will throw errors if in debug mode
|
|
7829
7515
|
* @param {string} source
|
|
7830
7516
|
* @param {number} type
|
|
@@ -7869,9 +7555,10 @@ function glCreateProgram(vsSource, fsSource)
|
|
|
7869
7555
|
/** Create WebGL texture from an image and init the texture settings
|
|
7870
7556
|
* Restores the active texture when done
|
|
7871
7557
|
* @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas} [image]
|
|
7558
|
+
* @param {boolean} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE
|
|
7872
7559
|
* @return {WebGLTexture}
|
|
7873
7560
|
* @memberof WebGL */
|
|
7874
|
-
function glCreateTexture(image)
|
|
7561
|
+
function glCreateTexture(image, wrap=false)
|
|
7875
7562
|
{
|
|
7876
7563
|
if (!glContext) return;
|
|
7877
7564
|
|
|
@@ -7897,8 +7584,9 @@ function glCreateTexture(image)
|
|
|
7897
7584
|
const minFilter = mipMap ? glContext.LINEAR_MIPMAP_LINEAR : magFilter;
|
|
7898
7585
|
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, magFilter);
|
|
7899
7586
|
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, minFilter);
|
|
7900
|
-
|
|
7901
|
-
glContext.texParameteri(glContext.TEXTURE_2D, glContext.
|
|
7587
|
+
const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
|
|
7588
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
|
|
7589
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
|
|
7902
7590
|
if (mipMap)
|
|
7903
7591
|
glContext.generateMipmap(glContext.TEXTURE_2D);
|
|
7904
7592
|
|
|
@@ -7950,7 +7638,7 @@ function glRegisterTextureInfo(textureInfo)
|
|
|
7950
7638
|
if (textureInfo.glTexture)
|
|
7951
7639
|
glSetTextureData(textureInfo.glTexture, textureInfo.image);
|
|
7952
7640
|
else
|
|
7953
|
-
textureInfo.glTexture = glCreateTexture(textureInfo.image);
|
|
7641
|
+
textureInfo.glTexture = glCreateTexture(textureInfo.image, textureInfo.wrap);
|
|
7954
7642
|
}
|
|
7955
7643
|
|
|
7956
7644
|
/** Tells WebGL to destroy the glTexture and stop tracking it
|
|
@@ -8169,6 +7857,7 @@ function glSetRenderTarget(texture, clear=false)
|
|
|
8169
7857
|
glFlush();
|
|
8170
7858
|
glRenderTarget = undefined;
|
|
8171
7859
|
glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
|
|
7860
|
+
glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
|
|
8172
7861
|
}
|
|
8173
7862
|
}
|
|
8174
7863
|
|
|
@@ -8291,9 +7980,9 @@ function glPolyStrip(points)
|
|
|
8291
7980
|
return area;
|
|
8292
7981
|
}
|
|
8293
7982
|
|
|
8294
|
-
// ensure counter-clockwise winding
|
|
7983
|
+
// ensure counter-clockwise winding (slice first so we don't mutate caller's array)
|
|
8295
7984
|
if (signedArea(points) < 0)
|
|
8296
|
-
points = points.reverse();
|
|
7985
|
+
points = points.slice().reverse();
|
|
8297
7986
|
|
|
8298
7987
|
// check if point is inside triangle
|
|
8299
7988
|
const e = 1e-9;
|
|
@@ -8393,6 +8082,398 @@ function glPolyStrip(points)
|
|
|
8393
8082
|
}
|
|
8394
8083
|
return strip;
|
|
8395
8084
|
}
|
|
8085
|
+
/**
|
|
8086
|
+
* LittleJS Engine Logo
|
|
8087
|
+
* - Draws the LittleJS splash screen logo
|
|
8088
|
+
* - Used internally during engine startup
|
|
8089
|
+
*/
|
|
8090
|
+
|
|
8091
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
8092
|
+
function drawEngineLogo(t)
|
|
8093
|
+
{
|
|
8094
|
+
const blackAndWhite = 0;
|
|
8095
|
+
const showName = 1;
|
|
8096
|
+
|
|
8097
|
+
// LittleJS Logo and Splash Screen
|
|
8098
|
+
const x = mainContext;
|
|
8099
|
+
const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
|
|
8100
|
+
const w = mainCanvas.width = innerWidth * dpr;
|
|
8101
|
+
const h = mainCanvas.height = innerHeight * dpr;
|
|
8102
|
+
{
|
|
8103
|
+
// background
|
|
8104
|
+
const p3 = percent(t, 1, .8);
|
|
8105
|
+
const p4 = percent(t, 0, .5);
|
|
8106
|
+
const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,hypot(w,h)*.6);
|
|
8107
|
+
g.addColorStop(0,hsl(0,0,lerp(0,p3/2,p4),p3).toString());
|
|
8108
|
+
g.addColorStop(1,hsl(0,0,0,p3).toString());
|
|
8109
|
+
x.save();
|
|
8110
|
+
x.fillStyle = g;
|
|
8111
|
+
x.fillRect(0,0,w,h);
|
|
8112
|
+
}
|
|
8113
|
+
const gradient = (X1,Y1,X2,Y2,C,S=1)=>
|
|
8114
|
+
{
|
|
8115
|
+
if (C >= 0)
|
|
8116
|
+
{
|
|
8117
|
+
if (blackAndWhite)
|
|
8118
|
+
x.fillStyle = '#fff';
|
|
8119
|
+
else
|
|
8120
|
+
{
|
|
8121
|
+
const g = x.fillStyle = x.createLinearGradient(X1,Y1,X2,Y2);
|
|
8122
|
+
g.addColorStop(0,color(C,2));
|
|
8123
|
+
g.addColorStop(1,color(C,1));
|
|
8124
|
+
}
|
|
8125
|
+
}
|
|
8126
|
+
else
|
|
8127
|
+
x.fillStyle = '#000';
|
|
8128
|
+
C >= -1 ? (x.fill(), S && x.stroke()) : x.stroke();
|
|
8129
|
+
}
|
|
8130
|
+
const circle = (X,Y,R,A=0,B=2*PI,C,S)=>
|
|
8131
|
+
{
|
|
8132
|
+
x.beginPath();
|
|
8133
|
+
x.arc(X,Y,R,p*A,p*B);
|
|
8134
|
+
gradient(X,Y-R,X,Y+R,C,S);
|
|
8135
|
+
}
|
|
8136
|
+
const rect = (X,Y,W,H,C)=>
|
|
8137
|
+
{
|
|
8138
|
+
x.beginPath();
|
|
8139
|
+
x.rect(X,Y,W,H*p);
|
|
8140
|
+
gradient(X,Y+H,X+W,Y,C);
|
|
8141
|
+
}
|
|
8142
|
+
const poly = (points,C,Y,H)=>
|
|
8143
|
+
{
|
|
8144
|
+
x.beginPath();
|
|
8145
|
+
for (const p of points)
|
|
8146
|
+
x.lineTo(p.x, p.y);
|
|
8147
|
+
x.closePath();
|
|
8148
|
+
gradient(0, Y, 0, Y+H,C);
|
|
8149
|
+
}
|
|
8150
|
+
const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%`:'#000';
|
|
8151
|
+
|
|
8152
|
+
// center and fit to screen
|
|
8153
|
+
const alpha = oscillate(1,1,t);
|
|
8154
|
+
const p = percent(alpha, .1, .5);
|
|
8155
|
+
const size = min(6, min(w,h)/99);
|
|
8156
|
+
x.translate(w/2,h/2);
|
|
8157
|
+
x.scale(size,size);
|
|
8158
|
+
x.translate(-40,-35);
|
|
8159
|
+
p < 1 && x.setLineDash([99*p,99]);
|
|
8160
|
+
x.lineJoin = x.lineCap = 'round';
|
|
8161
|
+
x.lineWidth = .1 + p*1.9;
|
|
8162
|
+
//x.strokeStyle='#fff7';
|
|
8163
|
+
|
|
8164
|
+
if (showName)
|
|
8165
|
+
{
|
|
8166
|
+
// engine name text
|
|
8167
|
+
const Y = 54;
|
|
8168
|
+
const s = 'LittleJS';
|
|
8169
|
+
x.font = '900 15.5px arial';
|
|
8170
|
+
x.lineWidth = .1+p*3.9;
|
|
8171
|
+
x.textAlign = 'center';
|
|
8172
|
+
x.textBaseline = 'top';
|
|
8173
|
+
rect(11,Y+1,59,8*p,-1);
|
|
8174
|
+
x.beginPath();
|
|
8175
|
+
|
|
8176
|
+
let w2 = 0;
|
|
8177
|
+
for (let i=0;i<s.length;++i)
|
|
8178
|
+
w2 += x.measureText(s[i]).width;
|
|
8179
|
+
for (let j=2;j--;)
|
|
8180
|
+
for (let i=0,X=40-w2/2;i<s.length;++i)
|
|
8181
|
+
{
|
|
8182
|
+
const w = x.measureText(s[i]).width, X2 = X+w/2;
|
|
8183
|
+
gradient(X2,Y,X2+2,Y+13,i>5?1:0);
|
|
8184
|
+
x[j?'strokeText':'fillText'](s[i],X2,Y+.5,17*p);
|
|
8185
|
+
X += w;
|
|
8186
|
+
}
|
|
8187
|
+
|
|
8188
|
+
x.lineWidth = .1 + p*1.9;
|
|
8189
|
+
rect(3,Y,73,0); // bottom
|
|
8190
|
+
}
|
|
8191
|
+
|
|
8192
|
+
rect(7,15,26,-7,0); // cab top
|
|
8193
|
+
rect(25,15,8,25,-1); // cab front
|
|
8194
|
+
rect(10,40,15,-25,1); // cab back
|
|
8195
|
+
rect(14,21,7,9,2); // cab window
|
|
8196
|
+
rect(38,20,6,-6,2); // little stack
|
|
8197
|
+
|
|
8198
|
+
// big stack
|
|
8199
|
+
rect(49,20,10,-6,0);
|
|
8200
|
+
const stackPoints = [vec2(44,8),vec2(64,8),vec2(59,8+6*p),vec2(49,8+6*p)];
|
|
8201
|
+
poly(stackPoints,2,8,6*p);
|
|
8202
|
+
rect(44,8,20,-7,0);
|
|
8203
|
+
|
|
8204
|
+
// engine
|
|
8205
|
+
for (let i=5;i--;) circle(59-i*6*p,30,10,0,2*PI,1,0);
|
|
8206
|
+
circle(59,30,4,0,7,2); // light
|
|
8207
|
+
|
|
8208
|
+
// engine outline
|
|
8209
|
+
rect(35,20,24,0); // top
|
|
8210
|
+
circle(59,30,10); // front
|
|
8211
|
+
circle(47,30,10,PI/2,PI*3/2); // middle
|
|
8212
|
+
circle(35,30,10,PI/2,PI*3/2); // back
|
|
8213
|
+
rect(7,40,13,7,-1); // bottom back
|
|
8214
|
+
rect(17,40,43,14,-1); // bottom center
|
|
8215
|
+
|
|
8216
|
+
// wheels
|
|
8217
|
+
for (let i=3;i--;) for (let j=2;j--;) circle(17+15*i,47,j?7:1,0,2*PI,2);
|
|
8218
|
+
|
|
8219
|
+
// cowcatcher
|
|
8220
|
+
for (let i=2;i--;)
|
|
8221
|
+
{
|
|
8222
|
+
let w=6, s=7, o=53+w*p*i
|
|
8223
|
+
const points = [vec2(o+s,54),vec2(o,40),vec2(o+w*p,40),vec2(o+s+w*p,54)];
|
|
8224
|
+
poly(points,0,40,14);
|
|
8225
|
+
}
|
|
8226
|
+
|
|
8227
|
+
x.restore();
|
|
8228
|
+
}
|
|
8229
|
+
|
|
8230
|
+
/**
|
|
8231
|
+
* LittleJS Medal System
|
|
8232
|
+
* - Achievement/trophy system for games
|
|
8233
|
+
* - Medal class with name, description, icon, and unlock tracking
|
|
8234
|
+
* - Automatic saving to local storage
|
|
8235
|
+
* - Visual display queue with slide-in notifications
|
|
8236
|
+
* - Newgrounds API integration for online achievements
|
|
8237
|
+
* - Debug mode to unlock/reset medals during development
|
|
8238
|
+
* @namespace Medals
|
|
8239
|
+
*/
|
|
8240
|
+
|
|
8241
|
+
let debugMedals = false;
|
|
8242
|
+
|
|
8243
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
8244
|
+
// Medals settings
|
|
8245
|
+
|
|
8246
|
+
/** How long to show medals for in seconds
|
|
8247
|
+
* @type {number}
|
|
8248
|
+
* @default
|
|
8249
|
+
* @memberof Settings */
|
|
8250
|
+
let medalDisplayTime = 5;
|
|
8251
|
+
|
|
8252
|
+
/** How quickly to slide on/off medals in seconds
|
|
8253
|
+
* @type {number}
|
|
8254
|
+
* @default
|
|
8255
|
+
* @memberof Settings */
|
|
8256
|
+
let medalDisplaySlideTime = .5;
|
|
8257
|
+
|
|
8258
|
+
/** Size of medal display
|
|
8259
|
+
* @type {Vector2}
|
|
8260
|
+
* @default Vector2(640,80)
|
|
8261
|
+
* @memberof Settings */
|
|
8262
|
+
let medalDisplaySize = vec2(640, 80);
|
|
8263
|
+
|
|
8264
|
+
/** Set to stop medals from being unlockable (like if cheats are enabled)
|
|
8265
|
+
* @type {boolean}
|
|
8266
|
+
* @default
|
|
8267
|
+
* @memberof Settings */
|
|
8268
|
+
let medalsPreventUnlock = false;
|
|
8269
|
+
|
|
8270
|
+
/** List of all medals
|
|
8271
|
+
* @type {Object}
|
|
8272
|
+
* @memberof Medals */
|
|
8273
|
+
const medals = {};
|
|
8274
|
+
|
|
8275
|
+
// Engine internal variables not exposed to documentation
|
|
8276
|
+
let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast;
|
|
8277
|
+
|
|
8278
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
8279
|
+
|
|
8280
|
+
/** Initialize medals with a save name used for storage
|
|
8281
|
+
* - Call this after creating all medals
|
|
8282
|
+
* - Checks if medals are unlocked
|
|
8283
|
+
* @param {string} saveName
|
|
8284
|
+
* @memberof Medals */
|
|
8285
|
+
function medalsInit(saveName)
|
|
8286
|
+
{
|
|
8287
|
+
// check if medals are unlocked
|
|
8288
|
+
medalsSaveName = saveName;
|
|
8289
|
+
if (!debugMedals)
|
|
8290
|
+
medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
|
|
8291
|
+
|
|
8292
|
+
// engine automatically renders medals
|
|
8293
|
+
engineAddPlugin(undefined, medalsRender);
|
|
8294
|
+
|
|
8295
|
+
// plugin functions
|
|
8296
|
+
function medalsRender()
|
|
8297
|
+
{
|
|
8298
|
+
if (!medalsDisplayQueue.length) return;
|
|
8299
|
+
|
|
8300
|
+
// update first medal in queue
|
|
8301
|
+
const medal = medalsDisplayQueue[0];
|
|
8302
|
+
const time = timeReal - medalsDisplayTimeLast;
|
|
8303
|
+
if (!medalsDisplayTimeLast)
|
|
8304
|
+
medalsDisplayTimeLast = timeReal;
|
|
8305
|
+
else if (time > medalDisplayTime)
|
|
8306
|
+
{
|
|
8307
|
+
medalsDisplayTimeLast = 0;
|
|
8308
|
+
medalsDisplayQueue.shift();
|
|
8309
|
+
}
|
|
8310
|
+
else
|
|
8311
|
+
{
|
|
8312
|
+
// slide on/off medals
|
|
8313
|
+
const slideOffTime = medalDisplayTime - medalDisplaySlideTime;
|
|
8314
|
+
const hidePercent =
|
|
8315
|
+
time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime :
|
|
8316
|
+
time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0;
|
|
8317
|
+
medal.render(hidePercent);
|
|
8318
|
+
}
|
|
8319
|
+
}
|
|
8320
|
+
}
|
|
8321
|
+
|
|
8322
|
+
/**
|
|
8323
|
+
* @callback MedalCallbackFunction - Function that processes a medal
|
|
8324
|
+
* @param {Medal} medal
|
|
8325
|
+
* @memberof Medals
|
|
8326
|
+
*/
|
|
8327
|
+
|
|
8328
|
+
/** Calls a function for each medal
|
|
8329
|
+
* @param {MedalCallbackFunction} callback
|
|
8330
|
+
* @memberof Medals */
|
|
8331
|
+
function medalsForEach(callback)
|
|
8332
|
+
{ Object.values(medals).forEach(medal=>callback(medal)); }
|
|
8333
|
+
|
|
8334
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
8335
|
+
|
|
8336
|
+
/**
|
|
8337
|
+
* Medal - Tracks an unlockable medal
|
|
8338
|
+
* @memberof Medals
|
|
8339
|
+
* @example
|
|
8340
|
+
* // create a medal
|
|
8341
|
+
* const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
|
|
8342
|
+
*
|
|
8343
|
+
* // initialize medals
|
|
8344
|
+
* medalsInit('Example Game');
|
|
8345
|
+
*
|
|
8346
|
+
* // unlock the medal
|
|
8347
|
+
* medal_example.unlock();
|
|
8348
|
+
*/
|
|
8349
|
+
class Medal
|
|
8350
|
+
{
|
|
8351
|
+
/** Create a medal object and adds it to the list of medals
|
|
8352
|
+
* @param {number} id - The unique identifier of the medal
|
|
8353
|
+
* @param {string} name - Name of the medal
|
|
8354
|
+
* @param {string} [description] - Description of the medal
|
|
8355
|
+
* @param {string} [icon] - Icon for the medal
|
|
8356
|
+
* @param {string} [src] - Image location for the medal
|
|
8357
|
+
*/
|
|
8358
|
+
constructor(id, name, description='', icon='🏆', src)
|
|
8359
|
+
{
|
|
8360
|
+
ASSERT(id >= 0 && !medals[id]);
|
|
8361
|
+
|
|
8362
|
+
/** @property {number} - The unique identifier of the medal */
|
|
8363
|
+
this.id = id;
|
|
8364
|
+
|
|
8365
|
+
/** @property {string} - Name of the medal */
|
|
8366
|
+
this.name = name;
|
|
8367
|
+
|
|
8368
|
+
/** @property {string} - Description of the medal */
|
|
8369
|
+
this.description = description;
|
|
8370
|
+
|
|
8371
|
+
/** @property {string} - Icon for the medal */
|
|
8372
|
+
this.icon = icon;
|
|
8373
|
+
|
|
8374
|
+
/** @property {boolean} - Is the medal unlocked? */
|
|
8375
|
+
this.unlocked = false;
|
|
8376
|
+
|
|
8377
|
+
// load the source image if provided
|
|
8378
|
+
if (src)
|
|
8379
|
+
(this.image = new Image).src = src;
|
|
8380
|
+
|
|
8381
|
+
// add this to list of medals
|
|
8382
|
+
medals[id] = this;
|
|
8383
|
+
}
|
|
8384
|
+
|
|
8385
|
+
/** Unlocks a medal if not already unlocked */
|
|
8386
|
+
unlock()
|
|
8387
|
+
{
|
|
8388
|
+
if (medalsPreventUnlock || this.unlocked) return;
|
|
8389
|
+
|
|
8390
|
+
// save the medal
|
|
8391
|
+
ASSERT(medalsSaveName, 'save name must be set');
|
|
8392
|
+
localStorage[this.storageKey()] = this.unlocked = true;
|
|
8393
|
+
medalsDisplayQueue.push(this);
|
|
8394
|
+
}
|
|
8395
|
+
|
|
8396
|
+
/** Render a medal
|
|
8397
|
+
* @param {number} [hidePercent] - How much to slide the medal off screen
|
|
8398
|
+
*/
|
|
8399
|
+
render(hidePercent=0)
|
|
8400
|
+
{
|
|
8401
|
+
const context = mainContext;
|
|
8402
|
+
const width = min(medalDisplaySize.x, mainCanvas.width);
|
|
8403
|
+
const height = medalDisplaySize.y;
|
|
8404
|
+
const x = mainCanvas.width - width;
|
|
8405
|
+
const y = -height*hidePercent;
|
|
8406
|
+
const backgroundColor = hsl(0,0,.9);
|
|
8407
|
+
|
|
8408
|
+
// draw containing rect and clip to that region
|
|
8409
|
+
context.save();
|
|
8410
|
+
context.beginPath();
|
|
8411
|
+
context.fillStyle = backgroundColor.toString();
|
|
8412
|
+
context.strokeStyle = BLACK.toString();
|
|
8413
|
+
context.lineWidth = 3;
|
|
8414
|
+
context.rect(x, y, width, height);
|
|
8415
|
+
context.fill();
|
|
8416
|
+
context.stroke();
|
|
8417
|
+
context.clip();
|
|
8418
|
+
|
|
8419
|
+
// draw the icon
|
|
8420
|
+
const gap = vec2(.1, .05).scale(height);
|
|
8421
|
+
const medalDisplayIconSize = height - 2*gap.x;
|
|
8422
|
+
this.renderIcon(vec2(x + gap.x + medalDisplayIconSize/2, y + height/2), medalDisplayIconSize);
|
|
8423
|
+
|
|
8424
|
+
// draw the name
|
|
8425
|
+
const nameSize = height*.5;
|
|
8426
|
+
const descriptionSize = height*.3;
|
|
8427
|
+
const pos = vec2(x + medalDisplayIconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
|
|
8428
|
+
const textWidth = width - medalDisplayIconSize - 3*gap.x;
|
|
8429
|
+
drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
|
|
8430
|
+
|
|
8431
|
+
// draw the description
|
|
8432
|
+
pos.y = y + height - gap.y*2 - descriptionSize/2;
|
|
8433
|
+
drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
|
|
8434
|
+
context.restore();
|
|
8435
|
+
}
|
|
8436
|
+
|
|
8437
|
+
/** Render the icon for a medal
|
|
8438
|
+
* @param {Vector2} pos - Screen space position
|
|
8439
|
+
* @param {number} size - Screen space size
|
|
8440
|
+
*/
|
|
8441
|
+
renderIcon(pos, size)
|
|
8442
|
+
{
|
|
8443
|
+
// draw the image or icon
|
|
8444
|
+
if (this.image)
|
|
8445
|
+
mainContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size);
|
|
8446
|
+
else
|
|
8447
|
+
drawTextScreen(this.icon, pos, size*.7, BLACK);
|
|
8448
|
+
}
|
|
8449
|
+
|
|
8450
|
+
// Get local storage key used by the medal
|
|
8451
|
+
storageKey() { return medalsSaveName + '_' + this.id; }
|
|
8452
|
+
}
|
|
8453
|
+
|
|
8454
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
8455
|
+
// Medals setting setters
|
|
8456
|
+
|
|
8457
|
+
/** Set how long to show medals for in seconds
|
|
8458
|
+
* @param {number} time
|
|
8459
|
+
* @memberof Settings */
|
|
8460
|
+
function setMedalDisplayTime(time) { medalDisplayTime = time; }
|
|
8461
|
+
|
|
8462
|
+
/** Set how quickly to slide on/off medals in seconds
|
|
8463
|
+
* @param {number} time
|
|
8464
|
+
* @memberof Settings */
|
|
8465
|
+
function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
|
|
8466
|
+
|
|
8467
|
+
/** Set size of medal display
|
|
8468
|
+
* @param {Vector2} size
|
|
8469
|
+
* @memberof Settings */
|
|
8470
|
+
function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
|
|
8471
|
+
|
|
8472
|
+
/** Set to stop medals from being unlockable
|
|
8473
|
+
* @param {boolean} preventUnlock
|
|
8474
|
+
* @memberof Settings */
|
|
8475
|
+
function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }
|
|
8476
|
+
|
|
8396
8477
|
/**
|
|
8397
8478
|
* LittleJS Newgrounds Plugin
|
|
8398
8479
|
* - NewgroundsMedal extends Medal with Newgrounds API functionality
|
|
@@ -9009,16 +9090,13 @@ class UISystemPlugin
|
|
|
9009
9090
|
this.lastHoverObject = undefined;
|
|
9010
9091
|
/** @property {UIObject} - Current confirm menu being shown */
|
|
9011
9092
|
this.confirmDialog = undefined;
|
|
9012
|
-
/** @
|
|
9013
|
-
this.
|
|
9093
|
+
/** @private */
|
|
9094
|
+
this._keyInputObject = undefined;
|
|
9095
|
+
/** @private */
|
|
9096
|
+
this._onKeyDown = (e) => this._keyInputObject?.onKeyDown(e);
|
|
9014
9097
|
|
|
9015
9098
|
engineAddPlugin(uiUpdate, uiRender);
|
|
9016
9099
|
|
|
9017
|
-
// key down handler
|
|
9018
|
-
function onKeyDown(e)
|
|
9019
|
-
{ uiSystem.keyInputObject?.onKeyDown(e); }
|
|
9020
|
-
document.addEventListener('keydown', onKeyDown);
|
|
9021
|
-
|
|
9022
9100
|
// set object position in parent space
|
|
9023
9101
|
function updateTransforms(o)
|
|
9024
9102
|
{
|
|
@@ -9360,8 +9438,23 @@ class UISystemPlugin
|
|
|
9360
9438
|
return p;
|
|
9361
9439
|
}
|
|
9362
9440
|
|
|
9441
|
+
/** Object to send keyboard input to (typically a UITextInput).
|
|
9442
|
+
* The document keydown listener is only attached while this is set,
|
|
9443
|
+
* so games that never use text input pay no event-handling cost.
|
|
9444
|
+
* @type {UIObject} */
|
|
9445
|
+
get keyInputObject() { return this._keyInputObject; }
|
|
9446
|
+
set keyInputObject(obj)
|
|
9447
|
+
{
|
|
9448
|
+
const had = !!this._keyInputObject;
|
|
9449
|
+
this._keyInputObject = obj;
|
|
9450
|
+
if (!had && obj)
|
|
9451
|
+
document.addEventListener('keydown', this._onKeyDown);
|
|
9452
|
+
else if (had && !obj)
|
|
9453
|
+
document.removeEventListener('keydown', this._onKeyDown);
|
|
9454
|
+
}
|
|
9455
|
+
|
|
9363
9456
|
/** Destroy and remove all objects
|
|
9364
|
-
* @memberof
|
|
9457
|
+
* @memberof UISystem */
|
|
9365
9458
|
destroyObjects()
|
|
9366
9459
|
{
|
|
9367
9460
|
for (const o of this.uiObjects)
|
|
@@ -9784,15 +9877,13 @@ class UIObject
|
|
|
9784
9877
|
* @return {string} */
|
|
9785
9878
|
toString()
|
|
9786
9879
|
{
|
|
9787
|
-
if (!debug) return;
|
|
9788
|
-
|
|
9789
9880
|
let text = 'type = ' + this.constructor.name;
|
|
9790
9881
|
if (this.text)
|
|
9791
9882
|
text += '\ntext = ' + this.text;
|
|
9792
9883
|
if (this.pos.x || this.pos.y)
|
|
9793
9884
|
text += '\npos = ' + this.pos;
|
|
9794
9885
|
if (this.localPos.x || this.localPos.y)
|
|
9795
|
-
text += '\
|
|
9886
|
+
text += '\nlocalPos = ' + this.localPos;
|
|
9796
9887
|
if (this.size.x || this.size.y)
|
|
9797
9888
|
text += '\nsize = ' + this.size;
|
|
9798
9889
|
if (this.color)
|
|
@@ -10386,6 +10477,135 @@ class UIVideo extends UIObject
|
|
|
10386
10477
|
this.video.remove();
|
|
10387
10478
|
super.destroy();
|
|
10388
10479
|
}
|
|
10480
|
+
}
|
|
10481
|
+
|
|
10482
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
10483
|
+
/**
|
|
10484
|
+
* UILayout - A container that auto-arranges children in a vertical list, horizontal list, or grid
|
|
10485
|
+
* - Set columns to 1 for a vertical list (default)
|
|
10486
|
+
* - Set columns to children.length for a horizontal list
|
|
10487
|
+
* - Set columns to N (1 < N < children.length) for a grid with N columns
|
|
10488
|
+
* - Per-child sizing: each row's height = max child.size.y in that row, each column's width = max child.size.x in that column
|
|
10489
|
+
* - Children are positioned centered in their cell
|
|
10490
|
+
* - Container auto-sizes to fit children plus padding
|
|
10491
|
+
* @extends UIObject
|
|
10492
|
+
* @memberof UISystem
|
|
10493
|
+
*/
|
|
10494
|
+
class UILayout extends UIObject
|
|
10495
|
+
{
|
|
10496
|
+
/** Create a UILayout container that auto-arranges children
|
|
10497
|
+
* @param {Vector2} [pos]
|
|
10498
|
+
* @param {number} [columns=1] - Number of columns (1 = vertical list)
|
|
10499
|
+
* @param {number} [gap=10] - Space between children
|
|
10500
|
+
* @param {number} [padding=10] - Space between container border and children
|
|
10501
|
+
* @param {boolean} [transparent=false] - If true, draws no background, outline, or shadow
|
|
10502
|
+
*/
|
|
10503
|
+
constructor(pos, columns=1, gap=10, padding=10, transparent=false)
|
|
10504
|
+
{
|
|
10505
|
+
super(pos);
|
|
10506
|
+
|
|
10507
|
+
ASSERT(isNumber(columns) && columns >= 1, 'ui layout columns must be a number >= 1');
|
|
10508
|
+
ASSERT(isNumber(gap), 'ui layout gap must be a number');
|
|
10509
|
+
ASSERT(isNumber(padding), 'ui layout padding must be a number');
|
|
10510
|
+
|
|
10511
|
+
/** @property {number} - Number of columns in the layout */
|
|
10512
|
+
this.columns = columns;
|
|
10513
|
+
/** @property {number} - Space between children */
|
|
10514
|
+
this.gap = gap;
|
|
10515
|
+
/** @property {number} - Space between container border and children */
|
|
10516
|
+
this.padding = padding;
|
|
10517
|
+
|
|
10518
|
+
if (transparent)
|
|
10519
|
+
{
|
|
10520
|
+
// pure positioning helper - skip background, outline, and shadow
|
|
10521
|
+
this.color = CLEAR_BLACK;
|
|
10522
|
+
this.gradientColor = undefined;
|
|
10523
|
+
this.lineWidth = 0;
|
|
10524
|
+
this.shadowColor = CLEAR_BLACK;
|
|
10525
|
+
}
|
|
10526
|
+
this.relayout();
|
|
10527
|
+
}
|
|
10528
|
+
|
|
10529
|
+
/** Add a child UIObject and re-layout
|
|
10530
|
+
* @param {UIObject} child
|
|
10531
|
+
* @return {UIObject} The child object added */
|
|
10532
|
+
addChild(child)
|
|
10533
|
+
{
|
|
10534
|
+
super.addChild(child);
|
|
10535
|
+
this.relayout();
|
|
10536
|
+
return child;
|
|
10537
|
+
}
|
|
10538
|
+
|
|
10539
|
+
/** Remove a child UIObject and re-layout
|
|
10540
|
+
* @param {UIObject} child */
|
|
10541
|
+
removeChild(child)
|
|
10542
|
+
{
|
|
10543
|
+
super.removeChild(child);
|
|
10544
|
+
this.relayout();
|
|
10545
|
+
}
|
|
10546
|
+
|
|
10547
|
+
/** Recompute child positions and container size based on per-child sizes.
|
|
10548
|
+
* Called automatically by addChild and removeChild. Call manually if you
|
|
10549
|
+
* mutate a child's size or change columns, gap, or padding. */
|
|
10550
|
+
relayout()
|
|
10551
|
+
{
|
|
10552
|
+
const n = this.children.length;
|
|
10553
|
+
if (!n)
|
|
10554
|
+
{
|
|
10555
|
+
this.size = vec2(this.padding * 2);
|
|
10556
|
+
return;
|
|
10557
|
+
}
|
|
10558
|
+
|
|
10559
|
+
const cols = this.columns;
|
|
10560
|
+
const rows = ceil(n / cols);
|
|
10561
|
+
const colWidths = new Array(cols).fill(0);
|
|
10562
|
+
const rowHeights = new Array(rows).fill(0);
|
|
10563
|
+
|
|
10564
|
+
// first pass: compute column widths and row heights from child sizes
|
|
10565
|
+
for (let i = 0; i < n; ++i)
|
|
10566
|
+
{
|
|
10567
|
+
const col = i % cols;
|
|
10568
|
+
const row = floor(i / cols);
|
|
10569
|
+
const child = this.children[i];
|
|
10570
|
+
colWidths[col] = max(colWidths[col], child.size.x);
|
|
10571
|
+
rowHeights[row] = max(rowHeights[row], child.size.y);
|
|
10572
|
+
}
|
|
10573
|
+
|
|
10574
|
+
// total content size (sum of column widths/row heights plus gaps between them)
|
|
10575
|
+
let contentWidth = this.gap * (cols - 1);
|
|
10576
|
+
for (const w of colWidths) contentWidth += w;
|
|
10577
|
+
let contentHeight = this.gap * (rows - 1);
|
|
10578
|
+
for (const h of rowHeights) contentHeight += h;
|
|
10579
|
+
|
|
10580
|
+
// cumulative column/row offsets so positioning is O(n) not O(n^2)
|
|
10581
|
+
const colOffsets = new Array(cols);
|
|
10582
|
+
let xAcc = 0;
|
|
10583
|
+
for (let c = 0; c < cols; ++c)
|
|
10584
|
+
{
|
|
10585
|
+
colOffsets[c] = xAcc;
|
|
10586
|
+
xAcc += colWidths[c];
|
|
10587
|
+
}
|
|
10588
|
+
const rowOffsets = new Array(rows);
|
|
10589
|
+
let yAcc = 0;
|
|
10590
|
+
for (let r = 0; r < rows; ++r)
|
|
10591
|
+
{
|
|
10592
|
+
rowOffsets[r] = yAcc;
|
|
10593
|
+
yAcc += rowHeights[r];
|
|
10594
|
+
}
|
|
10595
|
+
|
|
10596
|
+
// second pass: position each child centered in its cell
|
|
10597
|
+
for (let i = 0; i < n; ++i)
|
|
10598
|
+
{
|
|
10599
|
+
const col = i % cols;
|
|
10600
|
+
const row = floor(i / cols);
|
|
10601
|
+
const x = -contentWidth/2 + colOffsets[col] + this.gap * col + colWidths[col] / 2;
|
|
10602
|
+
const y = -contentHeight/2 + rowOffsets[row] + this.gap * row + rowHeights[row] / 2;
|
|
10603
|
+
this.children[i].localPos = vec2(x, y);
|
|
10604
|
+
}
|
|
10605
|
+
|
|
10606
|
+
// container size = content + padding on all sides
|
|
10607
|
+
this.size = vec2(contentWidth + this.padding * 2, contentHeight + this.padding * 2);
|
|
10608
|
+
}
|
|
10389
10609
|
}
|
|
10390
10610
|
/**
|
|
10391
10611
|
* LittleJS Box2D Physics Plugin
|
|
@@ -10912,14 +11132,29 @@ class Box2dObject extends EngineObject
|
|
|
10912
11132
|
this.body.ApplyForce(box2d.vec2dTo(force), box2d.vec2dTo(pos));
|
|
10913
11133
|
}
|
|
10914
11134
|
|
|
10915
|
-
/** Apply acceleration to this object
|
|
11135
|
+
/** Apply acceleration to this object (changes velocity by acceleration,
|
|
11136
|
+
* mass-independent — matches EngineObject.applyAcceleration semantics).
|
|
11137
|
+
* Use applyImpulse if you want the mass-dependent velocity change
|
|
11138
|
+
* Δv = impulse / mass, or applyForce for a Newton-style sustained force.
|
|
10916
11139
|
* @param {Vector2} acceleration
|
|
10917
11140
|
* @param {Vector2} [pos] */
|
|
10918
11141
|
applyAcceleration(acceleration, pos)
|
|
10919
|
-
{
|
|
11142
|
+
{
|
|
10920
11143
|
pos ||= this.getCenterOfMass();
|
|
10921
11144
|
this.setAwake();
|
|
10922
|
-
|
|
11145
|
+
const impulse = acceleration.scale(this.getMass());
|
|
11146
|
+
this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
|
|
11147
|
+
}
|
|
11148
|
+
|
|
11149
|
+
/** Apply an instantaneous linear impulse. Changes velocity immediately by
|
|
11150
|
+
* impulse / mass (so heavier bodies move less for the same impulse).
|
|
11151
|
+
* @param {Vector2} impulse
|
|
11152
|
+
* @param {Vector2} [pos] */
|
|
11153
|
+
applyImpulse(impulse, pos)
|
|
11154
|
+
{
|
|
11155
|
+
pos ||= this.getCenterOfMass();
|
|
11156
|
+
this.setAwake();
|
|
11157
|
+
this.body.ApplyLinearImpulse(box2d.vec2dTo(impulse), box2d.vec2dTo(pos));
|
|
10923
11158
|
}
|
|
10924
11159
|
|
|
10925
11160
|
/** Apply torque to this object
|
|
@@ -10929,13 +11164,23 @@ class Box2dObject extends EngineObject
|
|
|
10929
11164
|
this.setAwake();
|
|
10930
11165
|
this.body.ApplyTorque(torque);
|
|
10931
11166
|
}
|
|
10932
|
-
|
|
10933
|
-
/** Apply angular acceleration to this object
|
|
11167
|
+
|
|
11168
|
+
/** Apply angular acceleration to this object (changes angular velocity by
|
|
11169
|
+
* acceleration, mass-independent — matches EngineObject semantics).
|
|
10934
11170
|
* @param {number} acceleration */
|
|
10935
11171
|
applyAngularAcceleration(acceleration)
|
|
10936
11172
|
{
|
|
10937
11173
|
this.setAwake();
|
|
10938
|
-
this.body.ApplyAngularImpulse(acceleration);
|
|
11174
|
+
this.body.ApplyAngularImpulse(acceleration * this.getInertia());
|
|
11175
|
+
}
|
|
11176
|
+
|
|
11177
|
+
/** Apply an instantaneous angular impulse. Changes angular velocity by
|
|
11178
|
+
* impulse / inertia immediately.
|
|
11179
|
+
* @param {number} impulse */
|
|
11180
|
+
applyAngularImpulse(impulse)
|
|
11181
|
+
{
|
|
11182
|
+
this.setAwake();
|
|
11183
|
+
this.body.ApplyAngularImpulse(impulse);
|
|
10939
11184
|
}
|
|
10940
11185
|
|
|
10941
11186
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -11181,7 +11426,7 @@ class Box2dJoint
|
|
|
11181
11426
|
|
|
11182
11427
|
/** Check if the connected bodies should collide
|
|
11183
11428
|
* @return {boolean} */
|
|
11184
|
-
getCollideConnected() { return this.box2dJoint.
|
|
11429
|
+
getCollideConnected() { return this.box2dJoint.GetCollideConnected();}
|
|
11185
11430
|
|
|
11186
11431
|
/** Check if either connected body is active
|
|
11187
11432
|
* @return {boolean} */
|
|
@@ -12794,13 +13039,13 @@ const Ease =
|
|
|
12794
13039
|
* @param {number} x
|
|
12795
13040
|
* @returns {number}
|
|
12796
13041
|
* @memberof TweenSystem */
|
|
12797
|
-
SINE: (x) => 1 -
|
|
13042
|
+
SINE: (x) => 1 - cos(x * (PI / 2)),
|
|
12798
13043
|
|
|
12799
13044
|
/** Circular ease-in curve.
|
|
12800
13045
|
* @param {number} x
|
|
12801
13046
|
* @returns {number}
|
|
12802
13047
|
* @memberof TweenSystem */
|
|
12803
|
-
CIRC: (x) => 1 -
|
|
13048
|
+
CIRC: (x) => 1 - (1 - x * x)**.5,
|
|
12804
13049
|
|
|
12805
13050
|
/** Exponential ease-in curve (`2^(10x-10)`).
|
|
12806
13051
|
* @param {number} x
|
|
@@ -12819,7 +13064,7 @@ const Ease =
|
|
|
12819
13064
|
* @returns {number}
|
|
12820
13065
|
* @memberof TweenSystem */
|
|
12821
13066
|
ELASTIC: (x) =>
|
|
12822
|
-
-(2 ** (10 * x - 10)) *
|
|
13067
|
+
-(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
|
|
12823
13068
|
|
|
12824
13069
|
/** Spring-like ease-out: oscillates outward after passing the target.
|
|
12825
13070
|
* @param {number} x
|
|
@@ -12827,8 +13072,8 @@ const Ease =
|
|
|
12827
13072
|
* @memberof TweenSystem */
|
|
12828
13073
|
SPRING: (x) =>
|
|
12829
13074
|
1 -
|
|
12830
|
-
(
|
|
12831
|
-
|
|
13075
|
+
(sin(PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
|
|
13076
|
+
x ** 2.2 +
|
|
12832
13077
|
(1 - x)) *
|
|
12833
13078
|
(1.0 + 1.2 * x),
|
|
12834
13079
|
|
|
@@ -12932,7 +13177,7 @@ const Ease =
|
|
|
12932
13177
|
{
|
|
12933
13178
|
const tMid = (t0 + t1) / 2;
|
|
12934
13179
|
const [bx, by] = curve(tMid);
|
|
12935
|
-
if (
|
|
13180
|
+
if (abs(bx - x) < 1e-5) return by;
|
|
12936
13181
|
if (bx < x) t0 = tMid; else t1 = tMid;
|
|
12937
13182
|
}
|
|
12938
13183
|
return curve((t0 + t1) / 2)[1];
|
|
@@ -13064,3 +13309,760 @@ function tweenStopAll()
|
|
|
13064
13309
|
// Register with the engine so tweens auto-advance.
|
|
13065
13310
|
engineAddPlugin(tweenUpdate);
|
|
13066
13311
|
|
|
13312
|
+
/**
|
|
13313
|
+
* LittleJS PathFinder Plugin
|
|
13314
|
+
* - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths
|
|
13315
|
+
* - Works directly on a TileCollisionLayer, or override isWalkable/getCost for any grid
|
|
13316
|
+
* - Debug visualization via engine debug primitives (stripped in release builds)
|
|
13317
|
+
* - Port of frankforce.com pathFindingBase.cpp (2018)
|
|
13318
|
+
* @namespace PathFinding
|
|
13319
|
+
*/
|
|
13320
|
+
|
|
13321
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
13322
|
+
|
|
13323
|
+
// Diagonal step cost — pre-computed for the A* expansion inner loop.
|
|
13324
|
+
const PATHFINDER_DIAGONAL_COST = Math.SQRT2;
|
|
13325
|
+
|
|
13326
|
+
// Shared 1x1 size vector for per-tile debugRect calls. debugRect copies the
|
|
13327
|
+
// argument internally, so reusing one instance is safe.
|
|
13328
|
+
const PATHFINDER_TILE_VEC = vec2(1);
|
|
13329
|
+
|
|
13330
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
13331
|
+
|
|
13332
|
+
/** A single grid cell tracked by the pathfinder. Allocated once per cell at
|
|
13333
|
+
* PathFinder construction; reset (not reallocated) at the start of every
|
|
13334
|
+
* findPath call.
|
|
13335
|
+
* @memberof PathFinding */
|
|
13336
|
+
class PathFinderNode
|
|
13337
|
+
{
|
|
13338
|
+
/** @param {number} x - Tile x
|
|
13339
|
+
* @param {number} y - Tile y */
|
|
13340
|
+
constructor(x, y)
|
|
13341
|
+
{
|
|
13342
|
+
/** @property {Vector2} - Tile coords (integer) */
|
|
13343
|
+
this.pos = vec2(x, y);
|
|
13344
|
+
/** @property {Vector2} - World-space center of this tile (set by buildNodeData) */
|
|
13345
|
+
this.posWorld = vec2();
|
|
13346
|
+
/** @property {boolean} - True if this cell is passable (cleared each findPath call) */
|
|
13347
|
+
this.walkable = false;
|
|
13348
|
+
/** @property {number} - Extra cost added to A* G-score for stepping on this cell */
|
|
13349
|
+
this.cost = 0;
|
|
13350
|
+
/** @property {number} - A* G-score: actual cost from start to this node */
|
|
13351
|
+
this.g = 0;
|
|
13352
|
+
/** @property {number} - A* F-score: G + heuristic */
|
|
13353
|
+
this.f = 0;
|
|
13354
|
+
/** @property {PathFinderNode|null} - Parent for path reconstruction */
|
|
13355
|
+
this.parent = null;
|
|
13356
|
+
/** @property {boolean} - In the A* open list */
|
|
13357
|
+
this.isOpen = false;
|
|
13358
|
+
/** @property {boolean} - In the A* closed list */
|
|
13359
|
+
this.isClosed = false;
|
|
13360
|
+
}
|
|
13361
|
+
|
|
13362
|
+
/** Reset per-search state (called at the start of buildNodeData). */
|
|
13363
|
+
reset()
|
|
13364
|
+
{
|
|
13365
|
+
this.walkable = false;
|
|
13366
|
+
this.cost = 0;
|
|
13367
|
+
this.g = 0;
|
|
13368
|
+
this.f = 0;
|
|
13369
|
+
this.parent = null;
|
|
13370
|
+
this.isOpen = false;
|
|
13371
|
+
this.isClosed = false;
|
|
13372
|
+
}
|
|
13373
|
+
|
|
13374
|
+
/** True if walkable and not blocked by cost. */
|
|
13375
|
+
isClear()
|
|
13376
|
+
{
|
|
13377
|
+
return this.walkable && this.cost === 0;
|
|
13378
|
+
}
|
|
13379
|
+
}
|
|
13380
|
+
|
|
13381
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
13382
|
+
|
|
13383
|
+
/** Grid pathfinder using A* with two optional smoothing passes.
|
|
13384
|
+
* @memberof PathFinding
|
|
13385
|
+
* @example
|
|
13386
|
+
* // Tile-layer driven (most common):
|
|
13387
|
+
* const pf = new PathFinder(myTileCollisionLayer);
|
|
13388
|
+
* const path = pf.findPath(player.pos, mousePos);
|
|
13389
|
+
*
|
|
13390
|
+
* // Bare grid with custom walkability:
|
|
13391
|
+
* const pf = new PathFinder(vec2(50, 50));
|
|
13392
|
+
* pf.isWalkable = (x, y) => myGrid[y*50 + x] === 0;
|
|
13393
|
+
*/
|
|
13394
|
+
class PathFinder
|
|
13395
|
+
{
|
|
13396
|
+
/** @param {TileCollisionLayer|Vector2} source - Either a TileCollisionLayer
|
|
13397
|
+
* (size and walkability auto-derived) or a Vector2 grid size (user
|
|
13398
|
+
* overrides isWalkable). */
|
|
13399
|
+
constructor(source)
|
|
13400
|
+
{
|
|
13401
|
+
// Accept either a Vector2 size or a TileCollisionLayer (which has a .size).
|
|
13402
|
+
// We don't import TileCollisionLayer to avoid coupling; we duck-type on
|
|
13403
|
+
// .size + .getCollisionData.
|
|
13404
|
+
if (isVector2(source))
|
|
13405
|
+
{
|
|
13406
|
+
this.size = source.floor();
|
|
13407
|
+
this.tileLayer = undefined;
|
|
13408
|
+
}
|
|
13409
|
+
else
|
|
13410
|
+
{
|
|
13411
|
+
ASSERT(source && isVector2(source.size) && typeof source.getCollisionData === 'function',
|
|
13412
|
+
'PathFinder requires a Vector2 size or a TileCollisionLayer');
|
|
13413
|
+
this.size = source.size;
|
|
13414
|
+
this.tileLayer = source;
|
|
13415
|
+
}
|
|
13416
|
+
|
|
13417
|
+
// Tunables (public, freely re-assignable).
|
|
13418
|
+
this.heuristicWeight = 1;
|
|
13419
|
+
this.maxLoop = 500;
|
|
13420
|
+
this.smoothPath = true;
|
|
13421
|
+
this.debug = false;
|
|
13422
|
+
this.debugTime = 2;
|
|
13423
|
+
|
|
13424
|
+
// Pre-allocate the node array — one node per tile, reused across calls.
|
|
13425
|
+
this.nodes = new Array(this.size.x * this.size.y);
|
|
13426
|
+
for (let y = 0; y < this.size.y; ++y)
|
|
13427
|
+
for (let x = 0; x < this.size.x; ++x)
|
|
13428
|
+
this.nodes[x + y * this.size.x] = new PathFinderNode(x, y);
|
|
13429
|
+
|
|
13430
|
+
// Scratch Vector2 reused to avoid allocations in the isWalkable hot path.
|
|
13431
|
+
this.collisionScratch = vec2();
|
|
13432
|
+
}
|
|
13433
|
+
|
|
13434
|
+
/** Default walkability: if a tile layer was provided, returns true when the
|
|
13435
|
+
* cell has no solid collision data; otherwise returns true. Override on
|
|
13436
|
+
* the instance or via a subclass.
|
|
13437
|
+
* @param {number} x - Tile x
|
|
13438
|
+
* @param {number} y - Tile y
|
|
13439
|
+
* @returns {boolean} */
|
|
13440
|
+
isWalkable(x, y)
|
|
13441
|
+
{
|
|
13442
|
+
if (!this.tileLayer) return true;
|
|
13443
|
+
return !this.tileLayer.getCollisionData(this.collisionScratch.set(x, y));
|
|
13444
|
+
}
|
|
13445
|
+
|
|
13446
|
+
/** Default extra cost for stepping on a cell. Returns 0 (free) by default.
|
|
13447
|
+
* Override to add cost-weighted terrain (mud, swamp, etc).
|
|
13448
|
+
* @param {number} x - Tile x
|
|
13449
|
+
* @param {number} y - Tile y
|
|
13450
|
+
* @returns {number} */
|
|
13451
|
+
getCost(x, y)
|
|
13452
|
+
{
|
|
13453
|
+
return 0;
|
|
13454
|
+
}
|
|
13455
|
+
|
|
13456
|
+
/** Get the node at tile coords, or null if out of bounds.
|
|
13457
|
+
* @param {number} x
|
|
13458
|
+
* @param {number} y
|
|
13459
|
+
* @returns {PathFinderNode|null} */
|
|
13460
|
+
getNode(x, y)
|
|
13461
|
+
{
|
|
13462
|
+
if (x < 0 || y < 0 || x >= this.size.x || y >= this.size.y) return null;
|
|
13463
|
+
return this.nodes[x + y * this.size.x];
|
|
13464
|
+
}
|
|
13465
|
+
|
|
13466
|
+
/** Convert a world-space position to integer tile coords (no clamping).
|
|
13467
|
+
* @param {Vector2} worldPos
|
|
13468
|
+
* @returns {Vector2}
|
|
13469
|
+
* @memberof PathFinding */
|
|
13470
|
+
worldToTile(worldPos)
|
|
13471
|
+
{
|
|
13472
|
+
const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
|
|
13473
|
+
const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
|
|
13474
|
+
return vec2(floor(worldPos.x - ox), floor(worldPos.y - oy));
|
|
13475
|
+
}
|
|
13476
|
+
|
|
13477
|
+
/** Convert integer tile coords to the world-space center of that tile.
|
|
13478
|
+
* @param {number} x
|
|
13479
|
+
* @param {number} y
|
|
13480
|
+
* @returns {Vector2}
|
|
13481
|
+
* @memberof PathFinding */
|
|
13482
|
+
tileToWorld(x, y)
|
|
13483
|
+
{
|
|
13484
|
+
const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
|
|
13485
|
+
const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
|
|
13486
|
+
return vec2(x + 0.5 + ox, y + 0.5 + oy);
|
|
13487
|
+
}
|
|
13488
|
+
|
|
13489
|
+
/** Reset all nodes and re-populate walkable / cost / posWorld from the
|
|
13490
|
+
* current isWalkable / getCost overrides. Called at the start of
|
|
13491
|
+
* findPath; exposed so tests and tooling can drive it directly.
|
|
13492
|
+
* @private */
|
|
13493
|
+
buildNodeData()
|
|
13494
|
+
{
|
|
13495
|
+
const w = this.size.x;
|
|
13496
|
+
const h = this.size.y;
|
|
13497
|
+
const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
|
|
13498
|
+
const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
|
|
13499
|
+
for (let y = 0; y < h; ++y)
|
|
13500
|
+
for (let x = 0; x < w; ++x)
|
|
13501
|
+
{
|
|
13502
|
+
const node = this.nodes[x + y * w];
|
|
13503
|
+
node.reset();
|
|
13504
|
+
const walkable = !!this.isWalkable(x, y);
|
|
13505
|
+
const cost = walkable ? max(0, this.getCost(x, y)) : 0;
|
|
13506
|
+
node.walkable = walkable;
|
|
13507
|
+
node.cost = cost;
|
|
13508
|
+
node.posWorld.set(x + 0.5 + ox, y + 0.5 + oy);
|
|
13509
|
+
|
|
13510
|
+
if (this.debug && this.debugTime > 0)
|
|
13511
|
+
{
|
|
13512
|
+
if (!walkable)
|
|
13513
|
+
debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, 0.25), this.debugTime);
|
|
13514
|
+
else if (cost > 0)
|
|
13515
|
+
debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, min(0.2, cost * 0.05)), this.debugTime);
|
|
13516
|
+
}
|
|
13517
|
+
}
|
|
13518
|
+
}
|
|
13519
|
+
|
|
13520
|
+
/** Core A* search loop. Expects buildNodeData() to have been called first.
|
|
13521
|
+
* Marks node.parent for path reconstruction. Returns true if endNode was
|
|
13522
|
+
* reached; false on disconnected goal or maxLoop exhaustion.
|
|
13523
|
+
* @param {PathFinderNode} startNode
|
|
13524
|
+
* @param {PathFinderNode} endNode
|
|
13525
|
+
* @returns {boolean}
|
|
13526
|
+
* @private */
|
|
13527
|
+
aStarSearch(startNode, endNode)
|
|
13528
|
+
{
|
|
13529
|
+
ASSERT(startNode && endNode, 'aStarSearch needs both endpoints');
|
|
13530
|
+
ASSERT(startNode !== endNode, 'aStarSearch: start and end must differ — caller should handle trivial case');
|
|
13531
|
+
ASSERT(startNode.walkable && endNode.walkable, 'aStarSearch: endpoints must be walkable');
|
|
13532
|
+
|
|
13533
|
+
const openList = [startNode];
|
|
13534
|
+
startNode.isOpen = true;
|
|
13535
|
+
let loopCount = 0;
|
|
13536
|
+
|
|
13537
|
+
while (openList.length > 0)
|
|
13538
|
+
{
|
|
13539
|
+
// Find the open node with the smallest f score (linear scan).
|
|
13540
|
+
// Same as the C++ — fine up to a few thousand nodes.
|
|
13541
|
+
let bestIndex = 0;
|
|
13542
|
+
let bestF = openList[0].f;
|
|
13543
|
+
for (let i = 1; i < openList.length; ++i)
|
|
13544
|
+
{
|
|
13545
|
+
if (openList[i].f < bestF)
|
|
13546
|
+
{
|
|
13547
|
+
bestF = openList[i].f;
|
|
13548
|
+
bestIndex = i;
|
|
13549
|
+
}
|
|
13550
|
+
}
|
|
13551
|
+
const current = openList[bestIndex];
|
|
13552
|
+
|
|
13553
|
+
if (current === endNode) break;
|
|
13554
|
+
if (++loopCount > this.maxLoop) break;
|
|
13555
|
+
|
|
13556
|
+
// Move current from open to closed.
|
|
13557
|
+
current.isOpen = false;
|
|
13558
|
+
openList.splice(bestIndex, 1);
|
|
13559
|
+
current.isClosed = true;
|
|
13560
|
+
|
|
13561
|
+
if (this.debug && this.debugTime > 0)
|
|
13562
|
+
debugRect(current.posWorld, PATHFINDER_TILE_VEC, rgb(1, 1, 1, 0.05), this.debugTime);
|
|
13563
|
+
|
|
13564
|
+
// Expand all 8 neighbors.
|
|
13565
|
+
for (let dy = -1; dy <= 1; ++dy)
|
|
13566
|
+
for (let dx = -1; dx <= 1; ++dx)
|
|
13567
|
+
{
|
|
13568
|
+
if (dx === 0 && dy === 0) continue;
|
|
13569
|
+
const neighbor = this.getNode(current.pos.x + dx, current.pos.y + dy);
|
|
13570
|
+
if (!neighbor || !neighbor.walkable || neighbor.isClosed) continue;
|
|
13571
|
+
|
|
13572
|
+
let stepCost = 1;
|
|
13573
|
+
if (dx !== 0 && dy !== 0)
|
|
13574
|
+
{
|
|
13575
|
+
// Diagonal step: refuse if either cardinal neighbor is
|
|
13576
|
+
// blocked or has cost. Prevents cutting through corners.
|
|
13577
|
+
const card1 = this.getNode(current.pos.x + dx, current.pos.y);
|
|
13578
|
+
if (!card1 || card1.cost > 0 || !card1.walkable) continue;
|
|
13579
|
+
const card2 = this.getNode(current.pos.x, current.pos.y + dy);
|
|
13580
|
+
if (!card2 || card2.cost > 0 || !card2.walkable) continue;
|
|
13581
|
+
stepCost = PATHFINDER_DIAGONAL_COST;
|
|
13582
|
+
}
|
|
13583
|
+
|
|
13584
|
+
const tentativeG = current.g + stepCost + neighbor.cost;
|
|
13585
|
+
if (!neighbor.isOpen)
|
|
13586
|
+
{
|
|
13587
|
+
neighbor.isOpen = true;
|
|
13588
|
+
openList.push(neighbor);
|
|
13589
|
+
}
|
|
13590
|
+
else if (tentativeG >= neighbor.g)
|
|
13591
|
+
{
|
|
13592
|
+
continue;
|
|
13593
|
+
}
|
|
13594
|
+
|
|
13595
|
+
// Best path so far through neighbor — record it.
|
|
13596
|
+
neighbor.parent = current;
|
|
13597
|
+
neighbor.g = tentativeG;
|
|
13598
|
+
const gdx = endNode.pos.x - neighbor.pos.x;
|
|
13599
|
+
const gdy = endNode.pos.y - neighbor.pos.y;
|
|
13600
|
+
neighbor.f = neighbor.g + (gdx * gdx + gdy * gdy) * this.heuristicWeight;
|
|
13601
|
+
}
|
|
13602
|
+
}
|
|
13603
|
+
|
|
13604
|
+
return endNode.parent !== null;
|
|
13605
|
+
}
|
|
13606
|
+
|
|
13607
|
+
/** Find the clear (walkable, zero-cost) node closest to the given world
|
|
13608
|
+
* position. Spirals outward in expanding boxes until a clear node is
|
|
13609
|
+
* found or the search range is exhausted. Useful for snapping a click
|
|
13610
|
+
* or NPC spawn position to the nearest open tile.
|
|
13611
|
+
*
|
|
13612
|
+
* By default, calls `buildNodeData()` first so it works correctly on a
|
|
13613
|
+
* fresh PathFinder. If you're calling it many times in a row with
|
|
13614
|
+
* unchanged walkability, pass `rebuild=false` and call `buildNodeData()`
|
|
13615
|
+
* once externally to avoid redundant work.
|
|
13616
|
+
* @param {Vector2} worldPos
|
|
13617
|
+
* @param {number} [searchRange=10] - Max box-radius in tiles
|
|
13618
|
+
* @param {boolean} [rebuild=true] - Whether to call buildNodeData first
|
|
13619
|
+
* @returns {PathFinderNode|null}
|
|
13620
|
+
* @memberof PathFinding */
|
|
13621
|
+
getNearestClearNode(worldPos, searchRange = 10, rebuild = true)
|
|
13622
|
+
{
|
|
13623
|
+
ASSERT(isVector2(worldPos), 'worldPos must be a Vector2');
|
|
13624
|
+
if (rebuild) this.buildNodeData();
|
|
13625
|
+
|
|
13626
|
+
// Inline worldToTile to avoid a Vector2 allocation per call.
|
|
13627
|
+
const ox = this.tileLayer ? this.tileLayer.pos.x : 0;
|
|
13628
|
+
const oy = this.tileLayer ? this.tileLayer.pos.y : 0;
|
|
13629
|
+
const centerX = floor(worldPos.x - ox);
|
|
13630
|
+
const centerY = floor(worldPos.y - oy);
|
|
13631
|
+
|
|
13632
|
+
for (let offset = 0; offset <= searchRange; ++offset)
|
|
13633
|
+
{
|
|
13634
|
+
let nearest = null;
|
|
13635
|
+
let nearestDistSq = 0;
|
|
13636
|
+
|
|
13637
|
+
for (let dy = -offset; dy <= offset; ++dy)
|
|
13638
|
+
for (let dx = -offset; dx <= offset; ++dx)
|
|
13639
|
+
{
|
|
13640
|
+
// Only scan the perimeter of the current ring (skip the
|
|
13641
|
+
// interior we've already searched in earlier iterations).
|
|
13642
|
+
if (offset > 0 && abs(dx) !== offset && abs(dy) !== offset)
|
|
13643
|
+
continue;
|
|
13644
|
+
|
|
13645
|
+
const node = this.getNode(centerX + dx, centerY + dy);
|
|
13646
|
+
if (!node || !node.isClear()) continue;
|
|
13647
|
+
|
|
13648
|
+
const ddx = node.posWorld.x - worldPos.x;
|
|
13649
|
+
const ddy = node.posWorld.y - worldPos.y;
|
|
13650
|
+
const distSq = ddx * ddx + ddy * ddy;
|
|
13651
|
+
if (!nearest || distSq < nearestDistSq)
|
|
13652
|
+
{
|
|
13653
|
+
nearest = node;
|
|
13654
|
+
nearestDistSq = distSq;
|
|
13655
|
+
}
|
|
13656
|
+
}
|
|
13657
|
+
if (nearest) return nearest;
|
|
13658
|
+
}
|
|
13659
|
+
return null;
|
|
13660
|
+
}
|
|
13661
|
+
|
|
13662
|
+
/** Smooth a node path by removing redundant turns and tightening corners
|
|
13663
|
+
* where a grid-aligned diagonal is clear. Modifies the path in place.
|
|
13664
|
+
* Stays on the grid — does not introduce off-tile-center points.
|
|
13665
|
+
* Port of ShortenPath() in pathFinding.cpp.
|
|
13666
|
+
* @param {PathFinderNode[]} path
|
|
13667
|
+
* @private */
|
|
13668
|
+
smoothPathCorners(path)
|
|
13669
|
+
{
|
|
13670
|
+
if (path.length <= 2) return;
|
|
13671
|
+
|
|
13672
|
+
let i = 1;
|
|
13673
|
+
while (i < path.length - 1)
|
|
13674
|
+
{
|
|
13675
|
+
const prev = path[i - 1];
|
|
13676
|
+
const node = path[i];
|
|
13677
|
+
const next = path[i + 1];
|
|
13678
|
+
|
|
13679
|
+
const dx = next.pos.x - prev.pos.x;
|
|
13680
|
+
const dy = next.pos.y - prev.pos.y;
|
|
13681
|
+
const lenSq = dx * dx + dy * dy;
|
|
13682
|
+
|
|
13683
|
+
// dx,dy is the prev-to-current step direction; needed for the
|
|
13684
|
+
// 135° "mostly vertical/horizontal" disambiguation.
|
|
13685
|
+
const stepDx = node.pos.x - prev.pos.x;
|
|
13686
|
+
const stepDy = node.pos.y - prev.pos.y;
|
|
13687
|
+
const stepDxNext = next.pos.x - node.pos.x;
|
|
13688
|
+
const stepDyNext = next.pos.y - node.pos.y;
|
|
13689
|
+
|
|
13690
|
+
if (lenSq === 1)
|
|
13691
|
+
{
|
|
13692
|
+
// 45° angle — middle node is off the straight line. Drop it.
|
|
13693
|
+
if (this.debug && this.debugTime > 0)
|
|
13694
|
+
debugCircle(node.posWorld, 0.3, rgb(0.5, 0, 0.5, 0.5), this.debugTime);
|
|
13695
|
+
path.splice(i, 1);
|
|
13696
|
+
i = max(1, i - 1);
|
|
13697
|
+
continue;
|
|
13698
|
+
}
|
|
13699
|
+
else if (lenSq === 2)
|
|
13700
|
+
{
|
|
13701
|
+
// 90° corner. Check the alternative-diagonal cell.
|
|
13702
|
+
if (this.debug && this.debugTime > 0)
|
|
13703
|
+
debugCircle(node.posWorld, 0.3, rgb(1, 0, 0, 0.5), this.debugTime);
|
|
13704
|
+
|
|
13705
|
+
let sx, sy;
|
|
13706
|
+
if (prev.pos.y === node.pos.y && next.pos.x === node.pos.x)
|
|
13707
|
+
{ sx = prev.pos.x; sy = next.pos.y; }
|
|
13708
|
+
else
|
|
13709
|
+
{ sx = next.pos.x; sy = prev.pos.y; }
|
|
13710
|
+
|
|
13711
|
+
const shortcut = this.getNode(sx, sy);
|
|
13712
|
+
if (shortcut && shortcut.isClear())
|
|
13713
|
+
{
|
|
13714
|
+
path.splice(i, 1);
|
|
13715
|
+
i = max(1, i - 1);
|
|
13716
|
+
continue;
|
|
13717
|
+
}
|
|
13718
|
+
}
|
|
13719
|
+
else if (lenSq === 5)
|
|
13720
|
+
{
|
|
13721
|
+
// 135° angle (a knight's-move offset). Try to relocate the
|
|
13722
|
+
// middle node to whichever of two candidate cells is closer
|
|
13723
|
+
// to prev-of-prev, and only if the corner cut is also clear.
|
|
13724
|
+
if (this.debug && this.debugTime > 0)
|
|
13725
|
+
debugCircle(node.posWorld, 0.3, rgb(1, 1, 0, 0.5), this.debugTime);
|
|
13726
|
+
|
|
13727
|
+
const prevPrev = i >= 2 ? path[i - 2] : prev;
|
|
13728
|
+
let s1x, s1y, s2x, s2y;
|
|
13729
|
+
if (stepDx === 0 || stepDxNext === 0)
|
|
13730
|
+
{
|
|
13731
|
+
// mostly vertical
|
|
13732
|
+
s1x = next.pos.x; s1y = node.pos.y;
|
|
13733
|
+
s2x = prev.pos.x; s2y = node.pos.y;
|
|
13734
|
+
}
|
|
13735
|
+
else
|
|
13736
|
+
{
|
|
13737
|
+
// mostly horizontal
|
|
13738
|
+
s1x = node.pos.x; s1y = next.pos.y;
|
|
13739
|
+
s2x = node.pos.x; s2y = prev.pos.y;
|
|
13740
|
+
}
|
|
13741
|
+
const dd1x = s1x - prevPrev.pos.x;
|
|
13742
|
+
const dd1y = s1y - prevPrev.pos.y;
|
|
13743
|
+
const dd2x = s2x - prevPrev.pos.x;
|
|
13744
|
+
const dd2y = s2y - prevPrev.pos.y;
|
|
13745
|
+
const dist1Sq = dd1x * dd1x + dd1y * dd1y;
|
|
13746
|
+
const dist2Sq = dd2x * dd2x + dd2y * dd2y;
|
|
13747
|
+
const sx = dist1Sq < dist2Sq ? s1x : s1x === s2x && s1y === s2y ? s1x : s2x;
|
|
13748
|
+
const sy = dist1Sq < dist2Sq ? s1y : s1x === s2x && s1y === s2y ? s1y : s2y;
|
|
13749
|
+
|
|
13750
|
+
const shortcut = this.getNode(sx, sy);
|
|
13751
|
+
if (shortcut && shortcut !== node && shortcut.isClear())
|
|
13752
|
+
{
|
|
13753
|
+
// Also check the cut-corner cell is clear.
|
|
13754
|
+
const ccx = next.pos.x + s2x - s1x;
|
|
13755
|
+
const ccy = next.pos.y + s2y - s1y;
|
|
13756
|
+
const cutCorner = this.getNode(ccx, ccy);
|
|
13757
|
+
if (cutCorner && cutCorner.isClear())
|
|
13758
|
+
{
|
|
13759
|
+
path[i] = shortcut;
|
|
13760
|
+
i = max(1, i - 1);
|
|
13761
|
+
continue;
|
|
13762
|
+
}
|
|
13763
|
+
}
|
|
13764
|
+
}
|
|
13765
|
+
else if (lenSq === 4 || lenSq === 8)
|
|
13766
|
+
{
|
|
13767
|
+
// Straight line or a 1-cell bump.
|
|
13768
|
+
if (this.debug && this.debugTime > 0)
|
|
13769
|
+
debugCircle(node.posWorld, 0.3, rgb(0, 1, 0, 0.5), this.debugTime);
|
|
13770
|
+
|
|
13771
|
+
if (stepDx === stepDxNext && stepDy === stepDyNext)
|
|
13772
|
+
{
|
|
13773
|
+
// Truly straight — nothing to do, advance.
|
|
13774
|
+
++i;
|
|
13775
|
+
continue;
|
|
13776
|
+
}
|
|
13777
|
+
else
|
|
13778
|
+
{
|
|
13779
|
+
// Bump — try to flatten via the in-line cell.
|
|
13780
|
+
let sx, sy;
|
|
13781
|
+
if (prev.pos.y === next.pos.y)
|
|
13782
|
+
{ sx = node.pos.x; sy = prev.pos.y; }
|
|
13783
|
+
else
|
|
13784
|
+
{ sx = prev.pos.x; sy = node.pos.y; }
|
|
13785
|
+
const shortcut = this.getNode(sx, sy);
|
|
13786
|
+
if (shortcut && shortcut.isClear())
|
|
13787
|
+
{
|
|
13788
|
+
path[i] = shortcut;
|
|
13789
|
+
i = max(1, i - 1);
|
|
13790
|
+
continue;
|
|
13791
|
+
}
|
|
13792
|
+
}
|
|
13793
|
+
}
|
|
13794
|
+
|
|
13795
|
+
++i;
|
|
13796
|
+
}
|
|
13797
|
+
}
|
|
13798
|
+
|
|
13799
|
+
/** Smooth a node path via line-of-sight ("string pulling"). Walks the
|
|
13800
|
+
* input path collapsing runs of nodes into straight segments whenever
|
|
13801
|
+
* isLineClear permits, so the result can leave grid centers and cut
|
|
13802
|
+
* cleanly across open spaces.
|
|
13803
|
+
*
|
|
13804
|
+
* Bails (leaves the path unchanged) if any node has nonzero cost — a
|
|
13805
|
+
* straight geometric shortcut can't be trusted to be the lowest-cost
|
|
13806
|
+
* route when cost-weighted terrain is in play.
|
|
13807
|
+
*
|
|
13808
|
+
* Port of ShortenPath2() in pathFinding.cpp.
|
|
13809
|
+
* @param {PathFinderNode[]} path
|
|
13810
|
+
* @private */
|
|
13811
|
+
smoothPathStringPull(path)
|
|
13812
|
+
{
|
|
13813
|
+
if (path.length <= 2) return;
|
|
13814
|
+
for (const n of path)
|
|
13815
|
+
{
|
|
13816
|
+
if (!n.isClear()) return;
|
|
13817
|
+
}
|
|
13818
|
+
|
|
13819
|
+
const original = path.slice();
|
|
13820
|
+
path.length = 0;
|
|
13821
|
+
path.push(original[0]);
|
|
13822
|
+
let searchIndex = 0;
|
|
13823
|
+
|
|
13824
|
+
for (let i = 1; i < original.length; ++i)
|
|
13825
|
+
{
|
|
13826
|
+
const node = original[i];
|
|
13827
|
+
|
|
13828
|
+
// Skip if node is collinear with the search-window start and the
|
|
13829
|
+
// previous node — it adds no information. Note: a == b is the
|
|
13830
|
+
// degenerate i=1, searchIndex=0 case; skip the test then.
|
|
13831
|
+
{
|
|
13832
|
+
const a = original[searchIndex];
|
|
13833
|
+
const b = original[i - 1];
|
|
13834
|
+
if (a !== b)
|
|
13835
|
+
{
|
|
13836
|
+
const cross =
|
|
13837
|
+
(b.pos.x - a.pos.x) * (node.pos.y - a.pos.y) -
|
|
13838
|
+
(b.pos.y - a.pos.y) * (node.pos.x - a.pos.x);
|
|
13839
|
+
if (cross === 0) continue;
|
|
13840
|
+
}
|
|
13841
|
+
}
|
|
13842
|
+
|
|
13843
|
+
if (!this.isLineClear(node.pos, path[path.length - 1].pos))
|
|
13844
|
+
{
|
|
13845
|
+
// Look ahead — if any later node has a clear shot to the
|
|
13846
|
+
// back of our new path, skip this node and try later.
|
|
13847
|
+
let foundClearAfter = false;
|
|
13848
|
+
for (let j = i + 1; j < original.length; ++j)
|
|
13849
|
+
{
|
|
13850
|
+
if (this.isLineClear(original[j].pos, path[path.length - 1].pos))
|
|
13851
|
+
{
|
|
13852
|
+
foundClearAfter = true;
|
|
13853
|
+
break;
|
|
13854
|
+
}
|
|
13855
|
+
}
|
|
13856
|
+
if (foundClearAfter)
|
|
13857
|
+
{
|
|
13858
|
+
if (this.debug && this.debugTime > 0)
|
|
13859
|
+
debugLine(node.posWorld, path[path.length - 1].posWorld, rgb(0, 0, 1, 0.3), 0.02, this.debugTime);
|
|
13860
|
+
continue;
|
|
13861
|
+
}
|
|
13862
|
+
|
|
13863
|
+
// No clear line ahead — fall back to the last waypoint we did
|
|
13864
|
+
// have a clear line to. searchIndex tracks our scan position.
|
|
13865
|
+
for (; searchIndex < original.length; ++searchIndex)
|
|
13866
|
+
{
|
|
13867
|
+
const cand = original[searchIndex];
|
|
13868
|
+
if (this.isLineClear(node.pos, cand.pos))
|
|
13869
|
+
{
|
|
13870
|
+
path.push(cand);
|
|
13871
|
+
i = searchIndex;
|
|
13872
|
+
break;
|
|
13873
|
+
}
|
|
13874
|
+
}
|
|
13875
|
+
ASSERT(searchIndex < original.length, 'smoothPathStringPull: ran out of candidates');
|
|
13876
|
+
}
|
|
13877
|
+
}
|
|
13878
|
+
|
|
13879
|
+
path.push(original[original.length - 1]);
|
|
13880
|
+
}
|
|
13881
|
+
|
|
13882
|
+
/** Lookup helper: true when the node at tile coords (x, y) is in-bounds
|
|
13883
|
+
* and clear (walkable, zero-cost). Used by isLineClear's hot path.
|
|
13884
|
+
* @param {number} x
|
|
13885
|
+
* @param {number} y
|
|
13886
|
+
* @returns {boolean}
|
|
13887
|
+
* @private */
|
|
13888
|
+
isNodeClear(x, y)
|
|
13889
|
+
{
|
|
13890
|
+
const n = this.getNode(x, y);
|
|
13891
|
+
return n !== null && n.isClear();
|
|
13892
|
+
}
|
|
13893
|
+
|
|
13894
|
+
/** Check that the line between two tile-coord endpoints stays entirely
|
|
13895
|
+
* inside walkable, zero-cost cells. Stricter than just sampling along
|
|
13896
|
+
* the line — it also checks the diagonal-corner-adjacent cells so the
|
|
13897
|
+
* line can never "scrape past" a wall corner.
|
|
13898
|
+
*
|
|
13899
|
+
* Both endpoints must themselves be clear (asserted in debug). Port of
|
|
13900
|
+
* CheckLine() in pathFinding.cpp.
|
|
13901
|
+
* @param {Vector2} startPos - Tile coords
|
|
13902
|
+
* @param {Vector2} endPos - Tile coords
|
|
13903
|
+
* @returns {boolean}
|
|
13904
|
+
* @private */
|
|
13905
|
+
isLineClear(startPos, endPos)
|
|
13906
|
+
{
|
|
13907
|
+
ASSERT(isVector2(startPos) && isVector2(endPos), 'isLineClear needs Vector2 endpoints');
|
|
13908
|
+
ASSERT(this.isNodeClear(startPos.x, startPos.y) && this.isNodeClear(endPos.x, endPos.y),
|
|
13909
|
+
'isLineClear endpoints must be in-bounds and clear');
|
|
13910
|
+
|
|
13911
|
+
const dx = endPos.x - startPos.x;
|
|
13912
|
+
const dy = endPos.y - startPos.y;
|
|
13913
|
+
const adx = abs(dx);
|
|
13914
|
+
const ady = abs(dy);
|
|
13915
|
+
const sx = sign(dx);
|
|
13916
|
+
const sy = sign(dy);
|
|
13917
|
+
let x = startPos.x;
|
|
13918
|
+
let y = startPos.y;
|
|
13919
|
+
|
|
13920
|
+
if (ady === adx)
|
|
13921
|
+
{
|
|
13922
|
+
// Pure diagonal.
|
|
13923
|
+
while (x !== endPos.x)
|
|
13924
|
+
{
|
|
13925
|
+
if (x !== startPos.x)
|
|
13926
|
+
{
|
|
13927
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
13928
|
+
if (!this.isNodeClear(x, y - sy)) return false;
|
|
13929
|
+
}
|
|
13930
|
+
if (!this.isNodeClear(x, y + sy)) return false;
|
|
13931
|
+
x += sx;
|
|
13932
|
+
y += sy;
|
|
13933
|
+
}
|
|
13934
|
+
if (!this.isNodeClear(endPos.x, endPos.y - sy)) return false;
|
|
13935
|
+
}
|
|
13936
|
+
else if (ady < adx)
|
|
13937
|
+
{
|
|
13938
|
+
// Mostly horizontal.
|
|
13939
|
+
if (dy === 0)
|
|
13940
|
+
{
|
|
13941
|
+
// Purely horizontal.
|
|
13942
|
+
x += sx;
|
|
13943
|
+
while (x !== endPos.x)
|
|
13944
|
+
{
|
|
13945
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
13946
|
+
x += sx;
|
|
13947
|
+
}
|
|
13948
|
+
}
|
|
13949
|
+
else
|
|
13950
|
+
{
|
|
13951
|
+
let lastY = startPos.y;
|
|
13952
|
+
while (x !== endPos.x)
|
|
13953
|
+
{
|
|
13954
|
+
y = startPos.y + Math.trunc((dy * (x - startPos.x)) / dx);
|
|
13955
|
+
if (lastY !== y)
|
|
13956
|
+
{
|
|
13957
|
+
if (!this.isNodeClear(x - sx, y + sy)) return false;
|
|
13958
|
+
if (!this.isNodeClear(x, y - sy)) return false;
|
|
13959
|
+
}
|
|
13960
|
+
lastY = y;
|
|
13961
|
+
if (x !== startPos.x)
|
|
13962
|
+
{
|
|
13963
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
13964
|
+
}
|
|
13965
|
+
y += sy;
|
|
13966
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
13967
|
+
x += sx;
|
|
13968
|
+
}
|
|
13969
|
+
const finalY = endPos.y - sy;
|
|
13970
|
+
if (!this.isNodeClear(endPos.x, finalY)) return false;
|
|
13971
|
+
}
|
|
13972
|
+
}
|
|
13973
|
+
else
|
|
13974
|
+
{
|
|
13975
|
+
// Mostly vertical.
|
|
13976
|
+
if (dx === 0)
|
|
13977
|
+
{
|
|
13978
|
+
y += sy;
|
|
13979
|
+
while (y !== endPos.y)
|
|
13980
|
+
{
|
|
13981
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
13982
|
+
y += sy;
|
|
13983
|
+
}
|
|
13984
|
+
}
|
|
13985
|
+
else
|
|
13986
|
+
{
|
|
13987
|
+
let lastX = startPos.x;
|
|
13988
|
+
while (y !== endPos.y)
|
|
13989
|
+
{
|
|
13990
|
+
x = startPos.x + Math.trunc((dx * (y - startPos.y)) / dy);
|
|
13991
|
+
if (lastX !== x)
|
|
13992
|
+
{
|
|
13993
|
+
if (!this.isNodeClear(x + sx, y - sy)) return false;
|
|
13994
|
+
if (!this.isNodeClear(x - sx, y)) return false;
|
|
13995
|
+
}
|
|
13996
|
+
lastX = x;
|
|
13997
|
+
if (y !== startPos.y)
|
|
13998
|
+
{
|
|
13999
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
14000
|
+
}
|
|
14001
|
+
x += sx;
|
|
14002
|
+
if (!this.isNodeClear(x, y)) return false;
|
|
14003
|
+
y += sy;
|
|
14004
|
+
}
|
|
14005
|
+
const finalX = endPos.x - sx;
|
|
14006
|
+
if (!this.isNodeClear(finalX, endPos.y)) return false;
|
|
14007
|
+
}
|
|
14008
|
+
}
|
|
14009
|
+
return true;
|
|
14010
|
+
}
|
|
14011
|
+
|
|
14012
|
+
/** Find a path from startPos to endPos in world space. Returns an array
|
|
14013
|
+
* of world-space Vector2 points; empty array if no path exists.
|
|
14014
|
+
*
|
|
14015
|
+
* Start and end are snapped to the nearest walkable tile via
|
|
14016
|
+
* getNearestClearNode. Intermediate points are tile centers unless the
|
|
14017
|
+
* string-pulling smoothing pass moves them off-grid.
|
|
14018
|
+
* @param {Vector2} startPos - World-space start
|
|
14019
|
+
* @param {Vector2} endPos - World-space end
|
|
14020
|
+
* @returns {Vector2[]}
|
|
14021
|
+
* @memberof PathFinding */
|
|
14022
|
+
findPath(startPos, endPos)
|
|
14023
|
+
{
|
|
14024
|
+
ASSERT(isVector2(startPos) && isVector2(endPos), 'findPath needs Vector2 endpoints');
|
|
14025
|
+
|
|
14026
|
+
this.buildNodeData();
|
|
14027
|
+
|
|
14028
|
+
// rebuild=false because we just built — avoid redundant work per snap.
|
|
14029
|
+
const startNode = this.getNearestClearNode(startPos, 10, false);
|
|
14030
|
+
const endNode = this.getNearestClearNode(endPos, 10, false);
|
|
14031
|
+
if (!startNode || !endNode) return [];
|
|
14032
|
+
|
|
14033
|
+
// Trivial case: start and end snapped to the same tile.
|
|
14034
|
+
if (startNode === endNode) return [startNode.posWorld.copy()];
|
|
14035
|
+
|
|
14036
|
+
if (!this.aStarSearch(startNode, endNode)) return [];
|
|
14037
|
+
|
|
14038
|
+
// Walk back from endNode via parent pointers, then reverse — cheaper
|
|
14039
|
+
// than unshifting on every step.
|
|
14040
|
+
const nodePath = [];
|
|
14041
|
+
for (let n = endNode; n; n = n.parent)
|
|
14042
|
+
nodePath.push(n);
|
|
14043
|
+
nodePath.reverse();
|
|
14044
|
+
|
|
14045
|
+
if (this.smoothPath)
|
|
14046
|
+
{
|
|
14047
|
+
this.smoothPathCorners(nodePath);
|
|
14048
|
+
this.smoothPathStringPull(nodePath);
|
|
14049
|
+
}
|
|
14050
|
+
|
|
14051
|
+
// Convert to world-space Vector2 path. Return copies, not live node
|
|
14052
|
+
// references — callers shouldn't be able to mutate the grid.
|
|
14053
|
+
const result = nodePath.map(n => n.posWorld.copy());
|
|
14054
|
+
|
|
14055
|
+
if (this.debug && this.debugTime > 0 && result.length > 0)
|
|
14056
|
+
{
|
|
14057
|
+
for (let i = 1; i < result.length; ++i)
|
|
14058
|
+
debugLine(result[i - 1], result[i], RED, 0.1, this.debugTime);
|
|
14059
|
+
for (const p of result)
|
|
14060
|
+
debugCircle(p, 0.5, rgb(1, 0, 0, 0.3), this.debugTime);
|
|
14061
|
+
debugCircle(result[0], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
|
|
14062
|
+
debugCircle(result[result.length - 1], 0.5, rgb(0, 1, 0, 0.5), this.debugTime);
|
|
14063
|
+
}
|
|
14064
|
+
|
|
14065
|
+
return result;
|
|
14066
|
+
}
|
|
14067
|
+
}
|
|
14068
|
+
|