Zum Inhalt
wannacode

RPG 3: das ganze Spiel

Kurz gesagt: Das ganze Rollenspiel: Eine Klasse Game führt den Helden durch Kämpfe, Rast, Stufen und Erfahrung, und ein falscher Zug löst GameError aus, den das Spiel abfängt, um weiterzumachen.

Problem

Von einem Kampf zu einem Spiel

Die Helden und die Monster kämpfen schon. Aber ein Spiel ist mehr als ein Kampf: Du erschaffst einen Helden, stellst die Monster auf, gegen die er antritt, und er geht sie nacheinander durch. Zwischen den Kämpfen ruht er sich aus oder heilt sich mit Magie.

Wenn das alles in dem Hauptprogramm steht, liegen dort der Held, eine Liste mit Monstern, die Nummer des nächsten, Zähler und Prüfungen. Und wer hält den Spieler davon ab, einen Kampf ohne Helden zu starten oder von einem Barbaren Magie zu verlangen?

Der Zustand des Spiels und seine Regeln müssen an einem Ort stehen: in einer Klasse Game.

Die Idee

Die Klasse Game

Der Held und die Monster sind der Zustand des Spiels. Die Methoden von Game sind die Regeln: was der Spieler tun kann und was danach passiert. Die erste ist, einen Helden zu erstellen:

private Character hero;
private Monster[] Monster = 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("barbar")) {
hero = new Barbarian(name);
} else if (race.equals("hexe")) {
hero = new Witch(name);
} else {
throw new GameException("Dieses Volk gibt es nicht: " + race);
}
}
Der Zufallsgenerator kommt von außen, über den Konstruktor. Im echten Spiel ist es Random() ohne Startwert, und in den Beispielen ist es Random(12), damit die Ausgabe immer gleich ist.Die Felder beginnen mit _: Sie gehören zum Spiel und werden von außen nicht angefasst. [None] * 5 ist eine Liste mit 5 Plätzen, auf denen noch nichts liegt (None).

Ein unbekanntes Volk ist kein Grund, irgendeinen Helden zu erschaffen. Die Methode wirft GameError: unsere eigene Exception für jeden Zug, den die Regeln nicht erlauben. Sie bekommt weiter unten eine eigene Karte.

Die Idee

Eine Reihe von Monstern

Die Monster warten in einer Schlange: Das zuerst hinzugefügte ist der erste Kampf. Hier ist die Schlange eine Liste und zwei Indizes:

public void addMonster(Monster monster) {
if (count == Monster.length) {
throw new GameException("Die Reihe ist voll: höchstens " + Monster.length + " Monster");
}
Monster[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 ist die Anzahl der bisher hinzugefügten Monster. Das neue kommt nach self._monsters[self._count].
  • _next ist das Monster, das als Nächstes kämpft. Nach jedem Kampf steigt es um 1, und wenn es _count erreicht, ist die Reihe zu Ende.
Die Liste hat 5 Plätze. Das sechste Monster kommt nicht hinein: add_monster wirft GameError, bevor es außerhalb der Liste schreibt. Eine Liste in Python kann auch mit append wachsen, aber hier wächst sie absichtlich nicht: Die Regel des Spiels lautet „höchstens 5“.

create_random_monster ist der Bonus aus der alten Übung: ein zufällig gewähltes Monster. randrange(3) liefert 0, 1 oder 2.

Genauer, für Neugierige
  • Die alte Übung hielt die Monster in einer echten Warteschlange. In Python ist das eine deque: Sie merkt sich selbst, welches das erste ist, und sie wird nie voll. Sie kommt im Modul Collections: Dort entfallen _count und _next, und die Reihe wächst, so weit sie muss.
  • Die Monster vor _next bleiben in der Liste. Sie schaden nicht: Niemand liest sie mehr.

Die Idee

Stufen und Erfahrung

Nach jedem Sieg bekommt der Held Erfahrung in Höhe des Lebens des Monsters. Auf Stufe 1 braucht er 100 Erfahrung bis Stufe 2, auf Stufe 2 braucht er 200 bis Stufe 3 und so weiter. Eine neue Stufe gibt 10 Leben mehr, 3 Kraft mehr und volles Leben:

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

Die Methoden stehen in Character. Erfahrung über der Schwelle bleibt für die nächste Stufe. Die while-Schleife ist für den Fall, dass ein Sieg für zwei Stufen reicht.

Die Felder _max_health, _level und _experience sind neu in Character. Mit _max_health hebt Heilen das Leben nicht mehr über das Maximum. Von außen werden sie über die Properties max_health, level und experience gelesen, wie die Felder aus dem ersten Teil.

Probier es aus

Auf welcher Stufe steht der Barbar?

Grok beginnt auf Stufe 1 mit Kraft 20. Er gewinnt gegen den Schamanen (60 Erfahrung) und gegen den König (90 Erfahrung).

Was gibt das Programm aus? Stufe, Erfahrung, Kraft.

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)
Antwort

Sieh es dir an

Die Schlacht und die Rast

start_battle prüft zuerst, ob der Kampf möglich ist. Dann nimmt es das nächste Monster und lässt den Kampf auf Leben und Tod aus dem zweiten Teil laufen. Es gibt zurück, ob der Held überlebt hat:

public boolean startBattle() {
if (hero == null) {
throw new GameException("Erstelle zuerst einen Helden");
}
if (!hasMonsters()) {
throw new GameException("Keine Monster in der Reihe");
}
Monster monster = Monster[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() + " fällt in Runde " + rounds + ". Sieger: " + monster.getName());
return false;
}
System.out.println(monster.getName() + " wird in " + rounds + " Runden besiegt. " + hero.getName() + ": Leben " + hero.getHealth() + "/" + hero.getMaxHealth());
if (hero.gainExperience(monster.getReward())) {
System.out.println(hero.getName() + " erreicht Stufe " + hero.getLevel() + ": Leben " + hero.getMaxHealth() + ", Kraft " + hero.getPower());
}
return true;
}

