
Rol
Asumes el papel de uno o varios personajes definidos por números (vida, ataque, habilidades) que crecen a medida que exploras el mundo y superas combates. El reto está en decidir cómo hacerlos progresar y cómo aprovechar lo que saben hacer en cada situación.
También se llama: RPG, juego de rol, videojuego de rol
Mecánicas que lo definen
- Estadísticas numéricas (vida, magia, ataque, defensa, velocidad) que deciden el resultado de cada acción mediante fórmulas y algo de azar.
- Progresión del personaje con experiencia, niveles, equipo y habilidades nuevas; es lo que hace sentir que el esfuerzo rinde.
- Combate por turnos, con pausa o en tiempo real, con decisiones tácticas como a quién atacar, cuándo curar y cuándo gastar recursos limitados.
- Un grupo de personajes con papeles complementarios (quien aguanta golpes, quien cura, quien hace daño mágico).
- Exploración de un mundo con misiones, personajes, objetos y, a menudo, una historia larga.
Bucle de juego
Explorar, combatir, ganar experiencia y objetos, mejorar al personaje y adentrarse en zonas más difíciles.
Demo propia
Controles. Flechas arriba y abajo para elegir la acción, izquierda y derecha para elegir el enemigo, espacio o Enter para confirmar; las teclas 1 a 4 eligen la acción directamente. Con ratón o en móvil, toca un enemigo para apuntarle y toca una opción del menú.
Abrir en pantalla completaUn grupo de dos héroes, una guerrera y un mago, lucha contra entre uno y tres enemigos en combates por turnos al estilo del rol japonés. Al ganar reciben experiencia, suben de nivel y sus estadísticas crecen, y cada batalla es un poco más difícil que la anterior.
Hecha con Kaplay, en unas 199 líneas de JavaScript.
Cómo está hecha
Configuración y controles
Se crea el juego y se definen botones virtuales (up, down, left, right, ok) para que el resto del código no dependa de las teclas. La función write agrupa el dibujo de texto con la fuente del sistema.
// Virtual buttons: the game asks about "ok", not about a specific key. // No touch buttons: on mobile you tap the menu and the enemies directly. kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [24, 20, 44], buttons: { up: { keyboard: ["up", "w"] }, down: { keyboard: ["down", "s"] }, left: { keyboard: ["left", "a"] }, right: { keyboard: ["right", "d"] }, ok: { keyboard: ["space", "enter", "z"] }, }, }); const [WHITE, YELLOW, GREY, PANEL] = [rgb(240, 240, 255), rgb(255, 214, 90), rgb(150, 150, 185), rgb(42, 36, 74)]; const write = (txt, x, y, opts = {}) => drawText({ text: txt, pos: vec2(x, y), size: 16, font: FONT, color: WHITE, ...opts });Estadísticas de héroes y enemigos
Cada personaje es un objeto con números: vida, magia, ataque, defensa, velocidad, nivel y experiencia, más una habilidad con su coste en PM. Los enemigos salen de una tabla de tipos y se fortalecen un 15 % en cada batalla. En un juego de rol, diseñar es sobre todo decidir estos números.
// Each character is a handful of numbers. Those numbers, and how they grow, are the heart of the genre. function newParty() { return [ { name: "Ana", job: t({ es: "Guerrera", en: "Warrior" }), hp: 45, maxHp: 45, mp: 6, maxMp: 6, attack: 12, defense: 6, speed: 7, level: 1, xp: 0, color: rgb(90, 160, 255), x: 500, y: 110, skill: { name: t({ es: "Tajo", en: "Slash" }), cost: 3, mult: 2, hitsAll: false } }, { name: "Leo", job: t({ es: "Mago", en: "Mage" }), hp: 30, maxHp: 30, mp: 14, maxMp: 14, attack: 8, defense: 3, speed: 9, level: 1, xp: 0, color: rgb(230, 120, 220), x: 560, y: 175, skill: { name: t({ es: "Fuego", en: "Fire" }), cost: 4, mult: 1.5, hitsAll: true } }, ]; } const ENEMY_TYPES = [ { name: t({ es: "Limo", en: "Slime" }), hp: 18, attack: 9, defense: 2, speed: 4, xp: 4, shape: "circle", color: rgb(110, 200, 110) }, { name: t({ es: "Murciélago", en: "Bat" }), hp: 14, attack: 10, defense: 1, speed: 12, xp: 5, shape: "triangle", color: rgb(170, 110, 230) }, { name: t({ es: "Lobo", en: "Wolf" }), hp: 26, attack: 13, defense: 4, speed: 10, xp: 8, shape: "box", color: rgb(165, 165, 175) }, ]; // Between 1 and 3 enemies, 15 % stronger in every battle. function newEnemies(battle) { const count = randi(1, Math.min(3, battle + 1) + 1); const f = 1 + 0.15 * (battle - 1); return Array.from({ length: count }, (_, i) => { const type = choose(ENEMY_TYPES); const hp = Math.round(type.hp * f); return { ...type, hp, maxHp: hp, attack: Math.round(type.attack * f), enemy: true, x: 130 + (i % 2) * 110, y: 100 + i * 46 }; }); } let party, battle, potions; // the game state survives between battlesLa fórmula de daño
daño = (ataque × multiplicador − defensa ÷ 2) × un azar entre 0,85 y 1,15, con un mínimo de 1, y la mitad si el objetivo se defiende. El multiplicador es 1 para un ataque normal, 2 para el Tajo y 1,5 para el Fuego, que golpea a todos. Restar la defensa hace que subir el ataque rinda más contra enemigos débiles, el mínimo de 1 evita golpes inútiles y el azar impide que todo sea previsible. Pequeños cambios aquí alteran todo el equilibrio.
// damage = (attack × multiplier − defense ÷ 2) × random factor between 0.85 and 1.15; minimum 1. // A defending target takes half. Change these numbers and you change the whole balance. function computeDamage(attacker, target, mult = 1) { const base = attacker.attack * mult - target.defense / 2; const damage = Math.max(1, Math.round(base * rand(0.85, 1.15))); return target.defending ? Math.ceil(damage / 2) : damage; }El orden de los turnos
Al empezar cada ronda, todos los combatientes vivos se ordenan por velocidad en una cola y actúan uno tras otro. Por eso la velocidad es una estadística táctica, pues decide quién golpea o cura antes. Defender dura hasta el siguiente turno de quien se defiende.
// At the start of each round, the living are sorted by speed: the fastest acts first. function nextTurn() { if (alive(enemies).length === 0) return win(); if (alive(party).length === 0) return go("end", battle - 1); if (queue.length === 0) queue = alive([...party, ...enemies]).sort((a, b) => b.speed - a.speed); current = queue.shift(); if (current.hp <= 0) return nextTurn(); current.defending = false; // defending lasts until your next turn if (current.enemy) { mode = "waiting"; wait(0.7, enemyTurn); } else { mode = "menu"; target = Math.min(target, alive(enemies).length - 1); message = t({ es: `Turno de ${current.name}. ¿Qué hace?`, en: `${current.name}'s turn. What now?` }); } } wait(0.8, nextTurn);Las cuatro acciones
Atacar es gratis; la habilidad hace más daño pero gasta PM, un recurso que no se recupera entre batallas salvo al subir de nivel; la poción cura pero hay pocas; defender reduce a la mitad el daño recibido. La gracia del género está en que ninguna opción sea siempre la mejor.
// The four tactical decisions of every turn: attack, spend magic, heal or protect yourself. function act(i) { if (mode !== "menu") return; const h = current; const foe = alive(enemies)[target]; if (i === 0) { const d = computeDamage(h, foe); hit(foe, d); message = `${h.name} → ${foe.name}: ${d}`; } else if (i === 1) { const skill = h.skill; if (h.mp < skill.cost) return void (message = t({ es: "No te alcanza la magia (PM).", en: "Not enough magic (MP)." })); h.mp -= skill.cost; for (const e of skill.hitsAll ? alive(enemies) : [foe]) hit(e, computeDamage(h, e, skill.mult)); message = t({ es: `${h.name} usa ${skill.name}.`, en: `${h.name} uses ${skill.name}.` }); } else if (i === 2) { if (potions === 0) return void (message = t({ es: "No quedan pociones.", en: "No potions left." })); potions--; // The potion goes to the most wounded ally (relative to their maximum health). const wounded = alive(party).sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp)[0]; const heal = Math.min(25, wounded.maxHp - wounded.hp); wounded.hp += heal; popup(wounded, `+${heal}`, rgb(120, 240, 140)); message = t({ es: `${wounded.name} recupera ${heal} PV.`, en: `${wounded.name} recovers ${heal} HP.` }); } else { h.defending = true; message = t({ es: `${h.name} se defiende.`, en: `${h.name} defends.` }); } mode = "waiting"; wait(0.6, nextTurn); }La IA enemiga
Los enemigos eligen un héroe vivo al azar y lo atacan. Es lo mínimo para que el combate funcione; una IA más lista, que por ejemplo remate al más débil, haría el juego mucho más difícil.
// The simplest possible AI: attack a random living hero. function enemyTurn() { const foe = choose(alive(party)); const d = computeDamage(current, foe); hit(foe, d); const note = foe.defending ? t({ es: " (se defendió)", en: " (blocked)" }) : ""; message = `${current.name} → ${foe.name}: ${d}${note}`; wait(0.6, nextTurn); }Experiencia y subida de nivel
Cada enemigo vencido da experiencia a los héroes que siguen en pie. Con nivel × 10 puntos se sube de nivel, lo que mejora la vida, la magia, el ataque, la defensa y la velocidad y además cura del todo. Esta progresión, que convierte el esfuerzo en números más grandes, es lo que define al género.
// Winning gives experience; level × 10 points means a level up, and the stats grow. function win() { mode = "victory"; const xp = enemies.reduce((sum, e) => sum + e.xp, 0); const lines = [t({ es: `¡Victoria! +${xp} de experiencia y +1 poción.`, en: `Victory! +${xp} experience and +1 potion.` })]; for (const h of alive(party)) { h.xp += xp; while (h.xp >= h.level * 10) { h.xp -= h.level * 10; h.level++; Object.assign(h, { maxHp: h.maxHp + 6, maxMp: h.maxMp + 2, attack: h.attack + 2, defense: h.defense + 1, speed: h.speed + 1 }); [h.hp, h.mp] = [h.maxHp, h.maxMp]; // levelling up also heals lines.push(t({ es: `${h.name} sube a nivel ${h.level}.`, en: `${h.name} reaches level ${h.level}.` })); } } for (const h of party) h.hp = Math.max(1, h.hp); // fallen heroes get up with 1 HP [potions, battle] = [potions + 1, battle + 1]; message = lines.join(" "); }Teclado, ratón y tacto
Con el teclado mueves el cursor del menú y el objetivo y confirmas. Con el ratón o el dedo, tocar un enemigo lo elige como objetivo y tocar una opción la ejecuta; para eso basta comparar la posición del clic con la de cada elemento.
// Keyboard: up and down pick the action, left and right the enemy, space confirms. // Mouse or touch: clicking an enemy targets it; clicking the menu acts. const changeTarget = (d, n = alive(enemies).length) => n && (target = (target + d + n) % n); onButtonPress("up", () => (choice = (choice + 3) % 4)); onButtonPress("down", () => (choice = (choice + 1) % 4)); onButtonPress("left", () => changeTarget(-1)); onButtonPress("right", () => changeTarget(1)); onButtonPress("ok", () => (mode === "victory" ? go("game") : act(choice))); onKeyPress(["1", "2", "3", "4"], (key) => act(Number(key) - 1)); onMousePress(() => { if (mode === "victory") return go("game"); const m = mousePos(); alive(enemies).forEach((e, i) => m.dist(vec2(e.x, e.y)) < 32 && (target = i)); MENU.forEach((_, i) => m.x > 400 && m.y > 244 + i * 26 && m.y < 270 + i * 26 && act((choice = i))); });La interfaz
Todo se redibuja en cada fotograma a partir del estado (mensaje, luchadores, barras de vida, panel del grupo y menú). Los números mandan y el dibujo solo los muestra, así que cambiar la lógica no obliga a tocar los gráficos. Los números que flotan al golpear o curar dan la respuesta inmediata que necesita cada decisión.
// Everything is drawn every frame from the state: the numbers rule and the drawing shows them. function drawFighter(c, marked) { const op = c.hp > 0 ? 1 : 0.2; const p = vec2(c.x, c.y); if (c.shape === "circle") drawCircle({ pos: p, radius: 20, color: c.color, opacity: op }); else if (c.shape === "triangle") drawPolygon({ pts: [p.add(-26, -12), p.add(26, -12), p.add(0, 16)], color: c.color, opacity: op }); else if (c.shape === "box") drawRect({ pos: p.add(-24, -16), width: 48, height: 32, radius: 6, color: c.color, opacity: op }); else { drawRect({ pos: p.add(-12, -10), width: 24, height: 30, color: c.color, opacity: op }); drawCircle({ pos: p.add(0, -20), radius: 10, color: rgb(250, 220, 190), opacity: op }); if (c.defending) drawRect({ pos: p.add(-24, -12), width: 8, height: 28, color: YELLOW }); } drawRect({ pos: p.add(-24, 26), width: 48, height: 5, color: rgb(70, 40, 60) }); if (c.hp > 0) drawRect({ pos: p.add(-24, 26), width: (48 * c.hp) / c.maxHp, height: 5, color: rgb(120, 230, 120) }); if (marked) write("▼", c.x, c.y - 46, { anchor: "center", color: YELLOW }); } onUpdate(() => (popups = popups.filter((f) => (f.t += dt()) < 1))); onDraw(() => { drawRect({ pos: vec2(8, 6), width: 624, height: 46, radius: 6, color: PANEL }); write(message, 18, 12, { size: 15, width: 604 }); const mpLabel = t({ es: "PM", en: "MP" }); write(`${t({ es: "Batalla", en: "Battle" })} ${battle} · ${t({ es: "Pociones", en: "Potions" })}: ${potions}`, 628, 214, { size: 13, color: GREY, anchor: "topright" }); const living = alive(enemies); for (const e of enemies) drawFighter(e, mode === "menu" && e === living[target]); for (const h of party) drawFighter(h, mode === "menu" && h === current); drawRect({ pos: vec2(8, 236), width: 386, height: 118, radius: 6, color: PANEL }); party.forEach((h, i) => { const y = 248 + i * 52; write(`${h.name} · ${h.job} · ${t({ es: "Nv", en: "Lv" })} ${h.level} EXP ${h.xp}/${h.level * 10}`, 20, y, { color: h === current ? YELLOW : WHITE }); write(`${t({ es: "PV", en: "HP" })} ${h.hp}/${h.maxHp} ${mpLabel} ${h.mp}/${h.maxMp} ${t({ es: "ATQ", en: "ATK" })} ${h.attack} DEF ${h.defense} ${t({ es: "VEL", en: "SPD" })} ${h.speed}`, 20, y + 21, { size: 13, color: GREY }); }); drawRect({ pos: vec2(400, 236), width: 232, height: 118, radius: 6, color: PANEL }); if (mode === "victory") write(t({ es: "Espacio o clic:\nsiguiente batalla", en: "Space or click:\nnext battle" }), 416, 262, { color: YELLOW }); else MENU.forEach((o, i) => { const label = i === 1 && current && !current.enemy ? `${current.skill.name} (${current.skill.cost} ${mpLabel})` : o; const chosen = mode === "menu" && i === choice; write((chosen ? "▶ " : " ") + `${i + 1}. ${label}`, 412, 248 + i * 26, { color: mode !== "menu" ? GREY : chosen ? YELLOW : WHITE }); }); for (const f of popups) write(f.txt, f.x, f.y - f.t * 30, { anchor: "center", size: 20, color: f.color, opacity: 1 - f.t }); });Escenas
Hay tres escenas (title, game y end). El grupo vive fuera de las escenas, así que conserva su nivel de una batalla a otra; newGame lo reinicia todo.
function screen(title, subtitle, next) { 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("ok", next); onMousePress(next); } function newGame() { [party, battle, potions] = [newParty(), 1, 3]; go("game"); } scene("title", () => screen(t({ es: "Rol: combate por turnos", en: "Roleplaying: turn-based combat" }), t({ es: "Dirige a Ana y a Leo. En cada turno elige atacar, usar una habilidad (gasta PM), dar una poción o defenderte.\nFlechas y espacio, teclas 1–4, o haz clic en el menú y en los enemigos.\nPulsa espacio o haz clic para empezar.", en: "Lead Ana and Leo. Each turn, choose to attack, use a skill (costs MP), give a potion or defend.\nArrows and space, keys 1–4, or click the menu and the enemies.\nPress space or click to start.", }), newGame)); scene("end", (won, level = Math.max(...party.map((h) => h.level))) => screen(t({ es: "Tu grupo ha caído", en: "Your party has fallen" }), t({ es: `Batallas ganadas: ${won}. Nivel más alto: ${level}.\nPulsa espacio o haz clic para empezar de nuevo.`, en: `Battles won: ${won}. Highest level: ${level}.\nPress space or click to start again.`, }), newGame)); go("title");
Qué simplificamos
- No hay mapa ni exploración; solo el combate, que es donde se ven las estadísticas y la progresión.
- Una sola habilidad por héroe, un solo tipo de objeto (la poción, que cura al aliado más herido) y una IA enemiga que ataca al azar.
- Los personajes son figuras geométricas y no hay sonido, equipo ni tienda.
Para modificarla
- Añade una acción «Huir» que funcione con una probabilidad que dependa de la velocidad del héroe frente a la del enemigo más rápido.
- Haz que el Fuego haga el doble de daño a los limos y la mitad a los lobos (debilidades y resistencias).
- Mejora la IA para que los enemigos ataquen al héroe con menos vida, y compara cuántas batallas aguantas.
- Añade una tercera heroína sanadora con una habilidad que cure a todo el grupo.
Código completo
// Roleplaying demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, pixelDensity } from "../common.js";
// #region setup
// Virtual buttons: the game asks about "ok", not about a specific key.
// No touch buttons: on mobile you tap the menu and the enemies directly.
kaplay({
width: 640, height: 360, letterbox: true, pixelDensity, background: [24, 20, 44],
buttons: {
up: { keyboard: ["up", "w"] },
down: { keyboard: ["down", "s"] },
left: { keyboard: ["left", "a"] },
right: { keyboard: ["right", "d"] },
ok: { keyboard: ["space", "enter", "z"] },
},
});
const [WHITE, YELLOW, GREY, PANEL] = [rgb(240, 240, 255), rgb(255, 214, 90), rgb(150, 150, 185), rgb(42, 36, 74)];
const write = (txt, x, y, opts = {}) =>
drawText({ text: txt, pos: vec2(x, y), size: 16, font: FONT, color: WHITE, ...opts });
// #endregion
// #region stats
// Each character is a handful of numbers. Those numbers, and how they grow, are the heart of the genre.
function newParty() {
return [
{ name: "Ana", job: t({ es: "Guerrera", en: "Warrior" }), hp: 45, maxHp: 45, mp: 6, maxMp: 6,
attack: 12, defense: 6, speed: 7, level: 1, xp: 0, color: rgb(90, 160, 255), x: 500, y: 110,
skill: { name: t({ es: "Tajo", en: "Slash" }), cost: 3, mult: 2, hitsAll: false } },
{ name: "Leo", job: t({ es: "Mago", en: "Mage" }), hp: 30, maxHp: 30, mp: 14, maxMp: 14,
attack: 8, defense: 3, speed: 9, level: 1, xp: 0, color: rgb(230, 120, 220), x: 560, y: 175,
skill: { name: t({ es: "Fuego", en: "Fire" }), cost: 4, mult: 1.5, hitsAll: true } },
];
}
const ENEMY_TYPES = [
{ name: t({ es: "Limo", en: "Slime" }), hp: 18, attack: 9, defense: 2, speed: 4, xp: 4, shape: "circle", color: rgb(110, 200, 110) },
{ name: t({ es: "Murciélago", en: "Bat" }), hp: 14, attack: 10, defense: 1, speed: 12, xp: 5, shape: "triangle", color: rgb(170, 110, 230) },
{ name: t({ es: "Lobo", en: "Wolf" }), hp: 26, attack: 13, defense: 4, speed: 10, xp: 8, shape: "box", color: rgb(165, 165, 175) },
];
// Between 1 and 3 enemies, 15 % stronger in every battle.
function newEnemies(battle) {
const count = randi(1, Math.min(3, battle + 1) + 1);
const f = 1 + 0.15 * (battle - 1);
return Array.from({ length: count }, (_, i) => {
const type = choose(ENEMY_TYPES);
const hp = Math.round(type.hp * f);
return { ...type, hp, maxHp: hp, attack: Math.round(type.attack * f), enemy: true, x: 130 + (i % 2) * 110, y: 100 + i * 46 };
});
}
let party, battle, potions; // the game state survives between battles
// #endregion
// #region damage-formula
// damage = (attack × multiplier − defense ÷ 2) × random factor between 0.85 and 1.15; minimum 1.
// A defending target takes half. Change these numbers and you change the whole balance.
function computeDamage(attacker, target, mult = 1) {
const base = attacker.attack * mult - target.defense / 2;
const damage = Math.max(1, Math.round(base * rand(0.85, 1.15)));
return target.defending ? Math.ceil(damage / 2) : damage;
}
// #endregion
scene("game", () => {
const enemies = newEnemies(battle);
const alive = (list) => list.filter((c) => c.hp > 0);
const MENU = [t({ es: "Atacar", en: "Attack" }), t({ es: "Habilidad", en: "Skill" }), t({ es: "Poción", en: "Potion" }), t({ es: "Defender", en: "Defend" })];
let message = t({ es: "¡Aparecen enemigos!", en: "Enemies appear!" });
let mode = "waiting"; // waiting · menu · victory
let [current, choice, target, queue] = [null, 0, 0, []]; // target: index among living enemies
let popups = []; // numbers that float up on a hit or a heal
const popup = (c, txt, color) => popups.push({ x: c.x, y: c.y - 34, txt, color, t: 0 });
const hit = (c, damage) => { c.hp = Math.max(0, c.hp - damage); popup(c, String(damage), rgb(255, 110, 90)); };
// #region turn-order
// At the start of each round, the living are sorted by speed: the fastest acts first.
function nextTurn() {
if (alive(enemies).length === 0) return win();
if (alive(party).length === 0) return go("end", battle - 1);
if (queue.length === 0) queue = alive([...party, ...enemies]).sort((a, b) => b.speed - a.speed);
current = queue.shift();
if (current.hp <= 0) return nextTurn();
current.defending = false; // defending lasts until your next turn
if (current.enemy) {
mode = "waiting";
wait(0.7, enemyTurn);
} else {
mode = "menu";
target = Math.min(target, alive(enemies).length - 1);
message = t({ es: `Turno de ${current.name}. ¿Qué hace?`, en: `${current.name}'s turn. What now?` });
}
}
wait(0.8, nextTurn);
// #endregion
// #region actions
// The four tactical decisions of every turn: attack, spend magic, heal or protect yourself.
function act(i) {
if (mode !== "menu") return;
const h = current;
const foe = alive(enemies)[target];
if (i === 0) {
const d = computeDamage(h, foe);
hit(foe, d);
message = `${h.name} → ${foe.name}: ${d}`;
} else if (i === 1) {
const skill = h.skill;
if (h.mp < skill.cost) return void (message = t({ es: "No te alcanza la magia (PM).", en: "Not enough magic (MP)." }));
h.mp -= skill.cost;
for (const e of skill.hitsAll ? alive(enemies) : [foe]) hit(e, computeDamage(h, e, skill.mult));
message = t({ es: `${h.name} usa ${skill.name}.`, en: `${h.name} uses ${skill.name}.` });
} else if (i === 2) {
if (potions === 0) return void (message = t({ es: "No quedan pociones.", en: "No potions left." }));
potions--;
// The potion goes to the most wounded ally (relative to their maximum health).
const wounded = alive(party).sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp)[0];
const heal = Math.min(25, wounded.maxHp - wounded.hp);
wounded.hp += heal;
popup(wounded, `+${heal}`, rgb(120, 240, 140));
message = t({ es: `${wounded.name} recupera ${heal} PV.`, en: `${wounded.name} recovers ${heal} HP.` });
} else {
h.defending = true;
message = t({ es: `${h.name} se defiende.`, en: `${h.name} defends.` });
}
mode = "waiting";
wait(0.6, nextTurn);
}
// #endregion
// #region enemy-ai
// The simplest possible AI: attack a random living hero.
function enemyTurn() {
const foe = choose(alive(party));
const d = computeDamage(current, foe);
hit(foe, d);
const note = foe.defending ? t({ es: " (se defendió)", en: " (blocked)" }) : "";
message = `${current.name} → ${foe.name}: ${d}${note}`;
wait(0.6, nextTurn);
}
// #endregion
// #region xp-levels
// Winning gives experience; level × 10 points means a level up, and the stats grow.
function win() {
mode = "victory";
const xp = enemies.reduce((sum, e) => sum + e.xp, 0);
const lines = [t({ es: `¡Victoria! +${xp} de experiencia y +1 poción.`, en: `Victory! +${xp} experience and +1 potion.` })];
for (const h of alive(party)) {
h.xp += xp;
while (h.xp >= h.level * 10) {
h.xp -= h.level * 10;
h.level++;
Object.assign(h, { maxHp: h.maxHp + 6, maxMp: h.maxMp + 2, attack: h.attack + 2, defense: h.defense + 1, speed: h.speed + 1 });
[h.hp, h.mp] = [h.maxHp, h.maxMp]; // levelling up also heals
lines.push(t({ es: `${h.name} sube a nivel ${h.level}.`, en: `${h.name} reaches level ${h.level}.` }));
}
}
for (const h of party) h.hp = Math.max(1, h.hp); // fallen heroes get up with 1 HP
[potions, battle] = [potions + 1, battle + 1];
message = lines.join(" ");
}
// #endregion
// #region input
// Keyboard: up and down pick the action, left and right the enemy, space confirms.
// Mouse or touch: clicking an enemy targets it; clicking the menu acts.
const changeTarget = (d, n = alive(enemies).length) => n && (target = (target + d + n) % n);
onButtonPress("up", () => (choice = (choice + 3) % 4));
onButtonPress("down", () => (choice = (choice + 1) % 4));
onButtonPress("left", () => changeTarget(-1));
onButtonPress("right", () => changeTarget(1));
onButtonPress("ok", () => (mode === "victory" ? go("game") : act(choice)));
onKeyPress(["1", "2", "3", "4"], (key) => act(Number(key) - 1));
onMousePress(() => {
if (mode === "victory") return go("game");
const m = mousePos();
alive(enemies).forEach((e, i) => m.dist(vec2(e.x, e.y)) < 32 && (target = i));
MENU.forEach((_, i) => m.x > 400 && m.y > 244 + i * 26 && m.y < 270 + i * 26 && act((choice = i)));
});
// #endregion
// #region interface
// Everything is drawn every frame from the state: the numbers rule and the drawing shows them.
function drawFighter(c, marked) {
const op = c.hp > 0 ? 1 : 0.2;
const p = vec2(c.x, c.y);
if (c.shape === "circle") drawCircle({ pos: p, radius: 20, color: c.color, opacity: op });
else if (c.shape === "triangle") drawPolygon({ pts: [p.add(-26, -12), p.add(26, -12), p.add(0, 16)], color: c.color, opacity: op });
else if (c.shape === "box") drawRect({ pos: p.add(-24, -16), width: 48, height: 32, radius: 6, color: c.color, opacity: op });
else {
drawRect({ pos: p.add(-12, -10), width: 24, height: 30, color: c.color, opacity: op });
drawCircle({ pos: p.add(0, -20), radius: 10, color: rgb(250, 220, 190), opacity: op });
if (c.defending) drawRect({ pos: p.add(-24, -12), width: 8, height: 28, color: YELLOW });
}
drawRect({ pos: p.add(-24, 26), width: 48, height: 5, color: rgb(70, 40, 60) });
if (c.hp > 0) drawRect({ pos: p.add(-24, 26), width: (48 * c.hp) / c.maxHp, height: 5, color: rgb(120, 230, 120) });
if (marked) write("▼", c.x, c.y - 46, { anchor: "center", color: YELLOW });
}
onUpdate(() => (popups = popups.filter((f) => (f.t += dt()) < 1)));
onDraw(() => {
drawRect({ pos: vec2(8, 6), width: 624, height: 46, radius: 6, color: PANEL });
write(message, 18, 12, { size: 15, width: 604 });
const mpLabel = t({ es: "PM", en: "MP" });
write(`${t({ es: "Batalla", en: "Battle" })} ${battle} · ${t({ es: "Pociones", en: "Potions" })}: ${potions}`, 628, 214, { size: 13, color: GREY, anchor: "topright" });
const living = alive(enemies);
for (const e of enemies) drawFighter(e, mode === "menu" && e === living[target]);
for (const h of party) drawFighter(h, mode === "menu" && h === current);
drawRect({ pos: vec2(8, 236), width: 386, height: 118, radius: 6, color: PANEL });
party.forEach((h, i) => {
const y = 248 + i * 52;
write(`${h.name} · ${h.job} · ${t({ es: "Nv", en: "Lv" })} ${h.level} EXP ${h.xp}/${h.level * 10}`, 20, y, { color: h === current ? YELLOW : WHITE });
write(`${t({ es: "PV", en: "HP" })} ${h.hp}/${h.maxHp} ${mpLabel} ${h.mp}/${h.maxMp} ${t({ es: "ATQ", en: "ATK" })} ${h.attack} DEF ${h.defense} ${t({ es: "VEL", en: "SPD" })} ${h.speed}`, 20, y + 21, { size: 13, color: GREY });
});
drawRect({ pos: vec2(400, 236), width: 232, height: 118, radius: 6, color: PANEL });
if (mode === "victory") write(t({ es: "Espacio o clic:\nsiguiente batalla", en: "Space or click:\nnext battle" }), 416, 262, { color: YELLOW });
else MENU.forEach((o, i) => {
const label = i === 1 && current && !current.enemy ? `${current.skill.name} (${current.skill.cost} ${mpLabel})` : o;
const chosen = mode === "menu" && i === choice;
write((chosen ? "▶ " : " ") + `${i + 1}. ${label}`, 412, 248 + i * 26, { color: mode !== "menu" ? GREY : chosen ? YELLOW : WHITE });
});
for (const f of popups) write(f.txt, f.x, f.y - f.t * 30, { anchor: "center", size: 20, color: f.color, opacity: 1 - f.t });
});
// #endregion
});
// #region scenes
function screen(title, subtitle, next) {
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("ok", next);
onMousePress(next);
}
function newGame() {
[party, battle, potions] = [newParty(), 1, 3];
go("game");
}
scene("title", () => screen(t({ es: "Rol: combate por turnos", en: "Roleplaying: turn-based combat" }), t({
es: "Dirige a Ana y a Leo. En cada turno elige atacar, usar una habilidad (gasta PM), dar una poción o defenderte.\nFlechas y espacio, teclas 1–4, o haz clic en el menú y en los enemigos.\nPulsa espacio o haz clic para empezar.",
en: "Lead Ana and Leo. Each turn, choose to attack, use a skill (costs MP), give a potion or defend.\nArrows and space, keys 1–4, or click the menu and the enemies.\nPress space or click to start.",
}), newGame));
scene("end", (won, level = Math.max(...party.map((h) => h.level))) => screen(t({ es: "Tu grupo ha caído", en: "Your party has fallen" }), t({
es: `Batallas ganadas: ${won}. Nivel más alto: ${level}.\nPulsa espacio o haz clic para empezar de nuevo.`,
en: `Battles won: ${won}. Highest level: ${level}.\nPress space or click to start again.`,
}), newGame));
go("title");
// #endregionRetos de diseño
- Equilibrar las fórmulas y la curva de progresión para que subir de nivel se note, sin que el jugador quede invencible ni tenga que repetir combates sin fin para avanzar.
- Que cada decisión de combate importe; si atacar siempre es lo mejor, el menú sobra.
- Producir mucho contenido (textos, misiones, objetos, enemigos), lo que lo vuelve uno de los géneros más caros de hacer.
- Mostrar mucha información (estadísticas, inventario, mapas) sin abrumar a quien juega.
Historia
- 1975Origendnd
A mediados de los setenta, en los sistemas universitarios PLATO, juegos como pedit5 y dnd llevaron a la computadora las ideas del rol de mesa de Dungeons & Dragons.
dnd (video game) (Wikipedia) - 1980Akalabeth: World of Doom
Akalabeth, de Richard Garriott, combina un mapa visto desde arriba con mazmorras en primera persona y anticipa la serie Ultima.
Akalabeth: World of Doom (Wikipedia) - 1981Wizardry: Proving Grounds of the Mad Overlord
Wizardry introduce el grupo de aventureros con clases distintas y el combate por turnos con menú; inspirará al rol japonés.
Wizardry: Proving Grounds of the Mad Overlord (Wikipedia) - 1986Dragon Quest
Dragon Quest simplifica el rol para consola y fija la plantilla del rol japonés, con experiencia, niveles y combate por menú.
Dragon Quest (Wikipedia) - 1987Final Fantasy
Final Fantasy muestra al grupo de lado frente a los enemigos y deja elegir sus clases, un formato que el género repetirá durante años.
Final Fantasy (Wikipedia) - 1997Final Fantasy VII
Final Fantasy VII, con personajes en 3D, populariza el rol japonés en todo el mundo.
Final Fantasy VII (Wikipedia) - 1998Baldur's Gate
Baldur's Gate aplica las reglas de Dungeons & Dragons con combate en tiempo real pausable y revive el rol de computadora occidental.
Baldur's Gate (Wikipedia)
Juegos de referencia
1975
dnd
Uno de los primeros juegos de rol para computadora, hecho en el sistema universitario PLATO. Bajas por una mazmorra, combates monstruos y mejoras a tu personaje; es también el primer juego con lo que luego se llamaría un jefe.
1980
Akalabeth: World of Doom
Richard Garriott lo programó en BASIC siendo adolescente. Recorres un mapa visto desde arriba y bajas a mazmorras en primera persona dibujadas con líneas; es el antecesor directo de la serie Ultima.
1981
Wizardry: Proving Grounds of the Mad Overlord
Formas un grupo de seis aventureros de distintas clases y lo guías piso a piso por un laberinto en primera persona, con combates por turnos elegidos desde un menú. Es el primer juego de rol con grupo de verdad.
1986
Dragon Quest
Un solo héroe recorre un mundo visto desde arriba y combate por turnos eligiendo luchar, usar magia, usar un objeto o huir. Ganar da experiencia y oro, y subir de nivel abre zonas más peligrosas. Fijó la plantilla del rol japonés.
1987
Final Fantasy
Eliges cuatro personajes entre seis clases (guerrero, ladrón, mago blanco, mago negro…) y los llevas juntos toda la partida. Sus combates por turnos muestran al grupo de lado, frente a los enemigos, un formato que el género adoptó durante años.
1997
Final Fantasy VII
Rol japonés con personajes en 3D sobre fondos pintados y combates con barras de tiempo que se llenan solas. Combinas materias para personalizar las habilidades del grupo. Popularizó el rol japonés fuera de Japón.
1998
Baldur's Gate
Guías a un grupo de hasta seis personajes con las reglas de Advanced Dungeons & Dragons, que el juego calcula por ti, en combates en tiempo real que puedes pausar para dar órdenes. Revivió el rol de computadora occidental.
2013
Candy Box 2
Empieza como un contador de caramelos y se va abriendo en un pequeño juego de rol hecho con arte ASCII. Compras equipo, completas misiones contra monstruos y ganas objetos que abren nuevas zonas. Su código es libre (GPLv3).
2015
Undertale
Juego de rol en el que cada combate mezcla turnos con una pequeña lluvia de balas, en la que esquivas los ataques moviendo un corazón que representa tu alma. También puedes perdonar a los monstruos en vez de vencerlos.
Ejercicio
En la demo, cambia la fórmula de daño (por ejemplo, resta la defensa entera en lugar de la mitad) y la experiencia que pide cada nivel; juega tres batallas y anota cómo cambia la dificultad. Luego diseña en papel una tercera clase de héroe cuya habilidad haga útil una acción que hoy casi nadie elige.
Subgéneros
- Colección de criaturasJuego de rol en el que capturas o reclutas criaturas, las entrenas y las haces combatir por ti. El reto es armar y hacer crecer un equipo cuyas fortalezas cubran las debilidades de las demás.
- Exploración de mazmorrasJuego de rol en el que recorres laberintos cerrados, nivel a nivel, luchando contra monstruos, esquivando trampas y recogiendo tesoros. El reto es administrar la salud y los recursos de tu personaje o grupo para llegar cada vez más hondo.
- RoguelikeJuego 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.
- Rol de acciónJuego de rol en el que controlas directamente los ataques y movimientos de tu personaje en combates en tiempo real, mientras subes de nivel, mejoras sus atributos y consigues mejor equipo. El reto combina la habilidad con los controles y las decisiones sobre cómo hacer crecer al personaje.
- Rol japonésJuego de rol, de tradición japonesa, en el que guías a un grupo de personajes definidos por una historia larga y dramática, con combates, a menudo por turnos, y una estética cercana al anime. El reto está en hacer crecer al grupo y combinar sus habilidades para vencer enemigos cada vez más duros.
- Rol multijugador masivo en líneaJuego de rol en línea en el que tu personaje comparte un mundo persistente con miles de otros jugadores. Subes de nivel con misiones y combates, y los retos más grandes, como las incursiones, exigen coordinarte en grupo con otras personas.
- Rol occidentalJuego de rol, de tradición estadounidense y europea, en el que creas y personalizas a tu personaje y lo haces crecer a tu manera mientras exploras un mundo amplio con libertad. Tus decisiones y tus atributos abren o cierran caminos, misiones y finales.
- Rol tácticoJuego de rol en el que mueves a tus personajes, casilla por casilla y por turnos, sobre un mapa de batalla, aprovechando el terreno y las habilidades de cada uno. Entre batallas los haces crecer, y perder a uno puede costar caro.