JUEGS EN HTML5

Canvas responsive en juegos HTML5: guía práctica con Phaser 4

Hacer un juego responsive no significa simplemente poner width: 100% al canvas. En un juego HTML5 hay que coordinar al menos cinco cosas: el tamaño visual del elemento, la resolución interna del bitmap, la relación de aspecto, las coordenadas del input y la posición del HUD. En esta guía vamos a resolver cada una con ejemplos prácticos y después veremos cómo aplicar la misma idea en Phaser 4.

Canvas API · ResizeObserver · DPR · Phaser 4
Canvas responsive en juegos HTML5 para móvil tablet y escritorio
Un juego responsive debe mantener gameplay, nitidez e interfaz utilizables en pantallas con proporciones muy diferentes.
R
Responsive no es igual a estirar

El objetivo no es deformar un juego de 16:9 hasta llenar cualquier pantalla. Primero decides qué permanece fijo, qué puede crecer, qué puede recortarse y qué elementos del HUD deben recolocarse.

El problema: un canvas tiene dos tamaños

Este es el concepto más importante de todo el tutorial. Un elemento <canvas> tiene:

Tamaño CSS

  • Es el tamaño que ves en la página.
  • Se mide en CSS pixels.
  • Puede ser width: 100%.
  • No cambia automáticamente la resolución interna.

Tamaño interno

  • Es el bitmap real del canvas.
  • Depende de canvas.width y canvas.height.
  • Determina cuántos píxeles renderizamos.
  • Influye directamente en nitidez y rendimiento.

Por ejemplo, esto hace que el canvas se vea grande:

CSS Solo tamaño visual
canvas {
  width: 100%;
  height: auto;
}

Pero si internamente el canvas sigue teniendo una resolución muy pequeña, el navegador deberá escalar ese bitmap y el resultado puede verse borroso.

1. Empieza con un contenedor, no con el canvas

Una estructura bastante segura para un juego embebido en una web es controlar primero el espacio disponible:

HTML Contenedor del juego
<div id="game-shell">
  <canvas id="game"></canvas>
</div>
CSS Wrapper responsive 16:9
html,
body {
  margin: 0;
  background: #071522;
}

#game-shell {
  width: min(100%, 1280px);
  aspect-ratio: 16 / 9;

  margin-inline: auto;

  position: relative;
  overflow: hidden;

  background: #071522;
}

#game {
  display: block;

  width: 100%;
  height: 100%;
}

Con aspect-ratio: 16 / 9, el espacio visible conserva la proporción del juego. En un blog o portal, esta estrategia es especialmente cómoda porque el canvas no intenta ocupar una altura arbitraria.

2. Haz que el bitmap interno siga al tamaño visible

Ahora vamos a resolver la nitidez. Leeremos el tamaño CSS real del canvas y crearemos un bitmap interno proporcional a la densidad de píxeles del dispositivo.

JavaScript Canvas HiDPI
const canvas = document.querySelector("#game");
const ctx = canvas.getContext("2d");

function resizeCanvas() {
  const rect = canvas.getBoundingClientRect();

  const dpr = Math.min(
    window.devicePixelRatio || 1,
    2
  );

  const width = Math.round(rect.width * dpr);
  const height = Math.round(rect.height * dpr);

  if (
    canvas.width !== width ||
    canvas.height !== height
  ) {
    canvas.width = width;
    canvas.height = height;
  }

  // Trabajaremos en CSS pixels.
  ctx.setTransform(
    dpr, 0,
    0, dpr,
    0, 0
  );
}
¿Por qué limitar DPR a 2?

Un teléfono puede tener un devicePixelRatio de 3 o más. Eso no significa que siempre sea buena idea renderizar cuatro, nueve o más veces la cantidad de píxeles. En juegos, limitar el DPR es una decisión útil para equilibrar nitidez y fill-rate de GPU.

3. Usa ResizeObserver en lugar de depender solo de window.resize

Un canvas puede cambiar de tamaño sin que cambie la ventana: por ejemplo, si se abre un sidebar, cambia el layout del blog o el juego está dentro de un panel responsive.

Para reaccionar al tamaño real del contenedor:

JavaScript ResizeObserver
const shell = document.querySelector(
  "#game-shell"
);

const resizeObserver =
  new ResizeObserver(() => {
    resizeCanvas();
  });

resizeObserver.observe(shell);

resizeCanvas();

Así no dependemos de adivinar por qué cambió el layout. Siempre que el contenedor cambie de tamaño, recalculamos el canvas.

4. No dibujes usando directamente canvas.width

