plugins_tweenSystem.js

/**
 * LittleJS Tween System Plugin
 * - Lightweight tweens for numbers, Vector2, Color, or any .lerp-able type
 * - Chainable easing, looping, and ping-pong
 * - Property-path helper for the common case of animating an object field
 * - Auto-updates via engineAddPlugin; pauses with the game by default
 * @namespace TweenSystem
 */

'use strict';

///////////////////////////////////////////////////////////////////////////////

// Module-private list of tweens currently running, each with its active flag set while it is in it.
const tweenActive = [];
const tweenUpdateList = []; // the tweens an update moves, the ones active when it began
let tweenUpdatePass = 0; // counts the updates, a tween started during one waits for the next

// put a tween in the active list, or take it out, keeping its flag in step so a check costs nothing
function tweenActivate(tween)
{
    tween.activePass = tweenUpdatePass; // started again, even while active, so this update leaves it alone
    if (tween.active) return;
    tween.active = true;
    tweenActive.push(tween);
}
function tweenDeactivate(tween)
{
    if (!tween.active) return;
    tween.active = false;
    tweenActive.splice(tweenActive.indexOf(tween), 1);
}

// True if the value is an instance of a class that exposes a numeric-percent
// `lerp(other, percent)` method (Vector2, Color, or any future class).
function tweenIsLerpable(v) { return v && typeof v.lerp === 'function'; }

///////////////////////////////////////////////////////////////////////////////

/** A tween: drives a callback with a value interpolated between
 *  `start` and `end` over `duration` seconds. Pauses with the game by default.
 *  - In TypeScript it is a `Tween<T>` of the type it tweens, which comes from `start` and `end` or
 *    the callback's parameter, so `(v: number)=> ...` takes a number
 *  @template [T=any]
 *  @memberof TweenSystem
 *  @example
 *  // Animate a fade-out over 2 seconds with an ease-out sine curve.
 *  new Tween((v) => obj.alpha = v, 1, 0, 2, { ease: Ease.OUT(Ease.SINE) });
 */
class Tween
{
    /** Create a new tween. The callback fires immediately with `start` so the
     *  target snaps to the start value on the same frame the tween is created.
     *
     *  `start` and `end` may be numbers, Vector2, Vector3 or Color instances, or
     *  any object exposing a `lerp(other, percent) => sameType` method. The
     *  callback receives the interpolated value (a number, or a fresh instance
     *  for lerp-able types). Both endpoints must be the same type.
     *  @param {function(NonNullable<T>):void} callback - Called with the interpolated value each frame
     *  @param {T} [start=0] - Starting value
     *  @param {T} [end=1] - Ending value
     *  @param {number} [duration=1] - Duration in seconds
     *  @param {Object} [options]
     *  @param {function(number):number} [options.ease] - Easing function (defaults to LINEAR)
     *  @param {boolean} [options.useRealTime=false] - Advance even when the game is paused (matches Timer's useRealTime)
     *  @param {boolean} [options.paused=false] - Start in paused state */
    constructor(callback, start = /** @type {T} */ (0), end = /** @type {T} */ (1), duration = 1, options = {})
    {
        ASSERT(typeof callback === 'function', 'Tween callback must be a function');
        if (tweenIsLerpable(start))
        {
            ASSERT(start.constructor === end.constructor,
                'Tween start and end must be the same type');
        }
        else
        {
            ASSERT(isNumber(start), 'Tween start must be a number or have a .lerp method');
            ASSERT(isNumber(end),   'Tween end must be a number when start is a number');
        }
        ASSERT(isNumber(duration) && duration > 0, 'Tween duration must be > 0');

        // the callback's type is NonNullable<T>, which is T, so that TypeScript takes the type from start and end
        // first: a typed callback like (v: number)=> with start 10 then makes a Tween<number>, not a Tween<0|10>
        /** @property {function(T):void} - Called with the interpolated value each frame
         *  @type {function(T):void} */
        this.callback = callback;
        /** @property {T} - Starting value
         *  @type {T} */
        this.start = start;
        /** @property {T} - Ending value
         *  @type {T} */
        this.end = end;
        /** @property {number} - Total duration in seconds */
        this.duration = duration;
        /** @property {number} - Remaining time in seconds (counts down from duration to 0) */
        this.life = duration;
        /** @property {function(number):number} - Easing curve mapping [0,1] -> [0,1] */
        this.ease = options.ease || Ease.LINEAR;
        /** @property {boolean} - If true, advance even when the game is paused */
        this.useRealTime = !!options.useRealTime;
        /** @property {boolean} - If true, stop advancing until cleared */
        this.paused = !!options.paused;
        /** @property {undefined|function():void} - Called once the tween completes: when its last pass ends,
         *  the last iteration of a loop or pingPong, and again each time a restart plays through; then() sets it
         *  @type {undefined|function():void} */
        this.onComplete = undefined;

        /** Continuation when a pass ends, set by loop() and pingPong() to start the next iteration.
         *  @private */
        this.thenCallback = undefined;
        /** Remaining iterations including the current run (loop/pingPong only).
         *  @private */
        this.loopRemaining = 0;
        /** Whether it is in the active list, see isActive
         *  @private */
        this.active = false;
        /** The update it was started in, it first moves on the one after
         *  @private */
        this.activePass = 0;
        /** Engine time and real time of its last engine update, it moves by what passed since
         *  @private */
        this.lastTime = time;
        /** @private */
        this.lastTimeReal = timeReal;
        /** @property {Object|undefined} - The object tweenProperty animates, the tween stops once it is destroyed,
         *  even while paused
         *  @type {{destroyed?: boolean}|undefined} */
        this.target = undefined;

        tweenActivate(this);
        // Snap target to start immediately.
        callback(this.interp(duration));
    }

