Saltar al contenido
GameGenresEnglish
Disparos

Bullet hell

Un shoot 'em up que llena la pantalla de cientos de proyectiles en patrones intrincados; tu nave solo es vulnerable en un punto diminuto y el reto es colarte entre las balas.

También se llama: lluvia de balas, lluvia de misiles, danmaku

Mecánicas que lo definen

  • Patrones de balas densos y geométricos (anillos, abanicos, espirales) que se leen como figuras y no como disparos sueltos.
  • Una zona de impacto diminuta, mucho más pequeña que el dibujo de la nave, que hace justo esquivar por muy poco.
  • Dos velocidades de movimiento; el modo foco te vuelve lento para colarte por huecos estrechos y suele mostrar la zona de impacto.
  • Puntos por rozar las balas sin tocarlas, que premian arriesgarse.
  • Jefes con varias fases, cada una con su propio patrón, y bombas o cambios de fase que borran las balas de la pantalla.

Bucle de juego

Leer el patrón que se forma, encontrar el hueco, colarte en él con el foco, disparar al jefe cuando el patrón da respiro y pasar a la fase siguiente.

Demo propia

Controles. Flechas o WASD para moverte; la nave dispara sola. Mantén Shift o X para el modo foco (lento y con la zona de impacto visible). Con ratón o dedo, mantén pulsado y la nave sigue al puntero. En móvil también hay botones en pantalla, uno de ellos para el foco.

Abrir en pantalla completa

Una nave en una pantalla vertical frente a un jefe con tres fases, cada una con un patrón de balas distinto (anillos, espiral, y abanico con anillo). Lo justo para sentir lo que define al género, que es leer patrones geométricos y colarte por sus huecos con una zona de impacto diminuta.

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

