/**
* @file SimpleCanvas
* @module SimpleCanvas
* @author Thomas Hinkle
*
* @overview A simple library for handling basic canvas drawings,
* callbacks and animations.
* @summary Created as a teaching library to give students a
* close-to-the-browser-API experience in game development. This
* library mainly makes it easy to handle things like animations and
* resizing without having to get into the weeds too quickly. To use,
* simply include this file in your HTML, or you can link to it by
* pasting the following tag into your HTML before you run your own
* code:
* @example
* <html>
* <canvas id="game">
* <script src="http://www.tomhinkle.net/util/SimpleCanvasLibrary.js"></script>
* <script>
* const game = new GameCanvas('game')
* game.addDrawing(
* function ({ctx, elapsed, width, height}) {
* let x = (100 * elapsed/1000) % width;
* let y = height/2
* ctx.beginPath();
* ctx.arc(x,y,20,0,Math.PI*2);
* ctx.stroke();
* }
* );
* game.run();
* </script>
* </html>
*
*
*
*/
/**
* GameCanvas sets up a canvas for creating a simple
* game in.
*
* Hand it the id of the canvas you want to use from your
* HTML document.
*
* It returns an object for registering functions to do
* the drawing and for registering callbacks to handlers
* events on the canvas.
* @constructor GameCanvas
* @param {string|Canvas} canvas - The Canvas Element OR the ID of the canvas element we will render the game in.
* @param {Object} config - Optional argument with initial size of canvas in pixels (otherwise we just measure the element)
* @param {Size} config.size=undefined - Optional size for canvas. Otherwise size is taken from explicitly set width/height OR from the element's size on the page.
* @param {boolean} config.autoresize=true - autoresize=true whether to resize the game canvas to the DOM canvas automatically (defaults to true)
* @memberof SimpleCanvas
*
* @example <caption>Make a simple spinner using only the addDrawing method.</caption>
* game = GameCanvas('game'); // Set up a game on <canvas id='game'>
* game.addDrawing(
* function ({ctx,elapsed}) {
* ctx.beginPath();
* ctx.moveTo(100,100);
* ctx.lineTo(100+Math.cos(elapsed/1000)*100,
* 100+Math.sin(elapsed/1000)*100);
* ctx.stroke()
* }
* );
* game.run();
*
*
* @example <caption>Create a game where clicking makes balls drop.</caption>
game = GameCanvas('game');
const colors = ['red','green','purple','yellow','orange']
const drawings = []; // track our drawings so we can remove them...
var colorIndex = 0;
var color = colors[colorIndex]
game.addClickHandler(
// When the canvas is clicked...
function ({x,y}) {
// Add the drawing to the game...
var id = game.addDrawing(
function ({ctx,elapsed,height}) {
var ypos = y + elapsed/5;
while (ypos > height) {
ypos -= height; // come around the top...
}
ctx.beginPath();
ctx.fillStyle = color;
ctx.arc(x,ypos,20,0,Math.PI*2);
ctx.fill();
} // end drawing function
)
drawings.push(id); // Keep track of our drawing so we can remove it.
// If we have too many drawings, remove the first one we put on...
if (drawings.length > colors.length) {
const toRemove = drawings.shift()
game.removeDrawing(toRemove);
}
// shift colors for next ball
colorIndex += 1;
if (colorIndex >= colors.length) {colorIndex = 0}
color = colors[colorIndex];
} // end click callback
);
game.run(); // run the game!
**/
function GameCanvas (id='game',{size={},autoresize=true}={}) {
// if user accidentally omits the new keyword, this will
// silently correct the problem...
if ( !(this instanceof GameCanvas) ) {
return new GameCanvas(id);
}
// constructor logic follows.
if (!id) {
throw new Error('GameCanvas must be called with the ID of a canvas, like this\n\nconst game=new GameCanvas("mycanvasid")');
}
const canvas = id instanceof HTMLElement && id || document.getElementById(id)
if (!canvas) {
throw new Error('No canvas element found at ID=',id);
}
var width,height;
const ctx = canvas.getContext('2d');
var start; // start time!
const drawings = [];
const drawingMetadata = [];
const handlers = {
resize : []
};
setInitialCanvasSize();
// Slightly complicated: basically, we want to ignore the browser's lame default canvas sizing, and we want to respect
// whatever our users have done.
//
// So, we have three options, in order of priority:
// size explicitly set - respect it
// width/height attributes set on canvas - respect them
// OR - take the width/height of the initial element
function setInitialCanvasSize () {
// Initial sizing explicitly
if (size.width) {
canvas.width = size.width;
width = size.width;
}
else if (canvas.getAttribute('width')) {
width = canvas.width
}
else {
width = canvas.clientWidth;
canvas.width = width;
}
if (size.height) {
canvas.height = size.height;
height = size.height;
}
else if (canvas.getAttribute('height')) {
height = canvas.height
}
else {
height = canvas.clientHeight;
canvas.height = height;
}
}
function observeCanvasResize () {
const ro = new ResizeObserver(canvases => {
// there will only be one entry...
for (let cnv of canvases) {
//entry.target.style.borderRadius = Math.max(0, 250 - entry.contentRect.width) + 'px';
if (autoresize) {
setCanvasSize(cnv.contentRect.width,cnv.contentRect.height);
}
for (var h of handlers['resize']) {
/**
* @callback SimpleCanvas.GameCanvas~resizeCallback
* @param {Object} config
* @param {CanvasRenderingContext2D} config.ctx - drawing context
* @param {number} config.width - width of canvas element (will be same as internal width if autoresize is true)
* @param {number} config.height - height of canvas element (will be same as internal height if autoresize is true)
* @param {Canvas} canvas - the canvas DOM element
* @param {function} setCanvasSize - method to set internal size of canvas (width, height) if you want to implement custom sizing logic.
* @return true to prevent other handlers from being called, or false to allow other handlers to run.
**/
let result = h({width:cnv.contentRect.width,
height:cnv.contentRect.height,
canvas,setCanvasSize,ctx});
if (result) {
return // exit early
}
}
}
});
// Only observe the second box
ro.observe(canvas);
}
function setCanvasSize (w,h) {
width = w;
height = h;
canvas.width = w;
canvas.height = h;
}
function doDrawing (ts) {
ctx.clearRect(0,0,width,height);
/**
* @callback SimpleCanvas.GameCanvas~drawCallback
* @param {Object} config
* @param {CanvasRenderingContext2D} config.ctx
* @param {number} config.width - width of canvas
* @param {number} config.height - height of canvas
* @param {number} config.elapsed - milliseconds since first drawing
* @param {number} config.timestamp - current timestamp
* @param {number} config.stepTime - milliseconds passed since last tick
* @param {number} config.remove - a function that will remove this callback from the queue
**/
drawings.forEach((d,idx)=>{
function remove () {
drawings[idx] = ()=>{}
}
const md = drawingMetadata[idx];
if (md.off) {return} // end!
var elapsed;
var stepTime = md.__lastTime && ts - md.__lastTime || 0;
md.__lastTime = ts;
if (!md.__startTime) {
elapsed = 0;
md.__startTime = ts;
}
else {
elapsed = ts - md.__startTime;
}
if (d.draw) {
d.draw({ctx,width,height,remove,timestamp:ts,elapsed,stepTime})
}
else {
d({ctx,width,height,remove,timestamp:ts,elapsed,stepTime})
}
});
}
function tick (ts) {
doDrawing(ts);
window.requestAnimationFrame(tick);
}
var mousedown = false;
function setupHandler (canvas,eventType) {
handlers[eventType] = [];
canvas.tabIndex = 1000;
canvas.addEventListener(eventType, function (evt) {
const x = evt.offsetX;
const y = evt.offsetY;
for (var h of handlers[eventType]) {
/**
* @callback SimpleCanvas.GameCanvas~eventCallback
* @param {Object} config
* @param {number} config.x - offsetX of event (x with respect to canvas)
* @param {number} config.y - offsetY of event (y with respect to canvas)
* @param {string} config.type - type of event (i.e. mouseUp)
* @param {Object} config.event - javascript event object
* @return true to prevent other handlers from being
* called, or return undefined/false to allow other
* handlers to run.
*/
var result = h({x,y,type:eventType, event:evt})
if (result) {
// exit early!
return;
}
}
});
}
setupHandler(canvas,'click');
setupHandler(canvas,'dblclick');
setupHandler(canvas,'mousedown');
setupHandler(canvas,'mousemove');
setupHandler(canvas,'mouseup');
setupHandler(canvas,'keyup');
setupHandler(canvas,'keydown');
setupHandler(canvas,'keypress');
/**
* run the game (start animations, listen for events).
* @member SimpleCanvas.GameCanvas#run
* @method
*/
this.run = function () {
observeCanvasResize();
if (autoresize) {
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
}
tick ();
},
/**
* @member SimpleCanvas.GameCanvas#addDrawing
* @method
* @param {SimpleCanvas.GameCanvas~drawCallback} draw function OR an object with a draw callback method
* @desc Add a drawing to our drawing queue (it will remain until we remove it).
* @return ID that can be used in a {@link SimpleCanvas.GameCanvas#removeDrawing} callback to remove drawing.
* @example <caption>Passing a draw function</caption>
*
* game.addDrawing(
* function ({ctx,elapsed}) {
* ctx.beginPath();
* ctx.moveTo(200,200);
* ctx.lineTo(100,200+Math.sin(elapsed/10)*200);
* ctx.stroke();
* }
* );
*
* @example <caption>Passing an object with a draw method</caption>
* game.addDrawing(
* { x : 0,
* y : 0,
* w : 100,
* h : 100,
* draw ({ctx,stepTime,width,height}) {
* this.x += stepTime/20;
* this.y += stepTime/20;
* if (this.x > width) { this.x = 0 }
* if (this.y > height) { this.y = 0 }
* ctx.fillRect(this.x,this.y,this.w,this.h)
* },
* }
*);
*
* @example <caption>A drawing that will remove itself when it leaves the screen</caption>
* game.addDrawing(
* function ({ctx,elapsed,width,remove}) {
* const x = elapsed / 20
* ctx.fillRect(20,20,20);
* if (x > width) { remove () }
* }
* );
*
*/
this.addDrawing = function (d) {
if (typeof d !== 'function') {
if (typeof d === 'object') {
if (typeof d.draw !== 'function') {
throw new Error(`addDrawing requires a function or an object with a draw method as an argument. Received Object ${d} instead; d.draw => ${d.draw}.`);
}
}
else {
throw new Error(`addDrawing requires a function or an object with a draw method as an argument. Received ${d} (${typeof d}).`);
}
}
drawings.push(d);
drawingMetadata.push({});
return drawings.length - 1;
};
/**
* @member SimpleCanvas.GameCanvas#removeDrawing
* @method
* @param {number} id - drawing ID to remove (return value from {SimpleCanvas.GameCanvas#addDrawing}).
*/
this.removeDrawing = function (idx) {
if (typeof idx !== 'number') {
throw new Error(`removeDrawing must have a numeric ID as an argument. Received ${typeof idx} ${idx}`);
}
if (drawingMetadata[idx]) {
drawingMetadata[idx].off = true;
}
else {
console.log('WARNING: Attempt to remove non-existent drawing: %s',idx);
}
};
/**
* @member SimpleCanvas.GameCanvas#restoreDrawing
* @method
* @param {number} id - drawing ID to restore (start drawing again).
*/
this.restoreDrawing = function (idx) {
if (typeof idx !== 'number') {
throw new Error(`restoreDrawing must have a numeric ID as an argument. Received ${typeof idx} ${idx}`);
}
drawingMetadata[idx].off = false;
}
/**
* @member SimpleCanvas.GameCanvas#replaceDrawing
* @method
* @param {number} id - drawing ID to replace
* @param {SimpleCanvas.GameCanvas~drawCallback} f - draw function OR an object with a draw callback method
**/
this.replaceDrawing = function (idx, f) {
if (typeof idx !== 'number') {
throw new Error(`replaceDrawing must have a numeric ID as an argument. Received ${typeof idx} ${idx}`);
}
drawings[idx] = f;
return idx;
}
/**
* @member SimpleCanvas.GameCanvas#addHandler
* @method
* @desc Register a handler h for eventType
* Returns an ID that can be used to later remove
* the handler.
* @param {string} eventType - the javaScript event to handle. Can be click,dblclick,mousedown,mousemove,mouseup or keyup
* @param {SimpleCanvas.GameCanvas~eventCallback} eventCallback - A callback to handle events of type eventType.
* @return ID that can be used to remove handler with {@link SimpleCanvas.GameCanvas#removeHandler}
*/
this.addHandler = function (eventType,h) {
if (!handlers[eventType]) {
throw new Error(`No eventType ${eventType}: SimpleCanvasLibrary only supports events of type: ${Object.keys(handlers).join(',')}`);
}
if (typeof h !== 'function') {
throw new Error(`addHandler requires a function as second argument. ${h} is a ${typeof h}, not a function.`);
}
handlers[eventType].push(h);
return handlers[eventType].length - 1;
}
/**
* @member SimpleCanvas.GameCanvas#removeHandler
* @method
* @desc Remove handler for eventType.
* @param {String} eventType - the javaScript event to handle
* @param {Number} id - the ID of the handler to remove (this is the value returned by {@link SimpleCanvas.GameCanvas#addHandler})
*/
this.removeHandler = function (eventType,idx) {
if (!handlers[eventType]) {
throw new Error(`No eventType ${eventType}: SimpleCanvasLibrary only supports events of type: ${Object.keys(handlers).join(',')}`);;
}
handlers[eventType][idx] = ()=>{};
};
/**
* @member SimpleCanvas.GameCanvas#addClickHandler
* @method
* @desc Syntactic sugar for {@link SimpleCanvas.GameCanvas#addHandler|addHandler}('click',h)
*/
this.addClickHandler = function (h) {
if (typeof h !== 'function') {
throw new Error(`addClickHandler requires a function as an argument. ${h} is a ${typeof h}, not a function.`);
}
handlers.click.push(h);
return handlers.click.length - 1;
};
/**
* @member SimpleCanvas.GameCanvas#removeClickHandler
* @method
* Syntactic sugar for {@link SimpleCanvas.GameCanvas#removeHandler|removeHandler}('click',h)
*
* @example <caption>Make a drawing move whenever there is a click</caption>
*
* var xpos = 100;
* var ypos = 100;
* // Register a handler to update our variable each time
* // there is a click.
* game.addClickHandler(
* function ({x,y}) {
* // set variables...
* xpos = x;
* ypos = y;
* }
* )
* // Now create a drawing that uses the variable we set.
* game.addDrawing(
* function ({ctx}) {ctx.fillRect(xpos,ypos,30,30)}
* )
*/
this.removeClickHandler = function (idx) {
handlers.click[idx] = ()=>{}
};
/**
* @member SimpleCanvas.GameCanvas#addResizeHandler
* @method
* @desc Register a handler h for resize
* Returns an ID that can be used to later remove
* the handler.
* @param {string} eventType - the javaScript event to handle. Can be click,dblclick,mousedown,mousemove,mouseup or keyup
* @param {SimpleCanvas.GameCanvas~resizeCallback} resizeCallback - A callback to handle canvas resize events
* @return ID that can be used to remove handler with {@link SimpleCanvas.GameCanvas#removeResizeHandler}
*/
/*
* @example <caption>Use resize handler to see if we are in portrait or landscape orientation</caption>
* let portraitMode = false;
* game.addResizeHandler(
* function ({width,height}) {
* if (height > width) {
* portraitMode = true;
* }
* }
* );
*/
this.addResizeHandler = function (h) {
return this.addHandler('resize',h)
}
/**
* @member SimpleCanvas.GameCanvas#removeResizeHandler
* @method
* Syntactic sugar for {@link SimpleCanvas.GameCanvas#removeHandler|removeHandler}('resize',h)
*
this.removeResizeHandler = function (idx) {
return this.removeHandler('resize',h)
}
/**
* @member SimpleCanvas.GameCanvas#getSize
* @method
* @return {Size} size
*/
this.getSize = function () {
return {width, height}
}
}
function testLibrary () {
const c = document.createElement('canvas');
const body = document.getElementsByTagName('body')[0];
body.appendChild(c);
c.setAttribute('id','testCanvas');
c.setAttribute('width',800);
c.setAttribute('height',800);
const g = GameCanvas('testCanvas');
const id = g.addDrawing(({ctx})=>{ctx.fillRect(20,20,200,200)});
const id2 = g.addDrawing(({ctx})=>{ctx.fillRect(200,200,20,20)});
var tog = true;
g.addClickHandler(
({x,y})=>{
if (tog) {
g.removeDrawing(id2);
}
else {
g.restoreDrawing(id2);
}
tog = !tog;
g.replaceDrawing(
id,
({ctx,elapsed})=>{ctx.strokeRect(x,y,elapsed/500,elapsed/500)}
)
}
);
g.run();
}
/**
* Sprite sets up a constructor for a simple game sprite.
*
* You can draw your own spritesheet using a tool like https://www.piskelapp.com/
*
* @constructor Sprite
* @memberof SimpleCanvas
* @param {Object} config
* @param {string} config.src - URL of SpriteSheet resource.
* @param {number} config.x - Position of Sprite on the canvas
* @param {number} config.y - Position of Sprite on the canvas
* @param {number} config.frameWidth - width of each frame of the sprite sheet (defaults to width of image)
* @param {number} config.frameHeight - height of each frame of the sprite sheet (defaults to height of image)
* @param {number} config.frame - frame to start on (default to 0).
* @param {Array} config.frameSequence - list of frame indices to run (if not specified, we run all frames in order).
* @param {number} config.targetWidth - width of sprite to draw on canvas (same as source image if not specified)
* @param {number} config.targetHeight - height of sprite to draw on canvas (same as source image if not specified)
* @param {boolean} config.animate - whether to animate or not.
* @param {number} config.frameRate - Number of frames per second to run animation at.
* @param {number} config.repeat - Whether to repeat the animation or play only once (true by default)
* @param {number} config.angle - Angle to rotate drawing (in radians)
* @param {SimpleCanvas.Sprite~updateCallback} config.update - a callback to run on each animation frame just before drawing sprite to canvas.
*
**/
/** @typedef SimpleCanvas.Sprite~Sprite
* @property {Array} frameSequence - list of frame indices to run (if not specified, we run all frames in order).
* @property {boolean} animate - whether to run animation or not.
* @property {number} frameRate - frames per second to play animation at.
* @property {number} frameAnimationIndex - current frame (relative to frameSequence). Set to 0 to restart animation
**/
function Sprite ( {src,
x = 0,
y = 0,
frame = 0,
animate = true,
frameSequence,
frameWidth,
frameHeight,
angle,
targetWidth,
targetHeight,
frameRate = 24,
repeat = true,
update}) {
if ( !(this instanceof Sprite) ) {
return new Sprite(id);
}
if (!frameWidth) {
throw new Error(
'Sprite not provided required parameter frameWidth'
);
}
if (!frameHeight) {
throw new Error(
'Sprite not provided required parameter frameWidth'
);
}
if (!src) {
throw new Error(
'Sprite not provided with src or preloaded image: needs parameter src or image'
);
}
// set up image
this.image = new Image();
this.ready = false;
this.image.onload = ()=>{
this.ready=true;
if (!this.frames) {
this.frames = this.framesAcross * this.framesDown;
}
}
this.image.src = src;
this.animate = animate;
this.frameWidth = frameWidth;
/**
* Create a copy of sprite.
* @method SimpleCanvas.Sprite#copy
* @param {Object} newParams - settings to override (all other settings will be copied from current sprite)
* Return a copy of sprite.
**/
this.copy = function (newParams) {
const params = {...this,...newParams,src:src}
return new Sprite(params);
}
/**
* draw sprite to canvas
* @property SimpleCanvas.Sprite#frameHeight
*/
this.frameHeight = frameHeight;
/**
* draw sprite to canvas
* @property SimpleCanvas.Sprite#frameWidth
*/
this.frameRate = frameRate;
this.x = x;
this.y = y;
this.angle = angle;
this.frameAnimationIndex = frame;
this.frameSequence = frameSequence;
this.targetWidth = targetWidth || frameWidth;
this.targetHeight = targetHeight || frameHeight;
this.update = update;
this.repeat = repeat;
Object.defineProperties(
this,
{framesAcross : {
get : () => this.image.width/this.frameWidth
},
framesDown : {
get : () => this.image.height / this.frameHeight
},
frame : {
get : () => {
if (this.repeat) {
if (this.frameSequence) {
const sequenceIndex = this.frameAnimationIndex % this.frameSequence.length;
return this.frameSequence[Math.floor(sequenceIndex)]
}
else {
return this.frameAnimationIndex % this.frames;
}
}
else {
if (this.frameSequence) {
return this.frameSequence[Math.min(Math.floor(this.frameAnimationIndex),this.frameSequence.length - 1 )];
}
else {
return Math.min(Math.floor(this.frameAnimationIndex),this.frames - 1);
}
}
}
},
rowNum : {
get : () => Math.floor(Math.floor(this.frame) / this.framesAcross)
},
colNum : {
get : () => Math.floor(this.frame) % this.framesAcross
},
frameX : {
get : () => this.colNum * this.frameWidth
},
frameY : {
get : () => this.rowNum * this.frameHeight
},
remove : () => this.removeOnNextFrame = true
}
);
/**
* draw sprite to canvas
* @member SimpleCanvas.Sprite#draw
* @method
*/
this.draw = (cfg) => {
// if (!this.ready) {
// ctx.fillText(
// 'Loading...',
// this.x,this.y
// );
// }
const {ctx, elapsed, stepTime, remove} = cfg;
if (this.removeOnNextFrame) {
remove();
}
if (!this.ready) {
ctx.fillText(
'Loading image...',
this.x,this.y
);
}
else {
if (this.update) {
/**
* @callback SimpleCanvas.Sprite~updateCallback
* @desc This callback runs just before sprite is drawn to canvas.
* @param {Object} config
* @param {Object} config.sprite - sprite object being updated
* @param {number} config.width - width of canvas
* @param {number} config.height - height of canvas
* @param {number} config.elapsed - milliseconds since first drawing
* @param {number} config.timestamp - current timestamp
* @param {number} config.stepTime - milliseconds passed since last tick
* @param {number} config.remove - a function that will remove this callback from the queue
**/
this.update({
sprite:this,
...cfg
});
}
if (this.angle) {
ctx.translate(this.x+this.targetWidth/2,this.y+this.targetHeight/2);
ctx.rotate(this.angle);
ctx.translate(-(this.x+this.targetWidth/2),-(this.y+this.targetHeight/2));
}
ctx.drawImage(
this.image,
this.frameX,
this.frameY,
this.frameWidth,
this.frameHeight,
this.x,
this.y,
this.targetWidth,
this.targetHeight,
);
if (this.animate) {
this.frameAnimationIndex += stepTime / (1000/this.frameRate);
}
ctx.setTransform(1,0,0,1,0,0); // reset
}
}
return this;
}
/** @typedef {Object} Size
* @global
* @property {number} width - width in pixels
* @property {number} height - height in pixels
**/