    /** Set the easing curve and return this for chaining.
     *  @param {function(number):number} easeFn
     *  @returns {Tween<T>} */
    setEase(easeFn)
    {
        this.ease = easeFn;
        return this;
    }

    /** Set the completion callback, `onComplete`, and return this for chaining.
     *  It is called once the tween completes: when its pass ends, or for a
     *  `loop` or `pingPong` when its last iteration ends, so an endless one
     *  never calls it. Calling `then` again replaces the previous callback.
     *  - It works with `loop` and `pingPong` in either order, neither replaces the other
     *  - It is kept by `restart`, so a restarted tween calls it again when it completes
     *  - `stop` and `tweenStopAll` end a tween without calling it
     *  @param {function():void} callback
     *  @returns {Tween<T>} */
    then(callback)
    {
        this.onComplete = callback;
        return this;
    }

    /** Repeat this tween `n` total times. After each iteration finishes, the
     *  same tween starts over, so the handle returned stays good for the whole
     *  loop: pause or stop it to pause or stop every iteration left.
     *  `loop()` with no argument loops forever.
     *
     *  Mutually exclusive with `pingPong`; calling either replaces the other.
     *  A `then` callback, set before or after, is called when the last
     *  iteration ends.
     *  @param {number} [count=Infinity]
     *  @returns {Tween<T>} */
    loop(count = Infinity)
    {
        this.loopRemaining = count;
        this.thenCallback = () => tweenLoopContinuation(this);
        return this;
    }

    /** Like `loop`, but swap `start` and `end` between iterations so the value
     *  bounces back and forth. `pingPong()` with no argument bounces forever.
     *
     *  Mutually exclusive with `loop`; calling either replaces the other.
     *  A `then` callback, set before or after, is called when the last
     *  iteration ends.
     *  @param {number} [count=Infinity]
     *  @returns {Tween<T>} */
    pingPong(count = Infinity)
    {
        this.loopRemaining = count;
        this.thenCallback = () => tweenPingPongContinuation(this);
        return this;
    }

    /** Pause this tween. While paused, tweenUpdate skips it. */
    pause() { this.paused = true; }

    /** Resume a paused tween. */
    resume() { this.paused = false; }

    /** Reset this tween to the start: life back to duration, pause cleared,
     *  re-added to the active list if previously stopped, and the callback
     *  re-fired with the start value.
     *  It replays one pass: a loop or pingPong that has finished is not started
     *  over, a pingPong that ended on its way back plays that way again, and a
     *  restart mid loop keeps the iterations left. Call loop or pingPong again
     *  after restart to repeat it. The `then` callback is kept and is called
     *  again when it completes. */
    restart()
    {
        this.life = this.duration;
        this.paused = false;
        this.lastTime = time;
        this.lastTimeReal = timeReal;
        tweenActivate(this);
        this.callback(this.interp(this.duration));
    }

    /** True if this tween is in the active list and not paused.
     *  @returns {boolean} */
    isActive()
    {
        return !this.paused && this.active;
    }

