Zum Inhalt
wannacode

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));
}
Ausgabe
Ratte +5: EP 5
Goblin +9: EP 14
Schamane +12: EP 26
Stufe 2: Leben 48 -> 57
Wolf +14: EP 40
Kultist +20: EP 60
Stufe 3: Leben 57 -> 66
Golem +32: EP 92
Wächter +150: EP 242
Stufe 4: Leben 66 -> 75
Stufe 5: Leben 75 -> 84
Stufe 10: EP 720
Stufe 11: EP 870
Stufe 12: EP 1020

Der 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;
}
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)));
}
Ausgabe
Anfang -> rage, war_cry
rage -> whirlwind, war_cry
war_cry -> whirlwind, iron_skin
whirlwind -> bloodlust, iron_skin
iron_skin -> bloodlust, second_wind
bloodlust -> second_wind
second_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"));
Ausgabe
> EnemyKilled rat
Eintrag: EnemyKilled rat
Kills: 1
> TileEntered 2
Eintrag: TileEntered 2
> EnemyKilled wolf
Eintrag: EnemyKilled wolf
Kills: 2
> ItemPicked ore

Jede 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"));
Ausgabe
> EnemyKilled rat
> TileEntered 2
> der Älteste gibt die Quest
Neue Quest: Die vermissten Bergleute.
> TileEntered 1
> TileEntered 2
Die vermissten Bergleute: Auf der zweiten Ebene lebt jemand. Finde ihn.
> DialogChoice miner:warden
Die vermissten Bergleute: Besiege den Wächter der Tiefe auf der dritten Ebene.
> TileEntered 2
> EnemyKilled warden
Quest 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
  1. 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“.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.“.
  7. 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“.
  8. 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.
  9. 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);
Ausgabe
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.
> 1
Neue 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.
> 1
Du ü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.
> 1
Du 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:

  • raiseLevel im 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 gainXp vergeben. enemyKilled (außer Gold und Leiche) erhöht hero.xp und veröffentlicht ein Ereignis, und queueLevelUps legt einen LevelUpScreen auf den Stapel (die Lektion über die Bildschirme): Der Spieler wählt einen Wert oder eine Fähigkeit, und erst dann ändern raiseLevel und applyLevelChoice den 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.start und SilverQuest.start prü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. Unsere MinersQuest tut das nicht, weil der Wächter nicht vor dem Gespräch mit dem Ältesten stirbt.
  • Etappen mit mehr als einer Quelle. BloodTrailQuest hört TileEntered auf 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 setzt victory in enemyKilled, 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));
Antwort

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"));
Ausgabe
> EnemyKilled rat
> TileEntered 2
> der Älteste gibt die Quest
Neue Quest: Die vermissten Bergleute.
> TileEntered 1
> TileEntered 2
Die vermissten Bergleute: Auf der zweiten Ebene lebt jemand. Finde ihn.
> DialogChoice miner:warden
Die vermissten Bergleute: Besiege den Wächter der Tiefe auf der dritten Ebene.
> TileEntered 2
Die vermissten Bergleute: Auf der zweiten Ebene lebt jemand. Finde ihn.
> EnemyKilled warden
Quest 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.

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:

Ausgabe
> EnemyKilled wolf
> start
Neue Quest: Drei Wölfe.
> EnemyKilled rat
> EnemyKilled wolf
Drei Wölfe: Töte drei Wölfe (1/3)
> EnemyKilled wolf
Drei Wölfe: Töte drei Wölfe (2/3)
> EnemyKilled wolf
Quest abgeschlossen: Drei Wölfe!
> EnemyKilled wolf

Weitere Aufgaben

  • Belohnung. Füge Quest eine Methode reward() hinzu, die von done zu rewarded geht und die Belohnung schreibt. Mache dann aus dem Dialog des Jägers eine Antwort mit der Bedingung quest:wolves:done und der Aktion reward:wolves.
  • Zwei Quests gleichzeitig. Abonniere MinersQuest und WolfQuest am selben Bus und schreibe ein Szenario, in dem ein EnemyKilled beide bewegt. Warum reicht ein publish?
  • Abmelden. Füge dem Bus unsubscribe(type, handler) hinzu. Was musst du in publish tun, 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

1. Der Held ist auf Stufe 3 mit 92 EP. Er besiegt den Wächter (+150), und die EP werden 242. Die Schwelle für Stufe 4 ist 105, für Stufe 5 ist sie 170, für Stufe 6 ist sie 250. Wie viele Stufen warten, und wie bekommt der Spieler sie?
2. Ein Barbar hat „Raserei“ (rage, Zweig 1, Schritt 1) und „Kriegsschrei“ (war_cry, Zweig 2, Schritt 1) gelernt. Welche Fähigkeiten kann die nächste Stufe anbieten?
3. Warum geht publish im Bus eine Kopie der Liste der Listener durch (slice, list(...), new ArrayList<>(...)) und nicht die Liste selbst?
4. Die Quest ist in Etappe 3. Es kommt ein TileEntered mit depth 2 (der Held steigt wieder auf die zweite Ebene hinab). Was tut der Listener, der die Etappe auf 2 hebt, und warum?
5. Eine Antwort im Dialog hat die Bedingung quest:silver:active,item:ore:3. Der Held hat die Quest angenommen, aber nur 2 Erz. Was sieht der Spieler?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·