littlejsengine 1.19.3 → 1.20.0

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.
Files changed (50) hide show
  1. package/FAQ.md +841 -820
  2. package/README.md +29 -25
  3. package/dist/littlejs.d.ts +2455 -935
  4. package/dist/littlejs.esm.js +30344 -21110
  5. package/dist/littlejs.esm.min.js +4 -3
  6. package/dist/littlejs.js +30259 -21052
  7. package/dist/littlejs.min.js +2 -2
  8. package/dist/littlejs.release.js +26567 -20455
  9. package/package.json +10 -5
  10. package/plugins/audioEffects.js +28 -24
  11. package/plugins/box2d.js +2516 -2073
  12. package/plugins/drawUtilities.js +206 -31
  13. package/plugins/gltf.js +740 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -332
  16. package/plugins/math3d.js +58 -17
  17. package/plugins/medalSystem.js +326 -280
  18. package/plugins/newgrounds.js +400 -199
  19. package/plugins/pathFinder.js +208 -175
  20. package/plugins/pluginExport.js +15 -0
  21. package/plugins/postProcess.js +56 -13
  22. package/plugins/render3d.js +1643 -1577
  23. package/plugins/render3dDebug.js +109 -0
  24. package/plugins/render3dExtras.js +1754 -0
  25. package/plugins/render3dVoxels.js +502 -0
  26. package/plugins/textureSheet.js +496 -458
  27. package/plugins/threejs.js +183 -163
  28. package/plugins/tweakables.js +411 -0
  29. package/plugins/tweenSystem.js +263 -145
  30. package/plugins/uiSystem.js +2024 -1687
  31. package/plugins/zzfxm.js +159 -159
  32. package/src/engine.js +191 -75
  33. package/src/engineAudio.js +229 -125
  34. package/src/engineBuild.mjs +289 -256
  35. package/src/engineDebug.js +912 -742
  36. package/src/engineDraw.js +295 -193
  37. package/src/engineEditor.js +2431 -0
  38. package/src/engineExport.js +12 -0
  39. package/src/engineInput.js +1462 -1293
  40. package/src/engineMath.js +1274 -1252
  41. package/src/engineObject.js +194 -80
  42. package/src/engineParticles.js +583 -532
  43. package/src/engineRelease.js +67 -45
  44. package/src/engineSettings.js +77 -29
  45. package/src/engineTileLayer.js +995 -741
  46. package/src/engineUtilities.js +19 -13
  47. package/src/engineWebGL.js +239 -82
  48. package/src/jsconfig.json +17 -13
  49. package/plugins/box2d.wasm.js +0 -630
  50. package/plugins/box2d.wasm.wasm +0 -0
@@ -11,21 +11,37 @@
11
11
 
12
12
  ///////////////////////////////////////////////////////////////////////////////
13
13
 
14
- // Module-private list of tweens currently running.
14
+ // Module-private list of tweens currently running, each with its active flag set while it is in it.
15
15
  const tweenActive = [];
16
+ const tweenUpdateList = []; // the tweens an update moves, the ones active when it began
17
+ let tweenUpdatePass = 0; // counts the updates, a tween started during one waits for the next
16
18
 
17
- // Time tracking for delta computation between engine plugin calls.
18
- let lastTime = 0;
19
- let lastTimeReal = 0;
19
+ // put a tween in the active list, or take it out, keeping its flag in step so a check costs nothing
20
+ function tweenActivate(tween)
21
+ {
22
+ tween.activePass = tweenUpdatePass; // started again, even while active, so this update leaves it alone
23
+ if (tween.active) return;
24
+ tween.active = true;
25
+ tweenActive.push(tween);
26
+ }
27
+ function tweenDeactivate(tween)
28
+ {
29
+ if (!tween.active) return;
30
+ tween.active = false;
31
+ tweenActive.splice(tweenActive.indexOf(tween), 1);
32
+ }
20
33
 
21
34
  // True if the value is an instance of a class that exposes a numeric-percent
22
35
  // `lerp(other, percent)` method (Vector2, Color, or any future class).
23
- function isLerpable(v) { return v && typeof v.lerp === 'function'; }
36
+ function tweenIsLerpable(v) { return v && typeof v.lerp === 'function'; }
24
37
 
25
38
  ///////////////////////////////////////////////////////////////////////////////
26
39
 
