Source: wk/game/GameLoop.js

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

import Automaton           from '../pattern/automaton/Automaton.js';
import EventDispatcher     from '../pattern/observer/EventDispatcher.js';

/** @private */
const
  // events
  C_EVENT_STATE_HAS_CHANGED = 'event state has changed',
  C_EVENT_STARTED           = 'event started',
  C_EVENT_CONTINUED         = 'event continued',
  C_EVENT_RUNNING           = 'event running',
  C_EVENT_PAUSED            = 'event paused',
  C_EVENT_STOPPED           = 'event stopped',
  
  // states
  C_STOPPED = 'stopped',
  C_PAUSED  = 'paused',
  C_RUNNING = 'running',
  
  // actions
  C_START                 = 'start',
  C_PAUSE                 = 'pause',
  C_CONTINUE              = 'continue',
  C_STOP                  = 'stop',
  C_TOGGLE_START_STOP     = 'toggleStartStop',
  C_TOGGLE_PAUSE_CONTINUE = 'togglePauseContinue',

  F_NOTHING = () => {};

/**
 * @class
 * @description
 * A class that enables running a game loop which guarantees
 * exactly <code>ups</code> model updates per second (in most cases), even if the
 * view update rate is significantly lower.
 */
class GameLoop extends EventDispatcher
{ /**
   * @constructor
   * @param {{update:   ?Function,
   *          render:   ?Function,
   *          init:     ?Function,
   *          reset:    ?Function,
   *          finish:   ?Function,
   *          panic:    ?Function,
   *          document: ?Object,
   *          ups:      ?number
   *        }} p_config
   *
   * @param {?Function} p_config.update
   *        A model update function that is to be called exactly
   *        <code>ups</code> times per second
   *
   * @param {?Function} p_config.render
   *        A view render function that is to be called as often as possible
   *        up to 60 times per second.
   *
   * @param {?Function} p_config.init
   *        A hook to do some additional init action,
   *        when the game loop has been created
   *
   * @param {?Function} p_config.reset
   *        A function to reset the game to an initial state.
   *        It is called, whenever the game loop is created or restarted.
   *
   * @param {?Function} p_config.finish
   *        A function to finish the game when the game loop is stopped.
   *
   * @param {Function} [p_config.panic = this.panicDefault]
   *        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. By default the
   *        loop is paused.
   *
   * @param {Object} [p_config.document = window.document]
   *        The document where the views are to be displayed.
   *        If the document looses the visibility focus, the
   *        game loop automatically is paused. This, however,
   *        doesn't work correctly in all browsers.
   *
   * @param {number} [p_config.ups = 60]
   *                 The number of model updates per second.
   *                 The value should be 60 at least.
   *
   * @fires GameLoop.EVENT_STATE_HAS_CHANGED
   * @fires GameLoop.EVENT_STARTED
   * @fires GameLoop.EVENT_CONTINUED
   * @fires GameLoop.EVENT_RUNNING
   * @fires GameLoop.EVENT_PAUSED
   * @fires GameLoop.EVENT_STOPPED
   */
  constructor({update:   p_update   = F_NOTHING,
               render:   p_render   = F_NOTHING,
               init:     p_init     = F_NOTHING,
               reset:    p_reset    = F_NOTHING,
               finish:   p_finish   = F_NOTHING,
               panic:    p_panic    = null,
               document: p_document = window.document,
               ups:      p_ups      = 60
             })
  { super();
  
    this.v_raf_id = null; // the id of the current requestAnimationFrame call
    
      // The model should be updated exactly p_ups times per seconds.
    this.v_ups           = p_ups;
    this.v_model_step_ms = 1000/this.v_ups; // = 16,6667 ms   (duration of
    this.v_model_step_s  = 1/this.v_ups;    // = 0,016667 s    a model frame)
       
  //this.v_time_last_run_ms;        // the time the loop was run last
  //this.v_delta_ms;                // the accumulated time that hasn't been simulated yet
    this.v_is_interrupted = false;  // the loop is interrupted due to a visibility change
  
    this.v_automaton = // the automaton for switching the game loop state
      new Automaton
      ({initialState: 'stopped',
        transitions:
        { 'stopped':
          { 'start':               { state: 'running', do: this.f_start.bind(this) },
            'toggleStartStop':     { state: 'running', do: this.f_start.bind(this)  },
          },
    
          'paused':
          { 'continue':            { state: 'running', do: this.f_continue.bind(this) },
            'stop':                { state: 'stopped', do: this.f_stop.bind(this)     },
            'togglePauseContinue': { state: 'running', do: this.f_continue.bind(this) },
            'toggleStartStop':     { state: 'stopped', do: this.f_stop.bind(this)     }
          },
    
          'running':
          { 'pause':               { state: 'paused',  do: this.f_pause.bind(this) },
            'stop':                { state: 'stopped', do: this.f_stop.bind(this)  },
            'togglePauseContinue': { state: 'paused',  do: this.f_pause.bind(this) },
            'toggleStartStop':     { state: 'stopped', do: this.f_stop.bind(this)  }
          }
        }
      });
    
    this.v_document = p_document || window.document; // the document where the view is to be displayed
    if (this.v_document === window.document)
    { let f_event_listener =
          () =>
          { if (this.v_document.hidden && this.state === C_RUNNING)
            { this.pause(); this.v_is_interrupted = true; }
            else if (this.v_is_interrupted)
            { this.v_is_interrupted = false; this.continue(); }
          };
      try
      { this.v_document.removeEventListener('visibilitychange',f_event_listener); }
      catch
      {}
      this.v_document.addEventListener('visibilitychange',f_event_listener);
    }
    
    this.f_update = p_update;
    this.f_render = p_render;
    this.f_init   = p_init;
    this.f_reset  = p_reset;
    this.f_finish = p_finish;
    this.f_panic  = p_panic || this.panicDefault;
    
    // Initialize the current game loop.
    this.f_reset();
    this.f_render();
    this.f_init();
  }
  
