Saltar al contenido
GameGenresEnglish
Puzle

Bloques que caen

Las piezas caen desde arriba y tú las mueves y giras para encajarlas abajo; al completar una línea o un grupo, desaparecen. El reto es ordenar rápido sin dejar huecos mientras la caída se acelera.

También se llama: tipo Tetris, puzle de bloques

Mecánicas que lo definen

  • Las piezas caen solas en un pozo de tamaño fijo, y mientras bajan puedes moverlas a los lados y girarlas.
  • Cuando una pieza toca el fondo o el montón, se fija y aparece la siguiente; ver la pieza que viene permite planificar.
  • Las filas completas desaparecen y lo de arriba baja. En la rama de Columns, Dr. Mario y Puyo Puyo, lo que desaparece son grupos del mismo color.
  • La caída se acelera con cada nivel, y pierdes cuando el montón llega arriba.
  • En los duelos, lo que borras le llega al rival como filas o bloques de «basura».

Bucle de juego

Mirar la pieza actual y la siguiente, decidir dónde encaja, moverla y girarla, soltarla, borrar líneas y repetir con un poco menos de tiempo.

Demo propia

Controles. Flechas izquierda y derecha (o A/D) para mover, flecha arriba (o W/X) para girar, flecha abajo para bajar más rápido y espacio para soltar la pieza de golpe. En móvil, los botones en pantalla.

Abrir en pantalla completa

Un pozo de 10 × 20 casillas donde caen piezas de cuatro bloques. Las mueves y las giras para completar filas, que desaparecen; cada cinco filas sube el nivel y la caída se acelera, hasta que las piezas llegan arriba.

Hecha con Kaplay, en unas 191 líneas de JavaScript.

