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

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

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

Проблем

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

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

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

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

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

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

Идеята

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

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

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

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

static class CellBuffer {
final int width;
final int height;
final char[][] chars;
final String[][] colors;
CellBuffer(int width, int height) {
this.width = width;
this.height = height;
chars = new char[height][width];
colors = new String[height][width];
for (int y = 0; y < height; y++) {
Arrays.fill(chars[y], ' ');
Arrays.fill(colors[y], "default");
}
}
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;
}
void text(int x, int y, String text, String color) {
for (int i = 0; i < text.length(); i++) put(x + i, y, text.charAt(i), color);
}
String rowText(int y) {
return new String(chars[y]).stripTrailing();
}
}
  • put пише една клетка. Ако мястото е извън буфера, не прави нищо.
  • text пише низ знак по знак, всеки с put.
  • rowText връща един ред като низ, без интервалите в края: те не се виждат, а правят изхода по-дълъг.

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

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

CellBuffer 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++) System.out.println(buffer.rowText(y));
Изход
Ж 24/30
Паза

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

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

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

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

Идеята

Рамки и ленти

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

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");
}
}
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.isEmpty()) text(x + 2, y, " " + title + " ", "yellow");
}
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, а лентата показва едно място.

CellBuffer 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++) System.out.println(buffer.rowText(y));
Изход
[##########] 30/30
[#####-----] 15/30
[#---------] 1/30
[----------] 0/30
По-точно, за любопитните

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

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

Пробвай

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

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

CellBuffer 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++) System.out.println(buffer.rowText(y));
Отговор

Пробвай

Десният ръб

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

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

Виж го

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

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

CellBuffer 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++) System.out.println(buffer.rowText(y));
Изход
##############################
#......+- Инвентар --+.......#
#..@...| Отвара x2 |..>....#
#......| Брадва |.......#
####.##| Злато 35 |...w...#
#......+-------------+.......#
##############################
Ива, вещица ур.2
Ж [########--] 24/30 М [##---]

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

Идеята

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

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

interface Terminal {
void present(CellBuffer buffer);
}
static class PlainTerminal implements Terminal {
private String lastFrame = "";
public void present(CellBuffer buffer) {
List<String> lines = new ArrayList<>();
for (int y = 0; y < buffer.height; y++) lines.add(buffer.rowText(y));
while (!lines.isEmpty() && lines.get(lines.size() - 1).isEmpty()) lines.remove(lines.size() - 1);
String frame = String.join("\n", lines) + "\n----\n";
if (frame.equals(lastFrame)) return;
write(frame);
lastFrame = frame;
}
}
static class AnsiTerminal implements Terminal {
public void present(CellBuffer buffer) {
StringBuilder frame = new StringBuilder(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].equals(color)) {
color = buffer.colors[y][x];
frame.append(CODES.get(color));
}
frame.append(row.charAt(x));
}
frame.append(ESC + "[0m\n");
}
write(frame.toString());
}
}

PlainTerminal печата редовете с rowText, маха празните редове в края и слага ---- за край на кадъра. Ако кадърът е същият като предишния, не печата нищо: команда, която не сменя нищо (грешна команда, поглед към вече видяното), не трябва да пълни изхода с еднакви кадри.

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

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

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

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

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

static final String ESC = "\u001b";
static final Map<String, String> CODES = Map.of(
"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) {
System.out.print(text.replace(ESC, "\\e"));
}

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

Виж го

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

CellBuffer 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");
Terminal plain = new PlainTerminal();
Terminal 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 го получава два пъти, но го печата веднъж:

Terminal plain = new PlainTerminal();
Terminal 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):

CellBuffer 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 и пише само нея:

static class DiffTerminal implements Terminal {
private CellBuffer last;
int cells;
public void present(CellBuffer buffer) {
write(changes(buffer));
}
String changes(CellBuffer buffer) {
StringBuilder frame = new StringBuilder();
if (last == null) {
last = new CellBuffer(buffer.width, buffer.height);
frame.append(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.equals(last.colors[y][x])) continue;
frame.append(ESC + "[" + (y + 1) + ";" + (x + 1) + "H");
frame.append(CODES.get(color)).append(ch);
last.put(x, y, ch, color);
cells++;
}
}
if (cells > 0) frame.append(CODES.get("default"));
return frame.toString();
}
}

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

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

