Drawing in the console
In short: Drawing in the console happens in a buffer of cells (a character and a color) that is filled with text, frames and bars and shown at once by a terminal: plain, with ANSI colors, or with only the cells that changed.
Problem
The inventory ended up under the map
The first version of every console screen prints as the game goes: the map, then the life bar, then the messages. The player presses i, and the inventory should appear over the map, like a window over a window:
for row in MAP: print(row)print("L 24/30 M 5/12")# The player presses i: the inventory should be over the map.print("+- Inventory --+")print("| Potion x2 |")print("| Axe |")print("+--------------+")###############################......#..........~~~........##......+..........~~~...>....##......#..........~~~........#####.#####...................##............................###############################L 24/30 M 5/12+- Inventory --+| Potion x2 || Axe |+--------------+It came out underneath. The rows of the map are already in the terminal, and printing only moves forward. That is the first problem. There are three more:
- The frame tears. The combat code prints "The wolf bites you" the moment it happens, and that can be in the middle of the map.
- The colors are in the logic. The combat code knows which code turns the text red:
# In the combat code:if plain: print("The wolf bites you: -3")else: print("\x1b[31mThe wolf bites you: -3\x1b[0m")- Two outputs, double the code. The game's check wants plain text so it can compare it line by line, and the player wants colors. With a check like this in every line that prints, the two outputs drift apart sooner or later.
All four have one root: the game decides what to show and how to show it in the same place, at the same moment.
The idea
A buffer of cells
We split the two. The game draws into a buffer: a table with one cell for every place on the screen. A cell holds a character and a color. Drawing only writes into the table, and nothing reaches the screen. When the frame is ready, the whole buffer is shown at once.
This way the drawing order decides what is on top: the inventory, drawn after the map, simply overwrites its cells. And since nothing is shown before the frame is ready, nothing tears either.
The buffer is two two-dimensional arrays, one for the characters and one for the colors. The first index is the row (y), the second is the column (x), because rows are printed one after another:
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(" ")putwrites one cell. If the place is outside the buffer, it does nothing.textwrites a string character by character, each withput.row_textreturns one row as a string, without the spaces at the end: they are invisible and only make the output longer.
A color is just a name: "red", "gray". What it means is decided by the terminal later.
Why does put stay quiet instead of stopping with an error? The hero's name is long, a frame sits at the edge, a message is wider than the screen. Clipped text is better than a game that crashes because of a name:
buffer = CellBuffer(12, 2)buffer.text(0, 0, "L 24/30", "red")buffer.text(8, 1, "Warden", "red")for y in range(buffer.height): print(buffer.row_text(y))L 24/30 WardThe check is x >= width, not x > width. The indexes go from 0 to width - 1, so cell width is already outside the row.
More precisely, if you are curious
Python and JavaScript have no separate type for a character: a cell holds a string of length 1. Latin letters, accented letters like é and the characters #, +, | are one character each in the string, so one character is one cell. When this stops being true is in "More precisely" at the bottom.
You could also use an array of "cell" objects with a character and a color. Two separate arrays are simpler to copy and compare, and we will need that at the end of the lesson.
The idea
Frames and bars
On top of put and text come the things the game draws all the time: an empty rectangle, a frame with a title and a life bar:
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 first clears its area with fill_rect: otherwise the map underneath would show between the letters. Then it draws the edge: - at the top and bottom, | on the sides, + in the corners. The title sits on the top edge, with one space on each side.
bar works out how many places to fill: value × width / maximum, with integer division. Division rounds down, so the bar is full only at full life: 29 of 30 does not look like 30 of 30. On top of that there is one rule: a living hero never sees an empty bar. At 1 of 30 the division gives 0, and the bar shows one place.
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/30More precisely, if you are curious
Why not round to the nearest? Then 29 of 30 gives 10 places (a full bar for a wounded hero), and 1 of 30 gives 0 (an empty bar for a living one). The two extremes are exactly where the player looks at the bar. With integer division and the "at least one place" rule, a full bar means full life and an empty bar means death.
The width in bar is only the fillable part: the bar takes width + 2 cells because of [ and ].
Try it
Predict the bars
The hero has 29 life of 30, and the servant next to them 1 of 30. What does the program print?
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))29 × 10 / 30 is 9.67, and integer division gives 9: the bar is not full, because the hero is not either. 1 × 10 / 30 gives 0, but the servant is alive, so the bar shows one place.
Try it
The right edge
This is the loop in box that draws the side edges. The left one is in column x. Which column is the right one in?
for row in range(y, y + height):
self.put(x, row, "|", "gray")
self.put(, row, "|", "gray")A frame width wide takes the columns from x to x + width - 1. Column x + width is the first one after the frame: with it the frame gets one column wider, and the right corners are not on the edge.
See it
The map, the inventory and the bar
Here is the whole frame from the start: the map, the hero, the wolf, the inventory over the map and the status line at the bottom. The buffer is printed only at the end, row by row:
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, "Inventory")buffer.text(9, 2, "Potion x2", "red")buffer.text(9, 3, "Axe", "default")buffer.text(9, 4, "Gold 35", "yellow")buffer.text(0, 7, "Iva, witch lv2", "white")buffer.text(0, 8, "L", "red")buffer.bar(2, 8, 10, 24, 30, "red")buffer.text(15, 8, "24/30", "default")buffer.text(21, 8, "M", "blue")buffer.bar(23, 8, 5, 5, 12, "blue")for y in range(buffer.height): print(buffer.row_text(y))###############################......+- Inventory -+.......##..@...| Potion x2 |..>....##......| Axe |.......#####.##| Gold 35 |...w...##......+-------------+.......###############################Iva, witch lv2L [########--] 24/30 M [##---]The inventory is over the map, and the wolf is still visible to the right of it, because the frame covers only its own cells. The lines in the code are not in the order of the rows on the screen: the status line could have been drawn first, and the frame would be the same.
The idea
The terminal decides how
What is left is how the buffer reaches the screen. The answer depends on who is looking: the check wants plain text, the player wants colors, the browser wants HTML. So the buffer does not print itself. A terminal prints, and every terminal has one method: present(buffer), "show this frame".
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 prints the rows with row_text, drops the empty rows at the end and adds ---- to end the frame. If the frame is the same as the last one, it prints nothing: a command that changes nothing (a wrong command, a look at something already seen) should not fill the output with identical frames.
AnsiTerminal sends codes that the terminal carries out instead of showing them. Each one starts with the character ESC (code 27):
ESC[2Jclears the screen,ESC[Hputs the cursor at the top left;ESC[31mmeans "red from here on",ESC[90mgray,ESC[33myellow,ESC[0m"back to the normal color".
A color in the terminal is state: it stays until you change it. So AnsiTerminal writes a code only where a cell's color differs from the one before, and returns to the normal color at the end of every row.
Python has no interfaces. Both classes have a present method with the same parameters, and that is enough: the game calls present without asking what kind of terminal it is (duck typing). If you want the contract written down, a typing.Protocol with a present method does it without the classes inheriting anything.
In the lesson's programs, ESC, the codes and the output to the terminal are here:
ESC = "\x1b"CODES = { "default": ESC + "[0m", "red": ESC + "[31m", "yellow": ESC + "[33m", "blue": ESC + "[34m", "gray": ESC + "[90m", "white": ESC + "[97m",}
# To the terminal. ESC is printed as \e so that you can see it in the output.def write(text): print(text.replace(ESC, "\\e"), end="")write prints ESC as the text \e so that you can see it in the output on this page. In a real terminal the character itself is there, and instead of \e[31m you see red.
See it
One buffer, two terminals
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, "Inventory")buffer.text(9, 2, "Potion x2", "red")buffer.text(9, 3, "Axe", "default")buffer.text(9, 4, "Gold 35", "yellow")buffer.text(0, 7, "Iva, witch lv2", "white")buffer.text(0, 8, "L", "red")buffer.bar(2, 8, 10, 24, 30, "red")buffer.text(15, 8, "24/30", "default")buffer.text(21, 8, "M", "blue")buffer.bar(23, 8, 5, 5, 12, "blue")
plain = PlainTerminal()ansi = AnsiTerminal()plain.present(buffer)plain.present(buffer) # the same frame: not printed againansi.present(buffer)Step by step, in words
- An empty buffer: 30 × 9 = 270 cells, each one a space in the color default. Nothing has been printed.
- The map: text writes each row character by character, and each character is one put into one cell. 7 rows of 30, all gray.
- put writes one character with a color: the hero @ at (3, 2) in white, the wolf w at (25, 4) in red. A cell keeps both.
- box first clears its 15 × 5 cells (fill_rect), then draws the edge and the title. The map under it is overwritten in the buffer, not on the screen: the screen is still empty.
- The items go inside the frame: Potion x2, Axe, Gold 35. The drawing order decides what is on top: the inventory comes after the map.
- The status line: 24 of 30 in 10 places is 8 #, and 5 of 12 in 5 places is 2. The whole picture is ready, and the terminal has not seen any of it.
- PlainTerminal: only the characters, row by row, without the spaces at the end of a row (row y = 7 is 14 characters, not 30), and ---- to end the frame. The colors are lost.
- The same buffer again: PlainTerminal compares the frame with the last one it printed and prints nothing.
- AnsiTerminal: the same buffer, in color. It writes a color code only where the color changes: 35 codes for 9 rows, not one per cell.
- The game drew once. Which terminal shows it is decided by one line at the start, and the drawing code does not know.
The program draws the frame from above and hands it to both terminals. PlainTerminal gets it twice but prints it once:
plain = PlainTerminal()ansi = AnsiTerminal()plain.present(buffer)plain.present(buffer) # the same frame: not printed againansi.present(buffer)###############################......+- Inventory -+.......##..@...| Potion x2 |..>....##......| Axe |.......#####.##| Gold 35 |...w...##......+-------------+.......###############################Iva, witch lv2L [########--] 24/30 M [##---]----\e[2J\e[H\e[90m##############################\e[0m\e[90m#......+-\e[33m Inventory \e[90m-+.......#\e[0m\e[90m#..\e[97m@\e[90m...|\e[0m \e[31mPotion x2\e[0m \e[90m|..>....#\e[0m\e[90m#......|\e[0m Axe \e[90m|.......#\e[0m\e[90m####.##|\e[0m \e[33mGold 35\e[0m \e[90m|...\e[31mw\e[90m...#\e[0m\e[90m#......+-------------+.......#\e[0m\e[90m##############################\e[0m\e[97mIva, witch lv2\e[0m\e[31mL\e[0m \e[90m[\e[31m########\e[90m--]\e[0m 24/30 \e[34mM\e[0m \e[90m[\e[34m##\e[90m---]\e[0mSee how rarely a code appears in the output of AnsiTerminal: 35 codes for 270 cells. A row of gray map is a single code, and the \e[0m inside the frame marks where the cleared cells have the normal color.
Try it
Find the bug
A colleague touched AnsiTerminal, and the message under the life bar came out gray when it should be in the normal color. Here is what the program sends (\e is ESC):
buffer = CellBuffer(30, 2)buffer.text(0, 0, "L", "red")buffer.bar(2, 0, 10, 24, 30, "red")buffer.text(0, 1, "The wolf bites you.", "default")AnsiTerminal().present(buffer)\e[2J\e[H\e[31mL\e[0m \e[90m[\e[31m########\e[90m--]The wolf bites you.Which line has the bug?
Pick the line with the bug.
The row ends without resetting the color. The last cell of the bar is ], gray, and the terminal stays gray on the next row too. The program, on the other hand, starts the new row with color = "default", believes the color is already normal and sends no code for the first letter. The correct end of a row is ESC[0m and then the newline: then what the program believes and what is in the terminal are the same again.
The idea
Only what changed
PlainTerminal already skips a frame that is the same as the last one. That saves at the level of a frame: all or nothing. But in a turn-based game most turns change two or three cells: the hero takes a step, and the other 250 stay the same. AnsiTerminal still clears the screen and sends everything again.
A better terminal remembers what it has shown (a second buffer, last) and compares cell by cell. For every cell that differs it moves the cursor with ESC[row;columnH and writes only that cell:
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 frameTwo details catch everyone who writes this for the first time. The row and column in ESC[row;columnH start at 1, not 0, and the row comes first. And last is updated right away with the cell that was sent: otherwise the next frame sends the same cells again.
The program draws the world without the inventory. Then the hero takes a step and the wolf bites. show prints how many cells and how many characters each frame sends, and the short updates in full:
buffer = CellBuffer(30, 9)screen = DiffTerminal()draw_world(buffer)show(1, screen, buffer)buffer.put(3, 2, ".", "gray") # the hero takes a step to the rightbuffer.put(4, 2, "@", "white")show(2, screen, buffer)buffer.bar(2, 8, 10, 21, 30, "red") # the wolf bites: 24 → 21buffer.text(15, 8, "21/30", "default")show(3, screen, buffer)show(4, screen, buffer) # nothing has changedframe 1: 250 cells, 3173 charsframe 2: 2 cells, 28 chars\e[3;4H\e[90m.\e[3;5H\e[97m@\e[0mframe 3: 2 cells, 29 chars\e[9;11H\e[90m-\e[9;17H\e[0m1\e[0mframe 4: 0 cells, 0 chars- Frame 1 sends 250 cells and 3173 characters: the screen is empty and everything is new. That is more than the whole frame of AnsiTerminal (418 characters), because every cell comes with its own cursor move and its own color.
- The step is 2 cells and 28 characters: the old place becomes
., the new one@. - The bite changes one
#in the bar and one digit: from24to21only the4changes. - Frame 4 sends nothing. PlainTerminal skipping an identical frame is the same idea, reduced to "0 cells".
Why is it worth it? When the terminal is across a network (ssh, a server where several people play), every character goes over the network: 28 characters instead of the 418 that AnsiTerminal sends for a whole frame is a difference you feel on every turn. Slow terminals draw character by character, and ESC[2J first shows an empty screen and then fills it from the top: the screen flickers. Updating in place does not flicker.
Frame 1 also shows the other side: when almost everything changes (a new screen, the inventory opens), a whole frame is cheaper than cell by cell. That is why terminal libraries (such as curses) send the whole screen for a big change and only the difference for a small one.
The idea
In the real game
The canonical game has the same parts. Its buffer is 60 × 18 and also has clear(): on every turn the screen stack draws everything again into a cleared buffer.
# The game does not print directly: it draws into a buffer of cells (symbol + color), and the terminal decides how# to show it. So the same drawing becomes both plain text and a colored ANSI screen.SCREEN_WIDTH = 60SCREEN_HEIGHT = 18VIEW_WIDTH = 40VIEW_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')
# A frame with an empty inside: transparent screens (menu, inventory) are drawn in a frame over the map. 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')
# Bar [#####-----]: the filled part is proportional to the value. 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 lineThe two terminals:
# Two terminals with the same interface: present(buffer) shows the frame, echo(line) shows the command.# PlainTerminal prints text (for the checker and the lessons) and skips a frame equal to the previous one.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 clears the screen and adds color: blood is red, magic is blue, poison is green.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): passThere are three differences from the lesson, and each has a reason:
echo(line). PlainTerminal writes the command (> w) after the frame: that way the playthrough used to check the game reads like a conversation. AnsiTerminal does not write it, because the player sees what they typed.ESC[0mbefore every color. White in the game is1;37: bold and white. Without a reset the bold stays on the next color too. In the lesson all codes change only the color, so the reset is not needed.- No DiffTerminal. The game is turn-based: one frame per command, and the player waits until they type the next one anyway. If it ever goes over a network, DiffTerminal is a third class, and nothing else changes.
Which terminal it is gets decided by one line at the start of the game loop: with --plain it is PlainTerminal, otherwise AnsiTerminal. The status line is a function that draws into the buffer and knows nothing about the terminal:
# The bottom bar: name, class, level, life and mana with bars, gold, place. The last three messages.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)
# The digit after a command: "d5" is five steps to the right. Returns None if it is not a number.def command_number(command, start): if len(command) <= start: return None return parse_int_strict(command[start:])At the end of the region there is also a small helper for commands (d5 is five steps to the right): it lives here because both the status line and the commands belong to the map.
More precisely
Buffers, widths, colors
Double buffering. last in DiffTerminal is what graphics calls a double buffer: one buffer is the one on screen, and the next frame is drawn into the other. In games with a graphics card the two are swapped at once so that half a frame is never visible. Here we draw the whole frame before calling present, so the player never sees half a frame.
The width of characters. In a typical terminal, Cyrillic letters and the box-drawing characters (─, │, ┌) take one column each. The Unicode standard, however, gives both an "ambiguous" width: a terminal set up for Chinese or Japanese may draw them in two columns. Emoji and CJK ideographs always take two. There is a second problem too: emoji lie outside the basic plane of Unicode, and in Java, C# and JavaScript the length of a string with one emoji is 2 (two halves in UTF-16), while in Python it is 1. A buffer that writes one character of the string per cell cuts it in two. That is why the canonical game draws its frames in ASCII: +, -, |, # are one column everywhere and one character in every language.
16, 256 and millions of colors. ESC[31m to ESC[37m and ESC[90m to ESC[97m are the 16 basic colors. Their exact shade is chosen by the terminal's theme, so the red stays readable on both a light and a dark background. There is also ESC[38;5;Nm (a palette of 256) and ESC[38;2;R;G;Bm (an exact color), but not every terminal understands them, and an exact color can blend into someone's background. The game uses the 16.
Windows and redirected output. Windows Terminal understands ANSI codes. The old console window understands them from Windows 10 on, but the program has to switch them on (the ENABLE_VIRTUAL_TERMINAL_PROCESSING mode), otherwise the screen shows ←[31m. Separately, when the output is redirected to a file, the codes are junk in the file. Programs check whether they write to a terminal: sys.stdout.isatty(). The game leaves the choice to the player: --plain.
Exercise
The battle window
Solve this in your own editor, with the CellBuffer from the lesson.
The battle window. In a 36 × 5 buffer, draw a frame around the whole buffer with the title "Battle". Inside it, one row per enemy: the name from column 2, a bar 10 wide from column 17 and the life as current/max from column 30. A dead enemy (life 0) is gray and has no bar and no life, only dead from column 17. Print the buffer row by row.
names = ["blood wolf", "giant rat", "goblin shaman"]hp = [20, 1, 0]max_hp = [20, 9, 13]+- Battle -------------------------+| blood wolf [##########] 20/20|| giant rat [#---------] 1/9 || goblin shaman dead |+----------------------------------+More tasks
- A log in a frame. A function that draws the last N combat messages in a frame and cuts the long ones to its width, with
...at the end. - Fewer moves. In DiffTerminal, do not send
ESC[row;columnHwhen the changed cell comes right after the previous one on the same row: the cursor is already there. How many characters do you save in the first frame? - A third terminal. HtmlTerminal, which returns the frame as a
<pre>with a<span style="color: …">at every color change. The game does not change.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Polymorphism · Interfaces as abstract classes · Lists: append, remove and sort · dict and set
- Tasks on this topic: Chronicles of Arcanum