Zum Inhalt
wannacode

Ein Stapel von Bildschirmen

Kurz gesagt: Ein Stapel von Bildschirmen hält die geöffneten Bildschirme übereinander: Die Eingabe geht an den obersten, push öffnet einen neuen, pop schließt ihn, replace tauscht ihn aus, und durchsichtige werden über die darunterliegenden gezeichnet.

Problem

Eine Variable für alle Bildschirme

Das Spiel hat einen Titelbildschirm, ein Dorf, die Karte, einen Kampf, ein Inventar, das sich über der Karte öffnet, und ein Menü, das sich über dem Kampf öffnet. Jeder Bildschirm liest Befehle auf seine Art: Auf der Karte ist a ein Schritt nach links, im Kampf ein Hieb, im Inventar nichts.

Der erste Versuch, den fast alle machen: eine Variable mit dem Namen des Bildschirms und ein if darauf. Einmal bei der Behandlung des Befehls und noch einmal beim Zeichnen:

string screen = "title";
int wolf = 2;
string? line;
while ((line = Console.ReadLine()) != null)
{
string command = line.Trim();
if (screen == "title")
{
if (command == "n") screen = "map";
}
else if (screen == "map")
{
if (command == "i") screen = "inventory";
if (command == "f") screen = "battle";
}
else if (screen == "battle")
{
if (command == "i") screen = "inventory";
if (command == "a")
{
wolf--;
if (wolf == 0) screen = "map";
}
}
else if (screen == "inventory")
{
if (command == "x") screen = "map";
}
Console.Write(command + " → ");
if (screen == "title") Console.WriteLine("Chroniken von Arkanum");
else if (screen == "map") Console.WriteLine("Karte, Wolf: " + wolf);
else if (screen == "battle") Console.WriteLine("Kampf, Wolf: " + wolf);
else if (screen == "inventory") Console.WriteLine("Inventar: Trank x2");
}
Ausgabe
n → Karte, Wolf: 2
i → Inventar: Trank x2
x → Karte, Wolf: 2
f → Kampf, Wolf: 2
i → Inventar: Trank x2
x → Karte, Wolf: 2
a → Karte, Wolf: 2
a → Karte, Wolf: 2

Die Befehle kommen einer pro Zeile: n neues Spiel, i Inventar, x schließen, f gegen den Wolf kämpfen, a Hieb.

Eingabe
n
i
x
f
i
x
a
a

Sieh dir die sechste Zeile an. Das Inventar wurde aus dem Kampf geöffnet, und x schließt es zur Karte hin. Der Kampf ist weg, der Wolf lebt mit 2 Leben, und die beiden Hiebe danach landen auf der Karte, wo a nichts bedeutet. Die Variable merkt sich, wo du bist, aber nicht, woher du gekommen bist.

Das zweite Problem zeigt sich, wenn das Spiel wächst. Jeder neue Bildschirm ist ein neuer Zweig in jedem if: bei der Behandlung, beim Zeichnen und in jedem Bildschirm, der ihn öffnet. Ein Menü über dem Kampf und über der Karte sind zwei weitere Zweige an zwei Stellen. Vergisst du einen, hast du einen Bildschirm, der Befehle annimmt, aber nie gezeichnet wird.

Die Idee

Ein Bildschirm entscheidet über sich selbst

Dreh die Frage um. Statt dass die Schleife fragt „auf welchem Bildschirm bin ich“ und jeden Bildschirm kennt, ist jeder Bildschirm ein Objekt mit denselben vier Methoden:

  • Handle: was der Befehl hier bedeutet;
  • Update: was jeden Zug passiert, solange der Bildschirm oben liegt (im Kampf beißt der Wolf);
  • Draw: was der Bildschirm zeichnet;
  • IsTransparent: ob der Bildschirm darunter durchscheint.
abstract class Screen
{
public Game Game;
public string Name;
public Screen(Game game, string name)
{
Game = game;
Name = name;
}
public virtual bool IsTransparent()
{
return false;
}
public abstract void Handle(string command);
public virtual void Update() { }
public abstract void Draw(TextBuffer buffer);
}
abstract sagt, dass jeder Bildschirm sein eigenes Handle und sein eigenes Draw haben muss: Ohne sie lässt sich die Klasse nicht kompilieren. Update und IsTransparent haben ein Standardverhalten, und ein Bildschirm schreibt nur dann ein eigenes, wenn er es braucht.

