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):
enemies = ["Lobo", "Goblin"]selected_target = -1
def target_screen(command): global selected_target if command != "x": selected_target = int(command) - 1
def fight_turn(): if selected_target >= 0: print("Golpeas a " + enemies[selected_target] + ".") else: print("Nadie recibe el golpe.")
# El jugador elige 2 y luego cambia de idea (x).target_screen("2")fight_turn()target_screen("x")fight_turn()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): def handle(self, command): # El objetivo conoce el combate y lo llama él mismo. self.fight.attack(int(command) - 1) self.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 on_result.
La pantalla recibe un método más. Por defecto no hace nada: la mayoría de las pantallas nunca preguntan nada.
class Screen: def __init__(self, name): self.name = name
def draw(self): pass
def handle(self, command): pass
def update(self): pass
# La respuesta de la pantalla que estaba encima de esta. def on_result(self, result): passY el pop de la lección anterior se convierte en pop(result):
def pop(self, result): self.screens.pop() below = self.top() if below is not None: below.on_result(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 None 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 None. El número se cuenta desde 0, porque es un índice de la lista:
class TargetScreen(Screen): def __init__(self, stack, enemies): super().__init__("Objetivo") self.stack = stack self.enemies = enemies
def draw(self): line = "¿A quién golpeas?" for i in range(len(self.enemies)): line += f" {i + 1}) {self.enemies[i].name}" print(line + " x) atrás")
def handle(self, command): if command == "x": self.stack.pop(None) return choice = int(command) if 1 <= choice <= len(self.enemies): self.stack.pop(choice - 1)El combate abre el objetivo con 1 y lee la respuesta en on_result. Cuando no quedan enemigos, el combate también devuelve una respuesta a la pantalla de debajo, el mapa: «victoria».
class FightScreen(Screen): def __init__(self, stack): super().__init__("Combate") self.stack = stack self.enemies = [Enemy("Lobo", 4), Enemy("Goblin", 7)]
def draw(self): line = "Combate:" for enemy in self.enemies: line += f" {enemy.name} {enemy.hp}" print(line + " | 1) golpe")
def handle(self, command): if command == "1": self.stack.push(TargetScreen(self.stack, self.enemies))
def on_result(self, result): if result is None: print("Cambias de idea. Nadie recibe el golpe.") return enemy = self.enemies[result] enemy.hp -= 4 if enemy.hp > 0: print("Golpeas a " + enemy.name + " por 4.") return print(enemy.name + " cae.") self.enemies.remove(enemy) if not self.enemies: self.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:
stack = ScreenStack()stack.push(MapScreen(stack))while stack.top() is not None: stack.top().draw() command = input().strip() print("> " + command) stack.top().handle(command)print("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: def __init__(self, name): self.name = name
def draw(self): pass
def handle(self, command): pass
def update(self): pass
# La respuesta de la pantalla que estaba encima de esta. def on_result(self, result): pass
class ScreenStack: def __init__(self): self.screens = []
def push(self, screen): self.screens.append(screen)
def pop(self, result): self.screens.pop() below = self.top() if below is not None: below.on_result(result)
def top(self): return self.screens[-1] if self.screens else None
def names(self): return "[" + ", ".join(s.name for s in self.screens) + "]"
class Enemy: def __init__(self, name, hp): self.name = name self.hp = hp
class TargetScreen(Screen): def __init__(self, stack, enemies): super().__init__("Objetivo") self.stack = stack self.enemies = enemies
def draw(self): line = "¿A quién golpeas?" for i in range(len(self.enemies)): line += f" {i + 1}) {self.enemies[i].name}" print(line + " x) atrás")
def handle(self, command): if command == "x": self.stack.pop(None) return choice = int(command) if 1 <= choice <= len(self.enemies): self.stack.pop(choice - 1)
class FightScreen(Screen): def __init__(self, stack): super().__init__("Combate") self.stack = stack self.enemies = [Enemy("Lobo", 4), Enemy("Goblin", 7)]
def draw(self): line = "Combate:" for enemy in self.enemies: line += f" {enemy.name} {enemy.hp}" print(line + " | 1) golpe")
def handle(self, command): if command == "1": self.stack.push(TargetScreen(self.stack, self.enemies))
def on_result(self, result): if result is None: print("Cambias de idea. Nadie recibe el golpe.") return enemy = self.enemies[result] enemy.hp -= 4 if enemy.hp > 0: print("Golpeas a " + enemy.name + " por 4.") return print(enemy.name + " cae.") self.enemies.remove(enemy) if not self.enemies: self.stack.pop("victoria")
class ConfirmScreen(Screen): def __init__(self, stack, question): super().__init__(question) self.stack = stack self.question = question
def draw(self): print(self.question + " 1) sí 2) no")
def handle(self, command): if command == "1": self.stack.pop(True) elif command == "2": self.stack.pop(False)
class MapScreen(Screen): def __init__(self, stack): super().__init__("Mapa") self.stack = stack self.potions = 2 self.pending = ""
def draw(self): print("Mapa: pociones " + str(self.potions) + " | f) combate d) tirar una poción q) salir")
def handle(self, command): if command == "f": self.stack.push(FightScreen(self.stack)) elif command == "d": self.pending = "drop" self.stack.push(ConfirmScreen(self.stack, "¿Seguro que quieres tirar una poción?")) elif command == "q": self.pending = "quit" self.stack.push(ConfirmScreen(self.stack, "¿Seguro que quieres salir?"))
def on_result(self, result): asked = self.pending self.pending = "" if asked == "drop": if result is True: self.potions -= 1 print("Tiras una poción.") else: print("La poción se queda.") elif asked == "quit": if result is True: self.stack.pop(None) elif result == "victoria": print("¡Victoria! De vuelta al mapa.")
stack = ScreenStack()stack.push(MapScreen(stack))while stack.top() is not None: stack.top().draw() command = input().strip() print("> " + command) stack.top().handle(command)print("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): def __init__(self, stack, question): super().__init__(question) self.stack = stack self.question = question
def draw(self): print(self.question + " 1) sí 2) no")
def handle(self, command): if command == "1": self.stack.pop(True) elif command == "2": self.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): def __init__(self, stack): super().__init__("Mapa") self.stack = stack self.potions = 2 self.pending = ""
def draw(self): print("Mapa: pociones " + str(self.potions) + " | f) combate d) tirar una poción q) salir")
def handle(self, command): if command == "f": self.stack.push(FightScreen(self.stack)) elif command == "d": self.pending = "drop" self.stack.push(ConfirmScreen(self.stack, "¿Seguro que quieres tirar una poción?")) elif command == "q": self.pending = "quit" self.stack.push(ConfirmScreen(self.stack, "¿Seguro que quieres salir?"))
def on_result(self, result): asked = self.pending self.pending = "" if asked == "drop": if result is True: self.potions -= 1 print("Tiras una poción.") else: print("La poción se queda.") elif asked == "quit": if result is True: self.stack.pop(None) elif result == "victoria": print("¡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): def __init__(self, stack, enemies): super().__init__("Objetivo") self.stack = stack self.enemies = enemies
def handle(self, command): index = int(command) - 1 print("Objetivo recibe " + command + " y devuelve " + str(index)) self.stack.pop(index)
def update(self): print("Objetivo se actualiza, enemigos en la lista: " + str(len(self.enemies)))El bucle es como en el juego real: toma la cima, dale el comando, actualízala.
top = stack.top()top.handle(command)top.update()class Screen: def __init__(self, name): self.name = name
def draw(self): pass
def handle(self, command): pass
def update(self): pass
# La respuesta de la pantalla que estaba encima de esta. def on_result(self, result): pass
class ScreenStack: def __init__(self): self.screens = []
def push(self, screen): self.screens.append(screen)
def pop(self, result): self.screens.pop() below = self.top() if below is not None: below.on_result(result)
def top(self): return self.screens[-1] if self.screens else None
def names(self): return "[" + ", ".join(s.name for s in self.screens) + "]"
class TargetScreen(Screen): def __init__(self, stack, enemies): super().__init__("Objetivo") self.stack = stack self.enemies = enemies
def handle(self, command): index = int(command) - 1 print("Objetivo recibe " + command + " y devuelve " + str(index)) self.stack.pop(index)
def update(self): print("Objetivo se actualiza, enemigos en la lista: " + str(len(self.enemies)))
class FightScreen(Screen): def __init__(self, stack): super().__init__("Combate") self.stack = stack self.enemies = ["Lobo", "Goblin"]
def handle(self, command): print("Combate recibe " + command + " y abre Objetivo") self.stack.push(TargetScreen(self.stack, self.enemies))
def on_result(self, result): enemy = self.enemies.pop(result) print("Combate recibe " + str(result) + ": " + enemy + " cae") if not self.enemies: print("Combate: no quedan enemigos, devuelve victoria") self.stack.pop("victoria")
class MapScreen(Screen): def __init__(self, stack): super().__init__("Mapa") self.stack = stack
def on_result(self, result): print("Mapa recibe " + str(result) + ": abre Subida de nivel") self.stack.push(Screen("Subida de nivel"))
stack = ScreenStack()stack.push(MapScreen(stack))stack.push(FightScreen(stack))for command in ["1", "2", "1", "1"]: print("> " + command) top = stack.top() top.handle(command) top.update()print(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:
screens = ["Mapa", "Mensaje", "Combate", "Objetivo"]for screen in screens: print("update " + screen) if screen == "Mensaje": screens.remove(screen)update Mapaupdate Mensajeupdate Objetivofor va por índice: se quita Mensaje, todo se corre un lugar a la izquierda, y Combate, que era el siguiente, se salta. El programa sigue con un resultado equivocado.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: def __init__(self, kind, screen, result): self.kind = kind self.screen = screen self.result = resultclass ScreenStack: def __init__(self): self.screens = [] self.changes = []
def push(self, screen): self.changes.append(Change("push", screen, None))
def pop(self, result): self.changes.append(Change("pop", None, result))
def top(self): return self.screens[-1] if self.screens else None
def apply(self): index = 0 while index < len(self.changes): change = self.changes[index] index += 1 if change.kind == "push": self.screens.append(change.screen) elif change.kind == "pop": self.screens.pop() below = self.top() if below is not None: below.on_result(change.result) self.changes.clear()El bucle llama a apply al final:
top = stack.top()top.handle(command)top.update()stack.apply()El mismo combate con las mismas pantallas, solo cambia la pila:
class Screen: def __init__(self, name): self.name = name
def draw(self): pass
def handle(self, command): pass
def update(self): pass
# La respuesta de la pantalla que estaba encima de esta. def on_result(self, result): pass
class Change: def __init__(self, kind, screen, result): self.kind = kind self.screen = screen self.result = result
class ScreenStack: def __init__(self): self.screens = [] self.changes = []
def push(self, screen): self.changes.append(Change("push", screen, None))
def pop(self, result): self.changes.append(Change("pop", None, result))
def top(self): return self.screens[-1] if self.screens else None
def apply(self): index = 0 while index < len(self.changes): change = self.changes[index] index += 1 if change.kind == "push": self.screens.append(change.screen) elif change.kind == "pop": self.screens.pop() below = self.top() if below is not None: below.on_result(change.result) self.changes.clear()
def names(self): return "[" + ", ".join(s.name for s in self.screens) + "]"
class TargetScreen(Screen): def __init__(self, stack, enemies): super().__init__("Objetivo") self.stack = stack self.enemies = enemies
def handle(self, command): index = int(command) - 1 print("Objetivo recibe " + command + " y devuelve " + str(index)) self.stack.pop(index)
def update(self): print("Objetivo se actualiza, enemigos en la lista: " + str(len(self.enemies)))
class FightScreen(Screen): def __init__(self, stack): super().__init__("Combate") self.stack = stack self.enemies = ["Lobo", "Goblin"]
def handle(self, command): print("Combate recibe " + command + " y abre Objetivo") self.stack.push(TargetScreen(self.stack, self.enemies))
def on_result(self, result): enemy = self.enemies.pop(result) print("Combate recibe " + str(result) + ": " + enemy + " cae") if not self.enemies: print("Combate: no quedan enemigos, devuelve victoria") self.stack.pop("victoria")
class MapScreen(Screen): def __init__(self, stack): super().__init__("Mapa") self.stack = stack
def on_result(self, result): print("Mapa recibe " + str(result) + ": abre Subida de nivel") self.stack.push(Screen("Subida de nivel"))
stack = ScreenStack()stack.push(MapScreen(stack))stack.push(FightScreen(stack))stack.apply()for command in ["1", "2", "1", "1"]: print("> " + command) top = stack.top() top.handle(command) top.update() stack.apply()print(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 también funcionaría aquí por casualidad, pero while con un índice dice «vuelve a leer la longitud» de forma explícita y no depende de cómo se comporte el iterador.
- 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: def __init__(self): self.screens = [] self.changes = []
def push(self, screen): self.changes.append(Change("push", screen, None))
def pop(self, result): self.changes.append(Change("pop", None, result))
def top(self): return self.screens[-1] if self.screens else None
def apply(self): index = 0 while index < len(self.changes): change = self.changes[index] index += 1 if change.kind == "push": self.screens.append(change.screen) elif change.kind == "pop": self.screens.pop() below = self.top() if below is not None: below.on_result(change.result) self.changes.clear()
# Un turno.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 on_result. 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?
def apply(self):
index = 0
while index < :
change = self.changes[index]
index += 1
if change.kind == "push":
self.screens.append(change.screen)
elif change.kind == "pop":
self.screens.pop()
below = self.top()
if below is not None:
below.on_result(change.result)
self.changes.clear()La longitud se lee en cada vuelta: len(self.changes). 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?
stack = ScreenStack()stack.push(NamedScreen("Mapa"))stack.push(NamedScreen("Combate"))stack.apply()stack.pop("victoria")stack.push(NamedScreen("Subida de nivel"))print("Arriba: " + stack.top().name)stack.apply()print("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:
def replace(self, screen): self.changes.append(Change("replace", screen, None))
def reset(self, screen): self.changes.append(Change("reset", screen, None))apply tiene una rama para cada uno:
elif change.kind == "replace": self.screens.pop() self.screens.append(change.screen)elif change.kind == "reset": self.screens.clear() if change.screen is not None: self.screens.append(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:
stack = ScreenStack()stack.push(NamedScreen("Título"))stack.apply()print(stack.names())stack.replace(NamedScreen("Nuevo héroe"))stack.apply()print(stack.names())stack.replace(NamedScreen("Aldea"))stack.apply()print(stack.names())
print("El héroe cae. Con pop:")popped = in_fight()popped.pop("derrota")popped.pop("derrota")popped.push(NamedScreen("Fin del juego"))popped.apply()print(popped.names())
print("El héroe cae. Con reset:")clean = in_fight()clean.reset(NamedScreen("Fin del juego"))clean.apply()print(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: def __init__(self, name): self.name = name
def draw(self): pass
def handle(self, command): pass
def update(self): pass
# La respuesta de la pantalla que estaba encima de esta. def on_result(self, result): pass
class Change: def __init__(self, kind, screen, result): self.kind = kind self.screen = screen self.result = result
class ScreenStack: def __init__(self): self.screens = [] self.changes = []
def push(self, screen): self.changes.append(Change("push", screen, None))
def pop(self, result): self.changes.append(Change("pop", None, result))
def replace(self, screen): self.changes.append(Change("replace", screen, None))
def reset(self, screen): self.changes.append(Change("reset", screen, None))
def top(self): return self.screens[-1] if self.screens else None
def apply(self): index = 0 while index < len(self.changes): change = self.changes[index] index += 1 if change.kind == "push": self.screens.append(change.screen) elif change.kind == "pop": self.screens.pop() below = self.top() if below is not None: below.on_result(change.result) elif change.kind == "replace": self.screens.pop() self.screens.append(change.screen) elif change.kind == "reset": self.screens.clear() if change.screen is not None: self.screens.append(change.screen) self.changes.clear()
def names(self): return "[" + ", ".join(s.name for s in self.screens) + "]"
class NamedScreen(Screen): def on_result(self, result): print(self.name + " recibe " + str(result))
def in_fight(): stack = ScreenStack() stack.push(NamedScreen("Aldea")) stack.push(NamedScreen("Mapa")) stack.push(NamedScreen("Combate")) stack.apply() return stack
stack = ScreenStack()stack.push(NamedScreen("Título"))stack.apply()print(stack.names())stack.replace(NamedScreen("Nuevo héroe"))stack.apply()print(stack.names())stack.replace(NamedScreen("Aldea"))stack.apply()print(stack.names())
print("El héroe cae. Con pop:")popped = in_fight()popped.pop("derrota")popped.pop("derrota")popped.push(NamedScreen("Fin del juego"))popped.apply()print(popped.names())
print("El héroe cae. Con reset:")clean = in_fight()clean.reset(NamedScreen("Fin del juego"))clean.apply()print(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 ScreenStack: def __init__(self): self.screens = [] self.changes = []
def push(self, screen): self.changes.append(SimpleNamespace(kind='push', screen=screen, result=None))
def pop(self, result): self.changes.append(SimpleNamespace(kind='pop', screen=None, result=result))
def replace(self, screen): self.changes.append(SimpleNamespace(kind='replace', screen=screen, result=None))
# Vacía toda la pila (cargar, fin del juego); con None la pila queda vacía. def reset(self, screen): self.changes.append(SimpleNamespace(kind='reset', screen=screen, result=None))
def top(self): return None if len(self.screens) == 0 else self.screens[-1]
def is_empty(self): return len(self.screens) == 0 and len(self.changes) == 0
def contains(self, kind_name): for screen in self.screens: if getattr(screen, 'kind_name', None) == kind_name: return True return False
def apply(self): index = 0 while index < len(self.changes): change = self.changes[index] index += 1 if change.kind == 'push': self.screens.append(change.screen) elif change.kind == 'pop': # pop() sobre una lista vacía es un error en Python; en JavaScript devuelve undefined. if len(self.screens) > 0: self.screens.pop() below = self.top() if below is not None: below.on_result(change.result) elif change.kind == 'replace': if len(self.screens) > 0: self.screens.pop() self.screens.append(change.screen) else: self.screens = [] if change.screen is not None: self.screens.append(change.screen) self.changes = []
# El dibujo empieza en la pantalla opaca de más arriba y sube desde ahí. def draw(self, buffer): first = len(self.screens) - 1 while first > 0 and self.screens[first].is_transparent(): first -= 1 for i in range(first, len(self.screens)): if i >= 0: self.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): def __init__(self, game, battle): super().__init__(game) self.battle = battle self.note = ''
def is_transparent(self): return True
def draw(self, buffer): enemies = self.battle.alive_enemies() buffer.box(24, 9, 36, len(enemies) + 4, t('target.title')) for i in range(len(enemies)): buffer.text(26, 10 + i, str(i + 1) + ') ' + pad(enemies[i].name, 22) + ' ' + str(enemies[i].hp) + '/' + str(enemies[i].max_hp()), enemies[i].kind.color) buffer.text(26, 11 + len(enemies), self.note if len(self.note) > 0 else t('target.help'), 'gray')
def handle(self, command): if command == 'x': self.game.stack.pop(None) return choice = parse_int_strict(command) if choice is None or choice < 1 or choice > len(self.battle.alive_enemies()): self.note = t('ui.unknown', {'command': command}) return self.game.stack.pop(choice - 1)
# Una elección general de una lista (habilidad, poción, ranura): devuelve un número desde 0, o None.class ChoiceScreen(Screen): def __init__(self, game, title, labels): super().__init__(game) self.title = title self.labels = labels self.note = ''
def is_transparent(self): return True
def draw(self, buffer): buffer.box(4, 3, 52, len(self.labels) + 4, self.title) for i in range(len(self.labels)): buffer.text(6, 4 + i, str(i + 1) + ') ' + self.labels[i], 'default') buffer.text(6, 5 + len(self.labels), self.note if len(self.note) > 0 else t('choice.help'), 'gray')
def handle(self, command): if command == 'x': self.game.stack.pop(None) return choice = parse_int_strict(command) if choice is None or choice < 1 or choice > len(self.labels): self.note = t('ui.unknown', {'command': command}) return self.game.stack.pop(choice - 1)
class SlotScreen(ChoiceScreen): def __init__(self, game, mode): labels = [] for slot in SAVE_SLOTS: labels.append(t('slot.label', {'slot': slot, 'summary': slot_summary(game.io, slot)})) super().__init__(game, t('slot.saveTitle' if mode == 'save' else 'slot.loadTitle'), labels)
def handle(self, command): choice = parse_int_strict(command) if choice is not None and choice >= 1 and choice <= len(SAVE_SLOTS): self.game.stack.pop(SAVE_SLOTS[choice - 1]) return super().handle(command)El combate recuerda lo que preguntó (pending) y lee la respuesta en on_result:
def on_result(self, result): battle = self.battle hero = battle.hero pending = self.pending self.pending = None if result is None or pending is None: return if pending.kind == 'attack': battle.hero_act(AttackAction(hero, battle.alive_enemies()[result])) elif pending.kind == 'skill': skill = SKILLS[hero.skills[result]] if hero.mana < skill.mana: self.note = t('battle.noMana', {'skill': nm(skill)}) elif hero.cooldown_of(skill.id) > 0: self.note = t('battle.cooldown', {'skill': nm(skill), 'turns': hero.cooldown_of(skill.id)}) elif skill.target == 'one' and len(battle.alive_enemies()) > 1: self.pending = SimpleNamespace(kind='skillTarget', skill=skill) self.game.stack.push(TargetScreen(self.game, battle)) else: battle.hero_act(SkillAction(hero, skill, battle.alive_enemies()[0])) elif pending.kind == 'skillTarget': battle.hero_act(SkillAction(hero, pending.skill, battle.alive_enemies()[result])) else: battle.hero_act(ItemAction(hero, self.potion_ids()[result]))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): def __init__(self, game, question): super().__init__(game) self.question = question
def is_transparent(self): return True
def draw(self, buffer): buffer.box(16, 6, 28, 5, '') buffer.text(18, 7, self.question, 'white') buffer.text(18, 8, '1) ' + t('ui.yes') + ' 2) ' + t('ui.no'), 'default')
def handle(self, command): if command == '1': self.game.stack.pop(True) elif command == '2' or command == 'x': self.game.stack.pop(False)def on_result(self, result): game = self.game pending = self.pending self.pending = '' if result is None: return if pending == 'save': game.io.save(result, save_game(game)) self.note = t('menu.saved', {'slot': result}) elif pending == 'load': error = load_game(game, game.io.load(result)) if error is not None: self.note = error else: enter_world(game, t('load.done', {'slot': result})) elif pending == 'quit' and result is True: game.stack.reset(None)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: def __init__(self): self.screens = [] self.changes = []
def push(self, screen): self.changes.append(Change("push", screen, None))
def pop(self, result): self.changes.append(Change("pop", None, result))
def top(self): return self.screens[-1] if self.screens else None
def apply(self): index = 0 while index < len(self.changes): change = self.changes[index] index += 1 if change.kind == "push": self.screens.append(change.screen) elif change.kind == "pop": below = self.top() self.screens.pop() if below is not None: below.on_result(change.result) self.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 on_result 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.self.stack.push(TargetScreen(self.enemies, lambda index: self.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 cualquier valor, 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: on_result 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 on_result, 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: Polimorfismo · Listas: append, remove y sort · Pila, cola y lista enlazada · Lambdas
- Ejercicios sobre el tema: Las crónicas de Arcanum