Saltar al contenido
GameGenresEnglish
Disparos

Disparos en primera persona

Ves el mundo con los ojos de tu personaje, con el arma en primer plano, y te mueves libremente por espacios en 3D mientras apuntas y disparas a los enemigos.

También se llama: FPS, shooter en primera persona

Mecánicas que lo definen

  • Cámara en los ojos del personaje y una mira fija en el centro de la pantalla, así que apuntar es girar toda la vista.
  • Movimiento libre por el escenario (avanzar, retroceder, girar y desplazarte de lado) mientras disparas, para esquivar sin dejar de apuntar.
  • Varias armas con alcances, cadencias y munición distintas, y la decisión constante de cuál usar contra cada enemigo.
  • Salud, armadura y munición como recursos que se recogen por el nivel, o un escudo que se recarga si evitas el daño un momento.
  • Niveles con llaves, puertas, secretos y emboscadas, y en multijugador partidas a muerte (deathmatch) en mapas pensados para el combate.

Bucle de juego

Avanzar por el nivel, detectar a un enemigo, girar para ponerlo bajo la mira, disparar mientras te mueves para no recibir daño, recoger salud y munición, y seguir hacia la siguiente zona.

Demo propia

Controles. Flechas o WASD para girar y avanzar o retroceder, espacio, X o clic para disparar. En móvil, los botones en pantalla.

Abrir en pantalla completa

Un pequeño laberinto en primera persona dibujado con raycasting, la técnica de Wolfenstein 3D (1992): el mundo es una cuadrícula plana y el 3D es una ilusión que se calcula columna a columna. Dos enemigos se te acercan, disparas con la mira central y ganas al llegar a la puerta verde.

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

