Tutorial: Ein Textadventure in Python (Teil 2)

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

Das letzte Mal haben wir die Schauplätze implementiert. Doch eine Sache fehlt noch. Die Geschichte erfordert, dass beim ersten Betreten des Burghofs Vögel auffliegen sollen.

Dies können wir in enter_room einbauen:

Code: Alles auswählen

def enter_room(room):
    ...
    if room is burghof and not room.visited:
        room.visited = True
        emit("Du schreckst ein Dutzend schwarze Raben auf, die sich laut "
             "krächzend auf dem Dach des Burgfrieds in Sicherheit bringen.")
        emit()
Dazu muss natürlich jetzt jeder Raum noch eine Variable visited haben:

Code: Alles auswählen

class Room:
    def __init__(self, name, description):
        self.name = name
        self.description = description
        self.exits = {}
        self.visited = False
Doch einen Spezialfall hier, einen Spezialfall dort und der Quelltext ist schnell unübersichtlich geworden. Außerdem würde ich gerne Funktionen wie enter_room nicht abenteuerspezifisch gestalten müssen.

Wir könnten uns entscheiden, beim Betreten eines Raumes eine Methode enter aufzurufen. Dies würde uns ermöglichen, in einer Unterklasse eine spezielle Implementierung anzugeben. Nun bräuchte aber jeder Raum seine eigene Klasse, denn Python zwingt uns leider dazu, für jedes Objekt eine Klasse zu definieren. In JavaScript, wo ich keine Klassen habe, wäre das ein guter Weg. Doch bei Python missfällt er mir.

Dennoch möchte ich ihn kurz skizzieren:

Code: Alles auswählen

class Room:
    def enter(self):
        self.describe()

class VorDerBurg(Room):
    def describe(self):
        emit("Vor dir ragt eine alte trutzige Burg auf...")

class Burghof(Room):
    def describe(self):
        emit("Die hohen Mauern lassen nur weg Licht...")

    def enter(self):
        super()
        if not self.visited:
            self.visited = True
            emit("Du schreckst ein Dutzend schwarze Raben auf, die sich laut "
                 "krächzend auf dem Dach des Burgfrieds in Sicherheit bringen.")
            emit()

vor_der_burg = VorDerBurg()
burghof = Burghof()
In beiden Fällen will ich nach der eigentlichen Funktion auf möglichst elegante Weise noch eine weitere Funktion aufrufen. Und eigentlich möchte ich auch noch mehr: Ich möchte nach einem beliebigen Ereignis -- wo das Betreten eines Raumes nur ein Beispiel ist -- eine beliebige Funktion aufrufen können.

Das könnte so aussehen:

Code: Alles auswählen

@after("entering", burghof)
def do(room):
    if not room.visited:
        room.visited = True
        emit("Du schreckst ein Dutzend schwarze Raben auf, die sich laut "
             "krächzend auf dem Dach des Burgfrieds in Sicherheit bringen.")
        emit()
Die "Magie" steckt jetzt natürlich in der Funktion after und einem "Rahmenwerk", welches an der passenden Stelle prüft, ob es Ereignisse gibt, die ausgelöst werden müssen.

Das sieht dann so aus:

Code: Alles auswählen

def enter_room(room):
    global current_room
    current_room = room
    describe_room()
    
    trigger("after entering", room)
Hier ist die Implementierung:

Code: Alles auswählen

events = {}

def trigger(event_type, argument):
    funcs = events.get(event_type)
    if funcs:
        for func in funcs:
            if func(argument):
                return True

def after(event_type, object):
    def inner(func):
        def event(argument):
            if argument is object:
                return func(argument)
        funcs = events.setdefault("after " + event_type, [])
        funcs.append(event)
    return inner
Huh. Das könnte verwirrend sein. Beginnen wir am Anfang. In events will ich alle aufzurufenden Funktionen jeweils unter dem Namen des Ereignis (event_type, z.B. "after entering") als Liste speichern.

Die Funktion trigger sucht dann aus events die passende Liste heraus und wenn sie existiert, geht sie alle Funktionen durch und ruft sie der Reihe nach auf. Ich vereinbare, dass eine Funktion, die etwas anderes als "falsch" zurück gibt, dazu führt, dass das Aufrufen der Funktionen abgebrochen wird und dass trigger in diesem Fall auch etwas anderes als "falsch" (jede Python-Funktion, die keine explizite return-Anweisung hat, liefert implizit None zurück, was als "falsch" gilt) liefert. Dies wird später noch nützlich sein.

Die Funktion after ist ein "Decorator", eine Funktion, die eine Funktion liefert, die mit der dekorierten Funktion als Argument aufgerufen wird. Ein Beispiel macht es hoffentlich klarer:

Code: Alles auswählen

@after("entering", burghof)
def do(room):
    ...
=>
def do(room):
    ...
decorator = after("entering", burghof)
decorator(do)
Daher muss after eine Funktion zurückgeben, die ich inner genannt habe. Dieser Funktion wird eine Funktion (func) übergeben. Das erste, was inner macht, ist eine lokale Funktion namens event zu definieren, die prüft, ob das übergebene Argument dem Argument des Decorators entspricht (also der Burghof ist) und in diesem Fall die dekorierte Funktion (also do) aufruft. Andernfalls passiert nichts. Nun kann ich event unter dem passenden Schlüssel in eine Liste einfügen, damit trigger diese Funktion später aufrufen kann.

Nachdem wir dies verstanden haben, möchte ich den Mechanismus noch etwas erweitern.

Es wäre nett, wenn ich einfach

Code: Alles auswählen

@after("entering", burfhof, once=True)
benutzen könnte, um die Logik mit dem visited aus do herauszuziehen:

Code: Alles auswählen

def after(event_type, object, once=False):
    def wrapper(func):
        doit = True
        def event(argument):
            nonlocal doit
            if doit and argument is object:
                if once: doit = False
                return func(argument)
        funcs = events.setdefault("after " + event_type, [])
        funcs.append(event)
    return wrapper
Ich möchte noch eine weitere Sache ändern. Mir gefällt nicht, dass die Raben einen eigenen Absatz bekommen. Daher möchte ich `emit` wie folgt ändern: Alles was mit `emit` ausgegeben wird, wird zu einem Absatz zusammengefasst. Erst ein explizites `emit()` erzeugt einen neuen Absatz:

Code: Alles auswählen

    def emit(s="", width=80):
        global column
        if s:
            for word in str(s).split():
                column += len(word) + 1
                if column > width:
                    column = len(word) + 1
                    print()
                print(word, end=" ")
        else:
            column = 0
            print("\n")
Dann muss ich die `emit`s von `describe_room` umstellen:

Code: Alles auswählen

    def describe_room():
        emit(); emit(current_room.name)
        emit(); emit(current_room.description)
Und ich brauche ein `emit()` in `execute_command()`:

Code: Alles auswählen

    def execute_command():
        emit()
        ...
Und der Text erscheint, wie gewünscht:

Code: Alles auswählen

    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. Du schreckst ein Dutzend schwarze Raben 
    auf, die sich laut krächzend auf dem Dach des Burgfrieds in Sicherheit bringen. 
    
    ? 
Nun fliegen sie, die Raben. So viel Arbeit für ein bisschen Lokalkolorit...

Stefan
Dav1d
User
Beiträge: 1437
Registriert: Donnerstag 30. Juli 2009, 12:03
Kontaktdaten:

Gefällt mir, freue mich auf den nächsten Teil.

Nur das emit() stört micht, wie wäre es mit einem kwarg:

Code: Alles auswählen

def emit(s='', width=80, newline=False)
the more they change the more they stay the same
sma
User
Beiträge: 3018
Registriert: Montag 19. November 2007, 19:57
Wohnort: Kiel

Zugegeben: emit() ist blöd. Aber ein emit(newline=True) finde ich auch nicht schon. Ich hatte erst emitnl() als Funktion, aber es fängt damit an, dass es ja keine neue Zeile ist, die ich erzeuge, sondern ich beginne einen neuen Absatz. doch auch newpar() klang doof. Außerdem hatte ich im ersten Teil schon emit() benutzt und wollte das nicht zu sehr ändern.

Vielleicht brauche ich newparagraph() und addtoparagraph() als Funktionen. Ach verdammt, Namen sind schwer.

Ich hätte gerne Vorschläge :)

Stefan
Benutzeravatar
Hyperion
Moderator
Beiträge: 7478
Registriert: Freitag 4. August 2006, 14:56
Wohnort: Hamburg
Kontaktdaten:

So, bin endlich dazu gekommen, das aktuelle Thema durch zu lesen. Nice :-)

@emit: Hm... könntest Du nicht eine Art "Template"-Engine in `emit` einbauen, die Steuerbefehle im String akzeptiert? Dann könntest Du die Strings durch Wrapper-Funktionen "ummodeln", so dass sie von den gewünschten Steuerzeichen eingerahmt werden: Demo

Wenn man das Wrappen bei `emit` nervig findet, könnte man aus `room.name` und `room.description` ja Properties basteln, die das erledigen; damit hätte man zumindest den String frei von Markup erhalten.
encoding_kapiert = all(verstehen(lesen(info)) for info in (Leonidas Folien, Blog, Folien & Text inkl. Python3, utf-8 everywhere))
assert encoding_kapiert
Antworten