Ir al contenido
wannacode

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:

foreach (string row in MAP) Console.WriteLine(row);
Console.WriteLine("V 24/30 M 5/12");
// El jugador pulsa i: el inventario debe aparecer sobre el mapa.
Console.WriteLine("+- Inventario -+");
Console.WriteLine("| Poción x2 |");
Console.WriteLine("| Hacha |");
Console.WriteLine("+--------------+");
Salida
##############################
#......#..........~~~........#
#......+..........~~~...>....#
#......#..........~~~........#
####.#####...................#
#............................#
##############################
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) Console.WriteLine("El lobo te muerde: -3");
else Console.WriteLine("\u001b[31mEl lobo te muerde: -3\u001b[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
{
public readonly int Width;
public readonly int Height;
public readonly char[][] Chars;
public readonly string[][] Colors;
public CellBuffer(int width, int height)
{
Width = width;
Height = height;
Chars = new char[height][];
Colors = new string[height][];
for (int y = 0; y < height; y++)
{
Chars[y] = new char[width];
Colors[y] = new string[width];
Array.Fill(Chars[y], ' ');
Array.Fill(Colors[y], "default");
}
}
public 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;
}
public void Text(int x, int y, string text, string color)
{
for (int i = 0; i < text.Length; i++) Put(x + i, y, text[i], color);
}
public string RowText(int y) => new string(Chars[y]).TrimEnd(' ');
}
  • Put escribe una celda. Si el lugar está fuera del búfer, no hace nada.
  • Text escribe una cadena carácter a carácter, cada uno con Put.
  • RowText devuelve 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:

var buffer = new CellBuffer(12, 2);
buffer.Text(0, 0, "V 24/30", "red");
buffer.Text(8, 1, "Guardián", "red");
for (int y = 0; y < buffer.Height; y++) Console.WriteLine(buffer.RowText(y));
Salida
V 24/30
Guar

La 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

Un carácter es un char: una unidad UTF-16. Las letras latinas, las letras con acento como é y los caracteres #, +, | son un char cada una, así que un carácter de la cadena 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:

public 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");
}
}
public 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.Length > 0) Text(x + 2, y, " " + title + " ", "yellow");
}
public 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 primero vacía su área con FillRect: 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.

var 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++) Console.WriteLine(buffer.RowText(y));
Salida
[##########] 30/30
[#####-----] 15/30
[#---------] 1/30
[----------] 0/30
Má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?

var 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++) Console.WriteLine(buffer.RowText(y));
Respuesta

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 (int row = y; row < y + height; row++)
{
    Put(x, row, '|', "gray");
    Put(, row, '|', "gray");
}

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:

var 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, "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 (int y = 0; y < buffer.Height; y++) Console.WriteLine(buffer.RowText(y));
Salida
##############################
#......+- Inventario +.......#
#..@...| Poción x2 |..>....#
#......| Hacha |.......#
####.##| Oro 35 |...w...#
#......+-------------+.......#
##############################
Iva, bruja nv.2
V [########--] 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».

interface ITerminal
{
void Present(CellBuffer buffer);
}
class PlainTerminal : ITerminal
{
string lastFrame = "";
public void Present(CellBuffer buffer)
{
var lines = new List<string>();
for (int y = 0; y < buffer.Height; y++) lines.Add(buffer.RowText(y));
while (lines.Count > 0 && lines[^1].Length == 0) lines.RemoveAt(lines.Count - 1);
string frame = string.Join("\n", lines) + "\n----\n";
if (frame == lastFrame) return;
Write(frame);
lastFrame = frame;
}
}
class AnsiTerminal : ITerminal
{
public void Present(CellBuffer buffer)
{
string frame = 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] != color)
{
color = buffer.Colors[y][x];
frame += CODES[color];
}
frame += row[x];
}
frame += ESC + "[0m\n";
}
Write(frame);
}
}

