Saltar al contenido
GameGenresEnglish
Acción

Sigilo

Avanzas evitando que te descubran: te escondes en las sombras, estudias las rondas y el campo de visión de los guardias, y prefieres pasar sin ser visto antes que pelear.

También se llama: videojuego de sigilo, juego de sigilo

Mecánicas que lo definen

  • Enemigos con un campo de visión limitado por distancia, ángulo y obstáculos; quedar fuera de él es la forma principal de avanzar.
  • Rutas de patrulla predecibles que el jugador observa para encontrar el momento de pasar.
  • Estados de alerta graduales (tranquilo, sospecha, alerta) que avisan antes de castigar y permiten recuperarse de un error.
  • Escondites y cobertura, como sombras, cajas, esquinas o disfraces, y a menudo luz y ruido como recursos que se pueden manipular.
  • Combate débil o arriesgado a propósito, de modo que evitar el conflicto sea casi siempre la mejor opción.

Bucle de juego

Observar a los guardias, planear una ruta y un momento, moverte de escondite en escondite y, si te ven, esconderte hasta que la alerta baje para volver a intentarlo.

Demo propia

Controles. Flechas o WASD para moverte. En móvil, los botones en pantalla.

Abrir en pantalla completa

Un plano visto desde arriba con cuatro guardias que patrullan. Cada uno dibuja su cono de visión, que las paredes y las cajas recortan, y avisa con «?» cuando sospecha y con «!» cuando te descubre. Llega a la salida verde; la gema es opcional y está justo en la ruta de un guardia.

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

