game-shell
Version:
Ready-to-go game shell
738 lines (646 loc) • 20 kB
JavaScript
"use strict"
var EventEmitter = require("events").EventEmitter
, util = require("util")
, domready = require("domready")
, vkey = require("vkey")
, invert = require("invert-hash")
, uniq = require("uniq")
, bsearch = require("binary-search-bounds")
, iota = require("iota-array")
, min = Math.min
//Browser compatibility hacks
require("./lib/raf-polyfill.js")
var addMouseWheel = require("./lib/mousewheel-polyfill.js")
var hrtime = require("./lib/hrtime-polyfill.js")
//Remove angle braces and other useless crap
var filtered_vkey = (function() {
var result = new Array(256)
, i, j, k
for(i=0; i<256; ++i) {
result[i] = "UNK"
}
for(i in vkey) {
k = vkey[i]
if(k.charAt(0) === '<' && k.charAt(k.length-1) === '>') {
k = k.substring(1, k.length-1)
}
k = k.replace(/\s/g, "-")
result[parseInt(i)] = k
}
return result
})()
//Compute minimal common set of keyboard functions
var keyNames = uniq(Object.keys(invert(filtered_vkey)))
//Translates a virtual keycode to a normalized keycode
function virtualKeyCode(key) {
return bsearch.eq(keyNames, key)
}
//Maps a physical keycode to a normalized keycode
function physicalKeyCode(key) {
return virtualKeyCode(filtered_vkey[key])
}
//Game shell
function GameShell() {
EventEmitter.call(this)
this._curKeyState = new Array(keyNames.length)
this._pressCount = new Array(keyNames.length)
this._releaseCount = new Array(keyNames.length)
this._tickInterval = null
this._rafHandle = null
this._tickRate = 0
this._lastTick = hrtime()
this._frameTime = 0.0
this._paused = true
this._width = 0
this._height = 0
this._wantFullscreen = false
this._wantPointerLock = false
this._fullscreenActive = false
this._pointerLockActive = false
this._render = render.bind(undefined, this)
this.preventDefaults = true
this.stopPropagation = false
for(var i=0; i<keyNames.length; ++i) {
this._curKeyState[i] = false
this._pressCount[i] = this._releaseCount[i] = 0
}
//Public members
this.element = null
this.bindings = {}
this.frameSkip = 100.0
this.tickCount = 0
this.frameCount = 0
this.startTime = hrtime()
this.tickTime = this._tickRate
this.frameTime = 10.0
this.stickyFullscreen = false
this.stickyPointerLock = false
//Scroll stuff
this.scroll = [0,0,0]
//Mouse state
this.mouseX = 0
this.mouseY = 0
this.prevMouseX = 0
this.prevMouseY = 0
}
util.inherits(GameShell, EventEmitter)
var proto = GameShell.prototype
//Bind keynames
proto.keyNames = keyNames
//Binds a virtual keyboard event to a physical key
proto.bind = function(virtual_key) {
//Look up previous key bindings
var arr
if(virtual_key in this.bindings) {
arr = this.bindings[virtual_key]
} else {
arr = []
}
//Add keys to list
var physical_key
for(var i=1, n=arguments.length; i<n; ++i) {
physical_key = arguments[i]
if(virtualKeyCode(physical_key) >= 0) {
arr.push(physical_key)
} else if(physical_key in this.bindings) {
var keybinds = this.bindings[physical_key]
for(var j=0; j<keybinds.length; ++j) {
arr.push(keybinds[j])
}
}
}
//Remove any duplicate keys
arr = uniq(arr)
if(arr.length > 0) {
this.bindings[virtual_key] = arr
}
this.emit('bind', virtual_key, arr)
}
//Unbinds a virtual keyboard event
proto.unbind = function(virtual_key) {
if(virtual_key in this.bindings) {
delete this.bindings[virtual_key]
}
this.emit('unbind', virtual_key)
}
//Checks if a key is set in a given state
function lookupKey(state, bindings, key) {
if(key in bindings) {
var arr = bindings[key]
for(var i=0, n=arr.length; i<n; ++i) {
if(state[virtualKeyCode(arr[i])]) {
return true
}
}
return false
}
var kc = virtualKeyCode(key)
if(kc >= 0) {
return state[kc]
}
return false
}
//Checks if a key is set in a given state
function lookupCount(state, bindings, key) {
if(key in bindings) {
var arr = bindings[key], r = 0
for(var i=0, n=arr.length; i<n; ++i) {
r += state[virtualKeyCode(arr[i])]
}
return r
}
var kc = virtualKeyCode(key)
if(kc >= 0) {
return state[kc]
}
return 0
}
//Checks if a key (either physical or virtual) is currently held down
proto.down = function(key) {
return lookupKey(this._curKeyState, this.bindings, key)
}
//Checks if a key was ever down
proto.wasDown = function(key) {
return this.down(key) || !!this.press(key)
}
//Opposite of down
proto.up = function(key) {
return !this.down(key)
}
//Checks if a key was released during previous frame
proto.wasUp = function(key) {
return this.up(key) || !!this.release(key)
}
//Returns the number of times a key was pressed since last tick
proto.press = function(key) {
return lookupCount(this._pressCount, this.bindings, key)
}
//Returns the number of times a key was released since last tick
proto.release = function(key) {
return lookupCount(this._releaseCount, this.bindings, key)
}
//Pause/unpause the game loop
Object.defineProperty(proto, "paused", {
get: function() {
return this._paused
},
set: function(state) {
var ns = !!state
if(ns !== this._paused) {
if(!this._paused) {
this._paused = true
this._frameTime = min(1.0, (hrtime() - this._lastTick) / this._tickRate)
clearInterval(this._tickInterval)
//cancelAnimationFrame(this._rafHandle)
} else {
this._paused = false
this._lastTick = hrtime() - Math.floor(this._frameTime * this._tickRate)
this._tickInterval = setInterval(tick, this._tickRate, this)
this._rafHandle = requestAnimationFrame(this._render)
}
}
}
})
//Fullscreen state toggle
function tryFullscreen(shell) {
//Request full screen
var elem = shell.element
if(shell._wantFullscreen && !shell._fullscreenActive) {
var fs = elem.requestFullscreen ||
elem.requestFullScreen ||
elem.webkitRequestFullscreen ||
elem.webkitRequestFullScreen ||
elem.mozRequestFullscreen ||
elem.mozRequestFullScreen ||
function() {}
fs.call(elem)
}
if(shell._wantPointerLock && !shell._pointerLockActive) {
var pl = elem.requestPointerLock ||
elem.webkitRequestPointerLock ||
elem.mozRequestPointerLock ||
elem.msRequestPointerLock ||
elem.oRequestPointerLock ||
function() {}
pl.call(elem)
}
}
var cancelFullscreen = document.exitFullscreen ||
document.cancelFullscreen || //Why can no one agree on this?
document.cancelFullScreen ||
document.webkitCancelFullscreen ||
document.webkitCancelFullScreen ||
document.mozCancelFullscreen ||
document.mozCancelFullScreen ||
function(){}
Object.defineProperty(proto, "fullscreen", {
get: function() {
return this._fullscreenActive
},
set: function(state) {
var ns = !!state
if(!ns) {
this._wantFullscreen = false
cancelFullscreen.call(document)
} else {
this._wantFullscreen = true
tryFullscreen(this)
}
return this._fullscreenActive
}
})
function handleFullscreen(shell) {
shell._fullscreenActive = document.fullscreen ||
document.mozFullScreen ||
document.webkitIsFullScreen ||
false
if(!shell.stickyFullscreen && shell._fullscreenActive) {
shell._wantFullscreen = false
}
}
//Pointer lock state toggle
var exitPointerLock = document.exitPointerLock ||
document.webkitExitPointerLock ||
document.mozExitPointerLock ||
function() {}
Object.defineProperty(proto, "pointerLock", {
get: function() {
return this._pointerLockActive
},
set: function(state) {
var ns = !!state
if(!ns) {
this._wantPointerLock = false
exitPointerLock.call(document)
} else {
this._wantPointerLock = true
tryFullscreen(this)
}
return this._pointerLockActive
}
})
function handlePointerLockChange(shell, event) {
shell._pointerLockActive = shell.element === (
document.pointerLockElement ||
document.mozPointerLockElement ||
document.webkitPointerLockElement ||
null)
if(!shell.stickyPointerLock && shell._pointerLockActive) {
shell._wantPointerLock = false
}
}
//Width and height
Object.defineProperty(proto, "width", {
get: function() {
return this.element.clientWidth
}
})
Object.defineProperty(proto, "height", {
get: function() {
return this.element.clientHeight
}
})
//Set key state
function setKeyState(shell, key, state) {
var ps = shell._curKeyState[key]
if(ps !== state) {
if(state) {
shell._pressCount[key]++
} else {
shell._releaseCount[key]++
}
shell._curKeyState[key] = state
}
}
//Ticks the game state one update
function tick(shell) {
var skip = hrtime() + shell.frameSkip
, pCount = shell._pressCount
, rCount = shell._releaseCount
, i, s, t
, tr = shell._tickRate
, n = keyNames.length
while(!shell._paused &&
hrtime() >= shell._lastTick + tr) {
//Skip frames if we are over budget
if(hrtime() > skip) {
shell._lastTick = hrtime() + tr
return
}
//Tick the game
s = hrtime()
shell.emit("tick")
t = hrtime()
shell.tickTime = t - s
//Update counters and time
++shell.tickCount
shell._lastTick += tr
//Shift input state
for(i=0; i<n; ++i) {
pCount[i] = rCount[i] = 0
}
if(shell._pointerLockActive) {
shell.prevMouseX = shell.mouseX = shell.width>>1
shell.prevMouseY = shell.mouseY = shell.height>>1
} else {
shell.prevMouseX = shell.mouseX
shell.prevMouseY = shell.mouseY
}
shell.scroll[0] = shell.scroll[1] = shell.scroll[2] = 0
}
}
//Render stuff
function render(shell) {
//Request next frame
shell._rafHandle = requestAnimationFrame(shell._render)
//Tick the shell
tick(shell)
//Compute frame time
var dt
if(shell._paused) {
dt = shell._frameTime
} else {
dt = min(1.0, (hrtime() - shell._lastTick) / shell._tickRate)
}
//Draw a frame
++shell.frameCount
var s = hrtime()
shell.emit("render", dt)
var t = hrtime()
shell.frameTime = t - s
}
function isFocused(shell) {
return (document.activeElement === document.body) ||
(document.activeElement === shell.element)
}
function handleEvent(shell, ev) {
if(shell.preventDefaults) {
ev.preventDefault()
}
if(shell.stopPropagation) {
ev.stopPropagation()
}
}
//Set key up
function handleKeyUp(shell, ev) {
handleEvent(shell, ev)
var kc = physicalKeyCode(ev.keyCode || ev.char || ev.which || ev.charCode)
if(kc >= 0) {
setKeyState(shell, kc, false)
}
}
//Set key down
function handleKeyDown(shell, ev) {
if(!isFocused(shell)) {
return
}
handleEvent(shell, ev)
if(ev.metaKey) {
//Hack: Clear key state when meta gets pressed to prevent keys sticking
handleBlur(shell, ev)
} else {
var kc = physicalKeyCode(ev.keyCode || ev.char || ev.which || ev.charCode)
if(kc >= 0) {
setKeyState(shell, kc, true)
}
}
}
//Mouse events are really annoying
var mouseCodes = iota(32).map(function(n) {
return virtualKeyCode("mouse-" + (n+1))
})
function setMouseButtons(shell, buttons) {
for(var i=0; i<32; ++i) {
setKeyState(shell, mouseCodes[i], !!(buttons & (1<<i)))
}
}
function handleMouseMove(shell, ev) {
handleEvent(shell, ev)
if(shell._pointerLockActive) {
var movementX = ev.movementX ||
ev.mozMovementX ||
ev.webkitMovementX ||
0,
movementY = ev.movementY ||
ev.mozMovementY ||
ev.webkitMovementY ||
0
shell.mouseX += movementX
shell.mouseY += movementY
} else {
shell.mouseX = ev.clientX - shell.element.offsetLeft
shell.mouseY = ev.clientY - shell.element.offsetTop
}
return false
}
function handleMouseDown(shell, ev) {
handleEvent(shell, ev)
setKeyState(shell, mouseCodes[ev.button], true)
return false
}
function handleMouseUp(shell, ev) {
handleEvent(shell, ev)
setKeyState(shell, mouseCodes[ev.button], false)
return false
}
function handleMouseEnter(shell, ev) {
handleEvent(shell, ev)
if(shell._pointerLockActive) {
shell.prevMouseX = shell.mouseX = shell.width>>1
shell.prevMouseY = shell.mouseY = shell.height>>1
} else {
shell.prevMouseX = shell.mouseX = ev.clientX - shell.element.offsetLeft
shell.prevMouseY = shell.mouseY = ev.clientY - shell.element.offsetTop
}
return false
}
function handleMouseLeave(shell, ev) {
handleEvent(shell, ev)
setMouseButtons(shell, 0)
return false
}
//Handle mouse wheel events
function handleMouseWheel(shell, ev) {
handleEvent(shell, ev)
var scale = 1
switch(ev.deltaMode) {
case 0: //Pixel
scale = 1
break
case 1: //Line
scale = 12
break
case 2: //Page
scale = shell.height
break
}
//Add scroll
shell.scroll[0] += ev.deltaX * scale
shell.scroll[1] += ev.deltaY * scale
shell.scroll[2] += (ev.deltaZ * scale)||0.0
return false
}
function handleContexMenu(shell, ev) {
handleEvent(shell, ev)
return false
}
function handleBlur(shell, ev) {
var n = keyNames.length
, c = shell._curKeyState
, r = shell._releaseCount
, i
for(i=0; i<n; ++i) {
if(c[i]) {
++r[i]
}
c[i] = false
}
return false
}
function handleResizeElement(shell, ev) {
var w = shell.element.clientWidth|0
var h = shell.element.clientHeight|0
if((w !== shell._width) || (h !== shell._height)) {
shell._width = w
shell._height = h
shell.emit("resize", w, h)
}
}
function makeDefaultContainer() {
var container = document.createElement("div")
container.tabindex = 1
container.style.position = "absolute"
container.style.left = "0px"
container.style.right = "0px"
container.style.top = "0px"
container.style.bottom = "0px"
container.style.height = "100%"
container.style.overflow = "hidden"
document.body.appendChild(container)
document.body.style.overflow = "hidden" //Prevent bounce
document.body.style.height = "100%"
return container
}
function createShell(options) {
options = options || {}
//Check fullscreen and pointer lock flags
var useFullscreen = !!options.fullscreen
var usePointerLock = useFullscreen
if(typeof options.pointerLock !== undefined) {
usePointerLock = !!options.pointerLock
}
//Create initial shell
var shell = new GameShell()
shell._tickRate = options.tickRate || 30
shell.frameSkip = options.frameSkip || (shell._tickRate+5) * 5
shell.stickyFullscreen = !!options.stickyFullscreen || !!options.sticky
shell.stickyPointerLock = !!options.stickyPointerLock || !!options.sticky
//Set bindings
if(options.bindings) {
shell.bindings = options.bindings
}
//Wait for dom to intiailize
setTimeout(function() { domready(function initGameShell() {
//Retrieve element
var element = options.element
if(typeof element === "string") {
var e = document.querySelector(element)
if(!e) {
e = document.getElementById(element)
}
if(!e) {
e = document.getElementByClass(element)[0]
}
if(!e) {
e = makeDefaultContainer()
}
shell.element = e
} else if(typeof element === "object" && !!element) {
shell.element = element
} else if(typeof element === "function") {
shell.element = element()
} else {
shell.element = makeDefaultContainer()
}
//Disable user-select
if(shell.element.style) {
shell.element.style["-webkit-touch-callout"] = "none"
shell.element.style["-webkit-user-select"] = "none"
shell.element.style["-khtml-user-select"] = "none"
shell.element.style["-moz-user-select"] = "none"
shell.element.style["-ms-user-select"] = "none"
shell.element.style["user-select"] = "none"
}
//Hook resize handler
shell._width = shell.element.clientWidth
shell._height = shell.element.clientHeight
var handleResize = handleResizeElement.bind(undefined, shell)
if(typeof MutationObserver !== "undefined") {
var observer = new MutationObserver(handleResize)
observer.observe(shell.element, {
attributes: true,
subtree: true
})
} else {
shell.element.addEventListener("DOMSubtreeModified", handleResize, false)
}
window.addEventListener("resize", handleResize, false)
//Hook keyboard listener
window.addEventListener("keydown", handleKeyDown.bind(undefined, shell), false)
window.addEventListener("keyup", handleKeyUp.bind(undefined, shell), false)
//Disable right click
shell.element.oncontextmenu = handleContexMenu.bind(undefined, shell)
//Hook mouse listeners
shell.element.addEventListener("mousedown", handleMouseDown.bind(undefined, shell), false)
shell.element.addEventListener("mouseup", handleMouseUp.bind(undefined, shell), false)
shell.element.addEventListener("mousemove", handleMouseMove.bind(undefined, shell), false)
shell.element.addEventListener("mouseenter", handleMouseEnter.bind(undefined, shell), false)
//Mouse leave
var leave = handleMouseLeave.bind(undefined, shell)
shell.element.addEventListener("mouseleave", leave, false)
shell.element.addEventListener("mouseout", leave, false)
window.addEventListener("mouseleave", leave, false)
window.addEventListener("mouseout", leave, false)
//Blur event
var blur = handleBlur.bind(undefined, shell)
shell.element.addEventListener("blur", blur, false)
shell.element.addEventListener("focusout", blur, false)
shell.element.addEventListener("focus", blur, false)
window.addEventListener("blur", blur, false)
window.addEventListener("focusout", blur, false)
window.addEventListener("focus", blur, false)
//Mouse wheel handler
addMouseWheel(shell.element, handleMouseWheel.bind(undefined, shell), false)
//Fullscreen handler
var fullscreenChange = handleFullscreen.bind(undefined, shell)
document.addEventListener("fullscreenchange", fullscreenChange, false)
document.addEventListener("mozfullscreenchange", fullscreenChange, false)
document.addEventListener("webkitfullscreenchange", fullscreenChange, false)
//Stupid fullscreen hack
shell.element.addEventListener("click", tryFullscreen.bind(undefined, shell), false)
//Pointer lock change handler
var pointerLockChange = handlePointerLockChange.bind(undefined, shell)
document.addEventListener("pointerlockchange", pointerLockChange, false)
document.addEventListener("mozpointerlockchange", pointerLockChange, false)
document.addEventListener("webkitpointerlockchange", pointerLockChange, false)
document.addEventListener("pointerlocklost", pointerLockChange, false)
document.addEventListener("webkitpointerlocklost", pointerLockChange, false)
document.addEventListener("mozpointerlocklost", pointerLockChange, false)
//Update flags
shell.fullscreen = useFullscreen
shell.pointerLock = usePointerLock
//Default mouse button aliases
shell.bind("mouse-left", "mouse-1")
shell.bind("mouse-right", "mouse-3")
shell.bind("mouse-middle", "mouse-2")
//Initialize tick counter
shell._lastTick = hrtime()
shell.startTime = hrtime()
//Unpause shell
shell.paused = false
//Emit initialize event
shell.emit("init")
})}, 0)
return shell
}
module.exports = createShell