recover ist die Wahl nach dem Kampf. Nur ein Held, der von Healable erbt (isinstance prüft das), kann Magie nutzen. Die Rast dauert 5 Züge zu je 10 Leben:

public void recover(String way) {
if (way.equals("ruhe")) {
for (int turn = 0; turn < 5; turn++) {
hero.restore(10);
}
System.out.println(hero.getName() + " ruht 5 Züge lang: Leben " + hero.getHealth() + "/" + hero.getMaxHealth());
} else if (way.equals("magie")) {
if (!(hero instanceof IHealable)) {
throw new GameException(hero.getName() + " beherrscht keine Heilmagie");
}
System.out.println(hero.useAbility());
} else {
throw new GameException("Unbekannte Wahl „" + way + "“: ruhe oder magie?");
}
}

In der alten Übung wartete der Held 5 Sekunden. Hier zählt die Rast in Zügen, weil in einem rundenbasierten Spiel die Zeit in Zügen gemessen wird und die Prüfung des Spiels nicht warten soll.

Sieh es dir an

Das ganze Spiel

Grok der Barbar gegen den Schamanen und zwei zufällige Monster. Nach jedem Kampf ruht er sich aus. Fällt er, ist das Spiel aus:

Game game = new Game(new Random(12));
game.createHero("Grok", "barbar");
game.addMonster(new FallenShaman());
game.addMonster(game.createRandomMonster());
game.addMonster(game.createRandomMonster());
while (game.hasMonsters()) {
if (!game.startBattle()) {
System.out.println("Spiel vorbei");
break;
}
game.recover("ruhe");
}
Ausgabe
Gefallener Schamane wird in 3 Runden besiegt. Grok: Leben 90/110
Grok ruht 5 Züge lang: Leben 110/110
Gefallener Schamane wird in 3 Runden besiegt. Grok: Leben 90/110
Grok erreicht Stufe 2: Leben 120, Kraft 23
Grok ruht 5 Züge lang: Leben 120/120
Schlächter wird in 6 Runden besiegt. Grok: Leben 70/120
Grok ruht 5 Züge lang: Leben 120/120
Vergleiche die Ausgabe in den Tabs. Der Startwert ist in jeder Sprache 12, aber die Generatoren sind verschieden, und die zufälligen Monster auch. In Java begegnet Grok einem zweiten Schamanen und dem Schlächter, in C# dem Schlächter und dem Skelettkönig, und in Python und JavaScript zweimal dem Skelettkönig.

