Zum Inhalt
wannacode

Blut, Wunden und Effekte

Kurz gesagt: Ein Effekt ist etwas, das einige Züge auf einem Wesen bleibt: Er hat einen Moment beim Auflegen, einen in jedem Zug und einen beim Ablaufen, und die Wiederholungsregel sagt, ob erneutes Auflegen auffrischt, stapelt oder ignoriert wird.

Problem

Ein Schnitt, drei Züge

Ein Wolf kratzt dich, und aus der Wunde fließt Blut. Sie tut nicht nur einmal weh: Sie tut in jedem Zug weh, bis die Wunde sich schließt. Ein zweiter Schnitt beginnt keine neue Blutung, er fügt der alten eine Wunde hinzu. Und wenn das Leben unter ein Viertel fällt, zittert die Hand, und die Schläge werden ungenauer.

Hier sind drei Züge aus dem echten Spiel: Iva gegen den Wolfsführer.

=== Kampf ===
Iva, Barbar St.2
L [###########-] 62/67 M [########] 5/5
Blutx3:2
1) Wolfsführer [########] 36/36
Kampf! Gegen dich: Wolfsführer.
Wolfsführer springt mit den Zähnen voran.
Wolfsführer kratzt Iva (2).
Iva blutet.
Iva blutet stärker (Wunden: 3).
Iva verliert Blut (3).
1 Angriff 2 Fähigkeit 3 Gegenstand 4 Flucht

Unter der Lebensleiste steht Blutx3:2: Blutung mit drei Wunden (x3) und noch zwei Zügen. Das Protokoll sagt, was geschieht: Der Effekt wird aufgelegt, blutet stärker (Wunden: 3), und das Leben sinkt um 3. So kommen diese Zahlen zustande.

Das ist ein Effekt: etwas, das einige Züge auf einem Wesen bleibt und in ihnen wirkt. Gift, Brennen, Betäubung, Regeneration, Wut und Schild sind von derselben Art. Damit es sie alle geben kann, muss ein Effekt fünf Dinge können: drei Momente (beim Auflegen, in jedem Zug, beim Ablaufen), eine Wiederholungsregel, Änderungen der Werte, eine Spur auf der Karte und eine schwere Wunde. Die Lektion baut sie nacheinander in kleinen Programmen und zeigt dann dasselbe im Spiel.

Genauer, für Neugierige

Blut im Spiel ist eine Mechanik, kein Bild. Die Blutung ist ein Effekt mit Zahlen: Wunden, Züge, Schaden. Das Kampfprotokoll nennt die Stärke eines Treffers mit Wörtern („kratzt“, „zerschneidet“) und die Blutung mit einem Satz, ohne Schilderungen von Gewalt um ihrer selbst willen. Das Spiel ist für Schüler und Erwachsene und bleibt es. Das Blut ist da, weil es dem Spieler eine Entscheidung gibt: verbinden oder fliehen, abwarten oder noch einmal zuschlagen.

Die Idee

Ein Effekt hat drei Momente

Was macht Gift? Drei verschiedene Dinge in drei verschiedenen Momenten: wenn es aufgelegt wird („Gift sickert ins Blut“), in jedem Zug (Schaden) und wenn es abläuft („Seine Wirkung lässt nach“). Ein Effekt ist deshalb eine Klasse mit drei Methoden, die standardmäßig nichts tun: onApply, onTurn und onExpire. Jede Art von Effekt ändert nur das, was sie braucht.

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

Die Klasse hält außerdem eine id (damit findet man den Text und den Effekt in der Liste), turns (wie viele Züge übrig sind), power (die Stärke) und stacks (Wunden, Schichten). label() macht ein kurzes Etikett wie Gift:2 für die Leiste im Kampf.

Ein Wesen hält seine Effekte in einer Liste effects. In jedem Zug geht tickEffects die Liste durch: Der Effekt wirkt (onTurn), verliert dann einen Zug, und wenn die Züge ablaufen, wird er entfernt und sagt onExpire. Das Programm legt auf dieselbe Iva Gift für 3 Züge mit Stärke 2 und Regeneration für 2 Züge mit Stärke 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"Zug {turn}")
tick_effects(hero, log)
print(status(hero))
Ausgabe
Gift sickert in Iva.
Iva erholt sich langsam.
Zug 1
Das Gift brennt in Iva (2).
Iva erholt sich (+1).
Leben 19/20 [Gift:2 Regen:1]
Zug 2
Das Gift brennt in Iva (2).
Iva erholt sich (+1).
Regeneration lässt bei Iva nach.
Leben 18/20 [Gift:1]
Zug 3
Das Gift brennt in Iva (2).
Gift lässt bei Iva nach.
Leben 16/20