Cómo está hecha

  1. Configuración y constantes

    Se definen los botones virtuales de movimiento y las constantes que más pesan en el diseño. El alcance de la vista, el ancho del cono y el alcance reducido en la sombra deciden cuánto se arriesga el jugador. El jugador es más lento que un guardia alertado, así que esconderse rinde más que huir.

    const k = kaplay({
      width: 640,
      height: 360,
      letterbox: true, pixelDensity,
      background: [58, 64, 84],
      buttons: {
        left: { keyboard: ["left", "a"] },
        right: { keyboard: ["right", "d"] },
        up: { keyboard: ["up", "w"] },
        down: { keyboard: ["down", "s"] },
        start: { keyboard: ["space", "enter"] },
      },
    });
    touchControls(k, [
      { button: "left", label: "◀" },
      { button: "up", label: "▲" },
      { button: "down", label: "▼" },
      { button: "right", label: "▶" },
    ]);
    
    const SPEED = 110; // the player is slower than an alerted guard: hiding beats running
    const VIEW_RANGE = 150; // how far a guard sees
    const SHADOW_RANGE = 45; // how far a guard sees you when you stand in a shadow
    const FOV = Math.PI / 2.6; // width of the vision cone (about 70°)
    const RAYS = 40; // rays used to draw the cone
  2. Paredes, cajas y sombras

    El nivel es una lista de rectángulos. Las paredes y las cajas cumplen dos funciones: frenan el movimiento y tapan la vista. Las sombras no frenan nada, solo hacen que te vean desde mucho más cerca. Esa diferencia entre «cubrirse» (detrás de algo) y «ocultarse» (en la oscuridad) es la base del diseño de niveles de sigilo.

    // Walls and crates are plain rectangles [x, y, w, h]: they block movement AND sight.
    const WALLS = [[0, 0, 640, 12], [0, 348, 640, 12], [0, 0, 12, 360], [628, 0, 12, 360], [130, 12, 14, 120],
      [130, 210, 14, 138], [300, 90, 14, 180], [460, 12, 14, 150], [460, 230, 14, 118]];
    const CRATES = [[240, 100, 30, 30], [380, 60, 30, 30], [390, 280, 30, 30], [540, 170, 30, 30]];
    const BLOCKERS = [...WALLS, ...CRATES];
    // Shadows do not block anything: they just make you harder to see.
    const SHADOWS = [[150, 290, 120, 58], [314, 12, 80, 70], [474, 280, 90, 68]];
    const EXIT = [580, 20, 40, 40];
    
    const inRect = (p, [x, y, w, h]) => p.x >= x && p.x <= x + w && p.y >= y && p.y <= y + h;
    const inShadow = (p) => SHADOWS.some((s) => inRect(p, s));
    
    function buildLevel() {
      for (const [x, y, w, h] of SHADOWS) add([rect(w, h), pos(x, y), color(20, 22, 34), opacity(0.75)]);
      add([rect(EXIT[2], EXIT[3]), pos(EXIT[0], EXIT[1]), color(80, 200, 120)]);
      add([text(t({ es: "Salida", en: "Exit" }), { size: 11, font: FONT }), pos(EXIT[0] + 20, EXIT[1] + 20), anchor("center"), color(20, 60, 30)]);
      for (const [x, y, w, h] of WALLS) add([rect(w, h), pos(x, y), color(150, 156, 180), area(), body({ isStatic: true }), z(2)]);
      for (const [x, y, w, h] of CRATES) add([rect(w, h), pos(x, y), color(170, 120, 70), area(), body({ isStatic: true }), z(2)]);
    }
  3. Cono de visión y línea de visión

    canSee() hace tres pruebas en orden, de la más barata a la más cara. Primero, la distancia: si estás más lejos que el alcance (menor en la sombra), no te ve. Segundo, el ángulo: compara la dirección hacia ti con hacia dónde mira el guardia y, si la diferencia supera la mitad del cono, quedas fuera. Tercero, la línea de visión: lanza un rayo hacia ti y, si choca con una pared o una caja antes de llegar, estás tapado. castRay() mide ese rayo con la «prueba de franjas» (slab test), que calcula en qué tramo del rayo se está dentro de cada rectángulo.

    // Distance along a ray until it hits the first blocker (slab test against each rectangle).
    function castRay(origin, dir, maxDist) {
      let best = maxDist;
      for (const [x, y, w, h] of BLOCKERS) {
        let tMin = 0;
        let tMax = best;
        let hit = true;
        for (const [o, d, lo, hi] of [[origin.x, dir.x, x, x + w], [origin.y, dir.y, y, y + h]]) {
          if (Math.abs(d) < 1e-9) {
            if (o < lo || o > hi) hit = false;
            continue;
          }
          const t1 = (lo - o) / d;
          const t2 = (hi - o) / d;
          tMin = Math.max(tMin, Math.min(t1, t2));
          tMax = Math.min(tMax, Math.max(t1, t2));
          if (tMin > tMax) hit = false;
        }
        if (hit && tMin < best) best = tMin;
      }
      return best;
    }
    
    const angleDiff = (a, b) => Math.atan2(Math.sin(a - b), Math.cos(a - b)); // signed, in -π..π
    // A guard sees you if three tests pass: distance, angle and a clear line between you.
    function canSee(guard, target) {
      const toTarget = target.sub(guard.pos);
      const dist = toTarget.len();
      if (dist > (inShadow(target) ? SHADOW_RANGE : VIEW_RANGE)) return false;
      if (Math.abs(angleDiff(Math.atan2(toTarget.y, toTarget.x), guard.facing)) > FOV / 2) return false;
      return castRay(guard.pos, toTarget.unit(), dist) >= dist - 1;
    }
  4. El jugador

    Se mueve en ocho direcciones a velocidad constante y choca con las paredes gracias a body(). Cuando está en una sombra se oscurece, para que sepas que estás oculto. La gema es un objetivo opcional colocado a propósito en la ruta de un guardia, para que tomarla sea un riesgo elegido.

    const player = add([circle(9), pos(40, 320), color(120, 200, 255), area(), body(), anchor("center"), z(5)]);
    player.onUpdate(() => {
      const axis = (a, b) => (isButtonDown(b) ? 1 : 0) - (isButtonDown(a) ? 1 : 0);
      const dir = vec2(axis("left", "right"), axis("up", "down"));
      if (dir.len() > 0) player.move(dir.unit().scale(SPEED));
      // In a shadow the player turns dark, so you can tell you are hidden.
      player.color = inShadow(player.pos) ? rgb(50, 90, 130) : rgb(120, 200, 255);
      if (inRect(player.pos, EXIT)) go("end", true, gems, spotted);
    });
    // The optional objective: a gem right on a guard's route.
    add([polygon([vec2(0, -9), vec2(8, 0), vec2(0, 9), vec2(-8, 0)]), pos(400, 200), color(255, 210, 70), area(), "gem"]);
    player.onCollide("gem", (gem) => {
      destroy(gem);
      gems++;
      updateHud();
    });
  5. Rutas de patrulla

    Cada guardia recorre una lista de puntos (waypoints) y se detiene un segundo en cada uno mientras gira para mirar alrededor. Su dirección de vista no salta, sino que gira poco a poco hacia donde camina. Las rutas fijas y predecibles son una decisión de diseño, porque el jugador las estudia y planifica cuándo pasar.

    // Each guard walks a route of waypoints, pausing at each one to look around.
    const ROUTES = [
      [vec2(70, 170), vec2(270, 170)],
      [vec2(220, 60), vec2(220, 250), vec2(270, 250)],
      [vec2(380, 140), vec2(380, 240), vec2(430, 240)],
      [vec2(520, 100), vec2(600, 100), vec2(600, 250), vec2(520, 250)],
    ];
    const guards = ROUTES.map((route) =>
      add([
        circle(10), pos(route[0]), color(230, 90, 80), area(), body(), anchor("center"), z(5),
        { route, next: 1, pause: 0, facing: 0, state: "patrol", meter: 0, lastSeen: null, lostFor: 0 },
      ]),
    );
    
    function walkTo(g, target, speed) {
      const toTarget = target.sub(g.pos);
      if (toTarget.len() < 3) return true;
      turnTowards(g, Math.atan2(toTarget.y, toTarget.x));
      g.move(toTarget.unit().scale(speed));
      return false;
    }
    function turnTowards(g, angle) {
      const step = 4 * dt();
      const diff = angleDiff(angle, g.facing);
      g.facing += Math.abs(diff) < step ? diff : Math.sign(diff) * step;
    }
  6. Máquina de estados: patrulla, sospecha y alerta

    Cada guardia está siempre en uno de tres estados, y cada estado tiene su propia conducta. Mientras te ve, se llena un medidor de sospecha, más rápido cuanto más cerca estás; si deja de verte, se vacía. En patrulla sigue su ruta. Al empezar a llenarse el medidor pasa a sospecha («?»): se detiene, camina despacio hacia el último lugar donde te vio y mira alrededor. Si el medidor se llena, entra en alerta («!») y te persigue corriendo. Tras tres segundos sin verte vuelve a sospecha y, cuando el medidor se vacía, a patrulla. Este margen intermedio es lo que hace justo al género, porque te da tiempo de corregir un error antes de que te atrapen.

    // State machine: patrol → suspicious (the meter fills while it sees you) → alert.
    // Losing sight makes the meter drain, and the guard steps back down.
    for (const g of guards) {
      g.onUpdate(() => {
        const sees = canSee(g, player.pos);
        // Bumping into a guard always gives you away.
        if (g.pos.dist(player.pos) < 24) Object.assign(g, { meter: 1, lastSeen: player.pos.clone() });
        if (sees) {
          g.lastSeen = player.pos.clone();
          const closeness = 1 - g.pos.dist(player.pos) / VIEW_RANGE;
          g.meter = Math.min(1, g.meter + dt() * (0.6 + 1.6 * closeness));
        } else if (g.state !== "alert") g.meter = Math.max(0, g.meter - dt() * 0.3);
        if (g.state === "patrol" && g.meter > 0.05) g.state = "suspicious";
        else if (g.state === "suspicious" && g.meter >= 1) {
          g.state = "alert";
          spotted++;
          updateHud();
        } else if (g.state === "suspicious" && g.meter === 0) g.state = "patrol";
        else if (g.state === "alert") {
          g.lostFor = sees ? 0 : g.lostFor + dt();
          if (g.lostFor > 3) Object.assign(g, { state: "suspicious", meter: 0.6, lostFor: 0 });
        }
    
        // Each state has its own behaviour.
        if (g.state === "patrol") {
          if (g.pause > 0) {
            g.pause -= dt();
            g.facing += dt() * 1.2; // look around while waiting
          } else if (walkTo(g, g.route[g.next], 60)) {
            g.next = (g.next + 1) % g.route.length;
            g.pause = 1;
          }
        } else if (g.state === "suspicious") {
          // Go and check the last place it saw you, then look around there.
          if (walkTo(g, g.lastSeen, 35)) g.facing += dt() * 1.5;
        } else {
          walkTo(g, sees ? player.pos : g.lastSeen, 150);
          if (g.pos.dist(player.pos) < 24) go("end", false, gems, spotted);
        }
      });
    }
  7. Dibujar el cono

    El cono se dibuja como un abanico de 40 rayos repartidos en el ángulo de visión, y cada uno se corta donde choca con la primera pared. Así el dibujo coincide exactamente con lo que el guardia puede ver, y las sombras que proyectan las cajas muestran dónde estás a salvo. Su color cambia con el estado, y sobre el guardia se ven el signo y el medidor de sospecha.

    // The cone is a fan of rays, each one cut short by the first wall it hits.
    const CONE_COLORS = { patrol: rgb(255, 255, 200), suspicious: rgb(255, 200, 60), alert: rgb(255, 70, 60) };
    add([
      z(3),
      {
        draw() {
          for (const g of guards) {
            const pts = [g.pos];
            for (let i = 0; i <= RAYS; i++) {
              const a = g.facing - FOV / 2 + (FOV * i) / RAYS;
              const dir = vec2(Math.cos(a), Math.sin(a));
              pts.push(g.pos.add(dir.scale(castRay(g.pos, dir, VIEW_RANGE))));
            }
            drawPolygon({ pts, color: CONE_COLORS[g.state], opacity: 0.25 });
            // State indicator: "?" plus the suspicion meter, or "!" when alert.
            if (g.state !== "patrol") {
              drawText({ text: g.state === "alert" ? "!" : "?", pos: g.pos.add(-5, -36), size: 18, font: FONT, color: CONE_COLORS[g.state] });
              drawRect({ pos: g.pos.add(-12, -16), width: Math.max(1, 24 * g.meter), height: 4, color: CONE_COLORS[g.state] });
            }
          }
        },
      },
    ]);
  8. Escenas

    Tres escenas (title, game y end) con go(). El final dice cuántas veces te vieron, porque en el sigilo pasar sin ser visto vale más que solo llegar.

    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: 15, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(210, 215, 240)]);
      onButtonPress("start", next);
      onClick(next);
    }
    
    scene("title", () =>
      screen(
        t({ es: "Sigilo", en: "Stealth" }),
        t({
          es: "Flechas o WASD para moverte. Llega a la salida verde sin que te atrapen; la gema es opcional.\nEscóndete en las sombras y detrás de las cajas. «?» = sospecha, «!» = alerta.\nPulsa espacio o toca para empezar.",
          en: "Arrows or WASD to move. Reach the green exit without being caught; the gem is optional.\nHide in shadows and behind crates. «?» = suspicious, «!» = alert.\nPress space or tap to start.",
        }),
        () => go("game"),
      ),
    );
    
    scene("end", (won, gems, spotted) =>
      screen(
        won ? t({ es: "¡Escapaste!", en: "You escaped!" }) : t({ es: "Te atraparon", en: "You were caught" }),
        t({
          es: `Gema: ${gems}/1 · Veces que te vieron: ${spotted}. ${spotted === 0 && won ? "¡Sin ser visto!" : ""}\nPulsa espacio para repetir.`,
          en: `Gem: ${gems}/1 · Times spotted: ${spotted}. ${spotted === 0 && won ? "Unseen!" : ""}\nPress space to play again.`,
        }),
        () => go("game"),
      ),
    );
    
    go("title");

