Герои, врагове, показатели
Накратко: Едно същество за всички: героят и враговете се различават по данни (редове в таблица) и по два метода, а всеки показател е сбор от слоеве база + екипировка + ефекти, който се смята при всяко питане.
Проблем
Десет врага, три класа, един въпрос
В играта има десет вида врагове и три класа герои. Всички имат живот, сила, ловкост и защита, а при удар се питат едни и същи числа. Как се пише такова нещо?
Първата идея на всеки, който е учил наследяване, е клас за всеки вид: 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 го разделя на редове и клетки, като пропуска празните редове и бележките с #:
const STATS = ["maxHp", "strength", "agility", "defense", "evasion"];const SLOTS = ["armor", "ring"];
const ITEMS_TEXT = `# id|име|слот|модификаториleather|кожена броня|armor|defense:2chain|ризница|armor|defense:4,evasion:-5ring_strength|пръстен на силата|ring|strength:2ring_life|пръстен на живота|ring|maxHp:10`;
const EFFECTS_TEXT = `# id|име|модификаториrage|ярост|strength:3,defense:-2shield|щит|defense:3weak|слабост|strength:-4`;
const 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`;
function rows(table) { const result = []; for (const line of table.split("\n")) { const text = line.trim(); if (text === "" || text.startsWith("#")) continue; result.push(text.split("|")); } return result;}
function parseMods(text) { const mods = {}; if (text === "-") return mods; for (const part of text.split(",")) { const [stat, value] = part.split(":"); mods[stat] = Number(value); } return mods;}
const ITEMS = {};for (const [id, name, slot, mods] of rows(ITEMS_TEXT)) { ITEMS[id] = { id, name, slot, mods: parseMods(mods) };}
const EFFECTS = {};for (const [id, name, mods] of rows(EFFECTS_TEXT)) { EFFECTS[id] = { id, name, mods: parseMods(mods) };}
const KINDS = {};for (const [id, name, hp, strength, agility, defense, immune] of rows(ENEMIES_TEXT)) { KINDS[id] = { id, name, hp: Number(hp), strength: Number(strength), agility: Number(agility), defense: Number(defense), immune: immune === "-" ? [] : immune.split(",") };}Object.values(KINDS) връща враговете в реда, в който са добавени.Идеята
Едно същество за всички
Различията между героя и враговете са малко. Затова има един клас Creature с всичко общо: име, живот, база (число за всеки показател) и списък с ефекти. Ето началото му:
class Creature { constructor(name) { this.name = name; this.base = {}; for (const stat of STATS) this.base[stat] = 0; this.effects = []; this.hp = 0; }Creature има карта с показателите и списък с ефекти, а не е гоблин или скелет. Това е композиция: сглобяваш същество от части, а не го наследяваш от по-общо същество. Враг от таблицата просто пълни базата от своя ред:
class Enemy extends Creature { constructor(kind) { super(kind.name); this.kind = kind; this.base.maxHp = kind.hp; this.base.strength = kind.strength; this.base.agility = kind.agility; this.base.defense = kind.defense; this.hp = kind.hp; }
// Имунитетът е данни: списък от реда в таблицата. isImmune(effectId) { return this.kind.immune.includes(effectId); }}А героят носи още нещо: екипировка.
class Hero extends Creature { constructor(name, maxHp, strength, agility, defense) { super(name); this.base.maxHp = maxHp; this.base.strength = strength; this.base.agility = agility; this.base.defense = defense; this.hp = maxHp; this.equipment = {}; for (const slot of SLOTS) this.equipment[slot] = ""; }Остава едно ниво наследяване: Creature → Hero и Enemy. Двата наследника подменят по един метод, защото само там поведението е различно: героят има gearBonus (екипировка), врагът има isImmune (имунитет от своя ред). Всичко останало, включително всичко в бойната система, пита Creature и не знае кого пита.
В канона е същото. Ето конструкторът на Enemy и неговият isImmune:
constructor(id, kind) { super(id, nm(kind)); this.kind = kind; this.base.set('maxHp', kind.hp); this.base.set('strength', kind.strength); this.base.set('agility', kind.agility); this.base.set('defense', kind.defense); this.base.set('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;}isImmune(effectId) { return this.kind.immune.indexOf(effectId) >= 0;}Враг от таблицата копира своите числа в base и пази препратка към реда си (kind). Имунитетът не е клас, а списък в този ред.
По-точно, за любопитните
Правилото „композиция вместо наследяване“ не казва „никога не наследявай“. Казва: наследявай за поведение (един метод е различен), сглобявай за състав (частите са различни). Тук Creature има общия скелет, а наследниците са два и тънки. Ако героите бяха пет раси с пет умения, като в RPG 1, наследяването щеше да е правилното.
В канона и ефектите са малки класове (PoisonEffect, RageEffect), защото отровата и горенето правят нещо всеки ход. Ефект, който само сменя числа, може да е и ред в таблица, както е в урока.
Виж го
Всички през един код
Героят и четиримата врагове влизат в един списък от Creature. Цикълът не пита кой е кой: вика maxHp, stat и evasion, а към враг, който е имунен към кръв, добавя бележка. После всички получават един и същ ефект.
const everyone = [new Hero("варварин", 48, 7, 4, 3)];for (const kind of Object.values(KINDS)) everyone.push(new Enemy(kind));
for (const c of everyone) { let line = c.name + ": живот " + c.maxHp() + ", сила " + c.stat("strength") + ", защита " + c.stat("defense") + ", уклон " + c.evasion(); if (c.isImmune("bleed")) line += ", имунитет към кръв и отрова"; console.log(line);}
console.log("Слабост за всички:");for (const c of everyone) { const before = c.stat("strength"); c.addEffect("weak"); console.log(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 от яростта). Не се записва, а се събира от списъка с ефекти.
// Ефектите се събират: два ефекта върху един показател се прибавят един към друг.effectBonus(stat) { let total = 0; for (const effect of this.effects) total += effect.mods[stat] ?? 0; return total;}
// Суровата стойност (може да е отрицателна): база + екипировка + ефекти.rawStat(stat) { return this.base[stat] + this.gearBonus(stat) + this.effectBonus(stat);}
// Показателят не пада под нула.stat(stat) { const value = this.rawStat(stat); return value < 0 ? 0 : value;}Общото се смята при всяко питане и не се пази никъде. Така няма как да се разсинхронизира. Сваляш ризницата и защитата пада, защото gearBonus не намира предмета. Изтича ефектът и силата се връща, защото effectBonus не намира ефекта. Нищо не „връща числото“. Ако записваше общата защита в самото същество, трябваше да помниш да я променяш на всяко събитие: облича, сваля, ефектът почва, ефектът изтича, героят вдигне ниво. Едно забравено място и защитата „полудява“.
Героят от програмата е варварин (48 живот, 7 сила, 4 ловкост, 3 защита) с ризница, пръстен на силата и ярост. Всеки ред е база +екипировка +ефекти = общо:
addEffect(id) { for (const effect of this.effects) { if (effect.id === id) return; } this.effects.push(EFFECTS[id]);}
// Ефектите се събират: два ефекта върху един показател се прибавят един към друг.effectBonus(stat) { let total = 0; for (const effect of this.effects) total += effect.mods[stat] ?? 0; return total;}
// Суровата стойност (може да е отрицателна): база + екипировка + ефекти.rawStat(stat) { return this.base[stat] + this.gearBonus(stat) + this.effectBonus(stat);}
// Показателят не пада под нула.stat(stat) { const value = this.rawStat(stat); return value < 0 ? 0 : value;}
gearBonus(stat) { let total = 0; for (const slot of SLOTS) { const id = this.equipment[slot]; if (id === "") continue; total += ITEMS[id].mods[stat] ?? 0; } return total;}
// Животът не надвишава новия максимум.equip(id) { this.equipment[ITEMS[id].slot] = id; if (this.hp > this.maxHp()) this.hp = this.maxHp();}Стъпка по стъпка, с думи
- В същество е записана само базата: сила 7, ловкост 4, защита 3. Уклонът е производен: ловкост × 2 = 8.
- Ризница: защитата е 3 + 4 = 7. Бонусът към уклона е -5, но stat() е 0; суровото число остава и уклонът пада на 3.
- Пръстенът е в друг слот и не изгонва ризницата: слотовете се сумират. Сила 7 + 2 = 9.
- Яростта: +3 към силата, −2 към защитата. effectBonus събира ефектите: сила 12, защита 5. Изтече ли, слоят изчезва.
- Общото се смята наново: stat() = max(0, база + екипировка + ефекти). Не се пази, затова не се разсинхронизира.
const hero = new Hero("варварин", 48, 7, 4, 3);console.log("-- Начало --");report(hero);hero.equip("chain");hero.equip("ring_strength");console.log("-- С ризница и пръстен на силата --");report(hero);hero.addEffect("rage");console.log("-- И с ярост --");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 не пада под нула:
// Показателят не пада под нула.stat(stat) { const value = this.rawStat(stat); return value < 0 ? 0 : value;}
maxHp() { const value = this.stat("maxHp"); return value < 1 ? 1 : value;}А уклонът в същото време трябва да знае, че ризницата тежи. Той не ползва stat, а сурово число (rawStat), което може да е отрицателно:
// Уклонът се смята от ловкостта и от суровия бонус (може да е отрицателен).evasion() { return this.stat("agility") * 2 + this.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% живот ударите са по-неточни):
// Суровата стойност (може да е отрицателна): база + екипировка + ефекти.rawStat(stat) { let value = this.base.get(stat) + this.gearBonus(stat); for (const effect of this.effects) value += effect.modifier(stat); return value;}
stat(stat) { const value = this.rawStat(stat); return value < 0 ? 0 : value;}
maxHp() { const value = this.stat('maxHp'); return value < 1 ? 1 : value;}
maxMana() { return this.stat('maxMana');}
// Под 25% живот раната е тежка: ударите стават по-неточни.isHeavilyWounded() { return this.hp * 4 < this.maxHp();}
accuracy() { return 75 + this.stat('agility') * 2 + this.rawStat('accuracy') - (this.isHeavilyWounded() ? 15 : 0);}
evasion() { return this.stat('agility') * 2 + this.rawStat('evasion');}
critChance() { return 5 + Math.floor(this.stat('agility') / 10);}Ето откъде идва rawStat в канона: той събира базата, gearBonus и всеки ефект с effect.modifier(stat). Урокът пази effectBonus отделно, за да се виждат трите слоя един по един.
Идеята
Героят носи, врагът не
Базовият Creature.gearBonus връща 0, защото врагът няма екипировка. Героят го подменя:
gearBonus(stat) { let total = 0; for (const slot of SLOTS) { const id = this.equipment[slot]; if (id === "") continue; total += ITEMS[id].mods[stat] ?? 0; } return total;}Героят пази само кой предмет е в кой слот (име → id). Самите числа са в таблицата на предметите. Затова една ризница е един ред данни, а не копие във всеки герой, и сменянето на баланса е смяна на число на едно място.
Когато предмет влезе или излезе, максималният живот може да се смени. Пръстенът на живота дава +10. Свалиш ли го, а героят е на 58 от 58, текущият живот остава 58 при максимум 48: нещо, което не бива да се случва. Затова equip и unequip връщат живота в границата:
// Животът не надвишава новия максимум.equip(id) { this.equipment[ITEMS[id].slot] = id; if (this.hp > this.maxHp()) this.hp = this.maxHp();}В канона е същото (gearBonus обхожда слотовете и сумира модификаторите):
gearBonus(stat) { let total = 0; for (const slot of this.equipment.keys()) { const itemId = this.equipment.get(slot); if (itemId === '') continue; for (const mod of ITEMS.get(itemId).mods) { if (mod.stat === stat) total += mod.value; } } return total;}
weapon() { const id = this.equipment.get('weapon'); return ITEMS.get(id === '' ? 'fists' : id);}По-точно, за любопитните
В канона героят има и трети слот, оръжие, а weapon() връща юмруци, когато слотът е празен, за да няма специален случай „без оръжие“. Така героят винаги има какво да удари. В урока слотовете са два, за да се виждат числата.
Предметите се държат по id (низ), не по препратка. Затова записът на играта е прост (запазва се „ring_life“, не обект) и един и същи предмет може да е на много места.
Пробвай
Предвиди изхода
Героят слага пръстена на живота (+10 максимален живот) и се лекува докрай. После го удря слабост (−4 сила) и той сваля пръстена. Какво печата програмата?
const hero = new Hero("варварин", 48, 7, 4, 3);hero.equip("ring_life");hero.hp = hero.maxHp();hero.addEffect("weak");hero.unequip("ring");console.log(hero.hp + "/" + hero.maxHp());console.log(hero.stat("strength"));Максимумът пада от 58 на 48, а unequip връща живота в границата: 48 / 48. Силата е 7 − 4 = 3: ефектът е слой, който не зависи от пръстена. Вариантът 58 / 48 забравя да върне живота, 7 забравя ефекта, а 58 / 58 забравя и двете.
Пробвай
Намери грешката
Колега „опрости“ един ред и сега героят с ярост и щит има странни числа. Двата ефекта се слагат един след друг, а програмата печата това:
const 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()след изтичането?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Полиморфизъм · Масиви: push, splice и sort · Map и Set · Данни, switch и разглобяване
- Задачи по темата: Хрониките на Арканум