Cómo está hecha

  1. Configuración y controles

    El campo de juego es una columna vertical en el centro, como un monitor de arcade puesto en vertical; los paneles de los lados muestran el marcador. Hay dos velocidades, la normal para cruzar la pantalla y la del foco para los huecos estrechos. HITBOX es el radio del único punto que recibe daño, y GRAZE la distancia a la que una bala que pasa cerca da puntos.

    const k = kaplay({
      width: 640,
      height: 360,
      letterbox: true, pixelDensity,
      background: [8, 6, 26],
      buttons: {
        up: { keyboard: ["up", "w"] },
        down: { keyboard: ["down", "s"] },
        left: { keyboard: ["left", "a"] },
        right: { keyboard: ["right", "d"] },
        focus: { keyboard: ["shift", "x"] },
        start: { keyboard: ["space", "enter", "z"] },
      },
    });
    touchControls(k, [
      { button: "left", label: "◀" },
      { button: "up", label: "▲" },
      { button: "down", label: "▼" },
      { button: "right", label: "▶" },
      { button: "focus", label: "◎", side: "right" },
    ]);
    
    // A vertical playfield in the middle of the screen, like an upright arcade monitor.
    const FIELD = { x: 170, w: 300, h: 360 };
    const SPEED = 210; // normal speed: to cross the screen
    const FOCUS_SPEED = 80; // focus speed: to thread small gaps
    const HITBOX = 2.5; // radius of the only point that can be hit
    const GRAZE = 14; // bullets passing this close (without touching) give points
    const PINK = rgb(255, 90, 200);
    const CYAN = rgb(90, 220, 255);
    const GOLD = rgb(255, 210, 80);
  2. Cientos de balas como datos

    Si cada bala fuera un objeto del juego con colisiones propias, con cientos el juego se ralentizaría. Aquí cada bala es solo una posición, una velocidad y un radio dentro de una lista. Un único objeto, el enjambre, las mueve todas, borra las que salen del campo y las dibuja en un solo bucle. Los motores de bullet hell trabajan así.

    // Hundreds of bullets as game objects (each with area() and collision events) would be slow.
    // Here one object, the swarm, keeps every bullet as plain data {x, y, vx, vy, r} and
    // moves and draws them all in a single loop. Bullet hell engines work this way.
    let swarm;
    function addSwarm() {
      swarm = add([z(3), "swarm", {
        list: [],
        update() {
          for (const b of this.list) {
            b.x += b.vx * dt();
            b.y += b.vy * dt();
          }
          this.list = this.list.filter((b) => b.x > FIELD.x - 10 && b.x < FIELD.x + FIELD.w + 10 && b.y > -10 && b.y < FIELD.h + 10);
        },
        draw() {
          for (const b of this.list) drawCircle({ pos: vec2(b.x, b.y), radius: b.r, color: b.col, outline: { width: 1.5, color: WHITE } });
        },
      }]);
    }
    function shoot(x, y, angle, speed, r = 5, col = PINK) {
      const a = deg2rad(angle);
      swarm.list.push({ x, y, vx: Math.cos(a) * speed, vy: Math.sin(a) * speed, r, col, grazed: false });
    }
  3. Patrones con trigonometría

    Cada bala sale con un ángulo y una velocidad, y el coseno y el seno los convierten en movimiento horizontal y vertical. El anillo reparte n balas cada 360/n grados. El abanico calcula con atan2 el ángulo hacia el jugador y abre unas pocas balas alrededor. La espiral dispara un anillo pequeño una y otra vez, girándolo unos grados cada vez: cada bala vuela recta y la espiral solo aparece en el rastro que dejan.

    // The three classic patterns are pure trigonometry: each bullet is an angle and a speed.
    // Ring: n bullets evenly spread around a circle, 360/n degrees apart.
    function ring(x, y, n, speed, offset = 0, col = PINK) {
      for (let i = 0; i < n; i++) shoot(x, y, offset + (i * 360) / n, speed, 5, col);
    }
    // Fan: a few bullets spread around the direction toward the player (atan2 gives that angle).
    function fan(x, y, n, spread, speed, target) {
      const aim = rad2deg(Math.atan2(target.y - y, target.x - x));
      for (let i = 0; i < n; i++) shoot(x, y, aim + (i - (n - 1) / 2) * spread, speed, 4, GOLD);
    }
    // Spiral: a small ring fired again and again, turning a few degrees each time.
    // Each bullet flies straight; the spiral only appears in the trail they leave.
    function spiral(x, y, arms, angle, speed) {
      for (let i = 0; i < arms; i++) shoot(x, y, angle + (i * 360) / arms, speed, 4, CYAN);
    }
  4. La nave y el foco

    La nave es un triángulo grande, pero solo su punto central recibe daño. Mientras mantienes el foco te mueves más lento y ese punto se dibuja, junto con un halo que marca la distancia de roce. El disparo es automático porque en este género las manos están ocupadas esquivando. Con ratón o dedo la nave va hacia el puntero; en pantallas táctiles se queda un poco por encima del dedo para que puedas verla.

    const player = add([
      polygon([vec2(0, -12), vec2(10, 10), vec2(0, 5), vec2(-10, 10)]),
      color(140, 200, 255), pos(FIELD.x + FIELD.w / 2, 320), opacity(1), z(4),
      { lives: 3, invulnerable: 0, cooldown: 0 },
    ]);
    // The hitbox: a dot of 2.5 px that is only drawn while you focus.
    const dot = player.add([circle(HITBOX + 1), color(WHITE), outline(1.5, rgb(255, 40, 80)), opacity(0)]);
    const aura = player.add([circle(GRAZE), color(WHITE), opacity(0)]);
    const touch = matchMedia("(pointer: coarse)").matches;
    
    player.onUpdate(() => {
      const focus = isButtonDown("focus");
      const speed = focus ? FOCUS_SPEED : SPEED;
      let dir = vec2(0, 0);
      if (isButtonDown("left")) dir.x -= 1;
      if (isButtonDown("right")) dir.x += 1;
      if (isButtonDown("up")) dir.y -= 1;
      if (isButtonDown("down")) dir.y += 1;
      // Mouse or finger: the ship goes to the pointer (above the finger, so you can see it).
      if (isMouseDown()) dir = mousePos().sub(0, touch ? 50 : 0).sub(player.pos).scale(1 / 12);
      if (dir.len() > 1) dir = dir.unit();
      player.move(dir.scale(speed));
      player.pos.x = clamp(player.pos.x, FIELD.x + 10, FIELD.x + FIELD.w - 10);
      player.pos.y = clamp(player.pos.y, 20, FIELD.h - 12);
      dot.opacity = focus ? 1 : 0;
      aura.opacity = focus ? 0.12 : 0;
      // Shots are automatic: in this genre your hands are busy dodging.
      player.cooldown -= dt();
      if (player.cooldown <= 0) {
        player.cooldown = 0.08;
        add([rect(3, 12), color(200, 255, 200), pos(player.pos.sub(0, 12)), anchor("center"), area(), move(UP, 700), offscreen({ destroy: true }), "shot"]);
      }
      player.invulnerable = Math.max(0, player.invulnerable - dt());
      player.opacity = player.invulnerable > 0 && Math.floor(time() * 12) % 2 ? 0.25 : 1;
    });
  5. Un jefe por fases

    Las fases del jefe son una lista de datos con un nombre, una vida, cada cuánto dispara y qué patrón usa. Antes de cada fase hay una pausa en la que el jefe no dispara ni recibe daño. Al acabar una fase sus balas desaparecen y cada una da puntos, un premio y un respiro antes de un patrón más difícil. En la espiral, el giro cambia de sentido cada 60 disparos, así que también tú tienes que cambiar de dirección.

    // Three phases, each with its own health and pattern. When a phase ends its bullets
    // vanish (a reward and a breather) and the next, harder one begins.
    const PHASES = [
      { name: { es: "Anillos", en: "Rings" }, hp: 50, every: 0.75,
        fire: (b, n) => ring(b.pos.x, b.pos.y, 16, 85, n * 11) },
      { name: { es: "Espiral", en: "Spiral" }, hp: 60, every: 0.11,
        // Every 60 shots the spin reverses, so you have to change direction too.
        fire: (b, n) => spiral(b.pos.x, b.pos.y, 3, (b.spin += Math.floor(n / 60) % 2 ? -9 : 9), 95) },
      { name: { es: "Abanico y anillo", en: "Fan and ring" }, hp: 70, every: 0.55,
        fire: (b, n) => {
          fan(b.pos.x, b.pos.y, 5, 13, 150, player.pos);
          if (n % 3 === 0) ring(b.pos.x, b.pos.y, 22, 65, rand(0, 360));
        } },
    ];
    const boss = add([
      circle(22), color(200, 60, 120), outline(3, WHITE), pos(FIELD.x + FIELD.w / 2, 70), anchor("center"), area(), z(2),
      { phase: 0, hp: PHASES[0].hp, rest: 1.5, timer: 0, shots: 0, spin: 0, age: 0 },
    ]);
    const bar = add([rect(FIELD.w - 20, 5), color(255, 90, 200), pos(FIELD.x + 10, 8), z(6)]);
    boss.onUpdate(() => {
      boss.age += dt();
      boss.pos.x = FIELD.x + FIELD.w / 2 + Math.sin(boss.age * 0.7) * 90;
      const p = PHASES[boss.phase];
      bar.width = ((FIELD.w - 20) * boss.hp) / p.hp;
      boss.rest -= dt(); // a short pause before each phase
      if (boss.rest > 0) return;
      boss.timer -= dt();
      if (boss.timer <= 0) {
        boss.timer = p.every;
        p.fire(boss, boss.shots++);
      }
    });
    boss.onCollide("shot", (s) => {
      destroy(s);
      if (boss.rest > 0) return; // invulnerable between phases
      boss.hp--;
      score += 10;
      if (boss.hp > 0) return;
      score += swarm.list.length * 5; // every cancelled bullet is worth points
      swarm.list = [];
      boss.phase++;
      if (boss.phase === PHASES.length) return go("end", true, score, grazes);
      boss.hp = PHASES[boss.phase].hp;
      boss.shots = 0;
      boss.rest = 1.5;
      banner.text = t(PHASES[boss.phase].name);
      wait(1.4, () => (banner.text = ""));
    });
    banner.text = t(PHASES[0].name);
    wait(1.4, () => (banner.text = ""));
  6. Impactos y roces

    Comprobar un impacto es medir una distancia. Si el centro de la bala está más cerca que su radio más el de la zona de impacto, te alcanza. Un anillo más amplio detecta los roces, que dan puntos una vez por bala. Al perder una vida se borran las balas y eres invulnerable dos segundos, para volver a empezar en condiciones justas.

    // Collision is just distance: a bullet hits if it is closer than its radius plus the
    // hitbox. A wider ring around it detects "grazes", near misses that earn points.
    onUpdate(() => {
      for (const b of swarm.list) {
        const d = Math.hypot(b.x - player.pos.x, b.y - player.pos.y);
        if (d < b.r + HITBOX && player.invulnerable <= 0) return loseLife();
        if (d < b.r + GRAZE && !b.grazed) {
          b.grazed = true;
          grazes++;
          score += 2;
        }
      }
      hud.text = t({
        es: `Puntos\n${score}\n\nRoces\n${grazes}\n\nVidas\n${"♥".repeat(player.lives)}\n\nFase ${boss.phase + 1}/3`,
        en: `Score\n${score}\n\nGrazes\n${grazes}\n\nLives\n${"♥".repeat(player.lives)}\n\nPhase ${boss.phase + 1}/3`,
      });
    });
    function loseLife() {
      player.lives--;
      shake(6);
      swarm.list = []; // clearing the screen after a hit lets you start again fairly
      player.invulnerable = 2;
      if (player.lives <= 0) go("end", false, score, grazes);
    }
  7. Escenas

    Hay tres escenas (title, game y end) y go() pasa de una a otra. Al volver a game todo se crea de nuevo, incluido el enjambre de balas vacío.

    function screen(title, subtitle, next) {
      add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 70)), anchor("center")]);
      add([
        text(subtitle, { size: 15, font: FONT, align: "center", width: 580 }),
        pos(center().add(0, 25)), anchor("center"), color(200, 210, 255),
      ]);
      onButtonPress("start", next);
      onClick(next);
    }
    
    scene("title", () =>
      screen(
        t({ es: "Bullet hell", en: "Bullet hell" }),
        t({
          es: "Flechas o WASD para moverte; disparas solo. Mantén Shift o X para el foco: vas lento y ves tu punto de impacto, lo único que recibe daño.\nCon ratón o dedo, mantén pulsado y la nave te sigue.\nRoza las balas para sumar puntos y derrota las tres fases del jefe.\nPulsa espacio o haz clic para empezar.",
          en: "Arrows or WASD to move; you fire automatically. Hold Shift or X to focus: you move slowly and see your hitbox, the only part that can be hit.\nWith a mouse or finger, hold down and the ship follows you.\nGraze bullets for points and defeat the boss's three phases.\nPress space or click to start.",
        }),
        () => go("game"),
      ),
    );
    
    scene("end", (won, score, grazes) =>
      screen(
        won ? t({ es: "¡Jefe derrotado!", en: "Boss defeated!" }) : t({ es: "Sin vidas", en: "Out of lives" }),
        t({
          es: `Puntos: ${score}   Roces: ${grazes}\nPulsa espacio o haz clic para repetir.`,
          en: `Score: ${score}   Grazes: ${grazes}\nPress space or click to play again.`,
        }),
        () => go("game"),
      ),
    );
    
    go("title");

