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:

// 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.
record ActionRecord(int Actor, string Action, int Target, string Id);
// Un comando es un objeto: sabe cómo registrarse y cómo ejecutarse.
interface IBattleAction
{
ActionRecord ToRecord(Battle battle);
void Execute(Battle battle);
}
class AttackAction : IBattleAction
{
public readonly Fighter Actor;
public readonly Fighter Target;
public AttackAction(Fighter actor, Fighter target)
{
Actor = actor;
Target = target;
}
public ActionRecord ToRecord(Battle battle)
{
return new ActionRecord(battle.IndexOf(Actor), "attack", battle.IndexOf(Target), "");
}
public void Execute(Battle battle)
{
battle.Hit(Actor, Target, Actor.Power + battle.Rng.Roll(3));
}
}
class ItemAction : IBattleAction
{
public readonly Fighter Actor;
public readonly string ItemId;
public ItemAction(Fighter actor, string itemId)
{
Actor = actor;
ItemId = itemId;
}
public ActionRecord ToRecord(Battle battle)
{
return new ActionRecord(battle.IndexOf(Actor), "item", -1, ItemId);
}
public void Execute(Battle battle)
{
Actor.Potions -= 1;
int healed = Math.Min(10, Actor.MaxHp - Actor.Hp);
Actor.Hp += healed;
battle.Say(Actor.Name + ": poción, +" + healed + " de vida");
}
}
class FleeAction : IBattleAction
{
public readonly Fighter Actor;
public FleeAction(Fighter actor)
{
Actor = actor;
}
public ActionRecord ToRecord(Battle battle)
{
return new ActionRecord(battle.IndexOf(Actor), "flee", -1, "");
}
public void Execute(Battle battle)
{
if (battle.Rng.Roll(100) < 50)
{
battle.Say(Actor.Name + " huye.");
battle.Over = true;
battle.Outcome = "fled";
}
else
{
battle.Say(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 battle = NewBattle(7);
battle.Live = true;
Fighter hero = battle.Hero();
Fighter goblin = battle.Fighters[1];
var queue = new List<IBattleAction>
{
new AttackAction(hero, goblin),
new AttackAction(goblin, hero),
new SkillAction(hero, SkillOf(battle, "smash")!, goblin),
new ItemAction(hero, "potion"),
};
foreach (IBattleAction action in queue)
{
battle.Perform(action);
}
Console.WriteLine("Registro:");
foreach (ActionRecord r in battle.Record)
{
Console.WriteLine(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:

class AttackAction : IBattleAction
{
public Creature Actor;
public Creature? Target;
public AttackAction(Creature actor, Creature? target)
{
Actor = actor;
Target = target;
}
public ActionRecord ToRecord(Battle battle)
{
return new ActionRecord { Actor = battle.IndexOf(Actor), Action = "attack", Target = battle.IndexOf(Target), Id = "" };
}
public void Execute(Battle battle)
{
Strike(battle, Actor, Target!, Actor.AttackProfile(), 100);
}
}
class FleeAction : IBattleAction
{
public Creature Actor;
public FleeAction(Creature actor)
{
Actor = actor;
}
public ActionRecord ToRecord(Battle battle)
{
return new ActionRecord { Actor = battle.IndexOf(Actor), Action = "flee", Target = -1, Id = "" };
}
public void Execute(Battle battle)
{
if (!battle.CanFlee)
{
battle.Say(T("combat.noFlee"));
return;
}
int fastest = 0;
foreach (Enemy enemy in battle.AliveEnemies()) fastest = Math.Max(fastest, enemy.Stat("agility"));
int chance = Clamp(50 + (Actor.Stat("agility") - fastest) * 5, 15, 90);
if (battle.Game.Rng.Chance(chance))
{
battle.Say(T("combat.fled", ("name", Actor.Name)));
battle.Over = true;
battle.Outcome = "fled";
}
else
{
battle.Say(T("combat.fleeFail", ("name", 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
{
public readonly Rng Rng;
public readonly List<Fighter> Fighters;
public readonly List<Skill> Skills;
public readonly List<string> Log = new();
public readonly List<ActionRecord> Record = new();
public bool Over = false;
public bool Live = false;
public string Outcome = "none";
public int Rounds = 0;
public Battle(int seed, List<Fighter> fighters, List<Skill> skills)
{
Rng = new Rng(seed);
Fighters = fighters;
Skills = skills;
}
public Fighter Hero() => Fighters[0];
public void Say(string text)
{
Log.Add(text);
if (Live) Console.WriteLine(text);
}
public int IndexOf(Fighter fighter) => Fighters.IndexOf(fighter);
public List<Fighter> AliveEnemies()
{
var result = new List<Fighter>();
for (int i = 1; i < Fighters.Count; i++)
{
if (Fighters[i].Alive()) result.Add(Fighters[i]);
}
return result;
}
public void Hit(Fighter actor, Fighter target, int amount)
{
target.Hp = Math.Max(0, target.Hp - amount);
Say(actor.Name + " → " + target.Name + ": " + amount + " de daño");
if (!target.Alive()) Say(target.Name + " cae.");
}
// El único lugar por el que pasa cada comando: primero se registra y luego se ejecuta.
public void Perform(IBattleAction action)
{
Record.Add(action.ToRecord(this));
action.Execute(this);
CheckEnd();
}
public void CheckEnd()
{
if (Over) return;
if (!Hero().Alive())
{
Over = true;
Outcome = "lose";
Say("Derrota.");
}
else if (AliveEnemies().Count == 0)
{
Over = true;
Outcome = "win";
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):

public void Perform(IBattleAction action)
{
Record.Add(action.ToRecord(this));
action.Execute(this);
HandleDeaths();
}

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:

const string SKILLS = """
cleave|3|3|80|all|Remolino
smash|4|4|170|one|Golpe fuerte
""";
record Skill(string Id, int Mana, int Cooldown, int Percent, string Target, string Name);
static List<Skill> LoadSkills(string text)
{
var skills = new List<Skill>();
foreach (string line in text.Split('\n'))
{
string[] p = line.Split('|');
skills.Add(new Skill(p[0], int.Parse(p[1]), int.Parse(p[2]), int.Parse(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.
static partial class Arcanum
{
public static readonly string 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 : IBattleAction
{
public Creature Actor;
public Skill Skill;
public Creature? Target;
public SkillAction(Creature actor, Skill skill, Creature? target)
{
Actor = actor;
Skill = skill;
Target = target;
}
public ActionRecord ToRecord(Battle battle)
{
return new ActionRecord { Actor = battle.IndexOf(Actor), Action = "skill", Target = battle.IndexOf(Target), Id = Skill.Id };
}
public void Execute(Battle battle)
{
Hero hero = (Hero)Actor;
Skill skill = Skill;
hero.Mana -= skill.Mana;
hero.Cooldowns.Set(skill.Id, skill.Cooldown);
battle.Say(T("combat.uses", ("name", hero.Name), ("skill", Nm(skill))));
List<Creature> targets = new List<Creature> { Target! };
if (skill.Target == "all") targets = new List<Creature>(battle.AliveEnemies());
if (skill.Target == "self") targets = new List<Creature> { hero };
foreach (Creature target in targets)
{
if (skill.Kind == "weapon")
{
DamageProfile profile = hero.AttackProfile();
if (skill.Type != "-") profile.Type = skill.Type;
for (int hit = 0; hit < skill.Hits; hit++)
{
if (!target.IsAlive()) break;
bool landed = Strike(battle, hero, target, profile, skill.Power);
if (landed && skill.Effect != "-") ApplyEffect(target, MakeEffect(skill.Effect, skill.EffectTurns, skill.EffectPower), battle);
}
}
else if (skill.Kind == "spell")
{
SpellHit(battle, hero, target, skill.Min, skill.Max, skill.Type);
if (skill.Effect != "-") ApplyEffect(target, MakeEffect(skill.Effect, skill.EffectTurns, skill.EffectPower), battle);
}
else if (skill.Kind == "heal")
{
int healed = hero.Heal(skill.Min + hero.Level * 2);
battle.Say(T("combat.healed", ("name", hero.Name), ("amount", healed)));
if (skill.Effect != "-") ApplyEffect(hero, MakeEffect(skill.Effect, skill.EffectTurns, skill.EffectPower), battle);
}
else
{
ApplyEffect(target, MakeEffect(skill.Effect, skill.EffectTurns, skill.EffectPower), 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á.
static string Problem(Battle battle, Fighter hero, Skill skill)
{
if (hero.CooldownOf(skill.Id) > 0) return "recargando: " + hero.CooldownOf(skill.Id);
if (hero.Mana < skill.Mana) return "maná insuficiente";
return "";
}
static List<Skill> UsableSkills(Battle battle, Fighter hero)
{
var result = new List<Skill>();
foreach (Skill skill in battle.Skills)
{
if (Problem(battle, hero, skill) == "") result.Add(skill);
}
return result;
}
class Fighter
{
public readonly string Name;
public int Hp;
public readonly int MaxHp;
public readonly int Power;
public int Mana;
public readonly int MaxMana;
public int Potions;
public readonly Dictionary<string, int> Cooldowns = new();
public Fighter(string name, int hp, int power, int mana, int potions)
{
Name = name;
Hp = hp;
MaxHp = hp;
Power = power;
Mana = mana;
MaxMana = mana;
Potions = potions;
}
public bool Alive() => Hp > 0;
public int CooldownOf(string skillId) => Cooldowns.GetValueOrDefault(skillId, 0);
// Pasa un turno: cada recarga se acorta en uno.
public void Tick()
{
foreach (string skillId in new List<string>(Cooldowns.Keys))
{
Cooldowns[skillId] = Math.Max(0, Cooldowns[skillId] - 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 battle = NewBattle(7);
Fighter hero = battle.Hero();
for (int turn = 1; turn <= 6; turn++)
{
hero.Tick();
Console.WriteLine("Turno " + turn + ", maná " + hero.Mana);
Skill? chosen = null;
foreach (Skill skill in battle.Skills)
{
string why = Problem(battle, hero, skill);
Console.WriteLine(" " + skill.Name + ": " + (why == "" ? "lista" : why));
if (why == "" && chosen == null) chosen = skill;
}
if (chosen == null)
{
Console.WriteLine(" nada disponible");
continue;
}
Console.WriteLine(" -> usa " + chosen.Name);
battle.Perform(new SkillAction(hero, chosen, battle.AliveEnemies()[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.

Actor.Cooldowns[Skill.Id] = ;

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

public static List<Skill> UsableSkills(Hero hero)
{
List<Skill> result = new List<Skill>();
foreach (string skillId in hero.Skills)
{
Skill skill = SKILLS.Get(skillId);
if (hero.Mana >= skill.Mana && hero.CooldownOf(skill.Id) == 0) result.Add(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.
static Fighter? Aim(Battle battle, string[] parts, int index)
{
List<Fighter> alive = battle.AliveEnemies();
if (parts.Length <= index) return alive[0];
string text = parts[index];
if (text.Length != 1 || !char.IsDigit(text[0])) return null;
int number = text[0] - '0';
return number >= 1 && number <= alive.Count ? alive[number - 1] : null;
}
// Un comando no válido no cuesta turno: dice por qué y vuelve a preguntar.
static IBattleAction? Refuse(Battle battle, string line, string reason)
{
Console.WriteLine("? " + line.Trim() + ": " + reason);
return null;
}
static IBattleAction? Parse(Battle battle, Fighter hero, string line)
{
string[] parts = line.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
string word = parts.Length > 0 ? parts[0] : "";
if (word == "attack")
{
Fighter? target = Aim(battle, parts, 1);
return target == null ? Refuse(battle, line, "no existe ese objetivo") : new AttackAction(hero, target);
}
if (word == "skill")
{
Skill? skill = parts.Length > 1 ? SkillOf(battle, parts[1]) : null;
if (skill == null) return Refuse(battle, line, "habilidad desconocida");
string why = Problem(battle, hero, skill);
if (why != "") return Refuse(battle, line, why);
Fighter? target = Aim(battle, parts, 2);
return target == null ? Refuse(battle, line, "no existe ese objetivo") : new SkillAction(hero, skill, target);
}
if (word == "item")
{
return hero.Potions > 0 ? new ItemAction(hero, "potion") : Refuse(battle, line, "no hay pociones");
}
if (word == "flee") return new 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 battle = NewBattle(7);
battle.Live = true;
var lines = new List<string>();
string? line;
while ((line = Console.ReadLine()) != null)
{
if (line.Trim() != "") lines.Add(line);
}
Run(battle, new 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
{
public Battle Battle;
public string Note;
public TargetScreen(Game game, Battle battle) : base(game)
{
Battle = battle;
Note = "";
}
public override bool IsTransparent()
{
return true;
}
public override void Draw(CellBuffer buffer)
{
List<Enemy> enemies = Battle.AliveEnemies();
buffer.Box(24, 9, 36, enemies.Count + 4, T("target.title"));
for (int i = 0; i < enemies.Count; i++)
{
buffer.Text(26, 10 + i, (i + 1) + ") " + Pad(enemies[i].Name, 22) + " " + enemies[i].Hp + "/" + enemies[i].MaxHp(), enemies[i].Kind.Color);
}
buffer.Text(26, 11 + enemies.Count, Note.Length > 0 ? Note : T("target.help"), "gray");
}
public override void Handle(string command)
{
if (command == "x")
{
Game.Stack.Pop(null);
return;
}
long? choice = ParseIntStrict(command);
if (choice == null || choice < 1 || choice > Battle.AliveEnemies().Count)
{
Note = T("ui.unknown", ("command", command));
return;
}
Game.Stack.Pop((int)choice.Value - 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:

public override void OnResult(object? result)
{
Battle battle = Battle;
Hero hero = battle.Hero;
PendingChoice? pending = Pending;
Pending = null;
if (result == null || pending == null) return;
int index = (int)result;
if (pending.Kind == "attack")
{
battle.HeroAct(new AttackAction(hero, battle.AliveEnemies()[index]));
}
else if (pending.Kind == "skill")
{
Skill skill = SKILLS.Get(hero.Skills[index]);
if (hero.Mana < skill.Mana)
{
Note = T("battle.noMana", ("skill", Nm(skill)));
}
else if (hero.CooldownOf(skill.Id) > 0)
{
Note = T("battle.cooldown", ("skill", Nm(skill)), ("turns", hero.CooldownOf(skill.Id)));
}
else if (skill.Target == "one" && battle.AliveEnemies().Count > 1)
{
Pending = new PendingChoice { Kind = "skillTarget", Skill = skill };
Game.Stack.Push(new TargetScreen(Game, battle));
}
else
{
battle.HeroAct(new SkillAction(hero, skill, battle.AliveEnemies()[0]));
}
}
else if (pending.Kind == "skillTarget")
{
battle.HeroAct(new SkillAction(hero, pending.Skill!, battle.AliveEnemies()[index]));
}
else
{
battle.HeroAct(new ItemAction(hero, PotionIds()[index]));
}
}

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

// Quién elige el comando: la inteligencia artificial, el jugador o el registro.
interface IChooser
{
IBattleAction? Choose(Battle battle, Fighter actor);
}
class Auto : IChooser
{
public IBattleAction? Choose(Battle battle, Fighter actor)
{
return actor == battle.Hero() ? ChooseHero(battle) : ChooseEnemy(battle, actor);
}
}
class Player : IChooser
{
readonly List<string> lines;
int next = 0;
public Player(List<string> lines)
{
this.lines = lines;
}
public IBattleAction? Choose(Battle battle, Fighter actor)
{
if (actor != battle.Hero()) return ChooseEnemy(battle, actor);
while (next < lines.Count)
{
IBattleAction? action = Parse(battle, actor, lines[next++]);
if (action != null) return action;
}
return null;
}
}
class Replay : IChooser
{
readonly List<ActionRecord> record;
int next = 0;
public Replay(List<ActionRecord> record)
{
this.record = record;
}
public IBattleAction? Choose(Battle battle, Fighter actor)
{
if (next >= record.Count) return null;
return ActionFromRecord(battle, record[next++]);
}
}
static Fighter Weakest(Battle battle)
{
Fighter? best = null;
foreach (Fighter enemy in battle.AliveEnemies())
{
if (best == null || enemy.Hp < best.Hp) best = enemy;
}
return best!;
}
static IBattleAction ChooseEnemy(Battle battle, Fighter enemy)
{
return new AttackAction(enemy, battle.Hero());
}
static IBattleAction ChooseHero(Battle battle)
{
Fighter hero = battle.Hero();
Fighter target = Weakest(battle);
if (hero.Hp * 100 / hero.MaxHp < 35 && hero.Potions > 0) return new ItemAction(hero, "potion");
List<Skill> usable = UsableSkills(battle, hero);
foreach (Skill skill in usable)
{
if (skill.Target == "all" && battle.AliveEnemies().Count >= 2) return new SkillAction(hero, skill, target);
}
foreach (Skill skill in usable)
{
if (skill.Target == "one") return new SkillAction(hero, skill, target);
}
return new 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.
static void Run(Battle battle, IChooser chooser)
{
while (!battle.Over && battle.Rounds < 12)
{
battle.Rounds++;
battle.Say("== Ronda " + battle.Rounds + " ==");
foreach (Fighter actor in battle.Fighters)
{
if (battle.Over) break;
if (!actor.Alive()) continue;
actor.Tick();
IBattleAction? action = chooser.Choose(battle, actor);
if (action == null)
{
battle.Over = true;
break;
}
battle.Perform(action);
}
}
}
static IBattleAction ActionFromRecord(Battle battle, ActionRecord r)
{
Fighter actor = battle.Fighters[r.Actor];
Fighter target = r.Target >= 0 ? battle.Fighters[r.Target] : null!;
if (r.Action == "attack") return new AttackAction(actor, target);
if (r.Action == "skill") return new SkillAction(actor, SkillOf(battle, r.Id)!, target);
if (r.Action == "item") return new ItemAction(actor, r.Id);
return new FleeAction(actor);
}
static string Describe(ActionRecord r)
{
return r.Actor + " " + r.Action + " " + r.Target + (r.Id == "" ? "" : " " + r.Id);
}
// La repetición: un comienzo nuevo con la misma semilla, y el registro en lugar de una elección.
static Battle ReplayBattle(int seed, List<ActionRecord> record)
{
Battle battle = NewBattle(seed);
Run(battle, new Replay(record));
return battle;
}
static bool SameLines(List<string> first, List<string> second)
{
if (first.Count != second.Count) return false;
for (int i = 0; i < first.Count; i++)
{
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.
public void Perform(IBattleAction action)
{
Record.Add(action.ToRecord(this));
action.Execute(this);
CheckEnd();
}
// Un combate: rondas, y en cada ronda todos juegan por turno. El comando viene del chooser.
static void Run(Battle battle, IChooser chooser)
{
while (!battle.Over && battle.Rounds < 12)
{
battle.Rounds++;
battle.Say("== Ronda " + battle.Rounds + " ==");
foreach (Fighter actor in battle.Fighters)
{
if (battle.Over) break;
if (!actor.Alive()) continue;
actor.Tick();
IBattleAction? action = chooser.Choose(battle, actor);
if (action == null)
{
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.
static Battle ReplayBattle(int seed, List<ActionRecord> record)
{
Battle battle = NewBattle(seed);
Run(battle, new Replay(record));
return battle;
}
static bool SameLines(List<string> first, List<string> second)
{
if (first.Count != second.Count) return false;
for (int i = 0; i < first.Count; i++)
{
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 battle = NewBattle(7);
Run(battle, new Auto());
Console.WriteLine("Registro:");
foreach (ActionRecord r in battle.Record)
{
Console.WriteLine(Describe(r));
}
Battle again = ReplayBattle(7, battle.Record);
Console.WriteLine("La misma batalla desde el registro: " + (SameLines(battle.Log, again.Log) ? "sí" : "no"));
Battle other = ReplayBattle(8, battle.Record);
Console.WriteLine("La misma batalla desde otra semilla: " + (SameLines(battle.Log, other.Log) ? "sí" : "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)
public static IBattleAction ActionFromRecord(Battle battle, ActionRecord record)
{
Creature actor = battle.CreatureAt(record.Actor);
Creature? target = record.Target >= 0 ? battle.CreatureAt(record.Target) : null;
if (record.Action == "attack") return new AttackAction(actor, target);
if (record.Action == "skill") return new SkillAction(actor, SKILLS.Get(record.Id), target);
if (record.Action == "item") return new ItemAction(actor, record.Id);
if (record.Action == "flee") return new FleeAction(actor);
return new SpecialAction(actor, record.Id, target);
}
// Devuelve el registro del combate repetido.
public static List<string> ReplayBattle(BattleSnapshot snapshot, List<ActionRecord> record)
{
ReplayWorld world = new ReplayWorld(snapshot);
List<Enemy> enemies = new List<Enemy>();
foreach (Enemy enemy in snapshot.Enemies) enemies.Add(enemy.Copy());
Battle battle = new Battle(world, enemies, snapshot.CanFlee);
battle.Script = record;
battle.Start();
battle.Advance();
return battle.Log;
}
public static bool SameLines(List<string> first, List<string> second)
{
if (first.Count != second.Count) return false;
for (int i = 0; i < first.Count; i++)
{
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:

Console.WriteLine("Simulación sin pantalla");
int wins = 0;
int losses = 0;
int commands = 0;
int total = 1000;
for (int seed = 1; seed <= total; seed++)
{
Battle battle = NewBattle(seed);
Run(battle, new Auto());
if (battle.Outcome == "win") wins++;
else losses++;
commands += battle.Record.Count;
}
int average = commands * 10 / total;
Console.WriteLine("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:

public static SimResult SimulateFight(Game game, HeroClass heroClass, EnemyKind kind)
{
game.Hero = MakeSimHero(game, heroClass, SIM_LEVELS[kind.Depth]);
Battle battle = new Battle(game, new List<Enemy> { new Enemy(game.NewId(), kind) }, false);
battle.AutoHero = true;
battle.Advance();
return new SimResult { Win = battle.Outcome == "win", Turns = battle.HeroTurns };
}

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 battle = NewBattle(7);
Fighter hero = battle.Hero();
for (int turn = 1; turn <= 5; turn++)
{
hero.Tick();
string used = "-";
foreach (Skill skill in UsableSkills(battle, hero)) used = skill.Id;
if (used != "-")
{
battle.Perform(new SkillAction(hero, SkillOf(battle, used)!, battle.Fighters[1]));
}
Console.WriteLine(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 : IBattleAction
{
public readonly Fighter Actor;
public readonly Skill Skill;
public readonly Fighter Target;
public SkillAction(Fighter actor, Skill skill, Fighter target)
{
Actor = actor;
Skill = skill;
Target = target;
}
public ActionRecord ToRecord(Battle battle)
{
return new ActionRecord(battle.IndexOf(Actor), "attack", battle.IndexOf(Target), Skill.Id);
}
public void Execute(Battle battle)
{
Actor.Mana -= Skill.Mana;
Actor.Cooldowns[Skill.Id] = Skill.Cooldown;
battle.Say(Actor.Name + ": " + Skill.Name);
List<Fighter> targets = Skill.Target == "all" ? battle.AliveEnemies() : new List<Fighter> { Target };
foreach (Fighter victim in targets)
{
battle.Hit(Actor, victim, Actor.Power * Skill.Percent / 100 + battle.Rng.Roll(3));
}
}
}
Battle battle = NewBattle(7);
Run(battle, new Auto());
Console.WriteLine("Registro:");
foreach (ActionRecord r in battle.Record)
{
Console.WriteLine(Describe(r));
}
Battle again = ReplayBattle(7, battle.Record);
Console.WriteLine("La misma batalla desde el registro: " + (SameLines(battle.Log, again.Log) ? "sí" : "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 ·