Cómo está hecha

  1. Configuración y controles

    Se definen botones virtuales (left, right, down, rotate, drop) que el resto del código consulta, así el juego funciona igual con teclado o con los botones táctiles. Las constantes fijan el tamaño del pozo (10 columnas por 20 filas, el estándar del género) y cuánto tarda en repetirse el movimiento si mantienes una flecha.

    const k = kaplay({
      width: 640,
      height: 360,
      letterbox: true, pixelDensity,
      background: [24, 26, 44],
      // Virtual buttons: the game asks about "rotate", not about a specific key.
      buttons: {
        left: { keyboard: ["left", "a"] },
        right: { keyboard: ["right", "d"] },
        down: { keyboard: ["down", "s"] },
        rotate: { keyboard: ["up", "w", "x"] },
        drop: { keyboard: ["space"] },
        start: { keyboard: ["space", "enter"] },
      },
    });
    touchControls(k, [
      { button: "left", label: "◀" },
      { button: "down", label: "▼" },
      { button: "right", label: "▶" },
      { button: "rotate", label: "⟳", side: "right" },
      { button: "drop", label: "⤓", side: "right" },
    ]);
    
    const COLS = 10;
    const ROWS = 20;
    const CELL = 16;
    const BOARD_X = 240; // top-left corner of the well, in pixels
    const BOARD_Y = 20;
    const LINES_PER_LEVEL = 5; // real games usually use 10
    const REPEAT_DELAY = 0.17; // holding left/right: wait this long, then repeat…
    const REPEAT_RATE = 0.05; // …one step every this many seconds
  2. Las piezas como matrices

    Cada pieza es una matriz pequeña de unos y ceros: el 1 marca un bloque. Girarla 90° es reordenar la matriz: la columna x, leída de abajo hacia arriba, pasa a ser la fila x. Así una sola función gira las siete formas, sin dibujar a mano cada orientación. Las piezas salen de una «bolsa» con las siete formas barajadas, para que ninguna tarde demasiado en volver.

    // Each piece is a small matrix: 1 = block, 0 = empty. Seven shapes of four blocks.
    const SHAPES = {
      I: [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
      O: [[1, 1], [1, 1]],
      T: [[0, 1, 0], [1, 1, 1], [0, 0, 0]],
      S: [[0, 1, 1], [1, 1, 0], [0, 0, 0]],
      Z: [[1, 1, 0], [0, 1, 1], [0, 0, 0]],
      J: [[1, 0, 0], [1, 1, 1], [0, 0, 0]],
      L: [[0, 0, 1], [1, 1, 1], [0, 0, 0]],
    };
    const COLORS = {
      I: [80, 210, 230], O: [240, 210, 70], T: [170, 100, 230], S: [100, 210, 110],
      Z: [230, 90, 90], J: [80, 120, 230], L: [240, 150, 60],
    };
    
    // Rotating clockwise: column x, read from bottom to top, becomes row x.
    const rotate = (m) => m[0].map((_, x) => m.map((row) => row[x]).reverse());
    
    // "Bag" randomiser: shuffle the seven shapes and deal them one by one,
    // so you never wait too long for the piece you need.
    let bag = [];
    function nextKind() {
      if (bag.length === 0) bag = shuffle(Object.keys(SHAPES));
      return bag.pop();
    }
    
    // A piece is its shape matrix plus a position on the board (in cells).
    function spawn(kind) {
      const matrix = SHAPES[kind];
      return { kind, matrix, x: Math.floor((COLS - matrix[0].length) / 2), y: kind === "I" ? -1 : 0 };
    }
  3. El pozo y las colisiones

    El pozo es otra matriz, de 20 filas por 10 columnas: cada casilla está vacía o guarda el color de un bloque ya asentado. collides() responde la pregunta central del género: ¿cabe esta matriz en esta posición? Recorre los bloques de la pieza y comprueba que ninguno se salga por los lados o por el fondo ni pise un bloque del pozo. Las filas por encima del borde superior se permiten, porque ahí aparecen las piezas.

    // The well is a ROWS × COLS matrix: null = empty, otherwise the colour of a settled block.
    const emptyRow = () => Array(COLS).fill(null);
    
    // The key question of the genre: would this matrix fit at (x, y)?
    // It collides if any of its blocks is past a wall or the floor, or over a settled block.
    function collides(board, matrix, x, y) {
      return matrix.some((row, r) =>
        row.some((cell, c) => {
          if (!cell) return false;
          const [bx, by] = [x + c, y + r];
          return bx < 0 || bx >= COLS || by >= ROWS || (by >= 0 && board[by][bx] !== null);
        }),
      );
    }
  4. Mover y girar

    Todo movimiento sigue la misma regla, que es probar la posición nueva y aceptarla solo si no choca. Al girar, si la pieza girada no cabe, se prueba a empujarla una o dos casillas a los lados, una versión mínima de las «patadas de pared» de los juegos reales. Mantener una flecha mueve una vez y, tras una pausa corta, repite el paso rápido.

    // Every move is "try, and only commit if the new place is free".
    function tryMove(dx, dy) {
      if (collides(board, piece.matrix, piece.x + dx, piece.y + dy)) return false;
      piece.x += dx;
      piece.y += dy;
      return true;
    }
    // Rotation with simple "wall kicks": if the turned piece does not fit, nudge it sideways.
    function tryRotate() {
      const turned = rotate(piece.matrix);
      for (const kick of [0, -1, 1, -2, 2]) {
        if (!collides(board, turned, piece.x + kick, piece.y)) {
          piece.matrix = turned;
          piece.x += kick;
          return;
        }
      }
    }
    onButtonPress("rotate", tryRotate);
    // Left/right: one step on press, then auto-repeat while the button is held.
    let held = 0;
    for (const [button, dx] of [["left", -1], ["right", 1]]) {
      onButtonPress(button, () => { held = 0; tryMove(dx, 0); });
      onButtonDown(button, () => {
        held += dt();
        if (held > REPEAT_DELAY) {
          tryMove(dx, 0);
          held -= REPEAT_RATE;
        }
      });
    }
  5. Gravedad y caída

    Un temporizador hace bajar la pieza una fila a intervalos que se acortan con cada nivel. Esa aceleración es la presión del género, porque el tiempo para pensar se reduce. Mantener abajo acelera la caída (caída suave) y espacio la suelta hasta el fondo (caída dura); ambas dan algunos puntos extra. La «sombra» tenue muestra dónde caería la pieza: se calcula bajándola en la imaginación hasta que choca.

    // The piece falls one row every fallInterval() seconds, faster at each level.
    const fallInterval = () => Math.max(0.06, 0.8 * Math.pow(0.8, level - 1));
    onUpdate(() => {
      // Soft drop: holding down makes it fall much faster (one point per row).
      const soft = isButtonDown("down");
      fallTimer += dt();
      if (fallTimer < (soft ? Math.min(0.04, fallInterval()) : fallInterval())) return;
      fallTimer = 0;
      if (tryMove(0, 1)) score += soft ? 1 : 0;
      else lockPiece(); // it cannot fall any further: it settles
    });
    // Hard drop: straight to the bottom and lock at once (two points per row).
    onButtonPress("drop", () => {
      while (tryMove(0, 1)) score += 2;
      lockPiece();
    });
    // Where the piece would land: drawn as a faint "ghost" to help you aim.
    function landingY() {
      let y = piece.y;
      while (!collides(board, piece.matrix, piece.x, y + 1)) y++;
      return y;
    }
  6. Fijar la pieza y borrar líneas

    Cuando la pieza ya no puede bajar, sus bloques se copian en la matriz del pozo. Luego se buscan filas llenas, sin ninguna casilla vacía; cada una se quita y se añade una fila vacía arriba, de modo que todo lo de encima baja. El recorrido va de abajo hacia arriba y repite el índice tras borrar, porque la fila de arriba acaba de ocupar ese lugar. Borrar varias filas de una vez da muchos más puntos, y eso invita a arriesgar dejando un hueco para la pieza larga. Si la pieza nueva no cabe donde aparece, el pozo está lleno y la partida termina.

    const LINE_POINTS = [0, 100, 300, 500, 800]; // clearing several lines at once pays more
    function lockPiece() {
      // 1) Copy the piece's blocks into the board matrix.
      piece.matrix.forEach((row, r) => row.forEach((cell, c) => {
        if (cell && piece.y + r >= 0) board[piece.y + r][piece.x + c] = COLORS[piece.kind];
      }));
      // 2) A row is full when none of its cells is empty: remove it and add an empty one on top.
      let cleared = 0;
      for (let r = ROWS - 1; r >= 0; r--) {
        if (board[r].every((cell) => cell !== null)) {
          board.splice(r, 1);
          board.unshift(emptyRow());
          cleared++;
          r++; // look at this index again: the row above has just moved down into it
        }
      }
      lines += cleared;
      score += LINE_POINTS[cleared] * level;
      level = 1 + Math.floor(lines / LINES_PER_LEVEL);
      // 3) Next piece. If it does not fit where it appears, the well is full: game over.
      piece = spawn(next);
      next = nextKind();
      fallTimer = 0;
      if (collides(board, piece.matrix, piece.x, piece.y)) go("end", score, lines);
    }
  7. Dibujar desde las matrices

    No hay un objeto por bloque. Cada fotograma, onDraw recorre las matrices (el pozo, la pieza, su sombra y la pieza siguiente) y pinta un cuadrado por cada casilla ocupada. El estado del juego vive en los datos y el dibujo solo lo refleja, lo que vuelve el código corto y fácil de revisar.

    const drawCell = (x, y, c, opacity = 1) =>
      drawRect({ pos: vec2(x, y), width: CELL - 1, height: CELL - 1, color: rgb(...c), opacity });
    // Draws a shape matrix with its top-left corner at pixel (px, py); rows above the well stay hidden.
    const drawMatrix = (matrix, px, py, c, opacity = 1) =>
      matrix.forEach((row, r) =>
        row.forEach((cell, col) => cell && py + r * CELL >= BOARD_Y && drawCell(px + col * CELL, py + r * CELL, c, opacity)),
      );
    const toPx = (x, y) => [BOARD_X + x * CELL, BOARD_Y + y * CELL];
    onDraw(() => {
      const [w, h] = [COLS * CELL + 3, ROWS * CELL + 3];
      drawRect({ pos: vec2(BOARD_X - 2, BOARD_Y - 2), width: w, height: h, color: rgb(12, 14, 26), outline: { width: 2, color: rgb(90, 96, 140) } });
      board.forEach((row, r) => row.forEach((c, x) => c && drawCell(...toPx(x, r), c)));
      drawMatrix(piece.matrix, ...toPx(piece.x, landingY()), COLORS[piece.kind], 0.25);
      drawMatrix(piece.matrix, ...toPx(piece.x, piece.y), COLORS[piece.kind]);
      // Next piece preview and scoreboard.
      drawText({ text: t({ es: "Siguiente", en: "Next" }), pos: vec2(430, 24), size: 16, font: FONT });
      drawMatrix(SHAPES[next], 430, 56, COLORS[next]);
      const stats = t({ es: `Puntos: ${score}\nLíneas: ${lines}\nNivel: ${level}`, en: `Score: ${score}\nLines: ${lines}\nLevel: ${level}` });
      drawText({ text: stats, pos: vec2(40, 24), size: 18, font: FONT, lineSpacing: 10 });
      const help = t({ es: "◀ ▶ mover · ▲ girar\n▼ bajar · espacio soltar", en: "◀ ▶ move · ▲ rotate\n▼ soft drop · space hard drop" });
      drawText({ text: help, pos: vec2(40, 290), size: 13, font: FONT, color: rgb(160, 168, 210), lineSpacing: 6 });
    });
  8. Escenas

    El juego tiene tres escenas (title, game y end) y go() salta de una a otra. Empezar una partida nueva crea un pozo vacío, sin tener que limpiar nada a mano.

    function screen(title, subtitle, next) {
      add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]);
      add([
        text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }),
        pos(center().add(0, 30)),
        anchor("center"),
        color(200, 210, 255),
      ]);
      onButtonPress("start", next);
      onClick(next);
    }
    
    scene("title", () =>
      screen(
        t({ es: "Bloques que caen", en: "Falling blocks" }),
        t({
          es: "Flechas para mover, flecha arriba para girar, abajo para bajar más rápido y espacio para soltar la pieza. Completa filas para borrarlas.\nPulsa espacio, Enter o toca para empezar.",
          en: "Arrows to move, up to rotate, down to drop faster and space to drop the piece. Complete rows to clear them.\nPress space, Enter or tap to start.",
        }),
        () => go("game"),
      ),
    );
    
    scene("end", (score, lines) =>
      screen(
        t({ es: "Se llenó el pozo", en: "The well is full" }),
        t({
          es: `Puntos: ${score} · Líneas: ${lines}.\nPulsa espacio o toca para jugar otra vez.`,
          en: `Score: ${score} · Lines: ${lines}.\nPress space or tap to play again.`,
        }),
        () => go("game"),
      ),
    );
    
    go("title");

