Zum Inhalt
wannacode

Das Kampfsystem

Kurz gesagt: Ein Kampf läuft in Ticks: Jeder addiert sein Tempo zu einer Energieleiste, wer 100 erreicht, ist dran und zahlt 100. Ein Schlag ist Treffsicherheit gegen Ausweichen, Schaden = Waffe + Stärke − Abwehr (mindestens 1), ein kritischer Treffer verdoppelt, und die Schadensart entscheidet, was folgt.

Problem

Der Wolf ist schneller als der Golem

In der Mine trifft der Held einen Blutwolf und einen Goblin zugleich. Wer schlägt zuerst, und wie oft? Die erste Antwort lautet in Runden: der Held, dann jeder Feind der Reihe nach, und von vorn. Aber der Wolf ist doppelt so schnell wie der Erzgolem (Tempo 14 gegen 7). Schlagen beide einmal pro Runde, bedeutet Tempo nichts, und ein Spieler, der Punkte darin investiert hat, bekommt nichts dafür.

Deshalb ist die Reihenfolge anders, und ein Schlag hat eigene Regeln. Hier ist ein Kampfbildschirm aus dem Spiel (aus dem Durchlauf, an dem das Spiel geprüft wird): Der Held steht einer Riesenratte gegenüber, das Protokoll zeigt die letzten Zeilen, und der Schlag des Helden ging daneben.

