Към съдържанието
wannacode

Рисуване в конзолата

Накратко: Рисуването в конзолата става в буфер от клетки (знак и цвят), който се пълни с текст, рамки и ленти и се показва наведнъж от терминал: обикновен, с ANSI цветове или само с променените клетки.

Проблем

Инвентарът излезе под картата

Първата версия на всеки конзолен екран е печат, докато играта върви: картата, после лентата с живота, после съобщенията. Играчът натиска i и инвентарът трябва да излезе върху картата, като прозорец върху прозорец:

foreach (string row in MAP) Console.WriteLine(row);
Console.WriteLine("Ж 24/30 М 5/12");
// Играчът натиска i: инвентарът трябва да е върху картата.
Console.WriteLine("+- Инвентар ---+");
Console.WriteLine("| Отвара x2 |");
Console.WriteLine("| Брадва |");
Console.WriteLine("+--------------+");
Изход
##############################
#......#..........~~~........#
#......+..........~~~...>....#
#......#..........~~~........#
####.#####...................#
#............................#
##############################
Ж 24/30 М 5/12
+- Инвентар ---+
| Отвара x2 |
| Брадва |
+--------------+

Излезе отдолу. Редовете на картата вече са в терминала, а печатът върви само напред. Това е първият проблем. Има още три:

  • Кадърът се къса. Боят печата „Вълкът те ухапа“ в момента, в който се случи, а това може да е по средата на картата.
  • Цветовете са в логиката. Кодът на боя знае кой код прави текста червен:
// В кода на боя:
if (plain) Console.WriteLine("Вълкът те ухапа: -3");
else Console.WriteLine("\u001b[31mВълкът те ухапа: -3\u001b[0m");
  • Два изхода, двоен код. Проверката на играта иска обикновен текст, за да го сравни ред по ред, а играчът иска цветове. С такава проверка във всеки ред, който печата, двата изхода рано или късно се разминават.

Четирите имат един корен: играта решава какво да покаже и как да го покаже на едно и също място, в един и същ момент.

Идеята

Буфер от клетки

Разделяме двете. Играта рисува в буфер: таблица с по една клетка за всяко място на екрана. Клетката пази знак и цвят. Рисуването само пише в таблицата и нищо не излиза на екрана. Когато кадърът е готов, буферът се показва целият наведнъж.

Така редът на рисуване решава кое е отгоре: инвентарът, нарисуван след картата, просто презаписва клетките ѝ. А понеже нищо не се показва, преди кадърът да е готов, нищо не се и къса.

Буферът е два двумерни масива, за знаците и за цветовете. Първият индекс е редът (y), вторият е колоната (x), защото редовете се печатат един след друг:

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(' ');
}
  • Put пише една клетка. Ако мястото е извън буфера, не прави нищо.
  • Text пише низ знак по знак, всеки с Put.
  • RowText връща един ред като низ, без интервалите в края: те не се виждат, а правят изхода по-дълъг.

Цветът е само име: "red", "gray". Какво значи то, решава терминалът по-късно.

Защо Put мълчи, вместо да спре с грешка? Името на героя е дълго, рамката стои до ръба, съобщението е по-широко от екрана. Отрязан текст е по-добре от игра, която пада заради едно име:

var buffer = new CellBuffer(12, 2);
buffer.Text(0, 0, "Ж 24/30", "red");
buffer.Text(8, 1, "Пазачът", "red");
for (int y = 0; y < buffer.Height; y++) Console.WriteLine(buffer.RowText(y));
Изход
Ж 24/30
Паза

Проверката е x >= width, не x > width. Индексите са от 0 до width - 1, затова клетка width вече е извън реда.

По-точно, за любопитните

Знакът е char: един знак от UTF-16. Кирилицата, латиницата и знаците #, +, | са по един char, затова един знак от низа е една клетка. Кога това спира да е вярно, е в „По-точно“ най-долу.

Може и масив от обекти „клетка“ със знак и цвят. Два отделни масива са по-прости за копиране и сравняване, а това ще ни трябва в края на урока.

Идеята

Рамки и ленти

Над Put и Text идват нещата, които играта рисува постоянно: празен правоъгълник, рамка със заглавие и лента за живот:

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 първо изчиства мястото си с FillRect: иначе картата отдолу щеше да се вижда между буквите. После рисува ръба: - горе и долу, | отстрани, + в ъглите. Заглавието е върху горния ръб, с по един интервал от двете страни.

