ProjektBaut auf Katas: Objekte aufIm Handbuch: Terminal: Dateien und Verzeichnisse
image: teaser.webp image_alt: "Ein Terminal, in dem das Textadventure läuft: die Spielerin schließt mit dem Schlüssel die Tür nach Osten auf und steht im Keller" kind: projekt language: Ruby / Python languages: [ruby, python] duration: 3 Doppelstunden curriculum: "Sek I: Algorithmisches Problemlösen (Grundlagen der Objektorientierung); Oberstufe: objektorientierte Modellierung (UML-Klassendiagramme), objektorientierte Programmierung (Objekte, die Objekte kennen, Kapselung, Vererbung, Polymorphie)" topics: [objektorientierung] requires: [kata-objects] reference: [uml, terminal]

Ein Textadventure aus Objekten

Du baust ein Spiel, das man im Terminal spielt: Räume mit Ausgängen, Gegenstände, die man aufheben und benutzen kann, eine verschlossene Tür und einen Schlüssel dazu. Jeder Raum, jeder Gegenstand und die Spielerin sind Objekte, die einander kennen. Zum Schluss baust du deine eigene Welt, und jemand aus deiner Klasse spielt sie.

1. Das Spiel

So soll es am Ende aussehen. Was hinter > steht, tippt man selbst:

Flur
Ein langer Flur mit knarrenden Dielen.
Ausgänge: norden, osten (verschlossen)
> gehe norden
Küche
Es riecht nach Kaffee.
Hier liegt: schlüssel, apfel
Ausgänge: süden
> nimm schlüssel
Du nimmst schlüssel.
> gehe süden
Flur
Ein langer Flur mit knarrenden Dielen.
Ausgänge: norden, osten (verschlossen)
> benutze schlüssel
Du schließt die Tür auf.

Solche Spiele heißen Textadventures. Das erste bekannte, »Colossal Cave Adventure«, schrieb Will Crowther 1975 und 1976, nach seinen eigenen Karten der Mammoth Cave in Kentucky, durch die er selbst gekrochen war; nach ihm heißt die ganze Gattung Adventure.

Leg einen Ordner für dieses Projekt an:

mkdir /workspace/textadventure
cd /workspace/textadventure
touch adventure.rb

Deine Klassen schreibst du in adventure.rb.

mkdir /workspace/textadventure
cd /workspace/textadventure
touch adventure.py

Deine Klassen schreibst du in adventure.py.

2. Ein Raum

Ein Raum hat einen Namen und eine Beschreibung. Und er kennt seine Ausgänge: In jeder Richtung, in der es weitergeht, liegt ein anderer Raum. Ein Ausgang ist also ein Paar aus einer Richtung und einem Raum, und dafür gibt es ein Dictionary: Die Richtung ist der Schlüssel, der Raum der Wert. Im Dictionary steht dabei nicht eine Kopie des anderen Raums, sondern der Raum selbst: Zwei Räume, die einander kennen, sind zwei Objekte, die aufeinander zeigen.

Aufgabe 1Ein Raum mit Ausgängen

Schreib in adventure.rb eine Klasse Room. Ein Raum wird mit Namen und Beschreibung gebaut, Room.new("Küche", "Es riecht nach Kaffee."); name und description geben sie zurück. connect(direction, room) legt einen Ausgang in eine Richtung an, exit_to(direction) gibt den Raum in dieser Richtung zurück oder nil, wenn es dort nicht weitergeht, und directions die Richtungen der Ausgänge, in der Reihenfolge, in der sie angelegt wurden.

Tipp

Die Ausgänge merkt sich der Raum in einem Dictionary, @exits = {} in initialize: die Richtung als Schlüssel, der Raum als Wert.

Nächster Tipp

connect trägt ein, @exits[direction] = room; exit_to schlägt nach, @exits[direction], und das ist von selbst nil, wenn es den Schlüssel nicht gibt. @exits.keys sind die Richtungen.

Aufgabe 1Ein Raum mit Ausgängen

