Source: core.js

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;