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, una lista 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á Random() sin semilla, y en los ejemplos es Random(12), para que la salida sea siempre la misma.Los campos empiezan con _: pertenecen al juego y no se tocan desde fuera. [None] * 5 es una lista con 5 lugares en los que todavía no hay nada (None).

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 una lista 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 self._monsters[self._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.
La lista tiene 5 lugares. El sexto monstruo no entra: add_monster lanza GameError antes de escribir fuera de la lista. Una lista en Python también puede crecer con append, pero aquí deliberadamente no crece: la regla del juego es «como máximo 5».

create_random_monster es el extra del ejercicio antiguo: un monstruo elegido al azar. randrange(3) da 0, 1 o 2.

Más precisamente, para curiosos
  • El ejercicio antiguo guardaba los monstruos en una cola de verdad. En Python es una deque: recuerda por sí misma cuál es el primero, y nunca se llena. Llega en el módulo Colecciones: ahí desaparecen _count y _next, y la fila crece lo que haga falta.
  • Los monstruos anteriores a _next se quedan en la lista. 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 _max_health, _level y _experience son nuevos en Character. Con _max_health, curar ya no sube la vida por encima del máximo. Desde fuera se leen con las propiedades max_health, 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.

def gain_experience(self, exp):
self._experience += exp
up = False
while self._experience >= self._level * 100:
self._experience -= self._level * 100
self.level_up()
up = True
return up
def level_up(self):
self._level += 1
self._max_health += 10
self._power += 3
self._health = self._max_health
grok = Barbarian("Grok")
grok.gain_experience(60)
grok.gain_experience(90)
print(grok.level, grok.experience, grok.power)
Respuesta

Míralo

La batalla y el descanso

start_battle 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 hereda de Healable (lo comprueba isinstance) 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.

game = Game(Random(12))
game.create_hero("Grok", "bárbaro")
game.add_monster(FallenShaman())
game.add_monster(game.create_random_monster())
game.add_monster(game.create_random_monster())
while game.has_monsters():
if not game.start_battle():
print("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 la lista tiene 5 lugares, todavía vacíos.
  2. Cada add_monster() escribe en self._monsters[self._count] y sube self._count. El segundo y el tercero son aleatorios: Rey Esqueleto y Rey Esqueleto.
  3. self._next vale 0: sale Chamán Caído. Es derrotado en 3 rondas, y a Grok le quedan 90/110.
  4. self._next pasa a 1. El descanso son 5 turnos de 10 de vida: 110/110.
  5. self._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. self._next pasa a 2. El descanso son 5 turnos de 10 de vida: 120/120.
  7. self._next vale 2: sale Rey Esqueleto. Es derrotado en 5 rondas, y a Grok le quedan 68/120.
  8. self._next pasa a 3. El descanso son 5 turnos de 10 de vida: 118/120.
  9. self._next vale 3, igual que self._count: has_monsters() 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 Python el nombre de una excepción propia termina en Error, como la mayoría de las incorporadas: ValueError, TypeError, IndexError.

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 ValueError? Porque except GameError captura solo los rechazos según las reglas. Si el juego tiene un error y lanza AttributeError, no se esconderá como «No se puede». Detendrá el programa, y lo verás. Por ejemplo, recover antes de que haya un héroe: self._hero es None, y hero.restore(10) da AttributeError: 'NoneType' object has no attribute 'restore'.

Pruébalo

Encuentra el error

El sexto monstruo debería recibir «La fila está llena». En cambio, el programa se detiene con IndexError. ¿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():
    pass

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 input().

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 show_progress() 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 can_challenge(hero) dice si el héroe está al menos en el nivel 2. Si no, start_battle 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, use_skill(target) y upgrade_skill(). Esto ya puedes hacerlo, con una lista de habilidades en el héroe.
  • Inventario. Una clase Item con un nombre y un efecto, apply_effect(target), y un Inventory donde se añaden y se quitan objetos. Añadir funciona incluso ahora: una lista crece con append. 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 una lista 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. ¿Por qué class GameError(Exception): pass no tiene __init__?

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