littlejsengine 1.18.15 → 1.18.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.18.15';
38
+ const engineVersion = '1.18.18';
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
  ///////////////////////////////////////////////////////////////////////////////
@@ -2222,10 +2233,25 @@ class Color
2222
2233
  * @return {Color} */
2223
2234
  setFrom(c) { return this.set(c.r, c.g, c.b, c.a); }
2224
2235
 
2236
+ /** Sets the alpha of this color and returns self
2237
+ * @param {number} [a] - alpha
2238
+ * @return {Color} */
2239
+ setAlpha(a=1)
2240
+ {
2241
+ this.a = a;
2242
+ ASSERT_COLOR_VALID(this);
2243
+ return this;
2244
+ }
2245
+
2225
2246
  /** Returns a new color that is a copy of this
2226
2247
  * @return {Color} */
2227
2248
  copy() { return new Color(this.r, this.g, this.b, this.a); }
2228
2249
 
2250
+ /** Returns a copy of this color with the alpha set
2251
+ * @param {number} [a] - alpha
2252
+ * @return {Color} */
2253
+ withAlpha(a=1) { return new Color(this.r, this.g, this.b, a); }
2254
+
2229
2255
  /** Returns a copy of this color plus the color passed in
2230
2256
  * @param {Color} c - other color
2231
2257
  * @return {Color} */
@@ -2820,6 +2846,8 @@ let canvasFixedSize = vec2();
2820
2846
  let canvasPixelated = false;
2821
2847
 
2822
2848
  /** Disables texture filtering for crisper pixel art
2849
+ * - Leave true for pixel art so sprites stay sharp when scaled (uses NEAREST filtering)
2850
+ * - Set false for smooth/high-resolution art to enable bilinear filtering and mipmaps
2823
2851
  * @type {boolean}
2824
2852
  * @default
2825
2853
  * @memberof Settings */
@@ -2975,35 +3003,78 @@ let touchInputEnable = true;
2975
3003
  * - Supports left analog stick, 4 face buttons and start button (button 9)
2976
3004
  * - setTouchGamepadButtonCount(1) to use face buttons as right analog stick
2977
3005
  * - Analog stick buttons 10 and 11 are also activated when virtual sticks are touched
2978
-
3006
+ * - Rendered as a full-viewport HTML/SVG overlay, so controls may sit outside the game canvas
2979
3007
  * @type {boolean}
2980
3008
  * @default
2981
3009
  * @memberof Settings */
2982
3010
  let touchGamepadEnable = false;
2983
3011
 
2984
- /** True if touch gamepad should have start button in the center
2985
- * - Prevents activating within 2*touchGamepadSize of the virtual stick or face buttons
2986
- * (one radius for the visible control + one radius of buffer beyond its edge)
3012
+ /** True if touches outside the gamepad controls should still drive mouse/touch input
3013
+ * - When false (the default), enabling the touch gamepad suppresses touch-to-mouse input entirely
3014
+ * - Set true to also pass touches outside the controls through to the game as mouse/touch input
3015
+ * - Touches on the gamepad controls never drive the mouse regardless of this setting
3016
+ * @type {boolean}
3017
+ * @default
3018
+ * @memberof Settings */
3019
+ let touchGamepadPassthrough = false;
3020
+
3021
+ /** Size of center button if touch gamepad should have start button in the center
3022
+ * - Prevents activating when pressed near virtual stick or face buttons
2987
3023
  * - When the game is paused, any touch will press the button
2988
- * - Set size to enable the center button
3024
+ * - Measured in viewport CSS pixels
2989
3025
  * @type {number}
2990
3026
  * @default
2991
3027
  * @memberof Settings */
2992
- let touchGamepadCenterButtonSize = 300;
3028
+ let touchGamepadCenterButtonSize = 0;
2993
3029
 
2994
- /** Number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
3030
+ /** Number of buttons on the right side of the touch gamepad (0-4), using gamepad buttons 0-3
3031
+ * - A count of 1 is a single large button (the size of a stick)
3032
+ * - Ignored when touchGamepadRightStick is set (the right side is a stick instead)
2995
3033
  * @type {number}
2996
3034
  * @default
2997
3035
  * @memberof Settings */
2998
3036
  let touchGamepadButtonCount = 4;
2999
3037
 
3038
+ /** True if the touch gamepad should have a left analog stick (or dpad)
3039
+ * - When false, the left side is face buttons (touchGamepadLeftButtonCount) or nothing
3040
+ * @type {boolean}
3041
+ * @default
3042
+ * @memberof Settings */
3043
+ let touchGamepadLeftStick = true;
3044
+
3045
+ /** Number of buttons on the left side of the touch gamepad (0-4), using gamepad buttons 4-7
3046
+ * - Only used when touchGamepadLeftStick is false (otherwise the left side is a stick)
3047
+ * - A count of 1 is a single large button (the size of a stick)
3048
+ * @type {number}
3049
+ * @default
3050
+ * @memberof Settings */
3051
+ let touchGamepadLeftButtonCount = 0;
3052
+
3053
+ /** True if the touch gamepad right side should be an analog stick (or dpad) instead of face buttons
3054
+ * - When set, touchGamepadButtonCount is ignored and the right side is a stick
3055
+ * - Uses an analog stick when touchGamepadAnalog is true, otherwise an 8 way dpad
3056
+ * @type {boolean}
3057
+ * @default
3058
+ * @memberof Settings */
3059
+ let touchGamepadRightStick = false;
3060
+
3000
3061
  /** True if touch gamepad should be analog stick or false to use if 8 way dpad
3001
3062
  * @type {boolean}
3002
3063
  * @default
3003
3064
  * @memberof Settings */
3004
3065
  let touchGamepadAnalog = true;
3005
3066
 
3006
- /** Size of virtual gamepad for touch devices in pixels
3067
+ /** True if touch gamepad directional controls should float to where you press
3068
+ * - Only affects analog sticks and dpads, not face buttons
3069
+ * - Directional controls re-anchor to where you press within the bottom ~60% of their screen half; the top ~40% passes through to the game
3070
+ * - The right side floats only when it acts as the right analog stick (touchGamepadRightStick is set)
3071
+ * - A center button (touchGamepadCenterButtonSize) still works since it ignores touches near the sticks
3072
+ * @type {boolean}
3073
+ * @default
3074
+ * @memberof Settings */
3075
+ let touchGamepadFloating = false;
3076
+
3077
+ /** Size of virtual gamepad for touch devices in viewport CSS pixels
3007
3078
  * @type {number}
3008
3079
  * @default
3009
3080
  * @memberof Settings */
@@ -3021,6 +3092,13 @@ let touchGamepadAlpha = .3;
3021
3092
  * @memberof Settings */
3022
3093
  let touchGamepadDisplayTime = 3;
3023
3094
 
3095
+ /** Duration in ms to vibrate when a touch gamepad face button or start button is pressed
3096
+ * - Set to 0 to disable, also requires vibrateEnable and hardware support (ignored on iOS)
3097
+ * @type {number}
3098
+ * @default
3099
+ * @memberof Settings */
3100
+ let touchGamepadVibration = 0;
3101
+
3024
3102
  /** Allow vibration hardware if it exists
3025
3103
  * @type {boolean}
3026
3104
  * @default
@@ -3123,6 +3201,7 @@ function setCanvasPixelated(pixelated)
3123
3201
  }
3124
3202
 
3125
3203
  /** Disables texture filtering for crisper pixel art
3204
+ * - Leave true for pixel art; set false for smooth/high-resolution art
3126
3205
  * @param {boolean} pixelated
3127
3206
  * @memberof Settings */
3128
3207
  function setTilesPixelated(pixelated) { tilesPixelated = pixelated; }
@@ -3253,6 +3332,11 @@ function setTouchInputEnable(enable) { touchInputEnable = enable; }
3253
3332
  * @memberof Settings */
3254
3333
  function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
3255
3334
 
3335
+ /** Set if touches outside the gamepad controls should still drive mouse/touch input
3336
+ * @param {boolean} passthrough
3337
+ * @memberof Settings */
3338
+ function setTouchGamepadPassthrough(passthrough) { touchGamepadPassthrough = passthrough; }
3339
+
3256
3340
  /** Set if touch gamepad should have start button in the center
3257
3341
  * - Set size to enable the center button
3258
3342
  * - When the game is paused, any touch will press the button
@@ -3260,16 +3344,57 @@ function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
3260
3344
  * @memberof Settings */
3261
3345
  function setTouchGamepadCenterButtonSize(size) { touchGamepadCenterButtonSize = size; }
3262
3346
 
3263
- /** Set number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
3347
+ /** Set number of buttons on the right side of the touch gamepad (0-4, gamepad buttons 0-3)
3348
+ * @param {number} count
3349
+ * @memberof Settings */
3350
+ function setTouchGamepadButtonCount(count)
3351
+ {
3352
+ touchGamepadButtonCount = count;
3353
+ if (count > 0)
3354
+ touchGamepadRightStick = false;
3355
+ }
3356
+
3357
+ /** Set if the touch gamepad should have a left analog stick (or dpad)
3358
+ * @param {boolean} enable
3359
+ * @memberof Settings */
3360
+ function setTouchGamepadLeftStick(enable)
3361
+ {
3362
+ touchGamepadLeftStick = enable;
3363
+ if (enable)
3364
+ touchGamepadLeftButtonCount = 0;
3365
+ }
3366
+
3367
+ /** Set number of buttons on the left side of the touch gamepad (0-4, gamepad buttons 4-7)
3368
+ * - Only used when touchGamepadLeftStick is false
3264
3369
  * @param {number} count
3265
3370
  * @memberof Settings */
3266
- function setTouchGamepadButtonCount(count) { touchGamepadButtonCount = count; }
3371
+ function setTouchGamepadLeftButtonCount(count)
3372
+ {
3373
+ touchGamepadLeftButtonCount = count;
3374
+ if (count > 0)
3375
+ touchGamepadLeftStick = false;
3376
+ }
3377
+
3378
+ /** Set if the touch gamepad right side is an analog stick (or dpad) instead of face buttons
3379
+ * @param {boolean} rightStick
3380
+ * @memberof Settings */
3381
+ function setTouchGamepadRightStick(rightStick)
3382
+ {
3383
+ touchGamepadRightStick = rightStick;
3384
+ if (rightStick)
3385
+ touchGamepadButtonCount = 0;
3386
+ }
3267
3387
 
3268
3388
  /** Set if touch gamepad should be analog stick or 8 way dpad
3269
3389
  * @param {boolean} analog
3270
3390
  * @memberof Settings */
3271
3391
  function setTouchGamepadAnalog(analog) { touchGamepadAnalog = analog; }
3272
3392
 
3393
+ /** Set if touch gamepad directional controls should float to where you press
3394
+ * @param {boolean} floating
3395
+ * @memberof Settings */
3396
+ function setTouchGamepadFloating(floating) { touchGamepadFloating = floating; }
3397
+
3273
3398
  /** Set size of virtual gamepad for touch devices in pixels
3274
3399
  * @param {number} size
3275
3400
  * @memberof Settings */
@@ -3285,6 +3410,11 @@ function setTouchGamepadAlpha(alpha) { touchGamepadAlpha = alpha; }
3285
3410
  * @memberof Settings */
3286
3411
  function setTouchGamepadDisplayTime(time) { touchGamepadDisplayTime = time; }
3287
3412
 
3413
+ /** Set duration in ms to vibrate when a touch gamepad face or start button is pressed (0 disables)
3414
+ * @param {number} ms
3415
+ * @memberof Settings */
3416
+ function setTouchGamepadVibration(ms) { touchGamepadVibration = ms; }
3417
+
3288
3418
  /** Set to allow vibration hardware if it exists
3289
3419
  * @param {boolean} enable
3290
3420
  * @memberof Settings */
@@ -3463,10 +3593,10 @@ class EngineObject
3463
3593
  if (pa)
3464
3594
  {
3465
3595
  const c = cos(-pa), s = sin(-pa);
3466
- this.pos = new Vector2(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
3596
+ this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
3467
3597
  }
3468
3598
  else
3469
- this.pos = new Vector2(lx + pp.x, ly + pp.y);
3599
+ this.pos.set(lx + pp.x, ly + pp.y);
3470
3600
  this.angle = mirror*this.localAngle + pa;
3471
3601
  }
3472
3602
 
@@ -3698,6 +3828,9 @@ class EngineObject
3698
3828
  drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor);
3699
3829
  }
3700
3830
 
3831
+ /** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
3832
+ renderLight() {}
3833
+
3701
3834
  /** Destroy this object, destroy its children, detach its parent, and mark it for removal
3702
3835
  * @param {boolean} [immediate] - should attached effects be allowed to die off? */
3703
3836
  destroy(immediate=false)
@@ -4202,11 +4335,12 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
4202
4335
  // normal canvas 2D rendering method (slower)
4203
4336
  ++drawCount;
4204
4337
  ++primitiveCount;