Das Gift wirkt dreimal (Züge 1, 2 und 3) und läuft im dritten ab. Die Regeneration wirkt zweimal und läuft im zweiten ab. Die beiden Effekte laufen gleichzeitig, jeder mit seinen Zügen. Deshalb heißt turns = 3 „drei Wirkungen“, nicht „bis der dritte Zug vorbei ist“.

ctx ist das Protokoll: Es hat say(text) und addBlood(amount). Im Kampf ist es das Kampfprotokoll, auf der Karte das Spiel selbst. So funktioniert derselbe Effekt an beiden Orten, und das Protokoll entscheidet, wohin der Text geht. Die beiden Klassen und das Wesen selbst stehen im Block weiter unten.

Genauer, für Neugierige

Das Protokoll, das Wesen und die Texte. Die Texte der Effekte sind Daten, kein Code: ein Schlüssel und eine Vorlage, wie im Kanon (t('fx.on.' + this.id, …)). So schreibt derselbe Motor auf Bulgarisch, Englisch, Deutsch und Spanisch.

class Log:
def __init__(self):
self.blood = 0
def say(self, text):
print(text)
def add_blood(self, amount):
self.blood += amount
# Das stille Protokoll schreibt nichts, zählt aber das Blut.
class Quiet:
def __init__(self):
self.blood = 0
def say(self, text):
pass
def add_blood(self, amount):
self.blood += amount
class 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
# Unter 25 % Leben ist die Wunde schwer: Schläge werden ungenauer.
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}
# Die Texte sind Daten: ein Schlüssel und eine Vorlage. {name} ist der Name, {n} ist die Zahl.
TEXT = {
'fx.on.bleed': '{name} blutet.',
'fx.on.poison': 'Gift sickert in {name}.',
'fx.on.burn': '{name} fängt Feuer.',
'fx.on.stun': '{name} verliert das Gleichgewicht.',
'fx.on.regen': '{name} erholt sich langsam.',
'fx.on.rage': '{name} gerät in Wut!',
'fx.on.shield': 'Ein Schild schützt {name}.',
'fx.more.bleed': '{name} blutet stärker (Wunden: {n}).',
'fx.tick.bleed': '{name} verliert Blut ({n}).',
'fx.tick.poison': 'Das Gift brennt in {name} ({n}).',
'fx.tick.burn': 'Flammen beißen {name} ({n}).',
'fx.tick.regen': '{name} erholt sich (+{n}).',
'fx.off.bleed': 'Die Wunden von {name} hören auf zu bluten.',
'fx.off.poison': 'Gift lässt bei {name} nach.',
'fx.off.burn': 'Brennen lässt bei {name} nach.',
'fx.off.regen': 'Regeneration lässt bei {name} nach.',
'fx.off.rage': 'Wut lässt bei {name} nach.',
'fx.off.shield': 'Schild lässt bei {name} nach.',
'fxs.bleed': 'Blut',
'fxs.poison': 'Gift',
'fxs.burn': 'Feuer',
'fxs.stun': 'Betäub',
'fxs.regen': 'Regen',
'fxs.rage': 'Wut',
'fxs.shield': 'Schild',
'combat.wounded': '{name} ist schwer verwundet!',
}
def t(key, name='', n=0):
return TEXT[key].replace('{name}', name).replace('{n}', str(n))

Quiet ist ein Protokoll, das schweigt, aber das Blut zählt. Im Spiel ist es für Feinde außerhalb des Bildschirms: Sie bluten und sterben, ohne dem Spieler etwas zu schreiben. Das Wesen und seine Werte stammen aus der Lektion über Wesen; hier brauchen wir stat, heal, findEffect und removeEffect.

Im Spiel ist es dasselbe. Der Unterschied liegt nur in den Texten: Der Kanon hat eine gemeinsame Vorlage Die Wirkung von {Effekt} auf {Name} lässt nach und nimmt den Namen des Effekts aus der Tabelle, unsere Tabelle hat dagegen eine Zeile pro Effekt.

