Ir al contenido
Ayotl

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

Una bitmask por tick

✕
1/8

Cinco botones, cinco bits

La Atari tenía una palanca de cuatro direcciones y un botón. Daily Challenge guarda los cinco en un solo número, un bit por botón. Es todo core/buttons.js:

export const BTN = { UP: 1, DOWN: 2, LEFT: 4, RIGHT: 8, A: 16 };

Cada valor es una potencia de 2, así que se pueden sumar sin chocar: arriba y derecha a la vez es 1 | 8 = 9. Para preguntar si un botón está apretado se usa el y de bits: mask & BTN.UP da distinto de cero si el bit de arriba está encendido. Con cinco bits hay 32 combinaciones: del 0 (nada) al 31 (todo).

leccion.js

$ node leccion.js

Pulsa CORRER (o Ctrl+Enter).

Qué mirar

La columna del medio es el número en binario: cada 1 es un botón apretado. El 25 es 11001: A, derecha y arriba.

El operador & (MDN) ↗

Toda la lección en una página

Cinco botones, cinco bits

La Atari tenía una palanca de cuatro direcciones y un botón. Daily Challenge guarda los cinco en un solo número, un bit por botón. Es todo core/buttons.js:

export const BTN = { UP: 1, DOWN: 2, LEFT: 4, RIGHT: 8, A: 16 };

Cada valor es una potencia de 2, así que se pueden sumar sin chocar: arriba y derecha a la vez es 1 | 8 = 9. Para preguntar si un botón está apretado se usa el y de bits: mask & BTN.UP da distinto de cero si el bit de arriba está encendido. Con cinco bits hay 32 combinaciones: del 0 (nada) al 31 (todo).

const BTN = { UP: 1, DOWN: 2, LEFT: 4, RIGHT: 8, A: 16 };

function nombres(mask) {
  const lista = Object.keys(BTN).filter(b => mask & BTN[b]);
  return lista.length ? lista.join("+") : "nada";
}

for (const m of [0, 1, 9, 16, 25, 31]) {
  console.log(String(m).padStart(2), m.toString(2).padStart(5, "0"), nombres(m));
}
console.log("arriba + derecha =", BTN.UP | BTN.RIGHT);

La columna del medio es el número en binario: cada 1 es un botón apretado. El 25 es 11001: A, derecha y arriba.

Mantener y tocar: held y latched

core/input.js junta tres fuentes en la misma bitmask: el teclado (flechas, WASD, espacio), la cruceta en pantalla y los swipes sobre el juego. Las guarda en dos lados:

  • held: lo que está apretado ahora. Una tecla prende su bit al bajar y lo apaga al subir.
  • latched: lo que pasó una sola vez, como un swipe. Se guarda hasta el siguiente tick y luego se borra, para que un swipe rapidísimo entre dos ticks no se pierda.

Una vez por tick, el bucle llama a poll():

const m = this.held | this.latched; this.latched = 0; return m;

const BTN = { UP: 1, DOWN: 2, LEFT: 4, RIGHT: 8, A: 16 };
const input = {
  held: 0, latched: 0,
  bajaTecla(b) { this.held |= b; },
  subeTecla(b) { this.held &= ~b; },
  swipe(b)     { this.latched |= b; },
  poll() { const m = this.held | this.latched; this.latched = 0; return m; },
};

const log = [];
input.bajaTecla(BTN.RIGHT);  log.push(input.poll());   // tick 1
                             log.push(input.poll());   // tick 2: sigue apretada
input.swipe(BTN.UP);         log.push(input.poll());   // tick 3: el swipe
                             log.push(input.poll());   // tick 4: ya se borró
input.subeTecla(BTN.RIGHT);  log.push(input.poll());   // tick 5
console.log(log);

[8, 8, 9, 8, 0]: la derecha se mantiene mientras la tecla esté abajo; el swipe hacia arriba aparece una sola vez (8 + 1 = 9) y desaparece. ~b invierte los bits: held &= ~b apaga sólo ese botón.

Apretar no es mantener

A Snake le basta saber qué está apretado: if (mask & BTN.UP && this.dir.y !== 1) …. Pero otros juegos necesitan el momento en que se aprieta: en Tetris, mantener el botón no debe girar la pieza 30 veces por segundo. Para eso buttons.js trae una segunda función:

export const pressed = (mask, prev) => mask & ~prev;

Devuelve los bits que están ahora pero no estaban en el tick anterior: los flancos.

const pressed = (mask, prev) => mask & ~prev;

// A mantenido tres ticks, soltado y vuelto a apretar
const masks = [0, 16, 16, 16, 0, 16];
let prev = 0, giros = 0;
for (const m of masks) {
  if (pressed(m, prev) & 16) giros++;
  prev = m;
}
console.log("ticks con A:", masks.filter(m => m & 16).length, "| giros:", giros);

Cuatro ticks con A apretado, pero sólo dos giros: uno por cada vez que el botón bajó.

Toda la partida, en un texto

Cada tick, main.js guarda la bitmask antes de pasársela al juego: log.push(mask); game.update(mask);. Al final, el registro entero es un arreglo de números del 0 al 31, uno por tick.

Para mandarlo al servidor, net.js vuelve cada número un carácter, sumándole 48 (el código del '0'):

log.map(m => String.fromCharCode(48 + m)).join('')

Una partida de un minuto son 1800 caracteres. El servidor hace la cuenta al revés con charCodeAt(0) - 48 y rechaza el registro si algún número sale del 0 al 31.

const log = [0, 0, 8, 8, 9, 1, 1, 16, 0];
const texto = log.map(m => String.fromCharCode(48 + m)).join("");
console.log("para el servidor:", texto);

// del lado del servidor
const deVuelta = Array.from(texto, ch => ch.charCodeAt(0) - 48);
console.log("leído:", deVuelta);
console.log("¿válido?", deVuelta.every(m => m >= 0 && m <= 31));
console.log("un minuto =", 30 * 60, "caracteres");

Del 0 al 9 salen los dígitos, del 10 al 15 signos como : y ?, el 16 es @ y del 17 al 31, las letras de la A a la O. Un carácter por tick: compacto y fácil de revisar.