Dibujar en la consola
En resumen: Dibujar en la consola ocurre en un búfer de celdas (un carácter y un color) que se llena con texto, marcos y barras y que una terminal muestra de una vez: simple, con colores ANSI o solo con las celdas que cambiaron.
Problema
El inventario quedó debajo del mapa
La primera versión de toda pantalla de consola imprime a medida que corre el juego: el mapa, luego la barra de vida, luego los mensajes. El jugador pulsa i, y el inventario debe aparecer encima del mapa, como una ventana sobre otra ventana:
for row in MAP: print(row)print("V 24/30 M 5/12")# El jugador pulsa i: el inventario debe aparecer sobre el mapa.print("+- Inventario -+")print("| Poción x2 |")print("| Hacha |")print("+--------------+")###############################......#..........~~~........##......+..........~~~...>....##......#..........~~~........#####.#####...................##............................###############################V 24/30 M 5/12+- Inventario -+| Poción x2 || Hacha |+--------------+Salió debajo. Las filas del mapa ya están en la terminal, e imprimir solo avanza. Ese es el primer problema. Hay tres más:
- El fotograma se rompe. El código del combate imprime «El lobo te muerde» en el instante en que ocurre, y eso puede ser en mitad del mapa.
- Los colores están en la lógica. El código del combate sabe qué código pone el texto en rojo:
# En el código del combate:if plain: print("El lobo te muerde: -3")else: print("\x1b[31mEl lobo te muerde: -3\x1b[0m")- Dos salidas, el doble de código. La comprobación del juego quiere texto simple para compararlo línea por línea, y el jugador quiere colores. Con una condición así en cada línea que imprime, las dos salidas se separan tarde o temprano.
Los cuatro tienen una raíz: el juego decide qué mostrar y cómo mostrarlo en el mismo lugar y en el mismo momento.
La idea
Un búfer de celdas
Separamos las dos cosas. El juego dibuja en un búfer: una tabla con una celda para cada lugar de la pantalla. Una celda guarda un carácter y un color. Dibujar solo escribe en la tabla, y nada llega a la pantalla. Cuando el fotograma está listo, todo el búfer se muestra de una vez.
Así el orden de dibujo decide qué queda encima: el inventario, dibujado después del mapa, simplemente sobrescribe sus celdas. Y como no se muestra nada antes de que el fotograma esté listo, tampoco se rompe nada.
El búfer son dos arrays (arreglos) bidimensionales, uno para los caracteres y otro para los colores. El primer índice es la fila (y), el segundo es la columna (x), porque las filas se imprimen una tras otra:
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(" ")putescribe una celda. Si el lugar está fuera del búfer, no hace nada.textescribe una cadena carácter a carácter, cada uno conput.row_textdevuelve una fila como cadena, sin los espacios del final: son invisibles y solo alargan la salida.
Un color es solo un nombre: "red", "gray". Qué significa lo decide la terminal más tarde.
¿Por qué put se queda callado en lugar de detenerse con un error? El nombre del héroe es largo, un marco está en el borde, un mensaje es más ancho que la pantalla. Un texto recortado es mejor que un juego que se cae por culpa de un nombre:
buffer = CellBuffer(12, 2)buffer.text(0, 0, "V 24/30", "red")buffer.text(8, 1, "Guardián", "red")for y in range(buffer.height): print(buffer.row_text(y))V 24/30 GuarLa comprobación es x >= width, no x > width. Los índices van de 0 a width - 1, así que la celda width ya está fuera de la fila.
Más precisamente, para curiosos
Python y JavaScript no tienen un tipo aparte para un carácter: una celda guarda una cadena de longitud 1. Las letras latinas, las letras con acento como é y los caracteres #, +, | son un carácter cada una en la cadena, así que un carácter es una celda. Cuándo deja de ser cierto está en «Más precisamente» al final.
También podrías usar un array de objetos «celda» con un carácter y un color. Dos arrays separados son más fáciles de copiar y comparar, y lo necesitaremos al final de la lección.
La idea
Marcos y barras
Encima de put y text vienen las cosas que el juego dibuja todo el tiempo: un rectángulo vacío, un marco con título y una barra de vida:
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 primero vacía su área con fill_rect: si no, el mapa de debajo se vería entre las letras. Luego dibuja el borde: - arriba y abajo, | a los lados, + en las esquinas. El título va sobre el borde de arriba, con un espacio a cada lado.
bar calcula cuántos lugares llenar: valor × ancho / máximo, con división entera. La división redondea hacia abajo, así que la barra solo está llena con la vida llena: 29 de 30 no se ve como 30 de 30. Además hay una regla: un héroe vivo nunca ve una barra vacía. Con 1 de 30 la división da 0, y la barra muestra un lugar.
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/30Más precisamente, para curiosos
¿Por qué no redondear al más cercano? Entonces 29 de 30 da 10 lugares (una barra llena para un héroe herido), y 1 de 30 da 0 (una barra vacía para uno vivo). Los dos extremos son justo donde el jugador mira la barra. Con la división entera y la regla de «al menos un lugar», una barra llena significa vida llena y una barra vacía significa muerte.
El ancho en bar es solo la parte que se puede llenar: la barra ocupa width + 2 celdas por culpa de [ y ].
Pruébalo
¿Qué saldrá?
El héroe tiene 29 de vida sobre 30, y el siervo que está a su lado 1 sobre 30. ¿Qué imprime el programa?
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 es 9,67, y la división entera da 9: la barra no está llena, porque el héroe tampoco. 1 × 10 / 30 da 0, pero el siervo está vivo, así que la barra muestra un lugar.
Pruébalo
El borde derecho
Este es el bucle de box que dibuja los bordes laterales. El izquierdo está en la columna x. ¿En qué columna está el derecho?
for row in range(y, y + height):
self.put(x, row, "|", "gray")
self.put(, row, "|", "gray")Un marco de ancho width ocupa las columnas de x a x + width - 1. La columna x + width es la primera después del marco: con ella el marco se vuelve una columna más ancho, y las esquinas derechas no quedan en el borde.
Míralo
El mapa, el inventario y la barra
Aquí está el fotograma completo desde el principio: el mapa, el héroe, el lobo, el inventario sobre el mapa y la línea de estado abajo. El búfer se imprime solo al final, fila por fila:
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, "Inventario")buffer.text(9, 2, "Poción x2", "red")buffer.text(9, 3, "Hacha", "default")buffer.text(9, 4, "Oro 35", "yellow")buffer.text(0, 7, "Iva, bruja nv.2", "white")buffer.text(0, 8, "V", "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))###############################......+- Inventario +.......##..@...| Poción x2 |..>....##......| Hacha |.......#####.##| Oro 35 |...w...##......+-------------+.......###############################Iva, bruja nv.2V [########--] 24/30 M [##---]El inventario está sobre el mapa, y el lobo todavía se ve a la derecha, porque el marco cubre solo sus propias celdas. Las líneas del código no están en el orden de las filas de la pantalla: la línea de estado podría haberse dibujado primero, y el fotograma sería el mismo.
La idea
La terminal decide cómo
Falta saber cómo llega el búfer a la pantalla. La respuesta depende de quién mira: la comprobación quiere texto simple, el jugador quiere colores, el navegador quiere HTML. Así que el búfer no se imprime solo. Imprime un terminal, y todo terminal tiene un solo método: present(buffer), «muestra este fotograma».
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 imprime las filas con row_text, quita las filas vacías del final y añade ---- para cerrar el fotograma. Si el fotograma es igual al último, no imprime nada: un comando que no cambia nada (un comando equivocado, una mirada a algo ya visto) no debe llenar la salida de fotogramas idénticos.
AnsiTerminal envía códigos que la terminal ejecuta en lugar de mostrarlos. Cada uno empieza con el carácter ESC (código 27):
ESC[2Jborra la pantalla,ESC[Hpone el cursor arriba a la izquierda;ESC[31msignifica «de aquí en adelante, rojo»,ESC[90mgris,ESC[33mamarillo,ESC[0m«vuelve al color normal».
El color en la terminal es estado: se queda hasta que lo cambias. Por eso AnsiTerminal escribe un código solo donde el color de una celda difiere del anterior, y vuelve al color normal al final de cada fila.
Python no tiene interfaces. Las dos clases tienen un método present con los mismos parámetros, y eso basta: el juego llama a present sin preguntar qué tipo de terminal es (duck typing). Si quieres dejar el contrato por escrito, un typing.Protocol con un método present lo hace sin que las clases hereden nada.
En los programas de la lección, ESC, los códigos y la salida a la terminal están aquí:
ESC = "\x1b"CODES = { "default": ESC + "[0m", "red": ESC + "[31m", "yellow": ESC + "[33m", "blue": ESC + "[34m", "gray": ESC + "[90m", "white": ESC + "[97m",}
# Hacia la terminal. ESC se imprime como \e para que se vea en la salida.def write(text): print(text.replace(ESC, "\\e"), end="")write imprime ESC como el texto \e para que puedas verlo en la salida de esta página. En una terminal de verdad está el carácter mismo, y en lugar de \e[31m ves rojo.
Míralo
Un búfer, dos terminales
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, "Inventario")buffer.text(9, 2, "Poción x2", "red")buffer.text(9, 3, "Hacha", "default")buffer.text(9, 4, "Oro 35", "yellow")buffer.text(0, 7, "Iva, bruja nv.2", "white")buffer.text(0, 8, "V", "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) # el mismo fotograma: no se imprime otra vezansi.present(buffer)Paso a paso, en palabras
- Un búfer vacío: 30 × 9 = 270 celdas, cada una es un espacio de color default. Todavía no se ha impreso nada.
- El mapa: text escribe cada fila carácter a carácter, y cada carácter es un put en una celda. 7 filas de 30, todas grises.
- put escribe un carácter con un color: el héroe @ en (3, 2), blanco, y el lobo w en (25, 4), rojo. La celda recuerda las dos cosas.
- box vacía primero sus 15 × 5 celdas (fill_rect) y luego dibuja el borde y el título. El mapa de debajo se sobrescribe en el búfer, no en la pantalla: la pantalla sigue vacía.
- Los objetos van dentro del marco: Poción x2, Hacha, Oro 35. El orden de dibujo decide qué queda encima: el inventario va después del mapa.
- La línea de estado: 24 de 30 en 10 lugares son 8 #, y 5 de 12 en 5 lugares son 2. Toda la imagen está lista, y la terminal todavía no ha visto nada.
- PlainTerminal: solo los caracteres, fila por fila, sin los espacios del final (la fila y = 7 tiene 15 caracteres, no 30), y ---- para terminar el fotograma. Los colores se pierden.
- El mismo búfer otra vez: PlainTerminal compara el fotograma con el último impreso y no imprime nada.
- AnsiTerminal: el mismo búfer, con colores. Un código de color se escribe solo donde cambia el color: 35 códigos para 9 filas, no uno por celda.
- El juego dibujó una sola vez. Qué terminal lo muestra lo decide una línea al inicio, y el código que dibuja no lo sabe.
El programa dibuja el fotograma de arriba y se lo entrega a las dos terminales. PlainTerminal lo recibe dos veces, pero lo imprime una sola:
plain = PlainTerminal()ansi = AnsiTerminal()plain.present(buffer)plain.present(buffer) # el mismo fotograma: no se imprime otra vezansi.present(buffer)###############################......+- Inventario +.......##..@...| Poción x2 |..>....##......| Hacha |.......#####.##| Oro 35 |...w...##......+-------------+.......###############################Iva, bruja nv.2V [########--] 24/30 M [##---]----\e[2J\e[H\e[90m##############################\e[0m\e[90m#......+-\e[33m Inventario \e[90m+.......#\e[0m\e[90m#..\e[97m@\e[90m...|\e[0m \e[31mPoción x2\e[0m \e[90m|..>....#\e[0m\e[90m#......|\e[0m Hacha \e[90m|.......#\e[0m\e[90m####.##|\e[0m \e[33mOro 35\e[0m \e[90m|...\e[31mw\e[90m...#\e[0m\e[90m#......+-------------+.......#\e[0m\e[90m##############################\e[0m\e[97mIva, bruja nv.2\e[0m\e[31mV\e[0m \e[90m[\e[31m########\e[90m--]\e[0m 24/30 \e[34mM\e[0m \e[90m[\e[34m##\e[90m---]\e[0mFíjate en lo pocas veces que aparece un código en la salida de AnsiTerminal: 35 códigos para 270 celdas. Una fila de mapa gris es un solo código, y el \e[0m dentro del fotograma marca dónde las celdas vaciadas tienen el color normal.
Pruébalo
Encuentra el error
Un compañero tocó AnsiTerminal, y el mensaje bajo la barra de vida salió gris cuando debía tener el color normal. Esto es lo que envía el programa (\e es ESC):
buffer = CellBuffer(30, 2)buffer.text(0, 0, "V", "red")buffer.bar(2, 0, 10, 24, 30, "red")buffer.text(0, 1, "El lobo te muerde.", "default")AnsiTerminal().present(buffer)\e[2J\e[H\e[31mV\e[0m \e[90m[\e[31m########\e[90m--]El lobo te muerde.¿En qué línea está el error?
Elige la línea con el error.
La fila termina sin restablecer el color. La última celda de la barra es ], gris, y la terminal sigue gris también en la fila siguiente. El programa, en cambio, empieza la fila nueva con color = "default", cree que el color ya es normal y no envía ningún código para la primera letra. El final correcto de una fila es ESC[0m y luego el salto de línea: así lo que cree el programa y lo que hay en la terminal vuelven a coincidir.
La idea
Solo lo que cambió
PlainTerminal ya se salta un fotograma igual al último. Eso ahorra al nivel de un fotograma: todo o nada. Pero en un juego por turnos la mayoría de los turnos cambian dos o tres celdas: el héroe da un paso, y las otras 250 siguen igual. AnsiTerminal aun así borra la pantalla y lo envía todo otra vez.
Una terminal mejor recuerda lo que ha mostrado (un segundo búfer, last) y compara celda por celda. Por cada celda que difiere mueve el cursor con ESC[fila;columnaH y escribe solo esa celda:
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 frameHay dos detalles que pillan a todo el que escribe esto por primera vez. La fila y la columna en ESC[fila;columnaH empiezan en 1, no en 0, y la fila va primero. Y last se actualiza enseguida con la celda enviada: si no, el fotograma siguiente envía las mismas celdas otra vez.
El programa dibuja el mundo sin el inventario. Luego el héroe da un paso y el lobo muerde. show imprime cuántas celdas y cuántos caracteres envía cada fotograma, y las actualizaciones cortas completas:
buffer = CellBuffer(30, 9)screen = DiffTerminal()draw_world(buffer)show(1, screen, buffer)buffer.put(3, 2, ".", "gray") # el héroe da un paso a la derechabuffer.put(4, 2, "@", "white")show(2, screen, buffer)buffer.bar(2, 8, 10, 21, 30, "red") # el lobo muerde: 24 → 21buffer.text(15, 8, "21/30", "default")show(3, screen, buffer)show(4, screen, buffer) # no ha cambiado nadafotograma 1: 251 celdas, 3186 caracteresfotograma 2: 2 celdas, 28 caracteres\e[3;4H\e[90m.\e[3;5H\e[97m@\e[0mfotograma 3: 2 celdas, 29 caracteres\e[9;11H\e[90m-\e[9;17H\e[0m1\e[0mfotograma 4: 0 celdas, 0 caracteres- El fotograma 1 envía 251 celdas y 3186 caracteres: la pantalla está vacía y todo es nuevo. Eso es más que el fotograma completo de AnsiTerminal (419 caracteres), porque cada celda viene con su propio movimiento del cursor y su propio color.
- El paso son 2 celdas y 28 caracteres: el lugar viejo pasa a ser
., el nuevo@. - La mordida cambia un
#en la barra y un dígito: de24a21solo cambia el4. - El fotograma 4 no envía nada. Que PlainTerminal se salte un fotograma idéntico es la misma idea, reducida a «0 celdas».
¿Por qué vale la pena? Cuando la terminal está al otro lado de una red (ssh, un servidor donde juegan varias personas), cada carácter viaja por la red: 28 caracteres en lugar de los 419 que AnsiTerminal envía por un fotograma entero es una diferencia que se siente en cada turno. Las terminales lentas dibujan carácter a carácter, y ESC[2J primero muestra una pantalla vacía y luego la llena desde arriba: la pantalla parpadea. Actualizar en el sitio no parpadea.
El fotograma 1 muestra también el otro lado: cuando casi todo cambia (una pantalla nueva, se abre el inventario), un fotograma entero sale más barato que celda por celda. Por eso las bibliotecas de terminal (como curses) envían la pantalla entera para un cambio grande y solo la diferencia para uno pequeño.
La idea
En el juego real
El juego canónico tiene las mismas partes. Su búfer es de 60 × 18 y además tiene clear(): en cada turno la pila de pantallas lo dibuja todo otra vez en un búfer limpio.
# El juego no imprime directamente: dibuja en un búfer de celdas (símbolo + color), y el terminal decide cómo# mostrarlo. Así el mismo dibujo se vuelve texto simple y también una pantalla ANSI en color.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')
# Un marco con el interior vacío: las pantallas transparentes (menú, inventario) se dibujan en un marco sobre el mapa. 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')
# Barra [#####-----]: la parte llena es proporcional al valor. 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 lineLas dos terminales:
# Dos terminales con la misma interfaz: present(buffer) muestra el fotograma, echo(line) muestra el comando.# PlainTerminal imprime texto (para la comprobación y las lecciones) y omite un fotograma igual al anterior.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 borra la pantalla y añade color: la sangre es roja, la magia azul, el veneno verde.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): passHay tres diferencias con la lección, y cada una tiene su razón:
echo(line). PlainTerminal escribe el comando (> w) después del fotograma: así el recorrido con el que se comprueba el juego se lee como una conversación. AnsiTerminal no lo escribe, porque el jugador ve lo que tecleó.ESC[0mantes de cada color. El blanco en el juego es1;37: negrita y blanco. Sin restablecer, la negrita se queda también en el color siguiente. En la lección todos los códigos cambian solo el color, así que el restablecimiento no hace falta.- Sin DiffTerminal. El juego es por turnos: un fotograma por comando, y el jugador espera de todos modos hasta teclear el siguiente. Si algún día va por una red, DiffTerminal es una tercera clase, y nada más cambia.
Qué terminal es lo decide una línea al inicio del bucle del juego: con --plain es PlainTerminal, si no AnsiTerminal. La línea de estado es una función que dibuja en el búfer y no sabe nada de la terminal:
# La barra inferior: nombre, clase, nivel, vida y maná con barras, oro, lugar. Los tres últimos mensajes.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)
# El dígito tras un comando: «d5» son cinco pasos a la derecha. Devuelve None si no es un número.def command_number(command, start): if len(command) <= start: return None return parse_int_strict(command[start:])Al final de la región hay también un pequeño ayudante para los comandos (d5 son cinco pasos a la derecha): está aquí porque la línea de estado y los comandos pertenecen los dos al mapa.
Más precisamente
Búferes, anchos, colores
Doble búfer. last en DiffTerminal es lo que en gráficos se llama un doble búfer: un búfer es el que está en pantalla, y el fotograma siguiente se dibuja en el otro. En los juegos con tarjeta gráfica los dos se intercambian de una vez para que nunca se vea medio fotograma. Aquí dibujamos el fotograma entero antes de llamar a present, así que el jugador nunca ve medio fotograma.
El ancho de los caracteres. En una terminal típica, las letras cirílicas y los caracteres para dibujar cajas (─, │, ┌) ocupan una columna cada uno. El estándar Unicode, sin embargo, les da a ambos un ancho «ambiguo»: una terminal configurada para chino o japonés puede dibujarlos en dos columnas. Los emoji y los ideogramas CJK siempre ocupan dos. Hay además un segundo problema: los emoji están fuera del plano básico de Unicode, y en Java, C# y JavaScript la longitud de una cadena con un emoji es 2 (dos mitades en UTF-16), mientras que en Python es 1. Un búfer que escribe un carácter de la cadena por celda lo corta en dos. Por eso el juego canónico dibuja sus marcos en ASCII: +, -, |, # ocupan una columna en todas partes y son un carácter en todos los lenguajes.
16, 256 y millones de colores. ESC[31m a ESC[37m y ESC[90m a ESC[97m son los 16 colores básicos. Su tono exacto lo elige el tema de la terminal, para que el rojo siga siendo legible sobre un fondo claro y sobre uno oscuro. También existen ESC[38;5;Nm (una paleta de 256) y ESC[38;2;R;G;Bm (un color exacto), pero no todas las terminales los entienden, y un color exacto puede confundirse con el fondo de alguien. El juego usa los 16.
Windows y la salida redirigida. Windows Terminal entiende los códigos ANSI. La vieja ventana de consola los entiende desde Windows 10, pero el programa tiene que activarlos (el modo ENABLE_VIRTUAL_TERMINAL_PROCESSING), si no la pantalla muestra ←[31m. Aparte, cuando la salida se redirige a un archivo, los códigos son basura en el archivo. Los programas comprueban si escriben en una terminal: sys.stdout.isatty(). El juego deja la elección al jugador: --plain.
Ejercicio
La ventana de combate
Resuélvelo en tu propio editor, con el CellBuffer de la lección.
La ventana de combate. En un búfer de 36 × 5, dibuja un marco alrededor de todo el búfer con el título «Combate». Dentro, una fila por enemigo: el nombre desde la columna 2, una barra de ancho 10 desde la columna 17 y la vida como actual/máx desde la columna 30. Un enemigo muerto (vida 0) es gris y no tiene barra ni vida, solo muerto desde la columna 17. Imprime el búfer fila por fila.
names = ["lobo jefe", "rata gigante", "goblin lancero"]hp = [20, 1, 0]max_hp = [20, 9, 13]+- Combate ------------------------+| lobo jefe [##########] 20/20|| rata gigante [#---------] 1/9 || goblin lancero muerto |+----------------------------------+Más tareas
- Un registro en un marco. Una función que dibuje los últimos N mensajes de combate en un marco y recorte los largos a su ancho, con
...al final. - Menos movimientos. En DiffTerminal, no envíes
ESC[fila;columnaHcuando la celda cambiada viene justo después de la anterior en la misma fila: el cursor ya está ahí. ¿Cuántos caracteres ahorras en el primer fotograma? - Un tercera terminal. HtmlTerminal, que devuelva el fotograma como un
<pre>con un<span style="color: …">en cada cambio de color. El juego no cambia.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Polimorfismo · Interfaces como clases abstractas · Listas: append, remove y sort · dict y set
- Ejercicios sobre el tema: Las crónicas de Arcanum