littlejsengine 1.18.1 → 1.18.4

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.1';
38
+ const engineVersion = '1.18.4';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -69,7 +69,7 @@ let frame = 0;
69
69
  * @memberof Engine */
70
70
  let time = 0;
71
71
 
72
- /** Actual clock time since start in seconds (not affected by pause or frame rate clamping)
72
+ /** Actual clock time since start in seconds (not affected by pause, timescale, or frame rate clamping)
73
73
  * @type {number}
74
74
  * @memberof Engine */
75
75
  let timeReal = 0;
@@ -200,11 +200,14 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
200
200
  averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
201
201
  const debugSpeedUp = debug && keyIsDown('Equal'); // +
202
202
  const debugSpeedDown = debug && keyIsDown('Minus'); // -
203
- if (debug) // +/- to speed/slow time
204
- frameTimeDeltaMS *= debugSpeedUp ? 10 : debugSpeedDown ? .1 : 1;
205
- timeReal += frameTimeDeltaMS / 1e3;
203
+ const debugScale = debugSpeedUp ? 10 : debugSpeedDown ? .1 : 1;
204
+
205
+ // apply time deltas
206
+ timeReal += frameTimeDeltaMS * debugScale / 1e3;
207
+ const combinedScale = timeScale * debugScale;
208
+ frameTimeDeltaMS *= combinedScale;
206
209
  frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
207
- if (!debugSpeedUp)
210
+ if (combinedScale <= 1)
208
211
  frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp min framerate
209
212
 
210
213
  let wasUpdated = false;
@@ -2120,6 +2123,18 @@ let cameraAngle = 0;
2120
2123
  * @memberof Settings */
2121
2124
  let cameraScale = 32;
2122
2125
 
2126
+ ///////////////////////////////////////////////////////////////////////////////
2127
+ // Time settings
2128
+
2129
+ /** Scale applied to engine time, can be used for slow motion or fast forward
2130
+ * - 1 is normal speed, 2 is double speed, 0.5 is half speed
2131
+ * - 0 freezes the simulation without setting the paused flag
2132
+ * - Should be >= 0; stacks multiplicatively with the debug +/- shortcut
2133
+ * @type {number}
2134
+ * @default
2135
+ * @memberof Settings */
2136
+ let timeScale = 1;
2137
+
2123
2138
  ///////////////////////////////////////////////////////////////////////////////
2124
2139
  // Display settings
2125
2140
 
@@ -2323,18 +2338,22 @@ let touchInputEnable = true;
2323
2338
 
2324
2339
  /** True if touch gamepad should appear on mobile devices
2325
2340
  * - Supports left analog stick, 4 face buttons and start button (button 9)
2341
+ * - setTouchGamepadButtonCount(1) to use face buttons as right analog stick
2342
+ * - Analog stick buttons 10 and 11 are also activated when virtual sticks are touched
2343
+
2326
2344
  * @type {boolean}
2327
2345
  * @default
2328
2346
  * @memberof Settings */
2329
2347
  let touchGamepadEnable = false;
2330
2348
 
2331
2349
  /** True if touch gamepad should have start button in the center
2350
+ * - Prevents activating if overlappng with virtual stick or buttons if they are enabled
2332
2351
  * - When the game is paused, any touch will press the button
2333
- * - This can function as a way to pause/unpause the game
2334
- * @type {boolean}
2352
+ * - Set size to enable the center button
2353
+ * @type {number}
2335
2354
  * @default
2336
2355
  * @memberof Settings */
2337
- let touchGamepadCenterButton = true;
2356
+ let touchGamepadCenterButtonSize = 300;
2338
2357
 
2339
2358
  /** Number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
2340
2359
  * @type {number}
@@ -2352,7 +2371,7 @@ let touchGamepadAnalog = true;
2352
2371
  * @type {number}
2353
2372
  * @default
2354
2373
  * @memberof Settings */
2355
- let touchGamepadSize = 99;
2374
+ let touchGamepadSize = 100;
2356
2375
 
2357
2376
  /** Transparency of touch gamepad overlay
2358
2377
  * @type {number}
@@ -2444,6 +2463,11 @@ function setCameraAngle(angle) { cameraAngle = angle; }
2444
2463
  * @memberof Settings */
2445
2464
  function setCameraScale(scale) { cameraScale = scale; }
2446
2465
 
2466
+ /** Set scale applied to engine time
2467
+ * @param {number} scale
2468
+ * @memberof Settings */
2469
+ function setTimeScale(scale) { timeScale = scale; }
2470
+
2447
2471
  /** Set if tiles should be colorized when using canvas2d
2448
2472
  * This can be slower but results should look nearly identical to WebGL rendering
2449
2473
  * It can be enabled/disabled at any time
@@ -2620,11 +2644,12 @@ function setTouchInputEnable(enable) { touchInputEnable = enable; }
2620
2644
  * @memberof Settings */
2621
2645
  function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
2622
2646
 
2623
- /** True if touch gamepad should have start button in the center
2624
- * - This can function as a way to pause/unpause the game
2625
- * @param {boolean} enable
2647
+ /** Set if touch gamepad should have start button in the center
2648
+ * - Set size to enable the center button
2649
+ * - When the game is paused, any touch will press the button
2650
+ * @param {number} size
2626
2651
  * @memberof Settings */
2627
- function setTouchGamepadCenterButton(enable) { touchGamepadCenterButton = enable; }
2652
+ function setTouchGamepadCenterButtonSize(size) { touchGamepadCenterButtonSize = size; }
2628
2653
 
2629
2654
  /** Set number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
2630
2655
  * @param {number} count
@@ -3162,6 +3187,7 @@ class EngineObject
3162
3187
  child.parent = this;
3163
3188
  child.localPos = localPos.copy();
3164
3189
  child.localAngle = localAngle;
3190
+ child.updateTransforms();
3165
3191
  return child;
3166
3192
  }
3167
3193
 
@@ -3316,6 +3342,12 @@ let textureInfos = [];
3316
3342
  * @memberof Draw */