    /** Get how far this tween has progressed, from 0 (just started) to 1
     *  (completed). Clamped — overshoot past completion still reads 1.
     *  @returns {number} */
    getPercent()
    {
        return percent(this.duration - this.life, 0, this.duration);
    }

    /** Get the current interpolated value (the value most recently passed to
     *  the callback). Returns a number, Vector2, Vector3 or Color depending on the
     *  tween's start/end types.
     *  @returns {T} */
    getValue()
    {
        return this.interp(this.life);
    }

    /** Compute the interpolated value at the given remaining `life`.
     *  At life === duration the result is `start`; at life === 0 it is `end`.
     *  - At life 0 it is the end value exactly
     *  - A vector goes past its ends as far as the easing does, as a number does; a Color stays between them,
     *    so its channels stay in range, and any other type goes as far as its own lerp takes it
     *  @param {number} life
     *  @returns {T} */
    interp(life)
    {
        // the ends of whatever type it tweens, each kind is handled below
        const s = /** @type {any} */ (this.start), e = /** @type {any} */ (this.end);
        if (life <= 0) // the end exactly, an easing curve may land a rounding error short of it
            return typeof e.copy === 'function' ? e.copy() : e;
        const x = this.ease((this.duration - life) / this.duration);
        // the vectors as their lerp does it, which lands on the end exactly, but without its clamp
        const y = 1 - x;
        if (s instanceof Vector2)
            return /** @type {T} */ (vec2(e.x * x + s.x * y, e.y * x + s.y * y));
        if (typeof Vector3 !== 'undefined' && s instanceof Vector3) // a build may leave out the 3D math
            return /** @type {T} */ (vec3(e.x * x + s.x * y, e.y * x + s.y * y, e.z * x + s.z * y));
        if (tweenIsLerpable(s))
            return s.lerp(e, x);
        return s + (e - s) * x;
    }

    /** Remove this tween from the active list, ending a loop or pingPong too, without calling
     *  the then-callback. It keeps the then-callback, so a restart calls it when it completes. */
    stop()
    {
        tweenDeactivate(this);
        this.thenCallback = undefined;
    }
}

/** Library of named easing curves and direction modifiers.
 *  All curves accept `x` in [0,1] and return [0,1] (with possible overshoot
 *  for ELASTIC/BACK/SPRING/BOUNCE). Curves are values you pass to `setEase`
 *  or compose via the IN/OUT/IN_OUT/PIECEWISE/BEZIER modifiers.
 *  @memberof TweenSystem
 *  @example
 *  // Use a basic curve
 *  new Tween(callback, 0, 10, 1).setEase(Ease.SINE);
 *  // Use a modifier on a curve
 *  new Tween(callback, 0, 10, 1).setEase(Ease.OUT(Ease.BACK));
 */
