Zum Inhalt
wannacode

Fähigkeiten und Aktionen als Befehle

Kurz gesagt: Ein Befehl ist ein Objekt, das weiß, wie es sich ausführt und wie es sich als Daten aufzeichnet. Der Kampf führt Befehle durch eine Methode aus; wer sie wählt (ein Spieler, eine künstliche Intelligenz oder eine Aufzeichnung), ist eine andere Frage. Deshalb wiederholen aufgezeichnete Befehle und derselbe Startwert einen Kampf, und ohne Bildschirm läuft der Kampf tausendfach.

Problem

Vier wählen, einer führt aus

Im Kampf des Spiels gibt es vier „Wähler“. Der Spieler drückt 1, 2, 3 oder 4. Der Goblin schlägt einfach zu. Der Held im Autopiloten, der die Simulation antreibt, rechnet aus, was am besten ist. Und der Rückblick auf den Kampf muss dasselbe tun, was passiert ist, ohne dass jemand etwas wählt. Es gibt vier Arten von Entscheidungen, aber der Kampf, der sie ausführt, ist einer.

Wenn die Entscheidung eine Kette if (Wahl == 1) … else if (Wahl == 2) … ist und an vier Stellen steht, bedeutet jede neue Aktion vier Änderungen und vier Gelegenheiten, eine zu vergessen. Fügst du „Trank trinken“ hinzu, kennt der Rückblick ihn stillschweigend nicht und zeigt einen anderen Kampf.

Hier ist ein Kampf im Spiel. Nach 2 öffnet sich eine Liste der Fähigkeiten, und die, die noch aufladen, sind markiert (Wut: in 4 Zügen):

=== Kampf ===
Iva, Barbar St.6
L [##########--] 86/103 M [########] 9/9
Wu+- Fähigkeiten ------------------------------------+
1) W| 1) Wut (0 Mana) (in 4) |
Kam| 2) Wirbelwind (3 Mana) |
Iva| 3) Kriegsschrei (2 Mana) |
Iva| 4) Eisenhaut (3 Mana) |
Wäc| |
Wäc| Zahl: wählen, x: zurück |
1 An+--------------------------------------------------+

Die Idee ist, die Aktion zu einem Objekt zu machen: zu einem Befehl. Jeder Befehl weiß, wie er sich ausführt und wie er sich als Daten aufzeichnet. Der Kampf fragt nicht, was es ist, er führt es nur aus. Wer es gewählt hat (ein Spieler, ein Gegner, eine künstliche Intelligenz oder eine Aufzeichnung), ist eine andere Frage, mit der sich der Kampf nicht beschäftigt. Die Lektion baut das an einer kleinen Arena und zeigt danach dieselbe Stelle im Spiel: die Befehle, Fähigkeiten aus einer Tabelle, Mana und Abklingzeiten, Ziele, den Rückblick auf den Kampf und die Simulation ohne Bildschirm.

Die Idee

Ein Befehl ist ein Objekt

Die Arena der Lektion ist klein: ein Krieger gegen einen Goblin und eine Ratte. Gerechnet wird mit ganzen Zahlen, und die „Würfel“ sind ein Generator mit Startwert (wie in der Einführungslektion), deshalb ist der Kampf in jeder Sprache derselbe. Es gibt vier Arten von Befehlen, und aus Sicht des Kampfes tun alle dasselbe:

from dataclasses import dataclass
# Die Aufzeichnung einer Aktion: wer (eine Nummer), was, gegen wen (eine Nummer oder -1) und welche Fähigkeit oder welcher Gegenstand.
@dataclass(frozen=True)
class ActionRecord:
actor: int
action: str
target: int
id: str
class AttackAction:
def __init__(self, actor, target):
self.actor = actor
self.target = target
def to_record(self, battle):
return ActionRecord(battle.index_of(self.actor), "attack", battle.index_of(self.target), "")
def execute(self, battle):
battle.hit(self.actor, self.target, self.actor.power + battle.rng.roll(3))
class ItemAction:
def __init__(self, actor, item_id):
self.actor = actor
self.item_id = item_id
def to_record(self, battle):
return ActionRecord(battle.index_of(self.actor), "item", -1, self.item_id)
def execute(self, battle):
self.actor.potions -= 1
healed = min(10, self.actor.max_hp - self.actor.hp)
self.actor.hp += healed
battle.say(self.actor.name + ": Trank, +" + str(healed) + " Leben")
class FleeAction:
def __init__(self, actor):
self.actor = actor
def to_record(self, battle):
return ActionRecord(battle.index_of(self.actor), "flee", -1, "")
def execute(self, battle):
if battle.rng.roll(100) < 50:
battle.say(self.actor.name + " flieht.")
battle.over = True
battle.outcome = "fled"
else:
battle.say(self.actor.name + " kommt nicht weg.")

Jeder Befehl merkt sich, wer handelt (actor) und gegen wen (target), und hat zwei Methoden:

  • execute(battle): führt ihn aus. Der Befehl rechnet das Leben anderer nicht selbst aus: Er ruft battle.hit auf, das das Leben senkt und die Zeile ins Protokoll schreibt;
  • toRecord(battle): beschreibt ihn als Daten: die Nummer des Kämpfers, ein Wort für die Art, die Nummer des Ziels und die Fähigkeit oder den Gegenstand. In der Aufzeichnung stehen weder Leben noch Schaden noch Zufall.

Deshalb kann der Kampf eine Liste von Befehlen verschiedener Arten halten und auf allen dieselbe Methode aufrufen: Das ist der Polymorphismus aus der Vererbung, nur dass die Befehle hier Objekte mit einer gemeinsamen Schnittstelle sind. Das Programm legt einen Schlag des Helden, einen Schlag des Goblins, eine Fähigkeit und einen Trank in eine Liste und gibt sie nacheinander an perform. Die Zeilen des Kampfes erscheinen sofort (live), und am Ende wird die Aufzeichnung gedruckt.

battle = new_battle(7)
battle.live = True
hero = battle.hero()
goblin = battle.fighters[1]
queue = [
AttackAction(hero, goblin),
AttackAction(goblin, hero),
SkillAction(hero, skill_of(battle, "smash"), goblin),
ItemAction(hero, "potion"),
]
for action in queue:
battle.perform(action)
print("Aufzeichnung:")
for r in battle.record:
print(describe(r))
Ausgabe
Krieger → Goblin: 8 Schaden
Goblin → Krieger: 8 Schaden
Krieger: Wuchtschlag
Krieger → Goblin: 12 Schaden
Goblin fällt.
Krieger: Trank, +8 Leben
Aufzeichnung:
0 attack 1
1 attack 0
0 skill 1 smash
0 item -1 potion