Qué simplificamos

  • Solo hay un jefe y no hay niveles con oleadas previas; la demo de shoot 'em up ya muestra esa parte.
  • No hay bombas que el jugador pueda lanzar; en su lugar, perder una vida o terminar una fase borra las balas.
  • El disparo es automático y siempre igual, sin mejoras ni distintos tipos de disparo.

Para modificarla

  • Añade una bomba con una tecla que borre todas las balas y te dé invulnerabilidad un instante, con un número limitado de usos.
  • Crea una bala que acelere o que cambie de dirección a mitad de camino (guarda una aceleración en cada bala).
  • Dibuja la zona de impacto también fuera del foco y pregunta a otras personas si el juego se siente más justo o más difícil.
Código completo
// Bullet hell 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: [8, 6, 26],
  buttons: {
    up: { keyboard: ["up", "w"] },
    down: { keyboard: ["down", "s"] },
    left: { keyboard: ["left", "a"] },
    right: { keyboard: ["right", "d"] },
    focus: { keyboard: ["shift", "x"] },
    start: { keyboard: ["space", "enter", "z"] },
  },
});
touchControls(k, [
  { button: "left", label: "◀" },
  { button: "up", label: "▲" },
  { button: "down", label: "▼" },
  { button: "right", label: "▶" },
  { button: "focus", label: "◎", side: "right" },
]);