Cómo está hecha

  1. Configuración y controles

    Se crea el juego y se definen botones virtuales para girar, avanzar, retroceder y disparar. FOV es el ángulo de visión (60°) y RAYS el número de rayos, uno por cada columna de la pantalla. Son los dos números que más cambian la imagen.

    const k = kaplay({
      width: 640,
      height: 360,
      letterbox: true, pixelDensity,
      background: [0, 0, 0],
      buttons: {
        left: { keyboard: ["left", "a"] },
        right: { keyboard: ["right", "d"] },
        forward: { keyboard: ["up", "w"] },
        back: { keyboard: ["down", "s"] },
        fire: { keyboard: ["space", "x"] },
        start: { keyboard: ["enter"] }, // space is already "fire", which also starts
      },
    });
    touchControls(k, [
      { button: "left", label: "◀" },
      { button: "forward", label: "▲" },
      { button: "back", label: "▼" },
      { button: "right", label: "▶" },
      { button: "fire", label: "✹", side: "right" },
    ]);
    
    const W = 640;
    const H = 360;
    const FOV = Math.PI / 3; // 60° field of view
    const RAYS = 160; // one ray per screen column…
    const COL_W = W / RAYS; // …and each column is 4 px wide
    const MOVE_SPEED = 2.6; // tiles per second
    const TURN_SPEED = 2.2; // radians per second
    const ENEMY_SPEED = 1.1;
  2. El mapa es una cuadrícula plana

    El mundo es una lista de cadenas vista desde arriba, igual que el nivel de un juego 2D. Las posiciones se miden en baldosas, así que el jugador y los enemigos pueden estar en medio de una celda (2,5 es el centro de la celda 2). Para saber si hay pared basta con redondear hacia abajo y mirar el carácter.

    // The world is a grid seen from above: # wall · X exit door · P start · E enemy.
    // Positions are measured in tiles, so (2.5, 2.5) is the centre of a cell.
    const MAP = [
      "############",
      "#..........#",
      "#.P....E...#",
      "#..........#",
      "##.###.###.#",
      "#.....#....#",
      "#.###.#.E..#",
      "#.#...#....#",
      "#...#...##.#",
      "##########X#",
    ];
    const cellAt = (x, y) => MAP[Math.floor(y)]?.[Math.floor(x)] ?? "#";
    const isWall = (x, y) => "#X".includes(cellAt(x, y));
    const WALL_COLOR = { "#": [110, 130, 190], X: [70, 210, 110] };
  3. El rayo y el algoritmo DDA

    Es el corazón de la demo. Desde el jugador se lanza un rayo y se avanza por él de celda en celda, saltando siempre a la línea de la cuadrícula más cercana, en x o en y. Es el algoritmo DDA. Como solo se visitan las celdas que el rayo cruza, es muy rápido. Al chocar devuelve la distancia y el lado de la pared. La función shade oscurece con la distancia y un poco más las caras norte y sur, y con ese truco barato se distinguen las esquinas.

    // The trick of 1992: the world is flat (a 2D grid) and the "3D" is an
    // illusion. For every screen column we cast one ray from the player and walk
    // it cell by cell (the DDA algorithm) until it hits a wall. The farther the
    // wall, the shorter we draw that column.
    function castRay(ox, oy, angle) {
      const dx = Math.cos(angle);
      const dy = Math.sin(angle);
      let mapX = Math.floor(ox);
      let mapY = Math.floor(oy);
      const stepX = dx < 0 ? -1 : 1;
      const stepY = dy < 0 ? -1 : 1;
      // How far along the ray we must travel to cross one whole cell in x or in y.
      const deltaX = Math.abs(1 / dx);
      const deltaY = Math.abs(1 / dy);
      // Distance to the first vertical and the first horizontal grid line.
      let sideX = (dx < 0 ? ox - mapX : mapX + 1 - ox) * deltaX;
      let sideY = (dy < 0 ? oy - mapY : mapY + 1 - oy) * deltaY;
      for (let i = 0; i < 64; i++) {
        // Always jump to whichever grid line is closer: that is the next cell.
        let dist, side;
        if (sideX < sideY) {
          mapX += stepX;
          dist = sideX;
          sideX += deltaX;
          side = 0; // hit a vertical (east/west-facing) wall
        } else {
          mapY += stepY;
          dist = sideY;
          sideY += deltaY;
          side = 1; // hit a horizontal (north/south-facing) wall
        }
        const cell = cellAt(mapX, mapY);
        if ("#X".includes(cell)) return { dist, side, cell };
      }
      return { dist: 64, side: 0, cell: "#" };
    }
    
    // Darker with distance, and walls facing north/south a bit darker than
    // east/west ones: this cheap trick is what makes the corners readable.
    function shade([r, g, b], dist, side) {
      const light = (side ? 0.7 : 1) / (1 + dist * dist * 0.04);
      return rgb(r * light, g * light, b * light);
    }
  4. El jugador es la cámara

    El jugador no tiene forma, es un punto con un ángulo de visión. Girar cambia el ángulo y avanzar suma su coseno y su seno a la posición. El movimiento se prueba por separado en x y en y, y así, si un eje choca con una pared, te deslizas por ella en vez de quedarte pegado. Chocar con la puerta verde gana la partida.

    // The player is a point on the map with a viewing angle (0 = east,
    // PI/2 = south, because y grows downwards). It has no shape: it is the camera.
    const player = add([pos(2.5, 2.5), "player", { angle: 0, health: 100, cooldown: 0, flash: 0, hurt: 0 }]);
    
    // Move along x and along y separately: if one axis is blocked you slide along the wall.
    function tryMove(obj, dx, dy, radius) {
      if (!isWall(obj.pos.x + dx + Math.sign(dx) * radius, obj.pos.y)) obj.pos.x += dx;
      if (!isWall(obj.pos.x, obj.pos.y + dy + Math.sign(dy) * radius)) obj.pos.y += dy;
    }
    
    onUpdate(() => {
      if (isButtonDown("left")) player.angle -= TURN_SPEED * dt();
      if (isButtonDown("right")) player.angle += TURN_SPEED * dt();
      const dir = (isButtonDown("forward") ? 1 : 0) - (isButtonDown("back") ? 1 : 0);
      const step = dir * MOVE_SPEED * dt();
      const dx = Math.cos(player.angle) * step;
      const dy = Math.sin(player.angle) * step;
      // Walking into the exit door wins.
      if (dir && cellAt(player.pos.x + Math.cos(player.angle) * 0.4 * dir, player.pos.y + Math.sin(player.angle) * 0.4 * dir) === "X") go("end", true);
      tryMove(player, dx, dy, 0.25);
      player.cooldown -= dt();
      player.flash -= dt();
      player.hurt -= dt();
    });
  5. Enemigos que te ven

    Los enemigos no forman parte de la cuadrícula, son objetos sueltos en el mapa. Para decidir si te persiguen reutilizan el mismo raycast. Si un rayo hacia ti choca con una pared antes de llegar, no te ven. Si te tocan, pierdes salud poco a poco.

    // Enemies are not part of the grid: they are "billboards", flat pictures that
    // always face you. They walk towards you while they can see you.
    for (let y = 0; y < MAP.length; y++)
      for (let x = 0; x < MAP[y].length; x++)
        if (MAP[y][x] === "E") add([pos(x + 0.5, y + 0.5), "enemy", { hp: 2, hit: 0, screen: null }]);
    
    // Line of sight: if a ray towards the player hits a wall first, the enemy cannot see you.
    const canSee = (e, dist) => castRay(e.pos.x, e.pos.y, Math.atan2(player.pos.y - e.pos.y, player.pos.x - e.pos.x)).dist > dist;
    
    onUpdate("enemy", (e) => {
      e.hit -= dt();
      const dist = e.pos.dist(player.pos);
      if (dist > 0.7 && dist < 9 && canSee(e, dist)) {
        const toPlayer = player.pos.sub(e.pos).unit().scale(ENEMY_SPEED * dt());
        tryMove(e, toPlayer.x, toPlayer.y, 0.3);
      }
      if (dist < 0.8) {
        player.health -= 20 * dt(); // touching hurts, little by little
        player.hurt = 0.1;
        if (player.health <= 0) go("end", false);
      }
    });
  6. Disparo instantáneo

    El disparo no es un proyectil, es instantáneo (hitscan). Acierta al enemigo más cercano que esté bajo la mira, siempre que la pared de la columna central no esté más cerca que él. Cada enemigo aguanta dos impactos y parpadea en blanco al recibirlos, una reacción visible que hace que disparar se sienta bien.

    // "Hitscan": the shot is instant. It hits the nearest enemy under the
    // crosshair, as long as no wall in the centre column is closer than it.
    function fire() {
      if (player.cooldown > 0) return;
      player.cooldown = 0.35;
      player.flash = 0.08;
      const wallDist = castRay(player.pos.x, player.pos.y, player.angle).dist;
      const target = get("enemy")
        .filter((e) => e.screen && Math.abs(e.screen.x - W / 2) < e.screen.size * 0.25 && e.screen.depth < wallDist)
        .sort((a, b) => a.screen.depth - b.screen.depth)[0];
      if (!target) return;
      target.hit = 0.15;
      if (--target.hp <= 0) destroy(target);
    }
    onButtonPress("fire", fire);
    onMousePress(fire);
  7. Dibujar la ilusión

    Todo se dibuja a mano en cada fotograma. Primero el techo y el suelo, dos rectángulos. Luego, por cada columna, un rayo y un rectángulo vertical cuya altura es la de la pantalla dividida por la distancia, así que lo que está al doble de lejos se ve a la mitad. La distancia se multiplica por el coseno del ángulo del rayo para corregir el efecto de ojo de pez. Después van los enemigos, del más lejano al más cercano, cortados en tiras que solo se dibujan donde están más cerca que la pared de esa columna, que es lo que guarda el arreglo depth. El minimapa muestra la verdad detrás de la ilusión: una cuadrícula plana y un abanico de rayos.

    // A custom draw component: everything is drawn by hand each frame.
    const depth = new Array(RAYS); // distance of the wall in every column
    add([
      {
        draw() {
          drawRect({ pos: vec2(0, 0), width: W, height: H / 2, color: rgb(40, 40, 52) }); // ceiling
          drawRect({ pos: vec2(0, H / 2), width: W, height: H / 2, color: rgb(70, 62, 54) }); // floor
          // 1) Walls: one ray and one vertical rectangle per column.
          for (let i = 0; i < RAYS; i++) {
            const camX = (2 * (i + 0.5)) / RAYS - 1; // -1 at the left edge, +1 at the right
            const rayAngle = player.angle + Math.atan(camX * Math.tan(FOV / 2));
            const hit = castRay(player.pos.x, player.pos.y, rayAngle);
            // Fisheye fix: use the distance perpendicular to the view, not along the ray.
            const dist = hit.dist * Math.cos(rayAngle - player.angle);
            depth[i] = dist;
            const h = Math.min(H * 3, H / dist); // twice as far, half as tall
            drawRect({ pos: vec2(i * COL_W, (H - h) / 2), width: COL_W + 0.5, height: h, color: shade(WALL_COLOR[hit.cell], dist, hit.side) });
          }
          // 2) Enemies: sorted far to near, sliced into columns that are only
          // drawn where the enemy is closer than the wall (the depth buffer).
          const enemies = get("enemy");
          for (const e of enemies) {
            const rel = Math.atan2(e.pos.y - player.pos.y, e.pos.x - player.pos.x) - player.angle;
            const a = Math.atan2(Math.sin(rel), Math.cos(rel)); // wrap to -PI…PI
            const d = e.pos.dist(player.pos) * Math.cos(a);
            e.screen = Math.abs(a) < FOV && d > 0.2 ? { x: W / 2 + (Math.tan(a) / Math.tan(FOV / 2)) * (W / 2), size: (H / d) * 0.7, depth: d } : null;
          }
          for (const e of enemies.filter((o) => o.screen).sort((p, q) => q.screen.depth - p.screen.depth)) {
            const { x, size, depth: d } = e.screen;
            const top = H / 2 + H / d / 2 - size; // feet on the floor
            const body = shade(e.hit > 0 ? [255, 255, 255] : [210, 60, 60], d, 0);
            const half = size * 0.25; // a figure half as wide as it is tall
            for (let c = Math.max(0, Math.floor((x - half) / COL_W)); c < Math.min(RAYS, (x + half) / COL_W); c++) {
              if (depth[c] < d) continue; // a wall is in front of this slice
              const head = Math.abs(c * COL_W + COL_W / 2 - x) < half * 0.5; // the middle slices also get a head
              drawRect({ pos: vec2(c * COL_W, top + size * (head ? 0 : 0.25)), width: COL_W + 0.5, height: size * (head ? 1 : 0.75), color: body });
              if (head) drawRect({ pos: vec2(c * COL_W, top + size * 0.08), width: COL_W + 0.5, height: size * 0.06, color: rgb(30, 20, 20) });
            }
          }
          // 3) Weapon, muzzle flash and crosshair.
          if (player.flash > 0) drawCircle({ pos: vec2(W / 2, H - 70), radius: 26, color: rgb(255, 220, 120) });
          drawRect({ pos: vec2(W / 2 - 14, H - 64), width: 28, height: 64, color: rgb(60, 60, 70) });
          drawLine({ p1: vec2(W / 2 - 8, H / 2), p2: vec2(W / 2 + 8, H / 2), width: 2, color: rgb(255, 255, 255) });
          drawLine({ p1: vec2(W / 2, H / 2 - 8), p2: vec2(W / 2, H / 2 + 8), width: 2, color: rgb(255, 255, 255) });
          if (player.hurt > 0) drawRect({ pos: vec2(0, 0), width: W, height: H, color: rgb(255, 0, 0), opacity: 0.25 });
          drawMinimap(enemies);
        },
      },
    ]);
    
    // The minimap shows the truth behind the illusion: a flat grid, and a fan of rays.
    function drawMinimap(enemies) {
      const S = 7;
      const o = vec2(W - MAP[0].length * S - 10, 10);
      for (let y = 0; y < MAP.length; y++)
        for (let x = 0; x < MAP[y].length; x++)
          if (isWall(x, y)) drawRect({ pos: o.add(x * S, y * S), width: S, height: S, color: rgb(...WALL_COLOR[MAP[y][x]]), opacity: 0.7 });
      const p = o.add(player.pos.scale(S));
      for (let i = 0; i < RAYS; i += 20) {
        const camX = (2 * (i + 0.5)) / RAYS - 1;
        const a = player.angle + Math.atan(camX * Math.tan(FOV / 2));
        const len = depth[i] / Math.cos(a - player.angle);
        drawLine({ p1: p, p2: p.add(Math.cos(a) * len * S, Math.sin(a) * len * S), width: 1, color: rgb(255, 230, 120), opacity: 0.6 });
      }
      for (const e of enemies) drawCircle({ pos: o.add(e.pos.scale(S)), radius: 2, color: rgb(230, 70, 70) });
      drawCircle({ pos: p, radius: 2.5, color: rgb(255, 255, 255) });
    }
  8. Marcador

    La salud y los enemigos que quedan se muestran encima de la vista, porque en primera persona no ves a tu personaje y necesitas que la pantalla te diga cómo estás.

    const hud = add([text("", { size: 16, font: FONT }), pos(12, 10), z(10)]);
    hud.onUpdate(() => {
      hud.text = t({ es: "Salud ", en: "Health " }) + Math.max(0, Math.ceil(player.health)) + "   " + t({ es: "Enemigos ", en: "Enemies " }) + get("enemy").length;
    });
  9. Escenas

    Tres escenas (title, game y end) y go() para pasar de una a otra. Al volver a game, el nivel se reconstruye desde cero.

    function screen(title, subtitle) {
      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", () => go("game"));
      onButtonPress("fire", () => go("game"));
      onMousePress(() => go("game"));
    }
    
    scene("title", () =>
      screen(
        t({ es: "Disparos en primera persona", en: "First-person shooter" }),
        t({
          es: "Flechas o WASD para girar y avanzar, espacio o clic para disparar.\nEncuentra la puerta verde de salida.\nPulsa espacio o toca para empezar.",
          en: "Arrows or WASD to turn and move, space or click to shoot.\nFind the green exit door.\nPress space or tap to start.",
        }),
      ),
    );
    
    scene("end", (won) =>
      screen(
        won ? t({ es: "¡Saliste!", en: "You got out!" }) : t({ es: "Te alcanzaron", en: "They got you" }),
        t({ es: "Pulsa espacio o toca para jugar otra vez.", en: "Press space or tap to play again." }),
      ),
    );
    
    go("title");

