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:21) 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 huidaBajo 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.
class Effect: def __init__(self, effect_id, turns, power): self.id = effect_id self.turns = turns self.power = power self.stacks = 1
def stack_rule(self): return 'refresh'
def max_stacks(self): return 1
def modifier(self, stat): return 0
def on_apply(self, target, ctx): ctx.say(t('fx.on.' + self.id, target.name))
def on_turn(self, target, ctx): pass
def on_expire(self, target, ctx): ctx.say(t('fx.off.' + self.id, target.name))
def label(self): name = t('fxs.' + self.id) return name + 'x' + str(self.stacks) + ':' + str(self.turns) if self.stacks > 1 else name + ':' + str(self.turns)
class PoisonEffect(Effect): def on_turn(self, target, ctx): ctx.say(t('fx.tick.poison', target.name, self.power)) hurt(ctx, target, self.power)
class RegenEffect(Effect): def on_turn(self, target, ctx): healed = target.heal(self.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:
hero = Creature('Iva', 20, stats(10, 4, 10))log = Log()apply_effect(hero, make_effect('poison', 3, 2), log)apply_effect(hero, make_effect('regen', 2, 1), log)for turn in range(1, 4): print(f"Turno {turn}") tick_effects(hero, log) print(status(hero))El veneno se cuela en Iva.Iva empieza a recuperarse.Turno 1El veneno quema a Iva (2).Iva se recupera (+1).Vida 19/20 [ven:2 reg:1]Turno 2El veneno quema a Iva (2).Iva se recupera (+1).La regeneración deja de afectar a Iva.Vida 18/20 [ven:1]Turno 3El veneno quema a Iva (2).El veneno deja de afectar a Iva.Vida 16/20El 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.
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.
class Log: def __init__(self): self.blood = 0
def say(self, text): print(text)
def add_blood(self, amount): self.blood += amount
# El registro silencioso no escribe, pero cuenta la sangre.class Quiet: def __init__(self): self.blood = 0
def say(self, text): pass
def add_blood(self, amount): self.blood += amountclass Creature: def __init__(self, name, max_hp, base): self.name = name self.max_hp = max_hp self.hp = max_hp self.base = base self.effects = []
def is_alive(self): return self.hp > 0
def stat(self, stat): value = self.base[stat] for effect in self.effects: value += effect.modifier(stat) return 0 if value < 0 else value
# Con menos del 25 % de vida la herida es grave: los golpes son menos precisos. def is_heavily_wounded(self): return self.hp * 4 < self.max_hp
def accuracy(self): return 75 + self.stat('agility') * 2 - (15 if self.is_heavily_wounded() else 0)
def heal(self, amount): before = self.hp self.hp = min(self.max_hp, self.hp + amount) return self.hp - before
def find_effect(self, effect_id): for effect in self.effects: if effect.id == effect_id: return effect return None
def remove_effect(self, effect_id): kept = [] for effect in self.effects: if effect.id != effect_id: kept.append(effect) self.effects = kept
def stats(strength, defense, agility): return {'strength': strength, 'defense': defense, 'agility': agility}# Los textos son datos: una clave y una plantilla. {name} es el nombre, {n} es el número.TEXT = { 'fx.on.bleed': '{name} sangra.', 'fx.on.poison': 'El veneno se cuela en {name}.', 'fx.on.burn': '{name} se prende fuego.', 'fx.on.stun': '{name} pierde el equilibrio.', 'fx.on.regen': '{name} empieza a recuperarse.', 'fx.on.rage': '¡{name} entra en ira!', 'fx.on.shield': 'Un escudo protege a {name}.', 'fx.more.bleed': '{name} sangra más (heridas: {n}).', 'fx.tick.bleed': '{name} pierde sangre ({n}).', 'fx.tick.poison': 'El veneno quema a {name} ({n}).', 'fx.tick.burn': 'Las llamas muerden a {name} ({n}).', 'fx.tick.regen': '{name} se recupera (+{n}).', 'fx.off.bleed': 'Las heridas de {name} dejan de sangrar.', 'fx.off.poison': 'El veneno deja de afectar a {name}.', 'fx.off.burn': 'La quemadura deja de afectar a {name}.', 'fx.off.regen': 'La regeneración deja de afectar a {name}.', 'fx.off.rage': 'La ira deja de afectar a {name}.', 'fx.off.shield': 'El escudo deja de afectar a {name}.', 'fxs.bleed': 'sang', 'fxs.poison': 'ven', 'fxs.burn': 'fuego', 'fxs.stun': 'atur', 'fxs.regen': 'reg', 'fxs.rage': 'ira', 'fxs.shield': 'escu', 'combat.wounded': '¡{name} sufre una herida grave!',}
def t(key, name='', n=0): return TEXT[key].replace('{name}', name).replace('{n}', str(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.
# region effects# Un efecto tiene tres momentos: al aplicarse (on_apply), cada turno (on_turn) y al expirar (on_expire),# y una regla de repetición: renovar (refresh), sumar (stack) o ignorar (ignore).# ctx es el registro del combate o el mapa: ambos tienen say(text) y add_blood(amount).class Effect(ABC): def __init__(self, effect_id, turns, power): self.id = effect_id self.turns = turns self.power = power self.stacks = 1
def stack_rule(self): return 'refresh'
def max_stacks(self): return 1
def modifier(self, stat): return 0
def on_apply(self, target, ctx): ctx.say(t('fx.on.' + self.id, {'name': target.name}))
def on_turn(self, target, ctx): pass
def on_expire(self, target, ctx): ctx.say(t('fx.off', {'name': target.name, 'effect': t('fx.' + self.id)}))
def label(self): name = t('fxs.' + self.id) return name + 'x' + str(self.stacks) + ':' + str(self.turns) if self.stacks > 1 else name + ':' + str(self.turns)class PoisonEffect(Effect): def on_turn(self, target, ctx): ctx.say(t('fx.tick.poison', {'name': target.name, 'damage': self.power})) hurt(ctx, target, self.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, hastamaxStacks(). 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ó.def apply_effect(target, effect, ctx): if not target.is_alive(): return False existing = target.find_effect(effect.id) if existing is None: target.effects.append(effect) effect.on_apply(target, ctx) return True rule = existing.stack_rule() if rule == 'ignore': return False if rule == 'stack': existing.stacks = min(existing.max_stacks(), existing.stacks + effect.stacks) ctx.say(t('fx.more.' + effect.id, target.name, existing.stacks)) existing.turns = max(existing.turns, effect.turns) existing.power = max(existing.power, effect.power) return True
# Cada efecto actúa y luego pierde un turno; los vencidos se quitan.def tick_effects(creature, ctx): for effect in list(creature.effects): if not creature.is_alive(): return effect.on_turn(creature, ctx) effect.turns -= 1 if effect.turns <= 0: creature.remove_effect(effect.id) effect.on_expire(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:
goblin = Creature('Goblin', 40, stats(8, 2, 6))log = Log()incoming = [ make_effect('poison', 3, 2), make_effect('poison', 5, 1), make_effect('bleed', 3, 1), make_effect('bleed', 3, 1), make_effect('stun', 1, 0), make_effect('stun', 2, 0),]for effect in incoming: label = effect.label() changed = apply_effect(goblin, effect, log) print(f"{label} -> {'sí' if changed else 'no'} {status(goblin)}")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ó.
def apply_effect(target, effect, ctx):
if not target.is_alive():
return False
existing = target.find_effect(effect.id)
if existing is None:
target.effects.append(effect)
effect.on_apply(target, ctx)
return True
rule = existing.stack_rule()
if rule == 'ignore':
return False
if rule == 'stack':
existing.stacks = min(, existing.stacks + effect.stacks)
ctx.say(t('fx.more.' + effect.id, target.name, existing.stacks))
existing.turns = max(existing.turns, effect.turns)
existing.power = max(existing.power, effect.power)
return TrueLas heridas no crecen sin límite. El máximo de capas lo dice el propio efecto con maxStacks(): en el sangrado es 5, en todos los demás es 1. El número nuevo es, pues, el menor de maxStacks() y stacks + effect.stacks.
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ó.def apply_effect(target, effect, ctx): if not target.is_alive() or target.is_immune(effect.id): return False if effect.id == 'stun' and target.stun_proof: return False existing = target.find_effect(effect.id) if existing is None: target.effects.append(effect) effect.on_apply(target, ctx) return True rule = existing.stack_rule() if rule == 'ignore': return False if rule == 'stack': existing.stacks = min(existing.max_stacks(), existing.stacks + effect.stacks) ctx.say(t('fx.more.' + effect.id, {'name': target.name, 'stacks': existing.stacks})) existing.turns = max(existing.turns, effect.turns) existing.power = 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.def tick_effects(creature, ctx): for effect in list(creature.effects): if not creature.is_alive(): return effect.on_turn(creature, ctx) effect.turns -= 1 if effect.turns <= 0: creature.remove_effect(effect.id) effect.on_expire(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.class BleedEffect(Effect): def __init__(self, turns, stacks): super().__init__('bleed', turns, 1) self.stacks = stacks
def stack_rule(self): return 'stack'
def max_stacks(self): return 5
def on_turn(self, target, ctx): damage = self.stacks * self.power ctx.say(t('fx.tick.bleed', target.name, damage)) hurt(ctx, target, damage) ctx.add_blood(damage)
def on_expire(self, target, ctx): ctx.say(t('fx.off.bleed', target.name))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.class BleedEffect(Effect): def __init__(self, turns, stacks): super().__init__('bleed', turns, 1) self.stacks = stacks
def stack_rule(self): return 'stack'
def max_stacks(self): return 5
def on_turn(self, target, ctx): damage = self.stacks * self.power ctx.say(t('fx.tick.bleed', target.name, damage)) hurt(ctx, target, damage) ctx.add_blood(damage)
def on_expire(self, target, ctx): ctx.say(t('fx.off.bleed', target.name))
# Aplica un efecto según su regla; devuelve si algo cambió.def apply_effect(target, effect, ctx): if not target.is_alive(): return False existing = target.find_effect(effect.id) if existing is None: target.effects.append(effect) effect.on_apply(target, ctx) return True rule = existing.stack_rule() if rule == 'ignore': return False if rule == 'stack': existing.stacks = min(existing.max_stacks(), existing.stacks + effect.stacks) ctx.say(t('fx.more.' + effect.id, target.name, existing.stacks)) existing.turns = max(existing.turns, effect.turns) existing.power = max(existing.power, effect.power) return True
# Cada efecto actúa y luego pierde un turno; los vencidos se quitan.def tick_effects(creature, ctx): for effect in list(creature.effects): if not creature.is_alive(): return effect.on_turn(creature, ctx) effect.turns -= 1 if effect.turns <= 0: creature.remove_effect(effect.id) effect.on_expire(creature, ctx)
# Daño fuera de un golpe: nunca por debajo de cero, y avisa cuando la herida pasó a grave.def hurt(ctx, target, amount): was_heavy = target.is_heavily_wounded() target.hp = max(0, target.hp - amount) if target.is_alive() and not was_heavy and target.is_heavily_wounded(): ctx.say(t('combat.wounded', target.name))
for turn in range(1, 6): print(f"Turno {turn}") if turn <= 3: apply_effect(wolf, make_effect('bleed', 3, 1), log) tick_effects(wolf, log) print(status(wolf))Paso a paso, en palabras
- 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.
- 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.
- 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.
- 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.
- Turno 4: sin golpe. tickEffects vuelve a quitar 3 × 1 = 3 y resta un turno: queda 1. Vida 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).
- 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:
wolf = Creature('Lobo', 15, stats(8, 2, 6))log = Log()for turn in range(1, 6): print(f"Turno {turn}") if turn <= 3: apply_effect(wolf, make_effect('bleed', 3, 1), log) tick_effects(wolf, log) print(status(wolf))print(f"Sangre derramada: {log.blood}")Turno 1Lobo sangra.Lobo pierde sangre (1).Vida 14/15 [sang:2]Turno 2Lobo sangra más (heridas: 2).Lobo pierde sangre (2).Vida 12/15 [sangx2:2]Turno 3Lobo sangra más (heridas: 3).Lobo pierde sangre (3).Vida 9/15 [sangx3:2]Turno 4Lobo pierde sangre (3).Vida 6/15 [sangx3:1]Turno 5Lobo pierde sangre (3).¡Lobo sufre una herida grave!Las heridas de Lobo dejan de sangrar.Vida 3/15Sangre derramada: 12El 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.class BleedEffect(Effect): def __init__(self, turns, stacks): super().__init__('bleed', turns, 1) self.stacks = stacks
def stack_rule(self): return 'stack'
def max_stacks(self): return 5
def on_turn(self, target, ctx): damage = self.stacks * self.power ctx.say(t('fx.tick.bleed', {'name': target.name, 'damage': damage})) hurt(ctx, target, damage) ctx.add_blood(damage)
def on_expire(self, target, 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?
hero = Creature('Iva', 20, stats(10, 4, 10))quiet = Quiet()apply_effect(hero, make_effect('bleed', 3, 1), quiet)for turn in range(1, 5): if turn == 2: apply_effect(hero, make_effect('bleed', 3, 2), quiet) tick_effects(hero, quiet) print(f"Vida {hero.hp}")El sangrado tiene la regla stack: después del primer turno hay una herida (vida 19), luego se añaden dos, las heridas pasan a tres y los turnos vuelven a 3. Siguen tres turnos de 3: 16, 13 y 10. La respuesta 19, 18, 17, 16 es la renovación (la herida sigue siendo una, los turnos vuelven a 3) y 19, 18, 17, 17 es ignore (la segunda aplicación se descarta).
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.class BleedEffect(Effect): def __init__(self, turns, stacks): super().__init__('bleed', turns, 1) self.stacks = stacks
def stack_rule(self): return 'stack'
def max_stacks(self): return 5
def on_turn(self, target, ctx): damage = self.power ctx.say(t('fx.tick.bleed', target.name, damage)) hurt(ctx, target, damage) ctx.add_blood(damage)
def on_expire(self, target, ctx): ctx.say(t('fx.off.bleed', target.name))Turno 1Lobo sangra.Lobo pierde sangre (1).Vida 14/15 [sang:2]Turno 2Lobo sangra más (heridas: 2).Lobo pierde sangre (1).Vida 13/15 [sangx2:2]Turno 3Lobo sangra más (heridas: 3).Lobo pierde sangre (1).Vida 12/15 [sangx3:2]Turno 4Lobo pierde sangre (1).Vida 11/15 [sangx3:1]Turno 5Lobo pierde sangre (1).Las heridas de Lobo dejan de sangrar.Vida 10/15Sangre derramada: 5La 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.
El daño es power, no stacks × power. Las heridas se cuentan, la etiqueta las muestra, pero la vida no las ve: tres heridas quitan lo mismo que una. Lo correcto es damage = stacks × power. El error es traicionero: nada se rompe, applyEffect acumula, label() muestra las heridas y la diferencia solo se nota en los números.
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 efectosCada 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:
class RageEffect(Effect): def modifier(self, stat): if stat == 'strength': return self.power if stat == 'defense': return -2 return 0
class ShieldEffect(Effect): def modifier(self, stat): return self.power if stat == 'defense' else 0El 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:
hero = Creature('Iva', 30, stats(10, 4, 10))log = Log()apply_effect(hero, make_effect('rage', 3, 4), log)apply_effect(hero, make_effect('shield', 2, 5), log)print(stat_line(hero))for turn in range(1, 4): print(f"Turno {turn}") tick_effects(hero, log) print(stat_line(hero))¡Iva entra en ira!Un escudo protege a Iva.fuerza 14, defensa 7Turno 1fuerza 14, defensa 7Turno 2El escudo deja de afectar a Iva.fuerza 14, defensa 2Turno 3La ira deja de afectar a Iva.fuerza 10, defensa 4La 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:
class RageEffect(Effect): def modifier(self, stat): if stat == 'strength': return self.power if stat == 'defense': return -2 return 0class ShieldEffect(Effect): def modifier(self, stat): return self.power if stat == 'defense' else 0Má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.
class Level: def __init__(self, floor): self.floor = floor self.decor = [' '] * len(floor)
# La sangre solo se pone en un suelo limpio. def add_blood(self, x): if self.decor[x] == ' ' and self.floor[x] == '.': self.decor[x] = ','
def draw(self, walker_x): row = '' for x in range(len(self.floor)): glyph = self.floor[x] if self.decor[x] != ' ': glyph = self.decor[x] if x == walker_x: glyph = 'w' row += glyph return rowEl 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('#...........#')wolf = Creature('Lobo', 15, stats(8, 2, 6))quiet = Quiet()x = 1apply_effect(wolf, make_effect('bleed', 3, 1), quiet)print(level.draw(x))for step in range(1, 6): if wolf.find_effect('bleed') is not None: level.add_blood(x) x += 1 tick_effects(wolf, quiet) print(level.draw(x))print(f"Sangre derramada: {quiet.blood}")#w..........##,w.........##,,w........##,,,w.......##,,,.w......##,,,..w.....#Sangre derramada: 3Hay 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:
def move_enemy(game, level, enemy, x, y): if enemy.has_effect('bleed'): level.add_blood(enemy.x, enemy.y) enemy.x = x enemy.y = y# La sangre no cubre un cadáver: el cadáver sigue visible.def add_blood(self, x, y): if self.decor[y][x] == ' ' and self.is_walkable(x, y): self.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 12V [############] 48/48 M [########] 4/4El 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.def is_heavily_wounded(self): return self.hp * 4 < self.max_hp()
def accuracy(self): return 75 + self.stat('agility') * 2 + self.raw_stat('accuracy') - (15 if self.is_heavily_wounded() else 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:
hero = Creature('Iva', 40, stats(10, 4, 10))hero.hp = 14log = Log()apply_effect(hero, make_effect('poison', 3, 3), log)print(report(hero))for turn in range(1, 4): print(f"Turno {turn}") tick_effects(hero, log) print(report(hero))hero.heal(10)print(report(hero))El veneno se cuela en Iva.Vida 14/40 [ven:3] precisión 95Turno 1El veneno quema a Iva (3).Vida 11/40 [ven:2] precisión 95Turno 2El veneno quema a Iva (3).¡Iva sufre una herida grave!Vida 8/40 [ven:1] precisión 80Turno 3El veneno quema a Iva (3).El veneno deja de afectar a Iva.Vida 5/40 precisión 80Vida 15/40 precisión 95En 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.def hurt(ctx, target, amount): was_heavy = target.is_heavily_wounded() target.hp = max(0, target.hp - amount) if target.is_alive() and not was_heavy and target.is_heavily_wounded(): ctx.say(t('combat.wounded', target.name))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.
def is_heavily_wounded(self):
return self.hp * < self.max_hpPor debajo del 25 % es lo mismo que hp × 4 < maxHp. Multiplicamos en lugar de dividir maxHp / 4 para quedarnos con números enteros: así no hay redondeo y el resultado es igual en los cuatro lenguajes.
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.def damage_word(damage, max_hp): share = percent(damage, max_hp) 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.def begin_turn(self, actor): actor.energy -= 100 stunned = actor.has_effect('stun') tick_effects(actor, self) actor.tick_cooldowns() self.handle_deaths() if not actor.is_alive(): return False # Tras un aturdimiento el siguiente turno está asegurado: si no, tres enemigos con garrotes dejarían al héroe clavado. actor.stun_proof = stunned if stunned: self.say(t('combat.stunned', {'name': actor.name})) return False return TrueEn 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íoEl 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:
Vida 30/30 precisión 95El frío se pega a Iva.Vida 30/30 [frío:2] precisión 91Iva se enfría más (capas: 2).Vida 30/30 [fríox2:2] precisión 87Iva se enfría más (capas: 3).Vida 30/30 [fríox3:2] precisión 83Iva se enfría más (capas: 3).Vida 30/30 [fríox3:2] precisión 83Turno 1Vida 30/30 [fríox3:1] precisión 83Turno 2El frío deja de afectar a Iva.Vida 30/30 precisión 95La 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
burnya 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 deid). ¿Qué debe devolverapplyEffecty cuál es la diferencia entre la inmunidad y la reglaignore? - Vendaje. Haz una poción que quite el sangrado y ponga
regendurante 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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Herencia · Polimorfismo · Listas: append, remove y sort · dict y set
- Ejercicios sobre el tema: Las crónicas de Arcanum