Canvas de jogo responsivo
Um canvas que não é dimensionado corretamente fica borrado ou apresenta problemas em diferentes tamanhos de tela. Já depurei esse problema mais vezes do que consigo contar, então vou poupar você desse trabalho.
Experimente diferentes modos de dimensionamento:
O problema central
O canvas tem dois tamanhos que são fáceis de confundir. O tamanho definido pelo CSS controla o tamanho com que ele aparece na tela. A resolução interna controla quantos pixels você realmente desenha. Se esses valores não corresponderem, o resultado será desfoque ou distorção.
Em uma tela Retina, o CSS pode definir 800x600, mas a tela na verdade tem 1600x1200 pixels físicos. Se a resolução interna do canvas for apenas 800x600, o navegador o amplia e tudo fica borrado.
Configuração responsiva básica
Veja como fazer um canvas preencher a janela e manter a nitidez:
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
function resize() {
const dpr = window.devicePixelRatio || 1
const width = window.innerWidth
const height = window.innerHeight
// Define a resolução interna para corresponder aos pixels físicos
canvas.width = width * dpr
canvas.height = height * dpr
// Define o tamanho do CSS para corresponder aos pixels lógicos
canvas.style.width = width + 'px'
canvas.style.height = height + 'px'
// Dimensiona o contexto para que você possa desenhar em pixels lógicos
ctx.scale(dpr, dpr)
}
window.addEventListener('resize', resize)
resize()A linha ctx.scale(dpr, dpr) é o segredo. Depois disso, você pode desenhar na posição (100, 100), e o conteúdo aparecerá no lugar certo independentemente do DPR. Internamente, o canvas renderiza em 200x200 em uma tela 2x, mas seu código não precisa saber disso.
Uma armadilha: ctx.scale é cumulativo. Se você chamar a função de redimensionamento várias vezes, a escala continuará aumentando. Redefina primeiro a transformação com ctx.setTransform(1, 0, 0, 1, 0, 0) antes de aplicar a escala, ou aplique-a apenas uma vez.
Proporção fixa com barras
Muitos jogos precisam de uma proporção específica. Você não quer que seu jogo em 16:9 seja esticado para preencher um monitor ultrawide de 21:9. As barras pretas preservam a proporção.
const GAME_WIDTH = 1920
const GAME_HEIGHT = 1080
const ASPECT = GAME_WIDTH / GAME_HEIGHT
function resizeWithLetterbox() {
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
const windowAspect = windowWidth / windowHeight
let width, height
if (windowAspect > ASPECT) {
// A janela é mais larga que o jogo; adiciona barras nas laterais
height = windowHeight
width = height * ASPECT
} else {
// A janela é mais alta que o jogo; adiciona barras em cima e embaixo
width = windowWidth
height = width / ASPECT
}
canvas.style.width = width + 'px'
canvas.style.height = height + 'px'
canvas.style.position = 'absolute'
canvas.style.left = (windowWidth - width) / 2 + 'px'
canvas.style.top = (windowHeight - height) / 2 + 'px'
// A resolução interna permanece fixa
canvas.width = GAME_WIDTH
canvas.height = GAME_HEIGHT
}Para que as barras apareçam, defina o fundo do body como preto:
body {
margin: 0;
background: #000;
overflow: hidden;
}Escala inteira para pixel art
Pixel art fica nítida com escalas inteiras (1x, 2x, 3x) e borrada com escalas fracionárias, como 2,7x. Mesmo com image-rendering: pixelated, uma escala de 2,5x significa que alguns pixels terão 2 pixels de tela de largura e outros terão 3.
const GAME_WIDTH = 320
const GAME_HEIGHT = 180
function resizePixelPerfect() {
const maxWidth = window.innerWidth
const maxHeight = window.innerHeight
// Encontra a maior escala inteira que cabe na tela
const scaleX = Math.floor(maxWidth / GAME_WIDTH)
const scaleY = Math.floor(maxHeight / GAME_HEIGHT)
const scale = Math.max(1, Math.min(scaleX, scaleY))
const width = GAME_WIDTH * scale
const height = GAME_HEIGHT * scale
canvas.width = GAME_WIDTH
canvas.height = GAME_HEIGHT
canvas.style.width = width + 'px'
canvas.style.height = height + 'px'
canvas.style.imageRendering = 'pixelated'
}A desvantagem é que o jogo não preencherá a tela por completo. Em uma tela de 1920x1080 com um jogo de 320x180, você obtém uma escala de 5x (1600x900), com barras pretas ao redor das bordas. Alguns jogadores preferem isso; outros querem que o jogo preencha a tela, mesmo que fique um pouco borrado.
Dimensionamento do canvas WebGL
No WebGL, você precisa atualizar a viewport além do tamanho do canvas:
function resizeGL(gl, canvas) {
const dpr = window.devicePixelRatio || 1
const width = canvas.clientWidth * dpr
const height = canvas.clientHeight * dpr
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
gl.viewport(0, 0, width, height)
return true // Redimensionado; talvez seja necessário atualizar as matrizes de projeção
}
return false
}Chame essa função no início do loop de renderização, não apenas em eventos de redimensionamento. Alguns navegadores alteram o DPR quando você arrasta uma janela entre monitores, e é importante detectar essa mudança.
Aplicando debounce aos eventos de redimensionamento
Os eventos de redimensionamento são disparados rapidamente enquanto o usuário arrasta a borda da janela. Se sua função de redimensionamento for pesada, ocorrerão travamentos.
let resizeTimeout
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout)
resizeTimeout = setTimeout(resize, 100)
})O atraso de 100 ms significa que o redimensionamento só acontece depois que o usuário para de arrastar. Para um tratamento mais suave durante o arraste, use ResizeObserver:
const observer = new ResizeObserver(entries => {
resize()
})
observer.observe(document.body)ResizeObserver é acionado com menos frequência e tem uma otimização melhor pelo navegador.
Tela cheia
A API Fullscreen permite ocupar a tela inteira:
async function toggleFullscreen() {
if (!document.fullscreenElement) {
await canvas.requestFullscreen()
} else {
await document.exitFullscreen()
}
}
document.addEventListener('fullscreenchange', resize)Você deve chamar requestFullscreen em resposta a um gesto do usuário, como um clique. Caso contrário, os navegadores bloqueiam a solicitação. E lembre-se de redimensionar ao entrar ou sair do modo de tela cheia, pois o espaço disponível muda.
Uma ressalva de plataforma: em meados de 2026, a API Fullscreen ainda não oferece tela cheia para elementos no Safari do iPhone. requestFullscreen funciona em navegadores para desktop e no iPadOS, mas não é compatível no iPhone com elementos arbitrários, como um canvas — apenas a reprodução nativa de vídeo em tela cheia funciona. Detecte a disponibilidade do recurso com if (canvas.requestFullscreen) e ofereça uma alternativa adequada, como dimensionar o canvas para preencher a viewport visível ou sugerir que os usuários adicionem o jogo à tela inicial como um PWA.
Juntando tudo
Veja uma classe que lida com todos os casos comuns:
class ResponsiveCanvas {
constructor(canvas, options = {}) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.gameWidth = options.width || 1280
this.gameHeight = options.height || 720
this.pixelArt = options.pixelArt || false
this.letterbox = options.letterbox !== false
this.bind()
this.resize()
}
bind() {
window.addEventListener('resize', () => this.resize())
document.addEventListener('fullscreenchange', () => this.resize())
}
resize() {
const aspect = this.gameWidth / this.gameHeight
let width = window.innerWidth
let height = window.innerHeight
if (this.letterbox) {
if (width / height > aspect) {
width = height * aspect
} else {
height = width / aspect
}
}
if (this.pixelArt) {
const scale = Math.max(1, Math.floor(Math.min(
window.innerWidth / this.gameWidth,
window.innerHeight / this.gameHeight
)))
width = this.gameWidth * scale
height = this.gameHeight * scale
}
this.canvas.width = this.gameWidth
this.canvas.height = this.gameHeight
this.canvas.style.width = width + 'px'
this.canvas.style.height = height + 'px'
this.canvas.style.position = 'absolute'
this.canvas.style.left = (window.innerWidth - width) / 2 + 'px'
this.canvas.style.top = (window.innerHeight - height) / 2 + 'px'
if (this.pixelArt) {
this.canvas.style.imageRendering = 'pixelated'
}
}
get width() { return this.gameWidth }
get height() { return this.gameHeight }
}Use assim:
const responsive = new ResponsiveCanvas(document.getElementById('game'), {
width: 320,
height: 180,
pixelArt: true,
letterbox: true
})
// No código do jogo, use responsive.width e responsive.heightArmadilhas comuns
Alguns detalhes que costumam causar problemas:
O canvas é apagado ao ser redimensionado. Quando você define canvas.width ou canvas.height, o canvas é apagado. Se você não redesenha a cada frame, precisa redesenhar após o redimensionamento.
O DPR pode mudar. Se o usuário arrastar a janela do navegador de uma tela Retina para uma tela que não seja Retina, o DPR mudará. Verifique-o no loop de renderização, não apenas ao redimensionar.
Peculiaridades da viewport no Safari do iOS. A altura da viewport muda quando a barra de endereço aparece ou desaparece. Use window.visualViewport para obter dimensões mais precisas em dispositivos móveis.
O estado do contexto é redefinido. Definir as dimensões do canvas redefine o estado do contexto, incluindo transformações, estilos de preenchimento e imageSmoothingEnabled. Reaplique essas configurações após o redimensionamento.
Mais recursos
Loop de jogo com Canvas 2D aborda os fundamentos dos loops de atualização e renderização.
Renderização de pixel art se aprofunda em gráficos de pixels nítidos.
Jogos web adaptados para dispositivos móveis aborda controles por toque e problemas de viewport.
Fundamentos de WebGL aborda a configuração da viewport e do canvas para contextos WebGL.
Publique um jogo web que carregue rapidamente aborda a otimização do tamanho do canvas para melhorar o desempenho.