Desenhando no Canvas

Navegadores nos oferecem várias formas de exibir gráficos. A maneira mais simples é usar estilos para posicionar e colorir elementos DOM regulares. Isso pode nos levar bastante longe, como o jogo no capítulo anterior mostrou. Ao adicionar imagens de fundo parcialmente transparentes aos nós, podemos fazer com que eles fiquem exatamente do jeito que queremos. É até possível rotacionar ou distorcer nós com o estilo transform.
Mas estaríamos usando o DOM para algo para o qual ele não foi originalmente projetado. Algumas tarefas, como desenhar uma linha entre pontos arbitrários, são extremamente difíceis de fazer com elementos HTML comuns.
Existem duas alternativas. A primeira é baseada em DOM, mas utiliza Scalable Vector Graphics (SVG) em vez de HTML. Pense em SVG como um dialeto de documento marcado que foca em formas em vez de texto. Você pode embutir um documento SVG diretamente em um documento HTML ou incluí-lo com uma tag <img>.
A segunda alternativa é chamada de canvas. Um canvas é um único elemento DOM que encapsula uma imagem. Ele fornece uma interface de programação para desenhar formas no espaço ocupado pelo nó. A principal diferença entre um canvas e uma imagem SVG é que no SVG a descrição original das formas é preservada para que elas possam ser movidas ou redimensionadas a qualquer momento. Um canvas, por outro lado, converte as formas em pixels (pontos coloridos em um raster) assim que são desenhadas e não lembra o que esses pixels representam. A única forma de mover uma forma em um canvas é limpar o canvas (ou a parte do canvas ao redor da forma) e redesenhá-la em uma nova posição.
SVG
Este livro não entrará em detalhes sobre SVG, mas vou explicar brevemente como ele funciona. No final do capítulo, voltarei às compensações que você deve considerar ao decidir qual mecanismo de desenho é apropriado para uma dada aplicação.
Este é um documento HTML com uma imagem SVG simples nele:
<p>HTML normal aqui.</p> <svg xmlns="http://www.w3.org/2000/svg"> <circle r="50" cx="50" cy="50" fill="red"/> <rect x="120" y="5" width="90" height="90" stroke="blue" fill="none"/> </svg>
O atributo xmlns muda um elemento (e seus filhos) para um diferente namespace XML. Este namespace, identificado por um URL, especifica o dialeto que estamos falando no momento. As tags <circle> e <rect>, que não existem no HTML, têm significado no SVG — elas desenham formas usando o estilo e posição especificados pelos seus atributos.
Essas tags criam elementos DOM, assim como as tags HTML, com os quais scripts podem interagir. Por exemplo, isto muda o elemento <circle> para ser colorido de ciano:
let circle = document.querySelector("circle"); circle.setAttribute("fill", "cyan");
O elemento canvas
Gráficos podem ser desenhados em um elemento <canvas>. Você pode dar a um elemento assim atributos width e height para determinar seu tamanho em pixels.
Um canvas novo é vazio, significando que é inteiramente transparente e portanto aparece como espaço vazio no documento.
A tag <canvas> foi projetada para permitir diferentes estilos de desenho. Para obter acesso a uma interface de desenho real, precisamos primeiro criar um contexto, um objeto cujos métodos fornecem a interface de desenho. Atualmente existem três estilos de desenho amplamente suportados: "2d" para gráficos bidimensionais, "webgl" para gráficos tridimensionais através da interface OpenGL, e "webgpu", uma alternativa mais moderna e flexível ao WebGL.
Este livro não abordará WebGL ou WebGPU — ficaremos com duas dimensões. Mas se você estiver interessado em gráficos tridimensionais, eu recomendo que explore o WebGPU. Ele fornece uma interface direta para hardware gráfico e permite renderizar até cenas complicadas de forma eficiente, usando JavaScript.
Você cria um contexto com o método getContext no elemento DOM <canvas>.
<p>Antes do canvas.</p> <canvas width="120" height="60"></canvas> <p>Depois do canvas.</p> <script> let canvas = document.querySelector("canvas"); let context = canvas.getContext("2d"); context.fillStyle = "red"; context.fillRect(10, 10, 100, 50); </script>
Após criar o objeto de contexto, o exemplo desenha um retângulo vermelho que tem 100 pixels de largura e 50 pixels de altura, com seu canto superior esquerdo nas coordenadas (10, 10).
Assim como em HTML (e SVG), o sistema de coordenadas que o canvas usa coloca (0, 0) no canto superior esquerdo, e o eixo y positivo vai para baixo a partir dali. Isso significa que (10, 10) está 10 pixels abaixo e à direita do canto superior esquerdo.
Linhas e superfícies
Na interface do canvas, uma forma pode ser preenchida, significando que sua área recebe uma cor ou padrão específico, ou pode ser traçada, o que significa que uma linha é desenhada ao longo de sua borda. O SVG usa a mesma terminologia.
O método fillRect preenche um retângulo. Ele recebe primeiro as coordenadas x e y do canto superior esquerdo do retângulo, depois sua largura, e então sua altura. Um método similar chamado strokeRect desenha o contorno de um retângulo.
Nenhum desses métodos recebe outros parâmetros adicionais. A cor do preenchimento, a espessura do traçado e assim por diante não são determinadas por um argumento do método, como você poderia esperar, mas sim por propriedades do objeto de contexto.
A propriedade fillStyle controla a forma como as formas são preenchidas. Ela pode ser definida como uma string que especifica uma cor, utilizando a notação de cor usada pelo CSS.
A propriedade strokeStyle funciona de forma semelhante, mas determina a cor usada para uma linha traçada. A largura dessa linha é determinada pela propriedade lineWidth, que pode conter qualquer número positivo.
<canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.strokeStyle = "blue"; cx.strokeRect(5, 5, 50, 50); cx.lineWidth = 5; cx.strokeRect(135, 5, 50, 50); </script>
Quando nenhum atributo width ou height é especificado, como no exemplo, um elemento canvas recebe uma largura padrão de 300 pixels e altura de 150 pixels.
Caminhos
Um caminho é uma sequência de linhas. A interface 2D do canvas adota uma abordagem peculiar para descrever tal caminho. Ele é feito inteiramente por meio de efeitos colaterais. Caminhos não são valores que podem ser armazenados e passados adiante. Em vez disso, se você quer fazer algo com um caminho, você faz uma sequência de chamadas de método para descrever sua forma.
<canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.beginPath(); for (let y = 10; y < 100; y += 10) { cx.moveTo(10, y); cx.lineTo(90, y); } cx.stroke(); </script>
Este exemplo cria um caminho com vários segmentos de linhas horizontais e então o traça usando o método stroke. Cada segmento criado com lineTo começa na posição atual do caminho. Essa posição geralmente é o fim do último segmento, a menos que moveTo tenha sido chamado. Nesse caso, o próximo segmento começaria na posição passada para moveTo.
Ao preencher um caminho (usando o método fill), cada forma é preenchida separadamente. Um caminho pode conter múltiplas formas—cada movimento moveTo inicia uma nova. Mas o caminho precisa estar fechado (significando que seu início e fim estão na mesma posição) antes que possa ser preenchido. Se o caminho não estiver fechado, uma linha é adicionada do fim dele ao início, e a forma delimitada pelo caminho completo é preenchida.
<canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.beginPath(); cx.moveTo(50, 10); cx.lineTo(10, 70); cx.lineTo(90, 70); cx.fill(); </script>
Este exemplo desenha um triângulo preenchido. Note que apenas dois lados do triângulo são desenhados explicitamente. O terceiro, do canto inferior direito de volta ao topo, é implícito e não apareceria se você traçasse o caminho.
Você também pode usar o método closePath para fechar explicitamente um caminho adicionando um segmento de linha real de volta ao início do caminho. Esse segmento é desenhado quando o caminho é traçado.
Curvas
Um caminho também pode conter linhas curvadas. Infelizmente, elas são um pouco mais complexas de desenhar.
O método quadraticCurveTo desenha uma curva para um ponto dado. Para determinar a curvatura da linha, o método recebe um ponto de controle assim como um ponto de destino. Imagine esse ponto de controle como atraindo a linha, dando a ela sua curva. A linha não passará pelo ponto de controle, mas sua direção nos pontos inicial e final será tal que uma linha reta nessa direção apontaria para o ponto de controle. O exemplo a seguir ilustra isso:
<canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.beginPath(); cx.moveTo(10, 90); // control=(60, 10) goal=(90, 90) cx.quadraticCurveTo(60, 10, 90, 90); cx.lineTo(60, 10); cx.closePath(); cx.stroke(); </script>
Desenhamos uma curva quadrática da esquerda para a direita, com (60, 10) como o ponto de controle, e então desenhamos dois segmentos de linha passando por esse ponto de controle e voltando ao início da linha. O resultado lembra um pouco uma insígnia do Star Trek. Você pode ver o efeito do ponto de controle: as linhas que saem dos cantos inferiores começam na direção do ponto de controle e então curvam em direção ao seu destino.
O método bezierCurveTo desenha um tipo semelhante de curva. Em vez de um único ponto de controle, esse método tem dois — um para cada uma das pontas da linha. Aqui está um esboço semelhante para ilustrar o comportamento de tal curva:
<canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.beginPath(); cx.moveTo(10, 90); // control1=(10, 10) control2=(90, 10) goal=(50, 90) cx.bezierCurveTo(10, 10, 90, 10, 50, 90); cx.lineTo(90, 10); cx.lineTo(10, 10); cx.closePath(); cx.stroke(); </script>
Os dois pontos de controle especificam a direção em ambas as pontas da curva. Quanto mais distantes estiverem do ponto correspondente, mais a curva irá “inchar” nessa direção.
Tais curvas podem ser difíceis de trabalhar — nem sempre é claro como encontrar os pontos de controle que proporcionam a forma desejada. Às vezes você pode calculá-los, e às vezes terá que encontrar um valor adequado por tentativa e erro.
O método arc é uma forma de desenhar uma linha que curva ao longo da borda de um círculo. Ele recebe um par de coordenadas para o centro do arco, um raio, e então um ângulo inicial e um ângulo final.
Esses dois últimos parâmetros permitem desenhar apenas parte do círculo. Os ângulos são medidos em radianos, não em graus. Isso significa que um círculo completo tem um ângulo de 2π, ou 2 * Math.PI, o que é cerca de 6,28. A contagem do ângulo começa no ponto à direita do centro do círculo e segue no sentido horário a partir daí. Você pode usar um início em 0 e um fim maior que 2π (por exemplo, 7) para desenhar o círculo completo.
<canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.beginPath(); // center=(50, 50) radius=40 angle=0 to 7 cx.arc(50, 50, 40, 0, 7); // center=(150, 50) radius=40 angle=0 to ½π cx.arc(150, 50, 40, 0, 0.5 * Math.PI); cx.stroke(); </script>
A figura resultante contém uma linha desde a direita do círculo completo (primeira chamada ao arc) até a direita do quarto de círculo (segunda chamada).
Como outros métodos de desenho de caminho, uma linha desenhada com arc está conectada ao segmento de caminho anterior. Você pode chamar moveTo ou iniciar um novo caminho para evitar isso.
Desenhando um gráfico de pizza
Imagine que você acabou de conseguir um emprego na EconomiCorp, Inc. Sua primeira tarefa é desenhar um gráfico de pizza dos resultados de uma pesquisa de satisfação dos clientes.
A constante results contém um array de objetos que representam as respostas da pesquisa.
const results = [ {name: "Satisfied", count: 1043, color: "lightblue"}, {name: "Neutral", count: 563, color: "lightgreen"}, {name: "Unsatisfied", count: 510, color: "pink"}, {name: "No comment", count: 175, color: "silver"} ];
Para desenhar um gráfico de pizza, desenhamos várias fatias de pizza, cada uma composta de um arco e um par de linhas até o centro desse arco. Podemos calcular o ângulo ocupado por cada arco dividindo um círculo completo (2π) pelo número total de respostas e depois multiplicando esse número (o ângulo por resposta) pelo número de pessoas que escolheram uma determinada opção.
<canvas width="200" height="200"></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let total = results .reduce((sum, {count}) => sum + count, 0); // Começar no topo let currentAngle = -0.5 * Math.PI; for (let result of results) { let sliceAngle = (result.count / total) * 2 * Math.PI; cx.beginPath(); // centro=100,100, raio=100 // do ângulo atual, sentido horário pelo ângulo da fatia cx.arc(100, 100, 100, currentAngle, currentAngle + sliceAngle); currentAngle += sliceAngle; cx.lineTo(100, 100); cx.fillStyle = result.color; cx.fill(); } </script>
Mas um gráfico que não nos diz o que as fatias significam não é muito útil. Precisamos de uma forma de desenhar texto no canvas.
Texto
Um contexto de desenho 2D de canvas fornece os métodos fillText e strokeText. O último pode ser útil para contornar letras, mas geralmente fillText é o que você precisa. Ele preencherá o contorno do texto dado com o fillStyle atual.
<canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.font = "28px Georgia"; cx.fillStyle = "fuchsia"; cx.fillText("I can draw text, too!", 10, 50); </script>
Você pode especificar o tamanho, estilo e fonte do texto com a propriedade font. Este exemplo apenas define um tamanho de fonte e um nome de família. Também é possível adicionar italic ou bold ao início da string para selecionar um estilo.
Os dois últimos argumentos de fillText e strokeText indicam a posição em que a fonte será desenhada. Por padrão, eles indicam a posição do início da linha base alfabética do texto, que é a linha onde as letras “ficam”, sem contar partes penduradas em letras como j ou p. Você pode mudar a posição horizontal ajustando a propriedade textAlign para "end" ou "center" e a posição vertical ajustando textBaseline para "top", "middle" ou "bottom".
Voltaremos ao nosso gráfico de pizza, e ao problema de rotular as fatias, nos exercícios no final do capítulo.
Imagens
Em gráficos de computador, uma distinção é frequentemente feita entre gráficos vetoriais e gráficos bitmap. O primeiro é o que temos feito até agora neste capítulo — especificar uma imagem fornecendo uma descrição lógica de formas. Já os gráficos bitmap, por outro lado, não especificam formas reais, mas trabalham com dados de pixel (rasters de pontos coloridos).
O método drawImage nos permite desenhar dados de pixel em um canvas. Esses dados de pixel podem originar-se de um elemento <img> ou de outro canvas. O exemplo a seguir cria um elemento <img> desconectado e carrega uma imagem nele. Mas o método não pode começar a desenhar a partir dessa imagem imediatamente porque o navegador pode ainda não tê-la carregado. Para lidar com isso, registramos um manipulador de evento "load" e fazemos a pintura após a imagem ter carregado.
<canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let img = document.createElement("img"); img.src = "img/hat.png"; img.addEventListener("load", () => { for (let x = 10; x < 200; x += 30) { cx.drawImage(img, x, 10); } }); </script>
Por padrão, drawImage desenha a imagem em seu tamanho original. Você também pode passar dois argumentos adicionais para especificar a largura e a altura da imagem desenhada, quando esses não forem iguais à imagem original.
Quando drawImage recebe nove argumentos, ele pode ser usado para desenhar apenas um fragmento da imagem. O segundo ao quinto argumentos indicam o retângulo (x, y, largura e altura) da imagem fonte que deve ser copiado, e do sexto ao nono argumentos indicam o retângulo (no canvas) para onde ele deve ser copiado.
Isso pode ser usado para agrupar vários sprites (elementos de imagem) em um único arquivo de imagem e então desenhar apenas a parte que você precisa. Por exemplo, esta imagem contém um personagem de jogo em múltiplas poses:

Alternando qual pose desenhamos, podemos mostrar uma animação que parece um personagem andando.
Para animar uma imagem em um canvas, o método clearRect é útil. Ele se parece com fillRect, mas ao invés de colorir o retângulo, torna-o transparente, removendo os pixels desenhados anteriormente.
Sabemos que cada sprite, cada subimagem, tem 24 pixels de largura e 30 pixels de altura. O código a seguir carrega a imagem e então configura um intervalo (timer repetido) para desenhar o próximo _frame_:
<canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let img = document.createElement("img"); img.src = "img/player.png"; let spriteW = 24, spriteH = 30; img.addEventListener("load", () => { let cycle = 0; setInterval(() => { cx.clearRect(0, 0, spriteW, spriteH); cx.drawImage(img, // retângulo fonte cycle * spriteW, 0, spriteW, spriteH, // retângulo destino 0, 0, spriteW, spriteH); cycle = (cycle + 1) % 8; }, 120); }); </script>
A variável cycle monitora nossa posição na animação. Para cada _frame_, ela é incrementada e depois limitada ao intervalo de 0 a 7 usando o operador de resto. Essa variável é então usada para calcular a coordenada x que o sprite da pose atual tem na imagem.
Transformação
E se quisermos que nosso personagem ande para a esquerda em vez de para a direita? Poderíamos, claro, desenhar outro conjunto de sprites. Mas também poderíamos instruir o canvas a desenhar a imagem invertida.
Chamar o método scale fará com que tudo que for desenhado após ele seja escalonado. Este método aceita dois parâmetros, um para definir a escala horizontal e outro para definir a escala vertical.
<canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.scale(3, .5); cx.beginPath(); cx.arc(50, 50, 40, 0, 7); cx.lineWidth = 3; cx.stroke(); </script>
Escalar fará com que tudo sobre a imagem desenhada, incluindo a line width, seja esticado ou comprimido conforme especificado. Escalar por um valor negativo virará a imagem. A inversão acontece em torno do ponto (0, 0), o que significa que também inverterá a direção do sistema de coordenadas. Quando uma escala horizontal de -1 é aplicada, uma forma desenhada na posição x 100 terminará na posição que antes era -100.
Para virar uma imagem, não podemos simplesmente adicionar cx.scale(-1, 1) antes da chamada a drawImage. Isso movia nossa imagem para fora do canvas, onde não será visível. Poderíamos ajustar as coordinates dadas para drawImage para compensar isso, desenhando a imagem na posição x -50 em vez de 0. Outra solução, que não exige que o código que faz o desenho saiba sobre a mudança de escala, é ajustar o eixo em torno do qual a escala acontece.
Existem vários outros métodos além de scale que influenciam o sistema de coordenadas de um canvas. Você pode rotacionar formas desenhadas subsequentemente com o método rotate e movê-las com o método translate. A parte interessante — e confusa — é que essas transformações se empilham, significando que cada uma acontece relativa às transformações anteriores.
Se transladarmos 10 pixels horizontais duas vezes, tudo será desenhado 20 pixels para a direita. Se primeiro movermos o centro do sistema de coordenadas para (50, 50) e depois rotacionarmos 20 graus (aproximadamente 0.1π radianos), essa rotação acontecerá ao redor do ponto (50, 50).
Mas se primeiro rotacionarmos 20 graus e depois transladarmos por (50, 50), a translação acontecerá no sistema de coordenadas rotacionado e produzirá uma orientação diferente. A ordem em que as transformações são aplicadas importa.
Para virar uma imagem ao redor da linha vertical em uma dada posição x, podemos fazer o seguinte:
function flipHorizontally(context, around) { context.translate(around, 0); context.scale(-1, 1); context.translate(-around, 0); }
Movemos o eixo y para onde queremos que nosso espelho fique, aplicamos a inversão e finalmente movemos o eixo y de volta para seu lugar correto no universo espelhado. A imagem a seguir explica por que isso funciona:
Isso mostra os sistemas de coordenadas antes e depois de espelhar ao longo da linha central. Os triângulos são numerados para ilustrar cada passo. Se desenharmos um triângulo em uma posição x positiva, ele estaria, por padrão, no lugar onde o triângulo 1 está. Uma chamada para flipHorizontally primeiro faz uma translação para a direita, que nos leva ao triângulo 2. Depois aplica escala, invertendo o triângulo para a posição 3. Esse não é o lugar correto se ele fosse espelhado na linha dada. A segunda chamada translate corrige isso — ela “cancela” a translação inicial e faz o triângulo 4 aparecer exatamente onde deve.
Agora podemos desenhar um personagem espelhado na posição (100, 0) invertendo o mundo ao redor do centro vertical do personagem.
<canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let img = document.createElement("img"); img.src = "img/player.png"; let spriteW = 24, spriteH = 30; img.addEventListener("load", () => { flipHorizontally(cx, 100 + spriteW / 2); cx.drawImage(img, 0, 0, spriteW, spriteH, 100, 0, spriteW, spriteH); }); </script>
Armazenando e limpando transformações
As transformações permanecem. Tudo o que desenharmos depois de desenhar aquele personagem espelhado também será espelhado. Isso pode ser inconveniente.
É possível salvar a transformação atual, fazer alguns desenhos e transformações, e então restaurar a transformação antiga. Isso normalmente é o procedimento correto para uma função que precisa transformar temporariamente o sistema de coordenadas. Primeiro, salvamos qualquer transformação que o código que chamou a função estivesse usando. Em seguida, a função faz o que precisa, adicionando mais transformações sobre a transformação atual. Por fim, voltamos para a transformação com que começamos.
Os métodos save e restore no contexto 2D do canvas fazem esse gerenciamento de transformação. Conceitualmente, eles mantêm uma pilha de estados de transformação. Quando você chama save, o estado atual é empilhado, e quando chama restore, o estado do topo da pilha é retirado e usado como a transformação atual do contexto. Você também pode chamar resetTransform para resetar completamente a transformação.
A função branch no exemplo a seguir ilustra o que você pode fazer com uma função que altera a transformação e depois chama uma função (neste caso, ela mesma), que continua desenhando com a transformação dada.
Essa função desenha uma forma em árvore ao desenhar uma linha, mover o centro do sistema de coordenadas para a ponta da linha, e se chamar duas vezes—primeiro rotacionada para a esquerda e depois para a direita. Cada chamada reduz o comprimento do galho desenhado, e a recursão para quando o comprimento cai abaixo de 8.
<canvas width="600" height="300"></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); function branch(length, angle, scale) { cx.fillRect(0, 0, 1, length); if (length < 8) return; cx.save(); cx.translate(0, length); cx.rotate(-angle); branch(length * scale, angle, scale); cx.rotate(2 * angle); branch(length * scale, angle, scale); cx.restore(); } cx.translate(300, 0); branch(60, 0.5, 0.8); </script>
Se as chamadas para save e restore não estivessem lá, a segunda chamada recursiva para branch terminaria com a posição e rotação criadas pela primeira chamada. Ela estaria conectada não ao galho atual, mas ao galho mais interno e à direita desenhado pela primeira chamada. A forma resultante poderia até ser interessante, mas certamente não seria uma árvore.
De volta ao jogo
Agora sabemos o suficiente sobre desenho em canvas para começar a trabalhar em um sistema de _display_ baseado em canvas para o game do capítulo anterior. A nova tela não mostrará mais apenas caixas coloridas. Em vez disso, usaremos drawImage para desenhar imagens que representam os elementos do jogo.
Definimos outro tipo de objeto display chamado CanvasDisplay, suportando a mesma interface que o DOMDisplay do Capítulo 16—nomeadamente, os métodos syncState e clear.
Este objeto mantém um pouco mais de informação do que DOMDisplay. Em vez de usar a posição do scroll do seu elemento DOM, ele monitora seu próprio viewport, que nos diz qual parte do nível estamos vendo atualmente. Finalmente, ele mantém uma propriedade flipPlayer para que, mesmo quando o jogador está parado, ele continue olhando para a direção em que se movimentou por último.
class CanvasDisplay { constructor(parent, level) { this.canvas = document.createElement("canvas"); this.canvas.width = Math.min(600, level.width * scale); this.canvas.height = Math.min(450, level.height * scale); parent.appendChild(this.canvas); this.cx = this.canvas.getContext("2d"); this.flipPlayer = false; this.viewport = { left: 0, top: 0, width: this.canvas.width / scale, height: this.canvas.height / scale }; } clear() { this.canvas.remove(); } }
O método syncState primeiro calcula um novo viewport e então desenha a cena do jogo na posição apropriada.
CanvasDisplay.prototype.syncState = function(state) { this.updateViewport(state); this.clearDisplay(state.status); this.drawBackground(state.level); this.drawActors(state.actors); };
Ao contrário do DOMDisplay, este estilo de display precisa redesenhar o background a cada atualização. Porque formas em um canvas são apenas pixels, depois que as desenhamos não há uma boa forma de movê-las (ou removê-las). A única maneira de atualizar o display do canvas é limpá-lo e redesenhar a cena. Também podemos ter rolado a tela, o que requer que o background esteja em uma posição diferente.
O método updateViewport é similar ao método scrollPlayerIntoView do DOMDisplay. Ele verifica se o jogador está muito perto da borda da tela e move o viewport quando este é o caso.
CanvasDisplay.prototype.updateViewport = function(state) { let view = this.viewport, margin = view.width / 3; let player = state.player; let center = player.pos.plus(player.size.times(0.5)); if (center.x < view.left + margin) { view.left = Math.max(center.x - margin, 0); } else if (center.x > view.left + view.width - margin) { view.left = Math.min(center.x + margin - view.width, state.level.width - view.width); } if (center.y < view.top + margin) { view.top = Math.max(center.y - margin, 0); } else if (center.y > view.top + view.height - margin) { view.top = Math.min(center.y + margin - view.height, state.level.height - view.height); } };
As chamadas para Math.max e Math.min garantem que o viewport não termine mostrando espaço fora do nível. Math.max(x, 0) assegura que o número resultante não seja menor que zero. Math.min garante de forma semelhante que um valor fique abaixo de um limite dado.
Ao limpar o display, usaremos uma cor um pouco diferente dependendo se o jogo foi ganho (mais clara) ou perdido (mais escura).
CanvasDisplay.prototype.clearDisplay = function(status) { if (status == "won") { this.cx.fillStyle = "rgb(68, 191, 255)"; } else if (status == "lost") { this.cx.fillStyle = "rgb(44, 136, 214)"; } else { this.cx.fillStyle = "rgb(52, 166, 251)"; } this.cx.fillRect(0, 0, this.canvas.width, this.canvas.height); };
Para desenhar o fundo, percorremos os tiles visíveis na viewport atual, usando o mesmo truque usado no método touches do capítulo anterior.
let otherSprites = document.createElement("img"); otherSprites.src = "img/sprites.png"; CanvasDisplay.prototype.drawBackground = function(level) { let {left, top, width, height} = this.viewport; let xStart = Math.floor(left); let xEnd = Math.ceil(left + width); let yStart = Math.floor(top); let yEnd = Math.ceil(top + height); for (let y = yStart; y < yEnd; y++) { for (let x = xStart; x < xEnd; x++) { let tile = level.rows[y][x]; if (tile == "empty") continue; let screenX = (x - left) * scale; let screenY = (y - top) * scale; let tileX = tile == "lava" ? scale : 0; this.cx.drawImage(otherSprites, tileX, 0, scale, scale, screenX, screenY, scale, scale); } } };
Os tiles que não estão vazios são desenhados com drawImage. A imagem otherSprites contém as figuras usadas para elementos que não são o jogador. Ela contém, da esquerda para a direita, o tile de parede, o tile de lava e o sprite de uma moeda.

Os tiles de fundo têm 20 por 20 pixels, pois usaremos a mesma escala do DOMDisplay. Assim, o deslocamento para os tiles de lava é 20 (o valor da constante scale) e o deslocamento para as paredes é 0.
Não nos preocupamos em esperar a imagem do sprite carregar. Chamar drawImage com uma imagem que ainda não foi carregada simplesmente não faz nada. Portanto, pode acontecer de não desenharmos o jogo corretamente nos primeiros _frames_ enquanto a imagem está carregando, mas isso não é um problema sério. Como continuamos atualizando a tela, a cena correta aparecerá assim que o carregamento terminar.
O personagem andando mostrado anteriormente será usado para representar o jogador. O código que o desenha precisa escolher o sprite e a direção corretos com base no movimento atual do jogador. Os primeiros oito sprites contêm a animação de caminhada. Quando o jogador está andando no chão, percorremos eles com base no tempo atual. Queremos trocar de frame a cada 60 milissegundos, então o tempo é dividido por 60 primeiro. Quando o jogador está parado, desenhamos o nono sprite. Durante os pulos, que são reconhecidos pelo fato da velocidade vertical não ser zero, usamos o décimo sprite, o mais à direita.
Como os sprites são um pouco mais largos que o objeto jogador—24 em vez de 16 pixels para permitir espaço para pés e braços—o método precisa ajustar a coordenada x e a largura por uma certa quantidade (playerXOverlap).
let playerSprites = document.createElement("img"); playerSprites.src = "img/player.png"; const playerXOverlap = 4; CanvasDisplay.prototype.drawPlayer = function(player, x, y, width, height){ width += playerXOverlap * 2; x -= playerXOverlap; if (player.speed.x != 0) { this.flipPlayer = player.speed.x < 0; } let tile = 8; if (player.speed.y != 0) { tile = 9; } else if (player.speed.x != 0) { tile = Math.floor(Date.now() / 60) % 8; } this.cx.save(); if (this.flipPlayer) { flipHorizontally(this.cx, x + width / 2); } let tileX = tile * width; this.cx.drawImage(playerSprites, tileX, 0, width, height, x, y, width, height); this.cx.restore(); };
O método drawPlayer é chamado por drawActors, que é responsável por desenhar todos os atores no jogo.
CanvasDisplay.prototype.drawActors = function(actors) { for (let actor of actors) { let width = actor.size.x * scale; let height = actor.size.y * scale; let x = (actor.pos.x - this.viewport.left) * scale; let y = (actor.pos.y - this.viewport.top) * scale; if (actor.type == "player") { this.drawPlayer(actor, x, y, width, height); } else { let tileX = (actor.type == "coin" ? 2 : 1) * scale; this.cx.drawImage(otherSprites, tileX, 0, width, height, x, y, width, height); } } };
Quando estamos desenhando algo que não é o player, olhamos seu tipo para encontrar o deslocamento do sprite correto. O tile de lava é encontrado no deslocamento 20, e o sprite de coin é encontrado no 40 (duas vezes o scale).
Temos que subtrair a posição do viewport ao calcular a posição do ator, pois (0, 0) no nosso canvas corresponde ao canto superior esquerdo do viewport, e não ao canto superior esquerdo do nível. Também poderíamos ter usado translate para isso. De qualquer forma funciona.
Este documento conecta a nova tela em runGame:
<body> <script> runGame(GAME_LEVELS, CanvasDisplay); </script> </body>
Escolhendo uma interface gráfica
Quando você precisa gerar gráficos no navegador, pode escolher entre HTML simples, SVG e canvas. Não existe uma abordagem melhor que funcione em todas as situações. Cada opção tem pontos fortes e fracos.
HTML simples tem a vantagem de ser simples. Ele também se integra bem com texto. Tanto SVG quanto canvas permitem que você desenhe texto, mas eles não ajudam a posicionar esse texto nem a quebrá-lo quando ocupa mais de uma linha. Em uma imagem baseada em HTML, é muito mais fácil incluir blocos de texto.
SVG pode ser usado para produzir gráficos nítidos que ficam bons em qualquer nível de zoom. Diferente do HTML, ele é projetado para desenho e, portanto, mais adequado para esse propósito.
Tanto SVG quanto HTML constroem uma estrutura de dados (o DOM) que representa sua imagem. Isso torna possível modificar elementos depois de desenhados. Se você precisa mudar repetidamente uma pequena parte de uma grande imagem em resposta ao que o usuário está fazendo ou como parte de uma animação, fazer isso em um canvas pode ser desnecessariamente custoso. O DOM também permite registrar manipuladores de eventos do mouse em cada elemento da imagem (mesmo nas formas desenhadas com SVG). Isso não é possível com canvas.
Mas a abordagem orientada a pixels do canvas pode ser uma vantagem ao desenhar uma enorme quantidade de pequenos elementos. O fato de que ele não constrói uma estrutura de dados, mas apenas desenha repetidamente na mesma superfície de pixels, dá ao canvas um custo menor por forma. Existem também efeitos que só são práticos com um elemento canvas, como renderizar uma cena um pixel por vez (por exemplo, usando um ray tracer) ou pós-processar uma imagem com JavaScript (desfocando ou distorcendo-a).
Em alguns casos, você pode querer combinar várias dessas técnicas. Por exemplo, você pode desenhar um grafo com SVG ou canvas mas mostrar informações textuais posicionando um elemento HTML sobre a imagem.
Para aplicações pouco exigentes, realmente não faz muita diferença qual interface você escolhe. O display que construímos para nosso jogo neste capítulo poderia ter sido implementado usando qualquer uma dessas três tecnologias de gráficos, pois não precisava desenhar texto, lidar com interação do mouse ou trabalhar com um número extraordinariamente grande de elementos.
Resumo
Neste capítulo discutimos técnicas para desenhar gráficos no navegador, focando no elemento <canvas>.
Um nó canvas representa uma área em um documento que nosso programa pode desenhar. Esse desenho é feito por meio de um objeto de contexto de desenho, criado com o método getContext.
A interface de desenho 2D nos permite preencher e contornar várias formas. A propriedade fillStyle do contexto determina como as formas são preenchidas. As propriedades strokeStyle e lineWidth controlam o modo como as linhas são desenhadas.
Retângulos e pedaços de texto podem ser desenhados com uma única chamada de método. Os métodos fillRect e strokeRect desenham retângulos, e os métodos fillText e strokeText desenham texto. Para criar formas personalizadas, devemos primeiro construir um caminho.
Chamar beginPath inicia um novo caminho. Vários outros métodos adicionam linhas e curvas ao caminho atual. Por exemplo, lineTo pode adicionar uma linha reta. Quando um caminho está terminado, ele pode ser preenchido com o método fill ou contornado com o método stroke.
Mover pixels de uma imagem ou de outro canvas para nosso canvas é feito com o método drawImage. Por padrão, esse método desenha a imagem fonte inteira, mas dando mais parâmetros, você pode copiar uma área específica da imagem. Usamos isso em nosso jogo copiando poses individuais do personagem principal a partir de uma imagem que continha muitas dessas poses.
Transformações permitem desenhar uma forma em múltiplas orientações. Um contexto 2D tem uma transformação atual que pode ser alterada com os métodos translate, scale e rotate. Esses alterarão todas as operações de desenho subsequentes. Um estado de transformação pode ser salvo com o método save e restaurado com o método restore.
Ao mostrar uma animação em um canvas, o método clearRect pode ser usado para limpar parte do canvas antes de redesenhá-lo.
Exercícios
Formas
Escreva um programa que desenhe as seguintes formas em um canvas:

Ao desenhar as duas últimas formas, você pode querer consultar a explicação de Math.cos e Math.sin no Capítulo 14, que descreve como obter coordenadas em um círculo usando essas funções.
Recomendo criar uma função para cada forma. Passe a posição e, opcionalmente, outras propriedades, como o tamanho ou o número de pontas, como parâmetros. A alternativa, que é codificar números fixos no seu código, tende a tornar o código desnecessariamente difícil de ler e modificar.
<canvas width="600" height="200"></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); // Seu código aqui. </script>
Mostrar dicas...
O trapézio (1) é mais fácil de desenhar usando um path. Escolha coordenadas centrais adequadas e adicione cada um dos quatro cantos ao redor do centro.
O losango (2) pode ser desenhado de duas maneiras: a forma direta, com um path, ou a forma interessante, com uma transformação rotate. Para usar a rotação, você terá que aplicar um truque similar ao que fizemos na função flipHorizontally. Porque você quer rotacionar em torno do centro do seu retângulo e não em torno do ponto (0, 0), primeiro deve translate para lá, depois rotacionar, e então transladar de volta.
Certifique-se de resetar a transformação após desenhar qualquer forma que crie uma.
Para o ziguezague (3) torna-se impraticável escrever uma nova chamada para lineTo para cada segmento de linha. Em vez disso, você deveria usar um loop. Você pode fazer com que cada iteração desenhe dois segmentos de linha (para a direita e depois para a esquerda novamente) ou apenas um, caso em que deve usar a paridade (% 2) do índice do loop para determinar se vai para a esquerda ou para a direita.
Você também precisará de um loop para a espiral (4). Se você desenhar uma série de pontos, com cada ponto se movendo mais longe ao longo de um círculo em torno do centro da espiral, obterá um círculo. Se, durante o loop, variar o raio do círculo onde está colocando o ponto atual e fizer mais de uma volta, o resultado será uma espiral.
A estrela (5) representada é construída com linhas quadraticCurveTo. Você também poderia desenhar uma com linhas retas. Divida um círculo em oito partes para uma estrela com oito pontas, ou quantas partes você quiser. Desenhe linhas entre esses pontos, fazendo com que elas se curvem para o centro da estrela. Com quadraticCurveTo, você pode usar o centro como ponto de controle.
O gráfico de pizza
Anteriormente no capítulo, vimos um programa de exemplo que desenhou um gráfico de pizza. Modifique este programa para que o nome de cada categoria seja mostrado próximo à fatia que a representa. Tente encontrar uma forma visualmente agradável de posicionar automaticamente esse texto que funcione para outros conjuntos de dados também. Você pode supor que as categorias são grandes o suficiente para deixar espaço suficiente para seus rótulos.
Você pode precisar novamente de Math.sin e Math.cos, que são descritos no Capítulo 14.
<canvas width="600" height="300"></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let total = results .reduce((sum, {count}) => sum + count, 0); let currentAngle = -0.5 * Math.PI; let centerX = 300, centerY = 150; // Adicione código para desenhar os rótulos das fatias neste loop. for (let result of results) { let sliceAngle = (result.count / total) * 2 * Math.PI; cx.beginPath(); cx.arc(centerX, centerY, 100, currentAngle, currentAngle + sliceAngle); currentAngle += sliceAngle; cx.lineTo(centerX, centerY); cx.fillStyle = result.color; cx.fill(); } </script>
Mostrar dicas...
Você precisará chamar fillText e definir as propriedades textAlign e textBaseline do contexto de modo que o texto fique exatamente onde você quer.
Uma forma sensata de posicionar os rótulos seria colocar o texto na linha que vai do centro do gráfico até o meio da fatia. Você não quer colocar o texto colado ao lado do gráfico, mas sim deslocá-lo para o lado do gráfico um dado número de pixels.
O ângulo dessa linha é currentAngle + 0.. O código a seguir encontra uma posição nessa linha a 120 pixels do centro:
let middleAngle = currentAngle + 0.5 * sliceAngle; let textX = Math.cos(middleAngle) * 120 + centerX; let textY = Math.sin(middleAngle) * 120 + centerY;
Para textBaseline, o valor "middle" provavelmente é apropriado ao usar essa abordagem. O que usar para textAlign depende de qual lado do círculo estamos. No lado esquerdo, deve ser "right", e no lado direito, deve ser "left", para que o texto fique posicionado afastado do gráfico.
Se você não tem certeza de como descobrir em qual lado do círculo um dado ângulo está, veja a explicação de Math.cos no Capítulo 14. O cosseno de um ângulo nos diz qual coordenada x ele corresponde, o que por sua vez nos diz exatamente em qual lado do círculo estamos.
Uma bola quicando
Use a técnica requestAnimationFrame que vimos no Capítulo 14 e no Capítulo 16 para desenhar uma caixa com uma bola quicando dentro dela. A bola se move a uma velocidade constante e quica nas bordas da caixa quando as toca.
<canvas width="400" height="400"></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let lastTime = null; function frame(time) { if (lastTime != null) { updateAnimation(Math.min(100, time - lastTime) / 1000); } lastTime = time; requestAnimationFrame(frame); } requestAnimationFrame(frame); function updateAnimation(step) { // Seu código aqui. } </script>
Mostrar dicas...
Uma caixa é fácil de desenhar com strokeRect. Defina uma variável que armazene seu tamanho, ou defina duas variáveis se a largura e a altura da caixa forem diferentes. Para criar uma bola redonda, comece um caminho e chame arc(x, y, radius, 0, 7), que cria um arco indo de zero até mais que um círculo completo. Então preencha o caminho.
Para modelar a posição e a velocidade da bola, você pode usar a classe Vec do Capítulo 16 (que está disponível nesta página). Dê a ela uma velocidade inicial, preferencialmente que não seja puramente vertical nem horizontal, e para cada quadro multiplique essa velocidade pelo tempo decorrido. Quando a bola se aproximar demais de uma parede vertical, inverta a componente x da velocidade. Da mesma forma, inverta a componente y quando ela bater em uma parede horizontal.
Após encontrar a nova posição e velocidade da bola, use clearRect para apagar a cena e redesenhá-la usando a nova posição.
Espelhamento pré-calculado
Uma coisa desagradável sobre transformações é que elas desaceleram o desenho de bitmaps. A posição e o tamanho de cada pixel têm que ser transformados, e embora seja possível que navegadores fiquem mais inteligentes sobre transformação no futuro, atualmente elas causam um aumento mensurável no tempo necessário para desenhar um bitmap.
Em um jogo como o nosso, onde desenhamos apenas um sprite transformado, isso não é um problema. Mas imagine que precisamos desenhar centenas de personagens ou milhares de partículas girando em uma explosão.
Pense em uma forma de desenhar um personagem invertido sem carregar arquivos de imagem adicionais e sem ter que fazer chamadas transformadas de drawImage a cada quadro.
Mostrar dicas...
A chave para a solução é o fato de que podemos usar um elemento canvas como imagem fonte ao usar drawImage. É possível criar um <canvas> extra, sem adicioná-lo ao documento, e desenhar nossos sprites invertidos nele, uma vez só. Ao desenhar um quadro real, nós apenas copiamos os sprites já invertidos para o canvas principal.
Algum cuidado é necessário porque as imagens não carregam instantaneamente. Fazemos o desenho invertido apenas uma vez, e se o fizermos antes da imagem carregar, não desenhará nada. Um manipulador de "load" na imagem pode ser usado para desenhar as imagens invertidas no canvas extra. Esse canvas pode ser usado imediatamente como fonte para desenho (ele simplesmente ficará em branco até desenharmos o personagem nele).