Ir al contenido
wannacode

Una pila de pantallas

En resumen: Una pila de pantallas mantiene las pantallas abiertas una sobre otra: la entrada va a la de arriba, push abre una nueva, pop la cierra, replace la cambia, y las transparentes se dibujan sobre las que tienen debajo.

Problema

Una variable para todas las pantallas

El juego tiene una pantalla de título, una aldea, el mapa, un combate, un inventario que se abre sobre el mapa y un menú que se abre sobre el combate. Cada pantalla lee los comandos a su manera: en el mapa a es un paso a la izquierda, en el combate es un golpe, en el inventario no es nada.

El primer intento que casi todos hacen: una variable con el nombre de la pantalla y un if sobre ella. Una vez al manejar el comando y otra vez al dibujar:

string screen = "title";
int wolf = 2;
string? line;
while ((line = Console.ReadLine()) != null)
{
string command = line.Trim();
if (screen == "title")
{
if (command == "n") screen = "map";
}
else if (screen == "map")
{
if (command == "i") screen = "inventory";
if (command == "f") screen = "battle";
}
else if (screen == "battle")
{
if (command == "i") screen = "inventory";
if (command == "a")
{
wolf--;
if (wolf == 0) screen = "map";
}
}
else if (screen == "inventory")
{
if (command == "x") screen = "map";
}
Console.Write(command + " → ");
if (screen == "title") Console.WriteLine("Las crónicas de Arcanum");
else if (screen == "map") Console.WriteLine("Mapa, lobo: " + wolf);
else if (screen == "battle") Console.WriteLine("Combate, lobo: " + wolf);
else if (screen == "inventory") Console.WriteLine("Inventario: Poción x2");
}
Salida
n → Mapa, lobo: 2
i → Inventario: Poción x2
x → Mapa, lobo: 2
f → Combate, lobo: 2
i → Inventario: Poción x2
x → Mapa, lobo: 2
a → Mapa, lobo: 2
a → Mapa, lobo: 2

Los comandos llegan uno por línea: n juego nuevo, i inventario, x cerrar, f luchar contra el lobo, a golpe.

Entrada
n
i
x
f
i
x
a
a

Mira la sexta línea. El inventario se abrió desde el combate, y x lo cierra hacia el mapa. El combate ya no está, el lobo sigue vivo con 2 de vida, y los dos golpes siguientes caen en el mapa, donde a no significa nada. La variable recuerda dónde estás, pero no de dónde vienes.

El segundo problema aparece cuando el juego crece. Cada pantalla nueva es una rama nueva en cada if: en el manejo, en el dibujo y en cada pantalla que la abre. Un menú sobre el combate y sobre el mapa son dos ramas más en dos lugares. Si olvidas una, tienes una pantalla que recibe comandos pero nunca se dibuja.

La idea

Una pantalla decide sobre sí misma

Da la vuelta a la pregunta. En vez de que el bucle pregunte «¿en qué pantalla estoy?» y conozca todas las pantallas, cada pantalla es un objeto con los mismos cuatro métodos:

  • Handle: qué significa el comando aquí;
  • Update: qué pasa en cada turno mientras la pantalla está arriba (en el combate, el lobo muerde);
  • Draw: qué dibuja la pantalla;
  • IsTransparent: si la pantalla de abajo se ve a través.
abstract class Screen
{
public Game Game;
public string Name;
public Screen(Game game, string name)
{
Game = game;
Name = name;
}
public virtual bool IsTransparent()
{
return false;
}
public abstract void Handle(string command);
public virtual void Update() { }
public abstract void Draw(TextBuffer buffer);
}
abstract dice que cada pantalla debe tener su propio Handle y su propio Draw: sin ellos la clase no compila. Update e IsTransparent tienen un comportamiento por defecto, y una pantalla escribe el suyo solo si lo necesita.

Aquí están el combate y el inventario. El combate solo sabe de sí mismo: i abre el inventario encima, a golpea, y cuando el lobo cae, el combate se quita a sí mismo. El inventario no sabe desde dónde se abrió. Solo dice «quítame»:

class BattleScreen : Screen
{
public BattleScreen(Game game) : base(game, "Combate") { }
public override void Handle(string command)
{
if (command == "i") Game.Stack.Push(new InventoryScreen(Game));
if (command == "a")
{
Game.Wolf--;
if (Game.Wolf == 0) Game.Stack.Pop();
}
}
// En cada turno del combate el lobo muerde.
public override void Update()
{
if (Game.Wolf > 0) Game.Life--;
}
public override void Draw(TextBuffer buffer)
{
buffer.Box(0, 0, TextBuffer.W, TextBuffer.H);
buffer.Write(2, 1, "Lobo: " + Game.Wolf);
buffer.Write(2, 2, "Vida: " + Game.Life);
buffer.Write(2, 3, "a: golpe i: mochila");
}
}
class InventoryScreen : Screen
{
public InventoryScreen(Game game) : base(game, "Inventario") { }
public override void Handle(string command)
{
if (command == "x") Game.Stack.Pop();
}
public override void Draw(TextBuffer buffer)
{
buffer.Box(12, 1, 11, 3);
buffer.Write(13, 2, "Poción x2");
}
}

Las pantallas dibujan en un TextBuffer: cinco filas de 24 caracteres. Write pone texto en un lugar dado, Box dibuja un marco. El búfer completo está en el programa de abajo, y cómo funciona el dibujo en una terminal real está en la lección sobre el dibujo.

Una pantalla nueva es una clase nueva y una línea en la pantalla que la abre. No crece ningún if sobre nombres de pantalla.

Más precisamente, para curiosos

Así se ve en el juego. La región screen del canon:

// Cada pantalla es un objeto con la misma interfaz. El juego no pregunta «en qué pantalla estoy»; manda el comando a
// la pantalla que está arriba de la pila.
abstract class Screen
{
public Game Game;
public string KindName = "";
public Screen(Game game)
{
Game = game;
}
// Una pantalla transparente (menú, inventario, diálogo) deja ver la pantalla de debajo.
public virtual bool IsTransparent()
{
return false;
}
public virtual void Handle(string command) { }
public virtual void Update() { }
public virtual void Draw(CellBuffer buffer) { }
// La pantalla de debajo de la que se quita recibe su respuesta (el objetivo elegido, «sí» o «no», la ranura elegida).
public virtual void OnResult(object? result) { }
}

El método OnResult es para las pantallas que devuelven una respuesta (el objetivo elegido, «sí» o «no»). Es el tema de la siguiente lección.

La idea

La pila: de dónde vienes

Las pantallas se apilan una sobre otra: el inventario sobre el combate, el combate sobre el mapa. Cuando cierras la de arriba, quieres estar exactamente donde estabas. Eso es una pila: el último que entra es el primero que sale. La pila de pantallas tiene tres operaciones:

  • push: abrir una pantalla nueva encima (el mapa abre el combate, el combate abre el inventario);
  • pop: cerrar la de arriba y volver a la de debajo;
  • replace: cambiar la de arriba por una nueva, sin camino de vuelta (la pantalla de título se convierte en el mapa, la creación del héroe se convierte en la aldea).
class ScreenStack
{
public List<Screen> Screens = new List<Screen>();
public void Push(Screen screen)
{
Screens.Add(screen);
}
public void Pop()
{
Screens.RemoveAt(Screens.Count - 1);
}
public void Replace(Screen screen)
{
Pop();
Push(screen);
}
public Screen Top()
{
return Screens[Screens.Count - 1];
}
public string Names()
{
var names = new List<string>();
foreach (Screen screen in Screens) names.Add(screen.Name);
return "[" + string.Join(", ", names) + "]";
}
public void Draw(TextBuffer buffer)
{
Top().Draw(buffer);
}
}

El bucle del juego da el comando solo a la cima. No al mapa, no al combate que hay bajo el inventario. Solo la cima sabe qué significa el comando en este momento. Los mismos comandos, ahora con la pila. Show imprime la pila y el fotograma después de cada comando:

static void Show(Game game, TextBuffer buffer)
{
Console.WriteLine(game.Stack.Names());
buffer.Clear();
game.Stack.Draw(buffer);
buffer.Print();
}
var game = new Game();
var buffer = new TextBuffer();
game.Stack.Push(new TitleScreen(game));
Show(game, buffer);
string? line;
while ((line = Console.ReadLine()) != null)
{
string command = line.Trim();
Console.WriteLine("> " + command);
Screen top = game.Stack.Top();
top.Handle(command);
top.Update();
Show(game, buffer);
}
Salida
[Título]
Las crónicas de Arcanum
n: juego nuevo
> n
[Mapa]
########################
#..@......~~~..........#
#.......w.~~~..........#
########################
Vida: 10
> i
[Mapa, Inventario]
+---------+
|Poción x2|
+---------+
> x
[Mapa]
########################
#..@......~~~..........#
#.......w.~~~..........#
########################
Vida: 10
> f
[Mapa, Combate]
+----------------------+
| Lobo: 2 |
| Vida: 10 |
| a: golpe i: mochila |
+----------------------+
> i
[Mapa, Combate, Inventario]
+---------+
|Poción x2|
+---------+
> x
[Mapa, Combate]
+----------------------+
| Lobo: 2 |
| Vida: 9 |
| a: golpe i: mochila |
+----------------------+
> a
[Mapa, Combate]
+----------------------+
| Lobo: 1 |
| Vida: 8 |
| a: golpe i: mochila |
+----------------------+
> a
[Mapa]
########################
#..@......~~~..........#
#.........~~~..........#
########################
Vida: 8

El inventario abierto desde el combate ahora se cierra de vuelta al combate: [Mapa, Combate]. El lobo cae con el segundo golpe, el combate se quita a sí mismo, y debajo está el mapa, ahora sin la w. Ninguna pantalla recuerda de dónde vino. La pila sí.

Pero mira los fotogramas con el inventario. El mapa ha desaparecido, y el marco cuelga en el vacío. El Draw de la pila dibuja solo la cima. De eso trata lo siguiente.

Más precisamente, para curiosos

Solo se actualiza la cima. El bucle llama a Update en la cima. Mientras el inventario está abierto sobre el combate, el combate espera y el lobo no muerde: el juego está en pausa bajo el inventario. Es una decisión, no una ley. En un juego en tiempo real el mundo bajo el menú puede seguir, y entonces la pila también actualiza las pantallas por debajo de la cima que dicen que no se detienen.

pop sobre una pila vacía. Si la última pantalla se quita a sí misma, la pila queda vacía y el siguiente pop no tiene nada que quitar. En C#, RemoveAt(-1) lanza ArgumentOutOfRangeException. En el juego una pila vacía significa el final: el bucle corre hasta que la pila está vacía, y «Salir» en la pantalla de título vacía la pila.

Pruébalo

Predice la salida

La pila está vacía. ¿Qué hay en ella al final?

var game = new Game();
ScreenStack stack = game.Stack;
stack.Push(new TitleScreen(game));
stack.Replace(new MapScreen(game));
stack.Push(new BattleScreen(game));
stack.Push(new InventoryScreen(game));
stack.Pop();
stack.Pop();
stack.Push(new InventoryScreen(game));
Console.WriteLine(stack.Names());
Respuesta

La idea

Pantallas transparentes

El inventario es un marco pequeño sobre el mapa. Para que el mapa se vea alrededor, hay que dibujar primero el mapa y después el inventario, encima. Como un pintor que pinta primero el fondo y luego las figuras de delante.

Pero no desde la pantalla de abajo del todo. Bajo el combate está el mapa, y el combate lo cubre entero: dibujarlo sería trabajo perdido. Así que el dibujo empieza en la pantalla opaca más alta. Baja desde la cima mientras las pantallas sean transparentes. La primera que no lo sea es el comienzo. Desde ahí, dibuja hacia arriba:

public void Draw(TextBuffer buffer)
{
int first = Screens.Count - 1;
while (first > 0 && Screens[first].IsTransparent()) first--;
for (int i = first; i < Screens.Count; i++)
{
Screens[i].Draw(buffer);
}
}

El inventario dice que es transparente:

class InventoryScreen : Screen
{
public InventoryScreen(Game game) : base(game, "Inventario") { }
public override bool IsTransparent()
{
return true;
}
public override void Handle(string command)
{
if (command == "x") Game.Stack.Pop();
}
public override void Draw(TextBuffer buffer)
{
buffer.Box(12, 1, 11, 3);
buffer.Write(13, 2, "Poción x2");
}
}

Solo estos dos cambios. El mismo bucle, los mismos comandos:

Salida
[Título]
Las crónicas de Arcanum
n: juego nuevo
> n
[Mapa]
########################
#..@......~~~..........#
#.......w.~~~..........#
########################
Vida: 10
> i
[Mapa, Inventario]
########################
#..@......~~+---------+#
#.......w.~~|Poción x2|#
############+---------+#
Vida: 10
> x
[Mapa]
########################
#..@......~~~..........#
#.......w.~~~..........#
########################
Vida: 10
> f
[Mapa, Combate]
+----------------------+
| Lobo: 2 |
| Vida: 10 |
| a: golpe i: mochila |
+----------------------+
> i
[Mapa, Combate, Inventario]
+----------------------+
| Lobo: 2 +---------+|
| Vida: 9 |Poción x2||
| a: golpe +---------+|
+----------------------+
> x
[Mapa, Combate]
+----------------------+
| Lobo: 2 |
| Vida: 9 |
| a: golpe i: mochila |
+----------------------+
> a
[Mapa, Combate]
+----------------------+
| Lobo: 1 |
| Vida: 8 |
| a: golpe i: mochila |
+----------------------+
> a
[Mapa]
########################
#..@......~~~..........#
#.........~~~..........#
########################
Vida: 8

[Mapa, Inventario]: el mapa, con el marco encima, y el lobo se sigue viendo debajo. [Mapa, Combate, Inventario]: el dibujo empieza en el combate, porque no es transparente. El mapa que hay debajo ni siquiera se dibuja.

first > 0 se detiene en la pantalla de abajo del todo: esa siempre se dibuja, aunque sea transparente, porque no hay nada debajo.

Más precisamente, para curiosos

El método Draw de la región screen-stack del canon:

// El dibujo empieza en la pantalla opaca de más arriba y sube desde ahí.
public void Draw(CellBuffer buffer)
{
int first = Screens.Count - 1;
while (first > 0 && Screens[first].IsTransparent()) first--;
for (int i = first; i < Screens.Count; i++)
{
if (i >= 0) Screens[i].Draw(buffer);
}
}

La comprobación i >= 0 es para una pila vacía: entonces first es −1 y no hay nada que dibujar.

¿Por qué no simplemente desde cero? Si cada pantalla opaca lo cubre todo, el resultado es el mismo, pero cada fotograma dibuja todas las pantallas de abajo: mapa, combate, elección de objetivo, diálogo. Y hay algo peor: una pantalla opaca que no escribe todas las celdas deja ver trozos del mapa. «No soy transparente» es una promesa que dice «lo cubro todo», y la pila cuenta con ella.

Míralo

Turno a turno

El mismo programa con el inventario transparente. A la izquierda está la pila: cada placa es una pantalla con lo que dibuja por sí sola. El comando va a la placa de arriba. Las placas desvanecidas están bajo la pantalla opaca más alta y no se dibujan. Bajo la pila está el fotograma que imprime el programa.

