littlejsengine 1.18.28 → 1.19.3

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.
@@ -1,526 +1,528 @@
1
- /**
2
- * LittleJS Tween System Plugin
3
- * - Lightweight tweens for numbers, Vector2, Color, or any .lerp-able type
4
- * - Chainable easing, looping, and ping-pong
5
- * - Property-path helper for the common case of animating an object field
6
- * - Auto-updates via engineAddPlugin; pauses with the game by default
7
- * @namespace TweenSystem
8
- */
9
-
10
- 'use strict';
11
-
12
- ///////////////////////////////////////////////////////////////////////////////
13
-
14
- // Module-private list of tweens currently running.
15
- const tweenActive = [];
16
-
17
- // Time tracking for delta computation between engine plugin calls.
18
- let lastTime = 0;
19
- let lastTimeReal = 0;
20
-
21
- // True if the value is an instance of a class that exposes a numeric-percent
22
- // `lerp(other, percent)` method (Vector2, Color, or any future class).
23
- function isLerpable(v) { return v && typeof v.lerp === 'function'; }
24
-
25
- ///////////////////////////////////////////////////////////////////////////////
26
-
27
- /** A numeric tween: drives a callback with a value interpolated between
28
- * `start` and `end` over `duration` seconds. Pauses with the game by default.
29
- * @memberof TweenSystem
30
- * @example
31
- * // Animate a fade-out over 2 seconds with an ease-out sine curve.
32
- * new Tween((v) => obj.alpha = v, 1, 0, 2, { ease: Ease.OUT(Ease.SINE) });
33
- */
34
- class Tween
35
- {
36
- /** Create a new tween. The callback fires immediately with `start` so the
37
- * target snaps to the start value on the same frame the tween is created.
38
- *
39
- * `start` and `end` may be numbers, Vector2 instances, Color instances, or
40
- * any object exposing a `lerp(other, percent) => sameType` method. The
41
- * callback receives the interpolated value (a number, or a fresh instance
42
- * 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
46
- * @param {number} [duration=1] - Duration in seconds
47
- * @param {Object} [options]
48
- * @param {function(number):number} [options.ease] - Easing function (defaults to LINEAR)
49
- * @param {boolean} [options.useRealTime=false] - Advance even when the game is paused (matches Timer's useRealTime)
50
- * @param {boolean} [options.paused=false] - Start in paused state */
51
- constructor(callback, start = 0, end = 1, duration = 1, options = {})
52
- {
53
- ASSERT(typeof callback === 'function', 'Tween callback must be a function');
54
- if (isLerpable(start))
55
- {
56
- ASSERT(start.constructor === end.constructor,
57
- 'Tween start and end must be the same type');
58
- }
59
- else
60
- {
61
- ASSERT(isNumber(start), 'Tween start must be a number or have a .lerp method');
62
- ASSERT(isNumber(end), 'Tween end must be a number when start is a number');
63
- }
64
- ASSERT(isNumber(duration) && duration > 0, 'Tween duration must be > 0');
65
-
66
- /** @property {function(number|Vector2|Color):void} - Called with the interpolated value each frame */
67
- this.callback = callback;
68
- /** @property {number|Vector2|Color} - Starting value */
69
- this.start = start;
70
- /** @property {number|Vector2|Color} - Ending value */
71
- this.end = end;
72
- /** @property {number} - Total duration in seconds */
73
- this.duration = duration;
74
- /** @property {number} - Remaining time in seconds (counts down from duration to 0) */
75
- this.life = duration;
76
- /** @property {function(number):number} - Easing curve mapping [0,1] -> [0,1] */
77
- this.ease = options.ease || Ease.LINEAR;
78
- /** @property {boolean} - If true, advance even when the game is paused */
79
- this.useRealTime = !!options.useRealTime;
80
- /** @property {boolean} - If true, stop advancing until cleared */
81
- this.paused = !!options.paused;
82
-
83
- /** @private completion callback set by then(), loop(), pingPong(). */
84
- this.thenCallback = undefined;
85
- /** @private remaining iterations including the current run (loop/pingPong only). */
86
- this.loopRemaining = 0;
87
-
88
- tweenActive.push(this);
89
- // Snap target to start immediately.
90
- callback(this.interp(duration));
91
- }
92
-
93
- /** Set the easing curve and return this for chaining.
94
- * @param {function(number):number} easeFn
95
- * @returns {Tween}
96
- * @memberof TweenSystem */
97
- setEase(easeFn)
98
- {
99
- this.ease = easeFn;
100
- return this;
101
- }
102
-
103
- /** Set a single completion callback. Calling `then` again replaces the
104
- * previous callback. Returns this for chaining.
105
- *
106
- * Calling `then` after `loop` or `pingPong` overrides the loop chain
107
- * (last call wins).
108
- * @param {function():void} callback
109
- * @returns {Tween}
110
- * @memberof TweenSystem */
111
- then(callback)
112
- {
113
- this.thenCallback = callback;
114
- this.loopRemaining = 0;
115
- return this;
116
- }
117
-
118
- /** Repeat this tween `n` total times. After each iteration finishes, a
119
- * fresh tween with the same parameters takes over via the `then` slot.
120
- * `loop()` with no argument loops forever.
121
- *
122
- * Mutually exclusive with `pingPong`; calling either replaces the other,
123
- * and calling `then` after either clears the loop (last call wins).
124
- * @param {number} [count=Infinity]
125
- * @returns {Tween}
126
- * @memberof TweenSystem */
127
- loop(count = Infinity)
128
- {
129
- this.loopRemaining = count;
130
- this.thenCallback = () => loopContinuation(this);
131
- return this;
132
- }
133
-
134
- /** Like `loop`, but swap `start` and `end` between iterations so the value
135
- * bounces back and forth. `pingPong()` with no argument bounces forever.
136
- *
137
- * Mutually exclusive with `loop`; calling either replaces the other, and
138
- * calling `then` after either clears the loop (last call wins).
139
- * @param {number} [count=Infinity]
140
- * @returns {Tween}
141
- * @memberof TweenSystem */
142
- pingPong(count = Infinity)
143
- {
144
- this.loopRemaining = count;
145
- this.thenCallback = () => pingPongContinuation(this);
146
- return this;
147
- }
148
-
149
- /** Pause this tween. While paused, tweenUpdate skips it.
150
- * @memberof TweenSystem */
151
- pause() { this.paused = true; }
152
-
153
- /** Resume a paused tween.
154
- * @memberof TweenSystem */
155
- resume() { this.paused = false; }
156
-
157
- /** Reset this tween to the start: life back to duration, pause cleared,
158
- * re-added to the active list if previously stopped, and the callback
159
- * re-fired with the start value.
160
- * @memberof TweenSystem */
161
- restart()
162
- {
163
- this.life = this.duration;
164
- this.paused = false;
165
- if (tweenActive.indexOf(this) < 0) tweenActive.push(this);
166
- this.callback(this.interp(this.duration));
167
- }
168
-
169
- /** True if this tween is in the active list and not paused.
170
- * @returns {boolean}
171
- * @memberof TweenSystem */
172
- isActive()
173
- {
174
- return !this.paused && tweenActive.indexOf(this) >= 0;
175
- }
176
-
177
- /** Get how far this tween has progressed, from 0 (just started) to 1
178
- * (completed). Clamped — overshoot past completion still reads 1.
179
- * @returns {number}
180
- * @memberof TweenSystem */
181
- getPercent()
182
- {
183
- return percent(this.duration - this.life, 0, this.duration);
184
- }
185
-
186
- /** Get the current interpolated value (the value most recently passed to
187
- * the callback). Returns a number, Vector2, or Color depending on the
188
- * tween's start/end types.
189
- * @returns {number|Vector2|Color}
190
- * @memberof TweenSystem */
191
- getValue()
192
- {
193
- return this.interp(this.life);
194
- }
195
-
196
- /** Compute the interpolated value at the given remaining `life`.
197
- * At life === duration the result is `start`; at life === 0 it is `end`.
198
- * @param {number} life
199
- * @returns {number}
200
- * @memberof TweenSystem */
201
- interp(life)
202
- {
203
- const x = this.ease((this.duration - life) / this.duration);
204
- if (isLerpable(this.start))
205
- return this.start.lerp(this.end, x);
206
- return this.start + (this.end - this.start) * x;
207
- }
208
-
209
- /** Remove this tween from the active list and prevent any pending then-callback.
210
- * @memberof TweenSystem */
211
- stop()
212
- {
213
- const i = tweenActive.indexOf(this);
214
- if (i >= 0) tweenActive.splice(i, 1);
215
- this.thenCallback = undefined;
216
- }
217
- }
218
-
219
- /** Library of named easing curves and direction modifiers.
220
- * All curves accept `x` in [0,1] and return [0,1] (with possible overshoot
221
- * for ELASTIC/BACK/SPRING/BOUNCE). Curves are values you pass to `setEase`
222
- * or compose via the IN/OUT/IN_OUT/PIECEWISE/BEZIER modifiers.
223
- * @memberof TweenSystem
224
- * @example
225
- * // Use a basic curve
226
- * new Tween(callback, 0, 10, 1).setEase(Ease.SINE);
227
- * // Use a modifier on a curve
228
- * new Tween(callback, 0, 10, 1).setEase(Ease.OUT(Ease.BACK));
229
- */
230
- const Ease =
231
- {
232
- /** Linear (identity) curve.
233
- * @param {number} x
234
- * @returns {number}
235
- * @memberof TweenSystem */
236
- LINEAR: (x) => x,
237
-
238
- /** Power curve factory: `Ease.POWER(n)` returns `x => x**n`.
239
- * Use n=2 for quadratic, n=3 for cubic, etc.
240
- * @param {number} n
241
- * @returns {function(number):number}
242
- * @memberof TweenSystem */
243
- POWER: (n) => (x) => x ** n,
244
-
245
- /** Sine ease-in curve: starts slow, ends fast.
246
- * @param {number} x
247
- * @returns {number}
248
- * @memberof TweenSystem */
249
- SINE: (x) => 1 - cos(x * (PI / 2)),
250
-
251
- /** Circular ease-in curve.
252
- * @param {number} x
253
- * @returns {number}
254
- * @memberof TweenSystem */
255
- CIRC: (x) => 1 - (1 - x * x)**.5,
256
-
257
- /** Exponential ease-in curve (`2^(10x-10)`).
258
- * @param {number} x
259
- * @returns {number}
260
- * @memberof TweenSystem */
261
- EXPO: (x) => x === 0 ? 0 : 2 ** (10 * x - 10),
262
-
263
- /** Back ease-in: overshoots backward at the start before snapping forward.
264
- * @param {number} x
265
- * @returns {number}
266
- * @memberof TweenSystem */
267
- BACK: (x) => x * x * (2.70158 * x - 1.70158),
268
-
269
- /** Elastic ease-in: oscillates with decreasing amplitude.
270
- * @param {number} x
271
- * @returns {number}
272
- * @memberof TweenSystem */
273
- ELASTIC: (x) =>
274
- x === 0 ? 0 :
275
- x === 1 ? 1 :
276
- -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
277
-
278
- /** Spring-like ease-out: oscillates outward after passing the target.
279
- * @param {number} x
280
- * @returns {number}
281
- * @memberof TweenSystem */
282
- SPRING: (x) =>
283
- 1 -
284
- (sin(PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
285
- x ** 2.2 +
286
- (1 - x)) *
287
- (1.0 + 1.2 * x),
288
-
289
- /** Bouncing ease-in: slow ramp with bouncing impacts near the end.
290
- * Symmetric with the other base curves, which are all ease-in. To get the
291
- * classic "object falls and hits the ground" shape (bounces near x=1),
292
- * wrap with `Ease.OUT`: `Ease.OUT(Ease.BOUNCE)`.
293
- * @param {number} x
294
- * @returns {number}
295
- * @memberof TweenSystem
296
- * @example
297
- * Ease.BOUNCE // ease-in bounce (slow, then bouncy at end)
298
- * Ease.OUT(Ease.BOUNCE) // ease-out bounce (object hits ground)
299
- * Ease.IN_OUT(Ease.BOUNCE) // bounces at both ends
300
- */
301
- BOUNCE: (x) =>
302
- {
303
- // Inverted form of the standard easeOutBounce: 1 - bounceOut(1 - x).
304
- let t = 1 - x, f;
305
- if (t < 4 / 11) f = 7.5625 * t * t;
306
- else if (t < 8 / 11) f = 7.5625 * (t -= 6 / 11) * t + 0.75;
307
- else if (t < 10 / 11) f = 7.5625 * (t -= 9 / 11) * t + 0.9375;
308
- else f = 7.5625 * (t -= 10.5 / 11) * t + 0.984375;
309
- return 1 - f;
310
- },
311
-
312
- /** Ease-in direction modifier: returns the curve unchanged. Symmetric
313
- * with `OUT` and `IN_OUT`. Base curves are already ease-in by
314
- * convention, so wrapping a curve in `IN` is a no-op useful when
315
- * picking the direction programmatically.
316
- * @param {function(number):number} f - Curve to use as ease-in (returned unchanged)
317
- * @returns {function(number):number}
318
- * @memberof TweenSystem
319
- * @example
320
- * // Pick direction at runtime
321
- * const dir = bouncyMode ? Ease.OUT : Ease.IN;
322
- * new Tween(cb, 0, 10, 1).setEase(dir(Ease.BACK));
323
- */
324
- IN: (f) => f,
325
-
326
- /** Reverse a curve so it eases out instead of in: `x => 1 - f(1 - x)`.
327
- * @param {function(number):number} f
328
- * @returns {function(number):number}
329
- * @memberof TweenSystem
330
- * @example
331
- * Ease.OUT(Ease.POWER(2)) // ease-out quadratic
332
- */
333
- OUT: (f) => (x) => 1 - f(1 - x),
334
-
335
- /** Combine the first half of `f` with `Ease.OUT(f)` for a symmetric curve.
336
- * Bug-fix vs the original library: the original referenced an undefined
337
- * global `Piecewise`; this implementation routes through `Ease.PIECEWISE`.
338
- * @param {function(number):number} f
339
- * @returns {function(number):number}
340
- * @memberof TweenSystem */
341
- IN_OUT: (f) => Ease.PIECEWISE(f, Ease.OUT(f)),
342
-
343
- /** Split [0,1] into N equal sections and run a different curve in each.
344
- * Each curve is mapped to its section: section i runs over [i/n, (i+1)/n]
345
- * and its output is mapped to [i/n, (i+1)/n] of the overall range.
346
- * @param {...function(number):number} fns
347
- * @returns {function(number):number}
348
- * @memberof TweenSystem */
349
- PIECEWISE: (...fns) =>
350
- {
351
- const n = fns.length;
352
- return (x) =>
353
- {
354
- const i = (x * n - 1e-9) >> 0;
355
- return (fns[i]((x - i / n) * n) + i) / n;
356
- };
357
- },
358
-
359
- /** Cubic Bezier curve solver in the style of CSS `cubic-bezier`.
360
- * Control points (0,0), (x1,y1), (x2,y2), (1,1).
361
- * @param {number} x1
362
- * @param {number} y1
363
- * @param {number} x2
364
- * @param {number} y2
365
- * @returns {function(number):number}
366
- * @memberof TweenSystem
367
- * @example
368
- * Ease.BEZIER(0.25, 0.1, 0.25, 1) // CSS "ease"
369
- */
370
- BEZIER: (x1, y1, x2, y2) =>
371
- {
372
- // Parametric cubic Bezier with implicit (0,0) and (1,1) endpoints.
373
- const curve = (t) =>
374
- {
375
- const u = 1 - t;
376
- const c1 = 3 * u * u * t;
377
- const c2 = 3 * u * t * t;
378
- const t3 = t ** 3;
379
- return [c1 * x1 + c2 * x2 + t3, c1 * y1 + c2 * y2 + t3];
380
- };
381
- return (x) =>
382
- {
383
- // Binary search for t such that curve(t).x ≈ x, then return curve(t).y.
384
- let t0 = 0, t1 = 1;
385
- for (let i = 0; i < 128; i++)
386
- {
387
- const tMid = (t0 + t1) / 2;
388
- const [bx, by] = curve(tMid);
389
- if (abs(bx - x) < 1e-5) return by;
390
- if (bx < x) t0 = tMid; else t1 = tMid;
391
- }
392
- return curve((t0 + t1) / 2)[1];
393
- };
394
- },
395
- };
396
-
397
- /** Tween a property on an object by dot-path. Returns the underlying Tween
398
- * so all chaining methods (`setEase`, `then`, `loop`, `pingPong`, etc.)
399
- * remain available.
400
- *
401
- * `start` and `end` may be numbers, Vector2 instances, Color instances, or
402
- * any object with a `lerp(other, percent) => sameType` method.
403
- * @param {Object} target - The object whose property is being animated
404
- * @param {string} propertyPath - Dot-separated path, e.g. `'pos.x'` or `'color'`
405
- * @param {number|Vector2|Color} start - Starting value
406
- * @param {number|Vector2|Color} end - Ending value
407
- * @param {number} [duration=1] - Duration in seconds
408
- * @param {Object} [options] - Same options as the Tween constructor
409
- * @returns {Tween}
410
- * @memberof TweenSystem
411
- * @example
412
- * // Numeric: slide an object's x with an ease-out sine curve
413
- * tweenProperty(player, 'pos.x', 0, 10, 2).setEase(Ease.OUT(Ease.SINE));
414
- * // Vector2: animate a position diagonally
415
- * tweenProperty(player, 'pos', vec2(-5, 0), vec2(5, 3), 2);
416
- * // Color: pulse between two colors
417
- * tweenProperty(sprite, 'color', RED, BLUE, 1).pingPong();
418
- */
419
- function tweenProperty(target, propertyPath, start, end, duration = 1, options = {})
420
- {
421
- ASSERT(target != null && typeof target === 'object', 'tweenProperty target must be an object');
422
- ASSERT(isStringLike(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string');
423
-
424
- const parts = propertyPath.split('.');
425
- const lastKey = parts.pop();
426
- const callback = (value) =>
427
- {
428
- let obj = target;
429
- for (const k of parts)
430
- {
431
- obj = obj[k];
432
- ASSERT(obj != null, 'tweenProperty path does not resolve: ' + propertyPath);
433
- }
434
- obj[lastKey] = value;
435
- };
436
- return new Tween(callback, start, end, duration, options);
437
- }
438
-
439
- // Continuation that schedules the next loop iteration when one finishes.
440
- // Reuses the same Tween object across iterations so the user's handle
441
- // from `.loop()` keeps working calling `.stop()` mid-loop now cancels
442
- // the entire chain instead of just the current iteration.
443
- function loopContinuation(tween)
444
- {
445
- if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
446
- if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
447
- tween.life = tween.duration;
448
- tween.thenCallback = () => loopContinuation(tween);
449
- tweenActive.push(tween);
450
- // snap to start for the new iteration (matches Tween constructor behavior)
451
- tween.callback(tween.interp(tween.duration));
452
- }
453
-
454
- // Continuation for pingPong: swaps start and end on the same tween each iteration.
455
- function pingPongContinuation(tween)
456
- {
457
- if (tween.loopRemaining !== Infinity && tween.loopRemaining <= 1) return;
458
- if (tween.loopRemaining !== Infinity) tween.loopRemaining -= 1;
459
- const tmp = tween.start;
460
- tween.start = tween.end;
461
- tween.end = tmp;
462
- tween.life = tween.duration;
463
- tween.thenCallback = () => pingPongContinuation(tween);
464
- tweenActive.push(tween);
465
- tween.callback(tween.interp(tween.duration));
466
- }
467
-
468
- /** Engine plugin hook: advance every active tween by the appropriate delta.
469
- * Called once per render frame by the engine (no arguments). May also be
470
- * called explicitly with `(gameDelta, realDelta)` to drive tweens manually
471
- * useful for headless tests or custom replay/scrubbing systems.
472
- * @param {number} [gameDelta] - Game-time delta in seconds; default: time - lastTime
473
- * @param {number} [realDelta] - Real-time delta in seconds; default: timeReal - lastTimeReal
474
- * @memberof TweenSystem */
475
- function tweenUpdate(gameDelta, realDelta)
476
- {
477
- if (gameDelta === undefined)
478
- {
479
- // Engine path: compute deltas from engine time globals.
480
- gameDelta = time - lastTime;
481
- realDelta = timeReal - lastTimeReal;
482
- lastTime = time;
483
- lastTimeReal = timeReal;
484
- }
485
- else if (realDelta === undefined)
486
- {
487
- // Manual path with one arg: real and game advance together.
488
- realDelta = gameDelta;
489
- }
490
-
491
- // Iterate in reverse so removals don't disturb iteration.
492
- for (let i = tweenActive.length; i--;)
493
- {
494
- const t = tweenActive[i];
495
- if (t.paused) continue;
496
- const dt = t.useRealTime ? realDelta : gameDelta;
497
- if (dt <= 0) continue;
498
-
499
- t.life -= dt;
500
- if (t.life > 0)
501
- {
502
- t.callback(t.interp(t.life));
503
- }
504
- else
505
- {
506
- // Completion: fire end value, remove from active, fire then-callback.
507
- t.callback(t.interp(0));
508
- tweenActive.splice(i, 1);
509
- const cb = t.thenCallback;
510
- t.thenCallback = undefined;
511
- if (cb) cb();
512
- }
513
- }
514
- }
515
-
516
- /** Stop every active tween and clear their then-callbacks. Useful for resets
517
- * on level transitions or when changing scenes.
518
- * @memberof TweenSystem */
519
- function tweenStopAll()
520
- {
521
- for (const t of tweenActive) t.thenCallback = undefined;
522
- tweenActive.length = 0;
523
- }
524
-
525
- // Register with the engine so tweens auto-advance.
526
- engineAddPlugin(tweenUpdate);
1
+ /**
2
+ * LittleJS Tween System Plugin
3
+ * - Lightweight tweens for numbers, Vector2, Color, or any .lerp-able type
4
+ * - Chainable easing, looping, and ping-pong
5
+ * - Property-path helper for the common case of animating an object field
6
+ * - Auto-updates via engineAddPlugin; pauses with the game by default
7
+ * @namespace TweenSystem
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ ///////////////////////////////////////////////////////////////////////////////
13
+
14
+ // Module-private list of tweens currently running.
15
+ const tweenActive = [];
16
+
17
+ // Time tracking for delta computation between engine plugin calls.
18
+ let lastTime = 0;
19
+ let lastTimeReal = 0;
20
+
21
+ // True if the value is an instance of a class that exposes a numeric-percent
22
+ // `lerp(other, percent)` method (Vector2, Color, or any future class).
23
+ function isLerpable(v) { return v && typeof v.lerp === 'function'; }
24
+
25
+ ///////////////////////////////////////////////////////////////////////////////
26
+
27
+ /** A numeric tween: drives a callback with a value interpolated between
28
+ * `start` and `end` over `duration` seconds. Pauses with the game by default.
29
+ * @memberof TweenSystem
30
+ * @example
31
+ * // Animate a fade-out over 2 seconds with an ease-out sine curve.
32
+ * new Tween((v) => obj.alpha = v, 1, 0, 2, { ease: Ease.OUT(Ease.SINE) });
33
+ */
34
+ class Tween
35
+ {
36
+ /** Create a new tween. The callback fires immediately with `start` so the
37
+ * target snaps to the start value on the same frame the tween is created.
38
+ *
39
+ * `start` and `end` may be numbers, Vector2 instances, Color instances, or
40
+ * any object exposing a `lerp(other, percent) => sameType` method. The
41
+ * callback receives the interpolated value (a number, or a fresh instance
42
+ * 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
46
+ * @param {number} [duration=1] - Duration in seconds
47
+ * @param {Object} [options]
48
+ * @param {function(number):number} [options.ease] - Easing function (defaults to LINEAR)
49
+ * @param {boolean} [options.useRealTime=false] - Advance even when the game is paused (matches Timer's useRealTime)
50
+ * @param {boolean} [options.paused=false] - Start in paused state */
51
+ constructor(callback, start = 0, end = 1, duration = 1, options = {})
52
+ {
53
+ ASSERT(typeof callback === 'function', 'Tween callback must be a function');
54
+ if (isLerpable(start))
55
+ {
56
+ ASSERT(start.constructor === end.constructor,
57
+ 'Tween start and end must be the same type');
58
+ }
59
+ else
60
+ {
61
+ ASSERT(isNumber(start), 'Tween start must be a number or have a .lerp method');
62
+ ASSERT(isNumber(end), 'Tween end must be a number when start is a number');
63
+ }
64
+ ASSERT(isNumber(duration) && duration > 0, 'Tween duration must be > 0');
65
+
66
+ /** @property {function((number|Vector2|Color)):void} - Called with the interpolated value each frame */
67
+ this.callback = callback;
68
+ /** @property {number|Vector2|Color} - Starting value */
69
+ this.start = start;
70
+ /** @property {number|Vector2|Color} - Ending value */
71
+ this.end = end;
72
+ /** @property {number} - Total duration in seconds */
73
+ this.duration = duration;
74
+ /** @property {number} - Remaining time in seconds (counts down from duration to 0) */
75
+ this.life = duration;
76
+ /** @property {function(number):number} - Easing curve mapping [0,1] -> [0,1] */
77
+ this.ease = options.ease || Ease.LINEAR;
78
+ /** @property {boolean} - If true, advance even when the game is paused */
79
+ this.useRealTime = !!options.useRealTime;
80
+ /** @property {boolean} - If true, stop advancing until cleared */
81
+ this.paused = !!options.paused;
82
+
83
+ /** Completion callback set by then(), loop(), pingPong().
84
+ * @private */
85
+ this.thenCallback = undefined;
86
+ /** Remaining iterations including the current run (loop/pingPong only).
87
+ * @private */
88
+ this.loopRemaining = 0;
89
+
90
+ tweenActive.push(this);
91
+ // Snap target to start immediately.
92
+ callback(this.interp(duration));
93
+ }
94
+
95
+ /** Set the easing curve and return this for chaining.
96
+ * @param {function(number):number} easeFn
97
+ * @returns {Tween}
98
+ * @memberof TweenSystem */
99
+ setEase(easeFn)
100
+ {
101
+ this.ease = easeFn;
102
+ return this;
103
+ }
104
+
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).
110
+ * @param {function():void} callback
111
+ * @returns {Tween}
112
+ * @memberof TweenSystem */
113
+ then(callback)
114
+ {
115
+ this.thenCallback = callback;
116
+ this.loopRemaining = 0;
117
+ return this;
118
+ }
119
+
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.
122
+ * `loop()` with no argument loops forever.
123
+ *
124
+ * Mutually exclusive with `pingPong`; calling either replaces the other,
125
+ * and calling `then` after either clears the loop (last call wins).
126
+ * @param {number} [count=Infinity]
127
+ * @returns {Tween}
128
+ * @memberof TweenSystem */
129
+ loop(count = Infinity)
130
+ {
131
+ this.loopRemaining = count;
132
+ this.thenCallback = () => loopContinuation(this);
133
+ return this;
134
+ }
135
+
136
+ /** Like `loop`, but swap `start` and `end` between iterations so the value
137
+ * bounces back and forth. `pingPong()` with no argument bounces forever.
138
+ *
139
+ * Mutually exclusive with `loop`; calling either replaces the other, and
140
+ * calling `then` after either clears the loop (last call wins).
141
+ * @param {number} [count=Infinity]
142
+ * @returns {Tween}
143
+ * @memberof TweenSystem */
144
+ pingPong(count = Infinity)
145
+ {
146
+ this.loopRemaining = count;
147
+ this.thenCallback = () => pingPongContinuation(this);
148
+ return this;
149
+ }
150
+
151
+ /** Pause this tween. While paused, tweenUpdate skips it.
152
+ * @memberof TweenSystem */
153
+ pause() { this.paused = true; }
154
+
155
+ /** Resume a paused tween.
156
+ * @memberof TweenSystem */
157
+ resume() { this.paused = false; }
158
+
159
+ /** Reset this tween to the start: life back to duration, pause cleared,
160
+ * re-added to the active list if previously stopped, and the callback
161
+ * re-fired with the start value.
162
+ * @memberof TweenSystem */
163
+ restart()
164
+ {
165
+ this.life = this.duration;
166
+ this.paused = false;
167
+ if (tweenActive.indexOf(this) < 0) tweenActive.push(this);
168
+ this.callback(this.interp(this.duration));
169
+ }
170
+
171
+ /** True if this tween is in the active list and not paused.
172
+ * @returns {boolean}
173
+ * @memberof TweenSystem */
174
+ isActive()
175
+ {
176
+ return !this.paused && tweenActive.indexOf(this) >= 0;
177
+ }
178
+
179
+ /** Get how far this tween has progressed, from 0 (just started) to 1
180
+ * (completed). Clamped — overshoot past completion still reads 1.
181
+ * @returns {number}
182
+ * @memberof TweenSystem */
183
+ getPercent()
184
+ {
185
+ return percent(this.duration - this.life, 0, this.duration);
186
+ }
187
+
188
+ /** Get the current interpolated value (the value most recently passed to
189
+ * the callback). Returns a number, Vector2, or Color depending on the
190
+ * tween's start/end types.
191
+ * @returns {number|Vector2|Color}
192
+ * @memberof TweenSystem */
193
+ getValue()
194
+ {
195
+ return this.interp(this.life);
196
+ }
197
+
198
+ /** Compute the interpolated value at the given remaining `life`.
199
+ * At life === duration the result is `start`; at life === 0 it is `end`.
200
+ * @param {number} life
201
+ * @returns {number}
202
+ * @memberof TweenSystem */
203
+ interp(life)
204
+ {
205
+ 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;
209
+ }
210
+
211
+ /** Remove this tween from the active list and prevent any pending then-callback.
212
+ * @memberof TweenSystem */
213
+ stop()
214
+ {
215
+ const i = tweenActive.indexOf(this);
216
+ if (i >= 0) tweenActive.splice(i, 1);
217
+ this.thenCallback = undefined;
218
+ }
219
+ }
220
+
221
+ /** Library of named easing curves and direction modifiers.
222
+ * All curves accept `x` in [0,1] and return [0,1] (with possible overshoot
223
+ * for ELASTIC/BACK/SPRING/BOUNCE). Curves are values you pass to `setEase`
224
+ * or compose via the IN/OUT/IN_OUT/PIECEWISE/BEZIER modifiers.
225
+ * @memberof TweenSystem
226
+ * @example
227
+ * // Use a basic curve
228
+ * new Tween(callback, 0, 10, 1).setEase(Ease.SINE);
229
+ * // Use a modifier on a curve
230
+ * new Tween(callback, 0, 10, 1).setEase(Ease.OUT(Ease.BACK));
231
+ */
232
+ const Ease =
233
+ {
234
+ /** Linear (identity) curve.
235
+ * @param {number} x
236
+ * @returns {number}
237
+ * @memberof TweenSystem */
238
+ LINEAR: (x) => x,
239
+
240
+ /** Power curve factory: `Ease.POWER(n)` returns `x => x**n`.
241
+ * Use n=2 for quadratic, n=3 for cubic, etc.
242
+ * @param {number} n
243
+ * @returns {function(number):number}
244
+ * @memberof TweenSystem */
245
+ POWER: (n) => (x) => x ** n,
246
+
247
+ /** Sine ease-in curve: starts slow, ends fast.
248
+ * @param {number} x
249
+ * @returns {number}
250
+ * @memberof TweenSystem */
251
+ SINE: (x) => 1 - cos(x * (PI / 2)),
252
+
253
+ /** Circular ease-in curve.
254
+ * @param {number} x
255
+ * @returns {number}
256
+ * @memberof TweenSystem */
257
+ CIRC: (x) => 1 - (1 - x * x)**.5,
258
+
259
+ /** Exponential ease-in curve (`2^(10x-10)`).
260
+ * @param {number} x
261
+ * @returns {number}
262
+ * @memberof TweenSystem */
263
+ EXPO: (x) => x === 0 ? 0 : 2 ** (10 * x - 10),
264
+
265
+ /** Back ease-in: overshoots backward at the start before snapping forward.
266
+ * @param {number} x
267
+ * @returns {number}
268
+ * @memberof TweenSystem */
269
+ BACK: (x) => x * x * (2.70158 * x - 1.70158),
270
+
271
+ /** Elastic ease-in: oscillates with decreasing amplitude.
272
+ * @param {number} x
273
+ * @returns {number}
274
+ * @memberof TweenSystem */
275
+ ELASTIC: (x) =>
276
+ x === 0 ? 0 :
277
+ x === 1 ? 1 :
278
+ -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
279
+
280
+ /** Spring-like ease-out: oscillates outward after passing the target.
281
+ * @param {number} x
282
+ * @returns {number}
283
+ * @memberof TweenSystem */
284
+ SPRING: (x) =>
285
+ 1 -
286
+ (sin(PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
287
+ x ** 2.2 +
288
+ (1 - x)) *
289
+ (1.0 + 1.2 * x),
290
+
291
+ /** Bouncing ease-in: slow ramp with bouncing impacts near the end.
292
+ * Symmetric with the other base curves, which are all ease-in. To get the
293
+ * classic "object falls and hits the ground" shape (bounces near x=1),
294
+ * wrap with `Ease.OUT`: `Ease.OUT(Ease.BOUNCE)`.
295
+ * @param {number} x
296
+ * @returns {number}
297
+ * @memberof TweenSystem
298
+ * @example
299
+ * Ease.BOUNCE // ease-in bounce (slow, then bouncy at end)
300
+ * Ease.OUT(Ease.BOUNCE) // ease-out bounce (object hits ground)
301
+ * Ease.IN_OUT(Ease.BOUNCE) // bounces at both ends
302
+ */
303
+ BOUNCE: (x) =>
304
+ {
305
+ // Inverted form of the standard easeOutBounce: 1 - bounceOut(1 - x).
306
+ let t = 1 - x, f;
307
+ if (t < 4 / 11) f = 7.5625 * t * t;
308
+ else if (t < 8 / 11) f = 7.5625 * (t -= 6 / 11) * t + 0.75;
309
+ else if (t < 10 / 11) f = 7.5625 * (t -= 9 / 11) * t + 0.9375;
310
+ else f = 7.5625 * (t -= 10.5 / 11) * t + 0.984375;
311
+ return 1 - f;
312
+ },
313
+
314
+ /** Ease-in direction modifier: returns the curve unchanged. Symmetric
315
+ * with `OUT` and `IN_OUT`. Base curves are already ease-in by
316
+ * convention, so wrapping a curve in `IN` is a no-op useful when
317
+ * picking the direction programmatically.
318
+ * @param {function(number):number} f - Curve to use as ease-in (returned unchanged)
319
+ * @returns {function(number):number}
320
+ * @memberof TweenSystem
321
+ * @example
322
+ * // Pick direction at runtime
323
+ * const dir = bouncyMode ? Ease.OUT : Ease.IN;
324
+ * new Tween(cb, 0, 10, 1).setEase(dir(Ease.BACK));
325
+ */
326
+ IN: (f) => f,
327
+
328
+ /** Reverse a curve so it eases out instead of in: `x => 1 - f(1 - x)`.
329
+ * @param {function(number):number} f
330
+ * @returns {function(number):number}
331
+ * @memberof TweenSystem
332
+ * @example
333
+ * Ease.OUT(Ease.POWER(2)) // ease-out quadratic
334
+ */
335
+ OUT: (f) => (x) => 1 - f(1 - x),
336
+
337
+ /** 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
+ * @param {function(number):number} f
341
+ * @returns {function(number):number}
342
+ * @memberof TweenSystem */
343
+ IN_OUT: (f) => Ease.PIECEWISE(f, Ease.OUT(f)),
344
+
345
+ /** Split [0,1] into N equal sections and run a different curve in each.
346
+ * Each curve is mapped to its section: section i runs over [i/n, (i+1)/n]
347
+ * and its output is mapped to [i/n, (i+1)/n] of the overall range.
348
+ * @param {...function(number):number} fns
349
+ * @returns {function(number):number}
350
+ * @memberof TweenSystem */
351
+ PIECEWISE: (...fns) =>
352
+ {
353
+ const n = fns.length;
354
+ return (x) =>
355
+ {
356
+ const i = (x * n - 1e-9) >> 0;
357
+ return (fns[i]((x - i / n) * n) + i) / n;
358
+ };
359
+ },
360
+
361
+ /** Cubic Bezier curve solver in the style of CSS `cubic-bezier`.
362
+ * Control points (0,0), (x1,y1), (x2,y2), (1,1).
363
+ * @param {number} x1
364
+ * @param {number} y1
365
+ * @param {number} x2
366
+ * @param {number} y2
367
+ * @returns {function(number):number}
368
+ * @memberof TweenSystem
369
+ * @example
370
+ * Ease.BEZIER(0.25, 0.1, 0.25, 1) // CSS "ease"
371
+ */
372
+ BEZIER: (x1, y1, x2, y2) =>
373
+ {
374
+ // Parametric cubic Bezier with implicit (0,0) and (1,1) endpoints.
375
+ const curve = (t) =>
376
+ {
377
+ const u = 1 - t;
378
+ const c1 = 3 * u * u * t;
379
+ const c2 = 3 * u * t * t;
380
+ const t3 = t ** 3;
381
+ return [c1 * x1 + c2 * x2 + t3, c1 * y1 + c2 * y2 + t3];
382
+ };
383
+ return (x) =>
384
+ {
385
+ // Binary search for t such that curve(t).x x, then return curve(t).y.
386
+ let t0 = 0, t1 = 1;
387
+ for (let i = 0; i < 128; i++)
388
+ {
389
+ 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;
393
+ }
394
+ return curve((t0 + t1) / 2)[1];
395
+ };
396
+ },
397
+ };
398
+
399
+ /** Tween a property on an object by dot-path. Returns the underlying Tween
400
+ * so all chaining methods (`setEase`, `then`, `loop`, `pingPong`, etc.)
401
+ * remain available.
402
+ *
403
+ * `start` and `end` may be numbers, Vector2 instances, Color instances, or
404
+ * any object with a `lerp(other, percent) => sameType` method.
405
+ * @param {Object} target - The object whose property is being animated
406
+ * @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
409
+ * @param {number} [duration=1] - Duration in seconds
410
+ * @param {Object} [options] - Same options as the Tween constructor
411
+ * @returns {Tween}
412
+ * @memberof TweenSystem
413
+ * @example
414
+ * // Numeric: slide an object's x with an ease-out sine curve
415
+ * tweenProperty(player, 'pos.x', 0, 10, 2).setEase(Ease.OUT(Ease.SINE));
416
+ * // Vector2: animate a position diagonally
417
+ * tweenProperty(player, 'pos', vec2(-5, 0), vec2(5, 3), 2);
418
+ * // Color: pulse between two colors
419
+ * tweenProperty(sprite, 'color', RED, BLUE, 1).pingPong();
420
+ */
421
+ function tweenProperty(target, propertyPath, start, end, duration = 1, options = {})
422
+ {
423
+ ASSERT(target != null && typeof target === 'object', 'tweenProperty target must be an object');
424
+ ASSERT(isStringLike(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string');
425
+
426
+ const parts = propertyPath.split('.');
427
+ const lastKey = parts.pop();
428
+ const callback = (value) =>
429
+ {
430
+ let obj = target;
431
+ for (const k of parts)
432
+ {
433
+ obj = obj[k];
434
+ ASSERT(obj != null, 'tweenProperty path does not resolve: ' + propertyPath);
435
+ }
436
+ obj[lastKey] = value;
437
+ };
438
+ return new Tween(callback, start, end, duration, options);
439
+ }
440
+
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)
446
+ {
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));
454
+ }
455
+
456
+ // Continuation for pingPong: swaps start and end on the same tween each iteration.
457
+ function pingPongContinuation(tween)
458
+ {
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));
468
+ }
469
+
470
+ /** 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
476
+ * @memberof TweenSystem */
477
+ function tweenUpdate(gameDelta, realDelta)
478
+ {
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)
488
+ {
489
+ // Manual path with one arg: real and game advance together.
490
+ realDelta = gameDelta;
491
+ }
492
+
493
+ // Iterate in reverse so removals don't disturb iteration.
494
+ for (let i = tweenActive.length; i--;)
495
+ {
496
+ const t = tweenActive[i];
497
+ if (t.paused) continue;
498
+ const dt = t.useRealTime ? realDelta : gameDelta;
499
+ if (dt <= 0) continue;
500
+
501
+ t.life -= dt;
502
+ if (t.life > 0)
503
+ {
504
+ t.callback(t.interp(t.life));
505
+ }
506
+ else
507
+ {
508
+ // Completion: fire end value, remove from active, fire then-callback.
509
+ t.callback(t.interp(0));
510
+ tweenActive.splice(i, 1);
511
+ const cb = t.thenCallback;
512
+ t.thenCallback = undefined;
513
+ if (cb) cb();
514
+ }
515
+ }
516
+ }
517
+
518
+ /** Stop every active tween and clear their then-callbacks. Useful for resets
519
+ * on level transitions or when changing scenes.
520
+ * @memberof TweenSystem */
521
+ function tweenStopAll()
522
+ {
523
+ for (const t of tweenActive) t.thenCallback = undefined;
524
+ tweenActive.length = 0;
525
+ }
526
+
527
+ // Register with the engine so tweens auto-advance.
528
+ engineAddPlugin(tweenUpdate);