Ir al contenido
wannacode

RPG 3: el juego completo

En resumen: El RPG completo: una clase Game lleva al héroe por batallas, descanso, niveles y experiencia, y un movimiento incorrecto lanza GameError, que el juego captura para seguir adelante.

Problema

De una pelea a un juego

Los héroes y los monstruos ya luchan. Pero un juego es más que una pelea: creas un héroe, alineas los monstruos contra los que va a pelear, y los va superando uno por uno. Entre las peleas descansa o se cura con magia.

Si todo esto está en el programa principal, ahí estarán el héroe, un array de monstruos, el número del siguiente, contadores y comprobaciones. ¿Y quién va a impedir que el jugador empiece una pelea sin héroe, o que le pida magia a un bárbaro?

El estado del juego y sus reglas tienen que estar en un solo lugar: en una clase Game.

La idea

La clase Game

El héroe y los monstruos son el estado del juego. Los métodos de Game son las reglas: qué puede hacer el jugador y qué pasa después. La primera es crear un héroe:

private Character hero;
private Monster[] monstruos = new Monster[5];
private int count;
private int next;
private Random random;
public Game(Random random) {
this.random = random;
}
public void createHero(String name, String race) {
if (race.equals("paladín")) {
hero = new Paladin(name);
} else if (race.equals("bárbaro")) {
hero = new Barbarian(name);
} else if (race.equals("bruja")) {
hero = new Witch(name);
} else {
throw new GameException("No existe esa raza: " + race);
}
}
El generador de números aleatorios viene de fuera, por el constructor: es una función que devuelve un número de 0 a 1 cada vez que la llamas. En el juego real será Math.random, y en los ejemplos es makeRandom(12) de la lección «Cómo pensar un juego», porque Math.random no tiene semilla y la salida tiene que ser siempre la misma.Los campos empiezan con #: son privados y solo se ven dentro de la clase Game. new Array(5) es un array con 5 lugares vacíos.

Una raza desconocida no es motivo para crear un héroe cualquiera. El método lanza GameError: nuestra propia excepción para cada movimiento que las reglas no permiten. Tiene su propia tarjeta más adelante.

La idea

Una fila de monstruos

Los monstruos esperan en una cola: el que se añadió primero es la primera pelea. Aquí la cola es un array y dos índices:

public void addMonster(Monster monster) {
if (count == monstruos.length) {
throw new GameException("La fila está llena: como máximo " + monstruos.length + " monstruos");
}
monstruos[count] = monster;
count++;
}
public Monster createRandomMonster() {
int kind = random.nextInt(3);
if (kind == 0) {
return new FallenShaman();
}
if (kind == 1) {
return new SkeletonKing();
}
return new Butcher();
}
public boolean hasMonsters() {
return next < count;
}
  • #count es cuántos monstruos se han añadido. El nuevo va a this.#monsters[this.#count].
  • #next es cuál pelea a continuación. Después de cada pelea sube 1, y cuando llega a #count, la fila se acaba.
El array tiene 5 lugares. El sexto monstruo no entra: addMonster lanza GameError. Aquí la comprobación es lo único que mantiene la regla «como máximo 5»: un array en JavaScript crece solo, y this.#monsters[5] = monster no es un error sino un sexto lugar.

