Zum Inhalt
wannacode

Bildschirme, die eine Antwort zurückgeben

Kurz gesagt: Ein Bildschirm, der eine Antwort zurückgibt, schließt sich mit pop(result) und reicht das Ergebnis an den Bildschirm darunter weiter (onResult), während Änderungen am Stapel während des Zuges notiert und an seinem Ende mit apply ausgeführt werden.

Problem

Der Bildschirm fragt. Wer hört die Antwort?

In der letzten Lektion haben sich Bildschirme nur geöffnet und geschlossen. Jetzt muss ein Bildschirm etwas fragen und eine Antwort bekommen. Der Kampf hat zwei Feinde, der Spieler drückt „Angriff“, und der Kampf muss wissen, welcher. Das Menü muss „Bist du sicher?“ fragen, bevor es das Spiel beendet.

Der erste Versuch ist eine gemeinsame Variable: Der Ziel-Bildschirm schreibt die Wahl in selectedTarget, und der Kampf liest sie. Der Spieler wählt den Goblin, öffnet dann die Wahl noch einmal und überlegt es sich anders (x):

static string[] enemies = { "Wolf", "Goblin" };
static int selectedTarget = -1;
static void TargetScreen(string command)
{
if (command != "x") selectedTarget = int.Parse(command) - 1;
}
static void FightTurn()
{
if (selectedTarget >= 0) Console.WriteLine("Du triffst " + enemies[selectedTarget] + ".");
else Console.WriteLine("Niemand wird getroffen.");
}
// Der Spieler wählt 2 und überlegt es sich dann anders (x).
TargetScreen("2");
FightTurn();
TargetScreen("x");
FightTurn();
Ausgabe
Du triffst Goblin.
Du triffst Goblin.

Der zweite Treffer landet beim Goblin, obwohl der Spieler abgebrochen hat. Die Wahl vom letzten Zug steht noch in der Variable. Wer soll sie auf -1 zurücksetzen? Der Kampf, nachdem er sie gelesen hat? Und was, wenn das Inventar dieselbe Variable auch benutzt? Eine gemeinsame Variable hat keinen Besitzer, deshalb bleiben alte Antworten in ihr.

Der zweite Versuch ist, das Ziel den Kampf selbst aufrufen zu lassen:

class TargetScreen : Screen
{
FightScreen fight;
public override void Handle(string command)
{
// Das Ziel kennt den Kampf und ruft ihn selbst auf.
fight.Attack(int.Parse(command) - 1);
stack.Pop();
}
}

Das funktioniert, aber jetzt kennt das Ziel den Kampf. Im Spiel fragt „Wen?“ ein Angriff, ein Zauber und ein Trank, der einen Verbündeten heilt. Jeder von ihnen will einen eigenen Ziel-Bildschirm, oder einen Bildschirm mit einem if für alle, die ihn benutzen. Du brauchst einen Weg, damit die Antwort zu dem zurückgeht, der gefragt hat, ohne dass der Bildschirm, der antwortet, weiß, wer das ist.

Die Idee

pop(result) und onResult

Der Bildschirm, der fragt, öffnet mit push den, der antwortet. Der, der antwortet, entfernt sich mit pop selbst und reicht die Antwort weiter: pop(result). Der Stapel weiß, wer darunter liegt: genau der Bildschirm, der gefragt hat. Er gibt die Antwort weiter, indem er dessen OnResult aufruft.

Der Bildschirm bekommt eine weitere Methode. Standardmäßig tut sie nichts: Die meisten Bildschirme fragen nie etwas.

class Screen
{
public string Name;
public Screen(string name)
{
Name = name;
}
public virtual void Draw() { }
public virtual void Handle(string command) { }
public virtual void Update() { }
// Die Antwort des Bildschirms, der über diesem lag.
public virtual void OnResult(object? result) { }
}

Und pop aus der letzten Lektion wird zu pop(result):

public void Pop(object? result)
{
Screens.RemoveAt(Screens.Count - 1);
Screen? below = Top();
if (below != null) below.OnResult(result);
}

So kennen sich die beiden Seiten nicht. Das Ziel kennt die Frage und die Form der Antwort: die Nummer eines Feindes oder null für „abbrechen“. Der Kampf weiß, was er mit der Nummer tun soll. Es ist wie der Aufruf einer Methode, die einen Wert zurückgibt, nur dass zwischen Frage und Antwort Züge vergehen: Der Spieler denkt nach, drückt eine Taste und überlegt es sich vielleicht anders.

Sieh es dir an

Ein Ziel wählen

Das Ziel zeichnet eine Liste der lebenden Feinde und wartet auf eine Ziffer. x ist „zurück“ und gibt null zurück. Die Nummer wird ab 0 gezählt, weil sie ein Index in die Liste ist:

class TargetScreen : Screen
{
ScreenStack stack;
List<Enemy> enemies;
public TargetScreen(ScreenStack stack, List<Enemy> enemies) : base("Ziel")
{
this.stack = stack;
this.enemies = enemies;
}
public override void Draw()
{
string line = "Wen triffst du?";
for (int i = 0; i < enemies.Count; i++)
{
line += " " + (i + 1) + ") " + enemies[i].Name;
}
Console.WriteLine(line + " x) zurück");
}
public override void Handle(string command)
{
if (command == "x")
{
stack.Pop(null);
return;
}
int choice = int.Parse(command);
if (choice >= 1 && choice <= enemies.Count) stack.Pop(choice - 1);
}
}

Der Kampf öffnet das Ziel bei 1 und liest die Antwort in OnResult. Wenn keine Feinde mehr da sind, gibt der Kampf auch dem Bildschirm unter sich, der Karte, eine Antwort zurück: „Sieg“.

class FightScreen : Screen
{
ScreenStack stack;
List<Enemy> enemies = new List<Enemy>();
public FightScreen(ScreenStack stack) : base("Kampf")
{
this.stack = stack;
enemies.Add(new Enemy("Wolf", 4));
enemies.Add(new Enemy("Goblin", 7));
}
public override void Draw()
{
string line = "Kampf:";
foreach (Enemy enemy in enemies) line += " " + enemy.Name + " " + enemy.Hp;
Console.WriteLine(line + " | 1) Angriff");
}
public override void Handle(string command)
{
if (command == "1") stack.Push(new TargetScreen(stack, enemies));
}
public override void OnResult(object? result)
{
if (result == null)
{
Console.WriteLine("Du überlegst es dir anders. Niemand wird getroffen.");
return;
}
Enemy enemy = enemies[(int)result];
enemy.Hp -= 4;
if (enemy.Hp > 0)
{
Console.WriteLine("Du triffst " + enemy.Name + " (4).");
return;
}
Console.WriteLine(enemy.Name + " fällt.");
enemies.Remove(enemy);
if (enemies.Count == 0) stack.Pop("Sieg");
}
}

Das Programm liest seine Befehle aus der Eingabe. Jeder Zug zeichnet nur den obersten Bildschirm, damit die Ausgabe kurz bleibt:

ScreenStack stack = new ScreenStack();
stack.Push(new MapScreen(stack));
while (stack.Top() != null)
{
stack.Top()!.Draw();
string command = Console.ReadLine()!.Trim();
Console.WriteLine("> " + command);
stack.Top()!.Handle(command);
}
Console.WriteLine("Auf Wiedersehen.");
Ausgabe
Karte: Tränke 2 | f) Kampf d) Trank wegwerfen q) beenden
> f
Kampf: Wolf 4 Goblin 7 | 1) Angriff
> 1
Wen triffst du? 1) Wolf 2) Goblin x) zurück
> 2
Du triffst Goblin (4).
Kampf: Wolf 4 Goblin 3 | 1) Angriff
> 1
Wen triffst du? 1) Wolf 2) Goblin x) zurück
> x
Du überlegst es dir anders. Niemand wird getroffen.
Kampf: Wolf 4 Goblin 3 | 1) Angriff
> 1
Wen triffst du? 1) Wolf 2) Goblin x) zurück
> 2
Goblin fällt.
Kampf: Wolf 4 | 1) Angriff
> 1
Wen triffst du? 1) Wolf x) zurück
> 1
Wolf fällt.
Sieg! Zurück zur Karte.
Karte: Tränke 2 | f) Kampf d) Trank wegwerfen q) beenden
> d
Bist du sicher, dass du einen Trank wegwirfst? 1) ja 2) nein
> 2
Der Trank bleibt.
Karte: Tränke 2 | f) Kampf d) Trank wegwerfen q) beenden
> q
Bist du sicher, dass du das Spiel verlässt? 1) ja 2) nein
> 1
Auf Wiedersehen.

Die Eingabe ist:

Eingabe
f
1
2
1
x
1
2
1
1
d
2
q
1

Das Abbrechen (x) trifft niemanden: Die Antwort ist leer, und der Kampf weiß es. Die Wahl vom vorigen Zug bleibt nirgends liegen.

Genauer, für Neugierige

Das ganze Programm, mit der Karte und der Frage „Bist du sicher?“ von der nächsten Karte:

class Screen
{
public string Name;
public Screen(string name)
{
Name = name;
}
public virtual void Draw() { }
public virtual void Handle(string command) { }
public virtual void Update() { }
// Die Antwort des Bildschirms, der über diesem lag.
public virtual void OnResult(object? result) { }
}
class ScreenStack
{
public List<Screen> Screens = new List<Screen>();
public void Push(Screen screen)
{
Screens.Add(screen);
}
public void Pop(object? result)
{
Screens.RemoveAt(Screens.Count - 1);
Screen? below = Top();
if (below != null) below.OnResult(result);
}
public Screen? Top()
{
return Screens.Count == 0 ? null : Screens[Screens.Count - 1];
}
public string Names()
{
return "[" + string.Join(", ", Screens.Select(s => s.Name)) + "]";
}
}
class Enemy
{
public string Name;
public int Hp;
public Enemy(string name, int hp)
{
Name = name;
Hp = hp;
}
}
class TargetScreen : Screen
{
ScreenStack stack;
List<Enemy> enemies;
public TargetScreen(ScreenStack stack, List<Enemy> enemies) : base("Ziel")
{
this.stack = stack;
this.enemies = enemies;
}
public override void Draw()
{
string line = "Wen triffst du?";
for (int i = 0; i < enemies.Count; i++)
{
line += " " + (i + 1) + ") " + enemies[i].Name;
}
Console.WriteLine(line + " x) zurück");
}
public override void Handle(string command)
{
if (command == "x")
{
stack.Pop(null);
return;
}
int choice = int.Parse(command);
if (choice >= 1 && choice <= enemies.Count) stack.Pop(choice - 1);
}
}
class FightScreen : Screen
{
ScreenStack stack;
List<Enemy> enemies = new List<Enemy>();
public FightScreen(ScreenStack stack) : base("Kampf")
{
this.stack = stack;
enemies.Add(new Enemy("Wolf", 4));
enemies.Add(new Enemy("Goblin", 7));
}
public override void Draw()
{
string line = "Kampf:";
foreach (Enemy enemy in enemies) line += " " + enemy.Name + " " + enemy.Hp;
Console.WriteLine(line + " | 1) Angriff");
}
public override void Handle(string command)
{
if (command == "1") stack.Push(new TargetScreen(stack, enemies));
}
public override void OnResult(object? result)
{
if (result == null)
{
Console.WriteLine("Du überlegst es dir anders. Niemand wird getroffen.");
return;
}
Enemy enemy = enemies[(int)result];
enemy.Hp -= 4;
if (enemy.Hp > 0)
{
Console.WriteLine("Du triffst " + enemy.Name + " (4).");
return;
}
Console.WriteLine(enemy.Name + " fällt.");
enemies.Remove(enemy);
if (enemies.Count == 0) stack.Pop("Sieg");
}
}
class ConfirmScreen : Screen
{
ScreenStack stack;
string question;
public ConfirmScreen(ScreenStack stack, string question) : base(question)
{
this.stack = stack;
this.question = question;
}
public override void Draw()
{
Console.WriteLine(question + " 1) ja 2) nein");
}
public override void Handle(string command)
{
if (command == "1") stack.Pop(true);
else if (command == "2") stack.Pop(false);
}
}
class MapScreen : Screen
{
ScreenStack stack;
int potions = 2;
string pending = "";
public MapScreen(ScreenStack stack) : base("Karte")
{
this.stack = stack;
}
public override void Draw()
{
Console.WriteLine("Karte: Tränke " + potions + " | f) Kampf d) Trank wegwerfen q) beenden");
}
public override void Handle(string command)
{
if (command == "f")
{
stack.Push(new FightScreen(stack));
}
else if (command == "d")
{
pending = "drop";
stack.Push(new ConfirmScreen(stack, "Bist du sicher, dass du einen Trank wegwirfst?"));
}
else if (command == "q")
{
pending = "quit";
stack.Push(new ConfirmScreen(stack, "Bist du sicher, dass du das Spiel verlässt?"));
}
}
public override void OnResult(object? result)
{
string asked = pending;
pending = "";
if (asked == "drop")
{
if (result is true)
{
potions--;
Console.WriteLine("Du wirfst einen Trank weg.");
}
else
{
Console.WriteLine("Der Trank bleibt.");
}
}
else if (asked == "quit")
{
if (result is true) stack.Pop(null);
}
else if (result is "Sieg")
{
Console.WriteLine("Sieg! Zurück zur Karte.");
}
}
}
class Program
{
static void Main()
{
ScreenStack stack = new ScreenStack();
stack.Push(new MapScreen(stack));
while (stack.Top() != null)
{
stack.Top()!.Draw();
string command = Console.ReadLine()!.Trim();
Console.WriteLine("> " + command);
stack.Top()!.Handle(command);
}
Console.WriteLine("Auf Wiedersehen.");
}
}

Die Idee

Eine Frage für alles: „Bist du sicher?“

Der Frage-Bildschirm weiß nicht, was er fragt. Er zeigt den Text, den er bekommen hat, und gibt true oder false zurück:

class ConfirmScreen : Screen
{
ScreenStack stack;
string question;
public ConfirmScreen(ScreenStack stack, string question) : base(question)
{
this.stack = stack;
this.question = question;
}
public override void Draw()
{
Console.WriteLine(question + " 1) ja 2) nein");
}
public override void Handle(string command)
{
if (command == "1") stack.Pop(true);
else if (command == "2") stack.Pop(false);
}
}

Die Karte benutzt ihn für zwei verschiedene Fragen: ob ein Trank weggeworfen und ob das Spiel beendet werden soll. Aber die Antwort ist nur „ja“ oder „nein“. Woher weiß die Karte, auf welche Frage sie antwortet? Sie merkt sich, was sie gefragt hat. Vor dem push schreibt sie pending, und wenn die Antwort kommt, liest sie es und leert es sofort:

class MapScreen : Screen
{
ScreenStack stack;
int potions = 2;
string pending = "";
public MapScreen(ScreenStack stack) : base("Karte")
{
this.stack = stack;
}
public override void Draw()
{
Console.WriteLine("Karte: Tränke " + potions + " | f) Kampf d) Trank wegwerfen q) beenden");
}
public override void Handle(string command)
{
if (command == "f")
{
stack.Push(new FightScreen(stack));
}
else if (command == "d")
{
pending = "drop";
stack.Push(new ConfirmScreen(stack, "Bist du sicher, dass du einen Trank wegwirfst?"));
}
else if (command == "q")
{
pending = "quit";
stack.Push(new ConfirmScreen(stack, "Bist du sicher, dass du das Spiel verlässt?"));
}
}
public override void OnResult(object? result)
{
string asked = pending;
pending = "";
if (asked == "drop")
{
if (result is true)
{
potions--;
Console.WriteLine("Du wirfst einen Trank weg.");
}
else
{
Console.WriteLine("Der Trank bleibt.");
}
}
else if (asked == "quit")
{
if (result is true) stack.Pop(null);
}
else if (result is "Sieg")
{
Console.WriteLine("Sieg! Zurück zur Karte.");
}
}
}

Das Leeren ist wichtig. Bliebe pending auf „drop“, würde die nächste Antwort vom Kampf („Sieg“) als Antwort auf die Trank-Frage gelesen. Das ist derselbe Fehler wie bei der gemeinsamen Variable vom Anfang, nur jetzt innerhalb eines Bildschirms, der seine eigene Frage besitzt. Das echte Spiel macht dasselbe: Der Kampf merkt sich, ob er nach einem Angriff, einer Fähigkeit oder einem Trank gefragt hat.

Problem

Der Stapel ändert sich, während der Zug noch läuft

Bisher hat die Schleife nur Handle für den obersten aufgerufen. Ein echter Zug hat auch Update: Der Bildschirm aktualisiert nach dem Befehl seinen Zustand. Hier ist derselbe Kampf, aber jeder Bildschirm schreibt in ein Protokoll, was er tut. Im update zählt das Ziel die Feinde in seiner Liste:

class TargetScreen : Screen
{
ScreenStack stack;
List<string> enemies;
public TargetScreen(ScreenStack stack, List<string> enemies) : base("Ziel")
{
this.stack = stack;
this.enemies = enemies;
}
public override void Handle(string command)
{
int index = int.Parse(command) - 1;
Console.WriteLine("Ziel bekommt " + command + " und liefert " + index);
stack.Pop(index);
}
public override void Update()
{
Console.WriteLine("Ziel aktualisiert sich, Feinde in der Liste: " + enemies.Count);
}
}

Die Schleife ist wie im echten Spiel: den obersten nehmen, ihm den Befehl geben, ihn aktualisieren.

Screen top = stack.Top()!;
top.Handle(command);
top.Update();
class Screen
{
public string Name;
public Screen(string name)
{
Name = name;
}
public virtual void Draw() { }
public virtual void Handle(string command) { }
public virtual void Update() { }
// Die Antwort des Bildschirms, der über diesem lag.
public virtual void OnResult(object? result) { }
}
class ScreenStack
{
public List<Screen> Screens = new List<Screen>();
public void Push(Screen screen)
{
Screens.Add(screen);
}
public void Pop(object? result)
{
Screens.RemoveAt(Screens.Count - 1);
Screen? below = Top();
if (below != null) below.OnResult(result);
}
public Screen? Top()
{
return Screens.Count == 0 ? null : Screens[Screens.Count - 1];
}
public string Names()
{
return "[" + string.Join(", ", Screens.Select(s => s.Name)) + "]";
}
}
class TargetScreen : Screen
{
ScreenStack stack;
List<string> enemies;
public TargetScreen(ScreenStack stack, List<string> enemies) : base("Ziel")
{
this.stack = stack;
this.enemies = enemies;
}
public override void Handle(string command)
{
int index = int.Parse(command) - 1;
Console.WriteLine("Ziel bekommt " + command + " und liefert " + index);
stack.Pop(index);
}
public override void Update()
{
Console.WriteLine("Ziel aktualisiert sich, Feinde in der Liste: " + enemies.Count);
}
}
class FightScreen : Screen
{
ScreenStack stack;
List<string> enemies = new List<string> { "Wolf", "Goblin" };
public FightScreen(ScreenStack stack) : base("Kampf")
{
this.stack = stack;
}
public override void Handle(string command)
{
Console.WriteLine("Kampf bekommt " + command + " und öffnet Ziel");
stack.Push(new TargetScreen(stack, enemies));
}
public override void OnResult(object? result)
{
string enemy = enemies[(int)result!];
enemies.RemoveAt((int)result!);
Console.WriteLine("Kampf erhält " + result + ": " + enemy + " fällt");
if (enemies.Count == 0)
{
Console.WriteLine("Kampf: keine Feinde mehr, liefert Sieg");
stack.Pop("Sieg");
}
}
}
class MapScreen : Screen
{
ScreenStack stack;
public MapScreen(ScreenStack stack) : base("Karte")
{
this.stack = stack;
}
public override void OnResult(object? result)
{
Console.WriteLine("Karte erhält " + result + ": öffnet Stufenaufstieg");
stack.Push(new Screen("Stufenaufstieg"));
}
}
class Program
{
static void Main()
{
ScreenStack stack = new ScreenStack();
stack.Push(new MapScreen(stack));
stack.Push(new FightScreen(stack));
foreach (string command in new[] { "1", "2", "1", "1" })
{
Console.WriteLine("> " + command);
Screen top = stack.Top()!;
top.Handle(command);
top.Update();
}
Console.WriteLine(stack.Names());
}
}
Ausgabe
> 1
Kampf bekommt 1 und öffnet Ziel
> 2
Ziel bekommt 2 und liefert 1
Kampf erhält 1: Goblin fällt
Ziel aktualisiert sich, Feinde in der Liste: 1
> 1
Kampf bekommt 1 und öffnet Ziel
> 1
Ziel bekommt 1 und liefert 0
Kampf erhält 0: Wolf fällt
Kampf: keine Feinde mehr, liefert Sieg
Karte erhält Sieg: öffnet Stufenaufstieg
Ziel aktualisiert sich, Feinde in der Liste: 0
[Karte, Stufenaufstieg]

Sieh dir den letzten Zug an. Das Ziel ist mit seinem handle noch nicht fertig, und mitten darin hat pop es schon entfernt, der Kampf hat den Wolf getroffen, der Kampf hat „Sieg“ zurückgegeben, und die Karte hat „Stufenaufstieg“ geöffnet. Erst dann ruft die Schleife update für das Ziel auf: Ein Bildschirm, der nicht mehr auf dem Stapel liegt, aktualisiert eine Liste von 0 Feinden für einen Kampf, der vorbei ist. Im echten Spiel würde so ein update einen Feind nach Nummer aus einer leeren Liste lesen, und das Spiel würde mit einem Fehler anhalten.

Würde die Schleife vor update noch einmal nach dem obersten fragen, würde sie „Stufenaufstieg“ aktualisieren: den falschen Bildschirm, der noch kein einziges Mal gezeichnet wurde. Es gibt zwei Wurzeln. Der Stapel ändert sich mitten im Zug. Und die Antworten laufen verschachtelt: Das onResult des Kampfes läuft im handle des Ziels, und das onResult der Karte darin.

