UNPKG

littlejsengine

Version:

LittleJS - Tiny and Fast HTML5 Game Engine

710 lines (635 loc) 29.1 kB
/** * LittleJS Tile Layer System * - Caches arrays of tiles to off screen canvas for fast rendering * - Unlimited numbers of layers, allocates canvases as needed * - Tile layers can be drawn to using their context with canvas2d * - Tile layers can also have collision with EngineObjects * @namespace TileLayers */ 'use strict'; /////////////////////////////////////////////////////////////////////////////// // Tile Layer System /** Keep track of all tile layers with collision * @type {Array<TileCollisionLayer>} * @memberof TileLayers */ const tileCollisionLayers = []; /** Get tile collision data for a given cell in the grid * @param {Vector2} pos * @param {boolean} [solidOnly] - Only check solid layers? * @return {number} * @memberof TileLayers */ function tileCollisionGetData(pos, solidOnly=true) { // check all tile collision layers for (const layer of tileCollisionLayers) if (!solidOnly || layer.isSolid) if (pos.arrayCheck(layer.size)) { const data = layer.getCollisionData(pos); if (data) return data; } return 0; } /** Check if a tile layer collides with another object * @param {Vector2} pos * @param {Vector2} [size=vec2()] * @param {EngineObject|TileCollisionCallback} [callbackObject] - Callback, engine object, or undefined * @param {boolean} [solidOnly] - Only check solid layers? * @return {TileCollisionLayer} * @memberof TileLayers */ function tileCollisionTest(pos, size=vec2(), callbackObject, solidOnly=true) { for (const layer of tileCollisionLayers) { if (!solidOnly || layer.isSolid) if (layer.collisionTest(pos, size, callbackObject)) return layer; } } /** * @callback TileCollisionCallback - Function to handle a tile collision test * @param {number} tileData - the value of the tile at the position * @param {Vector2} pos - world space position of tile where the collision occurred * @memberof TileLayers */ /** Return the exact position of the boundary of first tile hit, undefined if nothing was hit. * The point will be inside the colliding tile if it hits * @param {Vector2} posStart * @param {Vector2} posEnd * @param {EngineObject|TileCollisionCallback} [callbackObject] - Callback, engine object, or undefined * @param {Vector2} [normal] - Optional normal of the surface hit * @param {boolean} [solidOnly=true] - Only check solid layers? * @return {Vector2|undefined} - position of the center of the tile hit or undefined if no hit * @memberof TileLayers */ function tileCollisionRaycast(posStart, posEnd, callbackObject, normal, solidOnly=true) { for (const layer of tileCollisionLayers) { if (!solidOnly || layer.isSolid) { const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, normal) if (hitPos) return hitPos; } } } /////////////////////////////////////////////////////////////////////////////// /** * Load tile layers from exported data * @param {Object} tileMapData - Level data from exported data * @param {TileInfo} [tileInfo] - Default tile info (used for size and texture) * @param {number} [renderOrder] - Render order of the top layer * @param {number} [collisionLayer] - Layer to use for collision if any * @param {boolean} [draw] - Should the layer be drawn automatically * @return {Array<TileCollisionLayer>} * @memberof TileLayers */ function tileLayersLoad(tileMapData, tileInfo=tile(), renderOrder=0, collisionLayer, draw=true) { if (!tileMapData) { // default level data if loading failed const s = 50; tileMapData = {}; tileMapData.height = tileMapData.width = s; tileMapData.layers = [{}]; tileMapData.layers[0].data = new Array(s*s).fill(0); } // validate the tile map data ASSERT(tileMapData.width && tileMapData.height); ASSERT(tileMapData.layers && tileMapData.layers.length); // create tile layers and fill with data const tileLayers = []; const levelSize = vec2(tileMapData.width, tileMapData.height); const layerCount = tileMapData.layers.length; for (let layerIndex=layerCount; layerIndex--;) { const dataLayer = tileMapData.layers[layerIndex]; ASSERT(dataLayer.data && dataLayer.data.length); ASSERT(levelSize.area() === dataLayer.data.length); const layerRenderOrder = renderOrder - (layerCount - 1 - layerIndex); const tileLayer = new TileCollisionLayer(vec2(), levelSize, tileInfo, layerRenderOrder); tileLayers[layerIndex] = tileLayer; // apply layer color const layerColor = dataLayer.tintcolor ? new Color().setHex(dataLayer.tintcolor) : dataLayer.color || WHITE; ASSERT(isColor(layerColor), 'layer color is not a color'); for (let x=levelSize.x; x--;) for (let y=levelSize.y; y--;) { const pos = vec2(x, levelSize.y-1-y); const data = dataLayer.data[x + y*levelSize.x]; if (data) { const layerData = new TileLayerData(data-1, 0, false, layerColor); tileLayer.setData(pos, layerData); // set collision for top layer if (layerIndex === collisionLayer) tileLayer.setCollisionData(pos, 1); } } if (draw) tileLayer.redraw(); } return tileLayers; } /////////////////////////////////////////////////////////////////////////////// /** * Tile layer data object stores info about how to draw a tile * @memberof TileLayers * @example * // create tile layer data with tile index 0 and random orientation and color * const tileIndex = 0; * const direction = randInt(4) * const mirror = randInt(2); * const color = randColor(); * const data = new TileLayerData(tileIndex, direction, mirror, color); */ class TileLayerData { /** Create a tile layer data object, one for each tile in a TileLayer * @param {number} [tile] - The tile to use, untextured if undefined * @param {number} [direction] - Integer direction of tile, in 90 degree increments * @param {boolean} [mirror] - If the tile should be mirrored along the x axis * @param {Color} [color] - Color of the tile */ constructor(tile, direction=0, mirror=false, color=new Color) { /** @property {number} - The tile to use, untextured if undefined */ this.tile = tile; /** @property {number} - Integer direction of tile, in 90 degree increments */ this.direction = direction; /** @property {boolean} - If the tile should be mirrored along the x axis */ this.mirror = mirror; /** @property {Color} - Color of the tile */ this.color = color.copy(); } /** Set this tile to clear, it will not be rendered */ clear() { this.tile = this.direction = 0; this.mirror = false; this.color = new Color; } } /////////////////////////////////////////////////////////////////////////////// /** * Canvas Layer - cached off screen rendering system * - Contains an offscreen canvas that can be rendered to * - WebGL rendering is optional, call updateWebGL to enable/update * @extends EngineObject * @memberof TileLayers * @example * const canvasLayer = new CanvasLayer(vec2(), vec2(200,100)); */ class CanvasLayer extends EngineObject { /** Create a canvas layer object * @param {Vector2} [pos] - World space position of the layer * @param {Vector2} [size] - World space size of the layer * @param {number} [angle] - Angle the layer is rotated by * @param {number} [renderOrder] - Objects sorted by renderOrder * @param {Vector2} [canvasSize] - Default size of canvas, can be changed later * @param {boolean} [useWebGL] - Should this layer use WebGL for rendering */ constructor(pos, size, angle=0, renderOrder=0, canvasSize=vec2(512), useWebGL=true) { ASSERT(isVector2(canvasSize), 'canvasSize must be a Vector2'); super(pos, size, undefined, angle, WHITE, renderOrder); /** @property {HTMLCanvasElement} - The canvas used by this layer */ this.canvas = headlessMode ? undefined : new OffscreenCanvas(canvasSize.x, canvasSize.y); /** @property {OffscreenCanvasRenderingContext2D} - The 2D canvas context used by this layer */ this.context = this.canvas?.getContext('2d'); /** @property {TextureInfo} - Texture info to use for this object rendering */ this.textureInfo = new TextureInfo(this.canvas, useWebGL); // disable physics by default this.mass = 0; } /** Destroy this canvas layer */ destroy() { if (this.destroyed) return; this.textureInfo.destroyWebGLTexture(); super.destroy(); } // Render the layer, called automatically by the engine render() { this.draw(this.pos, this.size, this.color, this.angle, this.mirror, this.additiveColor); } /** Draw this canvas layer centered in world space, with color applied if using WebGL * @param {Vector2} pos - Center in world space * @param {Vector2} [size] - Size in world space * @param {Color} [color] - Color to modulate with * @param {number} [angle] - Angle to rotate by * @param {boolean} [mirror] - If true image is flipped along the Y axis * @param {Color} [additiveColor] - Additive color to be applied if any * @param {boolean} [screenSpace] - If true the pos and size are in screen space * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to * @memberof Draw */ draw(pos, size, color=WHITE, angle=0, mirror=false, additiveColor, screenSpace=false, context) { // draw the canvas layer as a single tile that uses the whole texture const tileInfo = new TileInfo().setFullImage(this.textureInfo); const useWebGL = this.hasWebGL(); drawTile(pos, size, tileInfo, color, angle, mirror, additiveColor, useWebGL, screenSpace, context); } /** Draw a tile onto the layer canvas in world space * @param {Vector2} pos * @param {Vector2} [size=vec2(1)] * @param {TileInfo} [tileInfo] * @param {Color} [color=WHITE] * @param {number} [angle] * @param {boolean} [mirror] */ drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false) { pos = pos.subtract(this.pos).multiply(this.tileInfo.size); size = size.multiply(this.tileInfo.size); pos.y = this.canvas.height - pos.y; // draw the tile onto the layer canvas const oldMainCanvasSize = mainCanvasSize; mainCanvasSize = vec2(this.canvas.width, this.canvas.height); const useWebGL = this.hasWebGL(); useWebGL && glSetRenderTarget(this.textureInfo.glTexture); const drawContext = useWebGL ? undefined : this.context; drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext); useWebGL && glSetRenderTarget(); mainCanvasSize = oldMainCanvasSize; } /** Draw a rectangle onto the layer canvas in world space * @param {Vector2} pos * @param {Vector2} [size=vec2(1)] * @param {Color} [color=WHITE] * @param {number} [angle] */ drawRect(pos, size, color, angle) { this.drawTile(pos, size, undefined, color, angle); } /** Create WebGL texture if necessary and copy layer canvas to it */ updateWebGL() { this.textureInfo.createWebGLTexture(); } /** Check if this layer is using WebGL * @return {boolean} */ hasWebGL() { return glEnable && this.textureInfo.hasWebGL(); } } /////////////////////////////////////////////////////////////////////////////// /** * Tile Layer - cached rendering system for tile layers * - Each Tile layer is rendered to an off screen canvas * - To allow dynamic modifications, layers are rendered using canvas 2d * - Some devices like mobile phones are limited to 4k texture resolution * - For with 16x16 tiles this limits layers to 256x256 on mobile devices * - Tile layers are centered on their corner, so normal levels are at (0,0) * @extends CanvasLayer * @memberof TileLayers * @example * const tileLayer = new TileLayer(vec2(), vec2(200,100)); */ class TileLayer extends CanvasLayer { /** Create a tile layer object * @param {Vector2} pos - World space position * @param {Vector2} size - World space size * @param {TileInfo} [tileInfo] - Default tile info for layer (used for size and texture) * @param {number} [renderOrder] - Objects are sorted by renderOrder * @param {boolean} [useWebGL] - Should this layer use WebGL for rendering */ constructor(pos, size, tileInfo=tile(), renderOrder=0, useWebGL=true) { const canvasSize = tileInfo ? size.multiply(tileInfo.size) : size; super(pos, size, 0, renderOrder, canvasSize, useWebGL); /** @property {TileInfo} - Default tile info for layer */ this.tileInfo = undefined; /** @property {Array<TileLayerData>} - Default tile info for layer */ this.data = []; /** @property {boolean} - Is this layer using a webgl texture? */ this.isUsingWebGL = false; if (headlessMode) { // disable rendering in headless mode this.render = ()=> {}; this.redraw = ()=> {}; this.redrawStart = ()=> {}; this.redrawEnd = ()=> {}; this.drawTileData = ()=> {}; this.redrawTileData = ()=> {}; this.drawLayerTile = ()=> {}; this.drawLayerRect = ()=> {}; this.clearLayerRect = ()=> {}; return; } if (tileInfo) { // set tile info this.tileInfo = tileInfo.frame(0); this.tileInfo.bleed = 0; // disable bleed for tile layers } // init tile data for (let j = this.size.area(); j--;) this.data.push(new TileLayerData); } /** Set data at a given position in the array * @param {Vector2} layerPos - Local position in array * @param {TileLayerData} data - Data to set * @param {boolean} [redraw] - Force the tile to redraw if true */ setData(layerPos, data, redraw=false) { layerPos = layerPos.floor(); ASSERT(isVector2(layerPos), 'layerPos must be a Vector2'); ASSERT(data instanceof TileLayerData, 'data must be a TileLayerData'); if (!layerPos.arrayCheck(this.size)) return; this.data[(layerPos.y|0)*this.size.x+layerPos.x|0] = data; if (!redraw) return; const isRedraw = drawContext === this.context; isRedraw ? this.drawTileData(layerPos) : this.redrawTileData(layerPos); } /** Clear data at a given position in the array * @param {Vector2} layerPos - Local position in array * @param {boolean} [redraw] - Force the tile to redraw if true */ clearData(layerPos, redraw=false) { this.setData(layerPos, new TileLayerData, redraw) } /** Get data at a given position in the array * @param {Vector2} layerPos - Local position in array * @return {TileLayerData} */ getData(layerPos) { ASSERT(isVector2(layerPos), 'layerPos must be a Vector2'); return layerPos.arrayCheck(this.size) && this.data[(layerPos.y|0)*this.size.x+layerPos.x|0]; } // Update the tile layer, refresh texture if needed update() { if (!glEnable && this.isUsingWebGL) { // redraw the layer if webgl was disabled or context lost this.isUsingWebGL = false; this.redraw(); } } // Render the tile layer, called automatically by the engine render() { ASSERT(drawContext !== this.context, 'must call redrawEnd() after drawing tiles!'); const size = this.drawSize || this.size; const pos = this.pos.add(size.scale(.5)); this.draw(pos, this.size, this.color, this.angle, this.mirror, this.additiveColor); } /** Called after this layer is redrawn, does nothing by default */ onRedraw() {} /** Draw all the tile data to an offscreen canvas * - This may be slow if not using webgl but only needs to be done once */ redraw() { this.redrawStart(true); for (let x = this.size.x; x--;) for (let y = this.size.y; y--;) this.drawTileData(vec2(x,y), false); this.isUsingWebGL && glFlush(); this.onRedraw(); this.redrawEnd(); } /** Call to start the redraw process * - This can be used to manually update parts of the level * @param {boolean} [clear] - Should it clear the canvas before drawing */ redrawStart(clear=false) { if (!this.context) return; ASSERT(drawContext !== this.context); // save current render settings /** @type {[CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D, Vector2, Vector2, number, Color]} */ this.savedRenderSettings = [drawContext, mainCanvasSize, cameraPos, cameraScale, canvasClearColor]; // set the draw canvas and context to this layer // use camera settings to match this layer's canvas drawContext = this.context; const tileSize = this.tileInfo?.size ?? vec2(1); mainCanvasSize = this.size.multiply(tileSize); canvasClearColor = CLEAR_BLACK; cameraPos = this.size.multiply(tileSize).scale(.5); cameraScale = 1; // set render target to this layer this.isUsingWebGL = this.hasWebGL(); if (this.isUsingWebGL) glSetRenderTarget(this.textureInfo.glTexture, clear); else { // disable smoothing for pixel art this.context.imageSmoothingEnabled = !tilesPixelated; if (clear) { // clear and set size this.canvas.width = mainCanvasSize.x; this.canvas.height = mainCanvasSize.y; } } } /** Call to end the redraw process */ redrawEnd() { if (!this.context) return; ASSERT(drawContext === this.context); // set stuff back to normal if (this.isUsingWebGL) glSetRenderTarget(); [drawContext, mainCanvasSize, cameraPos, cameraScale, canvasClearColor] = this.savedRenderSettings; } /** Draw the tile at a given position in the tile layer * This can be used to clear out tiles when they are destroyed * Tiles can also be redrawn if inside a redrawStart/End block * @param {Vector2} layerPos * @param {boolean} [clear] - should the old tile be cleared out */ drawTileData(layerPos, clear=true) { if (!this.context) return; ASSERT(drawContext === this.context, 'must call redrawStart() before drawing tiles'); // clear out where the tile was, can be skipped for fully opaque tiles const drawSize = this.tileInfo?.size ?? vec2(1); const drawPos = layerPos.multiply(drawSize); clear && this.clearLayerRect(drawPos, drawSize); // draw the tile if it has layer data const d = this.getData(layerPos); if (!d.tile) return; const tileInfo = this.tileInfo && this.tileInfo.tile(d.tile); this.drawLayerTile(drawPos, drawSize, tileInfo, d.color, d.direction*PI/2, d.mirror); } /** Draw the tile at a given position in the tile layer * This can be used to clear tiles when they are destroyed * For better performance use drawTileData inside a redrawStart/End block * @param {Vector2} layerPos * @param {boolean} [clear] - should the old tile be cleared */ redrawTileData(layerPos, clear=true) { if (!this.context) return; ASSERT(drawContext !== this.context, 'redrawStart() should not be active when calling redrawTileData(), instead use drawTileData()'); this.redrawStart(); this.drawTileData(layerPos, clear); this.redrawEnd(); } /** Draw textured tile in layer space * @param {Vector2} pos - Position in pixel coordinates * @param {Vector2} [size=vec2(1)] - Size of the tile * @param {TileInfo} [tileInfo] - Tile info to use, untextured if undefined * @param {Color} [color=WHITE] - Color to modulate with * @param {number} [angle] - Angle to rotate by * @param {boolean} [mirror] - Is image flipped along the Y axis? * @param {Color} [additiveColor] - Additive color to be applied if any */ drawLayerTile(pos, size=vec2(1), tileInfo, color=WHITE, angle=0, mirror, additiveColor) { const drawPos = pos.add(size.scale(.5)); drawTile(drawPos, size, tileInfo, color, angle, mirror, additiveColor, this.isUsingWebGL); } /** Clear a rectangle in layer space * @param {Vector2} pos * @param {Vector2} size * @param {Color} [color=WHITE] - Color to modulate with * @param {number} [angle] - Angle to rotate by */ drawLayerRect(pos, size, color, angle=0) { this.drawLayerTile(pos, size, undefined, color, angle); } /** Clear a rectangle in layer space * @param {Vector2} pos - position in pixel coordinates * @param {Vector2} size */ clearLayerRect(pos, size) { ASSERT(drawContext === this.context, 'must call redrawStart() before clearing tiles'); const x = pos.x, y = this.canvas.height - pos.y - size.y; const useWebGL = this.hasWebGL(); if (useWebGL) glClearRect(x, y, size.x, size.y); else this.context.clearRect(x, y, size.x, size.y); } } /////////////////////////////////////////////////////////////////////////////// /** * Tile Collision Layer - a tile layer with collision * - adds collision data and functions to TileLayer * - there can be multiple tile collision layers * @extends TileLayer * @memberof TileLayers */ class TileCollisionLayer extends TileLayer { /** Create a tile layer object * @param {Vector2} pos - World space position * @param {Vector2} size - World space size * @param {TileInfo} [tileInfo] - Tile info for layer * @param {number} [renderOrder] - Objects are sorted by renderOrder * @param {boolean} [useWebGL] - Should this layer use WebGL for rendering */ constructor(pos, size, tileInfo=tile(), renderOrder=0, useWebGL=true) { super(pos, size.floor(), tileInfo, renderOrder, useWebGL); /** @property {Array<number>} - The tile collision grid */ this.collisionData = []; this.initCollision(this.size); // keep track of all collision layers tileCollisionLayers.push(this); // tile collision layers are solid by default this.isSolid = true; } /** Destroy this tile layer */ destroy() { if (this.destroyed) return; // remove from collision layers array and destroy const index = tileCollisionLayers.indexOf(this); ASSERT(index >= 0, 'tile collision layer not found in array'); index >= 0 && tileCollisionLayers.splice(index, 1); super.destroy(); } /** Clear and initialize tile collision to new size * @param {Vector2} size - width and height of tile collision 2d grid */ initCollision(size) { ASSERT(isVector2(size), 'size must be a Vector2'); this.size = size.floor(); this.collisionData = []; this.collisionData.length = size.area(); this.collisionData.fill(0); } /** Set tile collision data for a given cell in the layer * @param {Vector2} layerPos * @param {number} [data] */ setCollisionData(layerPos, data=1) { ASSERT(isVector2(layerPos), 'layerPos must be a Vector2'); const i = (layerPos.y|0)*this.size.x + layerPos.x|0; layerPos.arrayCheck(this.size) && (this.collisionData[i] = data); } /** Clear tile collision data for a given cell in the layer * @param {Vector2} layerPos */ clearCollisionData(layerPos) { this.setCollisionData(layerPos, 0); } /** Get tile collision data for a given cell in the layer * @param {Vector2} layerPos * @return {number} */ getCollisionData(layerPos) { ASSERT(isVector2(layerPos), 'layerPos must be a Vector2'); const i = (layerPos.y|0)*this.size.x + layerPos.x|0; return layerPos.arrayCheck(this.size) ? this.collisionData[i] : 0; } /** Check if collision with another object should occur * @param {Vector2} pos * @param {Vector2} [size=vec2()] * @param {EngineObject|TileCollisionCallback} [callbackObject] - Callback, engine object, or undefined * @return {boolean} */ collisionTest(pos, size=new Vector2, callbackObject) { ASSERT(isVector2(pos) && isVector2(size), 'pos and size must be Vector2s'); ASSERT(!callbackObject || typeof callbackObject === 'function' || callbackObject instanceof EngineObject, 'callbackObject must be a function or EngineObject'); // make function to check for collision const collisionTest = callbackObject ? typeof callbackObject === 'function' ? (tileData, pos)=> callbackObject(tileData, pos) : (tileData, pos)=> callbackObject.collideWithTile(tileData, pos) : ()=> true; // check any tiles in the area for collision const posX = pos.x - this.pos.x; const posY = pos.y - this.pos.y; const minX = max(posX - size.x/2|0, 0); const minY = max(posY - size.y/2|0, 0); const maxX = min(posX + size.x/2, this.size.x); const maxY = min(posY + size.y/2, this.size.y); const hitPos = new Vector2; for (let y = minY; y < maxY; ++y) for (let x = minX; x < maxX; ++x) { // check if the object should collide with this tile const tileData = this.collisionData[y*this.size.x+x]; if (tileData && collisionTest(tileData, hitPos.set(x+this.pos.x, y+this.pos.y))) return true; } return false; } /** Return the exact position of the boundary of first tile hit, undefined if nothing was hit. * The point will be inside the colliding tile if it hits (may have a tiny shift) * @param {Vector2} posStart * @param {Vector2} posEnd * @param {EngineObject|TileCollisionCallback} [callbackObject] - Callback, engine object, or undefined * @param {Vector2} [normal] - Optional normal of the surface hit * @return {Vector2|undefined} */ collisionRaycast(posStart, posEnd, callbackObject, normal) { ASSERT(isVector2(posStart) && isVector2(posEnd), 'positions must be Vector2s'); ASSERT(!callbackObject || typeof callbackObject === 'function' || callbackObject instanceof EngineObject, 'callbackObject must be a function or EngineObject'); // make function to check for collision const collisionTest = callbackObject ? typeof callbackObject === 'function' ? (tileData, pos)=> callbackObject(tileData, pos) : (tileData, pos)=> callbackObject.collideWithTile(tileData, pos) : (tileData)=> tileData > 0; const testFunction = (pos)=> { const tileData = this.getCollisionData(localPos.set(pos.x - this.pos.x, pos.y - this.pos.y)); return tileData && collisionTest(tileData, pos); } // use line test against tile collision const localPos = new Vector2; const hitPos = lineTest(posStart, posEnd, testFunction, normal); if (debugRaycast && hitPos) { const tilePos = hitPos.floor().add(vec2(.5)); debugRect(tilePos, vec2(1), '#f008'); debugLine(posStart, posEnd, '#00f', .02); debugLine(posStart, hitPos, '#f00', .02); debugPoint(hitPos, '#0f0'); normal && debugLine(hitPos, hitPos.add(normal), '#ff0', .02); } return hitPos; } }