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

const STATS = ["maxHp", "strength", "agility", "defense", "evasion"];
const SLOTS = ["armor", "ring"];
const ITEMS_TEXT = `
# id|име|слот|модификатори
leather|кожена броня|armor|defense:2
chain|ризница|armor|defense:4,evasion:-5
ring_strength|пръстен на силата|ring|strength:2
ring_life|пръстен на живота|ring|maxHp:10
`;
const EFFECTS_TEXT = `
# id|име|модификатори
rage|ярост|strength:3,defense:-2
shield|щит|defense:3
weak|слабост|strength:-4
`;
const 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
`;
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();
}
Стъпка по стъпка, с думи
  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, база + екипировка + ефекти). Не се пази, затова не се разсинхронизира.
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;

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

Пробвай

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

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

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

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

По-точно

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

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

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