Qué simplificamos

  • La rotación usa un empujón lateral simple cuando la pieza choca con una pared; los juegos comerciales siguen tablas de rotación y de «patadas» mucho más detalladas.
  • La pieza se fija en cuanto no puede bajar más; no hay «retardo de bloqueo», el medio segundo que muchos juegos dan para deslizarla antes de que quede fija.
  • No hay pieza guardada, ni animación al borrar filas, ni sonido, ni modo para dos jugadores.
  • El nivel sube cada cinco filas, la mitad de lo habitual, para que se note la aceleración en una partida corta.

Para modificarla

  • Añade una pieza guardada (hold) que puedas cambiar por la actual una vez por pieza, y piensa qué decisiones nuevas le da al jugador.
  • Agrega un retardo de bloqueo de medio segundo para deslizar la pieza antes de que quede fija. ¿Cómo cambia la sensación a velocidades altas?
  • Cambia la matriz de una pieza o añade una forma de cinco bloques. ¿Qué pasa con la rotación y con la dificultad?
  • Muestra las tres piezas siguientes en lugar de una y observa si juegas distinto.
Código completo
// Falling-block puzzle demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, touchControls, pixelDensity } from "../common.js";

// #region setup
const k = kaplay({
  width: 640,
  height: 360,
  letterbox: true, pixelDensity,
  background: [24, 26, 44],
  // Virtual buttons: the game asks about "rotate", not about a specific key.
  buttons: {
    left: { keyboard: ["left", "a"] },
    right: { keyboard: ["right", "d"] },
    down: { keyboard: ["down", "s"] },
    rotate: { keyboard: ["up", "w", "x"] },
    drop: { keyboard: ["space"] },
    start: { keyboard: ["space", "enter"] },
  },
});
touchControls(k, [
  { button: "left", label: "◀" },
  { button: "down", label: "▼" },
  { button: "right", label: "▶" },
  { button: "rotate", label: "⟳", side: "right" },
  { button: "drop", label: "⤓", side: "right" },
]);