const Ease =
{
    /** Linear (identity) curve.
     *  @param {number} x
     *  @returns {number}
     *  @memberof TweenSystem.Ease */
    LINEAR: (x) => x,

    /** Power curve factory: `Ease.POWER(n)` returns `x => x**n`.
     *  Use n=2 for quadratic, n=3 for cubic, etc.
     *  @param {number} n
     *  @returns {function(number):number}
     *  @memberof TweenSystem.Ease */
    POWER: (n) => (x) => x ** n,

    /** Sine ease-in curve: starts slow, ends fast.
     *  @param {number} x
     *  @returns {number}
     *  @memberof TweenSystem.Ease */
    SINE: (x) => 1 - cos(x * (PI / 2)),

    /** Circular ease-in curve.
     *  @param {number} x
     *  @returns {number}
     *  @memberof TweenSystem.Ease */
    CIRC: (x) => 1 - (1 - x * x)**.5,

    /** Exponential ease-in curve (`2^(10x-10)`).
     *  @param {number} x
     *  @returns {number}
     *  @memberof TweenSystem.Ease */
    EXPO: (x) => x === 0 ? 0 : 2 ** (10 * x - 10),

    /** Back ease-in: overshoots backward at the start before snapping forward.
     *  @param {number} x
     *  @returns {number}
     *  @memberof TweenSystem.Ease */
    BACK: (x) => x * x * (2.70158 * x - 1.70158),

    /** Elastic ease-in: oscillations that grow toward the end.
     *  @param {number} x
     *  @returns {number}
     *  @memberof TweenSystem.Ease */
    ELASTIC: (x) =>
        x === 0 ? 0 :
        x === 1 ? 1 :
        -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),

    /** Spring ease-in: wobbles around the start before springing to the end;
     *  `Ease.OUT(Ease.SPRING)` overshoots and settles on the target.
     *  @param {number} x
     *  @returns {number}
     *  @memberof TweenSystem.Ease */
    SPRING: (x) =>
        1 -
        (sin(PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) *
            x ** 2.2 +
            (1 - x)) *
            (1.0 + 1.2 * x),

    /** Bouncing ease-in: small bounces near the start, then a rise to the end.
     *  Symmetric with the other base curves, which are all ease-in. To get the
     *  classic "object falls and hits the ground" shape (bounces near x=1),
     *  wrap with `Ease.OUT`: `Ease.OUT(Ease.BOUNCE)`.
     *  @param {number} x
     *  @returns {number}
     *  @memberof TweenSystem.Ease
     *  @example
     *  Ease.BOUNCE                  // ease-in bounce (bouncy at start)
     *  Ease.OUT(Ease.BOUNCE)        // ease-out bounce (object hits ground)
     *  Ease.IN_OUT(Ease.BOUNCE)     // bounces at both ends
     */
    BOUNCE: (x) =>
    {
        // Inverted form of the standard easeOutBounce: 1 - bounceOut(1 - x).
        let t = 1 - x, f;
        if (t < 4 / 11) f = 7.5625 * t * t;
        else if (t < 8 / 11) f = 7.5625 * (t -= 6 / 11) * t + 0.75;
        else if (t < 10 / 11) f = 7.5625 * (t -= 9 / 11) * t + 0.9375;
        else f = 7.5625 * (t -= 10.5 / 11) * t + 0.984375;
        return 1 - f;
    },

    /** Ease-in direction modifier: returns the curve unchanged. Symmetric
     *  with `OUT` and `IN_OUT`. Base curves are already ease-in by
     *  convention, so wrapping a curve in `IN` is a no-op — useful when
     *  picking the direction programmatically.
     *  @param {function(number):number} f - Curve to use as ease-in (returned unchanged)
     *  @returns {function(number):number}
     *  @memberof TweenSystem.Ease
     *  @example
     *  // Pick direction at runtime
     *  const dir = bouncyMode ? Ease.OUT : Ease.IN;
     *  new Tween(cb, 0, 10, 1).setEase(dir(Ease.BACK));
     */
    IN: (f) => f,

    /** Reverse a curve so it eases out instead of in: `x => 1 - f(1 - x)`.
     *  @param {function(number):number} f
     *  @returns {function(number):number}
     *  @memberof TweenSystem.Ease
     *  @example
     *  Ease.OUT(Ease.POWER(2)) // ease-out quadratic
     */
    OUT: (f) => (x) => 1 - f(1 - x),

    /** Combine the first half of `f` with `Ease.OUT(f)` for a symmetric curve.
     *  @param {function(number):number} f
     *  @returns {function(number):number}
     *  @memberof TweenSystem.Ease */
    IN_OUT: (f) => Ease.PIECEWISE(f, Ease.OUT(f)),

    /** Split [0,1] into N equal sections and run a different curve in each.
     *  Each curve is mapped to its section: section i runs over [i/n, (i+1)/n]
     *  and its output is mapped to [i/n, (i+1)/n] of the overall range.
     *  @param {...function(number):number} fns
     *  @returns {function(number):number}
     *  @memberof TweenSystem.Ease */
    PIECEWISE: (...fns) =>
    {
        const n = fns.length;
        return (x) =>
        {
            const i = (x * n - 1e-9) >> 0;
            return (fns[i]((x - i / n) * n) + i) / n;
        };
    },

    /** Cubic Bezier curve solver in the style of CSS `cubic-bezier`.
     *  Control points (0,0), (x1,y1), (x2,y2), (1,1).
     *  @param {number} x1
     *  @param {number} y1
     *  @param {number} x2
     *  @param {number} y2
     *  @returns {function(number):number}
     *  @memberof TweenSystem.Ease
     *  @example
     *  Ease.BEZIER(0.25, 0.1, 0.25, 1) // CSS "ease"
     */
    BEZIER: (x1, y1, x2, y2) =>
    {
        // Parametric cubic Bezier with implicit (0,0) and (1,1) endpoints.
        const curve = (t) =>
        {
            const u = 1 - t;
            const c1 = 3 * u * u * t;
            const c2 = 3 * u * t * t;
            const t3 = t ** 3;
            return [c1 * x1 + c2 * x2 + t3, c1 * y1 + c2 * y2 + t3];
        };
        return (x) =>
        {
            // the ends are exact, a tween starts and ends on its values
            if (x <= 0) return 0;
            if (x >= 1) return 1;

            // binary search for t such that curve(t).x = x, then return curve(t).y; a fixed count, since stopping
            // once x is close can leave y far off where the curve is steep, 32 halvings put t within 1e-9
            let t0 = 0, t1 = 1;
            for (let i = 32; i--;)
            {
                const tMid = (t0 + t1) / 2;
                if (curve(tMid)[0] < x) t0 = tMid; else t1 = tMid;
            }
            return curve((t0 + t1) / 2)[1];
        };
    },
};

