Ir al contenido
wannacode

Sangre, heridas y efectos

En resumen: Un efecto es algo que se queda sobre una criatura unos turnos: tiene un momento al aplicarse, otro en cada turno y otro al vencer, y la regla de repetición dice si aplicarlo otra vez renueva, acumula o se ignora.

Problema

Un corte, tres turnos

Un lobo te araña y de la herida corre sangre. No duele una vez: duele en cada turno hasta que la herida se cierra. Un segundo corte no empieza un sangrado nuevo, añade una herida al mismo. Y cuando la vida baja de un cuarto, la mano tiembla y los golpes son menos precisos.

Aquí hay tres turnos del juego real: Iva contra el lobo jefe.

=== Combate ===
Iva, bárbaro nv.2
V [###########-] 62/67 M [########] 5/5
sangx3:2
1) Lobo jefe [########] 36/36
¡Combate! Contra ti: lobo jefe.
Lobo jefe se lanza con los dientes por delante.
Lobo jefe araña a Iva (2).
Iva sangra.
Iva sangra más (heridas: 3).
Iva pierde sangre (3).
1 golpe 2 habilidad 3 objeto 4 huida

Bajo la barra de vida está sangx3:2: sangrado con tres heridas (x3) y dos turnos más. El registro cuenta lo que pasa: el efecto se aplica, sangra más (heridas: 3) y la vida baja 3. De ahí salen esos números.

Esto es un efecto: algo que se queda sobre una criatura unos turnos y actúa durante ellos. El veneno, la quemadura, el aturdimiento, la regeneración, la ira y el escudo son de la misma clase. Para que existan todos, un efecto tiene que saber hacer cinco cosas: tres momentos (al aplicarse, en cada turno, al vencer), una regla de repetición, cambios en los valores, un rastro en el mapa y una herida grave. La lección las construye una a una en programas pequeños y luego muestra lo mismo en el juego.

Más precisamente, para curiosos

La sangre en el juego es una mecánica, no un dibujo. El sangrado es un efecto con números: heridas, turnos, daño. El registro del combate dice la fuerza de un golpe con palabras («araña», «corta») y el sangrado con una frase, sin descripciones de violencia por la violencia. El juego es para estudiantes y adultos, y sigue siéndolo. La sangre está porque le da al jugador una decisión: vendarse o huir, esperar o golpear otra vez.

La idea

Un efecto tiene tres momentos

¿Qué hace el veneno? Tres cosas distintas en tres momentos distintos: cuando se aplica («El veneno se cuela en la sangre»), en cada turno (daño) y cuando vence («Su efecto se acaba»). Por eso un efecto es una clase con tres métodos que por defecto no hacen nada: onApply, onTurn y onExpire. Cada tipo de efecto cambia solo lo que necesita.

static class Effect {
final String id;
int turns;
int power;
int stacks = 1;
Effect(String id, int turns, int power) {
this.id = id;
this.turns = turns;
this.power = power;
}
String stackRule() {
return "refresh";
}
int maxStacks() {
return 1;
}
int modifier(String stat) {
return 0;
}
void onApply(Creature target, Context ctx) {
ctx.say(t("fx.on." + this.id, target.name, 0));
}
void onTurn(Creature target, Context ctx) {}
void onExpire(Creature target, Context ctx) {
ctx.say(t("fx.off." + this.id, target.name, 0));
}
String label() {
String name = t("fxs." + this.id, "", 0);
return this.stacks > 1 ? name + "x" + this.stacks + ":" + this.turns : name + ":" + this.turns;
}
}
static class PoisonEffect extends Effect {
PoisonEffect(String id, int turns, int power) {
super(id, turns, power);
}
@Override
void onTurn(Creature target, Context ctx) {
ctx.say(t("fx.tick.poison", target.name, this.power));
hurt(ctx, target, this.power);
}
}
static class RegenEffect extends Effect {
RegenEffect(String id, int turns, int power) {
super(id, turns, power);
}
@Override
void onTurn(Creature target, Context ctx) {
int healed = target.heal(this.power);
if (healed > 0) ctx.say(t("fx.tick.regen", target.name, healed));
}
}

La clase guarda además un id (con él se encuentran el texto y el efecto en la lista), turns (cuántos turnos quedan), power (la fuerza) y stacks (heridas, capas). label() hace una etiqueta corta como ven:2 para la barra del combate.

Una criatura guarda sus efectos en una lista effects. En cada turno tickEffects recorre la lista: el efecto actúa (onTurn), luego pierde un turno y, cuando se acaban los turnos, se quita y dice onExpire. El programa pone a la misma Iva veneno durante 3 turnos con fuerza 2 y regeneración durante 2 turnos con fuerza 1:

Creature hero = new Creature("Iva", 20, stats(10, 4, 10));
Log log = new Log();
applyEffect(hero, makeEffect("poison", 3, 2), log);
applyEffect(hero, makeEffect("regen", 2, 1), log);
for (int turn = 1; turn <= 3; turn++) {
System.out.println("Turno " + turn);
tickEffects(hero, log);
System.out.println(status(hero));
}
Salida
El veneno se cuela en Iva.
Iva empieza a recuperarse.
Turno 1
El veneno quema a Iva (2).
Iva se recupera (+1).
Vida 19/20 [ven:2 reg:1]
Turno 2
El veneno quema a Iva (2).
Iva se recupera (+1).
La regeneración deja de afectar a Iva.
Vida 18/20 [ven:1]
Turno 3
El veneno quema a Iva (2).
El veneno deja de afectar a Iva.
Vida 16/20

El veneno actúa tres veces (turnos 1, 2 y 3) y vence en el tercero. La regeneración actúa dos veces y vence en el segundo. Los dos efectos corren a la vez, cada uno con sus turnos. Por eso turns = 3 significa «tres acciones», no «hasta que pase el tercer turno».

ctx es el registro: tiene say(texto) y addBlood(cantidad). En el combate es el registro del combate; en el mapa es el propio juego. Así el mismo efecto funciona en los dos sitios, y el registro decide adónde va el texto. Las dos clases y la criatura misma están en el bloque de abajo.

En los ejemplos las clases están anidadas en la clase principal: así Effect ve hurt y t como funciones libres, igual que en el canon.
Más precisamente, para curiosos

El registro, la criatura y los textos. Los textos de los efectos son datos, no código: una clave y una plantilla, como en el canon (t('fx.on.' + this.id, …)). Así el mismo motor escribe en búlgaro, inglés, alemán y español.

interface Context {
void say(String text);
void addBlood(int amount);
}
static class Log implements Context {
int blood = 0;
public void say(String text) {
System.out.println(text);
}
public void addBlood(int amount) {
blood += amount;
}
}
// El registro silencioso no escribe, pero cuenta la sangre.
static class Quiet implements Context {
int blood = 0;
public void say(String text) {}
public void addBlood(int amount) {
blood += amount;
}
}
static class Creature {
final String name;
final int maxHp;
int hp;
final Map<String, Integer> base;
List<Effect> effects = new ArrayList<>();
Creature(String name, int maxHp, Map<String, Integer> base) {
this.name = name;
this.maxHp = maxHp;
this.hp = maxHp;
this.base = base;
}
boolean isAlive() {
return this.hp > 0;
}
int stat(String stat) {
int value = this.base.get(stat);
for (Effect effect : this.effects) value += effect.modifier(stat);
return value < 0 ? 0 : value;
}
// Con menos del 25 % de vida la herida es grave: los golpes son menos precisos.
boolean isHeavilyWounded() {
return this.hp * 4 < this.maxHp;
}
int accuracy() {
return 75 + this.stat("agility") * 2 - (this.isHeavilyWounded() ? 15 : 0);
}
int heal(int amount) {
int before = this.hp;
this.hp = Math.min(this.maxHp, this.hp + amount);
return this.hp - before;
}
Effect findEffect(String id) {
for (Effect effect : this.effects) {
if (effect.id.equals(id)) return effect;
}
return null;
}
void removeEffect(String id) {
List<Effect> kept = new ArrayList<>();
for (Effect effect : this.effects) {
if (!effect.id.equals(id)) kept.add(effect);
}
this.effects = kept;
}
}
static Map<String, Integer> stats(int strength, int defense, int agility) {
return Map.of("strength", strength, "defense", defense, "agility", agility);
}
// Los textos son datos: una clave y una plantilla. {name} es el nombre, {n} es el número.
static final Map<String, String> TEXT = Map.ofEntries(
Map.entry("fx.on.bleed", "{name} sangra."),
Map.entry("fx.on.poison", "El veneno se cuela en {name}."),
Map.entry("fx.on.burn", "{name} se prende fuego."),
Map.entry("fx.on.stun", "{name} pierde el equilibrio."),
Map.entry("fx.on.regen", "{name} empieza a recuperarse."),
Map.entry("fx.on.rage", "¡{name} entra en ira!"),
Map.entry("fx.on.shield", "Un escudo protege a {name}."),
Map.entry("fx.more.bleed", "{name} sangra más (heridas: {n})."),
Map.entry("fx.tick.bleed", "{name} pierde sangre ({n})."),
Map.entry("fx.tick.poison", "El veneno quema a {name} ({n})."),
Map.entry("fx.tick.burn", "Las llamas muerden a {name} ({n})."),
Map.entry("fx.tick.regen", "{name} se recupera (+{n})."),
Map.entry("fx.off.bleed", "Las heridas de {name} dejan de sangrar."),
Map.entry("fx.off.poison", "El veneno deja de afectar a {name}."),
Map.entry("fx.off.burn", "La quemadura deja de afectar a {name}."),
Map.entry("fx.off.regen", "La regeneración deja de afectar a {name}."),
Map.entry("fx.off.rage", "La ira deja de afectar a {name}."),
Map.entry("fx.off.shield", "El escudo deja de afectar a {name}."),
Map.entry("fxs.bleed", "sang"),
Map.entry("fxs.poison", "ven"),
Map.entry("fxs.burn", "fuego"),
Map.entry("fxs.stun", "atur"),
Map.entry("fxs.regen", "reg"),
Map.entry("fxs.rage", "ira"),
Map.entry("fxs.shield", "escu"),
Map.entry("combat.wounded", "¡{name} sufre una herida grave!"));
static String t(String key, String name, int n) {
return TEXT.get(key).replace("{name}", name).replace("{n}", String.valueOf(n));
}

Quiet es un registro que calla pero cuenta la sangre. En el juego es para los enemigos fuera de la pantalla: sangran y mueren sin escribirle nada al jugador. La criatura y sus valores son de la lección sobre las criaturas; aquí necesitamos stat, heal, findEffect y removeEffect.

En el juego es lo mismo. La única diferencia está en los textos: el canon tiene una plantilla común El efecto de {efecto} sobre {nombre} se acaba y toma el nombre del efecto de la tabla, mientras que nuestra tabla tiene una línea por efecto.

static class Effect {
String id;
int turns;
int power;
int stacks;
Effect(String id, int turns, int power) {
this.id = id;
this.turns = turns;
this.power = power;
this.stacks = 1;
}
String stackRule() {
return "refresh";
}
int maxStacks() {
return 1;
}
int modifier(String stat) {
return 0;
}
void onApply(Creature target, Context ctx) {
ctx.say(t("fx.on." + this.id, "name", target.name));
}
void onTurn(Creature target, Context ctx) {}
void onExpire(Creature target, Context ctx) {
ctx.say(t("fx.off", "name", target.name, "effect", t("fx." + this.id)));
}
String label() {
String name = t("fxs." + this.id);
return this.stacks > 1 ? name + "x" + this.stacks + ":" + this.turns : name + ":" + this.turns;
}
}
static class PoisonEffect extends Effect {
PoisonEffect(String id, int turns, int power) {
super(id, turns, power);
}
@Override
void onTurn(Creature target, Context ctx) {
ctx.say(t("fx.tick.poison", "name", target.name, "damage", this.power));
hurt(ctx, target, this.power);
}
}

La idea

Qué pasa cuando un efecto se aplica por segunda vez

El lobo ya está envenenado y le echan más veneno. O ya sangra y lo arañan otra vez. O ya está aturdido y alguien le pega con un garrote. Los tres casos no son iguales, así que cada efecto lleva una regla de repetición:

  • renovar (refresh, la de por defecto): se quedan el tiempo más largo de los dos y la fuerza mayor. El veneno, la quemadura, la regeneración, la ira y el escudo son así;
  • acumular (stack): se añade una capa más, hasta maxStacks(). El sangrado es así;
  • ignorar (ignore): aplicarlo otra vez no hace nada. El aturdimiento es así: si no, tres enemigos con garrotes dejarían al héroe bloqueado para siempre.

La regla la lee el efecto que ya está sobre la criatura (existing) y la aplica applyEffect. La función devuelve true si algo cambió:

// Aplica un efecto según su regla; devuelve si algo cambió.
static boolean applyEffect(Creature target, Effect effect, Context ctx) {
if (!target.isAlive()) return false;
Effect existing = target.findEffect(effect.id);
if (existing == null) {
target.effects.add(effect);
effect.onApply(target, ctx);
return true;
}
String rule = existing.stackRule();
if (rule.equals("ignore")) return false;
if (rule.equals("stack")) {
existing.stacks = Math.min(existing.maxStacks(), existing.stacks + effect.stacks);
ctx.say(t("fx.more." + effect.id, target.name, existing.stacks));
}
existing.turns = Math.max(existing.turns, effect.turns);
existing.power = Math.max(existing.power, effect.power);
return true;
}
// Cada efecto actúa y luego pierde un turno; los vencidos se quitan.
static void tickEffects(Creature creature, Context ctx) {
for (Effect effect : new ArrayList<>(creature.effects)) {
if (!creature.isAlive()) return;
effect.onTurn(creature, ctx);
effect.turns -= 1;
if (effect.turns <= 0) {
creature.removeEffect(effect.id);
effect.onExpire(creature, ctx);
}
}
}

El programa pone seis efectos a un goblin y para cada uno imprime si algo cambió y qué lleva el goblin después:

Creature goblin = new Creature("Goblin", 40, stats(8, 2, 6));
Log log = new Log();
List<Effect> incoming = List.of(
makeEffect("poison", 3, 2),
makeEffect("poison", 5, 1),
makeEffect("bleed", 3, 1),
makeEffect("bleed", 3, 1),
makeEffect("stun", 1, 0),
makeEffect("stun", 2, 0));
for (Effect effect : incoming) {
String label = effect.label();
boolean changed = applyEffect(goblin, effect, log);
System.out.println(label + " -> " + (changed ? "sí" : "no") + " " + status(goblin));
}
Salida
El veneno se cuela en Goblin.
ven:3 -> sí Vida 40/40 [ven:3]
ven:5 -> sí Vida 40/40 [ven:5]
Goblin sangra.
sang:3 -> sí Vida 40/40 [ven:5 sang:3]
Goblin sangra más (heridas: 2).
sang:3 -> sí Vida 40/40 [ven:5 sangx2:3]
Goblin pierde el equilibrio.
atur:1 -> sí Vida 40/40 [ven:5 sangx2:3 atur:1]
atur:2 -> no Vida 40/40 [ven:5 sangx2:3 atur:1]

El segundo veneno alarga el primero (ven:3 pasa a ven:5), sin mensaje nuevo. El segundo sangrado es una herida nueva: sangx2:3. El segundo aturdimiento devuelve no y nada cambia.

// Aplica un efecto según su regla; devuelve si algo cambió.
static boolean applyEffect(Creature target, Effect effect, Context ctx) {
    if (!target.isAlive()) return false;
    Effect existing = target.findEffect(effect.id);
    if (existing == null) {
        target.effects.add(effect);
        effect.onApply(target, ctx);
        return true;
    }
    String rule = existing.stackRule();
    if (rule.equals("ignore")) return false;
    if (rule.equals("stack")) {
        existing.stacks = Math.min(, existing.stacks + effect.stacks);
        ctx.say(t("fx.more." + effect.id, target.name, existing.stacks));
    }
    existing.turns = Math.max(existing.turns, effect.turns);
    existing.power = Math.max(existing.power, effect.power);
    return true;
}

En el juego la función tiene dos líneas más arriba. No se aplica un efecto a una criatura muerta, y algunas criaturas son inmunes (el esqueleto de huesos no tiene sangre para sangrar). La segunda línea protege del aturdimiento sin fin: después de un aturdimiento el siguiente turno está asegurado.

// Aplica un efecto según su regla; devuelve si algo cambió.
static boolean applyEffect(Creature target, Effect effect, Context ctx) {
if (!target.isAlive() || target.isImmune(effect.id)) return false;
if (effect.id.equals("stun") && target.stunProof) return false;
Effect existing = target.findEffect(effect.id);
if (existing == null) {
target.effects.add(effect);
effect.onApply(target, ctx);
return true;
}
String rule = existing.stackRule();
if (rule.equals("ignore")) return false;
if (rule.equals("stack")) {
existing.stacks = Math.min(existing.maxStacks(), existing.stacks + effect.stacks);
ctx.say(t("fx.more." + effect.id, "name", target.name, "stacks", existing.stacks));
}
existing.turns = Math.max(existing.turns, effect.turns);
existing.power = Math.max(existing.power, effect.power);
return true;
}
// El turno de los efectos: cada uno actúa y luego pierde un turno; los que expiran se quitan.
static void tickEffects(Creature creature, Context ctx) {
for (Effect effect : new ArrayList<>(creature.effects)) {
if (!creature.isAlive()) return;
effect.onTurn(creature, ctx);
effect.turns -= 1;
if (effect.turns <= 0) {
creature.removeEffect(effect.id);
effect.onExpire(creature, ctx);
}
}
}
Más precisamente, para curiosos

La regla es un método del efecto, no un campo: un efecto nuevo con stackRule() { return "stack" } y maxStacks() { return 3 } es toda la diferencia. Esta idea se llama polimorfismo: applyEffect no sabe qué efecto es, se lo pregunta. La alternativa es un switch (effect.id) en applyEffect, que crece con cada efecto nuevo y deja las reglas lejos del efecto al que pertenecen.

Míralo

Sangrado: heridas que se acumulan

El sangrado es el efecto para el que existe la regla stack. Cada corte nuevo es una herida más (como máximo 5), cada herida pierde 1 de vida por turno y los turnos vuelven a 3. El daño de un turno es el número de heridas por la fuerza de una herida:

// Cada herida pierde 1 de vida por turno, hasta 5 heridas.
static class BleedEffect extends Effect {
BleedEffect(int turns, int stacks) {
super("bleed", turns, 1);
this.stacks = stacks;
}
@Override
String stackRule() {
return "stack";
}
@Override
int maxStacks() {
return 5;
}
@Override
void onTurn(Creature target, Context ctx) {
int damage = this.stacks * this.power;
ctx.say(t("fx.tick.bleed", target.name, damage));
hurt(ctx, target, damage);
ctx.addBlood(damage);
}
@Override
void onExpire(Creature target, Context ctx) {
ctx.say(t("fx.off.bleed", target.name, 0));
}
}

El constructor recibe turnos y heridas, no fuerza: la fuerza de una herida es siempre 1. Así, makeEffect("bleed", 3, 2) significa «sangrado durante tres turnos con dos heridas»: el tercer número en el sangrado son las heridas. Así un enemigo con un ataque de «desgarro» pone dos heridas de golpe.

// Cada herida pierde 1 de vida por turno, hasta 5 heridas.
static class BleedEffect extends Effect {
BleedEffect(int turns, int stacks) {
super("bleed", turns, 1);
this.stacks = stacks;
}
@Override
String stackRule() {
return "stack";
}
@Override
int maxStacks() {
return 5;
}
@Override
void onTurn(Creature target, Context ctx) {
int damage = this.stacks * this.power;
ctx.say(t("fx.tick.bleed", target.name, damage));
hurt(ctx, target, damage);
ctx.addBlood(damage);
}
@Override
void onExpire(Creature target, Context ctx) {
ctx.say(t("fx.off.bleed", target.name, 0));
}
}
// Aplica un efecto según su regla; devuelve si algo cambió.
static boolean applyEffect(Creature target, Effect effect, Context ctx) {
if (!target.isAlive()) return false;
Effect existing = target.findEffect(effect.id);
if (existing == null) {
target.effects.add(effect);
effect.onApply(target, ctx);
return true;
}
String rule = existing.stackRule();
if (rule.equals("ignore")) return false;
if (rule.equals("stack")) {
existing.stacks = Math.min(existing.maxStacks(), existing.stacks + effect.stacks);
ctx.say(t("fx.more." + effect.id, target.name, existing.stacks));
}
existing.turns = Math.max(existing.turns, effect.turns);
existing.power = Math.max(existing.power, effect.power);
return true;
}
// Cada efecto actúa y luego pierde un turno; los vencidos se quitan.
static void tickEffects(Creature creature, Context ctx) {
for (Effect effect : new ArrayList<>(creature.effects)) {
if (!creature.isAlive()) return;
effect.onTurn(creature, ctx);
effect.turns -= 1;
if (effect.turns <= 0) {
creature.removeEffect(effect.id);
effect.onExpire(creature, ctx);
}
}
}
// Daño fuera de un golpe: nunca por debajo de cero, y avisa cuando la herida pasó a grave.
static void hurt(Context ctx, Creature target, int amount) {
boolean wasHeavy = target.isHeavilyWounded();
target.hp = Math.max(0, target.hp - amount);
if (target.isAlive() && !wasHeavy && target.isHeavilyWounded()) ctx.say(t("combat.wounded", target.name, 0));
}
for (int turn = 1; turn <= 5; turn++) {
System.out.println("Turno " + turn);
if (turn <= 3) applyEffect(wolf, makeEffect("bleed", 3, 1), log);
tickEffects(wolf, log);
System.out.println(status(wolf));
}
Paso a paso, en palabras
  1. El lobo tiene 15 de vida. El sangrado es un efecto con la regla stack: cada corte nuevo es una herida más (hasta 5) y los turnos vuelven a 3. Tres golpes, cinco turnos de sangre.
  2. Turno 1: el primer golpe. El lobo aún no sangra, así que applyEffect añade un efecto nuevo (onApply). Luego el turno de efectos quita 1 × 1 = 1: vida 14, quedan 2 turnos.
  3. Turno 2: el segundo golpe. El efecto ya existe y su regla es stack: las heridas pasan a 2 y los turnos vuelven a 3. El daño es 2 × 1 = 2: vida 12.
  4. Turno 3: el tercer golpe, 3 heridas. Una herida cuesta 1 de vida por turno, tres heridas cuestan 3. Vida 9. No hay más golpes, pero la sangre sigue.
  5. Turno 4: sin golpe. tickEffects vuelve a quitar 3 × 1 = 3 y resta un turno: queda 1. Vida 6.
  6. Turno 5: la última sangre, 3. La vida baja a 3, menos del 25 % de 15: hurt avisa «herida grave». Los turnos se acaban y el efecto se quita (onExpire).
  7. Los mismos tres golpes con la regla refresh (sin acumular) solo habrían quitado 5 de vida; con stack quitaron 12 de 15. Sangre derramada: 12.

El programa golpea al lobo en tres turnos seguidos (turnos 1–3) y en los otros dos solo hace tictac. El lobo tiene 15 de vida:

Creature wolf = new Creature("Lobo", 15, stats(8, 2, 6));
Log log = new Log();
for (int turn = 1; turn <= 5; turn++) {
System.out.println("Turno " + turn);
if (turn <= 3) applyEffect(wolf, makeEffect("bleed", 3, 1), log);
tickEffects(wolf, log);
System.out.println(status(wolf));
}
System.out.println("Sangre derramada: " + log.blood);
Salida
Turno 1
Lobo sangra.
Lobo pierde sangre (1).
Vida 14/15 [sang:2]
Turno 2
Lobo sangra más (heridas: 2).
Lobo pierde sangre (2).
Vida 12/15 [sangx2:2]
Turno 3
Lobo sangra más (heridas: 3).
Lobo pierde sangre (3).
Vida 9/15 [sangx3:2]
Turno 4
Lobo pierde sangre (3).
Vida 6/15 [sangx3:1]
Turno 5
Lobo pierde sangre (3).
¡Lobo sufre una herida grave!
Las heridas de Lobo dejan de sangrar.
Vida 3/15
Sangre derramada: 12

El daño por turno crece 1, 2, 3 sin que los golpes sean más fuertes: las heridas se suman. En el turno 3 hay tres heridas y los turnos vuelven a ser 3, así que el sangrado sigue dos turnos más de 3 cada uno. En total 1 + 2 + 3 + 3 + 3 = 12. La sangre derramada es exactamente la vida quitada (15 − 3 = 12), porque addBlood cuenta el daño del sangrado. En el turno 5 la vida baja de un cuarto y aparece «herida grave» (las últimas tarjetas).

Si el sangrado no se acumulara, sino que solo se renovara, los mismos tres golpes quitarían 1 por turno y 5 de vida en total (5 turnos de sangre de 1). Con acumulación son 12. Un pequeño cambio en la regla hace peligrosas las armas con filo.

¿De dónde salen las heridas en el juego? Del sistema de combate: un golpe con un arma con filo pone una herida en un golpe crítico o en el 35 % de los casos, y el «desgarro» de un enemigo pone dos de golpe. La clase del juego es esta:

// #region bleeding
// El sangrado se acumula: cada corte nuevo es una herida más (hasta 5), cada una cuesta 1 de vida por turno, durante tres
// turnos. La sangre derramada cuenta para las estadísticas, y una criatura que sangra y se mueve deja un rastro.
static class BleedEffect extends Effect {
BleedEffect(int turns, int stacks) {
super("bleed", turns, 1);
this.stacks = stacks;
}
@Override
String stackRule() {
return "stack";
}
@Override
int maxStacks() {
return 5;
}
@Override
void onTurn(Creature target, Context ctx) {
int damage = this.stacks * this.power;
ctx.say(t("fx.tick.bleed", "name", target.name, "damage", damage));
hurt(ctx, target, damage);
ctx.addBlood(damage);
}
@Override
void onExpire(Creature target, Context ctx) {
ctx.say(t("fx.off.bleed", "name", target.name));
}
}
Más precisamente, para curiosos

En el canon, el sangrado del héroe y el de los enemigos son la misma clase. Por eso el antídoto quita el veneno, el sangrado y la quemadura con removeEffect, y la recuperación lenta del héroe (+1 de vida cada tres turnos) no funciona mientras sangra o está envenenado: worldTurn pregunta hasEffect.

Pruébalo

Predice la salida

El héroe sangra con una herida. Después del primer turno recibe otro corte con dos heridas. Los efectos trabajan con Quiet, así que solo se imprime la vida. ¿Qué imprime el programa?

Creature hero = new Creature("Iva", 20, stats(10, 4, 10));
Quiet quiet = new Quiet();
applyEffect(hero, makeEffect("bleed", 3, 1), quiet);
for (int turn = 1; turn <= 4; turn++) {
if (turn == 2) applyEffect(hero, makeEffect("bleed", 3, 2), quiet);
tickEffects(hero, quiet);
System.out.println("Vida " + hero.hp);
}
Respuesta

Pruébalo

Encuentra el error

Un compañero tocó onTurn del sangrado. El programa con el lobo imprime esto, y la vida baja 1 por turno aunque hay tres heridas:

// Cada herida pierde 1 de vida por turno, hasta 5 heridas.
static class BleedEffect extends Effect {
BleedEffect(int turns, int stacks) {
super("bleed", turns, 1);
this.stacks = stacks;
}
@Override
String stackRule() {
return "stack";
}
@Override
int maxStacks() {
return 5;
}
@Override
void onTurn(Creature target, Context ctx) {
int damage = this.power;
ctx.say(t("fx.tick.bleed", target.name, damage));
hurt(ctx, target, damage);
ctx.addBlood(damage);
}
@Override
void onExpire(Creature target, Context ctx) {
ctx.say(t("fx.off.bleed", target.name, 0));
}
}
Salida
Turno 1
Lobo sangra.
Lobo pierde sangre (1).
Vida 14/15 [sang:2]
Turno 2
Lobo sangra más (heridas: 2).
Lobo pierde sangre (1).
Vida 13/15 [sangx2:2]
Turno 3
Lobo sangra más (heridas: 3).
Lobo pierde sangre (1).
Vida 12/15 [sangx3:2]
Turno 4
Lobo pierde sangre (1).
Vida 11/15 [sangx3:1]
Turno 5
Lobo pierde sangre (1).
Las heridas de Lobo dejan de sangrar.
Vida 10/15
Sangre derramada: 5

La etiqueta dice heridas: 3, y «pierde sangre» se queda en 1. ¿Qué línea calcula el daño?

Elige la línea con el error.

La idea

Efectos que cambian los valores

No todos los efectos actúan en cada turno. La ira da fuerza y quita defensa mientras dura, y el escudo da defensa. No hacen nada en onTurn. En cambio, el valor de una criatura se calcula a partir de una base y de los efectos:

valor = base de la criatura + equipo + los cambios de los efectos

Cada efecto tiene un método modifier(stat) que devuelve cuánto añade a un valor dado (0 por defecto). La criatura suma las respuestas cada vez que se le pide un valor y no guarda el resultado. Por eso, cuando se quita un efecto de la lista, el valor vuelve solo:

static class RageEffect extends Effect {
RageEffect(String id, int turns, int power) {
super(id, turns, power);
}
@Override
int modifier(String stat) {
if (stat.equals("strength")) return this.power;
if (stat.equals("defense")) return -2;
return 0;
}
}
static class ShieldEffect extends Effect {
ShieldEffect(String id, int turns, int power) {
super(id, turns, power);
}
@Override
int modifier(String stat) {
return stat.equals("defense") ? this.power : 0;
}
}

El programa da a Iva ira durante 3 turnos con fuerza 4 y un escudo durante 2 turnos con fuerza 5, y luego imprime los valores:

Creature hero = new Creature("Iva", 30, stats(10, 4, 10));
Log log = new Log();
applyEffect(hero, makeEffect("rage", 3, 4), log);
applyEffect(hero, makeEffect("shield", 2, 5), log);
System.out.println(statLine(hero));
for (int turn = 1; turn <= 3; turn++) {
System.out.println("Turno " + turn);
tickEffects(hero, log);
System.out.println(statLine(hero));
}
Salida
¡Iva entra en ira!
Un escudo protege a Iva.
fuerza 14, defensa 7
Turno 1
fuerza 14, defensa 7
Turno 2
El escudo deja de afectar a Iva.
fuerza 14, defensa 2
Turno 3
La ira deja de afectar a Iva.
fuerza 10, defensa 4

La fuerza 14 es 10 + 4. La defensa es 4 − 2 (ira) + 5 (escudo) = 7. Cuando el escudo vence quedan 4 − 2 = 2, y cuando vence también la ira, todo vuelve a la base. Nadie «devuelve» nada: el valor se calcula de nuevo cada vez que se pide.

Dos detalles. stat no baja de 0: con debilidad y sin armadura la defensa es 0, no negativa. Y un efecto que se acumula y da un valor tiene que multiplicar por stacks en modifier, igual que el sangrado multiplica en onTurn: ese es el ejercicio del final de la lección.

El juego tiene los mismos dos efectos:

static class RageEffect extends Effect {
RageEffect(String id, int turns, int power) {
super(id, turns, power);
}
@Override
int modifier(String stat) {
if (stat.equals("strength")) return this.power;
if (stat.equals("defense")) return -2;
return 0;
}
}
static class ShieldEffect extends Effect {
ShieldEffect(String id, int turns, int power) {
super(id, turns, power);
}
@Override
int modifier(String stat) {
return stat.equals("defense") ? this.power : 0;
}
}
Más precisamente, para curiosos

El juego tiene otros cambios de valores: DodgeEffect (esquiva), WeakEffect (debilidad por el grito de un enemigo) y FuryEffect (la furia del Guardián, la segunda fase). Todos tienen unas diez líneas. Lo distinto de FuryEffect es que dura 999 turnos: hasta el final del combate.

La idea

El rastro de sangre

Un lobo que sangra deja un rastro en el mapa. Es parte del juego: la misión «El rastro de sangre» te lleva por él hasta la guarida del lobo jefe.

¿Quién pone la gota? No el efecto. El efecto sabe cuánta sangre corre (ctx.addBlood(damage)), pero no dónde está la criatura. Lo sabe quien la mueve. Por eso el movimiento pregunta hasEffect("bleed") y pone una gota en la celda que la criatura deja. El nivel guarda las gotas en una capa aparte sobre el suelo (decor) y pone sangre solo en suelo limpio: un cadáver y los objetos siguen visibles.

static class Level {
final String floor;
final char[] decor;
Level(String floor) {
this.floor = floor;
this.decor = new char[floor.length()];
Arrays.fill(this.decor, ' ');
}
// La sangre solo se pone en un suelo limpio.
void addBlood(int x) {
if (this.decor[x] == ' ' && this.floor.charAt(x) == '.') this.decor[x] = ',';
}
String draw(int walkerX) {
char[] row = new char[this.floor.length()];
for (int x = 0; x < row.length; x++) {
char glyph = this.floor.charAt(x);
if (this.decor[x] != ' ') glyph = this.decor[x];
if (x == walkerX) glyph = 'w';
row[x] = glyph;
}
return new String(row);
}
}

El programa manda a un lobo herido por un pasillo. La herida dura tres turnos, así que hay tres gotas, y luego el lobo camina limpio:

Level level = new Level("#...........#");
Creature wolf = new Creature("Lobo", 15, stats(8, 2, 6));
Quiet quiet = new Quiet();
int x = 1;
applyEffect(wolf, makeEffect("bleed", 3, 1), quiet);
System.out.println(level.draw(x));
for (int step = 1; step <= 5; step++) {
if (wolf.findEffect("bleed") != null) level.addBlood(x);
x++;
tickEffects(wolf, quiet);
System.out.println(level.draw(x));
}
System.out.println("Sangre derramada: " + quiet.blood);
Salida
#w..........#
#,w.........#
#,,w........#
#,,,w.......#
#,,,.w......#
#,,,..w.....#
Sangre derramada: 3

Hay una gota justo en las celdas que el lobo dejó mientras sangraba. Después del tercer turno el sangrado vence y el rastro se corta. El silencioso Quiet está aquí a propósito: este cálculo no necesita mensajes.

En el juego el movimiento es el mismo. El enemigo deja sangre en cada paso (moveEnemy) y el héroe en stepHero, los dos con la misma condición. Aquí están el paso del enemigo y la celda que acepta la sangre:

static void moveEnemy(Game game, Level level, Enemy enemy, int x, int y) {
if (enemy.hasEffect("bleed")) level.addBlood(enemy.x, enemy.y);
enemy.x = x;
enemy.y = y;
}
// La sangre no cubre un cadáver: el cadáver sigue visible.
void addBlood(int x, int y) {
if (this.decor[y][x] == ' ' && this.isWalkable(x, y)) this.decor[y][x] = ',';
}

Aquí está el rastro en el juego: las gotas , llevan por la galería hasta la guarida del lobo.

####.##### #########,### Galería alta
#.# Nivel 1 de 3
####,####
......,...# Alrededor:
.......,..# L lobo jefe (duerme
.......,..# w lobo sangriento (
######@###
.....
.....,...
........,L.
w..........
.........
#####
Iva, bárbaro nv.1 exp 19/20 oro 12
V [############] 48/48 M [########] 4/4
El rastro de sangre: Sigue la sangre hasta la guarida.
La puerta cruje y se abre.
Ves: lobo jefe.

La idea

La herida grave

Cuando la vida baja de un cuarto, la herida es grave: los golpes de la criatura son menos precisos. La regla está en dos sitios pequeños: isHeavilyWounded y la precisión.

// 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);
}

Por debajo del 25 % es hp × 4 < maxHp (sin fracciones y sin división). La precisión es 75 más el doble de la agilidad, y con una herida grave otros 15 menos. El programa envenena a Iva, que empieza con 14 de 40 de vida. En el segundo turno el veneno la baja del límite:

Creature hero = new Creature("Iva", 40, stats(10, 4, 10));
hero.hp = 14;
Log log = new Log();
applyEffect(hero, makeEffect("poison", 3, 3), log);
System.out.println(report(hero));
for (int turn = 1; turn <= 3; turn++) {
System.out.println("Turno " + turn);
tickEffects(hero, log);
System.out.println(report(hero));
}
hero.heal(10);
System.out.println(report(hero));
Salida
El veneno se cuela en Iva.
Vida 14/40 [ven:3] precisión 95
Turno 1
El veneno quema a Iva (3).
Vida 11/40 [ven:2] precisión 95
Turno 2
El veneno quema a Iva (3).
¡Iva sufre una herida grave!
Vida 8/40 [ven:1] precisión 80
Turno 3
El veneno quema a Iva (3).
El veneno deja de afectar a Iva.
Vida 5/40 precisión 80
Vida 15/40 precisión 95

En el turno 2 la vida es 8 y 8 × 4 = 32 está por debajo de 40, por eso la precisión baja de 95 a 80. El mensaje «herida grave» llega exactamente una vez. Eso lo hace hurt: pregunta antes del daño (wasHeavy) y dice algo solo cuando el cruce ocurre ahora.

// Daño fuera de un golpe: nunca por debajo de cero, y avisa cuando la herida pasó a grave.
static void hurt(Context ctx, Creature target, int amount) {
boolean wasHeavy = target.isHeavilyWounded();
target.hp = Math.max(0, target.hp - amount);
if (target.isAlive() && !wasHeavy && target.isHeavilyWounded()) ctx.say(t("combat.wounded", target.name, 0));
}

En el turno 3 la vida baja más, pero el mensaje no se repite. Al final heal(10) lleva la vida a 15 y la precisión vuelve a ser 95. Una herida grave no es un efecto de la lista: se calcula a partir de la vida y desaparece en cuanto la vida sube.

boolean isHeavilyWounded() {
    return this.hp *  < this.maxHp;
}
Más precisamente, para curiosos

La fuerza de un golpe se dice con palabras según la parte de la vida del objetivo que quita. Umbrales: por debajo del 10 % «araña», hasta el 25 % «golpea», hasta el 45 % «corta», por encima «aplasta». Por eso el sangrado es silencioso (números pequeños), mientras que el primer golpe del Guardián se siente como un golpe.

// 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ás precisamente

Quién llama a tickEffects

Los efectos no tienen reloj propio: alguien tiene que llamar a tickEffects. En el combate es el comienzo del turno de cada criatura. Una criatura aturdida pierde su turno. beginTurn recuerda si hay aturdimiento antes de tickEffects, porque el tictac lo quita (dura un turno).

// 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;
}

