Zum Inhalt
wannacode

In der Konsole zeichnen

Kurz gesagt: Das Zeichnen in der Konsole läuft in einem Puffer aus Zellen (ein Zeichen und eine Farbe), der mit Text, Rahmen und Balken gefüllt und von einem Terminal auf einmal gezeigt wird: einfach, mit ANSI-Farben oder nur mit den geänderten Zellen.

Problem

Das Inventar landete unter der Karte

Die erste Version jedes Konsolenbildschirms druckt, während das Spiel läuft: die Karte, dann den Lebensbalken, dann die Meldungen. Der Spieler drückt i, und das Inventar soll über der Karte erscheinen, wie ein Fenster über einem Fenster:

for (const row of MAP) console.log(row);
console.log("L 24/30 M 5/12");
// Der Spieler drückt i: Das Inventar soll über der Karte erscheinen.
console.log("+- Inventar ---+");
console.log("| Trank x2 |");
console.log("| Axt |");
console.log("+--------------+");
Ausgabe
##############################
#......#..........~~~........#
#......+..........~~~...>....#
#......#..........~~~........#
####.#####...................#
#............................#
##############################
L 24/30 M 5/12
+- Inventar ---+
| Trank x2 |
| Axt |
+--------------+

Es kam darunter heraus. Die Zeilen der Karte stehen schon im Terminal, und Drucken geht nur vorwärts. Das ist das erste Problem. Dazu kommen drei weitere:

  • Das Bild reißt. Der Kampfcode druckt „Der Wolf beißt dich“ in dem Moment, in dem es passiert, und das kann mitten in der Karte sein.
  • Die Farben stecken in der Logik. Der Kampfcode weiß, welcher Code den Text rot färbt:
// Im Kampfcode:
if (plain) console.log("Der Wolf beißt dich: -3");
else console.log("\x1b[31mDer Wolf beißt dich: -3\x1b[0m");
  • Zwei Ausgaben, doppelter Code. Die Prüfung des Spiels will einfachen Text, um ihn Zeile für Zeile zu vergleichen, und der Spieler will Farben. Mit einer solchen Abfrage in jeder Zeile, die druckt, laufen die beiden Ausgaben früher oder später auseinander.

Alle vier haben eine Wurzel: Das Spiel entscheidet was es zeigt und wie es das zeigt am selben Ort im selben Moment.

Die Idee

Ein Puffer aus Zellen

Wir trennen die beiden. Das Spiel zeichnet in einen Puffer: eine Tabelle mit einer Zelle für jeden Platz auf dem Bildschirm. Eine Zelle hält ein Zeichen und eine Farbe. Beim Zeichnen wird nur in die Tabelle geschrieben, und auf den Bildschirm kommt nichts. Wenn das Bild fertig ist, wird der ganze Puffer auf einmal gezeigt.

So entscheidet die Zeichenreihenfolge, was oben liegt: Das Inventar, nach der Karte gezeichnet, überschreibt einfach seine Zellen. Und weil vor dem fertigen Bild nichts gezeigt wird, reißt auch nichts.

Der Puffer besteht aus zwei zweidimensionalen Arrays, eines für die Zeichen und eines für die Farben. Der erste Index ist die Zeile (y), der zweite die Spalte (x), weil Zeilen nacheinander gedruckt werden:

class CellBuffer {
constructor(width, height) {
this.width = width;
this.height = height;
this.chars = [];
this.colors = [];
for (let y = 0; y < height; y++) {
this.chars.push(new Array(width).fill(" "));
this.colors.push(new Array(width).fill("default"));
}
}
put(x, y, ch, color) {
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
this.chars[y][x] = ch;
this.colors[y][x] = color;
}
text(x, y, text, color) {
for (let i = 0; i < text.length; i++) this.put(x + i, y, text[i], color);
}
rowText(y) {
return this.chars[y].join("").trimEnd();
}
}
  • put schreibt eine Zelle. Liegt der Platz außerhalb des Puffers, tut es nichts.
  • text schreibt einen String Zeichen für Zeichen, jedes mit put.
  • rowText gibt eine Zeile als String zurück, ohne die Leerzeichen am Ende: Sie sind unsichtbar und machen die Ausgabe nur länger.

Eine Farbe ist nur ein Name: "red", "gray". Was sie bedeutet, entscheidet später das Terminal.

Warum bleibt put still, statt mit einem Fehler zu stoppen? Der Name des Helden ist lang, ein Rahmen sitzt am Rand, eine Meldung ist breiter als der Bildschirm. Abgeschnittener Text ist besser als ein Spiel, das wegen eines Namens abstürzt:

const buffer = new CellBuffer(12, 2);
buffer.text(0, 0, "L 24/30", "red");
buffer.text(8, 1, "Wächter", "red");
for (let y = 0; y < buffer.height; y++) console.log(buffer.rowText(y));
Ausgabe
L 24/30
Wäch