3317
3343
  let drawCount;
3318
3344
 
3345
+ // internal predicates for tint short-circuiting in canvas2D draw paths
3346
+ // isWhite ignores alpha because alpha is applied via globalAlpha, not multiply
3347
+ // isBlack includes alpha so additive colors that only contribute alpha are not skipped
3348
+ /** @param {Color} c */ function isWhite(c) { return c.r >= 1 && c.g >= 1 && c.b >= 1; }
3349
+ /** @param {Color} c */ function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
3350
+
3319
3351
  ///////////////////////////////////////////////////////////////////////////////
3320
3352
 
3321
3353
  /**
@@ -3645,6 +3677,96 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=BLACK, angle=0,
3645
3677
  }
3646
3678
  }
3647
3679
 
3680
+ /** Draw a texture tiled (wrapped) across a rectangle in world space.
3681
+ * Useful for backgrounds, repeating patterns, and seamless fills.
3682
+ * The whole texture is tiled — sub-region (TileInfo) wrapping is not supported.
3683
+ * @param {Vector2} pos - Center of the rect in world space
3684
+ * @param {Vector2} size - Size of the rect in world space
3685
+ * @param {Vector2} wrapCount - How many times the texture repeats (x, y)
3686
+ * @param {TextureInfo|number} [texture=0] - TextureInfo or texture index into textureInfos
3687
+ * @param {Color} [color=WHITE] - Color to modulate with
3688
+ * @param {number} [angle=0] - Angle to rotate by
3689
+ * @param {Color} [additiveColor] - Additive color to be applied if any
3690
+ * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
3691
+ * @param {boolean} [screenSpace=false] - Are pos and size in screen space?
3692
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
3693
+ * @memberof Draw */
3694
+ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
3695
+ angle=0, additiveColor, useWebGL=glEnable, screenSpace=false, context)
3696
+ {
3697
+ ASSERT(isVector2(pos), 'pos must be a vec2');
3698
+ ASSERT(isVector2(size), 'size must be a vec2');
3699
+ ASSERT(isVector2(wrapCount), 'wrapCount must be a vec2');
3700
+ ASSERT(isColor(color), 'color is invalid');
3701
+ ASSERT(isNumber(angle), 'angle must be a number');
3702
+ ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
3703
+ ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
3704
+ ASSERT(!(texture instanceof TileInfo),
3705
+ 'pass a TextureInfo or texture index, not a TileInfo — use tileInfo.textureInfo');
3706
+
3707
+ // short-circuit before texture lookup — textureInfos[0] is undefined in headless mode
3708
+ if (headlessMode) return;
3709
+
3710
+ // resolve texture argument: TextureInfo or index
3711
+ const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
3712
+ ASSERT(textureInfo instanceof TextureInfo, 'texture not loaded');
3713
+ ASSERT(textureInfo.size.x > 0, 'texture not loaded');
3714
+
3715
+ if (useWebGL && glEnable)
3716
+ {
3717
+ ASSERT(!!glContext, 'WebGL is not enabled!');
3718
+ if (screenSpace)
3719
+ [pos, size, angle] = screenToWorldTransform(pos, size, angle);
3720
+ glSetTexture(textureInfo.glTexture);
3721
+ glDraw(pos.x, pos.y, size.x, size.y, angle,
3722
+ 0, 0, wrapCount.x, wrapCount.y,
3723
+ color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
3724
+ return;
3725
+ }
3726
+
3727
+ // Canvas2D path — increment drawCount here (WebGL batch counts via glBatchCount)
3728
+ ++drawCount;
3729
+
3730
+ if (!screenSpace)
3731
+ {
3732
+ pos = worldToScreen(pos);
3733
+ size = size.scale(cameraScale);
3734
+ angle -= cameraAngle;
3735
+ }
3736
+
3737
+ // pick image source: raw, or tinted bake. Match drawImageColor's
3738
+ // "no tint needed" predicate so behavior stays consistent.
3739
+ const noTint = !canvasColorTiles ||
3740
+ (additiveColor
3741
+ ? isWhite(color.add(additiveColor)) && additiveColor.a <= 0
3742
+ : isWhite(color));
3743
+ // alpha is baked into pixels by bakeTintedImage's additive branch;
3744
+ // in that case globalAlpha must NOT also apply color.a
3745
+ const alphaBaked = !noTint && additiveColor && !isBlack(additiveColor);
3746
+ const source = noTint
3747
+ ? textureInfo.image
3748
+ : bakeTintedImage(textureInfo.image, color, additiveColor);
3749
+
3750
+ context = context || drawContext;
3751
+ context.save();
3752
+ context.translate(pos.x + .5, pos.y + .5);
3753
+ context.rotate(angle);
3754
+ context.globalAlpha = alphaBaked ? 1 : color.a;
3755
+
3756
+ const pattern = context.createPattern(source, 'repeat');
3757
+ // map pattern-source pixels into user space so the rect contains
3758
+ // wrapCount.x × wrapCount.y repeats
3759
+ const m = new DOMMatrix()
3760
+ .translate(-size.x/2, -size.y/2)
3761
+ .scale(size.x / (wrapCount.x * source.width),
3762
+ size.y / (wrapCount.y * source.height));
3763
+ pattern.setTransform(m);
3764
+ context.fillStyle = pattern;
3765
+ context.fillRect(-size.x/2, -size.y/2, size.x, size.y);
3766
+ context.globalAlpha = 1;
3767
+ context.restore();
3768
+ }
3769
+
3648
3770
  /** Draw connected lines between a series of points
3649
3771
  * @param {Array<Vector2>} points
3650
3772
  * @param {number} [width]
@@ -4168,6 +4290,43 @@ function combineCanvases()
4168
4290
  mainContext.drawImage(workCanvas, 0, 0);
4169
4291
  }
4170
4292
 
4293
+ // Internal: bake a color/additive-color tint into workReadCanvas at the
4294
+ // image's native resolution. Returns the work canvas, suitable for
4295
+ // passing to context.createPattern. Used by drawTextureWrapped's
4296
+ // Canvas2D path. Caller is responsible for short-circuiting when no
4297
+ // tint is needed (i.e. color is white and additiveColor is black/none).
4298
+ function bakeTintedImage(image, color, additiveColor)
4299
+ {
4300
+ const w = image.width|0, h = image.height|0;
4301
+ workReadCanvas.width = w;
4302
+ workReadCanvas.height = h;
4303
+ workReadContext.drawImage(image, 0, 0);
4304
+
4305
+ const imageData = workReadContext.getImageData(0, 0, w, h);
4306
+ const data = imageData.data;
4307
+ if (additiveColor && !isBlack(additiveColor))
4308
+ {
4309
+ // multiply + additive (slower)
4310
+ const colorMultiply = [color.r, color.g, color.b, color.a];
4311
+ const colorAdd = [additiveColor.r * 255, additiveColor.g * 255,
4312
+ additiveColor.b * 255, additiveColor.a * 255];
4313
+ for (let i = 0; i < data.length; ++i)
4314
+ data[i] = data[i] * colorMultiply[i&3] + colorAdd[i&3] |0;
4315
+ }
4316
+ else
4317
+ {
4318
+ // RGB only, faster — alpha left intact for the caller
4319
+ for (let i = 0; i < data.length; i+=4)
4320
+ {
4321
+ data[i ] *= color.r;
4322
+ data[i+1] *= color.g;
4323
+ data[i+2] *= color.b;
4324
+ }
4325
+ }
4326
+ workReadContext.putImageData(imageData, 0, 0);
4327
+ return workReadCanvas;
4328
+ }
4329
+
4171
4330
  /** Helper function to draw an image with color and additive color applied
4172
4331
  * This is slower then normal drawImage when color is applied
4173
4332
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
@@ -4186,8 +4345,6 @@ function combineCanvases()
4186
4345
  * @memberof Draw */
4187
4346
  function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight, color, additiveColor, bleed=0)
