Source: src/ease.js

src/ease.js

import Penner from 'penner'
import Events from 'eventemitter3'

import { Easing } from './easing'

const easeOptions = {
    duration: 1000,
    ease: Penner.easeInOutSine,
    maxFrame: 1000 / 60,
    ticker: null,
    useRAF: true
}

/**
 * Manages a group of eases
 * @extends EventEmitter
 * @example
 * import * as PIXI from 'pixi.js'
 * import { Ease, ease } from 'pixi-ease'
 *
 * const app = new PIXI.Application()
 * const test = app.stage.addChild(new PIXI.Sprite(PIXI.Texture.WHITE))
 *
 * const move = ease.add(test, { x: 20, y: 15, alpha: 0.25 }, { reverse: true })
 * move.once('complete', () => console.log('move ease complete.'))
 *
 * test.generic = 25
 * const generic = ease.add(test, { generic: 0 }, { duration: 1500, ease: 'easeOutQuad' })
 * generic.on('each', () => console.log(test.generic))
 *
 * const secondEase = new Ease({ duration: 3000, ease: 'easeInBack' })
 * const test2 = app.stage.addChild(new PIXI.Sprite(PIXI.Texture.WHITE))
 * test2.tint = 0x0000ff
 * secondEase.add(test2, { blend: [0xff0000, 0x00ff00], scale: 2 })
 */
export class Ease extends Events
{
    /**
     * @param {object} [options]
     * @param {number} [options.duration=1000] default duration if not set
     * @param {(string|function)} [options.ease=Penner.easeInOutSine] default ease function if not set (see {@link https://www.npmjs.com/package/penner} for names of easing functions)
     * @param {boolean} [option.useRAF=true] attach to a requestAnimationFrame listener
     * @param {PIXI.Ticker} [options.ticker] attach to a PIXI.Ticker instead of RAF
     * @param {number} [options.maxFrame=1000/60] maximum frame time (set to Infinity to ignore); only used if useTicker = true
     * @fires Ease#complete
     * @fires Ease#each
     */
    constructor(options)
    {
        super()
        this.options = Object.assign({}, easeOptions, options)
        this.easings = []
        this.empty = true
        if (this.options.ticker)
        {
            this.options.ticker.add(this.update, this)
        }
    }

    /**
     * removes all eases and tickers
     */
    destroy()
    {
        this.removeAll()
        if (this.options.useTicker)
        {
            this.ticker.remove(this.update, this)
        }
        else if (this.options.useRAF)
        {
            cancelAnimationFrame(this.handleRAF)
            this.handleRAF = null
        }
    }

    /**
     * add ease(s) to a PIXI.DisplayObject element
     * @param {(PIXI.DisplayObject|PIXI.DisplayObject[])} element
     *
     * @param {object} params
     * @param {number} [params.x]
     * @param {number} [params.y]
     * @param {(PIXI.DisplayObject|PIXI.Point)} [params.position] changes both x and y
     * @param {number} [params.width]
     * @param {number} [params.height]
     * @param {number} [params.scale] changes both scale.x and scale.y
     * @param {number} [params.scaleX]
     * @param {number} [params.scaleY]
     * @param {number} [params.alpha]
     * @param {number} [params.rotation]
     * @param {(PIXI.DisplayObject|PIXI.Point)} [params.face] rotate the element to face a DisplayObject using the closest angle
     * @param {number} [params.skew] changes both skew.x and skew.y
     * @param {number} [params.skewX]
     * @param {number} [params.skewY]
     * @param {(number|number[])} [params.tint] cycle through colors - if number is provided then it cycles between current tint and number; if number[] is provided is cycles only between tints in the number[]
     * @param {(number|number[])} [params.blend] blend between colors - if number is provided then it blends current tint to number; if number[] is provided then it blends between the tints in the number[]
     * @param {number} [params.shake] shakes the object by this number (randomly placing the element +/-shake pixels away from starting point)
     * @param {number} [params.*] generic number parameter
     *
     * @param {object} [options]
     * @param {number} [options.duration]
     * @param {(string|function)} [options.ease]
     * @param {(boolean|number)} [options.repeat]
     * @param {boolean} [options.reverse]
     * @param {number} [options.wait] wait this number of milliseconds before ease starts
     *
     * @returns {Easing}
     */
    add(element, params, options)
    {
        options = options || {}
        options.duration = typeof options.duration !== 'undefined' ? options.duration : this.options.duration
        options.ease = options.ease || this.options.ease
        if (typeof options.ease === 'string')
        {
            options.ease = Penner[options.ease]
        }
        const easing = new Easing(element, params, options)
        this.easings.push(easing)
        if (this.empty && this.options.useRAF)
        {
            this.handleRAF = requestAnimationFrame(() => this.update())
            this.lastTime = Date.now()
        }
        this.empty = false
        return easing
    }

