Pantallas que devuelven una respuesta
En resumen: Una pantalla que devuelve una respuesta se cierra con pop(result) y entrega el resultado a la pantalla de debajo (onResult), mientras que los cambios en la pila se anotan durante el turno y se aplican al final con apply.
Problema
La pantalla pregunta. ¿Quién oye la respuesta?
En la lección anterior las pantallas solo se abrían y se cerraban. Ahora una pantalla tiene que preguntar algo y recibir una respuesta. El combate tiene dos enemigos, el jugador pulsa «ataque», y el combate necesita saber a cuál. El menú tiene que preguntar «¿Seguro?» antes de salir del juego.
El primer intento es una variable compartida: la pantalla de objetivo escribe la elección en selectedTarget, y el combate la lee. El jugador elige al Goblin, luego abre otra vez la elección y cambia de idea (x):
static string[] enemies = { "Lobo", "Goblin" };static int selectedTarget = -1;
static void TargetScreen(string command){ if (command != "x") selectedTarget = int.Parse(command) - 1;}
static void FightTurn(){ if (selectedTarget >= 0) Console.WriteLine("Golpeas a " + enemies[selectedTarget] + "."); else Console.WriteLine("Nadie recibe el golpe.");}
// El jugador elige 2 y luego cambia de idea (x).TargetScreen("2");FightTurn();TargetScreen("x");FightTurn();Golpeas a Goblin.Golpeas a Goblin.El segundo golpe cae sobre el Goblin, aunque el jugador canceló. La elección del turno anterior sigue en la variable. ¿Quién debe volver a ponerla en -1? ¿El combate, después de leerla? ¿Y si el inventario usa también la misma variable? Una variable compartida no tiene dueño, así que las respuestas viejas se quedan en ella.
El segundo intento es dejar que el objetivo llame al combate él mismo:
class TargetScreen : Screen{ FightScreen fight;
public override void Handle(string command) { // El objetivo conoce el combate y lo llama él mismo. fight.Attack(int.Parse(command) - 1); stack.Pop(); }}Eso funciona, pero ahora el objetivo conoce el combate. En el juego, «¿a quién?» lo pregunta un ataque, un hechizo y una poción que cura a un aliado. Cada uno quiere su propia pantalla de objetivo, o una pantalla con un if para todos los que la usan. Hace falta una forma de que la respuesta vuelva a quien preguntó, sin que la pantalla que responde sepa quién es.
La idea
pop(result) y onResult
La pantalla que pregunta abre con push a la que responde. La que responde se quita a sí misma con pop y entrega la respuesta: pop(result). La pila sabe quién está debajo: justo la pantalla que preguntó. Así que le pasa la respuesta llamando a su OnResult.
La pantalla recibe un método más. Por defecto no hace nada: la mayoría de las pantallas nunca preguntan nada.
class Screen{ public string Name;
public Screen(string name) { Name = name; }
public virtual void Draw() { }
public virtual void Handle(string command) { }
public virtual void Update() { }
// La respuesta de la pantalla que estaba encima de esta. public virtual void OnResult(object? result) { }}Y el pop de la lección anterior se convierte en pop(result):
public void Pop(object? result){ Screens.RemoveAt(Screens.Count - 1); Screen? below = Top(); if (below != null) below.OnResult(result);}De esta forma los dos lados no se conocen. El objetivo conoce la pregunta y la forma de la respuesta: el número de un enemigo, o null para «cancelar». El combate sabe qué hacer con el número. Es como llamar a un método que devuelve un valor, salvo que entre la pregunta y la respuesta pasan turnos: el jugador piensa, pulsa una tecla, puede cambiar de idea.
Míralo
Elegir un objetivo
El objetivo dibuja una lista de los enemigos vivos y espera un dígito. x es «atrás» y devuelve null. El número se cuenta desde 0, porque es un índice de la lista:
class TargetScreen : Screen{ ScreenStack stack; List<Enemy> enemies;
public TargetScreen(ScreenStack stack, List<Enemy> enemies) : base("Objetivo") { this.stack = stack; this.enemies = enemies; }
public override void Draw() { string line = "¿A quién golpeas?"; for (int i = 0; i < enemies.Count; i++) { line += " " + (i + 1) + ") " + enemies[i].Name; } Console.WriteLine(line + " x) atrás"); }
public override void Handle(string command) { if (command == "x") { stack.Pop(null); return; } int choice = int.Parse(command); if (choice >= 1 && choice <= enemies.Count) stack.Pop(choice - 1); }}El combate abre el objetivo con 1 y lee la respuesta en OnResult. Cuando no quedan enemigos, el combate también devuelve una respuesta a la pantalla de debajo, el mapa: «victoria».
class FightScreen : Screen{ ScreenStack stack; List<Enemy> enemies = new List<Enemy>();
public FightScreen(ScreenStack stack) : base("Combate") { this.stack = stack; enemies.Add(new Enemy("Lobo", 4)); enemies.Add(new Enemy("Goblin", 7)); }
public override void Draw() { string line = "Combate:"; foreach (Enemy enemy in enemies) line += " " + enemy.Name + " " + enemy.Hp; Console.WriteLine(line + " | 1) golpe"); }
public override void Handle(string command) { if (command == "1") stack.Push(new TargetScreen(stack, enemies)); }
public override void OnResult(object? result) { if (result == null) { Console.WriteLine("Cambias de idea. Nadie recibe el golpe."); return; } Enemy enemy = enemies[(int)result]; enemy.Hp -= 4; if (enemy.Hp > 0) { Console.WriteLine("Golpeas a " + enemy.Name + " por 4."); return; } Console.WriteLine(enemy.Name + " cae."); enemies.Remove(enemy); if (enemies.Count == 0) stack.Pop("victoria"); }}El programa lee sus comandos de la entrada. Cada turno dibuja solo la pantalla de arriba, para que la salida sea corta:
ScreenStack stack = new ScreenStack();stack.Push(new MapScreen(stack));while (stack.Top() != null){ stack.Top()!.Draw(); string command = Console.ReadLine()!.Trim(); Console.WriteLine("> " + command); stack.Top()!.Handle(command);}Console.WriteLine("Adiós.");Mapa: pociones 2 | f) combate d) tirar una poción q) salir> fCombate: Lobo 4 Goblin 7 | 1) golpe> 1¿A quién golpeas? 1) Lobo 2) Goblin x) atrás> 2Golpeas a Goblin por 4.Combate: Lobo 4 Goblin 3 | 1) golpe> 1¿A quién golpeas? 1) Lobo 2) Goblin x) atrás> xCambias de idea. Nadie recibe el golpe.Combate: Lobo 4 Goblin 3 | 1) golpe> 1¿A quién golpeas? 1) Lobo 2) Goblin x) atrás> 2Goblin cae.Combate: Lobo 4 | 1) golpe> 1¿A quién golpeas? 1) Lobo x) atrás> 1Lobo cae.¡Victoria! De vuelta al mapa.Mapa: pociones 2 | f) combate d) tirar una poción q) salir> d¿Seguro que quieres tirar una poción? 1) sí 2) no> 2La poción se queda.Mapa: pociones 2 | f) combate d) tirar una poción q) salir> q¿Seguro que quieres salir? 1) sí 2) no> 1Adiós.La entrada es:
f121x1211d2q1Cancelar (x) no golpea a nadie: la respuesta está vacía y el combate lo sabe. La elección del turno anterior no queda en ningún sitio.
Más precisamente, para curiosos
El programa completo, con el mapa y la pregunta «¿Seguro?» de la tarjeta siguiente:
class Screen{ public string Name;
public Screen(string name) { Name = name; }
public virtual void Draw() { }
public virtual void Handle(string command) { }
public virtual void Update() { }
// La respuesta de la pantalla que estaba encima de esta. public virtual void OnResult(object? result) { }}
class ScreenStack{ public List<Screen> Screens = new List<Screen>();
public void Push(Screen screen) { Screens.Add(screen); }
public void Pop(object? result) { Screens.RemoveAt(Screens.Count - 1); Screen? below = Top(); if (below != null) below.OnResult(result); }
public Screen? Top() { return Screens.Count == 0 ? null : Screens[Screens.Count - 1]; }
public string Names() { return "[" + string.Join(", ", Screens.Select(s => s.Name)) + "]"; }}
class Enemy{ public string Name; public int Hp;
public Enemy(string name, int hp) { Name = name; Hp = hp; }}
class TargetScreen : Screen{ ScreenStack stack; List<Enemy> enemies;
public TargetScreen(ScreenStack stack, List<Enemy> enemies) : base("Objetivo") { this.stack = stack; this.enemies = enemies; }
public override void Draw() { string line = "¿A quién golpeas?"; for (int i = 0; i < enemies.Count; i++) { line += " " + (i + 1) + ") " + enemies[i].Name; } Console.WriteLine(line + " x) atrás"); }
public override void Handle(string command) { if (command == "x") { stack.Pop(null); return; } int choice = int.Parse(command); if (choice >= 1 && choice <= enemies.Count) stack.Pop(choice - 1); }}
class FightScreen : Screen{ ScreenStack stack; List<Enemy> enemies = new List<Enemy>();
public FightScreen(ScreenStack stack) : base("Combate") { this.stack = stack; enemies.Add(new Enemy("Lobo", 4)); enemies.Add(new Enemy("Goblin", 7)); }
public override void Draw() { string line = "Combate:"; foreach (Enemy enemy in enemies) line += " " + enemy.Name + " " + enemy.Hp; Console.WriteLine(line + " | 1) golpe"); }
public override void Handle(string command) { if (command == "1") stack.Push(new TargetScreen(stack, enemies)); }
public override void OnResult(object? result) { if (result == null) { Console.WriteLine("Cambias de idea. Nadie recibe el golpe."); return; } Enemy enemy = enemies[(int)result]; enemy.Hp -= 4; if (enemy.Hp > 0) { Console.WriteLine("Golpeas a " + enemy.Name + " por 4."); return; } Console.WriteLine(enemy.Name + " cae."); enemies.Remove(enemy); if (enemies.Count == 0) stack.Pop("victoria"); }}
class ConfirmScreen : Screen{ ScreenStack stack; string question;
public ConfirmScreen(ScreenStack stack, string question) : base(question) { this.stack = stack; this.question = question; }
public override void Draw() { Console.WriteLine(question + " 1) sí 2) no"); }
public override void Handle(string command) { if (command == "1") stack.Pop(true); else if (command == "2") stack.Pop(false); }}
class MapScreen : Screen{ ScreenStack stack; int potions = 2; string pending = "";
public MapScreen(ScreenStack stack) : base("Mapa") { this.stack = stack; }
public override void Draw() { Console.WriteLine("Mapa: pociones " + potions + " | f) combate d) tirar una poción q) salir"); }
public override void Handle(string command) { if (command == "f") { stack.Push(new FightScreen(stack)); } else if (command == "d") { pending = "drop"; stack.Push(new ConfirmScreen(stack, "¿Seguro que quieres tirar una poción?")); } else if (command == "q") { pending = "quit"; stack.Push(new ConfirmScreen(stack, "¿Seguro que quieres salir?")); } }
public override void OnResult(object? result) { string asked = pending; pending = ""; if (asked == "drop") { if (result is true) { potions--; Console.WriteLine("Tiras una poción."); } else { Console.WriteLine("La poción se queda."); } } else if (asked == "quit") { if (result is true) stack.Pop(null); } else if (result is "victoria") { Console.WriteLine("¡Victoria! De vuelta al mapa."); } }}
class Program{ static void Main() { ScreenStack stack = new ScreenStack(); stack.Push(new MapScreen(stack)); while (stack.Top() != null) { stack.Top()!.Draw(); string command = Console.ReadLine()!.Trim(); Console.WriteLine("> " + command); stack.Top()!.Handle(command); } Console.WriteLine("Adiós."); }}La idea
Una pregunta para todo: «¿Seguro?»
La pantalla de pregunta no sabe qué está preguntando. Muestra el texto que recibió y devuelve true o false:
class ConfirmScreen : Screen{ ScreenStack stack; string question;
public ConfirmScreen(ScreenStack stack, string question) : base(question) { this.stack = stack; this.question = question; }
public override void Draw() { Console.WriteLine(question + " 1) sí 2) no"); }
public override void Handle(string command) { if (command == "1") stack.Pop(true); else if (command == "2") stack.Pop(false); }}El mapa la usa para dos preguntas distintas: si tirar una poción y si salir. Pero la respuesta es solo «sí» o «no». ¿Cómo sabe el mapa a qué pregunta responde? Recuerda lo que preguntó. Antes del push escribe pending, y cuando llega la respuesta, lo lee y lo limpia enseguida:
class MapScreen : Screen{ ScreenStack stack; int potions = 2; string pending = "";
public MapScreen(ScreenStack stack) : base("Mapa") { this.stack = stack; }
public override void Draw() { Console.WriteLine("Mapa: pociones " + potions + " | f) combate d) tirar una poción q) salir"); }
public override void Handle(string command) { if (command == "f") { stack.Push(new FightScreen(stack)); } else if (command == "d") { pending = "drop"; stack.Push(new ConfirmScreen(stack, "¿Seguro que quieres tirar una poción?")); } else if (command == "q") { pending = "quit"; stack.Push(new ConfirmScreen(stack, "¿Seguro que quieres salir?")); } }
public override void OnResult(object? result) { string asked = pending; pending = ""; if (asked == "drop") { if (result is true) { potions--; Console.WriteLine("Tiras una poción."); } else { Console.WriteLine("La poción se queda."); } } else if (asked == "quit") { if (result is true) stack.Pop(null); } else if (result is "victoria") { Console.WriteLine("¡Victoria! De vuelta al mapa."); } }}Limpiar importa. Si pending se quedara en «drop», la siguiente respuesta del combate («victoria») se leería como la respuesta a la pregunta de la poción. Es el mismo error de la variable compartida del principio, pero ahora dentro de una sola pantalla que es dueña de su propia pregunta. El juego real hace lo mismo: el combate recuerda si pidió un ataque, una habilidad o una poción.
Problema
La pila cambia mientras el turno sigue en marcha
Hasta ahora el bucle solo llamaba a Handle en la cima. Un turno real tiene también Update: la pantalla actualiza su estado después del comando. Aquí está el mismo combate, pero cada pantalla escribe en un registro lo que hace. En update, el objetivo cuenta los enemigos de su lista:
class TargetScreen : Screen{ ScreenStack stack; List<string> enemies;
public TargetScreen(ScreenStack stack, List<string> enemies) : base("Objetivo") { this.stack = stack; this.enemies = enemies; }
public override void Handle(string command) { int index = int.Parse(command) - 1; Console.WriteLine("Objetivo recibe " + command + " y devuelve " + index); stack.Pop(index); }
public override void Update() { Console.WriteLine("Objetivo se actualiza, enemigos en la lista: " + enemies.Count); }}El bucle es como en el juego real: toma la cima, dale el comando, actualízala.
Screen top = stack.Top()!;top.Handle(command);top.Update();class Screen{ public string Name;
public Screen(string name) { Name = name; }
public virtual void Draw() { }
public virtual void Handle(string command) { }
public virtual void Update() { }
// La respuesta de la pantalla que estaba encima de esta. public virtual void OnResult(object? result) { }}
class ScreenStack{ public List<Screen> Screens = new List<Screen>();
public void Push(Screen screen) { Screens.Add(screen); }
public void Pop(object? result) { Screens.RemoveAt(Screens.Count - 1); Screen? below = Top(); if (below != null) below.OnResult(result); }
public Screen? Top() { return Screens.Count == 0 ? null : Screens[Screens.Count - 1]; }
public string Names() { return "[" + string.Join(", ", Screens.Select(s => s.Name)) + "]"; }}
class TargetScreen : Screen{ ScreenStack stack; List<string> enemies;
public TargetScreen(ScreenStack stack, List<string> enemies) : base("Objetivo") { this.stack = stack; this.enemies = enemies; }
public override void Handle(string command) { int index = int.Parse(command) - 1; Console.WriteLine("Objetivo recibe " + command + " y devuelve " + index); stack.Pop(index); }
public override void Update() { Console.WriteLine("Objetivo se actualiza, enemigos en la lista: " + enemies.Count); }}
class FightScreen : Screen{ ScreenStack stack; List<string> enemies = new List<string> { "Lobo", "Goblin" };
public FightScreen(ScreenStack stack) : base("Combate") { this.stack = stack; }
public override void Handle(string command) { Console.WriteLine("Combate recibe " + command + " y abre Objetivo"); stack.Push(new TargetScreen(stack, enemies)); }
public override void OnResult(object? result) { string enemy = enemies[(int)result!]; enemies.RemoveAt((int)result!); Console.WriteLine("Combate recibe " + result + ": " + enemy + " cae"); if (enemies.Count == 0) { Console.WriteLine("Combate: no quedan enemigos, devuelve victoria"); stack.Pop("victoria"); } }}
class MapScreen : Screen{ ScreenStack stack;
public MapScreen(ScreenStack stack) : base("Mapa") { this.stack = stack; }
public override void OnResult(object? result) { Console.WriteLine("Mapa recibe " + result + ": abre Subida de nivel"); stack.Push(new Screen("Subida de nivel")); }}
class Program{ static void Main() { ScreenStack stack = new ScreenStack(); stack.Push(new MapScreen(stack)); stack.Push(new FightScreen(stack)); foreach (string command in new[] { "1", "2", "1", "1" }) { Console.WriteLine("> " + command); Screen top = stack.Top()!; top.Handle(command); top.Update(); } Console.WriteLine(stack.Names()); }}> 1Combate recibe 1 y abre Objetivo> 2Objetivo recibe 2 y devuelve 1Combate recibe 1: Goblin caeObjetivo se actualiza, enemigos en la lista: 1> 1Combate recibe 1 y abre Objetivo> 1Objetivo recibe 1 y devuelve 0Combate recibe 0: Lobo caeCombate: no quedan enemigos, devuelve victoriaMapa recibe victoria: abre Subida de nivelObjetivo se actualiza, enemigos en la lista: 0[Mapa, Subida de nivel]Mira el último turno. El objetivo todavía no ha terminado su handle, y dentro de él pop ya lo ha quitado, el combate ha golpeado al Lobo, el combate ha devuelto «victoria», y el mapa ha abierto «Subida de nivel». Solo entonces el bucle llama a update en el objetivo: una pantalla que ya no está en la pila actualiza una lista de 0 enemigos de un combate que terminó. En el juego real, un update así leería un enemigo por número de una lista vacía, y el juego se detendría con un error.
Si el bucle volviera a pedir la cima antes de update, actualizaría «Subida de nivel»: la pantalla equivocada, una que no se ha dibujado ni una sola vez. Hay dos raíces. La pila cambia en medio del turno. Y las respuestas se ejecutan anidadas: el onResult del combate corre dentro del handle del objetivo, y el onResult del mapa dentro de ese.
Más precisamente, para curiosos
Es peor cuando recorres todas las pantallas de la pila (digamos que cada una tiene un update) y una de ellas se quita a sí misma durante el recorrido. Entonces los lenguajes se comportan de forma distinta:
List<string> screens = new List<string> { "Mapa", "Mensaje", "Combate", "Objetivo" };try{ foreach (string screen in screens) { Console.WriteLine("update " + screen); if (screen == "Mensaje") screens.Remove(screen); }}catch (InvalidOperationException){ Console.WriteLine("InvalidOperationException");}update Mapaupdate MensajeInvalidOperationExceptionInvalidOperationException («Collection was modified») en el paso siguiente de foreach.Un error que detiene el programa es al menos visible. Un error silencioso, en el que una pantalla no se actualiza en un turno, es mucho más difícil de cazar.
La idea
Cambios aplazados: anotar ahora, aplicar después
La solución es no tocar la pila durante el turno. push y pop ya no mueven nada. Solo anotan el cambio en una lista, changes. Al final del turno, apply recorre la lista y aplica los cambios uno por uno. Un cambio es un objeto pequeño: un tipo, una pantalla (en push) y una respuesta (en pop).
class Change{ public string Kind; public Screen? Screen; public object? Result;
public Change(string kind, Screen? screen, object? result) { Kind = kind; Screen = screen; Result = result; }}class ScreenStack{ public List<Screen> Screens = new List<Screen>(); public List<Change> Changes = new List<Change>();
public void Push(Screen screen) { Changes.Add(new Change("push", screen, null)); }
public void Pop(object? result) { Changes.Add(new Change("pop", null, result)); }
public Screen? Top() { return Screens.Count == 0 ? null : Screens[Screens.Count - 1]; }
public void Apply() { int index = 0; while (index < Changes.Count) { Change 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); } } Changes.Clear(); }}El bucle llama a apply al final:
Screen top = stack.Top()!;top.Handle(command);top.Update();stack.Apply();El mismo combate con las mismas pantallas, solo cambia la pila:
class Screen{ public string Name;
public Screen(string name) { Name = name; }
public virtual void Draw() { }
public virtual void Handle(string command) { }
public virtual void Update() { }
// La respuesta de la pantalla que estaba encima de esta. public virtual void OnResult(object? result) { }}
class Change{ public string Kind; public Screen? Screen; public object? Result;
public Change(string kind, Screen? screen, object? result) { Kind = kind; Screen = screen; Result = result; }}
class ScreenStack{ public List<Screen> Screens = new List<Screen>(); public List<Change> Changes = new List<Change>();
public void Push(Screen screen) { Changes.Add(new Change("push", screen, null)); }
public void Pop(object? result) { Changes.Add(new Change("pop", null, result)); }
public Screen? Top() { return Screens.Count == 0 ? null : Screens[Screens.Count - 1]; }
public void Apply() { int index = 0; while (index < Changes.Count) { Change 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); } } Changes.Clear(); }
public string Names() { return "[" + string.Join(", ", Screens.Select(s => s.Name)) + "]"; }}
class TargetScreen : Screen{ ScreenStack stack; List<string> enemies;
public TargetScreen(ScreenStack stack, List<string> enemies) : base("Objetivo") { this.stack = stack; this.enemies = enemies; }
public override void Handle(string command) { int index = int.Parse(command) - 1; Console.WriteLine("Objetivo recibe " + command + " y devuelve " + index); stack.Pop(index); }
public override void Update() { Console.WriteLine("Objetivo se actualiza, enemigos en la lista: " + enemies.Count); }}
class FightScreen : Screen{ ScreenStack stack; List<string> enemies = new List<string> { "Lobo", "Goblin" };
public FightScreen(ScreenStack stack) : base("Combate") { this.stack = stack; }
public override void Handle(string command) { Console.WriteLine("Combate recibe " + command + " y abre Objetivo"); stack.Push(new TargetScreen(stack, enemies)); }
public override void OnResult(object? result) { string enemy = enemies[(int)result!]; enemies.RemoveAt((int)result!); Console.WriteLine("Combate recibe " + result + ": " + enemy + " cae"); if (enemies.Count == 0) { Console.WriteLine("Combate: no quedan enemigos, devuelve victoria"); stack.Pop("victoria"); } }}
class MapScreen : Screen{ ScreenStack stack;
public MapScreen(ScreenStack stack) : base("Mapa") { this.stack = stack; }
public override void OnResult(object? result) { Console.WriteLine("Mapa recibe " + result + ": abre Subida de nivel"); stack.Push(new Screen("Subida de nivel")); }}
class Program{ static void Main() { ScreenStack stack = new ScreenStack(); stack.Push(new MapScreen(stack)); stack.Push(new FightScreen(stack)); stack.Apply(); foreach (string command in new[] { "1", "2", "1", "1" }) { Console.WriteLine("> " + command); Screen top = stack.Top()!; top.Handle(command); top.Update(); stack.Apply(); } Console.WriteLine(stack.Names()); }}> 1Combate recibe 1 y abre Objetivo> 2Objetivo recibe 2 y devuelve 1Objetivo se actualiza, enemigos en la lista: 2Combate recibe 1: Goblin cae> 1Combate recibe 1 y abre Objetivo> 1Objetivo recibe 1 y devuelve 0Objetivo se actualiza, enemigos en la lista: 1Combate recibe 0: Lobo caeCombate: no quedan enemigos, devuelve victoriaMapa recibe victoria: abre Subida de nivel[Mapa, Subida de nivel]Ahora el objetivo termina handle y update mientras sigue arriba y todo lo que lo rodea está como estaba antes del turno. Solo entonces apply lo quita, y el combate recibe su respuesta. Cada pantalla trabaja en una pila que no cambia bajo sus pies.
Tres detalles de apply importan:
- while con un índice, no foreach. Mientras apply aplica un pop, el combate recibe su respuesta y puede escribir un cambio nuevo: pop(victoria). Va al final de la misma lista, y while vuelve a leer su longitud y también llega a él. foreach lanzaría el error de la tarjeta anterior.
- La lista se limpia al final. Si no, en el turno siguiente se volverían a aplicar los mismos cambios.
- El orden es el orden de escritura. Si una pantalla escribe un replace y luego un push en un turno, la pila hace exactamente eso.
Míralo
Un turno, cambio a cambio
class ScreenStack{ public List<Screen> Screens = new List<Screen>(); public List<Change> Changes = new List<Change>();
public void Push(Screen screen) { Changes.Add(new Change("push", screen, null)); }
public void Pop(object? result) { Changes.Add(new Change("pop", null, result)); }
public Screen? Top() { return Screens.Count == 0 ? null : Screens[Screens.Count - 1]; }
public void Apply() { int index = 0; while (index < Changes.Count) { Change 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); } } Changes.Clear(); }}
// Un turno.Screen top = stack.Top()!;top.Handle(command);top.Update();stack.Apply();Paso a paso, en palabras
- El último turno del combate. En la pila están Mapa, Combate y, arriba, Objetivo. Solo queda el lobo. La lista de cambios está vacía.
- El comando 1 va a la cima, Objetivo. Llama a Pop(0): 0 es el número del enemigo elegido. La pila no se mueve. El cambio solo se anota en Changes.
- Update se ejecuta en Objetivo, que sigue en la pila. Todo lo que ve es como antes del turno: el combate todavía no ha terminado.
- Fin del turno: Apply toma el cambio 0. Objetivo sale de la pila, y 0 baja hasta Combate a través de OnResult. El lobo cae, no quedan enemigos, y Combate llama a Pop(victoria): un cambio nuevo al final de la lista.
- while vuelve a leer la longitud de Changes, así que también llega al cambio nuevo. Combate sale, y Mapa recibe «victoria». Escribe Push(Subida de nivel).
- El tercer cambio es un Push: Subida de nivel entra en la pila, encima de Mapa.
- La lista terminó. Changes se limpia, para que los mismos cambios no se apliquen otra vez en el turno siguiente. La pila es Mapa, Subida de nivel. Cada pantalla recibió su respuesta después del turno, nunca en medio de uno.
Pruébalo
¿Qué falta?
Este es el bucle de apply. ¿Cómo debe ser la condición para que llegue también a los cambios que se escriben mientras se ejecuta?
public void Apply()
{
int index = 0;
while (index < )
{
Change 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);
}
}
Changes.Clear();
}La longitud se lee en cada vuelta: Changes.Count. Si la hubieras guardado en una variable antes del bucle, los cambios que onResult escribe durante apply se borrarían con la limpieza final y nunca se aplicarían.
Pruébalo
Predice la salida
NamedScreen es una pantalla que solo dice lo que recibió. La pila es la de cambios aplazados. ¿Qué imprime el programa?
ScreenStack stack = new ScreenStack();stack.Push(new NamedScreen("Mapa"));stack.Push(new NamedScreen("Combate"));stack.Apply();stack.Pop("victoria");stack.Push(new NamedScreen("Subida de nivel"));Console.WriteLine("Arriba: " + stack.Top()!.Name);stack.Apply();Console.WriteLine("Arriba: " + stack.Top()!.Name);La idea
Transiciones: replace y reset
No todo cambio de pantalla es una pregunta con respuesta. Algunos son transiciones sin camino de vuelta. Para ellos la pila tiene dos cambios más:
public void Replace(Screen screen){ Changes.Add(new Change("replace", screen, null));}
public void Reset(Screen? screen){ Changes.Add(new Change("reset", screen, null));}apply tiene una rama para cada uno:
else if (change.Kind == "replace"){ Screens.RemoveAt(Screens.Count - 1); Screens.Add(change.Screen!);}else if (change.Kind == "reset"){ Screens.Clear(); if (change.Screen != null) Screens.Add(change.Screen);}- replace quita la pantalla de arriba y pone otra en su lugar. La pantalla de título abre la creación del héroe, y la creación del héroe abre la aldea. Con push, la pantalla de título se quedaría abajo del todo, y con «atrás» el jugador caería en ella en medio de la partida.
- reset limpia toda la pila y pone una pantalla (o ninguna: entonces el juego termina). Tiene dos usos: el héroe ha caído (la pantalla de fin del juego) y se ha cargado una partida (la aldea o el mapa).
¿Por qué no usar pop para el final del juego? La pila es Aldea, Mapa, Combate. Aquí están las dos formas:
ScreenStack stack = new ScreenStack();stack.Push(new NamedScreen("Título"));stack.Apply();Console.WriteLine(stack.Names());stack.Replace(new NamedScreen("Nuevo héroe"));stack.Apply();Console.WriteLine(stack.Names());stack.Replace(new NamedScreen("Aldea"));stack.Apply();Console.WriteLine(stack.Names());
Console.WriteLine("El héroe cae. Con pop:");ScreenStack popped = InFight();popped.Pop("derrota");popped.Pop("derrota");popped.Push(new NamedScreen("Fin del juego"));popped.Apply();Console.WriteLine(popped.Names());
Console.WriteLine("El héroe cae. Con reset:");ScreenStack clean = InFight();clean.Reset(new NamedScreen("Fin del juego"));clean.Apply();Console.WriteLine(clean.Names());[Título][Nuevo héroe][Aldea]El héroe cae. Con pop:Mapa recibe derrotaAldea recibe derrota[Aldea, Fin del juego]El héroe cae. Con reset:[Fin del juego]Con pop, el mapa y la aldea reciben «derrota» y reaccionan como si el combate hubiera terminado de la manera normal. Además tienes que saber cuántas pantallas hay debajo. Y la aldea se queda bajo la pantalla de fin del juego. reset no llama a onResult en nadie: no es una respuesta, es un comienzo desde cero.
Más precisamente, para curiosos
replace y reset también son aplazados: se anotan y se aplican en apply, en el orden en que se escribieron. La creación del héroe en el juego llama a replace(la aldea) y justo después a push(el mensaje con el comienzo de la historia). Después de apply la pila es la aldea con el mensaje encima, y no al revés.
El programa completo:
class Screen{ public string Name;
public Screen(string name) { Name = name; }
public virtual void Draw() { }
public virtual void Handle(string command) { }
public virtual void Update() { }
// La respuesta de la pantalla que estaba encima de esta. public virtual void OnResult(object? result) { }}
class Change{ public string Kind; public Screen? Screen; public object? Result;
public Change(string kind, Screen? screen, object? result) { Kind = kind; Screen = screen; Result = result; }}
class ScreenStack{ public List<Screen> Screens = new List<Screen>(); public List<Change> Changes = new List<Change>();
public void Push(Screen screen) { Changes.Add(new Change("push", screen, null)); }
public void Pop(object? result) { Changes.Add(new Change("pop", null, result)); }
public void Replace(Screen screen) { Changes.Add(new Change("replace", screen, null)); }
public void Reset(Screen? screen) { Changes.Add(new Change("reset", screen, null)); }
public Screen? Top() { return Screens.Count == 0 ? null : Screens[Screens.Count - 1]; }
public void Apply() { int index = 0; while (index < Changes.Count) { Change 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 if (change.Kind == "reset") { Screens.Clear(); if (change.Screen != null) Screens.Add(change.Screen); } } Changes.Clear(); }
public string Names() { return "[" + string.Join(", ", Screens.Select(s => s.Name)) + "]"; }}
class NamedScreen : Screen{ public NamedScreen(string name) : base(name) { }
public override void OnResult(object? result) { Console.WriteLine(Name + " recibe " + result); }}
class Program{ static ScreenStack InFight() { ScreenStack stack = new ScreenStack(); stack.Push(new NamedScreen("Aldea")); stack.Push(new NamedScreen("Mapa")); stack.Push(new NamedScreen("Combate")); stack.Apply(); return stack; }
static void Main() { ScreenStack stack = new ScreenStack(); stack.Push(new NamedScreen("Título")); stack.Apply(); Console.WriteLine(stack.Names()); stack.Replace(new NamedScreen("Nuevo héroe")); stack.Apply(); Console.WriteLine(stack.Names()); stack.Replace(new NamedScreen("Aldea")); stack.Apply(); Console.WriteLine(stack.Names());
Console.WriteLine("El héroe cae. Con pop:"); ScreenStack popped = InFight(); popped.Pop("derrota"); popped.Pop("derrota"); popped.Push(new NamedScreen("Fin del juego")); popped.Apply(); Console.WriteLine(popped.Names());
Console.WriteLine("El héroe cae. Con reset:"); ScreenStack clean = InFight(); clean.Reset(new NamedScreen("Fin del juego")); clean.Apply(); Console.WriteLine(clean.Names()); }}La idea
En el juego real
La pila de Las crónicas de Arcanum es la misma, con tres cosas más:
// 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); } }}isEmptymira también los cambios. El juego empieza con un push de la pantalla de título, y salir es un reset sin pantalla. El bucle se detiene cuando no hay ni pantallas ni cambios en espera.containspregunta si la pila ya tiene una pantalla de cierto tipo. Así la pantalla de subida de nivel no entra dos veces si se ganan dos niveles en un turno.drawdibuja de abajo hacia arriba desde la pantalla opaca más alta, como en la lección anterior.
Elegir un objetivo en el juego es una pantalla transparente sobre el combate. No golpea a nadie por sí misma: devuelve el número con pop, y el combate decide qué hacer. Junto a ella están la elección general de una lista (una habilidad, una poción) y la elección de una ranura de guardado, que devuelve el nombre de la ranura, no un número:
// Elegir un objetivo es una pantalla transparente sobre el combate. No ataca: Pop(result) devuelve el número del objetivo,// y el combate decide qué hacer con él.class TargetScreen : Screen{ public Battle Battle; public string Note;
public TargetScreen(Game game, Battle battle) : base(game) { Battle = battle; Note = ""; }
public override bool IsTransparent() { return true; }
public override void Draw(CellBuffer buffer) { List<Enemy> enemies = Battle.AliveEnemies(); buffer.Box(24, 9, 36, enemies.Count + 4, T("target.title")); for (int i = 0; i < enemies.Count; i++) { buffer.Text(26, 10 + i, (i + 1) + ") " + Pad(enemies[i].Name, 22) + " " + enemies[i].Hp + "/" + enemies[i].MaxHp(), enemies[i].Kind.Color); } buffer.Text(26, 11 + enemies.Count, Note.Length > 0 ? Note : T("target.help"), "gray"); }
public override void Handle(string command) { if (command == "x") { Game.Stack.Pop(null); return; } long? choice = ParseIntStrict(command); if (choice == null || choice < 1 || choice > Battle.AliveEnemies().Count) { Note = T("ui.unknown", ("command", command)); return; } Game.Stack.Pop((int)choice.Value - 1); }}
// Una elección general de una lista (habilidad, poción, ranura): devuelve un número desde 0, o null.class ChoiceScreen : Screen{ public string Title; public List<string> Labels; public string Note;
public ChoiceScreen(Game game, string title, List<string> labels) : base(game) { Title = title; Labels = labels; Note = ""; }
public override bool IsTransparent() { return true; }
public override void Draw(CellBuffer buffer) { buffer.Box(4, 3, 52, Labels.Count + 4, Title); for (int i = 0; i < Labels.Count; i++) buffer.Text(6, 4 + i, (i + 1) + ") " + Labels[i], "default"); buffer.Text(6, 5 + Labels.Count, Note.Length > 0 ? Note : T("choice.help"), "gray"); }
public override void Handle(string command) { if (command == "x") { Game.Stack.Pop(null); return; } long? choice = ParseIntStrict(command); if (choice == null || choice < 1 || choice > Labels.Count) { Note = T("ui.unknown", ("command", command)); return; } Game.Stack.Pop((int)choice.Value - 1); }}
class SlotScreen : ChoiceScreen{ // C# llama al constructor de la clase base antes del cuerpo, así que las etiquetas se calculan en un método aparte. public SlotScreen(Game game, string mode) : base(game, T(mode == "save" ? "slot.saveTitle" : "slot.loadTitle"), SlotLabels(game)) { }
static List<string> SlotLabels(Game game) { List<string> labels = new List<string>(); foreach (string slot in SAVE_SLOTS) labels.Add(T("slot.label", ("slot", slot), ("summary", SlotSummary(game.Io, slot)))); return labels; }
public override void Handle(string command) { long? choice = ParseIntStrict(command); if (choice != null && choice >= 1 && choice <= SAVE_SLOTS.Length) { Game.Stack.Pop(SAVE_SLOTS[(int)choice.Value - 1]); return; } base.Handle(command); }}El combate recuerda lo que preguntó (pending) y lee la respuesta en OnResult:
public override void OnResult(object? result){ Battle battle = Battle; Hero hero = battle.Hero; PendingChoice? pending = Pending; Pending = null; if (result == null || pending == null) return; int index = (int)result; if (pending.Kind == "attack") { battle.HeroAct(new AttackAction(hero, battle.AliveEnemies()[index])); } else if (pending.Kind == "skill") { Skill skill = SKILLS.Get(hero.Skills[index]); if (hero.Mana < skill.Mana) { Note = T("battle.noMana", ("skill", Nm(skill))); } else if (hero.CooldownOf(skill.Id) > 0) { Note = T("battle.cooldown", ("skill", Nm(skill)), ("turns", hero.CooldownOf(skill.Id))); } else if (skill.Target == "one" && battle.AliveEnemies().Count > 1) { Pending = new PendingChoice { Kind = "skillTarget", Skill = skill }; Game.Stack.Push(new TargetScreen(Game, battle)); } else { battle.HeroAct(new SkillAction(hero, skill, battle.AliveEnemies()[0])); } } else if (pending.Kind == "skillTarget") { battle.HeroAct(new SkillAction(hero, pending.Skill!, battle.AliveEnemies()[index])); } else { battle.HeroAct(new ItemAction(hero, PotionIds()[index])); }}Mira la rama de la habilidad. El jugador eligió una habilidad que necesita un objetivo, y hay más de un enemigo. Entonces el combate, dentro de onResult, escribe una pregunta nueva: un push de la pantalla de objetivo. Esto ocurre durante apply, y while llega a él y lo aplica en el mismo turno. El jugador ve la lista de enemigos justo después de la lista de habilidades.
El menú es la otra pantalla que pregunta. «¿Seguro?» es la misma pantalla general, y la respuesta «sí» a la pregunta de salir es un reset sin pantalla: la pila queda vacía y el juego termina.
class ConfirmScreen : Screen{ public string Question;
public ConfirmScreen(Game game, string question) : base(game) { Question = question; }
public override bool IsTransparent() { return true; }
public override void Draw(CellBuffer buffer) { buffer.Box(16, 6, 28, 5, ""); buffer.Text(18, 7, Question, "white"); buffer.Text(18, 8, "1) " + T("ui.yes") + " 2) " + T("ui.no"), "default"); }
public override void Handle(string command) { if (command == "1") Game.Stack.Pop(true); else if (command == "2" || command == "x") Game.Stack.Pop(false); }}public override void OnResult(object? result){ Game game = Game; string pending = Pending; Pending = ""; if (result == null) return; if (pending == "save") { game.Io.Save((string)result, SaveGame(game)); Note = T("menu.saved", ("slot", result)); } else if (pending == "load") { string? error = LoadGame(game, game.Io.Load((string)result)); if (error != null) Note = error; else EnterWorld(game, T("load.done", ("slot", result))); } else if (pending == "quit" && result is bool yes && yes) { game.Stack.Reset(null); }}Pruébalo
Encuentra el error
Un compañero intercambió dos líneas en apply. El programa del combate de «Cambios aplazados» ahora imprime:
class ScreenStack{ public List<Screen> Screens = new List<Screen>(); public List<Change> Changes = new List<Change>();
public void Push(Screen screen) { Changes.Add(new Change("push", screen, null)); }
public void Pop(object? result) { Changes.Add(new Change("pop", null, result)); }
public Screen? Top() { return Screens.Count == 0 ? null : Screens[Screens.Count - 1]; }
public void Apply() { int index = 0; while (index < Changes.Count) { Change change = Changes[index]; index++; if (change.Kind == "push") { Screens.Add(change.Screen!); } else if (change.Kind == "pop") { Screen? below = Top(); Screens.RemoveAt(Screens.Count - 1); if (below != null) below.OnResult(change.Result); } } Changes.Clear(); }}> 1Combate recibe 1 y abre Objetivo> 2Objetivo recibe 2 y devuelve 1Objetivo se actualiza, enemigos en la lista: 2> 1Combate recibe 1 y abre Objetivo> 1Objetivo recibe 1 y devuelve 0Objetivo se actualiza, enemigos en la lista: 2[Mapa, Combate]El objetivo devuelve un número, pero el combate nunca se entera: los dos enemigos siguen vivos, y al final la pila es el mapa y el combate. ¿Qué línea se ejecuta demasiado pronto?
Elige la línea con el error.
La pantalla de debajo se lee antes de quitar la de arriba. Así que «debajo» es en realidad el propio objetivo, la respuesta va a él, y su OnResult no hace nada. Primero quita la pantalla de arriba, luego toma la cima: esa es la que preguntó.
Más precisamente
Con más precisión, para los curiosos
¿Por qué no un callback? El combate podría darle al objetivo una función: «cuando el jugador elija, llama a esta».
// El combate le dice al objetivo qué hacer con la elección.stack.Push(new TargetScreen(enemies, index => Attack(index)));Eso funciona, y es común. El coste está en los detalles. La función la llama el objetivo, dentro de su handle: volvemos a estar en medio del turno, a menos que la llamada también se aplace. La función se aferra al combate, y el juego tiene que poder guardarse: una pila de pantallas con datos se puede guardar, pero una función no se puede escribir en un archivo. Y el orden se vuelve invisible: a quién se va a llamar solo queda claro cuando ves quién pasó qué. onResult dice lo mismo con una regla que se ve en la pila: la respuesta siempre va a la pantalla de debajo.
Lo mismo en otros lugares. En Android, una activity abre otra y espera un resultado (startActivityForResult, hoy la Activity Result API). Los diálogos modales de las apps de escritorio devuelven un valor al cerrarse («Aceptar» o «Cancelar»). La navegación en las apps móviles también es una pila: abres una pantalla encima, y «atrás» te devuelve, a veces con un resultado.
Con await. En JavaScript, C# y Python puedes escribir const target = await targetScreen.open(): el combate «espera» la respuesta como en una llamada normal. Parece más natural, pero en un juego por turnos con un solo bucle es más difícil. El combate ya no es un objeto que recibe comandos, sino una función detenida a mitad de camino. Su estado está escondido en «dónde se quedó», y eso no entra en un archivo de guardado. Y sigue teniendo que estar claro en qué momento del turno se reanuda.
El tipo de la respuesta. Aquí la respuesta es object?, y el combate sabe que es un número. Se puede ser más estricto: el objetivo devuelve un tipo propio (digamos, un registro con el número), o la pantalla es genérica sobre el tipo de la respuesta. En el juego las respuestas son pocas y simples (un número, «sí» o «no», el nombre de una ranura, el resultado de un combate), así que basta con la comprobación de pending.
Ejercicio
Una tienda con dos preguntas
Resuélvelo en tu editor. Empieza desde la pila con cambios aplazados de esta lección y desde ConfirmScreen.
Tienda. ShopScreen tiene 20 de oro y vende una poción por 5 y una espada por 12. Elegir un artículo abre QuantityScreen, que devuelve la cantidad (de 1 a 9), o nada si el jugador pulsa x. Luego la tienda abre ConfirmScreen con la pregunta «¿Compras 2 x Poción por 10 monedas de oro?». Una pantalla recoge dos respuestas, una tras otra: OnResult sabe qué respuesta ha llegado por pending, que pasa de "count" a "confirm". Si no hay oro suficiente, no se compra nada. x en la tienda la cierra.
Nota: ConfirmScreen se abre desde OnResult, es decir, mientras Apply sigue en marcha. Por eso el bucle de Apply vuelve a leer la longitud de la lista.
1212122x211111xTienda: oro 20 | 1) Poción 5 2) Espada 12 x) salir> 1¿Cuántas? 1-9 | x) atrás> 2¿Compras 2 x Poción por 10 monedas de oro? 1) sí 2) no> 1Compras 2 x Poción.Tienda: oro 10 | 1) Poción 5 2) Espada 12 x) salir> 2¿Cuántas? 1-9 | x) atrás> 1¿Compras 1 x Espada por 12 monedas de oro? 1) sí 2) no> 2No compras.Tienda: oro 10 | 1) Poción 5 2) Espada 12 x) salir> 2¿Cuántas? 1-9 | x) atrás> xCambias de idea.Tienda: oro 10 | 1) Poción 5 2) Espada 12 x) salir> 2¿Cuántas? 1-9 | x) atrás> 1¿Compras 1 x Espada por 12 monedas de oro? 1) sí 2) no> 1No tienes oro suficiente.Tienda: oro 10 | 1) Poción 5 2) Espada 12 x) salir> 1¿Cuántas? 1-9 | x) atrás> 1¿Compras 1 x Poción por 5 monedas de oro? 1) sí 2) no> 1Compras 1 x Poción.Tienda: oro 5 | 1) Poción 5 2) Espada 12 x) salir> xAdiós.Más tareas
- Vender. Añade
s) vender: la tienda pregunta qué artículo (una pantalla más con una respuesta) y cuántos, y devuelve la mitad del precio, redondeada hacia abajo. ¿Cuántos valores tendrá ahorapending? - Mochila. Guarda los artículos comprados en una lista e imprímela al salir. Una poción comprada dos veces es una línea con 3 unidades, no dos líneas.
- Sin pending. Haz que
QuantityScreenyConfirmScreendevuelvan un registro con el nombre de la pregunta y el valor. ¿Puede entonces la tienda prescindir depending? ¿Qué ganas y qué pierdes?
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Excepciones · Listas · Pila, cola y lista enlazada · Lambdas y delegados
- Ejercicios sobre el tema: Las crónicas de Arcanum