Герои, врагове, показатели
Накратко: Едно същество за всички: героят и враговете се различават по данни (редове в таблица) и по два метода, а всеки показател е сбор от слоеве база + екипировка + ефекти, който се смята при всяко питане.
Проблем
Десет врага, три класа, един въпрос
В играта има десет вида врагове и три класа герои. Всички имат живот, сила, ловкост и защита, а при удар се питат едни и същи числа. Как се пише такова нещо?
Първата идея на всеки, който е учил наследяване, е клас за всеки вид: 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 го разделя на редове и клетки, като пропуска празните редове и бележките с #:
@dataclass(frozen=True)class Item: id: str name: str slot: str mods: dict
@dataclass(frozen=True)class Effect: id: str name: str mods: dict
@dataclass(frozen=True)class Kind: id: str name: str hp: int strength: int agility: int defense: int immune: list
STATS = ["maxHp", "strength", "agility", "defense", "evasion"]SLOTS = ["armor", "ring"]
ITEMS_TEXT = """# id|име|слот|модификаториleather|кожена броня|armor|defense:2chain|ризница|armor|defense:4,evasion:-5ring_strength|пръстен на силата|ring|strength:2ring_life|пръстен на живота|ring|maxHp:10"""
EFFECTS_TEXT = """# id|име|модификаториrage|ярост|strength:3,defense:-2shield|щит|defense:3weak|слабост|strength:-4"""
ENEMIES_TEXT = """# id|име|живот|сила|ловкост|защита|имунитетrat|гигантски плъх|9|1|6|0|-goblin|гоблин копиеносец|16|2|5|1|-skeleton|скелет|28|4|4|2|bleed,poisongolem|голем от руда|46|6|1|4|bleed,poison"""
def rows(table): result = [] for line in table.split("\n"): text = line.strip() if text == "" or text.startswith("#"): continue result.append(text.split("|")) return result
def parse_mods(text): mods = {} if text == "-": return mods for part in text.split(","): stat, value = part.split(":") mods[stat] = int(value) return mods
ITEMS = {}for row in rows(ITEMS_TEXT): ITEMS[row[0]] = Item(row[0], row[1], row[2], parse_mods(row[3]))
EFFECTS = {}for row in rows(EFFECTS_TEXT): EFFECTS[row[0]] = Effect(row[0], row[1], parse_mods(row[2]))
KINDS = {}for row in rows(ENEMIES_TEXT): immune = [] if row[6] == "-" else row[6].split(",") KINDS[row[0]] = Kind(row[0], row[1], int(row[2]), int(row[3]), int(row[4]), int(row[5]), immune)@dataclass(frozen=True) прави записа: полетата се дават в конструктора и не се сменят. dict държи модификаторите.Идеята
Едно същество за всички
Различията между героя и враговете са малко. Затова има един клас Creature с всичко общо: име, живот, база (число за всеки показател) и списък с ефекти. Ето началото му:
class Creature: def __init__(self, name): self.name = name self.base = {} for stat in STATS: self.base[stat] = 0 self.effects = [] self.hp = 0Creature има карта с показателите и списък с ефекти, а не е гоблин или скелет. Това е композиция: сглобяваш същество от части, а не го наследяваш от по-общо същество. Враг от таблицата просто пълни базата от своя ред:
class Enemy(Creature): def __init__(self, kind): super().__init__(kind.name) self.kind = kind self.base["maxHp"] = kind.hp self.base["strength"] = kind.strength self.base["agility"] = kind.agility self.base["defense"] = kind.defense self.hp = kind.hp
# Имунитетът е данни: списък от реда в таблицата. def is_immune(self, effect_id): return effect_id in self.kind.immuneА героят носи още нещо: екипировка.
class Hero(Creature): def __init__(self, name, max_hp, strength, agility, defense): super().__init__(name) self.base["maxHp"] = max_hp self.base["strength"] = strength self.base["agility"] = agility self.base["defense"] = defense self.hp = max_hp self.equipment = {} for slot in SLOTS: self.equipment[slot] = ""Остава едно ниво наследяване: Creature → Hero и Enemy. Двата наследника подменят по един метод, защото само там поведението е различно: героят има gearBonus (екипировка), врагът има isImmune (имунитет от своя ред). Всичко останало, включително всичко в бойната система, пита Creature и не знае кого пита.
В канона е същото. Ето конструкторът на Enemy и неговият isImmune:
def __init__(self, creature_id, kind): super().__init__(creature_id, nm(kind)) self.kind = kind self.base['maxHp'] = kind.hp self.base['strength'] = kind.strength self.base['agility'] = kind.agility self.base['defense'] = kind.defense self.base['speed'] = kind.speed self.hp = kind.hp self.state = kind.mood self.calm = 0 self.lost_turns = 0 self.phase = 1 self.dead = False self.summoned = False self.noticed = Falsedef is_immune(self, effect_id): return effect_id in self.kind.immuneВраг от таблицата копира своите числа в base и пази препратка към реда си (kind). Имунитетът не е клас, а списък в този ред.
По-точно, за любопитните
Правилото „композиция вместо наследяване“ не казва „никога не наследявай“. Казва: наследявай за поведение (един метод е различен), сглобявай за състав (частите са различни). Тук Creature има общия скелет, а наследниците са два и тънки. Ако героите бяха пет раси с пет умения, като в RPG 1, наследяването щеше да е правилното.
В канона и ефектите са малки класове (PoisonEffect, RageEffect), защото отровата и горенето правят нещо всеки ход. Ефект, който само сменя числа, може да е и ред в таблица, както е в урока.
Виж го
Всички през един код
Героят и четиримата врагове влизат в един списък от Creature. Цикълът не пита кой е кой: вика maxHp, stat и evasion, а към враг, който е имунен към кръв, добавя бележка. После всички получават един и същ ефект.
everyone = [Hero("варварин", 48, 7, 4, 3)]for kind in KINDS.values(): everyone.append(Enemy(kind))
for c in everyone: line = c.name + ": живот " + str(c.max_hp()) + ", сила " + str(c.stat("strength")) + ", защита " + str(c.stat("defense")) + ", уклон " + str(c.evasion()) if c.is_immune("bleed"): line += ", имунитет към кръв и отрова" print(line)
print("Слабост за всички:")for c in everyone: before = c.stat("strength") c.add_effect("weak") print(c.name + ": сила " + str(before) + " -> " + str(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 от яростта). Не се записва, а се събира от списъка с ефекти.
# Ефектите се събират: два ефекта върху един показател се прибавят един към друг.def effect_bonus(self, stat): total = 0 for effect in self.effects: total += effect.mods.get(stat, 0) return total
# Суровата стойност (може да е отрицателна): база + екипировка + ефекти.def raw_stat(self, stat): return self.base[stat] + self.gear_bonus(stat) + self.effect_bonus(stat)
# Показателят не пада под нула.def stat(self, stat): value = self.raw_stat(stat) return 0 if value < 0 else valueОбщото се смята при всяко питане и не се пази никъде. Така няма как да се разсинхронизира. Сваляш ризницата и защитата пада, защото gearBonus не намира предмета. Изтича ефектът и силата се връща, защото effectBonus не намира ефекта. Нищо не „връща числото“. Ако записваше общата защита в самото същество, трябваше да помниш да я променяш на всяко събитие: облича, сваля, ефектът почва, ефектът изтича, героят вдигне ниво. Едно забравено място и защитата „полудява“.
Героят от програмата е варварин (48 живот, 7 сила, 4 ловкост, 3 защита) с ризница, пръстен на силата и ярост. Всеки ред е база +екипировка +ефекти = общо:
def add_effect(self, effect_id): for effect in self.effects: if effect.id == effect_id: return self.effects.append(EFFECTS[effect_id])
# Ефектите се събират: два ефекта върху един показател се прибавят един към друг.def effect_bonus(self, stat): total = 0 for effect in self.effects: total += effect.mods.get(stat, 0) return total
# Суровата стойност (може да е отрицателна): база + екипировка + ефекти.def raw_stat(self, stat): return self.base[stat] + self.gear_bonus(stat) + self.effect_bonus(stat)
# Показателят не пада под нула.def stat(self, stat): value = self.raw_stat(stat) return 0 if value < 0 else value
def gear_bonus(self, stat): total = 0 for slot in SLOTS: item_id = self.equipment[slot] if item_id == "": continue total += ITEMS[item_id].mods.get(stat, 0) return total
# Животът не надвишава новия максимум.def equip(self, item_id): self.equipment[ITEMS[item_id].slot] = item_id if self.hp > self.max_hp(): self.hp = self.max_hp()Стъпка по стъпка, с думи
- В същество е записана само базата: сила 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("варварин", 48, 7, 4, 3)print("-- Начало --")report(hero)hero.equip("chain")hero.equip("ring_strength")print("-- С ризница и пръстен на силата --")report(hero)hero.add_effect("rage")print("-- И с ярост --")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 не пада под нула:
# Показателят не пада под нула.def stat(self, stat): value = self.raw_stat(stat) return 0 if value < 0 else value
def max_hp(self): value = self.stat("maxHp") return 1 if value < 1 else valueА уклонът в същото време трябва да знае, че ризницата тежи. Той не ползва stat, а сурово число (rawStat), което може да е отрицателно:
# Уклонът се смята от ловкостта и от суровия бонус (може да е отрицателен).def evasion(self): return self.stat("agility") * 2 + self.raw_stat("evasion")С ловкост 4 уклонът е 4 × 2 = 8. Ризницата сваля 5, но stat("evasion") е 0, а уклонът е 8 − 5 = 3. Ако уклонът ползваше stat("evasion"), ризницата нямаше да струва нищо.
maxHp има свое правило: не пада под 1. Същество с 0 максимален живот е мъртво още щом се роди, а делението на максималния живот (например лентата в интерфейса) би се счупило.
return if value < 0 else valueПоказателят не пада под нула: ако сборът е отрицателен, stat връща 0. Суровото число се пази за уклона, за да не се губи, че ризницата тежи.
В играта е същото, с още няколко производни числа: точност, шанс за критичен удар и „тежка рана“ (под 25% живот ударите са по-неточни):
# Суровата стойност (може да е отрицателна): база + екипировка + ефекти.def raw_stat(self, stat): value = self.base[stat] + self.gear_bonus(stat) for effect in self.effects: value += effect.modifier(stat) return value
def stat(self, stat): value = self.raw_stat(stat) return 0 if value < 0 else value
def max_hp(self): value = self.stat('maxHp') return 1 if value < 1 else value
def max_mana(self): return self.stat('maxMana')
# Под 25% живот раната е тежка: ударите стават по-неточни.def is_heavily_wounded(self): return self.hp * 4 < self.max_hp()
def accuracy(self): return 75 + self.stat('agility') * 2 + self.raw_stat('accuracy') - (15 if self.is_heavily_wounded() else 0)
def evasion(self): return self.stat('agility') * 2 + self.raw_stat('evasion')
def crit_chance(self): return 5 + self.stat('agility') // 10Ето откъде идва rawStat в канона: той събира базата, gearBonus и всеки ефект с effect.modifier(stat). Урокът пази effectBonus отделно, за да се виждат трите слоя един по един.
Идеята
Героят носи, врагът не
Базовият Creature.gearBonus връща 0, защото врагът няма екипировка. Героят го подменя:
def gear_bonus(self, stat): total = 0 for slot in SLOTS: item_id = self.equipment[slot] if item_id == "": continue total += ITEMS[item_id].mods.get(stat, 0) return totalГероят пази само кой предмет е в кой слот (име → id). Самите числа са в таблицата на предметите. Затова една ризница е един ред данни, а не копие във всеки герой, и сменянето на баланса е смяна на число на едно място.
Когато предмет влезе или излезе, максималният живот може да се смени. Пръстенът на живота дава +10. Свалиш ли го, а героят е на 58 от 58, текущият живот остава 58 при максимум 48: нещо, което не бива да се случва. Затова equip и unequip връщат живота в границата:
# Животът не надвишава новия максимум.def equip(self, item_id): self.equipment[ITEMS[item_id].slot] = item_id if self.hp > self.max_hp(): self.hp = self.max_hp()В канона е същото (gearBonus обхожда слотовете и сумира модификаторите):
def gear_bonus(self, stat): total = 0 for slot in self.equipment.keys(): item_id = self.equipment[slot] if item_id == '': continue for mod in ITEMS[item_id].mods: if mod.stat == stat: total += mod.value return total
def weapon(self): weapon_id = self.equipment['weapon'] return ITEMS['fists' if weapon_id == '' else weapon_id]По-точно, за любопитните
В канона героят има и трети слот, оръжие, а weapon() връща юмруци, когато слотът е празен, за да няма специален случай „без оръжие“. Така героят винаги има какво да удари. В урока слотовете са два, за да се виждат числата.
Предметите се държат по id (низ), не по препратка. Затова записът на играта е прост (запазва се „ring_life“, не обект) и един и същи предмет може да е на много места.
Пробвай
Предвиди изхода
Героят слага пръстена на живота (+10 максимален живот) и се лекува докрай. После го удря слабост (−4 сила) и той сваля пръстена. Какво печата програмата?
hero = Hero("варварин", 48, 7, 4, 3)hero.equip("ring_life")hero.hp = hero.max_hp()hero.add_effect("weak")hero.unequip("ring")print(str(hero.hp) + "/" + str(hero.max_hp()))print(hero.stat("strength"))Максимумът пада от 58 на 48, а unequip връща живота в границата: 48 / 48. Силата е 7 − 4 = 3: ефектът е слой, който не зависи от пръстена. Вариантът 58 / 48 забравя да върне живота, 7 забравя ефекта, а 58 / 58 забравя и двете.
Пробвай
Намери грешката
Колега „опрости“ един ред и сега героят с ярост и щит има странни числа. Двата ефекта се слагат един след друг, а програмата печата това:
hero = Hero("варварин", 48, 7, 4, 3)hero.add_effect("rage")hero.add_effect("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()след изтичането?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Полиморфизъм · Списъци: append, remove и sort · dict и set · dataclass, match и шаблони
- Задачи по темата: Хрониките на Арканум