  f_dispatch_event(p_type)
  { this.dispatchEvent
    ( p_type,
      { action:        this.v_automaton.lastAction,
        state:         this.v_automaton.state,
        previousState: this.v_automaton.previousState,
        ups:           this.v_ups,
        fps:           this.v_fps,
      }
    );
  }
  
  // The game loop function.
  f_loop(p_current_time_ms)
  { // Track the accumulated time that hasn't been simulated yet.
    this.v_delta_ms        += p_current_time_ms-this.v_time_last_run_ms; // please note +=
    this.v_fps              = 1000/(p_current_time_ms-this.v_time_last_run_ms);
    this.v_time_last_run_ms = p_current_time_ms;
  
    // Simulate the total elapsed time in fixed-size chunks.
    let l_counter_updates = 0;
    while (this.v_delta_ms >= this.v_model_step_ms)
    { this.f_update(this.v_model_step_s);      // Update the model world.
                                               // v_model_step_s doesn't change over time.
      this.v_delta_ms -= this.v_model_step_ms; // Reduce the time that hasn't been simulated.

      l_counter_updates++;

      if (l_counter_updates > this.v_ups) // less then one frame per second
      { this.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.
    this.f_render(this.v_delta_ms/this.v_model_step_ms);
  
    if (this.v_raf_id != null)
    { this.v_raf_id = requestAnimationFrame(this.f_loop.bind(this)); }
  }

  // starts the loop
  f_start()
  { this.v_raf_id =
      requestAnimationFrame
      ( // Initialize all variables needed.
        (p_current_time_ms) =>
        { this.f_reset();
          this.f_render(); // Visualize the initial state of the game.
      
          this.v_time_last_run_ms = p_current_time_ms;
          this.v_delta_ms = 0;
      
          // Now really start the game loop;
          this.v_raf_id = requestAnimationFrame(this.f_loop.bind(this));
        }
      );
  
    this.f_dispatch_event(C_EVENT_STARTED);
    this.f_dispatch_event(C_EVENT_RUNNING);
    this.f_dispatch_event(C_EVENT_STATE_HAS_CHANGED);
  }

  // pauses the loop
  f_pause()
  { cancelAnimationFrame(this.v_raf_id);
    this.v_raf_id = null;
    this.v_fps = 0;
  
    this.f_dispatch_event(C_EVENT_PAUSED);
    this.f_dispatch_event(C_EVENT_STATE_HAS_CHANGED);
  }

  // continues the loop
  f_continue()
  { this.v_raf_id =
      requestAnimationFrame
      ( // Initialize all variables needed.
        (p_current_time_ms) =>
        { this.v_time_last_run_ms = p_current_time_ms;
          this.v_raf_id = requestAnimationFrame(this.f_loop.bind(this));
        }
      );
  
    this.f_dispatch_event(C_EVENT_CONTINUED);
    this.f_dispatch_event(C_EVENT_RUNNING);
    this.f_dispatch_event(C_EVENT_STATE_HAS_CHANGED);
  }

  // stops the loop
  f_stop()
  { cancelAnimationFrame(this.v_raf_id);
    this.v_raf_id = null;
    this.v_fps = 0;
    this.f_finish();
    this.f_dispatch_event(C_EVENT_STOPPED);
    this.f_dispatch_event(C_EVENT_STATE_HAS_CHANGED);
  }
  
  /**
   * Method to start the game loop.
   * A call of this method has only an effect, if the current state of
   * the game loop is <code>GameLoop.STOPPED</code>.
   */
  start()
  { this.v_automaton.action(C_START); }
  
  /**
   * Method to pause the game loop.
   * A call of this method has only an effect, if the current state of
   * the game loop is <code>GameLoop.RUNNING</code>.
   */
  pause()
  { this.v_automaton.action(C_PAUSE); }
  
  /**
   * Method to continue the game loop.
   * A call of this method has only an effect, if the current state of
   * the game loop is <code>GameLoop.PAUSED</code>.
   */
  continue()
  { this.v_automaton.action(C_CONTINUE); }
  
  /**
   * Method to stop the game loop.
   * A call of this method has only an effect if the current state of
   * the game loop is not already <code>GameLoop.STOPPED</code>.
   */
  stop()
  { this.v_automaton.action(C_STOP); }
  
  /**
   * Method to toggle between the states
   * <code>GameLoop.RUNNING</code> and <code>GameLoop.STOPPED</code>.
   */
  toggleStartStop()
  { this.v_automaton.action(C_TOGGLE_START_STOP); }
  
  /**
   * Method to toggle between the states
   * <code>GameLoop.PAUSED</code> and <code>GameLoop.RUNNING</code>.
   */
  togglePauseContinue()
  { this.v_automaton.action(C_TOGGLE_PAUSE_CONTINUE); }
  
  /**
   * The current state of the game loop.
   * @member {string}
   */
  get state() { return this.v_automaton.state; }
  
   /**
   * The state of the game loop when the game loop
   * has been stopped by means of the method <code>stop</code>.
   * This is the default state, after the game loop has been initialized.
   * @member {string}
   */
  static get STOPPED() { return C_STOPPED; }
  
  /**
   * The state of the game loop when the game loop
   * has been paused by means of the method <code>pause</code>.
   * @member {string}
   */
  static get PAUSED() { return C_PAUSED; }

  /**
   * The state of the game loop when the game loop
   * has been started or continued by means one of the
   * methods <code>start</code> and <code>continue</code>.
   * @member {string}
   */
  static get RUNNING() { return C_RUNNING; }
  
  /**
   * The action to start the game.
   * @member {string}
   */
  static get START() { return C_START; }
  
  /**
   * The action to pause the game loop.
   * @member {string}
   */
  static get PAUSE() { return C_PAUSE; }
  
  /**
   * The action to continue the game loop.
   * @member {string}
   */
  static get CONTINUE() { return C_CONTINUE; }
  
  /**
   * The action to stop the game loop.
   * @member {string}
   */
  static get STOP() { return C_STOP; }
  
  /**
   * The action to toggle between the states
   * <code>GameLoop.RUNNING</code> and <code>GameLoop.STOPPED</code>.
   * @member {string}
   */
  static get TOGGLE_START_STOP() { return C_TOGGLE_START_STOP; }
  
  /**
   * The action to toggle between the states
   * <code>GameLoop.PAUSED</code> and <code>GameLoop.RUNNING</code>.
   * @member {string}
   */
  static get TOGGLE_PAUSE_CONTINUE() { return C_TOGGLE_PAUSE_CONTINUE; }
  
  /**
   * The name of the event <code>GameLoop.EVENT_STATE_HAS_CHANGED</code>.
   * @member {string}
   */
  static get EVENT_STATE_HAS_CHANGED() { return C_EVENT_STATE_HAS_CHANGED; }
  
  /**
   * The name of the event <code>GameLoop.EVENT_STARTED</code>.
   * @member {string}
   */
  static get EVENT_STARTED() { return C_EVENT_STARTED; }
  
  /**
   * The name of the event <code>GameLoop.EVENT_CONTINUED</code>.
   * @member {string}
   */
  static get EVENT_CONTINUED() { return C_EVENT_CONTINUED; }
  
  /**
   * The name of the event <code>GameLoop.EVENT_RUNNING</code>.
   * @member {string}
   */
  static get EVENT_RUNNING() { return C_EVENT_RUNNING; }
  
  /**
   * The name of the event <code>GameLoop.EVENT_PAUSED</code>.
   * @member {string}
   */
  static get EVENT_PAUSED() { return C_EVENT_PAUSED; }

  /**
   * The name of the event <code>GameLoop.EVENT_STOPPED</code>.
   * @member {string}
   */
  static get EVENT_STOPPED() { return C_EVENT_STOPPED; }
  
  /**
   *  The default panic function. It pauses the game loop.
   */
  panicDefault()
  { //console.log('Panic: No model updates for at least on second.');
    this.pause();
    this.v_delta_ms = 0;
  }
}

/**
 * This event is fired when the state of the game loop has changed.
 * @event    GameLoop#EVENT_STATE_HAS_CHANGED
 * @type     {Object}
 * @property {GameLoopEventDetail} detail
 * @memberOf GameLoop
 */

/**
 * This event is fired when the game loop has been started and is running.
 * The reset function has been called (if it exists).
 * @event    GameLoop#EVENT_STARTED
 * @type     {Object}
 * @property {GameLoopEventDetail} detail
 * @memberOf GameLoop
 */

/**
 * This event is fired when the game loop has been continued
 * after it had been paused.
 *
 * @event GameLoop#EVENT_CONTINUED
 * @type     {Object}
 * @property {GameLoopEventDetail} detail
 * @memberOf GameLoop
 */

/**
 * This event is fired when the game loop has been paused
 * while it was running.
 *
 * @event GameLoop#EVENT_PAUSED
 * @type     {Object}
 * @property {GameLoopEventDetail} detail
 * @memberOf GameLoop
 */

/**
 * This event is fired when the game loop switches into the state
 * <code>running</code> either because it has been started or continued.
 *
 * @event GameLoop#EVENT_RUNNING
 * @type     {Object}
 * @property {GameLoopEventDetail} detail
 * @memberOf GameLoop
 */

/**
 * This event is fired when the game loop has been stopped.
 *
 * @event GameLoop#EVENT_STOPPED
 * @type     {Object}
 * @property {GameLoopEventDetail} detail
 * @memberOf GameLoop
 */

export {GameLoop};
export default GameLoop;