Saltar al contenido
GameGenresEnglish
Disparos

Boomer shooter

Recorres a toda velocidad niveles laberínticos llenos de monstruos, cargando a la vez con un arsenal entero, buscando llaves y secretos y recogiendo vida y armadura del suelo. Es un FPS nuevo que imita a propósito los de los noventa, como Doom o Quake: sin recarga lenta ni coberturas, todo depende de moverte y apuntar rápido.

También se llama: shooter retro, disparos clásicos, FPS retro

Esta es una ficha básica: tiene la definición y la clasificación. Las fichas completas suman historia, juegos y una demo.

Demo propia

Controles. Haz clic para capturar el ratón y mirar, WASD o flechas para moverte y desplazarte de lado, clic o espacio para disparar. En móvil, arrastra para mirar y usa los botones en pantalla.

Abrir en pantalla completa

Un laberinto en primera persona con monstruos, una llave, una puerta cerrada y la salida. Te mueves rápido, disparas sin recargar y recoges vida del suelo, como en los FPS de los noventa. Está hecha con threeplay, un motor 3D en la línea de Kaplay.

Hecha con threeplay, en unas 155 líneas de JavaScript.

Cómo está hecha

  1. Configuración y controles

    Se crea el juego a 640×360 con botones virtuales para moverse y disparar; el disparo responde al ratón y al teclado a la vez. La velocidad es alta a propósito, porque moverse rápido es la mitad del género.

    const k = threeplay({
      width: 640,
      height: 360,
      letterbox: true,
      background: "#2b1d1d",
      buttons: {
        left: { keyboard: ["left", "a"] },
        right: { keyboard: ["right", "d"] },
        up: { keyboard: ["up", "w"] },
        down: { keyboard: ["down", "s"] },
        shoot: { keyboard: ["space", "j"], mouse: "left" },
      },
    });
    touchControls(k, [
      { button: "left", label: "◀" },
      { button: "up", label: "▲" },
      { button: "down", label: "▼" },
      { button: "right", label: "▶" },
      { button: "shoot", label: "✹", side: "right" },
    ]);
    const SPEED = 9; // fast on purpose: speed is half of the genre
    const TILE = 3;
  2. El laberinto como texto

    El nivel es un mapa de caracteres y addLevel() lo convierte en objetos sobre el suelo, una casilla de 3 m por carácter. Así se diseñaban los niveles de los noventa, con llaves que obligan a recorrer el mapa y puertas que lo cierran. La niebla esconde lo lejano y da el tono.

    // "#" wall, "@" start, "e" enemy, "+" health, "k" key, "D" locked door, "x" exit.
    const MAP = [
      "#############",
      "#@   #   e  #",
      "#    #  ##  #",
      "#  e    #k  #",
      "###  ####  ##",
      "#  +    e   #",
      "#  ###### ###",
      "#e #  x #   #",
      "#  #    D e #",
      "#  #######  #",
      "#      e    #",
      "#############",
    ];
    
    function buildLevel() {
      let start = vec3();
      addLevel(MAP, {
        tileSize: TILE,
        tiles: {
          "#": (p) => [box(TILE, 3.5, TILE), pos(p.x, 1.75, p.z), color("#7f5539"), area(), body({ isStatic: true })],
          "@": (p) => {
            start = p.clone();
            return [];
          },
          e: (p) => [box(1, 1.6, 1), pos(p.x, 0.8, p.z), rotate(), color("#c1121f"), area(), character(), health(3), "enemy"],
          "+": (p) => [box(0.6, 0.6, 0.6), pos(p.x, 0.5, p.z), color("#52b788"), area(), "medkit"],
          k: (p) => [box(0.5, 0.5, 0.15), pos(p.x, 1, p.z), rotate(), color("gold"), area(), "key"],
          D: (p) => [box(TILE, 3.5, 0.5), pos(p.x, 1.75, p.z), rotate(0, 90, 0), color("#ffd60a"), area(), body({ kinematic: true }), "door"],
          x: (p) => [cylinder(1, 0.1), pos(p.x, 0.05, p.z), color("#00f5d4"), area(), "exit"],
        },
      });
      const w = MAP[0].length * TILE;
      const d = MAP.length * TILE;
      add([box(w, 1, d), pos(0, -0.5, 0), color("#3a3a3a"), area(), body({ isStatic: true })]);
      setFog({ color: "#2b1d1d", near: 4, far: 30 });
      return start;
    }
  3. Correr y desplazarse de lado

    firstPerson() pone la cámara en los ojos del jugador y gira con el ratón. Como inputDir() es relativo a la cámara, A y D desplazan de lado sin girar, el movimiento que permite esquivar mientras disparas.

    const player = add([capsule(0.4, 1.6), pos(start.x, 1, start.z), rotate(), area(), character(), health(10), "player"]);
    player.hidden = true; // we look out of its eyes
    firstPerson(player, { height: 0.6, lockOnClick: !isTouchscreen() });
    
    onUpdate(() => {
      // Strafing: inputDir is relative to where the camera looks, so A/D step sideways.
      player.move(inputDir().multiplyScalar(SPEED));
    });
  4. Disparo instantáneo

    El disparo es un rayo desde el ojo hacia donde mira la cámara, con raycast(). Lo primero que toca recibe el daño al instante, sin proyectil. El arma y el fogonazo son rectángulos en pantalla que se animan con un tween.

    // Hitscan: the shot is a ray from the eye; whatever it hits first takes the damage.
    const gun = add([uiRect(36, 70, { radius: 4 }), pos(320, 372), anchor("bot"), color("#495057"), fixed()]);
    const flash = add([uiRect(30, 30, { radius: 15 }), pos(320, 296), anchor("center"), color("#ffd166"), fixed()]);
    flash.hidden = true;
    
    onButtonPress("shoot", () => {
      flash.hidden = false;
      wait(0.05, () => (flash.hidden = true));
      gun.pos.y = 380;
      tween(380, 372, 0.12, (y) => (gun.pos.y = y));
      beep({ freq: 180, slideTo: 60, duration: 0.1, type: "sawtooth" });
      const hit = raycast(camPos(), camera3d.getWorldDirection(vec3()), 60, { exclude: [player] });
      if (hit?.obj.is("enemy")) hit.obj.hurt(1);
    });
  5. Monstruos que te ven

    Cada enemigo lanza otro rayo hacia el jugador y solo lo persigue si no hay un muro en medio. Tiene health(3), y los eventos hurt y death sirven para el destello blanco y para eliminarlo. Tocarte te quita vida, con un instante de invulnerabilidad para que no se acumule en un solo fotograma.

    // An enemy chases only when it can see you: a ray to the player must not hit a wall first.
    function canSee(enemy) {
      const from = enemy.worldPos().add(vec3(0, 0.5, 0));
      const dir = player.worldPos().sub(from);
      const hit = raycast(from, dir, 20, { exclude: [enemy] });
      return hit?.obj === player;
    }
    onUpdate("enemy", (e) => {
      if (e.distanceTo(player) < 20 && canSee(e)) {
        e.moveTo(player, 3.5);
        e.lookAt(vec3(player.pos.x, e.pos.y, player.pos.z));
      }
    });
    on("hurt", "enemy", (e) => {
      e.color.set("white");
      wait(0.06, () => e.exists() && e.color.set("#c1121f"));
    });
    on("death", "enemy", (e) => {
      kills++;
      destroy(e);
      updateHud();
    });
    
    let invulnerable = false;
    player.onCollideUpdate("enemy", () => {
      if (invulnerable) return;
      invulnerable = true;
      player.hurt(1);
      camShake(0.15, 0.25);
      updateHud();
      wait(0.8, () => (invulnerable = false));
    });
    player.onDeath(() => go("end", false));
  6. Llave, puerta y botiquines

    Los objetos del suelo son sensores. El botiquín cura, la llave queda anotada y la puerta amarilla, un cuerpo cinemático, solo se hunde si la tocas con la llave. Este candado es lo que convierte el laberinto en un recorrido.

    let hasKey = false;
    player.onCollide("medkit", (m) => {
      destroy(m);
      player.heal(3);
      beep({ freq: 660, slideTo: 990, duration: 0.15 });
      updateHud();
    });
    player.onCollide("key", (key) => {
      destroy(key);
      hasKey = true;
      beep({ freq: 880, slideTo: 1320, duration: 0.2 });
      updateHud();
    });
    player.onCollide("door", (door) => {
      if (!hasKey || door.is("opening")) return;
      door.tag("opening");
      tween(door.pos.y, -1.8, 1, (y) => (door.pos.y = y));
    });
    player.onCollide("exit", () => go("end", true));
  7. El marcador

    Una línea con la vida, las bajas y si tienes la llave, más una mira en el centro de la pantalla.

    let kills = 0;
    const total = get("enemy").length;
    const hud = add([text("", { size: 16, font: FONT }), pos(12, 10), fixed()]);
    add([text("+", { size: 22, font: FONT }), pos(320, 180), anchor("center"), fixed()]);
    function updateHud() {
      const key = hasKey ? t({ es: "  · llave ✓", en: "  · key ✓" }) : "";
      hud.text = `${t({ es: "Vida", en: "Health" })} ${player.hp}   ${t({ es: "Bajas", en: "Kills" })} ${kills}/${total}${key}`;
    }
    updateHud();
  8. Escenas

    Título, partida y final. Al terminar se libera el ratón con unlockPointer() para que puedas volver a usar la página.

    function screen(title, subtitle) {
      add([text(title, { size: 38, font: FONT }), pos(320, 140), anchor("center"), fixed()]);
      add([text(subtitle, { size: 15, font: FONT }), pos(320, 190), anchor("center"), fixed()]);
      add([text(t({ es: "Clic o espacio para jugar", en: "Click or space to play" }), { size: 15, font: FONT }), pos(320, 240), anchor("center"), fixed()]);
      onButtonPress("shoot", () => go("game"));
    }
    
    scene("title", () => {
      screen("Boomer shooter", t({ es: "Encuentra la llave, abre la puerta amarilla y llega a la salida", en: "Find the key, open the yellow door and reach the exit" }));
    });
    scene("end", (won) => {
      unlockPointer();
      screen(won ? t({ es: "¡Saliste!", en: "You made it out!" }) : t({ es: "Has caído", en: "You died" }), "");
    });
    
    go("title");

