Ir al contenido
wannacode

El sistema de combate

En resumen: Un combate avanza en tics: cada uno suma su rapidez a una barra de energía, quien llega a 100 actúa y paga 100. Un golpe es puntería contra esquiva, daño = arma + fuerza − defensa (al menos 1), un golpe crítico lo duplica y el tipo de daño decide lo que sigue.

Problema

El lobo es más rápido que el gólem

En la mina el héroe se encuentra a la vez con un lobo sangriento y un goblin. ¿Quién golpea primero y cuántas veces? La primera respuesta es por rondas: el héroe, luego cada enemigo por orden, y otra vez desde el principio. Pero el lobo es el doble de rápido que el gólem de mena (rapidez 14 contra 7). Si los dos golpean una vez por ronda, la rapidez no significa nada, y un jugador que puso puntos en ella no recibe nada.

Por eso el orden es otro, y un golpe tiene sus propias reglas. Esta es una pantalla de combate del juego (del recorrido con el que se comprueba el juego): el héroe se enfrenta a una rata gigante, el registro guarda las últimas líneas y el golpe del héroe ha fallado.

=== Combate ===
Iva, bárbaro nv.1
V [###########-] 46/48 M [########] 4/4
1) Rata gigante [########] 9/9
¡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).
1 golpe 2 habilidad 3 objeto 4 huida

Para este fotograma hacen falta cinco cosas: el orden de los turnos, puntería contra esquiva, daño, tipos de daño y palabras en el registro. La lección las construye una a una con programas pequeños, luego las junta en un combate del héroe contra un lobo y un goblin, y muestra lo mismo en el juego. Los combatientes aquí traen valores ya hechos: cómo se componen a partir de la base, el equipo y los efectos está en la lección sobre criaturas.

La idea

Combatientes con valores ya hechos

Un combatiente es una clase con valores, vida y una barra de energía:

class Fighter {
final String name;
final int maxHp;
final int strength;
final int agility;
final int defense;
final int speed;
final int weaponMin;
final int weaponMax;
final String kind;
final String resist;
final String weak;
int hp;
int energy;
Fighter(String name, int maxHp, int strength, int agility, int defense, int speed,
int weaponMin, int weaponMax, String kind, String resist, String weak) {
this.name = name;
this.maxHp = maxHp;
this.strength = strength;
this.agility = agility;
this.defense = defense;
this.speed = speed;
this.weaponMin = weaponMin;
this.weaponMax = weaponMax;
this.kind = kind;
this.resist = resist;
this.weak = weak;
this.hp = maxHp;
}
int accuracy() {
return 75 + agility * 2;
}
int evasion() {
return agility * 2;
}
int critChance() {
return 5 + agility / 10;
}
boolean isAlive() {
return hp > 0;
}
}

De la destreza salen tres números para el combate:

  • puntería = 75 + 2 × destreza: qué bien aciertas;
  • esquiva = 2 × destreza: qué difícil es acertarte;
  • probabilidad de crítico = 5 + destreza / 10 (división entera): todo golpe tiene al menos un 5% de probabilidad.

El lobo tiene destreza 8 y el héroe 4: la puntería del héroe es 75 + 8 = 83, la esquiva del lobo es 16. En el juego las mismas tres fórmulas están en Creature, pero los valores vienen de las capas (base + equipo + efectos):

// El valor en bruto (puede ser negativo): base + equipo + efectos.
int rawStat(String stat) {
int value = this.base.get(stat) + this.gearBonus(stat);
for (Effect effect : this.effects) value += effect.modifier(stat);
return value;
}
int stat(String stat) {
int value = this.rawStat(stat);
return value < 0 ? 0 : value;
}
int maxHp() {
int value = this.stat("maxHp");
return value < 1 ? 1 : value;
}
int maxMana() {
return this.stat("maxMana");
}
// Con menos del 25 % de vida la herida es grave: los golpes se vuelven menos certeros.
boolean isHeavilyWounded() {
return this.hp * 4 < this.maxHp();
}
int accuracy() {
return 75 + this.stat("agility") * 2 + this.rawStat("accuracy") - (this.isHeavilyWounded() ? 15 : 0);
}
int evasion() {
return this.stat("agility") * 2 + this.rawStat("evasion");
}
int critChance() {
return 5 + this.stat("agility") / 10;
}

