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 GameException, 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 Main, 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. En el juego real será new Random(), y en los ejemplos tiene una semilla, para que la salida sea siempre la misma.

Una raza desconocida no es motivo para crear un héroe cualquiera. El método lanza GameException: 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 monsters[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 GameException antes de escribir fuera del array.

CreateRandomMonster es el extra del ejercicio antiguo: un monstruo elegido al azar.

Más precisamente, para curiosos
  • El ejercicio antiguo guardaba los monstruos en una cola de verdad, Queue<Monster>. Una cola recuerda por sí misma cuál es el primero, y nunca se llena. La lista List<T> llega en el módulo Web y escritorio, y Queue<T> es de la misma biblioteca y se usa igual: Enqueue añade por detrás, Dequeue toma por delante.
  • 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.

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.

public bool GainExperience(int exp)
{
Experience += exp;
bool up = false;
while (Experience >= Level * 100)
{
Experience -= Level * 100;
LevelUp();
up = true;
}
return up;
}
public void LevelUp()
{
Level++;
MaxHealth += 10;
Power += 3;
Health = MaxHealth;
}
Character grok = new Barbarian("Grok");
grok.GainExperience(60);
grok.GainExperience(90);
Console.WriteLine(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 implementa IHealable 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 dos pestañas. La semilla es 12 en los dos 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, y en C# con el Carnicero y el Rey Esqueleto.

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())
{
Console.WriteLine("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 monsters[count] y sube count. El segundo y el tercero son aleatorios: Carnicero y Rey Esqueleto.
  3. next vale 0: sale Chamán Caído. Es derrotado en 3 rondas, y a Grok le quedan 90/110.
  4. next pasa a 1. El descanso son 5 turnos de 10 de vida: 110/110.
  5. next vale 1: sale Carnicero. Es derrotado en 7 rondas, y a Grok le quedan 48/110. La experiencia alcanza para el nivel 2: vida 120, fuerza 23.
  6. next pasa a 2. El descanso son 5 turnos de 10 de vida: 120/120.
  7. next vale 2: sale Rey Esqueleto. Es derrotado en 5 rondas, y a Grok le quedan 68/120.
  8. next pasa a 3. El descanso son 5 turnos de 10 de vida: 118/120.
  9. next vale 3, igual que count: HasMonsters() devuelve false, y el bucle termina.

La idea

Un movimiento incorrecto: GameException

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);
}
}

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?

¿Por qué una excepción propia y no la incorporada ArgumentException? Porque catch (GameException e) captura solo los rechazos según las reglas. Si el juego tiene un error y lanza NullReferenceException, no se esconderá como «No se puede». Detendrá el programa, y lo verás.

Pruébalo

Encuentra el error

El sexto monstruo debería recibir «La fila está llena». En cambio, el programa se detiene con IndexOutOfRangeException. ¿En qué línea está el error?

Elige la línea con el error.

Pruébalo

¿Qué falta?

¿De qué clase hereda GameException?

class GameException : 
{
    public GameException(string message) : base(message)
    {
    }
}

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 GameException que el menú captura, diciendo «No se puede: …». La semilla es 12.

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: Carnicero
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
Carnicero es derrotado en 7 rondas. Morena: vida 30/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(Character hero) dice si el héroe está al menos en el nivel 2. Si no, StartBattle lanza GameException, 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(Character 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(Character target), y un Inventory donde se añaden y se quitan objetos. Con un array es incómodo. La lista List<T> llega en el módulo Web y escritorio.
  • 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. En C# la pila es Stack<T>, de la misma biblioteca que List<T> del módulo Web y escritorio.
  • 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. Este curso no tiene una lección sobre archivos. El camino más corto es File.WriteAllText y File.ReadAllText de System.IO.

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 GameException y no todas las excepciones?
4. ¿Por qué el descanso dura 5 turnos y no 5 segundos de espera?
5. ¿Qué captura catch (GameException e)?

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