/**
* @file mgb_canvas_engine.js
* @description API documentation for the MGB Canvas Engine.
*
* This file implements the MGB Canvas Engine which provides functionality
* for drawing, sprite management, collision detection, and event handling
* using the HTML5 Canvas.
*/
/** @global {boolean} debug - Flag for enabling debug mode */
let debug = true;
/** @global {HTMLCanvasElement} canvasEl - The main canvas element */
const canvasEl = document.getElementById('canvasEl');
/** @global {CanvasRenderingContext2D} ctx - The 2D rendering context for the canvas */
const ctx = canvasEl.getContext('2d');
/** @global {HTMLImageElement|null} backgroundImg - The background image */
let backgroundImg = null;
/**
* @typedef {Object} Cursor
* @property {number} x - Current x-coordinate of the cursor.
* @property {number} y - Current y-coordinate of the cursor.
* @property {boolean} isDown - Whether the cursor button is pressed.
* @property {boolean} left - Whether the left button is pressed.
* @property {boolean} right - Whether the right button is pressed.
*/
/** @global {Cursor} cursor - The cursor state */
const cursor = {
x: 0,
y: 0,
isDown: false,
left: false,
right: false
};
/** @global {Object} prevKeys - Object tracking previous key states */
const prevKeys = {};
/**
* Sets the background image of the document.
*
* If the source is empty or the image fails to load, the background is set to black.
*
* @param {string} src - URL of the background image.
*/
function setBackground(src) {
if (!src) {
backgroundImg = null;
document.body.style.backgroundColor = 'black';
return;
}
const img = new Image();
img.onload = () => {
backgroundImg = img;
document.body.style.backgroundColor = '';
};
img.onerror = () => {
console.warn(`Failed to load background image: ${src}`);
backgroundImg = null;
document.body.style.backgroundColor = 'black';
};
img.src = src;
}
/** @global {HTMLElement} mousePosEl - Element for displaying mouse position */
const mousePosEl = document.getElementById('mousePos');
/** @global {HTMLElement} hoverInfoEl - Element for displaying hover information */
const hoverInfoEl = document.getElementById('hoverInfo');
/** @global {Object} keys - Object for tracking current key states */
const keys = {};
/** @global {Array} drawables - Array that holds all drawable objects (sprites and text) */
const drawables = [];
/**
* Base class for all drawable objects on the canvas.
*/
class Drawable {
/**
* Creates a new Drawable.
*
* @param {number} x - The initial x-coordinate.
* @param {number} y - The initial y-coordinate.
*/
constructor(x, y) {
this.x = x;
this.y = y;
/** @type {boolean} */
this.hidden = false;
}
/**
* Draws the object on the canvas.
* This method should be overridden by subclasses.
*/
draw() {
// To be overridden by subclasses
}
}
/**
* Represents a sprite with image costumes, collision detection, and event handling.
* @extends Drawable
*/
class Sprite extends Drawable {
/**
* Creates a new Sprite.
*
* @param {number} [x=0] - Initial x-coordinate.
* @param {number} [y=0] - Initial y-coordinate.
* @param {string} [color='white'] - The sprite's color.
* @param {...string} imageSrcs - One or more image source URLs for the sprite costumes.
*/
constructor(x = 0, y = 0, color = 'white', ...imageSrcs) {
super(x, y);
this.color = color;
this.prevX = this.x;
this.prevY = this.y;
this.size = 30;
this.speed = 5;
this.border = false;
this.touching = [];
this.touchCallbacks = [];
this.touchOnceCache = new Set();
this.touchOnceCallbacks = [];
this.touchEndCallbacks = [];
this.costumes = [];
this.currentCostume = 0;
this.loadedCostumes = [];
this.events = {};
this.useOriginalSize = true;
this.scale = 1.0;
this.controls = null;
this.gravity = 0;
this.hitbox = false;
// Pen properties for drawing trails
this.penDown = false;
this.penTrails = [];
this.currentPath = null;
this.penColor = this.color;
this.penThickness = 1;
for (const src of imageSrcs) {
const img = new Image();
img.onload = () => this.loadedCostumes.push(img);
img.onerror = () => console.warn(`Failed to load image: ${src}`);
img.src = src;
this.costumes.push(img);
}
}
/**
* Updates the sprite. Override to define custom update behavior.
*/
update() {
// Default does nothing; override as needed.
}
/**
* Registers an event callback for the sprite.
*
* @param {string} eventName - Name of the event.
* @param {Function} callback - Callback function to invoke.
*/
on(eventName, callback) {
if (!this.events[eventName]) this.events[eventName] = [];
this.events[eventName].push(callback);
}
/**
* Registers a continuous touch event callback.
*
* @param {Sprite} target - The target sprite to detect touch with.
* @param {Function} callback - Function to call when touching the target.
*/
onTouch(target, callback) {
this.touchCallbacks.push({ target, callback });
}
/**
* Registers a one-time touch event callback.
*
* @param {Sprite} target - The target sprite.
* @param {Function} callback - Function to call once when touching the target.
*/
onTouchOnce(target, callback) {
this.touchOnceCallbacks.push({ target, callback });
}
/**
* Registers a callback for when the sprite stops touching a target.
*
* @param {Sprite} target - The target sprite.
* @param {Function} callback - Function to call when touch ends.
*/
onTouchEnd(target, callback) {
this.touchEndCallbacks.push({ target, callback });
}
/**
* Starts drawing a pen trail.
*/
startDrawing() {
if (!this.penDown) {
this.penDown = true;
this.currentPath = [{ x: this.x, y: this.y }];
this.penTrails.push(this.currentPath);
}
}
/**
* Stops drawing a pen trail.
*/
stopDrawing() {
this.penDown = false;
this.currentPath = null;
}
/**
* Clears all pen trails.
*/
clearPen() {
this.penTrails = [];
this.currentPath = null;
}
/**
* Triggers an event.
*
* @param {string} eventName - Name of the event.
* @param {*} eventObject - Data associated with the event.
*/
trigger(eventName, eventObject) {
if (this.events[eventName]) {
for (const cb of this.events[eventName]) {
cb(eventObject);
}
}
}
/**
* Checks if the sprite is clicked based on mouse coordinates.
*
* @param {number} mouseX - The x-coordinate of the mouse.
* @param {number} mouseY - The y-coordinate of the mouse.
* @returns {boolean} True if clicked; otherwise, false.
*/
isClicked(mouseX, mouseY) {
const size = this.getCollisionSize();
const w = size.width;
const h = size.height;
return (
mouseX >= this.x - w / 2 &&
mouseX <= this.x + w / 2 &&
mouseY >= this.y - h / 2 &&
mouseY <= this.y + h / 2
);
}
/**
* Checks if this sprite is touching another sprite.
*
* @param {Sprite} other - The other sprite to check against.
* @returns {boolean} True if touching; otherwise, false.
*/
isTouching(other) {
if (this.hitboxPolygon && other.hitboxPolygon) {
const poly1 = this.hitboxPolygon.map(vertex => ({
x: vertex.x * this.scale + this.x,
y: vertex.y * this.scale + this.y
}));
const poly2 = other.hitboxPolygon.map(vertex => ({
x: vertex.x * other.scale + other.x,
y: vertex.y * other.scale + other.y
}));
return polygonsIntersect(poly1, poly2);
}
const a = this.getCollisionSize();
const b = other.getCollisionSize();
return (
Math.abs(this.x - other.x) < (a.width + b.width) / 2 &&
Math.abs(this.y - other.y) < (a.height + b.height) / 2
);
}
/**
* Draws the sprite on the canvas.
*/
draw() {
if (this.hidden) return;
const img = this.costumes[this.currentCostume];
if (img && img.complete && img.naturalWidth > 0) {
let w, h;
if (this.useOriginalSize) {
w = img.naturalWidth * this.scale;
h = img.naturalHeight * this.scale;
} else {
w = this.size;
h = this.size;
}
ctx.drawImage(img, this.x - w / 2, this.y - h / 2, w, h);
} else {
ctx.fillStyle = this.color;
ctx.fillRect(this.x - this.size / 2, this.y - this.size / 2, this.size, this.size);
}
}
/**
* Sets the sprite's costume.
*
* @param {number} index - The index of the costume to display.
*/
setCostume(index) {
if (index >= 0 && index < this.costumes.length) this.currentCostume = index;
}
/**
* Checks if the sprite is hovered by the mouse.
*
* @param {number} mx - Mouse x-coordinate.
* @param {number} my - Mouse y-coordinate.
* @returns {boolean} True if hovered; otherwise, false.
*/
isHovered(mx, my) {
return this.isClicked(mx, my);
}
/**
* Sets the control scheme for the sprite.
*
* @param {Object} scheme - An object mapping controls (e.g., left, right, up, down).
*/
setControlScheme(scheme) {
this.controls = scheme;
}
/**
* Retrieves the collision size of the sprite.
*
* @returns {{width: number, height: number}} An object containing width and height.
*/
getCollisionSize() {
const img = this.costumes[this.currentCostume];
if (this.useOriginalSize && img && img.complete && img.naturalWidth > 0) {
return { width: img.naturalWidth * this.scale, height: img.naturalHeight * this.scale };
}
return { width: this.size, height: this.size };
}
/**
* Determines if the sprite is on the ground.
*
* @returns {boolean} True if on ground; otherwise, false.
*/
isOnGround() {
return this.touching.some(s => s.hitbox);
}
}
/**
* Represents a text object to be drawn on the canvas.
* @extends Drawable
*/
class Text extends Drawable {
/**
* Creates a new text object.
*
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
* @param {string} color - The text color.
* @param {string} text - The string content.
* @param {string} [font="20px monospace"] - The font style.
*/
constructor(x, y, color, text, font = "20px monospace") {
super(x, y);
this.color = color;
this.text = text;
this.font = font;
}
/**
* Draws the text on the canvas.
*/
draw() {
if (this.hidden) return;
ctx.fillStyle = this.color;
ctx.font = this.font;
ctx.fillText(this.text, this.x, this.y);
}
}
/**
* Creates a new sprite and adds it to the list of drawable objects.
*
* @param {number} [x=0] - Initial x-coordinate.
* @param {number} [y=0] - Initial y-coordinate.
* @param {string} [color='white'] - The sprite's color.
* @param {...string} imageSrcs - One or more image source URLs.
* @returns {Sprite} The newly created sprite.
*/
function createSprite(x = 0, y = 0, color = 'white', ...imageSrcs) {
const sprite = new Sprite(x, y, color, ...imageSrcs);
sprite.prevX = x;
sprite.prevY = y;
drawables.push(sprite);
return sprite;
}
/**
* Creates a new text object and adds it to the list of drawable objects.
*
* @param {number} x - The x-coordinate.
* @param {number} y - The y-coordinate.
* @param {string} color - The text color.
* @param {string} text - The text content.
* @param {string} [font="20px monospace"] - The font style.
* @returns {Text} The newly created text object.
*/
function createText(x, y, color, text, font = "20px monospace") {
const textObj = new Text(x, y, color, text, font);
drawables.push(textObj);
return textObj;
}
/**
* Resizes the canvas to match the window dimensions.
*/
function resizeCanvas() {
canvasEl.width = window.innerWidth;
canvasEl.height = window.innerHeight;
}
/**
* Hides a drawable object.
*
* @param {Drawable} object - The object to hide.
*/
function hide(object) {
object.hidden = true;
}
/**
* Determines if two polygons intersect using the Separating Axis Theorem.
*
* @param {Array<Object>} poly1 - Array of points defining the first polygon.
* @param {Array<Object>} poly2 - Array of points defining the second polygon.
* @returns {boolean} True if the polygons intersect; otherwise, false.
*/
function polygonsIntersect(poly1, poly2) {
function getAxes(polygon) {
const axes = [];
for (let i = 0; i < polygon.length; i++) {
const p1 = polygon[i];
const p2 = polygon[(i + 1) % polygon.length];
const edge = { x: p2.x - p1.x, y: p2.y - p1.y };
const normal = { x: -edge.y, y: edge.x };
const length = Math.hypot(normal.x, normal.y);
axes.push({ x: normal.x / length, y: normal.y / length });
}
return axes;
}
function project(polygon, axis) {
let min = Infinity, max = -Infinity;
polygon.forEach(point => {
const proj = point.x * axis.x + point.y * axis.y;
min = Math.min(min, proj);
max = Math.max(max, proj);
});
return { min, max };
}
function overlap(proj1, proj2) {
return proj1.max >= proj2.min && proj2.max >= proj1.min;
}
const axes1 = getAxes(poly1);
const axes2 = getAxes(poly2);
const axes = axes1.concat(axes2);
for (const axis of axes) {
const proj1 = project(poly1, axis);
const proj2 = project(poly2, axis);
if (!overlap(proj1, proj2)) return false;
}
return true;
}
/**
* Generates a hitbox polygon for an image by scanning its pixels.
*
* @param {HTMLImageElement} image - The image element.
* @param {number} [alphaThreshold=10] - Alpha threshold for edge detection.
* @returns {Array<Object>} An array of points representing the convex hull of the hitbox.
*/
function generateHitboxFromImage(image, alphaThreshold = 10) {
const offCanvas = document.createElement('canvas');
offCanvas.width = image.naturalWidth;
offCanvas.height = image.naturalHeight;
const offCtx = offCanvas.getContext('2d');
offCtx.drawImage(image, 0, 0);
const imageData = offCtx.getImageData(0, 0, image.naturalWidth, image.naturalHeight);
const data = imageData.data;
const points = [];
for (let y = 0; y < image.naturalHeight; y++) {
for (let x = 0; x < image.naturalWidth; x++) {
const index = (y * image.naturalWidth + x) * 4;
const alpha = data[index + 3];
if (alpha > alphaThreshold) {
let isEdge = false;
for (let ny = -1; ny <= 1 && !isEdge; ny++) {
for (let nx = -1; nx <= 1; nx++) {
const x2 = x + nx;
const y2 = y + ny;
if (x2 < 0 || x2 >= image.naturalWidth || y2 < 0 || y2 >= image.naturalHeight) {
isEdge = true;
break;
}
const index2 = (y2 * image.naturalWidth + x2) * 4;
const neighborAlpha = data[index2 + 3];
if (neighborAlpha <= alphaThreshold) {
isEdge = true;
break;
}
}
}
if (isEdge) {
points.push({ x, y });
}
}
}
}
const hull = convexHull(points);
return hull;
}
/**
* Computes the convex hull of a set of points using the Graham Scan algorithm.
*
* @param {Array<Object>} points - Array of points.
* @returns {Array<Object>} Array of points representing the convex hull.
*/
function convexHull(points) {
if (points.length < 3) return points;
let start = points[0];
for (const point of points) {
if (point.y < start.y || (point.y === start.y && point.x < start.x)) {
start = point;
}
}
const sorted = points.slice().sort((a, b) => {
const angleA = Math.atan2(a.y - start.y, a.x - start.x);
const angleB = Math.atan2(b.y - start.y, b.x - start.x);
return angleA - angleB;
});
const hull = [];
for (const point of sorted) {
while (hull.length >= 2 && cross(hull[hull.length - 2], hull[hull.length - 1], point) <= 0) {
hull.pop();
}
hull.push(point);
}
return hull;
}
/**
* Computes the cross product of vectors OA and OB.
*
* @param {Object} o - The origin point.
* @param {Object} a - Point A.
* @param {Object} b - Point B.
* @returns {number} The cross product.
*/
function cross(o, a, b) {
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
}
/**
* Automatically generates and assigns a hitbox polygon to a sprite.
*
* @param {Sprite} sprite - The sprite object.
* @param {number} [alphaThreshold=10] - Alpha threshold for hitbox generation.
*/
function autoGenerateHitbox(sprite, alphaThreshold = 10) {
const img = sprite.costumes[sprite.currentCostume];
if (img && img.complete && img.naturalWidth > 0) {
const polygon = generateHitboxFromImage(img, alphaThreshold);
sprite.hitboxPolygon = polygon.map(pt => ({
x: pt.x - img.naturalWidth / 2,
y: pt.y - img.naturalHeight / 2
}));
}
}
/**
* The main game loop that updates and renders all sprites.
*/
function gameLoop() {
ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
// Draw background
if (backgroundImg) {
ctx.drawImage(backgroundImg, 0, 0, canvasEl.width, canvasEl.height);
} else {
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvasEl.width, canvasEl.height);
}
// Update sprite positions and handle collisions
for (const sprite of drawables.filter(obj => obj instanceof Sprite)) {
let dx = 0;
let dy = 0;
if (sprite.controls) {
if (keys[sprite.controls.left]) dx -= sprite.speed;
if (keys[sprite.controls.right]) dx += sprite.speed;
if (keys[sprite.controls.up]) dy -= sprite.speed;
if (keys[sprite.controls.down]) dy += sprite.speed;
}
if (sprite.gravity) {
dy += sprite.gravity;
}
if (dx !== 0) {
sprite.x += dx;
for (const other of drawables.filter(obj => obj instanceof Sprite && obj.hitbox && obj !== sprite)) {
if (sprite.isTouching(other)) {
const a = sprite.getCollisionSize();
const b = other.getCollisionSize();
if (dx > 0) {
sprite.x = other.x - b.width / 2 - a.width / 2;
} else if (dx < 0) {
sprite.x = other.x + b.width / 2 + a.width / 2;
}
}
}
}
if (dy !== 0) {
sprite.y += dy;
for (const other of drawables.filter(obj => obj instanceof Sprite && obj.hitbox && obj !== sprite)) {
if (sprite.isTouching(other)) {
const a = sprite.getCollisionSize();
const b = other.getCollisionSize();
if (dy > 0) {
sprite.y = other.y - b.height / 2 - a.height / 2;
} else if (dy < 0) {
sprite.y = other.y + b.height / 2 + a.height / 2;
}
}
}
}
if (sprite.penDown && sprite.currentPath) {
const lastPoint = sprite.currentPath[sprite.currentPath.length - 1];
if (lastPoint.x !== sprite.x || lastPoint.y !== sprite.y) {
sprite.currentPath.push({ x: sprite.x, y: sprite.y });
}
}
const size = sprite.getCollisionSize();
const w = size.width;
const h = size.height;
sprite.border = false;
if (sprite.x - w / 2 < 0) { sprite.x = w / 2; sprite.border = true; }
if (sprite.y - h / 2 < 0) { sprite.y = h / 2; sprite.border = true; }
if (sprite.x + w / 2 > canvasEl.width) { sprite.x = canvasEl.width - w / 2; sprite.border = true; }
if (sprite.y + h / 2 > canvasEl.height) { sprite.y = canvasEl.height - h / 2; sprite.border = true; }
sprite.update();
}
// Handle collision events for touch callbacks
const collisionSprites = drawables.filter(obj => obj instanceof Sprite);
for (const sprite of collisionSprites) {
const prevTouching = new Set(sprite.touching);
sprite.touching = collisionSprites.filter(other => other !== sprite && sprite.isTouching(other));
for (const { target, callback } of sprite.touchCallbacks) {
sprite.touching.filter(s => s === target).forEach(() => callback());
}
for (const { target, callback } of sprite.touchOnceCallbacks) {
sprite.touching.filter(s => s === target && !sprite.touchOnceCache.has(s)).forEach(t => {
callback();
sprite.touchOnceCache.add(t);
});
}
for (const { target, callback } of sprite.touchEndCallbacks) {
prevTouching.forEach(t => {
if (t === target && !sprite.touching.includes(t)) {
callback();
sprite.touchOnceCache.delete(t);
}
});
}
}
// Draw pen trails
for (const sprite of drawables.filter(obj => obj instanceof Sprite)) {
for (const path of sprite.penTrails) {
if (path.length > 1) {
ctx.beginPath();
ctx.moveTo(path[0].x, path[0].y);
for (let i = 1; i < path.length; i++) {
ctx.lineTo(path[i].x, path[i].y);
}
ctx.strokeStyle = sprite.penColor;
ctx.lineWidth = sprite.penThickness;
ctx.stroke();
}
}
}
// Draw all drawable objects (sprites and text)
for (const obj of drawables) {
obj.draw();
}
requestAnimationFrame(gameLoop);
}
// Event listeners
window.addEventListener('keydown', e => keys[e.key] = true);
window.addEventListener('keyup', e => keys[e.key] = false);
canvasEl.addEventListener('mousemove', (e) => {
const rect = canvasEl.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
if (debug) {
let hovered = null;
for (const obj of drawables) {
if (obj instanceof Sprite && obj.isHovered(mouseX, mouseY)) {
hovered = obj;
break;
}
}
mousePosEl.textContent = `x: ${Math.floor(mouseX)}, y: ${Math.floor(mouseY)}${hovered ? ' (hovering: ' + (hovered.name || 'Unnamed') + ')' : ''}`;
if (hovered) {
ctx.font = '12px monospace';
ctx.fillStyle = 'white';
ctx.fillText(hovered.name || 'Unnamed', hovered.x + hovered.getCollisionSize().width / 2 + 4, hovered.y - hovered.getCollisionSize().height / 2 - 4);
}
mousePosEl.style.display = 'block';
hoverInfoEl.style.display = 'block';
if (hovered) {
hoverInfoEl.textContent = Object.entries(hovered)
.filter(([key, val]) => typeof val !== 'function')
.map(([key, val]) => {
if (Array.isArray(val)) return `${key}: [${val.length}]`;
if (val instanceof Set) return `${key}: Set(${val.size})`;
if (typeof val === 'object' && val !== null) return `${key}: {object}`;
return `${key}: ${val}`;
}).join("\n");
} else {
hoverInfoEl.textContent = '';
}
} else {
mousePosEl.style.display = 'none';
hoverInfoEl.style.display = 'none';
}
});
canvasEl.addEventListener('click', (e) => {
const rect = canvasEl.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
for (const obj of drawables) {
if (obj instanceof Sprite && obj.isClicked(mouseX, mouseY)) {
obj.trigger("click", e);
}
}
});
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
canvasEl.addEventListener('mousemove', (e) => {
const rect = canvasEl.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
cursor.x = mouseX;
cursor.y = mouseY;
if (debug) {
let hovered = null;
for (const obj of drawables) {
if (obj instanceof Sprite && obj.isHovered(mouseX, mouseY)) {
hovered = obj;
break;
}
}
mousePosEl.textContent = `x: ${Math.floor(mouseX)}, y: ${Math.floor(mouseY)}${hovered ? ' (hovering: ' + (hovered.name || 'Unnamed') + ')' : ''}`;
if (hovered) {
ctx.font = '12px monospace';
ctx.fillStyle = 'white';
ctx.fillText(hovered.name || 'Unnamed', hovered.x + hovered.getCollisionSize().width / 2 + 4, hovered.y - hovered.getCollisionSize().height / 2 - 4);
}
mousePosEl.style.display = 'block';
hoverInfoEl.style.display = 'block';
if (hovered) {
hoverInfoEl.textContent = Object.entries(hovered)
.filter(([key, val]) => typeof val !== 'function')
.map(([key, val]) => {
if (Array.isArray(val)) return `${key}: [${val.length}]`;
if (val instanceof Set) return `${key}: Set(${val.size})`;
if (typeof val === 'object' && val !== null) return `${key}: {object}`;
return `${key}: ${val}`;
}).join("\n");
} else {
hoverInfoEl.textContent = '';
}
} else {
mousePosEl.style.display = 'none';
hoverInfoEl.style.display = 'none';
}
});
canvasEl.addEventListener('mousedown', (e) => {
cursor.isDown = true;
if (e.button === 0) cursor.left = true;
if (e.button === 2) cursor.right = true;
});
canvasEl.addEventListener('mouseup', (e) => {
if (e.button === 0) cursor.left = false;
if (e.button === 2) cursor.right = false;
if (!cursor.left && !cursor.right) cursor.isDown = false;
});
canvasEl.addEventListener('contextmenu', (e) => e.preventDefault());
setTimeout(() => {
gameLoop();
}, 0);