La idea

Energía en vez de rondas

En vez de rondas, cada combatiente tiene una barra de energía. El mundo avanza en tics: en cada tic todos suman su rapidez a la barra. Quien llega a 100 puede actuar; al actuar, paga 100. El más rápido llega a 100 más a menudo, así que actúa más a menudo, y nadie espera a la «ronda».

Fighter nextActor() {
while (true) {
Fighter best = null;
for (Fighter f : participants()) {
if (f.energy >= 100 && (best == null || f.energy > best.energy)) best = f;
}
if (best != null) return best;
for (Fighter f : participants()) f.energy += Math.max(1, f.speed);
ticks++;
}
}

Estas son las reglas en el orden en que las lee el código:

  1. Entre los combatientes vivos se busca el que tiene más energía de los que están en 100 o más. En caso de empate gana el que va antes en la lista, y el héroe va primero. (Se compara con >, no con >=: uno posterior tiene que estar estrictamente más lleno.)
  2. Si no hay ninguno, empieza un tic: todos suman max(1, speed). El límite inferior 1 protege el bucle: un combatiente con rapidez 0 o menos (un efecto de ralentización) nunca llegaría a 100, y el bucle sería infinito.
  3. El ganador no se pone a cero, paga 100. El resto conserva su ventaja.

El programa alinea al héroe (rapidez 10), al lobo (14) y al goblin (10) e imprime los primeros siete turnos:

Fighter hero = new Fighter("Iva", 48, 7, 4, 3, 10, 4, 7, "slash", "", "");
Fighter wolf = new Fighter("Lobo sangriento", 20, 3, 8, 1, 14, 2, 5, "slash", "", "");
Fighter goblin = new Fighter("Goblin lancero", 16, 2, 5, 1, 10, 2, 5, "pierce", "", "");
Battle battle = new Battle(List.of(hero, wolf, goblin));
for (int i = 0; i < 7; i++) {
Fighter actor = battle.nextActor();
System.out.println("Tic " + battle.ticks + ": " + actor.name + " actúa (energía " + actor.energy + ").");
actor.energy -= 100;
}
Salida
Tic 8: Lobo sangriento actúa (energía 112).
Tic 10: Iva actúa (energía 100).
Tic 10: Goblin lancero actúa (energía 100).
Tic 15: Lobo sangriento actúa (energía 110).
Tic 20: Iva actúa (energía 100).
Tic 20: Goblin lancero actúa (energía 100).
Tic 22: Lobo sangriento actúa (energía 108).

El lobo es el primero en el tic 8 (14 × 8 = 112). En el tic 10 el héroe y el goblin están en 100 a la vez: el héroe va antes en la lista, y el goblin actúa justo después, sin un tic nuevo. El lobo vuelve a actuar en el tic 15, no en el 16: de su primer turno le quedan 12.

Míralo

La cola en movimiento

Las tres barras crecen tic a tic. Fíjate en la línea de 100 y en las fichas numeradas de abajo:

Fighter nextActor() {
while (true) {
Fighter best = null;
for (Fighter f : participants()) {
if (f.energy >= 100 && (best == null || f.energy > best.energy)) best = f;
}
if (best != null) return best;
for (Fighter f : participants()) f.energy += Math.max(1, f.speed);
ticks++;
}
}
// en el bucle del combate
Fighter actor = battle.nextActor();
actor.energy -= 100;
Paso a paso, en palabras
  1. Tres combatientes: Iva, Lobo sangriento y Goblin lancero. Cada uno tiene una barra de energía que empieza en 0 y una rapidez (10, 14, 10). Quien llega a 100 actúa.
  2. Todavía nadie puede actuar, así que el mundo hace un tic: cada uno suma su rapidez a la barra (al menos 1). Las barras crecen a distinta rapidez.
  3. Tic 8: Lobo sangriento está en 112. Llega primero a 100 y actúa. Paga 100, no todo: le quedan 12.
  4. Tic 10: Iva y Goblin lancero están los dos en 100. Empate: actúa el que va antes en la lista, y el héroe va primero.
  5. El héroe pagó y Goblin lancero sigue en 100. Sin tic nuevo: la siguiente llamada a nextActor lo devuelve enseguida.
  6. Tic 15: Lobo sangriento actúa otra vez, ahora con 110. El resto del primer turno le ahorra un tic: sin él esperaría hasta el tic 16.
  7. Tras 22 tics: Lobo sangriento actuó 3 veces, Iva y Goblin lancero 2 veces cada uno. La rapidez es el ritmo de la barra, no un número de turnos: así el más rápido actúa más a menudo y no hay «rondas».