Hier sind der Kampf und das Inventar. Der Kampf kennt nur sich selbst: i öffnet das Inventar obenauf, a schlägt zu, und wenn der Wolf fällt, entfernt sich der Kampf selbst. Das Inventar weiß nicht, woraus es geöffnet wurde. Es sagt nur „entferne mich“:

class BattleScreen : Screen
{
public BattleScreen(Game game) : base(game, "Kampf") { }
public override void Handle(string command)
{
if (command == "i") Game.Stack.Push(new InventoryScreen(Game));
if (command == "a")
{
Game.Wolf--;
if (Game.Wolf == 0) Game.Stack.Pop();
}
}
// In jedem Zug des Kampfes beißt der Wolf.
public override void Update()
{
if (Game.Wolf > 0) Game.Life--;
}
public override void Draw(TextBuffer buffer)
{
buffer.Box(0, 0, TextBuffer.W, TextBuffer.H);
buffer.Write(2, 1, "Wolf: " + Game.Wolf);
buffer.Write(2, 2, "Leben: " + Game.Life);
buffer.Write(2, 3, "a: Hieb i: Inventar");
}
}
class InventoryScreen : Screen
{
public InventoryScreen(Game game) : base(game, "Inventar") { }
public override void Handle(string command)
{
if (command == "x") Game.Stack.Pop();
}
public override void Draw(TextBuffer buffer)
{
buffer.Box(12, 1, 11, 3);
buffer.Write(13, 2, "Trank x2");
}
}

Die Bildschirme zeichnen in einen TextBuffer: fünf Zeilen zu je 24 Zeichen. Write setzt Text an eine bestimmte Stelle, Box zeichnet einen Rahmen. Der ganze Puffer steht im Programm unten, und wie das Zeichnen in einem echten Terminal funktioniert, steht in der Lektion über das Zeichnen.

Ein neuer Bildschirm ist eine neue Klasse und eine Zeile in dem Bildschirm, der ihn öffnet. Kein if auf Bildschirmnamen wächst.

Genauer, für Neugierige

So sieht es im Spiel aus. Die Region screen des Kanons:

// Jeder Bildschirm ist ein Objekt mit derselben Schnittstelle. Das Spiel fragt nicht „auf welchem Bildschirm bin ich“; es schickt den Befehl an
// den Bildschirm oben auf dem Stapel.
abstract class Screen
{
public Game Game;
public string KindName = "";
public Screen(Game game)
{
Game = game;
}
// Ein durchsichtiger Bildschirm (Menü, Inventar, Dialog) lässt den Bildschirm darunter durchscheinen.
public virtual bool IsTransparent()
{
return false;
}
public virtual void Handle(string command) { }
public virtual void Update() { }
public virtual void Draw(CellBuffer buffer) { }
// Der Bildschirm unter dem entfernten bekommt seine Antwort (das gewählte Ziel, „ja“ oder „nein“, der gewählte Slot).
public virtual void OnResult(object? result) { }
}

Die Methode OnResult ist für Bildschirme, die eine Antwort zurückschicken (das gewählte Ziel, „ja“ oder „nein“). Sie ist das Thema der nächsten Lektion.

Die Idee

Der Stapel: woher du gekommen bist

Bildschirme liegen übereinander: das Inventar über dem Kampf, der Kampf über der Karte. Wenn du den obersten schließt, willst du genau dort sein, wo du warst. Das ist ein Stapel: Der Letzte, der hineinkommt, geht als Erster hinaus. Der Stapel der Bildschirme hat drei Operationen:

  • push: einen neuen Bildschirm obenauf öffnen (die Karte öffnet den Kampf, der Kampf öffnet das Inventar);
  • pop: den obersten schließen und zu dem darunter zurückkehren;
  • replace: den obersten gegen einen neuen austauschen, ohne Weg zurück (der Titelbildschirm wird zur Karte, die Heldenerschaffung wird zum Dorf).