Schreib in adventure.py eine Klasse Room. Ein Raum wird mit Namen und Beschreibung gebaut, Room("Küche", "Es riecht nach Kaffee."); die Attribute name und description halten sie. connect(direction, room) legt einen Ausgang in eine Richtung an, exit_to(direction) gibt den Raum in dieser Richtung zurück oder None, wenn es dort nicht weitergeht, und directions() die Richtungen der Ausgänge als Liste, in der Reihenfolge, in der sie angelegt wurden.

Tipp

Die Ausgänge merkt sich der Raum in einem Dictionary, self.exits = {} in __init__: die Richtung als Schlüssel, der Raum als Wert.

Nächster Tipp

connect trägt ein, self.exits[direction] = room; exit_to schlägt mit self.exits.get(direction) nach, das None ergibt, wenn es den Schlüssel nicht gibt. list(self.exits.keys()) sind die Richtungen.

Vermutung 2Und zurück?
hall = Room.new("Flur", "")
kitchen = Room.new("Küche", "")
hall.connect("norden", kitchen)

Was ergibt danach kitchen.exit_to("süden")?

Und zurück?
Vermutung 2Und zurück?
hall = Room("Flur", "")
kitchen = Room("Küche", "")
hall.connect("norden", kitchen)

Was ergibt danach kitchen.exit_to("süden")?

Und zurück?

Hier geht es weiter, sobald du deine Antwort oben abgegeben hast.

connect legt einen Ausgang an, nur in eine Richtung: Der Flur kennt jetzt die Küche, die Küche aber nicht den Flur. Soll man auch zurückgehen können, verbindet man beide Räume: kitchen.connect("süden", hall). Das ist kein Fehler, sondern eine Möglichkeit: Eine Rutsche in den Keller führt nur nach unten.

3. Gegenstände

Ein Gegenstand hat einen Namen und eine Beschreibung, wie ein Raum. Ein Raum hat eine Liste seiner Gegenstände. Hebt jemand etwas auf, nimmt der Raum es aus seiner Liste und gibt es heraus.

Aufgabe 3Gegenstände im Raum

Schreib eine Klasse Item für Gegenstände, gebaut mit Namen und Beschreibung, Item.new("kiste", "Eine schwere Holzkiste."), mit name und description. Ein Raum bekommt eine Liste seiner Gegenstände: items gibt sie zurück, add_item(item) legt einen hinein, und take(name) nimmt den Gegenstand mit diesem Namen heraus und gibt ihn zurück, oder nil, wenn keiner so heißt.

Tipp

Item sieht aus wie der Anfang von Room: zwei Attribute und attr_reader. Der Raum bekommt in initialize @items = [] und ein :items beim attr_reader.

Nächster Tipp

In take suchst du den Gegenstand mit @items.find { |item| item.name == name }, nimmst ihn mit @items.delete(item) heraus, wenn es ihn gibt, und gibst ihn zurück.

Aufgabe 3Gegenstände im Raum

Schreib eine Klasse Item für Gegenstände, gebaut mit Namen und Beschreibung, Item("kiste", "Eine schwere Holzkiste."), mit den Attributen name und description. Ein Raum bekommt eine Liste seiner Gegenstände im Attribut items: add_item(item) legt einen hinein, und take(name) nimmt den Gegenstand mit diesem Namen heraus und gibt ihn zurück, oder None, wenn keiner so heißt.

Tipp

Item sieht aus wie der Anfang von Room: zwei Attribute. Der Raum bekommt in __init__ self.items = [].

Nächster Tipp

In take gehst du durch self.items; hat ein Gegenstand den Namen, nimmst du ihn mit self.items.remove(item) heraus und gibst ihn zurück. Nach der Schleife gibst du None zurück.

4. Ein Raum beschreibt sich

Was die Spielerin sieht, wenn sie einen Raum betritt, setzt der Raum selbst zusammen, aus seinem Namen, seiner Beschreibung, seinen Gegenständen und seinen Ausgängen. Nur er kennt alle vier; wer ihn fragt, bekommt fertigen Text.

Aufgabe 4Was die Spielerin sieht