En el juego la misma cola está al principio de la clase Battle, y un turno empieza pagando 100:

Creature nextActor() {
while (true) {
Creature best = null;
for (Creature creature : this.participants()) {
if (creature.energy >= 100 && (best == null || creature.energy > best.energy)) best = creature;
}
if (best != null) return best;
for (Creature creature : this.participants()) creature.energy += Math.max(1, creature.stat("speed"));
}
}
// El inicio de un turno: los efectos actúan, las recargas bajan. Un combatiente aturdido pierde el turno.
boolean beginTurn(Creature actor) {
actor.energy -= 100;
boolean stunned = actor.hasEffect("stun");
tickEffects(actor, this);
actor.tickCooldowns();
this.handleDeaths();
if (!actor.isAlive()) return false;
// Tras un aturdimiento el siguiente turno está asegurado: si no, tres enemigos con garrotes dejarían al héroe clavado.
actor.stunProof = stunned;
if (stunned) {
this.say(t("combat.stunned", "name", actor.name));
return false;
}
return true;
}

La primera línea de beginTurn es actor.energy -= 100. Después vienen los efectos (sangrado, veneno), las recargas y saltarse el turno si estás aturdido: son los temas de las próximas lecciones (sangre, heridas y efectos). Lo importante aquí es que el orden de los turnos está separado del golpe en sí: nextActor dice quién actúa, y lo que hace es otra cuestión.

Más precisamente, para curiosos

Esta cola es una cola de prioridad: el siguiente es el de mayor prioridad (más energía). Aquí se hace recorriendo a todos los combatientes vivos en cada llamada. En un combate hay cuatro como mucho, así que son unas pocas comparaciones. Con cientos de participantes (la batalla de ejércitos) se usa un heap binario: el siguiente sale en O(log n), no en O(n).

Los tics también se pueden saltar: en vez de sumar una rapidez cada vez, se calcula para cada uno ⌈(100 − energía) / rapidez⌉ tics hasta estar listo, se toma el menor y se adelanta a todos de golpe. El resultado es el mismo, sin bucle. El canon mantiene el bucle simple: se lee de un vistazo y basta para cuatro.

El juego tiene un límite de 200 turnos del héroe por combate (heroTurns): si algo en las reglas se atasca, el combate acaba como derrota en vez de colgarse para siempre.

La idea

Acierto o fallo

Un golpe no siempre acierta. La probabilidad es la puntería del atacante menos la esquiva del objetivo, limitada entre 10 y 95:

static int clamp(int value, int low, int high) {
if (value < low) return low;
if (value > high) return high;
return value;
}
static int hitChance(int accuracy, int evasion) {
return clamp(accuracy - evasion, 10, 95);
}

Limitar (clamp) significa que todo golpe tiene al menos un 10% de probabilidad de acertar y al menos un 5% de fallar: un objetivo ágil no es intocable, y el golpe más certero no es seguro. Después el combate tira un dado de 1 a 100 y falla si el resultado está por encima de la probabilidad.

El programa muestra cuatro pares (puntería, esquiva) y cinco tiradas con la semilla 2026 contra la probabilidad del héroe frente al lobo, 83 − 16 = 67%:

int[][] pairs = {{83, 16}, {91, 8}, {83, 80}, {120, 0}};
for (int[] pair : pairs) {
System.out.println(pair[0] + " - " + pair[1] + " = " + (pair[0] - pair[1]) + ", probabilidad " + hitChance(pair[0], pair[1]) + "%");
}
int chance = hitChance(83, 16);
Random dice = new Random(2026);
for (int i = 0; i < 5; i++) {
int roll = dice.range(1, 100);
System.out.println("sacas " + roll + (roll > chance ? " — fallo" : " — golpe"));
}
Salida
83 - 16 = 67, probabilidad 67%
91 - 8 = 83, probabilidad 83%
83 - 80 = 3, probabilidad 10%
120 - 0 = 120, probabilidad 95%
sacas 76 — fallo
sacas 59 — golpe
sacas 23 — golpe
sacas 37 — golpe
sacas 58 — golpe