const COLS = 10;
const ROWS = 20;
const CELL = 16;
const BOARD_X = 240; // top-left corner of the well, in pixels
const BOARD_Y = 20;
const LINES_PER_LEVEL = 5; // real games usually use 10
const REPEAT_DELAY = 0.17; // holding left/right: wait this long, then repeat…
const REPEAT_RATE = 0.05; // …one step every this many seconds
// #endregion

// #region pieces
// Each piece is a small matrix: 1 = block, 0 = empty. Seven shapes of four blocks.
const SHAPES = {
  I: [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
  O: [[1, 1], [1, 1]],
  T: [[0, 1, 0], [1, 1, 1], [0, 0, 0]],
  S: [[0, 1, 1], [1, 1, 0], [0, 0, 0]],
  Z: [[1, 1, 0], [0, 1, 1], [0, 0, 0]],
  J: [[1, 0, 0], [1, 1, 1], [0, 0, 0]],
  L: [[0, 0, 1], [1, 1, 1], [0, 0, 0]],
};
const COLORS = {
  I: [80, 210, 230], O: [240, 210, 70], T: [170, 100, 230], S: [100, 210, 110],
  Z: [230, 90, 90], J: [80, 120, 230], L: [240, 150, 60],
};

// Rotating clockwise: column x, read from bottom to top, becomes row x.
const rotate = (m) => m[0].map((_, x) => m.map((row) => row[x]).reverse());

// "Bag" randomiser: shuffle the seven shapes and deal them one by one,
// so you never wait too long for the piece you need.
let bag = [];
function nextKind() {
  if (bag.length === 0) bag = shuffle(Object.keys(SHAPES));
  return bag.pop();
}

// A piece is its shape matrix plus a position on the board (in cells).
function spawn(kind) {
  const matrix = SHAPES[kind];
  return { kind, matrix, x: Math.floor((COLS - matrix[0].length) / 2), y: kind === "I" ? -1 : 0 };
}
// #endregion

// #region board
// The well is a ROWS × COLS matrix: null = empty, otherwise the colour of a settled block.
const emptyRow = () => Array(COLS).fill(null);

// The key question of the genre: would this matrix fit at (x, y)?
// It collides if any of its blocks is past a wall or the floor, or over a settled block.
function collides(board, matrix, x, y) {
  return matrix.some((row, r) =>
    row.some((cell, c) => {
      if (!cell) return false;
      const [bx, by] = [x + c, y + r];
      return bx < 0 || bx >= COLS || by >= ROWS || (by >= 0 && board[by][bx] !== null);
    }),
  );
}
// #endregion

scene("game", () => {
  const board = Array.from({ length: ROWS }, emptyRow);
  bag = [];
  let piece = spawn(nextKind());
  let next = nextKind();
  let [score, lines, level, fallTimer] = [0, 0, 1, 0];

  // #region movement
  // Every move is "try, and only commit if the new place is free".
  function tryMove(dx, dy) {
    if (collides(board, piece.matrix, piece.x + dx, piece.y + dy)) return false;
    piece.x += dx;
    piece.y += dy;
    return true;
  }
  // Rotation with simple "wall kicks": if the turned piece does not fit, nudge it sideways.
  function tryRotate() {
    const turned = rotate(piece.matrix);
    for (const kick of [0, -1, 1, -2, 2]) {
      if (!collides(board, turned, piece.x + kick, piece.y)) {
        piece.matrix = turned;
        piece.x += kick;
        return;
      }
    }
  }
  onButtonPress("rotate", tryRotate);
  // Left/right: one step on press, then auto-repeat while the button is held.
  let held = 0;
  for (const [button, dx] of [["left", -1], ["right", 1]]) {
    onButtonPress(button, () => { held = 0; tryMove(dx, 0); });
    onButtonDown(button, () => {
      held += dt();
      if (held > REPEAT_DELAY) {
        tryMove(dx, 0);
        held -= REPEAT_RATE;
      }
    });
  }
  // #endregion

  // #region gravity
  // The piece falls one row every fallInterval() seconds, faster at each level.
  const fallInterval = () => Math.max(0.06, 0.8 * Math.pow(0.8, level - 1));
  onUpdate(() => {
    // Soft drop: holding down makes it fall much faster (one point per row).
    const soft = isButtonDown("down");
    fallTimer += dt();
    if (fallTimer < (soft ? Math.min(0.04, fallInterval()) : fallInterval())) return;
    fallTimer = 0;
    if (tryMove(0, 1)) score += soft ? 1 : 0;
    else lockPiece(); // it cannot fall any further: it settles
  });
  // Hard drop: straight to the bottom and lock at once (two points per row).
  onButtonPress("drop", () => {
    while (tryMove(0, 1)) score += 2;
    lockPiece();
  });
  // Where the piece would land: drawn as a faint "ghost" to help you aim.
  function landingY() {
    let y = piece.y;
    while (!collides(board, piece.matrix, piece.x, y + 1)) y++;
    return y;
  }
  // #endregion

  // #region lines
  const LINE_POINTS = [0, 100, 300, 500, 800]; // clearing several lines at once pays more
  function lockPiece() {
    // 1) Copy the piece's blocks into the board matrix.
    piece.matrix.forEach((row, r) => row.forEach((cell, c) => {
      if (cell && piece.y + r >= 0) board[piece.y + r][piece.x + c] = COLORS[piece.kind];
    }));
    // 2) A row is full when none of its cells is empty: remove it and add an empty one on top.
    let cleared = 0;
    for (let r = ROWS - 1; r >= 0; r--) {
      if (board[r].every((cell) => cell !== null)) {
        board.splice(r, 1);
        board.unshift(emptyRow());
        cleared++;
        r++; // look at this index again: the row above has just moved down into it
      }
    }
    lines += cleared;
    score += LINE_POINTS[cleared] * level;
    level = 1 + Math.floor(lines / LINES_PER_LEVEL);
    // 3) Next piece. If it does not fit where it appears, the well is full: game over.
    piece = spawn(next);
    next = nextKind();
    fallTimer = 0;
    if (collides(board, piece.matrix, piece.x, piece.y)) go("end", score, lines);
  }
  // #endregion

  // #region draw
  const drawCell = (x, y, c, opacity = 1) =>
    drawRect({ pos: vec2(x, y), width: CELL - 1, height: CELL - 1, color: rgb(...c), opacity });
  // Draws a shape matrix with its top-left corner at pixel (px, py); rows above the well stay hidden.
  const drawMatrix = (matrix, px, py, c, opacity = 1) =>
    matrix.forEach((row, r) =>
      row.forEach((cell, col) => cell && py + r * CELL >= BOARD_Y && drawCell(px + col * CELL, py + r * CELL, c, opacity)),
    );
  const toPx = (x, y) => [BOARD_X + x * CELL, BOARD_Y + y * CELL];
  onDraw(() => {
    const [w, h] = [COLS * CELL + 3, ROWS * CELL + 3];
    drawRect({ pos: vec2(BOARD_X - 2, BOARD_Y - 2), width: w, height: h, color: rgb(12, 14, 26), outline: { width: 2, color: rgb(90, 96, 140) } });
    board.forEach((row, r) => row.forEach((c, x) => c && drawCell(...toPx(x, r), c)));
    drawMatrix(piece.matrix, ...toPx(piece.x, landingY()), COLORS[piece.kind], 0.25);
    drawMatrix(piece.matrix, ...toPx(piece.x, piece.y), COLORS[piece.kind]);
    // Next piece preview and scoreboard.
    drawText({ text: t({ es: "Siguiente", en: "Next" }), pos: vec2(430, 24), size: 16, font: FONT });
    drawMatrix(SHAPES[next], 430, 56, COLORS[next]);
    const stats = t({ es: `Puntos: ${score}\nLíneas: ${lines}\nNivel: ${level}`, en: `Score: ${score}\nLines: ${lines}\nLevel: ${level}` });
    drawText({ text: stats, pos: vec2(40, 24), size: 18, font: FONT, lineSpacing: 10 });
    const help = t({ es: "◀ ▶ mover · ▲ girar\n▼ bajar · espacio soltar", en: "◀ ▶ move · ▲ rotate\n▼ soft drop · space hard drop" });
    drawText({ text: help, pos: vec2(40, 290), size: 13, font: FONT, color: rgb(160, 168, 210), lineSpacing: 6 });
  });
  // #endregion
});

// #region scenes
function screen(title, subtitle, next) {
  add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]);
  add([
    text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }),
    pos(center().add(0, 30)),
    anchor("center"),
    color(200, 210, 255),
  ]);
  onButtonPress("start", next);
  onClick(next);
}