# region effects
# Ein Effekt hat drei Momente: beim Anwenden (on_apply), jeden Zug (on_turn) und beim Ablaufen (on_expire),
# und eine Regel für Wiederholung: auffrischen (refresh), addieren (stack) oder ignorieren (ignore).
# ctx ist das Kampfprotokoll oder die Karte: beide haben say(text) und 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)

Die Idee

Was geschieht, wenn ein Effekt ein zweites Mal aufgelegt wird

Der Wolf ist schon vergiftet und bekommt noch mehr Gift. Oder er blutet schon und wird wieder gekratzt. Oder er ist schon betäubt, und jemand schlägt ihn mit einer Keule. Die drei Fälle sind nicht gleich, deshalb trägt jeder Effekt eine Wiederholungsregel:

  • auffrischen (refresh, der Standard): Die längere der beiden Dauern und die größere Stärke bleiben. Gift, Brennen, Regeneration, Wut und Schild sind so;
  • stapeln (stack): Eine weitere Schicht kommt dazu, bis maxStacks(). Die Blutung ist so;
  • ignorieren (ignore): Erneutes Auflegen tut nichts. Die Betäubung ist so: Sonst würden drei Feinde mit Keulen den Helden für immer festhalten.

Die Regel wird von dem Effekt gelesen, der schon auf dem Wesen liegt (existing), und von applyEffect angewendet. Die Funktion gibt true zurück, wenn sich etwas geändert hat:

# Legt einen Effekt nach seiner Regel auf; gibt zurück, ob sich etwas geändert hat.
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
# Jeder Effekt wirkt und verliert dann einen Zug; abgelaufene werden entfernt.
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)

Das Programm legt sechs Effekte auf einen Goblin und druckt für jeden, ob sich etwas geändert hat und was der Goblin danach trägt:

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} -> {'ja' if changed else 'nein'} {status(goblin)}")
Ausgabe
Gift sickert in Goblin.
Gift:3 -> ja Leben 40/40 [Gift:3]
Gift:5 -> ja Leben 40/40 [Gift:5]
Goblin blutet.
Blut:3 -> ja Leben 40/40 [Gift:5 Blut:3]
Goblin blutet stärker (Wunden: 2).
Blut:3 -> ja Leben 40/40 [Gift:5 Blutx2:3]
Goblin verliert das Gleichgewicht.
Betäub:1 -> ja Leben 40/40 [Gift:5 Blutx2:3 Betäub:1]
Betäub:2 -> nein Leben 40/40 [Gift:5 Blutx2:3 Betäub:1]

Das zweite Gift verlängert das erste (Gift:3 wird zu Gift:5), ohne neue Meldung. Die zweite Blutung ist eine neue Wunde: Blutx2:3. Die zweite Betäubung gibt nein zurück, und nichts ändert sich.

# Legt einen Effekt nach seiner Regel auf; gibt zurück, ob sich etwas geändert hat.
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 True

Im Spiel hat die Funktion oben noch zwei Zeilen. Auf ein totes Wesen wird kein Effekt gelegt, und manche Wesen sind immun (das Knochenskelett hat kein Blut zum Bluten). Die zweite Zeile schützt vor endlosem Betäuben: Nach einer Betäubung ist der nächste Zug sicher.

# Wendet einen Effekt nach seiner Regel an; liefert, ob sich etwas geändert hat.
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
# Der Zug der Effekte: Jeder wirkt, verliert dann einen Zug; abgelaufene werden entfernt.
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)
Genauer, für Neugierige

Die Regel ist eine Methode des Effekts, kein Feld: Ein neuer Effekt mit stackRule() { return "stack" } und maxStacks() { return 3 } ist der ganze Unterschied. Diese Idee heißt Polymorphie: applyEffect weiß nicht, was für ein Effekt es ist, es fragt ihn. Die Alternative ist ein switch (effect.id) in applyEffect, der mit jedem neuen Effekt wächst und die Regeln weit weg von dem Effekt hält, zu dem sie gehören.

Sieh es dir an

Blutung: Wunden, die sich stapeln

