littlejsengine 1.18.12 → 1.18.17
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/FAQ.md +197 -15
- package/README.md +31 -10
- package/dist/littlejs.d.ts +301 -92
- package/dist/littlejs.esm.js +1578 -518
- package/dist/littlejs.esm.min.js +1 -1
- package/dist/littlejs.js +1554 -517
- package/dist/littlejs.min.js +1 -1
- package/dist/littlejs.release.js +1525 -494
- package/package.json +4 -1
- package/plugins/box2d.js +26 -19
- package/plugins/drawUtilities.js +63 -0
- package/plugins/lightSystem.js +330 -0
- package/plugins/medalSystem.js +36 -5
- package/plugins/newgrounds.js +15 -3
- package/plugins/pathFinder.js +5 -3
- package/plugins/pluginExport.js +12 -3
- package/plugins/postProcess.js +8 -1
- package/plugins/tweenSystem.js +25 -20
- package/plugins/uiSystem.js +21 -3
- package/src/engine.js +14 -3
- package/src/engineAudio.js +48 -16
- package/src/engineBuild.mjs +2 -1
- package/src/engineDebug.js +30 -23
- package/src/engineDraw.js +80 -46
- package/src/engineExport.js +16 -2
- package/src/engineInput.js +534 -190
- package/src/engineLogo.js +1 -1
- package/src/engineMath.js +21 -7
- package/src/engineObject.js +24 -13
- package/src/engineParticles.js +45 -42
- package/src/engineRelease.js +1 -0
- package/src/engineSettings.js +112 -10
- package/src/engineTileLayer.js +67 -43
- package/src/engineUtilities.js +24 -11
- package/src/engineWebGL.js +23 -55
package/dist/littlejs.esm.js
CHANGED
|
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
|
|
|
35
35
|
* @type {string}
|
|
36
36
|
* @default
|
|
37
37
|
* @memberof Engine */
|
|
38
|
-
const engineVersion = '1.18.
|
|
38
|
+
const engineVersion = '1.18.17';
|
|
39
39
|
|
|
40
40
|
/** Frames per second to update
|
|
41
41
|
* @type {number}
|
|
@@ -164,12 +164,20 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored)
|
|
|
164
164
|
* ['tiles.png', 'tilesLevel.png'] // images to load
|
|
165
165
|
* );
|
|
166
166
|
* @memberof Engine */
|
|
167
|
-
async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement
|
|
167
|
+
async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement)
|
|
168
168
|
{
|
|
169
169
|
showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
|
|
170
170
|
ASSERT(!mainContext, 'engine already initialized');
|
|
171
|
+
// runtime guard so release builds (where the assert is stripped) don't
|
|
172
|
+
// double-register listeners / double-add canvases on a second call
|
|
173
|
+
if (mainContext) return;
|
|
171
174
|
ASSERT(isArray(imageSources), 'pass in images as array');
|
|
172
175
|
|
|
176
|
+
// ensure body exists for minimal HTML where the script runs before <body> is parsed
|
|
177
|
+
if (!document.body)
|
|
178
|
+
document.documentElement.appendChild(document.createElement('body'));
|
|
179
|
+
rootElement ||= document.body;
|
|
180
|
+
|
|
173
181
|
// allow passing in empty functions
|
|
174
182
|
gameInit ||= ()=>{};
|
|
175
183
|
gameUpdate ||= ()=>{};
|
|
@@ -195,6 +203,9 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
|
|
|
195
203
|
{
|
|
196
204
|
// update time keeping
|
|
197
205
|
let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS;
|
|
206
|
+
// skip delta on the very first frame so timeReal doesn't jump
|
|
207
|
+
// by ~page-load-time when RAF starts handing real timestamps
|
|
208
|
+
if (!frameTimeLastMS) frameTimeDeltaMS = 0;
|
|
198
209
|
frameTimeLastMS = frameTimeMS;
|
|
199
210
|
if (debug || debugWatermark)
|
|
200
211
|
averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
|
|
@@ -425,7 +436,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
|
|
|
425
436
|
promises.push(loadTexture(0));
|
|
426
437
|
|
|
427
438
|
// load engine font image
|
|
428
|
-
promises.push(
|
|
439
|
+
promises.push(imageFontInit());
|
|
429
440
|
|
|
430
441
|
if (showSplashScreen)
|
|
431
442
|
{
|
|
@@ -611,7 +622,7 @@ let debugKey = 'Escape';
|
|
|
611
622
|
let debugOverlay = false;
|
|
612
623
|
|
|
613
624
|
// Engine internal variables not exposed to documentation
|
|
614
|
-
let debugPrimitives = [], debugPhysics = false, debugRaycast = false, debugParticles = false, debugGamepads = false, debugTakeScreenshot;
|
|
625
|
+
let debugPrimitives = [], debugPhysics = false, debugRaycast = false, debugParticles = false, debugGamepads = false, debugSound = false, debugTakeScreenshot;
|
|
615
626
|
|
|
616
627
|
///////////////////////////////////////////////////////////////////////////////
|
|
617
628
|
// Debug helper functions
|
|
@@ -650,12 +661,8 @@ function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false, s
|
|
|
650
661
|
ASSERT(isNumber(time), 'time must be a number');
|
|
651
662
|
ASSERT(isNumber(angle), 'angle must be a number');
|
|
652
663
|
|
|
653
|
-
if (typeof size === 'number')
|
|
654
|
-
size = vec2(size); // allow passing in floats
|
|
655
664
|
if (isColor(color))
|
|
656
665
|
color = color.toString();
|
|
657
|
-
pos = pos.copy();
|
|
658
|
-
size = size.copy();
|
|
659
666
|
const timer = new Timer(time);
|
|
660
667
|
debugPrimitives.push({pos:pos.copy(), size:size.copy(), color, timer, angle, fill, screenSpace});
|
|
661
668
|
}
|
|
@@ -725,7 +732,7 @@ function debugPoint(pos, color, time, angle, screenSpace=false)
|
|
|
725
732
|
* @param {number} [time]
|
|
726
733
|
* @param {boolean} [screenSpace]
|
|
727
734
|
* @memberof Debug */
|
|
728
|
-
function debugLine(posA, posB, color, width=.1, time, screenSpace=false)
|
|
735
|
+
function debugLine(posA, posB, color, width=.1, time=0, screenSpace=false)
|
|
729
736
|
{
|
|
730
737
|
ASSERT(isVector2(posA), 'posA must be a vec2');
|
|
731
738
|
ASSERT(isVector2(posB), 'posB must be a vec2');
|
|
@@ -763,7 +770,7 @@ function debugOverlap(posA, sizeA, posB, sizeB, color, time, screenSpace=false)
|
|
|
763
770
|
debugRect(minPos.lerp(maxPos,.5), maxPos.subtract(minPos), color, time, 0, false, screenSpace);
|
|
764
771
|
}
|
|
765
772
|
|
|
766
|
-
/** Draw
|
|
773
|
+
/** Draw debug text in world space
|
|
767
774
|
* @param {string|number} text
|
|
768
775
|
* @param {Vector2} pos
|
|
769
776
|
* @param {number} [size]
|
|
@@ -855,6 +862,8 @@ function debugUpdate()
|
|
|
855
862
|
debugRaycast = !debugRaycast;
|
|
856
863
|
if (keyWasPressed('Digit5'))
|
|
857
864
|
debugScreenshot();
|
|
865
|
+
if (keyWasPressed('Digit7'))
|
|
866
|
+
debugSound = !debugSound;
|
|
858
867
|
}
|
|
859
868
|
if (debugVideoCaptureIsActive())
|
|
860
869
|
{
|
|
@@ -874,6 +883,9 @@ function debugRender()
|
|
|
874
883
|
// flush any gl sprites before drawing debug info
|
|
875
884
|
glFlush();
|
|
876
885
|
|
|
886
|
+
const savedDrawCount = drawCount;
|
|
887
|
+
const savedPrimitiveCount = primitiveCount;
|
|
888
|
+
|
|
877
889
|
if (debugTakeScreenshot)
|
|
878
890
|
{
|
|
879
891
|
// combine canvases, remove alpha and save
|
|
@@ -907,6 +919,8 @@ function debugRender()
|
|
|
907
919
|
const stickCount = gamepadStickData[i].length;
|
|
908
920
|
for (let j = 0; j < stickCount; j++)
|
|
909
921
|
{
|
|
922
|
+
if (!(j in gamepadStickData[i]))
|
|
923
|
+
continue; // skip sticks that are not present (eg a disabled touch left stick)
|
|
910
924
|
const stick = gamepadStick(j, i);
|
|
911
925
|
const drawPos = cornerPos.add(vec2(j*stickScale*2, 0));
|
|
912
926
|
const stickPos = drawPos.add(stick.scale(stickScale));
|
|
@@ -1085,6 +1099,8 @@ function debugRender()
|
|
|
1085
1099
|
debugContext.fillStyle = '#fff';
|
|
1086
1100
|
debugContext.fillText('5: Save Screenshot', x, y += h);
|
|
1087
1101
|
debugContext.fillText('6: Toggle Video Capture', x, y += h);
|
|
1102
|
+
debugContext.fillStyle = debugSound ? '#f00' : '#fff';
|
|
1103
|
+
debugContext.fillText('7: Debug Sound', x, y += h);
|
|
1088
1104
|
|
|
1089
1105
|
let keysPressed = '';
|
|
1090
1106
|
let mousePressed = '';
|
|
@@ -1094,7 +1110,7 @@ function debugRender()
|
|
|
1094
1110
|
continue;
|
|
1095
1111
|
if (parseInt(i) < 3)
|
|
1096
1112
|
mousePressed += i + ' ' ;
|
|
1097
|
-
else
|
|
1113
|
+
else
|
|
1098
1114
|
keysPressed += i + ' ' ;
|
|
1099
1115
|
}
|
|
1100
1116
|
mousePressed && debugContext.fillText('Mouse: ' + mousePressed, x, y += h);
|
|
@@ -1119,10 +1135,27 @@ function debugRender()
|
|
|
1119
1135
|
debugContext.fillText(debugParticles ? 'Debug Particles' : '', x, y += h);
|
|
1120
1136
|
debugContext.fillText(debugRaycast ? 'Debug Raycasts' : '', x, y += h);
|
|
1121
1137
|
debugContext.fillText(debugGamepads ? 'Debug Gamepads' : '', x, y += h);
|
|
1138
|
+
debugContext.fillText(debugSound ? 'Debug Sound' : '', x, y += h);
|
|
1122
1139
|
}
|
|
1123
1140
|
|
|
1124
1141
|
debugContext.restore();
|
|
1125
1142
|
}
|
|
1143
|
+
|
|
1144
|
+
if (debugWatermark || debugOverlay)
|
|
1145
|
+
{
|
|
1146
|
+
// show fps stats display
|
|
1147
|
+
mainContext.textAlign = 'right';
|
|
1148
|
+
mainContext.textBaseline = 'top';
|
|
1149
|
+
mainContext.font = '1em monospace';
|
|
1150
|
+
mainContext.fillStyle = '#000';
|
|
1151
|
+
const text = engineName + ' v' + engineVersion + ' / '
|
|
1152
|
+
+ savedDrawCount + ' / ' + savedPrimitiveCount + ' / '
|
|
1153
|
+
+ engineObjects.length + ' / ' + averageFPS.toFixed(1)
|
|
1154
|
+
+ (glEnable ? ' GL' : ' 2D') ;
|
|
1155
|
+
mainContext.fillText(text, mainCanvas.width-3, 3);
|
|
1156
|
+
mainContext.fillStyle = '#fff';
|
|
1157
|
+
mainContext.fillText(text, mainCanvas.width-2, 2);
|
|
1158
|
+
}
|
|
1126
1159
|
}
|
|
1127
1160
|
|
|
1128
1161
|
function debugRenderPost()
|
|
@@ -1132,21 +1165,6 @@ function debugRenderPost()
|
|
|
1132
1165
|
debugVideoCaptureUpdate();
|
|
1133
1166
|
return;
|
|
1134
1167
|
}
|
|
1135
|
-
|
|
1136
|
-
if (!debugWatermark && !debugOverlay) return;
|
|
1137
|
-
|
|
1138
|
-
// update fps display
|
|
1139
|
-
mainContext.textAlign = 'right';
|
|
1140
|
-
mainContext.textBaseline = 'top';
|
|
1141
|
-
mainContext.font = '1em monospace';
|
|
1142
|
-
mainContext.fillStyle = '#000';
|
|
1143
|
-
const text = engineName + ' v' + engineVersion + ' / '
|
|
1144
|
-
+ drawCount + ' / ' + primitiveCount + ' / '
|
|
1145
|
-
+ engineObjects.length + ' / ' + averageFPS.toFixed(1)
|
|
1146
|
-
+ (glEnable ? ' GL' : ' 2D') ;
|
|
1147
|
-
mainContext.fillText(text, mainCanvas.width-3, 3);
|
|
1148
|
-
mainContext.fillStyle = '#fff';
|
|
1149
|
-
mainContext.fillText(text, mainCanvas.width-2, 2);
|
|
1150
1168
|
}
|
|
1151
1169
|
|
|
1152
1170
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -1353,19 +1371,19 @@ const max = Math.max;
|
|
|
1353
1371
|
* @param {number} x
|
|
1354
1372
|
* @return {number}
|
|
1355
1373
|
* @memberof Math */
|
|
1356
|
-
const sign = Math.sign;
|
|
1374
|
+
const sign = (x) => Math.sign(x);
|
|
1357
1375
|
|
|
1358
1376
|
/** Returns hypotenuse of values passed in
|
|
1359
1377
|
* @param {...number} values
|
|
1360
1378
|
* @return {number}
|
|
1361
1379
|
* @memberof Math */
|
|
1362
|
-
const hypot = Math.hypot;
|
|
1380
|
+
const hypot = (...values) => Math.hypot(...values);
|
|
1363
1381
|
|
|
1364
1382
|
/** Returns log2 of value passed in
|
|
1365
1383
|
* @param {number} x
|
|
1366
1384
|
* @return {number}
|
|
1367
1385
|
* @memberof Math */
|
|
1368
|
-
const log2 = Math.log2;
|
|
1386
|
+
const log2 = (x) => Math.log2(x);
|
|
1369
1387
|
|
|
1370
1388
|
/** Returns sin of value passed in
|
|
1371
1389
|
* @param {number} x
|
|
@@ -1507,7 +1525,8 @@ function isOverlapping(posA, sizeA, posB, sizeB=vec2())
|
|
|
1507
1525
|
const dy = (posA.y - posB.y)*2;
|
|
1508
1526
|
const sx = sizeA.x + sizeB.x;
|
|
1509
1527
|
const sy = sizeA.y + sizeB.y;
|
|
1510
|
-
|
|
1528
|
+
// symmetric so isOverlapping(A,B) === isOverlapping(B,A) at touching edges
|
|
1529
|
+
return abs(dx) < sx && abs(dy) < sy;
|
|
1511
1530
|
}
|
|
1512
1531
|
|
|
1513
1532
|
/** Returns true if a line segment is intersecting an axis aligned box
|
|
@@ -1596,7 +1615,7 @@ function isStringLike(s) { return s != null && typeof s?.toString() === 'string'
|
|
|
1596
1615
|
/**
|
|
1597
1616
|
* Check if object is an array
|
|
1598
1617
|
* @param {any} a
|
|
1599
|
-
* @return {
|
|
1618
|
+
* @return {a is Array<any>}
|
|
1600
1619
|
* @memberof Math */
|
|
1601
1620
|
function isArray(a) { return Array.isArray(a); }
|
|
1602
1621
|
|
|
@@ -1734,7 +1753,13 @@ function randVec2(length=1) { return new Vector2().setAngle(rand(2*PI), length);
|
|
|
1734
1753
|
* @return {Vector2}
|
|
1735
1754
|
* @memberof Random */
|
|
1736
1755
|
function randInCircle(radius=1, minRadius=0)
|
|
1737
|
-
{
|
|
1756
|
+
{
|
|
1757
|
+
// r is uniform in area ⇒ r² uniform in [minRadius², radius²]
|
|
1758
|
+
// (the squared inner bound is what makes minRadius the actual exclusion edge)
|
|
1759
|
+
if (radius <= 0) return new Vector2;
|
|
1760
|
+
const ratio = clamp(minRadius / radius);
|
|
1761
|
+
return randVec2(radius * rand(ratio*ratio, 1)**.5);
|
|
1762
|
+
}
|
|
1738
1763
|
|
|
1739
1764
|
/** Returns a random color between the two passed in colors, combine components if linear
|
|
1740
1765
|
* @param {Color} [colorA=WHITE]
|
|
@@ -1804,7 +1829,14 @@ class RandomGenerator
|
|
|
1804
1829
|
* @param {number} [valueA]
|
|
1805
1830
|
* @param {number} [valueB]
|
|
1806
1831
|
* @return {number} */
|
|
1807
|
-
floatSign(valueA=1, valueB=0)
|
|
1832
|
+
floatSign(valueA=1, valueB=0)
|
|
1833
|
+
{
|
|
1834
|
+
const lo = min(valueA, valueB);
|
|
1835
|
+
const hi = max(valueA, valueB);
|
|
1836
|
+
const d = hi - lo;
|
|
1837
|
+
const e = this.float(d*2);
|
|
1838
|
+
return e < d ? lo + e : d - lo - e;
|
|
1839
|
+
}
|
|
1808
1840
|
|
|
1809
1841
|
/** Returns a random angle between -PI and PI
|
|
1810
1842
|
* @return {number} */
|
|
@@ -2517,9 +2549,15 @@ class Timer
|
|
|
2517
2549
|
* @return {number} */
|
|
2518
2550
|
get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
|
|
2519
2551
|
|
|
2520
|
-
/** Get percentage elapsed based on time it was set to, returns 0 if not set
|
|
2552
|
+
/** Get percentage elapsed based on time it was set to, returns 0 if not set.
|
|
2553
|
+
* Zero-duration timers report 1 (already elapsed).
|
|
2521
2554
|
* @return {number} */
|
|
2522
|
-
getPercent()
|
|
2555
|
+
getPercent()
|
|
2556
|
+
{
|
|
2557
|
+
if (!this.isSet()) return 0;
|
|
2558
|
+
if (!this.setTime) return 1;
|
|
2559
|
+
return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime);
|
|
2560
|
+
}
|
|
2523
2561
|
|
|
2524
2562
|
/** Get the time this timer was set to, returns 0 if not set
|
|
2525
2563
|
* @return {number} */
|
|
@@ -2546,9 +2584,9 @@ class Timer
|
|
|
2546
2584
|
* @memberof Utilities */
|
|
2547
2585
|
function formatTime(t)
|
|
2548
2586
|
{
|
|
2549
|
-
const
|
|
2587
|
+
const signStr = t < 0 ? '-' : '';
|
|
2550
2588
|
t = abs(t)|0;
|
|
2551
|
-
return
|
|
2589
|
+
return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
|
|
2552
2590
|
}
|
|
2553
2591
|
|
|
2554
2592
|
/** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
|
|
@@ -2634,15 +2672,20 @@ function shareURL(title, url, callback)
|
|
|
2634
2672
|
function readSaveData(saveName, defaultSaveData)
|
|
2635
2673
|
{
|
|
2636
2674
|
ASSERT(isStringLike(saveName), 'loadData requires saveName string');
|
|
2637
|
-
|
|
2638
|
-
//
|
|
2639
|
-
|
|
2675
|
+
|
|
2676
|
+
// tolerate localStorage being unavailable (iOS private mode, sandboxed
|
|
2677
|
+
// iframes) and corrupt JSON in stored data
|
|
2640
2678
|
let loadedData = {};
|
|
2641
|
-
|
|
2679
|
+
try
|
|
2642
2680
|
{
|
|
2643
|
-
|
|
2644
|
-
|
|
2681
|
+
const data = localStorage[saveName];
|
|
2682
|
+
if (data)
|
|
2683
|
+
{
|
|
2684
|
+
try { loadedData = JSON.parse(data); }
|
|
2685
|
+
catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
|
|
2686
|
+
}
|
|
2645
2687
|
}
|
|
2688
|
+
catch { LOG('readSaveData: localStorage unavailable — using defaults'); }
|
|
2646
2689
|
return { ...defaultSaveData, ...loadedData };
|
|
2647
2690
|
}
|
|
2648
2691
|
|
|
@@ -2653,7 +2696,9 @@ function readSaveData(saveName, defaultSaveData)
|
|
|
2653
2696
|
function writeSaveData(saveName, saveData)
|
|
2654
2697
|
{
|
|
2655
2698
|
ASSERT(isStringLike(saveName), 'saveData requires saveName string');
|
|
2656
|
-
localStorage
|
|
2699
|
+
// tolerate localStorage being unavailable or quota exceeded
|
|
2700
|
+
try { localStorage[saveName] = JSON.stringify(saveData); }
|
|
2701
|
+
catch { LOG('writeSaveData: failed to write', saveName); }
|
|
2657
2702
|
}
|
|
2658
2703
|
|
|
2659
2704
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -2748,7 +2793,7 @@ let canvasColorTiles = true;
|
|
|
2748
2793
|
|
|
2749
2794
|
/** Color to clear the canvas to before render, does not clear if alpha is 0
|
|
2750
2795
|
* @type {Color}
|
|
2751
|
-
* @memberof
|
|
2796
|
+
* @memberof Settings */
|
|
2752
2797
|
let canvasClearColor = CLEAR_BLACK;
|
|
2753
2798
|
|
|
2754
2799
|
/** The max size of the canvas, centered if window is larger
|
|
@@ -2941,34 +2986,78 @@ let touchInputEnable = true;
|
|
|
2941
2986
|
* - Supports left analog stick, 4 face buttons and start button (button 9)
|
|
2942
2987
|
* - setTouchGamepadButtonCount(1) to use face buttons as right analog stick
|
|
2943
2988
|
* - Analog stick buttons 10 and 11 are also activated when virtual sticks are touched
|
|
2944
|
-
|
|
2989
|
+
* - Rendered as a full-viewport HTML/SVG overlay, so controls may sit outside the game canvas
|
|
2945
2990
|
* @type {boolean}
|
|
2946
2991
|
* @default
|
|
2947
2992
|
* @memberof Settings */
|
|
2948
2993
|
let touchGamepadEnable = false;
|
|
2949
2994
|
|
|
2950
|
-
/** True if
|
|
2951
|
-
* -
|
|
2995
|
+
/** True if touches outside the gamepad controls should still drive mouse/touch input
|
|
2996
|
+
* - When false (the default), enabling the touch gamepad suppresses touch-to-mouse input entirely
|
|
2997
|
+
* - Set true to also pass touches outside the controls through to the game as mouse/touch input
|
|
2998
|
+
* - Touches on the gamepad controls never drive the mouse regardless of this setting
|
|
2999
|
+
* @type {boolean}
|
|
3000
|
+
* @default
|
|
3001
|
+
* @memberof Settings */
|
|
3002
|
+
let touchGamepadPassthrough = false;
|
|
3003
|
+
|
|
3004
|
+
/** Size of center button if touch gamepad should have start button in the center
|
|
3005
|
+
* - Prevents activating when pressed near virtual stick or face buttons
|
|
2952
3006
|
* - When the game is paused, any touch will press the button
|
|
2953
|
-
* -
|
|
3007
|
+
* - Measured in viewport CSS pixels
|
|
2954
3008
|
* @type {number}
|
|
2955
3009
|
* @default
|
|
2956
3010
|
* @memberof Settings */
|
|
2957
|
-
let touchGamepadCenterButtonSize =
|
|
3011
|
+
let touchGamepadCenterButtonSize = 0;
|
|
2958
3012
|
|
|
2959
|
-
/** Number of buttons on touch gamepad (0-4),
|
|
3013
|
+
/** Number of buttons on the right side of the touch gamepad (0-4), using gamepad buttons 0-3
|
|
3014
|
+
* - A count of 1 is a single large button (the size of a stick)
|
|
3015
|
+
* - Ignored when touchGamepadRightStick is set (the right side is a stick instead)
|
|
2960
3016
|
* @type {number}
|
|
2961
3017
|
* @default
|
|
2962
3018
|
* @memberof Settings */
|
|
2963
3019
|
let touchGamepadButtonCount = 4;
|
|
2964
3020
|
|
|
3021
|
+
/** True if the touch gamepad should have a left analog stick (or dpad)
|
|
3022
|
+
* - When false, the left side is face buttons (touchGamepadLeftButtonCount) or nothing
|
|
3023
|
+
* @type {boolean}
|
|
3024
|
+
* @default
|
|
3025
|
+
* @memberof Settings */
|
|
3026
|
+
let touchGamepadLeftStick = true;
|
|
3027
|
+
|
|
3028
|
+
/** Number of buttons on the left side of the touch gamepad (0-4), using gamepad buttons 4-7
|
|
3029
|
+
* - Only used when touchGamepadLeftStick is false (otherwise the left side is a stick)
|
|
3030
|
+
* - A count of 1 is a single large button (the size of a stick)
|
|
3031
|
+
* @type {number}
|
|
3032
|
+
* @default
|
|
3033
|
+
* @memberof Settings */
|
|
3034
|
+
let touchGamepadLeftButtonCount = 0;
|
|
3035
|
+
|
|
3036
|
+
/** True if the touch gamepad right side should be an analog stick (or dpad) instead of face buttons
|
|
3037
|
+
* - When set, touchGamepadButtonCount is ignored and the right side is a stick
|
|
3038
|
+
* - Uses an analog stick when touchGamepadAnalog is true, otherwise an 8 way dpad
|
|
3039
|
+
* @type {boolean}
|
|
3040
|
+
* @default
|
|
3041
|
+
* @memberof Settings */
|
|
3042
|
+
let touchGamepadRightStick = false;
|
|
3043
|
+
|
|
2965
3044
|
/** True if touch gamepad should be analog stick or false to use if 8 way dpad
|
|
2966
3045
|
* @type {boolean}
|
|
2967
3046
|
* @default
|
|
2968
3047
|
* @memberof Settings */
|
|
2969
3048
|
let touchGamepadAnalog = true;
|
|
2970
3049
|
|
|
2971
|
-
/**
|
|
3050
|
+
/** True if touch gamepad directional controls should float to where you press
|
|
3051
|
+
* - Only affects analog sticks and dpads, not face buttons
|
|
3052
|
+
* - Directional controls re-anchor to where you press within the bottom ~60% of their screen half; the top ~40% passes through to the game
|
|
3053
|
+
* - The right side floats only when it acts as the right analog stick (touchGamepadRightStick is set)
|
|
3054
|
+
* - A center button (touchGamepadCenterButtonSize) still works since it ignores touches near the sticks
|
|
3055
|
+
* @type {boolean}
|
|
3056
|
+
* @default
|
|
3057
|
+
* @memberof Settings */
|
|
3058
|
+
let touchGamepadFloating = false;
|
|
3059
|
+
|
|
3060
|
+
/** Size of virtual gamepad for touch devices in viewport CSS pixels
|
|
2972
3061
|
* @type {number}
|
|
2973
3062
|
* @default
|
|
2974
3063
|
* @memberof Settings */
|
|
@@ -2986,6 +3075,13 @@ let touchGamepadAlpha = .3;
|
|
|
2986
3075
|
* @memberof Settings */
|
|
2987
3076
|
let touchGamepadDisplayTime = 3;
|
|
2988
3077
|
|
|
3078
|
+
/** Duration in ms to vibrate when a touch gamepad face button or start button is pressed
|
|
3079
|
+
* - Set to 0 to disable, also requires vibrateEnable and hardware support (ignored on iOS)
|
|
3080
|
+
* @type {number}
|
|
3081
|
+
* @default
|
|
3082
|
+
* @memberof Settings */
|
|
3083
|
+
let touchGamepadVibration = 0;
|
|
3084
|
+
|
|
2989
3085
|
/** Allow vibration hardware if it exists
|
|
2990
3086
|
* @type {boolean}
|
|
2991
3087
|
* @default
|
|
@@ -3218,6 +3314,11 @@ function setTouchInputEnable(enable) { touchInputEnable = enable; }
|
|
|
3218
3314
|
* @memberof Settings */
|
|
3219
3315
|
function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
|
|
3220
3316
|
|
|
3317
|
+
/** Set if touches outside the gamepad controls should still drive mouse/touch input
|
|
3318
|
+
* @param {boolean} passthrough
|
|
3319
|
+
* @memberof Settings */
|
|
3320
|
+
function setTouchGamepadPassthrough(passthrough) { touchGamepadPassthrough = passthrough; }
|
|
3321
|
+
|
|
3221
3322
|
/** Set if touch gamepad should have start button in the center
|
|
3222
3323
|
* - Set size to enable the center button
|
|
3223
3324
|
* - When the game is paused, any touch will press the button
|
|
@@ -3225,16 +3326,57 @@ function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
|
|
|
3225
3326
|
* @memberof Settings */
|
|
3226
3327
|
function setTouchGamepadCenterButtonSize(size) { touchGamepadCenterButtonSize = size; }
|
|
3227
3328
|
|
|
3228
|
-
/** Set number of buttons on touch gamepad (0-4
|
|
3329
|
+
/** Set number of buttons on the right side of the touch gamepad (0-4, gamepad buttons 0-3)
|
|
3330
|
+
* @param {number} count
|
|
3331
|
+
* @memberof Settings */
|
|
3332
|
+
function setTouchGamepadButtonCount(count)
|
|
3333
|
+
{
|
|
3334
|
+
touchGamepadButtonCount = count;
|
|
3335
|
+
if (count > 0)
|
|
3336
|
+
touchGamepadRightStick = false;
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
/** Set if the touch gamepad should have a left analog stick (or dpad)
|
|
3340
|
+
* @param {boolean} enable
|
|
3341
|
+
* @memberof Settings */
|
|
3342
|
+
function setTouchGamepadLeftStick(enable)
|
|
3343
|
+
{
|
|
3344
|
+
touchGamepadLeftStick = enable;
|
|
3345
|
+
if (enable)
|
|
3346
|
+
touchGamepadLeftButtonCount = 0;
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3349
|
+
/** Set number of buttons on the left side of the touch gamepad (0-4, gamepad buttons 4-7)
|
|
3350
|
+
* - Only used when touchGamepadLeftStick is false
|
|
3229
3351
|
* @param {number} count
|
|
3230
3352
|
* @memberof Settings */
|
|
3231
|
-
function
|
|
3353
|
+
function setTouchGamepadLeftButtonCount(count)
|
|
3354
|
+
{
|
|
3355
|
+
touchGamepadLeftButtonCount = count;
|
|
3356
|
+
if (count > 0)
|
|
3357
|
+
touchGamepadLeftStick = false;
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
/** Set if the touch gamepad right side is an analog stick (or dpad) instead of face buttons
|
|
3361
|
+
* @param {boolean} rightStick
|
|
3362
|
+
* @memberof Settings */
|
|
3363
|
+
function setTouchGamepadRightStick(rightStick)
|
|
3364
|
+
{
|
|
3365
|
+
touchGamepadRightStick = rightStick;
|
|
3366
|
+
if (rightStick)
|
|
3367
|
+
touchGamepadButtonCount = 0;
|
|
3368
|
+
}
|
|
3232
3369
|
|
|
3233
3370
|
/** Set if touch gamepad should be analog stick or 8 way dpad
|
|
3234
3371
|
* @param {boolean} analog
|
|
3235
3372
|
* @memberof Settings */
|
|
3236
3373
|
function setTouchGamepadAnalog(analog) { touchGamepadAnalog = analog; }
|
|
3237
3374
|
|
|
3375
|
+
/** Set if touch gamepad directional controls should float to where you press
|
|
3376
|
+
* @param {boolean} floating
|
|
3377
|
+
* @memberof Settings */
|
|
3378
|
+
function setTouchGamepadFloating(floating) { touchGamepadFloating = floating; }
|
|
3379
|
+
|
|
3238
3380
|
/** Set size of virtual gamepad for touch devices in pixels
|
|
3239
3381
|
* @param {number} size
|
|
3240
3382
|
* @memberof Settings */
|
|
@@ -3250,6 +3392,11 @@ function setTouchGamepadAlpha(alpha) { touchGamepadAlpha = alpha; }
|
|
|
3250
3392
|
* @memberof Settings */
|
|
3251
3393
|
function setTouchGamepadDisplayTime(time) { touchGamepadDisplayTime = time; }
|
|
3252
3394
|
|
|
3395
|
+
/** Set duration in ms to vibrate when a touch gamepad face or start button is pressed (0 disables)
|
|
3396
|
+
* @param {number} ms
|
|
3397
|
+
* @memberof Settings */
|
|
3398
|
+
function setTouchGamepadVibration(ms) { touchGamepadVibration = ms; }
|
|
3399
|
+
|
|
3253
3400
|
/** Set to allow vibration hardware if it exists
|
|
3254
3401
|
* @param {boolean} enable
|
|
3255
3402
|
* @memberof Settings */
|
|
@@ -3360,7 +3507,7 @@ class EngineObject
|
|
|
3360
3507
|
this.color = color.copy();
|
|
3361
3508
|
/** @property {Color} - Additive color to apply when rendered */
|
|
3362
3509
|
this.additiveColor = undefined;
|
|
3363
|
-
/** @property {boolean} - Should
|
|
3510
|
+
/** @property {boolean} - Should the rendered tile flip along the y axis. Affects rendering and the local→world transform of attached children (a mirrored parent flips its children's localPos.x and localAngle). Does not affect this object's own physics, collision, or localToWorld/worldToLocal. */
|
|
3364
3511
|
this.mirror = false;
|
|
3365
3512
|
/** @property {boolean} - Has object been destroyed? */
|
|
3366
3513
|
this.destroyed = false;
|
|
@@ -3428,10 +3575,10 @@ class EngineObject
|
|
|
3428
3575
|
if (pa)
|
|
3429
3576
|
{
|
|
3430
3577
|
const c = cos(-pa), s = sin(-pa);
|
|
3431
|
-
this.pos
|
|
3578
|
+
this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
|
|
3432
3579
|
}
|
|
3433
3580
|
else
|
|
3434
|
-
this.pos
|
|
3581
|
+
this.pos.set(lx + pp.x, ly + pp.y);
|
|
3435
3582
|
this.angle = mirror*this.localAngle + pa;
|
|
3436
3583
|
}
|
|
3437
3584
|
|
|
@@ -3446,6 +3593,9 @@ class EngineObject
|
|
|
3446
3593
|
// child objects do not have physics
|
|
3447
3594
|
ASSERT(!this.parent);
|
|
3448
3595
|
|
|
3596
|
+
// bail if a collision callback destroyed us mid-frame
|
|
3597
|
+
if (this.destroyed) return;
|
|
3598
|
+
|
|
3449
3599
|
if (this.clampSpeed)
|
|
3450
3600
|
{
|
|
3451
3601
|
// limit max speed to prevent missing collisions
|
|
@@ -3501,6 +3651,8 @@ class EngineObject
|
|
|
3501
3651
|
|
|
3502
3652
|
// notify objects of collision and check if should be resolved
|
|
3503
3653
|
const collide1 = this.collideWithObject(o);
|
|
3654
|
+
// callback may have destroyed us; stop resolving against more objects
|
|
3655
|
+
if (this.destroyed) return;
|
|
3504
3656
|
const collide2 = o.collideWithObject(this);
|
|
3505
3657
|
if (!collide1 || !collide2) continue;
|
|
3506
3658
|
|
|
@@ -3596,13 +3748,17 @@ class EngineObject
|
|
|
3596
3748
|
const restitution = max(this.restitution, hitLayer.restitution);
|
|
3597
3749
|
if (isBlockedX)
|
|
3598
3750
|
{
|
|
3599
|
-
// try to
|
|
3751
|
+
// try to step over a 1-tile bump (direction follows gravity sign
|
|
3752
|
+
// so inverted gravity steps down off a ceiling bump instead of up;
|
|
3753
|
+
// zero gravity defaults to the normal-gravity step-up direction)
|
|
3600
3754
|
const epsilon = 1e-3;
|
|
3601
|
-
const
|
|
3602
|
-
const
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3755
|
+
const maxMove = .1;
|
|
3756
|
+
const gravitySign = gravity.y > 0 ? -1 : 1;
|
|
3757
|
+
const y = gravitySign > 0 ?
|
|
3758
|
+
floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon :
|
|
3759
|
+
ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon;
|
|
3760
|
+
const delta = abs(y - this.pos.y);
|
|
3761
|
+
if (delta < maxMove)
|
|
3606
3762
|
if (!tileCollisionTest(vec2(this.pos.x, y), this.size, this))
|
|
3607
3763
|
{
|
|
3608
3764
|
this.pos.y = y;
|
|
@@ -3654,6 +3810,9 @@ class EngineObject
|
|
|
3654
3810
|
drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor);
|
|
3655
3811
|
}
|
|
3656
3812
|
|
|
3813
|
+
/** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
|
|
3814
|
+
renderLight() {}
|
|
3815
|
+
|
|
3657
3816
|
/** Destroy this object, destroy its children, detach its parent, and mark it for removal
|
|
3658
3817
|
* @param {boolean} [immediate] - should attached effects be allowed to die off? */
|
|
3659
3818
|
destroy(immediate=false)
|
|
@@ -3742,6 +3901,8 @@ class EngineObject
|
|
|
3742
3901
|
* @return {EngineObject} The child object added */
|
|
3743
3902
|
addChild(child, localPos=vec2(), localAngle=0)
|
|
3744
3903
|
{
|
|
3904
|
+
ASSERT(!this.destroyed, 'cannot add child to destroyed object');
|
|
3905
|
+
if (this.destroyed) return child;
|
|
3745
3906
|
ASSERT(!child.parent && !this.children.includes(child));
|
|
3746
3907
|
ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
|
|
3747
3908
|
ASSERT(child !== this, 'cannot add self as child');
|
|
@@ -3758,10 +3919,7 @@ class EngineObject
|
|
|
3758
3919
|
removeChild(child)
|
|
3759
3920
|
{
|
|
3760
3921
|
ASSERT(child.parent === this && this.children.includes(child));
|
|
3761
|
-
|
|
3762
|
-
const index = this.children.indexOf(child);
|
|
3763
|
-
ASSERT(index >= 0, 'child not found in children array');
|
|
3764
|
-
index >= 0 && this.children.splice(index, 1);
|
|
3922
|
+
this.children.splice(this.children.indexOf(child), 1);
|
|
3765
3923
|
child.parent = undefined;
|
|
3766
3924
|
}
|
|
3767
3925
|
|
|
@@ -3836,7 +3994,7 @@ class EngineObject
|
|
|
3836
3994
|
* - Optimized tile sheet sprite rendering using WebGL batching
|
|
3837
3995
|
* - Primitive drawing for polygons, ellipses, and lines
|
|
3838
3996
|
* - Tile-based rendering with TileInfo and TextureInfo classes
|
|
3839
|
-
* - Text rendering with custom fonts and
|
|
3997
|
+
* - Text rendering with custom fonts and ImageFont support
|
|
3840
3998
|
* - Color and additive color blending for effects
|
|
3841
3999
|
* - Rotation, mirroring, and scaling transformations
|
|
3842
4000
|
* - Camera system with position, scale, and rotation
|
|
@@ -3932,7 +4090,7 @@ let primitiveCount;
|
|
|
3932
4090
|
* tile(1, 16, 3) // a tile at index 1 of size 16 on texture 3
|
|
3933
4091
|
* tile(vec2(4,8), vec2(30,10)) // a tile at index (4,8) with a size of (30,10)
|
|
3934
4092
|
* @memberof Draw */
|
|
3935
|
-
function tile(index=
|
|
4093
|
+
function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
|
|
3936
4094
|
{
|
|
3937
4095
|
ASSERT(isVector2(index) || typeof index === 'number', 'index must be a vec2 or number');
|
|
3938
4096
|
ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
|
|
@@ -3983,8 +4141,8 @@ class TileInfo
|
|
|
3983
4141
|
* @param {Vector2} [pos=vec2()] - Top left corner of tile in pixels
|
|
3984
4142
|
* @param {Vector2} [size] - Size of tile in pixels
|
|
3985
4143
|
* @param {TextureInfo} [textureInfo] - Texture info to use
|
|
3986
|
-
* @param {number} [padding] - How many pixels padding around
|
|
3987
|
-
* @param {number} [bleed] - How many pixels smaller to
|
|
4144
|
+
* @param {number} [padding] - How many pixels padding around all sides of each tile (increases grid size, does not affect tile size)
|
|
4145
|
+
* @param {number} [bleed] - How many pixels smaller to shrink UVS of tiles (does not affect grid size, only UVs)
|
|
3988
4146
|
*/
|
|
3989
4147
|
constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed)
|
|
3990
4148
|
{
|
|
@@ -4016,7 +4174,7 @@ class TileInfo
|
|
|
4016
4174
|
ASSERT(typeof frame === 'number');
|
|
4017
4175
|
const w = this.size.x + this.padding*2;
|
|
4018
4176
|
const x = frame*w;
|
|
4019
|
-
ASSERT(x
|
|
4177
|
+
ASSERT(x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
|
|
4020
4178
|
return this.offset(new Vector2(x));
|
|
4021
4179
|
}
|
|
4022
4180
|
|
|
@@ -4105,7 +4263,7 @@ class TextureInfo
|
|
|
4105
4263
|
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
|
|
4106
4264
|
* @memberof Draw */
|
|
4107
4265
|
function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
4108
|
-
angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace, context)
|
|
4266
|
+
angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=false, context)
|
|
4109
4267
|
{
|
|
4110
4268
|
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
4111
4269
|
ASSERT(isVector2(size), 'size must be a vec2');
|
|
@@ -4148,10 +4306,8 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
|
4148
4306
|
}
|
|
4149
4307
|
else
|
|
4150
4308
|
{
|
|
4151
|
-
// untextured:
|
|
4152
|
-
//
|
|
4153
|
-
// uvs/rgba zeroed). Color+additive are folded together to match
|
|
4154
|
-
// the Canvas2D path's color.add(additiveColor) on line ~337.
|
|
4309
|
+
// untextured: fold color+additive to match the Canvas2D path's
|
|
4310
|
+
// color.add(additiveColor) on line ~337.
|
|
4155
4311
|
const combined = additiveColor ? color.add(additiveColor) : color;
|
|
4156
4312
|
glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
|
|
4157
4313
|
}
|
|
@@ -4161,11 +4317,12 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
|
4161
4317
|
// normal canvas 2D rendering method (slower)
|
|
4162
4318
|
++drawCount;
|
|
4163
4319
|
++primitiveCount;
|
|
4164
|
-
size = new Vector2(size.x, -size.y); // flip upside down sprites
|
|
4165
4320
|
drawCanvas2D(pos, size, angle, mirror, (context)=>
|
|
4166
4321
|
{
|
|
4167
4322
|
if (textureInfo)
|
|
4168
4323
|
{
|
|
4324
|
+
// un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
|
|
4325
|
+
context.scale(1, -1);
|
|
4169
4326
|
// calculate uvs and render
|
|
4170
4327
|
const x = tileInfo.pos.x, y = tileInfo.pos.y;
|
|
4171
4328
|
const w = tileInfo.size.x, h = tileInfo.size.y;
|
|
@@ -4173,7 +4330,7 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
|
4173
4330
|
}
|
|
4174
4331
|
else
|
|
4175
4332
|
{
|
|
4176
|
-
// if no tile info, use untextured rect
|
|
4333
|
+
// if no tile info, use untextured rect (Y-symmetric, no compensation needed)
|
|
4177
4334
|
const c = additiveColor ? color.add(additiveColor) : color;
|
|
4178
4335
|
context.fillStyle = c.toString();
|
|
4179
4336
|
context.fillRect(-.5, -.5, 1, 1);
|
|
@@ -4247,11 +4404,10 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, an
|
|
|
4247
4404
|
// normal canvas 2D rendering method (slower)
|
|
4248
4405
|
++drawCount;
|
|
4249
4406
|
++primitiveCount;
|
|
4250
|
-
size = new Vector2(size.x, -size.y); // fix upside down sprites
|
|
4251
4407
|
drawCanvas2D(pos, size, angle, false, (context)=>
|
|
4252
4408
|
{
|
|
4253
|
-
//
|
|
4254
|
-
const gradient = context.createLinearGradient(0,
|
|
4409
|
+
// gradient endpoints are flipped to match the Y flip inside drawCanvas2D
|
|
4410
|
+
const gradient = context.createLinearGradient(0, .5, 0, -.5);
|
|
4255
4411
|
gradient.addColorStop(0, colorTop.toString());
|
|
4256
4412
|
gradient.addColorStop(1, colorBottom.toString());
|
|
4257
4413
|
context.fillStyle = gradient;
|
|
@@ -4364,7 +4520,7 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
|
|
|
4364
4520
|
* @param {boolean} [screenSpace]
|
|
4365
4521
|
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
|
|
4366
4522
|
* @memberof Draw */
|
|
4367
|
-
function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace, context)
|
|
4523
|
+
function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
|
|
4368
4524
|
{
|
|
4369
4525
|
ASSERT(isArray(points), 'points must be an array');
|
|
4370
4526
|
ASSERT(isNumber(width), 'width must be a number');
|
|
@@ -4413,7 +4569,7 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
|
|
|
4413
4569
|
* @param {boolean} [screenSpace]
|
|
4414
4570
|
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
|
|
4415
4571
|
* @memberof Draw */
|
|
4416
|
-
function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, screenSpace, context)
|
|
4572
|
+
function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
|
|
4417
4573
|
{
|
|
4418
4574
|
const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
|
|
4419
4575
|
const size = vec2(width, halfDelta.length()*2);
|
|
@@ -4429,9 +4585,9 @@ function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, sc
|
|
|
4429
4585
|
* @param {Vector2} [size=vec2(1)]
|
|
4430
4586
|
* @param {number} [sides]
|
|
4431
4587
|
* @param {Color} [color=WHITE]
|
|
4432
|
-
* @param {number} [angle]
|
|
4433
4588
|
* @param {number} [lineWidth]
|
|
4434
4589
|
* @param {Color} [lineColor=BLACK]
|
|
4590
|
+
* @param {number} [angle]
|
|
4435
4591
|
* @param {boolean} [useWebGL=glEnable]
|
|
4436
4592
|
* @param {boolean} [screenSpace]
|
|
4437
4593
|
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
|
|
@@ -4524,7 +4680,7 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
|
|
|
4524
4680
|
ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
|
|
4525
4681
|
|
|
4526
4682
|
// clamp line width to prevent artifacts
|
|
4527
|
-
lineWidth = clamp(lineWidth, 0,
|
|
4683
|
+
lineWidth = clamp(lineWidth, 0, min(size.x, size.y));
|
|
4528
4684
|
|
|
4529
4685
|
if (useWebGL && glEnable)
|
|
4530
4686
|
{
|
|
@@ -4566,24 +4722,26 @@ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useW
|
|
|
4566
4722
|
drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
|
|
4567
4723
|
}
|
|
4568
4724
|
|
|
4569
|
-
/** Draw
|
|
4725
|
+
/** Draw an ellipse filled with a radial gradient from the center to the rim
|
|
4570
4726
|
* - Best when batched with other untextured polys
|
|
4571
4727
|
* - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
|
|
4572
4728
|
* - Stacking gradients at the exact same position may show a faint vertical artifact
|
|
4573
4729
|
* @param {Vector2} pos
|
|
4574
|
-
* @param {
|
|
4730
|
+
* @param {Vector2} [size=vec2(1)] - Width and height diameter
|
|
4575
4731
|
* @param {Color} [colorInner=WHITE]
|
|
4576
4732
|
* @param {Color} [colorOuter=CLEAR_WHITE]
|
|
4733
|
+
* @param {number} [angle]
|
|
4577
4734
|
* @param {boolean} [useWebGL=glEnable]
|
|
4578
4735
|
* @param {boolean} [screenSpace]
|
|
4579
4736
|
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
|
|
4580
4737
|
* @memberof Draw */
|
|
4581
|
-
let
|
|
4582
|
-
function
|
|
4738
|
+
let drawEllipseGradientOffset = 0;
|
|
4739
|
+
function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
|
|
4583
4740
|
{
|
|
4584
4741
|
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
4585
|
-
ASSERT(
|
|
4742
|
+
ASSERT(isVector2(size), 'size must be a vec2');
|
|
4586
4743
|
ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid');
|
|
4744
|
+
ASSERT(isNumber(angle), 'angle must be a number');
|
|
4587
4745
|
ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
|
|
4588
4746
|
|
|
4589
4747
|
if (headlessMode) return;
|
|
@@ -4595,26 +4753,33 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
|
|
|
4595
4753
|
{
|
|
4596
4754
|
// convert to world space
|
|
4597
4755
|
pos = screenToWorld(pos);
|
|
4598
|
-
size
|
|
4756
|
+
size = size.scale(1/cameraScale);
|
|
4757
|
+
angle += cameraAngle;
|
|
4599
4758
|
}
|
|
4600
4759
|
// fan as tristrip; rotate the boundary vertex by one slice per call
|
|
4601
4760
|
// so back-to-back gradients at the same position have their hole
|
|
4602
4761
|
// (from gpu edge-rule on the boundary line-degen) at different rim
|
|
4603
4762
|
// verts and don't visibly stack
|
|
4604
4763
|
const sides = glCircleSides;
|
|
4605
|
-
const
|
|
4764
|
+
const radiusX = size.x/2, radiusY = size.y/2;
|
|
4606
4765
|
const innerInt = colorInner.rgbaInt();
|
|
4607
4766
|
const outerInt = colorOuter.rgbaInt();
|
|
4608
|
-
const offset =
|
|
4767
|
+
const offset = drawEllipseGradientOffset++;
|
|
4768
|
+
const c = cos(-angle), s = sin(-angle);
|
|
4769
|
+
const rim = (a) =>
|
|
4770
|
+
{
|
|
4771
|
+
const lx = sin(a)*radiusX, ly = cos(a)*radiusY;
|
|
4772
|
+
return vec2(pos.x + lx*c - ly*s, pos.y + lx*s + ly*c);
|
|
4773
|
+
};
|
|
4609
4774
|
const startA = (offset%sides)/sides*PI*2;
|
|
4610
|
-
const points = [
|
|
4775
|
+
const points = [rim(startA)];
|
|
4611
4776
|
const colors = [outerInt];
|
|
4612
4777
|
for (let i=sides; i--;)
|
|
4613
4778
|
{
|
|
4614
4779
|
const a = ((i+offset)%sides)/sides*PI*2;
|
|
4615
4780
|
points.push(pos);
|
|
4616
4781
|
colors.push(innerInt);
|
|
4617
|
-
points.push(
|
|
4782
|
+
points.push(rim(a));
|
|
4618
4783
|
colors.push(outerInt);
|
|
4619
4784
|
}
|
|
4620
4785
|
glDrawColoredPoints(points, colors);
|
|
@@ -4624,7 +4789,7 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
|
|
|
4624
4789
|
// normal canvas 2D rendering method (slower)
|
|
4625
4790
|
++drawCount;
|
|
4626
4791
|
++primitiveCount;
|
|
4627
|
-
drawCanvas2D(pos,
|
|
4792
|
+
drawCanvas2D(pos, size, angle, false, (context)=>
|
|
4628
4793
|
{
|
|
4629
4794
|
const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5);
|
|
4630
4795
|
gradient.addColorStop(0, colorInner.toString());
|
|
@@ -4637,13 +4802,34 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
|
|
|
4637
4802
|
}
|
|
4638
4803
|
}
|
|
4639
4804
|
|
|
4805
|
+
/** Draw a circle filled with a radial gradient from the center to the rim
|
|
4806
|
+
* - Best when batched with other untextured polys
|
|
4807
|
+
* - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
|
|
4808
|
+
* - Stacking gradients at the exact same position may show a faint vertical artifact
|
|
4809
|
+
* @param {Vector2} pos
|
|
4810
|
+
* @param {number} [size=1] - Diameter
|
|
4811
|
+
* @param {Color} [colorInner=WHITE]
|
|
4812
|
+
* @param {Color} [colorOuter=CLEAR_WHITE]
|
|
4813
|
+
* @param {boolean} [useWebGL=glEnable]
|
|
4814
|
+
* @param {boolean} [screenSpace]
|
|
4815
|
+
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
|
|
4816
|
+
* @memberof Draw */
|
|
4817
|
+
function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
|
|
4818
|
+
{
|
|
4819
|
+
ASSERT(isNumber(size), 'size must be a number');
|
|
4820
|
+
drawEllipseGradient(pos, vec2(size), colorInner, colorOuter, 0, useWebGL, screenSpace, context);
|
|
4821
|
+
}
|
|
4822
|
+
|
|
4640
4823
|
/**
|
|
4641
4824
|
* @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context
|
|
4642
4825
|
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
|
|
4643
4826
|
* @memberof Draw
|
|
4644
4827
|
*/
|
|
4645
4828
|
|
|
4646
|
-
/** Draw directly to a 2d canvas context in world space
|
|
4829
|
+
/** Draw directly to a 2d canvas context in world space.
|
|
4830
|
+
* The Y axis is flipped so world-Y-up coordinates render right-side up
|
|
4831
|
+
* (matches the WebGL path). Callers whose drawing depends on Y direction
|
|
4832
|
+
* (e.g. linear gradients) should flip their own Y endpoints accordingly.
|
|
4647
4833
|
* @param {Vector2} pos
|
|
4648
4834
|
* @param {Vector2} size
|
|
4649
4835
|
* @param {number} angle
|
|
@@ -4691,7 +4877,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
|
|
|
4691
4877
|
* @param {number} [angle]
|
|
4692
4878
|
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
|
|
4693
4879
|
* @memberof Draw */
|
|
4694
|
-
function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, font, fontStyle, maxWidth, angle=0, context=drawContext)
|
|
4880
|
+
function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
|
|
4695
4881
|
{
|
|
4696
4882
|
// convert to screen space
|
|
4697
4883
|
pos = worldToScreen(pos);
|
|
@@ -4731,16 +4917,16 @@ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLA
|
|
|
4731
4917
|
ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
|
|
4732
4918
|
ASSERT(isNumber(angle), 'angle must be a number');
|
|
4733
4919
|
|
|
4920
|
+
const lines = (text+'').split('\n');
|
|
4921
|
+
const posY = pos.y - (lines.length-1) * size/2; // center vertically
|
|
4922
|
+
// save before style mutations so caller's context state is preserved
|
|
4923
|
+
context.save();
|
|
4734
4924
|
context.fillStyle = color.toString();
|
|
4735
4925
|
context.strokeStyle = lineColor.toString();
|
|
4736
4926
|
context.lineWidth = lineWidth;
|
|
4737
4927
|
context.textAlign = textAlign;
|
|
4738
4928
|
context.font = fontStyle + ' ' + size + 'px '+ font;
|
|
4739
4929
|
context.textBaseline = 'middle';
|
|
4740
|
-
|
|
4741
|
-
const lines = (text+'').split('\n');
|
|
4742
|
-
const posY = pos.y - (lines.length-1) * size/2; // center vertically
|
|
4743
|
-
context.save();
|
|
4744
4930
|
context.translate(pos.x, posY);
|
|
4745
4931
|
context.rotate(-angle);
|
|
4746
4932
|
let yOffset = 0;
|
|
@@ -4901,6 +5087,9 @@ function isOnScreen(pos, size=0)
|
|
|
4901
5087
|
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
4902
5088
|
ASSERT(isVector2(size) || isNumber(size), 'size must be a vec2 or number');
|
|
4903
5089
|
|
|
5090
|
+
// cameraScale of 0 collapses world coords; nothing is visible
|
|
5091
|
+
if (!cameraScale) return false;
|
|
5092
|
+
|
|
4904
5093
|
// optimized circle on screen test
|
|
4905
5094
|
// pos = worldToScreen(pos);
|
|
4906
5095
|
let x = pos.x - cameraPos.x;
|
|
@@ -4942,7 +5131,10 @@ function combineCanvases()
|
|
|
4942
5131
|
const w = mainCanvasSize.x, h = mainCanvasSize.y;
|
|
4943
5132
|
workCanvas.width = w;
|
|
4944
5133
|
workCanvas.height = h;
|
|
4945
|
-
|
|
5134
|
+
// remove background alpha — explicit fillStyle so a previous caller
|
|
5135
|
+
// leaving workContext.fillStyle transparent can't silently no-op this
|
|
5136
|
+
workContext.fillStyle = '#000';
|
|
5137
|
+
workContext.fillRect(0,0,w,h);
|
|
4946
5138
|
glCopyToContext(workContext);
|
|
4947
5139
|
workContext.drawImage(mainCanvas, 0, 0);
|
|
4948
5140
|
mainContext.drawImage(workCanvas, 0, 0);
|
|
@@ -5085,33 +5277,33 @@ function setCursor(cursorStyle = 'auto')
|
|
|
5085
5277
|
///////////////////////////////////////////////////////////////////////////////
|
|
5086
5278
|
|
|
5087
5279
|
/** Engine font image, 8x8 font provided by the engine
|
|
5088
|
-
* @type {
|
|
5280
|
+
* @type {ImageFont}
|
|
5089
5281
|
* @memberof Draw */
|
|
5090
|
-
let
|
|
5282
|
+
let engineImageFont;
|
|
5091
5283
|
|
|
5092
5284
|
/**
|
|
5093
|
-
* Font
|
|
5285
|
+
* Image Font Object - Draw text by using tiles in an image
|
|
5094
5286
|
* - 96 characters (from space to tilde) are stored in an image
|
|
5095
5287
|
* - A 8x8 default engine font is supplied for general use
|
|
5096
5288
|
* - This system is WebGL enabled for fast text rendering
|
|
5097
5289
|
* - Fonts can also be colored and scaled along each axis
|
|
5098
|
-
*
|
|
5290
|
+
*
|
|
5099
5291
|
* @memberof Draw
|
|
5100
5292
|
* @example
|
|
5101
5293
|
* // use built in font
|
|
5102
|
-
* const font =
|
|
5294
|
+
* const font = engineImageFont;
|
|
5103
5295
|
*
|
|
5104
5296
|
* // draw text
|
|
5105
5297
|
* font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50));
|
|
5106
5298
|
*/
|
|
5107
|
-
class
|
|
5299
|
+
class ImageFont
|
|
5108
5300
|
{
|
|
5109
5301
|
/** Create an image font
|
|
5110
5302
|
* @param {TileInfo} tileInfo - Tile info of first character in font
|
|
5111
5303
|
*/
|
|
5112
5304
|
constructor(tileInfo)
|
|
5113
5305
|
{
|
|
5114
|
-
ASSERT(!!tileInfo, 'tileInfo is required for
|
|
5306
|
+
ASSERT(!!tileInfo, 'tileInfo is required for ImageFont');
|
|
5115
5307
|
|
|
5116
5308
|
/** @property {TileInfo} - Tile info for the font */
|
|
5117
5309
|
this.tileInfo = tileInfo.frame(0);
|
|
@@ -5196,7 +5388,7 @@ class FontImage
|
|
|
5196
5388
|
}
|
|
5197
5389
|
|
|
5198
5390
|
// load engine font, called automatically on startup
|
|
5199
|
-
async function
|
|
5391
|
+
async function imageFontInit()
|
|
5200
5392
|
{
|
|
5201
5393
|
const image = new Image;
|
|
5202
5394
|
await new Promise(resolve =>
|
|
@@ -5209,7 +5401,7 @@ async function fontImageInit()
|
|
|
5209
5401
|
const tilePos=vec2(), tileSize=vec2(8), padding=1, bleed=0;
|
|
5210
5402
|
const textureInfo = new TextureInfo(image);
|
|
5211
5403
|
const tileInfo = new TileInfo(tilePos, tileSize, textureInfo, padding, bleed);
|
|
5212
|
-
|
|
5404
|
+
engineImageFont = new ImageFont(tileInfo);
|
|
5213
5405
|
}
|
|
5214
5406
|
/**
|
|
5215
5407
|
* LittleJS Input System
|
|
@@ -5301,6 +5493,7 @@ function inputClear()
|
|
|
5301
5493
|
inputData[0] = [];
|
|
5302
5494
|
touchGamepadButtons.length = 0;
|
|
5303
5495
|
touchGamepadSticks.length = 0;
|
|
5496
|
+
touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor
|
|
5304
5497
|
gamepadStickData.length = 0;
|
|
5305
5498
|
gamepadDpadData.length = 0;
|
|
5306
5499
|
}
|
|
@@ -5543,6 +5736,14 @@ const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
|
|
|
5543
5736
|
|
|
5544
5737
|
// touch gamepad internal variables
|
|
5545
5738
|
const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
|
|
5739
|
+
// floating stick anchors (stage-local CSS pixels) and owning pointer ids, indexed by stick (0=left, 1=right)
|
|
5740
|
+
const touchGamepadStickAnchors = [], touchGamepadStickPointerId = [];
|
|
5741
|
+
// pointerId -> control role ('stick0', 'stick1', 'face<n>', or 'start')
|
|
5742
|
+
const touchGamepadPointerRole = new Map();
|
|
5743
|
+
// overlay DOM elements (created lazily on touch devices) and cached SVG shapes
|
|
5744
|
+
let touchGamepadOverlay, touchGamepadStage, touchGamepadSvg, touchGamepadSvgEls;
|
|
5745
|
+
let touchGamepadSideZones = [], touchGamepadZoneC;
|
|
5746
|
+
let touchGamepadNeedRelayout = true, touchGamepadLastLayout;
|
|
5546
5747
|
|
|
5547
5748
|
///////////////////////////////////////////////////////////////////////////////
|
|
5548
5749
|
// Input system functions used by engine
|
|
@@ -5658,14 +5859,24 @@ function inputInit()
|
|
|
5658
5859
|
mouseDeltaScreen = mouseDeltaScreen.add(movement);
|
|
5659
5860
|
}
|
|
5660
5861
|
function onMouseLeave() { mouseInWindow = false; } // mouse moved off window
|
|
5661
|
-
function onMouseWheel(e)
|
|
5662
|
-
{
|
|
5663
|
-
|
|
5862
|
+
function onMouseWheel(e)
|
|
5863
|
+
{
|
|
5864
|
+
// accumulate so multiple wheel events in one frame are not lost
|
|
5865
|
+
if (!e.ctrlKey)
|
|
5866
|
+
mouseWheel += sign(e.deltaY);
|
|
5664
5867
|
if (inputPreventDefault && e.cancelable && document.hasFocus())
|
|
5665
5868
|
e.preventDefault(); // prevent page scrolling
|
|
5666
5869
|
}
|
|
5667
5870
|
function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
|
|
5668
|
-
function onBlur()
|
|
5871
|
+
function onBlur()
|
|
5872
|
+
{
|
|
5873
|
+
inputClear();
|
|
5874
|
+
// release any held virtual gamepad controls so they don't stick
|
|
5875
|
+
touchGamepadPointerRole.clear();
|
|
5876
|
+
touchGamepadButtons.length = 0;
|
|
5877
|
+
touchGamepadSticks.length = 0;
|
|
5878
|
+
touchGamepadStickPointerId.length = 0;
|
|
5879
|
+
}
|
|
5669
5880
|
|
|
5670
5881
|
// enable touch input mouse passthrough
|
|
5671
5882
|
function touchInputInit()
|
|
@@ -5681,36 +5892,46 @@ function inputInit()
|
|
|
5681
5892
|
{
|
|
5682
5893
|
if (!touchInputEnable) return;
|
|
5683
5894
|
|
|
5684
|
-
// route touch to gamepad
|
|
5685
|
-
if (touchGamepadEnable)
|
|
5686
|
-
handleTouchGamepad(e);
|
|
5687
|
-
|
|
5688
5895
|
// fix stalled audio requiring user interaction
|
|
5689
5896
|
if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
|
|
5690
5897
|
audioContext.resume();
|
|
5691
5898
|
|
|
5692
|
-
//
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
if (
|
|
5899
|
+
// when the touch gamepad is enabled it owns touch input: suppress the
|
|
5900
|
+
// touch->mouse passthrough entirely unless touchGamepadPassthrough is set
|
|
5901
|
+
// (its own zones drive gameplay via pointer events)
|
|
5902
|
+
if (!touchGamepadEnable || touchGamepadPassthrough)
|
|
5696
5903
|
{
|
|
5697
|
-
//
|
|
5698
|
-
|
|
5699
|
-
const
|
|
5700
|
-
|
|
5701
|
-
|
|
5904
|
+
// touches that landed on a virtual gamepad zone are owned by the gamepad
|
|
5905
|
+
// (handled by its own pointer listeners) and must not drive the game mouse
|
|
5906
|
+
const isGamepadTouch = (t)=>
|
|
5907
|
+
touchGamepadSideZones.includes(t.target) || t.target === touchGamepadZoneC;
|
|
5908
|
+
const gameTouches = [];
|
|
5909
|
+
for (const t of e.touches)
|
|
5910
|
+
if (!isGamepadTouch(t)) gameTouches.push(t);
|
|
5911
|
+
|
|
5912
|
+
// check if touching and pass to mouse events
|
|
5913
|
+
const touching = gameTouches.length;
|
|
5914
|
+
const button = 0; // all touches are left mouse button
|
|
5915
|
+
if (touching)
|
|
5702
5916
|
{
|
|
5703
|
-
|
|
5704
|
-
|
|
5917
|
+
// set event pos and pass it along
|
|
5918
|
+
const pos = vec2(gameTouches[0].clientX, gameTouches[0].clientY);
|
|
5919
|
+
const mousePosScreenLast = mousePosScreen;
|
|
5920
|
+
mousePosScreen = mouseEventToScreen(pos);
|
|
5921
|
+
if (wasTouching)
|
|
5922
|
+
mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
|
|
5923
|
+
else
|
|
5924
|
+
{
|
|
5925
|
+
inputData[0][button] = 3;
|
|
5926
|
+
isUsingGamepad = false; // a passthrough tap is mouse-style input
|
|
5927
|
+
}
|
|
5705
5928
|
}
|
|
5706
|
-
else
|
|
5707
|
-
inputData[0][button] =
|
|
5708
|
-
}
|
|
5709
|
-
else if (wasTouching)
|
|
5710
|
-
inputData[0][button] = inputData[0][button] & 2 | 4;
|
|
5929
|
+
else if (wasTouching)
|
|
5930
|
+
inputData[0][button] = inputData[0][button] & 2 | 4;
|
|
5711
5931
|
|
|
5712
|
-
|
|
5713
|
-
|
|
5932
|
+
// set was touching
|
|
5933
|
+
wasTouching = touching;
|
|
5934
|
+
}
|
|
5714
5935
|
|
|
5715
5936
|
// prevent default handling like copy, magnifier lens, and scrolling
|
|
5716
5937
|
if (inputPreventDefault && e.cancelable && document.hasFocus())
|
|
@@ -5720,78 +5941,6 @@ function inputInit()
|
|
|
5720
5941
|
return true;
|
|
5721
5942
|
}
|
|
5722
5943
|
|
|
5723
|
-
// special handling for virtual gamepad mode
|
|
5724
|
-
function handleTouchGamepad(e)
|
|
5725
|
-
{
|
|
5726
|
-
// clear touch gamepad input
|
|
5727
|
-
touchGamepadSticks.length = 0;
|
|
5728
|
-
touchGamepadSticks[0] = vec2();
|
|
5729
|
-
touchGamepadSticks[1] = vec2();
|
|
5730
|
-
touchGamepadButtons.length = 0;
|
|
5731
|
-
isUsingGamepad = true;
|
|
5732
|
-
|
|
5733
|
-
const touching = e.touches.length;
|
|
5734
|
-
if (touching)
|
|
5735
|
-
{
|
|
5736
|
-
touchGamepadTimer.set();
|
|
5737
|
-
if (touchGamepadCenterButtonSize && !wasTouching && paused)
|
|
5738
|
-
{
|
|
5739
|
-
// touch anywhere to press start when paused
|
|
5740
|
-
touchGamepadButtons[9] = 1;
|
|
5741
|
-
return;
|
|
5742
|
-
}
|
|
5743
|
-
}
|
|
5744
|
-
|
|
5745
|
-
// don't process touch gamepad if paused
|
|
5746
|
-
if (paused) return;
|
|
5747
|
-
|
|
5748
|
-
// get center of left and right sides
|
|
5749
|
-
const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
|
|
5750
|
-
const buttonCenter = touchGamepadButtonCenter();
|
|
5751
|
-
const startCenter = mainCanvasSize.scale(.5);
|
|
5752
|
-
|
|
5753
|
-
// check each touch point
|
|
5754
|
-
for (const touch of e.touches)
|
|
5755
|
-
{
|
|
5756
|
-
const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
|
|
5757
|
-
if (stickCenter.distance(touchPos) < touchGamepadSize)
|
|
5758
|
-
{
|
|
5759
|
-
// virtual analog stick
|
|
5760
|
-
const delta = touchPos.subtract(stickCenter);
|
|
5761
|
-
touchGamepadSticks[0] = delta.scale(2/touchGamepadSize).clampLength();
|
|
5762
|
-
touchGamepadButtons[10] = 1; // also press a button when touching stick
|
|
5763
|
-
}
|
|
5764
|
-
else if (buttonCenter.distance(touchPos) < touchGamepadSize)
|
|
5765
|
-
{
|
|
5766
|
-
if (touchGamepadButtonCount === 1)
|
|
5767
|
-
{
|
|
5768
|
-
// virtual right analog stick
|
|
5769
|
-
const delta = touchPos.subtract(buttonCenter);
|
|
5770
|
-
touchGamepadSticks[1] = delta.scale(2/touchGamepadSize).clampLength();
|
|
5771
|
-
touchGamepadButtons[11] = 1; // also press a button when touching right stick
|
|
5772
|
-
}
|
|
5773
|
-
// virtual face buttons
|
|
5774
|
-
let button = buttonCenter.subtract(touchPos).direction();
|
|
5775
|
-
button = mod(button+2, 4);
|
|
5776
|
-
if (touchGamepadButtonCount === 1)
|
|
5777
|
-
button = 0;
|
|
5778
|
-
else if (touchGamepadButtonCount === 2)
|
|
5779
|
-
{
|
|
5780
|
-
const delta = buttonCenter.subtract(touchPos);
|
|
5781
|
-
button = -delta.x < delta.y ? 1 : 0;
|
|
5782
|
-
}
|
|
5783
|
-
// fix button locations (swap 2 and 3 to match gamepad layout)
|
|
5784
|
-
button = button === 3 ? 2 : button === 2 ? 3 : button;
|
|
5785
|
-
if (button < touchGamepadButtonCount)
|
|
5786
|
-
touchGamepadButtons[button] = 1;
|
|
5787
|
-
}
|
|
5788
|
-
else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize)
|
|
5789
|
-
{
|
|
5790
|
-
// virtual start button in center
|
|
5791
|
-
touchGamepadButtons[9] = 1;
|
|
5792
|
-
}
|
|
5793
|
-
}
|
|
5794
|
-
}
|
|
5795
5944
|
}
|
|
5796
5945
|
|
|
5797
5946
|
// convert a mouse or touch event position to screen space
|
|
@@ -5816,6 +5965,9 @@ function inputUpdate()
|
|
|
5816
5965
|
mousePos = screenToWorld(mousePosScreen);
|
|
5817
5966
|
mouseDelta = screenToWorldDelta(mouseDeltaScreen);
|
|
5818
5967
|
|
|
5968
|
+
// build the touch gamepad overlay lazily once enabled on a touch device
|
|
5969
|
+
touchGamepadInit();
|
|
5970
|
+
|
|
5819
5971
|
// update gamepads if enabled
|
|
5820
5972
|
gamepadsUpdate();
|
|
5821
5973
|
|
|
@@ -5829,22 +5981,16 @@ function inputUpdate()
|
|
|
5829
5981
|
v > min ? percent(v, min, max) :
|
|
5830
5982
|
v < -min ? -percent(-v, min, max) : 0;
|
|
5831
5983
|
return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
|
|
5832
|
-
}
|
|
5984
|
+
};
|
|
5833
5985
|
|
|
5834
5986
|
// update touch gamepad if enabled
|
|
5835
5987
|
if (touchGamepadEnable && isTouchDevice)
|
|
5836
5988
|
{
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
5842
|
-
|
|
5843
|
-
debugCircle(stickCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
|
|
5844
|
-
debugCircle(buttonCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
|
|
5845
|
-
if (touchGamepadCenterButtonSize)
|
|
5846
|
-
debugCircle(startCenter, 2*touchGamepadCenterButtonSize, 'cyan', 0, false, true);
|
|
5847
|
-
}
|
|
5989
|
+
// a side is either a stick or buttons - setting both is ambiguous
|
|
5990
|
+
ASSERT(!touchGamepadLeftStick || !touchGamepadLeftButtonCount,
|
|
5991
|
+
'set touchGamepadLeftStick or touchGamepadLeftButtonCount, not both');
|
|
5992
|
+
ASSERT(!touchGamepadRightStick || !touchGamepadButtonCount,
|
|
5993
|
+
'set touchGamepadRightStick or touchGamepadButtonCount, not both');
|
|
5848
5994
|
|
|
5849
5995
|
if (!touchGamepadTimer.isSet()) return;
|
|
5850
5996
|
|
|
@@ -5852,23 +5998,24 @@ function inputUpdate()
|
|
|
5852
5998
|
gamepadPrimary = 0; // touch gamepad uses index 0
|
|
5853
5999
|
const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
|
|
5854
6000
|
const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
|
|
5855
|
-
sticks
|
|
6001
|
+
sticks.length = 0; // only report sticks that are enabled
|
|
5856
6002
|
dpad.set();
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
sticks[0] = applyDeadZones(leftTouchStick);
|
|
5860
|
-
else if (leftTouchStick.lengthSquared() > .3)
|
|
6003
|
+
// read each side's directional stick (analog, or quantized to an 8 way dpad)
|
|
6004
|
+
for (let side = 0; side < 2; side++)
|
|
5861
6005
|
{
|
|
5862
|
-
|
|
5863
|
-
const
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
6006
|
+
if (!touchGamepadSideStick(side)) continue;
|
|
6007
|
+
const out = touchGamepadStickOut(side);
|
|
6008
|
+
sticks[out] = vec2();
|
|
6009
|
+
const touchStick = touchGamepadSticks[side] ?? vec2();
|
|
6010
|
+
if (touchGamepadAnalog)
|
|
6011
|
+
sticks[out] = applyDeadZones(touchStick);
|
|
6012
|
+
else if (touchStick.lengthSquared() > .3)
|
|
6013
|
+
{
|
|
6014
|
+
const x = clamp(round(touchStick.x), -1, 1);
|
|
6015
|
+
const y = clamp(round(touchStick.y), -1, 1);
|
|
6016
|
+
sticks[out] = vec2(x, -y).clampLength(); // clamp to circle
|
|
6017
|
+
if (!out) dpad.set(x, -y); // the primary (stick 0) also drives the dpad vector
|
|
6018
|
+
}
|
|
5872
6019
|
}
|
|
5873
6020
|
|
|
5874
6021
|
// read virtual gamepad buttons
|
|
@@ -5877,6 +6024,12 @@ function inputUpdate()
|
|
|
5877
6024
|
{
|
|
5878
6025
|
const wasDown = gamepadIsDown(i,0);
|
|
5879
6026
|
data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
|
|
6027
|
+
|
|
6028
|
+
// haptic tap when a face button or start button is first pressed (3 = newly down)
|
|
6029
|
+
// skip stick touches (10, 11) so movement doesn't buzz
|
|
6030
|
+
if (touchGamepadVibration && data[i] === 3 &&
|
|
6031
|
+
(i === 9 || touchGamepadIsFaceButton(i)))
|
|
6032
|
+
vibrate(touchGamepadVibration);
|
|
5880
6033
|
}
|
|
5881
6034
|
|
|
5882
6035
|
// disable normal gamepads when touch gamepad is active
|
|
@@ -5950,13 +6103,6 @@ function inputUpdate()
|
|
|
5950
6103
|
(gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
|
|
5951
6104
|
(gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
|
|
5952
6105
|
}
|
|
5953
|
-
else if (gamepad.axes && gamepad.axes.length >= 2)
|
|
5954
|
-
{
|
|
5955
|
-
// digital style dpad from axes
|
|
5956
|
-
const x = clamp(round(gamepad.axes[0]), -1, 1);
|
|
5957
|
-
const y = clamp(round(gamepad.axes[1]), -1, 1);
|
|
5958
|
-
dpad.set(x, -y);
|
|
5959
|
-
}
|
|
5960
6106
|
|
|
5961
6107
|
// copy dpad to left analog stick when pressed
|
|
5962
6108
|
if (gamepadDirectionEmulateStick && (dpad.x || dpad.y))
|
|
@@ -5984,80 +6130,470 @@ function inputUpdatePost()
|
|
|
5984
6130
|
function inputRender()
|
|
5985
6131
|
{
|
|
5986
6132
|
touchGamepadRender();
|
|
6133
|
+
}
|
|
6134
|
+
|
|
6135
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6136
|
+
// Touch gamepad - full-viewport HTML/SVG overlay driven by Pointer Events
|
|
6137
|
+
|
|
6138
|
+
const touchGamepadSvgNS = 'http://www.w3.org/2000/svg';
|
|
6139
|
+
|
|
6140
|
+
// build the overlay DOM once; no-op if already built, disabled, headless, or non-touch
|
|
6141
|
+
function touchGamepadInit()
|
|
6142
|
+
{
|
|
6143
|
+
if (touchGamepadOverlay || !touchGamepadEnable || !isTouchDevice || headlessMode ||
|
|
6144
|
+
!document.body) // body may not exist yet; retry on a later frame
|
|
6145
|
+
return;
|
|
6146
|
+
|
|
6147
|
+
// full-viewport overlay; only the input zones receive pointer events. The
|
|
6148
|
+
// env() padding insets the stage out of notches / home indicators natively.
|
|
6149
|
+
const overlay = touchGamepadOverlay = document.createElement('div');
|
|
6150
|
+
overlay.style.cssText =
|
|
6151
|
+
'position:fixed;inset:0;z-index:50;pointer-events:none;opacity:0;' +
|
|
6152
|
+
'touch-action:none;user-select:none;-webkit-user-select:none;' +
|
|
6153
|
+
'-webkit-touch-callout:none;transition:opacity .2s;box-sizing:border-box;' +
|
|
6154
|
+
'padding:env(safe-area-inset-top) env(safe-area-inset-right) ' +
|
|
6155
|
+
'env(safe-area-inset-bottom) env(safe-area-inset-left)';
|
|
6156
|
+
|
|
6157
|
+
// stage fills the padded (safe-area) content box; all controls live inside it
|
|
6158
|
+
const stage = touchGamepadStage = document.createElement('div');
|
|
6159
|
+
stage.style.cssText = 'position:relative;width:100%;height:100%;pointer-events:none';
|
|
6160
|
+
overlay.appendChild(stage);
|
|
6161
|
+
|
|
6162
|
+
// svg draws every visual and never blocks input
|
|
6163
|
+
const svg = touchGamepadSvg = document.createElementNS(touchGamepadSvgNS, 'svg');
|
|
6164
|
+
svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;' +
|
|
6165
|
+
'pointer-events:none;overflow:visible;fill:none;stroke:#fff;stroke-width:3';
|
|
6166
|
+
stage.appendChild(svg);
|
|
6167
|
+
|
|
6168
|
+
// invisible input zones (left stick, right buttons/stick, center start)
|
|
6169
|
+
const makeZone = ()=>
|
|
6170
|
+
{
|
|
6171
|
+
const z = document.createElement('div');
|
|
6172
|
+
z.style.cssText = 'position:absolute;pointer-events:auto;touch-action:none';
|
|
6173
|
+
z.addEventListener('pointerdown', e=> touchGamepadPointerDown(e, z));
|
|
6174
|
+
z.addEventListener('pointermove', e=> touchGamepadPointerMove(e));
|
|
6175
|
+
z.addEventListener('pointerup', e=> touchGamepadPointerUp(e));
|
|
6176
|
+
z.addEventListener('pointercancel', e=> touchGamepadPointerUp(e));
|
|
6177
|
+
stage.appendChild(z);
|
|
6178
|
+
return z;
|
|
6179
|
+
};
|
|
6180
|
+
touchGamepadSideZones[0] = makeZone(); // left
|
|
6181
|
+
touchGamepadSideZones[1] = makeZone(); // right
|
|
6182
|
+
touchGamepadZoneC = makeZone(); // center/start, appended last so it sits above the sides
|
|
6183
|
+
|
|
6184
|
+
addEventListener('resize', ()=> touchGamepadNeedRelayout = true);
|
|
6185
|
+
document.body.appendChild(overlay);
|
|
6186
|
+
touchGamepadNeedRelayout = true;
|
|
6187
|
+
}
|
|
6188
|
+
|
|
6189
|
+
// stage-local size in CSS pixels (excludes safe-area insets)
|
|
6190
|
+
function touchGamepadStageRect() { return touchGamepadStage.getBoundingClientRect(); }
|
|
6191
|
+
|
|
6192
|
+
// per-side touch gamepad config (side 0 = left, 1 = right) - the left and right
|
|
6193
|
+
// sides behave identically, differing only in position and gamepad button indices
|
|
6194
|
+
function touchGamepadSideStick(side)
|
|
6195
|
+
{ return side ? touchGamepadRightStick : touchGamepadLeftStick; }
|
|
6196
|
+
function touchGamepadSideButtonCount(side)
|
|
6197
|
+
{ return side ? touchGamepadButtonCount : touchGamepadLeftButtonCount; }
|
|
6198
|
+
// gamepad button index a side's buttons start at (right 0-3, left 4-7)
|
|
6199
|
+
function touchGamepadSideButtonBase(side)
|
|
6200
|
+
{ return side ? 0 : 4; }
|
|
6201
|
+
// output stick index for a side: the right stick uses stick 0 when there is no left stick
|
|
6202
|
+
function touchGamepadStickOut(side)
|
|
6203
|
+
{ return side && touchGamepadLeftStick ? 1 : 0; }
|
|
6204
|
+
// true if the side has any control (a stick or at least one button)
|
|
6205
|
+
function touchGamepadSideHasControl(side)
|
|
6206
|
+
{ return touchGamepadSideStick(side) || touchGamepadSideButtonCount(side) > 0; }
|
|
6207
|
+
|
|
6208
|
+
// true if gamepad button index i is an active touch gamepad face/single button
|
|
6209
|
+
function touchGamepadIsFaceButton(i)
|
|
6210
|
+
{
|
|
6211
|
+
for (let side = 0; side < 2; side++)
|
|
6212
|
+
{
|
|
6213
|
+
const base = touchGamepadSideButtonBase(side);
|
|
6214
|
+
if (!touchGamepadSideStick(side) &&
|
|
6215
|
+
i >= base && i < base + touchGamepadSideButtonCount(side))
|
|
6216
|
+
return true;
|
|
6217
|
+
}
|
|
6218
|
+
return false;
|
|
6219
|
+
}
|
|
5987
6220
|
|
|
5988
|
-
|
|
6221
|
+
// center of a side's controls in stage-local CSS pixels (stick rest / button cluster)
|
|
6222
|
+
// returns the floating stick anchor when that side is an active floating stick
|
|
6223
|
+
function touchGamepadSideCenter(side, W, H)
|
|
6224
|
+
{
|
|
6225
|
+
if (touchGamepadFloating && touchGamepadSideStick(side) && touchGamepadStickAnchors[side])
|
|
6226
|
+
return touchGamepadStickAnchors[side];
|
|
6227
|
+
let y = H - touchGamepadSize;
|
|
6228
|
+
const count = touchGamepadSideButtonCount(side);
|
|
6229
|
+
if (!touchGamepadSideStick(side) && (count === 2 || count === 3))
|
|
6230
|
+
y -= touchGamepadSize/4; // nudge a 2/3 button cluster up a bit
|
|
6231
|
+
return vec2(side ? W - touchGamepadSize : touchGamepadSize, y);
|
|
6232
|
+
}
|
|
6233
|
+
|
|
6234
|
+
// position the input zones for the current mode and rebuild the SVG visuals
|
|
6235
|
+
function touchGamepadRelayout()
|
|
6236
|
+
{
|
|
6237
|
+
if (!touchGamepadOverlay) return;
|
|
6238
|
+
const r = touchGamepadStageRect();
|
|
6239
|
+
const W = r.width, H = r.height, S = touchGamepadSize;
|
|
6240
|
+
const setZone = (z, css)=> z.style.cssText =
|
|
6241
|
+
'position:absolute;pointer-events:auto;touch-action:none;' + css;
|
|
6242
|
+
|
|
6243
|
+
if (paused && touchGamepadCenterButtonSize)
|
|
6244
|
+
{
|
|
6245
|
+
// while paused, any touch presses start
|
|
6246
|
+
setZone(touchGamepadZoneC, 'inset:0');
|
|
6247
|
+
for (const zone of touchGamepadSideZones) zone.style.display = 'none';
|
|
6248
|
+
touchGamepadZoneC.style.display = '';
|
|
6249
|
+
}
|
|
6250
|
+
else
|
|
5989
6251
|
{
|
|
5990
|
-
|
|
5991
|
-
|
|
6252
|
+
// position each side zone (left/right differ only by which edge they hug)
|
|
6253
|
+
for (let side = 0; side < 2; side++)
|
|
6254
|
+
{
|
|
6255
|
+
const zone = touchGamepadSideZones[side], edge = side ? 'right' : 'left';
|
|
6256
|
+
zone.style.display = touchGamepadSideHasControl(side) ? '' : 'none';
|
|
6257
|
+
if (touchGamepadFloating)
|
|
6258
|
+
{
|
|
6259
|
+
// bottom 60% grabs the control; the top 40% passes through. A side with no
|
|
6260
|
+
// control on the other side uses the full width (matching the hit-test)
|
|
6261
|
+
const width = touchGamepadSideHasControl(side ? 0 : 1) ? '50%' : '100%';
|
|
6262
|
+
setZone(zone, `${edge}:0;bottom:0;width:${width};height:60%`);
|
|
6263
|
+
}
|
|
6264
|
+
else // fixed: a compact box hugging the corner control
|
|
6265
|
+
setZone(zone, `${edge}:0;bottom:0;width:${3*S}px;height:${3*S}px`);
|
|
6266
|
+
}
|
|
6267
|
+
touchGamepadZoneC.style.display = touchGamepadCenterButtonSize ? '' : 'none';
|
|
6268
|
+
const c = touchGamepadCenterButtonSize;
|
|
6269
|
+
setZone(touchGamepadZoneC,
|
|
6270
|
+
`left:50%;top:50%;width:${2*c}px;height:${2*c}px;transform:translate(-50%,-50%)`);
|
|
6271
|
+
}
|
|
5992
6272
|
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
6273
|
+
touchGamepadBuildSvg(W, H);
|
|
6274
|
+
touchGamepadNeedRelayout = false;
|
|
6275
|
+
}
|
|
5996
6276
|
|
|
5997
|
-
|
|
5998
|
-
|
|
5999
|
-
|
|
6000
|
-
|
|
6001
|
-
|
|
6002
|
-
|
|
6277
|
+
// (re)build the SVG shapes for the current layout; dynamic bits update per-frame
|
|
6278
|
+
function touchGamepadBuildSvg(W, H)
|
|
6279
|
+
{
|
|
6280
|
+
const svg = touchGamepadSvg;
|
|
6281
|
+
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
|
6282
|
+
const els = touchGamepadSvgEls = { face: [], thumb: [] };
|
|
6283
|
+
const S = touchGamepadSize;
|
|
6284
|
+
const circle = (cx, cy, rr, fill)=>
|
|
6285
|
+
{
|
|
6286
|
+
const c = document.createElementNS(touchGamepadSvgNS, 'circle');
|
|
6287
|
+
c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', rr);
|
|
6288
|
+
if (fill) c.setAttribute('fill', fill);
|
|
6289
|
+
svg.appendChild(c);
|
|
6290
|
+
return c;
|
|
6291
|
+
};
|
|
6292
|
+
const cross = (ctr)=>
|
|
6293
|
+
{
|
|
6294
|
+
// plus-shaped dpad outline centered at ctr
|
|
6295
|
+
const a = S*.18, b = S*.5, x = ctr.x, y = ctr.y;
|
|
6296
|
+
const p = document.createElementNS(touchGamepadSvgNS, 'path');
|
|
6297
|
+
p.setAttribute('d',
|
|
6298
|
+
`M ${x-a} ${y-b} H ${x+a} V ${y-a} H ${x+b} V ${y+a} H ${x+a} ` +
|
|
6299
|
+
`V ${y+b} H ${x-a} V ${y+a} H ${x-b} V ${y-a} H ${x-a} Z`);
|
|
6300
|
+
svg.appendChild(p);
|
|
6301
|
+
};
|
|
6003
6302
|
|
|
6004
|
-
|
|
6005
|
-
|
|
6006
|
-
|
|
6007
|
-
|
|
6008
|
-
const
|
|
6009
|
-
|
|
6303
|
+
// draw each side: a directional stick, a single large button, or face buttons
|
|
6304
|
+
for (let side = 0; side < 2; side++)
|
|
6305
|
+
{
|
|
6306
|
+
const count = touchGamepadSideButtonCount(side);
|
|
6307
|
+
const base = touchGamepadSideButtonBase(side);
|
|
6308
|
+
const ctr = touchGamepadSideCenter(side, W, H);
|
|
6309
|
+
if (touchGamepadSideStick(side))
|
|
6010
6310
|
{
|
|
6011
|
-
//
|
|
6012
|
-
|
|
6311
|
+
// directional stick (circle or cross) with a thumb dot that moves per-frame
|
|
6312
|
+
if (touchGamepadAnalog) circle(ctr.x, ctr.y, S/2); else cross(ctr);
|
|
6313
|
+
els.thumb[side] = circle(ctr.x, ctr.y, S/4, '#fff');
|
|
6013
6314
|
}
|
|
6014
|
-
else
|
|
6315
|
+
else if (count === 1)
|
|
6316
|
+
els.face[base] = circle(ctr.x, ctr.y, S/2, '#000'); // single large button
|
|
6317
|
+
else for (let i = 0; i < count; i++)
|
|
6015
6318
|
{
|
|
6016
|
-
|
|
6017
|
-
|
|
6018
|
-
|
|
6019
|
-
|
|
6020
|
-
|
|
6021
|
-
|
|
6022
|
-
|
|
6319
|
+
const j = mod(i-1, 4);
|
|
6320
|
+
let button = count > 2 ? j : min(j, count-1);
|
|
6321
|
+
button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
|
|
6322
|
+
const offset = vec2().setDirection(j, S/2);
|
|
6323
|
+
if (count === 2) offset.x *= -1;
|
|
6324
|
+
// left side mirrors the right layout's positions, keeping indices in order
|
|
6325
|
+
// (e.g. 2 buttons -> button 4 at bottom, button 5 at left)
|
|
6326
|
+
if (!side) offset.x *= -1;
|
|
6327
|
+
const pos = ctr.add(offset);
|
|
6328
|
+
els.face[base + button] = circle(pos.x, pos.y, S/4, '#000');
|
|
6023
6329
|
}
|
|
6024
|
-
|
|
6025
|
-
context.stroke();
|
|
6330
|
+
}
|
|
6026
6331
|
|
|
6027
|
-
|
|
6332
|
+
// debug: draw the proximity hit regions the hit-test actually uses
|
|
6333
|
+
if (debug && debugGamepads) touchGamepadBuildDebug(W, H);
|
|
6334
|
+
}
|
|
6335
|
+
|
|
6336
|
+
// draw debug outlines of the touch control hit regions into the overlay svg
|
|
6337
|
+
function touchGamepadBuildDebug(W, H)
|
|
6338
|
+
{
|
|
6339
|
+
const S = touchGamepadSize, svg = touchGamepadSvg;
|
|
6340
|
+
const shape = (tag, attrs, stroke)=>
|
|
6341
|
+
{
|
|
6342
|
+
const el = document.createElementNS(touchGamepadSvgNS, tag);
|
|
6343
|
+
for (const k in attrs) el.setAttribute(k, attrs[k]);
|
|
6344
|
+
el.setAttribute('stroke', stroke);
|
|
6345
|
+
el.setAttribute('stroke-width', 2);
|
|
6346
|
+
el.setAttribute('fill', 'none');
|
|
6347
|
+
svg.appendChild(el);
|
|
6348
|
+
};
|
|
6349
|
+
const ring = (c, rr, stroke)=> shape('circle', {cx:c.x, cy:c.y, r:rr}, stroke);
|
|
6350
|
+
|
|
6351
|
+
// green line: the left/right split that assigns a stick press to a side
|
|
6352
|
+
shape('line', {x1:W/2, y1:0, x2:W/2, y2:H}, '#0f0');
|
|
6353
|
+
|
|
6354
|
+
// cyan: where each side's control can be grabbed
|
|
6355
|
+
for (let side = 0; side < 2; side++)
|
|
6356
|
+
{
|
|
6357
|
+
if (touchGamepadSideStick(side))
|
|
6028
6358
|
{
|
|
6029
|
-
|
|
6030
|
-
const buttonSize = touchGamepadButtonCount > 1 ?
|
|
6031
|
-
touchGamepadSize/4 : touchGamepadSize/2;
|
|
6032
|
-
for (let i=0; i<touchGamepadButtonCount; i++)
|
|
6359
|
+
if (touchGamepadFloating)
|
|
6033
6360
|
{
|
|
6034
|
-
|
|
6035
|
-
|
|
6036
|
-
|
|
6037
|
-
|
|
6038
|
-
button = button === 3 ? 2 : button === 2 ? 3 : button;
|
|
6039
|
-
const pos = touchGamepadButtonCount < 2 ? buttonCenter :
|
|
6040
|
-
buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
|
|
6041
|
-
context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
|
|
6042
|
-
context.beginPath();
|
|
6043
|
-
context.arc(pos.x, pos.y, buttonSize, 0,9);
|
|
6044
|
-
context.fill();
|
|
6045
|
-
context.stroke();
|
|
6361
|
+
// grab region: this side's half (or the full width if the other side is empty)
|
|
6362
|
+
const top = H*.4, full = !touchGamepadSideHasControl(side ? 0 : 1);
|
|
6363
|
+
const x = full ? 0 : (side ? W/2 : 0);
|
|
6364
|
+
shape('rect', {x, y:top, width:full ? W : W/2, height:H-top}, '#0ff');
|
|
6046
6365
|
}
|
|
6366
|
+
else
|
|
6367
|
+
ring(touchGamepadSideCenter(side, W, H), 2*S, '#0ff');
|
|
6047
6368
|
}
|
|
6369
|
+
else if (touchGamepadSideButtonCount(side) >= 1)
|
|
6370
|
+
ring(touchGamepadSideCenter(side, W, H), S, '#0ff'); // face / single-button radius
|
|
6371
|
+
}
|
|
6048
6372
|
|
|
6049
|
-
|
|
6050
|
-
|
|
6373
|
+
// yellow: start button radius; magenta: where start is blocked (near a control)
|
|
6374
|
+
if (touchGamepadCenterButtonSize)
|
|
6375
|
+
{
|
|
6376
|
+
ring(vec2(W/2, H/2), touchGamepadCenterButtonSize, '#ff0');
|
|
6377
|
+
for (let side = 0; side < 2; side++)
|
|
6378
|
+
if (touchGamepadSideHasControl(side))
|
|
6379
|
+
ring(touchGamepadSideCenter(side, W, H), 2*S, '#f0f');
|
|
6380
|
+
}
|
|
6381
|
+
}
|
|
6382
|
+
|
|
6383
|
+
// per-frame: fade the overlay and move the thumbs / set pressed states
|
|
6384
|
+
function touchGamepadRender()
|
|
6385
|
+
{
|
|
6386
|
+
if (!touchGamepadOverlay || headlessMode) return;
|
|
6387
|
+
|
|
6388
|
+
// hide and bail if disabled at runtime (overlay stays in the DOM for reuse)
|
|
6389
|
+
// display:none also takes the input zones out of hit-testing so touches are
|
|
6390
|
+
// not silently captured away from the game while disabled
|
|
6391
|
+
if (!touchGamepadEnable || !isTouchDevice)
|
|
6392
|
+
{
|
|
6393
|
+
if (touchGamepadOverlay.style.display !== 'none')
|
|
6394
|
+
{
|
|
6395
|
+
// just disabled: hide the overlay and release any held controls
|
|
6396
|
+
touchGamepadOverlay.style.display = 'none';
|
|
6397
|
+
touchGamepadPointerRole.clear();
|
|
6398
|
+
touchGamepadButtons.length = 0;
|
|
6399
|
+
touchGamepadSticks.length = 0;
|
|
6400
|
+
touchGamepadStickPointerId.length = 0;
|
|
6401
|
+
}
|
|
6402
|
+
return;
|
|
6403
|
+
}
|
|
6404
|
+
touchGamepadOverlay.style.display = '';
|
|
6405
|
+
|
|
6406
|
+
// relayout when the paused state, a layout setting, or the debug view changes
|
|
6407
|
+
const dbg = debug && debugGamepads;
|
|
6408
|
+
const layout = [touchGamepadButtonCount, touchGamepadLeftButtonCount, touchGamepadLeftStick,
|
|
6409
|
+
touchGamepadRightStick, touchGamepadAnalog, touchGamepadSize, touchGamepadFloating,
|
|
6410
|
+
touchGamepadCenterButtonSize, paused, dbg].join();
|
|
6411
|
+
if (layout !== touchGamepadLastLayout)
|
|
6412
|
+
{
|
|
6413
|
+
touchGamepadLastLayout = layout;
|
|
6414
|
+
touchGamepadNeedRelayout = true;
|
|
6415
|
+
}
|
|
6416
|
+
// relayout before the visibility bail-out so the paused full-screen start zone applies
|
|
6417
|
+
if (touchGamepadNeedRelayout) touchGamepadRelayout();
|
|
6418
|
+
|
|
6419
|
+
// fade out when idle (always show when displayTime is 0, or while debugging)
|
|
6420
|
+
const fade = touchGamepadDisplayTime ?
|
|
6421
|
+
percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
|
|
6422
|
+
const visible = dbg || (touchGamepadTimer.isSet() && fade > 0 && !paused);
|
|
6423
|
+
touchGamepadOverlay.style.opacity = !visible ? 0 : dbg ? 1 : fade*touchGamepadAlpha;
|
|
6424
|
+
if (!visible) return;
|
|
6425
|
+
|
|
6426
|
+
const r = touchGamepadStageRect();
|
|
6427
|
+
const W = r.width, H = r.height, S = touchGamepadSize;
|
|
6428
|
+
const els = touchGamepadSvgEls;
|
|
6429
|
+
if (!els) return;
|
|
6430
|
+
|
|
6431
|
+
for (let side = 0; side < 2; side++)
|
|
6432
|
+
if (touchGamepadSideStick(side) && els.thumb[side])
|
|
6433
|
+
{
|
|
6434
|
+
const ctr = touchGamepadSideCenter(side, W, H);
|
|
6435
|
+
const t = ctr.add((touchGamepadSticks[side] ?? vec2()).scale(S/2));
|
|
6436
|
+
els.thumb[side].setAttribute('cx', t.x);
|
|
6437
|
+
els.thumb[side].setAttribute('cy', t.y);
|
|
6438
|
+
}
|
|
6439
|
+
for (let i = 0; i < els.face.length; i++)
|
|
6440
|
+
if (els.face[i])
|
|
6441
|
+
els.face[i].setAttribute('fill', touchGamepadButtons[i] ? '#fff' : '#000');
|
|
6442
|
+
}
|
|
6443
|
+
|
|
6444
|
+
// convert a pointer event to stage-local CSS pixels
|
|
6445
|
+
function touchGamepadEventPos(e)
|
|
6446
|
+
{
|
|
6447
|
+
const r = touchGamepadStageRect();
|
|
6448
|
+
return vec2(e.clientX - r.left, e.clientY - r.top);
|
|
6449
|
+
}
|
|
6450
|
+
|
|
6451
|
+
// set a directional stick from a stage-local point and flag its stick-touch button
|
|
6452
|
+
// (stick 0 press = button 10, stick 1 press = button 11, following the output index)
|
|
6453
|
+
function touchGamepadApplyStick(side, p)
|
|
6454
|
+
{
|
|
6455
|
+
const delta = p.subtract(touchGamepadStickAnchors[side]);
|
|
6456
|
+
touchGamepadSticks[side] = delta.scale(2/touchGamepadSize).clampLength();
|
|
6457
|
+
touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10] = 1;
|
|
6458
|
+
}
|
|
6459
|
+
|
|
6460
|
+
// pick a side's gamepad button index from a stage-local point, or -1 if outside the cluster
|
|
6461
|
+
function touchGamepadFaceButtonAt(side, p, W, H)
|
|
6462
|
+
{
|
|
6463
|
+
const count = touchGamepadSideButtonCount(side);
|
|
6464
|
+
const base = touchGamepadSideButtonBase(side);
|
|
6465
|
+
const bc = touchGamepadSideCenter(side, W, H);
|
|
6466
|
+
if (bc.distance(p) >= touchGamepadSize) return -1;
|
|
6467
|
+
if (count === 1) return base; // single large button
|
|
6468
|
+
const d = bc.subtract(p);
|
|
6469
|
+
if (!side) d.x *= -1; // left side mirrors the right layout's positions horizontally
|
|
6470
|
+
let button = count === 2 ? (d.x < d.y ? 1 : 0) : mod(d.direction()+2, 4);
|
|
6471
|
+
button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
|
|
6472
|
+
return button < count ? base + button : -1;
|
|
6473
|
+
}
|
|
6474
|
+
|
|
6475
|
+
// pick which control a stage-local press activates, by priority then proximity,
|
|
6476
|
+
// independent of which zone element captured it - so overlapping zones on small
|
|
6477
|
+
// screens resolve to the nearest control instead of whichever zone is topmost
|
|
6478
|
+
// returns {role:'stick', side} or {role:'face', btn} or {role:'start'} or undefined
|
|
6479
|
+
function touchGamepadControlAt(p, W, H)
|
|
6480
|
+
{
|
|
6481
|
+
const S = touchGamepadSize;
|
|
6482
|
+
const leftHalf = p.x < W/2;
|
|
6483
|
+
const floatTop = H*.4; // floating grab region is the bottom 60% of the screen
|
|
6484
|
+
|
|
6485
|
+
// check each side (left first for priority); a side is a stick or buttons
|
|
6486
|
+
for (let side = 0; side < 2; side++)
|
|
6487
|
+
{
|
|
6488
|
+
const onHalf = side ? !leftHalf : leftHalf;
|
|
6489
|
+
if (touchGamepadSideStick(side))
|
|
6490
|
+
{
|
|
6491
|
+
// a side with no control on the other side uses the full width
|
|
6492
|
+
const otherControl = touchGamepadSideHasControl(side ? 0 : 1);
|
|
6493
|
+
const grab = touchGamepadFloating ?
|
|
6494
|
+
(!otherControl || onHalf) && p.y > floatTop :
|
|
6495
|
+
onHalf && touchGamepadSideCenter(side, W, H).distance(p) < 2*S;
|
|
6496
|
+
if (grab) return {role:'stick', side};
|
|
6497
|
+
}
|
|
6498
|
+
else if (touchGamepadSideButtonCount(side) >= 1)
|
|
6499
|
+
{
|
|
6500
|
+
const btn = touchGamepadFaceButtonAt(side, p, W, H);
|
|
6501
|
+
if (btn >= 0) return {role:'face', btn};
|
|
6502
|
+
}
|
|
6503
|
+
}
|
|
6504
|
+
|
|
6505
|
+
// center start button, blocked within 2*size of a control so drift off a
|
|
6506
|
+
// control can't accidentally fire start (matches the original exclusion logic)
|
|
6507
|
+
if (touchGamepadCenterButtonSize)
|
|
6508
|
+
{
|
|
6509
|
+
for (let side = 0; side < 2; side++)
|
|
6510
|
+
if (touchGamepadSideHasControl(side) &&
|
|
6511
|
+
touchGamepadSideCenter(side, W, H).distance(p) < 2*S)
|
|
6512
|
+
return;
|
|
6513
|
+
if (vec2(W/2, H/2).distance(p) < touchGamepadCenterButtonSize)
|
|
6514
|
+
return {role:'start'};
|
|
6515
|
+
}
|
|
6516
|
+
}
|
|
6517
|
+
|
|
6518
|
+
function touchGamepadPointerDown(e, zone)
|
|
6519
|
+
{
|
|
6520
|
+
if (!touchGamepadEnable) return;
|
|
6521
|
+
e.preventDefault();
|
|
6522
|
+
zone.setPointerCapture(e.pointerId);
|
|
6523
|
+
touchGamepadTimer.set();
|
|
6524
|
+
isUsingGamepad = true;
|
|
6525
|
+
|
|
6526
|
+
// resume audio on first interaction
|
|
6527
|
+
if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
|
|
6528
|
+
audioContext.resume();
|
|
6529
|
+
|
|
6530
|
+
// while paused, any touch is the start button
|
|
6531
|
+
if (paused)
|
|
6532
|
+
{
|
|
6533
|
+
if (touchGamepadCenterButtonSize)
|
|
6534
|
+
{
|
|
6535
|
+
touchGamepadButtons[9] = 1;
|
|
6536
|
+
touchGamepadPointerRole.set(e.pointerId, 'start');
|
|
6537
|
+
}
|
|
6538
|
+
return;
|
|
6539
|
+
}
|
|
6540
|
+
|
|
6541
|
+
const r = touchGamepadStageRect();
|
|
6542
|
+
const W = r.width, H = r.height;
|
|
6543
|
+
const p = vec2(e.clientX - r.left, e.clientY - r.top);
|
|
6544
|
+
|
|
6545
|
+
// choose the control by proximity/priority, not by which zone captured the touch
|
|
6546
|
+
const hit = touchGamepadControlAt(p, W, H);
|
|
6547
|
+
if (!hit) return;
|
|
6548
|
+
if (hit.role === 'stick')
|
|
6549
|
+
{
|
|
6550
|
+
const side = hit.side;
|
|
6551
|
+
touchGamepadStickAnchors[side] = touchGamepadFloating ? p : touchGamepadSideCenter(side, W, H);
|
|
6552
|
+
touchGamepadStickPointerId[side] = e.pointerId;
|
|
6553
|
+
touchGamepadPointerRole.set(e.pointerId, 'stick'+side);
|
|
6554
|
+
touchGamepadNeedRelayout = true; // base may have re-anchored
|
|
6555
|
+
touchGamepadApplyStick(side, p);
|
|
6556
|
+
}
|
|
6557
|
+
else if (hit.role === 'face')
|
|
6558
|
+
{
|
|
6559
|
+
touchGamepadButtons[hit.btn] = 1;
|
|
6560
|
+
touchGamepadPointerRole.set(e.pointerId, 'face'+hit.btn);
|
|
6561
|
+
}
|
|
6562
|
+
else // 'start'
|
|
6563
|
+
{
|
|
6564
|
+
touchGamepadButtons[9] = 1;
|
|
6565
|
+
touchGamepadPointerRole.set(e.pointerId, 'start');
|
|
6051
6566
|
}
|
|
6052
6567
|
}
|
|
6053
6568
|
|
|
6054
|
-
|
|
6055
|
-
function touchGamepadButtonCenter()
|
|
6569
|
+
function touchGamepadPointerMove(e)
|
|
6056
6570
|
{
|
|
6057
|
-
const
|
|
6058
|
-
if (
|
|
6059
|
-
|
|
6060
|
-
|
|
6571
|
+
const role = touchGamepadPointerRole.get(e.pointerId);
|
|
6572
|
+
if (!role) return;
|
|
6573
|
+
e.preventDefault();
|
|
6574
|
+
const p = touchGamepadEventPos(e);
|
|
6575
|
+
if (role === 'stick0' || role === 'stick1')
|
|
6576
|
+
touchGamepadApplyStick(role === 'stick1' ? 1 : 0, p);
|
|
6577
|
+
// face buttons & start are held until release (no slide-between this pass)
|
|
6578
|
+
}
|
|
6579
|
+
|
|
6580
|
+
function touchGamepadPointerUp(e)
|
|
6581
|
+
{
|
|
6582
|
+
const role = touchGamepadPointerRole.get(e.pointerId);
|
|
6583
|
+
if (!role) return;
|
|
6584
|
+
touchGamepadPointerRole.delete(e.pointerId);
|
|
6585
|
+
if (role === 'stick0' || role === 'stick1')
|
|
6586
|
+
{
|
|
6587
|
+
const side = role === 'stick1' ? 1 : 0;
|
|
6588
|
+
touchGamepadStickPointerId[side] = undefined;
|
|
6589
|
+
touchGamepadSticks[side] = vec2();
|
|
6590
|
+
delete touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10];
|
|
6591
|
+
}
|
|
6592
|
+
else if (role === 'start')
|
|
6593
|
+
delete touchGamepadButtons[9];
|
|
6594
|
+
else // 'face<n>'
|
|
6595
|
+
delete touchGamepadButtons[+role.slice(4)];
|
|
6596
|
+
touchGamepadTimer.set();
|
|
6061
6597
|
}
|
|
6062
6598
|
/**
|
|
6063
6599
|
* LittleJS Audio System
|
|
@@ -6162,7 +6698,7 @@ class Sound
|
|
|
6162
6698
|
/** @property {SoundLoadCallback} - function to call when sound is loaded */
|
|
6163
6699
|
this.onloadCallback = onloadCallback;
|
|
6164
6700
|
|
|
6165
|
-
if (
|
|
6701
|
+
if (isArray(asset))
|
|
6166
6702
|
{
|
|
6167
6703
|
// generate zzfx sound — copy so we don't mutate the caller's array
|
|
6168
6704
|
const zzfxSound = asset.slice();
|
|
@@ -6194,7 +6730,7 @@ class Sound
|
|
|
6194
6730
|
* @param {number} [randomnessScale] - How much to scale pitch randomness
|
|
6195
6731
|
* @param {boolean} [loop] - Should the sound loop?
|
|
6196
6732
|
* @param {boolean} [paused] - Should the sound start paused
|
|
6197
|
-
* @return {SoundInstance} - The
|
|
6733
|
+
* @return {SoundInstance} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode
|
|
6198
6734
|
*/
|
|
6199
6735
|
play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
|
|
6200
6736
|
{
|
|
@@ -6224,10 +6760,24 @@ class Sound
|
|
|
6224
6760
|
// get pan from screen space coords
|
|
6225
6761
|
pan = worldToScreen(pos).x * 2/mainCanvas.width - 1;
|
|
6226
6762
|
}
|
|
6227
|
-
|
|
6228
|
-
// Create
|
|
6229
|
-
const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
|
|
6230
|
-
|
|
6763
|
+
|
|
6764
|
+
// Create sound instance
|
|
6765
|
+
const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
|
|
6766
|
+
const instance = new SoundInstance(this, volume, rate, pan, loop, paused);
|
|
6767
|
+
|
|
6768
|
+
if (debug && debugSound && pos)
|
|
6769
|
+
{
|
|
6770
|
+
// visualize where positioned sounds play and their falloff range
|
|
6771
|
+
debugCircle(pos, .5, '#0ff', .5, true);
|
|
6772
|
+
if (this.range)
|
|
6773
|
+
{
|
|
6774
|
+
debugCircle(pos, 2*this.range, '#0ff', .5); // silent radius
|
|
6775
|
+
debugCircle(pos, 2*this.range*this.taper, '#0ff', .5); // full volume radius
|
|
6776
|
+
}
|
|
6777
|
+
debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5);
|
|
6778
|
+
}
|
|
6779
|
+
|
|
6780
|
+
return instance;
|
|
6231
6781
|
}
|
|
6232
6782
|
|
|
6233
6783
|
/** Play a music track that loops by default
|
|
@@ -6254,7 +6804,7 @@ class Sound
|
|
|
6254
6804
|
}
|
|
6255
6805
|
|
|
6256
6806
|
/** Get how long this sound is in seconds
|
|
6257
|
-
* @return {number} - How long the sound is in seconds (
|
|
6807
|
+
* @return {number} - How long the sound is in seconds (0 if loading)
|
|
6258
6808
|
*/
|
|
6259
6809
|
getDuration()
|
|
6260
6810
|
{ return this.sampleChannels?.[0]?.length / this.sampleRate || 0; }
|
|
@@ -6411,10 +6961,14 @@ class SoundInstance
|
|
|
6411
6961
|
{
|
|
6412
6962
|
if (fadeTime)
|
|
6413
6963
|
{
|
|
6414
|
-
// ramp off gain
|
|
6964
|
+
// ramp off gain from current volume (not 1, or low-volume
|
|
6965
|
+
// instances would jump back up before fading);
|
|
6966
|
+
// cancel any prior scheduling so stacked stop calls don't
|
|
6967
|
+
// re-anchor partway through a previous fade
|
|
6415
6968
|
const startFade = audioContext.currentTime;
|
|
6416
6969
|
const endFade = startFade + fadeTime;
|
|
6417
|
-
this.gainNode.gain.
|
|
6970
|
+
this.gainNode.gain.cancelScheduledValues(startFade);
|
|
6971
|
+
this.gainNode.gain.setValueAtTime(this.volume, startFade);
|
|
6418
6972
|
this.gainNode.gain.linearRampToValueAtTime(0, endFade);
|
|
6419
6973
|
this.source.stop(endFade);
|
|
6420
6974
|
}
|
|
@@ -6462,13 +7016,14 @@ class SoundInstance
|
|
|
6462
7016
|
*/
|
|
6463
7017
|
getCurrentTime()
|
|
6464
7018
|
{
|
|
6465
|
-
|
|
6466
|
-
|
|
6467
|
-
|
|
7019
|
+
if (!this.isPlaying()) return this.pausedTime;
|
|
7020
|
+
const duration = this.getDuration();
|
|
7021
|
+
// guard mod against 0 duration (rate=0 or sound not loaded)
|
|
7022
|
+
return duration ? mod(audioContext.currentTime - this.startTime, duration) : 0;
|
|
6468
7023
|
}
|
|
6469
7024
|
|
|
6470
7025
|
/** Get the total duration of this sound
|
|
6471
|
-
* @return {number} - Total duration in seconds
|
|
7026
|
+
* @return {number} - Total duration in seconds (0 if loading)
|
|
6472
7027
|
*/
|
|
6473
7028
|
getDuration() { return this.rate ? this.sound.getDuration() / this.rate : 0; }
|
|
6474
7029
|
|
|
@@ -6492,7 +7047,7 @@ function speak(text, volume=1, rate=1, pitch=1, language='')
|
|
|
6492
7047
|
{
|
|
6493
7048
|
ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch');
|
|
6494
7049
|
if (!soundEnable || headlessMode) return;
|
|
6495
|
-
if (
|
|
7050
|
+
if (typeof speechSynthesis === 'undefined') return;
|
|
6496
7051
|
|
|
6497
7052
|
// common languages (not supported by all browsers)
|
|
6498
7053
|
// en - english, it - italian, fr - french, de - german, es - spanish
|
|
@@ -6510,7 +7065,11 @@ function speak(text, volume=1, rate=1, pitch=1, language='')
|
|
|
6510
7065
|
|
|
6511
7066
|
/** Stop all queued speech
|
|
6512
7067
|
* @memberof Audio */
|
|
6513
|
-
function speakStop()
|
|
7068
|
+
function speakStop()
|
|
7069
|
+
{
|
|
7070
|
+
if (typeof speechSynthesis !== 'undefined')
|
|
7071
|
+
speechSynthesis.cancel();
|
|
7072
|
+
}
|
|
6514
7073
|
|
|
6515
7074
|
/** Get frequency of a note on a musical scale
|
|
6516
7075
|
* @param {number} semitoneOffset - How many semitones away from the root note
|
|
@@ -6572,13 +7131,22 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
|
|
|
6572
7131
|
const pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)});
|
|
6573
7132
|
source.connect(pannerNode).connect(gainNode);
|
|
6574
7133
|
|
|
6575
|
-
//
|
|
6576
|
-
|
|
6577
|
-
|
|
7134
|
+
// disconnect nodes when the sound ends so the audio graph doesn't grow
|
|
7135
|
+
// unbounded across many play() calls (source.stop() also fires 'ended')
|
|
7136
|
+
source.addEventListener('ended', ()=>
|
|
7137
|
+
{
|
|
7138
|
+
gainNode.disconnect();
|
|
7139
|
+
pannerNode.disconnect();
|
|
7140
|
+
if (onended) onended(source);
|
|
7141
|
+
});
|
|
6578
7142
|
|
|
6579
7143
|
// play and return sound
|
|
6580
7144
|
const startOffset = offset * rate;
|
|
6581
7145
|
source.start(0, startOffset);
|
|
7146
|
+
|
|
7147
|
+
if (debug && debugSound)
|
|
7148
|
+
LOG('sound', 'vol', volume.toFixed(2), 'rate', rate.toFixed(2), 'pan', pan.toFixed(2), loop ? 'loop' : '');
|
|
7149
|
+
|
|
6582
7150
|
return source;
|
|
6583
7151
|
}
|
|
6584
7152
|
|
|
@@ -6811,14 +7379,29 @@ function tileCollisionTest(pos, size=vec2(), callbackObject, solidOnly=true)
|
|
|
6811
7379
|
* @memberof TileLayers */
|
|
6812
7380
|
function tileCollisionRaycast(posStart, posEnd, callbackObject, normal, solidOnly=true)
|
|
6813
7381
|
{
|
|
7382
|
+
// check every layer and keep the closest hit so a far hit in an
|
|
7383
|
+
// earlier-registered layer doesn't shadow a closer hit in a later one
|
|
7384
|
+
let closestHit, closestDistSq, closestNormal;
|
|
7385
|
+
const scratchNormal = normal && vec2();
|
|
6814
7386
|
for (const layer of tileCollisionLayers)
|
|
6815
7387
|
{
|
|
6816
7388
|
if (!solidOnly || layer.isSolid)
|
|
6817
7389
|
{
|
|
6818
|
-
const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject,
|
|
6819
|
-
if (hitPos)
|
|
7390
|
+
const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, scratchNormal);
|
|
7391
|
+
if (hitPos)
|
|
7392
|
+
{
|
|
7393
|
+
const d = posStart.distanceSquared(hitPos);
|
|
7394
|
+
if (closestHit === undefined || d < closestDistSq)
|
|
7395
|
+
{
|
|
7396
|
+
closestHit = hitPos;
|
|
7397
|
+
closestDistSq = d;
|
|
7398
|
+
if (normal) closestNormal = scratchNormal.copy();
|
|
7399
|
+
}
|
|
7400
|
+
}
|
|
6820
7401
|
}
|
|
6821
7402
|
}
|
|
7403
|
+
if (closestHit && normal) normal.setFrom(closestNormal);
|
|
7404
|
+
return closestHit;
|
|
6822
7405
|
}
|
|
6823
7406
|
|
|
6824
7407
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -6992,38 +7575,6 @@ class CanvasLayer extends EngineObject
|
|
|
6992
7575
|
drawTile(pos, size, tileInfo, color, angle, mirror, additiveColor, useWebGL, screenSpace, context);
|
|
6993
7576
|
}
|
|
6994
7577
|
|
|
6995
|
-
/** Draw a tile onto the layer canvas in world space
|
|
6996
|
-
* @param {Vector2} pos
|
|
6997
|
-
* @param {Vector2} [size=vec2(1)]
|
|
6998
|
-
* @param {TileInfo} [tileInfo]
|
|
6999
|
-
* @param {Color} [color=WHITE]
|
|
7000
|
-
* @param {number} [angle]
|
|
7001
|
-
* @param {boolean} [mirror] */
|
|
7002
|
-
drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
|
|
7003
|
-
{
|
|
7004
|
-
pos = pos.subtract(this.pos).multiply(this.tileInfo.size);
|
|
7005
|
-
size = size.multiply(this.tileInfo.size);
|
|
7006
|
-
pos.y = this.canvas.height - pos.y;
|
|
7007
|
-
|
|
7008
|
-
// draw the tile onto the layer canvas
|
|
7009
|
-
const oldMainCanvasSize = mainCanvasSize;
|
|
7010
|
-
mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
|
|
7011
|
-
const useWebGL = this.hasWebGL();
|
|
7012
|
-
useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
|
|
7013
|
-
const drawContext = useWebGL ? undefined : this.context;
|
|
7014
|
-
drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
|
|
7015
|
-
useWebGL && glSetRenderTarget();
|
|
7016
|
-
mainCanvasSize = oldMainCanvasSize;
|
|
7017
|
-
}
|
|
7018
|
-
|
|
7019
|
-
/** Draw a rectangle onto the layer canvas in world space
|
|
7020
|
-
* @param {Vector2} pos
|
|
7021
|
-
* @param {Vector2} [size=vec2(1)]
|
|
7022
|
-
* @param {Color} [color=WHITE]
|
|
7023
|
-
* @param {number} [angle] */
|
|
7024
|
-
drawRect(pos, size, color, angle)
|
|
7025
|
-
{ this.drawTile(pos, size, undefined, color, angle); }
|
|
7026
|
-
|
|
7027
7578
|
/** Create WebGL texture if necessary and copy layer canvas to it */
|
|
7028
7579
|
updateWebGL()
|
|
7029
7580
|
{ this.textureInfo.createWebGLTexture(); }
|
|
@@ -7079,6 +7630,8 @@ class TileLayer extends CanvasLayer
|
|
|
7079
7630
|
this.redrawTileData = ()=> {};
|
|
7080
7631
|
this.drawLayerTile = ()=> {};
|
|
7081
7632
|
this.drawLayerRect = ()=> {};
|
|
7633
|
+
this.drawTile = ()=> {};
|
|
7634
|
+
this.drawRect = ()=> {};
|
|
7082
7635
|
this.clearLayerRect = ()=> {};
|
|
7083
7636
|
return;
|
|
7084
7637
|
}
|
|
@@ -7106,7 +7659,7 @@ class TileLayer extends CanvasLayer
|
|
|
7106
7659
|
ASSERT(data instanceof TileLayerData, 'data must be a TileLayerData');
|
|
7107
7660
|
|
|
7108
7661
|
if (!layerPos.arrayCheck(this.size)) return;
|
|
7109
|
-
this.data[(layerPos.y|0)*this.size.x+layerPos.x|0] = data;
|
|
7662
|
+
this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] = data;
|
|
7110
7663
|
|
|
7111
7664
|
if (!redraw) return;
|
|
7112
7665
|
const isRedraw = drawContext === this.context;
|
|
@@ -7121,11 +7674,11 @@ class TileLayer extends CanvasLayer
|
|
|
7121
7674
|
|
|
7122
7675
|
/** Get data at a given position in the array
|
|
7123
7676
|
* @param {Vector2} layerPos - Local position in array
|
|
7124
|
-
* @return {TileLayerData} */
|
|
7677
|
+
* @return {TileLayerData|undefined} */
|
|
7125
7678
|
getData(layerPos)
|
|
7126
|
-
{
|
|
7679
|
+
{
|
|
7127
7680
|
ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
|
|
7128
|
-
return layerPos.arrayCheck(this.size)
|
|
7681
|
+
return layerPos.arrayCheck(this.size) ? this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] : undefined;
|
|
7129
7682
|
}
|
|
7130
7683
|
|
|
7131
7684
|
// Update the tile layer, refresh texture if needed
|
|
@@ -7233,7 +7786,7 @@ class TileLayer extends CanvasLayer
|
|
|
7233
7786
|
|
|
7234
7787
|
// draw the tile if it has layer data
|
|
7235
7788
|
const d = this.getData(layerPos);
|
|
7236
|
-
if (!d.tile) return;
|
|
7789
|
+
if (!d || !d.tile) return;
|
|
7237
7790
|
|
|
7238
7791
|
const tileInfo = this.tileInfo && this.tileInfo.tile(d.tile);
|
|
7239
7792
|
this.drawLayerTile(drawPos, drawSize, tileInfo, d.color, d.direction*PI/2, d.mirror);
|
|
@@ -7279,6 +7832,38 @@ class TileLayer extends CanvasLayer
|
|
|
7279
7832
|
drawLayerRect(pos, size, color, angle=0)
|
|
7280
7833
|
{ this.drawLayerTile(pos, size, undefined, color, angle); }
|
|
7281
7834
|
|
|
7835
|
+
/** Draw a tile onto the layer canvas in world space
|
|
7836
|
+
* @param {Vector2} pos
|
|
7837
|
+
* @param {Vector2} [size=vec2(1)]
|
|
7838
|
+
* @param {TileInfo} [tileInfo]
|
|
7839
|
+
* @param {Color} [color=WHITE]
|
|
7840
|
+
* @param {number} [angle]
|
|
7841
|
+
* @param {boolean} [mirror] */
|
|
7842
|
+
drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
|
|
7843
|
+
{
|
|
7844
|
+
pos = pos.subtract(this.pos).multiply(this.tileInfo.size);
|
|
7845
|
+
size = size.multiply(this.tileInfo.size);
|
|
7846
|
+
pos.y = this.canvas.height - pos.y;
|
|
7847
|
+
|
|
7848
|
+
// draw the tile onto the layer canvas
|
|
7849
|
+
const oldMainCanvasSize = mainCanvasSize;
|
|
7850
|
+
mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
|
|
7851
|
+
const useWebGL = this.hasWebGL();
|
|
7852
|
+
useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
|
|
7853
|
+
const drawContext = useWebGL ? undefined : this.context;
|
|
7854
|
+
drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
|
|
7855
|
+
useWebGL && glSetRenderTarget();
|
|
7856
|
+
mainCanvasSize = oldMainCanvasSize;
|
|
7857
|
+
}
|
|
7858
|
+
|
|
7859
|
+
/** Draw a rectangle onto the layer canvas in world space
|
|
7860
|
+
* @param {Vector2} pos
|
|
7861
|
+
* @param {Vector2} [size=vec2(1)]
|
|
7862
|
+
* @param {Color} [color=WHITE]
|
|
7863
|
+
* @param {number} [angle] */
|
|
7864
|
+
drawRect(pos, size, color, angle)
|
|
7865
|
+
{ this.drawTile(pos, size, undefined, color, angle); }
|
|
7866
|
+
|
|
7282
7867
|
/** Clear a rectangle in layer space
|
|
7283
7868
|
* @param {Vector2} pos - position in pixel coordinates
|
|
7284
7869
|
* @param {Vector2} size
|
|
@@ -7357,7 +7942,7 @@ class TileCollisionLayer extends TileLayer
|
|
|
7357
7942
|
setCollisionData(layerPos, data=1)
|
|
7358
7943
|
{
|
|
7359
7944
|
ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
|
|
7360
|
-
const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
|
|
7945
|
+
const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
|
|
7361
7946
|
layerPos.arrayCheck(this.size) && (this.collisionData[i] = data);
|
|
7362
7947
|
}
|
|
7363
7948
|
|
|
@@ -7372,7 +7957,7 @@ class TileCollisionLayer extends TileLayer
|
|
|
7372
7957
|
getCollisionData(layerPos)
|
|
7373
7958
|
{
|
|
7374
7959
|
ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
|
|
7375
|
-
const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
|
|
7960
|
+
const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
|
|
7376
7961
|
return layerPos.arrayCheck(this.size) ? this.collisionData[i] : 0;
|
|
7377
7962
|
}
|
|
7378
7963
|
|
|
@@ -7395,10 +7980,17 @@ class TileCollisionLayer extends TileLayer
|
|
|
7395
7980
|
// check any tiles in the area for collision
|
|
7396
7981
|
const posX = pos.x - this.pos.x;
|
|
7397
7982
|
const posY = pos.y - this.pos.y;
|
|
7983
|
+
// reject AABBs entirely past either edge; without this, the negative
|
|
7984
|
+
// side leaks into row/col 0 because minX/minY clamp to 0 and the
|
|
7985
|
+
// point-test floor below forces maxX/maxY up to 1
|
|
7986
|
+
if (posX + size.x/2 < 0 || posX - size.x/2 > this.size.x) return false;
|
|
7987
|
+
if (posY + size.y/2 < 0 || posY - size.y/2 > this.size.y) return false;
|
|
7398
7988
|
const minX = max(posX - size.x/2|0, 0);
|
|
7399
7989
|
const minY = max(posY - size.y/2|0, 0);
|
|
7400
|
-
|
|
7401
|
-
|
|
7990
|
+
// ensure at least one cell is visited even when size is 0 and pos
|
|
7991
|
+
// lands exactly on an integer boundary (documented point-test mode)
|
|
7992
|
+
const maxX = min(max(posX + size.x/2, minX + 1), this.size.x);
|
|
7993
|
+
const maxY = min(max(posY + size.y/2, minY + 1), this.size.y);
|
|
7402
7994
|
const hitPos = new Vector2;
|
|
7403
7995
|
for (let y = minY; y < maxY; ++y)
|
|
7404
7996
|
for (let x = minX; x < maxX; ++x)
|
|
@@ -7511,13 +8103,13 @@ class ParticleEmitter extends EngineObject
|
|
|
7511
8103
|
* @param {number} [particleTime] - How long particles live
|
|
7512
8104
|
* @param {number} [sizeStart] - How big are particles at start
|
|
7513
8105
|
* @param {number} [sizeEnd] - How big are particles at end
|
|
7514
|
-
* @param {number} [speed] - How fast are particles when spawned
|
|
7515
|
-
* @param {number} [angleSpeed] - How fast are particles rotating
|
|
7516
|
-
* @param {number} [damping] - How much to dampen particle speed
|
|
7517
|
-
* @param {number} [angleDamping] - How much to dampen particle angular speed
|
|
8106
|
+
* @param {number} [speed] - How fast are particles when spawned, in world units per frame (at 60fps, so multiply units/sec by 1/60)
|
|
8107
|
+
* @param {number} [angleSpeed] - How fast are particles rotating, in radians per frame (at 60fps)
|
|
8108
|
+
* @param {number} [damping] - How much to dampen particle speed, per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame)
|
|
8109
|
+
* @param {number} [angleDamping] - How much to dampen particle angular speed, per-frame multiplier (1 = no damping)
|
|
7518
8110
|
* @param {number} [gravityScale] - How much gravity effect particles
|
|
7519
8111
|
* @param {number} [particleConeAngle] - Cone for start particle angle
|
|
7520
|
-
* @param {number} [fadeRate] -
|
|
8112
|
+
* @param {number} [fadeRate] - Fraction of life spent fading: half at fade-in (start), half at fade-out (end). e.g. .2 = 10% fade-in, 80% full opacity, 10% fade-out
|
|
7521
8113
|
* @param {number} [randomness] - Apply extra randomness percent
|
|
7522
8114
|
* @param {boolean} [collideTiles] - Do particles collide against tiles
|
|
7523
8115
|
* @param {boolean} [additive] - Should particles use additive blend
|
|
@@ -7589,19 +8181,19 @@ class ParticleEmitter extends EngineObject
|
|
|
7589
8181
|
this.sizeStart = sizeStart;
|
|
7590
8182
|
/** @property {number} - How big are particles at end */
|
|
7591
8183
|
this.sizeEnd = sizeEnd;
|
|
7592
|
-
/** @property {number} -
|
|
8184
|
+
/** @property {number} - Particle speed when spawned, in world units per frame (at 60fps) */
|
|
7593
8185
|
this.speed = speed;
|
|
7594
|
-
/** @property {number} -
|
|
8186
|
+
/** @property {number} - Particle angular speed when spawned, in radians per frame (at 60fps) */
|
|
7595
8187
|
this.angleSpeed = angleSpeed;
|
|
7596
|
-
/** @property {number} -
|
|
8188
|
+
/** @property {number} - Per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame) */
|
|
7597
8189
|
this.damping = damping;
|
|
7598
|
-
/** @property {number} -
|
|
8190
|
+
/** @property {number} - Per-frame angular velocity multiplier (1 = no damping) */
|
|
7599
8191
|
this.angleDamping = angleDamping;
|
|
7600
8192
|
/** @property {number} - How much gravity affects particles */
|
|
7601
8193
|
this.gravityScale = gravityScale;
|
|
7602
8194
|
/** @property {number} - Cone for start particle angle */
|
|
7603
8195
|
this.particleConeAngle = particleConeAngle;
|
|
7604
|
-
/** @property {number} -
|
|
8196
|
+
/** @property {number} - Fraction of life spent fading, split half at start and half at end (e.g. .2 = 10% fade-in + 10% fade-out) */
|
|
7605
8197
|
this.fadeRate = fadeRate;
|
|
7606
8198
|
/** @property {number} - Apply extra randomness percent */
|
|
7607
8199
|
this.randomness = randomness;
|
|
@@ -7664,12 +8256,16 @@ class ParticleEmitter extends EngineObject
|
|
|
7664
8256
|
else if (this.particles.length === 0)
|
|
7665
8257
|
this.destroy(true);
|
|
7666
8258
|
|
|
7667
|
-
// update and remove destroyed particles
|
|
7668
|
-
|
|
8259
|
+
// update and remove destroyed particles in place to avoid per-frame array allocation
|
|
8260
|
+
const particles = this.particles;
|
|
8261
|
+
let alive = 0;
|
|
8262
|
+
for (let i = 0; i < particles.length; ++i)
|
|
7669
8263
|
{
|
|
8264
|
+
const p = particles[i];
|
|
7670
8265
|
p.update();
|
|
7671
|
-
|
|
7672
|
-
}
|
|
8266
|
+
if (!p.destroyed) particles[alive++] = p;
|
|
8267
|
+
}
|
|
8268
|
+
particles.length = alive;
|
|
7673
8269
|
|
|
7674
8270
|
if (debugParticles)
|
|
7675
8271
|
{
|
|
@@ -7879,33 +8475,32 @@ class Particle
|
|
|
7879
8475
|
const hitLayer = tileCollisionTest(this.pos);
|
|
7880
8476
|
if (!testCollision(oldPos))
|
|
7881
8477
|
{
|
|
7882
|
-
|
|
8478
|
+
// testCollision already invoked collideCallback with the
|
|
8479
|
+
// correct (this, data, pos) args; no need to re-check here.
|
|
8480
|
+
// test which side we bounced off (or both if a corner)
|
|
8481
|
+
const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
|
|
8482
|
+
const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
|
|
8483
|
+
const hitRestitution = max(restitution, hitLayer.restitution);
|
|
8484
|
+
const hitFriction = max(friction, hitLayer.friction);
|
|
8485
|
+
if (isBlockedX)
|
|
7883
8486
|
{
|
|
7884
|
-
//
|
|
7885
|
-
|
|
7886
|
-
|
|
7887
|
-
|
|
7888
|
-
|
|
7889
|
-
|
|
7890
|
-
|
|
7891
|
-
|
|
7892
|
-
|
|
7893
|
-
this.
|
|
7894
|
-
|
|
7895
|
-
|
|
7896
|
-
|
|
7897
|
-
|
|
7898
|
-
|
|
7899
|
-
if (wasFalling)
|
|
7900
|
-
this.groundObject = hitLayer;
|
|
7901
|
-
|
|
7902
|
-
// move to previous Y position and bounce
|
|
7903
|
-
this.pos.y = oldPos.y;
|
|
7904
|
-
this.velocity.y *= -hitRestitution;
|
|
7905
|
-
this.velocity.x *= hitFriction;
|
|
7906
|
-
}
|
|
7907
|
-
debugPhysics && debugRect(this.pos, this.size, '#f00');
|
|
8487
|
+
// move to previous X position and bounce
|
|
8488
|
+
this.pos.x = oldPos.x;
|
|
8489
|
+
this.velocity.x *= -hitRestitution;
|
|
8490
|
+
this.velocity.y *= hitFriction;
|
|
8491
|
+
}
|
|
8492
|
+
if (isBlockedY || !isBlockedX)
|
|
8493
|
+
{
|
|
8494
|
+
const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
|
|
8495
|
+
if (wasFalling)
|
|
8496
|
+
this.groundObject = hitLayer;
|
|
8497
|
+
|
|
8498
|
+
// move to previous Y position and bounce
|
|
8499
|
+
this.pos.y = oldPos.y;
|
|
8500
|
+
this.velocity.y *= -hitRestitution;
|
|
8501
|
+
this.velocity.x *= hitFriction;
|
|
7908
8502
|
}
|
|
8503
|
+
debugPhysics && debugRect(this.pos, this.size, '#f00');
|
|
7909
8504
|
}
|
|
7910
8505
|
}
|
|
7911
8506
|
}
|
|
@@ -7952,7 +8547,7 @@ class Particle
|
|
|
7952
8547
|
{
|
|
7953
8548
|
// in local space of emitter
|
|
7954
8549
|
const a = emitter.angle;
|
|
7955
|
-
const c = cos(a), s = sin(a);
|
|
8550
|
+
const c = cos(-a), s = sin(-a);
|
|
7956
8551
|
pos.set(emitter.pos.x + pos.x*c - pos.y*s,
|
|
7957
8552
|
emitter.pos.y + pos.x*s + pos.y*c);
|
|
7958
8553
|
angle += a;
|
|
@@ -7960,8 +8555,8 @@ class Particle
|
|
|
7960
8555
|
if (trailScale)
|
|
7961
8556
|
{
|
|
7962
8557
|
// trail style particles
|
|
7963
|
-
const velocity = localSpace ?
|
|
7964
|
-
this.velocity.rotate(
|
|
8558
|
+
const velocity = localSpace ?
|
|
8559
|
+
this.velocity.rotate(emitter.angle) : this.velocity;
|
|
7965
8560
|
const speed = velocity.length();
|
|
7966
8561
|
if (speed)
|
|
7967
8562
|
{
|
|
@@ -8066,6 +8661,10 @@ function glInit(rootElement)
|
|
|
8066
8661
|
for (const info of glTextureInfos)
|
|
8067
8662
|
info.glTexture = undefined;
|
|
8068
8663
|
glActiveTexture = undefined;
|
|
8664
|
+
// drop any partially-filled batch so the next glFlush doesn't
|
|
8665
|
+
// upload stale glBatchCount against fresh empty buffers on restore
|
|
8666
|
+
glBatchCount = 0;
|
|
8667
|
+
glPolyMode = false;
|
|
8069
8668
|
pluginList.forEach(plugin=>plugin.glContextLost?.());
|
|
8070
8669
|
});
|
|
8071
8670
|
glCanvas.addEventListener('webglcontextrestored', ()=>
|
|
@@ -8424,6 +9023,10 @@ function glSetTextureData(texture, image)
|
|
|
8424
9023
|
glContext.bindTexture(glContext.TEXTURE_2D, texture);
|
|
8425
9024
|
glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, image);
|
|
8426
9025
|
|
|
9026
|
+
// keep mipmaps in sync with new level 0 data (same condition as glCreateTexture)
|
|
9027
|
+
if (!tilesPixelated && isPowerOfTwo(image.width) && isPowerOfTwo(image.height))
|
|
9028
|
+
glContext.generateMipmap(glContext.TEXTURE_2D);
|
|
9029
|
+
|
|
8427
9030
|
// rebind active texture
|
|
8428
9031
|
glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
|
|
8429
9032
|
}
|
|
@@ -8469,7 +9072,7 @@ function glFlush()
|
|
|
8469
9072
|
{
|
|
8470
9073
|
if (glEnable && glContext && glBatchCount)
|
|
8471
9074
|
{
|
|
8472
|
-
// set
|
|
9075
|
+
// set blend mode
|
|
8473
9076
|
const destBlend = glBatchAdditive ? glContext.ONE : glContext.ONE_MINUS_SRC_ALPHA;
|
|
8474
9077
|
glContext.blendFuncSeparate(glContext.SRC_ALPHA, destBlend, glContext.ONE, destBlend);
|
|
8475
9078
|
glContext.enable(glContext.BLEND);
|
|
@@ -8546,9 +9149,9 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
|
|
|
8546
9149
|
}
|
|
8547
9150
|
|
|
8548
9151
|
/** Add an untextured rect to the gl draw list
|
|
8549
|
-
*
|
|
8550
|
-
*
|
|
8551
|
-
*
|
|
9152
|
+
* Zeroes the uvs and rgba so the texture contribution multiplies to 0,
|
|
9153
|
+
* then carries the real color in the additive slot. Works regardless of
|
|
9154
|
+
* which texture is currently bound.
|
|
8552
9155
|
* @param {number} x
|
|
8553
9156
|
* @param {number} y
|
|
8554
9157
|
* @param {number} sizeX
|
|
@@ -8558,52 +9161,7 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
|
|
|
8558
9161
|
* @memberof WebGL */
|
|
8559
9162
|
function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
|
|
8560
9163
|
{
|
|
8561
|
-
|
|
8562
|
-
{
|
|
8563
|
-
// batch with surrounding polys as a 4-vertex tristrip rect
|
|
8564
|
-
const vertCount = 6; // 4 corners + 2 degenerate verts
|
|
8565
|
-
if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
|
|
8566
|
-
glFlush();
|
|
8567
|
-
|
|
8568
|
-
// compute rotated corners in world space (matches glDrawPointsTransform rotation)
|
|
8569
|
-
const hx = sizeX*.5, hy = sizeY*.5;
|
|
8570
|
-
const c = cos(angle), s = sin(angle);
|
|
8571
|
-
const chx = c*hx, shx = s*hx, chy = c*hy, shy = s*hy;
|
|
8572
|
-
const x0 = x - chx - shy, y0 = y + shx - chy; // (-hx,-hy)
|
|
8573
|
-
const x1 = x + chx - shy, y1 = y - shx - chy; // ( hx,-hy)
|
|
8574
|
-
const x2 = x - chx + shy, y2 = y + shx + chy; // (-hx, hy)
|
|
8575
|
-
const x3 = x + chx + shy, y3 = y - shx + chy; // ( hx, hy)
|
|
8576
|
-
|
|
8577
|
-
// write tristrip with leading/trailing degenerate verts
|
|
8578
|
-
let offset = glBatchCount * gl_INDICES_PER_POLY_VERTEX;
|
|
8579
|
-
glPositionData[offset++] = x0; glPositionData[offset++] = y0; glColorData[offset++] = rgba;
|
|
8580
|
-
glPositionData[offset++] = x0; glPositionData[offset++] = y0; glColorData[offset++] = rgba;
|
|
8581
|
-
glPositionData[offset++] = x1; glPositionData[offset++] = y1; glColorData[offset++] = rgba;
|
|
8582
|
-
glPositionData[offset++] = x2; glPositionData[offset++] = y2; glColorData[offset++] = rgba;
|
|
8583
|
-
glPositionData[offset++] = x3; glPositionData[offset++] = y3; glColorData[offset++] = rgba;
|
|
8584
|
-
glPositionData[offset++] = x3; glPositionData[offset++] = y3; glColorData[offset++] = rgba;
|
|
8585
|
-
glBatchCount += vertCount;
|
|
8586
|
-
return;
|
|
8587
|
-
}
|
|
8588
|
-
|
|
8589
|
-
// instanced path: zero uvs and rgba so the texture contribution is killed,
|
|
8590
|
-
// then carry the real color in the additive slot
|
|
8591
|
-
if (glBatchCount >= gl_MAX_INSTANCES || glBatchAdditive !== glAdditive)
|
|
8592
|
-
glFlush();
|
|
8593
|
-
glSetInstancedMode();
|
|
8594
|
-
|
|
8595
|
-
let offset = glBatchCount++ * gl_INDICES_PER_INSTANCE;
|
|
8596
|
-
glPositionData[offset++] = x;
|
|
8597
|
-
glPositionData[offset++] = y;
|
|
8598
|
-
glPositionData[offset++] = sizeX;
|
|
8599
|
-
glPositionData[offset++] = sizeY;
|
|
8600
|
-
glPositionData[offset++] = 0;
|
|
8601
|
-
glPositionData[offset++] = 0;
|
|
8602
|
-
glPositionData[offset++] = 0;
|
|
8603
|
-
glPositionData[offset++] = 0;
|
|
8604
|
-
glColorData[offset++] = 0;
|
|
8605
|
-
glColorData[offset++] = rgba;
|
|
8606
|
-
glPositionData[offset++] = angle;
|
|
9164
|
+
glDraw(x, y, sizeX, sizeY, angle, 0, 0, 0, 0, 0, rgba);
|
|
8607
9165
|
}
|
|
8608
9166
|
|
|
8609
9167
|
/** Transform and add a polygon to the gl draw list
|
|
@@ -8619,13 +9177,13 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
|
|
|
8619
9177
|
function glDrawPointsTransform(points, rgba, x, y, sx, sy, angle, tristrip=true)
|
|
8620
9178
|
{
|
|
8621
9179
|
const pointsOut = [];
|
|
9180
|
+
const sa = sin(-angle);
|
|
9181
|
+
const ca = cos(-angle);
|
|
8622
9182
|
for (const p of points)
|
|
8623
9183
|
{
|
|
8624
9184
|
// transform the point
|
|
8625
9185
|
const px = p.x*sx;
|
|
8626
9186
|
const py = p.y*sy;
|
|
8627
|
-
const sa = sin(-angle);
|
|
8628
|
-
const ca = cos(-angle);
|
|
8629
9187
|
pointsOut.push(vec2(x + ca*px - sa*py, y + sa*px + ca*py));
|
|
8630
9188
|
}
|
|
8631
9189
|
const drawPoints = tristrip ? glPolyStrip(pointsOut) : pointsOut;
|
|
@@ -8657,11 +9215,13 @@ function glDrawPoints(points, rgba)
|
|
|
8657
9215
|
{
|
|
8658
9216
|
if (!glEnable || points.length < 3)
|
|
8659
9217
|
return; // needs at least 3 points to have area
|
|
8660
|
-
|
|
9218
|
+
|
|
8661
9219
|
// flush if there is not enough room or if different blend mode
|
|
8662
9220
|
const vertCount = points.length + 2;
|
|
8663
9221
|
if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
|
|
8664
9222
|
glFlush();
|
|
9223
|
+
ASSERT(vertCount < gl_MAX_POLY_VERTEXES, 'poly exceeds max batch size');
|
|
9224
|
+
if (vertCount >= gl_MAX_POLY_VERTEXES) return; // release-build safety net
|
|
8665
9225
|
glSetPolyMode();
|
|
8666
9226
|
|
|
8667
9227
|
// setup triangle strip with degenerate verts at start and end
|
|
@@ -8685,11 +9245,13 @@ function glDrawColoredPoints(points, pointColors)
|
|
|
8685
9245
|
{
|
|
8686
9246
|
if (!glEnable || points.length < 3)
|
|
8687
9247
|
return; // needs at least 3 points to have area
|
|
8688
|
-
|
|
9248
|
+
|
|
8689
9249
|
// flush if there is not enough room or if different blend mode
|
|
8690
9250
|
const vertCount = points.length + 2;
|
|
8691
9251
|
if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
|
|
8692
9252
|
glFlush();
|
|
9253
|
+
ASSERT(vertCount < gl_MAX_POLY_VERTEXES, 'poly exceeds max batch size');
|
|
9254
|
+
if (vertCount >= gl_MAX_POLY_VERTEXES) return; // release-build safety net
|
|
8693
9255
|
glSetPolyMode();
|
|
8694
9256
|
|
|
8695
9257
|
// setup triangle strip with degenerate verts at start and end
|
|
@@ -8759,7 +9321,8 @@ function glMakeOutline(points, width, wrap=true)
|
|
|
8759
9321
|
const strip = [];
|
|
8760
9322
|
const n = points.length;
|
|
8761
9323
|
const e = 1e-6;
|
|
8762
|
-
|
|
9324
|
+
// miter ratio cap (dimensionless, matches SVG/Canvas2D convention)
|
|
9325
|
+
const miterLimit = 10;
|
|
8763
9326
|
for (let i = 0; i < n; i++)
|
|
8764
9327
|
{
|
|
8765
9328
|
// for each vertex, calculate normal based on adjacent edges
|
|
@@ -9015,7 +9578,7 @@ function drawEngineLogo(t)
|
|
|
9015
9578
|
x.closePath();
|
|
9016
9579
|
gradient(0, Y, 0, Y+H,C);
|
|
9017
9580
|
}
|
|
9018
|
-
const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}
|
|
9581
|
+
const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%)`:'#000';
|
|
9019
9582
|
|
|
9020
9583
|
// center and fit to screen
|
|
9021
9584
|
const alpha = oscillate(1,1,t);
|
|
@@ -9155,7 +9718,15 @@ function medalsInit(saveName)
|
|
|
9155
9718
|
// check if medals are unlocked
|
|
9156
9719
|
medalsSaveName = saveName;
|
|
9157
9720
|
if (!debugMedals)
|
|
9158
|
-
|
|
9721
|
+
{
|
|
9722
|
+
let saved = {};
|
|
9723
|
+
try { saved = JSON.parse(localStorage[saveName] || '{}'); }
|
|
9724
|
+
catch (e) { saved = {}; }
|
|
9725
|
+
medalsForEach(medal => {
|
|
9726
|
+
medal.unlocked = !!(saved[medal.id] && saved[medal.id].unlocked);
|
|
9727
|
+
});
|
|
9728
|
+
medalsSave();
|
|
9729
|
+
}
|
|
9159
9730
|
|
|
9160
9731
|
// engine automatically renders medals
|
|
9161
9732
|
engineAddPlugin(undefined, medalsRender);
|
|
@@ -9199,6 +9770,31 @@ function medalsInit(saveName)
|
|
|
9199
9770
|
function medalsForEach(callback)
|
|
9200
9771
|
{ Object.values(medals).forEach(medal=>callback(medal)); }
|
|
9201
9772
|
|
|
9773
|
+
/** Reset all medals to locked and persist the cleared catalog
|
|
9774
|
+
* @memberof Medals */
|
|
9775
|
+
function medalsReset()
|
|
9776
|
+
{
|
|
9777
|
+
medalsForEach(medal => medal.unlocked = false);
|
|
9778
|
+
medalsSave();
|
|
9779
|
+
}
|
|
9780
|
+
|
|
9781
|
+
function medalsSave()
|
|
9782
|
+
{
|
|
9783
|
+
if (!medalsSaveName) return;
|
|
9784
|
+
const data = {};
|
|
9785
|
+
medalsForEach(medal => {
|
|
9786
|
+
const entry = {
|
|
9787
|
+
name: medal.name,
|
|
9788
|
+
description: medal.description,
|
|
9789
|
+
icon: medal.icon,
|
|
9790
|
+
unlocked: medal.unlocked,
|
|
9791
|
+
};
|
|
9792
|
+
if (medal.image) entry.src = medal.image.src;
|
|
9793
|
+
data[medal.id] = entry;
|
|
9794
|
+
});
|
|
9795
|
+
localStorage[medalsSaveName] = JSON.stringify(data);
|
|
9796
|
+
}
|
|
9797
|
+
|
|
9202
9798
|
///////////////////////////////////////////////////////////////////////////////
|
|
9203
9799
|
|
|
9204
9800
|
/**
|
|
@@ -9256,9 +9852,9 @@ class Medal
|
|
|
9256
9852
|
{
|
|
9257
9853
|
if (medalsPreventUnlock || this.unlocked) return;
|
|
9258
9854
|
|
|
9259
|
-
// save the medal
|
|
9260
9855
|
ASSERT(medalsSaveName, 'save name must be set');
|
|
9261
|
-
|
|
9856
|
+
this.unlocked = true;
|
|
9857
|
+
medalsSave();
|
|
9262
9858
|
medalsDisplayQueue.push(this);
|
|
9263
9859
|
}
|
|
9264
9860
|
|
|
@@ -9316,8 +9912,6 @@ class Medal
|
|
|
9316
9912
|
drawTextScreen(this.icon, pos, size*.7, BLACK);
|
|
9317
9913
|
}
|
|
9318
9914
|
|
|
9319
|
-
// Get local storage key used by the medal
|
|
9320
|
-
storageKey() { return medalsSaveName + '_' + this.id; }
|
|
9321
9915
|
}
|
|
9322
9916
|
|
|
9323
9917
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -9425,8 +10019,20 @@ class NewgroundsPlugin
|
|
|
9425
10019
|
|
|
9426
10020
|
// get medals
|
|
9427
10021
|
const medalsResult = this.call('Medal.getList');
|
|
10022
|
+
|
|
10023
|
+
// bail early if the first call failed (offline / bad session /
|
|
10024
|
+
// server error) so we don't block the main thread on more sync
|
|
10025
|
+
// XHRs that are guaranteed to also fail
|
|
10026
|
+
if (!medalsResult || !medalsResult.result || medalsResult.result.error)
|
|
10027
|
+
{
|
|
10028
|
+
debugMedals && LOG('Newgrounds session unavailable; skipping plugin init');
|
|
10029
|
+
this.medals = [];
|
|
10030
|
+
this.scoreboards = [];
|
|
10031
|
+
return;
|
|
10032
|
+
}
|
|
10033
|
+
|
|
9428
10034
|
/** @property {Array} - Medals fetched from Newgrounds (empty until session is active) */
|
|
9429
|
-
this.medals = medalsResult
|
|
10035
|
+
this.medals = medalsResult.result.data?.['medals'] || [];
|
|
9430
10036
|
debugMedals && LOG(this.medals);
|
|
9431
10037
|
for (const newgroundsMedal of this.medals)
|
|
9432
10038
|
{
|
|
@@ -9446,11 +10052,11 @@ class NewgroundsPlugin
|
|
|
9446
10052
|
medal.description = medal.description + ` (${ medal.value })`;
|
|
9447
10053
|
}
|
|
9448
10054
|
}
|
|
9449
|
-
|
|
10055
|
+
|
|
9450
10056
|
// get scoreboards
|
|
9451
10057
|
const scoreboardResult = this.call('ScoreBoard.getBoards');
|
|
9452
10058
|
/** @property {Array} - Scoreboards fetched from Newgrounds */
|
|
9453
|
-
this.scoreboards = scoreboardResult
|
|
10059
|
+
this.scoreboards = scoreboardResult?.result?.data?.scoreboards || [];
|
|
9454
10060
|
debugMedals && LOG(this.scoreboards);
|
|
9455
10061
|
|
|
9456
10062
|
// keep the session alive with a ping every minute
|
|
@@ -9634,10 +10240,14 @@ class PostProcessPlugin
|
|
|
9634
10240
|
function postProcessRender()
|
|
9635
10241
|
{
|
|
9636
10242
|
if (headlessMode || !glEnable) return;
|
|
9637
|
-
|
|
10243
|
+
|
|
9638
10244
|
// clear out the buffer
|
|
9639
10245
|
glFlush();
|
|
9640
10246
|
|
|
10247
|
+
// ensure we render to the default framebuffer (in case any earlier
|
|
10248
|
+
// caller this frame left a render target bound)
|
|
10249
|
+
glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
|
|
10250
|
+
|
|
9641
10251
|
// setup shader program to draw a quad
|
|
9642
10252
|
glContext.useProgram(postProcess.shader);
|
|
9643
10253
|
glContext.bindVertexArray(postProcess.vao);
|
|
@@ -9678,11 +10288,343 @@ class PostProcessPlugin
|
|
|
9678
10288
|
glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, glCanvas);
|
|
9679
10289
|
}
|
|
9680
10290
|
|
|
10291
|
+
// restore default so subsequent dynamic texture uploads aren't flipped
|
|
10292
|
+
glContext.pixelStorei(glContext.UNPACK_FLIP_Y_WEBGL, false);
|
|
10293
|
+
|
|
9681
10294
|
// force it to set instanced mode
|
|
9682
10295
|
glSetInstancedMode(true);
|
|
9683
10296
|
}
|
|
9684
10297
|
}
|
|
9685
10298
|
}
|
|
10299
|
+
/**
|
|
10300
|
+
* LittleJS Light System Plugin
|
|
10301
|
+
* - Adds 2D dynamic lighting to the scene
|
|
10302
|
+
* - Lights are first-class EngineObjects (the Light class)
|
|
10303
|
+
* - Each Light draws a soft falloff blob of its color into a shared lightmap
|
|
10304
|
+
* - Lights accumulate ADDITIVELY in the lightmap (red + blue = magenta)
|
|
10305
|
+
* - The lightmap is then MULTIPLIED with the scene during composite, so unlit
|
|
10306
|
+
* areas go to the ambient color and lit areas show the scene tinted by the
|
|
10307
|
+
* accumulated light color
|
|
10308
|
+
* - Draw the world at full brightness — the lightmap does the darkening
|
|
10309
|
+
* - Any EngineObject may override renderLight() to additively contribute to the
|
|
10310
|
+
* lightmap (e.g. emissive lava tiles, weapon flashes, glowing crystals)
|
|
10311
|
+
* - Must be constructed BEFORE PostProcessPlugin so post-process sees lit pixels
|
|
10312
|
+
* @namespace LightSystem
|
|
10313
|
+
*/
|
|
10314
|
+
|
|
10315
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
10316
|
+
|
|
10317
|
+
/** Global Light System plugin object
|
|
10318
|
+
* @type {LightSystemPlugin}
|
|
10319
|
+
* @memberof LightSystem */
|
|
10320
|
+
let lightSystem;
|
|
10321
|
+
|
|
10322
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
10323
|
+
|
|
10324
|
+
/**
|
|
10325
|
+
* LightSystemPlugin
|
|
10326
|
+
* - Owns the offscreen lightmap texture, falloff/composite shaders, and the
|
|
10327
|
+
* per-frame render pass that multiplies the lightmap onto the WebGL scene
|
|
10328
|
+
* - The composite is MULTIPLICATIVE: unlit areas get the ambient color, lit
|
|
10329
|
+
* areas show the scene tinted by the accumulated light color. So you should
|
|
10330
|
+
* draw your world at full brightness — the lightmap handles the darkening.
|
|
10331
|
+
* @memberof LightSystem
|
|
10332
|
+
*/
|
|
10333
|
+
class LightSystemPlugin
|
|
10334
|
+
{
|
|
10335
|
+
/** Create the global light system plugin.
|
|
10336
|
+
* @param {Vector2} [textureSize] - Size of the lightmap texture (defaults to mainCanvasSize)
|
|
10337
|
+
* @param {Color} [ambientColor] - Color applied to unlit areas of the scene (defaults to BLACK = pitch dark). Set a small RGB like rgb(0.1,0.1,0.15) for a faint "moonlight" baseline so unlit areas aren't fully black.
|
|
10338
|
+
* @example
|
|
10339
|
+
* // simplest usage
|
|
10340
|
+
* new LightSystemPlugin();
|
|
10341
|
+
*/
|
|
10342
|
+
constructor(textureSize, ambientColor)
|
|
10343
|
+
{
|
|
10344
|
+
ASSERT(!lightSystem, 'LightSystemPlugin already initialized');
|
|
10345
|
+
ASSERT(!postProcess, 'LightSystemPlugin must be created before PostProcessPlugin');
|
|
10346
|
+
lightSystem = this;
|
|
10347
|
+
|
|
10348
|
+
/** @property {boolean} - When false, the render pass is skipped entirely */
|
|
10349
|
+
this.enabled = true;
|
|
10350
|
+
/** @property {Color} - Baseline color applied to unlit areas of the scene. Defaults to BLACK (pitch dark). Set to a small RGB for a faint ambient. The lightmap is cleared to this color each frame, then lights add on top, then the result multiplies the scene. */
|
|
10351
|
+
this.ambientColor = (ambientColor || BLACK).copy();
|
|
10352
|
+
/** @property {Vector2} - Size of the lightmap texture (set at construction; falls back to mainCanvasSize at init time) */
|
|
10353
|
+
this.textureSize = textureSize ? textureSize.copy() : undefined;
|
|
10354
|
+
|
|
10355
|
+
/** @property {WebGLTexture} - The lightmap texture */
|
|
10356
|
+
this.texture = undefined;
|
|
10357
|
+
/** @property {WebGLProgram} - Shader for drawing per-Light falloff blobs into the lightmap */
|
|
10358
|
+
this.lightShader = undefined;
|
|
10359
|
+
/** @property {WebGLProgram} - Shader for compositing the lightmap over the main scene */
|
|
10360
|
+
this.compositeShader = undefined;
|
|
10361
|
+
/** @property {WebGLVertexArrayObject} - Vertex array object for the light shader */
|
|
10362
|
+
this.lightVAO = undefined;
|
|
10363
|
+
/** @property {WebGLVertexArrayObject} - Vertex array object for the composite shader */
|
|
10364
|
+
this.compositeVAO = undefined;
|
|
10365
|
+
|
|
10366
|
+
initLightSystem();
|
|
10367
|
+
engineAddPlugin(undefined, lightSystemRender,
|
|
10368
|
+
lightSystemContextLost, lightSystemContextRestored);
|
|
10369
|
+
|
|
10370
|
+
function initLightSystem()
|
|
10371
|
+
{
|
|
10372
|
+
if (headlessMode) return;
|
|
10373
|
+
if (!glEnable)
|
|
10374
|
+
{
|
|
10375
|
+
console.warn('LightSystemPlugin: WebGL not enabled!');
|
|
10376
|
+
return;
|
|
10377
|
+
}
|
|
10378
|
+
|
|
10379
|
+
// resolve texture size default at init time (mainCanvasSize may
|
|
10380
|
+
// not be set yet at the moment the constructor first ran)
|
|
10381
|
+
if (!lightSystem.textureSize)
|
|
10382
|
+
lightSystem.textureSize = mainCanvasSize.copy();
|
|
10383
|
+
|
|
10384
|
+
// allocate the lightmap texture with null data at textureSize
|
|
10385
|
+
lightSystem.texture = glContext.createTexture();
|
|
10386
|
+
glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
|
|
10387
|
+
glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA,
|
|
10388
|
+
lightSystem.textureSize.x, lightSystem.textureSize.y, 0,
|
|
10389
|
+
glContext.RGBA, glContext.UNSIGNED_BYTE, null);
|
|
10390
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, glContext.LINEAR);
|
|
10391
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, glContext.LINEAR);
|
|
10392
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, glContext.CLAMP_TO_EDGE);
|
|
10393
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, glContext.CLAMP_TO_EDGE);
|
|
10394
|
+
|
|
10395
|
+
// light falloff shader: one quad per Light, fragment computes radial falloff
|
|
10396
|
+
lightSystem.lightShader = glCreateProgram(
|
|
10397
|
+
'#version 300 es\n' +
|
|
10398
|
+
'precision highp float;'+
|
|
10399
|
+
'uniform mat4 m;'+
|
|
10400
|
+
'uniform vec2 lightPos;'+
|
|
10401
|
+
'uniform float radius;'+
|
|
10402
|
+
'in vec2 g;'+ // unit quad geometry [0..1]
|
|
10403
|
+
'out vec2 vWorldPos;'+
|
|
10404
|
+
'void main(){'+
|
|
10405
|
+
'vec2 worldP=lightPos+(g-.5)*2.*radius;'+
|
|
10406
|
+
'gl_Position=m*vec4(worldP,1,1);'+
|
|
10407
|
+
'vWorldPos=worldP;'+
|
|
10408
|
+
'}'
|
|
10409
|
+
,
|
|
10410
|
+
'#version 300 es\n' +
|
|
10411
|
+
'precision highp float;'+
|
|
10412
|
+
'uniform vec2 lightPos;'+
|
|
10413
|
+
'uniform float radius;'+
|
|
10414
|
+
'uniform float fadeRange;'+
|
|
10415
|
+
'uniform vec4 color;'+
|
|
10416
|
+
'in vec2 vWorldPos;'+
|
|
10417
|
+
'out vec4 c;'+
|
|
10418
|
+
'void main(){'+
|
|
10419
|
+
'float dist=distance(vWorldPos,lightPos);'+
|
|
10420
|
+
'float t=clamp((radius-dist)/max(fadeRange,1e-6),0.,1.);'+
|
|
10421
|
+
'c=vec4(color.rgb*t*color.a,1.);'+
|
|
10422
|
+
'}'
|
|
10423
|
+
);
|
|
10424
|
+
|
|
10425
|
+
// composite shader: fullscreen quad, samples the lightmap
|
|
10426
|
+
lightSystem.compositeShader = glCreateProgram(
|
|
10427
|
+
'#version 300 es\n' +
|
|
10428
|
+
'precision highp float;'+
|
|
10429
|
+
'in vec2 p;'+
|
|
10430
|
+
'void main(){'+
|
|
10431
|
+
'gl_Position=vec4(p+p-1.,1,1);'+
|
|
10432
|
+
'}'
|
|
10433
|
+
,
|
|
10434
|
+
'#version 300 es\n' +
|
|
10435
|
+
'precision highp float;'+
|
|
10436
|
+
'uniform sampler2D s;'+
|
|
10437
|
+
'uniform vec3 iResolution;'+
|
|
10438
|
+
'out vec4 c;'+
|
|
10439
|
+
'void main(){'+
|
|
10440
|
+
'vec2 uv=gl_FragCoord.xy/iResolution.xy;'+
|
|
10441
|
+
'c=vec4(texture(s,uv).rgb,1.);'+
|
|
10442
|
+
'}'
|
|
10443
|
+
);
|
|
10444
|
+
|
|
10445
|
+
// VAO for the per-Light quad — reuses the engine unit triangle-strip
|
|
10446
|
+
lightSystem.lightVAO = glContext.createVertexArray();
|
|
10447
|
+
glContext.bindVertexArray(lightSystem.lightVAO);
|
|
10448
|
+
glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer);
|
|
10449
|
+
const gLight = glContext.getAttribLocation(lightSystem.lightShader, 'g');
|
|
10450
|
+
glContext.enableVertexAttribArray(gLight);
|
|
10451
|
+
glContext.vertexAttribPointer(gLight, 2, glContext.FLOAT, false, 8, 0);
|
|
10452
|
+
|
|
10453
|
+
// VAO for the composite fullscreen quad — same buffer, attribute named 'p'
|
|
10454
|
+
lightSystem.compositeVAO = glContext.createVertexArray();
|
|
10455
|
+
glContext.bindVertexArray(lightSystem.compositeVAO);
|
|
10456
|
+
glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer);
|
|
10457
|
+
const pComp = glContext.getAttribLocation(lightSystem.compositeShader, 'p');
|
|
10458
|
+
glContext.enableVertexAttribArray(pComp);
|
|
10459
|
+
glContext.vertexAttribPointer(pComp, 2, glContext.FLOAT, false, 8, 0);
|
|
10460
|
+
}
|
|
10461
|
+
function lightSystemRender()
|
|
10462
|
+
{
|
|
10463
|
+
if (headlessMode || !glEnable) return;
|
|
10464
|
+
if (!lightSystem.enabled) return;
|
|
10465
|
+
if (!lightSystem.texture) return; // init failed or context lost
|
|
10466
|
+
|
|
10467
|
+
// 1. flush any in-flight sprite batch from earlier render passes
|
|
10468
|
+
glFlush();
|
|
10469
|
+
const prevAdditive = glAdditive;
|
|
10470
|
+
|
|
10471
|
+
// 2. bind lightmap as render target, clear to ambientColor
|
|
10472
|
+
const ac = lightSystem.ambientColor;
|
|
10473
|
+
glContext.bindFramebuffer(glContext.FRAMEBUFFER, glFramebuffer);
|
|
10474
|
+
glContext.framebufferTexture2D(glContext.FRAMEBUFFER,
|
|
10475
|
+
glContext.COLOR_ATTACHMENT0, glContext.TEXTURE_2D, lightSystem.texture, 0);
|
|
10476
|
+
glContext.viewport(0, 0, lightSystem.textureSize.x, lightSystem.textureSize.y);
|
|
10477
|
+
glContext.clearColor(ac.r, ac.g, ac.b, ac.a);
|
|
10478
|
+
glContext.clear(glContext.COLOR_BUFFER_BIT);
|
|
10479
|
+
|
|
10480
|
+
// 3. walk engineObjects calling renderLight() — additive blend
|
|
10481
|
+
// (lightmap accumulates raw additive color contributions)
|
|
10482
|
+
setBlendMode(true);
|
|
10483
|
+
glContext.enable(glContext.BLEND);
|
|
10484
|
+
glContext.blendFunc(glContext.ONE, glContext.ONE);
|
|
10485
|
+
|
|
10486
|
+
for (const o of engineObjects)
|
|
10487
|
+
o.destroyed || o.renderLight();
|
|
10488
|
+
|
|
10489
|
+
// 4. drain any sprite-batched draws (e.g. drawTile inside a
|
|
10490
|
+
// custom renderLight override) so they hit the FBO, not the
|
|
10491
|
+
// canvas after we unbind
|
|
10492
|
+
glFlush();
|
|
10493
|
+
glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
|
|
10494
|
+
glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
|
|
10495
|
+
|
|
10496
|
+
// 5. composite: fullscreen quad, multiplicative blend onto glCanvas
|
|
10497
|
+
// (scene * lightmap — unlit areas go to black, lit areas are
|
|
10498
|
+
// the scene tinted by the accumulated light color)
|
|
10499
|
+
glContext.useProgram(lightSystem.compositeShader);
|
|
10500
|
+
glContext.bindVertexArray(lightSystem.compositeVAO);
|
|
10501
|
+
glContext.activeTexture(glContext.TEXTURE0);
|
|
10502
|
+
glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
|
|
10503
|
+
const cs = lightSystem.compositeShader;
|
|
10504
|
+
glContext.uniform1i(glContext.getUniformLocation(cs, 's'), 0);
|
|
10505
|
+
glContext.uniform3f(glContext.getUniformLocation(cs, 'iResolution'),
|
|
10506
|
+
mainCanvas.width, mainCanvas.height, 1);
|
|
10507
|
+
glContext.blendFunc(glContext.DST_COLOR, glContext.ZERO);
|
|
10508
|
+
glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
|
|
10509
|
+
|
|
10510
|
+
// 6. restore engine state so subsequent draws use the engine's
|
|
10511
|
+
// tracked texture binding (otherwise glSetTexture would think
|
|
10512
|
+
// the prior texture was still bound when actually the lightmap
|
|
10513
|
+
// is, and any debug text / future draw could sample the lightmap)
|
|
10514
|
+
if (glActiveTexture)
|
|
10515
|
+
glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
|
|
10516
|
+
setBlendMode(prevAdditive);
|
|
10517
|
+
glSetInstancedMode(true);
|
|
10518
|
+
}
|
|
10519
|
+
function lightSystemContextLost()
|
|
10520
|
+
{
|
|
10521
|
+
lightSystem.texture = undefined;
|
|
10522
|
+
lightSystem.lightShader = undefined;
|
|
10523
|
+
lightSystem.compositeShader = undefined;
|
|
10524
|
+
lightSystem.lightVAO = undefined;
|
|
10525
|
+
lightSystem.compositeVAO = undefined;
|
|
10526
|
+
LOG('LightSystemPlugin: WebGL context lost');
|
|
10527
|
+
}
|
|
10528
|
+
function lightSystemContextRestored()
|
|
10529
|
+
{
|
|
10530
|
+
initLightSystem();
|
|
10531
|
+
LOG('LightSystemPlugin: WebGL context restored');
|
|
10532
|
+
}
|
|
10533
|
+
}
|
|
10534
|
+
|
|
10535
|
+
/** Draw a single Light's falloff blob into the currently bound lightmap.
|
|
10536
|
+
* Called by Light.renderLight() during the plugin's render pass.
|
|
10537
|
+
* @param {Light} light */
|
|
10538
|
+
drawLight(light)
|
|
10539
|
+
{
|
|
10540
|
+
if (headlessMode || !glEnable || !this.lightShader) return;
|
|
10541
|
+
|
|
10542
|
+
// drain any sprite-batched draws queued by a previous custom
|
|
10543
|
+
// renderLight() override (e.g. drawRect inside a LavaTile). They were
|
|
10544
|
+
// queued in the engine's instanced-vertex format and must flush with
|
|
10545
|
+
// the engine's shader+VAO bound — NOT this plugin's light shader.
|
|
10546
|
+
glFlush();
|
|
10547
|
+
|
|
10548
|
+
glContext.useProgram(this.lightShader);
|
|
10549
|
+
glContext.bindVertexArray(this.lightVAO);
|
|
10550
|
+
|
|
10551
|
+
// re-apply the engine camera transform onto this shader. Divide by
|
|
10552
|
+
// mainCanvasSize (not textureSize) so world→NDC matches the main
|
|
10553
|
+
// pass; the viewport handles the lightmap's actual resolution.
|
|
10554
|
+
// No y-flip here: the composite samples this FBO with
|
|
10555
|
+
// gl_FragCoord/iResolution (origin bottom-left), so storing world
|
|
10556
|
+
// +Y at the top of the texture lines up with the canvas convention.
|
|
10557
|
+
const s = vec2(2*cameraScale).divide(mainCanvasSize);
|
|
10558
|
+
const rotatedCam = cameraPos.rotate(-cameraAngle);
|
|
10559
|
+
const p = vec2(-1).subtract(rotatedCam.multiply(s));
|
|
10560
|
+
const ca = cos(cameraAngle);
|
|
10561
|
+
const sa = sin(cameraAngle);
|
|
10562
|
+
const transform = [
|
|
10563
|
+
s.x * ca, s.y * sa, 0, 0,
|
|
10564
|
+
-s.x * sa, s.y * ca, 0, 0,
|
|
10565
|
+
1, 1, 1, 0,
|
|
10566
|
+
p.x, p.y, 0, 1];
|
|
10567
|
+
|
|
10568
|
+
const ls = this.lightShader;
|
|
10569
|
+
glContext.uniformMatrix4fv(glContext.getUniformLocation(ls, 'm'), false, transform);
|
|
10570
|
+
glContext.uniform2f(glContext.getUniformLocation(ls, 'lightPos'), light.pos.x, light.pos.y);
|
|
10571
|
+
glContext.uniform1f(glContext.getUniformLocation(ls, 'radius'), light.radius);
|
|
10572
|
+
glContext.uniform1f(glContext.getUniformLocation(ls, 'fadeRange'), light.fadeRange);
|
|
10573
|
+
const c = light.color;
|
|
10574
|
+
glContext.uniform4f(glContext.getUniformLocation(ls, 'color'), c.r, c.g, c.b, c.a);
|
|
10575
|
+
|
|
10576
|
+
glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
|
|
10577
|
+
|
|
10578
|
+
// restore engine's instanced shader+VAO so subsequent renderLight()
|
|
10579
|
+
// overrides that batch through drawRect/drawTile work correctly
|
|
10580
|
+
glSetInstancedMode(true);
|
|
10581
|
+
}
|
|
10582
|
+
}
|
|
10583
|
+
|
|
10584
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
10585
|
+
|
|
10586
|
+
/**
|
|
10587
|
+
* A Light is an EngineObject that contributes a soft additive blob of color
|
|
10588
|
+
* to the LightSystem plugin's lightmap.
|
|
10589
|
+
* @extends EngineObject
|
|
10590
|
+
* @memberof LightSystem
|
|
10591
|
+
* @example
|
|
10592
|
+
* new Light(vec2(5, 5), 4, rgb(1, 0.5, 0)); // orange light, full soft blob
|
|
10593
|
+
* new Light(vec2(0, 0), 8, rgb(1, 1, 1), 2); // white core with 2-unit soft halo
|
|
10594
|
+
*/
|
|
10595
|
+
class Light extends EngineObject
|
|
10596
|
+
{
|
|
10597
|
+
/** Create a light object and add it to the engine object list
|
|
10598
|
+
* @param {Vector2} pos - World space position
|
|
10599
|
+
* @param {number} radius - Total extent of the light in world units
|
|
10600
|
+
* @param {Color} [color] - Color of the light; alpha modulates intensity
|
|
10601
|
+
* @param {number} [fadeRange] - Width of the soft edge in world units (defaults to radius) */
|
|
10602
|
+
constructor(pos, radius, color, fadeRange)
|
|
10603
|
+
{
|
|
10604
|
+
super(pos, vec2(1), undefined, 0, color);
|
|
10605
|
+
ASSERT(isNumber(radius) && radius >= 0, 'Light radius must be a non-negative number');
|
|
10606
|
+
ASSERT(fadeRange === undefined || (isNumber(fadeRange) && fadeRange >= 0),
|
|
10607
|
+
'Light fadeRange must be a non-negative number when provided');
|
|
10608
|
+
|
|
10609
|
+
/** @property {number} - Total extent of the light in world units */
|
|
10610
|
+
this.radius = radius;
|
|
10611
|
+
/** @property {number} - Width of the soft edge in world units */
|
|
10612
|
+
this.fadeRange = fadeRange === undefined ? radius : fadeRange;
|
|
10613
|
+
}
|
|
10614
|
+
|
|
10615
|
+
/** Lights are invisible in the main render pass — they only contribute
|
|
10616
|
+
* to the lightmap via renderLight(). */
|
|
10617
|
+
render() {}
|
|
10618
|
+
|
|
10619
|
+
/** Draw this light's falloff blob into the lightmap.
|
|
10620
|
+
* Called by LightSystemPlugin during its render pass. No-op when the
|
|
10621
|
+
* plugin or WebGL is unavailable. */
|
|
10622
|
+
renderLight()
|
|
10623
|
+
{
|
|
10624
|
+
lightSystem && lightSystem.drawLight(this);
|
|
10625
|
+
}
|
|
10626
|
+
}
|
|
10627
|
+
|
|
9686
10628
|
/**
|
|
9687
10629
|
* LittleJS ZzFXM Plugin
|
|
9688
10630
|
* @namespace ZzFXM
|
|
@@ -10304,11 +11246,17 @@ class UISystemPlugin
|
|
|
10304
11246
|
* @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */
|
|
10305
11247
|
setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver)
|
|
10306
11248
|
{
|
|
10307
|
-
|
|
11249
|
+
// remove any prior listeners so repeated setup calls don't stack
|
|
11250
|
+
if (this._dragListeners)
|
|
11251
|
+
for (const [type, listener] of this._dragListeners)
|
|
11252
|
+
document.removeEventListener(type, listener);
|
|
11253
|
+
this._dragListeners = [];
|
|
11254
|
+
const setCallback = (callback, listenerType)=>
|
|
10308
11255
|
{
|
|
10309
|
-
|
|
11256
|
+
const listener = (e)=> { e.preventDefault(); callback && callback(e); };
|
|
10310
11257
|
document.addEventListener(listenerType, listener);
|
|
10311
|
-
|
|
11258
|
+
this._dragListeners.push([listenerType, listener]);
|
|
11259
|
+
};
|
|
10312
11260
|
setCallback(onDrop, 'drop');
|
|
10313
11261
|
setCallback(onDragEnter, 'dragenter');
|
|
10314
11262
|
setCallback(onDragLeave, 'dragleave');
|
|
@@ -10632,6 +11580,15 @@ class UIObject
|
|
|
10632
11580
|
if (this.destroyed)
|
|
10633
11581
|
return;
|
|
10634
11582
|
|
|
11583
|
+
// clear ui-system references that point at this object so events
|
|
11584
|
+
// don't keep firing against a destroyed target (especially the
|
|
11585
|
+
// keydown listener attached for keyInputObject)
|
|
11586
|
+
if (uiSystem.activeObject === this) uiSystem.activeObject = undefined;
|
|
11587
|
+
if (uiSystem.hoverObject === this) uiSystem.hoverObject = undefined;
|
|
11588
|
+
if (uiSystem.lastHoverObject === this) uiSystem.lastHoverObject = undefined;
|
|
11589
|
+
if (uiSystem.navigationObject === this) uiSystem.navigationObject = undefined;
|
|
11590
|
+
if (uiSystem.keyInputObject === this) uiSystem.keyInputObject = undefined;
|
|
11591
|
+
|
|
10635
11592
|
// disconnect from parent and destroy children
|
|
10636
11593
|
this.destroyed = 1;
|
|
10637
11594
|
this.parent?.removeChild(this);
|
|
@@ -10640,6 +11597,8 @@ class UIObject
|
|
|
10640
11597
|
child.parent = undefined;
|
|
10641
11598
|
child.destroy();
|
|
10642
11599
|
}
|
|
11600
|
+
// clear references so destroyed children can be GC'd
|
|
11601
|
+
this.children.length = 0;
|
|
10643
11602
|
}
|
|
10644
11603
|
|
|
10645
11604
|
/** Check if the mouse is overlapping this ui object
|
|
@@ -11229,6 +12188,7 @@ class UISlider extends UIObject
|
|
|
11229
12188
|
{
|
|
11230
12189
|
// toggle value between 0 and 1
|
|
11231
12190
|
this.value = this.value ? 0 : 1;
|
|
12191
|
+
this.onChange();
|
|
11232
12192
|
this.onRelease();
|
|
11233
12193
|
super.navigatePressed();
|
|
11234
12194
|
}
|
|
@@ -11588,6 +12548,11 @@ class Box2dObject extends EngineObject
|
|
|
11588
12548
|
// destroy physics body, fixtures, and joints
|
|
11589
12549
|
ASSERT(this.body, 'Box2dObject has no body to destroy');
|
|
11590
12550
|
box2d.world.DestroyBody(this.body);
|
|
12551
|
+
|
|
12552
|
+
// remove from tracked list so paused / headless sessions don't leak
|
|
12553
|
+
const i = box2d.objects.indexOf(this);
|
|
12554
|
+
if (i >= 0)
|
|
12555
|
+
box2d.objects.splice(i, 1);
|
|
11591
12556
|
super.destroy();
|
|
11592
12557
|
}
|
|
11593
12558
|
|
|
@@ -11676,7 +12641,9 @@ class Box2dObject extends EngineObject
|
|
|
11676
12641
|
/** Add a box shape to the body
|
|
11677
12642
|
* @param {Vector2} [size]
|
|
11678
12643
|
* @param {Vector2} [offset]
|
|
11679
|
-
* @param {number} [angle]
|
|
12644
|
+
* @param {number} [angle] - LittleJS convention (clockwise positive).
|
|
12645
|
+
* Negated internally to match Box2D's CCW-positive convention so the
|
|
12646
|
+
* fixture aligns with the same angle passed to drawRect/drawTile.
|
|
11680
12647
|
* @param {number} [density]
|
|
11681
12648
|
* @param {number} [friction]
|
|
11682
12649
|
* @param {number} [restitution]
|
|
@@ -11689,7 +12656,7 @@ class Box2dObject extends EngineObject
|
|
|
11689
12656
|
ASSERT(isNumber(angle), 'angle must be a number');
|
|
11690
12657
|
|
|
11691
12658
|
const shape = new box2d.instance.b2PolygonShape();
|
|
11692
|
-
shape.SetAsBox(size.x/2, size.y/2, box2d.vec2dTo(offset), angle);
|
|
12659
|
+
shape.SetAsBox(size.x/2, size.y/2, box2d.vec2dTo(offset), -angle);
|
|
11693
12660
|
return this.addShape(shape, density, friction, restitution, isSensor);
|
|
11694
12661
|
}
|
|
11695
12662
|
|
|
@@ -11705,23 +12672,19 @@ class Box2dObject extends EngineObject
|
|
|
11705
12672
|
|
|
11706
12673
|
function box2dCreatePolygonShape(points)
|
|
11707
12674
|
{
|
|
11708
|
-
|
|
12675
|
+
ASSERT(3 <= points.length && points.length <= 8);
|
|
12676
|
+
const buffer = box2d.instance._malloc(points.length * 8);
|
|
12677
|
+
for (let i=0, offset=0; i<points.length; ++i)
|
|
11709
12678
|
{
|
|
11710
|
-
|
|
11711
|
-
|
|
11712
|
-
|
|
11713
|
-
|
|
11714
|
-
offset += 4;
|
|
11715
|
-
box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].y;
|
|
11716
|
-
offset += 4;
|
|
11717
|
-
}
|
|
11718
|
-
return box2d.instance.wrapPointer(buffer, box2d.instance.b2Vec2);
|
|
12679
|
+
box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].x;
|
|
12680
|
+
offset += 4;
|
|
12681
|
+
box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].y;
|
|
12682
|
+
offset += 4;
|
|
11719
12683
|
}
|
|
11720
|
-
|
|
11721
|
-
ASSERT(3 <= points.length && points.length <= 8);
|
|
12684
|
+
const box2dPoints = box2d.instance.wrapPointer(buffer, box2d.instance.b2Vec2);
|
|
11722
12685
|
const shape = new box2d.instance.b2PolygonShape();
|
|
11723
|
-
const box2dPoints = box2dCreatePointList(points);
|
|
11724
12686
|
shape.Set(box2dPoints, points.length);
|
|
12687
|
+
box2d.instance._free(buffer);
|
|
11725
12688
|
return shape;
|
|
11726
12689
|
}
|
|
11727
12690
|
|
|
@@ -11991,9 +12954,10 @@ class Box2dObject extends EngineObject
|
|
|
11991
12954
|
{
|
|
11992
12955
|
const data = new box2d.instance.b2MassData();
|
|
11993
12956
|
this.body.GetMassData(data);
|
|
11994
|
-
|
|
11995
|
-
|
|
11996
|
-
|
|
12957
|
+
// use !== undefined so setMass(0) (static-equivalent) isn't silently ignored
|
|
12958
|
+
if (localCenter !== undefined) data.set_center(box2d.vec2dTo(localCenter));
|
|
12959
|
+
if (mass !== undefined) data.set_mass(mass);
|
|
12960
|
+
if (momentOfInertia !== undefined) data.set_I(momentOfInertia);
|
|
11997
12961
|
this.body.SetMassData(data);
|
|
11998
12962
|
}
|
|
11999
12963
|
|
|
@@ -13165,6 +14129,8 @@ class Box2dPlugin
|
|
|
13165
14129
|
const fixtureB = contact.GetFixtureB();
|
|
13166
14130
|
const objectA = fixtureA.GetBody().object;
|
|
13167
14131
|
const objectB = fixtureB.GetBody().object;
|
|
14132
|
+
// raw user-created b2Bodies may have no .object — skip those
|
|
14133
|
+
if (!objectA || !objectB) return;
|
|
13168
14134
|
objectA.beginContact(objectB);
|
|
13169
14135
|
objectB.beginContact(objectA);
|
|
13170
14136
|
}
|
|
@@ -13175,6 +14141,7 @@ class Box2dPlugin
|
|
|
13175
14141
|
const fixtureB = contact.GetFixtureB();
|
|
13176
14142
|
const objectA = fixtureA.GetBody().object;
|
|
13177
14143
|
const objectB = fixtureB.GetBody().object;
|
|
14144
|
+
if (!objectA || !objectB) return;
|
|
13178
14145
|
objectA.endContact(objectB);
|
|
13179
14146
|
objectB.endContact(objectA);
|
|
13180
14147
|
};
|
|
@@ -13553,7 +14520,7 @@ async function box2dInit()
|
|
|
13553
14520
|
debugDraw.DrawTransform = function(transform)
|
|
13554
14521
|
{
|
|
13555
14522
|
transform = box2d.instance.wrapPointer(transform, box2d.instance.b2Transform);
|
|
13556
|
-
const pos =
|
|
14523
|
+
const pos = box2d.vec2From(transform.get_p());
|
|
13557
14524
|
const angle = -transform.get_q().GetAngle();
|
|
13558
14525
|
const p1 = vec2(1,0), c1 = rgb(.75,0,0,.8);
|
|
13559
14526
|
const p2 = vec2(0,1), c2 = rgb(0,.75,0,.8);
|
|
@@ -13698,6 +14665,69 @@ function drawThreeSlice(pos, size, startTile, color, borderSize=1, additiveColor
|
|
|
13698
14665
|
const cornerPos = cornerOffset.multiply(vec2(flipX?-1:1, flipY?-flip:flip));
|
|
13699
14666
|
drawTile(pos.add(cornerPos.rotate(rotateAngle)), cornerSize, cornerTile, color, a, false, additiveColor, useWebGL, screenSpace, context);
|
|
13700
14667
|
}
|
|
14668
|
+
}
|
|
14669
|
+
|
|
14670
|
+
/** Draw a crescent / moon-phase shape built from a polygon
|
|
14671
|
+
* Routes through drawPoly, so it supports WebGL, screen space, color, and outlines
|
|
14672
|
+
* @param {Vector2} pos - Center position
|
|
14673
|
+
* @param {number} [size] - Diameter
|
|
14674
|
+
* @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
|
|
14675
|
+
* @param {Color} [color] - Fill color
|
|
14676
|
+
* @param {number} [angle] - Angle to rotate by
|
|
14677
|
+
* @param {boolean} [invert] - Flip which side is illuminated
|
|
14678
|
+
* @param {number} [lineWidth] - Outline width, 0 for no outline
|
|
14679
|
+
* @param {Color} [lineColor] - Outline color
|
|
14680
|
+
* @param {boolean} [useWebGL=glEnable] - Use WebGL for rendering
|
|
14681
|
+
* @param {boolean} [screenSpace] - Use screen space coordinates
|
|
14682
|
+
* @param {CanvasRenderingContext2D} [context] - Canvas context to use
|
|
14683
|
+
* @memberof DrawUtilities */
|
|
14684
|
+
function drawCrescent(pos, size=1, percent=0, color=WHITE, angle=0, invert=false, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
|
|
14685
|
+
{
|
|
14686
|
+
const points = getCrescentPoints(pos, size, percent, angle, invert);
|
|
14687
|
+
drawPoly(points, color, lineWidth, lineColor, vec2(), 0, useWebGL, screenSpace, context);
|
|
14688
|
+
}
|
|
14689
|
+
|
|
14690
|
+
/** Get the list of points that make up a crescent / moon-phase shape
|
|
14691
|
+
* Returns world-space points with pos and angle baked in, ready for drawPoly or other use
|
|
14692
|
+
* @param {Vector2} pos - Center position
|
|
14693
|
+
* @param {number} [size] - Diameter
|
|
14694
|
+
* @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
|
|
14695
|
+
* @param {number} [angle] - Angle to rotate by
|
|
14696
|
+
* @param {boolean} [invert] - Flip which side is illuminated
|
|
14697
|
+
* @param {number} [sides=glCircleSides] - Number of sides for a full circle (halved per arc)
|
|
14698
|
+
* @return {Array<Vector2>} - List of points making up the crescent
|
|
14699
|
+
* @memberof DrawUtilities */
|
|
14700
|
+
function getCrescentPoints(pos, size=1, percent=0, angle=0, invert=false, sides=glCircleSides)
|
|
14701
|
+
{
|
|
14702
|
+
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
14703
|
+
ASSERT(isNumber(size) && isNumber(percent), 'size and percent must be numbers');
|
|
14704
|
+
|
|
14705
|
+
// map phase to a signed terminator curve: -1 new, 0 half, 1 full
|
|
14706
|
+
let p = mod(percent*4, 4); // quarter phase 0..4
|
|
14707
|
+
if (p >= 2) // second half of cycle flips orientation
|
|
14708
|
+
angle += PI;
|
|
14709
|
+
p = p <= 2 ? p-1 : 3-p;
|
|
14710
|
+
if (invert) // flip the illuminated side
|
|
14711
|
+
{
|
|
14712
|
+
p = -p;
|
|
14713
|
+
angle += PI;
|
|
14714
|
+
}
|
|
14715
|
+
|
|
14716
|
+
// build the crescent: outer semicircle, then inner half-ellipse traced back
|
|
14717
|
+
const points = [];
|
|
14718
|
+
const segs = max(3, sides>>1);
|
|
14719
|
+
const radius = size/2;
|
|
14720
|
+
for (let i=0; i<=segs; i++)
|
|
14721
|
+
{
|
|
14722
|
+
const t = i/segs*PI;
|
|
14723
|
+
points.push(vec2(radius*cos(t), radius*sin(t)).rotate(angle).add(pos));
|
|
14724
|
+
}
|
|
14725
|
+
for (let i=segs; i>=0; i--)
|
|
14726
|
+
{
|
|
14727
|
+
const t = i/segs*PI;
|
|
14728
|
+
points.push(vec2(radius*cos(t), -radius*p*sin(t)).rotate(angle).add(pos));
|
|
14729
|
+
}
|
|
14730
|
+
return points;
|
|
13701
14731
|
}
|
|
13702
14732
|
/**
|
|
13703
14733
|
* LittleJS Tween System Plugin
|
|
@@ -13957,7 +14987,7 @@ const Ease =
|
|
|
13957
14987
|
* @param {number} x
|
|
13958
14988
|
* @returns {number}
|
|
13959
14989
|
* @memberof TweenSystem */
|
|
13960
|
-
EXPO: (x) => 2 ** (10 * x - 10),
|
|
14990
|
+
EXPO: (x) => x === 0 ? 0 : 2 ** (10 * x - 10),
|
|
13961
14991
|
|
|
13962
14992
|
/** Back ease-in: overshoots backward at the start before snapping forward.
|
|
13963
14993
|
* @param {number} x
|
|
@@ -13970,6 +15000,8 @@ const Ease =
|
|
|
13970
15000
|
* @returns {number}
|
|
13971
15001
|
* @memberof TweenSystem */
|
|
13972
15002
|
ELASTIC: (x) =>
|
|
15003
|
+
x === 0 ? 0 :
|
|
15004
|
+
x === 1 ? 1 :
|
|
13973
15005
|
-(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
|
|
13974
15006
|
|
|
13975
15007
|
/** Spring-like ease-out: oscillates outward after passing the target.
|
|
@@ -14130,29 +15162,32 @@ function tweenProperty(target, propertyPath, start, end, duration = 1, options =
|
|
|
14130
15162
|
}
|
|
14131
15163
|
|
|
14132
15164
|
// Continuation that schedules the next loop iteration when one finishes.
|
|
14133
|
-
//
|
|
14134
|
-
//
|
|
14135
|
-
|
|
15165
|
+
// Reuses the same Tween object across iterations so the user's handle
|
|
15166
|
+
// from `.loop()` keeps working — calling `.stop()` mid-loop now cancels
|
|
15167
|
+
// the entire chain instead of just the current iteration.
|
|
15168
|
+
function loopContinuation(tween)
|
|
14136
15169
|
{
|
|
14137
|
-
if (
|
|
14138
|
-
|
|
14139
|
-
|
|
14140
|
-
|
|
14141
|
-
|
|
14142
|
-
|
|
14143
|
-
|
|
15170
|
+
if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
|
|
15171
|
+
if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
|
|
15172
|
+
tween.life = tween.duration;
|
|
15173
|
+
tween.thenCallback = () => loopContinuation(tween);
|
|
15174
|
+
tweenActive.push(tween);
|
|
15175
|
+
// snap to start for the new iteration (matches Tween constructor behavior)
|
|
15176
|
+
tween.callback(tween.interp(tween.duration));
|
|
14144
15177
|
}
|
|
14145
15178
|
|
|
14146
|
-
// Continuation for pingPong:
|
|
14147
|
-
function pingPongContinuation(
|
|
15179
|
+
// Continuation for pingPong: swaps start and end on the same tween each iteration.
|
|
15180
|
+
function pingPongContinuation(tween)
|
|
14148
15181
|
{
|
|
14149
|
-
if (
|
|
14150
|
-
|
|
14151
|
-
|
|
14152
|
-
|
|
14153
|
-
|
|
14154
|
-
|
|
14155
|
-
|
|
15182
|
+
if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
|
|
15183
|
+
if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
|
|
15184
|
+
const tmp = tween.start;
|
|
15185
|
+
tween.start = tween.end;
|
|
15186
|
+
tween.end = tmp;
|
|
15187
|
+
tween.life = tween.duration;
|
|
15188
|
+
tween.thenCallback = () => pingPongContinuation(tween);
|
|
15189
|
+
tweenActive.push(tween);
|
|
15190
|
+
tween.callback(tween.interp(tween.duration));
|
|
14156
15191
|
}
|
|
14157
15192
|
|
|
14158
15193
|
/** Engine plugin hook: advance every active tween by the appropriate delta.
|
|
@@ -14486,11 +15521,13 @@ class PathFinder
|
|
|
14486
15521
|
if (dx !== 0 && dy !== 0)
|
|
14487
15522
|
{
|
|
14488
15523
|
// Diagonal step: refuse if either cardinal neighbor is
|
|
14489
|
-
// blocked
|
|
15524
|
+
// blocked. Prevents cutting through walls at corners.
|
|
15525
|
+
// (Costed-but-walkable cardinals do not block — diagonal
|
|
15526
|
+
// movement around expensive terrain is standard A*.)
|
|
14490
15527
|
const card1 = this.getNode(current.pos.x + dx, current.pos.y);
|
|
14491
|
-
if (!card1 ||
|
|
15528
|
+
if (!card1 || !card1.walkable) continue;
|
|
14492
15529
|
const card2 = this.getNode(current.pos.x, current.pos.y + dy);
|
|
14493
|
-
if (!card2 ||
|
|
15530
|
+
if (!card2 || !card2.walkable) continue;
|
|
14494
15531
|
stepCost = PATHFINDER_DIAGONAL_COST;
|
|
14495
15532
|
}
|
|
14496
15533
|
|
|
@@ -15089,12 +16126,18 @@ export
|
|
|
15089
16126
|
inputWASDEmulateDirection,
|
|
15090
16127
|
touchInputEnable,
|
|
15091
16128
|
touchGamepadEnable,
|
|
16129
|
+
touchGamepadPassthrough,
|
|
15092
16130
|
touchGamepadCenterButtonSize,
|
|
15093
16131
|
touchGamepadButtonCount,
|
|
16132
|
+
touchGamepadLeftStick,
|
|
16133
|
+
touchGamepadLeftButtonCount,
|
|
16134
|
+
touchGamepadRightStick,
|
|
15094
16135
|
touchGamepadAnalog,
|
|
16136
|
+
touchGamepadFloating,
|
|
15095
16137
|
touchGamepadSize,
|
|
15096
16138
|
touchGamepadAlpha,
|
|
15097
16139
|
touchGamepadDisplayTime,
|
|
16140
|
+
touchGamepadVibration,
|
|
15098
16141
|
vibrateEnable,
|
|
15099
16142
|
soundEnable,
|
|
15100
16143
|
soundVolume,
|
|
@@ -15137,11 +16180,18 @@ export
|
|
|
15137
16180
|
setGamepadDirectionEmulateStick,
|
|
15138
16181
|
setInputWASDEmulateDirection,
|
|
15139
16182
|
setTouchGamepadEnable,
|
|
16183
|
+
setTouchGamepadPassthrough,
|
|
15140
16184
|
setTouchGamepadCenterButtonSize,
|
|
15141
16185
|
setTouchGamepadButtonCount,
|
|
16186
|
+
setTouchGamepadLeftStick,
|
|
16187
|
+
setTouchGamepadLeftButtonCount,
|
|
16188
|
+
setTouchGamepadRightStick,
|
|
15142
16189
|
setTouchGamepadAnalog,
|
|
16190
|
+
setTouchGamepadFloating,
|
|
15143
16191
|
setTouchGamepadSize,
|
|
15144
16192
|
setTouchGamepadAlpha,
|
|
16193
|
+
setTouchGamepadDisplayTime,
|
|
16194
|
+
setTouchGamepadVibration,
|
|
15145
16195
|
setVibrateEnable,
|
|
15146
16196
|
setSoundEnable,
|
|
15147
16197
|
setSoundVolume,
|
|
@@ -15262,14 +16312,15 @@ export
|
|
|
15262
16312
|
drawRegularPoly,
|
|
15263
16313
|
drawEllipse,
|
|
15264
16314
|
drawCircle,
|
|
16315
|
+
drawEllipseGradient,
|
|
15265
16316
|
drawCircleGradient,
|
|
15266
16317
|
drawCanvas2D,
|
|
15267
16318
|
drawText,
|
|
15268
16319
|
drawTextScreen,
|
|
15269
16320
|
setBlendMode,
|
|
15270
16321
|
combineCanvases,
|
|
15271
|
-
|
|
15272
|
-
|
|
16322
|
+
engineImageFont,
|
|
16323
|
+
ImageFont,
|
|
15273
16324
|
isFullscreen,
|
|
15274
16325
|
toggleFullscreen,
|
|
15275
16326
|
setCursor,
|
|
@@ -15378,16 +16429,17 @@ export
|
|
|
15378
16429
|
// Medals
|
|
15379
16430
|
medals,
|
|
15380
16431
|
medalsPreventUnlock,
|
|
15381
|
-
medalsInit,
|
|
15382
|
-
medalsForEach,
|
|
15383
|
-
Medal,
|
|
15384
16432
|
medalDisplayTime,
|
|
15385
16433
|
medalDisplaySlideTime,
|
|
15386
16434
|
medalDisplaySize,
|
|
16435
|
+
medalsInit,
|
|
16436
|
+
medalsForEach,
|
|
16437
|
+
medalsReset,
|
|
15387
16438
|
setMedalDisplayTime,
|
|
15388
16439
|
setMedalDisplaySlideTime,
|
|
15389
16440
|
setMedalDisplaySize,
|
|
15390
16441
|
setMedalsPreventUnlock,
|
|
16442
|
+
Medal,
|
|
15391
16443
|
|
|
15392
16444
|
// Newgrounds
|
|
15393
16445
|
newgrounds,
|
|
@@ -15398,8 +16450,14 @@ export
|
|
|
15398
16450
|
postProcess,
|
|
15399
16451
|
PostProcessPlugin,
|
|
15400
16452
|
|
|
16453
|
+
// Light System
|
|
16454
|
+
lightSystem,
|
|
16455
|
+
LightSystemPlugin,
|
|
16456
|
+
Light,
|
|
16457
|
+
|
|
15401
16458
|
// ZzFXMusic
|
|
15402
16459
|
ZzFXMusic,
|
|
16460
|
+
zzfxM,
|
|
15403
16461
|
|
|
15404
16462
|
// UI System
|
|
15405
16463
|
uiSystem,
|
|
@@ -15446,6 +16504,8 @@ export
|
|
|
15446
16504
|
drawNineSliceScreen,
|
|
15447
16505
|
drawThreeSlice,
|
|
15448
16506
|
drawThreeSliceScreen,
|
|
16507
|
+
drawCrescent,
|
|
16508
|
+
getCrescentPoints,
|
|
15449
16509
|
|
|
15450
16510
|
// Tween System
|
|
15451
16511
|
Tween,
|