Zum Inhalt
wannacode

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|modifiers
spear|Speer|weapon|20|accuracy:5
war_axe|Streitaxt|weapon|80|accuracy:-5
leather|Lederrüstung|armor|30|defense:2
chain|Kettenhemd|armor|90|defense:4,evasion:-5
ring_life|Ring des Lebens|ring|70|maxHp:10
ring_agility|Ring des Geschicks|ring|70|agility:2
potion_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.

@dataclass(frozen=True)
class Mod:
stat: str
value: int
class Item:
def __init__(self, item_id, name, kind, price, mods):
self.id = item_id
self.name = name
self.kind = kind
self.price = price
self.mods = mods
self.stackable = kind == "potion" or kind == "misc"
# Eine Zeile der Tabelle ist ein Gegenstand: id|Name|Art|Preis|Modifikatoren.
def parse_rows(text, columns):
rows = []
for raw in text.split("\n"):
line = raw.strip()
if len(line) == 0 or line[0] == "#":
continue
cells = line.split("|")
if len(cells) != columns:
raise ValueError("erwartet: " + str(columns) + " Zellen in der Zeile: " + line)
rows.append(cells)
return rows
# „-“ heißt „keine Modifikatoren“; sonst Paare „Wert:Zahl“, durch Kommas getrennt.
def parse_mods(text):
mods = []
if text == "-":
return mods
for part in text.split(","):
pieces = part.split(":")
mods.append(Mod(pieces[0], int(pieces[1])))
return mods
def load_items(text):
items = {}
for c in parse_rows(text, 5):
items[c[0]] = Item(c[0], c[1], c[2], int(c[3]), parse_mods(c[4]))
return items
items = load_items(ITEMS_TEXT)
for item in items.values():
print(item.name + " (" + item.kind + ") " + str(item.price) + " Gold: " + mods_text(item.mods))
print("gesamt: " + str(len(items)))
try:
load_items(BAD_ROW)
except ValueError as e:
print(e)
Ausgabe
Speer (weapon) 20 Gold: accuracy +5
Streitaxt (weapon) 80 Gold: accuracy -5
Lederrüstung (armor) 30 Gold: defense +2
Kettenhemd (armor) 90 Gold: defense +4, evasion -5
Ring des Lebens (ring) 70 Gold: maxHp +10
Ring des Geschicks (ring) 70 Gold: agility +2
Heiltrank (potion) 15 Gold: -
Manatrank (potion) 15 Gold: -
Gegengift (potion) 10 Gold: -
Silbererz (misc) 8 Gold: -
gesamt: 10
erwartet: 5 Zellen in der Zeile: ring_life|Ring des Lebens|ring|70

Das 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.

dict behält die Einfügereihenfolge. Die Tabelle ist ein String mit dreifachen Anführungszeichen, und strip() 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:

def parse_rows(text, columns, table_name):
rows = []
for raw_line in text.split('\n'):
line = trim_spaces(raw_line)
if len(line) == 0 or line[0] == '#':
continue
cells = line.split('|')
if len(cells) != columns:
raise RuntimeError(table_name + ': expected ' + str(columns) + ' cells in: ' + line)
rows.append(cells)
return rows
def parse_mods(text):
mods = []
for part in split_list(text):
pieces = part.split(':')
mods.append(SimpleNamespace(stat=pieces[0], value=to_int(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“.
WEAPONS_DATA = """
# id|Name|Schadensart|min|max|Preis|Modifikatoren
fists|Fäuste|blunt|1|2|0|-
hand_axe|Handaxt|slash|4|7|20|-
spear|Speer|pierce|3|6|20|accuracy:5
staff|Stab|blunt|2|4|10|maxMana:4
war_axe|Streitaxt|slash|6|10|80|accuracy:-5
longbow|Langbogen|pierce|5|8|80|accuracy:10
mace|Streitkolben|blunt|5|8|60|-
rune_staff|Runenstab|blunt|3|6|75|maxMana:10
silver_sword|Silberschwert|slash|7|11|120|accuracy:5
"""
ARMORS_DATA = """
# id|Name|Preis|Modifikatoren
robe|Robe|5|maxMana:2
leather|Lederrüstung|30|defense:2
chain|Kettenhemd|90|defense:4,evasion:-5
scale|Schuppenpanzer|110|defense:5
"""
RINGS_DATA = """
# id|Name|Preis|Modifikatoren
ring_strength|Ring der Stärke|70|strength:2
ring_agility|Ring des Geschicks|70|agility:2
ring_life|Ring des Lebens|70|maxHp:10
ring_mana|Ring des Manas|70|maxMana:8
"""
POTIONS_DATA = """
# id|Name|Preis|Aktion|Stärke
potion_heal|Heiltrank|15|heal|20
potion_mana|Manatrank|15|mana|15
antidote|Gegengift|10|cure|0
"""
MISC_DATA = """
# id|Name|Preis
ore|Silbererz|8
gold|Gold|0
"""
# Beute: gewichtete Zeilen für jede Tabelle. „none“ ist „nichts“; die Anzahl geht von min bis max.
LOOT_DATA = """
# Tabelle|Gegenstand|Gewicht|min|max
vermin|none|60|0|0
vermin|potion_heal|25|1|1
vermin|antidote|15|1|1
goblin|none|40|0|0
goblin|potion_heal|30|1|1
goblin|potion_mana|10|1|1
goblin|spear|10|1|1
goblin|leather|10|1|1
beast|none|70|0|0
beast|potion_heal|30|1|1
leader|ring_agility|100|1|1
bones|none|50|0|0
bones|mace|20|1|1
bones|potion_heal|30|1|1
cult|potion_mana|40|1|1
cult|potion_heal|40|1|1
cult|rune_staff|20|1|1
golem|ore|100|1|2
warden|none|100|0|0
"""
SHOP_DATA = """
# was der Laden verkauft, in dieser Reihenfolge
potion_heal
potion_mana
antidote
leather
chain
war_axe
longbow
mace
rune_staff
ring_life
ring_mana
"""
# Die Erfahrung für jede Stufe: XP_TABLE[Stufe] ist die Gesamterfahrung, mit der man diese Stufe erreicht.
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.

SLOTS = ["weapon", "armor", "ring"]
class Hero:
def __init__(self, name, items):
self.name = name
self.items = items
self.base_stats = {}
self.equipment = {}
self.hp = 0
for slot in SLOTS:
self.equipment[slot] = ""
# Die Basis plus der Bonus von allem, was angelegt ist.
def gear_bonus(self, stat):
total = 0
for slot in SLOTS:
item_id = self.equipment[slot]
if len(item_id) == 0:
continue
for mod in self.items[item_id].mods:
if mod.stat == stat:
total += mod.value
return total
def stat(self, stat):
return self.base_stats.get(stat, 0) + self.gear_bonus(stat)
# Das Leben darf nicht über dem neuen Maximum bleiben.
def clamp_hp(self):
if self.hp > self.stat("maxHp"):
self.hp = self.stat("maxHp")
def equip(self, item_id):
item = self.items[item_id]
old = self.equipment[item.kind]
self.equipment[item.kind] = item_id
self.clamp_hp()
return old
def unequip(self, slot):
self.equipment[slot] = ""
self.clamp_hp()

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.

items = load_items(ITEMS_TEXT)
hero = Hero("Iva", items)
hero.base_stats["maxHp"] = 30
hero.base_stats["defense"] = 1
hero.base_stats["accuracy"] = 70
hero.hp = hero.stat("maxHp")
print(hero.name + ": " + stats(hero))
for item_id in ["spear", "leather", "ring_life"]:
hero.equip(item_id)
print("+ " + items[item_id].name + ": " + stats(hero))
hero.hp = hero.stat("maxHp")
print("Heilung: " + stats(hero))
hero.equip("war_axe")
print("+ " + items["war_axe"].name + ": " + stats(hero))
hero.unequip("ring")
print("- " + items["ring_life"].name + ": " + stats(hero))
Ausgabe
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 75
Heilung: hp 40/40, defense 3, accuracy 75
+ Streitaxt: hp 40/40, defense 3, accuracy 65
- Ring des Lebens: hp 30/30, defense 3, accuracy 65

Die 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:
def __init__(self, capacity, items):
self.capacity = capacity
self.items = items
self.slots = []
def count(self, item_id):
total = 0
for slot in self.slots:
if slot.id == item_id:
total += slot.count
return total
def can_add(self, item_id, count):
item = self.items[item_id]
if item.stackable and self.count(item_id) > 0:
return True
needed = 1 if item.stackable else count
return len(self.slots) + needed <= self.capacity
def add(self, item_id, count):
if not self.can_add(item_id, count):
return False
item = self.items[item_id]
if item.stackable:
for slot in self.slots:
if slot.id == item_id:
slot.count += count
return True
self.slots.append(Slot(item_id, count))
return True
for _ in range(count):
self.slots.append(Slot(item_id, 1))
return True
def remove(self, item_id, count):
left = count
kept = []
for slot in self.slots:
if slot.id == item_id and left > 0:
taken = min(left, slot.count)
slot.count -= taken
left -= taken
if slot.count > 0:
kept.append(slot)
self.slots = kept
def remove_at(self, index):
slot = self.slots[index]
slot.count -= 1
if slot.count <= 0:
del self.slots[index]

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.

def label(name, count):
return name + " x" + str(count) if count > 1 else name
def fill_text(bag):
return "(" + str(len(bag.slots)) + "/" + str(bag.capacity) + ")"
def print_bag(bag):
print("Der Beutel:")
for i in range(len(bag.slots)):
slot = bag.slots[i]
print(str(i + 1) + ") " + label(bag.items[slot.id].name, slot.count))
def add_and_show(bag, item_id, count):
added = bag.add(item_id, count)
print("+ " + label(bag.items[item_id].name, count) + ": " + ("erledigt" if added else "kein Platz") + " " + fill_text(bag))
items = load_items(ITEMS_TEXT)
bag = Inventory(4, items)
add_and_show(bag, "potion_heal", 2)
add_and_show(bag, "spear", 1)
add_and_show(bag, "potion_heal", 1)
add_and_show(bag, "potion_mana", 1)
add_and_show(bag, "leather", 1)
add_and_show(bag, "chain", 1)
add_and_show(bag, "potion_heal", 1)
add_and_show(bag, "ore", 1)
print_bag(bag)
bag.remove("potion_heal", 3)
print("- " + label(items["potion_heal"].name, 3) + ": " + fill_text(bag))
bag.remove("spear", 1)
print("- " + items["spear"].name + ": " + fill_text(bag))
add_and_show(bag, "chain", 1)
print_bag(bag)
Ausgabe
+ 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 x4
2) Speer
3) Manatrank
4) Lederrüstung
- Heiltrank x3: (4/4)
- Speer: (3/4)
+ Kettenhemd: erledigt (4/4)
Der Beutel:
1) Heiltrank
2) Manatrank
3) Lederrüstung
4) Kettenhemd