Die Blutung ist der Effekt, für den es die Regel stack gibt. Jeder neue Schnitt ist eine Wunde mehr (höchstens 5), jede Wunde kostet 1 Leben pro Zug, und die Züge springen auf 3 zurück. Der Schaden eines Zuges ist die Zahl der Wunden mal die Stärke einer Wunde:

# Jede Wunde kostet 1 Leben pro Zug, bis zu 5 Wunden.
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))

Der Konstruktor nimmt Züge und Wunden, nicht die Stärke: Die Stärke einer Wunde ist immer 1. makeEffect("bleed", 3, 2) heißt also „Blutung für drei Züge mit zwei Wunden“: Die dritte Zahl ist bei der Blutung die Zahl der Wunden. So legt ein Feind mit einem „Reißen“-Angriff zwei Wunden auf einmal.

# Jede Wunde kostet 1 Leben pro Zug, bis zu 5 Wunden.
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))
# Legt einen Effekt nach seiner Regel auf; gibt zurück, ob sich etwas geändert hat.
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
# Jeder Effekt wirkt und verliert dann einen Zug; abgelaufene werden entfernt.
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)
# Schaden außerhalb eines Schlags: nie unter null, und er meldet, wenn die Wunde schwer wurde.
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"Zug {turn}")
if turn <= 3:
apply_effect(wolf, make_effect('bleed', 3, 1), log)
tick_effects(wolf, log)
print(status(wolf))
Schritt für Schritt, in Worten
  1. Der Wolf hat 15 Leben. Die Blutung ist ein Effekt mit der Regel stack: Jeder neue Schnitt ist eine Wunde mehr (bis zu 5), und die Züge springen auf 3 zurück. Drei Schläge, fünf Züge Blut.
  2. Zug 1: der erste Schlag. Der Wolf blutet noch nicht, also legt applyEffect einen neuen Effekt auf (onApply). Dann nimmt der Effektzug 1 × 1 = 1: Leben 14, 2 Züge übrig.
  3. Zug 2: der zweite Schlag. Der Effekt besteht schon und seine Regel ist stack: Die Wunden werden 2, die Züge springen auf 3. Der Schaden ist 2 × 1 = 2: Leben 12.
  4. Zug 3: der dritte Schlag, 3 Wunden. Eine Wunde kostet 1 Leben pro Zug, drei Wunden kosten 3. Leben 9. Weitere Schläge gibt es nicht, aber das Blut bleibt.
  5. Zug 4: kein Schlag. tickEffects nimmt wieder 3 × 1 = 3 und zieht einen Zug ab: 1 übrig. Leben 6.
  6. Zug 5: das letzte Blut, 3. Das Leben fällt auf 3, unter 25 % von 15: hurt meldet „schwere Wunde“. Die Züge laufen ab und der Effekt wird entfernt (onExpire).
  7. Dieselben drei Schläge mit der Regel refresh (ohne Stapeln) hätten nur 5 Leben genommen; mit stack nahmen sie 12 von 15. Vergossenes Blut: 12.

Das Programm schlägt den Wolf in drei Zügen hintereinander (Züge 1–3), in den anderen zwei tickt es nur. Der Wolf hat 15 Leben:

wolf = Creature('Wolf', 15, stats(8, 2, 6))
log = Log()
for turn in range(1, 6):
print(f"Zug {turn}")
if turn <= 3:
apply_effect(wolf, make_effect('bleed', 3, 1), log)
tick_effects(wolf, log)
print(status(wolf))
print(f"Vergossenes Blut: {log.blood}")
Ausgabe
Zug 1
Wolf blutet.
Wolf verliert Blut (1).
Leben 14/15 [Blut:2]
Zug 2
Wolf blutet stärker (Wunden: 2).
Wolf verliert Blut (2).
Leben 12/15 [Blutx2:2]
Zug 3
Wolf blutet stärker (Wunden: 3).
Wolf verliert Blut (3).
Leben 9/15 [Blutx3:2]
Zug 4
Wolf verliert Blut (3).
Leben 6/15 [Blutx3:1]
Zug 5
Wolf verliert Blut (3).
Wolf ist schwer verwundet!
Die Wunden von Wolf hören auf zu bluten.
Leben 3/15
Vergossenes Blut: 12