// A vertical playfield in the middle of the screen, like an upright arcade monitor.
const FIELD = { x: 170, w: 300, h: 360 };
const SPEED = 210; // normal speed: to cross the screen
const FOCUS_SPEED = 80; // focus speed: to thread small gaps
const HITBOX = 2.5; // radius of the only point that can be hit
const GRAZE = 14; // bullets passing this close (without touching) give points
const PINK = rgb(255, 90, 200);
const CYAN = rgb(90, 220, 255);
const GOLD = rgb(255, 210, 80);
// #endregion

// #region bullets
// Hundreds of bullets as game objects (each with area() and collision events) would be slow.
// Here one object, the swarm, keeps every bullet as plain data {x, y, vx, vy, r} and
// moves and draws them all in a single loop. Bullet hell engines work this way.
let swarm;
function addSwarm() {
  swarm = add([z(3), "swarm", {
    list: [],
    update() {
      for (const b of this.list) {
        b.x += b.vx * dt();
        b.y += b.vy * dt();
      }
      this.list = this.list.filter((b) => b.x > FIELD.x - 10 && b.x < FIELD.x + FIELD.w + 10 && b.y > -10 && b.y < FIELD.h + 10);
    },
    draw() {
      for (const b of this.list) drawCircle({ pos: vec2(b.x, b.y), radius: b.r, color: b.col, outline: { width: 1.5, color: WHITE } });
    },
  }]);
}
function shoot(x, y, angle, speed, r = 5, col = PINK) {
  const a = deg2rad(angle);
  swarm.list.push({ x, y, vx: Math.cos(a) * speed, vy: Math.sin(a) * speed, r, col, grazed: false });
}
// #endregion