Qué simplificamos

  • Las paredes son de color liso, sin texturas; texturizar es elegir, para cada columna, qué tira vertical de la imagen dibujar según dónde chocó el rayo.
  • No hay desplazamiento lateral, ni munición, ni puertas que se abren. Solo una arma y dos enemigos que te persiguen si te ven.
  • Los enemigos son rectángulos planos que siempre te miran de frente (billboards), como los sprites de los juegos de 1992.
  • Se dibuja una columna de 4 píxeles por rayo (160 rayos), no una por píxel, para que funcione en computadoras modestas.

Para modificarla

  • Añade desplazamiento lateral (strafe) con dos teclas nuevas. Pista, es avanzar con el ángulo más 90°.
  • Crea un nuevo tipo de pared (otro carácter y otro color en WALL_COLOR) y úsalo para que el jugador reconozca cada zona del mapa.
  • Añade munición limitada y cajas de balas en el mapa que la recarguen. ¿Cómo cambia tu forma de jugar?
Código completo
// First-person shooter demo: a 1992-style raycaster
// 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: [0, 0, 0],
  buttons: {
    left: { keyboard: ["left", "a"] },
    right: { keyboard: ["right", "d"] },
    forward: { keyboard: ["up", "w"] },
    back: { keyboard: ["down", "s"] },
    fire: { keyboard: ["space", "x"] },
    start: { keyboard: ["enter"] }, // space is already "fire", which also starts
  },
});
touchControls(k, [
  { button: "left", label: "◀" },
  { button: "forward", label: "▲" },
  { button: "back", label: "▼" },
  { button: "right", label: "▶" },
  { button: "fire", label: "✹", side: "right" },
]);