En el mapa fuera del combate el turno es el mundo (worldTurn): el héroe actúa, los efectos del héroe hacen tictac después de cada paso y luego hacen tictac los efectos de cada enemigo. A un enemigo fuera de la pantalla se le hace tictac con Quiet: si muere desangrado nadie lo ve y no cae botín.

Un turno de efectos es, pues, lo mismo en dos sitios. El efecto no sabe desde dónde lo llaman, porque todo lo que necesita está en target y ctx. Por eso un efecto nuevo es una clase y una línea en makeEffect: el sistema de combate y el mapa no se tocan. La próxima lección, las acciones, trata de los efectos que las habilidades ponen a otros.

Ejercicio

Frío

Resuélvelo en tu editor.

Frío. Añade un efecto chill. Su regla es stack con 3 capas como máximo, y cada capa quita 2 de agilidad. La agilidad se calcula con modifier, así que hay que multiplicarla por las capas. Trabaja con el código de la lección: Effect, Creature, makeEffect, applyEffect y tickEffects.

Añade también los textos del efecto a la tabla de textos:

fx.on.chill: El frío se pega a {name}.
fx.more.chill: {name} se enfría más (capas: {n}).
fx.off.chill: El frío deja de afectar a {name}.
fxs.chill: frío

El programa crea a Iva (vida 30, fuerza 10, defensa 4, agilidad 10) e imprime report (el estado y la precisión, como en el programa de la herida grave). Primero imprime el estado sin efectos. Luego aplica cuatro veces makeEffect("chill", 2, 1) e imprime el estado después de cada una. Al final hace dos turnos con tickEffects e imprime «Turno» y el estado después de cada uno. La salida es:

Salida
Vida 30/30 precisión 95
El frío se pega a Iva.
Vida 30/30 [frío:2] precisión 91
Iva se enfría más (capas: 2).
Vida 30/30 [fríox2:2] precisión 87
Iva se enfría más (capas: 3).
Vida 30/30 [fríox3:2] precisión 83
Iva se enfría más (capas: 3).
Vida 30/30 [fríox3:2] precisión 83
Turno 1
Vida 30/30 [fríox3:1] precisión 83
Turno 2
El frío deja de afectar a Iva.
Vida 30/30 precisión 95

La precisión baja 4 por capa (2 de agilidad × 2). La cuarta aplicación se queda en 3 capas, pero dice «capas: 3» y devuelve los turnos a 2. Tras los dos turnos el frío vence y la precisión vuelve a ser 95.

Más ejercicios

  • Muro de fuego. El efecto burn ya está en el código. Haz que se acumule hasta 3 veces y muestra cómo cambia el daño.
  • Inmunidad. Añade a la criatura immune (un conjunto de id). ¿Qué debe devolver applyEffect y cuál es la diferencia entre la inmunidad y la regla ignore?
  • Vendaje. Haz una poción que quite el sangrado y ponga regen durante 3 turnos con fuerza 2. ¿Cuánta vida se devuelve después de tres turnos, si la vida no está llena?

Test

Ponte a prueba

1. El lobo tiene 3 heridas y sangra. ¿Cuánta vida pierde por turno y por qué?
2. El héroe está aturdido y un segundo enemigo lo aturde otra vez. ¿Qué hace la regla ignore?
3. Un efecto con turns = 3 se aplica en el turno 1 y nada lo renueva. ¿Cuántas veces actúa onTurn?
4. La agilidad es 10 y la vida es 9 de 40. ¿Cuál es la precisión?
5. ¿Quién pone la gota de sangre en el suelo: el efecto de sangrado o la criatura que camina?

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