27
- /** A numeric tween: drives a callback with a value interpolated between
40
+ /** A tween: drives a callback with a value interpolated between
28
41
  * `start` and `end` over `duration` seconds. Pauses with the game by default.
42
+ * - In TypeScript it is a `Tween<T>` of the type it tweens, which comes from `start` and `end` or
43
+ * the callback's parameter, so `(v: number)=> ...` takes a number
44
+ * @template [T=any]
29
45
  * @memberof TweenSystem
30
46
  * @example
31
47
  * // Animate a fade-out over 2 seconds with an ease-out sine curve.
@@ -36,22 +52,22 @@ class Tween
36
52
  /** Create a new tween. The callback fires immediately with `start` so the
37
53
  * target snaps to the start value on the same frame the tween is created.
38
54
  *
39
- * `start` and `end` may be numbers, Vector2 instances, Color instances, or
55
+ * `start` and `end` may be numbers, Vector2, Vector3 or Color instances, or
40
56
  * any object exposing a `lerp(other, percent) => sameType` method. The
41
57
  * callback receives the interpolated value (a number, or a fresh instance
42
58
  * for lerp-able types). Both endpoints must be the same type.
43
- * @param {function((number|Vector2|Color)):void} callback - Called with the interpolated value each frame
44
- * @param {number|Vector2|Color} [start=0] - Starting value
45
- * @param {number|Vector2|Color} [end=1] - Ending value
59
+ * @param {function(NonNullable<T>):void} callback - Called with the interpolated value each frame
60
+ * @param {T} [start=0] - Starting value
61
+ * @param {T} [end=1] - Ending value
46
62
  * @param {number} [duration=1] - Duration in seconds
47
63
  * @param {Object} [options]
48
64
  * @param {function(number):number} [options.ease] - Easing function (defaults to LINEAR)
49
65
  * @param {boolean} [options.useRealTime=false] - Advance even when the game is paused (matches Timer's useRealTime)
50
66
  * @param {boolean} [options.paused=false] - Start in paused state */
51
- constructor(callback, start = 0, end = 1, duration = 1, options = {})
67
+ constructor(callback, start = /** @type {T} */ (0), end = /** @type {T} */ (1), duration = 1, options = {})
52
68
  {
53
69
  ASSERT(typeof callback === 'function', 'Tween callback must be a function');
54
- if (isLerpable(start))
70
+ if (tweenIsLerpable(start))
55
71
  {
56
72
  ASSERT(start.constructor === end.constructor,
57
73
  'Tween start and end must be the same type');
@@ -63,11 +79,16 @@ class Tween
63
79
  }
64
80
  ASSERT(isNumber(duration) && duration > 0, 'Tween duration must be > 0');
65
81
 
66
- /** @property {function((number|Vector2|Color)):void} - Called with the interpolated value each frame */
82
+ // the callback's type is NonNullable<T>, which is T, so that TypeScript takes the type from start and end
83
+ // first: a typed callback like (v: number)=> with start 10 then makes a Tween<number>, not a Tween<0|10>
84
+ /** @property {function(T):void} - Called with the interpolated value each frame
85
+ * @type {function(T):void} */
67
86
  this.callback = callback;
68
- /** @property {number|Vector2|Color} - Starting value */
87
+ /** @property {T} - Starting value
88
+ * @type {T} */
69
89
  this.start = start;
70
- /** @property {number|Vector2|Color} - Ending value */
90
+ /** @property {T} - Ending value
91
+ * @type {T} */
71
92
  this.end = end;
72
93
  /** @property {number} - Total duration in seconds */
73
94
  this.duration = duration;
@@ -79,117 +100,137 @@ class Tween
79
100
  this.useRealTime = !!options.useRealTime;
80
101
  /** @property {boolean} - If true, stop advancing until cleared */
81
102
  this.paused = !!options.paused;
103
+ /** @property {undefined|function():void} - Called once the tween completes: when its last pass ends,
104
+ * the last iteration of a loop or pingPong, and again each time a restart plays through; then() sets it
105
+ * @type {undefined|function():void} */
106
+ this.onComplete = undefined;
82
107
 
83
- /** Completion callback set by then(), loop(), pingPong().
108
+ /** Continuation when a pass ends, set by loop() and pingPong() to start the next iteration.
84
109
  * @private */
85
110
  this.thenCallback = undefined;
86
111
  /** Remaining iterations including the current run (loop/pingPong only).
87
112
  * @private */
88
113
  this.loopRemaining = 0;
89
-
90
- tweenActive.push(this);
114
+ /** Whether it is in the active list, see isActive
115
+ * @private */
116
+ this.active = false;
117
+ /** The update it was started in, it first moves on the one after
118
+ * @private */
119
+ this.activePass = 0;
120
+ /** Engine time and real time of its last engine update, it moves by what passed since
121
+ * @private */
122
+ this.lastTime = time;
123
+ /** @private */
124
+ this.lastTimeReal = timeReal;
125
+ /** @property {Object|undefined} - The object tweenProperty animates, the tween stops once it is destroyed,
126
+ * even while paused
127
+ * @type {{destroyed?: boolean}|undefined} */
128
+ this.target = undefined;
129
+
130
+ tweenActivate(this);
91
131
  // Snap target to start immediately.
92
132
  callback(this.interp(duration));
93
133
  }