class ScreenStack
{
public List<Screen> Screens = new List<Screen>();
public void Push(Screen screen)
{
Screens.Add(screen);
}
public void Pop()
{
Screens.RemoveAt(Screens.Count - 1);
}
public void Replace(Screen screen)
{
Pop();
Push(screen);
}
public Screen Top()
{
return Screens[Screens.Count - 1];
}
public string Names()
{
var names = new List<string>();
foreach (Screen screen in Screens) names.Add(screen.Name);
return "[" + string.Join(", ", names) + "]";
}
public void Draw(TextBuffer buffer)
{
Top().Draw(buffer);
}
}

Die Spielschleife gibt den Befehl nur an den obersten. Nicht an die Karte, nicht an den Kampf unter dem Inventar. Nur der oberste weiß, was der Befehl gerade bedeutet. Dieselben Befehle, jetzt mit dem Stapel. Show gibt nach jedem Befehl den Stapel und das Bild aus:

static void Show(Game game, TextBuffer buffer)
{
Console.WriteLine(game.Stack.Names());
buffer.Clear();
game.Stack.Draw(buffer);
buffer.Print();
}
var game = new Game();
var buffer = new TextBuffer();
game.Stack.Push(new TitleScreen(game));
Show(game, buffer);
string? line;
while ((line = Console.ReadLine()) != null)
{
string command = line.Trim();
Console.WriteLine("> " + command);
Screen top = game.Stack.Top();
top.Handle(command);
top.Update();
Show(game, buffer);
}
Ausgabe
[Titel]
Chroniken von Arkanum
n: Neues Spiel
> n
[Karte]
########################
#..@......~~~..........#
#.......w.~~~..........#
########################
Leben: 10
> i
[Karte, Inventar]
+---------+
|Trank x2 |
+---------+
> x
[Karte]
########################
#..@......~~~..........#
#.......w.~~~..........#
########################
Leben: 10
> f
[Karte, Kampf]
+----------------------+
| Wolf: 2 |
| Leben: 10 |
| a: Hieb i: Inventar |
+----------------------+
> i
[Karte, Kampf, Inventar]
+---------+
|Trank x2 |
+---------+
> x
[Karte, Kampf]
+----------------------+
| Wolf: 2 |
| Leben: 9 |
| a: Hieb i: Inventar |
+----------------------+
> a
[Karte, Kampf]
+----------------------+
| Wolf: 1 |
| Leben: 8 |
| a: Hieb i: Inventar |
+----------------------+
> a
[Karte]
########################
#..@......~~~..........#
#.........~~~..........#
########################
Leben: 8

Das Inventar, das aus dem Kampf geöffnet wurde, schließt sich jetzt zurück zum Kampf: [Karte, Kampf]. Der Wolf fällt beim zweiten Hieb, der Kampf entfernt sich selbst, und darunter liegt die Karte, jetzt ohne das w. Kein Bildschirm merkt sich, woher er kam. Der Stapel tut es.

Aber sieh dir die Bilder mit dem Inventar an. Die Karte ist verschwunden, und der Rahmen hängt im leeren Raum. Das Draw des Stapels zeichnet nur den obersten. Darum geht es als Nächstes.

Genauer, für Neugierige

Nur der oberste wird aktualisiert. Die Schleife ruft Update für den obersten auf. Solange das Inventar über dem Kampf offen ist, wartet der Kampf und der Wolf beißt nicht: Das Spiel ist unter dem Inventar angehalten. Das ist eine Entscheidung, kein Gesetz. In einem Echtzeitspiel kann die Welt unter dem Menü weiterlaufen, und dann aktualisiert der Stapel auch Bildschirme unter dem obersten, die sagen, dass sie nicht anhalten.

pop auf einem leeren Stapel. Wenn sich der letzte Bildschirm selbst entfernt, ist der Stapel leer, und das nächste pop hat nichts zu entfernen. In C# wirft RemoveAt(-1) eine ArgumentOutOfRangeException. Im Spiel bedeutet ein leerer Stapel das Ende: Die Schleife läuft, bis der Stapel leer ist, und „Beenden“ im Titelbildschirm leert den Stapel.

Probier es aus