Los dados son el generador del canon (Random, mulberry32): con la misma semilla salen los mismos números en los cuatro lenguajes. Por eso un combate con semilla es repetible: se puede grabar, reproducir y comprobar con una prueba.

static int hitChance(int accuracy, int evasion) {
    return clamp(accuracy - evasion, , 95);
}

La idea

Daño y golpe crítico

Si aciertas, el daño es:

arma + fuerza − defensa, al menos 1.

El arma da un número de un intervalo (el hacha de mano del héroe es 4–7), se suma la fuerza del atacante y se resta la defensa del objetivo. El último límite importa: un golpe débil contra una armadura sólida no cura al objetivo ni es cero, se queda en 1.

Un golpe crítico tiene una probabilidad de 5% + destreza / 10 (y otro 10% con un arma perforante) y duplica arma y fuerza. La defensa se resta después de duplicar, así que la armadura quita lo mismo también en un crítico:

static int damageOf(int weapon, int strength, int defense, boolean crit) {
int damage = weapon + strength;
if (crit) damage *= 2;
damage -= defense;
if (damage < 1) damage = 1;
return damage;
}
static void show(String label, int weapon, int strength, int defense, boolean crit) {
String sum = weapon + " + " + strength;
if (crit) sum = "(" + sum + ") * 2";
System.out.println(label + ": " + sum + " - " + defense + " -> " + damageOf(weapon, strength, defense, crit));
}
show("golpe normal", 6, 7, 1, false);
show("golpe crítico", 6, 7, 1, true);
show("golpe débil contra armadura", 2, 1, 6, false);
for (int agility : new int[] {4, 8, 25, 40}) {
System.out.println("golpe crítico: destreza " + agility + " -> " + (5 + agility / 10) + "%");
}
Salida
golpe normal: 6 + 7 - 1 -> 12
golpe crítico: (6 + 7) * 2 - 1 -> 25
golpe débil contra armadura: 2 + 1 - 6 -> 1
golpe crítico: destreza 4 -> 5%
golpe crítico: destreza 8 -> 5%
golpe crítico: destreza 25 -> 7%
golpe crítico: destreza 40 -> 9%

La primera línea es el golpe del héroe contra el lobo (defensa 1). La segunda es el mismo golpe, pero crítico: 25, más del doble. La tercera es un golpe débil contra armadura 6: la resta da −3, y el juego devuelve 1. Las últimas cuatro líneas son la probabilidad de crítico con distinta destreza: crece despacio, un 1% cada diez puntos.

La idea

Tipos de daño

Un golpe tiene un tipo: cortante, perforante, contundente, fuego, veneno. El tipo dice dos cosas. Primero, cómo reacciona el objetivo: algunos seres son resistentes (el daño se divide entre 2), otros son débiles (se multiplica por 3/2). Segundo, qué sigue al golpe: lo cortante sangra, lo contundente aturde, el fuego quema, el veneno envenena (los efectos son el tema de la próxima lección).

static int resisted(Fighter target, String kind, int damage) {
if (target.resist.equals(kind)) return damage / 2;
if (target.weak.equals(kind)) return damage * 3 / 2;
return damage;
}

Un esqueleto es resistente a lo cortante y débil a lo contundente. Un golpe de 12 contra él da:

Fighter skeleton = new Fighter("Esqueleto", 28, 4, 4, 2, 10, 3, 6, "slash", "slash", "blunt");
String[] kinds = {"slash", "pierce", "blunt", "fire", "poison"};
String[] names = {"cortante", "perforante", "contundente", "fuego", "veneno"};
for (int i = 0; i < kinds.length; i++) {
System.out.println(names[i] + ": 12 -> " + resisted(skeleton, kinds[i], 12));
}
Salida
cortante: 12 -> 6
perforante: 12 -> 12
contundente: 12 -> 18
fuego: 12 -> 12
veneno: 12 -> 12

