Към съдържанието
wannacode

Предмети, инвентар, плячка

Накратко: Предметите са таблица в текст, прочетена в обекти; екипировката добавя модификатори към показателите, инвентарът има места и трупа отвари, плячката се хвърля по тегла, а магазинът купува и продава.

Проблем

Нов меч, без да пипаш кода

В мината героят намира копие. Търговецът продава ризница. Гоблинът пуска отвара. Ако всеки предмет е ред код, new Item("spear", "копие", ...), то всеки нов меч е нова версия на програмата, а балансьорът, който иска да смени цената на ризницата от 90 на 80, чака компилация. Играта има двадесет и два предмета, и ще има още.

Затова данните са отделени от кода. Предметите са таблица в текст: един ред е един предмет, клетките са разделени с |, редовете с # са бележки.

# id|name|kind|price|modifiers
spear|копие|weapon|20|accuracy:5
war_axe|бойна брадва|weapon|80|accuracy:-5
leather|кожена броня|armor|30|defense:2
chain|ризница|armor|90|defense:4,evasion:-5
ring_life|пръстен на живота|ring|70|maxHp:10
ring_agility|пръстен на ловкостта|ring|70|agility:2
potion_heal|отвара за живот|potion|15|-
potion_mana|отвара за мана|potion|15|-
antidote|противоотрова|potion|10|-
ore|сребърна руда|misc|8|-

Урокът е за пет неща около тази таблица: как текстът става обекти; как екипировката (оръжие, броня, пръстен) се събира в показателите; как инвентарът с ограничени места трупа еднаквите отвари; как плячката се хвърля по таблица с тегла; и как магазинът купува и продава за злато. Малката таблица горе има десет предмета; в канона са двадесет и два, но кодът е същият.

Идеята

Таблица в текст, прочетена в обекти

Четецът е три малки функции. parseRows разделя текста на редове, пропуска празните и бележките и разделя клетките по |; ако клетките не са колкото се очакват, спира веднага и казва кой ред е развален. parseMods чете модификаторите (defense:4,evasion:-5, а - значи „няма“). loadItems прави от всеки ред предмет.

