UNPKG

litecanvas

Version:

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

775 lines (774 loc) 23 kB
(() => { var setupZzFX = (global) => { global.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 * 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()); }; }; var defaultPalette = ["#211e20", "#555568", "#a0a08b", "#e9efec"]; function litecanvas(settings = {}) { const root = window, math = Math, perf = performance, TAU = math.PI * 2, raf = requestAnimationFrame, isNumber = Number.isFinite, _browserEventListeners = [], on = (elem, evt, callback) => { elem.addEventListener(evt, callback, false); _browserEventListeners.push(() => elem.removeEventListener(evt, callback, false), ); }, lowerCase = (str) => str.toLowerCase(), preventDefault = (ev) => ev.preventDefault(), beginPath = (c) => c.beginPath(), zzfx = setupZzFX(root), defaults = { width: null, height: null, autoscale: true, canvas: null, global: true, loop: null, tapEvents: true, keyboardEvents: true, }; settings = Object.assign(defaults, settings); let _loop = settings.loop, _initialized, _paused, _canvas, _canvasScale = 1, _ctx, _outline_fix = 0.5, _timeScale = 1, _lastFrameTime, _fpsInterval = 1e3 / 60, _accumulated, _rafid = 0, _defaultTextColor = 3, _fontFamily = "sans-serif", _fontSize = 20, _fontLineHeight = 1.2, _rngSeed = Date.now(), _currentPalette = defaultPalette, _lastPalette = defaultPalette, _colorPaletteState = [], _defaultSound = [0.5, 0, 1750, , , 0.3, 1, , , , 600, 0.1], _eventListeners = {}; const instance = { W: 0, H: 0, T: 0, MX: -1, MY: -1, TAU, lerp: (start, end, t) => { return start + t * (end - start); }, deg2rad: (degs) => { return (math.PI / 180) * degs; }, rad2deg: (rads) => { return (180 / math.PI) * rads; }, mod(a, b) { return ((a % b) + b) % b || 0; }, round: (n, precision = 0) => { if (!precision) { return math.round(n); } const multiplier = 10 ** precision; return math.round(n * multiplier) / multiplier; }, clamp: (value, min, max) => { if (value < min) return min; if (value > max) return max; return value; }, dist: (x1, y1, x2, y2) => { return math.hypot(x2 - x1, y2 - y1); }, wrap: (value, min, max) => { return value - (max - min) * math.floor((value - min) / (max - min)); }, map(value, start1, stop1, start2, stop2, withinBounds) { const result = ((value - start1) / (stop1 - start1)) * (stop2 - start2) + start2; return withinBounds ? instance.clamp(result, start2, stop2) : result; }, norm: (value, start, stop) => { return instance.map(value, start, stop, 0, 1); }, rand: (min = 0, max = 1) => { const a = 1664525; const c = 1013904223; const m = 4294967296; _rngSeed = (a * _rngSeed + c) % m; return (_rngSeed / m) * (max - min) + min; }, randi: (min = 0, max = 1) => { return ~~instance.rand(min, max + 1); }, rseed(value) { _rngSeed = ~~value; }, cls(color) { if (null == color) { _ctx.clearRect(0, 0, instance.W, instance.H); } else { instance.rectfill(0, 0, instance.W, instance.H, color); } }, rect(x, y, width, height, color, radii) { beginPath(_ctx); _ctx[radii ? "roundRect" : "rect"]( ~~x - _outline_fix, ~~y - _outline_fix, ~~width + _outline_fix * 2, ~~height + _outline_fix * 2, radii, ); instance.stroke(color); }, rectfill(x, y, width, height, color, radii) { beginPath(_ctx); _ctx[radii ? "roundRect" : "rect"](~~x, ~~y, ~~width, ~~height, radii); instance.fill(color); }, oval(x, y, radiusX, radiusY, color) { beginPath(_ctx); _ctx.ellipse(~~x, ~~y, ~~radiusX, ~~radiusY, 0, 0, TAU); instance.stroke(color); }, ovalfill(x, y, radiusX, radiusY, color) { beginPath(_ctx); _ctx.ellipse(~~x, ~~y, ~~radiusX, ~~radiusY, 0, 0, TAU); instance.fill(color); }, circ(x, y, radius, color) { instance.oval(x, y, radius, radius, color); }, circfill(x, y, radius, color) { instance.ovalfill(x, y, radius, radius, color); }, shape(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]); }, line(x1, y1, x2, y2, color) { 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); }, linewidth(value) { _ctx.lineWidth = ~~value; _outline_fix = ~~value % 2 ? 0.5 : 0; }, linedash(segments, offset = 0) { _ctx.setLineDash(segments); _ctx.lineDashOffset = offset; }, text(x, y, message, color = _defaultTextColor, fontStyle = "normal") { _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, ); } }, textgap(value) { _fontLineHeight = value; }, textfont(family) { _fontFamily = family; }, textsize(size) { _fontSize = size; }, textalign(align, baseline) { if (align) _ctx.textAlign = align; if (baseline) _ctx.textBaseline = baseline; }, image(x, y, source) { _ctx.drawImage(source, ~~x, ~~y); }, spr(x, y, pixels) { 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, ); } } } }, paint(width, height, callback, options = {}) { const canvas = options.canvas || new OffscreenCanvas(1, 1), scale = options.scale || 1, currentContext = _ctx; canvas.width = width * scale; canvas.height = height * scale; _ctx = canvas.getContext("2d"); _ctx.scale(scale, scale); callback(_ctx); _ctx = currentContext; return canvas.transferToImageBitmap(); }, ctx(context) { if (context) { _ctx = context; } return _ctx; }, push( translateX = 0, translateY = translateX, rotation = 0, scaleX = 1, scaleY = scaleX, ) { _ctx.save(); instance.translate(translateX, translateY); instance.rotate(rotation); instance.scale(scaleX, scaleY); }, pop() { _ctx.restore(); }, translate(x, y) { _ctx.translate(~~x, ~~y); }, scale(x, y = x) { _ctx.scale(x, y); }, rotate(radians) { _ctx.rotate(radians); }, alpha(value) { _ctx.globalAlpha = instance.clamp(value, 0, 1); }, fill(color) { _ctx.fillStyle = getColor(color); _ctx.fill(); }, stroke(color) { _ctx.strokeStyle = getColor(color); _ctx.stroke(); }, clip(callback) { beginPath(_ctx); callback(_ctx); _ctx.clip(); }, sfx(zzfxParams, pitchSlide, volumeFactor) { if ( !root.zzfxV || (navigator.userActivation && !navigator.userActivation.hasBeenActive) ) { return false; } zzfxParams ||= _defaultSound; if (pitchSlide || volumeFactor >= 0) { zzfxParams = zzfxParams.slice(); zzfxParams[0] = volumeFactor * (zzfxParams[0] || 1); zzfxParams[10] = ~~zzfxParams[10] + pitchSlide; } zzfx.apply(0, zzfxParams); return zzfxParams; }, volume(value) { root.zzfxV = value; }, canvas: () => _canvas, use(callback, config = {}) { loadPlugin(callback, config); }, resize(width, height = width, autoscale) { settings.width = width; settings.height = height; settings.autoscale = null == autoscale ? settings.autoscale : autoscale; resizeCanvas(); }, listen: (eventName, callback) => { eventName = lowerCase(eventName); _eventListeners[eventName] = _eventListeners[eventName] || new Set(); _eventListeners[eventName].add(callback); }, unlisten: (eventName, callback) => { eventName = lowerCase(eventName); if (_eventListeners[eventName]) { _eventListeners[eventName].delete(callback); } }, emit(eventName, arg1, arg2, arg3, arg4) { if (_initialized) { eventName = lowerCase(eventName); triggerEvent("before:" + eventName, arg1, arg2, arg3, arg4); if ( !_loop && root[eventName] !== instance[eventName] && "function" === typeof root[eventName] ) { root[eventName](arg1, arg2, arg3, arg4); } triggerEvent(eventName, arg1, arg2, arg3, arg4); triggerEvent("after:" + eventName, arg1, arg2, arg3, arg4); } return arg1; }, pal(colors, textColor = 3) { if (null == colors) { _currentPalette = _lastPalette; } else { _lastPalette = _currentPalette; _currentPalette = colors; } _colorPaletteState = []; _defaultTextColor = textColor; instance.emit("pal", _currentPalette, _defaultTextColor); }, palc(a, b) { if (null == a) { _colorPaletteState = []; } else { _colorPaletteState[a] = b; } }, def(key, value) { instance[key] = value; if (settings.global) { root[key] = value; } }, timescale(value) { _timeScale = value; }, framerate(value) { _fpsInterval = 1e3 / ~~value; }, stat(index) { const internals = [ settings, _initialized, _fpsInterval / 1e3, _canvasScale, _eventListeners, _currentPalette, _defaultSound, _timeScale, root.zzfxV, _rngSeed, _fontSize, _fontFamily, _colorPaletteState, _fontLineHeight, ]; return internals[index]; }, ispaused() { return _paused; }, pause() { if (!_paused) { _paused = true; _rafid = ~~cancelAnimationFrame(_rafid); instance.emit("paused"); } }, resume() { if (_initialized && _paused) { startGameLoop(); _paused = false; instance.emit("resumed"); } }, quit() { instance.emit("quit"); instance.pause(); _initialized = false; _eventListeners = {}; for (const removeListener of _browserEventListeners) { removeListener(); } if (settings.global) { for (const key in instance) { delete root[key]; } delete root.ENGINE; } }, }; const mathProps = "PI,sin,cos,atan2,hypot,tan,abs,ceil,floor,trunc,min,max,pow,sqrt,sign,exp"; for (const k of mathProps.split(",")) { instance[k] = math[k]; } function startGameLoop() { if (!_rafid) { _accumulated = 0; _lastFrameTime = perf.now(); _rafid = raf(drawFrame); } } function init() { if (settings.autoscale) { on(root, "resize", resizeCanvas); } if (settings.tapEvents) { const _getXY = (ev) => [ (ev.pageX - _canvas.offsetLeft) / _canvasScale, (ev.pageY - _canvas.offsetTop) / _canvasScale, ], _taps = new Map(), _registerTap = (id, x, y) => { const tap = { x, y, xi: x, yi: y, t: perf.now() }; _taps.set(id, tap); return tap; }, _updateTap = (id, x, y) => { const tap = _taps.get(id) || _registerTap(id); tap.x = x; tap.y = y; }, _checkTapped = (tap) => tap && perf.now() - tap.t <= 300; let _pressingMouse = false; on(_canvas, "mousedown", (ev) => { if (!ev.button) { preventDefault(ev); const [x, y] = _getXY(ev); instance.emit("tap", x, y, 0); _registerTap(0, x, y); _pressingMouse = true; } }); on(_canvas, "mouseup", (ev) => { if (!ev.button) { 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", (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", (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", (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 = []; 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 = new Set(); const _keysPress = new Set(); const keyCheck = (keySet, key = "") => { key = lowerCase(key); return key ? keySet.has("space" === key ? " " : key) : keySet.size > 0; }; let _lastKey = ""; on(root, "keydown", (event) => { const key = lowerCase(event.key); if (!_keysDown.has(key)) { _keysDown.add(key); _keysPress.add(key); _lastKey = key === " " ? "space" : key; } }); on(root, "keyup", (event) => { _keysDown.delete(lowerCase(event.key)); }); on(root, "blur", () => _keysDown.clear()); instance.listen("after:update", () => _keysPress.clear()); instance.def("iskeydown", (key) => { return keyCheck(_keysDown, key); }); instance.def("iskeypressed", (key) => { return keyCheck(_keysPress, key); }); instance.def("lastkey", () => _lastKey); } _initialized = true; instance.emit("init", instance); if (!_paused) { startGameLoop(); } } function drawFrame() { _rafid = raf(drawFrame); let now = perf.now(); let updated = 0; let frameTime = now - _lastFrameTime; _lastFrameTime = now; _accumulated += frameTime < 100 ? frameTime : _fpsInterval; while (_accumulated >= _fpsInterval) { updated++; _accumulated -= _fpsInterval; let dt = (_fpsInterval / 1e3) * _timeScale; instance.emit("update", dt, updated); instance.def("T", instance.T + dt); } if (updated) { instance.emit("draw", _ctx); if (updated > 1) { _accumulated = 0; } } } function setupCanvas() { const d = document; if ("string" === typeof settings.canvas) { _canvas = d.querySelector(settings.canvas); } else { _canvas = settings.canvas; } _canvas = _canvas || d.createElement("canvas"); _ctx = _canvas.getContext("2d"); on(_canvas, "click", () => focus()); if (!_canvas.parentNode) { d.body.appendChild(_canvas); } _canvas.oncontextmenu = () => false; } function resizeCanvas() { 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; _canvas.style = "image-rendering:pixelated"; if (settings.autoscale) { let maxScale = +settings.autoscale; if (!_canvas.style.display) { _canvas.style.display = "block"; _canvas.style.margin = "auto"; } _canvasScale = math.min(innerWidth / width, innerHeight / height); _canvasScale = maxScale > 1 && _canvasScale > maxScale ? maxScale : _canvasScale; _canvas.style.width = width * _canvasScale + "px"; _canvas.style.height = height * _canvasScale + "px"; } _ctx.imageSmoothingEnabled = false; instance.textalign("start", "top"); instance.emit("resized", _canvasScale); } function triggerEvent(eventName, arg1, arg2, arg3, arg4) { if (_eventListeners[eventName]) { for (const callback of _eventListeners[eventName]) { callback(arg1, arg2, arg3, arg4); } } } function loadPlugin(callback, config) { const pluginData = callback(instance, config); for (const key in pluginData) { instance.def(key, pluginData[key]); } } function getColor(index) { const i = _colorPaletteState[index] ?? index; return _currentPalette[~~i % _currentPalette.length]; } if (settings.global) { if (root.ENGINE) { throw new Error("only one global litecanvas is allowed"); } Object.assign(root, instance); root.ENGINE = instance; } setupCanvas(); resizeCanvas(); if (_loop) { for (const eventName in _loop) { if (_loop[eventName]) instance.listen(eventName, _loop[eventName]); } } raf(init); return instance; } window.litecanvas = litecanvas; })();