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

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

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

Проблем

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

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

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

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

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

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

Идеята

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

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

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

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

class CellBuffer:
def __init__(self, width, height):
self.width = width
self.height = height
self.chars = [[" "] * width for _ in range(height)]
self.colors = [["default"] * width for _ in range(height)]
def put(self, x, y, ch, color):
if x < 0 or y < 0 or x >= self.width or y >= self.height:
return
self.chars[y][x] = ch
self.colors[y][x] = color
def text(self, x, y, text, color):
for i, ch in enumerate(text):
self.put(x + i, y, ch, color)
def row_text(self, y):
return "".join(self.chars[y]).rstrip(" ")
  • put пише една клетка. Ако мястото е извън буфера, не прави нищо.
  • text пише низ знак по знак, всеки с put.
  • row_text връща един ред като низ, без интервалите в края: те не се виждат, а правят изхода по-дълъг.

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

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

buffer = CellBuffer(12, 2)
buffer.text(0, 0, "Ж 24/30", "red")
buffer.text(8, 1, "Пазачът", "red")
for y in range(buffer.height):
print(buffer.row_text(y))
Изход
Ж 24/30
Паза

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

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

Python и JavaScript нямат отделен тип за знак: клетката пази низ с дължина 1. Кирилицата, латиницата и знаците #, +, | са по един знак в низа, затова един знак е една клетка. Кога това спира да е вярно, е в „По-точно“ най-долу.

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

Идеята

Рамки и ленти

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

def fill_rect(self, x, y, width, height):
for row in range(y, y + height):
for col in range(x, x + width):
self.put(col, row, " ", "default")
def box(self, x, y, width, height, title):
self.fill_rect(x, y, width, height)
for col in range(x, x + width):
self.put(col, y, "-", "gray")
self.put(col, y + height - 1, "-", "gray")
for row in range(y, y + height):
self.put(x, row, "|", "gray")
self.put(x + width - 1, row, "|", "gray")
self.put(x, y, "+", "gray")
self.put(x + width - 1, y, "+", "gray")
self.put(x, y + height - 1, "+", "gray")
self.put(x + width - 1, y + height - 1, "+", "gray")
if title:
self.text(x + 2, y, " " + title + " ", "yellow")
def bar(self, x, y, width, value, maximum, color):
filled = value * width // maximum if maximum > 0 else 0
if value > 0 and filled == 0:
filled = 1
if filled > width:
filled = width
self.put(x, y, "[", "gray")
for i in range(width):
if i < filled:
self.put(x + 1 + i, y, "#", color)
else:
self.put(x + 1 + i, y, "-", "gray")
self.put(x + width + 1, y, "]", "gray")

box първо изчиства мястото си с fill_rect: иначе картата отдолу щеше да се вижда между буквите. После рисува ръба: - горе и долу, | отстрани, + в ъглите. Заглавието е върху горния ръб, с по един интервал от двете страни.

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