Das 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|max
goblin|none|40|0|0
goblin|potion_heal|30|1|1
goblin|potion_mana|10|1|1
goblin|spear|10|1|1
goblin|leather|10|1|1
golem|ore|100|1|2

Der 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.

@dataclass(frozen=True)
class Entry:
item_id: str
weight: int
min: int
max: int
@dataclass(frozen=True)
class Drop:
id: str
count: int
def load_loot(text):
tables = {}
for c in parse_rows(text, 5):
if c[0] not in tables:
tables[c[0]] = []
tables[c[0]].append(Entry(c[1], int(c[2]), int(c[3]), int(c[4])))
return tables
def total_weight(entries):
total = 0
for entry in 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.
def roll_loot(rng, entries):
roll = rng.next_int(total_weight(entries))
for entry in entries:
if roll < entry.weight:
if entry.item_id == "none":
return None
return Drop(entry.item_id, rng.range(entry.min, entry.max))
roll -= entry.weight
return None
# 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.
def roll_loot(rng, entries):
    roll = rng.next_int(total_weight(entries))
    for entry in entries:
        if roll < entry.weight:
            if entry.item_id == "none":
                return None
            return Drop(entry.item_id, rng.range(entry.min, entry.max))
        roll  entry.weight
    return None

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:

items = load_items(ITEMS_TEXT)
tables = load_loot(LOOT_TEXT)
rng = Random(6)
bag = Inventory(3, items)
for i in range(len(KILLS)):
drop = roll_loot(rng, tables[KILLS[i]])
head = "Gegner " + str(i + 1) + " (" + KILLS[i] + "): "
if drop is None:
print(head + "nichts")
continue
added = bag.add(drop.id, drop.count)
print(head + label(items[drop.id].name, drop.count) + " -> " + ("erledigt" if added else "kein Platz") + " " + fill_text(bag))
print_bag(bag)
Ausgabe
Gegner 1 (goblin): nichts
Gegner 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 x2
2) Speer
3) Silbererz x2

Der 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.

