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:
void OnEnemyKilled(Enemy enemy){ hero.Xp += enemy.Xp; // Der Kampf kennt jeden Teil des Spiels, den der Tod eines Feindes interessiert. quests.EnemyKilled(enemy); achievements.EnemyKilled(enemy); // Ein neuer Listener: noch eine Zeile hier und noch eine Änderung im Kampf. stats.Kills++;}Hier 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:
void OnEnemyKilled(Enemy enemy){ hero.Xp += enemy.Xp; bus.Publish(new 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{ public int Level = 1; public int Xp = 0; public int MaxHp = 48;}
record Kill(string Name, int Xp);
// XP_TABLE[n] ist die gesamte Erfahrung, mit der Stufe n erreicht wird.int[] xpTable = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720];
int XpForLevel(int level){ if (level < xpTable.Length) return xpTable[level]; // Nach dem Ende der Tabelle geht die Kurve mit 150 pro Stufe weiter. return xpTable[xpTable.Length - 1] + (level - xpTable.Length + 1) * 150;}
// Wie viele Stufen warten: Reicht die Erfahrung für die nächsten Schwellen?int PendingLevels(int xp, int level){ int levels = 0; while (xp >= XpForLevel(level + levels + 1)) levels++; return levels;}
void RaiseLevel(Hero hero){ hero.Level += 1; hero.MaxHp += 9;}Kill[] kills = [new Kill("Ratte", 5), new Kill("Goblin", 9), new Kill("Schamane", 12), new Kill("Wolf", 14), new Kill("Kultist", 20), new Kill("Golem", 32), new Kill("Wächter", 150)];
var hero = new Hero();foreach (var kill in kills){ hero.Xp += kill.Xp; Console.WriteLine(kill.Name + " +" + kill.Xp + ": EP " + hero.Xp); int raise = PendingLevels(hero.Xp, hero.Level); for (int i = 0; i < raise; i++) { int before = hero.MaxHp; RaiseLevel(hero); Console.WriteLine(" Stufe " + hero.Level + ": Leben " + before + " -> " + hero.MaxHp); }}for (int level = 10; level <= 12; level++){ Console.WriteLine("Stufe " + level + ": EP " + XpForLevel(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.
int[] xpTable = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720];
int XpForLevel(int level)
{
if (level < xpTable.Length) return xpTable[level];
// Nach dem Ende der Tabelle geht die Kurve mit 150 pro Stufe weiter.
return xpTable[xpTable.Length - 1] + (level - xpTable.Length + 1) * 150;
}
// Wie viele Stufen warten: Reicht die Erfahrung für die nächsten Schwellen?
int PendingLevels(int xp, int level)
{
int levels = 0;
while (xp XpForLevel(level + levels + 1)) levels++;
return levels;
}Erfahrung 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.
record Skill(string Id, int Branch, int Tier);
Skill[] allSkills = [ new Skill("rage", 1, 1), new Skill("whirlwind", 1, 2), new Skill("bloodlust", 1, 3), new Skill("war_cry", 2, 1), new Skill("iron_skin", 2, 2), new Skill("second_wind", 2, 3)];string[] order = ["rage", "war_cry", "whirlwind", "iron_skin", "bloodlust", "second_wind"];
List<string> Learnable(Skill[] skills, List<string> known){ var result = new List<string>(); foreach (var skill in skills) { if (known.Contains(skill.Id)) continue; bool ready = skill.Tier == 1; foreach (var other in skills) { if (known.Contains(other.Id) && other.Branch == skill.Branch && other.Tier == skill.Tier - 1) ready = true; } if (ready) result.Add(skill.Id); } return result;}
string Show(List<string> list){ return list.Count == 0 ? "-" : string.Join(", ", list);}Das Programm lernt die Fähigkeiten des Barbaren in einer vorgegebenen Reihenfolge und schreibt nach jeder, was verfügbar ist. Eine leere Liste ist -.
var known = new List<string>();Console.WriteLine("Anfang -> " + Show(Learnable(allSkills, known)));foreach (var id in order){ known.Add(id); Console.WriteLine(id + " -> " + Show(Learnable(allSkills, 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).
record GameEvent(string Type, string Id = "", int Depth = 0){ public string Label => Id == "" ? Depth.ToString() : Id;}
class EventBus{ readonly Dictionary<string, List<Action<GameEvent>>> handlers = new();
public void Subscribe(string type, Action<GameEvent> handler) { if (!handlers.ContainsKey(type)) handlers[type] = new List<Action<GameEvent>>(); handlers[type].Add(handler); }
public void Publish(GameEvent e) { if (!handlers.ContainsKey(e.Type)) return; // Eine Kopie der Liste: Ein Listener darf sich während des Veröffentlichens an- oder abmelden. foreach (var handler in new List<Action<GameEvent>>(handlers[e.Type])) handler(e); }}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.
var bus = new EventBus();var stats = new Stats();bus.Subscribe("EnemyKilled", e => Log(e));bus.Subscribe("TileEntered", e => Log(e));bus.Subscribe("EnemyKilled", e =>{ stats.Kills++; Console.WriteLine("Kills: " + stats.Kills);});
Fire(bus, new GameEvent("EnemyKilled", "rat"));Fire(bus, new GameEvent("TileEntered", Depth: 2));Fire(bus, new GameEvent("EnemyKilled", "wolf"));Fire(bus, new 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{ public readonly string Id; public readonly string Title; public readonly string[] Goals; public readonly string DoneGoal; public string State = "inactive"; public int Stage = 0;
public Quest(string id, string title, string[] goals, string doneGoal) { Id = id; Title = title; Goals = goals; DoneGoal = doneGoal; }
public virtual string Goal() { if (State == "done") return DoneGoal; return Goals[Stage - 1]; }
public void Start() { State = "active"; Stage = 1; Console.WriteLine("Neue Quest: " + Title + "."); }
public void Advance(int newStage) { Stage = newStage; Console.WriteLine(Title + ": " + Goal()); }
public void Complete() { State = "done"; Console.WriteLine("Quest abgeschlossen: " + Title + "!"); }}
class MinersQuest : Quest{ public MinersQuest() : base("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.") { }
public void Subscribe(EventBus bus) { bus.Subscribe("TileEntered", e => { if (State == "active" && Stage == 1 && e.Depth == 2) Advance(2); }); bus.Subscribe("DialogChoice", e => { if (State == "active" && Stage < 3 && e.Id == "miner:warden") Advance(3); }); bus.Subscribe("EnemyKilled", e => { if (State == "active" && e.Id == "warden") Complete(); }); }}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.
var bus = new EventBus();var quest = new MinersQuest();quest.Subscribe(bus);
Fire(bus, new GameEvent("EnemyKilled", "rat"));Fire(bus, new GameEvent("TileEntered", Depth: 2));Console.WriteLine("> der Älteste gibt die Quest");quest.Start();Fire(bus, new GameEvent("TileEntered", Depth: 1));Fire(bus, new GameEvent("TileEntered", Depth: 2));Fire(bus, new GameEvent("DialogChoice", "miner:warden"));Fire(bus, new GameEvent("TileEntered", Depth: 2));Fire(bus, new 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{ readonly Dictionary<string, List<Action<GameEvent>>> handlers = new();
public void Subscribe(string type, Action<GameEvent> handler) { if (!handlers.ContainsKey(type)) handlers[type] = new List<Action<GameEvent>>(); handlers[type].Add(handler); }
public void Publish(GameEvent e) { if (!handlers.ContainsKey(e.Type)) return; // Eine Kopie der Liste: Ein Listener darf sich während des Veröffentlichens an- oder abmelden. foreach (var handler in new List<Action<GameEvent>>(handlers[e.Type])) handler(e); }}
class Quest{ public readonly string Id; public readonly string Title; public readonly string[] Goals; public readonly string DoneGoal; public string State = "inactive"; public int Stage = 0;
public Quest(string id, string title, string[] goals, string doneGoal) { Id = id; Title = title; Goals = goals; DoneGoal = doneGoal; }
public virtual string Goal() { if (State == "done") return DoneGoal; return Goals[Stage - 1]; }
public void Start() { State = "active"; Stage = 1; Console.WriteLine("Neue Quest: " + Title + "."); }
public void Advance(int newStage) { Stage = newStage; Console.WriteLine(Title + ": " + Goal()); }
public void Complete() { State = "done"; Console.WriteLine("Quest abgeschlossen: " + Title + "!"); }}
class MinersQuest : Quest{ public MinersQuest() : base("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.") { }
public void Subscribe(EventBus bus) { bus.Subscribe("TileEntered", e => { if (State == "active" && Stage == 1 && e.Depth == 2) Advance(2); }); bus.Subscribe("DialogChoice", e => { if (State == "active" && Stage < 3 && e.Id == "miner:warden") Advance(3); }); bus.Subscribe("EnemyKilled", e => { if (State == "active" && e.Id == "warden") Complete(); }); }}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.
record Option(string Text, string Next, string Condition, string Actions);
record Node(string Text, List<Option> Options);
class Game{ static readonly Dictionary<string, string> Names = new() { ["ore"] = "Erz", ["silver_sword"] = "Silberschwert", ["potion_heal"] = "Heiltrank", };
public readonly HashSet<string> Flags = new(); public readonly Dictionary<string, int> Items = new(); public readonly Dictionary<string, Quest> Quests = new();
public Game(params Quest[] list) { foreach (var quest in list) Quests[quest.Id] = quest; }
public Quest GetQuest(string id) { return Quests[id]; }
public int Count(string id) { return Items.GetValueOrDefault(id, 0); }
string Label(string id, int n) { return Names[id] + " x" + n; }
public void Give(string id, int n) { Items[id] = Count(id) + n; Console.WriteLine("Du erhältst: " + Label(id, n) + "."); }
public void Take(string id, int n) { Items[id] = Count(id) - n; Console.WriteLine("Du übergibst: " + Label(id, n) + "."); }}string[] SplitList(string text){ return text == "-" ? [] : text.Split(',');}
bool CheckCondition(Game game, string condition){ foreach (var part in SplitList(condition)) { bool negate = part.StartsWith('!'); var p = (negate ? part.Substring(1) : part).Split(':'); bool ok; if (p[0] == "flag") ok = game.Flags.Contains(p[1]); else if (p[0] == "item") ok = game.Count(p[1]) >= int.Parse(p[2]); else if (p[0] == "quest") ok = game.GetQuest(p[1]).State == p[2]; else throw new ArgumentException("unknown condition: " + part); if (negate) ok = !ok; if (!ok) return false; } return true;}
void RunActions(Game game, string actions){ foreach (var part in SplitList(actions)) { var p = part.Split(':'); if (p[0] == "flag") { game.Flags.Add(p[1]); } else if (p[0] == "start") { game.GetQuest(p[1]).Start(); } else if (p[0] == "complete") { game.GetQuest(p[1]).Complete(); } else if (p[0] == "reward") { var quest = game.GetQuest(p[1]); quest.State = "rewarded"; Console.WriteLine("Belohnung für " + quest.Title + "."); } else if (p[0] == "give") { game.Give(p[1], int.Parse(p[2])); } else if (p[0] == "take") { game.Take(p[1], int.Parse(p[2])); } else { throw new ArgumentException("unknown action: " + part); } }}
List<Option> VisibleOptions(Game game, Node node){ var result = new List<Option>(); foreach (var option in node.Options) { if (option.Condition == "-" || CheckCondition(game, option.Condition)) result.Add(option); } return result;}
void Show(string speaker, Game game, Node node){ Console.WriteLine(speaker + ": " + node.Text); var options = VisibleOptions(game, node); for (int i = 0; i < options.Count; i++) Console.WriteLine(" " + (i + 1) + ") " + options[i].Text);}
string Choose(Game game, Node node, int number){ var option = VisibleOptions(game, node)[number - 1]; Console.WriteLine("> " + number); RunActions(game, option.Actions); return option.Next;}Die Daten sind die beiden Dialoge aus dem Spiel: Schmiedin Vela (Gegenstände und eine Quest) und Bergmann Dobri (ein einmaliges Geschenk mit einem Flag).
var smith = new Dictionary<string, Node>{ ["start"] = new Node("Meine Esse ist ohne Silber kalt. Bring mir drei Stück Erz, und ich schmiede dir ein Schwert.", [ new Option("Ich suche das Erz.", "deal", "quest:silver:inactive", "start:silver"), new Option("Hier ist das Erz.", "done", "quest:silver:active,item:ore:3", "take:ore:3,complete:silver,reward:silver,give:silver_sword:1"), new Option("Auf Wiedersehen.", "end", "-", "-")]), ["deal"] = new Node("Das Erz liegt auf der zweiten Ebene. Die Erzgolems tragen es in sich.", [ new Option("Gut.", "end", "-", "-")]), ["done"] = new Node("Ein Silberschwert. Seine Schneide wird nie stumpf.", [ new Option("Danke dir.", "end", "-", "-")]),};
var miner = new Dictionary<string, Node>{ ["start"] = new Node("Ein lebender Mensch! Die anderen sind nach oben gerannt... Ich saß hier unten fest.", [ new Option("Was ist da unten?", "warden", "quest:miners:active,!flag:miner_told", "flag:miner_told,give:potion_heal:1"), new Option("Bleib hier, ich komme zurück.", "end", "-", "-")]), ["warden"] = new 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.", [ new Option("Ich halte ihn auf.", "end", "-", "-")]),};var game = new Game(new Quest("silver", "Silber für die Schmiedin", ["Sammle drei Stücke Silbererz.", "Bring das Erz zu Schmiedin Vela."], "Die Schmiedin hat ihr Silber."), new 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."));var start = smith["start"];Show("Vela", game, start);string next = Choose(game, start, 1);Show("Vela", game, smith[next]);game.Give("ore", 3);Show("Vela", game, start);next = Choose(game, start, 1);Show("Vela", game, smith[next]);game.GetQuest("miners").Start();var minerStart = miner["start"];Show("Dobri", game, minerStart);next = Choose(game, minerStart, 1);Show("Dobri", game, miner[next]);Show("Dobri", game, minerStart);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.class LevelChoice{ public string Kind = ""; public string Stat = ""; public int Amount;}
static partial class Arcanum{ public static int XpForLevel(int level) { if (level < XP_TABLE.Count) return XP_TABLE[level]; return XP_TABLE[XP_TABLE.Count - 1] + (level - XP_TABLE.Count + 1) * 150; }
public static int PendingLevels(Hero hero) { int levels = 0; while (hero.Xp >= XpForLevel(hero.Level + levels + 1)) levels++; return levels; }
// Die neue Stufe selbst: mehr Leben und Mana, volle Gesundheit. public static void RaiseLevel(Hero hero) { HeroClass heroClass = hero.HeroClass(); hero.Level += 1; hero.Base["maxHp"] = hero.Base["maxHp"] + heroClass.HpPerLevel; hero.Base["maxMana"] = hero.Base["maxMana"] + heroClass.ManaPerLevel; hero.Hp = hero.MaxHp(); hero.Mana = hero.MaxMana(); }
// Der Fähigkeitenbaum: zwei Zweige mit drei Stufen. Eine Stufe lässt sich lernen, wenn die davor gelernt ist. public static List<string> LearnableSkills(Hero hero) { List<string> result = new List<string>(); foreach (Skill skill in SKILLS.Values()) { if (skill.ClassId != hero.ClassId || hero.Skills.IndexOf(skill.Id) >= 0) continue; bool ready = skill.Tier == 1; foreach (string known in hero.Skills) { Skill other = SKILLS.Get(known); if (other.Branch == skill.Branch && other.Tier == skill.Tier - 1) ready = true; } if (ready) result.Add(skill.Id); } return result; }
// Die Auswahl bei einer neuen Stufe: ein Wert oder eine neue Fähigkeit. public static List<LevelChoice> LevelUpChoices(Hero hero) { List<LevelChoice> choices = new List<LevelChoice> { new LevelChoice { Kind = "stat", Stat = "maxHp", Amount = 8 }, new LevelChoice { Kind = "stat", Stat = "strength", Amount = 1 }, new LevelChoice { Kind = "stat", Stat = "agility", Amount = 1 }, new LevelChoice { Kind = "stat", Stat = "defense", Amount = 1 }, }; foreach (string skillId in LearnableSkills(hero)) choices.Add(new LevelChoice { Kind = "skill", Stat = skillId, Amount = 0 }); return choices; }
public static void ApplyLevelChoice(Hero hero, LevelChoice choice) { if (choice.Kind == "skill") { hero.Skills.Add(choice.Stat); } else { hero.Base[choice.Stat] = hero.Base[choice.Stat] + choice.Amount; if (choice.Stat == "maxHp") hero.Hp = hero.MaxHp(); } }}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 GameEvent{ public string Type = ""; public int Depth; public int X; public int Y; public char Decor = ' '; public string Marker = ""; public string ItemId = ""; public int Count; public string KindId = ""; public string DialogId = ""; public string NodeId = "";}
class EventBus{ public Dictionary<string, List<Action<GameEvent>>> Handlers = new Dictionary<string, List<Action<GameEvent>>>();
public void Subscribe(string type, Action<GameEvent> handler) { if (!Handlers.ContainsKey(type)) Handlers[type] = new List<Action<GameEvent>>(); Handlers[type].Add(handler); }
public void Publish(GameEvent gameEvent) { if (!Handlers.ContainsKey(gameEvent.Type)) return; foreach (Action<GameEvent> handler in new List<Action<GameEvent>>(Handlers[gameEvent.Type])) handler(gameEvent); }}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.abstract class Quest{ public string Id; public string State; public int Stage;
public Quest(string id) { Id = id; State = "inactive"; Stage = 0; }
public string Title() { return T("quest." + Id + ".title"); }
public string Goal() { if (State == "done") return T("quest." + Id + ".done"); if (State == "rewarded") return T("quest.rewarded"); return T("quest." + Id + ".stage" + Stage); }
public virtual void Start(Game game) { State = "active"; Stage = 1; game.Say(T("quest.started", ("title", Title()))); }
public void Advance(Game game, int stage) { Stage = stage; game.Say(T("quest.updated", ("title", Title()), ("goal", Goal()))); }
public void Complete(Game game) { State = "done"; game.Say(T("quest.completed", ("title", Title()))); }
public virtual void Subscribe(EventBus bus, Game game) { }}
class MissingMinersQuest : Quest{ public MissingMinersQuest() : base("miners") { }
public override void Subscribe(EventBus bus, Game game) { bus.Subscribe("TileEntered", (gameEvent) => { if (State == "active" && Stage == 1 && gameEvent.Depth == 2) Advance(game, 2); }); bus.Subscribe("DialogChoice", (gameEvent) => { if (State == "active" && Stage < 3 && gameEvent.DialogId == "miner" && gameEvent.NodeId == "warden") Advance(game, 3); }); bus.Subscribe("EnemyKilled", (gameEvent) => { if (State == "active" && gameEvent.KindId == "warden") Complete(game); }); }}
class BloodTrailQuest : Quest{ public BloodTrailQuest() : base("blood") { }
public override void Start(Game game) { base.Start(game); if (game.HasFlag("leader_dead")) Complete(game); }
public override void Subscribe(EventBus bus, Game game) { bus.Subscribe("TileEntered", (gameEvent) => { if (State != "active" || gameEvent.Depth != 1) return; if (Stage == 1 && gameEvent.Decor == ',') Advance(game, 2); if (Stage < 3 && gameEvent.Marker == "lair") Advance(game, 3); }); bus.Subscribe("EnemyKilled", (gameEvent) => { if (gameEvent.KindId != "wolf_leader") return; game.SetFlag("leader_dead"); if (State == "active") Complete(game); }); }}
class SilverQuest : Quest{ public SilverQuest() : base("silver") { }
public override void Start(Game game) { base.Start(game); Check(game); }
public void Check(Game game) { if (State == "active" && Stage == 1 && game.Hero.Inventory.Count("ore") >= 3) Advance(game, 2); }
public override void Subscribe(EventBus bus, Game game) { bus.Subscribe("ItemPicked", (gameEvent) => { if (gameEvent.ItemId == "ore") Check(game); }); }}
static partial class Arcanum{ public static List<Quest> CreateQuests() { return new List<Quest> { new MissingMinersQuest(), new BloodTrailQuest(), new 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.static partial class Arcanum{ public static bool CheckCondition(Game game, string condition) { foreach (string part in SplitList(condition)) { bool negate = part[0] == '!'; string[] pieces = (negate ? part.Substring(1) : part).Split(':'); bool ok = false; if (pieces[0] == "flag") ok = game.HasFlag(pieces[1]); else if (pieces[0] == "item") ok = game.Hero.Inventory.Count(pieces[1]) >= ToInt(pieces[2]); else if (pieces[0] == "gold") ok = game.Hero.Gold >= ToInt(pieces[1]); else if (pieces[0] == "quest") ok = game.Quest(pieces[1]).State == pieces[2]; else throw new Exception("unknown condition: " + part); if (negate) ok = !ok; if (!ok) return false; } return true; }
public static void RunActions(Game game, string actions) { foreach (string part in SplitList(actions)) { string[] pieces = part.Split(':'); if (pieces[0] == "flag") { game.SetFlag(pieces[1]); } else if (pieces[0] == "start") { game.Quest(pieces[1]).Start(game); } else if (pieces[0] == "complete") { game.Quest(pieces[1]).Complete(game); } else if (pieces[0] == "reward") { game.Quest(pieces[1]).State = "rewarded"; game.Say(T("quest.rewardedSay", ("title", game.Quest(pieces[1]).Title()))); } else if (pieces[0] == "give") { game.GiveItem(pieces[1], ToInt(pieces[2])); } else if (pieces[0] == "take") { game.Hero.Inventory.Remove(pieces[1], ToInt(pieces[2])); game.Say(T("item.given", ("item", ItemLabel(pieces[1], ToInt(pieces[2]))))); } else if (pieces[0] == "gold") { game.Hero.Gold += ToInt(pieces[1]); game.Say(T("item.gold", ("amount", pieces[1]))); } else if (pieces[0] == "xp") { game.GainXp(ToInt(pieces[1])); } else { throw new Exception("unknown action: " + part); } } }
public static List<DialogOption> VisibleOptions(Game game, DialogNode node) { List<DialogOption> result = new List<DialogOption>(); foreach (DialogOption option in node.Options) { if (option.Condition == "-" || CheckCondition(game, option.Condition)) result.Add(option); } return result; }}DialogScreen (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.int[] xpTable = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720];
int XpForLevel(int level){ if (level < xpTable.Length) return xpTable[level]; // Nach dem Ende der Tabelle geht die Kurve mit 150 pro Stufe weiter. return xpTable[xpTable.Length - 1] + (level - xpTable.Length + 1) * 150;}
// Wie viele Stufen warten: Reicht die Erfahrung für die nächsten Schwellen?int PendingLevels(int xp, int level){ int levels = 0; while (xp >= XpForLevel(level + levels + 1)) levels++; return levels;}
Console.WriteLine(PendingLevels(130, 1));Console.WriteLine(PendingLevels(104, 3));Console.WriteLine(PendingLevels(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:
var bus = new EventBus();var quest = new MinersQuest();quest.Subscribe(bus);
Fire(bus, new GameEvent("EnemyKilled", "rat"));Fire(bus, new GameEvent("TileEntered", Depth: 2));Console.WriteLine("> der Älteste gibt die Quest");quest.Start();Fire(bus, new GameEvent("TileEntered", Depth: 1));Fire(bus, new GameEvent("TileEntered", Depth: 2));Fire(bus, new GameEvent("DialogChoice", "miner:warden"));Fire(bus, new GameEvent("TileEntered", Depth: 2));Fire(bus, new 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:
var bus = new EventBus();var quest = new WolfQuest();quest.Subscribe(bus);
Fire(bus, new GameEvent("EnemyKilled", "wolf"));Console.WriteLine("> start");quest.Start();Fire(bus, new GameEvent("EnemyKilled", "rat"));Fire(bus, new GameEvent("EnemyKilled", "wolf"));Fire(bus, new GameEvent("EnemyKilled", "wolf"));Fire(bus, new GameEvent("EnemyKilled", "wolf"));Fire(bus, new 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 · Dictionary und HashSet · Lambdas und Delegates · record, switch und Muster
- Aufgaben zum Thema: Die Chroniken von Arkanum