From 1b30d11112249e750f56fbccb91449e74236bf75 Mon Sep 17 00:00:00 2001 From: Aaron Fischer Date: Mon, 14 Aug 2017 17:07:15 +0200 Subject: [PATCH] Lets go --- src/index.html | 3 + src/main.js | 26 + src/vendor/kontra.js | 2315 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 2344 insertions(+) create mode 100644 src/index.html create mode 100644 src/main.js create mode 100644 src/vendor/kontra.js diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..92347c3 --- /dev/null +++ b/src/index.html @@ -0,0 +1,3 @@ + + + diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..6ae9105 --- /dev/null +++ b/src/main.js @@ -0,0 +1,26 @@ +var sprite = kontra.sprite({ + x: 100, // starting x,y position of the sprite + y: 80, + color: 'red', // fill color of the sprite rectangle + width: 20, // width and height of the sprite rectangle + height: 40, + dx: 2 // move the sprite 2px to the right every frame +}); + +var loop = kontra.gameLoop({ // create the main game loop + update: function() { // update the game state + sprite.update(); + + // wrap the sprites position when it reaches + // the edge of the screen + if (sprite.x > kontra.canvas.width) { + sprite.x = -sprite.width; + } + }, + render: function() { // render the game state + sprite.render(); + } +}); + +kontra.init(getElementById('game')); +loop.start(); // start the game diff --git a/src/vendor/kontra.js b/src/vendor/kontra.js new file mode 100644 index 0000000..5cfb406 --- /dev/null +++ b/src/vendor/kontra.js @@ -0,0 +1,2315 @@ +/* + * Kontra.js v3.0.0 (Custom Build on 2017-08-14) | MIT + * Build: https://straker.github.io/kontra/download?files=gameLoop+keyboard+sprite+assets+pool+quadtree+spriteSheet+tileEngine+store + */ +this.kontra = { + + /** + * Initialize the canvas. + * @memberof kontra + * + * @param {string|HTMLCanvasElement} canvas - Main canvas ID or Element for the game. + */ + init: function init(canvas) { + + // check if canvas is a string first, an element next, or default to getting + // first canvas on page + var canvasEl = this.canvas = document.getElementById(canvas) || + canvas || + document.querySelector('canvas'); + + if (!this._isCanvas(canvasEl)) { + throw Error('You must provide a canvas element for the game'); + } + + this.context = canvasEl.getContext('2d'); + }, + + /** + * Noop function. + * @see https://stackoverflow.com/questions/21634886/what-is-the-javascript-convention-for-no-operation#comment61796464_33458430 + * @memberof kontra + * @private + * + * The new operator is required when using sinon.stub to replace with the noop. + */ + _noop: new Function, + + /* + * Determine if a value is a String. + * @see https://github.com/jed/140bytes/wiki/Byte-saving-techniques#coercion-to-test-for-types + * @memberof kontra + * @private + * + * @param {*} value - Value to test. + * + * @returns {boolean} + */ + _isString: function isString(value) { + return ''+value === value; + }, + + /** + * Determine if a value is a Number. + * @see https://github.com/jed/140bytes/wiki/Byte-saving-techniques#coercion-to-test-for-types + * @memberof kontra + * @private + * + * @param {*} value - Value to test. + * + * @returns {boolean} + */ + _isNumber: function isNumber(value) { + return +value === value; + }, + + /** + * Determine if a value is a Function. + * @memberof kontra + * @private + * + * @param {*} value - Value to test. + * + * @returns {boolean} + */ + _isFunc: function isFunction(value) { + return typeof value === 'function'; + }, + + /** + * Determine if a value is an Image. An image can also be a canvas element for + * the purposes of drawing using drawImage(). + * @memberof kontra + * @private + * + * @param {*} value - Value to test. + * + * @returns {boolean} + */ + _isImage: function isImage(value) { + return !!value && value.nodeName === 'IMG' || this._isCanvas(value); + }, + + /** + * Determine if a value is a Canvas. + * @memberof kontra + * @private + * + * @param {*} value - Value to test. + * + * @returns {boolean} + */ + _isCanvas: function isCanvas(value) { + return !!value && value.nodeName === 'CANVAS'; + } +}; + +(function(kontra, requestAnimationFrame, performance) { + + /** + * Game loop that updates and renders the game every frame. + * @memberof kontra + * + * @param {object} properties - Properties of the game loop. + * @param {number} [properties.fps=60] - Desired frame rate. + * @param {boolean} [properties.clearCanvas=true] - Clear the canvas every frame. + * @param {function} properties.update - Function called to update the game. + * @param {function} properties.render - Function called to render the game. + */ + kontra.gameLoop = function(properties) { + properties = properties || {}; + + // check for required functions + if ( !(kontra._isFunc(properties.update) && kontra._isFunc(properties.render)) ) { + throw Error('You must provide update() and render() functions'); + } + + // animation variables + var fps = properties.fps || 60; + var accumulator = 0; + var delta = 1E3 / fps; // delta between performance.now timings (in ms) + var step = 1 / fps; + + var clear = (properties.clearCanvas === false ? + kontra._noop : + function clear() { + kontra.context.clearRect(0,0,kontra.canvas.width,kontra.canvas.height); + }); + var last, rAF, now, dt; + + /** + * Called every frame of the game loop. + */ + function frame() { + rAF = requestAnimationFrame(frame); + + now = performance.now(); + dt = now - last; + last = now; + + // prevent updating the game with a very large dt if the game were to lose focus + // and then regain focus later + if (dt > 1E3) { + return; + } + + accumulator += dt; + + while (accumulator >= delta) { + gameLoop.update(step); + + accumulator -= delta; + } + + clear(); + gameLoop.render(); + } + + // game loop object + var gameLoop = { + update: properties.update, + render: properties.render, + isStopped: true, + + /** + * Start the game loop. + * @memberof kontra.gameLoop + */ + start: function start() { + last = performance.now(); + this.isStopped = false; + requestAnimationFrame(frame); + }, + + /** + * Stop the game loop. + */ + stop: function stop() { + this.isStopped = true; + cancelAnimationFrame(rAF); + }, + + // expose properties for testing + _frame: frame, + set _last(value) { + last = value; + } + }; + + return gameLoop; + }; +})(kontra, requestAnimationFrame, performance); + +(function() { + var callbacks = {}; + var pressedKeys = {}; + + var keyMap = { + // named keys + 8: 'backspace', + 9: 'tab', + 13: 'enter', + 16: 'shift', + 17: 'ctrl', + 18: 'alt', + 20: 'capslock', + 27: 'esc', + 32: 'space', + 33: 'pageup', + 34: 'pagedown', + 35: 'end', + 36: 'home', + 37: 'left', + 38: 'up', + 39: 'right', + 40: 'down', + 45: 'insert', + 46: 'delete', + 91: 'leftwindow', + 92: 'rightwindow', + 93: 'select', + 144: 'numlock', + 145: 'scrolllock', + + // special characters + 106: '*', + 107: '+', + 109: '-', + 110: '.', + 111: '/', + 186: ';', + 187: '=', + 188: ',', + 189: '-', + 190: '.', + 191: '/', + 192: '`', + 219: '[', + 220: '\\', + 221: ']', + 222: '\'' + }; + + // alpha keys + // @see https://stackoverflow.com/a/43095772/2124254 + for (var i = 0; i < 26; i++) { + keyMap[65+i] = (10 + i).toString(36); + } + // numeric keys and keypad + for (i = 0; i < 10; i++) { + keyMap[48+i] = ''+i; + keyMap[96+i] = 'numpad'+i; + } + // f keys + for (i = 1; i < 20; i++) { + keyMap[111+i] = 'f'+i; + } + + var addEventListener = window.addEventListener; + addEventListener('keydown', keydownEventHandler); + addEventListener('keyup', keyupEventHandler); + addEventListener('blur', blurEventHandler); + + /** + * Execute a function that corresponds to a keyboard key. + * @private + * + * @param {Event} e + */ + function keydownEventHandler(e) { + var key = keyMap[e.which]; + pressedKeys[key] = true; + + if (callbacks[key]) { + callbacks[key](e); + } + } + + /** + * Set the released key to not being pressed. + * @private + * + * @param {Event} e + */ + function keyupEventHandler(e) { + var key = keyMap[e.which]; + pressedKeys[key] = false; + } + + /** + * Reset pressed keys. + * @private + * + * @param {Event} e + */ + function blurEventHandler(e) { + pressedKeys = {}; + } + + /** + * Object for using the keyboard. + */ + kontra.keys = { + /** + * Register a function to be called on a key press. + * @memberof kontra.keys + * + * @param {string|string[]} keys - key or keys to bind. + */ + bind: function bindKey(keys, callback) { + keys = (Array.isArray(keys) ? keys : [keys]); + + for (var i = 0, key; key = keys[i]; i++) { + callbacks[key] = callback; + } + }, + + /** + * Remove the callback function for a key. + * @memberof kontra.keys + * + * @param {string|string[]} keys - key or keys to unbind. + */ + unbind: function unbindKey(keys) { + keys = (Array.isArray(keys) ? keys : [keys]); + + for (var i = 0, key; key = keys[i]; i++) { + callbacks[key] = null; + } + }, + + /** + * Returns whether a key is pressed. + * @memberof kontra.keys + * + * @param {string} key - Key to check for press. + * + * @returns {boolean} + */ + pressed: function keyPressed(key) { + return !!pressedKeys[key]; + } + }; +})(); + +(function(kontra, Math, Infinity) { + + /** + * A vector for 2D space. + * + * Because each sprite has 3 vectors and there could possibly be hundreds of + * sprites at once, we can't return a new object with new functions every time + * (which saves on having to use `this` everywhere). Instead, we'll use a + * prototype so vectors only take up an x and y value and share functions. + * @memberof kontra + * + * @param {number} [x=0] - X coordinate. + * @param {number} [y=0] - Y coordinate. + */ + kontra.vector = function(x, y) { + var vector = Object.create(kontra.vector.prototype); + vector._init(x, y); + + return vector; + }; + + kontra.vector.prototype = { + /** + * Initialize the vectors x and y position. + * @memberof kontra.vector + * @private + * + * @param {number} [x=0] - X coordinate. + * @param {number} [y=0] - Y coordinate. + * + * @returns {vector} + */ + _init: function init(x, y) { + this._x = x || 0; + this._y = y || 0; + }, + + /** + * Add a vector to this vector. + * @memberof kontra.vector + * + * @param {vector} vector - Vector to add. + * @param {number} dt=1 - Time since last update. + */ + add: function add(vector, dt) { + this._x += (vector.x || 0) * (dt || 1); + this._y += (vector.y || 0) * (dt || 1); + }, + + /** + * Clamp the vector between two points that form a rectangle. + * @memberof kontra.vector + * + * @param {number} [xMin=-Infinity] - Min x value. + * @param {number} [yMin=Infinity] - Min y value. + * @param {number} [xMax=-Infinity] - Max x value. + * @param {number} [yMax=Infinity] - Max y value. + */ + clamp: function clamp(xMin, yMin, xMax, yMax) { + this._clamp = true; + this._xMin = (xMin !== undefined ? xMin : -Infinity); + this._yMin = (yMin !== undefined ? yMin : -Infinity); + this._xMax = (xMax !== undefined ? xMax : Infinity); + this._yMax = (yMax !== undefined ? yMax : Infinity); + }, + + /** + * Vector x + * @memberof kontra.vector + * + * @property {number} x + */ + get x() { + return this._x; + }, + + /** + * Vector y + * @memberof kontra.vector + * + * @property {number} y + */ + get y() { + return this._y; + }, + + set x(value) { + this._x = (this._clamp ? Math.min( Math.max(this._xMin, value), this._xMax ) : value); + }, + + set y(value) { + this._y = (this._clamp ? Math.min( Math.max(this._yMin, value), this._yMax ) : value); + } + }; + + + + + + /** + * A sprite with a position, velocity, and acceleration. + * @memberof kontra + * @requires kontra.vector + * + * @param {object} properties - Properties of the sprite. + * @param {number} properties.x - X coordinate of the sprite. + * @param {number} properties.y - Y coordinate of the sprite. + * @param {number} [properties.dx] - Change in X position. + * @param {number} [properties.dy] - Change in Y position. + * @param {number} [properties.ddx] - Change in X velocity. + * @param {number} [properties.ddy] - Change in Y velocity. + * + * @param {number} [properties.ttl=0] - How may frames the sprite should be alive. + * @param {Context} [properties.context=kontra.context] - Provide a context for the sprite to draw on. + * + * @param {Image|Canvas} [properties.image] - Image for the sprite. + * + * @param {object} [properties.animations] - Animations for the sprite instead of an image. + * + * @param {string} [properties.color] - If no image or animation is provided, use color to draw a rectangle for the sprite. + * @param {number} [properties.width] - Width of the sprite for drawing a rectangle. + * @param {number} [properties.height] - Height of the sprite for drawing a rectangle. + * + * @param {function} [properties.update] - Function to use to update the sprite. + * @param {function} [properties.render] - Function to use to render the sprite. + */ + kontra.sprite = function(properties) { + var sprite = Object.create(kontra.sprite.prototype); + sprite.init(properties); + + return sprite; + }; + + kontra.sprite.prototype = { + /** + * Initialize properties on the sprite. + * @memberof kontra.sprite + * + * @param {object} properties - Properties of the sprite. + * @param {number} properties.x - X coordinate of the sprite. + * @param {number} properties.y - Y coordinate of the sprite. + * @param {number} [properties.dx] - Change in X position. + * @param {number} [properties.dy] - Change in Y position. + * @param {number} [properties.ddx] - Change in X velocity. + * @param {number} [properties.ddy] - Change in Y velocity. + * + * @param {number} [properties.ttl=0] - How may frames the sprite should be alive. + * @param {Context} [properties.context=kontra.context] - Provide a context for the sprite to draw on. + * + * @param {Image|Canvas} [properties.image] - Image for the sprite. + * + * @param {object} [properties.animations] - Animations for the sprite instead of an image. + * + * @param {string} [properties.color] - If no image or animation is provided, use color to draw a rectangle for the sprite. + * @param {number} [properties.width] - Width of the sprite for drawing a rectangle. + * @param {number} [properties.height] - Height of the sprite for drawing a rectangle. + * + * @param {function} [properties.update] - Function to use to update the sprite. + * @param {function} [properties.render] - Function to use to render the sprite. + * + * If you need the sprite to live forever, or just need it to stay on screen until you + * decide when to kill it, you can set ttl to Infinity. + * Just be sure to set ttl to 0 when you want the sprite to die. + */ + init: function init(properties) { + var temp, animation, firstAnimation, self = this; + properties = properties || {}; + + // create the vectors if they don't exist or use the existing ones if they do + self.position = (self.position || kontra.vector()); + self.velocity = (self.velocity || kontra.vector()); + self.acceleration = (self.acceleration || kontra.vector()); + + self.position._init(properties.x, properties.y); + self.velocity._init(properties.dx, properties.dy); + self.acceleration._init(properties.ddx, properties.ddy); + + // default width and height to 0 if not passed in + self.width = self.height = 0; + + // loop through properties before overrides + for (var prop in properties) { + self[prop] = properties[prop]; + } + + self.ttl = properties.ttl || 0; + self.context = properties.context || kontra.context; + + // default to rect sprite + self.advance = self._advance; + self.draw = self._draw; + + // image sprite + if (kontra._isImage(temp = properties.image)) { + self.image = temp; + self.width = temp.width; + self.height = temp.height; + + self.draw = self._drawImg; + } + // animation sprite + else if (temp = properties.animations) { + self.animations = {}; + + // clone each animation so no sprite shares an animation + for (var name in temp) { + animation = temp[name]; + self.animations[name] = (animation.clone ? animation.clone() : animation); + + // default the current animation to the first one in the list + if (!firstAnimation) { + firstAnimation = self.animations[name]; + } + } + + self.currentAnimation = firstAnimation; + self.width = firstAnimation.width; + self.height = firstAnimation.height; + + self.advance = self._advanceAnim; + self.draw = self._drawAnim; + } + }, + + // define getter and setter shortcut functions to make it easier to work with the + // position, velocity, and acceleration vectors. + + /** + * Sprite position.x + * @memberof kontra.sprite + * + * @property {number} x + */ + get x() { + return this.position.x; + }, + + /** + * Sprite position.y + * @memberof kontra.sprite + * + * @property {number} y + */ + get y() { + return this.position.y; + }, + + /** + * Sprite velocity.x + * @memberof kontra.sprite + * + * @property {number} dx + */ + get dx() { + return this.velocity.x; + }, + + /** + * Sprite velocity.y + * @memberof kontra.sprite + * + * @property {number} dy + */ + get dy() { + return this.velocity.y; + }, + + /** + * Sprite acceleration.x + * @memberof kontra.sprite + * + * @property {number} ddx + */ + get ddx() { + return this.acceleration.x; + }, + + /** + * Sprite acceleration.y + * @memberof kontra.sprite + * + * @property {number} ddy + */ + get ddy() { + return this.acceleration.y; + }, + + set x(value) { + this.position.x = value; + }, + set y(value) { + this.position.y = value; + }, + set dx(value) { + this.velocity.x = value; + }, + set dy(value) { + this.velocity.y = value; + }, + set ddx(value) { + this.acceleration.x = value; + }, + set ddy(value) { + this.acceleration.y = value; + }, + + /** + * Determine if the sprite is alive. + * @memberof kontra.sprite + * + * @returns {boolean} + */ + isAlive: function isAlive() { + return this.ttl > 0; + }, + + /** + * Simple bounding box collision test. + * @memberof kontra.sprite + * + * @param {object} object - Object to check collision against. + * + * @returns {boolean} True if the objects collide, false otherwise. + */ + collidesWith: function collidesWith(object) { + return this.x < object.x + object.width && + this.x + this.width > object.x && + this.y < object.y + object.height && + this.y + this.height > object.y; + }, + + /** + * Update the sprites velocity and position. + * @memberof kontra.sprite + * @abstract + * + * @param {number} dt - Time since last update. + * + * This function can be overridden on a per sprite basis if more functionality + * is needed in the update step. Just call this.advance() when you need + * the sprite to update its position. + * + * @example + * sprite = kontra.sprite({ + * update: function update(dt) { + * // do some logic + * + * this.advance(dt); + * } + * }); + */ + update: function update(dt) { + this.advance(dt); + }, + + /** + * Render the sprite. + * @memberof kontra.sprite. + * @abstract + * + * This function can be overridden on a per sprite basis if more functionality + * is needed in the render step. Just call this.draw() when you need the + * sprite to draw its image. + * + * @example + * sprite = kontra.sprite({ + * render: function render() { + * // do some logic + * + * this.draw(); + * } + * }); + */ + render: function render() { + this.draw(); + }, + + /** + * Play an animation. + * @memberof kontra.sprite + * + * @param {string} name - Name of the animation to play. + */ + playAnimation: function playAnimation(name) { + this.currentAnimation = this.animations[name]; + }, + + /** + * Move the sprite by its velocity. + * @memberof kontra.sprite + * @private + * + * @param {number} dt - Time since last update. + */ + _advance: function advanceSprite(dt) { + this.velocity.add(this.acceleration, dt); + this.position.add(this.velocity, dt); + + this.ttl--; + }, + + /** + * Update the currently playing animation. Used when animations are passed to the sprite. + * @memberof kontra.sprite + * @private + * + * @param {number} dt - Time since last update. + */ + _advanceAnim: function advanceAnimation(dt) { + this._advance(dt); + + this.currentAnimation.update(dt); + }, + + /** + * Draw a simple rectangle. Useful for prototyping. + * @memberof kontra.sprite + * @private + */ + _draw: function drawRect() { + this.context.fillStyle = this.color; + this.context.fillRect(this.x, this.y, this.width, this.height); + }, + + /** + * Draw the sprite. + * @memberof kontra.sprite + * @private + */ + _drawImg: function drawImage() { + this.context.drawImage(this.image, this.x, this.y); + }, + + /** + * Draw the currently playing animation. Used when animations are passed to the sprite. + * @memberof kontra.sprite + * @private + */ + _drawAnim: function drawAnimation() { + this.currentAnimation.render({ + context: this.context, + x: this.x, + y: this.y + }); + } + }; +})(kontra, Math, Infinity); + +(function(Promise) { + var imageRegex = /(jpeg|jpg|gif|png)$/; + var audioRegex = /(wav|mp3|ogg|aac)$/; + var noRegex = /^no$/; + + // audio playability + // @see https://github.com/Modernizr/Modernizr/blob/master/feature-detects/audio.js + var audio = new Audio(); + var canUse = { + wav: '', + mp3: audio.canPlayType('audio/mpeg;').replace(noRegex,''), + ogg: audio.canPlayType('audio/ogg; codecs="vorbis"').replace(noRegex,''), + aac: audio.canPlayType('audio/aac;').replace(noRegex,''), + }; + + /** + * Join a path with proper separators. + * @see https://stackoverflow.com/a/43888647/2124254 + */ + function joinPath() { + var path = [], i = 0; + + for (; i < arguments.length; i++) { + if (arguments[i]) { + + // replace slashes at the beginning or end of a path + // replace 2 or more slashes at the beginning of the first path to + // preserve root routes (/root) + path.push( arguments[i].trim().replace(new RegExp('(^[\/]{' + (path[0] ? 1 : 2) + ',}|[\/]*$)', 'g'), '') ); + } + } + + return path.join('/'); + } + + /** + * Get the extension of an asset. + * + * @param {string} url - The URL to the asset. + * + * @returns {string} + */ + function getExtension(url) { + return url.split('.').pop(); + } + + /** + * Get the name of an asset. + * + * @param {string} url - The URL to the asset. + * + * @returns {string} + */ + function getName(url) { + var name = url.replace('.' + getExtension(url), ''); + + // remove slash if there is no folder in the path + return (name.indexOf('/') == 0 && name.lastIndexOf('/') == 0 ? name.substr(1) : name); + } + + /** + * Load an Image file. Uses imagePath to resolve URL. + * @memberOf kontra.assets + * @private + * + * @param {string} url - The URL to the Image file. + * + * @returns {Promise} A deferred promise. Promise resolves with the Image. + * + * @example + * kontra.loadImage('car.png'); + * kontra.loadImage('autobots/truck.png'); + */ + function loadImage(url) { + var name = getName(url); + var image = new Image(); + + var self = kontra.assets; + var imageAssets = self.images; + + url = joinPath(self.imagePath, url); + + return new Promise(function(resolve, reject) { + image.onload = function loadImageOnLoad() { + imageAssets[name] = imageAssets[url] = this; + resolve(this); + }; + + image.onerror = function loadImageOnError() { + reject('Unable to load image ' + url); + }; + + image.src = url; + }); + } + + /** + * Load an Audio file. Supports loading multiple audio formats which will be resolved by + * the browser in the order listed. Uses audioPath to resolve URL. + * @memberOf kontra.assets + * @private + * + * @param {string|string[]} url - The URL to the Audio file. + * + * @returns {Promise} A deferred promise. Promise resolves with the Audio. + * + * @example + * kontra.loadAudio('sound_effects/laser.mp3'); + * kontra.loadAudio(['explosion.mp3', 'explosion.m4a', 'explosion.ogg']); + */ + function loadAudio(url) { + var self = kontra.assets; + var audioAssets = self.audio; + var audioPath = self.audioPath; + var source, name, playableSource, audio, i; + + if (!Array.isArray(url)) { + url = [url]; + } + + return new Promise(function(resolve, reject) { + // determine which audio format the browser can play + for (i = 0; (source = url[i]); i++) { + if ( canUse[getExtension(source)] ) { + playableSource = source; + break; + } + } + + if (!playableSource) { + reject('cannot play any of the audio formats provided'); + } + else { + name = getName(playableSource); + audio = new Audio(); + source = joinPath(audioPath, playableSource); + + audio.addEventListener('canplay', function loadAudioOnLoad() { + audioAssets[name] = audioAssets[source] = this; + resolve(this); + }); + + audio.onerror = function loadAudioOnError() { + reject('Unable to load audio ' + source); + }; + + audio.src = source; + audio.load(); + } + }); + } + + /** + * Load a data file (be it text or JSON). Uses dataPath to resolve URL. + * @memberOf kontra.assets + * @private + * + * @param {string} url - The URL to the data file. + * + * @returns {Promise} A deferred promise. Resolves with the data or parsed JSON. + * + * @example + * kontra.loadData('bio.json'); + * kontra.loadData('dialog.txt'); + */ + function loadData(url) { + var name = getName(url); + var req = new XMLHttpRequest(); + + var self = kontra.assets; + var dataAssets = self.data; + + url = joinPath(self.dataPath, url); + + return new Promise(function(resolve, reject) { + req.addEventListener('load', function loadDataOnLoad() { + var data = req.responseText; + + if (req.status !== 200) { + return reject(data); + } + + try { + data = JSON.parse(data); + } + catch(e) {} + + dataAssets[name] = dataAssets[url] = data; + resolve(data); + }); + + req.open('GET', url, true); + req.send(); + }); + } + + /** + * Object for loading assets. + */ + kontra.assets = { + // all assets are stored by name as well as by URL + images: {}, + audio: {}, + data: {}, + + // base asset path for determining asset URLs + imagePath: '', + audioPath: '', + dataPath: '', + + /** + * Load an Image, Audio, or data file. + * @memberOf kontra.assets + * + * @param {string|string[]} - Comma separated list of assets to load. + * + * @returns {Promise} + * + * @example + * kontra.loadAsset('car.png'); + * kontra.loadAsset(['explosion.mp3', 'explosion.ogg']); + * kontra.loadAsset('bio.json'); + * kontra.loadAsset('car.png', ['explosion.mp3', 'explosion.ogg'], 'bio.json'); + */ + load: function loadAsset() { + var promises = []; + var url, extension, asset, i, promise; + + for (i = 0; (asset = arguments[i]); i++) { + url = (Array.isArray(asset) ? asset[0] : asset); + + extension = getExtension(url); + if (extension.match(imageRegex)) { + promise = loadImage(asset); + } + else if (extension.match(audioRegex)) { + promise = loadAudio(asset); + } + else { + promise = loadData(asset); + } + + promises.push(promise); + } + + return Promise.all(promises); + }, + + // expose properties for testing + _canUse: canUse, + }; +})(Promise); + +(function(kontra) { + + /** + * Object pool. The pool will grow in size to accommodate as many objects as are needed. + * Unused items are at the front of the pool and in use items are at the of the pool. + * @memberof kontra + * + * @param {object} properties - Properties of the pool. + * @param {function} properties.create - Function that returns the object to use in the pool. + * @param {number} properties.maxSize - The maximum size that the pool will grow to. + */ + kontra.pool = function(properties) { + properties = properties || {}; + + var lastIndex = 0; + var inUse = 0; + var obj; + + // check for the correct structure of the objects added to pools so we know that the + // rest of the pool code will work without errors + if (!kontra._isFunc(properties.create) || + ( !( obj = properties.create() ) || + !( kontra._isFunc(obj.update) && kontra._isFunc(obj.init) && + kontra._isFunc(obj.isAlive) ) + )) { + throw Error('Must provide create() function which returns an object with init(), update(), and isAlive() functions'); + } + + return { + create: properties.create, + + // start the pool with an object + objects: [obj], + size: 1, + maxSize: properties.maxSize || Infinity, + + /** + * Get an object from the pool. + * @memberof kontra.pool + * + * @param {object} properties - Properties to pass to object.init(). + */ + get: function get(properties) { + properties = properties || {}; + + // the pool is out of objects if the first object is in use and it can't grow + if (this.objects[0].isAlive()) { + if (this.size === this.maxSize) { + return; + } + // double the size of the array by filling it with twice as many objects + else { + for (var x = 0; x < this.size && this.objects.length < this.maxSize; x++) { + this.objects.unshift(this.create()); + } + + this.size = this.objects.length; + lastIndex = this.size - 1; + } + } + + // save off first object in pool to reassign to last object after unshift + var obj = this.objects[0]; + obj.init(properties); + + // unshift the array + for (var i = 1; i < this.size; i++) { + this.objects[i-1] = this.objects[i]; + } + + this.objects[lastIndex] = obj; + inUse++; + }, + + /** + * Return all objects that are alive from the pool. + * @memberof kontra.pool + * + * @returns {object[]} + */ + getAliveObjects: function getAliveObjects() { + return this.objects.slice(this.objects.length - inUse); + }, + + /** + * Clear the object pool. + * @memberof kontra.pool + */ + clear: function clear() { + inUse = lastIndex = this.objects.length = 0; + this.size = 1; + this.objects.push(this.create()); + }, + + /** + * Update all alive pool objects. + * @memberof kontra.pool + * + * @param {number} dt - Time since last update. + */ + update: function update(dt) { + var i = lastIndex; + var obj; + + // If the user kills an object outside of the update cycle, the pool won't know of + // the change until the next update and inUse won't be decremented. If the user then + // gets an object when inUse is the same size as objects.length, inUse will increment + // and this statement will evaluate to -1. + // + // I don't like having to go through the pool to kill an object as it forces you to + // know which object came from which pool. Instead, we'll just prevent the index from + // going below 0 and accept the fact that inUse may be out of sync for a frame. + var index = Math.max(this.objects.length - inUse, 0); + + // only iterate over the objects that are alive + while (i >= index) { + obj = this.objects[i]; + + obj.update(dt); + + // if the object is dead, move it to the front of the pool + if (!obj.isAlive()) { + + // push an object from the middle of the pool to the front of the pool + // without returning a new array through Array#splice to avoid garbage + // collection of the old array + // @see http://jsperf.com/object-pools-array-vs-loop + for (var j = i; j > 0; j--) { + this.objects[j] = this.objects[j-1]; + } + + this.objects[0] = obj; + inUse--; + index++; + } + else { + i--; + } + } + }, + + /** + * render all alive pool objects. + * @memberof kontra.pool + */ + render: function render() { + var index = Math.max(this.objects.length - inUse, 0); + + for (var i = lastIndex; i >= index; i--) { + this.objects[i].render && this.objects[i].render(); + } + } + }; + }; +})(kontra); + +(function(kontra) { + /** + * A quadtree for 2D collision checking. The quadtree acts like an object pool in that it + * will create subnodes as objects are needed but it won't clean up the subnodes when it + * collapses to avoid garbage collection. + * @memberof kontra + * + * @param {object} properties - Properties of the quadtree. + * @param {number} [properties.maxDepth=3] - Maximum node depths the quadtree can have. + * @param {number} [properties.maxObjects=25] - Maximum number of objects a node can support before splitting. + * @param {object} [properties.bounds] - The 2D space this node occupies. + * @param {object} [properties.parent] - Private. The node that contains this node. + * @param {number} [properties.depth=0] - Private. Current node depth. + * + * The quadrant indices are numbered as follows (following a z-order curve): + * | + * 0 | 1 + * ----+---- + * 2 | 3 + * | + */ + kontra.quadtree = function(properties) { + var quadtree = Object.create(kontra.quadtree.prototype); + quadtree._init(properties); + + return quadtree; + }; + + kontra.quadtree.prototype = { + /** + * Initialize properties on the quadtree. + * @memberof kontra.quadtree + * @private + * + * @param {object} properties - Properties of the quadtree. + * @param {number} [properties.maxDepth=3] - Maximum node depths the quadtree can have. + * @param {number} [properties.maxObjects=25] - Maximum number of objects a node can support before splitting. + * @param {object} [properties.bounds] - The 2D space this node occupies. + * @param {object} [properties.parent] - Private. The node that contains this node. + * @param {number} [properties.depth=0] - Private. Current node depth. + */ + _init: function init(properties) { + properties = properties || {}; + + this.maxDepth = properties.maxDepth || 3; + this.maxObjects = properties.maxObjects || 25; + + // since we won't clean up any subnodes, we need to keep track of which nodes are + // currently the leaf node so we know which nodes to add objects to + this._branch = false; + this._depth = properties.depth || 0; + this._parent = properties.parent; + + this.bounds = properties.bounds || { + x: 0, + y: 0, + width: kontra.canvas.width, + height: kontra.canvas.height + }; + + this.objects = []; + this.subnodes = []; + }, + + /** + * Clear the quadtree + * @memberof kontra.quadtree + */ + clear: function clear() { + if (this._branch) { + for (var i = 0; i < 4; i++) { + this.subnodes[i].clear(); + } + } + + this._branch = false; + this.objects.length = 0; + }, + + /** + * Find the leaf node the object belongs to and get all objects that are part of + * that node. + * @memberof kontra.quadtree + * + * @param {object} object - Object to use for finding the leaf node. + * + * @returns {object[]} A list of objects in the same leaf node as the object. + */ + get: function get(object) { + var objects = []; + var indices, i; + + // traverse the tree until we get to a leaf node + while (this.subnodes.length && this._branch) { + indices = this._getIndex(object); + + for (i = 0; i < indices.length; i++) { + objects.push.apply(objects, this.subnodes[ indices[i] ].get(object)); + } + + return objects; + } + + return this.objects; + }, + + /** + * Add an object to the quadtree. Once the number of objects in the node exceeds + * the maximum number of objects allowed, it will split and move all objects to their + * corresponding subnodes. + * @memberof kontra.quadtree + * + * @param {...object|object[]} Objects to add to the quadtree + * + * @example + * kontra.quadtree().add({id:1}, {id:2}, {id:3}); + * kontra.quadtree().add([{id:1}, {id:2}], {id:3}); + */ + add: function add() { + var i, j, object, obj, indices, index; + + for (j = 0; j < arguments.length; j++) { + object = arguments[j]; + + // add a group of objects separately + if (Array.isArray(object)) { + this.add.apply(this, object); + + continue; + } + + // current node has subnodes, so we need to add this object into a subnode + if (this.subnodes.length && this._branch) { + this._add2Sub(object); + + continue; + } + + // this node is a leaf node so add the object to it + this.objects.push(object); + + // split the node if there are too many objects + if (this.objects.length > this.maxObjects && this._depth < this.maxDepth) { + this._split(); + + // move all objects to their corresponding subnodes + for (i = 0; (obj = this.objects[i]); i++) { + this._add2Sub(obj); + } + + this.objects.length = 0; + } + } + }, + + /** + * Add an object to a subnode. + * @memberof kontra.quadtree + * @private + * + * @param {object} object - Object to add into a subnode + */ + _add2Sub: function addToSubnode(object) { + var indices = this._getIndex(object); + var i; + + // add the object to all subnodes it intersects + for (i = 0; i < indices.length; i++) { + this.subnodes[ indices[i] ].add(object); + } + }, + + /** + * Determine which subnodes the object intersects with. + * @memberof kontra.quadtree + * @private + * + * @param {object} object - Object to check. + * + * @returns {number[]} List of all subnodes object intersects. + */ + _getIndex: function getIndex(object) { + var indices = []; + + var verticalMidpoint = this.bounds.x + this.bounds.width / 2; + var horizontalMidpoint = this.bounds.y + this.bounds.height / 2; + + // save off quadrant checks for reuse + var intersectsTopQuadrants = object.y < horizontalMidpoint && object.y + object.height >= this.bounds.y; + var intersectsBottomQuadrants = object.y + object.height >= horizontalMidpoint && object.y < this.bounds.y + this.bounds.height; + + // object intersects with the left quadrants + if (object.x < verticalMidpoint && object.x + object.width >= this.bounds.x) { + if (intersectsTopQuadrants) { // top left + indices.push(0); + } + + if (intersectsBottomQuadrants) { // bottom left + indices.push(2); + } + } + + // object intersects with the right quadrants + if (object.x + object.width >= verticalMidpoint && object.x < this.bounds.x + this.bounds.width) { // top right + if (intersectsTopQuadrants) { + indices.push(1); + } + + if (intersectsBottomQuadrants) { // bottom right + indices.push(3); + } + } + + return indices; + }, + + /** + * Split the node into four subnodes. + * @memberof kontra.quadtree + * @private + */ + _split: function split() { + this._branch = true; + + // only split if we haven't split before + if (this.subnodes.length) { + return; + } + + var subWidth = this.bounds.width / 2 | 0; + var subHeight = this.bounds.height / 2 | 0; + + for (var i = 0; i < 4; i++) { + this.subnodes[i] = kontra.quadtree({ + bounds: { + x: this.bounds.x + (i % 2 === 1 ? subWidth : 0), // nodes 1 and 3 + y: this.bounds.y + (i >= 2 ? subHeight : 0), // nodes 2 and 3 + width: subWidth, + height: subHeight + }, + depth: this._depth+1, + maxDepth: this.maxDepth, + maxObjects: this.maxObjects, + parent: this + }); + } + }, + + /** + * Draw the quadtree. Useful for visual debugging. + * @memberof kontra.quadtree + */ + // render: function() { + // // don't draw empty leaf nodes, always draw branch nodes and the first node + // if (this.objects.length || this._depth === 0 || + // (this._parent && this._parent._branch)) { + + // kontra.context.strokeStyle = 'red'; + // kontra.context.strokeRect(this.bounds.x, this.bounds.y, this.bounds.width, this.bounds.height); + + // if (this.subnodes.length) { + // for (var i = 0; i < 4; i++) { + // this.subnodes[i].render(); + // } + // } + // } + // } + }; +})(kontra); + +(function(kontra) { + /** + * Single animation from a sprite sheet. + * @memberof kontra + * + * @param {object} properties - Properties of the animation. + * @param {object} properties.spriteSheet - Sprite sheet for the animation. + * @param {number[]} properties.frames - List of frames of the animation. + * @param {number} properties.frameRate - Number of frames to display in one second. + */ + kontra.animation = function(properties) { + var animation = Object.create(kontra.animation.prototype); + animation._init(properties); + + return animation; + }; + + kontra.animation.prototype = { + /** + * Initialize properties on the animation. + * @memberof kontra.animation + * @private + * + * @param {object} properties - Properties of the animation. + * @param {object} properties.spriteSheet - Sprite sheet for the animation. + * @param {number[]} properties.frames - List of frames of the animation. + * @param {number} properties.frameRate - Number of frames to display in one second. + */ + _init: function init(properties) { + properties = properties || {}; + + this.spriteSheet = properties.spriteSheet; + this.frames = properties.frames; + this.frameRate = properties.frameRate; + + var frame = properties.spriteSheet.frame; + this.width = frame.width; + this.height = frame.height; + this.margin = frame.margin || 0; + + this._frame = 0; + this._accum = 0; + }, + + /** + * Clone an animation to be used more than once. + * @memberof kontra.animation + * + * @returns {object} + */ + clone: function clone() { + return kontra.animation(this); + }, + + /** + * Update the animation. Used when the animation is not paused or stopped. + * @memberof kontra.animation + * @private + * + * @param {number} [dt=1/60] - Time since last update. + */ + update: function advance(dt) { + dt = dt || 1 / 60; + + this._accum += dt; + + // update to the next frame if it's time + while (this._accum * this.frameRate >= 1) { + this._frame = ++this._frame % this.frames.length; + + this._accum -= 1 / this.frameRate; + } + }, + + /** + * Draw the current frame. Used when the animation is not stopped. + * @memberof kontra.animation + * @private + * + * @param {object} properties - How to draw the animation. + * @param {number} properties.x - X position to draw. + * @param {number} properties.y - Y position to draw. + * @param {Context} [properties.context=kontra.context] - Provide a context for the sprite to draw on. + */ + render: function draw(properties) { + properties = properties || {}; + + var context = properties.context || kontra.context; + + // get the row and col of the frame + var row = this.frames[this._frame] / this.spriteSheet.framesPerRow | 0; + var col = this.frames[this._frame] % this.spriteSheet.framesPerRow | 0; + + context.drawImage( + this.spriteSheet.image, + col * this.width + (col * 2 + 1) * this.margin, + row * this.height + (row * 2 + 1) * this.margin, + this.width, this.height, + properties.x, properties.y, + this.width, this.height + ); + } + }; + + + + + + + /** + * Create a sprite sheet from an image. + * @memberof kontra + * + * @param {object} properties - Properties of the sprite sheet. + * @param {Image|Canvas} properties.image - Image for the sprite sheet. + * @param {number} properties.frameWidth - Width (in px) of each frame. + * @param {number} properties.frameHeight - Height (in px) of each frame. + * @param {number} properties.frameMargin - Margin (in px) between each frame. + * @param {object} properties.animations - Animations to create from the sprite sheet. + */ + kontra.spriteSheet = function(properties) { + var spriteSheet = Object.create(kontra.spriteSheet.prototype); + spriteSheet._init(properties); + + return spriteSheet; + }; + + kontra.spriteSheet.prototype = { + /** + * Initialize properties on the spriteSheet. + * @memberof kontra + * @private + * + * @param {object} properties - Properties of the sprite sheet. + * @param {Image|Canvas} properties.image - Image for the sprite sheet. + * @param {number} properties.frameWidth - Width (in px) of each frame. + * @param {number} properties.frameHeight - Height (in px) of each frame. + * @param {number} properties.frameMargin - Margin (in px) between each frame. + * @param {object} properties.animations - Animations to create from the sprite sheet. + */ + _init: function init(properties) { + properties = properties || {}; + + if (!kontra._isImage(properties.image)) { + throw Erorr('You must provide an Image for the SpriteSheet'); + } + + this.animations = {}; + this.image = properties.image; + this.frame = { + width: properties.frameWidth, + height: properties.frameHeight, + margin: properties.frameMargin + }; + + this.framesPerRow = properties.image.width / properties.frameWidth | 0; + + this.createAnimations(properties.animations); + }, + + /** + * Create animations from the sprite sheet. + * @memberof kontra.spriteSheet + * + * @param {object} animations - List of named animations to create from the Image. + * @param {number|string|number[]|string[]} animations.animationName.frames - A single frame or list of frames for this animation. + * @param {number} animations.animationName.frameRate - Number of frames to display in one second. + * + * @example + * var sheet = kontra.spriteSheet({image: img, frameWidth: 16, frameHeight: 16}); + * sheet.createAnimations({ + * idle: { + * frames: 1 // single frame animation + * }, + * walk: { + * frames: '2..6', // ascending consecutive frame animation (frames 2-6, inclusive) + * frameRate: 4 + * }, + * moonWalk: { + * frames: '6..2', // descending consecutive frame animation + * frameRate: 4 + * }, + * jump: { + * frames: [7, 12, 2], // non-consecutive frame animation + * frameRate: 3 + * }, + * attack: { + * frames: ['8..10', 13, '10..8'], // you can also mix and match, in this case frames [8,9,10,13,10,9,8] + * frameRate: 2 + * } + * }); + */ + createAnimations: function createAnimations(animations) { + var animation, frames, frameRate, sequence; + + for (var name in animations) { + animation = animations[name]; + frames = animation.frames; + frameRate = animation.frameRate; + + // array that holds the order of the animation + sequence = []; + + if (frames === undefined) { + throw Error('Animation ' + name + ' must provide a frames property'); + } + + if (!Array.isArray(frames)) { + frames = [frames]; + } + + for (var i = 0, frame; frame = frames[i]; i++) { + // add new frames to the end of the array + sequence.push.apply(sequence, this._parse(frame)); + } + + this.animations[name] = kontra.animation({ + spriteSheet: this, + frames: sequence, + frameRate: frameRate + }); + } + }, + + /** + * Parse a string of consecutive frames. + * @memberof kontra.spriteSheet + * @private + * + * @param {string} frames - Start and end frame. + * + * @returns {number[]} List of frames. + */ + _parse: function parse(consecutiveFrames) { + if (kontra._isNumber(consecutiveFrames)) { + return [consecutiveFrames]; + } + + var sequence = []; + var frames = consecutiveFrames.split('..'); + + // coerce string to number + // @see https://github.com/jed/140bytes/wiki/Byte-saving-techniques#coercion-to-test-for-types + var start = i = +frames[0]; + var end = +frames[1]; + + // ascending frame order + if (start < end) { + for (; i <= end; i++) { + sequence.push(i); + } + } + // descending order + else { + for (; i >= end; i--) { + sequence.push(i); + } + } + + return sequence; + } + }; +})(kontra); + +(function(kontra, Math, Array) { + // save Math.min and Math.max to variable and use that instead + + /** + * A tile engine for rendering tilesets. Works well with the tile engine program Tiled. + * @memberof kontra + * + * @param {object} properties - Properties of the tile engine. + * @param {number} [properties.tileWidth=32] - Width of a tile. + * @param {number} [properties.tileHeight=32] - Height of a tile. + * @param {number} properties.width - Width of the map (in tiles). + * @param {number} properties.height - Height of the map (in tiles). + * @param {number} [properties.x=0] - X position to draw. + * @param {number} [properties.y=0] - Y position to draw. + * @param {number} [properties.sx=0] - X position to clip the tileset. + * @param {number} [properties.sy=0] - Y position to clip the tileset. + * @param {Context} [properties.context=kontra.context] - Provide a context for the tile engine to draw on. + */ + kontra.tileEngine = function(properties) { + properties = properties || {}; + + // size of the map (in tiles) + if (!properties.width || !properties.height) { + throw Error('You must provide width and height properties'); + } + + /** + * Get the index of the x, y or row, col. + * @memberof kontra.tileEngine + * @private + * + * @param {number} position.x - X coordinate of the tile. + * @param {number} position.y - Y coordinate of the tile. + * @param {number} position.row - Row of the tile. + * @param {number} position.col - Col of the tile. + * + * @return {number} Returns the tile index or -1 if the x, y or row, col is outside the dimensions of the tile engine. + */ + function getIndex(position) { + var row, col; + + if (typeof position.x !== 'undefined' && typeof position.y !== 'undefined') { + row = tileEngine.getRow(position.y); + col = tileEngine.getCol(position.x); + } + else { + row = position.row; + col = position.col; + } + + // don't calculate out of bound numbers + if (row < 0 || col < 0 || row >= height || col >= width) { + return -1; + } + + return col + row * width; + } + + /** + * Modified binary search that will return the tileset associated with the tile + * @memberof kontra.tileEngine + * @private + * + * @param {number} tile - Tile grid. + * + * @return {object} + */ + function getTileset(tile) { + var min = 0; + var max = tileEngine.tilesets.length - 1; + var index, currTile; + + while (min <= max) { + index = (min + max) / 2 | 0; + currTile = tileEngine.tilesets[index]; + + if (tile >= currTile.firstGrid && tile <= currTile.lastGrid) { + return currTile; + } + else if (currTile.lastGrid < tile) { + min = index + 1; + } + else { + max = index - 1; + } + } + } + + /** + * Pre-render the tiles to make drawing fast. + * @memberof kontra.tileEngine + * @private + */ + function preRenderImage() { + var tile, tileset, image, x, y, sx, sy, tileOffset, w; + + // draw each layer in order + for (var i = 0, layer; layer = tileEngine.layers[layerOrder[i]]; i++) { + for (var j = 0, len = layer.data.length; j < len; j++) { + tile = layer.data[j]; + + // skip empty tiles (0) + if (!tile) { + continue; + } + + tileset = getTileset(tile); + image = tileset.image; + + x = (j % width) * tileWidth; + y = (j / width | 0) * tileHeight; + + tileOffset = tile - tileset.firstGrid; + w = image.width / tileWidth; + + sx = (tileOffset % w) * tileWidth; + sy = (tileOffset / w | 0) * tileHeight; + + offscreenContext.drawImage( + image, + sx, sy, tileWidth, tileHeight, + x, y, tileWidth, tileHeight + ); + } + } + } + + var width = properties.width; + var height = properties.height; + + // size of the tiles. Most common tile size on opengameart.org seems to be 32x32, + // followed by 16x16 + // Tiled names the property tilewidth and tileheight + var tileWidth = properties.tileWidth || properties.tilewidth || 32; + var tileHeight = properties.tileHeight || properties.tileheight || 32; + + var mapWidth = width * tileWidth; + var mapHeight = height * tileHeight; + + var context = properties.context || kontra.context; + var canvasWidth = context.canvas.width; + var canvasHeight = context.canvas.height; + + // create an off-screen canvas for pre-rendering the map + // @see http://jsperf.com/render-vs-prerender + var offscreenCanvas = document.createElement('canvas'); + var offscreenContext = offscreenCanvas.getContext('2d'); + + // when clipping an image, sx and sy must within the image region, otherwise + // Firefox and Safari won't draw it. + // @see http://stackoverflow.com/questions/19338032/canvas-indexsizeerror-index-or-size-is-negative-or-greater-than-the-allowed-a + var sxMax = Math.max(0, mapWidth - canvasWidth); + var syMax = Math.max(0, mapHeight - canvasHeight); + + var _sx, _sy; + + // draw order of layers (by name) + var layerOrder = []; + + var tileEngine = { + width: width, + height: height, + + tileWidth: tileWidth, + tileHeight: tileHeight, + + mapWidth: mapWidth, + mapHeight: mapHeight, + + context: context, + + x: properties.x || 0, + y: properties.y || 0, + + tilesets: [], + layers: {}, + + /** + * Add an tileset for the tile engine to use. + * @memberof kontra.tileEngine + * + * @param {object|object[]} tileset - Properties of the image to add. + * @param {Image|Canvas} tileset.image - Path to the image or Image object. + * @param {number} tileset.firstGrid - The first tile grid to start the image. + */ + addTilesets: function addTilesets(tilesets) { + if (!Array.isArray(tilesets)) { + tilesets = [tilesets] + } + + tilesets.forEach(function(tileset) { + var tilesetImage = tileset.image; + var image, firstGrid, numTiles, lastTileset, tiles; + + if (kontra._isImage(tilesetImage)) { + image = tilesetImage; + } + // see if the image path is in kontra.assets.images + else if (kontra._isString(tilesetImage)) { + var i = Infinity; + + while (i >= 0) { + i = tilesetImage.lastIndexOf('/', i); + var path = (i < 0 ? tilesetImage : tilesetImage.substr(i)); + + if (kontra.assets.images[path]) { + image = kontra.assets.images[path]; + break; + } + + i--; + } + } + + if (!image) { + throw Error('You must provide an Image for the tileset'); + } + + firstGrid = tileset.firstGrid; + + // if the width or height of the provided image is smaller than the tile size, + // default calculation to 1 + numTiles = ( (image.width / tileWidth | 0) || 1 ) * + ( (image.height / tileHeight | 0) || 1 ); + + if (!firstGrid) { + // only calculate the first grid if the tile map has a tileset already + if (tileEngine.tilesets.length > 0) { + lastTileset = tileEngine.tilesets[tileEngine.tilesets.length - 1]; + tiles = (lastTileset.image.width / tileWidth | 0) * + (lastTileset.image.height / tileHeight | 0); + + firstGrid = lastTileset.firstGrid + tiles; + } + // otherwise this is the first tile added to the tile map + else { + firstGrid = 1; + } + } + + tileEngine.tilesets.push({ + firstGrid: firstGrid, + lastGrid: firstGrid + numTiles - 1, + image: image + }); + + // sort the tile map so we can perform a binary search when drawing + tileEngine.tilesets.sort(function(a, b) { + return a.firstGrid - b.firstGrid; + }); + }); + }, + + /** + * Add a layer to the tile engine. + * @memberof kontra.tileEngine + * + * @param {object} properties - Properties of the layer to add. + * @param {string} properties.name - Name of the layer. + * @param {number[]} properties.data - Tile layer data. + * @param {boolean} [properties.render=true] - If the layer should be drawn. + * @param {number} [properties.zIndex] - Draw order for tile layer. Highest number is drawn last (i.e. on top of all other layers). + */ + addLayers: function addLayers(layers) { + if (!Array.isArray(layers)) { + layers = [layers] + } + + layers.forEach(function(layer) { + layer.render = (layer.render === undefined ? true : layer.render); + + var data, r, row, c, prop, value; + + // flatten a 2D array into a single array + if (Array.isArray(layer.data[0])) { + data = []; + + for (r = 0; row = layer.data[r]; r++) { + for (c = 0; c < width; c++) { + data.push(row[c] || 0); + } + } + } + else { + data = layer.data; + } + + tileEngine.layers[layer.name] = { + data: data, + zIndex: layer.zIndex || 0, + render: layer.render + }; + + // merge properties of layer onto layer object + for (prop in layer.properties) { + value = layer.properties[prop]; + + try { + value = JSON.parse(value); + } + catch(e) {} + + tileEngine.layers[layer.name][prop] = value; + } + + // only add the layer to the layer order if it should be drawn + if (tileEngine.layers[layer.name].render) { + layerOrder.push(layer.name); + + layerOrder.sort(function(a, b) { + return tileEngine.layers[a].zIndex - tileEngine.layers[b].zIndex; + }); + + } + }); + + preRenderImage(); + }, + + /** + * Simple bounding box collision test for layer tiles. + * @memberof kontra.tileEngine + * + * @param {string} name - Name of the layer. + * @param {object} object - Object to check collision against. + * @param {number} object.x - X coordinate of the object. + * @param {number} object.y - Y coordinate of the object. + * @param {number} object.width - Width of the object. + * @param {number} object.height - Height of the object. + * + * @returns {boolean} True if the object collides with a tile, false otherwise. + */ + layerCollidesWith: function layerCollidesWith(name, object) { + // calculate all tiles that the object can collide with + var row = tileEngine.getRow(object.y); + var col = tileEngine.getCol(object.x); + + var endRow = tileEngine.getRow(object.y + object.height); + var endCol = tileEngine.getCol(object.x + object.width); + + // check all tiles + var index; + for (var r = row; r <= endRow; r++) { + for (var c = col; c <= endCol; c++) { + index = getIndex({row: r, col: c}); + + if (tileEngine.layers[name].data[index]) { + return true; + } + } + } + + return false; + }, + + /** + * Get the tile from the specified layer at x, y or row, col. + * @memberof kontra.tileEngine + * + * @param {string} name - Name of the layer. + * @param {object} position - Position of the tile in either x, y or row, col. + * @param {number} position.x - X coordinate of the tile. + * @param {number} position.y - Y coordinate of the tile. + * @param {number} position.row - Row of the tile. + * @param {number} position.col - Col of the tile. + * + * @returns {number} + */ + tileAtLayer: function tileAtLayer(name, position) { + var index = getIndex(position); + + if (index >= 0) { + return tileEngine.layers[name].data[index]; + } + }, + + /** + * Render the pre-rendered canvas. + * @memberof kontra.tileEngine + */ + render: function render() { + /* istanbul ignore next */ + tileEngine.context.drawImage( + offscreenCanvas, + tileEngine.sx, tileEngine.sy, canvasWidth, canvasHeight, + tileEngine.x, tileEngine.y, canvasWidth, canvasHeight + ); + }, + + /** + * Render a specific layer. + * @memberof kontra.tileEngine + * + * @param {string} name - Name of the layer to render. + */ + renderLayer: function renderLayer(name) { + var layer = tileEngine.layers[name]; + + // calculate the starting tile + var row = tileEngine.getRow(); + var col = tileEngine.getCol(); + var index = getIndex({row: row, col: col}); + + // calculate where to start drawing the tile relative to the drawing canvas + var startX = col * tileWidth - tileEngine.sx; + var startY = row * tileHeight - tileEngine.sy; + + // calculate how many tiles the drawing canvas can hold + var viewWidth = Math.min(Math.ceil(canvasWidth / tileWidth) + 1, width); + var viewHeight = Math.min(Math.ceil(canvasHeight / tileHeight) + 1, height); + var numTiles = viewWidth * viewHeight; + + var count = 0; + var x, y, tile, tileset, image, tileOffset, w, sx, sy; + + // draw just enough of the layer to fit inside the drawing canvas + while (count < numTiles) { + tile = layer.data[index]; + + if (tile) { + tileset = getTileset(tile); + image = tileset.image; + + x = startX + (count % viewWidth) * tileWidth; + y = startY + (count / viewWidth | 0) * tileHeight; + + tileOffset = tile - tileset.firstGrid; + w = image.width / tileWidth; + + sx = (tileOffset % w) * tileWidth; + sy = (tileOffset / w | 0) * tileHeight; + + tileEngine.context.drawImage( + image, + sx, sy, tileWidth, tileHeight, + x, y, tileWidth, tileHeight + ); + } + + if (++count % viewWidth === 0) { + index = col + (++row * width); + } + else { + index++; + } + } + }, + + /** + * Get the row from the y coordinate. + * @memberof kontra.tileEngine + * + * @param {number} y - Y coordinate. + * + * @return {number} + */ + getRow: function getRow(y) { + y = y || 0; + + return (tileEngine.sy + y) / tileHeight | 0; + }, + + /** + * Get the col from the x coordinate. + * @memberof kontra.tileEngine + * + * @param {number} x - X coordinate. + * + * @return {number} + */ + getCol: function getCol(x) { + x = x || 0; + + return (tileEngine.sx + x) / tileWidth | 0; + }, + + get sx() { + return _sx; + }, + + get sy() { + return _sy; + }, + + // ensure sx and sy are within the image region + set sx(value) { + _sx = Math.min( Math.max(0, value), sxMax ); + }, + + set sy(value) { + _sy = Math.min( Math.max(0, value), syMax ); + }, + + // expose properties for testing + _layerOrder: layerOrder + }; + + // set here so we use setter function + tileEngine.sx = properties.sx || 0; + tileEngine.sy = properties.sy || 0; + + // make the off-screen canvas the full size of the map + offscreenCanvas.width = mapWidth; + offscreenCanvas.height = mapHeight; + + // merge properties of the tile engine onto the tile engine itself + for (var prop in properties.properties) { + var value = properties.properties[prop]; + + try { + value = JSON.parse(value); + } + catch(e) {} + + // passed in properties override properties.properties + tileEngine[prop] = tileEngine[prop] || value; + } + + if (properties.tilesets) { + tileEngine.addTilesets(properties.tilesets); + } + + if (properties.layers) { + tileEngine.addLayers(properties.layers); + } + + return tileEngine; + }; +})(kontra, Math, Array); + +/** + * Object for using localStorage. + */ +kontra.store = { + + /** + * Save an item to localStorage. + * @memberof kontra.store + * + * @param {string} key - Name to store the item as. + * @param {*} value - Item to store. + */ + set: function setStoreItem(key, value) { + if (value === undefined) { + localStorage.removeItem(key); + } + else { + localStorage.setItem(key, JSON.stringify(value)); + } + }, + + /** + * Retrieve an item from localStorage and convert it back to it's original type. + * @memberof kontra.store + * + * @param {string} key - Name of the item. + * + * @returns {*} + */ + get: function getStoreItem(key) { + var value = localStorage.getItem(key); + + try { + value = JSON.parse(value); + } + catch(e) {} + + return value; + } +}; \ No newline at end of file