Ticker.js

/**
* The MIT License
*
* Copyright (C) 2016 Isaac Sukin
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* This module is a modified code from
*/

(function initPolyfils() {
  const windowOrGlobal = typeof window === 'object' ? window : global;

  /**
   * performance.now polyfill
   *
   * @license http://opensource.org/licenses/MIT
   * Copyright (C) 2015 Paul Irish
   *
   * Date.now() is supported everywhere except IE8. For IE8 we use the Date.now polyfill:
   * github.com/Financial-Times/polyfill-service/blob/master/polyfills/Date.now/polyfill.js
   *
   * As Safari 6 doesn't have support for NavigationTiming,
   * we use a Date.now() timestamp for relative values.
   */
  if (!('performance' in windowOrGlobal)) {
    windowOrGlobal.performance = {};
  }

  Date.now = (Date.now || function getNow() {  // thanks IE8
    return new Date().getTime();
  });

  if (!('now' in windowOrGlobal.performance)) {
    let nowOffset = Date.now();

    if (performance.timing && performance.timing.navigationStart) {
      nowOffset = performance.timing.navigationStart;
    }

    windowOrGlobal.performance.now = function now() {
      return Date.now() - nowOffset;
    };
  }

  // http://paulirish.com/2011/requestanimationframe-for-smart-animating/
  // http://my.opera.com/emoller/blog/2011/12/20/requestanimationframe-for-smart-er-animating
  // requestAnimationFrame polyfill by Erik Möller. fixes from Paul Irish and Tino Zijdel
  // MIT license
  {
    let lastTime = 0;
    const vendors = ['ms', 'moz', 'webkit', 'o'];

    for (let x = 0; x < vendors.length && !windowOrGlobal.requestAnimationFrame; ++x) {
      windowOrGlobal.requestAnimationFrame = windowOrGlobal[vendors[x] + 'RequestAnimationFrame'];
      windowOrGlobal.cancelAnimationFrame = windowOrGlobal[vendors[x] + 'CancelAnimationFrame']
        || windowOrGlobal[vendors[x] + 'CancelRequestAnimationFrame'];
    }

    if (!windowOrGlobal.requestAnimationFrame) {
      windowOrGlobal.requestAnimationFrame = function requestAnimationFrame(callback) {
        const currTime = new Date().getTime();
        const timeToCall = Math.max(0, 16 - (currTime - lastTime));
        const id = windowOrGlobal.setTimeout(function () {
          callback(currTime + timeToCall);
        }, timeToCall);
        lastTime = currTime + timeToCall;
        return id;
      };
    }

    if (!windowOrGlobal.cancelAnimationFrame) {
      windowOrGlobal.cancelAnimationFrame = function cancelAnimationFrame(id) {
        clearTimeout(id);
      };
    }
  }
}());

import { compose } from 'stampit';
import EventEmitter from './EventEmitter';

/**
 * @class Ticker
 */