buffer = CellBuffer(20, 4)
values = [30, 15, 1, 0]
for i, value in enumerate(values):
buffer.bar(0, i, 10, value, 30, "red")
buffer.text(13, i, f"{value}/30", "default")
for y in range(buffer.height):
print(buffer.row_text(y))
Изход
[##########] 30/30
[#####-----] 15/30
[#---------] 1/30
[----------] 0/30
По-точно, за любопитните

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

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

Пробвай

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

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

buffer = CellBuffer(12, 2)
buffer.bar(0, 0, 10, 29, 30, "red")
buffer.bar(0, 1, 10, 1, 30, "red")
for y in range(buffer.height):
print(buffer.row_text(y))
Отговор

Пробвай

Десният ръб

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

for row in range(y, y + height):
    self.put(x, row, "|", "gray")
    self.put(, row, "|", "gray")

Виж го

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

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

buffer = CellBuffer(30, 9)
for y, row in enumerate(MAP):
buffer.text(0, y, row, "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 y in range(buffer.height):
print(buffer.row_text(y))
Изход
##############################
#......+- Инвентар --+.......#
#..@...| Отвара x2 |..>....#
#......| Брадва |.......#
####.##| Злато 35 |...w...#
#......+-------------+.......#
##############################
Ива, вещица ур.2
Ж [########--] 24/30 М [##---]

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

Идеята

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

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

class PlainTerminal:
def __init__(self):
self.last_frame = ""
def present(self, buffer):
lines = [buffer.row_text(y) for y in range(buffer.height)]
while lines and lines[-1] == "":
lines.pop()
frame = "\n".join(lines) + "\n----\n"
if frame == self.last_frame:
return
write(frame)
self.last_frame = frame
class AnsiTerminal:
def present(self, buffer):
frame = ESC + "[2J" + ESC + "[H"
for y in range(buffer.height):
row = buffer.row_text(y)
color = "default"
for x, ch in enumerate(row):
if buffer.colors[y][x] != color:
color = buffer.colors[y][x]
frame += CODES[color]
frame += ch
frame += ESC + "[0m\n"
write(frame)

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

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

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

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

Python няма интерфейси. Двата класа имат метод present с еднакви параметри и това стига: играта вика present, без да пита какъв е терминалът (duck typing). Ако искаш договорът да е записан, typing.Protocol с метод present го прави, без класовете да наследяват нещо.

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

ESC = "\x1b"
CODES = {
"default": ESC + "[0m", "red": ESC + "[31m", "yellow": ESC + "[33m",
"blue": ESC + "[34m", "gray": ESC + "[90m", "white": ESC + "[97m",
}
# Към терминала. ESC се печата като \e, за да се вижда в изхода.
def write(text):
print(text.replace(ESC, "\\e"), end="")

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

Виж го

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

buffer = CellBuffer(30, 9)
for y, row in enumerate(MAP):
buffer.text(0, y, row, "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")
plain = PlainTerminal()
ansi = 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 клетки (fill_rect), после рисува ръба и заглавието. Картата под рамката е изтрита в буфера, а не на екрана: екранът още е празен.
  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 го получава два пъти, но го печата веднъж:

plain = PlainTerminal()
ansi = 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):

buffer = CellBuffer(30, 2)
buffer.text(0, 0, "Ж", "red")
buffer.bar(2, 0, 10, 24, 30, "red")
buffer.text(0, 1, "Вълкът те ухапа.", "default")
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:
def __init__(self):
self.last = None
self.cells = 0
def present(self, buffer):
write(self.changes(buffer))
def changes(self, buffer):
frame = ""
if self.last is None:
self.last = CellBuffer(buffer.width, buffer.height)
frame = ESC + "[2J"
self.cells = 0
for y in range(buffer.height):
for x in range(buffer.width):
ch = buffer.chars[y][x]
color = buffer.colors[y][x]
if ch == self.last.chars[y][x] and color == self.last.colors[y][x]:
continue
frame += ESC + f"[{y + 1};{x + 1}H"
frame += CODES[color] + ch
self.last.put(x, y, ch, color)
self.cells += 1
if self.cells > 0:
frame += CODES["default"]
return frame

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

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

buffer = CellBuffer(30, 9)
screen = DiffTerminal()
draw_world(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 екран.
SCREEN_WIDTH = 60
SCREEN_HEIGHT = 18
VIEW_WIDTH = 40
VIEW_HEIGHT = 13
class CellBuffer:
def __init__(self, width, height):
self.width = width
self.height = height
self.chars = []
self.colors = []
for y in range(height):
self.chars.append([' '] * width)
self.colors.append(['default'] * width)
def clear(self):
for y in range(self.height):
for x in range(self.width):
self.chars[y][x] = ' '
self.colors[y][x] = 'default'
def put(self, x, y, ch, color):
if x < 0 or y < 0 or x >= self.width or y >= self.height:
return
self.chars[y][x] = ch
self.colors[y][x] = color
def text(self, x, y, text, color):
for i in range(len(text)):
self.put(x + i, y, text[i], color)
def fill_rect(self, x, y, width, height):
for row in range(y, y + height):
for col in range(x, x + width):
self.put(col, row, ' ', 'default')
# Рамка с празна вътрешност: прозрачните екрани (меню, инвентар) се рисуват в рамка върху картата.
def box(self, x, y, width, height, title):
self.fill_rect(x, y, width, height)
for col in range(x, x + width):
self.put(col, y, '-', 'gray')
self.put(col, y + height - 1, '-', 'gray')
for row in range(y, y + height):
self.put(x, row, '|', 'gray')
self.put(x + width - 1, row, '|', 'gray')
self.put(x, y, '+', 'gray')
self.put(x + width - 1, y, '+', 'gray')
self.put(x, y + height - 1, '+', 'gray')
self.put(x + width - 1, y + height - 1, '+', 'gray')
if len(title) > 0:
self.text(x + 2, y, ' ' + title + ' ', 'yellow')
# Лента [#####-----]: запълнената част е пропорционална на стойността.
def bar(self, x, y, width, value, maximum, color):
filled = (value * width) // maximum if maximum > 0 else 0
if value > 0 and filled == 0:
filled = 1
if filled > width:
filled = width
self.put(x, y, '[', 'gray')
for i in range(width):
if i < filled:
self.put(x + 1 + i, y, '#', color)
else:
self.put(x + 1 + i, y, '-', 'gray')
self.put(x + width + 1, y, ']', 'gray')
def row_text(self, y):
end = self.width
while end > 0 and self.chars[y][end - 1] == ' ':
end -= 1
line = ''
for x in range(end):
line += self.chars[y][x]
return line

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

# Два терминала с еднакъв интерфейс: present(buffer) показва кадъра, echo(line) показва командата.
# PlainTerminal печата текст (за проверката и уроците) и пропуска кадър, еднакъв с предишния.
class PlainTerminal:
def __init__(self, io):
self.io = io
self.last_frame = ''
def present(self, buffer):
lines = []
for y in range(buffer.height):
lines.append(buffer.row_text(y))
while len(lines) > 0 and len(lines[-1]) == 0:
lines.pop()
frame = '\n'.join(lines) + '\n----\n'
if frame != self.last_frame:
self.io.write(frame)
self.last_frame = frame
def echo(self, line):
self.io.write('> ' + line + '\n')
ANSI_CODES = {
'default': '\x1b[0m', 'red': '\x1b[31m', 'green': '\x1b[32m', 'yellow': '\x1b[33m',
'blue': '\x1b[34m', 'magenta': '\x1b[35m', 'cyan': '\x1b[36m', 'white': '\x1b[1;37m', 'gray': '\x1b[90m',
}
# AnsiTerminal чисти екрана и оцветява: кръвта е червена, магията синя, отровата зелена.
class AnsiTerminal:
def __init__(self, io):
self.io = io
def present(self, buffer):
out = '\x1b[2J\x1b[H'
for y in range(buffer.height):
end = len(buffer.row_text(y))
color = 'default'
for x in range(end):
if buffer.colors[y][x] != color:
color = buffer.colors[y][x]
out += '\x1b[0m' + ANSI_CODES[color]
out += buffer.chars[y][x]
out += '\x1b[0m\n'
self.io.write(out + '> ')
def echo(self, line):
pass

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

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

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

# Долната лента: име, клас, ниво, живот и мана с ленти, злато, място. Три последни съобщения.
def draw_hud(buffer, game, row):
hero = game.hero
next_xp = xp_for_level(hero.level + 1)
buffer.text(0, row, hero.name + ', ' + nm(hero.hero_class()) + ' ' + t('hud.level', {'level': hero.level}) + ' ' +
t('hud.xp', {'xp': hero.xp, 'next': next_xp}) + ' ' + 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.max_hp(), 'red')
buffer.text(17, row + 1, str(hero.hp) + '/' + str(hero.max_hp()), 'default')
buffer.text(25, row + 1, t('hud.mana'), 'blue')
buffer.bar(27, row + 1, 8, hero.mana, hero.max_mana(), 'blue')
buffer.text(38, row + 1, str(hero.mana) + '/' + str(hero.max_mana()), 'default')
buffer.text(45, row + 1, effect_labels(hero), 'green')
def draw_messages(buffer, game, row, count):
first = max(0, len(game.messages) - count)
for i in range(first, len(game.messages)):
buffer.text(0, row + i - first, game.messages[i], 'default')
def effect_labels(creature):
labels = []
for effect in creature.effects:
labels.append(effect.label())
return ' '.join(labels)
# Цифрата след команда: „d5“ е пет стъпки надясно. Връща None, ако не е число.
def command_number(command, start):
if len(command) <= start:
return None
return parse_int_strict(command[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. Отделно, когато изходът е пренасочен във файл, кодовете са боклук във файла. Програмите проверяват дали пишат в терминал: sys.stdout.isatty(). Играта оставя избора на играча: --plain.

Упражнение

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

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

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

names = ["вълк кръвник", "гигантски плъх", "гоблин шаман"]
hp = [20, 1, 0]
max_hp = [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 за достъпно обучение по програмиране ·