94
134
 
95
135
  /** Set the easing curve and return this for chaining.
96
136
  * @param {function(number):number} easeFn
97
- * @returns {Tween}
98
- * @memberof TweenSystem */
137
+ * @returns {Tween<T>} */
99
138
  setEase(easeFn)
100
139
  {
101
140
  this.ease = easeFn;
102
141
  return this;
103
142
  }
104
143
 
105
- /** Set a single completion callback. Calling `then` again replaces the
106
- * previous callback. Returns this for chaining.
107
- *
108
- * Calling `then` after `loop` or `pingPong` overrides the loop chain
109
- * (last call wins).
144
+ /** Set the completion callback, `onComplete`, and return this for chaining.
145
+ * It is called once the tween completes: when its pass ends, or for a
146
+ * `loop` or `pingPong` when its last iteration ends, so an endless one
147
+ * never calls it. Calling `then` again replaces the previous callback.
148
+ * - It works with `loop` and `pingPong` in either order, neither replaces the other
149
+ * - It is kept by `restart`, so a restarted tween calls it again when it completes
150
+ * - `stop` and `tweenStopAll` end a tween without calling it
110
151
  * @param {function():void} callback
111
- * @returns {Tween}
112
- * @memberof TweenSystem */
152
+ * @returns {Tween<T>} */
113
153
  then(callback)
114
154
  {
115
- this.thenCallback = callback;
116
- this.loopRemaining = 0;
155
+ this.onComplete = callback;
117
156
  return this;
118
157
  }
119
158
 
120
- /** Repeat this tween `n` total times. After each iteration finishes, a
121
- * fresh tween with the same parameters takes over via the `then` slot.
159
+ /** Repeat this tween `n` total times. After each iteration finishes, the
160
+ * same tween starts over, so the handle returned stays good for the whole
161
+ * loop: pause or stop it to pause or stop every iteration left.
122
162
  * `loop()` with no argument loops forever.
123
163
  *
124
- * Mutually exclusive with `pingPong`; calling either replaces the other,
125
- * and calling `then` after either clears the loop (last call wins).
164
+ * Mutually exclusive with `pingPong`; calling either replaces the other.
165
+ * A `then` callback, set before or after, is called when the last
166
+ * iteration ends.
126
167
  * @param {number} [count=Infinity]
127
- * @returns {Tween}
128
- * @memberof TweenSystem */
168
+ * @returns {Tween<T>} */
129
169
  loop(count = Infinity)
130
170
  {
131
171
  this.loopRemaining = count;
132
- this.thenCallback = () => loopContinuation(this);
172
+ this.thenCallback = () => tweenLoopContinuation(this);
133
173
  return this;
134
174
  }
135
175
 
136
176
  /** Like `loop`, but swap `start` and `end` between iterations so the value
137
177
  * bounces back and forth. `pingPong()` with no argument bounces forever.
138
178
  *
139
- * Mutually exclusive with `loop`; calling either replaces the other, and
140
- * calling `then` after either clears the loop (last call wins).
179
+ * Mutually exclusive with `loop`; calling either replaces the other.
180
+ * A `then` callback, set before or after, is called when the last
181
+ * iteration ends.
141
182
  * @param {number} [count=Infinity]
142
- * @returns {Tween}
143
- * @memberof TweenSystem */
183
+ * @returns {Tween<T>} */
144
184
  pingPong(count = Infinity)
145
185
  {
146
186
  this.loopRemaining = count;
147
- this.thenCallback = () => pingPongContinuation(this);
187
+ this.thenCallback = () => tweenPingPongContinuation(this);
148
188
  return this;
149
189
  }
150
190
 
151
- /** Pause this tween. While paused, tweenUpdate skips it.
152
- * @memberof TweenSystem */
191
+ /** Pause this tween. While paused, tweenUpdate skips it. */
153
192
  pause() { this.paused = true; }
154
193
 
155
- /** Resume a paused tween.
156
- * @memberof TweenSystem */
194
+ /** Resume a paused tween. */
157
195
  resume() { this.paused = false; }
158
196
 
159
197
  /** Reset this tween to the start: life back to duration, pause cleared,
160
198
  * re-added to the active list if previously stopped, and the callback
161
199
  * re-fired with the start value.
162
- * @memberof TweenSystem */
200
+ * It replays one pass: a loop or pingPong that has finished is not started
201
+ * over, a pingPong that ended on its way back plays that way again, and a
202
+ * restart mid loop keeps the iterations left. Call loop or pingPong again
203
+ * after restart to repeat it. The `then` callback is kept and is called
204
+ * again when it completes. */
163
205
  restart()