const W = 640;
const H = 360;
const FOV = Math.PI / 3; // 60° field of view
const RAYS = 160; // one ray per screen column…
const COL_W = W / RAYS; // …and each column is 4 px wide
const MOVE_SPEED = 2.6; // tiles per second
const TURN_SPEED = 2.2; // radians per second
const ENEMY_SPEED = 1.1;
// #endregion

// #region map
// The world is a grid seen from above: # wall · X exit door · P start · E enemy.
// Positions are measured in tiles, so (2.5, 2.5) is the centre of a cell.
const MAP = [
  "############",
  "#..........#",
  "#.P....E...#",
  "#..........#",
  "##.###.###.#",
  "#.....#....#",
  "#.###.#.E..#",
  "#.#...#....#",
  "#...#...##.#",
  "##########X#",
];
const cellAt = (x, y) => MAP[Math.floor(y)]?.[Math.floor(x)] ?? "#";
const isWall = (x, y) => "#X".includes(cellAt(x, y));
const WALL_COLOR = { "#": [110, 130, 190], X: [70, 210, 110] };
// #endregion

// #region raycast
// The trick of 1992: the world is flat (a 2D grid) and the "3D" is an
// illusion. For every screen column we cast one ray from the player and walk
// it cell by cell (the DDA algorithm) until it hits a wall. The farther the
// wall, the shorter we draw that column.
function castRay(ox, oy, angle) {
  const dx = Math.cos(angle);
  const dy = Math.sin(angle);
  let mapX = Math.floor(ox);
  let mapY = Math.floor(oy);
  const stepX = dx < 0 ? -1 : 1;
  const stepY = dy < 0 ? -1 : 1;
  // How far along the ray we must travel to cross one whole cell in x or in y.
  const deltaX = Math.abs(1 / dx);
  const deltaY = Math.abs(1 / dy);
  // Distance to the first vertical and the first horizontal grid line.
  let sideX = (dx < 0 ? ox - mapX : mapX + 1 - ox) * deltaX;
  let sideY = (dy < 0 ? oy - mapY : mapY + 1 - oy) * deltaY;
  for (let i = 0; i < 64; i++) {
    // Always jump to whichever grid line is closer: that is the next cell.
    let dist, side;
    if (sideX < sideY) {
      mapX += stepX;
      dist = sideX;
      sideX += deltaX;
      side = 0; // hit a vertical (east/west-facing) wall
    } else {
      mapY += stepY;
      dist = sideY;
      sideY += deltaY;
      side = 1; // hit a horizontal (north/south-facing) wall
    }
    const cell = cellAt(mapX, mapY);
    if ("#X".includes(cell)) return { dist, side, cell };
  }
  return { dist: 64, side: 0, cell: "#" };
}

