Source: GameLoop.js

/**
 * @author    Wolfgang Kowarschick <kowa@hs-augsburg.de>
 * @copyright 2017
 * @license   CC-BY-NC-SA-4.0
 * @based_on  https://isaacsukin.com/news/2015/01/detailed-explanation-javascript-game-loops-and-timing
 */

import EventDispatcher from './EventDispatcher.js';

const C_STATE_HAS_CHANGED_EVENT = 'stated_has_changed_event',

      C_STOPPED = 1,
      C_PAUSED  = 2,
      C_RUNNING = 3;

let
  v_game_loop = null, // There exists at most on game loop object,
                      // which is stored here.

// As GameLoop is a singleton class, all private members are stored
// directly in the module.
  v_document = null,  // the document where the view is to be displayed

  f_update,           // the model update function
  f_render,           // the view render function
  f_reset,            // a function the resets the game to an initial state
  f_panic,            // the panic function which is called, if f_update
                      // was not called for at least one second.

  v_state  = null,    // the current state of the loop
  v_raf_id = null,    // the id of the current requestAnimationFrame call

  // The model should be updated exactly 60 times per seconds.
  v_model_fps     = 60,
  v_model_step_ms = 1000/v_model_fps, // = 16,6667 ms   (duration of
  v_model_step_s  = 1/v_model_fps,    // = 0,016667 s    a model frame)

  v_time_last_run_ms, // the time the loop was run last
  v_delta_ms;         // the accumulated time that hasn't been simulated yet

// Create a new event
function f_new_event()
{
  return new CustomEvent(C_STATE_HAS_CHANGED_EVENT);
}

// The default panic function.
function f_panic_default()
{
  console.log('Panic: No model updates for at least on second.');
  v_game_loop.pause();
  v_delta_ms = 0;
}

// The game loop function.
function f_loop(p_current_time_ms)
{
  // Track the accumulated time that hasn't been simulated yet.
  v_delta_ms        += p_current_time_ms - v_time_last_run_ms; // please note +=
  v_time_last_run_ms = p_current_time_ms;

  // Simulate the total elapsed time in fixed-size chunks.
  let l_counter_updates = 0;
  while (v_delta_ms >= v_model_step_ms)
  {
    f_update(v_model_step_s);      // Update the model world.
                                   // v_model_step_s doesn't change over time.
    v_delta_ms -= v_model_step_ms; // Reduce the time that hasn't been simulated.

    l_counter_updates++;

    if (l_counter_updates > v_model_fps) // less then one frame per second
      f_panic();
  }

  // Visualize the current state of the model.
  // Use the time that has not been simulated in the model
  // to interpolate the current position.
  f_render(v_delta_ms/v_model_step_ms);

  v_raf_id = requestAnimationFrame(f_loop);
}

class GameLoop extends EventDispatcher
{
  /**
   * @class GameLoop
   * A singleton class that enables running a game loop which guarantees
   * exactly 60 model updates per second (in most cases), even if the
   * view update rate is significantly lower.
   */

  /**
   * @constructor
   * @param p_update {Function}
   *          A model update function that is to be called exactly 60 times
   *          per second.
   * @param p_render {Function}
   *          A view render function that is to be called as often as possible
   *          up to 60 times per second.
   * @param p_reset {Function}
   *          A function to reset the game to an initial state.
   * @param p_panic {Function}
   *          A function that is called if the state of the loop is
   *          <code>GameLoop.RUNNING</code> and the model update function
   *          is not called for at least one second.
   * @param p_document {Object}
   *          The document where the views are to be displayed.
   *          If the document looses the visibility focus, the
   *          game loop automatically is paused.
   */
  constructor(p_update, p_render, p_reset = null,
              p_panic = f_panic_default,
              p_document = window.document
             )
  {
    super({stated_has_changed_event: f_new_event()});

    if (v_document === null)
    {
      v_document = p_document;
      v_document.addEventListener
                 ( 'visibilitychange',
                   function()
                   {
                     if (v_document.hidden)
                       v_game_loop.pause();
                     else if (v_state === C_PAUSED)
                       v_game_loop.goOn();
                   }
                 );
    }

    f_update = p_update;
    f_render = p_render;
    f_reset  = p_reset;
    f_panic  = p_panic;

    // Make the game loop class to be a singleton class.
    if (v_game_loop === null)
      v_game_loop = this;

    // Reset or initialize the current game loop.
    v_game_loop.stop();

    return v_game_loop;
  }