Después de aplicar DPR, canvas.width ya no representa el tamaño visual. Puede ser el doble o triple del ancho CSS.

En su lugar utiliza el rect visible:

JavaScript Tamaño lógico visible
const rect =
  canvas.getBoundingClientRect();

const viewWidth = rect.width;
const viewHeight = rect.height;

Ahora podemos dibujar utilizando CSS pixels independientemente del DPR.

5. Ejemplo completo: escena responsive sin motor

Este ejemplo dibuja un pequeño escenario y recoloca los elementos usando el tamaño actual del canvas:

JavaScript Render responsive
function render() {
  const rect =
    canvas.getBoundingClientRect();

  const width = rect.width;
  const height = rect.height;

  ctx.clearRect(
    0,
    0,
    width,
    height
  );

  // Fondo
  ctx.fillStyle = "#071522";
  ctx.fillRect(
    0,
    0,
    width,
    height
  );

  // Suelo proporcional a la pantalla
  const groundY = height * 0.78;

  ctx.fillStyle = "#173d2c";
  ctx.fillRect(
    0,
    groundY,
    width,
    height - groundY
  );

  // Jugador
  const playerSize =
    Math.max(
      32,
      Math.min(64, width * 0.06)
    );

  const playerX = width * 0.18;
  const playerY =
    groundY - playerSize;

  ctx.fillStyle = "#22d3ee";

  ctx.fillRect(
    playerX,
    playerY,
    playerSize,
    playerSize
  );

  // Enemigo
  const enemySize =
    playerSize * 0.8;

  ctx.fillStyle = "#ff8a1f";

  ctx.fillRect(
    width * 0.72,
    groundY - enemySize,
    enemySize,
    enemySize
  );

  requestAnimationFrame(render);
}

resizeCanvas();
render();

Fíjate en que no existe un x = 900 que dependa de una resolución concreta. Las posiciones principales se calculan como porcentajes del espacio visible.

6. Pero un juego real necesita coordenadas lógicas

Calcular todo con porcentajes funciona para interfaces simples, pero puede convertirse en un problema para niveles, físicas y mapas.

En muchos juegos es mejor mantener una resolución lógica fija:

JavaScript Resolución lógica
const GAME_WIDTH = 1280;
const GAME_HEIGHT = 720;

Todo el gameplay vive en ese mundo de 1280 × 720 y el canvas visible solo decide cómo mostrarlo.

Esto produce una separación muy útil:

Game coordinates Posiciones del jugador, enemigos, colisiones, cámara y física.
Display coordinates Tamaño real del canvas dentro del navegador.
UI coordinates HUD, botones táctiles, safe areas y overlays adaptativos.

7. Calcular un escalado FIT manualmente

Supongamos que nuestro juego lógico mide:

Juego Base size
1280 × 720

y el contenedor disponible tiene cualquier otro tamaño.

Para hacer FIT sin deformar:

JavaScript Scale factor
const scale = Math.min(
  viewWidth / GAME_WIDTH,
  viewHeight / GAME_HEIGHT
);

const displayWidth =
  GAME_WIDTH * scale;

const displayHeight =
  GAME_HEIGHT * scale;

El juego completo queda visible. Si la relación de aspecto del dispositivo es distinta, aparecerán bandas libres en un eje.

FIT vs ENVELOP vs RESIZE

Comparación de FIT ENVELOP y RESIZE en un canvas responsive
FIT conserva todo el juego, ENVELOP llena el área recortando extremos y RESIZE cambia realmente el tamaño lógico disponible.

FIT

  • Mantiene relación de aspecto.
  • Todo el juego permanece visible.
  • Puede dejar letterbox.
  • Excelente para gameplay con composición fija.

ENVELOP

  • Mantiene relación de aspecto.
  • Llena completamente la pantalla.
  • Puede ocultar los bordes del mundo.
  • Útil si el gameplay tolera crop.

RESIZE

  • Cambia el tamaño real disponible.
  • No obliga a mantener una proporción fija.
  • Ideal para interfaces adaptativas.
  • Necesita relayout del mundo y HUD.

EXPAND

  • Combina ideas de RESIZE y FIT.
  • Utiliza todo el espacio del parent.
  • Puede ser útil para layouts híbridos.
  • Conviene probarlo en dispositivos reales.

8. Input responsive: transformar pointer a coordenadas del juego

Si el canvas se está escalando, no podemos asumir que:

Incorrecto Pointer
const x = event.clientX;
const y = event.clientY;

sean coordenadas válidas dentro del mundo.

Primero calculamos la posición relativa al canvas:

JavaScript Pointer local
function getPointerPosition(event) {
  const rect =
    canvas.getBoundingClientRect();

  const x =
    event.clientX - rect.left;

  const y =
    event.clientY - rect.top;

  return { x, y };
}

Si además utilizas un mundo lógico 1280 × 720 escalado con FIT, debes compensar el factor de escala y el offset del letterbox:

JavaScript Display → game coordinates
const offsetX =
  (viewWidth - displayWidth) / 2;

const offsetY =
  (viewHeight - displayHeight) / 2;

const gameX =
  (pointerX - offsetX) / scale;

const gameY =
  (pointerY - offsetY) / scale;
!
Este error rompe muchos juegos móviles

Visualmente el botón parece estar en un lugar, pero su hit area queda desplazada porque el input sigue utilizando coordenadas de pantalla sin convertirlas al sistema lógico.

9. Responsive en Phaser 4: el caso sencillo con FIT

Phaser ya incluye un Scale Manager que resuelve gran parte del trabajo anterior.

Si tu juego está diseñado sobre una resolución lógica fija de 1280 × 720:

Phaser 4 Scale.FIT
import * as Phaser from "phaser";

const config:
  Phaser.Types.Core.GameConfig = {

  type: Phaser.AUTO,

  width: 1280,
  height: 720,

  parent: "game-container",

  scale: {
    mode: Phaser.Scale.FIT,

    autoCenter:
      Phaser.Scale.CENTER_BOTH,

    width: 1280,
    height: 720
  },

  scene: [
    MainScene
  ]
};

new Phaser.Game(config);

FIT mantiene la relación de aspecto y escala el canvas para que entre dentro del parent. Es una muy buena opción para:

  • plataformas;
  • puzzle;
  • arcade;
  • juegos con niveles diseñados sobre una composición fija;
  • juegos donde no quieres mostrar más mundo en dispositivos ultrawide.

10. CSS del parent para Phaser

El Scale Manager necesita un parent con un tamaño real y predecible:

CSS Phaser parent
html,
body {
  margin: 0;

  width: 100%;
  min-height: 100%;

  background: #071522;
}

#game-container {
  width: 100%;
  height: 100dvh;

  overflow: hidden;

  display: flex;
  align-items: center;
  justify-content: center;
}

#game-container canvas {
  display: block;
}

Utilizar 100dvh evita parte de los problemas históricos de 100vh en navegadores móviles donde la barra del navegador entra y sale.

11. ¿Cuándo utilizar RESIZE en Phaser?

Con RESIZE Phaser ajusta el canvas al espacio disponible en el parent:

Phaser 4 Scale.RESIZE
scale: {
  mode: Phaser.Scale.RESIZE,

  parent: "game-container"
}

Aquí la Scene debe reaccionar al nuevo tamaño.

Phaser 4 Resize event
create() {
  this.scale.on(
    "resize",
    this.onResize,
    this
  );

  this.onResize(
    this.scale.gameSize
  );
}

onResize(gameSize) {
  const width =
    gameSize.width;

  const height =
    gameSize.height;

  this.scoreText.setPosition(
    24,
    24
  );

  this.pauseButton.setPosition(
    width - 40,
    40
  );

  this.player.setPosition(
    width * 0.25,
    height * 0.72
  );
}
RESIZE no es gratis

Un canvas de mayor resolución obliga a la GPU a renderizar más píxeles. En un monitor 4K o un teléfono con DPR alto puedes multiplicar enormemente el fill-rate. Añade límites y prueba rendimiento real.

12. HUD responsive: usa anchors, no posiciones mágicas

Un error clásico:

Evita Hardcoded HUD
pauseButton.setPosition(
  1180,
  40
);

Si el ancho puede cambiar, utiliza el ancho actual:

Mejor Anchor al borde
pauseButton.setPosition(
  width - 40,
  40
);

Podemos pensar el HUD como anchors:

Top left Score, monedas, información secundaria.
Top right Pausa, settings, botones de sistema.
Bottom zones Controles táctiles y acciones principales.

13. No escales la UI de móvil como si fuera una miniatura

Imagina que un botón mide 44 px en desktop. Si todo el juego se reduce mucho en un teléfono, ese botón puede terminar visualmente en 25 px y convertirse en un mal target táctil.

Por eso muchos juegos separan:

  • world scale: cámara, escenario, personajes;
  • UI scale: HUD y botones;
  • touch layout: zonas específicas para móvil.

Esto significa que responsive no siempre consiste en escalar exactamente todo con el mismo factor.

14. Portrait y landscape

También debes decidir si tu juego:

Solo landscape

  • Más sencillo de diseñar.
  • Puedes mostrar un aviso al rotar.
  • Útil para platformers y juegos de acción.

Adaptativo

  • Requiere dos composiciones reales.
  • HUD debe relayout.
  • La cámara puede necesitar más o menos mundo visible.

No intentes convertir automáticamente un diseño horizontal 16:9 en uno vertical 9:16 si el gameplay no fue pensado para ello.

15. Safe areas en móviles

Si tu juego utiliza toda la pantalla, evita colocar controles esenciales pegados a los bordes. En dispositivos con notch o barras del sistema puedes utilizar las CSS environment variables:

CSS Safe area
#game-ui {
  padding-top:
    env(safe-area-inset-top);

  padding-right:
    env(safe-area-inset-right);

  padding-bottom:
    env(safe-area-inset-bottom);

  padding-left:
    env(safe-area-inset-left);
}

16. Meta viewport

En una página móvil, asegúrate de tener:

HTML <head>
<meta
  name="viewport"
  content="width=device-width,
           initial-scale=1,
           viewport-fit=cover">

Sin una configuración correcta del viewport, el navegador móvil puede simular un ancho de layout distinto y romper todos tus cálculos.

17. Ejemplo Phaser completo con HUD responsive

TypeScript MainScene.ts
import * as Phaser from "phaser";

export class MainScene
  extends Phaser.Scene {

  private scoreText!: Phaser.GameObjects.Text;
  private pauseButton!: Phaser.GameObjects.Text;

  constructor() {
    super("MainScene");
  }

  create() {
    this.cameras.main
      .setBackgroundColor("#071522");

    this.scoreText =
      this.add.text(
        24,
        24,
        "SCORE: 0",
        {
          fontFamily: "Arial",
          fontSize: "24px",
          color: "#ffffff"
        }
      );

    this.pauseButton =
      this.add.text(
        0,
        0,
        "II",
        {
          fontFamily: "Arial",
          fontSize: "28px",
          color: "#ffffff"
        }
      )
      .setOrigin(0.5)
      .setInteractive();

    this.scale.on(
      "resize",
      this.layout,
      this
    );

    this.layout(
      this.scale.gameSize
    );
  }

  private layout(
    gameSize:
      Phaser.Structs.Size
  ) {
    const width =
      gameSize.width;

    const height =
      gameSize.height;

    this.scoreText
      .setPosition(
        24,
        24
      );

    this.pauseButton
      .setPosition(
        width - 40,
        40
      );

    // Aquí puedes reposicionar
    // controles o elementos del mundo.

    console.log(
      `viewport: ${width} x ${height}`
    );
  }
}

18. Checklist responsive antes de publicar

  1. Prueba 16:9 Por ejemplo 1920×1080 y 1280×720.
  2. Prueba ultrawide Verifica que no revele partes del mapa que deberían estar ocultas.
  3. Prueba móvil landscape Comprueba HUD, touch targets y barras del navegador.
  4. Prueba portrait Aunque tu juego no lo soporte, la experiencia debe explicar qué hacer.
  5. Prueba DPR alto Busca blur, GPU demasiado cargada y consumo de batería.
  6. Prueba input Click y touch deben coincidir con lo que el jugador ve.
  7. Prueba resize durante gameplay Gira el dispositivo o cambia el tamaño de la ventana.
  8. Mide rendimiento No asumas que un canvas más grande es gratis.

Qué estrategia recomiendo

Para la mayoría de juegos 2D del blog empezaría con:

Recomendación Gameplay con resolución fija
scale: {
  mode: Phaser.Scale.FIT,
  autoCenter:
    Phaser.Scale.CENTER_BOTH,

  width: 1280,
  height: 720
}

y haría que el HUD tenga reglas específicas para móvil.

Elegiría RESIZE cuando el diseño realmente deba mostrar más o menos contenido dependiendo del viewport, por ejemplo:

  • estrategia;
  • editores;
  • simuladores;
  • interfaces tipo dashboard;
  • juegos donde revelar más mundo no rompe el gameplay.

Relacionados

Phaser 4 con Vite y TypeScript desde cero Cómo migrar de Phaser 3 a Phaser 4

Referencias

Phaser Scale Manager MDN ResizeObserver MDN devicePixelRatio MDN Optimizing Canvas

Un canvas responsive de calidad mantiene tres cosas alineadas: lo que el jugador ve, las coordenadas donde vive el juego y el input que recibe. Cuando esas tres capas utilizan la misma estrategia de escalado, el mismo proyecto puede funcionar correctamente desde un teléfono hasta una pantalla de escritorio.

No hay comentarios:

Publicar un comentario