Schreib in Room eine Methode describe, die den Raum als Text beschreibt, Zeile für Zeile: den Namen, die Beschreibung, dann Hier liegt: mit den Namen der Gegenstände, durch Komma und Leerzeichen getrennt (die Zeile fehlt, wenn nichts da liegt), und zuletzt Ausgänge: mit den Richtungen, ebenso getrennt, oder Ausgänge: keine. Die Zeilen trennt "\n", wie im Beispiel oben auf der Seite.

Tipp

Sammle die Zeilen in einer Liste, lines = [@name, @description], hänge die beiden anderen an und setz sie am Ende mit lines.join("\n") zusammen.

Nächster Tipp

@items.map(&:name).join(', ') gibt die Namen mit Komma; die Zeile hängst du nur an, unless @items.empty?.

Nächster Tipp

Für die Ausgänge: directions.empty? ? 'keine' : directions.join(', ').

Aufgabe 4Was die Spielerin sieht

Schreib in Room eine Methode describe(), die den Raum als Text beschreibt, Zeile für Zeile: den Namen, die Beschreibung, dann Hier liegt: mit den Namen der Gegenstände, durch Komma und Leerzeichen getrennt (die Zeile fehlt, wenn nichts da liegt), und zuletzt Ausgänge: mit den Richtungen, ebenso getrennt, oder Ausgänge: keine. Die Zeilen trennt "\n", wie im Beispiel oben auf der Seite.

Tipp

Sammle die Zeilen in einer Liste, lines = [self.name, self.description], hänge die beiden anderen an und setz sie am Ende mit "\n".join(lines) zusammen.

Nächster Tipp

", ".join(item.name for item in self.items) gibt die Namen mit Komma; die Zeile hängst du nur an, if self.items:.

Nächster Tipp

Für die Ausgänge: ", ".join(self.directions()), oder keine, wenn die Liste leer ist.

5. Die Spielerin

Die Spielerin steht in einem Raum und hat eine Tasche. Geht sie in eine Richtung, fragt sie ihren Raum nach dem Ausgang; gibt es einen, steht sie danach im nächsten Raum. Ihr Raum ist ein Attribut wie jedes andere: Sie wechselt den Raum, indem sie dem Attribut einen anderen Raum zuweist.

Aufgabe 5Die Spielerin geht und nimmt

Schreib eine Klasse Player, gebaut mit dem Raum, in dem die Spielerin anfängt, Player.new(hall). room gibt ihren Raum zurück, inventory ihre Tasche, eine Liste, anfangs leer. go(direction) geht in diese Richtung und gibt die Beschreibung des neuen Raums zurück; gibt es dort keinen Ausgang, bleibt sie stehen, und go gibt Da geht es nicht weiter. zurück. take(name) nimmt einen Gegenstand aus dem Raum in die Tasche und gibt Du nimmst schlüssel. zurück, mit seinem Namen; liegt keiner mit diesem Namen da, Hier liegt nichts mit dem Namen schlüssel.

Tipp

Die Spielerin merkt sich @room und @inventory = []. Beide braucht man von außen: attr_reader :room, :inventory.

Nächster Tipp

In go fragst du @room.exit_to(direction). Ist das nil, gibst du Da geht es nicht weiter. zurück; sonst weist du @room den neuen Raum zu und gibst @room.describe zurück.

Nächster Tipp

In take holst du mit @room.take(name) den Gegenstand; ist er nil, gibst du die Meldung zurück, sonst legst du ihn in @inventory.

Aufgabe 5Die Spielerin geht und nimmt

Schreib eine Klasse Player, gebaut mit dem Raum, in dem die Spielerin anfängt, Player(hall). Das Attribut room ist ihr Raum, inventory ihre Tasche, eine Liste, anfangs leer. go(direction) geht in diese Richtung und gibt die Beschreibung des neuen Raums zurück; gibt es dort keinen Ausgang, bleibt sie stehen, und go gibt Da geht es nicht weiter. zurück. take(name) nimmt einen Gegenstand aus dem Raum in die Tasche und gibt Du nimmst schlüssel. zurück, mit seinem Namen; liegt keiner mit diesem Namen da, Hier liegt nichts mit dem Namen schlüssel.