Sage die Ausgabe voraus

Der Stapel ist leer. Was liegt am Ende darauf?

var game = new Game();
ScreenStack stack = game.Stack;
stack.Push(new TitleScreen(game));
stack.Replace(new MapScreen(game));
stack.Push(new BattleScreen(game));
stack.Push(new InventoryScreen(game));
stack.Pop();
stack.Pop();
stack.Push(new InventoryScreen(game));
Console.WriteLine(stack.Names());
Antwort

Die Idee

Durchsichtige Bildschirme

Das Inventar ist ein kleiner Rahmen über der Karte. Damit die Karte um ihn herum zu sehen ist, muss die Karte zuerst gezeichnet werden und das Inventar danach, darüber. Wie ein Maler, der zuerst den Hintergrund malt und dann die Figuren davor.

Aber nicht vom untersten Bildschirm aus. Unter dem Kampf liegt die Karte, und der Kampf verdeckt sie ganz: Sie zu zeichnen wäre verschwendete Arbeit. Also beginnt das Zeichnen beim obersten undurchsichtigen Bildschirm. Geh von oben nach unten, solange die Bildschirme durchsichtig sind. Der erste, der es nicht ist, ist der Anfang. Von dort zeichnest du nach oben:

public void Draw(TextBuffer buffer)
{
int first = Screens.Count - 1;
while (first > 0 && Screens[first].IsTransparent()) first--;
for (int i = first; i < Screens.Count; i++)
{
Screens[i].Draw(buffer);
}
}

Das Inventar sagt, dass es durchsichtig ist:

class InventoryScreen : Screen
{
public InventoryScreen(Game game) : base(game, "Inventar") { }
public override bool IsTransparent()
{
return true;
}
public override void Handle(string command)
{
if (command == "x") Game.Stack.Pop();
}
public override void Draw(TextBuffer buffer)
{
buffer.Box(12, 1, 11, 3);
buffer.Write(13, 2, "Trank x2");
}
}

Nur diese beiden Änderungen. Dieselbe Schleife, dieselben Befehle:

Ausgabe
[Titel]
Chroniken von Arkanum
n: Neues Spiel
> n
[Karte]
########################
#..@......~~~..........#
#.......w.~~~..........#
########################
Leben: 10
> i
[Karte, Inventar]
########################
#..@......~~+---------+#
#.......w.~~|Trank x2 |#
############+---------+#
Leben: 10
> x
[Karte]
########################
#..@......~~~..........#
#.......w.~~~..........#
########################
Leben: 10
> f
[Karte, Kampf]
+----------------------+
| Wolf: 2 |
| Leben: 10 |
| a: Hieb i: Inventar |
+----------------------+
> i
[Karte, Kampf, Inventar]
+----------------------+
| Wolf: 2 +---------+|
| Leben: 9 |Trank x2 ||
| a: Hieb i+---------+|
+----------------------+
> x
[Karte, Kampf]
+----------------------+
| Wolf: 2 |
| Leben: 9 |
| a: Hieb i: Inventar |
+----------------------+
> a
[Karte, Kampf]
+----------------------+
| Wolf: 1 |
| Leben: 8 |
| a: Hieb i: Inventar |
+----------------------+
> a
[Karte]
########################
#..@......~~~..........#
#.........~~~..........#
########################
Leben: 8

[Karte, Inventar]: die Karte, mit dem Rahmen darüber, und der Wolf ist darunter weiter zu sehen. [Karte, Kampf, Inventar]: Das Zeichnen beginnt beim Kampf, weil er nicht durchsichtig ist. Die Karte darunter wird gar nicht gezeichnet.

first > 0 hält beim untersten Bildschirm an: Er wird immer gezeichnet, auch wenn er durchsichtig ist, weil nichts darunter liegt.

Genauer, für Neugierige

Die Methode Draw aus der Region screen-stack des Kanons:

// 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);
}
}

Die Prüfung i >= 0 ist für einen leeren Stapel: Dann ist first gleich −1, und es gibt nichts zu zeichnen.

