El juego que vamos a hacer
En resumen: Las crónicas de Arcanum es un RPG de consola completo que se construye lección a lección: un mapa con campo de visión, combate por rapidez, heridas, objetos, misiones y guardado, en un solo archivo en cada uno de los cuatro lenguajes.
Problema
Un juego que quieras jugar
Hasta ahora los juegos eran ejercicios: razas que heredan de Character, un combate de diez turnos, salas con comandos. Enseñaban el lenguaje, y el juego era solo un pretexto. Nadie los jugaría dos veces.
Ahora hacemos lo contrario: un juego completo que se juega, y cada técnica entra porque el juego la necesita. Un mapa que se descubre mientras caminas. Un combate en el que el orden depende de la rapidez y una herida sigue sangrando en los turnos siguientes. Enemigos que te persiguen y huyen cuando están mal. Objetos, una tienda, niveles, diálogos, misiones, guardado. Y debajo, una pila de pantallas que lo mantiene todo en orden.
Se llama «Las crónicas de Arcanum». Los mineros de la vieja mina bajo Vadogris desaparecen, y en el camino hacia ella hay sangre. Bajas tres niveles. En el fondo te espera el Guardián abisal.
Pruébalo
Juega
Este es el juego de verdad: la versión de JavaScript, ejecutándose en el navegador. Elige una clase, habla con el anciano, compra algo y baja a la mina. En el mapa eres @, y las letras son enemigos. Si vas hacia un enemigo, empieza un combate.
Las crónicas de Arcanum
La mina bajo Vadogris lleva tres días en silencio. Hay sangre en el camino.
Haz clic en la pantalla para jugar con las teclas (flechas o WASD, I, M, dígitos); Enter pasa a la línea de comandos. Las partidas guardadas se quedan en este navegador.
En el mapa los comandos son w a s d (o las flechas, después de hacer clic en la pantalla), g recoge, i es el inventario, c es el héroe, m es el menú con guardar y cargar. En combate: 1 golpe, 2 habilidad, 3 objeto, 4 huida, y el objetivo se elige con un dígito. ? muestra la ayuda.
Más precisamente, para curiosos
El juego en el navegador es el mismo archivo que verás en las lecciones y ejecutarás en tu computadora. Solo se reemplaza el bloque que en Node lee los argumentos y escribe las partidas guardadas en archivos: aquí la entrada viene del campo bajo la pantalla, y las partidas se guardan en la memoria del navegador (localStorage). Cada juego nuevo tiene una semilla nueva, así que el mundo es distinto.
La idea
Qué hay dentro
- Tres clases. El bárbaro (mucha vida, un hacha cuyas heridas sangran, «Ira»), la amazona (una lanza, más golpes críticos, «Doble tiro») y la bruja (poca vida, maná, fuego, veneno y curación). Cada una tiene un árbol de habilidades con dos ramas.
- Un mapa con campo de visión. Ves solo aquello a lo que tienes una línea recta, y lo visto queda en la memoria (niebla). Tres niveles, escaleras, agua, puertas, objetos en el suelo.
- Combates en los que el orden va por rapidez. Puntería contra esquiva, daño según el arma, la fuerza y la defensa, golpes críticos, tipos de daño: cortante, perforante, contundente, fuego, veneno.
- Heridas y efectos. Un arma cortante abre una herida que sangra cada turno, y las heridas se acumulan. Un enemigo que sangra deja un rastro en el mapa, y puedes seguirlo hasta su guarida. También hay veneno, quemadura, aturdimiento, regeneración, ira y escudo.
- Enemigos que piensan. En el mapa duermen, deambulan, te persiguen por el camino más corto y huyen cuando están mal. En combate calculan qué les conviene más: un golpe, una habilidad o curarse.
- Objetos a partir de tablas. Armas, armaduras, anillos y pociones, botín con pesos, una tienda, un inventario con ranuras.
- Niveles, diálogos, tres misiones, un bus de eventos, guardar y cargar (una partida guardada rota se rechaza con una explicación), repetición del combate y una simulación para el balance.
El recorrido completo con el que se comprueba el juego son 230 comandos y 4396 líneas de salida: desde crear al héroe hasta vencer al jefe.
La idea
El mapa del código
El juego es un archivo de unas 5500 líneas en cada lenguaje. Un solo archivo, para ejecutarlo con un solo comando. Por dentro está dividido en partes (regiones), y cada parte está en una de seis capas:
| Capa | Partes | Líneas |
|---|---|---|
| Datos y textos | text items-data enemies-data skills-data dialogs-data maps-data data-load map-load data-load-all | 1549 |
| Entrada, salida, dibujo | random io-text buffer terminal hud | 308 |
| Las pantallas y la pila | screen screen-stack title create-hero town shop map-screen battle-screen replay-screen target-screen overlays | 1259 |
| El mundo y las reglas | event-bus entity inventory equip loot xp-levelup quests dialog level fov game save load | 1389 |
| Combate, efectos, IA | effects bleeding combat-order attack actions replay ai-battle pathfind ai-map | 857 |
| Ejecución y balance | simulate main node | 176 |
Las cifras son de la versión de JavaScript y se calculan al construir el sitio. Casi la mitad de las líneas son datos y textos: los objetos, los enemigos, los mapas y los diálogos son tablas, y cada texto del juego está en cuatro idiomas. La lógica no conoce el nombre de ni una sola espada.
Más precisamente, para curiosos
En un proyecto real las partes están en archivos y paquetes separados: screens/, world/, combat/, data/. Aquí están en un solo archivo por las lecciones, pero los límites son los mismos: el código de una pantalla no toca las reglas del combate, y el combate no imprime nada por sí mismo. Si quieres dividirlo, cortas por las regiones.
La idea
El bucle del juego
El corazón de todo juego es un bucle que da vueltas hasta que el juego termina. Aquí está en dos sitios: la pila de pantallas dice qué pantalla está arriba, y el bucle la dibuja, lee un comando y se lo entrega:
// El bucle del juego: dibujar → leer un comando → procesarlo → actualizar el mundo → aplicar los cambios// a la pila. Se repite hasta que la pila queda vacía.
// Entrada y salida: io con cuatro métodos. El bloque boot da la consola, la página da su propio io.interface Io { void write(String text);
String readLine();
void save(String slot, String text);
String load(String slot);
void close();}
static void main(Io io, Options options) { // La semilla es un número de 32 bits sin signo (en JavaScript Number(...) >>> 0); aquí son los mismos 32 bits en un int. int seed = (int) (long) options.seed; if (!options.lang.equals("bg") && !options.lang.equals("en") && !options.lang.equals("de") && !options.lang.equals("es")) { throw new RuntimeException("unknown language: " + options.lang + " (use bg, en, de or es)"); } LANG = options.lang; if (options.memorySaves) io.save("3", tamperedSave()); loadData(); int simulateCount = (int) options.simulate; if (simulateCount > 0) { simulate(io, simulateCount, seed); return; } Terminal terminal = options.plain ? new PlainTerminal(io) : new AnsiTerminal(io); Game game = new Game(io, seed); CellBuffer buffer = new CellBuffer(SCREEN_WIDTH, SCREEN_HEIGHT); game.stack.push(new TitleScreen(game)); game.stack.apply(); while (!game.stack.isEmpty()) { buffer.clear(); game.stack.draw(buffer); terminal.present(buffer); String line = io.readLine(); if (line == null) { io.write(t("ui.endOfInput") + "\n"); return; } terminal.echo(line); game.rawInput = trimSpaces(line); Screen top = game.stack.top(); top.handle(game.rawInput.toLowerCase(Locale.ROOT)); top.update(); game.stack.apply(); } io.write(t("ui.goodbye") + "\n");}Cada comando es un turno. El bucle no sabe nada de mapas, combates ni tiendas: llama a draw, handle y update de la pantalla de arriba y luego aplica los cambios a la pila. La pantalla decide todo lo demás: el mapa mueve al héroe, el combate lanza un golpe, el menú guarda. Por eso una pantalla nueva no toca el bucle.
Aquí tienes un turno real del recorrido, seguido por dentro: el comando «1» en el combate con el goblin baja por las seis capas del mapa del código y vuelve a subir como el nuevo fotograma.
// El bucle del juego: dibujar → leer un comando → procesarlo → actualizar el mundo → aplicar los cambios// a la pila. Se repite hasta que la pila queda vacía.
// Entrada y salida: io con cuatro métodos. El bloque boot da la consola, la página da su propio io.interface Io { void write(String text);
String readLine();
void save(String slot, String text);
String load(String slot);
void close();}
static void main(Io io, Options options) { // La semilla es un número de 32 bits sin signo (en JavaScript Number(...) >>> 0); aquí son los mismos 32 bits en un int. int seed = (int) (long) options.seed; if (!options.lang.equals("bg") && !options.lang.equals("en") && !options.lang.equals("de") && !options.lang.equals("es")) { throw new RuntimeException("unknown language: " + options.lang + " (use bg, en, de or es)"); } LANG = options.lang; if (options.memorySaves) io.save("3", tamperedSave()); loadData(); int simulateCount = (int) options.simulate; if (simulateCount > 0) { simulate(io, simulateCount, seed); return; } Terminal terminal = options.plain ? new PlainTerminal(io) : new AnsiTerminal(io); Game game = new Game(io, seed); CellBuffer buffer = new CellBuffer(SCREEN_WIDTH, SCREEN_HEIGHT); game.stack.push(new TitleScreen(game)); game.stack.apply(); while (!game.stack.isEmpty()) { buffer.clear(); game.stack.draw(buffer); terminal.present(buffer); String line = io.readLine(); if (line == null) { io.write(t("ui.endOfInput") + "\n"); return; } terminal.echo(line); game.rawInput = trimSpaces(line); Screen top = game.stack.top(); top.handle(game.rawInput.toLowerCase(Locale.ROOT)); top.update(); game.stack.apply(); } io.write(t("ui.goodbye") + "\n");}Paso a paso, en palabras
- Comando 56 del recorrido: Iva (30/48) está luchando contra un goblin lancero (16/16). El jugador escribe «1»: golpe.
- Entrada, salida, dibujo: io.readLine() devuelve «1». Para esta capa es solo texto: no sabe nada del combate.
- Las pantallas y la pila: arriba está el combate, debajo el mapa. La pantalla superior recibe «1» y, con un solo enemigo, llama a battle.heroAct(new AttackAction(…)).
- Aún dentro de top.handle: las reglas. El dado con semilla (hacha de mano, 4–7 en la tabla) da 6. 6 + fuerza 7 − defensa 1 = 12: «Iva aplasta a goblin lancero (12).»
- El hacha corta y abre una herida (35 % de probabilidad): «Goblin lancero sangra.» En el turno del goblin le quita 1 (16 → 4 → 3): «¡Goblin lancero sufre una herida grave!» Contraataca: «Goblin lancero araña a Iva (1).»
- update y apply: nadie llamó a push ni a pop, así que el combate sigue arriba. El combate no imprimió nada: sus 5 líneas nuevas esperan en su registro.
- Siguiente vuelta: la pantalla dibuja en el búfer de 60 × 18 (Iva 29/48, goblin 3/16, «sang:2» = 2 turnos más, las últimas 7 líneas del registro), la terminal lo imprime.
- Un comando, seis capas, cada una con su propio trabajo. El combate no sabe nada de la pantalla, así que también puede correr sin ella: eso es la simulación.
El bucle espera la línea y no hace nada más (la lectura bloquea). Cuando la entrada termina (no hay más líneas), el juego acaba limpiamente.
La idea
Un código, cuatro lenguajes, una salida
El juego está escrito cuatro veces: en Java, C#, Python y JavaScript, parte por parte y con los mismos nombres. La comprobación del sitio lo ejecuta en los cuatro lenguajes con los 230 comandos grabados y exige byte por byte la misma salida. Si una versión tira un dado de otra manera, el combate toma otro camino y la comprobación falla.
¿Cómo se consigue, si cada lenguaje tiene su propio Random? El juego no usa el generador integrado. Tiene uno propio, pequeño e igual en todas partes, solo con números enteros:
// mulberry32: un generador pequeño con semilla. Solo enteros de 32 bits (int y >>>), por eso// da la misma secuencia en todos los lenguajes.static class Random { int state;
Random(int seed) { this.state = seed; }
int next32() { this.state = this.state + 0x6d2b79f5; int z = this.state; z = (z ^ (z >>> 15)) * (z | 1); z = z ^ (z + (z ^ (z >>> 7)) * (z | 61)); return z ^ (z >>> 14); }
// De 0 a n-1, sin fracciones. int nextInt(int n) { return (int) (Integer.toUnsignedLong(this.next32()) % n); }
// De min a max, ambos incluidos. int range(int min, int max) { return min + this.nextInt(max - min + 1); }
boolean chance(int percent) { return this.nextInt(100) < percent; }}La misma semilla da los mismos números, y los mismos números dan el mismo combate. Eso es útil más allá de la comprobación. Un error que un jugador reporta («en el turno 412 el lobo me golpeó a través de una pared») se repite exactamente si tienes la semilla y los comandos. La repetición del combate en el juego funciona igual.
Más precisamente, para curiosos
Las reglas para que la salida sea idéntica: la división es solo entera; las colecciones conservan el orden de inserción; ordenar siempre tiene una segunda regla (dos enemigos con la misma rapidez se ordenan por número), porque no toda ordenación es estable. Los números se rellenan a mano, sin formato que dependa del idioma del sistema. Parece poca cosa, pero cada una de estas reglas ha atrapado una diferencia entre las versiones.
Un fotograma del recorrido, en el segundo nivel de la mina:
#.# #####.##### Vetas de plata . ~~~~~~.~~~~~~ Nivel 2 de 3 ~~~~~~.~~~~~~ ~~~~~~.~~~~~~ Alrededor: #####.##### G gólem de mena ###########.############# ..................#......# ..................#......# .G....@...........'......# ..................#......# ..................#....!.# ..................#......# ################# #######Iva, bárbaro nv.4 exp 148/170 oro 37V [##########--] 74/85 M [########] 7/7Recoges: mineral de plata.Plata para la herrera: Lleva el mineral a la herrera Vela.¡Gólem de mena se despierta!Y el combate con el jefe, después de que ha invocado a sus ayudantes:
=== Combate ===Iva, bárbaro nv.6 V [#######-----] 63/103 M [###-----] 4/91) Guardián abisal [###-----] 53/120 furia2) Siervo de piedra [########] 14/143) Siervo de piedra [########] 14/14 Guardián abisal araña a Iva (7). Iva golpea a Guardián abisal (17). Guardián abisal no alcanza a Iva. Iva golpea a Guardián abisal (14). Guardián abisal se agrieta y ruge. ¡Dos sirvientes de piedra salen de las grietas! Escudo deja de afectar a Iva.1 golpe 2 habilidad 3 objeto 4 huidaLa idea
Ejecútalo en tu computadora
El archivo completo está aquí: [Arcanum.java](/games/arcanum/es/Arcanum.java), [Arcanum.cs](/games/arcanum/es/Arcanum.cs), [arcanum.py](/games/arcanum/es/arcanum.py), [arcanum.js](/games/arcanum/es/arcanum.js). Ponlo en una carpeta vacía y ejecútalo:
java Arcanum.javaEn una terminal el juego borra la pantalla y usa colores: la sangre es roja, el maná azul, el veneno verde. Con --plain imprime los fotogramas como texto simple. Con --seed=42 el mundo es siempre el mismo, y con --lang=es el juego está en español (también existen bg, en y de). Las partidas guardadas van a una carpeta saves junto al juego.
javac.La idea
Cómo lo vamos a construir
Cada lección siguiente toma una parte del juego, explica por qué es como es y la construye con programas más pequeños que puedes ejecutar. Después muestra la misma parte en el juego real.
- El bucle del juego: entrada, actualización, dibujo, turno frente a fotograma, semilla.
- Una pila de pantallas: la pantalla como objeto, push, pop y replace, pantallas transparentes.
- Pantallas que devuelven una respuesta: elegir un objetivo, «¿Seguro?», cambios aplazados.
- Dibujar en la consola: un búfer de celdas, marcos, barras, colores.
- El mapa y el campo de visión: un mapa a partir de texto, colisión, cámara, campo de visión.
- Criaturas y atributos: composición, atributos por capas.
- El sistema de combate: orden por rapidez, puntería, daño, golpe crítico.
- Sangre, heridas y efectos: sangrado, veneno, quemadura, acumulación, el rastro.
- Habilidades y acciones como comandos: maná, recarga, repetición del combate.
- Enemigos que piensan: máquina de estados, búsqueda de caminos, utilidad.
- Objetos, inventario, botín: datos a partir de tablas, equipo, tienda.
- Niveles, diálogos, misiones: experiencia, árbol de habilidades, bus de eventos.
- Guardar y cargar: serialización, versión, comprobación.
- El juego completo y el balance: la simulación, los números, ideas para continuar.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Excepciones · Listas · Pila, cola y lista enlazada · HashMap y HashSet