
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 completaUn 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
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 coneParedes, 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)]); }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; }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(); });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; }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); } }); }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] }); } } }, }, ]);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");
// #endregionRetos 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
- 1981Origen005
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) - 1981Castle 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) - 1987Metal 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) - 1998Tenchu: 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) - 1998Metal 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) - 1998Thief: 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) - 2002Tom 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) - 2012Mark 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
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
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
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
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
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
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
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
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
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.