// #region patterns
// The three classic patterns are pure trigonometry: each bullet is an angle and a speed.
// Ring: n bullets evenly spread around a circle, 360/n degrees apart.
function ring(x, y, n, speed, offset = 0, col = PINK) {
  for (let i = 0; i < n; i++) shoot(x, y, offset + (i * 360) / n, speed, 5, col);
}
// Fan: a few bullets spread around the direction toward the player (atan2 gives that angle).
function fan(x, y, n, spread, speed, target) {
  const aim = rad2deg(Math.atan2(target.y - y, target.x - x));
  for (let i = 0; i < n; i++) shoot(x, y, aim + (i - (n - 1) / 2) * spread, speed, 4, GOLD);
}
// Spiral: a small ring fired again and again, turning a few degrees each time.
// Each bullet flies straight; the spiral only appears in the trail they leave.
function spiral(x, y, arms, angle, speed) {
  for (let i = 0; i < arms; i++) shoot(x, y, angle + (i * 360) / arms, speed, 4, CYAN);
}
// #endregion

scene("game", () => {
  addSwarm();
  let score = 0;
  let grazes = 0;
  // Side panels cover the bullets that leave the playfield.
  add([rect(FIELD.x, 360), color(20, 16, 48), z(8)]);
  add([rect(FIELD.x, 360), color(20, 16, 48), pos(FIELD.x + FIELD.w, 0), z(8)]);
  const hud = add([text("", { size: 15, font: FONT, width: 150 }), pos(12, 14), z(9)]);
  const banner = add([text("", { size: 22, font: FONT }), pos(FIELD.x + FIELD.w / 2, 180), anchor("center"), z(9)]);

  // #region player
  const player = add([
    polygon([vec2(0, -12), vec2(10, 10), vec2(0, 5), vec2(-10, 10)]),
    color(140, 200, 255), pos(FIELD.x + FIELD.w / 2, 320), opacity(1), z(4),
    { lives: 3, invulnerable: 0, cooldown: 0 },
  ]);
  // The hitbox: a dot of 2.5 px that is only drawn while you focus.
  const dot = player.add([circle(HITBOX + 1), color(WHITE), outline(1.5, rgb(255, 40, 80)), opacity(0)]);
  const aura = player.add([circle(GRAZE), color(WHITE), opacity(0)]);
  const touch = matchMedia("(pointer: coarse)").matches;

  player.onUpdate(() => {
    const focus = isButtonDown("focus");
    const speed = focus ? FOCUS_SPEED : SPEED;
    let dir = vec2(0, 0);
    if (isButtonDown("left")) dir.x -= 1;
    if (isButtonDown("right")) dir.x += 1;
    if (isButtonDown("up")) dir.y -= 1;
    if (isButtonDown("down")) dir.y += 1;
    // Mouse or finger: the ship goes to the pointer (above the finger, so you can see it).
    if (isMouseDown()) dir = mousePos().sub(0, touch ? 50 : 0).sub(player.pos).scale(1 / 12);
    if (dir.len() > 1) dir = dir.unit();
    player.move(dir.scale(speed));
    player.pos.x = clamp(player.pos.x, FIELD.x + 10, FIELD.x + FIELD.w - 10);
    player.pos.y = clamp(player.pos.y, 20, FIELD.h - 12);
    dot.opacity = focus ? 1 : 0;
    aura.opacity = focus ? 0.12 : 0;
    // Shots are automatic: in this genre your hands are busy dodging.
    player.cooldown -= dt();
    if (player.cooldown <= 0) {
      player.cooldown = 0.08;
      add([rect(3, 12), color(200, 255, 200), pos(player.pos.sub(0, 12)), anchor("center"), area(), move(UP, 700), offscreen({ destroy: true }), "shot"]);
    }
    player.invulnerable = Math.max(0, player.invulnerable - dt());
    player.opacity = player.invulnerable > 0 && Math.floor(time() * 12) % 2 ? 0.25 : 1;
  });
  // #endregion

  // #region boss
  // Three phases, each with its own health and pattern. When a phase ends its bullets
  // vanish (a reward and a breather) and the next, harder one begins.
  const PHASES = [
    { name: { es: "Anillos", en: "Rings" }, hp: 50, every: 0.75,
      fire: (b, n) => ring(b.pos.x, b.pos.y, 16, 85, n * 11) },
    { name: { es: "Espiral", en: "Spiral" }, hp: 60, every: 0.11,
      // Every 60 shots the spin reverses, so you have to change direction too.
      fire: (b, n) => spiral(b.pos.x, b.pos.y, 3, (b.spin += Math.floor(n / 60) % 2 ? -9 : 9), 95) },
    { name: { es: "Abanico y anillo", en: "Fan and ring" }, hp: 70, every: 0.55,
      fire: (b, n) => {
        fan(b.pos.x, b.pos.y, 5, 13, 150, player.pos);
        if (n % 3 === 0) ring(b.pos.x, b.pos.y, 22, 65, rand(0, 360));
      } },
  ];
  const boss = add([
    circle(22), color(200, 60, 120), outline(3, WHITE), pos(FIELD.x + FIELD.w / 2, 70), anchor("center"), area(), z(2),
    { phase: 0, hp: PHASES[0].hp, rest: 1.5, timer: 0, shots: 0, spin: 0, age: 0 },
  ]);
  const bar = add([rect(FIELD.w - 20, 5), color(255, 90, 200), pos(FIELD.x + 10, 8), z(6)]);
  boss.onUpdate(() => {
    boss.age += dt();
    boss.pos.x = FIELD.x + FIELD.w / 2 + Math.sin(boss.age * 0.7) * 90;
    const p = PHASES[boss.phase];
    bar.width = ((FIELD.w - 20) * boss.hp) / p.hp;
    boss.rest -= dt(); // a short pause before each phase
    if (boss.rest > 0) return;
    boss.timer -= dt();
    if (boss.timer <= 0) {
      boss.timer = p.every;
      p.fire(boss, boss.shots++);
    }
  });
  boss.onCollide("shot", (s) => {
    destroy(s);
    if (boss.rest > 0) return; // invulnerable between phases
    boss.hp--;
    score += 10;
    if (boss.hp > 0) return;
    score += swarm.list.length * 5; // every cancelled bullet is worth points
    swarm.list = [];
    boss.phase++;
    if (boss.phase === PHASES.length) return go("end", true, score, grazes);
    boss.hp = PHASES[boss.phase].hp;
    boss.shots = 0;
    boss.rest = 1.5;
    banner.text = t(PHASES[boss.phase].name);
    wait(1.4, () => (banner.text = ""));
  });
  banner.text = t(PHASES[0].name);
  wait(1.4, () => (banner.text = ""));
  // #endregion

  // #region hits-graze
  // Collision is just distance: a bullet hits if it is closer than its radius plus the
  // hitbox. A wider ring around it detects "grazes", near misses that earn points.
  onUpdate(() => {
    for (const b of swarm.list) {
      const d = Math.hypot(b.x - player.pos.x, b.y - player.pos.y);
      if (d < b.r + HITBOX && player.invulnerable <= 0) return loseLife();
      if (d < b.r + GRAZE && !b.grazed) {
        b.grazed = true;
        grazes++;
        score += 2;
      }
    }
    hud.text = t({
      es: `Puntos\n${score}\n\nRoces\n${grazes}\n\nVidas\n${"♥".repeat(player.lives)}\n\nFase ${boss.phase + 1}/3`,
      en: `Score\n${score}\n\nGrazes\n${grazes}\n\nLives\n${"♥".repeat(player.lives)}\n\nPhase ${boss.phase + 1}/3`,
    });
  });
  function loseLife() {
    player.lives--;
    shake(6);
    swarm.list = []; // clearing the screen after a hit lets you start again fairly
    player.invulnerable = 2;
    if (player.lives <= 0) go("end", false, score, grazes);
  }
  // #endregion
});

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