// Darker with distance, and walls facing north/south a bit darker than
// east/west ones: this cheap trick is what makes the corners readable.
function shade([r, g, b], dist, side) {
  const light = (side ? 0.7 : 1) / (1 + dist * dist * 0.04);
  return rgb(r * light, g * light, b * light);
}
// #endregion

scene("game", () => {
  // #region player
  // The player is a point on the map with a viewing angle (0 = east,
  // PI/2 = south, because y grows downwards). It has no shape: it is the camera.
  const player = add([pos(2.5, 2.5), "player", { angle: 0, health: 100, cooldown: 0, flash: 0, hurt: 0 }]);

  // Move along x and along y separately: if one axis is blocked you slide along the wall.
  function tryMove(obj, dx, dy, radius) {
    if (!isWall(obj.pos.x + dx + Math.sign(dx) * radius, obj.pos.y)) obj.pos.x += dx;
    if (!isWall(obj.pos.x, obj.pos.y + dy + Math.sign(dy) * radius)) obj.pos.y += dy;
  }

  onUpdate(() => {
    if (isButtonDown("left")) player.angle -= TURN_SPEED * dt();
    if (isButtonDown("right")) player.angle += TURN_SPEED * dt();
    const dir = (isButtonDown("forward") ? 1 : 0) - (isButtonDown("back") ? 1 : 0);
    const step = dir * MOVE_SPEED * dt();
    const dx = Math.cos(player.angle) * step;
    const dy = Math.sin(player.angle) * step;
    // Walking into the exit door wins.
    if (dir && cellAt(player.pos.x + Math.cos(player.angle) * 0.4 * dir, player.pos.y + Math.sin(player.angle) * 0.4 * dir) === "X") go("end", true);
    tryMove(player, dx, dy, 0.25);
    player.cooldown -= dt();
    player.flash -= dt();
    player.hurt -= dt();
  });
  // #endregion

  // #region enemies
  // Enemies are not part of the grid: they are "billboards", flat pictures that
  // always face you. They walk towards you while they can see you.
  for (let y = 0; y < MAP.length; y++)
    for (let x = 0; x < MAP[y].length; x++)
      if (MAP[y][x] === "E") add([pos(x + 0.5, y + 0.5), "enemy", { hp: 2, hit: 0, screen: null }]);

  // Line of sight: if a ray towards the player hits a wall first, the enemy cannot see you.
  const canSee = (e, dist) => castRay(e.pos.x, e.pos.y, Math.atan2(player.pos.y - e.pos.y, player.pos.x - e.pos.x)).dist > dist;

  onUpdate("enemy", (e) => {
    e.hit -= dt();
    const dist = e.pos.dist(player.pos);
    if (dist > 0.7 && dist < 9 && canSee(e, dist)) {
      const toPlayer = player.pos.sub(e.pos).unit().scale(ENEMY_SPEED * dt());
      tryMove(e, toPlayer.x, toPlayer.y, 0.3);
    }
    if (dist < 0.8) {
      player.health -= 20 * dt(); // touching hurts, little by little
      player.hurt = 0.1;
      if (player.health <= 0) go("end", false);
    }
  });
  // #endregion

  // #region shooting
  // "Hitscan": the shot is instant. It hits the nearest enemy under the
  // crosshair, as long as no wall in the centre column is closer than it.
  function fire() {
    if (player.cooldown > 0) return;
    player.cooldown = 0.35;
    player.flash = 0.08;
    const wallDist = castRay(player.pos.x, player.pos.y, player.angle).dist;
    const target = get("enemy")
      .filter((e) => e.screen && Math.abs(e.screen.x - W / 2) < e.screen.size * 0.25 && e.screen.depth < wallDist)
      .sort((a, b) => a.screen.depth - b.screen.depth)[0];
    if (!target) return;
    target.hit = 0.15;
    if (--target.hp <= 0) destroy(target);
  }
  onButtonPress("fire", fire);
  onMousePress(fire);
  // #endregion

  // #region render
  // A custom draw component: everything is drawn by hand each frame.
  const depth = new Array(RAYS); // distance of the wall in every column
  add([
    {
      draw() {
        drawRect({ pos: vec2(0, 0), width: W, height: H / 2, color: rgb(40, 40, 52) }); // ceiling
        drawRect({ pos: vec2(0, H / 2), width: W, height: H / 2, color: rgb(70, 62, 54) }); // floor
        // 1) Walls: one ray and one vertical rectangle per column.
        for (let i = 0; i < RAYS; i++) {
          const camX = (2 * (i + 0.5)) / RAYS - 1; // -1 at the left edge, +1 at the right
          const rayAngle = player.angle + Math.atan(camX * Math.tan(FOV / 2));
          const hit = castRay(player.pos.x, player.pos.y, rayAngle);
          // Fisheye fix: use the distance perpendicular to the view, not along the ray.
          const dist = hit.dist * Math.cos(rayAngle - player.angle);
          depth[i] = dist;
          const h = Math.min(H * 3, H / dist); // twice as far, half as tall
          drawRect({ pos: vec2(i * COL_W, (H - h) / 2), width: COL_W + 0.5, height: h, color: shade(WALL_COLOR[hit.cell], dist, hit.side) });
        }
        // 2) Enemies: sorted far to near, sliced into columns that are only
        // drawn where the enemy is closer than the wall (the depth buffer).
        const enemies = get("enemy");
        for (const e of enemies) {
          const rel = Math.atan2(e.pos.y - player.pos.y, e.pos.x - player.pos.x) - player.angle;
          const a = Math.atan2(Math.sin(rel), Math.cos(rel)); // wrap to -PI…PI
          const d = e.pos.dist(player.pos) * Math.cos(a);
          e.screen = Math.abs(a) < FOV && d > 0.2 ? { x: W / 2 + (Math.tan(a) / Math.tan(FOV / 2)) * (W / 2), size: (H / d) * 0.7, depth: d } : null;
        }
        for (const e of enemies.filter((o) => o.screen).sort((p, q) => q.screen.depth - p.screen.depth)) {
          const { x, size, depth: d } = e.screen;
          const top = H / 2 + H / d / 2 - size; // feet on the floor
          const body = shade(e.hit > 0 ? [255, 255, 255] : [210, 60, 60], d, 0);
          const half = size * 0.25; // a figure half as wide as it is tall
          for (let c = Math.max(0, Math.floor((x - half) / COL_W)); c < Math.min(RAYS, (x + half) / COL_W); c++) {
            if (depth[c] < d) continue; // a wall is in front of this slice
            const head = Math.abs(c * COL_W + COL_W / 2 - x) < half * 0.5; // the middle slices also get a head
            drawRect({ pos: vec2(c * COL_W, top + size * (head ? 0 : 0.25)), width: COL_W + 0.5, height: size * (head ? 1 : 0.75), color: body });
            if (head) drawRect({ pos: vec2(c * COL_W, top + size * 0.08), width: COL_W + 0.5, height: size * 0.06, color: rgb(30, 20, 20) });
          }
        }
        // 3) Weapon, muzzle flash and crosshair.
        if (player.flash > 0) drawCircle({ pos: vec2(W / 2, H - 70), radius: 26, color: rgb(255, 220, 120) });
        drawRect({ pos: vec2(W / 2 - 14, H - 64), width: 28, height: 64, color: rgb(60, 60, 70) });
        drawLine({ p1: vec2(W / 2 - 8, H / 2), p2: vec2(W / 2 + 8, H / 2), width: 2, color: rgb(255, 255, 255) });
        drawLine({ p1: vec2(W / 2, H / 2 - 8), p2: vec2(W / 2, H / 2 + 8), width: 2, color: rgb(255, 255, 255) });
        if (player.hurt > 0) drawRect({ pos: vec2(0, 0), width: W, height: H, color: rgb(255, 0, 0), opacity: 0.25 });
        drawMinimap(enemies);
      },
    },
  ]);

  // The minimap shows the truth behind the illusion: a flat grid, and a fan of rays.
  function drawMinimap(enemies) {
    const S = 7;
    const o = vec2(W - MAP[0].length * S - 10, 10);
    for (let y = 0; y < MAP.length; y++)
      for (let x = 0; x < MAP[y].length; x++)
        if (isWall(x, y)) drawRect({ pos: o.add(x * S, y * S), width: S, height: S, color: rgb(...WALL_COLOR[MAP[y][x]]), opacity: 0.7 });
    const p = o.add(player.pos.scale(S));
    for (let i = 0; i < RAYS; i += 20) {
      const camX = (2 * (i + 0.5)) / RAYS - 1;
      const a = player.angle + Math.atan(camX * Math.tan(FOV / 2));
      const len = depth[i] / Math.cos(a - player.angle);
      drawLine({ p1: p, p2: p.add(Math.cos(a) * len * S, Math.sin(a) * len * S), width: 1, color: rgb(255, 230, 120), opacity: 0.6 });
    }
    for (const e of enemies) drawCircle({ pos: o.add(e.pos.scale(S)), radius: 2, color: rgb(230, 70, 70) });
    drawCircle({ pos: p, radius: 2.5, color: rgb(255, 255, 255) });
  }
  // #endregion

  // #region hud
  const hud = add([text("", { size: 16, font: FONT }), pos(12, 10), z(10)]);
  hud.onUpdate(() => {
    hud.text = t({ es: "Salud ", en: "Health " }) + Math.max(0, Math.ceil(player.health)) + "   " + t({ es: "Enemigos ", en: "Enemies " }) + get("enemy").length;
  });
  // #endregion
});

