Tutorial: Ein Textadventure in Python (Teil 1)

Gute Links und Tutorials könnt ihr hier posten.
Antworten
sma
User
Beiträge: 3018
Registriert: Montag 19. November 2007, 19:57
Wohnort: Kiel

Ein Textadventure (auch Interactive Fiction genannt) ist eine Spielegattung aus den frühen 80er Jahren, in der der Computer Schauplätze, Gegenstände und Personen per Text beschreibt und der Spieler (oder die Spielerin) einfache Befehle wie "go north" oder "take the brass latern" gibt, um mit dem Spiel zu interagieren und letztlich interaktiv eine Geschichte erlebt, in der er (oder sie) Rätsel lösen muss oder überleben oder was auch immer.

(Originaltext ist hier: https://gist.github.com/2391632)

Diese wenig kreative Geschichte soll als Beispiel dienen:
Wir stehen vor einer alten trutzigen Burg mit hohen Mauern und tiefem Graben und haben ein Seil und Brot dabei. Von dort können wir über eine Zugbrücke in den verfallenen Burghof gehen. Vögel flattern auf und wir treffen Hugo, der bewaffnet mit einem Schwert, nicht wagt, in die Kellergewölbe zu gehen. Wir können den Burgfried besteigen und finden dort Ratten, die ein Nest bewachen, in der eine Kiste mit Silbermünzen liegt. Mit dem Brot können wir die Ratten weglocken und die Münzen nehmen. (Alternativ können wir unser Seil nehmen, um das Dach des Turms zu erreichen, von wo aus wir direkt an die Münzen kommen, allerdings ist nun das Seil weg.) Andernfalls greifen die Ratten an, was beim dritten Biss zu unserem Tod führt. Es heißt also fliehen. Die Ratten werden nicht in den Burghof folgen. Die Münzen können wir Hugo geben und sein Schwert bekommen, mit dem wir in den Kellergewölbe steigen können und dort gegen einen Kobold kämpfen, der eine Falltür bewacht. Ohne Schwert wird er angreifen und uns töten, wenn wir nicht sofort weglaufen. Der Kobold hat einen Schlüssel dabei, den wir nur nehmen können, wenn er besiegt ist und damit die Tür aufschließen, die zu einem Tempelraum führt, den wir mit unserem Seil erreichen können (sonst müssen wir springen). Dort ist ein blutiger Altar, auf dem ein goldener sechszackiger Stern liegt. Nehmen wir den Stern (er ist wertvoll), haben wir in der PG13-Version gewonnen. Andernfalls reißt nun der Boden auf und eine dämonische Monstrosität erscheint, die nicht besiegt werden kann und uns tötet, wenn wir nicht fliehen. Das können wir nur, wenn wir das Seil noch haben. Der Dämon wird Hugo töten, der noch ruft, "lasst das Wesen nicht entkommen" und nun kann man die Zugbrücke hochkurbeln, wodurch das Wesen eingesperrt wird, allerdings mit uns zusammen...
Das ist erst einmal eine ganze Menge.

Grundsätzlich gibt Schauplätze (gerne auch Räume genannt) wie den Burghof oder der Tempelraum, Gegenstände wie die Kiste mit Münzen oder das Schwert und Personen (oder Kreaturen) wie Hugo, der Kobold oder die Ratten.

Räume sind miteinander verbunden und wir, als Spielende, befinden uns immer in einem Raum. Gegenstände sind in einem Raum, in einem anderen Gegenstand (die Münzen in der Kiste) oder "in" einer Person. Personen sind (wie wir) immer in einem Raum. Wir können einen Raum wechseln, Gegenstände nehmen, weglegen oder benutzen und mit Personen interagieren, indem wir sie ansprechen, angreifen oder ihnen etwas geben.

Wir können das Spiel auf zwei Arten in Python implementieren: Entweder direkt oder als Rahmenwerk, mit derartige Spiele über eine spezielle (sogenannte Domänen-spezifische Sprache, englisch "DSL") beschrieben werden können und dann von unserem Python-Programm interpretiert werden. Um mehr über die "Domäne" zu lernen, wollen wir zunächst den einfacheren direkten Weg wählen.

Beginnen wir mit einer Klasse Room (ich werde das Programm englisch halten), mit deren Exemplaren wir die Schauplätze beschreiben können. Jeder Schauplatz hat einen Namen, eine Beschreibung und Verbindungen zu weiteren Räumen, die wir mit Himmelsrichtungen (oder "oben" / "unten") beschreiben wollen.

Code: Alles auswählen

class Room:
    def __init__(self, name, description):
        self.name = name
        self.description = description
        self.exits = {}

# define all rooms
vor_der_burg = Room("Vor der Burg",
    "Vor dir ragt eine alte trutzige Burg auf, das Ziel deiner tagelangen "
    "Reise durch die Wälder von Mordag. Im Norden führt eine herabgelassene "
    "Zugbrücke zu einem düsteren Burghof. Es ist still hier. Sehr still.")
burghof = Room("Der Burghof",
    "Die hohen Mauern lassen nur weg Licht der untergehenden Sonne hinein. "
    "Der Hof ist unübersichtlich. Im Laufe der Jahrhunderte hat sich hier "
    "viel Schutt und Müll angesammelt. Doch im Westen führt ein Tor in den "
    "Burgfried. Alle anderen Zugänge scheinen verschüttet zu sein.")
burgfried = Room("Der Burgfried",
    "Hier ist es richtig dunkel. Wie praktisch wäre jetzt ein Licht. Nach "
    "einige Treppen kommt ein kleiner Raum, in dem es entsetzlich stinkt. "
    "Wäre die restliche Treppe nicht eingestürzt, ginge es hier zum Dach.")

# all directions
directions = ("norden", "osten", "süden", "westen", "oben", "unten")

# connect rooms
vor_der_burg.exits["norden"] = burghof
burghof.exits["süden"] = vor_der_burg
burghof.exits["westen"] = burgfried
burgfried.exits["osten"] = burghof
Jedes Spiel hat einen Zustand. Zur Zeit müssen wir uns nur den aktuellen Raum merken.

Ich bin unschlüssig, ob ich das Spiel als Exemplar einer Klasse Game modellieren soll oder einfach eine globale Variable und Funktionen benutzen soll. Ich glaube, letzteres ist für den Anfang einfacher.

Code: Alles auswählen

# the current room
current_room = None

def enter_room(room):
    global current_room
    current_room = room
    describe_room()

def describe_room():
    emit()
    emit(current_room.name); emit()
    emit(current_room.description); emit()
Die Funktion enter_room setzt den übergebenen Raum als aktuellen Raum und gibt dann den Namen und die Beschreibung des Raums aus. Dazu benutze ich eine Funktion namens emit, die im Prinzip wie print funktioniert, allerdings an Wortgrenzen umbricht, damit die Ausgabe besser aussieht:

Code: Alles auswählen

def emit(s="", width=80):
    column = 0
    for word in str(s).split():
        column += len(word) + 1
        if column > width:
            column = len(word) + 1
            print()
        print(word, end=" ")
    print()
Ich gehe davon aus, dass die Konsole 80 Zeichen breit ist. Theoretisch könnte man das versuchen vom Betriebssystem zu erfragen, ein Detail, welches ich dem Leser überlasse.

Um das Spiel durchzuführen, setzen wir den Startschauplatz und verarbeiten dann in einer Schleife die Befehle, die wir mittels input (ich benutze Python 3.2, bei Python 2.7 wäre es raw_input) einlesen. Wird "ende" eingeben, beende ich das Spiel.

Code: Alles auswählen

def play():
    enter_room(vor_der_burg)
    while execute_command():
        pass

def execute_command():
    words = read_command()
    if words:
        if words[0] in ("gehe", "geh"):
            if len(words) > 2 and words[1] == "nach":
                execute_go(words[2])
            elif len(words) > 1:
                execute_go(words[1])
            else:
                emit("Wohin soll ich gehen?")
        elif words[0] == "ende":
            return False
        else:
            emit("Ich verstehe '%s' nicht." % "".join(words))
    return True

def read_command():
    return [word.lower() for word in input("? ").rstrip(".?!").split()]
Ich rufe execute_go auf, wenn "gehe" oder "geh" (was grammatikalisch wohl korrekter ist) mit einer Richtung und einem optionalen "nach" eingegeben wurde. Kommt danach noch mehr, wird dies ignoriert. Wir möchte, kann hier noch besser werden. Einige Spiele hatten damals ausgefeilte Parser, die z.B. "Gehe nach Norden, nimm alles außer der Laterne und gehe zurück." oder ähnliche Sätze verstanden haben.

In execute_go prüfe ich dann, ob es vom aktuellen Raum aus eine Verbindung in die angegebene Richtung gibt. Falls ja, werden wir diesen Raum betreten. Andernfalls wird ein Fehler angezeigt:

Code: Alles auswählen

def execute_go(direction):
    room = current_room.exits.get(direction)
    if room:
        enter_room(room)
    else:
        emit("Du kannst nicht nach '%s' gehen." % direction)
Wir können nun das Spiel das erste Mal ausprobieren, indem wir play() aufrufen, und den Burghof oder den Burgfried betreten. Wer auch die anderen Räume angelegt und verbunden hat, kann natürlich auch diese erforschen.

Code: Alles auswählen

Vor der Burg 

Vor dir ragt eine alte trutzige Burg auf, das Ziel deiner tagelangen Reise 
durch die Wälder von Mordag. Im Norden führt eine herabgelassene Zugbrücke zu 
einem düsteren Burghof. Es ist still hier. Sehr still. 

? geh nach norden

Der Burghof 

Die hohen Mauern lassen nur weg Licht der untergehenden Sonne hinein. Der Hof 
ist unübersichtlich. Im Laufe der Jahrhunderte hat sich hier viel Schutt und 
Müll angesammelt. Doch im Westen führt ein Tor in den Burgfried. Alle anderen 
Zugänge scheinen verschüttet zu sein. 
Weil man sich so häufig zwischen Räumen bewegt, soll es möglich sein, das Verb wegzulassen und einfach nur die Richtung anzugeben. Die folgende kleine Erweiterung macht dies möglich:

Code: Alles auswählen

def execute_command():
    words = read_command()
    if words:
        if words[0] in ("gehe", "geh"):
            ...
        elif words[0] in directions:
            execute_go(words[0])
        ...
    return True
Im englischen könnte man sogar einbuchstabige Befehle benutzen, doch auf deutsch ist "o" für "oben" oder "osten" leider nicht eindeutig. Wer mag, kann ja noch einbauen, dass man einen Punkt am Ende eines Satzes machen kann oder auch ein "bitte" einstreuen kann. Der Computer freut sich bestimmt über ein bisschen Höflichkeit.

Noch eine Kleinigkeit: Da ich schon alt und vergesslich bin, möchte ich mir die Beschreibung eines Raums auch per Befehl anzeigen lassen können:

Code: Alles auswählen

def execute_command():
    ...
        elif words[0] in ("schaue", "schau", "beschreibung"):
            describe_room()
Das soll für heute erst einmal reichen.

Stefan
Zuletzt geändert von sma am Sonntag 15. April 2012, 17:06, insgesamt 1-mal geändert.
nomnom
User
Beiträge: 487
Registriert: Mittwoch 19. Mai 2010, 16:25

Danke, hat mir Spaß gemacht, zu lesen. :) Jetzt hätte ich auch mal Lust, mir eine Geschichte auszudenken …
sma
User
Beiträge: 3018
Registriert: Montag 19. November 2007, 19:57
Wohnort: Kiel