PlainTerminal imprime las filas con RowText, 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[2J borra la pantalla, ESC[H pone el cursor arriba a la izquierda;
  • ESC[31m significa «de aquí en adelante, rojo», ESC[90m gris, ESC[33m amarillo, 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.

El juego guarda un ITerminal y llama a present sin saber qué clase hay debajo. Cuál sea lo decide una línea al inicio.

En los programas de la lección, ESC, los códigos y la salida a la terminal están aquí:

const string ESC = "\u001b";
static readonly Dictionary<string, string> CODES = new()
{
["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.
static void Write(string text) => Console.Write(text.Replace(ESC, "\\e"));

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

var 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, "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");
ITerminal plain = new PlainTerminal();
ITerminal ansi = new AnsiTerminal();
plain.Present(buffer);
plain.Present(buffer); // el mismo fotograma: no se imprime otra vez
ansi.Present(buffer);
Paso a paso, en palabras
  1. Un búfer vacío: 30 × 9 = 270 celdas, cada una es un espacio de color default. Todavía no se ha impreso nada.
  2. 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.
  3. 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.
  4. Box vacía primero sus 15 × 5 celdas (FillRect) 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.
  5. 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.
  6. 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.
  7. 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.
  8. El mismo búfer otra vez: PlainTerminal compara el fotograma con el último impreso y no imprime nada.
  9. 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.
  10. 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:

ITerminal plain = new PlainTerminal();
ITerminal ansi = new AnsiTerminal();
plain.Present(buffer);
plain.Present(buffer); // el mismo fotograma: no se imprime otra vez
ansi.Present(buffer);
Salida
##############################
#......+- Inventario +.......#
#..@...| Poción x2 |..>....#
#......| Hacha |.......#
####.##| Oro 35 |...w...#
#......+-------------+.......#
##############################
Iva, bruja nv.2
V [########--] 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[0m

Fí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):

var buffer = new 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");
new AnsiTerminal().Present(buffer);
Salida
\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 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 : ITerminal
{
CellBuffer? last;
public int Cells;
public void Present(CellBuffer buffer) => Write(Changes(buffer));
public string Changes(CellBuffer buffer)
{
string frame = "";
if (last == null)
{
last = new CellBuffer(buffer.Width, buffer.Height);
frame += 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 == last.Colors[y][x]) continue;
frame += ESC + "[" + (y + 1) + ";" + (x + 1) + "H";
frame += CODES[color] + ch;
last.Put(x, y, ch, color);
Cells++;
}
}
if (Cells > 0) frame += CODES["default"];
return frame;
}
}

Hay 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:

var buffer = new CellBuffer(30, 9);
var screen = new DiffTerminal();
DrawWorld(buffer);
Show(1, screen, buffer);
buffer.Put(3, 2, '.', "gray"); // el héroe da un paso a la derecha
buffer.Put(4, 2, '@', "white");
Show(2, screen, buffer);
buffer.Bar(2, 8, 10, 21, 30, "red"); // el lobo muerde: 24 → 21
buffer.Text(15, 8, "21/30", "default");
Show(3, screen, buffer);
Show(4, screen, buffer); // no ha cambiado nada
Salida
fotograma 1: 251 celdas, 3186 caracteres
fotograma 2: 2 celdas, 28 caracteres
\e[3;4H\e[90m.\e[3;5H\e[97m@\e[0m
fotograma 3: 2 celdas, 29 caracteres
\e[9;11H\e[90m-\e[9;17H\e[0m1\e[0m
fotograma 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: de 24 a 21 solo cambia el 4.
  • 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.
static partial class Arcanum
{
public const int SCREEN_WIDTH = 60;
public const int SCREEN_HEIGHT = 18;
public const int VIEW_WIDTH = 40;
public const int VIEW_HEIGHT = 13;
}
class CellBuffer
{
public int Width;
public int Height;
public char[][] Chars;
public string[][] Colors;
public CellBuffer(int width, int height)
{
Width = width;
Height = height;
Chars = new char[height][];
Colors = new string[height][];
for (int y = 0; y < height; y++)
{
Chars[y] = new char[width];
Colors[y] = new string[width];
Array.Fill(Chars[y], ' ');
Array.Fill(Colors[y], "default");
}
}
public void Clear()
{
for (int y = 0; y < Height; y++)
{
for (int x = 0; x < Width; x++)
{
Chars[y][x] = ' ';
Colors[y][x] = "default";
}
}
}
public 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;
}
public void Text(int x, int y, string text, string color)
{
for (int i = 0; i < text.Length; i++) Put(x + i, y, text[i], color);
}
public 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");
}
}
// Un marco con el interior vacío: las pantallas transparentes (menú, inventario) se dibujan en un marco sobre el mapa.
public 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.Length > 0) Text(x + 2, y, " " + title + " ", "yellow");
}
// Barra [#####-----]: la parte llena es proporcional al valor.
public 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");
}
public string RowText(int y)
{
int end = Width;
while (end > 0 && Chars[y][end - 1] == ' ') end--;
return new string(Chars[y], 0, end);
}
}

Las 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.
interface ITerminal
{
void Present(CellBuffer buffer);
void Echo(string line);
}
class PlainTerminal : ITerminal
{
public IGameIo Io;
public string LastFrame;
public PlainTerminal(IGameIo io)
{
Io = io;
LastFrame = "";
}
public void Present(CellBuffer buffer)
{
List<string> lines = new List<string>();
for (int y = 0; y < buffer.Height; y++) lines.Add(buffer.RowText(y));
while (lines.Count > 0 && lines[lines.Count - 1].Length == 0) lines.RemoveAt(lines.Count - 1);
string frame = string.Join("\n", lines) + "\n----\n";
if (frame != LastFrame)
{
Io.Write(frame);
LastFrame = frame;
}
}
public void Echo(string line)
{
Io.Write("> " + line + "\n");
}
}
static partial class Arcanum
{
public static readonly Dictionary<string, string> ANSI_CODES = new Dictionary<string, string>
{
["default"] = "\u001b[0m", ["red"] = "\u001b[31m", ["green"] = "\u001b[32m", ["yellow"] = "\u001b[33m",
["blue"] = "\u001b[34m", ["magenta"] = "\u001b[35m", ["cyan"] = "\u001b[36m", ["white"] = "\u001b[1;37m", ["gray"] = "\u001b[90m",
};
}
// AnsiTerminal borra la pantalla y añade color: la sangre es roja, la magia azul, el veneno verde.
class AnsiTerminal : ITerminal
{
public IGameIo Io;
public AnsiTerminal(IGameIo io)
{
Io = io;
}
public void Present(CellBuffer buffer)
{
string output = "\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] != color)
{
color = buffer.Colors[y][x];
output += "\u001b[0m" + ANSI_CODES[color];
}
output += buffer.Chars[y][x];
}
output += "\u001b[0m\n";
}
Io.Write(output + "> ");
}
public void Echo(string line) { }
}

Hay 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[0m antes de cada color. El blanco en el juego es 1;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.
static partial class Arcanum
{
public 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");
}
public static void DrawMessages(CellBuffer buffer, Game game, int row, int count)
{
int first = Math.Max(0, game.Messages.Count - count);
for (int i = first; i < game.Messages.Count; i++) buffer.Text(0, row + i - first, game.Messages[i], "default");
}
public static string EffectLabels(Creature creature)
{
List<string> labels = new List<string>();
foreach (Effect effect in creature.Effects) labels.Add(effect.Label());
return string.Join(" ", labels);
}
// El dígito tras un comando: «d5» son cinco pasos a la derecha. Devuelve null si no es un número.
public static long? CommandNumber(string command, int start)
{
if (command.Length <= start) return null;
return ParseIntStrict(command.Substring(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: Console.IsOutputRedirected. 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.

string[] names = { "lobo jefe", "rata gigante", "goblin lancero" };
int[] hp = { 20, 1, 0 };
int[] maxHp = { 20, 9, 13 };
Salida
+- 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;columnaH cuando 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

1. ¿Por qué el juego dibuja en un búfer en lugar de imprimir directamente?
2. ¿Por qué AnsiTerminal envía un código de color solo donde cambia el color?
3. Quieres que el juego corra en un navegador y que los colores sean elementos <span>. ¿Qué cambias?
4. El héroe da un paso a la derecha. ¿Qué envía DiffTerminal?
5. Un compañero cambió la comprobación de put a x > width. ¿Qué pasa con el texto que llega al borde derecho?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·