UNPKG

kontra

Version:

Kontra HTML5 game development library

503 lines (441 loc) 14.2 kB
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 {Canvas​Rendering​Context2D} [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; };