Ir al contenido
wannacode

Habilidades y acciones como comandos

En resumen: Un comando es un objeto que sabe cómo ejecutarse y cómo registrarse como datos. El combate ejecuta los comandos por un solo método; quién los elige (un jugador, una inteligencia artificial o un registro) es otra pregunta. Por eso los comandos grabados y la misma semilla repiten un combate, y sin pantalla el combate corre miles de veces.

Problema

Cuatro eligen, uno ejecuta

En el combate del juego hay cuatro «elegidores». El jugador pulsa 1, 2, 3 o 4. El goblin simplemente golpea. El héroe en piloto automático, que mueve la simulación, calcula qué le conviene. Y la repetición del combate tiene que hacer lo mismo que pasó, sin que nadie elija nada. Hay cuatro tipos de elecciones, pero el combate que las ejecuta es uno.

Si la elección es una cadena if (elección == 1) … else if (elección == 2) … y está en cuatro sitios, cada acción nueva son cuatro cambios y cuatro ocasiones de olvidar uno. Si añades «beber una poción», la repetición no la conocerá en silencio y mostrará otro combate.

Aquí tienes un combate del juego. Tras 2 se abre una lista de habilidades, y las que aún se están recargando van marcadas (Ira: en 4 turnos):

=== Combate ===
Iva, bárbaro nv.6
V [##########--] 86/103 M [########] 9/9
ir+- Habilidades ------------------------------------+
1) G| 1) Ira (0 de maná) (en 4) |
¡Co| 2) Torbellino (3 de maná) |
Iva| 3) Grito bélico (2 de maná) |
¡Iv| 4) Piel férrea (3 de maná) |
Gua| |
Gua| número: elegir, x: volver |
1 go+--------------------------------------------------+

La idea es convertir la acción en un objeto: un comando. Cada comando sabe cómo ejecutarse y cómo registrarse como datos. El combate no pregunta qué es, solo lo ejecuta. Quién lo eligió (un jugador, un enemigo, una inteligencia artificial o un registro) es otra pregunta, de la que el combate no se ocupa. La lección lo construye en una arena pequeña y luego muestra el mismo sitio en el juego: los comandos, las habilidades desde una tabla, el maná y las recargas, los objetivos, la repetición del combate y la simulación sin pantalla.

La idea

Un comando es un objeto

La arena de la lección es pequeña: un guerrero contra un goblin y una rata. Las cuentas son con enteros, y los «dados» son un generador con semilla (como en la lección de introducción), así que el combate es el mismo en todos los lenguajes. Hay cuatro tipos de comandos, y desde el punto de vista del combate todos hacen lo mismo:

from dataclasses import dataclass
# El registro de una acción: quién (un número), qué, contra quién (un número o -1) y qué habilidad u objeto.
@dataclass(frozen=True)
class ActionRecord:
actor: int
action: str
target: int
id: str
class AttackAction:
def __init__(self, actor, target):
self.actor = actor
self.target = target
def to_record(self, battle):
return ActionRecord(battle.index_of(self.actor), "attack", battle.index_of(self.target), "")
def execute(self, battle):
battle.hit(self.actor, self.target, self.actor.power + battle.rng.roll(3))
class ItemAction:
def __init__(self, actor, item_id):
self.actor = actor
self.item_id = item_id
def to_record(self, battle):
return ActionRecord(battle.index_of(self.actor), "item", -1, self.item_id)
def execute(self, battle):
self.actor.potions -= 1
healed = min(10, self.actor.max_hp - self.actor.hp)
self.actor.hp += healed
battle.say(self.actor.name + ": poción, +" + str(healed) + " de vida")
class FleeAction:
def __init__(self, actor):
self.actor = actor
def to_record(self, battle):
return ActionRecord(battle.index_of(self.actor), "flee", -1, "")
def execute(self, battle):
if battle.rng.roll(100) < 50:
battle.say(self.actor.name + " huye.")
battle.over = True
battle.outcome = "fled"
else:
battle.say(self.actor.name + " no logra huir.")

Cada comando guarda quién actúa (actor) y contra quién (target) y tiene dos métodos:

  • execute(battle): lo ejecuta. El comando no calcula por sí mismo la vida de los demás: llama a battle.hit, que baja la vida y escribe la línea en el diario;
  • toRecord(battle): lo describe como datos: el número del luchador, una palabra para el tipo, el número del objetivo y la habilidad o el objeto. En el registro no hay vida, ni daño, ni azar.

Por eso el combate puede tener una lista de comandos de distintos tipos y llamar a todos al mismo método: es el polimorfismo de la herencia, solo que aquí los comandos son objetos con una interfaz común. El programa mete en una lista un golpe del héroe, un golpe del goblin, una habilidad y una poción y se los da a perform uno a uno. Las líneas del combate salen enseguida (live), y al final se imprime el registro.

battle = new_battle(7)
battle.live = True
hero = battle.hero()
goblin = battle.fighters[1]
queue = [
AttackAction(hero, goblin),
AttackAction(goblin, hero),
SkillAction(hero, skill_of(battle, "smash"), goblin),
ItemAction(hero, "potion"),
]
for action in queue:
battle.perform(action)
print("Registro:")
for r in battle.record:
print(describe(r))
Salida
Guerrero → Goblin: 8 de daño
Goblin → Guerrero: 8 de daño
Guerrero: Golpe fuerte
Guerrero → Goblin: 12 de daño
Goblin cae.
Guerrero: poción, +8 de vida
Registro:
0 attack 1
1 attack 0
0 skill 1 smash
0 item -1 potion