// #region scenes
function screen(title, subtitle) {
  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", () => go("game"));
  onButtonPress("fire", () => go("game"));
  onMousePress(() => go("game"));
}

scene("title", () =>
  screen(
    t({ es: "Disparos en primera persona", en: "First-person shooter" }),
    t({
      es: "Flechas o WASD para girar y avanzar, espacio o clic para disparar.\nEncuentra la puerta verde de salida.\nPulsa espacio o toca para empezar.",
      en: "Arrows or WASD to turn and move, space or click to shoot.\nFind the green exit door.\nPress space or tap to start.",
    }),
  ),
);

scene("end", (won) =>
  screen(
    won ? t({ es: "¡Saliste!", en: "You got out!" }) : t({ es: "Te alcanzaron", en: "They got you" }),
    t({ es: "Pulsa espacio o toca para jugar otra vez.", en: "Press space or tap to play again." }),
  ),
);

go("title");
// #endregion

Retos de diseño

  • Dibujar el mundo en 3D lo bastante rápido. Wolfenstein 3D lo resolvió con raycasting sobre un mapa plano y los motores posteriores con técnicas para descartar lo que no se ve; con menos fotogramas, apuntar se vuelve torpe.
  • Que disparar se sienta bien, con retroceso, sonido, destello, reacción visible del enemigo e impactos que se lean al instante.
  • Que el jugador se oriente viendo solo lo que tiene delante, con niveles que se reconozcan por formas y colores, en vez de depender del mapa.
  • Enemigos que usen el espacio (cubrirse, rodear, avisar antes de atacar) sin volverse injustos para quien no los ve venir.
  • Apuntar con mando es más difícil que con ratón; los juegos de consola añaden asistencia de apuntado y ajustan la velocidad de giro.