/** Tween a property on an object by dot-path. Returns the underlying Tween
 *  so all chaining methods (`setEase`, `then`, `loop`, `pingPong`, etc.)
 *  remain available.
 *
 *  `start` and `end` may be numbers, Vector2, Vector3 or Color instances, or
 *  any object with a `lerp(other, percent) => sameType` method.
 *
 *  It stops on its own once the target is destroyed, so a looping tween on an object ends with it; a tween on a
 *  value inside the object, like `tweenProperty(obj.pos, 'x')`, or a new Tween with its own callback, has to be
 *  stopped by the game.
 *  @template [T=any]
 *  @param {Object} target - The object whose property is being animated
 *  @param {string} propertyPath - Dot-separated path, e.g. `'pos.x'` or `'color'`
 *  @param {T} start - Starting value
 *  @param {T} end - Ending value
 *  @param {number} [duration=1] - Duration in seconds
 *  @param {Object} [options] - Same options as the Tween constructor
 *  @param {function(number):number} [options.ease] - Easing function (defaults to LINEAR)
 *  @param {boolean} [options.useRealTime=false] - Advance even when the game is paused
 *  @param {boolean} [options.paused=false] - Start in paused state
 *  @returns {Tween<T>}
 *  @memberof TweenSystem
 *  @example
 *  // Numeric: slide an object's x with an ease-out sine curve
 *  tweenProperty(player, 'pos.x', 0, 10, 2).setEase(Ease.OUT(Ease.SINE));
 *  // Vector2: animate a position diagonally
 *  tweenProperty(player, 'pos', vec2(-5, 0), vec2(5, 3), 2);
 *  // Color: pulse between two colors
 *  tweenProperty(sprite, 'color', RED, BLUE, 1).pingPong();
 */
