Ir al contenido
Ayotl

← Así se hace un juego: Snake de Daily Challenge · Lección 3/16

Una pantalla de 160 × 192

✕
1/7

Píxeles virtuales

Todos los juegos dibujan en una pantalla de 160 × 192 píxeles, la proporción de la Atari 2600. No importa si tu teléfono tiene 400 o 1400 píxeles de ancho: el juego sólo conoce esos 160.

En core/renderer.js el canvas se crea de ese tamaño exacto y se apaga el suavizado:

canvas.width = VW; canvas.height = VH; this.ctx.imageSmoothingEnabled = false;

Y pintar es casi siempre una sola cosa: un rectángulo de color, rect(x, y, w, h, color). El programa imita ese renderer con letras: cada carácter vale un bloque de 10 × 10 píxeles.

leccion.js

$ node leccion.js

Pulsa CORRER (o Ctrl+Enter).

Qué mirar

Salen 16 columnas por 20 renglones de letras: 160 × 192 entre 10. El marcador mide 16 píxeles, y por eso el tablero empieza en el píxel 16, a media letra: la serpiente (en el píxel 96, la salida real de snake_01) y la fruta ocupan dos renglones cada una. La pantalla de verdad no tiene ese problema; el dibujo con letras es más burdo. Mueve la fruta cambiando el 120 y el 56.

Tutorial de Canvas (MDN) ↗

Toda la lección en una página

Píxeles virtuales

Todos los juegos dibujan en una pantalla de 160 × 192 píxeles, la proporción de la Atari 2600. No importa si tu teléfono tiene 400 o 1400 píxeles de ancho: el juego sólo conoce esos 160.

En core/renderer.js el canvas se crea de ese tamaño exacto y se apaga el suavizado:

canvas.width = VW; canvas.height = VH; this.ctx.imageSmoothingEnabled = false;

Y pintar es casi siempre una sola cosa: un rectángulo de color, rect(x, y, w, h, color). El programa imita ese renderer con letras: cada carácter vale un bloque de 10 × 10 píxeles.

const VW = 160, VH = 192, BLOQUE = 10;

// Un renderer de mentira: pinta letras en vez de píxeles
class Renderer {
  constructor() {
    this.filas = Array.from({ length: Math.ceil(VH / BLOQUE) }, () => Array(VW / BLOQUE).fill(" "));
  }
  rect(x, y, w, h, color) {
    for (let fy = Math.floor(y / BLOQUE); fy < Math.ceil((y + h) / BLOQUE); fy++)
      for (let fx = Math.floor(x / BLOQUE); fx < Math.ceil((x + w) / BLOQUE); fx++)
        if (this.filas[fy] && fx < this.filas[fy].length) this.filas[fy][fx] = color;
  }
  clear(color) { this.rect(0, 0, VW, VH, color); }
  mostrar() { for (const f of this.filas) console.log("|" + f.join("") + "|"); }
}

const r = new Renderer();
r.clear(".");
r.rect(0, 0, 160, 16, "=");      // el marcador de arriba
r.rect(70, 96, 30, 10, "s");     // tres casillas de serpiente
r.rect(120, 56, 10, 10, "f");    // una fruta
r.mostrar();

Salen 16 columnas por 20 renglones de letras: 160 × 192 entre 10. El marcador mide 16 píxeles, y por eso el tablero empieza en el píxel 16, a media letra: la serpiente (en el píxel 96, la salida real de snake_01) y la fruta ocupan dos renglones cada una. La pantalla de verdad no tiene ese problema; el dibujo con letras es más burdo. Mueve la fruta cambiando el 120 y el 56.

Agrandar sin que se vea borroso

160 píxeles en un teléfono serían una estampita. Así que el canvas se agranda con CSS, pero sólo por números enteros: ×2, ×3, ×4. Si fuera ×2.5, unos píxeles saldrían de 2 y otros de 3, y las letras de 3 × 5 se verían chuecas.

Esto es fit(), tal cual está en el renderer:

const s = Math.max(1, Math.floor(Math.min(availW / VW, availH / VH)));

Busca la escala que cabe a lo ancho y a lo alto, se queda con la menor y la redondea hacia abajo. Y el CSS del canvas lleva image-rendering: pixelated, para que el navegador agrande cada píxel como un cuadro duro en vez de difuminarlo.

const VW = 160, VH = 192;
function fit(availW, availH) {
  return Math.max(1, Math.floor(Math.min(availW / VW, availH / VH)));
}

const pantallas = [
  ["teléfono chico", 360, 560],
  ["teléfono grande", 412, 760],
  ["tableta", 820, 1000],
  ["laptop", 1366, 680],
];
for (const [nombre, w, h] of pantallas) {
  const s = fit(w, h);
  console.log(nombre.padEnd(16), "x" + s, "->", VW * s + "x" + VH * s, "px");
}

En la laptop manda la altura: 680 / 192 da 3.5, que baja a ×3. En el teléfono chico manda el ancho: 360 / 160 = 2.25, ×2. Sobra un poco de espacio a los lados, y es el precio de los píxeles parejos.

Dónde cae cada casilla

Snake no piensa en píxeles sino en casillas. Arriba de snake.js están sus medidas:

export const CELL = 10, COLS = 16, ROWS = 17, HUD = 16;

  • 16 columnas de 10 píxeles = 160: el ancho exacto de la pantalla.
  • 17 renglones de 10 = 170, más los 16 del marcador (HUD) = 186, que caben en 192.

Para pintar la casilla (x, y) se convierte a píxeles: x * CELL a lo ancho y HUD + y * CELL a lo alto, porque el tablero empieza debajo del marcador. Así pinta el cuerpo render():

r.rect(b.x * CELL, HUD + b.y * CELL, CELL, CELL, color)

Un comentario del archivo cuenta por qué 10 píxeles: «se ve mejor en teléfono que 20x22 de 8px».

const CELL = 10, COLS = 16, ROWS = 17, HUD = 16;

function aPixel(casilla) {
  return { px: casilla.x * CELL, py: HUD + casilla.y * CELL };
}

console.log("esquina de arriba:", aPixel({ x: 0, y: 0 }));
console.log("salida de snake_01:", aPixel({ x: 7, y: 8 }));
console.log("esquina de abajo:", aPixel({ x: COLS - 1, y: ROWS - 1 }));
console.log("alto usado:", HUD + ROWS * CELL, "de 192");

La última casilla empieza en el píxel 176 y termina en el 185: sobran 6 píxeles abajo. La lógica del juego nunca habla de píxeles; sólo render hace la cuenta.