/**
* @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
*/
/**
* @module wk/game/GameLoop
*/
import Automaton from '../pattern/automaton/Automaton.js';
import EventDispatcher from '../pattern/observer/EventDispatcher.js';
import GameLoopEventDetail from './GameLoopEventDetail.js';
/** @private */
const
// events
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 = () => {};
/**
* A 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.
*/
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 60 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_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);
}
// 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);
}
// 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);
}
// 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);
}
/**
* 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 game loop has been started and is running. The reset function has
* been called (if it exists).
*
* @event GameLoop.EVENT_STARTED
* @type {{detail: GameLoopEventDetail}}
*/
/**
* The name of the event <code>GameLoop.EVENT_STARTED</code>.
* @member {string}
*/
static get EVENT_STARTED() { return C_EVENT_STARTED; }
/**
* The game loop has been continued after it has been paused. It is running.
*
* @event GameLoop.EVENT_CONTINUED
* @type {{detail: GameLoopEventDetail}}
*/
/**
* The name of the event <code>GameLoop.EVENT_CONTINUED</code>.
* @member {string}
*/
static get EVENT_CONTINUED() { return C_EVENT_CONTINUED; }
/**
* The game loop is running either because it has been
* started or continued.
*
* @event GameLoop.EVENT_RUNNING
* @type {{detail: GameLoopEventDetail}}
*/
/**
* The name of the event <code>GameLoop.EVENT_RUNNING</code>.
* @member {string}
*/
static get EVENT_RUNNING() { return C_EVENT_RUNNING; }
/**
* The game loop has been paused. It is not running.
*
* @event GameLoop.EVENT_PAUSED
* @type {{detail: GameLoopEventDetail}}
*/
/**
* The name of the event <code>GameLoop.EVENT_PAUSED</code>.
* @member {string}
*/
static get EVENT_PAUSED() { return C_EVENT_PAUSED; }
/**
* The game loop has been stopped. It is not running.
* The finish function has been invoked (if it exists).
*
* @event GameLoop.EVENT_STOPPED
* @type {{detail: GameLoopEventDetail}}
*/
/**
* 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;
}
}
export default GameLoop;