4188
4347
  {
4189
- function isWhite(c) { return c.r >= 1 && c.g >= 1 && c.b >= 1; }
4190
- function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
4191
4348
  const sx2 = bleed;
4192
4349
  const sy2 = bleed;
4193
4350
  sWidth = max(1,sWidth|0);
@@ -4656,6 +4813,32 @@ function gamepadStickCount(gamepad=gamepadPrimary)
4656
4813
  return gamepadStickData[gamepad]?.length ?? 0;
4657
4814
  }
4658
4815
 
4816
+ /** Pulse a gamepad's vibration hardware using the dual-rumble effect if it exists
4817
+ * Strong magnitude is usually the left side motor, weak magnitude is usually the right side motor
4818
+ * @param {number} [gamepad] - gamepad index
4819
+ * @param {number} [duration] - effect duration in ms
4820
+ * @param {number} [strongMagnitude] - strong (left) motor intensity, 0 to 1
4821
+ * @param {number} [weakMagnitude] - weak (right) motor intensity, 0 to 1
4822
+ * @param {number} [startDelay] - delay in ms before the effect starts
4823
+ * @memberof Input */
4824
+ function gamepadVibrate(gamepad=gamepadPrimary, duration=200, strongMagnitude=1, weakMagnitude=1, startDelay=0)
4825
+ {
4826
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
4827
+ if (!vibrateEnable || headlessMode) return;
4828
+ const pad = navigator?.getGamepads?.()[gamepad];
4829
+ pad?.vibrationActuator?.playEffect?.('dual-rumble', {duration, strongMagnitude, weakMagnitude, startDelay});
4830
+ }
4831
+
4832
+ /** Stop vibration on a gamepad
4833
+ * @memberof Input */
4834
+ function gamepadVibrateStop(gamepad=gamepadPrimary)
4835
+ {
4836
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
4837
+ if (!vibrateEnable || headlessMode) return;
4838
+ const pad = navigator?.getGamepads?.()[gamepad];
4839
+ pad?.vibrationActuator?.reset?.();
4840
+ }
4841
+
4659
4842
  ///////////////////////////////////////////////////////////////////////////////
4660
4843
 
