litecanvas
Version:
Lightweight HTML5 canvas 2D game engine suitable for small projects and creative coding. Inspired by PICO-8 and P5/Processing.
1,370 lines (1,365 loc) • 51.2 kB
JavaScript
(() => {
// src/zzfx.js
var setupZzFX = (global) => {
const zzfxX = new AudioContext();
global.zzfxV = 1;
return (i = 1, d = 0.05, z = 220, e = 0, P = 0, S = 0.1, I = 0, c = 1, T = 0, H = 0, V = 0, J = 0, h = 0, j = 0, K = 0, E = 0, r = 0, B = 1, X = 0, L = 0, D = 0) => {
let n = Math, t = 2 * n.PI, a = 44100, F = T *= 500 * t / a / a, O = z *= (1 - d + 2 * d * n.random(d = [])) * t / a, x = 0, _ = 0, f = 0, g = 1, $ = 0, l = 0, o = 0, s = D < 0 ? -1 : 1, u = t * s * D * 2 / a, G = n.cos(u), C = n.sin, Q = C(u) / 4, M = 1 + Q, m = -2 * G / M, y = (1 - Q) / M, R = (1 + s * G) / 2 / M, A = -(s + G) / M, v = R, U = 0, W = 0, Y = 0, Z = 0;
for (e = a * e + 9, X *= a, P *= a, S *= a, r *= a, H *= 500 * t / a ** 3, K *= t / a, V *= t / a, J *= a, h = a * h | 0, i *= 0.3 * global.zzfxV, s = e + X + P + S + r | 0; f < s; d[f++] = o * i) ++l % (100 * E | 0) || (o = I ? 1 < I ? 2 < I ? 3 < I ? C(x * x) : n.max(n.min(n.tan(x), 1), -1) : 1 - (2 * x / t % 2 + 2) % 2 : 1 - 4 * n.abs(n.round(x / t) - x / t) : C(x), o = (h ? 1 - L + L * C(t * f / h) : 1) * (o < 0 ? -1 : 1) * n.abs(o) ** c * (f < e ? f / e : f < e + X ? 1 - (f - e) / X * (1 - B) : f < e + X + P ? B : f < s - r ? (s - f - r) / S * B : 0), o = r ? o / 2 + (r > f ? 0 : (f < s - r ? 1 : (s - f) / r) * d[f - r | 0] / 2 / i) : o, D && (o = Z = v * U + A * (U = W) + R * (W = o) - y * Y - m * (Y = Z))), u = (z += T += H) * n.cos(K * _++), x += u + u * j * C(f ** 5), g && ++g > J && (z += V, O += V, g = 0), !h || ++$ % h || (z = O, T = F, g = g || 1);
i = zzfxX.createBuffer(1, s, a), i.getChannelData(0).set(d), z = zzfxX.createBufferSource(), z.buffer = i, z.connect(zzfxX.destination), z.start();
};
};
// src/palette.js
var defaultPalette = [
"#111",
"#6a7799",
"#aec2c2",
"#FFF1E8",
"#e83b3b",
"#fabc20",
"#155fd9",
"#3cbcfc",
"#327345",
"#63c64d",
"#6c2c1f",
"#ac7c00"
];
// src/dev.js
var assert = (condition, message = "Assertion failed") => {
if (!condition) throw new Error(message);
};
// src/version.js
var version = "0.93.0";
// src/index.js
function litecanvas(settings = {}) {
const root = window, math = Math, TWO_PI = math.PI * 2, raf = requestAnimationFrame, _browserEventListeners = [], on = (elem, evt, callback) => {
elem.addEventListener(evt, callback, false);
_browserEventListeners.push(() => elem.removeEventListener(evt, callback, false));
}, beginPath = (c) => c.beginPath(), isNumber = Number.isFinite, zzfx = setupZzFX(root), defaults = {
width: null,
height: null,
autoscale: true,
pixelart: false,
canvas: null,
global: true,
loop: null,
tapEvents: true,
keyboardEvents: true,
animate: true
};
settings = Object.assign(defaults, settings);
let _initialized = false, _plugins = [], _canvas, _scale = 1, _ctx, _outline_fix = 0.5, _timeScale = 1, _lastFrameTime, _deltaTime = 1 / 60, _accumulated = 0, _rafid, _fontFamily = "sans-serif", _fontSize = 20, _rngSeed = Date.now(), _colors = defaultPalette, _defaultSound = [0.5, 0, 1750, , , 0.3, 1, , , , 600, 0.1], _coreEvents = "init,update,draw,tap,untap,tapping,tapped,resized", _mathFunctions = "PI,sin,cos,atan2,hypot,tan,abs,ceil,floor,trunc,min,max,pow,sqrt,sign,exp", _eventListeners = {};
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
*
* 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: TWO_PI / 4,
/**
* 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), "[litecanvas] lerp() 1st param must be a number");
DEV: assert(isNumber(end), "[litecanvas] lerp() 2nd param must be a number");
DEV: assert(isNumber(t), "[litecanvas] 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), "[litecanvas] round() 1st param must be a number");
DEV: assert(
null == precision || isNumber(precision) && precision >= 0,
"[litecanvas] 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), "[litecanvas] clamp() 1st param must be a number");
DEV: assert(isNumber(min), "[litecanvas] clamp() 2nd param must be a number");
DEV: assert(isNumber(max), "[litecanvas] clamp() 3rd param must be a number");
DEV: assert(
max > min,
"[litecanvas] clamp() 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), "[litecanvas] wrap() 1st param must be a number");
DEV: assert(isNumber(min), "[litecanvas] wrap() 2nd param must be a number");
DEV: assert(isNumber(max), "[litecanvas] wrap() 3rd param must be a number");
DEV: assert(
max > min,
"[litecanvas] wrap() the 2nd param must be less than 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), "[litecanvas] map() 1st param must be a number");
DEV: assert(isNumber(start1), "[litecanvas] map() 2nd param must be a number");
DEV: assert(isNumber(stop1), "[litecanvas] map() 3rd param must be a number");
DEV: assert(isNumber(start2), "[litecanvas] map() 4th param must be a number");
DEV: assert(isNumber(stop2), "[litecanvas] map() 5th param must be a number");
DEV: assert(
stop1 !== start1,
"[litecanvas] map() the 2nd param must be different than the 3rd param"
);
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), "[litecanvas] norm() 1st param must be a number");
DEV: assert(isNumber(start), "[litecanvas] norm() 2nd param must be a number");
DEV: assert(isNumber(stop), "[litecanvas] norm() 3rd param must be a number");
DEV: assert(
start !== stop,
"[litecanvas] norm() the 2nd param must be different than the 3rd param"
);
return instance.map(value, start, stop, 0, 1);
},
/**
* Interpolate between 2 values using a periodic function.
*
* @param {number} from - the lower bound
* @param {number} to - the higher bound
* @param {number} t - value passed to the periodic function
* @param {(n: number) => number} [fn] - the periodic function (which default to `Math.sin`)
*/
wave: (from, to, t, fn = Math.sin) => {
DEV: assert(isNumber(from), "[litecanvas] wave() 1st param must be a number");
DEV: assert(isNumber(to), "[litecanvas] wave() 2nd param must be a number");
DEV: assert(isNumber(t), "[litecanvas] wave() 3rd param must be a number");
DEV: assert(
"function" === typeof fn,
"[litecanvas] wave() 4rd param must be a function (n: number) => number"
);
return from + (fn(t) + 1) / 2 * (to - from);
},
/** 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, max = 1) => {
DEV: assert(isNumber(min), "[litecanvas] rand() 1st param must be a number");
DEV: assert(isNumber(max), "[litecanvas] rand() 2nd param must be a number");
DEV: assert(
max > min,
"[litecanvas] rand() the 1st param must be less than the 2nd param"
);
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), "[litecanvas] randi() 1st param must be a number");
DEV: assert(isNumber(max), "[litecanvas] randi() 2nd param must be a number");
DEV: assert(
max > min,
"[litecanvas] randi() the 1st param must be less than the 2nd param"
);
return math.floor(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(
null == value || isNumber(value) && value >= 0,
"[litecanvas] rseed() 1st param must be a positive number or zero"
);
_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,
"[litecanvas] 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
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/roundRect
*/
rect(x, y, width, height, color, radii) {
DEV: assert(isNumber(x), "[litecanvas] rect() 1st param must be a number");
DEV: assert(isNumber(y), "[litecanvas] rect() 2nd param must be a number");
DEV: assert(
isNumber(width) && width > 0,
"[litecanvas] rect() 3rd param must be a positive number"
);
DEV: assert(
isNumber(height) && height >= 0,
"[litecanvas] rect() 4th param must be a positive number or zero"
);
DEV: assert(
null == color || isNumber(color) && color >= 0,
"[litecanvas] rect() 5th param must be a positive number or zero"
);
DEV: assert(
null == radii || isNumber(radii) || Array.isArray(radii) && radii.length >= 1,
"[litecanvas] rect() 6th param 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), "[litecanvas] rectfill() 1st param must be a number");
DEV: assert(isNumber(y), "[litecanvas] rectfill() 2nd param must be a number");
DEV: assert(
isNumber(width) && width >= 0,
"[litecanvas] rectfill() 3rd param must be a positive number or zero"
);
DEV: assert(
isNumber(height) && height >= 0,
"[litecanvas] rectfill() 4th param must be a positive number or zero"
);
DEV: assert(
null == color || isNumber(color) && color >= 0,
"[litecanvas] rectfill() 5th param must be a positive number or zero"
);
DEV: assert(
null == radii || isNumber(radii) || Array.isArray(radii) && radii.length >= 1,
"[litecanvas] rectfill() 6th param 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 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), "[litecanvas] circ() 1st param must be a number");
DEV: assert(isNumber(y), "[litecanvas] circ() 2nd param must be a number");
DEV: assert(
isNumber(radius) && radius >= 0,
"[litecanvas] circ() 3rd param must be a positive number or zero"
);
DEV: assert(
null == color || isNumber(color) && color >= 0,
"[litecanvas] circ() 4th param must be a positive number or zero"
);
beginPath(_ctx);
_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), "[litecanvas] circfill() 1st param must be a number");
DEV: assert(isNumber(y), "[litecanvas] circfill() 2nd param must be a number");
DEV: assert(
isNumber(radius) && radius >= 0,
"[litecanvas] circfill() 3rd param must be a positive number or zero"
);
DEV: assert(
null == color || isNumber(color) && color >= 0,
"[litecanvas] circfill() 4th param must be a positive number or zero"
);
beginPath(_ctx);
_ctx.arc(~~x, ~~y, ~~radius, 0, TWO_PI);
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), "[litecanvas] oval() 1st param must be a number");
DEV: assert(isNumber(y), "[litecanvas] oval() 2nd param must be a number");
DEV: assert(
isNumber(radiusX) && radiusX >= 0,
"[litecanvas] oval() 3rd param must be a positive number or zero"
);
DEV: assert(
isNumber(radiusY) && radiusY >= 0,
"[litecanvas] oval() 4th param must be a positive number or zero"
);
DEV: assert(
null == color || isNumber(color) && color >= 0,
"[litecanvas] oval() 5th param must be a positive number or zero"
);
beginPath(_ctx);
_ctx.ellipse(~~x, ~~y, ~~radiusX, ~~radiusY, 0, 0, TWO_PI);
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), "[litecanvas] ovalfill() 1st param must be a number");
DEV: assert(isNumber(y), "[litecanvas] ovalfill() 2nd param must be a number");
DEV: assert(
isNumber(radiusX) && radiusX >= 0,
"[litecanvas] ovalfill() 3rd param must be a positive number or zero"
);
DEV: assert(
isNumber(radiusY) && radiusY >= 0,
"[litecanvas] ovalfill() 4th param must be a positive number or zero"
);
DEV: assert(
null == color || isNumber(color) && color >= 0,
"[litecanvas] ovalfill() 5th param must be a positive number or zero"
);
beginPath(_ctx);
_ctx.ellipse(~~x, ~~y, ~~radiusX, ~~radiusY, 0, 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), "[litecanvas] line() 1st param must be a number");
DEV: assert(isNumber(y1), "[litecanvas] line() 2nd param must be a number");
DEV: assert(
isNumber(x2),
"[litecanvas] line() 3rd param must be a positive number or zero"
);
DEV: assert(
isNumber(y2),
"[litecanvas] line() 4th param must be a positive number or zero"
);
DEV: assert(
null == color || isNumber(color) && color >= 0,
"[litecanvas] line() 5th param must be a positive number or zero"
);
beginPath(_ctx);
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,
"[litecanvas] 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,
"[litecanvas] linedash() 1st param must be an array of numbers"
);
DEV: assert(isNumber(offset), "[litecanvas] 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), "[litecanvas] text() 1st param must be a number");
DEV: assert(isNumber(y), "[litecanvas] text() 2nd param must be a number");
DEV: assert(
null == color || isNumber(color) && color >= 0,
"[litecanvas] text() 4th param must be a positive number or zero"
);
DEV: assert(
"string" === typeof fontStyle,
"[litecanvas] text() 5th param must be a string"
);
_ctx.font = `${fontStyle} ${_fontSize}px ${_fontFamily}`;
_ctx.fillStyle = _colors[~~color % _colors.length];
_ctx.fillText(message, ~~x, ~~y);
},
/**
* Set the font family
*
* @param {string} family
*/
textfont(family) {
DEV: assert(
"string" === typeof family,
"[litecanvas] textfont() 1st param must be a string"
);
_fontFamily = family;
},
/**
* Set the font size
*
* @param {number} size
*/
textsize(size) {
DEV: assert(isNumber(size), "[litecanvas] textsize() 1st param 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),
"[litecanvas] textalign() 1st param 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
),
"[litecanvas] textalign() 2nd param 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), "[litecanvas] image() 1st param must be a number");
DEV: assert(isNumber(y), "[litecanvas] 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} [options.canvas]
* @returns {ImageBitmap}
* @see https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas
*/
paint(width, height, drawing, options = {}) {
DEV: assert(
isNumber(width) && width >= 1,
"[litecanvas] paint() 1st param must be a positive number"
);
DEV: assert(
isNumber(height) && height >= 1,
"[litecanvas] paint() 2nd param must be a positive number"
);
DEV: assert(
"function" === typeof drawing || Array.isArray(drawing),
"[litecanvas] paint() 3rd param must be a function or array"
);
DEV: assert(
options && null == options.scale || isNumber(options.scale),
"[litecanvas] paint() 4th param (options.scale) must be a number"
);
DEV: assert(
options && null == options.canvas || options.canvas instanceof OffscreenCanvas,
"[litecanvas] paint() 4th param (options.canvas) must be an OffscreenCanvas"
);
const 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);
if (Array.isArray(drawing)) {
let x = 0, y = 0;
_ctx.imageSmoothingEnabled = false;
for (const str of drawing) {
for (const color of str) {
if (" " !== color && "." !== color) {
instance.rectfill(x, y, 1, 1, parseInt(color, 16));
}
x++;
}
y++;
x = 0;
}
} else {
drawing(_ctx);
}
_ctx = contextOriginal;
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), "[litecanvas] translate() 1st param must be a number");
DEV: assert(isNumber(y), "[litecanvas] 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), "[litecanvas] scale() 1st param must be a number");
DEV: assert(null == y || isNumber(y), "[litecanvas] 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), "[litecanvas] 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), "[litecanvas] alpha() 1st param 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,
"[litecanvas] fill() 1st param must be a positive number or zero"
);
_ctx.fillStyle = _colors[~~color % _colors.length];
_ctx.fill();
},
/**
* Outlines the current path with a given color.
*
* @param {number} [color=0]
*/
stroke(color) {
DEV: assert(
null == color || isNumber(color) && color >= 0,
"[litecanvas] stroke() 1st param must be a positive number or zero"
);
_ctx.strokeStyle = _colors[~~color % _colors.length];
_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,
"[litecanvas] clip() 1st param must be a function"
);
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 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),
"[litecanvas] sfx() 1st param must be an array"
);
DEV: assert(isNumber(pitchSlide), "[litecanvas] sfx() 2nd param must be a number");
DEV: assert(isNumber(volumeFactor), "[litecanvas] sfx() 3rd param must be a number");
if (!root.zzfxV || navigator.userActivation && !navigator.userActivation.hasBeenActive) {
return false;
}
zzfxParams = zzfxParams || _defaultSound;
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) && value >= 0,
"[litecanvas] volume() 1st param must be a positive number or zero"
);
root.zzfxV = value;
},
/** PLUGINS API */
/**
* Returns the canvas
*
* @returns {HTMLCanvasElement}
*/
canvas: () => _canvas,
/**
* Prepares a plugin to be loaded
*
* @param {pluginCallback} callback
*/
use(callback, config = {}) {
DEV: assert(
"function" === typeof callback,
"[litecanvas] use() 1st param must be a function"
);
DEV: assert(
"object" === typeof config,
"[litecanvas] use() 2nd param must be an object"
);
if (_initialized) {
loadPlugin(callback, config);
} else {
_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,
"[litecanvas] listen() 1st param must be a string"
);
DEV: assert(
"function" === typeof callback,
"[litecanvas] listen() 2nd param must be a function"
);
eventName = eventName.toLowerCase();
_eventListeners[eventName] = _eventListeners[eventName] || /* @__PURE__ */ new Set();
_eventListeners[eventName].add(callback);
return () => _eventListeners && _eventListeners[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,
"[litecanvas] emit() 1st param must be a string"
);
if (_initialized) {
eventName = eventName.toLowerCase();
triggerEvent("before:" + eventName, arg1, arg2, arg3, arg4);
triggerEvent(eventName, arg1, arg2, arg3, arg4);
triggerEvent("after:" + eventName, arg1, arg2, arg3, arg4);
}
},
/**
* Set or reset the color palette
*
* @param {string[]} [colors]
*/
pal(colors = defaultPalette) {
DEV: assert(
Array.isArray(colors) && colors.length > 0,
"[litecanvas] pal() 1st param must be a array of strings"
);
_colors = colors;
},
/**
* Define or update a instance property.
*
* @param {string} key
* @param {*} value
*/
def(key, value) {
DEV: assert("string" === typeof key, "[litecanvas] def() 1st param 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,
"[litecanvas] timescale() 1st param must be a positive number or zero"
);
_timeScale = value;
},
/**
* Set the target FPS (frames per second).
*
* @param {number} value
*/
framerate(value) {
DEV: assert(
isNumber(value) && value >= 1,
"[litecanvas] framerate() 1st param must be a positive number"
);
_deltaTime = 1 / ~~value;
},
/**
* Returns information about that engine instance.
*
* @param {number} n
* @returns {any}
*/
stat(n) {
DEV: assert(isNumber(n) && n >= 0, "[litecanvas] stat() 1st param must be a number");
const list = [
// 0
settings,
// 1
_initialized,
// 2
_deltaTime,
// 3
_scale,
// 4
_eventListeners,
// 5
_colors,
// 6
_defaultSound,
// 7
_timeScale,
// 8
root.zzfxV,
// 9
_rngSeed,
// 10
_fontSize,
// 11
_fontFamily
];
const data = { index: n, value: list[n] };
instance.emit("stat", data);
return data.value;
},
/**
* Stops the litecanvas instance and remove all event listeners.
*/
quit() {
instance.pause();
instance.emit("quit");
_eventListeners = {};
for (const removeListener of _browserEventListeners) {
removeListener();
}
if (settings.global) {
for (const key in instance) {
delete root[key];
}
delete root.ENGINE;
}
_initialized = false;
},
/**
* Pauses the engine loop (update & draw).
*/
pause() {
cancelAnimationFrame(_rafid);
_rafid = 0;
},
/**
* Resumes (if paused) the engine loop.
*/
resume() {
if (!_rafid && _initialized) {
_rafid = raf(drawFrame);
}
},
/**
* Returns `true` if the engine loop is paused.
*
* @returns {boolean}
*/
paused() {
return !_rafid;
}
};
for (const k of _mathFunctions.split(",")) {
instance[k] = math[k];
}
function init() {
const source = settings.loop ? settings.loop : root;
for (const event of _coreEvents.split(",")) {
DEV: if (root === source && source[event]) {
console.info(`[litecanvas] using window.${event}()`);
}
if (source[event]) instance.listen(event, source[event]);
}
for (const [callback, config] of _plugins) {
loadPlugin(callback, config);
}
if (settings.autoscale) {
on(root, "resize", resizeCanvas);
}
if (settings.tapEvents) {
const _getXY = (
/**
* @param {MouseEvent | Touch} ev
*/
(ev) => [
(ev.pageX - _canvas.offsetLeft) / _scale,
(ev.pageY - _canvas.offsetTop) / _scale
]
), _taps = /* @__PURE__ */ new Map(), _registerTap = (
/**
* @param {number} id
* @param {number} [x]
* @param {number} [y]
*/
(id, x, y) => {
const tap = {
// current x
x,
// current y
y,
// initial x
xi: x,
// initial y
yi: y,
// timestamp
t: performance.now()
};
_taps.set(id, tap);
return tap;
}
), _updateTap = (
/**
* @param {number} id
* @param {number} x
* @param {number} y
*/
(id, x, y) => {
const tap = _taps.get(id) || _registerTap(id);
tap.x = x;
tap.y = y;
}
), _checkTapped = (
/**
* @param {{t: number}} tap
*/
(tap) => tap && performance.now() - tap.t <= 300
), preventDefault = (
/**
* @param {Event} ev
*/
(ev) => ev.preventDefault()
);
let _pressingMouse = false;
on(
_canvas,
"mousedown",
/**
* @param {MouseEvent} ev
*/
(ev) => {
if (ev.button === 0) {
preventDefault(ev);
const [x, y] = _getXY(ev);
instance.emit("tap", x, y, 0);
_registerTap(0, x, y);
_pressingMouse = true;
}
}
);
on(
_canvas,
"mouseup",
/**
* @param {MouseEvent} ev
*/
(ev) => {
if (ev.button === 0) {
preventDefault(ev);
const tap = _taps.get(0);
const [x, y] = _getXY(ev);
if (_checkTapped(tap)) {
instance.emit("tapped", tap.xi, tap.yi, 0);
}
instance.emit("untap", x, y, 0);
_taps.delete(0);
_pressingMouse = false;
}
}
);
on(
root,
"mousemove",
/**
* @param {MouseEvent} ev
*/
(ev) => {
preventDefault(ev);
const [x, y] = _getXY(ev);
instance.def("MX", x);
instance.def("MY", y);
if (!_pressingMouse) return;
instance.emit("tapping", x, y, 0);
_updateTap(0, x, y);
}
);
on(
_canvas,
"touchstart",
/**
* @param {TouchEvent} ev
*/
(ev) => {
preventDefault(ev);
const touches = ev.changedTouches;
for (const touch of touches) {
const [x, y] = _getXY(touch);
instance.emit("tap", x, y, touch.identifier + 1);
_registerTap(touch.identifier + 1, x, y);
}
}
);
on(
_canvas,
"touchmove",
/**
* @param {TouchEvent} ev
*/
(ev) => {
preventDefault(ev);
const touches = ev.changedTouches;
for (const touch of touches) {
const [x, y] = _getXY(touch);
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.xi, tap.yi, 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 _keysDown = /* @__PURE__ */ new Set();
const _keysPress = /* @__PURE__ */ new Set();
const keyCheck = (keySet, key = "") => {
key = key.toLowerCase();
return !key ? keySet.size > 0 : keySet.has("space" === key ? " " : key);
};
on(root, "keydown", (event) => {
const key = event.key.toLowerCase();
if (!_keysDown.has(key)) {
_keysDown.add(key);
_keysPress.add(key);
}
});
on(root, "keyup", (event) => {
_keysDown.delete(event.key.toLowerCase());
});
on(root, "blur", () => _keysDown.clear());
instance.listen("after:update", () => _keysPress.clear());
instance.def(
"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,
"[litecanvas] iskeydown() 1st param must be a string or undefined"
);
return keyCheck(_keysDown, key);
}
);
instance.def(
"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,
"[litecanvas] iskeypressed() 1st param must be a string or undefined"
);
return keyCheck(_keysPress, key);
}
);
}
_initialized = true;
instance.emit("init", instance);
_lastFrameTime = performance.now();
instance.resume();
}
function drawFrame(now) {
if (!settings.animate) {
return instance.emit("draw");
}
let updated = 0;
let frameTime = (now - _lastFrameTime) / 1e3;
_lastFrameTime = now;
if (frameTime < 0.1) {
_accumulated += frameTime;
while (_accumulated >= _deltaTime) {
updated++;
instance.emit("update", _deltaTime * _timeScale, updated);
instance.def("T", instance.T + _deltaTime * _timeScale);
_accumulated -= _deltaTime;
}
}
if (updated) {
instance.emit("draw");
}
if (_rafid) {
_rafid = raf(drawFrame);
}
}
function setupCanvas() {
if ("string" === typeof settings.canvas) {
_canvas = document.querySelector(settings.canvas);
DEV: assert(
null != _canvas,
'[litecanvas] litecanvas() option "canvas" is an invalid CSS selector'
);
} else {
_canvas = settings.canvas;
}
_canvas = _canvas || document.createElement("canvas");
DEV: assert(
"CANVAS" === _canvas.tagName,
'[litecanvas] litecanvas() option "canvas" should be a canvas element or string (CSS selector)'
);
_ctx = _canvas.getContext("2d");
on(_canvas, "click", () => focus());
_canvas.style = "";
resizeCanvas();
if (!_canvas.parentNode) {
document.body.appendChild(_canvas);
}
_canvas.oncontextmenu = () => false;
}
function resizeCanvas() {
DEV: assert(
null == settings.width || isNumber(settings.width) && settings.width > 0,
'[litecanvas] litecanvas() option "width" should be a positive number when defined'
);
DEV: assert(
null == settings.height || isNumber(settings.height) && settings.height > 0,
'[litecanvas] litecanvas() option "height" should be a positive number when defined'
);
DEV: assert(
null == settings.height || settings.width > 0 && settings.height > 0,
'[litecanvas] litecanvas() option "width" is required when the option "height" is defined'
);
const width = settings.width > 0 ? settings.width : innerWidth, height = settings.width > 0 ? settings.height || settings.width : innerHeight;
instance.def("W", width);
instance.def("H", height);
_canvas.width = width;
_canvas.height = height;
if (settings.autoscale) {
let maxScale = +settings.autoscale;
if (!_canvas.style.display) {
_canvas.style.display = "block";
_canvas.style.margin = "auto";
}
_scale = math.min(innerWidth / width, innerHeight / height);
_scale = maxScale > 1 && _scale > maxScale ? maxScale : _scale;
_canvas.style.width = width * _scale + "px";
_canvas.style.height = height * _scale + "px";
}
if (settings.pixelart) {
_ctx.imageSmoothingEnabled = false;
_canvas.style.imageRendering = "pixelated";
}
instance.textalign("start", "top");
instance.emit("resized", _scale);
instance.cls(0);
if (!settings.animate) {
raf(drawFrame);
}