Ir al contenido
Ayotl

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

Cómo se divide el código

✕
1/8

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.js y music.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 un index.js que los registra.
  • main.js y shell.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.

leccion.js

$ 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.js y music.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 un index.js que los registra.
  • main.js y shell.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, title y hint: 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.param son los muros extra).
  • update(mask): avanza un tick con los botones de ese tick.
  • render(r): pinta el estado con el renderer r.
  • score, over y events: 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.