Die Prüfung heißt x >= width, nicht x > width. Die Indizes gehen von 0 bis width - 1, also liegt die Zelle width schon außerhalb der Zeile.

Genauer, für Neugierige

Python und JavaScript haben keinen eigenen Typ für ein Zeichen: Eine Zelle hält einen String der Länge 1. Lateinische Buchstaben, Buchstaben mit Akzent wie é und die Zeichen #, +, | sind je ein Zeichen im String, also ist ein Zeichen eine Zelle. Wann das nicht mehr stimmt, steht unten unter „Genauer, für Neugierige“.

Man könnte auch ein Array aus „Zellen“-Objekten mit einem Zeichen und einer Farbe nehmen. Zwei getrennte Arrays lassen sich einfacher kopieren und vergleichen, und das brauchen wir am Ende der Lektion.

Die Idee

Rahmen und Balken

Auf put und text kommen die Dinge, die das Spiel ständig zeichnet: ein leeres Rechteck, ein Rahmen mit Titel und ein Lebensbalken:

fillRect(x, y, width, height) {
for (let row = y; row < y + height; row++) {
for (let col = x; col < x + width; col++) this.put(col, row, " ", "default");
}
}
box(x, y, width, height, title) {
this.fillRect(x, y, width, height);
for (let col = x; col < x + width; col++) {
this.put(col, y, "-", "gray");
this.put(col, y + height - 1, "-", "gray");
}
for (let row = y; row < y + height; row++) {
this.put(x, row, "|", "gray");
this.put(x + width - 1, row, "|", "gray");
}
this.put(x, y, "+", "gray");
this.put(x + width - 1, y, "+", "gray");
this.put(x, y + height - 1, "+", "gray");
this.put(x + width - 1, y + height - 1, "+", "gray");
if (title.length > 0) this.text(x + 2, y, " " + title + " ", "yellow");
}
bar(x, y, width, value, max, color) {
let filled = max > 0 ? Math.floor((value * width) / max) : 0;
if (value > 0 && filled === 0) filled = 1;
if (filled > width) filled = width;
this.put(x, y, "[", "gray");
for (let i = 0; i < width; i++) {
if (i < filled) this.put(x + 1 + i, y, "#", color);
else this.put(x + 1 + i, y, "-", "gray");
}
this.put(x + width + 1, y, "]", "gray");
}

box leert zuerst seine Fläche mit fillRect: Sonst würde die Karte darunter zwischen den Buchstaben durchscheinen. Dann zeichnet es den Rand: - oben und unten, | an den Seiten, + in den Ecken. Der Titel sitzt auf dem oberen Rand, mit je einem Leerzeichen links und rechts.

bar rechnet aus, wie viele Plätze gefüllt werden: Wert × Breite / Maximum, mit ganzzahliger Division. Die Division rundet ab, also ist der Balken nur bei vollem Leben voll: 29 von 30 sieht nicht aus wie 30 von 30. Dazu gibt es eine Regel: Ein lebender Held sieht nie einen leeren Balken. Bei 1 von 30 ergibt die Division 0, und der Balken zeigt einen Platz.

