Предмети, инвентар, плячка
Накратко: Предметите са таблица в текст, прочетена в обекти; екипировката добавя модификатори към показателите, инвентарът има места и трупа отвари, плячката се хвърля по тегла, а магазинът купува и продава.
Проблем
Нов меч, без да пипаш кода
В мината героят намира копие. Търговецът продава ризница. Гоблинът пуска отвара. Ако всеки предмет е ред код, new Item("spear", "копие", ...), то всеки нов меч е нова версия на програмата, а балансьорът, който иска да смени цената на ризницата от 90 на 80, чака компилация. Играта има двадесет и два предмета, и ще има още.
Затова данните са отделени от кода. Предметите са таблица в текст: един ред е един предмет, клетките са разделени с |, редовете с # са бележки.
# id|name|kind|price|modifiersspear|копие|weapon|20|accuracy:5war_axe|бойна брадва|weapon|80|accuracy:-5leather|кожена броня|armor|30|defense:2chain|ризница|armor|90|defense:4,evasion:-5ring_life|пръстен на живота|ring|70|maxHp:10ring_agility|пръстен на ловкостта|ring|70|agility:2potion_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 { final String id; final String name; final String kind; final int price; final List<Mod> mods; final boolean stackable;
Item(String id, String name, String kind, int price, List<Mod> mods) { this.id = id; this.name = name; this.kind = kind; this.price = price; this.mods = mods; this.stackable = kind.equals("potion") || kind.equals("misc"); }}
// Един ред на таблицата е един предмет: id|име|вид|цена|модификатори.static List<String[]> parseRows(String text, int columns) { List<String[]> rows = new ArrayList<>(); for (String raw : text.split("\n")) { String line = raw.trim(); if (line.isEmpty() || line.charAt(0) == '#') continue; String[] cells = line.split("\\|"); if (cells.length != columns) { throw new RuntimeException("очаквани " + columns + " клетки в реда: " + line); } rows.add(cells); } return rows;}
// „-“ значи „няма модификатори“; иначе „показател:стойност“, разделени със запетая.static List<Mod> parseMods(String text) { List<Mod> mods = new ArrayList<>(); if (text.equals("-")) return mods; for (String part : text.split(",")) { String[] pieces = part.split(":"); mods.add(new Mod(pieces[0], Integer.parseInt(pieces[1]))); } return mods;}
static Map<String, Item> loadItems(String text) { Map<String, Item> items = new LinkedHashMap<>(); for (String[] c : parseRows(text, 5)) { items.put(c[0], new Item(c[0], c[1], c[2], Integer.parseInt(c[3]), parseMods(c[4]))); } return items;}Map<String, Item> items = loadItems(ITEMS_TEXT);for (Item item : items.values()) { System.out.println(item.name + " (" + item.kind + ") " + item.price + " злато: " + modsText(item.mods));}System.out.println("общо: " + items.size());try { loadItems(BAD_ROW);} catch (RuntimeException e) { System.out.println(e.getMessage());}копие (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Програмата печата предметите по реда на таблицата и накрая подава ред с липсваща клетка (цената): четецът го спира с точното съобщение. Развалена таблица трябва да спира играта при старт, а не посред бой, когато играчът първи стъпи на предмета.
Map пази предметите, а LinkedHashMap пази и реда им на вмъкване: „всички предмети“ излизат винаги в реда на таблицата. split("\\|") е регулярен израз, затова чертата се предпазва с обратна наклонена. Текстът на таблицата е текстов блок (""").Ето четецът в канона. Там всяка таблица има свои колони (оръжията са 10 клетки, бронята 7), затова parseRows взема броя им и името на таблицата за съобщението:
static List<List<String>> parseRows(String text, int columns, String tableName) { List<List<String>> rows = new ArrayList<>(); for (String rawLine : split(text, "\n")) { String line = trimSpaces(rawLine); if (line.length() == 0 || line.charAt(0) == '#') continue; List<String> cells = split(line, "|"); if (cells.size() != columns) { throw new RuntimeException(tableName + ": expected " + columns + " cells in: " + line); } rows.add(cells); } return rows;}
static List<Mod> parseMods(String text) { List<Mod> mods = new ArrayList<>(); for (String part : splitList(text)) { List<String> pieces = split(part, ":"); mods.add(new Mod(pieces.get(0), toInt(pieces.get(1)))); } return mods;}По-точно, за любопитните
Таблиците се различават по колони, затова канонът има по един кратък четец на вид (loadItems чете петте таблици една след друга), но общото е едно: parseRows. Нов вид данни (врагове, умения, диалози) е нова таблица и нов кратък четец, а не нов начин за четене.
Ето и самите данни от играта: имената са на четири езика, после идват цената и модификаторите.
// Данните са таблици в текст: един ред е един запис, клетките са разделени с „|“. Редовете с „#“ // отпред са бележки. Кодът ги чете при старт в обекти; за нов меч не се пипа код, а таблица. // Модификаторите са „показател:стойност“, разделени със запетая; „-“ значи „нищо“. static final 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:5staff|тояга|staff|Stab|bastón|blunt|2|4|10|maxMana:4war_axe|бойна брадва|war axe|Streitaxt|hacha de guerra|slash|6|10|80|accuracy:-5longbow|дълъг лък|longbow|Langbogen|arco largo|pierce|5|8|80|accuracy:10mace|боздуган|mace|Streitkolben|maza|blunt|5|8|60|-rune_staff|рунна тояга|rune staff|Runenstab|bastón rúnico|blunt|3|6|75|maxMana:10silver_sword|сребърен меч|silver sword|Silberschwert|espada de plata|slash|7|11|120|accuracy:5""";
static final String ARMORS_DATA = """# id|bg|en|de|es|цена|модификаториrobe|роба|robe|Robe|túnica|5|maxMana:2leather|кожена броня|leather armor|Lederrüstung|armadura de cuero|30|defense:2chain|ризница|chain mail|Kettenhemd|cota de malla|90|defense:4,evasion:-5scale|люспеста броня|scale armor|Schuppenpanzer|armadura de escamas|110|defense:5""";
static final String RINGS_DATA = """# id|bg|en|de|es|цена|модификаториring_strength|пръстен на силата|ring of strength|Ring der Stärke|anillo de fuerza|70|strength:2ring_agility|пръстен на ловкостта|ring of agility|Ring des Geschicks|anillo de destreza|70|agility:2ring_life|пръстен на живота|ring of life|Ring des Lebens|anillo de vida|70|maxHp:10ring_mana|пръстен на маната|ring of mana|Ring des Manas|anillo de maná|70|maxMana:8""";
static final String POTIONS_DATA = """# id|bg|en|de|es|цена|действие|силаpotion_heal|отвара за живот|healing potion|Heiltrank|poción de vida|15|heal|20potion_mana|отвара за мана|mana potion|Manatrank|poción de maná|15|mana|15antidote|противоотрова|antidote|Gegengift|antídoto|10|cure|0""";
static final String MISC_DATA = """# id|bg|en|de|es|ценаore|сребърна руда|silver ore|Silbererz|mineral de plata|8gold|злато|gold|Gold|oro|0""";
// Плячката: за всяка таблица редове с тегло. „none“ е „нищо“; бройката е от мин до макс. static final String LOOT_DATA = """# таблица|предмет|тегло|мин|максvermin|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""";
static final String SHOP_DATA = """# какво продава магазинът, в този редpotion_healpotion_manaantidoteleatherchainwar_axelongbowmacerune_staffring_lifering_mana""";
// Опитът, нужен за всяко ниво: XP_TABLE[ниво] е общият опит, с който се стига това ниво. static final String XP_DATA = "0|0|20|55|105|170|250|345|455|580|720";Идеята
Екипировка: база плюс предметите
Героят има три слота: weapon, armor, ring. В слот стои id на предмет или празен низ. Показателят не се пази готов: той се смята всеки път като база (числото на героя) плюс сумата от модификаторите на всичко сложено. Така свалянето на пръстен не иска „върни старото число“, а просто пак смята.
class Hero { static final String[] SLOTS = { "weapon", "armor", "ring" };
final String name; final Map<String, Item> items; final Map<String, Integer> baseStats = new HashMap<>(); final Map<String, String> equipment = new HashMap<>(); int hp;
Hero(String name, Map<String, Item> items) { this.name = name; this.items = items; for (String slot : SLOTS) equipment.put(slot, ""); }
// Базата плюс бонусът от всичко, което е сложено. int gearBonus(String stat) { int total = 0; for (String slot : SLOTS) { String itemId = equipment.get(slot); if (itemId.isEmpty()) continue; for (Mod mod : items.get(itemId).mods) { if (mod.stat().equals(stat)) total += mod.value(); } } return total; }
int stat(String stat) { return baseStats.getOrDefault(stat, 0) + gearBonus(stat); }
// Животът не може да е над новия максимум. void clampHp() { if (hp > stat("maxHp")) hp = stat("maxHp"); }
String equip(String itemId) { Item item = items.get(itemId); String old = equipment.get(item.kind); equipment.put(item.kind, itemId); clampHp(); return old; }
void unequip(String slot) { equipment.put(slot, ""); clampHp(); }}equip поставя предмета в слота на неговия вид (item.kind) и връща стария, за да може да се върне в торбата. Има един неочакван момент: ако пръстенът даваше +10 най-много живот и го свалиш, текущият живот може да остане над новия максимум. clampHp го връща надолу.
Map<String, Item> items = loadItems(ITEMS_TEXT);Hero hero = new Hero("Ива", items);hero.baseStats.put("maxHp", 30);hero.baseStats.put("defense", 1);hero.baseStats.put("accuracy", 70);hero.hp = hero.stat("maxHp");System.out.println(hero.name + ": " + stats(hero));
for (String itemId : new String[] { "spear", "leather", "ring_life" }) { hero.equip(itemId); System.out.println("+ " + items.get(itemId).name + ": " + stats(hero));}hero.hp = hero.stat("maxHp");System.out.println("лекуване: " + stats(hero));
hero.equip("war_axe");System.out.println("+ " + items.get("war_axe").name + ": " + stats(hero));hero.unequip("ring");System.out.println("- " + items.get("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 { final int capacity; final Map<String, Item> items; final List<Slot> slots = new ArrayList<>();
Inventory(int capacity, Map<String, Item> items) { this.capacity = capacity; this.items = items; }
int count(String itemId) { int total = 0; for (Slot slot : slots) { if (slot.id.equals(itemId)) total += slot.count; } return total; }
boolean canAdd(String itemId, int count) { Item item = items.get(itemId); if (item.stackable && count(itemId) > 0) return true; int needed = item.stackable ? 1 : count; return slots.size() + needed <= capacity; }
boolean add(String itemId, int count) { if (!canAdd(itemId, count)) return false; Item item = items.get(itemId); if (item.stackable) { for (Slot slot : slots) { if (slot.id.equals(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; }
void remove(String itemId, int count) { int left = count; List<Slot> kept = new ArrayList<>(); for (Slot slot : slots) { if (slot.id.equals(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.addAll(kept); }
void removeAt(int index) { Slot slot = slots.get(index); slot.count -= 1; if (slot.count <= 0) slots.remove(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.size() + "/" + bag.capacity + ")";}
static void printBag(Inventory bag) { System.out.println("Торбата:"); for (int i = 0; i < bag.slots.size(); i++) { Slot slot = bag.slots.get(i); System.out.println((i + 1) + ") " + label(bag.items.get(slot.id).name, slot.count)); }}
static void addAndShow(Inventory bag, String itemId, int count) { boolean added = bag.add(itemId, count); System.out.println("+ " + label(bag.items.get(itemId).name, count) + ": " + (added ? "готово" : "няма място") + " " + fillText(bag));}Map<String, Item> items = loadItems(ITEMS_TEXT);Inventory 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);System.out.println("- " + label(items.get("potion_heal").name, 3) + ": " + fillText(bag));bag.remove("spear", 1);System.out.println("- " + items.get("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) отвара за живот x42) копие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|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|2Хвърлянето е кратко. Теглата се събират (общо 100), хвърля се число от 0 до 99, после редовете се минават по ред и от числото се изважда теглото на всеки, докато числото стане по-малко от теглото на текущия ред. Този ред пада. Така широчината на реда е точно шансът му и не трябва нито подредба по тегла, нито проценти, които се събират до 100.
record Entry(String itemId, int weight, int min, int max) {}
record Drop(String id, int count) {}
static Map<String, List<Entry>> loadLoot(String text) { Map<String, List<Entry>> tables = new LinkedHashMap<>(); for (String[] c : parseRows(text, 5)) { if (!tables.containsKey(c[0])) tables.put(c[0], new ArrayList<>()); tables.get(c[0]).add(new Entry(c[1], Integer.parseInt(c[2]), Integer.parseInt(c[3]), Integer.parseInt(c[4]))); } return tables;}
static int totalWeight(List<Entry> entries) { int total = 0; for (Entry entry : entries) total += entry.weight(); return total;}
// Хвърля се число от 0 до общото тегло минус 1; теглата се изваждат едно по едно, докато числото падне в ред.static Drop rollLoot(Random rng, List<Entry> entries) { int roll = rng.nextInt(totalWeight(entries)); for (Entry entry : entries) { if (roll < entry.weight()) { if (entry.itemId().equals("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));
for (Entry entry : entries) {
if (roll < entry.weight()) {
if (entry.itemId().equals("none")) return null;
return new Drop(entry.itemId(), rng.range(entry.min(), entry.max()));
}
roll entry.weight();
}
return null;
}Теглата се изваждат: така във всеки следващ ред числото е измерено от началото на този ред. Без изваждане всеки ред би се сравнявал с неотместеното число и ранните редове биха падали твърде често. Операторът е -=.
Програмата убива шест врага със семе 6 и пуска плячката направо в торба с 3 места. Генераторът е Random от канона (същият mulberry32 като в боя), затова изходът е един и същ на четирите езика:
Map<String, Item> items = loadItems(ITEMS_TEXT);Map<String, List<Entry>> tables = loadLoot(LOOT_TEXT);Random rng = new Random(6);Inventory bag = new Inventory(3, items);for (int i = 0; i < KILLS.length; i++) { Drop drop = rollLoot(rng, tables.get(KILLS[i])); String head = "враг " + (i + 1) + " (" + KILLS[i] + "): "; if (drop == null) { System.out.println(head + "нищо"); continue; } boolean added = bag.add(drop.id(), drop.count()); System.out.println(head + label(items.get(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) отвара за живот x22) копие3) сребърна руда x2Първият гоблин не пуска нищо. Вторият пуска отвара, третият копие, големът руда по две (торбата вече е пълна, 3 от 3). Петият гоблин пуска отвара за мана, но няма място за нова купчина: в играта тя би останала на пода, а тук просто не влиза. Шестият пуска отвара за живот: вече има такава в торбата и тя се трупа, макар торбата да е пълна.
Случайното е само числото; теглата определят колко често. Да проверим: същата таблица, но 1000 убити гоблина, и се брои кой ред колко пъти е паднал.
List<Entry> entries = loadLoot(LOOT_TEXT).get("goblin");Random rng = new Random(2026);Map<String, Integer> counts = new HashMap<>();for (int i = 0; i < 1000; i++) { Drop drop = rollLoot(rng, entries); String key = drop == null ? "none" : drop.id(); counts.put(key, counts.getOrDefault(key, 0) + 1);}int total = totalWeight(entries);for (Entry entry : entries) { System.out.println(entry.itemId() + ": " + counts.getOrDefault(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)); for (Entry entry : entries) { if (roll < entry.weight()) { if (entry.itemId().equals("none")) return null; return new Drop(entry.itemId(), rng.range(entry.min(), entry.max())); } roll -= entry.weight(); } return null;}
boolean canAdd(String itemId, int count) { Item item = items.get(itemId); if (item.stackable && count(itemId) > 0) return true; int needed = item.stackable ? 1 : count; return slots.size() + needed <= capacity;}
boolean add(String itemId, int count) { if (!canAdd(itemId, count)) return false; Item item = items.get(itemId); if (item.stackable) { for (Slot slot : slots) { if (slot.id.equals(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;}Стъпка по стъпка, с думи
- Таблицата на гоблина е текст: 5 реда, всеки с тегло, общо 100. Теглото е ширината на реда в лентата: тегло 30 от 100 са 30% шанс. Торбата има 3 места.
- Враг 1 (goblin): хвърля се число от 0 до 99 и се получава 1. Теглата се изваждат едно по едно: 1 < 40. Числото пада в реда „нищо“ (тегло 40 от 100).
- „нищо“ е ред като всеки друг (тегло 40): плячка няма и торбата не се пипа.
- Враг 2 (goblin): хвърля се число от 0 до 99 и се получава 56. Теглата се изваждат едно по едно: 56 ≥ 40 → 16 → 16 < 30. Числото пада в реда „отвара за живот“ (тегло 30 от 100).
- Отвара за живот отива в торбата на ново място (1/3).
- Враг 3 (goblin): хвърля се число от 0 до 99 и се получава 89. Теглата се изваждат едно по едно: 89 ≥ 40 → 49 → 49 ≥ 30 → 19 → 19 ≥ 10 → 9 → 9 < 10. Числото пада в реда „копие“ (тегло 10 от 100).
- Копие отива в торбата на ново място (2/3).
- Враг 4 (golem): хвърля се число от 0 до 99 и се получава 1. Теглата се изваждат едно по едно: 1 < 100. Числото пада в реда „сребърна руда“ (тегло 100 от 100).
- Сребърна руда x2 отива в торбата на ново място (3/3).
- Враг 5 (goblin): хвърля се число от 0 до 99 и се получава 76. Теглата се изваждат едно по едно: 76 ≥ 40 → 36 → 36 ≥ 30 → 6 → 6 < 10. Числото пада в реда „отвара за мана“ (тегло 10 от 100).
- Торбата е пълна (3/3), а отвара за мана няма с какво да се трупа: canAdd връща false и add не прави нищо (в играта предметът остава на пода).
- Враг 6 (goblin): хвърля се число от 0 до 99 и се получава 56. Теглата се изваждат едно по едно: 56 ≥ 40 → 16 → 16 < 30. Числото пада в реда „отвара за живот“ (тегло 30 от 100).
- Отвара за живот вече има в торбата, затова се трупа на същото място (отвара за живот x2); заетите места остават 3/3.
- След 6 убити врагове торбата е пълна. Случайно е само числото; теглата казват колко често пада всеки ред, а торбата решава какво се побира.
Кодът до сцената е rollLoot, canAdd и add от програмата. На всяка стъпка светят редовете, които работят в този момент.
Идеята
Магазин: купуване и продаване
Магазинът е най-късото от петте неща, защото всичко за него е вече построено. Купуването е три проверки по ред: стига ли златото, има ли място в торбата, после се плаща и предметът се добавя. Продаването маха едно от мястото и дава половината от цената, закръглена надолу.
// Продажбата дава половината от цената, закръглена надолу.static int sellPrice(Item item) { return item.price / 2;}
static String buy(Player player, Map<String, Item> items, String itemId) { Item item = items.get(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, Map<String, Item> items, int slotNumber) { Slot slot = player.bag.slots.get(slotNumber - 1); Item item = items.get(slot.id); int price = sellPrice(item); player.bag.removeAt(slotNumber - 1); player.gold += price; return "продава " + item.name + " (" + price + "): готово, злато " + player.gold;}Map<String, Item> items = loadItems(ITEMS_TEXT);Player player = new Player(100, new Inventory(3, items));System.out.println(buy(player, items, "potion_heal"));System.out.println(buy(player, items, "potion_heal"));System.out.println(buy(player, items, "chain"));System.out.println(buy(player, items, "leather"));System.out.println(buy(player, items, "war_axe"));System.out.println(sell(player, items, 2));System.out.println(buy(player, items, "potion_mana"));System.out.println(buy(player, items, "antidote"));System.out.println(buy(player, items, "leather"));System.out.println(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, закръглено надолу): купуване и веднага продаване никога не е печалба.
Ето продажната цена в канона. Купуването там е същите проверки, само че екранът чете номера от клавиатурата:
static int sellPrice(Item item) { return item.price / 2;}Идеята
В играта
Това, което построихме, е същото като в канона, само по-малко. Ето истинския инвентар:
static class Inventory { int capacity; List<ItemStack> slots;
Inventory(int capacity) { this.capacity = capacity; this.slots = new ArrayList<>(); }
int count(String itemId) { int total = 0; for (ItemStack slot : this.slots) { if (slot.id.equals(itemId)) total += slot.count; } return total; }
boolean canAdd(String itemId, int count) { Item item = ITEMS.get(itemId); if (item.stackable && this.count(itemId) > 0) return true; int needed = item.stackable ? 1 : count; return this.slots.size() + needed <= this.capacity; }
boolean add(String itemId, int count) { if (!this.canAdd(itemId, count)) return false; Item item = ITEMS.get(itemId); if (item.stackable) { for (ItemStack slot : this.slots) { if (slot.id.equals(itemId)) { slot.count += count; return true; } } this.slots.add(new ItemStack(itemId, count)); return true; } for (int i = 0; i < count; i++) this.slots.add(new ItemStack(itemId, 1)); return true; }
void remove(String itemId, int count) { int left = count; List<ItemStack> kept = new ArrayList<>(); for (ItemStack slot : this.slots) { if (slot.id.equals(itemId) && left > 0) { int taken = Math.min(left, slot.count); slot.count -= taken; left -= taken; } if (slot.count > 0) kept.add(slot); } this.slots = kept; }
void removeAt(int index) { ItemStack slot = this.slots.get(index); slot.count -= 1; if (slot.count <= 0) this.slots.remove(index); }}Екипировката в канона е част от Creature: gearBonus събира модификаторите по слотовете и се вмъква между базата и ефектите (виж урока за героите и враговете):
@Overrideint gearBonus(String stat) { int total = 0; for (String slot : this.equipment.keySet()) { String itemId = this.equipment.get(slot); if (itemId.equals("")) continue; for (Mod mod : ITEMS.get(itemId).mods) { if (mod.stat.equals(stat)) total += mod.value; } } return total;}
Item weapon() { String id = this.equipment.get("weapon"); return ITEMS.get(id.equals("") ? "fists" : id);}Слагането на предмет от торбата сменя старото със новото на същото място, за да не се разбърква редът на предметите:
// Слага предмета от инвентара; старият заема неговото място в инвентара.static String equipFromInventory(Hero hero, int index) { ItemStack slot = hero.inventory.slots.get(index); Item item = ITEMS.get(slot.id); if (!item.kind.equals("weapon") && !item.kind.equals("armor") && !item.kind.equals("ring")) return ""; String old = hero.equipment.get(item.kind); hero.inventory.removeAt(index); hero.equipment.put(item.kind, item.id); if (!old.equals("")) hero.inventory.slots.add(index, new ItemStack(old, 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 ItemStack rollLoot(Random rng, String tableId) { List<LootEntry> entries = LOOT_TABLES.get(tableId); int total = 0; for (LootEntry entry : entries) total += entry.weight; int roll = rng.nextInt(total); for (LootEntry entry : entries) { if (roll < entry.weight) { if (entry.itemId.equals("none")) return null; return new ItemStack(entry.itemId, 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 };for (int roll : rolls) { System.out.println(roll + ": " + pick(ids, weights, roll));}Числото се сравнява с теглото на реда и ако не е по-малко, теглото се изважда. 49 е по-малко от 50: пада първият ред; 50 не е по-малко от 50, остава 0 и пада вторият; 79 минава през 50 (остава 29) и 29 е по-малко от 30: вторият; 80 минава през 50 и 30 (остава 0) и пада третият. Вторият отговор сравнява с <= и бърка границите; третият дели числата на три равни части и забравя теглата.
Пробвай
Намери грешката
Колега пренаписа проверката в canAdd, и сега торбата с 2 места събира три предмета, а отвара не влиза в пълна торба:
Map<String, Item> items = loadItems(ITEMS_TEXT);Inventory 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)Кой ред е развален?
Избери реда с грешката.
Проверката е обърната: count(itemId) == 0 пропуска всеки нов предмет, който се трупа, без да гледа за място (оттам „3/2“), а за вече започнатата купчина пада до общата проверка за място и отказва. Правилно: > 0, т.е. „такъв предмет вече е в торбата, място не трябва“.
По-точно
По-точно: вероятности, дялове и слабите места
Тегла, не проценти. Теглата не трябва да се събират до 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) { boolean added = bag.add(itemId, count); List<String> parts = new ArrayList<>(); for (Slot slot : bag.slots) parts.add(label(bag.items.get(slot.id).name, slot.count)); System.out.println("+ " + label(bag.items.get(itemId).name, count) + ": " + (added ? "готово" : "няма място") + " " + fillText(bag) + " [" + String.join(", ", parts) + "]");}
Map<String, Item> items = loadItems(ITEMS_TEXT);Inventory 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), а другите остават. Какво става с шансовете на останалите?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Хвърляне и свои изключения · Списъци · HashMap и HashSet · record, switch и шаблони
- Задачи по темата: Хрониките на Арканум