164
206
  {
165
207
  this.life = this.duration;
166
208
  this.paused = false;
167
- if (tweenActive.indexOf(this) < 0) tweenActive.push(this);
209
+ this.lastTime = time;
210
+ this.lastTimeReal = timeReal;
211
+ tweenActivate(this);
168
212
  this.callback(this.interp(this.duration));
169
213
  }
170
214
 
171
215
  /** True if this tween is in the active list and not paused.
172
- * @returns {boolean}
173
- * @memberof TweenSystem */
216
+ * @returns {boolean} */
174
217
  isActive()
175
218
  {
176
- return !this.paused && tweenActive.indexOf(this) >= 0;
219
+ return !this.paused && this.active;
177
220
  }
178
221
 
179
222
  /** Get how far this tween has progressed, from 0 (just started) to 1
180
223
  * (completed). Clamped — overshoot past completion still reads 1.
181
- * @returns {number}
182
- * @memberof TweenSystem */
224
+ * @returns {number} */
183
225
  getPercent()
184
226
  {
185
227
  return percent(this.duration - this.life, 0, this.duration);
186
228
  }
187
229
 
188
230
  /** Get the current interpolated value (the value most recently passed to
189
- * the callback). Returns a number, Vector2, or Color depending on the
231
+ * the callback). Returns a number, Vector2, Vector3 or Color depending on the
190
232
  * tween's start/end types.
191
- * @returns {number|Vector2|Color}
192
- * @memberof TweenSystem */
233
+ * @returns {T} */
193
234
  getValue()
194
235
  {
195
236
  return this.interp(this.life);
@@ -197,23 +238,34 @@ class Tween
197
238
 
198
239
  /** Compute the interpolated value at the given remaining `life`.
199
240
  * At life === duration the result is `start`; at life === 0 it is `end`.
241
+ * - At life 0 it is the end value exactly
242
+ * - A vector goes past its ends as far as the easing does, as a number does; a Color stays between them,
243
+ * so its channels stay in range, and any other type goes as far as its own lerp takes it
200
244
  * @param {number} life
201
- * @returns {number}
202
- * @memberof TweenSystem */
245
+ * @returns {T} */
203
246
  interp(life)
204
247
  {
248
+ // the ends of whatever type it tweens, each kind is handled below
249
+ const s = /** @type {any} */ (this.start), e = /** @type {any} */ (this.end);
250
+ if (life <= 0) // the end exactly, an easing curve may land a rounding error short of it
251
+ return typeof e.copy === 'function' ? e.copy() : e;
205
252
  const x = this.ease((this.duration - life) / this.duration);
206
- if (isLerpable(this.start))
207
- return this.start.lerp(this.end, x);
208
- return this.start + (this.end - this.start) * x;
253
+ // the vectors as their lerp does it, which lands on the end exactly, but without its clamp
254
+ const y = 1 - x;
255
+ if (s instanceof Vector2)
256
+ return /** @type {T} */ (vec2(e.x * x + s.x * y, e.y * x + s.y * y));
257
+ if (typeof Vector3 !== 'undefined' && s instanceof Vector3) // a build may leave out the 3D math
258
+ return /** @type {T} */ (vec3(e.x * x + s.x * y, e.y * x + s.y * y, e.z * x + s.z * y));
259
+ if (tweenIsLerpable(s))
260
+ return s.lerp(e, x);
261
+ return s + (e - s) * x;
209
262
  }
210
263
 
211
- /** Remove this tween from the active list and prevent any pending then-callback.
212
- * @memberof TweenSystem */
264
+ /** Remove this tween from the active list, ending a loop or pingPong too, without calling
265
+ * the then-callback. It keeps the then-callback, so a restart calls it when it completes. */
213
266
  stop()
214
267
  {
215
- const i = tweenActive.indexOf(this);
216
- if (i >= 0) tweenActive.splice(i, 1);
268
+ tweenDeactivate(this);
217
269
  this.thenCallback = undefined;
218
270
  }
219
271
  }
@@ -234,53 +286,54 @@ const Ease =
234
286
  /** Linear (identity) curve.
235
287
  * @param {number} x
236
288
  * @returns {number}
237
- * @memberof TweenSystem */
289
+ * @memberof TweenSystem.Ease */
238
290
  LINEAR: (x) => x,
239
291
 
240
292
  /** Power curve factory: `Ease.POWER(n)` returns `x => x**n`.
241
293
  * Use n=2 for quadratic, n=3 for cubic, etc.
242
294
  * @param {number} n
243
295
  * @returns {function(number):number}
244
- * @memberof TweenSystem */
296
+ * @memberof TweenSystem.Ease */
245
297
  POWER: (n) => (x) => x ** n,
