Skip to content

Canvas de jeu responsive

Un canvas qui ne se redimensionne pas correctement devient flou ou s’affiche mal selon la taille de l’écran. J’ai dû résoudre ce problème plus souvent que je ne saurais le compter, alors laissez-moi vous épargner ces difficultés.

Essayez différents modes de mise à l’échelle :

Le problème fondamental

Un canvas possède deux tailles qu’il est facile de confondre. La taille CSS détermine ses dimensions apparentes à l’écran. La résolution interne détermine le nombre de pixels que vous dessinez réellement. Si elles ne correspondent pas, l’image devient floue ou déformée.

Sur un écran Retina, votre CSS peut indiquer 800x600, alors que l’écran possède en réalité 1600x1200 pixels physiques. Si la résolution interne de votre canvas n’est que de 800x600, le navigateur l’agrandit et tout paraît flou.

Configuration responsive de base

Voici comment faire en sorte qu’un canvas remplisse la fenêtre tout en restant net :

js
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
  
  // Set internal resolution to match physical pixels
  canvas.width = width * dpr
  canvas.height = height * dpr
  
  // Set CSS size to match logical pixels
  canvas.style.width = width + 'px'
  canvas.style.height = height + 'px'
  
  // Scale context so you can draw in logical pixels
  ctx.scale(dpr, dpr)
}

window.addEventListener('resize', resize)
resize()

La ligne ctx.scale(dpr, dpr) est l’astuce essentielle. Vous pouvez ensuite dessiner à la position (100, 100), qui s’affichera au bon endroit quelle que soit la valeur du DPR. En interne, le canvas effectue un rendu en 200x200 sur un écran 2x, mais votre code n’a pas besoin de le savoir.

Attention à un piège : ctx.scale est cumulatif. Si vous appelez plusieurs fois la fonction de redimensionnement, la mise à l’échelle continuera d’augmenter. Réinitialisez d’abord la transformation avec ctx.setTransform(1, 0, 0, 1, 0, 0) avant d’appliquer la mise à l’échelle, ou ne l’appliquez qu’une seule fois.

Format d’image fixe avec bandes noires

De nombreux jeux nécessitent un format d’image précis. Vous ne voulez pas que votre jeu en 16:9 soit étiré pour remplir un moniteur ultralarge en 21:9. L’ajout de bandes noires permet de préserver les proportions.

js
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) {
    // Window is wider than game, add bars on sides
    height = windowHeight
    width = height * ASPECT
  } else {
    // Window is taller than game, add bars on top/bottom
    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'
  
  // Internal resolution stays fixed
  canvas.width = GAME_WIDTH
  canvas.height = GAME_HEIGHT
}

Pour que les bandes noires soient visibles, définissez l’arrière-plan du corps de la page en noir :

css
body {
  margin: 0;
  background: #000;
  overflow: hidden;
}

Mise à l’échelle entière pour le pixel art

Le pixel art reste net avec des facteurs d’échelle entiers (1x, 2x, 3x), mais devient flou avec des facteurs fractionnaires comme 2,7x. Même avec image-rendering: pixelated, une échelle de 2,5x signifie que certains pixels occupent 2 pixels à l’écran et d’autres 3.

js
const GAME_WIDTH = 320
const GAME_HEIGHT = 180

function resizePixelPerfect() {
  const maxWidth = window.innerWidth
  const maxHeight = window.innerHeight
  
  // Find largest integer scale that fits
  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'
}

En contrepartie, votre jeu ne remplira pas exactement l’écran. Sur un écran de 1920x1080 avec un jeu en 320x180, vous obtenez une mise à l’échelle 5x (1600x900), avec des bandes noires sur les bords. Certains joueurs préfèrent ce résultat ; d’autres souhaitent que le jeu remplisse tout l’écran, même s’il devient légèrement flou.

Mise à l’échelle d’un canvas WebGL

Avec WebGL, vous devez mettre à jour la zone d’affichage en plus de la taille du canvas :

js
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 // Resized, you may need to update projection matrices
  }
  return false
}

Appelez cette fonction au début de votre boucle de rendu, et pas uniquement lors des événements de redimensionnement. Certains navigateurs modifient le DPR lorsqu’une fenêtre passe d’un moniteur à un autre, et vous devez pouvoir détecter ce changement.

