
Laberintos
Te mueves por un laberinto, casi siempre visto desde arriba, y el reto es elegir bien tus rutas por sus pasillos para recoger objetos, encontrar la salida o escapar de quienes te persiguen.
También se llama: juego de laberintos, laberinto, persecución en laberinto
Mecánicas que lo definen
- Movimiento por pasillos en una rejilla; las decisiones importantes se toman en los cruces.
- Objetos repartidos por todo el laberinto (puntos, tesoros) que obligan a recorrerlo entero.
- Perseguidores con reglas de movimiento simples y distintas entre sí, que el jugador aprende a predecir.
- Potenciadores que invierten la persecución por un tiempo y cambian quién caza a quién.
Bucle de juego
Leer dónde están los perseguidores, elegir una ruta que recoja lo más posible con poco riesgo, recorrerla y corregirla en cada cruce.
Demo propia
Controles. Flechas o WASD para girar; puedes pedir el giro antes de llegar al cruce. Con ratón o tacto, toca hacia dónde quieres ir respecto del jugador, o usa los botones en pantalla.
Abrir en pantalla completaUn laberinto de rejilla con puntos que recoger, tres perseguidores con comportamientos distintos y cuatro potenciadores que invierten la persecución durante unos segundos. Lo justo para sentir lo que define al género, que es elegir rutas bajo presión.
Hecha con Kaplay, en unas 195 líneas de JavaScript.
Cómo está hecha
Configuración y controles
Se definen botones virtuales para las cuatro direcciones, así el mismo código sirve con teclado o con pantalla táctil. Las velocidades del jugador, de los perseguidores y del modo asustado, y la duración del potenciador son los números que fijan la dificultad.
const k = kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [12, 12, 30], // Virtual buttons: the game asks about "up", not about a specific key. 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: "right", label: "▶" }, { button: "up", label: "▲", side: "right" }, { button: "down", label: "▼" }, ]); const TILE = 18; const PLAYER_SPEED = 7; // cells per second const GHOST_SPEED = 6; const FRIGHTENED_SPEED = 3.5; const POWER_TIME = 6; // seconds const DIRECTIONS = { left: vec2(-1, 0), right: vec2(1, 0), up: vec2(0, -1), down: vec2(0, 1) };El laberinto como texto
El laberinto es una lista de cadenas; cada carácter es un muro, un punto, un potenciador o la casilla de salida de un personaje. isFree() responde si una casilla se puede pisar y cellToScreen() la convierte en píxeles. Toda la lógica trabaja en casillas, no en píxeles.
// The maze is a text drawing: # wall · . dot · o power-up // P player · R, S, N the three chasers (red, pink and orange). const MAP = [ "#####################", "#.........#.........#", "#o###.###.#.###.###o#", "#...................#", "#.###.#.#####.#.###.#", "#.....#...#...#.....#", "#####.###.#.###.#####", "#.........R.........#", "#.###.###.#.###.###.#", "#...#.....S.....#...#", "###.#.#.#####.#.#.###", "#.....#...N...#.....#", "#.#######.#.#######.#", "#.........P.........#", "#.###.###.#.###.###.#", "#o..#...........#..o#", "#.#.#.###.#.###.#.#.#", "#...................#", "#####################", ]; const X0 = (640 - MAP[0].length * TILE) / 2; const Y0 = (360 - MAP.length * TILE) / 2; const isFree = (c) => MAP[c.y]?.[c.x] !== undefined && MAP[c.y][c.x] !== "#"; const cellToScreen = (c) => vec2(X0 + c.x * TILE + TILE / 2, Y0 + c.y * TILE + TILE / 2); const findCell = (char) => { const y = MAP.findIndex((row) => row.includes(char)); return vec2(MAP[y].indexOf(char), y); };Moverse por la rejilla
Jugador y perseguidores se mueven igual, de centro de casilla a centro de casilla, con una interpolación para que se vea suave. Solo al llegar a una casilla se decide la dirección siguiente, así que las decisiones ocurren en los cruces, que es donde el género pone el interés.
// Everyone moves the same way: from cell centre to cell centre. On reaching a // cell, `choose` picks the next direction. So movement reads as a grid and // decisions only happen at junctions. function makeActor(home, comps) { const a = add([...comps, pos(cellToScreen(home)), anchor("center")]); Object.assign(a, { home, cell: home, next: home, dir: vec2(0), progress: 1 }); return a; } function step(a, speed, chooseDir) { a.progress += dt() * speed; if (a.progress >= 1) { a.cell = a.next; a.arrive?.(); a.dir = chooseDir(a); const ahead = a.cell.add(a.dir); if (isFree(ahead)) (a.next = ahead), (a.progress = 0); else (a.dir = vec2(0)), (a.progress = 1); } a.pos = cellToScreen(a.cell).lerp(cellToScreen(a.next), Math.min(a.progress, 1)); }Un control que perdona
El jugador recuerda la última dirección pedida y la toma en cuanto el pasillo lo permite, de modo que puedes girar un poco antes de llegar al cruce. Darse la vuelta es inmediato. Sin estos dos detalles, escapar en un pasillo se siente injusto.
// The player remembers the last direction asked for and takes it as soon as // the corridor allows: turning "before" reaching the junction makes the // controls feel precise. Turning around is instant. const player = makeActor(findCell("P"), [circle(7), color(255, 225, 60)]); let wanted = vec2(0); const request = (d) => { wanted = d; if (d.eq(player.dir.scale(-1))) { [player.cell, player.next] = [player.next, player.cell]; player.dir = d; player.progress = 1 - player.progress; } }; for (const [button, d] of Object.entries(DIRECTIONS)) onButtonPress(button, () => request(d)); // With a mouse or touch: tap where you want to go relative to the player. onMousePress(() => { const d = mousePos().sub(player.pos); request(Math.abs(d.x) > Math.abs(d.y) ? vec2(Math.sign(d.x), 0) : vec2(0, Math.sign(d.y))); }); const choosePlayerDir = (a) => (isFree(a.cell.add(wanted)) ? wanted : a.dir);Puntos y potenciadores
Cada punto suma poco y vaciar el laberinto gana la partida. El potenciador invierte los papeles durante unos segundos y obliga a los perseguidores a dar media vuelta, una señal visible de que las reglas cambiaron.
// Each dot is worth little; the power-up changes the rules for a few seconds. player.arrive = () => { const key = `${player.cell.x},${player.cell.y}`; const p = food.get(key); if (!p) return; destroy(p); food.delete(key); score += p.power ? 50 : 10; if (p.power) { power = POWER_TIME; eaten = 0; for (const g of ghosts) g.dir = g.dir.scale(-1); // they turn around: a clear sign something changed } if (food.size === 0) go("end", true, score); };Una IA de persecución legible
En cada cruce, el perseguidor toma, sin dar media vuelta, la salida que más lo acerca a su objetivo. Lo único que cambia entre ellos es el objetivo, el rojo apunta a tu casilla, el rosa a cuatro casillas por delante de ti y el naranja deambula al azar. Son reglas simples que el jugador puede aprender a leer y a aprovechar, que es la idea que Pac-Man hizo famosa.
// The AI is readable: at each junction the chaser takes, without turning // back, the exit that brings it closest to its target. Only the target // changes: red aims at your cell, pink at four cells ahead of you (an // ambush) and orange wanders at random. When frightened, they all flee at random. const ghosts = [ ["R", rgb(240, 60, 60), () => player.cell], ["S", rgb(255, 150, 200), () => player.cell.add(player.dir.scale(4))], ["N", rgb(255, 170, 60), null], ].map(([char, tint, target]) => makeActor(findCell(char), [rect(14, 14, { radius: [7, 7, 2, 2] }), color(tint), { tint, target }]), ); function chooseGhostDir(g) { let exits = Object.values(DIRECTIONS).filter((d) => isFree(g.cell.add(d)) && !d.eq(g.dir.scale(-1))); if (exits.length === 0) exits = [g.dir.scale(-1)]; // dead end: the only option is to go back if (power > 0 || !g.target) return choose(exits); const goal = g.target(); return exits.reduce((best, d) => (g.cell.add(d).dist(goal) < g.cell.add(best).dist(goal) ? d : best)); }Riesgo y recompensa
Tocar a un perseguidor cuesta una vida, salvo que esté asustado. Entonces vale cada vez más (200, 400, 800), lo que tienta a ir a por ellos en lugar de seguir comiendo puntos con calma.
// Risk and reward: touching a chaser costs a life, unless it is // frightened; then it is worth more each time (200, 400, 800). function resetPositions() { for (const a of [player, ...ghosts]) Object.assign(a, { cell: a.home, next: a.home, dir: vec2(0), progress: 1 }); wanted = vec2(0); power = 0; pause = 1; } function checkHits() { for (const g of ghosts) { if (g.pos.dist(player.pos) > TILE * 0.7) continue; if (power > 0) { eaten++; score += 100 * 2 ** eaten; Object.assign(g, { cell: g.home, next: g.home, progress: 1 }); } else if (--lives <= 0) { return go("end", false, score); } else { return resetPositions(); } } }Escenas
El juego tiene tres escenas (title, game y end) y go() salta de una a otra. Así se reinicia la partida sin tener que limpiar nada a mano.
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: 16, font: FONT, align: "center", width: 540 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255), ]); onButtonPress("start", next); onClick(next); } scene("title", () => screen( t({ es: "Laberinto", en: "Maze" }), t({ es: "Cómete todos los puntos sin que te atrapen. Los puntos grandes asustan\na los perseguidores y puedes comértelos. Flechas o toques para girar.\nPulsa espacio o toca para empezar.", en: "Eat every dot without getting caught. The big dots scare the chasers\nand let you eat them. Arrows or taps to turn.\nPress space or tap to start.", }), () => go("game"), ), ); scene("end", (won, score) => screen( won ? t({ es: "¡Laberinto limpio!", en: "Maze cleared!" }) : t({ es: "Te atraparon", en: "You got caught" }), t({ es: `Puntos: ${score}. Pulsa espacio para repetir.`, en: `Score: ${score}. Press space to play again.` }), () => go("game"), ), ); go("title");
Qué simplificamos
- Los perseguidores no alternan entre perseguir y dispersarse por turnos, como en Pac-Man; siempre persiguen, salvo cuando están asustados.
- No hay casa de fantasmas, túneles laterales, frutas ni niveles que aceleren.
- Las formas son círculos y rectángulos, sin animaciones ni sonido.
Para modificarla
- Agrega un cuarto perseguidor que imite al Clyde de Pac-Man, que te persigue si está lejos y se va a una esquina si está cerca.
- Haz que los perseguidores alternen cada pocos segundos entre perseguir y dispersarse hacia su esquina, y observa cómo cambia la sensación de presión.
- Diseña otro laberinto en el mapa de texto con un túnel que conecte los lados izquierdo y derecho.
Código completo
// Maze 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: [12, 12, 30],
// Virtual buttons: the game asks about "up", not about a specific key.
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: "right", label: "▶" },
{ button: "up", label: "▲", side: "right" },
{ button: "down", label: "▼" },
]);
const TILE = 18;
const PLAYER_SPEED = 7; // cells per second
const GHOST_SPEED = 6;
const FRIGHTENED_SPEED = 3.5;
const POWER_TIME = 6; // seconds
const DIRECTIONS = { left: vec2(-1, 0), right: vec2(1, 0), up: vec2(0, -1), down: vec2(0, 1) };
// #endregion
// #region maze
// The maze is a text drawing: # wall · . dot · o power-up
// P player · R, S, N the three chasers (red, pink and orange).
const MAP = [
"#####################",
"#.........#.........#",
"#o###.###.#.###.###o#",
"#...................#",
"#.###.#.#####.#.###.#",
"#.....#...#...#.....#",
"#####.###.#.###.#####",
"#.........R.........#",
"#.###.###.#.###.###.#",
"#...#.....S.....#...#",
"###.#.#.#####.#.#.###",
"#.....#...N...#.....#",
"#.#######.#.#######.#",
"#.........P.........#",
"#.###.###.#.###.###.#",
"#o..#...........#..o#",
"#.#.#.###.#.###.#.#.#",
"#...................#",
"#####################",
];
const X0 = (640 - MAP[0].length * TILE) / 2;
const Y0 = (360 - MAP.length * TILE) / 2;
const isFree = (c) => MAP[c.y]?.[c.x] !== undefined && MAP[c.y][c.x] !== "#";
const cellToScreen = (c) => vec2(X0 + c.x * TILE + TILE / 2, Y0 + c.y * TILE + TILE / 2);
const findCell = (char) => {
const y = MAP.findIndex((row) => row.includes(char));
return vec2(MAP[y].indexOf(char), y);
};
// #endregion
// #region movement
// Everyone moves the same way: from cell centre to cell centre. On reaching a
// cell, `choose` picks the next direction. So movement reads as a grid and
// decisions only happen at junctions.
function makeActor(home, comps) {
const a = add([...comps, pos(cellToScreen(home)), anchor("center")]);
Object.assign(a, { home, cell: home, next: home, dir: vec2(0), progress: 1 });
return a;
}
function step(a, speed, chooseDir) {
a.progress += dt() * speed;
if (a.progress >= 1) {
a.cell = a.next;
a.arrive?.();
a.dir = chooseDir(a);
const ahead = a.cell.add(a.dir);
if (isFree(ahead)) (a.next = ahead), (a.progress = 0);
else (a.dir = vec2(0)), (a.progress = 1);
}
a.pos = cellToScreen(a.cell).lerp(cellToScreen(a.next), Math.min(a.progress, 1));
}
// #endregion
scene("game", () => {
let score = 0;
let lives = 3;
let power = 0; // seconds the chasers stay frightened
let eaten = 0; // ghosts eaten with the same power-up
let pause = 1;
const hud = add([text("", { size: 15, font: FONT, width: 110 }), pos(14, 14)]);
// Walls and dots come from the map. Dots are stored by cell.
const food = new Map();
MAP.forEach((row, y) =>
[...row].forEach((c, x) => {
const p = cellToScreen(vec2(x, y));
if (c === "#") add([rect(TILE, TILE), pos(p), anchor("center"), color(40, 60, 170)]);
if (c === "." || c === "o")
food.set(`${x},${y}`, add([circle(c === "o" ? 6 : 2.5), pos(p), anchor("center"), color(255, 220, 170), { power: c === "o" }]));
}),
);
// #region player
// The player remembers the last direction asked for and takes it as soon as
// the corridor allows: turning "before" reaching the junction makes the
// controls feel precise. Turning around is instant.
const player = makeActor(findCell("P"), [circle(7), color(255, 225, 60)]);
let wanted = vec2(0);
const request = (d) => {
wanted = d;
if (d.eq(player.dir.scale(-1))) {
[player.cell, player.next] = [player.next, player.cell];
player.dir = d;
player.progress = 1 - player.progress;
}
};
for (const [button, d] of Object.entries(DIRECTIONS)) onButtonPress(button, () => request(d));
// With a mouse or touch: tap where you want to go relative to the player.
onMousePress(() => {
const d = mousePos().sub(player.pos);
request(Math.abs(d.x) > Math.abs(d.y) ? vec2(Math.sign(d.x), 0) : vec2(0, Math.sign(d.y)));
});
const choosePlayerDir = (a) => (isFree(a.cell.add(wanted)) ? wanted : a.dir);
// #endregion
// #region eating
// Each dot is worth little; the power-up changes the rules for a few seconds.
player.arrive = () => {
const key = `${player.cell.x},${player.cell.y}`;
const p = food.get(key);
if (!p) return;
destroy(p);
food.delete(key);
score += p.power ? 50 : 10;
if (p.power) {
power = POWER_TIME;
eaten = 0;
for (const g of ghosts) g.dir = g.dir.scale(-1); // they turn around: a clear sign something changed
}
if (food.size === 0) go("end", true, score);
};
// #endregion
// #region chasers
// The AI is readable: at each junction the chaser takes, without turning
// back, the exit that brings it closest to its target. Only the target
// changes: red aims at your cell, pink at four cells ahead of you (an
// ambush) and orange wanders at random. When frightened, they all flee at random.
const ghosts = [
["R", rgb(240, 60, 60), () => player.cell],
["S", rgb(255, 150, 200), () => player.cell.add(player.dir.scale(4))],
["N", rgb(255, 170, 60), null],
].map(([char, tint, target]) =>
makeActor(findCell(char), [rect(14, 14, { radius: [7, 7, 2, 2] }), color(tint), { tint, target }]),
);
function chooseGhostDir(g) {
let exits = Object.values(DIRECTIONS).filter((d) => isFree(g.cell.add(d)) && !d.eq(g.dir.scale(-1)));
if (exits.length === 0) exits = [g.dir.scale(-1)]; // dead end: the only option is to go back
if (power > 0 || !g.target) return choose(exits);
const goal = g.target();
return exits.reduce((best, d) => (g.cell.add(d).dist(goal) < g.cell.add(best).dist(goal) ? d : best));
}
// #endregion
// #region risk
// Risk and reward: touching a chaser costs a life, unless it is
// frightened; then it is worth more each time (200, 400, 800).
function resetPositions() {
for (const a of [player, ...ghosts]) Object.assign(a, { cell: a.home, next: a.home, dir: vec2(0), progress: 1 });
wanted = vec2(0);
power = 0;
pause = 1;
}
function checkHits() {
for (const g of ghosts) {
if (g.pos.dist(player.pos) > TILE * 0.7) continue;
if (power > 0) {
eaten++;
score += 100 * 2 ** eaten;
Object.assign(g, { cell: g.home, next: g.home, progress: 1 });
} else if (--lives <= 0) {
return go("end", false, score);
} else {
return resetPositions();
}
}
}
// #endregion
onUpdate(() => {
hud.text = t({ es: "Puntos\n", en: "Score\n" }) + score + t({ es: "\n\nVidas\n", en: "\n\nLives\n" }) + lives;
if (pause > 0) return void (pause -= dt());
power = Math.max(0, power - dt());
step(player, PLAYER_SPEED, choosePlayerDir);
for (const g of ghosts) {
step(g, power > 0 ? FRIGHTENED_SPEED : GHOST_SPEED, chooseGhostDir);
// Blue while the power lasts; they blink when it is about to run out.
const blink = power < 2 && Math.floor(time() * 6) % 2;
g.color = power > 0 && !blink ? rgb(60, 90, 255) : g.tint;
}
checkHits();
});
});
// #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: 16, font: FONT, align: "center", width: 540 }),
pos(center().add(0, 30)),
anchor("center"),
color(200, 210, 255),
]);
onButtonPress("start", next);
onClick(next);
}
scene("title", () =>
screen(
t({ es: "Laberinto", en: "Maze" }),
t({
es: "Cómete todos los puntos sin que te atrapen. Los puntos grandes asustan\na los perseguidores y puedes comértelos. Flechas o toques para girar.\nPulsa espacio o toca para empezar.",
en: "Eat every dot without getting caught. The big dots scare the chasers\nand let you eat them. Arrows or taps to turn.\nPress space or tap to start.",
}),
() => go("game"),
),
);
scene("end", (won, score) =>
screen(
won ? t({ es: "¡Laberinto limpio!", en: "Maze cleared!" }) : t({ es: "Te atraparon", en: "You got caught" }),
t({ es: `Puntos: ${score}. Pulsa espacio para repetir.`, en: `Score: ${score}. Press space to play again.` }),
() => go("game"),
),
);
go("title");
// #endregionRetos de diseño
- Una IA de persecución que se sienta amenazante pero legible, para que el jugador pueda anticiparla y burlarla en vez de sentir que lo atrapan al azar.
- Diseñar el laberinto con suficientes cruces y rutas de escape para que no haya trampas sin salida, y repartir los potenciadores para crear decisiones de riesgo y recompensa.
- Un control de giro que perdone, que recuerde la dirección pedida y la tome en cuanto el pasillo lo permita.
- Subir la dificultad (velocidad, agresividad de los perseguidores, duración del potenciador) sin cambiar las reglas que el jugador ya aprendió.
Historia
- 1973OrigenGotcha
Antes hubo laberintos en computadoras de investigación, como Mouse in the Maze en los años cincuenta o Maze (1973), en 3D; Gotcha fue el primer juego de laberinto de arcade.
Maze game (Wikipedia) - 1979Head On
Head On es el primer laberinto en el que el objetivo es recoger todos los puntos esquivando a un rival.
Head On (video game) (Wikipedia) - 1980Pac-Man
Pac-Man da a cada fantasma su propia forma de perseguir y añade potenciadores que invierten la caza. Se vuelve el modelo del género.
Pac-Man (Wikipedia) - 19813D Monster Maze
3D Monster Maze lleva el laberinto a la primera persona en una computadora doméstica, con un monstruo que te caza.
3D Monster Maze (Wikipedia) - 1982
En el caso Atari contra North American Philips, la justicia de EE. UU. frena el clon K.C. Munchkin!, pero trata la idea de perseguir en un laberinto como de uso libre. Por eso abundan los juegos de este tipo.
Maze game (Wikipedia) - 2007Pac-Man Championship Edition
Pac-Man Championship Edition renueva la fórmula con límite de tiempo, mitades del laberinto que se regeneran y velocidad creciente.
Pac-Man Championship Edition (Wikipedia) - 2010Pac-Man
Por los 30 años de Pac-Man, Google pone en su portada una versión jugable que sigue disponible en el navegador.
Pac-Man (Wikipedia)
Juegos de referencia
1973
Gotcha
Dos jugadores recorren un laberinto que cambia sin parar; uno persigue y el otro huye. Se considera el primer juego de laberinto de arcade.
1979
Head On
Conduces un coche por carriles concéntricos recogiendo puntos mientras otro coche viene en sentido contrario; solo puedes cambiar de carril en ciertos huecos. Fue el primer laberinto en el que el objetivo es pasar por encima de los puntos.
1980
Pac-Man
Te comes los puntos de un laberinto mientras te persiguen cuatro fantasmas, cada uno con su propia forma de cazarte. Los potenciadores de las esquinas los asustan y te dejan comértelos por un rato.
1981
3D Monster Maze
Buscas en primera persona la salida de un laberinto generado al azar mientras te acecha un tiranosaurio. Eres más rápido que él, pero un callejón sin salida puede ser el final.
2007
Pac-Man Championship Edition
Una vuelta a Pac-Man con límite de tiempo, en la que cada mitad del laberinto se renueva al vaciarla y el juego acelera según sube tu puntuación.
2022
Captain Neat-o in Treasure Maze
Recorres un laberinto recogiendo tesoros y esquivando serpientes guardianas hasta llegar a la salida. Es un juego corto, de unos tres minutos, hecho en PICO-8 para la Toy Box Jam 3.
Ejercicio
Juega la demo y describe con palabras cómo se mueve cada perseguidor. Luego cambia en el código el objetivo del rosa (por ejemplo, ocho casillas por delante en lugar de cuatro) y comenta si la emboscada se vuelve más o menos peligrosa.
Subgéneros
- Persecución en laberintoRecorres un laberinto que cabe en una pantalla recogiendo todos los puntos mientras te persiguen enemigos por los mismos pasillos; ganas leyendo sus rutas y usando los objetos que, por un rato, te dejan darles la vuelta.