Qué simplificamos

  • Los guardias solo ven; no oyen pasos ni ruidos, que en muchos juegos del género son tan importantes como la vista.
  • La luz es binaria, con zonas de sombra o sin ella; no hay un medidor de visibilidad gradual ni luces que se puedan apagar.
  • No puedes neutralizar a los guardias ni distraerlos; solo esconderte y esperar el momento.
  • Un guardia alertado corre en línea recta hacia ti, sin buscar caminos alrededor de las paredes.

Para modificarla

  • Añade ruido. Si el jugador corre (mantén Shift), los guardias cercanos que no te ven pasan a sospecha y miran hacia ti.
  • Cambia FOV y VIEW_RANGE y prueba si el nivel sigue siendo superable. Anota qué valores lo hacen demasiado fácil o injusto.
  • Haz que un guardia en alerta avise a los demás guardias que estén cerca y los pase también a sospecha.
  • Sustituye las sombras fijas por una lámpara que el jugador pueda apagar.
Código completo
// Stealth 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: [58, 64, 84],
  buttons: {
    left: { keyboard: ["left", "a"] },
    right: { keyboard: ["right", "d"] },
    up: { keyboard: ["up", "w"] },
    down: { keyboard: ["down", "s"] },
    start: { keyboard: ["space", "enter"] },
  },
});
touchControls(k, [
  { button: "left", label: "◀" },
  { button: "up", label: "▲" },
  { button: "down", label: "▼" },
  { button: "right", label: "▶" },
]);