    /**
     * create an ease that changes position (x, y) of the element by moving to the target at the speed
     * NOTE: under the hood this calls add(element, { x, y }, { duration: <calculated speed based on distance and speed> })
     * @param {PIXI.DisplayObject} element
     * @param {(PIXI.DisplayObject|PIXI.Point)} target
     * @param {number} speed in pixels / ms
     *
     * @param {object} [options]
     * @param {(string|function)} [options.ease]
     * @param {(boolean|number)} [options.repeat]
     * @param {boolean} [options.reverse]
     * @param {number} [options.wait] wait this number of milliseconds before ease starts
     * @param {boolean} [options.removeExisting] removes existing eases on the element of the same type (including x,y/position, skewX,skewY/skew, scaleX,scaleY/scale)
     *
     * @returns {Easing}
     */
    target(element, target, speed, options)
    {
        const duration = Math.sqrt(Math.pow(element.x - target.x, 2) + Math.pow(element.y - target.y, 2)) / speed
        options = options || {}
        options.duration = duration
        return this.add(element, { x: target.x, y: target.y }, options)
    }

    /**
     * helper function to add an ease that changes rotation to face the element at the desired target using the speed
     * NOTE: under the hood this calls add(element {x, y }, { duration: <calculated speed based on shortest rotation and speed> })
     * @param {PIXI.DisplayObject} element
     * @param {(PIXI.DisplayObject|PIXI.Point)} target
     * @param {number} speed in radians / ms
     *
     * @param {object} [options]
     * @param {(string|function)} [options.ease]
     * @param {(boolean|number)} [options.repeat]
     * @param {boolean} [options.reverse]
     * @param {number} [options.wait] wait this number of milliseconds before ease starts
     *
     * @returns {Easing}
     */
    face(element, target, speed, options)
    {
        const shortestAngle = Easing.shortestAngle(element.rotation, Math.atan2(target.y - element.y, target.x - element.x))
        const duration = Math.abs(shortestAngle - element.rotation) / speed
        options = options || {}
        options.duration = duration
        return this.add(element, { rotation: shortestAngle }, options)
    }

    /**
     * removes one or more eases from a DisplayObject
     * WARNING: 'complete' events will not fire for these removals
     * @param {PIXI.DisplayObject} element
     * @param {(string|string[])} [param] omit to remove all easings for an element
     */
    removeEase(element, param)
    {
        for (let i = 0; i < this.easings.length; i++)
        {
            if (this.easings[i].remove(element, param))
            {
                this.easings.splice(i, 1)
                i--
            }
        }
        if (this.easings.length === 0)
        {
            this.empty = true
            if (this.options.useRAF && this.handleRAF)
            {
                cancelAnimationFrame(this.handleRAF)
                this.handleRAF = null
            }
        }
    }

    /**
     * remove all easings
     * WARNING: 'complete' events will not fire for these removals
     */
    removeAll()
    {
        this.easings = []
        this.empty = true
        if (this.options.useRAF && this.handleRAF)
        {
            cancelAnimationFrame(this.handleRAF)
            this.handleRAF = null
        }
}

    /**
     * update frame; this is called automatically if options.useTicker !== false
     * @param {number} elapsed time in ms since last frame
     */
    update(elapsed)
    {
        if (this.options.useTicker)
        {
            elapsed = this.ticker.elapsedMS
        }
        else if (this.options.useRAF)
        {
            const now = Date.now()
            elapsed = now - this.lastTime
            this.lastTime = now
        }
        elapsed = Math.min(elapsed, this.options.maxFrame)
        if (!this.empty)
        {
            const list = this.easings.slice(0)
            for (let easing of list)
            {
                if (easing.update(elapsed))
                {
                    this.easings.splice(this.easings.indexOf(easing), 1)
                }
            }
            this.emit('each', this)
            if (this.easings.length === 0)
            {
                this.empty = true
                this.emit('complete', this)
            }
        }
        if (this.options.useRAF && this.easings.length)
        {
            this.handleRAF = requestAnimationFrame(() => this.update())
        }
        else
        {
            this.handleRAF = null
        }
    }

    /**
     * number of easings
     * @type {number}
     */
    get count()
    {
        return this.easings.length
    }

    /**
     * number of active easings across all elements
     * @returns {number}
     */
    countRunning()
    {
        let count = 0
        for (let entry of this.easings)
        {
            count += entry.count
        }
        return count
    }

    /**
     * default duration for eases.add() (only applies to newly added eases)
     * @type {number}
     */
    set duration(duration)
    {
        this.options.duration = duration
    }
    get duration()
    {
        return this.options.duration
    }

    /**
     * default ease for eases.add() (only applies to newly added eases)
     * @type {(string|Function)}
     */
    set ease(ease)
    {
        this.options.ease = ease
    }
    get ease()
    {
        return this.options.ease
    }
}

// manages the ids used to define the DisplayObject ease variable (enabled multiple eases attached to the same object)
Ease.id = 0

/**
 * default instantiated Ease class
 * @type {Ease}
 */
export let ease = new Ease()

Ease.ease = ease

export class List
{
    constructor()
    {
        console.warn('Ease.List was deprecated. Use new Ease() instead.')
    }
}

/**
 * fires when there are no more eases
 * @event Ease#complete
 * @type {Ease}
 */

 /**
 * fires on each loop when there are eases running
 * @event Ease#each
 * @type {Ease}
 */