
Rol táctico
Juego 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.
También se llama: RPG táctico, rol estratégico, SRPG
Mecánicas que lo definen
- Un mapa de casillas donde cada unidad muestra su alcance de movimiento, y el terreno cambia lo que cuesta avanzar y lo que protege.
- Turnos por fases, en los que mueves cada unidad y luego actúa (atacar, usar una habilidad o esperar) antes de que juegue el rival.
- Clases con números y papeles distintos, contraataques y una previsión del resultado antes de atacar.
- Progresión de rol entre batallas, con experiencia, niveles y equipo; en muchos juegos, la unidad que cae se pierde para siempre.
Bucle de juego
Leer el mapa y las amenazas, llevar cada unidad a la casilla justa, atacar, ver la respuesta del enemigo y ajustar; entre batallas, mejorar al grupo.
Demo propia
Controles. Clic en una unidad azul para ver su alcance, clic en una casilla azul para moverla y clic en un enemigo marcado en rojo para atacar (o en otra casilla para esperar). «Terminar turno» o la tecla E cede el turno; Escape cancela antes de mover. En móvil, toca.
Abrir en pantalla completaTres héroes contra tres bandidos y su jefe, en un tablero de casillas con bosque y rocas. Cada turno mueves a cada héroe dentro de su alcance y atacas a un enemigo vecino; luego juega el rival. Basta para sentir lo que define al género, que es decidir dónde se para cada unidad.
Hecha con Kaplay, en unas 195 líneas de JavaScript.
Cómo está hecha
Configuración
El tamaño de las casillas, dónde empieza el tablero y la pausa entre acciones del rival. Esa pausa es importante, porque en un juego por turnos el jugador necesita ver qué hizo cada enemigo.
kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [24, 26, 38], // Played with the mouse or a finger; the keys are only shortcuts. buttons: { start: { keyboard: ["space", "enter"] }, endTurn: { keyboard: ["e"] }, cancel: { keyboard: ["escape"] }, }, }); const TILE = 40, OX = 8, OY = 36; // tile size and top-left corner of the board const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]]; const ENEMY_DELAY = 0.6; // seconds between enemy actions, so you can follow themEl campo de batalla
El mapa es texto, donde cada letra es un terreno. La roca bloquea el paso y el bosque cuesta dos puntos de movimiento pero protege a quien está encima. El terreno convierte un tablero vacío en un problema táctico.
// The battlefield as text. . grass · # rock (blocks the way) · f forest // (entering costs 2 moves, and a unit standing on it takes 1 less damage). const MAP = [ "....f..#....", ".f.....#..f.", "...#..f.....", "...#....#...", "......ff#...", ".f..#.......", "....#..f..f.", "......#.....", ]; const COLS = MAP[0].length, ROWS = MAP.length; const terrain = (c, r) => MAP[r]?.[c] ?? "#"; // outside the board counts as rock const stepCost = (c, r) => ({ ".": 1, f: 2 })[terrain(c, r)] ?? Infinity; const tileCenter = (c, r) => vec2(OX + c * TILE + TILE / 2, OY + r * TILE + TILE / 2);Las unidades
Cada unidad es una fila de números (vida, ataque, defensa y movimiento) más su casilla. El caballero aguanta, la lancera pega fuerte y la exploradora llega lejos pero es frágil. Diseñar estas diferencias es diseñar el juego.
// A unit is a handful of numbers: health, attack, defence and how far it moves. const ROSTER = [ { team: "hero", label: { es: "Caballero", en: "Knight" }, col: 1, row: 3, hp: 18, atk: 7, def: 3, moves: 3 }, { team: "hero", label: { es: "Lancera", en: "Lancer" }, col: 1, row: 5, hp: 14, atk: 8, def: 2, moves: 4 }, { team: "hero", label: { es: "Exploradora", en: "Scout" }, col: 0, row: 4, hp: 11, atk: 6, def: 1, moves: 5 }, { team: "foe", label: { es: "Bandido", en: "Bandit" }, col: 8, row: 1, hp: 10, atk: 6, def: 1, moves: 3 }, { team: "foe", label: { es: "Bandido", en: "Bandit" }, col: 10, row: 2, hp: 10, atk: 6, def: 1, moves: 3 }, { team: "foe", label: { es: "Bandido", en: "Bandit" }, col: 9, row: 6, hp: 10, atk: 6, def: 1, moves: 3 }, { team: "foe", label: { es: "Jefe", en: "Captain" }, col: 11, row: 4, hp: 16, atk: 7, def: 2, moves: 2, guard: true }, ];El tablero
Se dibuja cada casilla según su terreno y se colocan las unidades. La barra bajo cada una muestra su vida y, cuando ya actuó en el turno, se ve atenuada.
// Draw the map, then place each unit from the roster on its tile. for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { add([rect(TILE - 2, TILE - 2, { radius: 3 }), pos(OX + c * TILE + 1, OY + r * TILE + 1), color({ ".": rgb(70, 110, 70), f: rgb(36, 78, 46), "#": rgb(110, 104, 100) }[terrain(c, r)])]); if (terrain(c, r) === "f") add([polygon([vec2(0, -9), vec2(8, 7), vec2(-8, 7)]), pos(tileCenter(c, r)), color(70, 140, 80)]); } for (const d of ROSTER) { const u = add([rect(28, 28, { radius: 6 }), pos(tileCenter(d.col, d.row)), anchor("center"), opacity(1), z(10), color(d.team === "hero" ? rgb(80, 140, 240) : rgb(210, 80, 70)), outline(2, rgb(15, 15, 25)), "unit", d.team, { ...d, label: t(d.label), maxHp: d.hp, done: false }]); u.add([text(u.label[0], { size: 16, font: FONT }), anchor("center")]); u.onDraw(() => { drawRect({ pos: vec2(-14, 16), width: 28, height: 4, color: rgb(40, 20, 20) }); drawRect({ pos: vec2(-14, 16), width: (28 * u.hp) / u.maxHp, height: 4, color: rgb(120, 230, 120) }); }); u.onUpdate(() => (u.opacity = u.done ? 0.5 : 1)); // greyed out once it has acted } const unitAt = (c, r) => get("unit").find((u) => u.col === c && u.row === r); const adjacent = (a, c, r) => Math.abs(a.col - c) + Math.abs(a.row - r) === 1; function place(u, c, r) { [u.col, u.row] = [c, r]; tween(u.pos, tileCenter(c, r), 0.2, (p) => (u.pos = p), easings.easeOutQuad); }El alcance de movimiento
Es el algoritmo central del género. Desde la unidad se expande una búsqueda por las casillas vecinas, sumando lo que cuesta entrar en cada una, y se detiene cuando ya no alcanzan los puntos de movimiento. Los enemigos y las rocas cortan el paso, y los aliados se pueden atravesar, pero no se puede terminar sobre ellos. El resultado son las casillas azules.
// Flood fill from the unit: a tile is reachable if the moves spent to get // there fit in its budget. Forest costs more, rocks and enemies block, // allies can be walked through but not stood on. function reachable(u) { const spent = new Map([[`${u.col},${u.row}`, 0]]), open = [[u.col, u.row]]; while (open.length) { const [c, r] = open.shift(); for (const [dc, dr] of DIRS) { const [nc, nr] = [c + dc, r + dr]; const cost = spent.get(`${c},${r}`) + stepCost(nc, nr); const other = unitAt(nc, nr); if (cost > u.moves || (other && other.team !== u.team) || spent.get(`${nc},${nr}`) <= cost) continue; spent.set(`${nc},${nr}`, cost); open.push([nc, nr]); } } return [...spent.keys()].map((k) => k.split(",").map(Number)).filter(([c, r]) => !unitAt(c, r) || unitAt(c, r) === u); }Tu fase
Cada héroe hace dos pasos: moverse y luego atacar o esperar. Una vez que se movió, no puede deshacerlo, y por eso pensar antes de mover es el corazón del género. Cuando todos actuaron, o si pulsas «Terminar turno», juega el rival.
// Click a hero, click a blue tile to move, then a red enemy to attack // (or anywhere else to wait). When every hero has acted, the enemy plays. const isMarked = (c, r) => marked.some(([mc, mr]) => mc === c && mr === r); function finish(u) { u.done = true; [selected, mode, marked] = [null, null, []]; if (phase === "player" && get("hero").every((h) => h.done)) enemyPhase(); } onMousePress(() => { if (phase !== "player") return; const [c, r] = tileUnder(); if (c < 0 || r < 0 || c >= COLS || r >= ROWS) return; const u = unitAt(c, r); if (mode === "move" && isMarked(c, r)) { place(selected, c, r); const targets = get("foe").filter((f) => adjacent(f, c, r)); if (!targets.length) return finish(selected); // nobody to attack: the turn of this unit ends [mode, marked] = ["act", targets.map((f) => [f.col, f.row])]; } else if (mode === "act") { if (u && isMarked(c, r)) fight(selected, u); finish(selected); } else if (u?.team === "hero" && !u.done) [selected, mode, marked] = [u, "move", reachable(u)]; else [selected, mode, marked] = [null, null, []]; }); // Cancel only before moving: once a unit moves, it has committed. onButtonPress("cancel", () => mode === "move" && ([selected, mode, marked] = [null, null, []])); onButtonPress("endTurn", () => phase === "player" && mode !== "act" && enemyPhase()); add([z(5)]).onDraw(() => { for (const [c, r] of marked) drawRect({ pos: vec2(OX + c * TILE + 2, OY + r * TILE + 2), width: TILE - 4, height: TILE - 4, // blue: move here · red: attack this ...(mode === "move" ? { color: rgb(90, 170, 255), opacity: 0.45 } : { fill: false, outline: { width: 4, color: rgb(255, 70, 60) } }) }); if (selected) drawRect({ pos: selected.pos.sub(17, 17), width: 34, height: 34, fill: false, outline: { width: 2, color: rgb(255, 240, 150) } }); });Combate
El daño es ataque menos defensa, como mínimo 1, y el bosque resta uno más. Si el atacado sobrevive, contraataca, así que cada ataque tiene un costo. El panel lateral te avisa cuánto harás y cuánto recibirás antes de atacar.
// Damage is attack minus defence (minimum 1), with -1 on a forest tile. // If the defender survives, it strikes back: attacking always has a cost. const damage = (a, d) => Math.max(1, a.atk - d.def - (terrain(d.col, d.row) === "f" ? 1 : 0)); function strike(a, d) { const dmg = damage(a, d); d.hp -= dmg; add([text(`-${dmg}`, { size: 18, font: FONT }), pos(d.pos.sub(0, 22)), anchor("center"), color(255, 230, 120), z(30), move(vec2(0, -1), 30), opacity(1), lifespan(0.8, { fade: 0.4 })]); if (d.hp <= 0) destroy(d); } function fight(a, d) { strike(a, d); if (d.hp > 0) strike(d, a); // counterattack if (!get("foe").length || !get("hero").length) { phase = "over"; wait(1, () => go("end", get("hero").length > 0, turn)); } }La IA enemiga
Una IA sencilla pero legible. Cada enemigo prueba todas las casillas a su alcance y todos los héroes vecinos de cada una, y se queda con el mejor ataque (primero rematar, luego el mayor daño y, a igualdad, a quien tenga menos vida). Si no alcanza a nadie, se acerca. El jefe no se mueve hasta que entras en su zona.
// A simple but readable AI. Each enemy looks at every tile it can reach and // every hero next to that tile, and picks the best attack: a kill first, // then the most damage. If nobody is in reach, it walks towards the heroes. function enemyAct(e) { let best = null; for (const [c, r] of reachable(e)) for (const h of get("hero").filter((h) => adjacent(h, c, r))) { const score = (damage(e, h) >= h.hp ? 100 : 0) + damage(e, h) * 10 - h.hp; if (!best || score > best.score) best = { c, r, h, score }; } if (best) { place(e, best.c, best.r); return wait(0.25, () => fight(e, best.h)); } if (e.guard) return; // the captain holds its position const distance = ([c, r]) => Math.min(...get("hero").map((h) => Math.abs(h.col - c) + Math.abs(h.row - r))); const [c, r] = reachable(e).reduce((a, b) => (distance(b) < distance(a) ? b : a)); place(e, c, r); } function enemyPhase() { [phase, selected, mode, marked] = ["enemy", null, null, []]; const foes = get("foe"); foes.forEach((e, i) => wait(ENEMY_DELAY * (i + 1), () => phase === "enemy" && e.exists() && enemyAct(e))); wait(ENEMY_DELAY * (foes.length + 1) + 0.3, () => { if (phase !== "enemy") return; [phase, turn] = ["player", turn + 1]; get("hero").forEach((h) => (h.done = false)); }); }Información en pantalla
En un juego táctico la información es la estrategia. El panel muestra los números de la unidad bajo el cursor y, al apuntar, una previsión del intercambio, para que decidas con datos y no a ciegas.
// The side panel shows the unit under the cursor and, while aiming, a // forecast of the exchange: in this genre, information is the strategy. const banner = add([text("", { size: 14, font: FONT }), pos(OX, 10)]); const info = add([text("", { size: 13, font: FONT, width: 128, lineSpacing: 3 }), pos(500, 40)]); const button = add([rect(128, 30, { radius: 4 }), pos(500, 320), color(50, 56, 80), outline(2, rgb(150, 170, 255)), area()]); button.add([text(t({ es: "Terminar turno (E)", en: "End turn (E)" }), { size: 13, font: FONT }), pos(64, 15), anchor("center")]); button.onClick(() => phase === "player" && mode !== "act" && enemyPhase()); onUpdate(() => { banner.text = phase === "enemy" ? t({ es: `Turno ${turn} · Juega el enemigo…`, en: `Turn ${turn} · Enemy phase…` }) : t({ es: `Turno ${turn} · Tu fase: mueve y ataca con cada unidad azul`, en: `Turn ${turn} · Your phase: move and attack with each blue unit` }); const u = unitAt(...tileUnder()) ?? selected; const stats = u ? `${u.label}\n${t({ es: "Vida", en: "HP" })} ${u.hp}/${u.maxHp}\n${t({ es: "Ataque", en: "Attack" })} ${u.atk}\n${t({ es: "Defensa", en: "Defence" })} ${u.def}\n${t({ es: "Movimiento", en: "Move" })} ${u.moves}` : ""; const aim = mode === "act" && u?.team === "foe" && isMarked(u.col, u.row) ? `\n\n${t({ es: "Harás", en: "You deal" })} ${damage(selected, u)}\n${t({ es: "Te devuelve", en: "It hits back" })} ${damage(u, selected) * (damage(selected, u) < u.hp ? 1 : 0)}` : ""; info.text = stats + aim + `\n\n${t({ es: "Bosque: cuesta 2 y\nresta 1 de daño", en: "Forest: costs 2 and\nblocks 1 damage" })}\n${t({ es: "Roca: no se pasa", en: "Rock: impassable" })}`; });Escenas
Tres escenas (title, game y end). La pantalla final dice si ganaste y en cuántos turnos, que es una forma sencilla de invitar a mejorar.
function screen(title, subtitle, next) { add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 85)), anchor("center")]); add([text(subtitle, { size: 15, font: FONT, align: "center", width: 580 }), pos(center().add(0, 25)), anchor("center"), color(200, 210, 255)]); onButtonPress("start", next); onClick(next); } scene("title", () => screen( t({ es: "Rol táctico", en: "Tactical RPG" }), t({ es: "Haz clic en una unidad azul: las casillas azules muestran hasta dónde llega. Haz clic en una para moverte y luego en un enemigo rojo adyacente para atacar (o en otro lugar para esperar). El enemigo juega cuando todas tus unidades actuaron.\nDerrota a todos los enemigos sin perder a todos tus héroes.\nHaz clic o pulsa espacio para empezar.", en: "Click a blue unit: the blue tiles show how far it can go. Click one to move, then click an adjacent red enemy to attack (or anywhere else to wait). The enemy plays once all your units have acted.\nDefeat every enemy before you lose all your heroes.\nClick or press space to start.", }), () => go("game"), ), ); scene("end", (won, turn) => screen( won ? t({ es: "¡Victoria!", en: "Victory!" }) : t({ es: "Derrota", en: "Defeat" }), won ? t({ es: `Despejaste el campo en ${turn} turnos. Haz clic para jugar otra vez.`, en: `You cleared the field in ${turn} turns. Click to play again.` }) : t({ es: "Cayeron todos tus héroes. Haz clic para intentarlo otra vez.", en: "All your heroes fell. Click to try again." }), () => go("game"), )); go("title");
Qué simplificamos
- No hay azar. El daño es ataque menos defensa, así que puedes calcular cada intercambio antes de hacerlo.
- No hay experiencia, niveles, objetos ni clases con habilidades; las unidades solo se diferencian en sus números.
- Todos atacan cuerpo a cuerpo y la batalla es una sola, sin campaña entre combates.
Para modificarla
- Agrega una arquera que ataque a dos casillas de distancia y no reciba contraataque cuerpo a cuerpo.
- Haz que cada enemigo derrotado dé experiencia y que al subir de nivel mejoren los números del héroe.
- Añade un porcentaje de acierto que dependa del terreno y muéstralo en la previsión. ¿Se vuelve más o menos táctico?
Código completo
// Tactical RPG demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, pixelDensity } from "../common.js";
// #region setup
kaplay({
width: 640,
height: 360,
letterbox: true, pixelDensity,
background: [24, 26, 38],
// Played with the mouse or a finger; the keys are only shortcuts.
buttons: {
start: { keyboard: ["space", "enter"] },
endTurn: { keyboard: ["e"] },
cancel: { keyboard: ["escape"] },
},
});
const TILE = 40, OX = 8, OY = 36; // tile size and top-left corner of the board
const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]];
const ENEMY_DELAY = 0.6; // seconds between enemy actions, so you can follow them
// #endregion
// #region map
// The battlefield as text. . grass · # rock (blocks the way) · f forest
// (entering costs 2 moves, and a unit standing on it takes 1 less damage).
const MAP = [
"....f..#....",
".f.....#..f.",
"...#..f.....",
"...#....#...",
"......ff#...",
".f..#.......",
"....#..f..f.",
"......#.....",
];
const COLS = MAP[0].length, ROWS = MAP.length;
const terrain = (c, r) => MAP[r]?.[c] ?? "#"; // outside the board counts as rock
const stepCost = (c, r) => ({ ".": 1, f: 2 })[terrain(c, r)] ?? Infinity;
const tileCenter = (c, r) => vec2(OX + c * TILE + TILE / 2, OY + r * TILE + TILE / 2);
// #endregion
// #region units
// A unit is a handful of numbers: health, attack, defence and how far it moves.
const ROSTER = [
{ team: "hero", label: { es: "Caballero", en: "Knight" }, col: 1, row: 3, hp: 18, atk: 7, def: 3, moves: 3 },
{ team: "hero", label: { es: "Lancera", en: "Lancer" }, col: 1, row: 5, hp: 14, atk: 8, def: 2, moves: 4 },
{ team: "hero", label: { es: "Exploradora", en: "Scout" }, col: 0, row: 4, hp: 11, atk: 6, def: 1, moves: 5 },
{ team: "foe", label: { es: "Bandido", en: "Bandit" }, col: 8, row: 1, hp: 10, atk: 6, def: 1, moves: 3 },
{ team: "foe", label: { es: "Bandido", en: "Bandit" }, col: 10, row: 2, hp: 10, atk: 6, def: 1, moves: 3 },
{ team: "foe", label: { es: "Bandido", en: "Bandit" }, col: 9, row: 6, hp: 10, atk: 6, def: 1, moves: 3 },
{ team: "foe", label: { es: "Jefe", en: "Captain" }, col: 11, row: 4, hp: 16, atk: 7, def: 2, moves: 2, guard: true },
];
// #endregion
scene("game", () => {
let phase = "player", turn = 1; // phase: player · enemy · over
let selected = null, mode = null; // the hero receiving orders; mode: "move" (where to go) or "act" (whom to attack)
let marked = []; // highlighted tiles, as [column, row]
const tileUnder = () => [Math.floor((mousePos().x - OX) / TILE), Math.floor((mousePos().y - OY) / TILE)];
// #region board
// Draw the map, then place each unit from the roster on its tile.
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++) {
add([rect(TILE - 2, TILE - 2, { radius: 3 }), pos(OX + c * TILE + 1, OY + r * TILE + 1),
color({ ".": rgb(70, 110, 70), f: rgb(36, 78, 46), "#": rgb(110, 104, 100) }[terrain(c, r)])]);
if (terrain(c, r) === "f") add([polygon([vec2(0, -9), vec2(8, 7), vec2(-8, 7)]), pos(tileCenter(c, r)), color(70, 140, 80)]);
}
for (const d of ROSTER) {
const u = add([rect(28, 28, { radius: 6 }), pos(tileCenter(d.col, d.row)), anchor("center"), opacity(1), z(10),
color(d.team === "hero" ? rgb(80, 140, 240) : rgb(210, 80, 70)), outline(2, rgb(15, 15, 25)), "unit", d.team,
{ ...d, label: t(d.label), maxHp: d.hp, done: false }]);
u.add([text(u.label[0], { size: 16, font: FONT }), anchor("center")]);
u.onDraw(() => {
drawRect({ pos: vec2(-14, 16), width: 28, height: 4, color: rgb(40, 20, 20) });
drawRect({ pos: vec2(-14, 16), width: (28 * u.hp) / u.maxHp, height: 4, color: rgb(120, 230, 120) });
});
u.onUpdate(() => (u.opacity = u.done ? 0.5 : 1)); // greyed out once it has acted
}
const unitAt = (c, r) => get("unit").find((u) => u.col === c && u.row === r);
const adjacent = (a, c, r) => Math.abs(a.col - c) + Math.abs(a.row - r) === 1;
function place(u, c, r) {
[u.col, u.row] = [c, r];
tween(u.pos, tileCenter(c, r), 0.2, (p) => (u.pos = p), easings.easeOutQuad);
}
// #endregion
// #region movement-range
// Flood fill from the unit: a tile is reachable if the moves spent to get
// there fit in its budget. Forest costs more, rocks and enemies block,
// allies can be walked through but not stood on.
function reachable(u) {
const spent = new Map([[`${u.col},${u.row}`, 0]]), open = [[u.col, u.row]];
while (open.length) {
const [c, r] = open.shift();
for (const [dc, dr] of DIRS) {
const [nc, nr] = [c + dc, r + dr];
const cost = spent.get(`${c},${r}`) + stepCost(nc, nr);
const other = unitAt(nc, nr);
if (cost > u.moves || (other && other.team !== u.team) || spent.get(`${nc},${nr}`) <= cost) continue;
spent.set(`${nc},${nr}`, cost);
open.push([nc, nr]);
}
}
return [...spent.keys()].map((k) => k.split(",").map(Number)).filter(([c, r]) => !unitAt(c, r) || unitAt(c, r) === u);
}
// #endregion
// #region player-turn
// Click a hero, click a blue tile to move, then a red enemy to attack
// (or anywhere else to wait). When every hero has acted, the enemy plays.
const isMarked = (c, r) => marked.some(([mc, mr]) => mc === c && mr === r);
function finish(u) {
u.done = true;
[selected, mode, marked] = [null, null, []];
if (phase === "player" && get("hero").every((h) => h.done)) enemyPhase();
}
onMousePress(() => {
if (phase !== "player") return;
const [c, r] = tileUnder();
if (c < 0 || r < 0 || c >= COLS || r >= ROWS) return;
const u = unitAt(c, r);
if (mode === "move" && isMarked(c, r)) {
place(selected, c, r);
const targets = get("foe").filter((f) => adjacent(f, c, r));
if (!targets.length) return finish(selected); // nobody to attack: the turn of this unit ends
[mode, marked] = ["act", targets.map((f) => [f.col, f.row])];
} else if (mode === "act") {
if (u && isMarked(c, r)) fight(selected, u);
finish(selected);
} else if (u?.team === "hero" && !u.done) [selected, mode, marked] = [u, "move", reachable(u)];
else [selected, mode, marked] = [null, null, []];
});
// Cancel only before moving: once a unit moves, it has committed.
onButtonPress("cancel", () => mode === "move" && ([selected, mode, marked] = [null, null, []]));
onButtonPress("endTurn", () => phase === "player" && mode !== "act" && enemyPhase());
add([z(5)]).onDraw(() => {
for (const [c, r] of marked)
drawRect({ pos: vec2(OX + c * TILE + 2, OY + r * TILE + 2), width: TILE - 4, height: TILE - 4, // blue: move here · red: attack this
...(mode === "move" ? { color: rgb(90, 170, 255), opacity: 0.45 } : { fill: false, outline: { width: 4, color: rgb(255, 70, 60) } }) });
if (selected) drawRect({ pos: selected.pos.sub(17, 17), width: 34, height: 34, fill: false, outline: { width: 2, color: rgb(255, 240, 150) } });
});
// #endregion
// #region combat
// Damage is attack minus defence (minimum 1), with -1 on a forest tile.
// If the defender survives, it strikes back: attacking always has a cost.
const damage = (a, d) => Math.max(1, a.atk - d.def - (terrain(d.col, d.row) === "f" ? 1 : 0));
function strike(a, d) {
const dmg = damage(a, d);
d.hp -= dmg;
add([text(`-${dmg}`, { size: 18, font: FONT }), pos(d.pos.sub(0, 22)), anchor("center"), color(255, 230, 120), z(30),
move(vec2(0, -1), 30), opacity(1), lifespan(0.8, { fade: 0.4 })]);
if (d.hp <= 0) destroy(d);
}
function fight(a, d) {
strike(a, d);
if (d.hp > 0) strike(d, a); // counterattack
if (!get("foe").length || !get("hero").length) {
phase = "over";
wait(1, () => go("end", get("hero").length > 0, turn));
}
}
// #endregion
// #region enemy-ai
// A simple but readable AI. Each enemy looks at every tile it can reach and
// every hero next to that tile, and picks the best attack: a kill first,
// then the most damage. If nobody is in reach, it walks towards the heroes.
function enemyAct(e) {
let best = null;
for (const [c, r] of reachable(e))
for (const h of get("hero").filter((h) => adjacent(h, c, r))) {
const score = (damage(e, h) >= h.hp ? 100 : 0) + damage(e, h) * 10 - h.hp;
if (!best || score > best.score) best = { c, r, h, score };
}
if (best) {
place(e, best.c, best.r);
return wait(0.25, () => fight(e, best.h));
}
if (e.guard) return; // the captain holds its position
const distance = ([c, r]) => Math.min(...get("hero").map((h) => Math.abs(h.col - c) + Math.abs(h.row - r)));
const [c, r] = reachable(e).reduce((a, b) => (distance(b) < distance(a) ? b : a));
place(e, c, r);
}
function enemyPhase() {
[phase, selected, mode, marked] = ["enemy", null, null, []];
const foes = get("foe");
foes.forEach((e, i) => wait(ENEMY_DELAY * (i + 1), () => phase === "enemy" && e.exists() && enemyAct(e)));
wait(ENEMY_DELAY * (foes.length + 1) + 0.3, () => {
if (phase !== "enemy") return;
[phase, turn] = ["player", turn + 1];
get("hero").forEach((h) => (h.done = false));
});
}
// #endregion
// #region hud
// The side panel shows the unit under the cursor and, while aiming, a
// forecast of the exchange: in this genre, information is the strategy.
const banner = add([text("", { size: 14, font: FONT }), pos(OX, 10)]);
const info = add([text("", { size: 13, font: FONT, width: 128, lineSpacing: 3 }), pos(500, 40)]);
const button = add([rect(128, 30, { radius: 4 }), pos(500, 320), color(50, 56, 80), outline(2, rgb(150, 170, 255)), area()]);
button.add([text(t({ es: "Terminar turno (E)", en: "End turn (E)" }), { size: 13, font: FONT }), pos(64, 15), anchor("center")]);
button.onClick(() => phase === "player" && mode !== "act" && enemyPhase());
onUpdate(() => {
banner.text = phase === "enemy"
? t({ es: `Turno ${turn} · Juega el enemigo…`, en: `Turn ${turn} · Enemy phase…` })
: t({ es: `Turno ${turn} · Tu fase: mueve y ataca con cada unidad azul`, en: `Turn ${turn} · Your phase: move and attack with each blue unit` });
const u = unitAt(...tileUnder()) ?? selected;
const stats = u ? `${u.label}\n${t({ es: "Vida", en: "HP" })} ${u.hp}/${u.maxHp}\n${t({ es: "Ataque", en: "Attack" })} ${u.atk}\n${t({ es: "Defensa", en: "Defence" })} ${u.def}\n${t({ es: "Movimiento", en: "Move" })} ${u.moves}` : "";
const aim = mode === "act" && u?.team === "foe" && isMarked(u.col, u.row)
? `\n\n${t({ es: "Harás", en: "You deal" })} ${damage(selected, u)}\n${t({ es: "Te devuelve", en: "It hits back" })} ${damage(u, selected) * (damage(selected, u) < u.hp ? 1 : 0)}`
: "";
info.text = stats + aim + `\n\n${t({ es: "Bosque: cuesta 2 y\nresta 1 de daño", en: "Forest: costs 2 and\nblocks 1 damage" })}\n${t({ es: "Roca: no se pasa", en: "Rock: impassable" })}`;
});
// #endregion
});
// #region scenes
function screen(title, subtitle, next) {
add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 85)), anchor("center")]);
add([text(subtitle, { size: 15, font: FONT, align: "center", width: 580 }), pos(center().add(0, 25)), anchor("center"), color(200, 210, 255)]);
onButtonPress("start", next);
onClick(next);
}
scene("title", () =>
screen(
t({ es: "Rol táctico", en: "Tactical RPG" }),
t({
es: "Haz clic en una unidad azul: las casillas azules muestran hasta dónde llega. Haz clic en una para moverte y luego en un enemigo rojo adyacente para atacar (o en otro lugar para esperar). El enemigo juega cuando todas tus unidades actuaron.\nDerrota a todos los enemigos sin perder a todos tus héroes.\nHaz clic o pulsa espacio para empezar.",
en: "Click a blue unit: the blue tiles show how far it can go. Click one to move, then click an adjacent red enemy to attack (or anywhere else to wait). The enemy plays once all your units have acted.\nDefeat every enemy before you lose all your heroes.\nClick or press space to start.",
}),
() => go("game"),
),
);
scene("end", (won, turn) => screen(
won ? t({ es: "¡Victoria!", en: "Victory!" }) : t({ es: "Derrota", en: "Defeat" }),
won
? t({ es: `Despejaste el campo en ${turn} turnos. Haz clic para jugar otra vez.`, en: `You cleared the field in ${turn} turns. Click to play again.` })
: t({ es: "Cayeron todos tus héroes. Haz clic para intentarlo otra vez.", en: "All your heroes fell. Click to try again." }),
() => go("game"),
));
go("title");
// #endregionRetos de diseño
- Hacer legible la información, es decir, alcances, zonas de amenaza y previsión de daño, para que el jugador decida con datos.
- Una IA enemiga que sea un desafío sin hacer trampa y cuyas decisiones se entiendan.
- Equilibrar clases y mapas para que no haya una sola estrategia que gane siempre.
- Dosificar el azar y la muerte permanente para que la derrota se sienta justa y no arbitraria.
Historia
- 1982Origen
The Dragon and Princess (1982), de Koei, se considera precursor por su combate táctico por turnos en una pantalla aparte; Fire Emblem (1990) fijó el modelo.
Tactical role-playing game (Wikipedia) - 1983
Bokosuka Wars, de Koji Sumii, sienta las bases del género, que en Japón se llama «RPG de simulación».
Tactical role-playing game (Wikipedia) - 1990Fire Emblem: Shadow Dragon and the Blade of Light
Fire Emblem, de Intelligent Systems, une ideas de Dragon Quest con la estrategia por turnos y se vuelve el arquetipo del género. El personaje que se queda sin vida suele perderse para siempre.
Tactical role-playing game (Wikipedia) - 1992
Shining Force, para Mega Drive, suma todavía más elementos del rol de consola.
Tactical role-playing game (Wikipedia) - 1994UFO: Enemy Unknown
UFO: Enemy Unknown (X-COM) alterna la gestión de una base en tiempo real con misiones tácticas por turnos, y define la variante occidental del género en PC.
UFO: Enemy Unknown (Wikipedia) - 1997Final Fantasy Tactics
Final Fantasy Tactics, con batallas en campos isométricos en 3D y un sistema de oficios, es el que más hace por llevar el género a Norteamérica.
Tactical role-playing game (Wikipedia) - 2003
Disgaea: Hour of Darkness, de Nippon Ichi, mezcla el combate en casillas con paneles de colores que alteran el terreno y un «mundo de objetos» generado al azar. Su éxito da origen a toda una franquicia.
Disgaea: Hour of Darkness (Wikipedia) - 2012XCOM: Enemy Unknown
XCOM: Enemy Unknown, de Firaxis, reimagina X-COM con coberturas y muerte permanente, y reaviva el interés por la táctica por turnos.
XCOM: Enemy Unknown (Wikipedia) - 2025Pico-Saga
Pico-Saga lleva la fórmula de Fire Emblem a la consola de fantasía PICO-8, con mapas que se generan en cada partida y que se juegan en el navegador.
Pico-Saga 1.0 (Lexaloffle BBS, PICO-8)
Juegos de referencia
1990
Fire Emblem: Shadow Dragon and the Blade of Light
Mueves a tu ejército por un mapa de casillas en turnos alternos con el enemigo. Cada clase se mueve y combate distinto, las armas se gastan y el personaje que cae no vuelve en el resto de la campaña. Se volvió el arquetipo del género; fuera de Japón no salió hasta 2020.
1994
UFO: Enemy Unknown
Conocido en América como X-COM, alterna la defensa del planeta contra una invasión extraterrestre, en tiempo real, con misiones tácticas por turnos en las que tus soldados gastan unidades de tiempo para moverse y disparar. Los que caen no vuelven.
1997
Final Fantasy Tactics
Combates por turnos en campos isométricos tridimensionales, donde el terreno y el clima influyen. Un sistema de oficios permite convertir a cada personaje en guerrero, mago u otros roles. Fue el que más hizo por llevar el género a Norteamérica.
2012
XCOM: Enemy Unknown
Reimaginación de UFO: Enemy Unknown. Tu escuadra de hasta seis soldados actúa por turnos y se protege tras coberturas completas o medias; los soldados pueden morir para siempre.
2025
Pico-Saga
Rol táctico por turnos inspirado en Fire Emblem, con mapas generados al azar en cada partida, un triángulo de armas, terreno que cambia el movimiento y la evasión, y castillos que hay que asediar.
Ejercicio
En la demo, edita el mapa de texto para abrir o cerrar un paso entre rocas y observa cómo cambia el comportamiento de la IA. Luego agrega una cuarta unidad con números propios y discute qué papel cumple en el grupo.