Qué simplificamos

  • Hay un solo arma y un solo tipo de enemigo; el género vive de arsenales enteros y de monstruos con conductas distintas.
  • Los enemigos solo persiguen y empujan; no disparan ni esquivan.
  • Sin armadura, munición ni secretos, y el arma es un rectángulo en pantalla.

Para modificarla

  • Agrega un segundo arma, una escopeta que lance cinco rayos con un poco de dispersión.
  • Haz que un tipo de enemigo dispare proyectiles lentos que se puedan esquivar desplazándote de lado.
  • Esconde un secreto detrás de un muro falso que se abra al tocarlo.
Código completo
import threeplay from "../../vendor/threeplay/threeplay.mjs";
import { FONT, t, touchControls } from "../common.js";

// #region setup
const k = threeplay({
  width: 640,
  height: 360,
  letterbox: true,
  background: "#2b1d1d",
  buttons: {
    left: { keyboard: ["left", "a"] },
    right: { keyboard: ["right", "d"] },
    up: { keyboard: ["up", "w"] },
    down: { keyboard: ["down", "s"] },
    shoot: { keyboard: ["space", "j"], mouse: "left" },
  },
});
touchControls(k, [
  { button: "left", label: "◀" },
  { button: "up", label: "▲" },
  { button: "down", label: "▼" },
  { button: "right", label: "▶" },
  { button: "shoot", label: "✹", side: "right" },
]);
const SPEED = 9; // fast on purpose: speed is half of the genre
const TILE = 3;
// #endregion