Las armas contundentes (la maza, el bastón) rompen huesos, mientras que una espada resbala. Por eso no toda arma sirve contra todo enemigo. En la tabla de enemigos de los datos del juego hay dos columnas para esto: resist y weak. Después del daño el juego comprueba el tipo del golpe y aplica un efecto:

static void afterHit(Battle battle, Creature target, String type, boolean crit) {
if (!target.isAlive()) return;
Random rng = battle.game.rng;
if (type.equals("slash") && (crit || rng.chance(35))) applyEffect(target, makeEffect("bleed", 3, 1), battle);
else if (type.equals("blunt") && rng.chance(10)) applyEffect(target, makeEffect("stun", 1, 0), battle);
else if (type.equals("fire") && rng.chance(30)) applyEffect(target, makeEffect("burn", 3, 2), battle);
else if (type.equals("poison")) applyEffect(target, makeEffect("poison", 3, 2), battle);
}

En el canon resist y weak de un enemigo son listas de tipos (el esqueleto es resistente a lo cortante, el gólem a lo perforante y al fuego), no un solo tipo. Aquí es una cadena, para que el código sea corto.

La idea

La palabra en vez del número

El registro del combate no escribe «daño 12». Escribe «Iva aplasta al lobo (12)». La palabra para la fuerza del golpe no depende del número, sino de la parte de la vida del objetivo que quita:

static int percent(int a, int b) {
return a * 100 / b;
}
static String damageWord(int damage, int maxHp) {
int share = percent(damage, maxHp);
if (share < 10) return "araña a";
if (share < 25) return "golpea a";
if (share < 45) return "corta a";
return "aplasta a";
}
for (int damage : new int[] {1, 2, 4, 5, 8, 9, 10, 12}) {
System.out.println(damage + " de 20: " + percent(damage, 20) + "%, " + damageWord(damage, 20));
}
Salida
1 de 20: 5%, araña a
2 de 20: 10%, golpea a
4 de 20: 20%, golpea a
5 de 20: 25%, corta a
8 de 20: 40%, corta a
9 de 20: 45%, aplasta a
10 de 20: 50%, aplasta a
12 de 20: 60%, aplasta a

Por debajo del 10% es un arañazo, por debajo del 25% un golpe, por debajo del 45% un corte, y por encima un aplastamiento. Así, 12 de daño son un aplastamiento para un lobo con 20 de vida, pero solo un corte para el héroe con 48. El jugador entiende que el golpe es duro sin comparar números. Esta es la función en el juego (las palabras son claves de los textos en los cuatro idiomas):

// La palabra para la fuerza de un golpe depende de cuánta vida le quita al objetivo.
static String damageWord(int damage, int maxHp) {
int share = percent(damage, maxHp);
if (share < 10) return t("verb.scratch");
if (share < 25) return t("verb.hit");
if (share < 45) return t("verb.cut");
return t("verb.crush");
}

Míralo

El combate entero

Ahora todo se junta. Un golpe comprueba la puntería, tira daño y crítico, resta la defensa, aplica la resistencia, no admite menos de 1, lo dice con una palabra y baja la vida:

static boolean strike(Battle battle, Random dice, Fighter attacker, Fighter target) {
int hitChance = clamp(attacker.accuracy() - target.evasion(), 10, 95);
if (dice.range(1, 100) > hitChance) {
battle.say(attacker.name + " no alcanza a " + target.name + ".");
return false;
}
int damage = dice.range(attacker.weaponMin, attacker.weaponMax) + attacker.strength;
int critChance = attacker.critChance();
if (attacker.kind.equals("pierce")) critChance += 10;
boolean crit = dice.chance(critChance);
if (crit) damage *= 2;
damage -= target.defense;
damage = resisted(target, attacker.kind, damage);
if (damage < 1) damage = 1;
String line = attacker.name + " " + damageWord(damage, target.maxHp) + " " + target.name + " (" + damage + ").";
if (crit) line += " ¡La sangre salpica!";
battle.say(line);
target.hp = Math.max(0, target.hp - damage);
return true;
}

