RPG 3: the whole game
Problem
From one fight to a game
The heroes and the monsters already fight. But a game is more than one fight: you create a hero, line up the monsters he will fight, and he goes through them one by one. Between the fights he rests or heals with magic.
If all of this is in the main program, that is where the hero will be, along with a list of monsters, the number of the next one, counters and checks. And who will stop the player from starting a fight without a hero, or asking a barbarian for magic?
The state of the game and its rules have to be in one place: in a class Game.
The idea
The Game class
The hero and the monsters are the state of the game. The methods of Game are the rules: what the player can do and what happens after that. The first one is creating a hero:
private Character hero;private Monster[] monsters = 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("paladin")) { hero = new Paladin(name); } else if (race.equals("barbarian")) { hero = new Barbarian(name); } else if (race.equals("witch")) { hero = new Witch(name); } else { throw new GameException("No such race: " + race); }}private Character hero;private Monster[] monsters = 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 == "paladin") { hero = new Paladin(name); } else if (race == "barbarian") { hero = new Barbarian(name); } else if (race == "witch") { hero = new Witch(name); } else { throw new GameException("No such race: " + race); }}def __init__(self, random): self._hero = None self._monsters = [None] * 5 self._count = 0 self._next = 0 self._random = random
def create_hero(self, name, race): if race == "paladin": self._hero = Paladin(name) elif race == "barbarian": self._hero = Barbarian(name) elif race == "witch": self._hero = Witch(name) else: raise GameError(f"No such race: {race}")#hero = null;#monsters = new Array(5);#count = 0;#next = 0;#random;
constructor(random) { this.#random = random;}
createHero(name, race) { if (race === "paladin") { this.#hero = new Paladin(name); } else if (race === "barbarian") { this.#hero = new Barbarian(name); } else if (race === "witch") { this.#hero = new Witch(name); } else { throw new GameError(`No such race: ${race}`); }}Random() without a seed, and in the examples it is Random(12), so the output is always the same.The fields start with _: they belong to the game and are not touched from outside. [None] * 5 is a list with 5 places, none of which holds anything yet (None).An unknown race is no reason to create some random hero. The method throws GameError: our own exception for every move the rules do not allow. It gets its own card further on.
The idea
A lineup of monsters
The monsters wait in a queue: the one added first is the first fight. Here the queue is a list and two indexes:
public void addMonster(Monster monster) { if (count == monsters.length) { throw new GameException("The lineup is full: at most " + monsters.length + " monsters"); } monsters[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;}public void AddMonster(Monster monster){ if (count == monsters.Length) { throw new GameException("The lineup is full: at most " + monsters.Length + " monsters"); } monsters[count] = monster; count++;}
public Monster CreateRandomMonster(){ int kind = random.Next(3); if (kind == 0) { return new FallenShaman(); } if (kind == 1) { return new SkeletonKing(); } return new Butcher();}
public bool HasMonsters(){ return next < count;}def add_monster(self, monster): if self._count == len(self._monsters): raise GameError(f"The lineup is full: at most {len(self._monsters)} monsters") self._monsters[self._count] = monster self._count += 1
def create_random_monster(self): kind = self._random.randrange(3) if kind == 0: return FallenShaman() if kind == 1: return SkeletonKing() return Butcher()
def has_monsters(self): return self._next < self._countaddMonster(monster) { if (this.#count === this.#monsters.length) { throw new GameError(`The lineup is full: at most ${this.#monsters.length} monsters`); } this.#monsters[this.#count] = monster; this.#count++;}
createRandomMonster() { const kind = Math.floor(this.#random() * 3); if (kind === 0) { return new FallenShaman(); } if (kind === 1) { return new SkeletonKing(); } return new Butcher();}
hasMonsters() { return this.#next < this.#count;}_countis how many monsters have been added. The new one goes toself._monsters[self._count]._nextis which one fights next. After every fight it goes up by 1, and when it reaches_count, the lineup is over.
add_monster throws GameError before it writes outside the list. A list in Python can also grow with append, but here it deliberately does not grow: the game's rule is "at most 5".create_random_monster is the bonus from the old exercise: a monster picked at random. randrange(3) gives 0, 1 or 2.
More precisely, if you are curious
-
The old exercise kept the monsters in a real queue. In Python that is a
deque: it remembers by itself which one is first, and it never fills up. It comes in the Collections module: there_countand_nextgo away, and the lineup grows as much as it needs. - The monsters before
_nextstay in the list. They do no harm: no one reads them anymore.
The idea
Levels and experience
After every win the hero gets experience equal to the monster's health. On level 1 it takes 100 experience to reach level 2, on level 2 it takes 200 to reach level 3, and so on. A new level gives 10 more health, 3 more power and full health:
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;}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;}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_healthgainExperience(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;}The methods are in Character. Experience above the threshold stays for the next level. The while loop is for the case where one win is enough for two levels.
The fields _max_health, _level and _experience are new in Character. With _max_health, healing no longer raises health above the maximum. From outside they are read through the properties max_health, level and experience, like the fields from the first part.
Try it
What level is the barbarian on?
Grok starts on level 1 with power 20. He wins against the shaman (60 experience) and against the king (90 experience).
What will the program print? Level, experience, power.
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)60 + 90 = 150 experience. On level 1 the threshold is 100: Grok goes up to level 2, and 50 is left. Level 3 takes 200. The power is 20 + 3 = 23.
See it
The battle and the rest
start_battle first checks whether the fight is possible. Then it takes the next monster and runs the fight to the death from the second part. It returns whether the hero survived:
public boolean startBattle() { if (hero == null) { throw new GameException("Create a hero first"); } if (!hasMonsters()) { throw new GameException("No monsters in the lineup"); } Monster monster = monsters[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() + " falls in round " + rounds + ". Winner: " + monster.getName()); return false; } System.out.println(monster.getName() + " is defeated in " + rounds + " rounds. " + hero.getName() + ": health " + hero.getHealth() + "/" + hero.getMaxHealth()); if (hero.gainExperience(monster.getReward())) { System.out.println(hero.getName() + " reaches level " + hero.getLevel() + ": health " + hero.getMaxHealth() + ", power " + hero.getPower()); } return true;}public bool StartBattle(){ if (hero == null) { throw new GameException("Create a hero first"); } if (!HasMonsters()) { throw new GameException("No monsters in the lineup"); } Monster monster = monsters[next]; next++; int rounds = 0; while (hero.IsAlive() && monster.IsAlive()) { rounds++; hero.Attack(monster); if (monster.IsAlive()) { monster.Attack(hero); } } if (!hero.IsAlive()) { Console.WriteLine(hero.Name + " falls in round " + rounds + ". Winner: " + monster.Name); return false; } Console.WriteLine(monster.Name + " is defeated in " + rounds + " rounds. " + hero.Name + ": health " + hero.Health + "/" + hero.MaxHealth); if (hero.GainExperience(monster.Reward)) { Console.WriteLine(hero.Name + " reaches level " + hero.Level + ": health " + hero.MaxHealth + ", power " + hero.Power); } return true;}def start_battle(self): if self._hero is None: raise GameError("Create a hero first") if not self.has_monsters(): raise GameError("No monsters in the lineup") hero = self._hero monster = self._monsters[self._next] self._next += 1 rounds = 0 while hero.is_alive() and monster.is_alive(): rounds += 1 hero.attack(monster) if monster.is_alive(): monster.attack(hero) if not hero.is_alive(): print(f"{hero.name} falls in round {rounds}. Winner: {monster.name}") return False print(f"{monster.name} is defeated in {rounds} rounds. {hero.name}: health {hero.health}/{hero.max_health}") if hero.gain_experience(monster.reward): print(f"{hero.name} reaches level {hero.level}: health {hero.max_health}, power {hero.power}") return TruestartBattle() { if (this.#hero === null) { throw new GameError("Create a hero first"); } if (!this.hasMonsters()) { throw new GameError("No monsters in the lineup"); } const hero = this.#hero; const monster = this.#monsters[this.#next]; this.#next++; let rounds = 0; while (hero.isAlive() && monster.isAlive()) { rounds++; hero.attack(monster); if (monster.isAlive()) { monster.attack(hero); } } if (!hero.isAlive()) { console.log(`${hero.name} falls in round ${rounds}. Winner: ${monster.name}`); return false; } console.log(`${monster.name} is defeated in ${rounds} rounds. ${hero.name}: health ${hero.health}/${hero.maxHealth}`); if (hero.gainExperience(monster.reward)) { console.log(`${hero.name} reaches level ${hero.level}: health ${hero.maxHealth}, power ${hero.power}`); } return true;}recover is the choice after the fight. Only a hero who inherits from Healable (isinstance checks it) can use magic. The rest is 5 turns of 10 health:
public void recover(String way) { if (way.equals("rest")) { for (int turn = 0; turn < 5; turn++) { hero.restore(10); } System.out.println(hero.getName() + " rests for 5 turns: health " + hero.getHealth() + "/" + hero.getMaxHealth()); } else if (way.equals("magic")) { if (!(hero instanceof IHealable)) { throw new GameException(hero.getName() + " does not know healing magic"); } System.out.println(hero.useAbility()); } else { throw new GameException("Unknown choice '" + way + "': rest or magic?"); }}public void Recover(string way){ if (way == "rest") { for (int turn = 0; turn < 5; turn++) { hero.Restore(10); } Console.WriteLine(hero.Name + " rests for 5 turns: health " + hero.Health + "/" + hero.MaxHealth); } else if (way == "magic") { if (!(hero is IHealable)) { throw new GameException(hero.Name + " does not know healing magic"); } Console.WriteLine(hero.UseAbility()); } else { throw new GameException("Unknown choice '" + way + "': rest or magic?"); }}def recover(self, way): hero = self._hero if way == "rest": for _ in range(5): hero.restore(10) print(f"{hero.name} rests for 5 turns: health {hero.health}/{hero.max_health}") elif way == "magic": if not isinstance(hero, Healable): raise GameError(f"{hero.name} does not know healing magic") print(hero.use_ability()) else: raise GameError(f"Unknown choice '{way}': rest or magic?")recover(way) { const hero = this.#hero; if (way === "rest") { for (let turn = 0; turn < 5; turn++) { hero.restore(10); } console.log(`${hero.name} rests for 5 turns: health ${hero.health}/${hero.maxHealth}`); } else if (way === "magic") { if (typeof hero.heal !== "function") { throw new GameError(`${hero.name} does not know healing magic`); } console.log(hero.useAbility()); } else { throw new GameError(`Unknown choice '${way}': rest or magic?`); }}In the old exercise the hero waited 5 seconds. Here the rest is in turns, because in a turn-based game time is measured in turns, and the check of the game should not have to wait.
See it
The whole game
Grok the barbarian against the shaman and two random monsters. After every fight he rests. If he falls, the game is over:
Game game = new Game(new Random(12));game.createHero("Grok", "barbarian");game.addMonster(new FallenShaman());game.addMonster(game.createRandomMonster());game.addMonster(game.createRandomMonster());while (game.hasMonsters()) { if (!game.startBattle()) { System.out.println("Game over"); break; } game.recover("rest");}Fallen Shaman is defeated in 3 rounds. Grok: health 90/110Grok rests for 5 turns: health 110/110Fallen Shaman is defeated in 3 rounds. Grok: health 90/110Grok reaches level 2: health 120, power 23Grok rests for 5 turns: health 120/120Butcher is defeated in 6 rounds. Grok: health 70/120Grok rests for 5 turns: health 120/120Game game = new Game(new Random(12));game.CreateHero("Grok", "barbarian");game.AddMonster(new FallenShaman());game.AddMonster(game.CreateRandomMonster());game.AddMonster(game.CreateRandomMonster());while (game.HasMonsters()){ if (!game.StartBattle()) { Console.WriteLine("Game over"); break; } game.Recover("rest");}Fallen Shaman is defeated in 3 rounds. Grok: health 90/110Grok rests for 5 turns: health 110/110Butcher is defeated in 7 rounds. Grok: health 48/110Grok reaches level 2: health 120, power 23Grok rests for 5 turns: health 120/120Skeleton King is defeated in 5 rounds. Grok: health 68/120Grok rests for 5 turns: health 118/120game = Game(Random(12))game.create_hero("Grok", "barbarian")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("Game over") break game.recover("rest")Fallen Shaman is defeated in 3 rounds. Grok: health 90/110Grok rests for 5 turns: health 110/110Skeleton King is defeated in 6 rounds. Grok: health 45/110Grok reaches level 2: health 120, power 23Grok rests for 5 turns: health 120/120Skeleton King is defeated in 5 rounds. Grok: health 68/120Grok rests for 5 turns: health 118/120const game = new Game(makeRandom(12));game.createHero("Grok", "barbarian");game.addMonster(new FallenShaman());game.addMonster(game.createRandomMonster());game.addMonster(game.createRandomMonster());while (game.hasMonsters()) { if (!game.startBattle()) { console.log("Game over"); break; } game.recover("rest");}Fallen Shaman is defeated in 3 rounds. Grok: health 90/110Grok rests for 5 turns: health 110/110Skeleton King is defeated in 6 rounds. Grok: health 45/110Grok reaches level 2: health 120, power 23Grok rests for 5 turns: health 120/120Skeleton King is defeated in 5 rounds. Grok: health 68/120Grok rests for 5 turns: health 118/120game = Game(Random(12))game.create_hero("Grok", "barbarian")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("Game over") break game.recover("rest")Step by step, in words
- The game gets a generator with seed 12. The hero is Grok, and the list has 5 places, still empty.
- Each add_monster() writes to self._monsters[self._count] and raises self._count. The second and the third are random: Skeleton King and Skeleton King.
- self._next is 0: out comes Fallen Shaman. It is defeated in 3 rounds, and Grok has 90/110 left.
- self._next becomes 1. The rest is 5 turns of 10 health: 110/110.
- self._next is 1: out comes Skeleton King. It is defeated in 6 rounds, and Grok has 45/110 left. The experience is enough for level 2: health 120, power 23.
- self._next becomes 2. The rest is 5 turns of 10 health: 120/120.
- self._next is 2: out comes Skeleton King. It is defeated in 5 rounds, and Grok has 68/120 left.
- self._next becomes 3. The rest is 5 turns of 10 health: 118/120.
- self._next is 3, the same as self._count: has_monsters() returns False, and the loop ends.
The idea
A wrong move: GameError
The player can ask for a fight without a hero, a race that does not exist, or magic from a barbarian. That is not a bug in the program. It is a move the rules do not allow. The methods of Game throw their own exception for it:
class GameException extends RuntimeException { public GameException(String message) { super(message); }}class GameException : Exception{ public GameException(string message) : base(message) { }}class GameError(Exception): passclass GameError extends Error { constructor(message) { super(message); this.name = "GameError"; }}Error, like most built-in ones: ValueError, TypeError, IndexError.The game catches only that one, tells the player why the move is not allowed and carries on with the next move:
Game game = new Game(new Random(12));String[] commands = { "fight", "hero Grok dragon", "hero Grok barbarian", "magic", "sleep" };for (String command : commands) { String[] words = command.split(" "); try { if (words[0].equals("fight")) { game.startBattle(); } else if (words[0].equals("hero")) { game.createHero(words[1], words[2]); System.out.println("The hero is " + words[1]); } else { game.recover(words[0]); } } catch (GameException e) { System.out.println("Not allowed: " + e.getMessage()); }}Not allowed: Create a hero firstNot allowed: No such race: dragonThe hero is GrokNot allowed: Grok does not know healing magicNot allowed: Unknown choice 'sleep': rest or magic?Game game = new Game(new Random(12));string[] commands = { "fight", "hero Grok dragon", "hero Grok barbarian", "magic", "sleep" };foreach (string command in commands){ string[] words = command.Split(' '); try { if (words[0] == "fight") { game.StartBattle(); } else if (words[0] == "hero") { game.CreateHero(words[1], words[2]); Console.WriteLine("The hero is " + words[1]); } else { game.Recover(words[0]); } } catch (GameException e) { Console.WriteLine("Not allowed: " + e.Message); }}Not allowed: Create a hero firstNot allowed: No such race: dragonThe hero is GrokNot allowed: Grok does not know healing magicNot allowed: Unknown choice 'sleep': rest or magic?game = Game(Random(12))commands = ["fight", "hero Grok dragon", "hero Grok barbarian", "magic", "sleep"]for command in commands: words = command.split() try: if words[0] == "fight": game.start_battle() elif words[0] == "hero": game.create_hero(words[1], words[2]) print(f"The hero is {words[1]}") else: game.recover(words[0]) except GameError as e: print(f"Not allowed: {e}")Not allowed: Create a hero firstNot allowed: No such race: dragonThe hero is GrokNot allowed: Grok does not know healing magicNot allowed: Unknown choice 'sleep': rest or magic?const game = new Game(makeRandom(12));const commands = ["fight", "hero Grok dragon", "hero Grok barbarian", "magic", "sleep"];for (const command of commands) { const words = command.split(" "); try { if (words[0] === "fight") { game.startBattle(); } else if (words[0] === "hero") { game.createHero(words[1], words[2]); console.log(`The hero is ${words[1]}`); } else { game.recover(words[0]); } } catch (e) { if (!(e instanceof GameError)) { throw e; } console.log(`Not allowed: ${e.message}`); }}Not allowed: Create a hero firstNot allowed: No such race: dragonThe hero is GrokNot allowed: Grok does not know healing magicNot allowed: Unknown choice 'sleep': rest or magic?Why your own exception and not the built-in ValueError? Because except GameError catches only the refusals by the rules. If the game has a bug and throws AttributeError, it will not hide as "Not allowed". It will stop the program, and you will see it. For example, recover before there is a hero: self._hero is None, and hero.restore(10) gives AttributeError: 'NoneType' object has no attribute 'restore'.
Try it
Find the bug
The sixth monster should get "The lineup is full". Instead, the program stops with IndexError. Which line is the bug on?
Pick the line with the bug.
self._count is 5, and 5 > len(self._monsters) is False. The check lets it pass, and the sixth is written to self._monsters[5], while the last place is self._monsters[4]: IndexError: list assignment index out of range. The list is full when self._count becomes equal to its length: self._count == len(self._monsters).Try it
What is missing?
Which class does GameError inherit from?
class GameError():
passException, the parent of your own exceptions in Python. Without its own __init__, GameError uses the constructor of Exception: it keeps the message, and str(e) returns it.Exercise
Play and extend
Solve these in your editor. Start from the classes in the lesson.
1. A menu. The commands come from input, one per line, until "quit":
- "hero Name race" creates the hero;
- "monster shaman" (or "king", "butcher") adds a ready-made monster, and "monster Name health power" adds your own: a class
Beastof the race "beast"; - "random" adds a random monster;
- "fight" starts the next fight, and "rest" and "magic" are the choice after it.
Every refusal is a GameError that the menu catches, saying "Not allowed: …". The seed is 12. The lines are read with input().
hero Morena elfhero Morena witchmonster Wolf 50 9monster dragonmonster shamanrandomfightmagicfightrestfightquitNot allowed: No such race: elfThe hero is MorenaIn the lineup: WolfNot allowed: No such monster: dragonIn the lineup: Fallen ShamanIn the lineup: Skeleton KingWolf is defeated in 4 rounds. Morena: health 68/95Morena calls on the elements: health 83/95Fallen Shaman is defeated in 4 rounds. Morena: health 47/95Morena reaches level 2: health 105, power 19Morena rests for 5 turns: health 105/105Skeleton King is defeated in 7 rounds. Morena: health 9/1052. Progress. Add a method show_progress() to Game that shows the hero's level and how much experience he has out of how much he needs for the next level. Ardan the paladin goes through two shamans and the Skeleton King. After every fight he shows his progress and heals with magic.
Fallen Shaman is defeated in 5 rounds. Ardan: health 72/120Level 1, experience 60/100Ardan heals: health 92/120Fallen Shaman is defeated in 5 rounds. Ardan: health 44/120Ardan reaches level 2: health 130, power 17Level 2, experience 20/200Ardan heals: health 130/130Skeleton King is defeated in 8 rounds. Ardan: health 18/130Level 2, experience 110/200Ardan heals: health 38/1303. A boss. A class Diablo, a subclass of Monster: 160 health, 14 power. Every third hit of his is double. A method can_challenge(hero) says whether the hero is at least on level 2. If not, start_battle throws GameError, and Diablo stays in the lineup. Run him once against Grok on level 1, then in a new game after the shaman and the king.
Not allowed: Diablo fights only heroes of level 2 and upFallen Shaman is defeated in 3 rounds. Grok: health 90/110Grok rests for 5 turns: health 110/110Skeleton King is defeated in 6 rounds. Grok: health 45/110Grok reaches level 2: health 120, power 23Grok rests for 5 turns: health 120/120Diablo is defeated in 7 rounds. Grok: health 24/120Grok reaches level 3: health 130, power 26Grok rests for 5 turns: health 130/130Keep going on your own
The old exercise suggested more ideas. Some of them need a tool that comes later:
- Skills with levels. A class
Skillwith a name and a level,use_skill(target)andupgrade_skill(). You can do this now, with a list of skills in the hero. - Inventory. A class
Itemwith a name and an effect,apply_effect(target), and anInventorywhere items are added and removed. Adding works even now: a list grows withappend. Removing an element from the middle comes in the Collections module. - Bosses with requirements. A boss that wants certain items and skills before it accepts the fight, and drops loot after a win. It needs the inventory above.
- Screens. Menu, map, inventory: each screen is an object with
show(),hide()andupdate(), and the open screens are on a stack, so that "back" works. The stack comes in the Collections module. - Save and load. The hero, the level and the best score are written to a file and read on the next run. Files come in the Text and files module.
Test