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:
class AttackAction { constructor(actor, target) { this.actor = actor; this.target = target; }
toRecord(battle) { return { actor: battle.indexOf(this.actor), action: "attack", target: battle.indexOf(this.target), id: "" }; }
execute(battle) { battle.hit(this.actor, this.target, this.actor.power + battle.rng.roll(3)); }}
class ItemAction { constructor(actor, itemId) { this.actor = actor; this.itemId = itemId; }
toRecord(battle) { return { actor: battle.indexOf(this.actor), action: "item", target: -1, id: this.itemId }; }
execute(battle) { this.actor.potions -= 1; const healed = Math.min(10, this.actor.maxHp - this.actor.hp); this.actor.hp += healed; battle.say(`${this.actor.name}: Trank, +${healed} Leben`); }}
class FleeAction { constructor(actor) { this.actor = actor; }
toRecord(battle) { return { actor: battle.indexOf(this.actor), action: "flee", target: -1, id: "" }; }
execute(battle) { if (battle.rng.roll(100) < 50) { battle.say(`${this.actor.name} flieht.`); battle.over = true; battle.outcome = "fled"; } else { battle.say(`${this.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 ruftbattle.hitauf, 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.
const battle = newBattle(7);battle.live = true;const hero = battle.hero();const goblin = battle.fighters[1];const queue = [ new AttackAction(hero, goblin), new AttackAction(goblin, hero), new SkillAction(hero, skillOf(battle, "smash"), goblin), new ItemAction(hero, "potion"),];for (const action of queue) { battle.perform(action);}console.log("Aufzeichnung:");for (const r of battle.record) { console.log(describe(r));}Krieger → Goblin: 8 SchadenGoblin → Krieger: 8 SchadenKrieger: WuchtschlagKrieger → Goblin: 12 SchadenGoblin fällt.Krieger: Trank, +8 LebenAufzeichnung:0 attack 11 attack 00 skill 1 smash0 item -1 potionDie 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 { constructor(actor, target) { this.actor = actor; this.target = target; }
toRecord(battle) { return { actor: battle.indexOf(this.actor), action: 'attack', target: battle.indexOf(this.target), id: '' }; }
execute(battle) { strike(battle, this.actor, this.target, this.actor.attackProfile(), 100); }}class FleeAction { constructor(actor) { this.actor = actor; }
toRecord(battle) { return { actor: battle.indexOf(this.actor), action: 'flee', target: -1, id: '' }; }
execute(battle) { if (!battle.canFlee) { battle.say(t('combat.noFlee')); return; } let fastest = 0; for (const enemy of battle.aliveEnemies()) fastest = Math.max(fastest, enemy.stat('agility')); const chance = clamp(50 + (this.actor.stat('agility') - fastest) * 5, 15, 90); if (battle.game.rng.chance(chance)) { battle.say(t('combat.fled', { name: this.actor.name })); battle.over = true; battle.outcome = 'fled'; } else { battle.say(t('combat.fleeFail', { name: this.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 { constructor(seed, fighters, skills) { this.rng = new Rng(seed); this.fighters = fighters; this.skills = skills; this.log = []; this.record = []; this.over = false; this.live = false; this.outcome = "none"; this.rounds = 0; }
hero() { return this.fighters[0]; }
say(text) { this.log.push(text); if (this.live) console.log(text); }
indexOf(fighter) { return this.fighters.indexOf(fighter); }
aliveEnemies() { return this.fighters.slice(1).filter((f) => f.alive()); }
hit(actor, target, amount) { target.hp = Math.max(0, target.hp - amount); this.say(`${actor.name} → ${target.name}: ${amount} Schaden`); if (!target.alive()) this.say(`${target.name} fällt.`); }
// Die einzige Stelle, durch die jeder Befehl geht: Er wird zuerst aufgezeichnet, dann ausgeführt. perform(action) { this.record.push(action.toRecord(this)); action.execute(this); this.checkEnd(); }
checkEnd() { if (this.over) return; if (!this.hero().alive()) { this.over = true; this.outcome = "lose"; this.say("Niederlage."); } else if (this.aliveEnemies().length === 0) { this.over = true; this.outcome = "win"; this.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):
perform(action) { this.record.push(action.toRecord(this)); action.execute(this); this.handleDeaths();}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:
const SKILLS = `cleave|3|3|80|all|Wirbelsmash|4|4|170|one|Wuchtschlag`.trim();
function loadSkills(text) { return text.split("\n").map((line) => { const p = line.split("|"); return { id: p[0], mana: Number(p[1]), cooldown: Number(p[2]), percent: Number(p[3]), target: p[4], name: p[5] }; });}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.const SKILLS_DATA = `# id|Klasse|Zweig|Stufe|Art|Ziel|Mana|Abklingzeit|Treffer|Stärke|min|max|Schaden|Effekt|Züge|Effektstärke|Namerage|barbarian|1|1|buff|self|0|5|0|0|0|0|-|rage|3|6|Wutwhirlwind|barbarian|1|2|weapon|all|3|3|1|80|0|0|-|-|0|0|Wirbelwindbloodlust|barbarian|1|3|weapon|one|4|4|1|170|0|0|slash|bleed|3|2|BlutrauschEine 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 { constructor(actor, skill, target) { this.actor = actor; this.skill = skill; this.target = target; }
toRecord(battle) { return { actor: battle.indexOf(this.actor), action: 'skill', target: battle.indexOf(this.target), id: this.skill.id }; }
execute(battle) { const hero = this.actor; const skill = this.skill; hero.mana -= skill.mana; hero.cooldowns.set(skill.id, skill.cooldown); battle.say(t('combat.uses', { name: hero.name, skill: nm(skill) })); let targets = [this.target]; if (skill.target === 'all') targets = battle.aliveEnemies(); if (skill.target === 'self') targets = [hero]; for (const target of targets) { if (skill.kind === 'weapon') { const profile = hero.attackProfile(); if (skill.type !== '-') profile.type = skill.type; for (let hit = 0; hit < skill.hits; hit++) { if (!target.isAlive()) break; const landed = strike(battle, hero, target, profile, skill.power); if (landed && skill.effect !== '-') applyEffect(target, makeEffect(skill.effect, skill.effectTurns, skill.effectPower), battle); } } else if (skill.kind === 'spell') { spellHit(battle, hero, target, skill.min, skill.max, skill.type); if (skill.effect !== '-') applyEffect(target, makeEffect(skill.effect, skill.effectTurns, skill.effectPower), battle); } else if (skill.kind === 'heal') { const healed = hero.heal(skill.min + hero.level * 2); battle.say(t('combat.healed', { name: hero.name, amount: healed })); if (skill.effect !== '-') applyEffect(hero, makeEffect(skill.effect, skill.effectTurns, skill.effectPower), battle); } else { applyEffect(target, makeEffect(skill.effect, skill.effectTurns, skill.effectPower), 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.executezahlt: Es senkt das Mana und setzt die Abklingzeit;Fighter.tickzählt: Zu Beginn des Zugs eines Kämpfers sinkt jede Abklingzeit um eins;problemfragt: 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.function problem(battle, hero, skill) { if (hero.cooldownOf(skill.id) > 0) return `lädt auf: ${hero.cooldownOf(skill.id)}`; if (hero.mana < skill.mana) return "zu wenig Mana"; return "";}
function usableSkills(battle, hero) { return battle.skills.filter((skill) => problem(battle, hero, skill) === "");}class Fighter { constructor(name, hp, power, mana, potions) { this.name = name; this.hp = hp; this.maxHp = hp; this.power = power; this.mana = mana; this.maxMana = mana; this.potions = potions; this.cooldowns = new Map(); }
alive() { return this.hp > 0; }
cooldownOf(skillId) { return this.cooldowns.get(skillId) ?? 0; }
// Ein Zug vergeht: Jede Abklingzeit wird um eins kürzer. tick() { for (const [skillId, left] of this.cooldowns) { this.cooldowns.set(skillId, Math.max(0, left - 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:
const battle = newBattle(7);const hero = battle.hero();for (let turn = 1; turn <= 6; turn++) { hero.tick(); console.log(`Zug ${turn}, Mana ${hero.mana}`); let chosen = null; for (const skill of battle.skills) { const why = problem(battle, hero, skill); console.log(" " + skill.name + ": " + (why === "" ? "bereit" : why)); if (why === "" && chosen === null) chosen = skill; } if (chosen === null) { console.log(" nichts nutzbar"); continue; } console.log(" -> nutzt " + chosen.name); battle.perform(new SkillAction(hero, chosen, battle.aliveEnemies()[0]));}Zug 1, Mana 8 Wirbel: bereit Wuchtschlag: bereit -> nutzt WirbelZug 2, Mana 5 Wirbel: lädt auf: 2 Wuchtschlag: bereit -> nutzt WuchtschlagZug 3, Mana 1 Wirbel: lädt auf: 1 Wuchtschlag: lädt auf: 3 nichts nutzbarZug 4, Mana 1 Wirbel: zu wenig Mana Wuchtschlag: lädt auf: 2 nichts nutzbarZug 5, Mana 1 Wirbel: zu wenig Mana Wuchtschlag: lädt auf: 1 nichts nutzbarZug 6, Mana 1 Wirbel: zu wenig Mana Wuchtschlag: zu wenig Mana nichts nutzbarSieh 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.
this.actor.cooldowns.set(this.skill.id, );Wird eine Fähigkeit eingesetzt, trägt SkillAction die Zahl aus der Tabelle in die Abklingzeiten des Kämpfers ein: skill.cooldown. In den folgenden Zügen senkt tick sie auf 0, und erst dann sagt problem „bereit“.
Im Spiel ist die Liste der verfügbaren Fähigkeiten dieselbe Prüfung, nur auf den Helden des Spiels angewendet:
function usableSkills(hero) { const result = []; for (const skillId of hero.skills) { const skill = SKILLS.get(skillId); if (hero.mana >= skill.mana && hero.cooldownOf(skill.id) === 0) result.push(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.function aim(battle, parts, index) { const alive = battle.aliveEnemies(); if (parts.length <= index) return alive[0]; const text = parts[index]; if (!/^\d$/.test(text)) return null; const number = Number(text); return number >= 1 && number <= alive.length ? alive[number - 1] : null;}
// Ein ungültiger Befehl kostet keinen Zug: Er sagt, warum, und fragt noch einmal.function refuse(battle, line, reason) { console.log(`? ${line.trim()}: ${reason}`); return null;}
function parse(battle, hero, line) { const parts = line.trim().split(/\s+/); const word = parts[0]; if (word === "attack") { const target = aim(battle, parts, 1); return target === null ? refuse(battle, line, "kein solches Ziel") : new AttackAction(hero, target); } if (word === "skill") { const skill = parts.length > 1 ? skillOf(battle, parts[1]) : null; if (skill === null) return refuse(battle, line, "unbekannte Fähigkeit"); const why = problem(battle, hero, skill); if (why !== "") return refuse(battle, line, why); const target = aim(battle, parts, 2); return target === null ? refuse(battle, line, "kein solches Ziel") : new SkillAction(hero, skill, target); } if (word === "item") { return hero.potions > 0 ? new ItemAction(hero, "potion") : refuse(battle, line, "keine Tränke"); } if (word === "flee") return new 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:
skill fireballattack 5skill cleaveskill cleavedanceskill smash 2itemattack 1attackconst battle = newBattle(7);battle.live = true;const lines = readFileSync(0, "utf8").split("\n").filter((line) => line.trim() !== "");run(battle, new Player(lines));== Runde 1 ==? skill fireball: unbekannte Fähigkeit? attack 5: kein solches ZielKrieger: WirbelKrieger → Goblin: 6 SchadenKrieger → Ratte: 6 SchadenGoblin → Krieger: 8 SchadenRatte → Krieger: 4 Schaden== Runde 2 ==? skill cleave: lädt auf: 2? dance: unbekannter BefehlKrieger: WuchtschlagKrieger → Ratte: 12 SchadenRatte fällt.Goblin → Krieger: 7 Schaden== Runde 3 ==Krieger: Trank, +10 LebenGoblin → Krieger: 7 Schaden== Runde 4 ==Krieger → Goblin: 6 SchadenGoblin → Krieger: 6 Schaden== Runde 5 ==Krieger → Goblin: 7 SchadenGoblin 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/41) Speergoblin [########] 16/162) 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 extends Screen { constructor(game, battle) { super(game); this.battle = battle; this.note = ''; }
isTransparent() { return true; }
draw(buffer) { const enemies = this.battle.aliveEnemies(); buffer.box(24, 9, 36, enemies.length + 4, t('target.title')); for (let i = 0; i < enemies.length; i++) { buffer.text(26, 10 + i, (i + 1) + ') ' + pad(enemies[i].name, 22) + ' ' + enemies[i].hp + '/' + enemies[i].maxHp(), enemies[i].kind.color); } buffer.text(26, 11 + enemies.length, this.note.length > 0 ? this.note : t('target.help'), 'gray'); }
handle(command) { if (command === 'x') { this.game.stack.pop(null); return; } const choice = parseIntStrict(command); if (choice === null || choice < 1 || choice > this.battle.aliveEnemies().length) { this.note = t('ui.unknown', { command: command }); return; } this.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:
onResult(result) { const battle = this.battle; const hero = battle.hero; const pending = this.pending; this.pending = null; if (result === null || pending === null) return; if (pending.kind === 'attack') { battle.heroAct(new AttackAction(hero, battle.aliveEnemies()[result])); } else if (pending.kind === 'skill') { const skill = SKILLS.get(hero.skills[result]); if (hero.mana < skill.mana) { this.note = t('battle.noMana', { skill: nm(skill) }); } else if (hero.cooldownOf(skill.id) > 0) { this.note = t('battle.cooldown', { skill: nm(skill), turns: hero.cooldownOf(skill.id) }); } else if (skill.target === 'one' && battle.aliveEnemies().length > 1) { this.pending = { kind: 'skillTarget', skill: skill }; this.game.stack.push(new TargetScreen(this.game, battle)); } else { battle.heroAct(new SkillAction(hero, skill, battle.aliveEnemies()[0])); } } else if (pending.kind === 'skillTarget') { battle.heroAct(new SkillAction(hero, pending.skill, battle.aliveEnemies()[result])); } else { battle.heroAct(new ItemAction(hero, this.potionIds()[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 { choose(battle, actor) { return actor === battle.hero() ? chooseHero(battle) : chooseEnemy(battle, actor); }}
class Player { constructor(lines) { this.lines = lines; this.next = 0; }
choose(battle, actor) { if (actor !== battle.hero()) return chooseEnemy(battle, actor); while (this.next < this.lines.length) { const action = parse(battle, actor, this.lines[this.next++]); if (action !== null) return action; } return null; }}
class Replay { constructor(record) { this.record = record; this.next = 0; }
choose(battle, actor) { if (this.next >= this.record.length) return null; return actionFromRecord(battle, this.record[this.next++]); }}
function weakest(battle) { let best = null; for (const enemy of battle.aliveEnemies()) { if (best === null || enemy.hp < best.hp) best = enemy; } return best;}
function chooseEnemy(battle, enemy) { return new AttackAction(enemy, battle.hero());}
function chooseHero(battle) { const hero = battle.hero(); const target = weakest(battle); if (Math.floor((hero.hp * 100) / hero.maxHp) < 35 && hero.potions > 0) return new ItemAction(hero, "potion"); const usable = usableSkills(battle, hero); for (const skill of usable) { if (skill.target === "all" && battle.aliveEnemies().length >= 2) return new SkillAction(hero, skill, target); } for (const skill of usable) { if (skill.target === "one") return new SkillAction(hero, skill, target); } return new AttackAction(hero, target);}Autowä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);Playerliefert die Befehle des Helden aus der Eingabe, und die Gegner bleiben bei der künstlichen Intelligenz;Replaysehen 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.function run(battle, chooser) { while (!battle.over && battle.rounds < 12) { battle.rounds++; battle.say(`== Runde ${battle.rounds} ==`); for (const actor of battle.fighters) { if (battle.over) break; if (!actor.alive()) continue; actor.tick(); const action = chooser.choose(battle, actor); if (action === null) { battle.over = true; break; } battle.perform(action); } }}
function actionFromRecord(battle, r) { const actor = battle.fighters[r.actor]; const target = r.target >= 0 ? battle.fighters[r.target] : null; if (r.action === "attack") return new AttackAction(actor, target); if (r.action === "skill") return new SkillAction(actor, skillOf(battle, r.id), target); if (r.action === "item") return new ItemAction(actor, r.id); return new FleeAction(actor);}
function describe(r) { return `${r.actor} ${r.action} ${r.target}` + (r.id ? ` ${r.id}` : "");}
// Der Rückblick: ein neuer Anfang mit demselben Startwert, und die Aufzeichnung statt einer Wahl.function replayBattle(seed, record) { const battle = newBattle(seed); run(battle, new Replay(record)); return battle;}
function sameLines(first, second) { if (first.length !== second.length) return false; for (let i = 0; i < first.length; i++) { 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.perform(action) { this.record.push(action.toRecord(this)); action.execute(this); this.checkEnd();}
// Ein Kampf: Runden, und in jeder Runde ist jeder der Reihe nach dran. Den Befehl liefert der Chooser.function run(battle, chooser) { while (!battle.over && battle.rounds < 12) { battle.rounds++; battle.say(`== Runde ${battle.rounds} ==`); for (const actor of battle.fighters) { if (battle.over) break; if (!actor.alive()) continue; actor.tick(); const action = chooser.choose(battle, actor); if (action === null) { battle.over = true; break; } battle.perform(action); } }}
// Der Rückblick: ein neuer Anfang mit demselben Startwert, und die Aufzeichnung statt einer Wahl.function replayBattle(seed, record) { const battle = newBattle(seed); run(battle, new Replay(record)); return battle;}
function sameLines(first, second) { if (first.length !== second.length) return false; for (let i = 0; i < first.length; i++) { if (first[i] !== second[i]) return false; } return true;}Schritt für Schritt, in Worten
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
const battle = newBattle(7);run(battle, new Auto());console.log("Aufzeichnung:");for (const r of battle.record) { console.log(describe(r));}const again = replayBattle(7, battle.record);console.log("Derselbe Kampf aus der Aufzeichnung: " + (sameLines(battle.log, again.log) ? "ja" : "nein"));const other = replayBattle(8, battle.record);console.log("Derselbe Kampf aus einem anderen Startwert: " + (sameLines(battle.log, other.log) ? "ja" : "nein"));Aufzeichnung:0 skill 2 cleave1 attack 02 attack 00 skill 2 smash1 attack 00 item -1 potion1 attack 00 attack 11 attack 00 attack 1Derselbe Kampf aus der Aufzeichnung: jaDerselbe Kampf aus einem anderen Startwert: neinDer 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)function actionFromRecord(battle, record) { const actor = battle.creatureAt(record.actor); const target = record.target >= 0 ? battle.creatureAt(record.target) : null; if (record.action === 'attack') return new AttackAction(actor, target); if (record.action === 'skill') return new SkillAction(actor, SKILLS.get(record.id), target); if (record.action === 'item') return new ItemAction(actor, record.id); if (record.action === 'flee') return new FleeAction(actor); return new SpecialAction(actor, record.id, target);}
// Liefert das Protokoll des abgespielten Kampfes.function replayBattle(snapshot, record) { const world = new ReplayWorld(snapshot); const enemies = []; for (const enemy of snapshot.enemies) enemies.push(enemy.copy()); const battle = new Battle(world, enemies, snapshot.canFlee); battle.script = record; battle.start(); battle.advance(); return battle.log;}
function sameLines(first, second) { if (first.length !== second.length) return false; for (let i = 0; i < first.length; i++) { 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:
console.log("Simulation ohne Bildschirm");let wins = 0;let losses = 0;let commands = 0;const total = 1000;for (let seed = 1; seed <= total; seed++) { const battle = newBattle(seed); run(battle, new Auto()); if (battle.outcome === "win") wins++; else losses++; commands += battle.record.length;}const average = Math.floor((commands * 10) / total);console.log(`Kämpfe: ${total}, Siege: ${wins}, Niederlagen: ${losses}, Befehle im Schnitt: ${Math.floor(average / 10)}.${average % 10}`);Simulation ohne BildschirmKämpfe: 1000, Siege: 875, Niederlagen: 125, Befehle im Schnitt: 9.9Das 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:
function simulateFight(game, heroClass, kind) { game.hero = makeSimHero(game, heroClass, SIM_LEVELS[kind.depth]); const battle = new Battle(game, [new Enemy(game.newId(), kind)], false); battle.autoHero = true; battle.advance(); return { win: battle.outcome === 'win', turns: battle.heroTurns };}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?
const battle = newBattle(7);const hero = battle.hero();for (let turn = 1; turn <= 5; turn++) { hero.tick(); let used = "-"; for (const skill of usableSkills(battle, hero)) used = skill.id; if (used !== "-") { battle.perform(new SkillAction(hero, skillOf(battle, used), battle.fighters[1])); } console.log(turn, used);}Zug 1: Beide sind verfügbar, genommen wird die letzte, Wuchtschlag (Mana 8 → 4, Abklingzeit 4). Zug 2: Wuchtschlag lädt auf (3), übrig bleibt Wirbel, und 4 Mana reichen dafür (Mana 1). Ab Zug 3 ist das Mana 1 und reicht für nichts, auch in Zug 5 nicht, wenn Wuchtschlag schon aufgeladen ist. Die anderen Ausgaben gehören zu einem Programm ohne Abklingzeit (Wuchtschlag würde sich in Zug 2 wiederholen) und zu einem Programm, das kein Mana verbraucht (in Zug 5 wäre Wuchtschlag wieder verfügbar).
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 { constructor(actor, skill, target) { this.actor = actor; this.skill = skill; this.target = target; }
toRecord(battle) { return { actor: battle.indexOf(this.actor), action: "attack", target: battle.indexOf(this.target), id: this.skill.id }; }
execute(battle) { this.actor.mana -= this.skill.mana; this.actor.cooldowns.set(this.skill.id, this.skill.cooldown); battle.say(`${this.actor.name}: ${this.skill.name}`); const targets = this.skill.target === "all" ? battle.aliveEnemies() : [this.target]; for (const victim of targets) { battle.hit(this.actor, victim, Math.floor((this.actor.power * this.skill.percent) / 100) + battle.rng.roll(3)); } }}const battle = newBattle(7);run(battle, new Auto());console.log("Aufzeichnung:");for (const r of battle.record) { console.log(describe(r));}const again = replayBattle(7, battle.record);console.log("Derselbe Kampf aus der Aufzeichnung: " + (sameLines(battle.log, again.log) ? "ja" : "nein"));Aufzeichnung:0 attack 2 cleave1 attack 02 attack 00 attack 2 smash1 attack 00 item -1 potion1 attack 00 attack 11 attack 00 attack 1Derselbe Kampf aus der Aufzeichnung: neinIn der Aufzeichnung ist Wirbel attack. Welche Zeile ist schuld?
Wähle die Zeile mit dem Fehler.
toRecord schreibt "attack" statt "skill". Live wird die Fähigkeit als Fähigkeit ausgeführt: Das Mana ist bezahlt, der Schlag trifft alle. Aber in der Aufzeichnung steht „Schlag“ gegen ein Ziel, und der Rückblick tut genau das, was er liest: Er schlägt zu, ohne Mana zu zahlen und ohne Abklingzeit. Die Protokolle weichen schon beim ersten Befehl ab. Das Zeichen steckt in der Aufzeichnung selbst: Zeilen wie 0 attack 2 cleave, in denen sich Art und Fähigkeit widersprechen.
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:
skill smash 2dancerestitemskill smash 1attack 1skill smash 1== Runde 1 ==Krieger: WuchtschlagKrieger → Ratte: 12 SchadenRatte fällt.Goblin → Krieger: 8 Schaden== Runde 2 ==? dance: unbekannter BefehlKrieger: Rast +2 ManaGoblin → Krieger: 8 Schaden== Runde 3 ==Krieger: Trank, +10 LebenGoblin → Krieger: 6 Schaden== Runde 4 ==? skill smash 1: lädt auf: 1Krieger → Goblin: 8 SchadenGoblin → Krieger: 7 Schaden== Runde 5 ==Krieger: WuchtschlagKrieger → Goblin: 11 SchadenGoblin fällt.Sieg!Derselbe Kampf aus der Aufzeichnung: jaWeitere 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.
FleeActionwürfelt (50 %). Gibfleein 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()zuAttackActionundItemActionhinzuzufü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
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Arrays: push, splice und sort · Map und Set · Pfeilfunktionen · Daten, switch und Destrukturierung
- Aufgaben zum Thema: Die Chroniken von Arkanum