littlejsengine 1.18.15 → 1.18.17

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