/**
* @file Engine class controlling the main canvas loop
* @author Lorenzo Rossi
*/
import { Color } from "./color.js";
import { Point } from "./point.js";
import "./jszip.js";
/** Class containing the main engine running a canvas */
class Engine {
/**
* Create the engine controlling a canvas
* @param {object} canvas DOM element containing the canvas
*/
constructor(canvas) {
this._canvas = canvas;
// init variables
this._frame_count = 0;
this._no_loop = false;
this._is_recording = false;
this._first_frame_recorded = 0;
this._frames_recorded = 0;
this._zip = null;
this._raf_id = null;
// delta time buffer
this._dt_buffer = new MovingAverage(30);
// last timed frame
this._last_timestamp = 0;
// mouse coordinates
this._mouse_coords = new Point(0, 0);
this._p_mouse_coords = new Point(0, 0);
// extract the drawing context
this._ctx = this._canvas.getContext("2d", { alpha: false });
// start sketch
this._run();
}
/**
* Starts the sketch
* @private
*/
_run() {
// anti alias
this._ctx.imageSmoothingQuality = "high";
this.preload();
this.setup();
this._timeDraw();
}
/**
* Handles time update
* @private
*/
_timeDraw(timestamp) {
// request next frame and store the id
this._raf_id = window.requestAnimationFrame(this._timeDraw.bind(this));
if (timestamp === undefined) return;
if (this._no_loop) return;
if (this._last_timestamp === 0) {
this._last_timestamp = timestamp;
}
const dt = timestamp - this._last_timestamp;
// draw the frame
this._ctx.save();
this.draw(dt);
this._ctx.restore();
// save current frame if recording
if (this._is_recording) {
// compute frame name
const frame_count = this._frame_count - this._first_frame_recorded;
const filename = frame_count.toString().padStart(7, 0) + ".png";
// extract data from canvas
const data = this._canvas.toDataURL("image/png").split(";base64,")[1];
// add frame to zip
this._zip.file(filename, data, { base64: true });
// update the count of recorded frames
this._frames_recorded++;
}
// update delta time buffer
this._dt_buffer.push(dt);
this._last_timestamp = timestamp;
// update frame count and timing
this._frame_count++;
}
/**
* Starts looping the script
*/
loop() {
this._no_loop = false;
}
/**
* Stops looping the script
*/
noLoop() {
this._no_loop = true;
}
/**
* Completely stop the engine.
* Note that this step is final and there's no way to programatically restart it after this call.
*/
stop() {
if (this._raf_id !== null) {
window.cancelAnimationFrame(this._raf_id);
this._raf_id = null;
}
}
/**
* Start recording frames
*/
startRecording() {
this._is_recording = true;
this._first_frame_recorded = this._frame_count;
this._frames_recorded = 0;
this._zip = new JSZip();
}
/**
* Stop recording frames
*/
stopRecording() {
this._is_recording = false;
}
/**
* Save the recording as a series of frames in a zip file
* @param {string} filename of the file to download
*/
saveRecording(filename = "frames.zip") {
// if the recording is not active, do nothing
// also skipped if no frame has been recorded
if (this._is_recording || this._zip == null || this._frames_recorded == 0)
return;
// download zip file
this._zip.generateAsync({ type: "blob" }).then((blob) => {
// convert blob to url
const data = URL.createObjectURL(blob);
//
this._downloadFile(filename, data);
});
}
/**
* Save current frame
* @param {string} [title] The image filename (optional).
*/
saveFrame(title = null) {
if (title == null)
title = "frame-" + this._frame_count.toString().padStart(6, 0);
this._downloadFile(title, this._canvas.toDataURL("image/png"));
}
/**
* Returns the coordinates corresponding to a mouse/touch event
* @param {object} e event
* @returns {Point} x,y coordinates
* @private
*/
_calculatePressCoords(e) {
// calculate size ratio
const boundingBox = this._canvas.getBoundingClientRect();
const ratio =
Math.min(boundingBox.width, boundingBox.height) /
this._canvas.getAttribute("width");
// calculate real mouse/touch position
if ("touches" in e) {
// we're dealing with a touchscreen
if (e.touches.length > 0) {
// touch has been pressed
const tx = (e.touches[0].pageX - boundingBox.left) / ratio;
const ty = (e.touches[0].pageY - boundingBox.top) / ratio;
return new Point(tx, ty);
}
// touch has been released
return new Point(-1, -1);
} else {
// we're dealing with a mouse
const mx = (e.pageX - boundingBox.left) / ratio;
const my = (e.pageY - boundingBox.top) / ratio;
return new Point(mx, my);
}
}
/**
* Create and download a file
* @param {string} filename name of the file
* @param {string} data data url or base64 data
* @private
*/
_downloadFile(filename, data) {
// create file
const container = document.createElement("a");
container.href = data;
container.setAttribute("download", filename);
document.body.appendChild(container);
// download file
container.click();
document.body.removeChild(container);
}
/**
* Handler for mouse click/touchscreen tap
* @param {MouseEvent} e event
*/
clickHandler(e) {
const p = this._calculatePressCoords(e);
this.click(p.x, p.y);
}
/**
* Handler for mouse down
* @param {MouseEvent} e event
*/
mouseDownHandler(e) {
this._mouse_pressed = true;
const p = this._calculatePressCoords(e);
this.mouseDown(p.x, p.y);
}
/**
* Handler for mouse up
* @param {MouseEvent} e event
*/
mouseUpHandler(e) {
this._mouse_pressed = false;
const p = this._calculatePressCoords(e);
this.mouseUp(p.x, p.y);
}
/**
* Handler for moved mouse
* @param {MouseEvent} e event
*/
mouseMoveHandler(e) {
const p = this._calculatePressCoords(e);
// update mouse position
this._p_mouse_coords = this._mouse_coords.copy();
this._mouse_coords = p.copy();
if (!this._mouse_pressed) {
this.mouseMoved(p.x, p.y);
} else {
this.mouseDragged(p.x, p.y);
}
}
/**
* Handler for key pressed event
* @param {KeyboardEvent} e event
*/
keyDownHandler(e) {
this.keyDown(e.key, e.code);
}
/**
* Handler for key up event
* @param {KeyboardEvent} e event
*/
keyUpHandler(e) {
this.keyUp(e.key, e.code);
}
/**
* Handler for key press event
* @param {KeyboardEvent} e event
*/
keyPressHandler(e) {
this.keyPress(e.key, e.code);
}
/**
* Public callback for mouse click and touchscreen tap
* @param {number} x coordinate of the click/tap location
* @param {number} y coordinate of the click/tap location
*/
click(x, y) {}
/**
* Public callback for mouse and touchscreen pressed
* @param {number} x coordinate of the click/tap location
* @param {number} y coordinate of the click/tap location
*/
mouseDown(x, y) {}
/**
* Public callback for mouse and touchscreen drag
* @param {number} x coordinate of the click/tap location
* @param {number} y coordinate of the click/tap location
*/
mouseDragged(x, y) {}
/**
* Public callback for mouse and touchscreen up
* @param {number} x coordinate of the click/tap location
* @param {number} y coordinate of the click/tap location
*/
mouseUp(x, y) {}
/**
* Public callback for mouse and touchscreen moved
* @param {number} x coordinate of the click/tap location
* @param {number} y coordinate of the click/tap location
*/
mouseMoved(x, y) {}
/**
* Public callback for key press
* @param {string} key pressed key
* @param {string} code code of the pressed key
*/
keyPress(key, code) {}
/**
* Public callback for key down
* @param {string} key pressed key
* @param {number} code code of the pressed key
*/
keyDown(key, code) {}
/**
* Public callback for key up
* @param {string} key pressed key
* @param {number} code code of the pressed key
*/
keyUp(key, code) {}
/**
* Set the background color for the canvas
* @param {string | number} color Color can be a CSS< RGB, RGBA, HEX, HEAX, HSL, HSLA string, a Color object, or a monochrome value (number)
*/
background(color) {
// reset background
this._ctx.save();
// reset canvas
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
// set background
if (typeof color === "number")
this._ctx.fillStyle = Color.fromMonochrome(color).rgba;
else if (color instanceof Color) this._ctx.fillStyle = color.rgba;
else this._ctx.fillStyle = color;
this._ctx.fillRect(0, 0, this._canvas.width, this._canvas.height);
this._ctx.restore();
}
/**
* Scale the canvas by a factor from the center.
* If only one parameter is passed, the canvas will be scaled uniformly.
* @param {number} x scaling factor in the x direction
* @param {number} y scaling factor in the y direction
*/
scaleFromCenter(x, y = null) {
if (y == null) y = x;
this._ctx.translate(this._canvas.width / 2, this._canvas.height / 2);
this._ctx.scale(x, y);
this._ctx.translate(-this._canvas.width / 2, -this._canvas.height / 2);
}
/**
* Function ran once, before the sketch is actually loaded
*/
preload() {}
/**
* Function ran once
*/
setup() {}
/**
* Main sketch function, will be run continuously unless noLoop() is called
* @param {number} dt Delta time in milliseconds since last frame
*/
draw(dt) {}
/**
* Get the current drawing context
* @returns {object} The current drawing context
*/
get ctx() {
return this._ctx;
}
/**
* Get the current drawing canvas
* @returns {object} The current drawing canvas
*/
get canvas() {
return this._canvas;
}
/**
* Get the count of frames since the start
* @returns {number} The number of total frames
*/
get frameCount() {
return this._frame_count;
}
/**
* Get the current framerate as frames per second (fps)
* @returns {number} The current fps
*/
get frameRate() {
return 1000 / this._dt_buffer.latest;
}
/**
* Get the average framerate as frames per second (fps)
* @returns {number} The average fps
*/
get frameRateAverage() {
return 1000 / this._dt_buffer.average;
}
/**
* Get the current framerate as milliseconds per frame (mspf)
* @returns {number} The current mspf
*/
get deltaTime() {
return this._dt_buffer.latest;
}
/**
* Get the average framerate as milliseconds per frame (mspf)
* @returns {number} The average mspf
*/
get deltaTimeAverage() {
return this._dt_buffer.average;
}
/**
* Get the drawing area width
* @returns {number} The drawing area width
*/
get width() {
return this._canvas.width;
}
/**
* Get the drawing area height
* @returns {number} The drawing area height
*/
get height() {
return this._canvas.height;
}
/**
* Get the current recording state
* @returns {boolean} The current recording state
*/
get is_recording() {
return this._is_recording;
}
/**
* Get the current mouse position
* @returns {Point} The current mouse position
* @readonly
*/
get mousePosition() {
return this._mouse_coords.copy();
}
/**
* Get the previous mouse position
* @returns {Point} The previous mouse position
* @readonly
*/
get prevMousePosition() {
return this._p_mouse_coords.copy();
}
}
/**
* Class for a moving average buffer.
* @private
*/
class MovingAverage {
/**
*
* @param {number} size of the buffer
*/
constructor(size) {
this._size = size;
this._sum = 0;
this._count = 0;
this._average = 0;
this._buffer = new Array(size).fill(0);
}
/**
* Add a value to the buffer
* @param {number} value value to add
*/
push(value) {
const index = this._count % this._size;
this._sum += value - this._buffer[index];
this._buffer[index] = value;
this._count++;
const size = Math.min(this._count, this._size);
this._average = this._sum / size;
}
/**
* Get the current average
* @returns {number} average
*/
get average() {
return this._average;
}
/**
* Get the last stored value
* @returns {number} last value
*/
get latest() {
if (this._count == 0) return 0;
const index = (this._count - 1) % this._size;
return this._buffer[index];
}
}
export { Engine };