Герои, врагове, показатели
Накратко: Едно същество за всички: героят и враговете се различават по данни (редове в таблица) и по два метода, а всеки показател е сбор от слоеве база + екипировка + ефекти, който се смята при всяко питане.
Проблем
Десет врага, три класа, един въпрос
В играта има десет вида врагове и три класа герои. Всички имат живот, сила, ловкост и защита, а при удар се питат едни и същи числа. Как се пише такова нещо?
Първата идея на всеки, който е учил наследяване, е клас за всеки вид: 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, Dictionary<string, int> Mods);
record Effect(string Id, string Name, Dictionary<string, int> Mods);
record Kind(string Id, string Name, int Hp, int Strength, int Agility, int Defense, string[] Immune);
static class Data{ public static readonly string[] Stats = ["maxHp", "strength", "agility", "defense", "evasion"]; public static readonly string[] Slots = ["armor", "ring"];
public const string ItemsText = """ # id|име|слот|модификатори leather|кожена броня|armor|defense:2 chain|ризница|armor|defense:4,evasion:-5 ring_strength|пръстен на силата|ring|strength:2 ring_life|пръстен на живота|ring|maxHp:10 """;
public const string EffectsText = """ # id|име|модификатори rage|ярост|strength:3,defense:-2 shield|щит|defense:3 weak|слабост|strength:-4 """;
public const string EnemiesText = """ # 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 """;
public static readonly Dictionary<string, Item> Items = new(); public static readonly Dictionary<string, Effect> Effects = new(); public static readonly Dictionary<string, Kind> Kinds = new();
static Data() { foreach (string[] row in Rows(ItemsText)) { Items[row[0]] = new Item(row[0], row[1], row[2], ParseMods(row[3])); } foreach (string[] row in Rows(EffectsText)) { Effects[row[0]] = new Effect(row[0], row[1], ParseMods(row[2])); } foreach (string[] row in Rows(EnemiesText)) { string[] immune = row[6] == "-" ? Array.Empty<string>() : row[6].Split(','); Kinds[row[0]] = new Kind(row[0], row[1], int.Parse(row[2]), int.Parse(row[3]), int.Parse(row[4]), int.Parse(row[5]), immune); } }
static List<string[]> Rows(string table) { var result = new List<string[]>(); foreach (string line in table.Split('\n')) { string text = line.Trim(); if (text == "" || text.StartsWith('#')) continue; result.Add(text.Split('|')); } return result; }
static Dictionary<string, int> ParseMods(string text) { var mods = new Dictionary<string, int>(); if (text == "-") return mods; foreach (string part in text.Split(',')) { string[] pair = part.Split(':'); mods[pair[0]] = int.Parse(pair[1]); } return mods; }}record е запис с полета без промяна. Dictionary връща елементите в реда на добавяне, докато няма изтривания, и таблиците тук не се трият.Идеята
Едно същество за всички
Различията между героя и враговете са малко. Затова има един клас Creature с всичко общо: име, живот, база (число за всеки показател) и списък с ефекти. Ето началото му:
class Creature{ public readonly string Name; public readonly Dictionary<string, int> Base = new(); public readonly List<Effect> Effects = new(); public int Hp;
public Creature(string name) { Name = name; foreach (string stat in Data.Stats) Base[stat] = 0; }Creature има карта с показателите и списък с ефекти, а не е гоблин или скелет. Това е композиция: сглобяваш същество от части, а не го наследяваш от по-общо същество. Враг от таблицата просто пълни базата от своя ред:
class Enemy : Creature{ public readonly Kind Kind;
public Enemy(Kind kind) : base(kind.Name) { Kind = kind; Base["maxHp"] = kind.Hp; Base["strength"] = kind.Strength; Base["agility"] = kind.Agility; Base["defense"] = kind.Defense; Hp = kind.Hp; }
// Имунитетът е данни: списък от реда в таблицата. public override bool IsImmune(string effectId) => Array.IndexOf(Kind.Immune, effectId) >= 0;}А героят носи още нещо: екипировка.
class Hero : Creature{ public readonly Dictionary<string, string> Equipment = new();
public Hero(string name, int maxHp, int strength, int agility, int defense) : base(name) { Base["maxHp"] = maxHp; Base["strength"] = strength; Base["agility"] = agility; Base["defense"] = defense; Hp = maxHp; foreach (string slot in Data.Slots) Equipment[slot] = ""; }Остава едно ниво наследяване: Creature → Hero и Enemy. Двата наследника подменят по един метод, защото само там поведението е различно: героят има gearBonus (екипировка), врагът има isImmune (имунитет от своя ред). Всичко останало, включително всичко в бойната система, пита Creature и не знае кого пита.
В канона е същото. Ето конструкторът на Enemy и неговият isImmune:
public Enemy(int id, EnemyKind kind) : base(id, Nm(kind)){ Kind = kind; Base["maxHp"] = kind.Hp; Base["strength"] = kind.Strength; Base["agility"] = kind.Agility; Base["defense"] = kind.Defense; Base["speed"] = kind.Speed; Hp = kind.Hp; State = kind.Mood; Calm = 0; LostTurns = 0; Phase = 1; Dead = false; Summoned = false; Noticed = false;}public override bool IsImmune(string effectId){ return Kind.Immune.IndexOf(effectId) >= 0;}Враг от таблицата копира своите числа в base и пази препратка към реда си (kind). Имунитетът не е клас, а списък в този ред.
По-точно, за любопитните
Правилото „композиция вместо наследяване“ не казва „никога не наследявай“. Казва: наследявай за поведение (един метод е различен), сглобявай за състав (частите са различни). Тук Creature има общия скелет, а наследниците са два и тънки. Ако героите бяха пет раси с пет умения, като в RPG 1, наследяването щеше да е правилното.
В канона и ефектите са малки класове (PoisonEffect, RageEffect), защото отровата и горенето правят нещо всеки ход. Ефект, който само сменя числа, може да е и ред в таблица, както е в урока.
Виж го
Всички през един код
Героят и четиримата врагове влизат в един списък от Creature. Цикълът не пита кой е кой: вика maxHp, stat и evasion, а към враг, който е имунен към кръв, добавя бележка. После всички получават един и същ ефект.
var all = new List<Creature> { new Hero("варварин", 48, 7, 4, 3) };foreach (Kind kind in Data.Kinds.Values) all.Add(new Enemy(kind));
foreach (Creature c in all){ string line = c.Name + ": живот " + c.MaxHp() + ", сила " + c.Stat("strength") + ", защита " + c.Stat("defense") + ", уклон " + c.Evasion(); if (c.IsImmune("bleed")) line += ", имунитет към кръв и отрова"; Console.WriteLine(line);}
Console.WriteLine("Слабост за всички:");foreach (Creature c in all){ int before = c.Stat("strength"); c.AddEffect("weak"); Console.WriteLine(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 от яростта). Не се записва, а се събира от списъка с ефекти.
// Ефектите се събират: два ефекта върху един показател се прибавят един към друг.public int EffectBonus(string stat){ int total = 0; foreach (Effect effect in Effects) total += effect.Mods.GetValueOrDefault(stat, 0); return total;}
// Суровата стойност (може да е отрицателна): база + екипировка + ефекти.public int RawStat(string stat) => Base[stat] + GearBonus(stat) + EffectBonus(stat);
// Показателят не пада под нула.public int Stat(string stat){ int value = RawStat(stat); return value < 0 ? 0 : value;}Общото се смята при всяко питане и не се пази никъде. Така няма как да се разсинхронизира. Сваляш ризницата и защитата пада, защото gearBonus не намира предмета. Изтича ефектът и силата се връща, защото effectBonus не намира ефекта. Нищо не „връща числото“. Ако записваше общата защита в самото същество, трябваше да помниш да я променяш на всяко събитие: облича, сваля, ефектът почва, ефектът изтича, героят вдигне ниво. Едно забравено място и защитата „полудява“.
Героят от програмата е варварин (48 живот, 7 сила, 4 ловкост, 3 защита) с ризница, пръстен на силата и ярост. Всеки ред е база +екипировка +ефекти = общо:
public void AddEffect(string id){ foreach (Effect effect in Effects) { if (effect.Id == id) return; } Effects.Add(Data.Effects[id]);}
// Ефектите се събират: два ефекта върху един показател се прибавят един към друг.public int EffectBonus(string stat){ int total = 0; foreach (Effect effect in Effects) total += effect.Mods.GetValueOrDefault(stat, 0); return total;}
// Суровата стойност (може да е отрицателна): база + екипировка + ефекти.public int RawStat(string stat) => Base[stat] + GearBonus(stat) + EffectBonus(stat);
// Показателят не пада под нула.public int Stat(string stat){ int value = RawStat(stat); return value < 0 ? 0 : value;}
public override int GearBonus(string stat){ int total = 0; foreach (string slot in Data.Slots) { string id = Equipment[slot]; if (id == "") continue; total += Data.Items[id].Mods.GetValueOrDefault(stat, 0); } return total;}
// Животът не надвишава новия максимум.public void Equip(string id){ Equipment[Data.Items[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, база + екипировка + ефекти). Не се пази, затова не се разсинхронизира.
var hero = new Hero("варварин", 48, 7, 4, 3);Console.WriteLine("-- Начало --");Report(hero);hero.Equip("chain");hero.Equip("ring_strength");Console.WriteLine("-- С ризница и пръстен на силата --");Report(hero);hero.AddEffect("rage");Console.WriteLine("-- И с ярост --");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 не пада под нула:
// Показателят не пада под нула.public int Stat(string stat){ int value = RawStat(stat); return value < 0 ? 0 : value;}
public int MaxHp(){ int value = Stat("maxHp"); return value < 1 ? 1 : value;}А уклонът в същото време трябва да знае, че ризницата тежи. Той не ползва stat, а сурово число (rawStat), което може да е отрицателно:
// Уклонът се смята от ловкостта и от суровия бонус (може да е отрицателен).public int Evasion() => 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% живот ударите са по-неточни):
// Суровата стойност (може да е отрицателна): база + екипировка + ефекти.public int RawStat(string stat){ int value = Base[stat] + GearBonus(stat); foreach (Effect effect in Effects) value += effect.Modifier(stat); return value;}
public int Stat(string stat){ int value = RawStat(stat); return value < 0 ? 0 : value;}
public int MaxHp(){ int value = Stat("maxHp"); return value < 1 ? 1 : value;}
public int MaxMana(){ return Stat("maxMana");}
// Под 25% живот раната е тежка: ударите стават по-неточни.public bool IsHeavilyWounded(){ return Hp * 4 < MaxHp();}
public int Accuracy(){ return 75 + Stat("agility") * 2 + RawStat("accuracy") - (IsHeavilyWounded() ? 15 : 0);}
public int Evasion(){ return Stat("agility") * 2 + RawStat("evasion");}
public int CritChance(){ return 5 + Stat("agility") / 10;}Ето откъде идва rawStat в канона: той събира базата, gearBonus и всеки ефект с effect.modifier(stat). Урокът пази effectBonus отделно, за да се виждат трите слоя един по един.
Идеята
Героят носи, врагът не
Базовият Creature.gearBonus връща 0, защото врагът няма екипировка. Героят го подменя:
public override int GearBonus(string stat){ int total = 0; foreach (string slot in Data.Slots) { string id = Equipment[slot]; if (id == "") continue; total += Data.Items[id].Mods.GetValueOrDefault(stat, 0); } return total;}Героят пази само кой предмет е в кой слот (име → id). Самите числа са в таблицата на предметите. Затова една ризница е един ред данни, а не копие във всеки герой, и сменянето на баланса е смяна на число на едно място.
Когато предмет влезе или излезе, максималният живот може да се смени. Пръстенът на живота дава +10. Свалиш ли го, а героят е на 58 от 58, текущият живот остава 58 при максимум 48: нещо, което не бива да се случва. Затова equip и unequip връщат живота в границата:
// Животът не надвишава новия максимум.public void Equip(string id){ Equipment[Data.Items[id].Slot] = id; if (Hp > MaxHp()) Hp = MaxHp();}В канона е същото (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];}По-точно, за любопитните
В канона героят има и трети слот, оръжие, а weapon() връща юмруци, когато слотът е празен, за да няма специален случай „без оръжие“. Така героят винаги има какво да удари. В урока слотовете са два, за да се виждат числата.
Предметите се държат по id (низ), не по препратка. Затова записът на играта е прост (запазва се „ring_life“, не обект) и един и същи предмет може да е на много места.
Пробвай
Предвиди изхода
Героят слага пръстена на живота (+10 максимален живот) и се лекува докрай. После го удря слабост (−4 сила) и той сваля пръстена. Какво печата програмата?
var hero = new Hero("варварин", 48, 7, 4, 3);hero.Equip("ring_life");hero.Hp = hero.MaxHp();hero.AddEffect("weak");hero.Unequip("ring");Console.WriteLine(hero.Hp + "/" + hero.MaxHp());Console.WriteLine(hero.Stat("strength"));Максимумът пада от 58 на 48, а unequip връща живота в границата: 48 / 48. Силата е 7 − 4 = 3: ефектът е слой, който не зависи от пръстена. Вариантът 58 / 48 забравя да върне живота, 7 забравя ефекта, а 58 / 58 забравя и двете.
Пробвай
Намери грешката
Колега „опрости“ един ред и сега героят с ярост и щит има странни числа. Двата ефекта се слагат един след друг, а програмата печата това:
var 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()след изтичането?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Полиморфизъм · Списъци · Dictionary и HashSet · record, switch и шаблони
- Задачи по темата: Хрониките на Арканум