Historia

  1. 1973
    OrigenMaze War

    Los orígenes del género se remontan a Maze War (1973) y Spasim (1974), con disparos multijugador en primera persona y gráficos de alambre.

    First-person shooter (Wikipedia)
  2. 1991

    Hovertank, de id Software, usa raycasting para dibujar el 3D más rápido y lograr un juego más fluido.

    First-person shooter (Wikipedia)
  3. 1992
    Wolfenstein 3D

    Wolfenstein 3D sienta las bases del FPS moderno con raycasting, que calcula solo las paredes visibles. Su primer episodio se distribuye gratis como shareware.

    Wolfenstein 3D (Wikipedia)
  4. 1993
    Doom

    Doom suma mejores gráficos y partidas a muerte (deathmatch) multijugador, y guarda sus niveles en archivos WAD que los aficionados pueden modificar. Durante años al género se le llama «clones de Doom».

    Doom (1993 video game) (Wikipedia)
  5. 1996
    Quake

    Quake pasa a un 3D completo en tiempo real, con modelos poligonales, y su versión QuakeWorld hace del juego en línea una de las primeras formas de deporte electrónico.

    Quake (video game) (Wikipedia)
  6. 1997

    GoldenEye 007, para Nintendo 64, demuestra que el género funciona en consola y populariza las partidas a muerte a pantalla dividida de hasta cuatro jugadores.

    GoldenEye 007 (1997 video game) (Wikipedia)
  7. 1998
    Half-Life

    Half-Life cuenta su historia con secuencias guionizadas sin quitarte nunca el control de la cámara, y sus modificaciones de aficionados dan origen a Counter-Strike.

    Half-Life (video game) (Wikipedia)
  8. 2001

    Halo, para Xbox, adapta el apuntado al mando, limita a dos las armas que llevas y añade un escudo que se recarga solo, ideas que copiarán muchos juegos de consola.

    Halo: Combat Evolved (Wikipedia)
  9. 2021
    Q1K3

    Q1K3 mete en 13 kilobytes de JavaScript, para la competición js13kGames, un FPS con dos niveles, cinco tipos de enemigos y tres armas que se juega en el navegador.

    Q1K3 (GitHub, PhobosLab)