scene("title", () =>
  screen(
    t({ es: "Bloques que caen", en: "Falling blocks" }),
    t({
      es: "Flechas para mover, flecha arriba para girar, abajo para bajar más rápido y espacio para soltar la pieza. Completa filas para borrarlas.\nPulsa espacio, Enter o toca para empezar.",
      en: "Arrows to move, up to rotate, down to drop faster and space to drop the piece. Complete rows to clear them.\nPress space, Enter or tap to start.",
    }),
    () => go("game"),
  ),
);

scene("end", (score, lines) =>
  screen(
    t({ es: "Se llenó el pozo", en: "The well is full" }),
    t({
      es: `Puntos: ${score} · Líneas: ${lines}.\nPulsa espacio o toca para jugar otra vez.`,
      en: `Score: ${score} · Lines: ${lines}.\nPress space or tap to play again.`,
    }),
    () => go("game"),
  ),
);

go("title");
// #endregion

Retos de diseño

  • Elegir cómo se sortean las piezas. El azar puro puede dejar largas rachas sin la pieza que necesitas; una «bolsa» con las siete formas barajadas garantiza que cada una salga una vez por ronda.
  • Afinar la sensación de control, con la repetición al mantener una dirección, el tiempo antes de que la pieza se fije y la rotación que empuja la pieza si choca con una pared.
  • Una curva de velocidad que empiece tranquila y acelere hasta exigir decisiones casi instantáneas, sin saltos injustos.
  • Que todo se lea de un vistazo, con un color por forma, la vista de la pieza siguiente y una sombra que marque dónde caerá.
  • Premiar el riesgo, de modo que borrar varias filas de una vez valga mucho más que borrarlas de una en una.

