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:

function onEnemyKilled(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:

function onEnemyKilled(enemy) {
hero.xp += enemy.xp;
bus.publish(makeEvent("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 {
constructor() {
this.level = 1;
this.xp = 0;
this.maxHp = 48;
}
}
// XP_TABLE[n] ist die gesamte Erfahrung, mit der Stufe n erreicht wird.
const XP_TABLE = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720];
function xpForLevel(level) {
if (level < XP_TABLE.length) return XP_TABLE[level];
// Nach dem Ende der Tabelle geht die Kurve mit 150 pro Stufe weiter.
return XP_TABLE[XP_TABLE.length - 1] + (level - XP_TABLE.length + 1) * 150;
}
// Wie viele Stufen warten: Reicht die Erfahrung für die nächsten Schwellen?
function pendingLevels(xp, level) {
let levels = 0;
while (xp >= xpForLevel(level + levels + 1)) levels++;
return levels;
}
function raiseLevel(hero) {
hero.level += 1;
hero.maxHp += 9;
}
const KILLS = [{ name: "Ratte", xp: 5 }, { name: "Goblin", xp: 9 }, { name: "Schamane", xp: 12 }, { name: "Wolf", xp: 14 }, { name: "Kultist", xp: 20 }, { name: "Golem", xp: 32 }, { name: "Wächter", xp: 150 }];
const hero = new Hero();
for (const kill of KILLS) {
hero.xp += kill.xp;
console.log(kill.name + " +" + kill.xp + ": EP " + hero.xp);
const raise = pendingLevels(hero.xp, hero.level);
for (let i = 0; i < raise; i++) {
const before = hero.maxHp;
raiseLevel(hero);
console.log(" Stufe " + hero.level + ": Leben " + before + " -> " + hero.maxHp);
}
}
for (let level = 10; level <= 12; level++) {
console.log("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.
const XP_TABLE = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720];

function xpForLevel(level) {
  if (level < XP_TABLE.length) return XP_TABLE[level];
  // Nach dem Ende der Tabelle geht die Kurve mit 150 pro Stufe weiter.
  return XP_TABLE[XP_TABLE.length - 1] + (level - XP_TABLE.length + 1) * 150;
}

// Wie viele Stufen warten: Reicht die Erfahrung für die nächsten Schwellen?
function pendingLevels(xp, level) {
  let 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.

const SKILLS = [
{ id: "rage", branch: 1, tier: 1 },
{ id: "whirlwind", branch: 1, tier: 2 },
{ id: "bloodlust", branch: 1, tier: 3 },
{ id: "war_cry", branch: 2, tier: 1 },
{ id: "iron_skin", branch: 2, tier: 2 },
{ id: "second_wind", branch: 2, tier: 3 },
];
const ORDER = ["rage", "war_cry", "whirlwind", "iron_skin", "bloodlust", "second_wind"];
function learnable(skills, known) {
const result = [];
for (const skill of skills) {
if (known.includes(skill.id)) continue;
let ready = skill.tier === 1;
for (const other of skills) {
if (known.includes(other.id) && other.branch === skill.branch && other.tier === skill.tier - 1) ready = true;
}
if (ready) result.push(skill.id);
}
return result;
}
function show(list) {
return list.length === 0 ? "-" : list.join(", ");
}

Das Programm lernt die Fähigkeiten des Barbaren in einer vorgegebenen Reihenfolge und schreibt nach jeder, was verfügbar ist. Eine leere Liste ist -.

const known = [];
console.log("Anfang -> " + show(learnable(SKILLS, known)));
for (const id of ORDER) {
known.push(id);
console.log(id + " -> " + show(learnable(SKILLS, 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).

function makeEvent(type, id = "", depth = 0) {
return { type, id, depth };
}
function label(event) {
return event.id === "" ? String(event.depth) : event.id;
}
class EventBus {
constructor() {
this.handlers = new Map();
}
subscribe(type, handler) {
if (!this.handlers.has(type)) this.handlers.set(type, []);
this.handlers.get(type).push(handler);
}
publish(event) {
if (!this.handlers.has(event.type)) return;
// Eine Kopie der Liste: Ein Listener darf sich während des Veröffentlichens an- oder abmelden.
for (const handler of this.handlers.get(event.type).slice()) handler(event);
}
}

Das erste Programm legt zwei Listener auf EnemyKilled (ein Protokoll und den Zähler der Siege) und einen auf TileEntered. Auf ItemPicked hört niemand, und der Bus tut einfach nichts.

const bus = new EventBus();
const stats = new Stats();
bus.subscribe("EnemyKilled", log);
bus.subscribe("TileEntered", log);
bus.subscribe("EnemyKilled", (event) => {
stats.kills++;
console.log("Kills: " + stats.kills);
});
fire(bus, makeEvent("EnemyKilled", "rat"));
fire(bus, makeEvent("TileEntered", "", 2));
fire(bus, makeEvent("EnemyKilled", "wolf"));
fire(bus, makeEvent("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 {
constructor(id, title, goals, doneGoal) {
this.id = id;
this.title = title;
this.goals = goals;
this.doneGoal = doneGoal;
this.state = "inactive";
this.stage = 0;
}
goal() {
if (this.state === "done") return this.doneGoal;
return this.goals[this.stage - 1];
}
start() {
this.state = "active";
this.stage = 1;
console.log("Neue Quest: " + this.title + ".");
}
advance(newStage) {
this.stage = newStage;
console.log(this.title + ": " + this.goal());
}
complete() {
this.state = "done";
console.log("Quest abgeschlossen: " + this.title + "!");
}
}
class MinersQuest extends Quest {
constructor() {
super("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.");
}
subscribe(bus) {
bus.subscribe("TileEntered", (event) => {
if (this.state === "active" && this.stage === 1 && event.depth === 2) this.advance(2);
});
bus.subscribe("DialogChoice", (event) => {
if (this.state === "active" && this.stage < 3 && event.id === "miner:warden") this.advance(3);
});
bus.subscribe("EnemyKilled", (event) => {
if (this.state === "active" && event.id === "warden") this.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.

const bus = new EventBus();
const quest = new MinersQuest();
quest.subscribe(bus);
fire(bus, makeEvent("EnemyKilled", "rat"));
fire(bus, makeEvent("TileEntered", "", 2));
console.log("> der Älteste gibt die Quest");
quest.start();
fire(bus, makeEvent("TileEntered", "", 1));
fire(bus, makeEvent("TileEntered", "", 2));
fire(bus, makeEvent("DialogChoice", "miner:warden"));
fire(bus, makeEvent("TileEntered", "", 2));
fire(bus, makeEvent("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 {
constructor() {
this.handlers = new Map();
}
subscribe(type, handler) {
if (!this.handlers.has(type)) this.handlers.set(type, []);
this.handlers.get(type).push(handler);
}
publish(event) {
if (!this.handlers.has(event.type)) return;
// Eine Kopie der Liste: Ein Listener darf sich während des Veröffentlichens an- oder abmelden.
for (const handler of this.handlers.get(event.type).slice()) handler(event);
}
}
class Quest {
constructor(id, title, goals, doneGoal) {
this.id = id;
this.title = title;
this.goals = goals;
this.doneGoal = doneGoal;
this.state = "inactive";
this.stage = 0;
}
goal() {
if (this.state === "done") return this.doneGoal;
return this.goals[this.stage - 1];
}
start() {
this.state = "active";
this.stage = 1;
console.log("Neue Quest: " + this.title + ".");
}
advance(newStage) {
this.stage = newStage;
console.log(this.title + ": " + this.goal());
}
complete() {
this.state = "done";
console.log("Quest abgeschlossen: " + this.title + "!");
}
}
class MinersQuest extends Quest {
constructor() {
super("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.");
}
subscribe(bus) {
bus.subscribe("TileEntered", (event) => {
if (this.state === "active" && this.stage === 1 && event.depth === 2) this.advance(2);
});
bus.subscribe("DialogChoice", (event) => {
if (this.state === "active" && this.stage < 3 && event.id === "miner:warden") this.advance(3);
});
bus.subscribe("EnemyKilled", (event) => {
if (this.state === "active" && event.id === "warden") this.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.

class Game {
static NAMES = { ore: "Erz", silver_sword: "Silberschwert", potion_heal: "Heiltrank" };
constructor(...quests) {
this.flags = new Set();
this.items = new Map();
this.quests = new Map(quests.map((quest) => [quest.id, quest]));
}
quest(id) {
return this.quests.get(id);
}
count(id) {
return this.items.get(id) ?? 0;
}
label(id, n) {
return Game.NAMES[id] + " x" + n;
}
give(id, n) {
this.items.set(id, this.count(id) + n);
console.log("Du erhältst: " + this.label(id, n) + ".");
}
take(id, n) {
this.items.set(id, this.count(id) - n);
console.log("Du übergibst: " + this.label(id, n) + ".");
}
}
function splitList(text) {
return text === "-" ? [] : text.split(",");
}
function checkCondition(game, condition) {
for (const part of splitList(condition)) {
const negate = part.startsWith("!");
const p = (negate ? part.substring(1) : part).split(":");
let ok;
if (p[0] === "flag") ok = game.flags.has(p[1]);
else if (p[0] === "item") ok = game.count(p[1]) >= Number(p[2]);
else if (p[0] === "quest") ok = game.quest(p[1]).state === p[2];
else throw new Error("unknown condition: " + part);
if (negate) ok = !ok;
if (!ok) return false;
}
return true;
}
function runActions(game, actions) {
for (const part of splitList(actions)) {
const p = part.split(":");
if (p[0] === "flag") {
game.flags.add(p[1]);
} else if (p[0] === "start") {
game.quest(p[1]).start();
} else if (p[0] === "complete") {
game.quest(p[1]).complete();
} else if (p[0] === "reward") {
const quest = game.quest(p[1]);
quest.state = "rewarded";
console.log("Belohnung für " + quest.title + ".");
} else if (p[0] === "give") {
game.give(p[1], Number(p[2]));
} else if (p[0] === "take") {
game.take(p[1], Number(p[2]));
} else {
throw new Error("unknown action: " + part);
}
}
}
function visibleOptions(game, node) {
return node.options.filter((o) => o.condition === "-" || checkCondition(game, o.condition));
}
function show(speaker, game, node) {
console.log(speaker + ": " + node.text);
visibleOptions(game, node).forEach((option, i) => console.log(" " + (i + 1) + ") " + option.text));
}
function choose(game, node, number) {
const option = visibleOptions(game, node)[number - 1];
console.log("> " + 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).

const SMITH = {
start: {
text: "Meine Esse ist ohne Silber kalt. Bring mir drei Stück Erz, und ich schmiede dir ein Schwert.",
options: [
{ text: "Ich suche das Erz.", next: "deal", condition: "quest:silver:inactive", actions: "start:silver" },
{ text: "Hier ist das Erz.", next: "done", condition: "quest:silver:active,item:ore:3", actions: "take:ore:3,complete:silver,reward:silver,give:silver_sword:1" },
{ text: "Auf Wiedersehen.", next: "end", condition: "-", actions: "-" },
],
},
deal: {
text: "Das Erz liegt auf der zweiten Ebene. Die Erzgolems tragen es in sich.",
options: [
{ text: "Gut.", next: "end", condition: "-", actions: "-" },
],
},
done: {
text: "Ein Silberschwert. Seine Schneide wird nie stumpf.",
options: [
{ text: "Danke dir.", next: "end", condition: "-", actions: "-" },
],
},
};
const MINER = {
start: {
text: "Ein lebender Mensch! Die anderen sind nach oben gerannt... Ich saß hier unten fest.",
options: [
{ text: "Was ist da unten?", next: "warden", condition: "quest:miners:active,!flag:miner_told", actions: "flag:miner_told,give:potion_heal:1" },
{ text: "Bleib hier, ich komme zurück.", next: "end", condition: "-", actions: "-" },
],
},
warden: {
text: "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.",
options: [
{ text: "Ich halte ihn auf.", next: "end", condition: "-", actions: "-" },
],
},
};
const 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."));
const start = SMITH.start;
show("Vela", game, start);
let 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.quest("miners").start();
const 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.
function xpForLevel(level) {
if (level < XP_TABLE.length) return XP_TABLE[level];
return XP_TABLE[XP_TABLE.length - 1] + (level - XP_TABLE.length + 1) * 150;
}
function pendingLevels(hero) {
let levels = 0;
while (hero.xp >= xpForLevel(hero.level + levels + 1)) levels++;
return levels;
}
// Die neue Stufe selbst: mehr Leben und Mana, volle Gesundheit.
function raiseLevel(hero) {
const heroClass = hero.heroClass();
hero.level += 1;
hero.base.set('maxHp', hero.base.get('maxHp') + heroClass.hpPerLevel);
hero.base.set('maxMana', hero.base.get('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.
function learnableSkills(hero) {
const result = [];
for (const skill of SKILLS.values()) {
if (skill.classId !== hero.classId || hero.skills.indexOf(skill.id) >= 0) continue;
let ready = skill.tier === 1;
for (const known of hero.skills) {
const other = SKILLS.get(known);
if (other.branch === skill.branch && other.tier === skill.tier - 1) ready = true;
}
if (ready) result.push(skill.id);
}
return result;
}
// Die Auswahl bei einer neuen Stufe: ein Wert oder eine neue Fähigkeit.
function levelUpChoices(hero) {
const choices = [
{ kind: 'stat', stat: 'maxHp', amount: 8 },
{ kind: 'stat', stat: 'strength', amount: 1 },
{ kind: 'stat', stat: 'agility', amount: 1 },
{ kind: 'stat', stat: 'defense', amount: 1 },
];
for (const skillId of learnableSkills(hero)) choices.push({ kind: 'skill', stat: skillId, amount: 0 });
return choices;
}
function applyLevelChoice(hero, choice) {
if (choice.kind === 'skill') {
hero.skills.push(choice.stat);
} else {
hero.base.set(choice.stat, hero.base.get(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 EventBus {
constructor() {
this.handlers = new Map();
}
subscribe(type, handler) {
if (!this.handlers.has(type)) this.handlers.set(type, []);
this.handlers.get(type).push(handler);
}
publish(event) {
if (!this.handlers.has(event.type)) return;
for (const handler of this.handlers.get(event.type).slice()) handler(event);
}
}

Die Ereignisse im Spiel sind freie Objekte ({ type: 'EnemyKilled', kindId: … }), mit anderen Feldern für jeden Typ: kindId, itemId, dialogId mit nodeId, depth, decor, marker. In Java, C# und Python ist jede Form ein eigener Datensatz. Wir haben alles an type, id und depth gebunden, damit ein Typ in alle vier Sprachen passt.

Die Idee

Im Spiel: Quests und Dialoge

Hier sind die echten Quests. Die Basis Quest nutzt Schlüssel zu den Texten (t('quest.' + id + '.title')), damit das Spiel übersetzt werden kann; MissingMinersQuest ist unsere, und die anderen beiden zeigen, was ein Automat noch kann:

// Eine Quest ist eine kleine Zustandsmaschine: inactive → active (Abschnitte) → done → rewarded. Sie abonniert
// selbst die Ereignisse, die sie weiterbringen.
class Quest {
constructor(id) {
this.id = id;
this.state = 'inactive';
this.stage = 0;
}
title() {
return t('quest.' + this.id + '.title');
}
goal() {
if (this.state === 'done') return t('quest.' + this.id + '.done');
if (this.state === 'rewarded') return t('quest.rewarded');
return t('quest.' + this.id + '.stage' + this.stage);
}
start(game) {
this.state = 'active';
this.stage = 1;
game.say(t('quest.started', { title: this.title() }));
}
advance(game, stage) {
this.stage = stage;
game.say(t('quest.updated', { title: this.title(), goal: this.goal() }));
}
complete(game) {
this.state = 'done';
game.say(t('quest.completed', { title: this.title() }));
}
subscribe(bus, game) {}
}
class MissingMinersQuest extends Quest {
constructor() {
super('miners');
}
subscribe(bus, game) {
bus.subscribe('TileEntered', (event) => {
if (this.state === 'active' && this.stage === 1 && event.depth === 2) this.advance(game, 2);
});
bus.subscribe('DialogChoice', (event) => {
if (this.state === 'active' && this.stage < 3 && event.dialogId === 'miner' && event.nodeId === 'warden') this.advance(game, 3);
});
bus.subscribe('EnemyKilled', (event) => {
if (this.state === 'active' && event.kindId === 'warden') this.complete(game);
});
}
}
class BloodTrailQuest extends Quest {
constructor() {
super('blood');
}
start(game) {
super.start(game);
if (game.hasFlag('leader_dead')) this.complete(game);
}
subscribe(bus, game) {
bus.subscribe('TileEntered', (event) => {
if (this.state !== 'active' || event.depth !== 1) return;
if (this.stage === 1 && event.decor === ',') this.advance(game, 2);
if (this.stage < 3 && event.marker === 'lair') this.advance(game, 3);
});
bus.subscribe('EnemyKilled', (event) => {
if (event.kindId !== 'wolf_leader') return;
game.setFlag('leader_dead');
if (this.state === 'active') this.complete(game);
});
}
}
class SilverQuest extends Quest {
constructor() {
super('silver');
}
start(game) {
super.start(game);
this.check(game);
}
check(game) {
if (this.state === 'active' && this.stage === 1 && game.hero.inventory.count('ore') >= 3) this.advance(game, 2);
}
subscribe(bus, game) {
bus.subscribe('ItemPicked', (event) => {
if (event.itemId === 'ore') this.check(game);
});
}
}
function createQuests() {
return [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.
function checkCondition(game, condition) {
for (const part of splitList(condition)) {
const negate = part.charAt(0) === '!';
const pieces = (negate ? part.substring(1) : part).split(':');
let 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 Error('unknown condition: ' + part);
if (negate) ok = !ok;
if (!ok) return false;
}
return true;
}
function runActions(game, actions) {
for (const part of splitList(actions)) {
const 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 Error('unknown action: ' + part);
}
}
}
function visibleOptions(game, node) {
const result = [];
for (const option of node.options) {
if (option.condition === '-' || checkCondition(game, option.condition)) result.push(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.
const XP_TABLE = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720];
function xpForLevel(level) {
if (level < XP_TABLE.length) return XP_TABLE[level];
// Nach dem Ende der Tabelle geht die Kurve mit 150 pro Stufe weiter.
return XP_TABLE[XP_TABLE.length - 1] + (level - XP_TABLE.length + 1) * 150;
}
// Wie viele Stufen warten: Reicht die Erfahrung für die nächsten Schwellen?
function pendingLevels(xp, level) {
let levels = 0;
while (xp >= xpForLevel(level + levels + 1)) levels++;
return levels;
}
console.log(pendingLevels(130, 1));
console.log(pendingLevels(104, 3));
console.log(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:

const bus = new EventBus();
const quest = new MinersQuest();
quest.subscribe(bus);
fire(bus, makeEvent("EnemyKilled", "rat"));
fire(bus, makeEvent("TileEntered", "", 2));
console.log("> der Älteste gibt die Quest");
quest.start();
fire(bus, makeEvent("TileEntered", "", 1));
fire(bus, makeEvent("TileEntered", "", 2));
fire(bus, makeEvent("DialogChoice", "miner:warden"));
fire(bus, makeEvent("TileEntered", "", 2));
fire(bus, makeEvent("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:

const bus = new EventBus();
const quest = new WolfQuest();
quest.subscribe(bus);
fire(bus, makeEvent("EnemyKilled", "wolf"));
console.log("> start");
quest.start();
fire(bus, makeEvent("EnemyKilled", "rat"));
fire(bus, makeEvent("EnemyKilled", "wolf"));
fire(bus, makeEvent("EnemyKilled", "wolf"));
fire(bus, makeEvent("EnemyKilled", "wolf"));
fire(bus, makeEvent("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 ·