createRandomMonster es el extra del ejercicio antiguo: un monstruo elegido al azar. Math.floor(this.#random() * 3) da 0, 1 o 2: el número de 0 a 1 por 3 va de 0 a 3 (sin incluir el 3), y Math.floor corta la parte decimal.

Más precisamente, para curiosos
  • El ejercicio antiguo guardaba los monstruos en una cola de verdad. JavaScript no tiene una clase de cola aparte: una cola es un array donde push añade por detrás y shift toma por delante. Llega en el módulo Colecciones: ahí desaparecen #count y #next.
  • Los monstruos anteriores a #next se quedan en el array. No hacen daño: nadie los lee ya.

La idea

Niveles y experiencia

Después de cada victoria el héroe recibe experiencia igual a la vida del monstruo. En el nivel 1 necesita 100 de experiencia para llegar al nivel 2, en el nivel 2 necesita 200 para llegar al 3, y así sucesivamente. Un nivel nuevo da 10 de vida más, 3 de fuerza más y la vida completa:

public boolean gainExperience(int exp) {
experience += exp;
boolean up = false;
while (experience >= level * 100) {
experience -= level * 100;
levelUp();
up = true;
}
return up;
}
public void levelUp() {
level++;
maxHealth += 10;
power += 3;
health = maxHealth;
}

Los métodos están en Character. La experiencia que pasa del umbral se guarda para el nivel siguiente. El bucle while es para el caso en que una victoria alcanza para dos niveles.

Los campos _maxHealth, _level y _experience son nuevos en Character. Con _maxHealth, curar ya no sube la vida por encima del máximo. Desde fuera se leen con las propiedades maxHealth, level y experience, como los campos de la primera parte.

Pruébalo

¿En qué nivel está el bárbaro?

Grok empieza en el nivel 1 con fuerza 20. Gana contra el chamán (60 de experiencia) y contra el rey (90 de experiencia).

¿Qué imprimirá el programa? Nivel, experiencia, fuerza.

gainExperience(exp) {
this._experience += exp;
let up = false;
while (this._experience >= this._level * 100) {
this._experience -= this._level * 100;
this.levelUp();
up = true;
}
return up;
}
levelUp() {
this._level++;
this._maxHealth += 10;
this._power += 3;
this._health = this._maxHealth;
}
const grok = new Barbarian("Grok");
grok.gainExperience(60);
grok.gainExperience(90);
console.log(grok.level, grok.experience, grok.power);
Respuesta

Míralo

La batalla y el descanso

startBattle primero comprueba si la pelea es posible. Después toma el siguiente monstruo y ejecuta la pelea a muerte de la segunda parte. Devuelve si el héroe sobrevivió:

public boolean startBattle() {
if (hero == null) {
throw new GameException("Crea primero un héroe");
}
if (!hasMonsters()) {
throw new GameException("No hay monstruos en la fila");
}
Monster monster = monstruos[next];
next++;
int rounds = 0;
while (hero.isAlive() && monster.isAlive()) {
rounds++;
hero.attack(monster);
if (monster.isAlive()) {
monster.attack(hero);
}
}
if (!hero.isAlive()) {
System.out.println(hero.getName() + " cae en la ronda " + rounds + ". Ganador: " + monster.getName());
return false;
}
System.out.println(monster.getName() + " es derrotado en " + rounds + " rondas. " + hero.getName() + ": vida " + hero.getHealth() + "/" + hero.getMaxHealth());
if (hero.gainExperience(monster.getReward())) {
System.out.println(hero.getName() + " llega al nivel " + hero.getLevel() + ": vida " + hero.getMaxHealth() + ", fuerza " + hero.getPower());
}
return true;
}

recover es la elección después de la pelea. Solo un héroe que tiene un método heal (typeof hero.heal es "function") puede usar magia. El descanso son 5 turnos de 10 de vida:

public void recover(String way) {
if (way.equals("descanso")) {
for (int turn = 0; turn < 5; turn++) {
hero.restore(10);
}
System.out.println(hero.getName() + " descansa 5 turnos: vida " + hero.getHealth() + "/" + hero.getMaxHealth());
} else if (way.equals("magia")) {
if (!(hero instanceof IHealable)) {
throw new GameException(hero.getName() + " no domina la magia curativa");
}
System.out.println(hero.useAbility());
} else {
throw new GameException("Elección desconocida «" + way + "»: ¿descanso o magia?");
}
}

En el ejercicio antiguo el héroe esperaba 5 segundos. Aquí el descanso se cuenta en turnos, porque en un juego por turnos el tiempo se mide en turnos, y la comprobación del juego no debería tener que esperar.

Míralo

El juego completo

Grok el bárbaro contra el chamán y dos monstruos aleatorios. Después de cada pelea descansa. Si cae, el juego termina:

Game game = new Game(new Random(12));
game.createHero("Grok", "bárbaro");
game.addMonster(new FallenShaman());
game.addMonster(game.createRandomMonster());
game.addMonster(game.createRandomMonster());
while (game.hasMonsters()) {
if (!game.startBattle()) {
System.out.println("Fin del juego");
break;
}
game.recover("descanso");
}
Salida
Chamán Caído es derrotado en 3 rondas. Grok: vida 90/110
Grok descansa 5 turnos: vida 110/110
Chamán Caído es derrotado en 3 rondas. Grok: vida 90/110
Grok llega al nivel 2: vida 120, fuerza 23
Grok descansa 5 turnos: vida 120/120
Carnicero es derrotado en 6 rondas. Grok: vida 70/120
Grok descansa 5 turnos: vida 120/120
Compara la salida en las pestañas. La semilla es 12 en todos los lenguajes, pero los generadores son distintos, y también lo son los monstruos aleatorios. En Java Grok se encuentra con un segundo chamán y con el Carnicero, en C# con el Carnicero y el Rey Esqueleto, y en Python y JavaScript con el Rey Esqueleto dos veces.

const game = new Game(makeRandom(12));
game.createHero("Grok", "bárbaro");
game.addMonster(new FallenShaman());
game.addMonster(game.createRandomMonster());
game.addMonster(game.createRandomMonster());
while (game.hasMonsters()) {
if (!game.startBattle()) {
console.log("Fin del juego");
break;
}
game.recover("descanso");
}
Paso a paso, en palabras
  1. El juego recibe un generador con semilla 12. El héroe es Grok, y el array tiene 5 lugares, todavía vacíos.
  2. Cada addMonster() escribe en this.#monsters[this.#count] y sube this.#count. El segundo y el tercero son aleatorios: Rey Esqueleto y Rey Esqueleto.
  3. this.#next vale 0: sale Chamán Caído. Es derrotado en 3 rondas, y a Grok le quedan 90/110.
  4. this.#next pasa a 1. El descanso son 5 turnos de 10 de vida: 110/110.
  5. this.#next vale 1: sale Rey Esqueleto. Es derrotado en 6 rondas, y a Grok le quedan 45/110. La experiencia alcanza para el nivel 2: vida 120, fuerza 23.
  6. this.#next pasa a 2. El descanso son 5 turnos de 10 de vida: 120/120.
  7. this.#next vale 2: sale Rey Esqueleto. Es derrotado en 5 rondas, y a Grok le quedan 68/120.
  8. this.#next pasa a 3. El descanso son 5 turnos de 10 de vida: 118/120.
  9. this.#next vale 3, igual que this.#count: hasMonsters() devuelve false, y el bucle termina.

La idea

Un movimiento incorrecto: GameError

El jugador puede pedir una pelea sin héroe, una raza que no existe, o magia a un bárbaro. Eso no es un error del programa. Es un movimiento que las reglas no permiten. Los métodos de Game lanzan para eso su propia excepción:

class GameException extends RuntimeException {
public GameException(String message) {
super(message);
}
}
En JavaScript el nombre de una excepción propia termina en Error, como las incorporadas: TypeError, RangeError.

El juego captura solo esa, le dice al jugador por qué el movimiento no está permitido y sigue con el movimiento siguiente:

Game game = new Game(new Random(12));
String[] commands = { "pelea", "héroe Grok dragón", "héroe Grok bárbaro", "magia", "dormir" };
for (String command : commands) {
String[] words = command.split(" ");
try {
if (words[0].equals("pelea")) {
game.startBattle();
} else if (words[0].equals("héroe")) {
game.createHero(words[1], words[2]);
System.out.println("El héroe es " + words[1]);
} else {
game.recover(words[0]);
}
} catch (GameException e) {
System.out.println("No se puede: " + e.getMessage());
}
}
Salida
No se puede: Crea primero un héroe
No se puede: No existe esa raza: dragón
El héroe es Grok
No se puede: Grok no domina la magia curativa
No se puede: Elección desconocida «dormir»: ¿descanso o magia?
En JavaScript hay un solo catch, y captura todo. Por eso dentro, e instanceof GameError pregunta si es un rechazo según las reglas, y throw e; lanza todo lo demás hacia arriba.

¿Por qué una excepción propia y no la incorporada Error? Porque la comprobación e instanceof GameError deja pasar solo los rechazos según las reglas. Si el juego tiene un error y lanza TypeError, no se esconderá como «No se puede». Detendrá el programa, y lo verás. Por ejemplo, recover antes de que haya un héroe: this.#hero es null, y hero.restore(10) da TypeError: Cannot read properties of null (reading 'restore').

Pruébalo

Encuentra el error

El sexto monstruo debería recibir «La fila está llena». En cambio, entra: el programa imprime «Monstruos: 6» y no dice nada. ¿En qué línea está el error?

Elige la línea con el error.

Pruébalo

¿Qué falta?

¿De qué clase hereda GameError?

class GameError extends  {
  constructor(message) {
    super(message);
    this.name = "GameError";
  }
}

Ejercicio

Juega y amplía

Resuelve estos ejercicios en tu editor. Empieza con las clases de la lección.

1. Un menú. Los comandos llegan por la entrada, uno por línea, hasta «salir»:

  • «héroe Nombre raza» crea el héroe;
  • «monstruo chamán» (o «rey», «carnicero») añade un monstruo ya hecho, y «monstruo Nombre vida fuerza» añade uno tuyo: una clase Beast de la raza «bestia»;
  • «azar» añade un monstruo aleatorio;
  • «pelea» empieza la siguiente pelea, y «descanso» y «magia» son la elección después de ella.

Cada rechazo es una GameError que el menú captura, diciendo «No se puede: …». La semilla es 12. Las líneas se leen con await ask(""), y después de «salir», rl.close() cierra la entrada.

Entrada
héroe Morena elfo
héroe Morena bruja
monstruo Lobo 50 9
monstruo dragón
monstruo chamán
azar
pelea
magia
pelea
descanso
pelea
salir
Salida
No se puede: No existe esa raza: elfo
El héroe es Morena
En la fila: Lobo
No se puede: No existe ese monstruo: dragón
En la fila: Chamán Caído
En la fila: Rey Esqueleto
Lobo es derrotado en 4 rondas. Morena: vida 68/95
Morena invoca a los elementos: vida 83/95
Chamán Caído es derrotado en 4 rondas. Morena: vida 47/95
Morena llega al nivel 2: vida 105, fuerza 19
Morena descansa 5 turnos: vida 105/105
Rey Esqueleto es derrotado en 7 rondas. Morena: vida 9/105

2. Progreso. Añade a Game un método showProgress() que muestre el nivel del héroe y cuánta experiencia tiene de la que necesita para el siguiente nivel. Ardan el paladín pasa por dos chamanes y el Rey Esqueleto. Después de cada pelea muestra su progreso y se cura con magia.

Salida
Chamán Caído es derrotado en 5 rondas. Ardan: vida 72/120
Nivel 1, experiencia 60/100
Ardan se cura: vida 92/120
Chamán Caído es derrotado en 5 rondas. Ardan: vida 44/120
Ardan llega al nivel 2: vida 130, fuerza 17
Nivel 2, experiencia 20/200
Ardan se cura: vida 130/130
Rey Esqueleto es derrotado en 8 rondas. Ardan: vida 18/130
Nivel 2, experiencia 110/200
Ardan se cura: vida 38/130

3. Un jefe. Una clase Diablo, subclase de Monster: 160 de vida, 14 de fuerza. Uno de cada tres golpes suyos es doble. Un método canChallenge(hero) dice si el héroe está al menos en el nivel 2. Si no, startBattle lanza GameError, y Diablo se queda en la fila. Ejecútalo una vez contra Grok en el nivel 1, y luego en un juego nuevo después del chamán y del rey.

Salida
No se puede: Diablo solo pelea contra héroes de nivel 2 o más
Chamán Caído es derrotado en 3 rondas. Grok: vida 90/110
Grok descansa 5 turnos: vida 110/110
Rey Esqueleto es derrotado en 6 rondas. Grok: vida 45/110
Grok llega al nivel 2: vida 120, fuerza 23
Grok descansa 5 turnos: vida 120/120
Diablo es derrotado en 7 rondas. Grok: vida 24/120
Grok llega al nivel 3: vida 130, fuerza 26
Grok descansa 5 turnos: vida 130/130

Sigue por tu cuenta

El ejercicio antiguo proponía más ideas. Algunas necesitan una herramienta que llega más adelante:

  • Habilidades con niveles. Una clase Skill con un nombre y un nivel, useSkill(target) y upgradeSkill(). Esto ya puedes hacerlo, con un array de habilidades en el héroe.
  • Inventario. Una clase Item con un nombre y un efecto, applyEffect(target), y un Inventory donde se añaden y se quitan objetos. Añadir funciona incluso ahora: un array crece con push. Quitar un elemento del medio llega en el módulo Colecciones.
  • Jefes con requisitos. Un jefe que pide ciertos objetos y habilidades antes de aceptar la pelea, y suelta botín después de una victoria. Necesita el inventario de arriba.
  • Pantallas. Menú, mapa, inventario: cada pantalla es un objeto con show(), hide() y update(), y las pantallas abiertas están en una pila, para que «volver» funcione. La pila llega en el módulo Colecciones.
  • Guardar y cargar. El héroe, el nivel y la mejor puntuación se escriben en un archivo y se leen en la siguiente ejecución. Los archivos llegan en el módulo Texto y archivos.

Test

Ponte a prueba

1. La fila es un array de 5 lugares, #count vale 3, #next vale 1. ¿Qué significa eso?
2. El héroe está en el nivel 1 con 80 de experiencia y gana 130 de experiencia. ¿Qué pasa?
3. ¿Por qué el juego captura solo GameError y no todas las excepciones?
4. ¿Por qué el descanso dura 5 turnos y no 5 segundos de espera?
5. catch comprueba e instanceof GameError, y si no hace throw e. ¿Qué pasa si llega un TypeError?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·