littlejsengine 1.18.15 → 1.18.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.17';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -596,6 +596,7 @@ const debugPhysics = 0;
596
596
  const debugParticles = 0;
597
597
  const debugRaycast = 0;
598
598
  const debugGamepads = 0;
599
+ const debugSound = 0;
599
600
  const debugPointSize = .5;
600
601
 
601
602
  // debug commands are automatically removed from the final build
@@ -2297,35 +2298,78 @@ let touchInputEnable = true;
2297
2298
  * - Supports left analog stick, 4 face buttons and start button (button 9)
2298
2299
  * - setTouchGamepadButtonCount(1) to use face buttons as right analog stick
2299
2300
  * - Analog stick buttons 10 and 11 are also activated when virtual sticks are touched
2300
-
2301
+ * - Rendered as a full-viewport HTML/SVG overlay, so controls may sit outside the game canvas
2301
2302
  * @type {boolean}
2302
2303
  * @default
2303
2304
  * @memberof Settings */
2304
2305
  let touchGamepadEnable = false;
2305
2306
 
2306
- /** True if touch gamepad should have start button in the center
2307
- * - Prevents activating within 2*touchGamepadSize of the virtual stick or face buttons
2308
- * (one radius for the visible control + one radius of buffer beyond its edge)
2307
+ /** True if touches outside the gamepad controls should still drive mouse/touch input
2308
+ * - When false (the default), enabling the touch gamepad suppresses touch-to-mouse input entirely
2309
+ * - Set true to also pass touches outside the controls through to the game as mouse/touch input
2310
+ * - Touches on the gamepad controls never drive the mouse regardless of this setting
2311
+ * @type {boolean}
2312
+ * @default
2313
+ * @memberof Settings */
2314
+ let touchGamepadPassthrough = false;
2315
+
2316
+ /** Size of center button if touch gamepad should have start button in the center
2317
+ * - Prevents activating when pressed near virtual stick or face buttons
2309
2318
  * - When the game is paused, any touch will press the button
2310
- * - Set size to enable the center button
2319
+ * - Measured in viewport CSS pixels
2311
2320
  * @type {number}
2312
2321
  * @default
2313
2322
  * @memberof Settings */
2314
- let touchGamepadCenterButtonSize = 300;
2323
+ let touchGamepadCenterButtonSize = 0;
2315
2324
 
2316
- /** Number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
2325
+ /** Number of buttons on the right side of the touch gamepad (0-4), using gamepad buttons 0-3
2326
+ * - A count of 1 is a single large button (the size of a stick)
2327
+ * - Ignored when touchGamepadRightStick is set (the right side is a stick instead)
2317
2328
  * @type {number}
2318
2329
  * @default
2319
2330
  * @memberof Settings */
2320
2331
  let touchGamepadButtonCount = 4;
2321
2332
 
2333
+ /** True if the touch gamepad should have a left analog stick (or dpad)
2334
+ * - When false, the left side is face buttons (touchGamepadLeftButtonCount) or nothing
2335
+ * @type {boolean}
2336
+ * @default
2337
+ * @memberof Settings */
2338
+ let touchGamepadLeftStick = true;
2339
+
2340
+ /** Number of buttons on the left side of the touch gamepad (0-4), using gamepad buttons 4-7
2341
+ * - Only used when touchGamepadLeftStick is false (otherwise the left side is a stick)
2342
+ * - A count of 1 is a single large button (the size of a stick)
2343
+ * @type {number}
2344
+ * @default
2345
+ * @memberof Settings */
2346
+ let touchGamepadLeftButtonCount = 0;
2347
+
2348
+ /** True if the touch gamepad right side should be an analog stick (or dpad) instead of face buttons
2349
+ * - When set, touchGamepadButtonCount is ignored and the right side is a stick
2350
+ * - Uses an analog stick when touchGamepadAnalog is true, otherwise an 8 way dpad
2351
+ * @type {boolean}
2352
+ * @default
2353
+ * @memberof Settings */
2354
+ let touchGamepadRightStick = false;
2355
+
2322
2356
  /** True if touch gamepad should be analog stick or false to use if 8 way dpad
2323
2357
  * @type {boolean}
2324
2358
  * @default
2325
2359
  * @memberof Settings */
2326
2360
  let touchGamepadAnalog = true;
2327
2361
 
2328
- /** Size of virtual gamepad for touch devices in pixels
2362
+ /** True if touch gamepad directional controls should float to where you press
2363
+ * - Only affects analog sticks and dpads, not face buttons
2364
+ * - Directional controls re-anchor to where you press within the bottom ~60% of their screen half; the top ~40% passes through to the game
2365
+ * - The right side floats only when it acts as the right analog stick (touchGamepadRightStick is set)
2366
+ * - A center button (touchGamepadCenterButtonSize) still works since it ignores touches near the sticks
2367
+ * @type {boolean}
2368
+ * @default
2369
+ * @memberof Settings */
2370
+ let touchGamepadFloating = false;
2371
+
2372
+ /** Size of virtual gamepad for touch devices in viewport CSS pixels
2329
2373
  * @type {number}
2330
2374
  * @default
2331
2375
  * @memberof Settings */
@@ -2343,6 +2387,13 @@ let touchGamepadAlpha = .3;
2343
2387
  * @memberof Settings */
2344
2388
  let touchGamepadDisplayTime = 3;
2345
2389
 
2390
+ /** Duration in ms to vibrate when a touch gamepad face button or start button is pressed
2391
+ * - Set to 0 to disable, also requires vibrateEnable and hardware support (ignored on iOS)
2392
+ * @type {number}
2393
+ * @default
2394
+ * @memberof Settings */
2395
+ let touchGamepadVibration = 0;
2396
+
2346
2397
  /** Allow vibration hardware if it exists
2347
2398
  * @type {boolean}
2348
2399
  * @default
@@ -2575,6 +2626,11 @@ function setTouchInputEnable(enable) { touchInputEnable = enable; }
2575
2626
  * @memberof Settings */
2576
2627
  function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
2577
2628
 
2629
+ /** Set if touches outside the gamepad controls should still drive mouse/touch input
2630
+ * @param {boolean} passthrough
2631
+ * @memberof Settings */
2632
+ function setTouchGamepadPassthrough(passthrough) { touchGamepadPassthrough = passthrough; }
2633
+
2578
2634
  /** Set if touch gamepad should have start button in the center
2579
2635
  * - Set size to enable the center button
2580
2636
  * - When the game is paused, any touch will press the button
@@ -2582,16 +2638,57 @@ function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
2582
2638
  * @memberof Settings */
2583
2639
  function setTouchGamepadCenterButtonSize(size) { touchGamepadCenterButtonSize = size; }
2584
2640
 
2585
- /** Set number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
2641
+ /** Set number of buttons on the right side of the touch gamepad (0-4, gamepad buttons 0-3)
2586
2642
  * @param {number} count
2587
2643
  * @memberof Settings */
2588
- function setTouchGamepadButtonCount(count) { touchGamepadButtonCount = count; }
2644
+ function setTouchGamepadButtonCount(count)
2645
+ {
2646
+ touchGamepadButtonCount = count;
2647
+ if (count > 0)
2648
+ touchGamepadRightStick = false;
2649
+ }
2650
+
2651
+ /** Set if the touch gamepad should have a left analog stick (or dpad)
2652
+ * @param {boolean} enable
2653
+ * @memberof Settings */
2654
+ function setTouchGamepadLeftStick(enable)
2655
+ {
2656
+ touchGamepadLeftStick = enable;
2657
+ if (enable)
2658
+ touchGamepadLeftButtonCount = 0;
2659
+ }
2660
+
2661
+ /** Set number of buttons on the left side of the touch gamepad (0-4, gamepad buttons 4-7)
2662
+ * - Only used when touchGamepadLeftStick is false
2663
+ * @param {number} count
2664
+ * @memberof Settings */
2665
+ function setTouchGamepadLeftButtonCount(count)
2666
+ {
2667
+ touchGamepadLeftButtonCount = count;
2668
+ if (count > 0)
2669
+ touchGamepadLeftStick = false;
2670
+ }
2671
+
2672
+ /** Set if the touch gamepad right side is an analog stick (or dpad) instead of face buttons
2673
+ * @param {boolean} rightStick
2674
+ * @memberof Settings */
2675
+ function setTouchGamepadRightStick(rightStick)
2676
+ {
2677
+ touchGamepadRightStick = rightStick;
2678
+ if (rightStick)
2679
+ touchGamepadButtonCount = 0;
2680
+ }
2589
2681
 
2590
2682
  /** Set if touch gamepad should be analog stick or 8 way dpad
2591
2683
  * @param {boolean} analog
2592
2684
  * @memberof Settings */
2593
2685
  function setTouchGamepadAnalog(analog) { touchGamepadAnalog = analog; }
2594
2686
 
2687
+ /** Set if touch gamepad directional controls should float to where you press
2688
+ * @param {boolean} floating
2689
+ * @memberof Settings */
2690
+ function setTouchGamepadFloating(floating) { touchGamepadFloating = floating; }
2691
+
2595
2692
  /** Set size of virtual gamepad for touch devices in pixels
2596
2693
  * @param {number} size
2597
2694
  * @memberof Settings */
@@ -2607,6 +2704,11 @@ function setTouchGamepadAlpha(alpha) { touchGamepadAlpha = alpha; }
2607
2704
  * @memberof Settings */
2608
2705
  function setTouchGamepadDisplayTime(time) { touchGamepadDisplayTime = time; }
2609
2706
 
2707
+ /** Set duration in ms to vibrate when a touch gamepad face or start button is pressed (0 disables)
2708
+ * @param {number} ms
2709
+ * @memberof Settings */
2710
+ function setTouchGamepadVibration(ms) { touchGamepadVibration = ms; }
2711
+
2610
2712
  /** Set to allow vibration hardware if it exists
2611
2713
  * @param {boolean} enable
2612
2714
  * @memberof Settings */
@@ -2785,10 +2887,10 @@ class EngineObject
2785
2887
  if (pa)
2786
2888
  {
2787
2889
  const c = cos(-pa), s = sin(-pa);
2788
- this.pos = new Vector2(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
2890
+ this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
2789
2891
  }
2790
2892
  else
2791
- this.pos = new Vector2(lx + pp.x, ly + pp.y);
2893
+ this.pos.set(lx + pp.x, ly + pp.y);
2792
2894
  this.angle = mirror*this.localAngle + pa;
2793
2895
  }
2794
2896
 
@@ -3020,6 +3122,9 @@ class EngineObject
3020
3122
  drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor);
3021
3123
  }
3022
3124
 
3125
+ /** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
3126
+ renderLight() {}
3127
+
3023
3128
  /** Destroy this object, destroy its children, detach its parent, and mark it for removal
3024
3129
  * @param {boolean} [immediate] - should attached effects be allowed to die off? */
3025
3130
  destroy(immediate=false)
@@ -3524,11 +3629,12 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
3524
3629
  // normal canvas 2D rendering method (slower)