CellBuffer buffer = new CellBuffer(30, 9);
DiffTerminal 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 final int SCREEN_WIDTH = 60;
static final int SCREEN_HEIGHT = 18;
static final int VIEW_WIDTH = 40;
static final int VIEW_HEIGHT = 13;
static class CellBuffer {
int width;
int height;
char[][] chars;
String[][] colors;
CellBuffer(int width, int height) {
this.width = width;
this.height = height;
this.chars = new char[height][width];
this.colors = new String[height][width];
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
this.chars[y][x] = ' ';
this.colors[y][x] = "default";
}
}
}
void clear() {
for (int y = 0; y < this.height; y++) {
for (int x = 0; x < this.width; x++) {
this.chars[y][x] = ' ';
this.colors[y][x] = "default";
}
}
}
void put(int x, int y, char ch, String color) {
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
this.chars[y][x] = ch;
this.colors[y][x] = color;
}
void text(int x, int y, String text, String color) {
for (int i = 0; i < text.length(); i++) this.put(x + i, y, text.charAt(i), color);
}
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++) this.put(col, row, ' ', "default");
}
}
// Рамка с празна вътрешност: прозрачните екрани (меню, инвентар) се рисуват в рамка върху картата.
void box(int x, int y, int width, int height, String title) {
this.fillRect(x, y, width, height);
for (int col = x; col < x + width; col++) {
this.put(col, y, '-', "gray");
this.put(col, y + height - 1, '-', "gray");
}
for (int 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");
}
// Лента [#####-----]: запълнената част е пропорционална на стойността.
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;
this.put(x, y, '[', "gray");
for (int 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");
}
String rowText(int y) {
int end = this.width;
while (end > 0 && this.chars[y][end - 1] == ' ') end--;
String line = "";
for (int x = 0; x < end; x++) line += this.chars[y][x];
return line;
}
}

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

// Два терминала с еднакъв интерфейс: present(buffer) показва кадъра, echo(line) показва командата.
// PlainTerminal печата текст (за проверката и уроците) и пропуска кадър, еднакъв с предишния.
interface Terminal {
void present(CellBuffer buffer);
void echo(String line);
}
static class PlainTerminal implements Terminal {
Io io;
String lastFrame;
PlainTerminal(Io io) {
this.io = io;
this.lastFrame = "";
}
public void present(CellBuffer buffer) {
List<String> lines = new ArrayList<>();
for (int y = 0; y < buffer.height; y++) lines.add(buffer.rowText(y));
while (lines.size() > 0 && lines.get(lines.size() - 1).length() == 0) lines.remove(lines.size() - 1);
String frame = String.join("\n", lines) + "\n----\n";
if (!frame.equals(this.lastFrame)) {
this.io.write(frame);
this.lastFrame = frame;
}
}
public void echo(String line) {
this.io.write("> " + line + "\n");
}
}
static final Map<String, String> ANSI_CODES = new LinkedHashMap<>(Map.ofEntries(
Map.entry("default", "\u001b[0m"), Map.entry("red", "\u001b[31m"), Map.entry("green", "\u001b[32m"), Map.entry("yellow", "\u001b[33m"),
Map.entry("blue", "\u001b[34m"), Map.entry("magenta", "\u001b[35m"), Map.entry("cyan", "\u001b[36m"), Map.entry("white", "\u001b[1;37m"), Map.entry("gray", "\u001b[90m")
));
// AnsiTerminal чисти екрана и оцветява: кръвта е червена, магията синя, отровата зелена.
static class AnsiTerminal implements Terminal {
Io io;
AnsiTerminal(Io io) {
this.io = io;
}
public void present(CellBuffer buffer) {
StringBuilder out = new StringBuilder("\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].equals(color)) {
color = buffer.colors[y][x];
out.append("\u001b[0m").append(ANSI_CODES.get(color));
}
out.append(buffer.chars[y][x]);
}
out.append("\u001b[0m\n");
}
this.io.write(out + "> ");
}
public void echo(String line) {}
}

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

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

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

// Долната лента: име, клас, ниво, живот и мана с ленти, злато, място. Три последни съобщения.
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");
}
static void drawMessages(CellBuffer buffer, Game game, int row, int count) {
int first = Math.max(0, game.messages.size() - count);
for (int i = first; i < game.messages.size(); i++) buffer.text(0, row + i - first, game.messages.get(i), "default");
}
static String effectLabels(Creature creature) {
List<String> labels = new ArrayList<>();
for (Effect effect : creature.effects) labels.add(effect.label());
return String.join(" ", labels);
}
// Цифрата след команда: „d5“ е пет стъпки надясно. Връща null, ако не е число.
static Integer 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. Отделно, когато изходът е пренасочен във файл, кодовете са боклук във файла. Програмите проверяват дали пишат в терминал: System.console() != null (от Java 22 System.console().isTerminal(), защото console() вече не връща null). Играта оставя избора на играча: --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 за достъпно обучение по програмиране ·