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:
foreach (string row in MAP) Console.WriteLine(row);Console.WriteLine("L 24/30 M 5/12");// Der Spieler drückt i: Das Inventar soll über der Karte erscheinen.Console.WriteLine("+- Inventar ---+");Console.WriteLine("| Trank x2 |");Console.WriteLine("| Axt |");Console.WriteLine("+--------------+");###############################......#..........~~~........##......+..........~~~...>....##......#..........~~~........#####.#####...................##............................###############################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.WriteLine("Der Wolf beißt dich: -3");else Console.WriteLine("\u001b[31mDer Wolf beißt dich: -3\u001b[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{ public readonly int Width; public readonly int Height; public readonly char[][] Chars; public readonly string[][] Colors;
public CellBuffer(int width, int height) { Width = width; Height = height; Chars = new char[height][]; Colors = new string[height][]; for (int y = 0; y < height; y++) { Chars[y] = new char[width]; Colors[y] = new string[width]; Array.Fill(Chars[y], ' '); Array.Fill(Colors[y], "default"); } }
public void Put(int x, int y, char ch, string color) { if (x < 0 || y < 0 || x >= Width || y >= Height) return; Chars[y][x] = ch; Colors[y][x] = color; }
public void Text(int x, int y, string text, string color) { for (int i = 0; i < text.Length; i++) Put(x + i, y, text[i], color); }
public string RowText(int y) => new string(Chars[y]).TrimEnd(' ');}Putschreibt eine Zelle. Liegt der Platz außerhalb des Puffers, tut es nichts.Textschreibt einen String Zeichen für Zeichen, jedes mitPut.RowTextgibt 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:
var buffer = new CellBuffer(12, 2);buffer.Text(0, 0, "L 24/30", "red");buffer.Text(8, 1, "Wächter", "red");for (int y = 0; y < buffer.Height; y++) Console.WriteLine(buffer.RowText(y));L 24/30 WächDie 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
Ein Zeichen ist ein char: eine UTF-16-Einheit. Lateinische Buchstaben, Buchstaben mit Akzent wie é und die Zeichen #, +, | sind je ein char, also ist ein Zeichen des Strings 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:
public void FillRect(int x, int y, int width, int height){ for (int row = y; row < y + height; row++) { for (int col = x; col < x + width; col++) Put(col, row, ' ', "default"); }}
public void Box(int x, int y, int width, int height, string title){ FillRect(x, y, width, height); for (int col = x; col < x + width; col++) { Put(col, y, '-', "gray"); Put(col, y + height - 1, '-', "gray"); } for (int row = y; row < y + height; row++) { Put(x, row, '|', "gray"); Put(x + width - 1, row, '|', "gray"); } Put(x, y, '+', "gray"); Put(x + width - 1, y, '+', "gray"); Put(x, y + height - 1, '+', "gray"); Put(x + width - 1, y + height - 1, '+', "gray"); if (title.Length > 0) Text(x + 2, y, " " + title + " ", "yellow");}
public void Bar(int x, int y, int width, int value, int max, string color){ int filled = max > 0 ? value * width / max : 0; if (value > 0 && filled == 0) filled = 1; if (filled > width) filled = width; Put(x, y, '[', "gray"); for (int i = 0; i < width; i++) { if (i < filled) Put(x + 1 + i, y, '#', color); else Put(x + 1 + i, y, '-', "gray"); } 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.
var buffer = new CellBuffer(20, 4);int[] values = { 30, 15, 1, 0 };for (int i = 0; i < values.Length; i++){ buffer.Bar(0, i, 10, values[i], 30, "red"); buffer.Text(13, i, values[i] + "/30", "default");}for (int y = 0; y < buffer.Height; y++) Console.WriteLine(buffer.RowText(y));[##########] 30/30[#####-----] 15/30[#---------] 1/30[----------] 0/30Genauer, 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?
var buffer = new CellBuffer(12, 2);buffer.Bar(0, 0, 10, 29, 30, "red");buffer.Bar(0, 1, 10, 1, 30, "red");for (int y = 0; y < buffer.Height; y++) Console.WriteLine(buffer.RowText(y));29 × 10 / 30 ist 9,67, und die ganzzahlige Division ergibt 9: Der Balken ist nicht voll, weil der Held es auch nicht ist. 1 × 10 / 30 ergibt 0, aber der Diener lebt, also zeigt der Balken einen Platz.
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 (int row = y; row < y + height; row++)
{
Put(x, row, '|', "gray");
Put(, row, '|', "gray");
}Ein Rahmen mit der Breite width nimmt die Spalten von x bis x + width - 1 ein. Die Spalte x + width ist die erste hinter dem Rahmen: Damit wird der Rahmen eine Spalte breiter, und die rechten Ecken liegen nicht am Rand.
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:
var buffer = new CellBuffer(30, 9);for (int 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 (int y = 0; y < buffer.Height; y++) Console.WriteLine(buffer.RowText(y));###############################......+- Inventar --+.......##..@...| Trank x2 |..>....##......| Axt |.......#####.##| Gold 35 |...w...##......+-------------+.......###############################Iva, Hexe St.2L [########--] 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“.
interface ITerminal{ void Present(CellBuffer buffer);}
class PlainTerminal : ITerminal{ string lastFrame = "";
public void Present(CellBuffer buffer) { var lines = new List<string>(); for (int y = 0; y < buffer.Height; y++) lines.Add(buffer.RowText(y)); while (lines.Count > 0 && lines[^1].Length == 0) lines.RemoveAt(lines.Count - 1); string frame = string.Join("\n", lines) + "\n----\n"; if (frame == lastFrame) return; Write(frame); lastFrame = frame; }}
class AnsiTerminal : ITerminal{ public void Present(CellBuffer buffer) { string frame = ESC + "[2J" + ESC + "[H"; for (int y = 0; y < buffer.Height; y++) { string row = buffer.RowText(y); string color = "default"; for (int 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[2Jlöscht den Bildschirm,ESC[Hsetzt den Cursor nach oben links;ESC[31mheißt „ab hier rot“,ESC[90mgrau,ESC[33mgelb,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.
Das Spiel hält ein ITerminal und ruft present auf, ohne zu wissen, welche Klasse darunter liegt. Welche es ist, entscheidet eine Zeile am Start.
In den Programmen dieser Lektion stehen ESC, die Codes und die Ausgabe an das Terminal hier:
const string ESC = "\u001b";static readonly Dictionary<string, string> CODES = new(){ ["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.static void Write(string text) => Console.Write(text.Replace(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
var buffer = new CellBuffer(30, 9);for (int 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");
ITerminal plain = new PlainTerminal();ITerminal ansi = new AnsiTerminal();plain.Present(buffer);plain.Present(buffer); // dasselbe Bild: wird nicht noch einmal gedrucktansi.Present(buffer);Schritt für Schritt, in Worten
- Ein leerer Puffer: 30 × 9 = 270 Zellen, jede ein Leerzeichen in der Farbe default. Es wurde noch nichts gedruckt.
- 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.
- 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.
- 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.
- Die Gegenstände kommen in den Rahmen: Trank x2, Axt, Gold 35. Die Zeichenreihenfolge entscheidet, was oben liegt: Das Inventar kommt nach der Karte.
- 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.
- 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.
- Derselbe Puffer noch einmal: PlainTerminal vergleicht das Bild mit dem zuletzt gedruckten und druckt nichts.
- 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.
- 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:
ITerminal plain = new PlainTerminal();ITerminal ansi = new AnsiTerminal();plain.Present(buffer);plain.Present(buffer); // dasselbe Bild: wird nicht noch einmal gedrucktansi.Present(buffer);###############################......+- Inventar --+.......##..@...| Trank x2 |..>....##......| Axt |.......#####.##| Gold 35 |...w...##......+-------------+.......###############################Iva, Hexe St.2L [########--] 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[0mSieh, 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):
var 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);\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 Zeile endet, ohne die Farbe zurückzusetzen. Die letzte Zelle des Balkens ist ], grau, und das Terminal bleibt auch in der nächsten Zeile grau. Das Programm dagegen beginnt die neue Zeile mit color = "default", glaubt, die Farbe sei schon normal, und sendet für den ersten Buchstaben keinen Code. Das richtige Ende einer Zeile ist ESC[0m und dann der Zeilenumbruch: Dann stimmt wieder überein, was das Programm glaubt und was im Terminal steht.
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 : ITerminal{ CellBuffer? last; public int Cells;
public void Present(CellBuffer buffer) => Write(Changes(buffer));
public string Changes(CellBuffer buffer) { string frame = ""; if (last == null) { last = new CellBuffer(buffer.Width, buffer.Height); frame += ESC + "[2J"; } Cells = 0; for (int y = 0; y < buffer.Height; y++) { for (int x = 0; x < buffer.Width; x++) { char ch = buffer.Chars[y][x]; string color = buffer.Colors[y][x]; if (ch == last.Chars[y][x] && color == last.Colors[y][x]) continue; frame += ESC + "[" + (y + 1) + ";" + (x + 1) + "H"; frame += CODES[color] + ch; last.Put(x, y, ch, color); Cells++; } } if (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:
var buffer = new CellBuffer(30, 9);var screen = new DiffTerminal();DrawWorld(buffer);Show(1, screen, buffer);buffer.Put(3, 2, '.', "gray"); // der Held geht einen Schritt nach rechtsbuffer.Put(4, 2, '@', "white");Show(2, screen, buffer);buffer.Bar(2, 8, 10, 21, 30, "red"); // der Wolf beißt: 24 → 21buffer.Text(15, 8, "21/30", "default");Show(3, screen, buffer);Show(4, screen, buffer); // nichts hat sich geändertBild 1: 250 Zellen, 3173 ZeichenBild 2: 2 Zellen, 28 Zeichen\e[3;4H\e[90m.\e[3;5H\e[97m@\e[0mBild 3: 2 Zellen, 29 Zeichen\e[9;11H\e[90m-\e[9;17H\e[0m1\e[0mBild 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: Von24auf21ändert sich nur die4. - 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.static partial class Arcanum{ public const int SCREEN_WIDTH = 60; public const int SCREEN_HEIGHT = 18; public const int VIEW_WIDTH = 40; public const int VIEW_HEIGHT = 13;}
class CellBuffer{ public int Width; public int Height; public char[][] Chars; public string[][] Colors;
public CellBuffer(int width, int height) { Width = width; Height = height; Chars = new char[height][]; Colors = new string[height][]; for (int y = 0; y < height; y++) { Chars[y] = new char[width]; Colors[y] = new string[width]; Array.Fill(Chars[y], ' '); Array.Fill(Colors[y], "default"); } }
public void Clear() { for (int y = 0; y < Height; y++) { for (int x = 0; x < Width; x++) { Chars[y][x] = ' '; Colors[y][x] = "default"; } } }
public void Put(int x, int y, char ch, string color) { if (x < 0 || y < 0 || x >= Width || y >= Height) return; Chars[y][x] = ch; Colors[y][x] = color; }
public void Text(int x, int y, string text, string color) { for (int i = 0; i < text.Length; i++) Put(x + i, y, text[i], color); }
public void FillRect(int x, int y, int width, int height) { for (int row = y; row < y + height; row++) { for (int col = x; col < x + width; col++) Put(col, row, ' ', "default"); } }
// Ein Rahmen mit leerem Inneren: durchsichtige Bildschirme (Menü, Inventar) werden im Rahmen über die Karte gezeichnet. public void Box(int x, int y, int width, int height, string title) { FillRect(x, y, width, height); for (int col = x; col < x + width; col++) { Put(col, y, '-', "gray"); Put(col, y + height - 1, '-', "gray"); } for (int row = y; row < y + height; row++) { Put(x, row, '|', "gray"); Put(x + width - 1, row, '|', "gray"); } Put(x, y, '+', "gray"); Put(x + width - 1, y, '+', "gray"); Put(x, y + height - 1, '+', "gray"); Put(x + width - 1, y + height - 1, '+', "gray"); if (title.Length > 0) Text(x + 2, y, " " + title + " ", "yellow"); }
// Balken [#####-----]: Der gefüllte Teil ist proportional zum Wert. public void Bar(int x, int y, int width, int value, int max, string color) { int filled = max > 0 ? (value * width) / max : 0; if (value > 0 && filled == 0) filled = 1; if (filled > width) filled = width; Put(x, y, '[', "gray"); for (int i = 0; i < width; i++) { if (i < filled) Put(x + 1 + i, y, '#', color); else Put(x + 1 + i, y, '-', "gray"); } Put(x + width + 1, y, ']', "gray"); }
public string RowText(int y) { int end = Width; while (end > 0 && Chars[y][end - 1] == ' ') end--; return new string(Chars[y], 0, end); }}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.interface ITerminal{ void Present(CellBuffer buffer);
void Echo(string line);}
class PlainTerminal : ITerminal{ public IGameIo Io; public string LastFrame;
public PlainTerminal(IGameIo io) { Io = io; LastFrame = ""; }
public void Present(CellBuffer buffer) { List<string> lines = new List<string>(); for (int y = 0; y < buffer.Height; y++) lines.Add(buffer.RowText(y)); while (lines.Count > 0 && lines[lines.Count - 1].Length == 0) lines.RemoveAt(lines.Count - 1); string frame = string.Join("\n", lines) + "\n----\n"; if (frame != LastFrame) { Io.Write(frame); LastFrame = frame; } }
public void Echo(string line) { Io.Write("> " + line + "\n"); }}
static partial class Arcanum{ public static readonly Dictionary<string, string> ANSI_CODES = new Dictionary<string, string> { ["default"] = "\u001b[0m", ["red"] = "\u001b[31m", ["green"] = "\u001b[32m", ["yellow"] = "\u001b[33m", ["blue"] = "\u001b[34m", ["magenta"] = "\u001b[35m", ["cyan"] = "\u001b[36m", ["white"] = "\u001b[1;37m", ["gray"] = "\u001b[90m", };}
// AnsiTerminal löscht den Bildschirm und fügt Farbe hinzu: Blut ist rot, Magie blau, Gift grün.class AnsiTerminal : ITerminal{ public IGameIo Io;
public AnsiTerminal(IGameIo io) { Io = io; }
public void Present(CellBuffer buffer) { string output = "\u001b[2J\u001b[H"; for (int y = 0; y < buffer.Height; y++) { int end = buffer.RowText(y).Length; string color = "default"; for (int x = 0; x < end; x++) { if (buffer.Colors[y][x] != color) { color = buffer.Colors[y][x]; output += "\u001b[0m" + ANSI_CODES[color]; } output += buffer.Chars[y][x]; } output += "\u001b[0m\n"; } Io.Write(output + "> "); }
public void Echo(string 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[0mvor jeder Farbe. Weiß ist im Spiel1;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.static partial class Arcanum{ public static void DrawHud(CellBuffer buffer, Game game, int row) { Hero hero = game.Hero; int 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"); }
public static void DrawMessages(CellBuffer buffer, Game game, int row, int count) { int first = Math.Max(0, game.Messages.Count - count); for (int i = first; i < game.Messages.Count; i++) buffer.Text(0, row + i - first, game.Messages[i], "default"); }
public static string EffectLabels(Creature creature) { List<string> labels = new List<string>(); foreach (Effect effect in creature.Effects) labels.Add(effect.Label()); return string.Join(" ", labels); }
// Die Ziffer nach einem Befehl: „d5“ sind fünf Schritte nach rechts. Liefert null, wenn es keine Zahl ist. public static long? CommandNumber(string command, int 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: Console.IsOutputRedirected. 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.
string[] names = { "Blutwolf", "Riesenratte", "Speergoblin" };int[] hp = { 20, 1, 0 };int[] maxHp = { 20, 9, 13 };+- 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
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Polymorphie · Interfaces · Listen · Dictionary und HashSet
- Aufgaben zum Thema: Die Chroniken von Arkanum