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/41) 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 FluchtFü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 { constructor(name, maxHp, strength, agility, defense, speed, weaponMin, weaponMax, kind, resist, weak) { this.name = name; this.maxHp = maxHp; this.strength = strength; this.agility = agility; this.defense = defense; this.speed = speed; this.weaponMin = weaponMin; this.weaponMax = weaponMax; this.kind = kind; this.resist = resist; this.weak = weak; this.hp = maxHp; this.energy = 0; }
accuracy() { return 75 + this.agility * 2; }
evasion() { return this.agility * 2; }
critChance() { return 5 + Math.floor(this.agility / 10); }
isAlive() { return this.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.rawStat(stat) { let value = this.base.get(stat) + this.gearBonus(stat); for (const effect of this.effects) value += effect.modifier(stat); return value;}
stat(stat) { const value = this.rawStat(stat); return value < 0 ? 0 : value;}
maxHp() { const value = this.stat('maxHp'); return value < 1 ? 1 : value;}
maxMana() { return this.stat('maxMana');}
// Unter 25 % Leben ist die Wunde schwer: Treffer werden ungenauer.isHeavilyWounded() { return this.hp * 4 < this.maxHp();}
accuracy() { return 75 + this.stat('agility') * 2 + this.rawStat('accuracy') - (this.isHeavilyWounded() ? 15 : 0);}
evasion() { return this.stat('agility') * 2 + this.rawStat('evasion');}
critChance() { return 5 + Math.floor(this.stat('agility') / 10);}Math.floor(this.agility / 10) ist die Ganzzahldivision: In JavaScript ist / immer gebrochen.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“.
nextActor() { while (true) { let best = null; for (const f of this.participants()) { if (f.energy >= 100 && (best === null || f.energy > best.energy)) best = f; } if (best !== null) return best; for (const f of this.participants()) f.energy += Math.max(1, f.speed); this.ticks++; }}Hier sind die Regeln in der Reihenfolge, in der der Code sie liest:
- 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.) - 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. - 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:
const hero = new Fighter("Iva", 48, 7, 4, 3, 10, 4, 7, "slash", "", "");const wolf = new Fighter("Blutwolf", 20, 3, 8, 1, 14, 2, 5, "slash", "", "");const goblin = new Fighter("Speergoblin", 16, 2, 5, 1, 10, 2, 5, "pierce", "", "");const battle = new Battle([hero, wolf, goblin]);for (let i = 0; i < 7; i++) { const actor = battle.nextActor(); console.log("Tick " + battle.ticks + ": " + actor.name + " ist dran (Energie " + actor.energy + ")."); actor.energy -= 100;}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:
nextActor() { while (true) { let best = null; for (const f of this.participants()) { if (f.energy >= 100 && (best === null || f.energy > best.energy)) best = f; } if (best !== null) return best; for (const f of this.participants()) f.energy += Math.max(1, f.speed); this.ticks++; }}
// in der Kampfschleifeconst actor = battle.nextActor();actor.energy -= 100;Schritt für Schritt, in Worten
- 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.
- Noch kann niemand handeln, also macht die Welt einen Tick: Jeder addiert sein Tempo zur Leiste (mindestens 1). Die Leisten wachsen unterschiedlich schnell.
- Tick 8: Blutwolf steht bei 112. Er erreicht als Erster 100 und handelt. Er zahlt 100, nicht alles: 12 bleiben übrig.
- Tick 10: Iva und Speergoblin stehen beide bei 100. Gleichstand: Wer früher in der Liste steht, ist dran, und der Held steht vorn.
- Der Held hat bezahlt, und Speergoblin steht noch bei 100. Kein neuer Tick: Der nächste Aufruf von nextActor liefert ihn sofort.
- 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.
- 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:
nextActor() { while (true) { let best = null; for (const creature of this.participants()) { if (creature.energy >= 100 && (best === null || creature.energy > best.energy)) best = creature; } if (best !== null) return best; for (const creature of this.participants()) creature.energy += Math.max(1, creature.stat('speed')); }}// Der Beginn eines Zuges: Effekte wirken, Abklingzeiten sinken. Ein betäubter Kämpfer setzt aus.beginTurn(actor) { actor.energy -= 100; const stunned = actor.hasEffect('stun'); tickEffects(actor, this); actor.tickCooldowns(); this.handleDeaths(); if (!actor.isAlive()) return false; // Nach einer Betäubung ist der nächste Zug sicher: Sonst würden drei Feinde mit Keulen den Helden festnageln. actor.stunProof = stunned; if (stunned) { this.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:
function clamp(value, low, high) { if (value < low) return low; if (value > high) return high; return value;}
function hitChance(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%:
const pairs = [[83, 16], [91, 8], [83, 80], [120, 0]];for (const [accuracy, evasion] of pairs) { console.log(accuracy + " - " + evasion + " = " + (accuracy - evasion) + ", Chance " + hitChance(accuracy, evasion) + "%");}
const chance = hitChance(83, 16);const dice = new Random(2026);for (let i = 0; i < 5; i++) { const roll = dice.range(1, 100); console.log("du würfelst " + roll + (roll > chance ? " — daneben" : " — Treffer"));}83 - 16 = 67, Chance 67%91 - 8 = 83, Chance 83%83 - 80 = 3, Chance 10%120 - 0 = 120, Chance 95%du würfelst 76 — danebendu würfelst 59 — Trefferdu würfelst 23 — Trefferdu würfelst 37 — Trefferdu würfelst 58 — TrefferDie 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.
function hitChance(accuracy, evasion) {
return clamp(accuracy - evasion, , 95);
}Die untere Grenze ist 10: Selbst gegen den wendigsten Feind (83 − 80 = 3) trifft ein Schlag in einem von zehn Fällen. Die obere ist 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:
function damageOf(weapon, strength, defense, crit) { let damage = weapon + strength; if (crit) damage *= 2; damage -= defense; if (damage < 1) damage = 1; return damage;}
function show(label, weapon, strength, defense, crit) { let sum = weapon + " + " + strength; if (crit) sum = "(" + sum + ") * 2"; console.log(label + ": " + sum + " - " + defense + " -> " + damageOf(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 (const agility of [4, 8, 25, 40]) { console.log("kritischer Treffer: Geschick " + agility + " -> " + (5 + Math.floor(agility / 10)) + "%");}normaler Schlag: 6 + 7 - 1 -> 12kritischer Treffer: (6 + 7) * 2 - 1 -> 25schwacher Schlag gegen Rüstung: 2 + 1 - 6 -> 1kritischer 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).
function resisted(target, kind, damage) { if (target.resist === kind) return Math.floor(damage / 2); if (target.weak === kind) return Math.floor((damage * 3) / 2); return damage;}Ein Skelett ist resistent gegen Schneidendes und schwach gegen Stumpfes. Ein Schlag von 12 gegen es ergibt:
const skeleton = new Fighter("Skelett", 28, 4, 4, 2, 10, 3, 6, "slash", "slash", "blunt");const kinds = ["slash", "pierce", "blunt", "fire", "poison"];const names = ["schneidend", "stechend", "stumpf", "Feuer", "Gift"];for (let i = 0; i < kinds.length; i++) { console.log(names[i] + ": 12 -> " + resisted(skeleton, kinds[i], 12));}schneidend: 12 -> 6stechend: 12 -> 12stumpf: 12 -> 18Feuer: 12 -> 12Gift: 12 -> 12Stumpfe 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:
function afterHit(battle, target, type, crit) { if (!target.isAlive()) return; const rng = battle.game.rng; if (type === 'slash' && (crit || rng.chance(35))) applyEffect(target, makeEffect('bleed', 3, 1), battle); else if (type === 'blunt' && rng.chance(10)) applyEffect(target, makeEffect('stun', 1, 0), battle); else if (type === 'fire' && rng.chance(30)) applyEffect(target, makeEffect('burn', 3, 2), battle); else if (type === 'poison') applyEffect(target, makeEffect('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:
function percent(a, b) { return Math.floor((a * 100) / b);}
function damageWord(damage, maxHp) { const share = percent(damage, maxHp); if (share < 10) return "kratzt"; if (share < 25) return "trifft"; if (share < 45) return "zerschneidet"; return "zerschmettert";}for (const damage of [1, 2, 4, 5, 8, 9, 10, 12]) { console.log(damage + " von 20: " + percent(damage, 20) + "%, " + damageWord(damage, 20));}1 von 20: 5%, kratzt2 von 20: 10%, trifft4 von 20: 20%, trifft5 von 20: 25%, zerschneidet8 von 20: 40%, zerschneidet9 von 20: 45%, zerschmettert10 von 20: 50%, zerschmettert12 von 20: 60%, zerschmettertUnter 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.function damageWord(damage, maxHp) { const share = percent(damage, maxHp); 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:
function strike(battle, dice, attacker, target) { const hitChance = clamp(attacker.accuracy() - target.evasion(), 10, 95); if (dice.range(1, 100) > hitChance) { battle.say(attacker.name + " verfehlt " + target.name + "."); return false; } let damage = dice.range(attacker.weaponMin, attacker.weaponMax) + attacker.strength; let critChance = attacker.critChance(); if (attacker.kind === "pierce") critChance += 10; const crit = dice.chance(critChance); if (crit) damage *= 2; damage -= target.defense; damage = resisted(target, attacker.kind, damage); if (damage < 1) damage = 1; let line = attacker.name + " " + damageWord(damage, target.maxHp) + " " + target.name + " (" + damage + ")."; if (crit) line += " Blut spritzt!"; battle.say(line); target.hp = Math.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:
const dice = new Random(16);const hero = new Fighter("Iva", 48, 7, 4, 3, 10, 4, 7, "slash", "", "");const wolf = new Fighter("Blutwolf", 20, 3, 8, 1, 14, 2, 5, "slash", "", "");const goblin = new Fighter("Speergoblin", 16, 2, 5, 1, 10, 2, 5, "pierce", "", "");const battle = new Battle([hero, wolf, goblin]);battle.say("Kampf! Gegen dich: " + wolf.name + ", " + goblin.name + ".");while (true) { const actor = battle.nextActor(); actor.energy -= 100; const target = actor === hero ? battle.participants()[1] : hero; strike(battle, dice, actor, target); if (!target.isAlive()) battle.say(target.name + (target === hero ? " fällt. Die Mine verschluckt das Licht." : " fällt und steht nicht mehr auf.")); if (!hero.isAlive() || battle.participants().length === 1) break;}battle.say((hero.isAlive() ? "Sieg!" : "Niederlage.") + " " + hero.name + ": Leben " + hero.hp + "/" + hero.maxHp);for (const line of battle.log) console.log(line);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/48Im 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:
draw(buffer) { const battle = this.battle; const hero = battle.hero; buffer.text(0, 0, '=== ' + t('battle.title') + ' ===', 'yellow'); buffer.text(0, 1, hero.name + ', ' + nm(hero.heroClass()) + ' ' + t('hud.level', { level: hero.level }), 'white'); buffer.text(2, 2, t('hud.hp'), 'red'); buffer.bar(4, 2, 12, hero.hp, hero.maxHp(), 'red'); buffer.text(19, 2, hero.hp + '/' + hero.maxHp(), 'default'); buffer.text(27, 2, t('hud.mana'), 'blue'); buffer.bar(29, 2, 8, hero.mana, hero.maxMana(), 'blue'); buffer.text(40, 2, hero.mana + '/' + hero.maxMana(), 'default'); // Die Zeilen folgen direkt aufeinander: der Held, die Feinde, das Protokoll, die Befehle. let row = 3; const heroEffects = effectLabels(hero); if (heroEffects.length > 0) { buffer.text(2, row, heroEffects, 'green'); row += 1; } const enemies = battle.aliveEnemies(); for (let i = 0; i < enemies.length && i < 4; i++) { const enemy = enemies[i]; buffer.text(0, row, (i + 1) + ') ', 'default'); buffer.text(3, row, pad(cap(enemy.name), 22), enemy.kind.color); buffer.bar(25, row, 8, enemy.hp, enemy.maxHp(), 'red'); buffer.text(36, row, enemy.hp + '/' + enemy.maxHp(), 'default'); buffer.text(44, row, effectLabels(enemy), 'green'); row += 1; } const logRows = 7; const first = Math.max(0, battle.log.length - logRows); for (let i = first; i < battle.log.length; i++) { buffer.text(1, row, battle.log[i], logColor(battle.log[i])); row += 1; } if (battle.over) { buffer.text(0, row, t(battle.outcome === 'win' ? 'battle.won' : battle.outcome === 'fled' ? 'battle.away' : 'battle.lost'), 'yellow'); } else { buffer.text(0, row, this.note.length > 0 ? this.note : t('battle.commands'), this.note.length > 0 ? 'red' : '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.
function strike(battle, attacker, target, profile, power) { const rng = battle.game.rng; const hitChance = clamp(attacker.accuracy() - target.evasion(), 10, 95); if (rng.range(1, 100) > hitChance) { battle.say(t('combat.miss', { attacker: attacker.name, target: target.name })); return false; } let damage = rng.range(profile.min, profile.max) + attacker.stat('strength'); damage = Math.floor((damage * power) / 100); let critChance = attacker.critChance(); if (profile.type === 'pierce') critChance += 10; const crit = rng.chance(critChance); if (crit) damage *= 2; damage -= target.stat('defense'); damage = resisted(target, profile.type, damage); if (damage < 1) damage = 1; let line = t('combat.hit', { attacker: attacker.name, verb: damageWord(damage, target.maxHp()), target: target.name, damage: damage, }); if (crit) line += t(!target.isHero() && target.isBloodless() ? 'combat.critBone' : 'combat.critBlood'); battle.say(line); dealDamage(battle, target, damage); afterHit(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?
const battle = new Battle([ new Fighter("Ritter", 30, 5, 5, 1, 10, 3, 6, "slash", "", ""), new Fighter("Magier", 30, 5, 5, 1, 10, 3, 6, "slash", "", ""), new Fighter("Schütze", 30, 5, 5, 1, 15, 3, 6, "slash", "", ""),]);const order = [];for (let i = 0; i < 8; i++) { const actor = battle.nextActor(); actor.energy -= 100; order.push(actor.name);}console.log(order.join(", "));Der Schütze (Tempo 15) erreicht als Erster 100, bei Tick 7 (105), und handelt. Der Ritter und der Magier (10) erreichen 100 gemeinsam bei Tick 10: Bei Gleichstand handelt der frühere in der Liste, also kommt der Ritter vor dem Magier. Dann der Schütze bei Tick 14 (5 + 15 × 7 = 110), und bei Tick 20 stehen alle drei bei 100 und handeln in der Reihenfolge der Liste. Die zweite Möglichkeit kehrt den Gleichstand um (der Magier vor dem Ritter: Vergleich mit >= statt >). Die dritte läuft in Runden wie am Anfang der Lektion und beachtet das Tempo nicht.
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:
const hero = new Fighter("Iva", 48, 7, 4, 3, 10, 4, 7, "slash", "", "");const wolf = new Fighter("Blutwolf", 20, 3, 8, 1, 14, 2, 5, "slash", "", "");const goblin = new Fighter("Speergoblin", 16, 2, 5, 1, 10, 2, 5, "pierce", "", "");const battle = new Battle([hero, wolf, goblin]);for (let i = 0; i < 7; i++) { const actor = battle.nextActor(); console.log("Tick " + battle.ticks + ": " + actor.name + " ist dran (Energie " + actor.energy + ")."); actor.energy = 0;}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.
actor.energy = 0 wirft alles über 100 weg. Der Wolf steht bei 112, und der Rest 12 geht verloren, deshalb kostet ihn jeder Zug volle 8 Ticks, und Tempo 14 ist dasselbe wie 13. Richtig ist actor.energy -= 100: Es werden genau 100 gezahlt, und der Rest bleibt.
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:
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ächstencountTeilnehmer 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
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Klassen und Objekte · Konstruktoren und this · Instanzmethoden · Arrays: push, splice und sort
- Aufgaben zum Thema: Die Chroniken von Arkanum