Warum nicht einfach bei null? Wenn jeder undurchsichtige Bildschirm alles verdeckt, ist das Ergebnis dasselbe, aber jedes Bild zeichnet alle Bildschirme darunter: Karte, Kampf, Zielwahl, Dialog. Es gibt auch etwas Schlimmeres: Ein undurchsichtiger Bildschirm, der nicht jede Zelle beschreibt, lässt Stücke der Karte durchscheinen. „Ich bin nicht durchsichtig“ ist ein Versprechen, das besagt „Ich verdecke alles“, und der Stapel verlässt sich darauf.

Sieh es dir an

Zug um Zug

Dasselbe Programm mit dem durchsichtigen Inventar. Links ist der Stapel: Jede Platte ist ein Bildschirm mit dem, was er für sich allein zeichnet. Der Befehl geht an die oberste Platte. Die verblassten Platten liegen unter dem obersten undurchsichtigen und werden nicht gezeichnet. Unter dem Stapel ist das Bild, das das Programm ausgibt.

class ScreenStack
{
public List<Screen> Screens = new List<Screen>();
public void Push(Screen screen)
{
Screens.Add(screen);
}
public void Pop()
{
Screens.RemoveAt(Screens.Count - 1);
}
public void Replace(Screen screen)
{
Pop();
Push(screen);
}
public Screen Top()
{
return Screens[Screens.Count - 1];
}
public string Names()
{
var names = new List<string>();
foreach (Screen screen in Screens) names.Add(screen.Name);
return "[" + string.Join(", ", names) + "]";
}
public void Draw(TextBuffer buffer)
{
int first = Screens.Count - 1;
while (first > 0 && Screens[first].IsTransparent()) first--;
for (int i = first; i < Screens.Count; i++)
{
Screens[i].Draw(buffer);
}
}
}
string? line;
while ((line = Console.ReadLine()) != null)
{
string command = line.Trim();
Console.WriteLine("> " + command);
Screen top = game.Stack.Top();
top.Handle(command);
top.Update();
Show(game, buffer);
}
Schritt für Schritt, in Worten
  1. Das Spiel beginnt mit einem Bildschirm auf dem Stapel: dem Titelbildschirm. Er liegt oben und bekommt die Eingabe.
  2. n geht an den Titelbildschirm. Er setzt die Karte an seine eigene Stelle (replace): Der Titelbildschirm kommt nicht zurück.
  3. i geht an die Karte, und sie legt das Inventar obenauf (push). Das Inventar ist durchsichtig: Zuerst wird die Karte gezeichnet, dann das Inventar darüber.
  4. x geht an das Inventar: Es entfernt sich selbst (pop). Darunter liegt die Karte, genau so, wie du sie verlassen hast.
  5. f: Die Karte legt den Kampf obenauf (push). Der Kampf ist undurchsichtig, also wird die Karte darunter gar nicht gezeichnet.
  6. i geht jetzt an den Kampf, nicht an die Karte. Der Kampf öffnet das Inventar über sich. Das Zeichnen beginnt beim Kampf, dem obersten undurchsichtigen Bildschirm. Der Wolf hat gebissen: Leben 9.
  7. x schließt das Inventar, und darunter liegt der Kampf: Du kehrst dorthin zurück, woher du gekommen bist. Die einzelne Variable vom Anfang der Lektion hat dich hier zur Karte gebracht.
  8. a geht an den Kampf: Der Wolf wird getroffen, und der Stapel ändert sich nicht. Das update des Kampfes läuft: Der Wolf beißt zurück, Leben 8.
  9. Der Wolf fällt. Der Kampf entfernt sich selbst (pop), und die Karte liegt wieder oben, jetzt ohne den Wolf.
  10. Die Eingabe geht nach oben. Das Zeichnen geht von dem obersten undurchsichtigen Bildschirm aus aufwärts. Bildschirme, die eine Antwort zurückschicken, und Änderungen, die auf das Ende des Zuges warten, sind die nächste Lektion.

Probier es aus

Was fehlt?

Das Zeichnen des Stapels. Solange der Bildschirm bei first durchsichtig ist, gehst du nach unten:

public void Draw(TextBuffer buffer)
{
    int first = Screens.Count - 1;
    while (first > 0 && ) first--;
    for (int i = first; i < Screens.Count; i++)
    {
        Screens[i].Draw(buffer);
    }
}

