kontra
Version:
Kontra HTML5 game development library
1,706 lines (1,553 loc) • 229 kB
JavaScript
/**
* @preserve
* Kontra.js v10.0.2
*/
var kontra = (function () {
/**
* A group of helpful functions that are commonly used for game development. Includes things such as converting between radians and degrees and getting random integers.
*
* ```js
* import { degToRad } from 'kontra';
*
* let radians = degToRad(180); // => 3.14
* ```
* @sectionName Helpers
*/
/**
* Convert degrees to radians.
* @function degToRad
*
* @param {Number} deg - Degrees to convert.
*
* @returns {Number} The value in radians.
*/
function degToRad(deg) {
return (deg * Math.PI) / 180;
}
/**
* Convert radians to degrees.
* @function radToDeg
*
* @param {Number} rad - Radians to convert.
*
* @returns {Number} The value in degrees.
*/
function radToDeg(rad) {
return (rad * 180) / Math.PI;
}
/**
* Return the angle in radians from one point to another point.
*
* ```js
* import { angleToTarget, Sprite } from 'kontra';
*
* let sprite = Sprite({
* x: 10,
* y: 10,
* width: 20,
* height: 40,
* color: 'blue'
* });
*
* sprite.rotation = angleToTarget(sprite, {x: 100, y: 30});
*
* let sprite2 = Sprite({
* x: 100,
* y: 30,
* width: 20,
* height: 40,
* color: 'red',
* });
*
* sprite2.rotation = angleToTarget(sprite2, sprite);
* ```
* @function angleToTarget
*
* @param {{x: Number, y: Number}} source - The {x,y} source point.
* @param {{x: Number, y: Number}} target - The {x,y} target point.
*
* @returns {Number} Angle (in radians) from the source point to the target point.
*/
function angleToTarget(source, target) {
return Math.atan2(target.y - source.y, target.x - source.x);
}
/**
* Rotate a point by an angle.
* @function rotatePoint
*
* @param {{x: Number, y: Number}} point - The {x,y} point to rotate.
* @param {Number} angle - Angle (in radians) to rotate.
*
* @returns {{x: Number, y: Number}} The new x and y coordinates after rotation.
*/
function rotatePoint(point, angle) {
let sin = Math.sin(angle);
let cos = Math.cos(angle);
return {
x: point.x * cos - point.y * sin,
y: point.x * sin + point.y * cos
};
}
/**
* Move a point by an angle and distance.
* @function movePoint
*
* @param {{x: Number, y: Number}} point - The {x,y} point to move.
* @param {Number} angle - Angle (in radians) to move.
* @param {Number} distance - Distance to move.
*
* @returns {{x: Number, y: Number}} The new x and y coordinates after moving.
*/
function movePoint(point, angle, distance) {
return {
x: point.x + Math.cos(angle) * distance,
y: point.y + Math.sin(angle) * distance
};
}
/**
* Linearly interpolate between two values. The function calculates the number between two values based on a percent. Great for smooth transitions.
*
* ```js
* import { lerp } from 'kontra';
*
* console.log( lerp(10, 20, 0.5) ); // => 15
* console.log( lerp(10, 20, 2) ); // => 30
* ```
* @function lerp
*
* @param {Number} start - Start value.
* @param {Number} end - End value.
* @param {Number} percent - Percent to interpolate.
*
* @returns {Number} Interpolated number between the start and end values
*/
function lerp(start, end, percent) {
return start * (1 - percent) + end * percent;
}
/**
* Return the linear interpolation percent between two values. The function calculates the percent between two values of a given value.
*
* ```js
* import { inverseLerp } from 'kontra';
*
* console.log( inverseLerp(10, 20, 15) ); // => 0.5
* console.log( inverseLerp(10, 20, 30) ); // => 2
* ```
* @function inverseLerp
*
* @param {Number} start - Start value.
* @param {Number} end - End value.
* @param {Number} value - Value between start and end.
*
* @returns {Number} Percent difference between the start and end values.
*/
function inverseLerp(start, end, value) {
return (value - start) / (end - start);
}
/**
* Clamp a number between two values, preventing it from going below or above the minimum and maximum values.
* @function clamp
*
* @param {Number} min - Min value.
* @param {Number} max - Max value.
* @param {Number} value - Value to clamp.
*
* @returns {Number} Value clamped between min and max.
*/
function clamp(min, max, value) {
return Math.min(Math.max(min, value), max);
}
/**
* Save an item to localStorage. A value of `undefined` will remove the item from localStorage.
* @function setStoreItem
*
* @param {String} key - The name of the key.
* @param {*} value - The value to store.
*/
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 its original type.
*
* Normally when you save a value to LocalStorage it converts it into a string. So if you were to save a number, it would be saved as `"12"` instead of `12`. This function enables the value to be returned as `12`.
* @function getStoreItem
*
* @param {String} key - Name of the key of the item to retrieve.
*
* @returns {*} The retrieved item.
*/
function getStoreItem(key) {
let value = localStorage.getItem(key);
try {
value = JSON.parse(value);
} catch (e) {
// do nothing
}
return value;
}
/**
* Check if two objects collide. Uses a simple [Axis-Aligned Bounding Box (AABB) collision check](https://developer.mozilla.org/en-US/docs/Games/Techniques/2D_collision_detection#Axis-Aligned_Bounding_Box). Takes into account the objects [anchor](api/gameObject#anchor) and [scale](api/gameObject#scale).
*
* **NOTE:** Does not take into account object rotation. If you need collision detection between rotated objects you will need to implement your own `collides()` function. I suggest looking at the Separate Axis Theorem.
*
* ```js
* import { Sprite, collides } from 'kontra';
*
* let sprite = Sprite({
* x: 100,
* y: 200,
* width: 20,
* height: 40
* });
*
* let sprite2 = Sprite({
* x: 150,
* y: 200,
* width: 20,
* height: 20
* });
*
* collides(sprite, sprite2); //=> false
*
* sprite2.x = 115;
*
* collides(sprite, sprite2); //=> true
* ```
* @function collides
*
* @param {{x: Number, y: Number, width: Number, height: Number}|{world: {x: Number, y: Number, width: Number, height: Number}}} obj1 - Object reference.
* @param {{x: Number, y: Number, width: Number, height: Number}|{world: {x: Number, y: Number, width: Number, height: Number}}} obj2 - Object to check collision against.
*
* @returns {Boolean} `true` if the objects collide, `false` otherwise.
*/
function collides(obj1, obj2) {
let rect1 = getWorldRect(obj1);
let rect2 = getWorldRect(obj2);
// @ifdef GAMEOBJECT_RADIUS
// don't work with ellipses (i.e. scaling has made
// the width and height not the same which means it's
// an ellipse and not a circle)
if (
(obj1.radius && rect1.width != rect1.height) ||
(obj2.radius && rect2.width != rect2.height)
) {
return false;
}
[rect1, rect2] = [rect1, rect2].map(rect => {
if ((rect == rect1 ? obj1 : obj2).radius) {
rect.radius = rect.width / 2;
rect.x += rect.radius;
rect.y += rect.radius;
}
return rect;
});
if (obj1.radius && obj2.radius) {
return (
Math.hypot(rect1.x - rect2.x, rect1.y - rect2.y) <
rect1.radius + rect2.radius
);
}
if (obj1.radius || obj2.radius) {
return circleRectCollision(
obj1.radius ? rect1 : rect2, // circle
obj1.radius ? obj2 : obj1 // rect
);
}
// @endif
return (
rect1.x < rect2.x + rect2.width &&
rect1.x + rect1.width > rect2.x &&
rect1.y < rect2.y + rect2.height &&
rect1.y + rect1.height > rect2.y
);
}
/**
* Return the world rect of an object. The rect is the world position of the top-left corner of the object and its size. Takes into account the objects anchor and scale.
* @function getWorldRect
*
* @param {{x: Number, y: Number, width: Number, height: Number}|{world: {x: Number, y: Number, width: Number, height: Number}}|{mapwidth: Number, mapheight: Number}} obj - Object to get world rect of.
*
* @returns {{x: Number, y: Number, width: Number, height: Number}} The world `x`, `y`, `width`, and `height` of the object.
*/
function getWorldRect(obj) {
let { x = 0, y = 0, width, height, radius } = obj.world || obj;
// take into account tileEngine
if (obj.mapwidth) {
width = obj.mapwidth;
height = obj.mapheight;
}
// @ifdef GAMEOBJECT_RADIUS
// account for circle
if (radius) {
width = radius.x * 2;
height = radius.y * 2;
}
// @endif
// @ifdef GAMEOBJECT_ANCHOR
// account for anchor
if (obj.anchor) {
x -= width * obj.anchor.x;
y -= height * obj.anchor.y;
}
// @endif
// @ifdef GAMEOBJECT_SCALE
// account for negative scales
if (width < 0) {
x += width;
width *= -1;
}
if (height < 0) {
y += height;
height *= -1;
}
// @endif
return {
x,
y,
width,
height
};
}
/**
* Compare two objects world rects to determine how to sort them. Is used as the `compareFunction` to [Array.prototype.sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort).
* @function depthSort
*
* @param {{x: Number, y: Number, width: Number, height: Number}|{world: {x: Number, y: Number, width: Number, height: Number}}} obj1 - First object to compare.
* @param {{x: Number, y: Number, width: Number, height: Number}|{world: {x: Number, y: Number, width: Number, height: Number}}} obj2 - Second object to compare.
* @param {String} [prop='y'] - Objects [getWorldRect](api/helpers#getWorldRect) property to compare.
*
* @returns {Number} The difference between the objects compare property.
*/
function depthSort(obj1, obj2, prop = 'y') {
[obj1, obj2] = [obj1, obj2].map(getWorldRect);
return obj1[prop] - obj2[prop];
}
let noop = () => {};
// style used for DOM nodes needed for screen readers
let srOnlyStyle =
'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';
// prevent focus from scrolling the page
let focusParams = { preventScroll: true };
/**
* Append a node directly after the canvas and as the last element of other kontra nodes.
*
* @param {HTMLElement} node - Node to append.
* @param {HTMLCanvasElement} canvas - Canvas to append after.
*/
function addToDom(node, canvas) {
let container = canvas.parentNode;
node.setAttribute('data-kontra', '');
if (container) {
let target =
[
...container.querySelectorAll(':scope > [data-kontra]')
].pop() || canvas;
target.after(node);
} else if (canvas.nodeName == 'CANVAS') {
document.body.append(node);
} else {
canvas.append(node);
}
}
/**
* Remove an item from an array.
*
* @param {*[]} array - Array to remove from.
* @param {*} item - Item to remove.
*
* @returns {Boolean|undefined} True if the item was removed.
*/
function removeFromArray(array, item) {
let index = array.indexOf(item);
if (index != -1) {
array.splice(index, 1);
return true;
}
}
/**
* Detection collision between a rectangle and a circle.
* @see https://yal.cc/rectangle-circle-intersection-test/
*
* @param {Object} rect - Rectangular object to check collision against.
* @param {Object} circle - Circular object to check collision against.
*
* @returns {Boolean} True if objects collide.
*/
function circleRectCollision(circle, rect) {
let { x, y, width, height } = getWorldRect(rect);
// account for camera
do {
x -= rect.sx || 0;
y -= rect.sy || 0;
} while ((rect = rect.parent));
let dx = circle.x - Math.max(x, Math.min(circle.x, x + width));
let dy = circle.y - Math.max(y, Math.min(circle.y, y + height));
return dx * dx + dy * dy < circle.radius * circle.radius;
}
/**
* A simple event system. Allows you to hook into Kontra lifecycle events or create your own, such as for [Plugins](api/plugin).
*
* ```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$2 = {};
/**
* There are currently only three lifecycle events:
* - `init` - Emitted after `kontra.init()` is called.
* - `tick` - Emitted every frame of [GameLoop](api/gameLoop) before the loops `update()` and `render()` functions are called.
* - `assetLoaded` - Emitted after an asset has fully loaded using the asset loader. The callback function is passed the asset and the url of the asset as parameters.
* @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$2[event] = callbacks$2[event] || [];
callbacks$2[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) {
callbacks$2[event] = (callbacks$2[event] || []).filter(
fn => fn != callback
);
}
/**
* 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 - Comma separated list of arguments passed to all callbacks.
*/
function emit(event, ...args) {
(callbacks$2[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, context;
// allow contextless environments, such as using ThreeJS as the main
// canvas, by proxying all canvas context calls
let handler$1 = {
// by using noop we can proxy both property and function calls
// so neither will throw errors
get(target, key) {
// export for testing
if (key == '_proxy') return true;
return noop;
}
};
/**
* 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.
* @param {Object} [options] - Game options.
* @param {Boolean} [options.contextless=false] - If the game will run in an contextless environment. A contextless environment uses a [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) for the `canvas` and `context` so all property and function calls will noop.
*
* @returns {{canvas: HTMLCanvasElement, context: CanvasRenderingContext2D}} 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$1(canvas, { contextless = false } = {}) {
// 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 (contextless) {
canvasEl = canvasEl || new Proxy({}, handler$1);
}
// @ifdef DEBUG
if (!canvasEl) {
throw Error('You must provide a canvas element for the game');
}
// @endif
context = canvasEl.getContext('2d') || new Proxy({}, handler$1);
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 Animation directly, but rather you would create them from a [SpriteSheet](api/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 {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.
* @param {String} [properties.name] - The name of the animation.
*/
class Animation {
constructor({ spriteSheet, frames, frameRate, loop = true, name }) {
let {
width,
height,
spacing = 0,
margin = 0
} = spriteSheet.frame;
Object.assign(this, {
/**
* The sprite sheet to use for the animation.
* @memberof Animation
* @property {SpriteSheet} spriteSheet
*/
spriteSheet,
/**
* Sequence of frames to use from the sprite sheet.
* @memberof Animation
* @property {Number[]} frames
*/
frames,
/**
* Number of frames to display per second. Adjusting this value will change the speed of the animation.
* @memberof Animation
* @property {Number} frameRate
*/
frameRate,
/**
* If the animation should loop back to the beginning once completed.
* @memberof Animation
* @property {Boolean} loop
*/
loop,
/**
* The name of the animation.
* @memberof Animation
* @property {String} name
*/
name,
/**
* The width of an individual frame. Taken from the [frame width value](api/spriteSheet#frame) of the sprite sheet.
* @memberof Animation
* @property {Number} width
*/
width,
/**
* The height of an individual frame. Taken from the [frame height value](api/spriteSheet#frame) of the sprite sheet.
* @memberof Animation
* @property {Number} height
*/
height,
/**
* The space between each frame. Taken from the [frame spacing value](api/spriteSheet#frame) of the sprite sheet.
* @memberof Animation
* @property {Number} spacing
*/
spacing,
/**
* The border space around the sprite sheet image. Taken from the [frame margin value](api/spriteSheet#frame) of the sprite sheet.
* @memberof Animation
* @property {Number} margin
*/
margin,
/**
* If the animation is currently stopped. Stopped animations will not update when the [update()](api/animation#update) function is called.
*
* Animations are not considered stopped until either the [stop()](api/animation#stop) function is called or the animation gets to the last frame and does not loop.
*
* ```js
* import { Animation } from 'kontra';
*
* let animation = Animation({
* // ...
* });
* console.log(animation.isStopped); //=> false
*
* animation.start();
* console.log(animation.isStopped); //=> false
*
* animation.stop();
* console.log(animation.isStopped); //=> true
* ```
* @memberof Animation
* @property {Boolean} isStopped
*/
isStopped: false,
// f = frame, a = accumulator
_f: 0,
_a: 0
});
}
/**
* Clone an animation so it can be used more than once. By default animations passed to [Sprite](api/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 {Animation} A new Animation instance.
*/
clone() {
return new Animation(this);
}
/**
* Start the animation.
* @memberof Animation
* @function start
*/
start() {
this.isStopped = false;
if (!this.loop) {
this.reset();
}
}
/**
* Stop the animation.
* @memberof Animation
* @function stop
*/
stop() {
this.isStopped = true;
}
/**
* 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 (this.isStopped) {
return;
}
// if the animation doesn't loop we stop at the last frame
if (!this.loop && this._f == this.frames.length - 1) {
this.stop();
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](api/animation#width).
* @param {Number} [properties.height] - height of the sprite. Defaults to [Animation.height](api/animation#height).
* @param {CanvasRenderingContext2D} [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,
this.margin + col * this.width + (col * 2 + 1) * this.spacing,
this.margin + row * this.height + (row * 2 + 1) * this.spacing,
this.width,
this.height,
x,
y,
width,
height
);
}
}
function factory$b() {
return new Animation(...arguments);
}
/**
* A promise based asset loader for loading images, audio, and data files. An `assetLoaded` event is emitted after each asset is fully loaded. The callback for the event is passed the asset and the url to the asset as parameters.
*
* ```js
* import { load, on } from 'kontra';
*
* let numAssets = 3;
* let assetsLoaded = 0;
* on('assetLoaded', (asset, url) => {
* assetsLoaded++;
*
* // inform user or update progress bar
* });
*
* 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|webp)$/;
let audioRegex = /(wav|mp3|ogg|aac)$/;
let leadingSlash = /^\//;
let trailingSlash = /\/$/;
let dataMap = /*@__PURE__*/ 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: audio.canPlayType('audio/wav; codecs="1"'),
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](api/assets#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 {{[name: String]: HTMLImageElement}} imageAssets
*/
let imageAssets = {};
/**
* Object of all loaded audio assets by both file name and path. If the base [audio path](api/assets#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 {{[name: String]: HTMLAudioElement}} audioAssets
*/
let audioAssets = {};
/**
* Object of all loaded data assets by both file name and path. If the base [data path](api/assets#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 {{[name: String]: any}} 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](api/assets#setImagePath) to resolve the URL.
*
* Once loaded, the asset will be accessible on the the [imageAssets](api/assets#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<HTMLImageElement>} 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;
emit('assetLoaded', this, url);
resolve(this);
};
image.onerror = function loadImageOnError() {
reject(
/* @ifdef 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](api/assets#setAudioPath) to resolve the URL.
*
* Once loaded, the asset will be accessible on the the [audioAssets](api/assets#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|String[]} url - The URL to the Audio file.
*
* @returns {Promise<HTMLAudioElement>} A deferred promise. Promise resolves with the Audio.
*/
function loadAudio(url) {
return new Promise((resolve, reject) => {
let _url = url,
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(
/* @ifdef 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;
emit('assetLoaded', this, url);
resolve(this);
});
audioEl.onerror = function loadAudioOnError() {
reject(
/* @ifdef DEBUG */ 'Unable to load audio ' +
/* @endif */ resolvedUrl
);
};
audioEl.src = resolvedUrl;
audioEl.load();
});
}
/**
* Load a single Data asset. Uses the base [data path](api/assets#setDataPath) to resolve the URL.
*
* Once loaded, the asset will be accessible on the the [dataAssets](api/assets#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;
emit('assetLoaded', response, url);
return response;
});
}
/**
* Load Image, Audio, or data files. Uses the [loadImage](api/assets#loadImage), [loadAudio](api/assets#loadAudio), and [loadData](api/assets#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<any[]>} 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);
})
);
}
/**
* A simple 2d vector object. Takes either separate `x` and `y` coordinates or a Vector-like object.
*
* ```js
* import { Vector } from 'kontra';
*
* let vector = Vector(100, 200);
* let vector2 = Vector({x: 100, y: 200});
* ```
* @class Vector
*
* @param {Number|{x: number, y: number}} [x=0] - X coordinate of the vector or a Vector-like object. If passing an object, the `y` param is ignored.
* @param {Number} [y=0] - Y coordinate of the vector.
*/
class Vector {
constructor(x = 0, y = 0, vec = {}) {
if (x.x != undefined) {
this.x = x.x;
this.y = x.y;
}
else {
this.x = x;
this.y = y;
}
// @ifdef VECTOR_CLAMP
// preserve vector clamping when creating new vectors
if (vec._c) {
this.clamp(vec._a, vec._b, vec._d, vec._e);
// reset x and y so clamping takes effect
this.x = x;
this.y = y;
}
// @endif
}
/**
* Set the x and y coordinate of the vector.
* @memberof Vector
* @function set
*
* @param {Vector|{x: number, y: number}} vector - Vector to set coordinates from.
*/
set(vec) {
this.x = vec.x;
this.y = vec.y;
}
/**
* Calculate the addition of the current vector with the given vector.
* @memberof Vector
* @function add
*
* @param {Vector|{x: number, y: number}} vector - Vector to add to the current Vector.
*
* @returns {Vector} A new Vector instance whose value is the addition of the two vectors.
*/
add(vec) {
return new Vector(this.x + vec.x, this.y + vec.y, this);
}
// @ifdef VECTOR_SUBTRACT
/**
* Calculate the subtraction of the current vector with the given vector.
* @memberof Vector
* @function subtract
*
* @param {Vector|{x: number, y: number}} vector - Vector to subtract from the current Vector.
*
* @returns {Vector} A new Vector instance whose value is the subtraction of the two vectors.
*/
subtract(vec) {
return new Vector(this.x - vec.x, this.y - vec.y, this);
}
// @endif
// @ifdef VECTOR_SCALE
/**
* Calculate the multiple of the current vector by a value.
* @memberof Vector
* @function scale
*
* @param {Number} value - Value to scale the current Vector.
*
* @returns {Vector} A new Vector instance whose value is multiplied by the scalar.
*/
scale(value) {
return new Vector(this.x * value, this.y * value);
}
// @endif
// @ifdef VECTOR_NORMALIZE
/**
* Calculate the normalized value of the current vector. Requires the Vector [length](api/vector#length) function.
* @memberof Vector
* @function normalize
*
* @returns {Vector} A new Vector instance whose value is the normalized vector.
*/
// @see https://github.com/jed/140bytes/wiki/Byte-saving-techniques#use-placeholder-arguments-instead-of-var
normalize(length = this.length() || 1) {
return new Vector(this.x / length, this.y / length);
}
// @endif
// @ifdef VECTOR_DOT||VECTOR_ANGLE
/**
* Calculate the dot product of the current vector with the given vector.
* @memberof Vector
* @function dot
*
* @param {Vector|{x: number, y: number}} vector - Vector to dot product against.
*
* @returns {Number} The dot product of the vectors.
*/
dot(vec) {
return this.x * vec.x + this.y * vec.y;
}
// @endif
// @ifdef VECTOR_LENGTH||VECTOR_NORMALIZE||VECTOR_ANGLE
/**
* Calculate the length (magnitude) of the Vector.
* @memberof Vector
* @function length
*
* @returns {Number} The length of the vector.
*/
length() {
return Math.hypot(this.x, this.y);
}
// @endif
// @ifdef VECTOR_DISTANCE
/**
* Calculate the distance between the current vector and the given vector.
* @memberof Vector
* @function distance
*
* @param {Vector|{x: number, y: number}} vector - Vector to calculate the distance between.
*
* @returns {Number} The distance between the two vectors.
*/
distance(vec) {
return Math.hypot(this.x - vec.x, this.y - vec.y);
}
// @endif
// @ifdef VECTOR_ANGLE
/**
* Calculate the angle (in radians) between the current vector and the given vector. Requires the Vector [dot](api/vector#dot) and [length](api/vector#length) functions.
* @memberof Vector
* @function angle
*
* @param {Vector} vector - Vector to calculate the angle between.
*
* @returns {Number} The angle (in radians) between the two vectors.
*/
angle(vec) {
return Math.acos(this.dot(vec) / (this.length() * vec.length()));
}
// @endif
// @ifdef VECTOR_DIRECTION
/**
* Calculate the angle (in radians) of the current vector.
* @memberof Vector
* @function direction
*
* @returns {Number} The angle (in radians) of the vector.
*/
direction() {
return Math.atan2(this.y, this.x);
}
// @endif
// @ifdef VECTOR_CLAMP
/**
* Clamp the Vector between two points, preventing `x` and `y` from going below or above the minimum and maximum values. Perfect for keeping a sprite from going outside the game boundaries.
*
* ```js
* import { Vector } from 'kontra';
*
* let vector = Vector(100, 200);
* vector.clamp(0, 0, 200, 300);
*
* vector.x += 200;
* console.log(vector.x); //=> 200
*
* vector.y -= 300;
* console.log(vector.y); //=> 0
*
* vector.add({x: -500, y: 500});
* console.log(vector); //=> {x: 0, y: 300}
* ```
* @memberof Vector
* @function clamp
*
* @param {Number} xMin - Minimum x value.
* @param {Number} yMin - Minimum y value.
* @param {Number} xMax - Maximum x value.
* @param {Number} yMax - Maximum y value.
*/
clamp(xMin, yMin, xMax, yMax) {
this._c = true;
this._a = xMin;
this._b = yMin;
this._d = xMax;
this._e = yMax;
}
/**
* X coordinate of the vector.
* @memberof Vector
* @property {Number} x
*/
get x() {
return this._x;
}
/**
* Y coordinate of the vector.
* @memberof Vector
* @property {Number} y
*/
get y() {
return this._y;
}
set x(value) {
this._x = this._c ? clamp(this._a, this._d, value) : value;
}
set y(value) {
this._y = this._c ? clamp(this._b, this._e, value) : value;
}
// @endif
}
function factory$a() {
return new Vector(...arguments);
}
/**
* This is a private class that is used just to help make the GameObject class more manageable and smaller.
*
* It maintains everything that can be changed in the update function:
* position
* velocity
* acceleration
* ttl
*/
class Updatable {
constructor(properties) {
return this.init(properties);
}
init(properties = {}) {
// --------------------------------------------------
// defaults
// --------------------------------------------------
/**
* The game objects position vector. Represents the local position of the object as opposed to the [world](api/gameObject#world) position.
* @property {Vector} position
* @memberof GameObject
* @page GameObject
*/
this.position = factory$a();
// --------------------------------------------------
// optionals
// --------------------------------------------------
// @ifdef GAMEOBJECT_VELOCITY
/**
* The game objects velocity vector.
* @memberof GameObject
* @property {Vector} velocity
* @page GameObject
*/
this.velocity = factory$a();
// @endif
// @ifdef GAMEOBJECT_ACCELERATION
/**
* The game objects acceleration vector.
* @memberof GameObject
* @property {Vector} acceleration
* @page GameObject
*/
this.acceleration = factory$a();
// @endif
// @ifdef GAMEOBJECT_TTL
/**
* How may frames the game object should be alive.
* @memberof GameObject
* @property {Number} ttl
* @page GameObject
*/
this.ttl = Infinity;
// @endif
// add all properties to the object, overriding any defaults
Object.assign(this, properties);
}
/**
* Update the position of the game object and all children using their velocity and acceleration. Calls the game objects [advance()](api/gameObject#advance) function.
* @memberof GameObject
* @function update
* @page GameObject
*
* @param {Number} [dt] - Time since last update.
*/
update(dt) {
this.advance(dt);
}
/**
* Move the game object by its acceleration and velocity. If you pass `dt` it will multiply the vector and acceleration by that number. This means the `dx`, `dy`, `ddx` and `ddy` should be how far you want the object to move in 1 second rather than in 1 frame.
*
* If you override the game objects [update()](api/gameObject#update) function with your own update function, you can call this function to move the game object normally.
*
* ```js
* import { GameObject } from 'kontra';
*
* let gameObject = GameObject({
* x: 100,
* y: 200,
* width: 20,
* height: 40,
* dx: 5,
* dy: 2,
* update: function() {
* // move the game object normally
* this.advance();
*
* // change the velocity at the edges of the canvas
* if (this.x < 0 ||
* this.x + this.width > this.context.canvas.width) {
* this.dx = -this.dx;
* }
* if (this.y < 0 ||
* this.y + this.height > this.context.canvas.height) {
* this.dy = -this.dy;
* }
* }
* });
* ```
* @memberof GameObject
* @function advance
* @page GameObject
*
* @param {Number} [dt] - Time since last update.
*
*/
advance(dt) {
// @ifdef GAMEOBJECT_VELOCITY
// @ifdef GAMEOBJECT_ACCELERATION
let acceleration = this.acceleration;
// @ifdef VECTOR_SCALE
if (dt) {
acceleration = acceleration.scale(dt);
}
// @endif
this.velocity = this.velocity.add(acceleration);
// @endif
// @endif
// @ifdef GAMEOBJECT_VELOCITY
let velocity = this.velocity;
// @ifdef VECTOR_SCALE
if (dt) {
velocity = velocity.scale(dt);
}
// @endif
this.position = this.position.add(velocity);
this._pc();
// @endif
// @ifdef GAMEOBJECT_TTL
this.ttl--;
// @endif
}
// --------------------------------------------------
// velocity
// --------------------------------------------------
// @ifdef GAMEOBJECT_VELOCITY
/**
* X coordinate of the velocity vector.
* @memberof GameObject
* @property {Number} dx
* @page GameObject
*/
get dx() {
return this.velocity.x;