3525
3630
  ++drawCount;
3526
3631
  ++primitiveCount;
3527
- size = new Vector2(size.x, -size.y); // flip upside down sprites
3528
3632
  drawCanvas2D(pos, size, angle, mirror, (context)=>
3529
3633
  {
3530
3634
  if (textureInfo)
3531
3635
  {
3636
+ // un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
3637
+ context.scale(1, -1);
3532
3638
  // calculate uvs and render
3533
3639
  const x = tileInfo.pos.x, y = tileInfo.pos.y;
3534
3640
  const w = tileInfo.size.x, h = tileInfo.size.y;
@@ -3536,7 +3642,7 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
3536
3642
  }
3537
3643
  else
3538
3644
  {
3539
- // if no tile info, use untextured rect
3645
+ // if no tile info, use untextured rect (Y-symmetric, no compensation needed)
3540
3646
  const c = additiveColor ? color.add(additiveColor) : color;
3541
3647
  context.fillStyle = c.toString();
3542
3648
  context.fillRect(-.5, -.5, 1, 1);
@@ -3610,11 +3716,10 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, an
3610
3716
  // normal canvas 2D rendering method (slower)
3611
3717
  ++drawCount;
3612
3718
  ++primitiveCount;
3613
- size = new Vector2(size.x, -size.y); // fix upside down sprites
3614
3719
  drawCanvas2D(pos, size, angle, false, (context)=>
3615
3720
  {
3616
- // if no tile info, use untextured rect
3617
- const gradient = context.createLinearGradient(0, -.5, 0, .5);
3721
+ // gradient endpoints are flipped to match the Y flip inside drawCanvas2D
3722
+ const gradient = context.createLinearGradient(0, .5, 0, -.5);
3618
3723
  gradient.addColorStop(0, colorTop.toString());
3619
3724
  gradient.addColorStop(1, colorBottom.toString());
3620
3725
  context.fillStyle = gradient;
@@ -4033,7 +4138,10 @@ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHIT
4033
4138
  * @memberof Draw
4034
4139
  */
4035
4140
 
4036
- /** Draw directly to a 2d canvas context in world space
4141
+ /** Draw directly to a 2d canvas context in world space.
4142
+ * The Y axis is flipped so world-Y-up coordinates render right-side up
4143
+ * (matches the WebGL path). Callers whose drawing depends on Y direction
4144
+ * (e.g. linear gradients) should flip their own Y endpoints accordingly.
4037
4145
  * @param {Vector2} pos
4038
4146
  * @param {Vector2} size
4039
4147
  * @param {number} angle
@@ -4697,6 +4805,7 @@ function inputClear()
4697
4805
  inputData[0] = [];
4698
4806
  touchGamepadButtons.length = 0;
4699
4807
  touchGamepadSticks.length = 0;
4808
+ touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor
4700
4809
  gamepadStickData.length = 0;
4701
4810
  gamepadDpadData.length = 0;
4702
4811
  }
@@ -4939,6 +5048,14 @@ const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
4939
5048
 
4940
5049
  // touch gamepad internal variables
4941
5050
  const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
5051
+ // floating stick anchors (stage-local CSS pixels) and owning pointer ids, indexed by stick (0=left, 1=right)
5052
+ const touchGamepadStickAnchors = [], touchGamepadStickPointerId = [];
5053
+ // pointerId -> control role ('stick0', 'stick1', 'face<n>', or 'start')
5054
+ const touchGamepadPointerRole = new Map();
5055
+ // overlay DOM elements (created lazily on touch devices) and cached SVG shapes
5056
+ let touchGamepadOverlay, touchGamepadStage, touchGamepadSvg, touchGamepadSvgEls;
5057
+ let touchGamepadSideZones = [], touchGamepadZoneC;
5058
+ let touchGamepadNeedRelayout = true, touchGamepadLastLayout;
4942
5059
 
4943
5060
  ///////////////////////////////////////////////////////////////////////////////
4944
5061
  // Input system functions used by engine
@@ -5063,7 +5180,15 @@ function inputInit()
5063
5180
  e.preventDefault(); // prevent page scrolling
5064
5181
  }
5065
5182
  function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
5066
- function onBlur() { inputClear(); } // reset input when focus is lost
5183
+ function onBlur()
5184
+ {
5185
+ inputClear();
5186
+ // release any held virtual gamepad controls so they don't stick
5187
+ touchGamepadPointerRole.clear();
5188
+ touchGamepadButtons.length = 0;
5189
+ touchGamepadSticks.length = 0;
5190
+ touchGamepadStickPointerId.length = 0;
5191
+ }
5067
5192
 
5068
5193
  // enable touch input mouse passthrough
5069
5194
  function touchInputInit()
@@ -5079,36 +5204,46 @@ function inputInit()
5079
5204
  {
5080
5205
  if (!touchInputEnable) return;
5081
5206
 
5082
- // route touch to gamepad
5083
- if (touchGamepadEnable)
5084
- handleTouchGamepad(e);
5085
-
5086
5207
  // fix stalled audio requiring user interaction
5087
5208
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
5088
5209
  audioContext.resume();
5089
5210
 
5090
- // check if touching and pass to mouse events
5091
- const touching = e.touches.length;
5092
- const button = 0; // all touches are left mouse button
5093
- if (touching)
5211
+ // when the touch gamepad is enabled it owns touch input: suppress the
5212
+ // touch->mouse passthrough entirely unless touchGamepadPassthrough is set
5213
+ // (its own zones drive gameplay via pointer events)
5214
+ if (!touchGamepadEnable || touchGamepadPassthrough)
5094
5215
  {
5095
- // set event pos and pass it along
5096
- const pos = vec2(e.touches[0].clientX, e.touches[0].clientY);
5097
- const mousePosScreenLast = mousePosScreen;
5098
- mousePosScreen = mouseEventToScreen(pos);
5099
- if (wasTouching)
5216
+ // touches that landed on a virtual gamepad zone are owned by the gamepad
5217
+ // (handled by its own pointer listeners) and must not drive the game mouse
5218
+ const isGamepadTouch = (t)=>
5219
+ touchGamepadSideZones.includes(t.target) || t.target === touchGamepadZoneC;
5220
+ const gameTouches = [];
5221
+ for (const t of e.touches)
5222
+ if (!isGamepadTouch(t)) gameTouches.push(t);
5223
+
5224
+ // check if touching and pass to mouse events
5225
+ const touching = gameTouches.length;
5226
+ const button = 0; // all touches are left mouse button
5227
+ if (touching)
5100
5228
  {
5101
- mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
5102
- isUsingGamepad = touchGamepadEnable;
5229
+ // set event pos and pass it along
5230
+ const pos = vec2(gameTouches[0].clientX, gameTouches[0].clientY);
5231
+ const mousePosScreenLast = mousePosScreen;
5232
+ mousePosScreen = mouseEventToScreen(pos);
5233
+ if (wasTouching)
5234
+ mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
5235
+ else
5236
+ {
5237
+ inputData[0][button] = 3;
5238
+ isUsingGamepad = false; // a passthrough tap is mouse-style input
5239
+ }
5103
5240
  }
5104
- else
5105
- inputData[0][button] = 3;
5106
- }
5107
- else if (wasTouching)
5108
- inputData[0][button] = inputData[0][button] & 2 | 4;
5241
+ else if (wasTouching)
5242
+ inputData[0][button] = inputData[0][button] & 2 | 4;
5109
5243
 
5110
- // set was touching
5111
- wasTouching = touching;
5244
+ // set was touching
5245
+ wasTouching = touching;
5246
+ }
5112
5247
 
5113
5248
  // prevent default handling like copy, magnifier lens, and scrolling
5114
5249
  if (inputPreventDefault && e.cancelable && document.hasFocus())
@@ -5118,83 +5253,6 @@ function inputInit()
5118
5253
  return true;
5119
5254
  }
5120
5255
 
5121
- // special handling for virtual gamepad mode
5122
- function handleTouchGamepad(e)
5123
- {
5124
- // clear touch gamepad input
5125
- touchGamepadSticks.length = 0;
5126
- touchGamepadSticks[0] = vec2();
5127
- touchGamepadSticks[1] = vec2();
5128
- touchGamepadButtons.length = 0;
5129
- isUsingGamepad = true;
5130
-
5131
- const touching = e.touches.length;
5132
- if (touching)
5133
- {
5134
- touchGamepadTimer.set();
5135
- if (touchGamepadCenterButtonSize && !wasTouching && paused)
5136
- {
5137
- // touch anywhere to press start when paused
5138
- touchGamepadButtons[9] = 1;
5139
- return;
5140
- }
5141
- }
5142
-
5143
- // don't process touch gamepad if paused
5144
- if (paused) return;
5145
-
5146
- // get center of left and right sides
5147
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5148
- const buttonCenter = touchGamepadButtonCenter();
5149
- const startCenter = mainCanvasSize.scale(.5);
5150
-
5151
- // check each touch point
5152
- for (const touch of e.touches)
5153
- {
5154
- const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
5155
- if (stickCenter.distance(touchPos) < touchGamepadSize)
5156
- {
5157
- // virtual analog stick
5158
- const delta = touchPos.subtract(stickCenter);
5159
- touchGamepadSticks[0] = delta.scale(2/touchGamepadSize).clampLength();
5160
- touchGamepadButtons[10] = 1; // also press a button when touching stick
5161
- }
5162
- else if (buttonCenter.distance(touchPos) < touchGamepadSize)
5163
- {
5164
- if (touchGamepadButtonCount === 1)
5165
- {
5166
- // virtual right analog stick
5167
- const delta = touchPos.subtract(buttonCenter);
5168
- touchGamepadSticks[1] = delta.scale(2/touchGamepadSize).clampLength();
5169
- touchGamepadButtons[11] = 1; // also press a button when touching right stick
5170
- }
5171
- // virtual face buttons
5172
- let button = buttonCenter.subtract(touchPos).direction();
5173
- button = mod(button+2, 4);
5174
- if (touchGamepadButtonCount === 1)
5175
- button = 0;
5176
- else if (touchGamepadButtonCount === 2)
5177
- {
5178
- const delta = buttonCenter.subtract(touchPos);
5179
- button = -delta.x < delta.y ? 1 : 0;
5180
- }
5181
- // fix button locations (swap 2 and 3 to match gamepad layout)
5182
- button = button === 3 ? 2 : button === 2 ? 3 : button;
5183
- if (button < touchGamepadButtonCount)
5184
- touchGamepadButtons[button] = 1;
5185
- }
5186
- else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize &&
5187
- stickCenter.distance(touchPos) >= 2 * touchGamepadSize &&
5188
- buttonCenter.distance(touchPos) >= 2 * touchGamepadSize)
5189
- {
5190
- // virtual start button in center
5191
- // require a fat-finger buffer of touchGamepadSize beyond the
5192
- // edge of the stick/buttons so drift off those controls can't
5193
- // accidentally fire start
5194
- touchGamepadButtons[9] = 1;
5195
- }
5196
- }
5197
- }
5198
5256
  }
5199
5257
 
5200
5258
  // convert a mouse or touch event position to screen space
@@ -5219,6 +5277,9 @@ function inputUpdate()
5219
5277
  mousePos = screenToWorld(mousePosScreen);
5220
5278
  mouseDelta = screenToWorldDelta(mouseDeltaScreen);
5221
5279
 
5280
+ // build the touch gamepad overlay lazily once enabled on a touch device
5281
+ touchGamepadInit();
5282
+
5222
5283
  // update gamepads if enabled
5223
5284
  gamepadsUpdate();
5224
5285
 
@@ -5237,22 +5298,11 @@ function inputUpdate()
5237
5298
  // update touch gamepad if enabled
5238
5299
  if (touchGamepadEnable && isTouchDevice)
5239
5300
  {
5240
- if (debugGamepads)
5241
- {
5242
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5243
- const buttonCenter = touchGamepadButtonCenter();
5244
- const startCenter = mainCanvasSize.scale(.5);
5245
-
5246
- debugCircle(stickCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
5247
- debugCircle(buttonCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
5248
- if (touchGamepadCenterButtonSize)
5249
- {
5250
- debugCircle(startCenter, 2*touchGamepadCenterButtonSize, 'cyan', 0, false, true);
5251
- // exclusion bubbles around controls (where start is blocked)
5252
- debugCircle(stickCenter, 4*touchGamepadSize, 'magenta', 0, false, true);
5253
- debugCircle(buttonCenter, 4*touchGamepadSize, 'magenta', 0, false, true);
5254
- }
5255
- }
5301
+ // a side is either a stick or buttons - setting both is ambiguous
5302
+ ASSERT(!touchGamepadLeftStick || !touchGamepadLeftButtonCount,
5303
+ 'set touchGamepadLeftStick or touchGamepadLeftButtonCount, not both');
5304
+ ASSERT(!touchGamepadRightStick || !touchGamepadButtonCount,
5305
+ 'set touchGamepadRightStick or touchGamepadButtonCount, not both');
5256
5306
 
5257
5307
  if (!touchGamepadTimer.isSet()) return;
5258
5308
 
@@ -5260,23 +5310,24 @@ function inputUpdate()
5260
5310
  gamepadPrimary = 0; // touch gamepad uses index 0
5261
5311
  const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
5262
5312
  const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
5263
- sticks[0] = vec2();
5313
+ sticks.length = 0; // only report sticks that are enabled
5264
5314
  dpad.set();
5265
- const leftTouchStick = touchGamepadSticks[0] ?? vec2();
5266
- if (touchGamepadAnalog)
5267
- sticks[0] = applyDeadZones(leftTouchStick);
5268
- else if (leftTouchStick.lengthSquared() > .3)
5269
- {
5270
- // convert to 8 way dpad
5271
- const x = clamp(round(leftTouchStick.x), -1, 1);
5272
- const y = clamp(round(leftTouchStick.y), -1, 1);
5273
- dpad.set(x, -y);
5274
- sticks[0] = dpad.clampLength(); // clamp to circle
5275
- }
5276
- if (touchGamepadButtonCount === 1)
5315
+ // read each side's directional stick (analog, or quantized to an 8 way dpad)
5316
+ for (let side = 0; side < 2; side++)
5277
5317
  {
5278
- const rightTouchStick = touchGamepadSticks[1] ?? vec2();
5279
- sticks[1] = applyDeadZones(rightTouchStick);
5318
+ if (!touchGamepadSideStick(side)) continue;
5319
+ const out = touchGamepadStickOut(side);
5320
+ sticks[out] = vec2();
5321
+ const touchStick = touchGamepadSticks[side] ?? vec2();
5322
+ if (touchGamepadAnalog)
5323
+ sticks[out] = applyDeadZones(touchStick);
5324
+ else if (touchStick.lengthSquared() > .3)
5325
+ {
5326
+ const x = clamp(round(touchStick.x), -1, 1);
5327
+ const y = clamp(round(touchStick.y), -1, 1);
5328
+ sticks[out] = vec2(x, -y).clampLength(); // clamp to circle
5329
+ if (!out) dpad.set(x, -y); // the primary (stick 0) also drives the dpad vector
5330
+ }
5280
5331
  }
5281
5332
 
5282
5333
  // read virtual gamepad buttons
@@ -5285,6 +5336,12 @@ function inputUpdate()
5285
5336
  {
5286
5337
  const wasDown = gamepadIsDown(i,0);
5287
5338
  data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
5339
+
5340
+ // haptic tap when a face button or start button is first pressed (3 = newly down)
5341
+ // skip stick touches (10, 11) so movement doesn't buzz
5342
+ if (touchGamepadVibration && data[i] === 3 &&
5343
+ (i === 9 || touchGamepadIsFaceButton(i)))
5344
+ vibrate(touchGamepadVibration);
5288
5345
  }
5289
5346
 
5290
5347
  // disable normal gamepads when touch gamepad is active
@@ -5385,80 +5442,470 @@ function inputUpdatePost()
5385
5442
  function inputRender()
5386
5443
  {
5387
5444
  touchGamepadRender();
5445
+ }
5446
+
5447
+ ///////////////////////////////////////////////////////////////////////////////
5448
+ // Touch gamepad - full-viewport HTML/SVG overlay driven by Pointer Events
5449
+
5450
+ const touchGamepadSvgNS = 'http://www.w3.org/2000/svg';
5451
+
5452
+ // build the overlay DOM once; no-op if already built, disabled, headless, or non-touch
5453
+ function touchGamepadInit()
5454
+ {
5455
+ if (touchGamepadOverlay || !touchGamepadEnable || !isTouchDevice || headlessMode ||
5456
+ !document.body) // body may not exist yet; retry on a later frame
5457
+ return;
5458
+
5459
+ // full-viewport overlay; only the input zones receive pointer events. The
5460
+ // env() padding insets the stage out of notches / home indicators natively.
5461
+ const overlay = touchGamepadOverlay = document.createElement('div');
5462
+ overlay.style.cssText =
5463
+ 'position:fixed;inset:0;z-index:50;pointer-events:none;opacity:0;' +
5464
+ 'touch-action:none;user-select:none;-webkit-user-select:none;' +
5465
+ '-webkit-touch-callout:none;transition:opacity .2s;box-sizing:border-box;' +
5466
+ 'padding:env(safe-area-inset-top) env(safe-area-inset-right) ' +
5467
+ 'env(safe-area-inset-bottom) env(safe-area-inset-left)';
5468
+
5469
+ // stage fills the padded (safe-area) content box; all controls live inside it
5470
+ const stage = touchGamepadStage = document.createElement('div');
5471
+ stage.style.cssText = 'position:relative;width:100%;height:100%;pointer-events:none';
5472
+ overlay.appendChild(stage);
5473
+
5474
+ // svg draws every visual and never blocks input
5475
+ const svg = touchGamepadSvg = document.createElementNS(touchGamepadSvgNS, 'svg');
5476
+ svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;' +
5477
+ 'pointer-events:none;overflow:visible;fill:none;stroke:#fff;stroke-width:3';
5478
+ stage.appendChild(svg);
5479
+
5480
+ // invisible input zones (left stick, right buttons/stick, center start)
5481
+ const makeZone = ()=>
5482
+ {
5483
+ const z = document.createElement('div');
5484
+ z.style.cssText = 'position:absolute;pointer-events:auto;touch-action:none';
5485
+ z.addEventListener('pointerdown', e=> touchGamepadPointerDown(e, z));
5486
+ z.addEventListener('pointermove', e=> touchGamepadPointerMove(e));
5487
+ z.addEventListener('pointerup', e=> touchGamepadPointerUp(e));
5488
+ z.addEventListener('pointercancel', e=> touchGamepadPointerUp(e));
5489
+ stage.appendChild(z);
5490
+ return z;
5491
+ };
5492
+ touchGamepadSideZones[0] = makeZone(); // left
5493
+ touchGamepadSideZones[1] = makeZone(); // right
5494
+ touchGamepadZoneC = makeZone(); // center/start, appended last so it sits above the sides
5495
+
5496
+ addEventListener('resize', ()=> touchGamepadNeedRelayout = true);
5497
+ document.body.appendChild(overlay);
5498
+ touchGamepadNeedRelayout = true;
5499
+ }
5500
+
5501
+ // stage-local size in CSS pixels (excludes safe-area insets)
5502
+ function touchGamepadStageRect() { return touchGamepadStage.getBoundingClientRect(); }
5503
+
5504
+ // per-side touch gamepad config (side 0 = left, 1 = right) - the left and right
5505
+ // sides behave identically, differing only in position and gamepad button indices
5506
+ function touchGamepadSideStick(side)
5507
+ { return side ? touchGamepadRightStick : touchGamepadLeftStick; }
5508
+ function touchGamepadSideButtonCount(side)
5509
+ { return side ? touchGamepadButtonCount : touchGamepadLeftButtonCount; }
5510
+ // gamepad button index a side's buttons start at (right 0-3, left 4-7)
5511
+ function touchGamepadSideButtonBase(side)
5512
+ { return side ? 0 : 4; }
5513
+ // output stick index for a side: the right stick uses stick 0 when there is no left stick
5514
+ function touchGamepadStickOut(side)
5515
+ { return side && touchGamepadLeftStick ? 1 : 0; }
5516
+ // true if the side has any control (a stick or at least one button)
5517
+ function touchGamepadSideHasControl(side)
5518
+ { return touchGamepadSideStick(side) || touchGamepadSideButtonCount(side) > 0; }
5519
+
5520
+ // true if gamepad button index i is an active touch gamepad face/single button
5521
+ function touchGamepadIsFaceButton(i)
5522
+ {
5523
+ for (let side = 0; side < 2; side++)
5524
+ {
5525
+ const base = touchGamepadSideButtonBase(side);
5526
+ if (!touchGamepadSideStick(side) &&
5527
+ i >= base && i < base + touchGamepadSideButtonCount(side))
5528
+ return true;
5529
+ }
5530
+ return false;
5531
+ }
5532
+
5533
+ // center of a side's controls in stage-local CSS pixels (stick rest / button cluster)
5534
+ // returns the floating stick anchor when that side is an active floating stick
5535
+ function touchGamepadSideCenter(side, W, H)
5536
+ {
5537
+ if (touchGamepadFloating && touchGamepadSideStick(side) && touchGamepadStickAnchors[side])
5538
+ return touchGamepadStickAnchors[side];
5539
+ let y = H - touchGamepadSize;
5540
+ const count = touchGamepadSideButtonCount(side);
5541
+ if (!touchGamepadSideStick(side) && (count === 2 || count === 3))
5542
+ y -= touchGamepadSize/4; // nudge a 2/3 button cluster up a bit
5543
+ return vec2(side ? W - touchGamepadSize : touchGamepadSize, y);
5544
+ }
5545
+
5546
+ // position the input zones for the current mode and rebuild the SVG visuals
5547
+ function touchGamepadRelayout()
5548
+ {
5549
+ if (!touchGamepadOverlay) return;
5550
+ const r = touchGamepadStageRect();
5551
+ const W = r.width, H = r.height, S = touchGamepadSize;
5552
+ const setZone = (z, css)=> z.style.cssText =
5553
+ 'position:absolute;pointer-events:auto;touch-action:none;' + css;
5388
5554
 
5389
- function touchGamepadRender()
5555
+ if (paused && touchGamepadCenterButtonSize)
5556
+ {
5557
+ // while paused, any touch presses start
5558
+ setZone(touchGamepadZoneC, 'inset:0');
5559
+ for (const zone of touchGamepadSideZones) zone.style.display = 'none';
5560
+ touchGamepadZoneC.style.display = '';
5561
+ }
5562
+ else
5390
5563
  {
5391
- if (!touchInputEnable || !isTouchDevice || headlessMode) return;
5392
- if (!touchGamepadEnable || !touchGamepadTimer.isSet() && touchGamepadDisplayTime) return;
5564
+ // position each side zone (left/right differ only by which edge they hug)
5565
+ for (let side = 0; side < 2; side++)
5566
+ {
5567
+ const zone = touchGamepadSideZones[side], edge = side ? 'right' : 'left';
5568
+ zone.style.display = touchGamepadSideHasControl(side) ? '' : 'none';
5569
+ if (touchGamepadFloating)
5570
+ {
5571
+ // bottom 60% grabs the control; the top 40% passes through. A side with no
5572
+ // control on the other side uses the full width (matching the hit-test)
5573
+ const width = touchGamepadSideHasControl(side ? 0 : 1) ? '50%' : '100%';
5574
+ setZone(zone, `${edge}:0;bottom:0;width:${width};height:60%`);
5575
+ }
5576
+ else // fixed: a compact box hugging the corner control
5577
+ setZone(zone, `${edge}:0;bottom:0;width:${3*S}px;height:${3*S}px`);
5578
+ }
5579
+ touchGamepadZoneC.style.display = touchGamepadCenterButtonSize ? '' : 'none';
5580
+ const c = touchGamepadCenterButtonSize;
5581
+ setZone(touchGamepadZoneC,
5582
+ `left:50%;top:50%;width:${2*c}px;height:${2*c}px;transform:translate(-50%,-50%)`);
5583
+ }
5393
5584
 
5394
- // fade off when not touching or paused
5395
- const alpha = touchGamepadDisplayTime ? percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
5396
- if (!alpha || paused) return;
5585
+ touchGamepadBuildSvg(W, H);
5586
+ touchGamepadNeedRelayout = false;
5587
+ }
5397
5588
 
5398
- // setup the canvas
5399
- const context = mainContext;
5400
- context.save();
5401
- context.globalAlpha = alpha*touchGamepadAlpha;
5402
- context.strokeStyle = '#fff';
5403
- context.lineWidth = 3;
5589
+ // (re)build the SVG shapes for the current layout; dynamic bits update per-frame
5590
+ function touchGamepadBuildSvg(W, H)
5591
+ {
5592
+ const svg = touchGamepadSvg;
5593
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
5594
+ const els = touchGamepadSvgEls = { face: [], thumb: [] };
5595
+ const S = touchGamepadSize;
5596
+ const circle = (cx, cy, rr, fill)=>
5597
+ {
5598
+ const c = document.createElementNS(touchGamepadSvgNS, 'circle');
5599
+ c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', rr);
5600
+ if (fill) c.setAttribute('fill', fill);
5601
+ svg.appendChild(c);
5602
+ return c;
5603
+ };
5604
+ const cross = (ctr)=>
5605
+ {
5606
+ // plus-shaped dpad outline centered at ctr
5607
+ const a = S*.18, b = S*.5, x = ctr.x, y = ctr.y;
5608
+ const p = document.createElementNS(touchGamepadSvgNS, 'path');
5609
+ p.setAttribute('d',
5610
+ `M ${x-a} ${y-b} H ${x+a} V ${y-a} H ${x+b} V ${y+a} H ${x+a} ` +
5611
+ `V ${y+b} H ${x-a} V ${y+a} H ${x-b} V ${y-a} H ${x-a} Z`);
5612
+ svg.appendChild(p);
5613
+ };
5404
5614
 
5405
- // draw left analog stick
5406
- const leftTouchStick = touchGamepadSticks[0] ?? vec2();
5407
- context.fillStyle = leftTouchStick.lengthSquared() > 0 ? '#fff' : '#000';
5408
- context.beginPath();
5409
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5410
- if (touchGamepadAnalog)
5615
+ // draw each side: a directional stick, a single large button, or face buttons
5616
+ for (let side = 0; side < 2; side++)
5617
+ {
5618
+ const count = touchGamepadSideButtonCount(side);
5619
+ const base = touchGamepadSideButtonBase(side);
5620
+ const ctr = touchGamepadSideCenter(side, W, H);
5621
+ if (touchGamepadSideStick(side))
5411
5622
  {
5412
- // draw circle shaped gamepad
5413
- context.arc(stickCenter.x, stickCenter.y, touchGamepadSize/2, 0, 9);
5623
+ // directional stick (circle or cross) with a thumb dot that moves per-frame
5624
+ if (touchGamepadAnalog) circle(ctr.x, ctr.y, S/2); else cross(ctr);
5625
+ els.thumb[side] = circle(ctr.x, ctr.y, S/4, '#fff');
5414
5626
  }
5415
- else
5627
+ else if (count === 1)
5628
+ els.face[base] = circle(ctr.x, ctr.y, S/2, '#000'); // single large button
5629
+ else for (let i = 0; i < count; i++)
5416
5630
  {
5417
- // draw cross shaped gamepad
5418
- for (let i=10; --i;)
5419
- {
5420
- const angle = i*PI/4;
5421
- context.arc(stickCenter.x, stickCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8);
5422
- i%2 && context.arc(stickCenter.x, stickCenter.y, touchGamepadSize*.33, angle, angle);
5423
- }
5631
+ const j = mod(i-1, 4);
5632
+ let button = count > 2 ? j : min(j, count-1);
5633
+ button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
5634
+ const offset = vec2().setDirection(j, S/2);
5635
+ if (count === 2) offset.x *= -1;
5636
+ // left side mirrors the right layout's positions, keeping indices in order
5637
+ // (e.g. 2 buttons -> button 4 at bottom, button 5 at left)
5638
+ if (!side) offset.x *= -1;
5639
+ const pos = ctr.add(offset);
5640
+ els.face[base + button] = circle(pos.x, pos.y, S/4, '#000');
5424
5641
  }
5425
- context.fill();
5426
- context.stroke();
5642
+ }
5643
+
5644
+ // debug: draw the proximity hit regions the hit-test actually uses
5645
+ if (debug && debugGamepads) touchGamepadBuildDebug(W, H);
5646
+ }
5647
+
5648
+ // draw debug outlines of the touch control hit regions into the overlay svg
5649
+ function touchGamepadBuildDebug(W, H)
5650
+ {
5651
+ const S = touchGamepadSize, svg = touchGamepadSvg;
5652
+ const shape = (tag, attrs, stroke)=>
5653
+ {
5654
+ const el = document.createElementNS(touchGamepadSvgNS, tag);
5655
+ for (const k in attrs) el.setAttribute(k, attrs[k]);
5656
+ el.setAttribute('stroke', stroke);
5657
+ el.setAttribute('stroke-width', 2);
5658
+ el.setAttribute('fill', 'none');
5659
+ svg.appendChild(el);
5660
+ };
5661
+ const ring = (c, rr, stroke)=> shape('circle', {cx:c.x, cy:c.y, r:rr}, stroke);
5662
+
5663
+ // green line: the left/right split that assigns a stick press to a side
5664
+ shape('line', {x1:W/2, y1:0, x2:W/2, y2:H}, '#0f0');
5427
5665
 
5428
- // draw right face buttons
5666
+ // cyan: where each side's control can be grabbed
5667
+ for (let side = 0; side < 2; side++)
5668
+ {
5669
+ if (touchGamepadSideStick(side))
5429
5670
  {
5430
- const buttonCenter = touchGamepadButtonCenter();
5431
- const buttonSize = touchGamepadButtonCount > 1 ?
5432
- touchGamepadSize/4 : touchGamepadSize/2;
5433
- for (let i=0; i<touchGamepadButtonCount; i++)
5671
+ if (touchGamepadFloating)
5434
5672
  {
5435
- const j = mod(i-1, 4);
5436
- let button = touchGamepadButtonCount > 2 ?
5437
- j : min(j, touchGamepadButtonCount-1);
5438
- // fix button locations (swap 2 and 3 to match gamepad layout)
5439
- button = button === 3 ? 2 : button === 2 ? 3 : button;
5440
- const pos = touchGamepadButtonCount < 2 ? buttonCenter :
5441
- buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
5442
- context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
5443
- context.beginPath();
5444
- context.arc(pos.x, pos.y, buttonSize, 0,9);
5445
- context.fill();
5446
- context.stroke();
5673
+ // grab region: this side's half (or the full width if the other side is empty)
5674
+ const top = H*.4, full = !touchGamepadSideHasControl(side ? 0 : 1);
5675
+ const x = full ? 0 : (side ? W/2 : 0);
5676
+ shape('rect', {x, y:top, width:full ? W : W/2, height:H-top}, '#0ff');
5447
5677
  }
5678
+ else
5679
+ ring(touchGamepadSideCenter(side, W, H), 2*S, '#0ff');
5448
5680
  }
5681
+ else if (touchGamepadSideButtonCount(side) >= 1)
5682
+ ring(touchGamepadSideCenter(side, W, H), S, '#0ff'); // face / single-button radius
5683
+ }
5449
5684
 
5450
- // set canvas back to normal
5451
- context.restore();
5685
+ // yellow: start button radius; magenta: where start is blocked (near a control)
5686
+ if (touchGamepadCenterButtonSize)
5687
+ {
5688
+ ring(vec2(W/2, H/2), touchGamepadCenterButtonSize, '#ff0');
5689
+ for (let side = 0; side < 2; side++)
5690
+ if (touchGamepadSideHasControl(side))
5691
+ ring(touchGamepadSideCenter(side, W, H), 2*S, '#f0f');
5692
+ }
5693
+ }
5694
+
5695
+ // per-frame: fade the overlay and move the thumbs / set pressed states
5696
+ function touchGamepadRender()
5697
+ {
5698
+ if (!touchGamepadOverlay || headlessMode) return;
5699
+
5700
+ // hide and bail if disabled at runtime (overlay stays in the DOM for reuse)
5701
+ // display:none also takes the input zones out of hit-testing so touches are
5702
+ // not silently captured away from the game while disabled
5703
+ if (!touchGamepadEnable || !isTouchDevice)
5704
+ {
5705
+ if (touchGamepadOverlay.style.display !== 'none')
5706
+ {
5707
+ // just disabled: hide the overlay and release any held controls
5708
+ touchGamepadOverlay.style.display = 'none';
5709
+ touchGamepadPointerRole.clear();
5710
+ touchGamepadButtons.length = 0;
5711
+ touchGamepadSticks.length = 0;
5712
+ touchGamepadStickPointerId.length = 0;
5713
+ }
5714
+ return;
5452
5715
  }
5716
+ touchGamepadOverlay.style.display = '';
5717
+
5718
+ // relayout when the paused state, a layout setting, or the debug view changes
5719
+ const dbg = debug && debugGamepads;
5720
+ const layout = [touchGamepadButtonCount, touchGamepadLeftButtonCount, touchGamepadLeftStick,
5721
+ touchGamepadRightStick, touchGamepadAnalog, touchGamepadSize, touchGamepadFloating,
5722
+ touchGamepadCenterButtonSize, paused, dbg].join();
5723
+ if (layout !== touchGamepadLastLayout)
5724
+ {
5725
+ touchGamepadLastLayout = layout;
5726
+ touchGamepadNeedRelayout = true;
5727
+ }
5728
+ // relayout before the visibility bail-out so the paused full-screen start zone applies
5729
+ if (touchGamepadNeedRelayout) touchGamepadRelayout();
5730
+
5731
+ // fade out when idle (always show when displayTime is 0, or while debugging)
5732
+ const fade = touchGamepadDisplayTime ?
5733
+ percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1;
5734
+ const visible = dbg || (touchGamepadTimer.isSet() && fade > 0 && !paused);
5735
+ touchGamepadOverlay.style.opacity = !visible ? 0 : dbg ? 1 : fade*touchGamepadAlpha;
5736
+ if (!visible) return;
5737
+
5738
+ const r = touchGamepadStageRect();
5739
+ const W = r.width, H = r.height, S = touchGamepadSize;
5740
+ const els = touchGamepadSvgEls;
5741
+ if (!els) return;
5742
+
5743
+ for (let side = 0; side < 2; side++)
5744
+ if (touchGamepadSideStick(side) && els.thumb[side])
5745
+ {
5746
+ const ctr = touchGamepadSideCenter(side, W, H);
5747
+ const t = ctr.add((touchGamepadSticks[side] ?? vec2()).scale(S/2));
5748
+ els.thumb[side].setAttribute('cx', t.x);
5749
+ els.thumb[side].setAttribute('cy', t.y);
5750
+ }
5751
+ for (let i = 0; i < els.face.length; i++)
5752
+ if (els.face[i])
5753
+ els.face[i].setAttribute('fill', touchGamepadButtons[i] ? '#fff' : '#000');
5754
+ }
5755
+
5756
+ // convert a pointer event to stage-local CSS pixels
5757
+ function touchGamepadEventPos(e)
5758
+ {
5759
+ const r = touchGamepadStageRect();
5760
+ return vec2(e.clientX - r.left, e.clientY - r.top);
5761
+ }
5762
+
5763
+ // set a directional stick from a stage-local point and flag its stick-touch button
5764
+ // (stick 0 press = button 10, stick 1 press = button 11, following the output index)
5765
+ function touchGamepadApplyStick(side, p)
5766
+ {
5767
+ const delta = p.subtract(touchGamepadStickAnchors[side]);
5768
+ touchGamepadSticks[side] = delta.scale(2/touchGamepadSize).clampLength();
5769
+ touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10] = 1;
5453
5770
  }
5454
5771
 
5455
- // center position for right touch pad face buttons
5456
- function touchGamepadButtonCenter()
5772
+ // pick a side's gamepad button index from a stage-local point, or -1 if outside the cluster
5773
+ function touchGamepadFaceButtonAt(side, p, W, H)
5457
5774
  {
5458
- const center = mainCanvasSize.subtract(vec2(touchGamepadSize));
5459
- if (touchGamepadButtonCount === 2)
5460
- center.x += touchGamepadSize/2;
5461
- return center;
5775
+ const count = touchGamepadSideButtonCount(side);
5776
+ const base = touchGamepadSideButtonBase(side);
5777
+ const bc = touchGamepadSideCenter(side, W, H);
5778
+ if (bc.distance(p) >= touchGamepadSize) return -1;
5779
+ if (count === 1) return base; // single large button
5780
+ const d = bc.subtract(p);
5781
+ if (!side) d.x *= -1; // left side mirrors the right layout's positions horizontally
5782
+ let button = count === 2 ? (d.x < d.y ? 1 : 0) : mod(d.direction()+2, 4);
5783
+ button = button === 3 ? 2 : button === 2 ? 3 : button; // match gamepad layout
5784
+ return button < count ? base + button : -1;
5785
+ }
5786
+
5787
+ // pick which control a stage-local press activates, by priority then proximity,
5788
+ // independent of which zone element captured it - so overlapping zones on small
5789
+ // screens resolve to the nearest control instead of whichever zone is topmost
5790
+ // returns {role:'stick', side} or {role:'face', btn} or {role:'start'} or undefined
5791
+ function touchGamepadControlAt(p, W, H)
5792
+ {
5793
+ const S = touchGamepadSize;
5794
+ const leftHalf = p.x < W/2;
5795
+ const floatTop = H*.4; // floating grab region is the bottom 60% of the screen
5796
+
5797
+ // check each side (left first for priority); a side is a stick or buttons
5798
+ for (let side = 0; side < 2; side++)
5799
+ {
5800
+ const onHalf = side ? !leftHalf : leftHalf;
5801
+ if (touchGamepadSideStick(side))
5802
+ {
5803
+ // a side with no control on the other side uses the full width
5804
+ const otherControl = touchGamepadSideHasControl(side ? 0 : 1);
5805
+ const grab = touchGamepadFloating ?
5806
+ (!otherControl || onHalf) && p.y > floatTop :
5807
+ onHalf && touchGamepadSideCenter(side, W, H).distance(p) < 2*S;
5808
+ if (grab) return {role:'stick', side};
5809
+ }
5810
+ else if (touchGamepadSideButtonCount(side) >= 1)
5811
+ {
5812
+ const btn = touchGamepadFaceButtonAt(side, p, W, H);
5813
+ if (btn >= 0) return {role:'face', btn};
5814
+ }
5815
+ }
5816
+
5817
+ // center start button, blocked within 2*size of a control so drift off a
5818
+ // control can't accidentally fire start (matches the original exclusion logic)
5819
+ if (touchGamepadCenterButtonSize)
5820
+ {
5821
+ for (let side = 0; side < 2; side++)
5822
+ if (touchGamepadSideHasControl(side) &&
5823
+ touchGamepadSideCenter(side, W, H).distance(p) < 2*S)
5824
+ return;
5825
+ if (vec2(W/2, H/2).distance(p) < touchGamepadCenterButtonSize)
5826
+ return {role:'start'};
5827
+ }
5828
+ }
5829
+
5830
+ function touchGamepadPointerDown(e, zone)
5831
+ {
5832
+ if (!touchGamepadEnable) return;
5833
+ e.preventDefault();
5834
+ zone.setPointerCapture(e.pointerId);
5835
+ touchGamepadTimer.set();
5836
+ isUsingGamepad = true;
5837
+
5838
+ // resume audio on first interaction
5839
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
5840
+ audioContext.resume();
5841
+
5842
+ // while paused, any touch is the start button
5843
+ if (paused)
5844
+ {
5845
+ if (touchGamepadCenterButtonSize)
5846
+ {
5847
+ touchGamepadButtons[9] = 1;
5848
+ touchGamepadPointerRole.set(e.pointerId, 'start');
5849
+ }
5850
+ return;
5851
+ }
5852
+
5853
+ const r = touchGamepadStageRect();
5854
+ const W = r.width, H = r.height;
5855
+ const p = vec2(e.clientX - r.left, e.clientY - r.top);
5856
+
5857
+ // choose the control by proximity/priority, not by which zone captured the touch
5858
+ const hit = touchGamepadControlAt(p, W, H);
5859
+ if (!hit) return;
5860
+ if (hit.role === 'stick')
5861
+ {
5862
+ const side = hit.side;
5863
+ touchGamepadStickAnchors[side] = touchGamepadFloating ? p : touchGamepadSideCenter(side, W, H);
5864
+ touchGamepadStickPointerId[side] = e.pointerId;
5865
+ touchGamepadPointerRole.set(e.pointerId, 'stick'+side);
5866
+ touchGamepadNeedRelayout = true; // base may have re-anchored
5867
+ touchGamepadApplyStick(side, p);
5868
+ }
5869
+ else if (hit.role === 'face')
5870
+ {
5871
+ touchGamepadButtons[hit.btn] = 1;
5872
+ touchGamepadPointerRole.set(e.pointerId, 'face'+hit.btn);
5873
+ }
5874
+ else // 'start'
5875
+ {
5876
+ touchGamepadButtons[9] = 1;
5877
+ touchGamepadPointerRole.set(e.pointerId, 'start');
5878
+ }
5879
+ }
5880
+
5881
+ function touchGamepadPointerMove(e)
5882
+ {
5883
+ const role = touchGamepadPointerRole.get(e.pointerId);
5884
+ if (!role) return;
5885
+ e.preventDefault();
5886
+ const p = touchGamepadEventPos(e);
5887
+ if (role === 'stick0' || role === 'stick1')
5888
+ touchGamepadApplyStick(role === 'stick1' ? 1 : 0, p);
5889
+ // face buttons & start are held until release (no slide-between this pass)
5890
+ }
5891
+
5892
+ function touchGamepadPointerUp(e)
5893
+ {
5894
+ const role = touchGamepadPointerRole.get(e.pointerId);
5895
+ if (!role) return;
5896
+ touchGamepadPointerRole.delete(e.pointerId);
5897
+ if (role === 'stick0' || role === 'stick1')
5898
+ {
5899
+ const side = role === 'stick1' ? 1 : 0;
5900
+ touchGamepadStickPointerId[side] = undefined;
5901
+ touchGamepadSticks[side] = vec2();
5902
+ delete touchGamepadButtons[touchGamepadStickOut(side) ? 11 : 10];
5903
+ }
5904
+ else if (role === 'start')
5905
+ delete touchGamepadButtons[9];
5906
+ else // 'face<n>'
5907
+ delete touchGamepadButtons[+role.slice(4)];
5908
+ touchGamepadTimer.set();
5462
5909
  }
5463
5910
  /**
5464
5911
  * LittleJS Audio System
@@ -5595,7 +6042,7 @@ class Sound
5595
6042
  * @param {number} [randomnessScale] - How much to scale pitch randomness
5596
6043
  * @param {boolean} [loop] - Should the sound loop?
5597
6044
  * @param {boolean} [paused] - Should the sound start paused
5598
- * @return {SoundInstance} - The audio source node
6045
+ * @return {SoundInstance} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode
5599
6046
  */
5600
6047
  play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
5601
6048
  {
@@ -5626,9 +6073,23 @@ class Sound
5626
6073
  pan = worldToScreen(pos).x * 2/mainCanvas.width - 1;
5627
6074
  }
5628
6075
 
5629
- // Create and return sound instance
6076
+ // Create sound instance
5630
6077
  const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
5631
- return new SoundInstance(this, volume, rate, pan, loop, paused);
6078
+ const instance = new SoundInstance(this, volume, rate, pan, loop, paused);
6079
+
6080
+ if (debug && debugSound && pos)
6081
+ {
6082
+ // visualize where positioned sounds play and their falloff range
6083
+ debugCircle(pos, .5, '#0ff', .5, true);
6084
+ if (this.range)
6085
+ {
6086
+ debugCircle(pos, 2*this.range, '#0ff', .5); // silent radius
6087
+ debugCircle(pos, 2*this.range*this.taper, '#0ff', .5); // full volume radius
6088
+ }
6089
+ debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5);
6090
+ }
6091
+
6092
+ return instance;
5632
6093
  }
5633
6094
 
5634
6095
  /** Play a music track that loops by default
@@ -5994,6 +6455,10 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
5994
6455
  // play and return sound
5995
6456
  const startOffset = offset * rate;
5996
6457
  source.start(0, startOffset);
6458
+
6459
+ if (debug && debugSound)
6460
+ LOG('sound', 'vol', volume.toFixed(2), 'rate', rate.toFixed(2), 'pan', pan.toFixed(2), loop ? 'loop' : '');
6461
+
5997
6462
  return source;
5998
6463
  }
5999
6464
 
@@ -6827,6 +7292,11 @@ class TileCollisionLayer extends TileLayer
6827
7292
  // check any tiles in the area for collision
6828
7293
  const posX = pos.x - this.pos.x;
6829
7294
  const posY = pos.y - this.pos.y;
7295
+ // reject AABBs entirely past either edge; without this, the negative
7296
+ // side leaks into row/col 0 because minX/minY clamp to 0 and the
7297
+ // point-test floor below forces maxX/maxY up to 1
7298
+ if (posX + size.x/2 < 0 || posX - size.x/2 > this.size.x) return false;
7299
+ if (posY + size.y/2 < 0 || posY - size.y/2 > this.size.y) return false;
6830
7300
  const minX = max(posX - size.x/2|0, 0);
6831
7301
  const minY = max(posY - size.y/2|0, 0);
6832
7302
  // ensure at least one cell is visited even when size is 0 and pos
@@ -6945,10 +7415,10 @@ class ParticleEmitter extends EngineObject
6945
7415
  * @param {number} [particleTime] - How long particles live
6946
7416
  * @param {number} [sizeStart] - How big are particles at start
6947
7417
  * @param {number} [sizeEnd] - How big are particles at end
6948
- * @param {number} [speed] - How fast are particles when spawned
6949
- * @param {number} [angleSpeed] - How fast are particles rotating
6950
- * @param {number} [damping] - How much to dampen particle speed
6951
- * @param {number} [angleDamping] - How much to dampen particle angular speed
7418
+ * @param {number} [speed] - How fast are particles when spawned, in world units per frame (at 60fps, so multiply units/sec by 1/60)
7419
+ * @param {number} [angleSpeed] - How fast are particles rotating, in radians per frame (at 60fps)
7420
+ * @param {number} [damping] - How much to dampen particle speed, per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame)
7421
+ * @param {number} [angleDamping] - How much to dampen particle angular speed, per-frame multiplier (1 = no damping)
6952
7422
  * @param {number} [gravityScale] - How much gravity effect particles
6953
7423
  * @param {number} [particleConeAngle] - Cone for start particle angle
6954
7424
  * @param {number} [fadeRate] - Fraction of life spent fading: half at fade-in (start), half at fade-out (end). e.g. .2 = 10% fade-in, 80% full opacity, 10% fade-out
@@ -7023,13 +7493,13 @@ class ParticleEmitter extends EngineObject
7023
7493
  this.sizeStart = sizeStart;
7024
7494
  /** @property {number} - How big are particles at end */
7025
7495
  this.sizeEnd = sizeEnd;
7026
- /** @property {number} - How fast are particles when spawned */
7496
+ /** @property {number} - Particle speed when spawned, in world units per frame (at 60fps) */
7027
7497
  this.speed = speed;
7028
- /** @property {number} - How fast are particles rotating */
7498
+ /** @property {number} - Particle angular speed when spawned, in radians per frame (at 60fps) */
7029
7499
  this.angleSpeed = angleSpeed;
7030
- /** @property {number} - How much to dampen particle speed */
7500
+ /** @property {number} - Per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame) */
7031
7501
  this.damping = damping;
7032
- /** @property {number} - How much to dampen particle angular speed */
7502
+ /** @property {number} - Per-frame angular velocity multiplier (1 = no damping) */
7033
7503
  this.angleDamping = angleDamping;
7034
7504
  /** @property {number} - How much gravity affects particles */
7035
7505
  this.gravityScale = gravityScale;
@@ -7098,12 +7568,16 @@ class ParticleEmitter extends EngineObject
7098
7568
  else if (this.particles.length === 0)
7099
7569
  this.destroy(true);
7100
7570
 
7101
- // update and remove destroyed particles
7102
- this.particles = this.particles.filter((p)=>
7571
+ // update and remove destroyed particles in place to avoid per-frame array allocation
7572
+ const particles = this.particles;
7573
+ let alive = 0;
7574
+ for (let i = 0; i < particles.length; ++i)
7103
7575
  {
7576
+ const p = particles[i];
7104
7577
  p.update();
7105
- return !p.destroyed;
7106
- });
7578
+ if (!p.destroyed) particles[alive++] = p;
7579
+ }
7580
+ particles.length = alive;
7107
7581
 
7108
7582
  if (debugParticles)
7109
7583
  {
@@ -7385,7 +7859,7 @@ class Particle
7385
7859
  {
7386
7860
  // in local space of emitter
7387
7861
  const a = emitter.angle;
7388
- const c = cos(a), s = sin(a);
7862
+ const c = cos(-a), s = sin(-a);
7389
7863
  pos.set(emitter.pos.x + pos.x*c - pos.y*s,
7390
7864
  emitter.pos.y + pos.x*s + pos.y*c);
7391
7865
  angle += a;
@@ -7393,8 +7867,8 @@ class Particle
7393
7867
  if (trailScale)
7394
7868
  {
7395
7869
  // trail style particles
7396
- const velocity = localSpace ?
7397
- this.velocity.rotate(-emitter.angle) : this.velocity;
7870
+ const velocity = localSpace ?
7871
+ this.velocity.rotate(emitter.angle) : this.velocity;
7398
7872
  const speed = velocity.length();
7399
7873
  if (speed)
7400
7874
  {
@@ -8556,7 +9030,15 @@ function medalsInit(saveName)
8556
9030
  // check if medals are unlocked
8557
9031
  medalsSaveName = saveName;
8558
9032
  if (!debugMedals)
8559
- medalsForEach(medal=> medal.unlocked = !!localStorage[medal.storageKey()]);
9033
+ {
9034
+ let saved = {};
9035
+ try { saved = JSON.parse(localStorage[saveName] || '{}'); }
9036
+ catch (e) { saved = {}; }
9037
+ medalsForEach(medal => {
9038
+ medal.unlocked = !!(saved[medal.id] && saved[medal.id].unlocked);
9039
+ });
9040
+ medalsSave();
9041
+ }
8560
9042
 
8561
9043
  // engine automatically renders medals
8562
9044
  engineAddPlugin(undefined, medalsRender);
@@ -8600,6 +9082,31 @@ function medalsInit(saveName)
8600
9082
  function medalsForEach(callback)
8601
9083
  { Object.values(medals).forEach(medal=>callback(medal)); }
8602
9084
 
9085
+ /** Reset all medals to locked and persist the cleared catalog
9086
+ * @memberof Medals */
9087
+ function medalsReset()
9088
+ {
9089
+ medalsForEach(medal => medal.unlocked = false);
9090
+ medalsSave();
9091
+ }
9092
+
9093
+ function medalsSave()
9094
+ {
9095
+ if (!medalsSaveName) return;
9096
+ const data = {};
9097
+ medalsForEach(medal => {
9098
+ const entry = {
9099
+ name: medal.name,
9100
+ description: medal.description,
9101
+ icon: medal.icon,
9102
+ unlocked: medal.unlocked,
9103
+ };
9104
+ if (medal.image) entry.src = medal.image.src;
9105
+ data[medal.id] = entry;
9106
+ });
9107
+ localStorage[medalsSaveName] = JSON.stringify(data);
9108
+ }
9109
+
8603
9110
  ///////////////////////////////////////////////////////////////////////////////
8604
9111
 
8605
9112
  /**
@@ -8657,9 +9164,9 @@ class Medal
8657
9164
  {
8658
9165
  if (medalsPreventUnlock || this.unlocked) return;
8659
9166
 
8660
- // save the medal
8661
9167
  ASSERT(medalsSaveName, 'save name must be set');
8662
- localStorage[this.storageKey()] = this.unlocked = true;
9168
+ this.unlocked = true;
9169
+ medalsSave();
8663
9170
  medalsDisplayQueue.push(this);
8664
9171
  }
8665
9172
 
@@ -8717,8 +9224,6 @@ class Medal
8717
9224
  drawTextScreen(this.icon, pos, size*.7, BLACK);
8718
9225
  }
8719
9226
 
8720
- // Get local storage key used by the medal
8721
- storageKey() { return medalsSaveName + '_' + this.id; }
8722
9227
  }
8723
9228
 
8724
9229
  ///////////////////////////////////////////////////////////////////////////////
@@ -9103,6 +9608,335 @@ class PostProcessPlugin
9103
9608
  }
9104
9609
  }
9105
9610
  }
9611
+ /**
9612
+ * LittleJS Light System Plugin
9613
+ * - Adds 2D dynamic lighting to the scene
9614
+ * - Lights are first-class EngineObjects (the Light class)
9615
+ * - Each Light draws a soft falloff blob of its color into a shared lightmap
9616
+ * - Lights accumulate ADDITIVELY in the lightmap (red + blue = magenta)
9617
+ * - The lightmap is then MULTIPLIED with the scene during composite, so unlit
9618
+ * areas go to the ambient color and lit areas show the scene tinted by the
9619
+ * accumulated light color
9620
+ * - Draw the world at full brightness — the lightmap does the darkening
9621
+ * - Any EngineObject may override renderLight() to additively contribute to the
9622
+ * lightmap (e.g. emissive lava tiles, weapon flashes, glowing crystals)
9623
+ * - Must be constructed BEFORE PostProcessPlugin so post-process sees lit pixels
9624
+ * @namespace LightSystem
9625
+ */
9626
+
9627
+ ///////////////////////////////////////////////////////////////////////////////
9628
+
9629
+ /** Global Light System plugin object
9630
+ * @type {LightSystemPlugin}
9631
+ * @memberof LightSystem */
9632
+ let lightSystem;
9633
+
9634
+ ///////////////////////////////////////////////////////////////////////////////
9635
+
9636
+ /**
9637
+ * LightSystemPlugin
9638
+ * - Owns the offscreen lightmap texture, falloff/composite shaders, and the
9639
+ * per-frame render pass that multiplies the lightmap onto the WebGL scene
9640
+ * - The composite is MULTIPLICATIVE: unlit areas get the ambient color, lit
9641
+ * areas show the scene tinted by the accumulated light color. So you should
9642
+ * draw your world at full brightness — the lightmap handles the darkening.
9643
+ * @memberof LightSystem
9644
+ */
9645
+ class LightSystemPlugin
9646
+ {
9647
+ /** Create the global light system plugin.
9648
+ * @param {Vector2} [textureSize] - Size of the lightmap texture (defaults to mainCanvasSize)
9649
+ * @param {Color} [ambientColor] - Color applied to unlit areas of the scene (defaults to BLACK = pitch dark). Set a small RGB like rgb(0.1,0.1,0.15) for a faint "moonlight" baseline so unlit areas aren't fully black.
9650
+ * @example
9651
+ * // simplest usage
9652
+ * new LightSystemPlugin();
9653
+ */
9654
+ constructor(textureSize, ambientColor)
9655
+ {
9656
+ ASSERT(!lightSystem, 'LightSystemPlugin already initialized');
9657
+ ASSERT(!postProcess, 'LightSystemPlugin must be created before PostProcessPlugin');
9658
+ lightSystem = this;
9659
+
9660
+ /** @property {boolean} - When false, the render pass is skipped entirely */
9661
+ this.enabled = true;
9662
+ /** @property {Color} - Baseline color applied to unlit areas of the scene. Defaults to BLACK (pitch dark). Set to a small RGB for a faint ambient. The lightmap is cleared to this color each frame, then lights add on top, then the result multiplies the scene. */
9663
+ this.ambientColor = (ambientColor || BLACK).copy();
9664
+ /** @property {Vector2} - Size of the lightmap texture (set at construction; falls back to mainCanvasSize at init time) */
9665
+ this.textureSize = textureSize ? textureSize.copy() : undefined;
9666
+
9667
+ /** @property {WebGLTexture} - The lightmap texture */
9668
+ this.texture = undefined;
9669
+ /** @property {WebGLProgram} - Shader for drawing per-Light falloff blobs into the lightmap */
9670
+ this.lightShader = undefined;
9671
+ /** @property {WebGLProgram} - Shader for compositing the lightmap over the main scene */
9672
+ this.compositeShader = undefined;
9673
+ /** @property {WebGLVertexArrayObject} - Vertex array object for the light shader */
9674
+ this.lightVAO = undefined;
9675
+ /** @property {WebGLVertexArrayObject} - Vertex array object for the composite shader */
9676
+ this.compositeVAO = undefined;
9677
+
9678
+ initLightSystem();
9679
+ engineAddPlugin(undefined, lightSystemRender,
9680
+ lightSystemContextLost, lightSystemContextRestored);
9681
+
9682
+ function initLightSystem()
9683
+ {
9684
+ if (headlessMode) return;
9685
+ if (!glEnable)
9686
+ {
9687
+ console.warn('LightSystemPlugin: WebGL not enabled!');
9688
+ return;
9689
+ }
9690
+
9691
+ // resolve texture size default at init time (mainCanvasSize may
9692
+ // not be set yet at the moment the constructor first ran)
9693
+ if (!lightSystem.textureSize)
9694
+ lightSystem.textureSize = mainCanvasSize.copy();
9695
+
9696
+ // allocate the lightmap texture with null data at textureSize
9697
+ lightSystem.texture = glContext.createTexture();
9698
+ glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
9699
+ glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA,
9700
+ lightSystem.textureSize.x, lightSystem.textureSize.y, 0,
9701
+ glContext.RGBA, glContext.UNSIGNED_BYTE, null);
9702
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, glContext.LINEAR);
9703
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, glContext.LINEAR);
9704
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, glContext.CLAMP_TO_EDGE);
9705
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, glContext.CLAMP_TO_EDGE);
9706
+
9707
+ // light falloff shader: one quad per Light, fragment computes radial falloff
9708
+ lightSystem.lightShader = glCreateProgram(
9709
+ '#version 300 es\n' +
9710
+ 'precision highp float;'+
9711
+ 'uniform mat4 m;'+
9712
+ 'uniform vec2 lightPos;'+
9713
+ 'uniform float radius;'+
9714
+ 'in vec2 g;'+ // unit quad geometry [0..1]
9715
+ 'out vec2 vWorldPos;'+
9716
+ 'void main(){'+
9717
+ 'vec2 worldP=lightPos+(g-.5)*2.*radius;'+
9718
+ 'gl_Position=m*vec4(worldP,1,1);'+
9719
+ 'vWorldPos=worldP;'+
9720
+ '}'
9721
+ ,
9722
+ '#version 300 es\n' +
9723
+ 'precision highp float;'+
9724
+ 'uniform vec2 lightPos;'+
9725
+ 'uniform float radius;'+
9726
+ 'uniform float fadeRange;'+
9727
+ 'uniform vec4 color;'+
9728
+ 'in vec2 vWorldPos;'+
9729
+ 'out vec4 c;'+
9730
+ 'void main(){'+
9731
+ 'float dist=distance(vWorldPos,lightPos);'+
9732
+ 'float t=clamp((radius-dist)/max(fadeRange,1e-6),0.,1.);'+
9733
+ 'c=vec4(color.rgb*t*color.a,1.);'+
9734
+ '}'
9735
+ );
9736
+
9737
+ // composite shader: fullscreen quad, samples the lightmap
9738
+ lightSystem.compositeShader = glCreateProgram(
9739
+ '#version 300 es\n' +
9740
+ 'precision highp float;'+
9741
+ 'in vec2 p;'+
9742
+ 'void main(){'+
9743
+ 'gl_Position=vec4(p+p-1.,1,1);'+
9744
+ '}'
9745
+ ,
9746
+ '#version 300 es\n' +
9747
+ 'precision highp float;'+
9748
+ 'uniform sampler2D s;'+
9749
+ 'uniform vec3 iResolution;'+
9750
+ 'out vec4 c;'+
9751
+ 'void main(){'+
9752
+ 'vec2 uv=gl_FragCoord.xy/iResolution.xy;'+
9753
+ 'c=vec4(texture(s,uv).rgb,1.);'+
9754
+ '}'
9755
+ );
9756
+
9757
+ // VAO for the per-Light quad — reuses the engine unit triangle-strip
9758
+ lightSystem.lightVAO = glContext.createVertexArray();
9759
+ glContext.bindVertexArray(lightSystem.lightVAO);
9760
+ glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer);
9761
+ const gLight = glContext.getAttribLocation(lightSystem.lightShader, 'g');
9762
+ glContext.enableVertexAttribArray(gLight);
9763
+ glContext.vertexAttribPointer(gLight, 2, glContext.FLOAT, false, 8, 0);
9764
+
9765
+ // VAO for the composite fullscreen quad — same buffer, attribute named 'p'
9766
+ lightSystem.compositeVAO = glContext.createVertexArray();
9767
+ glContext.bindVertexArray(lightSystem.compositeVAO);
9768
+ glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer);
9769
+ const pComp = glContext.getAttribLocation(lightSystem.compositeShader, 'p');
9770
+ glContext.enableVertexAttribArray(pComp);
9771
+ glContext.vertexAttribPointer(pComp, 2, glContext.FLOAT, false, 8, 0);
9772
+ }
9773
+ function lightSystemRender()
9774
+ {
9775
+ if (headlessMode || !glEnable) return;
9776
+ if (!lightSystem.enabled) return;
9777
+ if (!lightSystem.texture) return; // init failed or context lost
9778
+
9779
+ // 1. flush any in-flight sprite batch from earlier render passes
9780
+ glFlush();
9781
+ const prevAdditive = glAdditive;
9782
+
9783
+ // 2. bind lightmap as render target, clear to ambientColor
9784
+ const ac = lightSystem.ambientColor;
9785
+ glContext.bindFramebuffer(glContext.FRAMEBUFFER, glFramebuffer);
9786
+ glContext.framebufferTexture2D(glContext.FRAMEBUFFER,
9787
+ glContext.COLOR_ATTACHMENT0, glContext.TEXTURE_2D, lightSystem.texture, 0);
9788
+ glContext.viewport(0, 0, lightSystem.textureSize.x, lightSystem.textureSize.y);
9789
+ glContext.clearColor(ac.r, ac.g, ac.b, ac.a);
9790
+ glContext.clear(glContext.COLOR_BUFFER_BIT);
9791
+
9792
+ // 3. walk engineObjects calling renderLight() — additive blend
9793
+ // (lightmap accumulates raw additive color contributions)
9794
+ setBlendMode(true);
9795
+ glContext.enable(glContext.BLEND);
9796
+ glContext.blendFunc(glContext.ONE, glContext.ONE);
9797
+
9798
+ for (const o of engineObjects)
9799
+ o.destroyed || o.renderLight();
9800
+
9801
+ // 4. drain any sprite-batched draws (e.g. drawTile inside a
9802
+ // custom renderLight override) so they hit the FBO, not the
9803
+ // canvas after we unbind
9804
+ glFlush();
9805
+ glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
9806
+ glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y);
9807
+
9808
+ // 5. composite: fullscreen quad, multiplicative blend onto glCanvas
9809
+ // (scene * lightmap — unlit areas go to black, lit areas are
9810
+ // the scene tinted by the accumulated light color)
9811
+ glContext.useProgram(lightSystem.compositeShader);
9812
+ glContext.bindVertexArray(lightSystem.compositeVAO);
9813
+ glContext.activeTexture(glContext.TEXTURE0);
9814
+ glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
9815
+ const cs = lightSystem.compositeShader;
9816
+ glContext.uniform1i(glContext.getUniformLocation(cs, 's'), 0);
9817
+ glContext.uniform3f(glContext.getUniformLocation(cs, 'iResolution'),
9818
+ mainCanvas.width, mainCanvas.height, 1);
9819
+ glContext.blendFunc(glContext.DST_COLOR, glContext.ZERO);
9820
+ glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
9821
+
9822
+ // 6. restore engine state so subsequent draws use the engine's
9823
+ // tracked texture binding (otherwise glSetTexture would think
9824
+ // the prior texture was still bound when actually the lightmap
9825
+ // is, and any debug text / future draw could sample the lightmap)
9826
+ if (glActiveTexture)
9827
+ glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
9828
+ setBlendMode(prevAdditive);
9829
+ glSetInstancedMode(true);
9830
+ }
9831
+ function lightSystemContextLost()
9832
+ {
9833
+ lightSystem.texture = undefined;
9834
+ lightSystem.lightShader = undefined;
9835
+ lightSystem.compositeShader = undefined;
9836
+ lightSystem.lightVAO = undefined;
9837
+ lightSystem.compositeVAO = undefined;
9838
+ LOG('LightSystemPlugin: WebGL context lost');
9839
+ }
9840
+ function lightSystemContextRestored()
9841
+ {
9842
+ initLightSystem();
9843
+ LOG('LightSystemPlugin: WebGL context restored');
9844
+ }
9845
+ }
9846
+
9847
+ /** Draw a single Light's falloff blob into the currently bound lightmap.
9848
+ * Called by Light.renderLight() during the plugin's render pass.
9849
+ * @param {Light} light */
9850
+ drawLight(light)
9851
+ {
9852
+ if (headlessMode || !glEnable || !this.lightShader) return;
9853
+
9854
+ // drain any sprite-batched draws queued by a previous custom
9855
+ // renderLight() override (e.g. drawRect inside a LavaTile). They were
9856
+ // queued in the engine's instanced-vertex format and must flush with
9857
+ // the engine's shader+VAO bound — NOT this plugin's light shader.
9858
+ glFlush();
9859
+
9860
+ glContext.useProgram(this.lightShader);
9861
+ glContext.bindVertexArray(this.lightVAO);
9862
+
9863
+ // re-apply the engine camera transform onto this shader. Divide by
9864
+ // mainCanvasSize (not textureSize) so world→NDC matches the main
9865
+ // pass; the viewport handles the lightmap's actual resolution.
9866
+ // No y-flip here: the composite samples this FBO with
9867
+ // gl_FragCoord/iResolution (origin bottom-left), so storing world
9868
+ // +Y at the top of the texture lines up with the canvas convention.
9869
+ const s = vec2(2*cameraScale).divide(mainCanvasSize);
9870
+ const rotatedCam = cameraPos.rotate(-cameraAngle);
9871
+ const p = vec2(-1).subtract(rotatedCam.multiply(s));
9872
+ const ca = cos(cameraAngle);
9873
+ const sa = sin(cameraAngle);
9874
+ const transform = [
9875
+ s.x * ca, s.y * sa, 0, 0,
9876
+ -s.x * sa, s.y * ca, 0, 0,
9877
+ 1, 1, 1, 0,
9878
+ p.x, p.y, 0, 1];
9879
+
9880
+ const ls = this.lightShader;
9881
+ glContext.uniformMatrix4fv(glContext.getUniformLocation(ls, 'm'), false, transform);
9882
+ glContext.uniform2f(glContext.getUniformLocation(ls, 'lightPos'), light.pos.x, light.pos.y);
9883
+ glContext.uniform1f(glContext.getUniformLocation(ls, 'radius'), light.radius);
9884
+ glContext.uniform1f(glContext.getUniformLocation(ls, 'fadeRange'), light.fadeRange);
9885
+ const c = light.color;
9886
+ glContext.uniform4f(glContext.getUniformLocation(ls, 'color'), c.r, c.g, c.b, c.a);
9887
+
9888
+ glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
9889
+
9890
+ // restore engine's instanced shader+VAO so subsequent renderLight()
9891
+ // overrides that batch through drawRect/drawTile work correctly
9892
+ glSetInstancedMode(true);
9893
+ }
9894
+ }
9895
+
9896
+ ///////////////////////////////////////////////////////////////////////////////
9897
+
9898
+ /**
9899
+ * A Light is an EngineObject that contributes a soft additive blob of color
9900
+ * to the LightSystem plugin's lightmap.
9901
+ * @extends EngineObject
9902
+ * @memberof LightSystem
9903
+ * @example
9904
+ * new Light(vec2(5, 5), 4, rgb(1, 0.5, 0)); // orange light, full soft blob
9905
+ * new Light(vec2(0, 0), 8, rgb(1, 1, 1), 2); // white core with 2-unit soft halo
9906
+ */
9907
+ class Light extends EngineObject
9908
+ {
9909
+ /** Create a light object and add it to the engine object list
9910
+ * @param {Vector2} pos - World space position
9911
+ * @param {number} radius - Total extent of the light in world units
9912
+ * @param {Color} [color] - Color of the light; alpha modulates intensity
9913
+ * @param {number} [fadeRange] - Width of the soft edge in world units (defaults to radius) */
9914
+ constructor(pos, radius, color, fadeRange)
9915
+ {
9916
+ super(pos, vec2(1), undefined, 0, color);
9917
+ ASSERT(isNumber(radius) && radius >= 0, 'Light radius must be a non-negative number');
9918
+ ASSERT(fadeRange === undefined || (isNumber(fadeRange) && fadeRange >= 0),
9919
+ 'Light fadeRange must be a non-negative number when provided');
9920
+
9921
+ /** @property {number} - Total extent of the light in world units */
9922
+ this.radius = radius;
9923
+ /** @property {number} - Width of the soft edge in world units */
9924
+ this.fadeRange = fadeRange === undefined ? radius : fadeRange;
9925
+ }
9926
+
9927
+ /** Lights are invisible in the main render pass — they only contribute
9928
+ * to the lightmap via renderLight(). */
9929
+ render() {}
9930
+
9931
+ /** Draw this light's falloff blob into the lightmap.
9932
+ * Called by LightSystemPlugin during its render pass. No-op when the
9933
+ * plugin or WebGL is unavailable. */
9934
+ renderLight()
9935
+ {
9936
+ lightSystem && lightSystem.drawLight(this);
9937
+ }
9938
+ }
9939
+
9106
9940
  /**
9107
9941
  * LittleJS ZzFXM Plugin
9108
9942
  * @namespace ZzFXM
@@ -11150,23 +11984,19 @@ class Box2dObject extends EngineObject
11150
11984
 
11151
11985
  function box2dCreatePolygonShape(points)
11152
11986
  {
11153
- function box2dCreatePointList(points)
11987
+ ASSERT(3 <= points.length && points.length <= 8);
11988
+ const buffer = box2d.instance._malloc(points.length * 8);
11989
+ for (let i=0, offset=0; i<points.length; ++i)
11154
11990
  {
11155
- const buffer = box2d.instance._malloc(points.length * 8);
11156
- for (let i=0, offset=0; i<points.length; ++i)
11157
- {
11158
- box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].x;
11159
- offset += 4;
11160
- box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].y;
11161
- offset += 4;
11162
- }
11163
- return box2d.instance.wrapPointer(buffer, box2d.instance.b2Vec2);
11991
+ box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].x;
11992
+ offset += 4;
11993
+ box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].y;
11994
+ offset += 4;
11164
11995
  }
11165
-
11166
- ASSERT(3 <= points.length && points.length <= 8);
11996
+ const box2dPoints = box2d.instance.wrapPointer(buffer, box2d.instance.b2Vec2);
11167
11997
  const shape = new box2d.instance.b2PolygonShape();
11168
- const box2dPoints = box2dCreatePointList(points);
11169
11998
  shape.Set(box2dPoints, points.length);
11999
+ box2d.instance._free(buffer);
11170
12000
  return shape;
11171
12001
  }
11172
12002
 
@@ -13147,6 +13977,69 @@ function drawThreeSlice(pos, size, startTile, color, borderSize=1, additiveColor
13147
13977
  const cornerPos = cornerOffset.multiply(vec2(flipX?-1:1, flipY?-flip:flip));
13148
13978
  drawTile(pos.add(cornerPos.rotate(rotateAngle)), cornerSize, cornerTile, color, a, false, additiveColor, useWebGL, screenSpace, context);
13149
13979
  }
13980
+ }
13981
+
13982
+ /** Draw a crescent / moon-phase shape built from a polygon
13983
+ * Routes through drawPoly, so it supports WebGL, screen space, color, and outlines
13984
+ * @param {Vector2} pos - Center position
13985
+ * @param {number} [size] - Diameter
13986
+ * @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
13987
+ * @param {Color} [color] - Fill color
13988
+ * @param {number} [angle] - Angle to rotate by
13989
+ * @param {boolean} [invert] - Flip which side is illuminated
13990
+ * @param {number} [lineWidth] - Outline width, 0 for no outline
13991
+ * @param {Color} [lineColor] - Outline color
13992
+ * @param {boolean} [useWebGL=glEnable] - Use WebGL for rendering
13993
+ * @param {boolean} [screenSpace] - Use screen space coordinates
13994
+ * @param {CanvasRenderingContext2D} [context] - Canvas context to use
13995
+ * @memberof DrawUtilities */
13996
+ function drawCrescent(pos, size=1, percent=0, color=WHITE, angle=0, invert=false, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=false, context)
13997
+ {
13998
+ const points = getCrescentPoints(pos, size, percent, angle, invert);
13999
+ drawPoly(points, color, lineWidth, lineColor, vec2(), 0, useWebGL, screenSpace, context);
14000
+ }
14001
+
14002
+ /** Get the list of points that make up a crescent / moon-phase shape
14003
+ * Returns world-space points with pos and angle baked in, ready for drawPoly or other use
14004
+ * @param {Vector2} pos - Center position
14005
+ * @param {number} [size] - Diameter
14006
+ * @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
14007
+ * @param {number} [angle] - Angle to rotate by
14008
+ * @param {boolean} [invert] - Flip which side is illuminated
14009
+ * @param {number} [sides=glCircleSides] - Number of sides for a full circle (halved per arc)
14010
+ * @return {Array<Vector2>} - List of points making up the crescent
14011
+ * @memberof DrawUtilities */
14012
+ function getCrescentPoints(pos, size=1, percent=0, angle=0, invert=false, sides=glCircleSides)
14013
+ {
14014
+ ASSERT(isVector2(pos), 'pos must be a vec2');
14015
+ ASSERT(isNumber(size) && isNumber(percent), 'size and percent must be numbers');
14016
+
14017
+ // map phase to a signed terminator curve: -1 new, 0 half, 1 full
14018
+ let p = mod(percent*4, 4); // quarter phase 0..4
14019
+ if (p >= 2) // second half of cycle flips orientation
14020
+ angle += PI;
14021
+ p = p <= 2 ? p-1 : 3-p;
14022
+ if (invert) // flip the illuminated side
14023
+ {
14024
+ p = -p;
14025
+ angle += PI;
14026
+ }
14027
+
14028
+ // build the crescent: outer semicircle, then inner half-ellipse traced back
14029
+ const points = [];
14030
+ const segs = max(3, sides>>1);
14031
+ const radius = size/2;
14032
+ for (let i=0; i<=segs; i++)
14033
+ {
14034
+ const t = i/segs*PI;
14035
+ points.push(vec2(radius*cos(t), radius*sin(t)).rotate(angle).add(pos));
14036
+ }
14037
+ for (let i=segs; i>=0; i--)
14038
+ {
14039
+ const t = i/segs*PI;
14040
+ points.push(vec2(radius*cos(t), -radius*p*sin(t)).rotate(angle).add(pos));
14041
+ }
14042
+ return points;
13150
14043
  }
13151
14044
  /**
13152
14045
  * LittleJS Tween System Plugin