El combate es un bucle: nextActor, pagar 100, el golpe del héroe al primer enemigo vivo o el golpe de un enemigo al héroe, una palabra para la caída, y el final cuando un bando ya no está vivo. El héroe (Iva) contra un lobo y un goblin, con la semilla 16:

Random dice = new Random(16);
Fighter hero = new Fighter("Iva", 48, 7, 4, 3, 10, 4, 7, "slash", "", "");
Fighter wolf = new Fighter("Lobo sangriento", 20, 3, 8, 1, 14, 2, 5, "slash", "", "");
Fighter goblin = new Fighter("Goblin lancero", 16, 2, 5, 1, 10, 2, 5, "pierce", "", "");
Battle battle = new Battle(List.of(hero, wolf, goblin));
battle.say("¡Combate! Contra ti: " + wolf.name + ", " + goblin.name + ".");
while (true) {
Fighter actor = battle.nextActor();
actor.energy -= 100;
Fighter target = actor == hero ? battle.participants().get(1) : hero;
strike(battle, dice, actor, target);
if (!target.isAlive()) battle.say(target.name + (target == hero ? " cae. La mina se traga la luz." : " cae y no se levanta."));
if (!hero.isAlive() || battle.participants().size() == 1) break;
}
battle.say((hero.isAlive() ? "¡Victoria!" : "Derrota.") + " " + hero.name + ": vida " + hero.hp + "/" + hero.maxHp);
for (String line : battle.log) System.out.println(line);
Salida
¡Combate! Contra ti: Lobo sangriento, Goblin lancero.
Lobo sangriento araña a Iva (4).
Iva aplasta a Lobo sangriento (13).
Goblin lancero no alcanza a Iva.
Lobo sangriento golpea a Iva (7). ¡La sangre salpica!
Iva aplasta a Lobo sangriento (13).
Lobo sangriento cae y no se levanta.
Goblin lancero no alcanza a Iva.
Iva aplasta a Goblin lancero (10).
Goblin lancero araña a Iva (2).
Iva aplasta a Goblin lancero (11).
Goblin lancero cae y no se levanta.
¡Victoria! Iva: vida 35/48

En el registro hay 2 fallos y 1 golpe crítico: el lobo golpea al héroe por 7, más que los 2–5 habituales, y al final el registro dice que la sangre salpica. El registro es una lista de líneas. La pantalla de combate dibuja las últimas:

void draw(CellBuffer buffer) {
Battle battle = this.battle;
Hero hero = battle.hero;
buffer.text(0, 0, "=== " + t("battle.title") + " ===", "yellow");
buffer.text(0, 1, hero.name + ", " + nm(hero.heroClass()) + " " + t("hud.level", "level", hero.level), "white");
buffer.text(2, 2, t("hud.hp"), "red");
buffer.bar(4, 2, 12, hero.hp, hero.maxHp(), "red");
buffer.text(19, 2, hero.hp + "/" + hero.maxHp(), "default");
buffer.text(27, 2, t("hud.mana"), "blue");
buffer.bar(29, 2, 8, hero.mana, hero.maxMana(), "blue");
buffer.text(40, 2, hero.mana + "/" + hero.maxMana(), "default");
// Las líneas van una tras otra: el héroe, los enemigos, el registro, los comandos.
int row = 3;
String heroEffects = effectLabels(hero);
if (heroEffects.length() > 0) {
buffer.text(2, row, heroEffects, "green");
row += 1;
}
List<Enemy> enemies = battle.aliveEnemies();
for (int i = 0; i < enemies.size() && i < 4; i++) {
Enemy enemy = enemies.get(i);
buffer.text(0, row, (i + 1) + ") ", "default");
buffer.text(3, row, pad(cap(enemy.name), 22), enemy.kind.color);
buffer.bar(25, row, 8, enemy.hp, enemy.maxHp(), "red");
buffer.text(36, row, enemy.hp + "/" + enemy.maxHp(), "default");
buffer.text(44, row, effectLabels(enemy), "green");
row += 1;
}
int logRows = 7;
int first = Math.max(0, battle.log.size() - logRows);
for (int i = first; i < battle.log.size(); i++) {
buffer.text(1, row, battle.log.get(i), logColor(battle.log.get(i)));
row += 1;
}
if (battle.over) {
buffer.text(0, row, t(battle.outcome.equals("win") ? "battle.won" : battle.outcome.equals("fled") ? "battle.away" : "battle.lost"), "yellow");
} else {
buffer.text(0, row, this.note.length() > 0 ? this.note : t("battle.commands"), this.note.length() > 0 ? "red" : "white");
}
}

