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:
import { createInterface } from "node:readline";
const rl = createInterface({ input: process.stdin });let screen = "title";let wolf = 2;for await (const line of rl) { const 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"; } process.stdout.write(command + " → "); if (screen === "title") console.log("Chroniken von Arkanum"); else if (screen === "map") console.log(`Karte, Wolf: ${wolf}`); else if (screen === "battle") console.log(`Kampf, Wolf: ${wolf}`); else if (screen === "inventory") console.log("Inventar: Trank x2");}n → Karte, Wolf: 2i → Inventar: Trank x2x → Karte, Wolf: 2f → Kampf, Wolf: 2i → Inventar: Trank x2x → Karte, Wolf: 2a → Karte, Wolf: 2a → Karte, Wolf: 2Die Befehle kommen einer pro Zeile: n neues Spiel, i Inventar, x schließen, f gegen den Wolf kämpfen, a Hieb.
nixfixaaSieh 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.
// JavaScript hat keine abstrakten Klassen: Hier tun handle und draw nichts,// und jeder Bildschirm schreibt sie neu.class Screen { constructor(game, name) { this.game = game; this.name = name; }
isTransparent() { return false; }
handle(command) {}
update() {}
draw(buffer) {}}handle und draw. Jeder Bildschirm schreibt seine eigenen. Wenn du es vergisst, tut der Bildschirm einfach nichts, ohne Fehler.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 { constructor(game) { super(game, "Kampf"); }
handle(command) { if (command === "i") this.game.stack.push(new InventoryScreen(this.game)); if (command === "a") { this.game.wolf--; if (this.game.wolf === 0) this.game.stack.pop(); } }
// In jedem Zug des Kampfes beißt der Wolf. update() { if (this.game.wolf > 0) this.game.life--; }
draw(buffer) { buffer.box(0, 0, TextBuffer.W, TextBuffer.H); buffer.write(2, 1, `Wolf: ${this.game.wolf}`); buffer.write(2, 2, `Leben: ${this.game.life}`); buffer.write(2, 3, "a: Hieb i: Inventar"); }}class InventoryScreen extends Screen { constructor(game) { super(game, "Inventar"); }
handle(command) { if (command === "x") this.game.stack.pop(); }
draw(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.class Screen { constructor(game) { this.game = game; }
// Ein durchsichtiger Bildschirm (Menü, Inventar, Dialog) lässt den Bildschirm darunter durchscheinen. isTransparent() { return false; }
handle(command) {}
update() {}
draw(buffer) {}
// Der Bildschirm unter dem entfernten bekommt seine Antwort (das gewählte Ziel, „ja“ oder „nein“, der gewählte Slot). onResult(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 { constructor() { this.screens = []; }
push(screen) { this.screens.push(screen); }
pop() { this.screens.pop(); }
replace(screen) { this.pop(); this.push(screen); }
top() { return this.screens[this.screens.length - 1]; }
names() { return `[${this.screens.map((s) => s.name).join(", ")}]`; }
draw(buffer) { this.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:
function show(game, buffer) { console.log(game.stack.names()); buffer.clear(); game.stack.draw(buffer); buffer.print();}
const game = new Game();const buffer = new TextBuffer();game.stack.push(new TitleScreen(game));show(game, buffer);const rl = createInterface({ input: process.stdin });for await (const line of rl) { const command = line.trim(); console.log(`> ${command}`); const top = game.stack.top(); top.handle(command); top.update(); show(game, buffer);}[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: 8Das 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 JavaScript gibt pop() auf einem leeren Array undefined zurück, ohne Fehler, und das Programm scheitert später bei top().handle(...), weit weg von der wahren Ursache. 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?
const game = new Game();const 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.log(stack.names());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:
draw(buffer) { let first = this.screens.length - 1; while (first > 0 && this.screens[first].isTransparent()) first--; for (let i = first; i < this.screens.length; i++) { this.screens[i].draw(buffer); }}Das Inventar sagt, dass es durchsichtig ist:
class InventoryScreen extends Screen { constructor(game) { super(game, "Inventar"); }
isTransparent() { return true; }
handle(command) { if (command === "x") this.game.stack.pop(); }
draw(buffer) { buffer.box(12, 1, 11, 3); buffer.write(13, 2, "Trank x2"); }}Nur diese beiden Änderungen. Dieselbe Schleife, dieselben Befehle:
[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.draw(buffer) { let first = this.screens.length - 1; while (first > 0 && this.screens[first].isTransparent()) first--; for (let i = first; i < this.screens.length; i++) { if (i >= 0) this.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 { constructor() { this.screens = []; }
push(screen) { this.screens.push(screen); }
pop() { this.screens.pop(); }
replace(screen) { this.pop(); this.push(screen); }
top() { return this.screens[this.screens.length - 1]; }
names() { return `[${this.screens.map((s) => s.name).join(", ")}]`; }
draw(buffer) { let first = this.screens.length - 1; while (first > 0 && this.screens[first].isTransparent()) first--; for (let i = first; i < this.screens.length; i++) { this.screens[i].draw(buffer); } }}
for await (const line of rl) { const command = line.trim(); console.log(`> ${command}`); const top = game.stack.top(); top.handle(command); top.update(); show(game, buffer);}Schritt für Schritt, in Worten
- Das Spiel beginnt mit einem Bildschirm auf dem Stapel: dem Titelbildschirm. Er liegt oben und bekommt die Eingabe.
- n geht an den Titelbildschirm. Er setzt die Karte an seine eigene Stelle (replace): Der Titelbildschirm kommt nicht zurück.
- 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.
- x geht an das Inventar: Es entfernt sich selbst (pop). Darunter liegt die Karte, genau so, wie du sie verlassen hast.
- f: Die Karte legt den Kampf obenauf (push). Der Kampf ist undurchsichtig, also wird die Karte darunter gar nicht gezeichnet.
- 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.
- 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.
- 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.
- Der Wolf fällt. Der Kampf entfernt sich selbst (pop), und die Karte liegt wieder oben, jetzt ohne den Wolf.
- 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:
draw(buffer) {
let first = this.screens.length - 1;
while (first > 0 && ) first--;
for (let i = first; i < this.screens.length; i++) {
this.screens[i].draw(buffer);
}
}Die Frage ist, ob der Bildschirm an der Position first durchsichtig ist: this.screens[first].isTransparent(). Wenn ja, muss auch der darunter gezeichnet werden.
Probier es aus
Finde den Fehler
Ein Kollege hat den Stapel umgeschrieben. Das Programm mit den Befehlen n, i, x gibt aus:
[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: 10Das 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.
Das oberste Element des Stapels ist das letzte, nicht das erste. Mit this.screens[0] geht der Befehl an die Karte ganz unten. Die Karte weiß nicht, was x bedeutet, und das Inventar hört es nie. Die Liste wächst am Ende, also arbeiten push, pop und top alle mit ihrem Ende.
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 ScreenStack { constructor() { this.screens = []; this.changes = []; }
push(screen) { this.changes.push({ kind: 'push', screen: screen, result: null }); }
pop(result) { this.changes.push({ kind: 'pop', screen: null, result: result }); }
replace(screen) { this.changes.push({ kind: 'replace', screen: screen, result: null }); }
// Leert den ganzen Stapel (Laden, Game over); mit null bleibt der Stapel leer. reset(screen) { this.changes.push({ kind: 'reset', screen: screen, result: null }); }
top() { return this.screens.length === 0 ? null : this.screens[this.screens.length - 1]; }
isEmpty() { return this.screens.length === 0 && this.changes.length === 0; }
contains(kindName) { for (const screen of this.screens) { if (screen.kindName === kindName) return true; } return false; }
apply() { let index = 0; while (index < this.changes.length) { const change = this.changes[index]; index++; if (change.kind === 'push') { this.screens.push(change.screen); } else if (change.kind === 'pop') { this.screens.pop(); const below = this.top(); if (below !== null) below.onResult(change.result); } else if (change.kind === 'replace') { this.screens.pop(); this.screens.push(change.screen); } else { this.screens = []; if (change.screen !== null) this.screens.push(change.screen); } } this.changes = []; }
// Das Zeichnen beginnt beim obersten undurchsichtigen Bildschirm und geht nach oben. draw(buffer) { let first = this.screens.length - 1; while (first > 0 && this.screens[first].isTransparent()) first--; for (let i = first; i < this.screens.length; i++) { if (i >= 0) this.screens[i].draw(buffer); } }}- push, pop und replace fassen die Liste nicht sofort an. Sie notieren die Änderung in
changes, undapply()führt sie nach dem Zug aus. In unserem Programm entfernt sich der Kampf inhandle, und gleich danach ruft die Schleifeupdatefü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).
nfimxxaa[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: 8Sieh 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 { createInterface } from "node:readline";
class TextBuffer { static W = 24; static H = 5;
constructor() { this.rows = []; }
clear() { this.rows = []; for (let y = 0; y < TextBuffer.H; y++) this.rows.push(" ".repeat(TextBuffer.W)); }
write(x, y, text) { const row = this.rows[y]; this.rows[y] = row.substring(0, x) + text + row.substring(x + text.length); }
box(x, y, w, h) { const edge = `+${"-".repeat(w - 2)}+`; this.write(x, y, edge); for (let i = 1; i < h - 1; i++) this.write(x, y + i, `|${" ".repeat(w - 2)}|`); this.write(x, y + h - 1, edge); }
print() { for (const row of this.rows) console.log(row.trimEnd()); }}
class Game { constructor() { this.stack = new ScreenStack(); this.life = 10; this.wolf = 2; }}
// JavaScript hat keine abstrakten Klassen: Hier tun handle und draw nichts,// und jeder Bildschirm schreibt sie neu.class Screen { constructor(game, name) { this.game = game; this.name = name; }
isTransparent() { return false; }
handle(command) {}
update() {}
draw(buffer) {}}
class ScreenStack { constructor() { this.screens = []; }
push(screen) { this.screens.push(screen); }
pop() { this.screens.pop(); }
replace(screen) { this.pop(); this.push(screen); }
top() { return this.screens[this.screens.length - 1]; }
names() { return `[${this.screens.map((s) => s.name).join(", ")}]`; }
draw(buffer) { let first = this.screens.length - 1; while (first > 0 && this.screens[first].isTransparent()) first--; for (let i = first; i < this.screens.length; i++) { this.screens[i].draw(buffer); } }}
class TitleScreen extends Screen { constructor(game) { super(game, "Titel"); }
handle(command) { if (command === "n") this.game.stack.replace(new MapScreen(this.game)); }
draw(buffer) { buffer.write(1, 1, "Chroniken von Arkanum"); buffer.write(1, 3, "n: Neues Spiel"); }}
class MapScreen extends Screen { static ROWS = [ "########################", "#..@......~~~..........#", "#.........~~~..........#", "########################", ];
constructor(game) { super(game, "Karte"); }
handle(command) { if (command === "i") this.game.stack.push(new InventoryScreen(this.game)); if (command === "f" && this.game.wolf > 0) this.game.stack.push(new BattleScreen(this.game)); }
draw(buffer) { MapScreen.ROWS.forEach((row, y) => buffer.write(0, y, row)); if (this.game.wolf > 0) buffer.write(8, 2, "w"); buffer.write(1, 4, `Leben: ${this.game.life}`); }}
class BattleScreen extends Screen { constructor(game) { super(game, "Kampf"); }
handle(command) { if (command === "i") this.game.stack.push(new InventoryScreen(this.game)); if (command === "a") { this.game.wolf--; if (this.game.wolf === 0) this.game.stack.pop(); } }
// In jedem Zug des Kampfes beißt der Wolf. update() { if (this.game.wolf > 0) this.game.life--; }
draw(buffer) { buffer.box(0, 0, TextBuffer.W, TextBuffer.H); buffer.write(2, 1, `Wolf: ${this.game.wolf}`); buffer.write(2, 2, `Leben: ${this.game.life}`); buffer.write(2, 3, "a: Hieb i: Inventar"); }}
class InventoryScreen extends Screen { constructor(game) { super(game, "Inventar"); }
isTransparent() { return true; }
handle(command) { if (command === "x") this.game.stack.pop(); }
draw(buffer) { buffer.box(12, 1, 11, 3); buffer.write(13, 2, "Trank x2"); }}
function show(game, buffer) { console.log(game.stack.names()); buffer.clear(); game.stack.draw(buffer); buffer.print();}
const game = new Game();const buffer = new TextBuffer();game.stack.push(new TitleScreen(game));show(game, buffer);const rl = createInterface({ input: process.stdin });for await (const line of rl) { const command = line.trim(); console.log(`> ${command}`); const 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
popablehnen, wenn nur noch ein Bildschirm auf dem Stapel liegt, und gib den Grund aus.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Polymorphie · Arrays: push, splice und sort · Stack, Queue und verkettete Liste · Pfeilfunktionen
- Aufgaben zum Thema: Die Chroniken von Arkanum