Ir al contenido
wannacode

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 += 1

Aquí 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 += 9
KILLS = [("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)))
Salida
rata +5: exp 5
goblin +9: exp 14
chamán +12: exp 26
nivel 2: vida 48 -> 57
lobo +14: exp 40
cultista +20: exp 60
nivel 3: vida 57 -> 66
gólem +32: exp 92
Guardián +150: exp 242
nivel 4: vida 66 -> 75
nivel 5: vida 75 -> 84
nivel 10: exp 720
nivel 11: exp 870
nivel 12: exp 1020

El 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 levels
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)))
Salida
inicio -> rage, war_cry
rage -> whirlwind, war_cry
war_cry -> whirlwind, iron_skin
whirlwind -> bloodlust, iron_skin
iron_skin -> bloodlust, second_wind
bloodlust -> second_wind
second_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"))
Salida
> EnemyKilled rat
registro: EnemyKilled rat
bajas: 1
> TileEntered 2
registro: TileEntered 2
> EnemyKilled wolf
registro: EnemyKilled wolf
bajas: 2
> ItemPicked ore

Cada 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"))
Salida
> EnemyKilled rat
> TileEntered 2
> el anciano da la misión
Nueva misión: Los mineros desaparecidos.
> TileEntered 1
> TileEntered 2
Los mineros desaparecidos: Alguien está vivo en el segundo nivel. Encuéntralo.
> DialogChoice miner:warden
Los 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
  1. 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».
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.».
  7. 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».
  8. 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.
  9. 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.next

Los 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)
Salida
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.
> 1
Nueva 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.
> 1
Entregas: 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é.
> 1
Recibes: 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:

  • raiseLevel en 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) sube hero.xp y publica un evento, y queueLevelUps pone una LevelUpScreen en la pila (la lección sobre las pantallas): el jugador elige un atributo o una habilidad y solo entonces raiseLevel y applyLevelChoice cambian 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.start y SilverQuest.start comprueban 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. Nuestra MinersQuest no lo hace, porque el Guardián no muere antes de hablar con el anciano.
  • Etapas con más de una fuente. BloodTrailQuest escucha TileEntered de 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 pone victory en enemyKilled, 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 result

DialogScreen (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))
Respuesta

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"))
Salida
> EnemyKilled rat
> TileEntered 2
> el anciano da la misión
Nueva misión: Los mineros desaparecidos.
> TileEntered 1
> TileEntered 2
Los mineros desaparecidos: Alguien está vivo en el segundo nivel. Encuéntralo.
> DialogChoice miner:warden
Los mineros desaparecidos: Derrota al Guardián abisal en el tercer nivel.
> TileEntered 2
Los 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.

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:

Salida
> EnemyKilled wolf
> start
Nueva misión: Tres lobos.
> EnemyKilled rat
> EnemyKilled wolf
Tres lobos: Mata a tres lobos (1/3)
> EnemyKilled wolf
Tres lobos: Mata a tres lobos (2/3)
> EnemyKilled wolf
¡Misión cumplida: Tres lobos!
> EnemyKilled wolf

Más tareas

  • Recompensa. Añade a Quest un método reward() que pase de done a rewarded y escriba la recompensa. Luego haz en el diálogo del cazador una respuesta con la condición quest:wolves:done y la acción reward:wolves.
  • Dos misiones a la vez. Suscribe MinersQuest y WolfQuest al mismo bus y escribe un escenario en el que un EnemyKilled mueva a las dos. ¿Por qué basta un solo publish?
  • Darse de baja. Añade unsubscribe(type, handler) al bus. ¿Qué tienes que hacer en publish para 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

1. El héroe está en el nivel 3 con 92 exp. Mata al Guardián (+150) y la exp pasa a 242. El umbral del nivel 4 es 105, el del nivel 5 es 170 y el del nivel 6 es 250. ¿Cuántos niveles esperan y cómo los recibe el jugador?
2. Un bárbaro ha aprendido «Ira» (rage, rama 1, paso 1) y «Grito bélico» (war_cry, rama 2, paso 1). ¿Qué habilidades puede ofrecer el siguiente nivel?
3. ¿Por qué publish en el bus recorre una copia de la lista de oyentes (slice, list(...), new ArrayList<>(...)) y no la lista misma?
4. La misión está en la etapa 3. Llega un TileEntered con depth 2 (el héroe vuelve a bajar al segundo nivel). ¿Qué hace el oyente que sube la etapa a 2 y por qué?
5. Una respuesta del diálogo tiene la condición quest:silver:active,item:ore:3. El héroe aceptó la misión, pero solo tiene 2 de mineral. ¿Qué ve el jugador?

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