class ScreenStack
{
public List<Screen> Screens = new List<Screen>();
public void Push(Screen screen)
{
Screens.Add(screen);
}
public void Pop()
{
Screens.RemoveAt(Screens.Count - 1);
}
public void Replace(Screen screen)
{
Pop();
Push(screen);
}
public Screen Top()
{
return Screens[Screens.Count - 1];
}
public string Names()
{
var names = new List<string>();
foreach (Screen screen in Screens) names.Add(screen.Name);
return "[" + string.Join(", ", names) + "]";
}
public void Draw(TextBuffer buffer)
{
int first = Screens.Count - 1;
while (first > 0 && Screens[first].IsTransparent()) first--;
for (int i = first; i < Screens.Count; i++)
{
Screens[i].Draw(buffer);
}
}
}
string? line;
while ((line = Console.ReadLine()) != null)
{
string command = line.Trim();
Console.WriteLine("> " + command);
Screen top = game.Stack.Top();
top.Handle(command);
top.Update();
Show(game, buffer);
}
Paso a paso, en palabras
  1. El juego empieza con una pantalla en la pila: la pantalla de título. Está en la cima, así que recibe la entrada.
  2. n va a la pantalla de título. Esta pone el mapa en su propio lugar (replace): la pantalla de título no va a volver.
  3. i va al mapa, que pone el inventario encima (push). El inventario es transparente: primero se dibuja el mapa y luego el inventario sobre él.
  4. x va al inventario: se quita a sí mismo (pop). Debajo está el mapa, exactamente como lo dejaste.
  5. f: el mapa pone el combate encima (push). El combate no es transparente, así que el mapa que hay debajo ni siquiera se dibuja.
  6. Ahora i va al combate, no al mapa. El combate abre el inventario sobre sí mismo. El dibujo empieza en el combate, la pantalla opaca más alta. El lobo mordió: vida 9.
  7. x cierra el inventario y debajo está el combate: vuelves de donde viniste. La variable única del principio de la lección te llevó aquí al mapa.
  8. a va al combate: el lobo recibe el golpe, y la pila no cambia. Se ejecuta el update del combate: el lobo muerde de vuelta, vida 8.
  9. El lobo cae. El combate se quita a sí mismo (pop), y el mapa vuelve a estar en la cima, ya sin el lobo.
  10. La entrada va a la cima. El dibujo sube desde la pantalla opaca más alta. Las pantallas que devuelven una respuesta y los cambios que esperan al final del turno son la lección siguiente.

Pruébalo

¿Qué falta?

El dibujo de la pila. Mientras la pantalla en first sea transparente, baja:

public void Draw(TextBuffer buffer)
{
    int first = Screens.Count - 1;
    while (first > 0 && ) first--;
    for (int i = first; i < Screens.Count; i++)
    {
        Screens[i].Draw(buffer);
    }
}

Pruébalo

Encuentra el error

Un compañero reescribió la pila. El programa con los comandos n, i, x imprime:

Salida
[Título]
Las crónicas de Arcanum
n: juego nuevo
> n
[Mapa]
########################
#..@......~~~..........#
#.......w.~~~..........#
########################
Vida: 10
> i
[Mapa, Inventario]
########################
#..@......~~+---------+#
#.......w.~~|Poción x2|#
############+---------+#
Vida: 10
> x
[Mapa, Inventario]
########################
#..@......~~+---------+#
#.......w.~~|Poción x2|#
############+---------+#
Vida: 10

El inventario no se cierra: después de x la pila sigue siendo [Mapa, Inventario]. ¿En qué línea está el error?

Elige la línea con el error.

La idea

La pila en el juego real

La región screen-stack del canon. Los métodos son los mismos, con algunas diferencias:

// La pila no cambia durante el procesamiento: Push, Pop y Replace solo anotan el cambio, y
// Apply() lo aplica después del turno. Si no, una pantalla que se quita de la pila seguiría funcionando.
class ScreenChange
{
public string Kind = "";
public Screen? Screen;
public object? Result;
}
class ScreenStack
{
public List<Screen> Screens = new List<Screen>();
public List<ScreenChange> Changes = new List<ScreenChange>();
public void Push(Screen screen)
{
Changes.Add(new ScreenChange { Kind = "push", Screen = screen, Result = null });
}
public void Pop(object? result)
{
Changes.Add(new ScreenChange { Kind = "pop", Screen = null, Result = result });
}
public void Replace(Screen screen)
{
Changes.Add(new ScreenChange { Kind = "replace", Screen = screen, Result = null });
}
// Vacía toda la pila (cargar, fin del juego); con null la pila queda vacía.
public void Reset(Screen? screen)
{
Changes.Add(new ScreenChange { Kind = "reset", Screen = screen, Result = null });
}
public Screen? Top()
{
return Screens.Count == 0 ? null : Screens[Screens.Count - 1];
}
public bool IsEmpty()
{
return Screens.Count == 0 && Changes.Count == 0;
}
public bool Contains(string kindName)
{
foreach (Screen screen in Screens)
{
if (screen.KindName == kindName) return true;
}
return false;
}
public void Apply()
{
int index = 0;
while (index < Changes.Count)
{
ScreenChange change = Changes[index];
index++;
if (change.Kind == "push")
{
Screens.Add(change.Screen!);
}
else if (change.Kind == "pop")
{
Screens.RemoveAt(Screens.Count - 1);
Screen? below = Top();
if (below != null) below.OnResult(change.Result);
}
else if (change.Kind == "replace")
{
Screens.RemoveAt(Screens.Count - 1);
Screens.Add(change.Screen!);
}
else
{
Screens = new List<Screen>();
if (change.Screen != null) Screens.Add(change.Screen);
}
}
Changes = new List<ScreenChange>();
}
// El dibujo empieza en la pantalla opaca de más arriba y sube desde ahí.
public void Draw(CellBuffer buffer)
{
int first = Screens.Count - 1;
while (first > 0 && Screens[first].IsTransparent()) first--;
for (int i = first; i < Screens.Count; i++)
{
if (i >= 0) Screens[i].Draw(buffer);
}
}
}
  • push, pop y replace no tocan la lista de inmediato. Anotan el cambio en changes, y Apply() los ejecuta después del turno. En nuestro programa el combate se quita a sí mismo en Handle, y justo después el bucle llama a Update en ese mismo combate, que ya no está en la pila. Aquí funciona porque un lobo muerto no muerde. En un juego más grande es un error que tarda mucho en encontrarse. Por qué y cómo se aplazan los cambios es la siguiente lección.
  • pop lleva una respuesta: la pantalla de debajo la recibe en OnResult. Así la pantalla de elección de objetivo devuelve el objetivo al combate. Eso también está en la siguiente lección.
  • reset vacía toda la pila (cargar una partida, el final del juego), y contains pregunta si una pantalla de cierto tipo ya está en la pila (por ejemplo, para que no se abra una segunda pantalla de subida de nivel).

Más precisamente

La misma idea en otros lugares

La navegación en las apps móviles es una pila de pantallas. En Android y iOS abres una pantalla encima (push), y el botón Atrás la quita (pop). Entrar después del inicio de sesión suele ser un replace: Atrás no debe llevar otra vez al formulario de acceso.

Un diálogo modal en el navegador es una pantalla sobre la página. Mientras está abierto, solo él recibe clics y teclas, y la página de debajo se ve pero no reacciona. Igual que el inventario sobre el mapa.

La pila de llamadas es la misma idea en el lenguaje mismo. Un método llama a otro (push), ese devuelve (pop), y el que llamó continúa exactamente donde se quedó. El combate continúa después del inventario de la misma manera.

Cuando una pila no basta. Una pila dice que hay exactamente una pantalla arriba y que recibe todo. A veces eso no es verdad:

  • dos pantallas lado a lado que trabajan juntas: un mapa y un minimapa, un combate y el registro del combate, un juego de dos jugadores en pantalla dividida. Entonces la pantalla es un árbol de paneles, y la entrada va al panel con el foco;
  • cosas que siempre están arriba, sea cual sea la pantalla: una barra de vida, un aviso «Partida guardada». Son una capa aparte, que se dibuja después de la pila;
  • ventanas que se cierran en cualquier orden (un juego de estrategia con cinco ventanas abiertas). Eso ya no es una pila sino una lista con foco.

Ejercicio