Tipp

Die Spielerin merkt sich self.room und self.inventory = [].

Nächster Tipp

In go fragst du self.room.exit_to(direction). Ist das None, gibst du Da geht es nicht weiter. zurück; sonst weist du self.room den neuen Raum zu und gibst self.room.describe() zurück.

Nächster Tipp

In take holst du mit self.room.take(name) den Gegenstand; ist er None, gibst du die Meldung zurück, sonst hängst du ihn an self.inventory.

6. Verschlossene Türen

Jetzt wird es ein Spiel: Eine Tür ist verschlossen, und man braucht den passenden Schlüssel. Ein Apfel lässt sich essen. Beide sind Gegenstände, aber benutze tut bei jedem etwas anderes. Darum bekommt jede Art von Gegenstand eine eigene Klasse, die von Item erbt und die Methode use überschreibt: Die Spielerin ruft nur use auf und muss nicht wissen, was sie in der Hand hat.

Zeichne den Plan zuerst, in der UML-Werkstatt (wie sie funktioniert, steht im Handbuch):

Aufgabe 6Gegenstände, die etwas können

Leg textadventure/textadventure.uml an und zeichne darin die Klasse Item mit einer Methode + use(player: Player): String und die Klassen Key und Food, die von Item erben und use überschreiben. Dazu die Klasse Player mit einer Methode + use(name: String): String.

Tipp

Eine Klasse, die erbt, beginnt mit class Key extends Item. Darunter, eingerückt, steht nur, was sie anders macht.

Nächster Tipp

use steht dreimal: in Item mit dem, was ein gewöhnlicher Gegenstand tut, und in Key und Food, die es überschreiben.

Eine Tür ist dabei kein eigenes Objekt: Der Raum merkt sich zu jeder verschlossenen Richtung, welcher Schlüssel passt. Ob eine Tür offen ist, weiß nur er; die Spielerin fragt ihn, bevor sie durchgeht, und der Schlüssel bittet ihn, aufzuschließen.

Aufgabe 7Schlüssel, Schloss und Apfel

Jetzt der Code zum Diagramm, in adventure.rb:

  • Room bekommt lock(direction, key_name), das die Tür in dieser Richtung abschließt; der Schlüssel, der passt, heißt key_name. locked?(direction) sagt, ob sie verschlossen ist, und unlock(key_name) schließt alle Türen des Raums auf, zu denen dieser Schlüssel passt, und gibt true zurück, wenn es mindestens eine gab, sonst false. In describe steht hinter einer verschlossenen Richtung (verschlossen).
  • go gibt bei einer verschlossenen Tür Die Tür nach osten ist verschlossen. zurück, mit der Richtung, und die Spielerin bleibt stehen.
  • Item bekommt use(player), das Damit kannst du hier nichts anfangen. zurückgibt. Key erbt von Item; sein use schließt im Raum der Spielerin die Türen auf, zu denen er passt (sein Name ist der Name des Schlüssels), und gibt Du schließt die Tür auf. zurück, sonst Hier passt schlüssel in kein Schloss., mit seinem Namen. Food erbt auch von Item; sein use nimmt es aus der Tasche der Spielerin und gibt Du isst apfel. Knackig und süß. zurück, mit Namen und Beschreibung.
  • Player bekommt use(name): Es sucht den Gegenstand in der Tasche und ruft sein use auf; ist keiner mit diesem Namen da, gibt es Du hast nichts mit dem Namen apfel. zurück.
Tipp

Der Raum merkt sich die Schlösser in einem zweiten Dictionary, @locks = {}: die Richtung als Schlüssel, der Name des passenden Schlüssels als Wert. locked? fragt @locks.key?(direction).

Nächster Tipp

unlock sucht die Richtungen, deren Wert key_name ist, @locks.select { |_, key| key == key_name }.keys, löscht sie aus @locks und gibt zurück, ob es welche gab.

Nächster Tipp

