Рисуване в конзолата
Накратко: Рисуването в конзолата става в буфер от клетки (знак и цвят), който се пълни с текст, рамки и ленти и се показва наведнъж от терминал: обикновен, с 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));29 × 10 / 30 е 9,67, а цялото деление дава 9: лентата не е пълна, защото и героят не е. 1 × 10 / 30 дава 0, но слугата е жив, затова лентата показва едно място.
Пробвай
Десният ръб
Това е цикълът в Box, който рисува страничните ръбове. Левият е в колона x. В коя колона е десният?
for (int row = y; row < y + height; row++)
{
Put(x, row, '|', "gray");
Put(, row, '|', "gray");
}Рамка с ширина width заема колоните от x до x + width - 1. Колона x + width е първата след рамката: с нея рамката става с една колона по-широка, а десните ъгли не са на ръба.
Виж го
Картата, инвентарът и лентата
Ето целият кадър от началото: картата, героят, вълкът, инвентарът върху картата и долната лента. Буферът се печата чак накрая, ред по ред:
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);Стъпка по стъпка, с думи
- Празен буфер: 30 × 9 = 270 клетки, всяка е интервал с цвят default. Нищо не е отпечатано.
- Картата: Text пише всеки ред знак по знак, а всеки знак е един Put в една клетка. 7 реда по 30, сиви.
- Put пише един знак с цвят: героят @ в (3, 2), бял, и вълкът w в (25, 4), червен. Клетката помни и двете.
- Box първо изчиства своите 15 × 5 клетки (FillRect), после рисува ръба и заглавието. Картата под рамката е изтрита в буфера, а не на екрана: екранът още е празен.
- Предметите влизат в рамката: Отвара x2, Брадва, Злато 35. Редът на рисуване решава кое е отгоре: инвентарът е след картата.
- Долната лента: 24 от 30 в 10 места са 8 #, а 5 от 12 в 5 места са 2. Цялата картина е готова, а терминалът още не е видял нищо.
- PlainTerminal: само знаците, ред по ред, без интервалите в края (ред y = 7 е 16 знака, не 30), и ---- за край на кадъра. Цветовете се губят.
- Същият буфер пак: PlainTerminal сравнява кадъра с последния отпечатан и не печата нищо.
- AnsiTerminal: същият буфер, с цветове. Код за цвят се пише само там, където цветът се сменя: 35 кода за 9 реда, не по един на клетка.
- Играта рисува веднъж. Кой терминал ще го покаже, решава един ред при старта, а кодът, който рисува, не знае.
Програмата рисува кадъра от по-горе и го дава на двата терминала. 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--]Вълкът те ухапа.На кой ред е грешката?
Избери реда с грешката.
Редът свършва, без да върне цвета. Последната клетка на лентата е ], сива, и терминалът остава сив и на следващия ред. Програмата пък започва новия ред с color = "default", мисли, че цветът вече е обикновен, и не праща код за първата буква. Верният край на реда е ESC[0m и после новият ред: тогава това, което програмата мисли, и това, което е в терминала, пак са едно и също.
Идеята
Само промененото
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 → 21buffer.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: …">при всяка смяна на цвета. Играта не се променя.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Полиморфизъм · Интерфейси · Списъци · Dictionary и HashSet
- Задачи по темата: Хрониките на Арканум