record Mod(string Stat, int Value);
class Item
{
public readonly string Id;
public readonly string Name;
public readonly string Kind;
public readonly int Price;
public readonly List<Mod> Mods;
public readonly bool Stackable;
public Item(string id, string name, string kind, int price, List<Mod> mods)
{
Id = id;
Name = name;
Kind = kind;
Price = price;
Mods = mods;
Stackable = kind == "potion" || kind == "misc";
}
}
// Един ред на таблицата е един предмет: id|име|вид|цена|модификатори.
static List<string[]> ParseRows(string text, int columns)
{
var rows = new List<string[]>();
foreach (string raw in text.Split('\n'))
{
string line = raw.Trim();
if (line.Length == 0 || line[0] == '#') continue;
string[] cells = line.Split('|');
if (cells.Length != columns)
{
throw new Exception("очаквани " + columns + " клетки в реда: " + line);
}
rows.Add(cells);
}
return rows;
}
// „-“ значи „няма модификатори“; иначе „показател:стойност“, разделени със запетая.
static List<Mod> ParseMods(string text)
{
var mods = new List<Mod>();
if (text == "-") return mods;
foreach (string part in text.Split(','))
{
string[] pieces = part.Split(':');
mods.Add(new Mod(pieces[0], int.Parse(pieces[1])));
}
return mods;
}
static OrderedDictionary<string, Item> LoadItems(string text)
{
var items = new OrderedDictionary<string, Item>();
foreach (string[] c in ParseRows(text, 5))
{
items[c[0]] = new Item(c[0], c[1], c[2], int.Parse(c[3]), ParseMods(c[4]));
}
return items;
}
var items = LoadItems(ITEMS_TEXT);
foreach (Item item in items.Values)
{
Console.WriteLine(item.Name + " (" + item.Kind + ") " + item.Price + " злато: " + ModsText(item.Mods));
}
Console.WriteLine("общо: " + items.Count);
try
{
LoadItems(BAD_ROW);
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
Изход
копие (weapon) 20 злато: accuracy +5
бойна брадва (weapon) 80 злато: accuracy -5
кожена броня (armor) 30 злато: defense +2
ризница (armor) 90 злато: defense +4, evasion -5
пръстен на живота (ring) 70 злато: maxHp +10
пръстен на ловкостта (ring) 70 злато: agility +2
отвара за живот (potion) 15 злато: -
отвара за мана (potion) 15 злато: -
противоотрова (potion) 10 злато: -
сребърна руда (misc) 8 злато: -
общо: 10
очаквани 5 клетки в реда: ring_life|пръстен на живота|ring|70

Програмата печата предметите по реда на таблицата и накрая подава ред с липсваща клетка (цената): четецът го спира с точното съобщение. Развалена таблица трябва да спира играта при старт, а не посред бой, когато играчът първи стъпи на предмета.

OrderedDictionary<string, Item> е речник, който пази реда на вмъкване (има го от .NET 9). Обикновеният Dictionary в практика също го пази, докато нищо не се трие, но не го обещава. Таблицата е суров низ (""").

Ето четецът в канона. Там всяка таблица има свои колони (оръжията са 10 клетки, бронята 7), затова parseRows взема броя им и името на таблицата за съобщението:

public static List<string[]> ParseRows(string text, int columns, string tableName)
{
List<string[]> rows = new List<string[]>();
foreach (string rawLine in text.Split('\n'))
{
string line = TrimSpaces(rawLine);
if (line.Length == 0 || line[0] == '#') continue;
string[] cells = line.Split('|');
if (cells.Length != columns)
{
throw new Exception(tableName + ": expected " + columns + " cells in: " + line);
}
rows.Add(cells);
}
return rows;
}
public static List<Mod> ParseMods(string text)
{
List<Mod> mods = new List<Mod>();
foreach (string part in SplitList(text))
{
string[] pieces = part.Split(':');
mods.Add(new Mod { Stat = pieces[0], Value = ToInt(pieces[1]) });
}
return mods;
}
По-точно, за любопитните

Таблиците се различават по колони, затова канонът има по един кратък четец на вид (loadItems чете петте таблици една след друга), но общото е едно: parseRows. Нов вид данни (врагове, умения, диалози) е нова таблица и нов кратък четец, а не нов начин за четене.

Ето и самите данни от играта: имената са на четири езика, после идват цената и модификаторите.

// Данните са таблици в текст: един ред е един запис, клетките са разделени с „|“. Редовете с „#“
// отпред са бележки. Кодът ги чете при старт в обекти; за нов меч не се пипа код, а таблица.
// Модификаторите са „показател:стойност“, разделени със запетая; „-“ значи „нищо“.
static partial class Arcanum
{
public static readonly string WEAPONS_DATA = """
# id|bg|en|de|es|вид щета|мин|макс|цена|модификатори
fists|юмруци|fists|Fäuste|puños|blunt|1|2|0|-
hand_axe|ръчна брадва|hand axe|Handaxt|hacha de mano|slash|4|7|20|-
spear|копие|spear|Speer|lanza|pierce|3|6|20|accuracy:5
staff|тояга|staff|Stab|bastón|blunt|2|4|10|maxMana:4
war_axe|бойна брадва|war axe|Streitaxt|hacha de guerra|slash|6|10|80|accuracy:-5
longbow|дълъг лък|longbow|Langbogen|arco largo|pierce|5|8|80|accuracy:10
mace|боздуган|mace|Streitkolben|maza|blunt|5|8|60|-
rune_staff|рунна тояга|rune staff|Runenstab|bastón rúnico|blunt|3|6|75|maxMana:10
silver_sword|сребърен меч|silver sword|Silberschwert|espada de plata|slash|7|11|120|accuracy:5
""";
public static readonly string ARMORS_DATA = """
# id|bg|en|de|es|цена|модификатори
robe|роба|robe|Robe|túnica|5|maxMana:2
leather|кожена броня|leather armor|Lederrüstung|armadura de cuero|30|defense:2
chain|ризница|chain mail|Kettenhemd|cota de malla|90|defense:4,evasion:-5
scale|люспеста броня|scale armor|Schuppenpanzer|armadura de escamas|110|defense:5
""";
public static readonly string RINGS_DATA = """
# id|bg|en|de|es|цена|модификатори
ring_strength|пръстен на силата|ring of strength|Ring der Stärke|anillo de fuerza|70|strength:2
ring_agility|пръстен на ловкостта|ring of agility|Ring des Geschicks|anillo de destreza|70|agility:2
ring_life|пръстен на живота|ring of life|Ring des Lebens|anillo de vida|70|maxHp:10
ring_mana|пръстен на маната|ring of mana|Ring des Manas|anillo de maná|70|maxMana:8
""";
public static readonly string POTIONS_DATA = """
# id|bg|en|de|es|цена|действие|сила
potion_heal|отвара за живот|healing potion|Heiltrank|poción de vida|15|heal|20
potion_mana|отвара за мана|mana potion|Manatrank|poción de maná|15|mana|15
antidote|противоотрова|antidote|Gegengift|antídoto|10|cure|0
""";
public static readonly string MISC_DATA = """
# id|bg|en|de|es|цена
ore|сребърна руда|silver ore|Silbererz|mineral de plata|8
gold|злато|gold|Gold|oro|0
""";
// Плячката: за всяка таблица редове с тегло. „none“ е „нищо“; бройката е от мин до макс.
public static readonly string LOOT_DATA = """
# таблица|предмет|тегло|мин|макс
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
""";
public static readonly string SHOP_DATA = """
# какво продава магазинът, в този ред
potion_heal
potion_mana
antidote
leather
chain
war_axe
longbow
mace
rune_staff
ring_life
ring_mana
""";
// Опитът, нужен за всяко ниво: XP_TABLE[ниво] е общият опит, с който се стига това ниво.
public static readonly string XP_DATA = "0|0|20|55|105|170|250|345|455|580|720";
}

Идеята

Екипировка: база плюс предметите

Героят има три слота: weapon, armor, ring. В слот стои id на предмет или празен низ. Показателят не се пази готов: той се смята всеки път като база (числото на героя) плюс сумата от модификаторите на всичко сложено. Така свалянето на пръстен не иска „върни старото число“, а просто пак смята.

class Hero
{
public static readonly string[] Slots = ["weapon", "armor", "ring"];
public readonly string Name;
public readonly OrderedDictionary<string, Item> Items;
public readonly Dictionary<string, int> BaseStats = new();
public readonly Dictionary<string, string> Equipment = new();
public int Hp;
public Hero(string name, OrderedDictionary<string, Item> items)
{
Name = name;
Items = items;
foreach (string slot in Slots) Equipment[slot] = "";
}
// Базата плюс бонусът от всичко, което е сложено.
public int GearBonus(string stat)
{
int total = 0;
foreach (string slot in Slots)
{
string itemId = Equipment[slot];
if (itemId.Length == 0) continue;
foreach (Mod mod in Items[itemId].Mods)
{
if (mod.Stat == stat) total += mod.Value;
}
}
return total;
}
public int Stat(string stat)
{
return BaseStats.GetValueOrDefault(stat, 0) + GearBonus(stat);
}
// Животът не може да е над новия максимум.
public void ClampHp()
{
if (Hp > Stat("maxHp")) Hp = Stat("maxHp");
}
public string Equip(string itemId)
{
Item item = Items[itemId];
string old = Equipment[item.Kind];
Equipment[item.Kind] = itemId;
ClampHp();
return old;
}
public void Unequip(string slot)
{
Equipment[slot] = "";
ClampHp();
}
}

equip поставя предмета в слота на неговия вид (item.kind) и връща стария, за да може да се върне в торбата. Има един неочакван момент: ако пръстенът даваше +10 най-много живот и го свалиш, текущият живот може да остане над новия максимум. clampHp го връща надолу.

var items = LoadItems(ITEMS_TEXT);
var hero = new Hero("Ива", items);
hero.BaseStats["maxHp"] = 30;
hero.BaseStats["defense"] = 1;
hero.BaseStats["accuracy"] = 70;
hero.Hp = hero.Stat("maxHp");
Console.WriteLine(hero.Name + ": " + Stats(hero));
foreach (string itemId in new[] { "spear", "leather", "ring_life" })
{
hero.Equip(itemId);
Console.WriteLine("+ " + items[itemId].Name + ": " + Stats(hero));
}
hero.Hp = hero.Stat("maxHp");
Console.WriteLine("лекуване: " + Stats(hero));
hero.Equip("war_axe");
Console.WriteLine("+ " + items["war_axe"].Name + ": " + Stats(hero));
hero.Unequip("ring");
Console.WriteLine("- " + items["ring_life"].Name + ": " + Stats(hero));
Изход
Ива: hp 30/30, defense 1, accuracy 70
+ копие: hp 30/30, defense 1, accuracy 75
+ кожена броня: hp 30/30, defense 3, accuracy 75
+ пръстен на живота: hp 30/40, defense 3, accuracy 75
лекуване: hp 40/40, defense 3, accuracy 75
+ бойна брадва: hp 40/40, defense 3, accuracy 65
- пръстен на живота: hp 30/30, defense 3, accuracy 65

Редовете следват героя: копието добавя 5 към точността, кожената броня 2 към защитата, пръстенът 10 към най-много живот (но текущият живот още е 30/40: максимумът расте, а животът не). След лекуването бойната брадва заема мястото на копието и точността пада от 75 на 65 (−5 вместо +5). А свалянето на пръстена връща най-много живот на 30, и clampHp сваля и текущия до 30.

Идеята

Инвентар: места и купчини

Торбата има ограничен брой места (в играта 10). Предметът заема място. Но десет отвари за живот не са десет места: еднаквите отвари и рудата се трупат на едно. В таблицата това са видовете potion и misc (stackable). Оръжията и бронята не се трупат: два меча са две места.

class Inventory
{
public readonly int Capacity;
public readonly OrderedDictionary<string, Item> Items;
public readonly List<Slot> Slots = new();
public Inventory(int capacity, OrderedDictionary<string, Item> items)
{
Capacity = capacity;
Items = items;
}
public int Count(string itemId)
{
int total = 0;
foreach (Slot slot in Slots)
{
if (slot.Id == itemId) total += slot.Count;
}
return total;
}
public bool CanAdd(string itemId, int count)
{
Item item = Items[itemId];
if (item.Stackable && Count(itemId) > 0) return true;
int needed = item.Stackable ? 1 : count;
return Slots.Count + needed <= Capacity;
}
public bool Add(string itemId, int count)
{
if (!CanAdd(itemId, count)) return false;
Item item = Items[itemId];
if (item.Stackable)
{
foreach (Slot slot in Slots)
{
if (slot.Id == itemId)
{
slot.Count += count;
return true;
}
}
Slots.Add(new Slot(itemId, count));
return true;
}
for (int i = 0; i < count; i++) Slots.Add(new Slot(itemId, 1));
return true;
}
public void Remove(string itemId, int count)
{
int left = count;
var kept = new List<Slot>();
foreach (Slot slot in Slots)
{
if (slot.Id == itemId && left > 0)
{
int taken = Math.Min(left, slot.Count);
slot.Count -= taken;
left -= taken;
}
if (slot.Count > 0) kept.Add(slot);
}
Slots.Clear();
Slots.AddRange(kept);
}
public void RemoveAt(int index)
{
Slot slot = Slots[index];
slot.Count -= 1;
if (slot.Count <= 0) Slots.RemoveAt(index);
}
}

Всичко се решава в canAdd: ако предметът се трупа и вече има такъв, място не трябва; иначе трябват толкова нови места, колкото са нужни. add пита canAdd първо и ако не става, не прави нищо (не слага половин купчина). Резултатът е true или false, и така извикващият знае дали да каже „няма място“.

static string Label(string name, int count)
{
return count > 1 ? name + " x" + count : name;
}
static string FillText(Inventory bag)
{
return "(" + bag.Slots.Count + "/" + bag.Capacity + ")";
}
static void PrintBag(Inventory bag)
{
Console.WriteLine("Торбата:");
for (int i = 0; i < bag.Slots.Count; i++)
{
Slot slot = bag.Slots[i];
Console.WriteLine((i + 1) + ") " + Label(bag.Items[slot.Id].Name, slot.Count));
}
}
static void AddAndShow(Inventory bag, string itemId, int count)
{
bool added = bag.Add(itemId, count);
Console.WriteLine("+ " + Label(bag.Items[itemId].Name, count) + ": " + (added ? "готово" : "няма място") + " " + FillText(bag));
}
var items = LoadItems(ITEMS_TEXT);
var 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.WriteLine("- " + Label(items["potion_heal"].Name, 3) + ": " + FillText(bag));
bag.Remove("spear", 1);
Console.WriteLine("- " + items["spear"].Name + ": " + FillText(bag));
AddAndShow(bag, "chain", 1);
PrintBag(bag);
Изход
+ отвара за живот x2: готово (1/4)
+ копие: готово (2/4)
+ отвара за живот: готово (2/4)
+ отвара за мана: готово (3/4)
+ кожена броня: готово (4/4)
+ ризница: няма място (4/4)
+ отвара за живот: готово (4/4)
+ сребърна руда: няма място (4/4)
Торбата:
1) отвара за живот x4
2) копие
3) отвара за мана
4) кожена броня
- отвара за живот x3: (4/4)
- копие: (3/4)
+ ризница: готово (4/4)
Торбата:
1) отвара за живот
2) отвара за мана
3) кожена броня
4) ризница

Ризницата не влиза при пълна торба (4 от 4), а още една отвара за живот влиза: купчината е вече започната. Рудата няма къде. След това remove маха три отвари от купчината и копието, и ризницата влиза на освободеното място. removeAt(i) маха един предмет от място (магазинът го ползва за продажба) и освобождава мястото, когато бройката падне на нула.

По-точно, за любопитните

Купчината пази id и count, а не самия предмет. Определението на предмета (име, цена, модификатори) е едно на целия свят, в таблицата; в торбата стоят само препратки с бройки. Двадесет копия на копието не копират двадесет пъти цената.

Идеята

Плячка с тегла

Какво пуска гоблинът? Не винаги едно и също, а и не с еднакъв шанс: често нищо, понякога отвара, рядко копие. За това всеки враг има таблица с плячка: редове с тегло. Теглото е относително: ред с тегло 30 в таблица с общо 100 пада в 30% от случаите. Редът none значи „нищо“, а бройката е случайна между мин и макс.

# 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

Хвърлянето е кратко. Теглата се събират (общо 100), хвърля се число от 0 до 99, после редовете се минават по ред и от числото се изважда теглото на всеки, докато числото стане по-малко от теглото на текущия ред. Този ред пада. Така широчината на реда е точно шансът му и не трябва нито подредба по тегла, нито проценти, които се събират до 100.

record Entry(string ItemId, int Weight, int Min, int Max);
record Drop(string Id, int Count);
static Dictionary<string, List<Entry>> LoadLoot(string text)
{
var tables = new Dictionary<string, List<Entry>>();
foreach (string[] c in ParseRows(text, 5))
{
if (!tables.ContainsKey(c[0])) tables[c[0]] = new List<Entry>();
tables[c[0]].Add(new Entry(c[1], int.Parse(c[2]), int.Parse(c[3]), int.Parse(c[4])));
}
return tables;
}
static int TotalWeight(List<Entry> entries)
{
int total = 0;
foreach (Entry entry in entries) total += entry.Weight;
return total;
}
// Хвърля се число от 0 до общото тегло минус 1; теглата се изваждат едно по едно, докато числото падне в ред.
static Drop? RollLoot(Random rng, List<Entry> entries)
{
int roll = rng.NextInt(TotalWeight(entries));
foreach (Entry entry in entries)
{
if (roll < entry.Weight)
{
if (entry.ItemId == "none") return null;
return new Drop(entry.ItemId, rng.Range(entry.Min, entry.Max));
}
roll -= entry.Weight;
}
return null;
}
// Хвърля се число от 0 до общото тегло минус 1; теглата се изваждат едно по едно, докато числото падне в ред.
static Drop? RollLoot(Random rng, List<Entry> entries)
{
    int roll = rng.NextInt(TotalWeight(entries));
    foreach (Entry entry in entries)
    {
        if (roll < entry.Weight)
        {
            if (entry.ItemId == "none") return null;
            return new Drop(entry.ItemId, rng.Range(entry.Min, entry.Max));
        }
        roll  entry.Weight;
    }
    return null;
}

Програмата убива шест врага със семе 6 и пуска плячката направо в торба с 3 места. Генераторът е Random от канона (същият mulberry32 като в боя), затова изходът е един и същ на четирите езика:

var items = LoadItems(ITEMS_TEXT);
var tables = LoadLoot(LOOT_TEXT);
var rng = new Random(6);
var bag = new Inventory(3, items);
for (int i = 0; i < kills.Length; i++)
{
Drop? drop = RollLoot(rng, tables[kills[i]]);
string head = "враг " + (i + 1) + " (" + kills[i] + "): ";
if (drop == null)
{
Console.WriteLine(head + "нищо");
continue;
}
bool added = bag.Add(drop.Id, drop.Count);
Console.WriteLine(head + Label(items[drop.Id].Name, drop.Count) + " -> " + (added ? "готово" : "няма място") + " " + FillText(bag));
}
PrintBag(bag);
Изход
враг 1 (goblin): нищо
враг 2 (goblin): отвара за живот -> готово (1/3)
враг 3 (goblin): копие -> готово (2/3)
враг 4 (golem): сребърна руда x2 -> готово (3/3)
враг 5 (goblin): отвара за мана -> няма място (3/3)
враг 6 (goblin): отвара за живот -> готово (3/3)
Торбата:
1) отвара за живот x2
2) копие
3) сребърна руда x2

Първият гоблин не пуска нищо. Вторият пуска отвара, третият копие, големът руда по две (торбата вече е пълна, 3 от 3). Петият гоблин пуска отвара за мана, но няма място за нова купчина: в играта тя би останала на пода, а тук просто не влиза. Шестият пуска отвара за живот: вече има такава в торбата и тя се трупа, макар торбата да е пълна.

Случайното е само числото; теглата определят колко често. Да проверим: същата таблица, но 1000 убити гоблина, и се брои кой ред колко пъти е паднал.

var entries = LoadLoot(LOOT_TEXT)["goblin"];
var rng = new Random(2026);
var counts = new Dictionary<string, int>();
for (int i = 0; i < 1000; i++)
{
Drop? drop = RollLoot(rng, entries);
string key = drop == null ? "none" : drop.Id;
counts[key] = counts.GetValueOrDefault(key, 0) + 1;
}
int total = TotalWeight(entries);
foreach (Entry entry in entries)
{
Console.WriteLine(entry.ItemId + ": " + counts.GetValueOrDefault(entry.ItemId, 0) + " (очаквани " + entry.Weight * 1000 / total + ")");
}
Изход
none: 396 (очаквани 400)
potion_heal: 300 (очаквани 300)
potion_mana: 117 (очаквани 100)
spear: 91 (очаквани 100)
leather: 96 (очаквани 100)

Очакваните бройки са теглата × 10, и получените са близо до тях, но рядко са точно равни. Това е вероятност, а не разпределение по график. При по-малко убити врагове разликата е по-голяма (шест врага са твърде малко за каквото и да е), затова един добър баланс се проверява с хиляди убити врагове: последният урок прави точно това с цялата игра.

Виж го

Хвърлянето в действие

Ето същите шест убити врагове по ходове. Лентата е таблицата: колкото по-широк е редът, толкова по-често пада. Показалецът отива на хвърленото число, изваждането на теглата се вижда отдолу, а предметът излиза от своя ред и влиза в торбата: на ново място, на старото място (трупа се) или не влиза, ако торбата е пълна.

// Хвърля се число от 0 до общото тегло минус 1; теглата се изваждат едно по едно, докато числото падне в ред.
static Drop? RollLoot(Random rng, List<Entry> entries)
{
int roll = rng.NextInt(TotalWeight(entries));
foreach (Entry entry in entries)
{
if (roll < entry.Weight)
{
if (entry.ItemId == "none") return null;
return new Drop(entry.ItemId, rng.Range(entry.Min, entry.Max));
}
roll -= entry.Weight;
}
return null;
}
public bool CanAdd(string itemId, int count)
{
Item item = Items[itemId];
if (item.Stackable && Count(itemId) > 0) return true;
int needed = item.Stackable ? 1 : count;
return Slots.Count + needed <= Capacity;
}
public bool Add(string itemId, int count)
{
if (!CanAdd(itemId, count)) return false;
Item item = Items[itemId];
if (item.Stackable)
{
foreach (Slot slot in Slots)
{
if (slot.Id == itemId)
{
slot.Count += count;
return true;
}
}
Slots.Add(new Slot(itemId, count));
return true;
}
for (int i = 0; i < count; i++) Slots.Add(new Slot(itemId, 1));
return true;
}
Стъпка по стъпка, с думи
  1. Таблицата на гоблина е текст: 5 реда, всеки с тегло, общо 100. Теглото е ширината на реда в лентата: тегло 30 от 100 са 30% шанс. Торбата има 3 места.
  2. Враг 1 (goblin): хвърля се число от 0 до 99 и се получава 1. Теглата се изваждат едно по едно: 1 < 40. Числото пада в реда „нищо“ (тегло 40 от 100).
  3. „нищо“ е ред като всеки друг (тегло 40): плячка няма и торбата не се пипа.
  4. Враг 2 (goblin): хвърля се число от 0 до 99 и се получава 56. Теглата се изваждат едно по едно: 56 ≥ 40 → 16 → 16 < 30. Числото пада в реда „отвара за живот“ (тегло 30 от 100).
  5. Отвара за живот отива в торбата на ново място (1/3).
  6. Враг 3 (goblin): хвърля се число от 0 до 99 и се получава 89. Теглата се изваждат едно по едно: 89 ≥ 40 → 49 → 49 ≥ 30 → 19 → 19 ≥ 10 → 9 → 9 < 10. Числото пада в реда „копие“ (тегло 10 от 100).
  7. Копие отива в торбата на ново място (2/3).
  8. Враг 4 (golem): хвърля се число от 0 до 99 и се получава 1. Теглата се изваждат едно по едно: 1 < 100. Числото пада в реда „сребърна руда“ (тегло 100 от 100).
  9. Сребърна руда x2 отива в торбата на ново място (3/3).
  10. Враг 5 (goblin): хвърля се число от 0 до 99 и се получава 76. Теглата се изваждат едно по едно: 76 ≥ 40 → 36 → 36 ≥ 30 → 6 → 6 < 10. Числото пада в реда „отвара за мана“ (тегло 10 от 100).
  11. Торбата е пълна (3/3), а отвара за мана няма с какво да се трупа: canAdd връща false и add не прави нищо (в играта предметът остава на пода).
  12. Враг 6 (goblin): хвърля се число от 0 до 99 и се получава 56. Теглата се изваждат едно по едно: 56 ≥ 40 → 16 → 16 < 30. Числото пада в реда „отвара за живот“ (тегло 30 от 100).
  13. Отвара за живот вече има в торбата, затова се трупа на същото място (отвара за живот x2); заетите места остават 3/3.
  14. След 6 убити врагове торбата е пълна. Случайно е само числото; теглата казват колко често пада всеки ред, а торбата решава какво се побира.

Кодът до сцената е rollLoot, canAdd и add от програмата. На всяка стъпка светят редовете, които работят в този момент.

Идеята

Магазин: купуване и продаване

Магазинът е най-късото от петте неща, защото всичко за него е вече построено. Купуването е три проверки по ред: стига ли златото, има ли място в торбата, после се плаща и предметът се добавя. Продаването маха едно от мястото и дава половината от цената, закръглена надолу.

// Продажбата дава половината от цената, закръглена надолу.
static int SellPrice(Item item)
{
return item.Price / 2;
}
static string Buy(Player player, OrderedDictionary<string, Item> items, string itemId)
{
Item item = items[itemId];
string result = "готово";
if (player.Gold < item.Price) result = "не стига златото";
else if (!player.Bag.CanAdd(itemId, 1)) result = "няма място";
else
{
player.Gold -= item.Price;
player.Bag.Add(itemId, 1);
}
return "купува " + item.Name + " (" + item.Price + "): " + result + ", злато " + player.Gold;
}
static string Sell(Player player, OrderedDictionary<string, Item> items, int slotNumber)
{
Slot slot = player.Bag.Slots[slotNumber - 1];
Item item = items[slot.Id];
int price = SellPrice(item);
player.Bag.RemoveAt(slotNumber - 1);
player.Gold += price;
return "продава " + item.Name + " (" + price + "): готово, злато " + player.Gold;
}
var items = LoadItems(ITEMS_TEXT);
var player = new Player(100, new Inventory(3, items));
Console.WriteLine(Buy(player, items, "potion_heal"));
Console.WriteLine(Buy(player, items, "potion_heal"));
Console.WriteLine(Buy(player, items, "chain"));
Console.WriteLine(Buy(player, items, "leather"));
Console.WriteLine(Buy(player, items, "war_axe"));
Console.WriteLine(Sell(player, items, 2));
Console.WriteLine(Buy(player, items, "potion_mana"));
Console.WriteLine(Buy(player, items, "antidote"));
Console.WriteLine(Buy(player, items, "leather"));
Console.WriteLine(Sell(player, items, 1));
Изход
купува отвара за живот (15): готово, злато 85
купува отвара за живот (15): готово, злато 70
купува ризница (90): не стига златото, злато 70
купува кожена броня (30): готово, злато 40
купува бойна брадва (80): не стига златото, злато 40
продава кожена броня (15): готово, злато 55
купува отвара за мана (15): готово, злато 40
купува противоотрова (10): готово, злато 30
купува кожена броня (30): няма място, злато 30
продава отвара за живот (7): готово, злато 37

Редът на проверките е важен. Ризницата за 90 не се купува със 70 злато, нито бойната брадва за 80 с 40. На деветия ред героят има злато за кожена броня (30), но торбата е пълна: проверката за злато минава, canAdd отказва и златото не се взима. Продажбата на кожената броня дава 15 (30 / 2), а на отвара за живот 7 (15 / 2, закръглено надолу): купуване и веднага продаване никога не е печалба.

Ето продажната цена в канона. Купуването там е същите проверки, само че екранът чете номера от клавиатурата:

public static int SellPrice(Item item)
{
return item.Price / 2;
}

Идеята

В играта

Това, което построихме, е същото като в канона, само по-малко. Ето истинския инвентар:

class Inventory
{
public int Capacity;
public List<ItemStack> Slots;
public Inventory(int capacity)
{
Capacity = capacity;
Slots = new List<ItemStack>();
}
public int Count(string itemId)
{
int total = 0;
foreach (ItemStack slot in Slots)
{
if (slot.Id == itemId) total += slot.Count;
}
return total;
}
public bool CanAdd(string itemId, int count)
{
Item item = ITEMS[itemId];
if (item.Stackable && Count(itemId) > 0) return true;
int needed = item.Stackable ? 1 : count;
return Slots.Count + needed <= Capacity;
}
public bool Add(string itemId, int count)
{
if (!CanAdd(itemId, count)) return false;
Item item = ITEMS[itemId];
if (item.Stackable)
{
foreach (ItemStack slot in Slots)
{
if (slot.Id == itemId)
{
slot.Count += count;
return true;
}
}
Slots.Add(new ItemStack { Id = itemId, Count = count });
return true;
}
for (int i = 0; i < count; i++) Slots.Add(new ItemStack { Id = itemId, Count = 1 });
return true;
}
public void Remove(string itemId, int count)
{
int left = count;
List<ItemStack> kept = new List<ItemStack>();
foreach (ItemStack slot in Slots)
{
if (slot.Id == itemId && left > 0)
{
int taken = Math.Min(left, slot.Count);
slot.Count -= taken;
left -= taken;
}
if (slot.Count > 0) kept.Add(slot);
}
Slots = kept;
}
public void RemoveAt(int index)
{
ItemStack slot = Slots[index];
slot.Count -= 1;
if (slot.Count <= 0) Slots.RemoveAt(index);
}
}

Екипировката в канона е част от Creature: gearBonus събира модификаторите по слотовете и се вмъква между базата и ефектите (виж урока за героите и враговете):

public override int GearBonus(string stat)
{
int total = 0;
foreach (string slot in Equipment.Keys)
{
string itemId = Equipment.Get(slot);
if (itemId == "") continue;
foreach (Mod mod in ITEMS[itemId].Mods)
{
if (mod.Stat == stat) total += mod.Value;
}
}
return total;
}
public Item Weapon()
{
string id = Equipment.Get("weapon");
return ITEMS[id == "" ? "fists" : id];
}

Слагането на предмет от торбата сменя старото със новото на същото място, за да не се разбърква редът на предметите:

// Слага предмета от инвентара; старият заема неговото място в инвентара.
static partial class Arcanum
{
public static string EquipFromInventory(Hero hero, int index)
{
ItemStack slot = hero.Inventory.Slots[index];
Item item = ITEMS[slot.Id];
if (item.Kind != "weapon" && item.Kind != "armor" && item.Kind != "ring") return "";
string old = hero.Equipment.Get(item.Kind);
hero.Inventory.RemoveAt(index);
hero.Equipment.Set(item.Kind, item.Id);
if (old != "") hero.Inventory.Slots.Insert(index, new ItemStack { 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;
}
}

И хвърлянето на плячка, което познаваш:

// Плячка с тегла: тегло 30 от общо 100 значи 30% шанс.
static partial class Arcanum
{
public static ItemStack? RollLoot(Random rng, string tableId)
{
List<LootEntry> entries = LOOT_TABLES[tableId];
int total = 0;
foreach (LootEntry entry in entries) total += entry.Weight;
int roll = rng.NextInt(total);
foreach (LootEntry entry in entries)
{
if (roll < entry.Weight)
{
if (entry.ItemId == "none") return null;
return new ItemStack { Id = entry.ItemId, Count = rng.Range(entry.Min, entry.Max) };
}
roll -= entry.Weight;
}
return null;
}
}

Разликите от нашата програма:

  • Една таблица по вид. Оръжията, бронята, пръстените, отварите и другото са всеки със свои колони; нашата таблица е една, за да се чете лесно.
  • Имена на четири езика. Всеки ред в канона носи името на bg, en, de и es; nm(item) избира по езика на играта. Тук името е една клетка.
  • Глобален ITEMS. Канонът пази предметите в един глобален речник и ги търси по id. Ние ги подаваме на Inventory и Hero, за да се виждат зависимостите и да се тества лесно.
  • Торба с 10 места. Героят в канона има Inventory(10); тук местата са 3 и 4, за да се вижда кога се пълнят.
  • Злато и отвари. Златото не е предмет в торбата, а число при героя (враговете пускат злато отделно от плячката). Отварите имат действие (heal, mana, cure) и drinkPotion ги пие в боя и на картата; тук ги само трупаме.
  • Плячката пада на земята. Убитият враг оставя предмета на пода (dropItem), а героят го вдига с g; при пълна торба предметът остава на пода и играчът вижда „торбата е пълна“. В нашата програма плячката влиза направо в торбата, а пълна торба връща false.

Пробвай

Предвиди изхода

Три реда с тегла 50, 30 и 20 и шест хвърлени числа. Какво печата програмата?

static string Pick(string[] ids, int[] weights, int roll)
{
for (int i = 0; i < ids.Length; i++)
{
if (roll < weights[i]) return ids[i];
roll -= weights[i];
}
return ids[ids.Length - 1];
}
string[] ids = ["none", "potion_heal", "spear"];
int[] weights = [50, 30, 20];
int[] rolls = [0, 49, 50, 79, 80, 99];
foreach (int roll in rolls)
{
Console.WriteLine(roll + ": " + Pick(ids, weights, roll));
}
Отговор

Пробвай

Намери грешката

Колега пренаписа проверката в canAdd, и сега торбата с 2 места събира три предмета, а отвара не влиза в пълна торба:

var items = LoadItems(ITEMS_TEXT);
var bag = new Inventory(2, items);
AddAndShow(bag, "spear", 1);
AddAndShow(bag, "potion_heal", 1);
AddAndShow(bag, "potion_mana", 1);
AddAndShow(bag, "potion_heal", 1);
Изход
+ копие: готово (1/2)
+ отвара за живот: готово (2/2)
+ отвара за мана: готово (3/2)
+ отвара за живот: няма място (3/2)

Кой ред е развален?

Избери реда с грешката.

По-точно

По-точно: вероятности, дялове и слабите места

Тегла, не проценти. Теглата не трябва да се събират до 100. Таблица 3, 1, 1 е същото като 60, 20, 20. Сборът е делителят и се смята наново при всяко хвърляне, затова нов ред се добавя, без да се пипа останалото. Процентите би трябвало да се събират до 100 след всяка промяна, а теглата не.

Изваждане или събиране. Може да се хвърли число и да се сравнява със суми (40, 70, 80, 90, 100): ред i пада, когато числото е под i-тата сума. Изваждането е същото без втори масив. За големи таблици сборовете се пазят наготово и се търси двоично; за тези от игрите е излишно.

„Нищо“ е ред. none с тегло 40 е ред като всеки друг: с едно число таблицата дава 0% или 90% „нищо“. А таблицата на водача (leader) в канона има само един ред с тегло 100: пръстенът пада винаги.

Всичко или нищо. Всяка промяна на торбата минава през add, който първо пита canAdd. Така „половин купчина“ или „три предмета в две места“ не може да се случи. Упражнението по-долу защитава именно това.

Закръгляне. цена / 2 с цяло деление отрязва остатъка, затова продажбата никога не дава повече от половината. В Python и JavaScript цялото деление е // и Math.floor; те работят правилно, защото цените не са отрицателни.

Случайност и повторяемост. Хвърлянето не знае колко ще падне; знае само как да отговори на числото. Генераторът е със семе (mulberry32), затова със същото семе и същите убийства плячката е същата на четирите езика и при записано минаване. Това ще ни трябва и за записа, и за симулацията.

Упражнение

Купчини с горна граница

Реши задачата в редактора си. Тръгни от програмата за инвентара.

В играта десет отвари на едно място са прекалено много. Добави горна граница на купчината: най-много 5 от един предмет на място. Какво се променя:

  • купчина, която стигне 5, е пълна; още от същото отива на ново място;
  • add трябва да е всичко или нищо: ако всички не се побират (в стари купчини и в нови места), не се слага нищо;
  • не-трупащите предмети са на място по един, както досега.

Нужни са room (колко още влиза в стари купчини), поправено canAdd и add, който първо допълва старите купчини, а после прави нови. Торбата е с 3 места, а сценарият е този:

static void AddAndShow(Inventory bag, string itemId, int count)
{
bool added = bag.Add(itemId, count);
var parts = new List<string>();
foreach (Slot slot in bag.Slots) parts.Add(Label(bag.Items[slot.Id].Name, slot.Count));
Console.WriteLine("+ " + Label(bag.Items[itemId].Name, count) + ": " + (added ? "готово" : "няма място") + " " + FillText(bag) + " [" + string.Join(", ", parts) + "]");
}
var items = LoadItems(ITEMS_TEXT);
var 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);

Изходът е:

Изход
+ отвара за живот x4: готово (1/3) [отвара за живот x4]
+ отвара за живот x3: готово (2/3) [отвара за живот x5, отвара за живот x2]
+ отвара за мана x2: готово (3/3) [отвара за живот x5, отвара за живот x2, отвара за мана x2]
+ отвара за живот x3: готово (3/3) [отвара за живот x5, отвара за живот x5, отвара за мана x2]
+ отвара за живот: няма място (3/3) [отвара за живот x5, отвара за живот x5, отвара за мана x2]
+ отвара за мана x4: няма място (3/3) [отвара за живот x5, отвара за живот x5, отвара за мана x2]

Редовете показват и границите: втората стъпка допълва старата купчина до 5 и отваря ново място за останалите 2; четвъртата допълва вече започнатата купчина и не иска ново място; последната (още 4 отвари за мана при купчина от 2) не слага нищо, макар 3 от тях да влизат, защото четвъртата няма къде.

Още задачи

  • Продай боклука. Добави sellJunk(player, items): продава всичко от вид misc и връща колко злато е получено.
  • Най-доброто оръжие. Когато героят вземе оръжие, да го сложи само ако сборът на модификаторите му е по-голям от сбора на сложеното сега.
  • Късмет. Дай на rollLoot параметър luck: колкото е по-голям, с толкова се намалява теглото на реда none (но не под 0), а другите остават. Какво става с шансовете на останалите?

Тест

Провери се

1. Защо предметите на играта са таблица в текст, а не new Item(...) в кода?
2. Героят е с 40 живот и пръстен на живота (+10 най-много живот). Животът му е пълен: 40/40. Сваля пръстена. Колко живот има след това?
3. Торбата има 4 места и всичките са заети. В нея има отвара за живот. Може ли да се добави още една такава отвара?
4. Таблицата на гоблина е: нищо 40, отвара за живот 30, отвара за мана 10, копие 10, кожена броня 10. Хвърлено е 85. Кой предмет пада?
5. отвара за живот струва 15. За колко я продава героят в магазина?
6. При 1000 убити гоблина програмата брои 396 пъти „нищо“, а не точно 400. Защо?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·