const SPEED = 110; // the player is slower than an alerted guard: hiding beats running
const VIEW_RANGE = 150; // how far a guard sees
const SHADOW_RANGE = 45; // how far a guard sees you when you stand in a shadow
const FOV = Math.PI / 2.6; // width of the vision cone (about 70°)
const RAYS = 40; // rays used to draw the cone
// #endregion

// #region level
// Walls and crates are plain rectangles [x, y, w, h]: they block movement AND sight.
const WALLS = [[0, 0, 640, 12], [0, 348, 640, 12], [0, 0, 12, 360], [628, 0, 12, 360], [130, 12, 14, 120],
  [130, 210, 14, 138], [300, 90, 14, 180], [460, 12, 14, 150], [460, 230, 14, 118]];
const CRATES = [[240, 100, 30, 30], [380, 60, 30, 30], [390, 280, 30, 30], [540, 170, 30, 30]];
const BLOCKERS = [...WALLS, ...CRATES];
// Shadows do not block anything: they just make you harder to see.
const SHADOWS = [[150, 290, 120, 58], [314, 12, 80, 70], [474, 280, 90, 68]];
const EXIT = [580, 20, 40, 40];

const inRect = (p, [x, y, w, h]) => p.x >= x && p.x <= x + w && p.y >= y && p.y <= y + h;
const inShadow = (p) => SHADOWS.some((s) => inRect(p, s));

