← Así se hace un juego: Snake de Daily Challenge · Lección 3/16
Una pantalla de 160 × 192
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.
$ 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.
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.