UNPKG

litecanvas

Version:

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

1,456 lines (1,270 loc) 64.2 kB
// @ts-check import { setupZzFX } from './zzfx.js' import { defaultPalette } from './palette.js' import { assert } from './dev.js' import { version } from './version.js' /** * The litecanvas constructor * * @param {LitecanvasOptions} [settings] * @returns {LitecanvasInstance} */ export default function litecanvas(settings = {}) { const root = window, math = Math, perf = performance, TAU = math.PI * 2, raf = requestAnimationFrame, isNumber = Number.isFinite, /** @type {Function[]} */ _browserEventListeners = [], /** @type {(elem: EventTarget, evt: string, callback: (event: Event) => void) => void} */ on = (elem, evt, callback) => { elem.addEventListener(evt, callback, false) _browserEventListeners.push(() => elem.removeEventListener(evt, callback, false)) }, /** @type {(str: string) => string} */ lowerCase = (str) => str.toLowerCase(), /** @type {(ev: Event) => void} */ preventDefault = (ev) => ev.preventDefault(), /** @type {(c: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => void} */ beginPath = (c) => c.beginPath(), zzfx = setupZzFX(root), /** @type {LitecanvasOptions} */ defaults = { width: null, height: null, autoscale: true, canvas: null, global: true, loop: null, tapEvents: true, keyboardEvents: true, } DEV: assert( null == settings || 'object' === typeof settings, 'litecanvas() 1st argument must be a object' ) // setup the settings default values settings = Object.assign(defaults, settings) let _loop = settings.loop, /** @type {boolean} */ _initialized, /** @type {boolean} */ _paused, /** @type {HTMLCanvasElement} */ _canvas, /** @type {number} */ _canvasScale = 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) */ _fpsInterval = 1000 / 60, /** @type {number} */ _accumulated, /** @type {number} */ _rafid = 0, /** @type {number} */ _defaultTextColor = 3, /** @type {string} */ _fontFamily = 'sans-serif', /** @type {number} */ _fontSize = 20, /** @type {number} */ _fontLineHeight = 1.2, /** @type {number} */ _rngSeed = Date.now(), /** @type {string[]} */ _currentPalette = defaultPalette, /** @type {string[]} */ _lastPalette = defaultPalette, /** @type {number[]} */ _colorPaletteState = [], /** @type {number[]} */ _defaultSound = [0.5, 0, 1750, , , 0.3, 1, , , , 600, 0.1], /** * @type {Object<string,Set<Function>>} game event listeners */ _eventListeners = {} /** @type {LitecanvasInstance} */ const instance = { /** @type {number} */ W: 0, /** @type {number} */ H: 0, /** @type {number} */ T: 0, /** @type {number} */ MX: -1, /** @type {number} */ MY: -1, /** MATH API */ /** * Twice the value of the mathematical constant PI (π). * Approximately 6.28318 * * @type {number} */ TAU, /** * 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 argument must be a number') DEV: assert(isNumber(end), 'lerp() 2nd argument must be a number') DEV: assert(isNumber(t), 'lerp() 3rd argument must be a number') return start + t * (end - start) }, /** * Convert degrees to radians * * @param {number} degs * @returns {number} the value in radians */ deg2rad: (degs) => { DEV: assert(isNumber(degs), 'deg2rad() 1st argument 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 argument must be a number') return (180 / math.PI) * rads }, /** * Modulus (Euclidean division). * * Note: When `b == 0` returns `0`, rather than `NaN`. * * @param {number} a dividend * @param {number} b divisor * @returns {number} the remainder */ mod(a, b) { DEV: assert(isNumber(a), 'mod() 1st argument must be a number') DEV: assert(isNumber(b) && b >= 0, 'mod() 2nd argument must be a non-negative number') return ((a % b) + b) % b || 0 }, /** * 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 argument must be a number') DEV: assert( isNumber(precision) && precision >= 0, 'round() 2nd argument must be a non-negative number' ) 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 argument must be a number') DEV: assert(isNumber(min), 'clamp() 2nd argument must be a number') DEV: assert(isNumber(max), 'clamp() 3rd argument must be a number') DEV: assert(max >= min, 'clamp() the 2nd argument must be less than the 3rd argument') if (value < min) return min if (value > max) return max return value }, /** * Calculates the distance between a point (x1, y1) to another (x2, y2). * * @param {number} x1 * @param {number} y1 * @param {number} x2 * @param {number} y2 * @returns {number} */ dist: (x1, y1, x2, y2) => { DEV: assert(isNumber(x1), 'dist() 1st argument must be a number') DEV: assert(isNumber(y1), 'dist() 2nd argument must be a number') DEV: assert(isNumber(x2), 'dist() 3rd argument must be a number') DEV: assert(isNumber(y2), 'dist() 4th argument must be a number') return math.hypot(x2 - x1, y2 - y1) }, /** * 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 argument must be a number') DEV: assert(isNumber(min), 'wrap() 2nd argument must be a number') DEV: assert(isNumber(max), 'wrap() 3rd argument must be a number') DEV: assert(max > min, 'wrap() the 2nd argument must be less than the 3rd argument') 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 argument must be a number') DEV: assert(isNumber(start1), 'map() 2nd argument must be a number') DEV: assert(isNumber(stop1), 'map() 3rd argument must be a number') DEV: assert(isNumber(start2), 'map() 4th argument must be a number') DEV: assert(isNumber(stop2), 'map() 5th argument must be a number') DEV: assert( stop1 !== start1, 'map() the 2nd argument must be different than the 3rd argument' ) // 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 argument must be a number') DEV: assert(isNumber(start), 'norm() 2nd argument must be a number') DEV: assert(isNumber(stop), 'norm() 3rd argument must be a number') DEV: assert( start !== stop, 'norm() the 2nd argument must be different than the 3rd argument' ) 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 argument must be a number') DEV: assert(isNumber(max), 'rand() 2nd argument must be a number') DEV: assert(max >= min, 'rand() the 1st argument must be less than the 2nd argument') const a = 1664525 const c = 1013904223 const m = 4294967296 _rngSeed = (a * _rngSeed + c) % m return (_rngSeed / 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 argument must be a number') DEV: assert(isNumber(max), 'randi() 2nd argument must be a number') DEV: assert(max >= min, 'randi() the 1st argument must be less than the 2nd argument') return ~~instance.rand(min, max + 1) }, /** * Initializes the random number generator with an explicit seed value. * * Note: The seed should be a integer number greater than or equal to zero. * * @param {number} value */ rseed(value) { DEV: assert( isNumber(value) && value >= 0, 'rseed() 1st argument must be a non-negative integer' ) _rngSeed = ~~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 argument must be a non-negative number' ) if (null == color) { _ctx.clearRect(0, 0, instance.W, instance.H) } else { instance.rectfill(0, 0, instance.W, instance.H, 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 * * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/roundRect */ rect(x, y, width, height, color, radii) { DEV: assert(isNumber(x), 'rect() 1st argument must be a number') DEV: assert(isNumber(y), 'rect() 2nd argument must be a number') DEV: assert( isNumber(width) && width > 0, 'rect() 3rd argument must be a positive number' ) DEV: assert( isNumber(height) && height >= 0, 'rect() 4th argument must be a non-negative number' ) DEV: assert( null == color || (isNumber(color) && color >= 0), 'rect() 5th argument must be a non-negative number' ) DEV: assert( null == radii || isNumber(radii) || (Array.isArray(radii) && radii.length >= 1), 'rect() 6th argument must be a number or array of numbers' ) beginPath(_ctx) _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 argument must be a number') DEV: assert(isNumber(y), 'rectfill() 2nd argument must be a number') DEV: assert( isNumber(width) && width >= 0, 'rectfill() 3rd argument must be a non-negative number' ) DEV: assert( isNumber(height) && height >= 0, 'rectfill() 4th argument must be a non-negative number' ) DEV: assert( null == color || (isNumber(color) && color >= 0), 'rectfill() 5th argument must be a non-negative number' ) DEV: assert( null == radii || isNumber(radii) || (Array.isArray(radii) && radii.length >= 1), 'rectfill() 6th argument must be a number or array of at least 2 numbers' ) beginPath(_ctx) _ctx[radii ? 'roundRect' : 'rect'](~~x, ~~y, ~~width, ~~height, radii) instance.fill(color) }, /** * Draw a ellipse outline * * @param {number} x * @param {number} y * @param {number} radiusX * @param {number} radiusY * @param {number} [color=0] the color index */ oval(x, y, radiusX, radiusY, color) { DEV: assert(isNumber(x), 'oval() 1st argument must be a number') DEV: assert(isNumber(y), 'oval() 2nd argument must be a number') DEV: assert( isNumber(radiusX) && radiusX >= 0, 'oval() 3rd argument must be a non-negative number' ) DEV: assert( isNumber(radiusY) && radiusY >= 0, 'oval() 4th argument must be a non-negative number' ) DEV: assert( null == color || (isNumber(color) && color >= 0), 'oval() 5th argument must be a non-negative number' ) beginPath(_ctx) _ctx.ellipse(~~x, ~~y, ~~radiusX, ~~radiusY, 0, 0, TAU) instance.stroke(color) }, /** * Draw a color-filled ellipse * * @param {number} x * @param {number} y * @param {number} radiusX * @param {number} radiusY * @param {number} [color=0] the color index */ ovalfill(x, y, radiusX, radiusY, color) { DEV: assert(isNumber(x), 'ovalfill() 1st argument must be a number') DEV: assert(isNumber(y), 'ovalfill() 2nd argument must be a number') DEV: assert( isNumber(radiusX) && radiusX >= 0, 'ovalfill() 3rd argument must be a non-negative number' ) DEV: assert( isNumber(radiusY) && radiusY >= 0, 'ovalfill() 4th argument must be a non-negative number' ) DEV: assert( null == color || (isNumber(color) && color >= 0), 'ovalfill() 5th argument must be a non-negative number' ) beginPath(_ctx) _ctx.ellipse(~~x, ~~y, ~~radiusX, ~~radiusY, 0, 0, TAU) 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 argument must be a number') DEV: assert(isNumber(y), 'circ() 2nd argument must be a number') DEV: assert( isNumber(radius) && radius >= 0, 'circ() 3rd argument must be a non-negative number' ) DEV: assert( null == color || (isNumber(color) && color >= 0), 'circ() 4th argument must be a non-negative number' ) instance.oval(x, y, radius, radius, 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 argument must be a number') DEV: assert(isNumber(y), 'circfill() 2nd argument must be a number') DEV: assert( isNumber(radius) && radius >= 0, 'circfill() 3rd argument must be a non-negative number' ) DEV: assert( null == color || (isNumber(color) && color >= 0), 'circfill() 4th argument must be a non-negative number' ) instance.ovalfill(x, y, radius, radius, color) }, /** * Make a custom shape in the canvas context. * Then, just use `fill` or `stroke` to draw the shape. * * @param {number[]} points an array of Xs and Ys coordinates */ shape(points) { DEV: assert(Array.isArray(points), 'shape() 1st argument must be an array of numbers') DEV: assert( points.length >= 6, 'shape() 1st argument must be an array with at least 6 numbers (3 points)' ) beginPath(_ctx) for (let i = 0; i < points.length; i += 2) { if (i) { _ctx.lineTo(~~points[i], ~~points[i + 1]) } else { _ctx.moveTo(~~points[i], ~~points[i + 1]) } } _ctx.lineTo(~~points[0], ~~points[1]) }, /** * 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 argument must be a number') DEV: assert(isNumber(y1), 'line() 2nd argument must be a number') DEV: assert(isNumber(x2), 'line() 3rd argument must be a non-negative number') DEV: assert(isNumber(y2), 'line() 4th argument must be a non-negative number') DEV: assert( null == color || (isNumber(color) && color >= 0), 'line() 5th argument must be a non-negative number' ) beginPath(_ctx) let xfix = _outline_fix && ~~x1 === ~~x2 ? 0.5 : 0 let yfix = _outline_fix && ~~y1 === ~~y2 ? 0.5 : 0 _ctx.moveTo(~~x1 + xfix, ~~y1 + yfix) _ctx.lineTo(~~x2 + xfix, ~~y2 + yfix) instance.stroke(color) }, /** * Sets the thickness of the 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 argument must be a non-negative integer' ) _ctx.lineWidth = ~~value _outline_fix = ~~value % 2 ? 0.5 : 0 }, /** * 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 argument must be an array of numbers' ) DEV: assert(isNumber(offset), 'linedash() 2nd argument must be a number') _ctx.setLineDash(segments) _ctx.lineDashOffset = offset }, /** TEXT RENDERING API */ /** * Draw text. You can use `\n` to break lines. * * @param {number} x * @param {number} y * @param {string} message the text message * @param {number} [color] the color index * @param {string} [fontStyle] can be "normal" (default), "italic" and/or "bold". */ text(x, y, message, color = _defaultTextColor, fontStyle = 'normal') { DEV: assert(isNumber(x), 'text() 1st argument must be a number') DEV: assert(isNumber(y), 'text() 2nd argument must be a number') DEV: assert( null == color || (isNumber(color) && color >= 0), 'text() 4th argument must be a non-negative number' ) DEV: assert('string' === typeof fontStyle, 'text() 5th argument must be a string') _ctx.font = `${fontStyle} ${_fontSize}px ${_fontFamily}` _ctx.fillStyle = getColor(color) const messages = ('' + message).split('\n') for (let i = 0; i < messages.length; i++) { _ctx.fillText(messages[i], ~~x, ~~y + _fontSize * _fontLineHeight * i) } }, /** * Sets the height ratio of the text lines based on current text size. * * Default = `1.2` * * @param {number} value */ textgap(value) { DEV: assert(isNumber(value), 'textgap() 1st argument must be a number') _fontLineHeight = value }, /** * Set the font family * * @param {string} family */ textfont(family) { DEV: assert('string' === typeof family, 'textfont() 1st argument must be a string') _fontFamily = family }, /** * Set the font size * * @param {number} size */ textsize(size) { DEV: assert(isNumber(size), 'textsize() 1st argument must be a number') _fontSize = size }, /** * Sets the alignment used when drawing texts * * @param {CanvasTextAlign} align the horizontal alignment. Possible values: "left", "right", "center", "start" or "end" * @param {CanvasTextBaseline} 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 argument must be null or one of the following strings: center, left, right, start or end.' ) DEV: assert( null == baseline || ['top', 'bottom', 'middle', 'hanging', 'alphabetic', 'ideographic'].includes( baseline ), 'textalign() 2nd argument must be null or one of the following strings: 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 {CanvasImageSource} source */ image(x, y, source) { DEV: assert(isNumber(x), 'image() 1st argument must be a number') DEV: assert(isNumber(y), 'image() 2nd argument must be a number') _ctx.drawImage(source, ~~x, ~~y) }, /** * Draw a sprite, using a string of rows and columns representing a bitmask. * - Each colored pixel must be a base 36 number (0-9 or a-z). * - Use "." (dot) for transparent pixels. * - Any other characters (like symbols) are ignored. * - empty lines are ignored * * @param {number} x * @param {number} y * @param {string} pixels */ spr(x, y, pixels) { DEV: assert(isNumber(x), 'spr() 1st argument must be a number') DEV: assert(isNumber(y), 'spr() 2nd argument must be a number') DEV: assert('string' === typeof pixels, 'spr() 3rd argument must be a string') const rows = pixels .replace(/[^\w.\n]/g, '') .split('\n') .filter((s) => s) for (let i = 0; i < rows.length; i++) { for (let j = 0; j < rows[i].length; j++) { if (rows[i][j] !== '.') { instance.rectfill(x + j, y + i, 1, 1, parseInt(rows[i][j], 36) || 0) } } } }, /** * Draw in an OffscreenCanvas and returns its image. * * @param {number} width * @param {number} height * @param {drawCallback} callback * @param {object} [options] * @param {number} [options.scale=1] * @param {OffscreenCanvas} [options.canvas] * @returns {ImageBitmap} * @see https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas */ paint(width, height, callback, options = {}) { DEV: assert(isNumber(width) && width >= 1, 'paint() 1st argument must be a number >= 1') DEV: assert( isNumber(height) && height >= 1, 'paint() 2nd argument must be a number >= 1' ) DEV: assert('function' === typeof callback, 'paint() 3rd argument must be a function') DEV: assert( (options && null == options.scale) || (isNumber(options.scale) && options.scale > 0), 'paint() 4th argument (options.scale) must be a positive number' ) DEV: assert( (options && null == options.canvas) || options.canvas instanceof OffscreenCanvas, 'paint() 4th argument (options.canvas) must be an OffscreenCanvas' ) const /** @type {OffscreenCanvas} */ canvas = options.canvas || new OffscreenCanvas(1, 1), scale = options.scale || 1, currentContext = _ctx // context backup canvas.width = width * scale canvas.height = height * scale _ctx = canvas.getContext('2d') _ctx.scale(scale, scale) callback(_ctx) _ctx = currentContext // 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) { DEV: assert( null == context || context instanceof CanvasRenderingContext2D || context instanceof OffscreenCanvasRenderingContext2D, 'ctx() 1st argument must be an [Offscreen]CanvasRenderingContext2D' ) if (context) { _ctx = context } return _ctx }, /** * Saves the current drawing style settings and, optionally, transforms (translate/rotate/scale) the canvas. * * @param {number} [translateX] * @param {number} [translateY] * @param {number} [rotation] in radians * @param {number} [scaleX] * @param {number} [scaleY] * * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/save */ push(translateX = 0, translateY = translateX, rotation = 0, scaleX = 1, scaleY = scaleX) { DEV: assert(isNumber(translateX), 'push() 1st argument must be a number') DEV: assert(isNumber(translateY), 'push() 2nd argument must be a number') DEV: assert(isNumber(rotation), 'push() 3rd argument must be a number') DEV: assert(isNumber(scaleX), 'push() 4th argument must be a number') DEV: assert(isNumber(scaleY), 'push() 5th argument must be a number') _ctx.save() instance.translate(translateX, translateY) instance.rotate(rotation) instance.scale(scaleX, scaleY) }, /** * 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 argument must be a number') DEV: assert(isNumber(y), 'translate() 2nd argument must be a number') _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 = x) { DEV: assert(isNumber(x), 'scale() 1st argument must be a number') DEV: assert(isNumber(y), 'scale() 2nd argument must be a number') _ctx.scale(x, y) }, /** * Adds a rotation to the transformation matrix. * * @param {number} radians */ rotate(radians) { DEV: assert(isNumber(radians), 'rotate() 1st argument must be a number') _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 argument must be a number') _ctx.globalAlpha = instance.clamp(value, 0, 1) }, /** * Fills the current path with a given color. * * @param {number} [color=0] */ fill(color) { DEV: assert( null == color || (isNumber(color) && color >= 0), 'fill() 1st argument must be a non-negative number' ) _ctx.fillStyle = getColor(color) _ctx.fill() }, /** * Outlines the current path with a given color. * * @param {number} [color=0] */ stroke(color) { DEV: assert( null == color || (isNumber(color) && color >= 0), 'stroke() 1st argument must be a non-negative number' ) _ctx.strokeStyle = getColor(color) _ctx.stroke() }, /** * Turns a path (in the callback) into the current clipping region. * * @param {clipCallback} callback * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/clip */ clip(callback) { DEV: assert( 'function' === typeof callback, 'clip() 1st argument must be a function (ctx) => void' ) beginPath(_ctx) callback(_ctx) _ctx.clip() }, /** 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 parameters * @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, volumeFactor) { DEV: assert( null == zzfxParams || Array.isArray(zzfxParams), 'sfx() 1st argument must be an array' ) DEV: assert( null == pitchSlide || isNumber(pitchSlide), 'sfx() 2nd argument must be a number' ) DEV: assert( null == volumeFactor || isNumber(volumeFactor), 'sfx() 3rd argument must be a number' ) if ( !root.zzfxV || (navigator.userActivation && !navigator.userActivation.hasBeenActive) ) { return false } zzfxParams ||= _defaultSound // if has other arguments, copy the sound to not change the original if (pitchSlide || volumeFactor >= 0) { 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) && value >= 0, 'volume() 1st argument must be a non-negative number' ) root.zzfxV = value }, /** PLUGINS API */ /** * Returns the canvas * * @returns {HTMLCanvasElement} */ canvas: () => _canvas, /** * Loads a Litecanvas plugin * * @param {pluginCallback} callback * @param {object} [config] */ use(callback, config = {}) { DEV: assert( 'function' === typeof callback, 'use() 1st argument must be a function (instance, config) => any' ) DEV: assert('object' === typeof config, 'use() 2nd argument must be an object') loadPlugin(callback, config) }, /** * Resizes the canvas * * @param {number} width * @param {number} [height] * @param {boolean|number} [autoscale] */ resize(width, height = width, autoscale) { DEV: assert( isNumber(width) && width >= 1, 'resize() 1st argument must be a number >= 1' ) DEV: assert( isNumber(height) && height >= 1, 'resize() 2nd argument must be a number >= 1' ) DEV: assert( null == autoscale || 'boolean' === typeof autoscale || (isNumber(autoscale) && autoscale > 1), 'resize() 3rd argument must be a boolean or a number > 1' ) settings.width = width settings.height = height settings.autoscale = null == autoscale ? settings.autoscale : autoscale resizeCanvas() }, /** * Add a game event listener. * * @param {string} eventName the event type name * @param {Function} callback the function that is called when the event occurs */ listen: (eventName, callback) => { DEV: assert('string' === typeof eventName, 'listen() 1st argument must be a string') DEV: assert('function' === typeof callback, 'listen() 2nd argument must be a function') eventName = lowerCase(eventName) _eventListeners[eventName] = _eventListeners[eventName] || new Set() _eventListeners[eventName].add(callback) }, /** * Remove a game event listener. * * @param {string} eventName the event type name * @param {Function} callback the function that is called when the event occurs */ unlisten: (eventName, callback) => { DEV: assert('string' === typeof eventName, 'unlisten() 1st argument must be a string') DEV: assert( 'function' === typeof callback, 'unlisten() 2nd argument must be a function' ) eventName = lowerCase(eventName) if (_eventListeners[eventName]) { _eventListeners[eventName].delete(callback) } }, /** * Call all listeners attached to a game event. * * Note: when the `litecanvas()` "loop" option is `null` (default), * `emit()` will first call a global function matching the event name (if it exists). * E.g: `emit("boom")` calls `window.boom()`. * * @param {string} eventName The event type name * @param {any} [arg1] any data to be passed over the listeners * @param {any} [arg2] any data to be passed over the listeners * @param {any} [arg3] any data to be passed over the listeners * @param {any} [arg4] any data to be passed over the listeners * @returns {any} always returns the second argument */ emit(eventName, arg1, arg2, arg3, arg4) { DEV: assert('string' === typeof eventName, 'emit() 1st argument must be a string') if (_initialized) { eventName = lowerCase(eventName) triggerEvent('before:' + eventName, arg1, arg2, arg3, arg4) // if the "loop" option not exists, // calls a global function with the same name as the event, // as long as it's not a global litecanvas function (to avoid infinite loops) if ( !_loop && root[eventName] !== instance[eventName] && 'function' === typeof root[eventName] /* if is a function */ ) { root[eventName](arg1, arg2, arg3, arg4) } triggerEvent(eventName, arg1, arg2, arg3, arg4) triggerEvent('after:' + eventName, arg1, arg2, arg3, arg4) } return arg1 }, /** * Set new palette colors or restore previous palette. * * @param {string[]} [colors] an array of colors * @param {number} [textColor] the default text color this palette */ pal(colors, textColor = 3) { DEV: assert( null == colors || (Array.isArray(colors) && colors.length > 0), 'pal() 1st argument must be null or an array of colors' ) DEV: assert( isNumber(textColor) && textColor >= 0, 'pal() 2nd argument must be a non-negative number' ) if (null == colors) { _currentPalette = _lastPalette } else { _lastPalette = _currentPalette _currentPalette = colors } _colorPaletteState = [] _defaultTextColor = textColor instance.emit('pal', _currentPalette, _defaultTextColor) }, /** * Replace the color "a" with color "b". * * If called without arguments, reset the current palette. * * Note: `palc()` don't affect drawings made with `image()`. * * @param {number?} a * @param {number?} b */ palc(a, b) { DEV: assert( null == a || (isNumber(a) && a >= 0), 'palc() 1st argument must be a positive number' ) DEV: assert( isNumber(a) ? isNumber(b) && b >= 0 : null == b, 'palc() 2nd argument must be a positive number' ) if (null == a) { _colorPaletteState = [] } else { _colorPaletteState[a] = b } }, /** * Define or update a instance property. * * Note: when the `litecanvas()` option "global" is `true` (default), * `def()` with set/update a window property. * * E.g: `def('ONE', 1)` do `window.ONE = 1`. * * @param {string} key the property name * @param {any} value the property value */ def(key, value) { DEV: assert('string' === typeof key, 'def() 1st argument must be a string') DEV: if (null == value) { console.warn( `[litecanvas] def() changed the key "${key}" to null (previous value was ${instance[key]})` ) } 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) && value >= 0, 'timescale() 1st argument must be a non-negative number' ) _timeScale = value }, /** * Set the target FPS (frames per second). * * @param {number} value */ framerate(value) { DEV: assert( isNumber(value) && value >= 1, 'framerate() 1st argument must be a number >= 1' ) _fpsInterval = 1000 / ~~value }, /** * Returns information about the engine instance. * * @param {number} index * @returns {any} */ stat(index) { DEV: assert(isNumber(index), 'stat() 1st argument must be a number') const internals = [ // 0 settings, // 1 _initialized, // 2 _fpsInterval / 1000, // 3 _canvasScale, // 4 _eventListeners, // 5 _currentPalette, // 6 _defaultSound, // 7 _timeScale, // 8 root.zzfxV, // 9 _rngSeed, // 10 _fontSize, // 11 _fontFamily, // 12 _colorPaletteState, // 13 _fontLineHeight, ] DEV: assert( index >= 0 && index < internals.length, 'stat() 1st argument must be a number between 0 and ' + (internals.length - 1) ) return internals[index] }, /** * Returns `true` if the engine loop is paused. * * @returns {boolean} */ ispaused() { return _paused }, /** * Pauses the engine loop (update & draw). */ pause() { if (!_paused) { _paused = true _rafid = ~~cancelAnimationFrame(_rafid) instance.emit('paused') } }, /** * Resumes (if paused) the engine loop. */ resume() { DEV: assert( _initialized, 'resume() cannot be called before the "init" event and neither after the quit() function' ) if (_initialized && _paused) { startGameLoop() _paused = false instance.emit('resumed') } }, /** * Shutdown the litecanvas instance and remove all event listeners. */ quit() { // emit "quit" event to manual clean ups instance.emit('quit') // stop the game loop (update & draw) instance.pause() // deinitialize the engine _initialized = false // clear all engine event listeners _eventListeners = {} // clear all browser event listeners for (const removeListener of _browserEventListeners) { removeListener() } // maybe clear global context if (settings.global) { for (const key in instance) { delete root[key] } delete root.ENGINE } DEV: console.warn('[litecanvas] quit() terminated a Litecanvas instance.') }, } // prettier-ignore const mathProps = 'PI,sin,cos,atan2,hypot,tan,abs,ceil,floor,trunc,min,max,pow,sqrt,sign,exp' for (const k of mathProps.split(',')) { // import native Math functions instance[k] = math[k] } function startGameLoop() { if (!_rafid) { _accumulated = 0 _lastFrameTime = perf.now() _rafid = raf(drawFrame) } } function init() { // listen window resize event when "autoscale" is enabled if (settings.autoscale) { on(root, 'resize', resizeCanvas) } // default mouse/touch handlers if (settings.tapEvents) { const _getXY = /** * @param {MouseEvent | Touch} ev */