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.
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.widthycanvas.height. - Determina cuántos píxeles renderizamos.
- Influye directamente en nitidez y rendimiento.
Por ejemplo, esto hace que el canvas se vea grande:
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:
<div id="game-shell">
<canvas id="game"></canvas>
</div>
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.
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
);
}
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:
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:
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:
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:
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:
7. Calcular un escalado FIT manualmente
Supongamos que nuestro juego lógico mide:
1280 × 720
y el contenedor disponible tiene cualquier otro tamaño.
Para hacer FIT sin deformar:
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
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:
const x = event.clientX;
const y = event.clientY;
sean coordenadas válidas dentro del mundo.
Primero calculamos la posición relativa al canvas:
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:
const offsetX =
(viewWidth - displayWidth) / 2;
const offsetY =
(viewHeight - displayHeight) / 2;
const gameX =
(pointerX - offsetX) / scale;
const gameY =
(pointerY - offsetY) / scale;
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:
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:
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:
scale: {
mode: Phaser.Scale.RESIZE,
parent: "game-container"
}
Aquí la Scene debe reaccionar al nuevo tamaño.
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
);
}
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:
pauseButton.setPosition(
1180,
40
);
Si el ancho puede cambiar, utiliza el ancho actual:
pauseButton.setPosition(
width - 40,
40
);
Podemos pensar el HUD como anchors:
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:
#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:
<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
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
-
Prueba 16:9 Por ejemplo 1920×1080 y 1280×720.
-
Prueba ultrawide Verifica que no revele partes del mapa que deberían estar ocultas.
-
Prueba móvil landscape Comprueba HUD, touch targets y barras del navegador.
-
Prueba portrait Aunque tu juego no lo soporte, la experiencia debe explicar qué hacer.
-
Prueba DPR alto Busca blur, GPU demasiado cargada y consumo de batería.
-
Prueba input Click y touch deben coincidir con lo que el jugador ve.
-
Prueba resize durante gameplay Gira el dispositivo o cambia el tamaño de la ventana.
-
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:
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
Referencias
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