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.
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.
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.
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.
Die Ausgänge merkt sich der Raum in einem Dictionary, @exits = {} in initialize: die Richtung als Schlüssel, der Raum als Wert.
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.
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.
Die Ausgänge merkt sich der Raum in einem Dictionary, self.exits = {} in __init__: die Richtung als Schlüssel, der Raum als Wert.
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.
hall = Room.new("Flur", "") kitchen = Room.new("Küche", "") hall.connect("norden", kitchen)
Was ergibt danach kitchen.exit_to("süden")?
hall = Room("Flur", "") kitchen = Room("Küche", "") hall.connect("norden", kitchen)
Was ergibt danach kitchen.exit_to("süden")?
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.
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.
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.
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.
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.
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.
Item sieht aus wie der Anfang von Room: zwei Attribute. Der Raum bekommt in __init__ self.items = [].
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.
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.
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.
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.
@items.map(&:name).join(', ') gibt die Namen mit Komma; die Zeile hängst du nur an, unless @items.empty?.
Für die Ausgänge: directions.empty? ? 'keine' : directions.join(', ').
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.
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.
", ".join(item.name for item in self.items) gibt die Namen mit Komma; die Zeile hängst du nur an, if self.items:.
Für die Ausgänge: ", ".join(self.directions()), oder keine, wenn die Liste leer ist.
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.
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.
Die Spielerin merkt sich @room und @inventory = []. Beide braucht man von außen: attr_reader :room, :inventory.
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.
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.
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.
Die Spielerin merkt sich self.room und self.inventory = [].
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.
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.
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):
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.
Eine Klasse, die erbt, beginnt mit class Key extends Item. Darunter, eingerückt, steht nur, was sie anders macht.
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.
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.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).
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.
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.
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.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.
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.
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.
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.
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
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.
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.
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
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.
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.
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?
Gib den Ordner textadventure ab, mit deinen Klassen und deiner Welt. Deine Lehrkraft sieht genau diesen Stand und kann dein Spiel ausprobieren.
play?