Niveles, diálogos y misiones
En resumen: La experiencia se cuenta con una tabla y los niveles y habilidades nuevos se dan de uno en uno; el combate y el mapa solo publican eventos en un bus, mientras las misiones se suscriben y son máquinas de estados; un diálogo es un árbol en el que las condiciones ocultan las respuestas que no vienen al caso.
Problema
El lobo que no sabe que hay una misión
Hasta ahora el héroe mataba enemigos y nada más. Ahora el juego quiere que pase algo más: matar al lobo jefe debe cerrar una misión, matar al Guardián debe dar la victoria, entrar al segundo nivel debe avanzar otra misión y la experiencia debe subir el nivel. Lo más simple es escribirlo donde muere el enemigo:
def on_enemy_killed(enemy): hero.xp += enemy.xp # El combate conoce cada parte del juego a la que le importa que muera un enemigo. quests.enemy_killed(enemy) achievements.enemy_killed(enemy) # Un oyente nuevo: una línea más aquí y un cambio más en el combate. stats.kills += 1Aquí el combate conoce las misiones, los logros y las estadísticas. Una misión nueva es una línea nueva en el combate, y el combate ya tiene muchas razones para cambiar. Peor aún: cada oyente nuevo se añade en código ajeno, y cada enlace olvidado es una misión que nunca termina.
La solución es invertir la dirección del conocimiento. El combate dice en un solo lugar «el enemigo murió» y no se preocupa de quién escucha. Quien quiere saberlo se apunta (se suscribe). Eso es un bus de eventos. La lección construye cinco cosas, en el orden en que las usa el juego: experiencia y niveles (una curva como tabla), un árbol de habilidades, un bus de eventos, una misión como máquina de estados y un diálogo como árbol con condiciones.
Así se ve el mismo lugar cuando hay un bus. El combate publica una línea y nada más:
def on_enemy_killed(enemy): hero.xp += enemy.xp bus.publish(GameEvent("EnemyKilled", enemy.kind))La idea
Experiencia y niveles: la curva es una tabla
¿Cuánta experiencia pide cada nivel? Una fórmula (por ejemplo 100 × nivel²) es suave, pero incómoda para afinar un juego: quieres decir «el nivel 3 llega tras unos seis enemigos», no calcular. Por eso la curva es una tabla: XP_TABLE[n] es la experiencia total con la que se alcanza el nivel n. Pasado el final de la tabla la curva sigue con 150 más por nivel, para que el juego no falle en el nivel 11.
El héroe tiene xp y level. En cada muerte xp crece, y pendingLevels dice cuántos niveles esperan: cuántos de los siguientes umbrales ya alcanzó la experiencia. El nivel nuevo es un paso aparte (raiseLevel): más vida. El programa mata a siete enemigos del juego, cada uno con su experiencia (rata 5, goblin 9, ... el Guardián 150), y escribe cada nivel nuevo:
class Hero: def __init__(self): self.level = 1 self.xp = 0 self.max_hp = 48
# XP_TABLE[n] es la experiencia total con la que se alcanza el nivel n.XP_TABLE = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720]
def xp_for_level(level): if level < len(XP_TABLE): return XP_TABLE[level] # Pasado el final de la tabla la curva sigue con 150 por nivel. return XP_TABLE[-1] + (level - len(XP_TABLE) + 1) * 150
# Cuántos niveles esperan: ¿llega la experiencia a los siguientes umbrales?def pending_levels(xp, level): levels = 0 while xp >= xp_for_level(level + levels + 1): levels += 1 return levels
def raise_level(hero): hero.level += 1 hero.max_hp += 9KILLS = [("rata", 5), ("goblin", 9), ("chamán", 12), ("lobo", 14), ("cultista", 20), ("gólem", 32), ("Guardián", 150)]
hero = Hero()for name, xp in KILLS: hero.xp += xp print(name + " +" + str(xp) + ": exp " + str(hero.xp)) raise_count = pending_levels(hero.xp, hero.level) for _ in range(raise_count): before = hero.max_hp raise_level(hero) print(" nivel " + str(hero.level) + ": vida " + str(before) + " -> " + str(hero.max_hp))
for level in range(10, 13): print("nivel " + str(level) + ": exp " + str(xp_for_level(level)))rata +5: exp 5goblin +9: exp 14chamán +12: exp 26 nivel 2: vida 48 -> 57lobo +14: exp 40cultista +20: exp 60 nivel 3: vida 57 -> 66gólem +32: exp 92Guardián +150: exp 242 nivel 4: vida 66 -> 75 nivel 5: vida 75 -> 84nivel 10: exp 720nivel 11: exp 870nivel 12: exp 1020El Guardián (+150) da dos niveles de golpe, el 4 y el 5. Por eso pendingLevels es un bucle y no una comparación: un golpe grande puede saltarse un umbral. Las últimas tres líneas muestran la cola de la tabla: el nivel 10 está en ella (720), mientras que el 11 y el 12 salen del + 150 por nivel (870, 1020).
# XP_TABLE[n] es la experiencia total con la que se alcanza el nivel n.
XP_TABLE = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720]
def xp_for_level(level):
if level < len(XP_TABLE):
return XP_TABLE[level]
# Pasado el final de la tabla la curva sigue con 150 por nivel.
return XP_TABLE[-1] + (level - len(XP_TABLE) + 1) * 150
# Cuántos niveles esperan: ¿llega la experiencia a los siguientes umbrales?
def pending_levels(xp, level):
levels = 0
while xp xp_for_level(level + levels + 1):
levels += 1
return levelsLa experiencia alcanza un umbral cuando es igual o mayor: >=. Con > un héroe con exactamente 20 exp no recibiría el nivel 2, mientras que el umbral de la tabla significa «con tanta experiencia se alcanza el nivel». La igualdad también cuenta en la tarea «Predice la salida» de más abajo.
Más precisamente, para curiosos
¿Por qué una tabla y no una fórmula? La tabla son datos: un ritmo más lento, un nivel nuevo u otro modo es otra tabla, no otro código. El canon la guarda como números separados por | (0|0|20|55|105|170|250|345|455|580|720) y la lee al arrancar junto con los objetos y los enemigos. Los dos primeros ceros son para los niveles 0 y 1: el héroe empieza en el nivel 1.
La curva se afina con la simulación de la última lección: se juegan miles de partidas y se mira qué nivel alcanza el héroe frente a qué enemigo.
La idea
El árbol de habilidades: dos caminos de tres pasos
En cada nivel nuevo el jugador elige: un atributo (+8 vida, +1 fuerza, +1 destreza, +1 defensa) o una habilidad nueva. Las habilidades de una clase forman un árbol con dos ramas de tres pasos. La regla es corta: una habilidad está disponible si aún no está aprendida y es el paso 1, o si en la misma rama está aprendido el paso inmediatamente inferior.
@dataclass(frozen=True)class Skill: id: str branch: int tier: int
SKILLS = [ Skill("rage", 1, 1), Skill("whirlwind", 1, 2), Skill("bloodlust", 1, 3), Skill("war_cry", 2, 1), Skill("iron_skin", 2, 2), Skill("second_wind", 2, 3),]ORDER = ["rage", "war_cry", "whirlwind", "iron_skin", "bloodlust", "second_wind"]
def learnable(skills, known): result = [] for skill in skills: if skill.id in known: continue ready = skill.tier == 1 for other in skills: if other.id in known and other.branch == skill.branch and other.tier == skill.tier - 1: ready = True if ready: result.append(skill.id) return result
def show(items): return "-" if len(items) == 0 else ", ".join(items)El programa aprende las habilidades del bárbaro en un orden dado y tras cada una escribe qué está disponible. Una lista vacía es -.
known = []print("inicio -> " + show(learnable(SKILLS, known)))for skill_id in ORDER: known.append(skill_id) print(skill_id + " -> " + show(learnable(SKILLS, known)))inicio -> rage, war_cryrage -> whirlwind, war_crywar_cry -> whirlwind, iron_skinwhirlwind -> bloodlust, iron_skiniron_skin -> bloodlust, second_windbloodlust -> second_windsecond_wind -> -Al principio solo están disponibles los dos comienzos: rage y war_cry. Aprender rage abre whirlwind, pero bloodlust aún espera (pide whirlwind), y la rama de war_cry no cambia. Al final, second_wind -> -: el árbol se agotó y en la elección de nivel nuevo solo quedan los atributos.
La idea
Un bus de eventos: quien publica no sabe quién escucha
El bus es lo más simple: un diccionario de tipo de evento a lista de funciones. subscribe(type, handler) pone una función en la lista de ese tipo. publish(event) llama a todas las funciones de la lista del tipo del evento. Nada más.
Un evento es un registro pequeño: un tipo y datos. Aquí son type, id (el enemigo, el objeto o el diálogo) y depth (el nivel). Ejemplos: EnemyKilled (un enemigo murió), TileEntered (el héroe entró a un nivel), DialogChoice (se eligió una respuesta) e ItemPicked (se recogió un objeto).
@dataclass(frozen=True)class GameEvent: type: str id: str = "" depth: int = 0
def label(self): return str(self.depth) if self.id == "" else self.id
class EventBus: def __init__(self): self.handlers = {}
def subscribe(self, type, handler): self.handlers.setdefault(type, []).append(handler)
def publish(self, event): if event.type not in self.handlers: return # Una copia de la lista: un oyente puede suscribirse o darse de baja mientras se publica. for handler in list(self.handlers[event.type]): handler(event)El primer programa pone dos oyentes en EnemyKilled (un registro y el contador de bajas) y uno en TileEntered. En ItemPicked no escucha nadie y el bus simplemente no hace nada.
bus = EventBus()stats = Stats()
def count_kill(event): stats.kills += 1 print("bajas: " + str(stats.kills))
bus.subscribe("EnemyKilled", log)bus.subscribe("TileEntered", log)bus.subscribe("EnemyKilled", count_kill)
fire(bus, GameEvent("EnemyKilled", "rat"))fire(bus, GameEvent("TileEntered", depth=2))fire(bus, GameEvent("EnemyKilled", "wolf"))fire(bus, GameEvent("ItemPicked", "ore"))> EnemyKilled ratregistro: EnemyKilled ratbajas: 1> TileEntered 2registro: TileEntered 2> EnemyKilled wolfregistro: EnemyKilled wolfbajas: 2> ItemPicked oreCada línea con > es una publicación, y las líneas de debajo son los oyentes que respondieron, en el orden en que se suscribieron. Tras EnemyKilled rat responden los dos, tras TileEntered 2 solo el registro, y ItemPicked ore no despierta a nadie. El código que publica no cambió ni una línea cuando aparecieron los oyentes.
Más precisamente, para curiosos
Este patrón se llama Observer (observador) o publish/subscribe. En el mundo de la interfaz es addEventListener, en los servidores son las colas de mensajes. El juego usa la forma más simple: todo en un hilo, los oyentes se llaman de inmediato, uno tras otro. Por eso un oyente que hace algo pesado frena todo el turno. Los oyentes se llaman en el orden en que se suscribieron, pero no hay que confiar en él (en el canon ningún oyente depende de otro).
publish recorre una copia de la lista. Una misión que inicia otra misión puede suscribirse al mismo tipo durante la publicación; sin la copia esto lanza una excepción en Java y C#, y en JavaScript el oyente nuevo se llamaría en la misma publicación.
La idea
Una misión es una máquina de estados
Una misión tiene un estado: inactive (aún no dada), active (con etapa 1, 2, 3), done (cumplida) y rewarded (la recompensa está recogida). Las transiciones no las hace el jugador, sino los eventos. Por eso la misión se suscribe sola a los que la mueven y guarda toda su lógica en un solo lugar.
| De | A | Cuándo |
|---|---|---|
inactive |
active, etapa 1 |
el anciano da la misión (la acción start del diálogo) |
active, etapa 1 |
etapa 2 | TileEntered con depth 2 |
active, etapa menor que 3 |
etapa 3 | DialogChoice miner:warden |
active |
done |
EnemyKilled warden |
done |
rewarded |
el diálogo ejecuta la acción reward |
La base Quest es la parte común: un título, una lista de metas por etapa y tres métodos que cambian el estado y escriben al jugador. MinersQuest («Los mineros desaparecidos») es una subclase que solo se suscribe:
class Quest: def __init__(self, id, title, goals, done_goal): self.id = id self.title = title self.goals = goals self.done_goal = done_goal self.state = "inactive" self.stage = 0
def goal(self): if self.state == "done": return self.done_goal return self.goals[self.stage - 1]
def start(self): self.state = "active" self.stage = 1 print("Nueva misión: " + self.title + ".")
def advance(self, new_stage): self.stage = new_stage print(self.title + ": " + self.goal())
def complete(self): self.state = "done" print("¡Misión cumplida: " + self.title + "!")
class MinersQuest(Quest): def __init__(self): super().__init__("miners", "Los mineros desaparecidos", ["Baja a la mina y busca a los mineros.", "Alguien está vivo en el segundo nivel. Encuéntralo.", "Derrota al Guardián abisal en el tercer nivel."], "El Guardián está derrotado. Vadogris está a salvo.")
def subscribe(self, bus): def on_tile(event): if self.state == "active" and self.stage == 1 and event.depth == 2: self.advance(2)
def on_dialog(event): if self.state == "active" and self.stage < 3 and event.id == "miner:warden": self.advance(3)
def on_kill(event): if self.state == "active" and event.id == "warden": self.complete()
bus.subscribe("TileEntered", on_tile) bus.subscribe("DialogChoice", on_dialog) bus.subscribe("EnemyKilled", on_kill)Cada oyente empieza con state == "active": el bus no conoce el estado de la misión, así que la pregunta es de la misión. La segunda condición (stage == 1, stage < 3, id == ...) dice qué evento exacto mueve exactamente esta transición. La base no tiene subscribe a propósito; en el canon existe como método vacío, para que el juego pueda recorrer todas las misiones y llamar a subscribe en cada una.
El escenario tiene ocho pasos, y la misión ignora cuatro. El anciano da la misión en el tercero, y los pasos son justo los que muestra la escena de abajo.
bus = EventBus()quest = MinersQuest()quest.subscribe(bus)
fire(bus, GameEvent("EnemyKilled", "rat"))fire(bus, GameEvent("TileEntered", depth=2))print("> el anciano da la misión")quest.start()fire(bus, GameEvent("TileEntered", depth=1))fire(bus, GameEvent("TileEntered", depth=2))fire(bus, GameEvent("DialogChoice", "miner:warden"))fire(bus, GameEvent("TileEntered", depth=2))fire(bus, GameEvent("EnemyKilled", "warden"))> EnemyKilled rat> TileEntered 2> el anciano da la misiónNueva misión: Los mineros desaparecidos.> TileEntered 1> TileEntered 2Los mineros desaparecidos: Alguien está vivo en el segundo nivel. Encuéntralo.> DialogChoice miner:wardenLos mineros desaparecidos: Derrota al Guardián abisal en el tercer nivel.> TileEntered 2> EnemyKilled warden¡Misión cumplida: Los mineros desaparecidos!Míralo
Los eventos viajan hacia la misión
Arriba está el camino de la misión con una bola que se posa sobre su estado. Cada evento es una ficha: sube al bus, va hasta la misión y la misión o avanza, o se sacude y dice «ignora». Se ve cuáles cuatro de los ocho eventos no mueven nada y por qué: la misión aún no empezó, el nivel es otro, y en uno la etapa ya pasó.
class EventBus: def __init__(self): self.handlers = {}
def subscribe(self, type, handler): self.handlers.setdefault(type, []).append(handler)
def publish(self, event): if event.type not in self.handlers: return # Una copia de la lista: un oyente puede suscribirse o darse de baja mientras se publica. for handler in list(self.handlers[event.type]): handler(event)
class Quest: def __init__(self, id, title, goals, done_goal): self.id = id self.title = title self.goals = goals self.done_goal = done_goal self.state = "inactive" self.stage = 0
def goal(self): if self.state == "done": return self.done_goal return self.goals[self.stage - 1]
def start(self): self.state = "active" self.stage = 1 print("Nueva misión: " + self.title + ".")
def advance(self, new_stage): self.stage = new_stage print(self.title + ": " + self.goal())
def complete(self): self.state = "done" print("¡Misión cumplida: " + self.title + "!")
class MinersQuest(Quest): def __init__(self): super().__init__("miners", "Los mineros desaparecidos", ["Baja a la mina y busca a los mineros.", "Alguien está vivo en el segundo nivel. Encuéntralo.", "Derrota al Guardián abisal en el tercer nivel."], "El Guardián está derrotado. Vadogris está a salvo.")
def subscribe(self, bus): def on_tile(event): if self.state == "active" and self.stage == 1 and event.depth == 2: self.advance(2)
def on_dialog(event): if self.state == "active" and self.stage < 3 and event.id == "miner:warden": self.advance(3)
def on_kill(event): if self.state == "active" and event.id == "warden": self.complete()
bus.subscribe("TileEntered", on_tile) bus.subscribe("DialogChoice", on_dialog) bus.subscribe("EnemyKilled", on_kill)Paso a paso, en palabras
- El juego publica eventos en el bus, y el bus llama a todos los suscritos a ese tipo. Arriba está la misión «Los mineros desaparecidos»: aún no ha empezado. La bola está en «inactiva».
- Muere una rata (EnemyKilled rat). La misión está suscrita a EnemyKilled, el bus la llama, pero el oyente pregunta: ¿está activa la misión y el enemigo es el Guardián? No: no pasa nada. El combate no sabe que alguien escucha.
- El héroe baja al segundo nivel (TileEntered 2). El nivel es el correcto, pero la misión sigue inactiva (state no es "active"), así que otra vez nada. Los eventos llegan cuando lo dice el juego, no cuando la misión está lista.
- El anciano da la misión: start() la deja activa, etapa 1. La bola pasa a «etapa 1». No es un evento del bus, sino una acción del diálogo.
- El héroe entra en el nivel 1 (TileEntered 1). La misión está activa y en la etapa 1, pero quiere el nivel 2: depth es 1, la condición no se cumple. Un evento nuevo del mismo tipo no es avance.
- Segundo nivel (TileEntered 2): activa, etapa 1 y depth 2. El oyente llama a advance(2): la bola pasa a «etapa 2» y el juego escribe «Los mineros desaparecidos: Alguien está vivo en el segundo nivel. Encuéntralo.».
- El héroe habla con el minero y elige «¿Qué hay allá abajo?»: el diálogo publica DialogChoice miner:warden. La misión está en la etapa 2 (menos de 3) y el id coincide: advance(3). La bola está en «etapa 3».
- El héroe vuelve al nivel 2 (TileEntered 2). depth == 2 es cierto, pero la etapa ya es 3, no 1: la comprobación stage == 1 detiene el evento. Sin ella la misión retrocedería a la etapa 2.
- El Guardián muere (EnemyKilled warden). La misión está activa y el id es "warden": complete(). La bola está en «cumplida». El combate solo dijo «el enemigo murió»; quién escucha no es asunto suyo.
El código junto a la escena es el bus, la base de la misión y la subclase MinersQuest del programa. En cada paso se iluminan las líneas que trabajan en ese momento.
La escena muestra solo la misión «Los mineros desaparecidos». La experiencia y el diálogo están en los programas de arriba y de abajo, no en la escena.
La idea
Un diálogo es un árbol con condiciones
Una conversación es un árbol: un nodo (node) tiene una frase y varias respuestas (option), y cada respuesta lleva a un nodo siguiente (o a end, que lo cierra). Si eso fuera todo, el diálogo sería igual para todos los jugadores. Pero Vela no debe ofrecer la misión de la plata si ya está aceptada, ni quitar mineral que el héroe no tiene. Por eso una respuesta tiene una condición y acciones:
- condición (
condition):flag:nombre,item:objeto:cantidad,quest:misión:estado, con un!delante para negar. Varias condiciones van separadas por comas y deben cumplirse todas (un «y» lógico).-significa «siempre». - acciones (
actions):flag,start,complete,reward,give,take. Se ejecutan en orden cuando se elige la respuesta.
Las condiciones y las acciones son texto corto de la forma tipo:argumento:argumento, porque los diálogos son datos: pueden vivir en un archivo, no en el código. visibleOptions muestra solo las respuestas cuya condición es verdadera, y choose ejecuta las acciones de la elegida.
@dataclass(frozen=True)class Option: text: str next: str condition: str actions: str
@dataclass(frozen=True)class Node: text: str options: list
class Game: NAMES = {"ore": "mineral", "silver_sword": "espada de plata", "potion_heal": "poción de curación"}
def __init__(self, *quests): self.flags = set() self.items = {} self.quests = {quest.id: quest for quest in quests}
def quest(self, id): return self.quests[id]
def count(self, id): return self.items.get(id, 0)
def label(self, id, n): return Game.NAMES[id] + " x" + str(n)
def give(self, id, n): self.items[id] = self.count(id) + n print("Recibes: " + self.label(id, n) + ".")
def take(self, id, n): self.items[id] = self.count(id) - n print("Entregas: " + self.label(id, n) + ".")def split_list(text): return [] if text == "-" else text.split(",")
def check_condition(game, condition): for part in split_list(condition): negate = part.startswith("!") p = (part[1:] if negate else part).split(":") if p[0] == "flag": ok = p[1] in game.flags elif p[0] == "item": ok = game.count(p[1]) >= int(p[2]) elif p[0] == "quest": ok = game.quest(p[1]).state == p[2] else: raise ValueError("unknown condition: " + part) if negate: ok = not ok if not ok: return False return True
def run_actions(game, actions): for part in split_list(actions): p = part.split(":") if p[0] == "flag": game.flags.add(p[1]) elif p[0] == "start": game.quest(p[1]).start() elif p[0] == "complete": game.quest(p[1]).complete() elif p[0] == "reward": quest = game.quest(p[1]) quest.state = "rewarded" print("Recompensa por " + quest.title + ".") elif p[0] == "give": game.give(p[1], int(p[2])) elif p[0] == "take": game.take(p[1], int(p[2])) else: raise ValueError("unknown action: " + part)
def visible_options(game, node): return [o for o in node.options if o.condition == "-" or check_condition(game, o.condition)]
def show(speaker, game, node): print(speaker + ": " + node.text) for i, option in enumerate(visible_options(game, node)): print(" " + str(i + 1) + ") " + option.text)
def choose(game, node, number): option = visible_options(game, node)[number - 1] print("> " + str(number)) run_actions(game, option.actions) return option.nextLos datos son los dos diálogos del juego: la herrera Vela (objetos y una misión) y el minero Dobri (un regalo único con una bandera).
SMITH = { "start": Node("Mi fragua está fría sin plata. Tráeme tres trozos de mineral y te forjaré una espada.", [ Option("Buscaré el mineral.", "deal", "quest:silver:inactive", "start:silver"), Option("Aquí está el mineral.", "done", "quest:silver:active,item:ore:3", "take:ore:3,complete:silver,reward:silver,give:silver_sword:1"), Option("Adiós.", "end", "-", "-"), ]), "deal": Node("El mineral está en el segundo nivel. Los gólems de mena lo llevan dentro.", [ Option("Bien.", "end", "-", "-"), ]), "done": Node("Una espada de plata. Su filo nunca se embota.", [ Option("Gracias.", "end", "-", "-"), ]),}
MINER = { "start": Node("¡Un alma viva! Los otros corrieron hacia arriba... Yo quedé atrapado aquí abajo.", [ Option("¿Qué hay allá abajo?", "warden", "quest:miners:active,!flag:miner_told", "flag:miner_told,give:potion_heal:1"), Option("Quédate aquí, volveré.", "end", "-", "-"), ]), "warden": Node("El Guardián abisal. Un guardián de piedra despertado por nuestros picos. Cuando se agrieta, llama a los muertos en su ayuda. Toma mi poción.", [ Option("Lo detendré.", "end", "-", "-"), ]),}game = Game(Quest("silver", "Plata para la herrera", ["Reúne tres trozos de mineral de plata.", "Lleva el mineral a la herrera Vela."], "La herrera tiene su plata."), Quest("miners", "Los mineros desaparecidos", ["Baja a la mina y busca a los mineros.", "Alguien está vivo en el segundo nivel. Encuéntralo.", "Derrota al Guardián abisal en el tercer nivel."], "El Guardián está derrotado. Vadogris está a salvo."))start = SMITH["start"]show("Vela", game, start)next_id = choose(game, start, 1)show("Vela", game, SMITH[next_id])game.give("ore", 3)show("Vela", game, start)next_id = choose(game, start, 1)show("Vela", game, SMITH[next_id])game.quest("miners").start()miner_start = MINER["start"]show("Dobri", game, miner_start)next_id = choose(game, miner_start, 1)show("Dobri", game, MINER[next_id])show("Dobri", game, miner_start)Vela: Mi fragua está fría sin plata. Tráeme tres trozos de mineral y te forjaré una espada. 1) Buscaré el mineral. 2) Adiós.> 1Nueva misión: Plata para la herrera.Vela: El mineral está en el segundo nivel. Los gólems de mena lo llevan dentro. 1) Bien.Recibes: mineral x3.Vela: Mi fragua está fría sin plata. Tráeme tres trozos de mineral y te forjaré una espada. 1) Aquí está el mineral. 2) Adiós.> 1Entregas: mineral x3.¡Misión cumplida: Plata para la herrera!Recompensa por Plata para la herrera.Recibes: espada de plata x1.Vela: Una espada de plata. Su filo nunca se embota. 1) Gracias.Nueva misión: Los mineros desaparecidos.Dobri: ¡Un alma viva! Los otros corrieron hacia arriba... Yo quedé atrapado aquí abajo. 1) ¿Qué hay allá abajo? 2) Quédate aquí, volveré.> 1Recibes: poción de curación x1.Dobri: El Guardián abisal. Un guardián de piedra despertado por nuestros picos. Cuando se agrieta, llama a los muertos en su ayuda. Toma mi poción. 1) Lo detendré.Dobri: ¡Un alma viva! Los otros corrieron hacia arriba... Yo quedé atrapado aquí abajo. 1) Quédate aquí, volveré.Fíjate en cómo cambian las respuestas de Vela para el mismo nodo start: en la primera visita son dos (deal y «Adiós»), porque done pide la misión y el mineral. Cuando el héroe trae tres de mineral, ya no ofrece deal (la misión está activa), sino done. Las acciones de done quitan el mineral, cierran la misión, dan la recompensa y la espada de plata. Con Dobri la respuesta «¿Qué hay allá abajo?» desaparece en cuanto se pone la bandera miner_told: el regalo es único.
La idea
En el juego: experiencia, habilidades y el bus
Aquí están los fragmentos reales del canon. Primero, los niveles y el árbol (xp-levelup). Además de nuestros xpForLevel y pendingLevels hay learnableSkills (solo las habilidades de la clase del héroe, según la lista del héroe), levelUpChoices (cuatro atributos y las habilidades disponibles) y applyLevelChoice:
# La curva de niveles es una tabla: XP_TABLE[n] es la experiencia con la que se llega al nivel n.def xp_for_level(level): if level < len(XP_TABLE): return XP_TABLE[level] return XP_TABLE[-1] + (level - len(XP_TABLE) + 1) * 150
def pending_levels(hero): levels = 0 while hero.xp >= xp_for_level(hero.level + levels + 1): levels += 1 return levels
# El nivel nuevo en sí: más vida y maná, salud completa.def raise_level(hero): hero_class = hero.hero_class() hero.level += 1 hero.base['maxHp'] = hero.base['maxHp'] + hero_class.hp_per_level hero.base['maxMana'] = hero.base['maxMana'] + hero_class.mana_per_level hero.hp = hero.max_hp() hero.mana = hero.max_mana()
# El árbol de habilidades: dos ramas de tres pasos. Un paso se puede aprender si el anterior ya está aprendido.def learnable_skills(hero): result = [] for skill in SKILLS.values(): if skill.class_id != hero.class_id or skill.id in hero.skills: continue ready = skill.tier == 1 for known in hero.skills: other = SKILLS[known] if other.branch == skill.branch and other.tier == skill.tier - 1: ready = True if ready: result.append(skill.id) return result
# Las opciones en un nivel nuevo: un atributo o una habilidad nueva.def level_up_choices(hero): choices = [ SimpleNamespace(kind='stat', stat='maxHp', amount=8), SimpleNamespace(kind='stat', stat='strength', amount=1), SimpleNamespace(kind='stat', stat='agility', amount=1), SimpleNamespace(kind='stat', stat='defense', amount=1), ] for skill_id in learnable_skills(hero): choices.append(SimpleNamespace(kind='skill', stat=skill_id, amount=0)) return choices
def apply_level_choice(hero, choice): if choice.kind == 'skill': hero.skills.append(choice.stat) else: hero.base[choice.stat] = hero.base[choice.stat] + choice.amount if choice.stat == 'maxHp': hero.hp = hero.max_hp()Las diferencias con nuestro programa son pocas:
raiseLevelen el juego suma también maná y devuelve la salud completa; nosotros solo tocamos la vida máxima.- El nivel no se da en
gainXp.enemyKilled(además del oro y el cadáver) subehero.xpy publica un evento, yqueueLevelUpspone unaLevelUpScreenen la pila (la lección sobre las pantallas): el jugador elige un atributo o una habilidad y solo entoncesraiseLevelyapplyLevelChoicecambian al héroe. Hay una pantalla por nivel, no un diálogo para todos. - Los atributos cambian en
base(la lección sobre las entidades), no en el héroe mismo.
Y el bus es el mismo que el nuestro:
# El bus de eventos: el que publica no sabe quién escucha. Las misiones se suscriben y siguen su propio# progreso, y el sistema de combate solo dice «el enemigo ha muerto».class EventBus: def __init__(self): self.handlers = {}
def subscribe(self, event_type, handler): if event_type not in self.handlers: self.handlers[event_type] = [] self.handlers[event_type].append(handler)
def publish(self, event): if event.type not in self.handlers: return for handler in list(self.handlers[event.type]): handler(event)Los eventos del juego son objetos libres ({ type: 'EnemyKilled', kindId: … }), con campos distintos para cada tipo: kindId, itemId, dialogId con nodeId, depth, decor, marker. En Java, C# y Python cada forma es un registro aparte. Nosotros lo atamos todo a type, id y depth, para que un solo tipo sirva en los cuatro lenguajes.
La idea
En el juego: misiones y diálogos
Aquí están las misiones reales. La base Quest usa claves de los textos (t('quest.' + id + '.title')) para que el juego se pueda traducir; MissingMinersQuest es la nuestra, y las otras dos muestran qué más puede hacer una máquina de estados:
# Una misión es una pequeña máquina de estados: inactive → active (etapas) → done → rewarded. Se suscribe# sola a los eventos que la hacen avanzar.class Quest(ABC): def __init__(self, quest_id): self.id = quest_id self.state = 'inactive' self.stage = 0
def title(self): return t('quest.' + self.id + '.title')
def goal(self): if self.state == 'done': return t('quest.' + self.id + '.done') if self.state == 'rewarded': return t('quest.rewarded') return t('quest.' + self.id + '.stage' + str(self.stage))
def start(self, game): self.state = 'active' self.stage = 1 game.say(t('quest.started', {'title': self.title()}))
def advance(self, game, stage): self.stage = stage game.say(t('quest.updated', {'title': self.title(), 'goal': self.goal()}))
def complete(self, game): self.state = 'done' game.say(t('quest.completed', {'title': self.title()}))
@abstractmethod def subscribe(self, bus, game): pass
class MissingMinersQuest(Quest): def __init__(self): super().__init__('miners')
def subscribe(self, bus, game): def on_tile_entered(event): if self.state == 'active' and self.stage == 1 and event.depth == 2: self.advance(game, 2)
def on_dialog_choice(event): if self.state == 'active' and self.stage < 3 and event.dialog_id == 'miner' and event.node_id == 'warden': self.advance(game, 3)
def on_enemy_killed(event): if self.state == 'active' and event.kind_id == 'warden': self.complete(game)
bus.subscribe('TileEntered', on_tile_entered) bus.subscribe('DialogChoice', on_dialog_choice) bus.subscribe('EnemyKilled', on_enemy_killed)
class BloodTrailQuest(Quest): def __init__(self): super().__init__('blood')
def start(self, game): super().start(game) if game.has_flag('leader_dead'): self.complete(game)
def subscribe(self, bus, game): def on_tile_entered(event): if self.state != 'active' or event.depth != 1: return if self.stage == 1 and event.decor == ',': self.advance(game, 2) if self.stage < 3 and event.marker == 'lair': self.advance(game, 3)
def on_enemy_killed(event): if event.kind_id != 'wolf_leader': return game.set_flag('leader_dead') if self.state == 'active': self.complete(game)
bus.subscribe('TileEntered', on_tile_entered) bus.subscribe('EnemyKilled', on_enemy_killed)
class SilverQuest(Quest): def __init__(self): super().__init__('silver')
def start(self, game): super().start(game) self.check(game)
def check(self, game): if self.state == 'active' and self.stage == 1 and game.hero.inventory.count('ore') >= 3: self.advance(game, 2)
def subscribe(self, bus, game): def on_item_picked(event): if event.item_id == 'ore': self.check(game)
bus.subscribe('ItemPicked', on_item_picked)
def create_quests(): return [MissingMinersQuest(), BloodTrailQuest(), SilverQuest()]- Una misión que empieza después de su condición.
BloodTrailQuest.startySilverQuest.startcomprueban de inmediato si la condición ya es cierta: el lobo jefe puede estar ya muerto, o el mineral ya en la mochila, antes de aceptar la misión. Así la misión no se queda para siempre en la etapa 1. NuestraMinersQuestno lo hace, porque el Guardián no muere antes de hablar con el anciano. - Etapas con más de una fuente.
BloodTrailQuestescuchaTileEnteredde dos maneras: para un rastro de sangre (decor) y para la guarida (marker). No hace falta que los eventos estén coordinados: el oyente pregunta por su estado y responde. - Llegar a la victoria. El Guardián no es solo
EnemyKilled: el juego ponevictoryenenemyKilled, y la misión avanza con el mismo evento. La misión «Los mineros desaparecidos» no tiene recompensa: la victoria muestra la pantalla de victoria.
El diálogo del canon es lo que construimos, y algo más: las acciones incluyen también gold y xp (la recompensa del cazador), y las condiciones incluyen gold: (oro suficiente):
# Un diálogo es un árbol con condiciones: las opciones cuyas condiciones no se cumplen no se muestran.def check_condition(game, condition): for part in split_list(condition): negate = part[:1] == '!' pieces = (part[1:] if negate else part).split(':') ok = False if pieces[0] == 'flag': ok = game.has_flag(pieces[1]) elif pieces[0] == 'item': ok = game.hero.inventory.count(pieces[1]) >= to_int(pieces[2]) elif pieces[0] == 'gold': ok = game.hero.gold >= to_int(pieces[1]) elif pieces[0] == 'quest': ok = game.quest(pieces[1]).state == pieces[2] else: raise RuntimeError('unknown condition: ' + part) if negate: ok = not ok if not ok: return False return True
def run_actions(game, actions): for part in split_list(actions): pieces = part.split(':') if pieces[0] == 'flag': game.set_flag(pieces[1]) elif pieces[0] == 'start': game.quest(pieces[1]).start(game) elif pieces[0] == 'complete': game.quest(pieces[1]).complete(game) elif pieces[0] == 'reward': game.quest(pieces[1]).state = 'rewarded' game.say(t('quest.rewardedSay', {'title': game.quest(pieces[1]).title()})) elif pieces[0] == 'give': game.give_item(pieces[1], to_int(pieces[2])) elif pieces[0] == 'take': game.hero.inventory.remove(pieces[1], to_int(pieces[2])) game.say(t('item.given', {'item': item_label(pieces[1], to_int(pieces[2]))})) elif pieces[0] == 'gold': game.hero.gold += to_int(pieces[1]) game.say(t('item.gold', {'amount': pieces[1]})) elif pieces[0] == 'xp': game.gain_xp(to_int(pieces[1])) else: raise RuntimeError('unknown action: ' + part)
def visible_options(game, node): result = [] for option in node.options: if option.condition == '-' or check_condition(game, option.condition): result.append(option) return resultDialogScreen (la pantalla de la pila) llama a visibleOptions, y cuando el jugador elige una respuesta ejecuta runActions y publica DialogChoice con el nodo siguiente. Así el diálogo y la misión no se conocen: la charla con el minero mueve la misión solo porque el diálogo publicó y la misión escuchó.
Pruébalo
Predice la salida
Tres cálculos de pendingLevels con la tabla de la lección. ¿Qué imprime el programa?
# XP_TABLE[n] es la experiencia total con la que se alcanza el nivel n.XP_TABLE = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720]
def xp_for_level(level): if level < len(XP_TABLE): return XP_TABLE[level] # Pasado el final de la tabla la curva sigue con 150 por nivel. return XP_TABLE[-1] + (level - len(XP_TABLE) + 1) * 150
# Cuántos niveles esperan: ¿llega la experiencia a los siguientes umbrales?def pending_levels(xp, level): levels = 0 while xp >= xp_for_level(level + levels + 1): levels += 1 return levels
print(pending_levels(130, 1))print(pending_levels(104, 3))print(pending_levels(55, 1))Con 130 exp y nivel 1 se alcanzan los umbrales 20, 55 y 105 (130 ≥ 105) y no el 170: tres niveles. Con 104 exp y nivel 3 el siguiente umbral es 105 y 104 no lo alcanza: cero. Con 55 exp y nivel 1 los umbrales son 20 y 55 (55 ≥ 55, la igualdad cuenta) y el 105 no: dos. La segunda respuesta usa > en lugar de >=; la tercera cuenta desde xpForLevel(level), es decir, cuenta también el nivel en el que el héroe ya está.
Pruébalo
Encuentra el error
Un colega quitó una comprobación en MinersQuest y ahora la misión retrocede. El héroe ya habló con el minero (etapa 3) y vuelve a bajar al segundo nivel:
bus = EventBus()quest = MinersQuest()quest.subscribe(bus)
fire(bus, GameEvent("EnemyKilled", "rat"))fire(bus, GameEvent("TileEntered", depth=2))print("> el anciano da la misión")quest.start()fire(bus, GameEvent("TileEntered", depth=1))fire(bus, GameEvent("TileEntered", depth=2))fire(bus, GameEvent("DialogChoice", "miner:warden"))fire(bus, GameEvent("TileEntered", depth=2))fire(bus, GameEvent("EnemyKilled", "warden"))> EnemyKilled rat> TileEntered 2> el anciano da la misiónNueva misión: Los mineros desaparecidos.> TileEntered 1> TileEntered 2Los mineros desaparecidos: Alguien está vivo en el segundo nivel. Encuéntralo.> DialogChoice miner:wardenLos mineros desaparecidos: Derrota al Guardián abisal en el tercer nivel.> TileEntered 2Los mineros desaparecidos: Alguien está vivo en el segundo nivel. Encuéntralo.> EnemyKilled warden¡Misión cumplida: Los mineros desaparecidos!Al final de la salida, tras el tercer TileEntered 2 (el héroe ya habló con el minero), la meta vuelve a ser «Alguien está vivo en el segundo nivel. Encuéntralo.», aunque el héroe ya sabe del Guardián. ¿Qué línea lo causa?
Elige la línea con el error.
La condición de TileEntered no pregunta en qué etapa está la misión: state == "active" y depth == 2 son ciertos también en la etapa 3, por eso bajar vuelve a llamar a advance(2). Correcto: stage == 1 && depth == 2. El bus no guarda el orden de los eventos, así que cada oyente comprueba tanto el estado como la etapa. El error es silencioso: nada lanza una excepción, solo la meta del registro de misiones es incorrecta.
Más precisamente
Con más precisión: orden de los oyentes, excepciones y determinismo
Síncrono. publish vuelve cuando terminaron todos los oyentes. Todo va en un hilo, así que un oyente que publica un evento nuevo lo dispara dentro del primero (publicación anidada). Si la misión A escucha EnemyKilled y publica QuestDone, y la misión B escucha QuestDone, B se llama antes de que termine el primer publish. Limita la profundidad: un bucle infinito de eventos es el error más difícil de los buses.
Orden de los oyentes. El orden de suscripción es previsible a propósito (una lista, no un conjunto): el juego debe dar el mismo resultado con la misma semilla en los cuatro lenguajes. Pero si los oyentes dependen del orden, el diseño está mal: dos oyentes que se necesitan deberían ser uno.
Una excepción en un oyente. Aquí interrumpe publish y los oyentes siguientes no se llaman. En un juego de un jugador es aceptable (mejor ver el error); en un servidor se envuelve en try.
Darse de baja. Nuestro bus no tiene unsubscribe. Una misión que está done sigue suscrita y pregunta state == "active" cada vez; es barato. Donde los oyentes son muchos o viven poco (elementos de una pantalla), sin darse de baja quedan oyentes «colgados» y memoria que no se devuelve.
La máquina crece. Cinco estados con dos transiciones cada uno son diez líneas de if. Cuando la misión se ramifica (elegir bando, fallar pasado un tiempo, comprobar tres metas en cualquier orden), el estado ya no es un solo valor. Entonces cada meta es su propia máquina pequeña, y la misión está «done» cuando lo están todas. El canon se detiene a propósito antes de eso.
El diálogo como datos. Nuestro formato es una cadena tipo:arg:arg. Una ventaja: el diálogo puede estar en un archivo y cambiarse sin código nuevo. Un coste: un error en el nombre (quets:silver:active) se descubre solo al ejecutar (unknown condition), no al compilar. Por eso checkCondition y runActions lanzan una excepción ante un tipo desconocido en lugar de saltarlo en silencio.
Ejercicio
Tres lobos
Resuelve la tarea en tu editor. Parte del programa de la misión.
La misión «Tres lobos». Escribe WolfQuest (una subclase de Quest) con una meta, «Mata a tres lobos»: la misión cuenta los lobos muertos y pasa a done con el tercero. Escucha solo EnemyKilled; cuenta solo lobos (id es "wolf") y solo mientras está active. Con cada lobo muerto que no sea el último, escribe la meta con el contador, por ejemplo «Mata a tres lobos (1/3)» (sobrescribe goal()), llamando a advance(1). Con el tercero llama a complete(). Los eventos antes de start() y después de done no cuentan.
La base Quest, EventBus y fire son las mismas que en el programa de la misión. Este es el escenario:
bus = EventBus()quest = WolfQuest()quest.subscribe(bus)
fire(bus, GameEvent("EnemyKilled", "wolf"))print("> start")quest.start()fire(bus, GameEvent("EnemyKilled", "rat"))fire(bus, GameEvent("EnemyKilled", "wolf"))fire(bus, GameEvent("EnemyKilled", "wolf"))fire(bus, GameEvent("EnemyKilled", "wolf"))fire(bus, GameEvent("EnemyKilled", "wolf"))La salida es:
> EnemyKilled wolf> startNueva misión: Tres lobos.> EnemyKilled rat> EnemyKilled wolfTres lobos: Mata a tres lobos (1/3)> EnemyKilled wolfTres lobos: Mata a tres lobos (2/3)> EnemyKilled wolf¡Misión cumplida: Tres lobos!> EnemyKilled wolfMás tareas
- Recompensa. Añade a
Questun métodoreward()que pase dedonearewardedy escriba la recompensa. Luego haz en el diálogo del cazador una respuesta con la condiciónquest:wolves:doney la acciónreward:wolves. - Dos misiones a la vez. Suscribe
MinersQuestyWolfQuestal mismo bus y escribe un escenario en el que unEnemyKilledmueva a las dos. ¿Por qué basta un solopublish? - Darse de baja. Añade
unsubscribe(type, handler)al bus. ¿Qué tienes que hacer enpublishpara que un oyente pueda darse de baja durante la publicación? - El árbol de habilidades. Añade una tercera clase con dos ramas en las que el segundo paso pida dos pasos aprendidos (uno de cada rama). ¿Qué cambia en
learnable?
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Listas: append, remove y sort · dict y set · Lambdas · dataclass, match y patrones
- Ejercicios sobre el tema: Las crónicas de Arcanum