Der Schaden pro Zug wächst 1, 2, 3, ohne dass die Schläge stärker wären: Die Wunden addieren sich. In Zug 3 sind es drei Wunden, und die Züge stehen wieder auf 3, die Blutung geht also noch zwei Züge mit je 3 weiter. Insgesamt 1 + 2 + 3 + 3 + 3 = 12. Das vergossene Blut ist genau das genommene Leben (15 − 3 = 12), weil addBlood den Schaden der Blutung zählt. In Zug 5 fällt das Leben unter ein Viertel, und „schwere Wunde“ erscheint (die letzten Karten).

Würde sich die Blutung nicht stapeln, sondern nur auffrischen, nähmen dieselben drei Schläge 1 pro Zug und insgesamt 5 Leben (5 Züge Blut zu je 1). Mit Stapeln sind es 12. Eine kleine Änderung der Regel macht Waffen mit Klinge gefährlich.

Woher kommen die Wunden im Spiel? Aus dem Kampfsystem: Ein Schlag mit einer Klingenwaffe legt bei einem kritischen Treffer oder in 35 % der Fälle eine Wunde, und das „Reißen“ eines Feindes legt zwei auf einmal. Die Klasse im Spiel ist diese:

# region bleeding
# Blutung stapelt sich: Jeder neue Schnitt ist eine Wunde mehr (bis 5), jede kostet 1 Leben pro Zug, drei
# Züge lang. Vergossenes Blut zählt für die Statistik, und eine blutende Kreatur, die sich bewegt, hinterlässt eine Spur.
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}))
Genauer, für Neugierige

Im Kanon ist die Blutung des Helden und die der Feinde dieselbe Klasse. Deshalb entfernt das Gegengift Gift, Blutung und Brennen mit removeEffect, und die langsame Erholung des Helden (+1 Leben in jedem dritten Zug) wirkt nicht, solange er blutet oder vergiftet ist: worldTurn fragt hasEffect.

Probier es aus

Sage die Ausgabe voraus

Der Held blutet mit einer Wunde. Nach dem ersten Zug bekommt er einen weiteren Schnitt mit zwei Wunden. Die Effekte arbeiten mit Quiet, gedruckt wird also nur das Leben. Was gibt das Programm aus?

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"Leben {hero.hp}")
Antwort

Probier es aus

Finde den Fehler

Ein Kollege hat onTurn der Blutung angefasst. Das Programm mit dem Wolf gibt das hier aus, und das Leben sinkt um 1 pro Zug, obwohl es drei Wunden gibt:

# Jede Wunde kostet 1 Leben pro Zug, bis zu 5 Wunden.
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))
Ausgabe
Zug 1
Wolf blutet.
Wolf verliert Blut (1).
Leben 14/15 [Blut:2]
Zug 2
Wolf blutet stärker (Wunden: 2).
Wolf verliert Blut (1).
Leben 13/15 [Blutx2:2]
Zug 3
Wolf blutet stärker (Wunden: 3).
Wolf verliert Blut (1).
Leben 12/15 [Blutx3:2]
Zug 4
Wolf verliert Blut (1).
Leben 11/15 [Blutx3:1]
Zug 5
Wolf verliert Blut (1).
Die Wunden von Wolf hören auf zu bluten.
Leben 10/15
Vergossenes Blut: 5

Das Etikett sagt Wunden: 3, und „verliert Blut“ bleibt bei 1. Welche Zeile berechnet den Schaden?

Wähle die Zeile mit dem Fehler.

Die Idee

Effekte, die Werte ändern

Nicht jeder Effekt wirkt in jedem Zug. Wut gibt Stärke und nimmt Verteidigung, solange sie anhält, und ein Schild gibt Verteidigung. Sie tun in onTurn nichts. Stattdessen wird der Wert eines Wesens aus einer Basis und den Effekten berechnet:

Wert = Basis des Wesens + Ausrüstung + die Änderungen der Effekte

Jeder Effekt hat eine Methode modifier(stat), die zurückgibt, wie viel er zu einem Wert hinzufügt (standardmäßig 0). Das Wesen addiert die Antworten bei jeder Frage nach einem Wert und speichert das Ergebnis nicht. Wird ein Effekt aus der Liste entfernt, kehrt der Wert deshalb von selbst zurück:

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 0