function buildLevel() {
  for (const [x, y, w, h] of SHADOWS) add([rect(w, h), pos(x, y), color(20, 22, 34), opacity(0.75)]);
  add([rect(EXIT[2], EXIT[3]), pos(EXIT[0], EXIT[1]), color(80, 200, 120)]);
  add([text(t({ es: "Salida", en: "Exit" }), { size: 11, font: FONT }), pos(EXIT[0] + 20, EXIT[1] + 20), anchor("center"), color(20, 60, 30)]);
  for (const [x, y, w, h] of WALLS) add([rect(w, h), pos(x, y), color(150, 156, 180), area(), body({ isStatic: true }), z(2)]);
  for (const [x, y, w, h] of CRATES) add([rect(w, h), pos(x, y), color(170, 120, 70), area(), body({ isStatic: true }), z(2)]);
}
// #endregion

// #region line-of-sight
// Distance along a ray until it hits the first blocker (slab test against each rectangle).
function castRay(origin, dir, maxDist) {
  let best = maxDist;
  for (const [x, y, w, h] of BLOCKERS) {
    let tMin = 0;
    let tMax = best;
    let hit = true;
    for (const [o, d, lo, hi] of [[origin.x, dir.x, x, x + w], [origin.y, dir.y, y, y + h]]) {
      if (Math.abs(d) < 1e-9) {
        if (o < lo || o > hi) hit = false;
        continue;
      }
      const t1 = (lo - o) / d;
      const t2 = (hi - o) / d;
      tMin = Math.max(tMin, Math.min(t1, t2));
      tMax = Math.min(tMax, Math.max(t1, t2));
      if (tMin > tMax) hit = false;
    }
    if (hit && tMin < best) best = tMin;
  }
  return best;
}

const angleDiff = (a, b) => Math.atan2(Math.sin(a - b), Math.cos(a - b)); // signed, in -π..π
// A guard sees you if three tests pass: distance, angle and a clear line between you.
function canSee(guard, target) {
  const toTarget = target.sub(guard.pos);
  const dist = toTarget.len();
  if (dist > (inShadow(target) ? SHADOW_RANGE : VIEW_RANGE)) return false;
  if (Math.abs(angleDiff(Math.atan2(toTarget.y, toTarget.x), guard.facing)) > FOV / 2) return false;
  return castRay(guard.pos, toTarget.unit(), dist) >= dist - 1;
}

// #endregion