// #region level
// "#" wall, "@" start, "e" enemy, "+" health, "k" key, "D" locked door, "x" exit.
const MAP = [
  "#############",
  "#@   #   e  #",
  "#    #  ##  #",
  "#  e    #k  #",
  "###  ####  ##",
  "#  +    e   #",
  "#  ###### ###",
  "#e #  x #   #",
  "#  #    D e #",
  "#  #######  #",
  "#      e    #",
  "#############",
];

function buildLevel() {
  let start = vec3();
  addLevel(MAP, {
    tileSize: TILE,
    tiles: {
      "#": (p) => [box(TILE, 3.5, TILE), pos(p.x, 1.75, p.z), color("#7f5539"), area(), body({ isStatic: true })],
      "@": (p) => {
        start = p.clone();
        return [];
      },
      e: (p) => [box(1, 1.6, 1), pos(p.x, 0.8, p.z), rotate(), color("#c1121f"), area(), character(), health(3), "enemy"],
      "+": (p) => [box(0.6, 0.6, 0.6), pos(p.x, 0.5, p.z), color("#52b788"), area(), "medkit"],
      k: (p) => [box(0.5, 0.5, 0.15), pos(p.x, 1, p.z), rotate(), color("gold"), area(), "key"],
      D: (p) => [box(TILE, 3.5, 0.5), pos(p.x, 1.75, p.z), rotate(0, 90, 0), color("#ffd60a"), area(), body({ kinematic: true }), "door"],
      x: (p) => [cylinder(1, 0.1), pos(p.x, 0.05, p.z), color("#00f5d4"), area(), "exit"],
    },
  });
  const w = MAP[0].length * TILE;
  const d = MAP.length * TILE;
  add([box(w, 1, d), pos(0, -0.5, 0), color("#3a3a3a"), area(), body({ isStatic: true })]);
  setFog({ color: "#2b1d1d", near: 4, far: 30 });
  return start;
}
// #endregion

