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
JavaScript
// @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
*/