scene("game", () => {
  buildLevel();
  let gems = 0;
  let spotted = 0;
  const hud = add([text("", { size: 14, font: FONT }), pos(20, 18), z(10)]);
  const updateHud = () =>
    (hud.text = t({ es: `Gema: ${gems}/1 · Veces que te vieron: ${spotted}`, en: `Gem: ${gems}/1 · Times spotted: ${spotted}` }));
  updateHud();
  // #region player
  const player = add([circle(9), pos(40, 320), color(120, 200, 255), area(), body(), anchor("center"), z(5)]);
  player.onUpdate(() => {
    const axis = (a, b) => (isButtonDown(b) ? 1 : 0) - (isButtonDown(a) ? 1 : 0);
    const dir = vec2(axis("left", "right"), axis("up", "down"));
    if (dir.len() > 0) player.move(dir.unit().scale(SPEED));
    // In a shadow the player turns dark, so you can tell you are hidden.
    player.color = inShadow(player.pos) ? rgb(50, 90, 130) : rgb(120, 200, 255);
    if (inRect(player.pos, EXIT)) go("end", true, gems, spotted);
  });
  // The optional objective: a gem right on a guard's route.
  add([polygon([vec2(0, -9), vec2(8, 0), vec2(0, 9), vec2(-8, 0)]), pos(400, 200), color(255, 210, 70), area(), "gem"]);
  player.onCollide("gem", (gem) => {
    destroy(gem);
    gems++;
    updateHud();
  });
  // #endregion

  // #region guards
  // Each guard walks a route of waypoints, pausing at each one to look around.
  const ROUTES = [
    [vec2(70, 170), vec2(270, 170)],
    [vec2(220, 60), vec2(220, 250), vec2(270, 250)],
    [vec2(380, 140), vec2(380, 240), vec2(430, 240)],
    [vec2(520, 100), vec2(600, 100), vec2(600, 250), vec2(520, 250)],
  ];
  const guards = ROUTES.map((route) =>
    add([
      circle(10), pos(route[0]), color(230, 90, 80), area(), body(), anchor("center"), z(5),
      { route, next: 1, pause: 0, facing: 0, state: "patrol", meter: 0, lastSeen: null, lostFor: 0 },
    ]),
  );

  function walkTo(g, target, speed) {
    const toTarget = target.sub(g.pos);
    if (toTarget.len() < 3) return true;
    turnTowards(g, Math.atan2(toTarget.y, toTarget.x));
    g.move(toTarget.unit().scale(speed));
    return false;
  }
  function turnTowards(g, angle) {
    const step = 4 * dt();
    const diff = angleDiff(angle, g.facing);
    g.facing += Math.abs(diff) < step ? diff : Math.sign(diff) * step;
  }
  // #endregion

  // #region states
  // State machine: patrol → suspicious (the meter fills while it sees you) → alert.
  // Losing sight makes the meter drain, and the guard steps back down.
  for (const g of guards) {
    g.onUpdate(() => {
      const sees = canSee(g, player.pos);
      // Bumping into a guard always gives you away.
      if (g.pos.dist(player.pos) < 24) Object.assign(g, { meter: 1, lastSeen: player.pos.clone() });
      if (sees) {
        g.lastSeen = player.pos.clone();
        const closeness = 1 - g.pos.dist(player.pos) / VIEW_RANGE;
        g.meter = Math.min(1, g.meter + dt() * (0.6 + 1.6 * closeness));
      } else if (g.state !== "alert") g.meter = Math.max(0, g.meter - dt() * 0.3);
      if (g.state === "patrol" && g.meter > 0.05) g.state = "suspicious";
      else if (g.state === "suspicious" && g.meter >= 1) {
        g.state = "alert";
        spotted++;
        updateHud();
      } else if (g.state === "suspicious" && g.meter === 0) g.state = "patrol";
      else if (g.state === "alert") {
        g.lostFor = sees ? 0 : g.lostFor + dt();
        if (g.lostFor > 3) Object.assign(g, { state: "suspicious", meter: 0.6, lostFor: 0 });
      }

      // Each state has its own behaviour.
      if (g.state === "patrol") {
        if (g.pause > 0) {
          g.pause -= dt();
          g.facing += dt() * 1.2; // look around while waiting
        } else if (walkTo(g, g.route[g.next], 60)) {
          g.next = (g.next + 1) % g.route.length;
          g.pause = 1;
        }
      } else if (g.state === "suspicious") {
        // Go and check the last place it saw you, then look around there.
        if (walkTo(g, g.lastSeen, 35)) g.facing += dt() * 1.5;
      } else {
        walkTo(g, sees ? player.pos : g.lastSeen, 150);
        if (g.pos.dist(player.pos) < 24) go("end", false, gems, spotted);
      }
    });
  }
  // #endregion

  // #region cone
  // The cone is a fan of rays, each one cut short by the first wall it hits.
  const CONE_COLORS = { patrol: rgb(255, 255, 200), suspicious: rgb(255, 200, 60), alert: rgb(255, 70, 60) };
  add([
    z(3),
    {
      draw() {
        for (const g of guards) {
          const pts = [g.pos];
          for (let i = 0; i <= RAYS; i++) {
            const a = g.facing - FOV / 2 + (FOV * i) / RAYS;
            const dir = vec2(Math.cos(a), Math.sin(a));
            pts.push(g.pos.add(dir.scale(castRay(g.pos, dir, VIEW_RANGE))));
          }
          drawPolygon({ pts, color: CONE_COLORS[g.state], opacity: 0.25 });
          // State indicator: "?" plus the suspicion meter, or "!" when alert.
          if (g.state !== "patrol") {
            drawText({ text: g.state === "alert" ? "!" : "?", pos: g.pos.add(-5, -36), size: 18, font: FONT, color: CONE_COLORS[g.state] });
            drawRect({ pos: g.pos.add(-12, -16), width: Math.max(1, 24 * g.meter), height: 4, color: CONE_COLORS[g.state] });
          }
        }
      },
    },
  ]);
  // #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: 15, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(210, 215, 240)]);
  onButtonPress("start", next);
  onClick(next);
}

scene("title", () =>
  screen(
    t({ es: "Sigilo", en: "Stealth" }),
    t({
      es: "Flechas o WASD para moverte. Llega a la salida verde sin que te atrapen; la gema es opcional.\nEscóndete en las sombras y detrás de las cajas. «?» = sospecha, «!» = alerta.\nPulsa espacio o toca para empezar.",
      en: "Arrows or WASD to move. Reach the green exit without being caught; the gem is optional.\nHide in shadows and behind crates. «?» = suspicious, «!» = alert.\nPress space or tap to start.",
    }),
    () => go("game"),
  ),
);

scene("end", (won, gems, spotted) =>
  screen(
    won ? t({ es: "¡Escapaste!", en: "You escaped!" }) : t({ es: "Te atraparon", en: "You were caught" }),
    t({
      es: `Gema: ${gems}/1 · Veces que te vieron: ${spotted}. ${spotted === 0 && won ? "¡Sin ser visto!" : ""}\nPulsa espacio para repetir.`,
      en: `Gem: ${gems}/1 · Times spotted: ${spotted}. ${spotted === 0 && won ? "Unseen!" : ""}\nPress space to play again.`,
    }),
    () => go("game"),
  ),
);

go("title");
// #endregion

Retos de diseño

  • Que el jugador entienda siempre por qué lo vieron. Conos, medidores de luz e indicadores de estado hacen visible lo que el guardia percibe.
  • Ajustar la detección para que sea gradual y justa, con un margen de sospecha en lugar de una derrota instantánea al primer contacto visual.
  • Diseñar niveles con varias rutas, coberturas y puntos desde donde observar antes de arriesgarse.
  • Una inteligencia artificial de guardias legible y coherente, que el jugador pueda predecir y engañar.
  • Que fallar no obligue a repetir demasiado; puntos de control frecuentes o alertas recuperables mantienen la tensión sin frustrar.

Historia

  1. 1981
    Origen005

    005 (Sega, 1981), en el que llevas documentos secretos evitando las linternas enemigas, tiene el récord Guinness de primer juego de sigilo y fue el primero en mostrar el «cono de visión» del enemigo. Retro Gamer apunta a Manbiki Shounen (1979) como antecedente.

    Stealth game (Wikipedia)
  2. 1981
    Castle Wolfenstein

    Castle Wolfenstein es uno de los primeros juegos basados en el sigilo; puedes disfrazarte con uniformes enemigos para pasar junto a los guardias.

    Castle Wolfenstein (Wikipedia)
  3. 1987
    Metal Gear

    Metal Gear, de Hideo Kojima, es el primer juego de sigilo de éxito en consolas: empiezas sin armas y debes evitar el combate.

    Stealth game (Wikipedia)
  4. 1998
    Tenchu: Stealth Assassins

    Tenchu se considera el primer juego de sigilo en 3D, con un indicador que muestra la distancia y el nivel de alerta de los enemigos cercanos.

    Stealth game (Wikipedia)
  5. 1998
    Metal Gear Solid

    Metal Gear Solid populariza el género. Su radar muestra el campo de visión de los guardias y la partida pasa por modos de alerta, evasión e infiltración.

    Metal Gear Solid (Wikipedia)
  6. 1998
    Thief: The Dark Project

    Thief: The Dark Project lleva el sigilo a la primera persona. Un indicador en forma de gema dice cuánto te ven, y el ruido de tus pasos depende del suelo.

    Thief: The Dark Project (Wikipedia)
  7. 2002
    Tom Clancy's Splinter Cell

    Splinter Cell hace de la luz el centro del juego, con un medidor de visibilidad y la posibilidad de romper las luces para crear sombras.

    Tom Clancy's Splinter Cell (Wikipedia)
  8. 2012
    Mark of the Ninja

    Mark of the Ninja lleva el sigilo a 2D con vista lateral y dibuja en pantalla lo que ven los guardias y hasta dónde llega el ruido.

    Stealth game (Wikipedia)

Juegos de referencia

1981

005

Sega. arcade

Llevas un maletín con documentos secretos hasta un helicóptero cruzando edificios y almacenes, sin entrar en el haz de las linternas de los enemigos y escondiéndote detrás de cajas. Es de los primeros juegos que muestran el campo de visión del enemigo como un cono.

1981

Castle Wolfenstein

Silas Warner (Muse Software). Apple II, Atari 8-bit, Commodore 64, MS-DOS

Escapas de un castillo habitación por habitación buscando planos secretos. Puedes pelear, pero también disfrazarte con un uniforme enemigo para pasar junto a los guardias sin que te detengan.

1987

Metal Gear

Konami (Hideo Kojima). MSX2, NES

Te infiltras solo y sin armas en una base enemiga, vista desde arriba. Los guardias siguen rondas fijas y la meta es evitar que te vean y no pelear; si te descubren, entran en alerta y pueden llegar refuerzos.

1998

Metal Gear Solid

Konami (Hideo Kojima). PlayStation

Te infiltras en una instalación vigilada esquivando cámaras y patrullas, pegado a las paredes y usando un radar que muestra el campo de visión de los guardias. Llevó el sigilo de la saga a las 3D con una historia contada como una película.

1998

Tenchu: Stealth Assassins

Acquire. PlayStation

Guías a un ninja por misiones en escenarios 3D con el objetivo de no ser detectado. Un indicador muestra a qué distancia están los enemigos y su nivel de alerta, y si te acercas a uno sin que te vea lo eliminas al instante.

1998

Thief: The Dark Project

Looking Glass Studios. Windows

Eres un ladrón que roba en una ciudad oscura viendo el mundo en primera persona. Un indicador te dice cuánta luz te alumbra y los guardias oyen tus pasos según el suelo que pisas, así que la luz y el sonido son tus herramientas principales.

2002

Tom Clancy's Splinter Cell

Ubi Soft Montreal. Xbox, Windows, PlayStation 2, GameCube

Un agente se mueve entre las sombras con gafas de visión nocturna y puede romper las luces para oscurecer el camino. Un medidor muestra cuánta luz le da, y ser descubierto suele hacer sonar alarmas que complican la misión.

2012

Mark of the Ninja

Klei Entertainment. Xbox 360, Windows, macOS, Linux

Sigilo en 2D de vista lateral. Dibuja en pantalla lo que normalmente es invisible, como hasta dónde llega el ruido de tus pasos y qué zonas ven los guardias, para que planifiques con información completa.

2017

The Rescue

jeremyjackson89. navegador

Juego de sigilo corto en el que un agente se infiltra en una base enemiga para encontrar a un compañero desaparecido. Se hizo para js13kGames 2017, así que todo el juego cabe en 13 kilobytes, y su autor lo publicó gratis y jugable en el navegador.

Ejercicio

Juega la demo dos veces, una intentando que no te vean nunca y otra tomando la gema. Después cambia el ancho del cono (FOV) y el alcance en la sombra, y discute cuándo el nivel se vuelve injusto. Por último, dibuja en papel un nivel nuevo con al menos dos rutas posibles y marca los puntos desde donde el jugador puede observar a salvo.

Fuentes