Genauer, für Neugierige

Es wird schlimmer, wenn du alle Bildschirme auf dem Stapel durchgehst (sagen wir, jeder hat ein update) und sich einer von ihnen während des Durchgangs selbst entfernt. Dann verhalten sich die Sprachen unterschiedlich:

List<string> screens = new List<string> { "Karte", "Nachricht", "Kampf", "Ziel" };
try
{
foreach (string screen in screens)
{
Console.WriteLine("update " + screen);
if (screen == "Nachricht") screens.Remove(screen);
}
}
catch (InvalidOperationException)
{
Console.WriteLine("InvalidOperationException");
}
Ausgabe
update Karte
update Nachricht
InvalidOperationException
C# bemerkt die Änderung und wirft beim nächsten Schritt von foreach eine InvalidOperationException („Collection was modified“).

Ein Fehler, der das Programm anhält, ist wenigstens sichtbar. Ein stiller Fehler, bei dem ein Bildschirm in einem Zug nicht aktualisiert wird, ist viel schwerer zu fangen.

Die Idee

Aufgeschobene Änderungen: jetzt notieren, später ausführen

Die Lösung ist, den Stapel während des Zuges nicht anzufassen. push und pop bewegen nichts mehr. Sie notieren die Änderung nur in einer Liste, changes. Am Ende des Zuges geht apply die Liste durch und führt die Änderungen eine nach der anderen aus. Eine Änderung ist ein kleines Objekt: eine Art, ein Bildschirm (bei push) und eine Antwort (bei pop).

class Change
{
public string Kind;
public Screen? Screen;
public object? Result;
public Change(string kind, Screen? screen, object? result)
{
Kind = kind;
Screen = screen;
Result = result;
}
}
class ScreenStack
{
public List<Screen> Screens = new List<Screen>();
public List<Change> Changes = new List<Change>();
public void Push(Screen screen)
{
Changes.Add(new Change("push", screen, null));
}
public void Pop(object? result)
{
Changes.Add(new Change("pop", null, result));
}
public Screen? Top()
{
return Screens.Count == 0 ? null : Screens[Screens.Count - 1];
}
public void Apply()
{
int index = 0;
while (index < Changes.Count)
{
Change change = Changes[index];
index++;
if (change.Kind == "push")
{
Screens.Add(change.Screen!);
}
else if (change.Kind == "pop")
{
Screens.RemoveAt(Screens.Count - 1);
Screen? below = Top();
if (below != null) below.OnResult(change.Result);
}
}
Changes.Clear();
}
}

Die Schleife ruft apply zuletzt auf:

Screen top = stack.Top()!;
top.Handle(command);
top.Update();
stack.Apply();

Derselbe Kampf mit denselben Bildschirmen, nur der Stapel ist anders:

class Screen
{
public string Name;
public Screen(string name)
{
Name = name;
}
public virtual void Draw() { }
public virtual void Handle(string command) { }
public virtual void Update() { }
// Die Antwort des Bildschirms, der über diesem lag.
public virtual void OnResult(object? result) { }
}
class Change
{
public string Kind;
public Screen? Screen;
public object? Result;
public Change(string kind, Screen? screen, object? result)
{
Kind = kind;
Screen = screen;
Result = result;
}
}
class ScreenStack
{
public List<Screen> Screens = new List<Screen>();
public List<Change> Changes = new List<Change>();
public void Push(Screen screen)
{
Changes.Add(new Change("push", screen, null));
}
public void Pop(object? result)
{
Changes.Add(new Change("pop", null, result));
}
public Screen? Top()
{
return Screens.Count == 0 ? null : Screens[Screens.Count - 1];
}
public void Apply()
{
int index = 0;
while (index < Changes.Count)
{
Change change = Changes[index];
index++;
if (change.Kind == "push")
{
Screens.Add(change.Screen!);
}
else if (change.Kind == "pop")
{
Screens.RemoveAt(Screens.Count - 1);
Screen? below = Top();
if (below != null) below.OnResult(change.Result);
}
}
Changes.Clear();
}
public string Names()
{
return "[" + string.Join(", ", Screens.Select(s => s.Name)) + "]";
}
}
class TargetScreen : Screen
{
ScreenStack stack;
List<string> enemies;
public TargetScreen(ScreenStack stack, List<string> enemies) : base("Ziel")
{
this.stack = stack;
this.enemies = enemies;
}
public override void Handle(string command)
{
int index = int.Parse(command) - 1;
Console.WriteLine("Ziel bekommt " + command + " und liefert " + index);
stack.Pop(index);
}
public override void Update()
{
Console.WriteLine("Ziel aktualisiert sich, Feinde in der Liste: " + enemies.Count);
}
}
class FightScreen : Screen
{
ScreenStack stack;
List<string> enemies = new List<string> { "Wolf", "Goblin" };
public FightScreen(ScreenStack stack) : base("Kampf")
{
this.stack = stack;
}
public override void Handle(string command)
{
Console.WriteLine("Kampf bekommt " + command + " und öffnet Ziel");
stack.Push(new TargetScreen(stack, enemies));
}
public override void OnResult(object? result)
{
string enemy = enemies[(int)result!];
enemies.RemoveAt((int)result!);
Console.WriteLine("Kampf erhält " + result + ": " + enemy + " fällt");
if (enemies.Count == 0)
{
Console.WriteLine("Kampf: keine Feinde mehr, liefert Sieg");
stack.Pop("Sieg");
}
}
}
class MapScreen : Screen
{
ScreenStack stack;
public MapScreen(ScreenStack stack) : base("Karte")
{
this.stack = stack;
}
public override void OnResult(object? result)
{
Console.WriteLine("Karte erhält " + result + ": öffnet Stufenaufstieg");
stack.Push(new Screen("Stufenaufstieg"));
}
}
class Program
{
static void Main()
{
ScreenStack stack = new ScreenStack();
stack.Push(new MapScreen(stack));
stack.Push(new FightScreen(stack));
stack.Apply();
foreach (string command in new[] { "1", "2", "1", "1" })
{
Console.WriteLine("> " + command);
Screen top = stack.Top()!;
top.Handle(command);
top.Update();
stack.Apply();
}
Console.WriteLine(stack.Names());
}
}
Ausgabe
> 1
Kampf bekommt 1 und öffnet Ziel
> 2
Ziel bekommt 2 und liefert 1
Ziel aktualisiert sich, Feinde in der Liste: 2
Kampf erhält 1: Goblin fällt
> 1
Kampf bekommt 1 und öffnet Ziel
> 1
Ziel bekommt 1 und liefert 0
Ziel aktualisiert sich, Feinde in der Liste: 1
Kampf erhält 0: Wolf fällt
Kampf: keine Feinde mehr, liefert Sieg
Karte erhält Sieg: öffnet Stufenaufstieg
[Karte, Stufenaufstieg]

Jetzt beendet das Ziel handle und update, solange es noch oben liegt und alles um es herum so ist wie vor dem Zug. Erst dann entfernt apply es, und der Kampf bekommt seine Antwort. Jeder Bildschirm arbeitet in einem Stapel, der sich nicht unter seinen Füßen verändert.