4205
- size = new Vector2(size.x, -size.y); // flip upside down sprites
4206
4338
  drawCanvas2D(pos, size, angle, mirror, (context)=>
4207
4339
  {
4208
4340
  if (textureInfo)
4209
4341
  {
4342
+ // un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
4343
+ context.scale(1, -1);
4210
4344
  // calculate uvs and render
4211
4345
  const x = tileInfo.pos.x, y = tileInfo.pos.y;
4212
4346
  const w = tileInfo.size.x, h = tileInfo.size.y;
@@ -4214,7 +4348,7 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
4214
4348
  }
4215
4349
  else
4216
4350
  {
4217
- // if no tile info, use untextured rect
4351
+ // if no tile info, use untextured rect (Y-symmetric, no compensation needed)
4218
4352
  const c = additiveColor ? color.add(additiveColor) : color;
4219
4353
  context.fillStyle = c.toString();
4220
4354
  context.fillRect(-.5, -.5, 1, 1);
@@ -4288,11 +4422,10 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, an
4288
4422
  // normal canvas 2D rendering method (slower)
4289
4423
  ++drawCount;
4290
4424
  ++primitiveCount;
4291
- size = new Vector2(size.x, -size.y); // fix upside down sprites
4292
4425
  drawCanvas2D(pos, size, angle, false, (context)=>
4293
4426
  {
4294
- // if no tile info, use untextured rect
4295
- const gradient = context.createLinearGradient(0, -.5, 0, .5);
4427
+ // gradient endpoints are flipped to match the Y flip inside drawCanvas2D
4428
+ const gradient = context.createLinearGradient(0, .5, 0, -.5);
4296
4429
  gradient.addColorStop(0, colorTop.toString());
4297
4430
  gradient.addColorStop(1, colorBottom.toString());
4298
4431
  context.fillStyle = gradient;
@@ -4711,7 +4844,10 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
4711
4844
  * @memberof Draw
4712
4845
  */
4713
4846
 
4714
- /** Draw directly to a 2d canvas context in world space
4847
+ /** Draw directly to a 2d canvas context in world space.
4848
+ * The Y axis is flipped so world-Y-up coordinates render right-side up
4849
+ * (matches the WebGL path). Callers whose drawing depends on Y direction
4850
+ * (e.g. linear gradients) should flip their own Y endpoints accordingly.
4715
4851
  * @param {Vector2} pos
4716
4852
  * @param {Vector2} size
4717
4853
  * @param {number} angle
@@ -4957,6 +5093,62 @@ function screenToWorldTransform(screenPos, screenSize, screenAngle=0)
4957
5093
  * @memberof Draw */
4958
5094
  function getCameraSize() { return mainCanvasSize.scale(1/cameraScale); }
4959
5095
 
5096
+ /** Fit the camera to a rectangle in world space by setting cameraPos and cameraScale
5097
+ * - worldMargin pads the content rectangle in world units, so the gap scales with the content on resize
5098
+ * - screenInset reserves space in screen pixels on each viewport edge (for example a HUD band) and
5099
+ * re-centers the content away from that edge, so the reserved band stays a fixed pixel size on resize
5100
+ * - worldMargin and screenInset may each be a number for all sides, a Vector2 (x=left/right, y=top/bottom),
5101
+ * or an object with any of {top, right, bottom, left}
5102
+ * @param {Vector2} center - Center of the rectangle in world space
5103
+ * @param {Vector2} size - Size of the rectangle in world space
5104
+ * @param {number|Vector2|Object} [worldMargin] - World space padding added around the content rectangle
5105
+ * @param {number|Vector2|Object} [screenInset] - Screen space padding in pixels reserved on each viewport edge
5106
+ * @return {number} - The new camera scale
5107
+ * @memberof Draw */
5108
+ function cameraFit(center, size, worldMargin, screenInset)
5109
+ {
5110
+ ASSERT(isVector2(center), 'center must be a vec2');
5111
+ ASSERT(isVector2(size), 'size must be a vec2');
5112
+
5113
+ // pad the content
5114
+ const margin = padSides(worldMargin);
5115
+ const inset = padSides(screenInset);
5116
+ const worldW = size.x + margin.left + margin.right;
5117
+ const worldH = size.y + margin.top + margin.bottom;
5118
+ const viewW = mainCanvasSize.x - inset.left - inset.right;
5119
+ const viewH = mainCanvasSize.y - inset.top - inset.bottom;
5120
+
5121
+ // bail on a degenerate rect or viewport rather than NaN the camera
5122
+ if (!(worldW > 0 && worldH > 0 && viewW > 0 && viewH > 0))
5123
+ return cameraScale;
5124
+
5125
+ // scale to fit the padded content
5126
+ cameraScale = min(viewW / worldW, viewH / worldH);
5127
+
5128
+ // calculate offset vectors
5129
+ const marginVector = vec2(margin.right - margin.left, margin.top - margin.bottom).scale(.5);
5130
+ const insetVector = vec2(inset.right - inset.left, inset.top - inset.bottom).scale(.5 / cameraScale);
5131
+
5132
+ // apply the offsets and return camera scale
5133
+ cameraPos = center.add(marginVector).add(insetVector);
5134
+ return cameraScale;
5135
+
5136
+ function padSides(p)
5137
+ {
5138
+ // normalize a padding option to {top, right, bottom, left}
5139
+ if (p === undefined || isNumber(p))
5140
+ p = vec2(p);
5141
+ if (isVector2(p))
5142
+ return { top: p.y, right: p.x, bottom: p.y, left: p.x };
5143
+ return {
5144
+ top: p.top || 0,
5145
+ right: p.right || 0,
5146
+ bottom: p.bottom || 0,
5147
+ left: p.left || 0,
5148
+ };
5149
+ }
5150
+ }
5151
+
4960
5152
  /** Check if a box, point, or circle is on screen with a circle test
4961
5153
  * If size is a Vector2, uses the length as diameter
4962
5154
  * This can be used to cull offscreen objects from render or update
@@ -4995,14 +5187,13 @@ function isOnScreen(pos, size=0)
4995
5187
  y + size > -h && y - size < h;
4996
5188
  }
4997
5189
 
4998
- /** Enable normal or additive blend mode
5190
+ /** Enable additive blending
4999
5191
  * @param {boolean} [additive]
5000
- * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
5001
5192
  * @memberof Draw */
5002
- function setBlendMode(additive=false, context=drawContext)
5193
+ function setAdditiveBlendMode(additive=true)
5003
5194
  {
5004
5195
  glAdditive = additive;
5005
- context.globalCompositeOperation = additive ? 'lighter' : 'source-over';
5196
+ drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
5006
5197
  }
5007
5198
 
5008
5199
  /** Combines LittleJS canvases onto the main canvas
@@ -5328,11 +5519,30 @@ let mouseWheel = 0;
5328
5519
  * @memberof Input */
5329
5520
  let mouseInWindow = true;
5330
5521
 
5331
- /** Returns true if user is using gamepad (has more recently pressed a gamepad button)
5522
+ /** True if a gamepad is the most recently used input device.
5523
+ * Equivalent to usingGamepadInput(); derived from lastInputDevice each frame.
5332
5524
  * @type {boolean}
5333
5525
  * @memberof Input */
5334
5526
  let isUsingGamepad = false;
5335
5527
 
5528
+ /** The most recently used input device: 'mouse' | 'keyboard' | 'gamepad'.
5529
+ * Sticky: it holds its value while every device is idle, so a mouse-follow
5530
+ * control (e.g. paddle = mousePos) won't snap back the instant the stick/keys
5531
+ * are released. With several devices in play at once (e.g. keyboard to move +
5532
+ * mouse to aim) it tracks whichever was touched last each frame, so it may
5533
+ * alternate — that's intended; use it to pick which control drives a shared
5534
+ * action. Updated every frame by inputUpdate().
5535
+ * @type {string}
5536
+ * @memberof Input */
5537
+ let lastInputDevice = 'mouse';
5538
+
5539
+ /** Screen-pixel mouse movement per frame that counts as "using the mouse"
5540
+ * (so sub-pixel hand jitter doesn't steal focus from the keyboard/gamepad).
5541
+ * @type {number}
5542
+ * @default
5543
+ * @memberof Input */
5544
+ let inputMouseMoveThreshold = 6;
5545
+
5336
5546
  /** Prevents input continuing to the default browser handling (true by default)
5337
5547
  * @type {boolean}
5338
5548
  * @memberof Input */
@@ -5353,6 +5563,18 @@ const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
5353
5563
  * @memberof Input */
5354
5564
  function setInputPreventDefault(preventDefault=true) { inputPreventDefault = preventDefault; }
5355
5565
 
5566
+ /** Set the screen-pixel mouse movement per frame that counts as using the mouse
5567
+ * @param {number} threshold
5568
+ * @memberof Input */
5569
+ function setInputMouseMoveThreshold(threshold) { inputMouseMoveThreshold = threshold; }
5570
+
5571
+ /** @return {boolean} - Is the mouse the most recently used input device? @memberof Input */
5572
+ function usingMouseInput() { return lastInputDevice === 'mouse'; }
5573
+ /** @return {boolean} - Is the keyboard the most recently used input device? @memberof Input */
5574
+ function usingKeyboardInput() { return lastInputDevice === 'keyboard'; }
5575
+ /** @return {boolean} - Is a gamepad the most recently used input device? @memberof Input */
5576
+ function usingGamepadInput() { return lastInputDevice === 'gamepad'; }
5577
+
5356
5578
  /** Clears an input key state
5357
5579
  * @param {string|number} key
5358
5580
  * @param {number} [device]
@@ -5375,6 +5597,7 @@ function inputClear()
5375
5597
  inputData[0] = [];
5376
5598
  touchGamepadButtons.length = 0;
5377
5599
  touchGamepadSticks.length = 0;
5600
+ touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor
5378
5601
  gamepadStickData.length = 0;
5379
5602
  gamepadDpadData.length = 0;
5380
5603
  }
@@ -5617,6 +5840,14 @@ const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
5617
5840
 
5618
5841
  // touch gamepad internal variables
5619
5842
  const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
5843
+ // floating stick anchors (stage-local CSS pixels) and owning pointer ids, indexed by stick (0=left, 1=right)
5844
+ const touchGamepadStickAnchors = [], touchGamepadStickPointerId = [];
5845
+ // pointerId -> control role ('stick0', 'stick1', 'face<n>', or 'start')
5846
+ const touchGamepadPointerRole = new Map();
5847
+ // overlay DOM elements (created lazily on touch devices) and cached SVG shapes
5848
+ let touchGamepadOverlay, touchGamepadStage, touchGamepadSvg, touchGamepadSvgEls;
5849
+ let touchGamepadSideZones = [], touchGamepadZoneC;
5850
+ let touchGamepadNeedRelayout = true, touchGamepadLastLayout;
5620
5851
 
5621
5852
  ///////////////////////////////////////////////////////////////////////////////
5622
5853
  // Input system functions used by engine
@@ -5644,7 +5875,6 @@ function inputInit()
5644
5875
  {
5645
5876
  if (!e.repeat)
5646
5877
  {
5647
- isUsingGamepad = false;
5648
5878
  inputData[0][e.code] = 3;
5649
5879
  if (inputWASDEmulateDirection)
5650
5880
  inputData[0][remapKey(e.code)] = 3;
@@ -5703,7 +5933,6 @@ function inputInit()
5703
5933
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
5704
5934
  audioContext.resume();
5705
5935
 
5706
- isUsingGamepad = false;
5707
5936
  inputData[0][e.button] = 3;
5708
5937
 
5709
5938
  const mousePosScreenLast = mousePosScreen;
@@ -5741,7 +5970,15 @@ function inputInit()
5741
5970
  e.preventDefault(); // prevent page scrolling
5742
5971
  }
5743
5972
  function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
5744
- function onBlur() { inputClear(); } // reset input when focus is lost
5973
+ function onBlur()
5974
+ {
5975
+ inputClear();
5976
+ // release any held virtual gamepad controls so they don't stick
5977
+ touchGamepadPointerRole.clear();
5978
+ touchGamepadButtons.length = 0;
5979
+ touchGamepadSticks.length = 0;
5980
+ touchGamepadStickPointerId.length = 0;
5981
+ }
5745
5982
 
5746
5983
  // enable touch input mouse passthrough
5747
5984
  function touchInputInit()
@@ -5757,36 +5994,43 @@ function inputInit()
5757
5994
  {
5758
5995
  if (!touchInputEnable) return;
5759
5996
 
5760
- // route touch to gamepad
5761
- if (touchGamepadEnable)
5762
- handleTouchGamepad(e);
5763
-
5764
5997
  // fix stalled audio requiring user interaction
5765
5998
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
5766
5999
  audioContext.resume();
5767
6000
 
5768
- // check if touching and pass to mouse events
5769
- const touching = e.touches.length;
5770
- const button = 0; // all touches are left mouse button
5771
- if (touching)
6001
+ // when the touch gamepad is enabled it owns touch input: suppress the
6002
+ // touch->mouse passthrough entirely unless touchGamepadPassthrough is set
6003
+ // (its own zones drive gameplay via pointer events)
6004
+ if (!touchGamepadEnable || touchGamepadPassthrough)
5772
6005
  {
5773
- // set event pos and pass it along
5774
- const pos = vec2(e.touches[0].clientX, e.touches[0].clientY);
5775
- const mousePosScreenLast = mousePosScreen;
5776
- mousePosScreen = mouseEventToScreen(pos);
5777
- if (wasTouching)
6006
+ // touches that landed on a virtual gamepad zone are owned by the gamepad
6007
+ // (handled by its own pointer listeners) and must not drive the game mouse
6008
+ const isGamepadTouch = (t)=>
6009
+ touchGamepadSideZones.includes(t.target) || t.target === touchGamepadZoneC;
6010
+ const gameTouches = [];
6011
+ for (const t of e.touches)
6012
+ if (!isGamepadTouch(t)) gameTouches.push(t);
6013
+
6014
+ // check if touching and pass to mouse events
6015
+ const touching = gameTouches.length;
6016
+ const button = 0; // all touches are left mouse button
6017
+ if (touching)
5778
6018
  {
5779
- mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
5780
- isUsingGamepad = touchGamepadEnable;
6019
+ // set event pos and pass it along
6020
+ const pos = vec2(gameTouches[0].clientX, gameTouches[0].clientY);
6021
+ const mousePosScreenLast = mousePosScreen;
6022
+ mousePosScreen = mouseEventToScreen(pos);
6023
+ if (wasTouching)
6024
+ mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
6025
+ else
6026
+ inputData[0][button] = 3;
5781
6027
  }
5782
- else
5783
- inputData[0][button] = 3;
5784
- }
5785
- else if (wasTouching)
5786
- inputData[0][button] = inputData[0][button] & 2 | 4;
6028
+ else if (wasTouching)
6029
+ inputData[0][button] = inputData[0][button] & 2 | 4;
5787
6030
 
5788
- // set was touching
5789
- wasTouching = touching;
6031
+ // set was touching
6032
+ wasTouching = touching;
6033
+ }
5790
6034
 
5791
6035
  // prevent default handling like copy, magnifier lens, and scrolling
5792
6036
  if (inputPreventDefault && e.cancelable && document.hasFocus())
@@ -5796,83 +6040,6 @@ function inputInit()
5796
6040
  return true;
5797
6041
  }
5798
6042
 
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
6043
  }
5877
6044
 
5878
6045
  // convert a mouse or touch event position to screen space
@@ -5897,9 +6064,48 @@ function inputUpdate()
5897
6064
  mousePos = screenToWorld(mousePosScreen);
5898
6065
  mouseDelta = screenToWorldDelta(mouseDeltaScreen);
5899
6066
 
6067
+ // build the touch gamepad overlay lazily once enabled on a touch device
6068
+ touchGamepadInit();
6069
+
5900
6070
  // update gamepads if enabled
5901
6071
  gamepadsUpdate();
5902
-
6072
+
6073
+ // update most recently used input device
6074
+ updateLastInputDevice();
6075
+
6076
+ function updateLastInputDevice()
6077
+ {
6078
+ // mouse: any button held or moved
6079
+ const mouseActive = mouseIsDown(0) || mouseIsDown(1) || mouseIsDown(2) || mouseDeltaScreen.length() > inputMouseMoveThreshold;
6080
+
6081
+ // gamepad: any button held or stick moved
6082
+ let gamepadActive = false;
6083
+ for (let s = gamepadStickCount(); s-- && !gamepadActive;)
6084
+ gamepadActive = gamepadStick(s).lengthSquared() > .04;
6085
+ for (let b = 17; b-- && !gamepadActive;)
6086
+ gamepadActive = gamepadIsDown(b);
6087
+
6088
+ // keyboard: any non-mouse key down
6089
+ let keyboardActive = false;
6090
+ for (const k in inputData[0])
6091
+ if (isNaN(+k) && (inputData[0][k] & 1))
6092
+ {
6093
+ keyboardActive = true;
6094
+ break;
6095
+ }
6096
+
6097
+ // update the last input
6098
+ if (gamepadActive)
6099
+ lastInputDevice = 'gamepad';
6100
+ else if (mouseActive)
6101
+ lastInputDevice = 'mouse';
6102
+ else if (keyboardActive)
6103
+ lastInputDevice = 'keyboard';
6104
+
6105
+ // set flag if gamepad is last device
6106
+ isUsingGamepad = lastInputDevice === 'gamepad';
6107
+ }
6108
+
5903
6109
  // gamepads are updated by engine every frame automatically
5904
6110
  function gamepadsUpdate()
5905
6111
  {
@@ -5915,22 +6121,11 @@ function inputUpdate()
5915
6121
  // update touch gamepad if enabled
5916
6122
  if (touchGamepadEnable && isTouchDevice)
5917
6123
  {
5918
- if (debugGamepads)
5919
- {
5920
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5921
- const buttonCenter = touchGamepadButtonCenter();
5922
- const startCenter = mainCanvasSize.scale(.5);
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
- }
6124
+ // a side is either a stick or buttons - setting both is ambiguous
6125
+ ASSERT(!touchGamepadLeftStick || !touchGamepadLeftButtonCount,
6126
+ 'set touchGamepadLeftStick or touchGamepadLeftButtonCount, not both');
6127
+ ASSERT(!touchGamepadRightStick || !touchGamepadButtonCount,
6128
+ 'set touchGamepadRightStick or touchGamepadButtonCount, not both');
5934
6129
 
5935
6130
  if (!touchGamepadTimer.isSet()) return;
5936
6131
 
@@ -5938,23 +6133,24 @@ function inputUpdate()
5938
6133
  gamepadPrimary = 0; // touch gamepad uses index 0
5939
6134
  const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
5940
6135
  const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
5941
- sticks[0] = vec2();
6136
+ sticks.length = 0; // only report sticks that are enabled
5942
6137
  dpad.set();
5943
- const leftTouchStick = touchGamepadSticks[0] ?? vec2();
5944
- if (touchGamepadAnalog)
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)
6138
+ // read each side's directional stick (analog, or quantized to an 8 way dpad)
6139
+ for (let side = 0; side < 2; side++)
5955
6140
  {
5956
- const rightTouchStick = touchGamepadSticks[1] ?? vec2();
5957
- sticks[1] = applyDeadZones(rightTouchStick);
6141
+ if (!touchGamepadSideStick(side)) continue;
6142
+ const out = touchGamepadStickOut(side);
6143
+ sticks[out] = vec2();
6144
+ const touchStick = touchGamepadSticks[side] ?? vec2();
6145
+ if (touchGamepadAnalog)
6146
+ sticks[out] = applyDeadZones(touchStick);
6147
+ else if (touchStick.lengthSquared() > .3)
6148
+ {
6149
+ const x = clamp(round(touchStick.x), -1, 1);
6150
+ const y = clamp(round(touchStick.y), -1, 1);
6151
+ sticks[out] = vec2(x, -y).clampLength(); // clamp to circle
6152
+ if (!out) dpad.set(x, -y); // the primary (stick 0) also drives the dpad vector
6153
+ }
5958
6154
  }
5959
6155
 
5960
6156
  // read virtual gamepad buttons
@@ -5963,6 +6159,12 @@ function inputUpdate()
5963
6159
  {
5964
6160
  const wasDown = gamepadIsDown(i,0);
5965
6161
  data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
6162
+
6163
+ // haptic tap when a face button or start button is first pressed (3 = newly down)
6164
+ // skip stick touches (10, 11) so movement doesn't buzz
6165
+ if (touchGamepadVibration && data[i] === 3 &&
6166
+ (i === 9 || touchGamepadIsFaceButton(i)))
6167
+ vibrate(touchGamepadVibration);
5966
6168
  }
5967
6169
 
5968
6170
  // disable normal gamepads when touch gamepad is active
@@ -6026,7 +6228,6 @@ function inputUpdate()
6026
6228
  gamepadHadInput[i] = true;
6027
6229
  if (!gamepadHadInput[gamepadPrimary])
6028
6230
  gamepadPrimary = i;
6029
- isUsingGamepad ||= (gamepadPrimary === i);
6030
6231
  }
6031
6232
 
6032
6233
  if (gamepad.mapping === 'standard')
@@ -6063,80 +6264,476 @@ function inputUpdatePost()
6063
6264
  function inputRender()
6064
6265
  {
6065
6266
  touchGamepadRender();
6267
+ }
6066
6268
 
6067
- function touchGamepadRender()
6068
- {
6069
- if (!touchInputEnable || !isTouchDevice || headlessMode) return;
6070
- if (!touchGamepadEnable || !touchGamepadTimer.isSet() && touchGamepadDisplayTime) return;
6269
+ ///////////////////////////////////////////////////////////////////////////////
6270
+ // Touch gamepad - full-viewport HTML/SVG overlay driven by Pointer Events
6071
6271
 
6072
- // fade off when not touching or paused
6073
- const alpha = touchGamepadDisplayTime ? percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
6074
- if (!alpha || paused) return;
6272
+ const touchGamepadSvgNS = 'http://www.w3.org/2000/svg';
6075
6273
 
6076
- // setup the canvas
6077
- const context = mainContext;
6078
- context.save();
6079
- context.globalAlpha = alpha*touchGamepadAlpha;
6080
- context.strokeStyle = '#fff';
6081
- context.lineWidth = 3;
6274
+ // build the overlay DOM once; no-op if already built, disabled, headless, or non-touch
6275
+ function touchGamepadInit()
6276
+ {
6277
+ if (touchGamepadOverlay || !touchGamepadEnable || !isTouchDevice || headlessMode ||
6278
+ !document.body) // body may not exist yet; retry on a later frame
6279
+ return;
6082
6280
 
6083
- // draw left analog stick
6084
- const leftTouchStick = touchGamepadSticks[0] ?? vec2();
6085
- context.fillStyle = leftTouchStick.lengthSquared() > 0 ? '#fff' : '#000';
6086
- context.beginPath();
6087
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
6088
- if (touchGamepadAnalog)
6281
+ // full-viewport overlay; only the input zones receive pointer events. The
6282
+ // env() padding insets the stage out of notches / home indicators natively.
6283
+ const overlay = touchGamepadOverlay = document.createElement('div');
6284
+ overlay.style.cssText =
6285
+ 'position:fixed;inset:0;z-index:50;pointer-events:none;opacity:0;' +
6286
+ 'touch-action:none;user-select:none;-webkit-user-select:none;' +
6287
+ '-webkit-touch-callout:none;transition:opacity .2s;box-sizing:border-box;' +
6288
+ 'padding:env(safe-area-inset-top) env(safe-area-inset-right) ' +
6289
+ 'env(safe-area-inset-bottom) env(safe-area-inset-left)';
6290
+
6291
+ // stage fills the padded (safe-area) content box; all controls live inside it
6292
+ const stage = touchGamepadStage = document.createElement('div');
6293
+ stage.style.cssText = 'position:relative;width:100%;height:100%;pointer-events:none';
6294
+ overlay.appendChild(stage);
6295
+
6296
+ // svg draws every visual and never blocks input
6297
+ const svg = touchGamepadSvg = document.createElementNS(touchGamepadSvgNS, 'svg');
6298
+ svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;' +
6299
+ 'pointer-events:none;overflow:visible;fill:none;stroke:#fff;stroke-width:3';
6300
+ stage.appendChild(svg);
6301
+
6302
+ // invisible input zones (left stick, right buttons/stick, center start)
6303
+ const makeZone = ()=>
6304
+ {
6305
+ const z = document.createElement('div');
6306
+ z.style.cssText = 'position:absolute;pointer-events:auto;touch-action:none';
6307
+ z.addEventListener('pointerdown', e=> touchGamepadPointerDown(e, z));
6308
+ z.addEventListener('pointermove', e=> touchGamepadPointerMove(e));
6309
+ z.addEventListener('pointerup', e=> touchGamepadPointerUp(e));
6310
+ z.addEventListener('pointercancel', e=> touchGamepadPointerUp(e));
6311
+ stage.appendChild(z);
6312
+ return z;
6313
+ };
6314
+ touchGamepadSideZones[0] = makeZone(); // left
6315
+ touchGamepadSideZones[1] = makeZone(); // right
6316
+ touchGamepadZoneC = makeZone(); // center/start, appended last so it sits above the sides
6317
+
6318
+ addEventListener('resize', ()=> touchGamepadNeedRelayout = true);
6319
+ document.body.appendChild(overlay);
6320
+ touchGamepadNeedRelayout = true;
6321
+ }
6322
+
6323
+ // stage-local size in CSS pixels (excludes safe-area insets)
6324
+ function touchGamepadStageRect() { return touchGamepadStage.getBoundingClientRect(); }
6325
+
6326
+ // per-side touch gamepad config (side 0 = left, 1 = right) - the left and right
6327
+ // sides behave identically, differing only in position and gamepad button indices
6328
+ function touchGamepadSideStick(side)
6329
+ { return side ? touchGamepadRightStick : touchGamepadLeftStick; }
6330
+ function touchGamepadSideButtonCount(side)
6331
+ { return side ? touchGamepadButtonCount : touchGamepadLeftButtonCount; }
6332
+ // gamepad button index a side's buttons start at (right 0-3, left 4-7)
6333
+ function touchGamepadSideButtonBase(side)
6334
+ { return side ? 0 : 4; }
6335
+ // output stick index for a side: the right stick uses stick 0 when there is no left stick
6336
+ function touchGamepadStickOut(side)
6337
+ { return side && touchGamepadLeftStick ? 1 : 0; }
6338
+ // true if the side has any control (a stick or at least one button)
6339
+ function touchGamepadSideHasControl(side)
6340
+ { return touchGamepadSideStick(side) || touchGamepadSideButtonCount(side) > 0; }
6341
+
6342
+ // true if gamepad button index i is an active touch gamepad face/single button
6343
+ function touchGamepadIsFaceButton(i)
6344
+ {
6345
+ for (let side = 0; side < 2; side++)
6346
+ {
6347
+ const base = touchGamepadSideButtonBase(side);
6348
+ if (!touchGamepadSideStick(side) &&
6349
+ i >= base && i < base + touchGamepadSideButtonCount(side))
6350
+ return true;
6351
+ }
6352
+ return false;
6353
+ }
6354
+
6355
+ // center of a side's controls in stage-local CSS pixels (stick rest / button cluster)
6356
+ // returns the floating stick anchor when that side is an active floating stick
6357
+ function touchGamepadSideCenter(side, W, H)
6358
+ {
6359
+ if (touchGamepadFloating && touchGamepadSideStick(side) && touchGamepadStickAnchors[side])
6360
+ return touchGamepadStickAnchors[side];
6361
+ let y = H - touchGamepadSize;
6362
+ const count = touchGamepadSideButtonCount(side);
6363
+ if (!touchGamepadSideStick(side) && (count === 2 || count === 3))
6364
+ y -= touchGamepadSize/4; // nudge a 2/3 button cluster up a bit
6365
+ return vec2(side ? W - touchGamepadSize : touchGamepadSize, y);
6366
+ }
6367
+
6368
+ // position the input zones for the current mode and rebuild the SVG visuals
6369
+ function touchGamepadRelayout()
6370
+ {
6371
+ if (!touchGamepadOverlay) return;
6372
+ const r = touchGamepadStageRect();
6373
+ const W = r.width, H = r.height, S = touchGamepadSize;
6374
+ const setZone = (z, css)=> z.style.cssText =
6375
+ 'position:absolute;pointer-events:auto;touch-action:none;' + css;
6376
+
6377
+ if (paused)
6378
+ {
6379
+ // the gamepad is hidden while paused, so its side zones must not capture
6380
+ // touches - otherwise they silently steal taps from menus and dialogs
6381
+ for (const zone of touchGamepadSideZones) zone.style.display = 'none';
6382
+ if (touchGamepadCenterButtonSize)
6089
6383
  {
6090
- // draw circle shaped gamepad
6091
- context.arc(stickCenter.x, stickCenter.y, touchGamepadSize/2, 0, 9);
6384
+ // any touch presses start
6385
+ setZone(touchGamepadZoneC, 'inset:0');
6386
+ touchGamepadZoneC.style.display = '';
6092
6387
  }
6093
6388
  else
6389
+ touchGamepadZoneC.style.display = 'none';
6390
+ }
6391
+ else
6392
+ {
6393
+ // position each side zone (left/right differ only by which edge they hug)
6394
+ for (let side = 0; side < 2; side++)
6094
6395
  {
6095
- // draw cross shaped gamepad
6096
- for (let i=10; --i;)
6396
+ const zone = touchGamepadSideZones[side], edge = side ? 'right' : 'left';
6397
+ zone.style.display = touchGamepadSideHasControl(side) ? '' : 'none';
6398
+ if (touchGamepadFloating)
6097
6399
  {
6098
- const angle = i*PI/4;
6099
- context.arc(stickCenter.x, stickCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8);
6100
- i%2 && context.arc(stickCenter.x, stickCenter.y, touchGamepadSize*.33, angle, angle);
6400
+ // bottom 60% grabs the control; the top 40% passes through. A side with no
6401
+ // control on the other side uses the full width (matching the hit-test)
6402
+ const width = touchGamepadSideHasControl(side ? 0 : 1) ? '50%' : '100%';
6403
+ setZone(zone, `${edge}:0;bottom:0;width:${width};height:60%`);
6101
6404
  }
6405
+ else // fixed: a compact box hugging the corner control
6406
+ setZone(zone, `${edge}:0;bottom:0;width:${3*S}px;height:${3*S}px`);
6102
6407
  }
6103
- context.fill();
6104
- context.stroke();
6408
+ touchGamepadZoneC.style.display = touchGamepadCenterButtonSize ? '' : 'none';
6409
+ const c = touchGamepadCenterButtonSize;
6410
+ setZone(touchGamepadZoneC,
6411
+ `left:50%;top:50%;width:${2*c}px;height:${2*c}px;transform:translate(-50%,-50%)`);
6412
+ }
6413
+
6414
+ touchGamepadBuildSvg(W, H);
6415
+ touchGamepadNeedRelayout = false;
6416
+ }
6417
+
6418
+ // (re)build the SVG shapes for the current layout; dynamic bits update per-frame
6419
+ function touchGamepadBuildSvg(W, H)
6420
+ {
6421
+ const svg = touchGamepadSvg;
6422
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
6423
+ const els = touchGamepadSvgEls = { face: [], thumb: [] };
6424
+ const S = touchGamepadSize;
6425
+ const circle = (cx, cy, rr, fill)=>
6426
+ {
6427
+ const c = document.createElementNS(touchGamepadSvgNS, 'circle');
6428
+ c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', rr);
6429
+ if (fill) c.setAttribute('fill', fill);
6430
+ svg.appendChild(c);
6431
+ return c;
6432
+ };
6433
+ const cross = (ctr)=>
6434
+ {
6435
+ // plus-shaped dpad outline centered at ctr
6436
+ const a = S*.18, b = S*.5, x = ctr.x, y = ctr.y;
6437
+ const p = document.createElementNS(touchGamepadSvgNS, 'path');
6438
+ p.setAttribute('d',
6439
+ `M ${x-a} ${y-b} H ${x+a} V ${y-a} H ${x+b} V ${y+a} H ${x+a} ` +
6440
+ `V ${y+b} H ${x-a} V ${y+a} H ${x-b} V ${y-a} H ${x-a} Z`);
6441
+ svg.appendChild(p);
6442
+ };
6105
6443
 
6106
- // draw right face buttons
6444
+ // draw each side: a directional stick, a single large button, or face buttons
6445
+ for (let side = 0; side < 2; side++)
6446
+ {
6447
+ const count = touchGamepadSideButtonCount(side);
6448
+ const base = touchGamepadSideButtonBase(side);
6449
+ const ctr = touchGamepadSideCenter(side, W, H);
6450
+ if (touchGamepadSideStick(side))
6451
+ {
6452
+ // directional stick (circle or cross) with a thumb dot that moves per-frame
6453
+ if (touchGamepadAnalog) circle(ctr.x, ctr.y, S/2); else cross(ctr);
6454
+ els.thumb[side] = circle(ctr.x, ctr.y, S/4, '#fff');
6455
+ }
6456
+ else if (count === 1)
6457
+ els.face[base] = circle(ctr.x, ctr.y, S/2, '#000'); // single large button
6458
+ else for (let i = 0; i < count; i++)
6459
+ {
6460
+ const j = mod(i-1, 4);
6461
+ let button = count > 2 ? j : min(j, count-1);
6462
+ button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
6463
+ const offset = vec2().setDirection(j, S/2);
6464
+ if (count === 2) offset.x *= -1;
6465
+ // left side mirrors the right layout's positions, keeping indices in order
6466
+ // (e.g. 2 buttons -> button 4 at bottom, button 5 at left)
6467
+ if (!side) offset.x *= -1;
6468
+ const pos = ctr.add(offset);
6469
+ els.face[base + button] = circle(pos.x, pos.y, S/4, '#000');
6470
+ }
6471
+ }
6472
+
6473
+ // debug: draw the proximity hit regions the hit-test actually uses
6474
+ if (debug && debugGamepads) touchGamepadBuildDebug(W, H);
6475
+ }
6476
+
6477
+ // draw debug outlines of the touch control hit regions into the overlay svg
6478
+ function touchGamepadBuildDebug(W, H)
6479
+ {
6480
+ const S = touchGamepadSize, svg = touchGamepadSvg;
6481
+ const shape = (tag, attrs, stroke)=>
6482
+ {
6483
+ const el = document.createElementNS(touchGamepadSvgNS, tag);
6484
+ for (const k in attrs) el.setAttribute(k, attrs[k]);
6485
+ el.setAttribute('stroke', stroke);
6486
+ el.setAttribute('stroke-width', 2);
6487
+ el.setAttribute('fill', 'none');
6488
+ svg.appendChild(el);
6489
+ };
6490
+ const ring = (c, rr, stroke)=> shape('circle', {cx:c.x, cy:c.y, r:rr}, stroke);
6491
+
6492
+ // green line: the left/right split that assigns a stick press to a side
6493
+ shape('line', {x1:W/2, y1:0, x2:W/2, y2:H}, '#0f0');
6494
+
6495
+ // cyan: where each side's control can be grabbed
6496
+ for (let side = 0; side < 2; side++)
6497
+ {
6498
+ if (touchGamepadSideStick(side))
6107
6499
  {
6108
- const buttonCenter = touchGamepadButtonCenter();
6109
- const buttonSize = touchGamepadButtonCount > 1 ?
6110
- touchGamepadSize/4 : touchGamepadSize/2;
6111
- for (let i=0; i<touchGamepadButtonCount; i++)
6500
+ if (touchGamepadFloating)
6112
6501
  {
6113
- const j = mod(i-1, 4);
6114
- let button = touchGamepadButtonCount > 2 ?
6115
- j : min(j, touchGamepadButtonCount-1);
6116
- // fix button locations (swap 2 and 3 to match gamepad layout)
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();
6502
+ // grab region: this side's half (or the full width if the other side is empty)
6503
+ const top = H*.4, full = !touchGamepadSideHasControl(side ? 0 : 1);
6504
+ const x = full ? 0 : (side ? W/2 : 0);
6505
+ shape('rect', {x, y:top, width:full ? W : W/2, height:H-top}, '#0ff');
6125
6506
  }
6507
+ else
6508
+ ring(touchGamepadSideCenter(side, W, H), 2*S, '#0ff');
6126
6509
  }
6510
+ else if (touchGamepadSideButtonCount(side) >= 1)
6511
+ ring(touchGamepadSideCenter(side, W, H), S, '#0ff'); // face / single-button radius
6512
+ }
6127
6513
 
6128
- // set canvas back to normal
6129
- context.restore();
6514
+ // yellow: start button radius; magenta: where start is blocked (near a control)
6515
+ if (touchGamepadCenterButtonSize)
6516
+ {
6517
+ ring(vec2(W/2, H/2), touchGamepadCenterButtonSize, '#ff0');
6518
+ for (let side = 0; side < 2; side++)
6519
+ if (touchGamepadSideHasControl(side))
6520
+ ring(touchGamepadSideCenter(side, W, H), 2*S, '#f0f');
6521
+ }
6522
+ }
6523
+
6524
+ // per-frame: fade the overlay and move the thumbs / set pressed states
6525
+ function touchGamepadRender()
6526
+ {
6527
+ if (!touchGamepadOverlay || headlessMode) return;
6528
+
6529
+ // hide and bail if disabled at runtime (overlay stays in the DOM for reuse)
6530
+ // display:none also takes the input zones out of hit-testing so touches are
6531
+ // not silently captured away from the game while disabled
6532
+ if (!touchGamepadEnable || !isTouchDevice)
6533
+ {
6534
+ if (touchGamepadOverlay.style.display !== 'none')
6535
+ {
6536
+ // just disabled: hide the overlay and release any held controls
6537
+ touchGamepadOverlay.style.display = 'none';
6538
+ touchGamepadPointerRole.clear();
6539
+ touchGamepadButtons.length = 0;
6540
+ touchGamepadSticks.length = 0;
6541
+ touchGamepadStickPointerId.length = 0;
6542
+ }
6543
+ return;
6544
+ }
6545
+ touchGamepadOverlay.style.display = '';
6546
+
6547
+ // relayout when the paused state, a layout setting, or the debug view changes
6548
+ const dbg = debug && debugGamepads;
6549
+ const layout = [touchGamepadButtonCount, touchGamepadLeftButtonCount, touchGamepadLeftStick,
6550
+ touchGamepadRightStick, touchGamepadAnalog, touchGamepadSize, touchGamepadFloating,
6551
+ touchGamepadCenterButtonSize, paused, dbg].join();
6552
+ if (layout !== touchGamepadLastLayout)
6553
+ {
6554
+ touchGamepadLastLayout = layout;
6555
+ touchGamepadNeedRelayout = true;
6556
+ }
6557
+ // relayout before the visibility bail-out so the paused full-screen start zone applies
6558
+ if (touchGamepadNeedRelayout) touchGamepadRelayout();
6559
+
6560
+ // fade out when idle (always show when displayTime is 0, or while debugging)
6561
+ const fade = touchGamepadDisplayTime ?
6562
+ percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
6563
+ const visible = dbg || (touchGamepadTimer.isSet() && fade > 0 && !paused);
6564
+ touchGamepadOverlay.style.opacity = !visible ? 0 : dbg ? 1 : fade*touchGamepadAlpha;
6565
+ if (!visible) return;
6566
+
6567
+ const r = touchGamepadStageRect();
6568
+ const W = r.width, H = r.height, S = touchGamepadSize;
6569
+ const els = touchGamepadSvgEls;
6570
+ if (!els) return;
6571
+
6572
+ for (let side = 0; side < 2; side++)
6573
+ if (touchGamepadSideStick(side) && els.thumb[side])
6574
+ {
6575
+ const ctr = touchGamepadSideCenter(side, W, H);
6576
+ const t = ctr.add((touchGamepadSticks[side] ?? vec2()).scale(S/2));
6577
+ els.thumb[side].setAttribute('cx', t.x);
6578
+ els.thumb[side].setAttribute('cy', t.y);
6579
+ }
6580
+ for (let i = 0; i < els.face.length; i++)
6581
+ if (els.face[i])
6582
+ els.face[i].setAttribute('fill', touchGamepadButtons[i] ? '#fff' : '#000');
6583
+ }
6584
+
6585
+ // convert a pointer event to stage-local CSS pixels
6586
+ function touchGamepadEventPos(e)
6587
+ {
6588
+ const r = touchGamepadStageRect();
6589
+ return vec2(e.clientX - r.left, e.clientY - r.top);
6590
+ }
6591
+
6592
+ // set a directional stick from a stage-local point and flag its stick-touch button
6593
+ // (stick 0 press = button 10, stick 1 press = button 11, following the output index)
6594
+ function touchGamepadApplyStick(side, p)
6595
+ {
6596
+ const delta = p.subtract(touchGamepadStickAnchors[side]);
6597
+ touchGamepadSticks[side] = delta.scale(2/touchGamepadSize).clampLength();
6598
+ touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10] = 1;
6599
+ }
6600
+
6601
+ // pick a side's gamepad button index from a stage-local point, or -1 if outside the cluster
6602
+ function touchGamepadFaceButtonAt(side, p, W, H)
6603
+ {
6604
+ const count = touchGamepadSideButtonCount(side);
6605
+ const base = touchGamepadSideButtonBase(side);
6606
+ const bc = touchGamepadSideCenter(side, W, H);
6607
+ if (bc.distance(p) >= touchGamepadSize) return -1;
6608
+ if (count === 1) return base; // single large button
6609
+ const d = bc.subtract(p);
6610
+ if (!side) d.x *= -1; // left side mirrors the right layout's positions horizontally
6611
+ let button = count === 2 ? (d.x < d.y ? 1 : 0) : mod(d.direction()+2, 4);
6612
+ button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
6613
+ return button < count ? base + button : -1;
6614
+ }
6615
+
6616
+ // pick which control a stage-local press activates, by priority then proximity,
6617
+ // independent of which zone element captured it - so overlapping zones on small
6618
+ // screens resolve to the nearest control instead of whichever zone is topmost
6619
+ // returns {role:'stick', side} or {role:'face', btn} or {role:'start'} or undefined
6620
+ function touchGamepadControlAt(p, W, H)
6621
+ {
6622
+ const S = touchGamepadSize;
6623
+ const leftHalf = p.x < W/2;
6624
+ const floatTop = H*.4; // floating grab region is the bottom 60% of the screen
6625
+
6626
+ // check each side (left first for priority); a side is a stick or buttons
6627
+ for (let side = 0; side < 2; side++)
6628
+ {
6629
+ const onHalf = side ? !leftHalf : leftHalf;
6630
+ if (touchGamepadSideStick(side))
6631
+ {
6632
+ // a side with no control on the other side uses the full width
6633
+ const otherControl = touchGamepadSideHasControl(side ? 0 : 1);
6634
+ const grab = touchGamepadFloating ?
6635
+ (!otherControl || onHalf) && p.y > floatTop :
6636
+ onHalf && touchGamepadSideCenter(side, W, H).distance(p) < 2*S;
6637
+ if (grab) return {role:'stick', side};
6638
+ }
6639
+ else if (touchGamepadSideButtonCount(side) >= 1)
6640
+ {
6641
+ const btn = touchGamepadFaceButtonAt(side, p, W, H);
6642
+ if (btn >= 0) return {role:'face', btn};
6643
+ }
6644
+ }
6645
+
6646
+ // center start button, blocked within 2*size of a control so drift off a
6647
+ // control can't accidentally fire start (matches the original exclusion logic)
6648
+ if (touchGamepadCenterButtonSize)
6649
+ {
6650
+ for (let side = 0; side < 2; side++)
6651
+ if (touchGamepadSideHasControl(side) &&
6652
+ touchGamepadSideCenter(side, W, H).distance(p) < 2*S)
6653
+ return;
6654
+ if (vec2(W/2, H/2).distance(p) < touchGamepadCenterButtonSize)
6655
+ return {role:'start'};
6656
+ }
6657
+ }
6658
+
6659
+ function touchGamepadPointerDown(e, zone)
6660
+ {
6661
+ if (!touchGamepadEnable) return;
6662
+ e.preventDefault();
6663
+ zone.setPointerCapture(e.pointerId);
6664
+ touchGamepadTimer.set();
6665
+
6666
+ // resume audio on first interaction
6667
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
6668
+ audioContext.resume();
6669
+
6670
+ // while paused, any touch is the start button
6671
+ if (paused)
6672
+ {
6673
+ if (touchGamepadCenterButtonSize)
6674
+ {
6675
+ touchGamepadButtons[9] = 1;
6676
+ touchGamepadPointerRole.set(e.pointerId, 'start');
6677
+ }
6678
+ return;
6679
+ }
6680
+
6681
+ const r = touchGamepadStageRect();
6682
+ const W = r.width, H = r.height;
6683
+ const p = vec2(e.clientX - r.left, e.clientY - r.top);
6684
+
6685
+ // choose the control by proximity/priority, not by which zone captured the touch
6686
+ const hit = touchGamepadControlAt(p, W, H);
6687
+ if (!hit) return;
6688
+ if (hit.role === 'stick')
6689
+ {
6690
+ const side = hit.side;
6691
+ touchGamepadStickAnchors[side] = touchGamepadFloating ? p : touchGamepadSideCenter(side, W, H);
6692
+ touchGamepadStickPointerId[side] = e.pointerId;
6693
+ touchGamepadPointerRole.set(e.pointerId, 'stick'+side);
6694
+ touchGamepadNeedRelayout = true; // base may have re-anchored
6695
+ touchGamepadApplyStick(side, p);
6696
+ }
6697
+ else if (hit.role === 'face')
6698
+ {
6699
+ touchGamepadButtons[hit.btn] = 1;
6700
+ touchGamepadPointerRole.set(e.pointerId, 'face'+hit.btn);
6701
+ }
6702
+ else // 'start'
6703
+ {
6704
+ touchGamepadButtons[9] = 1;
6705
+ touchGamepadPointerRole.set(e.pointerId, 'start');
6130
6706
  }
6131
6707
  }
6132
6708
 
6133
- // center position for right touch pad face buttons
6134
- function touchGamepadButtonCenter()
6709
+ function touchGamepadPointerMove(e)
6135
6710
  {
6136
- const center = mainCanvasSize.subtract(vec2(touchGamepadSize));
6137
- if (touchGamepadButtonCount === 2)
6138
- center.x += touchGamepadSize/2;
6139
- return center;
6711
+ const role = touchGamepadPointerRole.get(e.pointerId);
6712
+ if (!role) return;
6713
+ e.preventDefault();
6714
+ const p = touchGamepadEventPos(e);
6715
+ if (role === 'stick0' || role === 'stick1')
6716
+ touchGamepadApplyStick(role === 'stick1' ? 1 : 0, p);
6717
+ // face buttons & start are held until release (no slide-between this pass)
6718
+ }
6719
+
6720
+ function touchGamepadPointerUp(e)
6721
+ {
6722
+ const role = touchGamepadPointerRole.get(e.pointerId);
6723
+ if (!role) return;
6724
+ touchGamepadPointerRole.delete(e.pointerId);
6725
+ if (role === 'stick0' || role === 'stick1')
6726
+ {
6727
+ const side = role === 'stick1' ? 1 : 0;
6728
+ touchGamepadStickPointerId[side] = undefined;
6729
+ touchGamepadSticks[side] = vec2();
6730
+ delete touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10];
6731
+ }
6732
+ else if (role === 'start')
6733
+ delete touchGamepadButtons[9];
6734
+ else // 'face<n>'
6735
+ delete touchGamepadButtons[+role.slice(4)];
6736
+ touchGamepadTimer.set();
6140
6737
  }
6141
6738
  /**
6142
6739
  * LittleJS Audio System
@@ -6273,7 +6870,7 @@ class Sound
6273
6870
  * @param {number} [randomnessScale] - How much to scale pitch randomness
6274
6871
  * @param {boolean} [loop] - Should the sound loop?
6275
6872
  * @param {boolean} [paused] - Should the sound start paused
6276
- * @return {SoundInstance} - The audio source node
6873
+ * @return {SoundInstance} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode
6277
6874
  */
6278
6875
  play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
6279
6876
  {
@@ -6304,9 +6901,23 @@ class Sound
6304
6901
  pan = worldToScreen(pos).x * 2/mainCanvas.width - 1;
6305
6902
  }
6306
6903
 
6307
- // Create and return sound instance
6904
+ // Create sound instance
6308
6905
  const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
6309
- return new SoundInstance(this, volume, rate, pan, loop, paused);
6906
+ const instance = new SoundInstance(this, volume, rate, pan, loop, paused);
6907
+
6908
+ if (debug && debugSound && pos)
6909
+ {
6910
+ // visualize where positioned sounds play and their falloff range
6911
+ debugCircle(pos, .5, '#0ff', .5, true);
6912
+ if (this.range)
6913
+ {
6914
+ debugCircle(pos, 2*this.range, '#0ff', .5); // silent radius
6915
+ debugCircle(pos, 2*this.range*this.taper, '#0ff', .5); // full volume radius
6916
+ }
6917
+ debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5);
6918
+ }
6919
+
6920
+ return instance;
6310
6921
  }
6311
6922
 
6312
6923
  /** Play a music track that loops by default
@@ -6672,6 +7283,10 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
6672
7283
  // play and return sound
6673
7284
  const startOffset = offset * rate;
6674
7285
  source.start(0, startOffset);
7286
+
7287
+ if (debug && debugSound)
7288
+ LOG('sound', 'vol', volume.toFixed(2), 'rate', rate.toFixed(2), 'pan', pan.toFixed(2), loop ? 'loop' : '');
7289
+
6675
7290
  return source;
6676
7291
  }
6677
7292
 
@@ -7505,6 +8120,11 @@ class TileCollisionLayer extends TileLayer
7505
8120
  // check any tiles in the area for collision
7506
8121
  const posX = pos.x - this.pos.x;
7507
8122
  const posY = pos.y - this.pos.y;
8123
+ // reject AABBs entirely past either edge; without this, the negative
8124
+ // side leaks into row/col 0 because minX/minY clamp to 0 and the
8125
+ // point-test floor below forces maxX/maxY up to 1
8126
+ if (posX + size.x/2 < 0 || posX - size.x/2 > this.size.x) return false;
8127
+ if (posY + size.y/2 < 0 || posY - size.y/2 > this.size.y) return false;
7508
8128
  const minX = max(posX - size.x/2|0, 0);
7509
8129
  const minY = max(posY - size.y/2|0, 0);
7510
8130
  // ensure at least one cell is visited even when size is 0 and pos
@@ -7623,10 +8243,10 @@ class ParticleEmitter extends EngineObject
7623
8243
  * @param {number} [particleTime] - How long particles live
7624
8244
  * @param {number} [sizeStart] - How big are particles at start
7625
8245
  * @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
8246
+ * @param {number} [speed] - How fast are particles when spawned, in world units per frame (at 60fps, so multiply units/sec by 1/60)
8247
+ * @param {number} [angleSpeed] - How fast are particles rotating, in radians per frame (at 60fps)
8248
+ * @param {number} [damping] - How much to dampen particle speed, per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame)
8249
+ * @param {number} [angleDamping] - How much to dampen particle angular speed, per-frame multiplier (1 = no damping)
7630
8250
  * @param {number} [gravityScale] - How much gravity effect particles
7631
8251
  * @param {number} [particleConeAngle] - Cone for start particle angle
7632
8252
  * @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 +8321,13 @@ class ParticleEmitter extends EngineObject
7701
8321
  this.sizeStart = sizeStart;
7702
8322
  /** @property {number} - How big are particles at end */
7703
8323
  this.sizeEnd = sizeEnd;
7704
- /** @property {number} - How fast are particles when spawned */
8324
+ /** @property {number} - Particle speed when spawned, in world units per frame (at 60fps) */
7705
8325
  this.speed = speed;
7706
- /** @property {number} - How fast are particles rotating */
8326
+ /** @property {number} - Particle angular speed when spawned, in radians per frame (at 60fps) */
7707
8327
  this.angleSpeed = angleSpeed;
7708
- /** @property {number} - How much to dampen particle speed */
8328
+ /** @property {number} - Per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame) */
7709
8329
  this.damping = damping;
7710
- /** @property {number} - How much to dampen particle angular speed */
8330
+ /** @property {number} - Per-frame angular velocity multiplier (1 = no damping) */
7711
8331
  this.angleDamping = angleDamping;
7712
8332
  /** @property {number} - How much gravity affects particles */
7713
8333
  this.gravityScale = gravityScale;
@@ -7776,12 +8396,16 @@ class ParticleEmitter extends EngineObject
7776
8396
  else if (this.particles.length === 0)
7777
8397
  this.destroy(true);
7778
8398
 
7779
- // update and remove destroyed particles
7780
- this.particles = this.particles.filter((p)=>
8399
+ // update and remove destroyed particles in place to avoid per-frame array allocation
8400
+ const particles = this.particles;
8401
+ let alive = 0;
8402
+ for (let i = 0; i < particles.length; ++i)
7781
8403
  {
8404
+ const p = particles[i];
7782
8405
  p.update();
7783
- return !p.destroyed;
7784
- });
8406
+ if (!p.destroyed) particles[alive++] = p;
8407
+ }
8408
+ particles.length = alive;
7785
8409
 
7786
8410
  if (debugParticles)
7787
8411
  {
@@ -8053,9 +8677,6 @@ class Particle
8053
8677
  this.color.b = p2 * this.colorStart.b + p1 * this.colorEnd.b;
8054
8678
  this.color.a = (p2 * this.colorStart.a + p1 * this.colorEnd.a) * alphaFade;
8055
8679
 
8056
- // draw the particle
8057
- additive && setBlendMode(true);
8058
-
8059
8680
  // update the position and angle for drawing
8060
8681
  const pos = particleDrawPos.set(this.pos.x, this.pos.y);
8061
8682
  let angle = this.angle;
@@ -8063,16 +8684,19 @@ class Particle
8063
8684
  {
8064
8685
  // in local space of emitter
8065
8686
  const a = emitter.angle;
8066
- const c = cos(a), s = sin(a);
8687
+ const c = cos(-a), s = sin(-a);
8067
8688
  pos.set(emitter.pos.x + pos.x*c - pos.y*s,
8068
8689
  emitter.pos.y + pos.x*s + pos.y*c);
8069
8690
  angle += a;
8070
8691
  }
8692
+
8693
+ // draw the particle
8694
+ additive && setAdditiveBlendMode();
8071
8695
  if (trailScale)
8072
8696
  {
8073
8697
  // trail style particles
8074
- const velocity = localSpace ?
8075
- this.velocity.rotate(-emitter.angle) : this.velocity;
8698
+ const velocity = localSpace ?
8699
+ this.velocity.rotate(emitter.angle) : this.velocity;
8076
8700
  const speed = velocity.length();
8077
8701
  if (speed)
8078
8702
  {
@@ -8085,7 +8709,7 @@ class Particle
8085
8709
  }
8086
8710
  else
8087
8711
  drawTile(pos, size, this.tileInfo, this.color, angle, this.mirror);
8088
- additive && setBlendMode();
8712
+ additive && setAdditiveBlendMode(false);
8089
8713
  debugParticles && debugRect(pos, size, '#f005', 0, angle);
8090
8714
  }
8091
8715
  }
@@ -9234,7 +9858,15 @@ function medalsInit(saveName)
9234
9858
  // check if medals are unlocked
9235
9859
  medalsSaveName = saveName;
9236
9860
  if (!debugMedals)
9237
- medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
9861
+ {
9862
+ let saved = {};
9863
+ try { saved = JSON.parse(localStorage[saveName] || '{}'); }
9864
+ catch (e) { saved = {}; }
9865
+ medalsForEach(medal => {
9866
+ medal.unlocked = !!(saved[medal.id] && saved[medal.id].unlocked);
9867
+ });
9868
+ medalsSave();
9869
+ }
9238
9870
 
9239
9871
  // engine automatically renders medals
9240
9872
  engineAddPlugin(undefined, medalsRender);
@@ -9278,6 +9910,31 @@ function medalsInit(saveName)
9278
9910
  function medalsForEach(callback)
9279
9911
  { Object.values(medals).forEach(medal=>callback(medal)); }
9280
9912
 
9913
+ /** Reset all medals to locked and persist the cleared catalog
9914
+ * @memberof Medals */
9915
+ function medalsReset()
9916
+ {
9917
+ medalsForEach(medal => medal.unlocked = false);
9918
+ medalsSave();
9919
+ }
9920
+
9921
+ function medalsSave()
9922
+ {
9923
+ if (!medalsSaveName) return;
9924
+ const data = {};
9925
+ medalsForEach(medal => {
9926
+ const entry = {
9927
+ name: medal.name,
9928
+ description: medal.description,
9929
+ icon: medal.icon,
9930
+ unlocked: medal.unlocked,
9931
+ };
9932
+ if (medal.image) entry.src = medal.image.src;
9933
+ data[medal.id] = entry;
9934
+ });
9935
+ localStorage[medalsSaveName] = JSON.stringify(data);
9936
+ }
9937
+
9281
9938
  ///////////////////////////////////////////////////////////////////////////////
9282
9939
 
9283
9940
  /**
@@ -9335,9 +9992,9 @@ class Medal
9335
9992
  {
9336
9993
  if (medalsPreventUnlock || this.unlocked) return;
9337
9994
 
9338
- // save the medal
9339
9995
  ASSERT(medalsSaveName, 'save name must be set');
9340
- localStorage[this.storageKey()] = this.unlocked = true;
9996
+ this.unlocked = true;
9997
+ medalsSave();
9341
9998
  medalsDisplayQueue.push(this);
9342
9999
  }
9343
10000
 
@@ -9395,8 +10052,6 @@ class Medal
9395
10052
  drawTextScreen(this.icon, pos, size*.7, BLACK);
9396
10053
  }
9397
10054
 
9398
- // Get local storage key used by the medal
9399
- storageKey() { return medalsSaveName + '_' + this.id; }
9400
10055
  }
9401
10056
 
9402
10057
  ///////////////////////////////////////////////////////////////////////////////
@@ -9781,6 +10436,335 @@ class PostProcessPlugin
9781
10436
  }
9782
10437
  }
9783
10438
  }
10439
+ /**
10440
+ * LittleJS Light System Plugin
10441
+ * - Adds 2D dynamic lighting to the scene
10442
+ * - Lights are first-class EngineObjects (the Light class)
10443
+ * - Each Light draws a soft falloff blob of its color into a shared lightmap
10444
+ * - Lights accumulate ADDITIVELY in the lightmap (red + blue = magenta)
10445
+ * - The lightmap is then MULTIPLIED with the scene during composite, so unlit
10446
+ * areas go to the ambient color and lit areas show the scene tinted by the
10447
+ * accumulated light color
10448
+ * - Draw the world at full brightness — the lightmap does the darkening
10449
+ * - Any EngineObject may override renderLight() to additively contribute to the
10450
+ * lightmap (e.g. emissive lava tiles, weapon flashes, glowing crystals)
10451
+ * - Must be constructed BEFORE PostProcessPlugin so post-process sees lit pixels
10452
+ * @namespace LightSystem
10453
+ */
10454
+
10455
+ ///////////////////////////////////////////////////////////////////////////////
10456
+
10457
+ /** Global Light System plugin object
10458
+ * @type {LightSystemPlugin}
10459
+ * @memberof LightSystem */
10460
+ let lightSystem;
10461
+
10462
+ ///////////////////////////////////////////////////////////////////////////////
10463
+
10464
+ /**
10465
+ * LightSystemPlugin
10466
+ * - Owns the offscreen lightmap texture, falloff/composite shaders, and the
10467
+ * per-frame render pass that multiplies the lightmap onto the WebGL scene
10468
+ * - The composite is MULTIPLICATIVE: unlit areas get the ambient color, lit
10469
+ * areas show the scene tinted by the accumulated light color. So you should
10470
+ * draw your world at full brightness — the lightmap handles the darkening.
10471
+ * @memberof LightSystem
10472
+ */
10473
+ class LightSystemPlugin
10474
+ {
10475
+ /** Create the global light system plugin.
10476
+ * @param {Vector2} [textureSize] - Size of the lightmap texture (defaults to mainCanvasSize)
10477
+ * @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.
10478
+ * @example
10479
+ * // simplest usage
10480
+ * new LightSystemPlugin();
10481
+ */
10482
+ constructor(textureSize, ambientColor)
10483
+ {
10484
+ ASSERT(!lightSystem, 'LightSystemPlugin already initialized');
10485
+ ASSERT(!postProcess, 'LightSystemPlugin must be created before PostProcessPlugin');
10486
+ lightSystem = this;
10487
+
10488
+ /** @property {boolean} - When false, the render pass is skipped entirely */
10489
+ this.enabled = true;
10490
+ /** @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. */
10491
+ this.ambientColor = (ambientColor || BLACK).copy();
10492
+ /** @property {Vector2} - Size of the lightmap texture (set at construction; falls back to mainCanvasSize at init time) */
10493
+ this.textureSize = textureSize ? textureSize.copy() : undefined;
10494
+
10495
+ /** @property {WebGLTexture} - The lightmap texture */
10496
+ this.texture = undefined;
10497
+ /** @property {WebGLProgram} - Shader for drawing per-Light falloff blobs into the lightmap */
10498
+ this.lightShader = undefined;
10499
+ /** @property {WebGLProgram} - Shader for compositing the lightmap over the main scene */
10500
+ this.compositeShader = undefined;
10501
+ /** @property {WebGLVertexArrayObject} - Vertex array object for the light shader */
10502
+ this.lightVAO = undefined;
10503
+ /** @property {WebGLVertexArrayObject} - Vertex array object for the composite shader */
10504
+ this.compositeVAO = undefined;
10505
+
10506
+ initLightSystem();
10507
+ engineAddPlugin(undefined, lightSystemRender,
10508
+ lightSystemContextLost, lightSystemContextRestored);
10509
+
10510
+ function initLightSystem()
10511
+ {
10512
+ if (headlessMode) return;
10513
+ if (!glEnable)
10514
+ {
10515
+ console.warn('LightSystemPlugin: WebGL not enabled!');
10516
+ return;
10517
+ }
10518
+
10519
+ // resolve texture size default at init time (mainCanvasSize may
10520
+ // not be set yet at the moment the constructor first ran)
10521
+ if (!lightSystem.textureSize)
10522
+ lightSystem.textureSize = mainCanvasSize.copy();
10523
+
10524
+ // allocate the lightmap texture with null data at textureSize
10525
+ lightSystem.texture = glContext.createTexture();
10526
+ glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
10527
+ glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA,
10528
+ lightSystem.textureSize.x, lightSystem.textureSize.y, 0,
10529
+ glContext.RGBA, glContext.UNSIGNED_BYTE, null);
10530
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, glContext.LINEAR);
10531
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, glContext.LINEAR);
10532
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, glContext.CLAMP_TO_EDGE);
10533
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, glContext.CLAMP_TO_EDGE);
10534
+
10535
+ // light falloff shader: one quad per Light, fragment computes radial falloff
10536
+ lightSystem.lightShader = glCreateProgram(
10537
+ '#version 300 es\n' +
10538
+ 'precision highp float;'+
10539
+ 'uniform mat4 m;'+
10540
+ 'uniform vec2 lightPos;'+
10541
+ 'uniform float radius;'+
10542
+ 'in vec2 g;'+ // unit quad geometry [0..1]
10543
+ 'out vec2 vWorldPos;'+
10544
+ 'void main(){'+
10545
+ 'vec2 worldP=lightPos+(g-.5)*2.*radius;'+
10546
+ 'gl_Position=m*vec4(worldP,1,1);'+
10547
+ 'vWorldPos=worldP;'+
10548
+ '}'
10549
+ ,
10550
+ '#version 300 es\n' +
10551
+ 'precision highp float;'+
10552
+ 'uniform vec2 lightPos;'+
10553
+ 'uniform float radius;'+
10554
+ 'uniform float fadeRange;'+
10555
+ 'uniform vec4 color;'+
10556
+ 'in vec2 vWorldPos;'+
10557
+ 'out vec4 c;'+
10558
+ 'void main(){'+
10559
+ 'float dist=distance(vWorldPos,lightPos);'+
10560
+ 'float t=clamp((radius-dist)/max(fadeRange,1e-6),0.,1.);'+
10561
+ 'c=vec4(color.rgb*t*color.a,1.);'+
10562
+ '}'
10563
+ );
10564
+
10565
+ // composite shader: fullscreen quad, samples the lightmap
10566
+ lightSystem.compositeShader = glCreateProgram(
10567
+ '#version 300 es\n' +
10568
+ 'precision highp float;'+
10569
+ 'in vec2 p;'+
10570
+ 'void main(){'+
10571
+ 'gl_Position=vec4(p+p-1.,1,1);'+
10572
+ '}'
10573
+ ,
10574
+ '#version 300 es\n' +
10575
+ 'precision highp float;'+
10576
+ 'uniform sampler2D s;'+
10577
+ 'uniform vec3 iResolution;'+
10578
+ 'out vec4 c;'+
10579
+ 'void main(){'+
10580
+ 'vec2 uv=gl_FragCoord.xy/iResolution.xy;'+
10581
+ 'c=vec4(texture(s,uv).rgb,1.);'+
10582
+ '}'
10583
+ );
10584
+
10585
+ // VAO for the per-Light quad — reuses the engine unit triangle-strip
10586
+ lightSystem.lightVAO = glContext.createVertexArray();
10587
+ glContext.bindVertexArray(lightSystem.lightVAO);
10588
+ glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer);
10589
+ const gLight = glContext.getAttribLocation(lightSystem.lightShader, 'g');
10590
+ glContext.enableVertexAttribArray(gLight);
10591
+ glContext.vertexAttribPointer(gLight, 2, glContext.FLOAT, false, 8, 0);
10592
+
10593
+ // VAO for the composite fullscreen quad — same buffer, attribute named 'p'
10594
+ lightSystem.compositeVAO = glContext.createVertexArray();
10595
+ glContext.bindVertexArray(lightSystem.compositeVAO);
10596
+ glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer);
10597
+ const pComp = glContext.getAttribLocation(lightSystem.compositeShader, 'p');
10598
+ glContext.enableVertexAttribArray(pComp);
10599
+ glContext.vertexAttribPointer(pComp, 2, glContext.FLOAT, false, 8, 0);
10600
+ }
10601
+ function lightSystemRender()
10602
+ {
10603
+ if (headlessMode || !glEnable) return;
10604
+ if (!lightSystem.enabled) return;
10605
+ if (!lightSystem.texture) return; // init failed or context lost
10606
+
10607
+ // 1. flush any in-flight sprite batch from earlier render passes
10608
+ glFlush();
10609
+ const prevAdditive = glAdditive;
10610
+
10611
+ // 2. bind lightmap as render target, clear to ambientColor
10612
+ const ac = lightSystem.ambientColor;
10613
+ glContext.bindFramebuffer(glContext.FRAMEBUFFER, glFramebuffer);
10614
+ glContext.framebufferTexture2D(glContext.FRAMEBUFFER,
10615
+ glContext.COLOR_ATTACHMENT0, glContext.TEXTURE_2D, lightSystem.texture, 0);
10616
+ glContext.viewport(0, 0, lightSystem.textureSize.x, lightSystem.textureSize.y);
10617
+ glContext.clearColor(ac.r, ac.g, ac.b, ac.a);
10618
+ glContext.clear(glContext.COLOR_BUFFER_BIT);
10619
+
10620
+ // 3. walk engineObjects calling renderLight() — additive blend
10621
+ // (lightmap accumulates raw additive color contributions)
10622
+ setAdditiveBlendMode();
10623
+ glContext.enable(glContext.BLEND);
10624
+ glContext.blendFunc(glContext.ONE, glContext.ONE);
10625
+
10626
+ for (const o of engineObjects)
10627
+ o.destroyed || o.renderLight();
10628
+
10629
+ // 4. drain any sprite-batched draws (e.g. drawTile inside a
10630
+ // custom renderLight override) so they hit the FBO, not the
10631
+ // canvas after we unbind
10632
+ glFlush();
10633
+ glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
10634
+ glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
10635
+
10636
+ // 5. composite: fullscreen quad, multiplicative blend onto glCanvas
10637
+ // (scene * lightmap — unlit areas go to black, lit areas are
10638
+ // the scene tinted by the accumulated light color)
10639
+ glContext.useProgram(lightSystem.compositeShader);
10640
+ glContext.bindVertexArray(lightSystem.compositeVAO);
10641
+ glContext.activeTexture(glContext.TEXTURE0);
10642
+ glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
10643
+ const cs = lightSystem.compositeShader;
10644
+ glContext.uniform1i(glContext.getUniformLocation(cs, 's'), 0);
10645
+ glContext.uniform3f(glContext.getUniformLocation(cs, 'iResolution'),
10646
+ mainCanvas.width, mainCanvas.height, 1);
10647
+ glContext.blendFunc(glContext.DST_COLOR, glContext.ZERO);
10648
+ glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
10649
+
10650
+ // 6. restore engine state so subsequent draws use the engine's
10651
+ // tracked texture binding (otherwise glSetTexture would think
10652
+ // the prior texture was still bound when actually the lightmap
10653
+ // is, and any debug text / future draw could sample the lightmap)
10654
+ if (glActiveTexture)
10655
+ glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
10656
+ setAdditiveBlendMode(prevAdditive);
10657
+ glSetInstancedMode(true);
10658
+ }
10659
+ function lightSystemContextLost()
10660
+ {
10661
+ lightSystem.texture = undefined;
10662
+ lightSystem.lightShader = undefined;
10663
+ lightSystem.compositeShader = undefined;
10664
+ lightSystem.lightVAO = undefined;
10665
+ lightSystem.compositeVAO = undefined;
10666
+ LOG('LightSystemPlugin: WebGL context lost');
10667
+ }
10668
+ function lightSystemContextRestored()
10669
+ {
10670
+ initLightSystem();
10671
+ LOG('LightSystemPlugin: WebGL context restored');
10672
+ }
10673
+ }
10674
+
10675
+ /** Draw a single Light's falloff blob into the currently bound lightmap.
10676
+ * Called by Light.renderLight() during the plugin's render pass.
10677
+ * @param {Light} light */
10678
+ drawLight(light)
10679
+ {
10680
+ if (headlessMode || !glEnable || !this.lightShader) return;
10681
+
10682
+ // drain any sprite-batched draws queued by a previous custom
10683
+ // renderLight() override (e.g. drawRect inside a LavaTile). They were
10684
+ // queued in the engine's instanced-vertex format and must flush with
10685
+ // the engine's shader+VAO bound — NOT this plugin's light shader.
10686
+ glFlush();
10687
+
10688
+ glContext.useProgram(this.lightShader);
10689
+ glContext.bindVertexArray(this.lightVAO);
10690
+
10691
+ // re-apply the engine camera transform onto this shader. Divide by
10692
+ // mainCanvasSize (not textureSize) so world→NDC matches the main
10693
+ // pass; the viewport handles the lightmap's actual resolution.
10694
+ // No y-flip here: the composite samples this FBO with
10695
+ // gl_FragCoord/iResolution (origin bottom-left), so storing world
10696
+ // +Y at the top of the texture lines up with the canvas convention.
10697
+ const s = vec2(2*cameraScale).divide(mainCanvasSize);
10698
+ const rotatedCam = cameraPos.rotate(-cameraAngle);
10699
+ const p = vec2(-1).subtract(rotatedCam.multiply(s));
10700
+ const ca = cos(cameraAngle);
10701
+ const sa = sin(cameraAngle);
10702
+ const transform = [
10703
+ s.x * ca, s.y * sa, 0, 0,
10704
+ -s.x * sa, s.y * ca, 0, 0,
10705
+ 1, 1, 1, 0,
10706
+ p.x, p.y, 0, 1];
10707
+
10708
+ const ls = this.lightShader;
10709
+ glContext.uniformMatrix4fv(glContext.getUniformLocation(ls, 'm'), false, transform);
10710
+ glContext.uniform2f(glContext.getUniformLocation(ls, 'lightPos'), light.pos.x, light.pos.y);
10711
+ glContext.uniform1f(glContext.getUniformLocation(ls, 'radius'), light.radius);
10712
+ glContext.uniform1f(glContext.getUniformLocation(ls, 'fadeRange'), light.fadeRange);
10713
+ const c = light.color;
10714
+ glContext.uniform4f(glContext.getUniformLocation(ls, 'color'), c.r, c.g, c.b, c.a);
10715
+
10716
+ glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
10717
+
10718
+ // restore engine's instanced shader+VAO so subsequent renderLight()
10719
+ // overrides that batch through drawRect/drawTile work correctly
10720
+ glSetInstancedMode(true);
10721
+ }
10722
+ }
10723
+
10724
+ ///////////////////////////////////////////////////////////////////////////////
10725
+
10726
+ /**
10727
+ * A Light is an EngineObject that contributes a soft additive blob of color
10728
+ * to the LightSystem plugin's lightmap.
10729
+ * @extends EngineObject
10730
+ * @memberof LightSystem
10731
+ * @example
10732
+ * new Light(vec2(5, 5), 4, rgb(1, 0.5, 0)); // orange light, full soft blob
10733
+ * new Light(vec2(0, 0), 8, rgb(1, 1, 1), 2); // white core with 2-unit soft halo
10734
+ */
10735
+ class Light extends EngineObject
10736
+ {
10737
+ /** Create a light object and add it to the engine object list
10738
+ * @param {Vector2} pos - World space position
10739
+ * @param {number} radius - Total extent of the light in world units
10740
+ * @param {Color} [color] - Color of the light; alpha modulates intensity
10741
+ * @param {number} [fadeRange] - Width of the soft edge in world units (defaults to radius) */
10742
+ constructor(pos, radius, color, fadeRange)
10743
+ {
10744
+ super(pos, vec2(1), undefined, 0, color);
10745
+ ASSERT(isNumber(radius) && radius >= 0, 'Light radius must be a non-negative number');
10746
+ ASSERT(fadeRange === undefined || (isNumber(fadeRange) && fadeRange >= 0),
10747
+ 'Light fadeRange must be a non-negative number when provided');
10748
+
10749
+ /** @property {number} - Total extent of the light in world units */
10750
+ this.radius = radius;
10751
+ /** @property {number} - Width of the soft edge in world units */
10752
+ this.fadeRange = fadeRange === undefined ? radius : fadeRange;
10753
+ }
10754
+
10755
+ /** Lights are invisible in the main render pass — they only contribute
10756
+ * to the lightmap via renderLight(). */
10757
+ render() {}
10758
+
10759
+ /** Draw this light's falloff blob into the lightmap.
10760
+ * Called by LightSystemPlugin during its render pass. No-op when the
10761
+ * plugin or WebGL is unavailable. */
10762
+ renderLight()
10763
+ {
10764
+ lightSystem && lightSystem.drawLight(this);
10765
+ }
10766
+ }
10767
+
9784
10768
  /**
9785
10769
  * LittleJS ZzFXM Plugin
9786
10770
  * @namespace ZzFXM
@@ -10078,7 +11062,7 @@ class UISystemPlugin
10078
11062
  let targetPos, targetSize;
10079
11063
  if (o.parent)
10080
11064
  {
10081
- targetPos = o.parent.pos;
11065
+ targetPos = o.parent.nativePos;
10082
11066
  targetSize = o.parent.size;
10083
11067
  }
10084
11068
  else
@@ -10092,7 +11076,7 @@ class UISystemPlugin
10092
11076
  }
10093
11077
 
10094
11078
  const a = o.anchor;
10095
- o.pos = targetPos
11079
+ o.nativePos = targetPos
10096
11080
  .add(targetSize.multiply(a).scale(.5)) // anchor point on target
10097
11081
  .subtract(o.size.multiply(a).scale(.5)) // pivot shift on self
10098
11082
  .add(o.localPos); // user offset
@@ -10198,13 +11182,18 @@ class UISystemPlugin
10198
11182
 
10199
11183
  function updateObject(o)
10200
11184
  {
10201
- if (!o.visible) return;
11185
+ if (o.destroyed || !o.visible) return;
10202
11186
 
10203
11187
  // update in reverse order to detect mouse enter/leave
10204
11188
  updateTransforms(o);
10205
11189
  for (let i=o.children.length; i--;)
10206
- updateObject(o.children[i]);
10207
- o.update();
11190
+ {
11191
+ // a child may destroy siblings mid-update (e.g. dialog close)
11192
+ const child = o.children[i];
11193
+ child && updateObject(child);
11194
+ }
11195
+ if (!o.destroyed)
11196
+ o.update();
10208
11197
  }
10209
11198
  }
10210
11199
  function uiRender()
@@ -10628,10 +11617,15 @@ class UIObject
10628
11617
  ASSERT(isVector2(pos), 'ui object pos must be a vec2');
10629
11618
  ASSERT(isVector2(size), 'ui object size must be a vec2');
10630
11619
 
10631
- /** @property {Vector2} - Local position of the object */
11620
+ /** @property {Vector2} - Position you set: an offset from this object's
11621
+ * anchor point (the parent box, or the canvas for roots). This is the
11622
+ * input that controls placement — set this, not nativePos. */
10632
11623
  this.localPos = pos.copy();
