
Roguelike
Juego de rol en el que bajas por una mazmorra que se genera de nuevo en cada partida, moviéndote por turnos sobre una cuadrícula. Si tu personaje muere, lo pierdes todo y empiezas otra vez en una mazmorra distinta, así que cada paso exige pensar.
También se llama: videojuego de mazmorras, roguelike tradicional
Mecánicas que lo definen
- Generación procedural de pisos, monstruos y objetos; ninguna partida se repite, así que no sirve memorizar el mapa.
- Muerte permanente, sin cargar partida; morir termina la partida y le da peso a cada decisión.
- Turnos sobre una cuadrícula, porque el mundo espera a que decidas y el reto es pensar, no los reflejos.
- Recursos escasos (comida, pociones, pergaminos) y objetos sin identificar que obligan a experimentar y a arriesgar.
- Nada se conserva entre partidas, salvo lo que aprendes tú. Esa es la diferencia con el roguelite, que guarda alguna mejora permanente y suele mezclar la fórmula con otro género (acción, plataformas, cartas).
Bucle de juego
Explorar el piso, resolver los encuentros, administrar los recursos, encontrar la escalera y bajar; al morir, aprender y empezar una partida distinta.
Demo propia
Controles. Flechas, WASD o HJKL para moverte; choca con un enemigo para atacarlo; espacio para esperar un turno. Con ratón o en móvil, toca el mapa hacia donde quieres dar un paso (sobre ti, esperas).
Abrir en pantalla completaUna mazmorra de salas y pasillos que se genera de nuevo en cada piso, con enemigos que solo se mueven cuando tú te mueves, pociones y una escalera al siguiente piso. Si mueres, la partida se pierde y empiezas en una mazmorra nueva.
Hecha con Kaplay, en unas 174 líneas de JavaScript.
Cómo está hecha
Configuración y controles
El juego es una rejilla de 32 × 16 casillas. Los botones virtuales aceptan flechas, WASD y también HJKL, las teclas de movimiento del Rogue original. Solo sobreviven entre pisos el número de piso, el jugador y la semilla.
// A grid of 32 × 16 tiles of 20 px, with a strip on top (status) and one at the bottom (messages). // No touch buttons: on mobile you tap the map in the direction you want to move. kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [8, 8, 14], buttons: { up: { keyboard: ["up", "w", "k"] }, down: { keyboard: ["down", "s", "j"] }, left: { keyboard: ["left", "a", "h"] }, right: { keyboard: ["right", "d", "l"] }, wait: { keyboard: ["space", "."] }, }, }); const TILE = 20, COLS = 32, ROWS = 16, TOP = 20, SIGHT = 6; const glyph = (c, x, y, color, opacity = 1) => drawText({ text: c, pos: vec2(x * TILE + TILE / 2, TOP + y * TILE + TILE / 2 + 1), size: 18, font: FONT, anchor: "center", color, opacity }); let floor, player, seed; // the only things that survive between floorsGeneración procedural
El mapa empieza lleno de roca. Se prueban hasta 80 salas de tamaño y posición al azar, se descartan las que se tocan con otra y cada sala aceptada se une a la anterior con un pasillo en forma de L, lo que garantiza que todo el piso esté conectado. La escalera va en la última sala, lejos de la primera. Si salen menos de cuatro salas, se genera otra vez.
// Rooms and corridors: random rooms are tried, the ones that overlap or touch are discarded, // and each new room is joined to the previous one with an L-shaped corridor. function generateDungeon() { const map = Array.from({ length: ROWS }, () => Array(COLS).fill("#")); const rooms = []; for (let attempt = 0; attempt < 80 && rooms.length < 7; attempt++) { const w = randi(4, 9), h = randi(3, 6); const x = randi(1, COLS - w - 1), y = randi(1, ROWS - h - 1); const overlaps = rooms.some((r) => x - 2 < r.x + r.w && r.x - 2 < x + w && y - 2 < r.y + r.h && r.y - 2 < y + h); if (overlaps) continue; for (let j = y; j < y + h; j++) for (let i = x; i < x + w; i++) map[j][i] = "."; const center = { x: x + Math.floor(w / 2), y: y + Math.floor(h / 2) }; if (rooms.length) digCorridor(map, rooms[rooms.length - 1].center, center); rooms.push({ x, y, w, h, center }); } if (rooms.length < 4) return generateDungeon(); // too small: try again const last = rooms[rooms.length - 1].center; map[last.y][last.x] = ">"; // the stairs, in the last room return { map, rooms }; } function digCorridor(map, a, b) { let { x, y } = a; const horizontalFirst = chance(0.5); while (x !== b.x || y !== b.y) { if ((horizontalFirst && x !== b.x) || y === b.y) x += Math.sign(b.x - x); else y += Math.sign(b.y - y); if (map[y][x] === "#") map[y][x] = "."; } }Enemigos y pociones
Los enemigos y dos pociones se colocan en casillas libres de salas al azar, nunca en la primera, donde empiezas. Cada piso añade un enemigo y desbloquea tipos más fuertes, así que bajar es arriesgar más.
// Enemies and potions go in random rooms (never the first one, where you start). // Each floor brings more enemies and, from the third one on, ogres. const MONSTERS = [ { letter: "r", name: t({ es: "la rata", en: "the rat" }), hp: 3, damage: [1, 2], color: rgb(200, 170, 120) }, { letter: "g", name: t({ es: "el goblin", en: "the goblin" }), hp: 6, damage: [1, 3], color: rgb(120, 220, 110) }, { letter: "O", name: t({ es: "el ogro", en: "the ogre" }), hp: 11, damage: [2, 5], color: rgb(240, 110, 90) }, ]; function populate(map, rooms) { const enemies = []; const freeSpot = () => { const r = choose(rooms.slice(1)); const p = { x: randi(r.x, r.x + r.w), y: randi(r.y, r.y + r.h) }; return map[p.y][p.x] === "." && !enemies.some((e) => e.x === p.x && e.y === p.y) ? p : freeSpot(); }; for (let n = 0; n < 2 + floor; n++) enemies.push({ ...choose(MONSTERS.slice(0, Math.min(3, floor + 1))), ...freeSpot() }); for (let n = 0; n < 2; n++) { const p = freeSpot(); map[p.y][p.x] = "!"; } return enemies; }Niebla de guerra
Solo ves las casillas cercanas, y el mapa recuerda en tono oscuro lo que ya exploraste. Así la mazmorra nueva es un territorio desconocido y explorar es parte del reto.
// Fog of war: you only see SIGHT tiles away, and the map remembers what you have explored. const seen = Array.from({ length: ROWS }, () => Array(COLS).fill(false)); const visible = (x, y) => Math.hypot(x - player.x, y - player.y) <= SIGHT; const reveal = () => map.forEach((row, y) => row.forEach((_, x) => visible(x, y) && (seen[y][x] = true))); reveal();Combate por choque
No hay botón de ataque; moverte hacia un enemigo es golpearlo. El daño es un número al azar dentro del rango de cada criatura. Es la convención de casi todos los roguelikes y mantiene los controles mínimos.
// Bumping into an enemy attacks it. Damage is a random number within a range. const capitalize = (s) => s[0].toUpperCase() + s.slice(1); function attack(attacker, defender) { const damage = randi(attacker.damage[0], attacker.damage[1] + 1); defender.hp -= damage; if (attacker === player) message += t({ es: `Golpeas a ${defender.name} (${damage}). `, en: `You hit ${defender.name} (${damage}). ` }); else message += t({ es: `${capitalize(attacker.name)} te golpea (${damage}). `, en: `${capitalize(attacker.name)} hits you (${damage}). ` }); if (defender.hp <= 0 && defender !== player) { enemies.splice(enemies.indexOf(defender), 1); message += t({ es: `${capitalize(defender.name)} muere. `, en: `${capitalize(defender.name)} dies. ` }); } }La IA enemiga
Si el enemigo está al lado, ataca; si te ve, da un paso hacia ti por el eje en que estás más lejos (y si está bloqueado, por el otro); si no te ve, espera. Reglas tan simples ya generan situaciones tácticas, como luchar en un pasillo para enfrentar a un solo enemigo a la vez.
// If you are next to it, it attacks; if it sees you, it takes a step towards you; otherwise it waits. function enemyTurn(e) { const dx = player.x - e.x, dy = player.y - e.y; if (Math.abs(dx) + Math.abs(dy) === 1) return attack(e, player); if (!visible(e.x, e.y)) return; const steps = Math.abs(dx) > Math.abs(dy) ? [[Math.sign(dx), 0], [0, Math.sign(dy)]] : [[0, Math.sign(dy)], [Math.sign(dx), 0]]; for (const [sx, sy] of steps) { const x = e.x + sx, y = e.y + sy; if ((sx || sy) && map[y][x] !== "#" && !enemyAt(x, y)) return Object.assign(e, { x, y }); } }El mundo avanza por turnos
Todo ocurre dentro de act, que solo se llama cuando el jugador hace algo. Primero resuelve tu acción (moverte, atacar, beber una poción o bajar), luego mueve a cada enemigo y al final revisa si moriste. Chocar con una pared no gasta turno. Como el juego espera, cada decisión puede pensarse con calma.
// The world only moves when you act: a step, a hit or waiting is one turn, // and then every enemy moves. If you do nothing, nothing happens. function act(dx, dy) { const x = player.x + dx, y = player.y + dy; const enemy = enemyAt(x, y); if (!enemy && map[y][x] === "#") return; // bumping into a wall does not use up the turn message = ""; if (enemy) attack(player, enemy); else Object.assign(player, { x, y }); if (!enemy && map[y][x] === ">") { floor++; // the stairs: a new, harder floor is generated return go("game"); } if (!enemy && map[y][x] === "!") { map[y][x] = "."; player.hp = Math.min(player.maxHp, player.hp + 6); message += t({ es: "Bebes una poción: +6 de vida. ", en: "You drink a potion: +6 health. " }); } player.turns++; for (const e of [...enemies]) enemyTurn(e); reveal(); if (player.hp <= 0) die(); }Teclado, ratón y tacto
Las teclas llaman a act con una dirección. Con el ratón o el dedo se compara el punto tocado con la posición del jugador y se da un paso por el eje con más diferencia, así que el juego se puede jugar entero sin teclado.
// Keyboard: arrows, WASD or HJKL; space waits one turn. // Mouse or touch: a click takes one step towards where you tapped (on yourself, you wait). onButtonPress("up", () => act(0, -1)); onButtonPress("down", () => act(0, 1)); onButtonPress("left", () => act(-1, 0)); onButtonPress("right", () => act(1, 0)); onButtonPress("wait", () => act(0, 0)); onMousePress(() => { const m = mousePos(); const dx = m.x - (player.x * TILE + TILE / 2), dy = m.y - (TOP + player.y * TILE + TILE / 2); if (Math.max(Math.abs(dx), Math.abs(dy)) < TILE / 2) act(0, 0); else if (Math.abs(dx) > Math.abs(dy)) act(Math.sign(dx), 0); else act(0, Math.sign(dy)); });Dibujo de la mazmorra
Cada fotograma se dibujan las casillas exploradas; la roca maciza que no toca ningún suelo se omite para que se lean las salas. Los seres son letras (@ tú, r rata, g goblin, O ogro), un homenaje a la presentación en texto del género. La semilla aparece en pantalla para poder repetir una mazmorra.
// Walls and floor are rectangles; creatures are letters, as in the original Rogue. const touchesFloor = (x, y) => [-1, 0, 1].some((j) => [-1, 0, 1].some((i) => (map[y + j]?.[x + i] ?? "#") !== "#")); onDraw(() => { for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) { const c = map[y][x], near = visible(x, y), op = near ? 1 : 0.4; if (!seen[y][x] || (c === "#" && !touchesFloor(x, y))) continue; // solid rock is not drawn const color = c === "#" ? (near ? rgb(110, 94, 150) : rgb(50, 44, 70)) : near ? rgb(40, 38, 62) : rgb(20, 20, 32); drawRect({ pos: vec2(x * TILE, TOP + y * TILE), width: TILE - 1, height: TILE - 1, color }); if (c === ">") glyph(">", x, y, rgb(255, 214, 90), op); if (c === "!") glyph("!", x, y, rgb(255, 100, 160), op); } for (const e of enemies) if (visible(e.x, e.y)) glyph(e.letter, e.x, e.y, e.color); glyph("@", player.x, player.y, rgb(255, 255, 255)); const hpLabel = t({ es: "Vida", en: "Health" }), turnLabel = t({ es: "Turno", en: "Turn" }); drawText({ text: `${t({ es: "Piso", en: "Floor" })} ${floor} ${hpLabel} ${player.hp}/${player.maxHp} ${turnLabel} ${player.turns} ${t({ es: "Semilla", en: "Seed" })} ${seed}`, pos: vec2(8, 2), size: 15, font: FONT }); drawText({ text: message, pos: vec2(8, TOP + ROWS * TILE + 2), size: 15, font: FONT, color: rgb(200, 210, 255) }); });Muerte permanente
Al morir no hay punto de guardado ni segunda vida. newGame elige una semilla nueva y reinicia el piso y al jugador, así que la siguiente mazmorra será otra. Esto es lo que separa al roguelike del roguelite, que conservaría alguna mejora entre partidas.
// When you die there is no checkpoint and no second life: the run is lost // and the next one starts on floor 1 with a new dungeon (and a new seed). function die() { go("end", floor, player.turns); } function newGame() { seed = randi(1, 100000); randSeed(seed); // the same seed would produce the same dungeon floor = 1; player = { name: t({ es: "tú", en: "you" }), hp: 20, maxHp: 20, damage: [2, 4], turns: 0 }; go("game"); }Escenas
Título, juego y fin (title, game y end). Bajar una escalera vuelve a entrar en la escena del juego, que genera un piso nuevo sin limpiar nada a mano.
function screen(title, subtitle) { add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 50)), 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("wait", newGame); onKeyPress("enter", newGame); onMousePress(newGame); } scene("title", () => screen("Roguelike", t({ es: "Eres la @. Baja por las escaleras > tan hondo como puedas. Choca con un enemigo para atacarlo; ! es una poción.\nEl mundo solo se mueve cuando tú te mueves. Flechas o clic hacia donde quieres ir.\nPulsa espacio o haz clic para empezar.", en: "You are the @. Go down the stairs > as deep as you can. Bump into an enemy to attack it; ! is a potion.\nThe world only moves when you move. Arrows, or click where you want to go.\nPress space or click to start.", })), ); scene("end", (lastFloor, turns) => screen(t({ es: "Has muerto", en: "You died" }), t({ es: `Caíste en el piso ${lastFloor} tras ${turns} turnos. Muerte permanente: esta mazmorra se pierde para siempre.\nPulsa espacio o haz clic para explorar una nueva.`, en: `You fell on floor ${lastFloor} after ${turns} turns. Permadeath: this dungeon is lost forever.\nPress space or click to explore a new one.`, })), ); go("title");
Qué simplificamos
- Solo hay movimiento en cuatro direcciones, tres tipos de enemigo y un tipo de objeto, sin inventario, experiencia ni objetos sin identificar.
- La visión es un círculo alrededor del jugador y no calcula qué tapan las paredes (en un roguelike real se usa un algoritmo de campo de visión).
- Los pasillos en forma de L pueden atravesar otras salas; los generadores reales usan árboles BSP, autómatas celulares u otras técnicas.
Para modificarla
- Fija la semilla con un número en newGame y comprueba que siempre sale la misma mazmorra. ¿Para qué serviría en un torneo o en una clase?
- Convierte la demo en un roguelite. Al morir, guarda en localStorage una mejora permanente (por ejemplo, +2 de vida máxima) y discute cómo cambia la sensación.
- Añade hambre, un contador que baja cada turno y que obliga a no quedarse demasiado tiempo en un piso.
- Permite el movimiento en diagonal y observa cómo cambian los pasillos como lugar seguro para pelear.
Código completo
// Roguelike demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, pixelDensity } from "../common.js";
// #region setup
// A grid of 32 × 16 tiles of 20 px, with a strip on top (status) and one at the bottom (messages).
// No touch buttons: on mobile you tap the map in the direction you want to move.
kaplay({
width: 640, height: 360, letterbox: true, pixelDensity, background: [8, 8, 14],
buttons: {
up: { keyboard: ["up", "w", "k"] },
down: { keyboard: ["down", "s", "j"] },
left: { keyboard: ["left", "a", "h"] },
right: { keyboard: ["right", "d", "l"] },
wait: { keyboard: ["space", "."] },
},
});
const TILE = 20, COLS = 32, ROWS = 16, TOP = 20, SIGHT = 6;
const glyph = (c, x, y, color, opacity = 1) =>
drawText({ text: c, pos: vec2(x * TILE + TILE / 2, TOP + y * TILE + TILE / 2 + 1), size: 18, font: FONT, anchor: "center", color, opacity });
let floor, player, seed; // the only things that survive between floors
// #endregion
// #region generation
// Rooms and corridors: random rooms are tried, the ones that overlap or touch are discarded,
// and each new room is joined to the previous one with an L-shaped corridor.
function generateDungeon() {
const map = Array.from({ length: ROWS }, () => Array(COLS).fill("#"));
const rooms = [];
for (let attempt = 0; attempt < 80 && rooms.length < 7; attempt++) {
const w = randi(4, 9), h = randi(3, 6);
const x = randi(1, COLS - w - 1), y = randi(1, ROWS - h - 1);
const overlaps = rooms.some((r) => x - 2 < r.x + r.w && r.x - 2 < x + w && y - 2 < r.y + r.h && r.y - 2 < y + h);
if (overlaps) continue;
for (let j = y; j < y + h; j++) for (let i = x; i < x + w; i++) map[j][i] = ".";
const center = { x: x + Math.floor(w / 2), y: y + Math.floor(h / 2) };
if (rooms.length) digCorridor(map, rooms[rooms.length - 1].center, center);
rooms.push({ x, y, w, h, center });
}
if (rooms.length < 4) return generateDungeon(); // too small: try again
const last = rooms[rooms.length - 1].center;
map[last.y][last.x] = ">"; // the stairs, in the last room
return { map, rooms };
}
function digCorridor(map, a, b) {
let { x, y } = a;
const horizontalFirst = chance(0.5);
while (x !== b.x || y !== b.y) {
if ((horizontalFirst && x !== b.x) || y === b.y) x += Math.sign(b.x - x);
else y += Math.sign(b.y - y);
if (map[y][x] === "#") map[y][x] = ".";
}
}
// #endregion
// #region populate
// Enemies and potions go in random rooms (never the first one, where you start).
// Each floor brings more enemies and, from the third one on, ogres.
const MONSTERS = [
{ letter: "r", name: t({ es: "la rata", en: "the rat" }), hp: 3, damage: [1, 2], color: rgb(200, 170, 120) },
{ letter: "g", name: t({ es: "el goblin", en: "the goblin" }), hp: 6, damage: [1, 3], color: rgb(120, 220, 110) },
{ letter: "O", name: t({ es: "el ogro", en: "the ogre" }), hp: 11, damage: [2, 5], color: rgb(240, 110, 90) },
];
function populate(map, rooms) {
const enemies = [];
const freeSpot = () => {
const r = choose(rooms.slice(1));
const p = { x: randi(r.x, r.x + r.w), y: randi(r.y, r.y + r.h) };
return map[p.y][p.x] === "." && !enemies.some((e) => e.x === p.x && e.y === p.y) ? p : freeSpot();
};
for (let n = 0; n < 2 + floor; n++) enemies.push({ ...choose(MONSTERS.slice(0, Math.min(3, floor + 1))), ...freeSpot() });
for (let n = 0; n < 2; n++) {
const p = freeSpot();
map[p.y][p.x] = "!";
}
return enemies;
}
// #endregion
scene("game", () => {
const { map, rooms } = generateDungeon();
const enemies = populate(map, rooms);
Object.assign(player, rooms[0].center);
let message = t({ es: `Piso ${floor}. Busca la escalera >.`, en: `Floor ${floor}. Find the stairs >.` });
const enemyAt = (x, y) => enemies.find((e) => e.x === x && e.y === y);
// #region vision
// Fog of war: you only see SIGHT tiles away, and the map remembers what you have explored.
const seen = Array.from({ length: ROWS }, () => Array(COLS).fill(false));
const visible = (x, y) => Math.hypot(x - player.x, y - player.y) <= SIGHT;
const reveal = () => map.forEach((row, y) => row.forEach((_, x) => visible(x, y) && (seen[y][x] = true)));
reveal();
// #endregion
// #region combat
// Bumping into an enemy attacks it. Damage is a random number within a range.
const capitalize = (s) => s[0].toUpperCase() + s.slice(1);
function attack(attacker, defender) {
const damage = randi(attacker.damage[0], attacker.damage[1] + 1);
defender.hp -= damage;
if (attacker === player) message += t({ es: `Golpeas a ${defender.name} (${damage}). `, en: `You hit ${defender.name} (${damage}). ` });
else message += t({ es: `${capitalize(attacker.name)} te golpea (${damage}). `, en: `${capitalize(attacker.name)} hits you (${damage}). ` });
if (defender.hp <= 0 && defender !== player) {
enemies.splice(enemies.indexOf(defender), 1);
message += t({ es: `${capitalize(defender.name)} muere. `, en: `${capitalize(defender.name)} dies. ` });
}
}
// #endregion
// #region enemy-ai
// If you are next to it, it attacks; if it sees you, it takes a step towards you; otherwise it waits.
function enemyTurn(e) {
const dx = player.x - e.x, dy = player.y - e.y;
if (Math.abs(dx) + Math.abs(dy) === 1) return attack(e, player);
if (!visible(e.x, e.y)) return;
const steps = Math.abs(dx) > Math.abs(dy) ? [[Math.sign(dx), 0], [0, Math.sign(dy)]] : [[0, Math.sign(dy)], [Math.sign(dx), 0]];
for (const [sx, sy] of steps) {
const x = e.x + sx, y = e.y + sy;
if ((sx || sy) && map[y][x] !== "#" && !enemyAt(x, y)) return Object.assign(e, { x, y });
}
}
// #endregion
// #region turns
// The world only moves when you act: a step, a hit or waiting is one turn,
// and then every enemy moves. If you do nothing, nothing happens.
function act(dx, dy) {
const x = player.x + dx, y = player.y + dy;
const enemy = enemyAt(x, y);
if (!enemy && map[y][x] === "#") return; // bumping into a wall does not use up the turn
message = "";
if (enemy) attack(player, enemy);
else Object.assign(player, { x, y });
if (!enemy && map[y][x] === ">") {
floor++; // the stairs: a new, harder floor is generated
return go("game");
}
if (!enemy && map[y][x] === "!") {
map[y][x] = ".";
player.hp = Math.min(player.maxHp, player.hp + 6);
message += t({ es: "Bebes una poción: +6 de vida. ", en: "You drink a potion: +6 health. " });
}
player.turns++;
for (const e of [...enemies]) enemyTurn(e);
reveal();
if (player.hp <= 0) die();
}
// #endregion
// #region input
// Keyboard: arrows, WASD or HJKL; space waits one turn.
// Mouse or touch: a click takes one step towards where you tapped (on yourself, you wait).
onButtonPress("up", () => act(0, -1));
onButtonPress("down", () => act(0, 1));
onButtonPress("left", () => act(-1, 0));
onButtonPress("right", () => act(1, 0));
onButtonPress("wait", () => act(0, 0));
onMousePress(() => {
const m = mousePos();
const dx = m.x - (player.x * TILE + TILE / 2), dy = m.y - (TOP + player.y * TILE + TILE / 2);
if (Math.max(Math.abs(dx), Math.abs(dy)) < TILE / 2) act(0, 0);
else if (Math.abs(dx) > Math.abs(dy)) act(Math.sign(dx), 0);
else act(0, Math.sign(dy));
});
// #endregion
// #region drawing
// Walls and floor are rectangles; creatures are letters, as in the original Rogue.
const touchesFloor = (x, y) => [-1, 0, 1].some((j) => [-1, 0, 1].some((i) => (map[y + j]?.[x + i] ?? "#") !== "#"));
onDraw(() => {
for (let y = 0; y < ROWS; y++)
for (let x = 0; x < COLS; x++) {
const c = map[y][x], near = visible(x, y), op = near ? 1 : 0.4;
if (!seen[y][x] || (c === "#" && !touchesFloor(x, y))) continue; // solid rock is not drawn
const color = c === "#" ? (near ? rgb(110, 94, 150) : rgb(50, 44, 70)) : near ? rgb(40, 38, 62) : rgb(20, 20, 32);
drawRect({ pos: vec2(x * TILE, TOP + y * TILE), width: TILE - 1, height: TILE - 1, color });
if (c === ">") glyph(">", x, y, rgb(255, 214, 90), op);
if (c === "!") glyph("!", x, y, rgb(255, 100, 160), op);
}
for (const e of enemies) if (visible(e.x, e.y)) glyph(e.letter, e.x, e.y, e.color);
glyph("@", player.x, player.y, rgb(255, 255, 255));
const hpLabel = t({ es: "Vida", en: "Health" }), turnLabel = t({ es: "Turno", en: "Turn" });
drawText({ text: `${t({ es: "Piso", en: "Floor" })} ${floor} ${hpLabel} ${player.hp}/${player.maxHp} ${turnLabel} ${player.turns} ${t({ es: "Semilla", en: "Seed" })} ${seed}`, pos: vec2(8, 2), size: 15, font: FONT });
drawText({ text: message, pos: vec2(8, TOP + ROWS * TILE + 2), size: 15, font: FONT, color: rgb(200, 210, 255) });
});
// #endregion
});
// #region permadeath
// When you die there is no checkpoint and no second life: the run is lost
// and the next one starts on floor 1 with a new dungeon (and a new seed).
function die() {
go("end", floor, player.turns);
}
function newGame() {
seed = randi(1, 100000);
randSeed(seed); // the same seed would produce the same dungeon
floor = 1;
player = { name: t({ es: "tú", en: "you" }), hp: 20, maxHp: 20, damage: [2, 4], turns: 0 };
go("game");
}
// #endregion
// #region scenes
function screen(title, subtitle) {
add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 50)), 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("wait", newGame);
onKeyPress("enter", newGame);
onMousePress(newGame);
}
scene("title", () =>
screen("Roguelike", t({
es: "Eres la @. Baja por las escaleras > tan hondo como puedas. Choca con un enemigo para atacarlo; ! es una poción.\nEl mundo solo se mueve cuando tú te mueves. Flechas o clic hacia donde quieres ir.\nPulsa espacio o haz clic para empezar.",
en: "You are the @. Go down the stairs > as deep as you can. Bump into an enemy to attack it; ! is a potion.\nThe world only moves when you move. Arrows, or click where you want to go.\nPress space or click to start.",
})),
);
scene("end", (lastFloor, turns) =>
screen(t({ es: "Has muerto", en: "You died" }), t({
es: `Caíste en el piso ${lastFloor} tras ${turns} turnos. Muerte permanente: esta mazmorra se pierde para siempre.\nPulsa espacio o haz clic para explorar una nueva.`,
en: `You fell on floor ${lastFloor} after ${turns} turns. Permadeath: this dungeon is lost forever.\nPress space or click to explore a new one.`,
})),
);
go("title");
// #endregionRetos de diseño
- Un generador que produzca niveles variados y a la vez siempre jugables, con todas las salas conectadas y sin trampas imposibles de evitar.
- Que la muerte se sienta justa, de modo que el peligro sea visible y haya opciones para escapar.
- Equilibrar azar y habilidad para que la buena suerte no baste para ganar ni la mala condene siempre a perder.
- Comunicar sistemas profundos con una presentación mínima, a veces solo con letras.
Historia
- 1980OrigenRogue
Beneath Apple Manor (1978) ya tenía mazmorras al azar, pero Rogue (1980), creado por Michael Toy y Glenn Wichman y basado en Adventure y en Dungeons & Dragons, es el que dio nombre al género.
Roguelike (Wikipedia) - 1987NetHack
NetHack, heredero de Hack, se desarrolla en colaboración por internet y se vuelve famoso por la cantidad de interacciones entre sus sistemas.
NetHack (Wikipedia) - 1993Torneko no Daibōken: Fushigi no Dungeon
Torneko no Daibōken, de Chunsoft, lleva el roguelike a las consolas y abre la saga Mystery Dungeon.
Mystery Dungeon (Wikipedia) - 2006Dungeon Crawl Stone Soup
Sale Dungeon Crawl Stone Soup, un roguelike libre que su comunidad sigue manteniendo y que se juega también en el navegador.
Dungeon Crawl Stone Soup (Wikipedia) - 2008
En la International Roguelike Development Conference de Berlín se redacta la «interpretación de Berlín», una lista de rasgos (generación procedural, muerte permanente, turnos, cuadrícula…) para decidir qué es un roguelike; a los juegos que solo tienen algunos de esos rasgos se los suele llamar roguelite.
Roguelike (Wikipedia) - 2008Spelunky
Spelunky lleva los niveles al azar y la muerte permanente a un juego de plataformas en tiempo real.
Spelunky (Wikipedia) - 2011The Binding of Isaac
The Binding of Isaac aplica la fórmula a la acción vista desde arriba, con sótanos armados al azar y muerte permanente; es un ejemplo típico de roguelite.
The Binding of Isaac (Wikipedia)
Juegos de referencia
1980
Rogue
Bajas por una mazmorra de caracteres ASCII en busca del Amuleto de Yendor. Los pisos, los monstruos y los tesoros se generan de nuevo en cada partida, el mundo solo avanza cuando actúas y morir borra la partida. Le dio nombre al género.
1987
NetHack
Heredero de Hack y de Rogue, desarrollado en colaboración por internet desde 1987. Es famoso por la cantidad de interacciones entre objetos, monstruos y el entorno, que premian experimentar. Es software libre.
1993
Torneko no Daibōken: Fushigi no Dungeon
El mercader Torneko, de Dragon Quest IV, explora mazmorras al azar recogiendo objetos y luchando por turnos, con hambre que te obliga a avanzar. Llevó el roguelike a las consolas y abrió la saga Mystery Dungeon.
2006
Dungeon Crawl Stone Soup
Roguelike libre mantenido por su comunidad desde 2006, heredero de Linley's Dungeon Crawl. Eliges especie y oficio, bajas por ramas de mazmorra generadas al azar y una muerte termina la partida. Se juega en caracteres de texto o con gráficos de baldosas.
2008
Spelunky
Plataformas en el que cada cueva se genera al azar y morir te devuelve al principio sin guardar. Fue de los primeros en llevar las ideas del roguelike a la acción en tiempo real, y la versión original es gratuita.
2011
The Binding of Isaac
Disparas a monstruos sala por sala en sótanos armados al azar con salas prediseñadas. Perder todos los corazones termina la partida y empiezas en un sótano nuevo; los objetos que encuentras cambian cómo juegas cada vez.
2016
Enter the Gungeon
Lluvia de balas con vista cenital y salas generadas al azar. Esquivas con una voltereta que te vuelve invulnerable un instante, y las «balas de fogueo» borran todos los proyectiles de la sala.
2019
Entombed
Pequeño roguelike en primera persona con mazmorras generadas al azar, hecho en menos de 13 kilobytes para la competencia js13kGames 2019.
Ejercicio
En la demo, cambia cuántas salas se generan, su tamaño y el radio de visión, y observa cómo cambia la tensión. Luego fija la semilla (randSeed con un número) para que otras personas jueguen la misma mazmorra y comparar decisiones. Para cerrar, piensa qué habría que añadir para convertirla en un roguelite.
Subgéneros
- Roguelike de acciónRoguelike en el que combates en tiempo real, con reflejos y puntería, en lugar de por turnos. Cada partida recorre niveles generados al azar donde reúnes mejoras para tu personaje, y si mueres vuelves a empezar desde el principio.
- Roguelike de construcción de barajasRoguelike en el que combates jugando cartas de una baraja que construyes durante la partida, eligiendo recompensas al azar tras cada victoria. El reto es formar combinaciones que funcionen sin llenar la baraja de cartas débiles, porque si pierdes empiezas de nuevo con la baraja inicial.
- RogueliteJuego que toma del roguelike las partidas cortas con niveles generados al azar y la muerte que obliga a reiniciar, pero lo mezcla con otros géneros. Además, algo de lo que logras se conserva entre partidas, así que cada intento te deja un poco más fuerte.