Source: anime/tween.js

const EventEmitter = require('engine/EventEmitter');

const { getTargetAndKey } = require('./utils');
const { Easing, Interpolation } = require('./easing');

/**
 * Action type enums
 * @enum {number}
 * @memberof Tween
 */
const ACTION_TYPES = {
  REPEAT: 0,
  WAIT: 1,
  ANIMATE: 2,
};

// TODO: better easing support (https://github.com/rezoner/ease)

/**
 * @class Tween
 * @extends {EventEmitter}
 */
class Tween extends EventEmitter {
  /**
   * @constructor
   * @param {object} context Object to apply this tween to.
   */
  constructor(context) {
    super();

    this.context = context;

    /**
     * List of actions.
     * @type {array}
     */
    this.actions = [];
    this.index = -1;
    this.current = null;
    this.currentAction = null;

    /**
     * Delta cache for updating
     * @type {number}
     */
    this.delta = 0;

    /**
     * Tween duration.
     * @type {number}
     * @default 500
     */
    this.duration = 500;

    /**
     * Progress of current performing action
     * @type {number}
     */
    this.progress = 0;

    /**
     * Tween's easing function.
     * @property {function} easing
     */
    this.easing = Easing.Linear.None;
    /**
     * Tween's interpolation function.
     * @property {function} interpolationFn
     */
    this.interpolation = Interpolation.Linear;

    /**
     * Whether this tween is finished
     * @type {boolean}
     */
    this.isFinished = false;

    /**
     * Whether this tween is removed
     * @type {boolean}
     */
    this.isRemoved = false;

    /**
     * Whether this tween is paused
     * @type {boolean}
     */
    this.isPaused = false;

    // Interal variables
    this.repeatCount = 0;
    this.propCtx = [];  // Property context list
    this.propKey = [];  // Property key list
    this.before = [];   // Target property list
    this.change = [];   // Property change list
    this.types = [];    // Property type list
  }

  /**
   * Initialize this tween
   * @param  {object} context Target object.
   */
  init(context) {
    this.removeAllListeners();

    this.context = context;

    this.actions.length = 0;
    this.index = -1;
    this.current = null;
    this.currentAction = null;

    this.delta = 0;
    this.duration = 500;
    this.progress = 0;

    this.easing = Easing.Linear.None;
    this.interpolation = Interpolation.Linear;

    this.isFinished = false;
    this.isRemoved = false;
    this.isPaused = false;

    this.repeatCount = 0;
    this.propCtx.length = 0;
    this.propKey.length = 0;
    this.before.length = 0;
    this.change.length = 0;
    this.types.length = 0;
  }

  /**
   * Push a new action to the tween.
   * @memberof Tween#
   * @method to
   * @param  {Object} properties              Target properties
   * @param  {Number} duration                Duration of the action in ms
   * @param  {String|Function} easing         Easing function
   * @param  {String|Function} interpolation  Interpolation function
   * @return {Tween} Tween itself for chaining.
   */
  to(properties, duration, easing = Easing.Linear.None, interpolation = Interpolation.Linear) {
    let easingFn = easing, interpolationFn = interpolation;
    if (typeof easing === 'string') {
      easing = easing.split('.');
      easingFn = Easing[easing[0]][easing[1]];
    }

    if (typeof interpolation === 'string') {
      interpolationFn = Interpolation[interpolation];
    }

    /**
     * props [
     *   propertyContext1, propertyKey1, targetValue1,
     *   propertyContext2, propertyKey2, targetValue2,
     *   ...
     * ]
     */
    let props = [], keys = Object.keys(properties), pair;
    for (let i = 0; i < keys.length; i++) {
      pair = getTargetAndKey(this.context, keys[i]);
      props.push(pair[0], pair[1], properties[keys[i]]);
    }
    this.actions.push([props, duration, easingFn, interpolationFn]);

    return this;
  }

  /**
   * Repeat the tween for times.
   * @memberof Tween#
   * @method repeat
   * @param  {number} times How many times to repeat.
   * @return {Tween}  Tween itself.
   */
  repeat(times) {
    this.actions.push([ACTION_TYPES.REPEAT, times]);
    return this;
  }

  /**
   * Wait a short time before next action.
   * @memberof Tween#
   * @method wait
   * @param  {number} time Time to wait in ms.
   * @return {Tween}  Tween itself for chaining.
   */
  wait(time) {
    this.actions.push([ACTION_TYPES.WAIT, time]);
    return this;
  }

  /**
   * Stop this tween.
   * @memberof Tween#
   * @method stop
   * @return {Tween}  Tween itself for chaining.
   */
  stop() {
    this.isRemoved = true;
    this.removeAllListeners();
    return this;
  }

  /**
   * Pause this tween.
   * @memberof Tween#
   * @method pause
   * @return {Tween}  Tween itself for chaining.
   */
  pause() {
    this.isPaused = true;
    return this;
  }