10633
- /** @property {Vector2} - Screen space position of the object */
10634
- this.pos = pos.copy();
11624
+ /** @property {Vector2} - Resolved position in native UI space, recomputed
11625
+ * every frame from localPos + anchor (and nativeHeight, if set). This is a
11626
+ * derived output used for drawing and hit-testing; assigning to it has no
11627
+ * effect since it is overwritten each frame. Set localPos instead. */
11628
+ this.nativePos = pos.copy();
10635
11629
  /** @property {Vector2} - Screen space size of the object */
10636
11630
  this.size = size.copy();
10637
11631
  /** @property {Color} - Color of the object */
@@ -10766,7 +11760,7 @@ class UIObject
10766
11760
  const size = !isTouchDevice ? this.size :
10767
11761
  this.size.add(vec2(this.extraTouchSize || 0));
10768
11762
  const pos = uiSystem.screenToNative(mousePosScreen);
10769
- return isOverlapping(this.pos, size, pos);
11763
+ return isOverlapping(this.nativePos, size, pos);
10770
11764
  }
10771
11765
 
10772
11766
  /** Update the object, called automatically by plugin once each frame */
@@ -10856,7 +11850,7 @@ class UIObject
10856
11850
  this.color : this.color;
10857
11851
  const lineWidth = this.lineWidth * (isNavigationObject ? 1.5 : 1);