Temporisation des événements de redimensionnement

Les événements de redimensionnement se déclenchent très rapidement lorsque l’utilisateur fait glisser le bord de la fenêtre. Si votre fonction de redimensionnement est coûteuse, l’affichage manquera de fluidité.

js
let resizeTimeout
window.addEventListener('resize', () => {
  clearTimeout(resizeTimeout)
  resizeTimeout = setTimeout(resize, 100)
})

Le délai de 100 ms signifie que le redimensionnement ne s’effectue qu’une fois que l’utilisateur a cessé de faire glisser la fenêtre. Pour une gestion plus fluide pendant le déplacement, utilisez plutôt ResizeObserver :

js
const observer = new ResizeObserver(entries => {
  resize()
})
observer.observe(document.body)

ResizeObserver se déclenche moins fréquemment et bénéficie d’une meilleure optimisation par le navigateur.

Plein écran

L’API Fullscreen permet d’occuper la totalité de l’écran :

js
async function toggleFullscreen() {
  if (!document.fullscreenElement) {
    await canvas.requestFullscreen()
  } else {
    await document.exitFullscreen()
  }
}

document.addEventListener('fullscreenchange', resize)

Vous devez appeler requestFullscreen en réponse à une action de l’utilisateur, comme un clic. Sinon, les navigateurs bloquent la requête. Pensez également à redimensionner le canvas lors de l’entrée et de la sortie du mode plein écran, car l’espace disponible change.

Une réserve concernant les plateformes : à la mi-2026, l’API Fullscreen ne permet toujours pas d’afficher un élément en plein écran dans Safari sur iPhone. requestFullscreen fonctionne dans les navigateurs de bureau et sur iPadOS, mais l’iPhone ne le prend pas en charge pour des éléments arbitraires comme un canvas — seul le plein écran vidéo natif fonctionne. Vérifiez la disponibilité de cette fonctionnalité avec if (canvas.requestFullscreen) et prévoyez une solution de repli élégante, par exemple en dimensionnant le canvas pour remplir la zone d’affichage visible ou en invitant les utilisateurs à ajouter le jeu à leur écran d’accueil sous forme de PWA.

Assemblage de l’ensemble

Voici une classe qui gère tous les cas courants :

js
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 }
}

Utilisez-la comme ceci :

js
const responsive = new ResponsiveCanvas(document.getElementById('game'), {
  width: 320,
  height: 180,
  pixelArt: true,
  letterbox: true
})

// In your game code, use responsive.width and responsive.height

Pièges courants

Voici quelques points qui posent souvent problème :

Le canvas s’efface lors du redimensionnement. Lorsque vous définissez canvas.width ou canvas.height, le canvas s’efface. Si vous ne redessinez pas l’image à chaque frame, vous devez le faire après le redimensionnement.

Le DPR peut changer. Si l’utilisateur fait passer la fenêtre du navigateur d’un écran Retina à un écran non-Retina, le DPR change. Vérifiez sa valeur dans votre boucle de rendu, et pas uniquement lors du redimensionnement.

Particularités de la zone d’affichage dans Safari sur iOS. La hauteur de la zone d’affichage change lorsque la barre d’adresse apparaît ou disparaît. Utilisez window.visualViewport pour obtenir des dimensions plus précises sur mobile.

L’état du contexte est réinitialisé. La modification des dimensions du canvas réinitialise l’état du contexte, notamment les transformations, les styles de remplissage et imageSmoothingEnabled. Réappliquez-les après le redimensionnement.

Ressources complémentaires

Boucle de jeu Canvas 2D présente les principes fondamentaux des boucles de mise à jour et de rendu.

Rendu du pixel art approfondit la création de graphismes en pixels parfaitement nets.

Jeux web adaptés aux mobiles aborde les entrées tactiles et les problèmes liés à la zone d’affichage.

Principes fondamentaux de WebGL explique la configuration de la zone d’affichage et du canvas pour les contextes WebGL.

Publier un jeu web qui se charge rapidement explique comment optimiser la taille du canvas pour améliorer les performances.