246
298
 
247
299
  /** Sine ease-in curve: starts slow, ends fast.
248
300
  * @param {number} x
249
301
  * @returns {number}
250
- * @memberof TweenSystem */
302
+ * @memberof TweenSystem.Ease */
251
303
  SINE: (x) => 1 - cos(x * (PI / 2)),
252
304
 
253
305
  /** Circular ease-in curve.
254
306
  * @param {number} x
255
307
  * @returns {number}
256
- * @memberof TweenSystem */
308
+ * @memberof TweenSystem.Ease */
257
309
  CIRC: (x) => 1 - (1 - x * x)**.5,
258
310
 
259
311
  /** Exponential ease-in curve (`2^(10x-10)`).
260
312
  * @param {number} x
261
313
  * @returns {number}
262
- * @memberof TweenSystem */
314
+ * @memberof TweenSystem.Ease */
263
315
  EXPO: (x) => x === 0 ? 0 : 2 ** (10 * x - 10),
264
316
 
265
317
  /** Back ease-in: overshoots backward at the start before snapping forward.
266
318
  * @param {number} x
267
319
  * @returns {number}
268
- * @memberof TweenSystem */
320
+ * @memberof TweenSystem.Ease */
269
321
  BACK: (x) => x * x * (2.70158 * x - 1.70158),
270
322
 
271
- /** Elastic ease-in: oscillates with decreasing amplitude.
323
+ /** Elastic ease-in: oscillations that grow toward the end.
272
324
  * @param {number} x
273
325
  * @returns {number}
274
- * @memberof TweenSystem */
326
+ * @memberof TweenSystem.Ease */
275
327
  ELASTIC: (x) =>
276
328
  x === 0 ? 0 :
277
329
  x === 1 ? 1 :
278
330
  -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
279
331
 
280
- /** Spring-like ease-out: oscillates outward after passing the target.
332
+ /** Spring ease-in: wobbles around the start before springing to the end;
333
+ * `Ease.OUT(Ease.SPRING)` overshoots and settles on the target.
281
334
  * @param {number} x
282
335
  * @returns {number}
283
- * @memberof TweenSystem */
336
+ * @memberof TweenSystem.Ease */
284
337
  SPRING: (x) =>
285
338
  1 -
