UNPKG

game-shell

Version:
738 lines (646 loc) 20 kB
"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