Gegenstände, Inventar, Beute
Kurz gesagt: Gegenstände sind eine Tabelle in Text, die in Objekte gelesen wird; die Ausrüstung addiert Modifikatoren zu den Werten, das Inventar hat Plätze und stapelt Tränke, die Beute wird nach Gewichten gewürfelt, und der Laden kauft und verkauft.
Problem
Ein neues Schwert, ohne den Code anzufassen
In der Mine findet der Held einen Speer. Der Händler verkauft ein Kettenhemd. Der Goblin lässt einen Trank fallen. Wenn jeder Gegenstand eine Zeile Code ist, new Item("spear", "Speer", ...), dann ist jedes neue Schwert eine neue Version des Programms, und ein Spieldesigner, der den Preis des Kettenhemds von 90 auf 80 ändern will, wartet auf eine Kompilierung. Das Spiel hat zweiundzwanzig Gegenstände, und es werden mehr.
Deshalb sind Daten vom Code getrennt. Die Gegenstände sind eine Tabelle in Text: Eine Zeile ist ein Gegenstand, die Zellen sind durch | getrennt, und Zeilen mit # am Anfang sind Notizen.
# id|name|kind|price|modifiersspear|Speer|weapon|20|accuracy:5war_axe|Streitaxt|weapon|80|accuracy:-5leather|Lederrüstung|armor|30|defense:2chain|Kettenhemd|armor|90|defense:4,evasion:-5ring_life|Ring des Lebens|ring|70|maxHp:10ring_agility|Ring des Geschicks|ring|70|agility:2potion_heal|Heiltrank|potion|15|-potion_mana|Manatrank|potion|15|-antidote|Gegengift|potion|10|-ore|Silbererz|misc|8|-Die Lektion handelt von fünf Dingen rund um diese Tabelle: wie der Text zu Objekten wird; wie sich die Ausrüstung (Waffe, Rüstung, Ring) zu den Werten addiert; wie das Inventar mit begrenzten Plätzen gleiche Tränke stapelt; wie die Beute aus einer Tabelle mit Gewichten gewürfelt wird; und wie der Laden für Gold kauft und verkauft. Die kleine Tabelle oben hat zehn Gegenstände; im Kanon sind es zweiundzwanzig, aber der Code ist derselbe.
Die Idee
Eine Tabelle in Text, in Objekte gelesen
Der Leser besteht aus drei kleinen Funktionen. parseRows teilt den Text in Zeilen, überspringt leere und Notizen und teilt die Zellen an |; stimmt die Zahl der Zellen nicht, hält es sofort an und sagt, welche Zeile kaputt ist. parseMods liest die Modifikatoren (defense:4,evasion:-5, und - heißt „keine“). loadItems macht aus jeder Zeile einen Gegenstand.
class Item { constructor(id, name, kind, price, mods) { this.id = id; this.name = name; this.kind = kind; this.price = price; this.mods = mods; this.stackable = kind === "potion" || kind === "misc"; }}
// Eine Zeile der Tabelle ist ein Gegenstand: id|Name|Art|Preis|Modifikatoren.function parseRows(text, columns) { const rows = []; for (const raw of text.split("\n")) { const line = raw.trim(); if (line.length === 0 || line[0] === "#") continue; const cells = line.split("|"); if (cells.length !== columns) { throw new Error("erwartet: " + columns + " Zellen in der Zeile: " + line); } rows.push(cells); } return rows;}
// „-“ heißt „keine Modifikatoren“; sonst Paare „Wert:Zahl“, durch Kommas getrennt.function parseMods(text) { const mods = []; if (text === "-") return mods; for (const part of text.split(",")) { const pieces = part.split(":"); mods.push({ stat: pieces[0], value: parseInt(pieces[1], 10) }); } return mods;}
function loadItems(text) { const items = new Map(); for (const c of parseRows(text, 5)) { items.set(c[0], new Item(c[0], c[1], c[2], parseInt(c[3], 10), parseMods(c[4]))); } return items;}const items = loadItems(ITEMS_TEXT);for (const item of items.values()) { console.log(item.name + " (" + item.kind + ") " + item.price + " Gold: " + modsText(item.mods));}console.log("gesamt: " + items.size);try { loadItems(BAD_ROW);} catch (e) { console.log(e.message);}Speer (weapon) 20 Gold: accuracy +5Streitaxt (weapon) 80 Gold: accuracy -5Lederrüstung (armor) 30 Gold: defense +2Kettenhemd (armor) 90 Gold: defense +4, evasion -5Ring des Lebens (ring) 70 Gold: maxHp +10Ring des Geschicks (ring) 70 Gold: agility +2Heiltrank (potion) 15 Gold: -Manatrank (potion) 15 Gold: -Gegengift (potion) 10 Gold: -Silbererz (misc) 8 Gold: -gesamt: 10erwartet: 5 Zellen in der Zeile: ring_life|Ring des Lebens|ring|70Das Programm gibt die Gegenstände in der Reihenfolge der Tabelle aus und übergibt am Ende eine Zeile mit fehlender Zelle (dem Preis): Der Leser hält sie mit der genauen Meldung an. Eine kaputte Tabelle muss das Spiel beim Start anhalten, nicht mitten im Kampf, wenn der Spieler zum ersten Mal auf den Gegenstand trifft.
Map behält die Einfügereihenfolge. Die Tabelle ist ein String in Backticks, und trim() entfernt den Zeilenumbruch am Anfang und am Ende.Hier ist der Leser im Kanon. Dort hat jede Tabelle ihre eigenen Spalten (Waffen sind 10 Zellen, Rüstungen 7), deshalb nimmt parseRows ihre Zahl und den Namen der Tabelle für die Meldung entgegen:
function parseRows(text, columns, tableName) { const rows = []; for (const rawLine of text.split('\n')) { const line = trimSpaces(rawLine); if (line.length === 0 || line.charAt(0) === '#') continue; const cells = line.split('|'); if (cells.length !== columns) { throw new Error(tableName + ': expected ' + columns + ' cells in: ' + line); } rows.push(cells); } return rows;}
function parseMods(text) { const mods = []; for (const part of splitList(text)) { const pieces = part.split(':'); mods.push({ stat: pieces[0], value: toInt(pieces[1]) }); } return mods;}Genauer, für Neugierige
Tabellen unterscheiden sich in den Spalten, deshalb hat der Kanon pro Art einen kurzen Leser (loadItems liest die fünf Tabellen nacheinander), aber eines ist gemeinsam: parseRows. Eine neue Art von Daten (Gegner, Fähigkeiten, Dialoge) ist eine neue Tabelle und ein neuer kurzer Leser, nicht eine neue Art zu lesen.
Und hier sind die Daten selbst aus dem Spiel: die Namen in vier Sprachen, dann der Preis und die Modifikatoren.
// Die Daten sind Tabellen in Text: eine Zeile ist ein Eintrag, die Zellen sind durch „|“ getrennt. Zeilen mit „#“// davor sind Notizen. Der Code liest sie beim Start in Objekte; für ein neues Schwert braucht es eine Zeile, keinen Code.// Modifikatoren sind „Wert:Zahl“, durch Kommas getrennt; „-“ heißt „nichts“.const WEAPONS_DATA = `# id|Name|Schadensart|min|max|Preis|Modifikatorenfists|Fäuste|blunt|1|2|0|-hand_axe|Handaxt|slash|4|7|20|-spear|Speer|pierce|3|6|20|accuracy:5staff|Stab|blunt|2|4|10|maxMana:4war_axe|Streitaxt|slash|6|10|80|accuracy:-5longbow|Langbogen|pierce|5|8|80|accuracy:10mace|Streitkolben|blunt|5|8|60|-rune_staff|Runenstab|blunt|3|6|75|maxMana:10silver_sword|Silberschwert|slash|7|11|120|accuracy:5`;
const ARMORS_DATA = `# id|Name|Preis|Modifikatorenrobe|Robe|5|maxMana:2leather|Lederrüstung|30|defense:2chain|Kettenhemd|90|defense:4,evasion:-5scale|Schuppenpanzer|110|defense:5`;
const RINGS_DATA = `# id|Name|Preis|Modifikatorenring_strength|Ring der Stärke|70|strength:2ring_agility|Ring des Geschicks|70|agility:2ring_life|Ring des Lebens|70|maxHp:10ring_mana|Ring des Manas|70|maxMana:8`;
const POTIONS_DATA = `# id|Name|Preis|Aktion|Stärkepotion_heal|Heiltrank|15|heal|20potion_mana|Manatrank|15|mana|15antidote|Gegengift|10|cure|0`;
const MISC_DATA = `# id|Name|Preisore|Silbererz|8gold|Gold|0`;
// Beute: gewichtete Zeilen für jede Tabelle. „none“ ist „nichts“; die Anzahl geht von min bis max.const LOOT_DATA = `# Tabelle|Gegenstand|Gewicht|min|maxvermin|none|60|0|0vermin|potion_heal|25|1|1vermin|antidote|15|1|1goblin|none|40|0|0goblin|potion_heal|30|1|1goblin|potion_mana|10|1|1goblin|spear|10|1|1goblin|leather|10|1|1beast|none|70|0|0beast|potion_heal|30|1|1leader|ring_agility|100|1|1bones|none|50|0|0bones|mace|20|1|1bones|potion_heal|30|1|1cult|potion_mana|40|1|1cult|potion_heal|40|1|1cult|rune_staff|20|1|1golem|ore|100|1|2warden|none|100|0|0`;
const SHOP_DATA = `# was der Laden verkauft, in dieser Reihenfolgepotion_healpotion_manaantidoteleatherchainwar_axelongbowmacerune_staffring_lifering_mana`;
// Die Erfahrung für jede Stufe: XP_TABLE[Stufe] ist die Gesamterfahrung, mit der man diese Stufe erreicht.const XP_DATA = '0|0|20|55|105|170|250|345|455|580|720';Die Idee
Ausrüstung: die Basis plus die Gegenstände
Der Held hat drei Plätze: weapon, armor, ring. Ein Platz enthält die id eines Gegenstands oder einen leeren String. Ein Wert wird nicht fertig gespeichert: Er wird jedes Mal als Basis (die Zahl des Helden) plus die Summe der Modifikatoren von allem, was angelegt ist, berechnet. Einen Ring abzunehmen braucht also kein „stell die alte Zahl wieder her“, es wird einfach neu gerechnet.
const SLOTS = ["weapon", "armor", "ring"];
class Hero { constructor(name, items) { this.name = name; this.items = items; this.baseStats = new Map(); this.equipment = new Map(); this.hp = 0; for (const slot of SLOTS) this.equipment.set(slot, ""); }
// Die Basis plus der Bonus von allem, was angelegt ist. gearBonus(stat) { let total = 0; for (const slot of SLOTS) { const itemId = this.equipment.get(slot); if (itemId.length === 0) continue; for (const mod of this.items.get(itemId).mods) { if (mod.stat === stat) total += mod.value; } } return total; }
stat(stat) { return (this.baseStats.get(stat) ?? 0) + this.gearBonus(stat); }
// Das Leben darf nicht über dem neuen Maximum bleiben. clampHp() { if (this.hp > this.stat("maxHp")) this.hp = this.stat("maxHp"); }
equip(itemId) { const item = this.items.get(itemId); const old = this.equipment.get(item.kind); this.equipment.set(item.kind, itemId); this.clampHp(); return old; }
unequip(slot) { this.equipment.set(slot, ""); this.clampHp(); }}equip legt den Gegenstand auf den Platz seiner Art (item.kind) und gibt den alten zurück, damit er in den Beutel zurückgehen kann. Es gibt einen unerwarteten Moment: Gab der Ring +10 maximales Leben und du nimmst ihn ab, kann das aktuelle Leben über dem neuen Maximum bleiben. clampHp holt es wieder herunter.
const items = loadItems(ITEMS_TEXT);const hero = new Hero("Iva", items);hero.baseStats.set("maxHp", 30);hero.baseStats.set("defense", 1);hero.baseStats.set("accuracy", 70);hero.hp = hero.stat("maxHp");console.log(hero.name + ": " + stats(hero));
for (const itemId of ["spear", "leather", "ring_life"]) { hero.equip(itemId); console.log("+ " + items.get(itemId).name + ": " + stats(hero));}hero.hp = hero.stat("maxHp");console.log("Heilung: " + stats(hero));
hero.equip("war_axe");console.log("+ " + items.get("war_axe").name + ": " + stats(hero));hero.unequip("ring");console.log("- " + items.get("ring_life").name + ": " + stats(hero));Iva: hp 30/30, defense 1, accuracy 70+ Speer: hp 30/30, defense 1, accuracy 75+ Lederrüstung: hp 30/30, defense 3, accuracy 75+ Ring des Lebens: hp 30/40, defense 3, accuracy 75Heilung: hp 40/40, defense 3, accuracy 75+ Streitaxt: hp 40/40, defense 3, accuracy 65- Ring des Lebens: hp 30/30, defense 3, accuracy 65Die Zeilen folgen dem Helden: Der Speer addiert 5 zum Treffer, die Lederrüstung 2 zur Abwehr, der Ring 10 zum maximalen Leben (aber das aktuelle Leben ist noch 30/40: Das Maximum wächst, das Leben nicht). Nach der Heilung nimmt die Streitaxt den Platz des Speers ein, und der Treffer sinkt von 75 auf 65 (−5 statt +5). Und das Abnehmen des Rings bringt das maximale Leben auf 30 zurück, und clampHp senkt auch das aktuelle Leben auf 30.
Die Idee
Inventar: Plätze und Stapel
Der Beutel hat eine begrenzte Zahl von Plätzen (im Spiel 10). Ein Gegenstand belegt einen Platz. Aber zehn Heiltränke sind nicht zehn Plätze: Gleiche Tränke und Erz werden gestapelt, auf einem Platz. In der Tabelle sind das die Arten potion und misc (stackable). Waffen und Rüstungen werden nicht gestapelt: Zwei Schwerter sind zwei Plätze.
class Inventory { constructor(capacity, items) { this.capacity = capacity; this.items = items; this.slots = []; }
count(itemId) { let total = 0; for (const slot of this.slots) { if (slot.id === itemId) total += slot.count; } return total; }
canAdd(itemId, count) { const item = this.items.get(itemId); if (item.stackable && this.count(itemId) > 0) return true; const needed = item.stackable ? 1 : count; return this.slots.length + needed <= this.capacity; }
add(itemId, count) { if (!this.canAdd(itemId, count)) return false; const item = this.items.get(itemId); if (item.stackable) { for (const slot of this.slots) { if (slot.id === itemId) { slot.count += count; return true; } } this.slots.push(new Slot(itemId, count)); return true; } for (let i = 0; i < count; i++) this.slots.push(new Slot(itemId, 1)); return true; }
remove(itemId, count) { let left = count; const kept = []; for (const slot of this.slots) { if (slot.id === itemId && left > 0) { const taken = Math.min(left, slot.count); slot.count -= taken; left -= taken; } if (slot.count > 0) kept.push(slot); } this.slots = kept; }
removeAt(index) { const slot = this.slots[index]; slot.count -= 1; if (slot.count <= 0) this.slots.splice(index, 1); }}Alles wird in canAdd entschieden: Wenn der Gegenstand stapelbar ist und es schon einen gibt, ist kein Platz nötig; sonst werden so viele neue Plätze gebraucht, wie es Gegenstände gibt. add fragt zuerst canAdd, und wenn es nicht geht, tut es nichts (es legt keinen halben Stapel ab). Das Ergebnis ist true oder false, so weiß der Aufrufer, ob er „kein Platz“ sagen muss.
function label(name, count) { return count > 1 ? name + " x" + count : name;}
function fillText(bag) { return "(" + bag.slots.length + "/" + bag.capacity + ")";}
function printBag(bag) { console.log("Der Beutel:"); for (let i = 0; i < bag.slots.length; i++) { const slot = bag.slots[i]; console.log((i + 1) + ") " + label(bag.items.get(slot.id).name, slot.count)); }}
function addAndShow(bag, itemId, count) { const added = bag.add(itemId, count); console.log("+ " + label(bag.items.get(itemId).name, count) + ": " + (added ? "erledigt" : "kein Platz") + " " + fillText(bag));}const items = loadItems(ITEMS_TEXT);const bag = new Inventory(4, items);addAndShow(bag, "potion_heal", 2);addAndShow(bag, "spear", 1);addAndShow(bag, "potion_heal", 1);addAndShow(bag, "potion_mana", 1);addAndShow(bag, "leather", 1);addAndShow(bag, "chain", 1);addAndShow(bag, "potion_heal", 1);addAndShow(bag, "ore", 1);printBag(bag);
bag.remove("potion_heal", 3);console.log("- " + label(items.get("potion_heal").name, 3) + ": " + fillText(bag));bag.remove("spear", 1);console.log("- " + items.get("spear").name + ": " + fillText(bag));addAndShow(bag, "chain", 1);printBag(bag);+ Heiltrank x2: erledigt (1/4)+ Speer: erledigt (2/4)+ Heiltrank: erledigt (2/4)+ Manatrank: erledigt (3/4)+ Lederrüstung: erledigt (4/4)+ Kettenhemd: kein Platz (4/4)+ Heiltrank: erledigt (4/4)+ Silbererz: kein Platz (4/4)Der Beutel:1) Heiltrank x42) Speer3) Manatrank4) Lederrüstung- Heiltrank x3: (4/4)- Speer: (3/4)+ Kettenhemd: erledigt (4/4)Der Beutel:1) Heiltrank2) Manatrank3) Lederrüstung4) KettenhemdDas Kettenhemd passt nicht in einen vollen Beutel (4 von 4), aber ein weiterer Heiltrank passt: Sein Stapel ist schon begonnen. Das Erz hat keinen Platz. Danach nimmt remove drei Tränke vom Stapel und den Speer weg, und das Kettenhemd passt auf den freien Platz. removeAt(i) entfernt einen Gegenstand von einem Platz (der Laden benutzt es zum Verkaufen) und gibt den Platz frei, wenn die Zahl auf null fällt.
Genauer, für Neugierige
Ein Stapel hält eine id und eine count, nicht den Gegenstand selbst. Die Definition eines Gegenstands (Name, Preis, Modifikatoren) gibt es einmal für die ganze Welt, in der Tabelle; der Beutel hält nur Verweise mit Anzahlen. Zwanzig Speere kopieren den Preis nicht zwanzigmal.
Die Idee
Beute mit Gewichten
Was lässt der Goblin fallen? Nicht immer dasselbe, und nicht mit gleicher Chance: oft nichts, manchmal einen Trank, selten einen Speer. Dafür hat jeder Gegner eine Beutetabelle: Zeilen mit einem Gewicht. Das Gewicht ist relativ: Eine Zeile mit Gewicht 30 in einer Tabelle mit Summe 100 fällt in 30 % der Fälle. Die Zeile none heißt „nichts“, und die Anzahl ist zufällig zwischen min und max.
# table|item|weight|min|maxgoblin|none|40|0|0goblin|potion_heal|30|1|1goblin|potion_mana|10|1|1goblin|spear|10|1|1goblin|leather|10|1|1golem|ore|100|1|2Der Wurf ist kurz. Die Gewichte werden addiert (zusammen 100), es wird eine Zahl von 0 bis 99 gewürfelt, dann werden die Zeilen der Reihe nach durchgegangen, und von der Zahl wird das Gewicht jeder Zeile abgezogen, bis die Zahl kleiner ist als das Gewicht der aktuellen Zeile. Diese Zeile fällt. So ist die Breite einer Zeile genau ihre Chance, und man braucht weder eine Sortierung nach Gewichten noch Prozente, die sich zu 100 addieren.
function loadLoot(text) { const tables = new Map(); for (const c of parseRows(text, 5)) { if (!tables.has(c[0])) tables.set(c[0], []); tables.get(c[0]).push({ itemId: c[1], weight: parseInt(c[2], 10), min: parseInt(c[3], 10), max: parseInt(c[4], 10) }); } return tables;}
function totalWeight(entries) { let total = 0; for (const entry of entries) total += entry.weight; return total;}
// Gewürfelt wird eine Zahl von 0 bis zum Gesamtgewicht minus 1; die Gewichte werden eins nach dem anderen abgezogen, bis die Zahl in eine Zeile fällt.function rollLoot(rng, entries) { let roll = rng.nextInt(totalWeight(entries)); for (const entry of entries) { if (roll < entry.weight) { if (entry.itemId === "none") return null; return { id: entry.itemId, count: rng.range(entry.min, entry.max) }; } roll -= entry.weight; } return null;}// Gewürfelt wird eine Zahl von 0 bis zum Gesamtgewicht minus 1; die Gewichte werden eins nach dem anderen abgezogen, bis die Zahl in eine Zeile fällt.
function rollLoot(rng, entries) {
let roll = rng.nextInt(totalWeight(entries));
for (const entry of entries) {
if (roll < entry.weight) {
if (entry.itemId === "none") return null;
return { id: entry.itemId, count: rng.range(entry.min, entry.max) };
}
roll entry.weight;
}
return null;
}Die Gewichte werden abgezogen: So wird die Zahl in jeder folgenden Zeile vom Anfang dieser Zeile an gemessen. Ohne das Abziehen würde jede Zeile mit der unverschobenen Zahl verglichen, und die frühen Zeilen fielen zu oft. Der Operator ist -=.
Das Programm besiegt sechs Gegner mit dem Startwert 6 und legt die Beute direkt in einen Beutel mit 3 Plätzen. Der Generator ist das Random des Kanons (derselbe mulberry32 wie im Kampf), deshalb ist die Ausgabe in allen vier Sprachen gleich:
const items = loadItems(ITEMS_TEXT);const tables = loadLoot(LOOT_TEXT);const rng = new Random(6);const bag = new Inventory(3, items);for (let i = 0; i < KILLS.length; i++) { const drop = rollLoot(rng, tables.get(KILLS[i])); const head = "Gegner " + (i + 1) + " (" + KILLS[i] + "): "; if (drop === null) { console.log(head + "nichts"); continue; } const added = bag.add(drop.id, drop.count); console.log(head + label(items.get(drop.id).name, drop.count) + " -> " + (added ? "erledigt" : "kein Platz") + " " + fillText(bag));}printBag(bag);Gegner 1 (goblin): nichtsGegner 2 (goblin): Heiltrank -> erledigt (1/3)Gegner 3 (goblin): Speer -> erledigt (2/3)Gegner 4 (golem): Silbererz x2 -> erledigt (3/3)Gegner 5 (goblin): Manatrank -> kein Platz (3/3)Gegner 6 (goblin): Heiltrank -> erledigt (3/3)Der Beutel:1) Heiltrank x22) Speer3) Silbererz x2Der erste Goblin lässt nichts fallen. Der zweite lässt einen Trank fallen, der dritte einen Speer, der Golem zwei Stück Erz (der Beutel ist schon voll, 3 von 3). Der fünfte Goblin lässt einen Manatrank fallen, aber für einen neuen Stapel ist kein Platz: Im Spiel bliebe er auf dem Boden liegen, hier kommt er einfach nicht hinein. Der sechste lässt einen Heiltrank fallen: Es gibt schon einen im Beutel, und er wird gestapelt, obwohl der Beutel voll ist.
Zufällig ist nur die Zahl; die Gewichte bestimmen, wie oft. Prüfen wir es: dieselbe Tabelle, aber 1000 besiegte Goblins, und es wird gezählt, wie oft jede Zeile fiel.
const entries = loadLoot(LOOT_TEXT).get("goblin");const rng = new Random(2026);const counts = new Map();for (let i = 0; i < 1000; i++) { const drop = rollLoot(rng, entries); const key = drop === null ? "none" : drop.id; counts.set(key, (counts.get(key) ?? 0) + 1);}const total = totalWeight(entries);for (const entry of entries) { console.log(entry.itemId + ": " + (counts.get(entry.itemId) ?? 0) + " (erwartet " + Math.floor((entry.weight * 1000) / total) + ")");}none: 396 (erwartet 400)potion_heal: 300 (erwartet 300)potion_mana: 117 (erwartet 100)spear: 91 (erwartet 100)leather: 96 (erwartet 100)Die erwarteten Anzahlen sind die Gewichte × 10, und die Ergebnisse liegen nahe daran, sind aber selten genau gleich. Das ist Wahrscheinlichkeit, keine Verteilung nach Fahrplan. Bei weniger besiegten Gegnern ist der Unterschied größer (sechs Gegner sind für nichts genug), deshalb prüft man ein gutes Gleichgewicht mit tausenden besiegten Gegnern: Die letzte Lektion macht genau das mit dem ganzen Spiel.
Sieh es dir an
Der Wurf in Aktion
Hier sind dieselben sechs besiegten Gegner Zug um Zug. Der Balken ist die Tabelle: Je breiter eine Zeile, desto öfter fällt sie. Der Zeiger geht zur gewürfelten Zahl, unten ist das Abziehen der Gewichte zu sehen, und der Gegenstand verlässt seine Zeile und kommt in den Beutel: auf einen neuen Platz, auf den alten Platz (er stapelt sich) oder gar nicht, wenn der Beutel voll ist.
// Gewürfelt wird eine Zahl von 0 bis zum Gesamtgewicht minus 1; die Gewichte werden eins nach dem anderen abgezogen, bis die Zahl in eine Zeile fällt.function rollLoot(rng, entries) { let roll = rng.nextInt(totalWeight(entries)); for (const entry of entries) { if (roll < entry.weight) { if (entry.itemId === "none") return null; return { id: entry.itemId, count: rng.range(entry.min, entry.max) }; } roll -= entry.weight; } return null;}
canAdd(itemId, count) { const item = this.items.get(itemId); if (item.stackable && this.count(itemId) > 0) return true; const needed = item.stackable ? 1 : count; return this.slots.length + needed <= this.capacity;}
add(itemId, count) { if (!this.canAdd(itemId, count)) return false; const item = this.items.get(itemId); if (item.stackable) { for (const slot of this.slots) { if (slot.id === itemId) { slot.count += count; return true; } } this.slots.push(new Slot(itemId, count)); return true; } for (let i = 0; i < count; i++) this.slots.push(new Slot(itemId, 1)); return true;}Schritt für Schritt, in Worten
- Die Tabelle des Goblins ist Text: 5 Zeilen, jede mit einem Gewicht, zusammen 100. Das Gewicht ist die Breite der Zeile im Balken: Gewicht 30 von 100 sind 30 % Chance. Der Beutel hat 3 Plätze.
- Gegner 1 (goblin): Es wird eine Zahl von 0 bis 99 gewürfelt, sie ergibt 1. Die Gewichte werden eins nach dem anderen abgezogen: 1 < 40. Die Zahl fällt in die Zeile „nichts“ (Gewicht 40 von 100).
- „nichts“ ist eine Zeile wie jede andere (Gewicht 40): keine Beute, und der Beutel bleibt unberührt.
- Gegner 2 (goblin): Es wird eine Zahl von 0 bis 99 gewürfelt, sie ergibt 56. Die Gewichte werden eins nach dem anderen abgezogen: 56 ≥ 40 → 16 → 16 < 30. Die Zahl fällt in die Zeile „Heiltrank“ (Gewicht 30 von 100).
- Heiltrank kommt in den Beutel auf einen neuen Platz (1/3).
- Gegner 3 (goblin): Es wird eine Zahl von 0 bis 99 gewürfelt, sie ergibt 89. Die Gewichte werden eins nach dem anderen abgezogen: 89 ≥ 40 → 49 → 49 ≥ 30 → 19 → 19 ≥ 10 → 9 → 9 < 10. Die Zahl fällt in die Zeile „Speer“ (Gewicht 10 von 100).
- Speer kommt in den Beutel auf einen neuen Platz (2/3).
- Gegner 4 (golem): Es wird eine Zahl von 0 bis 99 gewürfelt, sie ergibt 1. Die Gewichte werden eins nach dem anderen abgezogen: 1 < 100. Die Zahl fällt in die Zeile „Silbererz“ (Gewicht 100 von 100).
- Silbererz x2 kommt in den Beutel auf einen neuen Platz (3/3).
- Gegner 5 (goblin): Es wird eine Zahl von 0 bis 99 gewürfelt, sie ergibt 76. Die Gewichte werden eins nach dem anderen abgezogen: 76 ≥ 40 → 36 → 36 ≥ 30 → 6 → 6 < 10. Die Zahl fällt in die Zeile „Manatrank“ (Gewicht 10 von 100).
- Der Beutel ist voll (3/3), und Manatrank hat nichts, womit es sich stapeln ließe: canAdd gibt false zurück und add tut nichts (im Spiel bleibt der Gegenstand auf dem Boden liegen).
- Gegner 6 (goblin): Es wird eine Zahl von 0 bis 99 gewürfelt, sie ergibt 56. Die Gewichte werden eins nach dem anderen abgezogen: 56 ≥ 40 → 16 → 16 < 30. Die Zahl fällt in die Zeile „Heiltrank“ (Gewicht 30 von 100).
- Heiltrank ist schon im Beutel, deshalb wird es auf demselben Platz gestapelt (Heiltrank x2); die belegten Plätze bleiben bei 3/3.
- Nach 6 besiegten Gegnern ist der Beutel voll. Zufällig ist nur die Zahl; die Gewichte bestimmen, wie oft jede Zeile fällt, und der Beutel entscheidet, was hineinpasst.
Der Code neben der Szene ist rollLoot, canAdd und add aus dem Programm. Bei jedem Schritt leuchten die Zeilen auf, die in diesem Moment arbeiten.
Die Idee
Der Laden: kaufen und verkaufen
Der Laden ist das Kürzeste der fünf Dinge, weil alles, was er braucht, schon gebaut ist. Kaufen sind drei Prüfungen der Reihe nach: Reicht das Gold, ist im Beutel Platz, dann wird bezahlt und der Gegenstand hinzugefügt. Verkaufen entfernt einen Gegenstand von einem Platz und zahlt den halben Preis, abgerundet.
// Ein Verkauf bringt den halben Preis, abgerundet.function sellPrice(item) { return Math.floor(item.price / 2);}
function buy(player, items, itemId) { const item = items.get(itemId); let result = "erledigt"; if (player.gold < item.price) result = "zu wenig Gold"; else if (!player.bag.canAdd(itemId, 1)) result = "kein Platz"; else { player.gold -= item.price; player.bag.add(itemId, 1); } return "kauft " + item.name + " (" + item.price + "): " + result + ", Gold " + player.gold;}
function sell(player, items, slotNumber) { const slot = player.bag.slots[slotNumber - 1]; const item = items.get(slot.id); const price = sellPrice(item); player.bag.removeAt(slotNumber - 1); player.gold += price; return "verkauft " + item.name + " (" + price + "): erledigt, Gold " + player.gold;}const items = loadItems(ITEMS_TEXT);const player = new Player(100, new Inventory(3, items));console.log(buy(player, items, "potion_heal"));console.log(buy(player, items, "potion_heal"));console.log(buy(player, items, "chain"));console.log(buy(player, items, "leather"));console.log(buy(player, items, "war_axe"));console.log(sell(player, items, 2));console.log(buy(player, items, "potion_mana"));console.log(buy(player, items, "antidote"));console.log(buy(player, items, "leather"));console.log(sell(player, items, 1));kauft Heiltrank (15): erledigt, Gold 85kauft Heiltrank (15): erledigt, Gold 70kauft Kettenhemd (90): zu wenig Gold, Gold 70kauft Lederrüstung (30): erledigt, Gold 40kauft Streitaxt (80): zu wenig Gold, Gold 40verkauft Lederrüstung (15): erledigt, Gold 55kauft Manatrank (15): erledigt, Gold 40kauft Gegengift (10): erledigt, Gold 30kauft Lederrüstung (30): kein Platz, Gold 30verkauft Heiltrank (7): erledigt, Gold 37Die Reihenfolge der Prüfungen ist wichtig. Das Kettenhemd für 90 kann man nicht mit 70 Gold kaufen, die Streitaxt für 80 nicht mit 40. In der neunten Zeile hat der Held das Gold für eine Lederrüstung (30), aber der Beutel ist voll: Die Goldprüfung geht durch, canAdd lehnt ab, und es wird kein Gold genommen. Der Verkauf der Lederrüstung bringt 15 (30 / 2), der eines Heiltranks 7 (15 / 2, abgerundet): Dasselbe zu kaufen und sofort zu verkaufen ist nie ein Gewinn.
Hier ist der Verkaufspreis im Kanon. Das Kaufen sind dort dieselben Prüfungen, nur liest der Bildschirm die Nummer von der Tastatur:
function sellPrice(item) { return Math.floor(item.price / 2);}Die Idee
Im Spiel
Was wir gebaut haben, ist dasselbe wie im Kanon, nur kleiner. Hier ist das echte Inventar:
// Das Inventar hat eine begrenzte Zahl von Plätzen. Gleiche Tränke und Erz stapeln sich in einem Platz.class Inventory { constructor(capacity) { this.capacity = capacity; this.slots = []; }
count(itemId) { let total = 0; for (const slot of this.slots) { if (slot.id === itemId) total += slot.count; } return total; }
canAdd(itemId, count) { const item = ITEMS.get(itemId); if (item.stackable && this.count(itemId) > 0) return true; const needed = item.stackable ? 1 : count; return this.slots.length + needed <= this.capacity; }
add(itemId, count) { if (!this.canAdd(itemId, count)) return false; const item = ITEMS.get(itemId); if (item.stackable) { for (const slot of this.slots) { if (slot.id === itemId) { slot.count += count; return true; } } this.slots.push({ id: itemId, count: count }); return true; } for (let i = 0; i < count; i++) this.slots.push({ id: itemId, count: 1 }); return true; }
remove(itemId, count) { let left = count; const kept = []; for (const slot of this.slots) { if (slot.id === itemId && left > 0) { const taken = Math.min(left, slot.count); slot.count -= taken; left -= taken; } if (slot.count > 0) kept.push(slot); } this.slots = kept; }
removeAt(index) { const slot = this.slots[index]; slot.count -= 1; if (slot.count <= 0) this.slots.splice(index, 1); }}Die Ausrüstung im Kanon ist Teil von Creature: gearBonus addiert die Modifikatoren über die Plätze und sitzt zwischen der Basis und den Effekten (siehe die Lektion über Helden und Gegner):
gearBonus(stat) { let total = 0; for (const slot of this.equipment.keys()) { const itemId = this.equipment.get(slot); if (itemId === '') continue; for (const mod of ITEMS.get(itemId).mods) { if (mod.stat === stat) total += mod.value; } } return total;}
weapon() { const id = this.equipment.get('weapon'); return ITEMS.get(id === '' ? 'fists' : id);}Das Anlegen eines Gegenstands aus dem Beutel setzt den alten an dieselbe Stelle, damit die Reihenfolge der Gegenstände nicht durcheinandergerät:
// Legt den Gegenstand aus dem Inventar an; der alte kommt an seinen Platz im Inventar.function equipFromInventory(hero, index) { const slot = hero.inventory.slots[index]; const item = ITEMS.get(slot.id); if (item.kind !== 'weapon' && item.kind !== 'armor' && item.kind !== 'ring') return ''; const old = hero.equipment.get(item.kind); hero.inventory.removeAt(index); hero.equipment.set(item.kind, item.id); if (old !== '') hero.inventory.slots.splice(index, 0, { id: old, count: 1 }); if (hero.hp > hero.maxHp()) hero.hp = hero.maxHp(); if (hero.mana > hero.maxMana()) hero.mana = hero.maxMana(); return item.id;}Und der Beutewurf, den du kennst:
// Gewichtete Beute: Gewicht 30 von insgesamt 100 bedeutet 30 % Chance.function rollLoot(rng, tableId) { const entries = LOOT_TABLES.get(tableId); let total = 0; for (const entry of entries) total += entry.weight; let roll = rng.nextInt(total); for (const entry of entries) { if (roll < entry.weight) { if (entry.itemId === 'none') return null; return { id: entry.itemId, count: rng.range(entry.min, entry.max) }; } roll -= entry.weight; } return null;}Die Unterschiede zu unserem Programm:
- Eine Tabelle pro Art. Waffen, Rüstungen, Ringe, Tränke und der Rest haben jeweils eigene Spalten; unsere Tabelle ist eine einzige, damit sie sich leicht liest.
- Namen in vier Sprachen. Jede Zeile im Kanon trägt den Namen in
bg,en,deundes;nm(item)wählt nach der Sprache des Spiels. Hier ist der Name eine Zelle. - Ein globales
ITEMS. Der Kanon hält die Gegenstände in einem globalen Dictionary und sucht sie nachid. Wir geben sie anInventoryundHeroweiter, damit die Abhängigkeiten sichtbar sind und das Testen leicht ist. - Ein Beutel mit 10 Plätzen. Der Held im Kanon hat
Inventory(10); hier sind es 3 und 4 Plätze, damit man sieht, wann sie sich füllen. - Gold und Tränke. Gold ist kein Gegenstand im Beutel, sondern eine Zahl beim Helden (Gegner lassen Gold getrennt von der Beute fallen). Tränke haben eine Aktion (
heal,mana,cure), unddrinkPotiontrinkt sie im Kampf und auf der Karte; hier stapeln wir sie nur. - Beute fällt auf den Boden. Ein besiegter Gegner lässt den Gegenstand auf dem Boden liegen (
dropItem), und der Held hebt ihn mitgauf; bei vollem Beutel bleibt der Gegenstand liegen, und der Spieler sieht „Der Beutel ist voll“. In unserem Programm geht die Beute direkt in den Beutel, und ein voller Beutel gibtfalsezurück.
Probier es aus
Sag die Ausgabe voraus
Drei Zeilen mit den Gewichten 50, 30 und 20 und sechs gewürfelte Zahlen. Was gibt das Programm aus?
function pick(ids, weights, roll) { for (let i = 0; i < ids.length; i++) { if (roll < weights[i]) return ids[i]; roll -= weights[i]; } return ids[ids.length - 1];}
const ids = ["none", "potion_heal", "spear"];const weights = [50, 30, 20];const rolls = [0, 49, 50, 79, 80, 99];for (const roll of rolls) { console.log(roll + ": " + pick(ids, weights, roll));}Die Zahl wird mit dem Gewicht der Zeile verglichen, und ist sie nicht kleiner, wird das Gewicht abgezogen. 49 ist kleiner als 50: Die erste Zeile fällt; 50 ist nicht kleiner als 50, es bleibt 0, und die zweite Zeile fällt; 79 geht an 50 vorbei (es bleiben 29), und 29 ist kleiner als 30: die zweite; 80 geht an 50 und 30 vorbei (es bleibt 0), und die dritte fällt. Die zweite Antwort vergleicht mit <= und liegt an den Grenzen falsch; die dritte teilt die Zahlen in drei gleiche Teile und vergisst die Gewichte.
Probier es aus
Finde den Fehler
Ein Kollege hat die Prüfung in canAdd umgeschrieben, und jetzt fasst ein Beutel mit 2 Plätzen drei Gegenstände, und ein Trank passt nicht in einen vollen Beutel:
const items = loadItems(ITEMS_TEXT);const bag = new Inventory(2, items);addAndShow(bag, "spear", 1);addAndShow(bag, "potion_heal", 1);addAndShow(bag, "potion_mana", 1);addAndShow(bag, "potion_heal", 1);+ Speer: erledigt (1/2)+ Heiltrank: erledigt (2/2)+ Manatrank: erledigt (3/2)+ Heiltrank: kein Platz (3/2)Welche Zeile ist kaputt?
Wähle die Zeile mit dem Fehler.
Die Prüfung ist umgekehrt: count(itemId) == 0 lässt jeden neuen stapelbaren Gegenstand durch, ohne nach Platz zu sehen (daher „3/2“), während ein schon begonnener Stapel bei der allgemeinen Platzprüfung landet und abgelehnt wird. Richtig: > 0, also „so ein Gegenstand ist schon im Beutel, ein Platz ist nicht nötig“.
Genauer
Genauer: Wahrscheinlichkeiten, Anteile und Schwachstellen
Gewichte, keine Prozente. Gewichte müssen sich nicht zu 100 addieren. Eine Tabelle 3, 1, 1 ist dasselbe wie 60, 20, 20. Die Summe ist der Teiler und wird bei jedem Wurf neu berechnet, deshalb kann eine neue Zeile hinzukommen, ohne den Rest anzufassen. Prozente müssten sich nach jeder Änderung zu 100 addieren, Gewichte nicht.
Abziehen oder addieren. Man kann eine Zahl würfeln und mit Summen (40, 70, 80, 90, 100) vergleichen: Zeile i fällt, wenn die Zahl unter der i-ten Summe liegt. Abziehen ist dasselbe ohne ein zweites Array. Bei großen Tabellen werden die Summen fertig gehalten und binär gesucht; für die Tabellen eines Spiels ist das unnötig.
„Nichts“ ist eine Zeile. none mit Gewicht 40 ist eine Zeile wie jede andere: Mit einer Zahl liefert eine Tabelle 0 % oder 90 % „nichts“. Und die Tabelle des Anführers (leader) im Kanon hat nur eine Zeile mit Gewicht 100: Der Ring fällt immer.
Alles oder nichts. Jede Änderung am Beutel läuft über add, das zuerst canAdd fragt. So können „ein halber Stapel“ oder „drei Gegenstände in zwei Plätzen“ nicht entstehen. Die Übung unten schützt genau das.
Runden. Preis / 2 mit ganzzahliger Division schneidet den Rest ab, deshalb zahlt ein Verkauf nie mehr als die Hälfte. In Python und JavaScript ist die ganzzahlige Division // und Math.floor; sie arbeiten richtig, weil Preise nicht negativ sind.
Zufall und Wiederholbarkeit. Der Wurf weiß nicht, was fallen wird; er weiß nur, wie er auf eine Zahl antwortet. Der Generator hat einen Startwert (mulberry32), deshalb ist die Beute mit demselben Startwert und denselben Besiegten in allen vier Sprachen und in einem aufgezeichneten Durchlauf gleich. Das brauchen wir auch für das Speichern und die Simulation.
Übung
Stapel mit einer Obergrenze
Löse die Aufgabe in deinem Editor. Geh vom Inventarprogramm aus.
Im Spiel sind zehn Tränke auf einem Platz zu viel. Füge eine Obergrenze für einen Stapel hinzu: höchstens 5 von einem Gegenstand pro Platz. Was sich ändert:
- ein Stapel, der 5 erreicht, ist voll; mehr vom selben kommt auf einen neuen Platz;
addmuss alles oder nichts sein: Passen nicht alle hinein (in alte Stapel und in neue Plätze), wird nichts abgelegt;- nicht stapelbare Gegenstände belegen wie bisher einen Platz pro Stück.
Gebraucht werden room (wie viel noch in alte Stapel passt), ein korrigiertes canAdd und ein add, das zuerst die alten Stapel auffüllt und dann neue macht. Der Beutel hat 3 Plätze, und das Szenario ist dieses:
function addAndShow(bag, itemId, count) { const added = bag.add(itemId, count); const parts = []; for (const slot of bag.slots) parts.push(label(bag.items.get(slot.id).name, slot.count)); console.log("+ " + label(bag.items.get(itemId).name, count) + ": " + (added ? "erledigt" : "kein Platz") + " " + fillText(bag) + " [" + parts.join(", ") + "]");}
const items = loadItems(ITEMS_TEXT);const bag = new Inventory(3, items);addAndShow(bag, "potion_heal", 4);addAndShow(bag, "potion_heal", 3);addAndShow(bag, "potion_mana", 2);addAndShow(bag, "potion_heal", 3);addAndShow(bag, "potion_heal", 1);addAndShow(bag, "potion_mana", 4);Die Ausgabe ist:
+ Heiltrank x4: erledigt (1/3) [Heiltrank x4]+ Heiltrank x3: erledigt (2/3) [Heiltrank x5, Heiltrank x2]+ Manatrank x2: erledigt (3/3) [Heiltrank x5, Heiltrank x2, Manatrank x2]+ Heiltrank x3: erledigt (3/3) [Heiltrank x5, Heiltrank x5, Manatrank x2]+ Heiltrank: kein Platz (3/3) [Heiltrank x5, Heiltrank x5, Manatrank x2]+ Manatrank x4: kein Platz (3/3) [Heiltrank x5, Heiltrank x5, Manatrank x2]Die Zeilen zeigen auch die Grenzen: Der zweite Schritt füllt den alten Stapel auf 5 auf und öffnet einen neuen Platz für die übrigen 2; der vierte füllt den schon begonnenen Stapel auf und braucht keinen neuen Platz; der letzte (noch 4 Manatränke bei einem Stapel von 2) legt nichts ab, obwohl 3 davon passen würden, weil der vierte nirgends hin kann.
Weitere Aufgaben
- Verkaufe den Plunder. Füge
sellJunk(player, items)hinzu: Es verkauft alles der Artmiscund gibt zurück, wie viel Gold eingenommen wurde. - Die beste Waffe. Wenn der Held eine Waffe aufhebt, soll er sie nur anlegen, wenn die Summe ihrer Modifikatoren größer ist als die der gerade angelegten.
- Glück. Gib
rollLooteinen Parameterluck: Je größer er ist, desto mehr wird das Gewicht der Zeilenoneverringert (aber nicht unter 0), und die anderen bleiben. Was passiert mit den Chancen der anderen?
Test
Prüfe dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Werfen und eigene Exceptions · Arrays: push, splice und sort · Map und Set · Daten, switch und Destrukturierung
- Aufgaben zum Thema: Die Chroniken von Arkanum