286
339
  (sin(PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
@@ -288,15 +341,15 @@ const Ease =
288
341
  (1 - x)) *
289
342
  (1.0 + 1.2 * x),
290
343
 
291
- /** Bouncing ease-in: slow ramp with bouncing impacts near the end.
344
+ /** Bouncing ease-in: small bounces near the start, then a rise to the end.
292
345
  * Symmetric with the other base curves, which are all ease-in. To get the
293
346
  * classic "object falls and hits the ground" shape (bounces near x=1),
294
347
  * wrap with `Ease.OUT`: `Ease.OUT(Ease.BOUNCE)`.
295
348
  * @param {number} x
296
349
  * @returns {number}
297
- * @memberof TweenSystem
350
+ * @memberof TweenSystem.Ease
298
351
  * @example
299
- * Ease.BOUNCE // ease-in bounce (slow, then bouncy at end)
352
+ * Ease.BOUNCE // ease-in bounce (bouncy at start)
300
353
  * Ease.OUT(Ease.BOUNCE) // ease-out bounce (object hits ground)
301
354
  * Ease.IN_OUT(Ease.BOUNCE) // bounces at both ends
302
355
  */
@@ -317,7 +370,7 @@ const Ease =
317
370
  * picking the direction programmatically.
318
371
  * @param {function(number):number} f - Curve to use as ease-in (returned unchanged)
319
372
  * @returns {function(number):number}
320
- * @memberof TweenSystem
373
+ * @memberof TweenSystem.Ease
321
374
  * @example
322
375
  * // Pick direction at runtime
323
376
  * const dir = bouncyMode ? Ease.OUT : Ease.IN;
@@ -328,18 +381,16 @@ const Ease =
328
381
  /** Reverse a curve so it eases out instead of in: `x => 1 - f(1 - x)`.
329
382
  * @param {function(number):number} f
330
383
  * @returns {function(number):number}
331
- * @memberof TweenSystem
384
+ * @memberof TweenSystem.Ease
332
385
  * @example
333
386
  * Ease.OUT(Ease.POWER(2)) // ease-out quadratic
334
387
  */
335
388
  OUT: (f) => (x) => 1 - f(1 - x),
336
389
 
337
390
  /** Combine the first half of `f` with `Ease.OUT(f)` for a symmetric curve.
338
- * Bug-fix vs the original library: the original referenced an undefined
339
- * global `Piecewise`; this implementation routes through `Ease.PIECEWISE`.
340
391
  * @param {function(number):number} f
341
392
  * @returns {function(number):number}
342
- * @memberof TweenSystem */
393
+ * @memberof TweenSystem.Ease */
343
394
  IN_OUT: (f) => Ease.PIECEWISE(f, Ease.OUT(f)),
344
395
 
345
396
  /** Split [0,1] into N equal sections and run a different curve in each.
@@ -347,7 +398,7 @@ const Ease =
347
398
  * and its output is mapped to [i/n, (i+1)/n] of the overall range.
348
399
  * @param {...function(number):number} fns
349
400
  * @returns {function(number):number}
350
- * @memberof TweenSystem */
401
+ * @memberof TweenSystem.Ease */
351
402
  PIECEWISE: (...fns) =>
352
403
  {
353
404
  const n = fns.length;
@@ -365,7 +416,7 @@ const Ease =
365
416
  * @param {number} x2
366
417
  * @param {number} y2
367
418
  * @returns {function(number):number}
368
- * @memberof TweenSystem
419
+ * @memberof TweenSystem.Ease
369
420
  * @example
370
421
  * Ease.BEZIER(0.25, 0.1, 0.25, 1) // CSS "ease"
371
422
  */
@@ -382,14 +433,17 @@ const Ease =
382
433
  };
383
434
  return (x) =>
384
435
  {
385
- // Binary search for t such that curve(t).x ≈ x, then return curve(t).y.
436
+ // the ends are exact, a tween starts and ends on its values
437
+ if (x <= 0) return 0;
438
+ if (x >= 1) return 1;
439
+
440
+ // binary search for t such that curve(t).x = x, then return curve(t).y; a fixed count, since stopping
441
+ // once x is close can leave y far off where the curve is steep, 32 halvings put t within 1e-9
386
442
  let t0 = 0, t1 = 1;
387
- for (let i = 0; i < 128; i++)
443
+ for (let i = 32; i--;)
388
444
  {
389
445
  const tMid = (t0 + t1) / 2;
390
- const [bx, by] = curve(tMid);
391
- if (abs(bx - x) < 1e-5) return by;
392
- if (bx < x) t0 = tMid; else t1 = tMid;
446
+ if (curve(tMid)[0] < x) t0 = tMid; else t1 = tMid;
393
447
  }
394
448
  return curve((t0 + t1) / 2)[1];
395
449
  };
@@ -400,15 +454,23 @@ const Ease =
400
454
  * so all chaining methods (`setEase`, `then`, `loop`, `pingPong`, etc.)
401
455
  * remain available.
402
456
  *
403
- * `start` and `end` may be numbers, Vector2 instances, Color instances, or
457
+ * `start` and `end` may be numbers, Vector2, Vector3 or Color instances, or
404
458
  * any object with a `lerp(other, percent) => sameType` method.
459
+ *
460
+ * It stops on its own once the target is destroyed, so a looping tween on an object ends with it; a tween on a
461
+ * value inside the object, like `tweenProperty(obj.pos, 'x')`, or a new Tween with its own callback, has to be
462
+ * stopped by the game.
463
+ * @template [T=any]
405
464
  * @param {Object} target - The object whose property is being animated
406
465
  * @param {string} propertyPath - Dot-separated path, e.g. `'pos.x'` or `'color'`
407
- * @param {number|Vector2|Color} start - Starting value
408
- * @param {number|Vector2|Color} end - Ending value
466
+ * @param {T} start - Starting value
467
+ * @param {T} end - Ending value
409
468
  * @param {number} [duration=1] - Duration in seconds
410
469
  * @param {Object} [options] - Same options as the Tween constructor
411
- * @returns {Tween}
470
+ * @param {function(number):number} [options.ease] - Easing function (defaults to LINEAR)
471
+ * @param {boolean} [options.useRealTime=false] - Advance even when the game is paused
472
+ * @param {boolean} [options.paused=false] - Start in paused state
473
+ * @returns {Tween<T>}
412
474
  * @memberof TweenSystem
413
475
  * @example
414
476
  * // Numeric: slide an object's x with an ease-out sine curve
@@ -425,8 +487,11 @@ function tweenProperty(target, propertyPath, start, end, duration = 1, options =
425
487
 
426
488
  const parts = propertyPath.split('.');
427
489
  const lastKey = parts.pop();
490
+ let tween;
428
491
  const callback = (value) =>
429
492
  {
493
+ // a destroyed object ends it, a loop or pingPong would run on it for good and keep it from being freed
494
+ if (target.destroyed) return void tween?.stop();
430
495
  let obj = target;
431
496
  for (const k of parts)
432
497
  {
@@ -435,92 +500,145 @@ function tweenProperty(target, propertyPath, start, end, duration = 1, options =
435
500
  }
436
501
  obj[lastKey] = value;
437
502
  };
438
- return new Tween(callback, start, end, duration, options);
503
+ tween = new Tween(callback, start, end, duration, options);
504
+ tween.target = target;
505
+ return tween;
439
506
  }
440
507
 
441
- // Continuation that schedules the next loop iteration when one finishes.
442
- // Reuses the same Tween object across iterations so the user's handle
443
- // from `.loop()` keeps working — calling `.stop()` mid-loop now cancels
444
- // the entire chain instead of just the current iteration.
445
- function loopContinuation(tween)
508
+ // Start the next iteration with the time the last one ran over already spent, so a loop keeps its
509
+ // pace; an update that ran over by more than a whole iteration lands where the cycle is, not owing it
510
+ function tweenCarryOvershoot(tween)
446
511
  {
447
- if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
448
- if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
449
- tween.life = tween.duration;
450
- tween.thenCallback = () => loopContinuation(tween);
451
- tweenActive.push(tween);
452
- // snap to start for the new iteration (matches Tween constructor behavior)
453
- tween.callback(tween.interp(tween.duration));
512
+ const duration = tween.duration;
513
+ tween.life = duration ? min(tween.life, 0) % duration + duration : 1e-9;
454
514
  }
455
515
 
456
- // Continuation for pingPong: swaps start and end on the same tween each iteration.
457
- function pingPongContinuation(tween)
516
+ // How many iterations the update that finished one ran through: that one and every whole one after it
517
+ function tweenPassed(tween)
458
518
  {
459
- if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
460
- if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
461
- const tmp = tween.start;
462
- tween.start = tween.end;
463
- tween.end = tmp;
464
- tween.life = tween.duration;
465
- tween.thenCallback = () => pingPongContinuation(tween);
466
- tweenActive.push(tween);
467
- tween.callback(tween.interp(tween.duration));
519
+ const duration = tween.duration;
520
+ return duration ? 1 + floor(-min(tween.life, 0) / duration) : 1;
521
+ }
522
+
523
+ // start the next iteration of a loop or pingPong, the time the last one ran over already spent, true
524
+ function tweenNextIteration(tween, passed, continuation)
525
+ {
526
+ tween.loopRemaining -= passed;
527
+ tweenCarryOvershoot(tween);
528
+ tween.thenCallback = continuation;
529
+ tweenActivate(tween);
530
+ tween.callback(tween.interp(tween.life)); // snap to where the new iteration is
531
+ return true;
532
+ }
533
+
534
+ // Continuation that schedules the next loop iteration when one finishes, true if it did.
535
+ // Reuses the same Tween across iterations, so the handle from `.loop()` pauses or stops the whole loop.
536
+ function tweenLoopContinuation(tween)
537
+ {
538
+ // count every iteration that went by, a finite loop ends once they run out
539
+ const passed = tweenPassed(tween);
540
+ if (tween.loopRemaining <= passed) return false; // Infinity never runs out
541
+ return tweenNextIteration(tween, passed, () => tweenLoopContinuation(tween));
542
+ }
543
+
544
+ // Continuation for pingPong: swaps start and end on the same tween each iteration, true if it started another.
545
+ function tweenPingPongContinuation(tween)
546
+ {
547
+ // swap the ends once for each iteration that went by, but when they run out the last one keeps its
548
+ // direction, so it ends on the end it really reached and a restart plays that way again
549
+ const passed = tweenPassed(tween);
550
+ const done = tween.loopRemaining <= passed; // Infinity never runs out
551
+ const swaps = done ? max(tween.loopRemaining - 1, 0) : passed;
552
+ if (swaps & 1)
553
+ {
554
+ const tmp = tween.start;
555
+ tween.start = tween.end;
556
+ tween.end = tmp;
557
+ }
558
+ if (done)
559
+ {
560
+ // the completion gave the other end, give the one it finished on
561
+ if (swaps & 1)
562
+ tween.callback(tween.interp(0));
563
+ return false;
564
+ }
565
+ return tweenNextIteration(tween, passed, () => tweenPingPongContinuation(tween));
468
566
  }
469
567
 
470
568
  /** Engine plugin hook: advance every active tween by the appropriate delta.
471
- * Called once per render frame by the engine (no arguments). May also be
472
- * called explicitly with `(gameDelta, realDelta)` to drive tweens manually
473
- * — useful for headless tests or custom replay/scrubbing systems.
474
- * @param {number} [gameDelta] - Game-time delta in seconds; default: time - lastTime
475
- * @param {number} [realDelta] - Real-time delta in seconds; default: timeReal - lastTimeReal
569
+ * The engine calls it with no arguments on every fixed update, so it can run
570
+ * more than once in a rendered frame, and on paused updates too, where only
571
+ * real time tweens move. May also be called with `(gameDelta, realDelta)` to drive tweens without the engine
572
+ * loop, for headless tests or an engine in manual step that is not stepped; in a running game such a call adds to
573
+ * the engine's own update, and a delta of 0 or less does nothing.
574
+ * @param {number} [gameDelta] - Game-time delta in seconds; default: game time since the tween's last engine update
575
+ * @param {number} [realDelta] - Real-time delta in seconds; default: real time since the tween's last engine update
476
576
  * @memberof TweenSystem */
477
577
  function tweenUpdate(gameDelta, realDelta)
478
578
  {
479
- if (gameDelta === undefined)
480
- {
481
- // Engine path: compute deltas from engine time globals.
482
- gameDelta = time - lastTime;
483
- realDelta = timeReal - lastTimeReal;
484
- lastTime = time;
485
- lastTimeReal = timeReal;
486
- }
487
- else if (realDelta === undefined)
579
+ // Engine path: each tween moves by the time since its own last update, so one made
580
+ // this update, before or after this call, first moves on the next, like a Timer
581
+ const enginePath = gameDelta === undefined;
582
+ if (!enginePath && realDelta === undefined)
488
583
  {
489
584
  // Manual path with one arg: real and game advance together.
490
585
  realDelta = gameDelta;
491
586
  }
492
587
 
493
- // Iterate in reverse so removals don't disturb iteration.
494
- for (let i = tweenActive.length; i--;)
588
+ // Move the tweens that were active when this update began, each once: a callback
589
+ // may stop any tween, even all of them, and one that is stopped is skipped; a tween
590
+ // made or started again during the update, like the next turn of a loop, moves on
591
+ // from the next update. Newest first, as the list has always been walked.
592
+ // a callback that calls tweenUpdate itself gets a list of its own, the outer update is still walking this one
593
+ const list = tweenUpdateList.length ? [] : tweenUpdateList, pass = ++tweenUpdatePass;
594
+ for (const t of tweenActive)
595
+ list.push(t);
596
+ for (let i = list.length; i--;)
495
597
  {
496
- const t = tweenActive[i];
497
- if (t.paused) continue;
498
- const dt = t.useRealTime ? realDelta : gameDelta;
499
- if (dt <= 0) continue;
598
+ const t = list[i];
599
+ if (!t.active || t.activePass === pass) continue; // stopped, or started again by a callback this update
600
+ let dt;
601
+ if (enginePath)
602
+ {
603
+ // a paused tween keeps count too, so it does not jump when resumed
604
+ dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime;
605
+ t.lastTime = time;
606
+ t.lastTimeReal = timeReal;
607
+ }
608
+ else
609
+ dt = t.useRealTime ? realDelta : gameDelta;
610
+ if (t.target?.destroyed) { t.stop(); continue; } // its object is gone, paused or not
611
+ if (t.paused || dt <= 0) continue;
500
612
 
501
613
  t.life -= dt;
502
- if (t.life > 0)
614
+ if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration
503
615
  {
504
616
  t.callback(t.interp(t.life));
505
617
  }
506
618
  else
507
619
  {
508
- // Completion: fire end value, remove from active, fire then-callback.
620
+ // Completion: fire end value, remove from active, start the next iteration
621
+ // of a loop or pingPong, or when there is none it has completed, fire onComplete
509
622
  t.callback(t.interp(0));
510
- tweenActive.splice(i, 1);
511
- const cb = t.thenCallback;
623
+ if (!t.active || t.activePass === pass)
624
+ continue; // stopped or restarted by its own callback, the run it was on ends without completing
625
+ tweenDeactivate(t);
626
+ const next = t.thenCallback;
512
627
  t.thenCallback = undefined;
513
- if (cb) cb();
628
+ if (!(next && next()) && t.onComplete)
629
+ t.onComplete();
514
630
  }
515
631
  }
632
+ list.length = 0;
516
633
  }
517
634
 
518
- /** Stop every active tween and clear their then-callbacks. Useful for resets
519
- * on level transitions or when changing scenes.
635
+ /** Stop every active tween, ending loops too, without calling their then-callbacks.
636
+ * Useful for resets on level transitions or when changing scenes.
520
637
  * @memberof TweenSystem */
521
638
  function tweenStopAll()
522
639
  {
523
- for (const t of tweenActive) t.thenCallback = undefined;
640
+ for (const t of tweenActive)
641
+ t.thenCallback = undefined, t.active = false;
524
642
  tweenActive.length = 0;
525
643
  }
526
644