10858
11852
 
10859
- uiSystem.drawRect(this.pos, this.size, color, lineWidth, lineColor, this.cornerRadius, this.gradientColor, this.shadowColor, this.shadowBlur, this.shadowOffset);
11853
+ uiSystem.drawRect(this.nativePos, this.size, color, lineWidth, lineColor, this.cornerRadius, this.gradientColor, this.shadowColor, this.shadowBlur, this.shadowOffset);
10860
11854
  }
10861
11855
 
10862
11856
  /** Get the size for text with overrides and scale
@@ -10893,8 +11887,8 @@ class UIObject
10893
11887
  let text = 'type = ' + this.constructor.name;
10894
11888
  if (this.text)
10895
11889
  text += '\ntext = ' + this.text;
10896
- if (this.pos.x || this.pos.y)
10897
- text += '\npos = ' + this.pos;
11890
+ if (this.nativePos.x || this.nativePos.y)
11891
+ text += '\nnativePos = ' + this.nativePos;
10898
11892
  if (this.localPos.x || this.localPos.y)
10899
11893
  text += '\nlocalPos = ' + this.localPos;
10900
11894
  if (this.size.x || this.size.y)
@@ -10914,7 +11908,7 @@ class UIObject
10914
11908
  this.isHoverObject() ? YELLOW :
10915
11909
  this.disabled ? PURPLE :
10916
11910
  this.interactive ? RED : BLUE;
10917
- uiSystem.drawRect(this.pos, this.size, CLEAR_BLACK, 4, color);
11911
+ uiSystem.drawRect(this.nativePos, this.size, CLEAR_BLACK, 4, color);
10918
11912
  }
10919
11913
 
10920
11914
  /** Internal function called when object is clicked
@@ -10997,7 +11991,7 @@ class UIText extends UIObject
10997
11991
 
10998
11992
  // render the text
10999
11993
  const textSize = this.getTextSize();
11000
- uiSystem.drawText(this.text, this.pos, textSize, this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow, this.shadowColor, this.shadowBlur, this.shadowOffset);
11994
+ uiSystem.drawText(this.text, this.nativePos, textSize, this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow, this.shadowColor, this.shadowBlur, this.shadowOffset);
11001
11995
  }
11002
11996
  }
11003
11997
 
@@ -11092,7 +12086,7 @@ class UITextInput extends UIObject
11092
12086
  let text = this.text;
11093
12087
  if (this.isKeyInputObject()) // add a cursor to end of text
11094
12088
  text += timeReal%1 < .5 ? '█' : '░';
11095
- uiSystem.drawText(text, this.pos, textSize,
12089
+ uiSystem.drawText(text, this.nativePos, textSize,
11096
12090
  this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
11097
12091
  }
11098
12092
  }
@@ -11135,7 +12129,7 @@ class UITile extends UIObject
11135
12129
  }
11136
12130
  render()
11137
12131
  {
11138
- uiSystem.drawTile(this.pos, this.size, this.tileInfo, this.color, this.angle, this.mirror, this.shadowColor, this.shadowBlur, this.shadowOffset);
12132
+ uiSystem.drawTile(this.nativePos, this.size, this.tileInfo, this.color, this.angle, this.mirror, this.shadowColor, this.shadowBlur, this.shadowOffset);
11139
12133
  }
11140
12134
  }
11141
12135
 
@@ -11174,7 +12168,7 @@ class UIButton extends UIObject
11174
12168
 
11175
12169
  // draw the text scaled to fit
11176
12170
  const textSize = this.getTextSize();
11177
- uiSystem.drawText(this.text, this.pos.add(this.textOffset), textSize,
12171
+ uiSystem.drawText(this.text, this.nativePos.add(this.textOffset), textSize,
11178
12172
  this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
11179
12173
  }
11180
12174
  }
@@ -11222,13 +12216,13 @@ class UICheckbox extends UIObject
11222
12216
  const p = this.cornerRadius / min(this.size.x, this.size.y) * 2;
11223
12217
  const length = lerp(1, 2**.5/2, p) / 2;
11224
12218
  let s = this.size.scale(length);
11225
- uiSystem.drawLine(this.pos.add(s.multiply(vec2(-1))), this.pos.add(s.multiply(vec2(1))), this.lineWidth, this.lineColor);
11226
- uiSystem.drawLine(this.pos.add(s.multiply(vec2(-1,1))), this.pos.add(s.multiply(vec2(1,-1))), this.lineWidth, this.lineColor);
12219
+ uiSystem.drawLine(this.nativePos.add(s.multiply(vec2(-1))), this.nativePos.add(s.multiply(vec2(1))), this.lineWidth, this.lineColor);
12220
+ uiSystem.drawLine(this.nativePos.add(s.multiply(vec2(-1,1))), this.nativePos.add(s.multiply(vec2(1,-1))), this.lineWidth, this.lineColor);
11227
12221
  }
11228
12222
 
11229
12223
  // draw the text next to the checkbox
11230
12224
  const textSize = this.getTextSize();
11231
- const pos = this.pos.add(vec2(this.size.x,0));
12225
+ const pos = this.nativePos.add(vec2(this.size.x,0));
11232
12226
  uiSystem.drawText(this.text, pos, textSize,
11233
12227
  this.textColor, this.textLineWidth, this.textLineColor, 'left', this.font, this.fontStyle, false, this.textShadow);
11234
12228
  }
@@ -11284,7 +12278,7 @@ class UISlider extends UIObject
11284
12278
  const isHorizontal = this.size.x > this.size.y;
11285
12279
  const handleSize = isHorizontal ? this.size.y : this.size.x;
11286
12280
  const barSize = isHorizontal ? this.size.x : this.size.y;
11287
- const centerPos = isHorizontal ? this.pos.x : this.pos.y;
12281
+ const centerPos = isHorizontal ? this.nativePos.x : this.nativePos.y;
11288
12282
 
11289
12283
  // check if value changed
11290
12284
  const handleWidth = barSize - handleSize;
@@ -11318,7 +12312,7 @@ class UISlider extends UIObject
11318
12312
  const minWidth = min(handleWidth, this.cornerRadius * 2);
11319
12313
  const progressWidth = lerp(minWidth, barWidth, this.value);
11320
12314
  const p = (progressWidth - barWidth) * (isHorizontal ? .5 : -.5);
11321
- const pos = this.pos.add(isHorizontal ? vec2(p, 0) : vec2(0, p));
12315
+ const pos = this.nativePos.add(isHorizontal ? vec2(p, 0) : vec2(0, p));
11322
12316
  const color = this.disabled ? this.disabledColor : this.handleColor;
11323
12317
  const drawSize = isHorizontal ?
11324
12318
  vec2(progressWidth, this.size.y) : vec2(this.size.x, progressWidth);
@@ -11329,7 +12323,7 @@ class UISlider extends UIObject
11329
12323
  // draw the slider handle
11330
12324
  const value = clamp(isHorizontal ? this.value : 1 - this.value);
11331
12325
  const p = (barWidth - handleWidth) * (value - .5);
11332
- const pos = this.pos.add(isHorizontal ? vec2(p, 0) : vec2(0, p));
12326
+ const pos = this.nativePos.add(isHorizontal ? vec2(p, 0) : vec2(0, p));
11333
12327
  const color = this.disabled ? this.disabledColor : this.handleColor;
11334
12328
  const drawSize = vec2(handleWidth);
11335
12329
  uiSystem.drawRect(pos, drawSize, color, this.lineWidth, this.lineColor, this.cornerRadius, this.gradientColor);
@@ -11337,7 +12331,7 @@ class UISlider extends UIObject
11337
12331
 
11338
12332
  // draw the text scaled to fit on the slider
11339
12333
  const textSize = this.getTextSize();
11340
- uiSystem.drawText(this.text, this.pos, textSize,
12334
+ uiSystem.drawText(this.text, this.nativePos, textSize,
11341
12335
  this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
11342
12336
  }
11343
12337
  navigatePressed()
@@ -11476,7 +12470,7 @@ class UIVideo extends UIObject
11476
12470
  const context = uiSystem.uiContext;
11477
12471
  const s = this.size;
11478
12472
  context.save();
11479
- context.translate(this.pos.x, this.pos.y);
12473
+ context.translate(this.nativePos.x, this.nativePos.y);
11480
12474
  context.drawImage(this.video, -s.x/2, -s.y/2, s.x, s.y);
11481
12475
  context.restore();
11482
12476
  }
@@ -11828,23 +12822,19 @@ class Box2dObject extends EngineObject
11828
12822
 
11829
12823
  function box2dCreatePolygonShape(points)
11830
12824
  {
11831
- function box2dCreatePointList(points)
12825
+ ASSERT(3 <= points.length && points.length <= 8);
12826
+ const buffer = box2d.instance._malloc(points.length * 8);
12827
+ for (let i=0, offset=0; i<points.length; ++i)
11832
12828
  {
11833
- const buffer = box2d.instance._malloc(points.length * 8);
11834
- for (let i=0, offset=0; i<points.length; ++i)
11835
- {
11836
- box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].x;
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);
12829
+ box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].x;
12830
+ offset += 4;
12831
+ box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].y;
12832
+ offset += 4;
11842
12833
  }
11843
-
11844
- ASSERT(3 <= points.length && points.length <= 8);
12834
+ const box2dPoints = box2d.instance.wrapPointer(buffer, box2d.instance.b2Vec2);
11845
12835
  const shape = new box2d.instance.b2PolygonShape();
11846
- const box2dPoints = box2dCreatePointList(points);
11847
12836
  shape.Set(box2dPoints, points.length);
12837
+ box2d.instance._free(buffer);
11848
12838
  return shape;
11849
12839
  }
11850
12840
 
@@ -13709,8 +14699,8 @@ async function box2dInit()
13709
14699
  * This function can not apply color because it draws using the 2d context
13710
14700
  * @param {Vector2} pos - Screen space position
13711
14701
  * @param {Vector2} size - Screen space size
13712
- * @param {TileInfo} startTile - Starting tile for the nine-slice pattern
13713
- * @param {number} [borderSize] - Width of the border sections
14702
+ * @param {TileInfo} startTile - Top-left tile of the 3x3 block to sample (see drawNineSlice)
14703
+ * @param {number} [borderSize] - Rendered thickness of the border sections
13714
14704
  * @param {number} [extraSpace] - Extra spacing adjustment
13715
14705
  * @param {number} [angle] - Angle to rotate by
13716
14706
  * @memberof DrawUtilities */
