const async = require('async')
const Util = require('./Util')
const Asset = require('./Asset')
const Audio = require('./Audio')
const Entity = require('./Entity')
const HasEventsMixin = require('./HasEventsMixin')
const HasEntitiesMixin = require('./HasEntitiesMixin')
const HasScenesMixin = require('./HasScenesMixin')
/**
* GameEngine is the base class for the top level container, mapping the game into a browser.
* You should extend GameEngine to implement your own game.
* @extends HasEventsMixin
* @mixes HasScenesMixin
* @mixes HasEntitiesMixin
* @property {!string} targetId The DOM id of the element to target. This element will be replaced with a HTML5 'canvas' element.
* @property {boolean} fullscreen Allow the canvas element to occupy the maximum possible space (optional, default false)
* @property {boolean} showHUD Show the debug Heads Up Display (HUD) (optional, default false)
* @property {integer} x The viewport x-coordinate in pixels (optional, default 0)
* @property {integer} y The viewport y-coordinate in pixels (optional, default 0)
* @property {float} scale The current scale (zoom) where 1 = 100% (optional, default 1)
* @property {integer} minX The minimum viewport x-coordinate in pixels (optional)
* @property {integer} minY The minimum viewport y-coordinate in pixels (optional)
* @property {integer} maxX The maximum viewport x-coordinate in pixels (optional)
* @property {integer} maxY The maximum viewport y-coordinate in pixels (optional)
* @property {float} globalAlpha The global alpha value (0 to 1) for drawing scenes (optional, default 1)
* @property {boolean} enableScroll Enable mouse scrolling (optional, default true)
* @property {boolean} enableZoom Enable mouse zooming (optional, default true)
* @property {integer} width The width of the viewport in pixels (readonly)
* @property {integer} height The height of the viewport in pixels (readonly)
*/
class GameEngine {
/**
* Create a new GameEngine with the specified options.
* @param {object} options The options for the GameEngine, composed of the properties.
* @example <caption>To create a GameEngine attached to a specified element in HTML5</caption>
<div id='game' width="1200" height="960"></div>
<script type="text/javascript">
const GameEngine = require('GameEngine')
var game = new GameEngine({ targetId: 'game', fullscreen: true, showHUD: true });
game.start();
</script>
*/
constructor (options = {}) {
// Has Events
HasEventsMixin(this, options)
// Has a collection of Scenes
HasScenesMixin(this, options)
// Has a collection of Entities
HasEntitiesMixin(this, options)
// targetId is the DOM id of the element to target. This element will be replaced with a HTML5 'canvas' element.
this.targetId = options.targetId
// If fullscreen is true, the element will maximise as much as possible
this.fullscreen = !!options.fullscreen
// Show the Debug HUD
this.showHUD = !!options.showHUD
// The initial scale where 1 is 100%
this.scale = typeof (options.scale) === 'undefined' ? 1 : options.scale
// The initial coordinates for scrolling
this.x = options.x || 0
this.y = options.y || 0
// The min/max scale values when zoom is enabled
this.maxScale = options.maxScale
this.minScale = options.minScale
// The min/max scroll values when scroll is enabled
this.minX = options.minX
this.minY = options.minY
this.maxX = options.maxX
this.maxY = options.maxY
// Global Alpha
this.globalAlpha = typeof (options.globalAlpha) === 'undefined' ? 1 : options.globalAlpha
// Enable mouse scrolling/zooming
this.enableScroll = typeof (options.enableScroll) === 'undefined' ? true : !!options.enableScroll
this.enableZoom = typeof (options.enableZoom) === 'undefined' ? true : !!options.enableZoom
// Private properties
this._audioDefs = {}
this._assetDefs = {}
// Events
this.defineEvents(['running', 'mouseup', 'mousedown', 'mousemove', 'resize'])
}
/**
* Start the game, loading all {@link Asset}s and {@link Audio}s defined by [addAsset()]{@link GameEngine#addAsset} and [addAudio()]{@link GameEngine#addAudio}, bind to the HTML element, call [init()]{@link GameEngine#init} and start the renderer.
*
* @param {function} callback The callback function to invoke when the game has been started (optional)
*/
start (callback) {
console.debug('starting')
if (this.processKey) {
document.onkeyup = (e) => { this.processKey(e) }
}
async.series([
// Load Assets
(cb) => {
async.parallel([
// Load Assets
(cb2) => { this.loadAssets(cb2) },
// Load Audio
(cb2) => { this.loadAudio(cb2) }
], cb)
},
// Boot Element
(cb) => { this.bootElement(cb) },
// Init
(cb) => { this.init(cb) }
],
(err) => {
if (err) {
console.error('error while starting', err)
if (callback) callback(err)
return
}
this.running = true
setImmediate(() => { this.redraw(); this._tick() })
console.debug('started')
this.trigger('running', this)
if (callback) callback()
})
}
/**
* Set the global alpha for drawing.
* @param {float} ga The Global alpha value 0 - 1
*/
setGlobalAlpha (ga) {
this.globalAlpha = ga
}
/**
* Init is called after asset and audio loading. You should override init to create your Scenes, Entities and other game objects.
*
* Note: If you override init, you *must* call the callback when done.
* @param {function} callback The callback function to invoke when the init has been completed
*/
init (callback) {
console.debug('init')
callback()
}
/**
* Stop the game engine.
*/
stop () {
this.running = false
}
/**
* Add an asset definition. Note that the {@link Asset} resource will not be created until [start()]{@link GameEngine#start} is called.
* @param {String} name Asset Name
* @param {String} src Source filename
*/
addAsset (name, src) {
this._assetDefs[name] = src
}
/**
* Get the {@link Asset} with the specified name.
* @param {String} name Asset Name
* @returns {Asset} The Asset with the specified name, or null
* @throws {Exception} Will throw 'Asset not found' if the Asset has not been loaded
*/
getAsset (name) {
if (!this.assets[name]) throw 'Asset not found: ' + name
return this.assets[name]
}
/**
* Add an audio definition. Note that the {@link Audio} resource will not be created until [start()]{@link GameEngine#start} is called.
* @param {String} name Audio Name
* @param {String} src Source filename
* @param {String} type MIME type
*/
addAudio (name, src, type) {
this._audioDefs[name] = { src: src, type: type }
}
/**
* Get the {@link Audio} with the specified name.
* @param {String} name Audio Name
* @returns {Audio} The Audio with the specified name, or null
* @throws {Exception} Will throw 'Audio not found' if the Audio has not been loaded
*/
getAudio (name) {
if (!this.audio[name]) throw 'Audio not found: ' + name
return this.audio[name]
}
/**
* Mark the whole GameEngine to be redrawn.
*/
redraw () {
this._doredraw = true
// Redraw All Scenes
this.redrawScenes()
// Redraw All Entities
this.redrawEntities()
}
/**
* Create and Load all defined {@link Asset}s.
* @param {function} callback The callback function to invoke when all assets have been loaded (optional)
*/
loadAssets (callback) {
console.debug('loading assets')
this.assets = {}
for (var i in this._assetDefs) {
this.assets[i] = new Asset({ name: i, src: this._assetDefs[i] })
}
async.each(this.assets, (asset, cb) => { asset.load(cb) }, callback)
}
/**
* Create and Load all defined {@link Audio}s.
* @param {function} callback The callback function to invoke when all assets have been loaded (optional)
*/
loadAudio (callback) {
console.debug('loading audio')
this.audio = {}
for (var i in this._audioDefs) {
this.audio[i] = new Audio({ name: i, src: this._audioDefs[i].src, type: this._audioDefs[i].type })
}
async.each(this.audio, (audio, cb) => { audio.load(cb) }, callback)
}
/**
* Boot the GameEngine into an HTML 'canvas' element, and replace the DOM element specified by {@link GameEngine.targetId} with it
* @param {function} callback The callback function to invoke when the element has been replaced (optional)
*/
bootElement (callback) {
this.target = document.getElementById(this.targetId)
this.element = document.createElement('canvas')
this.document = this.target.ownerDocument
this.window = this.document.defaultView || this.document.parentWindow
if (this.fullscreen) {
this.recomputeFullScreen()
this.window.addEventListener('resize', Util.debounce(() => { this.recomputeFullScreen() }, 100))
} else {
this.element.width = this.target.getAttribute('width')
this.element.height = this.target.getAttribute('height')
}
this.element.classList.add('gamescreen')
this.target.parentNode.replaceChild(this.element, this.target)
this.width = this.element.width / this.scale
this.height = this.element.height / this.scale
this._bindMouseWheel()
if (callback) callback()
}
/**
* Recompute the [width]{@link GameEngine.width} and [height]{@link GameEngine.height} from the 'canvas' element dimensions and call [redraw()]{@link GameEngine#redraw}
*/
recomputeFullScreen () {
this.element.width = this.window.innerWidth
this.element.height = this.window.innerHeight
this.width = Math.round(this.element.width / this.scale)
this.height = Math.round(this.element.height / this.scale)
this.redraw()
this.trigger('resize', this)
}
_bindMouseWheel () {
var ael = this.element.addEventListener
if (!ael) ael = this.element.attachEvent
if (this.enableScroll || this.enableZoom) {
ael('mousewheel', (e) => this._panZoom(e), false)
ael('DOMMouseScroll', (e) => this._panZoom(e), false)
}
ael('mousemove', (e) => this._move(e), false)
ael('mousedown', (e) => this.trigger('mousedown', this, e))
ael('mouseup', (e) => this.trigger('mouseup', this, e))
}
_tick () {
var context = this.element.getContext('2d')
this.redraw()
context.fillStyle = 'black'
context.fillRect(0, 0, this.element.width, this.element.height)
context.save()
// Set Alpha, scale
context.scale(this.scale, this.scale)
context.translate(this.x, this.y)
context.globalAlpha = this.globalAlpha
// Ensure TiledScene sort order
if (!this._sceneOrderMap) this.sortScenesZ()
// Draw all the scenes in z-order
this.drawScenes(context)
// Draw all the entities in z-order
this.drawEntities(context)
context.restore()
// HUD
if (this.showHUD && this._doredraw) { this._drawHUD(context) }
this._doredraw = false
if (this.running) window.requestAnimationFrame(this._tick.bind(this), 0)
}
// Event Handlers
_panZoom (e) {
if (e.shiftKey && this.enableZoom) {
var f = e.deltaY / 100
this.scale = this.scale + f
if (this.minScale) this.scale = Math.max(this.scale, this.minScale)
if (this.maxScale) this.scale = Math.min(this.scale, this.maxScale)
// Update Width/Height
var ow = this.width; var oh = this.height
this.width = this.element.width / this.scale
this.height = this.element.height / this.scale
// Update x and y to centre zoom
this.x = this.x - (ow - this.width) / 2
this.y = this.y - (oh - this.height) / 2
} else if (this.enableScroll) {
this.x += e.deltaX
this.y += e.deltaY
}
// Limits?
this._enforceScrollLimits()
// Correct Mouse Coords
this._setMouseCoords(e)
// Redraw Everything
this.redraw()
// Prevent DOM Stuff
e.preventDefault()
e.stopPropagation()
}
_enforceScrollLimits () {
if (this.minX) this.x = Math.max(this.minX * this.scale, this.x)
if (this.minY) this.y = Math.max(this.minY * this.scale, this.y)
if (this.maxX) this.x = Math.min(this.maxX / this.scale, this.x)
if (this.maxY) this.y = Math.min(this.maxY / this.scale, this.y)
}
_move (e) {
this._setMouseCoords(e)
this.redraw()
this.trigger('mousemove', this, e)
// Prevent DOM Stuff
e.preventDefault()
e.stopPropagation()
}
_setMouseCoords (e) {
this.mouseX = (e.x / this.scale) - this.x
this.mouseY = (e.y / this.scale) - this.y
}
_drawHUD (context) {
context.save()
context.font = '14px Arial'
context.fillStyle = 'white'
context.fillText(
'Screen (X: ' + Math.round(this.x) +
' Y: ' + Math.round(this.y) +
' W: ' + Math.round(this.width) +
' H: ' + Math.round(this.height) + ')' +
' Zoom: ' + Math.round(this.scale * 100) + '%' +
' Mouse (X: ' + Math.round(this.mouseX) + ' Y: ' + Math.round(this.mouseY) + ')' +
' Limits Min: (X: ' + Math.round(this.minX / this.scale) + ', Y: ' + Math.round(this.minY / this.scale) + ')' +
' Limit Max: (X: ' + Math.round(this.maxX * this.scale) + ', Y: ' + Math.round(this.maxY * this.scale) + ')'
, 10, 20)
context.restore()
}
}
module.exports = GameEngine