Герои, врагове, показатели
Накратко: Едно същество за всички: героят и враговете се различават по данни (редове в таблица) и по два метода, а всеки показател е сбор от слоеве база + екипировка + ефекти, който се смята при всяко питане.
Проблем
Десет врага, три класа, един въпрос
В играта има десет вида врагове и три класа герои. Всички имат живот, сила, ловкост и защита, а при удар се питат едни и същи числа. Как се пише такова нещо?
Първата идея на всеки, който е учил наследяване, е клас за всеки вид: Enemy е родител, Goblin и Skeleton са наследници. Скоро се вижда дървото:
Enemy├── Rat├── Goblin│ ├── GoblinSpearman│ └── GoblinShaman├── Undead│ ├── Skeleton имунитет към кръв и отрова│ └── StoneServant имунитет към кръв и отрова (пак!)├── Golem имунитет към кръв и отрова (пак!!)└── ...Скелетът, каменният слуга и големът не кървят и не се отравят, но не са от един род: единият е мъртвец, другите са от камък. Имунитетът или ще се препише в три класа, или ще трябва клас „Безкръвен“ между тях, който пресича рода „Нежив“. А с втората особеност (слаб към огън) ще има още един пресичащ се род. Дълбокото дърво е добро, докато всеки случай влиза в един клон.
Има и друг проблем: нов враг означава нов клас, нов компилиран код и програмист. Дизайнерът, който иска гоблин с 30 живот, не може да го направи сам.
В RPG 1 петте раси наследяват един Character. Там наследяването е на място, защото всяка раса прави нещо различно (свое умение). Тук враговете се различават по числа. Различно поведение се пише в метод. Различни числа са данни.
Идеята
Врагът е ред в таблица
Първо правило на играта: ако две неща се различават само по числа, те са един клас и два реда данни. Враговете в урока са четири реда текст, а клетките са разделени с |:
# id|име|живот|сила|ловкост|защита|имунитетrat|гигантски плъх|9|1|6|0|-goblin|гоблин копиеносец|16|2|5|1|-skeleton|скелет|28|4|4|2|bleed,poisongolem|голем от руда|46|6|1|4|bleed,poisonИграта работи точно така, само че с десет врага. Ето истинската таблица от канона. Колоните, които този урок не ползва (знак, цвят, щета, опит, злато, плячка, умение, дълбочина), са пропуснати, а редовете са същите:
# id|име|живот|сила|ловкост|защита|бързина|устойчив|слаб|имунитет|настроениеrat|гигантски плъх|9|1|6|0|12|-|-|-|wandergoblin|гоблин копиеносец|16|2|5|1|10|-|-|-|wandershaman|гоблин шаман|13|1|4|0|9|-|-|-|sleepwolf|вълк кръвник|20|3|8|1|14|-|-|-|sleepwolf_leader|вълк водач|36|4|9|2|14|-|-|-|sleepskeleton|скелет|28|4|4|2|10|slash|blunt|bleed,poison|sleepcultist|култист|26|3|6|1|11|-|-|-|wandergolem|голем от руда|46|6|1|4|7|pierce,fire|blunt|bleed,poison|sleepservant|каменен слуга|14|2|3|1|9|-|blunt|bleed,poison|sleepwarden|Пазач на дълбините|120|5|4|3|10|-|-|bleed,poison|guardКодът чете таблицата при старт и прави запис за всеки ред. Нов враг е нов ред: нито един ред код не се променя. Това се казва данни вместо код (data-driven): играта е движок, а съдържанието е в таблици, които се редактират без програмист.
По-точно, за любопитните
Така се четат таблиците на урока (враговете, предметите и ефектите). Текстът е константа, а rows го разделя на редове и клетки, като пропуска празните редове и бележките с #:
record Item(String id, String name, String slot, Map<String, Integer> mods) {}
record Effect(String id, String name, Map<String, Integer> mods) {}
record Kind(String id, String name, int hp, int strength, int agility, int defense, List<String> immune) {}
final class Data { static final String[] STATS = { "maxHp", "strength", "agility", "defense", "evasion" }; static final String[] SLOTS = { "armor", "ring" };
static final String ITEMS_TEXT = """ # id|име|слот|модификатори leather|кожена броня|armor|defense:2 chain|ризница|armor|defense:4,evasion:-5 ring_strength|пръстен на силата|ring|strength:2 ring_life|пръстен на живота|ring|maxHp:10""";
static final String EFFECTS_TEXT = """ # id|име|модификатори rage|ярост|strength:3,defense:-2 shield|щит|defense:3 weak|слабост|strength:-4""";
static final String ENEMIES_TEXT = """ # id|име|живот|сила|ловкост|защита|имунитет rat|гигантски плъх|9|1|6|0|- goblin|гоблин копиеносец|16|2|5|1|- skeleton|скелет|28|4|4|2|bleed,poison golem|голем от руда|46|6|1|4|bleed,poison""";
static final Map<String, Item> ITEMS = new LinkedHashMap<>(); static final Map<String, Effect> EFFECTS = new LinkedHashMap<>(); static final Map<String, Kind> KINDS = new LinkedHashMap<>();
static { for (String[] row : rows(ITEMS_TEXT)) { ITEMS.put(row[0], new Item(row[0], row[1], row[2], parseMods(row[3]))); } for (String[] row : rows(EFFECTS_TEXT)) { EFFECTS.put(row[0], new Effect(row[0], row[1], parseMods(row[2]))); } for (String[] row : rows(ENEMIES_TEXT)) { List<String> immune = row[6].equals("-") ? List.of() : List.of(row[6].split(",")); KINDS.put(row[0], new Kind(row[0], row[1], Integer.parseInt(row[2]), Integer.parseInt(row[3]), Integer.parseInt(row[4]), Integer.parseInt(row[5]), immune)); } }
static List<String[]> rows(String table) { List<String[]> result = new ArrayList<>(); for (String line : table.split("\n")) { String text = line.trim(); if (text.isEmpty() || text.startsWith("#")) continue; result.add(text.split("\\|")); } return result; }
static Map<String, Integer> parseMods(String text) { Map<String, Integer> mods = new HashMap<>(); if (text.equals("-")) return mods; for (String part : text.split(",")) { String[] pair = part.split(":"); mods.put(pair[0], Integer.parseInt(pair[1])); } return mods; }}record е запис с полета без промяна. LinkedHashMap пази реда на добавяне, затова враговете излизат в реда на таблицата.Идеята
Едно същество за всички
Различията между героя и враговете са малко. Затова има един клас Creature с всичко общо: име, живот, база (число за всеки показател) и списък с ефекти. Ето началото му:
class Creature { final String name; final Map<String, Integer> base = new HashMap<>(); final List<Effect> effects = new ArrayList<>(); int hp;
Creature(String name) { this.name = name; for (String stat : Data.STATS) base.put(stat, 0); }Creature има карта с показателите и списък с ефекти, а не е гоблин или скелет. Това е композиция: сглобяваш същество от части, а не го наследяваш от по-общо същество. Враг от таблицата просто пълни базата от своя ред:
class Enemy extends Creature { final Kind kind;
Enemy(Kind kind) { super(kind.name()); this.kind = kind; base.put("maxHp", kind.hp()); base.put("strength", kind.strength()); base.put("agility", kind.agility()); base.put("defense", kind.defense()); hp = kind.hp(); }
// Имунитетът е данни: списък от реда в таблицата. @Override boolean isImmune(String effectId) { return kind.immune().contains(effectId); }}А героят носи още нещо: екипировка.
class Hero extends Creature { final Map<String, String> equipment = new HashMap<>();
Hero(String name, int maxHp, int strength, int agility, int defense) { super(name); base.put("maxHp", maxHp); base.put("strength", strength); base.put("agility", agility); base.put("defense", defense); hp = maxHp; for (String slot : Data.SLOTS) equipment.put(slot, ""); }Остава едно ниво наследяване: Creature → Hero и Enemy. Двата наследника подменят по един метод, защото само там поведението е различно: героят има gearBonus (екипировка), врагът има isImmune (имунитет от своя ред). Всичко останало, включително всичко в бойната система, пита Creature и не знае кого пита.
В канона е същото. Ето конструкторът на Enemy и неговият isImmune:
Enemy(int id, EnemyKind kind) { super(id, nm(kind)); this.kind = kind; this.base.put("maxHp", kind.hp); this.base.put("strength", kind.strength); this.base.put("agility", kind.agility); this.base.put("defense", kind.defense); this.base.put("speed", kind.speed); this.hp = kind.hp; this.state = kind.mood; this.calm = 0; this.lostTurns = 0; this.phase = 1; this.dead = false; this.summoned = false; this.noticed = false;}@Overrideboolean isImmune(String effectId) { return this.kind.immune.indexOf(effectId) >= 0;}Враг от таблицата копира своите числа в base и пази препратка към реда си (kind). Имунитетът не е клас, а списък в този ред.
По-точно, за любопитните
Правилото „композиция вместо наследяване“ не казва „никога не наследявай“. Казва: наследявай за поведение (един метод е различен), сглобявай за състав (частите са различни). Тук Creature има общия скелет, а наследниците са два и тънки. Ако героите бяха пет раси с пет умения, като в RPG 1, наследяването щеше да е правилното.
В канона и ефектите са малки класове (PoisonEffect, RageEffect), защото отровата и горенето правят нещо всеки ход. Ефект, който само сменя числа, може да е и ред в таблица, както е в урока.
Виж го
Всички през един код
Героят и четиримата врагове влизат в един списък от Creature. Цикълът не пита кой е кой: вика maxHp, stat и evasion, а към враг, който е имунен към кръв, добавя бележка. После всички получават един и същ ефект.
List<Creature> all = new ArrayList<>();all.add(new Hero("варварин", 48, 7, 4, 3));for (Kind kind : Data.KINDS.values()) all.add(new Enemy(kind));
for (Creature c : all) { String line = c.name + ": живот " + c.maxHp() + ", сила " + c.stat("strength") + ", защита " + c.stat("defense") + ", уклон " + c.evasion(); if (c.isImmune("bleed")) line += ", имунитет към кръв и отрова"; System.out.println(line);}
System.out.println("Слабост за всички:");for (Creature c : all) { int before = c.stat("strength"); c.addEffect("weak"); System.out.println(c.name + ": сила " + before + " -> " + c.stat("strength"));}варварин: живот 48, сила 7, защита 3, уклон 8гигантски плъх: живот 9, сила 1, защита 0, уклон 12гоблин копиеносец: живот 16, сила 2, защита 1, уклон 10скелет: живот 28, сила 4, защита 2, уклон 8, имунитет към кръв и отроваголем от руда: живот 46, сила 6, защита 4, уклон 2, имунитет към кръв и отроваСлабост за всички:варварин: сила 7 -> 3гигантски плъх: сила 1 -> 0гоблин копиеносец: сила 2 -> 0скелет: сила 4 -> 0голем от руда: сила 6 -> 2Слабостта вади 4 от силата на всеки. Плъхът, гоблинът и скелетът падат под нула и остават на 0: не могат да ударят с отрицателна сила. Големът е с 6, затова остава 2. Следващата карта казва защо.
Идеята
Показателят е сбор от слоеве
Силата на героя не е едно число. Тя е сбор:
- база: това, с което героят е роден (7 сила на варварина). Записва се в същество;
- екипировка: бонусите на предметите, които носи сега (+2 от пръстена). Не се записва, а се събира от слотовете;
- ефекти: бонусите и тежестите на ефектите, които действат сега (+3 от яростта). Не се записва, а се събира от списъка с ефекти.
// Ефектите се събират: два ефекта върху един показател се прибавят един към друг.int effectBonus(String stat) { int total = 0; for (Effect effect : effects) total += effect.mods().getOrDefault(stat, 0); return total;}
// Суровата стойност (може да е отрицателна): база + екипировка + ефекти.int rawStat(String stat) { return base.get(stat) + gearBonus(stat) + effectBonus(stat);}
// Показателят не пада под нула.int stat(String stat) { int value = rawStat(stat); return value < 0 ? 0 : value;}Общото се смята при всяко питане и не се пази никъде. Така няма как да се разсинхронизира. Сваляш ризницата и защитата пада, защото gearBonus не намира предмета. Изтича ефектът и силата се връща, защото effectBonus не намира ефекта. Нищо не „връща числото“. Ако записваше общата защита в самото същество, трябваше да помниш да я променяш на всяко събитие: облича, сваля, ефектът почва, ефектът изтича, героят вдигне ниво. Едно забравено място и защитата „полудява“.
Героят от програмата е варварин (48 живот, 7 сила, 4 ловкост, 3 защита) с ризница, пръстен на силата и ярост. Всеки ред е база +екипировка +ефекти = общо:
void addEffect(String id) { for (Effect effect : effects) { if (effect.id().equals(id)) return; } effects.add(Data.EFFECTS.get(id));}
// Ефектите се събират: два ефекта върху един показател се прибавят един към друг.int effectBonus(String stat) { int total = 0; for (Effect effect : effects) total += effect.mods().getOrDefault(stat, 0); return total;}
// Суровата стойност (може да е отрицателна): база + екипировка + ефекти.int rawStat(String stat) { return base.get(stat) + gearBonus(stat) + effectBonus(stat);}
// Показателят не пада под нула.int stat(String stat) { int value = rawStat(stat); return value < 0 ? 0 : value;}
@Overrideint gearBonus(String stat) { int total = 0; for (String slot : Data.SLOTS) { String id = equipment.get(slot); if (id.isEmpty()) continue; total += Data.ITEMS.get(id).mods().getOrDefault(stat, 0); } return total;}
// Животът не надвишава новия максимум.void equip(String id) { equipment.put(Data.ITEMS.get(id).slot(), id); if (hp > maxHp()) hp = maxHp();}Стъпка по стъпка, с думи
- В същество е записана само базата: сила 7, ловкост 4, защита 3. Уклонът е производен: ловкост × 2 = 8.
- Ризница: защитата е 3 + 4 = 7. Бонусът към уклона е -5, но stat() е 0; суровото число остава и уклонът пада на 3.
- Пръстенът е в друг слот и не изгонва ризницата: слотовете се сумират. Сила 7 + 2 = 9.
- Яростта: +3 към силата, −2 към защитата. effectBonus събира ефектите: сила 12, защита 5. Изтече ли, слоят изчезва.
- Общото се смята наново: stat() = max(0, база + екипировка + ефекти). Не се пази, затова не се разсинхронизира.
Hero hero = new Hero("варварин", 48, 7, 4, 3);System.out.println("-- Начало --");report(hero);hero.equip("chain");hero.equip("ring_strength");System.out.println("-- С ризница и пръстен на силата --");report(hero);hero.addEffect("rage");System.out.println("-- И с ярост --");report(hero);-- Начало --варварин: живот 48/48 maxHp: 48 +0 +0 = 48 strength: 7 +0 +0 = 7 agility: 4 +0 +0 = 4 defense: 3 +0 +0 = 3 evasion: 0 +0 +0 = 0 уклон: 8-- С ризница и пръстен на силата --варварин: живот 48/48 maxHp: 48 +0 +0 = 48 strength: 7 +2 +0 = 9 agility: 4 +0 +0 = 4 defense: 3 +4 +0 = 7 evasion: 0 -5 +0 = 0 уклон: 3-- И с ярост --варварин: живот 48/48 maxHp: 48 +0 +0 = 48 strength: 7 +2 +3 = 12 agility: 4 +0 +0 = 4 defense: 3 +4 -2 = 5 evasion: 0 -5 +0 = 0 уклон: 3Погледни последния ред: evasion: 0 −5 +0 = 0, но уклонът е 3. Защо в следващата карта.
Идеята
Суровото число и показателят
Ризницата е тежка: вади 5 от уклона. Но показател със знак минус няма смисъл: сила −3 би превърнала удара в лечение. Затова stat не пада под нула:
// Показателят не пада под нула.int stat(String stat) { int value = rawStat(stat); return value < 0 ? 0 : value;}
int maxHp() { int value = stat("maxHp"); return value < 1 ? 1 : value;}А уклонът в същото време трябва да знае, че ризницата тежи. Той не ползва stat, а сурово число (rawStat), което може да е отрицателно:
// Уклонът се смята от ловкостта и от суровия бонус (може да е отрицателен).int evasion() { return stat("agility") * 2 + rawStat("evasion");}С ловкост 4 уклонът е 4 × 2 = 8. Ризницата сваля 5, но stat("evasion") е 0, а уклонът е 8 − 5 = 3. Ако уклонът ползваше stat("evasion"), ризницата нямаше да струва нищо.
maxHp има свое правило: не пада под 1. Същество с 0 максимален живот е мъртво още щом се роди, а делението на максималния живот (например лентата в интерфейса) би се счупило.
return value < 0 ? : value;Показателят не пада под нула: ако сборът е отрицателен, stat връща 0. Суровото число се пази за уклона, за да не се губи, че ризницата тежи.
В играта е същото, с още няколко производни числа: точност, шанс за критичен удар и „тежка рана“ (под 25% живот ударите са по-неточни):
// Суровата стойност (може да е отрицателна): база + екипировка + ефекти.int rawStat(String stat) { int value = this.base.get(stat) + this.gearBonus(stat); for (Effect effect : this.effects) value += effect.modifier(stat); return value;}
int stat(String stat) { int value = this.rawStat(stat); return value < 0 ? 0 : value;}
int maxHp() { int value = this.stat("maxHp"); return value < 1 ? 1 : value;}
int maxMana() { return this.stat("maxMana");}
// Под 25% живот раната е тежка: ударите стават по-неточни.boolean isHeavilyWounded() { return this.hp * 4 < this.maxHp();}
int accuracy() { return 75 + this.stat("agility") * 2 + this.rawStat("accuracy") - (this.isHeavilyWounded() ? 15 : 0);}
int evasion() { return this.stat("agility") * 2 + this.rawStat("evasion");}
int critChance() { return 5 + this.stat("agility") / 10;}Ето откъде идва rawStat в канона: той събира базата, gearBonus и всеки ефект с effect.modifier(stat). Урокът пази effectBonus отделно, за да се виждат трите слоя един по един.
Идеята
Героят носи, врагът не
Базовият Creature.gearBonus връща 0, защото врагът няма екипировка. Героят го подменя:
@Overrideint gearBonus(String stat) { int total = 0; for (String slot : Data.SLOTS) { String id = equipment.get(slot); if (id.isEmpty()) continue; total += Data.ITEMS.get(id).mods().getOrDefault(stat, 0); } return total;}Героят пази само кой предмет е в кой слот (име → id). Самите числа са в таблицата на предметите. Затова една ризница е един ред данни, а не копие във всеки герой, и сменянето на баланса е смяна на число на едно място.
Когато предмет влезе или излезе, максималният живот може да се смени. Пръстенът на живота дава +10. Свалиш ли го, а героят е на 58 от 58, текущият живот остава 58 при максимум 48: нещо, което не бива да се случва. Затова equip и unequip връщат живота в границата:
// Животът не надвишава новия максимум.void equip(String id) { equipment.put(Data.ITEMS.get(id).slot(), id); if (hp > maxHp()) hp = maxHp();}В канона е същото (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);}По-точно, за любопитните
В канона героят има и трети слот, оръжие, а weapon() връща юмруци, когато слотът е празен, за да няма специален случай „без оръжие“. Така героят винаги има какво да удари. В урока слотовете са два, за да се виждат числата.
Предметите се държат по id (низ), не по препратка. Затова записът на играта е прост (запазва се „ring_life“, не обект) и един и същи предмет може да е на много места.
Пробвай
Предвиди изхода
Героят слага пръстена на живота (+10 максимален живот) и се лекува докрай. После го удря слабост (−4 сила) и той сваля пръстена. Какво печата програмата?
Hero hero = new Hero("варварин", 48, 7, 4, 3);hero.equip("ring_life");hero.hp = hero.maxHp();hero.addEffect("weak");hero.unequip("ring");System.out.println(hero.hp + "/" + hero.maxHp());System.out.println(hero.stat("strength"));Максимумът пада от 58 на 48, а unequip връща живота в границата: 48 / 48. Силата е 7 − 4 = 3: ефектът е слой, който не зависи от пръстена. Вариантът 58 / 48 забравя да върне живота, 7 забравя ефекта, а 58 / 58 забравя и двете.
Пробвай
Намери грешката
Колега „опрости“ един ред и сега героят с ярост и щит има странни числа. Двата ефекта се слагат един след друг, а програмата печата това:
Hero hero = new Hero("варварин", 48, 7, 4, 3);hero.addEffect("rage");hero.addEffect("shield");report(hero);варварин: живот 48/48 maxHp: 48 +0 +0 = 48 strength: 7 +0 +0 = 7 agility: 4 +0 +0 = 4 defense: 3 +0 +3 = 6 evasion: 0 +0 +0 = 0 уклон: 8Яростта не помага на силата (+3 няма), а защитата е 3 + 3, не 3 − 2 + 3. Ето трите метода, които смятат слоевете. Кой ред е виновен?
Избери реда с грешката.
В effectBonus присвояването = пази само последния ефект: всеки нов затрива предишния. Щитът няма бонус към силата и го нулира, а за защитата остава само +3 на щита без −2 на яростта. Трябва +=: слоят „ефекти“ е сбор от всички ефекти, а не последният.
По-точно
Кога клас, кога ред
Когато в играта се появи нещо ново, питай: различава ли се по числа или по поведение?
- Нов враг с други живот, сила и защита, имунитет към огън, друго настроение: ред в таблицата;
- нов предмет с модификатори: ред в таблицата, а нов вид слот е ново име в списъка със слотове;
- ефект, който само сменя числа (ярост, щит, слабост): ред в таблицата;
- ефект, който прави нещо всеки ход (отрова, горене, лекуване): метод, затова в канона е малък клас;
- враг, който решава как да ходи (шаманът призовава, вълкът бяга): поведение, значи код. В канона това са умения и крайни автомати, не наследници на
Creature.
Клас (или подмяна на метод) е нужен, когато различното поведение не се побира в число. Всичко останало расте с редове в таблица.
Упражнение
Амулетът
Реши задачата в редактора си.
Амулет. Добави трети слот amulet и предмет amulet_bear („амулет на мечката“: +6 максимален живот, +1 сила, −1 ловкост) само като нов ред в таблицата на предметите и ново име в списъка със слотове. Нов клас не трябва. Програмата чете команди от входа (по една на ред): equip <предмет>, remove <слот>, buff <ефект> и heal (животът става максимален). След всяка команда пише > команда и един ред: живот/максимум | сила | защита | ловкост | уклон. Героят е същият варварин (48, 7, 4, 3).
С вход equip chain / equip amulet_bear / heal / buff rage / remove amulet / remove armor (по един на ред) изходът е:
> equip chain48/48 | сила 7 | защита 7 | ловкост 4 | уклон 3> equip amulet_bear48/54 | сила 8 | защита 7 | ловкост 3 | уклон 1> heal54/54 | сила 8 | защита 7 | ловкост 3 | уклон 1> buff rage54/54 | сила 11 | защита 5 | ловкост 3 | уклон 1> remove amulet48/48 | сила 10 | защита 5 | ловкост 4 | уклон 3> remove armor48/48 | сила 10 | защита 1 | ловкост 4 | уклон 8Обърни внимание на heal и на remove amulet: с амулета максимумът е 54 и heal пълни до 54, а remove amulet връща живота в границата 48.
Още задачи
- Нов враг без код. Добави вълк кръвник от канона (20 живот, 3 сила, 8 ловкост, 1 защита, без имунитет) само като ред в таблицата на враговете. Колко е неговият уклон?
- Втори пръстен. Направи слотовете
ring1иring2. Какво става сring_strength, ако го сложиш и в двата? Е ли правилно? - Ефект със срок. Добави на ефекта брой ходове и метод
tick(), който ги намалява и маха ефекта на нула. Какво трябва да направиstat()след изтичането?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Полиморфизъм · Списъци · HashMap и HashSet · record, switch и шаблони
- Задачи по темата: Хрониките на Арканум