Към съдържанието
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 го разделя на редове и клетки, като пропуска празните редове и бележките с #:

@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:2
chain|ризница|armor|defense:4,evasion:-5
ring_strength|пръстен на силата|ring|strength:2
ring_life|пръстен на живота|ring|maxHp:10
"""
EFFECTS_TEXT = """
# id|име|модификатори
rage|ярост|strength:3,defense:-2
shield|щит|defense:3
weak|слабост|strength:-4
"""
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
"""
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 = 0

Creature има карта с показателите и списък с ефекти, а не е гоблин или скелет. Това е композиция: сглобяваш същество от части, а не го наследяваш от по-общо същество. Враг от таблицата просто пълни базата от своя ред:

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 = False
def 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()
Стъпка по стъпка, с думи
  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, база + екипировка + ефекти). Не се пази, затова не се разсинхронизира.
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

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

Пробвай

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

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

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

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

По-точно

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

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

  • Нов враг с други живот, сила и защита, имунитет към огън, друго настроение: ред в таблицата;
  • нов предмет с модификатори: ред в таблицата, а нов вид слот е ново име в списъка със слотове;
  • ефект, който само сменя числа (ярост, щит, слабост): ред в таблицата;
  • ефект, който прави нещо всеки ход (отрова, горене, лекуване): метод, затова в канона е малък клас;
  • враг, който решава как да ходи (шаманът призовава, вълкът бяга): поведение, значи код. В канона това са умения и крайни автомати, не наследници на 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 за достъпно обучение по програмиране ·