Bar смята колко места да запълни: стойност × ширина / максимум, с цяло деление. Делението закръгля надолу, затова лентата е пълна само при пълен живот: 29 от 30 не изглежда като 30 от 30. Отгоре има едно правило: жив герой никога не вижда празна лента. При 1 от 30 делението дава 0, а лентата показва едно място.

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/30
По-точно, за любопитните

Защо не закръгляне към най-близкото? Тогава 29 от 30 дава 10 места (пълна лента, а героят е ранен), а 1 от 30 дава 0 (празна лента, а героят е жив). Двете крайности са точно мястото, където играчът гледа лентата. С цяло деление и правилото „поне едно място“ пълната лента значи пълен живот, а празната значи смърт.

Ширината в Bar е само на запълнимата част: лентата заема ширина + 2 клетки заради [ и ].

Пробвай

Предвиди лентите

Героят има 29 живота от 30, а слугата до него 1 от 30. Какво печата програмата?

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));
Отговор

Пробвай

Десният ръб

Това е цикълът в Box, който рисува страничните ръбове. Левият е в колона x. В коя колона е десният?

for (int row = y; row < y + height; row++)
{
    Put(x, row, '|', "gray");
    Put(, row, '|', "gray");
}

Виж го

Картата, инвентарът и лентата