Y el golpe en sí es el mismo en el juego, con dos diferencias: un golpe tiene una potencia (power), con la que las habilidades golpean más fuerte que un golpe normal, y después del daño viene afterHit, que aplica efectos.

static boolean strike(Battle battle, Creature attacker, Creature target, AttackProfile profile, int power) {
Random rng = battle.game.rng;
int hitChance = clamp(attacker.accuracy() - target.evasion(), 10, 95);
if (rng.range(1, 100) > hitChance) {
battle.say(t("combat.miss", "attacker", attacker.name, "target", target.name));
return false;
}
int damage = rng.range(profile.min, profile.max) + attacker.stat("strength");
damage = (damage * power) / 100;
int critChance = attacker.critChance();
if (profile.type.equals("pierce")) critChance += 10;
boolean crit = rng.chance(critChance);
if (crit) damage *= 2;
damage -= target.stat("defense");
damage = resisted(target, profile.type, damage);
if (damage < 1) damage = 1;
String line = t("combat.hit",
"attacker", attacker.name, "verb", damageWord(damage, target.maxHp()), "target", target.name, "damage", damage);
if (crit) line += t(!target.isHero() && ((Enemy) target).isBloodless() ? "combat.critBone" : "combat.critBlood");
battle.say(line);
dealDamage(battle, target, damage);
afterHit(battle, target, profile.type, crit);
return true;
}
Más precisamente, para curiosos

El golpe crítico es ×2, pero un combatiente con lanza (perforante) tiene otro 10% de probabilidad de conseguirlo: en el canon lo perforante es «el arma de los golpes críticos». Aquí es una línea: critChance += 10.

Los conjuros del juego siempre aciertan y la defensa no los detiene (spellHit): solo se calculan con el bonus de quien los lanza y un tipo de daño. Por eso una bruja con una bola de fuego y un bárbaro con un hacha son elecciones distintas, no la versión más débil y la más fuerte del mismo guerrero.

Pruébalo

Predice la salida

El caballero y la maga tienen rapidez 10, el arquero 15. La lista está en este orden: caballero, maga, arquero. ¿Qué imprime el programa?

Battle battle = new Battle(List.of(
new Fighter("Caballero", 30, 5, 5, 1, 10, 3, 6, "slash", "", ""),
new Fighter("Maga", 30, 5, 5, 1, 10, 3, 6, "slash", "", ""),
new Fighter("Arquero", 30, 5, 5, 1, 15, 3, 6, "slash", "", "")));
String order = "";
for (int i = 0; i < 8; i++) {
Fighter actor = battle.nextActor();
actor.energy -= 100;
order += (i > 0 ? ", " : "") + actor.name;
}
System.out.println(order);
Respuesta

Pruébalo

Encuentra el error

Un compañero pensó que la energía debía quedar en cero después del turno y tocó una línea. Esto imprime ahora el programa:

Fighter hero = new Fighter("Iva", 48, 7, 4, 3, 10, 4, 7, "slash", "", "");
Fighter wolf = new Fighter("Lobo sangriento", 20, 3, 8, 1, 14, 2, 5, "slash", "", "");
Fighter goblin = new Fighter("Goblin lancero", 16, 2, 5, 1, 10, 2, 5, "pierce", "", "");
Battle battle = new Battle(List.of(hero, wolf, goblin));
for (int i = 0; i < 7; i++) {
Fighter actor = battle.nextActor();
System.out.println("Tic " + battle.ticks + ": " + actor.name + " actúa (energía " + actor.energy + ").");
actor.energy = 0;
}
Salida
Tic 8: Lobo sangriento actúa (energía 112).
Tic 10: Iva actúa (energía 100).
Tic 10: Goblin lancero actúa (energía 100).
Tic 16: Lobo sangriento actúa (energía 112).
Tic 20: Iva actúa (energía 100).
Tic 20: Goblin lancero actúa (energía 100).
Tic 24: Lobo sangriento actúa (energía 112).