Historia

  1. 1984
    OrigenTetris

    Tetris, de Alekséi Pázhitnov, para la computadora soviética Electrónika 60. Las fuentes difieren: Henk Rogers sitúa la primera versión en 1984; Pázhitnov dijo 1985 en una entrevista de 1993.

    Tetris (Wikipedia)
  2. 1989
    Tetris

    Nintendo incluye la versión de Tetris para Game Boy con la consola en Norteamérica y Europa; supera los 35 millones de copias y vuelve masivo el género.

    Tile-matching video game (Wikipedia)
  3. 1989
    Columns

    Columns, de Jay Geertsen, hace caer columnas de tres joyas cuyo orden puedes cambiar; se borran tres o más iguales en línea, también en diagonal.

    Columns (video game) (Wikipedia)
  4. 1990
    Dr. Mario

    Dr. Mario cambia la meta, porque ya no se trata de sobrevivir sino de limpiar un frasco lleno de virus alineando cuatro del mismo color con cápsulas de dos colores.

    Dr. Mario (Wikipedia)
  5. 1991
    Puyo Puyo

    Puyo Puyo, heredero de Dr. Mario, hace caer pares de piezas de colores que se eliminan al agruparse.

    Tile-matching video game (Wikipedia)
  6. 1996
    Tetris

    The Tetris Company empieza a mantener especificaciones estándar, revisadas cada año, para todas las versiones autorizadas de Tetris.

    Tetris (Wikipedia)
  7. 2004
    Lumines: Puzzle Fusion

    Lumines une el género con la música, porque una línea de tiempo barre la pantalla al ritmo y borra los cuadrados de un solo color que formaste con bloques de 2 × 2.

    Lumines: Puzzle Fusion (Wikipedia)
  8. 2019
    Tetris 99

    Tetris 99 lleva el género al battle royale, con 99 jugadores en línea que se envían filas de basura hasta que solo queda uno.

    Tetris 99 (Wikipedia)

