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