scene("game", () => {
  const start = buildLevel();

  // #region player
  const player = add([capsule(0.4, 1.6), pos(start.x, 1, start.z), rotate(), area(), character(), health(10), "player"]);
  player.hidden = true; // we look out of its eyes
  firstPerson(player, { height: 0.6, lockOnClick: !isTouchscreen() });

  onUpdate(() => {
    // Strafing: inputDir is relative to where the camera looks, so A/D step sideways.
    player.move(inputDir().multiplyScalar(SPEED));
  });
  // #endregion

  // #region shooting
  // Hitscan: the shot is a ray from the eye; whatever it hits first takes the damage.
  const gun = add([uiRect(36, 70, { radius: 4 }), pos(320, 372), anchor("bot"), color("#495057"), fixed()]);
  const flash = add([uiRect(30, 30, { radius: 15 }), pos(320, 296), anchor("center"), color("#ffd166"), fixed()]);
  flash.hidden = true;

  onButtonPress("shoot", () => {
    flash.hidden = false;
    wait(0.05, () => (flash.hidden = true));
    gun.pos.y = 380;
    tween(380, 372, 0.12, (y) => (gun.pos.y = y));
    beep({ freq: 180, slideTo: 60, duration: 0.1, type: "sawtooth" });
    const hit = raycast(camPos(), camera3d.getWorldDirection(vec3()), 60, { exclude: [player] });
    if (hit?.obj.is("enemy")) hit.obj.hurt(1);
  });
  // #endregion

  // #region enemies
  // An enemy chases only when it can see you: a ray to the player must not hit a wall first.
  function canSee(enemy) {
    const from = enemy.worldPos().add(vec3(0, 0.5, 0));
    const dir = player.worldPos().sub(from);
    const hit = raycast(from, dir, 20, { exclude: [enemy] });
    return hit?.obj === player;
  }
  onUpdate("enemy", (e) => {
    if (e.distanceTo(player) < 20 && canSee(e)) {
      e.moveTo(player, 3.5);
      e.lookAt(vec3(player.pos.x, e.pos.y, player.pos.z));
    }
  });
  on("hurt", "enemy", (e) => {
    e.color.set("white");
    wait(0.06, () => e.exists() && e.color.set("#c1121f"));
  });
  on("death", "enemy", (e) => {
    kills++;
    destroy(e);
    updateHud();
  });

  let invulnerable = false;
  player.onCollideUpdate("enemy", () => {
    if (invulnerable) return;
    invulnerable = true;
    player.hurt(1);
    camShake(0.15, 0.25);
    updateHud();
    wait(0.8, () => (invulnerable = false));
  });
  player.onDeath(() => go("end", false));
  // #endregion

  // #region pickups
  let hasKey = false;
  player.onCollide("medkit", (m) => {
    destroy(m);
    player.heal(3);
    beep({ freq: 660, slideTo: 990, duration: 0.15 });
    updateHud();
  });
  player.onCollide("key", (key) => {
    destroy(key);
    hasKey = true;
    beep({ freq: 880, slideTo: 1320, duration: 0.2 });
    updateHud();
  });
  player.onCollide("door", (door) => {
    if (!hasKey || door.is("opening")) return;
    door.tag("opening");
    tween(door.pos.y, -1.8, 1, (y) => (door.pos.y = y));
  });
  player.onCollide("exit", () => go("end", true));
  // #endregion

  // #region hud
  let kills = 0;
  const total = get("enemy").length;
  const hud = add([text("", { size: 16, font: FONT }), pos(12, 10), fixed()]);
  add([text("+", { size: 22, font: FONT }), pos(320, 180), anchor("center"), fixed()]);
  function updateHud() {
    const key = hasKey ? t({ es: "  · llave ✓", en: "  · key ✓" }) : "";
    hud.text = `${t({ es: "Vida", en: "Health" })} ${player.hp}   ${t({ es: "Bajas", en: "Kills" })} ${kills}/${total}${key}`;
  }
  updateHud();
  // #endregion
});