nomnom hat geschrieben:Danke, hat mir Spaß gemacht, zu lesen. :) Jetzt hätte ich auch mal Lust, mir eine Geschichte auszudenken …
Freut mich.

Ich habe mich übrigens noch einmal umentschieden und die "Game"-Klasse entfernt und so das ganze hoffentlich weiter vereinfacht.

Stefan
Benutzeravatar
mkesper
User
Beiträge: 919
Registriert: Montag 20. November 2006, 15:48
Wohnort: formerly known as mkallas
Kontaktdaten:

Coole Idee. Ich finde aber globale Variablen schwieriger, wenn man vermeiden will, auch globalen Code zu benutzen.
Ich würde auch noch auf das Interactive Fiction Archive und die Messinglaterne verweisen wollen. Da gibt's reichlich Material zu Spieldesign etc.
Benutzeravatar
Hyperion
Moderator
Beiträge: 7478
Registriert: Freitag 4. August 2006, 14:56
Wohnort: Hamburg
Kontaktdaten:

Mir hat das bisher ganz gut gefallen :-)

Ich gestehe, dass mir die globale Variable auch nicht so doll gefällt... aber es ist ja auch erst der Anfang und man wird sehen, wie sich das noch so entwickelt.

Eine Sache kann man aber imho schon noch leicht "optimieren". Benutze doch `textwrap.fill` statt Deiner `emit`-Funktion. Imho sollte ein