Drei Einzelheiten in apply sind wichtig:

  • while mit einem Index, nicht foreach. Während apply ein pop ausführt, bekommt der Kampf seine Antwort und kann eine neue Änderung schreiben: pop(Sieg). Sie landet am Ende derselben Liste, und while liest ihre Länge neu und erreicht sie auch. foreach würde den Fehler von der vorigen Karte werfen.
  • Die Liste wird am Ende geleert. Sonst würden im nächsten Zug dieselben Änderungen noch einmal ausgeführt.
  • Die Reihenfolge ist die Reihenfolge des Schreibens. Schreibt ein Bildschirm in einem Zug ein replace und dann ein push, tut der Stapel genau das.

Sieh es dir an

Ein Zug, Änderung für Änderung

class ScreenStack
{
public List<Screen> Screens = new List<Screen>();
public List<Change> Changes = new List<Change>();
public void Push(Screen screen)
{
Changes.Add(new Change("push", screen, null));
}
public void Pop(object? result)
{
Changes.Add(new Change("pop", null, result));
}
public Screen? Top()
{
return Screens.Count == 0 ? null : Screens[Screens.Count - 1];
}
public void Apply()
{
int index = 0;
while (index < Changes.Count)
{
Change change = Changes[index];
index++;
if (change.Kind == "push")
{
Screens.Add(change.Screen!);
}
else if (change.Kind == "pop")
{
Screens.RemoveAt(Screens.Count - 1);
Screen? below = Top();
if (below != null) below.OnResult(change.Result);
}
}
Changes.Clear();
}
}
// Ein Zug.
Screen top = stack.Top()!;
top.Handle(command);
top.Update();
stack.Apply();
Schritt für Schritt, in Worten
  1. Der letzte Zug des Kampfes. Auf dem Stapel liegen Karte, Kampf und oben Ziel. Nur der Wolf ist übrig. Die Liste der Änderungen ist leer.
  2. Der Befehl 1 geht an das oberste, Ziel. Es ruft Pop(0) auf: 0 ist die Nummer des gewählten Feindes. Der Stapel rührt sich nicht. Die Änderung wird nur in Changes notiert.
  3. Update läuft für Ziel, das noch auf dem Stapel liegt. Alles, was es sieht, ist wie vor dem Zug: Der Kampf ist noch nicht vorbei.
  4. Ende des Zuges: Apply nimmt Änderung 0. Ziel verlässt den Stapel, und 0 geht über OnResult hinunter an Kampf. Der Wolf fällt, es sind keine Feinde mehr da, und Kampf ruft Pop(Sieg) auf: eine neue Änderung am Ende der Liste.
  5. while liest die Länge von Changes neu, also erreicht es auch die neue Änderung. Kampf verlässt den Stapel, und Karte bekommt „Sieg“. Karte schreibt Push(Stufenaufstieg).
  6. Die dritte Änderung ist ein Push: Stufenaufstieg kommt auf den Stapel, über Karte.
  7. Die Liste ist abgearbeitet. Changes wird geleert, damit dieselben Änderungen im nächsten Zug nicht noch einmal ausgeführt werden. Der Stapel ist Karte, Stufenaufstieg. Jeder Bildschirm hat seine Antwort nach dem Zug bekommen, nie mitten in einem.

Probier es aus

Was fehlt?

Das ist die Schleife von apply. Wie muss die Bedingung lauten, damit sie auch die Änderungen erreicht, die geschrieben werden, während sie läuft?

public void Apply()
{
    int index = 0;
    while (index < )
    {
        Change change = Changes[index];
        index++;
        if (change.Kind == "push")
        {
            Screens.Add(change.Screen!);
        }
        else if (change.Kind == "pop")
        {
            Screens.RemoveAt(Screens.Count - 1);
            Screen? below = Top();
            if (below != null) below.OnResult(change.Result);
        }
    }
    Changes.Clear();
}

Probier es aus

Sage die Ausgabe voraus

NamedScreen ist ein Bildschirm, der nur sagt, was er bekommen hat. Der Stapel ist der mit den aufgeschobenen Änderungen. Was gibt das Programm aus?

ScreenStack stack = new ScreenStack();
stack.Push(new NamedScreen("Karte"));
stack.Push(new NamedScreen("Kampf"));
stack.Apply();
stack.Pop("Sieg");
stack.Push(new NamedScreen("Stufenaufstieg"));
Console.WriteLine("Oben: " + stack.Top()!.Name);
stack.Apply();
Console.WriteLine("Oben: " + stack.Top()!.Name);
Antwort

Die Idee

Übergänge: replace und reset

Nicht jeder Wechsel des Bildschirms ist eine Frage mit Antwort. Manche sind Übergänge ohne Weg zurück. Für sie hat der Stapel noch zwei Änderungen:

public void Replace(Screen screen)
{
Changes.Add(new Change("replace", screen, null));
}
public void Reset(Screen? screen)
{
Changes.Add(new Change("reset", screen, null));
}

apply hat für jede einen Zweig:

else if (change.Kind == "replace")
{
Screens.RemoveAt(Screens.Count - 1);
Screens.Add(change.Screen!);
}
else if (change.Kind == "reset")
{
Screens.Clear();
if (change.Screen != null) Screens.Add(change.Screen);
}
  • replace entfernt den obersten Bildschirm und setzt einen anderen an seine Stelle. Der Titelbildschirm öffnet die Heldenerschaffung, und die Heldenerschaffung öffnet das Dorf. Mit push bliebe der Titelbildschirm ganz unten, und bei „zurück“ würde der Spieler mitten im Spiel dort landen.
  • reset leert den ganzen Stapel und setzt einen Bildschirm (oder keinen: dann endet das Spiel). Es hat zwei Einsatzzwecke: Der Held ist gefallen (der Spielende-Bildschirm) und ein Spielstand wurde geladen (das Dorf oder die Karte).

Warum nicht pop für das Ende des Spiels benutzen? Der Stapel ist Dorf, Karte, Kampf. Hier sind beide Wege:

ScreenStack stack = new ScreenStack();
stack.Push(new NamedScreen("Titel"));
stack.Apply();
Console.WriteLine(stack.Names());
stack.Replace(new NamedScreen("Neuer Held"));
stack.Apply();
Console.WriteLine(stack.Names());
stack.Replace(new NamedScreen("Dorf"));
stack.Apply();
Console.WriteLine(stack.Names());
Console.WriteLine("Der Held fällt. Mit pop:");
ScreenStack popped = InFight();
popped.Pop("Niederlage");
popped.Pop("Niederlage");
popped.Push(new NamedScreen("Spielende"));
popped.Apply();
Console.WriteLine(popped.Names());
Console.WriteLine("Der Held fällt. Mit reset:");
ScreenStack clean = InFight();
clean.Reset(new NamedScreen("Spielende"));
clean.Apply();
Console.WriteLine(clean.Names());
Ausgabe
[Titel]
[Neuer Held]
[Dorf]
Der Held fällt. Mit pop:
Karte erhält Niederlage
Dorf erhält Niederlage
[Dorf, Spielende]
Der Held fällt. Mit reset:
[Spielende]

Bei pop bekommen die Karte und das Dorf „Niederlage“ und reagieren, als wäre der Kampf auf normale Weise zu Ende gegangen. Du musst auch wissen, wie viele Bildschirme darunter liegen. Und das Dorf bleibt unter dem Spielende-Bildschirm. reset ruft onResult bei niemandem auf: Das ist keine Antwort, es ist ein Neustart.

Genauer, für Neugierige