Das Programm gibt Iva Wut für 3 Züge mit Stärke 4 und ein Schild für 2 Züge mit Stärke 5 und druckt dann die Werte:

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"Zug {turn}")
tick_effects(hero, log)
print(stat_line(hero))
Ausgabe
Iva gerät in Wut!
Ein Schild schützt Iva.
Stärke 14, Verteidigung 7
Zug 1
Stärke 14, Verteidigung 7
Zug 2
Schild lässt bei Iva nach.
Stärke 14, Verteidigung 2
Zug 3
Wut lässt bei Iva nach.
Stärke 10, Verteidigung 4

Stärke 14 ist 10 + 4. Die Verteidigung ist 4 − 2 (Wut) + 5 (Schild) = 7. Läuft das Schild ab, bleiben 4 − 2 = 2, und läuft auch die Wut ab, ist alles wieder bei der Basis. Niemand „gibt zurück“: Der Wert wird bei jeder Frage neu berechnet.

Zwei Einzelheiten. stat fällt nicht unter 0: Mit Schwäche und ohne Rüstung ist die Verteidigung 0, nicht negativ. Und ein Effekt, der sich stapelt und einen Wert gibt, muss in modifier mit stacks multiplizieren, so wie die Blutung in onTurn multipliziert: Das ist die Übung am Ende der Lektion.

Das Spiel hat dieselben zwei Effekte:

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 0
Genauer, für Neugierige

Das Spiel hat weitere Wertänderungen: DodgeEffect (Ausweichen), WeakEffect (Schwäche durch den Schrei eines Feindes) und FuryEffect (die Raserei des Wächters, die zweite Phase). Alle sind etwa zehn Zeilen lang. Bei FuryEffect ist anders, dass er 999 Züge dauert: bis zum Ende des Kampfes.

Die Idee

Die Blutspur

Ein blutender Wolf hinterlässt eine Spur auf der Karte. Sie gehört zum Spiel: Die Quest „Die Blutspur“ führt dich auf ihr zum Bau des Wolfsführers.

Wer legt den Tropfen? Nicht der Effekt. Der Effekt weiß, wie viel Blut fließt (ctx.addBlood(damage)), aber nicht, wo das Wesen steht. Das weiß der, der es bewegt. Deshalb fragt die Bewegung hasEffect("bleed") und legt einen Tropfen auf die Zelle, die das Wesen verlässt. Die Ebene hält die Tropfen in einer eigenen Schicht über dem Boden (decor) und legt Blut nur auf sauberen Boden: Eine Leiche und Gegenstände bleiben sichtbar.

class Level:
def __init__(self, floor):
self.floor = floor
self.decor = [' '] * len(floor)
# Blut kommt nur auf einen sauberen Boden.
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 row

Das Programm schickt einen verwundeten Wolf durch einen Gang. Die Wunde dauert drei Züge, es gibt also drei Tropfen, und danach läuft der Wolf sauber weiter:

level = Level('#...........#')
wolf = Creature('Wolf', 15, stats(8, 2, 6))
quiet = Quiet()
x = 1
apply_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"Vergossenes Blut: {quiet.blood}")
Ausgabe
#w..........#
#,w.........#
#,,w........#
#,,,w.......#
#,,,.w......#
#,,,..w.....#
Vergossenes Blut: 3

Genau auf den Zellen, die der Wolf verlassen hat, solange er blutete, liegt ein Tropfen. Nach dem dritten Zug läuft die Blutung ab, und die Spur endet. Das stille Quiet ist hier Absicht: Diese Rechnung braucht keine Meldungen.

Im Spiel ist die Bewegung dieselbe. Der Feind hinterlässt bei jedem Schritt Blut (moveEnemy) und der Held bei stepHero, beide mit derselben Bedingung. Hier sind der Schritt des Feindes und die Zelle, die das Blut annimmt:

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
# Blut bedeckt keine Leiche: Die Leiche bleibt sichtbar.
def add_blood(self, x, y):
if self.decor[y][x] == ' ' and self.is_walkable(x, y):
self.decor[y][x] = ','

Hier ist die Spur im Spiel: Die Tropfen , führen durch die Galerie zum Bau des Wolfs.

