littlejsengine 1.18.15 → 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/README.md +4 -4
- package/dist/littlejs.d.ts +198 -17
- package/dist/littlejs.esm.js +1180 -257
- package/dist/littlejs.esm.min.js +1 -1
- package/dist/littlejs.js +1160 -257
- package/dist/littlejs.min.js +1 -1
- package/dist/littlejs.release.js +1134 -241
- package/package.json +1 -1
- package/plugins/box2d.js +9 -13
- package/plugins/drawUtilities.js +63 -0
- package/plugins/lightSystem.js +330 -0
- package/plugins/medalSystem.js +36 -5
- package/plugins/pluginExport.js +8 -0
- package/src/engine.js +1 -1
- package/src/engineAudio.js +21 -3
- package/src/engineBuild.mjs +1 -0
- package/src/engineDebug.js +27 -16
- package/src/engineDraw.js +9 -6
- package/src/engineExport.js +12 -0
- package/src/engineInput.js +528 -189
- package/src/engineObject.js +5 -2
- package/src/engineParticles.js +19 -15
- package/src/engineRelease.js +1 -0
- package/src/engineSettings.js +111 -10
- package/src/engineTileLayer.js +5 -0
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}
|
|
@@ -596,6 +596,7 @@ const debugPhysics = 0;
|
|
|
596
596
|
const debugParticles = 0;
|
|
597
597
|
const debugRaycast = 0;
|
|
598
598
|
const debugGamepads = 0;
|
|
599
|
+
const debugSound = 0;
|
|
599
600
|
const debugPointSize = .5;
|
|
600
601
|
|
|
601
602
|
// debug commands are automatically removed from the final build
|
|
@@ -2297,35 +2298,78 @@ let touchInputEnable = true;
|
|
|
2297
2298
|
* - Supports left analog stick, 4 face buttons and start button (button 9)
|
|
2298
2299
|
* - setTouchGamepadButtonCount(1) to use face buttons as right analog stick
|
|
2299
2300
|
* - Analog stick buttons 10 and 11 are also activated when virtual sticks are touched
|
|
2300
|
-
|
|
2301
|
+
* - Rendered as a full-viewport HTML/SVG overlay, so controls may sit outside the game canvas
|
|
2301
2302
|
* @type {boolean}
|
|
2302
2303
|
* @default
|
|
2303
2304
|
* @memberof Settings */
|
|
2304
2305
|
let touchGamepadEnable = false;
|
|
2305
2306
|
|
|
2306
|
-
/** True if
|
|
2307
|
-
* -
|
|
2308
|
-
*
|
|
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
|
|
2309
2318
|
* - When the game is paused, any touch will press the button
|
|
2310
|
-
* -
|
|
2319
|
+
* - Measured in viewport CSS pixels
|
|
2311
2320
|
* @type {number}
|
|
2312
2321
|
* @default
|
|
2313
2322
|
* @memberof Settings */
|
|
2314
|
-
let touchGamepadCenterButtonSize =
|
|
2323
|
+
let touchGamepadCenterButtonSize = 0;
|
|
2315
2324
|
|
|
2316
|
-
/** 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)
|
|
2317
2328
|
* @type {number}
|
|
2318
2329
|
* @default
|
|
2319
2330
|
* @memberof Settings */
|
|
2320
2331
|
let touchGamepadButtonCount = 4;
|
|
2321
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
|
+
|
|
2322
2356
|
/** True if touch gamepad should be analog stick or false to use if 8 way dpad
|
|
2323
2357
|
* @type {boolean}
|
|
2324
2358
|
* @default
|
|
2325
2359
|
* @memberof Settings */
|
|
2326
2360
|
let touchGamepadAnalog = true;
|
|
2327
2361
|
|
|
2328
|
-
/**
|
|
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
|
|
2329
2373
|
* @type {number}
|
|
2330
2374
|
* @default
|
|
2331
2375
|
* @memberof Settings */
|
|
@@ -2343,6 +2387,13 @@ let touchGamepadAlpha = .3;
|
|
|
2343
2387
|
* @memberof Settings */
|
|
2344
2388
|
let touchGamepadDisplayTime = 3;
|
|
2345
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
|
+
|
|
2346
2397
|
/** Allow vibration hardware if it exists
|
|
2347
2398
|
* @type {boolean}
|
|
2348
2399
|
* @default
|
|
@@ -2575,6 +2626,11 @@ function setTouchInputEnable(enable) { touchInputEnable = enable; }
|
|
|
2575
2626
|
* @memberof Settings */
|
|
2576
2627
|
function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
|
|
2577
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
|
+
|
|
2578
2634
|
/** Set if touch gamepad should have start button in the center
|
|
2579
2635
|
* - Set size to enable the center button
|
|
2580
2636
|
* - When the game is paused, any touch will press the button
|
|
@@ -2582,16 +2638,57 @@ function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
|
|
|
2582
2638
|
* @memberof Settings */
|
|
2583
2639
|
function setTouchGamepadCenterButtonSize(size) { touchGamepadCenterButtonSize = size; }
|
|
2584
2640
|
|
|
2585
|
-
/** 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)
|
|
2586
2642
|
* @param {number} count
|
|
2587
2643
|
* @memberof Settings */
|
|
2588
|
-
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
|
+
}
|
|
2589
2681
|
|
|
2590
2682
|
/** Set if touch gamepad should be analog stick or 8 way dpad
|
|
2591
2683
|
* @param {boolean} analog
|
|
2592
2684
|
* @memberof Settings */
|
|
2593
2685
|
function setTouchGamepadAnalog(analog) { touchGamepadAnalog = analog; }
|
|
2594
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
|
+
|
|
2595
2692
|
/** Set size of virtual gamepad for touch devices in pixels
|
|
2596
2693
|
* @param {number} size
|
|
2597
2694
|
* @memberof Settings */
|
|
@@ -2607,6 +2704,11 @@ function setTouchGamepadAlpha(alpha) { touchGamepadAlpha = alpha; }
|
|
|
2607
2704
|
* @memberof Settings */
|
|
2608
2705
|
function setTouchGamepadDisplayTime(time) { touchGamepadDisplayTime = time; }
|
|
2609
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
|
+
|
|
2610
2712
|
/** Set to allow vibration hardware if it exists
|
|
2611
2713
|
* @param {boolean} enable
|
|
2612
2714
|
* @memberof Settings */
|
|
@@ -2785,10 +2887,10 @@ class EngineObject
|
|
|
2785
2887
|
if (pa)
|
|
2786
2888
|
{
|
|
2787
2889
|
const c = cos(-pa), s = sin(-pa);
|
|
2788
|
-
this.pos
|
|
2890
|
+
this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
|
|
2789
2891
|
}
|
|
2790
2892
|
else
|
|
2791
|
-
this.pos
|
|
2893
|
+
this.pos.set(lx + pp.x, ly + pp.y);
|
|
2792
2894
|
this.angle = mirror*this.localAngle + pa;
|
|
2793
2895
|
}
|
|
2794
2896
|
|
|
@@ -3020,6 +3122,9 @@ class EngineObject
|
|
|
3020
3122
|
drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor);
|
|
3021
3123
|
}
|
|
3022
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
|
+
|
|
3023
3128
|
/** Destroy this object, destroy its children, detach its parent, and mark it for removal
|
|
3024
3129
|
* @param {boolean} [immediate] - should attached effects be allowed to die off? */
|
|
3025
3130
|
destroy(immediate=false)
|
|
@@ -3524,11 +3629,12 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
|
3524
3629
|
// normal canvas 2D rendering method (slower)
|
|
3525
3630
|
++drawCount;
|
|
3526
3631
|
++primitiveCount;
|
|
3527
|
-
size = new Vector2(size.x, -size.y); // flip upside down sprites
|
|
3528
3632
|
drawCanvas2D(pos, size, angle, mirror, (context)=>
|
|
3529
3633
|
{
|
|
3530
3634
|
if (textureInfo)
|
|
3531
3635
|
{
|
|
3636
|
+
// un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
|
|
3637
|
+
context.scale(1, -1);
|
|
3532
3638
|
// calculate uvs and render
|
|
3533
3639
|
const x = tileInfo.pos.x, y = tileInfo.pos.y;
|
|
3534
3640
|
const w = tileInfo.size.x, h = tileInfo.size.y;
|
|
@@ -3536,7 +3642,7 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
|
3536
3642
|
}
|
|
3537
3643
|
else
|
|
3538
3644
|
{
|
|
3539
|
-
// if no tile info, use untextured rect
|
|
3645
|
+
// if no tile info, use untextured rect (Y-symmetric, no compensation needed)
|
|
3540
3646
|
const c = additiveColor ? color.add(additiveColor) : color;
|
|
3541
3647
|
context.fillStyle = c.toString();
|
|
3542
3648
|
context.fillRect(-.5, -.5, 1, 1);
|
|
@@ -3610,11 +3716,10 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, an
|
|
|
3610
3716
|
// normal canvas 2D rendering method (slower)
|
|
3611
3717
|
++drawCount;
|
|
3612
3718
|
++primitiveCount;
|
|
3613
|
-
size = new Vector2(size.x, -size.y); // fix upside down sprites
|
|
3614
3719
|
drawCanvas2D(pos, size, angle, false, (context)=>
|
|
3615
3720
|
{
|
|
3616
|
-
//
|
|
3617
|
-
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);
|
|
3618
3723
|
gradient.addColorStop(0, colorTop.toString());
|
|
3619
3724
|
gradient.addColorStop(1, colorBottom.toString());
|
|
3620
3725
|
context.fillStyle = gradient;
|
|
@@ -4033,7 +4138,10 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
|
|
|
4033
4138
|
* @memberof Draw
|
|
4034
4139
|
*/
|
|
4035
4140
|
|
|
4036
|
-
/** 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.
|
|
4037
4145
|
* @param {Vector2} pos
|
|
4038
4146
|
* @param {Vector2} size
|
|
4039
4147
|
* @param {number} angle
|
|
@@ -4697,6 +4805,7 @@ function inputClear()
|
|
|
4697
4805
|
inputData[0] = [];
|
|
4698
4806
|
touchGamepadButtons.length = 0;
|
|
4699
4807
|
touchGamepadSticks.length = 0;
|
|
4808
|
+
touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor
|
|
4700
4809
|
gamepadStickData.length = 0;
|
|
4701
4810
|
gamepadDpadData.length = 0;
|
|
4702
4811
|
}
|
|
@@ -4939,6 +5048,14 @@ const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
|
|
|
4939
5048
|
|
|
4940
5049
|
// touch gamepad internal variables
|
|
4941
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;
|
|
4942
5059
|
|
|
4943
5060
|
///////////////////////////////////////////////////////////////////////////////
|
|
4944
5061
|
// Input system functions used by engine
|
|
@@ -5063,7 +5180,15 @@ function inputInit()
|
|
|
5063
5180
|
e.preventDefault(); // prevent page scrolling
|
|
5064
5181
|
}
|
|
5065
5182
|
function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
|
|
5066
|
-
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
|
+
}
|
|
5067
5192
|
|
|
5068
5193
|
// enable touch input mouse passthrough
|
|
5069
5194
|
function touchInputInit()
|
|
@@ -5079,36 +5204,46 @@ function inputInit()
|
|
|
5079
5204
|
{
|
|
5080
5205
|
if (!touchInputEnable) return;
|
|
5081
5206
|
|
|
5082
|
-
// route touch to gamepad
|
|
5083
|
-
if (touchGamepadEnable)
|
|
5084
|
-
handleTouchGamepad(e);
|
|
5085
|
-
|
|
5086
5207
|
// fix stalled audio requiring user interaction
|
|
5087
5208
|
if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
|
|
5088
5209
|
audioContext.resume();
|
|
5089
5210
|
|
|
5090
|
-
//
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
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)
|
|
5094
5215
|
{
|
|
5095
|
-
//
|
|
5096
|
-
|
|
5097
|
-
const
|
|
5098
|
-
|
|
5099
|
-
|
|
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)
|
|
5100
5228
|
{
|
|
5101
|
-
|
|
5102
|
-
|
|
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
|
+
}
|
|
5103
5240
|
}
|
|
5104
|
-
else
|
|
5105
|
-
inputData[0][button] =
|
|
5106
|
-
}
|
|
5107
|
-
else if (wasTouching)
|
|
5108
|
-
inputData[0][button] = inputData[0][button] & 2 | 4;
|
|
5241
|
+
else if (wasTouching)
|
|
5242
|
+
inputData[0][button] = inputData[0][button] & 2 | 4;
|
|
5109
5243
|
|
|
5110
|
-
|
|
5111
|
-
|
|
5244
|
+
// set was touching
|
|
5245
|
+
wasTouching = touching;
|
|
5246
|
+
}
|
|
5112
5247
|
|
|
5113
5248
|
// prevent default handling like copy, magnifier lens, and scrolling
|
|
5114
5249
|
if (inputPreventDefault && e.cancelable && document.hasFocus())
|
|
@@ -5118,83 +5253,6 @@ function inputInit()
|
|
|
5118
5253
|
return true;
|
|
5119
5254
|
}
|
|
5120
5255
|
|
|
5121
|
-
// special handling for virtual gamepad mode
|
|
5122
|
-
function handleTouchGamepad(e)
|
|
5123
|
-
{
|
|
5124
|
-
// clear touch gamepad input
|
|
5125
|
-
touchGamepadSticks.length = 0;
|
|
5126
|
-
touchGamepadSticks[0] = vec2();
|
|
5127
|
-
touchGamepadSticks[1] = vec2();
|
|
5128
|
-
touchGamepadButtons.length = 0;
|
|
5129
|
-
isUsingGamepad = true;
|
|
5130
|
-
|
|
5131
|
-
const touching = e.touches.length;
|
|
5132
|
-
if (touching)
|
|
5133
|
-
{
|
|
5134
|
-
touchGamepadTimer.set();
|
|
5135
|
-
if (touchGamepadCenterButtonSize && !wasTouching && paused)
|
|
5136
|
-
{
|
|
5137
|
-
// touch anywhere to press start when paused
|
|
5138
|
-
touchGamepadButtons[9] = 1;
|
|
5139
|
-
return;
|
|
5140
|
-
}
|
|
5141
|
-
}
|
|
5142
|
-
|
|
5143
|
-
// don't process touch gamepad if paused
|
|
5144
|
-
if (paused) return;
|
|
5145
|
-
|
|
5146
|
-
// get center of left and right sides
|
|
5147
|
-
const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
|
|
5148
|
-
const buttonCenter = touchGamepadButtonCenter();
|
|
5149
|
-
const startCenter = mainCanvasSize.scale(.5);
|
|
5150
|
-
|
|
5151
|
-
// check each touch point
|
|
5152
|
-
for (const touch of e.touches)
|
|
5153
|
-
{
|
|
5154
|
-
const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
|
|
5155
|
-
if (stickCenter.distance(touchPos) < touchGamepadSize)
|
|
5156
|
-
{
|
|
5157
|
-
// virtual analog stick
|
|
5158
|
-
const delta = touchPos.subtract(stickCenter);
|
|
5159
|
-
touchGamepadSticks[0] = delta.scale(2/touchGamepadSize).clampLength();
|
|
5160
|
-
touchGamepadButtons[10] = 1; // also press a button when touching stick
|
|
5161
|
-
}
|
|
5162
|
-
else if (buttonCenter.distance(touchPos) < touchGamepadSize)
|
|
5163
|
-
{
|
|
5164
|
-
if (touchGamepadButtonCount === 1)
|
|
5165
|
-
{
|
|
5166
|
-
// virtual right analog stick
|
|
5167
|
-
const delta = touchPos.subtract(buttonCenter);
|
|
5168
|
-
touchGamepadSticks[1] = delta.scale(2/touchGamepadSize).clampLength();
|
|
5169
|
-
touchGamepadButtons[11] = 1; // also press a button when touching right stick
|
|
5170
|
-
}
|
|
5171
|
-
// virtual face buttons
|
|
5172
|
-
let button = buttonCenter.subtract(touchPos).direction();
|
|
5173
|
-
button = mod(button+2, 4);
|
|
5174
|
-
if (touchGamepadButtonCount === 1)
|
|
5175
|
-
button = 0;
|
|
5176
|
-
else if (touchGamepadButtonCount === 2)
|
|
5177
|
-
{
|
|
5178
|
-
const delta = buttonCenter.subtract(touchPos);
|
|
5179
|
-
button = -delta.x < delta.y ? 1 : 0;
|
|
5180
|
-
}
|
|
5181
|
-
// fix button locations (swap 2 and 3 to match gamepad layout)
|
|
5182
|
-
button = button === 3 ? 2 : button === 2 ? 3 : button;
|
|
5183
|
-
if (button < touchGamepadButtonCount)
|
|
5184
|
-
touchGamepadButtons[button] = 1;
|
|
5185
|
-
}
|
|
5186
|
-
else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize &&
|
|
5187
|
-
stickCenter.distance(touchPos) >= 2 * touchGamepadSize &&
|
|
5188
|
-
buttonCenter.distance(touchPos) >= 2 * touchGamepadSize)
|
|
5189
|
-
{
|
|
5190
|
-
// virtual start button in center
|
|
5191
|
-
// require a fat-finger buffer of touchGamepadSize beyond the
|
|
5192
|
-
// edge of the stick/buttons so drift off those controls can't
|
|
5193
|
-
// accidentally fire start
|
|
5194
|
-
touchGamepadButtons[9] = 1;
|
|
5195
|
-
}
|
|
5196
|
-
}
|
|
5197
|
-
}
|
|
5198
5256
|
}
|
|
5199
5257
|
|
|
5200
5258
|
// convert a mouse or touch event position to screen space
|
|
@@ -5219,6 +5277,9 @@ function inputUpdate()
|
|
|
5219
5277
|
mousePos = screenToWorld(mousePosScreen);
|
|
5220
5278
|
mouseDelta = screenToWorldDelta(mouseDeltaScreen);
|
|
5221
5279
|
|
|
5280
|
+
// build the touch gamepad overlay lazily once enabled on a touch device
|
|
5281
|
+
touchGamepadInit();
|
|
5282
|
+
|
|
5222
5283
|
// update gamepads if enabled
|
|
5223
5284
|
gamepadsUpdate();
|
|
5224
5285
|
|
|
@@ -5237,22 +5298,11 @@ function inputUpdate()
|
|
|
5237
5298
|
// update touch gamepad if enabled
|
|
5238
5299
|
if (touchGamepadEnable && isTouchDevice)
|
|
5239
5300
|
{
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
|
|
5245
|
-
|
|
5246
|
-
debugCircle(stickCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
|
|
5247
|
-
debugCircle(buttonCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
|
|
5248
|
-
if (touchGamepadCenterButtonSize)
|
|
5249
|
-
{
|
|
5250
|
-
debugCircle(startCenter, 2*touchGamepadCenterButtonSize, 'cyan', 0, false, true);
|
|
5251
|
-
// exclusion bubbles around controls (where start is blocked)
|
|
5252
|
-
debugCircle(stickCenter, 4*touchGamepadSize, 'magenta', 0, false, true);
|
|
5253
|
-
debugCircle(buttonCenter, 4*touchGamepadSize, 'magenta', 0, false, true);
|
|
5254
|
-
}
|
|
5255
|
-
}
|
|
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');
|
|
5256
5306
|
|
|
5257
5307
|
if (!touchGamepadTimer.isSet()) return;
|
|
5258
5308
|
|
|
@@ -5260,23 +5310,24 @@ function inputUpdate()
|
|
|
5260
5310
|
gamepadPrimary = 0; // touch gamepad uses index 0
|
|
5261
5311
|
const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
|
|
5262
5312
|
const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
|
|
5263
|
-
sticks
|
|
5313
|
+
sticks.length = 0; // only report sticks that are enabled
|
|
5264
5314
|
dpad.set();
|
|
5265
|
-
|
|
5266
|
-
|
|
5267
|
-
sticks[0] = applyDeadZones(leftTouchStick);
|
|
5268
|
-
else if (leftTouchStick.lengthSquared() > .3)
|
|
5269
|
-
{
|
|
5270
|
-
// convert to 8 way dpad
|
|
5271
|
-
const x = clamp(round(leftTouchStick.x), -1, 1);
|
|
5272
|
-
const y = clamp(round(leftTouchStick.y), -1, 1);
|
|
5273
|
-
dpad.set(x, -y);
|
|
5274
|
-
sticks[0] = dpad.clampLength(); // clamp to circle
|
|
5275
|
-
}
|
|
5276
|
-
if (touchGamepadButtonCount === 1)
|
|
5315
|
+
// read each side's directional stick (analog, or quantized to an 8 way dpad)
|
|
5316
|
+
for (let side = 0; side < 2; side++)
|
|
5277
5317
|
{
|
|
5278
|
-
|
|
5279
|
-
|
|
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
|
+
}
|
|
5280
5331
|
}
|
|
5281
5332
|
|
|
5282
5333
|
// read virtual gamepad buttons
|
|
@@ -5285,6 +5336,12 @@ function inputUpdate()
|
|
|
5285
5336
|
{
|
|
5286
5337
|
const wasDown = gamepadIsDown(i,0);
|
|
5287
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);
|
|
5288
5345
|
}
|
|
5289
5346
|
|
|
5290
5347
|
// disable normal gamepads when touch gamepad is active
|
|
@@ -5385,80 +5442,470 @@ function inputUpdatePost()
|
|
|
5385
5442
|
function inputRender()
|
|
5386
5443
|
{
|
|
5387
5444
|
touchGamepadRender();
|
|
5445
|
+
}
|
|
5446
|
+
|
|
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;
|
|
5388
5554
|
|
|
5389
|
-
|
|
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
|
|
5390
5563
|
{
|
|
5391
|
-
|
|
5392
|
-
|
|
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
|
+
}
|
|
5393
5584
|
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5585
|
+
touchGamepadBuildSvg(W, H);
|
|
5586
|
+
touchGamepadNeedRelayout = false;
|
|
5587
|
+
}
|
|
5397
5588
|
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
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
|
+
};
|
|
5404
5614
|
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
const
|
|
5410
|
-
|
|
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))
|
|
5411
5622
|
{
|
|
5412
|
-
//
|
|
5413
|
-
|
|
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');
|
|
5414
5626
|
}
|
|
5415
|
-
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++)
|
|
5416
5630
|
{
|
|
5417
|
-
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
|
|
5423
|
-
|
|
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');
|
|
5424
5641
|
}
|
|
5425
|
-
|
|
5426
|
-
|
|
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');
|
|
5427
5665
|
|
|
5428
|
-
|
|
5666
|
+
// cyan: where each side's control can be grabbed
|
|
5667
|
+
for (let side = 0; side < 2; side++)
|
|
5668
|
+
{
|
|
5669
|
+
if (touchGamepadSideStick(side))
|
|
5429
5670
|
{
|
|
5430
|
-
|
|
5431
|
-
const buttonSize = touchGamepadButtonCount > 1 ?
|
|
5432
|
-
touchGamepadSize/4 : touchGamepadSize/2;
|
|
5433
|
-
for (let i=0; i<touchGamepadButtonCount; i++)
|
|
5671
|
+
if (touchGamepadFloating)
|
|
5434
5672
|
{
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
button = button === 3 ? 2 : button === 2 ? 3 : button;
|
|
5440
|
-
const pos = touchGamepadButtonCount < 2 ? buttonCenter :
|
|
5441
|
-
buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
|
|
5442
|
-
context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
|
|
5443
|
-
context.beginPath();
|
|
5444
|
-
context.arc(pos.x, pos.y, buttonSize, 0,9);
|
|
5445
|
-
context.fill();
|
|
5446
|
-
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');
|
|
5447
5677
|
}
|
|
5678
|
+
else
|
|
5679
|
+
ring(touchGamepadSideCenter(side, W, H), 2*S, '#0ff');
|
|
5448
5680
|
}
|
|
5681
|
+
else if (touchGamepadSideButtonCount(side) >= 1)
|
|
5682
|
+
ring(touchGamepadSideCenter(side, W, H), S, '#0ff'); // face / single-button radius
|
|
5683
|
+
}
|
|
5449
5684
|
|
|
5450
|
-
|
|
5451
|
-
|
|
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');
|
|
5692
|
+
}
|
|
5693
|
+
}
|
|
5694
|
+
|
|
5695
|
+
// per-frame: fade the overlay and move the thumbs / set pressed states
|
|
5696
|
+
function touchGamepadRender()
|
|
5697
|
+
{
|
|
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;
|
|
5452
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;
|
|
5453
5770
|
}
|
|
5454
5771
|
|
|
5455
|
-
//
|
|
5456
|
-
function
|
|
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)
|
|
5457
5774
|
{
|
|
5458
|
-
const
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
return
|
|
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();
|
|
5462
5909
|
}
|
|
5463
5910
|
/**
|
|
5464
5911
|
* LittleJS Audio System
|
|
@@ -5595,7 +6042,7 @@ class Sound
|
|
|
5595
6042
|
* @param {number} [randomnessScale] - How much to scale pitch randomness
|
|
5596
6043
|
* @param {boolean} [loop] - Should the sound loop?
|
|
5597
6044
|
* @param {boolean} [paused] - Should the sound start paused
|
|
5598
|
-
* @return {SoundInstance} - The
|
|
6045
|
+
* @return {SoundInstance} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode
|
|
5599
6046
|
*/
|
|
5600
6047
|
play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
|
|
5601
6048
|
{
|
|
@@ -5626,9 +6073,23 @@ class Sound
|
|
|
5626
6073
|
pan = worldToScreen(pos).x * 2/mainCanvas.width - 1;
|
|
5627
6074
|
}
|
|
5628
6075
|
|
|
5629
|
-
// Create
|
|
6076
|
+
// Create sound instance
|
|
5630
6077
|
const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
|
|
5631
|
-
|
|
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;
|
|
5632
6093
|
}
|
|
5633
6094
|
|
|
5634
6095
|
/** Play a music track that loops by default
|
|
@@ -5994,6 +6455,10 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
|
|
|
5994
6455
|
// play and return sound
|
|
5995
6456
|
const startOffset = offset * rate;
|
|
5996
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
|
+
|
|
5997
6462
|
return source;
|
|
5998
6463
|
}
|
|
5999
6464
|
|
|
@@ -6827,6 +7292,11 @@ class TileCollisionLayer extends TileLayer
|
|
|
6827
7292
|
// check any tiles in the area for collision
|
|
6828
7293
|
const posX = pos.x - this.pos.x;
|
|
6829
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;
|
|
6830
7300
|
const minX = max(posX - size.x/2|0, 0);
|
|
6831
7301
|
const minY = max(posY - size.y/2|0, 0);
|
|
6832
7302
|
// ensure at least one cell is visited even when size is 0 and pos
|
|
@@ -6945,10 +7415,10 @@ class ParticleEmitter extends EngineObject
|
|
|
6945
7415
|
* @param {number} [particleTime] - How long particles live
|
|
6946
7416
|
* @param {number} [sizeStart] - How big are particles at start
|
|
6947
7417
|
* @param {number} [sizeEnd] - How big are particles at end
|
|
6948
|
-
* @param {number} [speed] - How fast are particles when spawned
|
|
6949
|
-
* @param {number} [angleSpeed] - How fast are particles rotating
|
|
6950
|
-
* @param {number} [damping] - How much to dampen particle speed
|
|
6951
|
-
* @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)
|
|
6952
7422
|
* @param {number} [gravityScale] - How much gravity effect particles
|
|
6953
7423
|
* @param {number} [particleConeAngle] - Cone for start particle angle
|
|
6954
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
|
|
@@ -7023,13 +7493,13 @@ class ParticleEmitter extends EngineObject
|
|
|
7023
7493
|
this.sizeStart = sizeStart;
|
|
7024
7494
|
/** @property {number} - How big are particles at end */
|
|
7025
7495
|
this.sizeEnd = sizeEnd;
|
|
7026
|
-
/** @property {number} -
|
|
7496
|
+
/** @property {number} - Particle speed when spawned, in world units per frame (at 60fps) */
|
|
7027
7497
|
this.speed = speed;
|
|
7028
|
-
/** @property {number} -
|
|
7498
|
+
/** @property {number} - Particle angular speed when spawned, in radians per frame (at 60fps) */
|
|
7029
7499
|
this.angleSpeed = angleSpeed;
|
|
7030
|
-
/** @property {number} -
|
|
7500
|
+
/** @property {number} - Per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame) */
|
|
7031
7501
|
this.damping = damping;
|
|
7032
|
-
/** @property {number} -
|
|
7502
|
+
/** @property {number} - Per-frame angular velocity multiplier (1 = no damping) */
|
|
7033
7503
|
this.angleDamping = angleDamping;
|
|
7034
7504
|
/** @property {number} - How much gravity affects particles */
|
|
7035
7505
|
this.gravityScale = gravityScale;
|
|
@@ -7098,12 +7568,16 @@ class ParticleEmitter extends EngineObject
|
|
|
7098
7568
|
else if (this.particles.length === 0)
|
|
7099
7569
|
this.destroy(true);
|
|
7100
7570
|
|
|
7101
|
-
// update and remove destroyed particles
|
|
7102
|
-
|
|
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)
|
|
7103
7575
|
{
|
|
7576
|
+
const p = particles[i];
|
|
7104
7577
|
p.update();
|
|
7105
|
-
|
|
7106
|
-
}
|
|
7578
|
+
if (!p.destroyed) particles[alive++] = p;
|
|
7579
|
+
}
|
|
7580
|
+
particles.length = alive;
|
|
7107
7581
|
|
|
7108
7582
|
if (debugParticles)
|
|
7109
7583
|
{
|
|
@@ -7385,7 +7859,7 @@ class Particle
|
|
|
7385
7859
|
{
|
|
7386
7860
|
// in local space of emitter
|
|
7387
7861
|
const a = emitter.angle;
|
|
7388
|
-
const c = cos(a), s = sin(a);
|
|
7862
|
+
const c = cos(-a), s = sin(-a);
|
|
7389
7863
|
pos.set(emitter.pos.x + pos.x*c - pos.y*s,
|
|
7390
7864
|
emitter.pos.y + pos.x*s + pos.y*c);
|
|
7391
7865
|
angle += a;
|
|
@@ -7393,8 +7867,8 @@ class Particle
|
|
|
7393
7867
|
if (trailScale)
|
|
7394
7868
|
{
|
|
7395
7869
|
// trail style particles
|
|
7396
|
-
const velocity = localSpace ?
|
|
7397
|
-
this.velocity.rotate(
|
|
7870
|
+
const velocity = localSpace ?
|
|
7871
|
+
this.velocity.rotate(emitter.angle) : this.velocity;
|
|
7398
7872
|
const speed = velocity.length();
|
|
7399
7873
|
if (speed)
|
|
7400
7874
|
{
|
|
@@ -8556,7 +9030,15 @@ function medalsInit(saveName)
|
|
|
8556
9030
|
// check if medals are unlocked
|
|
8557
9031
|
medalsSaveName = saveName;
|
|
8558
9032
|
if (!debugMedals)
|
|
8559
|
-
|
|
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
|
+
}
|
|
8560
9042
|
|
|
8561
9043
|
// engine automatically renders medals
|
|
8562
9044
|
engineAddPlugin(undefined, medalsRender);
|
|
@@ -8600,6 +9082,31 @@ function medalsInit(saveName)
|
|
|
8600
9082
|
function medalsForEach(callback)
|
|
8601
9083
|
{ Object.values(medals).forEach(medal=>callback(medal)); }
|
|
8602
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
|
+
|
|
8603
9110
|
///////////////////////////////////////////////////////////////////////////////
|
|
8604
9111
|
|
|
8605
9112
|
/**
|
|
@@ -8657,9 +9164,9 @@ class Medal
|
|
|
8657
9164
|
{
|
|
8658
9165
|
if (medalsPreventUnlock || this.unlocked) return;
|
|
8659
9166
|
|
|
8660
|
-
// save the medal
|
|
8661
9167
|
ASSERT(medalsSaveName, 'save name must be set');
|
|
8662
|
-
|
|
9168
|
+
this.unlocked = true;
|
|
9169
|
+
medalsSave();
|
|
8663
9170
|
medalsDisplayQueue.push(this);
|
|
8664
9171
|
}
|
|
8665
9172
|
|
|
@@ -8717,8 +9224,6 @@ class Medal
|
|
|
8717
9224
|
drawTextScreen(this.icon, pos, size*.7, BLACK);
|
|
8718
9225
|
}
|
|
8719
9226
|
|
|
8720
|
-
// Get local storage key used by the medal
|
|
8721
|
-
storageKey() { return medalsSaveName + '_' + this.id; }
|
|
8722
9227
|
}
|
|
8723
9228
|
|
|
8724
9229
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -9103,6 +9608,335 @@ class PostProcessPlugin
|
|
|
9103
9608
|
}
|
|
9104
9609
|
}
|
|
9105
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
|
+
|
|
9106
9940
|
/**
|
|
9107
9941
|
* LittleJS ZzFXM Plugin
|
|
9108
9942
|
* @namespace ZzFXM
|
|
@@ -11150,23 +11984,19 @@ class Box2dObject extends EngineObject
|
|
|
11150
11984
|
|
|
11151
11985
|
function box2dCreatePolygonShape(points)
|
|
11152
11986
|
{
|
|
11153
|
-
|
|
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)
|
|
11154
11990
|
{
|
|
11155
|
-
|
|
11156
|
-
|
|
11157
|
-
|
|
11158
|
-
|
|
11159
|
-
offset += 4;
|
|
11160
|
-
box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].y;
|
|
11161
|
-
offset += 4;
|
|
11162
|
-
}
|
|
11163
|
-
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;
|
|
11164
11995
|
}
|
|
11165
|
-
|
|
11166
|
-
ASSERT(3 <= points.length && points.length <= 8);
|
|
11996
|
+
const box2dPoints = box2d.instance.wrapPointer(buffer, box2d.instance.b2Vec2);
|
|
11167
11997
|
const shape = new box2d.instance.b2PolygonShape();
|
|
11168
|
-
const box2dPoints = box2dCreatePointList(points);
|
|
11169
11998
|
shape.Set(box2dPoints, points.length);
|
|
11999
|
+
box2d.instance._free(buffer);
|
|
11170
12000
|
return shape;
|
|
11171
12001
|
}
|
|
11172
12002
|
|
|
@@ -13147,6 +13977,69 @@ function drawThreeSlice(pos, size, startTile, color, borderSize=1, additiveColor
|
|
|
13147
13977
|
const cornerPos = cornerOffset.multiply(vec2(flipX?-1:1, flipY?-flip:flip));
|
|
13148
13978
|
drawTile(pos.add(cornerPos.rotate(rotateAngle)), cornerSize, cornerTile, color, a, false, additiveColor, useWebGL, screenSpace, context);
|
|
13149
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;
|
|
13150
14043
|
}
|
|
13151
14044
|
/**
|
|
13152
14045
|
* LittleJS Tween System Plugin
|