4661
4844
  /** Pulse the vibration hardware if it exists
@@ -4893,7 +5076,7 @@ function inputInit()
4893
5076
  if (touching)
4894
5077
  {
4895
5078
  touchGamepadTimer.set();
4896
- if (touchGamepadCenterButton && !wasTouching && paused)
5079
+ if (touchGamepadCenterButtonSize && !wasTouching && paused)
4897
5080
  {
4898
5081
  // touch anywhere to press start when paused
4899
5082
  touchGamepadButtons[9] = 1;
@@ -4918,6 +5101,7 @@ function inputInit()
4918
5101
  // virtual analog stick
4919
5102
  const delta = touchPos.subtract(stickCenter);
4920
5103
  touchGamepadSticks[0] = delta.scale(2/touchGamepadSize).clampLength();
5104
+ touchGamepadButtons[10] = 1; // also press a button when touching stick
4921
5105
  }
4922
5106
  else if (buttonCenter.distance(touchPos) < touchGamepadSize)
4923
5107
  {
@@ -4926,6 +5110,7 @@ function inputInit()
4926
5110
  // virtual right analog stick
4927
5111
  const delta = touchPos.subtract(buttonCenter);
4928
5112
  touchGamepadSticks[1] = delta.scale(2/touchGamepadSize).clampLength();
5113
+ touchGamepadButtons[11] = 1; // also press a button when touching right stick
4929
5114
  }
4930
5115
  // virtual face buttons
4931
5116
  let button = buttonCenter.subtract(touchPos).direction();
@@ -4942,8 +5127,7 @@ function inputInit()
4942
5127
  if (button < touchGamepadButtonCount)
4943
5128
  touchGamepadButtons[button] = 1;
4944
5129
  }
4945
- else if (touchGamepadCenterButton &&
4946
- startCenter.distance(touchPos) < touchGamepadSize)
5130
+ else if (startCenter.distance(touchPos) < touchGamepadCenterButtonSize)
4947
5131
  {
4948
5132
  // virtual start button in center
4949
5133
  touchGamepadButtons[9] = 1;
@@ -4992,6 +5176,18 @@ function inputUpdate()
4992
5176
  // update touch gamepad if enabled
4993
5177
  if (touchGamepadEnable && isTouchDevice)
4994
5178
  {
5179
+ if (debugGamepads)
5180
+ {
5181
+ const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5182
+ const buttonCenter = touchGamepadButtonCenter();
5183
+ const startCenter = mainCanvasSize.scale(.5);
5184
+
5185
+ debugCircle(stickCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
5186
+ debugCircle(buttonCenter, 2*touchGamepadSize, 'cyan', 0, false, true);
5187
+ if (touchGamepadCenterButtonSize)
5188
+ debugCircle(startCenter, 2*touchGamepadCenterButtonSize, 'cyan', 0, false, true);
5189
+ }
5190
+
4995
5191
  if (!touchGamepadTimer.isSet()) return;
4996
5192
 
4997
5193
  // read virtual analog stick
@@ -5019,7 +5215,7 @@ function inputUpdate()
5019
5215
 
5020
5216
  // read virtual gamepad buttons
5021
5217
  const data = inputData[1] ?? (inputData[1] = []);
5022
- for (let i=10; i--;)
5218
+ for (let i=12; i--;)
5023
5219
  {
5024
5220
  const wasDown = gamepadIsDown(i,0);
5025
5221
  data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
@@ -7618,9 +7814,8 @@ function glClearCanvas()
7618
7814
  /** Set the WebGL texture, called automatically if using multiple textures
7619
7815
  * - This may also flush the gl buffer resulting in more draw calls and worse performance
7620
7816
  * @param {WebGLTexture} texture
7621
- * @param {boolean} [wrap] - Should the texture wrap or clamp
7622
7817
  * @memberof WebGL */
7623
- function glSetTexture(texture, wrap=false)
7818
+ function glSetTexture(texture)
7624
7819
  {
7625
7820
  // must flush cache with the old texture to set a new one
7626
7821
  if (!glContext || texture === glActiveTexture) return;
@@ -7628,11 +7823,6 @@ function glSetTexture(texture, wrap=false)
7628
7823
  glFlush();
7629
7824
  glActiveTexture = texture;
7630
7825
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
7631
-
7632
- // set wrap mode
7633
- const wrapMode = wrap ? glContext.REPEAT : glContext.CLAMP_TO_EDGE;
7634
- glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapMode);
7635
- glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapMode);
7636
7826
  }
7637
7827
 
7638
7828
  /** Compile WebGL shader of the given type, will throw errors if in debug mode
@@ -7707,6 +7897,8 @@ function glCreateTexture(image)
7707
7897
  const minFilter = mipMap ? glContext.LINEAR_MIPMAP_LINEAR : magFilter;
7708
7898
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, magFilter);
7709
7899
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, minFilter);
7900
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, glContext.REPEAT);
7901
+ glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, glContext.REPEAT);
7710
7902
  if (mipMap)
7711
7903
  glContext.generateMipmap(glContext.TEXTURE_2D);
7712
7904
 
@@ -9623,7 +9815,7 @@ class UIObject
9623
9815
 
9624
9816
  /** Internal function called when object is clicked
9625
9817
  * @param {boolean} [playSound] */