Key#use(player) ruft player.room.unlock(name) auf und gibt je nach Ergebnis die eine oder andere Meldung zurück. Player#use(name) findet den Gegenstand wie Room#take, nimmt ihn aber nicht heraus, und gibt item.use(self) zurück: self ist die Spielerin.

Aufgabe 7Schlüssel, Schloss und Apfel

Jetzt der Code zum Diagramm, in adventure.py:

  • Room bekommt lock(direction, key_name), das die Tür in dieser Richtung abschließt; der Schlüssel, der passt, heißt key_name. is_locked(direction) sagt, ob sie verschlossen ist, und unlock(key_name) schließt alle Türen des Raums auf, zu denen dieser Schlüssel passt, und gibt True zurück, wenn es mindestens eine gab, sonst False. In describe() steht hinter einer verschlossenen Richtung (verschlossen).
  • go gibt bei einer verschlossenen Tür Die Tür nach osten ist verschlossen. zurück, mit der Richtung, und die Spielerin bleibt stehen.
  • Item bekommt use(player), das Damit kannst du hier nichts anfangen. zurückgibt. Key erbt von Item; sein use schließt im Raum der Spielerin die Türen auf, zu denen er passt (sein Name ist der Name des Schlüssels), und gibt Du schließt die Tür auf. zurück, sonst Hier passt schlüssel in kein Schloss., mit seinem Namen. Food erbt auch von Item; sein use nimmt es aus der Tasche der Spielerin und gibt Du isst apfel. Knackig und süß. zurück, mit Namen und Beschreibung.
  • Player bekommt use(name): Es sucht den Gegenstand in der Tasche und ruft sein use auf; ist keiner mit diesem Namen da, gibt es Du hast nichts mit dem Namen apfel. zurück.
Tipp

Der Raum merkt sich die Schlösser in einem zweiten Dictionary, self.locks = {}: die Richtung als Schlüssel, der Name des passenden Schlüssels als Wert. is_locked fragt direction in self.locks.

Nächster Tipp

unlock sucht die Richtungen, deren Wert key_name ist, [d for d, key in self.locks.items() if key == key_name], löscht sie aus self.locks und gibt zurück, ob es welche gab.

Nächster Tipp

Key.use(self, player) ruft player.room.unlock(self.name) auf und gibt je nach Ergebnis die eine oder andere Meldung zurück. Player.use(self, name) findet den Gegenstand in self.inventory, nimmt ihn aber nicht heraus, und gibt item.use(self) zurück: self ist die Spielerin.

7. Deine Welt

Die Spielschleife, die Befehle liest und an die Spielerin weitergibt, ist schon fertig:

cd /workspace/textadventure
wget https://workspace.hackschule.de/textadventure/starter/play.rb

play.rb holt sich mit require_relative 'adventure' deine Klassen und ruft build_world auf, eine Funktion in adventure.rb, die die Welt baut und die Spielerin in ihren ersten Raum stellt. So sieht eine kleine Welt aus, mit zwei Räumen:

def build_world
  hall = Room.new("Flur", "Ein langer Flur mit knarrenden Dielen.")
  kitchen = Room.new("Küche", "Es riecht nach Kaffee.")
  hall.connect("norden", kitchen)
  kitchen.connect("süden", hall)
  kitchen.add_item(Food.new("apfel", "Knackig und süß."))
  Player.new(hall)
end

Spielen kannst du mit ruby play.rb.

cd /workspace/textadventure
wget https://workspace.hackschule.de/textadventure/starter/play.py

play.py holt sich mit from adventure import build_world die Funktion build_world aus adventure.py, die die Welt baut und die Spielerin in ihren ersten Raum stellt. So sieht eine kleine Welt aus, mit zwei Räumen:

def build_world():
    hall = Room("Flur", "Ein langer Flur mit knarrenden Dielen.")
    kitchen = Room("Küche", "Es riecht nach Kaffee.")
    hall.connect("norden", kitchen)
    kitchen.connect("süden", hall)
    kitchen.add_item(Food("apfel", "Knackig und süß."))
    return Player(hall)

Spielen kannst du mit python3 play.py.