scene("title", () =>
  screen(
    t({ es: "Bullet hell", en: "Bullet hell" }),
    t({
      es: "Flechas o WASD para moverte; disparas solo. Mantén Shift o X para el foco: vas lento y ves tu punto de impacto, lo único que recibe daño.\nCon ratón o dedo, mantén pulsado y la nave te sigue.\nRoza las balas para sumar puntos y derrota las tres fases del jefe.\nPulsa espacio o haz clic para empezar.",
      en: "Arrows or WASD to move; you fire automatically. Hold Shift or X to focus: you move slowly and see your hitbox, the only part that can be hit.\nWith a mouse or finger, hold down and the ship follows you.\nGraze bullets for points and defeat the boss's three phases.\nPress space or click to start.",
    }),
    () => go("game"),
  ),
);

scene("end", (won, score, grazes) =>
  screen(
    won ? t({ es: "¡Jefe derrotado!", en: "Boss defeated!" }) : t({ es: "Sin vidas", en: "Out of lives" }),
    t({
      es: `Puntos: ${score}   Roces: ${grazes}\nPulsa espacio o haz clic para repetir.`,
      en: `Score: ${score}   Grazes: ${grazes}\nPress space or click to play again.`,
    }),
    () => go("game"),
  ),
);

go("title");
// #endregion

