Blut, Wunden und Effekte
Kurz gesagt: Ein Effekt ist etwas, das einige Züge auf einem Wesen bleibt: Er hat einen Moment beim Auflegen, einen in jedem Zug und einen beim Ablaufen, und die Wiederholungsregel sagt, ob erneutes Auflegen auffrischt, stapelt oder ignoriert wird.
Problem
Ein Schnitt, drei Züge
Ein Wolf kratzt dich, und aus der Wunde fließt Blut. Sie tut nicht nur einmal weh: Sie tut in jedem Zug weh, bis die Wunde sich schließt. Ein zweiter Schnitt beginnt keine neue Blutung, er fügt der alten eine Wunde hinzu. Und wenn das Leben unter ein Viertel fällt, zittert die Hand, und die Schläge werden ungenauer.
Hier sind drei Züge aus dem echten Spiel: Iva gegen den Wolfsführer.
=== Kampf ===Iva, Barbar St.2 L [###########-] 62/67 M [########] 5/5 Blutx3:21) Wolfsführer [########] 36/36 Kampf! Gegen dich: Wolfsführer. Wolfsführer springt mit den Zähnen voran. Wolfsführer kratzt Iva (2). Iva blutet. Iva blutet stärker (Wunden: 3). Iva verliert Blut (3).1 Angriff 2 Fähigkeit 3 Gegenstand 4 FluchtUnter der Lebensleiste steht Blutx3:2: Blutung mit drei Wunden (x3) und noch zwei Zügen. Das Protokoll sagt, was geschieht: Der Effekt wird aufgelegt, blutet stärker (Wunden: 3), und das Leben sinkt um 3. So kommen diese Zahlen zustande.
Das ist ein Effekt: etwas, das einige Züge auf einem Wesen bleibt und in ihnen wirkt. Gift, Brennen, Betäubung, Regeneration, Wut und Schild sind von derselben Art. Damit es sie alle geben kann, muss ein Effekt fünf Dinge können: drei Momente (beim Auflegen, in jedem Zug, beim Ablaufen), eine Wiederholungsregel, Änderungen der Werte, eine Spur auf der Karte und eine schwere Wunde. Die Lektion baut sie nacheinander in kleinen Programmen und zeigt dann dasselbe im Spiel.
Genauer, für Neugierige
Blut im Spiel ist eine Mechanik, kein Bild. Die Blutung ist ein Effekt mit Zahlen: Wunden, Züge, Schaden. Das Kampfprotokoll nennt die Stärke eines Treffers mit Wörtern („kratzt“, „zerschneidet“) und die Blutung mit einem Satz, ohne Schilderungen von Gewalt um ihrer selbst willen. Das Spiel ist für Schüler und Erwachsene und bleibt es. Das Blut ist da, weil es dem Spieler eine Entscheidung gibt: verbinden oder fliehen, abwarten oder noch einmal zuschlagen.
Die Idee
Ein Effekt hat drei Momente
Was macht Gift? Drei verschiedene Dinge in drei verschiedenen Momenten: wenn es aufgelegt wird („Gift sickert ins Blut“), in jedem Zug (Schaden) und wenn es abläuft („Seine Wirkung lässt nach“). Ein Effekt ist deshalb eine Klasse mit drei Methoden, die standardmäßig nichts tun: onApply, onTurn und onExpire. Jede Art von Effekt ändert nur das, was sie braucht.
class Effect { constructor(id, turns, power) { this.id = id; this.turns = turns; this.power = power; this.stacks = 1; }
stackRule() { return "refresh"; }
maxStacks() { return 1; }
modifier(stat) { return 0; }
onApply(target, ctx) { ctx.say(t("fx.on." + this.id, target.name)); }
onTurn(target, ctx) {}
onExpire(target, ctx) { ctx.say(t("fx.off." + this.id, target.name)); }
label() { const name = t("fxs." + this.id); return this.stacks > 1 ? name + "x" + this.stacks + ":" + this.turns : name + ":" + this.turns; }}
class PoisonEffect extends Effect { onTurn(target, ctx) { ctx.say(t("fx.tick.poison", target.name, this.power)); hurt(ctx, target, this.power); }}
class RegenEffect extends Effect { onTurn(target, ctx) { const healed = target.heal(this.power); if (healed > 0) ctx.say(t("fx.tick.regen", target.name, healed)); }}Die Klasse hält außerdem eine id (damit findet man den Text und den Effekt in der Liste), turns (wie viele Züge übrig sind), power (die Stärke) und stacks (Wunden, Schichten). label() macht ein kurzes Etikett wie Gift:2 für die Leiste im Kampf.
Ein Wesen hält seine Effekte in einer Liste effects. In jedem Zug geht tickEffects die Liste durch: Der Effekt wirkt (onTurn), verliert dann einen Zug, und wenn die Züge ablaufen, wird er entfernt und sagt onExpire. Das Programm legt auf dieselbe Iva Gift für 3 Züge mit Stärke 2 und Regeneration für 2 Züge mit Stärke 1:
const hero = new Creature("Iva", 20, stats(10, 4, 10));const log = new Log();applyEffect(hero, makeEffect("poison", 3, 2), log);applyEffect(hero, makeEffect("regen", 2, 1), log);for (let turn = 1; turn <= 3; turn++) { console.log("Zug " + turn); tickEffects(hero, log); console.log(status(hero));}Gift sickert in Iva.Iva erholt sich langsam.Zug 1Das Gift brennt in Iva (2).Iva erholt sich (+1).Leben 19/20 [Gift:2 Regen:1]Zug 2Das Gift brennt in Iva (2).Iva erholt sich (+1).Regeneration lässt bei Iva nach.Leben 18/20 [Gift:1]Zug 3Das Gift brennt in Iva (2).Gift lässt bei Iva nach.Leben 16/20Das Gift wirkt dreimal (Züge 1, 2 und 3) und läuft im dritten ab. Die Regeneration wirkt zweimal und läuft im zweiten ab. Die beiden Effekte laufen gleichzeitig, jeder mit seinen Zügen. Deshalb heißt turns = 3 „drei Wirkungen“, nicht „bis der dritte Zug vorbei ist“.
ctx ist das Protokoll: Es hat say(text) und addBlood(amount). Im Kampf ist es das Kampfprotokoll, auf der Karte das Spiel selbst. So funktioniert derselbe Effekt an beiden Orten, und das Protokoll entscheidet, wohin der Text geht. Die beiden Klassen und das Wesen selbst stehen im Block weiter unten.
Genauer, für Neugierige
Das Protokoll, das Wesen und die Texte. Die Texte der Effekte sind Daten, kein Code: ein Schlüssel und eine Vorlage, wie im Kanon (t('fx.on.' + this.id, …)). So schreibt derselbe Motor auf Bulgarisch, Englisch, Deutsch und Spanisch.
class Log { constructor() { this.blood = 0; }
say(text) { console.log(text); }
addBlood(amount) { this.blood += amount; }}
// Das stille Protokoll schreibt nichts, zählt aber das Blut.class Quiet { constructor() { this.blood = 0; }
say(text) {}
addBlood(amount) { this.blood += amount; }}class Creature { constructor(name, maxHp, base) { this.name = name; this.maxHp = maxHp; this.hp = maxHp; this.base = base; this.effects = []; }
isAlive() { return this.hp > 0; }
stat(stat) { let value = this.base[stat]; for (const effect of this.effects) value += effect.modifier(stat); return value < 0 ? 0 : value; }
// Unter 25 % Leben ist die Wunde schwer: Schläge werden ungenauer. isHeavilyWounded() { return this.hp * 4 < this.maxHp; }
accuracy() { return 75 + this.stat("agility") * 2 - (this.isHeavilyWounded() ? 15 : 0); }
heal(amount) { const before = this.hp; this.hp = Math.min(this.maxHp, this.hp + amount); return this.hp - before; }
findEffect(id) { for (const effect of this.effects) { if (effect.id === id) return effect; } return null; }
removeEffect(id) { const kept = []; for (const effect of this.effects) { if (effect.id !== id) kept.push(effect); } this.effects = kept; }}
function stats(strength, defense, agility) { return { strength: strength, defense: defense, agility: agility };}// Die Texte sind Daten: ein Schlüssel und eine Vorlage. {name} ist der Name, {n} ist die Zahl.const TEXT = { "fx.on.bleed": "{name} blutet.", "fx.on.poison": "Gift sickert in {name}.", "fx.on.burn": "{name} fängt Feuer.", "fx.on.stun": "{name} verliert das Gleichgewicht.", "fx.on.regen": "{name} erholt sich langsam.", "fx.on.rage": "{name} gerät in Wut!", "fx.on.shield": "Ein Schild schützt {name}.", "fx.more.bleed": "{name} blutet stärker (Wunden: {n}).", "fx.tick.bleed": "{name} verliert Blut ({n}).", "fx.tick.poison": "Das Gift brennt in {name} ({n}).", "fx.tick.burn": "Flammen beißen {name} ({n}).", "fx.tick.regen": "{name} erholt sich (+{n}).", "fx.off.bleed": "Die Wunden von {name} hören auf zu bluten.", "fx.off.poison": "Gift lässt bei {name} nach.", "fx.off.burn": "Brennen lässt bei {name} nach.", "fx.off.regen": "Regeneration lässt bei {name} nach.", "fx.off.rage": "Wut lässt bei {name} nach.", "fx.off.shield": "Schild lässt bei {name} nach.", "fxs.bleed": "Blut", "fxs.poison": "Gift", "fxs.burn": "Feuer", "fxs.stun": "Betäub", "fxs.regen": "Regen", "fxs.rage": "Wut", "fxs.shield": "Schild", "combat.wounded": "{name} ist schwer verwundet!",};
function t(key, name = "", n = 0) { return TEXT[key].replace("{name}", name).replace("{n}", String(n));}Quiet ist ein Protokoll, das schweigt, aber das Blut zählt. Im Spiel ist es für Feinde außerhalb des Bildschirms: Sie bluten und sterben, ohne dem Spieler etwas zu schreiben. Das Wesen und seine Werte stammen aus der Lektion über Wesen; hier brauchen wir stat, heal, findEffect und removeEffect.
Im Spiel ist es dasselbe. Der Unterschied liegt nur in den Texten: Der Kanon hat eine gemeinsame Vorlage Die Wirkung von {Effekt} auf {Name} lässt nach und nimmt den Namen des Effekts aus der Tabelle, unsere Tabelle hat dagegen eine Zeile pro Effekt.
// #region effects// Ein Effekt hat drei Momente: beim Anwenden (onApply), jeden Zug (onTurn) und beim Ablaufen (onExpire),// und eine Regel für Wiederholung: auffrischen (refresh), addieren (stack) oder ignorieren (ignore).// ctx ist das Kampfprotokoll oder die Karte: beide haben say(text) und addBlood(amount).class Effect { constructor(id, turns, power) { this.id = id; this.turns = turns; this.power = power; this.stacks = 1; }
stackRule() { return 'refresh'; }
maxStacks() { return 1; }
modifier(stat) { return 0; }
onApply(target, ctx) { ctx.say(t('fx.on.' + this.id, { name: target.name })); }
onTurn(target, ctx) {}
onExpire(target, ctx) { ctx.say(t('fx.off', { name: target.name, effect: t('fx.' + this.id) })); }
label() { const name = t('fxs.' + this.id); return this.stacks > 1 ? name + 'x' + this.stacks + ':' + this.turns : name + ':' + this.turns; }}class PoisonEffect extends Effect { onTurn(target, ctx) { ctx.say(t('fx.tick.poison', { name: target.name, damage: this.power })); hurt(ctx, target, this.power); }}Die Idee
Was geschieht, wenn ein Effekt ein zweites Mal aufgelegt wird
Der Wolf ist schon vergiftet und bekommt noch mehr Gift. Oder er blutet schon und wird wieder gekratzt. Oder er ist schon betäubt, und jemand schlägt ihn mit einer Keule. Die drei Fälle sind nicht gleich, deshalb trägt jeder Effekt eine Wiederholungsregel:
- auffrischen (
refresh, der Standard): Die längere der beiden Dauern und die größere Stärke bleiben. Gift, Brennen, Regeneration, Wut und Schild sind so; - stapeln (
stack): Eine weitere Schicht kommt dazu, bismaxStacks(). Die Blutung ist so; - ignorieren (
ignore): Erneutes Auflegen tut nichts. Die Betäubung ist so: Sonst würden drei Feinde mit Keulen den Helden für immer festhalten.
Die Regel wird von dem Effekt gelesen, der schon auf dem Wesen liegt (existing), und von applyEffect angewendet. Die Funktion gibt true zurück, wenn sich etwas geändert hat:
// Legt einen Effekt nach seiner Regel auf; gibt zurück, ob sich etwas geändert hat.function applyEffect(target, effect, ctx) { if (!target.isAlive()) return false; const existing = target.findEffect(effect.id); if (existing === null) { target.effects.push(effect); effect.onApply(target, ctx); return true; } const rule = existing.stackRule(); if (rule === "ignore") return false; if (rule === "stack") { existing.stacks = Math.min(existing.maxStacks(), existing.stacks + effect.stacks); ctx.say(t("fx.more." + effect.id, target.name, existing.stacks)); } existing.turns = Math.max(existing.turns, effect.turns); existing.power = Math.max(existing.power, effect.power); return true;}
// Jeder Effekt wirkt und verliert dann einen Zug; abgelaufene werden entfernt.function tickEffects(creature, ctx) { for (const effect of creature.effects.slice()) { if (!creature.isAlive()) return; effect.onTurn(creature, ctx); effect.turns -= 1; if (effect.turns <= 0) { creature.removeEffect(effect.id); effect.onExpire(creature, ctx); } }}Das Programm legt sechs Effekte auf einen Goblin und druckt für jeden, ob sich etwas geändert hat und was der Goblin danach trägt:
const goblin = new Creature("Goblin", 40, stats(8, 2, 6));const log = new Log();const incoming = [ makeEffect("poison", 3, 2), makeEffect("poison", 5, 1), makeEffect("bleed", 3, 1), makeEffect("bleed", 3, 1), makeEffect("stun", 1, 0), makeEffect("stun", 2, 0),];for (const effect of incoming) { const label = effect.label(); const changed = applyEffect(goblin, effect, log); console.log(label + " -> " + (changed ? "ja" : "nein") + " " + status(goblin));}Gift sickert in Goblin.Gift:3 -> ja Leben 40/40 [Gift:3]Gift:5 -> ja Leben 40/40 [Gift:5]Goblin blutet.Blut:3 -> ja Leben 40/40 [Gift:5 Blut:3]Goblin blutet stärker (Wunden: 2).Blut:3 -> ja Leben 40/40 [Gift:5 Blutx2:3]Goblin verliert das Gleichgewicht.Betäub:1 -> ja Leben 40/40 [Gift:5 Blutx2:3 Betäub:1]Betäub:2 -> nein Leben 40/40 [Gift:5 Blutx2:3 Betäub:1]Das zweite Gift verlängert das erste (Gift:3 wird zu Gift:5), ohne neue Meldung. Die zweite Blutung ist eine neue Wunde: Blutx2:3. Die zweite Betäubung gibt nein zurück, und nichts ändert sich.
// Legt einen Effekt nach seiner Regel auf; gibt zurück, ob sich etwas geändert hat.
function applyEffect(target, effect, ctx) {
if (!target.isAlive()) return false;
const existing = target.findEffect(effect.id);
if (existing === null) {
target.effects.push(effect);
effect.onApply(target, ctx);
return true;
}
const rule = existing.stackRule();
if (rule === "ignore") return false;
if (rule === "stack") {
existing.stacks = Math.min(, existing.stacks + effect.stacks);
ctx.say(t("fx.more." + effect.id, target.name, existing.stacks));
}
existing.turns = Math.max(existing.turns, effect.turns);
existing.power = Math.max(existing.power, effect.power);
return true;
}Wunden wachsen nicht ohne Grenze. Die höchste Zahl von Schichten nennt der Effekt selbst mit maxStacks(): bei der Blutung ist es 5, bei allen anderen 1. Die neue Zahl ist also das Kleinere von maxStacks() und stacks + effect.stacks.
Im Spiel hat die Funktion oben noch zwei Zeilen. Auf ein totes Wesen wird kein Effekt gelegt, und manche Wesen sind immun (das Knochenskelett hat kein Blut zum Bluten). Die zweite Zeile schützt vor endlosem Betäuben: Nach einer Betäubung ist der nächste Zug sicher.
// Wendet einen Effekt nach seiner Regel an; liefert, ob sich etwas geändert hat.function applyEffect(target, effect, ctx) { if (!target.isAlive() || target.isImmune(effect.id)) return false; if (effect.id === 'stun' && target.stunProof) return false; const existing = target.findEffect(effect.id); if (existing === null) { target.effects.push(effect); effect.onApply(target, ctx); return true; } const rule = existing.stackRule(); if (rule === 'ignore') return false; if (rule === 'stack') { existing.stacks = Math.min(existing.maxStacks(), existing.stacks + effect.stacks); ctx.say(t('fx.more.' + effect.id, { name: target.name, stacks: existing.stacks })); } existing.turns = Math.max(existing.turns, effect.turns); existing.power = Math.max(existing.power, effect.power); return true;}// Der Zug der Effekte: Jeder wirkt, verliert dann einen Zug; abgelaufene werden entfernt.function tickEffects(creature, ctx) { for (const effect of creature.effects.slice()) { if (!creature.isAlive()) return; effect.onTurn(creature, ctx); effect.turns -= 1; if (effect.turns <= 0) { creature.removeEffect(effect.id); effect.onExpire(creature, ctx); } }}Genauer, für Neugierige
Die Regel ist eine Methode des Effekts, kein Feld: Ein neuer Effekt mit stackRule() { return "stack" } und maxStacks() { return 3 } ist der ganze Unterschied. Diese Idee heißt Polymorphie: applyEffect weiß nicht, was für ein Effekt es ist, es fragt ihn. Die Alternative ist ein switch (effect.id) in applyEffect, der mit jedem neuen Effekt wächst und die Regeln weit weg von dem Effekt hält, zu dem sie gehören.
Sieh es dir an
Blutung: Wunden, die sich stapeln
Die Blutung ist der Effekt, für den es die Regel stack gibt. Jeder neue Schnitt ist eine Wunde mehr (höchstens 5), jede Wunde kostet 1 Leben pro Zug, und die Züge springen auf 3 zurück. Der Schaden eines Zuges ist die Zahl der Wunden mal die Stärke einer Wunde:
// Jede Wunde kostet 1 Leben pro Zug, bis zu 5 Wunden.class BleedEffect extends Effect { constructor(turns, stacks) { super("bleed", turns, 1); this.stacks = stacks; }
stackRule() { return "stack"; }
maxStacks() { return 5; }
onTurn(target, ctx) { const damage = this.stacks * this.power; ctx.say(t("fx.tick.bleed", target.name, damage)); hurt(ctx, target, damage); ctx.addBlood(damage); }
onExpire(target, ctx) { ctx.say(t("fx.off.bleed", target.name)); }}Der Konstruktor nimmt Züge und Wunden, nicht die Stärke: Die Stärke einer Wunde ist immer 1. makeEffect("bleed", 3, 2) heißt also „Blutung für drei Züge mit zwei Wunden“: Die dritte Zahl ist bei der Blutung die Zahl der Wunden. So legt ein Feind mit einem „Reißen“-Angriff zwei Wunden auf einmal.
// Jede Wunde kostet 1 Leben pro Zug, bis zu 5 Wunden.class BleedEffect extends Effect { constructor(turns, stacks) { super("bleed", turns, 1); this.stacks = stacks; }
stackRule() { return "stack"; }
maxStacks() { return 5; }
onTurn(target, ctx) { const damage = this.stacks * this.power; ctx.say(t("fx.tick.bleed", target.name, damage)); hurt(ctx, target, damage); ctx.addBlood(damage); }
onExpire(target, ctx) { ctx.say(t("fx.off.bleed", target.name)); }}
// Legt einen Effekt nach seiner Regel auf; gibt zurück, ob sich etwas geändert hat.function applyEffect(target, effect, ctx) { if (!target.isAlive()) return false; const existing = target.findEffect(effect.id); if (existing === null) { target.effects.push(effect); effect.onApply(target, ctx); return true; } const rule = existing.stackRule(); if (rule === "ignore") return false; if (rule === "stack") { existing.stacks = Math.min(existing.maxStacks(), existing.stacks + effect.stacks); ctx.say(t("fx.more." + effect.id, target.name, existing.stacks)); } existing.turns = Math.max(existing.turns, effect.turns); existing.power = Math.max(existing.power, effect.power); return true;}
// Jeder Effekt wirkt und verliert dann einen Zug; abgelaufene werden entfernt.function tickEffects(creature, ctx) { for (const effect of creature.effects.slice()) { if (!creature.isAlive()) return; effect.onTurn(creature, ctx); effect.turns -= 1; if (effect.turns <= 0) { creature.removeEffect(effect.id); effect.onExpire(creature, ctx); } }}
// Schaden außerhalb eines Schlags: nie unter null, und er meldet, wenn die Wunde schwer wurde.function hurt(ctx, target, amount) { const wasHeavy = target.isHeavilyWounded(); target.hp = Math.max(0, target.hp - amount); if (target.isAlive() && !wasHeavy && target.isHeavilyWounded()) ctx.say(t("combat.wounded", target.name));}
for (let turn = 1; turn <= 5; turn++) { console.log("Zug " + turn); if (turn <= 3) applyEffect(wolf, makeEffect("bleed", 3, 1), log); tickEffects(wolf, log); console.log(status(wolf));}Schritt für Schritt, in Worten
- Der Wolf hat 15 Leben. Die Blutung ist ein Effekt mit der Regel stack: Jeder neue Schnitt ist eine Wunde mehr (bis zu 5), und die Züge springen auf 3 zurück. Drei Schläge, fünf Züge Blut.
- Zug 1: der erste Schlag. Der Wolf blutet noch nicht, also legt applyEffect einen neuen Effekt auf (onApply). Dann nimmt der Effektzug 1 × 1 = 1: Leben 14, 2 Züge übrig.
- Zug 2: der zweite Schlag. Der Effekt besteht schon und seine Regel ist stack: Die Wunden werden 2, die Züge springen auf 3. Der Schaden ist 2 × 1 = 2: Leben 12.
- Zug 3: der dritte Schlag, 3 Wunden. Eine Wunde kostet 1 Leben pro Zug, drei Wunden kosten 3. Leben 9. Weitere Schläge gibt es nicht, aber das Blut bleibt.
- Zug 4: kein Schlag. tickEffects nimmt wieder 3 × 1 = 3 und zieht einen Zug ab: 1 übrig. Leben 6.
- Zug 5: das letzte Blut, 3. Das Leben fällt auf 3, unter 25 % von 15: hurt meldet „schwere Wunde“. Die Züge laufen ab und der Effekt wird entfernt (onExpire).
- Dieselben drei Schläge mit der Regel refresh (ohne Stapeln) hätten nur 5 Leben genommen; mit stack nahmen sie 12 von 15. Vergossenes Blut: 12.
Das Programm schlägt den Wolf in drei Zügen hintereinander (Züge 1–3), in den anderen zwei tickt es nur. Der Wolf hat 15 Leben:
const wolf = new Creature("Wolf", 15, stats(8, 2, 6));const log = new Log();for (let turn = 1; turn <= 5; turn++) { console.log("Zug " + turn); if (turn <= 3) applyEffect(wolf, makeEffect("bleed", 3, 1), log); tickEffects(wolf, log); console.log(status(wolf));}console.log("Vergossenes Blut: " + log.blood);Zug 1Wolf blutet.Wolf verliert Blut (1).Leben 14/15 [Blut:2]Zug 2Wolf blutet stärker (Wunden: 2).Wolf verliert Blut (2).Leben 12/15 [Blutx2:2]Zug 3Wolf blutet stärker (Wunden: 3).Wolf verliert Blut (3).Leben 9/15 [Blutx3:2]Zug 4Wolf verliert Blut (3).Leben 6/15 [Blutx3:1]Zug 5Wolf verliert Blut (3).Wolf ist schwer verwundet!Die Wunden von Wolf hören auf zu bluten.Leben 3/15Vergossenes Blut: 12Der Schaden pro Zug wächst 1, 2, 3, ohne dass die Schläge stärker wären: Die Wunden addieren sich. In Zug 3 sind es drei Wunden, und die Züge stehen wieder auf 3, die Blutung geht also noch zwei Züge mit je 3 weiter. Insgesamt 1 + 2 + 3 + 3 + 3 = 12. Das vergossene Blut ist genau das genommene Leben (15 − 3 = 12), weil addBlood den Schaden der Blutung zählt. In Zug 5 fällt das Leben unter ein Viertel, und „schwere Wunde“ erscheint (die letzten Karten).
Würde sich die Blutung nicht stapeln, sondern nur auffrischen, nähmen dieselben drei Schläge 1 pro Zug und insgesamt 5 Leben (5 Züge Blut zu je 1). Mit Stapeln sind es 12. Eine kleine Änderung der Regel macht Waffen mit Klinge gefährlich.
Woher kommen die Wunden im Spiel? Aus dem Kampfsystem: Ein Schlag mit einer Klingenwaffe legt bei einem kritischen Treffer oder in 35 % der Fälle eine Wunde, und das „Reißen“ eines Feindes legt zwei auf einmal. Die Klasse im Spiel ist diese:
// #region bleeding// Blutung stapelt sich: Jeder neue Schnitt ist eine Wunde mehr (bis 5), jede kostet 1 Leben pro Zug, drei// Züge lang. Vergossenes Blut zählt für die Statistik, und eine blutende Kreatur, die sich bewegt, hinterlässt eine Spur.class BleedEffect extends Effect { constructor(turns, stacks) { super('bleed', turns, 1); this.stacks = stacks; }
stackRule() { return 'stack'; }
maxStacks() { return 5; }
onTurn(target, ctx) { const damage = this.stacks * this.power; ctx.say(t('fx.tick.bleed', { name: target.name, damage: damage })); hurt(ctx, target, damage); ctx.addBlood(damage); }
onExpire(target, ctx) { ctx.say(t('fx.off.bleed', { name: target.name })); }}Genauer, für Neugierige
Im Kanon ist die Blutung des Helden und die der Feinde dieselbe Klasse. Deshalb entfernt das Gegengift Gift, Blutung und Brennen mit removeEffect, und die langsame Erholung des Helden (+1 Leben in jedem dritten Zug) wirkt nicht, solange er blutet oder vergiftet ist: worldTurn fragt hasEffect.
Probier es aus
Sage die Ausgabe voraus
Der Held blutet mit einer Wunde. Nach dem ersten Zug bekommt er einen weiteren Schnitt mit zwei Wunden. Die Effekte arbeiten mit Quiet, gedruckt wird also nur das Leben. Was gibt das Programm aus?
const hero = new Creature("Iva", 20, stats(10, 4, 10));const quiet = new Quiet();applyEffect(hero, makeEffect("bleed", 3, 1), quiet);for (let turn = 1; turn <= 4; turn++) { if (turn === 2) applyEffect(hero, makeEffect("bleed", 3, 2), quiet); tickEffects(hero, quiet); console.log("Leben " + hero.hp);}Die Blutung hat die Regel stack: Nach dem ersten Zug ist es eine Wunde (Leben 19), dann kommen zwei dazu, die Wunden werden drei, und die Züge springen auf 3 zurück. Es folgen drei Züge mit je 3: 16, 13 und 10. Die Antwort 19, 18, 17, 16 ist das Auffrischen (die Wunde bleibt eine, die Züge springen auf 3 zurück), und 19, 18, 17, 17 ist ignore (das zweite Auflegen wird weggeworfen).
Probier es aus
Finde den Fehler
Ein Kollege hat onTurn der Blutung angefasst. Das Programm mit dem Wolf gibt das hier aus, und das Leben sinkt um 1 pro Zug, obwohl es drei Wunden gibt:
// Jede Wunde kostet 1 Leben pro Zug, bis zu 5 Wunden.class BleedEffect extends Effect { constructor(turns, stacks) { super("bleed", turns, 1); this.stacks = stacks; }
stackRule() { return "stack"; }
maxStacks() { return 5; }
onTurn(target, ctx) { const damage = this.power; ctx.say(t("fx.tick.bleed", target.name, damage)); hurt(ctx, target, damage); ctx.addBlood(damage); }
onExpire(target, ctx) { ctx.say(t("fx.off.bleed", target.name)); }}Zug 1Wolf blutet.Wolf verliert Blut (1).Leben 14/15 [Blut:2]Zug 2Wolf blutet stärker (Wunden: 2).Wolf verliert Blut (1).Leben 13/15 [Blutx2:2]Zug 3Wolf blutet stärker (Wunden: 3).Wolf verliert Blut (1).Leben 12/15 [Blutx3:2]Zug 4Wolf verliert Blut (1).Leben 11/15 [Blutx3:1]Zug 5Wolf verliert Blut (1).Die Wunden von Wolf hören auf zu bluten.Leben 10/15Vergossenes Blut: 5Das Etikett sagt Wunden: 3, und „verliert Blut“ bleibt bei 1. Welche Zeile berechnet den Schaden?
Wähle die Zeile mit dem Fehler.
Der Schaden ist power, nicht stacks × power. Die Wunden werden gezählt, das Etikett zeigt sie, aber das Leben sieht sie nicht: Drei Wunden nehmen so viel wie eine. Richtig ist damage = stacks × power. Der Fehler ist hinterhältig: Nichts geht kaputt, applyEffect stapelt, label() zeigt die Wunden, und der Unterschied zeigt sich nur in den Zahlen.
Die Idee
Effekte, die Werte ändern
Nicht jeder Effekt wirkt in jedem Zug. Wut gibt Stärke und nimmt Verteidigung, solange sie anhält, und ein Schild gibt Verteidigung. Sie tun in onTurn nichts. Stattdessen wird der Wert eines Wesens aus einer Basis und den Effekten berechnet:
Wert = Basis des Wesens + Ausrüstung + die Änderungen der EffekteJeder Effekt hat eine Methode modifier(stat), die zurückgibt, wie viel er zu einem Wert hinzufügt (standardmäßig 0). Das Wesen addiert die Antworten bei jeder Frage nach einem Wert und speichert das Ergebnis nicht. Wird ein Effekt aus der Liste entfernt, kehrt der Wert deshalb von selbst zurück:
class RageEffect extends Effect { modifier(stat) { if (stat === "strength") return this.power; if (stat === "defense") return -2; return 0; }}
class ShieldEffect extends Effect { modifier(stat) { return stat === "defense" ? this.power : 0; }}Das Programm gibt Iva Wut für 3 Züge mit Stärke 4 und ein Schild für 2 Züge mit Stärke 5 und druckt dann die Werte:
const hero = new Creature("Iva", 30, stats(10, 4, 10));const log = new Log();applyEffect(hero, makeEffect("rage", 3, 4), log);applyEffect(hero, makeEffect("shield", 2, 5), log);console.log(statLine(hero));for (let turn = 1; turn <= 3; turn++) { console.log("Zug " + turn); tickEffects(hero, log); console.log(statLine(hero));}Iva gerät in Wut!Ein Schild schützt Iva.Stärke 14, Verteidigung 7Zug 1Stärke 14, Verteidigung 7Zug 2Schild lässt bei Iva nach.Stärke 14, Verteidigung 2Zug 3Wut lässt bei Iva nach.Stärke 10, Verteidigung 4Stärke 14 ist 10 + 4. Die Verteidigung ist 4 − 2 (Wut) + 5 (Schild) = 7. Läuft das Schild ab, bleiben 4 − 2 = 2, und läuft auch die Wut ab, ist alles wieder bei der Basis. Niemand „gibt zurück“: Der Wert wird bei jeder Frage neu berechnet.
Zwei Einzelheiten. stat fällt nicht unter 0: Mit Schwäche und ohne Rüstung ist die Verteidigung 0, nicht negativ. Und ein Effekt, der sich stapelt und einen Wert gibt, muss in modifier mit stacks multiplizieren, so wie die Blutung in onTurn multipliziert: Das ist die Übung am Ende der Lektion.
Das Spiel hat dieselben zwei Effekte:
class RageEffect extends Effect { modifier(stat) { if (stat === 'strength') return this.power; if (stat === 'defense') return -2; return 0; }}class ShieldEffect extends Effect { modifier(stat) { return stat === 'defense' ? this.power : 0; }}Genauer, für Neugierige
Das Spiel hat weitere Wertänderungen: DodgeEffect (Ausweichen), WeakEffect (Schwäche durch den Schrei eines Feindes) und FuryEffect (die Raserei des Wächters, die zweite Phase). Alle sind etwa zehn Zeilen lang. Bei FuryEffect ist anders, dass er 999 Züge dauert: bis zum Ende des Kampfes.
Die Idee
Die Blutspur
Ein blutender Wolf hinterlässt eine Spur auf der Karte. Sie gehört zum Spiel: Die Quest „Die Blutspur“ führt dich auf ihr zum Bau des Wolfsführers.
Wer legt den Tropfen? Nicht der Effekt. Der Effekt weiß, wie viel Blut fließt (ctx.addBlood(damage)), aber nicht, wo das Wesen steht. Das weiß der, der es bewegt. Deshalb fragt die Bewegung hasEffect("bleed") und legt einen Tropfen auf die Zelle, die das Wesen verlässt. Die Ebene hält die Tropfen in einer eigenen Schicht über dem Boden (decor) und legt Blut nur auf sauberen Boden: Eine Leiche und Gegenstände bleiben sichtbar.
class Level { constructor(floor) { this.floor = floor; this.decor = new Array(floor.length).fill(" "); }
// Blut kommt nur auf einen sauberen Boden. addBlood(x) { if (this.decor[x] === " " && this.floor[x] === ".") this.decor[x] = ","; }
draw(walkerX) { let row = ""; for (let x = 0; x < this.floor.length; x++) { let glyph = this.floor[x]; if (this.decor[x] !== " ") glyph = this.decor[x]; if (x === walkerX) glyph = "w"; row += glyph; } return row; }}Das Programm schickt einen verwundeten Wolf durch einen Gang. Die Wunde dauert drei Züge, es gibt also drei Tropfen, und danach läuft der Wolf sauber weiter:
const level = new Level("#...........#");const wolf = new Creature("Wolf", 15, stats(8, 2, 6));const quiet = new Quiet();let x = 1;applyEffect(wolf, makeEffect("bleed", 3, 1), quiet);console.log(level.draw(x));for (let step = 1; step <= 5; step++) { if (wolf.findEffect("bleed") !== null) level.addBlood(x); x++; tickEffects(wolf, quiet); console.log(level.draw(x));}console.log("Vergossenes Blut: " + quiet.blood);#w..........##,w.........##,,w........##,,,w.......##,,,.w......##,,,..w.....#Vergossenes Blut: 3Genau auf den Zellen, die der Wolf verlassen hat, solange er blutete, liegt ein Tropfen. Nach dem dritten Zug läuft die Blutung ab, und die Spur endet. Das stille Quiet ist hier Absicht: Diese Rechnung braucht keine Meldungen.
Im Spiel ist die Bewegung dieselbe. Der Feind hinterlässt bei jedem Schritt Blut (moveEnemy) und der Held bei stepHero, beide mit derselben Bedingung. Hier sind der Schritt des Feindes und die Zelle, die das Blut annimmt:
function moveEnemy(game, level, enemy, x, y) { if (enemy.hasEffect('bleed')) level.addBlood(enemy.x, enemy.y); enemy.x = x; enemy.y = y;}// Blut bedeckt keine Leiche: Die Leiche bleibt sichtbar.addBlood(x, y) { if (this.decor[y][x] === ' ' && this.isWalkable(x, y)) this.decor[y][x] = ',';}Hier ist die Spur im Spiel: Die Tropfen , führen durch die Galerie zum Bau des Wolfs.
####.##### #########,### Obere Galerie #.# Ebene 1 von 3 ####,#### ......,...# Ringsum: .......,..# L Wolfsführer (schl .......,..# w Blutwolf (schläft ######@### ..... .....,... ........,L. w.......... ......... #####Iva, Barbar St.1 EP 19/20 Gold 12L [############] 48/48 M [########] 4/4Die Blutspur: Folge dem Blut bis zum Bau.Die Tür knarrt auf.Du siehst: Wolfsführer.Die Idee
Die schwere Wunde
Fällt das Leben unter ein Viertel, ist die Wunde schwer: Die Schläge des Wesens werden ungenauer. Die Regel steht an zwei kleinen Stellen: isHeavilyWounded und die Treffsicherheit.
// Unter 25 % Leben ist die Wunde schwer: Treffer werden ungenauer.isHeavilyWounded() { return this.hp * 4 < this.maxHp();}
accuracy() { return 75 + this.stat('agility') * 2 + this.rawStat('accuracy') - (this.isHeavilyWounded() ? 15 : 0);}Unter 25 % heißt hp × 4 < maxHp (ohne Brüche und ohne Division). Die Treffsicherheit ist 75 plus das Doppelte der Gewandtheit, und bei einer schweren Wunde noch 15 weniger. Das Programm vergiftet Iva, die mit 14 von 40 Leben beginnt. Im zweiten Zug drückt das Gift sie unter die Grenze:
const hero = new Creature("Iva", 40, stats(10, 4, 10));hero.hp = 14;const log = new Log();applyEffect(hero, makeEffect("poison", 3, 3), log);console.log(report(hero));for (let turn = 1; turn <= 3; turn++) { console.log("Zug " + turn); tickEffects(hero, log); console.log(report(hero));}hero.heal(10);console.log(report(hero));Gift sickert in Iva.Leben 14/40 [Gift:3] Treffsicherheit 95Zug 1Das Gift brennt in Iva (3).Leben 11/40 [Gift:2] Treffsicherheit 95Zug 2Das Gift brennt in Iva (3).Iva ist schwer verwundet!Leben 8/40 [Gift:1] Treffsicherheit 80Zug 3Das Gift brennt in Iva (3).Gift lässt bei Iva nach.Leben 5/40 Treffsicherheit 80Leben 15/40 Treffsicherheit 95In Zug 2 ist das Leben 8, und 8 × 4 = 32 liegt unter 40, deshalb sinkt die Treffsicherheit von 95 auf 80. Die Meldung „schwere Wunde“ kommt genau einmal. Das macht hurt: Es fragt vor dem Schaden (wasHeavy) und sagt nur dann etwas, wenn der Übergang jetzt geschieht.
// Schaden außerhalb eines Schlags: nie unter null, und er meldet, wenn die Wunde schwer wurde.function hurt(ctx, target, amount) { const wasHeavy = target.isHeavilyWounded(); target.hp = Math.max(0, target.hp - amount); if (target.isAlive() && !wasHeavy && target.isHeavilyWounded()) ctx.say(t("combat.wounded", target.name));}In Zug 3 sinkt das Leben weiter, aber die Meldung wiederholt sich nicht. Am Ende bringt heal(10) das Leben auf 15, und die Treffsicherheit ist wieder 95. Eine schwere Wunde ist kein Effekt in der Liste: Sie wird aus dem Leben berechnet und verschwindet, sobald das Leben steigt.
isHeavilyWounded() {
return this.hp * < this.maxHp;
}Unter 25 % ist dasselbe wie hp × 4 < maxHp. Wir multiplizieren, statt maxHp / 4 zu teilen, um bei ganzen Zahlen zu bleiben: So gibt es keine Rundung, und das Ergebnis ist in allen vier Sprachen gleich.
Genauer, für Neugierige
Die Stärke eines Treffers wird mit Wörtern gesagt, je nachdem, welchen Anteil des Lebens des Ziels er nimmt. Schwellen: unter 10 % „kratzt“, bis 25 % „trifft“, bis 45 % „zerschneidet“, darüber „zerschmettert“. Deshalb ist die Blutung leise (kleine Zahlen), während sich der erste Schlag des Wächters wie ein Schlag anfühlt.
// Das Wort für die Stärke eines Treffers hängt davon ab, wie viel vom Leben des Ziels er nimmt.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');}Genauer
Wer ruft tickEffects auf
Effekte haben keine eigene Uhr: Jemand muss tickEffects aufrufen. Im Kampf ist das der Beginn des Zuges jedes Wesens. Ein betäubtes Wesen überspringt seinen Zug. beginTurn merkt sich vor tickEffects, ob eine Betäubung da ist, weil das Ticken sie entfernt (sie dauert einen Zug).
// Der Beginn eines Zuges: Effekte wirken, Abklingzeiten sinken. Ein betäubter Kämpfer setzt aus.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;}Auf der Karte außerhalb des Kampfes ist der Zug die Welt (worldTurn): Der Held handelt, die Effekte des Helden ticken nach jedem Schritt, und danach ticken die Effekte jedes Feindes. Ein Feind außerhalb des Bildschirms wird mit Quiet getickt: Verblutet er, sieht es niemand, und es fällt keine Beute.
Ein Effektzug ist also dasselbe an zwei Orten. Der Effekt weiß nicht, von wo er aufgerufen wird, weil alles, was er braucht, in target und ctx steckt. Deshalb ist ein neuer Effekt eine Klasse und eine Zeile in makeEffect: Kampfsystem und Karte bleiben unberührt. Die nächste Lektion, Aktionen, handelt von den Effekten, die Fähigkeiten auf andere legen.
Übung
Kälte
Löse das in deinem Editor.
Kälte. Füge einen Effekt chill hinzu. Seine Regel ist stack mit höchstens 3 Schichten, und jede Schicht nimmt 2 von der Gewandtheit. Die Gewandtheit wird über modifier berechnet, sie muss also mit den Schichten multipliziert werden. Arbeite mit dem Code aus der Lektion: Effect, Creature, makeEffect, applyEffect und tickEffects.
Füge auch die Texte des Effekts zur Tabelle der Texte hinzu:
fx.on.chill: Kälte kriecht in {name}.fx.more.chill: {name} friert stärker (Schichten: {n}).fx.off.chill: Kälte lässt bei {name} nach.fxs.chill: KälteDas Programm erzeugt Iva (Leben 30, Stärke 10, Verteidigung 4, Gewandtheit 10) und druckt report (den Zustand und die Treffsicherheit, wie im Programm zur schweren Wunde). Zuerst druckt es den Zustand ohne Effekte. Dann legt es viermal makeEffect("chill", 2, 1) auf und druckt nach jedem Auflegen den Zustand. Am Ende macht es zwei Züge mit tickEffects und druckt nach jedem „Zug“ und den Zustand. Die Ausgabe ist:
Leben 30/30 Treffsicherheit 95Kälte kriecht in Iva.Leben 30/30 [Kälte:2] Treffsicherheit 91Iva friert stärker (Schichten: 2).Leben 30/30 [Kältex2:2] Treffsicherheit 87Iva friert stärker (Schichten: 3).Leben 30/30 [Kältex3:2] Treffsicherheit 83Iva friert stärker (Schichten: 3).Leben 30/30 [Kältex3:2] Treffsicherheit 83Zug 1Leben 30/30 [Kältex3:1] Treffsicherheit 83Zug 2Kälte lässt bei Iva nach.Leben 30/30 Treffsicherheit 95Die Treffsicherheit sinkt um 4 pro Schicht (2 Gewandtheit × 2). Das vierte Auflegen bleibt bei 3 Schichten, sagt aber „Schichten: 3“ und setzt die Züge wieder auf 2. Nach den zwei Zügen läuft die Kälte ab, und die Treffsicherheit ist wieder 95.
Weitere Übungen
- Feuerwand. Der Effekt
burnist schon im Code. Lass ihn sich bis zu 3-mal stapeln und zeige, wie sich der Schaden ändert. - Immunität. Füge dem Wesen
immune(eine Menge vonids) hinzu. Was sollapplyEffectzurückgeben, und was ist der Unterschied zwischen Immunität und der Regelignore? - Verband. Baue einen Trank, der die Blutung entfernt und
regenfür 3 Züge mit Stärke 2 auflegt. Wie viel Leben ist nach drei Zügen zurückgegeben, wenn das Leben nicht voll ist?
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Vererbung · Polymorphie · Arrays: push, splice und sort · Map und Set
- Aufgaben zum Thema: Die Chroniken von Arkanum