game = Game(Random(12))
game.create_hero("Grok", "barbar")
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("Spiel vorbei")
break
game.recover("ruhe")
Schritt für Schritt, in Worten
  1. Das Spiel bekommt einen Generator mit Startwert 12. Der Held ist Grok, und die Liste hat 5 Plätze, noch leer.
  2. Jedes add_monster() schreibt nach self._monsters[self._count] und erhöht self._count. Das zweite und das dritte sind zufällig: Skelettkönig und Skelettkönig.
  3. self._next ist 0: Es kommt Gefallener Schamane. Es wird in 3 Runden besiegt, und Grok hat noch 90/110.
  4. self._next wird 1. Die Rast sind 5 Züge zu je 10 Leben: 110/110.
  5. self._next ist 1: Es kommt Skelettkönig. Es wird in 6 Runden besiegt, und Grok hat noch 45/110. Die Erfahrung reicht für Stufe 2: Leben 120, Kraft 23.
  6. self._next wird 2. Die Rast sind 5 Züge zu je 10 Leben: 120/120.
  7. self._next ist 2: Es kommt Skelettkönig. Es wird in 5 Runden besiegt, und Grok hat noch 68/120.
  8. self._next wird 3. Die Rast sind 5 Züge zu je 10 Leben: 118/120.
  9. self._next ist 3, genauso wie self._count: has_monsters() gibt False zurück, und die Schleife endet.

Die Idee

Ein falscher Zug: GameError

Der Spieler kann einen Kampf ohne Helden verlangen, ein Volk, das es nicht gibt, oder Magie von einem Barbaren. Das ist kein Fehler im Programm. Es ist ein Zug, den die Regeln nicht erlauben. Die Methoden von Game werfen dafür ihre eigene Exception:

class GameException extends RuntimeException {
public GameException(String message) {
super(message);
}
}
In Python endet der Name einer eigenen Exception auf Error, wie bei den meisten eingebauten: ValueError, TypeError, IndexError.

Das Spiel fängt nur diese eine ab, sagt dem Spieler, warum der Zug nicht erlaubt ist, und macht mit dem nächsten Zug weiter:

Game game = new Game(new Random(12));
String[] commands = { "kampf", "held Grok drache", "held Grok barbar", "magie", "schlaf" };
for (String command : commands) {
String[] words = command.split(" ");
try {
if (words[0].equals("kampf")) {
game.startBattle();
} else if (words[0].equals("held")) {
game.createHero(words[1], words[2]);
System.out.println("Der Held ist " + words[1]);
} else {
game.recover(words[0]);
}
} catch (GameException e) {
System.out.println("Nicht erlaubt: " + e.getMessage());
}
}
Ausgabe
Nicht erlaubt: Erstelle zuerst einen Helden
Nicht erlaubt: Dieses Volk gibt es nicht: drache
Der Held ist Grok
Nicht erlaubt: Grok beherrscht keine Heilmagie
Nicht erlaubt: Unbekannte Wahl „schlaf“: ruhe oder magie?

Warum eine eigene Exception und nicht die eingebaute ValueError? Weil except GameError nur die Ablehnungen nach den Regeln abfängt. Hat das Spiel einen Fehler und wirft AttributeError, versteckt sich dieser nicht als „Nicht erlaubt“. Er hält das Programm an, und du siehst ihn. Zum Beispiel recover, bevor es einen Helden gibt: self._hero ist None, und hero.restore(10) ergibt AttributeError: 'NoneType' object has no attribute 'restore'.

Probier es aus

Finde den Fehler

Das sechste Monster sollte „Die Reihe ist voll“ bekommen. Stattdessen bricht das Programm mit IndexError ab. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Probier es aus

Was fehlt?

Von welcher Klasse erbt GameError?

class GameError():
    pass

Übung

Spielen und erweitern

Löse diese Aufgaben in deinem Editor. Beginne mit den Klassen aus der Lektion.

1. Ein Menü. Die Befehle kommen aus der Eingabe, einer pro Zeile, bis „ende“:

  • „held Name Volk“ erstellt den Helden;
  • „monster schamane“ (oder „könig“, „schlächter“) fügt ein fertiges Monster hinzu, und „monster Name Leben Kraft“ fügt dein eigenes hinzu: eine Klasse Beast mit dem Volk „Bestie“;
  • „zufall“ fügt ein zufälliges Monster hinzu;
  • „kampf“ startet den nächsten Kampf, und „ruhe“ und „magie“ sind die Wahl danach.

Jede Ablehnung ist eine GameError, die das Menü abfängt, mit der Meldung „Nicht erlaubt: …“. Der Startwert ist 12. Die Zeilen werden mit input() gelesen.

Eingabe
held Morena Elf
held Morena hexe
monster Wolf 50 9
monster drache
monster schamane
zufall
kampf
magie
kampf
ruhe
kampf
ende
Ausgabe
Nicht erlaubt: Dieses Volk gibt es nicht: Elf
Der Held ist Morena
In der Reihe: Wolf
Nicht erlaubt: Dieses Monster gibt es nicht: drache
In der Reihe: Gefallener Schamane
In der Reihe: Skelettkönig
Wolf wird in 4 Runden besiegt. Morena: Leben 68/95
Morena ruft die Elemente an: Leben 83/95
Gefallener Schamane wird in 4 Runden besiegt. Morena: Leben 47/95
Morena erreicht Stufe 2: Leben 105, Kraft 19
Morena ruht 5 Züge lang: Leben 105/105
Skelettkönig wird in 7 Runden besiegt. Morena: Leben 9/105

