← Así se hace un juego: Snake de Daily Challenge · Lección 2/16
Cómo se divide el código
Las carpetas
Todo el arcade vive en public/arcade/, dividido por responsabilidad:
core/: lo que comparten los 36 juegos.renderer.js(pintar),input.js(botones),audio.jsymusic.js(sonido),rng.js(azar con semilla),map.js(leer mapas),buttons.js,palette.js(colores).games/: un archivo por juego (snake.js,tetris.js…) y unindex.jsque los registra.main.jsyshell.js: las pantallas, el menú y el bucle que llama al juego 30 veces por segundo.net.js: la conversación con el servidor.verify.js: volver a jugar una partida a partir de sus botones.
Y aparte, public/content/: los mapas y la música, en archivos de texto.
$ node leccion.js
Pulsa CORRER (o Ctrl+Enter).
Qué mirar
La línea que divide el proyecto: de un lado lo que necesita el navegador (pantalla, teclado, sonido); del otro, lo que es puro cálculo y por eso también corre en Node. El juego queda del lado puro.
Toda la lección en una página
Las carpetas
Todo el arcade vive en public/arcade/, dividido por responsabilidad:
core/: lo que comparten los 36 juegos.renderer.js(pintar),input.js(botones),audio.jsymusic.js(sonido),rng.js(azar con semilla),map.js(leer mapas),buttons.js,palette.js(colores).games/: un archivo por juego (snake.js,tetris.js…) y unindex.jsque los registra.main.jsyshell.js: las pantallas, el menú y el bucle que llama al juego 30 veces por segundo.net.js: la conversación con el servidor.verify.js: volver a jugar una partida a partir de sus botones.
Y aparte, public/content/: los mapas y la música, en archivos de texto.
const archivos = {
"core/renderer.js": { dom: true, hace: "pintar en el canvas" },
"core/input.js": { dom: true, hace: "teclado, D-pad y swipes" },
"core/audio.js": { dom: true, hace: "el chip de sonido" },
"core/rng.js": { dom: false, hace: "azar con semilla" },
"core/map.js": { dom: false, hace: "mapas de texto a cuadrícula" },
"games/snake.js": { dom: false, hace: "las reglas de Snake" },
"verify.js": { dom: false, hace: "re-simular una partida" },
"main.js": { dom: true, hace: "pantallas y bucle" },
};
const puros = Object.keys(archivos).filter(f => !archivos[f].dom);
console.log("pueden correr en el servidor:");
for (const f of puros) console.log(" " + f, "->", archivos[f].hace);
La línea que divide el proyecto: de un lado lo que necesita el navegador (pantalla, teclado, sonido); del otro, lo que es puro cálculo y por eso también corre en Node. El juego queda del lado puro.
El contrato de un juego
Los 36 juegos cumplen la misma forma, y por eso main.js puede correr cualquiera sin saber cuál es:
static id,titleyhint: el nombre y la ayuda de controles (en español, inglés y japonés).constructor(seed, mapText, opts): arma la partida con la semilla del día, el texto del mapa y opciones (en Snake,opts.paramson los muros extra).update(mask): avanza un tick con los botones de ese tick.render(r): pinta el estado con el rendererr.score,overyevents: el puntaje, si ya terminó y lo que pasó en este tick ('eat','die').
El programa arma un juego de juguete con ese contrato y un «main» que no sabe nada de él.
class Monedas {
static id = "monedas";
static title = "MONEDAS";
constructor(seed, mapText, opts = {}) {
this.meta = opts.param ?? 3;
this.score = 0; this.over = false; this.events = [];
}
update(mask) {
this.events.length = 0;
if (this.over) return;
if (mask & 16) { this.score += 5; this.events.push("eat"); }
if (this.score >= this.meta * 5) { this.over = true; this.events.push("die"); }
}
render(r) { r.text("SCORE " + this.score); }
}
// El "main": no sabe qué juego es, sólo usa el contrato
function correr(Juego, log) {
const g = new Juego(1234, "", { param: 2 });
const r = { text: s => console.log(" pantalla:", s) };
for (const mask of log) {
g.update(mask);
for (const e of g.events) console.log(" sonido:", e);
if (g.over) break;
}
g.render(r);
return g.score;
}
console.log(Monedas.title, "->", correr(Monedas, [0, 16, 0, 16, 16]));
Con param: 2 la partida acaba al segundo A: suenan eat, eat y die, y el último 16 del log ya no se juega. Cambia param a 3 y vuelve a correrlo.
La lógica no toca la pantalla
El encabezado de snake.js lo dice así: «Snake determinista y sin dependencias de DOM: update() solo recibe la bitmask del tick, render() solo recibe el renderer».
update no pinta, no suena y no lee el teclado. Si quiere que algo suene, lo anota en events, y es main.js quien decide qué hacer:
for (const e of game.events) chip.sfx(e);
Gracias a eso, la misma partida puede correr en el teléfono (con sonido), en el servidor (sin nada), como fantasma que ves jugar o en un video generado por una herramienta. Ninguno de esos sitios necesita una pantalla de verdad.
function verifyRun(Game, log) {
const g = new Game();
let ticks = 0;
for (const mask of log) {
if (g.over) break;
g.update(mask);
ticks++;
}
return { score: g.score, over: g.over, ticks };
}
class Contador {
constructor() { this.score = 0; this.over = false; this.events = []; }
update(mask) {
this.events.length = 0;
if (mask & 16) { this.score++; this.events.push("eat"); }
if (this.score === 3) this.over = true;
}
}
// Igual que verify.js: sin sonido, sin pantalla, sin teclado
console.log(verifyRun(Contador, [16, 0, 16, 16, 16, 16]));
Ésta es casi literal la función verifyRun de verify.js. Se detiene en cuanto over es verdadero (4 ticks), aunque el log traiga más: lo que alguien teclee después de perder no cuenta.