Probier es aus

Finde den Fehler

Ein Kollege hat den Stapel umgeschrieben. Das Programm mit den Befehlen n, i, x gibt aus:

Ausgabe
[Titel]
Chroniken von Arkanum
n: Neues Spiel
> n
[Karte]
########################
#..@......~~~..........#
#.......w.~~~..........#
########################
Leben: 10
> i
[Karte, Inventar]
########################
#..@......~~+---------+#
#.......w.~~|Trank x2 |#
############+---------+#
Leben: 10
> x
[Karte, Inventar]
########################
#..@......~~+---------+#
#.......w.~~|Trank x2 |#
############+---------+#
Leben: 10

Das Inventar schließt sich nicht: Nach x ist der Stapel immer noch [Karte, Inventar]. In welcher Zeile ist der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Der Stapel im echten Spiel

Die Region screen-stack des Kanons. Die Methoden sind dieselben, mit ein paar Unterschieden:

// 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);
}
}
}
  • push, pop und replace fassen die Liste nicht sofort an. Sie notieren die Änderung in changes, und Apply() führt sie nach dem Zug aus. In unserem Programm entfernt sich der Kampf in Handle, und gleich danach ruft die Schleife Update für denselben Kampf auf, der nicht mehr auf dem Stapel liegt. Hier geht das gut, weil ein toter Wolf nicht beißt. In einem größeren Spiel ist das ein Fehler, den man lange sucht. Warum und wie die Änderungen aufgeschoben werden, ist die nächste Lektion.
  • pop trägt eine Antwort: Der Bildschirm darunter bekommt sie in OnResult. So gibt der Bildschirm der Zielwahl das Ziel an den Kampf zurück. Auch das steht in der nächsten Lektion.
  • reset leert den ganzen Stapel (Spiel laden, Ende des Spiels), und contains fragt, ob ein Bildschirm einer bestimmten Art schon auf dem Stapel liegt (zum Beispiel, damit sich kein zweiter Stufenaufstieg-Bildschirm öffnet).

Genauer

Dieselbe Idee anderswo

Die Navigation in mobilen Apps ist ein Stapel von Bildschirmen. Auf Android und iOS öffnest du einen Bildschirm obenauf (push), und die Zurück-Taste entfernt ihn (pop). Der Einstieg nach dem Login ist meist ein replace: Zurück soll nicht wieder zum Login-Formular führen.

Ein modaler Dialog im Browser ist ein Bildschirm über der Seite. Solange er offen ist, bekommt nur er Klicks und Tasten, und die Seite darunter ist sichtbar, reagiert aber nicht. Genau wie das Inventar über der Karte.

Der Aufrufstapel ist dieselbe Idee in der Sprache selbst. Eine Methode ruft eine andere auf (push), die kehrt zurück (pop), und der Aufrufer macht genau dort weiter, wo er aufgehört hat. Der Kampf geht nach dem Inventar auf dieselbe Weise weiter.

Wenn ein Stapel nicht reicht. Ein Stapel sagt, dass genau ein Bildschirm oben liegt und alles bekommt. Manchmal stimmt das nicht:

  • zwei Bildschirme nebeneinander, die zusammenarbeiten: eine Karte und eine Minikarte, ein Kampf und das Kampfprotokoll, ein Spiel für zwei Spieler auf geteiltem Bildschirm. Dann ist der Bildschirm ein Baum von Panels, und die Eingabe geht an das Panel mit dem Fokus;
  • Dinge, die immer oben liegen, egal welcher Bildschirm: eine Lebensleiste, ein Hinweis „Spiel gespeichert“. Sie sind eine eigene Schicht, die nach dem Stapel gezeichnet wird;
  • Fenster, die in beliebiger Reihenfolge geschlossen werden (ein Strategiespiel mit fünf offenen Fenstern). Das ist kein Stapel mehr, sondern eine Liste mit Fokus.

Übung

Ein Menü über allem

Löse das in deinem eigenen Editor. Das ganze Programm mit durchsichtigen Bildschirmen steht unter der Aufgabe.