=== Kampf ===
Iva, Barbar St.1
L [###########-] 46/48 M [########] 4/4
1) Riesenratte [########] 9/9
Kampf! Gegen dich: Riesenratte.
Riesenratte kratzt Iva (1).
Iva verfehlt Riesenratte.
Riesenratte kratzt Iva (1).
1 Angriff 2 Fähigkeit 3 Gegenstand 4 Flucht

Für dieses Bild braucht man fünf Dinge: die Zugreihenfolge, Treffsicherheit gegen Ausweichen, Schaden, Schadensarten und Worte im Protokoll. Die Lektion baut sie nacheinander mit kleinen Programmen, setzt sie dann zu einem Kampf des Helden gegen einen Wolf und einen Goblin zusammen und zeigt dasselbe im Spiel. Die Kämpfer haben hier fertige Werte: Wie sie sich aus Basis, Ausrüstung und Effekten zusammensetzen, steht in der Lektion über Wesen.

Die Idee

Kämpfer mit fertigen Werten

Ein Kämpfer ist eine Klasse mit Werten, Leben und einer Energieleiste:

class Fighter:
def __init__(self, name, max_hp, strength, agility, defense, speed,
weapon_min, weapon_max, kind, resist, weak):
self.name = name
self.max_hp = max_hp
self.strength = strength
self.agility = agility
self.defense = defense
self.speed = speed
self.weapon_min = weapon_min
self.weapon_max = weapon_max
self.kind = kind
self.resist = resist
self.weak = weak
self.hp = max_hp
self.energy = 0
def accuracy(self):
return 75 + self.agility * 2
def evasion(self):
return self.agility * 2
def crit_chance(self):
return 5 + self.agility // 10
def is_alive(self):
return self.hp > 0

Aus dem Geschick kommen drei Zahlen für den Kampf:

  • Treffsicherheit = 75 + 2 × Geschick: wie gut du triffst;
  • Ausweichen = 2 × Geschick: wie schwer man dich trifft;
  • Chance auf einen kritischen Treffer = 5 + Geschick / 10 (Ganzzahldivision): Jeder Schlag hat mindestens 5% Chance.

Der Wolf hat Geschick 8 und der Held 4: Die Treffsicherheit des Helden ist 75 + 8 = 83, das Ausweichen des Wolfs 16. Im Spiel stehen dieselben drei Formeln in Creature, aber die Werte kommen aus den Schichten (Basis + Ausrüstung + Effekte):

# Der Rohwert (er kann negativ sein): Basis + Ausrüstung + Effekte.
def raw_stat(self, stat):
value = self.base[stat] + self.gear_bonus(stat)
for effect in self.effects:
value += effect.modifier(stat)
return value
def stat(self, stat):
value = self.raw_stat(stat)
return 0 if value < 0 else value
def max_hp(self):
value = self.stat('maxHp')
return 1 if value < 1 else value
def max_mana(self):
return self.stat('maxMana')
# 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)
def evasion(self):
return self.stat('agility') * 2 + self.raw_stat('evasion')
def crit_chance(self):
return 5 + self.stat('agility') // 10
self.agility // 10 ist die Ganzzahldivision. Die Werte sind nie negativ, deshalb liefern // und / in den anderen Sprachen dasselbe.

Die Idee

Energie statt Runden

Statt Runden hat jeder Kämpfer eine Energieleiste. Die Welt läuft in Ticks: Bei jedem Tick addiert jeder sein Tempo zur Leiste. Wer 100 erreicht, darf handeln; wenn er handelt, zahlt er 100. Der Schnellere erreicht 100 öfter, also ist er öfter dran, und niemand wartet auf die „Runde“.

def next_actor(self):
while True:
best = None
for f in self.participants():
if f.energy >= 100 and (best is None or f.energy > best.energy):
best = f
if best is not None:
return best
for f in self.participants():
f.energy += max(1, f.speed)
self.ticks += 1

Hier sind die Regeln in der Reihenfolge, in der der Code sie liest:

  1. Unter den lebenden Kämpfern wird der mit der meisten Energie unter denen mit 100 oder mehr gesucht. Bei Gleichstand gewinnt der frühere in der Liste, und der Held steht vorn. (Verglichen wird mit >, nicht mit >=: Ein späterer muss strikt voller sein.)
  2. Gibt es keinen, beginnt ein Tick: Jeder addiert max(1, speed). Die untere Grenze 1 schützt die Schleife: Ein Kämpfer mit Tempo 0 oder weniger (ein Verlangsamungseffekt) würde nie 100 erreichen, und die Schleife wäre endlos.
  3. Der Gewinner wird nicht auf null gesetzt, er zahlt 100. Der Rest erhält seinen Vorsprung.

Das Programm reiht den Helden (Tempo 10), den Wolf (14) und den Goblin (10) auf und gibt die ersten sieben Züge aus:

hero = Fighter("Iva", 48, 7, 4, 3, 10, 4, 7, "slash", "", "")
wolf = Fighter("Blutwolf", 20, 3, 8, 1, 14, 2, 5, "slash", "", "")
goblin = Fighter("Speergoblin", 16, 2, 5, 1, 10, 2, 5, "pierce", "", "")
battle = Battle([hero, wolf, goblin])
for _ in range(7):
actor = battle.next_actor()
print(f"Tick {battle.ticks}: {actor.name} ist dran (Energie {actor.energy}).")
actor.energy -= 100
Ausgabe
Tick 8: Blutwolf ist dran (Energie 112).
Tick 10: Iva ist dran (Energie 100).
Tick 10: Speergoblin ist dran (Energie 100).
Tick 15: Blutwolf ist dran (Energie 110).
Tick 20: Iva ist dran (Energie 100).
Tick 20: Speergoblin ist dran (Energie 100).
Tick 22: Blutwolf ist dran (Energie 108).

Der Wolf ist bei Tick 8 zuerst dran (14 × 8 = 112). Bei Tick 10 stehen der Held und der Goblin gleichzeitig bei 100: Der Held steht in der Liste früher, und der Goblin handelt gleich danach, ohne neuen Tick. Der Wolf ist wieder bei Tick 15 dran, nicht bei 16: Von seinem ersten Zug sind 12 übrig.

Sieh es dir an

Die Warteschlange in Bewegung

Die drei Leisten wachsen Tick für Tick. Achte auf die Linie bei 100 und auf die nummerierten Plättchen unten:

def next_actor(self):
while True:
best = None
for f in self.participants():
if f.energy >= 100 and (best is None or f.energy > best.energy):
best = f
if best is not None:
return best
for f in self.participants():
f.energy += max(1, f.speed)
self.ticks += 1
# in der Kampfschleife
actor = battle.next_actor()
actor.energy -= 100
Schritt für Schritt, in Worten
  1. Drei Kämpfer: Iva, Blutwolf und Speergoblin. Jeder hat eine Energieleiste, die bei 0 beginnt, und ein Tempo (10, 14, 10). Wer 100 erreicht, ist dran.
  2. Noch kann niemand handeln, also macht die Welt einen Tick: Jeder addiert sein Tempo zur Leiste (mindestens 1). Die Leisten wachsen unterschiedlich schnell.
  3. Tick 8: Blutwolf steht bei 112. Er erreicht als Erster 100 und handelt. Er zahlt 100, nicht alles: 12 bleiben übrig.
  4. Tick 10: Iva und Speergoblin stehen beide bei 100. Gleichstand: Wer früher in der Liste steht, ist dran, und der Held steht vorn.
  5. Der Held hat bezahlt, und Speergoblin steht noch bei 100. Kein neuer Tick: Der nächste Aufruf von nextActor liefert ihn sofort.
  6. Tick 15: Blutwolf ist wieder dran, jetzt bei 110. Der Rest vom ersten Zug spart einen Tick: Ohne ihn müsste er bis Tick 16 warten.
  7. Nach 22 Ticks: Blutwolf war 3-mal dran, Iva und Speergoblin je 2-mal. Tempo ist die Geschwindigkeit der Leiste, keine Zahl von Zügen: So kommt der Schnellere öfter dran, und es gibt keine „Runden“.

Im Spiel steht dieselbe Warteschlange am Anfang der Klasse Battle, und ein Zug beginnt mit dem Zahlen von 100:

def next_actor(self):
while True:
best = None
for creature in self.participants():
if creature.energy >= 100 and (best is None or creature.energy > best.energy):
best = creature
if best is not None:
return best
for creature in self.participants():
creature.energy += max(1, creature.stat('speed'))
# 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

Die erste Zeile von beginTurn ist actor.energy -= 100. Danach kommen Effekte (Bluten, Gift), Abklingzeiten und das Aussetzen bei Betäubung: Das sind die Themen der nächsten Lektionen (Blut, Wunden und Effekte). Wichtig ist hier, dass die Zugreihenfolge vom Schlag selbst getrennt ist: nextActor sagt, wer handelt, und was er tut, ist eine andere Frage.

Genauer, für Neugierige

Diese Warteschlange ist eine Prioritätswarteschlange: Der Nächste ist der mit der höchsten Priorität (der meisten Energie). Hier wird sie umgesetzt, indem bei jedem Aufruf alle lebenden Kämpfer durchlaufen werden. In einem Kampf sind es höchstens vier, das sind also ein paar Vergleiche. Bei Hunderten Teilnehmern (die Schlacht von Armeen) nimmt man einen binären Heap: Der Nächste kommt in O(log n) heraus, nicht in O(n).

Auch die Ticks lassen sich überspringen: Statt jedes Mal ein Tempo zu addieren, rechnet man für jeden Kämpfer ⌈(100 − Energie) / Tempo⌉ Ticks bis zur Bereitschaft aus, nimmt den kleinsten und schiebt alle auf einmal um ihn vor. Das Ergebnis ist dasselbe, ohne Schleife. Der Kanon behält die einfache Schleife: Man liest sie auf einen Blick, und für vier reicht sie.

Das Spiel hat eine Grenze von 200 Heldenzügen pro Kampf (heroTurns): Wenn in den Regeln etwas hängen bleibt, endet der Kampf als Niederlage, statt ewig zu laufen.

Die Idee

Treffer oder Fehlschlag

Ein Schlag trifft nicht immer. Die Chance ist die Treffsicherheit des Angreifers minus das Ausweichen des Ziels, begrenzt auf 10 bis 95:

def clamp(value, low, high):
if value < low:
return low
if value > high:
return high
return value
def hit_chance(accuracy, evasion):
return clamp(accuracy - evasion, 10, 95)

Das Begrenzen (clamp) bedeutet: Jeder Schlag trifft mit mindestens 10% Chance und verfehlt mit mindestens 5%. Ein wendiges Ziel ist nicht unberührbar, und der genaueste Schlag ist nicht sicher. Dann würfelt der Kampf von 1 bis 100 und verfehlt, wenn der Wurf über der Chance liegt.

Das Programm zeigt vier Paare (Treffsicherheit, Ausweichen) und fünf Würfe mit dem Startwert 2026 gegen die Chance des Helden gegen den Wolf, 83 − 16 = 67%:

pairs = [(83, 16), (91, 8), (83, 80), (120, 0)]
for accuracy, evasion in pairs:
print(f"{accuracy} - {evasion} = {accuracy - evasion}, Chance {hit_chance(accuracy, evasion)}%")
chance = hit_chance(83, 16)
dice = Random(2026)
for _ in range(5):
roll = dice.range(1, 100)
print(f"du würfelst {roll}" + (" — daneben" if roll > chance else " — Treffer"))
Ausgabe
83 - 16 = 67, Chance 67%
91 - 8 = 83, Chance 83%
83 - 80 = 3, Chance 10%
120 - 0 = 120, Chance 95%
du würfelst 76 — daneben
du würfelst 59 — Treffer
du würfelst 23 — Treffer
du würfelst 37 — Treffer
du würfelst 58 — Treffer

Die Würfel sind der Generator aus dem Kanon (Random, mulberry32): Mit demselben Startwert fallen in allen vier Sprachen dieselben Zahlen. Deshalb ist ein Kampf mit Startwert wiederholbar: Man kann ihn aufzeichnen, abspielen und mit einem Test prüfen.

def hit_chance(accuracy, evasion):
    return clamp(accuracy - evasion, , 95)

Die Idee

Schaden und kritischer Treffer

Hast du getroffen, ist der Schaden:

Waffe + Stärke − Abwehr, mindestens 1.

Die Waffe liefert eine Zahl aus einem Bereich (die Handaxt des Helden hat 4–7), die Stärke des Angreifers wird addiert, die Abwehr des Ziels abgezogen. Die letzte Grenze ist wichtig: Ein schwacher Schlag gegen eine feste Rüstung heilt das Ziel nicht und ist auch nicht null, sondern bleibt 1.

Ein kritischer Treffer hat eine Chance von 5% + Geschick / 10 (und weitere 10% bei einer stechenden Waffe) und verdoppelt Waffe und Stärke. Die Abwehr wird nach dem Verdoppeln abgezogen, deshalb nimmt die Rüstung auch beim kritischen Treffer dasselbe weg:

def damage_of(weapon, strength, defense, crit):
damage = weapon + strength
if crit:
damage *= 2
damage -= defense
if damage < 1:
damage = 1
return damage
def show(label, weapon, strength, defense, crit):
total = f"{weapon} + {strength}"
if crit:
total = f"({total}) * 2"
print(f"{label}: {total} - {defense} -> {damage_of(weapon, strength, defense, crit)}")
show("normaler Schlag", 6, 7, 1, False)
show("kritischer Treffer", 6, 7, 1, True)
show("schwacher Schlag gegen Rüstung", 2, 1, 6, False)
for agility in [4, 8, 25, 40]:
print(f"kritischer Treffer: Geschick {agility} -> {5 + agility // 10}%")
Ausgabe
normaler Schlag: 6 + 7 - 1 -> 12
kritischer Treffer: (6 + 7) * 2 - 1 -> 25
schwacher Schlag gegen Rüstung: 2 + 1 - 6 -> 1
kritischer Treffer: Geschick 4 -> 5%
kritischer Treffer: Geschick 8 -> 5%
kritischer Treffer: Geschick 25 -> 7%
kritischer Treffer: Geschick 40 -> 9%

Die erste Zeile ist der Schlag des Helden gegen den Wolf (Abwehr 1). Die zweite ist derselbe Schlag, aber kritisch: 25, mehr als das Doppelte. Die dritte ist ein schwacher Schlag gegen Rüstung 6: Die Subtraktion ergibt −3, und das Spiel liefert 1. Die letzten vier Zeilen sind die Chance auf einen kritischen Treffer bei verschiedenem Geschick: Sie wächst langsam, um 1% je zehn Punkte.

Die Idee

Schadensarten

Ein Schlag hat eine Art: schneidend, stechend, stumpf, Feuer, Gift. Die Art sagt zwei Dinge. Erstens, wie das Ziel reagiert: Manche Wesen sind resistent (der Schaden wird durch 2 geteilt), andere schwach (er wird mit 3/2 multipliziert). Zweitens, was nach dem Schlag folgt: Schneidendes blutet, Stumpfes betäubt, Feuer brennt, Gift vergiftet (die Effekte sind das Thema der nächsten Lektion).

def resisted(target, kind, damage):
if target.resist == kind:
return damage // 2
if target.weak == kind:
return damage * 3 // 2
return damage

Ein Skelett ist resistent gegen Schneidendes und schwach gegen Stumpfes. Ein Schlag von 12 gegen es ergibt:

skeleton = Fighter("Skelett", 28, 4, 4, 2, 10, 3, 6, "slash", "slash", "blunt")
kinds = ["slash", "pierce", "blunt", "fire", "poison"]
names = ["schneidend", "stechend", "stumpf", "Feuer", "Gift"]
for i in range(len(kinds)):
print(f"{names[i]}: 12 -> {resisted(skeleton, kinds[i], 12)}")
Ausgabe
schneidend: 12 -> 6
stechend: 12 -> 12
stumpf: 12 -> 18
Feuer: 12 -> 12
Gift: 12 -> 12

Stumpfe Waffen (der Streitkolben, der Stab) zertrümmern Knochen, ein Schwert gleitet daran ab. Darum taugt nicht jede Waffe gegen jeden Feind. In der Tabelle der Feinde in den Daten des Spiels gibt es dafür zwei Spalten: resist und weak. Nach dem Schaden prüft das Spiel die Art des Schlags und setzt einen Effekt:

def after_hit(battle, target, damage_type, crit):
if not target.is_alive():
return
rng = battle.game.rng
if damage_type == 'slash' and (crit or rng.chance(35)):
apply_effect(target, make_effect('bleed', 3, 1), battle)
elif damage_type == 'blunt' and rng.chance(10):
apply_effect(target, make_effect('stun', 1, 0), battle)
elif damage_type == 'fire' and rng.chance(30):
apply_effect(target, make_effect('burn', 3, 2), battle)
elif damage_type == 'poison':
apply_effect(target, make_effect('poison', 3, 2), battle)

Im Kanon sind resist und weak eines Feindes Listen von Arten (das Skelett ist resistent gegen Schneidendes, der Golem gegen Stechendes und Feuer), nicht nur eine Art. Hier ist es ein einzelner String, damit der Code kurz bleibt.

Die Idee

Das Wort statt der Zahl

Das Kampfprotokoll schreibt nicht „Schaden 12“. Es schreibt „Iva zerschmettert den Wolf (12)“. Das Wort für die Wucht des Schlags hängt nicht von der Zahl ab, sondern vom Anteil am Leben des Ziels, den er nimmt:

def percent(a, b):
return a * 100 // b
def damage_word(damage, max_hp):
share = percent(damage, max_hp)
if share < 10:
return "kratzt"
if share < 25:
return "trifft"
if share < 45:
return "zerschneidet"
return "zerschmettert"
for damage in [1, 2, 4, 5, 8, 9, 10, 12]:
print(f"{damage} von 20: {percent(damage, 20)}%, {damage_word(damage, 20)}")
Ausgabe
1 von 20: 5%, kratzt
2 von 20: 10%, trifft
4 von 20: 20%, trifft
5 von 20: 25%, zerschneidet
8 von 20: 40%, zerschneidet
9 von 20: 45%, zerschmettert
10 von 20: 50%, zerschmettert
12 von 20: 60%, zerschmettert

Unter 10% ist es ein Kratzer, unter 25% ein Treffer, unter 45% ein Schnitt und darüber ein Zerschmettern. So sind 12 Schaden für einen Wolf mit 20 Leben ein Zerschmettern, für den Helden mit 48 aber nur ein Schnitt. Der Spieler versteht, dass der Schlag schwer ist, ohne Zahlen zu vergleichen. Hier ist die Funktion im Spiel (die Wörter sind Schlüssel zu den Texten in den vier Sprachen):

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

Sieh es dir an

Der ganze Kampf

Jetzt kommt alles zusammen. Ein Schlag prüft die Treffsicherheit, würfelt Schaden und kritischen Treffer, zieht die Abwehr ab, wendet die Resistenz an, lässt nichts unter 1 zu, sagt es mit einem Wort und senkt das Leben:

def strike(battle, dice, attacker, target):
hit_chance = clamp(attacker.accuracy() - target.evasion(), 10, 95)
if dice.range(1, 100) > hit_chance:
battle.say(f"{attacker.name} verfehlt {target.name}.")
return False
damage = dice.range(attacker.weapon_min, attacker.weapon_max) + attacker.strength
crit_chance = attacker.crit_chance()
if attacker.kind == "pierce":
crit_chance += 10
crit = dice.chance(crit_chance)
if crit:
damage *= 2
damage -= target.defense
damage = resisted(target, attacker.kind, damage)
if damage < 1:
damage = 1
line = f"{attacker.name} {damage_word(damage, target.max_hp)} {target.name} ({damage})."
if crit:
line += " Blut spritzt!"
battle.say(line)
target.hp = max(0, target.hp - damage)
return True

Der Kampf ist eine Schleife: nextActor, 100 zahlen, der Schlag des Helden auf den ersten lebenden Feind oder der Schlag eines Feindes auf den Helden, ein Wort für den Sturz, und Schluss, wenn eine Seite nicht mehr lebt. Der Held (Iva) gegen einen Wolf und einen Goblin, mit dem Startwert 16:

dice = Random(16)
hero = Fighter("Iva", 48, 7, 4, 3, 10, 4, 7, "slash", "", "")
wolf = Fighter("Blutwolf", 20, 3, 8, 1, 14, 2, 5, "slash", "", "")
goblin = Fighter("Speergoblin", 16, 2, 5, 1, 10, 2, 5, "pierce", "", "")
battle = Battle([hero, wolf, goblin])
battle.say("Kampf! Gegen dich: " + wolf.name + ", " + goblin.name + ".")
while True:
actor = battle.next_actor()
actor.energy -= 100
target = battle.participants()[1] if actor is hero else hero
strike(battle, dice, actor, target)
if not target.is_alive():
battle.say(target.name + (" fällt. Die Mine verschluckt das Licht." if target is hero else " fällt und steht nicht mehr auf."))
if not hero.is_alive() or len(battle.participants()) == 1:
break
battle.say(("Sieg!" if hero.is_alive() else "Niederlage.") + " " + hero.name + f": Leben {hero.hp}/{hero.max_hp}")
for line in battle.log:
print(line)
Ausgabe
Kampf! Gegen dich: Blutwolf, Speergoblin.
Blutwolf kratzt Iva (4).
Iva zerschmettert Blutwolf (13).
Speergoblin verfehlt Iva.
Blutwolf trifft Iva (7). Blut spritzt!
Iva zerschmettert Blutwolf (13).
Blutwolf fällt und steht nicht mehr auf.
Speergoblin verfehlt Iva.
Iva zerschmettert Speergoblin (10).
Speergoblin kratzt Iva (2).
Iva zerschmettert Speergoblin (11).
Speergoblin fällt und steht nicht mehr auf.
Sieg! Iva: Leben 35/48

Im Protokoll stehen 2 Fehlschläge und 1 kritischer Treffer: Der Wolf trifft den Helden für 7, mehr als die üblichen 2–5, und am Ende heißt es, dass Blut spritzt. Das Protokoll ist eine Liste von Zeilen. Der Kampfbildschirm zeichnet die letzten davon:

def draw(self, buffer):
battle = self.battle
hero = battle.hero
buffer.text(0, 0, '=== ' + t('battle.title') + ' ===', 'yellow')
buffer.text(0, 1, hero.name + ', ' + nm(hero.hero_class()) + ' ' + t('hud.level', {'level': hero.level}), 'white')
buffer.text(2, 2, t('hud.hp'), 'red')
buffer.bar(4, 2, 12, hero.hp, hero.max_hp(), 'red')
buffer.text(19, 2, str(hero.hp) + '/' + str(hero.max_hp()), 'default')
buffer.text(27, 2, t('hud.mana'), 'blue')
buffer.bar(29, 2, 8, hero.mana, hero.max_mana(), 'blue')
buffer.text(40, 2, str(hero.mana) + '/' + str(hero.max_mana()), 'default')
# Die Zeilen folgen direkt aufeinander: der Held, die Feinde, das Protokoll, die Befehle.
row = 3
hero_effects = effect_labels(hero)
if len(hero_effects) > 0:
buffer.text(2, row, hero_effects, 'green')
row += 1
enemies = battle.alive_enemies()
i = 0
while i < len(enemies) and i < 4:
enemy = enemies[i]
buffer.text(0, row, str(i + 1) + ') ', 'default')
buffer.text(3, row, pad(cap(enemy.name), 22), enemy.kind.color)
buffer.bar(25, row, 8, enemy.hp, enemy.max_hp(), 'red')
buffer.text(36, row, str(enemy.hp) + '/' + str(enemy.max_hp()), 'default')
buffer.text(44, row, effect_labels(enemy), 'green')
row += 1
i += 1
log_rows = 7
first = max(0, len(battle.log) - log_rows)
for i in range(first, len(battle.log)):
buffer.text(1, row, battle.log[i], log_color(battle.log[i]))
row += 1
if battle.over:
buffer.text(0, row, t('battle.won' if battle.outcome == 'win' else 'battle.away' if battle.outcome == 'fled' else 'battle.lost'), 'yellow')
else:
buffer.text(0, row, self.note if len(self.note) > 0 else t('battle.commands'), 'red' if len(self.note) > 0 else 'white')

Und der Schlag selbst ist im Spiel derselbe, mit zwei Unterschieden: Ein Schlag hat eine Wucht (power), mit der Fähigkeiten härter zuschlagen als ein gewöhnlicher Schlag, und nach dem Schaden kommt afterHit, das Effekte setzt.

def strike(battle, attacker, target, profile, power):
rng = battle.game.rng
hit_chance = clamp(attacker.accuracy() - target.evasion(), 10, 95)
if rng.range(1, 100) > hit_chance:
battle.say(t('combat.miss', {'attacker': attacker.name, 'target': target.name}))
return False
damage = rng.range(profile.min, profile.max) + attacker.stat('strength')
damage = (damage * power) // 100
crit_chance = attacker.crit_chance()
if profile.type == 'pierce':
crit_chance += 10
crit = rng.chance(crit_chance)
if crit:
damage *= 2
damage -= target.stat('defense')
damage = resisted(target, profile.type, damage)
if damage < 1:
damage = 1
line = t('combat.hit', {
'attacker': attacker.name, 'verb': damage_word(damage, target.max_hp()), 'target': target.name, 'damage': damage,
})
if crit:
line += t('combat.critBone' if not target.is_hero() and target.is_bloodless() else 'combat.critBlood')
battle.say(line)
deal_damage(battle, target, damage)
after_hit(battle, target, profile.type, crit)
return True
Genauer, für Neugierige

Der kritische Treffer ist ×2, aber ein Kämpfer mit Speer (stechend) hat weitere 10% Chance darauf: Im Kanon ist Stechendes „die Waffe der kritischen Treffer“. Hier ist das eine Zeile: critChance += 10.

Zauber im Spiel treffen immer, und die Abwehr hält sie nicht auf (spellHit): Sie werden nur mit dem Bonus des Zaubernden und einer Schadensart gerechnet. Darum sind eine Hexe mit Feuerball und ein Barbar mit Axt verschiedene Wahlen und nicht die schwächere und die stärkere Fassung desselben Kriegers.

Probier es aus

Sage die Ausgabe voraus

Der Ritter und der Magier haben Tempo 10, der Schütze 15. Die Liste hat diese Reihenfolge: Ritter, Magier, Schütze. Was gibt das Programm aus?

battle = Battle([
Fighter("Ritter", 30, 5, 5, 1, 10, 3, 6, "slash", "", ""),
Fighter("Magier", 30, 5, 5, 1, 10, 3, 6, "slash", "", ""),
Fighter("Schütze", 30, 5, 5, 1, 15, 3, 6, "slash", "", ""),
])
order = []
for _ in range(8):
actor = battle.next_actor()
actor.energy -= 100
order.append(actor.name)
print(", ".join(order))
Antwort

Probier es aus

Finde den Fehler

Ein Kollege fand, dass die Energie nach einem Zug null sein sollte, und hat eine Zeile angefasst. Das gibt das Programm jetzt aus:

hero = Fighter("Iva", 48, 7, 4, 3, 10, 4, 7, "slash", "", "")
wolf = Fighter("Blutwolf", 20, 3, 8, 1, 14, 2, 5, "slash", "", "")
goblin = Fighter("Speergoblin", 16, 2, 5, 1, 10, 2, 5, "pierce", "", "")
battle = Battle([hero, wolf, goblin])
for _ in range(7):
actor = battle.next_actor()
print(f"Tick {battle.ticks}: {actor.name} ist dran (Energie {actor.energy}).")
actor.energy = 0
Ausgabe
Tick 8: Blutwolf ist dran (Energie 112).
Tick 10: Iva ist dran (Energie 100).
Tick 10: Speergoblin ist dran (Energie 100).
Tick 16: Blutwolf ist dran (Energie 112).
Tick 20: Iva ist dran (Energie 100).
Tick 20: Speergoblin ist dran (Energie 100).
Tick 24: Blutwolf ist dran (Energie 112).

Der Wolf handelt bei Tick 8 und dann bei Tick 16, nicht bei 15 wie in der Lektion. Welche Zeile ist schuld?

Wähle die Zeile mit dem Fehler.

Genauer

Was dieses System offenlässt

Die Energie-Warteschlange hat wenige Regeln, aber dahinter stehen Entscheidungen.

Jeder Zug kostet 100. Eine schnelle Aktion (ein Schluck aus einem Trank) könnte 50 kosten, eine schwere (eine starke Fähigkeit) 150. Dann ist der Preis eine Eigenschaft der Aktion, und der Zug zahlt energy -= cost. So lässt sich das Gleichgewicht feiner einstellen, aber der Spieler muss die Folgen sehen, und dafür braucht er die Warteschlange auf dem Bildschirm.

Die Warteschlange ist nicht zu sehen. Der Kampfbildschirm im Kanon zeigt nicht, wer als Nächster dran ist. Es zu zeigen folgt aus derselben Regel: Man simuliert nextActor auf einer Kopie der Kämpfer und schreibt die Namen hin. Wir tun es nicht, weil der Spieler sonst tiefer plant und der Kampf langsamer zu spielen ist.

Das Begrenzen der Chance ist eine Spielentscheidung. Mit 10–95 steckt in jedem Kampf Zufall. Enger, etwa 30–70, ist ein unruhigerer Kampf, weiter, etwa 1–99, ein berechenbarerer. Die Zahlen sind nicht „richtig“: Sie werden mit einer Simulation eingestellt (die letzte Lektion des Moduls).

Der kritische Treffer darf nicht schnell wachsen. Doppelter Schaden ist stark, deshalb ist die Chance 5% + Geschick / 10 und nicht Geschick. Bei einem Wachstum von 1% je zehn Punkte macht Geschick einen Kampf nicht zur Lotterie.

Die Würfel sind eine eigene Eingabe. strike sucht sich keine Zufallszahl selbst, sondern bekommt den Generator. Darum ist ein Kampf mit Startwert wiederholbar: Man schreibt einen Test, der einen ganzen Kampf Byte für Byte prüft, und legt eine Aufzeichnung des Kampfs an (die Lektion über Aktionen).

Übung

Eine Warteschlange aus der Aufzeichnung

Löse das in deinem Editor.

Eine Warteschlange aus der Aufzeichnung. Das Programm liest in der ersten Zeile der Eingabe die Zahl der Züge N, danach je einen Kämpfer pro Zeile in der Form Name Tempo. Die Kämpfer stehen in der Reihenfolge, in der sie geschrieben wurden (das ist die Reihenfolge bei Gleichstand). Das Programm gibt die ersten N Züge wie in der Lektion aus: den Tick, den Namen und die Energie des Kämpfers, bevor er 100 zahlt.

Mit der Eingabe 10 | Golem 7 | Magier 10 | Schütze 15 (je eine pro Zeile) lautet die Ausgabe:

Ausgabe
Tick 7: Schütze ist dran (Energie 105).
Tick 10: Magier ist dran (Energie 100).
Tick 14: Schütze ist dran (Energie 110).
Tick 15: Golem ist dran (Energie 105).
Tick 20: Magier ist dran (Energie 100).
Tick 20: Schütze ist dran (Energie 100).
Tick 27: Schütze ist dran (Energie 105).
Tick 29: Golem ist dran (Energie 103).
Tick 30: Magier ist dran (Energie 100).
Tick 34: Schütze ist dran (Energie 110).

Achte auf Tick 20: Der Magier und der Schütze stehen gemeinsam bei 100, und der frühere in der Liste handelt. Achte auch auf Tick 14: Der Schütze ist wieder mit 110 dran, weil der Rest seines ersten Zugs mitgenommen wird.

Weitere Aufgaben

  • Tempo null. Gib einem Kämpfer Tempo 0 und −3. Was passiert ohne max(1, speed)? Und mit der Begrenzung?
  • Die Warteschlange auf dem Bildschirm. Schreibe preview(battle, count), das die Namen der nächsten count Teilnehmer liefert, ohne die Kämpfer zu verändern (sie arbeitet mit einer Kopie).
  • Wie viele Kämpfe gewinnt der Held? Lass den Kampf aus der Lektion mit Startwerten von 1 bis 1000 laufen und zähle die Siege. Bei wie vielen gibt es einen kritischen Treffer? Das ist der erste Schritt zur Simulation in der letzten Lektion.

Test

Teste dich

1. Nach seinem Zug zahlt ein Kämpfer 100 Energie, statt auf null zurückzugehen. Warum?
2. Die Treffsicherheit ist 83 und das Ausweichen des Ziels 80. Wie hoch ist die Trefferchance?
3. Waffe 6, Stärke 7, Abwehr des Ziels 1, kritischer Treffer. Wie hoch ist der Schaden?
4. Ein Skelett ist resistent gegen Schneidendes und schwach gegen Stumpfes. Wie viel Schaden macht ein stumpfer Schlag von 12?
5. Wovon hängt das Wort im Protokoll („kratzt“, „trifft“, „zerschneidet“, „zerschmettert“) ab?

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