UNPKG

kontra

Version:

Kontra HTML5 game development library

1,828 lines (1,637 loc) 103 kB
var kontra = (function () { 'use strict'; /** * A simple event system, mostly created to support [Plugins](api/plugin). Allows you to hook into Kontra lifecycle events or create your own. * * ```js * import { on, off, emit } from 'kontra'; * * function callback(a, b, c) { * console.log({a, b, c}); * }); * * on('myEvent', callback); * emit('myEvent', 1, 2, 3); //=> {a: 1, b: 2, c: 3} * off('myEvent', callback); * ``` * @sectionName Events */ // expose for testing let callbacks = {}; /** * There are currently only two lifecycle events: * - `init` - Emitted after `init()` is called. * - `tick` - Emitted every frame of kontra.GameLoop before the loops `update()` and `render()` functions are called. * @sectionName Lifecycle Events */ /** * Register a callback for an event to be called whenever the event is emitted. The callback will be passed all arguments used in the `emit` call. * @function on * * @param {String} event - Name of the event. * @param {Function} callback - Function that will be called when the event is emitted. */ function on(event, callback) { callbacks[event] = callbacks[event] || []; callbacks[event].push(callback); } /** * Remove a callback for an event. * @function off * * @param {String} event - Name of the event. * @param {Function} callback - The function that was passed during registration. */ function off(event, callback) { let index; if (!callbacks[event] || (index = callbacks[event].indexOf(callback)) < 0) return; callbacks[event].splice(index, 1); } /** * Call all callback functions for the event. All arguments will be passed to the callback functions. * @function emit * * @param {String} event - Name of the event. * @param {*} [args] - Arguments passed to all callbacks. */ function emit(event, ...args) { if (!callbacks[event]) return; callbacks[event].map(fn => fn(...args)); } /** * Functions for initializing the Kontra library and getting the canvas and context * objects. * * ```js * import { getCanvas, getContext, init } from 'kontra'; * * let { canvas, context } = init(); * * // or can get canvas and context through functions * canvas = getCanvas(); * context = getContext(); * ``` * @sectionName Core */ let canvasEl; let context; /** * Return the canvas element. * @function getCanvas * * @returns {HTMLCanvasElement} The canvas element for the game. */ function getCanvas() { return canvasEl; } /** * Return the context object. * @function getContext * * @returns {CanvasRenderingContext2D} The context object the game draws to. */ function getContext() { return context; } /** * Initialize the library and set up the canvas. Typically you will call `init()` as the first thing and give it the canvas to use. This will allow all Kontra objects to reference the canvas when created. * * ```js * import { init } from 'kontra'; * * let { canvas, context } = init('game'); * ``` * @function init * * @param {String|HTMLCanvasElement} [canvas] - The canvas for Kontra to use. Can either be the ID of the canvas element or the canvas element itself. Defaults to using the first canvas element on the page. * * @returns {Object} An object with properties `canvas` and `context`. `canvas` it the canvas element for the game and `context` is the context object the game draws to. */ function init(canvas) { // check if canvas is a string first, an element next, or default to getting // first canvas on page canvasEl = document.getElementById(canvas) || canvas || document.querySelector('canvas'); // @if DEBUG if (!canvasEl) { throw Error('You must provide a canvas element for the game'); } // @endif context = canvasEl.getContext('2d'); context.imageSmoothingEnabled = false; emit('init'); return { canvas: canvasEl, context }; } /** * An object for drawing sprite sheet animations. * * An animation defines the sequence of frames to use from a sprite sheet. It also defines at what speed the animation should run using `frameRate`. * * Typically you don't create an kontra.Animation directly, but rather you would create them from kontra.SpriteSheet by passing the `animations` argument. * * ```js * import { SpriteSheet, Animation } from 'kontra'; * * let image = new Image(); * image.src = 'assets/imgs/character_walk_sheet.png'; * image.onload = function() { * let spriteSheet = SpriteSheet({ * image: image, * frameWidth: 72, * frameHeight: 97 * }); * * // you typically wouldn't create an Animation this way * let animation = Animation({ * spriteSheet: spriteSheet, * frames: [1,2,3,6], * frameRate: 30 * }); * }; * ``` * @class Animation * * @param {Object} properties - Properties of the animation. * @param {kontra.SpriteSheet} 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. * @param {Boolean} [properties.loop=true] - If the animation should loop. */ class Animation { constructor({spriteSheet, frames, frameRate, loop = true} = {}) { /** * The sprite sheet to use for the animation. * @memberof Animation * @property {kontra.SpriteSheet} spriteSheet */ this.spriteSheet = spriteSheet; /** * Sequence of frames to use from the sprite sheet. * @memberof Animation * @property {Number[]} frames */ this.frames = frames; /** * Number of frames to display per second. Adjusting this value will change the speed of the animation. * @memberof Animation * @property {Number} frameRate */ this.frameRate = frameRate; /** * If the animation should loop back to the beginning once completed. * @memberof Animation * @property {Boolean} loop */ this.loop = loop; let { width, height, margin = 0 } = spriteSheet.frame; /** * The width of an individual frame. Taken from the property of the same name in the [spriteSheet](#spriteSheet). * @memberof Animation * @property {Number} width */ this.width = width; /** * The height of an individual frame. Taken from the property of the same name in the [spriteSheet](#spriteSheet). * @memberof Animation * @property {Number} height */ this.height = height; /** * The space between each frame. Taken from the property of the same name in the [spriteSheet](#spriteSheet). * @memberof Animation * @property {Number} margin */ this.margin = margin; // f = frame, a = accumulator this._f = 0; this._a = 0; } /** * Clone an animation so it can be used more than once. By default animations passed to kontra.Sprite will be cloned so no two sprites update the same animation. Otherwise two sprites who shared the same animation would make it update twice as fast. * @memberof Animation * @function clone * * @returns {kontra.Animation} A new kontra.Animation instance. */ clone() { return animationFactory(this); } /** * Reset an animation to the first frame. * @memberof Animation * @function reset */ reset() { this._f = 0; this._a = 0; } /** * Update the animation. * @memberof Animation * @function update * * @param {Number} [dt=1/60] - Time since last update. */ update(dt = 1/60) { // if the animation doesn't loop we stop at the last frame if (!this.loop && this._f == this.frames.length-1) return; this._a += dt; // update to the next frame if it's time while (this._a * this.frameRate >= 1) { this._f = ++this._f % this.frames.length; this._a -= 1 / this.frameRate; } } /** * Draw the current frame of the animation. * @memberof Animation * @function render * * @param {Object} properties - Properties to draw the animation. * @param {Number} properties.x - X position to draw the animation. * @param {Number} properties.y - Y position to draw the animation. * @param {Number} [properties.width] - width of the sprite. Defaults to [Animation.width](#width). * @param {Number} [properties.height] - height of the sprite. Defaults to [Animation.height](#height). * @param {Canvas​Rendering​Context2D} [properties.context] - The context the animation should draw to. Defaults to [core.getContext()](api/core#getContext). */ render({x, y, width = this.width, height = this.height, context = getContext()} = {}) { // get the row and col of the frame let row = this.frames[this._f] / this.spriteSheet._f | 0; let col = this.frames[this._f] % this.spriteSheet._f | 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, x, y, width, height ); } } function animationFactory(properties) { return new Animation(properties); } animationFactory.prototype = Animation.prototype; animationFactory.class = Animation; /** * A promise based asset loader for loading images, audio, and data files. * * ```js * import { load } from 'kontra'; * * load( * 'assets/imgs/character.png', * 'assets/data/tile_engine_basic.json', * ['/audio/music.ogg', '/audio/music.mp3'] * ).then(function(assets) { * // all assets have loaded * }).catch(function(err) { * // error loading an asset * }); * ``` * @sectionName Assets */ let imageRegex = /(jpeg|jpg|gif|png)$/; let audioRegex = /(wav|mp3|ogg|aac)$/; let leadingSlash = /^\//; let trailingSlash = /\/$/; let dataMap = new WeakMap(); let imagePath = ''; let audioPath = ''; let dataPath = ''; /** * Get the full URL from the base. * * @param {String} url - The URL to the asset. * @param {String} base - Base URL. * * @returns {String} */ function getUrl(url, base) { return new URL(url, base).href; } /** * Join a base path and asset path. * * @param {String} base - The asset base path. * @param {String} url - The URL to the asset. * * @returns {String} */ function joinPath(base, url) { return [base.replace(trailingSlash, ''), base ? url.replace(leadingSlash, '') : url] .filter(s => s) .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) { let name = url.replace('.' + getExtension(url), ''); // remove leading slash if there is no folder in the path // @see https://stackoverflow.com/a/50592629/2124254 return name.split('/').length == 2 ? name.replace(leadingSlash, '') : name; } /** * Get browser audio playability. * @see https://github.com/Modernizr/Modernizr/blob/master/feature-detects/audio.js * * @param {HTMLMediaElement} audio - Audio element. * * @returns {object} */ function getCanPlay(audio) { return { wav: '', mp3: audio.canPlayType('audio/mpeg;'), ogg: audio.canPlayType('audio/ogg; codecs="vorbis"'), aac: audio.canPlayType('audio/aac;') }; } /** * Object of all loaded image assets by both file name and path. If the base [image path](#setImagePath) was set before the image was loaded, the file name and path will not include the base image path. * * ```js * import { load, setImagePath, imageAssets } from 'kontra'; * * load('assets/imgs/character.png').then(function() { * // Image asset can be accessed by both * // name: imageAssets['assets/imgs/character'] * // path: imageAssets['assets/imgs/character.png'] * }); * * setImagePath('assets/imgs'); * load('character_walk_sheet.png').then(function() { * // Image asset can be accessed by both * // name: imageAssets['character_walk_sheet'] * // path: imageAssets['character_walk_sheet.png'] * }); * ``` * @property {Object} imageAssets */ let imageAssets = {}; /** * Object of all loaded audio assets by both file name and path. If the base [audio path](#setAudioPath) was set before the audio was loaded, the file name and path will not include the base audio path. * * ```js * import { load, setAudioPath, audioAssets } from 'kontra'; * * load('/audio/music.ogg').then(function() { * // Audio asset can be accessed by both * // name: audioAssets['/audio/music'] * // path: audioAssets['/audio/music.ogg'] * }); * * setAudioPath('/audio'); * load('sound.ogg').then(function() { * // Audio asset can be accessed by both * // name: audioAssets['sound'] * // path: audioAssets['sound.ogg'] * }); * ``` * @property {Object} audioAssets */ let audioAssets = {}; /** * Object of all loaded data assets by both file name and path. If the base [data path](#setDataPath) was set before the data was loaded, the file name and path will not include the base data path. * * ```js * import { load, setDataPath, dataAssets } from 'kontra'; * * load('assets/data/file.txt').then(function() { * // Audio asset can be accessed by both * // name: dataAssets['assets/data/file'] * // path: dataAssets['assets/data/file.txt'] * }); * * setDataPath('assets/data'); * load('info.json').then(function() { * // Audio asset can be accessed by both * // name: dataAssets['info'] * // path: dataAssets['info.json'] * }); * ``` * @property {Object} dataAssets */ let dataAssets = {}; /** * Add a global kontra object so TileEngine can access information about the * loaded assets when kontra is loaded in parts rather than as a whole (e.g. * `import { load, TileEngine } from 'kontra';`) */ function addGlobal() { if (!window.__k) { window.__k = { dm: dataMap, u: getUrl, d: dataAssets, i: imageAssets }; } } /** * Sets the base path for all image assets. If a base path is set, all load calls for image assets will prepend the base path to the URL. * * ```js * import { setImagePath, load } from 'kontra'; * * setImagePath('/imgs'); * load('character.png'); // loads '/imgs/character.png' * ``` * @function setImagePath * * @param {String} path - Base image path. */ function setImagePath(path) { imagePath = path; } /** * Sets the base path for all audio assets. If a base path is set, all load calls for audio assets will prepend the base path to the URL. * * ```js * import { setAudioPath, load } from 'kontra'; * * setAudioPath('/audio'); * load('music.ogg'); // loads '/audio/music.ogg' * ``` * @function setAudioPath * * @param {String} path - Base audio path. */ function setAudioPath(path) { audioPath = path; } /** * Sets the base path for all data assets. If a base path is set, all load calls for data assets will prepend the base path to the URL. * * ```js * import { setDataPath, load } from 'kontra'; * * setDataPath('/data'); * load('file.json'); // loads '/data/file.json' * ``` * @function setDataPath * * @param {String} path - Base data path. */ function setDataPath(path) { dataPath = path; } /** * Load a single Image asset. Uses the base [image path](#setImagePath) to resolve the URL. * * Once loaded, the asset will be accessible on the the [imageAssets](#imageAssets) property. * * ```js * import { loadImage } from 'kontra'; * * loadImage('car.png').then(function(image) { * console.log(image.src); //=> 'car.png' * }) * ``` * @function loadImage * * @param {String} url - The URL to the Image file. * * @returns {Promise} A deferred promise. Promise resolves with the Image. */ function loadImage(url) { addGlobal(); return new Promise((resolve, reject) => { let resolvedUrl, image, fullUrl; resolvedUrl = joinPath(imagePath, url); if (imageAssets[resolvedUrl]) return resolve(imageAssets[resolvedUrl]); image = new Image(); image.onload = function loadImageOnLoad() { fullUrl = getUrl(resolvedUrl, window.location.href); imageAssets[ getName(url) ] = imageAssets[resolvedUrl] = imageAssets[fullUrl] = this; resolve(this); }; image.onerror = function loadImageOnError() { reject(/* @if DEBUG */ 'Unable to load image ' + /* @endif */ resolvedUrl); }; image.src = resolvedUrl; }); } /** * Load a single Audio asset. Supports loading multiple audio formats which the loader will use to load the first audio format supported by the browser in the order listed. Uses the base [audio path](#setAudioPath) to resolve the URL. * * Once loaded, the asset will be accessible on the the [audioAssets](#audioAssets) property. Since the loader determines which audio asset to load based on browser support, you should only reference the audio by its name and not by its file path since there's no guarantee which asset was loaded. * * ```js * import { loadAudio, audioAssets } from 'kontra'; * * loadAudio([ * '/audio/music.mp3', * '/audio/music.ogg' * ]).then(function(audio) { * * // access audio by its name only (not by its .mp3 or .ogg path) * audioAssets['/audio/music'].play(); * }) * ``` * @function loadAudio * * @param {String} url - The URL to the Audio file. * * @returns {Promise} A deferred promise. Promise resolves with the Audio. */ function loadAudio(url) { return new Promise((resolve, reject) => { let audioEl, canPlay, resolvedUrl, fullUrl; audioEl = new Audio(); canPlay = getCanPlay(audioEl); // determine the first audio format the browser can play url = [].concat(url) .reduce((playableSource, source) => playableSource ? playableSource : canPlay[ getExtension(source) ] ? source : null , 0); // 0 is the shortest falsy value if (!url) { return reject(/* @if DEBUG */ 'cannot play any of the audio formats provided' + /* @endif */ url); } resolvedUrl = joinPath(audioPath, url); if (audioAssets[resolvedUrl]) return resolve(audioAssets[resolvedUrl]); audioEl.addEventListener('canplay', function loadAudioOnLoad() { fullUrl = getUrl(resolvedUrl, window.location.href); audioAssets[ getName(url) ] = audioAssets[resolvedUrl] = audioAssets[fullUrl] = this; resolve(this); }); audioEl.onerror = function loadAudioOnError() { reject(/* @if DEBUG */ 'Unable to load audio ' + /* @endif */ resolvedUrl); }; audioEl.src = resolvedUrl; audioEl.load(); }); } /** * Load a single Data asset. Uses the base [data path](#setDataPath) to resolve the URL. * * Once loaded, the asset will be accessible on the the [dataAssets](#dataAssets) property. * * ```js * import { loadData } from 'kontra'; * * loadData('assets/data/tile_engine_basic.json').then(function(data) { * // data contains the parsed JSON data * }) * ``` * @function loadData * * @param {String} url - The URL to the Data file. * * @returns {Promise} A deferred promise. Promise resolves with the contents of the file. If the file is a JSON file, the contents will be parsed as JSON. */ function loadData(url) { addGlobal(); let resolvedUrl, fullUrl; resolvedUrl = joinPath(dataPath, url); if (dataAssets[resolvedUrl]) return Promise.resolve(dataAssets[resolvedUrl]); return fetch(resolvedUrl).then(response => { if (!response.ok) throw response; return response.clone().json().catch(() => response.text()) }).then(response => { fullUrl = getUrl(resolvedUrl, window.location.href); if (typeof response === 'object') { dataMap.set(response, fullUrl); } dataAssets[ getName(url) ] = dataAssets[resolvedUrl] = dataAssets[fullUrl] = response; return response; }); } /** * Load Image, Audio, or data files. Uses the [loadImage](#loadImage), [loadAudio](#loadAudio), and [loadData](#loadData) functions to load each asset type. * * ```js * import { load } from 'kontra'; * * load( * 'assets/imgs/character.png', * 'assets/data/tile_engine_basic.json', * ['/audio/music.ogg', '/audio/music.mp3'] * ).then(function(assets) { * // all assets have loaded * }).catch(function(err) { * // error loading an asset * }); * ``` * @function load * * @param {String|String[]} urls - Comma separated list of asset urls to load. * * @returns {Promise} A deferred promise. Resolves with all the loaded assets. */ function load(...urls) { addGlobal(); return Promise.all( urls.map(asset => { // account for a string or an array for the url let extension = getExtension( [].concat(asset)[0] ); return extension.match(imageRegex) ? loadImage(asset) : extension.match(audioRegex) ? loadAudio(asset) : loadData(asset); }) ); } // expose for testing // Override the getCanPlay function to provide a specific return type for tests /** * Noop function */ const noop = () => {}; /** * Clear the canvas. */ function clear() { let canvas = getCanvas(); getContext().clearRect(0, 0, canvas.width, canvas.height); } /** * The game loop updates and renders the game every frame. The game loop is stopped by default and will not start until the loops `start()` function is called. * * The game loop uses a time-based animation with a fixed `dt` to [avoid frame rate issues](http://blog.sklambert.com/using-time-based-animation-implement/). Each update call is guaranteed to equal 1/60 of a second. * * This means that you can avoid having to do time based calculations in your update functions and instead do fixed updates. * * ```js * import { Sprite, GameLoop } from 'kontra'; * * let sprite = Sprite({ * x: 100, * y: 200, * width: 20, * height: 40, * color: 'red' * }); * * let loop = GameLoop({ * update: function(dt) { * // no need to determine how many pixels you want to * // move every second and multiple by dt * // sprite.x += 180 * dt; * * // instead just update by how many pixels you want * // to move every frame and the loop will ensure 60FPS * sprite.x += 3; * }, * render: function() { * sprite.render(); * } * }); * * loop.start(); * ``` * @sectionName GameLoop * * @param {Object} properties - Properties of the game loop. * @param {Function} properties.update - Function called every frame to update the game. Is passed the fixed `dt` as a parameter. * @param {Function} properties.render - Function called every frame to render the game. * @param {Number} [properties.fps=60] - Desired frame rate. * @param {Boolean} [properties.clearCanvas=true] - Clear the canvas every frame before the `render()` function is called. */ function GameLoop({fps = 60, clearCanvas = true, update, render} = {}) { // check for required functions // @if DEBUG if ( !(update && render) ) { throw Error('You must provide update() and render() functions'); } // @endif // animation variables let accumulator = 0; let delta = 1E3 / fps; // delta between performance.now timings (in ms) let step = 1 / fps; let clearFn = clearCanvas ? clear : noop; let last, rAF, now, dt, loop; /** * 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; } emit('tick'); accumulator += dt; while (accumulator >= delta) { loop.update(step); accumulator -= delta; } clearFn(); loop.render(); } // game loop object loop = { /** * Called every frame to update the game. Put all of your games update logic here. * @memberof GameLoop * @function update * * @param {Number} dt - The fixed dt time of 1/60 of a frame. */ update, /** * Called every frame to render the game. Put all of your games render logic here. * @memberof GameLoop * @function render */ render, /** * If the game loop is currently stopped. * * ```js * import { GameLoop } from 'kontra'; * * let loop = GameLoop({ * // ... * }); * console.log(loop.isStopped); //=> true * * loop.start(); * console.log(loop.isStopped); //=> false * * loop.stop(); * console.log(loop.isStopped); //=> true * ``` * @memberof GameLoop * @property {Boolean} isStopped */ isStopped: true, /** * Start the game loop. * @memberof GameLoop * @function start */ start() { last = performance.now(); this.isStopped = false; requestAnimationFrame(frame); }, /** * Stop the game loop. * @memberof GameLoop * @function stop */ stop() { this.isStopped = true; cancelAnimationFrame(rAF); }, // expose properties for testing // @if DEBUG _frame: frame, set _last(value) { last = value; } // @endif }; return loop; } /** * A minimalistic keyboard API. You can use it move the main sprite or respond to a key press. * * ```js * import { initKeys, keyPressed } from 'kontra'; * * // this function must be called first before keyboard * // functions will work * initKeys(); * * function update() { * if (keyPressed('left')) { * // move left * } * } * ``` * @sectionName Keyboard */ /** * Below is a list of keys that are provided by default. If you need to extend this list, you can use the [keyMap](#keyMap) property. * * - a-z * - 0-9 * - enter, esc, space, left, up, right, down * @sectionName Available Keys */ let callbacks$1 = {}; let pressedKeys = {}; /** * A map of keycodes to key names. Add to this object to expand the list of [available keys](#available-keys). * * ```js * import { keyMap, bindKeys } from 'kontra'; * * keyMap[34] = 'pageDown'; * * bindKeys('pageDown', function(e) { * // handle pageDown key * }); * ``` * @property {Object} keyMap */ let keyMap = { // named keys 13: 'enter', 27: 'esc', 32: 'space', 37: 'left', 38: 'up', 39: 'right', 40: 'down' }; /** * Execute a function that corresponds to a keyboard key. * * @param {KeyboardEvent} evt */ function keydownEventHandler(evt) { let key = keyMap[evt.which]; pressedKeys[key] = true; if (callbacks$1[key]) { callbacks$1[key](evt); } } /** * Set the released key to not being pressed. * * @param {KeyboardEvent} evt */ function keyupEventHandler(evt) { pressedKeys[ keyMap[evt.which] ] = false; } /** * Reset pressed keys. */ function blurEventHandler() { pressedKeys = {}; } /** * Initialize keyboard event listeners. This function must be called before using other keyboard functions. * @function initKeys */ function initKeys() { let i; // alpha keys // @see https://stackoverflow.com/a/43095772/2124254 for (i = 0; i < 26; i++) { // rollupjs considers this a side-effect (for now), so we'll do it in the // initKeys function // @see https://twitter.com/lukastaegert/status/1107011988515893249?s=20 keyMap[65+i] = (10 + i).toString(36); } // numeric keys for (i = 0; i < 10; i++) { keyMap[48+i] = ''+i; } window.addEventListener('keydown', keydownEventHandler); window.addEventListener('keyup', keyupEventHandler); window.addEventListener('blur', blurEventHandler); } /** * Bind a set of keys that will call the callback function when they are pressed. Takes a single key or an array of keys. Is passed the original KeyboardEvent as a parameter. * * ```js * import { initKeys, bindKeys } from 'kontra'; * * initKeys(); * * bindKeys('p', function(e) { * // pause the game * }); * bindKeys(['enter', 'space'], function(e) { * e.preventDefault(); * // fire gun * }); * ``` * @function bindKeys * * @param {String|String[]} keys - Key or keys to bind. */ function bindKeys(keys, callback) { // smaller than doing `Array.isArray(keys) ? keys : [keys]` [].concat(keys).map(key => callbacks$1[key] = callback); } /** * Remove the callback function for a bound set of keys. Takes a single key or an array of keys. * * ```js * import { unbindKeys } from 'kontra'; * * unbindKeys('left'); * unbindKeys(['enter', 'space']); * ``` * @function unbindKeys * * @param {String|String[]} keys - Key or keys to unbind. */ function unbindKeys(keys) { // 0 is the smallest falsy value [].concat(keys).map(key => callbacks$1[key] = 0); } /** * Check if a key is currently pressed. Use during an `update()` function to perform actions each frame. * * ```js * import { Sprite, initKeys, keyPressed } from 'kontra'; * * initKeys(); * * let sprite = Sprite({ * update: function() { * if (keyPressed('left')){ * // left arrow pressed * } * else if (keyPressed('right')) { * // right arrow pressed * } * * if (keyPressed('up')) { * // up arrow pressed * } * else if (keyPressed('down')) { * // down arrow pressed * } * } * }); * ``` * @function keyPressed * * @param {String} key - Key to check for pressed state. * * @returns {Boolean} `true` if the key is pressed, `false` otherwise. */ function keyPressed(key) { return !!pressedKeys[key]; } /** * A plugin system based on the [interceptor pattern](https://en.wikipedia.org/wiki/Interceptor_pattern), designed to share reusable code such as more advance collision detection or a 2D physics engine. * * ```js * import { registerPlugin, Sprite } from 'kontra'; * import loggingPlugin from 'path/to/plugin/code.js' * * // register a plugin that adds logging to all Sprites * registerPlugin(Sprite, loggingPlugin); * ``` * @sectionName Plugin */ /** * @docs docs/api_docs/plugin.js */ /** * Get the kontra object method name from the plugin. * * @param {String} methodName - Before/After function name * * @returns {String} */ function getMethod(methodName) { let methodTitle = methodName.substr( methodName.search(/[A-Z]/) ); return methodTitle[0].toLowerCase() + methodTitle.substr(1); } /** * Remove an interceptor. * * @param {function[]} interceptors - Before/After interceptor list * @param {function} fn - Interceptor function */ function removeInterceptor(interceptors, fn) { let index = interceptors.indexOf(fn); if (index !== -1) { interceptors.splice(index, 1); } } /** * Register a plugin to run a set of functions before or after the Kontra object functions. * @function registerPlugin * * @param {Object} kontraObj - Kontra object to attach the plugin to. * @param {Object} pluginObj - Plugin object with before and after intercept functions. */ function registerPlugin(kontraObj, pluginObj) { let objectProto = kontraObj.prototype; if (!objectProto) return; // create interceptor list and functions if (!objectProto._inc) { objectProto._inc = {}; objectProto._bInc = function beforePlugins(context, method, ...args) { return this._inc[method].before.reduce((acc, fn) => { let newArgs = fn(context, ...acc); return newArgs ? newArgs : acc; }, args); }; objectProto._aInc = function afterPlugins(context, method, result, ...args) { return this._inc[method].after.reduce((acc, fn) => { let newResult = fn(context, acc, ...args); return newResult ? newResult : acc; }, result); }; } // add plugin to interceptors Object.getOwnPropertyNames(pluginObj).forEach(methodName => { let method = getMethod(methodName); if (!objectProto[method]) return; // override original method if (!objectProto['_o' + method]) { objectProto['_o' + method] = objectProto[method]; objectProto[method] = function interceptedFn(...args) { // call before interceptors let alteredArgs = this._bInc(this, method, ...args); let result = objectProto['_o' + method].call(this, ...alteredArgs); // call after interceptors return this._aInc(this, method, result, ...args); }; } // create interceptors for the method if (!objectProto._inc[method]) { objectProto._inc[method] = { before: [], after: [] }; } if (methodName.startsWith('before')) { objectProto._inc[method].before.push(pluginObj[methodName]); } else if (methodName.startsWith('after')) { objectProto._inc[method].after.push(pluginObj[methodName]); } }); } /** * Unregister a plugin from a Kontra object. * @function unregisterPlugin * * @param {Object} kontraObj - Kontra object to detach plugin from. * @param {Object} pluginObj - The plugin object that was passed during registration. */ function unregisterPlugin(kontraObj, pluginObj) { let objectProto = kontraObj.prototype; if (!objectProto || !objectProto._inc) return; // remove plugin from interceptors Object.getOwnPropertyNames(pluginObj).forEach(methodName => { let method = getMethod(methodName); if (methodName.startsWith('before')) { removeInterceptor(objectProto._inc[method].before, pluginObj[methodName]); } else if (methodName.startsWith('after')) { removeInterceptor(objectProto._inc[method].after, pluginObj[methodName]); } }); } /** * Safely extend the functionality of a Kontra object. Any properties that already exist on the Kontra object will not be added. * * ```js * import { extendObject, Vector } from 'kontra'; * * // add a subtract function to all Vectors * extendObject(Vector, { * subtract(vec) { * return Vector(this.x - vec.x, this.y - vec.y); * } * }); * ``` * @function extendObject * * @param {Object} kontraObj - Kontra object to extend * @param {Object} properties - Properties to add. */ function extendObject(kontraObj, properties) { let objectProto = kontraObj.prototype; if (!objectProto) return; Object.getOwnPropertyNames(properties).forEach(prop => { if (!objectProto[prop]) { objectProto[prop] = properties[prop]; } }); } /** * A simple pointer API. You can use it move the main sprite or respond to a pointer event. Works with both mouse and touch events. * * Pointer events can be added on a global level or on individual sprites or objects. Before an object can receive pointer events, you must tell the pointer which objects to track and the object must haven been rendered to the canvas using `object.render()`. * * After an object is tracked and rendered, you can assign it an `onDown()`, `onUp()`, or `onOver()` functions which will be called whenever a pointer down, up, or over event happens on the object. * * ```js * import { initPointer, track, Sprite } from 'kontra'; * * // this function must be called first before pointer * // functions will work * initPointer(); * * let sprite = Sprite({ * onDown: function() { * // handle on down events on the sprite * }, * onUp: function() { * // handle on up events on the sprite * }, * onOver: function() { * // handle on over events on the sprite * } * }); * * track(sprite); * sprite.render(); * ``` * * By default, the pointer is treated as a circle and will check for collisions against objects assuming they are rectangular (have a width and height property). * * If you need to perform a different type of collision detection, assign the object a `collidesWithPointer()` function and it will be called instead. The function is passed the pointer object. Use this function to determine how the pointer circle should collide with the object. * * ```js * import { Sprite } from 'kontra'; * let sprite = Srite({ * x: 10, * y: 10, * radius: 10 * collidesWithPointer: function(pointer) { * // perform a circle v circle collision test * let dx = pointer.x - this.x; * let dy = pointer.y - this.y; * return Math.sqrt(dx * dx + dy * dy) < this.radius; * } * }); * ``` * @sectionName Pointer */ // save each object as they are rendered to determine which object // is on top when multiple objects are the target of an event. // we'll always use the last frame's object order so we know // the finalized order of all objects, otherwise an object could ask // if it's being hovered when it's rendered first even if other objects // would block it later in the render order let thisFrameRenderOrder = []; let lastFrameRenderOrder = []; let callbacks$2 = {}; let trackedObjects = []; let pressedButtons = {}; /** * Below is a list of buttons that you can use. * * - left, middle, right * @sectionName Available Buttons */ let buttonMap = { 0: 'left', 1: 'middle', 2: 'right' }; /** * Object containing the `radius` and current `x` and `y` position of the pointer relative to the top-left corner of the canvas. * * ```js * import { initPointer, pointer } from 'kontra'; * * initPointer(); * * console.log(pointer); //=> { x: 100, y: 200, radius: 5 }; * ``` * @property {Object} pointer */ let pointer = { x: 0, y: 0, radius: 5 // arbitrary size }; /** * Detection collision between a rectangle and a circlevt. * @see https://yal.cc/rectangle-circle-intersection-test/ * * @param {Object} object - Object to check collision against. */ function circleRectCollision(object) { let x = object.x; let y = object.y; if (object.anchor) { x -= object.width * object.anchor.x; y -= object.height * object.anchor.y; } let dx = pointer.x - Math.max(x, Math.min(pointer.x, x + object.width)); let dy = pointer.y - Math.max(y, Math.min(pointer.y, y + object.height)); return (dx * dx + dy * dy) < (pointer.radius * pointer.radius); } /** * Get the first on top object that the pointer collides with. * * @returns {Object} First object to collide with the pointer. */ function getCurrentObject() { // if pointer events are required on the very first frame or without a game // loop, use the current frame order array let frameOrder = (lastFrameRenderOrder.length ? lastFrameRenderOrder : thisFrameRenderOrder); let length = frameOrder.length - 1; let object, collides; for (let i = length; i >= 0; i--) { object = frameOrder[i]; if (object.collidesWithPointer) { collides = object.collidesWithPointer(pointer); } else { collides = circleRectCollision(object); } if (collides) { return object; } } } /** * Execute the onDown callback for an object. * * @param {MouseEvent|TouchEvent} evt */ function pointerDownHandler(evt) { // touchstart should be treated like a left mouse button let button = evt.button !== undefined ? buttonMap[evt.button] : 'left'; pressedButtons[button] = true; pointerHandler(evt, 'onDown'); } /** * Execute the onUp callback for an object. * * @param {MouseEvent|TouchEvent} evt */ function pointerUpHandler(evt) { let button = evt.button !== undefined ? buttonMap[evt.button] : 'left'; pressedButtons[button] = false; pointerHandler(evt, 'onUp'); } /** * Track the position of the mousevt. * * @param {MouseEvent|TouchEvent} evt */ function mouseMoveHandler(evt) { pointerHandler(evt, 'onOver'); } /** * Reset pressed buttons. */ function blurEventHandler$1() { pressedButtons = {}; } /** * Find the first object for the event and execute it's callback function * * @param {MouseEvent|TouchEvent} evt * @param {string} eventName - Which event was called. */ function pointerHandler(evt, eventName) { let canvas = getCanvas(); if (!canvas) return; let clientX, clientY; if (['touchstart', 'touchmove', 'touchend'].indexOf(evt.type) !== -1) { clientX = (evt.touches[0] || evt.changedTouches[0]).clientX; clientY = (evt.touches[0] || evt.changedTouches[0]).clientY; } else { clientX = evt.clientX; clientY = evt.clientY; } let ratio = canvas.height / canvas.offsetHeight; let rect = canvas.getBoundingClientRect(); let x = (clientX - rect.left) * ratio; let y = (clientY - rect.top) * ratio; pointer.x = x; pointer.y = y; evt.preventDefault(); let object = getCurrentObject(); if (object && object[eventName]) { object[eventName](evt); } if (callbacks$2[eventName]) { callbacks$2[eventName](evt, object); } } /** * Initialize pointer event listeners. This function must be called before using other pointer functions. * @function initPointer */ function initPointer() { let canvas = getCanvas(); canvas.addEventListener('mousedown', pointerDownHandler); canvas.addEventListener('touchstart', pointerDownHandler); canvas.addEventListener('mouseup', pointerUpHandler); canvas.addEventListener('touchend', pointerUpHandler); canvas.addEventListener('blur', blurEventHandler$1); canvas.addEventListener('mousemove', mouseMoveHandler); canvas.addEventListener('touchmove', mouseMoveHandler); // reset object render order on every new frame on('tick', () => { lastFrameRenderOrder.length = 0; thisFrameRenderOrder.map(object => { lastFrameRenderOrder.push(object); }); thisFrameRenderOrder.length = 0; }); } /** * Begin tracking pointer events for a set of objects. Takes a single object or an array of objects. * * ```js * import { initPointer, track } from 'kontra'; * * initPointer(); * * track(obj); * track([obj1, obj2]); * ``` * @function track * * @param {Object|Object[]} objects - Objects to track. */ function track(objects) { [].concat(objects).map(object => { // override the objects render function to keep track of render order if (!object._r) { object._r = object.render; object.render = function() { thisFrameRenderOrder.push(this); this._r(); }; trackedObjects.push(object); } }); } /** * Remove the callback function for a bound set of objects. * * ```js * import { untrack } from 'kontra'; * * untrack(obj); * untrack([obj1, obj2]); * ``` * @function untrack * * @param {Object|Object[]} objects - Object or objects to stop tracking. */ function untrack(objects) { [].concat(objects).map(object => { // restore original render function to no longer track render order object.render = object._r; object._r = 0; // 0 is the shortest falsy value let index = trackedObjects.indexOf(object); if (index !== -1) { trackedObjects.splice(index, 1); } }); } /** * Check to see if the pointer is currently over the object. Since multiple objects may be rendered on top of one another, only the top most object under the pointer will return true. * * ```js * import { initPointer, track, pointer, pointerOver, Sprite } from 'kontra'; * * initPointer(); * * let sprite1 = Sprite({ * x: 10, * y: 10, * width: 10, * height: 10 * }); * let sprite2 = Sprite({ * x: 15, * y: 10, * width: 10, * height: 10 * }); * * track([sprite1, sprite2]); * * sprite1.render(); * sprite2.render(); * * pointer.x = 14; * pointer.y = 15; * * console.log(pointerOver(sprite1)); //=> false * console.log(pointerOver(sprite2)); //=> true * ``` * @function pointerOver * * @param {Object} object - The object to check if the pointer is over. * * @returns {Boolean} `true` if the pointer is currently over the object, `false` otherwise. */ function pointerOver(object) { if (!trackedObjects.includes(object)) return false; return getCurrentObject() === object; } /** * Register a function to be called on all pointer down events. Is passed the original Event and the target object (if there is one). * * ```js * import { initPointer, onPointerDown } from 'kontra'; * * initPointer(); * * onPointerDown(function(e, object) { * // handle pointer down * }) * ``` * @function onPointerDown * * @param {Function} callback - Function to call on pointer down. */ function onPointerDown(callback) { callbacks$2.onDown = callback; } /** * Register a function to be called on all pointer up events. Is passed the original Event and the target object (if there is one). * * ```js * import { initPointer, onPointerUp } from 'kontra'; * * initPointer(); * * onPointerUp(function(e, object) { * // handle pointer up * }) * ``` * @function onPointerUp * * @param {Function} callback - Function to call on pointer up. */ function onPointerUp(callback) { callbacks$2.onUp = callback; } /** * Check if a button is currently pressed. Use during an `update()` function to perform actions each frame. * * ```js * import { initPointer, pointerPressed } from 'kontra'; * * initPointer(); * * Sprite({ * update: function() { * if (pointerPressed('left')){ * // left mouse button pressed * } * else if (pointerPressed('right')) { * // right mouse button pressed * } * } * }); * ``` * @function pointerPressed * * @param {String} button - Button to check for pressed state. * * @returns {Boolean} `true` if the button is pressed, `false` otherwise. */ function pointerPressed(button) { return !!pressedButtons[button] } /** * A fast and memory efficient [object pool](https://gameprogrammingpatterns.com/object-pool.html) for sprite reuse. Perfect for particle systems or SHUMPs. The pool starts out with just one object, but will grow in size to accommodate as many objects as are needed. * * <canvas width="600" height="200" id="pool-example"></canvas> * <script src="assets/js/pool.js"></script> * @class Pool * * @param {Object} properties - Properties of the pool. * @param {Function} properties.create - Function that returns a new object to be added to the pool when there are no more alive objects. * @param {Number} [properties.maxSize=1024] - The maximum number of objects allowed in the pool. The pool will never grow beyond this size. */ class Pool { /** * @docs docs/api_docs/pool.js */ constructor({create, maxSize = 1024} = {}) { // 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 DEBUG let obj; if (!create || ( !( obj = create() ) || !( obj.update && obj.init && obj.isAlive ) )) { throw Error('Must provide create() function which returns an object with init(), update(), and isAlive() functions'); } // @endif // c = create, i = inUse this._c = create; this._i = 0; /** * All objects currently in the pool, both alive and not alive. * @memberof Pool * @property {Object[]} objects */ this.objects = [create()]; // start the pool with an object /** * The number of alive objects. * @memberof Pool * @property {Number} size */ this.size = 1; /** * The maximum number of objects allowed in the pool. The pool will never grow beyond this size. * @memberof Pool * @property {Number} maxSize */ this.maxSize = maxSize; } /** * Get and return an object from the pool. The properties parameter will be passed directly to the objects `init()` function. If you're using a kontra.Sprite, you should also pass the `ttl` property to designate how many frames you want the object to be alive for. * * If you want to control when the sprite is ready for reuse, pass `Infinity` for `ttl`. You'll need to set the sprites `ttl` to `0` when you're ready for the sprite to be reused. * * ```js * // exclude-tablist * let sprite = pool.get({ * // the object will get these properties and values * x: 100, * y: 200, * width: 20, * height: 40, * color: 'red', * * // pass Infinity for ttl to prevent the object from being reused * // until you set it back to 0 * ttl: Infinity * }); * ``` * @memberof Pool * @function get * * @param {Object} properties - Properties to pass to the objects `init()` function. * * @returns {Object} The newly initialized object. */ get(properties = {}) { // the pool is out of objects if the first object is in use and it can't grow if (this.objects.length == this._i) { if (this.size === this.maxSize) { return; } // double the size of the array by filling it with twice as many objects else { for (let x = 0; x < this.size && this.objects.length < this.maxSize; x++) { this.objects.unshift(this._c()); } this.size = this.objects.length; } } // save off first object in pool to reassign to last object after unshift let obj = this.objects.shift(); obj.init(properties); this.objects.push(obj); this._i++; return obj } /** * Returns an array of all alive objects. Useful if you need to do special processing on all alive objects outside of the pool, such as add all alive objects to a kontra.Quadtree. * @memberof Pool * @function getAliveObjects * * @returns {Object[]} An Array of all alive objects. */ getAliveObjects() { return this.objects.slice(this.objects.length - this._i); } /** * Clear the object pool. Removes all objects from the pool and resets its [size](#size) to 1. * @memberof Pool * @function clear */ clear() { this._i = this.objects.length = 0; this.size = 1; this.objects.push(this._c()); } /** * Update all alive objects in the pool by calling the objects `update()` function. This function also manages when each object should be recycled, so it is recommended that you do not call the objects `update()` function outside of this function. * @memberof Pool * @function update * * @param {Number} [dt] - Time since last update. */ update(dt) { let i = this.size - 1; let obj; // If the user kills an object outside of the updat