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

Герои, врагове, показатели

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

Проблем

Десет врага, три класа, един въпрос

В играта има десет вида врагове и три класа герои. Всички имат живот, сила, ловкост и защита, а при удар се питат едни и същи числа. Как се пише такова нещо?

Първата идея на всеки, който е учил наследяване, е клас за всеки вид: 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,poison
golem|голем от руда|46|6|1|4|bleed,poison

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

# id|име|живот|сила|ловкост|защита|бързина|устойчив|слаб|имунитет|настроение
rat|гигантски плъх|9|1|6|0|12|-|-|-|wander
goblin|гоблин копиеносец|16|2|5|1|10|-|-|-|wander
shaman|гоблин шаман|13|1|4|0|9|-|-|-|sleep
wolf|вълк кръвник|20|3|8|1|14|-|-|-|sleep
wolf_leader|вълк водач|36|4|9|2|14|-|-|-|sleep
skeleton|скелет|28|4|4|2|10|slash|blunt|bleed,poison|sleep
cultist|култист|26|3|6|1|11|-|-|-|wander
golem|голем от руда|46|6|1|4|7|pierce,fire|blunt|bleed,poison|sleep
servant|каменен слуга|14|2|3|1|9|-|blunt|bleed,poison|sleep
warden|Пазач на дълбините|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();
}
Стъпка по стъпка, с думи
  1. В същество е записана само базата: сила 7, ловкост 4, защита 3. Уклонът е производен: ловкост × 2 = 8.
  2. Ризница: защитата е 3 + 4 = 7. Бонусът към уклона е -5, но stat() е 0; суровото число остава и уклонът пада на 3.
  3. Пръстенът е в друг слот и не изгонва ризницата: слотовете се сумират. Сила 7 + 2 = 9.
  4. Яростта: +3 към силата, −2 към защитата. effectBonus събира ефектите: сила 12, защита 5. Изтече ли, слоят изчезва.
  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;

В играта е същото, с още няколко производни числа: точност, шанс за критичен удар и „тежка рана“ (под 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"));
Отговор

Пробвай

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

Колега „опрости“ един ред и сега героят с ярост и щит има странни числа. Двата ефекта се слагат един след друг, а програмата печата това:

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. Ето трите метода, които смятат слоевете. Кой ред е виновен?

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

По-точно

Кога клас, кога ред

Когато в играта се появи нещо ново, питай: различава ли се по числа или по поведение?

  • Нов враг с други живот, сила и защита, имунитет към огън, друго настроение: ред в таблицата;
  • нов предмет с модификатори: ред в таблицата, а нов вид слот е ново име в списъка със слотове;
  • ефект, който само сменя числа (ярост, щит, слабост): ред в таблицата;
  • ефект, който прави нещо всеки ход (отрова, горене, лекуване): метод, затова в канона е малък клас;
  • враг, който решава как да ходи (шаманът призовава, вълкът бяга): поведение, значи код. В канона това са умения и крайни автомати, не наследници на 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 chain
48/48 | сила 7 | защита 7 | ловкост 4 | уклон 3
> equip amulet_bear
48/54 | сила 8 | защита 7 | ловкост 3 | уклон 1
> heal
54/54 | сила 8 | защита 7 | ловкост 3 | уклон 1
> buff rage
54/54 | сила 11 | защита 5 | ловкост 3 | уклон 1
> remove amulet
48/48 | сила 10 | защита 5 | ловкост 4 | уклон 3
> remove armor
48/48 | сила 10 | защита 1 | ловкост 4 | уклон 8

Обърни внимание на heal и на remove amulet: с амулета максимумът е 54 и heal пълни до 54, а remove amulet връща живота в границата 48.

Още задачи

  • Нов враг без код. Добави вълк кръвник от канона (20 живот, 3 сила, 8 ловкост, 1 защита, без имунитет) само като ред в таблицата на враговете. Колко е неговият уклон?
  • Втори пръстен. Направи слотовете ring1 и ring2. Какво става с ring_strength, ако го сложиш и в двата? Е ли правилно?
  • Ефект със срок. Добави на ефекта брой ходове и метод tick(), който ги намалява и маха ефекта на нула. Какво трябва да направи stat() след изтичането?

Тест

Провери се

1. В играта има нов враг: същият като гоблина, но с 30 живот вместо 16. Какво се прави?
2. Героят с 3 защита облича ризница (+4 защита) и после му слагат ярост (−2 защита). Колко е stat("defense")?
3. Героят е с ловкост 4 и ризница с бонус към уклона −5. Колко е evasion()?
4. Защо в Creature няма поле „защита с екипировката“, което да се обновява при обличане?
5. Защо Hero.gearBonus презаписва метода на Creature, а не просто има свое поле?

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