Juegos de referencia

1973

Maze War

Steve Colley, Greg Thompson y Howard Palmer (NASA Ames). Imlac PDS-1

Recorres en primera persona un laberinto de líneas y disparas a los otros jugadores, que en las versiones posteriores aparecen como un gran ojo. Lo hicieron tres estudiantes de secundaria en el centro Ames de la NASA, y llegó a jugarse entre universidades por ARPANET.

1992

Wolfenstein 3D

id Software. MS-DOS

Escapas de una fortaleza recorriendo laberintos de pasillos y disparando a los guardias. Sus paredes se dibujan con raycasting sobre un mapa plano de cuadrícula, la misma técnica de la demo, y su primer episodio se distribuyó gratis como shareware.

1993

Doom

id Software. MS-DOS

Te abres paso a tiros por una base llena de demonios, buscando llaves de colores para abrir puertas. Popularizó las partidas a muerte (deathmatch) y guardaba los niveles en archivos WAD que los aficionados podían editar. Su primer episodio era shareware.

1996

Quake

id Software. MS-DOS, Windows

Shooter en primera persona con escenarios y enemigos en 3D poligonal real. Su multijugador en línea, mejorado con QuakeWorld, fue una de las primeras formas de deporte electrónico.

1998

Half-Life

Valve. Windows

Un científico escapa de un laboratorio subterráneo tras un accidente. La historia se cuenta con secuencias guionizadas mientras sigues controlando la cámara, sin escenas cinemáticas, y sus modificaciones de aficionados dieron origen a Counter-Strike.

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.

Ejercicio

En la demo, quita la corrección de ojo de pez (el Math.cos del render) y mira cómo se curvan las paredes. Luego cambia FOV y RAYS y compara la imagen y la fluidez. Por último, redibuja el mapa de texto para que el jugador se oriente sin el minimapa, usando solo formas y colores de pared.

Subgéneros

  • Arena shooterPartidas multijugador frenéticas en mapas cerrados y pequeños, donde todos empiezan en igualdad y ganan quienes se mueven más rápido, controlan las armas del mapa y apuntan mejor.
  • Boomer shooterRecorres 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.

Fuentes