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.release.js
CHANGED
|
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
|
|
|
35
35
|
* @type {string}
|
|
36
36
|
* @default
|
|
37
37
|
* @memberof Engine */
|
|
38
|
-
const engineVersion = '1.18.
|
|
38
|
+
const engineVersion = '1.18.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
|
{
|
|
@@ -585,6 +596,7 @@ const debugPhysics = 0;
|
|
|
585
596
|
const debugParticles = 0;
|
|
586
597
|
const debugRaycast = 0;
|
|
587
598
|
const debugGamepads = 0;
|
|
599
|
+
const debugSound = 0;
|
|
588
600
|
const debugPointSize = .5;
|
|
589
601
|
|
|
590
602
|
// debug commands are automatically removed from the final build
|
|
@@ -671,19 +683,19 @@ const max = Math.max;
|
|
|
671
683
|
* @param {number} x
|
|
672
684
|
* @return {number}
|
|
673
685
|
* @memberof Math */
|
|
674
|
-
const sign = Math.sign;
|
|
686
|
+
const sign = (x) => Math.sign(x);
|
|
675
687
|
|
|
676
688
|
/** Returns hypotenuse of values passed in
|
|
677
689
|
* @param {...number} values
|
|
678
690
|
* @return {number}
|
|
679
691
|
* @memberof Math */
|
|
680
|
-
const hypot = Math.hypot;
|
|
692
|
+
const hypot = (...values) => Math.hypot(...values);
|
|
681
693
|
|
|
682
694
|
/** Returns log2 of value passed in
|
|
683
695
|
* @param {number} x
|
|
684
696
|
* @return {number}
|
|
685
697
|
* @memberof Math */
|
|
686
|
-
const log2 = Math.log2;
|
|
698
|
+
const log2 = (x) => Math.log2(x);
|
|
687
699
|
|
|
688
700
|
/** Returns sin of value passed in
|
|
689
701
|
* @param {number} x
|
|
@@ -825,7 +837,8 @@ function isOverlapping(posA, sizeA, posB, sizeB=vec2())
|
|
|
825
837
|
const dy = (posA.y - posB.y)*2;
|
|
826
838
|
const sx = sizeA.x + sizeB.x;
|
|
827
839
|
const sy = sizeA.y + sizeB.y;
|
|
828
|
-
|
|
840
|
+
// symmetric so isOverlapping(A,B) === isOverlapping(B,A) at touching edges
|
|
841
|
+
return abs(dx) < sx && abs(dy) < sy;
|
|
829
842
|
}
|
|
830
843
|
|
|
831
844
|
/** Returns true if a line segment is intersecting an axis aligned box
|
|
@@ -914,7 +927,7 @@ function isStringLike(s) { return s != null && typeof s?.toString() === 'string'
|
|
|
914
927
|
/**
|
|
915
928
|
* Check if object is an array
|
|
916
929
|
* @param {any} a
|
|
917
|
-
* @return {
|
|
930
|
+
* @return {a is Array<any>}
|
|
918
931
|
* @memberof Math */
|
|
919
932
|
function isArray(a) { return Array.isArray(a); }
|
|
920
933
|
|
|
@@ -1052,7 +1065,13 @@ function randVec2(length=1) { return new Vector2().setAngle(rand(2*PI), length);
|
|
|
1052
1065
|
* @return {Vector2}
|
|
1053
1066
|
* @memberof Random */
|
|
1054
1067
|
function randInCircle(radius=1, minRadius=0)
|
|
1055
|
-
{
|
|
1068
|
+
{
|
|
1069
|
+
// r is uniform in area ⇒ r² uniform in [minRadius², radius²]
|
|
1070
|
+
// (the squared inner bound is what makes minRadius the actual exclusion edge)
|
|
1071
|
+
if (radius <= 0) return new Vector2;
|
|
1072
|
+
const ratio = clamp(minRadius / radius);
|
|
1073
|
+
return randVec2(radius * rand(ratio*ratio, 1)**.5);
|
|
1074
|
+
}
|
|
1056
1075
|
|
|
1057
1076
|
/** Returns a random color between the two passed in colors, combine components if linear
|
|
1058
1077
|
* @param {Color} [colorA=WHITE]
|
|
@@ -1122,7 +1141,14 @@ class RandomGenerator
|
|
|
1122
1141
|
* @param {number} [valueA]
|
|
1123
1142
|
* @param {number} [valueB]
|
|
1124
1143
|
* @return {number} */
|
|
1125
|
-
floatSign(valueA=1, valueB=0)
|
|
1144
|
+
floatSign(valueA=1, valueB=0)
|
|
1145
|
+
{
|
|
1146
|
+
const lo = min(valueA, valueB);
|
|
1147
|
+
const hi = max(valueA, valueB);
|
|
1148
|
+
const d = hi - lo;
|
|
1149
|
+
const e = this.float(d*2);
|
|
1150
|
+
return e < d ? lo + e : d - lo - e;
|
|
1151
|
+
}
|
|
1126
1152
|
|
|
1127
1153
|
/** Returns a random angle between -PI and PI
|
|
1128
1154
|
* @return {number} */
|
|
@@ -1835,9 +1861,15 @@ class Timer
|
|
|
1835
1861
|
* @return {number} */
|
|
1836
1862
|
get() { return this.isSet()? this.getGlobalTime() - this.time : 0; }
|
|
1837
1863
|
|
|
1838
|
-
/** Get percentage elapsed based on time it was set to, returns 0 if not set
|
|
1864
|
+
/** Get percentage elapsed based on time it was set to, returns 0 if not set.
|
|
1865
|
+
* Zero-duration timers report 1 (already elapsed).
|
|
1839
1866
|
* @return {number} */
|
|
1840
|
-
getPercent()
|
|
1867
|
+
getPercent()
|
|
1868
|
+
{
|
|
1869
|
+
if (!this.isSet()) return 0;
|
|
1870
|
+
if (!this.setTime) return 1;
|
|
1871
|
+
return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime);
|
|
1872
|
+
}
|
|
1841
1873
|
|
|
1842
1874
|
/** Get the time this timer was set to, returns 0 if not set
|
|
1843
1875
|
* @return {number} */
|
|
@@ -1864,9 +1896,9 @@ class Timer
|
|
|
1864
1896
|
* @memberof Utilities */
|
|
1865
1897
|
function formatTime(t)
|
|
1866
1898
|
{
|
|
1867
|
-
const
|
|
1899
|
+
const signStr = t < 0 ? '-' : '';
|
|
1868
1900
|
t = abs(t)|0;
|
|
1869
|
-
return
|
|
1901
|
+
return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60;
|
|
1870
1902
|
}
|
|
1871
1903
|
|
|
1872
1904
|
/** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await!
|
|
@@ -1952,15 +1984,20 @@ function shareURL(title, url, callback)
|
|
|
1952
1984
|
function readSaveData(saveName, defaultSaveData)
|
|
1953
1985
|
{
|
|
1954
1986
|
ASSERT(isStringLike(saveName), 'loadData requires saveName string');
|
|
1955
|
-
|
|
1956
|
-
//
|
|
1957
|
-
|
|
1987
|
+
|
|
1988
|
+
// tolerate localStorage being unavailable (iOS private mode, sandboxed
|
|
1989
|
+
// iframes) and corrupt JSON in stored data
|
|
1958
1990
|
let loadedData = {};
|
|
1959
|
-
|
|
1991
|
+
try
|
|
1960
1992
|
{
|
|
1961
|
-
|
|
1962
|
-
|
|
1993
|
+
const data = localStorage[saveName];
|
|
1994
|
+
if (data)
|
|
1995
|
+
{
|
|
1996
|
+
try { loadedData = JSON.parse(data); }
|
|
1997
|
+
catch { LOG('readSaveData: corrupt JSON for', saveName, '— using defaults'); }
|
|
1998
|
+
}
|
|
1963
1999
|
}
|
|
2000
|
+
catch { LOG('readSaveData: localStorage unavailable — using defaults'); }
|
|
1964
2001
|
return { ...defaultSaveData, ...loadedData };
|
|
1965
2002
|
}
|
|
1966
2003
|
|
|
@@ -1971,7 +2008,9 @@ function readSaveData(saveName, defaultSaveData)
|
|
|
1971
2008
|
function writeSaveData(saveName, saveData)
|
|
1972
2009
|
{
|
|
1973
2010
|
ASSERT(isStringLike(saveName), 'saveData requires saveName string');
|
|
1974
|
-
localStorage
|
|
2011
|
+
// tolerate localStorage being unavailable or quota exceeded
|
|
2012
|
+
try { localStorage[saveName] = JSON.stringify(saveData); }
|
|
2013
|
+
catch { LOG('writeSaveData: failed to write', saveName); }
|
|
1975
2014
|
}
|
|
1976
2015
|
|
|
1977
2016
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -2066,7 +2105,7 @@ let canvasColorTiles = true;
|
|
|
2066
2105
|
|
|
2067
2106
|
/** Color to clear the canvas to before render, does not clear if alpha is 0
|
|
2068
2107
|
* @type {Color}
|
|
2069
|
-
* @memberof
|
|
2108
|
+
* @memberof Settings */
|
|
2070
2109
|
let canvasClearColor = CLEAR_BLACK;
|
|
2071
2110
|
|
|
2072
2111
|
/** The max size of the canvas, centered if window is larger
|
|
@@ -2259,34 +2298,78 @@ let touchInputEnable = true;
|
|
|
2259
2298
|
* - Supports left analog stick, 4 face buttons and start button (button 9)
|
|
2260
2299
|
* - setTouchGamepadButtonCount(1) to use face buttons as right analog stick
|
|
2261
2300
|
* - Analog stick buttons 10 and 11 are also activated when virtual sticks are touched
|
|
2262
|
-
|
|
2301
|
+
* - Rendered as a full-viewport HTML/SVG overlay, so controls may sit outside the game canvas
|
|
2263
2302
|
* @type {boolean}
|
|
2264
2303
|
* @default
|
|
2265
2304
|
* @memberof Settings */
|
|
2266
2305
|
let touchGamepadEnable = false;
|
|
2267
2306
|
|
|
2268
|
-
/** True if
|
|
2269
|
-
* -
|
|
2307
|
+
/** True if touches outside the gamepad controls should still drive mouse/touch input
|
|
2308
|
+
* - When false (the default), enabling the touch gamepad suppresses touch-to-mouse input entirely
|
|
2309
|
+
* - Set true to also pass touches outside the controls through to the game as mouse/touch input
|
|
2310
|
+
* - Touches on the gamepad controls never drive the mouse regardless of this setting
|
|
2311
|
+
* @type {boolean}
|
|
2312
|
+
* @default
|
|
2313
|
+
* @memberof Settings */
|
|
2314
|
+
let touchGamepadPassthrough = false;
|
|
2315
|
+
|
|
2316
|
+
/** Size of center button if touch gamepad should have start button in the center
|
|
2317
|
+
* - Prevents activating when pressed near virtual stick or face buttons
|
|
2270
2318
|
* - When the game is paused, any touch will press the button
|
|
2271
|
-
* -
|
|
2319
|
+
* - Measured in viewport CSS pixels
|
|
2272
2320
|
* @type {number}
|
|
2273
2321
|
* @default
|
|
2274
2322
|
* @memberof Settings */
|
|
2275
|
-
let touchGamepadCenterButtonSize =
|
|
2323
|
+
let touchGamepadCenterButtonSize = 0;
|
|
2276
2324
|
|
|
2277
|
-
/** Number of buttons on touch gamepad (0-4),
|
|
2325
|
+
/** Number of buttons on the right side of the touch gamepad (0-4), using gamepad buttons 0-3
|
|
2326
|
+
* - A count of 1 is a single large button (the size of a stick)
|
|
2327
|
+
* - Ignored when touchGamepadRightStick is set (the right side is a stick instead)
|
|
2278
2328
|
* @type {number}
|
|
2279
2329
|
* @default
|
|
2280
2330
|
* @memberof Settings */
|
|
2281
2331
|
let touchGamepadButtonCount = 4;
|
|
2282
2332
|
|
|
2333
|
+
/** True if the touch gamepad should have a left analog stick (or dpad)
|
|
2334
|
+
* - When false, the left side is face buttons (touchGamepadLeftButtonCount) or nothing
|
|
2335
|
+
* @type {boolean}
|
|
2336
|
+
* @default
|
|
2337
|
+
* @memberof Settings */
|
|
2338
|
+
let touchGamepadLeftStick = true;
|
|
2339
|
+
|
|
2340
|
+
/** Number of buttons on the left side of the touch gamepad (0-4), using gamepad buttons 4-7
|
|
2341
|
+
* - Only used when touchGamepadLeftStick is false (otherwise the left side is a stick)
|
|
2342
|
+
* - A count of 1 is a single large button (the size of a stick)
|
|
2343
|
+
* @type {number}
|
|
2344
|
+
* @default
|
|
2345
|
+
* @memberof Settings */
|
|
2346
|
+
let touchGamepadLeftButtonCount = 0;
|
|
2347
|
+
|
|
2348
|
+
/** True if the touch gamepad right side should be an analog stick (or dpad) instead of face buttons
|
|
2349
|
+
* - When set, touchGamepadButtonCount is ignored and the right side is a stick
|
|
2350
|
+
* - Uses an analog stick when touchGamepadAnalog is true, otherwise an 8 way dpad
|
|
2351
|
+
* @type {boolean}
|
|
2352
|
+
* @default
|
|
2353
|
+
* @memberof Settings */
|
|
2354
|
+
let touchGamepadRightStick = false;
|
|
2355
|
+
|
|
2283
2356
|
/** True if touch gamepad should be analog stick or false to use if 8 way dpad
|
|
2284
2357
|
* @type {boolean}
|
|
2285
2358
|
* @default
|
|
2286
2359
|
* @memberof Settings */
|
|
2287
2360
|
let touchGamepadAnalog = true;
|
|
2288
2361
|
|
|
2289
|
-
/**
|
|
2362
|
+
/** True if touch gamepad directional controls should float to where you press
|
|
2363
|
+
* - Only affects analog sticks and dpads, not face buttons
|
|
2364
|
+
* - Directional controls re-anchor to where you press within the bottom ~60% of their screen half; the top ~40% passes through to the game
|
|
2365
|
+
* - The right side floats only when it acts as the right analog stick (touchGamepadRightStick is set)
|
|
2366
|
+
* - A center button (touchGamepadCenterButtonSize) still works since it ignores touches near the sticks
|
|
2367
|
+
* @type {boolean}
|
|
2368
|
+
* @default
|
|
2369
|
+
* @memberof Settings */
|
|
2370
|
+
let touchGamepadFloating = false;
|
|
2371
|
+
|
|
2372
|
+
/** Size of virtual gamepad for touch devices in viewport CSS pixels
|
|
2290
2373
|
* @type {number}
|
|
2291
2374
|
* @default
|
|
2292
2375
|
* @memberof Settings */
|
|
@@ -2304,6 +2387,13 @@ let touchGamepadAlpha = .3;
|
|
|
2304
2387
|
* @memberof Settings */
|
|
2305
2388
|
let touchGamepadDisplayTime = 3;
|
|
2306
2389
|
|
|
2390
|
+
/** Duration in ms to vibrate when a touch gamepad face button or start button is pressed
|
|
2391
|
+
* - Set to 0 to disable, also requires vibrateEnable and hardware support (ignored on iOS)
|
|
2392
|
+
* @type {number}
|
|
2393
|
+
* @default
|
|
2394
|
+
* @memberof Settings */
|
|
2395
|
+
let touchGamepadVibration = 0;
|
|
2396
|
+
|
|
2307
2397
|
/** Allow vibration hardware if it exists
|
|
2308
2398
|
* @type {boolean}
|
|
2309
2399
|
* @default
|
|
@@ -2536,6 +2626,11 @@ function setTouchInputEnable(enable) { touchInputEnable = enable; }
|
|
|
2536
2626
|
* @memberof Settings */
|
|
2537
2627
|
function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
|
|
2538
2628
|
|
|
2629
|
+
/** Set if touches outside the gamepad controls should still drive mouse/touch input
|
|
2630
|
+
* @param {boolean} passthrough
|
|
2631
|
+
* @memberof Settings */
|
|
2632
|
+
function setTouchGamepadPassthrough(passthrough) { touchGamepadPassthrough = passthrough; }
|
|
2633
|
+
|
|
2539
2634
|
/** Set if touch gamepad should have start button in the center
|
|
2540
2635
|
* - Set size to enable the center button
|
|
2541
2636
|
* - When the game is paused, any touch will press the button
|
|
@@ -2543,16 +2638,57 @@ function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
|
|
|
2543
2638
|
* @memberof Settings */
|
|
2544
2639
|
function setTouchGamepadCenterButtonSize(size) { touchGamepadCenterButtonSize = size; }
|
|
2545
2640
|
|
|
2546
|
-
/** Set number of buttons on touch gamepad (0-4
|
|
2641
|
+
/** Set number of buttons on the right side of the touch gamepad (0-4, gamepad buttons 0-3)
|
|
2547
2642
|
* @param {number} count
|
|
2548
2643
|
* @memberof Settings */
|
|
2549
|
-
function setTouchGamepadButtonCount(count)
|
|
2644
|
+
function setTouchGamepadButtonCount(count)
|
|
2645
|
+
{
|
|
2646
|
+
touchGamepadButtonCount = count;
|
|
2647
|
+
if (count > 0)
|
|
2648
|
+
touchGamepadRightStick = false;
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
/** Set if the touch gamepad should have a left analog stick (or dpad)
|
|
2652
|
+
* @param {boolean} enable
|
|
2653
|
+
* @memberof Settings */
|
|
2654
|
+
function setTouchGamepadLeftStick(enable)
|
|
2655
|
+
{
|
|
2656
|
+
touchGamepadLeftStick = enable;
|
|
2657
|
+
if (enable)
|
|
2658
|
+
touchGamepadLeftButtonCount = 0;
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
/** Set number of buttons on the left side of the touch gamepad (0-4, gamepad buttons 4-7)
|
|
2662
|
+
* - Only used when touchGamepadLeftStick is false
|
|
2663
|
+
* @param {number} count
|
|
2664
|
+
* @memberof Settings */
|
|
2665
|
+
function setTouchGamepadLeftButtonCount(count)
|
|
2666
|
+
{
|
|
2667
|
+
touchGamepadLeftButtonCount = count;
|
|
2668
|
+
if (count > 0)
|
|
2669
|
+
touchGamepadLeftStick = false;
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
/** Set if the touch gamepad right side is an analog stick (or dpad) instead of face buttons
|
|
2673
|
+
* @param {boolean} rightStick
|
|
2674
|
+
* @memberof Settings */
|
|
2675
|
+
function setTouchGamepadRightStick(rightStick)
|
|
2676
|
+
{
|
|
2677
|
+
touchGamepadRightStick = rightStick;
|
|
2678
|
+
if (rightStick)
|
|
2679
|
+
touchGamepadButtonCount = 0;
|
|
2680
|
+
}
|
|
2550
2681
|
|
|
2551
2682
|
/** Set if touch gamepad should be analog stick or 8 way dpad
|
|
2552
2683
|
* @param {boolean} analog
|
|
2553
2684
|
* @memberof Settings */
|
|
2554
2685
|
function setTouchGamepadAnalog(analog) { touchGamepadAnalog = analog; }
|
|
2555
2686
|
|
|
2687
|
+
/** Set if touch gamepad directional controls should float to where you press
|
|
2688
|
+
* @param {boolean} floating
|
|
2689
|
+
* @memberof Settings */
|
|
2690
|
+
function setTouchGamepadFloating(floating) { touchGamepadFloating = floating; }
|
|
2691
|
+
|
|
2556
2692
|
/** Set size of virtual gamepad for touch devices in pixels
|
|
2557
2693
|
* @param {number} size
|
|
2558
2694
|
* @memberof Settings */
|
|
@@ -2568,6 +2704,11 @@ function setTouchGamepadAlpha(alpha) { touchGamepadAlpha = alpha; }
|
|
|
2568
2704
|
* @memberof Settings */
|
|
2569
2705
|
function setTouchGamepadDisplayTime(time) { touchGamepadDisplayTime = time; }
|
|
2570
2706
|
|
|
2707
|
+
/** Set duration in ms to vibrate when a touch gamepad face or start button is pressed (0 disables)
|
|
2708
|
+
* @param {number} ms
|
|
2709
|
+
* @memberof Settings */
|
|
2710
|
+
function setTouchGamepadVibration(ms) { touchGamepadVibration = ms; }
|
|
2711
|
+
|
|
2571
2712
|
/** Set to allow vibration hardware if it exists
|
|
2572
2713
|
* @param {boolean} enable
|
|
2573
2714
|
* @memberof Settings */
|
|
@@ -2678,7 +2819,7 @@ class EngineObject
|
|
|
2678
2819
|
this.color = color.copy();
|
|
2679
2820
|
/** @property {Color} - Additive color to apply when rendered */
|
|
2680
2821
|
this.additiveColor = undefined;
|
|
2681
|
-
/** @property {boolean} - Should
|
|
2822
|
+
/** @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. */
|
|
2682
2823
|
this.mirror = false;
|
|
2683
2824
|
/** @property {boolean} - Has object been destroyed? */
|
|
2684
2825
|
this.destroyed = false;
|
|
@@ -2746,10 +2887,10 @@ class EngineObject
|
|
|
2746
2887
|
if (pa)
|
|
2747
2888
|
{
|
|
2748
2889
|
const c = cos(-pa), s = sin(-pa);
|
|
2749
|
-
this.pos
|
|
2890
|
+
this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
|
|
2750
2891
|
}
|
|
2751
2892
|
else
|
|
2752
|
-
this.pos
|
|
2893
|
+
this.pos.set(lx + pp.x, ly + pp.y);
|
|
2753
2894
|
this.angle = mirror*this.localAngle + pa;
|
|
2754
2895
|
}
|
|
2755
2896
|
|
|
@@ -2764,6 +2905,9 @@ class EngineObject
|
|
|
2764
2905
|
// child objects do not have physics
|
|
2765
2906
|
ASSERT(!this.parent);
|
|
2766
2907
|
|
|
2908
|
+
// bail if a collision callback destroyed us mid-frame
|
|
2909
|
+
if (this.destroyed) return;
|
|
2910
|
+
|
|
2767
2911
|
if (this.clampSpeed)
|
|
2768
2912
|
{
|
|
2769
2913
|
// limit max speed to prevent missing collisions
|
|
@@ -2819,6 +2963,8 @@ class EngineObject
|
|
|
2819
2963
|
|
|
2820
2964
|
// notify objects of collision and check if should be resolved
|
|
2821
2965
|
const collide1 = this.collideWithObject(o);
|
|
2966
|
+
// callback may have destroyed us; stop resolving against more objects
|
|
2967
|
+
if (this.destroyed) return;
|
|
2822
2968
|
const collide2 = o.collideWithObject(this);
|
|
2823
2969
|
if (!collide1 || !collide2) continue;
|
|
2824
2970
|
|
|
@@ -2914,13 +3060,17 @@ class EngineObject
|
|
|
2914
3060
|
const restitution = max(this.restitution, hitLayer.restitution);
|
|
2915
3061
|
if (isBlockedX)
|
|
2916
3062
|
{
|
|
2917
|
-
// try to
|
|
3063
|
+
// try to step over a 1-tile bump (direction follows gravity sign
|
|
3064
|
+
// so inverted gravity steps down off a ceiling bump instead of up;
|
|
3065
|
+
// zero gravity defaults to the normal-gravity step-up direction)
|
|
2918
3066
|
const epsilon = 1e-3;
|
|
2919
|
-
const
|
|
2920
|
-
const
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
3067
|
+
const maxMove = .1;
|
|
3068
|
+
const gravitySign = gravity.y > 0 ? -1 : 1;
|
|
3069
|
+
const y = gravitySign > 0 ?
|
|
3070
|
+
floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon :
|
|
3071
|
+
ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon;
|
|
3072
|
+
const delta = abs(y - this.pos.y);
|
|
3073
|
+
if (delta < maxMove)
|
|
2924
3074
|
if (!tileCollisionTest(vec2(this.pos.x, y), this.size, this))
|
|
2925
3075
|
{
|
|
2926
3076
|
this.pos.y = y;
|
|
@@ -2972,6 +3122,9 @@ class EngineObject
|
|
|
2972
3122
|
drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor);
|
|
2973
3123
|
}
|
|
2974
3124
|
|
|
3125
|
+
/** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
|
|
3126
|
+
renderLight() {}
|
|
3127
|
+
|
|
2975
3128
|
/** Destroy this object, destroy its children, detach its parent, and mark it for removal
|
|
2976
3129
|
* @param {boolean} [immediate] - should attached effects be allowed to die off? */
|
|
2977
3130
|
destroy(immediate=false)
|
|
@@ -3060,6 +3213,8 @@ class EngineObject
|
|
|
3060
3213
|
* @return {EngineObject} The child object added */
|
|
3061
3214
|
addChild(child, localPos=vec2(), localAngle=0)
|
|
3062
3215
|
{
|
|
3216
|
+
ASSERT(!this.destroyed, 'cannot add child to destroyed object');
|
|
3217
|
+
if (this.destroyed) return child;
|
|
3063
3218
|
ASSERT(!child.parent && !this.children.includes(child));
|
|
3064
3219
|
ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
|
|
3065
3220
|
ASSERT(child !== this, 'cannot add self as child');
|
|
@@ -3076,10 +3231,7 @@ class EngineObject
|
|
|
3076
3231
|
removeChild(child)
|
|
3077
3232
|
{
|
|
3078
3233
|
ASSERT(child.parent === this && this.children.includes(child));
|
|
3079
|
-
|
|
3080
|
-
const index = this.children.indexOf(child);
|
|
3081
|
-
ASSERT(index >= 0, 'child not found in children array');
|
|
3082
|
-
index >= 0 && this.children.splice(index, 1);
|
|
3234
|
+
this.children.splice(this.children.indexOf(child), 1);
|
|
3083
3235
|
child.parent = undefined;
|
|
3084
3236
|
}
|
|
3085
3237
|
|
|
@@ -3154,7 +3306,7 @@ class EngineObject
|
|
|
3154
3306
|
* - Optimized tile sheet sprite rendering using WebGL batching
|
|
3155
3307
|
* - Primitive drawing for polygons, ellipses, and lines
|
|
3156
3308
|
* - Tile-based rendering with TileInfo and TextureInfo classes
|
|
3157
|
-
* - Text rendering with custom fonts and
|
|
3309
|
+
* - Text rendering with custom fonts and ImageFont support
|
|
3158
3310
|
* - Color and additive color blending for effects
|
|
3159
3311
|
* - Rotation, mirroring, and scaling transformations
|
|
3160
3312
|
* - Camera system with position, scale, and rotation
|
|
@@ -3250,7 +3402,7 @@ let primitiveCount;
|
|
|
3250
3402
|
* tile(1, 16, 3) // a tile at index 1 of size 16 on texture 3
|
|
3251
3403
|
* tile(vec2(4,8), vec2(30,10)) // a tile at index (4,8) with a size of (30,10)
|
|
3252
3404
|
* @memberof Draw */
|
|
3253
|
-
function tile(index=
|
|
3405
|
+
function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
|
|
3254
3406
|
{
|
|
3255
3407
|
ASSERT(isVector2(index) || typeof index === 'number', 'index must be a vec2 or number');
|
|
3256
3408
|
ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
|
|
@@ -3301,8 +3453,8 @@ class TileInfo
|
|
|
3301
3453
|
* @param {Vector2} [pos=vec2()] - Top left corner of tile in pixels
|
|
3302
3454
|
* @param {Vector2} [size] - Size of tile in pixels
|
|
3303
3455
|
* @param {TextureInfo} [textureInfo] - Texture info to use
|
|
3304
|
-
* @param {number} [padding] - How many pixels padding around
|
|
3305
|
-
* @param {number} [bleed] - How many pixels smaller to
|
|
3456
|
+
* @param {number} [padding] - How many pixels padding around all sides of each tile (increases grid size, does not affect tile size)
|
|
3457
|
+
* @param {number} [bleed] - How many pixels smaller to shrink UVS of tiles (does not affect grid size, only UVs)
|
|
3306
3458
|
*/
|
|
3307
3459
|
constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed)
|
|
3308
3460
|
{
|
|
@@ -3334,7 +3486,7 @@ class TileInfo
|
|
|
3334
3486
|
ASSERT(typeof frame === 'number');
|
|
3335
3487
|
const w = this.size.x + this.padding*2;
|
|
3336
3488
|
const x = frame*w;
|
|
3337
|
-
ASSERT(x
|
|
3489
|
+
ASSERT(x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
|
|
3338
3490
|
return this.offset(new Vector2(x));
|
|
3339
3491
|
}
|
|
3340
3492
|
|
|
@@ -3423,7 +3575,7 @@ class TextureInfo
|
|
|
3423
3575
|
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
|
|
3424
3576
|
* @memberof Draw */
|
|
3425
3577
|
function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
3426
|
-
angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace, context)
|
|
3578
|
+
angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=false, context)
|
|
3427
3579
|
{
|
|
3428
3580
|
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
3429
3581
|
ASSERT(isVector2(size), 'size must be a vec2');
|
|
@@ -3466,10 +3618,8 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
|
3466
3618
|
}
|
|
3467
3619
|
else
|
|
3468
3620
|
{
|
|
3469
|
-
// untextured:
|
|
3470
|
-
//
|
|
3471
|
-
// uvs/rgba zeroed). Color+additive are folded together to match
|
|
3472
|
-
// the Canvas2D path's color.add(additiveColor) on line ~337.
|
|
3621
|
+
// untextured: fold color+additive to match the Canvas2D path's
|
|
3622
|
+
// color.add(additiveColor) on line ~337.
|
|
3473
3623
|
const combined = additiveColor ? color.add(additiveColor) : color;
|
|
3474
3624
|
glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
|
|
3475
3625
|
}
|
|
@@ -3479,11 +3629,12 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
|
3479
3629
|
// normal canvas 2D rendering method (slower)
|
|
3480
3630
|
++drawCount;
|
|
3481
3631
|
++primitiveCount;
|
|
3482
|
-
size = new Vector2(size.x, -size.y); // flip upside down sprites
|
|
3483
3632
|
drawCanvas2D(pos, size, angle, mirror, (context)=>
|
|
3484
3633
|
{
|
|
3485
3634
|
if (textureInfo)
|
|
3486
3635
|
{
|
|
3636
|
+
// un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
|
|
3637
|
+
context.scale(1, -1);
|
|
3487
3638
|
// calculate uvs and render
|
|
3488
3639
|
const x = tileInfo.pos.x, y = tileInfo.pos.y;
|
|
3489
3640
|
const w = tileInfo.size.x, h = tileInfo.size.y;
|
|
@@ -3491,7 +3642,7 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
|
3491
3642
|
}
|
|
3492
3643
|
else
|
|
3493
3644
|
{
|
|
3494
|
-
// if no tile info, use untextured rect
|
|
3645
|
+
// if no tile info, use untextured rect (Y-symmetric, no compensation needed)
|
|
3495
3646
|
const c = additiveColor ? color.add(additiveColor) : color;
|
|
3496
3647
|
context.fillStyle = c.toString();
|
|
3497
3648
|
context.fillRect(-.5, -.5, 1, 1);
|
|
@@ -3565,11 +3716,10 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, an
|
|
|
3565
3716
|
// normal canvas 2D rendering method (slower)
|
|
3566
3717
|
++drawCount;
|
|
3567
3718
|
++primitiveCount;
|
|
3568
|
-
size = new Vector2(size.x, -size.y); // fix upside down sprites
|
|
3569
3719
|
drawCanvas2D(pos, size, angle, false, (context)=>
|
|
3570
3720
|
{
|
|
3571
|
-
//
|
|
3572
|
-
const gradient = context.createLinearGradient(0,
|
|
3721
|
+
// gradient endpoints are flipped to match the Y flip inside drawCanvas2D
|
|
3722
|
+
const gradient = context.createLinearGradient(0, .5, 0, -.5);
|
|
3573
3723
|
gradient.addColorStop(0, colorTop.toString());
|
|
3574
3724
|
gradient.addColorStop(1, colorBottom.toString());
|
|
3575
3725
|
context.fillStyle = gradient;
|
|
@@ -3682,7 +3832,7 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
|
|
|
3682
3832
|
* @param {boolean} [screenSpace]
|
|
3683
3833
|
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
|
|
3684
3834
|
* @memberof Draw */
|
|
3685
|
-
function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace, context)
|
|
3835
|
+
function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
|
|
3686
3836
|
{
|
|
3687
3837
|
ASSERT(isArray(points), 'points must be an array');
|
|
3688
3838
|
ASSERT(isNumber(width), 'width must be a number');
|
|
@@ -3731,7 +3881,7 @@ function drawLineList(points, width=.1, color, wrap=false, pos=vec2(), angle=0,
|
|
|
3731
3881
|
* @param {boolean} [screenSpace]
|
|
3732
3882
|
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
|
|
3733
3883
|
* @memberof Draw */
|
|
3734
|
-
function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, screenSpace, context)
|
|
3884
|
+
function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=false, context)
|
|
3735
3885
|
{
|
|
3736
3886
|
const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
|
|
3737
3887
|
const size = vec2(width, halfDelta.length()*2);
|
|
@@ -3747,9 +3897,9 @@ function drawLine(posA, posB, width=.1, color, pos=vec2(), angle=0, useWebGL, sc
|
|
|
3747
3897
|
* @param {Vector2} [size=vec2(1)]
|
|
3748
3898
|
* @param {number} [sides]
|
|
3749
3899
|
* @param {Color} [color=WHITE]
|
|
3750
|
-
* @param {number} [angle]
|
|
3751
3900
|
* @param {number} [lineWidth]
|
|
3752
3901
|
* @param {Color} [lineColor=BLACK]
|
|
3902
|
+
* @param {number} [angle]
|
|
3753
3903
|
* @param {boolean} [useWebGL=glEnable]
|
|
3754
3904
|
* @param {boolean} [screenSpace]
|
|
3755
3905
|
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
|
|
@@ -3842,7 +3992,7 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
|
|
|
3842
3992
|
ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
|
|
3843
3993
|
|
|
3844
3994
|
// clamp line width to prevent artifacts
|
|
3845
|
-
lineWidth = clamp(lineWidth, 0,
|
|
3995
|
+
lineWidth = clamp(lineWidth, 0, min(size.x, size.y));
|
|
3846
3996
|
|
|
3847
3997
|
if (useWebGL && glEnable)
|
|
3848
3998
|
{
|
|
@@ -3884,24 +4034,26 @@ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useW
|
|
|
3884
4034
|
drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
|
|
3885
4035
|
}
|
|
3886
4036
|
|
|
3887
|
-
/** Draw
|
|
4037
|
+
/** Draw an ellipse filled with a radial gradient from the center to the rim
|
|
3888
4038
|
* - Best when batched with other untextured polys
|
|
3889
4039
|
* - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
|
|
3890
4040
|
* - Stacking gradients at the exact same position may show a faint vertical artifact
|
|
3891
4041
|
* @param {Vector2} pos
|
|
3892
|
-
* @param {
|
|
4042
|
+
* @param {Vector2} [size=vec2(1)] - Width and height diameter
|
|
3893
4043
|
* @param {Color} [colorInner=WHITE]
|
|
3894
4044
|
* @param {Color} [colorOuter=CLEAR_WHITE]
|
|
4045
|
+
* @param {number} [angle]
|
|
3895
4046
|
* @param {boolean} [useWebGL=glEnable]
|
|
3896
4047
|
* @param {boolean} [screenSpace]
|
|
3897
4048
|
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
|
|
3898
4049
|
* @memberof Draw */
|
|
3899
|
-
let
|
|
3900
|
-
function
|
|
4050
|
+
let drawEllipseGradientOffset = 0;
|
|
4051
|
+
function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=false, context)
|
|
3901
4052
|
{
|
|
3902
4053
|
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
3903
|
-
ASSERT(
|
|
4054
|
+
ASSERT(isVector2(size), 'size must be a vec2');
|
|
3904
4055
|
ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid');
|
|
4056
|
+
ASSERT(isNumber(angle), 'angle must be a number');
|
|
3905
4057
|
ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
|
|
3906
4058
|
|
|
3907
4059
|
if (headlessMode) return;
|
|
@@ -3913,26 +4065,33 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
|
|
|
3913
4065
|
{
|
|
3914
4066
|
// convert to world space
|
|
3915
4067
|
pos = screenToWorld(pos);
|
|
3916
|
-
size
|
|
4068
|
+
size = size.scale(1/cameraScale);
|
|
4069
|
+
angle += cameraAngle;
|
|
3917
4070
|
}
|
|
3918
4071
|
// fan as tristrip; rotate the boundary vertex by one slice per call
|
|
3919
4072
|
// so back-to-back gradients at the same position have their hole
|
|
3920
4073
|
// (from gpu edge-rule on the boundary line-degen) at different rim
|
|
3921
4074
|
// verts and don't visibly stack
|
|
3922
4075
|
const sides = glCircleSides;
|
|
3923
|
-
const
|
|
4076
|
+
const radiusX = size.x/2, radiusY = size.y/2;
|
|
3924
4077
|
const innerInt = colorInner.rgbaInt();
|
|
3925
4078
|
const outerInt = colorOuter.rgbaInt();
|
|
3926
|
-
const offset =
|
|
4079
|
+
const offset = drawEllipseGradientOffset++;
|
|
4080
|
+
const c = cos(-angle), s = sin(-angle);
|
|
4081
|
+
const rim = (a) =>
|
|
4082
|
+
{
|
|
4083
|
+
const lx = sin(a)*radiusX, ly = cos(a)*radiusY;
|
|
4084
|
+
return vec2(pos.x + lx*c - ly*s, pos.y + lx*s + ly*c);
|
|
4085
|
+
};
|
|
3927
4086
|
const startA = (offset%sides)/sides*PI*2;
|
|
3928
|
-
const points = [
|
|
4087
|
+
const points = [rim(startA)];
|
|
3929
4088
|
const colors = [outerInt];
|
|
3930
4089
|
for (let i=sides; i--;)
|
|
3931
4090
|
{
|
|
3932
4091
|
const a = ((i+offset)%sides)/sides*PI*2;
|
|
3933
4092
|
points.push(pos);
|
|
3934
4093
|
colors.push(innerInt);
|
|
3935
|
-
points.push(
|
|
4094
|
+
points.push(rim(a));
|
|
3936
4095
|
colors.push(outerInt);
|
|
3937
4096
|
}
|
|
3938
4097
|
glDrawColoredPoints(points, colors);
|
|
@@ -3942,7 +4101,7 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
|
|
|
3942
4101
|
// normal canvas 2D rendering method (slower)
|
|
3943
4102
|
++drawCount;
|
|
3944
4103
|
++primitiveCount;
|
|
3945
|
-
drawCanvas2D(pos,
|
|
4104
|
+
drawCanvas2D(pos, size, angle, false, (context)=>
|
|
3946
4105
|
{
|
|
3947
4106
|
const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5);
|
|
3948
4107
|
gradient.addColorStop(0, colorInner.toString());
|
|
@@ -3955,13 +4114,34 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
|
|
|
3955
4114
|
}
|
|
3956
4115
|
}
|
|
3957
4116
|
|
|
4117
|
+
/** Draw a circle filled with a radial gradient from the center to the rim
|
|
4118
|
+
* - Best when batched with other untextured polys
|
|
4119
|
+
* - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
|
|
4120
|
+
* - Stacking gradients at the exact same position may show a faint vertical artifact
|
|
4121
|
+
* @param {Vector2} pos
|
|
4122
|
+
* @param {number} [size=1] - Diameter
|
|
4123
|
+
* @param {Color} [colorInner=WHITE]
|
|
4124
|
+
* @param {Color} [colorOuter=CLEAR_WHITE]
|
|
4125
|
+
* @param {boolean} [useWebGL=glEnable]
|
|
4126
|
+
* @param {boolean} [screenSpace]
|
|
4127
|
+
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
|
|
4128
|
+
* @memberof Draw */
|
|
4129
|
+
function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=false, context)
|
|
4130
|
+
{
|
|
4131
|
+
ASSERT(isNumber(size), 'size must be a number');
|
|
4132
|
+
drawEllipseGradient(pos, vec2(size), colorInner, colorOuter, 0, useWebGL, screenSpace, context);
|
|
4133
|
+
}
|
|
4134
|
+
|
|
3958
4135
|
/**
|
|
3959
4136
|
* @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context
|
|
3960
4137
|
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
|
|
3961
4138
|
* @memberof Draw
|
|
3962
4139
|
*/
|
|
3963
4140
|
|
|
3964
|
-
/** Draw directly to a 2d canvas context in world space
|
|
4141
|
+
/** Draw directly to a 2d canvas context in world space.
|
|
4142
|
+
* The Y axis is flipped so world-Y-up coordinates render right-side up
|
|
4143
|
+
* (matches the WebGL path). Callers whose drawing depends on Y direction
|
|
4144
|
+
* (e.g. linear gradients) should flip their own Y endpoints accordingly.
|
|
3965
4145
|
* @param {Vector2} pos
|
|
3966
4146
|
* @param {Vector2} size
|
|
3967
4147
|
* @param {number} angle
|
|
@@ -4009,7 +4189,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
|
|
|
4009
4189
|
* @param {number} [angle]
|
|
4010
4190
|
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
|
|
4011
4191
|
* @memberof Draw */
|
|
4012
|
-
function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, font, fontStyle, maxWidth, angle=0, context=drawContext)
|
|
4192
|
+
function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
|
|
4013
4193
|
{
|
|
4014
4194
|
// convert to screen space
|
|
4015
4195
|
pos = worldToScreen(pos);
|
|
@@ -4049,16 +4229,16 @@ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLA
|
|
|
4049
4229
|
ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
|
|
4050
4230
|
ASSERT(isNumber(angle), 'angle must be a number');
|
|
4051
4231
|
|
|
4232
|
+
const lines = (text+'').split('\n');
|
|
4233
|
+
const posY = pos.y - (lines.length-1) * size/2; // center vertically
|
|
4234
|
+
// save before style mutations so caller's context state is preserved
|
|
4235
|
+
context.save();
|
|
4052
4236
|
context.fillStyle = color.toString();
|
|
4053
4237
|
context.strokeStyle = lineColor.toString();
|
|
4054
4238
|
context.lineWidth = lineWidth;
|
|
4055
4239
|
context.textAlign = textAlign;
|
|
4056
4240
|
context.font = fontStyle + ' ' + size + 'px '+ font;
|
|
4057
4241
|
context.textBaseline = 'middle';
|
|
4058
|
-
|
|
4059
|
-
const lines = (text+'').split('\n');
|
|
4060
|
-
const posY = pos.y - (lines.length-1) * size/2; // center vertically
|
|
4061
|
-
context.save();
|
|
4062
4242
|
context.translate(pos.x, posY);
|
|
4063
4243
|
context.rotate(-angle);
|
|
4064
4244
|
let yOffset = 0;
|
|
@@ -4219,6 +4399,9 @@ function isOnScreen(pos, size=0)
|
|
|
4219
4399
|
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
4220
4400
|
ASSERT(isVector2(size) || isNumber(size), 'size must be a vec2 or number');
|
|
4221
4401
|
|
|
4402
|
+
// cameraScale of 0 collapses world coords; nothing is visible
|
|
4403
|
+
if (!cameraScale) return false;
|
|
4404
|
+
|
|
4222
4405
|
// optimized circle on screen test
|
|
4223
4406
|
// pos = worldToScreen(pos);
|
|
4224
4407
|
let x = pos.x - cameraPos.x;
|
|
@@ -4260,7 +4443,10 @@ function combineCanvases()
|
|
|
4260
4443
|
const w = mainCanvasSize.x, h = mainCanvasSize.y;
|
|
4261
4444
|
workCanvas.width = w;
|
|
4262
4445
|
workCanvas.height = h;
|
|
4263
|
-
|
|
4446
|
+
// remove background alpha — explicit fillStyle so a previous caller
|
|
4447
|
+
// leaving workContext.fillStyle transparent can't silently no-op this
|
|
4448
|
+
workContext.fillStyle = '#000';
|
|
4449
|
+
workContext.fillRect(0,0,w,h);
|
|
4264
4450
|
glCopyToContext(workContext);
|
|
4265
4451
|
workContext.drawImage(mainCanvas, 0, 0);
|
|
4266
4452
|
mainContext.drawImage(workCanvas, 0, 0);
|
|
@@ -4403,33 +4589,33 @@ function setCursor(cursorStyle = 'auto')
|
|
|
4403
4589
|
///////////////////////////////////////////////////////////////////////////////
|
|
4404
4590
|
|
|
4405
4591
|
/** Engine font image, 8x8 font provided by the engine
|
|
4406
|
-
* @type {
|
|
4592
|
+
* @type {ImageFont}
|
|
4407
4593
|
* @memberof Draw */
|
|
4408
|
-
let
|
|
4594
|
+
let engineImageFont;
|
|
4409
4595
|
|
|
4410
4596
|
/**
|
|
4411
|
-
* Font
|
|
4597
|
+
* Image Font Object - Draw text by using tiles in an image
|
|
4412
4598
|
* - 96 characters (from space to tilde) are stored in an image
|
|
4413
4599
|
* - A 8x8 default engine font is supplied for general use
|
|
4414
4600
|
* - This system is WebGL enabled for fast text rendering
|
|
4415
4601
|
* - Fonts can also be colored and scaled along each axis
|
|
4416
|
-
*
|
|
4602
|
+
*
|
|
4417
4603
|
* @memberof Draw
|
|
4418
4604
|
* @example
|
|
4419
4605
|
* // use built in font
|
|
4420
|
-
* const font =
|
|
4606
|
+
* const font = engineImageFont;
|
|
4421
4607
|
*
|
|
4422
4608
|
* // draw text
|
|
4423
4609
|
* font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50));
|
|
4424
4610
|
*/
|
|
4425
|
-
class
|
|
4611
|
+
class ImageFont
|
|
4426
4612
|
{
|
|
4427
4613
|
/** Create an image font
|
|
4428
4614
|
* @param {TileInfo} tileInfo - Tile info of first character in font
|
|
4429
4615
|
*/
|
|
4430
4616
|
constructor(tileInfo)
|
|
4431
4617
|
{
|
|
4432
|
-
ASSERT(!!tileInfo, 'tileInfo is required for
|
|
4618
|
+
ASSERT(!!tileInfo, 'tileInfo is required for ImageFont');
|
|
4433
4619
|
|
|
4434
4620
|
/** @property {TileInfo} - Tile info for the font */
|
|
4435
4621
|
this.tileInfo = tileInfo.frame(0);
|
|
@@ -4514,7 +4700,7 @@ class FontImage
|
|
|
4514
4700
|
}
|
|
4515
4701
|
|
|
4516
4702
|
// load engine font, called automatically on startup
|
|
4517
|
-
async function
|
|
4703
|
+
async function imageFontInit()
|
|
4518
4704
|
{
|
|
4519
4705
|
const image = new Image;
|
|
4520
4706
|
await new Promise(resolve =>
|
|
@@ -4527,7 +4713,7 @@ async function fontImageInit()
|
|
|
4527
4713
|
const tilePos=vec2(), tileSize=vec2(8), padding=1, bleed=0;
|
|
4528
4714
|
const textureInfo = new TextureInfo(image);
|
|
4529
4715
|
const tileInfo = new TileInfo(tilePos, tileSize, textureInfo, padding, bleed);
|
|
4530
|
-
|
|
4716
|
+
engineImageFont = new ImageFont(tileInfo);
|
|
4531
4717
|
}
|
|
4532
4718
|
/**
|
|
4533
4719
|
* LittleJS Input System
|
|
@@ -4619,6 +4805,7 @@ function inputClear()
|
|
|
4619
4805
|
inputData[0] = [];
|
|
4620
4806
|
touchGamepadButtons.length = 0;
|
|
4621
4807
|
touchGamepadSticks.length = 0;
|
|
4808
|
+
touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor
|
|
4622
4809
|
gamepadStickData.length = 0;
|
|
4623
4810
|
gamepadDpadData.length = 0;
|
|
4624
4811
|
}
|
|
@@ -4861,6 +5048,14 @@ const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
|
|
|
4861
5048
|
|
|
4862
5049
|
// touch gamepad internal variables
|
|
4863
5050
|
const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
|
|
5051
|
+
// floating stick anchors (stage-local CSS pixels) and owning pointer ids, indexed by stick (0=left, 1=right)
|
|
5052
|
+
const touchGamepadStickAnchors = [], touchGamepadStickPointerId = [];
|
|
5053
|
+
// pointerId -> control role ('stick0', 'stick1', 'face<n>', or 'start')
|
|
5054
|
+
const touchGamepadPointerRole = new Map();
|
|
5055
|
+
// overlay DOM elements (created lazily on touch devices) and cached SVG shapes
|
|
5056
|
+
let touchGamepadOverlay, touchGamepadStage, touchGamepadSvg, touchGamepadSvgEls;
|
|
5057
|
+
let touchGamepadSideZones = [], touchGamepadZoneC;
|
|
5058
|
+
let touchGamepadNeedRelayout = true, touchGamepadLastLayout;
|
|
4864
5059
|
|
|
4865
5060
|
///////////////////////////////////////////////////////////////////////////////
|
|
4866
5061
|
// Input system functions used by engine
|
|
@@ -4976,14 +5171,24 @@ function inputInit()
|
|
|
4976
5171
|
mouseDeltaScreen = mouseDeltaScreen.add(movement);
|
|
4977
5172
|
}
|
|
4978
5173
|
function onMouseLeave() { mouseInWindow = false; } // mouse moved off window
|
|
4979
|
-
function onMouseWheel(e)
|
|
4980
|
-
{
|
|
4981
|
-
|
|
5174
|
+
function onMouseWheel(e)
|
|
5175
|
+
{
|
|
5176
|
+
// accumulate so multiple wheel events in one frame are not lost
|
|
5177
|
+
if (!e.ctrlKey)
|
|
5178
|
+
mouseWheel += sign(e.deltaY);
|
|
4982
5179
|
if (inputPreventDefault && e.cancelable && document.hasFocus())
|
|
4983
5180
|
e.preventDefault(); // prevent page scrolling
|
|
4984
5181
|
}
|
|
4985
5182
|
function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
|
|
4986
|
-
function onBlur()
|
|
5183
|
+
function onBlur()
|
|
5184
|
+
{
|
|
5185
|
+
inputClear();
|
|
5186
|
+
// release any held virtual gamepad controls so they don't stick
|
|
5187
|
+
touchGamepadPointerRole.clear();
|
|
5188
|
+
touchGamepadButtons.length = 0;
|
|
5189
|
+
touchGamepadSticks.length = 0;
|
|
5190
|
+
touchGamepadStickPointerId.length = 0;
|
|
5191
|
+
}
|
|
4987
5192
|
|
|
4988
5193
|
// enable touch input mouse passthrough
|
|
4989
5194
|
function touchInputInit()
|
|
@@ -4999,36 +5204,46 @@ function inputInit()
|
|
|
4999
5204
|
{
|
|
5000
5205
|
if (!touchInputEnable) return;
|
|
5001
5206
|
|
|
5002
|
-
// route touch to gamepad
|
|
5003
|
-
if (touchGamepadEnable)
|
|
5004
|
-
handleTouchGamepad(e);
|
|
5005
|
-
|
|
5006
5207
|
// fix stalled audio requiring user interaction
|
|
5007
5208
|
if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
|
|
5008
5209
|
audioContext.resume();
|
|
5009
5210
|
|
|
5010
|
-
//
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
if (
|
|
5211
|
+
// when the touch gamepad is enabled it owns touch input: suppress the
|
|
5212
|
+
// touch->mouse passthrough entirely unless touchGamepadPassthrough is set
|
|
5213
|
+
// (its own zones drive gameplay via pointer events)
|
|
5214
|
+
if (!touchGamepadEnable || touchGamepadPassthrough)
|
|
5014
5215
|
{
|
|
5015
|
-
//
|
|
5016
|
-
|
|
5017
|
-
const
|
|
5018
|
-
|
|
5019
|
-
|
|
5216
|
+
// touches that landed on a virtual gamepad zone are owned by the gamepad
|
|
5217
|
+
// (handled by its own pointer listeners) and must not drive the game mouse
|
|
5218
|
+
const isGamepadTouch = (t)=>
|
|
5219
|
+
touchGamepadSideZones.includes(t.target) || t.target === touchGamepadZoneC;
|
|
5220
|
+
const gameTouches = [];
|
|
5221
|
+
for (const t of e.touches)
|
|
5222
|
+
if (!isGamepadTouch(t)) gameTouches.push(t);
|
|
5223
|
+
|
|
5224
|
+
// check if touching and pass to mouse events
|
|
5225
|
+
const touching = gameTouches.length;
|
|
5226
|
+
const button = 0; // all touches are left mouse button
|
|
5227
|
+
if (touching)
|
|
5020
5228
|
{
|
|
5021
|
-
|
|
5022
|
-
|
|
5229
|
+
// set event pos and pass it along
|
|
5230
|
+
const pos = vec2(gameTouches[0].clientX, gameTouches[0].clientY);
|
|
5231
|
+
const mousePosScreenLast = mousePosScreen;
|
|
5232
|
+
mousePosScreen = mouseEventToScreen(pos);
|
|
5233
|
+
if (wasTouching)
|
|
5234
|
+
mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
|
|
5235
|
+
else
|
|
5236
|
+
{
|
|
5237
|
+
inputData[0][button] = 3;
|
|
5238
|
+
isUsingGamepad = false; // a passthrough tap is mouse-style input
|
|
5239
|
+
}
|
|
5023
5240
|
}
|
|
5024
|
-
else
|
|
5025
|
-
inputData[0][button] =
|
|
5026
|
-
}
|
|
5027
|
-
else if (wasTouching)
|
|
5028
|
-
inputData[0][button] = inputData[0][button] & 2 | 4;
|
|
5241
|
+
else if (wasTouching)
|
|
5242
|
+
inputData[0][button] = inputData[0][button] & 2 | 4;
|
|
5029
5243
|
|
|
5030
|
-
|
|
5031
|
-
|
|
5244
|
+
// set was touching
|
|
5245
|
+
wasTouching = touching;
|
|
5246
|
+
}
|
|
5032
5247
|
|
|
5033
5248
|
// prevent default handling like copy, magnifier lens, and scrolling
|
|
5034
5249
|
if (inputPreventDefault && e.cancelable && document.hasFocus())
|
|
@@ -5038,78 +5253,6 @@ function inputInit()
|
|
|
5038
5253
|
return true;
|
|
5039
5254
|
}
|
|
5040
5255
|
|
|
5041
|
-
// special handling for virtual gamepad mode
|
|
5042
|
-
function handleTouchGamepad(e)
|
|
5043
|
-
{
|
|
5044
|
-
// clear touch gamepad input
|
|
5045
|
-
touchGamepadSticks.length = 0;
|
|
5046
|
-
touchGamepadSticks[0] = vec2();
|
|
5047
|
-
touchGamepadSticks[1] = vec2();
|
|
5048
|
-
touchGamepadButtons.length = 0;
|
|
5049
|
-
isUsingGamepad = true;
|
|
5050
|
-
|
|
5051
|
-
const touching = e.touches.length;
|
|
5052
|
-
if (touching)
|
|
5053
|
-
{
|
|
5054
|
-
touchGamepadTimer.set();
|
|
5055
|
-
if (touchGamepadCenterButtonSize && !wasTouching && paused)
|
|
5056
|
-
{
|
|
5057
|
-
// touch anywhere to press start when paused
|
|
5058
|
-
touchGamepadButtons[9] = 1;
|
|
5059
|
-
return;
|
|
5060
|
-
}
|
|
5061
|
-
}
|
|
5062
|
-
|
|
5063
|
-
// don't process touch gamepad if paused
|
|
5064
|
-
if (paused) return;
|
|
5065
|
-
|
|
5066
|
-
// get center of left and right sides
|
|
5067
|
-
const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
|
|
5068
|
-
const buttonCenter = touchGamepadButtonCenter();
|
|
5069
|
-
const startCenter = mainCanvasSize.scale(.5);
|
|
5070
|
-
|
|
5071
|
-
// check each touch point
|
|
5072
|
-
for (const touch of e.touches)
|
|
5073
|
-
{
|
|
5074
|
-
const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
|
|
5075
|
-
if (stickCenter.distance(touchPos) < touchGamepadSize)
|
|
5076
|
-
{
|
|
5077
|
-
// virtual analog stick
|
|
5078
|
-
const delta = touchPos.subtract(stickCenter);
|
|
5079
|
-
touchGamepadSticks[0] = delta.scale(2/touchGamepadSize).clampLength();
|
|
5080
|
-
touchGamepadButtons[10] = 1; // also press a button when touching stick
|
|
5081
|
-
}
|
|
5082
|
-
else if (buttonCenter.distance(touchPos) < touchGamepadSize)
|
|
5083
|
-
{
|
|
5084
|
-
if (touchGamepadButtonCount === 1)
|
|
5085
|
-
{
|
|
5086
|
-
// virtual right analog stick
|
|
5087
|
-
const delta = touchPos.subtract(buttonCenter);
|
|
5088
|
-
touchGamepadSticks[1] = delta.scale(2/touchGamepadSize).clampLength();
|
|
5089
|
-
touchGamepadButtons[11] = 1; // also press a button when touching right stick
|
|
5090
|
-
}
|
|
5091
|
-
// virtual face buttons
|
|
5092
|
-
let button = buttonCenter.subtract(touchPos).direction();
|
|
5093
|
-
button = mod(button+2, 4);
|
|
5094
|
-
if (touchGamepadButtonCount === 1)
|
|
5095
|
-
button = 0;
|
|
5096
|
-
else if (touchGamepadButtonCount === 2)
|
|
5097
|
-
{
|
|
5098
|
-
const delta = buttonCenter.subtract(touchPos);
|
|
5099
|
-
button = -delta.x < delta.y ? 1 : 0;
|
|
5100
|
-
}
|
|
5101
|
-
// fix button locations (swap 2 and 3 to match gamepad layout)
|
|
5102
|
-
button = button === 3 ? 2 : button === 2 ? 3 : button;
|
|
5103
|
-
if (button < touchGamepadButtonCount)
|
|
5104
|
-
touchGamepadButtons[button] = 1;
|
|
5105
|
-
}
|
|
5106
|
-
else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize)
|
|
5107
|
-
{
|
|
5108
|
-
// virtual start button in center
|
|
5109
|
-
touchGamepadButtons[9] = 1;
|
|
5110
|
-
}
|
|
5111
|
-
}
|
|
5112
|
-
}
|
|
5113
5256
|
}
|
|
5114
5257
|
|
|
5115
5258
|
// convert a mouse or touch event position to screen space
|
|
@@ -5134,6 +5277,9 @@ function inputUpdate()
|
|
|
5134
5277
|
mousePos = screenToWorld(mousePosScreen);
|
|
5135
5278
|
mouseDelta = screenToWorldDelta(mouseDeltaScreen);
|
|
5136
5279
|
|
|
5280
|
+
// build the touch gamepad overlay lazily once enabled on a touch device
|
|
5281
|
+
touchGamepadInit();
|
|
5282
|
+
|
|
5137
5283
|
// update gamepads if enabled
|
|
5138
5284
|
gamepadsUpdate();
|
|
5139
5285
|
|
|
@@ -5147,22 +5293,16 @@ function inputUpdate()
|
|
|
5147
5293
|
v > min ? percent(v, min, max) :
|
|
5148
5294
|
v < -min ? -percent(-v, min, max) : 0;
|
|
5149
5295
|
return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
|
|
5150
|
-
}
|
|
5296
|
+
};
|
|
5151
5297
|
|
|
5152
5298
|
// update touch gamepad if enabled
|
|
5153
5299
|
if (touchGamepadEnable && isTouchDevice)
|
|
5154
5300
|
{
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
debugCircle(stickCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
|
|
5162
|
-
debugCircle(buttonCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
|
|
5163
|
-
if (touchGamepadCenterButtonSize)
|
|
5164
|
-
debugCircle(startCenter, 2*touchGamepadCenterButtonSize, 'cyan', 0, false, true);
|
|
5165
|
-
}
|
|
5301
|
+
// a side is either a stick or buttons - setting both is ambiguous
|
|
5302
|
+
ASSERT(!touchGamepadLeftStick || !touchGamepadLeftButtonCount,
|
|
5303
|
+
'set touchGamepadLeftStick or touchGamepadLeftButtonCount, not both');
|
|
5304
|
+
ASSERT(!touchGamepadRightStick || !touchGamepadButtonCount,
|
|
5305
|
+
'set touchGamepadRightStick or touchGamepadButtonCount, not both');
|
|
5166
5306
|
|
|
5167
5307
|
if (!touchGamepadTimer.isSet()) return;
|
|
5168
5308
|
|
|
@@ -5170,23 +5310,24 @@ function inputUpdate()
|
|
|
5170
5310
|
gamepadPrimary = 0; // touch gamepad uses index 0
|
|
5171
5311
|
const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
|
|
5172
5312
|
const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
|
|
5173
|
-
sticks
|
|
5313
|
+
sticks.length = 0; // only report sticks that are enabled
|
|
5174
5314
|
dpad.set();
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
sticks[0] = applyDeadZones(leftTouchStick);
|
|
5178
|
-
else if (leftTouchStick.lengthSquared() > .3)
|
|
5315
|
+
// read each side's directional stick (analog, or quantized to an 8 way dpad)
|
|
5316
|
+
for (let side = 0; side < 2; side++)
|
|
5179
5317
|
{
|
|
5180
|
-
|
|
5181
|
-
const
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5318
|
+
if (!touchGamepadSideStick(side)) continue;
|
|
5319
|
+
const out = touchGamepadStickOut(side);
|
|
5320
|
+
sticks[out] = vec2();
|
|
5321
|
+
const touchStick = touchGamepadSticks[side] ?? vec2();
|
|
5322
|
+
if (touchGamepadAnalog)
|
|
5323
|
+
sticks[out] = applyDeadZones(touchStick);
|
|
5324
|
+
else if (touchStick.lengthSquared() > .3)
|
|
5325
|
+
{
|
|
5326
|
+
const x = clamp(round(touchStick.x), -1, 1);
|
|
5327
|
+
const y = clamp(round(touchStick.y), -1, 1);
|
|
5328
|
+
sticks[out] = vec2(x, -y).clampLength(); // clamp to circle
|
|
5329
|
+
if (!out) dpad.set(x, -y); // the primary (stick 0) also drives the dpad vector
|
|
5330
|
+
}
|
|
5190
5331
|
}
|
|
5191
5332
|
|
|
5192
5333
|
// read virtual gamepad buttons
|
|
@@ -5195,6 +5336,12 @@ function inputUpdate()
|
|
|
5195
5336
|
{
|
|
5196
5337
|
const wasDown = gamepadIsDown(i,0);
|
|
5197
5338
|
data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
|
|
5339
|
+
|
|
5340
|
+
// haptic tap when a face button or start button is first pressed (3 = newly down)
|
|
5341
|
+
// skip stick touches (10, 11) so movement doesn't buzz
|
|
5342
|
+
if (touchGamepadVibration && data[i] === 3 &&
|
|
5343
|
+
(i === 9 || touchGamepadIsFaceButton(i)))
|
|
5344
|
+
vibrate(touchGamepadVibration);
|
|
5198
5345
|
}
|
|
5199
5346
|
|
|
5200
5347
|
// disable normal gamepads when touch gamepad is active
|
|
@@ -5268,13 +5415,6 @@ function inputUpdate()
|
|
|
5268
5415
|
(gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
|
|
5269
5416
|
(gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
|
|
5270
5417
|
}
|
|
5271
|
-
else if (gamepad.axes && gamepad.axes.length >= 2)
|
|
5272
|
-
{
|
|
5273
|
-
// digital style dpad from axes
|
|
5274
|
-
const x = clamp(round(gamepad.axes[0]), -1, 1);
|
|
5275
|
-
const y = clamp(round(gamepad.axes[1]), -1, 1);
|
|
5276
|
-
dpad.set(x, -y);
|
|
5277
|
-
}
|
|
5278
5418
|
|
|
5279
5419
|
// copy dpad to left analog stick when pressed
|
|
5280
5420
|
if (gamepadDirectionEmulateStick && (dpad.x || dpad.y))
|
|
@@ -5302,80 +5442,470 @@ function inputUpdatePost()
|
|
|
5302
5442
|
function inputRender()
|
|
5303
5443
|
{
|
|
5304
5444
|
touchGamepadRender();
|
|
5445
|
+
}
|
|
5305
5446
|
|
|
5306
|
-
|
|
5447
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5448
|
+
// Touch gamepad - full-viewport HTML/SVG overlay driven by Pointer Events
|
|
5449
|
+
|
|
5450
|
+
const touchGamepadSvgNS = 'http://www.w3.org/2000/svg';
|
|
5451
|
+
|
|
5452
|
+
// build the overlay DOM once; no-op if already built, disabled, headless, or non-touch
|
|
5453
|
+
function touchGamepadInit()
|
|
5454
|
+
{
|
|
5455
|
+
if (touchGamepadOverlay || !touchGamepadEnable || !isTouchDevice || headlessMode ||
|
|
5456
|
+
!document.body) // body may not exist yet; retry on a later frame
|
|
5457
|
+
return;
|
|
5458
|
+
|
|
5459
|
+
// full-viewport overlay; only the input zones receive pointer events. The
|
|
5460
|
+
// env() padding insets the stage out of notches / home indicators natively.
|
|
5461
|
+
const overlay = touchGamepadOverlay = document.createElement('div');
|
|
5462
|
+
overlay.style.cssText =
|
|
5463
|
+
'position:fixed;inset:0;z-index:50;pointer-events:none;opacity:0;' +
|
|
5464
|
+
'touch-action:none;user-select:none;-webkit-user-select:none;' +
|
|
5465
|
+
'-webkit-touch-callout:none;transition:opacity .2s;box-sizing:border-box;' +
|
|
5466
|
+
'padding:env(safe-area-inset-top) env(safe-area-inset-right) ' +
|
|
5467
|
+
'env(safe-area-inset-bottom) env(safe-area-inset-left)';
|
|
5468
|
+
|
|
5469
|
+
// stage fills the padded (safe-area) content box; all controls live inside it
|
|
5470
|
+
const stage = touchGamepadStage = document.createElement('div');
|
|
5471
|
+
stage.style.cssText = 'position:relative;width:100%;height:100%;pointer-events:none';
|
|
5472
|
+
overlay.appendChild(stage);
|
|
5473
|
+
|
|
5474
|
+
// svg draws every visual and never blocks input
|
|
5475
|
+
const svg = touchGamepadSvg = document.createElementNS(touchGamepadSvgNS, 'svg');
|
|
5476
|
+
svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;' +
|
|
5477
|
+
'pointer-events:none;overflow:visible;fill:none;stroke:#fff;stroke-width:3';
|
|
5478
|
+
stage.appendChild(svg);
|
|
5479
|
+
|
|
5480
|
+
// invisible input zones (left stick, right buttons/stick, center start)
|
|
5481
|
+
const makeZone = ()=>
|
|
5482
|
+
{
|
|
5483
|
+
const z = document.createElement('div');
|
|
5484
|
+
z.style.cssText = 'position:absolute;pointer-events:auto;touch-action:none';
|
|
5485
|
+
z.addEventListener('pointerdown', e=> touchGamepadPointerDown(e, z));
|
|
5486
|
+
z.addEventListener('pointermove', e=> touchGamepadPointerMove(e));
|
|
5487
|
+
z.addEventListener('pointerup', e=> touchGamepadPointerUp(e));
|
|
5488
|
+
z.addEventListener('pointercancel', e=> touchGamepadPointerUp(e));
|
|
5489
|
+
stage.appendChild(z);
|
|
5490
|
+
return z;
|
|
5491
|
+
};
|
|
5492
|
+
touchGamepadSideZones[0] = makeZone(); // left
|
|
5493
|
+
touchGamepadSideZones[1] = makeZone(); // right
|
|
5494
|
+
touchGamepadZoneC = makeZone(); // center/start, appended last so it sits above the sides
|
|
5495
|
+
|
|
5496
|
+
addEventListener('resize', ()=> touchGamepadNeedRelayout = true);
|
|
5497
|
+
document.body.appendChild(overlay);
|
|
5498
|
+
touchGamepadNeedRelayout = true;
|
|
5499
|
+
}
|
|
5500
|
+
|
|
5501
|
+
// stage-local size in CSS pixels (excludes safe-area insets)
|
|
5502
|
+
function touchGamepadStageRect() { return touchGamepadStage.getBoundingClientRect(); }
|
|
5503
|
+
|
|
5504
|
+
// per-side touch gamepad config (side 0 = left, 1 = right) - the left and right
|
|
5505
|
+
// sides behave identically, differing only in position and gamepad button indices
|
|
5506
|
+
function touchGamepadSideStick(side)
|
|
5507
|
+
{ return side ? touchGamepadRightStick : touchGamepadLeftStick; }
|
|
5508
|
+
function touchGamepadSideButtonCount(side)
|
|
5509
|
+
{ return side ? touchGamepadButtonCount : touchGamepadLeftButtonCount; }
|
|
5510
|
+
// gamepad button index a side's buttons start at (right 0-3, left 4-7)
|
|
5511
|
+
function touchGamepadSideButtonBase(side)
|
|
5512
|
+
{ return side ? 0 : 4; }
|
|
5513
|
+
// output stick index for a side: the right stick uses stick 0 when there is no left stick
|
|
5514
|
+
function touchGamepadStickOut(side)
|
|
5515
|
+
{ return side && touchGamepadLeftStick ? 1 : 0; }
|
|
5516
|
+
// true if the side has any control (a stick or at least one button)
|
|
5517
|
+
function touchGamepadSideHasControl(side)
|
|
5518
|
+
{ return touchGamepadSideStick(side) || touchGamepadSideButtonCount(side) > 0; }
|
|
5519
|
+
|
|
5520
|
+
// true if gamepad button index i is an active touch gamepad face/single button
|
|
5521
|
+
function touchGamepadIsFaceButton(i)
|
|
5522
|
+
{
|
|
5523
|
+
for (let side = 0; side < 2; side++)
|
|
5524
|
+
{
|
|
5525
|
+
const base = touchGamepadSideButtonBase(side);
|
|
5526
|
+
if (!touchGamepadSideStick(side) &&
|
|
5527
|
+
i >= base && i < base + touchGamepadSideButtonCount(side))
|
|
5528
|
+
return true;
|
|
5529
|
+
}
|
|
5530
|
+
return false;
|
|
5531
|
+
}
|
|
5532
|
+
|
|
5533
|
+
// center of a side's controls in stage-local CSS pixels (stick rest / button cluster)
|
|
5534
|
+
// returns the floating stick anchor when that side is an active floating stick
|
|
5535
|
+
function touchGamepadSideCenter(side, W, H)
|
|
5536
|
+
{
|
|
5537
|
+
if (touchGamepadFloating && touchGamepadSideStick(side) && touchGamepadStickAnchors[side])
|
|
5538
|
+
return touchGamepadStickAnchors[side];
|
|
5539
|
+
let y = H - touchGamepadSize;
|
|
5540
|
+
const count = touchGamepadSideButtonCount(side);
|
|
5541
|
+
if (!touchGamepadSideStick(side) && (count === 2 || count === 3))
|
|
5542
|
+
y -= touchGamepadSize/4; // nudge a 2/3 button cluster up a bit
|
|
5543
|
+
return vec2(side ? W - touchGamepadSize : touchGamepadSize, y);
|
|
5544
|
+
}
|
|
5545
|
+
|
|
5546
|
+
// position the input zones for the current mode and rebuild the SVG visuals
|
|
5547
|
+
function touchGamepadRelayout()
|
|
5548
|
+
{
|
|
5549
|
+
if (!touchGamepadOverlay) return;
|
|
5550
|
+
const r = touchGamepadStageRect();
|
|
5551
|
+
const W = r.width, H = r.height, S = touchGamepadSize;
|
|
5552
|
+
const setZone = (z, css)=> z.style.cssText =
|
|
5553
|
+
'position:absolute;pointer-events:auto;touch-action:none;' + css;
|
|
5554
|
+
|
|
5555
|
+
if (paused && touchGamepadCenterButtonSize)
|
|
5556
|
+
{
|
|
5557
|
+
// while paused, any touch presses start
|
|
5558
|
+
setZone(touchGamepadZoneC, 'inset:0');
|
|
5559
|
+
for (const zone of touchGamepadSideZones) zone.style.display = 'none';
|
|
5560
|
+
touchGamepadZoneC.style.display = '';
|
|
5561
|
+
}
|
|
5562
|
+
else
|
|
5307
5563
|
{
|
|
5308
|
-
|
|
5309
|
-
|
|
5564
|
+
// position each side zone (left/right differ only by which edge they hug)
|
|
5565
|
+
for (let side = 0; side < 2; side++)
|
|
5566
|
+
{
|
|
5567
|
+
const zone = touchGamepadSideZones[side], edge = side ? 'right' : 'left';
|
|
5568
|
+
zone.style.display = touchGamepadSideHasControl(side) ? '' : 'none';
|
|
5569
|
+
if (touchGamepadFloating)
|
|
5570
|
+
{
|
|
5571
|
+
// bottom 60% grabs the control; the top 40% passes through. A side with no
|
|
5572
|
+
// control on the other side uses the full width (matching the hit-test)
|
|
5573
|
+
const width = touchGamepadSideHasControl(side ? 0 : 1) ? '50%' : '100%';
|
|
5574
|
+
setZone(zone, `${edge}:0;bottom:0;width:${width};height:60%`);
|
|
5575
|
+
}
|
|
5576
|
+
else // fixed: a compact box hugging the corner control
|
|
5577
|
+
setZone(zone, `${edge}:0;bottom:0;width:${3*S}px;height:${3*S}px`);
|
|
5578
|
+
}
|
|
5579
|
+
touchGamepadZoneC.style.display = touchGamepadCenterButtonSize ? '' : 'none';
|
|
5580
|
+
const c = touchGamepadCenterButtonSize;
|
|
5581
|
+
setZone(touchGamepadZoneC,
|
|
5582
|
+
`left:50%;top:50%;width:${2*c}px;height:${2*c}px;transform:translate(-50%,-50%)`);
|
|
5583
|
+
}
|
|
5310
5584
|
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
|
|
5585
|
+
touchGamepadBuildSvg(W, H);
|
|
5586
|
+
touchGamepadNeedRelayout = false;
|
|
5587
|
+
}
|
|
5314
5588
|
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
|
|
5589
|
+
// (re)build the SVG shapes for the current layout; dynamic bits update per-frame
|
|
5590
|
+
function touchGamepadBuildSvg(W, H)
|
|
5591
|
+
{
|
|
5592
|
+
const svg = touchGamepadSvg;
|
|
5593
|
+
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
|
5594
|
+
const els = touchGamepadSvgEls = { face: [], thumb: [] };
|
|
5595
|
+
const S = touchGamepadSize;
|
|
5596
|
+
const circle = (cx, cy, rr, fill)=>
|
|
5597
|
+
{
|
|
5598
|
+
const c = document.createElementNS(touchGamepadSvgNS, 'circle');
|
|
5599
|
+
c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', rr);
|
|
5600
|
+
if (fill) c.setAttribute('fill', fill);
|
|
5601
|
+
svg.appendChild(c);
|
|
5602
|
+
return c;
|
|
5603
|
+
};
|
|
5604
|
+
const cross = (ctr)=>
|
|
5605
|
+
{
|
|
5606
|
+
// plus-shaped dpad outline centered at ctr
|
|
5607
|
+
const a = S*.18, b = S*.5, x = ctr.x, y = ctr.y;
|
|
5608
|
+
const p = document.createElementNS(touchGamepadSvgNS, 'path');
|
|
5609
|
+
p.setAttribute('d',
|
|
5610
|
+
`M ${x-a} ${y-b} H ${x+a} V ${y-a} H ${x+b} V ${y+a} H ${x+a} ` +
|
|
5611
|
+
`V ${y+b} H ${x-a} V ${y+a} H ${x-b} V ${y-a} H ${x-a} Z`);
|
|
5612
|
+
svg.appendChild(p);
|
|
5613
|
+
};
|
|
5321
5614
|
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
const
|
|
5327
|
-
|
|
5615
|
+
// draw each side: a directional stick, a single large button, or face buttons
|
|
5616
|
+
for (let side = 0; side < 2; side++)
|
|
5617
|
+
{
|
|
5618
|
+
const count = touchGamepadSideButtonCount(side);
|
|
5619
|
+
const base = touchGamepadSideButtonBase(side);
|
|
5620
|
+
const ctr = touchGamepadSideCenter(side, W, H);
|
|
5621
|
+
if (touchGamepadSideStick(side))
|
|
5328
5622
|
{
|
|
5329
|
-
//
|
|
5330
|
-
|
|
5623
|
+
// directional stick (circle or cross) with a thumb dot that moves per-frame
|
|
5624
|
+
if (touchGamepadAnalog) circle(ctr.x, ctr.y, S/2); else cross(ctr);
|
|
5625
|
+
els.thumb[side] = circle(ctr.x, ctr.y, S/4, '#fff');
|
|
5331
5626
|
}
|
|
5332
|
-
else
|
|
5627
|
+
else if (count === 1)
|
|
5628
|
+
els.face[base] = circle(ctr.x, ctr.y, S/2, '#000'); // single large button
|
|
5629
|
+
else for (let i = 0; i < count; i++)
|
|
5333
5630
|
{
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
5631
|
+
const j = mod(i-1, 4);
|
|
5632
|
+
let button = count > 2 ? j : min(j, count-1);
|
|
5633
|
+
button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
|
|
5634
|
+
const offset = vec2().setDirection(j, S/2);
|
|
5635
|
+
if (count === 2) offset.x *= -1;
|
|
5636
|
+
// left side mirrors the right layout's positions, keeping indices in order
|
|
5637
|
+
// (e.g. 2 buttons -> button 4 at bottom, button 5 at left)
|
|
5638
|
+
if (!side) offset.x *= -1;
|
|
5639
|
+
const pos = ctr.add(offset);
|
|
5640
|
+
els.face[base + button] = circle(pos.x, pos.y, S/4, '#000');
|
|
5341
5641
|
}
|
|
5342
|
-
|
|
5343
|
-
|
|
5642
|
+
}
|
|
5643
|
+
|
|
5644
|
+
// debug: draw the proximity hit regions the hit-test actually uses
|
|
5645
|
+
if (debug && debugGamepads) touchGamepadBuildDebug(W, H);
|
|
5646
|
+
}
|
|
5647
|
+
|
|
5648
|
+
// draw debug outlines of the touch control hit regions into the overlay svg
|
|
5649
|
+
function touchGamepadBuildDebug(W, H)
|
|
5650
|
+
{
|
|
5651
|
+
const S = touchGamepadSize, svg = touchGamepadSvg;
|
|
5652
|
+
const shape = (tag, attrs, stroke)=>
|
|
5653
|
+
{
|
|
5654
|
+
const el = document.createElementNS(touchGamepadSvgNS, tag);
|
|
5655
|
+
for (const k in attrs) el.setAttribute(k, attrs[k]);
|
|
5656
|
+
el.setAttribute('stroke', stroke);
|
|
5657
|
+
el.setAttribute('stroke-width', 2);
|
|
5658
|
+
el.setAttribute('fill', 'none');
|
|
5659
|
+
svg.appendChild(el);
|
|
5660
|
+
};
|
|
5661
|
+
const ring = (c, rr, stroke)=> shape('circle', {cx:c.x, cy:c.y, r:rr}, stroke);
|
|
5662
|
+
|
|
5663
|
+
// green line: the left/right split that assigns a stick press to a side
|
|
5664
|
+
shape('line', {x1:W/2, y1:0, x2:W/2, y2:H}, '#0f0');
|
|
5344
5665
|
|
|
5345
|
-
|
|
5666
|
+
// cyan: where each side's control can be grabbed
|
|
5667
|
+
for (let side = 0; side < 2; side++)
|
|
5668
|
+
{
|
|
5669
|
+
if (touchGamepadSideStick(side))
|
|
5346
5670
|
{
|
|
5347
|
-
|
|
5348
|
-
const buttonSize = touchGamepadButtonCount > 1 ?
|
|
5349
|
-
touchGamepadSize/4 : touchGamepadSize/2;
|
|
5350
|
-
for (let i=0; i<touchGamepadButtonCount; i++)
|
|
5671
|
+
if (touchGamepadFloating)
|
|
5351
5672
|
{
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
button = button === 3 ? 2 : button === 2 ? 3 : button;
|
|
5357
|
-
const pos = touchGamepadButtonCount < 2 ? buttonCenter :
|
|
5358
|
-
buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
|
|
5359
|
-
context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
|
|
5360
|
-
context.beginPath();
|
|
5361
|
-
context.arc(pos.x, pos.y, buttonSize, 0,9);
|
|
5362
|
-
context.fill();
|
|
5363
|
-
context.stroke();
|
|
5673
|
+
// grab region: this side's half (or the full width if the other side is empty)
|
|
5674
|
+
const top = H*.4, full = !touchGamepadSideHasControl(side ? 0 : 1);
|
|
5675
|
+
const x = full ? 0 : (side ? W/2 : 0);
|
|
5676
|
+
shape('rect', {x, y:top, width:full ? W : W/2, height:H-top}, '#0ff');
|
|
5364
5677
|
}
|
|
5678
|
+
else
|
|
5679
|
+
ring(touchGamepadSideCenter(side, W, H), 2*S, '#0ff');
|
|
5365
5680
|
}
|
|
5681
|
+
else if (touchGamepadSideButtonCount(side) >= 1)
|
|
5682
|
+
ring(touchGamepadSideCenter(side, W, H), S, '#0ff'); // face / single-button radius
|
|
5683
|
+
}
|
|
5366
5684
|
|
|
5367
|
-
|
|
5368
|
-
|
|
5685
|
+
// yellow: start button radius; magenta: where start is blocked (near a control)
|
|
5686
|
+
if (touchGamepadCenterButtonSize)
|
|
5687
|
+
{
|
|
5688
|
+
ring(vec2(W/2, H/2), touchGamepadCenterButtonSize, '#ff0');
|
|
5689
|
+
for (let side = 0; side < 2; side++)
|
|
5690
|
+
if (touchGamepadSideHasControl(side))
|
|
5691
|
+
ring(touchGamepadSideCenter(side, W, H), 2*S, '#f0f');
|
|
5369
5692
|
}
|
|
5370
5693
|
}
|
|
5371
5694
|
|
|
5372
|
-
//
|
|
5373
|
-
function
|
|
5695
|
+
// per-frame: fade the overlay and move the thumbs / set pressed states
|
|
5696
|
+
function touchGamepadRender()
|
|
5374
5697
|
{
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5698
|
+
if (!touchGamepadOverlay || headlessMode) return;
|
|
5699
|
+
|
|
5700
|
+
// hide and bail if disabled at runtime (overlay stays in the DOM for reuse)
|
|
5701
|
+
// display:none also takes the input zones out of hit-testing so touches are
|
|
5702
|
+
// not silently captured away from the game while disabled
|
|
5703
|
+
if (!touchGamepadEnable || !isTouchDevice)
|
|
5704
|
+
{
|
|
5705
|
+
if (touchGamepadOverlay.style.display !== 'none')
|
|
5706
|
+
{
|
|
5707
|
+
// just disabled: hide the overlay and release any held controls
|
|
5708
|
+
touchGamepadOverlay.style.display = 'none';
|
|
5709
|
+
touchGamepadPointerRole.clear();
|
|
5710
|
+
touchGamepadButtons.length = 0;
|
|
5711
|
+
touchGamepadSticks.length = 0;
|
|
5712
|
+
touchGamepadStickPointerId.length = 0;
|
|
5713
|
+
}
|
|
5714
|
+
return;
|
|
5715
|
+
}
|
|
5716
|
+
touchGamepadOverlay.style.display = '';
|
|
5717
|
+
|
|
5718
|
+
// relayout when the paused state, a layout setting, or the debug view changes
|
|
5719
|
+
const dbg = debug && debugGamepads;
|
|
5720
|
+
const layout = [touchGamepadButtonCount, touchGamepadLeftButtonCount, touchGamepadLeftStick,
|
|
5721
|
+
touchGamepadRightStick, touchGamepadAnalog, touchGamepadSize, touchGamepadFloating,
|
|
5722
|
+
touchGamepadCenterButtonSize, paused, dbg].join();
|
|
5723
|
+
if (layout !== touchGamepadLastLayout)
|
|
5724
|
+
{
|
|
5725
|
+
touchGamepadLastLayout = layout;
|
|
5726
|
+
touchGamepadNeedRelayout = true;
|
|
5727
|
+
}
|
|
5728
|
+
// relayout before the visibility bail-out so the paused full-screen start zone applies
|
|
5729
|
+
if (touchGamepadNeedRelayout) touchGamepadRelayout();
|
|
5730
|
+
|
|
5731
|
+
// fade out when idle (always show when displayTime is 0, or while debugging)
|
|
5732
|
+
const fade = touchGamepadDisplayTime ?
|
|
5733
|
+
percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
|
|
5734
|
+
const visible = dbg || (touchGamepadTimer.isSet() && fade > 0 && !paused);
|
|
5735
|
+
touchGamepadOverlay.style.opacity = !visible ? 0 : dbg ? 1 : fade*touchGamepadAlpha;
|
|
5736
|
+
if (!visible) return;
|
|
5737
|
+
|
|
5738
|
+
const r = touchGamepadStageRect();
|
|
5739
|
+
const W = r.width, H = r.height, S = touchGamepadSize;
|
|
5740
|
+
const els = touchGamepadSvgEls;
|
|
5741
|
+
if (!els) return;
|
|
5742
|
+
|
|
5743
|
+
for (let side = 0; side < 2; side++)
|
|
5744
|
+
if (touchGamepadSideStick(side) && els.thumb[side])
|
|
5745
|
+
{
|
|
5746
|
+
const ctr = touchGamepadSideCenter(side, W, H);
|
|
5747
|
+
const t = ctr.add((touchGamepadSticks[side] ?? vec2()).scale(S/2));
|
|
5748
|
+
els.thumb[side].setAttribute('cx', t.x);
|
|
5749
|
+
els.thumb[side].setAttribute('cy', t.y);
|
|
5750
|
+
}
|
|
5751
|
+
for (let i = 0; i < els.face.length; i++)
|
|
5752
|
+
if (els.face[i])
|
|
5753
|
+
els.face[i].setAttribute('fill', touchGamepadButtons[i] ? '#fff' : '#000');
|
|
5754
|
+
}
|
|
5755
|
+
|
|
5756
|
+
// convert a pointer event to stage-local CSS pixels
|
|
5757
|
+
function touchGamepadEventPos(e)
|
|
5758
|
+
{
|
|
5759
|
+
const r = touchGamepadStageRect();
|
|
5760
|
+
return vec2(e.clientX - r.left, e.clientY - r.top);
|
|
5761
|
+
}
|
|
5762
|
+
|
|
5763
|
+
// set a directional stick from a stage-local point and flag its stick-touch button
|
|
5764
|
+
// (stick 0 press = button 10, stick 1 press = button 11, following the output index)
|
|
5765
|
+
function touchGamepadApplyStick(side, p)
|
|
5766
|
+
{
|
|
5767
|
+
const delta = p.subtract(touchGamepadStickAnchors[side]);
|
|
5768
|
+
touchGamepadSticks[side] = delta.scale(2/touchGamepadSize).clampLength();
|
|
5769
|
+
touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10] = 1;
|
|
5770
|
+
}
|
|
5771
|
+
|
|
5772
|
+
// pick a side's gamepad button index from a stage-local point, or -1 if outside the cluster
|
|
5773
|
+
function touchGamepadFaceButtonAt(side, p, W, H)
|
|
5774
|
+
{
|
|
5775
|
+
const count = touchGamepadSideButtonCount(side);
|
|
5776
|
+
const base = touchGamepadSideButtonBase(side);
|
|
5777
|
+
const bc = touchGamepadSideCenter(side, W, H);
|
|
5778
|
+
if (bc.distance(p) >= touchGamepadSize) return -1;
|
|
5779
|
+
if (count === 1) return base; // single large button
|
|
5780
|
+
const d = bc.subtract(p);
|
|
5781
|
+
if (!side) d.x *= -1; // left side mirrors the right layout's positions horizontally
|
|
5782
|
+
let button = count === 2 ? (d.x < d.y ? 1 : 0) : mod(d.direction()+2, 4);
|
|
5783
|
+
button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
|
|
5784
|
+
return button < count ? base + button : -1;
|
|
5785
|
+
}
|
|
5786
|
+
|
|
5787
|
+
// pick which control a stage-local press activates, by priority then proximity,
|
|
5788
|
+
// independent of which zone element captured it - so overlapping zones on small
|
|
5789
|
+
// screens resolve to the nearest control instead of whichever zone is topmost
|
|
5790
|
+
// returns {role:'stick', side} or {role:'face', btn} or {role:'start'} or undefined
|
|
5791
|
+
function touchGamepadControlAt(p, W, H)
|
|
5792
|
+
{
|
|
5793
|
+
const S = touchGamepadSize;
|
|
5794
|
+
const leftHalf = p.x < W/2;
|
|
5795
|
+
const floatTop = H*.4; // floating grab region is the bottom 60% of the screen
|
|
5796
|
+
|
|
5797
|
+
// check each side (left first for priority); a side is a stick or buttons
|
|
5798
|
+
for (let side = 0; side < 2; side++)
|
|
5799
|
+
{
|
|
5800
|
+
const onHalf = side ? !leftHalf : leftHalf;
|
|
5801
|
+
if (touchGamepadSideStick(side))
|
|
5802
|
+
{
|
|
5803
|
+
// a side with no control on the other side uses the full width
|
|
5804
|
+
const otherControl = touchGamepadSideHasControl(side ? 0 : 1);
|
|
5805
|
+
const grab = touchGamepadFloating ?
|
|
5806
|
+
(!otherControl || onHalf) && p.y > floatTop :
|
|
5807
|
+
onHalf && touchGamepadSideCenter(side, W, H).distance(p) < 2*S;
|
|
5808
|
+
if (grab) return {role:'stick', side};
|
|
5809
|
+
}
|
|
5810
|
+
else if (touchGamepadSideButtonCount(side) >= 1)
|
|
5811
|
+
{
|
|
5812
|
+
const btn = touchGamepadFaceButtonAt(side, p, W, H);
|
|
5813
|
+
if (btn >= 0) return {role:'face', btn};
|
|
5814
|
+
}
|
|
5815
|
+
}
|
|
5816
|
+
|
|
5817
|
+
// center start button, blocked within 2*size of a control so drift off a
|
|
5818
|
+
// control can't accidentally fire start (matches the original exclusion logic)
|
|
5819
|
+
if (touchGamepadCenterButtonSize)
|
|
5820
|
+
{
|
|
5821
|
+
for (let side = 0; side < 2; side++)
|
|
5822
|
+
if (touchGamepadSideHasControl(side) &&
|
|
5823
|
+
touchGamepadSideCenter(side, W, H).distance(p) < 2*S)
|
|
5824
|
+
return;
|
|
5825
|
+
if (vec2(W/2, H/2).distance(p) < touchGamepadCenterButtonSize)
|
|
5826
|
+
return {role:'start'};
|
|
5827
|
+
}
|
|
5828
|
+
}
|
|
5829
|
+
|
|
5830
|
+
function touchGamepadPointerDown(e, zone)
|
|
5831
|
+
{
|
|
5832
|
+
if (!touchGamepadEnable) return;
|
|
5833
|
+
e.preventDefault();
|
|
5834
|
+
zone.setPointerCapture(e.pointerId);
|
|
5835
|
+
touchGamepadTimer.set();
|
|
5836
|
+
isUsingGamepad = true;
|
|
5837
|
+
|
|
5838
|
+
// resume audio on first interaction
|
|
5839
|
+
if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
|
|
5840
|
+
audioContext.resume();
|
|
5841
|
+
|
|
5842
|
+
// while paused, any touch is the start button
|
|
5843
|
+
if (paused)
|
|
5844
|
+
{
|
|
5845
|
+
if (touchGamepadCenterButtonSize)
|
|
5846
|
+
{
|
|
5847
|
+
touchGamepadButtons[9] = 1;
|
|
5848
|
+
touchGamepadPointerRole.set(e.pointerId, 'start');
|
|
5849
|
+
}
|
|
5850
|
+
return;
|
|
5851
|
+
}
|
|
5852
|
+
|
|
5853
|
+
const r = touchGamepadStageRect();
|
|
5854
|
+
const W = r.width, H = r.height;
|
|
5855
|
+
const p = vec2(e.clientX - r.left, e.clientY - r.top);
|
|
5856
|
+
|
|
5857
|
+
// choose the control by proximity/priority, not by which zone captured the touch
|
|
5858
|
+
const hit = touchGamepadControlAt(p, W, H);
|
|
5859
|
+
if (!hit) return;
|
|
5860
|
+
if (hit.role === 'stick')
|
|
5861
|
+
{
|
|
5862
|
+
const side = hit.side;
|
|
5863
|
+
touchGamepadStickAnchors[side] = touchGamepadFloating ? p : touchGamepadSideCenter(side, W, H);
|
|
5864
|
+
touchGamepadStickPointerId[side] = e.pointerId;
|
|
5865
|
+
touchGamepadPointerRole.set(e.pointerId, 'stick'+side);
|
|
5866
|
+
touchGamepadNeedRelayout = true; // base may have re-anchored
|
|
5867
|
+
touchGamepadApplyStick(side, p);
|
|
5868
|
+
}
|
|
5869
|
+
else if (hit.role === 'face')
|
|
5870
|
+
{
|
|
5871
|
+
touchGamepadButtons[hit.btn] = 1;
|
|
5872
|
+
touchGamepadPointerRole.set(e.pointerId, 'face'+hit.btn);
|
|
5873
|
+
}
|
|
5874
|
+
else // 'start'
|
|
5875
|
+
{
|
|
5876
|
+
touchGamepadButtons[9] = 1;
|
|
5877
|
+
touchGamepadPointerRole.set(e.pointerId, 'start');
|
|
5878
|
+
}
|
|
5879
|
+
}
|
|
5880
|
+
|
|
5881
|
+
function touchGamepadPointerMove(e)
|
|
5882
|
+
{
|
|
5883
|
+
const role = touchGamepadPointerRole.get(e.pointerId);
|
|
5884
|
+
if (!role) return;
|
|
5885
|
+
e.preventDefault();
|
|
5886
|
+
const p = touchGamepadEventPos(e);
|
|
5887
|
+
if (role === 'stick0' || role === 'stick1')
|
|
5888
|
+
touchGamepadApplyStick(role === 'stick1' ? 1 : 0, p);
|
|
5889
|
+
// face buttons & start are held until release (no slide-between this pass)
|
|
5890
|
+
}
|
|
5891
|
+
|
|
5892
|
+
function touchGamepadPointerUp(e)
|
|
5893
|
+
{
|
|
5894
|
+
const role = touchGamepadPointerRole.get(e.pointerId);
|
|
5895
|
+
if (!role) return;
|
|
5896
|
+
touchGamepadPointerRole.delete(e.pointerId);
|
|
5897
|
+
if (role === 'stick0' || role === 'stick1')
|
|
5898
|
+
{
|
|
5899
|
+
const side = role === 'stick1' ? 1 : 0;
|
|
5900
|
+
touchGamepadStickPointerId[side] = undefined;
|
|
5901
|
+
touchGamepadSticks[side] = vec2();
|
|
5902
|
+
delete touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10];
|
|
5903
|
+
}
|
|
5904
|
+
else if (role === 'start')
|
|
5905
|
+
delete touchGamepadButtons[9];
|
|
5906
|
+
else // 'face<n>'
|
|
5907
|
+
delete touchGamepadButtons[+role.slice(4)];
|
|
5908
|
+
touchGamepadTimer.set();
|
|
5379
5909
|
}
|
|
5380
5910
|
/**
|
|
5381
5911
|
* LittleJS Audio System
|
|
@@ -5480,7 +6010,7 @@ class Sound
|
|
|
5480
6010
|
/** @property {SoundLoadCallback} - function to call when sound is loaded */
|
|
5481
6011
|
this.onloadCallback = onloadCallback;
|
|
5482
6012
|
|
|
5483
|
-
if (
|
|
6013
|
+
if (isArray(asset))
|
|
5484
6014
|
{
|
|
5485
6015
|
// generate zzfx sound — copy so we don't mutate the caller's array
|
|
5486
6016
|
const zzfxSound = asset.slice();
|
|
@@ -5512,7 +6042,7 @@ class Sound
|
|
|
5512
6042
|
* @param {number} [randomnessScale] - How much to scale pitch randomness
|
|
5513
6043
|
* @param {boolean} [loop] - Should the sound loop?
|
|
5514
6044
|
* @param {boolean} [paused] - Should the sound start paused
|
|
5515
|
-
* @return {SoundInstance} - The
|
|
6045
|
+
* @return {SoundInstance} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode
|
|
5516
6046
|
*/
|
|
5517
6047
|
play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
|
|
5518
6048
|
{
|
|
@@ -5542,10 +6072,24 @@ class Sound
|
|
|
5542
6072
|
// get pan from screen space coords
|
|
5543
6073
|
pan = worldToScreen(pos).x * 2/mainCanvas.width - 1;
|
|
5544
6074
|
}
|
|
5545
|
-
|
|
5546
|
-
// Create
|
|
5547
|
-
const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
|
|
5548
|
-
|
|
6075
|
+
|
|
6076
|
+
// Create sound instance
|
|
6077
|
+
const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
|
|
6078
|
+
const instance = new SoundInstance(this, volume, rate, pan, loop, paused);
|
|
6079
|
+
|
|
6080
|
+
if (debug && debugSound && pos)
|
|
6081
|
+
{
|
|
6082
|
+
// visualize where positioned sounds play and their falloff range
|
|
6083
|
+
debugCircle(pos, .5, '#0ff', .5, true);
|
|
6084
|
+
if (this.range)
|
|
6085
|
+
{
|
|
6086
|
+
debugCircle(pos, 2*this.range, '#0ff', .5); // silent radius
|
|
6087
|
+
debugCircle(pos, 2*this.range*this.taper, '#0ff', .5); // full volume radius
|
|
6088
|
+
}
|
|
6089
|
+
debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5);
|
|
6090
|
+
}
|
|
6091
|
+
|
|
6092
|
+
return instance;
|
|
5549
6093
|
}
|
|
5550
6094
|
|
|
5551
6095
|
/** Play a music track that loops by default
|
|
@@ -5572,7 +6116,7 @@ class Sound
|
|
|
5572
6116
|
}
|
|
5573
6117
|
|
|
5574
6118
|
/** Get how long this sound is in seconds
|
|
5575
|
-
* @return {number} - How long the sound is in seconds (
|
|
6119
|
+
* @return {number} - How long the sound is in seconds (0 if loading)
|
|
5576
6120
|
*/
|
|
5577
6121
|
getDuration()
|
|
5578
6122
|
{ return this.sampleChannels?.[0]?.length / this.sampleRate || 0; }
|
|
@@ -5729,10 +6273,14 @@ class SoundInstance
|
|
|
5729
6273
|
{
|
|
5730
6274
|
if (fadeTime)
|
|
5731
6275
|
{
|
|
5732
|
-
// ramp off gain
|
|
6276
|
+
// ramp off gain from current volume (not 1, or low-volume
|
|
6277
|
+
// instances would jump back up before fading);
|
|
6278
|
+
// cancel any prior scheduling so stacked stop calls don't
|
|
6279
|
+
// re-anchor partway through a previous fade
|
|
5733
6280
|
const startFade = audioContext.currentTime;
|
|
5734
6281
|
const endFade = startFade + fadeTime;
|
|
5735
|
-
this.gainNode.gain.
|
|
6282
|
+
this.gainNode.gain.cancelScheduledValues(startFade);
|
|
6283
|
+
this.gainNode.gain.setValueAtTime(this.volume, startFade);
|
|
5736
6284
|
this.gainNode.gain.linearRampToValueAtTime(0, endFade);
|
|
5737
6285
|
this.source.stop(endFade);
|
|
5738
6286
|
}
|
|
@@ -5780,13 +6328,14 @@ class SoundInstance
|
|
|
5780
6328
|
*/
|
|
5781
6329
|
getCurrentTime()
|
|
5782
6330
|
{
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
6331
|
+
if (!this.isPlaying()) return this.pausedTime;
|
|
6332
|
+
const duration = this.getDuration();
|
|
6333
|
+
// guard mod against 0 duration (rate=0 or sound not loaded)
|
|
6334
|
+
return duration ? mod(audioContext.currentTime - this.startTime, duration) : 0;
|
|
5786
6335
|
}
|
|
5787
6336
|
|
|
5788
6337
|
/** Get the total duration of this sound
|
|
5789
|
-
* @return {number} - Total duration in seconds
|
|
6338
|
+
* @return {number} - Total duration in seconds (0 if loading)
|
|
5790
6339
|
*/
|
|
5791
6340
|
getDuration() { return this.rate ? this.sound.getDuration() / this.rate : 0; }
|
|
5792
6341
|
|
|
@@ -5810,7 +6359,7 @@ function speak(text, volume=1, rate=1, pitch=1, language='')
|
|
|
5810
6359
|
{
|
|
5811
6360
|
ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch');
|
|
5812
6361
|
if (!soundEnable || headlessMode) return;
|
|
5813
|
-
if (
|
|
6362
|
+
if (typeof speechSynthesis === 'undefined') return;
|
|
5814
6363
|
|
|
5815
6364
|
// common languages (not supported by all browsers)
|
|
5816
6365
|
// en - english, it - italian, fr - french, de - german, es - spanish
|
|
@@ -5828,7 +6377,11 @@ function speak(text, volume=1, rate=1, pitch=1, language='')
|
|
|
5828
6377
|
|
|
5829
6378
|
/** Stop all queued speech
|
|
5830
6379
|
* @memberof Audio */
|
|
5831
|
-
function speakStop()
|
|
6380
|
+
function speakStop()
|
|
6381
|
+
{
|
|
6382
|
+
if (typeof speechSynthesis !== 'undefined')
|
|
6383
|
+
speechSynthesis.cancel();
|
|
6384
|
+
}
|
|
5832
6385
|
|
|
5833
6386
|
/** Get frequency of a note on a musical scale
|
|
5834
6387
|
* @param {number} semitoneOffset - How many semitones away from the root note
|
|
@@ -5890,13 +6443,22 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
|
|
|
5890
6443
|
const pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)});
|
|
5891
6444
|
source.connect(pannerNode).connect(gainNode);
|
|
5892
6445
|
|
|
5893
|
-
//
|
|
5894
|
-
|
|
5895
|
-
|
|
6446
|
+
// disconnect nodes when the sound ends so the audio graph doesn't grow
|
|
6447
|
+
// unbounded across many play() calls (source.stop() also fires 'ended')
|
|
6448
|
+
source.addEventListener('ended', ()=>
|
|
6449
|
+
{
|
|
6450
|
+
gainNode.disconnect();
|
|
6451
|
+
pannerNode.disconnect();
|
|
6452
|
+
if (onended) onended(source);
|
|
6453
|
+
});
|
|
5896
6454
|
|
|
5897
6455
|
// play and return sound
|
|
5898
6456
|
const startOffset = offset * rate;
|
|
5899
6457
|
source.start(0, startOffset);
|
|
6458
|
+
|
|
6459
|
+
if (debug && debugSound)
|
|
6460
|
+
LOG('sound', 'vol', volume.toFixed(2), 'rate', rate.toFixed(2), 'pan', pan.toFixed(2), loop ? 'loop' : '');
|
|
6461
|
+
|
|
5900
6462
|
return source;
|
|
5901
6463
|
}
|
|
5902
6464
|
|
|
@@ -6129,14 +6691,29 @@ function tileCollisionTest(pos, size=vec2(), callbackObject, solidOnly=true)
|
|
|
6129
6691
|
* @memberof TileLayers */
|
|
6130
6692
|
function tileCollisionRaycast(posStart, posEnd, callbackObject, normal, solidOnly=true)
|
|
6131
6693
|
{
|
|
6694
|
+
// check every layer and keep the closest hit so a far hit in an
|
|
6695
|
+
// earlier-registered layer doesn't shadow a closer hit in a later one
|
|
6696
|
+
let closestHit, closestDistSq, closestNormal;
|
|
6697
|
+
const scratchNormal = normal && vec2();
|
|
6132
6698
|
for (const layer of tileCollisionLayers)
|
|
6133
6699
|
{
|
|
6134
6700
|
if (!solidOnly || layer.isSolid)
|
|
6135
6701
|
{
|
|
6136
|
-
const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject,
|
|
6137
|
-
if (hitPos)
|
|
6702
|
+
const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, scratchNormal);
|
|
6703
|
+
if (hitPos)
|
|
6704
|
+
{
|
|
6705
|
+
const d = posStart.distanceSquared(hitPos);
|
|
6706
|
+
if (closestHit === undefined || d < closestDistSq)
|
|
6707
|
+
{
|
|
6708
|
+
closestHit = hitPos;
|
|
6709
|
+
closestDistSq = d;
|
|
6710
|
+
if (normal) closestNormal = scratchNormal.copy();
|
|
6711
|
+
}
|
|
6712
|
+
}
|
|
6138
6713
|
}
|
|
6139
6714
|
}
|
|
6715
|
+
if (closestHit && normal) normal.setFrom(closestNormal);
|
|
6716
|
+
return closestHit;
|
|
6140
6717
|
}
|
|
6141
6718
|
|
|
6142
6719
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -6310,38 +6887,6 @@ class CanvasLayer extends EngineObject
|
|
|
6310
6887
|
drawTile(pos, size, tileInfo, color, angle, mirror, additiveColor, useWebGL, screenSpace, context);
|
|
6311
6888
|
}
|
|
6312
6889
|
|
|
6313
|
-
/** Draw a tile onto the layer canvas in world space
|
|
6314
|
-
* @param {Vector2} pos
|
|
6315
|
-
* @param {Vector2} [size=vec2(1)]
|
|
6316
|
-
* @param {TileInfo} [tileInfo]
|
|
6317
|
-
* @param {Color} [color=WHITE]
|
|
6318
|
-
* @param {number} [angle]
|
|
6319
|
-
* @param {boolean} [mirror] */
|
|
6320
|
-
drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
|
|
6321
|
-
{
|
|
6322
|
-
pos = pos.subtract(this.pos).multiply(this.tileInfo.size);
|
|
6323
|
-
size = size.multiply(this.tileInfo.size);
|
|
6324
|
-
pos.y = this.canvas.height - pos.y;
|
|
6325
|
-
|
|
6326
|
-
// draw the tile onto the layer canvas
|
|
6327
|
-
const oldMainCanvasSize = mainCanvasSize;
|
|
6328
|
-
mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
|
|
6329
|
-
const useWebGL = this.hasWebGL();
|
|
6330
|
-
useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
|
|
6331
|
-
const drawContext = useWebGL ? undefined : this.context;
|
|
6332
|
-
drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
|
|
6333
|
-
useWebGL && glSetRenderTarget();
|
|
6334
|
-
mainCanvasSize = oldMainCanvasSize;
|
|
6335
|
-
}
|
|
6336
|
-
|
|
6337
|
-
/** Draw a rectangle onto the layer canvas in world space
|
|
6338
|
-
* @param {Vector2} pos
|
|
6339
|
-
* @param {Vector2} [size=vec2(1)]
|
|
6340
|
-
* @param {Color} [color=WHITE]
|
|
6341
|
-
* @param {number} [angle] */
|
|
6342
|
-
drawRect(pos, size, color, angle)
|
|
6343
|
-
{ this.drawTile(pos, size, undefined, color, angle); }
|
|
6344
|
-
|
|
6345
6890
|
/** Create WebGL texture if necessary and copy layer canvas to it */
|
|
6346
6891
|
updateWebGL()
|
|
6347
6892
|
{ this.textureInfo.createWebGLTexture(); }
|
|
@@ -6397,6 +6942,8 @@ class TileLayer extends CanvasLayer
|
|
|
6397
6942
|
this.redrawTileData = ()=> {};
|
|
6398
6943
|
this.drawLayerTile = ()=> {};
|
|
6399
6944
|
this.drawLayerRect = ()=> {};
|
|
6945
|
+
this.drawTile = ()=> {};
|
|
6946
|
+
this.drawRect = ()=> {};
|
|
6400
6947
|
this.clearLayerRect = ()=> {};
|
|
6401
6948
|
return;
|
|
6402
6949
|
}
|
|
@@ -6424,7 +6971,7 @@ class TileLayer extends CanvasLayer
|
|
|
6424
6971
|
ASSERT(data instanceof TileLayerData, 'data must be a TileLayerData');
|
|
6425
6972
|
|
|
6426
6973
|
if (!layerPos.arrayCheck(this.size)) return;
|
|
6427
|
-
this.data[(layerPos.y|0)*this.size.x+layerPos.x|0] = data;
|
|
6974
|
+
this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] = data;
|
|
6428
6975
|
|
|
6429
6976
|
if (!redraw) return;
|
|
6430
6977
|
const isRedraw = drawContext === this.context;
|
|
@@ -6439,11 +6986,11 @@ class TileLayer extends CanvasLayer
|
|
|
6439
6986
|
|
|
6440
6987
|
/** Get data at a given position in the array
|
|
6441
6988
|
* @param {Vector2} layerPos - Local position in array
|
|
6442
|
-
* @return {TileLayerData} */
|
|
6989
|
+
* @return {TileLayerData|undefined} */
|
|
6443
6990
|
getData(layerPos)
|
|
6444
|
-
{
|
|
6991
|
+
{
|
|
6445
6992
|
ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
|
|
6446
|
-
return layerPos.arrayCheck(this.size)
|
|
6993
|
+
return layerPos.arrayCheck(this.size) ? this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] : undefined;
|
|
6447
6994
|
}
|
|
6448
6995
|
|
|
6449
6996
|
// Update the tile layer, refresh texture if needed
|
|
@@ -6551,7 +7098,7 @@ class TileLayer extends CanvasLayer
|
|
|
6551
7098
|
|
|
6552
7099
|
// draw the tile if it has layer data
|
|
6553
7100
|
const d = this.getData(layerPos);
|
|
6554
|
-
if (!d.tile) return;
|
|
7101
|
+
if (!d || !d.tile) return;
|
|
6555
7102
|
|
|
6556
7103
|
const tileInfo = this.tileInfo && this.tileInfo.tile(d.tile);
|
|
6557
7104
|
this.drawLayerTile(drawPos, drawSize, tileInfo, d.color, d.direction*PI/2, d.mirror);
|
|
@@ -6597,6 +7144,38 @@ class TileLayer extends CanvasLayer
|
|
|
6597
7144
|
drawLayerRect(pos, size, color, angle=0)
|
|
6598
7145
|
{ this.drawLayerTile(pos, size, undefined, color, angle); }
|
|
6599
7146
|
|
|
7147
|
+
/** Draw a tile onto the layer canvas in world space
|
|
7148
|
+
* @param {Vector2} pos
|
|
7149
|
+
* @param {Vector2} [size=vec2(1)]
|
|
7150
|
+
* @param {TileInfo} [tileInfo]
|
|
7151
|
+
* @param {Color} [color=WHITE]
|
|
7152
|
+
* @param {number} [angle]
|
|
7153
|
+
* @param {boolean} [mirror] */
|
|
7154
|
+
drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false)
|
|
7155
|
+
{
|
|
7156
|
+
pos = pos.subtract(this.pos).multiply(this.tileInfo.size);
|
|
7157
|
+
size = size.multiply(this.tileInfo.size);
|
|
7158
|
+
pos.y = this.canvas.height - pos.y;
|
|
7159
|
+
|
|
7160
|
+
// draw the tile onto the layer canvas
|
|
7161
|
+
const oldMainCanvasSize = mainCanvasSize;
|
|
7162
|
+
mainCanvasSize = vec2(this.canvas.width, this.canvas.height);
|
|
7163
|
+
const useWebGL = this.hasWebGL();
|
|
7164
|
+
useWebGL && glSetRenderTarget(this.textureInfo.glTexture);
|
|
7165
|
+
const drawContext = useWebGL ? undefined : this.context;
|
|
7166
|
+
drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext);
|
|
7167
|
+
useWebGL && glSetRenderTarget();
|
|
7168
|
+
mainCanvasSize = oldMainCanvasSize;
|
|
7169
|
+
}
|
|
7170
|
+
|
|
7171
|
+
/** Draw a rectangle onto the layer canvas in world space
|
|
7172
|
+
* @param {Vector2} pos
|
|
7173
|
+
* @param {Vector2} [size=vec2(1)]
|
|
7174
|
+
* @param {Color} [color=WHITE]
|
|
7175
|
+
* @param {number} [angle] */
|
|
7176
|
+
drawRect(pos, size, color, angle)
|
|
7177
|
+
{ this.drawTile(pos, size, undefined, color, angle); }
|
|
7178
|
+
|
|
6600
7179
|
/** Clear a rectangle in layer space
|
|
6601
7180
|
* @param {Vector2} pos - position in pixel coordinates
|
|
6602
7181
|
* @param {Vector2} size
|
|
@@ -6675,7 +7254,7 @@ class TileCollisionLayer extends TileLayer
|
|
|
6675
7254
|
setCollisionData(layerPos, data=1)
|
|
6676
7255
|
{
|
|
6677
7256
|
ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
|
|
6678
|
-
const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
|
|
7257
|
+
const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
|
|
6679
7258
|
layerPos.arrayCheck(this.size) && (this.collisionData[i] = data);
|
|
6680
7259
|
}
|
|
6681
7260
|
|
|
@@ -6690,7 +7269,7 @@ class TileCollisionLayer extends TileLayer
|
|
|
6690
7269
|
getCollisionData(layerPos)
|
|
6691
7270
|
{
|
|
6692
7271
|
ASSERT(isVector2(layerPos), 'layerPos must be a Vector2');
|
|
6693
|
-
const i = (layerPos.y|0)*this.size.x + layerPos.x|0;
|
|
7272
|
+
const i = (layerPos.y|0)*this.size.x + (layerPos.x|0);
|
|
6694
7273
|
return layerPos.arrayCheck(this.size) ? this.collisionData[i] : 0;
|
|
6695
7274
|
}
|
|
6696
7275
|
|
|
@@ -6713,10 +7292,17 @@ class TileCollisionLayer extends TileLayer
|
|
|
6713
7292
|
// check any tiles in the area for collision
|
|
6714
7293
|
const posX = pos.x - this.pos.x;
|
|
6715
7294
|
const posY = pos.y - this.pos.y;
|
|
7295
|
+
// reject AABBs entirely past either edge; without this, the negative
|
|
7296
|
+
// side leaks into row/col 0 because minX/minY clamp to 0 and the
|
|
7297
|
+
// point-test floor below forces maxX/maxY up to 1
|
|
7298
|
+
if (posX + size.x/2 < 0 || posX - size.x/2 > this.size.x) return false;
|
|
7299
|
+
if (posY + size.y/2 < 0 || posY - size.y/2 > this.size.y) return false;
|
|
6716
7300
|
const minX = max(posX - size.x/2|0, 0);
|
|
6717
7301
|
const minY = max(posY - size.y/2|0, 0);
|
|
6718
|
-
|
|
6719
|
-
|
|
7302
|
+
// ensure at least one cell is visited even when size is 0 and pos
|
|
7303
|
+
// lands exactly on an integer boundary (documented point-test mode)
|
|
7304
|
+
const maxX = min(max(posX + size.x/2, minX + 1), this.size.x);
|
|
7305
|
+
const maxY = min(max(posY + size.y/2, minY + 1), this.size.y);
|
|
6720
7306
|
const hitPos = new Vector2;
|
|
6721
7307
|
for (let y = minY; y < maxY; ++y)
|
|
6722
7308
|
for (let x = minX; x < maxX; ++x)
|
|
@@ -6829,13 +7415,13 @@ class ParticleEmitter extends EngineObject
|
|
|
6829
7415
|
* @param {number} [particleTime] - How long particles live
|
|
6830
7416
|
* @param {number} [sizeStart] - How big are particles at start
|
|
6831
7417
|
* @param {number} [sizeEnd] - How big are particles at end
|
|
6832
|
-
* @param {number} [speed] - How fast are particles when spawned
|
|
6833
|
-
* @param {number} [angleSpeed] - How fast are particles rotating
|
|
6834
|
-
* @param {number} [damping] - How much to dampen particle speed
|
|
6835
|
-
* @param {number} [angleDamping] - How much to dampen particle angular speed
|
|
7418
|
+
* @param {number} [speed] - How fast are particles when spawned, in world units per frame (at 60fps, so multiply units/sec by 1/60)
|
|
7419
|
+
* @param {number} [angleSpeed] - How fast are particles rotating, in radians per frame (at 60fps)
|
|
7420
|
+
* @param {number} [damping] - How much to dampen particle speed, per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame)
|
|
7421
|
+
* @param {number} [angleDamping] - How much to dampen particle angular speed, per-frame multiplier (1 = no damping)
|
|
6836
7422
|
* @param {number} [gravityScale] - How much gravity effect particles
|
|
6837
7423
|
* @param {number} [particleConeAngle] - Cone for start particle angle
|
|
6838
|
-
* @param {number} [fadeRate] -
|
|
7424
|
+
* @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
|
|
6839
7425
|
* @param {number} [randomness] - Apply extra randomness percent
|
|
6840
7426
|
* @param {boolean} [collideTiles] - Do particles collide against tiles
|
|
6841
7427
|
* @param {boolean} [additive] - Should particles use additive blend
|
|
@@ -6907,19 +7493,19 @@ class ParticleEmitter extends EngineObject
|
|
|
6907
7493
|
this.sizeStart = sizeStart;
|
|
6908
7494
|
/** @property {number} - How big are particles at end */
|
|
6909
7495
|
this.sizeEnd = sizeEnd;
|
|
6910
|
-
/** @property {number} -
|
|
7496
|
+
/** @property {number} - Particle speed when spawned, in world units per frame (at 60fps) */
|
|
6911
7497
|
this.speed = speed;
|
|
6912
|
-
/** @property {number} -
|
|
7498
|
+
/** @property {number} - Particle angular speed when spawned, in radians per frame (at 60fps) */
|
|
6913
7499
|
this.angleSpeed = angleSpeed;
|
|
6914
|
-
/** @property {number} -
|
|
7500
|
+
/** @property {number} - Per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame) */
|
|
6915
7501
|
this.damping = damping;
|
|
6916
|
-
/** @property {number} -
|
|
7502
|
+
/** @property {number} - Per-frame angular velocity multiplier (1 = no damping) */
|
|
6917
7503
|
this.angleDamping = angleDamping;
|
|
6918
7504
|
/** @property {number} - How much gravity affects particles */
|
|
6919
7505
|
this.gravityScale = gravityScale;
|
|
6920
7506
|
/** @property {number} - Cone for start particle angle */
|
|
6921
7507
|
this.particleConeAngle = particleConeAngle;
|
|
6922
|
-
/** @property {number} -
|
|
7508
|
+
/** @property {number} - Fraction of life spent fading, split half at start and half at end (e.g. .2 = 10% fade-in + 10% fade-out) */
|
|
6923
7509
|
this.fadeRate = fadeRate;
|
|
6924
7510
|
/** @property {number} - Apply extra randomness percent */
|
|
6925
7511
|
this.randomness = randomness;
|
|
@@ -6982,12 +7568,16 @@ class ParticleEmitter extends EngineObject
|
|
|
6982
7568
|
else if (this.particles.length === 0)
|
|
6983
7569
|
this.destroy(true);
|
|
6984
7570
|
|
|
6985
|
-
// update and remove destroyed particles
|
|
6986
|
-
|
|
7571
|
+
// update and remove destroyed particles in place to avoid per-frame array allocation
|
|
7572
|
+
const particles = this.particles;
|
|
7573
|
+
let alive = 0;
|
|
7574
|
+
for (let i = 0; i < particles.length; ++i)
|
|
6987
7575
|
{
|
|
7576
|
+
const p = particles[i];
|
|
6988
7577
|
p.update();
|
|
6989
|
-
|
|
6990
|
-
}
|
|
7578
|
+
if (!p.destroyed) particles[alive++] = p;
|
|
7579
|
+
}
|
|
7580
|
+
particles.length = alive;
|
|
6991
7581
|
|
|
6992
7582
|
if (debugParticles)
|
|
6993
7583
|
{
|
|
@@ -7197,33 +7787,32 @@ class Particle
|
|
|
7197
7787
|
const hitLayer = tileCollisionTest(this.pos);
|
|
7198
7788
|
if (!testCollision(oldPos))
|
|
7199
7789
|
{
|
|
7200
|
-
|
|
7790
|
+
// testCollision already invoked collideCallback with the
|
|
7791
|
+
// correct (this, data, pos) args; no need to re-check here.
|
|
7792
|
+
// test which side we bounced off (or both if a corner)
|
|
7793
|
+
const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
|
|
7794
|
+
const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
|
|
7795
|
+
const hitRestitution = max(restitution, hitLayer.restitution);
|
|
7796
|
+
const hitFriction = max(friction, hitLayer.friction);
|
|
7797
|
+
if (isBlockedX)
|
|
7201
7798
|
{
|
|
7202
|
-
//
|
|
7203
|
-
|
|
7204
|
-
|
|
7205
|
-
|
|
7206
|
-
|
|
7207
|
-
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
|
|
7211
|
-
this.
|
|
7212
|
-
|
|
7213
|
-
|
|
7214
|
-
|
|
7215
|
-
|
|
7216
|
-
|
|
7217
|
-
if (wasFalling)
|
|
7218
|
-
this.groundObject = hitLayer;
|
|
7219
|
-
|
|
7220
|
-
// move to previous Y position and bounce
|
|
7221
|
-
this.pos.y = oldPos.y;
|
|
7222
|
-
this.velocity.y *= -hitRestitution;
|
|
7223
|
-
this.velocity.x *= hitFriction;
|
|
7224
|
-
}
|
|
7225
|
-
debugPhysics && debugRect(this.pos, this.size, '#f00');
|
|
7799
|
+
// move to previous X position and bounce
|
|
7800
|
+
this.pos.x = oldPos.x;
|
|
7801
|
+
this.velocity.x *= -hitRestitution;
|
|
7802
|
+
this.velocity.y *= hitFriction;
|
|
7803
|
+
}
|
|
7804
|
+
if (isBlockedY || !isBlockedX)
|
|
7805
|
+
{
|
|
7806
|
+
const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
|
|
7807
|
+
if (wasFalling)
|
|
7808
|
+
this.groundObject = hitLayer;
|
|
7809
|
+
|
|
7810
|
+
// move to previous Y position and bounce
|
|
7811
|
+
this.pos.y = oldPos.y;
|
|
7812
|
+
this.velocity.y *= -hitRestitution;
|
|
7813
|
+
this.velocity.x *= hitFriction;
|
|
7226
7814
|
}
|
|
7815
|
+
debugPhysics && debugRect(this.pos, this.size, '#f00');
|
|
7227
7816
|
}
|
|
7228
7817
|
}
|
|
7229
7818
|
}
|
|
@@ -7270,7 +7859,7 @@ class Particle
|
|
|
7270
7859
|
{
|
|
7271
7860
|
// in local space of emitter
|
|
7272
7861
|
const a = emitter.angle;
|
|
7273
|
-
const c = cos(a), s = sin(a);
|
|
7862
|
+
const c = cos(-a), s = sin(-a);
|
|
7274
7863
|
pos.set(emitter.pos.x + pos.x*c - pos.y*s,
|
|
7275
7864
|
emitter.pos.y + pos.x*s + pos.y*c);
|
|
7276
7865
|
angle += a;
|
|
@@ -7278,8 +7867,8 @@ class Particle
|
|
|
7278
7867
|
if (trailScale)
|
|
7279
7868
|
{
|
|
7280
7869
|
// trail style particles
|
|
7281
|
-
const velocity = localSpace ?
|
|
7282
|
-
this.velocity.rotate(
|
|
7870
|
+
const velocity = localSpace ?
|
|
7871
|
+
this.velocity.rotate(emitter.angle) : this.velocity;
|
|
7283
7872
|
const speed = velocity.length();
|
|
7284
7873
|
if (speed)
|
|
7285
7874
|
{
|
|
@@ -7384,6 +7973,10 @@ function glInit(rootElement)
|
|
|
7384
7973
|
for (const info of glTextureInfos)
|
|
7385
7974
|
info.glTexture = undefined;
|
|
7386
7975
|
glActiveTexture = undefined;
|
|
7976
|
+
// drop any partially-filled batch so the next glFlush doesn't
|
|
7977
|
+
// upload stale glBatchCount against fresh empty buffers on restore
|
|
7978
|
+
glBatchCount = 0;
|
|
7979
|
+
glPolyMode = false;
|
|
7387
7980
|
pluginList.forEach(plugin=>plugin.glContextLost?.());
|
|
7388
7981
|
});
|
|
7389
7982
|
glCanvas.addEventListener('webglcontextrestored', ()=>
|
|
@@ -7742,6 +8335,10 @@ function glSetTextureData(texture, image)
|
|
|
7742
8335
|
glContext.bindTexture(glContext.TEXTURE_2D, texture);
|
|
7743
8336
|
glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, image);
|
|
7744
8337
|
|
|
8338
|
+
// keep mipmaps in sync with new level 0 data (same condition as glCreateTexture)
|
|
8339
|
+
if (!tilesPixelated && isPowerOfTwo(image.width) && isPowerOfTwo(image.height))
|
|
8340
|
+
glContext.generateMipmap(glContext.TEXTURE_2D);
|
|
8341
|
+
|
|
7745
8342
|
// rebind active texture
|
|
7746
8343
|
glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
|
|
7747
8344
|
}
|
|
@@ -7787,7 +8384,7 @@ function glFlush()
|
|
|
7787
8384
|
{
|
|
7788
8385
|
if (glEnable && glContext && glBatchCount)
|
|
7789
8386
|
{
|
|
7790
|
-
// set
|
|
8387
|
+
// set blend mode
|
|
7791
8388
|
const destBlend = glBatchAdditive ? glContext.ONE : glContext.ONE_MINUS_SRC_ALPHA;
|
|
7792
8389
|
glContext.blendFuncSeparate(glContext.SRC_ALPHA, destBlend, glContext.ONE, destBlend);
|
|
7793
8390
|
glContext.enable(glContext.BLEND);
|
|
@@ -7864,9 +8461,9 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
|
|
|
7864
8461
|
}
|
|
7865
8462
|
|
|
7866
8463
|
/** Add an untextured rect to the gl draw list
|
|
7867
|
-
*
|
|
7868
|
-
*
|
|
7869
|
-
*
|
|
8464
|
+
* Zeroes the uvs and rgba so the texture contribution multiplies to 0,
|
|
8465
|
+
* then carries the real color in the additive slot. Works regardless of
|
|
8466
|
+
* which texture is currently bound.
|
|
7870
8467
|
* @param {number} x
|
|
7871
8468
|
* @param {number} y
|
|
7872
8469
|
* @param {number} sizeX
|
|
@@ -7876,52 +8473,7 @@ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgb
|
|
|
7876
8473
|
* @memberof WebGL */
|
|
7877
8474
|
function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
|
|
7878
8475
|
{
|
|
7879
|
-
|
|
7880
|
-
{
|
|
7881
|
-
// batch with surrounding polys as a 4-vertex tristrip rect
|
|
7882
|
-
const vertCount = 6; // 4 corners + 2 degenerate verts
|
|
7883
|
-
if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
|
|
7884
|
-
glFlush();
|
|
7885
|
-
|
|
7886
|
-
// compute rotated corners in world space (matches glDrawPointsTransform rotation)
|
|
7887
|
-
const hx = sizeX*.5, hy = sizeY*.5;
|
|
7888
|
-
const c = cos(angle), s = sin(angle);
|
|
7889
|
-
const chx = c*hx, shx = s*hx, chy = c*hy, shy = s*hy;
|
|
7890
|
-
const x0 = x - chx - shy, y0 = y + shx - chy; // (-hx,-hy)
|
|
7891
|
-
const x1 = x + chx - shy, y1 = y - shx - chy; // ( hx,-hy)
|
|
7892
|
-
const x2 = x - chx + shy, y2 = y + shx + chy; // (-hx, hy)
|
|
7893
|
-
const x3 = x + chx + shy, y3 = y - shx + chy; // ( hx, hy)
|
|
7894
|
-
|
|
7895
|
-
// write tristrip with leading/trailing degenerate verts
|
|
7896
|
-
let offset = glBatchCount * gl_INDICES_PER_POLY_VERTEX;
|
|
7897
|
-
glPositionData[offset++] = x0; glPositionData[offset++] = y0; glColorData[offset++] = rgba;
|
|
7898
|
-
glPositionData[offset++] = x0; glPositionData[offset++] = y0; glColorData[offset++] = rgba;
|
|
7899
|
-
glPositionData[offset++] = x1; glPositionData[offset++] = y1; glColorData[offset++] = rgba;
|
|
7900
|
-
glPositionData[offset++] = x2; glPositionData[offset++] = y2; glColorData[offset++] = rgba;
|
|
7901
|
-
glPositionData[offset++] = x3; glPositionData[offset++] = y3; glColorData[offset++] = rgba;
|
|
7902
|
-
glPositionData[offset++] = x3; glPositionData[offset++] = y3; glColorData[offset++] = rgba;
|
|
7903
|
-
glBatchCount += vertCount;
|
|
7904
|
-
return;
|
|
7905
|
-
}
|
|
7906
|
-
|
|
7907
|
-
// instanced path: zero uvs and rgba so the texture contribution is killed,
|
|
7908
|
-
// then carry the real color in the additive slot
|
|
7909
|
-
if (glBatchCount >= gl_MAX_INSTANCES || glBatchAdditive !== glAdditive)
|
|
7910
|
-
glFlush();
|
|
7911
|
-
glSetInstancedMode();
|
|
7912
|
-
|
|
7913
|
-
let offset = glBatchCount++ * gl_INDICES_PER_INSTANCE;
|
|
7914
|
-
glPositionData[offset++] = x;
|
|
7915
|
-
glPositionData[offset++] = y;
|
|
7916
|
-
glPositionData[offset++] = sizeX;
|
|
7917
|
-
glPositionData[offset++] = sizeY;
|
|
7918
|
-
glPositionData[offset++] = 0;
|
|
7919
|
-
glPositionData[offset++] = 0;
|
|
7920
|
-
glPositionData[offset++] = 0;
|
|
7921
|
-
glPositionData[offset++] = 0;
|
|
7922
|
-
glColorData[offset++] = 0;
|
|
7923
|
-
glColorData[offset++] = rgba;
|
|
7924
|
-
glPositionData[offset++] = angle;
|
|
8476
|
+
glDraw(x, y, sizeX, sizeY, angle, 0, 0, 0, 0, 0, rgba);
|
|
7925
8477
|
}
|
|
7926
8478
|
|
|
7927
8479
|
/** Transform and add a polygon to the gl draw list
|
|
@@ -7937,13 +8489,13 @@ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba)
|
|
|
7937
8489
|
function glDrawPointsTransform(points, rgba, x, y, sx, sy, angle, tristrip=true)
|
|
7938
8490
|
{
|
|
7939
8491
|
const pointsOut = [];
|
|
8492
|
+
const sa = sin(-angle);
|
|
8493
|
+
const ca = cos(-angle);
|
|
7940
8494
|
for (const p of points)
|
|
7941
8495
|
{
|
|
7942
8496
|
// transform the point
|
|
7943
8497
|
const px = p.x*sx;
|
|
7944
8498
|
const py = p.y*sy;
|
|
7945
|
-
const sa = sin(-angle);
|
|
7946
|
-
const ca = cos(-angle);
|
|
7947
8499
|
pointsOut.push(vec2(x + ca*px - sa*py, y + sa*px + ca*py));
|
|
7948
8500
|
}
|
|
7949
8501
|
const drawPoints = tristrip ? glPolyStrip(pointsOut) : pointsOut;
|
|
@@ -7975,11 +8527,13 @@ function glDrawPoints(points, rgba)
|
|
|
7975
8527
|
{
|
|
7976
8528
|
if (!glEnable || points.length < 3)
|
|
7977
8529
|
return; // needs at least 3 points to have area
|
|
7978
|
-
|
|
8530
|
+
|
|
7979
8531
|
// flush if there is not enough room or if different blend mode
|
|
7980
8532
|
const vertCount = points.length + 2;
|
|
7981
8533
|
if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
|
|
7982
8534
|
glFlush();
|
|
8535
|
+
ASSERT(vertCount < gl_MAX_POLY_VERTEXES, 'poly exceeds max batch size');
|
|
8536
|
+
if (vertCount >= gl_MAX_POLY_VERTEXES) return; // release-build safety net
|
|
7983
8537
|
glSetPolyMode();
|
|
7984
8538
|
|
|
7985
8539
|
// setup triangle strip with degenerate verts at start and end
|
|
@@ -8003,11 +8557,13 @@ function glDrawColoredPoints(points, pointColors)
|
|
|
8003
8557
|
{
|
|
8004
8558
|
if (!glEnable || points.length < 3)
|
|
8005
8559
|
return; // needs at least 3 points to have area
|
|
8006
|
-
|
|
8560
|
+
|
|
8007
8561
|
// flush if there is not enough room or if different blend mode
|
|
8008
8562
|
const vertCount = points.length + 2;
|
|
8009
8563
|
if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive)
|
|
8010
8564
|
glFlush();
|
|
8565
|
+
ASSERT(vertCount < gl_MAX_POLY_VERTEXES, 'poly exceeds max batch size');
|
|
8566
|
+
if (vertCount >= gl_MAX_POLY_VERTEXES) return; // release-build safety net
|
|
8011
8567
|
glSetPolyMode();
|
|
8012
8568
|
|
|
8013
8569
|
// setup triangle strip with degenerate verts at start and end
|
|
@@ -8077,7 +8633,8 @@ function glMakeOutline(points, width, wrap=true)
|
|
|
8077
8633
|
const strip = [];
|
|
8078
8634
|
const n = points.length;
|
|
8079
8635
|
const e = 1e-6;
|
|
8080
|
-
|
|
8636
|
+
// miter ratio cap (dimensionless, matches SVG/Canvas2D convention)
|
|
8637
|
+
const miterLimit = 10;
|
|
8081
8638
|
for (let i = 0; i < n; i++)
|
|
8082
8639
|
{
|
|
8083
8640
|
// for each vertex, calculate normal based on adjacent edges
|
|
@@ -8333,7 +8890,7 @@ function drawEngineLogo(t)
|
|
|
8333
8890
|
x.closePath();
|
|
8334
8891
|
gradient(0, Y, 0, Y+H,C);
|
|
8335
8892
|
}
|
|
8336
|
-
const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}
|
|
8893
|
+
const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%)`:'#000';
|
|
8337
8894
|
|
|
8338
8895
|
// center and fit to screen
|
|
8339
8896
|
const alpha = oscillate(1,1,t);
|
|
@@ -8473,7 +9030,15 @@ function medalsInit(saveName)
|
|
|
8473
9030
|
// check if medals are unlocked
|
|
8474
9031
|
medalsSaveName = saveName;
|
|
8475
9032
|
if (!debugMedals)
|
|
8476
|
-
|
|
9033
|
+
{
|
|
9034
|
+
let saved = {};
|
|
9035
|
+
try { saved = JSON.parse(localStorage[saveName] || '{}'); }
|
|
9036
|
+
catch (e) { saved = {}; }
|
|
9037
|
+
medalsForEach(medal => {
|
|
9038
|
+
medal.unlocked = !!(saved[medal.id] && saved[medal.id].unlocked);
|
|
9039
|
+
});
|
|
9040
|
+
medalsSave();
|
|
9041
|
+
}
|
|
8477
9042
|
|
|
8478
9043
|
// engine automatically renders medals
|
|
8479
9044
|
engineAddPlugin(undefined, medalsRender);
|
|
@@ -8517,6 +9082,31 @@ function medalsInit(saveName)
|
|
|
8517
9082
|
function medalsForEach(callback)
|
|
8518
9083
|
{ Object.values(medals).forEach(medal=>callback(medal)); }
|
|
8519
9084
|
|
|
9085
|
+
/** Reset all medals to locked and persist the cleared catalog
|
|
9086
|
+
* @memberof Medals */
|
|
9087
|
+
function medalsReset()
|
|
9088
|
+
{
|
|
9089
|
+
medalsForEach(medal => medal.unlocked = false);
|
|
9090
|
+
medalsSave();
|
|
9091
|
+
}
|
|
9092
|
+
|
|
9093
|
+
function medalsSave()
|
|
9094
|
+
{
|
|
9095
|
+
if (!medalsSaveName) return;
|
|
9096
|
+
const data = {};
|
|
9097
|
+
medalsForEach(medal => {
|
|
9098
|
+
const entry = {
|
|
9099
|
+
name: medal.name,
|
|
9100
|
+
description: medal.description,
|
|
9101
|
+
icon: medal.icon,
|
|
9102
|
+
unlocked: medal.unlocked,
|
|
9103
|
+
};
|
|
9104
|
+
if (medal.image) entry.src = medal.image.src;
|
|
9105
|
+
data[medal.id] = entry;
|
|
9106
|
+
});
|
|
9107
|
+
localStorage[medalsSaveName] = JSON.stringify(data);
|
|
9108
|
+
}
|
|
9109
|
+
|
|
8520
9110
|
///////////////////////////////////////////////////////////////////////////////
|
|
8521
9111
|
|
|
8522
9112
|
/**
|
|
@@ -8574,9 +9164,9 @@ class Medal
|
|
|
8574
9164
|
{
|
|
8575
9165
|
if (medalsPreventUnlock || this.unlocked) return;
|
|
8576
9166
|
|
|
8577
|
-
// save the medal
|
|
8578
9167
|
ASSERT(medalsSaveName, 'save name must be set');
|
|
8579
|
-
|
|
9168
|
+
this.unlocked = true;
|
|
9169
|
+
medalsSave();
|
|
8580
9170
|
medalsDisplayQueue.push(this);
|
|
8581
9171
|
}
|
|
8582
9172
|
|
|
@@ -8634,8 +9224,6 @@ class Medal
|
|
|
8634
9224
|
drawTextScreen(this.icon, pos, size*.7, BLACK);
|
|
8635
9225
|
}
|
|
8636
9226
|
|
|
8637
|
-
// Get local storage key used by the medal
|
|
8638
|
-
storageKey() { return medalsSaveName + '_' + this.id; }
|
|
8639
9227
|
}
|
|
8640
9228
|
|
|
8641
9229
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -8743,8 +9331,20 @@ class NewgroundsPlugin
|
|
|
8743
9331
|
|
|
8744
9332
|
// get medals
|
|
8745
9333
|
const medalsResult = this.call('Medal.getList');
|
|
9334
|
+
|
|
9335
|
+
// bail early if the first call failed (offline / bad session /
|
|
9336
|
+
// server error) so we don't block the main thread on more sync
|
|
9337
|
+
// XHRs that are guaranteed to also fail
|
|
9338
|
+
if (!medalsResult || !medalsResult.result || medalsResult.result.error)
|
|
9339
|
+
{
|
|
9340
|
+
debugMedals && LOG('Newgrounds session unavailable; skipping plugin init');
|
|
9341
|
+
this.medals = [];
|
|
9342
|
+
this.scoreboards = [];
|
|
9343
|
+
return;
|
|
9344
|
+
}
|
|
9345
|
+
|
|
8746
9346
|
/** @property {Array} - Medals fetched from Newgrounds (empty until session is active) */
|
|
8747
|
-
this.medals = medalsResult
|
|
9347
|
+
this.medals = medalsResult.result.data?.['medals'] || [];
|
|
8748
9348
|
debugMedals && LOG(this.medals);
|
|
8749
9349
|
for (const newgroundsMedal of this.medals)
|
|
8750
9350
|
{
|
|
@@ -8764,11 +9364,11 @@ class NewgroundsPlugin
|
|
|
8764
9364
|
medal.description = medal.description + ` (${ medal.value })`;
|
|
8765
9365
|
}
|
|
8766
9366
|
}
|
|
8767
|
-
|
|
9367
|
+
|
|
8768
9368
|
// get scoreboards
|
|
8769
9369
|
const scoreboardResult = this.call('ScoreBoard.getBoards');
|
|
8770
9370
|
/** @property {Array} - Scoreboards fetched from Newgrounds */
|
|
8771
|
-
this.scoreboards = scoreboardResult
|
|
9371
|
+
this.scoreboards = scoreboardResult?.result?.data?.scoreboards || [];
|
|
8772
9372
|
debugMedals && LOG(this.scoreboards);
|
|
8773
9373
|
|
|
8774
9374
|
// keep the session alive with a ping every minute
|
|
@@ -8952,10 +9552,14 @@ class PostProcessPlugin
|
|
|
8952
9552
|
function postProcessRender()
|
|
8953
9553
|
{
|
|
8954
9554
|
if (headlessMode || !glEnable) return;
|
|
8955
|
-
|
|
9555
|
+
|
|
8956
9556
|
// clear out the buffer
|
|
8957
9557
|
glFlush();
|
|
8958
9558
|
|
|
9559
|
+
// ensure we render to the default framebuffer (in case any earlier
|
|
9560
|
+
// caller this frame left a render target bound)
|
|
9561
|
+
glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
|
|
9562
|
+
|
|
8959
9563
|
// setup shader program to draw a quad
|
|
8960
9564
|
glContext.useProgram(postProcess.shader);
|
|
8961
9565
|
glContext.bindVertexArray(postProcess.vao);
|
|
@@ -8996,11 +9600,343 @@ class PostProcessPlugin
|
|
|
8996
9600
|
glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, glCanvas);
|
|
8997
9601
|
}
|
|
8998
9602
|
|
|
9603
|
+
// restore default so subsequent dynamic texture uploads aren't flipped
|
|
9604
|
+
glContext.pixelStorei(glContext.UNPACK_FLIP_Y_WEBGL, false);
|
|
9605
|
+
|
|
8999
9606
|
// force it to set instanced mode
|
|
9000
9607
|
glSetInstancedMode(true);
|
|
9001
9608
|
}
|
|
9002
9609
|
}
|
|
9003
9610
|
}
|
|
9611
|
+
/**
|
|
9612
|
+
* LittleJS Light System Plugin
|
|
9613
|
+
* - Adds 2D dynamic lighting to the scene
|
|
9614
|
+
* - Lights are first-class EngineObjects (the Light class)
|
|
9615
|
+
* - Each Light draws a soft falloff blob of its color into a shared lightmap
|
|
9616
|
+
* - Lights accumulate ADDITIVELY in the lightmap (red + blue = magenta)
|
|
9617
|
+
* - The lightmap is then MULTIPLIED with the scene during composite, so unlit
|
|
9618
|
+
* areas go to the ambient color and lit areas show the scene tinted by the
|
|
9619
|
+
* accumulated light color
|
|
9620
|
+
* - Draw the world at full brightness — the lightmap does the darkening
|
|
9621
|
+
* - Any EngineObject may override renderLight() to additively contribute to the
|
|
9622
|
+
* lightmap (e.g. emissive lava tiles, weapon flashes, glowing crystals)
|
|
9623
|
+
* - Must be constructed BEFORE PostProcessPlugin so post-process sees lit pixels
|
|
9624
|
+
* @namespace LightSystem
|
|
9625
|
+
*/
|
|
9626
|
+
|
|
9627
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
9628
|
+
|
|
9629
|
+
/** Global Light System plugin object
|
|
9630
|
+
* @type {LightSystemPlugin}
|
|
9631
|
+
* @memberof LightSystem */
|
|
9632
|
+
let lightSystem;
|
|
9633
|
+
|
|
9634
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
9635
|
+
|
|
9636
|
+
/**
|
|
9637
|
+
* LightSystemPlugin
|
|
9638
|
+
* - Owns the offscreen lightmap texture, falloff/composite shaders, and the
|
|
9639
|
+
* per-frame render pass that multiplies the lightmap onto the WebGL scene
|
|
9640
|
+
* - The composite is MULTIPLICATIVE: unlit areas get the ambient color, lit
|
|
9641
|
+
* areas show the scene tinted by the accumulated light color. So you should
|
|
9642
|
+
* draw your world at full brightness — the lightmap handles the darkening.
|
|
9643
|
+
* @memberof LightSystem
|
|
9644
|
+
*/
|
|
9645
|
+
class LightSystemPlugin
|
|
9646
|
+
{
|
|
9647
|
+
/** Create the global light system plugin.
|
|
9648
|
+
* @param {Vector2} [textureSize] - Size of the lightmap texture (defaults to mainCanvasSize)
|
|
9649
|
+
* @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.
|
|
9650
|
+
* @example
|
|
9651
|
+
* // simplest usage
|
|
9652
|
+
* new LightSystemPlugin();
|
|
9653
|
+
*/
|
|
9654
|
+
constructor(textureSize, ambientColor)
|
|
9655
|
+
{
|
|
9656
|
+
ASSERT(!lightSystem, 'LightSystemPlugin already initialized');
|
|
9657
|
+
ASSERT(!postProcess, 'LightSystemPlugin must be created before PostProcessPlugin');
|
|
9658
|
+
lightSystem = this;
|
|
9659
|
+
|
|
9660
|
+
/** @property {boolean} - When false, the render pass is skipped entirely */
|
|
9661
|
+
this.enabled = true;
|
|
9662
|
+
/** @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. */
|
|
9663
|
+
this.ambientColor = (ambientColor || BLACK).copy();
|
|
9664
|
+
/** @property {Vector2} - Size of the lightmap texture (set at construction; falls back to mainCanvasSize at init time) */
|
|
9665
|
+
this.textureSize = textureSize ? textureSize.copy() : undefined;
|
|
9666
|
+
|
|
9667
|
+
/** @property {WebGLTexture} - The lightmap texture */
|
|
9668
|
+
this.texture = undefined;
|
|
9669
|
+
/** @property {WebGLProgram} - Shader for drawing per-Light falloff blobs into the lightmap */
|
|
9670
|
+
this.lightShader = undefined;
|
|
9671
|
+
/** @property {WebGLProgram} - Shader for compositing the lightmap over the main scene */
|
|
9672
|
+
this.compositeShader = undefined;
|
|
9673
|
+
/** @property {WebGLVertexArrayObject} - Vertex array object for the light shader */
|
|
9674
|
+
this.lightVAO = undefined;
|
|
9675
|
+
/** @property {WebGLVertexArrayObject} - Vertex array object for the composite shader */
|
|
9676
|
+
this.compositeVAO = undefined;
|
|
9677
|
+
|
|
9678
|
+
initLightSystem();
|
|
9679
|
+
engineAddPlugin(undefined, lightSystemRender,
|
|
9680
|
+
lightSystemContextLost, lightSystemContextRestored);
|
|
9681
|
+
|
|
9682
|
+
function initLightSystem()
|
|
9683
|
+
{
|
|
9684
|
+
if (headlessMode) return;
|
|
9685
|
+
if (!glEnable)
|
|
9686
|
+
{
|
|
9687
|
+
console.warn('LightSystemPlugin: WebGL not enabled!');
|
|
9688
|
+
return;
|
|
9689
|
+
}
|
|
9690
|
+
|
|
9691
|
+
// resolve texture size default at init time (mainCanvasSize may
|
|
9692
|
+
// not be set yet at the moment the constructor first ran)
|
|
9693
|
+
if (!lightSystem.textureSize)
|
|
9694
|
+
lightSystem.textureSize = mainCanvasSize.copy();
|
|
9695
|
+
|
|
9696
|
+
// allocate the lightmap texture with null data at textureSize
|
|
9697
|
+
lightSystem.texture = glContext.createTexture();
|
|
9698
|
+
glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
|
|
9699
|
+
glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA,
|
|
9700
|
+
lightSystem.textureSize.x, lightSystem.textureSize.y, 0,
|
|
9701
|
+
glContext.RGBA, glContext.UNSIGNED_BYTE, null);
|
|
9702
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, glContext.LINEAR);
|
|
9703
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, glContext.LINEAR);
|
|
9704
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, glContext.CLAMP_TO_EDGE);
|
|
9705
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, glContext.CLAMP_TO_EDGE);
|
|
9706
|
+
|
|
9707
|
+
// light falloff shader: one quad per Light, fragment computes radial falloff
|
|
9708
|
+
lightSystem.lightShader = glCreateProgram(
|
|
9709
|
+
'#version 300 es\n' +
|
|
9710
|
+
'precision highp float;'+
|
|
9711
|
+
'uniform mat4 m;'+
|
|
9712
|
+
'uniform vec2 lightPos;'+
|
|
9713
|
+
'uniform float radius;'+
|
|
9714
|
+
'in vec2 g;'+ // unit quad geometry [0..1]
|
|
9715
|
+
'out vec2 vWorldPos;'+
|
|
9716
|
+
'void main(){'+
|
|
9717
|
+
'vec2 worldP=lightPos+(g-.5)*2.*radius;'+
|
|
9718
|
+
'gl_Position=m*vec4(worldP,1,1);'+
|
|
9719
|
+
'vWorldPos=worldP;'+
|
|
9720
|
+
'}'
|
|
9721
|
+
,
|
|
9722
|
+
'#version 300 es\n' +
|
|
9723
|
+
'precision highp float;'+
|
|
9724
|
+
'uniform vec2 lightPos;'+
|
|
9725
|
+
'uniform float radius;'+
|
|
9726
|
+
'uniform float fadeRange;'+
|
|
9727
|
+
'uniform vec4 color;'+
|
|
9728
|
+
'in vec2 vWorldPos;'+
|
|
9729
|
+
'out vec4 c;'+
|
|
9730
|
+
'void main(){'+
|
|
9731
|
+
'float dist=distance(vWorldPos,lightPos);'+
|
|
9732
|
+
'float t=clamp((radius-dist)/max(fadeRange,1e-6),0.,1.);'+
|
|
9733
|
+
'c=vec4(color.rgb*t*color.a,1.);'+
|
|
9734
|
+
'}'
|
|
9735
|
+
);
|
|
9736
|
+
|
|
9737
|
+
// composite shader: fullscreen quad, samples the lightmap
|
|
9738
|
+
lightSystem.compositeShader = glCreateProgram(
|
|
9739
|
+
'#version 300 es\n' +
|
|
9740
|
+
'precision highp float;'+
|
|
9741
|
+
'in vec2 p;'+
|
|
9742
|
+
'void main(){'+
|
|
9743
|
+
'gl_Position=vec4(p+p-1.,1,1);'+
|
|
9744
|
+
'}'
|
|
9745
|
+
,
|
|
9746
|
+
'#version 300 es\n' +
|
|
9747
|
+
'precision highp float;'+
|
|
9748
|
+
'uniform sampler2D s;'+
|
|
9749
|
+
'uniform vec3 iResolution;'+
|
|
9750
|
+
'out vec4 c;'+
|
|
9751
|
+
'void main(){'+
|
|
9752
|
+
'vec2 uv=gl_FragCoord.xy/iResolution.xy;'+
|
|
9753
|
+
'c=vec4(texture(s,uv).rgb,1.);'+
|
|
9754
|
+
'}'
|
|
9755
|
+
);
|
|
9756
|
+
|
|
9757
|
+
// VAO for the per-Light quad — reuses the engine unit triangle-strip
|
|
9758
|
+
lightSystem.lightVAO = glContext.createVertexArray();
|
|
9759
|
+
glContext.bindVertexArray(lightSystem.lightVAO);
|
|
9760
|
+
glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer);
|
|
9761
|
+
const gLight = glContext.getAttribLocation(lightSystem.lightShader, 'g');
|
|
9762
|
+
glContext.enableVertexAttribArray(gLight);
|
|
9763
|
+
glContext.vertexAttribPointer(gLight, 2, glContext.FLOAT, false, 8, 0);
|
|
9764
|
+
|
|
9765
|
+
// VAO for the composite fullscreen quad — same buffer, attribute named 'p'
|
|
9766
|
+
lightSystem.compositeVAO = glContext.createVertexArray();
|
|
9767
|
+
glContext.bindVertexArray(lightSystem.compositeVAO);
|
|
9768
|
+
glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer);
|
|
9769
|
+
const pComp = glContext.getAttribLocation(lightSystem.compositeShader, 'p');
|
|
9770
|
+
glContext.enableVertexAttribArray(pComp);
|
|
9771
|
+
glContext.vertexAttribPointer(pComp, 2, glContext.FLOAT, false, 8, 0);
|
|
9772
|
+
}
|
|
9773
|
+
function lightSystemRender()
|
|
9774
|
+
{
|
|
9775
|
+
if (headlessMode || !glEnable) return;
|
|
9776
|
+
if (!lightSystem.enabled) return;
|
|
9777
|
+
if (!lightSystem.texture) return; // init failed or context lost
|
|
9778
|
+
|
|
9779
|
+
// 1. flush any in-flight sprite batch from earlier render passes
|
|
9780
|
+
glFlush();
|
|
9781
|
+
const prevAdditive = glAdditive;
|
|
9782
|
+
|
|
9783
|
+
// 2. bind lightmap as render target, clear to ambientColor
|
|
9784
|
+
const ac = lightSystem.ambientColor;
|
|
9785
|
+
glContext.bindFramebuffer(glContext.FRAMEBUFFER, glFramebuffer);
|
|
9786
|
+
glContext.framebufferTexture2D(glContext.FRAMEBUFFER,
|
|
9787
|
+
glContext.COLOR_ATTACHMENT0, glContext.TEXTURE_2D, lightSystem.texture, 0);
|
|
9788
|
+
glContext.viewport(0, 0, lightSystem.textureSize.x, lightSystem.textureSize.y);
|
|
9789
|
+
glContext.clearColor(ac.r, ac.g, ac.b, ac.a);
|
|
9790
|
+
glContext.clear(glContext.COLOR_BUFFER_BIT);
|
|
9791
|
+
|
|
9792
|
+
// 3. walk engineObjects calling renderLight() — additive blend
|
|
9793
|
+
// (lightmap accumulates raw additive color contributions)
|
|
9794
|
+
setBlendMode(true);
|
|
9795
|
+
glContext.enable(glContext.BLEND);
|
|
9796
|
+
glContext.blendFunc(glContext.ONE, glContext.ONE);
|
|
9797
|
+
|
|
9798
|
+
for (const o of engineObjects)
|
|
9799
|
+
o.destroyed || o.renderLight();
|
|
9800
|
+
|
|
9801
|
+
// 4. drain any sprite-batched draws (e.g. drawTile inside a
|
|
9802
|
+
// custom renderLight override) so they hit the FBO, not the
|
|
9803
|
+
// canvas after we unbind
|
|
9804
|
+
glFlush();
|
|
9805
|
+
glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
|
|
9806
|
+
glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
|
|
9807
|
+
|
|
9808
|
+
// 5. composite: fullscreen quad, multiplicative blend onto glCanvas
|
|
9809
|
+
// (scene * lightmap — unlit areas go to black, lit areas are
|
|
9810
|
+
// the scene tinted by the accumulated light color)
|
|
9811
|
+
glContext.useProgram(lightSystem.compositeShader);
|
|
9812
|
+
glContext.bindVertexArray(lightSystem.compositeVAO);
|
|
9813
|
+
glContext.activeTexture(glContext.TEXTURE0);
|
|
9814
|
+
glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
|
|
9815
|
+
const cs = lightSystem.compositeShader;
|
|
9816
|
+
glContext.uniform1i(glContext.getUniformLocation(cs, 's'), 0);
|
|
9817
|
+
glContext.uniform3f(glContext.getUniformLocation(cs, 'iResolution'),
|
|
9818
|
+
mainCanvas.width, mainCanvas.height, 1);
|
|
9819
|
+
glContext.blendFunc(glContext.DST_COLOR, glContext.ZERO);
|
|
9820
|
+
glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
|
|
9821
|
+
|
|
9822
|
+
// 6. restore engine state so subsequent draws use the engine's
|
|
9823
|
+
// tracked texture binding (otherwise glSetTexture would think
|
|
9824
|
+
// the prior texture was still bound when actually the lightmap
|
|
9825
|
+
// is, and any debug text / future draw could sample the lightmap)
|
|
9826
|
+
if (glActiveTexture)
|
|
9827
|
+
glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
|
|
9828
|
+
setBlendMode(prevAdditive);
|
|
9829
|
+
glSetInstancedMode(true);
|
|
9830
|
+
}
|
|
9831
|
+
function lightSystemContextLost()
|
|
9832
|
+
{
|
|
9833
|
+
lightSystem.texture = undefined;
|
|
9834
|
+
lightSystem.lightShader = undefined;
|
|
9835
|
+
lightSystem.compositeShader = undefined;
|
|
9836
|
+
lightSystem.lightVAO = undefined;
|
|
9837
|
+
lightSystem.compositeVAO = undefined;
|
|
9838
|
+
LOG('LightSystemPlugin: WebGL context lost');
|
|
9839
|
+
}
|
|
9840
|
+
function lightSystemContextRestored()
|
|
9841
|
+
{
|
|
9842
|
+
initLightSystem();
|
|
9843
|
+
LOG('LightSystemPlugin: WebGL context restored');
|
|
9844
|
+
}
|
|
9845
|
+
}
|
|
9846
|
+
|
|
9847
|
+
/** Draw a single Light's falloff blob into the currently bound lightmap.
|
|
9848
|
+
* Called by Light.renderLight() during the plugin's render pass.
|
|
9849
|
+
* @param {Light} light */
|
|
9850
|
+
drawLight(light)
|
|
9851
|
+
{
|
|
9852
|
+
if (headlessMode || !glEnable || !this.lightShader) return;
|
|
9853
|
+
|
|
9854
|
+
// drain any sprite-batched draws queued by a previous custom
|
|
9855
|
+
// renderLight() override (e.g. drawRect inside a LavaTile). They were
|
|
9856
|
+
// queued in the engine's instanced-vertex format and must flush with
|
|
9857
|
+
// the engine's shader+VAO bound — NOT this plugin's light shader.
|
|
9858
|
+
glFlush();
|
|
9859
|
+
|
|
9860
|
+
glContext.useProgram(this.lightShader);
|
|
9861
|
+
glContext.bindVertexArray(this.lightVAO);
|
|
9862
|
+
|
|
9863
|
+
// re-apply the engine camera transform onto this shader. Divide by
|
|
9864
|
+
// mainCanvasSize (not textureSize) so world→NDC matches the main
|
|
9865
|
+
// pass; the viewport handles the lightmap's actual resolution.
|
|
9866
|
+
// No y-flip here: the composite samples this FBO with
|
|
9867
|
+
// gl_FragCoord/iResolution (origin bottom-left), so storing world
|
|
9868
|
+
// +Y at the top of the texture lines up with the canvas convention.
|
|
9869
|
+
const s = vec2(2*cameraScale).divide(mainCanvasSize);
|
|
9870
|
+
const rotatedCam = cameraPos.rotate(-cameraAngle);
|
|
9871
|
+
const p = vec2(-1).subtract(rotatedCam.multiply(s));
|
|
9872
|
+
const ca = cos(cameraAngle);
|
|
9873
|
+
const sa = sin(cameraAngle);
|
|
9874
|
+
const transform = [
|
|
9875
|
+
s.x * ca, s.y * sa, 0, 0,
|
|
9876
|
+
-s.x * sa, s.y * ca, 0, 0,
|
|
9877
|
+
1, 1, 1, 0,
|
|
9878
|
+
p.x, p.y, 0, 1];
|
|
9879
|
+
|
|
9880
|
+
const ls = this.lightShader;
|
|
9881
|
+
glContext.uniformMatrix4fv(glContext.getUniformLocation(ls, 'm'), false, transform);
|
|
9882
|
+
glContext.uniform2f(glContext.getUniformLocation(ls, 'lightPos'), light.pos.x, light.pos.y);
|
|
9883
|
+
glContext.uniform1f(glContext.getUniformLocation(ls, 'radius'), light.radius);
|
|
9884
|
+
glContext.uniform1f(glContext.getUniformLocation(ls, 'fadeRange'), light.fadeRange);
|
|
9885
|
+
const c = light.color;
|
|
9886
|
+
glContext.uniform4f(glContext.getUniformLocation(ls, 'color'), c.r, c.g, c.b, c.a);
|
|
9887
|
+
|
|
9888
|
+
glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
|
|
9889
|
+
|
|
9890
|
+
// restore engine's instanced shader+VAO so subsequent renderLight()
|
|
9891
|
+
// overrides that batch through drawRect/drawTile work correctly
|
|
9892
|
+
glSetInstancedMode(true);
|
|
9893
|
+
}
|
|
9894
|
+
}
|
|
9895
|
+
|
|
9896
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
9897
|
+
|
|
9898
|
+
/**
|
|
9899
|
+
* A Light is an EngineObject that contributes a soft additive blob of color
|
|
9900
|
+
* to the LightSystem plugin's lightmap.
|
|
9901
|
+
* @extends EngineObject
|
|
9902
|
+
* @memberof LightSystem
|
|
9903
|
+
* @example
|
|
9904
|
+
* new Light(vec2(5, 5), 4, rgb(1, 0.5, 0)); // orange light, full soft blob
|
|
9905
|
+
* new Light(vec2(0, 0), 8, rgb(1, 1, 1), 2); // white core with 2-unit soft halo
|
|
9906
|
+
*/
|
|
9907
|
+
class Light extends EngineObject
|
|
9908
|
+
{
|
|
9909
|
+
/** Create a light object and add it to the engine object list
|
|
9910
|
+
* @param {Vector2} pos - World space position
|
|
9911
|
+
* @param {number} radius - Total extent of the light in world units
|
|
9912
|
+
* @param {Color} [color] - Color of the light; alpha modulates intensity
|
|
9913
|
+
* @param {number} [fadeRange] - Width of the soft edge in world units (defaults to radius) */
|
|
9914
|
+
constructor(pos, radius, color, fadeRange)
|
|
9915
|
+
{
|
|
9916
|
+
super(pos, vec2(1), undefined, 0, color);
|
|
9917
|
+
ASSERT(isNumber(radius) && radius >= 0, 'Light radius must be a non-negative number');
|
|
9918
|
+
ASSERT(fadeRange === undefined || (isNumber(fadeRange) && fadeRange >= 0),
|
|
9919
|
+
'Light fadeRange must be a non-negative number when provided');
|
|
9920
|
+
|
|
9921
|
+
/** @property {number} - Total extent of the light in world units */
|
|
9922
|
+
this.radius = radius;
|
|
9923
|
+
/** @property {number} - Width of the soft edge in world units */
|
|
9924
|
+
this.fadeRange = fadeRange === undefined ? radius : fadeRange;
|
|
9925
|
+
}
|
|
9926
|
+
|
|
9927
|
+
/** Lights are invisible in the main render pass — they only contribute
|
|
9928
|
+
* to the lightmap via renderLight(). */
|
|
9929
|
+
render() {}
|
|
9930
|
+
|
|
9931
|
+
/** Draw this light's falloff blob into the lightmap.
|
|
9932
|
+
* Called by LightSystemPlugin during its render pass. No-op when the
|
|
9933
|
+
* plugin or WebGL is unavailable. */
|
|
9934
|
+
renderLight()
|
|
9935
|
+
{
|
|
9936
|
+
lightSystem && lightSystem.drawLight(this);
|
|
9937
|
+
}
|
|
9938
|
+
}
|
|
9939
|
+
|
|
9004
9940
|
/**
|
|
9005
9941
|
* LittleJS ZzFXM Plugin
|
|
9006
9942
|
* @namespace ZzFXM
|
|
@@ -9622,11 +10558,17 @@ class UISystemPlugin
|
|
|
9622
10558
|
* @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */
|
|
9623
10559
|
setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver)
|
|
9624
10560
|
{
|
|
9625
|
-
|
|
10561
|
+
// remove any prior listeners so repeated setup calls don't stack
|
|
10562
|
+
if (this._dragListeners)
|
|
10563
|
+
for (const [type, listener] of this._dragListeners)
|
|
10564
|
+
document.removeEventListener(type, listener);
|
|
10565
|
+
this._dragListeners = [];
|
|
10566
|
+
const setCallback = (callback, listenerType)=>
|
|
9626
10567
|
{
|
|
9627
|
-
|
|
10568
|
+
const listener = (e)=> { e.preventDefault(); callback && callback(e); };
|
|
9628
10569
|
document.addEventListener(listenerType, listener);
|
|
9629
|
-
|
|
10570
|
+
this._dragListeners.push([listenerType, listener]);
|
|
10571
|
+
};
|
|
9630
10572
|
setCallback(onDrop, 'drop');
|
|
9631
10573
|
setCallback(onDragEnter, 'dragenter');
|
|
9632
10574
|
setCallback(onDragLeave, 'dragleave');
|
|
@@ -9950,6 +10892,15 @@ class UIObject
|
|
|
9950
10892
|
if (this.destroyed)
|
|
9951
10893
|
return;
|
|
9952
10894
|
|
|
10895
|
+
// clear ui-system references that point at this object so events
|
|
10896
|
+
// don't keep firing against a destroyed target (especially the
|
|
10897
|
+
// keydown listener attached for keyInputObject)
|
|
10898
|
+
if (uiSystem.activeObject === this) uiSystem.activeObject = undefined;
|
|
10899
|
+
if (uiSystem.hoverObject === this) uiSystem.hoverObject = undefined;
|
|
10900
|
+
if (uiSystem.lastHoverObject === this) uiSystem.lastHoverObject = undefined;
|
|
10901
|
+
if (uiSystem.navigationObject === this) uiSystem.navigationObject = undefined;
|
|
10902
|
+
if (uiSystem.keyInputObject === this) uiSystem.keyInputObject = undefined;
|
|
10903
|
+
|
|
9953
10904
|
// disconnect from parent and destroy children
|
|
9954
10905
|
this.destroyed = 1;
|
|
9955
10906
|
this.parent?.removeChild(this);
|
|
@@ -9958,6 +10909,8 @@ class UIObject
|
|
|
9958
10909
|
child.parent = undefined;
|
|
9959
10910
|
child.destroy();
|
|
9960
10911
|
}
|
|
10912
|
+
// clear references so destroyed children can be GC'd
|
|
10913
|
+
this.children.length = 0;
|
|
9961
10914
|
}
|
|
9962
10915
|
|
|
9963
10916
|
/** Check if the mouse is overlapping this ui object
|
|
@@ -10547,6 +11500,7 @@ class UISlider extends UIObject
|
|
|
10547
11500
|
{
|
|
10548
11501
|
// toggle value between 0 and 1
|
|
10549
11502
|
this.value = this.value ? 0 : 1;
|
|
11503
|
+
this.onChange();
|
|
10550
11504
|
this.onRelease();
|
|
10551
11505
|
super.navigatePressed();
|
|
10552
11506
|
}
|
|
@@ -10906,6 +11860,11 @@ class Box2dObject extends EngineObject
|
|
|
10906
11860
|
// destroy physics body, fixtures, and joints
|
|
10907
11861
|
ASSERT(this.body, 'Box2dObject has no body to destroy');
|
|
10908
11862
|
box2d.world.DestroyBody(this.body);
|
|
11863
|
+
|
|
11864
|
+
// remove from tracked list so paused / headless sessions don't leak
|
|
11865
|
+
const i = box2d.objects.indexOf(this);
|
|
11866
|
+
if (i >= 0)
|
|
11867
|
+
box2d.objects.splice(i, 1);
|
|
10909
11868
|
super.destroy();
|
|
10910
11869
|
}
|
|
10911
11870
|
|
|
@@ -10994,7 +11953,9 @@ class Box2dObject extends EngineObject
|
|
|
10994
11953
|
/** Add a box shape to the body
|
|
10995
11954
|
* @param {Vector2} [size]
|
|
10996
11955
|
* @param {Vector2} [offset]
|
|
10997
|
-
* @param {number} [angle]
|
|
11956
|
+
* @param {number} [angle] - LittleJS convention (clockwise positive).
|
|
11957
|
+
* Negated internally to match Box2D's CCW-positive convention so the
|
|
11958
|
+
* fixture aligns with the same angle passed to drawRect/drawTile.
|
|
10998
11959
|
* @param {number} [density]
|
|
10999
11960
|
* @param {number} [friction]
|
|
11000
11961
|
* @param {number} [restitution]
|
|
@@ -11007,7 +11968,7 @@ class Box2dObject extends EngineObject
|
|
|
11007
11968
|
ASSERT(isNumber(angle), 'angle must be a number');
|
|
11008
11969
|
|
|
11009
11970
|
const shape = new box2d.instance.b2PolygonShape();
|
|
11010
|
-
shape.SetAsBox(size.x/2, size.y/2, box2d.vec2dTo(offset), angle);
|
|
11971
|
+
shape.SetAsBox(size.x/2, size.y/2, box2d.vec2dTo(offset), -angle);
|
|
11011
11972
|
return this.addShape(shape, density, friction, restitution, isSensor);
|
|
11012
11973
|
}
|
|
11013
11974
|
|
|
@@ -11023,23 +11984,19 @@ class Box2dObject extends EngineObject
|
|
|
11023
11984
|
|
|
11024
11985
|
function box2dCreatePolygonShape(points)
|
|
11025
11986
|
{
|
|
11026
|
-
|
|
11987
|
+
ASSERT(3 <= points.length && points.length <= 8);
|
|
11988
|
+
const buffer = box2d.instance._malloc(points.length * 8);
|
|
11989
|
+
for (let i=0, offset=0; i<points.length; ++i)
|
|
11027
11990
|
{
|
|
11028
|
-
|
|
11029
|
-
|
|
11030
|
-
|
|
11031
|
-
|
|
11032
|
-
offset += 4;
|
|
11033
|
-
box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].y;
|
|
11034
|
-
offset += 4;
|
|
11035
|
-
}
|
|
11036
|
-
return box2d.instance.wrapPointer(buffer, box2d.instance.b2Vec2);
|
|
11991
|
+
box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].x;
|
|
11992
|
+
offset += 4;
|
|
11993
|
+
box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].y;
|
|
11994
|
+
offset += 4;
|
|
11037
11995
|
}
|
|
11038
|
-
|
|
11039
|
-
ASSERT(3 <= points.length && points.length <= 8);
|
|
11996
|
+
const box2dPoints = box2d.instance.wrapPointer(buffer, box2d.instance.b2Vec2);
|
|
11040
11997
|
const shape = new box2d.instance.b2PolygonShape();
|
|
11041
|
-
const box2dPoints = box2dCreatePointList(points);
|
|
11042
11998
|
shape.Set(box2dPoints, points.length);
|
|
11999
|
+
box2d.instance._free(buffer);
|
|
11043
12000
|
return shape;
|
|
11044
12001
|
}
|
|
11045
12002
|
|
|
@@ -11309,9 +12266,10 @@ class Box2dObject extends EngineObject
|
|
|
11309
12266
|
{
|
|
11310
12267
|
const data = new box2d.instance.b2MassData();
|
|
11311
12268
|
this.body.GetMassData(data);
|
|
11312
|
-
|
|
11313
|
-
|
|
11314
|
-
|
|
12269
|
+
// use !== undefined so setMass(0) (static-equivalent) isn't silently ignored
|
|
12270
|
+
if (localCenter !== undefined) data.set_center(box2d.vec2dTo(localCenter));
|
|
12271
|
+
if (mass !== undefined) data.set_mass(mass);
|
|
12272
|
+
if (momentOfInertia !== undefined) data.set_I(momentOfInertia);
|
|
11315
12273
|
this.body.SetMassData(data);
|
|
11316
12274
|
}
|
|
11317
12275
|
|
|
@@ -12483,6 +13441,8 @@ class Box2dPlugin
|
|
|
12483
13441
|
const fixtureB = contact.GetFixtureB();
|
|
12484
13442
|
const objectA = fixtureA.GetBody().object;
|
|
12485
13443
|
const objectB = fixtureB.GetBody().object;
|
|
13444
|
+
// raw user-created b2Bodies may have no .object — skip those
|
|
13445
|
+
if (!objectA || !objectB) return;
|
|
12486
13446
|
objectA.beginContact(objectB);
|
|
12487
13447
|
objectB.beginContact(objectA);
|
|
12488
13448
|
}
|
|
@@ -12493,6 +13453,7 @@ class Box2dPlugin
|
|
|
12493
13453
|
const fixtureB = contact.GetFixtureB();
|
|
12494
13454
|
const objectA = fixtureA.GetBody().object;
|
|
12495
13455
|
const objectB = fixtureB.GetBody().object;
|
|
13456
|
+
if (!objectA || !objectB) return;
|
|
12496
13457
|
objectA.endContact(objectB);
|
|
12497
13458
|
objectB.endContact(objectA);
|
|
12498
13459
|
};
|
|
@@ -12871,7 +13832,7 @@ async function box2dInit()
|
|
|
12871
13832
|
debugDraw.DrawTransform = function(transform)
|
|
12872
13833
|
{
|
|
12873
13834
|
transform = box2d.instance.wrapPointer(transform, box2d.instance.b2Transform);
|
|
12874
|
-
const pos =
|
|
13835
|
+
const pos = box2d.vec2From(transform.get_p());
|
|
12875
13836
|
const angle = -transform.get_q().GetAngle();
|
|
12876
13837
|
const p1 = vec2(1,0), c1 = rgb(.75,0,0,.8);
|
|
12877
13838
|
const p2 = vec2(0,1), c2 = rgb(0,.75,0,.8);
|
|
@@ -13016,6 +13977,69 @@ function drawThreeSlice(pos, size, startTile, color, borderSize=1, additiveColor
|
|
|
13016
13977
|
const cornerPos = cornerOffset.multiply(vec2(flipX?-1:1, flipY?-flip:flip));
|
|
13017
13978
|
drawTile(pos.add(cornerPos.rotate(rotateAngle)), cornerSize, cornerTile, color, a, false, additiveColor, useWebGL, screenSpace, context);
|
|
13018
13979
|
}
|
|
13980
|
+
}
|
|
13981
|
+
|
|
13982
|
+
/** Draw a crescent / moon-phase shape built from a polygon
|
|
13983
|
+
* Routes through drawPoly, so it supports WebGL, screen space, color, and outlines
|
|
13984
|
+
* @param {Vector2} pos - Center position
|
|
13985
|
+
* @param {number} [size] - Diameter
|
|
13986
|
+
* @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
|
|
13987
|
+
* @param {Color} [color] - Fill color
|
|
13988
|
+
* @param {number} [angle] - Angle to rotate by
|
|
13989
|
+
* @param {boolean} [invert] - Flip which side is illuminated
|
|
13990
|
+
* @param {number} [lineWidth] - Outline width, 0 for no outline
|
|
13991
|
+
* @param {Color} [lineColor] - Outline color
|
|
13992
|
+
* @param {boolean} [useWebGL=glEnable] - Use WebGL for rendering
|
|
13993
|
+
* @param {boolean} [screenSpace] - Use screen space coordinates
|
|
13994
|
+
* @param {CanvasRenderingContext2D} [context] - Canvas context to use
|
|
13995
|
+
* @memberof DrawUtilities */
|
|
13996
|
+
function drawCrescent(pos, size=1, percent=0, color=WHITE, angle=0, invert=false, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
|
|
13997
|
+
{
|
|
13998
|
+
const points = getCrescentPoints(pos, size, percent, angle, invert);
|
|
13999
|
+
drawPoly(points, color, lineWidth, lineColor, vec2(), 0, useWebGL, screenSpace, context);
|
|
14000
|
+
}
|
|
14001
|
+
|
|
14002
|
+
/** Get the list of points that make up a crescent / moon-phase shape
|
|
14003
|
+
* Returns world-space points with pos and angle baked in, ready for drawPoly or other use
|
|
14004
|
+
* @param {Vector2} pos - Center position
|
|
14005
|
+
* @param {number} [size] - Diameter
|
|
14006
|
+
* @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
|
|
14007
|
+
* @param {number} [angle] - Angle to rotate by
|
|
14008
|
+
* @param {boolean} [invert] - Flip which side is illuminated
|
|
14009
|
+
* @param {number} [sides=glCircleSides] - Number of sides for a full circle (halved per arc)
|
|
14010
|
+
* @return {Array<Vector2>} - List of points making up the crescent
|
|
14011
|
+
* @memberof DrawUtilities */
|
|
14012
|
+
function getCrescentPoints(pos, size=1, percent=0, angle=0, invert=false, sides=glCircleSides)
|
|
14013
|
+
{
|
|
14014
|
+
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
14015
|
+
ASSERT(isNumber(size) && isNumber(percent), 'size and percent must be numbers');
|
|
14016
|
+
|
|
14017
|
+
// map phase to a signed terminator curve: -1 new, 0 half, 1 full
|
|
14018
|
+
let p = mod(percent*4, 4); // quarter phase 0..4
|
|
14019
|
+
if (p >= 2) // second half of cycle flips orientation
|
|
14020
|
+
angle += PI;
|
|
14021
|
+
p = p <= 2 ? p-1 : 3-p;
|
|
14022
|
+
if (invert) // flip the illuminated side
|
|
14023
|
+
{
|
|
14024
|
+
p = -p;
|
|
14025
|
+
angle += PI;
|
|
14026
|
+
}
|
|
14027
|
+
|
|
14028
|
+
// build the crescent: outer semicircle, then inner half-ellipse traced back
|
|
14029
|
+
const points = [];
|
|
14030
|
+
const segs = max(3, sides>>1);
|
|
14031
|
+
const radius = size/2;
|
|
14032
|
+
for (let i=0; i<=segs; i++)
|
|
14033
|
+
{
|
|
14034
|
+
const t = i/segs*PI;
|
|
14035
|
+
points.push(vec2(radius*cos(t), radius*sin(t)).rotate(angle).add(pos));
|
|
14036
|
+
}
|
|
14037
|
+
for (let i=segs; i>=0; i--)
|
|
14038
|
+
{
|
|
14039
|
+
const t = i/segs*PI;
|
|
14040
|
+
points.push(vec2(radius*cos(t), -radius*p*sin(t)).rotate(angle).add(pos));
|
|
14041
|
+
}
|
|
14042
|
+
return points;
|
|
13019
14043
|
}
|
|
13020
14044
|
/**
|
|
13021
14045
|
* LittleJS Tween System Plugin
|
|
@@ -13275,7 +14299,7 @@ const Ease =
|
|
|
13275
14299
|
* @param {number} x
|
|
13276
14300
|
* @returns {number}
|
|
13277
14301
|
* @memberof TweenSystem */
|
|
13278
|
-
EXPO: (x) => 2 ** (10 * x - 10),
|
|
14302
|
+
EXPO: (x) => x === 0 ? 0 : 2 ** (10 * x - 10),
|
|
13279
14303
|
|
|
13280
14304
|
/** Back ease-in: overshoots backward at the start before snapping forward.
|
|
13281
14305
|
* @param {number} x
|
|
@@ -13288,6 +14312,8 @@ const Ease =
|
|
|
13288
14312
|
* @returns {number}
|
|
13289
14313
|
* @memberof TweenSystem */
|
|
13290
14314
|
ELASTIC: (x) =>
|
|
14315
|
+
x === 0 ? 0 :
|
|
14316
|
+
x === 1 ? 1 :
|
|
13291
14317
|
-(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
|
|
13292
14318
|
|
|
13293
14319
|
/** Spring-like ease-out: oscillates outward after passing the target.
|
|
@@ -13448,29 +14474,32 @@ function tweenProperty(target, propertyPath, start, end, duration = 1, options =
|
|
|
13448
14474
|
}
|
|
13449
14475
|
|
|
13450
14476
|
// Continuation that schedules the next loop iteration when one finishes.
|
|
13451
|
-
//
|
|
13452
|
-
//
|
|
13453
|
-
|
|
13454
|
-
|
|
13455
|
-
|
|
13456
|
-
|
|
13457
|
-
|
|
13458
|
-
|
|
13459
|
-
|
|
13460
|
-
|
|
13461
|
-
|
|
13462
|
-
|
|
13463
|
-
|
|
13464
|
-
|
|
13465
|
-
|
|
13466
|
-
|
|
13467
|
-
|
|
13468
|
-
|
|
13469
|
-
|
|
13470
|
-
|
|
13471
|
-
|
|
13472
|
-
|
|
13473
|
-
|
|
14477
|
+
// Reuses the same Tween object across iterations so the user's handle
|
|
14478
|
+
// from `.loop()` keeps working — calling `.stop()` mid-loop now cancels
|
|
14479
|
+
// the entire chain instead of just the current iteration.
|
|
14480
|
+
function loopContinuation(tween)
|
|
14481
|
+
{
|
|
14482
|
+
if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
|
|
14483
|
+
if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
|
|
14484
|
+
tween.life = tween.duration;
|
|
14485
|
+
tween.thenCallback = () => loopContinuation(tween);
|
|
14486
|
+
tweenActive.push(tween);
|
|
14487
|
+
// snap to start for the new iteration (matches Tween constructor behavior)
|
|
14488
|
+
tween.callback(tween.interp(tween.duration));
|
|
14489
|
+
}
|
|
14490
|
+
|
|
14491
|
+
// Continuation for pingPong: swaps start and end on the same tween each iteration.
|
|
14492
|
+
function pingPongContinuation(tween)
|
|
14493
|
+
{
|
|
14494
|
+
if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
|
|
14495
|
+
if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
|
|
14496
|
+
const tmp = tween.start;
|
|
14497
|
+
tween.start = tween.end;
|
|
14498
|
+
tween.end = tmp;
|
|
14499
|
+
tween.life = tween.duration;
|
|
14500
|
+
tween.thenCallback = () => pingPongContinuation(tween);
|
|
14501
|
+
tweenActive.push(tween);
|
|
14502
|
+
tween.callback(tween.interp(tween.duration));
|
|
13474
14503
|
}
|
|
13475
14504
|
|
|
13476
14505
|
/** Engine plugin hook: advance every active tween by the appropriate delta.
|
|
@@ -13804,11 +14833,13 @@ class PathFinder
|
|
|
13804
14833
|
if (dx !== 0 && dy !== 0)
|
|
13805
14834
|
{
|
|
13806
14835
|
// Diagonal step: refuse if either cardinal neighbor is
|
|
13807
|
-
// blocked
|
|
14836
|
+
// blocked. Prevents cutting through walls at corners.
|
|
14837
|
+
// (Costed-but-walkable cardinals do not block — diagonal
|
|
14838
|
+
// movement around expensive terrain is standard A*.)
|
|
13808
14839
|
const card1 = this.getNode(current.pos.x + dx, current.pos.y);
|
|
13809
|
-
if (!card1 ||
|
|
14840
|
+
if (!card1 || !card1.walkable) continue;
|
|
13810
14841
|
const card2 = this.getNode(current.pos.x, current.pos.y + dy);
|
|
13811
|
-
if (!card2 ||
|
|
14842
|
+
if (!card2 || !card2.walkable) continue;
|
|
13812
14843
|
stepCost = PATHFINDER_DIAGONAL_COST;
|
|
13813
14844
|
}
|
|
13814
14845
|
|