entries = load_loot(LOOT_TEXT)["goblin"]
rng = Random(2026)
counts = {}
for i in range(1000):
drop = roll_loot(rng, entries)
key = "none" if drop is None else drop.id
counts[key] = counts.get(key, 0) + 1
total = total_weight(entries)
for entry in entries:
print(entry.item_id + ": " + str(counts.get(entry.item_id, 0)) + " (erwartet " + str(entry.weight * 1000 // total) + ")")
Ausgabe
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.
def roll_loot(rng, entries):
roll = rng.next_int(total_weight(entries))
for entry in entries:
if roll < entry.weight:
if entry.item_id == "none":
return None
return Drop(entry.item_id, rng.range(entry.min, entry.max))
roll -= entry.weight
return None
def can_add(self, item_id, count):
item = self.items[item_id]
if item.stackable and self.count(item_id) > 0:
return True
needed = 1 if item.stackable else count
return len(self.slots) + needed <= self.capacity
def add(self, item_id, count):
if not self.can_add(item_id, count):
return False
item = self.items[item_id]
if item.stackable:
for slot in self.slots:
if slot.id == item_id:
slot.count += count
return True
self.slots.append(Slot(item_id, count))
return True
for _ in range(count):
self.slots.append(Slot(item_id, 1))
return True
Schritt für Schritt, in Worten
  1. 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.
  2. 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).
  3. „nichts“ ist eine Zeile wie jede andere (Gewicht 40): keine Beute, und der Beutel bleibt unberührt.
  4. 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).
  5. Heiltrank kommt in den Beutel auf einen neuen Platz (1/3).
  6. 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).
  7. Speer kommt in den Beutel auf einen neuen Platz (2/3).
  8. 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).
  9. Silbererz x2 kommt in den Beutel auf einen neuen Platz (3/3).
  10. 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).
  11. 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).
  12. 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).
  13. Heiltrank ist schon im Beutel, deshalb wird es auf demselben Platz gestapelt (Heiltrank x2); die belegten Plätze bleiben bei 3/3.
  14. 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.
def sell_price(item):
return item.price // 2
def buy(player, items, item_id):
item = items[item_id]
result = "erledigt"
if player.gold < item.price:
result = "zu wenig Gold"
elif not player.bag.can_add(item_id, 1):
result = "kein Platz"
else:
player.gold -= item.price
player.bag.add(item_id, 1)
return "kauft " + item.name + " (" + str(item.price) + "): " + result + ", Gold " + str(player.gold)
def sell(player, items, slot_number):
slot = player.bag.slots[slot_number - 1]
item = items[slot.id]
price = sell_price(item)
player.bag.remove_at(slot_number - 1)
player.gold += price
return "verkauft " + item.name + " (" + str(price) + "): erledigt, Gold " + str(player.gold)
items = load_items(ITEMS_TEXT)
player = Player(100, Inventory(3, items))
print(buy(player, items, "potion_heal"))
print(buy(player, items, "potion_heal"))
print(buy(player, items, "chain"))
print(buy(player, items, "leather"))
print(buy(player, items, "war_axe"))
print(sell(player, items, 2))
print(buy(player, items, "potion_mana"))
print(buy(player, items, "antidote"))
print(buy(player, items, "leather"))
print(sell(player, items, 1))
Ausgabe
kauft Heiltrank (15): erledigt, Gold 85
kauft Heiltrank (15): erledigt, Gold 70
kauft Kettenhemd (90): zu wenig Gold, Gold 70
kauft Lederrüstung (30): erledigt, Gold 40
kauft Streitaxt (80): zu wenig Gold, Gold 40
verkauft Lederrüstung (15): erledigt, Gold 55
kauft Manatrank (15): erledigt, Gold 40
kauft Gegengift (10): erledigt, Gold 30
kauft Lederrüstung (30): kein Platz, Gold 30
verkauft Heiltrank (7): erledigt, Gold 37

Die 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:

def sell_price(item):
return 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:
def __init__(self, capacity):
self.capacity = capacity
self.slots = []
def count(self, item_id):
total = 0
for slot in self.slots:
if slot.id == item_id:
total += slot.count
return total
def can_add(self, item_id, count):
item = ITEMS[item_id]
if item.stackable and self.count(item_id) > 0:
return True
needed = 1 if item.stackable else count
return len(self.slots) + needed <= self.capacity
def add(self, item_id, count):
if not self.can_add(item_id, count):
return False
item = ITEMS[item_id]
if item.stackable:
for slot in self.slots:
if slot.id == item_id:
slot.count += count
return True
self.slots.append(SimpleNamespace(id=item_id, count=count))
return True
for i in range(count):
self.slots.append(SimpleNamespace(id=item_id, count=1))
return True
def remove(self, item_id, count):
left = count
kept = []
for slot in self.slots:
if slot.id == item_id and left > 0:
taken = min(left, slot.count)
slot.count -= taken
left -= taken
if slot.count > 0:
kept.append(slot)
self.slots = kept
def remove_at(self, index):
slot = self.slots[index]
slot.count -= 1
if slot.count <= 0:
del self.slots[index]

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):