function tweenProperty(target, propertyPath, start, end, duration = 1, options = {})
{
    ASSERT(target != null && typeof target === 'object', 'tweenProperty target must be an object');
    ASSERT(isStringLike(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string');

    const parts = propertyPath.split('.');
    const lastKey = parts.pop();
    let tween;
    const callback = (value) =>
    {
        // a destroyed object ends it, a loop or pingPong would run on it for good and keep it from being freed
        if (target.destroyed) return void tween?.stop();
        let obj = target;
        for (const k of parts)
        {
            obj = obj[k];
            ASSERT(obj != null, 'tweenProperty path does not resolve: ' + propertyPath);
        }
        obj[lastKey] = value;
    };
    tween = new Tween(callback, start, end, duration, options);
    tween.target = target;
    return tween;
}

// Start the next iteration with the time the last one ran over already spent, so a loop keeps its
// pace; an update that ran over by more than a whole iteration lands where the cycle is, not owing it
function tweenCarryOvershoot(tween)
{
    const duration = tween.duration;
    tween.life = duration ? min(tween.life, 0) % duration + duration : 1e-9;
}

// How many iterations the update that finished one ran through: that one and every whole one after it
function tweenPassed(tween)
{
    const duration = tween.duration;
    return duration ? 1 + floor(-min(tween.life, 0) / duration) : 1;
}

// start the next iteration of a loop or pingPong, the time the last one ran over already spent, true
function tweenNextIteration(tween, passed, continuation)
{
    tween.loopRemaining -= passed;
    tweenCarryOvershoot(tween);
    tween.thenCallback = continuation;
    tweenActivate(tween);
    tween.callback(tween.interp(tween.life)); // snap to where the new iteration is
    return true;
}

// Continuation that schedules the next loop iteration when one finishes, true if it did.
// Reuses the same Tween across iterations, so the handle from `.loop()` pauses or stops the whole loop.
function tweenLoopContinuation(tween)
{
    // count every iteration that went by, a finite loop ends once they run out
    const passed = tweenPassed(tween);
    if (tween.loopRemaining <= passed) return false; // Infinity never runs out
    return tweenNextIteration(tween, passed, () => tweenLoopContinuation(tween));
}

// Continuation for pingPong: swaps start and end on the same tween each iteration, true if it started another.
function tweenPingPongContinuation(tween)
{
    // swap the ends once for each iteration that went by, but when they run out the last one keeps its
    // direction, so it ends on the end it really reached and a restart plays that way again
    const passed = tweenPassed(tween);
    const done = tween.loopRemaining <= passed; // Infinity never runs out
    const swaps = done ? max(tween.loopRemaining - 1, 0) : passed;
    if (swaps & 1)
    {
        const tmp = tween.start;
        tween.start = tween.end;
        tween.end = tmp;
    }
    if (done)
    {
        // the completion gave the other end, give the one it finished on
        if (swaps & 1)
            tween.callback(tween.interp(0));
        return false;
    }
    return tweenNextIteration(tween, passed, () => tweenPingPongContinuation(tween));
}

/** Engine plugin hook: advance every active tween by the appropriate delta.
 *  The engine calls it with no arguments on every fixed update, so it can run
 *  more than once in a rendered frame, and on paused updates too, where only
 *  real time tweens move. May also be called with `(gameDelta, realDelta)` to drive tweens without the engine
 *  loop, for headless tests or an engine in manual step that is not stepped; in a running game such a call adds to
 *  the engine's own update, and a delta of 0 or less does nothing.
 *  @param {number} [gameDelta] - Game-time delta in seconds; default: game time since the tween's last engine update
 *  @param {number} [realDelta] - Real-time delta in seconds; default: real time since the tween's last engine update
 *  @memberof TweenSystem */
function tweenUpdate(gameDelta, realDelta)
{
    // Engine path: each tween moves by the time since its own last update, so one made
    // this update, before or after this call, first moves on the next, like a Timer
    const enginePath = gameDelta === undefined;
    if (!enginePath && realDelta === undefined)
    {
        // Manual path with one arg: real and game advance together.
        realDelta = gameDelta;
    }

    // Move the tweens that were active when this update began, each once: a callback
    // may stop any tween, even all of them, and one that is stopped is skipped; a tween
    // made or started again during the update, like the next turn of a loop, moves on
    // from the next update. Newest first, as the list has always been walked.
    // a callback that calls tweenUpdate itself gets a list of its own, the outer update is still walking this one
    const list = tweenUpdateList.length ? [] : tweenUpdateList, pass = ++tweenUpdatePass;
    for (const t of tweenActive)
        list.push(t);
    for (let i = list.length; i--;)
    {
        const t = list[i];
        // stopped, or started again by a callback this update, or during an update a callback ran inside it, which
        // counts on from this one
        if (!t.active || t.activePass >= pass) continue;
        let dt;
        if (enginePath)
        {
            // a paused tween keeps count too, so it does not jump when resumed
            dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime;
            t.lastTime = time;
            t.lastTimeReal = timeReal;
        }
        else
            dt = t.useRealTime ? realDelta : gameDelta;
        if (t.target?.destroyed) { t.stop(); continue; } // its object is gone, paused or not
        if (t.paused || dt <= 0) continue;

        t.life -= dt;
        if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration
        {
            t.callback(t.interp(t.life));
        }
        else
        {
            // Completion: fire end value, remove from active, start the next iteration
            // of a loop or pingPong, or when there is none it has completed, fire onComplete
            t.callback(t.interp(0));
            if (!t.active || t.activePass >= pass)
                continue; // stopped or restarted by its own callback, the run it was on ends without completing
            tweenDeactivate(t);
            const next = t.thenCallback;
            t.thenCallback = undefined;
            if (!(next && next()) && t.onComplete)
                t.onComplete();
        }
    }
    list.length = 0;
}

/** Stop every active tween, ending loops too, without calling their then-callbacks.
 *  Useful for resets on level transitions or when changing scenes.
 *  @memberof TweenSystem */
function tweenStopAll()
{
    for (const t of tweenActive)
        t.thenCallback = undefined, t.active = false;
    tweenActive.length = 0;
}

// Register with the engine so tweens auto-advance.
engineAddPlugin(tweenUpdate);