UNPKG

litecanvas

Version:

Lightweight HTML5 canvas 2D game engine suitable for small projects and creative coding. Inspired by PICO-8 and P5/Processing.

1,495 lines (1,316 loc) 51.1 kB
import { zzfx } from './zzfx.js' import { colors } from './palette.js' import { assert } from './dev.js' import './types.js' /** * The litecanvas constructor * * @param {LitecanvasOptions} [settings] * @returns {LitecanvasInstance} */ export default function litecanvas(settings = {}) { const root = globalThis, math = Math, TWO_PI = math.PI * 2, raf = requestAnimationFrame, /** @type {Function[]} */ _browserEventListeners = [], /** @type {(elem:HTMLElement, evt:string, callback:(event:Event)=>void)=>void} */ on = (elem, evt, callback) => { elem.addEventListener(evt, callback, false) _browserEventListeners.push(() => elem.removeEventListener(evt, callback, false) ) }, isNumber = Number.isFinite, /** @type {LitecanvasOptions} */ defaults = { width: null, height: null, autoscale: true, pixelart: false, antialias: false, canvas: null, global: true, loop: null, pauseOnBlur: true, tapEvents: true, keyboardEvents: true, animate: true, } // setup the settings default values settings = Object.assign(defaults, settings) let /** @type {boolean} */ _initialized = false, /** @type {Function[]} */ _plugins = [], /** @type {HTMLCanvasElement|string} _canvas */ _canvas, /** @type {number} */ _scale = 1, /** @type {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} */ _ctx, /** @type {number} */ _outline_fix = 0.5, /** @type {number} */ _timeScale = 1, /** @type {number} */ _lastFrameTime, /** @type {number} duration of a frame at 60 FPS (default) */ _deltaTime = 1 / 60, /** @type {number} */ _accumulated = 0, /** @type {number} */ _rafid, /** @type {string} */ _fontFamily = 'sans-serif', /** @type {number} */ _fontSize = 20, /** @type {number} */ _rng_seed = Date.now(), /** * default game events * @type {Object<string,Set<Function>>} */ _events = { init: false, update: false, draw: false, resized: false, tap: false, untap: false, tapping: false, tapped: false, }, /** * Helpers to be used by plugins * * @type {LitecanvasPluginHelpers} */ _helpers = { settings: Object.assign({}, settings), colors, } /** @type {LitecanvasInstance} */ const instance = { /** @type {number} */ WIDTH: 0, /** @type {number} */ HEIGHT: 0, /** @type {HTMLCanvasElement} */ CANVAS: false, /** @type {number} */ ELAPSED: 0, /** @type {number} */ CENTERX: 0, /** @type {number} */ CENTERY: 0, /** @type {number} */ MOUSEX: -1, /** @type {number} */ MOUSEY: -1, /** @type {number[]} */ DEFAULT_SFX: [0.5, 0, 1750, , , 0.3, 1, , , , 600, 0.1], /** MATH API */ /** * Twice the value of the mathematical constant PI (π). * Approximately 6.28318 * * Note: TWO_PI radians equals 360°, PI radians equals 180°, * HALF_PI radians equals 90°, and HALF_PI/2 radians equals 45°. * * @type {number} */ TWO_PI, /** * Half the value of the mathematical constant PI (π). * Approximately 1.57079 * * @type {number} */ HALF_PI: math.PI / 2, /** * Calculates a linear (interpolation) value over t%. * * @param {number} start * @param {number} end * @param {number} t The progress in percentage, where 0 = 0% and 1 = 100%. * @returns {number} The unterpolated value * @tutorial https://gamedev.net/tutorials/programming/general-and-gameplay-programming/a-brief-introduction-to-lerp-r4954/ */ lerp: (start, end, t) => { DEV: assert(isNumber(start), 'lerp: 1st param must be a number') DEV: assert(isNumber(end), 'lerp: 2nd param must be a number') DEV: assert(isNumber(t), 'lerp: 3rd param must be a number') return t * (end - start) + start }, /** * Convert degrees to radians * * @param {number} degs * @returns {number} the value in radians */ deg2rad: (degs) => { DEV: assert(isNumber(degs), 'deg2rad: 1st param must be a number') return (math.PI / 180) * degs }, /** * Convert radians to degrees * * @param {number} rads * @returns {number} the value in degrees */ rad2deg: (rads) => { DEV: assert(isNumber(rads), 'rad2deg: 1st param must be a number') return (180 / math.PI) * rads }, /** * Returns the rounded value of an number to optional precision (number of digits after the decimal point). * * Note: precision is optional but must be >= 0 * * @param {number} n number to round. * @param {number} [precision] number of decimal digits to round to, default is 0. * @returns {number} rounded number. */ round: (n, precision = 0) => { DEV: assert(isNumber(n), 'round: 1st param must be a number') DEV: assert( null == precision || (isNumber(precision) && precision >= 0), 'round: 2nd param must be a positive number or zero' ) if (!precision) { return math.round(n) } const multiplier = 10 ** precision return math.round(n * multiplier) / multiplier }, /** * Constrains a number between `min` and `max`. * * @param {number} value * @param {number} min * @param {number} max * @returns {number} */ clamp: (value, min, max) => { DEV: assert(isNumber(value), 'clamp: 1st param must be a number') DEV: assert(isNumber(min), 'clamp: 2nd param must be a number') DEV: assert(isNumber(max), 'clamp: 3rd param must be a number') DEV: assert( max > min, 'randi: the 2nd param must be less than the 3rd param' ) if (value < min) return min if (value > max) return max return value }, /** * Wraps a number between `min` (inclusive) and `max` (exclusive). * * @param {number} value * @param {number} min * @param {number} max * @returns {number} */ wrap: (value, min, max) => { DEV: assert(isNumber(value), 'wrap: 1st param must be a number') DEV: assert(isNumber(min), 'wrap: 2nd param must be a number') DEV: assert(isNumber(max), 'wrap: 3rd param must be a number') DEV: assert( max > min, 'randi: the 2nd param must be less than the 3rd param' ) DEV: assert( max !== min, 'randi: the 2nd param must be not equal to the 3rd param' ) return value - (max - min) * math.floor((value - min) / (max - min)) }, /** * Re-maps a number from one range to another. * * @param {number} value the value to be remapped. * @param {number} start1 lower bound of the value's current range. * @param {number} stop1 upper bound of the value's current range. * @param {number} start2 lower bound of the value's target range. * @param {number} stop2 upper bound of the value's target range. * @param {boolean} [withinBounds=false] constrain the value to the newly mapped range * @returns {number} the remapped number */ map(value, start1, stop1, start2, stop2, withinBounds) { DEV: assert(isNumber(value), 'map: 1st param must be a number') DEV: assert(isNumber(start1), 'map: 2nd param must be a number') DEV: assert(isNumber(stop1), 'map: 3rd param must be a number') DEV: assert(isNumber(start2), 'map: 4th param must be a number') DEV: assert(isNumber(stop2), 'map: 5th param must be a number') // prettier-ignore const result = ((value - start1) / (stop1 - start1)) * (stop2 - start2) + start2 return withinBounds ? instance.clamp(result, start2, stop2) : result }, /** * Maps a number from one range to a value between 0 and 1. * Identical to `map(value, min, max, 0, 1)`. * Note: Numbers outside the range are not clamped to 0 and 1. * * @param {number} value * @param {number} start * @param {number} stop * @returns {number} the normalized number. */ norm: (value, start, stop) => { DEV: assert(isNumber(value), 'norm: 1st param must be a number') DEV: assert(isNumber(start), 'norm: 2nd param must be a number') DEV: assert(isNumber(stop), 'norm: 3rd param must be a number') return instance.map(value, start, stop, 0, 1) }, /** RNG API */ /** * Generates a pseudorandom float between min (inclusive) and max (exclusive) * using the Linear Congruential Generator (LCG) algorithm. * * @param {number} [min=0.0] * @param {number} [max=1.0] * @returns {number} the random number */ rand: (min = 0.0, max = 1.0) => { DEV: assert(isNumber(min), 'rand: 1st param must be a number') DEV: assert(isNumber(max), 'rand: 2nd param must be a number') DEV: assert( max > min, 'rand: the 1st param must be less than the 2nd param' ) const a = 1664525 const c = 1013904223 const m = 4294967296 _rng_seed = (a * _rng_seed + c) % m return (_rng_seed / m) * (max - min) + min }, /** * Generates a pseudorandom integer between min (inclusive) and max (inclusive) * * @param {number} [min=0] * @param {number} [max=1] * @returns {number} the random number */ randi: (min = 0, max = 1) => { DEV: assert(isNumber(min), 'randi: 1st param must be a number') DEV: assert(isNumber(max), 'randi: 2nd param must be a number') DEV: assert( max > min, 'randi: the 1st param must be less than the 2nd param' ) return math.floor(instance.rand(min, max + 1)) }, /** * If a value is passed, initializes the random number generator with an explicit seed value. * Otherwise, returns the current seed state. * * @param {number} value * @returns {number} the seed state */ seed: (value) => { DEV: assert( null == value || (isNumber(value) && value >= 0), 'seed: 1st param must be a positive number or zero' ) return null == value ? _rng_seed : (_rng_seed = ~~value) }, /** BASIC GRAPHICS API */ /** * Clear the game screen with an optional color * * @param {number} [color] The background color (index) or null/undefined (for transparent) */ cls(color) { DEV: assert( null == color || (isNumber(color) && color >= 0), 'cls: 1st param must be a positive number or zero or undefined' ) if (null == color) { _ctx.clearRect(0, 0, _ctx.canvas.width, _ctx.canvas.height) } else { instance.rectfill( 0, 0, _ctx.canvas.width, _ctx.canvas.height, color ) } }, /** * Draw a rectangle outline * * @param {number} x * @param {number} y * @param {number} width * @param {number} height * @param {number} [color=0] the color index * @param {number|number[]} [radii] A number or list specifying the radii used to draw a rounded-borders rectangle */ rect(x, y, width, height, color, radii) { DEV: assert(isNumber(x), 'rect: 1st param must be a number') DEV: assert(isNumber(y), 'rect: 2nd param must be a number') DEV: assert( isNumber(width) && width > 0, 'rect: 3rd param must be a positive number' ) DEV: assert( isNumber(height) && height >= 0, 'rect: 4th param must be a positive number or zero' ) DEV: assert( null == color || (isNumber(color) && color >= 0), 'rect: 5th param must be a positive number or zero' ) DEV: assert( null == radii || isNumber(radii) || (Array.isArray(radii) && radii.length >= 1), 'rect: 6th param must be a number or array of numbers' ) _ctx.beginPath() _ctx[radii ? 'roundRect' : 'rect']( ~~x - _outline_fix, ~~y - _outline_fix, ~~width + _outline_fix * 2, ~~height + _outline_fix * 2, radii ) instance.stroke(color) }, /** * Draw a color-filled rectangle * * @param {number} x * @param {number} y * @param {number} width * @param {number} height * @param {number} [color=0] the color index * @param {number|number[]} [radii] A number or list specifying the radii used to draw a rounded-borders rectangle */ rectfill(x, y, width, height, color, radii) { DEV: assert(isNumber(x), 'rectfill: 1st param must be a number') DEV: assert(isNumber(y), 'rectfill: 2nd param must be a number') DEV: assert( isNumber(width) && width >= 0, 'rectfill: 3rd param must be a positive number or zero' ) DEV: assert( isNumber(height) && height >= 0, 'rectfill: 4th param must be a positive number or zero' ) DEV: assert( null == color || (isNumber(color) && color >= 0), 'rectfill: 5th param must be a positive number or zero' ) DEV: assert( null == radii || isNumber(radii) || (Array.isArray(radii) && radii.length >= 1), 'rectfill: 6th param must be a number or array of at least 2 numbers' ) _ctx.beginPath() _ctx[radii ? 'roundRect' : 'rect']( ~~x, ~~y, ~~width, ~~height, radii ) instance.fill(color) }, /** * Draw a circle outline * * @param {number} x * @param {number} y * @param {number} radius * @param {number} [color=0] the color index */ circ(x, y, radius, color) { DEV: assert(isNumber(x), 'circ: 1st param must be a number') DEV: assert(isNumber(y), 'circ: 2nd param must be a number') DEV: assert( isNumber(radius) && radius >= 0, 'circ: 3rd param must be a positive number or zero' ) DEV: assert( null == color || (isNumber(color) && color >= 0), 'circ: 4th param must be a positive number or zero' ) _ctx.beginPath() _ctx.arc(~~x, ~~y, ~~radius, 0, TWO_PI) instance.stroke(color) }, /** * Draw a color-filled circle * * @param {number} x * @param {number} y * @param {number} radius * @param {number} [color=0] the color index */ circfill(x, y, radius, color) { DEV: assert(isNumber(x), 'circfill: 1st param must be a number') DEV: assert(isNumber(y), 'circfill: 2nd param must be a number') DEV: assert( isNumber(radius) && radius >= 0, 'circfill: 3rd param must be a positive number or zero' ) DEV: assert( null == color || (isNumber(color) && color >= 0), 'circfill: 4th param must be a positive number or zero' ) _ctx.beginPath() _ctx.arc(~~x, ~~y, ~~radius, 0, TWO_PI) instance.fill(color) }, /** * Draw a line * * @param {number} x1 * @param {number} y1 * @param {number} x2 * @param {number} y2 * @param {number} [color=0] the color index */ line(x1, y1, x2, y2, color) { DEV: assert(isNumber(x1), 'line: 1st param must be a number') DEV: assert(isNumber(y1), 'line: 2nd param must be a number') DEV: assert( isNumber(x2), 'line: 3rd param must be a positive number or zero' ) DEV: assert( isNumber(y2), 'line: 4th param must be a positive number or zero' ) DEV: assert( null == color || (isNumber(color) && color >= 0), 'line: 5th param must be a positive number or zero' ) _ctx.beginPath() let xfix = _outline_fix !== 0 && ~~x1 === ~~x2 ? 0.5 : 0 let yfix = _outline_fix !== 0 && ~~y1 === ~~y2 ? 0.5 : 0 _ctx.moveTo(~~x1 + xfix, ~~y1 + yfix) _ctx.lineTo(~~x2 + xfix, ~~y2 + yfix) instance.stroke(color) }, /** * Sets the thickness of lines * * @param {number} value * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineWidth */ linewidth(value) { DEV: assert( isNumber(value) && ~~value > 0, 'linewidth: 1st param must be a positive number' ) _ctx.lineWidth = ~~value _outline_fix = 0 === ~~value % 2 ? 0 : 0.5 }, /** * Sets the line dash pattern used when drawing lines * * @param {number[]} segments the line dash pattern * @param {number} [offset=0] the line dash offset, or "phase". * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/setLineDash * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineDashOffset */ linedash(segments, offset = 0) { DEV: assert( Array.isArray(segments) && segments.length > 0, 'linedash: 1st param must be an array of numbers' ) DEV: assert( isNumber(offset), 'linedash: 2nd param must be a number' ) _ctx.setLineDash(segments) _ctx.lineDashOffset = offset }, /** TEXT RENDERING API */ /** * Draw text * * @param {number} x * @param {number} y * @param {string} message the text message * @param {number} [color=3] the color index * @param {string} [fontStyle] can be "normal" (default), "italic" and/or "bold". */ text(x, y, message, color = 3, fontStyle = 'normal') { DEV: assert(isNumber(x), 'text: 1st param must be a number') DEV: assert(isNumber(y), 'text: 2nd param must be a number') // DEV: assert( // 'string' === typeof message, // 'text: 3rd param must be a string' // ) DEV: assert( null == color || (isNumber(color) && color >= 0), 'text: 4th param must be a positive number or zero' ) DEV: assert( 'string' === typeof fontStyle, 'text: 5th param must be a string' ) _ctx.font = `${fontStyle} ${_fontSize}px ${_fontFamily}` _ctx.fillStyle = instance.getcolor(color) _ctx.fillText(message, ~~x, ~~y) }, /** * Set the font family * * @param {string} family */ textfont(family) { DEV: assert( 'string' === typeof family, 'textfont: 1st param must be a string' ) _fontFamily = family }, /** * Set the font size * * @param {number} size */ textsize(size) { DEV: assert(isNumber(size), 'textsize: 1st param must be a number') _fontSize = size }, /** * Sets the alignment used when drawing texts * * @param {string} align the horizontal alignment. Possible values: "left", "right", "center", "start" or "end" * @param {string} baseline the vertical alignment. Possible values: "top", "bottom", "middle", "hanging" or "ideographic" * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/textBaseline * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/textAlign */ textalign(align, baseline) { DEV: assert( null == align || ['left', 'right', 'center', 'start', 'end'].includes(align), 'textalign: 1st param must be null or one of the following values: center, left, right, start or end.' ) DEV: assert( null == baseline || [ 'top', 'bottom', 'middle', 'hanging', 'alphabetic', 'ideographic', ].includes(baseline), 'textalign: 2nd param must be null or one of the following values: middle, top, bottom, hanging, alphabetic or ideographic.' ) if (align) _ctx.textAlign = align if (baseline) _ctx.textBaseline = baseline }, /** IMAGE GRAPHICS API */ /** * Draw an image * * @param {number} x * @param {number} y * @param {OffscreenCanvas|HTMLImageElement|HTMLCanvasElement} source */ image(x, y, source) { DEV: assert(isNumber(x), 'image: 1st param must be a number') DEV: assert(isNumber(y), 'image: 2nd param must be a number') _ctx.drawImage(source, ~~x, ~~y) }, /** * Draw in an OffscreenCanvas and returns its image. * * @param {number} width * @param {number} height * @param {string[]|drawCallback} drawing * @param {object} [options] * @param {number} [options.scale=1] * @param {OffscreenCanvas | HTMLCanvasElement} [options.canvas] * @returns {ImageBitmap} * @see https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas */ paint(width, height, drawing, options = {}) { DEV: assert(isNumber(width), 'paint: 1st param must be a number') DEV: assert(isNumber(height), 'paint: 2nd param must be a number') DEV: assert( 'function' === typeof drawing || Array.isArray(drawing), 'paint: 3rd param must be a function or array' ) DEV: assert( (options && !options.scale) || isNumber(options.scale), 'paint: 4th param (options.scale) must be a number' ) const /** @type {OffscreenCanvas} */ canvas = options.canvas || new OffscreenCanvas(1, 1), scale = options.scale || 1, contextOriginal = _ctx canvas.width = width * scale canvas.height = height * scale _ctx = canvas.getContext('2d') _ctx.scale(scale, scale) // draw pixel art if `draw` is a array if (drawing.push) { let x = 0, y = 0 _ctx.imageSmoothingEnabled = false for (const str of drawing) { for (const color of str) { if (' ' !== color && '.' !== color) { // support for 16-color palette using hex (from 0 to f) instance.rectfill(x, y, 1, 1, parseInt(color, 16)) } x++ } y++ x = 0 } } else { drawing(_ctx) } _ctx = contextOriginal // restore the context return canvas.transferToImageBitmap() }, /** ADVANCED GRAPHICS API */ /** * Get or set the canvas context 2D * * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] * @returns {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D */ ctx(context) { if (context) { _ctx = context } return _ctx }, /** * saves the current drawing style settings and transformations * * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/save */ push: () => _ctx.save(), /** * restores the drawing style settings and transformations * * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/restore */ pop: () => _ctx.restore(), /** * Adds a translation to the transformation matrix. * * @param {number} x * @param {number} y */ translate: (x, y) => { DEV: assert(isNumber(x), 'translate: 1st param must be a number') DEV: assert(isNumber(y), 'translate: 2nd param must be a number') return _ctx.translate(~~x, ~~y) }, /** * Adds a scaling transformation to the canvas units horizontally and/or vertically. * * @param {number} x * @param {number} [y] */ scale: (x, y) => { DEV: assert(isNumber(x), 'scale: 1st param must be a number') DEV: assert( null == y || isNumber(y), 'scale: 2nd param must be a number' ) return _ctx.scale(x, y || x) }, /** * Adds a rotation to the transformation matrix. * * @param {number} radians */ rotate: (radians) => { DEV: assert(isNumber(radians), 'rotate: 1st param must be a number') return _ctx.rotate(radians) }, /** * Sets the alpha (opacity) value to apply when drawing new shapes and images * * @param {number} value float from 0 to 1 (e.g: 0.5 = 50% transparent) * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalAlpha */ alpha(value) { DEV: assert(isNumber(value), 'alpha: 1st param must be a number') _ctx.globalAlpha = instance.clamp(value, 0, 1) }, /** * Returns a newly instantiated Path2D object, optionally with another * path as an argument (creates a copy), or optionally with a string * consisting of SVG path data. * * @param {Path2D|string} [arg] * @returns Path2D * @see https://developer.mozilla.org/en-US/docs/Web/API/Path2D/Path2D */ path: (arg) => { DEV: assert( null == arg || 'string' === typeof arg || arg instanceof Path2D, 'path: 1st param must be a string or a Path2D instance' ) return new Path2D(arg) }, /** * Fills the current or given path with a given color. * * @param {number} [color=0] * @param {Path2D} [path] */ fill(color, path) { DEV: assert( null == color || (isNumber(color) && color >= 0), 'fill: 1st param must be a positive number or zero' ) DEV: assert( null == path || path instanceof Path2D, 'fill: 2nd param must be a Path2D instance' ) _ctx.fillStyle = instance.getcolor(color) if (path) { _ctx.fill(path) } else { _ctx.fill() } }, /** * Outlines the current or given path with a given color. * * @param {number} [color=0] * @param {Path2D} [path] */ stroke(color, path) { DEV: assert( null == color || (isNumber(color) && color >= 0), 'stroke: 1st param must be a positive number or zero' ) DEV: assert( null == path || path instanceof Path2D, 'stroke: 2nd param must be a Path2D instance' ) _ctx.strokeStyle = instance.getcolor(color) if (path) { _ctx.stroke(path) } else { _ctx.stroke() } }, /** * Turn given path into a clipping region. * * @param {Path2D} path * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/clip */ clip(path) { DEV: assert( path instanceof Path2D, 'clip: 1st param must be a Path2D instance' ) _ctx.clip(path) }, /** SOUND API */ /** * Play a sound effects using ZzFX library. * If the first argument is omitted, plays an default sound. * * @param {number[]} [zzfxParams] a ZzFX array of params * @param {number} [pitchSlide] a value to increment/decrement the pitch * @param {number} [volumeFactor] the volume factor * @returns {number[] | boolean} The sound that was played or `false` * * @see https://github.com/KilledByAPixel/ZzFX */ sfx(zzfxParams, pitchSlide = 0, volumeFactor = 1) { DEV: assert( null == zzfxParams || Array.isArray(zzfxParams), 'sfx: 1st param must be an array' ) DEV: assert(isNumber(pitchSlide), 'sfx: 2nd param must be a number') DEV: assert( isNumber(volumeFactor), 'sfx: 3rd param must be a number' ) if ( root.zzfxV <= 0 || (navigator.userActivation && !navigator.userActivation.hasBeenActive) ) { return false } zzfxParams = zzfxParams || instance.DEFAULT_SFX // if has other arguments, copy the sound to not change the original if (pitchSlide !== 0 || volumeFactor !== 1) { zzfxParams = zzfxParams.slice() zzfxParams[0] = volumeFactor * (zzfxParams[0] || 1) zzfxParams[10] = ~~zzfxParams[10] + pitchSlide } zzfx.apply(0, zzfxParams) return zzfxParams }, /** * Set the ZzFX's global volume factor. * Note: use 0 to mute all sound effects. * * @param {number} value */ volume(value) { DEV: assert(isNumber(value), 'volume: 1st param must be a number') root.zzfxV = value }, /** PLUGINS API */ /** * Prepares a plugin to be loaded * * @param {pluginCallback} callback */ use(callback, config = {}) { DEV: assert( 'function' === typeof callback, 'use: 1st param must be a function' ) DEV: assert( 'object' === typeof config, 'use: 2nd param must be an object' ) _initialized ? loadPlugin(callback, config) : _plugins.push([callback, config]) }, /** * Add a game event listener * * @param {string} eventName the event type name * @param {Function} callback the function that is called when the event occurs * @returns {Function} a function to remove the listener */ listen(eventName, callback) { DEV: assert( 'string' === typeof eventName, 'listen: 1st param must be a string' ) DEV: assert( 'function' === typeof callback, 'listen: 2nd param must be a function' ) _events[eventName] = _events[eventName] || new Set() _events[eventName].add(callback) // return a function to remove this event listener return () => _events[eventName].delete(callback) }, /** * Call all listeners attached to a game event * * @param {string} eventName The event type name * @param {*} [arg1] any data to be passed over the listeners * @param {*} [arg2] any data to be passed over the listeners * @param {*} [arg3] any data to be passed over the listeners * @param {*} [arg4] any data to be passed over the listeners */ emit(eventName, arg1, arg2, arg3, arg4) { DEV: assert( 'string' === typeof eventName, 'emit: 1st param must be a string' ) if (_initialized) { triggerEvent('before:' + eventName, arg1, arg2, arg3, arg4) triggerEvent(eventName, arg1, arg2, arg3, arg4) triggerEvent('after:' + eventName, arg1, arg2, arg3, arg4) } }, /** * Get a color by index * * @param {number} [index=0] The color number * @returns {string} the color code */ getcolor: (index) => { DEV: assert( null == index || (isNumber(index) && index >= 0), 'getcolor: 1st param must be a number' ) return colors[~~index % colors.length] }, /** * Create or update a instance variable * * @param {string} key * @param {*} value */ setvar(key, value) { DEV: assert( 'string' === typeof key, 'setvar: 1st param must be a string' ) if (null == value) { console.warn(`setvar: key "${key}" was defined as ${value}`) } instance[key] = value if (settings.global) { root[key] = value } }, /** * The scale of the game's delta time (dt). * Values higher than 1 increase the speed of time, while values smaller than 1 decrease it. * A value of 0 freezes time and is effectively equivalent to pausing. * * @param {number} value */ timescale(value) { DEV: assert( isNumber(value), 'timescale: 1st param must be a number' ) _timeScale = value }, /** * Set the target FPS at runtime. * * @param {number} value */ setfps(value) { DEV: assert( isNumber(value) && value >= 1, 'setfps: 1st param must be a positive number' ) _deltaTime = 1 / ~~value }, /** * Stops the litecanvas instance and remove all event listeners. */ quit() { // stop the renderer cancelAnimationFrame(_rafid) // emit "quit" event to manual clean ups instance.emit('quit') // clear all engine events _events = [] // clear all browser events for (const removeListener of _browserEventListeners) { removeListener() } // maybe clear global context if (settings.global) { for (const key in instance) { delete root[key] } delete root.ENGINE } }, } // prettier-ignore for (const k of 'PI,sin,cos,atan2,hypot,tan,abs,ceil,floor,trunc,min,max,pow,sqrt,sign,exp'.split(',')) { // import native Math functions instance[k] = math[k] } function init() { _initialized = true // setup default event listeners const source = settings.loop ? settings.loop : root for (const event in _events) { if (source[event]) instance.listen(event, source[event]) } // load plugins for (const [callback, config] of _plugins) { loadPlugin(callback, config) } // listen window resize event when "autoscale" is enabled if (settings.autoscale) { on(root, 'resize', resizeCanvas) } // default mouse/touch handlers if (settings.tapEvents) { const _getXY = (pageX, pageY) => [ (pageX - _canvas.offsetLeft) / _scale, (pageY - _canvas.offsetTop) / _scale, ], _taps = new Map(), _registerTap = (id, x, y) => { const tap = { x, y, startX: x, startY: y, // timestamp ts: performance.now(), } _taps.set(id, tap) return tap }, _updateTap = (id, x, y) => { const tap = _taps.get(id) || _registerTap(id) tap.x = x tap.y = y }, _checkTapped = (tap) => tap && performance.now() - tap.ts <= 300, preventDefault = (ev) => ev.preventDefault() let _pressingMouse = false on(_canvas, 'mousedown', (ev) => { if (ev.button === 0) { preventDefault(ev) const [x, y] = _getXY(ev.pageX, ev.pageY) instance.emit('tap', x, y, 0) _registerTap(0, x, y) _pressingMouse = true } }) on(_canvas, 'mouseup', (ev) => { if (ev.button === 0) { preventDefault(ev) const tap = _taps.get(0) const [x, y] = _getXY(ev.pageX, ev.pageY) if (_checkTapped(tap)) { instance.emit('tapped', tap.startX, tap.startY, 0) } instance.emit('untap', x, y, 0) _taps.delete(0) _pressingMouse = false } }) on(_canvas, 'mousemove', (ev) => { preventDefault(ev) const [x, y] = _getXY(ev.pageX, ev.pageY) instance.setvar('MOUSEX', x) instance.setvar('MOUSEY', y) if (!_pressingMouse) return instance.emit('tapping', x, y, 0) _updateTap(0, x, y) }) on(_canvas, 'touchstart', (ev) => { preventDefault(ev) /** @type {TouchList} touches */ const touches = ev.changedTouches for (const touch of touches) { const [x, y] = _getXY(touch.pageX, touch.pageY) instance.emit('tap', x, y, touch.identifier + 1) _registerTap(touch.identifier + 1, x, y) } }) on(_canvas, 'touchmove', (ev) => { preventDefault(ev) /** @type {TouchList} touches */ const touches = ev.changedTouches for (const touch of touches) { const [x, y] = _getXY(touch.pageX, touch.pageY) instance.emit('tapping', x, y, touch.identifier + 1) _updateTap(touch.identifier + 1, x, y) } }) const _touchEndHandler = (ev) => { preventDefault(ev) const existing = [] if (ev.targetTouches.length > 0) { for (const touch of ev.targetTouches) { existing.push(touch.identifier + 1) } } for (const [id, tap] of _taps) { if (existing.includes(id)) continue if (_checkTapped(tap)) { instance.emit('tapped', tap.startX, tap.startY, id) } instance.emit('untap', tap.x, tap.y, id) _taps.delete(id) } } on(_canvas, 'touchend', _touchEndHandler) on(_canvas, 'touchcancel', _touchEndHandler) on(root, 'blur', () => { _pressingMouse = false for (const [id, tap] of _taps) { instance.emit('untap', tap.x, tap.y, id) _taps.delete(id) } }) } if (settings.keyboardEvents) { const toLowerCase = (s) => s.toLowerCase() /** @type {Set<string>} */ const _keysDown = new Set() /** @type {Set<string>} */ const _keysPress = new Set() /** * @param {Set<string>} keysSet * @param {string} [key] * @returns {boolean} */ const keyCheck = (keysSet, key) => { return !key ? keysSet.size > 0 : keysSet.has( 'space' === toLowerCase(key) ? ' ' : toLowerCase(key) ) } on(root, 'keydown', (/** @type {KeyboardEvent} */ event) => { if (!_keysDown.has(toLowerCase(event.key))) { _keysDown.add(toLowerCase(event.key)) _keysPress.add(toLowerCase(event.key)) } }) on(root, 'keyup', (/** @type {KeyboardEvent} */ event) => { _keysDown.delete(toLowerCase(event.key)) }) on(root, 'blur', () => _keysDown.clear()) instance.listen('after:draw', () => _keysPress.clear()) instance.setvar( 'iskeydown', /** * Checks if a which key is pressed (down) on the keyboard. * Note: use `iskeydown()` to check for any key. * * @param {string} [key] * @returns {boolean} */ (key) => { DEV: assert( null == key || 'string' === typeof key, 'iskeydown: 1st param must be a string or undefined' ) return keyCheck(_keysDown, key) } ) instance.setvar( 'iskeypressed', /** * Checks if a which key just got pressed on the keyboard. * Note: use `iskeypressed()` to check for any key. * * @param {string} [key] * @returns {boolean} */ (key) => { DEV: assert( null == key || 'string' === typeof key, 'iskeypressed: 1st param must be a string or undefined' ) return keyCheck(_keysPress, key) } ) } // listen browser focus/blur events and pause the update/draw loop if (settings.pauseOnBlur) { on(root, 'blur', () => { _rafid = cancelAnimationFrame(_rafid) }) on(root, 'focus', () => { if (!_rafid) { _rafid = raf(drawFrame) } }) } // start the game loop instance.emit('init', instance) _lastFrameTime = performance.now() _rafid = raf(drawFrame) } /** * @param {DOMHighResTimeStamp} now */ function drawFrame(now) { let updated = 0, frameTime = (now - _lastFrameTime) / 1000 _lastFrameTime = now if (settings.animate) { // request the next frame _rafid = raf(drawFrame) if (frameTime > 0.3) { return console.warn('skipping too long frame') } _accumulated += frameTime while (_accumulated >= _deltaTime) { instance.emit('update', _deltaTime * _timeScale) instance.setvar( 'ELAPSED', instance.ELAPSED + _deltaTime * _timeScale ) updated++ _accumulated -= _deltaTime } } else { // when the canvas is not animated // we for one frame when a redraw is triggered updated = 1 } if (updated) { // always set default values for // _ctx.textAlign and _ctx.textBaseline before draw instance.textalign('start', 'top') instance.emit('draw') } } function setupCanvas() { _canvas = settings.canvas || document.createElement('canvas') /** @type {HTMLCanvasElement} */ _canvas = 'string' === typeof _canvas ? document.querySelector(_canvas) : _canvas DEV: assert( _canvas && _canvas.tagName === 'CANVAS', 'Invalid canvas element' ) instance.setvar('CANVAS', _canvas) _ctx = _canvas.getContext('2d') on(_canvas, 'click', () => root.focus()) _canvas.style = '' resizeCanvas() if (!_canvas.parentNode) document.body.appendChild(_canvas) } function resizeCanvas() { DEV: assert( null == settings.width || (isNumber(settings.width) && settings.width > 0), 'Litecanvas\' option "width" should be a positive number when defined' ) DEV: assert( null == settings.height || (isNumber(settings.height) && settings.height > 0), 'Litecanvas\' option "height" should be a positive number when defined' ) DEV: assert( null == settings.height || (settings.width > 0 && settings.height > 0), 'Litecanvas\' option "width" is required when the option "height" is defined' ) const width = settings.width || root.innerWidth, height = settings.height || settings.width || root.innerHeight instance.setvar('WIDTH', (_canvas.width = width)) instance.setvar('HEIGHT', (_canvas.height = height)) instance.setvar('CENTERX', instance.WIDTH / 2) instance.setvar('CENTERY', instance.HEIGHT / 2) if (settings.autoscale) { if (!_canvas.style.display) { _canvas.style.display = 'block' _canvas.style.margin = 'auto' } _scale = math.min( root.innerWidth / instance.WIDTH, root.innerHeight / instance.HEIGHT ) _scale = (settings.pixelart ? ~~_scale : _scale) || 1 _canvas.style.width = instance.WIDTH * _scale + 'px' _canvas.style.height = instance.HEIGHT * _scale + 'px' } // restore canvas image rendering properties if (!settings.antialias || settings.pixelart) { _ctx.imageSmoothingEnabled = false _canvas.style.imageRendering = 'pixelated' } // trigger "resized" event instance.emit('resized', _scale) // force redraw if (!settings.animate) { ra