Stufen, Dialoge und Quests
Kurz gesagt: Erfahrung wird nach einer Tabelle gezählt, und neue Stufen und Fähigkeiten werden eine nach der anderen vergeben; Kampf und Karte veröffentlichen nur Ereignisse auf einem Bus, während Quests sie abonnieren und endliche Automaten sind; ein Dialog ist ein Baum, in dem Bedingungen unpassende Antworten verbergen.
Problem
Der Wolf, der nicht weiß, dass es eine Quest gibt
Bisher hat der Held Feinde besiegt, und das war alles. Jetzt soll das Spiel noch mehr tun: Der Tod des Wolfsführers soll eine Quest abschließen, der Tod des Wächters den Sieg bringen, das Betreten der zweiten Ebene eine andere Quest voranbringen, und Erfahrung soll die Stufe erhöhen. Am einfachsten schreibst du es dorthin, wo der Feind stirbt:
def on_enemy_killed(enemy): hero.xp += enemy.xp # Der Kampf kennt jeden Teil des Spiels, den der Tod eines Feindes interessiert. quests.enemy_killed(enemy) achievements.enemy_killed(enemy) # Ein neuer Listener: noch eine Zeile hier und noch eine Änderung im Kampf. stats.kills += 1Hier kennt der Kampf Quests, Erfolge und Statistik. Eine neue Quest ist eine neue Zeile im Kampf, und der Kampf hat schon viele Gründe, sich zu ändern. Schlimmer noch: Jeder neue Listener wird in fremdem Code eingetragen, und jede vergessene Verbindung ist eine Quest, die nie endet.
Die Lösung ist, die Richtung des Wissens umzudrehen. Der Kampf sagt an einer Stelle „der Feind ist gestorben“ und kümmert sich nicht darum, wer zuhört. Wer es wissen will, trägt sich ein (abonniert). Das ist ein Ereignisbus. Die Lektion baut fünf Dinge, in der Reihenfolge, in der das Spiel sie braucht: Erfahrung und Stufen (eine Kurve als Tabelle), einen Fähigkeitenbaum, einen Ereignisbus, eine Quest als endlicher Automat und einen Dialog als Baum mit Bedingungen.
So sieht dieselbe Stelle mit einem Bus aus. Der Kampf veröffentlicht eine Zeile, mehr nicht:
def on_enemy_killed(enemy): hero.xp += enemy.xp bus.publish(GameEvent("EnemyKilled", enemy.kind))Die Idee
Erfahrung und Stufen: Die Kurve ist eine Tabelle
Wie viel Erfahrung braucht jede Stufe? Eine Formel (etwa 100 × Stufe²) ist glatt, aber zum Abstimmen eines Spiels unbequem: Du willst sagen „Stufe 3 kommt nach etwa sechs Feinden“, nicht rechnen. Deshalb ist die Kurve eine Tabelle: XP_TABLE[n] ist die gesamte Erfahrung, mit der Stufe n erreicht wird. Über das Ende der Tabelle hinaus geht die Kurve mit 150 mehr pro Stufe weiter, damit das Spiel auf Stufe 11 nicht abstürzt.
Der Held hat xp und level. Bei jedem Sieg wächst xp, und pendingLevels sagt, wie viele Stufen warten: wie viele der nächsten Schwellen die Erfahrung schon erreicht hat. Die neue Stufe ist ein eigener Schritt (raiseLevel): mehr Leben. Das Programm besiegt sieben Feinde aus dem Spiel, jeden mit seiner Erfahrung (Ratte 5, Goblin 9, ... der Wächter 150), und schreibt jede neue Stufe:
class Hero: def __init__(self): self.level = 1 self.xp = 0 self.max_hp = 48
# XP_TABLE[n] ist die gesamte Erfahrung, mit der Stufe n erreicht wird.XP_TABLE = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720]
def xp_for_level(level): if level < len(XP_TABLE): return XP_TABLE[level] # Nach dem Ende der Tabelle geht die Kurve mit 150 pro Stufe weiter. return XP_TABLE[-1] + (level - len(XP_TABLE) + 1) * 150
# Wie viele Stufen warten: Reicht die Erfahrung für die nächsten Schwellen?def pending_levels(xp, level): levels = 0 while xp >= xp_for_level(level + levels + 1): levels += 1 return levels
def raise_level(hero): hero.level += 1 hero.max_hp += 9KILLS = [("Ratte", 5), ("Goblin", 9), ("Schamane", 12), ("Wolf", 14), ("Kultist", 20), ("Golem", 32), ("Wächter", 150)]
hero = Hero()for name, xp in KILLS: hero.xp += xp print(name + " +" + str(xp) + ": EP " + str(hero.xp)) raise_count = pending_levels(hero.xp, hero.level) for _ in range(raise_count): before = hero.max_hp raise_level(hero) print(" Stufe " + str(hero.level) + ": Leben " + str(before) + " -> " + str(hero.max_hp))
for level in range(10, 13): print("Stufe " + str(level) + ": EP " + str(xp_for_level(level)))Ratte +5: EP 5Goblin +9: EP 14Schamane +12: EP 26 Stufe 2: Leben 48 -> 57Wolf +14: EP 40Kultist +20: EP 60 Stufe 3: Leben 57 -> 66Golem +32: EP 92Wächter +150: EP 242 Stufe 4: Leben 66 -> 75 Stufe 5: Leben 75 -> 84Stufe 10: EP 720Stufe 11: EP 870Stufe 12: EP 1020Der Wächter (+150) gibt zwei Stufen auf einmal, 4 und 5. Darum ist pendingLevels eine Schleife und kein Vergleich: Ein großer Schlag kann eine Schwelle überspringen. Die letzten drei Zeilen zeigen das Ende der Tabelle: Stufe 10 steht noch darin (720), 11 und 12 kommen aus dem + 150 pro Stufe (870, 1020).
# XP_TABLE[n] ist die gesamte Erfahrung, mit der Stufe n erreicht wird.
XP_TABLE = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720]
def xp_for_level(level):
if level < len(XP_TABLE):
return XP_TABLE[level]
# Nach dem Ende der Tabelle geht die Kurve mit 150 pro Stufe weiter.
return XP_TABLE[-1] + (level - len(XP_TABLE) + 1) * 150
# Wie viele Stufen warten: Reicht die Erfahrung für die nächsten Schwellen?
def pending_levels(xp, level):
levels = 0
while xp xp_for_level(level + levels + 1):
levels += 1
return levelsErfahrung erreicht eine Schwelle, wenn sie ihr gleich oder größer ist: >=. Mit > bekäme ein Held mit genau 20 EP nicht Stufe 2, während die Schwelle in der Tabelle bedeutet „mit so viel Erfahrung wird die Stufe erreicht“. Gleichheit zählt auch in der Aufgabe „Sage die Ausgabe voraus“ weiter unten.
Genauer, für Neugierige
Warum eine Tabelle und keine Formel? Die Tabelle sind Daten: ein langsameres Tempo, eine neue Stufe oder ein anderer Modus ist eine andere Tabelle, kein anderer Code. Der Kanon hält sie als Zahlen, getrennt durch | (0|0|20|55|105|170|250|345|455|580|720), und liest sie beim Start zusammen mit den Gegenständen und den Feinden. Die ersten beiden Nullen sind für die Stufen 0 und 1: Der Held beginnt auf Stufe 1.
Die Kurve wird mit der Simulation aus der letzten Lektion abgestimmt: Es laufen Tausende von Spielen, und du schaust, welche Stufe der Held gegen welchen Feind erreicht.
Die Idee
Der Fähigkeitenbaum: zwei Pfade mit drei Schritten
Bei jeder neuen Stufe wählt der Spieler: einen Wert (+8 Leben, +1 Stärke, +1 Geschick, +1 Abwehr) oder eine neue Fähigkeit. Die Fähigkeiten einer Klasse bilden einen Baum mit zwei Zweigen zu je drei Schritten. Die Regel ist kurz: Eine Fähigkeit ist verfügbar, wenn sie noch nicht gelernt ist und entweder Schritt 1 ist oder im selben Zweig der Schritt eins darunter gelernt ist.
@dataclass(frozen=True)class Skill: id: str branch: int tier: int
SKILLS = [ Skill("rage", 1, 1), Skill("whirlwind", 1, 2), Skill("bloodlust", 1, 3), Skill("war_cry", 2, 1), Skill("iron_skin", 2, 2), Skill("second_wind", 2, 3),]ORDER = ["rage", "war_cry", "whirlwind", "iron_skin", "bloodlust", "second_wind"]
def learnable(skills, known): result = [] for skill in skills: if skill.id in known: continue ready = skill.tier == 1 for other in skills: if other.id in known and other.branch == skill.branch and other.tier == skill.tier - 1: ready = True if ready: result.append(skill.id) return result
def show(items): return "-" if len(items) == 0 else ", ".join(items)Das Programm lernt die Fähigkeiten des Barbaren in einer vorgegebenen Reihenfolge und schreibt nach jeder, was verfügbar ist. Eine leere Liste ist -.
known = []print("Anfang -> " + show(learnable(SKILLS, known)))for skill_id in ORDER: known.append(skill_id) print(skill_id + " -> " + show(learnable(SKILLS, known)))Anfang -> rage, war_cryrage -> whirlwind, war_crywar_cry -> whirlwind, iron_skinwhirlwind -> bloodlust, iron_skiniron_skin -> bloodlust, second_windbloodlust -> second_windsecond_wind -> -Am Anfang sind nur die beiden Anfänge verfügbar: rage und war_cry. Wer rage lernt, öffnet whirlwind, aber bloodlust wartet noch (es braucht whirlwind), und der Zweig von war_cry ändert sich nicht. Am Ende steht second_wind -> -: Der Baum ist aufgebraucht, und in der Auswahl für die neue Stufe bleiben nur die Werte.
Die Idee
Ein Ereignisbus: Wer veröffentlicht, weiß nicht, wer zuhört
Der Bus ist das Einfachste: ein Wörterbuch von einem Ereignistyp auf eine Liste von Funktionen. subscribe(type, handler) legt eine Funktion in die Liste für diesen Typ. publish(event) ruft alle Funktionen der Liste für den Typ des Ereignisses auf. Mehr nicht.
Ein Ereignis ist ein kleiner Datensatz: ein Typ und Daten. Hier sind es type, id (der Feind, der Gegenstand oder der Dialog) und depth (die Ebene). Beispiele sind EnemyKilled (ein Feind ist gestorben), TileEntered (der Held hat eine Ebene betreten), DialogChoice (eine Antwort wurde gewählt) und ItemPicked (ein Gegenstand wurde aufgenommen).
@dataclass(frozen=True)class GameEvent: type: str id: str = "" depth: int = 0
def label(self): return str(self.depth) if self.id == "" else self.id
class EventBus: def __init__(self): self.handlers = {}
def subscribe(self, type, handler): self.handlers.setdefault(type, []).append(handler)
def publish(self, event): if event.type not in self.handlers: return # Eine Kopie der Liste: Ein Listener darf sich während des Veröffentlichens an- oder abmelden. for handler in list(self.handlers[event.type]): handler(event)Das erste Programm legt zwei Listener auf EnemyKilled (ein Protokoll und den Zähler der Siege) und einen auf TileEntered. Auf ItemPicked hört niemand, und der Bus tut einfach nichts.
bus = EventBus()stats = Stats()
def count_kill(event): stats.kills += 1 print("Kills: " + str(stats.kills))
bus.subscribe("EnemyKilled", log)bus.subscribe("TileEntered", log)bus.subscribe("EnemyKilled", count_kill)
fire(bus, GameEvent("EnemyKilled", "rat"))fire(bus, GameEvent("TileEntered", depth=2))fire(bus, GameEvent("EnemyKilled", "wolf"))fire(bus, GameEvent("ItemPicked", "ore"))> EnemyKilled ratEintrag: EnemyKilled ratKills: 1> TileEntered 2Eintrag: TileEntered 2> EnemyKilled wolfEintrag: EnemyKilled wolfKills: 2> ItemPicked oreJede Zeile mit > ist ein Veröffentlichen, und die Zeilen darunter sind die Listener, die sich gemeldet haben, in der Reihenfolge ihrer Anmeldung. Nach EnemyKilled rat melden sich beide, nach TileEntered 2 nur das Protokoll, und ItemPicked ore weckt niemanden. Der Code, der veröffentlicht, hat keine Zeile geändert, als die Listener auftauchten.
Genauer, für Neugierige
Dieses Muster heißt Observer oder Publish/Subscribe. In der UI-Welt ist es addEventListener, auf Servern sind es Nachrichtenwarteschlangen. Das Spiel nutzt die einfachste Form: alles in einem Thread, die Listener werden sofort nacheinander aufgerufen. Ein Listener, der etwas Schweres tut, bremst also den ganzen Zug. Die Listener werden in der Reihenfolge ihrer Anmeldung aufgerufen, aber man sollte sich nicht darauf verlassen (im Kanon hängt kein Listener von einem anderen ab).
publish läuft über eine Kopie der Liste. Eine Quest, die eine neue Quest startet, kann während des Veröffentlichens denselben Typ abonnieren; ohne die Kopie wirft das in Java und C# eine Ausnahme, und in JavaScript würde der neue Listener noch im selben Publish aufgerufen.
Die Idee
Eine Quest ist ein endlicher Automat
Eine Quest hat einen Zustand: inactive (noch nicht vergeben), active (mit Etappe 1, 2, 3), done (abgeschlossen) und rewarded (die Belohnung ist abgeholt). Die Übergänge macht nicht der Spieler, sondern Ereignisse. Darum abonniert die Quest selbst die, die sie voranbringen, und hält ihre ganze Logik an einem Ort.
| Von | Nach | Wann |
|---|---|---|
inactive |
active, Etappe 1 |
der Älteste gibt die Quest (Aktion start des Dialogs) |
active, Etappe 1 |
Etappe 2 | TileEntered mit depth 2 |
active, Etappe unter 3 |
Etappe 3 | DialogChoice miner:warden |
active |
done |
EnemyKilled warden |
done |
rewarded |
der Dialog führt die Aktion reward aus |
Die Basis Quest ist der gemeinsame Teil: ein Titel, eine Liste von Zielen je Etappe und drei Methoden, die den Zustand ändern und dem Spieler schreiben. MinersQuest („Die vermissten Bergleute“) ist eine Unterklasse, die nur abonniert:
class Quest: def __init__(self, id, title, goals, done_goal): self.id = id self.title = title self.goals = goals self.done_goal = done_goal self.state = "inactive" self.stage = 0
def goal(self): if self.state == "done": return self.done_goal return self.goals[self.stage - 1]
def start(self): self.state = "active" self.stage = 1 print("Neue Quest: " + self.title + ".")
def advance(self, new_stage): self.stage = new_stage print(self.title + ": " + self.goal())
def complete(self): self.state = "done" print("Quest abgeschlossen: " + self.title + "!")
class MinersQuest(Quest): def __init__(self): super().__init__("miners", "Die vermissten Bergleute", ["Steig in die Mine hinab und such die Bergleute.", "Auf der zweiten Ebene lebt jemand. Finde ihn.", "Besiege den Wächter der Tiefe auf der dritten Ebene."], "Der Wächter ist besiegt. Graufurt ist gerettet.")
def subscribe(self, bus): def on_tile(event): if self.state == "active" and self.stage == 1 and event.depth == 2: self.advance(2)
def on_dialog(event): if self.state == "active" and self.stage < 3 and event.id == "miner:warden": self.advance(3)
def on_kill(event): if self.state == "active" and event.id == "warden": self.complete()
bus.subscribe("TileEntered", on_tile) bus.subscribe("DialogChoice", on_dialog) bus.subscribe("EnemyKilled", on_kill)Jeder Listener beginnt mit state == "active": Der Bus kennt den Zustand der Quest nicht, also ist die Frage Sache der Quest. Die zweite Bedingung (stage == 1, stage < 3, id == ...) sagt, welches Ereignis genau diesen Übergang auslöst. Der Basis fehlt subscribe absichtlich; im Kanon gibt es sie als leere Methode, damit das Spiel alle Quests durchlaufen und bei jeder subscribe aufrufen kann.
Das Szenario hat acht Schritte, vier davon ignoriert die Quest. Der Älteste gibt die Quest im dritten Schritt, und die Schritte sind genau die, die die Szene unten zeigt.
bus = EventBus()quest = MinersQuest()quest.subscribe(bus)
fire(bus, GameEvent("EnemyKilled", "rat"))fire(bus, GameEvent("TileEntered", depth=2))print("> der Älteste gibt die Quest")quest.start()fire(bus, GameEvent("TileEntered", depth=1))fire(bus, GameEvent("TileEntered", depth=2))fire(bus, GameEvent("DialogChoice", "miner:warden"))fire(bus, GameEvent("TileEntered", depth=2))fire(bus, GameEvent("EnemyKilled", "warden"))> EnemyKilled rat> TileEntered 2> der Älteste gibt die QuestNeue Quest: Die vermissten Bergleute.> TileEntered 1> TileEntered 2Die vermissten Bergleute: Auf der zweiten Ebene lebt jemand. Finde ihn.> DialogChoice miner:wardenDie vermissten Bergleute: Besiege den Wächter der Tiefe auf der dritten Ebene.> TileEntered 2> EnemyKilled wardenQuest abgeschlossen: Die vermissten Bergleute!Sieh es dir an
Ereignisse wandern zur Quest
Oben ist der Weg der Quest mit einer Kugel, die auf ihrem Zustand sitzt. Jedes Ereignis ist eine Kachel: Sie steigt zum Bus, geht zur Quest, und die Quest rückt entweder vor oder zuckt und sagt „ignoriert“. Du siehst, welche vier der acht Ereignisse nichts bewegen und warum: Die Quest hat noch nicht begonnen, die Ebene ist eine andere, und bei einem ist die Etappe schon vorbei.
class EventBus: def __init__(self): self.handlers = {}
def subscribe(self, type, handler): self.handlers.setdefault(type, []).append(handler)
def publish(self, event): if event.type not in self.handlers: return # Eine Kopie der Liste: Ein Listener darf sich während des Veröffentlichens an- oder abmelden. for handler in list(self.handlers[event.type]): handler(event)
class Quest: def __init__(self, id, title, goals, done_goal): self.id = id self.title = title self.goals = goals self.done_goal = done_goal self.state = "inactive" self.stage = 0
def goal(self): if self.state == "done": return self.done_goal return self.goals[self.stage - 1]
def start(self): self.state = "active" self.stage = 1 print("Neue Quest: " + self.title + ".")
def advance(self, new_stage): self.stage = new_stage print(self.title + ": " + self.goal())
def complete(self): self.state = "done" print("Quest abgeschlossen: " + self.title + "!")
class MinersQuest(Quest): def __init__(self): super().__init__("miners", "Die vermissten Bergleute", ["Steig in die Mine hinab und such die Bergleute.", "Auf der zweiten Ebene lebt jemand. Finde ihn.", "Besiege den Wächter der Tiefe auf der dritten Ebene."], "Der Wächter ist besiegt. Graufurt ist gerettet.")
def subscribe(self, bus): def on_tile(event): if self.state == "active" and self.stage == 1 and event.depth == 2: self.advance(2)
def on_dialog(event): if self.state == "active" and self.stage < 3 and event.id == "miner:warden": self.advance(3)
def on_kill(event): if self.state == "active" and event.id == "warden": self.complete()
bus.subscribe("TileEntered", on_tile) bus.subscribe("DialogChoice", on_dialog) bus.subscribe("EnemyKilled", on_kill)Schritt für Schritt, in Worten
- Das Spiel veröffentlicht Ereignisse im Bus, und der Bus ruft jeden auf, der diesen Typ abonniert hat. Oben ist die Quest „Die vermissten Bergleute“: Sie hat noch nicht begonnen. Die Kugel steht auf „inaktiv“.
- Eine Ratte stirbt (EnemyKilled rat). Die Quest hat EnemyKilled abonniert, der Bus ruft sie auf, aber der Listener fragt: Ist die Quest aktiv, und ist der Feind der Wächter? Nein: Es passiert nichts. Der Kampf weiß nicht, dass jemand zuhört.
- Der Held steigt auf die zweite Ebene hinab (TileEntered 2). Die Ebene stimmt, aber die Quest ist noch inaktiv (state ist nicht "active"), also wieder nichts. Ereignisse kommen, wann das Spiel es will, nicht wann die Quest bereit ist.
- Der Älteste gibt die Quest: start() macht sie aktiv, Etappe 1. Die Kugel wandert auf „Etappe 1“. Das ist kein Ereignis vom Bus, sondern eine Aktion des Dialogs.
- Der Held betritt Ebene 1 (TileEntered 1). Die Quest ist aktiv und in Etappe 1, will aber Ebene 2: depth ist 1, die Bedingung greift nicht. Ein neues Ereignis desselben Typs ist kein Fortschritt.
- Zweite Ebene (TileEntered 2): aktiv, Etappe 1 und depth 2. Der Listener ruft advance(2) auf: Die Kugel wandert auf „Etappe 2“, und das Spiel schreibt „Die vermissten Bergleute: Auf der zweiten Ebene lebt jemand. Finde ihn.“.
- Der Held spricht mit dem Bergmann und wählt „Was ist da unten?“: Der Dialog veröffentlicht DialogChoice miner:warden. Die Quest ist in Etappe 2 (unter 3), und die id passt: advance(3). Die Kugel steht auf „Etappe 3“.
- Der Held geht erneut auf Ebene 2 (TileEntered 2). depth == 2 stimmt, aber die Etappe ist schon 3, nicht 1: Die Prüfung stage == 1 stoppt das Ereignis. Ohne sie würde die Quest auf Etappe 2 zurückrutschen.
- Der Wächter stirbt (EnemyKilled warden). Die Quest ist aktiv, und die id ist "warden": complete(). Die Kugel steht auf „erledigt“. Der Kampf hat nur gesagt „der Feind ist gestorben“; wer zuhört, geht ihn nichts an.
Der Code neben der Szene ist der Bus, die Basis der Quest und die Unterklasse MinersQuest aus dem Programm. In jedem Schritt leuchten die Zeilen, die in diesem Moment arbeiten.
Die Szene zeigt nur die Quest „Die vermissten Bergleute“. Erfahrung und Dialog stehen in den Programmen oben und unten, nicht in der Szene.
Die Idee
Ein Dialog ist ein Baum mit Bedingungen
Ein Gespräch ist ein Baum: Ein Knoten (node) hat eine Zeile und mehrere Antworten (option), und jede Antwort führt zu einem nächsten Knoten (oder zu end, was ihn schließt). Wäre das alles, wäre der Dialog für jeden Spieler gleich. Aber Vela soll die Silber-Quest nicht anbieten, wenn sie schon angenommen ist, und kein Erz nehmen, das der Held nicht hat. Darum hat eine Antwort eine Bedingung und Aktionen:
- Bedingung (
condition):flag:name,item:gegenstand:anzahl,quest:quest:zustand, mit einem!davor zur Verneinung. Mehrere Bedingungen stehen mit Komma getrennt und müssen alle gelten (ein logisches „und“).-heißt „immer“. - Aktionen (
actions):flag,start,complete,reward,give,take. Sie laufen der Reihe nach, wenn die Antwort gewählt wird.
Bedingungen und Aktionen sind kurzer Text der Form art:argument:argument, weil Dialoge Daten sind: Sie können in einer Datei leben, nicht im Code. visibleOptions zeigt nur die Antworten, deren Bedingung wahr ist, und choose führt die Aktionen der gewählten aus.
@dataclass(frozen=True)class Option: text: str next: str condition: str actions: str
@dataclass(frozen=True)class Node: text: str options: list
class Game: NAMES = {"ore": "Erz", "silver_sword": "Silberschwert", "potion_heal": "Heiltrank"}
def __init__(self, *quests): self.flags = set() self.items = {} self.quests = {quest.id: quest for quest in quests}
def quest(self, id): return self.quests[id]
def count(self, id): return self.items.get(id, 0)
def label(self, id, n): return Game.NAMES[id] + " x" + str(n)
def give(self, id, n): self.items[id] = self.count(id) + n print("Du erhältst: " + self.label(id, n) + ".")
def take(self, id, n): self.items[id] = self.count(id) - n print("Du übergibst: " + self.label(id, n) + ".")def split_list(text): return [] if text == "-" else text.split(",")
def check_condition(game, condition): for part in split_list(condition): negate = part.startswith("!") p = (part[1:] if negate else part).split(":") if p[0] == "flag": ok = p[1] in game.flags elif p[0] == "item": ok = game.count(p[1]) >= int(p[2]) elif p[0] == "quest": ok = game.quest(p[1]).state == p[2] else: raise ValueError("unknown condition: " + part) if negate: ok = not ok if not ok: return False return True
def run_actions(game, actions): for part in split_list(actions): p = part.split(":") if p[0] == "flag": game.flags.add(p[1]) elif p[0] == "start": game.quest(p[1]).start() elif p[0] == "complete": game.quest(p[1]).complete() elif p[0] == "reward": quest = game.quest(p[1]) quest.state = "rewarded" print("Belohnung für " + quest.title + ".") elif p[0] == "give": game.give(p[1], int(p[2])) elif p[0] == "take": game.take(p[1], int(p[2])) else: raise ValueError("unknown action: " + part)
def visible_options(game, node): return [o for o in node.options if o.condition == "-" or check_condition(game, o.condition)]
def show(speaker, game, node): print(speaker + ": " + node.text) for i, option in enumerate(visible_options(game, node)): print(" " + str(i + 1) + ") " + option.text)
def choose(game, node, number): option = visible_options(game, node)[number - 1] print("> " + str(number)) run_actions(game, option.actions) return option.nextDie Daten sind die beiden Dialoge aus dem Spiel: Schmiedin Vela (Gegenstände und eine Quest) und Bergmann Dobri (ein einmaliges Geschenk mit einem Flag).
SMITH = { "start": Node("Meine Esse ist ohne Silber kalt. Bring mir drei Stück Erz, und ich schmiede dir ein Schwert.", [ Option("Ich suche das Erz.", "deal", "quest:silver:inactive", "start:silver"), Option("Hier ist das Erz.", "done", "quest:silver:active,item:ore:3", "take:ore:3,complete:silver,reward:silver,give:silver_sword:1"), Option("Auf Wiedersehen.", "end", "-", "-"), ]), "deal": Node("Das Erz liegt auf der zweiten Ebene. Die Erzgolems tragen es in sich.", [ Option("Gut.", "end", "-", "-"), ]), "done": Node("Ein Silberschwert. Seine Schneide wird nie stumpf.", [ Option("Danke dir.", "end", "-", "-"), ]),}
MINER = { "start": Node("Ein lebender Mensch! Die anderen sind nach oben gerannt... Ich saß hier unten fest.", [ Option("Was ist da unten?", "warden", "quest:miners:active,!flag:miner_told", "flag:miner_told,give:potion_heal:1"), Option("Bleib hier, ich komme zurück.", "end", "-", "-"), ]), "warden": Node("Der Wächter der Tiefe. Ein Steinwächter, geweckt von unseren Hacken. Wenn er Risse bekommt, ruft er die Toten zu Hilfe. Nimm meinen Trank.", [ Option("Ich halte ihn auf.", "end", "-", "-"), ]),}game = Game(Quest("silver", "Silber für die Schmiedin", ["Sammle drei Stücke Silbererz.", "Bring das Erz zu Schmiedin Vela."], "Die Schmiedin hat ihr Silber."), Quest("miners", "Die vermissten Bergleute", ["Steig in die Mine hinab und such die Bergleute.", "Auf der zweiten Ebene lebt jemand. Finde ihn.", "Besiege den Wächter der Tiefe auf der dritten Ebene."], "Der Wächter ist besiegt. Graufurt ist gerettet."))start = SMITH["start"]show("Vela", game, start)next_id = choose(game, start, 1)show("Vela", game, SMITH[next_id])game.give("ore", 3)show("Vela", game, start)next_id = choose(game, start, 1)show("Vela", game, SMITH[next_id])game.quest("miners").start()miner_start = MINER["start"]show("Dobri", game, miner_start)next_id = choose(game, miner_start, 1)show("Dobri", game, MINER[next_id])show("Dobri", game, miner_start)Vela: Meine Esse ist ohne Silber kalt. Bring mir drei Stück Erz, und ich schmiede dir ein Schwert. 1) Ich suche das Erz. 2) Auf Wiedersehen.> 1Neue Quest: Silber für die Schmiedin.Vela: Das Erz liegt auf der zweiten Ebene. Die Erzgolems tragen es in sich. 1) Gut.Du erhältst: Erz x3.Vela: Meine Esse ist ohne Silber kalt. Bring mir drei Stück Erz, und ich schmiede dir ein Schwert. 1) Hier ist das Erz. 2) Auf Wiedersehen.> 1Du übergibst: Erz x3.Quest abgeschlossen: Silber für die Schmiedin!Belohnung für Silber für die Schmiedin.Du erhältst: Silberschwert x1.Vela: Ein Silberschwert. Seine Schneide wird nie stumpf. 1) Danke dir.Neue Quest: Die vermissten Bergleute.Dobri: Ein lebender Mensch! Die anderen sind nach oben gerannt... Ich saß hier unten fest. 1) Was ist da unten? 2) Bleib hier, ich komme zurück.> 1Du erhältst: Heiltrank x1.Dobri: Der Wächter der Tiefe. Ein Steinwächter, geweckt von unseren Hacken. Wenn er Risse bekommt, ruft er die Toten zu Hilfe. Nimm meinen Trank. 1) Ich halte ihn auf.Dobri: Ein lebender Mensch! Die anderen sind nach oben gerannt... Ich saß hier unten fest. 1) Bleib hier, ich komme zurück.Achte darauf, wie sich Velas Antworten für denselben Knoten start ändern: Beim ersten Besuch sind es zwei (deal und „Auf Wiedersehen“), weil done die Quest und Erz braucht. Nachdem der Held drei Erz gebracht hat, bietet sie deal nicht mehr an (die Quest ist aktiv), sondern done. Die Aktionen von done nehmen das Erz, schließen die Quest, geben die Belohnung und das Silberschwert. Bei Dobri verschwindet die Antwort „Was ist da unten?“, sobald das Flag miner_told gesetzt ist: Das Geschenk ist einmalig.
Die Idee
Im Spiel: Erfahrung, Fähigkeiten und der Bus
Hier sind die echten Auszüge aus dem Kanon. Zuerst die Stufen und der Baum (xp-levelup). Neben unserem xpForLevel und pendingLevels gibt es learnableSkills (nur die Fähigkeiten der Klasse des Helden, nach der Liste des Helden), levelUpChoices (vier Werte und die verfügbaren Fähigkeiten) und applyLevelChoice:
# Die Stufenkurve ist eine Tabelle: XP_TABLE[n] ist die Erfahrung, mit der man Stufe n erreicht.def xp_for_level(level): if level < len(XP_TABLE): return XP_TABLE[level] return XP_TABLE[-1] + (level - len(XP_TABLE) + 1) * 150
def pending_levels(hero): levels = 0 while hero.xp >= xp_for_level(hero.level + levels + 1): levels += 1 return levels
# Die neue Stufe selbst: mehr Leben und Mana, volle Gesundheit.def raise_level(hero): hero_class = hero.hero_class() hero.level += 1 hero.base['maxHp'] = hero.base['maxHp'] + hero_class.hp_per_level hero.base['maxMana'] = hero.base['maxMana'] + hero_class.mana_per_level hero.hp = hero.max_hp() hero.mana = hero.max_mana()
# Der Fähigkeitenbaum: zwei Zweige mit drei Stufen. Eine Stufe lässt sich lernen, wenn die davor gelernt ist.def learnable_skills(hero): result = [] for skill in SKILLS.values(): if skill.class_id != hero.class_id or skill.id in hero.skills: continue ready = skill.tier == 1 for known in hero.skills: other = SKILLS[known] if other.branch == skill.branch and other.tier == skill.tier - 1: ready = True if ready: result.append(skill.id) return result
# Die Auswahl bei einer neuen Stufe: ein Wert oder eine neue Fähigkeit.def level_up_choices(hero): choices = [ SimpleNamespace(kind='stat', stat='maxHp', amount=8), SimpleNamespace(kind='stat', stat='strength', amount=1), SimpleNamespace(kind='stat', stat='agility', amount=1), SimpleNamespace(kind='stat', stat='defense', amount=1), ] for skill_id in learnable_skills(hero): choices.append(SimpleNamespace(kind='skill', stat=skill_id, amount=0)) return choices
def apply_level_choice(hero, choice): if choice.kind == 'skill': hero.skills.append(choice.stat) else: hero.base[choice.stat] = hero.base[choice.stat] + choice.amount if choice.stat == 'maxHp': hero.hp = hero.max_hp()Die Unterschiede zu unserem Programm sind gering:
raiseLevelim Spiel fügt auch Mana hinzu und stellt die volle Gesundheit wieder her; wir fassen nur das maximale Leben an.- Die Stufe wird nicht in
gainXpvergeben.enemyKilled(außer Gold und Leiche) erhöhthero.xpund veröffentlicht ein Ereignis, undqueueLevelUpslegt einenLevelUpScreenauf den Stapel (die Lektion über die Bildschirme): Der Spieler wählt einen Wert oder eine Fähigkeit, und erst dann ändernraiseLevelundapplyLevelChoiceden Helden. Es gibt einen Bildschirm pro Stufe, nicht einen Dialog für alle. - Die Werte ändern sich in
base(die Lektion über die Entitäten), nicht im Helden selbst.
Und der Bus ist derselbe wie unserer:
# Der Ereignisbus: Der Sender weiß nicht, wer zuhört. Quests abonnieren und verfolgen ihren eigenen# Fortschritt, und das Kampfsystem sagt nur „der Feind ist gestorben“.class EventBus: def __init__(self): self.handlers = {}
def subscribe(self, event_type, handler): if event_type not in self.handlers: self.handlers[event_type] = [] self.handlers[event_type].append(handler)
def publish(self, event): if event.type not in self.handlers: return for handler in list(self.handlers[event.type]): handler(event)Die Ereignisse im Spiel sind freie Objekte ({ type: 'EnemyKilled', kindId: … }), mit anderen Feldern für jeden Typ: kindId, itemId, dialogId mit nodeId, depth, decor, marker. In Java, C# und Python ist jede Form ein eigener Datensatz. Wir haben alles an type, id und depth gebunden, damit ein Typ in alle vier Sprachen passt.
Die Idee
Im Spiel: Quests und Dialoge
Hier sind die echten Quests. Die Basis Quest nutzt Schlüssel zu den Texten (t('quest.' + id + '.title')), damit das Spiel übersetzt werden kann; MissingMinersQuest ist unsere, und die anderen beiden zeigen, was ein Automat noch kann:
# Eine Quest ist eine kleine Zustandsmaschine: inactive → active (Abschnitte) → done → rewarded. Sie abonniert# selbst die Ereignisse, die sie weiterbringen.class Quest(ABC): def __init__(self, quest_id): self.id = quest_id self.state = 'inactive' self.stage = 0
def title(self): return t('quest.' + self.id + '.title')
def goal(self): if self.state == 'done': return t('quest.' + self.id + '.done') if self.state == 'rewarded': return t('quest.rewarded') return t('quest.' + self.id + '.stage' + str(self.stage))
def start(self, game): self.state = 'active' self.stage = 1 game.say(t('quest.started', {'title': self.title()}))
def advance(self, game, stage): self.stage = stage game.say(t('quest.updated', {'title': self.title(), 'goal': self.goal()}))
def complete(self, game): self.state = 'done' game.say(t('quest.completed', {'title': self.title()}))
@abstractmethod def subscribe(self, bus, game): pass
class MissingMinersQuest(Quest): def __init__(self): super().__init__('miners')
def subscribe(self, bus, game): def on_tile_entered(event): if self.state == 'active' and self.stage == 1 and event.depth == 2: self.advance(game, 2)
def on_dialog_choice(event): if self.state == 'active' and self.stage < 3 and event.dialog_id == 'miner' and event.node_id == 'warden': self.advance(game, 3)
def on_enemy_killed(event): if self.state == 'active' and event.kind_id == 'warden': self.complete(game)
bus.subscribe('TileEntered', on_tile_entered) bus.subscribe('DialogChoice', on_dialog_choice) bus.subscribe('EnemyKilled', on_enemy_killed)
class BloodTrailQuest(Quest): def __init__(self): super().__init__('blood')
def start(self, game): super().start(game) if game.has_flag('leader_dead'): self.complete(game)
def subscribe(self, bus, game): def on_tile_entered(event): if self.state != 'active' or event.depth != 1: return if self.stage == 1 and event.decor == ',': self.advance(game, 2) if self.stage < 3 and event.marker == 'lair': self.advance(game, 3)
def on_enemy_killed(event): if event.kind_id != 'wolf_leader': return game.set_flag('leader_dead') if self.state == 'active': self.complete(game)
bus.subscribe('TileEntered', on_tile_entered) bus.subscribe('EnemyKilled', on_enemy_killed)
class SilverQuest(Quest): def __init__(self): super().__init__('silver')
def start(self, game): super().start(game) self.check(game)
def check(self, game): if self.state == 'active' and self.stage == 1 and game.hero.inventory.count('ore') >= 3: self.advance(game, 2)
def subscribe(self, bus, game): def on_item_picked(event): if event.item_id == 'ore': self.check(game)
bus.subscribe('ItemPicked', on_item_picked)
def create_quests(): return [MissingMinersQuest(), BloodTrailQuest(), SilverQuest()]- Eine Quest, die nach ihrer Bedingung beginnt.
BloodTrailQuest.startundSilverQuest.startprüfen sofort, ob die Bedingung schon wahr ist: Der Wolfsführer kann schon tot sein oder das Erz schon im Rucksack liegen, bevor die Quest angenommen wird. So bleibt die Quest nicht für immer auf Etappe 1. UnsereMinersQuesttut das nicht, weil der Wächter nicht vor dem Gespräch mit dem Ältesten stirbt. - Etappen mit mehr als einer Quelle.
BloodTrailQuesthörtTileEnteredauf zwei Arten: für eine Blutspur (decor) und für den Bau (marker). Die Ereignisse müssen nicht abgestimmt sein: Der Listener fragt nach seinem Zustand und antwortet. - Der Sieg. Der Wächter ist nicht nur
EnemyKilled: Das Spiel setztvictoryinenemyKilled, und die Quest rückt mit demselben Ereignis vor. Die Quest „Die vermissten Bergleute“ hat keine Belohnung: Der Sieg zeigt den Siegesbildschirm.
Der Dialog im Kanon ist das, was wir gebaut haben, und ein bisschen mehr: Die Aktionen enthalten auch gold und xp (die Belohnung des Jägers), und die Bedingungen auch gold: (genug Gold):
# Ein Dialog ist ein Baum mit Bedingungen: Auswahlen, deren Bedingungen nicht erfüllt sind, werden nicht gezeigt.def check_condition(game, condition): for part in split_list(condition): negate = part[:1] == '!' pieces = (part[1:] if negate else part).split(':') ok = False if pieces[0] == 'flag': ok = game.has_flag(pieces[1]) elif pieces[0] == 'item': ok = game.hero.inventory.count(pieces[1]) >= to_int(pieces[2]) elif pieces[0] == 'gold': ok = game.hero.gold >= to_int(pieces[1]) elif pieces[0] == 'quest': ok = game.quest(pieces[1]).state == pieces[2] else: raise RuntimeError('unknown condition: ' + part) if negate: ok = not ok if not ok: return False return True
def run_actions(game, actions): for part in split_list(actions): pieces = part.split(':') if pieces[0] == 'flag': game.set_flag(pieces[1]) elif pieces[0] == 'start': game.quest(pieces[1]).start(game) elif pieces[0] == 'complete': game.quest(pieces[1]).complete(game) elif pieces[0] == 'reward': game.quest(pieces[1]).state = 'rewarded' game.say(t('quest.rewardedSay', {'title': game.quest(pieces[1]).title()})) elif pieces[0] == 'give': game.give_item(pieces[1], to_int(pieces[2])) elif pieces[0] == 'take': game.hero.inventory.remove(pieces[1], to_int(pieces[2])) game.say(t('item.given', {'item': item_label(pieces[1], to_int(pieces[2]))})) elif pieces[0] == 'gold': game.hero.gold += to_int(pieces[1]) game.say(t('item.gold', {'amount': pieces[1]})) elif pieces[0] == 'xp': game.gain_xp(to_int(pieces[1])) else: raise RuntimeError('unknown action: ' + part)
def visible_options(game, node): result = [] for option in node.options: if option.condition == '-' or check_condition(game, option.condition): result.append(option) return resultDialogScreen (der Bildschirm auf dem Stapel) ruft visibleOptions auf, und wenn der Spieler eine Antwort wählt, führt er runActions aus und veröffentlicht DialogChoice mit dem nächsten Knoten. So kennen sich Dialog und Quest nicht: Das Gespräch mit dem Bergmann bringt die Quest nur voran, weil der Dialog veröffentlicht und die Quest zugehört hat.
Probier es aus
Sage die Ausgabe voraus
Drei Berechnungen von pendingLevels mit der Tabelle aus der Lektion. Was gibt das Programm aus?
# XP_TABLE[n] ist die gesamte Erfahrung, mit der Stufe n erreicht wird.XP_TABLE = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720]
def xp_for_level(level): if level < len(XP_TABLE): return XP_TABLE[level] # Nach dem Ende der Tabelle geht die Kurve mit 150 pro Stufe weiter. return XP_TABLE[-1] + (level - len(XP_TABLE) + 1) * 150
# Wie viele Stufen warten: Reicht die Erfahrung für die nächsten Schwellen?def pending_levels(xp, level): levels = 0 while xp >= xp_for_level(level + levels + 1): levels += 1 return levels
print(pending_levels(130, 1))print(pending_levels(104, 3))print(pending_levels(55, 1))Bei 130 EP und Stufe 1 sind die Schwellen 20, 55 und 105 erreicht (130 ≥ 105), 170 nicht: drei Stufen. Bei 104 EP und Stufe 3 ist die nächste Schwelle 105, und 104 erreicht sie nicht: null. Bei 55 EP und Stufe 1 sind die Schwellen 20 und 55 (55 ≥ 55, Gleichheit zählt), 105 nicht: zwei. Die zweite Antwort nimmt > statt >=; die dritte zählt ab xpForLevel(level), also auch die Stufe mit, auf der der Held schon ist.
Probier es aus
Finde den Fehler
Ein Kollege hat eine Prüfung in MinersQuest entfernt, und nun geht die Quest rückwärts. Der Held hat schon mit dem Bergmann gesprochen (Etappe 3) und steigt wieder auf die zweite Ebene hinab:
bus = EventBus()quest = MinersQuest()quest.subscribe(bus)
fire(bus, GameEvent("EnemyKilled", "rat"))fire(bus, GameEvent("TileEntered", depth=2))print("> der Älteste gibt die Quest")quest.start()fire(bus, GameEvent("TileEntered", depth=1))fire(bus, GameEvent("TileEntered", depth=2))fire(bus, GameEvent("DialogChoice", "miner:warden"))fire(bus, GameEvent("TileEntered", depth=2))fire(bus, GameEvent("EnemyKilled", "warden"))> EnemyKilled rat> TileEntered 2> der Älteste gibt die QuestNeue Quest: Die vermissten Bergleute.> TileEntered 1> TileEntered 2Die vermissten Bergleute: Auf der zweiten Ebene lebt jemand. Finde ihn.> DialogChoice miner:wardenDie vermissten Bergleute: Besiege den Wächter der Tiefe auf der dritten Ebene.> TileEntered 2Die vermissten Bergleute: Auf der zweiten Ebene lebt jemand. Finde ihn.> EnemyKilled wardenQuest abgeschlossen: Die vermissten Bergleute!Am Ende der Ausgabe, nach dem dritten TileEntered 2 (der Held hat schon mit dem Bergmann gesprochen), ist das Ziel wieder „Auf der zweiten Ebene lebt jemand. Finde ihn.“, obwohl der Held schon vom Wächter weiß. Welche Zeile verursacht das?
Wähle die Zeile mit dem Fehler.
Die Bedingung von TileEntered fragt nicht, in welcher Etappe die Quest ist: state == "active" und depth == 2 stimmen auch in Etappe 3, darum ruft das Hinabsteigen wieder advance(2) auf. Richtig: stage == 1 && depth == 2. Der Bus bewahrt die Reihenfolge der Ereignisse nicht, deshalb prüft jeder Listener sowohl den Zustand als auch die Etappe. Der Fehler ist still: Nichts wirft eine Ausnahme, nur das Ziel im Questlog ist falsch.
Genauer
Genauer: Reihenfolge der Listener, Ausnahmen und Determinismus
Synchron. publish kehrt zurück, wenn alle Listener fertig sind. Alles läuft in einem Thread, darum feuert ein Listener, der ein neues Ereignis veröffentlicht, es innerhalb des ersten (verschachteltes Veröffentlichen). Hört Quest A auf EnemyKilled und veröffentlicht QuestDone, und hört Quest B auf QuestDone, wird B aufgerufen, bevor das erste publish endet. Begrenze die Tiefe: Eine Endlosschleife von Ereignissen ist der härteste Fehler von Bussen.
Reihenfolge der Listener. Die Reihenfolge der Anmeldung ist absichtlich vorhersagbar (eine Liste, keine Menge): Das Spiel muss bei gleichem Startwert in allen vier Sprachen dasselbe Ergebnis geben. Aber hängen Listener von der Reihenfolge ab, ist der Entwurf falsch: Zwei Listener, die einander brauchen, sollten einer sein.
Eine Ausnahme in einem Listener. Hier unterbricht sie publish, und die Listener danach werden nicht aufgerufen. In einem Einzelspielerspiel ist das akzeptabel (besser, den Fehler zu sehen); auf einem Server fängt man sie mit try ab.
Abmelden. Unser Bus hat kein unsubscribe. Eine Quest, die done ist, bleibt abonniert und fragt jedes Mal state == "active"; das ist billig. Wo Listener zahlreich oder kurzlebig sind (Elemente auf dem Bildschirm), bleiben ohne Abmelden „hängende“ Listener und Speicher zurück, der nie zurückgegeben wird.
Der Automat wächst. Fünf Zustände mit je zwei Übergängen sind zehn if-Zeilen. Verzweigt sich eine Quest (eine Seite wählen, nach einer Zeit scheitern, drei Ziele in beliebiger Reihenfolge prüfen), ist der Zustand kein einzelner Wert mehr. Dann ist jedes Ziel ein eigener kleiner Automat, und die Quest ist „done“, wenn alle es sind. Der Kanon hält absichtlich davor an.
Ein Dialog als Daten. Unser Format ist ein String art:arg:arg. Ein Vorteil: Der Dialog kann in einer Datei stehen und ohne neuen Code geändert werden. Ein Preis: Ein Fehler im Namen (quets:silver:active) wird erst zur Laufzeit gefunden (unknown condition), nicht beim Kompilieren. Darum werfen checkCondition und runActions bei einer unbekannten Art, statt sie still zu überspringen.
Übung
Drei Wölfe
Löse die Aufgabe in deinem Editor. Geh vom Programm für die Quest aus.
Die Quest „Drei Wölfe“. Schreibe WolfQuest (eine Unterklasse von Quest) mit einem Ziel, „Töte drei Wölfe“: Die Quest zählt die getöteten Wölfe und geht beim dritten auf done. Sie hört nur auf EnemyKilled; sie zählt nur Wölfe (id ist "wolf") und nur, solange sie active ist. Bei jedem getöteten Wolf, der nicht der letzte ist, schreibt sie das Ziel mit dem Zähler, zum Beispiel „Töte drei Wölfe (1/3)“ (überschreibe goal()), indem sie advance(1) aufruft. Beim dritten ruft sie complete() auf. Ereignisse vor start() und nach done zählen nicht.
Die Basis Quest, EventBus und fire sind dieselben wie im Programm für die Quest. Hier ist das Szenario:
bus = EventBus()quest = WolfQuest()quest.subscribe(bus)
fire(bus, GameEvent("EnemyKilled", "wolf"))print("> start")quest.start()fire(bus, GameEvent("EnemyKilled", "rat"))fire(bus, GameEvent("EnemyKilled", "wolf"))fire(bus, GameEvent("EnemyKilled", "wolf"))fire(bus, GameEvent("EnemyKilled", "wolf"))fire(bus, GameEvent("EnemyKilled", "wolf"))Die Ausgabe ist:
> EnemyKilled wolf> startNeue Quest: Drei Wölfe.> EnemyKilled rat> EnemyKilled wolfDrei Wölfe: Töte drei Wölfe (1/3)> EnemyKilled wolfDrei Wölfe: Töte drei Wölfe (2/3)> EnemyKilled wolfQuest abgeschlossen: Drei Wölfe!> EnemyKilled wolfWeitere Aufgaben
- Belohnung. Füge
Questeine Methodereward()hinzu, die vondonezurewardedgeht und die Belohnung schreibt. Mache dann aus dem Dialog des Jägers eine Antwort mit der Bedingungquest:wolves:doneund der Aktionreward:wolves. - Zwei Quests gleichzeitig. Abonniere
MinersQuestundWolfQuestam selben Bus und schreibe ein Szenario, in dem einEnemyKilledbeide bewegt. Warum reicht einpublish? - Abmelden. Füge dem Bus
unsubscribe(type, handler)hinzu. Was musst du inpublishtun, damit sich ein Listener während des Veröffentlichens abmelden kann? - Der Fähigkeitenbaum. Füge eine dritte Klasse mit zwei Zweigen hinzu, in denen der zweite Schritt zwei gelernte Schritte braucht (je einen aus jedem Zweig). Was ändert das in
learnable?
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Listen: append, remove und sort · dict und set · Lambdas · dataclass, match und Muster
- Aufgaben zum Thema: Die Chroniken von Arkanum