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 abattle.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 = Truehero = 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))Guerrero → Goblin: 8 de dañoGoblin → Guerrero: 8 de dañoGuerrero: Golpe fuerteGuerrero → Goblin: 12 de dañoGoblin cae.Guerrero: poción, +8 de vidaRegistro:0 attack 11 attack 00 skill 1 smash0 item -1 potionEl 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|Remolinosmash|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 skillsEl 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|nombrerage|barbarian|1|1|buff|self|0|5|0|0|0|0|-|rage|3|6|Irawhirlwind|barbarian|1|2|weapon|all|3|3|1|80|0|0|-|-|0|0|Torbellinobloodlust|barbarian|1|3|weapon|one|4|4|1|170|0|0|slash|bleed|3|2|Sed de sangreUna 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.executepaga: baja el maná y pone la recarga;Fighter.tickcuenta: al comienzo del turno de un luchador cada recarga baja en uno;problempregunta: 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]))Turno 1, maná 8 Remolino: lista Golpe fuerte: lista -> usa RemolinoTurno 2, maná 5 Remolino: recargando: 2 Golpe fuerte: lista -> usa Golpe fuerteTurno 3, maná 1 Remolino: recargando: 1 Golpe fuerte: recargando: 3 nada disponibleTurno 4, maná 1 Remolino: maná insuficiente Golpe fuerte: recargando: 2 nada disponibleTurno 5, maná 1 Remolino: maná insuficiente Golpe fuerte: recargando: 1 nada disponibleTurno 6, maná 1 Remolino: maná insuficiente Golpe fuerte: maná insuficiente nada disponibleMira 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] = Cuando se usa una habilidad, SkillAction pone en las recargas del luchador el número de la tabla: skill.cooldown. En los turnos siguientes tick lo baja hasta 0, y problem dice «lista» solo entonces.
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 resultMá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:
skill fireballattack 5skill cleaveskill cleavedanceskill smash 2itemattack 1attackbattle = new_battle(7)battle.live = Truelines = [line for line in sys.stdin.read().split("\n") if line.strip()]run(battle, Player(lines))== Ronda 1 ==? skill fireball: habilidad desconocida? attack 5: no existe ese objetivoGuerrero: RemolinoGuerrero → Goblin: 6 de dañoGuerrero → Rata: 6 de dañoGoblin → Guerrero: 8 de dañoRata → Guerrero: 4 de daño== Ronda 2 ==? skill cleave: recargando: 2? dance: comando desconocidoGuerrero: Golpe fuerteGuerrero → Rata: 12 de dañoRata cae.Goblin → Guerrero: 7 de daño== Ronda 3 ==Guerrero: poción, +10 de vidaGoblin → Guerrero: 7 de daño== Ronda 4 ==Guerrero → Goblin: 6 de dañoGoblin → Guerrero: 6 de daño== Ronda 5 ==Guerrero → Goblin: 7 de dañoGoblin 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/41) Goblin lancero [########] 16/162) 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)Autoelige 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);Playerda los comandos del héroe desde la entrada, y los enemigos quedan con la inteligencia artificial;Replaylo 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 TrueMí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 TruePaso a paso, en palabras
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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"))Registro:0 skill 2 cleave1 attack 02 attack 00 skill 2 smash1 attack 00 item -1 potion1 attack 00 attack 11 attack 00 attack 1La misma batalla desde el registro: síLa misma batalla desde otra semilla: noLa 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 TrueMá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 = 0losses = 0commands = 0total = 1000for 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 // totalprint(f"Batallas: {total}, victorias: {wins}, derrotas: {losses}, comandos de media: {average // 10}.{average % 10}")Simulación sin pantallaBatallas: 1000, victorias: 875, derrotas: 125, comandos de media: 9.9El 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)Turno 1: están disponibles las dos, se toma la última, Golpe fuerte (maná 8 → 4, recarga 4). Turno 2: Golpe fuerte se recarga (3), queda Remolino y 4 de maná le alcanzan (maná 1). Desde el turno 3 el maná es 1 y no alcanza para nada, ni siquiera en el turno 5, cuando Golpe fuerte ya se ha recargado. Las otras salidas son de un programa sin recarga (Golpe fuerte se repetiría en el turno 2) y de un programa que no gasta maná (en el turno 5 Golpe fuerte estaría disponible otra vez).
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"))Registro:0 attack 2 cleave1 attack 02 attack 00 attack 2 smash1 attack 00 item -1 potion1 attack 00 attack 11 attack 00 attack 1La misma batalla desde el registro: noEn el registro Remolino es attack. ¿Qué línea tiene la culpa?
Elige la línea con el error.
toRecord escribe "attack" en lugar de "skill". En vivo la habilidad se ejecuta como habilidad: el maná está pagado, el golpe alcanza a todos. Pero el registro dice «golpe» contra un objetivo, y la repetición hace exactamente lo que lee: golpea, sin pagar maná y sin recarga. Los diarios se desvían ya en el primer comando. La pista está en el propio registro: líneas como 0 attack 2 cleave, donde el tipo y la habilidad se contradicen.
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:
skill smash 2dancerestitemskill smash 1attack 1skill smash 1== Ronda 1 ==Guerrero: Golpe fuerteGuerrero → Rata: 12 de dañoRata cae.Goblin → Guerrero: 8 de daño== Ronda 2 ==? dance: comando desconocidoGuerrero: descanso +2 manáGoblin → Guerrero: 8 de daño== Ronda 3 ==Guerrero: poción, +10 de vidaGoblin → Guerrero: 6 de daño== Ronda 4 ==? skill smash 1: recargando: 1Guerrero → Goblin: 8 de dañoGoblin → Guerrero: 7 de daño== Ronda 5 ==Guerrero: Golpe fuerteGuerrero → Goblin: 11 de dañoGoblin 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.
FleeActiontira dados (50 %). Ponfleeen 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()aAttackActionyItemAction. ¿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
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