def gear_bonus(self, stat):
total = 0
for slot in self.equipment.keys():
item_id = self.equipment[slot]
if item_id == '':
continue
for mod in ITEMS[item_id].mods:
if mod.stat == stat:
total += mod.value
return total
def weapon(self):
weapon_id = self.equipment['weapon']
return ITEMS['fists' if weapon_id == '' else weapon_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.
def equip_from_inventory(hero, index):
slot = hero.inventory.slots[index]
item = ITEMS[slot.id]
if item.kind != 'weapon' and item.kind != 'armor' and item.kind != 'ring':
return ''
old = hero.equipment[item.kind]
hero.inventory.remove_at(index)
hero.equipment[item.kind] = item.id
if old != '':
hero.inventory.slots.insert(index, SimpleNamespace(id=old, count=1))
if hero.hp > hero.max_hp():
hero.hp = hero.max_hp()
if hero.mana > hero.max_mana():
hero.mana = hero.max_mana()
return item.id

Und der Beutewurf, den du kennst:

# Gewichtete Beute: Gewicht 30 von insgesamt 100 bedeutet 30 % Chance.
def roll_loot(rng, table_id):
entries = LOOT_TABLES[table_id]
total = 0
for entry in entries:
total += entry.weight
roll = rng.next_int(total)
for entry in entries:
if roll < entry.weight:
if entry.item_id == 'none':
return None
return SimpleNamespace(id=entry.item_id, count=rng.range(entry.min, entry.max))
roll -= entry.weight
return None

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, de und es; 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 nach id. Wir geben sie an Inventory und Hero weiter, 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), und drinkPotion trinkt 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 mit g auf; 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 gibt false zurü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?

def pick(ids, weights, roll):
for i in range(len(ids)):
if roll < weights[i]:
return ids[i]
roll -= weights[i]
return ids[len(ids) - 1]
ids = ["none", "potion_heal", "spear"]
weights = [50, 30, 20]
rolls = [0, 49, 50, 79, 80, 99]
for roll in rolls:
print(str(roll) + ": " + pick(ids, weights, roll))
Antwort

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:

items = load_items(ITEMS_TEXT)
bag = Inventory(2, items)
add_and_show(bag, "spear", 1)
add_and_show(bag, "potion_heal", 1)
add_and_show(bag, "potion_mana", 1)
add_and_show(bag, "potion_heal", 1)
Ausgabe
+ 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.

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;
  • add muss 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:

def add_and_show(bag, item_id, count):
added = bag.add(item_id, count)
parts = []
for slot in bag.slots:
parts.append(label(bag.items[slot.id].name, slot.count))
print("+ " + label(bag.items[item_id].name, count) + ": " + ("erledigt" if added else "kein Platz") + " " + fill_text(bag) + " [" + ", ".join(parts) + "]")
items = load_items(ITEMS_TEXT)
bag = Inventory(3, items)
add_and_show(bag, "potion_heal", 4)
add_and_show(bag, "potion_heal", 3)
add_and_show(bag, "potion_mana", 2)
add_and_show(bag, "potion_heal", 3)
add_and_show(bag, "potion_heal", 1)
add_and_show(bag, "potion_mana", 4)

Die Ausgabe ist:

Ausgabe
+ 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 Art misc und 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 rollLoot einen Parameter luck: Je größer er ist, desto mehr wird das Gewicht der Zeile none verringert (aber nicht unter 0), und die anderen bleiben. Was passiert mit den Chancen der anderen?

Test

Prüfe dich

1. Warum sind die Gegenstände des Spiels eine Tabelle in Text und nicht new Item(...) im Code?
2. Der Held hat 40 Leben mit einem Ring des Lebens (+10 maximales Leben). Das Leben ist voll: 40/40. Er nimmt den Ring ab. Wie viel Leben hat er danach?
3. Der Beutel hat 4 Plätze, und alle sind belegt. Es ist ein Heiltrank darin. Kann noch ein solcher Trank hinzugefügt werden?
4. Die Tabelle des Goblins ist: nichts 40, Heiltrank 30, Manatrank 10, Speer 10, Lederrüstung 10. Gewürfelt wird 85. Welcher Gegenstand fällt?
5. Ein Heiltrank kostet 15. Für wie viel verkauft der Held ihn im Laden?
6. Bei 1000 besiegten Goblins zählt das Programm 396-mal „nichts“ und nicht genau 400. Warum?

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