Code: Alles auswählen

from textwrap import fill as emit
reichen, um den Code so zu belassen.

Ach nee, ich sehe grad, dass Du das `print` in die Funktion reingezogen hast. Dann müsstest Du `emit` doch anpassen und auf `fill` umstellen...

Vielleicht noch eine Anmerkung / Idee: Wird es so sein, dass Räume und Verbindungen immer "statisch" bleiben? Einen einstürzenden Tunnel könnte man ja im Moment schwer umsetzen. Solche dynamischen Ereignisse sind imho ziemlich cool und können durchaus helfen, Spannung zu generieren :-) Evtl. ist der Aufwand aber auch zu groß...

Bin auf jeden Fall gespannt, wie es weiter gehen wird :-)
encoding_kapiert = all(verstehen(lesen(info)) for info in (Leonidas Folien, Blog, Folien & Text inkl. Python3, utf-8 everywhere))
assert encoding_kapiert
sma
User
Beiträge: 3018
Registriert: Montag 19. November 2007, 19:57
Wohnort: Kiel

Ich könnte alle globalen Variablen zu Attributen einer Klasse machen, doch dann missbrauche ich IMHO die Klasse nur als Modul. Außerdem müsste ich überall explizit "self" benutzen. Wollte ich Räume (und Gegenstände und Personen) explizit in einem Dictionary verwalten (statt implizit, weil sie ja Attribute in einem Modul/einer Klasse/einem Exemplar sind), müsste ich vermehrt mit Strings statt mit Namen arbeiten, was ich nicht so gut fand.