Retos de diseño

  • Que los patrones sean densos pero legibles. Las balas enemigas usan colores y bordes que contrastan con el fondo y nunca se confunden con la decoración.
  • Que cada patrón deje un hueco justo pero posible, comprobado con la zona de impacto real y no con el dibujo.
  • Mover y comprobar cientos de balas sin que el juego se ralentice, con datos simples y colisión por distancia en lugar de física.
  • Una curva entre fases que presente cada patrón primero lento y luego rápido, para que el jugador lo aprenda antes de dominarlo.

Historia

  1. 1993
    OrigenBatsugun

    Batsugun (Toaplan, 1993) introdujo los patrones de balas cada vez más complejos y una zona de impacto muy pequeña para hacerlos justos.

    Bullet hell (Wikipedia)
  2. 1995
    DonPachi

    DonPachi, de Cave, añade un sistema de combos que premia destruir enemigos rápido.

    Bullet hell (Wikipedia)
  3. 1997

    Story of Eastern Wonderland, segundo juego de Touhou Project, es el primero de la serie que es una lluvia de balas.

    Bullet hell (Wikipedia)
  4. 2001
    Ikaruga

    Ikaruga, al salir en GameCube, llama la atención sobre el género fuera de Japón.

    Bullet hell (Wikipedia)
  5. 2002
    Touhou Koumakyou ~ the Embodiment of Scarlet Devil

    Embodiment of Scarlet Devil, el primer Touhou para Windows, estrena las cartas de hechizo de los jefes y tiene un modo foco para esquivar más despacio y puntos por rozar las balas.

    Embodiment of Scarlet Devil (Wikipedia)
  6. 2015
    Undertale

    Undertale lleva la lluvia de balas a un juego de rol: en cada combate esquivas los ataques del enemigo moviendo un corazón.

    Undertale (Wikipedia)
  7. 2016
    Enter the Gungeon

    Enter the Gungeon mezcla la lluvia de balas con el roguelike, en salas con vista cenital por las que te mueves libremente.

    Enter the Gungeon (Wikipedia)
  8. 2022

    Vampire Survivors populariza el «bullet heaven», que invierte el género: ahora es tu personaje el que llena la pantalla de balas.

    Bullet hell (Wikipedia)