El lobo actúa en el tic 8 y luego en el tic 16, no en el 15 como en la lección. ¿Qué línea tiene la culpa?

Elige la línea con el error.

Más precisamente

Lo que este sistema deja fuera

La cola de energía tiene pocas reglas, pero detrás hay decisiones.

Cada turno cuesta 100. Una acción rápida (un trago de poción) podría costar 50, y una pesada (una habilidad fuerte) 150. Entonces el coste es una propiedad de la acción, y el turno paga energy -= cost. Así el equilibrio se ajusta con más finura, pero el jugador tiene que ver las consecuencias, y para eso necesita la cola en pantalla.

La cola no se ve. La pantalla de combate del canon no muestra quién es el siguiente. Mostrarlo se deduce de la misma regla: se simula nextActor sobre una copia de los combatientes y se escriben los nombres. No lo hacemos porque entonces el jugador planifica más a fondo y el combate es más lento de jugar.

Limitar la probabilidad es una decisión del juego. Con 10–95 todo combate tiene algo de azar. Más estrecho, por ejemplo 30–70, es un combate más ruidoso; más ancho, por ejemplo 1–99, uno más previsible. Los números no son «correctos»: se ajustan con una simulación (la última lección del módulo).

El golpe crítico no debe crecer rápido. El daño doble es fuerte, por eso la probabilidad es 5% + destreza / 10 y no destreza. Con un crecimiento de 1% cada diez puntos, la destreza no convierte un combate en una lotería.

Los dados son una entrada aparte. strike no busca un número aleatorio por sí mismo, sino que recibe el generador. Por eso un combate con semilla es repetible: se escribe una prueba que comprueba un combate entero byte a byte, y se hace una grabación del combate (la lección sobre acciones).

Ejercicio

Una cola a partir de un registro

Resuélvelo en tu editor.

Una cola a partir de un registro. El programa lee en la primera línea de la entrada el número de turnos N, y después un combatiente por línea con la forma nombre rapidez. Los combatientes están en el orden en que se escribieron (ese es el orden en caso de empate). El programa imprime los primeros N turnos como en la lección: el tic, el nombre y la energía del combatiente antes de pagar 100.

Con la entrada 10 | Gólem 7 | Maga 10 | Arquero 15 (una por línea) la salida es:

Salida
Tic 7: Arquero actúa (energía 105).
Tic 10: Maga actúa (energía 100).
Tic 14: Arquero actúa (energía 110).
Tic 15: Gólem actúa (energía 105).
Tic 20: Maga actúa (energía 100).
Tic 20: Arquero actúa (energía 100).
Tic 27: Arquero actúa (energía 105).
Tic 29: Gólem actúa (energía 103).
Tic 30: Maga actúa (energía 100).
Tic 34: Arquero actúa (energía 110).

Fíjate en el tic 20: la maga y el arquero están en 100 a la vez, y actúa el que va antes en la lista. Fíjate también en el tic 14: el arquero vuelve a actuar con 110, porque el resto de su primer turno se conserva.

Más ejercicios

  • Rapidez cero. Dale a un combatiente rapidez 0 y −3. ¿Qué pasa sin max(1, speed)? ¿Y con el límite?
  • La cola en pantalla. Escribe preview(battle, count), que devuelva los nombres de los siguientes count participantes sin cambiar a los combatientes (trabaja con una copia).
  • ¿Cuántos combates gana el héroe? Ejecuta el combate de la lección con semillas de 1 a 1000 y cuenta las victorias. ¿En cuántos hay un golpe crítico? Es el primer paso hacia la simulación de la última lección.

Test

Ponte a prueba

1. Después de su turno, un combatiente paga 100 de energía en vez de volver a cero. ¿Por qué?
2. La puntería es 83 y la esquiva del objetivo es 80. ¿Qué probabilidad hay de acertar?
3. Arma 6, fuerza 7, defensa del objetivo 1, golpe crítico. ¿Cuánto es el daño?
4. Un esqueleto es resistente a lo cortante y débil a lo contundente. ¿Cuánto daño hace un golpe contundente de 12?
5. ¿De qué depende la palabra del registro («araña a», «golpea a», «corta a», «aplasta a»)?

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