require('engine/polyfill');
const EventEmitter = require('engine/EventEmitter');
const Vector = require('engine/Vector');
const resize = require('engine/resize');
const device = require('engine/device');
const config = require('game/config');
/**
* @type {EventEmitter}
* @private
*/
const core = new EventEmitter();
let nextGameIdx = 1;
// Public properties and methods
Object.assign(core, {
/**
* Version text.
* @memberof module:engine/core
* @type {string}
*/
version: 'v1.1.0-dev',
/**
* Set to `false` to disable version info console output.
* @memberof module:engine/core
* @type {Boolean}
*/
sayHello: true,
/**
* Main Canvas element.
* @memberof module:engine/core
* @type {HTMLCanvasElement}
*/
view: null,
/**
* Div that contains game canvas.
* @memberof module:engine/core
* @type {HTMLCanvasElement}
*/
containerView: null,
/**
* Canvas resolution properly choosed based on configs.
* @memberof module:engine/core
* @type {Number}
*/
resolution: 1,
/**
* Size of game content.
* @memberof module:engine/core
* @type {Vector}
* @default (640, 400)
*/
size: Vector.create(config.width || 640, config.height || 400),
/**
* Size of view (devicePixelRatio independent).
* @memberof module:engine/core
* @type {Vector}
*/
viewSize: Vector.create(config.width || 640, config.height || 400),
/**
* Current resize function.
* @memberof module:engine/core
* @type {function}
*/
resizeFunc: null,
/**
* Map of registered games.
* @memberof module:engine/core
* @type {object}
*/
games: {},
/**
* Current activated game.
* Note: this may be undefined during switching.
* Will be deprecated in future versions.
* @memberof module:engine/core
* @type {Game}
*/
game: null,
/**
* Map that contains pause state of all kinds of reasons.
* See {@link core.pause} for more information.
* @memberof module:engine/core
* @type {object}
*/
pauses: {},
/**
* Global time speed, whose value is between 0 and 1.
* @memberof module:engine/core
* @type {number}
* @default 1
*/
speed: 1,
/**
* Rotate prompt element for mobile devices.
* @memberof module:engine/core
* @type {HTMLElement}
*/
rotatePromptElm: null,
/**
* Whether the rotate prompt is visible.
* @memberof module:engine/core
* @type {boolean}
*/
rotatePromptVisible: false,
/**
* Switch to a game.
* @memberof module:engine/core
* @param {Game} gameCtor Game class to be set
* @param {Boolean} [newInstance=false] Whether create new instance for this game.
* @param {Object} [param={}] Parameters to pass to the game(to `Game#awake`)
*/
setGame: function(gameCtor, newInstance = false, param = {}) {
if (!gameCtor.id) {
gameCtor.id = nextGameIdx++;
}
let pair = core.games[gameCtor.id];
if (!pair) {
pair = { ctor: gameCtor, inst: null, param: param };
}
pair.param = param;
if (newInstance) {
pair.inst = null;
}
nextGame = pair;
},
/**
* Main entry.
* @memberof module:engine/core
* @param {Game} gameCtor First game class
* @param {Game} loaderCtor Asset loader class
*/
main: function(gameCtor, loaderCtor) {
core.setGame(loaderCtor, true, { gameClass: gameCtor });
window.addEventListener('load', boot, false);
document.addEventListener('DOMContentLoaded', boot, false);
},
/**
* Pause the engine with a reason.
* @memberof module:engine/core
*
* @example
* import core from 'engine/core';
* // Pause when ad is playing
* core.pause('ad');
* // And resume after the ad finished
* core.resume('ad');
*
* @param {String} [reason='untitled'] The reason to pause, you have to pass
* the same reason when resume from this
* pause.
*/
pause: function(reason = 'untitled') {
let i, alreadyPaused = false;
for (i in core.pauses) {
if (!core.pauses.hasOwnProperty(i)) {continue;}
// Do not pause again if game is paused by other reasons
if (core.pauses[i]) {
alreadyPaused = true;
break;
}
}
core.pauses[reason] = true;
if (!alreadyPaused) {
core.emit('pause', reason);
}
},
/**
* Unpause the engine.
* @memberof module:engine/core
* @param {String} [reason='untitled'] Resume from pause tagged with this reason
* @param {Boolean} [force=false] Whether force to resume
*/
resume: function(reason = 'untitled', force = false) {
let i;
if (force) {
// Resume everything
for (i in core.pauses) {
if (!core.pauses.hasOwnProperty(i)) {continue;}
core.pauses[i] = false;
}
core.emit('resume');
}
else if (typeof(core.pauses[reason]) === 'boolean') {
core.pauses[reason] = false;
for (i in core.pauses) {
if (!core.pauses.hasOwnProperty(i)) {continue;}
// Do not resume if game is still paused by other reasons
if (core.pauses[i]) {
return;
}
}
core.emit('resume', reason);
}
},
});
/**
* Width of the game.
* Should keep the same to `config` settings.
* @memberof module:engine/core
* @type {number}
* @readonly
*/
Object.defineProperty(core, 'width', {
get: function() {
return this.size.x;
},
});
/**
* Height of the game.
* Should keep the same to `config` settings.
* @memberof module:engine/core
* @type {number}
* @readonly
*/
Object.defineProperty(core, 'height', {
get: function() {
return this.size.y;
},
});
/**
* Width of the game view, `devicePixelRatio` is not take into account.
* @memberof module:engine/core
* @type {number}
* @readonly
*/
Object.defineProperty(core, 'viewWidth', {
get: function() {
return this.viewSize.x;
},
});
/**
* Height of the game view, `devicePixelRatio` is not take into account.
* @memberof module:engine/core
* @type {number}
* @readonly
*/
Object.defineProperty(core, 'viewHeight', {
get: function() {
return this.viewSize.y;
},
});
/**
* Whether the game is currently paused by any reason.
* @memberof module:engine/core
* @type {boolean}
* @readonly
*/
Object.defineProperty(core, 'paused', {
get: function() {
// Paused by any reason?
for (var i in core.pauses) {
if (core.pauses[i]) {
return true;
}
}
// Totally unpaused
return false;
},
});
// Fetch device info and setup
if (config.gfx && config.gfx.resolution) {
core.resolution = chooseProperResolution(config.gfx.resolution);
}
// - Private properties and methods
let nextGame = null;
let loopId = 0;
let resizeFunc = _letterBoxResize;
/**
* @private
*/
function startLoop() {
loopId = requestAnimationFrame(loop);
}
/**
* @param {Number} timestamp Timestamp at this frame.
* @private
*/
function loop(timestamp) {
loopId = requestAnimationFrame(loop);
// Do not update anything when paused
if (!core.paused) {
// Switch to new game
if (nextGame) {
let pair = nextGame;
nextGame = null;
// Freeze current game before switching
if (core.game) {
core.off('pause', core.game.pause, core.game);
core.off('resume', core.game.resume, core.game);
core.off('resize', core.game.resize, core.game);
core.game.freeze();
}
core.game = null;
// Create instance of game if not exist
if (!pair.inst) {pair.inst = new pair.ctor();}
// Awake the game
core.game = pair.inst;
core.on('pause', core.game.pause, core.game);
core.on('resume', core.game.resume, core.game);
core.on('resize', core.game.resize, core.game);
core.game.awake(pair.param);
// Resize container of the game
resizeFunc();
}
// Update current game
if (core.game) {
core.game.run(timestamp);
}
// Tick
core.emit('tick');
}
}
/**
* @private
*/
core.endLoop = function endLoop() {
cancelAnimationFrame(loopId);
};
/**
* @private
*/
function boot() {
window.removeEventListener('load', boot);
document.removeEventListener('DOMContentLoaded', boot);
// Disable scroll
_noPageScroll();
core.view = document.getElementById(config.canvas || 'game');
core.containerView = document.getElementById('container');
/**
* Keep focus when mouse/touch event occurs on the canvas.
* @private
*/
function focus() { window.focus(); }
core.view.addEventListener('mousedown', focus);
core.view.addEventListener('touchstart', focus);
// Start game loop
startLoop();
// Pick a resize function
switch (config.resizeMode) {
case 'letter-box':
resizeFunc = _letterBoxResize;
break;
case 'crop':
resizeFunc = _cropResize;
break;
case 'scale-inner':
resizeFunc = _scaleInnerResize;
break;
case 'scale-outer':
resizeFunc = _scaleOuterResize;
break;
default:
resizeFunc = _letterBoxResize;
}
core.resizeFunc = resizeFunc;
// Listen to the resize and orientation events
window.addEventListener('resize', resizeFunc, false);
window.addEventListener('orientationchange', resizeFunc, false);
// Manually resize for the first time
resizeFunc(true);
// Setup visibility change API
const visibleResume = function() {
if (config.pauseOnHide) {core.resume('visibility');}
};
const visiblePause = function() {
if (config.pauseOnHide) {core.pause('visibility');}
};
// Main visibility API function
const vis = (function() {
let stateKey, eventKey;
const keys = {
hidden: 'visibilitychange',
mozHidden: 'mozvisibilitychange',
webkitHidden: 'webkitvisibilitychange',
msHidden: 'msvisibilitychange',
};
for (stateKey in keys) {
if (stateKey in document) {
eventKey = keys[stateKey];
break;
}
}
return function(c) {
if (c) {document.addEventListener(eventKey, c, false);}
return !document[stateKey];
};
})();
// Check if current tab is active or not
vis(function() {
if (vis()) {
// The setTimeout() is used due to a delay
// before the tab gains focus again, very important!
setTimeout(visibleResume, 300);
}
else {
visiblePause();
}
});
// Check if browser window has focus
if (window.addEventListener) {
window.addEventListener('focus', function() {
setTimeout(visibleResume, 300);
}, false);
window.addEventListener('blur', visiblePause, false);
}
else {
window.attachEvent('focus', function() {
setTimeout(visibleResume, 300);
});
window.attachEvent('blur', visiblePause);
}
// Create rotate prompt if required
if (device.mobile && config.showRotatePrompt) {
const div = document.createElement('div');
div.innerHTML = config.rotatePromptImg ? '' : config.rotatePromptMsg;
div.style.position = 'absolute';
div.style.height = '12px';
div.style.textAlign = 'center';
div.style.left = 0;
div.style.right = 0;
div.style.top = 0;
div.style.bottom = 0;
div.style.margin = 'auto';
div.style.display = 'none';
div.style.color = config.rotatePromptFontColor || 'black';
core.rotatePromptElm = div;
document.body.appendChild(div);
if (config.rotatePromptImg) {
const img = new Image();
img.onload = function() {
div.image = img;
div.appendChild(img);
resizeRotatePrompt();
};
img.src = config.rotatePromptImg;
img.className = 'center';
core.rotatePromptImg = img;
}
// Check orientation and display the rotate prompt if required
const isLandscape = (core.width / core.height >= 1);
core.on('resize', function() {
if (window.innerWidth < window.innerHeight && isLandscape) {
core.rotatePromptVisible = true;
}
else if (window.innerWidth > window.innerHeight && !isLandscape) {
core.rotatePromptVisible = true;
}
else {
core.rotatePromptVisible = false;
}
// Hide game view
core.view.style.display = core.rotatePromptVisible ? 'none' : 'block';
// Show rotate view
core.rotatePromptElm.style.backgroundColor = config.rotatePromptBGColor || 'black';
core.rotatePromptElm.style.display = core.rotatePromptVisible ? '-webkit-box' : 'none';
core.rotatePromptElm.style.display = core.rotatePromptVisible ? '-webkit-flex' : 'none';
core.rotatePromptElm.style.display = core.rotatePromptVisible ? '-ms-flexbox' : 'none';
core.rotatePromptElm.style.display = core.rotatePromptVisible ? 'flex' : 'none';
resizeRotatePrompt();
// Pause the game if orientation is not correct
if (core.rotatePromptVisible) {
core.pause('rotate');
}
else {
core.resume('rotate');
}
});
}
core.emit('boot');
core.emit('booted');
}
/**
* @param {Object|Number} res Resolution configs
* @return {Number} Properly choosed resolution number
* @private
*/
function chooseProperResolution(res) {
// Default value
if (!res) {
return 1;
}
// Numerical value
else if (Number.isFinite(res)) {
return res;
}
// Calculate based on window size and device pixel ratio
else {
res.values.sort(function(a, b) { return a - b; });
const gameRatio = core.width / core.height;
const windowRatio = window.innerWidth / window.innerHeight;
const scale = res.retina ? window.devicePixelRatio : 1;
let result = res.values[0];
for (let i = 0; i < res.values.length; i++) {
result = res.values[i];
if ((gameRatio >= windowRatio && window.innerWidth * scale <= core.width * result) ||
(gameRatio < windowRatio && window.innerHeight * scale <= core.height * result)) {
break;
}
}
return result;
}
}
/**
* @private
*/
function resizeRotatePrompt() {
core.rotatePromptElm.style.width = `${window.innerWidth}px`;
core.rotatePromptElm.style.height = `${window.innerHeight}px`;
_alignToWindowCenter(core.rotatePromptElm, window.innerWidth, window.innerHeight);
const imgRatio = core.rotatePromptImg.width / core.rotatePromptImg.height;
const windowRatio = window.innerWidth / window.innerHeight;
let w, h;
if (imgRatio < windowRatio) {
w = Math.floor(window.innerHeight * 0.8);
h = Math.floor(w / imgRatio);
}
else {
h = Math.floor(window.innerWidth * 0.8);
w = Math.floor(h * imgRatio);
}
core.rotatePromptImg.style.height = `${w}px`;
core.rotatePromptImg.style.width = `${h}px`;
}
// Resize functions
let windowSize = { x: 1, y: 1 };
let scaledWidth, scaledHeight;
let result, container;
/**
* @private
*/
function _letterBoxResize() {
// Update sizes
windowSize.x = window.innerWidth;
windowSize.y = window.innerHeight;
// Use inner box scaling function to calculate correct size
result = resize.outerBoxResize(windowSize, core.viewSize);
scaledWidth = Math.floor(core.viewSize.x * result.scale);
scaledHeight = Math.floor(core.viewSize.y * result.scale);
// Resize the view
core.view.style.width = `${scaledWidth}px`;
core.view.style.height = `${scaledHeight}px`;
// Resize the container
core.containerView.style.width = `${scaledWidth}px`;
core.containerView.style.height = `${scaledHeight}px`;
core.containerView.style.marginTop = `${Math.floor(result.top)}px`;
core.containerView.style.marginLeft = `${Math.floor(result.left)}px`;
// Broadcast resize events
core.emit('resize', core.viewSize.x, core.viewSize.y);
// Reset scroll for mobile devices
if (device.mobile) {
window.scrollTo(0, 1);
}
}
/**
* @private
*/
function _cropResize() {
// Update sizes
core.viewSize.set(window.innerWidth, window.innerHeight);
core.view.style.width = core.containerView.style.width = `${window.innerWidth}px`;
core.view.style.height = core.containerView.style.height = `${window.innerHeight}px`;
// Broadcast resize events
core.emit('resize', core.viewSize.x, core.viewSize.y);
// Reset scroll for mobile devices
if (device.mobile) {
window.scrollTo(0, 1);
}
}
/**
* @private
*/
function _scaleInnerResize() {
// Update sizes
core.viewSize.set(window.innerWidth, window.innerHeight);
core.view.style.width = core.containerView.style.width = `${window.innerWidth}px`;
core.view.style.height = core.containerView.style.height = `${window.innerHeight}px`;
// Resize container of current game
if (core.game) {
container = core.game.stage;
result = resize.innerBoxResize(core.viewSize, core.size);
container.scale.set(result.scale);
container.position.set(result.left, result.top);
}
// Broadcast resize events
core.emit('resize', core.viewSize.x, core.viewSize.y);
// Reset scroll for mobile devices
if (device.mobile) {
window.scrollTo(0, 1);
}
}
/**
* @private
*/
function _scaleOuterResize() {
// Update sizes
core.viewSize.set(window.innerWidth, window.innerHeight);
core.view.style.width = core.containerView.style.width = `${window.innerWidth}px`;
core.view.style.height = core.containerView.style.height = `${window.innerHeight}px`;
// Resize container of current game
if (core.game) {
container = core.game.stage;
result = resize.outerBoxResize(core.viewSize, core.size);
container.scale.set(result.scale);
container.position.set(result.left, result.top);
}
// Broadcast resize events
core.emit('resize', core.viewSize.x, core.viewSize.y);
// Reset scroll for mobile devices
if (device.mobile) {
window.scrollTo(0, 1);
}
}
// CSS helpers
/**
* Alien an element to the center.
* @param {HTMLElement} el Element to align
* @param {Number} w Width of this element
* @param {Number} h Height of this element
* @private
*/
function _alignToWindowCenter(el, w, h) {
el.style.marginLeft = `${Math.floor((window.innerWidth - w) / 2)}px`;
el.style.marginTop = `${Math.floor((window.innerHeight - h) / 2)}px`;
}
/**
* Prevent mouse scroll
* @private
*/
function _noPageScroll() {
document.ontouchmove = function(event) {
event.preventDefault();
};
}
/**
* Engine core that manages game loop and resize functionality.
* @exports engine/core
*
* @requires module:engine/polyfill
* @requires module:engine/EventEmitter
* @requires module:engine/Vector
* @requires module:engine/resize
* @requires module:engine/device
*/
module.exports = core;