replace und reset sind auch aufgeschoben: Sie werden notiert und in apply in der Reihenfolge ausgeführt, in der sie geschrieben wurden. Die Heldenerschaffung im Spiel ruft replace(das Dorf) auf und gleich danach push(die Nachricht mit dem Anfang der Geschichte). Nach apply ist der Stapel das Dorf mit der Nachricht obenauf, nicht umgekehrt.

Das ganze Programm:

class Screen
{
public string Name;
public Screen(string name)
{
Name = name;
}
public virtual void Draw() { }
public virtual void Handle(string command) { }
public virtual void Update() { }
// Die Antwort des Bildschirms, der über diesem lag.
public virtual void OnResult(object? result) { }
}
class Change
{
public string Kind;
public Screen? Screen;
public object? Result;
public Change(string kind, Screen? screen, object? result)
{
Kind = kind;
Screen = screen;
Result = result;
}
}
class ScreenStack
{
public List<Screen> Screens = new List<Screen>();
public List<Change> Changes = new List<Change>();
public void Push(Screen screen)
{
Changes.Add(new Change("push", screen, null));
}
public void Pop(object? result)
{
Changes.Add(new Change("pop", null, result));
}
public void Replace(Screen screen)
{
Changes.Add(new Change("replace", screen, null));
}
public void Reset(Screen? screen)
{
Changes.Add(new Change("reset", screen, null));
}
public Screen? Top()
{
return Screens.Count == 0 ? null : Screens[Screens.Count - 1];
}
public void Apply()
{
int index = 0;
while (index < Changes.Count)
{
Change change = Changes[index];
index++;
if (change.Kind == "push")
{
Screens.Add(change.Screen!);
}
else if (change.Kind == "pop")
{
Screens.RemoveAt(Screens.Count - 1);
Screen? below = Top();
if (below != null) below.OnResult(change.Result);
}
else if (change.Kind == "replace")
{
Screens.RemoveAt(Screens.Count - 1);
Screens.Add(change.Screen!);
}
else if (change.Kind == "reset")
{
Screens.Clear();
if (change.Screen != null) Screens.Add(change.Screen);
}
}
Changes.Clear();
}
public string Names()
{
return "[" + string.Join(", ", Screens.Select(s => s.Name)) + "]";
}
}
class NamedScreen : Screen
{
public NamedScreen(string name) : base(name) { }
public override void OnResult(object? result)
{
Console.WriteLine(Name + " erhält " + result);
}
}
class Program
{
static ScreenStack InFight()
{
ScreenStack stack = new ScreenStack();
stack.Push(new NamedScreen("Dorf"));
stack.Push(new NamedScreen("Karte"));
stack.Push(new NamedScreen("Kampf"));
stack.Apply();
return stack;
}
static void Main()
{
ScreenStack stack = new ScreenStack();
stack.Push(new NamedScreen("Titel"));
stack.Apply();
Console.WriteLine(stack.Names());
stack.Replace(new NamedScreen("Neuer Held"));
stack.Apply();
Console.WriteLine(stack.Names());
stack.Replace(new NamedScreen("Dorf"));
stack.Apply();
Console.WriteLine(stack.Names());
Console.WriteLine("Der Held fällt. Mit pop:");
ScreenStack popped = InFight();
popped.Pop("Niederlage");
popped.Pop("Niederlage");
popped.Push(new NamedScreen("Spielende"));
popped.Apply();
Console.WriteLine(popped.Names());
Console.WriteLine("Der Held fällt. Mit reset:");
ScreenStack clean = InFight();
clean.Reset(new NamedScreen("Spielende"));
clean.Apply();
Console.WriteLine(clean.Names());
}
}

Die Idee

Im echten Spiel

Der Stapel der Chroniken von Arkanum ist derselbe, mit drei Dingen mehr:

// Der Stapel ändert sich während der Verarbeitung nicht: Push, Pop und Replace merken die Änderung nur, und
// Apply() wendet sie nach dem Zug an. Sonst würde ein Bildschirm, der vom Stapel genommen wird, weiterlaufen.
class ScreenChange
{
public string Kind = "";
public Screen? Screen;
public object? Result;
}
class ScreenStack
{
public List<Screen> Screens = new List<Screen>();
public List<ScreenChange> Changes = new List<ScreenChange>();
public void Push(Screen screen)
{
Changes.Add(new ScreenChange { Kind = "push", Screen = screen, Result = null });
}
public void Pop(object? result)
{
Changes.Add(new ScreenChange { Kind = "pop", Screen = null, Result = result });
}
public void Replace(Screen screen)
{
Changes.Add(new ScreenChange { Kind = "replace", Screen = screen, Result = null });
}
// Leert den ganzen Stapel (Laden, Game over); mit null bleibt der Stapel leer.
public void Reset(Screen? screen)
{
Changes.Add(new ScreenChange { Kind = "reset", Screen = screen, Result = null });
}
public Screen? Top()
{
return Screens.Count == 0 ? null : Screens[Screens.Count - 1];
}
public bool IsEmpty()
{
return Screens.Count == 0 && Changes.Count == 0;
}
public bool Contains(string kindName)
{
foreach (Screen screen in Screens)
{
if (screen.KindName == kindName) return true;
}
return false;
}
public void Apply()
{
int index = 0;
while (index < Changes.Count)
{
ScreenChange change = Changes[index];
index++;
if (change.Kind == "push")
{
Screens.Add(change.Screen!);
}
else if (change.Kind == "pop")
{
Screens.RemoveAt(Screens.Count - 1);
Screen? below = Top();
if (below != null) below.OnResult(change.Result);
}
else if (change.Kind == "replace")
{
Screens.RemoveAt(Screens.Count - 1);
Screens.Add(change.Screen!);
}
else
{
Screens = new List<Screen>();
if (change.Screen != null) Screens.Add(change.Screen);
}
}
Changes = new List<ScreenChange>();
}
// Das Zeichnen beginnt beim obersten undurchsichtigen Bildschirm und geht nach oben.
public void Draw(CellBuffer buffer)
{
int first = Screens.Count - 1;
while (first > 0 && Screens[first].IsTransparent()) first--;
for (int i = first; i < Screens.Count; i++)
{
if (i >= 0) Screens[i].Draw(buffer);
}
}
}
  • isEmpty schaut auch auf die Änderungen. Das Spiel beginnt mit einem push des Titelbildschirms, und das Beenden ist ein reset ohne Bildschirm. Die Schleife hält an, wenn weder Bildschirme noch wartende Änderungen da sind.
  • contains fragt, ob der Stapel schon einen Bildschirm einer bestimmten Art hat. So kommt der Stufenaufstieg-Bildschirm nicht doppelt hinein, wenn in einem Zug zwei Stufen gewonnen werden.
  • draw zeichnet von unten nach oben ab dem obersten undurchsichtigen Bildschirm, wie in der letzten Lektion.

Die Zielwahl im Spiel ist ein durchsichtiger Bildschirm über dem Kampf. Er trifft selbst niemanden: Er gibt die Nummer mit pop zurück, und der Kampf entscheidet, was zu tun ist. Daneben stehen die allgemeine Wahl aus einer Liste (eine Fähigkeit, ein Trank) und die Wahl eines Speicherplatzes, die den Namen des Platzes zurückgibt, keine Zahl:

// Ein Ziel zu wählen ist ein durchsichtiger Bildschirm über dem Kampf. Er greift nicht an: Pop(result) liefert die Zielnummer,
// und der Kampf entscheidet, was damit geschieht.
class TargetScreen : Screen
{
public Battle Battle;
public string Note;
public TargetScreen(Game game, Battle battle) : base(game)
{
Battle = battle;
Note = "";
}
public override bool IsTransparent()
{
return true;
}
public override void Draw(CellBuffer buffer)
{
List<Enemy> enemies = Battle.AliveEnemies();
buffer.Box(24, 9, 36, enemies.Count + 4, T("target.title"));
for (int i = 0; i < enemies.Count; i++)
{
buffer.Text(26, 10 + i, (i + 1) + ") " + Pad(enemies[i].Name, 22) + " " + enemies[i].Hp + "/" + enemies[i].MaxHp(), enemies[i].Kind.Color);
}
buffer.Text(26, 11 + enemies.Count, Note.Length > 0 ? Note : T("target.help"), "gray");
}
public override void Handle(string command)
{
if (command == "x")
{
Game.Stack.Pop(null);
return;
}
long? choice = ParseIntStrict(command);
if (choice == null || choice < 1 || choice > Battle.AliveEnemies().Count)
{
Note = T("ui.unknown", ("command", command));
return;
}
Game.Stack.Pop((int)choice.Value - 1);
}
}
// Eine allgemeine Auswahl aus einer Liste (Fähigkeit, Trank, Slot): liefert eine 0-basierte Nummer oder null.
class ChoiceScreen : Screen
{
public string Title;
public List<string> Labels;
public string Note;
public ChoiceScreen(Game game, string title, List<string> labels) : base(game)
{
Title = title;
Labels = labels;
Note = "";
}
public override bool IsTransparent()
{
return true;
}
public override void Draw(CellBuffer buffer)
{
buffer.Box(4, 3, 52, Labels.Count + 4, Title);
for (int i = 0; i < Labels.Count; i++) buffer.Text(6, 4 + i, (i + 1) + ") " + Labels[i], "default");
buffer.Text(6, 5 + Labels.Count, Note.Length > 0 ? Note : T("choice.help"), "gray");
}
public override void Handle(string command)
{
if (command == "x")
{
Game.Stack.Pop(null);
return;
}
long? choice = ParseIntStrict(command);
if (choice == null || choice < 1 || choice > Labels.Count)
{
Note = T("ui.unknown", ("command", command));
return;
}
Game.Stack.Pop((int)choice.Value - 1);
}
}
class SlotScreen : ChoiceScreen
{
// C# ruft den Konstruktor der Basisklasse vor dem Rumpf auf, darum werden die Beschriftungen in einer eigenen Methode berechnet.
public SlotScreen(Game game, string mode) : base(game, T(mode == "save" ? "slot.saveTitle" : "slot.loadTitle"), SlotLabels(game)) { }
static List<string> SlotLabels(Game game)
{
List<string> labels = new List<string>();
foreach (string slot in SAVE_SLOTS) labels.Add(T("slot.label", ("slot", slot), ("summary", SlotSummary(game.Io, slot))));
return labels;
}
public override void Handle(string command)
{
long? choice = ParseIntStrict(command);
if (choice != null && choice >= 1 && choice <= SAVE_SLOTS.Length)
{
Game.Stack.Pop(SAVE_SLOTS[(int)choice.Value - 1]);
return;
}
base.Handle(command);
}
}

Der Kampf merkt sich, was er gefragt hat (pending), und liest die Antwort in OnResult:

public override void OnResult(object? result)
{
Battle battle = Battle;
Hero hero = battle.Hero;
PendingChoice? pending = Pending;
Pending = null;
if (result == null || pending == null) return;
int index = (int)result;
if (pending.Kind == "attack")
{
battle.HeroAct(new AttackAction(hero, battle.AliveEnemies()[index]));
}
else if (pending.Kind == "skill")
{
Skill skill = SKILLS.Get(hero.Skills[index]);
if (hero.Mana < skill.Mana)
{
Note = T("battle.noMana", ("skill", Nm(skill)));
}
else if (hero.CooldownOf(skill.Id) > 0)
{
Note = T("battle.cooldown", ("skill", Nm(skill)), ("turns", hero.CooldownOf(skill.Id)));
}
else if (skill.Target == "one" && battle.AliveEnemies().Count > 1)
{
Pending = new PendingChoice { Kind = "skillTarget", Skill = skill };
Game.Stack.Push(new TargetScreen(Game, battle));
}
else
{
battle.HeroAct(new SkillAction(hero, skill, battle.AliveEnemies()[0]));
}
}
else if (pending.Kind == "skillTarget")
{
battle.HeroAct(new SkillAction(hero, pending.Skill!, battle.AliveEnemies()[index]));
}
else
{
battle.HeroAct(new ItemAction(hero, PotionIds()[index]));
}
}

Sieh dir den Zweig der Fähigkeit an. Der Spieler hat eine Fähigkeit gewählt, die ein Ziel braucht, und es gibt mehr als einen Feind. Dann schreibt der Kampf in onResult eine neue Frage: ein push des Ziel-Bildschirms. Das geschieht während apply, und while erreicht es und führt es im selben Zug aus. Der Spieler sieht die Liste der Feinde gleich nach der Liste der Fähigkeiten.

Das Menü ist der andere Bildschirm, der fragt. „Bist du sicher?“ ist derselbe allgemeine Bildschirm, und die Antwort „ja“ auf die Frage zum Beenden ist ein reset ohne Bildschirm: Der Stapel wird leer, und das Spiel endet.

class ConfirmScreen : Screen
{
public string Question;
public ConfirmScreen(Game game, string question) : base(game)
{
Question = question;
}
public override bool IsTransparent()
{
return true;
}
public override void Draw(CellBuffer buffer)
{
buffer.Box(16, 6, 28, 5, "");
buffer.Text(18, 7, Question, "white");
buffer.Text(18, 8, "1) " + T("ui.yes") + " 2) " + T("ui.no"), "default");
}
public override void Handle(string command)
{
if (command == "1") Game.Stack.Pop(true);
else if (command == "2" || command == "x") Game.Stack.Pop(false);
}
}
public override void OnResult(object? result)
{
Game game = Game;
string pending = Pending;
Pending = "";
if (result == null) return;
if (pending == "save")
{
game.Io.Save((string)result, SaveGame(game));
Note = T("menu.saved", ("slot", result));
}
else if (pending == "load")
{
string? error = LoadGame(game, game.Io.Load((string)result));
if (error != null) Note = error;
else EnterWorld(game, T("load.done", ("slot", result)));
}
else if (pending == "quit" && result is bool yes && yes)
{
game.Stack.Reset(null);
}
}

Probier es aus

Finde den Fehler

Ein Kollege hat in apply zwei Zeilen vertauscht. Das Kampfprogramm aus „Aufgeschobene Änderungen“ gibt jetzt aus:

class ScreenStack
{
public List<Screen> Screens = new List<Screen>();
public List<Change> Changes = new List<Change>();
public void Push(Screen screen)
{
Changes.Add(new Change("push", screen, null));
}
public void Pop(object? result)
{
Changes.Add(new Change("pop", null, result));
}
public Screen? Top()
{
return Screens.Count == 0 ? null : Screens[Screens.Count - 1];
}
public void Apply()
{
int index = 0;
while (index < Changes.Count)
{
Change change = Changes[index];
index++;
if (change.Kind == "push")
{
Screens.Add(change.Screen!);
}
else if (change.Kind == "pop")
{
Screen? below = Top();
Screens.RemoveAt(Screens.Count - 1);
if (below != null) below.OnResult(change.Result);
}
}
Changes.Clear();
}
}
Ausgabe
> 1
Kampf bekommt 1 und öffnet Ziel
> 2
Ziel bekommt 2 und liefert 1
Ziel aktualisiert sich, Feinde in der Liste: 2
> 1
Kampf bekommt 1 und öffnet Ziel
> 1
Ziel bekommt 1 und liefert 0
Ziel aktualisiert sich, Feinde in der Liste: 2
[Karte, Kampf]

