Ir al contenido
Ayotl

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

30 ticks por segundo, siempre

✕
1/8

El problema de los cuadros

El navegador avisa cada vez que la pantalla está lista para un cuadro nuevo con requestAnimationFrame. Pero no todas las pantallas van igual: muchas a 60 cuadros por segundo, otras a 90 o 120, y un teléfono ocupado se salta cuadros.

Si la serpiente avanzara una casilla por cuadro, en un teléfono de 120 Hz iría al doble de rápido que en uno de 60. Y peor: el servidor, que no tiene pantalla, no sabría cuántos cuadros hubo.

La solución de Daily Challenge: separar dibujar de simular. Se dibuja cuando la pantalla quiera; se simula siempre a 30 ticks por segundo.

leccion.js

$ node leccion.js

Pulsa CORRER (o Ctrl+Enter).

Qué mirar

Por cuadro: 60 contra 120 pasos en el mismo segundo. Por tick: 30 en las dos. El juego va igual de rápido; la pantalla de 120 Hz sólo dibuja más seguido.

requestAnimationFrame (MDN) ↗

Toda la lección en una página

El problema de los cuadros

El navegador avisa cada vez que la pantalla está lista para un cuadro nuevo con requestAnimationFrame. Pero no todas las pantallas van igual: muchas a 60 cuadros por segundo, otras a 90 o 120, y un teléfono ocupado se salta cuadros.

Si la serpiente avanzara una casilla por cuadro, en un teléfono de 120 Hz iría al doble de rápido que en uno de 60. Y peor: el servidor, que no tiene pantalla, no sabría cuántos cuadros hubo.

La solución de Daily Challenge: separar dibujar de simular. Se dibuja cuando la pantalla quiera; se simula siempre a 30 ticks por segundo.

// Un segundo de juego en dos pantallas distintas
for (const hz of [60, 120]) {
  const frame = 1000 / hz;
  let porCuadro = 0, porTick = 0, acc = 0;
  for (let t = 0; t < 1000 - 0.001; t += frame) {
    porCuadro++;                      // mal: un paso por cuadro
    acc += frame;                     // bien: pasos fijos de 1000/30 ms
    while (acc >= 1000 / 30) { porTick++; acc -= 1000 / 30; }
  }
  console.log(hz + " Hz -> por cuadro:", porCuadro, "| por tick:", porTick);
}

Por cuadro: 60 contra 120 pasos en el mismo segundo. Por tick: 30 en las dos. El juego va igual de rápido; la pantalla de 120 Hz sólo dibuja más seguido.

El acumulador

Así está el bucle en shell.js (le quité el manejo de errores):

const dt = Math.min(now - last, 250); last = now; acc += dt; while (acc >= TICK_MS) { tick(); acc -= TICK_MS; } render();

con TICK_MS = 1000 / 30, unos 33.3 ms. En cada cuadro se suma al acumulador el tiempo que pasó y se gastan tantos ticks como quepan. Lo que sobra se queda para el próximo cuadro. Si un cuadro llega tarde, ese cuadro corre dos o tres ticks seguidos; si llega temprano, ninguno. A la larga, siempre 30 por segundo.

const TICK_MS = 1000 / 30;
let acc = 0, ticks = 0;

// milisegundos entre cuadros: parejos, uno tardado y uno muy rápido
const cuadros = [16.7, 16.7, 16.7, 70, 8, 16.7];
for (const dt of cuadros) {
  acc += dt;
  let aqui = 0;
  while (acc >= TICK_MS) { ticks++; aqui++; acc -= TICK_MS; }
  console.log("dt", String(dt).padStart(4), "-> ticks en este cuadro:", aqui, " sobra:", acc.toFixed(1));
}
console.log("total:", ticks, "ticks en", cuadros.reduce((a, b) => a + b, 0).toFixed(1), "ms");

El cuadro tardado (70 ms) corre dos ticks de golpe para alcanzar al reloj; el de 8 ms no corre ninguno. Al final van 4 ticks en 145 ms, lo que toca a 30 por segundo.

El tope de 250 ms y la pausa

Si cambias de pestaña, el navegador deja de llamar a requestAnimationFrame. Al volver, now - last pueden ser cinco segundos: sin tope, el bucle correría 150 ticks de un jalón y te encontrarías la serpiente estrellada. Por eso dt se recorta a 250 ms: como mucho, 7 ticks de golpe.

Y en pausa (o durante la cuenta atrás al volver) no se llama a tick y el acumulador se vacía: if (pausa.quieto) { acc = 0; … return; }. El tiempo parado no existe para el juego, ni se escribe en el registro de la partida.

Esto es también lo que hace justa la partida: el puntaje sale de ticks, no de segundos del reloj. El servidor no necesita saber qué tan rápido iba tu teléfono.

const TICK_MS = 1000 / 30;
function ticksDeUnCuadro(dt, conTope) {
  let acc = conTope ? Math.min(dt, 250) : dt, n = 0;
  while (acc >= TICK_MS) { n++; acc -= TICK_MS; }
  return n;
}

const ausencia = 5000;   // cinco segundos en otra pestaña
console.log("sin tope:", ticksDeUnCuadro(ausencia, false), "ticks de golpe");
console.log("con tope:", ticksDeUnCuadro(ausencia, true), "ticks de golpe");

150 contra 7. Con el tope, el juego no intenta «recuperar» el tiempo perdido: simplemente sigue.

La serpiente no se mueve en cada tick

30 ticks por segundo serían 30 casillas por segundo: imposible de jugar. Snake lleva otro contador, this.interval = 8: se mueve un tick de cada ocho. En update:

this.tick++; if (this.tick % this.interval !== 0) return;

Los demás ticks no se pierden: se lee el control, corre la cuenta atrás del bicho de premio y baja el hambre. Al comer, el intervalo baja y la serpiente acelera:

this.interval = Math.max(3, 8 - Math.floor(this.eaten / 4));

Una velocidad que sale de enteros y del contador de ticks, sin ningún reloj: idéntica en el teléfono y en el servidor.

for (const eaten of [0, 4, 8, 12, 16, 20, 40]) {
  const interval = Math.max(3, 8 - Math.floor(eaten / 4));
  const casillas = (30 / interval).toFixed(2);
  console.log("frutas", String(eaten).padStart(2), "-> cada", interval, "ticks =", casillas, "casillas/s");
}

Arranca a 3.75 casillas por segundo y llega al tope (cada 3 ticks, 10 por segundo) a las 20 frutas. Después ya no acelera: Math.max(3, …) lo detiene.