Ich hatte ursprünglich die Vorstellung, die Spielbeschreibung statisch (wie ein Programm) zu halten und im zweiten Teil des Tutorials schon formuliert: "...weil ich die Spielbeschreibung möglichst statisch halten möchte. Dafür gibt es keinen wirklichen Grund, sondern nur die wage Idee, man könnte später aus dem Spiel ja eine Webanwendung machen, wo es dann praktisch wäre, Spieldaten und den Zustand (die _Session_) zu trennen."

Inzwischen bin ich mir nicht mehr sicher, denn einiges wird einfacher, wenn ich meine Datenstruktur direkt manipulieren kann. Und einstürzende Gänge sind ja durchaus eine interessante Idee. Will man so etwas statisch machen, muss man das von Anfang an planen und Bedingungen in die Beschreibung einbauen.

Was emit() angeht: Ich wollte erstens möglichst ohne andere Module auskommen und zweitens brauche ich letztlich eine Funktion, mit der ich einen Absatz mit mehreren Aufrufen von emit() erzeugen kann, nicht wie jetzt, wo emit() immer einen Absatz erzeugt. Ich denke, das wird dann mit textwrap.fill schwieriger, während ich jetzt nur die Variable "column" global machen muss.

Für den zweiten Teil (Gegenstände) bin ich aber noch auf der Suche nach der besten Repräsentation und muss meinen Wunsch bekämpfen, eine eigene Prototyp-basierte Sprache statt Python für das Spiel zu benutzen ;)

Stefan
Antworten