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:

Scanner in = new Scanner(System.in);
String screen = "title";
int wolf = 2;
while (in.hasNextLine()) {
String command = in.nextLine().trim();
if (screen.equals("title")) {
if (command.equals("n")) screen = "map";
} else if (screen.equals("map")) {
if (command.equals("i")) screen = "inventory";
if (command.equals("f")) screen = "battle";
} else if (screen.equals("battle")) {
if (command.equals("i")) screen = "inventory";
if (command.equals("a")) {
wolf--;
if (wolf == 0) screen = "map";
}
} else if (screen.equals("inventory")) {
if (command.equals("x")) screen = "map";
}
System.out.print(command + " → ");
if (screen.equals("title")) System.out.println("Chroniken von Arkanum");
else if (screen.equals("map")) System.out.println("Karte, Wolf: " + wolf);
else if (screen.equals("battle")) System.out.println("Kampf, Wolf: " + wolf);
else if (screen.equals("inventory")) System.out.println("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 {
Game game;
String name;
Screen(Game game, String name) {
this.game = game;
this.name = name;
}
boolean isTransparent() {
return false;
}
abstract void handle(String command);
void update() {}
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 extends Screen {
BattleScreen(Game game) {
super(game, "Kampf");
}
@Override
void handle(String command) {
if (command.equals("i")) game.stack.push(new InventoryScreen(game));
if (command.equals("a")) {
game.wolf--;
if (game.wolf == 0) game.stack.pop();
}
}
// In jedem Zug des Kampfes beißt der Wolf.
@Override
void update() {
if (game.wolf > 0) game.life--;
}
@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 extends Screen {
InventoryScreen(Game game) {
super(game, "Inventar");
}
@Override
void handle(String command) {
if (command.equals("x")) game.stack.pop();
}
@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.
static class Screen {
Game game;
String kindName = "";
Screen(Game game) {
this.game = game;
}
// Ein durchsichtiger Bildschirm (Menü, Inventar, Dialog) lässt den Bildschirm darunter durchscheinen.
boolean isTransparent() {
return false;
}
void handle(String command) {}
void update() {}
void draw(CellBuffer buffer) {}
// Der Bildschirm unter dem entfernten bekommt seine Antwort (das gewählte Ziel, „ja“ oder „nein“, der gewählte Slot).
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 {
List<Screen> screens = new ArrayList<>();
void push(Screen screen) {
screens.add(screen);
}
void pop() {
screens.remove(screens.size() - 1);
}
void replace(Screen screen) {
pop();
push(screen);
}
Screen top() {
return screens.get(screens.size() - 1);
}
String names() {
List<String> names = new ArrayList<>();
for (Screen screen : screens) names.add(screen.name);
return "[" + String.join(", ", names) + "]";
}
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) {
System.out.println(game.stack.names());
buffer.clear();
game.stack.draw(buffer);
buffer.print();
}
Game game = new Game();
TextBuffer buffer = new TextBuffer();
game.stack.push(new TitleScreen(game));
show(game, buffer);
Scanner in = new Scanner(System.in);
while (in.hasNextLine()) {
String command = in.nextLine().trim();
System.out.println("> " + 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 Java wirft remove(-1) eine IndexOutOfBoundsException. 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?

Game 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));
System.out.println(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:

void draw(TextBuffer buffer) {
int first = screens.size() - 1;
while (first > 0 && screens.get(first).isTransparent()) first--;
for (int i = first; i < screens.size(); i++) {
screens.get(i).draw(buffer);
}
}

Das Inventar sagt, dass es durchsichtig ist:

class InventoryScreen extends Screen {
InventoryScreen(Game game) {
super(game, "Inventar");
}
@Override
boolean isTransparent() {
return true;
}
@Override
void handle(String command) {
if (command.equals("x")) game.stack.pop();
}
@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.
void draw(CellBuffer buffer) {
int first = this.screens.size() - 1;
while (first > 0 && this.screens.get(first).isTransparent()) first--;
for (int i = first; i < this.screens.size(); i++) {
if (i >= 0) this.screens.get(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 {
List<Screen> screens = new ArrayList<>();
void push(Screen screen) {
screens.add(screen);
}
void pop() {
screens.remove(screens.size() - 1);
}
void replace(Screen screen) {
pop();
push(screen);
}
Screen top() {
return screens.get(screens.size() - 1);
}
String names() {
List<String> names = new ArrayList<>();
for (Screen screen : screens) names.add(screen.name);
return "[" + String.join(", ", names) + "]";
}
void draw(TextBuffer buffer) {
int first = screens.size() - 1;
while (first > 0 && screens.get(first).isTransparent()) first--;
for (int i = first; i < screens.size(); i++) {
screens.get(i).draw(buffer);
}
}
}
while (in.hasNextLine()) {
String command = in.nextLine().trim();
System.out.println("> " + 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:

void draw(TextBuffer buffer) {
    int first = screens.size() - 1;
    while (first > 0 && ) first--;
    for (int i = first; i < screens.size(); i++) {
        screens.get(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.
static class StackChange {
String kind;
Screen screen;
Object result;
StackChange(String kind, Screen screen, Object result) {
this.kind = kind;
this.screen = screen;
this.result = result;
}
}
static class ScreenStack {
List<Screen> screens = new ArrayList<>();
List<StackChange> changes = new ArrayList<>();
void push(Screen screen) {
this.changes.add(new StackChange("push", screen, null));
}
void pop(Object result) {
this.changes.add(new StackChange("pop", null, result));
}
void replace(Screen screen) {
this.changes.add(new StackChange("replace", screen, null));
}
// Leert den ganzen Stapel (Laden, Game over); mit null bleibt der Stapel leer.
void reset(Screen screen) {
this.changes.add(new StackChange("reset", screen, null));
}
Screen top() {
return this.screens.size() == 0 ? null : this.screens.get(this.screens.size() - 1);
}
boolean isEmpty() {
return this.screens.size() == 0 && this.changes.size() == 0;
}
boolean contains(String kindName) {
for (Screen screen : this.screens) {
if (screen.kindName.equals(kindName)) return true;
}
return false;
}
// pop auf einem leeren Stapel tut in JavaScript nichts; in Java wirft remove, darum gibt es eine Prüfung.
void removeTop() {
if (this.screens.size() > 0) this.screens.remove(this.screens.size() - 1);
}
void apply() {
int index = 0;
while (index < this.changes.size()) {
StackChange change = this.changes.get(index);
index++;
if (change.kind.equals("push")) {
this.screens.add(change.screen);
} else if (change.kind.equals("pop")) {
this.removeTop();
Screen below = this.top();
if (below != null) below.onResult(change.result);
} else if (change.kind.equals("replace")) {
this.removeTop();
this.screens.add(change.screen);
} else {
this.screens = new ArrayList<>();
if (change.screen != null) this.screens.add(change.screen);
}
}
this.changes = new ArrayList<>();
}
// Das Zeichnen beginnt beim obersten undurchsichtigen Bildschirm und geht nach oben.
void draw(CellBuffer buffer) {
int first = this.screens.size() - 1;
while (first > 0 && this.screens.get(first).isTransparent()) first--;
for (int i = first; i < this.screens.size(); i++) {
if (i >= 0) this.screens.get(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:

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
class TextBuffer {
static final int W = 24;
static final int H = 5;
String[] rows = new String[H];
void clear() {
for (int y = 0; y < H; y++) rows[y] = " ".repeat(W);
}
void write(int x, int y, String text) {
rows[y] = rows[y].substring(0, x) + text + rows[y].substring(x + text.length());
}
void box(int x, int y, int w, int h) {
String edge = "+" + "-".repeat(w - 2) + "+";
write(x, y, edge);
for (int i = 1; i < h - 1; i++) write(x, y + i, "|" + " ".repeat(w - 2) + "|");
write(x, y + h - 1, edge);
}
void print() {
for (String row : rows) System.out.println(row.stripTrailing());
}
}
class Game {
ScreenStack stack = new ScreenStack();
int life = 10;
int wolf = 2;
}
abstract class Screen {
Game game;
String name;
Screen(Game game, String name) {
this.game = game;
this.name = name;
}
boolean isTransparent() {
return false;
}
abstract void handle(String command);
void update() {}
abstract void draw(TextBuffer buffer);
}
class ScreenStack {
List<Screen> screens = new ArrayList<>();
void push(Screen screen) {
screens.add(screen);
}
void pop() {
screens.remove(screens.size() - 1);
}
void replace(Screen screen) {
pop();
push(screen);
}
Screen top() {
return screens.get(screens.size() - 1);
}
String names() {
List<String> names = new ArrayList<>();
for (Screen screen : screens) names.add(screen.name);
return "[" + String.join(", ", names) + "]";
}
void draw(TextBuffer buffer) {
int first = screens.size() - 1;
while (first > 0 && screens.get(first).isTransparent()) first--;
for (int i = first; i < screens.size(); i++) {
screens.get(i).draw(buffer);
}
}
}
class TitleScreen extends Screen {
TitleScreen(Game game) {
super(game, "Titel");
}
@Override
void handle(String command) {
if (command.equals("n")) game.stack.replace(new MapScreen(game));
}
@Override
void draw(TextBuffer buffer) {
buffer.write(1, 1, "Chroniken von Arkanum");
buffer.write(1, 3, "n: Neues Spiel");
}
}
class MapScreen extends Screen {
static final String[] ROWS = {
"########################",
"#..@......~~~..........#",
"#.........~~~..........#",
"########################",
};
MapScreen(Game game) {
super(game, "Karte");
}
@Override
void handle(String command) {
if (command.equals("i")) game.stack.push(new InventoryScreen(game));
if (command.equals("f") && game.wolf > 0) game.stack.push(new BattleScreen(game));
}
@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 extends Screen {
BattleScreen(Game game) {
super(game, "Kampf");
}
@Override
void handle(String command) {
if (command.equals("i")) game.stack.push(new InventoryScreen(game));
if (command.equals("a")) {
game.wolf--;
if (game.wolf == 0) game.stack.pop();
}
}
// In jedem Zug des Kampfes beißt der Wolf.
@Override
void update() {
if (game.wolf > 0) game.life--;
}
@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 extends Screen {
InventoryScreen(Game game) {
super(game, "Inventar");
}
@Override
boolean isTransparent() {
return true;
}
@Override
void handle(String command) {
if (command.equals("x")) game.stack.pop();
}
@Override
void draw(TextBuffer buffer) {
buffer.box(12, 1, 11, 3);
buffer.write(13, 2, "Trank x2");
}
}
public class Main {
static void show(Game game, TextBuffer buffer) {
System.out.println(game.stack.names());
buffer.clear();
game.stack.draw(buffer);
buffer.print();
}
public static void main(String[] args) {
Game game = new Game();
TextBuffer buffer = new TextBuffer();
game.stack.push(new TitleScreen(game));
show(game, buffer);
Scanner in = new Scanner(System.in);
while (in.hasNextLine()) {
String command = in.nextLine().trim();
System.out.println("> " + command);
Screen top = game.stack.top();
top.handle(command);
top.update();
show(game, buffer);
}
}
}

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 ·