  /** State of the game loop when the game loop has been stopped by
   *  means of the function <code>stop</code>.
   *  This is the default state, after the game loop has been initialized.
   */
  static get STATE_HAS_CHANGED_EVENT() { return C_STATE_HAS_CHANGED_EVENT; }

  /**
   * State of the game loop when the game loop has been stopped by
   * means of the function <code>stop</code>.
   * This is the default state, after the game loop has been initialized.
   */
  static get STOPPED() { return C_STOPPED; }

  /**
   * State of the game loop when the game loop has been paused by
   * means of the function <code>pause</code>.
   */
  static get PAUSED() { return C_PAUSED; }

  /**
   * State of the game loop when the game loop has been started or
   * continued by means of the function <code>start</code>
   * or <code>continue</code>.
   */
  static get RUNNING() { return C_RUNNING; }

  /**
   * @return {number} The current state of the game loop. The value is either
   *                  <code>GameLoop.STOPPED</code>,
   *                  <code>GameLoop.PAUSED</code>, or
   *                  <code>GameLoop.RUNNING</code>
   */
  static get state() { return v_state; }

  /**
   * Function to start the game loop.
   * This function has only an effect, if the current state of
   * the game loop is <code>gameLoop.STOPPED</code>.
   */

  start()
  {
    if (v_state === C_STOPPED) // Don't start a game twice.
    {
      v_state = C_RUNNING;
      this.dispatchEvent(f_new_event());

      // Initialize and start the game loop.
      v_raf_id =
        requestAnimationFrame
        ( // Initialize all variables needed.
          function (p_current_time_ms)
          {
            f_render(); // Visualize the initial state of the game.

            v_time_last_run_ms = p_current_time_ms;
            v_delta_ms = 0;

            // Now really start the game loop;
            v_raf_id = requestAnimationFrame(f_loop);
          }
        );
    }
  }

  /**
   * Function to pause the game loop.
   * This function has only an effect, if the current state of
   * the game loop is <code>gameLoop.RUNNING</code>.
   */
  pause()
  {
    if (v_state === C_RUNNING) // Don't pause a game that ist not running.
    {
      v_state = C_PAUSED;
      this.dispatchEvent(f_new_event());

      cancelAnimationFrame(v_raf_id);
      v_raf_id = null;
    }
  }

  /**
   * Function to continue the game loop.
   * This function has only an effect, if the current state of
   * the game loop is <code>GameLoop.PAUSED</code>.
   */
  goOn()
  {
    if (v_state === C_PAUSED) // The game is not paused, so it cannot be continued.
    {
      v_state = C_RUNNING;
      this.dispatchEvent(f_new_event());

      // Initialize and restart the game loop.
      v_raf_id =
        requestAnimationFrame
        ( // Initialize all variables needed.
          function (p_current_time_ms)
          {
            v_time_last_run_ms = p_current_time_ms;
            v_raf_id = requestAnimationFrame(f_loop);
          }
        );
    }
  }

  /**
   * Function to continue the game loop.
   * This function has only an effect, if the current state of
   * the game loop is not already <code>GameLoop.STOPPED</code>.
   *
   * @fires GameLoop:state_has_changed
   */
  stop()
  {
    if (v_state !== C_STOPPED) // Don't stop a game twice.
    {
      v_state = C_STOPPED;
      this.dispatchEvent(f_new_event());

      cancelAnimationFrame(v_raf_id);
      v_raf_id = null;

      // Reset the app and visualize the new state;
      if (f_reset !== null)
        f_reset();
      f_render();
    }
  }
}

export default GameLoop;