####.##### #########,### Obere Galerie
#.# Ebene 1 von 3
####,####
......,...# Ringsum:
.......,..# L Wolfsführer (schl
.......,..# w Blutwolf (schläft
######@###
.....
.....,...
........,L.
w..........
.........
#####
Iva, Barbar St.1 EP 19/20 Gold 12
L [############] 48/48 M [########] 4/4
Die Blutspur: Folge dem Blut bis zum Bau.
Die Tür knarrt auf.
Du siehst: Wolfsführer.

Die Idee

Die schwere Wunde

Fällt das Leben unter ein Viertel, ist die Wunde schwer: Die Schläge des Wesens werden ungenauer. Die Regel steht an zwei kleinen Stellen: isHeavilyWounded und die Treffsicherheit.

# Unter 25 % Leben ist die Wunde schwer: Treffer werden ungenauer.
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)

Unter 25 % heißt hp × 4 < maxHp (ohne Brüche und ohne Division). Die Treffsicherheit ist 75 plus das Doppelte der Gewandtheit, und bei einer schweren Wunde noch 15 weniger. Das Programm vergiftet Iva, die mit 14 von 40 Leben beginnt. Im zweiten Zug drückt das Gift sie unter die Grenze:

hero = Creature('Iva', 40, stats(10, 4, 10))
hero.hp = 14
log = Log()
apply_effect(hero, make_effect('poison', 3, 3), log)
print(report(hero))
for turn in range(1, 4):
print(f"Zug {turn}")
tick_effects(hero, log)
print(report(hero))
hero.heal(10)
print(report(hero))
Ausgabe
Gift sickert in Iva.
Leben 14/40 [Gift:3] Treffsicherheit 95
Zug 1
Das Gift brennt in Iva (3).
Leben 11/40 [Gift:2] Treffsicherheit 95
Zug 2
Das Gift brennt in Iva (3).
Iva ist schwer verwundet!
Leben 8/40 [Gift:1] Treffsicherheit 80
Zug 3
Das Gift brennt in Iva (3).
Gift lässt bei Iva nach.
Leben 5/40 Treffsicherheit 80
Leben 15/40 Treffsicherheit 95

In Zug 2 ist das Leben 8, und 8 × 4 = 32 liegt unter 40, deshalb sinkt die Treffsicherheit von 95 auf 80. Die Meldung „schwere Wunde“ kommt genau einmal. Das macht hurt: Es fragt vor dem Schaden (wasHeavy) und sagt nur dann etwas, wenn der Übergang jetzt geschieht.

# Schaden außerhalb eines Schlags: nie unter null, und er meldet, wenn die Wunde schwer wurde.
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))

In Zug 3 sinkt das Leben weiter, aber die Meldung wiederholt sich nicht. Am Ende bringt heal(10) das Leben auf 15, und die Treffsicherheit ist wieder 95. Eine schwere Wunde ist kein Effekt in der Liste: Sie wird aus dem Leben berechnet und verschwindet, sobald das Leben steigt.

def is_heavily_wounded(self):
    return self.hp *  < self.max_hp
Genauer, für Neugierige

Die Stärke eines Treffers wird mit Wörtern gesagt, je nachdem, welchen Anteil des Lebens des Ziels er nimmt. Schwellen: unter 10 % „kratzt“, bis 25 % „trifft“, bis 45 % „zerschneidet“, darüber „zerschmettert“. Deshalb ist die Blutung leise (kleine Zahlen), während sich der erste Schlag des Wächters wie ein Schlag anfühlt.

# Das Wort für die Stärke eines Treffers hängt davon ab, wie viel vom Leben des Ziels er nimmt.
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')

Genauer

Wer ruft tickEffects auf

Effekte haben keine eigene Uhr: Jemand muss tickEffects aufrufen. Im Kampf ist das der Beginn des Zuges jedes Wesens. Ein betäubtes Wesen überspringt seinen Zug. beginTurn merkt sich vor tickEffects, ob eine Betäubung da ist, weil das Ticken sie entfernt (sie dauert einen Zug).

# Der Beginn eines Zuges: Effekte wirken, Abklingzeiten sinken. Ein betäubter Kämpfer setzt aus.
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
# Nach einer Betäubung ist der nächste Zug sicher: Sonst würden drei Feinde mit Keulen den Helden festnageln.
actor.stun_proof = stunned
if stunned:
self.say(t('combat.stunned', {'name': actor.name}))
return False
return True