Menü. Füge einen MenuScreen mit dem Namen „Menü“ hinzu. m öffnet ihn von der Karte, vom Kampf und vom Inventar aus. Das Menü ist durchsichtig, und x schließt es. Es zeichnet einen Rahmen Box(14, 2, 10, 3) mit x: raus darin, bei (15, 3).

Eingabe
n
f
i
m
x
x
a
a
Ausgabe
[Titel]
Chroniken von Arkanum
n: Neues Spiel
> n
[Karte]
########################
#..@......~~~..........#
#.......w.~~~..........#
########################
Leben: 10
> f
[Karte, Kampf]
+----------------------+
| Wolf: 2 |
| Leben: 10 |
| a: Hieb i: Inventar |
+----------------------+
> i
[Karte, Kampf, Inventar]
+----------------------+
| Wolf: 2 +---------+|
| Leben: 9 |Trank x2 ||
| a: Hieb i+---------+|
+----------------------+
> m
[Karte, Kampf, Inventar, Menü]
+----------------------+
| Wolf: 2 +---------+|
| Leben: 9 |T+--------+
| a: Hieb i+-|x: raus |
+-------------+--------+
> x
[Karte, Kampf, Inventar]
+----------------------+
| Wolf: 2 +---------+|
| Leben: 9 |Trank x2 ||
| a: Hieb i+---------+|
+----------------------+
> x
[Karte, Kampf]
+----------------------+
| Wolf: 2 |
| Leben: 9 |
| a: Hieb i: Inventar |
+----------------------+
> a
[Karte, Kampf]
+----------------------+
| Wolf: 1 |
| Leben: 8 |
| a: Hieb i: Inventar |
+----------------------+
> a
[Karte]
########################
#..@......~~~..........#
#.........~~~..........#
########################
Leben: 8

Sieh dir das Bild mit dem Menü an: Der Kampf, das Inventar und das Menü werden gezeichnet, in dieser Reihenfolge. Das Menü verdeckt eine Ecke des Inventars, weil es im Stapel höher liegt.

Genauer, für Neugierige

Das ganze Programm mit durchsichtigen Bildschirmen:

var game = new Game();
var buffer = new TextBuffer();
game.Stack.Push(new TitleScreen(game));
Show(game, buffer);
string? line;
while ((line = Console.ReadLine()) != null)
{
string command = line.Trim();
Console.WriteLine("> " + command);
Screen top = game.Stack.Top();
top.Handle(command);
top.Update();
Show(game, buffer);
}
static void Show(Game game, TextBuffer buffer)
{
Console.WriteLine(game.Stack.Names());
buffer.Clear();
game.Stack.Draw(buffer);
buffer.Print();
}
class TextBuffer
{
public const int W = 24;
public const int H = 5;
string[] rows = new string[H];
public void Clear()
{
for (int y = 0; y < H; y++) rows[y] = new string(' ', W);
}
public void Write(int x, int y, string text)
{
rows[y] = rows[y].Substring(0, x) + text + rows[y].Substring(x + text.Length);
}
public void Box(int x, int y, int w, int h)
{
string edge = "+" + new string('-', w - 2) + "+";
Write(x, y, edge);
for (int i = 1; i < h - 1; i++) Write(x, y + i, "|" + new string(' ', w - 2) + "|");
Write(x, y + h - 1, edge);
}
public void Print()
{
foreach (string row in rows) Console.WriteLine(row.TrimEnd());
}
}
class Game
{
public ScreenStack Stack = new ScreenStack();
public int Life = 10;
public int Wolf = 2;
}
abstract class Screen
{
public Game Game;
public string Name;
public Screen(Game game, string name)
{
Game = game;
Name = name;
}
public virtual bool IsTransparent()
{
return false;
}
public abstract void Handle(string command);
public virtual void Update() { }
public abstract void Draw(TextBuffer buffer);
}
class ScreenStack
{
public List<Screen> Screens = new List<Screen>();
public void Push(Screen screen)
{
Screens.Add(screen);
}
public void Pop()
{
Screens.RemoveAt(Screens.Count - 1);
}
public void Replace(Screen screen)
{
Pop();
Push(screen);
}
public Screen Top()
{
return Screens[Screens.Count - 1];
}
public string Names()
{
var names = new List<string>();
foreach (Screen screen in Screens) names.Add(screen.Name);
return "[" + string.Join(", ", names) + "]";
}
public void Draw(TextBuffer buffer)
{
int first = Screens.Count - 1;
while (first > 0 && Screens[first].IsTransparent()) first--;
for (int i = first; i < Screens.Count; i++)
{
Screens[i].Draw(buffer);
}
}
}
class TitleScreen : Screen
{
public TitleScreen(Game game) : base(game, "Titel") { }
public override void Handle(string command)
{
if (command == "n") Game.Stack.Replace(new MapScreen(Game));
}
public override void Draw(TextBuffer buffer)
{
buffer.Write(1, 1, "Chroniken von Arkanum");
buffer.Write(1, 3, "n: Neues Spiel");
}
}
class MapScreen : Screen
{
static readonly string[] Rows =
{
"########################",
"#..@......~~~..........#",
"#.........~~~..........#",
"########################",
};
public MapScreen(Game game) : base(game, "Karte") { }
public override void Handle(string command)
{
if (command == "i") Game.Stack.Push(new InventoryScreen(Game));
if (command == "f" && Game.Wolf > 0) Game.Stack.Push(new BattleScreen(Game));
}
public override void Draw(TextBuffer buffer)
{
for (int y = 0; y < Rows.Length; y++) buffer.Write(0, y, Rows[y]);
if (Game.Wolf > 0) buffer.Write(8, 2, "w");
buffer.Write(1, 4, "Leben: " + Game.Life);
}
}
class BattleScreen : Screen
{
public BattleScreen(Game game) : base(game, "Kampf") { }
public override void Handle(string command)
{
if (command == "i") Game.Stack.Push(new InventoryScreen(Game));
if (command == "a")
{
Game.Wolf--;
if (Game.Wolf == 0) Game.Stack.Pop();
}
}
// In jedem Zug des Kampfes beißt der Wolf.
public override void Update()
{
if (Game.Wolf > 0) Game.Life--;
}
public override void Draw(TextBuffer buffer)
{
buffer.Box(0, 0, TextBuffer.W, TextBuffer.H);
buffer.Write(2, 1, "Wolf: " + Game.Wolf);
buffer.Write(2, 2, "Leben: " + Game.Life);
buffer.Write(2, 3, "a: Hieb i: Inventar");
}
}
class InventoryScreen : Screen
{
public InventoryScreen(Game game) : base(game, "Inventar") { }
public override bool IsTransparent()
{
return true;
}
public override void Handle(string command)
{
if (command == "x") Game.Stack.Pop();
}
public override void Draw(TextBuffer buffer)
{
buffer.Box(12, 1, 11, 3);
buffer.Write(13, 2, "Trank x2");
}
}

Weitere Aufgaben

  • Heldenerschaffung. Füge zwischen dem Titelbildschirm und der Karte einen Bildschirm ein, der nach einem Namen fragt. Beide Übergänge benutzen replace: Vom Helden gibt es keinen Weg zurück.
  • Pause. p öffnet von jedem Bildschirm aus einen undurchsichtigen Bildschirm „Pause“. Prüfe, dass der Wolf nicht beißt, solange das Spiel pausiert.
  • Wächter für den Stapel. Lass pop ablehnen, wenn nur noch ein Bildschirm auf dem Stapel liegt, und gib den Grund aus.

Test

Teste dich

1. Der Stapel ist [Karte, Kampf, Inventar]. Der Spieler tippt a (Hieb). Was passiert mit dem Wolf?
2. Der Stapel ist [Karte, Kampf, Inventar, Menü]. Das Inventar und das Menü sind durchsichtig, die Karte und der Kampf nicht. Welche Bildschirme werden gezeichnet, und in welcher Reihenfolge?
3. Warum öffnet der Titelbildschirm die Karte mit replace und nicht mit push?
4. Ein Kollege hält screen und noch eine Variable, previous, für „woher ich gekommen bin“. Wann reicht das nicht?
5. Du fügst einen Bildschirm „Held“ hinzu, der mit c von der Karte aus aufgeht. Was ändert sich im Programm mit dem Stapel?

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