littlejsengine 1.18.15 → 1.18.18
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 +277 -32
- package/dist/littlejs.esm.js +1395 -304
- package/dist/littlejs.esm.min.js +1 -1
- package/dist/littlejs.js +1367 -303
- package/dist/littlejs.min.js +1 -1
- package/dist/littlejs.release.js +1341 -287
- package/package.json +1 -1
- package/plugins/box2d.js +9 -13
- package/plugins/drawUtilities.js +84 -10
- package/plugins/lightSystem.js +330 -0
- package/plugins/medalSystem.js +36 -5
- package/plugins/pluginExport.js +8 -0
- package/plugins/uiSystem.js +35 -25
- 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 +68 -10
- package/src/engineExport.js +20 -1
- package/src/engineInput.js +597 -191
- package/src/engineMath.js +15 -0
- package/src/engineObject.js +5 -2
- package/src/engineParticles.js +23 -19
- package/src/engineRelease.js +1 -0
- package/src/engineSettings.js +114 -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.18';
|
|
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
|
|
@@ -1544,10 +1545,25 @@ class Color
|
|
|
1544
1545
|
* @return {Color} */
|
|
1545
1546
|
setFrom(c) { return this.set(c.r, c.g, c.b, c.a); }
|
|
1546
1547
|
|
|
1548
|
+
/** Sets the alpha of this color and returns self
|
|
1549
|
+
* @param {number} [a] - alpha
|
|
1550
|
+
* @return {Color} */
|
|
1551
|
+
setAlpha(a=1)
|
|
1552
|
+
{
|
|
1553
|
+
this.a = a;
|
|
1554
|
+
ASSERT_COLOR_VALID(this);
|
|
1555
|
+
return this;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1547
1558
|
/** Returns a new color that is a copy of this
|
|
1548
1559
|
* @return {Color} */
|
|
1549
1560
|
copy() { return new Color(this.r, this.g, this.b, this.a); }
|
|
1550
1561
|
|
|
1562
|
+
/** Returns a copy of this color with the alpha set
|
|
1563
|
+
* @param {number} [a] - alpha
|
|
1564
|
+
* @return {Color} */
|
|
1565
|
+
withAlpha(a=1) { return new Color(this.r, this.g, this.b, a); }
|
|
1566
|
+
|
|
1551
1567
|
/** Returns a copy of this color plus the color passed in
|
|
1552
1568
|
* @param {Color} c - other color
|
|
1553
1569
|
* @return {Color} */
|
|
@@ -2142,6 +2158,8 @@ let canvasFixedSize = vec2();
|
|
|
2142
2158
|
let canvasPixelated = false;
|
|
2143
2159
|
|
|
2144
2160
|
/** Disables texture filtering for crisper pixel art
|
|
2161
|
+
* - Leave true for pixel art so sprites stay sharp when scaled (uses NEAREST filtering)
|
|
2162
|
+
* - Set false for smooth/high-resolution art to enable bilinear filtering and mipmaps
|
|
2145
2163
|
* @type {boolean}
|
|
2146
2164
|
* @default
|
|
2147
2165
|
* @memberof Settings */
|
|
@@ -2297,35 +2315,78 @@ let touchInputEnable = true;
|
|
|
2297
2315
|
* - Supports left analog stick, 4 face buttons and start button (button 9)
|
|
2298
2316
|
* - setTouchGamepadButtonCount(1) to use face buttons as right analog stick
|
|
2299
2317
|
* - Analog stick buttons 10 and 11 are also activated when virtual sticks are touched
|
|
2300
|
-
|
|
2318
|
+
* - Rendered as a full-viewport HTML/SVG overlay, so controls may sit outside the game canvas
|
|
2301
2319
|
* @type {boolean}
|
|
2302
2320
|
* @default
|
|
2303
2321
|
* @memberof Settings */
|
|
2304
2322
|
let touchGamepadEnable = false;
|
|
2305
2323
|
|
|
2306
|
-
/** True if
|
|
2307
|
-
* -
|
|
2308
|
-
*
|
|
2324
|
+
/** True if touches outside the gamepad controls should still drive mouse/touch input
|
|
2325
|
+
* - When false (the default), enabling the touch gamepad suppresses touch-to-mouse input entirely
|
|
2326
|
+
* - Set true to also pass touches outside the controls through to the game as mouse/touch input
|
|
2327
|
+
* - Touches on the gamepad controls never drive the mouse regardless of this setting
|
|
2328
|
+
* @type {boolean}
|
|
2329
|
+
* @default
|
|
2330
|
+
* @memberof Settings */
|
|
2331
|
+
let touchGamepadPassthrough = false;
|
|
2332
|
+
|
|
2333
|
+
/** Size of center button if touch gamepad should have start button in the center
|
|
2334
|
+
* - Prevents activating when pressed near virtual stick or face buttons
|
|
2309
2335
|
* - When the game is paused, any touch will press the button
|
|
2310
|
-
* -
|
|
2336
|
+
* - Measured in viewport CSS pixels
|
|
2311
2337
|
* @type {number}
|
|
2312
2338
|
* @default
|
|
2313
2339
|
* @memberof Settings */
|
|
2314
|
-
let touchGamepadCenterButtonSize =
|
|
2340
|
+
let touchGamepadCenterButtonSize = 0;
|
|
2315
2341
|
|
|
2316
|
-
/** Number of buttons on touch gamepad (0-4),
|
|
2342
|
+
/** Number of buttons on the right side of the touch gamepad (0-4), using gamepad buttons 0-3
|
|
2343
|
+
* - A count of 1 is a single large button (the size of a stick)
|
|
2344
|
+
* - Ignored when touchGamepadRightStick is set (the right side is a stick instead)
|
|
2317
2345
|
* @type {number}
|
|
2318
2346
|
* @default
|
|
2319
2347
|
* @memberof Settings */
|
|
2320
2348
|
let touchGamepadButtonCount = 4;
|
|
2321
2349
|
|
|
2350
|
+
/** True if the touch gamepad should have a left analog stick (or dpad)
|
|
2351
|
+
* - When false, the left side is face buttons (touchGamepadLeftButtonCount) or nothing
|
|
2352
|
+
* @type {boolean}
|
|
2353
|
+
* @default
|
|
2354
|
+
* @memberof Settings */
|
|
2355
|
+
let touchGamepadLeftStick = true;
|
|
2356
|
+
|
|
2357
|
+
/** Number of buttons on the left side of the touch gamepad (0-4), using gamepad buttons 4-7
|
|
2358
|
+
* - Only used when touchGamepadLeftStick is false (otherwise the left side is a stick)
|
|
2359
|
+
* - A count of 1 is a single large button (the size of a stick)
|
|
2360
|
+
* @type {number}
|
|
2361
|
+
* @default
|
|
2362
|
+
* @memberof Settings */
|
|
2363
|
+
let touchGamepadLeftButtonCount = 0;
|
|
2364
|
+
|
|
2365
|
+
/** True if the touch gamepad right side should be an analog stick (or dpad) instead of face buttons
|
|
2366
|
+
* - When set, touchGamepadButtonCount is ignored and the right side is a stick
|
|
2367
|
+
* - Uses an analog stick when touchGamepadAnalog is true, otherwise an 8 way dpad
|
|
2368
|
+
* @type {boolean}
|
|
2369
|
+
* @default
|
|
2370
|
+
* @memberof Settings */
|
|
2371
|
+
let touchGamepadRightStick = false;
|
|
2372
|
+
|
|
2322
2373
|
/** True if touch gamepad should be analog stick or false to use if 8 way dpad
|
|
2323
2374
|
* @type {boolean}
|
|
2324
2375
|
* @default
|
|
2325
2376
|
* @memberof Settings */
|
|
2326
2377
|
let touchGamepadAnalog = true;
|
|
2327
2378
|
|
|
2328
|
-
/**
|
|
2379
|
+
/** True if touch gamepad directional controls should float to where you press
|
|
2380
|
+
* - Only affects analog sticks and dpads, not face buttons
|
|
2381
|
+
* - Directional controls re-anchor to where you press within the bottom ~60% of their screen half; the top ~40% passes through to the game
|
|
2382
|
+
* - The right side floats only when it acts as the right analog stick (touchGamepadRightStick is set)
|
|
2383
|
+
* - A center button (touchGamepadCenterButtonSize) still works since it ignores touches near the sticks
|
|
2384
|
+
* @type {boolean}
|
|
2385
|
+
* @default
|
|
2386
|
+
* @memberof Settings */
|
|
2387
|
+
let touchGamepadFloating = false;
|
|
2388
|
+
|
|
2389
|
+
/** Size of virtual gamepad for touch devices in viewport CSS pixels
|
|
2329
2390
|
* @type {number}
|
|
2330
2391
|
* @default
|
|
2331
2392
|
* @memberof Settings */
|
|
@@ -2343,6 +2404,13 @@ let touchGamepadAlpha = .3;
|
|
|
2343
2404
|
* @memberof Settings */
|
|
2344
2405
|
let touchGamepadDisplayTime = 3;
|
|
2345
2406
|
|
|
2407
|
+
/** Duration in ms to vibrate when a touch gamepad face button or start button is pressed
|
|
2408
|
+
* - Set to 0 to disable, also requires vibrateEnable and hardware support (ignored on iOS)
|
|
2409
|
+
* @type {number}
|
|
2410
|
+
* @default
|
|
2411
|
+
* @memberof Settings */
|
|
2412
|
+
let touchGamepadVibration = 0;
|
|
2413
|
+
|
|
2346
2414
|
/** Allow vibration hardware if it exists
|
|
2347
2415
|
* @type {boolean}
|
|
2348
2416
|
* @default
|
|
@@ -2445,6 +2513,7 @@ function setCanvasPixelated(pixelated)
|
|
|
2445
2513
|
}
|
|
2446
2514
|
|
|
2447
2515
|
/** Disables texture filtering for crisper pixel art
|
|
2516
|
+
* - Leave true for pixel art; set false for smooth/high-resolution art
|
|
2448
2517
|
* @param {boolean} pixelated
|
|
2449
2518
|
* @memberof Settings */
|
|
2450
2519
|
function setTilesPixelated(pixelated) { tilesPixelated = pixelated; }
|
|
@@ -2575,6 +2644,11 @@ function setTouchInputEnable(enable) { touchInputEnable = enable; }
|
|
|
2575
2644
|
* @memberof Settings */
|
|
2576
2645
|
function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
|
|
2577
2646
|
|
|
2647
|
+
/** Set if touches outside the gamepad controls should still drive mouse/touch input
|
|
2648
|
+
* @param {boolean} passthrough
|
|
2649
|
+
* @memberof Settings */
|
|
2650
|
+
function setTouchGamepadPassthrough(passthrough) { touchGamepadPassthrough = passthrough; }
|
|
2651
|
+
|
|
2578
2652
|
/** Set if touch gamepad should have start button in the center
|
|
2579
2653
|
* - Set size to enable the center button
|
|
2580
2654
|
* - When the game is paused, any touch will press the button
|
|
@@ -2582,16 +2656,57 @@ function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
|
|
|
2582
2656
|
* @memberof Settings */
|
|
2583
2657
|
function setTouchGamepadCenterButtonSize(size) { touchGamepadCenterButtonSize = size; }
|
|
2584
2658
|
|
|
2585
|
-
/** Set number of buttons on touch gamepad (0-4
|
|
2659
|
+
/** Set number of buttons on the right side of the touch gamepad (0-4, gamepad buttons 0-3)
|
|
2586
2660
|
* @param {number} count
|
|
2587
2661
|
* @memberof Settings */
|
|
2588
|
-
function setTouchGamepadButtonCount(count)
|
|
2662
|
+
function setTouchGamepadButtonCount(count)
|
|
2663
|
+
{
|
|
2664
|
+
touchGamepadButtonCount = count;
|
|
2665
|
+
if (count > 0)
|
|
2666
|
+
touchGamepadRightStick = false;
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
/** Set if the touch gamepad should have a left analog stick (or dpad)
|
|
2670
|
+
* @param {boolean} enable
|
|
2671
|
+
* @memberof Settings */
|
|
2672
|
+
function setTouchGamepadLeftStick(enable)
|
|
2673
|
+
{
|
|
2674
|
+
touchGamepadLeftStick = enable;
|
|
2675
|
+
if (enable)
|
|
2676
|
+
touchGamepadLeftButtonCount = 0;
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
/** Set number of buttons on the left side of the touch gamepad (0-4, gamepad buttons 4-7)
|
|
2680
|
+
* - Only used when touchGamepadLeftStick is false
|
|
2681
|
+
* @param {number} count
|
|
2682
|
+
* @memberof Settings */
|
|
2683
|
+
function setTouchGamepadLeftButtonCount(count)
|
|
2684
|
+
{
|
|
2685
|
+
touchGamepadLeftButtonCount = count;
|
|
2686
|
+
if (count > 0)
|
|
2687
|
+
touchGamepadLeftStick = false;
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
/** Set if the touch gamepad right side is an analog stick (or dpad) instead of face buttons
|
|
2691
|
+
* @param {boolean} rightStick
|
|
2692
|
+
* @memberof Settings */
|
|
2693
|
+
function setTouchGamepadRightStick(rightStick)
|
|
2694
|
+
{
|
|
2695
|
+
touchGamepadRightStick = rightStick;
|
|
2696
|
+
if (rightStick)
|
|
2697
|
+
touchGamepadButtonCount = 0;
|
|
2698
|
+
}
|
|
2589
2699
|
|
|
2590
2700
|
/** Set if touch gamepad should be analog stick or 8 way dpad
|
|
2591
2701
|
* @param {boolean} analog
|
|
2592
2702
|
* @memberof Settings */
|
|
2593
2703
|
function setTouchGamepadAnalog(analog) { touchGamepadAnalog = analog; }
|
|
2594
2704
|
|
|
2705
|
+
/** Set if touch gamepad directional controls should float to where you press
|
|
2706
|
+
* @param {boolean} floating
|
|
2707
|
+
* @memberof Settings */
|
|
2708
|
+
function setTouchGamepadFloating(floating) { touchGamepadFloating = floating; }
|
|
2709
|
+
|
|
2595
2710
|
/** Set size of virtual gamepad for touch devices in pixels
|
|
2596
2711
|
* @param {number} size
|
|
2597
2712
|
* @memberof Settings */
|
|
@@ -2607,6 +2722,11 @@ function setTouchGamepadAlpha(alpha) { touchGamepadAlpha = alpha; }
|
|
|
2607
2722
|
* @memberof Settings */
|
|
2608
2723
|
function setTouchGamepadDisplayTime(time) { touchGamepadDisplayTime = time; }
|
|
2609
2724
|
|
|
2725
|
+
/** Set duration in ms to vibrate when a touch gamepad face or start button is pressed (0 disables)
|
|
2726
|
+
* @param {number} ms
|
|
2727
|
+
* @memberof Settings */
|
|
2728
|
+
function setTouchGamepadVibration(ms) { touchGamepadVibration = ms; }
|
|
2729
|
+
|
|
2610
2730
|
/** Set to allow vibration hardware if it exists
|
|
2611
2731
|
* @param {boolean} enable
|
|
2612
2732
|
* @memberof Settings */
|
|
@@ -2785,10 +2905,10 @@ class EngineObject
|
|
|
2785
2905
|
if (pa)
|
|
2786
2906
|
{
|
|
2787
2907
|
const c = cos(-pa), s = sin(-pa);
|
|
2788
|
-
this.pos
|
|
2908
|
+
this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
|
|
2789
2909
|
}
|
|
2790
2910
|
else
|
|
2791
|
-
this.pos
|
|
2911
|
+
this.pos.set(lx + pp.x, ly + pp.y);
|
|
2792
2912
|
this.angle = mirror*this.localAngle + pa;
|
|
2793
2913
|
}
|
|
2794
2914
|
|
|
@@ -3020,6 +3140,9 @@ class EngineObject
|
|
|
3020
3140
|
drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor);
|
|
3021
3141
|
}
|
|
3022
3142
|
|
|
3143
|
+
/** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
|
|
3144
|
+
renderLight() {}
|
|
3145
|
+
|
|
3023
3146
|
/** Destroy this object, destroy its children, detach its parent, and mark it for removal
|
|
3024
3147
|
* @param {boolean} [immediate] - should attached effects be allowed to die off? */
|
|
3025
3148
|
destroy(immediate=false)
|
|
@@ -3524,11 +3647,12 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
|
3524
3647
|
// normal canvas 2D rendering method (slower)
|
|
3525
3648
|
++drawCount;
|
|
3526
3649
|
++primitiveCount;
|
|
3527
|
-
size = new Vector2(size.x, -size.y); // flip upside down sprites
|
|
3528
3650
|
drawCanvas2D(pos, size, angle, mirror, (context)=>
|
|
3529
3651
|
{
|
|
3530
3652
|
if (textureInfo)
|
|
3531
3653
|
{
|
|
3654
|
+
// un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
|
|
3655
|
+
context.scale(1, -1);
|
|
3532
3656
|
// calculate uvs and render
|
|
3533
3657
|
const x = tileInfo.pos.x, y = tileInfo.pos.y;
|
|
3534
3658
|
const w = tileInfo.size.x, h = tileInfo.size.y;
|
|
@@ -3536,7 +3660,7 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
|
3536
3660
|
}
|
|
3537
3661
|
else
|
|
3538
3662
|
{
|
|
3539
|
-
// if no tile info, use untextured rect
|
|
3663
|
+
// if no tile info, use untextured rect (Y-symmetric, no compensation needed)
|
|
3540
3664
|
const c = additiveColor ? color.add(additiveColor) : color;
|
|
3541
3665
|
context.fillStyle = c.toString();
|
|
3542
3666
|
context.fillRect(-.5, -.5, 1, 1);
|
|
@@ -3610,11 +3734,10 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, an
|
|
|
3610
3734
|
// normal canvas 2D rendering method (slower)
|
|
3611
3735
|
++drawCount;
|
|
3612
3736
|
++primitiveCount;
|
|
3613
|
-
size = new Vector2(size.x, -size.y); // fix upside down sprites
|
|
3614
3737
|
drawCanvas2D(pos, size, angle, false, (context)=>
|
|
3615
3738
|
{
|
|
3616
|
-
//
|
|
3617
|
-
const gradient = context.createLinearGradient(0,
|
|
3739
|
+
// gradient endpoints are flipped to match the Y flip inside drawCanvas2D
|
|
3740
|
+
const gradient = context.createLinearGradient(0, .5, 0, -.5);
|
|
3618
3741
|
gradient.addColorStop(0, colorTop.toString());
|
|
3619
3742
|
gradient.addColorStop(1, colorBottom.toString());
|
|
3620
3743
|
context.fillStyle = gradient;
|
|
@@ -4033,7 +4156,10 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
|
|
|
4033
4156
|
* @memberof Draw
|
|
4034
4157
|
*/
|
|
4035
4158
|
|
|
4036
|
-
/** Draw directly to a 2d canvas context in world space
|
|
4159
|
+
/** Draw directly to a 2d canvas context in world space.
|
|
4160
|
+
* The Y axis is flipped so world-Y-up coordinates render right-side up
|
|
4161
|
+
* (matches the WebGL path). Callers whose drawing depends on Y direction
|
|
4162
|
+
* (e.g. linear gradients) should flip their own Y endpoints accordingly.
|
|
4037
4163
|
* @param {Vector2} pos
|
|
4038
4164
|
* @param {Vector2} size
|
|
4039
4165
|
* @param {number} angle
|
|
@@ -4279,6 +4405,62 @@ function screenToWorldTransform(screenPos, screenSize, screenAngle=0)
|
|
|
4279
4405
|
* @memberof Draw */
|
|
4280
4406
|
function getCameraSize() { return mainCanvasSize.scale(1/cameraScale); }
|
|
4281
4407
|
|
|
4408
|
+
/** Fit the camera to a rectangle in world space by setting cameraPos and cameraScale
|
|
4409
|
+
* - worldMargin pads the content rectangle in world units, so the gap scales with the content on resize
|
|
4410
|
+
* - screenInset reserves space in screen pixels on each viewport edge (for example a HUD band) and
|
|
4411
|
+
* re-centers the content away from that edge, so the reserved band stays a fixed pixel size on resize
|
|
4412
|
+
* - worldMargin and screenInset may each be a number for all sides, a Vector2 (x=left/right, y=top/bottom),
|
|
4413
|
+
* or an object with any of {top, right, bottom, left}
|
|
4414
|
+
* @param {Vector2} center - Center of the rectangle in world space
|
|
4415
|
+
* @param {Vector2} size - Size of the rectangle in world space
|
|
4416
|
+
* @param {number|Vector2|Object} [worldMargin] - World space padding added around the content rectangle
|
|
4417
|
+
* @param {number|Vector2|Object} [screenInset] - Screen space padding in pixels reserved on each viewport edge
|
|
4418
|
+
* @return {number} - The new camera scale
|
|
4419
|
+
* @memberof Draw */
|
|
4420
|
+
function cameraFit(center, size, worldMargin, screenInset)
|
|
4421
|
+
{
|
|
4422
|
+
ASSERT(isVector2(center), 'center must be a vec2');
|
|
4423
|
+
ASSERT(isVector2(size), 'size must be a vec2');
|
|
4424
|
+
|
|
4425
|
+
// pad the content
|
|
4426
|
+
const margin = padSides(worldMargin);
|
|
4427
|
+
const inset = padSides(screenInset);
|
|
4428
|
+
const worldW = size.x + margin.left + margin.right;
|
|
4429
|
+
const worldH = size.y + margin.top + margin.bottom;
|
|
4430
|
+
const viewW = mainCanvasSize.x - inset.left - inset.right;
|
|
4431
|
+
const viewH = mainCanvasSize.y - inset.top - inset.bottom;
|
|
4432
|
+
|
|
4433
|
+
// bail on a degenerate rect or viewport rather than NaN the camera
|
|
4434
|
+
if (!(worldW > 0 && worldH > 0 && viewW > 0 && viewH > 0))
|
|
4435
|
+
return cameraScale;
|
|
4436
|
+
|
|
4437
|
+
// scale to fit the padded content
|
|
4438
|
+
cameraScale = min(viewW / worldW, viewH / worldH);
|
|
4439
|
+
|
|
4440
|
+
// calculate offset vectors
|
|
4441
|
+
const marginVector = vec2(margin.right - margin.left, margin.top - margin.bottom).scale(.5);
|
|
4442
|
+
const insetVector = vec2(inset.right - inset.left, inset.top - inset.bottom).scale(.5 / cameraScale);
|
|
4443
|
+
|
|
4444
|
+
// apply the offsets and return camera scale
|
|
4445
|
+
cameraPos = center.add(marginVector).add(insetVector);
|
|
4446
|
+
return cameraScale;
|
|
4447
|
+
|
|
4448
|
+
function padSides(p)
|
|
4449
|
+
{
|
|
4450
|
+
// normalize a padding option to {top, right, bottom, left}
|
|
4451
|
+
if (p === undefined || isNumber(p))
|
|
4452
|
+
p = vec2(p);
|
|
4453
|
+
if (isVector2(p))
|
|
4454
|
+
return { top: p.y, right: p.x, bottom: p.y, left: p.x };
|
|
4455
|
+
return {
|
|
4456
|
+
top: p.top || 0,
|
|
4457
|
+
right: p.right || 0,
|
|
4458
|
+
bottom: p.bottom || 0,
|
|
4459
|
+
left: p.left || 0,
|
|
4460
|
+
};
|
|
4461
|
+
}
|
|
4462
|
+
}
|
|
4463
|
+
|
|
4282
4464
|
/** Check if a box, point, or circle is on screen with a circle test
|
|
4283
4465
|
* If size is a Vector2, uses the length as diameter
|
|
4284
4466
|
* This can be used to cull offscreen objects from render or update
|
|
@@ -4317,14 +4499,13 @@ function isOnScreen(pos, size=0)
|
|
|
4317
4499
|
y + size > -h && y - size < h;
|
|
4318
4500
|
}
|
|
4319
4501
|
|
|
4320
|
-
/** Enable
|
|
4502
|
+
/** Enable additive blending
|
|
4321
4503
|
* @param {boolean} [additive]
|
|
4322
|
-
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
|
|
4323
4504
|
* @memberof Draw */
|
|
4324
|
-
function
|
|
4505
|
+
function setAdditiveBlendMode(additive=true)
|
|
4325
4506
|
{
|
|
4326
4507
|
glAdditive = additive;
|
|
4327
|
-
|
|
4508
|
+
drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
|
|
4328
4509
|
}
|
|
4329
4510
|
|
|
4330
4511
|
/** Combines LittleJS canvases onto the main canvas
|
|
@@ -4650,11 +4831,30 @@ let mouseWheel = 0;
|
|
|
4650
4831
|
* @memberof Input */
|
|
4651
4832
|
let mouseInWindow = true;
|
|
4652
4833
|
|
|
4653
|
-
/**
|
|
4834
|
+
/** True if a gamepad is the most recently used input device.
|
|
4835
|
+
* Equivalent to usingGamepadInput(); derived from lastInputDevice each frame.
|
|
4654
4836
|
* @type {boolean}
|
|
4655
4837
|
* @memberof Input */
|
|
4656
4838
|
let isUsingGamepad = false;
|
|
4657
4839
|
|
|
4840
|
+
/** The most recently used input device: 'mouse' | 'keyboard' | 'gamepad'.
|
|
4841
|
+
* Sticky: it holds its value while every device is idle, so a mouse-follow
|
|
4842
|
+
* control (e.g. paddle = mousePos) won't snap back the instant the stick/keys
|
|
4843
|
+
* are released. With several devices in play at once (e.g. keyboard to move +
|
|
4844
|
+
* mouse to aim) it tracks whichever was touched last each frame, so it may
|
|
4845
|
+
* alternate — that's intended; use it to pick which control drives a shared
|
|
4846
|
+
* action. Updated every frame by inputUpdate().
|
|
4847
|
+
* @type {string}
|
|
4848
|
+
* @memberof Input */
|
|
4849
|
+
let lastInputDevice = 'mouse';
|
|
4850
|
+
|
|
4851
|
+
/** Screen-pixel mouse movement per frame that counts as "using the mouse"
|
|
4852
|
+
* (so sub-pixel hand jitter doesn't steal focus from the keyboard/gamepad).
|
|
4853
|
+
* @type {number}
|
|
4854
|
+
* @default
|
|
4855
|
+
* @memberof Input */
|
|
4856
|
+
let inputMouseMoveThreshold = 6;
|
|
4857
|
+
|
|
4658
4858
|
/** Prevents input continuing to the default browser handling (true by default)
|
|
4659
4859
|
* @type {boolean}
|
|
4660
4860
|
* @memberof Input */
|
|
@@ -4675,6 +4875,18 @@ const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
|
|
|
4675
4875
|
* @memberof Input */
|
|
4676
4876
|
function setInputPreventDefault(preventDefault=true) { inputPreventDefault = preventDefault; }
|
|
4677
4877
|
|
|
4878
|
+
/** Set the screen-pixel mouse movement per frame that counts as using the mouse
|
|
4879
|
+
* @param {number} threshold
|
|
4880
|
+
* @memberof Input */
|
|
4881
|
+
function setInputMouseMoveThreshold(threshold) { inputMouseMoveThreshold = threshold; }
|
|
4882
|
+
|
|
4883
|
+
/** @return {boolean} - Is the mouse the most recently used input device? @memberof Input */
|
|
4884
|
+
function usingMouseInput() { return lastInputDevice === 'mouse'; }
|
|
4885
|
+
/** @return {boolean} - Is the keyboard the most recently used input device? @memberof Input */
|
|
4886
|
+
function usingKeyboardInput() { return lastInputDevice === 'keyboard'; }
|
|
4887
|
+
/** @return {boolean} - Is a gamepad the most recently used input device? @memberof Input */
|
|
4888
|
+
function usingGamepadInput() { return lastInputDevice === 'gamepad'; }
|
|
4889
|
+
|
|
4678
4890
|
/** Clears an input key state
|
|
4679
4891
|
* @param {string|number} key
|
|
4680
4892
|
* @param {number} [device]
|
|
@@ -4697,6 +4909,7 @@ function inputClear()
|
|
|
4697
4909
|
inputData[0] = [];
|
|
4698
4910
|
touchGamepadButtons.length = 0;
|
|
4699
4911
|
touchGamepadSticks.length = 0;
|
|
4912
|
+
touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor
|
|
4700
4913
|
gamepadStickData.length = 0;
|
|
4701
4914
|
gamepadDpadData.length = 0;
|
|
4702
4915
|
}
|
|
@@ -4939,6 +5152,14 @@ const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
|
|
|
4939
5152
|
|
|
4940
5153
|
// touch gamepad internal variables
|
|
4941
5154
|
const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
|
|
5155
|
+
// floating stick anchors (stage-local CSS pixels) and owning pointer ids, indexed by stick (0=left, 1=right)
|
|
5156
|
+
const touchGamepadStickAnchors = [], touchGamepadStickPointerId = [];
|
|
5157
|
+
// pointerId -> control role ('stick0', 'stick1', 'face<n>', or 'start')
|
|
5158
|
+
const touchGamepadPointerRole = new Map();
|
|
5159
|
+
// overlay DOM elements (created lazily on touch devices) and cached SVG shapes
|
|
5160
|
+
let touchGamepadOverlay, touchGamepadStage, touchGamepadSvg, touchGamepadSvgEls;
|
|
5161
|
+
let touchGamepadSideZones = [], touchGamepadZoneC;
|
|
5162
|
+
let touchGamepadNeedRelayout = true, touchGamepadLastLayout;
|
|
4942
5163
|
|
|
4943
5164
|
///////////////////////////////////////////////////////////////////////////////
|
|
4944
5165
|
// Input system functions used by engine
|
|
@@ -4966,7 +5187,6 @@ function inputInit()
|
|
|
4966
5187
|
{
|
|
4967
5188
|
if (!e.repeat)
|
|
4968
5189
|
{
|
|
4969
|
-
isUsingGamepad = false;
|
|
4970
5190
|
inputData[0][e.code] = 3;
|
|
4971
5191
|
if (inputWASDEmulateDirection)
|
|
4972
5192
|
inputData[0][remapKey(e.code)] = 3;
|
|
@@ -5025,7 +5245,6 @@ function inputInit()
|
|
|
5025
5245
|
if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
|
|
5026
5246
|
audioContext.resume();
|
|
5027
5247
|
|
|
5028
|
-
isUsingGamepad = false;
|
|
5029
5248
|
inputData[0][e.button] = 3;
|
|
5030
5249
|
|
|
5031
5250
|
const mousePosScreenLast = mousePosScreen;
|
|
@@ -5063,7 +5282,15 @@ function inputInit()
|
|
|
5063
5282
|
e.preventDefault(); // prevent page scrolling
|
|
5064
5283
|
}
|
|
5065
5284
|
function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
|
|
5066
|
-
function onBlur()
|
|
5285
|
+
function onBlur()
|
|
5286
|
+
{
|
|
5287
|
+
inputClear();
|
|
5288
|
+
// release any held virtual gamepad controls so they don't stick
|
|
5289
|
+
touchGamepadPointerRole.clear();
|
|
5290
|
+
touchGamepadButtons.length = 0;
|
|
5291
|
+
touchGamepadSticks.length = 0;
|
|
5292
|
+
touchGamepadStickPointerId.length = 0;
|
|
5293
|
+
}
|
|
5067
5294
|
|
|
5068
5295
|
// enable touch input mouse passthrough
|
|
5069
5296
|
function touchInputInit()
|
|
@@ -5079,36 +5306,43 @@ function inputInit()
|
|
|
5079
5306
|
{
|
|
5080
5307
|
if (!touchInputEnable) return;
|
|
5081
5308
|
|
|
5082
|
-
// route touch to gamepad
|
|
5083
|
-
if (touchGamepadEnable)
|
|
5084
|
-
handleTouchGamepad(e);
|
|
5085
|
-
|
|
5086
5309
|
// fix stalled audio requiring user interaction
|
|
5087
5310
|
if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
|
|
5088
5311
|
audioContext.resume();
|
|
5089
5312
|
|
|
5090
|
-
//
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
if (
|
|
5313
|
+
// when the touch gamepad is enabled it owns touch input: suppress the
|
|
5314
|
+
// touch->mouse passthrough entirely unless touchGamepadPassthrough is set
|
|
5315
|
+
// (its own zones drive gameplay via pointer events)
|
|
5316
|
+
if (!touchGamepadEnable || touchGamepadPassthrough)
|
|
5094
5317
|
{
|
|
5095
|
-
//
|
|
5096
|
-
|
|
5097
|
-
const
|
|
5098
|
-
|
|
5099
|
-
|
|
5318
|
+
// touches that landed on a virtual gamepad zone are owned by the gamepad
|
|
5319
|
+
// (handled by its own pointer listeners) and must not drive the game mouse
|
|
5320
|
+
const isGamepadTouch = (t)=>
|
|
5321
|
+
touchGamepadSideZones.includes(t.target) || t.target === touchGamepadZoneC;
|
|
5322
|
+
const gameTouches = [];
|
|
5323
|
+
for (const t of e.touches)
|
|
5324
|
+
if (!isGamepadTouch(t)) gameTouches.push(t);
|
|
5325
|
+
|
|
5326
|
+
// check if touching and pass to mouse events
|
|
5327
|
+
const touching = gameTouches.length;
|
|
5328
|
+
const button = 0; // all touches are left mouse button
|
|
5329
|
+
if (touching)
|
|
5100
5330
|
{
|
|
5101
|
-
|
|
5102
|
-
|
|
5331
|
+
// set event pos and pass it along
|
|
5332
|
+
const pos = vec2(gameTouches[0].clientX, gameTouches[0].clientY);
|
|
5333
|
+
const mousePosScreenLast = mousePosScreen;
|
|
5334
|
+
mousePosScreen = mouseEventToScreen(pos);
|
|
5335
|
+
if (wasTouching)
|
|
5336
|
+
mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
|
|
5337
|
+
else
|
|
5338
|
+
inputData[0][button] = 3;
|
|
5103
5339
|
}
|
|
5104
|
-
else
|
|
5105
|
-
inputData[0][button] =
|
|
5106
|
-
}
|
|
5107
|
-
else if (wasTouching)
|
|
5108
|
-
inputData[0][button] = inputData[0][button] & 2 | 4;
|
|
5340
|
+
else if (wasTouching)
|
|
5341
|
+
inputData[0][button] = inputData[0][button] & 2 | 4;
|
|
5109
5342
|
|
|
5110
|
-
|
|
5111
|
-
|
|
5343
|
+
// set was touching
|
|
5344
|
+
wasTouching = touching;
|
|
5345
|
+
}
|
|
5112
5346
|
|
|
5113
5347
|
// prevent default handling like copy, magnifier lens, and scrolling
|
|
5114
5348
|
if (inputPreventDefault && e.cancelable && document.hasFocus())
|
|
@@ -5118,83 +5352,6 @@ function inputInit()
|
|
|
5118
5352
|
return true;
|
|
5119
5353
|
}
|
|
5120
5354
|
|
|
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
5355
|
}
|
|
5199
5356
|
|
|
5200
5357
|
// convert a mouse or touch event position to screen space
|
|
@@ -5219,9 +5376,48 @@ function inputUpdate()
|
|
|
5219
5376
|
mousePos = screenToWorld(mousePosScreen);
|
|
5220
5377
|
mouseDelta = screenToWorldDelta(mouseDeltaScreen);
|
|
5221
5378
|
|
|
5379
|
+
// build the touch gamepad overlay lazily once enabled on a touch device
|
|
5380
|
+
touchGamepadInit();
|
|
5381
|
+
|
|
5222
5382
|
// update gamepads if enabled
|
|
5223
5383
|
gamepadsUpdate();
|
|
5224
|
-
|
|
5384
|
+
|
|
5385
|
+
// update most recently used input device
|
|
5386
|
+
updateLastInputDevice();
|
|
5387
|
+
|
|
5388
|
+
function updateLastInputDevice()
|
|
5389
|
+
{
|
|
5390
|
+
// mouse: any button held or moved
|
|
5391
|
+
const mouseActive = mouseIsDown(0) || mouseIsDown(1) || mouseIsDown(2) || mouseDeltaScreen.length() > inputMouseMoveThreshold;
|
|
5392
|
+
|
|
5393
|
+
// gamepad: any button held or stick moved
|
|
5394
|
+
let gamepadActive = false;
|
|
5395
|
+
for (let s = gamepadStickCount(); s-- && !gamepadActive;)
|
|
5396
|
+
gamepadActive = gamepadStick(s).lengthSquared() > .04;
|
|
5397
|
+
for (let b = 17; b-- && !gamepadActive;)
|
|
5398
|
+
gamepadActive = gamepadIsDown(b);
|
|
5399
|
+
|
|
5400
|
+
// keyboard: any non-mouse key down
|
|
5401
|
+
let keyboardActive = false;
|
|
5402
|
+
for (const k in inputData[0])
|
|
5403
|
+
if (isNaN(+k) && (inputData[0][k] & 1))
|
|
5404
|
+
{
|
|
5405
|
+
keyboardActive = true;
|
|
5406
|
+
break;
|
|
5407
|
+
}
|
|
5408
|
+
|
|
5409
|
+
// update the last input
|
|
5410
|
+
if (gamepadActive)
|
|
5411
|
+
lastInputDevice = 'gamepad';
|
|
5412
|
+
else if (mouseActive)
|
|
5413
|
+
lastInputDevice = 'mouse';
|
|
5414
|
+
else if (keyboardActive)
|
|
5415
|
+
lastInputDevice = 'keyboard';
|
|
5416
|
+
|
|
5417
|
+
// set flag if gamepad is last device
|
|
5418
|
+
isUsingGamepad = lastInputDevice === 'gamepad';
|
|
5419
|
+
}
|
|
5420
|
+
|
|
5225
5421
|
// gamepads are updated by engine every frame automatically
|
|
5226
5422
|
function gamepadsUpdate()
|
|
5227
5423
|
{
|
|
@@ -5237,22 +5433,11 @@ function inputUpdate()
|
|
|
5237
5433
|
// update touch gamepad if enabled
|
|
5238
5434
|
if (touchGamepadEnable && isTouchDevice)
|
|
5239
5435
|
{
|
|
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
|
-
}
|
|
5436
|
+
// a side is either a stick or buttons - setting both is ambiguous
|
|
5437
|
+
ASSERT(!touchGamepadLeftStick || !touchGamepadLeftButtonCount,
|
|
5438
|
+
'set touchGamepadLeftStick or touchGamepadLeftButtonCount, not both');
|
|
5439
|
+
ASSERT(!touchGamepadRightStick || !touchGamepadButtonCount,
|
|
5440
|
+
'set touchGamepadRightStick or touchGamepadButtonCount, not both');
|
|
5256
5441
|
|
|
5257
5442
|
if (!touchGamepadTimer.isSet()) return;
|
|
5258
5443
|
|
|
@@ -5260,23 +5445,24 @@ function inputUpdate()
|
|
|
5260
5445
|
gamepadPrimary = 0; // touch gamepad uses index 0
|
|
5261
5446
|
const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
|
|
5262
5447
|
const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
|
|
5263
|
-
sticks
|
|
5448
|
+
sticks.length = 0; // only report sticks that are enabled
|
|
5264
5449
|
dpad.set();
|
|
5265
|
-
|
|
5266
|
-
|
|
5267
|
-
sticks[0] = applyDeadZones(leftTouchStick);
|
|
5268
|
-
else if (leftTouchStick.lengthSquared() > .3)
|
|
5450
|
+
// read each side's directional stick (analog, or quantized to an 8 way dpad)
|
|
5451
|
+
for (let side = 0; side < 2; side++)
|
|
5269
5452
|
{
|
|
5270
|
-
|
|
5271
|
-
const
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5453
|
+
if (!touchGamepadSideStick(side)) continue;
|
|
5454
|
+
const out = touchGamepadStickOut(side);
|
|
5455
|
+
sticks[out] = vec2();
|
|
5456
|
+
const touchStick = touchGamepadSticks[side] ?? vec2();
|
|
5457
|
+
if (touchGamepadAnalog)
|
|
5458
|
+
sticks[out] = applyDeadZones(touchStick);
|
|
5459
|
+
else if (touchStick.lengthSquared() > .3)
|
|
5460
|
+
{
|
|
5461
|
+
const x = clamp(round(touchStick.x), -1, 1);
|
|
5462
|
+
const y = clamp(round(touchStick.y), -1, 1);
|
|
5463
|
+
sticks[out] = vec2(x, -y).clampLength(); // clamp to circle
|
|
5464
|
+
if (!out) dpad.set(x, -y); // the primary (stick 0) also drives the dpad vector
|
|
5465
|
+
}
|
|
5280
5466
|
}
|
|
5281
5467
|
|
|
5282
5468
|
// read virtual gamepad buttons
|
|
@@ -5285,6 +5471,12 @@ function inputUpdate()
|
|
|
5285
5471
|
{
|
|
5286
5472
|
const wasDown = gamepadIsDown(i,0);
|
|
5287
5473
|
data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
|
|
5474
|
+
|
|
5475
|
+
// haptic tap when a face button or start button is first pressed (3 = newly down)
|
|
5476
|
+
// skip stick touches (10, 11) so movement doesn't buzz
|
|
5477
|
+
if (touchGamepadVibration && data[i] === 3 &&
|
|
5478
|
+
(i === 9 || touchGamepadIsFaceButton(i)))
|
|
5479
|
+
vibrate(touchGamepadVibration);
|
|
5288
5480
|
}
|
|
5289
5481
|
|
|
5290
5482
|
// disable normal gamepads when touch gamepad is active
|
|
@@ -5348,7 +5540,6 @@ function inputUpdate()
|
|
|
5348
5540
|
gamepadHadInput[i] = true;
|
|
5349
5541
|
if (!gamepadHadInput[gamepadPrimary])
|
|
5350
5542
|
gamepadPrimary = i;
|
|
5351
|
-
isUsingGamepad ||= (gamepadPrimary === i);
|
|
5352
5543
|
}
|
|
5353
5544
|
|
|
5354
5545
|
if (gamepad.mapping === 'standard')
|
|
@@ -5385,80 +5576,476 @@ function inputUpdatePost()
|
|
|
5385
5576
|
function inputRender()
|
|
5386
5577
|
{
|
|
5387
5578
|
touchGamepadRender();
|
|
5579
|
+
}
|
|
5388
5580
|
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
if (!touchInputEnable || !isTouchDevice || headlessMode) return;
|
|
5392
|
-
if (!touchGamepadEnable || !touchGamepadTimer.isSet() && touchGamepadDisplayTime) return;
|
|
5581
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5582
|
+
// Touch gamepad - full-viewport HTML/SVG overlay driven by Pointer Events
|
|
5393
5583
|
|
|
5394
|
-
|
|
5395
|
-
const alpha = touchGamepadDisplayTime ? percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
|
|
5396
|
-
if (!alpha || paused) return;
|
|
5584
|
+
const touchGamepadSvgNS = 'http://www.w3.org/2000/svg';
|
|
5397
5585
|
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5586
|
+
// build the overlay DOM once; no-op if already built, disabled, headless, or non-touch
|
|
5587
|
+
function touchGamepadInit()
|
|
5588
|
+
{
|
|
5589
|
+
if (touchGamepadOverlay || !touchGamepadEnable || !isTouchDevice || headlessMode ||
|
|
5590
|
+
!document.body) // body may not exist yet; retry on a later frame
|
|
5591
|
+
return;
|
|
5404
5592
|
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5593
|
+
// full-viewport overlay; only the input zones receive pointer events. The
|
|
5594
|
+
// env() padding insets the stage out of notches / home indicators natively.
|
|
5595
|
+
const overlay = touchGamepadOverlay = document.createElement('div');
|
|
5596
|
+
overlay.style.cssText =
|
|
5597
|
+
'position:fixed;inset:0;z-index:50;pointer-events:none;opacity:0;' +
|
|
5598
|
+
'touch-action:none;user-select:none;-webkit-user-select:none;' +
|
|
5599
|
+
'-webkit-touch-callout:none;transition:opacity .2s;box-sizing:border-box;' +
|
|
5600
|
+
'padding:env(safe-area-inset-top) env(safe-area-inset-right) ' +
|
|
5601
|
+
'env(safe-area-inset-bottom) env(safe-area-inset-left)';
|
|
5602
|
+
|
|
5603
|
+
// stage fills the padded (safe-area) content box; all controls live inside it
|
|
5604
|
+
const stage = touchGamepadStage = document.createElement('div');
|
|
5605
|
+
stage.style.cssText = 'position:relative;width:100%;height:100%;pointer-events:none';
|
|
5606
|
+
overlay.appendChild(stage);
|
|
5607
|
+
|
|
5608
|
+
// svg draws every visual and never blocks input
|
|
5609
|
+
const svg = touchGamepadSvg = document.createElementNS(touchGamepadSvgNS, 'svg');
|
|
5610
|
+
svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;' +
|
|
5611
|
+
'pointer-events:none;overflow:visible;fill:none;stroke:#fff;stroke-width:3';
|
|
5612
|
+
stage.appendChild(svg);
|
|
5613
|
+
|
|
5614
|
+
// invisible input zones (left stick, right buttons/stick, center start)
|
|
5615
|
+
const makeZone = ()=>
|
|
5616
|
+
{
|
|
5617
|
+
const z = document.createElement('div');
|
|
5618
|
+
z.style.cssText = 'position:absolute;pointer-events:auto;touch-action:none';
|
|
5619
|
+
z.addEventListener('pointerdown', e=> touchGamepadPointerDown(e, z));
|
|
5620
|
+
z.addEventListener('pointermove', e=> touchGamepadPointerMove(e));
|
|
5621
|
+
z.addEventListener('pointerup', e=> touchGamepadPointerUp(e));
|
|
5622
|
+
z.addEventListener('pointercancel', e=> touchGamepadPointerUp(e));
|
|
5623
|
+
stage.appendChild(z);
|
|
5624
|
+
return z;
|
|
5625
|
+
};
|
|
5626
|
+
touchGamepadSideZones[0] = makeZone(); // left
|
|
5627
|
+
touchGamepadSideZones[1] = makeZone(); // right
|
|
5628
|
+
touchGamepadZoneC = makeZone(); // center/start, appended last so it sits above the sides
|
|
5629
|
+
|
|
5630
|
+
addEventListener('resize', ()=> touchGamepadNeedRelayout = true);
|
|
5631
|
+
document.body.appendChild(overlay);
|
|
5632
|
+
touchGamepadNeedRelayout = true;
|
|
5633
|
+
}
|
|
5634
|
+
|
|
5635
|
+
// stage-local size in CSS pixels (excludes safe-area insets)
|
|
5636
|
+
function touchGamepadStageRect() { return touchGamepadStage.getBoundingClientRect(); }
|
|
5637
|
+
|
|
5638
|
+
// per-side touch gamepad config (side 0 = left, 1 = right) - the left and right
|
|
5639
|
+
// sides behave identically, differing only in position and gamepad button indices
|
|
5640
|
+
function touchGamepadSideStick(side)
|
|
5641
|
+
{ return side ? touchGamepadRightStick : touchGamepadLeftStick; }
|
|
5642
|
+
function touchGamepadSideButtonCount(side)
|
|
5643
|
+
{ return side ? touchGamepadButtonCount : touchGamepadLeftButtonCount; }
|
|
5644
|
+
// gamepad button index a side's buttons start at (right 0-3, left 4-7)
|
|
5645
|
+
function touchGamepadSideButtonBase(side)
|
|
5646
|
+
{ return side ? 0 : 4; }
|
|
5647
|
+
// output stick index for a side: the right stick uses stick 0 when there is no left stick
|
|
5648
|
+
function touchGamepadStickOut(side)
|
|
5649
|
+
{ return side && touchGamepadLeftStick ? 1 : 0; }
|
|
5650
|
+
// true if the side has any control (a stick or at least one button)
|
|
5651
|
+
function touchGamepadSideHasControl(side)
|
|
5652
|
+
{ return touchGamepadSideStick(side) || touchGamepadSideButtonCount(side) > 0; }
|
|
5653
|
+
|
|
5654
|
+
// true if gamepad button index i is an active touch gamepad face/single button
|
|
5655
|
+
function touchGamepadIsFaceButton(i)
|
|
5656
|
+
{
|
|
5657
|
+
for (let side = 0; side < 2; side++)
|
|
5658
|
+
{
|
|
5659
|
+
const base = touchGamepadSideButtonBase(side);
|
|
5660
|
+
if (!touchGamepadSideStick(side) &&
|
|
5661
|
+
i >= base && i < base + touchGamepadSideButtonCount(side))
|
|
5662
|
+
return true;
|
|
5663
|
+
}
|
|
5664
|
+
return false;
|
|
5665
|
+
}
|
|
5666
|
+
|
|
5667
|
+
// center of a side's controls in stage-local CSS pixels (stick rest / button cluster)
|
|
5668
|
+
// returns the floating stick anchor when that side is an active floating stick
|
|
5669
|
+
function touchGamepadSideCenter(side, W, H)
|
|
5670
|
+
{
|
|
5671
|
+
if (touchGamepadFloating && touchGamepadSideStick(side) && touchGamepadStickAnchors[side])
|
|
5672
|
+
return touchGamepadStickAnchors[side];
|
|
5673
|
+
let y = H - touchGamepadSize;
|
|
5674
|
+
const count = touchGamepadSideButtonCount(side);
|
|
5675
|
+
if (!touchGamepadSideStick(side) && (count === 2 || count === 3))
|
|
5676
|
+
y -= touchGamepadSize/4; // nudge a 2/3 button cluster up a bit
|
|
5677
|
+
return vec2(side ? W - touchGamepadSize : touchGamepadSize, y);
|
|
5678
|
+
}
|
|
5679
|
+
|
|
5680
|
+
// position the input zones for the current mode and rebuild the SVG visuals
|
|
5681
|
+
function touchGamepadRelayout()
|
|
5682
|
+
{
|
|
5683
|
+
if (!touchGamepadOverlay) return;
|
|
5684
|
+
const r = touchGamepadStageRect();
|
|
5685
|
+
const W = r.width, H = r.height, S = touchGamepadSize;
|
|
5686
|
+
const setZone = (z, css)=> z.style.cssText =
|
|
5687
|
+
'position:absolute;pointer-events:auto;touch-action:none;' + css;
|
|
5688
|
+
|
|
5689
|
+
if (paused)
|
|
5690
|
+
{
|
|
5691
|
+
// the gamepad is hidden while paused, so its side zones must not capture
|
|
5692
|
+
// touches - otherwise they silently steal taps from menus and dialogs
|
|
5693
|
+
for (const zone of touchGamepadSideZones) zone.style.display = 'none';
|
|
5694
|
+
if (touchGamepadCenterButtonSize)
|
|
5411
5695
|
{
|
|
5412
|
-
//
|
|
5413
|
-
|
|
5696
|
+
// any touch presses start
|
|
5697
|
+
setZone(touchGamepadZoneC, 'inset:0');
|
|
5698
|
+
touchGamepadZoneC.style.display = '';
|
|
5414
5699
|
}
|
|
5415
5700
|
else
|
|
5701
|
+
touchGamepadZoneC.style.display = 'none';
|
|
5702
|
+
}
|
|
5703
|
+
else
|
|
5704
|
+
{
|
|
5705
|
+
// position each side zone (left/right differ only by which edge they hug)
|
|
5706
|
+
for (let side = 0; side < 2; side++)
|
|
5416
5707
|
{
|
|
5417
|
-
|
|
5418
|
-
|
|
5708
|
+
const zone = touchGamepadSideZones[side], edge = side ? 'right' : 'left';
|
|
5709
|
+
zone.style.display = touchGamepadSideHasControl(side) ? '' : 'none';
|
|
5710
|
+
if (touchGamepadFloating)
|
|
5419
5711
|
{
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
|
|
5712
|
+
// bottom 60% grabs the control; the top 40% passes through. A side with no
|
|
5713
|
+
// control on the other side uses the full width (matching the hit-test)
|
|
5714
|
+
const width = touchGamepadSideHasControl(side ? 0 : 1) ? '50%' : '100%';
|
|
5715
|
+
setZone(zone, `${edge}:0;bottom:0;width:${width};height:60%`);
|
|
5423
5716
|
}
|
|
5717
|
+
else // fixed: a compact box hugging the corner control
|
|
5718
|
+
setZone(zone, `${edge}:0;bottom:0;width:${3*S}px;height:${3*S}px`);
|
|
5424
5719
|
}
|
|
5425
|
-
|
|
5426
|
-
|
|
5720
|
+
touchGamepadZoneC.style.display = touchGamepadCenterButtonSize ? '' : 'none';
|
|
5721
|
+
const c = touchGamepadCenterButtonSize;
|
|
5722
|
+
setZone(touchGamepadZoneC,
|
|
5723
|
+
`left:50%;top:50%;width:${2*c}px;height:${2*c}px;transform:translate(-50%,-50%)`);
|
|
5724
|
+
}
|
|
5725
|
+
|
|
5726
|
+
touchGamepadBuildSvg(W, H);
|
|
5727
|
+
touchGamepadNeedRelayout = false;
|
|
5728
|
+
}
|
|
5729
|
+
|
|
5730
|
+
// (re)build the SVG shapes for the current layout; dynamic bits update per-frame
|
|
5731
|
+
function touchGamepadBuildSvg(W, H)
|
|
5732
|
+
{
|
|
5733
|
+
const svg = touchGamepadSvg;
|
|
5734
|
+
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
|
5735
|
+
const els = touchGamepadSvgEls = { face: [], thumb: [] };
|
|
5736
|
+
const S = touchGamepadSize;
|
|
5737
|
+
const circle = (cx, cy, rr, fill)=>
|
|
5738
|
+
{
|
|
5739
|
+
const c = document.createElementNS(touchGamepadSvgNS, 'circle');
|
|
5740
|
+
c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', rr);
|
|
5741
|
+
if (fill) c.setAttribute('fill', fill);
|
|
5742
|
+
svg.appendChild(c);
|
|
5743
|
+
return c;
|
|
5744
|
+
};
|
|
5745
|
+
const cross = (ctr)=>
|
|
5746
|
+
{
|
|
5747
|
+
// plus-shaped dpad outline centered at ctr
|
|
5748
|
+
const a = S*.18, b = S*.5, x = ctr.x, y = ctr.y;
|
|
5749
|
+
const p = document.createElementNS(touchGamepadSvgNS, 'path');
|
|
5750
|
+
p.setAttribute('d',
|
|
5751
|
+
`M ${x-a} ${y-b} H ${x+a} V ${y-a} H ${x+b} V ${y+a} H ${x+a} ` +
|
|
5752
|
+
`V ${y+b} H ${x-a} V ${y+a} H ${x-b} V ${y-a} H ${x-a} Z`);
|
|
5753
|
+
svg.appendChild(p);
|
|
5754
|
+
};
|
|
5755
|
+
|
|
5756
|
+
// draw each side: a directional stick, a single large button, or face buttons
|
|
5757
|
+
for (let side = 0; side < 2; side++)
|
|
5758
|
+
{
|
|
5759
|
+
const count = touchGamepadSideButtonCount(side);
|
|
5760
|
+
const base = touchGamepadSideButtonBase(side);
|
|
5761
|
+
const ctr = touchGamepadSideCenter(side, W, H);
|
|
5762
|
+
if (touchGamepadSideStick(side))
|
|
5763
|
+
{
|
|
5764
|
+
// directional stick (circle or cross) with a thumb dot that moves per-frame
|
|
5765
|
+
if (touchGamepadAnalog) circle(ctr.x, ctr.y, S/2); else cross(ctr);
|
|
5766
|
+
els.thumb[side] = circle(ctr.x, ctr.y, S/4, '#fff');
|
|
5767
|
+
}
|
|
5768
|
+
else if (count === 1)
|
|
5769
|
+
els.face[base] = circle(ctr.x, ctr.y, S/2, '#000'); // single large button
|
|
5770
|
+
else for (let i = 0; i < count; i++)
|
|
5771
|
+
{
|
|
5772
|
+
const j = mod(i-1, 4);
|
|
5773
|
+
let button = count > 2 ? j : min(j, count-1);
|
|
5774
|
+
button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
|
|
5775
|
+
const offset = vec2().setDirection(j, S/2);
|
|
5776
|
+
if (count === 2) offset.x *= -1;
|
|
5777
|
+
// left side mirrors the right layout's positions, keeping indices in order
|
|
5778
|
+
// (e.g. 2 buttons -> button 4 at bottom, button 5 at left)
|
|
5779
|
+
if (!side) offset.x *= -1;
|
|
5780
|
+
const pos = ctr.add(offset);
|
|
5781
|
+
els.face[base + button] = circle(pos.x, pos.y, S/4, '#000');
|
|
5782
|
+
}
|
|
5783
|
+
}
|
|
5784
|
+
|
|
5785
|
+
// debug: draw the proximity hit regions the hit-test actually uses
|
|
5786
|
+
if (debug && debugGamepads) touchGamepadBuildDebug(W, H);
|
|
5787
|
+
}
|
|
5788
|
+
|
|
5789
|
+
// draw debug outlines of the touch control hit regions into the overlay svg
|
|
5790
|
+
function touchGamepadBuildDebug(W, H)
|
|
5791
|
+
{
|
|
5792
|
+
const S = touchGamepadSize, svg = touchGamepadSvg;
|
|
5793
|
+
const shape = (tag, attrs, stroke)=>
|
|
5794
|
+
{
|
|
5795
|
+
const el = document.createElementNS(touchGamepadSvgNS, tag);
|
|
5796
|
+
for (const k in attrs) el.setAttribute(k, attrs[k]);
|
|
5797
|
+
el.setAttribute('stroke', stroke);
|
|
5798
|
+
el.setAttribute('stroke-width', 2);
|
|
5799
|
+
el.setAttribute('fill', 'none');
|
|
5800
|
+
svg.appendChild(el);
|
|
5801
|
+
};
|
|
5802
|
+
const ring = (c, rr, stroke)=> shape('circle', {cx:c.x, cy:c.y, r:rr}, stroke);
|
|
5803
|
+
|
|
5804
|
+
// green line: the left/right split that assigns a stick press to a side
|
|
5805
|
+
shape('line', {x1:W/2, y1:0, x2:W/2, y2:H}, '#0f0');
|
|
5427
5806
|
|
|
5428
|
-
|
|
5807
|
+
// cyan: where each side's control can be grabbed
|
|
5808
|
+
for (let side = 0; side < 2; side++)
|
|
5809
|
+
{
|
|
5810
|
+
if (touchGamepadSideStick(side))
|
|
5429
5811
|
{
|
|
5430
|
-
|
|
5431
|
-
const buttonSize = touchGamepadButtonCount > 1 ?
|
|
5432
|
-
touchGamepadSize/4 : touchGamepadSize/2;
|
|
5433
|
-
for (let i=0; i<touchGamepadButtonCount; i++)
|
|
5812
|
+
if (touchGamepadFloating)
|
|
5434
5813
|
{
|
|
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();
|
|
5814
|
+
// grab region: this side's half (or the full width if the other side is empty)
|
|
5815
|
+
const top = H*.4, full = !touchGamepadSideHasControl(side ? 0 : 1);
|
|
5816
|
+
const x = full ? 0 : (side ? W/2 : 0);
|
|
5817
|
+
shape('rect', {x, y:top, width:full ? W : W/2, height:H-top}, '#0ff');
|
|
5447
5818
|
}
|
|
5819
|
+
else
|
|
5820
|
+
ring(touchGamepadSideCenter(side, W, H), 2*S, '#0ff');
|
|
5448
5821
|
}
|
|
5822
|
+
else if (touchGamepadSideButtonCount(side) >= 1)
|
|
5823
|
+
ring(touchGamepadSideCenter(side, W, H), S, '#0ff'); // face / single-button radius
|
|
5824
|
+
}
|
|
5449
5825
|
|
|
5450
|
-
|
|
5451
|
-
|
|
5826
|
+
// yellow: start button radius; magenta: where start is blocked (near a control)
|
|
5827
|
+
if (touchGamepadCenterButtonSize)
|
|
5828
|
+
{
|
|
5829
|
+
ring(vec2(W/2, H/2), touchGamepadCenterButtonSize, '#ff0');
|
|
5830
|
+
for (let side = 0; side < 2; side++)
|
|
5831
|
+
if (touchGamepadSideHasControl(side))
|
|
5832
|
+
ring(touchGamepadSideCenter(side, W, H), 2*S, '#f0f');
|
|
5833
|
+
}
|
|
5834
|
+
}
|
|
5835
|
+
|
|
5836
|
+
// per-frame: fade the overlay and move the thumbs / set pressed states
|
|
5837
|
+
function touchGamepadRender()
|
|
5838
|
+
{
|
|
5839
|
+
if (!touchGamepadOverlay || headlessMode) return;
|
|
5840
|
+
|
|
5841
|
+
// hide and bail if disabled at runtime (overlay stays in the DOM for reuse)
|
|
5842
|
+
// display:none also takes the input zones out of hit-testing so touches are
|
|
5843
|
+
// not silently captured away from the game while disabled
|
|
5844
|
+
if (!touchGamepadEnable || !isTouchDevice)
|
|
5845
|
+
{
|
|
5846
|
+
if (touchGamepadOverlay.style.display !== 'none')
|
|
5847
|
+
{
|
|
5848
|
+
// just disabled: hide the overlay and release any held controls
|
|
5849
|
+
touchGamepadOverlay.style.display = 'none';
|
|
5850
|
+
touchGamepadPointerRole.clear();
|
|
5851
|
+
touchGamepadButtons.length = 0;
|
|
5852
|
+
touchGamepadSticks.length = 0;
|
|
5853
|
+
touchGamepadStickPointerId.length = 0;
|
|
5854
|
+
}
|
|
5855
|
+
return;
|
|
5856
|
+
}
|
|
5857
|
+
touchGamepadOverlay.style.display = '';
|
|
5858
|
+
|
|
5859
|
+
// relayout when the paused state, a layout setting, or the debug view changes
|
|
5860
|
+
const dbg = debug && debugGamepads;
|
|
5861
|
+
const layout = [touchGamepadButtonCount, touchGamepadLeftButtonCount, touchGamepadLeftStick,
|
|
5862
|
+
touchGamepadRightStick, touchGamepadAnalog, touchGamepadSize, touchGamepadFloating,
|
|
5863
|
+
touchGamepadCenterButtonSize, paused, dbg].join();
|
|
5864
|
+
if (layout !== touchGamepadLastLayout)
|
|
5865
|
+
{
|
|
5866
|
+
touchGamepadLastLayout = layout;
|
|
5867
|
+
touchGamepadNeedRelayout = true;
|
|
5452
5868
|
}
|
|
5869
|
+
// relayout before the visibility bail-out so the paused full-screen start zone applies
|
|
5870
|
+
if (touchGamepadNeedRelayout) touchGamepadRelayout();
|
|
5871
|
+
|
|
5872
|
+
// fade out when idle (always show when displayTime is 0, or while debugging)
|
|
5873
|
+
const fade = touchGamepadDisplayTime ?
|
|
5874
|
+
percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
|
|
5875
|
+
const visible = dbg || (touchGamepadTimer.isSet() && fade > 0 && !paused);
|
|
5876
|
+
touchGamepadOverlay.style.opacity = !visible ? 0 : dbg ? 1 : fade*touchGamepadAlpha;
|
|
5877
|
+
if (!visible) return;
|
|
5878
|
+
|
|
5879
|
+
const r = touchGamepadStageRect();
|
|
5880
|
+
const W = r.width, H = r.height, S = touchGamepadSize;
|
|
5881
|
+
const els = touchGamepadSvgEls;
|
|
5882
|
+
if (!els) return;
|
|
5883
|
+
|
|
5884
|
+
for (let side = 0; side < 2; side++)
|
|
5885
|
+
if (touchGamepadSideStick(side) && els.thumb[side])
|
|
5886
|
+
{
|
|
5887
|
+
const ctr = touchGamepadSideCenter(side, W, H);
|
|
5888
|
+
const t = ctr.add((touchGamepadSticks[side] ?? vec2()).scale(S/2));
|
|
5889
|
+
els.thumb[side].setAttribute('cx', t.x);
|
|
5890
|
+
els.thumb[side].setAttribute('cy', t.y);
|
|
5891
|
+
}
|
|
5892
|
+
for (let i = 0; i < els.face.length; i++)
|
|
5893
|
+
if (els.face[i])
|
|
5894
|
+
els.face[i].setAttribute('fill', touchGamepadButtons[i] ? '#fff' : '#000');
|
|
5895
|
+
}
|
|
5896
|
+
|
|
5897
|
+
// convert a pointer event to stage-local CSS pixels
|
|
5898
|
+
function touchGamepadEventPos(e)
|
|
5899
|
+
{
|
|
5900
|
+
const r = touchGamepadStageRect();
|
|
5901
|
+
return vec2(e.clientX - r.left, e.clientY - r.top);
|
|
5453
5902
|
}
|
|
5454
5903
|
|
|
5455
|
-
//
|
|
5456
|
-
|
|
5904
|
+
// set a directional stick from a stage-local point and flag its stick-touch button
|
|
5905
|
+
// (stick 0 press = button 10, stick 1 press = button 11, following the output index)
|
|
5906
|
+
function touchGamepadApplyStick(side, p)
|
|
5457
5907
|
{
|
|
5458
|
-
const
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5908
|
+
const delta = p.subtract(touchGamepadStickAnchors[side]);
|
|
5909
|
+
touchGamepadSticks[side] = delta.scale(2/touchGamepadSize).clampLength();
|
|
5910
|
+
touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10] = 1;
|
|
5911
|
+
}
|
|
5912
|
+
|
|
5913
|
+
// pick a side's gamepad button index from a stage-local point, or -1 if outside the cluster
|
|
5914
|
+
function touchGamepadFaceButtonAt(side, p, W, H)
|
|
5915
|
+
{
|
|
5916
|
+
const count = touchGamepadSideButtonCount(side);
|
|
5917
|
+
const base = touchGamepadSideButtonBase(side);
|
|
5918
|
+
const bc = touchGamepadSideCenter(side, W, H);
|
|
5919
|
+
if (bc.distance(p) >= touchGamepadSize) return -1;
|
|
5920
|
+
if (count === 1) return base; // single large button
|
|
5921
|
+
const d = bc.subtract(p);
|
|
5922
|
+
if (!side) d.x *= -1; // left side mirrors the right layout's positions horizontally
|
|
5923
|
+
let button = count === 2 ? (d.x < d.y ? 1 : 0) : mod(d.direction()+2, 4);
|
|
5924
|
+
button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
|
|
5925
|
+
return button < count ? base + button : -1;
|
|
5926
|
+
}
|
|
5927
|
+
|
|
5928
|
+
// pick which control a stage-local press activates, by priority then proximity,
|
|
5929
|
+
// independent of which zone element captured it - so overlapping zones on small
|
|
5930
|
+
// screens resolve to the nearest control instead of whichever zone is topmost
|
|
5931
|
+
// returns {role:'stick', side} or {role:'face', btn} or {role:'start'} or undefined
|
|
5932
|
+
function touchGamepadControlAt(p, W, H)
|
|
5933
|
+
{
|
|
5934
|
+
const S = touchGamepadSize;
|
|
5935
|
+
const leftHalf = p.x < W/2;
|
|
5936
|
+
const floatTop = H*.4; // floating grab region is the bottom 60% of the screen
|
|
5937
|
+
|
|
5938
|
+
// check each side (left first for priority); a side is a stick or buttons
|
|
5939
|
+
for (let side = 0; side < 2; side++)
|
|
5940
|
+
{
|
|
5941
|
+
const onHalf = side ? !leftHalf : leftHalf;
|
|
5942
|
+
if (touchGamepadSideStick(side))
|
|
5943
|
+
{
|
|
5944
|
+
// a side with no control on the other side uses the full width
|
|
5945
|
+
const otherControl = touchGamepadSideHasControl(side ? 0 : 1);
|
|
5946
|
+
const grab = touchGamepadFloating ?
|
|
5947
|
+
(!otherControl || onHalf) && p.y > floatTop :
|
|
5948
|
+
onHalf && touchGamepadSideCenter(side, W, H).distance(p) < 2*S;
|
|
5949
|
+
if (grab) return {role:'stick', side};
|
|
5950
|
+
}
|
|
5951
|
+
else if (touchGamepadSideButtonCount(side) >= 1)
|
|
5952
|
+
{
|
|
5953
|
+
const btn = touchGamepadFaceButtonAt(side, p, W, H);
|
|
5954
|
+
if (btn >= 0) return {role:'face', btn};
|
|
5955
|
+
}
|
|
5956
|
+
}
|
|
5957
|
+
|
|
5958
|
+
// center start button, blocked within 2*size of a control so drift off a
|
|
5959
|
+
// control can't accidentally fire start (matches the original exclusion logic)
|
|
5960
|
+
if (touchGamepadCenterButtonSize)
|
|
5961
|
+
{
|
|
5962
|
+
for (let side = 0; side < 2; side++)
|
|
5963
|
+
if (touchGamepadSideHasControl(side) &&
|
|
5964
|
+
touchGamepadSideCenter(side, W, H).distance(p) < 2*S)
|
|
5965
|
+
return;
|
|
5966
|
+
if (vec2(W/2, H/2).distance(p) < touchGamepadCenterButtonSize)
|
|
5967
|
+
return {role:'start'};
|
|
5968
|
+
}
|
|
5969
|
+
}
|
|
5970
|
+
|
|
5971
|
+
function touchGamepadPointerDown(e, zone)
|
|
5972
|
+
{
|
|
5973
|
+
if (!touchGamepadEnable) return;
|
|
5974
|
+
e.preventDefault();
|
|
5975
|
+
zone.setPointerCapture(e.pointerId);
|
|
5976
|
+
touchGamepadTimer.set();
|
|
5977
|
+
|
|
5978
|
+
// resume audio on first interaction
|
|
5979
|
+
if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
|
|
5980
|
+
audioContext.resume();
|
|
5981
|
+
|
|
5982
|
+
// while paused, any touch is the start button
|
|
5983
|
+
if (paused)
|
|
5984
|
+
{
|
|
5985
|
+
if (touchGamepadCenterButtonSize)
|
|
5986
|
+
{
|
|
5987
|
+
touchGamepadButtons[9] = 1;
|
|
5988
|
+
touchGamepadPointerRole.set(e.pointerId, 'start');
|
|
5989
|
+
}
|
|
5990
|
+
return;
|
|
5991
|
+
}
|
|
5992
|
+
|
|
5993
|
+
const r = touchGamepadStageRect();
|
|
5994
|
+
const W = r.width, H = r.height;
|
|
5995
|
+
const p = vec2(e.clientX - r.left, e.clientY - r.top);
|
|
5996
|
+
|
|
5997
|
+
// choose the control by proximity/priority, not by which zone captured the touch
|
|
5998
|
+
const hit = touchGamepadControlAt(p, W, H);
|
|
5999
|
+
if (!hit) return;
|
|
6000
|
+
if (hit.role === 'stick')
|
|
6001
|
+
{
|
|
6002
|
+
const side = hit.side;
|
|
6003
|
+
touchGamepadStickAnchors[side] = touchGamepadFloating ? p : touchGamepadSideCenter(side, W, H);
|
|
6004
|
+
touchGamepadStickPointerId[side] = e.pointerId;
|
|
6005
|
+
touchGamepadPointerRole.set(e.pointerId, 'stick'+side);
|
|
6006
|
+
touchGamepadNeedRelayout = true; // base may have re-anchored
|
|
6007
|
+
touchGamepadApplyStick(side, p);
|
|
6008
|
+
}
|
|
6009
|
+
else if (hit.role === 'face')
|
|
6010
|
+
{
|
|
6011
|
+
touchGamepadButtons[hit.btn] = 1;
|
|
6012
|
+
touchGamepadPointerRole.set(e.pointerId, 'face'+hit.btn);
|
|
6013
|
+
}
|
|
6014
|
+
else // 'start'
|
|
6015
|
+
{
|
|
6016
|
+
touchGamepadButtons[9] = 1;
|
|
6017
|
+
touchGamepadPointerRole.set(e.pointerId, 'start');
|
|
6018
|
+
}
|
|
6019
|
+
}
|
|
6020
|
+
|
|
6021
|
+
function touchGamepadPointerMove(e)
|
|
6022
|
+
{
|
|
6023
|
+
const role = touchGamepadPointerRole.get(e.pointerId);
|
|
6024
|
+
if (!role) return;
|
|
6025
|
+
e.preventDefault();
|
|
6026
|
+
const p = touchGamepadEventPos(e);
|
|
6027
|
+
if (role === 'stick0' || role === 'stick1')
|
|
6028
|
+
touchGamepadApplyStick(role === 'stick1' ? 1 : 0, p);
|
|
6029
|
+
// face buttons & start are held until release (no slide-between this pass)
|
|
6030
|
+
}
|
|
6031
|
+
|
|
6032
|
+
function touchGamepadPointerUp(e)
|
|
6033
|
+
{
|
|
6034
|
+
const role = touchGamepadPointerRole.get(e.pointerId);
|
|
6035
|
+
if (!role) return;
|
|
6036
|
+
touchGamepadPointerRole.delete(e.pointerId);
|
|
6037
|
+
if (role === 'stick0' || role === 'stick1')
|
|
6038
|
+
{
|
|
6039
|
+
const side = role === 'stick1' ? 1 : 0;
|
|
6040
|
+
touchGamepadStickPointerId[side] = undefined;
|
|
6041
|
+
touchGamepadSticks[side] = vec2();
|
|
6042
|
+
delete touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10];
|
|
6043
|
+
}
|
|
6044
|
+
else if (role === 'start')
|
|
6045
|
+
delete touchGamepadButtons[9];
|
|
6046
|
+
else // 'face<n>'
|
|
6047
|
+
delete touchGamepadButtons[+role.slice(4)];
|
|
6048
|
+
touchGamepadTimer.set();
|
|
5462
6049
|
}
|
|
5463
6050
|
/**
|
|
5464
6051
|
* LittleJS Audio System
|
|
@@ -5595,7 +6182,7 @@ class Sound
|
|
|
5595
6182
|
* @param {number} [randomnessScale] - How much to scale pitch randomness
|
|
5596
6183
|
* @param {boolean} [loop] - Should the sound loop?
|
|
5597
6184
|
* @param {boolean} [paused] - Should the sound start paused
|
|
5598
|
-
* @return {SoundInstance} - The
|
|
6185
|
+
* @return {SoundInstance} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode
|
|
5599
6186
|
*/
|
|
5600
6187
|
play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
|
|
5601
6188
|
{
|
|
@@ -5626,9 +6213,23 @@ class Sound
|
|
|
5626
6213
|
pan = worldToScreen(pos).x * 2/mainCanvas.width - 1;
|
|
5627
6214
|
}
|
|
5628
6215
|
|
|
5629
|
-
// Create
|
|
6216
|
+
// Create sound instance
|
|
5630
6217
|
const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
|
|
5631
|
-
|
|
6218
|
+
const instance = new SoundInstance(this, volume, rate, pan, loop, paused);
|
|
6219
|
+
|
|
6220
|
+
if (debug && debugSound && pos)
|
|
6221
|
+
{
|
|
6222
|
+
// visualize where positioned sounds play and their falloff range
|
|
6223
|
+
debugCircle(pos, .5, '#0ff', .5, true);
|
|
6224
|
+
if (this.range)
|
|
6225
|
+
{
|
|
6226
|
+
debugCircle(pos, 2*this.range, '#0ff', .5); // silent radius
|
|
6227
|
+
debugCircle(pos, 2*this.range*this.taper, '#0ff', .5); // full volume radius
|
|
6228
|
+
}
|
|
6229
|
+
debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5);
|
|
6230
|
+
}
|
|
6231
|
+
|
|
6232
|
+
return instance;
|
|
5632
6233
|
}
|
|
5633
6234
|
|
|
5634
6235
|
/** Play a music track that loops by default
|
|
@@ -5994,6 +6595,10 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
|
|
|
5994
6595
|
// play and return sound
|
|
5995
6596
|
const startOffset = offset * rate;
|
|
5996
6597
|
source.start(0, startOffset);
|
|
6598
|
+
|
|
6599
|
+
if (debug && debugSound)
|
|
6600
|
+
LOG('sound', 'vol', volume.toFixed(2), 'rate', rate.toFixed(2), 'pan', pan.toFixed(2), loop ? 'loop' : '');
|
|
6601
|
+
|
|
5997
6602
|
return source;
|
|
5998
6603
|
}
|
|
5999
6604
|
|
|
@@ -6827,6 +7432,11 @@ class TileCollisionLayer extends TileLayer
|
|
|
6827
7432
|
// check any tiles in the area for collision
|
|
6828
7433
|
const posX = pos.x - this.pos.x;
|
|
6829
7434
|
const posY = pos.y - this.pos.y;
|
|
7435
|
+
// reject AABBs entirely past either edge; without this, the negative
|
|
7436
|
+
// side leaks into row/col 0 because minX/minY clamp to 0 and the
|
|
7437
|
+
// point-test floor below forces maxX/maxY up to 1
|
|
7438
|
+
if (posX + size.x/2 < 0 || posX - size.x/2 > this.size.x) return false;
|
|
7439
|
+
if (posY + size.y/2 < 0 || posY - size.y/2 > this.size.y) return false;
|
|
6830
7440
|
const minX = max(posX - size.x/2|0, 0);
|
|
6831
7441
|
const minY = max(posY - size.y/2|0, 0);
|
|
6832
7442
|
// ensure at least one cell is visited even when size is 0 and pos
|
|
@@ -6945,10 +7555,10 @@ class ParticleEmitter extends EngineObject
|
|
|
6945
7555
|
* @param {number} [particleTime] - How long particles live
|
|
6946
7556
|
* @param {number} [sizeStart] - How big are particles at start
|
|
6947
7557
|
* @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
|
|
7558
|
+
* @param {number} [speed] - How fast are particles when spawned, in world units per frame (at 60fps, so multiply units/sec by 1/60)
|
|
7559
|
+
* @param {number} [angleSpeed] - How fast are particles rotating, in radians per frame (at 60fps)
|
|
7560
|
+
* @param {number} [damping] - How much to dampen particle speed, per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame)
|
|
7561
|
+
* @param {number} [angleDamping] - How much to dampen particle angular speed, per-frame multiplier (1 = no damping)
|
|
6952
7562
|
* @param {number} [gravityScale] - How much gravity effect particles
|
|
6953
7563
|
* @param {number} [particleConeAngle] - Cone for start particle angle
|
|
6954
7564
|
* @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 +7633,13 @@ class ParticleEmitter extends EngineObject
|
|
|
7023
7633
|
this.sizeStart = sizeStart;
|
|
7024
7634
|
/** @property {number} - How big are particles at end */
|
|
7025
7635
|
this.sizeEnd = sizeEnd;
|
|
7026
|
-
/** @property {number} -
|
|
7636
|
+
/** @property {number} - Particle speed when spawned, in world units per frame (at 60fps) */
|
|
7027
7637
|
this.speed = speed;
|
|
7028
|
-
/** @property {number} -
|
|
7638
|
+
/** @property {number} - Particle angular speed when spawned, in radians per frame (at 60fps) */
|
|
7029
7639
|
this.angleSpeed = angleSpeed;
|
|
7030
|
-
/** @property {number} -
|
|
7640
|
+
/** @property {number} - Per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame) */
|
|
7031
7641
|
this.damping = damping;
|
|
7032
|
-
/** @property {number} -
|
|
7642
|
+
/** @property {number} - Per-frame angular velocity multiplier (1 = no damping) */
|
|
7033
7643
|
this.angleDamping = angleDamping;
|
|
7034
7644
|
/** @property {number} - How much gravity affects particles */
|
|
7035
7645
|
this.gravityScale = gravityScale;
|
|
@@ -7098,12 +7708,16 @@ class ParticleEmitter extends EngineObject
|
|
|
7098
7708
|
else if (this.particles.length === 0)
|
|
7099
7709
|
this.destroy(true);
|
|
7100
7710
|
|
|
7101
|
-
// update and remove destroyed particles
|
|
7102
|
-
|
|
7711
|
+
// update and remove destroyed particles in place to avoid per-frame array allocation
|
|
7712
|
+
const particles = this.particles;
|
|
7713
|
+
let alive = 0;
|
|
7714
|
+
for (let i = 0; i < particles.length; ++i)
|
|
7103
7715
|
{
|
|
7716
|
+
const p = particles[i];
|
|
7104
7717
|
p.update();
|
|
7105
|
-
|
|
7106
|
-
}
|
|
7718
|
+
if (!p.destroyed) particles[alive++] = p;
|
|
7719
|
+
}
|
|
7720
|
+
particles.length = alive;
|
|
7107
7721
|
|
|
7108
7722
|
if (debugParticles)
|
|
7109
7723
|
{
|
|
@@ -7375,9 +7989,6 @@ class Particle
|
|
|
7375
7989
|
this.color.b = p2 * this.colorStart.b + p1 * this.colorEnd.b;
|
|
7376
7990
|
this.color.a = (p2 * this.colorStart.a + p1 * this.colorEnd.a) * alphaFade;
|
|
7377
7991
|
|
|
7378
|
-
// draw the particle
|
|
7379
|
-
additive && setBlendMode(true);
|
|
7380
|
-
|
|
7381
7992
|
// update the position and angle for drawing
|
|
7382
7993
|
const pos = particleDrawPos.set(this.pos.x, this.pos.y);
|
|
7383
7994
|
let angle = this.angle;
|
|
@@ -7385,16 +7996,19 @@ class Particle
|
|
|
7385
7996
|
{
|
|
7386
7997
|
// in local space of emitter
|
|
7387
7998
|
const a = emitter.angle;
|
|
7388
|
-
const c = cos(a), s = sin(a);
|
|
7999
|
+
const c = cos(-a), s = sin(-a);
|
|
7389
8000
|
pos.set(emitter.pos.x + pos.x*c - pos.y*s,
|
|
7390
8001
|
emitter.pos.y + pos.x*s + pos.y*c);
|
|
7391
8002
|
angle += a;
|
|
7392
8003
|
}
|
|
8004
|
+
|
|
8005
|
+
// draw the particle
|
|
8006
|
+
additive && setAdditiveBlendMode();
|
|
7393
8007
|
if (trailScale)
|
|
7394
8008
|
{
|
|
7395
8009
|
// trail style particles
|
|
7396
|
-
const velocity = localSpace ?
|
|
7397
|
-
this.velocity.rotate(
|
|
8010
|
+
const velocity = localSpace ?
|
|
8011
|
+
this.velocity.rotate(emitter.angle) : this.velocity;
|
|
7398
8012
|
const speed = velocity.length();
|
|
7399
8013
|
if (speed)
|
|
7400
8014
|
{
|
|
@@ -7407,7 +8021,7 @@ class Particle
|
|
|
7407
8021
|
}
|
|
7408
8022
|
else
|
|
7409
8023
|
drawTile(pos, size, this.tileInfo, this.color, angle, this.mirror);
|
|
7410
|
-
additive &&
|
|
8024
|
+
additive && setAdditiveBlendMode(false);
|
|
7411
8025
|
debugParticles && debugRect(pos, size, '#f005', 0, angle);
|
|
7412
8026
|
}
|
|
7413
8027
|
}
|
|
@@ -8556,7 +9170,15 @@ function medalsInit(saveName)
|
|
|
8556
9170
|
// check if medals are unlocked
|
|
8557
9171
|
medalsSaveName = saveName;
|
|
8558
9172
|
if (!debugMedals)
|
|
8559
|
-
|
|
9173
|
+
{
|
|
9174
|
+
let saved = {};
|
|
9175
|
+
try { saved = JSON.parse(localStorage[saveName] || '{}'); }
|
|
9176
|
+
catch (e) { saved = {}; }
|
|
9177
|
+
medalsForEach(medal => {
|
|
9178
|
+
medal.unlocked = !!(saved[medal.id] && saved[medal.id].unlocked);
|
|
9179
|
+
});
|
|
9180
|
+
medalsSave();
|
|
9181
|
+
}
|
|
8560
9182
|
|
|
8561
9183
|
// engine automatically renders medals
|
|
8562
9184
|
engineAddPlugin(undefined, medalsRender);
|
|
@@ -8600,6 +9222,31 @@ function medalsInit(saveName)
|
|
|
8600
9222
|
function medalsForEach(callback)
|
|
8601
9223
|
{ Object.values(medals).forEach(medal=>callback(medal)); }
|
|
8602
9224
|
|
|
9225
|
+
/** Reset all medals to locked and persist the cleared catalog
|
|
9226
|
+
* @memberof Medals */
|
|
9227
|
+
function medalsReset()
|
|
9228
|
+
{
|
|
9229
|
+
medalsForEach(medal => medal.unlocked = false);
|
|
9230
|
+
medalsSave();
|
|
9231
|
+
}
|
|
9232
|
+
|
|
9233
|
+
function medalsSave()
|
|
9234
|
+
{
|
|
9235
|
+
if (!medalsSaveName) return;
|
|
9236
|
+
const data = {};
|
|
9237
|
+
medalsForEach(medal => {
|
|
9238
|
+
const entry = {
|
|
9239
|
+
name: medal.name,
|
|
9240
|
+
description: medal.description,
|
|
9241
|
+
icon: medal.icon,
|
|
9242
|
+
unlocked: medal.unlocked,
|
|
9243
|
+
};
|
|
9244
|
+
if (medal.image) entry.src = medal.image.src;
|
|
9245
|
+
data[medal.id] = entry;
|
|
9246
|
+
});
|
|
9247
|
+
localStorage[medalsSaveName] = JSON.stringify(data);
|
|
9248
|
+
}
|
|
9249
|
+
|
|
8603
9250
|
///////////////////////////////////////////////////////////////////////////////
|
|
8604
9251
|
|
|
8605
9252
|
/**
|
|
@@ -8657,9 +9304,9 @@ class Medal
|
|
|
8657
9304
|
{
|
|
8658
9305
|
if (medalsPreventUnlock || this.unlocked) return;
|
|
8659
9306
|
|
|
8660
|
-
// save the medal
|
|
8661
9307
|
ASSERT(medalsSaveName, 'save name must be set');
|
|
8662
|
-
|
|
9308
|
+
this.unlocked = true;
|
|
9309
|
+
medalsSave();
|
|
8663
9310
|
medalsDisplayQueue.push(this);
|
|
8664
9311
|
}
|
|
8665
9312
|
|
|
@@ -8717,8 +9364,6 @@ class Medal
|
|
|
8717
9364
|
drawTextScreen(this.icon, pos, size*.7, BLACK);
|
|
8718
9365
|
}
|
|
8719
9366
|
|
|
8720
|
-
// Get local storage key used by the medal
|
|
8721
|
-
storageKey() { return medalsSaveName + '_' + this.id; }
|
|
8722
9367
|
}
|
|
8723
9368
|
|
|
8724
9369
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -9103,6 +9748,335 @@ class PostProcessPlugin
|
|
|
9103
9748
|
}
|
|
9104
9749
|
}
|
|
9105
9750
|
}
|
|
9751
|
+
/**
|
|
9752
|
+
* LittleJS Light System Plugin
|
|
9753
|
+
* - Adds 2D dynamic lighting to the scene
|
|
9754
|
+
* - Lights are first-class EngineObjects (the Light class)
|
|
9755
|
+
* - Each Light draws a soft falloff blob of its color into a shared lightmap
|
|
9756
|
+
* - Lights accumulate ADDITIVELY in the lightmap (red + blue = magenta)
|
|
9757
|
+
* - The lightmap is then MULTIPLIED with the scene during composite, so unlit
|
|
9758
|
+
* areas go to the ambient color and lit areas show the scene tinted by the
|
|
9759
|
+
* accumulated light color
|
|
9760
|
+
* - Draw the world at full brightness — the lightmap does the darkening
|
|
9761
|
+
* - Any EngineObject may override renderLight() to additively contribute to the
|
|
9762
|
+
* lightmap (e.g. emissive lava tiles, weapon flashes, glowing crystals)
|
|
9763
|
+
* - Must be constructed BEFORE PostProcessPlugin so post-process sees lit pixels
|
|
9764
|
+
* @namespace LightSystem
|
|
9765
|
+
*/
|
|
9766
|
+
|
|
9767
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
9768
|
+
|
|
9769
|
+
/** Global Light System plugin object
|
|
9770
|
+
* @type {LightSystemPlugin}
|
|
9771
|
+
* @memberof LightSystem */
|
|
9772
|
+
let lightSystem;
|
|
9773
|
+
|
|
9774
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
9775
|
+
|
|
9776
|
+
/**
|
|
9777
|
+
* LightSystemPlugin
|
|
9778
|
+
* - Owns the offscreen lightmap texture, falloff/composite shaders, and the
|
|
9779
|
+
* per-frame render pass that multiplies the lightmap onto the WebGL scene
|
|
9780
|
+
* - The composite is MULTIPLICATIVE: unlit areas get the ambient color, lit
|
|
9781
|
+
* areas show the scene tinted by the accumulated light color. So you should
|
|
9782
|
+
* draw your world at full brightness — the lightmap handles the darkening.
|
|
9783
|
+
* @memberof LightSystem
|
|
9784
|
+
*/
|
|
9785
|
+
class LightSystemPlugin
|
|
9786
|
+
{
|
|
9787
|
+
/** Create the global light system plugin.
|
|
9788
|
+
* @param {Vector2} [textureSize] - Size of the lightmap texture (defaults to mainCanvasSize)
|
|
9789
|
+
* @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.
|
|
9790
|
+
* @example
|
|
9791
|
+
* // simplest usage
|
|
9792
|
+
* new LightSystemPlugin();
|
|
9793
|
+
*/
|
|
9794
|
+
constructor(textureSize, ambientColor)
|
|
9795
|
+
{
|
|
9796
|
+
ASSERT(!lightSystem, 'LightSystemPlugin already initialized');
|
|
9797
|
+
ASSERT(!postProcess, 'LightSystemPlugin must be created before PostProcessPlugin');
|
|
9798
|
+
lightSystem = this;
|
|
9799
|
+
|
|
9800
|
+
/** @property {boolean} - When false, the render pass is skipped entirely */
|
|
9801
|
+
this.enabled = true;
|
|
9802
|
+
/** @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. */
|
|
9803
|
+
this.ambientColor = (ambientColor || BLACK).copy();
|
|
9804
|
+
/** @property {Vector2} - Size of the lightmap texture (set at construction; falls back to mainCanvasSize at init time) */
|
|
9805
|
+
this.textureSize = textureSize ? textureSize.copy() : undefined;
|
|
9806
|
+
|
|
9807
|
+
/** @property {WebGLTexture} - The lightmap texture */
|
|
9808
|
+
this.texture = undefined;
|
|
9809
|
+
/** @property {WebGLProgram} - Shader for drawing per-Light falloff blobs into the lightmap */
|
|
9810
|
+
this.lightShader = undefined;
|
|
9811
|
+
/** @property {WebGLProgram} - Shader for compositing the lightmap over the main scene */
|
|
9812
|
+
this.compositeShader = undefined;
|
|
9813
|
+
/** @property {WebGLVertexArrayObject} - Vertex array object for the light shader */
|
|
9814
|
+
this.lightVAO = undefined;
|
|
9815
|
+
/** @property {WebGLVertexArrayObject} - Vertex array object for the composite shader */
|
|
9816
|
+
this.compositeVAO = undefined;
|
|
9817
|
+
|
|
9818
|
+
initLightSystem();
|
|
9819
|
+
engineAddPlugin(undefined, lightSystemRender,
|
|
9820
|
+
lightSystemContextLost, lightSystemContextRestored);
|
|
9821
|
+
|
|
9822
|
+
function initLightSystem()
|
|
9823
|
+
{
|
|
9824
|
+
if (headlessMode) return;
|
|
9825
|
+
if (!glEnable)
|
|
9826
|
+
{
|
|
9827
|
+
console.warn('LightSystemPlugin: WebGL not enabled!');
|
|
9828
|
+
return;
|
|
9829
|
+
}
|
|
9830
|
+
|
|
9831
|
+
// resolve texture size default at init time (mainCanvasSize may
|
|
9832
|
+
// not be set yet at the moment the constructor first ran)
|
|
9833
|
+
if (!lightSystem.textureSize)
|
|
9834
|
+
lightSystem.textureSize = mainCanvasSize.copy();
|
|
9835
|
+
|
|
9836
|
+
// allocate the lightmap texture with null data at textureSize
|
|
9837
|
+
lightSystem.texture = glContext.createTexture();
|
|
9838
|
+
glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
|
|
9839
|
+
glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA,
|
|
9840
|
+
lightSystem.textureSize.x, lightSystem.textureSize.y, 0,
|
|
9841
|
+
glContext.RGBA, glContext.UNSIGNED_BYTE, null);
|
|
9842
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, glContext.LINEAR);
|
|
9843
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, glContext.LINEAR);
|
|
9844
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, glContext.CLAMP_TO_EDGE);
|
|
9845
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, glContext.CLAMP_TO_EDGE);
|
|
9846
|
+
|
|
9847
|
+
// light falloff shader: one quad per Light, fragment computes radial falloff
|
|
9848
|
+
lightSystem.lightShader = glCreateProgram(
|
|
9849
|
+
'#version 300 es\n' +
|
|
9850
|
+
'precision highp float;'+
|
|
9851
|
+
'uniform mat4 m;'+
|
|
9852
|
+
'uniform vec2 lightPos;'+
|
|
9853
|
+
'uniform float radius;'+
|
|
9854
|
+
'in vec2 g;'+ // unit quad geometry [0..1]
|
|
9855
|
+
'out vec2 vWorldPos;'+
|
|
9856
|
+
'void main(){'+
|
|
9857
|
+
'vec2 worldP=lightPos+(g-.5)*2.*radius;'+
|
|
9858
|
+
'gl_Position=m*vec4(worldP,1,1);'+
|
|
9859
|
+
'vWorldPos=worldP;'+
|
|
9860
|
+
'}'
|
|
9861
|
+
,
|
|
9862
|
+
'#version 300 es\n' +
|
|
9863
|
+
'precision highp float;'+
|
|
9864
|
+
'uniform vec2 lightPos;'+
|
|
9865
|
+
'uniform float radius;'+
|
|
9866
|
+
'uniform float fadeRange;'+
|
|
9867
|
+
'uniform vec4 color;'+
|
|
9868
|
+
'in vec2 vWorldPos;'+
|
|
9869
|
+
'out vec4 c;'+
|
|
9870
|
+
'void main(){'+
|
|
9871
|
+
'float dist=distance(vWorldPos,lightPos);'+
|
|
9872
|
+
'float t=clamp((radius-dist)/max(fadeRange,1e-6),0.,1.);'+
|
|
9873
|
+
'c=vec4(color.rgb*t*color.a,1.);'+
|
|
9874
|
+
'}'
|
|
9875
|
+
);
|
|
9876
|
+
|
|
9877
|
+
// composite shader: fullscreen quad, samples the lightmap
|
|
9878
|
+
lightSystem.compositeShader = glCreateProgram(
|
|
9879
|
+
'#version 300 es\n' +
|
|
9880
|
+
'precision highp float;'+
|
|
9881
|
+
'in vec2 p;'+
|
|
9882
|
+
'void main(){'+
|
|
9883
|
+
'gl_Position=vec4(p+p-1.,1,1);'+
|
|
9884
|
+
'}'
|
|
9885
|
+
,
|
|
9886
|
+
'#version 300 es\n' +
|
|
9887
|
+
'precision highp float;'+
|
|
9888
|
+
'uniform sampler2D s;'+
|
|
9889
|
+
'uniform vec3 iResolution;'+
|
|
9890
|
+
'out vec4 c;'+
|
|
9891
|
+
'void main(){'+
|
|
9892
|
+
'vec2 uv=gl_FragCoord.xy/iResolution.xy;'+
|
|
9893
|
+
'c=vec4(texture(s,uv).rgb,1.);'+
|
|
9894
|
+
'}'
|
|
9895
|
+
);
|
|
9896
|
+
|
|
9897
|
+
// VAO for the per-Light quad — reuses the engine unit triangle-strip
|
|
9898
|
+
lightSystem.lightVAO = glContext.createVertexArray();
|
|
9899
|
+
glContext.bindVertexArray(lightSystem.lightVAO);
|
|
9900
|
+
glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer);
|
|
9901
|
+
const gLight = glContext.getAttribLocation(lightSystem.lightShader, 'g');
|
|
9902
|
+
glContext.enableVertexAttribArray(gLight);
|
|
9903
|
+
glContext.vertexAttribPointer(gLight, 2, glContext.FLOAT, false, 8, 0);
|
|
9904
|
+
|
|
9905
|
+
// VAO for the composite fullscreen quad — same buffer, attribute named 'p'
|
|
9906
|
+
lightSystem.compositeVAO = glContext.createVertexArray();
|
|
9907
|
+
glContext.bindVertexArray(lightSystem.compositeVAO);
|
|
9908
|
+
glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer);
|
|
9909
|
+
const pComp = glContext.getAttribLocation(lightSystem.compositeShader, 'p');
|
|
9910
|
+
glContext.enableVertexAttribArray(pComp);
|
|
9911
|
+
glContext.vertexAttribPointer(pComp, 2, glContext.FLOAT, false, 8, 0);
|
|
9912
|
+
}
|
|
9913
|
+
function lightSystemRender()
|
|
9914
|
+
{
|
|
9915
|
+
if (headlessMode || !glEnable) return;
|
|
9916
|
+
if (!lightSystem.enabled) return;
|
|
9917
|
+
if (!lightSystem.texture) return; // init failed or context lost
|
|
9918
|
+
|
|
9919
|
+
// 1. flush any in-flight sprite batch from earlier render passes
|
|
9920
|
+
glFlush();
|
|
9921
|
+
const prevAdditive = glAdditive;
|
|
9922
|
+
|
|
9923
|
+
// 2. bind lightmap as render target, clear to ambientColor
|
|
9924
|
+
const ac = lightSystem.ambientColor;
|
|
9925
|
+
glContext.bindFramebuffer(glContext.FRAMEBUFFER, glFramebuffer);
|
|
9926
|
+
glContext.framebufferTexture2D(glContext.FRAMEBUFFER,
|
|
9927
|
+
glContext.COLOR_ATTACHMENT0, glContext.TEXTURE_2D, lightSystem.texture, 0);
|
|
9928
|
+
glContext.viewport(0, 0, lightSystem.textureSize.x, lightSystem.textureSize.y);
|
|
9929
|
+
glContext.clearColor(ac.r, ac.g, ac.b, ac.a);
|
|
9930
|
+
glContext.clear(glContext.COLOR_BUFFER_BIT);
|
|
9931
|
+
|
|
9932
|
+
// 3. walk engineObjects calling renderLight() — additive blend
|
|
9933
|
+
// (lightmap accumulates raw additive color contributions)
|
|
9934
|
+
setAdditiveBlendMode();
|
|
9935
|
+
glContext.enable(glContext.BLEND);
|
|
9936
|
+
glContext.blendFunc(glContext.ONE, glContext.ONE);
|
|
9937
|
+
|
|
9938
|
+
for (const o of engineObjects)
|
|
9939
|
+
o.destroyed || o.renderLight();
|
|
9940
|
+
|
|
9941
|
+
// 4. drain any sprite-batched draws (e.g. drawTile inside a
|
|
9942
|
+
// custom renderLight override) so they hit the FBO, not the
|
|
9943
|
+
// canvas after we unbind
|
|
9944
|
+
glFlush();
|
|
9945
|
+
glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
|
|
9946
|
+
glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
|
|
9947
|
+
|
|
9948
|
+
// 5. composite: fullscreen quad, multiplicative blend onto glCanvas
|
|
9949
|
+
// (scene * lightmap — unlit areas go to black, lit areas are
|
|
9950
|
+
// the scene tinted by the accumulated light color)
|
|
9951
|
+
glContext.useProgram(lightSystem.compositeShader);
|
|
9952
|
+
glContext.bindVertexArray(lightSystem.compositeVAO);
|
|
9953
|
+
glContext.activeTexture(glContext.TEXTURE0);
|
|
9954
|
+
glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
|
|
9955
|
+
const cs = lightSystem.compositeShader;
|
|
9956
|
+
glContext.uniform1i(glContext.getUniformLocation(cs, 's'), 0);
|
|
9957
|
+
glContext.uniform3f(glContext.getUniformLocation(cs, 'iResolution'),
|
|
9958
|
+
mainCanvas.width, mainCanvas.height, 1);
|
|
9959
|
+
glContext.blendFunc(glContext.DST_COLOR, glContext.ZERO);
|
|
9960
|
+
glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
|
|
9961
|
+
|
|
9962
|
+
// 6. restore engine state so subsequent draws use the engine's
|
|
9963
|
+
// tracked texture binding (otherwise glSetTexture would think
|
|
9964
|
+
// the prior texture was still bound when actually the lightmap
|
|
9965
|
+
// is, and any debug text / future draw could sample the lightmap)
|
|
9966
|
+
if (glActiveTexture)
|
|
9967
|
+
glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
|
|
9968
|
+
setAdditiveBlendMode(prevAdditive);
|
|
9969
|
+
glSetInstancedMode(true);
|
|
9970
|
+
}
|
|
9971
|
+
function lightSystemContextLost()
|
|
9972
|
+
{
|
|
9973
|
+
lightSystem.texture = undefined;
|
|
9974
|
+
lightSystem.lightShader = undefined;
|
|
9975
|
+
lightSystem.compositeShader = undefined;
|
|
9976
|
+
lightSystem.lightVAO = undefined;
|
|
9977
|
+
lightSystem.compositeVAO = undefined;
|
|
9978
|
+
LOG('LightSystemPlugin: WebGL context lost');
|
|
9979
|
+
}
|
|
9980
|
+
function lightSystemContextRestored()
|
|
9981
|
+
{
|
|
9982
|
+
initLightSystem();
|
|
9983
|
+
LOG('LightSystemPlugin: WebGL context restored');
|
|
9984
|
+
}
|
|
9985
|
+
}
|
|
9986
|
+
|
|
9987
|
+
/** Draw a single Light's falloff blob into the currently bound lightmap.
|
|
9988
|
+
* Called by Light.renderLight() during the plugin's render pass.
|
|
9989
|
+
* @param {Light} light */
|
|
9990
|
+
drawLight(light)
|
|
9991
|
+
{
|
|
9992
|
+
if (headlessMode || !glEnable || !this.lightShader) return;
|
|
9993
|
+
|
|
9994
|
+
// drain any sprite-batched draws queued by a previous custom
|
|
9995
|
+
// renderLight() override (e.g. drawRect inside a LavaTile). They were
|
|
9996
|
+
// queued in the engine's instanced-vertex format and must flush with
|
|
9997
|
+
// the engine's shader+VAO bound — NOT this plugin's light shader.
|
|
9998
|
+
glFlush();
|
|
9999
|
+
|
|
10000
|
+
glContext.useProgram(this.lightShader);
|
|
10001
|
+
glContext.bindVertexArray(this.lightVAO);
|
|
10002
|
+
|
|
10003
|
+
// re-apply the engine camera transform onto this shader. Divide by
|
|
10004
|
+
// mainCanvasSize (not textureSize) so world→NDC matches the main
|
|
10005
|
+
// pass; the viewport handles the lightmap's actual resolution.
|
|
10006
|
+
// No y-flip here: the composite samples this FBO with
|
|
10007
|
+
// gl_FragCoord/iResolution (origin bottom-left), so storing world
|
|
10008
|
+
// +Y at the top of the texture lines up with the canvas convention.
|
|
10009
|
+
const s = vec2(2*cameraScale).divide(mainCanvasSize);
|
|
10010
|
+
const rotatedCam = cameraPos.rotate(-cameraAngle);
|
|
10011
|
+
const p = vec2(-1).subtract(rotatedCam.multiply(s));
|
|
10012
|
+
const ca = cos(cameraAngle);
|
|
10013
|
+
const sa = sin(cameraAngle);
|
|
10014
|
+
const transform = [
|
|
10015
|
+
s.x * ca, s.y * sa, 0, 0,
|
|
10016
|
+
-s.x * sa, s.y * ca, 0, 0,
|
|
10017
|
+
1, 1, 1, 0,
|
|
10018
|
+
p.x, p.y, 0, 1];
|
|
10019
|
+
|
|
10020
|
+
const ls = this.lightShader;
|
|
10021
|
+
glContext.uniformMatrix4fv(glContext.getUniformLocation(ls, 'm'), false, transform);
|
|
10022
|
+
glContext.uniform2f(glContext.getUniformLocation(ls, 'lightPos'), light.pos.x, light.pos.y);
|
|
10023
|
+
glContext.uniform1f(glContext.getUniformLocation(ls, 'radius'), light.radius);
|
|
10024
|
+
glContext.uniform1f(glContext.getUniformLocation(ls, 'fadeRange'), light.fadeRange);
|
|
10025
|
+
const c = light.color;
|
|
10026
|
+
glContext.uniform4f(glContext.getUniformLocation(ls, 'color'), c.r, c.g, c.b, c.a);
|
|
10027
|
+
|
|
10028
|
+
glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
|
|
10029
|
+
|
|
10030
|
+
// restore engine's instanced shader+VAO so subsequent renderLight()
|
|
10031
|
+
// overrides that batch through drawRect/drawTile work correctly
|
|
10032
|
+
glSetInstancedMode(true);
|
|
10033
|
+
}
|
|
10034
|
+
}
|
|
10035
|
+
|
|
10036
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
10037
|
+
|
|
10038
|
+
/**
|
|
10039
|
+
* A Light is an EngineObject that contributes a soft additive blob of color
|
|
10040
|
+
* to the LightSystem plugin's lightmap.
|
|
10041
|
+
* @extends EngineObject
|
|
10042
|
+
* @memberof LightSystem
|
|
10043
|
+
* @example
|
|
10044
|
+
* new Light(vec2(5, 5), 4, rgb(1, 0.5, 0)); // orange light, full soft blob
|
|
10045
|
+
* new Light(vec2(0, 0), 8, rgb(1, 1, 1), 2); // white core with 2-unit soft halo
|
|
10046
|
+
*/
|
|
10047
|
+
class Light extends EngineObject
|
|
10048
|
+
{
|
|
10049
|
+
/** Create a light object and add it to the engine object list
|
|
10050
|
+
* @param {Vector2} pos - World space position
|
|
10051
|
+
* @param {number} radius - Total extent of the light in world units
|
|
10052
|
+
* @param {Color} [color] - Color of the light; alpha modulates intensity
|
|
10053
|
+
* @param {number} [fadeRange] - Width of the soft edge in world units (defaults to radius) */
|
|
10054
|
+
constructor(pos, radius, color, fadeRange)
|
|
10055
|
+
{
|
|
10056
|
+
super(pos, vec2(1), undefined, 0, color);
|
|
10057
|
+
ASSERT(isNumber(radius) && radius >= 0, 'Light radius must be a non-negative number');
|
|
10058
|
+
ASSERT(fadeRange === undefined || (isNumber(fadeRange) && fadeRange >= 0),
|
|
10059
|
+
'Light fadeRange must be a non-negative number when provided');
|
|
10060
|
+
|
|
10061
|
+
/** @property {number} - Total extent of the light in world units */
|
|
10062
|
+
this.radius = radius;
|
|
10063
|
+
/** @property {number} - Width of the soft edge in world units */
|
|
10064
|
+
this.fadeRange = fadeRange === undefined ? radius : fadeRange;
|
|
10065
|
+
}
|
|
10066
|
+
|
|
10067
|
+
/** Lights are invisible in the main render pass — they only contribute
|
|
10068
|
+
* to the lightmap via renderLight(). */
|
|
10069
|
+
render() {}
|
|
10070
|
+
|
|
10071
|
+
/** Draw this light's falloff blob into the lightmap.
|
|
10072
|
+
* Called by LightSystemPlugin during its render pass. No-op when the
|
|
10073
|
+
* plugin or WebGL is unavailable. */
|
|
10074
|
+
renderLight()
|
|
10075
|
+
{
|
|
10076
|
+
lightSystem && lightSystem.drawLight(this);
|
|
10077
|
+
}
|
|
10078
|
+
}
|
|
10079
|
+
|
|
9106
10080
|
/**
|
|
9107
10081
|
* LittleJS ZzFXM Plugin
|
|
9108
10082
|
* @namespace ZzFXM
|
|
@@ -9400,7 +10374,7 @@ class UISystemPlugin
|
|
|
9400
10374
|
let targetPos, targetSize;
|
|
9401
10375
|
if (o.parent)
|
|
9402
10376
|
{
|
|
9403
|
-
targetPos = o.parent.
|
|
10377
|
+
targetPos = o.parent.nativePos;
|
|
9404
10378
|
targetSize = o.parent.size;
|
|
9405
10379
|
}
|
|
9406
10380
|
else
|
|
@@ -9414,7 +10388,7 @@ class UISystemPlugin
|
|
|
9414
10388
|
}
|
|
9415
10389
|
|
|
9416
10390
|
const a = o.anchor;
|
|
9417
|
-
o.
|
|
10391
|
+
o.nativePos = targetPos
|
|
9418
10392
|
.add(targetSize.multiply(a).scale(.5)) // anchor point on target
|
|
9419
10393
|
.subtract(o.size.multiply(a).scale(.5)) // pivot shift on self
|
|
9420
10394
|
.add(o.localPos); // user offset
|
|
@@ -9520,13 +10494,18 @@ class UISystemPlugin
|
|
|
9520
10494
|
|
|
9521
10495
|
function updateObject(o)
|
|
9522
10496
|
{
|
|
9523
|
-
if (!o.visible) return;
|
|
10497
|
+
if (o.destroyed || !o.visible) return;
|
|
9524
10498
|
|
|
9525
10499
|
// update in reverse order to detect mouse enter/leave
|
|
9526
10500
|
updateTransforms(o);
|
|
9527
10501
|
for (let i=o.children.length; i--;)
|
|
9528
|
-
|
|
9529
|
-
|
|
10502
|
+
{
|
|
10503
|
+
// a child may destroy siblings mid-update (e.g. dialog close)
|
|
10504
|
+
const child = o.children[i];
|
|
10505
|
+
child && updateObject(child);
|
|
10506
|
+
}
|
|
10507
|
+
if (!o.destroyed)
|
|
10508
|
+
o.update();
|
|
9530
10509
|
}
|
|
9531
10510
|
}
|
|
9532
10511
|
function uiRender()
|
|
@@ -9950,10 +10929,15 @@ class UIObject
|
|
|
9950
10929
|
ASSERT(isVector2(pos), 'ui object pos must be a vec2');
|
|
9951
10930
|
ASSERT(isVector2(size), 'ui object size must be a vec2');
|
|
9952
10931
|
|
|
9953
|
-
/** @property {Vector2} -
|
|
10932
|
+
/** @property {Vector2} - Position you set: an offset from this object's
|
|
10933
|
+
* anchor point (the parent box, or the canvas for roots). This is the
|
|
10934
|
+
* input that controls placement — set this, not nativePos. */
|
|
9954
10935
|
this.localPos = pos.copy();
|
|
9955
|
-
/** @property {Vector2} -
|
|
9956
|
-
|
|
10936
|
+
/** @property {Vector2} - Resolved position in native UI space, recomputed
|
|
10937
|
+
* every frame from localPos + anchor (and nativeHeight, if set). This is a
|
|
10938
|
+
* derived output used for drawing and hit-testing; assigning to it has no
|
|
10939
|
+
* effect since it is overwritten each frame. Set localPos instead. */
|
|
10940
|
+
this.nativePos = pos.copy();
|
|
9957
10941
|
/** @property {Vector2} - Screen space size of the object */
|
|
9958
10942
|
this.size = size.copy();
|
|
9959
10943
|
/** @property {Color} - Color of the object */
|
|
@@ -10088,7 +11072,7 @@ class UIObject
|
|
|
10088
11072
|
const size = !isTouchDevice ? this.size :
|
|
10089
11073
|
this.size.add(vec2(this.extraTouchSize || 0));
|
|
10090
11074
|
const pos = uiSystem.screenToNative(mousePosScreen);
|
|
10091
|
-
return isOverlapping(this.
|
|
11075
|
+
return isOverlapping(this.nativePos, size, pos);
|
|
10092
11076
|
}
|
|
10093
11077
|
|
|
10094
11078
|
/** Update the object, called automatically by plugin once each frame */
|
|
@@ -10178,7 +11162,7 @@ class UIObject
|
|
|
10178
11162
|
this.color : this.color;
|
|
10179
11163
|
const lineWidth = this.lineWidth * (isNavigationObject ? 1.5 : 1);
|
|
10180
11164
|
|
|
10181
|
-
uiSystem.drawRect(this.
|
|
11165
|
+
uiSystem.drawRect(this.nativePos, this.size, color, lineWidth, lineColor, this.cornerRadius, this.gradientColor, this.shadowColor, this.shadowBlur, this.shadowOffset);
|
|
10182
11166
|
}
|
|
10183
11167
|
|
|
10184
11168
|
/** Get the size for text with overrides and scale
|
|
@@ -10215,8 +11199,8 @@ class UIObject
|
|
|
10215
11199
|
let text = 'type = ' + this.constructor.name;
|
|
10216
11200
|
if (this.text)
|
|
10217
11201
|
text += '\ntext = ' + this.text;
|
|
10218
|
-
if (this.
|
|
10219
|
-
text += '\
|
|
11202
|
+
if (this.nativePos.x || this.nativePos.y)
|
|
11203
|
+
text += '\nnativePos = ' + this.nativePos;
|
|
10220
11204
|
if (this.localPos.x || this.localPos.y)
|
|
10221
11205
|
text += '\nlocalPos = ' + this.localPos;
|
|
10222
11206
|
if (this.size.x || this.size.y)
|
|
@@ -10236,7 +11220,7 @@ class UIObject
|
|
|
10236
11220
|
this.isHoverObject() ? YELLOW :
|
|
10237
11221
|
this.disabled ? PURPLE :
|
|
10238
11222
|
this.interactive ? RED : BLUE;
|
|
10239
|
-
uiSystem.drawRect(this.
|
|
11223
|
+
uiSystem.drawRect(this.nativePos, this.size, CLEAR_BLACK, 4, color);
|
|
10240
11224
|
}
|
|
10241
11225
|
|
|
10242
11226
|
/** Internal function called when object is clicked
|
|
@@ -10319,7 +11303,7 @@ class UIText extends UIObject
|
|
|
10319
11303
|
|
|
10320
11304
|
// render the text
|
|
10321
11305
|
const textSize = this.getTextSize();
|
|
10322
|
-
uiSystem.drawText(this.text, this.
|
|
11306
|
+
uiSystem.drawText(this.text, this.nativePos, textSize, this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow, this.shadowColor, this.shadowBlur, this.shadowOffset);
|
|
10323
11307
|
}
|
|
10324
11308
|
}
|
|
10325
11309
|
|
|
@@ -10414,7 +11398,7 @@ class UITextInput extends UIObject
|
|
|
10414
11398
|
let text = this.text;
|
|
10415
11399
|
if (this.isKeyInputObject()) // add a cursor to end of text
|
|
10416
11400
|
text += timeReal%1 < .5 ? '█' : '░';
|
|
10417
|
-
uiSystem.drawText(text, this.
|
|
11401
|
+
uiSystem.drawText(text, this.nativePos, textSize,
|
|
10418
11402
|
this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
|
|
10419
11403
|
}
|
|
10420
11404
|
}
|
|
@@ -10457,7 +11441,7 @@ class UITile extends UIObject
|
|
|
10457
11441
|
}
|
|
10458
11442
|
render()
|
|
10459
11443
|
{
|
|
10460
|
-
uiSystem.drawTile(this.
|
|
11444
|
+
uiSystem.drawTile(this.nativePos, this.size, this.tileInfo, this.color, this.angle, this.mirror, this.shadowColor, this.shadowBlur, this.shadowOffset);
|
|
10461
11445
|
}
|
|
10462
11446
|
}
|
|
10463
11447
|
|
|
@@ -10496,7 +11480,7 @@ class UIButton extends UIObject
|
|
|
10496
11480
|
|
|
10497
11481
|
// draw the text scaled to fit
|
|
10498
11482
|
const textSize = this.getTextSize();
|
|
10499
|
-
uiSystem.drawText(this.text, this.
|
|
11483
|
+
uiSystem.drawText(this.text, this.nativePos.add(this.textOffset), textSize,
|
|
10500
11484
|
this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
|
|
10501
11485
|
}
|
|
10502
11486
|
}
|
|
@@ -10544,13 +11528,13 @@ class UICheckbox extends UIObject
|
|
|
10544
11528
|
const p = this.cornerRadius / min(this.size.x, this.size.y) * 2;
|
|
10545
11529
|
const length = lerp(1, 2**.5/2, p) / 2;
|
|
10546
11530
|
let s = this.size.scale(length);
|
|
10547
|
-
uiSystem.drawLine(this.
|
|
10548
|
-
uiSystem.drawLine(this.
|
|
11531
|
+
uiSystem.drawLine(this.nativePos.add(s.multiply(vec2(-1))), this.nativePos.add(s.multiply(vec2(1))), this.lineWidth, this.lineColor);
|
|
11532
|
+
uiSystem.drawLine(this.nativePos.add(s.multiply(vec2(-1,1))), this.nativePos.add(s.multiply(vec2(1,-1))), this.lineWidth, this.lineColor);
|
|
10549
11533
|
}
|
|
10550
11534
|
|
|
10551
11535
|
// draw the text next to the checkbox
|
|
10552
11536
|
const textSize = this.getTextSize();
|
|
10553
|
-
const pos = this.
|
|
11537
|
+
const pos = this.nativePos.add(vec2(this.size.x,0));
|
|
10554
11538
|
uiSystem.drawText(this.text, pos, textSize,
|
|
10555
11539
|
this.textColor, this.textLineWidth, this.textLineColor, 'left', this.font, this.fontStyle, false, this.textShadow);
|
|
10556
11540
|
}
|
|
@@ -10606,7 +11590,7 @@ class UISlider extends UIObject
|
|
|
10606
11590
|
const isHorizontal = this.size.x > this.size.y;
|
|
10607
11591
|
const handleSize = isHorizontal ? this.size.y : this.size.x;
|
|
10608
11592
|
const barSize = isHorizontal ? this.size.x : this.size.y;
|
|
10609
|
-
const centerPos = isHorizontal ? this.
|
|
11593
|
+
const centerPos = isHorizontal ? this.nativePos.x : this.nativePos.y;
|
|
10610
11594
|
|
|
10611
11595
|
// check if value changed
|
|
10612
11596
|
const handleWidth = barSize - handleSize;
|
|
@@ -10640,7 +11624,7 @@ class UISlider extends UIObject
|
|
|
10640
11624
|
const minWidth = min(handleWidth, this.cornerRadius * 2);
|
|
10641
11625
|
const progressWidth = lerp(minWidth, barWidth, this.value);
|
|
10642
11626
|
const p = (progressWidth - barWidth) * (isHorizontal ? .5 : -.5);
|
|
10643
|
-
const pos = this.
|
|
11627
|
+
const pos = this.nativePos.add(isHorizontal ? vec2(p, 0) : vec2(0, p));
|
|
10644
11628
|
const color = this.disabled ? this.disabledColor : this.handleColor;
|
|
10645
11629
|
const drawSize = isHorizontal ?
|
|
10646
11630
|
vec2(progressWidth, this.size.y) : vec2(this.size.x, progressWidth);
|
|
@@ -10651,7 +11635,7 @@ class UISlider extends UIObject
|
|
|
10651
11635
|
// draw the slider handle
|
|
10652
11636
|
const value = clamp(isHorizontal ? this.value : 1 - this.value);
|
|
10653
11637
|
const p = (barWidth - handleWidth) * (value - .5);
|
|
10654
|
-
const pos = this.
|
|
11638
|
+
const pos = this.nativePos.add(isHorizontal ? vec2(p, 0) : vec2(0, p));
|
|
10655
11639
|
const color = this.disabled ? this.disabledColor : this.handleColor;
|
|
10656
11640
|
const drawSize = vec2(handleWidth);
|
|
10657
11641
|
uiSystem.drawRect(pos, drawSize, color, this.lineWidth, this.lineColor, this.cornerRadius, this.gradientColor);
|
|
@@ -10659,7 +11643,7 @@ class UISlider extends UIObject
|
|
|
10659
11643
|
|
|
10660
11644
|
// draw the text scaled to fit on the slider
|
|
10661
11645
|
const textSize = this.getTextSize();
|
|
10662
|
-
uiSystem.drawText(this.text, this.
|
|
11646
|
+
uiSystem.drawText(this.text, this.nativePos, textSize,
|
|
10663
11647
|
this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
|
|
10664
11648
|
}
|
|
10665
11649
|
navigatePressed()
|
|
@@ -10798,7 +11782,7 @@ class UIVideo extends UIObject
|
|
|
10798
11782
|
const context = uiSystem.uiContext;
|
|
10799
11783
|
const s = this.size;
|
|
10800
11784
|
context.save();
|
|
10801
|
-
context.translate(this.
|
|
11785
|
+
context.translate(this.nativePos.x, this.nativePos.y);
|
|
10802
11786
|
context.drawImage(this.video, -s.x/2, -s.y/2, s.x, s.y);
|
|
10803
11787
|
context.restore();
|
|
10804
11788
|
}
|
|
@@ -11150,23 +12134,19 @@ class Box2dObject extends EngineObject
|
|
|
11150
12134
|
|
|
11151
12135
|
function box2dCreatePolygonShape(points)
|
|
11152
12136
|
{
|
|
11153
|
-
|
|
12137
|
+
ASSERT(3 <= points.length && points.length <= 8);
|
|
12138
|
+
const buffer = box2d.instance._malloc(points.length * 8);
|
|
12139
|
+
for (let i=0, offset=0; i<points.length; ++i)
|
|
11154
12140
|
{
|
|
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);
|
|
12141
|
+
box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].x;
|
|
12142
|
+
offset += 4;
|
|
12143
|
+
box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].y;
|
|
12144
|
+
offset += 4;
|
|
11164
12145
|
}
|
|
11165
|
-
|
|
11166
|
-
ASSERT(3 <= points.length && points.length <= 8);
|
|
12146
|
+
const box2dPoints = box2d.instance.wrapPointer(buffer, box2d.instance.b2Vec2);
|
|
11167
12147
|
const shape = new box2d.instance.b2PolygonShape();
|
|
11168
|
-
const box2dPoints = box2dCreatePointList(points);
|
|
11169
12148
|
shape.Set(box2dPoints, points.length);
|
|
12149
|
+
box2d.instance._free(buffer);
|
|
11170
12150
|
return shape;
|
|
11171
12151
|
}
|
|
11172
12152
|
|
|
@@ -13031,8 +14011,8 @@ async function box2dInit()
|
|
|
13031
14011
|
* This function can not apply color because it draws using the 2d context
|
|
13032
14012
|
* @param {Vector2} pos - Screen space position
|
|
13033
14013
|
* @param {Vector2} size - Screen space size
|
|
13034
|
-
* @param {TileInfo} startTile -
|
|
13035
|
-
* @param {number} [borderSize] -
|
|
14014
|
+
* @param {TileInfo} startTile - Top-left tile of the 3x3 block to sample (see drawNineSlice)
|
|
14015
|
+
* @param {number} [borderSize] - Rendered thickness of the border sections
|
|
13036
14016
|
* @param {number} [extraSpace] - Extra spacing adjustment
|
|
13037
14017
|
* @param {number} [angle] - Angle to rotate by
|
|
13038
14018
|
* @memberof DrawUtilities */
|
|
@@ -13043,11 +14023,16 @@ function drawNineSliceScreen(pos, size, startTile, borderSize=32, extraSpace=2,
|
|
|
13043
14023
|
|
|
13044
14024
|
/** Draw a scalable nine-slice UI element in world space
|
|
13045
14025
|
* This function can apply color and additive color if WebGL is enabled
|
|
14026
|
+
* The nine-slice samples a 3x3 block of tiles from the tilesheet, it does not
|
|
14027
|
+
* subdivide a single tile. Pass the top-left tile of that block as startTile;
|
|
14028
|
+
* the other 8 tiles (edges, corners, and center) are taken automatically from
|
|
14029
|
+
* the 3x3 grid of tiles extending right and down from it. borderSize only sets
|
|
14030
|
+
* the rendered thickness of the edges and corners, not how the texture is cut.
|
|
13046
14031
|
* @param {Vector2} pos - World space position
|
|
13047
14032
|
* @param {Vector2} size - World space size
|
|
13048
|
-
* @param {TileInfo} startTile -
|
|
14033
|
+
* @param {TileInfo} startTile - Top-left tile of the 3x3 block to sample the nine-slice from
|
|
13049
14034
|
* @param {Color} [color] - Color to modulate with
|
|
13050
|
-
* @param {number} [borderSize] -
|
|
14035
|
+
* @param {number} [borderSize] - Rendered thickness of the border sections
|
|
13051
14036
|
* @param {Color} [additiveColor] - Additive color
|
|
13052
14037
|
* @param {number} [extraSpace] - Extra spacing adjustment
|
|
13053
14038
|
* @param {number} [angle] - Angle to rotate by
|
|
@@ -13057,7 +14042,8 @@ function drawNineSliceScreen(pos, size, startTile, borderSize=32, extraSpace=2,
|
|
|
13057
14042
|
* @memberof DrawUtilities */
|
|
13058
14043
|
function drawNineSlice(pos, size, startTile, color, borderSize=1, additiveColor, extraSpace=.05, angle=0, useWebGL=glEnable, screenSpace, context)
|
|
13059
14044
|
{
|
|
13060
|
-
// setup nine slice tiles
|
|
14045
|
+
// setup nine slice tiles - startTile is the top-left of a 3x3 tile block,
|
|
14046
|
+
// so the center tile is one tile down and right from it
|
|
13061
14047
|
const centerTile = startTile.offset(startTile.size);
|
|
13062
14048
|
const centerSize = size.add(vec2(extraSpace-borderSize*2));
|
|
13063
14049
|
const cornerSize = vec2(borderSize);
|
|
@@ -13091,8 +14077,8 @@ function drawNineSlice(pos, size, startTile, color, borderSize=1, additiveColor,
|
|
|
13091
14077
|
* This function can not apply color because it draws using the 2d context
|
|
13092
14078
|
* @param {Vector2} pos - Screen space position
|
|
13093
14079
|
* @param {Vector2} size - Screen space size
|
|
13094
|
-
* @param {TileInfo} startTile -
|
|
13095
|
-
* @param {number} [borderSize] -
|
|
14080
|
+
* @param {TileInfo} startTile - First of 3 consecutive tiles: corner, side, center (see drawThreeSlice)
|
|
14081
|
+
* @param {number} [borderSize] - Rendered thickness of the border sections
|
|
13096
14082
|
* @param {number} [extraSpace] - Extra spacing adjustment
|
|
13097
14083
|
* @param {number} [angle] - Angle to rotate by
|
|
13098
14084
|
* @memberof DrawUtilities */
|
|
@@ -13103,11 +14089,15 @@ function drawThreeSliceScreen(pos, size, startTile, borderSize=32, extraSpace=2,
|
|
|
13103
14089
|
|
|
13104
14090
|
/** Draw a scalable three-slice UI element in world space
|
|
13105
14091
|
* This function can apply color and additive color if WebGL is enabled
|
|
14092
|
+
* The three-slice samples 3 consecutive tiles from the tilesheet, it does not
|
|
14093
|
+
* subdivide a single tile. Pass the first tile as startTile; the three tiles
|
|
14094
|
+
* are used in order as corner, side, and center, then rotated and mirrored to
|
|
14095
|
+
* build all four edges and corners. borderSize only sets the rendered thickness.
|
|
13106
14096
|
* @param {Vector2} pos - World space position
|
|
13107
14097
|
* @param {Vector2} size - World space size
|
|
13108
|
-
* @param {TileInfo} startTile -
|
|
14098
|
+
* @param {TileInfo} startTile - First of 3 consecutive tiles (corner, side, center) for the three-slice
|
|
13109
14099
|
* @param {Color} [color] - Color to modulate with
|
|
13110
|
-
* @param {number} [borderSize] -
|
|
14100
|
+
* @param {number} [borderSize] - Rendered thickness of the border sections
|
|
13111
14101
|
* @param {Color} [additiveColor] - Additive color
|
|
13112
14102
|
* @param {number} [extraSpace] - Extra spacing adjustment
|
|
13113
14103
|
* @param {number} [angle] - Angle to rotate by
|
|
@@ -13117,7 +14107,7 @@ function drawThreeSliceScreen(pos, size, startTile, borderSize=32, extraSpace=2,
|
|
|
13117
14107
|
* @memberof DrawUtilities */
|
|
13118
14108
|
function drawThreeSlice(pos, size, startTile, color, borderSize=1, additiveColor, extraSpace=.05, angle=0, useWebGL=glEnable, screenSpace, context)
|
|
13119
14109
|
{
|
|
13120
|
-
// setup three slice tiles
|
|
14110
|
+
// setup three slice tiles - 3 tiles in a row starting at startTile
|
|
13121
14111
|
const cornerTile = startTile.frame(0);
|
|
13122
14112
|
const sideTile = startTile.frame(1);
|
|
13123
14113
|
const centerTile = startTile.frame(2);
|
|
@@ -13147,6 +14137,70 @@ function drawThreeSlice(pos, size, startTile, color, borderSize=1, additiveColor
|
|
|
13147
14137
|
const cornerPos = cornerOffset.multiply(vec2(flipX?-1:1, flipY?-flip:flip));
|
|
13148
14138
|
drawTile(pos.add(cornerPos.rotate(rotateAngle)), cornerSize, cornerTile, color, a, false, additiveColor, useWebGL, screenSpace, context);
|
|
13149
14139
|
}
|
|
14140
|
+
}
|
|
14141
|
+
|
|
14142
|
+
/** Draw a crescent / moon-phase shape built from a polygon
|
|
14143
|
+
* Routes through drawPoly, so it supports WebGL, screen space, color, and outlines
|
|
14144
|
+
* @param {Vector2} pos - Center position
|
|
14145
|
+
* @param {number} [size] - Diameter
|
|
14146
|
+
* @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
|
|
14147
|
+
* @param {Color} [color] - Fill color
|
|
14148
|
+
* @param {number} [angle] - Angle to rotate by
|
|
14149
|
+
* @param {boolean} [invert] - Flip which side is illuminated
|
|
14150
|
+
* @param {number} [lineWidth] - Outline width, 0 for no outline
|
|
14151
|
+
* @param {Color} [lineColor] - Outline color
|
|
14152
|
+
* @param {boolean} [useWebGL=glEnable] - Use WebGL for rendering
|
|
14153
|
+
* @param {boolean} [screenSpace] - Use screen space coordinates
|
|
14154
|
+
* @param {CanvasRenderingContext2D} [context] - Canvas context to use
|
|
14155
|
+
* @memberof DrawUtilities */
|
|
14156
|
+
function drawCrescent(pos, size=1, percent=0, color=WHITE, angle=0, invert=false, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
|
|
14157
|
+
{
|
|
14158
|
+
// build local-space points and let drawPoly apply pos/angle so screen space works
|
|
14159
|
+
const points = getCrescentPoints(vec2(), size, percent, 0, invert);
|
|
14160
|
+
drawPoly(points, color, lineWidth, lineColor, pos, angle, useWebGL, screenSpace, context);
|
|
14161
|
+
}
|
|
14162
|
+
|
|
14163
|
+
/** Get the list of points that make up a crescent / moon-phase shape
|
|
14164
|
+
* Returns world-space points with pos and angle baked in, ready for drawPoly or other use
|
|
14165
|
+
* @param {Vector2} pos - Center position
|
|
14166
|
+
* @param {number} [size] - Diameter
|
|
14167
|
+
* @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
|
|
14168
|
+
* @param {number} [angle] - Angle to rotate by
|
|
14169
|
+
* @param {boolean} [invert] - Flip which side is illuminated
|
|
14170
|
+
* @param {number} [sides=glCircleSides] - Number of sides for a full circle (halved per arc)
|
|
14171
|
+
* @return {Array<Vector2>} - List of points making up the crescent
|
|
14172
|
+
* @memberof DrawUtilities */
|
|
14173
|
+
function getCrescentPoints(pos, size=1, percent=0, angle=0, invert=false, sides=glCircleSides)
|
|
14174
|
+
{
|
|
14175
|
+
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
14176
|
+
ASSERT(isNumber(size) && isNumber(percent), 'size and percent must be numbers');
|
|
14177
|
+
|
|
14178
|
+
// map phase to a signed terminator curve: -1 new, 0 half, 1 full
|
|
14179
|
+
let p = mod(percent*4, 4); // quarter phase 0..4
|
|
14180
|
+
if (p >= 2) // second half of cycle flips orientation
|
|
14181
|
+
angle += PI;
|
|
14182
|
+
p = p <= 2 ? p-1 : 3-p;
|
|
14183
|
+
if (invert) // flip the illuminated side
|
|
14184
|
+
{
|
|
14185
|
+
p = -p;
|
|
14186
|
+
angle += PI;
|
|
14187
|
+
}
|
|
14188
|
+
|
|
14189
|
+
// build the crescent: outer semicircle, then inner half-ellipse traced back
|
|
14190
|
+
const points = [];
|
|
14191
|
+
const segs = max(3, sides>>1);
|
|
14192
|
+
const radius = size/2;
|
|
14193
|
+
for (let i=0; i<=segs; i++)
|
|
14194
|
+
{
|
|
14195
|
+
const t = i/segs*PI;
|
|
14196
|
+
points.push(vec2(radius*cos(t), radius*sin(t)).rotate(angle).add(pos));
|
|
14197
|
+
}
|
|
14198
|
+
for (let i=segs; i>=0; i--)
|
|
14199
|
+
{
|
|
14200
|
+
const t = i/segs*PI;
|
|
14201
|
+
points.push(vec2(radius*cos(t), -radius*p*sin(t)).rotate(angle).add(pos));
|
|
14202
|
+
}
|
|
14203
|
+
return points;
|
|
13150
14204
|
}
|
|
13151
14205
|
/**
|
|
13152
14206
|
* LittleJS Tween System Plugin
|