Juegos de referencia

1989

Columns

Jay Geertsen. HP 9000, MS-DOS, Mac, arcade, Mega Drive

Caen columnas de tres joyas de colores que puedes reordenar mientras bajan; tres o más iguales en línea, también en diagonal, desaparecen. Jay Geertsen lo compartió en 1989 y Sega lo lanzó en arcades y consolas.

1990

Dr. Mario

Nintendo. NES, Game Boy

Caen cápsulas de dos colores que giras y colocas para alinear cuatro del mismo color con los virus del frasco. Ganas el nivel cuando eliminas todos los virus.

1991

Puyo Puyo

Compile. MSX2, Famicom Disk System, arcade

Caen pares de gotas de colores que desaparecen al juntarse cuatro iguales; si al caer forman otro grupo, encadenas combos. La versión de arcade de Sega (1992) amplió el juego con un modo historia y un duelo para dos jugadores.

2004

Lumines: Puzzle Fusion

Q Entertainment. PlayStation Portable

Caen bloques de 2 × 2 en dos colores y formas cuadrados de un solo color; una línea de tiempo que barre la pantalla al ritmo de la música los borra. Lo diseñó Tetsuya Mizuguchi.

2019

SenTetrox

Pim Schreurs. navegador

Una variante del clásico de bloques que caen en la que las piezas tienen vida propia y vuelven a huir hacia arriba cuando han cumplido su función. Se hizo para la competencia js13kGames 2019, que limita el juego a 13 kilobytes.

2019

Tetris 99

Arika. Nintendo Switch

Noventa y nueve jugadores en línea juegan a la vez; cada fila que borras envía filas de basura al pozo de otro, y gana quien sobrevive al final.

Ejercicio

En la demo, cambia LINES_PER_LEVEL y la fórmula de fallInterval() y juega una partida con cada versión; anota en qué momento dejas de poder pensar. Luego dibuja en papel una pieza nueva de cinco bloques como matriz de unos y ceros, añádela a SHAPES y comenta cómo cambia la dificultad.

Fuentes