2. Fortschritt. Füge Game eine Methode show_progress() hinzu, die die Stufe des Helden zeigt und wie viel Erfahrung er von der Menge hat, die er für die nächste Stufe braucht. Ardan der Paladin geht durch zwei Schamanen und den Skelettkönig. Nach jedem Kampf zeigt er seinen Fortschritt und heilt sich mit Magie.

Ausgabe
Gefallener Schamane wird in 5 Runden besiegt. Ardan: Leben 72/120
Stufe 1, Erfahrung 60/100
Ardan heilt sich: Leben 92/120
Gefallener Schamane wird in 5 Runden besiegt. Ardan: Leben 44/120
Ardan erreicht Stufe 2: Leben 130, Kraft 17
Stufe 2, Erfahrung 20/200
Ardan heilt sich: Leben 130/130
Skelettkönig wird in 8 Runden besiegt. Ardan: Leben 18/130
Stufe 2, Erfahrung 110/200
Ardan heilt sich: Leben 38/130

3. Ein Boss. Eine Klasse Diablo, eine Unterklasse von Monster: 160 Leben, 14 Kraft. Jeder dritte Treffer von ihm zählt doppelt. Eine Methode can_challenge(hero) sagt, ob der Held mindestens auf Stufe 2 ist. Wenn nicht, wirft start_battle GameError, und Diablo bleibt in der Reihe. Lass ihn einmal gegen Grok auf Stufe 1 laufen, dann in einem neuen Spiel nach dem Schamanen und dem König.

Ausgabe
Nicht erlaubt: Diablo kämpft nur gegen Helden ab Stufe 2
Gefallener Schamane wird in 3 Runden besiegt. Grok: Leben 90/110
Grok ruht 5 Züge lang: Leben 110/110
Skelettkönig wird in 6 Runden besiegt. Grok: Leben 45/110
Grok erreicht Stufe 2: Leben 120, Kraft 23
Grok ruht 5 Züge lang: Leben 120/120
Diablo wird in 7 Runden besiegt. Grok: Leben 24/120
Grok erreicht Stufe 3: Leben 130, Kraft 26
Grok ruht 5 Züge lang: Leben 130/130

Mach allein weiter

Die alte Übung hatte weitere Ideen. Manche brauchen ein Werkzeug, das erst später kommt:

  • Fertigkeiten mit Stufen. Eine Klasse Skill mit einem Namen und einer Stufe, use_skill(target) und upgrade_skill(). Das kannst du schon jetzt, mit einer Liste von Fertigkeiten im Helden.
  • Inventar. Eine Klasse Item mit einem Namen und einer Wirkung, apply_effect(target), und ein Inventory, in dem Gegenstände hinzugefügt und entfernt werden. Hinzufügen geht schon jetzt: Eine Liste wächst mit append. Ein Element aus der Mitte zu entfernen kommt im Modul Collections.
  • Bosse mit Voraussetzungen. Ein Boss, der bestimmte Gegenstände und Fertigkeiten verlangt, bevor er den Kampf annimmt, und nach einem Sieg Beute fallen lässt. Dafür brauchst du das Inventar von oben.
  • Bildschirme. Menü, Karte, Inventar: Jeder Bildschirm ist ein Objekt mit show(), hide() und update(), und die offenen Bildschirme liegen auf einem Stapel, damit „zurück“ funktioniert. Der Stapel kommt im Modul Collections.
  • Speichern und Laden. Der Held, die Stufe und das beste Ergebnis werden in eine Datei geschrieben und beim nächsten Start gelesen. Dateien kommen im Modul Text und Dateien.

Test

Teste dich

1. Die Reihe ist eine Liste mit 5 Plätzen, _count ist 3, _next ist 1. Was bedeutet das?
2. Der Held ist auf Stufe 1 mit 80 Erfahrung und gewinnt 130 Erfahrung. Was passiert?
3. Warum fängt das Spiel nur GameError ab und nicht jede Exception?
4. Warum dauert die Rast 5 Züge und nicht 5 Sekunden Warten?
5. Warum hat class GameError(Exception): pass kein __init__?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·