Рисуване в конзолата
Накратко: Рисуването в конзолата става в буфер от клетки (знак и цвят), който се пълни с текст, рамки и ленти и се показва наведнъж от терминал: обикновен, с 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));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 е първата след рамката: с нея рамката става с една колона по-широка, а десните ъгли не са на ръба.
Виж го
Картата, инвентарът и лентата
Ето целият кадър от началото: картата, героят, вълкът, инвентарът върху картата и долната лента. Буферът се печата чак накрая, ред по ред:
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);Стъпка по стъпка, с думи
- Празен буфер: 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 го получава два пъти, но го печата веднъж:
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--]Вълкът те ухапа.На кой ред е грешката?
Избери реда с грешката.
Редът свършва, без да върне цвета. Последната клетка на лентата е ], сива, и терминалът остава сив и на следващия ред. Програмата пък започва новия ред с color = "default", мисли, че цветът вече е обикновен, и не праща код за първата буква. Верният край на реда е ESC[0m и после новият ред: тогава това, което програмата мисли, и това, което е в терминала, пак са едно и също.
Идеята
Само промененото
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 → 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 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: …">при всяка смяна на цвета. Играта не се променя.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Интерфейси · Низове · Списъци · HashMap и HashSet
- Задачи по темата: Хрониките на Арканум