const buffer = new CellBuffer(20, 4);
const values = [30, 15, 1, 0];
values.forEach((value, i) => {
buffer.bar(0, i, 10, value, 30, "red");
buffer.text(13, i, value + "/30", "default");
});
for (let y = 0; y < buffer.height; y++) console.log(buffer.rowText(y));
Ausgabe
[##########] 30/30
[#####-----] 15/30
[#---------] 1/30
[----------] 0/30
Genauer, für Neugierige

Warum nicht auf den nächsten Wert runden? Dann ergeben 29 von 30 genau 10 Plätze (ein voller Balken für einen verwundeten Helden), und 1 von 30 ergibt 0 (ein leerer Balken für einen lebenden). Die beiden Extreme sind genau die Stellen, an denen der Spieler auf den Balken schaut. Mit der ganzzahligen Division und der Regel „mindestens ein Platz“ heißt ein voller Balken volles Leben und ein leerer Balken Tod.

Die Breite in bar ist nur der füllbare Teil: Der Balken nimmt wegen [ und ] width + 2 Zellen ein.

Probier es aus

Was kommt heraus?

Der Held hat 29 Leben von 30 und der Diener neben ihm 1 von 30. Was druckt das Programm?

const buffer = new CellBuffer(12, 2);
buffer.bar(0, 0, 10, 29, 30, "red");
buffer.bar(0, 1, 10, 1, 30, "red");
for (let y = 0; y < buffer.height; y++) console.log(buffer.rowText(y));
Antwort

Probier es aus

Der rechte Rand

Das ist die Schleife in box, die die Seitenränder zeichnet. Der linke liegt in der Spalte x. In welcher Spalte liegt der rechte?

for (let row = y; row < y + height; row++) {
  this.put(x, row, "|", "gray");
  this.put(, row, "|", "gray");
}

Sieh es dir an

Die Karte, das Inventar und der Balken

Hier ist das ganze Bild von Anfang an: die Karte, der Held, der Wolf, das Inventar über der Karte und die Statuszeile unten. Der Puffer wird erst am Ende gedruckt, Zeile für Zeile:

const buffer = new CellBuffer(30, 9);
for (let y = 0; y < MAP.length; y++) buffer.text(0, y, MAP[y], "gray");
buffer.put(3, 2, "@", "white");
buffer.put(25, 4, "w", "red");
buffer.box(7, 1, 15, 5, "Inventar");
buffer.text(9, 2, "Trank x2", "red");
buffer.text(9, 3, "Axt", "default");
buffer.text(9, 4, "Gold 35", "yellow");
buffer.text(0, 7, "Iva, Hexe St.2", "white");
buffer.text(0, 8, "L", "red");
buffer.bar(2, 8, 10, 24, 30, "red");
buffer.text(15, 8, "24/30", "default");
buffer.text(21, 8, "M", "blue");
buffer.bar(23, 8, 5, 5, 12, "blue");
for (let y = 0; y < buffer.height; y++) console.log(buffer.rowText(y));
Ausgabe
##############################
#......+- Inventar --+.......#
#..@...| Trank x2 |..>....#
#......| Axt |.......#
####.##| Gold 35 |...w...#
#......+-------------+.......#
##############################
Iva, Hexe St.2
L [########--] 24/30 M [##---]

Das Inventar liegt über der Karte, und rechts daneben ist der Wolf noch zu sehen, weil der Rahmen nur seine eigenen Zellen bedeckt. Die Zeilen im Code stehen nicht in der Reihenfolge der Zeilen auf dem Bildschirm: Die Statuszeile hätte zuerst gezeichnet werden können, und das Bild wäre dasselbe.

Die Idee

Das Terminal entscheidet, wie

Es bleibt die Frage, wie der Puffer auf den Bildschirm kommt. Die Antwort hängt davon ab, wer hinschaut: Die Prüfung will einfachen Text, der Spieler will Farben, der Browser will HTML. Der Puffer druckt sich also nicht selbst. Ein Terminal druckt, und jedes Terminal hat eine einzige Methode: present(buffer), „zeig dieses Bild“.

class PlainTerminal {
constructor() {
this.lastFrame = "";
}
present(buffer) {
const lines = [];
for (let y = 0; y < buffer.height; y++) lines.push(buffer.rowText(y));
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
const frame = lines.join("\n") + "\n----\n";
if (frame === this.lastFrame) return;
write(frame);
this.lastFrame = frame;
}
}
class AnsiTerminal {
present(buffer) {
let frame = ESC + "[2J" + ESC + "[H";
for (let y = 0; y < buffer.height; y++) {
const row = buffer.rowText(y);
let color = "default";
for (let x = 0; x < row.length; x++) {
if (buffer.colors[y][x] !== color) {
color = buffer.colors[y][x];
frame += CODES[color];
}
frame += row[x];
}
frame += ESC + "[0m\n";
}
write(frame);
}
}

PlainTerminal druckt die Zeilen mit rowText, lässt die leeren Zeilen am Ende weg und hängt ---- als Ende des Bildes an. Ist das Bild dasselbe wie das letzte, druckt es nichts: Ein Befehl, der nichts ändert (ein falscher Befehl, ein Blick auf etwas schon Gesehenes), soll die Ausgabe nicht mit gleichen Bildern füllen.

AnsiTerminal sendet Codes, die das Terminal ausführt, statt sie zu zeigen. Jeder beginnt mit dem Zeichen ESC (Code 27):

  • ESC[2J löscht den Bildschirm, ESC[H setzt den Cursor nach oben links;
  • ESC[31m heißt „ab hier rot“, ESC[90m grau, ESC[33m gelb, ESC[0m „zurück zur normalen Farbe“.

Eine Farbe im Terminal ist Zustand: Sie bleibt, bis du sie änderst. Deshalb schreibt AnsiTerminal einen Code nur dort, wo sich die Farbe einer Zelle von der davor unterscheidet, und kehrt am Ende jeder Zeile zur normalen Farbe zurück.

JavaScript hat keine Interfaces. Beide Klassen haben eine Methode present mit denselben Parametern, und das reicht: Das Spiel ruft present auf, ohne zu fragen, was für ein Terminal es ist (Duck Typing).

In den Programmen dieser Lektion stehen ESC, die Codes und die Ausgabe an das Terminal hier:

const ESC = "\x1b";
const CODES = {
default: ESC + "[0m", red: ESC + "[31m", yellow: ESC + "[33m",
blue: ESC + "[34m", gray: ESC + "[90m", white: ESC + "[97m",
};
// An das Terminal. ESC wird als \e gedruckt, damit man es in der Ausgabe sieht.
function write(text) {
process.stdout.write(text.replaceAll(ESC, "\\e"));
}

write druckt ESC als den Text \e, damit du es in der Ausgabe auf dieser Seite sehen kannst. In einem echten Terminal steht dort das Zeichen selbst, und statt \e[31m siehst du Rot.

Sieh es dir an

Ein Puffer, zwei Terminals

const buffer = new CellBuffer(30, 9);
for (let y = 0; y < MAP.length; y++) buffer.text(0, y, MAP[y], "gray");
buffer.put(3, 2, "@", "white");
buffer.put(25, 4, "w", "red");
buffer.box(7, 1, 15, 5, "Inventar");
buffer.text(9, 2, "Trank x2", "red");
buffer.text(9, 3, "Axt", "default");
buffer.text(9, 4, "Gold 35", "yellow");
buffer.text(0, 7, "Iva, Hexe St.2", "white");
buffer.text(0, 8, "L", "red");
buffer.bar(2, 8, 10, 24, 30, "red");
buffer.text(15, 8, "24/30", "default");
buffer.text(21, 8, "M", "blue");
buffer.bar(23, 8, 5, 5, 12, "blue");
const plain = new PlainTerminal();
const ansi = new AnsiTerminal();
plain.present(buffer);
plain.present(buffer); // dasselbe Bild: wird nicht noch einmal gedruckt
ansi.present(buffer);
Schritt für Schritt, in Worten
  1. Ein leerer Puffer: 30 × 9 = 270 Zellen, jede ein Leerzeichen in der Farbe default. Es wurde noch nichts gedruckt.
  2. Die Karte: text schreibt jede Zeile Zeichen für Zeichen, und jedes Zeichen ist ein put in eine Zelle. 7 Zeilen zu je 30, alle grau.
  3. put schreibt ein Zeichen mit einer Farbe: der Held @ bei (3, 2), weiß, und der Wolf w bei (25, 4), rot. Die Zelle merkt sich beides.
  4. box leert zuerst seine 15 × 5 Zellen (fillRect) und zeichnet dann den Rand und den Titel. Die Karte darunter ist im Puffer überschrieben, nicht auf dem Bildschirm: Der Bildschirm ist noch leer.
  5. Die Gegenstände kommen in den Rahmen: Trank x2, Axt, Gold 35. Die Zeichenreihenfolge entscheidet, was oben liegt: Das Inventar kommt nach der Karte.
  6. Die Statuszeile: 24 von 30 auf 10 Plätzen sind 8 #, und 5 von 12 auf 5 Plätzen sind 2. Das ganze Bild ist fertig, und das Terminal hat noch nichts davon gesehen.
  7. PlainTerminal: nur die Zeichen, Zeile für Zeile, ohne die Leerzeichen am Ende (Zeile y = 7 hat 14 Zeichen, nicht 30), und ---- als Ende des Bildes. Die Farben gehen verloren.
  8. Derselbe Puffer noch einmal: PlainTerminal vergleicht das Bild mit dem zuletzt gedruckten und druckt nichts.
  9. AnsiTerminal: derselbe Puffer, in Farbe. Ein Farbcode steht nur dort, wo sich die Farbe ändert: 35 Codes für 9 Zeilen, nicht einer pro Zelle.
  10. Das Spiel hat einmal gezeichnet. Welches Terminal es zeigt, entscheidet eine Zeile am Start, und der Zeichencode weiß es nicht.

Das Programm zeichnet das Bild von oben und übergibt es beiden Terminals. PlainTerminal bekommt es zweimal, druckt es aber nur einmal:

const plain = new PlainTerminal();
const ansi = new AnsiTerminal();
plain.present(buffer);
plain.present(buffer); // dasselbe Bild: wird nicht noch einmal gedruckt
ansi.present(buffer);
Ausgabe
##############################
#......+- Inventar --+.......#
#..@...| Trank x2 |..>....#
#......| Axt |.......#
####.##| Gold 35 |...w...#
#......+-------------+.......#
##############################
Iva, Hexe St.2
L [########--] 24/30 M [##---]
----
\e[2J\e[H\e[90m##############################\e[0m
\e[90m#......+-\e[33m Inventar \e[90m--+.......#\e[0m
\e[90m#..\e[97m@\e[90m...|\e[0m \e[31mTrank x2\e[0m \e[90m|..>....#\e[0m
\e[90m#......|\e[0m Axt \e[90m|.......#\e[0m
\e[90m####.##|\e[0m \e[33mGold 35\e[0m \e[90m|...\e[31mw\e[90m...#\e[0m
\e[90m#......+-------------+.......#\e[0m
\e[90m##############################\e[0m
\e[97mIva, Hexe St.2\e[0m
\e[31mL\e[0m \e[90m[\e[31m########\e[90m--]\e[0m 24/30 \e[34mM\e[0m \e[90m[\e[34m##\e[90m---]\e[0m

Sieh, wie selten in der Ausgabe von AnsiTerminal ein Code auftaucht: 35 Codes für 270 Zellen. Eine Zeile grauer Karte ist ein einziger Code, und das \e[0m mitten im Bild zeigt, wo die geleerten Zellen die normale Farbe haben.

Probier es aus

Finde den Fehler

Ein Kollege hat AnsiTerminal angefasst, und die Meldung unter dem Lebensbalken kam grau heraus, obwohl sie in der normalen Farbe sein sollte. Das sendet das Programm (\e ist ESC):

const buffer = new CellBuffer(30, 2);
buffer.text(0, 0, "L", "red");
buffer.bar(2, 0, 10, 24, 30, "red");
buffer.text(0, 1, "Der Wolf beißt dich.", "default");
new AnsiTerminal().present(buffer);
Ausgabe
\e[2J\e[H\e[31mL\e[0m \e[90m[\e[31m########\e[90m--]
Der Wolf beißt dich.

In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Nur das Geänderte

PlainTerminal überspringt schon ein Bild, das dem letzten gleicht. Das spart auf der Ebene eines Bildes: alles oder nichts. Aber in einem rundenbasierten Spiel ändern die meisten Züge zwei oder drei Zellen: Der Held geht einen Schritt, und die anderen 250 bleiben gleich. AnsiTerminal löscht trotzdem den Bildschirm und sendet alles noch einmal.

Ein besseres Terminal merkt sich, was es gezeigt hat (ein zweiter Puffer, last), und vergleicht Zelle für Zelle. Für jede Zelle, die sich unterscheidet, bewegt es den Cursor mit ESC[Zeile;SpalteH und schreibt nur diese Zelle:

class DiffTerminal {
constructor() {
this.last = null;
this.cells = 0;
}
present(buffer) {
write(this.changes(buffer));
}
changes(buffer) {
let frame = "";
if (this.last === null) {
this.last = new CellBuffer(buffer.width, buffer.height);
frame = ESC + "[2J";
}
this.cells = 0;
for (let y = 0; y < buffer.height; y++) {
for (let x = 0; x < buffer.width; x++) {
const ch = buffer.chars[y][x];
const color = buffer.colors[y][x];
if (ch === this.last.chars[y][x] && color === this.last.colors[y][x]) continue;
frame += ESC + "[" + (y + 1) + ";" + (x + 1) + "H";
frame += CODES[color] + ch;
this.last.put(x, y, ch, color);
this.cells++;
}
}
if (this.cells > 0) frame += CODES["default"];
return frame;
}
}

Zwei Details erwischen jeden, der das zum ersten Mal schreibt. Zeile und Spalte in ESC[Zeile;SpalteH beginnen bei 1, nicht bei 0, und die Zeile kommt zuerst. Und last wird sofort mit der gesendeten Zelle aktualisiert: Sonst sendet das nächste Bild dieselben Zellen noch einmal.

Das Programm zeichnet die Welt ohne das Inventar. Dann geht der Held einen Schritt, und der Wolf beißt. show druckt, wie viele Zellen und wie viele Zeichen jedes Bild sendet, und die kurzen Updates ganz:

const buffer = new CellBuffer(30, 9);
const screen = new DiffTerminal();
drawWorld(buffer);
show(1, screen, buffer);
buffer.put(3, 2, ".", "gray"); // der Held geht einen Schritt nach rechts
buffer.put(4, 2, "@", "white");
show(2, screen, buffer);
buffer.bar(2, 8, 10, 21, 30, "red"); // der Wolf beißt: 24 → 21
buffer.text(15, 8, "21/30", "default");
show(3, screen, buffer);
show(4, screen, buffer); // nichts hat sich geändert
Ausgabe
Bild 1: 250 Zellen, 3173 Zeichen
Bild 2: 2 Zellen, 28 Zeichen
\e[3;4H\e[90m.\e[3;5H\e[97m@\e[0m
Bild 3: 2 Zellen, 29 Zeichen
\e[9;11H\e[90m-\e[9;17H\e[0m1\e[0m
Bild 4: 0 Zellen, 0 Zeichen
  • Bild 1 sendet 250 Zellen und 3173 Zeichen: Der Bildschirm ist leer, und alles ist neu. Das ist mehr als das ganze Bild von AnsiTerminal (418 Zeichen), weil jede Zelle ihre eigene Cursorbewegung und ihre eigene Farbe mitbringt.
  • Der Schritt sind 2 Zellen und 28 Zeichen: Der alte Platz wird zu ., der neue zu @.
  • Der Biss ändert ein # im Balken und eine Ziffer: Von 24 auf 21 ändert sich nur die 4.
  • Bild 4 sendet nichts. Dass PlainTerminal ein gleiches Bild überspringt, ist dieselbe Idee, reduziert auf „0 Zellen“.

Warum lohnt sich das? Wenn das Terminal über ein Netz hängt (ssh, ein Server, auf dem mehrere Leute spielen), geht jedes Zeichen über das Netz: 28 Zeichen statt der 418, die AnsiTerminal für ein ganzes Bild sendet, ist ein Unterschied, den man in jedem Zug spürt. Langsame Terminals zeichnen Zeichen für Zeichen, und ESC[2J zeigt zuerst einen leeren Bildschirm und füllt ihn dann von oben: Der Bildschirm flackert. Ein Update an Ort und Stelle flackert nicht.

Bild 1 zeigt auch die andere Seite: Wenn sich fast alles ändert (ein neuer Bildschirm, das Inventar öffnet sich), ist ein ganzes Bild billiger als Zelle für Zelle. Deshalb senden Terminal-Bibliotheken (wie curses) bei einer großen Änderung den ganzen Bildschirm und bei einer kleinen nur den Unterschied.

Die Idee

Im echten Spiel

Das kanonische Spiel hat dieselben Teile. Sein Puffer ist 60 × 18 und hat auch clear(): In jedem Zug zeichnet der Stapel der Bildschirme alles noch einmal in einen geleerten Puffer.

// Das Spiel druckt nicht direkt: Es zeichnet in einen Puffer aus Zellen (Zeichen + Farbe), und das Terminal entscheidet, wie
// es gezeigt wird. So wird dieselbe Zeichnung zu einfachem Text und zu einem farbigen ANSI-Bildschirm.
const SCREEN_WIDTH = 60;
const SCREEN_HEIGHT = 18;
const VIEW_WIDTH = 40;
const VIEW_HEIGHT = 13;
class CellBuffer {
constructor(width, height) {
this.width = width;
this.height = height;
this.chars = [];
this.colors = [];
for (let y = 0; y < height; y++) {
this.chars.push(new Array(width).fill(' '));
this.colors.push(new Array(width).fill('default'));
}
}
clear() {
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
this.chars[y][x] = ' ';
this.colors[y][x] = 'default';
}
}
}
put(x, y, ch, color) {
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
this.chars[y][x] = ch;
this.colors[y][x] = color;
}
text(x, y, text, color) {
for (let i = 0; i < text.length; i++) this.put(x + i, y, text.charAt(i), color);
}
fillRect(x, y, width, height) {
for (let row = y; row < y + height; row++) {
for (let col = x; col < x + width; col++) this.put(col, row, ' ', 'default');
}
}
// Ein Rahmen mit leerem Inneren: durchsichtige Bildschirme (Menü, Inventar) werden im Rahmen über die Karte gezeichnet.
box(x, y, width, height, title) {
this.fillRect(x, y, width, height);
for (let col = x; col < x + width; col++) {
this.put(col, y, '-', 'gray');
this.put(col, y + height - 1, '-', 'gray');
}
for (let row = y; row < y + height; row++) {
this.put(x, row, '|', 'gray');
this.put(x + width - 1, row, '|', 'gray');
}
this.put(x, y, '+', 'gray');
this.put(x + width - 1, y, '+', 'gray');
this.put(x, y + height - 1, '+', 'gray');
this.put(x + width - 1, y + height - 1, '+', 'gray');
if (title.length > 0) this.text(x + 2, y, ' ' + title + ' ', 'yellow');
}
// Balken [#####-----]: Der gefüllte Teil ist proportional zum Wert.
bar(x, y, width, value, max, color) {
let filled = max > 0 ? Math.floor((value * width) / max) : 0;
if (value > 0 && filled === 0) filled = 1;
if (filled > width) filled = width;
this.put(x, y, '[', 'gray');
for (let i = 0; i < width; i++) {
if (i < filled) this.put(x + 1 + i, y, '#', color);
else this.put(x + 1 + i, y, '-', 'gray');
}
this.put(x + width + 1, y, ']', 'gray');
}
rowText(y) {
let end = this.width;
while (end > 0 && this.chars[y][end - 1] === ' ') end--;
let line = '';
for (let x = 0; x < end; x++) line += this.chars[y][x];
return line;
}
}

Die beiden Terminals:

// Zwei Terminals mit derselben Schnittstelle: present(buffer) zeigt das Bild, echo(line) zeigt den Befehl.
// PlainTerminal druckt Text (für die Prüfung und die Lektionen) und überspringt ein Bild, das dem vorigen gleicht.
class PlainTerminal {
constructor(io) {
this.io = io;
this.lastFrame = '';
}
present(buffer) {
const lines = [];
for (let y = 0; y < buffer.height; y++) lines.push(buffer.rowText(y));
while (lines.length > 0 && lines[lines.length - 1].length === 0) lines.pop();
const frame = lines.join('\n') + '\n----\n';
if (frame !== this.lastFrame) {
this.io.write(frame);
this.lastFrame = frame;
}
}
echo(line) {
this.io.write('> ' + line + '\n');
}
}
const ANSI_CODES = new Map([
['default', '\x1b[0m'], ['red', '\x1b[31m'], ['green', '\x1b[32m'], ['yellow', '\x1b[33m'],
['blue', '\x1b[34m'], ['magenta', '\x1b[35m'], ['cyan', '\x1b[36m'], ['white', '\x1b[1;37m'], ['gray', '\x1b[90m'],
]);
// AnsiTerminal löscht den Bildschirm und fügt Farbe hinzu: Blut ist rot, Magie blau, Gift grün.
class AnsiTerminal {
constructor(io) {
this.io = io;
}
present(buffer) {
let out = '\x1b[2J\x1b[H';
for (let y = 0; y < buffer.height; y++) {
const end = buffer.rowText(y).length;
let color = 'default';
for (let x = 0; x < end; x++) {
if (buffer.colors[y][x] !== color) {
color = buffer.colors[y][x];
out += '\x1b[0m' + ANSI_CODES.get(color);
}
out += buffer.chars[y][x];
}
out += '\x1b[0m\n';
}
this.io.write(out + '> ');
}
echo(line) {}
}

Es gibt drei Unterschiede zur Lektion, und jeder hat einen Grund:

  • echo(line). PlainTerminal schreibt den Befehl (> w) nach dem Bild: So liest sich der Durchlauf, mit dem das Spiel geprüft wird, wie ein Gespräch. AnsiTerminal schreibt ihn nicht, weil der Spieler sieht, was er getippt hat.
  • ESC[0m vor jeder Farbe. Weiß ist im Spiel 1;37: fett und weiß. Ohne Zurücksetzen bleibt das Fett auch in der nächsten Farbe an. In der Lektion ändern alle Codes nur die Farbe, deshalb braucht es das Zurücksetzen nicht.
  • Kein DiffTerminal. Das Spiel ist rundenbasiert: ein Bild pro Befehl, und der Spieler wartet sowieso, bis er den nächsten tippt. Geht es irgendwann über ein Netz, ist DiffTerminal eine dritte Klasse, und sonst ändert sich nichts.

Welches Terminal es ist, entscheidet eine Zeile am Anfang der Spielschleife: Mit --plain ist es PlainTerminal, sonst AnsiTerminal. Die Statuszeile ist eine Funktion, die in den Puffer zeichnet und nichts über das Terminal weiß:

// Die untere Leiste: Name, Klasse, Stufe, Leben und Mana mit Balken, Gold, Ort. Die letzten drei Meldungen.
function drawHud(buffer, game, row) {
const hero = game.hero;
const next = xpForLevel(hero.level + 1);
buffer.text(0, row, hero.name + ', ' + nm(hero.heroClass()) + ' ' + t('hud.level', { level: hero.level }) + ' ' +
t('hud.xp', { xp: hero.xp, next: next }) + ' ' + t('hud.gold', { gold: hero.gold }), 'white');
buffer.text(0, row + 1, t('hud.hp'), 'red');
buffer.bar(2, row + 1, 12, hero.hp, hero.maxHp(), 'red');
buffer.text(17, row + 1, hero.hp + '/' + hero.maxHp(), 'default');
buffer.text(25, row + 1, t('hud.mana'), 'blue');
buffer.bar(27, row + 1, 8, hero.mana, hero.maxMana(), 'blue');
buffer.text(38, row + 1, hero.mana + '/' + hero.maxMana(), 'default');
buffer.text(45, row + 1, effectLabels(hero), 'green');
}
function drawMessages(buffer, game, row, count) {
const first = Math.max(0, game.messages.length - count);
for (let i = first; i < game.messages.length; i++) buffer.text(0, row + i - first, game.messages[i], 'default');
}
function effectLabels(creature) {
const labels = [];
for (const effect of creature.effects) labels.push(effect.label());
return labels.join(' ');
}
// Die Ziffer nach einem Befehl: „d5“ sind fünf Schritte nach rechts. Liefert null, wenn es keine Zahl ist.
function commandNumber(command, start) {
if (command.length <= start) return null;
return parseIntStrict(command.substring(start));
}

Am Ende der Region steht auch ein kleiner Helfer für Befehle (d5 sind fünf Schritte nach rechts): Er liegt hier, weil Statuszeile und Befehle beide zur Karte gehören.

Genauer

Puffer, Breiten, Farben

Double Buffering. last in DiffTerminal ist das, was die Grafik einen Doppelpuffer nennt: Ein Puffer ist der auf dem Bildschirm, und das nächste Bild wird in den anderen gezeichnet. Bei Spielen mit Grafikkarte werden die beiden auf einmal getauscht, damit nie ein halbes Bild zu sehen ist. Hier zeichnen wir das ganze Bild, bevor wir present aufrufen, also sieht der Spieler nie ein halbes Bild.

Die Breite von Zeichen. In einem typischen Terminal nehmen kyrillische Buchstaben und die Rahmenzeichen (─, │, ┌) je eine Spalte ein. Der Unicode-Standard gibt beiden aber eine „mehrdeutige“ Breite: Ein Terminal, das für Chinesisch oder Japanisch eingerichtet ist, kann sie in zwei Spalten zeichnen. Emoji und CJK-Ideogramme nehmen immer zwei. Dazu kommt ein zweites Problem: Emoji liegen außerhalb der Basisebene von Unicode, und in Java, C# und JavaScript hat ein String mit einem Emoji die Länge 2 (zwei Hälften in UTF-16), in Python dagegen 1. Ein Puffer, der ein Zeichen des Strings pro Zelle schreibt, schneidet es entzwei. Deshalb zeichnet das kanonische Spiel seine Rahmen in ASCII: +, -, |, # sind überall eine Spalte und in jeder Sprache ein Zeichen.

16, 256 und Millionen Farben. ESC[31m bis ESC[37m und ESC[90m bis ESC[97m sind die 16 Grundfarben. Ihren genauen Ton wählt das Farbschema des Terminals, damit das Rot auf hellem wie auf dunklem Hintergrund lesbar bleibt. Es gibt auch ESC[38;5;Nm (eine Palette aus 256) und ESC[38;2;R;G;Bm (eine genaue Farbe), aber nicht jedes Terminal versteht sie, und eine genaue Farbe kann mit dem Hintergrund von jemandem verschmelzen. Das Spiel benutzt die 16.

Windows und umgeleitete Ausgabe. Windows Terminal versteht ANSI-Codes. Das alte Konsolenfenster versteht sie ab Windows 10, aber das Programm muss sie einschalten (der Modus ENABLE_VIRTUAL_TERMINAL_PROCESSING), sonst zeigt der Bildschirm ←[31m. Außerdem sind die Codes Müll in der Datei, wenn die Ausgabe in eine Datei umgeleitet wird. Programme prüfen, ob sie in ein Terminal schreiben: process.stdout.isTTY. Das Spiel überlässt die Wahl dem Spieler: --plain.

Übung

Das Kampffenster

Löse das in deinem eigenen Editor, mit dem CellBuffer aus der Lektion.

Das Kampffenster. Zeichne in einen Puffer von 36 × 5 einen Rahmen um den ganzen Puffer mit dem Titel „Kampf“. Darin steht eine Zeile pro Gegner: der Name ab Spalte 2, ein Balken der Breite 10 ab Spalte 17 und das Leben als aktuell/max ab Spalte 30. Ein toter Gegner (Leben 0) ist grau und hat keinen Balken und kein Leben, nur tot ab Spalte 17. Drucke den Puffer Zeile für Zeile.

const names = ["Blutwolf", "Riesenratte", "Speergoblin"];
const hp = [20, 1, 0];
const maxHp = [20, 9, 13];
Ausgabe
+- Kampf --------------------------+
| Blutwolf [##########] 20/20|
| Riesenratte [#---------] 1/9 |
| Speergoblin tot |
+----------------------------------+

Weitere Aufgaben

  • Ein Protokoll im Rahmen. Eine Funktion, die die letzten N Kampfmeldungen in einen Rahmen zeichnet und die langen auf seine Breite kürzt, mit ... am Ende.
  • Weniger Bewegungen. Sende in DiffTerminal kein ESC[Zeile;SpalteH, wenn die geänderte Zelle direkt auf die vorherige in derselben Zeile folgt: Der Cursor steht schon dort. Wie viele Zeichen sparst du im ersten Bild?
  • Ein drittes Terminal. HtmlTerminal, das das Bild als <pre> mit einem <span style="color: …"> bei jedem Farbwechsel zurückgibt. Das Spiel ändert sich nicht.

Test

Teste dich

1. Warum zeichnet das Spiel in einen Puffer, statt sofort zu drucken?
2. Warum sendet AnsiTerminal einen Farbcode nur dort, wo sich die Farbe ändert?
3. Du willst, dass das Spiel im Browser läuft und die Farben <span>-Elemente sind. Was änderst du?
4. Der Held geht einen Schritt nach rechts. Was sendet DiffTerminal?
5. Ein Kollege hat die Prüfung in put zu x > width geändert. Was passiert mit Text, der den rechten Rand erreicht?

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