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 wirft GameError, 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, ein Array 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: Es ist eine Funktion, die bei jedem Aufruf eine Zahl von 0 bis 1 zurückgibt. Im echten Spiel ist es Math.random, und in den Beispielen ist es makeRandom(12) aus der Lektion „Wie du über ein Spiel nachdenkst“, weil Math.random keinen Startwert hat und die Ausgabe immer gleich sein muss.Die Felder beginnen mit #: Sie sind privat und nur innerhalb der Klasse Game sichtbar. new Array(5) ist ein Array mit 5 leeren Plätzen.

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 ein Array 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 this.#monsters[this.#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.
Das Array hat 5 Plätze. Das sechste Monster kommt nicht hinein: addMonster wirft GameError. Hier hält allein die Prüfung die Regel „höchstens 5“ ein: Ein Array in JavaScript wächst von selbst, und this.#monsters[5] = monster ist kein Fehler, sondern ein sechster Platz.

createRandomMonster ist der Bonus aus der alten Übung: ein zufällig gewähltes Monster. Math.floor(this.#random() * 3) liefert 0, 1 oder 2: Die Zahl von 0 bis 1 mal 3 liegt zwischen 0 und 3 (ohne 3), und Math.floor schneidet den Nachkommateil ab.

Genauer, für Neugierige
  • Die alte Übung hielt die Monster in einer echten Warteschlange. JavaScript hat keine eigene Klasse für Warteschlangen: Eine Warteschlange ist ein Array, bei dem push hinten anfügt und shift vorne entnimmt. Sie kommt im Modul Collections: Dort entfallen #count und #next.
  • Die Monster vor #next bleiben in dem Array. 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 _maxHealth, _level und _experience sind neu in Character. Mit _maxHealth hebt Heilen das Leben nicht mehr über das Maximum. Von außen werden sie über die Properties maxHealth, 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.

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

Sieh es dir an

Die Schlacht und die Rast

startBattle 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 eine Methode heal hat (typeof hero.heal ist "function"), 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.

const game = new Game(makeRandom(12));
game.createHero("Grok", "barbar");
game.addMonster(new FallenShaman());
game.addMonster(game.createRandomMonster());
game.addMonster(game.createRandomMonster());
while (game.hasMonsters()) {
if (!game.startBattle()) {
console.log("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 das Array hat 5 Plätze, noch leer.
  2. Jedes addMonster() schreibt nach this.#monsters[this.#count] und erhöht this.#count. Das zweite und das dritte sind zufällig: Skelettkönig und Skelettkönig.
  3. this.#next ist 0: Es kommt Gefallener Schamane. Es wird in 3 Runden besiegt, und Grok hat noch 90/110.
  4. this.#next wird 1. Die Rast sind 5 Züge zu je 10 Leben: 110/110.
  5. this.#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. this.#next wird 2. Die Rast sind 5 Züge zu je 10 Leben: 120/120.
  7. this.#next ist 2: Es kommt Skelettkönig. Es wird in 5 Runden besiegt, und Grok hat noch 68/120.
  8. this.#next wird 3. Die Rast sind 5 Züge zu je 10 Leben: 118/120.
  9. this.#next ist 3, genauso wie this.#count: hasMonsters() 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 JavaScript endet der Name einer eigenen Exception auf Error, wie bei den eingebauten: TypeError, RangeError.

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?
In JavaScript gibt es ein einziges catch, und es fängt alles ab. Deshalb fragt darin e instanceof GameError, ob es eine Ablehnung nach den Regeln ist, und throw e; wirft alles andere weiter nach oben.

Warum eine eigene Exception und nicht die eingebaute Error? Weil die Prüfung e instanceof GameError nur die Ablehnungen nach den Regeln durchlässt. Hat das Spiel einen Fehler und wirft TypeError, 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: this.#hero ist null, und hero.restore(10) ergibt TypeError: Cannot read properties of null (reading 'restore').

Probier es aus

Finde den Fehler

Das sechste Monster sollte „Die Reihe ist voll“ bekommen. Stattdessen kommt es hinein: Das Programm gibt „Monster: 6“ aus und sagt nichts. 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 extends  {
  constructor(message) {
    super(message);
    this.name = "GameError";
  }
}

Ü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 await ask("") gelesen, und nach „ende“ schließt rl.close() die Eingabe.

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 showProgress() 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 canChallenge(hero) sagt, ob der Held mindestens auf Stufe 2 ist. Wenn nicht, wirft startBattle 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, useSkill(target) und upgradeSkill(). Das kannst du schon jetzt, mit einem Array von Fertigkeiten im Helden.
  • Inventar. Eine Klasse Item mit einem Namen und einer Wirkung, applyEffect(target), und ein Inventory, in dem Gegenstände hinzugefügt und entfernt werden. Hinzufügen geht schon jetzt: Ein Array wächst mit push. 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 ein Array 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. catch prüft e instanceof GameError und macht sonst throw e. Was passiert, wenn ein TypeError kommt?

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