// #region scenes
function screen(title, subtitle) {
  add([text(title, { size: 38, font: FONT }), pos(320, 140), anchor("center"), fixed()]);
  add([text(subtitle, { size: 15, font: FONT }), pos(320, 190), anchor("center"), fixed()]);
  add([text(t({ es: "Clic o espacio para jugar", en: "Click or space to play" }), { size: 15, font: FONT }), pos(320, 240), anchor("center"), fixed()]);
  onButtonPress("shoot", () => go("game"));
}

scene("title", () => {
  screen("Boomer shooter", t({ es: "Encuentra la llave, abre la puerta amarilla y llega a la salida", en: "Find the key, open the yellow door and reach the exit" }));
});
scene("end", (won) => {
  unlockPointer();
  screen(won ? t({ es: "¡Saliste!", en: "You made it out!" }) : t({ es: "Has caído", en: "You died" }), "");
});

go("title");
// #endregion

Historia

  1. 2018
    OrigenDusk

    Según Dave Oshry, de New Blood Interactive, los fans acuñaron el término tras el lanzamiento de Dusk (2018). «Boomer» alude, en broma, a lo anticuado.

    Shooter game (Wikipedia)
  2. 2020
    Ultrakill

    Ultrakill sale en acceso anticipado y suma al modelo de los noventa movimientos rápidos de juegos modernos y el estilo de los juegos de acción.

    Ultrakill (Wikipedia)

Juegos de referencia

2018

Dusk

David Szymanski (New Blood Interactive). Windows, Linux, macOS, PlayStation 4, Nintendo Switch, Xbox Series X/S

FPS de ambiente oscuro que imita los gráficos y el ritmo de los shooters de los noventa. Tras su lanzamiento, sus fans acuñaron el término «boomer shooter».

2020

Ultrakill

Arsi «Hakita» Patala (New Blood Interactive). Windows

FPS con gráficos al estilo de las primeras PlayStation que suma movimientos rápidos de shooters modernos y elementos de juegos de acción como Devil May Cry. Está en acceso anticipado desde 2020.

2021

Q1K3

Dominic Szablewski (PhobosLab). navegador

Homenaje a Quake (1996) en unos 13 kilobytes de JavaScript, hecho para la competición js13kGames de 2021. Te mueves con el teclado, apuntas con el ratón y recorres dos niveles basados en el primer episodio de Quake.