Juegos de referencia

1993

Batsugun

Toaplan. arcade

Shoot 'em up vertical con oleadas complejas, patrones de balas densos y mucha potencia de fuego. Se le considera el primer «manic shooter», antecedente de la lluvia de balas.

1995

DonPachi

Cave. arcade

Shoot 'em up vertical de desplazamiento con el que Cave empieza su serie de lluvias de balas. Un contador de combos premia encadenar enemigos destruidos sin pausa.

2001

Ikaruga

Treasure. arcade

La nave cambia entre blanco y negro cuando quieres. Absorbe las balas de su color para cargar un láser y muere con las del color contrario, así que esquivar se vuelve también elegir.

2002

Touhou Koumakyou ~ the Embodiment of Scarlet Devil

Team Shanghai Alice. Windows

Sexto juego de la serie Touhou Project y el primero para Windows, obra de un círculo de una sola persona. Estrena las «cartas de hechizo» de los jefes, y tiene un modo foco para esquivar más lento y puntos por rozar las balas.

2015

Undertale

Toby Fox. Windows, macOS

Juego de rol en el que cada combate mezcla turnos con una pequeña lluvia de balas, en la que esquivas los ataques moviendo un corazón que representa tu alma. También puedes perdonar a los monstruos en vez de vencerlos.

2016

Enter the Gungeon

Dodge Roll. Windows, macOS, Linux, PlayStation 4

Lluvia de balas con vista cenital y salas generadas al azar. Esquivas con una voltereta que te vuelve invulnerable un instante, y las «balas de fogueo» borran todos los proyectiles de la sala.

2024

NEHONOBASA

nizakashii. navegador, Windows

Lluvia de balas independiente de cuatro niveles y un jefe final. Al mantener el disparo la nave va más lenta para esquivar con precisión, y las bombas que chocan entre sí limpian la pantalla. Su autor lo publica gratis y jugable en el navegador.

Ejercicio

En la demo, cambia cuántas balas tiene el anillo y cuánto gira la espiral en cada disparo, y busca el punto en que el patrón deja de tener hueco. Luego diseña una cuarta fase que combine dos patrones y pide a alguien que la juegue con foco y sin foco.

Fuentes