Auf der Karte außerhalb des Kampfes ist der Zug die Welt (worldTurn): Der Held handelt, die Effekte des Helden ticken nach jedem Schritt, und danach ticken die Effekte jedes Feindes. Ein Feind außerhalb des Bildschirms wird mit Quiet getickt: Verblutet er, sieht es niemand, und es fällt keine Beute.

Ein Effektzug ist also dasselbe an zwei Orten. Der Effekt weiß nicht, von wo er aufgerufen wird, weil alles, was er braucht, in target und ctx steckt. Deshalb ist ein neuer Effekt eine Klasse und eine Zeile in makeEffect: Kampfsystem und Karte bleiben unberührt. Die nächste Lektion, Aktionen, handelt von den Effekten, die Fähigkeiten auf andere legen.

Übung

Kälte

Löse das in deinem Editor.

Kälte. Füge einen Effekt chill hinzu. Seine Regel ist stack mit höchstens 3 Schichten, und jede Schicht nimmt 2 von der Gewandtheit. Die Gewandtheit wird über modifier berechnet, sie muss also mit den Schichten multipliziert werden. Arbeite mit dem Code aus der Lektion: Effect, Creature, makeEffect, applyEffect und tickEffects.

Füge auch die Texte des Effekts zur Tabelle der Texte hinzu:

fx.on.chill: Kälte kriecht in {name}.
fx.more.chill: {name} friert stärker (Schichten: {n}).
fx.off.chill: Kälte lässt bei {name} nach.
fxs.chill: Kälte

Das Programm erzeugt Iva (Leben 30, Stärke 10, Verteidigung 4, Gewandtheit 10) und druckt report (den Zustand und die Treffsicherheit, wie im Programm zur schweren Wunde). Zuerst druckt es den Zustand ohne Effekte. Dann legt es viermal makeEffect("chill", 2, 1) auf und druckt nach jedem Auflegen den Zustand. Am Ende macht es zwei Züge mit tickEffects und druckt nach jedem „Zug“ und den Zustand. Die Ausgabe ist:

Ausgabe
Leben 30/30 Treffsicherheit 95
Kälte kriecht in Iva.
Leben 30/30 [Kälte:2] Treffsicherheit 91
Iva friert stärker (Schichten: 2).
Leben 30/30 [Kältex2:2] Treffsicherheit 87
Iva friert stärker (Schichten: 3).
Leben 30/30 [Kältex3:2] Treffsicherheit 83
Iva friert stärker (Schichten: 3).
Leben 30/30 [Kältex3:2] Treffsicherheit 83
Zug 1
Leben 30/30 [Kältex3:1] Treffsicherheit 83
Zug 2
Kälte lässt bei Iva nach.
Leben 30/30 Treffsicherheit 95

Die Treffsicherheit sinkt um 4 pro Schicht (2 Gewandtheit × 2). Das vierte Auflegen bleibt bei 3 Schichten, sagt aber „Schichten: 3“ und setzt die Züge wieder auf 2. Nach den zwei Zügen läuft die Kälte ab, und die Treffsicherheit ist wieder 95.

Weitere Übungen

  • Feuerwand. Der Effekt burn ist schon im Code. Lass ihn sich bis zu 3-mal stapeln und zeige, wie sich der Schaden ändert.
  • Immunität. Füge dem Wesen immune (eine Menge von ids) hinzu. Was soll applyEffect zurückgeben, und was ist der Unterschied zwischen Immunität und der Regel ignore?
  • Verband. Baue einen Trank, der die Blutung entfernt und regen für 3 Züge mit Stärke 2 auflegt. Wie viel Leben ist nach drei Zügen zurückgegeben, wenn das Leben nicht voll ist?

Test

Teste dich

1. Der Wolf hat 3 Wunden und blutet. Wie viel Leben verliert er pro Zug, und warum?
2. Der Held ist betäubt, und ein zweiter Feind betäubt ihn noch einmal. Was macht die Regel ignore?
3. Ein Effekt mit turns = 3 wird in Zug 1 aufgelegt, und nichts frischt ihn auf. Wie oft läuft onTurn?
4. Die Gewandtheit ist 10, das Leben ist 9 von 40. Wie hoch ist die Treffsicherheit?
5. Wer legt den Bluttropfen auf den Boden: der Blutungseffekt oder das Wesen, das läuft?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·