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
JavaScript
(() => {
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;
})();