Un menú sobre todo

Resuélvelo en tu propio editor. El programa completo con pantallas transparentes está debajo de la tarea.

Menú. Añade un MenuScreen con el nombre «Menú». m lo abre desde el mapa, desde el combate y desde el inventario. El menú es transparente, y x lo cierra. Dibuja un marco Box(14, 2, 10, 3) con x: atrás dentro, en (15, 3).

Entrada
n
f
i
m
x
x
a
a
Salida
[Título]
Las crónicas de Arcanum
n: juego nuevo
> n
[Mapa]
########################
#..@......~~~..........#
#.......w.~~~..........#
########################
Vida: 10
> f
[Mapa, Combate]
+----------------------+
| Lobo: 2 |
| Vida: 10 |
| a: golpe i: mochila |
+----------------------+
> i
[Mapa, Combate, Inventario]
+----------------------+
| Lobo: 2 +---------+|
| Vida: 9 |Poción x2||
| a: golpe +---------+|
+----------------------+
> m
[Mapa, Combate, Inventario, Menú]
+----------------------+
| Lobo: 2 +---------+|
| Vida: 9 |P+--------+
| a: golpe +-|x: atrás|
+-------------+--------+
> x
[Mapa, Combate, Inventario]
+----------------------+
| Lobo: 2 +---------+|
| Vida: 9 |Poción x2||
| a: golpe +---------+|
+----------------------+
> x
[Mapa, Combate]
+----------------------+
| Lobo: 2 |
| Vida: 9 |
| a: golpe i: mochila |
+----------------------+
> a
[Mapa, Combate]
+----------------------+
| Lobo: 1 |
| Vida: 8 |
| a: golpe i: mochila |
+----------------------+
> a
[Mapa]
########################
#..@......~~~..........#
#.........~~~..........#
########################
Vida: 8

Mira el fotograma con el menú: se dibujan el combate, el inventario y el menú, en ese orden. El menú cubre una esquina del inventario, porque está más arriba en la pila.

Más precisamente, para curiosos

El programa completo con pantallas transparentes:

var game = new Game();
var buffer = new TextBuffer();
game.Stack.Push(new TitleScreen(game));
Show(game, buffer);
string? line;
while ((line = Console.ReadLine()) != null)
{
string command = line.Trim();
Console.WriteLine("> " + command);
Screen top = game.Stack.Top();
top.Handle(command);
top.Update();
Show(game, buffer);
}
static void Show(Game game, TextBuffer buffer)
{
Console.WriteLine(game.Stack.Names());
buffer.Clear();
game.Stack.Draw(buffer);
buffer.Print();
}
class TextBuffer
{
public const int W = 24;
public const int H = 5;
string[] rows = new string[H];
public void Clear()
{
for (int y = 0; y < H; y++) rows[y] = new string(' ', W);
}
public void Write(int x, int y, string text)
{
rows[y] = rows[y].Substring(0, x) + text + rows[y].Substring(x + text.Length);
}
public void Box(int x, int y, int w, int h)
{
string edge = "+" + new string('-', w - 2) + "+";
Write(x, y, edge);
for (int i = 1; i < h - 1; i++) Write(x, y + i, "|" + new string(' ', w - 2) + "|");
Write(x, y + h - 1, edge);
}
public void Print()
{
foreach (string row in rows) Console.WriteLine(row.TrimEnd());
}
}
class Game
{
public ScreenStack Stack = new ScreenStack();
public int Life = 10;
public int Wolf = 2;
}
abstract class Screen
{
public Game Game;
public string Name;
public Screen(Game game, string name)
{
Game = game;
Name = name;
}
public virtual bool IsTransparent()
{
return false;
}
public abstract void Handle(string command);
public virtual void Update() { }
public abstract void Draw(TextBuffer buffer);
}
class ScreenStack
{
public List<Screen> Screens = new List<Screen>();
public void Push(Screen screen)
{
Screens.Add(screen);
}
public void Pop()
{
Screens.RemoveAt(Screens.Count - 1);
}
public void Replace(Screen screen)
{
Pop();
Push(screen);
}
public Screen Top()
{
return Screens[Screens.Count - 1];
}
public string Names()
{
var names = new List<string>();
foreach (Screen screen in Screens) names.Add(screen.Name);
return "[" + string.Join(", ", names) + "]";
}
public void Draw(TextBuffer buffer)
{
int first = Screens.Count - 1;
while (first > 0 && Screens[first].IsTransparent()) first--;
for (int i = first; i < Screens.Count; i++)
{
Screens[i].Draw(buffer);
}
}
}
class TitleScreen : Screen
{
public TitleScreen(Game game) : base(game, "Título") { }
public override void Handle(string command)
{
if (command == "n") Game.Stack.Replace(new MapScreen(Game));
}
public override void Draw(TextBuffer buffer)
{
buffer.Write(1, 1, "Las crónicas de Arcanum");
buffer.Write(1, 3, "n: juego nuevo");
}
}
class MapScreen : Screen
{
static readonly string[] Rows =
{
"########################",
"#..@......~~~..........#",
"#.........~~~..........#",
"########################",
};
public MapScreen(Game game) : base(game, "Mapa") { }
public override void Handle(string command)
{
if (command == "i") Game.Stack.Push(new InventoryScreen(Game));
if (command == "f" && Game.Wolf > 0) Game.Stack.Push(new BattleScreen(Game));
}
public override void Draw(TextBuffer buffer)
{
for (int y = 0; y < Rows.Length; y++) buffer.Write(0, y, Rows[y]);
if (Game.Wolf > 0) buffer.Write(8, 2, "w");
buffer.Write(1, 4, "Vida: " + Game.Life);
}
}
class BattleScreen : Screen
{
public BattleScreen(Game game) : base(game, "Combate") { }
public override void Handle(string command)
{
if (command == "i") Game.Stack.Push(new InventoryScreen(Game));
if (command == "a")
{
Game.Wolf--;
if (Game.Wolf == 0) Game.Stack.Pop();
}
}
// En cada turno del combate el lobo muerde.
public override void Update()
{
if (Game.Wolf > 0) Game.Life--;
}
public override void Draw(TextBuffer buffer)
{
buffer.Box(0, 0, TextBuffer.W, TextBuffer.H);
buffer.Write(2, 1, "Lobo: " + Game.Wolf);
buffer.Write(2, 2, "Vida: " + Game.Life);
buffer.Write(2, 3, "a: golpe i: mochila");
}
}
class InventoryScreen : Screen
{
public InventoryScreen(Game game) : base(game, "Inventario") { }
public override bool IsTransparent()
{
return true;
}
public override void Handle(string command)
{
if (command == "x") Game.Stack.Pop();
}
public override void Draw(TextBuffer buffer)
{
buffer.Box(12, 1, 11, 3);
buffer.Write(13, 2, "Poción x2");
}
}

Más tareas

  • Creación del héroe. Entre la pantalla de título y el mapa, añade una pantalla que pida un nombre. Las dos transiciones usan replace: desde el héroe no hay camino de vuelta.
  • Pausa. p abre una pantalla opaca «Pausa» desde cualquier pantalla. Comprueba que el lobo no muerde mientras el juego está en pausa.
  • Guarda de la pila. Haz que pop se niegue cuando solo queda una pantalla en la pila, e imprime el motivo.

Test

Ponte a prueba

1. La pila es [Mapa, Combate, Inventario]. El jugador escribe a (golpe). ¿Qué le pasa al lobo?
2. La pila es [Mapa, Combate, Inventario, Menú]. El inventario y el menú son transparentes; el mapa y el combate, no. ¿Qué pantallas se dibujan y en qué orden?
3. ¿Por qué la pantalla de título abre el mapa con replace y no con push?
4. Un compañero guarda screen y una variable más, previous, para «de dónde vengo». ¿Cuándo no basta?
5. Añades una pantalla «Héroe» que se abre con c desde el mapa. ¿Qué cambia en el programa con la pila?

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