littlejsengine 1.18.15 → 1.18.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/littlejs.d.ts +198 -17
- package/dist/littlejs.esm.js +1180 -257
- package/dist/littlejs.esm.min.js +1 -1
- package/dist/littlejs.js +1160 -257
- package/dist/littlejs.min.js +1 -1
- package/dist/littlejs.release.js +1134 -241
- package/package.json +1 -1
- package/plugins/box2d.js +9 -13
- package/plugins/drawUtilities.js +63 -0
- package/plugins/lightSystem.js +330 -0
- package/plugins/medalSystem.js +36 -5
- package/plugins/pluginExport.js +8 -0
- package/src/engine.js +1 -1
- package/src/engineAudio.js +21 -3
- package/src/engineBuild.mjs +1 -0
- package/src/engineDebug.js +27 -16
- package/src/engineDraw.js +9 -6
- package/src/engineExport.js +12 -0
- package/src/engineInput.js +528 -189
- package/src/engineObject.js +5 -2
- package/src/engineParticles.js +19 -15
- package/src/engineRelease.js +1 -0
- package/src/engineSettings.js +111 -10
- package/src/engineTileLayer.js +5 -0
package/dist/littlejs.esm.js
CHANGED
|
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
|
|
|
35
35
|
* @type {string}
|
|
36
36
|
* @default
|
|
37
37
|
* @memberof Engine */
|
|
38
|
-
const engineVersion = '1.18.
|
|
38
|
+
const engineVersion = '1.18.17';
|
|
39
39
|
|
|
40
40
|
/** Frames per second to update
|
|
41
41
|
* @type {number}
|
|
@@ -622,7 +622,7 @@ let debugKey = 'Escape';
|
|
|
622
622
|
let debugOverlay = false;
|
|
623
623
|
|
|
624
624
|
// Engine internal variables not exposed to documentation
|
|
625
|
-
let debugPrimitives = [], debugPhysics = false, debugRaycast = false, debugParticles = false, debugGamepads = false, debugTakeScreenshot;
|
|
625
|
+
let debugPrimitives = [], debugPhysics = false, debugRaycast = false, debugParticles = false, debugGamepads = false, debugSound = false, debugTakeScreenshot;
|
|
626
626
|
|
|
627
627
|
///////////////////////////////////////////////////////////////////////////////
|
|
628
628
|
// Debug helper functions
|
|
@@ -862,6 +862,8 @@ function debugUpdate()
|
|
|
862
862
|
debugRaycast = !debugRaycast;
|
|
863
863
|
if (keyWasPressed('Digit5'))
|
|
864
864
|
debugScreenshot();
|
|
865
|
+
if (keyWasPressed('Digit7'))
|
|
866
|
+
debugSound = !debugSound;
|
|
865
867
|
}
|
|
866
868
|
if (debugVideoCaptureIsActive())
|
|
867
869
|
{
|
|
@@ -881,6 +883,9 @@ function debugRender()
|
|
|
881
883
|
// flush any gl sprites before drawing debug info
|
|
882
884
|
glFlush();
|
|
883
885
|
|
|
886
|
+
const savedDrawCount = drawCount;
|
|
887
|
+
const savedPrimitiveCount = primitiveCount;
|
|
888
|
+
|
|
884
889
|
if (debugTakeScreenshot)
|
|
885
890
|
{
|
|
886
891
|
// combine canvases, remove alpha and save
|
|
@@ -914,6 +919,8 @@ function debugRender()
|
|
|
914
919
|
const stickCount = gamepadStickData[i].length;
|
|
915
920
|
for (let j = 0; j < stickCount; j++)
|
|
916
921
|
{
|
|
922
|
+
if (!(j in gamepadStickData[i]))
|
|
923
|
+
continue; // skip sticks that are not present (eg a disabled touch left stick)
|
|
917
924
|
const stick = gamepadStick(j, i);
|
|
918
925
|
const drawPos = cornerPos.add(vec2(j*stickScale*2, 0));
|
|
919
926
|
const stickPos = drawPos.add(stick.scale(stickScale));
|
|
@@ -1092,6 +1099,8 @@ function debugRender()
|
|
|
1092
1099
|
debugContext.fillStyle = '#fff';
|
|
1093
1100
|
debugContext.fillText('5: Save Screenshot', x, y += h);
|
|
1094
1101
|
debugContext.fillText('6: Toggle Video Capture', x, y += h);
|
|
1102
|
+
debugContext.fillStyle = debugSound ? '#f00' : '#fff';
|
|
1103
|
+
debugContext.fillText('7: Debug Sound', x, y += h);
|
|
1095
1104
|
|
|
1096
1105
|
let keysPressed = '';
|
|
1097
1106
|
let mousePressed = '';
|
|
@@ -1126,10 +1135,27 @@ function debugRender()
|
|
|
1126
1135
|
debugContext.fillText(debugParticles ? 'Debug Particles' : '', x, y += h);
|
|
1127
1136
|
debugContext.fillText(debugRaycast ? 'Debug Raycasts' : '', x, y += h);
|
|
1128
1137
|
debugContext.fillText(debugGamepads ? 'Debug Gamepads' : '', x, y += h);
|
|
1138
|
+
debugContext.fillText(debugSound ? 'Debug Sound' : '', x, y += h);
|
|
1129
1139
|
}
|
|
1130
1140
|
|
|
1131
1141
|
debugContext.restore();
|
|
1132
1142
|
}
|
|
1143
|
+
|
|
1144
|
+
if (debugWatermark || debugOverlay)
|
|
1145
|
+
{
|
|
1146
|
+
// show fps stats display
|
|
1147
|
+
mainContext.textAlign = 'right';
|
|
1148
|
+
mainContext.textBaseline = 'top';
|
|
1149
|
+
mainContext.font = '1em monospace';
|
|
1150
|
+
mainContext.fillStyle = '#000';
|
|
1151
|
+
const text = engineName + ' v' + engineVersion + ' / '
|
|
1152
|
+
+ savedDrawCount + ' / ' + savedPrimitiveCount + ' / '
|
|
1153
|
+
+ engineObjects.length + ' / ' + averageFPS.toFixed(1)
|
|
1154
|
+
+ (glEnable ? ' GL' : ' 2D') ;
|
|
1155
|
+
mainContext.fillText(text, mainCanvas.width-3, 3);
|
|
1156
|
+
mainContext.fillStyle = '#fff';
|
|
1157
|
+
mainContext.fillText(text, mainCanvas.width-2, 2);
|
|
1158
|
+
}
|
|
1133
1159
|
}
|
|
1134
1160
|
|
|
1135
1161
|
function debugRenderPost()
|
|
@@ -1139,21 +1165,6 @@ function debugRenderPost()
|
|
|
1139
1165
|
debugVideoCaptureUpdate();
|
|
1140
1166
|
return;
|
|
1141
1167
|
}
|
|
1142
|
-
|
|
1143
|
-
if (!debugWatermark && !debugOverlay) return;
|
|
1144
|
-
|
|
1145
|
-
// update fps display
|
|
1146
|
-
mainContext.textAlign = 'right';
|
|
1147
|
-
mainContext.textBaseline = 'top';
|
|
1148
|
-
mainContext.font = '1em monospace';
|
|
1149
|
-
mainContext.fillStyle = '#000';
|
|
1150
|
-
const text = engineName + ' v' + engineVersion + ' / '
|
|
1151
|
-
+ drawCount + ' / ' + primitiveCount + ' / '
|
|
1152
|
-
+ engineObjects.length + ' / ' + averageFPS.toFixed(1)
|
|
1153
|
-
+ (glEnable ? ' GL' : ' 2D') ;
|
|
1154
|
-
mainContext.fillText(text, mainCanvas.width-3, 3);
|
|
1155
|
-
mainContext.fillStyle = '#fff';
|
|
1156
|
-
mainContext.fillText(text, mainCanvas.width-2, 2);
|
|
1157
1168
|
}
|
|
1158
1169
|
|
|
1159
1170
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -2975,35 +2986,78 @@ let touchInputEnable = true;
|
|
|
2975
2986
|
* - Supports left analog stick, 4 face buttons and start button (button 9)
|
|
2976
2987
|
* - setTouchGamepadButtonCount(1) to use face buttons as right analog stick
|
|
2977
2988
|
* - Analog stick buttons 10 and 11 are also activated when virtual sticks are touched
|
|
2978
|
-
|
|
2989
|
+
* - Rendered as a full-viewport HTML/SVG overlay, so controls may sit outside the game canvas
|
|
2979
2990
|
* @type {boolean}
|
|
2980
2991
|
* @default
|
|
2981
2992
|
* @memberof Settings */
|
|
2982
2993
|
let touchGamepadEnable = false;
|
|
2983
2994
|
|
|
2984
|
-
/** True if
|
|
2985
|
-
* -
|
|
2986
|
-
*
|
|
2995
|
+
/** True if touches outside the gamepad controls should still drive mouse/touch input
|
|
2996
|
+
* - When false (the default), enabling the touch gamepad suppresses touch-to-mouse input entirely
|
|
2997
|
+
* - Set true to also pass touches outside the controls through to the game as mouse/touch input
|
|
2998
|
+
* - Touches on the gamepad controls never drive the mouse regardless of this setting
|
|
2999
|
+
* @type {boolean}
|
|
3000
|
+
* @default
|
|
3001
|
+
* @memberof Settings */
|
|
3002
|
+
let touchGamepadPassthrough = false;
|
|
3003
|
+
|
|
3004
|
+
/** Size of center button if touch gamepad should have start button in the center
|
|
3005
|
+
* - Prevents activating when pressed near virtual stick or face buttons
|
|
2987
3006
|
* - When the game is paused, any touch will press the button
|
|
2988
|
-
* -
|
|
3007
|
+
* - Measured in viewport CSS pixels
|
|
2989
3008
|
* @type {number}
|
|
2990
3009
|
* @default
|
|
2991
3010
|
* @memberof Settings */
|
|
2992
|
-
let touchGamepadCenterButtonSize =
|
|
3011
|
+
let touchGamepadCenterButtonSize = 0;
|
|
2993
3012
|
|
|
2994
|
-
/** Number of buttons on touch gamepad (0-4),
|
|
3013
|
+
/** Number of buttons on the right side of the touch gamepad (0-4), using gamepad buttons 0-3
|
|
3014
|
+
* - A count of 1 is a single large button (the size of a stick)
|
|
3015
|
+
* - Ignored when touchGamepadRightStick is set (the right side is a stick instead)
|
|
2995
3016
|
* @type {number}
|
|
2996
3017
|
* @default
|
|
2997
3018
|
* @memberof Settings */
|
|
2998
3019
|
let touchGamepadButtonCount = 4;
|
|
2999
3020
|
|
|
3021
|
+
/** True if the touch gamepad should have a left analog stick (or dpad)
|
|
3022
|
+
* - When false, the left side is face buttons (touchGamepadLeftButtonCount) or nothing
|
|
3023
|
+
* @type {boolean}
|
|
3024
|
+
* @default
|
|
3025
|
+
* @memberof Settings */
|
|
3026
|
+
let touchGamepadLeftStick = true;
|
|
3027
|
+
|
|
3028
|
+
/** Number of buttons on the left side of the touch gamepad (0-4), using gamepad buttons 4-7
|
|
3029
|
+
* - Only used when touchGamepadLeftStick is false (otherwise the left side is a stick)
|
|
3030
|
+
* - A count of 1 is a single large button (the size of a stick)
|
|
3031
|
+
* @type {number}
|
|
3032
|
+
* @default
|
|
3033
|
+
* @memberof Settings */
|
|
3034
|
+
let touchGamepadLeftButtonCount = 0;
|
|
3035
|
+
|
|
3036
|
+
/** True if the touch gamepad right side should be an analog stick (or dpad) instead of face buttons
|
|
3037
|
+
* - When set, touchGamepadButtonCount is ignored and the right side is a stick
|
|
3038
|
+
* - Uses an analog stick when touchGamepadAnalog is true, otherwise an 8 way dpad
|
|
3039
|
+
* @type {boolean}
|
|
3040
|
+
* @default
|
|
3041
|
+
* @memberof Settings */
|
|
3042
|
+
let touchGamepadRightStick = false;
|
|
3043
|
+
|
|
3000
3044
|
/** True if touch gamepad should be analog stick or false to use if 8 way dpad
|
|
3001
3045
|
* @type {boolean}
|
|
3002
3046
|
* @default
|
|
3003
3047
|
* @memberof Settings */
|
|
3004
3048
|
let touchGamepadAnalog = true;
|
|
3005
3049
|
|
|
3006
|
-
/**
|
|
3050
|
+
/** True if touch gamepad directional controls should float to where you press
|
|
3051
|
+
* - Only affects analog sticks and dpads, not face buttons
|
|
3052
|
+
* - Directional controls re-anchor to where you press within the bottom ~60% of their screen half; the top ~40% passes through to the game
|
|
3053
|
+
* - The right side floats only when it acts as the right analog stick (touchGamepadRightStick is set)
|
|
3054
|
+
* - A center button (touchGamepadCenterButtonSize) still works since it ignores touches near the sticks
|
|
3055
|
+
* @type {boolean}
|
|
3056
|
+
* @default
|
|
3057
|
+
* @memberof Settings */
|
|
3058
|
+
let touchGamepadFloating = false;
|
|
3059
|
+
|
|
3060
|
+
/** Size of virtual gamepad for touch devices in viewport CSS pixels
|
|
3007
3061
|
* @type {number}
|
|
3008
3062
|
* @default
|
|
3009
3063
|
* @memberof Settings */
|
|
@@ -3021,6 +3075,13 @@ let touchGamepadAlpha = .3;
|
|
|
3021
3075
|
* @memberof Settings */
|
|
3022
3076
|
let touchGamepadDisplayTime = 3;
|
|
3023
3077
|
|
|
3078
|
+
/** Duration in ms to vibrate when a touch gamepad face button or start button is pressed
|
|
3079
|
+
* - Set to 0 to disable, also requires vibrateEnable and hardware support (ignored on iOS)
|
|
3080
|
+
* @type {number}
|
|
3081
|
+
* @default
|
|
3082
|
+
* @memberof Settings */
|
|
3083
|
+
let touchGamepadVibration = 0;
|
|
3084
|
+
|
|
3024
3085
|
/** Allow vibration hardware if it exists
|
|
3025
3086
|
* @type {boolean}
|
|
3026
3087
|
* @default
|
|
@@ -3253,6 +3314,11 @@ function setTouchInputEnable(enable) { touchInputEnable = enable; }
|
|
|
3253
3314
|
* @memberof Settings */
|
|
3254
3315
|
function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
|
|
3255
3316
|
|
|
3317
|
+
/** Set if touches outside the gamepad controls should still drive mouse/touch input
|
|
3318
|
+
* @param {boolean} passthrough
|
|
3319
|
+
* @memberof Settings */
|
|
3320
|
+
function setTouchGamepadPassthrough(passthrough) { touchGamepadPassthrough = passthrough; }
|
|
3321
|
+
|
|
3256
3322
|
/** Set if touch gamepad should have start button in the center
|
|
3257
3323
|
* - Set size to enable the center button
|
|
3258
3324
|
* - When the game is paused, any touch will press the button
|
|
@@ -3260,16 +3326,57 @@ function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
|
|
|
3260
3326
|
* @memberof Settings */
|
|
3261
3327
|
function setTouchGamepadCenterButtonSize(size) { touchGamepadCenterButtonSize = size; }
|
|
3262
3328
|
|
|
3263
|
-
/** Set number of buttons on touch gamepad (0-4
|
|
3329
|
+
/** Set number of buttons on the right side of the touch gamepad (0-4, gamepad buttons 0-3)
|
|
3330
|
+
* @param {number} count
|
|
3331
|
+
* @memberof Settings */
|
|
3332
|
+
function setTouchGamepadButtonCount(count)
|
|
3333
|
+
{
|
|
3334
|
+
touchGamepadButtonCount = count;
|
|
3335
|
+
if (count > 0)
|
|
3336
|
+
touchGamepadRightStick = false;
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
/** Set if the touch gamepad should have a left analog stick (or dpad)
|
|
3340
|
+
* @param {boolean} enable
|
|
3341
|
+
* @memberof Settings */
|
|
3342
|
+
function setTouchGamepadLeftStick(enable)
|
|
3343
|
+
{
|
|
3344
|
+
touchGamepadLeftStick = enable;
|
|
3345
|
+
if (enable)
|
|
3346
|
+
touchGamepadLeftButtonCount = 0;
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3349
|
+
/** Set number of buttons on the left side of the touch gamepad (0-4, gamepad buttons 4-7)
|
|
3350
|
+
* - Only used when touchGamepadLeftStick is false
|
|
3264
3351
|
* @param {number} count
|
|
3265
3352
|
* @memberof Settings */
|
|
3266
|
-
function
|
|
3353
|
+
function setTouchGamepadLeftButtonCount(count)
|
|
3354
|
+
{
|
|
3355
|
+
touchGamepadLeftButtonCount = count;
|
|
3356
|
+
if (count > 0)
|
|
3357
|
+
touchGamepadLeftStick = false;
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
/** Set if the touch gamepad right side is an analog stick (or dpad) instead of face buttons
|
|
3361
|
+
* @param {boolean} rightStick
|
|
3362
|
+
* @memberof Settings */
|
|
3363
|
+
function setTouchGamepadRightStick(rightStick)
|
|
3364
|
+
{
|
|
3365
|
+
touchGamepadRightStick = rightStick;
|
|
3366
|
+
if (rightStick)
|
|
3367
|
+
touchGamepadButtonCount = 0;
|
|
3368
|
+
}
|
|
3267
3369
|
|
|
3268
3370
|
/** Set if touch gamepad should be analog stick or 8 way dpad
|
|
3269
3371
|
* @param {boolean} analog
|
|
3270
3372
|
* @memberof Settings */
|
|
3271
3373
|
function setTouchGamepadAnalog(analog) { touchGamepadAnalog = analog; }
|
|
3272
3374
|
|
|
3375
|
+
/** Set if touch gamepad directional controls should float to where you press
|
|
3376
|
+
* @param {boolean} floating
|
|
3377
|
+
* @memberof Settings */
|
|
3378
|
+
function setTouchGamepadFloating(floating) { touchGamepadFloating = floating; }
|
|
3379
|
+
|
|
3273
3380
|
/** Set size of virtual gamepad for touch devices in pixels
|
|
3274
3381
|
* @param {number} size
|
|
3275
3382
|
* @memberof Settings */
|
|
@@ -3285,6 +3392,11 @@ function setTouchGamepadAlpha(alpha) { touchGamepadAlpha = alpha; }
|
|
|
3285
3392
|
* @memberof Settings */
|
|
3286
3393
|
function setTouchGamepadDisplayTime(time) { touchGamepadDisplayTime = time; }
|
|
3287
3394
|
|
|
3395
|
+
/** Set duration in ms to vibrate when a touch gamepad face or start button is pressed (0 disables)
|
|
3396
|
+
* @param {number} ms
|
|
3397
|
+
* @memberof Settings */
|
|
3398
|
+
function setTouchGamepadVibration(ms) { touchGamepadVibration = ms; }
|
|
3399
|
+
|
|
3288
3400
|
/** Set to allow vibration hardware if it exists
|
|
3289
3401
|
* @param {boolean} enable
|
|
3290
3402
|
* @memberof Settings */
|
|
@@ -3463,10 +3575,10 @@ class EngineObject
|
|
|
3463
3575
|
if (pa)
|
|
3464
3576
|
{
|
|
3465
3577
|
const c = cos(-pa), s = sin(-pa);
|
|
3466
|
-
this.pos
|
|
3578
|
+
this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
|
|
3467
3579
|
}
|
|
3468
3580
|
else
|
|
3469
|
-
this.pos
|
|
3581
|
+
this.pos.set(lx + pp.x, ly + pp.y);
|
|
3470
3582
|
this.angle = mirror*this.localAngle + pa;
|
|
3471
3583
|
}
|
|
3472
3584
|
|
|
@@ -3698,6 +3810,9 @@ class EngineObject
|
|
|
3698
3810
|
drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor);
|
|
3699
3811
|
}
|
|
3700
3812
|
|
|
3813
|
+
/** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
|
|
3814
|
+
renderLight() {}
|
|
3815
|
+
|
|
3701
3816
|
/** Destroy this object, destroy its children, detach its parent, and mark it for removal
|
|
3702
3817
|
* @param {boolean} [immediate] - should attached effects be allowed to die off? */
|
|
3703
3818
|
destroy(immediate=false)
|
|
@@ -4202,11 +4317,12 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
|
4202
4317
|
// normal canvas 2D rendering method (slower)
|
|
4203
4318
|
++drawCount;
|
|
4204
4319
|
++primitiveCount;
|
|
4205
|
-
size = new Vector2(size.x, -size.y); // flip upside down sprites
|
|
4206
4320
|
drawCanvas2D(pos, size, angle, mirror, (context)=>
|
|
4207
4321
|
{
|
|
4208
4322
|
if (textureInfo)
|
|
4209
4323
|
{
|
|
4324
|
+
// un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
|
|
4325
|
+
context.scale(1, -1);
|
|
4210
4326
|
// calculate uvs and render
|
|
4211
4327
|
const x = tileInfo.pos.x, y = tileInfo.pos.y;
|
|
4212
4328
|
const w = tileInfo.size.x, h = tileInfo.size.y;
|
|
@@ -4214,7 +4330,7 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
|
|
|
4214
4330
|
}
|
|
4215
4331
|
else
|
|
4216
4332
|
{
|
|
4217
|
-
// if no tile info, use untextured rect
|
|
4333
|
+
// if no tile info, use untextured rect (Y-symmetric, no compensation needed)
|
|
4218
4334
|
const c = additiveColor ? color.add(additiveColor) : color;
|
|
4219
4335
|
context.fillStyle = c.toString();
|
|
4220
4336
|
context.fillRect(-.5, -.5, 1, 1);
|
|
@@ -4288,11 +4404,10 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, an
|
|
|
4288
4404
|
// normal canvas 2D rendering method (slower)
|
|
4289
4405
|
++drawCount;
|
|
4290
4406
|
++primitiveCount;
|
|
4291
|
-
size = new Vector2(size.x, -size.y); // fix upside down sprites
|
|
4292
4407
|
drawCanvas2D(pos, size, angle, false, (context)=>
|
|
4293
4408
|
{
|
|
4294
|
-
//
|
|
4295
|
-
const gradient = context.createLinearGradient(0,
|
|
4409
|
+
// gradient endpoints are flipped to match the Y flip inside drawCanvas2D
|
|
4410
|
+
const gradient = context.createLinearGradient(0, .5, 0, -.5);
|
|
4296
4411
|
gradient.addColorStop(0, colorTop.toString());
|
|
4297
4412
|
gradient.addColorStop(1, colorBottom.toString());
|
|
4298
4413
|
context.fillStyle = gradient;
|
|
@@ -4711,7 +4826,10 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
|
|
|
4711
4826
|
* @memberof Draw
|
|
4712
4827
|
*/
|
|
4713
4828
|
|
|
4714
|
-
/** Draw directly to a 2d canvas context in world space
|
|
4829
|
+
/** Draw directly to a 2d canvas context in world space.
|
|
4830
|
+
* The Y axis is flipped so world-Y-up coordinates render right-side up
|
|
4831
|
+
* (matches the WebGL path). Callers whose drawing depends on Y direction
|
|
4832
|
+
* (e.g. linear gradients) should flip their own Y endpoints accordingly.
|
|
4715
4833
|
* @param {Vector2} pos
|
|
4716
4834
|
* @param {Vector2} size
|
|
4717
4835
|
* @param {number} angle
|
|
@@ -5375,6 +5493,7 @@ function inputClear()
|
|
|
5375
5493
|
inputData[0] = [];
|
|
5376
5494
|
touchGamepadButtons.length = 0;
|
|
5377
5495
|
touchGamepadSticks.length = 0;
|
|
5496
|
+
touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor
|
|
5378
5497
|
gamepadStickData.length = 0;
|
|
5379
5498
|
gamepadDpadData.length = 0;
|
|
5380
5499
|
}
|
|
@@ -5617,6 +5736,14 @@ const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
|
|
|
5617
5736
|
|
|
5618
5737
|
// touch gamepad internal variables
|
|
5619
5738
|
const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
|
|
5739
|
+
// floating stick anchors (stage-local CSS pixels) and owning pointer ids, indexed by stick (0=left, 1=right)
|
|
5740
|
+
const touchGamepadStickAnchors = [], touchGamepadStickPointerId = [];
|
|
5741
|
+
// pointerId -> control role ('stick0', 'stick1', 'face<n>', or 'start')
|
|
5742
|
+
const touchGamepadPointerRole = new Map();
|
|
5743
|
+
// overlay DOM elements (created lazily on touch devices) and cached SVG shapes
|
|
5744
|
+
let touchGamepadOverlay, touchGamepadStage, touchGamepadSvg, touchGamepadSvgEls;
|
|
5745
|
+
let touchGamepadSideZones = [], touchGamepadZoneC;
|
|
5746
|
+
let touchGamepadNeedRelayout = true, touchGamepadLastLayout;
|
|
5620
5747
|
|
|
5621
5748
|
///////////////////////////////////////////////////////////////////////////////
|
|
5622
5749
|
// Input system functions used by engine
|
|
@@ -5741,7 +5868,15 @@ function inputInit()
|
|
|
5741
5868
|
e.preventDefault(); // prevent page scrolling
|
|
5742
5869
|
}
|
|
5743
5870
|
function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
|
|
5744
|
-
function onBlur()
|
|
5871
|
+
function onBlur()
|
|
5872
|
+
{
|
|
5873
|
+
inputClear();
|
|
5874
|
+
// release any held virtual gamepad controls so they don't stick
|
|
5875
|
+
touchGamepadPointerRole.clear();
|
|
5876
|
+
touchGamepadButtons.length = 0;
|
|
5877
|
+
touchGamepadSticks.length = 0;
|
|
5878
|
+
touchGamepadStickPointerId.length = 0;
|
|
5879
|
+
}
|
|
5745
5880
|
|
|
5746
5881
|
// enable touch input mouse passthrough
|
|
5747
5882
|
function touchInputInit()
|
|
@@ -5757,36 +5892,46 @@ function inputInit()
|
|
|
5757
5892
|
{
|
|
5758
5893
|
if (!touchInputEnable) return;
|
|
5759
5894
|
|
|
5760
|
-
// route touch to gamepad
|
|
5761
|
-
if (touchGamepadEnable)
|
|
5762
|
-
handleTouchGamepad(e);
|
|
5763
|
-
|
|
5764
5895
|
// fix stalled audio requiring user interaction
|
|
5765
5896
|
if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
|
|
5766
5897
|
audioContext.resume();
|
|
5767
5898
|
|
|
5768
|
-
//
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
if (
|
|
5899
|
+
// when the touch gamepad is enabled it owns touch input: suppress the
|
|
5900
|
+
// touch->mouse passthrough entirely unless touchGamepadPassthrough is set
|
|
5901
|
+
// (its own zones drive gameplay via pointer events)
|
|
5902
|
+
if (!touchGamepadEnable || touchGamepadPassthrough)
|
|
5772
5903
|
{
|
|
5773
|
-
//
|
|
5774
|
-
|
|
5775
|
-
const
|
|
5776
|
-
|
|
5777
|
-
|
|
5904
|
+
// touches that landed on a virtual gamepad zone are owned by the gamepad
|
|
5905
|
+
// (handled by its own pointer listeners) and must not drive the game mouse
|
|
5906
|
+
const isGamepadTouch = (t)=>
|
|
5907
|
+
touchGamepadSideZones.includes(t.target) || t.target === touchGamepadZoneC;
|
|
5908
|
+
const gameTouches = [];
|
|
5909
|
+
for (const t of e.touches)
|
|
5910
|
+
if (!isGamepadTouch(t)) gameTouches.push(t);
|
|
5911
|
+
|
|
5912
|
+
// check if touching and pass to mouse events
|
|
5913
|
+
const touching = gameTouches.length;
|
|
5914
|
+
const button = 0; // all touches are left mouse button
|
|
5915
|
+
if (touching)
|
|
5778
5916
|
{
|
|
5779
|
-
|
|
5780
|
-
|
|
5917
|
+
// set event pos and pass it along
|
|
5918
|
+
const pos = vec2(gameTouches[0].clientX, gameTouches[0].clientY);
|
|
5919
|
+
const mousePosScreenLast = mousePosScreen;
|
|
5920
|
+
mousePosScreen = mouseEventToScreen(pos);
|
|
5921
|
+
if (wasTouching)
|
|
5922
|
+
mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
|
|
5923
|
+
else
|
|
5924
|
+
{
|
|
5925
|
+
inputData[0][button] = 3;
|
|
5926
|
+
isUsingGamepad = false; // a passthrough tap is mouse-style input
|
|
5927
|
+
}
|
|
5781
5928
|
}
|
|
5782
|
-
else
|
|
5783
|
-
inputData[0][button] =
|
|
5784
|
-
}
|
|
5785
|
-
else if (wasTouching)
|
|
5786
|
-
inputData[0][button] = inputData[0][button] & 2 | 4;
|
|
5929
|
+
else if (wasTouching)
|
|
5930
|
+
inputData[0][button] = inputData[0][button] & 2 | 4;
|
|
5787
5931
|
|
|
5788
|
-
|
|
5789
|
-
|
|
5932
|
+
// set was touching
|
|
5933
|
+
wasTouching = touching;
|
|
5934
|
+
}
|
|
5790
5935
|
|
|
5791
5936
|
// prevent default handling like copy, magnifier lens, and scrolling
|
|
5792
5937
|
if (inputPreventDefault && e.cancelable && document.hasFocus())
|
|
@@ -5796,83 +5941,6 @@ function inputInit()
|
|
|
5796
5941
|
return true;
|
|
5797
5942
|
}
|
|
5798
5943
|
|
|
5799
|
-
// special handling for virtual gamepad mode
|
|
5800
|
-
function handleTouchGamepad(e)
|
|
5801
|
-
{
|
|
5802
|
-
// clear touch gamepad input
|
|
5803
|
-
touchGamepadSticks.length = 0;
|
|
5804
|
-
touchGamepadSticks[0] = vec2();
|
|
5805
|
-
touchGamepadSticks[1] = vec2();
|
|
5806
|
-
touchGamepadButtons.length = 0;
|
|
5807
|
-
isUsingGamepad = true;
|
|
5808
|
-
|
|
5809
|
-
const touching = e.touches.length;
|
|
5810
|
-
if (touching)
|
|
5811
|
-
{
|
|
5812
|
-
touchGamepadTimer.set();
|
|
5813
|
-
if (touchGamepadCenterButtonSize && !wasTouching && paused)
|
|
5814
|
-
{
|
|
5815
|
-
// touch anywhere to press start when paused
|
|
5816
|
-
touchGamepadButtons[9] = 1;
|
|
5817
|
-
return;
|
|
5818
|
-
}
|
|
5819
|
-
}
|
|
5820
|
-
|
|
5821
|
-
// don't process touch gamepad if paused
|
|
5822
|
-
if (paused) return;
|
|
5823
|
-
|
|
5824
|
-
// get center of left and right sides
|
|
5825
|
-
const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
|
|
5826
|
-
const buttonCenter = touchGamepadButtonCenter();
|
|
5827
|
-
const startCenter = mainCanvasSize.scale(.5);
|
|
5828
|
-
|
|
5829
|
-
// check each touch point
|
|
5830
|
-
for (const touch of e.touches)
|
|
5831
|
-
{
|
|
5832
|
-
const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
|
|
5833
|
-
if (stickCenter.distance(touchPos) < touchGamepadSize)
|
|
5834
|
-
{
|
|
5835
|
-
// virtual analog stick
|
|
5836
|
-
const delta = touchPos.subtract(stickCenter);
|
|
5837
|
-
touchGamepadSticks[0] = delta.scale(2/touchGamepadSize).clampLength();
|
|
5838
|
-
touchGamepadButtons[10] = 1; // also press a button when touching stick
|
|
5839
|
-
}
|
|
5840
|
-
else if (buttonCenter.distance(touchPos) < touchGamepadSize)
|
|
5841
|
-
{
|
|
5842
|
-
if (touchGamepadButtonCount === 1)
|
|
5843
|
-
{
|
|
5844
|
-
// virtual right analog stick
|
|
5845
|
-
const delta = touchPos.subtract(buttonCenter);
|
|
5846
|
-
touchGamepadSticks[1] = delta.scale(2/touchGamepadSize).clampLength();
|
|
5847
|
-
touchGamepadButtons[11] = 1; // also press a button when touching right stick
|
|
5848
|
-
}
|
|
5849
|
-
// virtual face buttons
|
|
5850
|
-
let button = buttonCenter.subtract(touchPos).direction();
|
|
5851
|
-
button = mod(button+2, 4);
|
|
5852
|
-
if (touchGamepadButtonCount === 1)
|
|
5853
|
-
button = 0;
|
|
5854
|
-
else if (touchGamepadButtonCount === 2)
|
|
5855
|
-
{
|
|
5856
|
-
const delta = buttonCenter.subtract(touchPos);
|
|
5857
|
-
button = -delta.x < delta.y ? 1 : 0;
|
|
5858
|
-
}
|
|
5859
|
-
// fix button locations (swap 2 and 3 to match gamepad layout)
|
|
5860
|
-
button = button === 3 ? 2 : button === 2 ? 3 : button;
|
|
5861
|
-
if (button < touchGamepadButtonCount)
|
|
5862
|
-
touchGamepadButtons[button] = 1;
|
|
5863
|
-
}
|
|
5864
|
-
else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize &&
|
|
5865
|
-
stickCenter.distance(touchPos) >= 2 * touchGamepadSize &&
|
|
5866
|
-
buttonCenter.distance(touchPos) >= 2 * touchGamepadSize)
|
|
5867
|
-
{
|
|
5868
|
-
// virtual start button in center
|
|
5869
|
-
// require a fat-finger buffer of touchGamepadSize beyond the
|
|
5870
|
-
// edge of the stick/buttons so drift off those controls can't
|
|
5871
|
-
// accidentally fire start
|
|
5872
|
-
touchGamepadButtons[9] = 1;
|
|
5873
|
-
}
|
|
5874
|
-
}
|
|
5875
|
-
}
|
|
5876
5944
|
}
|
|
5877
5945
|
|
|
5878
5946
|
// convert a mouse or touch event position to screen space
|
|
@@ -5897,6 +5965,9 @@ function inputUpdate()
|
|
|
5897
5965
|
mousePos = screenToWorld(mousePosScreen);
|
|
5898
5966
|
mouseDelta = screenToWorldDelta(mouseDeltaScreen);
|
|
5899
5967
|
|
|
5968
|
+
// build the touch gamepad overlay lazily once enabled on a touch device
|
|
5969
|
+
touchGamepadInit();
|
|
5970
|
+
|
|
5900
5971
|
// update gamepads if enabled
|
|
5901
5972
|
gamepadsUpdate();
|
|
5902
5973
|
|
|
@@ -5915,22 +5986,11 @@ function inputUpdate()
|
|
|
5915
5986
|
// update touch gamepad if enabled
|
|
5916
5987
|
if (touchGamepadEnable && isTouchDevice)
|
|
5917
5988
|
{
|
|
5918
|
-
|
|
5919
|
-
|
|
5920
|
-
|
|
5921
|
-
|
|
5922
|
-
|
|
5923
|
-
|
|
5924
|
-
debugCircle(stickCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
|
|
5925
|
-
debugCircle(buttonCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
|
|
5926
|
-
if (touchGamepadCenterButtonSize)
|
|
5927
|
-
{
|
|
5928
|
-
debugCircle(startCenter, 2*touchGamepadCenterButtonSize, 'cyan', 0, false, true);
|
|
5929
|
-
// exclusion bubbles around controls (where start is blocked)
|
|
5930
|
-
debugCircle(stickCenter, 4*touchGamepadSize, 'magenta', 0, false, true);
|
|
5931
|
-
debugCircle(buttonCenter, 4*touchGamepadSize, 'magenta', 0, false, true);
|
|
5932
|
-
}
|
|
5933
|
-
}
|
|
5989
|
+
// a side is either a stick or buttons - setting both is ambiguous
|
|
5990
|
+
ASSERT(!touchGamepadLeftStick || !touchGamepadLeftButtonCount,
|
|
5991
|
+
'set touchGamepadLeftStick or touchGamepadLeftButtonCount, not both');
|
|
5992
|
+
ASSERT(!touchGamepadRightStick || !touchGamepadButtonCount,
|
|
5993
|
+
'set touchGamepadRightStick or touchGamepadButtonCount, not both');
|
|
5934
5994
|
|
|
5935
5995
|
if (!touchGamepadTimer.isSet()) return;
|
|
5936
5996
|
|
|
@@ -5938,23 +5998,24 @@ function inputUpdate()
|
|
|
5938
5998
|
gamepadPrimary = 0; // touch gamepad uses index 0
|
|
5939
5999
|
const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
|
|
5940
6000
|
const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
|
|
5941
|
-
sticks
|
|
6001
|
+
sticks.length = 0; // only report sticks that are enabled
|
|
5942
6002
|
dpad.set();
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
sticks[0] = applyDeadZones(leftTouchStick);
|
|
5946
|
-
else if (leftTouchStick.lengthSquared() > .3)
|
|
5947
|
-
{
|
|
5948
|
-
// convert to 8 way dpad
|
|
5949
|
-
const x = clamp(round(leftTouchStick.x), -1, 1);
|
|
5950
|
-
const y = clamp(round(leftTouchStick.y), -1, 1);
|
|
5951
|
-
dpad.set(x, -y);
|
|
5952
|
-
sticks[0] = dpad.clampLength(); // clamp to circle
|
|
5953
|
-
}
|
|
5954
|
-
if (touchGamepadButtonCount === 1)
|
|
6003
|
+
// read each side's directional stick (analog, or quantized to an 8 way dpad)
|
|
6004
|
+
for (let side = 0; side < 2; side++)
|
|
5955
6005
|
{
|
|
5956
|
-
|
|
5957
|
-
|
|
6006
|
+
if (!touchGamepadSideStick(side)) continue;
|
|
6007
|
+
const out = touchGamepadStickOut(side);
|
|
6008
|
+
sticks[out] = vec2();
|
|
6009
|
+
const touchStick = touchGamepadSticks[side] ?? vec2();
|
|
6010
|
+
if (touchGamepadAnalog)
|
|
6011
|
+
sticks[out] = applyDeadZones(touchStick);
|
|
6012
|
+
else if (touchStick.lengthSquared() > .3)
|
|
6013
|
+
{
|
|
6014
|
+
const x = clamp(round(touchStick.x), -1, 1);
|
|
6015
|
+
const y = clamp(round(touchStick.y), -1, 1);
|
|
6016
|
+
sticks[out] = vec2(x, -y).clampLength(); // clamp to circle
|
|
6017
|
+
if (!out) dpad.set(x, -y); // the primary (stick 0) also drives the dpad vector
|
|
6018
|
+
}
|
|
5958
6019
|
}
|
|
5959
6020
|
|
|
5960
6021
|
// read virtual gamepad buttons
|
|
@@ -5963,6 +6024,12 @@ function inputUpdate()
|
|
|
5963
6024
|
{
|
|
5964
6025
|
const wasDown = gamepadIsDown(i,0);
|
|
5965
6026
|
data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
|
|
6027
|
+
|
|
6028
|
+
// haptic tap when a face button or start button is first pressed (3 = newly down)
|
|
6029
|
+
// skip stick touches (10, 11) so movement doesn't buzz
|
|
6030
|
+
if (touchGamepadVibration && data[i] === 3 &&
|
|
6031
|
+
(i === 9 || touchGamepadIsFaceButton(i)))
|
|
6032
|
+
vibrate(touchGamepadVibration);
|
|
5966
6033
|
}
|
|
5967
6034
|
|
|
5968
6035
|
// disable normal gamepads when touch gamepad is active
|
|
@@ -6063,80 +6130,470 @@ function inputUpdatePost()
|
|
|
6063
6130
|
function inputRender()
|
|
6064
6131
|
{
|
|
6065
6132
|
touchGamepadRender();
|
|
6133
|
+
}
|
|
6134
|
+
|
|
6135
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6136
|
+
// Touch gamepad - full-viewport HTML/SVG overlay driven by Pointer Events
|
|
6137
|
+
|
|
6138
|
+
const touchGamepadSvgNS = 'http://www.w3.org/2000/svg';
|
|
6066
6139
|
|
|
6067
|
-
|
|
6140
|
+
// build the overlay DOM once; no-op if already built, disabled, headless, or non-touch
|
|
6141
|
+
function touchGamepadInit()
|
|
6142
|
+
{
|
|
6143
|
+
if (touchGamepadOverlay || !touchGamepadEnable || !isTouchDevice || headlessMode ||
|
|
6144
|
+
!document.body) // body may not exist yet; retry on a later frame
|
|
6145
|
+
return;
|
|
6146
|
+
|
|
6147
|
+
// full-viewport overlay; only the input zones receive pointer events. The
|
|
6148
|
+
// env() padding insets the stage out of notches / home indicators natively.
|
|
6149
|
+
const overlay = touchGamepadOverlay = document.createElement('div');
|
|
6150
|
+
overlay.style.cssText =
|
|
6151
|
+
'position:fixed;inset:0;z-index:50;pointer-events:none;opacity:0;' +
|
|
6152
|
+
'touch-action:none;user-select:none;-webkit-user-select:none;' +
|
|
6153
|
+
'-webkit-touch-callout:none;transition:opacity .2s;box-sizing:border-box;' +
|
|
6154
|
+
'padding:env(safe-area-inset-top) env(safe-area-inset-right) ' +
|
|
6155
|
+
'env(safe-area-inset-bottom) env(safe-area-inset-left)';
|
|
6156
|
+
|
|
6157
|
+
// stage fills the padded (safe-area) content box; all controls live inside it
|
|
6158
|
+
const stage = touchGamepadStage = document.createElement('div');
|
|
6159
|
+
stage.style.cssText = 'position:relative;width:100%;height:100%;pointer-events:none';
|
|
6160
|
+
overlay.appendChild(stage);
|
|
6161
|
+
|
|
6162
|
+
// svg draws every visual and never blocks input
|
|
6163
|
+
const svg = touchGamepadSvg = document.createElementNS(touchGamepadSvgNS, 'svg');
|
|
6164
|
+
svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;' +
|
|
6165
|
+
'pointer-events:none;overflow:visible;fill:none;stroke:#fff;stroke-width:3';
|
|
6166
|
+
stage.appendChild(svg);
|
|
6167
|
+
|
|
6168
|
+
// invisible input zones (left stick, right buttons/stick, center start)
|
|
6169
|
+
const makeZone = ()=>
|
|
6170
|
+
{
|
|
6171
|
+
const z = document.createElement('div');
|
|
6172
|
+
z.style.cssText = 'position:absolute;pointer-events:auto;touch-action:none';
|
|
6173
|
+
z.addEventListener('pointerdown', e=> touchGamepadPointerDown(e, z));
|
|
6174
|
+
z.addEventListener('pointermove', e=> touchGamepadPointerMove(e));
|
|
6175
|
+
z.addEventListener('pointerup', e=> touchGamepadPointerUp(e));
|
|
6176
|
+
z.addEventListener('pointercancel', e=> touchGamepadPointerUp(e));
|
|
6177
|
+
stage.appendChild(z);
|
|
6178
|
+
return z;
|
|
6179
|
+
};
|
|
6180
|
+
touchGamepadSideZones[0] = makeZone(); // left
|
|
6181
|
+
touchGamepadSideZones[1] = makeZone(); // right
|
|
6182
|
+
touchGamepadZoneC = makeZone(); // center/start, appended last so it sits above the sides
|
|
6183
|
+
|
|
6184
|
+
addEventListener('resize', ()=> touchGamepadNeedRelayout = true);
|
|
6185
|
+
document.body.appendChild(overlay);
|
|
6186
|
+
touchGamepadNeedRelayout = true;
|
|
6187
|
+
}
|
|
6188
|
+
|
|
6189
|
+
// stage-local size in CSS pixels (excludes safe-area insets)
|
|
6190
|
+
function touchGamepadStageRect() { return touchGamepadStage.getBoundingClientRect(); }
|
|
6191
|
+
|
|
6192
|
+
// per-side touch gamepad config (side 0 = left, 1 = right) - the left and right
|
|
6193
|
+
// sides behave identically, differing only in position and gamepad button indices
|
|
6194
|
+
function touchGamepadSideStick(side)
|
|
6195
|
+
{ return side ? touchGamepadRightStick : touchGamepadLeftStick; }
|
|
6196
|
+
function touchGamepadSideButtonCount(side)
|
|
6197
|
+
{ return side ? touchGamepadButtonCount : touchGamepadLeftButtonCount; }
|
|
6198
|
+
// gamepad button index a side's buttons start at (right 0-3, left 4-7)
|
|
6199
|
+
function touchGamepadSideButtonBase(side)
|
|
6200
|
+
{ return side ? 0 : 4; }
|
|
6201
|
+
// output stick index for a side: the right stick uses stick 0 when there is no left stick
|
|
6202
|
+
function touchGamepadStickOut(side)
|
|
6203
|
+
{ return side && touchGamepadLeftStick ? 1 : 0; }
|
|
6204
|
+
// true if the side has any control (a stick or at least one button)
|
|
6205
|
+
function touchGamepadSideHasControl(side)
|
|
6206
|
+
{ return touchGamepadSideStick(side) || touchGamepadSideButtonCount(side) > 0; }
|
|
6207
|
+
|
|
6208
|
+
// true if gamepad button index i is an active touch gamepad face/single button
|
|
6209
|
+
function touchGamepadIsFaceButton(i)
|
|
6210
|
+
{
|
|
6211
|
+
for (let side = 0; side < 2; side++)
|
|
6068
6212
|
{
|
|
6069
|
-
|
|
6070
|
-
if (!
|
|
6213
|
+
const base = touchGamepadSideButtonBase(side);
|
|
6214
|
+
if (!touchGamepadSideStick(side) &&
|
|
6215
|
+
i >= base && i < base + touchGamepadSideButtonCount(side))
|
|
6216
|
+
return true;
|
|
6217
|
+
}
|
|
6218
|
+
return false;
|
|
6219
|
+
}
|
|
6071
6220
|
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
|
|
6221
|
+
// center of a side's controls in stage-local CSS pixels (stick rest / button cluster)
|
|
6222
|
+
// returns the floating stick anchor when that side is an active floating stick
|
|
6223
|
+
function touchGamepadSideCenter(side, W, H)
|
|
6224
|
+
{
|
|
6225
|
+
if (touchGamepadFloating && touchGamepadSideStick(side) && touchGamepadStickAnchors[side])
|
|
6226
|
+
return touchGamepadStickAnchors[side];
|
|
6227
|
+
let y = H - touchGamepadSize;
|
|
6228
|
+
const count = touchGamepadSideButtonCount(side);
|
|
6229
|
+
if (!touchGamepadSideStick(side) && (count === 2 || count === 3))
|
|
6230
|
+
y -= touchGamepadSize/4; // nudge a 2/3 button cluster up a bit
|
|
6231
|
+
return vec2(side ? W - touchGamepadSize : touchGamepadSize, y);
|
|
6232
|
+
}
|
|
6075
6233
|
|
|
6076
|
-
|
|
6077
|
-
|
|
6078
|
-
|
|
6079
|
-
|
|
6080
|
-
|
|
6081
|
-
|
|
6234
|
+
// position the input zones for the current mode and rebuild the SVG visuals
|
|
6235
|
+
function touchGamepadRelayout()
|
|
6236
|
+
{
|
|
6237
|
+
if (!touchGamepadOverlay) return;
|
|
6238
|
+
const r = touchGamepadStageRect();
|
|
6239
|
+
const W = r.width, H = r.height, S = touchGamepadSize;
|
|
6240
|
+
const setZone = (z, css)=> z.style.cssText =
|
|
6241
|
+
'position:absolute;pointer-events:auto;touch-action:none;' + css;
|
|
6082
6242
|
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
|
|
6086
|
-
|
|
6087
|
-
const
|
|
6088
|
-
|
|
6089
|
-
|
|
6090
|
-
|
|
6091
|
-
|
|
6092
|
-
|
|
6093
|
-
|
|
6243
|
+
if (paused && touchGamepadCenterButtonSize)
|
|
6244
|
+
{
|
|
6245
|
+
// while paused, any touch presses start
|
|
6246
|
+
setZone(touchGamepadZoneC, 'inset:0');
|
|
6247
|
+
for (const zone of touchGamepadSideZones) zone.style.display = 'none';
|
|
6248
|
+
touchGamepadZoneC.style.display = '';
|
|
6249
|
+
}
|
|
6250
|
+
else
|
|
6251
|
+
{
|
|
6252
|
+
// position each side zone (left/right differ only by which edge they hug)
|
|
6253
|
+
for (let side = 0; side < 2; side++)
|
|
6094
6254
|
{
|
|
6095
|
-
|
|
6096
|
-
|
|
6255
|
+
const zone = touchGamepadSideZones[side], edge = side ? 'right' : 'left';
|
|
6256
|
+
zone.style.display = touchGamepadSideHasControl(side) ? '' : 'none';
|
|
6257
|
+
if (touchGamepadFloating)
|
|
6097
6258
|
{
|
|
6098
|
-
|
|
6099
|
-
|
|
6100
|
-
|
|
6259
|
+
// bottom 60% grabs the control; the top 40% passes through. A side with no
|
|
6260
|
+
// control on the other side uses the full width (matching the hit-test)
|
|
6261
|
+
const width = touchGamepadSideHasControl(side ? 0 : 1) ? '50%' : '100%';
|
|
6262
|
+
setZone(zone, `${edge}:0;bottom:0;width:${width};height:60%`);
|
|
6101
6263
|
}
|
|
6264
|
+
else // fixed: a compact box hugging the corner control
|
|
6265
|
+
setZone(zone, `${edge}:0;bottom:0;width:${3*S}px;height:${3*S}px`);
|
|
6102
6266
|
}
|
|
6103
|
-
|
|
6104
|
-
|
|
6267
|
+
touchGamepadZoneC.style.display = touchGamepadCenterButtonSize ? '' : 'none';
|
|
6268
|
+
const c = touchGamepadCenterButtonSize;
|
|
6269
|
+
setZone(touchGamepadZoneC,
|
|
6270
|
+
`left:50%;top:50%;width:${2*c}px;height:${2*c}px;transform:translate(-50%,-50%)`);
|
|
6271
|
+
}
|
|
6105
6272
|
|
|
6106
|
-
|
|
6273
|
+
touchGamepadBuildSvg(W, H);
|
|
6274
|
+
touchGamepadNeedRelayout = false;
|
|
6275
|
+
}
|
|
6276
|
+
|
|
6277
|
+
// (re)build the SVG shapes for the current layout; dynamic bits update per-frame
|
|
6278
|
+
function touchGamepadBuildSvg(W, H)
|
|
6279
|
+
{
|
|
6280
|
+
const svg = touchGamepadSvg;
|
|
6281
|
+
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
|
6282
|
+
const els = touchGamepadSvgEls = { face: [], thumb: [] };
|
|
6283
|
+
const S = touchGamepadSize;
|
|
6284
|
+
const circle = (cx, cy, rr, fill)=>
|
|
6285
|
+
{
|
|
6286
|
+
const c = document.createElementNS(touchGamepadSvgNS, 'circle');
|
|
6287
|
+
c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', rr);
|
|
6288
|
+
if (fill) c.setAttribute('fill', fill);
|
|
6289
|
+
svg.appendChild(c);
|
|
6290
|
+
return c;
|
|
6291
|
+
};
|
|
6292
|
+
const cross = (ctr)=>
|
|
6293
|
+
{
|
|
6294
|
+
// plus-shaped dpad outline centered at ctr
|
|
6295
|
+
const a = S*.18, b = S*.5, x = ctr.x, y = ctr.y;
|
|
6296
|
+
const p = document.createElementNS(touchGamepadSvgNS, 'path');
|
|
6297
|
+
p.setAttribute('d',
|
|
6298
|
+
`M ${x-a} ${y-b} H ${x+a} V ${y-a} H ${x+b} V ${y+a} H ${x+a} ` +
|
|
6299
|
+
`V ${y+b} H ${x-a} V ${y+a} H ${x-b} V ${y-a} H ${x-a} Z`);
|
|
6300
|
+
svg.appendChild(p);
|
|
6301
|
+
};
|
|
6302
|
+
|
|
6303
|
+
// draw each side: a directional stick, a single large button, or face buttons
|
|
6304
|
+
for (let side = 0; side < 2; side++)
|
|
6305
|
+
{
|
|
6306
|
+
const count = touchGamepadSideButtonCount(side);
|
|
6307
|
+
const base = touchGamepadSideButtonBase(side);
|
|
6308
|
+
const ctr = touchGamepadSideCenter(side, W, H);
|
|
6309
|
+
if (touchGamepadSideStick(side))
|
|
6107
6310
|
{
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6311
|
+
// directional stick (circle or cross) with a thumb dot that moves per-frame
|
|
6312
|
+
if (touchGamepadAnalog) circle(ctr.x, ctr.y, S/2); else cross(ctr);
|
|
6313
|
+
els.thumb[side] = circle(ctr.x, ctr.y, S/4, '#fff');
|
|
6314
|
+
}
|
|
6315
|
+
else if (count === 1)
|
|
6316
|
+
els.face[base] = circle(ctr.x, ctr.y, S/2, '#000'); // single large button
|
|
6317
|
+
else for (let i = 0; i < count; i++)
|
|
6318
|
+
{
|
|
6319
|
+
const j = mod(i-1, 4);
|
|
6320
|
+
let button = count > 2 ? j : min(j, count-1);
|
|
6321
|
+
button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
|
|
6322
|
+
const offset = vec2().setDirection(j, S/2);
|
|
6323
|
+
if (count === 2) offset.x *= -1;
|
|
6324
|
+
// left side mirrors the right layout's positions, keeping indices in order
|
|
6325
|
+
// (e.g. 2 buttons -> button 4 at bottom, button 5 at left)
|
|
6326
|
+
if (!side) offset.x *= -1;
|
|
6327
|
+
const pos = ctr.add(offset);
|
|
6328
|
+
els.face[base + button] = circle(pos.x, pos.y, S/4, '#000');
|
|
6329
|
+
}
|
|
6330
|
+
}
|
|
6331
|
+
|
|
6332
|
+
// debug: draw the proximity hit regions the hit-test actually uses
|
|
6333
|
+
if (debug && debugGamepads) touchGamepadBuildDebug(W, H);
|
|
6334
|
+
}
|
|
6335
|
+
|
|
6336
|
+
// draw debug outlines of the touch control hit regions into the overlay svg
|
|
6337
|
+
function touchGamepadBuildDebug(W, H)
|
|
6338
|
+
{
|
|
6339
|
+
const S = touchGamepadSize, svg = touchGamepadSvg;
|
|
6340
|
+
const shape = (tag, attrs, stroke)=>
|
|
6341
|
+
{
|
|
6342
|
+
const el = document.createElementNS(touchGamepadSvgNS, tag);
|
|
6343
|
+
for (const k in attrs) el.setAttribute(k, attrs[k]);
|
|
6344
|
+
el.setAttribute('stroke', stroke);
|
|
6345
|
+
el.setAttribute('stroke-width', 2);
|
|
6346
|
+
el.setAttribute('fill', 'none');
|
|
6347
|
+
svg.appendChild(el);
|
|
6348
|
+
};
|
|
6349
|
+
const ring = (c, rr, stroke)=> shape('circle', {cx:c.x, cy:c.y, r:rr}, stroke);
|
|
6350
|
+
|
|
6351
|
+
// green line: the left/right split that assigns a stick press to a side
|
|
6352
|
+
shape('line', {x1:W/2, y1:0, x2:W/2, y2:H}, '#0f0');
|
|
6353
|
+
|
|
6354
|
+
// cyan: where each side's control can be grabbed
|
|
6355
|
+
for (let side = 0; side < 2; side++)
|
|
6356
|
+
{
|
|
6357
|
+
if (touchGamepadSideStick(side))
|
|
6358
|
+
{
|
|
6359
|
+
if (touchGamepadFloating)
|
|
6112
6360
|
{
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
button = button === 3 ? 2 : button === 2 ? 3 : button;
|
|
6118
|
-
const pos = touchGamepadButtonCount < 2 ? buttonCenter :
|
|
6119
|
-
buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
|
|
6120
|
-
context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
|
|
6121
|
-
context.beginPath();
|
|
6122
|
-
context.arc(pos.x, pos.y, buttonSize, 0,9);
|
|
6123
|
-
context.fill();
|
|
6124
|
-
context.stroke();
|
|
6361
|
+
// grab region: this side's half (or the full width if the other side is empty)
|
|
6362
|
+
const top = H*.4, full = !touchGamepadSideHasControl(side ? 0 : 1);
|
|
6363
|
+
const x = full ? 0 : (side ? W/2 : 0);
|
|
6364
|
+
shape('rect', {x, y:top, width:full ? W : W/2, height:H-top}, '#0ff');
|
|
6125
6365
|
}
|
|
6366
|
+
else
|
|
6367
|
+
ring(touchGamepadSideCenter(side, W, H), 2*S, '#0ff');
|
|
6126
6368
|
}
|
|
6369
|
+
else if (touchGamepadSideButtonCount(side) >= 1)
|
|
6370
|
+
ring(touchGamepadSideCenter(side, W, H), S, '#0ff'); // face / single-button radius
|
|
6371
|
+
}
|
|
6127
6372
|
|
|
6128
|
-
|
|
6129
|
-
|
|
6373
|
+
// yellow: start button radius; magenta: where start is blocked (near a control)
|
|
6374
|
+
if (touchGamepadCenterButtonSize)
|
|
6375
|
+
{
|
|
6376
|
+
ring(vec2(W/2, H/2), touchGamepadCenterButtonSize, '#ff0');
|
|
6377
|
+
for (let side = 0; side < 2; side++)
|
|
6378
|
+
if (touchGamepadSideHasControl(side))
|
|
6379
|
+
ring(touchGamepadSideCenter(side, W, H), 2*S, '#f0f');
|
|
6380
|
+
}
|
|
6381
|
+
}
|
|
6382
|
+
|
|
6383
|
+
// per-frame: fade the overlay and move the thumbs / set pressed states
|
|
6384
|
+
function touchGamepadRender()
|
|
6385
|
+
{
|
|
6386
|
+
if (!touchGamepadOverlay || headlessMode) return;
|
|
6387
|
+
|
|
6388
|
+
// hide and bail if disabled at runtime (overlay stays in the DOM for reuse)
|
|
6389
|
+
// display:none also takes the input zones out of hit-testing so touches are
|
|
6390
|
+
// not silently captured away from the game while disabled
|
|
6391
|
+
if (!touchGamepadEnable || !isTouchDevice)
|
|
6392
|
+
{
|
|
6393
|
+
if (touchGamepadOverlay.style.display !== 'none')
|
|
6394
|
+
{
|
|
6395
|
+
// just disabled: hide the overlay and release any held controls
|
|
6396
|
+
touchGamepadOverlay.style.display = 'none';
|
|
6397
|
+
touchGamepadPointerRole.clear();
|
|
6398
|
+
touchGamepadButtons.length = 0;
|
|
6399
|
+
touchGamepadSticks.length = 0;
|
|
6400
|
+
touchGamepadStickPointerId.length = 0;
|
|
6401
|
+
}
|
|
6402
|
+
return;
|
|
6403
|
+
}
|
|
6404
|
+
touchGamepadOverlay.style.display = '';
|
|
6405
|
+
|
|
6406
|
+
// relayout when the paused state, a layout setting, or the debug view changes
|
|
6407
|
+
const dbg = debug && debugGamepads;
|
|
6408
|
+
const layout = [touchGamepadButtonCount, touchGamepadLeftButtonCount, touchGamepadLeftStick,
|
|
6409
|
+
touchGamepadRightStick, touchGamepadAnalog, touchGamepadSize, touchGamepadFloating,
|
|
6410
|
+
touchGamepadCenterButtonSize, paused, dbg].join();
|
|
6411
|
+
if (layout !== touchGamepadLastLayout)
|
|
6412
|
+
{
|
|
6413
|
+
touchGamepadLastLayout = layout;
|
|
6414
|
+
touchGamepadNeedRelayout = true;
|
|
6415
|
+
}
|
|
6416
|
+
// relayout before the visibility bail-out so the paused full-screen start zone applies
|
|
6417
|
+
if (touchGamepadNeedRelayout) touchGamepadRelayout();
|
|
6418
|
+
|
|
6419
|
+
// fade out when idle (always show when displayTime is 0, or while debugging)
|
|
6420
|
+
const fade = touchGamepadDisplayTime ?
|
|
6421
|
+
percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
|
|
6422
|
+
const visible = dbg || (touchGamepadTimer.isSet() && fade > 0 && !paused);
|
|
6423
|
+
touchGamepadOverlay.style.opacity = !visible ? 0 : dbg ? 1 : fade*touchGamepadAlpha;
|
|
6424
|
+
if (!visible) return;
|
|
6425
|
+
|
|
6426
|
+
const r = touchGamepadStageRect();
|
|
6427
|
+
const W = r.width, H = r.height, S = touchGamepadSize;
|
|
6428
|
+
const els = touchGamepadSvgEls;
|
|
6429
|
+
if (!els) return;
|
|
6430
|
+
|
|
6431
|
+
for (let side = 0; side < 2; side++)
|
|
6432
|
+
if (touchGamepadSideStick(side) && els.thumb[side])
|
|
6433
|
+
{
|
|
6434
|
+
const ctr = touchGamepadSideCenter(side, W, H);
|
|
6435
|
+
const t = ctr.add((touchGamepadSticks[side] ?? vec2()).scale(S/2));
|
|
6436
|
+
els.thumb[side].setAttribute('cx', t.x);
|
|
6437
|
+
els.thumb[side].setAttribute('cy', t.y);
|
|
6438
|
+
}
|
|
6439
|
+
for (let i = 0; i < els.face.length; i++)
|
|
6440
|
+
if (els.face[i])
|
|
6441
|
+
els.face[i].setAttribute('fill', touchGamepadButtons[i] ? '#fff' : '#000');
|
|
6442
|
+
}
|
|
6443
|
+
|
|
6444
|
+
// convert a pointer event to stage-local CSS pixels
|
|
6445
|
+
function touchGamepadEventPos(e)
|
|
6446
|
+
{
|
|
6447
|
+
const r = touchGamepadStageRect();
|
|
6448
|
+
return vec2(e.clientX - r.left, e.clientY - r.top);
|
|
6449
|
+
}
|
|
6450
|
+
|
|
6451
|
+
// set a directional stick from a stage-local point and flag its stick-touch button
|
|
6452
|
+
// (stick 0 press = button 10, stick 1 press = button 11, following the output index)
|
|
6453
|
+
function touchGamepadApplyStick(side, p)
|
|
6454
|
+
{
|
|
6455
|
+
const delta = p.subtract(touchGamepadStickAnchors[side]);
|
|
6456
|
+
touchGamepadSticks[side] = delta.scale(2/touchGamepadSize).clampLength();
|
|
6457
|
+
touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10] = 1;
|
|
6458
|
+
}
|
|
6459
|
+
|
|
6460
|
+
// pick a side's gamepad button index from a stage-local point, or -1 if outside the cluster
|
|
6461
|
+
function touchGamepadFaceButtonAt(side, p, W, H)
|
|
6462
|
+
{
|
|
6463
|
+
const count = touchGamepadSideButtonCount(side);
|
|
6464
|
+
const base = touchGamepadSideButtonBase(side);
|
|
6465
|
+
const bc = touchGamepadSideCenter(side, W, H);
|
|
6466
|
+
if (bc.distance(p) >= touchGamepadSize) return -1;
|
|
6467
|
+
if (count === 1) return base; // single large button
|
|
6468
|
+
const d = bc.subtract(p);
|
|
6469
|
+
if (!side) d.x *= -1; // left side mirrors the right layout's positions horizontally
|
|
6470
|
+
let button = count === 2 ? (d.x < d.y ? 1 : 0) : mod(d.direction()+2, 4);
|
|
6471
|
+
button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
|
|
6472
|
+
return button < count ? base + button : -1;
|
|
6473
|
+
}
|
|
6474
|
+
|
|
6475
|
+
// pick which control a stage-local press activates, by priority then proximity,
|
|
6476
|
+
// independent of which zone element captured it - so overlapping zones on small
|
|
6477
|
+
// screens resolve to the nearest control instead of whichever zone is topmost
|
|
6478
|
+
// returns {role:'stick', side} or {role:'face', btn} or {role:'start'} or undefined
|
|
6479
|
+
function touchGamepadControlAt(p, W, H)
|
|
6480
|
+
{
|
|
6481
|
+
const S = touchGamepadSize;
|
|
6482
|
+
const leftHalf = p.x < W/2;
|
|
6483
|
+
const floatTop = H*.4; // floating grab region is the bottom 60% of the screen
|
|
6484
|
+
|
|
6485
|
+
// check each side (left first for priority); a side is a stick or buttons
|
|
6486
|
+
for (let side = 0; side < 2; side++)
|
|
6487
|
+
{
|
|
6488
|
+
const onHalf = side ? !leftHalf : leftHalf;
|
|
6489
|
+
if (touchGamepadSideStick(side))
|
|
6490
|
+
{
|
|
6491
|
+
// a side with no control on the other side uses the full width
|
|
6492
|
+
const otherControl = touchGamepadSideHasControl(side ? 0 : 1);
|
|
6493
|
+
const grab = touchGamepadFloating ?
|
|
6494
|
+
(!otherControl || onHalf) && p.y > floatTop :
|
|
6495
|
+
onHalf && touchGamepadSideCenter(side, W, H).distance(p) < 2*S;
|
|
6496
|
+
if (grab) return {role:'stick', side};
|
|
6497
|
+
}
|
|
6498
|
+
else if (touchGamepadSideButtonCount(side) >= 1)
|
|
6499
|
+
{
|
|
6500
|
+
const btn = touchGamepadFaceButtonAt(side, p, W, H);
|
|
6501
|
+
if (btn >= 0) return {role:'face', btn};
|
|
6502
|
+
}
|
|
6130
6503
|
}
|
|
6504
|
+
|
|
6505
|
+
// center start button, blocked within 2*size of a control so drift off a
|
|
6506
|
+
// control can't accidentally fire start (matches the original exclusion logic)
|
|
6507
|
+
if (touchGamepadCenterButtonSize)
|
|
6508
|
+
{
|
|
6509
|
+
for (let side = 0; side < 2; side++)
|
|
6510
|
+
if (touchGamepadSideHasControl(side) &&
|
|
6511
|
+
touchGamepadSideCenter(side, W, H).distance(p) < 2*S)
|
|
6512
|
+
return;
|
|
6513
|
+
if (vec2(W/2, H/2).distance(p) < touchGamepadCenterButtonSize)
|
|
6514
|
+
return {role:'start'};
|
|
6515
|
+
}
|
|
6516
|
+
}
|
|
6517
|
+
|
|
6518
|
+
function touchGamepadPointerDown(e, zone)
|
|
6519
|
+
{
|
|
6520
|
+
if (!touchGamepadEnable) return;
|
|
6521
|
+
e.preventDefault();
|
|
6522
|
+
zone.setPointerCapture(e.pointerId);
|
|
6523
|
+
touchGamepadTimer.set();
|
|
6524
|
+
isUsingGamepad = true;
|
|
6525
|
+
|
|
6526
|
+
// resume audio on first interaction
|
|
6527
|
+
if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
|
|
6528
|
+
audioContext.resume();
|
|
6529
|
+
|
|
6530
|
+
// while paused, any touch is the start button
|
|
6531
|
+
if (paused)
|
|
6532
|
+
{
|
|
6533
|
+
if (touchGamepadCenterButtonSize)
|
|
6534
|
+
{
|
|
6535
|
+
touchGamepadButtons[9] = 1;
|
|
6536
|
+
touchGamepadPointerRole.set(e.pointerId, 'start');
|
|
6537
|
+
}
|
|
6538
|
+
return;
|
|
6539
|
+
}
|
|
6540
|
+
|
|
6541
|
+
const r = touchGamepadStageRect();
|
|
6542
|
+
const W = r.width, H = r.height;
|
|
6543
|
+
const p = vec2(e.clientX - r.left, e.clientY - r.top);
|
|
6544
|
+
|
|
6545
|
+
// choose the control by proximity/priority, not by which zone captured the touch
|
|
6546
|
+
const hit = touchGamepadControlAt(p, W, H);
|
|
6547
|
+
if (!hit) return;
|
|
6548
|
+
if (hit.role === 'stick')
|
|
6549
|
+
{
|
|
6550
|
+
const side = hit.side;
|
|
6551
|
+
touchGamepadStickAnchors[side] = touchGamepadFloating ? p : touchGamepadSideCenter(side, W, H);
|
|
6552
|
+
touchGamepadStickPointerId[side] = e.pointerId;
|
|
6553
|
+
touchGamepadPointerRole.set(e.pointerId, 'stick'+side);
|
|
6554
|
+
touchGamepadNeedRelayout = true; // base may have re-anchored
|
|
6555
|
+
touchGamepadApplyStick(side, p);
|
|
6556
|
+
}
|
|
6557
|
+
else if (hit.role === 'face')
|
|
6558
|
+
{
|
|
6559
|
+
touchGamepadButtons[hit.btn] = 1;
|
|
6560
|
+
touchGamepadPointerRole.set(e.pointerId, 'face'+hit.btn);
|
|
6561
|
+
}
|
|
6562
|
+
else // 'start'
|
|
6563
|
+
{
|
|
6564
|
+
touchGamepadButtons[9] = 1;
|
|
6565
|
+
touchGamepadPointerRole.set(e.pointerId, 'start');
|
|
6566
|
+
}
|
|
6567
|
+
}
|
|
6568
|
+
|
|
6569
|
+
function touchGamepadPointerMove(e)
|
|
6570
|
+
{
|
|
6571
|
+
const role = touchGamepadPointerRole.get(e.pointerId);
|
|
6572
|
+
if (!role) return;
|
|
6573
|
+
e.preventDefault();
|
|
6574
|
+
const p = touchGamepadEventPos(e);
|
|
6575
|
+
if (role === 'stick0' || role === 'stick1')
|
|
6576
|
+
touchGamepadApplyStick(role === 'stick1' ? 1 : 0, p);
|
|
6577
|
+
// face buttons & start are held until release (no slide-between this pass)
|
|
6131
6578
|
}
|
|
6132
6579
|
|
|
6133
|
-
|
|
6134
|
-
function touchGamepadButtonCenter()
|
|
6580
|
+
function touchGamepadPointerUp(e)
|
|
6135
6581
|
{
|
|
6136
|
-
const
|
|
6137
|
-
if (
|
|
6138
|
-
|
|
6139
|
-
|
|
6582
|
+
const role = touchGamepadPointerRole.get(e.pointerId);
|
|
6583
|
+
if (!role) return;
|
|
6584
|
+
touchGamepadPointerRole.delete(e.pointerId);
|
|
6585
|
+
if (role === 'stick0' || role === 'stick1')
|
|
6586
|
+
{
|
|
6587
|
+
const side = role === 'stick1' ? 1 : 0;
|
|
6588
|
+
touchGamepadStickPointerId[side] = undefined;
|
|
6589
|
+
touchGamepadSticks[side] = vec2();
|
|
6590
|
+
delete touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10];
|
|
6591
|
+
}
|
|
6592
|
+
else if (role === 'start')
|
|
6593
|
+
delete touchGamepadButtons[9];
|
|
6594
|
+
else // 'face<n>'
|
|
6595
|
+
delete touchGamepadButtons[+role.slice(4)];
|
|
6596
|
+
touchGamepadTimer.set();
|
|
6140
6597
|
}
|
|
6141
6598
|
/**
|
|
6142
6599
|
* LittleJS Audio System
|
|
@@ -6273,7 +6730,7 @@ class Sound
|
|
|
6273
6730
|
* @param {number} [randomnessScale] - How much to scale pitch randomness
|
|
6274
6731
|
* @param {boolean} [loop] - Should the sound loop?
|
|
6275
6732
|
* @param {boolean} [paused] - Should the sound start paused
|
|
6276
|
-
* @return {SoundInstance} - The
|
|
6733
|
+
* @return {SoundInstance} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode
|
|
6277
6734
|
*/
|
|
6278
6735
|
play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
|
|
6279
6736
|
{
|
|
@@ -6304,9 +6761,23 @@ class Sound
|
|
|
6304
6761
|
pan = worldToScreen(pos).x * 2/mainCanvas.width - 1;
|
|
6305
6762
|
}
|
|
6306
6763
|
|
|
6307
|
-
// Create
|
|
6764
|
+
// Create sound instance
|
|
6308
6765
|
const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
|
|
6309
|
-
|
|
6766
|
+
const instance = new SoundInstance(this, volume, rate, pan, loop, paused);
|
|
6767
|
+
|
|
6768
|
+
if (debug && debugSound && pos)
|
|
6769
|
+
{
|
|
6770
|
+
// visualize where positioned sounds play and their falloff range
|
|
6771
|
+
debugCircle(pos, .5, '#0ff', .5, true);
|
|
6772
|
+
if (this.range)
|
|
6773
|
+
{
|
|
6774
|
+
debugCircle(pos, 2*this.range, '#0ff', .5); // silent radius
|
|
6775
|
+
debugCircle(pos, 2*this.range*this.taper, '#0ff', .5); // full volume radius
|
|
6776
|
+
}
|
|
6777
|
+
debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5);
|
|
6778
|
+
}
|
|
6779
|
+
|
|
6780
|
+
return instance;
|
|
6310
6781
|
}
|
|
6311
6782
|
|
|
6312
6783
|
/** Play a music track that loops by default
|
|
@@ -6672,6 +7143,10 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
|
|
|
6672
7143
|
// play and return sound
|
|
6673
7144
|
const startOffset = offset * rate;
|
|
6674
7145
|
source.start(0, startOffset);
|
|
7146
|
+
|
|
7147
|
+
if (debug && debugSound)
|
|
7148
|
+
LOG('sound', 'vol', volume.toFixed(2), 'rate', rate.toFixed(2), 'pan', pan.toFixed(2), loop ? 'loop' : '');
|
|
7149
|
+
|
|
6675
7150
|
return source;
|
|
6676
7151
|
}
|
|
6677
7152
|
|
|
@@ -7505,6 +7980,11 @@ class TileCollisionLayer extends TileLayer
|
|
|
7505
7980
|
// check any tiles in the area for collision
|
|
7506
7981
|
const posX = pos.x - this.pos.x;
|
|
7507
7982
|
const posY = pos.y - this.pos.y;
|
|
7983
|
+
// reject AABBs entirely past either edge; without this, the negative
|
|
7984
|
+
// side leaks into row/col 0 because minX/minY clamp to 0 and the
|
|
7985
|
+
// point-test floor below forces maxX/maxY up to 1
|
|
7986
|
+
if (posX + size.x/2 < 0 || posX - size.x/2 > this.size.x) return false;
|
|
7987
|
+
if (posY + size.y/2 < 0 || posY - size.y/2 > this.size.y) return false;
|
|
7508
7988
|
const minX = max(posX - size.x/2|0, 0);
|
|
7509
7989
|
const minY = max(posY - size.y/2|0, 0);
|
|
7510
7990
|
// ensure at least one cell is visited even when size is 0 and pos
|
|
@@ -7623,10 +8103,10 @@ class ParticleEmitter extends EngineObject
|
|
|
7623
8103
|
* @param {number} [particleTime] - How long particles live
|
|
7624
8104
|
* @param {number} [sizeStart] - How big are particles at start
|
|
7625
8105
|
* @param {number} [sizeEnd] - How big are particles at end
|
|
7626
|
-
* @param {number} [speed] - How fast are particles when spawned
|
|
7627
|
-
* @param {number} [angleSpeed] - How fast are particles rotating
|
|
7628
|
-
* @param {number} [damping] - How much to dampen particle speed
|
|
7629
|
-
* @param {number} [angleDamping] - How much to dampen particle angular speed
|
|
8106
|
+
* @param {number} [speed] - How fast are particles when spawned, in world units per frame (at 60fps, so multiply units/sec by 1/60)
|
|
8107
|
+
* @param {number} [angleSpeed] - How fast are particles rotating, in radians per frame (at 60fps)
|
|
8108
|
+
* @param {number} [damping] - How much to dampen particle speed, per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame)
|
|
8109
|
+
* @param {number} [angleDamping] - How much to dampen particle angular speed, per-frame multiplier (1 = no damping)
|
|
7630
8110
|
* @param {number} [gravityScale] - How much gravity effect particles
|
|
7631
8111
|
* @param {number} [particleConeAngle] - Cone for start particle angle
|
|
7632
8112
|
* @param {number} [fadeRate] - Fraction of life spent fading: half at fade-in (start), half at fade-out (end). e.g. .2 = 10% fade-in, 80% full opacity, 10% fade-out
|
|
@@ -7701,13 +8181,13 @@ class ParticleEmitter extends EngineObject
|
|
|
7701
8181
|
this.sizeStart = sizeStart;
|
|
7702
8182
|
/** @property {number} - How big are particles at end */
|
|
7703
8183
|
this.sizeEnd = sizeEnd;
|
|
7704
|
-
/** @property {number} -
|
|
8184
|
+
/** @property {number} - Particle speed when spawned, in world units per frame (at 60fps) */
|
|
7705
8185
|
this.speed = speed;
|
|
7706
|
-
/** @property {number} -
|
|
8186
|
+
/** @property {number} - Particle angular speed when spawned, in radians per frame (at 60fps) */
|
|
7707
8187
|
this.angleSpeed = angleSpeed;
|
|
7708
|
-
/** @property {number} -
|
|
8188
|
+
/** @property {number} - Per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame) */
|
|
7709
8189
|
this.damping = damping;
|
|
7710
|
-
/** @property {number} -
|
|
8190
|
+
/** @property {number} - Per-frame angular velocity multiplier (1 = no damping) */
|
|
7711
8191
|
this.angleDamping = angleDamping;
|
|
7712
8192
|
/** @property {number} - How much gravity affects particles */
|
|
7713
8193
|
this.gravityScale = gravityScale;
|
|
@@ -7776,12 +8256,16 @@ class ParticleEmitter extends EngineObject
|
|
|
7776
8256
|
else if (this.particles.length === 0)
|
|
7777
8257
|
this.destroy(true);
|
|
7778
8258
|
|
|
7779
|
-
// update and remove destroyed particles
|
|
7780
|
-
|
|
8259
|
+
// update and remove destroyed particles in place to avoid per-frame array allocation
|
|
8260
|
+
const particles = this.particles;
|
|
8261
|
+
let alive = 0;
|
|
8262
|
+
for (let i = 0; i < particles.length; ++i)
|
|
7781
8263
|
{
|
|
8264
|
+
const p = particles[i];
|
|
7782
8265
|
p.update();
|
|
7783
|
-
|
|
7784
|
-
}
|
|
8266
|
+
if (!p.destroyed) particles[alive++] = p;
|
|
8267
|
+
}
|
|
8268
|
+
particles.length = alive;
|
|
7785
8269
|
|
|
7786
8270
|
if (debugParticles)
|
|
7787
8271
|
{
|
|
@@ -8063,7 +8547,7 @@ class Particle
|
|
|
8063
8547
|
{
|
|
8064
8548
|
// in local space of emitter
|
|
8065
8549
|
const a = emitter.angle;
|
|
8066
|
-
const c = cos(a), s = sin(a);
|
|
8550
|
+
const c = cos(-a), s = sin(-a);
|
|
8067
8551
|
pos.set(emitter.pos.x + pos.x*c - pos.y*s,
|
|
8068
8552
|
emitter.pos.y + pos.x*s + pos.y*c);
|
|
8069
8553
|
angle += a;
|
|
@@ -8071,8 +8555,8 @@ class Particle
|
|
|
8071
8555
|
if (trailScale)
|
|
8072
8556
|
{
|
|
8073
8557
|
// trail style particles
|
|
8074
|
-
const velocity = localSpace ?
|
|
8075
|
-
this.velocity.rotate(
|
|
8558
|
+
const velocity = localSpace ?
|
|
8559
|
+
this.velocity.rotate(emitter.angle) : this.velocity;
|
|
8076
8560
|
const speed = velocity.length();
|
|
8077
8561
|
if (speed)
|
|
8078
8562
|
{
|
|
@@ -9234,7 +9718,15 @@ function medalsInit(saveName)
|
|
|
9234
9718
|
// check if medals are unlocked
|
|
9235
9719
|
medalsSaveName = saveName;
|
|
9236
9720
|
if (!debugMedals)
|
|
9237
|
-
|
|
9721
|
+
{
|
|
9722
|
+
let saved = {};
|
|
9723
|
+
try { saved = JSON.parse(localStorage[saveName] || '{}'); }
|
|
9724
|
+
catch (e) { saved = {}; }
|
|
9725
|
+
medalsForEach(medal => {
|
|
9726
|
+
medal.unlocked = !!(saved[medal.id] && saved[medal.id].unlocked);
|
|
9727
|
+
});
|
|
9728
|
+
medalsSave();
|
|
9729
|
+
}
|
|
9238
9730
|
|
|
9239
9731
|
// engine automatically renders medals
|
|
9240
9732
|
engineAddPlugin(undefined, medalsRender);
|
|
@@ -9278,6 +9770,31 @@ function medalsInit(saveName)
|
|
|
9278
9770
|
function medalsForEach(callback)
|
|
9279
9771
|
{ Object.values(medals).forEach(medal=>callback(medal)); }
|
|
9280
9772
|
|
|
9773
|
+
/** Reset all medals to locked and persist the cleared catalog
|
|
9774
|
+
* @memberof Medals */
|
|
9775
|
+
function medalsReset()
|
|
9776
|
+
{
|
|
9777
|
+
medalsForEach(medal => medal.unlocked = false);
|
|
9778
|
+
medalsSave();
|
|
9779
|
+
}
|
|
9780
|
+
|
|
9781
|
+
function medalsSave()
|
|
9782
|
+
{
|
|
9783
|
+
if (!medalsSaveName) return;
|
|
9784
|
+
const data = {};
|
|
9785
|
+
medalsForEach(medal => {
|
|
9786
|
+
const entry = {
|
|
9787
|
+
name: medal.name,
|
|
9788
|
+
description: medal.description,
|
|
9789
|
+
icon: medal.icon,
|
|
9790
|
+
unlocked: medal.unlocked,
|
|
9791
|
+
};
|
|
9792
|
+
if (medal.image) entry.src = medal.image.src;
|
|
9793
|
+
data[medal.id] = entry;
|
|
9794
|
+
});
|
|
9795
|
+
localStorage[medalsSaveName] = JSON.stringify(data);
|
|
9796
|
+
}
|
|
9797
|
+
|
|
9281
9798
|
///////////////////////////////////////////////////////////////////////////////
|
|
9282
9799
|
|
|
9283
9800
|
/**
|
|
@@ -9335,9 +9852,9 @@ class Medal
|
|
|
9335
9852
|
{
|
|
9336
9853
|
if (medalsPreventUnlock || this.unlocked) return;
|
|
9337
9854
|
|
|
9338
|
-
// save the medal
|
|
9339
9855
|
ASSERT(medalsSaveName, 'save name must be set');
|
|
9340
|
-
|
|
9856
|
+
this.unlocked = true;
|
|
9857
|
+
medalsSave();
|
|
9341
9858
|
medalsDisplayQueue.push(this);
|
|
9342
9859
|
}
|
|
9343
9860
|
|
|
@@ -9395,8 +9912,6 @@ class Medal
|
|
|
9395
9912
|
drawTextScreen(this.icon, pos, size*.7, BLACK);
|
|
9396
9913
|
}
|
|
9397
9914
|
|
|
9398
|
-
// Get local storage key used by the medal
|
|
9399
|
-
storageKey() { return medalsSaveName + '_' + this.id; }
|
|
9400
9915
|
}
|
|
9401
9916
|
|
|
9402
9917
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -9781,6 +10296,335 @@ class PostProcessPlugin
|
|
|
9781
10296
|
}
|
|
9782
10297
|
}
|
|
9783
10298
|
}
|
|
10299
|
+
/**
|
|
10300
|
+
* LittleJS Light System Plugin
|
|
10301
|
+
* - Adds 2D dynamic lighting to the scene
|
|
10302
|
+
* - Lights are first-class EngineObjects (the Light class)
|
|
10303
|
+
* - Each Light draws a soft falloff blob of its color into a shared lightmap
|
|
10304
|
+
* - Lights accumulate ADDITIVELY in the lightmap (red + blue = magenta)
|
|
10305
|
+
* - The lightmap is then MULTIPLIED with the scene during composite, so unlit
|
|
10306
|
+
* areas go to the ambient color and lit areas show the scene tinted by the
|
|
10307
|
+
* accumulated light color
|
|
10308
|
+
* - Draw the world at full brightness — the lightmap does the darkening
|
|
10309
|
+
* - Any EngineObject may override renderLight() to additively contribute to the
|
|
10310
|
+
* lightmap (e.g. emissive lava tiles, weapon flashes, glowing crystals)
|
|
10311
|
+
* - Must be constructed BEFORE PostProcessPlugin so post-process sees lit pixels
|
|
10312
|
+
* @namespace LightSystem
|
|
10313
|
+
*/
|
|
10314
|
+
|
|
10315
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
10316
|
+
|
|
10317
|
+
/** Global Light System plugin object
|
|
10318
|
+
* @type {LightSystemPlugin}
|
|
10319
|
+
* @memberof LightSystem */
|
|
10320
|
+
let lightSystem;
|
|
10321
|
+
|
|
10322
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
10323
|
+
|
|
10324
|
+
/**
|
|
10325
|
+
* LightSystemPlugin
|
|
10326
|
+
* - Owns the offscreen lightmap texture, falloff/composite shaders, and the
|
|
10327
|
+
* per-frame render pass that multiplies the lightmap onto the WebGL scene
|
|
10328
|
+
* - The composite is MULTIPLICATIVE: unlit areas get the ambient color, lit
|
|
10329
|
+
* areas show the scene tinted by the accumulated light color. So you should
|
|
10330
|
+
* draw your world at full brightness — the lightmap handles the darkening.
|
|
10331
|
+
* @memberof LightSystem
|
|
10332
|
+
*/
|
|
10333
|
+
class LightSystemPlugin
|
|
10334
|
+
{
|
|
10335
|
+
/** Create the global light system plugin.
|
|
10336
|
+
* @param {Vector2} [textureSize] - Size of the lightmap texture (defaults to mainCanvasSize)
|
|
10337
|
+
* @param {Color} [ambientColor] - Color applied to unlit areas of the scene (defaults to BLACK = pitch dark). Set a small RGB like rgb(0.1,0.1,0.15) for a faint "moonlight" baseline so unlit areas aren't fully black.
|
|
10338
|
+
* @example
|
|
10339
|
+
* // simplest usage
|
|
10340
|
+
* new LightSystemPlugin();
|
|
10341
|
+
*/
|
|
10342
|
+
constructor(textureSize, ambientColor)
|
|
10343
|
+
{
|
|
10344
|
+
ASSERT(!lightSystem, 'LightSystemPlugin already initialized');
|
|
10345
|
+
ASSERT(!postProcess, 'LightSystemPlugin must be created before PostProcessPlugin');
|
|
10346
|
+
lightSystem = this;
|
|
10347
|
+
|
|
10348
|
+
/** @property {boolean} - When false, the render pass is skipped entirely */
|
|
10349
|
+
this.enabled = true;
|
|
10350
|
+
/** @property {Color} - Baseline color applied to unlit areas of the scene. Defaults to BLACK (pitch dark). Set to a small RGB for a faint ambient. The lightmap is cleared to this color each frame, then lights add on top, then the result multiplies the scene. */
|
|
10351
|
+
this.ambientColor = (ambientColor || BLACK).copy();
|
|
10352
|
+
/** @property {Vector2} - Size of the lightmap texture (set at construction; falls back to mainCanvasSize at init time) */
|
|
10353
|
+
this.textureSize = textureSize ? textureSize.copy() : undefined;
|
|
10354
|
+
|
|
10355
|
+
/** @property {WebGLTexture} - The lightmap texture */
|
|
10356
|
+
this.texture = undefined;
|
|
10357
|
+
/** @property {WebGLProgram} - Shader for drawing per-Light falloff blobs into the lightmap */
|
|
10358
|
+
this.lightShader = undefined;
|
|
10359
|
+
/** @property {WebGLProgram} - Shader for compositing the lightmap over the main scene */
|
|
10360
|
+
this.compositeShader = undefined;
|
|
10361
|
+
/** @property {WebGLVertexArrayObject} - Vertex array object for the light shader */
|
|
10362
|
+
this.lightVAO = undefined;
|
|
10363
|
+
/** @property {WebGLVertexArrayObject} - Vertex array object for the composite shader */
|
|
10364
|
+
this.compositeVAO = undefined;
|
|
10365
|
+
|
|
10366
|
+
initLightSystem();
|
|
10367
|
+
engineAddPlugin(undefined, lightSystemRender,
|
|
10368
|
+
lightSystemContextLost, lightSystemContextRestored);
|
|
10369
|
+
|
|
10370
|
+
function initLightSystem()
|
|
10371
|
+
{
|
|
10372
|
+
if (headlessMode) return;
|
|
10373
|
+
if (!glEnable)
|
|
10374
|
+
{
|
|
10375
|
+
console.warn('LightSystemPlugin: WebGL not enabled!');
|
|
10376
|
+
return;
|
|
10377
|
+
}
|
|
10378
|
+
|
|
10379
|
+
// resolve texture size default at init time (mainCanvasSize may
|
|
10380
|
+
// not be set yet at the moment the constructor first ran)
|
|
10381
|
+
if (!lightSystem.textureSize)
|
|
10382
|
+
lightSystem.textureSize = mainCanvasSize.copy();
|
|
10383
|
+
|
|
10384
|
+
// allocate the lightmap texture with null data at textureSize
|
|
10385
|
+
lightSystem.texture = glContext.createTexture();
|
|
10386
|
+
glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
|
|
10387
|
+
glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA,
|
|
10388
|
+
lightSystem.textureSize.x, lightSystem.textureSize.y, 0,
|
|
10389
|
+
glContext.RGBA, glContext.UNSIGNED_BYTE, null);
|
|
10390
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, glContext.LINEAR);
|
|
10391
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, glContext.LINEAR);
|
|
10392
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, glContext.CLAMP_TO_EDGE);
|
|
10393
|
+
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, glContext.CLAMP_TO_EDGE);
|
|
10394
|
+
|
|
10395
|
+
// light falloff shader: one quad per Light, fragment computes radial falloff
|
|
10396
|
+
lightSystem.lightShader = glCreateProgram(
|
|
10397
|
+
'#version 300 es\n' +
|
|
10398
|
+
'precision highp float;'+
|
|
10399
|
+
'uniform mat4 m;'+
|
|
10400
|
+
'uniform vec2 lightPos;'+
|
|
10401
|
+
'uniform float radius;'+
|
|
10402
|
+
'in vec2 g;'+ // unit quad geometry [0..1]
|
|
10403
|
+
'out vec2 vWorldPos;'+
|
|
10404
|
+
'void main(){'+
|
|
10405
|
+
'vec2 worldP=lightPos+(g-.5)*2.*radius;'+
|
|
10406
|
+
'gl_Position=m*vec4(worldP,1,1);'+
|
|
10407
|
+
'vWorldPos=worldP;'+
|
|
10408
|
+
'}'
|
|
10409
|
+
,
|
|
10410
|
+
'#version 300 es\n' +
|
|
10411
|
+
'precision highp float;'+
|
|
10412
|
+
'uniform vec2 lightPos;'+
|
|
10413
|
+
'uniform float radius;'+
|
|
10414
|
+
'uniform float fadeRange;'+
|
|
10415
|
+
'uniform vec4 color;'+
|
|
10416
|
+
'in vec2 vWorldPos;'+
|
|
10417
|
+
'out vec4 c;'+
|
|
10418
|
+
'void main(){'+
|
|
10419
|
+
'float dist=distance(vWorldPos,lightPos);'+
|
|
10420
|
+
'float t=clamp((radius-dist)/max(fadeRange,1e-6),0.,1.);'+
|
|
10421
|
+
'c=vec4(color.rgb*t*color.a,1.);'+
|
|
10422
|
+
'}'
|
|
10423
|
+
);
|
|
10424
|
+
|
|
10425
|
+
// composite shader: fullscreen quad, samples the lightmap
|
|
10426
|
+
lightSystem.compositeShader = glCreateProgram(
|
|
10427
|
+
'#version 300 es\n' +
|
|
10428
|
+
'precision highp float;'+
|
|
10429
|
+
'in vec2 p;'+
|
|
10430
|
+
'void main(){'+
|
|
10431
|
+
'gl_Position=vec4(p+p-1.,1,1);'+
|
|
10432
|
+
'}'
|
|
10433
|
+
,
|
|
10434
|
+
'#version 300 es\n' +
|
|
10435
|
+
'precision highp float;'+
|
|
10436
|
+
'uniform sampler2D s;'+
|
|
10437
|
+
'uniform vec3 iResolution;'+
|
|
10438
|
+
'out vec4 c;'+
|
|
10439
|
+
'void main(){'+
|
|
10440
|
+
'vec2 uv=gl_FragCoord.xy/iResolution.xy;'+
|
|
10441
|
+
'c=vec4(texture(s,uv).rgb,1.);'+
|
|
10442
|
+
'}'
|
|
10443
|
+
);
|
|
10444
|
+
|
|
10445
|
+
// VAO for the per-Light quad — reuses the engine unit triangle-strip
|
|
10446
|
+
lightSystem.lightVAO = glContext.createVertexArray();
|
|
10447
|
+
glContext.bindVertexArray(lightSystem.lightVAO);
|
|
10448
|
+
glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer);
|
|
10449
|
+
const gLight = glContext.getAttribLocation(lightSystem.lightShader, 'g');
|
|
10450
|
+
glContext.enableVertexAttribArray(gLight);
|
|
10451
|
+
glContext.vertexAttribPointer(gLight, 2, glContext.FLOAT, false, 8, 0);
|
|
10452
|
+
|
|
10453
|
+
// VAO for the composite fullscreen quad — same buffer, attribute named 'p'
|
|
10454
|
+
lightSystem.compositeVAO = glContext.createVertexArray();
|
|
10455
|
+
glContext.bindVertexArray(lightSystem.compositeVAO);
|
|
10456
|
+
glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer);
|
|
10457
|
+
const pComp = glContext.getAttribLocation(lightSystem.compositeShader, 'p');
|
|
10458
|
+
glContext.enableVertexAttribArray(pComp);
|
|
10459
|
+
glContext.vertexAttribPointer(pComp, 2, glContext.FLOAT, false, 8, 0);
|
|
10460
|
+
}
|
|
10461
|
+
function lightSystemRender()
|
|
10462
|
+
{
|
|
10463
|
+
if (headlessMode || !glEnable) return;
|
|
10464
|
+
if (!lightSystem.enabled) return;
|
|
10465
|
+
if (!lightSystem.texture) return; // init failed or context lost
|
|
10466
|
+
|
|
10467
|
+
// 1. flush any in-flight sprite batch from earlier render passes
|
|
10468
|
+
glFlush();
|
|
10469
|
+
const prevAdditive = glAdditive;
|
|
10470
|
+
|
|
10471
|
+
// 2. bind lightmap as render target, clear to ambientColor
|
|
10472
|
+
const ac = lightSystem.ambientColor;
|
|
10473
|
+
glContext.bindFramebuffer(glContext.FRAMEBUFFER, glFramebuffer);
|
|
10474
|
+
glContext.framebufferTexture2D(glContext.FRAMEBUFFER,
|
|
10475
|
+
glContext.COLOR_ATTACHMENT0, glContext.TEXTURE_2D, lightSystem.texture, 0);
|
|
10476
|
+
glContext.viewport(0, 0, lightSystem.textureSize.x, lightSystem.textureSize.y);
|
|
10477
|
+
glContext.clearColor(ac.r, ac.g, ac.b, ac.a);
|
|
10478
|
+
glContext.clear(glContext.COLOR_BUFFER_BIT);
|
|
10479
|
+
|
|
10480
|
+
// 3. walk engineObjects calling renderLight() — additive blend
|
|
10481
|
+
// (lightmap accumulates raw additive color contributions)
|
|
10482
|
+
setBlendMode(true);
|
|
10483
|
+
glContext.enable(glContext.BLEND);
|
|
10484
|
+
glContext.blendFunc(glContext.ONE, glContext.ONE);
|
|
10485
|
+
|
|
10486
|
+
for (const o of engineObjects)
|
|
10487
|
+
o.destroyed || o.renderLight();
|
|
10488
|
+
|
|
10489
|
+
// 4. drain any sprite-batched draws (e.g. drawTile inside a
|
|
10490
|
+
// custom renderLight override) so they hit the FBO, not the
|
|
10491
|
+
// canvas after we unbind
|
|
10492
|
+
glFlush();
|
|
10493
|
+
glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
|
|
10494
|
+
glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
|
|
10495
|
+
|
|
10496
|
+
// 5. composite: fullscreen quad, multiplicative blend onto glCanvas
|
|
10497
|
+
// (scene * lightmap — unlit areas go to black, lit areas are
|
|
10498
|
+
// the scene tinted by the accumulated light color)
|
|
10499
|
+
glContext.useProgram(lightSystem.compositeShader);
|
|
10500
|
+
glContext.bindVertexArray(lightSystem.compositeVAO);
|
|
10501
|
+
glContext.activeTexture(glContext.TEXTURE0);
|
|
10502
|
+
glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
|
|
10503
|
+
const cs = lightSystem.compositeShader;
|
|
10504
|
+
glContext.uniform1i(glContext.getUniformLocation(cs, 's'), 0);
|
|
10505
|
+
glContext.uniform3f(glContext.getUniformLocation(cs, 'iResolution'),
|
|
10506
|
+
mainCanvas.width, mainCanvas.height, 1);
|
|
10507
|
+
glContext.blendFunc(glContext.DST_COLOR, glContext.ZERO);
|
|
10508
|
+
glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
|
|
10509
|
+
|
|
10510
|
+
// 6. restore engine state so subsequent draws use the engine's
|
|
10511
|
+
// tracked texture binding (otherwise glSetTexture would think
|
|
10512
|
+
// the prior texture was still bound when actually the lightmap
|
|
10513
|
+
// is, and any debug text / future draw could sample the lightmap)
|
|
10514
|
+
if (glActiveTexture)
|
|
10515
|
+
glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
|
|
10516
|
+
setBlendMode(prevAdditive);
|
|
10517
|
+
glSetInstancedMode(true);
|
|
10518
|
+
}
|
|
10519
|
+
function lightSystemContextLost()
|
|
10520
|
+
{
|
|
10521
|
+
lightSystem.texture = undefined;
|
|
10522
|
+
lightSystem.lightShader = undefined;
|
|
10523
|
+
lightSystem.compositeShader = undefined;
|
|
10524
|
+
lightSystem.lightVAO = undefined;
|
|
10525
|
+
lightSystem.compositeVAO = undefined;
|
|
10526
|
+
LOG('LightSystemPlugin: WebGL context lost');
|
|
10527
|
+
}
|
|
10528
|
+
function lightSystemContextRestored()
|
|
10529
|
+
{
|
|
10530
|
+
initLightSystem();
|
|
10531
|
+
LOG('LightSystemPlugin: WebGL context restored');
|
|
10532
|
+
}
|
|
10533
|
+
}
|
|
10534
|
+
|
|
10535
|
+
/** Draw a single Light's falloff blob into the currently bound lightmap.
|
|
10536
|
+
* Called by Light.renderLight() during the plugin's render pass.
|
|
10537
|
+
* @param {Light} light */
|
|
10538
|
+
drawLight(light)
|
|
10539
|
+
{
|
|
10540
|
+
if (headlessMode || !glEnable || !this.lightShader) return;
|
|
10541
|
+
|
|
10542
|
+
// drain any sprite-batched draws queued by a previous custom
|
|
10543
|
+
// renderLight() override (e.g. drawRect inside a LavaTile). They were
|
|
10544
|
+
// queued in the engine's instanced-vertex format and must flush with
|
|
10545
|
+
// the engine's shader+VAO bound — NOT this plugin's light shader.
|
|
10546
|
+
glFlush();
|
|
10547
|
+
|
|
10548
|
+
glContext.useProgram(this.lightShader);
|
|
10549
|
+
glContext.bindVertexArray(this.lightVAO);
|
|
10550
|
+
|
|
10551
|
+
// re-apply the engine camera transform onto this shader. Divide by
|
|
10552
|
+
// mainCanvasSize (not textureSize) so world→NDC matches the main
|
|
10553
|
+
// pass; the viewport handles the lightmap's actual resolution.
|
|
10554
|
+
// No y-flip here: the composite samples this FBO with
|
|
10555
|
+
// gl_FragCoord/iResolution (origin bottom-left), so storing world
|
|
10556
|
+
// +Y at the top of the texture lines up with the canvas convention.
|
|
10557
|
+
const s = vec2(2*cameraScale).divide(mainCanvasSize);
|
|
10558
|
+
const rotatedCam = cameraPos.rotate(-cameraAngle);
|
|
10559
|
+
const p = vec2(-1).subtract(rotatedCam.multiply(s));
|
|
10560
|
+
const ca = cos(cameraAngle);
|
|
10561
|
+
const sa = sin(cameraAngle);
|
|
10562
|
+
const transform = [
|
|
10563
|
+
s.x * ca, s.y * sa, 0, 0,
|
|
10564
|
+
-s.x * sa, s.y * ca, 0, 0,
|
|
10565
|
+
1, 1, 1, 0,
|
|
10566
|
+
p.x, p.y, 0, 1];
|
|
10567
|
+
|
|
10568
|
+
const ls = this.lightShader;
|
|
10569
|
+
glContext.uniformMatrix4fv(glContext.getUniformLocation(ls, 'm'), false, transform);
|
|
10570
|
+
glContext.uniform2f(glContext.getUniformLocation(ls, 'lightPos'), light.pos.x, light.pos.y);
|
|
10571
|
+
glContext.uniform1f(glContext.getUniformLocation(ls, 'radius'), light.radius);
|
|
10572
|
+
glContext.uniform1f(glContext.getUniformLocation(ls, 'fadeRange'), light.fadeRange);
|
|
10573
|
+
const c = light.color;
|
|
10574
|
+
glContext.uniform4f(glContext.getUniformLocation(ls, 'color'), c.r, c.g, c.b, c.a);
|
|
10575
|
+
|
|
10576
|
+
glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
|
|
10577
|
+
|
|
10578
|
+
// restore engine's instanced shader+VAO so subsequent renderLight()
|
|
10579
|
+
// overrides that batch through drawRect/drawTile work correctly
|
|
10580
|
+
glSetInstancedMode(true);
|
|
10581
|
+
}
|
|
10582
|
+
}
|
|
10583
|
+
|
|
10584
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
10585
|
+
|
|
10586
|
+
/**
|
|
10587
|
+
* A Light is an EngineObject that contributes a soft additive blob of color
|
|
10588
|
+
* to the LightSystem plugin's lightmap.
|
|
10589
|
+
* @extends EngineObject
|
|
10590
|
+
* @memberof LightSystem
|
|
10591
|
+
* @example
|
|
10592
|
+
* new Light(vec2(5, 5), 4, rgb(1, 0.5, 0)); // orange light, full soft blob
|
|
10593
|
+
* new Light(vec2(0, 0), 8, rgb(1, 1, 1), 2); // white core with 2-unit soft halo
|
|
10594
|
+
*/
|
|
10595
|
+
class Light extends EngineObject
|
|
10596
|
+
{
|
|
10597
|
+
/** Create a light object and add it to the engine object list
|
|
10598
|
+
* @param {Vector2} pos - World space position
|
|
10599
|
+
* @param {number} radius - Total extent of the light in world units
|
|
10600
|
+
* @param {Color} [color] - Color of the light; alpha modulates intensity
|
|
10601
|
+
* @param {number} [fadeRange] - Width of the soft edge in world units (defaults to radius) */
|
|
10602
|
+
constructor(pos, radius, color, fadeRange)
|
|
10603
|
+
{
|
|
10604
|
+
super(pos, vec2(1), undefined, 0, color);
|
|
10605
|
+
ASSERT(isNumber(radius) && radius >= 0, 'Light radius must be a non-negative number');
|
|
10606
|
+
ASSERT(fadeRange === undefined || (isNumber(fadeRange) && fadeRange >= 0),
|
|
10607
|
+
'Light fadeRange must be a non-negative number when provided');
|
|
10608
|
+
|
|
10609
|
+
/** @property {number} - Total extent of the light in world units */
|
|
10610
|
+
this.radius = radius;
|
|
10611
|
+
/** @property {number} - Width of the soft edge in world units */
|
|
10612
|
+
this.fadeRange = fadeRange === undefined ? radius : fadeRange;
|
|
10613
|
+
}
|
|
10614
|
+
|
|
10615
|
+
/** Lights are invisible in the main render pass — they only contribute
|
|
10616
|
+
* to the lightmap via renderLight(). */
|
|
10617
|
+
render() {}
|
|
10618
|
+
|
|
10619
|
+
/** Draw this light's falloff blob into the lightmap.
|
|
10620
|
+
* Called by LightSystemPlugin during its render pass. No-op when the
|
|
10621
|
+
* plugin or WebGL is unavailable. */
|
|
10622
|
+
renderLight()
|
|
10623
|
+
{
|
|
10624
|
+
lightSystem && lightSystem.drawLight(this);
|
|
10625
|
+
}
|
|
10626
|
+
}
|
|
10627
|
+
|
|
9784
10628
|
/**
|
|
9785
10629
|
* LittleJS ZzFXM Plugin
|
|
9786
10630
|
* @namespace ZzFXM
|
|
@@ -11828,23 +12672,19 @@ class Box2dObject extends EngineObject
|
|
|
11828
12672
|
|
|
11829
12673
|
function box2dCreatePolygonShape(points)
|
|
11830
12674
|
{
|
|
11831
|
-
|
|
12675
|
+
ASSERT(3 <= points.length && points.length <= 8);
|
|
12676
|
+
const buffer = box2d.instance._malloc(points.length * 8);
|
|
12677
|
+
for (let i=0, offset=0; i<points.length; ++i)
|
|
11832
12678
|
{
|
|
11833
|
-
|
|
11834
|
-
|
|
11835
|
-
|
|
11836
|
-
|
|
11837
|
-
offset += 4;
|
|
11838
|
-
box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].y;
|
|
11839
|
-
offset += 4;
|
|
11840
|
-
}
|
|
11841
|
-
return box2d.instance.wrapPointer(buffer, box2d.instance.b2Vec2);
|
|
12679
|
+
box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].x;
|
|
12680
|
+
offset += 4;
|
|
12681
|
+
box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].y;
|
|
12682
|
+
offset += 4;
|
|
11842
12683
|
}
|
|
11843
|
-
|
|
11844
|
-
ASSERT(3 <= points.length && points.length <= 8);
|
|
12684
|
+
const box2dPoints = box2d.instance.wrapPointer(buffer, box2d.instance.b2Vec2);
|
|
11845
12685
|
const shape = new box2d.instance.b2PolygonShape();
|
|
11846
|
-
const box2dPoints = box2dCreatePointList(points);
|
|
11847
12686
|
shape.Set(box2dPoints, points.length);
|
|
12687
|
+
box2d.instance._free(buffer);
|
|
11848
12688
|
return shape;
|
|
11849
12689
|
}
|
|
11850
12690
|
|
|
@@ -13825,6 +14665,69 @@ function drawThreeSlice(pos, size, startTile, color, borderSize=1, additiveColor
|
|
|
13825
14665
|
const cornerPos = cornerOffset.multiply(vec2(flipX?-1:1, flipY?-flip:flip));
|
|
13826
14666
|
drawTile(pos.add(cornerPos.rotate(rotateAngle)), cornerSize, cornerTile, color, a, false, additiveColor, useWebGL, screenSpace, context);
|
|
13827
14667
|
}
|
|
14668
|
+
}
|
|
14669
|
+
|
|
14670
|
+
/** Draw a crescent / moon-phase shape built from a polygon
|
|
14671
|
+
* Routes through drawPoly, so it supports WebGL, screen space, color, and outlines
|
|
14672
|
+
* @param {Vector2} pos - Center position
|
|
14673
|
+
* @param {number} [size] - Diameter
|
|
14674
|
+
* @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
|
|
14675
|
+
* @param {Color} [color] - Fill color
|
|
14676
|
+
* @param {number} [angle] - Angle to rotate by
|
|
14677
|
+
* @param {boolean} [invert] - Flip which side is illuminated
|
|
14678
|
+
* @param {number} [lineWidth] - Outline width, 0 for no outline
|
|
14679
|
+
* @param {Color} [lineColor] - Outline color
|
|
14680
|
+
* @param {boolean} [useWebGL=glEnable] - Use WebGL for rendering
|
|
14681
|
+
* @param {boolean} [screenSpace] - Use screen space coordinates
|
|
14682
|
+
* @param {CanvasRenderingContext2D} [context] - Canvas context to use
|
|
14683
|
+
* @memberof DrawUtilities */
|
|
14684
|
+
function drawCrescent(pos, size=1, percent=0, color=WHITE, angle=0, invert=false, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
|
|
14685
|
+
{
|
|
14686
|
+
const points = getCrescentPoints(pos, size, percent, angle, invert);
|
|
14687
|
+
drawPoly(points, color, lineWidth, lineColor, vec2(), 0, useWebGL, screenSpace, context);
|
|
14688
|
+
}
|
|
14689
|
+
|
|
14690
|
+
/** Get the list of points that make up a crescent / moon-phase shape
|
|
14691
|
+
* Returns world-space points with pos and angle baked in, ready for drawPoly or other use
|
|
14692
|
+
* @param {Vector2} pos - Center position
|
|
14693
|
+
* @param {number} [size] - Diameter
|
|
14694
|
+
* @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
|
|
14695
|
+
* @param {number} [angle] - Angle to rotate by
|
|
14696
|
+
* @param {boolean} [invert] - Flip which side is illuminated
|
|
14697
|
+
* @param {number} [sides=glCircleSides] - Number of sides for a full circle (halved per arc)
|
|
14698
|
+
* @return {Array<Vector2>} - List of points making up the crescent
|
|
14699
|
+
* @memberof DrawUtilities */
|
|
14700
|
+
function getCrescentPoints(pos, size=1, percent=0, angle=0, invert=false, sides=glCircleSides)
|
|
14701
|
+
{
|
|
14702
|
+
ASSERT(isVector2(pos), 'pos must be a vec2');
|
|
14703
|
+
ASSERT(isNumber(size) && isNumber(percent), 'size and percent must be numbers');
|
|
14704
|
+
|
|
14705
|
+
// map phase to a signed terminator curve: -1 new, 0 half, 1 full
|
|
14706
|
+
let p = mod(percent*4, 4); // quarter phase 0..4
|
|
14707
|
+
if (p >= 2) // second half of cycle flips orientation
|
|
14708
|
+
angle += PI;
|
|
14709
|
+
p = p <= 2 ? p-1 : 3-p;
|
|
14710
|
+
if (invert) // flip the illuminated side
|
|
14711
|
+
{
|
|
14712
|
+
p = -p;
|
|
14713
|
+
angle += PI;
|
|
14714
|
+
}
|
|
14715
|
+
|
|
14716
|
+
// build the crescent: outer semicircle, then inner half-ellipse traced back
|
|
14717
|
+
const points = [];
|
|
14718
|
+
const segs = max(3, sides>>1);
|
|
14719
|
+
const radius = size/2;
|
|
14720
|
+
for (let i=0; i<=segs; i++)
|
|
14721
|
+
{
|
|
14722
|
+
const t = i/segs*PI;
|
|
14723
|
+
points.push(vec2(radius*cos(t), radius*sin(t)).rotate(angle).add(pos));
|
|
14724
|
+
}
|
|
14725
|
+
for (let i=segs; i>=0; i--)
|
|
14726
|
+
{
|
|
14727
|
+
const t = i/segs*PI;
|
|
14728
|
+
points.push(vec2(radius*cos(t), -radius*p*sin(t)).rotate(angle).add(pos));
|
|
14729
|
+
}
|
|
14730
|
+
return points;
|
|
13828
14731
|
}
|
|
13829
14732
|
/**
|
|
13830
14733
|
* LittleJS Tween System Plugin
|
|
@@ -15223,12 +16126,18 @@ export
|
|
|
15223
16126
|
inputWASDEmulateDirection,
|
|
15224
16127
|
touchInputEnable,
|
|
15225
16128
|
touchGamepadEnable,
|
|
16129
|
+
touchGamepadPassthrough,
|
|
15226
16130
|
touchGamepadCenterButtonSize,
|
|
15227
16131
|
touchGamepadButtonCount,
|
|
16132
|
+
touchGamepadLeftStick,
|
|
16133
|
+
touchGamepadLeftButtonCount,
|
|
16134
|
+
touchGamepadRightStick,
|
|
15228
16135
|
touchGamepadAnalog,
|
|
16136
|
+
touchGamepadFloating,
|
|
15229
16137
|
touchGamepadSize,
|
|
15230
16138
|
touchGamepadAlpha,
|
|
15231
16139
|
touchGamepadDisplayTime,
|
|
16140
|
+
touchGamepadVibration,
|
|
15232
16141
|
vibrateEnable,
|
|
15233
16142
|
soundEnable,
|
|
15234
16143
|
soundVolume,
|
|
@@ -15271,12 +16180,18 @@ export
|
|
|
15271
16180
|
setGamepadDirectionEmulateStick,
|
|
15272
16181
|
setInputWASDEmulateDirection,
|
|
15273
16182
|
setTouchGamepadEnable,
|
|
16183
|
+
setTouchGamepadPassthrough,
|
|
15274
16184
|
setTouchGamepadCenterButtonSize,
|
|
15275
16185
|
setTouchGamepadButtonCount,
|
|
16186
|
+
setTouchGamepadLeftStick,
|
|
16187
|
+
setTouchGamepadLeftButtonCount,
|
|
16188
|
+
setTouchGamepadRightStick,
|
|
15276
16189
|
setTouchGamepadAnalog,
|
|
16190
|
+
setTouchGamepadFloating,
|
|
15277
16191
|
setTouchGamepadSize,
|
|
15278
16192
|
setTouchGamepadAlpha,
|
|
15279
16193
|
setTouchGamepadDisplayTime,
|
|
16194
|
+
setTouchGamepadVibration,
|
|
15280
16195
|
setVibrateEnable,
|
|
15281
16196
|
setSoundEnable,
|
|
15282
16197
|
setSoundVolume,
|
|
@@ -15519,6 +16434,7 @@ export
|
|
|
15519
16434
|
medalDisplaySize,
|
|
15520
16435
|
medalsInit,
|
|
15521
16436
|
medalsForEach,
|
|
16437
|
+
medalsReset,
|
|
15522
16438
|
setMedalDisplayTime,
|
|
15523
16439
|
setMedalDisplaySlideTime,
|
|
15524
16440
|
setMedalDisplaySize,
|
|
@@ -15534,6 +16450,11 @@ export
|
|
|
15534
16450
|
postProcess,
|
|
15535
16451
|
PostProcessPlugin,
|
|
15536
16452
|
|
|
16453
|
+
// Light System
|
|
16454
|
+
lightSystem,
|
|
16455
|
+
LightSystemPlugin,
|
|
16456
|
+
Light,
|
|
16457
|
+
|
|
15537
16458
|
// ZzFXMusic
|
|
15538
16459
|
ZzFXMusic,
|
|
15539
16460
|
zzfxM,
|
|
@@ -15583,6 +16504,8 @@ export
|
|
|
15583
16504
|
drawNineSliceScreen,
|
|
15584
16505
|
drawThreeSlice,
|
|
15585
16506
|
drawThreeSliceScreen,
|
|
16507
|
+
drawCrescent,
|
|
16508
|
+
getCrescentPoints,
|
|
15586
16509
|
|
|
15587
16510
|
// Tween System
|
|
15588
16511
|
Tween,
|