Mario JS - 01 Canvas, Sprite e requestAnimationFrame()
Olá pessoal, este artigo tem com objetivo compartilhar um pequeno projeto que acabei dando início esses dias. A ideia é publicar pequenas etapa do desenvolvimento desse projeto, para que cada passo seja acompanhado pelos leitores interessados. Nesse primeiro artigo iremos abordar principalmente o funcionamento de um Sprite.
O repositório do código apresentado neste artigo está disponível no GitHub. Para acessar clique Aqui.
O que é Sprite?
Sprite é uma técnica de animação 2D, que para gerar a impressão de animação ou movimento troca as imagens rapidamente. Geralmente as imagens de um Sprite são organizadas em um Sprite Sheet, uma imagem grande com várias imagens pequenas agrupadas. Nesta primeira parte do projeto estaremos utilizando a imagem abaixo.
Para dar a impressão de que o Mário esta andando, se troca rapidamente entre a imagem da primeira coluna e a imagem da segunda coluna. Para trocar o lado que o Mário está indo, basta trocar de linha. Esta imagem possui apenas quatro figuras, porém em animações mais complexas se pode ter várias linhas e colunas, mas a ideia do funcionamento continua sendo a mesma.
HTML - Canvas
O jogo será criado em uma tag canvas. Um canvas é que uma área delimitada em um arquivo HTML para a renderização imagens, neste caso, o jogo do Mário. As imagens que serão utilizadas no jogo devem ser importadas dentro da tag canvas. O Arquivo index.html irá ficar da seguinte forma:
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mario JS</title>
<link rel="stylesheet" href="mario.css">
</head>
<body>
<canvas id="canvas" width="800" height="600">
<img id="mario_sprite" src="img/mario_sprite.png" alt="Mario Sprite" >
</canvas>
<script src="mario.js" defer></script>
<script src="game.js" defer></script>
</body>
</html>
Na tag canvas foi adicionado um id, para poder manipular este elemento com JavaScript. Ainda foi adicionado um atributo definindo uma largura (width) e altura (height). Dentro desta tag foi adicionado uma tag img para importar a imagem do sprite. Também foram importados dois arquivos JavaScript, o arquivo mario.js irá conter um objeto com todos os atributos e métodos relacionado ao Mário e o arquivo game.js será responsável por dar início ao jogo e renderizar as cenas no canvas.
Para poder enxergar os limite do canvas, foi adicionado uma borda.
style.css
canvas {
border: 1px solid black;
}
Mostrando a imagem no canvas
Para exibir a imagem no canvas precisamos primeiro selecionar o canvas do nosso arquivo index.html. Para podermos desenhar no canvas precisamos chamar a função getContext(), com o parâmetro "2d", como vamos lidar com imagens 2d. Além da opção 2d, o getContext() pode receber também parâmetros "webgl", "webgl2" e "webgpu" para lidar com renderizações de imagens 3d e ainda opção "bitmaprenderer", para exibir imagens de bitmap, mas neste artigo iremos nos restringir ao getContext("2d").
Para exibir a imagem dentro do canvas precisamos instanciar um novo objeto do tipo Image, e atribuir o caminho da imagem ao atributo src (src, se refere a source, que em português significa origem ou fonte). No final do arquivo criamos uma função responsável por desenhar a imagem no canvas, que, quando o documento terminar de ser carregado, chamando a função drawImage(Image, posição x no canvas, posição y do canvas). O código pode ser observado abaixo.
game.js
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const marioImg = new Image();
marioImg.src = './img/mario_sprite.png';
function draw() {
ctx.drawImage(marioImg, 0,0);
}
window.onload = function() {
draw();
}
Se tudo estiver funcionando de maneira correta, a imagem inteira do sprite deve estar aparecendo no canto superior esquerdo do canvas. Para mudar o posicionamento da imagem dentro do canvas basta alterar os valores da posição x e y. Brinque um pouco com estes dois valores para ter um melhor entendimento do seu funcionamento, pois estes valores serão muito utilizados nas próximas etapas do desenvolvimento do jogo.
Exibindo apenas um Sprite
A função drawImage() ainda possui mais duas variações, uma com cinco argumento, onde os dois últimos argumentos são responsáveis por determinar a largura e a altura da imagem e a variação com 9 argumentos, sendo esta a variação que será utilizada para trabalharmos com o nosso Sprite Sheet. Trabalhar com uma função que precisa de tantos argumentos pode ser um tanto quanto desafiador, mas a linguagem JavaScript nos permite adicionar uma quebra de linha para cada argumento, facilitando muito a leitura e compreensão do código. Observe o código abaixo para facilitar o seu intendimento.
game.js
...
function draw() {
ctx.drawImage(
marioImg, // Imagem a ser carregada
97, // px da esquerda da imagem a ser cortada
143, // px da parte superior da imagem a ser cortada
97, // px do sprite que desejo exibir na horizontal
143, // px do sprite que desejo exibir na vertical
0, // distância da borda esquerda do canvas
450, // distância da borda superior do canvas
97, // largura total da imagem cortada
143, // altura total da imagem cortada
);
}
...
Ao atualizar a função draw() como visto acima, o Mário deve ficar na parte inferior esquerda do canvas, correndo para a esquerda, como na imagem abaixo.
Estes argumentos podem ser bastante confusos, caso tenha entendido pode pular para o próximo capítulo. Para facilitar o entendimento observe a função e imagem abaixo:
drawImage(image, sx, sy, sLargura, sAltura, dx, dy, largura, altura);
- image: o objeto Image a ser exibido no canvas
- sx: a distância horizontal da borda esquerda da imagem até o início da parte da imagem que se deseja exibir.
- sy: a distância vertical da borda superior da imagem até o início da parte da imagem que se deseja exibir.
- sLargura: largura do sprite que você deseja exibir.
- sAltura: altura do sprite que você deseja exibir.
- dx: distância que a imagem terá da borda lateral esquerda do canvas.
- dy: distância que a imagem terá da borda superior do canvas.
- largura: largura da imagem.
- altura: altura da imagem.
Criando um objeto chamado Mário
Um objeto, na programação, é formada por duas coisas, os atributos, que são variáveis que armazenam as características do objeto e os métodos, que são funções que realizam as ações deste objeto. Os primeiros atributos a serem atribuídos ao nosso objeto Mário serão os atributos necessários para desenhar o sprite na tela, ou seja, os argumentos para a função drawImage() vista acima. Os métodos atribuídos a ele neste primeiro artigo serão:
- wait(): que exibirá a imagem do Mário parado.
- turnLeft(): que colocará o Mário na direção voltada para a esquerda.
- turnRight(): que colocará o Mário na direção voltada para a direita.
- run(): que exibirá a imagem do Mário correndo.
Por questão de organização, o objeto mario será criado em um arquivo próprio, e primeiro método a ser criado será o wait(). Ela apresentará apenas uma imagem estática do Mário, que inicialmente estará olhando para a direita, mas que posteriormente poderá mudar de direção com os métodos turnLeft() e turnRight().
Por questão de organização, corte a parte do código responsável por instanciar o a imagem do sprite do arquivo game.js e o cole no topo do arquivo mario.js.
mario.js
const marioImg = new Image(); // colado do arquivo game.js
marioImg.src = './img/mario_sprite.png'; // colado do arquivo game.js
const mario = {
x: 100,
y: 0,
img: marioImg,
sprite: {
width: 97,
height: 143,
line: 0,
column: 0,
},
wait: function() {
ctx.drawImage(
this.img, // Imagem
0, // Linha do Sprite
this.sprite.height * this.sprite.line, // Coluna do Sprite
this.sprite.width, // Largura do Sprite
this.sprite.height, // Altura do Sprite
this.x, // Posição horizontal
450 - this.y, // Posição vertical
this.sprite.width, // Largura
this.sprite.height // Altura
);
},
}
Os primeiros atributos x e y se referem a posição do Mário no canvas, o img se refere a imagem do Sprite.
Também temos um atributo com a largura, altura e linha do Sprite. Para descobrir a largura do sprite basta dividir a largura da Sprite Sheet pelo número de Sprites que tem na horizontal, já para a altura basta fazer o mesmo, porém com a altura do Sprite Sheet e o número de Sprites na vertical. A linha é igual a zero, porque inicialmente iremos trabalhar apenas com os Sprites da primeira linha.
Para a função wait() iremos desenhar neste primeiro momento apenas o Sprite da primeira coluna, por isso atribuiremos a este valor o número 0. Como a linha pode ser alterada conforme a direção em que o Mário estiver olhando, iremos atribuir a este valor a altura do Sprite, vezes a linha em que este Sprite se encontra. Para os outros argumentos, serão atribuídos os valores diretos, sem alteração, exceto a posição vertical. Como o valor y começa no topo do canvas, foi atribuído um valor fixo (450) e a posição y é subtraída deste valor, assim quando o valor de y subir, o Mário irá para cima, e não mais para baixo como anteriormente. Isso pode parecer um pouco confuso no momento, mas vai facilitar bastante o desenvolvimento do jogo mais para frente.
Se tudo estiver correto, basta chamar a função mario.wait() dentro da função draw() e teremos o Mário esperando e virado para a direita, como na imagem abaixo.
Métodos mario.turnLeft() e mario.turnRight()
Como no último exemplo foi pego o Sprite da primeira linha, o Mário ficou voltado para a direita. Para desenhar o Mário esperando voltado para a esquerda basta atribuir o valor 1 para o sprite.line. Mas como durante o jogo ele irá trocar de direção várias vezes, também será necessário criar uma função para fazê-lo olhar para a direita novamente. Essas duas funções podem ser criadas facilmente como observado abaixo:
mario.js
const marioImg = new Image();
marioImg.src = './img/mario_sprite.png';
const mario = {
x: 100,
y: 0,
img: marioImg,
sprite: {
width: 97,
height: 143,
line: 0
},
wait: function() {
...
},
turnLeft: function() {this.sprite.line = 1},
turnRight: function() {this.sprite.line = 0},
}
Para testar estas funções basta chamar primeiro a função mario.turnLeft() e depois mario.turnLeft() seguido de mario.turnRight() antes da função mario.wait(), no arquivo game.js, com pode ser observado abaixo:
game.js
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
function draw() {
mario.turnLeft();
mario.turnRight();
mario.wait();
}
window.onload = function() {
draw();
};
Criando um sprite com animação - mario.run()
A ideia por trás de uma animação com Sprites é a troca de imagens de forma rápida, com o intuito de dar a impressão de movimento. Para dar este efeito será utilizada uma função recursiva, ou seja, a função draw() em game.js irá chamar a si mesma. Assim que a função draw() terminar de desenhar o Sprite do Mário, ela será chamada novamente e irá desenhar a próxima cena, neste caso, a próxima posição do Mário.
Seria possível criar um loop infinito com uma função while(true){draw();}, porém o javaScript possui uma função especial para esta função chamada requestAnimationFrame(draw). Esta função foi otimizada para renderizar a página, renderizando apenas o que é necessário, fazendo com que as animações ocorram de maneira mais elve e consequentemente mais suave.
O método run(), que será criado no objeto mario, será muito similar ao método wait(), com uma pequena diferença que a cada rodada ele irá desenhar um Sprite diferente. Tambeḿ sera alterada a função draw() do arquivo game.js.
mario.js
const marioImg = new Image();
marioImg.src = './img/mario_sprite.png';
const mario = {
x: 100,
y: 0,
img: marioImg,
sprite: {
width: 97,
height: 143,
line: 0
},
runningStep: 0, // ATENÇÃO ATRIBUTO NOVO
wait: function() {
...
},
turnLeft: function() {this.sprite.line = 1},
turnRight: function() {this.sprite.line = 0},
run: function() {
ctx.drawImage(
this.img, // Imagem
this.sprite.width * this.runningStep, // Coluna do Sprite
this.sprite.height * this.sprite.line, // Linha do Sprite
this.sprite.width, // Largura do Sprite
this.sprite.height, // Altura do Sprite
this.x, // Posição horizontal
450 - this.y, // Posição vertical
this.sprite.width, // Largura
this.sprite.height // Altura
);
(this.runningStep < 1) ? this.runningStep++ : this.runningStep = 0
},
}
Foi adicionado um novo atributo ao Mário, chamado de runningStep, este atributo é responsável com manter o registro de qual passo nós paramos, ou seja, qual sprite iremos desenhar na próxima renderização. A função ctx.drawImage() recebe quase todos os mesmo atributos que na função wait(), exceto pelo atributo responsável por selecionar a coluna do Sprite, que agora é a largura do sprite, multiplicado pelo atributo runningStep. Após a função ctx.drawImage() ainda foi adicionado um contador, para incrementar o passo, ou se passar o limite de passos, começar do zero novamente. No arquivo game.js será adicionado a função requestAnimationFrame(draw), dentro da função draw(), para chamar esta função novamente e criar um loop infinito.
game.js
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
function draw() {
mario.run();
requestAnimationFrame(draw);
}
window.onload = function() {
draw();
};
Se rodar o jogo agora será possível ver algum movimento, mas longe de ser o efeito esperado. Provavelmente você verá ambos os Mários sendo desenhados um por cima do outro, e talvez ainda algum efeito piscando. Isso se deve ao fato que a função draw(), a cada iteração que é chamado, apenas desenha um novo Mário, sem apagar o último que ainda se encontra por debaixo dele. Para resolver este problema, usamos a função clearRect(início x, início y, fim x e fim y), começando em x e y igual a zero e terminando na largura e altura do canvas, que conseguimos através dos atributos canvas.width e canvasheight*. observe o código abaixo.
game.js
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
function draw() {
// Limpando a tela
ctx.clearRect(0, 0, canvas.width, canvas.height);
mario.run();
requestAnimationFrame(draw);
}
window.onload = function() {
draw();
};
Agora, se tudo estiver funcionando de acordo, você deve ver o Mário correndo, mas a uma velocidade muito acima daquela esperada.
Isso porque assim que o computador terminar a primeira função draw(), ele já a chama novamente. Para resolver este problema será criado um pequeno objeto frame que ficara responsável por controlar quantos quadros por segundo serão exibidos. O objeto frame terá um atributo fps referente a quantos quadros por segundo nosso canvas será atualizado, e um atributo lastTime, que armazenará a última vez que o quadro foi atualizado. No início da função draw() será verificado já passou tempo suficiente para atualizar a tela ou não. Para calcular o delta tempo que desejamos esperar se divide mil pelo número de quadros por segundo que se deseja, se o delta tempo atual for menor que o delta tempo desejado, se chama novamente a função draw, e da um retorno vazio para que o resto da função não seja executada. Veja abaixo como este esta função deve ficar.
game.js
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const frame = {
fps: 10,
lastTime: 0
};
function draw(currentTime) {
// Cuida que a animação ocorra em uma velocidade constante
const timeDelta = currentTime - frame.lastTime;
if (timeDelta < (1000 / frame.fps)) {
requestAnimationFrame(draw);
return;
}
frame.lastTime = currentTime;
// Limpando a tela
ctx.clearRect(0, 0, canvas.width, canvas.height);
mario.run();
requestAnimationFrame(draw);
}
window.onload = function() {
draw();
};
Se tudo estiver funcionando de acordo, o Mário deve estar com uma animação similar ao gif abaixo. Adicione as funções mario.turnLeft() e posteriormente também a função mario.turnRight() para ver que podemos mudar a direção do Mário para ambos os lados.
Considerações finais
Durante este primeiro artigo foram abordados os seguintes temas:
- O que é um Sprite,
- Como criar um canvas e exibir uma imagem,
- Como cortar esta imagem para exibir apenas um Sprite,
- Como criar uma animação, alternando rapidamente imagens de um Sprite,
- Como controlar a velocidade com que estas imagens são exibidas no canvas.
Espero que vocês tenham gostado deste artigo e espero publicar uma continuação em breve. Caso tenham dúvidas ou sugestões de melhorias, deixem um recados nos comentários. Muito obrigado por lerem este artigo até o final e até uma próxima.