Un gioco coi canvas
Movimento di una pallina 2D
Utilizzando i canvas proviamo a simulare il comportamento di una pallina in 2D. Qui uno screenshot di come si può vedere la “pallina” (cioè un piccolo cerchio).

Vediamo nello specifico il funzionamento di questo algoritmo.
Algoritmo di animazione
Per rappresentare uno o più oggetti che si muovono nel tempo, è necessario creare una animazione, ovvero un algoritmo che consiste nella ripetizione dei seguenti passi:
a) aggiornamento della posizione di tutti gli oggetti all’interno del canvas.
b) aggiornamento dell’interfaccia utente (cioè del canvas)
Se l’intervallo è sufficientemente veloce, l’utente ha l’illusione di vedere gli oggetti muoversi nella schermata, esattamente come avviene nel cinema e nel video.
Questo meccanismo come si può vedere non è altro che l’applicazione del modello evento (cioè la ripetizione), azione (aggiornamento posizione) e reazione (ridisegno dell’interfaccia utente). Come vedremo, è alla base anche dei videogiochi. Applichiamolo all’animazione della pallina.
Il componente ball
Utilizziamo la progettazione a componenti, quindi creiamo ua funzione costruttrice che crea la pallina. Questa riceve come parametro una coppia di coordinate iniziali, una dimensione, un angolo ed una velocità, ed infine la dimensione del canvas stesso.
const createBall = (posx, posy, radius, startAngle, speed, cwidth, cheight) => {...}La refresh
L’effetto visivo dello spostamento della pallina si ottiene mediante aggiornamento della posizione (x,y) della pallina stessa. Dobbiamo quindi scrivere una funzione refresh che calcola le nuove coordinate in cui si dovrà trovare la pallina in base alla sua velocità e la sua direzione. Questa funzione dovrà essere eseguita periodicamente tramite un meccanismo di refresh automatico con una frequenza abbastanza veloce da rendere il movimento fluido per l’occhio umano.
Ma come si calcolano le nuove coordinate?
La pallina ha posizione (x,y), una direzione (startAngle) e una velocità (speed). La velocità della pallina ha una componente orizzontale, che corrisponde al coseno dell’angolo di direzione, ed una componente verticale pari al seno dell’angolo.

