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.equals("x")) selectedTarget = Integer.parseInt(command) - 1;}
static void fightTurn() { if (selectedTarget >= 0) System.out.println("Golpeas a " + enemies[selectedTarget] + "."); else System.out.println("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 extends Screen { FightScreen fight;
@Override void handle(String command) { // El objetivo conoce el combate y lo llama él mismo. fight.attack(Integer.parseInt(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 { String name;
Screen(String name) { this.name = name; }
void draw() {}
void handle(String command) {}
void update() {}
// La respuesta de la pantalla que estaba encima de esta. void onResult(Object result) {}}Y el pop de la lección anterior se convierte en pop(result):
void pop(Object result) { screens.remove(screens.size() - 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 extends Screen { ScreenStack stack; List<Enemy> enemies;
TargetScreen(ScreenStack stack, List<Enemy> enemies) { super("Objetivo"); this.stack = stack; this.enemies = enemies; }
@Override void draw() { String line = "¿A quién golpeas?"; for (int i = 0; i < enemies.size(); i++) { line += " " + (i + 1) + ") " + enemies.get(i).name; } System.out.println(line + " x) atrás"); }
@Override void handle(String command) { if (command.equals("x")) { stack.pop(null); return; } int choice = Integer.parseInt(command); if (choice >= 1 && choice <= enemies.size()) 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 extends Screen { ScreenStack stack; List<Enemy> enemies = new ArrayList<>();
FightScreen(ScreenStack stack) { super("Combate"); this.stack = stack; enemies.add(new Enemy("Lobo", 4)); enemies.add(new Enemy("Goblin", 7)); }
@Override void draw() { String line = "Combate:"; for (Enemy enemy : enemies) line += " " + enemy.name + " " + enemy.hp; System.out.println(line + " | 1) golpe"); }
@Override void handle(String command) { if (command.equals("1")) stack.push(new TargetScreen(stack, enemies)); }
@Override void onResult(Object result) { if (result == null) { System.out.println("Cambias de idea. Nadie recibe el golpe."); return; } Enemy enemy = enemies.get((int) result); enemy.hp -= 4; if (enemy.hp > 0) { System.out.println("Golpeas a " + enemy.name + " por 4."); return; } System.out.println(enemy.name + " cae."); enemies.remove(enemy); if (enemies.isEmpty()) 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));Scanner in = new Scanner(System.in);while (stack.top() != null) { stack.top().draw(); String command = in.nextLine().trim(); System.out.println("> " + command); stack.top().handle(command);}System.out.println("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:
import java.util.ArrayList;import java.util.List;import java.util.Scanner;
class Screen { String name;
Screen(String name) { this.name = name; }
void draw() {}
void handle(String command) {}
void update() {}
// La respuesta de la pantalla que estaba encima de esta. void onResult(Object result) {}}
class ScreenStack { List<Screen> screens = new ArrayList<>();
void push(Screen screen) { screens.add(screen); }
void pop(Object result) { screens.remove(screens.size() - 1); Screen below = top(); if (below != null) below.onResult(result); }
Screen top() { return screens.isEmpty() ? null : screens.get(screens.size() - 1); }
String names() { List<String> names = new ArrayList<>(); for (Screen screen : screens) names.add(screen.name); return "[" + String.join(", ", names) + "]"; }}
class Enemy { String name; int hp;
Enemy(String name, int hp) { this.name = name; this.hp = hp; }}
class TargetScreen extends Screen { ScreenStack stack; List<Enemy> enemies;
TargetScreen(ScreenStack stack, List<Enemy> enemies) { super("Objetivo"); this.stack = stack; this.enemies = enemies; }
@Override void draw() { String line = "¿A quién golpeas?"; for (int i = 0; i < enemies.size(); i++) { line += " " + (i + 1) + ") " + enemies.get(i).name; } System.out.println(line + " x) atrás"); }
@Override void handle(String command) { if (command.equals("x")) { stack.pop(null); return; } int choice = Integer.parseInt(command); if (choice >= 1 && choice <= enemies.size()) stack.pop(choice - 1); }}
class FightScreen extends Screen { ScreenStack stack; List<Enemy> enemies = new ArrayList<>();
FightScreen(ScreenStack stack) { super("Combate"); this.stack = stack; enemies.add(new Enemy("Lobo", 4)); enemies.add(new Enemy("Goblin", 7)); }
@Override void draw() { String line = "Combate:"; for (Enemy enemy : enemies) line += " " + enemy.name + " " + enemy.hp; System.out.println(line + " | 1) golpe"); }
@Override void handle(String command) { if (command.equals("1")) stack.push(new TargetScreen(stack, enemies)); }
@Override void onResult(Object result) { if (result == null) { System.out.println("Cambias de idea. Nadie recibe el golpe."); return; } Enemy enemy = enemies.get((int) result); enemy.hp -= 4; if (enemy.hp > 0) { System.out.println("Golpeas a " + enemy.name + " por 4."); return; } System.out.println(enemy.name + " cae."); enemies.remove(enemy); if (enemies.isEmpty()) stack.pop("victoria"); }}
class ConfirmScreen extends Screen { ScreenStack stack; String question;
ConfirmScreen(ScreenStack stack, String question) { super(question); this.stack = stack; this.question = question; }
@Override void draw() { System.out.println(question + " 1) sí 2) no"); }
@Override void handle(String command) { if (command.equals("1")) stack.pop(true); else if (command.equals("2")) stack.pop(false); }}
class MapScreen extends Screen { ScreenStack stack; int potions = 2; String pending = "";
MapScreen(ScreenStack stack) { super("Mapa"); this.stack = stack; }
@Override void draw() { System.out.println("Mapa: pociones " + potions + " | f) combate d) tirar una poción q) salir"); }
@Override void handle(String command) { if (command.equals("f")) { stack.push(new FightScreen(stack)); } else if (command.equals("d")) { pending = "drop"; stack.push(new ConfirmScreen(stack, "¿Seguro que quieres tirar una poción?")); } else if (command.equals("q")) { pending = "quit"; stack.push(new ConfirmScreen(stack, "¿Seguro que quieres salir?")); } }
@Override void onResult(Object result) { String asked = pending; pending = ""; if (asked.equals("drop")) { if (result.equals(true)) { potions--; System.out.println("Tiras una poción."); } else { System.out.println("La poción se queda."); } } else if (asked.equals("quit")) { if (result.equals(true)) stack.pop(null); } else if (result.equals("victoria")) { System.out.println("¡Victoria! De vuelta al mapa."); } }}
public class Main { public static void main(String[] args) { ScreenStack stack = new ScreenStack(); stack.push(new MapScreen(stack)); Scanner in = new Scanner(System.in); while (stack.top() != null) { stack.top().draw(); String command = in.nextLine().trim(); System.out.println("> " + command); stack.top().handle(command); } System.out.println("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 extends Screen { ScreenStack stack; String question;
ConfirmScreen(ScreenStack stack, String question) { super(question); this.stack = stack; this.question = question; }
@Override void draw() { System.out.println(question + " 1) sí 2) no"); }
@Override void handle(String command) { if (command.equals("1")) stack.pop(true); else if (command.equals("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 extends Screen { ScreenStack stack; int potions = 2; String pending = "";
MapScreen(ScreenStack stack) { super("Mapa"); this.stack = stack; }
@Override void draw() { System.out.println("Mapa: pociones " + potions + " | f) combate d) tirar una poción q) salir"); }
@Override void handle(String command) { if (command.equals("f")) { stack.push(new FightScreen(stack)); } else if (command.equals("d")) { pending = "drop"; stack.push(new ConfirmScreen(stack, "¿Seguro que quieres tirar una poción?")); } else if (command.equals("q")) { pending = "quit"; stack.push(new ConfirmScreen(stack, "¿Seguro que quieres salir?")); } }
@Override void onResult(Object result) { String asked = pending; pending = ""; if (asked.equals("drop")) { if (result.equals(true)) { potions--; System.out.println("Tiras una poción."); } else { System.out.println("La poción se queda."); } } else if (asked.equals("quit")) { if (result.equals(true)) stack.pop(null); } else if (result.equals("victoria")) { System.out.println("¡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 extends Screen { ScreenStack stack; List<String> enemies;
TargetScreen(ScreenStack stack, List<String> enemies) { super("Objetivo"); this.stack = stack; this.enemies = enemies; }
@Override void handle(String command) { int index = Integer.parseInt(command) - 1; System.out.println("Objetivo recibe " + command + " y devuelve " + index); stack.pop(index); }
@Override void update() { System.out.println("Objetivo se actualiza, enemigos en la lista: " + enemies.size()); }}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();import java.util.ArrayList;import java.util.List;
class Screen { String name;
Screen(String name) { this.name = name; }
void draw() {}
void handle(String command) {}
void update() {}
// La respuesta de la pantalla que estaba encima de esta. void onResult(Object result) {}}
class ScreenStack { List<Screen> screens = new ArrayList<>();
void push(Screen screen) { screens.add(screen); }
void pop(Object result) { screens.remove(screens.size() - 1); Screen below = top(); if (below != null) below.onResult(result); }
Screen top() { return screens.isEmpty() ? null : screens.get(screens.size() - 1); }
String names() { List<String> names = new ArrayList<>(); for (Screen screen : screens) names.add(screen.name); return "[" + String.join(", ", names) + "]"; }}
class TargetScreen extends Screen { ScreenStack stack; List<String> enemies;
TargetScreen(ScreenStack stack, List<String> enemies) { super("Objetivo"); this.stack = stack; this.enemies = enemies; }
@Override void handle(String command) { int index = Integer.parseInt(command) - 1; System.out.println("Objetivo recibe " + command + " y devuelve " + index); stack.pop(index); }
@Override void update() { System.out.println("Objetivo se actualiza, enemigos en la lista: " + enemies.size()); }}
class FightScreen extends Screen { ScreenStack stack; List<String> enemies = new ArrayList<>(List.of("Lobo", "Goblin"));
FightScreen(ScreenStack stack) { super("Combate"); this.stack = stack; }
@Override void handle(String command) { System.out.println("Combate recibe " + command + " y abre Objetivo"); stack.push(new TargetScreen(stack, enemies)); }
@Override void onResult(Object result) { String enemy = enemies.remove((int) result); System.out.println("Combate recibe " + result + ": " + enemy + " cae"); if (enemies.isEmpty()) { System.out.println("Combate: no quedan enemigos, devuelve victoria"); stack.pop("victoria"); } }}
class MapScreen extends Screen { ScreenStack stack;
MapScreen(ScreenStack stack) { super("Mapa"); this.stack = stack; }
@Override void onResult(Object result) { System.out.println("Mapa recibe " + result + ": abre Subida de nivel"); stack.push(new Screen("Subida de nivel")); }}
public class Main { public static void main(String[] args) { ScreenStack stack = new ScreenStack(); stack.push(new MapScreen(stack)); stack.push(new FightScreen(stack)); for (String command : new String[] { "1", "2", "1", "1" }) { System.out.println("> " + command); Screen top = stack.top(); top.handle(command); top.update(); } System.out.println(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 ArrayList<>(List.of("Mapa", "Mensaje", "Combate", "Objetivo"));try { for (String screen : screens) { System.out.println("update " + screen); if (screen.equals("Mensaje")) screens.remove(screen); }} catch (ConcurrentModificationException e) { System.out.println("ConcurrentModificationException");}update Mapaupdate MensajeConcurrentModificationExceptionJava nota el cambio y lanza ConcurrentModificationException en el paso siguiente. Cuidado: si quitas el penúltimo elemento, no hay error. hasNext() compara la posición con la nueva longitud, dice «no hay más», y el bucle se detiene en silencio sin el último:
List<String> screens = new ArrayList<>(List.of("Mapa", "Mensaje", "Combate", "Objetivo"));try { for (String screen : screens) { System.out.println("update " + screen); if (screen.equals("Combate")) screens.remove(screen); }} catch (ConcurrentModificationException e) { System.out.println("ConcurrentModificationException");}update Mapaupdate Mensajeupdate CombateUn 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 { String kind; Screen screen; Object result;
Change(String kind, Screen screen, Object result) { this.kind = kind; this.screen = screen; this.result = result; }}class ScreenStack { List<Screen> screens = new ArrayList<>(); List<Change> changes = new ArrayList<>();
void push(Screen screen) { changes.add(new Change("push", screen, null)); }
void pop(Object result) { changes.add(new Change("pop", null, result)); }
Screen top() { return screens.isEmpty() ? null : screens.get(screens.size() - 1); }
void apply() { int index = 0; while (index < changes.size()) { Change change = changes.get(index); index++; if (change.kind.equals("push")) { screens.add(change.screen); } else if (change.kind.equals("pop")) { screens.remove(screens.size() - 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:
import java.util.ArrayList;import java.util.List;
class Screen { String name;
Screen(String name) { this.name = name; }
void draw() {}
void handle(String command) {}
void update() {}
// La respuesta de la pantalla que estaba encima de esta. void onResult(Object result) {}}
class Change { String kind; Screen screen; Object result;
Change(String kind, Screen screen, Object result) { this.kind = kind; this.screen = screen; this.result = result; }}
class ScreenStack { List<Screen> screens = new ArrayList<>(); List<Change> changes = new ArrayList<>();
void push(Screen screen) { changes.add(new Change("push", screen, null)); }
void pop(Object result) { changes.add(new Change("pop", null, result)); }
Screen top() { return screens.isEmpty() ? null : screens.get(screens.size() - 1); }
void apply() { int index = 0; while (index < changes.size()) { Change change = changes.get(index); index++; if (change.kind.equals("push")) { screens.add(change.screen); } else if (change.kind.equals("pop")) { screens.remove(screens.size() - 1); Screen below = top(); if (below != null) below.onResult(change.result); } } changes.clear(); }
String names() { List<String> names = new ArrayList<>(); for (Screen screen : screens) names.add(screen.name); return "[" + String.join(", ", names) + "]"; }}
class TargetScreen extends Screen { ScreenStack stack; List<String> enemies;
TargetScreen(ScreenStack stack, List<String> enemies) { super("Objetivo"); this.stack = stack; this.enemies = enemies; }
@Override void handle(String command) { int index = Integer.parseInt(command) - 1; System.out.println("Objetivo recibe " + command + " y devuelve " + index); stack.pop(index); }
@Override void update() { System.out.println("Objetivo se actualiza, enemigos en la lista: " + enemies.size()); }}
class FightScreen extends Screen { ScreenStack stack; List<String> enemies = new ArrayList<>(List.of("Lobo", "Goblin"));
FightScreen(ScreenStack stack) { super("Combate"); this.stack = stack; }
@Override void handle(String command) { System.out.println("Combate recibe " + command + " y abre Objetivo"); stack.push(new TargetScreen(stack, enemies)); }
@Override void onResult(Object result) { String enemy = enemies.remove((int) result); System.out.println("Combate recibe " + result + ": " + enemy + " cae"); if (enemies.isEmpty()) { System.out.println("Combate: no quedan enemigos, devuelve victoria"); stack.pop("victoria"); } }}
class MapScreen extends Screen { ScreenStack stack;
MapScreen(ScreenStack stack) { super("Mapa"); this.stack = stack; }
@Override void onResult(Object result) { System.out.println("Mapa recibe " + result + ": abre Subida de nivel"); stack.push(new Screen("Subida de nivel")); }}
public class Main { public static void main(String[] args) { ScreenStack stack = new ScreenStack(); stack.push(new MapScreen(stack)); stack.push(new FightScreen(stack)); stack.apply(); for (String command : new String[] { "1", "2", "1", "1" }) { System.out.println("> " + command); Screen top = stack.top(); top.handle(command); top.update(); stack.apply(); } System.out.println(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 { List<Screen> screens = new ArrayList<>(); List<Change> changes = new ArrayList<>();
void push(Screen screen) { changes.add(new Change("push", screen, null)); }
void pop(Object result) { changes.add(new Change("pop", null, result)); }
Screen top() { return screens.isEmpty() ? null : screens.get(screens.size() - 1); }
void apply() { int index = 0; while (index < changes.size()) { Change change = changes.get(index); index++; if (change.kind.equals("push")) { screens.add(change.screen); } else if (change.kind.equals("pop")) { screens.remove(screens.size() - 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?
void apply() {
int index = 0;
while (index < ) {
Change change = changes.get(index);
index++;
if (change.kind.equals("push")) {
screens.add(change.screen);
} else if (change.kind.equals("pop")) {
screens.remove(screens.size() - 1);
Screen below = top();
if (below != null) below.onResult(change.result);
}
}
changes.clear();
}La longitud se lee en cada vuelta: changes.size(). 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"));System.out.println("Arriba: " + stack.top().name);stack.apply();System.out.println("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:
void replace(Screen screen) { changes.add(new Change("replace", screen, null));}
void reset(Screen screen) { changes.add(new Change("reset", screen, null));}apply tiene una rama para cada uno:
} else if (change.kind.equals("replace")) { screens.remove(screens.size() - 1); screens.add(change.screen);} else if (change.kind.equals("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();System.out.println(stack.names());stack.replace(new NamedScreen("Nuevo héroe"));stack.apply();System.out.println(stack.names());stack.replace(new NamedScreen("Aldea"));stack.apply();System.out.println(stack.names());
System.out.println("El héroe cae. Con pop:");ScreenStack popped = inFight();popped.pop("derrota");popped.pop("derrota");popped.push(new NamedScreen("Fin del juego"));popped.apply();System.out.println(popped.names());
System.out.println("El héroe cae. Con reset:");ScreenStack clean = inFight();clean.reset(new NamedScreen("Fin del juego"));clean.apply();System.out.println(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:
import java.util.ArrayList;import java.util.List;
class Screen { String name;
Screen(String name) { this.name = name; }
void draw() {}
void handle(String command) {}
void update() {}
// La respuesta de la pantalla que estaba encima de esta. void onResult(Object result) {}}
class Change { String kind; Screen screen; Object result;
Change(String kind, Screen screen, Object result) { this.kind = kind; this.screen = screen; this.result = result; }}
class ScreenStack { List<Screen> screens = new ArrayList<>(); List<Change> changes = new ArrayList<>();
void push(Screen screen) { changes.add(new Change("push", screen, null)); }
void pop(Object result) { changes.add(new Change("pop", null, result)); }
void replace(Screen screen) { changes.add(new Change("replace", screen, null)); }
void reset(Screen screen) { changes.add(new Change("reset", screen, null)); }
Screen top() { return screens.isEmpty() ? null : screens.get(screens.size() - 1); }
void apply() { int index = 0; while (index < changes.size()) { Change change = changes.get(index); index++; if (change.kind.equals("push")) { screens.add(change.screen); } else if (change.kind.equals("pop")) { screens.remove(screens.size() - 1); Screen below = top(); if (below != null) below.onResult(change.result); } else if (change.kind.equals("replace")) { screens.remove(screens.size() - 1); screens.add(change.screen); } else if (change.kind.equals("reset")) { screens.clear(); if (change.screen != null) screens.add(change.screen); } } changes.clear(); }
String names() { List<String> names = new ArrayList<>(); for (Screen screen : screens) names.add(screen.name); return "[" + String.join(", ", names) + "]"; }}
class NamedScreen extends Screen { NamedScreen(String name) { super(name); }
@Override void onResult(Object result) { System.out.println(name + " recibe " + result); }}
public class Main { 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; }
public static void main(String[] args) { ScreenStack stack = new ScreenStack(); stack.push(new NamedScreen("Título")); stack.apply(); System.out.println(stack.names()); stack.replace(new NamedScreen("Nuevo héroe")); stack.apply(); System.out.println(stack.names()); stack.replace(new NamedScreen("Aldea")); stack.apply(); System.out.println(stack.names());
System.out.println("El héroe cae. Con pop:"); ScreenStack popped = inFight(); popped.pop("derrota"); popped.pop("derrota"); popped.push(new NamedScreen("Fin del juego")); popped.apply(); System.out.println(popped.names());
System.out.println("El héroe cae. Con reset:"); ScreenStack clean = inFight(); clean.reset(new NamedScreen("Fin del juego")); clean.apply(); System.out.println(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.static class StackChange { String kind; Screen screen; Object result;
StackChange(String kind, Screen screen, Object result) { this.kind = kind; this.screen = screen; this.result = result; }}
static class ScreenStack { List<Screen> screens = new ArrayList<>(); List<StackChange> changes = new ArrayList<>();
void push(Screen screen) { this.changes.add(new StackChange("push", screen, null)); }
void pop(Object result) { this.changes.add(new StackChange("pop", null, result)); }
void replace(Screen screen) { this.changes.add(new StackChange("replace", screen, null)); }
// Vacía toda la pila (cargar, fin del juego); con null la pila queda vacía. void reset(Screen screen) { this.changes.add(new StackChange("reset", screen, null)); }
Screen top() { return this.screens.size() == 0 ? null : this.screens.get(this.screens.size() - 1); }
boolean isEmpty() { return this.screens.size() == 0 && this.changes.size() == 0; }
boolean contains(String kindName) { for (Screen screen : this.screens) { if (screen.kindName.equals(kindName)) return true; } return false; }
// pop sobre una pila vacía no hace nada en JavaScript; en Java remove lanza una excepción, así que hay una comprobación. void removeTop() { if (this.screens.size() > 0) this.screens.remove(this.screens.size() - 1); }
void apply() { int index = 0; while (index < this.changes.size()) { StackChange change = this.changes.get(index); index++; if (change.kind.equals("push")) { this.screens.add(change.screen); } else if (change.kind.equals("pop")) { this.removeTop(); Screen below = this.top(); if (below != null) below.onResult(change.result); } else if (change.kind.equals("replace")) { this.removeTop(); this.screens.add(change.screen); } else { this.screens = new ArrayList<>(); if (change.screen != null) this.screens.add(change.screen); } } this.changes = new ArrayList<>(); }
// El dibujo empieza en la pantalla opaca de más arriba y sube desde ahí. void draw(CellBuffer buffer) { int first = this.screens.size() - 1; while (first > 0 && this.screens.get(first).isTransparent()) first--; for (int i = first; i < this.screens.size(); i++) { if (i >= 0) this.screens.get(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.static class TargetScreen extends Screen { Battle battle; String note;
TargetScreen(Game game, Battle battle) { super(game); this.battle = battle; this.note = ""; }
@Override boolean isTransparent() { return true; }
@Override void draw(CellBuffer buffer) { List<Enemy> enemies = this.battle.aliveEnemies(); buffer.box(24, 9, 36, enemies.size() + 4, t("target.title")); for (int i = 0; i < enemies.size(); i++) { buffer.text(26, 10 + i, (i + 1) + ") " + pad(enemies.get(i).name, 22) + " " + enemies.get(i).hp + "/" + enemies.get(i).maxHp(), enemies.get(i).kind.color); } buffer.text(26, 11 + enemies.size(), this.note.length() > 0 ? this.note : t("target.help"), "gray"); }
@Override void handle(String command) { if (command.equals("x")) { this.game.stack.pop(null); return; } Integer choice = parseIntStrict(command); if (choice == null || choice < 1 || choice > this.battle.aliveEnemies().size()) { this.note = t("ui.unknown", "command", command); return; } this.game.stack.pop(choice - 1); }}
// Una elección general de una lista (habilidad, poción, ranura): devuelve un número desde 0, o null.static class ChoiceScreen extends Screen { String title; List<String> labels; String note;
ChoiceScreen(Game game, String title, List<String> labels) { super(game); this.title = title; this.labels = labels; this.note = ""; }
@Override boolean isTransparent() { return true; }
@Override void draw(CellBuffer buffer) { buffer.box(4, 3, 52, this.labels.size() + 4, this.title); for (int i = 0; i < this.labels.size(); i++) buffer.text(6, 4 + i, (i + 1) + ") " + this.labels.get(i), "default"); buffer.text(6, 5 + this.labels.size(), this.note.length() > 0 ? this.note : t("choice.help"), "gray"); }
@Override void handle(String command) { if (command.equals("x")) { this.game.stack.pop(null); return; } Integer choice = parseIntStrict(command); if (choice == null || choice < 1 || choice > this.labels.size()) { this.note = t("ui.unknown", "command", command); return; } this.game.stack.pop(choice - 1); }}
static class SlotScreen extends ChoiceScreen { SlotScreen(Game game, String mode) { super(game, t(mode.equals("save") ? "slot.saveTitle" : "slot.loadTitle"), slotLabels(game)); }
// En Java el constructor llama primero a super, así que las etiquetas se construyen en una función aparte. static List<String> slotLabels(Game game) { List<String> labels = new ArrayList<>(); for (String slot : SAVE_SLOTS) labels.add(t("slot.label", "slot", slot, "summary", slotSummary(game.io, slot))); return labels; }
@Override void handle(String command) { Integer choice = parseIntStrict(command); if (choice != null && choice >= 1 && choice <= SAVE_SLOTS.size()) { this.game.stack.pop(SAVE_SLOTS.get(choice - 1)); return; } super.handle(command); }}El combate recuerda lo que preguntó (pending) y lee la respuesta en onResult:
void onResult(Object result) { Battle battle = this.battle; Hero hero = battle.hero; Pending pending = this.pending; this.pending = null; if (result == null || pending == null) return; int index = (Integer) result; if (pending.kind.equals("attack")) { battle.heroAct(new AttackAction(hero, battle.aliveEnemies().get(index))); } else if (pending.kind.equals("skill")) { Skill skill = SKILLS.get(hero.skills.get(index)); if (hero.mana < skill.mana) { this.note = t("battle.noMana", "skill", nm(skill)); } else if (hero.cooldownOf(skill.id) > 0) { this.note = t("battle.cooldown", "skill", nm(skill), "turns", hero.cooldownOf(skill.id)); } else if (skill.target.equals("one") && battle.aliveEnemies().size() > 1) { this.pending = new Pending("skillTarget", skill); this.game.stack.push(new TargetScreen(this.game, battle)); } else { battle.heroAct(new SkillAction(hero, skill, battle.aliveEnemies().get(0))); } } else if (pending.kind.equals("skillTarget")) { battle.heroAct(new SkillAction(hero, pending.skill, battle.aliveEnemies().get(index))); } else { battle.heroAct(new ItemAction(hero, this.potionIds().get(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.
static class ConfirmScreen extends Screen { String question;
ConfirmScreen(Game game, String question) { super(game); this.question = question; }
@Override boolean isTransparent() { return true; }
@Override void draw(CellBuffer buffer) { buffer.box(16, 6, 28, 5, ""); buffer.text(18, 7, this.question, "white"); buffer.text(18, 8, "1) " + t("ui.yes") + " 2) " + t("ui.no"), "default"); }
@Override void handle(String command) { if (command.equals("1")) this.game.stack.pop(true); else if (command.equals("2") || command.equals("x")) this.game.stack.pop(false); }}void onResult(Object result) { Game game = this.game; String pending = this.pending; this.pending = ""; if (result == null) return; if (pending.equals("save")) { game.io.save((String) result, saveGame(game)); this.note = t("menu.saved", "slot", result); } else if (pending.equals("load")) { String error = loadGame(game, game.io.load((String) result)); if (error != null) this.note = error; else enterWorld(game, t("load.done", "slot", result)); } else if (pending.equals("quit") && Boolean.TRUE.equals(result)) { 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 { List<Screen> screens = new ArrayList<>(); List<Change> changes = new ArrayList<>();
void push(Screen screen) { changes.add(new Change("push", screen, null)); }
void pop(Object result) { changes.add(new Change("pop", null, result)); }
Screen top() { return screens.isEmpty() ? null : screens.get(screens.size() - 1); }
void apply() { int index = 0; while (index < changes.size()) { Change change = changes.get(index); index++; if (change.kind.equals("push")) { screens.add(change.screen); } else if (change.kind.equals("pop")) { Screen below = top(); screens.remove(screens.size() - 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
- Ejercicios sobre el tema: Las crónicas de Arcanum