  /**
   * Resume this tween from pausing.
   * @memberof Tween#
   * @method resume
   * @return {Tween}  Tween itself for chaining.
   */
  resume() {
    this.isPaused = false;
    return this;
  }

  /**
   * Do next action.
   * @private
   */
  _next() {
    this.delta = 0;

    this.index++;

    if (this.index >= this.actions.length) {
      this.isFinished = true;
      this.isRemoved = true;

      this.emit('finish', this);

      return;
    }

    this.current = this.actions[this.index];

    if (this.current[0] === ACTION_TYPES.WAIT) {
      this.duration = this.current[1];
      this.currentAction = ACTION_TYPES.WAIT;
    }
    else if (this.current[0] === ACTION_TYPES.REPEAT) {
      if (!this.current.counter) {
        this.current.counter = this.current[1];
      }
      this.current.counter--;
      if (this.current.counter > 0) {
        this.emit('repeat', this);

        // Restart from beginning
        this.index = -1;
        this.current = null;
        this._step(0);
      }
      else {
        // Reset counter for next repeat if exists
        this.current.counter = this.current[1];

        // Clear for next action
        this.current = null;
        this.currentAction = null;
        this._step(0);
      }
    }
    else {
      this.properties = this.current[0];

      this.propCtx.length = 0;
      this.propKey.length = 0;
      this.change.length = 0;
      this.before.length = 0;
      this.types.length = 0;

      for (let i = 0; i < this.properties.length; i += 3) {
        // Property context
        let context = this.properties[i];
        // Property key
        let key = this.properties[i + 1];
        // Current value
        let currValue = context[key];
        // Target value
        let targetValue = this.properties[i + 2];

        // Construct action lists
        this.propKey.push(key);
        this.propCtx.push(context);

        // Number
        if (typeof(currValue) === 'number') {
          this.before.push(currValue);
          this.change.push(targetValue - currValue);
          this.types.push(0);
        }
        // String
        else if (typeof(currValue) === 'string') {
          this.before.push(currValue);
          this.change.push(targetValue);
          this.types.push(1);
        }
        // Boolean or object
        else {
          this.before.push(currValue);
          this.change.push(targetValue);
          this.types.push(2);
        }
      }

      this.currentAction = ACTION_TYPES.ANIMATE;

      this.duration = this.current[1];
      this.easing = this.current[2];
      this.interpolation = this.current[3];
    }
  }

  /**
   * Update.
   * @param  {number} delta Delta time
   * @protected
   */
  _step(delta) {
    if (this.isRemoved || this.isPaused) {return;}

    this.delta += delta;

    if (!this.current) {
      this._next();
    }

    switch (this.currentAction) {
      case ACTION_TYPES.ANIMATE:
        this._doAnimate();
        break;
      case ACTION_TYPES.WAIT:
        this._doWait();
        break;
    }
  }

  /**
   * Do current action.
   * @private
   */
  _doAnimate() {
    this.progress = Math.min(1, this.delta / this.duration);
    let mod = this.easing(this.progress);

    let i, key;
    for (i = 0; i < this.change.length; i++) {
      key = this.propKey[i];
      switch (this.types[i]) {
        // Number tweening
        case 0:
          this.propCtx[i][key] = this.before[i] + this.change[i] * mod;
          break;
        // Tweening text content
        case 1:
          this.propCtx[i][key] = this.change[i].slice(0, Math.floor(this.change[i].length * mod));
          break;
        // Instantly value changing for boolean and objects
        case 2:
          if (this.progress >= 1) {this.propCtx[i][key] = this.change[i];}
          break;
      }
    }

    if (this.progress >= 1) {
      this._next();
    }
  }

  /**
   * Do wait action.
   * @private
   */
  _doWait() {
    if (this.delta >= this.duration) {
      this._next();
    }
  }

  /**
   * Recycle this tween for later use.
   */
  recycle() {
    pool.push(this);
  }
}

// Object recycle
const pool = [];
for (let i = 0; i < 20; i++) {
  pool.push(new Tween(null));
}

/**
 * Tween factory method.
 * @memberOf Tween
 * @param  {object} context Target object.
 * @return {module:engine/animation/tween~Tween} Tween instance.
 */
Tween.create = function(context) {
  let t = pool.pop();
  if (!t) {
    t = new Tween(context);
  }
  t.init(context);
  return t;
};

/**
 * Classic tween animation.
 * Use {@link SystemAnime#tween} to create a new tween and start it immediately.
 *
 * @exports engine/anime/tween
 *
 * @requires engine/EventEmitter
 * @requires engine/anime/utils
 * @requires engine/anime/easing
 */
module.exports = Tween;
module.exports.ACTION_TYPES = ACTION_TYPES;