let vx = speed * Math.cos(startAngle);
let vy = speed * Math.sin(startAngle);Quindi la nuova posizione sarà data da:
let newx = x + vx;
let newy = y + vy;Quindi se ad esempio all’istante t la pallina si trova nella posizione (100,100) ed ha velocità di 20 con angolo 30 le nuove posizioni saranno:
newx = 100 + 20 * Math.cos(30) = 117
newy = 100 + 20 * Math.sin(30) = 110Ad ogni refresh viene ricalcolata la posizione della pallina (sarà poi la render a mostrarla all’utente).
Ma cosa succede quando la pallina raggiunge immancabilmente la parete? Nella nostra simulazione ipotizziamo che l’urto sia elastico, quindi non c’è perdita di velocità, ma cambia solo l’angolo di uscita, che per effetto del rimbalzo cambia secondo la seguente regola:
- se l’urto è verso le pareti verticali (dx o sx) si inverte la velocità orizzontale:
if (newx > cwidth - radius || newx < radius) {
vx = -vx;
}- se l’urto è verso le pareti orizzontali (sopra o sotto) si inverte la velocità verticale:
if (newy > cheight - radius || newy < radius) {
vy = -vy;
} Qui il codice completo:
const createBall = (posx, posy, radius, startAngle, speed, cwidth, cheight) => {
let x = posx;
let y = posy;
// calcola la componente orizzontale e verticale del movimento
let vx = speed * Math.cos(startAngle);
let vy = speed * Math.sin(startAngle);
const refresh = () => {
let newx = x + vx;
let newy = y + vy;
//impatto con le pareti
if (newx > cwidth - radius || newx < radius) {
vx = -vx;
}
if (newy > cheight - radius || newy < radius) {
vy = -vy;
}
x = newx;
y = newy;
}
return {
x: () => x,
y: () => y,
radius: radius,
refresh: refresh
}
}La render
A questo punto creiamo il componente che gestisce la render.
const createDrawComponent = (canvas) => {
const ctx = canvas.getContext("2d");
return {
renderBall: (ball) => {
ctx.beginPath();
ctx.arc(ball.x(), ball.y(), ball.radius, 0, 2 * Math.PI);
ctx.lineWidth = 1;
ctx.fill();
},
clear: () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
}La funzione render riceve i dati della pallina, e la disegna sul canvas, mentre la funzione clear ripulisce l’intero canvas per l’aggiornamento. Le mettiamo separate perché l’operazione di clear va effettuata una volta ad ogni refresh, l’operazione di render invece è richiamabile più volte nella stessa refresh.
Il ciclo di animazione
A questo punto possiamo mettere tutto insieme ed avviare l’animazione:
((canvas) => {
const startX = 10 + Math.random() * (canvas.width-10);
const startY = 10 + Math.random() * (canvas.height-10);
const startAngle = 90 + Math.random() * 180;
const ball = createBall(startX, startY, 10, startAngle, 1, canvas.width, canvas.height);
const drawComponent = createDrawComponent(canvas);
const refresh = () => {
ball.refresh();
drawComponent.clear();
drawComponent.renderBall(ball);
requestAnimationFrame(refresh);
}
requestAnimationFrame(refresh);
})(document.getElementById("canvas"));Come si può vedere, viene usata la funzione requestAnimationFrame a cui si passa (ricorsivamente) la funzione refresh. Questa funzione è più efficiente rispetto a setInterval, perché mentre setInterval emette un evento interval ogni n millisecondi decisi dal programmatore, questo valore di n può andare bene su una macchina ma meno bene su un altro. Invece requestAnimationFrame è un evento generato dalla macchina stessa quando è pronta per mostrare un nuovo refresh. Pertanto requestAnimationFrame emette l’evento di refresh con una periodicità che cambia da computer a computer, anzi da sessione a sessione in base alle condizioni di carico della macchina su cui gira l’applicazione web. Se si vuole gestire quindi aggiornamenti sincronizzati sul tempo, occorre quindi svincolare la refresh della render, gestendo la prima con un setInterval e la seconda con un requestAnimationFrame.
Vogliamo creare due palline? Nessun problema:
((canvas) => {
const ball1 = createBall(50, 50, 10, Math.random()%360, 5, 500);
const ball2 = createBall(450, 450, 10, Math.random()%360, 6, 500);
const drawComponent = createDrawComponent(canvas);
const refresh = () => {
ball1.refresh();
ball2.refresh();
drawComponent.clear();
drawComponent.renderBall(ball1);
drawComponent.renderBall(ball2);
requestAnimationFrame(refresh);
}
requestAnimationFrame(refresh);
})(document.getElementById("canvas"));Pallina e racchetta (“Pong”)
Pong è stato probailmente il primo videogioco grafico della storia. Due giocatori giocano con una pallina e ciascuno con una racchetta cerca di mandare la pallina nel campo avversario. In questa esercitazione vedremo come giocare con una sola racchetta, il passaggio a due racchette è lasciato al programmatore.
la Racchetta
La racchetta viene creata come un rettangolo di dimensione data. La sua particolarità è che può essere controllata da un evento utente da tastiera, nello specifico coi pulsanti cursore destro e sinistro, che la fanno spostare di un certo numero di pixel a destra o sinistra. Qui il codice completo:
const createRacket = (posx, posy, rwidth, rheight, start, end) => {
let x = posx;
let y = posy;
window.onkeydown = function (e) {
console.log(e.code);
if (e.code === "ArrowRight" && x < end - rwidth) {
x += 10;
}
if (e.code === "ArrowLeft" && x > start) {
x -= 10;
}
};
return {
x: () => x,
y: () => y,
width: () => rwidth,
height: () => rheight,
endx: () => x + rwidth,
endy: () => y + rheight
}
}Per intercettare un evento da tastiera in una pagina web è sufficiente eseguire il seguente codice:
document.onkeydown = function(e) {
console.log(String.fromCharCode(e.keyCode)+" --> "+e.keyCode);
};
Il valore di keyCode indica il codice numerico del tasto premuto, mentre code è l’identificativo stringa.
La pallina
La pallina deve gestire adesso non solo l’urto con le pareti ma anche l’urto con la racchetta, solo che la racchetta si trova dentro il canvas e quindi bisogna gestire l’urto di due oggetti entrambi con dimensioni rettangolari. Per fare questo ci servono le coordinate x1,x2,y1,y2 sia della pallina che della racchetta, dove queste coordinate rappresentano il margine sinistro, destro, superiore ed inferiore degli oggetti.
A questo punto possiamo definire la collisione ad esempio dall’alto come quella situazione in cui il margine inferiore della pallina è “sotto” il margine superiore della racchetta, e contemporaneamente il margine superiore della pallina è sopra il margine inferiore della racchetta (altrimenti la pallina sarebbe sotto la racchetta), inoltre deve rientrare anche nei margini destro e sinistro.
E’ forse più semplice capirlo con uno schema grafico.

Come si può vedere lo scenario che dobbiamo verificare è quando i due rettangoli A e B si intersecano. Non solo ma come detto sopra dobbiamo capire anche il tipo di urto, perché questo deve poi ovviamente determinare il rimbalzo della pallina.
Creiamo quindi la funzione check che va a verificare proprio questo:
const check = (ax1, ax2, ay1, ay2, bx1, bx2, by1, by2) => {
// da sopra verso il basso
if (ax2 > bx1 && ax1 < bx2 && ay2 > by1 && ay1 < by1) {
return "up";
}
// da sotto verso l'alto
if (ax2 > bx1 && ax1 < bx2 && ay1 < by2 && ay2 > by2) {
return "down";
}
// arriva da destra e supera il lato destro dell'oggetto
if (ay1 < by2 && ay2 > by1 && ax1 < bx2 && ax2 > bx2) {
return "right";
}
// arriva da sinistra e supera il lato sinistro dell'oggetto
if (ay1 < by2 && ay2 > by1 && ax2 > bx1 && ax1 < bx1) {
return "left";
}
return null;
}A questo punto possiamo riscrivere la funzione refresh di ball tenendo conto proprio del check:
const createBall = (posx, posy, radius, startAngle, speed, cwidth, cheight) => {
const check = (ax1, ax2, ay1, ay2, bx1, bx2, by1, by2) => {
...
}
...
const refresh = (objects) => {
...
// impatto con altri oggetti
objects.forEach((object) => {
const impact = check(newx, newx + radius, newy, newy + radius,
object.x(), object.endx(), object.y(), object.endy());
if (impact === "up" && vy > 0) {
vy = -vy;
}
if (impact === "down" && vy < 0) {
vy = -vy;
}
if (impact === "left" && vx > 0) {
vx = -vx;
}
if (impact === "right" && vx < 0) {
vx = -vx;
}
});
...
}
return {
...
}E’ importante anche capire questo concetto anche in termini di programmazione per componenti. E’ la pallina, e non la racchetta ad essere responsabile del check. Perchè? Perché è la pallina, e non la racchetta a subirne le conseguenze, cioè a modificare la propria traiettoria. Per questo la funzione render riceve come argomento una lista di oggetti (la racchetta o qualsiasi altro oggetto) e per ciascuno di essi va a verificare se c’è un rimbalzo da effettuare.
La render
Bisogna modificare il componente Draw gestendo anche la render della racchetta:
const createDrawComponent = (canvas) => {
const ctx = canvas.getContext("2d");
return {
renderBall: (ball) => {
ctx.beginPath();
ctx.arc(ball.x(), ball.y(), ball.radius, 0, 2 * Math.PI);
ctx.lineWidth = 1;
ctx.fill();
},
renderObject: (object) => {
ctx.beginPath();
ctx.fillRect(object.x(), object.y(), object.width(), object.height());
ctx.lineWidth = 1;
ctx.fill();
},
clear: () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
}Usiamo il termine generico “RenderObject” perché in realtà possiamo usare questo meccanismo per mostrare qualsiasi oggetto rettangolare.
L’animazione
A questo punto possiamo mettere tutto insieme:
((canvas) => {
const startX = 10 + Math.random() * (canvas.width - 10);
const startY = 10 + Math.random() * (canvas.height - 10);
const startAngle = 90 + Math.random() * 180;
const ball = createBall(startX, startY, 10, startAngle, 1, canvas.width, canvas.height);
const cwidth = canvas.width / 4;
const cposx = canvas.width / 2 - cwidth / 2;
const cheight = canvas.height * 0.02;
const cposy = canvas.height * 0.9;
const racket = createRacket(cposx, cposy, cwidth, cheight, 0, canvas.width);
const drawComponent = createDrawComponent(canvas);
const refresh = () => {
ball.refresh([racket]);
drawComponent.clear();
drawComponent.renderBall(ball);
drawComponent.renderObject(racket);
requestAnimationFrame(refresh);
}
requestAnimationFrame(refresh);
})(document.getElementById("canvas"));Notare che il flusso cambia rispetto alla semplice animazione:
Evento:
- viene generato un evento periodico animationFrame
- l’utente può generare eventi keydown
Azione:
- l’evento keydown aggiorna la posizione della racchetta;
- la pallina aggiorna la propria posizione e velocità;
Reazione:
- la render mostra tutti gli oggetti allo stato attuale
Questo meccanismo è alla base dei videogiochi e consente di separare la logica del gioco (refresh) dalla sua rappresentazione.
Come esercizio è lasciato allo studente di creare un altro oggetto (ad esempio un muro) e modificare l’applicazione di conseguenza.