@@ -13721,11 +14711,16 @@ function drawNineSliceScreen(pos, size, startTile, borderSize=32, extraSpace=2,
13721
14711
 
13722
14712
  /** Draw a scalable nine-slice UI element in world space
13723
14713
  * This function can apply color and additive color if WebGL is enabled
14714
+ * The nine-slice samples a 3x3 block of tiles from the tilesheet, it does not
14715
+ * subdivide a single tile. Pass the top-left tile of that block as startTile;
14716
+ * the other 8 tiles (edges, corners, and center) are taken automatically from
14717
+ * the 3x3 grid of tiles extending right and down from it. borderSize only sets
14718
+ * the rendered thickness of the edges and corners, not how the texture is cut.
13724
14719
  * @param {Vector2} pos - World space position
13725
14720
  * @param {Vector2} size - World space size
13726
- * @param {TileInfo} startTile - Starting tile for the nine-slice pattern
14721
+ * @param {TileInfo} startTile - Top-left tile of the 3x3 block to sample the nine-slice from
13727
14722
  * @param {Color} [color] - Color to modulate with
13728
- * @param {number} [borderSize] - Width of the border sections
14723
+ * @param {number} [borderSize] - Rendered thickness of the border sections
13729
14724
  * @param {Color} [additiveColor] - Additive color
13730
14725
  * @param {number} [extraSpace] - Extra spacing adjustment
13731
14726
  * @param {number} [angle] - Angle to rotate by
@@ -13735,7 +14730,8 @@ function drawNineSliceScreen(pos, size, startTile, borderSize=32, extraSpace=2,
13735
14730
  * @memberof DrawUtilities */
13736
14731
  function drawNineSlice(pos, size, startTile, color, borderSize=1, additiveColor, extraSpace=.05, angle=0, useWebGL=glEnable, screenSpace, context)
13737
14732
  {
13738
- // setup nine slice tiles
14733
+ // setup nine slice tiles - startTile is the top-left of a 3x3 tile block,
14734
+ // so the center tile is one tile down and right from it
13739
14735
  const centerTile = startTile.offset(startTile.size);
13740
14736
  const centerSize = size.add(vec2(extraSpace-borderSize*2));
13741
14737
  const cornerSize = vec2(borderSize);
@@ -13769,8 +14765,8 @@ function drawNineSlice(pos, size, startTile, color, borderSize=1, additiveColor,
13769
14765
  * This function can not apply color because it draws using the 2d context
13770
14766
  * @param {Vector2} pos - Screen space position
13771
14767
  * @param {Vector2} size - Screen space size
13772
- * @param {TileInfo} startTile - Starting tile for the three-slice pattern
13773
- * @param {number} [borderSize] - Width of the border sections
14768
+ * @param {TileInfo} startTile - First of 3 consecutive tiles: corner, side, center (see drawThreeSlice)
14769
+ * @param {number} [borderSize] - Rendered thickness of the border sections
13774
14770
  * @param {number} [extraSpace] - Extra spacing adjustment
13775
14771
  * @param {number} [angle] - Angle to rotate by
13776
14772
  * @memberof DrawUtilities */
@@ -13781,11 +14777,15 @@ function drawThreeSliceScreen(pos, size, startTile, borderSize=32, extraSpace=2,
13781
14777
 
13782
14778
  /** Draw a scalable three-slice UI element in world space
13783
14779
  * This function can apply color and additive color if WebGL is enabled
14780
+ * The three-slice samples 3 consecutive tiles from the tilesheet, it does not
14781
+ * subdivide a single tile. Pass the first tile as startTile; the three tiles
14782
+ * are used in order as corner, side, and center, then rotated and mirrored to
14783
+ * build all four edges and corners. borderSize only sets the rendered thickness.
13784
14784
  * @param {Vector2} pos - World space position
13785
14785
  * @param {Vector2} size - World space size
13786
- * @param {TileInfo} startTile - Starting tile for the three-slice pattern
14786
+ * @param {TileInfo} startTile - First of 3 consecutive tiles (corner, side, center) for the three-slice
13787
14787
  * @param {Color} [color] - Color to modulate with
13788
- * @param {number} [borderSize] - Width of the border sections
14788
+ * @param {number} [borderSize] - Rendered thickness of the border sections
13789
14789
  * @param {Color} [additiveColor] - Additive color
13790
14790
  * @param {number} [extraSpace] - Extra spacing adjustment
13791
14791
  * @param {number} [angle] - Angle to rotate by
@@ -13795,7 +14795,7 @@ function drawThreeSliceScreen(pos, size, startTile, borderSize=32, extraSpace=2,
13795
14795
  * @memberof DrawUtilities */
13796
14796
  function drawThreeSlice(pos, size, startTile, color, borderSize=1, additiveColor, extraSpace=.05, angle=0, useWebGL=glEnable, screenSpace, context)
13797
14797
  {
13798
- // setup three slice tiles
14798
+ // setup three slice tiles - 3 tiles in a row starting at startTile
13799
14799
  const cornerTile = startTile.frame(0);
13800
14800
  const sideTile = startTile.frame(1);
13801
14801
  const centerTile = startTile.frame(2);
@@ -13825,6 +14825,70 @@ function drawThreeSlice(pos, size, startTile, color, borderSize=1, additiveColor
13825
14825
  const cornerPos = cornerOffset.multiply(vec2(flipX?-1:1, flipY?-flip:flip));
13826
14826
  drawTile(pos.add(cornerPos.rotate(rotateAngle)), cornerSize, cornerTile, color, a, false, additiveColor, useWebGL, screenSpace, context);
13827
14827
  }
14828
+ }
14829
+
14830
+ /** Draw a crescent / moon-phase shape built from a polygon
14831
+ * Routes through drawPoly, so it supports WebGL, screen space, color, and outlines
14832
+ * @param {Vector2} pos - Center position
14833
+ * @param {number} [size] - Diameter
14834
+ * @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
14835
+ * @param {Color} [color] - Fill color
14836
+ * @param {number} [angle] - Angle to rotate by
14837
+ * @param {boolean} [invert] - Flip which side is illuminated
14838
+ * @param {number} [lineWidth] - Outline width, 0 for no outline
14839
+ * @param {Color} [lineColor] - Outline color
14840
+ * @param {boolean} [useWebGL=glEnable] - Use WebGL for rendering
14841
+ * @param {boolean} [screenSpace] - Use screen space coordinates
14842
+ * @param {CanvasRenderingContext2D} [context] - Canvas context to use
14843
+ * @memberof DrawUtilities */
14844
+ function drawCrescent(pos, size=1, percent=0, color=WHITE, angle=0, invert=false, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
14845
+ {
14846
+ // build local-space points and let drawPoly apply pos/angle so screen space works
14847
+ const points = getCrescentPoints(vec2(), size, percent, 0, invert);
14848
+ drawPoly(points, color, lineWidth, lineColor, pos, angle, useWebGL, screenSpace, context);
14849
+ }
14850
+
14851
+ /** Get the list of points that make up a crescent / moon-phase shape
14852
+ * Returns world-space points with pos and angle baked in, ready for drawPoly or other use
14853
+ * @param {Vector2} pos - Center position
14854
+ * @param {number} [size] - Diameter
14855
+ * @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
14856
+ * @param {number} [angle] - Angle to rotate by
14857
+ * @param {boolean} [invert] - Flip which side is illuminated
14858
+ * @param {number} [sides=glCircleSides] - Number of sides for a full circle (halved per arc)
14859
+ * @return {Array<Vector2>} - List of points making up the crescent
14860
+ * @memberof DrawUtilities */
14861
+ function getCrescentPoints(pos, size=1, percent=0, angle=0, invert=false, sides=glCircleSides)
14862
+ {
14863
+ ASSERT(isVector2(pos), 'pos must be a vec2');
14864
+ ASSERT(isNumber(size) && isNumber(percent), 'size and percent must be numbers');
14865
+
14866
+ // map phase to a signed terminator curve: -1 new, 0 half, 1 full
14867
+ let p = mod(percent*4, 4); // quarter phase 0..4
14868
+ if (p >= 2) // second half of cycle flips orientation
14869
+ angle += PI;
14870
+ p = p <= 2 ? p-1 : 3-p;
14871
+ if (invert) // flip the illuminated side
14872
+ {
14873
+ p = -p;
14874
+ angle += PI;
14875
+ }
14876
+
14877
+ // build the crescent: outer semicircle, then inner half-ellipse traced back
14878
+ const points = [];
14879
+ const segs = max(3, sides>>1);
14880
+ const radius = size/2;
14881
+ for (let i=0; i<=segs; i++)
14882
+ {
14883
+ const t = i/segs*PI;
14884
+ points.push(vec2(radius*cos(t), radius*sin(t)).rotate(angle).add(pos));
14885
+ }
14886
+ for (let i=segs; i>=0; i--)
14887
+ {
14888
+ const t = i/segs*PI;
14889
+ points.push(vec2(radius*cos(t), -radius*p*sin(t)).rotate(angle).add(pos));
14890
+ }
14891
+ return points;
13828
14892
  }
13829
14893
  /**
13830
14894
  * LittleJS Tween System Plugin
@@ -15223,12 +16287,18 @@ export
15223
16287
  inputWASDEmulateDirection,
15224
16288
  touchInputEnable,
15225
16289
  touchGamepadEnable,
16290
+ touchGamepadPassthrough,
15226
16291
  touchGamepadCenterButtonSize,
15227
16292
  touchGamepadButtonCount,
16293
+ touchGamepadLeftStick,
16294
+ touchGamepadLeftButtonCount,
16295
+ touchGamepadRightStick,
15228
16296
  touchGamepadAnalog,
16297
+ touchGamepadFloating,
15229
16298
  touchGamepadSize,
15230
16299
  touchGamepadAlpha,
15231
16300
  touchGamepadDisplayTime,
16301
+ touchGamepadVibration,
15232
16302
  vibrateEnable,
15233
16303
  soundEnable,
15234
16304
  soundVolume,
@@ -15271,12 +16341,18 @@ export
15271
16341
  setGamepadDirectionEmulateStick,
15272
16342
  setInputWASDEmulateDirection,
15273
16343
  setTouchGamepadEnable,
16344
+ setTouchGamepadPassthrough,
15274
16345
  setTouchGamepadCenterButtonSize,
15275
16346
  setTouchGamepadButtonCount,
16347
+ setTouchGamepadLeftStick,
16348
+ setTouchGamepadLeftButtonCount,
16349
+ setTouchGamepadRightStick,
15276
16350
  setTouchGamepadAnalog,
16351
+ setTouchGamepadFloating,
15277
16352
  setTouchGamepadSize,
15278
16353
  setTouchGamepadAlpha,
15279
16354
  setTouchGamepadDisplayTime,
16355
+ setTouchGamepadVibration,
15280
16356
  setVibrateEnable,
15281
16357
  setSoundEnable,
15282
16358
  setSoundVolume,
@@ -15402,7 +16478,7 @@ export
15402
16478
  drawCanvas2D,
15403
16479
  drawText,
15404
16480
  drawTextScreen,
15405
- setBlendMode,
16481
+ setAdditiveBlendMode,
15406
16482
  combineCanvases,
15407
16483
  engineImageFont,
15408
16484
  ImageFont,
@@ -15410,6 +16486,7 @@ export
15410
16486
  toggleFullscreen,
15411
16487
  setCursor,
15412
16488
  getCameraSize,
16489
+ cameraFit,
15413
16490
  isOnScreen,
15414
16491
 
15415
16492
  // WebGL
@@ -15453,10 +16530,16 @@ export
15453
16530
  mouseWheel,
15454
16531
  mouseInWindow,
15455
16532
  isUsingGamepad,
16533
+ lastInputDevice,
16534
+ inputMouseMoveThreshold,
15456
16535
  inputPreventDefault,
15457
16536
  gamepadPrimary,
15458
16537
  isTouchDevice,
15459
16538
  setInputPreventDefault,
16539
+ setInputMouseMoveThreshold,
16540
+ usingMouseInput,
16541
+ usingKeyboardInput,
16542
+ usingGamepadInput,
15460
16543
  gamepadIsDown,
15461
16544
  gamepadWasPressed,
15462
16545
  gamepadWasReleased,
@@ -15519,6 +16602,7 @@ export
15519
16602
  medalDisplaySize,
15520
16603
  medalsInit,
15521
16604
  medalsForEach,
16605
+ medalsReset,
15522
16606
  setMedalDisplayTime,
15523
16607
  setMedalDisplaySlideTime,
15524
16608
  setMedalDisplaySize,
@@ -15534,6 +16618,11 @@ export
15534
16618
  postProcess,
15535
16619
  PostProcessPlugin,
15536
16620
 
16621
+ // Light System
16622
+ lightSystem,
16623
+ LightSystemPlugin,
16624
+ Light,
16625
+
15537
16626
  // ZzFXMusic
15538
16627
  ZzFXMusic,
15539
16628
  zzfxM,
@@ -15583,6 +16672,8 @@ export
15583
16672
  drawNineSliceScreen,
15584
16673
  drawThreeSlice,
15585
16674
  drawThreeSliceScreen,
16675
+ drawCrescent,
16676
+ getCrescentPoints,
15586
16677
 
15587
16678
  // Tween System
15588
16679
  Tween,