const Ticker = compose({
  init() {
    // The amount of time (in milliseconds) to simulate each time update()
    // runs. See `MainLoop.setSimulationTimestep()` for details.
    this.simulationTimestep = 1000 / 60;

    // The cumulative amount of in-app time that hasn't been simulated yet.
    // See the comments inside animate() for details.
    this.frameDelta = 0;

    // The timestamp in milliseconds of the last time the main loop was run.
    // Used to compute the time elapsed between frames.
    this.lastFrameTimeMs = 0;

    // An exponential moving average of the frames per secondthis..
    this.fps = 60;

    // The timestamp (in milliseconds) of the last time the `fps` moving
    // average was updated.
    this.lastFpsUpdate = 0;

    // The number of frames delivered in the current second.
    this.framesThisSecond = 0;

    // The number of times update() is called in a given frame. This is only
    // relevant inside of animate(), but a reference is held externally so that
    // this variable is not marked for garbage collection every time the main
    // loop runs.
    this.numUpdateSteps = 0;

    // The minimum amount of time in milliseconds that must pass since the last
    // frame was executed before another frame can be executed. The
    // multiplicative inverse caps the FPS (the default of zero means there is
    // no cap).
    this.minFrameDelay = 0;
    
    // Whether the main loop is running.
    this.running = false;
    
    this.paused = false;

    // `true` if `MainLoop.start()` has been called and the most recent time it
    // was called has not been followed by a call to `MainLoop.stop()`. This is
    // different than `running` because there is a delay of a few milliseconds
    // after `MainLoop.start()` is called before the application is considered
    // "running." This delay is due to waiting for the next frame.
    this.started = false;

    // Whether the simulation has fallen too far behind real time.
    // Specifically, `panic` will be set to `true` if too many updates occur in
    // one frame. This is only relevant inside of animate(), but a reference is
    // held externally so that this variable is not marked for garbage
    // collection every time the main loop runs.
    this.panic = false;

    // The ID of the currently executing frame. Used to cancel frames when
    // stopping the loop.
    this.rafHandle;

    /**
     * The main loop that runs updates and rendering.
     *
     * @param {DOMHighResTimeStamp} timestamp
     *   The current timestamp. In practice this is supplied by
     *   requestAnimationFrame at the time that it starts to fire callbacks. This
     *   should only be used for comparison to other timestamps because the epoch
     *   (i.e. the "zero" time) depends on the engine running this code. In engines
     *   that support `DOMHighResTimeStamp` (all modern browsers except iOS Safari
     *   8) the epoch is the time the page started loading, specifically
     *   `performance.timing.navigationStart`. Everywhere else, including node.js,
     *   the epoch is the Unix epoch (1970-01-01T00:00:00Z).
     *
     * @ignore
     */
    this.animate = timestamp => {
      // Run the loop again the next time the browser is ready to render.
      // We set rafHandle immediately so that the next frame can be canceled
      // during the current frame.
      this.rafHandle = requestAnimationFrame(this.animate);
      
      // Throttle the frame rate (if minFrameDelay is set to a non-zero value by
      // `MainLoop.setMaxAllowedFPS()`).
      if (timestamp < this.lastFrameTimeMs + this.minFrameDelay) {
        return;
      }

      // frameDelta is the cumulative amount of in-app time that hasn't been
      // simulated yet. Add the time since the last frame. We need to track total
      // not-yet-simulated time (as opposed to just the time elapsed since the
      // last frame) because not all actually elapsed time is guaranteed to be
      // simulated each frame. See the comments below for details.
      this.frameDelta += timestamp - this.lastFrameTimeMs;
      this.lastFrameTimeMs = timestamp;

      // Run any updates that are not dependent on time in the simulation. See
      // `MainLoop.setBegin()` for additional details on how to use this.
      this.emit('begin', timestamp, this.frameDelta);

      // Update the estimate of the frame rate, `fps`. Every second, the number
      // of frames that occurred in that second are included in an exponential
      // moving average of all frames per second, with an alpha of 0.25. This
      // means that more recent seconds affect the estimated frame rate more than
      // older seconds.
      if (timestamp > this.lastFpsUpdate + 1000) {
        // Compute the new exponential moving average with an alpha of 0.25.
        // Using constants inline is okay here.
        this.fps = 0.25 * this.framesThisSecond + 0.75 * this.fps;

        this.lastFpsUpdate = timestamp;
        this.framesThisSecond = 0;
      }

      this.framesThisSecond++;

      /*
      * A naive way to move an object along its X-axis might be to write a main
      * loop containing the statement `obj.x += 10;` which would move the object
      * 10 units per frame. This approach suffers from the issue that it is
      * dependent on the frame rate. In other words, if your application is
      * running slowly (that is, fewer frames per second), your object will also
      * appear to move slowly, whereas if your application is running quickly
      * (that is, more frames per second), your object will appear to move
      * quickly. This is undesirable, especially in multiplayer/multi-user
      * applications.
      *
      * One solution is to multiply the speed by the amount of time that has
      * passed between rendering frames. For example, if you want your object to
      * move 600 units per second, you might write `obj.x += 600 * delta`, where
      * `delta` is the time passed since the last frame. (For convenience, let's
      * move this statement to an update() function that takes `delta` as a
      * parameter.) This way, your object will move a constant distance over
      * time. However, at low frame rates and high speeds, your object will move
      * large distances every frame, which can cause it to do strange things
      * such as move through walls. Additionally, we would like our program to
      * be deterministic. That is, every time we run the application with the
      * same input, we would like exactly the same output. If the time between
      * frames (the `delta`) varies, our output will diverge the longer the
      * program runs due to accumulated rounding errors, even at normal frame
      * rates.
      *
      * A better solution is to separate the amount of time simulated in each
      * update() from the amount of time between frames. Our update() function
      * doesn't need to change; we just need to change the delta we pass to it
      * so that each update() simulates a fixed amount of time (that is, `delta`
      * should have the same value each time update() is called). The update()
      * function can be run multiple times per frame if needed to simulate the
      * total amount of time passed since the last frame. (If the time that has
      * passed since the last frame is less than the fixed simulation time, we
      * just won't run an update() until the the next frame. If there is
      * unsimulated time left over that is less than our timestep, we'll just
      * leave it to be simulated during the next frame.) This approach avoids
      * inconsistent rounding errors and ensures that there are no giant leaps
      * through walls between frames.
      *
      * That is what is done below. It introduces a new problem, but it is a
      * manageable one: if the amount of time spent simulating is consistently
      * longer than the amount of time between frames, the application could
      * freeze and crash in a spiral of death. This won't happen as long as the
      * fixed simulation time is set to a value that is high enough that
      * update() calls usually take less time than the amount of time they're
      * simulating. If it does start to happen anyway, see `MainLoop.setEnd()`
      * for a discussion of ways to stop it.
      *
      * Additionally, see `MainLoop.setUpdate()` for a discussion of performance
      * considerations.
      *
      * Further reading for those interested:
      *
      * - http://gameprogrammingpatterns.com/game-loop.html
      * - http://gafferongames.com/game-physics/fix-your-timestep/
      * - https://gamealchemist.wordpress.com/2013/03/16/thoughts-on-the-javascript-game-loop/
      * - https://developer.mozilla.org/en-US/docs/Games/Anatomy
      */
      let numUpdateSteps = 0;
      while (this.frameDelta >= this.simulationTimestep) {
        this.emit('update', this.simulationTimestep);
        this.frameDelta -= this.simulationTimestep;

        /*
        * Sanity check: bail if we run the loop too many times.
        *
        * One way this could happen is if update() takes longer to run than
        * the time it simulates, thereby causing a spiral of death. For ways
        * to avoid this, see `MainLoop.setEnd()`. Another way this could
        * happen is if the browser throttles serving frames, which typically
        * occurs when the tab is in the background or the device battery is
        * low. An event outside of the main loop such as audio processing or
        * synchronous resource reads could also cause the application to hang
        * temporarily and accumulate not-yet-simulated time as a result.
        *
        * 240 is chosen because, for any sane value of simulationTimestep, 240
        * updates will simulate at least one second, and it will simulate four
        * seconds with the default value of simulationTimestep. (Safari
        * notifies users that the script is taking too long to run if it takes
        * more than five seconds.)
        *
        * If there are more updates to run in a frame than this, the
        * application will appear to slow down to the user until it catches
        * back up. In networked applications this will usually cause the user
        * to get out of sync with their peers, but if the updates are taking
        * this long already, they're probably already out of sync.
        */
        if (++numUpdateSteps >= 240) {
          this.panic = true;

          break;
        }
      }

      /*
      * Render the screen. We do this regardless of whether update() has run
      * during this frame because it is possible to interpolate between updates
      * to make the frame rate appear faster than updates are actually
      * happening. See `MainLoop.setDraw()` for an explanation of how to do
      * that.
      *
      * We draw after updating because we want the screen to reflect a state of
      * the application that is as up-to-date as possible. (`MainLoop.start()`
      * draws the very first frame in the application's initial state, before
      * any updates have occurred.) Some sources speculate that rendering
      * earlier in the requestAnimationFrame callback can get the screen painted
      * faster; this is mostly not true, and even when it is, it's usually just
      * a trade-off between rendering the current frame sooner and rendering the
      * next frame later.
      *
      * See `MainLoop.setDraw()` for details about draw() itself.
      */
      this.emit('draw', this.frameDelta / this.simulationTimestep);

      // Run any updates that are not dependent on time in the simulation. See
      // `MainLoop.setEnd()` for additional details on how to use this.
      this.emit('end', this.fps, this.panic);

      this.panic = false;
    };
  },
  methods: {
    /**
     * Gets how many milliseconds should be simulated by every run of update().
     *
     * See `MainLoop.setSimulationTimestep()` for details on this value.
     *
     * @memberof Ticker#
     * @return {Number}
     *   The number of milliseconds that should be simulated by every run of
     *   {@link #setUpdate update}().
     */
    getSimulationTimestep() {
      return this.simulationTimestep;
    },
    /**
     * Sets how many milliseconds should be simulated by every run of update().
     *
     * The perceived frames per second (FPS) is effectively capped at the
     * multiplicative inverse of the simulation timestep. That is, if the
     * timestep is 1000 / 60 (which is the default), then the maximum perceived
     * FPS is effectively 60. Decreasing the timestep increases the maximum
     * perceived FPS at the cost of running {@link #setUpdate update}() more
     * times per frame at lower frame rates. Since running update() more times
     * takes more time to process, this can actually slow down the frame rate.
     * Additionally, if the amount of time it takes to run update() exceeds or
     * very nearly exceeds the timestep, the application will freeze and crash
     * in a spiral of death (unless it is rescued; see `MainLoop.setEnd()` for
     * an explanation of what can be done if a spiral of death is occurring).
     *
     * The exception to this is that interpolating between updates for each
     * render can increase the perceived frame rate and reduce visual
     * stuttering. See `MainLoop.setDraw()` for an explanation of how to do
     * this.
     *
     * If you are considering decreasing the simulation timestep in order to
     * raise the maximum perceived FPS, keep in mind that most monitors can't
     * display more than 60 FPS. Whether humans can tell the difference among
     * high frame rates depends on the application, but for reference, film is
     * usually displayed at 24 FPS, other videos at 30 FPS, most games are
     * acceptable above 30 FPS, and virtual reality might require 75 FPS to
     * feel natural. Some gaming monitors go up to 144 FPS. Setting the
     * timestep below 1000 / 144 is discouraged and below 1000 / 240 is
     * strongly discouraged. The default of 1000 / 60 is good in most cases.
     *
     * The simulation timestep should typically only be changed at
     * deterministic times (e.g. before the main loop starts for the first
     * time, and not in response to user input or slow frame rates) to avoid
     * introducing non-deterministic behavior. The update timestep should be
     * the same for all players/users in multiplayer/multi-user applications.
     *
     * See also `MainLoop.getSimulationTimestep()`.
     *
     * @memberof Ticker#
     * @param {Number} timestep
     *   The number of milliseconds that should be simulated by every run of
     *   {@link #setUpdate update}().
     */
    setSimulationTimestep(timestep) {
      this.simulationTimestep = timestep;

      return this;
    },
    /**
     * Returns the exponential moving average of the frames per second.
     *
     * @memberof Ticker#
     * @return {Number}
     *   The exponential moving average of the frames per second.
     */
    getFPS() {
      return this.fps;
    },
    /**
     * Gets the maximum frame rate.
     *
     * Other factors also limit the FPS; see `MainLoop.setSimulationTimestep`
     * for details.
     *
     * See also `MainLoop.setMaxAllowedFPS()`.
     *
     * @memberof Ticker#
     * @return {Number}
     *   The maximum number of frames per second allowed.
     */
    getMaxAllowedFPS() {
      return 1000 / this.minFrameDelay;
    },

    /**
     * Sets a maximum frame rate.
     *
     * See also `MainLoop.getMaxAllowedFPS()`.
     *
     * @memberof Ticker#
     * @param {Number} [fps=Infinity]
     *   The maximum number of frames per second to execute. If Infinity or not
     *   passed, there will be no FPS cap (although other factors do limit the
     *   FPS; see `MainLoop.setSimulationTimestep` for details). If zero, this
     *   will stop the loop, and when the loop is next started, it will return
     *   to the previous maximum frame rate. Passing negative values will stall
     *   the loop until this function is called again with a positive value.
     *
     * @chainable
     */
    setMaxAllowedFPS(fps) {
      if (typeof fps === 'undefined') {
        this.fps = Infinity;
      }

      if (fps === 0) {
        this.stop();
      } else {
        // Dividing by Infinity returns zero.
        this.minFrameDelay = 1000 / fps;
      }

      return this;
    },

    /**
     * Reset the amount of time that has not yet been simulated to zero.
     *
     * This introduces non-deterministic behavior if called after the
     * application has started running (unless it is being reset, in which case
     * it doesn't matter). However, this can be useful in cases where the
     * amount of time that has not yet been simulated has grown very large
     * (for example, when the application's tab gets put in the background and
     * the browser throttles the timers as a result). In applications with
     * lockstep the player would get dropped, but in other networked
     * applications it may be necessary to snap or ease the player/user to the
     * authoritative state and discard pending updates in the process. In
     * non-networked applications it may also be acceptable to simply resume
     * the application where it last left off and ignore the accumulated
     * unsimulated time.
     *
     * @memberof Ticker#
     * @return {Number}
     *   The cumulative amount of elapsed time in milliseconds that has not yet
     *   been simulated, but is being discarded as a result of calling this
     *   function.
     */
    resetFrameDelta() {
      const oldFrameDelta = this.frameDelta;
      this.frameDelta = 0;
      return oldFrameDelta;
    },
    /**
     * Starts the main loop.
     *
     * Note that the application is not considered "running" immediately after
     * this function returns; rather, it is considered "running" after the
     * application draws its first frame. The distinction is that event
     * handlers should remain paused until the application is running, even
     * after `MainLoop.start()` is called. Check `MainLoop.isRunning()` for the
     * current status. To act after the application starts, register a callback
     * with requestAnimationFrame() after calling this function and execute the
     * action in that callback. It is safe to call `MainLoop.start()` multiple
     * times even before the application starts running and without calling
     * `MainLoop.stop()` in between, although there is no reason to do this;
     * the main loop will only start if it is not already started.
     *
     * See also `MainLoop.stop()`.
     *
     * @memberof Ticker#
     */
    start() {
      if (!this.started) {
        // Since the application doesn't start running immediately, track
        // whether this function was called and use that to keep it from
        // starting the main loop multiple times.
        this.started = true;

        // In the main loop, draw() is called after update(), so if we
        // entered the main loop immediately, we would never render the
        // initial state before any updates occur. Instead, we run one
        // frame where all we do is draw, and then start the main loop with
        // the next frame.
        this.rafHandle = requestAnimationFrame(timestamp => {
          // Render the initial state before any updates occur.
          this.emit('draw', 1);

          // The application isn't considered "running" until the
          // application starts drawing.
          this.running = true;

          // Reset variables that are used for tracking time so that we
          // don't simulate time passed while the application was paused.
          this.lastFrameTimeMs = timestamp;
          this.lastFpsUpdate = timestamp;
          this.framesThisSecond = 0;

          // Start the main loop.
          this.rafHandle = requestAnimationFrame(this.animate);
        });
      }

      return this;
    },

    /**
     * Stops the main loop.
     *
     * Event handling and other background tasks should also be paused when the
     * main loop is paused.
     *
     * Note that pausing in multiplayer/multi-user applications will cause the
     * player's/user's client to become out of sync. In this case the
     * simulation should exit, or the player/user needs to be snapped to their
     * updated position when the main loop is started again.
     *
     * See also `MainLoop.start()` and `MainLoop.isRunning()`.
     *
     * @memberof Ticker#
     */
    stop() {
      this.running = false;
      this.started = false;
      cancelAnimationFrame(this.rafHandle);

      return this;
    },
    pause() {
      if (this.isRunning() && !this.paused) {
        this.paused = true;
        this.stop();
      }
      
      return this;
    },
    resume() {
      if (this.paused) {
        this.paused = false;
        this.start();
      }
      
      return this;
    },
    /**
     * Returns whether the main loop is currently running.
     *
     * See also `MainLoop.start()` and `MainLoop.stop()`.
     *
     * @memberof Ticker#
     * @return {Boolean}
     *   Whether the main loop is currently running.
     */
    isRunning() {
      return this.running;
    },
  },
}, EventEmitter);

export default Ticker;