Die Befehle sind gehe, nimm, benutze, schau, tasche und ende. Die Namen der Gegenstände schreibst du klein, so wie man sie im Spiel tippt.

Aufgabe 8Deine eigene Welt

Schreib in adventure.rb eine Funktion build_world, die deine eigene Welt baut und die Spielerin in ihrem ersten Raum zurückgibt. Die Welt hat mindestens drei Räume, die man vom ersten Raum aus erreicht, mindestens eine verschlossene Tür und irgendwo einen Schlüssel. Die Funktion all_rooms(start), die alle erreichbaren Räume findet, kopierst du dazu in deine Datei:

def all_rooms(start)
  seen = [start]
  queue = [start]
  until queue.empty?
    room = queue.shift
    room.directions.each do |direction|
      other = room.exit_to(direction)
      next if seen.include?(other)
      seen << other
      queue << other
    end
  end
  seen
end
Tipp

Zeichne deine Welt zuerst auf Papier: Kästen für die Räume, Pfeile für die Ausgänge, eine Tür, die verschlossen ist, und wo der Schlüssel liegt.

Nächster Tipp

Die kleine Welt oben auf der Seite ist ein Anfang: Bau weitere Räume dazu, verbinde sie mit connect und schließ eine Tür mit lock ab. Der Name des Schlüssels, Key.new("schlüssel", …), ist derselbe wie der in lock.

Aufgabe 8Deine eigene Welt

Schreib in adventure.py eine Funktion build_world(), die deine eigene Welt baut und die Spielerin in ihrem ersten Raum zurückgibt. Die Welt hat mindestens drei Räume, die man vom ersten Raum aus erreicht, mindestens eine verschlossene Tür und irgendwo einen Schlüssel. Die Funktion all_rooms(start), die alle erreichbaren Räume findet, kopierst du dazu in deine Datei:

def all_rooms(start):
    seen = [start]
    queue = [start]
    while queue:
        room = queue.pop(0)
        for direction in room.directions():
            other = room.exit_to(direction)
            if other not in seen:
                seen.append(other)
                queue.append(other)
    return seen
Tipp

Zeichne deine Welt zuerst auf Papier: Kästen für die Räume, Pfeile für die Ausgänge, eine Tür, die verschlossen ist, und wo der Schlüssel liegt.

Nächster Tipp

Die kleine Welt oben auf der Seite ist ein Anfang: Bau weitere Räume dazu, verbinde sie mit connect und schließ eine Tür mit lock ab. Der Name des Schlüssels, Key("schlüssel", …), ist derselbe wie der in lock.

Ausprobieren: Lass jemanden aus deiner Klasse dein Spiel spielen, ohne etwas zu erklären. Achte darauf, wo sie oder er hängen bleibt: bei einem Befehl, den das Spiel nicht versteht, bei einem Ausgang, der in der Beschreibung fehlt, oder bei einem Schlüssel, den man nicht findet.
Aufgabe 9Was beim Spieltest auffiel

Jemand aus deiner Klasse hat dein Spiel gespielt. Wo ist sie oder er hängen geblieben, und was änderst du deshalb an deiner Welt oder an deinen Klassen?

Mindestens 20 Wörter. Nur du und deine Lehrkraft können die Antwort lesen.

8. Abgabe

Aufgabe 10Dein Abenteuer

Gib den Ordner textadventure ab, mit deinen Klassen und deiner Welt. Deine Lehrkraft sieht genau diesen Stand und kann dein Spiel ausprobieren.

9. Wie geht es weiter?

  • Ein Ziel: Ein Raum, in dem das Spiel endet, wenn man ihn betritt, mit einer Nachricht. Wer weiß, ob er erreicht ist: der Raum, die Spielerin oder play?
  • Eine Lampe: dunkle Räume, deren Beschreibung man nur sieht, wenn man eine Lampe in der Tasche hat. Welche Klasse fragt wen?
  • Speichern: Wie ließe sich ein Spielstand in eine Datei schreiben und wieder laden, mit allen Räumen, die aufeinander zeigen?

10. Zum Nachschlagen