Die Aufzeichnung ist das, was vom Kampf bleibt: vier Zeilen mit ein paar Wörtern. Sie wird für den Rückblick gebraucht, der später kommt.

Im Spiel haben die Klassen dieselbe Form. So sehen der Schlag und die Flucht aus:

# Aktionen sind Befehle: ein Objekt mit execute(battle). Der Spieler und die KI wählen einen
# Befehl, und der Kampf führt ihn aus. So lässt die Simulation denselben Kampf ohne Bildschirm laufen.
class AttackAction:
def __init__(self, actor, target):
self.actor = actor
self.target = target
def to_record(self, battle):
return SimpleNamespace(actor=battle.index_of(self.actor), action='attack', target=battle.index_of(self.target), id='')
def execute(self, battle):
strike(battle, self.actor, self.target, self.actor.attack_profile(), 100)
class FleeAction:
def __init__(self, actor):
self.actor = actor
def to_record(self, battle):
return SimpleNamespace(actor=battle.index_of(self.actor), action='flee', target=-1, id='')
def execute(self, battle):
if not battle.can_flee:
battle.say(t('combat.noFlee'))
return
fastest = 0
for enemy in battle.alive_enemies():
fastest = max(fastest, enemy.stat('agility'))
chance = clamp(50 + (self.actor.stat('agility') - fastest) * 5, 15, 90)
if battle.game.rng.chance(chance):
battle.say(t('combat.fled', {'name': self.actor.name}))
battle.over = True
battle.outcome = 'fled'
else:
battle.say(t('combat.fleeFail', {'name': self.actor.name}))

Die Flucht hat eine Chance (nach dem Geschick des schnellsten Gegners), schreibt aber keinen Schaden. Auch Gegner benutzen Befehle: Fähigkeiten wie das Spucken des Schamanen oder seine Heilung sind die Klasse SpecialAction, eine pro Art von Fähigkeit.

Genauer, für Neugierige

Das ist das Muster Command (aus dem Buch der Gang of Four): Eine Anfrage wird zu einem Objekt. Daraus folgt, was mit if schwer ist: Aufzeichnen (die Befehle speichern), Wiederholen (sie noch einmal abspielen), Rückgängigmachen (jeder Befehl kennt auch seine Gegenaktion, und die Befehle werden über einen Stapel zurückgespielt) und Warteschlange (ein Befehl wartet auf seinen Zug). Diese Lektion nutzt die ersten beiden. Rückgängigmachen in einem Kampf mit Würfeln, Blutung und Tod ist schwer, denn das Gegenteil von „Schaden 7“ ist nicht einfach „Leben + 7“; deshalb führt das Spiel eine Aufzeichnung statt eines Stapels zum Rückgängigmachen. In einem Texteditor oder Zeichenprogramm dient das Muster genau dem Rückgängigmachen.

Die Idee

Der Kampf führt aus

Ein Kampf ist eine Klasse mit Kämpfern, einem Protokoll und einer Aufzeichnung. Wichtig an ihr ist eine Stelle, durch die jeder Befehl geht:

class Battle:
def __init__(self, seed, fighters, skills):
self.rng = Rng(seed)
self.fighters = fighters
self.skills = skills
self.log = []
self.record = []
self.over = False
self.live = False
self.outcome = "none"
self.rounds = 0
def hero(self):
return self.fighters[0]
def say(self, text):
self.log.append(text)
if self.live:
print(text)
def index_of(self, fighter):
return self.fighters.index(fighter)
def alive_enemies(self):
return [f for f in self.fighters[1:] if f.alive()]
def hit(self, actor, target, amount):
target.hp = max(0, target.hp - amount)
self.say(actor.name + " → " + target.name + ": " + str(amount) + " Schaden")
if not target.alive():
self.say(target.name + " fällt.")
# Die einzige Stelle, durch die jeder Befehl geht: Er wird zuerst aufgezeichnet, dann ausgeführt.
def perform(self, action):
self.record.append(action.to_record(self))
action.execute(self)
self.check_end()
def check_end(self):
if self.over:
return
if not self.hero().alive():
self.over = True
self.outcome = "lose"
self.say("Niederlage.")
elif not self.alive_enemies():
self.over = True
self.outcome = "win"
self.say("Sieg!")

perform tut drei Dinge in dieser Reihenfolge: Es zeichnet den Befehl auf, führt ihn aus und prüft, ob der Kampf zu Ende ist. Einen Umweg gibt es nicht: Weder der Held noch der Goblin noch der Rückblick führen etwas auf anderem Weg aus. Deshalb ist die Aufzeichnung vollständig: Wenn etwas den Kampf verändert hat, ist es hier durchgekommen.

hit ist die zweite Stelle, die es nur einmal geben sollte: Nur sie senkt das Leben und schreibt die Zeile „wer → wen: Schaden“. So ist das Protokoll genau genug, um es Zeile für Zeile zu vergleichen. Im Spiel werden nach jedem Schlag auch die Folgen aus der vorigen Lektion angewendet: Blutung, Betäubung, Brennen, Gift. Sie stehen an einer Stelle, deshalb sind sie für Spieler, Gegner, Fähigkeit und Rückblick gleich, statt einmal in jedem Befehl zu stehen.

Im Spiel ist perform dasselbe, nur dass es nach dem Ausführen die Todesfälle behandelt (Beute, Erfahrung, Ereignisse für die Quests):

def perform(self, action):
self.record.append(action.to_record(self))
action.execute(self)
self.handle_deaths()

Die Idee

Fähigkeiten sind Daten

SkillAction ist eine Klasse, und es gibt viele Fähigkeiten: Wirbel trifft alle, Wuchtschlag trifft einen Gegner mit 170 % der Stärke. Der Unterschied steht in einer Tabelle, nicht im Code. Jede Zeile des Textes ist eine Fähigkeit: ein Bezeichner, Mana, Abklingzeit, Stärke in Prozent, Ziel und ein Anzeigename:

from dataclasses import dataclass
SKILLS = """
cleave|3|3|80|all|Wirbel
smash|4|4|170|one|Wuchtschlag
""".strip()
@dataclass(frozen=True)
class Skill:
id: str
mana: int
cooldown: int
percent: int
target: str
name: str
def load_skills(text):
skills = []
for line in text.split("\n"):
p = line.split("|")
skills.append(Skill(p[0], int(p[1]), int(p[2]), int(p[3]), p[4], p[5]))
return skills

Das Programm liest den Text einmal beim Start (loadSkills) und hält Skill-Records. Eine neue Fähigkeit einer bekannten Art ist eine neue Zeile, keine neue Klasse. Das ist dieselbe Idee wie die Tabelle der Kacheln auf der Karte: Daten sind vom Code getrennt.

Im Spiel ist die Tabelle breiter: Neben Mana und Abklingzeit gibt es Klasse und Zweig im Fähigkeitsbaum, eine Art (Waffenschlag, Zauber, Heilung, Effekt auf sich selbst, Effekt auf die Gegner), ein Ziel one, all oder self, eine Zahl von Treffern, Zauberschaden und einen Effekt mit Zügen und Stärke. Hier sind ihre ersten Zeilen, wobei die Spalten mit den Namen in den anderen Sprachen weggelassen sind:

# Fähigkeiten: Klasse, Zweig (1 oder 2), Stufe im Zweig (1-3), Art (weapon: mit der Waffe, spell: Zauber,
# heal: Heilen, buff: Effekt auf sich selbst, debuff: Effekt auf Feinde), Ziel (one, all, self),
# Mana, Abklingzeit in Zügen, Treffer, Stärke in Prozent eines Waffentreffers, Zauberschaden
# (min-max), Schadensart („-“ ist die Art der Waffe), Effekt, Effektzüge, Effektstärke.
SKILLS_DATA = """
# id|Klasse|Zweig|Stufe|Art|Ziel|Mana|Abklingzeit|Treffer|Stärke|min|max|Schaden|Effekt|Züge|Effektstärke|Name
rage|barbarian|1|1|buff|self|0|5|0|0|0|0|-|rage|3|6|Wut
whirlwind|barbarian|1|2|weapon|all|3|3|1|80|0|0|-|-|0|0|Wirbelwind
bloodlust|barbarian|1|3|weapon|one|4|4|1|170|0|0|slash|bleed|3|2|Blutrausch

Eine SkillAction im Spiel liest eine Zeile dieser Tabelle und führt je nach Art einen Waffenschlag aus, wirkt einen Zauber, heilt oder legt einen Effekt auf:

class SkillAction:
def __init__(self, actor, skill, target):
self.actor = actor
self.skill = skill
self.target = target
def to_record(self, battle):
return SimpleNamespace(actor=battle.index_of(self.actor), action='skill', target=battle.index_of(self.target), id=self.skill.id)
def execute(self, battle):
hero = self.actor
skill = self.skill
hero.mana -= skill.mana
hero.cooldowns[skill.id] = skill.cooldown
battle.say(t('combat.uses', {'name': hero.name, 'skill': nm(skill)}))
targets = [self.target]
if skill.target == 'all':
targets = battle.alive_enemies()
if skill.target == 'self':
targets = [hero]
for target in targets:
if skill.kind == 'weapon':
profile = hero.attack_profile()
if skill.type != '-':
profile.type = skill.type
for hit in range(skill.hits):
if not target.is_alive():
break
landed = strike(battle, hero, target, profile, skill.power)
if landed and skill.effect != '-':
apply_effect(target, make_effect(skill.effect, skill.effect_turns, skill.effect_power), battle)
elif skill.kind == 'spell':
spell_hit(battle, hero, target, skill.min, skill.max, skill.type)
if skill.effect != '-':
apply_effect(target, make_effect(skill.effect, skill.effect_turns, skill.effect_power), battle)
elif skill.kind == 'heal':
healed = hero.heal(skill.min + hero.level * 2)
battle.say(t('combat.healed', {'name': hero.name, 'amount': healed}))
if skill.effect != '-':
apply_effect(hero, make_effect(skill.effect, skill.effect_turns, skill.effect_power), battle)
else:
apply_effect(target, make_effect(skill.effect, skill.effect_turns, skill.effect_power), battle)

Die Idee

Mana und Abklingzeit

Eine Fähigkeit ohne Kosten ist jedes Mal die beste Wahl. Deshalb gibt es zwei Begrenzer: Mana (ein Vorrat, der verbraucht wird) und Abklingzeit (wie viele Züge eine Fähigkeit nach dem Einsatz nicht verfügbar ist). Im Code stehen sie an drei Stellen, und jede tut eine Sache:

  • SkillAction.execute zahlt: Es senkt das Mana und setzt die Abklingzeit;
  • Fighter.tick zählt: Zu Beginn des Zugs eines Kämpfers sinkt jede Abklingzeit um eins;
  • problem fragt: Es gibt den Grund zurück, warum eine Fähigkeit nicht verfügbar ist, oder einen leeren String.
# Warum eine Fähigkeit jetzt nicht verfügbar ist: ein leerer String, wenn sie es ist.
def problem(battle, hero, skill):
if hero.cooldown_of(skill.id) > 0:
return "lädt auf: " + str(hero.cooldown_of(skill.id))
if hero.mana < skill.mana:
return "zu wenig Mana"
return ""
def usable_skills(battle, hero):
return [skill for skill in battle.skills if problem(battle, hero, skill) == ""]
class Fighter:
def __init__(self, name, hp, power, mana, potions):
self.name = name
self.hp = hp
self.max_hp = hp
self.power = power
self.mana = mana
self.max_mana = mana
self.potions = potions
self.cooldowns = {}
def alive(self):
return self.hp > 0
def cooldown_of(self, skill_id):
return self.cooldowns.get(skill_id, 0)
# Ein Zug vergeht: Jede Abklingzeit wird um eins kürzer.
def tick(self):
for skill_id in self.cooldowns:
self.cooldowns[skill_id] = max(0, self.cooldowns[skill_id] - 1)

Das Programm gibt dem Helden sechs Züge und zeigt für jede Fähigkeit, ob sie bereit ist, und wenn nicht, warum. In jedem Zug nutzt der Held die erste verfügbare:

battle = new_battle(7)
hero = battle.hero()
for turn in range(1, 7):
hero.tick()
print(f"Zug {turn}, Mana {hero.mana}")
chosen = None
for skill in battle.skills:
why = problem(battle, hero, skill)
print(" " + skill.name + ": " + ("bereit" if why == "" else why))
if why == "" and chosen is None:
chosen = skill
if chosen is None:
print(" nichts nutzbar")
continue
print(" -> nutzt " + chosen.name)
battle.perform(SkillAction(hero, chosen, battle.alive_enemies()[0]))
Ausgabe
Zug 1, Mana 8
Wirbel: bereit
Wuchtschlag: bereit
-> nutzt Wirbel
Zug 2, Mana 5
Wirbel: lädt auf: 2
Wuchtschlag: bereit
-> nutzt Wuchtschlag
Zug 3, Mana 1
Wirbel: lädt auf: 1
Wuchtschlag: lädt auf: 3
nichts nutzbar
Zug 4, Mana 1
Wirbel: zu wenig Mana
Wuchtschlag: lädt auf: 2
nichts nutzbar
Zug 5, Mana 1
Wirbel: zu wenig Mana
Wuchtschlag: lädt auf: 1
nichts nutzbar
Zug 6, Mana 1
Wirbel: zu wenig Mana
Wuchtschlag: zu wenig Mana
nichts nutzbar

Sieh dir Zug 2 an: Wirbel lädt noch auf (2 Züge), aber Wuchtschlag ist bereit, weil das Mana reicht. In Zug 4 hat Wirbel aufgeladen, aber das Mana (1) reicht nicht. In Zug 6 haben beide aufgeladen, und das Mana reicht trotzdem für keine. Deshalb prüft problem zuerst die Abklingzeit: Beim Wuchtschlag wechselt der Grund von „lädt auf“ zu „zu wenig Mana“ erst, wenn die Abklingzeit vorbei ist.

self.actor.cooldowns[self.skill.id] = 

Im Spiel ist die Liste der verfügbaren Fähigkeiten dieselbe Prüfung, nur auf den Helden des Spiels angewendet:

def usable_skills(hero):
result = []
for skill_id in hero.skills:
skill = SKILLS[skill_id]
if hero.mana >= skill.mana and hero.cooldown_of(skill.id) == 0:
result.append(skill)
return result
Genauer, für Neugierige

Im Spiel kommt Mana mit jedem Schritt auf der Karte um eins zurück, nicht pro Zug im Kampf, außerdem mit einem Trank und bei einer neuen Stufe. Im Kampf ist es also ein Vorrat, der reichen muss: Gibst du es im ersten Zug für Wirbel aus, hast du es nicht, wenn der Boss es verlangt. Die Abklingzeit wird in den Zügen des Kämpfers selbst gezählt (tick steht am Anfang seines Zugs), nicht in Runden. Ein schnellerer Kämpfer, der öfter handelt, lädt also öfter auf.

Die Idee

Ziele

Eine Fähigkeit hat ein Ziel: one (ein Gegner), all (alle Lebenden) oder self (der Held). SkillAction sammelt die Ziele in einer Liste und wendet die Wirkung auf jedes an: Bei all sind das alle lebenden Gegner, sonst ein Ziel. Deshalb trifft Wirbel mit einem Befehl sowohl den Goblin als auch die Ratte und Wuchtschlag nur einen.

Aber wer wählt das Ziel? Der Spieler tippt eine Nummer: Der Gegner ist 1 oder 2 unter den Lebenden, und ohne Nummer ist es der erste. Das Programm macht aus der Zeile des Spielers in parse einen Befehl:

# Das Ziel ist eine Nummer ab 1 unter den lebenden Gegnern; ohne Nummer ist es der erste.
def aim(battle, parts, index):
alive = battle.alive_enemies()
if len(parts) <= index:
return alive[0]
text = parts[index]
if len(text) != 1 or not text.isdigit():
return None
number = int(text)
return alive[number - 1] if 1 <= number <= len(alive) else None
# Ein ungültiger Befehl kostet keinen Zug: Er sagt, warum, und fragt noch einmal.
def refuse(battle, line, reason):
print("? " + line.strip() + ": " + reason)
return None
def parse(battle, hero, line):
parts = line.split()
word = parts[0] if parts else ""
if word == "attack":
target = aim(battle, parts, 1)
return refuse(battle, line, "kein solches Ziel") if target is None else AttackAction(hero, target)
if word == "skill":
skill = skill_of(battle, parts[1]) if len(parts) > 1 else None
if skill is None:
return refuse(battle, line, "unbekannte Fähigkeit")
why = problem(battle, hero, skill)
if why != "":
return refuse(battle, line, why)
target = aim(battle, parts, 2)
return refuse(battle, line, "kein solches Ziel") if target is None else SkillAction(hero, skill, target)
if word == "item":
return ItemAction(hero, "potion") if hero.potions > 0 else refuse(battle, line, "keine Tränke")
if word == "flee":
return FleeAction(hero)
return refuse(battle, line, "unbekannter Befehl")

Beachte, dass ein falscher Befehl keinen Zug kostet. parse nennt den Grund (unbekannte Fähigkeit, kein solches Ziel, die Fähigkeit lädt auf) und gibt nichts zurück, und Player liest die nächste Zeile. Ein Befehl, der nicht gebaut wurde, wird nicht aufgezeichnet und nicht ausgeführt, der Kampf erfährt also nie davon. Es ist dieselbe Regel wie beim Anrempeln einer Wand auf der Karte.

Das Programm liest die Befehle von der Standardeingabe, einen pro Zeile. Einige davon sind absichtlich falsch:

Eingabe
skill fireball
attack 5
skill cleave
skill cleave
dance
skill smash 2
item
attack 1
attack
battle = new_battle(7)
battle.live = True
lines = [line for line in sys.stdin.read().split("\n") if line.strip()]
run(battle, Player(lines))
Ausgabe
== Runde 1 ==
? skill fireball: unbekannte Fähigkeit
? attack 5: kein solches Ziel
Krieger: Wirbel
Krieger → Goblin: 6 Schaden
Krieger → Ratte: 6 Schaden
Goblin → Krieger: 8 Schaden
Ratte → Krieger: 4 Schaden
== Runde 2 ==
? skill cleave: lädt auf: 2
? dance: unbekannter Befehl
Krieger: Wuchtschlag
Krieger → Ratte: 12 Schaden
Ratte fällt.
Goblin → Krieger: 7 Schaden
== Runde 3 ==
Krieger: Trank, +10 Leben
Goblin → Krieger: 7 Schaden
== Runde 4 ==
Krieger → Goblin: 6 Schaden
Goblin → Krieger: 6 Schaden
== Runde 5 ==
Krieger → Goblin: 7 Schaden
Goblin fällt.
Sieg!

Die Zeilen mit ? sind die Ablehnungen: Es gibt keine Fähigkeit „fireball“, keinen fünften Gegner, Wirbel lädt noch auf, „dance“ ist kein Befehl. Keine davon ging durch perform, deshalb steht keine im Protokoll oder in der Aufzeichnung.

Im Spiel ist die Zielwahl ein eigener Bildschirm über dem Kampf, durchsichtig, der nicht selbst zuschlägt: Er gibt die Nummer des Ziels mit pop(result) zurück, und der Kampf entscheidet, was er damit tut (die Lektion über Bildschirme, die eine Antwort zurückgeben). So sieht er aus:

=== Kampf ===
Iva, Barbar St.1
L [############] 48/48 M [########] 4/4
1) Speergoblin [########] 16/16
2) Goblin-Schamane [########] 13/13
Kampf! Gegen dich: Speergoblin, Goblin-Schamane.
1 Angriff 2 Fähigkeit 3 Gegenstand 4 Flucht
+- Ziel ---------------------------+
| 1) Speergoblin 16/16 |
| 2) Goblin-Schamane 13/13 |
| |
| Zahl: Ziel, x: zurück |
+----------------------------------+
# Ein Ziel zu wählen ist ein durchsichtiger Bildschirm über dem Kampf. Er greift nicht an: pop(result) liefert die Zielnummer,
# und der Kampf entscheidet, was damit geschieht.
class TargetScreen(Screen):
def __init__(self, game, battle):
super().__init__(game)
self.battle = battle
self.note = ''
def is_transparent(self):
return True
def draw(self, buffer):
enemies = self.battle.alive_enemies()
buffer.box(24, 9, 36, len(enemies) + 4, t('target.title'))
for i in range(len(enemies)):
buffer.text(26, 10 + i, str(i + 1) + ') ' + pad(enemies[i].name, 22) + ' ' + str(enemies[i].hp) + '/' + str(enemies[i].max_hp()), enemies[i].kind.color)
buffer.text(26, 11 + len(enemies), self.note if len(self.note) > 0 else t('target.help'), 'gray')
def handle(self, command):
if command == 'x':
self.game.stack.pop(None)
return
choice = parse_int_strict(command)
if choice is None or choice < 1 or choice > len(self.battle.alive_enemies()):
self.note = t('ui.unknown', {'command': command})
return
self.game.stack.pop(choice - 1)

Der Kampfbildschirm merkt sich, was begonnen wurde (pending), und onResult beendet den Befehl, wenn die Antwort kommt: Es prüft Mana und Abklingzeit, öffnet bei Bedarf einen zweiten Bildschirm für das Ziel und ruft zuletzt heroAct mit dem fertigen Befehl auf:

def on_result(self, result):
battle = self.battle
hero = battle.hero
pending = self.pending
self.pending = None
if result is None or pending is None:
return
if pending.kind == 'attack':
battle.hero_act(AttackAction(hero, battle.alive_enemies()[result]))
elif pending.kind == 'skill':
skill = SKILLS[hero.skills[result]]
if hero.mana < skill.mana:
self.note = t('battle.noMana', {'skill': nm(skill)})
elif hero.cooldown_of(skill.id) > 0:
self.note = t('battle.cooldown', {'skill': nm(skill), 'turns': hero.cooldown_of(skill.id)})
elif skill.target == 'one' and len(battle.alive_enemies()) > 1:
self.pending = SimpleNamespace(kind='skillTarget', skill=skill)
self.game.stack.push(TargetScreen(self.game, battle))
else:
battle.hero_act(SkillAction(hero, skill, battle.alive_enemies()[0]))
elif pending.kind == 'skillTarget':
battle.hero_act(SkillAction(hero, pending.skill, battle.alive_enemies()[result]))
else:
battle.hero_act(ItemAction(hero, self.potion_ids()[result]))

Die Idee

Wer wählt: der Chooser

Die Befehle gibt es nun. Wer wählt sie? Jeder Wähler ist ein Objekt mit derselben Methode choose(battle, actor), die einen Befehl oder nichts zurückgibt (das Ende der Eingabe):

class Auto:
def choose(self, battle, actor):
return choose_hero(battle) if actor is battle.hero() else choose_enemy(battle, actor)
class Player:
def __init__(self, lines):
self.lines = lines
self.next = 0
def choose(self, battle, actor):
if actor is not battle.hero():
return choose_enemy(battle, actor)
while self.next < len(self.lines):
action = parse(battle, actor, self.lines[self.next])
self.next += 1
if action is not None:
return action
return None
class Replay:
def __init__(self, record):
self.record = record
self.next = 0
def choose(self, battle, actor):
if self.next >= len(self.record):
return None
action = action_from_record(battle, self.record[self.next])
self.next += 1
return action
def weakest(battle):
best = None
for enemy in battle.alive_enemies():
if best is None or enemy.hp < best.hp:
best = enemy
return best
def choose_enemy(battle, enemy):
return AttackAction(enemy, battle.hero())
def choose_hero(battle):
hero = battle.hero()
target = weakest(battle)
if hero.hp * 100 // hero.max_hp < 35 and hero.potions > 0:
return ItemAction(hero, "potion")
usable = usable_skills(battle, hero)
for skill in usable:
if skill.target == "all" and len(battle.alive_enemies()) >= 2:
return SkillAction(hero, skill, target)
for skill in usable:
if skill.target == "one":
return SkillAction(hero, skill, target)
return AttackAction(hero, target)
  • Auto wählt für alle: Die Gegner schlagen den Helden, und der Held entscheidet nach Priorität: ein Trank bei wenig Leben, eine Fähigkeit für viele Ziele, ein starker Schlag, ein einfacher Schlag. So läuft die Simulation. Das ist die einfachste „Bewertung des Nutzens“; die klügeren Entscheidungen der Gegner sind das Thema der nächsten Lektion (Gegner, die denken);
  • Player liefert die Befehle des Helden aus der Eingabe, und die Gegner bleiben bei der künstlichen Intelligenz;
  • Replay sehen wir gleich.

Die Kampfschleife run weiß nicht, wer wer ist. Sie lässt Runden laufen, sagt jedem Kämpfer „du bist dran“ und holt einen Befehl vom Chooser. Deshalb sind wer wählt und was ausgeführt wird zwei unabhängige Dinge: Spieler, Autopilot und Aufzeichnung werden getauscht, ohne den Kampf anzufassen.

# Ein Kampf: Runden, und in jeder Runde ist jeder der Reihe nach dran. Den Befehl liefert der Chooser.
def run(battle, chooser):
while not battle.over and battle.rounds < 12:
battle.rounds += 1
battle.say("== Runde " + str(battle.rounds) + " ==")
for actor in battle.fighters:
if battle.over:
break
if not actor.alive():
continue
actor.tick()
action = chooser.choose(battle, actor)
if action is None:
battle.over = True
break
battle.perform(action)
def action_from_record(battle, r):
actor = battle.fighters[r.actor]
target = battle.fighters[r.target] if r.target >= 0 else None
if r.action == "attack":
return AttackAction(actor, target)
if r.action == "skill":
return SkillAction(actor, skill_of(battle, r.id), target)
if r.action == "item":
return ItemAction(actor, r.id)
return FleeAction(actor)
def describe(r):
return f"{r.actor} {r.action} {r.target}" + (f" {r.id}" if r.id else "")
# Der Rückblick: ein neuer Anfang mit demselben Startwert, und die Aufzeichnung statt einer Wahl.
def replay_battle(seed, record):
battle = new_battle(seed)
run(battle, Replay(record))
return battle
def same_lines(first, second):
if len(first) != len(second):
return False
for i in range(len(first)):
if first[i] != second[i]:
return False
return True

Sieh es dir an

Aufzeichnen und wiederholen

Die Befehle gehen jetzt durch perform, und dort werden sie aufgezeichnet. Es bleibt, sie noch einmal abzuspielen. Ein Rückblick ist ein neuer Kampf vom selben Anfang (derselbe Startwert, dieselben Kämpfer), in dem der Chooser Replay ist: Er gibt die aufgezeichneten Befehle einen nach dem anderen heraus. Auf der Szene oben ist der Kampf live, in der Mitte das Band mit der Aufzeichnung, unten der Rückblick.

# Die einzige Stelle, durch die jeder Befehl geht: Er wird zuerst aufgezeichnet, dann ausgeführt.
def perform(self, action):
self.record.append(action.to_record(self))
action.execute(self)
self.check_end()
# Ein Kampf: Runden, und in jeder Runde ist jeder der Reihe nach dran. Den Befehl liefert der Chooser.
def run(battle, chooser):
while not battle.over and battle.rounds < 12:
battle.rounds += 1
battle.say("== Runde " + str(battle.rounds) + " ==")
for actor in battle.fighters:
if battle.over:
break
if not actor.alive():
continue
actor.tick()
action = chooser.choose(battle, actor)
if action is None:
battle.over = True
break
battle.perform(action)
# Der Rückblick: ein neuer Anfang mit demselben Startwert, und die Aufzeichnung statt einer Wahl.
def replay_battle(seed, record):
battle = new_battle(seed)
run(battle, Replay(record))
return battle
def same_lines(first, second):
if len(first) != len(second):
return False
for i in range(len(first)):
if first[i] != second[i]:
return False
return True
Schritt für Schritt, in Worten
  1. Der Kampf läuft in Runden. Ein Kämpfer ist dran, und der Chooser liefert einen Befehl: die künstliche Intelligenz (oder der Spieler) für den Helden, einen Schlag für die Gegner. Der Kampf fragt nicht, was für ein Befehl es ist: Er nimmt ihn nur.
  2. perform zeichnet den Befehl auf und führt ihn aus. Der erste ist die Fähigkeit aus der Tabelle (0 skill 2): Das Mana sinkt auf 5; das Leben des Goblins geht 18 → 12, das der Ratte 10 → 4. Das Plättchen auf dem Band ist seine Aufzeichnung.
  3. Jeder Befehl, der des Helden und der der Gegner, geht durch dasselbe perform und legt ein Plättchen aufs Band. Die Aufzeichnung ist nur Daten: wer (eine Nummer), was, gegen wen. Sie enthält weder Leben noch Schaden noch Zufall.
  4. Der Kampf ist vorbei: 10 Befehle in der Aufzeichnung (das Band zeigt die ersten 8). Der Rückblick beginnt von vorn mit demselben Startwert und denselben Kämpfern bei vollem Leben. Nur die Wahl ist anders: Die Befehle kommen aus der Aufzeichnung, nicht von einem Spieler und einer künstlichen Intelligenz.
  5. Replay.choose gibt das nächste Plättchen als Befehl heraus, und run und perform sind dieselben wie live. Der Generator hat denselben Startwert, deshalb fällt jeder Schlag gleich aus und die Zahlen im unteren Kampf wiederholen den oberen Zug für Zug.
  6. sameLines vergleicht die beiden Protokolle Zeile für Zeile: 21 Zeilen und kein einziger Unterschied. So lässt sich ein Rückblick prüfen, nicht nur anschauen.
  7. Der Startwert und die Aufzeichnung sind der ganze Kampf. Dieselbe Schleife, tausendmal ohne Bildschirm gelaufen, ist auch die Simulation für das Gleichgewicht.

Das Programm spielt einen Kampf mit dem Autopiloten, druckt die Aufzeichnung und spielt sie zweimal ab: mit demselben Startwert und mit einem anderen.

battle = new_battle(7)
run(battle, Auto())
print("Aufzeichnung:")
for r in battle.record:
print(describe(r))
again = replay_battle(7, battle.record)
print("Derselbe Kampf aus der Aufzeichnung: " + ("ja" if same_lines(battle.log, again.log) else "nein"))
other = replay_battle(8, battle.record)
print("Derselbe Kampf aus einem anderen Startwert: " + ("ja" if same_lines(battle.log, other.log) else "nein"))
Ausgabe
Aufzeichnung:
0 skill 2 cleave
1 attack 0
2 attack 0
0 skill 2 smash
1 attack 0
0 item -1 potion
1 attack 0
0 attack 1
1 attack 0
0 attack 1
Derselbe Kampf aus der Aufzeichnung: ja
Derselbe Kampf aus einem anderen Startwert: nein

Der erste Rückblick stimmt Zeile für Zeile mit dem Kampf überein, der zweite nicht. Die Aufzeichnung speichert die Entscheidungen (wer, was, gegen wen), nicht die Würfel. Schaden und Treffer kommen aus dem Generator mit Startwert, deshalb ergibt dieselbe Aufzeichnung auf einem anderen Startwert dieselben Entscheidungen auf anderen Zahlen. Das ist auch der Grund, warum die Aufzeichnung klein ist: 10 Befehle, nicht 21 Protokollzeilen.

Im Spiel ist der Rückblick ein Bildschirm nach einem Sieg (die Taste r). Er zeigt dasselbe Protokoll seitenweise und eine Antwort, ob es mit dem Kampf übereinstimmt:

=== Kampf-Rückblick (1/1) ===
Kampf! Gegen dich: Riesenratte.
Riesenratte kratzt Iva (1).
Iva verfehlt Riesenratte.
Riesenratte kratzt Iva (1).
Iva zerschmettert Riesenratte (12).
Riesenratte fällt und steht nicht mehr auf.
Gold: +1.
Riesenratte lässt zurück: Gegengift.
Der Rückblick stimmt mit dem Kampf überein: ja
(Enter: zurück)
def action_from_record(battle, record):
actor = battle.creature_at(record.actor)
target = battle.creature_at(record.target) if record.target >= 0 else None
if record.action == 'attack':
return AttackAction(actor, target)
if record.action == 'skill':
return SkillAction(actor, SKILLS[record.id], target)
if record.action == 'item':
return ItemAction(actor, record.id)
if record.action == 'flee':
return FleeAction(actor)
return SpecialAction(actor, record.id, target)
# Liefert das Protokoll des abgespielten Kampfes.
def replay_battle(snapshot, record):
world = ReplayWorld(snapshot)
enemies = []
for enemy in snapshot.enemies:
enemies.append(enemy.copy())
battle = Battle(world, enemies, snapshot.can_flee)
battle.script = record
battle.start()
battle.advance()
return battle.log
def same_lines(first, second):
if len(first) != len(second):
return False
for i in range(len(first)):
if first[i] != second[i]:
return False
return True
Genauer, für Neugierige

Im Spiel beginnt der Rückblick nicht beim Startwert der Welt, sondern bei einer Momentaufnahme vom Anfang des Kampfes: dem Zustand des Generators und Kopien des Helden und der Gegner (mit den Fähigkeiten, dem Mana und den Gegenständen des Helden in diesem Augenblick). Der Held hat ein Inventar und eine Stufe, die sich nicht aus einem Startwert bauen lassen, deshalb trägt die Momentaufnahme sie. Der Rückblick ist ein Battle mit einem script statt Spieler und künstlicher Intelligenz: advance nimmt die Befehle aus der Aufzeichnung. Die Welt des Rückblicks (ReplayWorld) verändert sich nicht: keine Leichen, kein Gold, keine Quests, nur das Protokoll bleibt.

Eine Aufzeichnung hängt von der Version der Regeln ab: Änderst du den Schaden von Wirbel, stimmen alte Aufzeichnungen nicht mehr. Aufzeichnungen taugen also zum Prüfen und zur Fehlersuche in derselben Version, nicht zur dauerhaften Ablage.

Die Idee

Tausend Kämpfe ohne Bildschirm

Lässt du run mit Auto für beide Seiten laufen, brauchst du keinen Bildschirm, keinen Spieler und keine Aufzeichnung. Das Programm spielt Kämpfe mit Startwerten von 1 bis 1000 und zählt die Siege:

print("Simulation ohne Bildschirm")
wins = 0
losses = 0
commands = 0
total = 1000
for seed in range(1, total + 1):
battle = new_battle(seed)
run(battle, Auto())
if battle.outcome == "win":
wins += 1
else:
losses += 1
commands += len(battle.record)
average = commands * 10 // total
print(f"Kämpfe: {total}, Siege: {wins}, Niederlagen: {losses}, Befehle im Schnitt: {average // 10}.{average % 10}")
Ausgabe
Simulation ohne Bildschirm
Kämpfe: 1000, Siege: 875, Niederlagen: 125, Befehle im Schnitt: 9.9

Das Ergebnis sind 875 Siege und 125 Niederlagen. So misst man das Gleichgewicht: Gewinnt der Held jeden Kampf, sind die Gegner zu schwach; verliert er, sind sie zu stark; man schiebt die Zahlen, bis das Ergebnis so ist, wie du es willst. Alles ist deterministisch: Derselbe Code liefert jedes Mal und in jeder Sprache dieselben Zahlen.

Im Spiel ist das --simulate=1000: Kämpfe jeder Klasse gegen jeden Gegner, wie du in der letzten Lektion sehen wirst. Die Grundlage ist diese: Der Held im Autopiloten (autoHero) wählt Befehle mit derselben Bewertung, und der Kampf läuft mit demselben advance, ohne Bildschirm:

def simulate_fight(game, hero_class, kind):
game.hero = make_sim_hero(game, hero_class, SIM_LEVELS[kind.depth])
battle = Battle(game, [Enemy(game.new_id(), kind)], False)
battle.auto_hero = True
battle.advance()
return SimpleNamespace(win=battle.outcome == 'win', turns=battle.hero_turns)

Die Simulation ist möglich, weil der Kampf in ein Protokoll schreibt, nicht auf den Bildschirm (in der Lektion kopiert live es nur dorthin), und die Wahl des Befehls austauschbar ist.

Probier es aus

Sage die Ausgabe voraus

Der Held nutzt in jedem Zug die letzte verfügbare Fähigkeit (Wuchtschlag, wenn möglich, sonst Wirbel). Wirbel kostet 3 Mana und lädt 3 Züge auf, Wuchtschlag kostet 4 und lädt 4 Züge auf, und der Held beginnt mit 8 Mana. Was gibt das Programm aus?

battle = new_battle(7)
hero = battle.hero()
for turn in range(1, 6):
hero.tick()
used = "-"
for skill in usable_skills(battle, hero):
used = skill.id
if used != "-":
battle.perform(SkillAction(hero, skill_of(battle, used), battle.fighters[1]))
print(turn, used)
Antwort

Probier es aus

Finde den Fehler

Ein Kollege hat SkillAction geändert, und jetzt stimmt der Rückblick nicht mit dem Kampf überein. Die Aufzeichnung sieht seltsam aus:

class SkillAction:
def __init__(self, actor, skill, target):
self.actor = actor
self.skill = skill
self.target = target
def to_record(self, battle):
return ActionRecord(battle.index_of(self.actor), "attack", battle.index_of(self.target), self.skill.id)
def execute(self, battle):
self.actor.mana -= self.skill.mana
self.actor.cooldowns[self.skill.id] = self.skill.cooldown
battle.say(self.actor.name + ": " + self.skill.name)
targets = battle.alive_enemies() if self.skill.target == "all" else [self.target]
for victim in targets:
battle.hit(self.actor, victim, self.actor.power * self.skill.percent // 100 + battle.rng.roll(3))
battle = new_battle(7)
run(battle, Auto())
print("Aufzeichnung:")
for r in battle.record:
print(describe(r))
again = replay_battle(7, battle.record)
print("Derselbe Kampf aus der Aufzeichnung: " + ("ja" if same_lines(battle.log, again.log) else "nein"))
Ausgabe
Aufzeichnung:
0 attack 2 cleave
1 attack 0
2 attack 0
0 attack 2 smash
1 attack 0
0 item -1 potion
1 attack 0
0 attack 1
1 attack 0
0 attack 1
Derselbe Kampf aus der Aufzeichnung: nein

In der Aufzeichnung ist Wirbel attack. Welche Zeile ist schuld?

Wähle die Zeile mit dem Fehler.

Genauer

Was ein Befehl nicht kann

Ein Befehl als Objekt ist praktisch, hat aber Grenzen, die man kennen sollte.

Die Klasse schützt sich nicht selbst vor einem unzulässigen Befehl. SkillAction prüft das Mana nicht, weil parse (für den Spieler) und usableSkills (für die künstliche Intelligenz) es prüfen, bevor sie ihn bauen. Umgehst du diese Prüfungen irgendwo, wird das Mana negativ. Die andere Wahl ist, die Prüfung in den Befehl selbst zu legen, der „ich kann nicht“ zurückgibt. Das Spiel wählt die erste: Ein unzulässiger Befehl wird nie gebaut.

Eine Aufzeichnung hängt von den Regeln ab. Sie speichert die Entscheidungen, nicht die Folgen. Änderst du die Schadensformel oder fügst einen weiteren Würfelwurf hinzu, ergeben alte Aufzeichnungen einen anderen Kampf. Die Reihenfolge der Aufrufe des Generators gehört zum Vertrag.

Der Zufall muss im Startwert stecken. Nutzt ein Teil des Kampfes das Random der Sprache oder die Uhr, läuft der Rückblick auseinander. Deshalb hat das Spiel einen eigenen Generator, und Schaden und Treffer werden nur an wenigen Stellen gewürfelt.

Ein Befehl ist kein Ereignis. Ein Ereignis sagt „etwas ist passiert“ (ein Gegner ist gestorben), ein Befehl sagt „tu“. Der Event-Bus ist eine andere Lektion (die Quests), und beide arbeiten oft zusammen: Ein Befehl verändert den Kampf, und der Kampf löst ein Ereignis aus.

Eine Klasse pro Art wächst nicht endlos. Für vier Arten von Befehlen ist das gut, für achtzehn Fähigkeiten ist es zu viel. Deshalb sind Fähigkeiten Daten, und SkillAction ist eine Klasse für alle.

Übung

Rast

Löse die Aufgabe in deinem Editor. Beginne mit dem Programm mit dem Spieler aus dieser Lektion.

Rast. Füge dem Helden eine vierte Art von Befehl hinzu: rest gibt 2 Mana zurück (nicht mehr als das Maximum) und kostet den Zug. Der Spieler tippt sie als rest. Drei Stellen müssen angefasst werden: die Klasse RestAction selbst (mit einem toRecord, das rest zurückgibt), parse (das Wort des Spielers) und actionFromRecord (die Aufzeichnung). Am Ende spielt das Programm den Rückblick ab und schreibt, ob er übereinstimmt. Die Eingabe (eine pro Zeile) ist `skill smash 2`, `dance`, `rest`, `item`, `skill smash 1`, `attack 1`, `skill smash 1`, und die Ausgabe ist:

Eingabe
skill smash 2
dance
rest
item
skill smash 1
attack 1
skill smash 1
Ausgabe
== Runde 1 ==
Krieger: Wuchtschlag
Krieger → Ratte: 12 Schaden
Ratte fällt.
Goblin → Krieger: 8 Schaden
== Runde 2 ==
? dance: unbekannter Befehl
Krieger: Rast +2 Mana
Goblin → Krieger: 8 Schaden
== Runde 3 ==
Krieger: Trank, +10 Leben
Goblin → Krieger: 6 Schaden
== Runde 4 ==
? skill smash 1: lädt auf: 1
Krieger → Goblin: 8 Schaden
Goblin → Krieger: 7 Schaden
== Runde 5 ==
Krieger: Wuchtschlag
Krieger → Goblin: 11 Schaden
Goblin fällt.
Sieg!
Derselbe Kampf aus der Aufzeichnung: ja

Weitere Aufgaben

  • Eine Heilfähigkeit. Füge der Tabelle eine Fähigkeit hinzu, die den Helden heilt (Ziel self). Wie viele Zeilen Code müssen sich ändern, und warum so wenige?
  • Flucht. FleeAction würfelt (50 %). Gib flee in der Eingabe des Spielers an und sieh dir den Rückblick an: Stimmt er überein? Warum stehen die Würfel nicht in der Aufzeichnung?
  • Rückgängig. Versuche, eine Methode undo() zu AttackAction und ItemAction hinzuzufügen. Welcher der Befehle lässt sich ohne Momentaufnahme des Zustands nicht zurücknehmen?
  • Gleichgewicht. Erhöhe die Stärke des Goblins um 1 und lass die Simulation laufen. Wie sehr ändern sich die Siege, und warum stammen die Zahlen aus tausend Kämpfen und nicht aus einem?

Test

Prüf dich

1. Du fügst dem Spiel einen Befehl „Rast“ hinzu. Welche zwei weiteren Stellen müssen außer der Klasse selbst davon erfahren, damit sowohl der Spieler als auch der Rückblick funktionieren?
2. Die Aufzeichnung speichert die Nummer des Kämpfers (0, 1, 2), nicht das Objekt selbst. Warum?
3. Der Held hat 8 Mana und nutzt Wirbel (3 Mana, Abklingzeit 3). In seinem nächsten Zug tippt der Spieler dieselbe Fähigkeit. Was passiert?
4. Der Rückblick mit einem anderen Startwert („Derselbe Kampf aus einem anderen Startwert: nein“) nutzt dieselbe Aufzeichnung. Warum kommt ein anderer Kampf heraus?
5. Was macht eine Simulation ohne Bildschirm möglich?

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