Das Ziel gibt eine Zahl zurück, aber der Kampf erfährt es nie: Beide Feinde leben, und am Ende ist der Stapel die Karte und der Kampf. Welche Zeile läuft zu früh?

Wähle die Zeile mit dem Fehler.

Genauer

Genauer, für Neugierige

Warum kein Callback? Der Kampf könnte dem Ziel eine Funktion geben: „Wenn der Spieler wählt, ruf das hier auf.“

// Der Kampf sagt dem Ziel, was es mit der Wahl tun soll.
stack.Push(new TargetScreen(enemies, index => Attack(index)));

Das funktioniert, und es ist üblich. Der Preis steckt in den Einzelheiten. Die Funktion wird vom Ziel aufgerufen, in seinem handle: Wir sind wieder mitten im Zug, es sei denn, auch der Aufruf wird aufgeschoben. Die Funktion hält den Kampf fest, und das Spiel muss gespeichert werden: Ein Stapel von Bildschirmen mit Daten lässt sich speichern, aber eine Funktion lässt sich nicht in eine Datei schreiben. Und die Reihenfolge wird unsichtbar: Wer aufgerufen wird, ist erst klar, wenn man sieht, wer was übergeben hat. onResult sagt dasselbe mit einer Regel, die man im Stapel sieht: Die Antwort geht immer an den Bildschirm darunter.

Dasselbe anderswo. Auf Android öffnet eine Activity eine andere und wartet auf ein Ergebnis (startActivityForResult, heute die Activity Result API). Modale Dialoge in Desktop-Apps geben beim Schließen einen Wert zurück („OK“ oder „Abbrechen“). Die Navigation in mobilen Apps ist auch ein Stapel: Du öffnest einen Bildschirm obenauf, und „zurück“ bringt dich zurück, manchmal mit einem Ergebnis.

Mit await. In JavaScript, C# und Python kannst du const target = await targetScreen.open() schreiben: Der Kampf „wartet“ auf die Antwort wie bei einem gewöhnlichen Aufruf. Das sieht natürlicher aus, ist aber in einem rundenbasierten Spiel mit einer Schleife schwieriger. Der Kampf ist dann kein Objekt mehr, das Befehle empfängt, sondern eine Funktion, die mittendrin angehalten wurde. Ihr Zustand steckt in „wo sie stehen geblieben ist“, und das kommt nicht in eine Speicherdatei. Und es muss immer noch klar sein, an welchem Punkt des Zuges sie weitermacht.

Der Typ der Antwort. Hier ist die Antwort object?, und der Kampf weiß, dass es eine Zahl ist. Es geht auch strenger: Das Ziel gibt einen eigenen Typ zurück (sagen wir, einen Datensatz mit der Nummer), oder der Bildschirm ist generisch über den Typ der Antwort. Im Spiel sind die Antworten wenige und einfach (eine Zahl, „ja“ oder „nein“, ein Platzname, das Ergebnis eines Kampfes), deshalb reicht die Prüfung von pending.

Übung

Ein Laden mit zwei Fragen

Löse es in deinem Editor. Beginne mit dem Stapel mit aufgeschobenen Änderungen aus dieser Lektion und mit ConfirmScreen.

Laden. ShopScreen hat 20 Gold und verkauft einen Trank für 5 und ein Schwert für 12. Die Wahl eines Gegenstands öffnet QuantityScreen, der die Anzahl (von 1 bis 9) zurückgibt oder nichts, wenn der Spieler x drückt. Dann öffnet der Laden ConfirmScreen mit der Frage „Kaufst du 2 x Trank für 10 Gold?“. Ein Bildschirm sammelt zwei Antworten, eine nach der anderen: OnResult weiß an pending, welche Antwort eingetroffen ist, das von "count" zu "confirm" wechselt. Reicht das Gold nicht, wird nichts gekauft. x im Laden schließt ihn.

Beachte: ConfirmScreen wird aus OnResult geöffnet, also während Apply noch läuft. Deshalb liest die Schleife in Apply die Länge der Liste neu.

Eingabe
1
2
1
2
1
2
2
x
2
1
1
1
1
1
x
Ausgabe
Laden: Gold 20 | 1) Trank 5 2) Schwert 12 x) verlassen
> 1
Wie viele? 1-9 | x) zurück
> 2
Kaufst du 2 x Trank für 10 Gold? 1) ja 2) nein
> 1
Du kaufst 2 x Trank.
Laden: Gold 10 | 1) Trank 5 2) Schwert 12 x) verlassen
> 2
Wie viele? 1-9 | x) zurück
> 1
Kaufst du 1 x Schwert für 12 Gold? 1) ja 2) nein
> 2
Du kaufst nicht.
Laden: Gold 10 | 1) Trank 5 2) Schwert 12 x) verlassen
> 2
Wie viele? 1-9 | x) zurück
> x
Du überlegst es dir anders.
Laden: Gold 10 | 1) Trank 5 2) Schwert 12 x) verlassen
> 2
Wie viele? 1-9 | x) zurück
> 1
Kaufst du 1 x Schwert für 12 Gold? 1) ja 2) nein
> 1
Du hast nicht genug Gold.
Laden: Gold 10 | 1) Trank 5 2) Schwert 12 x) verlassen
> 1
Wie viele? 1-9 | x) zurück
> 1
Kaufst du 1 x Trank für 5 Gold? 1) ja 2) nein
> 1
Du kaufst 1 x Trank.
Laden: Gold 5 | 1) Trank 5 2) Schwert 12 x) verlassen
> x
Auf Wiedersehen.

Weitere Aufgaben

  • Verkaufen. Füge s) verkaufen hinzu: Der Laden fragt, welchen Gegenstand (ein weiterer Bildschirm mit einer Antwort) und wie viele, und zahlt den halben Preis zurück, abgerundet. Wie viele Werte hat pending jetzt?
  • Tasche. Halte die gekauften Gegenstände in einer Liste und gib sie beim Verlassen aus. Ein zweimal gekaufter Trank ist eine Zeile mit 3 Stück, nicht zwei Zeilen.
  • Kein pending. Lass QuantityScreen und ConfirmScreen einen Datensatz mit dem Namen der Frage und dem Wert zurückgeben. Kommt der Laden dann ohne pending aus? Was gewinnst du und was verlierst du?

Test

Teste dich

1. Warum ruft der Ziel-Bildschirm den Kampf nicht selbst auf, sondern gibt mit pop eine Zahl zurück?
2. Die Karte hat „Bist du sicher?“ geöffnet und true bekommen. Woher weiß sie, ob sie das Spiel beenden oder einen Trank wegwerfen soll?
3. Warum geht apply die Änderungen mit while (index < length) durch und nicht mit foreach?
4. Ein Kollege hat die letzte Zeile von apply gelöscht, die changes leert. Was passiert?
5. Der Held fällt im Kampf. Der Stapel ist Dorf, Karte, Kampf. Warum ruft das Spiel reset(Spielende) auf und nicht zweimal pop und ein push?

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