Ето целият кадър от началото: картата, героят, вълкът, инвентарът върху картата и долната лента. Буферът се печата чак накрая, ред по ред:

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, "Инвентар");
buffer.Text(9, 2, "Отвара x2", "red");
buffer.Text(9, 3, "Брадва", "default");
buffer.Text(9, 4, "Злато 35", "yellow");
buffer.Text(0, 7, "Ива, вещица ур.2", "white");
buffer.Text(0, 8, "Ж", "red");
buffer.Bar(2, 8, 10, 24, 30, "red");
buffer.Text(15, 8, "24/30", "default");
buffer.Text(21, 8, "М", "blue");
buffer.Bar(23, 8, 5, 5, 12, "blue");
for (int y = 0; y < buffer.Height; y++) Console.WriteLine(buffer.RowText(y));
Изход
##############################
#......+- Инвентар --+.......#
#..@...| Отвара x2 |..>....#
#......| Брадва |.......#
####.##| Злато 35 |...w...#
#......+-------------+.......#
##############################
Ива, вещица ур.2
Ж [########--] 24/30 М [##---]

Инвентарът е върху картата, а вълкът се вижда вдясно от него, защото рамката покрива само своите клетки. Редовете в кода не са подредени като редовете на екрана: долната лента можеше да се нарисува първа и кадърът щеше да е същият.

Идеята

Терминалът решава как

Остава въпросът как буферът стига до екрана. Отговорът зависи от това кой гледа: проверката иска обикновен текст, играчът иска цветове, браузърът иска HTML. Затова буферът не печата сам. Печата терминал, а всички терминали имат един метод: Present(buffer), „покажи този кадър“.

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 печата редовете с RowText, маха празните редове в края и слага ---- за край на кадъра. Ако кадърът е същият като предишния, не печата нищо: команда, която не сменя нищо (грешна команда, поглед към вече видяното), не трябва да пълни изхода с еднакви кадри.

AnsiTerminal праща кодове, които терминалът изпълнява, вместо да ги покаже. Всеки започва със знака ESC (код 27):

  • ESC[2J чисти екрана, ESC[H слага курсора горе вляво;
  • ESC[31m значи „оттук нататък червено“, ESC[90m сиво, ESC[33m жълто, ESC[0m „върни обикновения цвят“.

Цветът в терминала е състояние: стои, докато не го смениш. Затова AnsiTerminal пише код само там, където цветът на клетката е друг от предишния, и връща обикновения цвят в края на всеки ред.

Играта държи ITerminal и вика present, без да знае кой клас е отдолу. Кой ще е, решава един ред при старта.

В програмите на урока ESC, кодовете и изходът към терминала са тук:

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",
};
// Към терминала. ESC се печата като \e, за да се вижда в изхода.
static void Write(string text) => Console.Write(text.Replace(ESC, "\\e"));

Write печата ESC като текста \e, за да се вижда в изхода на страницата. В истински терминал на негово място е самият знак, и вместо \e[31m виждаш червено.

Виж го

Един буфер, два терминала

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, "Инвентар");
buffer.Text(9, 2, "Отвара x2", "red");
buffer.Text(9, 3, "Брадва", "default");
buffer.Text(9, 4, "Злато 35", "yellow");
buffer.Text(0, 7, "Ива, вещица ур.2", "white");
buffer.Text(0, 8, "Ж", "red");
buffer.Bar(2, 8, 10, 24, 30, "red");
buffer.Text(15, 8, "24/30", "default");
buffer.Text(21, 8, "М", "blue");
buffer.Bar(23, 8, 5, 5, 12, "blue");
ITerminal plain = new PlainTerminal();
ITerminal ansi = new AnsiTerminal();
plain.Present(buffer);
plain.Present(buffer); // същият кадър: не се печата пак
ansi.Present(buffer);
Стъпка по стъпка, с думи
  1. Празен буфер: 30 × 9 = 270 клетки, всяка е интервал с цвят default. Нищо не е отпечатано.
  2. Картата: Text пише всеки ред знак по знак, а всеки знак е един Put в една клетка. 7 реда по 30, сиви.
  3. Put пише един знак с цвят: героят @ в (3, 2), бял, и вълкът w в (25, 4), червен. Клетката помни и двете.
  4. Box първо изчиства своите 15 × 5 клетки (FillRect), после рисува ръба и заглавието. Картата под рамката е изтрита в буфера, а не на екрана: екранът още е празен.
  5. Предметите влизат в рамката: Отвара x2, Брадва, Злато 35. Редът на рисуване решава кое е отгоре: инвентарът е след картата.
  6. Долната лента: 24 от 30 в 10 места са 8 #, а 5 от 12 в 5 места са 2. Цялата картина е готова, а терминалът още не е видял нищо.
  7. PlainTerminal: само знаците, ред по ред, без интервалите в края (ред y = 7 е 16 знака, не 30), и ---- за край на кадъра. Цветовете се губят.
  8. Същият буфер пак: PlainTerminal сравнява кадъра с последния отпечатан и не печата нищо.
  9. AnsiTerminal: същият буфер, с цветове. Код за цвят се пише само там, където цветът се сменя: 35 кода за 9 реда, не по един на клетка.
  10. Играта рисува веднъж. Кой терминал ще го покаже, решава един ред при старта, а кодът, който рисува, не знае.

Програмата рисува кадъра от по-горе и го дава на двата терминала. PlainTerminal го получава два пъти, но го печата веднъж:

ITerminal plain = new PlainTerminal();
ITerminal ansi = new AnsiTerminal();
plain.Present(buffer);
plain.Present(buffer); // същият кадър: не се печата пак
ansi.Present(buffer);
Изход
##############################
#......+- Инвентар --+.......#
#..@...| Отвара x2 |..>....#
#......| Брадва |.......#
####.##| Злато 35 |...w...#
#......+-------------+.......#
##############################
Ива, вещица ур.2
Ж [########--] 24/30 М [##---]
----
\e[2J\e[H\e[90m##############################\e[0m
\e[90m#......+-\e[33m Инвентар \e[90m--+.......#\e[0m
\e[90m#..\e[97m@\e[90m...|\e[0m \e[31mОтвара x2\e[0m \e[90m|..>....#\e[0m
\e[90m#......|\e[0m Брадва \e[90m|.......#\e[0m
\e[90m####.##|\e[0m \e[33mЗлато 35\e[0m \e[90m|...\e[31mw\e[90m...#\e[0m
\e[90m#......+-------------+.......#\e[0m
\e[90m##############################\e[0m
\e[97mИва, вещица ур.2\e[0m
\e[31mЖ\e[0m \e[90m[\e[31m########\e[90m--]\e[0m 24/30 \e[34mМ\e[0m \e[90m[\e[34m##\e[90m---]\e[0m

Виж колко рядко има код в изхода на AnsiTerminal: 35 кода за 270 клетки. Ред от сивата карта е един код, а \e[0m вътре в рамката е там, където изчистените клетки са с обикновен цвят.

Пробвай

Намери грешката

Колега пипна AnsiTerminal и съобщението под лентата за живот излезе сиво, а трябва да е с обикновен цвят. Ето какво праща програмата (\e е ESC):

var buffer = new CellBuffer(30, 2);
buffer.Text(0, 0, "Ж", "red");
buffer.Bar(2, 0, 10, 24, 30, "red");
buffer.Text(0, 1, "Вълкът те ухапа.", "default");
new AnsiTerminal().Present(buffer);
Изход
\e[2J\e[H\e[31mЖ\e[0m \e[90m[\e[31m########\e[90m--]
Вълкът те ухапа.

На кой ред е грешката?

Избери реда с грешката.

Идеята

Само промененото

PlainTerminal вече пропуска кадър, еднакъв с предишния. Това е спестяване на ниво кадър: всичко или нищо. Но в походова игра повечето ходове сменят две-три клетки: героят прави крачка, а останалите 250 са същите. AnsiTerminal въпреки това чисти екрана и праща всичко наново.

По-добрият терминал помни какво е показал (втори буфер, last) и сравнява клетка по клетка. За всяка различна клетка мести курсора с ESC[ред;колонаH и пише само нея:

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

Два детайла хващат всеки, който го пише за първи път. Редът и колоната в ESC[ред;колонаH са от 1, а не от 0, и редът е пръв. А last се обновява веднага с изпратената клетка: иначе следващият кадър праща същите клетки пак.

Програмата рисува света без инвентара. После героят прави крачка, а вълкът хапе. Show печата колко клетки и колко знака праща всеки кадър, а късите обновявания и целите:

var buffer = new CellBuffer(30, 9);
var screen = new DiffTerminal();
DrawWorld(buffer);
Show(1, screen, buffer);
buffer.Put(3, 2, '.', "gray"); // героят прави крачка надясно
buffer.Put(4, 2, '@', "white");
Show(2, screen, buffer);
buffer.Bar(2, 8, 10, 21, 30, "red"); // вълкът хапе: 24 → 21
buffer.Text(15, 8, "21/30", "default");
Show(3, screen, buffer);
Show(4, screen, buffer); // нищо не се е променило
Изход
кадър 1: 252 клетки, 3199 знака
кадър 2: 2 клетки, 28 знака
\e[3;4H\e[90m.\e[3;5H\e[97m@\e[0m
кадър 3: 2 клетки, 29 знака
\e[9;11H\e[90m-\e[9;17H\e[0m1\e[0m
кадър 4: 0 клетки, 0 знака
  • Кадър 1 праща 252 клетки и 3199 знака: екранът е празен и всичко е ново. Това е повече от целия кадър на AnsiTerminal (420 знака), защото всяка клетка идва със свое преместване и свой цвят.
  • Крачката е 2 клетки и 28 знака: старото място става ., новото @.
  • Ухапването сменя едно # в лентата и една цифра: от 24 в 21 се сменя само 4.
  • Кадър 4 не праща нищо. Пропускането на еднакъв кадър от PlainTerminal е същото, сведено до „0 клетки“.

Защо си струва? Когато терминалът е през мрежа (ssh, сървър, при който играят няколко души), всеки знак минава по мрежата: 28 знака вместо 420, колкото праща AnsiTerminal за целия кадър, е разлика, която се усеща на всеки ход. Бавните терминали рисуват знак по знак, а ESC[2J първо показва празен екран и после го пълни отгоре надолу: екранът мига. С обновяване на място мигане няма.

Кадър 1 показва и обратната страна: когато се сменя почти всичко (нов екран, отваря се инвентар), цял кадър е по-евтин от клетка по клетка. Затова библиотеките за терминал (като curses) при голяма промяна пращат целия екран, а при малка само разликата.

Идеята

В истинската игра

Каноничната игра има същите части. Буферът е 60 × 18 и има още Clear(): на всеки ход стекът от екрани рисува всичко отначало в изчистен буфер.

// Играта не печата направо: рисува в буфер от клетки (знак + цвят), а терминалът решава как да го
// покаже. Така едно и също рисуване става и обикновен текст, и цветен ANSI екран.
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");
}
}
// Рамка с празна вътрешност: прозрачните екрани (меню, инвентар) се рисуват в рамка върху картата.
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");
}
public string RowText(int y)
{
int end = Width;
while (end > 0 && Chars[y][end - 1] == ' ') end--;
return new string(Chars[y], 0, end);
}
}

Двата терминала:

// Два терминала с еднакъв интерфейс: Present(buffer) показва кадъра, Echo(line) показва командата.
// PlainTerminal печата текст (за проверката и уроците) и пропуска кадър, еднакъв с предишния.
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 чисти екрана и оцветява: кръвта е червена, магията синя, отровата зелена.
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) { }
}

Разликите от урока са три, и всяка има причина:

  • echo(line). PlainTerminal пише командата (> w) след кадъра: така минаването, с което се проверява играта, е четимо като разговор. AnsiTerminal не я пише, защото играчът вижда какво е написал.
  • ESC[0m преди всеки цвят. Белият в играта е 1;37: удебелен и бял. Без връщане удебеленото остава и на следващия цвят. В урока всички кодове сменят само цвета, затова връщането не трябва.
  • Няма DiffTerminal. Играта е походова: един кадър на команда, а играчът и без това чака, докато напише следващата. Ако тръгне по мрежа, DiffTerminal е трети клас, а нищо друго не се сменя.

Кой терминал, решава един ред в началото на цикъла на играта: с --plain е PlainTerminal, иначе AnsiTerminal. Долната лента е функция, която рисува в буфера и не знае за терминала:

// Долната лента: име, клас, ниво, живот и мана с ленти, злато, място. Три последни съобщения.
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);
}
// Цифрата след команда: „d5“ е пет стъпки надясно. Връща null, ако не е число.
public static long? CommandNumber(string command, int start)
{
if (command.Length <= start) return null;
return ParseIntStrict(command.Substring(start));
}
}

В края на региона има и малка помощна функция за командите (d5 е пет крачки надясно): тя е тук, защото и лентата, и командите са част от картата.

По-точно

Буфери, ширини, цветове

Двоен буфер. last в DiffTerminal е това, което графиката нарича двоен буфер: един буфер е показаният, а в другия се рисува следващият кадър. В игрите с видеокарта двата се разменят наведнъж, за да не се види половин кадър. Тук рисуваме целия кадър, преди да викнем present, затова играчът никога не вижда половин кадър.

Ширината на знаците. В обичайния терминал кирилицата и знаците за рамки (─, │, ┌) заемат по една колона. По стандарта Unicode обаче и двете са с „двусмислена“ ширина: терминал, настроен за китайски или японски, може да ги рисува в две колони. Емоджи и йероглифите са винаги в две. Има и втори проблем: емоджи е извън основната равнина на Unicode, и в Java, C# и JavaScript дължината на низа с едно емоджи е 2 (две половини от UTF-16), а в Python е 1. Буферът, който пише по един знак от низа на клетка, ще го разреже на две. Затова каноничната игра рисува рамките с ASCII: +, -, |, # са една колона навсякъде и един знак във всеки език.

16, 256 и милиони цветове. ESC[31m до ESC[37m и ESC[90m до ESC[97m са 16-те основни цвята. Точният им нюанс решава темата на терминала, затова червеното е четимо и на светъл, и на тъмен фон. Има още ESC[38;5;Nm (палитра от 256) и ESC[38;2;R;G;Bm (точен цвят), но не всеки терминал ги разбира, а точният цвят може да се слее с фона на някого. Играта ползва 16-те.

Windows и пренасочен изход. Windows Terminal разбира ANSI кодовете. Старият конзолен прозорец ги разбира от Windows 10 нататък, но програмата трябва да ги включи (режимът ENABLE_VIRTUAL_TERMINAL_PROCESSING), иначе на екрана излиза ←[31m. Отделно, когато изходът е пренасочен във файл, кодовете са боклук във файла. Програмите проверяват дали пишат в терминал: Console.IsOutputRedirected. Играта оставя избора на играча: --plain.

Упражнение

Прозорецът на боя

Реши задачата в редактора си, с CellBuffer от урока.

Прозорецът на боя. Нарисувай в буфер 36 × 5 рамка на целия буфер със заглавие „Бой“. Вътре, по един ред за всеки враг: името от колона 2, лента с ширина 10 от колона 17 и живота като текущ/макс от колона 30. Мъртвият враг (живот 0) е сив и няма лента и живот, само мъртъв от колона 17. Отпечатай буфера ред по ред.

string[] names = { "вълк кръвник", "гигантски плъх", "гоблин шаман" };
int[] hp = { 20, 1, 0 };
int[] maxHp = { 20, 9, 13 };
Изход
+- Бой ----------------------------+
| вълк кръвник [##########] 20/20|
| гигантски плъх [#---------] 1/9 |
| гоблин шаман мъртъв |
+----------------------------------+

Още задачи

  • Дневник в рамка. Функция, която рисува последните N съобщения от боя в рамка и реже дългите по ширината ѝ, с ... накрая.
  • По-малко премествания. В DiffTerminal не пращай ESC[ред;колонаH, когато променената клетка е точно след предишната на същия ред: курсорът вече е там. Колко знака спестяваш в първия кадър?
  • Трети терминал. HtmlTerminal, който връща кадъра като <pre> с <span style="color: …"> при всяка смяна на цвета. Играта не се променя.

Тест

Провери се

1. Защо играта рисува в буфер, вместо да печата направо?
2. Защо AnsiTerminal праща код за цвят само там, където цветът се сменя?
3. Искаш играта да върви в браузъра и цветовете да са <span> елементи. Какво пипаш?
4. Героят прави крачка надясно. Какво праща DiffTerminal?
5. Колега смени проверката в put на x > width. Какво става с текст, който стига до десния ръб?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·