9626
- click(playSound)
9818
+ click(playSound=true)
9627
9819
  {
9628
9820
  this.onClick();
9629
9821
  if (playSound && this.soundClick)
@@ -10596,7 +10788,8 @@ class Box2dObject extends EngineObject
10596
10788
  {
10597
10789
  this.pos = pos;
10598
10790
  this.angle = angle;
10599
- this.body.SetTransform(box2d.vec2dTo(pos), angle);
10791
+ // box2d uses reverse angle
10792
+ this.body.SetTransform(box2d.vec2dTo(pos), -angle);
10600
10793
  }
10601
10794
 
10602
10795
  /** Sets the position
@@ -10607,7 +10800,7 @@ class Box2dObject extends EngineObject
10607
10800
  /** Sets the angle
10608
10801
  * @param {number} angle */
10609
10802
  setAngle(angle)
10610
- { this.setTransform(box2d.vec2From(this.body.GetPosition()), -angle); }
10803
+ { this.setTransform(box2d.vec2From(this.body.GetPosition()), angle); }
10611
10804
 
10612
10805
  /** Sets the linear velocity
10613
10806
  * @param {Vector2} velocity */
@@ -12152,6 +12345,7 @@ async function box2dInit()
12152
12345
  {
12153
12346
  if (o.body)
12154
12347
  {
12348
+ // box2d uses reverse angle
12155
12349
  o.pos = box2d.vec2From(o.body.GetPosition());
12156
12350
  o.angle = -o.body.GetAngle();
12157
12351
  }
@@ -12362,3 +12556,511 @@ function drawThreeSlice(pos, size, startTile, color, borderSize=1, additiveColor
12362
12556
  drawTile(pos.add(cornerPos.rotate(rotateAngle)), cornerSize, cornerTile, color, a, false, additiveColor, useWebGL, screenSpace, context);
12363
12557
  }
12364
12558
  }
12559
+ /**
12560
+ * LittleJS Tween System Plugin
12561
+ * - Lightweight tweens for numbers, Vector2, Color, or any .lerp-able type
12562
+ * - Chainable easing, looping, and ping-pong
12563
+ * - Property-path helper for the common case of animating an object field
12564
+ * - Auto-updates via engineAddPlugin; pauses with the game by default
12565
+ * @namespace TweenSystem
12566
+ */
12567
+
12568
+ ///////////////////////////////////////////////////////////////////////////////
12569
+
12570
+ // Module-private list of tweens currently running.
12571
+ const tweenActive = [];
12572
+
12573
+ // Time tracking for delta computation between engine plugin calls.
12574
+ let lastTime = 0;
12575
+ let lastTimeReal = 0;
12576
+
12577
+ // True if the value is an instance of a class that exposes a numeric-percent
12578
+ // `lerp(other, percent)` method (Vector2, Color, or any future class).
12579
+ function isLerpable(v) { return v && typeof v.lerp === 'function'; }
12580
+
12581
+ ///////////////////////////////////////////////////////////////////////////////
12582
+
12583
+ /** A numeric tween: drives a callback with a value interpolated between
12584
+ * `start` and `end` over `duration` seconds. Pauses with the game by default.
12585
+ * @memberof TweenSystem
12586
+ * @example
12587
+ * // Animate a fade-out over 2 seconds with an ease-out sine curve.
12588
+ * new Tween((v) => obj.alpha = v, 1, 0, 2, { ease: Ease.OUT(Ease.SINE) });
12589
+ */
12590
+ class Tween
12591
+ {
12592
+ /** Create a new tween. The callback fires immediately with `start` so the
12593
+ * target snaps to the start value on the same frame the tween is created.
12594
+ *
12595
+ * `start` and `end` may be numbers, Vector2 instances, Color instances, or
12596
+ * any object exposing a `lerp(other, percent) => sameType` method. The
12597
+ * callback receives the interpolated value (a number, or a fresh instance
12598
+ * for lerp-able types). Both endpoints must be the same type.
12599
+ * @param {function(number|Vector2|Color):void} callback - Called with the interpolated value each frame
12600
+ * @param {number|Vector2|Color} [start=0] - Starting value
12601
+ * @param {number|Vector2|Color} [end=1] - Ending value
12602
+ * @param {number} [duration=1] - Duration in seconds
12603
+ * @param {Object} [options]
12604
+ * @param {function(number):number} [options.ease] - Easing function (defaults to LINEAR)
12605
+ * @param {boolean} [options.useRealTime=false] - Advance even when the game is paused (matches Timer's useRealTime)
12606
+ * @param {boolean} [options.paused=false] - Start in paused state */
12607
+ constructor(callback, start = 0, end = 1, duration = 1, options = {})
12608
+ {
12609
+ ASSERT(typeof callback === 'function', 'Tween callback must be a function');
12610
+ if (isLerpable(start))
12611
+ {
12612
+ ASSERT(start.constructor === end.constructor,
12613
+ 'Tween start and end must be the same type');
12614
+ }
12615
+ else
12616
+ {
12617
+ ASSERT(isNumber(start), 'Tween start must be a number or have a .lerp method');
12618
+ ASSERT(isNumber(end), 'Tween end must be a number when start is a number');
12619
+ }
12620
+ ASSERT(isNumber(duration) && duration > 0, 'Tween duration must be > 0');
12621
+
12622
+ this.callback = callback;
12623
+ this.start = start;
12624
+ this.end = end;
12625
+ this.duration = duration;
12626
+ this.life = duration;
12627
+ this.ease = options.ease || Ease.LINEAR;
12628
+ this.useRealTime = !!options.useRealTime;
12629
+ this.paused = !!options.paused;
12630
+
12631
+ /** @private completion callback set by then(), loop(), pingPong(). */
12632
+ this.thenCallback = undefined;
12633
+ /** @private remaining iterations including the current run (loop/pingPong only). */
12634
+ this.loopRemaining = 0;
12635
+
12636
+ tweenActive.push(this);
12637
+ // Snap target to start immediately.
12638
+ callback(this.interp(duration));
12639
+ }
12640
+
12641
+ /** Set the easing curve and return this for chaining.
12642
+ * @param {function(number):number} easeFn
12643
+ * @returns {Tween}
12644
+ * @memberof TweenSystem */
12645
+ setEase(easeFn)
12646
+ {
12647
+ this.ease = easeFn;
12648
+ return this;
12649
+ }
12650
+
12651
+ /** Set a single completion callback. Calling `then` again replaces the
12652
+ * previous callback. Returns this for chaining.
12653
+ *
12654
+ * Calling `then` after `loop` or `pingPong` overrides the loop chain
12655
+ * (last call wins).
12656
+ * @param {function():void} callback
12657
+ * @returns {Tween}
12658
+ * @memberof TweenSystem */
12659
+ then(callback)
12660
+ {
12661
+ this.thenCallback = callback;
12662
+ this.loopRemaining = 0;
12663
+ return this;
12664
+ }
12665
+
12666
+ /** Repeat this tween `n` total times. After each iteration finishes, a
12667
+ * fresh tween with the same parameters takes over via the `then` slot.
12668
+ * `loop()` with no argument loops forever.
12669
+ *
12670
+ * Mutually exclusive with `pingPong`; calling either replaces the other,
12671
+ * and calling `then` after either clears the loop (last call wins).
12672
+ * @param {number} [count=Infinity]
12673
+ * @returns {Tween}
12674
+ * @memberof TweenSystem */
12675
+ loop(count = Infinity)
12676
+ {
12677
+ this.loopRemaining = count;
12678
+ this.thenCallback = () => loopContinuation(this);
12679
+ return this;
12680
+ }
12681
+
12682
+ /** Like `loop`, but swap `start` and `end` between iterations so the value
12683
+ * bounces back and forth. `pingPong()` with no argument bounces forever.
12684
+ *
12685
+ * Mutually exclusive with `loop`; calling either replaces the other, and
12686
+ * calling `then` after either clears the loop (last call wins).
12687
+ * @param {number} [count=Infinity]
12688
+ * @returns {Tween}
12689
+ * @memberof TweenSystem */
12690
+ pingPong(count = Infinity)
12691
+ {
12692
+ this.loopRemaining = count;
12693
+ this.thenCallback = () => pingPongContinuation(this);
12694
+ return this;
12695
+ }
12696
+
12697
+ /** Pause this tween. While paused, tweenUpdate skips it.
12698
+ * @memberof TweenSystem */
12699
+ pause() { this.paused = true; }
12700
+
12701
+ /** Resume a paused tween.
12702
+ * @memberof TweenSystem */
12703
+ resume() { this.paused = false; }
12704
+
12705
+ /** Reset this tween to the start: life back to duration, pause cleared,
12706
+ * re-added to the active list if previously stopped, and the callback
12707
+ * re-fired with the start value.
12708
+ * @memberof TweenSystem */
12709
+ restart()
12710
+ {
12711
+ this.life = this.duration;
12712
+ this.paused = false;
12713
+ if (tweenActive.indexOf(this) < 0) tweenActive.push(this);
12714
+ this.callback(this.interp(this.duration));
12715
+ }
12716
+
12717
+ /** True if this tween is in the active list and not paused.
12718
+ * @returns {boolean}
12719
+ * @memberof TweenSystem */
12720
+ isActive()
12721
+ {
12722
+ return !this.paused && tweenActive.indexOf(this) >= 0;
12723
+ }
12724
+
12725
+ /** Get how far this tween has progressed, from 0 (just started) to 1
12726
+ * (completed). Clamped — overshoot past completion still reads 1.
12727
+ * @returns {number}
12728
+ * @memberof TweenSystem */
12729
+ getPercent()
12730
+ {
12731
+ return percent(this.duration - this.life, 0, this.duration);
12732
+ }
12733
+
12734
+ /** Get the current interpolated value (the value most recently passed to
12735
+ * the callback). Returns a number, Vector2, or Color depending on the
12736
+ * tween's start/end types.
12737
+ * @returns {number|Vector2|Color}
12738
+ * @memberof TweenSystem */
12739
+ getValue()
12740
+ {
12741
+ return this.interp(this.life);
12742
+ }
12743
+
12744
+ /** Compute the interpolated value at the given remaining `life`.
12745
+ * At life === duration the result is `start`; at life === 0 it is `end`.
12746
+ * @param {number} life
12747
+ * @returns {number}
12748
+ * @memberof TweenSystem */
12749
+ interp(life)
12750
+ {
12751
+ const x = this.ease((this.duration - life) / this.duration);
12752
+ if (isLerpable(this.start))
12753
+ return this.start.lerp(this.end, x);
12754
+ return this.start + (this.end - this.start) * x;
12755
+ }
12756
+
12757
+ /** Remove this tween from the active list and prevent any pending then-callback.
12758
+ * @memberof TweenSystem */
12759
+ stop()
12760
+ {
12761
+ const i = tweenActive.indexOf(this);
12762
+ if (i >= 0) tweenActive.splice(i, 1);
12763
+ this.thenCallback = undefined;
12764
+ }
12765
+ }
12766
+
12767
+ /** Library of named easing curves and direction modifiers.
12768
+ * All curves accept `x` in [0,1] and return [0,1] (with possible overshoot
12769
+ * for ELASTIC/BACK/SPRING/BOUNCE). Curves are values you pass to `setEase`
12770
+ * or compose via the IN/OUT/IN_OUT/PIECEWISE/BEZIER modifiers.
12771
+ * @memberof TweenSystem
12772
+ * @example
12773
+ * // Use a basic curve
12774
+ * new Tween(callback, 0, 10, 1).setEase(Ease.SINE);
12775
+ * // Use a modifier on a curve
12776
+ * new Tween(callback, 0, 10, 1).setEase(Ease.OUT(Ease.BACK));
12777
+ */
12778
+ const Ease =
12779
+ {
12780
+ /** Linear (identity) curve.
12781
+ * @param {number} x
12782
+ * @returns {number}
12783
+ * @memberof TweenSystem */
12784
+ LINEAR: (x) => x,
12785
+
12786
+ /** Power curve factory: `Ease.POWER(n)` returns `x => x**n`.
12787
+ * Use n=2 for quadratic, n=3 for cubic, etc.
12788
+ * @param {number} n
12789
+ * @returns {function(number):number}
12790
+ * @memberof TweenSystem */
12791
+ POWER: (n) => (x) => x ** n,
12792
+
12793
+ /** Sine ease-in curve: starts slow, ends fast.
12794
+ * @param {number} x
12795
+ * @returns {number}
12796
+ * @memberof TweenSystem */
12797
+ SINE: (x) => 1 - Math.cos(x * (Math.PI / 2)),
12798
+
12799
+ /** Circular ease-in curve.
12800
+ * @param {number} x
12801
+ * @returns {number}
12802
+ * @memberof TweenSystem */
12803
+ CIRC: (x) => 1 - Math.sqrt(1 - x * x),
12804
+
12805
+ /** Exponential ease-in curve (`2^(10x-10)`).
12806
+ * @param {number} x
12807
+ * @returns {number}
12808
+ * @memberof TweenSystem */
12809
+ EXPO: (x) => 2 ** (10 * x - 10),
12810
+
12811
+ /** Back ease-in: overshoots backward at the start before snapping forward.
12812
+ * @param {number} x
12813
+ * @returns {number}
12814
+ * @memberof TweenSystem */
12815
+ BACK: (x) => x * x * (2.70158 * x - 1.70158),
12816
+
12817
+ /** Elastic ease-in: oscillates with decreasing amplitude.
12818
+ * @param {number} x
12819
+ * @returns {number}
12820
+ * @memberof TweenSystem */
12821
+ ELASTIC: (x) =>
12822
+ -(2 ** (10 * x - 10)) * Math.sin(((37 - 40 * x) * Math.PI) / 6),
12823
+
12824
+ /** Spring-like ease-out: oscillates outward after passing the target.
12825
+ * @param {number} x
12826
+ * @returns {number}
12827
+ * @memberof TweenSystem */
12828
+ SPRING: (x) =>
12829
+ 1 -
12830
+ (Math.sin(Math.PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
12831
+ Math.pow(x, 2.2) +
12832
+ (1 - x)) *
12833
+ (1.0 + 1.2 * x),
12834
+
12835
+ /** Bouncing ease-in: slow ramp with bouncing impacts near the end.
12836
+ * Symmetric with the other base curves, which are all ease-in. To get the
12837
+ * classic "object falls and hits the ground" shape (bounces near x=1),
12838
+ * wrap with `Ease.OUT`: `Ease.OUT(Ease.BOUNCE)`.
12839
+ * @param {number} x
12840
+ * @returns {number}
12841
+ * @memberof TweenSystem
12842
+ * @example
12843
+ * Ease.BOUNCE // ease-in bounce (slow, then bouncy at end)
12844
+ * Ease.OUT(Ease.BOUNCE) // ease-out bounce (object hits ground)
12845
+ * Ease.IN_OUT(Ease.BOUNCE) // bounces at both ends
12846
+ */
12847
+ BOUNCE: (x) =>
12848
+ {
12849
+ // Inverted form of the standard easeOutBounce: 1 - bounceOut(1 - x).
12850
+ let t = 1 - x, f;
12851
+ if (t < 4 / 11) f = 7.5625 * t * t;
12852
+ else if (t < 8 / 11) f = 7.5625 * (t -= 6 / 11) * t + 0.75;
12853
+ else if (t < 10 / 11) f = 7.5625 * (t -= 9 / 11) * t + 0.9375;
12854
+ else f = 7.5625 * (t -= 10.5 / 11) * t + 0.984375;
12855
+ return 1 - f;
12856
+ },
12857
+
12858
+ /** Ease-in direction modifier: returns the curve unchanged. Symmetric
12859
+ * with `OUT` and `IN_OUT`. Base curves are already ease-in by
12860
+ * convention, so wrapping a curve in `IN` is a no-op — useful when
12861
+ * picking the direction programmatically.
12862
+ * @param {function(number):number} f - Curve to use as ease-in (returned unchanged)
12863
+ * @returns {function(number):number}
12864
+ * @memberof TweenSystem
12865
+ * @example
12866
+ * // Pick direction at runtime
12867
+ * const dir = bouncyMode ? Ease.OUT : Ease.IN;
12868
+ * new Tween(cb, 0, 10, 1).setEase(dir(Ease.BACK));
12869
+ */
12870
+ IN: (f) => f,
12871
+
12872
+ /** Reverse a curve so it eases out instead of in: `x => 1 - f(1 - x)`.
12873
+ * @param {function(number):number} f
12874
+ * @returns {function(number):number}
12875
+ * @memberof TweenSystem
12876
+ * @example
12877
+ * Ease.OUT(Ease.POWER(2)) // ease-out quadratic
12878
+ */
12879
+ OUT: (f) => (x) => 1 - f(1 - x),
12880
+
12881
+ /** Combine the first half of `f` with `Ease.OUT(f)` for a symmetric curve.
12882
+ * Bug-fix vs the original library: the original referenced an undefined
12883
+ * global `Piecewise`; this implementation routes through `Ease.PIECEWISE`.
12884
+ * @param {function(number):number} f
12885
+ * @returns {function(number):number}
12886
+ * @memberof TweenSystem */
12887
+ IN_OUT: (f) => Ease.PIECEWISE(f, Ease.OUT(f)),
12888
+
12889
+ /** Split [0,1] into N equal sections and run a different curve in each.
12890
+ * Each curve is mapped to its section: section i runs over [i/n, (i+1)/n]
12891
+ * and its output is mapped to [i/n, (i+1)/n] of the overall range.
12892
+ * @param {...function(number):number} fns
12893
+ * @returns {function(number):number}
12894
+ * @memberof TweenSystem */
12895
+ PIECEWISE: (...fns) =>
12896
+ {
12897
+ const n = fns.length;
12898
+ return (x) =>
12899
+ {
12900
+ const i = (x * n - 1e-9) >> 0;
12901
+ return (fns[i]((x - i / n) * n) + i) / n;
12902
+ };
12903
+ },
12904
+
12905
+ /** Cubic Bezier curve solver in the style of CSS `cubic-bezier`.
12906
+ * Control points (0,0), (x1,y1), (x2,y2), (1,1).
12907
+ * @param {number} x1
12908
+ * @param {number} y1
12909
+ * @param {number} x2
12910
+ * @param {number} y2
12911
+ * @returns {function(number):number}
12912
+ * @memberof TweenSystem
12913
+ * @example
12914
+ * Ease.BEZIER(0.25, 0.1, 0.25, 1) // CSS "ease"
12915
+ */
12916
+ BEZIER: (x1, y1, x2, y2) =>
12917
+ {
12918
+ // Parametric cubic Bezier with implicit (0,0) and (1,1) endpoints.
12919
+ const curve = (t) =>
12920
+ {
12921
+ const u = 1 - t;
12922
+ const c1 = 3 * u * u * t;
12923
+ const c2 = 3 * u * t * t;
12924
+ const t3 = t ** 3;
12925
+ return [c1 * x1 + c2 * x2 + t3, c1 * y1 + c2 * y2 + t3];
12926
+ };
12927
+ return (x) =>
12928
+ {
12929
+ // Binary search for t such that curve(t).x ≈ x, then return curve(t).y.
12930
+ let t0 = 0, t1 = 1;
12931
+ for (let i = 0; i < 128; i++)
12932
+ {
12933
+ const tMid = (t0 + t1) / 2;
12934
+ const [bx, by] = curve(tMid);
12935
+ if (Math.abs(bx - x) < 1e-5) return by;
12936
+ if (bx < x) t0 = tMid; else t1 = tMid;
12937
+ }
12938
+ return curve((t0 + t1) / 2)[1];
12939
+ };
12940
+ },
12941
+ };
12942
+
12943
+ /** Tween a property on an object by dot-path. Returns the underlying Tween
12944
+ * so all chaining methods (`setEase`, `then`, `loop`, `pingPong`, etc.)
12945
+ * remain available.
12946
+ *
12947
+ * `start` and `end` may be numbers, Vector2 instances, Color instances, or
12948
+ * any object with a `lerp(other, percent) => sameType` method.
12949
+ * @param {Object} target - The object whose property is being animated
12950
+ * @param {string} propertyPath - Dot-separated path, e.g. `'pos.x'` or `'color'`
12951
+ * @param {number|Vector2|Color} start - Starting value
12952
+ * @param {number|Vector2|Color} end - Ending value
12953
+ * @param {number} [duration=1] - Duration in seconds
12954
+ * @param {Object} [options] - Same options as the Tween constructor
12955
+ * @returns {Tween}
12956
+ * @memberof TweenSystem
12957
+ * @example
12958
+ * // Numeric: slide an object's x with an ease-out sine curve
12959
+ * tweenProperty(player, 'pos.x', 0, 10, 2).setEase(Ease.OUT(Ease.SINE));
12960
+ * // Vector2: animate a position diagonally
12961
+ * tweenProperty(player, 'pos', vec2(-5, 0), vec2(5, 3), 2);
12962
+ * // Color: pulse between two colors
12963
+ * tweenProperty(sprite, 'color', RED, BLUE, 1).pingPong();
12964
+ */
12965
+ function tweenProperty(target, propertyPath, start, end, duration = 1, options = {})
12966
+ {
12967
+ ASSERT(target != null && typeof target === 'object', 'tweenProperty target must be an object');
12968
+ ASSERT(isString(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string');
12969
+
12970
+ const parts = propertyPath.split('.');
12971
+ const lastKey = parts.pop();
12972
+ const callback = (value) =>
12973
+ {
12974
+ let obj = target;
12975
+ for (const k of parts) obj = obj[k];
12976
+ obj[lastKey] = value;
12977
+ };
12978
+ return new Tween(callback, start, end, duration, options);
12979
+ }
12980
+
12981
+ // Continuation that schedules the next loop iteration when one finishes.
12982
+ // Called from the completed tween's `then` slot. Decrements the counter and
12983
+ // only spawns a new tween if more iterations remain.
12984
+ function loopContinuation(prev)
12985
+ {
12986
+ if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
12987
+ const next = new Tween(prev.callback, prev.start, prev.end, prev.duration,
12988
+ { ease: prev.ease, useRealTime: prev.useRealTime });
12989
+ next.loopRemaining = prev.loopRemaining === Infinity
12990
+ ? Infinity
12991
+ : prev.loopRemaining - 1;
12992
+ next.thenCallback = () => loopContinuation(next);
12993
+ }
12994
+
12995
+ // Continuation for pingPong: spawns a new tween with start and end swapped.
12996
+ function pingPongContinuation(prev)
12997
+ {
12998
+ if (prev.loopRemaining !== Infinity && prev.loopRemaining <= 1) return;
12999
+ const next = new Tween(prev.callback, prev.end, prev.start, prev.duration,
13000
+ { ease: prev.ease, useRealTime: prev.useRealTime });
13001
+ next.loopRemaining = prev.loopRemaining === Infinity
13002
+ ? Infinity
13003
+ : prev.loopRemaining - 1;
13004
+ next.thenCallback = () => pingPongContinuation(next);
13005
+ }
13006
+
13007
+ /** Engine plugin hook: advance every active tween by the appropriate delta.
13008
+ * Called once per render frame by the engine (no arguments). May also be
13009
+ * called explicitly with `(gameDelta, realDelta)` to drive tweens manually
13010
+ * — useful for headless tests or custom replay/scrubbing systems.
13011
+ * @param {number} [gameDelta] - Game-time delta in seconds; default: time - lastTime
13012
+ * @param {number} [realDelta] - Real-time delta in seconds; default: timeReal - lastTimeReal
13013
+ * @memberof TweenSystem */
13014
+ function tweenUpdate(gameDelta, realDelta)
13015
+ {
13016
+ if (gameDelta === undefined)
13017
+ {
13018
+ // Engine path: compute deltas from engine time globals.
13019
+ gameDelta = time - lastTime;
13020
+ realDelta = timeReal - lastTimeReal;
13021
+ lastTime = time;
13022
+ lastTimeReal = timeReal;
13023
+ }
13024
+ else if (realDelta === undefined)
13025
+ {
13026
+ // Manual path with one arg: real and game advance together.
13027
+ realDelta = gameDelta;
13028
+ }
13029
+
13030
+ // Iterate in reverse so removals don't disturb iteration.
13031
+ for (let i = tweenActive.length; i--;)
13032
+ {
13033
+ const t = tweenActive[i];
13034
+ if (t.paused) continue;
13035
+ const dt = t.useRealTime ? realDelta : gameDelta;
13036
+ if (dt <= 0) continue;
13037
+
13038
+ t.life -= dt;
13039
+ if (t.life > 0)
13040
+ {
13041
+ t.callback(t.interp(t.life));
13042
+ }
13043
+ else
13044
+ {
13045
+ // Completion: fire end value, remove from active, fire then-callback.
13046
+ t.callback(t.interp(0));
13047
+ tweenActive.splice(i, 1);
13048
+ const cb = t.thenCallback;
13049
+ t.thenCallback = undefined;
13050
+ if (cb) cb();
13051
+ }
13052
+ }
13053
+ }
13054
+
13055
+ /** Stop every active tween and clear their then-callbacks. Useful for resets
13056
+ * on level transitions or when changing scenes.
13057
+ * @memberof TweenSystem */
13058
+ function tweenStopAll()
13059
+ {
13060
+ for (const t of tweenActive) t.thenCallback = undefined;
13061
+ tweenActive.length = 0;
13062
+ }
13063
+
13064
+ // Register with the engine so tweens auto-advance.
13065
+ engineAddPlugin(tweenUpdate);
13066
+