kontra
Version:
Kontra HTML5 game development library
503 lines (441 loc) • 14.2 kB
JavaScript
import { getCanvas, getContext } from './core.js'
/**
* A tile engine for managing and drawing tilesets.
*
* <figure>
* <a href="assets/imgs/mapPack_tilesheet.png">
* <img src="assets/imgs/mapPack_tilesheet.png" alt="Tileset to create an overworld map in various seasons.">
* </a>
* <figcaption>Tileset image courtesy of <a href="https://kenney.nl/assets">Kenney</a>.</figcaption>
* </figure>
* @sectionName TileEngine
*
* @param {Object} properties - Properties of the tile engine.
* @param {Number} properties.width - Width of the tile map (in number of tiles).
* @param {Number} properties.height - Height of the tile map (in number of tiles).
* @param {Number} properties.tilewidth - Width of a single tile (in pixels).
* @param {Number} properties.tileheight - Height of a single tile (in pixels).
* @param {CanvasRenderingContext2D} [properties.context] - The context the tile engine should draw to. Defaults to [core.getContext()](api/core#getContext)
*
* @param {Object[]} properties.tilesets - Array of tileset objects.
* @param {Number} properties.tilesetN.firstgid - First tile index of the tileset. The first tileset will have a firstgid of 1 as 0 represents an empty tile.
* @param {String|HTMLImageElement} properties.tilesetN.image - Relative path to the HTMLImageElement or an HTMLImageElement. If passing a relative path, the image file must have been [loaded](./assets) first.
* @param {Number} [properties.tilesetN.margin=0] - The amount of whitespace between each tile (in pixels).
* @param {Number} [properties.tilesetN.tilewidth] - Width of the tileset (in pixels). Defaults to properties.tilewidth.
* @param {Number} [properties.tilesetN.tileheight] - Height of the tileset (in pixels). Defaults to properties.tileheight.
* @param {String} [properties.tilesetN.source] - Relative path to the source JSON file. The source JSON file must have been [loaded](./assets) first.
* @param {Number} [properties.tilesetN.columns] - Number of columns in the tileset image.
*
* @param {Object[]} properties.layers - Array of layer objects.
* @param {String} properties.layerN.name - Unique name of the layer.
* @param {Number[]} properties.layerN.data - 1D array of tile indices.
* @param {Boolean} [properties.layerN.visible=true] - If the layer should be drawn or not.
* @param {Number} [properties.layerN.opacity=1] - Percent opacity of the layer.
*/
/**
* @docs docs/api_docs/tileEngine.js
*/
export default function TileEngine(properties = {}) {
let {
width,
height,
tilewidth,
tileheight,
context = getContext(),
tilesets,
layers
} = properties;
let mapwidth = width * tilewidth;
let mapheight = height * tileheight
// create an off-screen canvas for pre-rendering the map
// @see http://jsperf.com/render-vs-prerender
let offscreenCanvas = document.createElement('canvas');
let offscreenContext = offscreenCanvas.getContext('2d');
offscreenCanvas.width = mapwidth;
offscreenCanvas.height = mapheight;
// map layer names to data
let layerMap = {};
let layerCanvases = {};
/**
* The width of tile map (in tiles).
* @memberof TileEngine
* @property {Number} width
*/
/**
* The height of tile map (in tiles).
* @memberof TileEngine
* @property {Number} height
*/
/**
* The width a tile (in pixels).
* @memberof TileEngine
* @property {Number} tilewidth
*/
/**
* The height of a tile (in pixels).
* @memberof TileEngine
* @property {Number} tileheight
*/
/**
* Array of all layers of the tile engine.
* @memberof TileEngine
* @property {Object[]} layers
*/
/**
* Array of all tilesets of the tile engine.
* @memberof TileEngine
* @property {Object[]} tilesets
*/
let tileEngine = Object.assign({
/**
* The context the tile engine will draw to.
* @memberof TileEngine
* @property {CanvasRenderingContext2D} context
*/
context: context,
/**
* The width of the tile map (in pixels).
* @memberof TileEngine
* @property {Number} mapwidth
*/
mapwidth: mapwidth,
/**
* The height of the tile map (in pixels).
* @memberof TileEngine
* @property {Number} mapheight
*/
mapheight: mapheight,
_sx: 0,
_sy: 0,
/**
* X coordinate of the tile map camera.
* @memberof TileEngine
* @property {Number} sx
*/
get sx() {
return this._sx;
},
/**
* Y coordinate of the tile map camera.
* @memberof TileEngine
* @property {Number} sy
*/
get sy() {
return this._sy;
},
// 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
set sx(value) {
this._sx = Math.min( Math.max(0, value), mapwidth - getCanvas().width );
},
set sy(value) {
this._sy = Math.min( Math.max(0, value), mapheight - getCanvas().height );
},
/**
* Render all visible layers.
* @memberof TileEngine
* @function render
*/
render() {
render(offscreenCanvas);
},
/**
* Render a specific layer by name.
* @memberof TileEngine
* @function renderLayer
*
* @param {String} name - Name of the layer to render.
*/
renderLayer(name) {
let canvas = layerCanvases[name];
let layer = layerMap[name];
if (!canvas) {
// cache the rendered layer so we can render it again without redrawing
// all tiles
canvas = document.createElement('canvas');
canvas.width = mapwidth;
canvas.height = mapheight;
layerCanvases[name] = canvas;
tileEngine._r(layer, canvas.getContext('2d'));
}
render(canvas);
},
/**
* Check if the object collides with the layer (shares a gird coordinate with any positive tile index in layers data). The object being checked must have the properties `x`, `y`, `width`, and `height` so that its position in the grid can be calculated. kontra.Sprite defines these properties for you.
*
* ```js
* import { TileEngine, Sprite } from 'kontra';
*
* let tileEngine = TileEngine({
* tilewidth: 32,
* tileheight: 32,
* width: 4,
* height: 4,
* tilesets: [{
* // ...
* }],
* layers: [{
* name: 'collision',
* data: [ 0,0,0,0,
* 0,1,4,0,
* 0,2,5,0,
* 0,0,0,0 ]
* }]
* });
*
* let sprite = Sprite({
* x: 50,
* y: 20,
* width: 5,
* height: 5
* });
*
* tileEngine.layerCollidesWith('collision', sprite); //=> false
*
* sprite.y = 28;
*
* tileEngine.layerCollidesWith('collision', sprite); //=> true
* ```
* @memberof TileEngine
* @function layerCollidesWith
*
* @param {String} name - The name of the layer to check for collision.
* @param {Object} object - Object to check collision against.
*
* @returns {boolean} `true` if the object collides with a tile, `false` otherwise.
*/
layerCollidesWith(name, object) {
let row = getRow(object.y);
let col = getCol(object.x);
let endRow = getRow(object.y + object.height);
let endCol = getCol(object.x + object.width);
let layer = layerMap[name];
// check all tiles
for (let r = row; r <= endRow; r++) {
for (let c = col; c <= endCol; c++) {
if (layer.data[c + r * this.width]) {
return true;
}
}
}
return false;
},
/**
* Get the tile at the specified layer using either x and y coordinates or row and column coordinates.
*
* ```js
* import { TileEngine } from 'kontra';
*
* let tileEngine = TileEngine({
* tilewidth: 32,
* tileheight: 32,
* width: 4,
* height: 4,
* tilesets: [{
* // ...
* }],
* layers: [{
* name: 'collision',
* data: [ 0,0,0,0,
* 0,1,4,0,
* 0,2,5,0,
* 0,0,0,0 ]
* }]
* });
*
* tileEngine.tileAtLayer('collision', {x: 50, y: 50}); //=> 1
* tileEngine.tileAtLayer('collision', {row: 2, col: 1}); //=> 2
* ```
* @memberof TileEngine
* @function tileAtLayer
*
* @param {String} name - Name of the layer.
* @param {Object} position - Position of the tile in either {x, y} or {row, col} coordinates.
*
* @returns {Number} The tile index. Will return `-1` if no layer exists by the provided name.
*/
tileAtLayer(name, position) {
let row = position.row || getRow(position.y);
let col = position.col || getCol(position.x);
if (layerMap[name]) {
return layerMap[name].data[col + row * tileEngine.width];
}
return -1;
},
/**
* Set the tile at the specified layer using either x and y coordinates or row and column coordinates.
*
* ```js
* import { TileEngine } from 'kontra';
*
* let tileEngine = TileEngine({
* tilewidth: 32,
* tileheight: 32,
* width: 4,
* height: 4,
* tilesets: [{
* // ...
* }],
* layers: [{
* name: 'collision',
* data: [ 0,0,0,0,
* 0,1,4,0,
* 0,2,5,0,
* 0,0,0,0 ]
* }]
* });
*
* tileEngine.setTileAtLayer('collision', {row: 2, col: 1}, 10);
* tileEngine.tileAtLayer('collision', {row: 2, col: 1}); //=> 10
* ```
* @memberof TileEngine
* @function setTileAtLayer
*
* @param {String} name - Name of the layer.
* @param {Object} position - Position of the tile in either {x, y} or {row, col} coordinates.
* @param {Number} tile - Tile index to set.
*/
setTileAtLayer(name, position, tile) {
let row = position.row || getRow(position.y);
let col = position.col || getCol(position.x);
if (layerMap[name]) {
layerMap[name].data[col + row * tileEngine.width] = tile;
prerender();
}
},
// expose for testing
_r: renderLayer,
// @if DEBUG
layerCanvases: layerCanvases
// @endif
}, properties);
// resolve linked files (source, image)
tileEngine.tilesets.map(tileset => {
// get the url of the Tiled JSON object (in this case, the properties object)
let url = (window.__k ? window.__k.dm.get(properties) : '') || window.location.href;
if (tileset.source) {
// @if DEBUG
if (!window.__k) {
throw Error(`You must use "load" or "loadData" to resolve tileset.source`);
}
// @endif
let source = window.__k.d[window.__k.u(tileset.source, url)];
// @if DEBUG
if (!source) {
throw Error(`You must load the tileset source "${tileset.source}" before loading the tileset`);
}
// @endif
Object.keys(source).map(key => {
tileset[key] = source[key];
});
}
if (''+tileset.image === tileset.image) {
// @if DEBUG
if (!window.__k) {
throw Error(`You must use "load" or "loadImage" to resolve tileset.image`);
}
// @endif
let image = window.__k.i[window.__k.u(tileset.image, url)];
// @if DEBUG
if (!image) {
throw Error(`You must load the image "${tileset.image}" before loading the tileset`);
}
// @endif
tileset.image = image;
}
});
/**
* Get the row from the y coordinate.
* @private
*
* @param {Number} y - Y coordinate.
*
* @return {Number}
*/
function getRow(y) {
return (tileEngine.sy + y) / tileEngine.tileheight | 0;
}
/**
* Get the col from the x coordinate.
* @private
*
* @param {Number} x - X coordinate.
*
* @return {Number}
*/
function getCol(x) {
return (tileEngine.sx + x) / tileEngine.tilewidth | 0;
}
/**
* Render a layer.
* @private
*
* @param {Object} layer - Layer data.
* @param {Context} context - Context to draw layer to.
*/
function renderLayer(layer, context) {
context.save();
context.globalAlpha = layer.opacity;
layer.data.map((tile, index) => {
// skip empty tiles (0)
if (!tile) return;
// find the tileset the tile belongs to
// assume tilesets are ordered by firstgid
let tileset;
for (let i = tileEngine.tilesets.length-1; i >= 0; i--) {
tileset = tileEngine.tilesets[i];
if (tile / tileset.firstgid >= 1) {
break;
}
}
let tilewidth = tileset.tilewidth || tileEngine.tilewidth;
let tileheight = tileset.tileheight || tileEngine.tileheight;
let margin = tileset.margin || 0;
let image = tileset.image;
let offset = tile - tileset.firstgid;
let cols = tileset.columns ||
image.width / (tilewidth + margin) | 0;
let x = (index % tileEngine.width) * tilewidth;
let y = (index / tileEngine.width | 0) * tileheight;
let sx = (offset % cols) * (tilewidth + margin);
let sy = (offset / cols | 0) * (tileheight + margin);
context.drawImage(
image,
sx, sy, tilewidth, tileheight,
x, y, tilewidth, tileheight
);
});
context.restore();
}
/**
* Pre-render the tiles to make drawing fast.
* @private
*/
function prerender() {
if (tileEngine.layers) {
tileEngine.layers.map(layer => {
layerMap[layer.name] = layer;
if (layer.visible !== false) {
tileEngine._r(layer, offscreenContext);
}
});
}
}
/**
* Render a tile engine canvas.
* @private
*
* @param {HTMLCanvasElement} canvas - Tile engine canvas to draw.
*/
function render(canvas) {
let { width, height } = getCanvas();
tileEngine.context.drawImage(
canvas,
tileEngine.sx, tileEngine.sy, width, height,
0, 0, width, height
);
}
prerender();
return tileEngine;
};