El registro es lo que queda del combate: cuatro líneas de pocas palabras. Hace falta para la repetición, que viene más adelante.

En el juego las clases tienen la misma forma. Así se ven el golpe y la huida:

# Las acciones son comandos: un objeto con execute(battle). El jugador y la IA eligen un
# comando, y el combate lo ejecuta. Así la simulación corre el mismo combate sin pantalla.
class AttackAction:
def __init__(self, actor, target):
self.actor = actor
self.target = target
def to_record(self, battle):
return SimpleNamespace(actor=battle.index_of(self.actor), action='attack', target=battle.index_of(self.target), id='')
def execute(self, battle):
strike(battle, self.actor, self.target, self.actor.attack_profile(), 100)
class FleeAction:
def __init__(self, actor):
self.actor = actor
def to_record(self, battle):
return SimpleNamespace(actor=battle.index_of(self.actor), action='flee', target=-1, id='')
def execute(self, battle):
if not battle.can_flee:
battle.say(t('combat.noFlee'))
return
fastest = 0
for enemy in battle.alive_enemies():
fastest = max(fastest, enemy.stat('agility'))
chance = clamp(50 + (self.actor.stat('agility') - fastest) * 5, 15, 90)
if battle.game.rng.chance(chance):
battle.say(t('combat.fled', {'name': self.actor.name}))
battle.over = True
battle.outcome = 'fled'
else:
battle.say(t('combat.fleeFail', {'name': self.actor.name}))

La huida tiene una probabilidad (según la destreza del enemigo más rápido), pero no escribe daño. Los enemigos también usan comandos: habilidades como la baba del chamán o su curación son la clase SpecialAction, una por tipo de habilidad.

Más precisamente, para curiosos

Este es el patrón Command (del libro de la Gang of Four): una petición se convierte en objeto. De él salen cosas que con if son difíciles: registrar (guardar los comandos), repetir (reproducirlos otra vez), deshacer (cada comando conoce también su acción contraria y los comandos se retroceden por una pila) y cola (un comando espera su turno). Esta lección usa los dos primeros. Deshacer en un combate con dados, sangrado y muerte es difícil, porque lo contrario de «daño 7» no es simplemente «vida + 7»; por eso el juego guarda un registro y no una pila para deshacer. En un editor de texto o un programa de dibujo, el patrón sirve justo para deshacer.

La idea

El combate ejecuta

Un combate es una clase con luchadores, un diario y un registro. Lo importante en ella es un sitio por el que pasa cada comando:

class Battle:
def __init__(self, seed, fighters, skills):
self.rng = Rng(seed)
self.fighters = fighters
self.skills = skills
self.log = []
self.record = []
self.over = False
self.live = False
self.outcome = "none"
self.rounds = 0
def hero(self):
return self.fighters[0]
def say(self, text):
self.log.append(text)
if self.live:
print(text)
def index_of(self, fighter):
return self.fighters.index(fighter)
def alive_enemies(self):
return [f for f in self.fighters[1:] if f.alive()]
def hit(self, actor, target, amount):
target.hp = max(0, target.hp - amount)
self.say(actor.name + " → " + target.name + ": " + str(amount) + " de daño")
if not target.alive():
self.say(target.name + " cae.")
# El único lugar por el que pasa cada comando: primero se registra y luego se ejecuta.
def perform(self, action):
self.record.append(action.to_record(self))
action.execute(self)
self.check_end()
def check_end(self):
if self.over:
return
if not self.hero().alive():
self.over = True
self.outcome = "lose"
self.say("Derrota.")
elif not self.alive_enemies():
self.over = True
self.outcome = "win"
self.say("¡Victoria!")

perform hace tres cosas en este orden: registra el comando, lo ejecuta y comprueba si el combate ha terminado. No hay atajo: ni el héroe, ni el goblin, ni la repetición ejecutan nada por otro camino. Por eso el registro está completo: si algo cambió el combate, pasó por aquí.

hit es el segundo sitio que conviene que sea único: solo él baja la vida y escribe la línea «quién → a quién: daño». Así el diario es lo bastante exacto para compararlo línea por línea. En el juego, tras cada golpe se aplican también las consecuencias de la lección anterior: sangrado, aturdimiento, quemadura, veneno. Están en un solo sitio, por eso son iguales para un jugador, un enemigo, una habilidad y una repetición, en lugar de una copia en cada comando.

En el juego perform es lo mismo, solo que tras ejecutar trata las muertes (botín, experiencia, eventos para las misiones):

def perform(self, action):
self.record.append(action.to_record(self))
action.execute(self)
self.handle_deaths()

La idea

Las habilidades son datos

SkillAction es una sola clase, y las habilidades son muchas: Remolino golpea a todos, Golpe fuerte golpea a un enemigo con el 170 % de la fuerza. La diferencia está en una tabla, no en el código. Cada línea del texto es una habilidad: un identificador, maná, recarga, fuerza en porcentaje, objetivo y un nombre para mostrar:

from dataclasses import dataclass
SKILLS = """
cleave|3|3|80|all|Remolino
smash|4|4|170|one|Golpe fuerte
""".strip()
@dataclass(frozen=True)
class Skill:
id: str
mana: int
cooldown: int
percent: int
target: str
name: str
def load_skills(text):
skills = []
for line in text.split("\n"):
p = line.split("|")
skills.append(Skill(p[0], int(p[1]), int(p[2]), int(p[3]), p[4], p[5]))
return skills

El programa lee el texto una vez al empezar (loadSkills) y guarda registros Skill. Una habilidad nueva de un tipo conocido es una línea nueva, no una clase nueva. Es la misma idea que la tabla de baldosas del mapa: los datos están separados del código.

En el juego la tabla es más ancha: además de maná y recarga hay clase y rama en el árbol de habilidades, un tipo (golpe con arma, hechizo, curación, efecto sobre uno mismo, efecto sobre los enemigos), un objetivo one, all o self, un número de golpes, daño del hechizo, y un efecto con turnos y fuerza. Aquí van sus primeras líneas, con las columnas de los nombres en los otros idiomas quitadas:

# Habilidades: clase, rama (1 o 2), paso en la rama (1-3), tipo (weapon: con el arma, spell: hechizo,
# heal: curar, buff: efecto sobre uno mismo, debuff: efecto sobre los enemigos), objetivo (one, all, self),
# maná, recarga en turnos, golpes, potencia como porcentaje de un golpe con el arma, daño del hechizo
# (mín-máx), tipo de daño («-» es el tipo del arma), efecto, turnos del efecto, potencia del efecto.
SKILLS_DATA = """
# id|clase|rama|paso|tipo|objetivo|maná|recarga|golpes|fuerza|mín|máx|daño|efecto|turnos|fuerza del efecto|nombre
rage|barbarian|1|1|buff|self|0|5|0|0|0|0|-|rage|3|6|Ira
whirlwind|barbarian|1|2|weapon|all|3|3|1|80|0|0|-|-|0|0|Torbellino
bloodlust|barbarian|1|3|weapon|one|4|4|1|170|0|0|slash|bleed|3|2|Sed de sangre

Una SkillAction del juego lee una línea de esta tabla y, según el tipo, hace un golpe con arma, lanza un hechizo, cura o pone un efecto:

class SkillAction:
def __init__(self, actor, skill, target):
self.actor = actor
self.skill = skill
self.target = target
def to_record(self, battle):
return SimpleNamespace(actor=battle.index_of(self.actor), action='skill', target=battle.index_of(self.target), id=self.skill.id)
def execute(self, battle):
hero = self.actor
skill = self.skill
hero.mana -= skill.mana
hero.cooldowns[skill.id] = skill.cooldown
battle.say(t('combat.uses', {'name': hero.name, 'skill': nm(skill)}))
targets = [self.target]
if skill.target == 'all':
targets = battle.alive_enemies()
if skill.target == 'self':
targets = [hero]
for target in targets:
if skill.kind == 'weapon':
profile = hero.attack_profile()
if skill.type != '-':
profile.type = skill.type
for hit in range(skill.hits):
if not target.is_alive():
break
landed = strike(battle, hero, target, profile, skill.power)
if landed and skill.effect != '-':
apply_effect(target, make_effect(skill.effect, skill.effect_turns, skill.effect_power), battle)
elif skill.kind == 'spell':
spell_hit(battle, hero, target, skill.min, skill.max, skill.type)
if skill.effect != '-':
apply_effect(target, make_effect(skill.effect, skill.effect_turns, skill.effect_power), battle)
elif skill.kind == 'heal':
healed = hero.heal(skill.min + hero.level * 2)
battle.say(t('combat.healed', {'name': hero.name, 'amount': healed}))
if skill.effect != '-':
apply_effect(hero, make_effect(skill.effect, skill.effect_turns, skill.effect_power), battle)
else:
apply_effect(target, make_effect(skill.effect, skill.effect_turns, skill.effect_power), battle)

La idea

Maná y recarga

Una habilidad sin coste es siempre la mejor elección. Por eso hay dos limitadores: el maná (una reserva que se gasta) y la recarga (cuántos turnos no está disponible una habilidad tras usarla). En el código están en tres sitios, y cada uno hace una cosa:

  • SkillAction.execute paga: baja el maná y pone la recarga;
  • Fighter.tick cuenta: al comienzo del turno de un luchador cada recarga baja en uno;
  • problem pregunta: devuelve el motivo por el que una habilidad no está disponible, o una cadena vacía.
# Por qué una habilidad no está disponible ahora: una cadena vacía si lo está.
def problem(battle, hero, skill):
if hero.cooldown_of(skill.id) > 0:
return "recargando: " + str(hero.cooldown_of(skill.id))
if hero.mana < skill.mana:
return "maná insuficiente"
return ""
def usable_skills(battle, hero):
return [skill for skill in battle.skills if problem(battle, hero, skill) == ""]
class Fighter:
def __init__(self, name, hp, power, mana, potions):
self.name = name
self.hp = hp
self.max_hp = hp
self.power = power
self.mana = mana
self.max_mana = mana
self.potions = potions
self.cooldowns = {}
def alive(self):
return self.hp > 0
def cooldown_of(self, skill_id):
return self.cooldowns.get(skill_id, 0)
# Pasa un turno: cada recarga se acorta en uno.
def tick(self):
for skill_id in self.cooldowns:
self.cooldowns[skill_id] = max(0, self.cooldowns[skill_id] - 1)

El programa da al héroe seis turnos y muestra para cada habilidad si está lista, y si no, por qué. En cada turno el héroe usa la primera disponible:

battle = new_battle(7)
hero = battle.hero()
for turn in range(1, 7):
hero.tick()
print(f"Turno {turn}, maná {hero.mana}")
chosen = None
for skill in battle.skills:
why = problem(battle, hero, skill)
print(" " + skill.name + ": " + ("lista" if why == "" else why))
if why == "" and chosen is None:
chosen = skill
if chosen is None:
print(" nada disponible")
continue
print(" -> usa " + chosen.name)
battle.perform(SkillAction(hero, chosen, battle.alive_enemies()[0]))
Salida
Turno 1, maná 8
Remolino: lista
Golpe fuerte: lista
-> usa Remolino
Turno 2, maná 5
Remolino: recargando: 2
Golpe fuerte: lista
-> usa Golpe fuerte
Turno 3, maná 1
Remolino: recargando: 1
Golpe fuerte: recargando: 3
nada disponible
Turno 4, maná 1
Remolino: maná insuficiente
Golpe fuerte: recargando: 2
nada disponible
Turno 5, maná 1
Remolino: maná insuficiente
Golpe fuerte: recargando: 1
nada disponible
Turno 6, maná 1
Remolino: maná insuficiente
Golpe fuerte: maná insuficiente
nada disponible

Mira el turno 2: Remolino aún se recarga (2 turnos), pero Golpe fuerte está listo porque el maná alcanza. En el turno 4 Remolino ya se ha recargado, pero el maná (1) no alcanza. En el turno 6 ambos se han recargado y el maná sigue sin alcanzar para ninguno. Por eso problem comprueba primero la recarga: en Golpe fuerte el motivo cambia de «recargando» a «maná insuficiente» solo cuando termina la recarga.

self.actor.cooldowns[self.skill.id] = 

En el juego la lista de habilidades disponibles es la misma comprobación, solo que sobre el héroe del juego:

def usable_skills(hero):
result = []
for skill_id in hero.skills:
skill = SKILLS[skill_id]
if hero.mana >= skill.mana and hero.cooldown_of(skill.id) == 0:
result.append(skill)
return result
Más precisamente, para curiosos

En el juego el maná vuelve de uno en uno con cada paso por el mapa, no por turno de combate, y también con una poción y al subir de nivel. Así que en el combate es una reserva que tiene que alcanzar: si la gastas en Remolino en el primer turno, no la tienes cuando el jefe la pida. La recarga se cuenta en los turnos del propio luchador (tick está al comienzo de su turno), no en rondas. Por eso un luchador más rápido, que actúa más a menudo, se recarga más a menudo.

La idea

Los objetivos

Una habilidad tiene un objetivo: one (un enemigo), all (todos los vivos) o self (el héroe). SkillAction reúne los objetivos en una lista y aplica el efecto a cada uno: con all son todos los enemigos vivos, y si no es un solo objetivo. Por eso Remolino golpea con un comando tanto al goblin como a la rata, y Golpe fuerte solo a uno.

Pero ¿quién elige el objetivo? El jugador escribe un número: el enemigo es 1 o 2 entre los vivos, y sin número es el primero. El programa convierte la línea del jugador en un comando en parse:

# El objetivo es un número desde 1 entre los enemigos vivos; sin número es el primero.
def aim(battle, parts, index):
alive = battle.alive_enemies()
if len(parts) <= index:
return alive[0]
text = parts[index]
if len(text) != 1 or not text.isdigit():
return None
number = int(text)
return alive[number - 1] if 1 <= number <= len(alive) else None
# Un comando no válido no cuesta turno: dice por qué y vuelve a preguntar.
def refuse(battle, line, reason):
print("? " + line.strip() + ": " + reason)
return None
def parse(battle, hero, line):
parts = line.split()
word = parts[0] if parts else ""
if word == "attack":
target = aim(battle, parts, 1)
return refuse(battle, line, "no existe ese objetivo") if target is None else AttackAction(hero, target)
if word == "skill":
skill = skill_of(battle, parts[1]) if len(parts) > 1 else None
if skill is None:
return refuse(battle, line, "habilidad desconocida")
why = problem(battle, hero, skill)
if why != "":
return refuse(battle, line, why)
target = aim(battle, parts, 2)
return refuse(battle, line, "no existe ese objetivo") if target is None else SkillAction(hero, skill, target)
if word == "item":
return ItemAction(hero, "potion") if hero.potions > 0 else refuse(battle, line, "no hay pociones")
if word == "flee":
return FleeAction(hero)
return refuse(battle, line, "comando desconocido")

Fíjate en que un comando erróneo no cuesta turno. parse dice el motivo (habilidad desconocida, no existe ese objetivo, la habilidad se está recargando) y no devuelve nada, y Player lee la línea siguiente. Un comando que no se creó no se registra ni se ejecuta, así que el combate nunca se entera. Es la misma regla que al chocar con una pared en el mapa.

El programa lee los comandos de la entrada estándar, uno por línea. Algunos son erróneos a propósito:

Entrada
skill fireball
attack 5
skill cleave
skill cleave
dance
skill smash 2
item
attack 1
attack
battle = new_battle(7)
battle.live = True
lines = [line for line in sys.stdin.read().split("\n") if line.strip()]
run(battle, Player(lines))
Salida
== Ronda 1 ==
? skill fireball: habilidad desconocida
? attack 5: no existe ese objetivo
Guerrero: Remolino
Guerrero → Goblin: 6 de daño
Guerrero → Rata: 6 de daño
Goblin → Guerrero: 8 de daño
Rata → Guerrero: 4 de daño
== Ronda 2 ==
? skill cleave: recargando: 2
? dance: comando desconocido
Guerrero: Golpe fuerte
Guerrero → Rata: 12 de daño
Rata cae.
Goblin → Guerrero: 7 de daño
== Ronda 3 ==
Guerrero: poción, +10 de vida
Goblin → Guerrero: 7 de daño
== Ronda 4 ==
Guerrero → Goblin: 6 de daño
Goblin → Guerrero: 6 de daño
== Ronda 5 ==
Guerrero → Goblin: 7 de daño
Goblin cae.
¡Victoria!

Las líneas con ? son los rechazos: no existe la habilidad «fireball», no hay quinto enemigo, Remolino aún se recarga, «dance» no es un comando. Ninguna pasó por perform, así que ninguna está en el diario ni en el registro.

En el juego, elegir el objetivo es una pantalla aparte sobre el combate, transparente, que no golpea por sí misma: devuelve el número del objetivo con pop(result), y el combate decide qué hacer con él (la lección sobre pantallas que devuelven una respuesta). Se ve así:

=== Combate ===
Iva, bárbaro nv.1
V [############] 48/48 M [########] 4/4
1) Goblin lancero [########] 16/16
2) Goblin chamán [########] 13/13
¡Combate! Contra ti: goblin lancero, goblin chamán.
1 golpe 2 habilidad 3 objeto 4 huida
+- Objetivo -----------------------+
| 1) goblin lancero 16/16 |
| 2) goblin chamán 13/13 |
| |
| número: objetivo, x: volver |
+----------------------------------+
# 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)

La pantalla del combate guarda lo que se empezó (pending), y onResult termina el comando cuando llega la respuesta: comprueba el maná y la recarga, abre una segunda pantalla para el objetivo si hace falta y, por último, llama a heroAct con el comando ya hecho:

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]))

La idea

Quién elige: el chooser

Los comandos ya existen. ¿Quién los elige? Cada elegidor es un objeto con el mismo método choose(battle, actor), que devuelve un comando o nada (el final de la entrada):

class Auto:
def choose(self, battle, actor):
return choose_hero(battle) if actor is battle.hero() else choose_enemy(battle, actor)
class Player:
def __init__(self, lines):
self.lines = lines
self.next = 0
def choose(self, battle, actor):
if actor is not battle.hero():
return choose_enemy(battle, actor)
while self.next < len(self.lines):
action = parse(battle, actor, self.lines[self.next])
self.next += 1
if action is not None:
return action
return None
class Replay:
def __init__(self, record):
self.record = record
self.next = 0
def choose(self, battle, actor):
if self.next >= len(self.record):
return None
action = action_from_record(battle, self.record[self.next])
self.next += 1
return action
def weakest(battle):
best = None
for enemy in battle.alive_enemies():
if best is None or enemy.hp < best.hp:
best = enemy
return best
def choose_enemy(battle, enemy):
return AttackAction(enemy, battle.hero())
def choose_hero(battle):
hero = battle.hero()
target = weakest(battle)
if hero.hp * 100 // hero.max_hp < 35 and hero.potions > 0:
return ItemAction(hero, "potion")
usable = usable_skills(battle, hero)
for skill in usable:
if skill.target == "all" and len(battle.alive_enemies()) >= 2:
return SkillAction(hero, skill, target)
for skill in usable:
if skill.target == "one":
return SkillAction(hero, skill, target)
return AttackAction(hero, target)
  • Auto elige para todos: los enemigos golpean al héroe, y el héroe decide por prioridad: una poción con poca vida, una habilidad para varios objetivos, un golpe fuerte, un golpe normal. Así funciona la simulación. Es la «valoración de utilidad» más simple; las decisiones más listas de los enemigos son el tema de la lección siguiente (Enemigos que piensan);
  • Player da los comandos del héroe desde la entrada, y los enemigos quedan con la inteligencia artificial;
  • Replay lo veremos enseguida.

El bucle del combate run no sabe quién es quién. Hace correr las rondas, dice a cada luchador «te toca» y toma un comando del chooser. Por eso quién elige y qué se ejecuta son dos cosas independientes: el jugador, el piloto automático y el registro se cambian sin tocar el combate.

# Un combate: rondas, y en cada ronda todos juegan por turno. El comando viene del chooser.
def run(battle, chooser):
while not battle.over and battle.rounds < 12:
battle.rounds += 1
battle.say("== Ronda " + str(battle.rounds) + " ==")
for actor in battle.fighters:
if battle.over:
break
if not actor.alive():
continue
actor.tick()
action = chooser.choose(battle, actor)
if action is None:
battle.over = True
break
battle.perform(action)
def action_from_record(battle, r):
actor = battle.fighters[r.actor]
target = battle.fighters[r.target] if r.target >= 0 else None
if r.action == "attack":
return AttackAction(actor, target)
if r.action == "skill":
return SkillAction(actor, skill_of(battle, r.id), target)
if r.action == "item":
return ItemAction(actor, r.id)
return FleeAction(actor)
def describe(r):
return f"{r.actor} {r.action} {r.target}" + (f" {r.id}" if r.id else "")
# La repetición: un comienzo nuevo con la misma semilla, y el registro en lugar de una elección.
def replay_battle(seed, record):
battle = new_battle(seed)
run(battle, Replay(record))
return battle
def same_lines(first, second):
if len(first) != len(second):
return False
for i in range(len(first)):
if first[i] != second[i]:
return False
return True

Míralo

Registrar y repetir

Los comandos ya pasan por perform, y ahí se registran. Falta reproducirlos otra vez. Una repetición es un combate nuevo desde el mismo comienzo (la misma semilla, los mismos luchadores) en el que el chooser es Replay: va entregando los comandos grabados uno a uno. En la escena de arriba está el combate en vivo, en medio la cinta con el registro y abajo la repetición.

# El único lugar por el que pasa cada comando: primero se registra y luego se ejecuta.
def perform(self, action):
self.record.append(action.to_record(self))
action.execute(self)
self.check_end()
# Un combate: rondas, y en cada ronda todos juegan por turno. El comando viene del chooser.
def run(battle, chooser):
while not battle.over and battle.rounds < 12:
battle.rounds += 1
battle.say("== Ronda " + str(battle.rounds) + " ==")
for actor in battle.fighters:
if battle.over:
break
if not actor.alive():
continue
actor.tick()
action = chooser.choose(battle, actor)
if action is None:
battle.over = True
break
battle.perform(action)
# La repetición: un comienzo nuevo con la misma semilla, y el registro en lugar de una elección.
def replay_battle(seed, record):
battle = new_battle(seed)
run(battle, Replay(record))
return battle
def same_lines(first, second):
if len(first) != len(second):
return False
for i in range(len(first)):
if first[i] != second[i]:
return False
return True
Paso a paso, en palabras
  1. El combate avanza por rondas. Le toca a un luchador y el chooser da un comando: la inteligencia artificial (o el jugador) para el héroe, un golpe para los enemigos. El combate no pregunta qué comando es: solo lo toma.
  2. perform registra el comando y lo ejecuta. El primero es la habilidad de la tabla (0 skill 2): el maná baja a 5; la vida del goblin cambia 18 → 12, la de la rata 10 → 4. La ficha de la cinta es su registro.
  3. Cada comando, el del héroe y el de los enemigos, pasa por el mismo perform y deja una ficha en la cinta. El registro son solo datos: quién (un número), qué, contra quién. No contiene vida, daño ni azar.
  4. El combate terminó: 10 comandos en el registro (la cinta muestra los primeros 8). La repetición empieza de nuevo con la misma semilla y los mismos luchadores con la vida llena. Solo cambia la elección: los comandos vienen del registro, no de un jugador ni de una inteligencia artificial.
  5. Replay.choose entrega la siguiente ficha como comando, y run y perform son los mismos que en vivo. El generador tiene la misma semilla, así que cada golpe sale igual y los números del combate de abajo repiten los de arriba turno por turno.
  6. sameLines compara los dos registros línea por línea: 21 líneas y ninguna diferencia. Así una repetición se puede comprobar, no solo mirar.
  7. La semilla y el registro son todo el combate. El mismo bucle, ejecutado miles de veces sin pantalla, es también la simulación para el equilibrio.

El programa juega un combate con el piloto automático, imprime el registro y lo repite dos veces: con la misma semilla y con otra.

battle = new_battle(7)
run(battle, Auto())
print("Registro:")
for r in battle.record:
print(describe(r))
again = replay_battle(7, battle.record)
print("La misma batalla desde el registro: " + ("sí" if same_lines(battle.log, again.log) else "no"))
other = replay_battle(8, battle.record)
print("La misma batalla desde otra semilla: " + ("sí" if same_lines(battle.log, other.log) else "no"))
Salida
Registro:
0 skill 2 cleave
1 attack 0
2 attack 0
0 skill 2 smash
1 attack 0
0 item -1 potion
1 attack 0
0 attack 1
1 attack 0
0 attack 1
La misma batalla desde el registro: sí
La misma batalla desde otra semilla: no

La primera repetición coincide con el combate línea por línea, la segunda no. El registro guarda las elecciones (quién, qué, contra quién), no los dados. El daño y los aciertos salen del generador con semilla, así que el mismo registro sobre otra semilla da las mismas elecciones sobre otros números. Esa es también la razón de que el registro sea pequeño: 10 comandos, no 21 líneas de diario.

En el juego la repetición es una pantalla tras una victoria (la tecla r). Muestra el mismo diario por páginas y una respuesta sobre si coincide con el combate:

=== Repetición del combate (1/1) ===
¡Combate! Contra ti: rata gigante.
Rata gigante araña a Iva (1).
Iva no alcanza a rata gigante.
Rata gigante araña a Iva (1).
Iva aplasta a rata gigante (12).
Rata gigante cae y no se levanta.
Oro: +1.
Rata gigante deja: antídoto.
La repetición coincide con el combate: sí
(Enter: volver)
def action_from_record(battle, record):
actor = battle.creature_at(record.actor)
target = battle.creature_at(record.target) if record.target >= 0 else None
if record.action == 'attack':
return AttackAction(actor, target)
if record.action == 'skill':
return SkillAction(actor, SKILLS[record.id], target)
if record.action == 'item':
return ItemAction(actor, record.id)
if record.action == 'flee':
return FleeAction(actor)
return SpecialAction(actor, record.id, target)
# Devuelve el registro del combate repetido.
def replay_battle(snapshot, record):
world = ReplayWorld(snapshot)
enemies = []
for enemy in snapshot.enemies:
enemies.append(enemy.copy())
battle = Battle(world, enemies, snapshot.can_flee)
battle.script = record
battle.start()
battle.advance()
return battle.log
def same_lines(first, second):
if len(first) != len(second):
return False
for i in range(len(first)):
if first[i] != second[i]:
return False
return True
Más precisamente, para curiosos

En el juego la repetición no parte de la semilla del mundo, sino de una instantánea del comienzo del combate: el estado del generador y copias del héroe y de los enemigos (con las habilidades, el maná y los objetos del héroe en ese momento). El héroe tiene un inventario y un nivel que no se construyen desde una semilla, por eso la instantánea los lleva. La repetición es un Battle con un script en lugar de jugador e inteligencia artificial: advance toma los comandos del registro. El mundo de la repetición (ReplayWorld) no cambia: no hay cadáveres, ni oro, ni misiones, solo queda el diario.

Un registro depende de la versión de las reglas: si cambias el daño de Remolino, los registros viejos ya no coinciden. Por eso los registros sirven para comprobar y para buscar errores en la misma versión, no como almacén permanente.

La idea

Mil combates sin pantalla

Si ejecutas run con Auto para los dos bandos, no necesitas pantalla, ni jugador, ni registro. El programa juega combates con semillas del 1 al 1000 y cuenta las victorias:

print("Simulación sin pantalla")
wins = 0
losses = 0
commands = 0
total = 1000
for seed in range(1, total + 1):
battle = new_battle(seed)
run(battle, Auto())
if battle.outcome == "win":
wins += 1
else:
losses += 1
commands += len(battle.record)
average = commands * 10 // total
print(f"Batallas: {total}, victorias: {wins}, derrotas: {losses}, comandos de media: {average // 10}.{average % 10}")
Salida
Simulación sin pantalla
Batallas: 1000, victorias: 875, derrotas: 125, comandos de media: 9.9

El resultado es 875 victorias y 125 derrotas. Así se mide el equilibrio: si el héroe gana todos los combates, los enemigos son débiles; si pierde, son demasiado fuertes; los números se mueven hasta que el resultado es el que quieres. Todo es determinista: el mismo código da los mismos números cada vez y en todos los lenguajes.

En el juego esto es --simulate=1000: combates de cada clase contra cada enemigo, como verás en la última lección. La base es esta: el héroe en piloto automático (autoHero) elige comandos con la misma valoración, y el combate corre con el mismo advance, sin pantalla:

def simulate_fight(game, hero_class, kind):
game.hero = make_sim_hero(game, hero_class, SIM_LEVELS[kind.depth])
battle = Battle(game, [Enemy(game.new_id(), kind)], False)
battle.auto_hero = True
battle.advance()
return SimpleNamespace(win=battle.outcome == 'win', turns=battle.hero_turns)

La simulación es posible porque el combate escribe en un diario, no en la pantalla (en la lección live solo lo copia allí), y la elección del comando es intercambiable.

Pruébalo

Predice la salida

En cada turno el héroe usa la última habilidad disponible (Golpe fuerte si puede, si no Remolino). Remolino cuesta 3 de maná y se recarga 3 turnos, Golpe fuerte cuesta 4 y se recarga 4, y el héroe empieza con 8 de maná. ¿Qué imprime el programa?

battle = new_battle(7)
hero = battle.hero()
for turn in range(1, 6):
hero.tick()
used = "-"
for skill in usable_skills(battle, hero):
used = skill.id
if used != "-":
battle.perform(SkillAction(hero, skill_of(battle, used), battle.fighters[1]))
print(turn, used)
Respuesta

Pruébalo

Encuentra el error

Un compañero cambió SkillAction y ahora la repetición no coincide con el combate. El registro tiene un aspecto raro:

class SkillAction:
def __init__(self, actor, skill, target):
self.actor = actor
self.skill = skill
self.target = target
def to_record(self, battle):
return ActionRecord(battle.index_of(self.actor), "attack", battle.index_of(self.target), self.skill.id)
def execute(self, battle):
self.actor.mana -= self.skill.mana
self.actor.cooldowns[self.skill.id] = self.skill.cooldown
battle.say(self.actor.name + ": " + self.skill.name)
targets = battle.alive_enemies() if self.skill.target == "all" else [self.target]
for victim in targets:
battle.hit(self.actor, victim, self.actor.power * self.skill.percent // 100 + battle.rng.roll(3))
battle = new_battle(7)
run(battle, Auto())
print("Registro:")
for r in battle.record:
print(describe(r))
again = replay_battle(7, battle.record)
print("La misma batalla desde el registro: " + ("sí" if same_lines(battle.log, again.log) else "no"))
Salida
Registro:
0 attack 2 cleave
1 attack 0
2 attack 0
0 attack 2 smash
1 attack 0
0 item -1 potion
1 attack 0
0 attack 1
1 attack 0
0 attack 1
La misma batalla desde el registro: no

En el registro Remolino es attack. ¿Qué línea tiene la culpa?

Elige la línea con el error.

Más precisamente

Lo que un comando no puede hacer

Un comando como objeto es cómodo, pero tiene límites que conviene conocer.

La clase no se protege sola de un comando ilegal. SkillAction no comprueba el maná, porque lo comprueban parse (para el jugador) y usableSkills (para la inteligencia artificial) antes de crearlo. Si te saltas esas comprobaciones en algún sitio, el maná se vuelve negativo. La otra opción es poner la comprobación dentro del propio comando, que devuelve «no puedo». El juego elige la primera: un comando ilegal no se crea.

Un registro depende de las reglas. Guarda las elecciones, no las consecuencias. Si cambias la fórmula del daño o añades una tirada de dados más, los registros viejos dan otro combate. El orden de las llamadas al generador forma parte del contrato.

El azar tiene que estar en la semilla. Si una parte del combate usa el Random del lenguaje o el reloj, la repetición se desvía. Por eso el juego tiene su propio generador, y el daño y los aciertos se tiran solo en unos pocos sitios.

Un comando no es un evento. Un evento dice «algo pasó» (murió un enemigo), un comando dice «haz». El bus de eventos es otra lección (las misiones), y los dos suelen trabajar juntos: un comando cambia el combate, y el combate lanza un evento.

Una clase por tipo no crece sin fin. Para cuatro tipos de comandos está bien, para dieciocho habilidades es demasiado. Por eso las habilidades son datos, y SkillAction es una clase para todas.

Ejercicio

Descanso

Resuelve la tarea en tu editor. Empieza con el programa del jugador de esta lección.

Descanso. Añade un cuarto tipo de comando para el héroe: rest devuelve 2 de maná (no más que el máximo) y cuesta el turno. El jugador lo escribe como rest. Hay que tocar tres sitios: la clase RestAction en sí (con un toRecord que devuelve rest), parse (la palabra del jugador) y actionFromRecord (el registro). Al final el programa reproduce la repetición y escribe si coincide. La entrada (una por línea) es `skill smash 2`, `dance`, `rest`, `item`, `skill smash 1`, `attack 1`, `skill smash 1`, y la salida es:

Entrada
skill smash 2
dance
rest
item
skill smash 1
attack 1
skill smash 1
Salida
== Ronda 1 ==
Guerrero: Golpe fuerte
Guerrero → Rata: 12 de daño
Rata cae.
Goblin → Guerrero: 8 de daño
== Ronda 2 ==
? dance: comando desconocido
Guerrero: descanso +2 maná
Goblin → Guerrero: 8 de daño
== Ronda 3 ==
Guerrero: poción, +10 de vida
Goblin → Guerrero: 6 de daño
== Ronda 4 ==
? skill smash 1: recargando: 1
Guerrero → Goblin: 8 de daño
Goblin → Guerrero: 7 de daño
== Ronda 5 ==
Guerrero: Golpe fuerte
Guerrero → Goblin: 11 de daño
Goblin cae.
¡Victoria!
La misma batalla desde el registro: sí

Más tareas

  • Una habilidad curativa. Añade a la tabla una habilidad que cure al héroe (objetivo self). ¿Cuántas líneas de código hay que cambiar y por qué tan pocas?
  • Huida. FleeAction tira dados (50 %). Pon flee en la entrada del jugador y mira la repetición: ¿coincide? ¿Por qué los dados no están en el registro?
  • Deshacer. Intenta añadir un método undo() a AttackAction y ItemAction. ¿Cuál de los comandos no se puede deshacer sin una instantánea del estado?
  • Equilibrio. Sube en 1 la fuerza del goblin y ejecuta la simulación. ¿Cuánto cambian las victorias y por qué los números salen de mil combates y no de uno?

Test

Compruébate

1. Añades al juego un comando «descanso». Además de la clase en sí, ¿qué otros dos sitios tienen que conocerlo para que funcionen tanto el jugador como la repetición?
2. El registro guarda el número del luchador (0, 1, 2), no el objeto. ¿Por qué?
3. El héroe tiene 8 de maná y usa Remolino (3 de maná, recarga 3). En su siguiente turno el jugador escribe la misma habilidad. ¿Qué pasa?
4. La repetición con otra semilla («La misma batalla desde otra semilla: no») usa el mismo registro. ¿Por qué sale otro combate?
5. ¿Qué hace posible una simulación sin pantalla?

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