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

Нива, диалози и куестове

Накратко: Опитът се смята по таблица, а новите нива и умения се дават едно по едно; боят и картата само публикуват събития в шина, а куестовете се абонират и са крайни автомати; диалогът е дърво, в което условията крият ненужните отговори.

Проблем

Вълкът, който не знае, че има куест

Досега героят убиваше врагове и това беше всичко. Сега играта иска да се случи още нещо: убийството на вълка водач да затвори куест, убийството на Пазача да даде победата, влизането на второ ниво да мести друг куест напред, а опитът да вдигне нивото. Най-простият начин е да го запишеш там, където врагът умира:

void OnEnemyKilled(Enemy enemy)
{
hero.Xp += enemy.Xp;
// Боят знае за всеки, който се интересува от смъртта на враг.
quests.EnemyKilled(enemy);
achievements.EnemyKilled(enemy);
// Нов слушател: още един ред тук и още една промяна в боя.
stats.Kills++;
}

Тук боят знае за куестовете, постиженията и статистиката. Нов куест е нов ред в боя, а боят вече има много причини да се променя. Още по-лошо: всеки нов слушател се добавя в чужд код, и всяка забравена връзка е куест, който никога не приключва.

Решението е да се обърне посоката на знанието. Боят казва на едно място „врагът умря“ и не се интересува кой слуша. Който иска да знае, се записва (абонира). Това е шина от събития. Урокът строи пет неща, по реда, в който играта ги ползва: опит и нива (крива като таблица), дърво от умения, шина от събития, куест като краен автомат и диалог като дърво с условия.

Така изглежда същото място, когато има шина. Боят публикува един ред и толкова:

void OnEnemyKilled(Enemy enemy)
{
hero.Xp += enemy.Xp;
bus.Publish(new GameEvent("EnemyKilled", enemy.Kind));
}

Идеята

Опит и нива: кривата е таблица

Колко опит иска всяко ниво? Формула (например 100 × ниво²) е гладка, но за настройка на играта е неудобна: искаш да кажеш „ниво 3 е след около шест врага“, не да пресмяташ. Затова кривата е таблица: XP_TABLE[n] е общият опит, с който се стига ниво n. Над края на таблицата кривата продължава с още 150 на ниво, за да не гърми играта на ниво 11.

Героят има xp и level. На всяко убийство xp расте, а pendingLevels казва колко нива чакат: колко от следващите прагове опитът вече е стигнал. Новото ниво е отделна стъпка (raiseLevel): повече живот. Програмата убива седем врага от играта, всеки с опита си (плъх 5, гоблин 9, ... Пазачът 150), и пише всяко ново ниво:

class Hero
{
public int Level = 1;
public int Xp = 0;
public int MaxHp = 48;
}
record Kill(string Name, int Xp);
// XP_TABLE[n] е общият опит, с който се стига ниво n.
int[] xpTable = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720];
int XpForLevel(int level)
{
if (level < xpTable.Length) return xpTable[level];
// След края на таблицата кривата продължава с 150 на ниво.
return xpTable[xpTable.Length - 1] + (level - xpTable.Length + 1) * 150;
}
// Колко нива чакат: опитът стига ли до следващите прагове.
int PendingLevels(int xp, int level)
{
int levels = 0;
while (xp >= XpForLevel(level + levels + 1)) levels++;
return levels;
}
void RaiseLevel(Hero hero)
{
hero.Level += 1;
hero.MaxHp += 9;
}
Kill[] kills = [new Kill("плъх", 5), new Kill("гоблин", 9), new Kill("шаман", 12), new Kill("вълк", 14), new Kill("култист", 20), new Kill("голем", 32), new Kill("Пазач", 150)];
var hero = new Hero();
foreach (var kill in kills)
{
hero.Xp += kill.Xp;
Console.WriteLine(kill.Name + " +" + kill.Xp + ": опит " + hero.Xp);
int raise = PendingLevels(hero.Xp, hero.Level);
for (int i = 0; i < raise; i++)
{
int before = hero.MaxHp;
RaiseLevel(hero);
Console.WriteLine(" ниво " + hero.Level + ": живот " + before + " -> " + hero.MaxHp);
}
}
for (int level = 10; level <= 12; level++)
{
Console.WriteLine("ниво " + level + ": опит " + XpForLevel(level));
}
Изход
плъх +5: опит 5
гоблин +9: опит 14
шаман +12: опит 26
ниво 2: живот 48 -> 57
вълк +14: опит 40
култист +20: опит 60
ниво 3: живот 57 -> 66
голем +32: опит 92
Пазач +150: опит 242
ниво 4: живот 66 -> 75
ниво 5: живот 75 -> 84
ниво 10: опит 720
ниво 11: опит 870
ниво 12: опит 1020

Пазачът (+150) дава две нива наведнъж, 4 и 5. Това показва защо pendingLevels е цикъл, а не сравнение: един голям удар може да прескочи праг. Последните три реда показват опашката на таблицата: ниво 10 е в нея (720), а 11 и 12 идват от + 150 на ниво (870, 1020).

// XP_TABLE[n] е общият опит, с който се стига ниво n.
int[] xpTable = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720];

int XpForLevel(int level)
{
    if (level < xpTable.Length) return xpTable[level];
    // След края на таблицата кривата продължава с 150 на ниво.
    return xpTable[xpTable.Length - 1] + (level - xpTable.Length + 1) * 150;
}

// Колко нива чакат: опитът стига ли до следващите прагове.
int PendingLevels(int xp, int level)
{
    int levels = 0;
    while (xp  XpForLevel(level + levels + 1)) levels++;
    return levels;
}
По-точно, за любопитните

Защо таблица, а не формула? Таблицата е данни: играч с по-бавен темп, ново ниво или друг режим са друга таблица, не друг код. Канонът я държи като число, разделено с | (0|0|20|55|105|170|250|345|455|580|720), и я чете при старт заедно с предметите и враговете. Първите две нули са за нива 0 и 1: героят започва на ниво 1.

Кривата се настройва със симулацията от последния урок: пускат се хиляди игри и се гледа на кое ниво героят стига до какъв враг.

Идеята

Дървото на уменията: две пътеки по три стъпки

На всяко ново ниво играчът избира: показател (+8 живот, +1 сила, +1 ловкост, +1 защита) или ново умение. Уменията на един клас са дърво с два клона по три стъпки. Правилото е кратко: умение е достъпно, ако вече не е научено и е или стъпка 1, или в същия клон е научена стъпка с едно по-ниска.

record Skill(string Id, int Branch, int Tier);
Skill[] allSkills = [
new Skill("rage", 1, 1),
new Skill("whirlwind", 1, 2),
new Skill("bloodlust", 1, 3),
new Skill("war_cry", 2, 1),
new Skill("iron_skin", 2, 2),
new Skill("second_wind", 2, 3)];
string[] order = ["rage", "war_cry", "whirlwind", "iron_skin", "bloodlust", "second_wind"];
List<string> Learnable(Skill[] skills, List<string> known)
{
var result = new List<string>();
foreach (var skill in skills)
{
if (known.Contains(skill.Id)) continue;
bool ready = skill.Tier == 1;
foreach (var other in skills)
{
if (known.Contains(other.Id) && other.Branch == skill.Branch && other.Tier == skill.Tier - 1) ready = true;
}
if (ready) result.Add(skill.Id);
}
return result;
}
string Show(List<string> list)
{
return list.Count == 0 ? "-" : string.Join(", ", list);
}

Програмата учи уменията на варварина в зададен ред и след всяко пише какво е достъпно. Празният списък е -.

var known = new List<string>();
Console.WriteLine("начало -> " + Show(Learnable(allSkills, known)));
foreach (var id in order)
{
known.Add(id);
Console.WriteLine(id + " -> " + Show(Learnable(allSkills, known)));
}
Изход
начало -> rage, war_cry
rage -> whirlwind, war_cry
war_cry -> whirlwind, iron_skin
whirlwind -> bloodlust, iron_skin
iron_skin -> bloodlust, second_wind
bloodlust -> second_wind
second_wind -> -

В началото достъпни са само двете начала: rage и war_cry. Научаването на rage отваря whirlwind, но bloodlust още чака (иска whirlwind), а клонът на war_cry не се променя. Накрая second_wind -> -: дървото е изчерпано и в избора за ново ниво остават само показателите.

Идеята

Шина от събития: кой публикува, не знае кой слуша

Шината е най-простото нещо: речник от тип на събитие към списък от функции. subscribe(type, handler) слага функция в списъка за този тип. publish(event) вика всички функции от списъка за типа на събитието. Нищо друго.

Събитието е малък запис: тип и данни. Тук то е type, id (врагът, предметът или диалогът) и depth (нивото). Такива са EnemyKilled (врагът умря), TileEntered (героят е влязъл на ниво), DialogChoice (избран е отговор) и ItemPicked (взет е предмет).

record GameEvent(string Type, string Id = "", int Depth = 0)
{
public string Label => Id == "" ? Depth.ToString() : Id;
}
class EventBus
{
readonly Dictionary<string, List<Action<GameEvent>>> handlers = new();
public void Subscribe(string type, Action<GameEvent> handler)
{
if (!handlers.ContainsKey(type)) handlers[type] = new List<Action<GameEvent>>();
handlers[type].Add(handler);
}
public void Publish(GameEvent e)
{
if (!handlers.ContainsKey(e.Type)) return;
// Копие на списъка: слушател може да се абонира или да се отпише по време на публикуването.
foreach (var handler in new List<Action<GameEvent>>(handlers[e.Type])) handler(e);
}
}

Първата програма слага двама слушатели на EnemyKilled (запис и броячът на убитите) и един на TileEntered. На ItemPicked няма никой и шината просто не прави нищо.

var bus = new EventBus();
var stats = new Stats();
bus.Subscribe("EnemyKilled", e => Log(e));
bus.Subscribe("TileEntered", e => Log(e));
bus.Subscribe("EnemyKilled", e =>
{
stats.Kills++;
Console.WriteLine("убити: " + stats.Kills);
});
Fire(bus, new GameEvent("EnemyKilled", "rat"));
Fire(bus, new GameEvent("TileEntered", Depth: 2));
Fire(bus, new GameEvent("EnemyKilled", "wolf"));
Fire(bus, new GameEvent("ItemPicked", "ore"));
Изход
> EnemyKilled rat
запис: EnemyKilled rat
убити: 1
> TileEntered 2
запис: TileEntered 2
> EnemyKilled wolf
запис: EnemyKilled wolf
убити: 2
> ItemPicked ore

Всеки ред с > е публикуване, а редовете под него са слушателите, които са се обадили, в реда на абониране. След EnemyKilled rat се обаждат и двамата, след TileEntered 2 само записът, а ItemPicked ore не пробужда никого. Който публикува, не е променил нито ред, когато са се появили слушателите.

По-точно, за любопитните

Този шаблон се казва Observer (наблюдател) или publish/subscribe. В UI светът му е addEventListener, в сървърите са опашки от съобщения. Играта ползва най-простата форма: всичко в една нишка, слушателите се викат веднага, един след друг. Затова слушател, който прави нещо тежко, забавя целия ход. Слушателите се викат в реда на абониране, но не бива да се разчита на него (в канона никой слушател не зависи от друг).

publish обхожда копие на списъка. Куест, който започва нов куест, може да се абонира за същия тип по време на публикуването; без копие в Java и C# това хвърля изключение, а в JavaScript новият слушател би бил извикан още в същото публикуване.

Идеята

Куестът е краен автомат

Куестът има състояние: inactive (още не е даден), active (с етап 1, 2, 3), done (изпълнен) и rewarded (наградата е взета). Преходите не ги прави играчът, а събитията. Затова куестът сам се абонира за тези, които го движат, и държи цялата си логика на едно място.

От Към Кога
inactive active, етап 1 старейшината дава куеста (действие start от диалога)
active, етап 1 етап 2 TileEntered с depth 2
active, етап под 3 етап 3 DialogChoice miner:warden
active done EnemyKilled warden
done rewarded диалогът изпълнява действие reward

Основата Quest е общата част: заглавие, списък с цели по етапи и три метода, които сменят състоянието и пишат на играча. MinersQuest („Изчезналите миньори“) е подклас, който само се абонира:

class Quest
{
public readonly string Id;
public readonly string Title;
public readonly string[] Goals;
public readonly string DoneGoal;
public string State = "inactive";
public int Stage = 0;
public Quest(string id, string title, string[] goals, string doneGoal)
{
Id = id;
Title = title;
Goals = goals;
DoneGoal = doneGoal;
}
public virtual string Goal()
{
if (State == "done") return DoneGoal;
return Goals[Stage - 1];
}
public void Start()
{
State = "active";
Stage = 1;
Console.WriteLine("Нов куест: „" + Title + "“.");
}
public void Advance(int newStage)
{
Stage = newStage;
Console.WriteLine("„" + Title + "“: " + Goal());
}
public void Complete()
{
State = "done";
Console.WriteLine("Куестът „" + Title + "“ е изпълнен!");
}
}
class MinersQuest : Quest
{
public MinersQuest() : base("miners", "Изчезналите миньори", ["Слез в мината и потърси миньорите.", "Някой е жив на второто ниво. Намери го.", "Победи Пазача на дълбините на третото ниво."], "Пазачът е победен. Сивия брод е спасен.")
{
}
public void Subscribe(EventBus bus)
{
bus.Subscribe("TileEntered", e =>
{
if (State == "active" && Stage == 1 && e.Depth == 2) Advance(2);
});
bus.Subscribe("DialogChoice", e =>
{
if (State == "active" && Stage < 3 && e.Id == "miner:warden") Advance(3);
});
bus.Subscribe("EnemyKilled", e =>
{
if (State == "active" && e.Id == "warden") Complete();
});
}
}

Всеки слушател започва с state == "active": шината не знае в какво състояние е куестът, затова въпросът е негов. Второто условие (stage == 1, stage < 3, id == ...) казва кое точно събитие движи точно този преход. Основата е нарочно без subscribe; в канона я има празна, за да може играта да обходи всички куестове и да викне subscribe на всеки.

Сценарият има осем стъпки, четири от които куестът игнорира. Старейшината дава куеста на третата, а стъпките са точно тези, които показва сцената по-долу.

var bus = new EventBus();
var quest = new MinersQuest();
quest.Subscribe(bus);
Fire(bus, new GameEvent("EnemyKilled", "rat"));
Fire(bus, new GameEvent("TileEntered", Depth: 2));
Console.WriteLine("> старейшината дава куеста");
quest.Start();
Fire(bus, new GameEvent("TileEntered", Depth: 1));
Fire(bus, new GameEvent("TileEntered", Depth: 2));
Fire(bus, new GameEvent("DialogChoice", "miner:warden"));
Fire(bus, new GameEvent("TileEntered", Depth: 2));
Fire(bus, new GameEvent("EnemyKilled", "warden"));
Изход
> EnemyKilled rat
> TileEntered 2
> старейшината дава куеста
Нов куест: „Изчезналите миньори“.
> TileEntered 1
> TileEntered 2
„Изчезналите миньори“: Някой е жив на второто ниво. Намери го.
> DialogChoice miner:warden
„Изчезналите миньори“: Победи Пазача на дълбините на третото ниво.
> TileEntered 2
> EnemyKilled warden
Куестът „Изчезналите миньори“ е изпълнен!

Виж го

Събитията вървят към куеста

Горе е пътят на куеста с топче, което стои на състоянието му. Всяко събитие е плочка: вдига се до шината, отива до куеста и куестът или се мести напред, или трепва и казва „игнорира“. Вижда се кои четири от осемте събития не движат нищо и защо: куестът още не е започнал, нивото е друго, а при едното етапът вече е изминат.

class EventBus
{
readonly Dictionary<string, List<Action<GameEvent>>> handlers = new();
public void Subscribe(string type, Action<GameEvent> handler)
{
if (!handlers.ContainsKey(type)) handlers[type] = new List<Action<GameEvent>>();
handlers[type].Add(handler);
}
public void Publish(GameEvent e)
{
if (!handlers.ContainsKey(e.Type)) return;
// Копие на списъка: слушател може да се абонира или да се отпише по време на публикуването.
foreach (var handler in new List<Action<GameEvent>>(handlers[e.Type])) handler(e);
}
}
class Quest
{
public readonly string Id;
public readonly string Title;
public readonly string[] Goals;
public readonly string DoneGoal;
public string State = "inactive";
public int Stage = 0;
public Quest(string id, string title, string[] goals, string doneGoal)
{
Id = id;
Title = title;
Goals = goals;
DoneGoal = doneGoal;
}
public virtual string Goal()
{
if (State == "done") return DoneGoal;
return Goals[Stage - 1];
}
public void Start()
{
State = "active";
Stage = 1;
Console.WriteLine("Нов куест: „" + Title + "“.");
}
public void Advance(int newStage)
{
Stage = newStage;
Console.WriteLine("„" + Title + "“: " + Goal());
}
public void Complete()
{
State = "done";
Console.WriteLine("Куестът „" + Title + "“ е изпълнен!");
}
}
class MinersQuest : Quest
{
public MinersQuest() : base("miners", "Изчезналите миньори", ["Слез в мината и потърси миньорите.", "Някой е жив на второто ниво. Намери го.", "Победи Пазача на дълбините на третото ниво."], "Пазачът е победен. Сивия брод е спасен.")
{
}
public void Subscribe(EventBus bus)
{
bus.Subscribe("TileEntered", e =>
{
if (State == "active" && Stage == 1 && e.Depth == 2) Advance(2);
});
bus.Subscribe("DialogChoice", e =>
{
if (State == "active" && Stage < 3 && e.Id == "miner:warden") Advance(3);
});
bus.Subscribe("EnemyKilled", e =>
{
if (State == "active" && e.Id == "warden") Complete();
});
}
}
Стъпка по стъпка, с думи
  1. Играта публикува събития в шината, а шината вика всеки, който се е абонирал за този тип. Горе е куестът „Изчезналите миньори“: още не е започнал. Топчето стои на „неактивен“.
  2. Умира плъх (EnemyKilled rat). Куестът е абониран за EnemyKilled, шината го вика, но слушателят пита: активен ли е куестът и врагът Пазач ли е? Не е: нищо не се случва. Боят не знае, че някой слуша.
  3. Героят слиза на второто ниво (TileEntered 2). Условието е правилно, но куестът още е неактивен (state не е "active"), затова пак нищо. Събитията идват, когато им е времето на играта, не на куеста.
  4. Старейшината дава куеста: start() го прави active, етап 1. Топчето минава на „етап 1“. Това не е събитие от шината, а действие от диалога.
  5. Героят влиза на ниво 1 (TileEntered 1). Куестът вече е активен и на етап 1, но иска ниво 2: depth е 1, условието не минава. Ново събитие за същия тип не значи напредък.
  6. Второ ниво (TileEntered 2): активен, етап 1 и depth 2. Слушателят вика advance(2): топчето минава на „етап 2“ и играта пише „„Изчезналите миньори“: Някой е жив на второто ниво. Намери го.“.
  7. Героят говори с миньора и избира „Какво има долу?“: диалогът публикува DialogChoice miner:warden. Куестът е на етап 2 (под 3) и идентификаторът съвпада: advance(3). Топчето е на „етап 3“.
  8. Героят пак слиза на ниво 2 (TileEntered 2). Условието depth == 2 е вярно, но етапът вече е 3, не 1: проверката stage == 1 спира събитието. Без нея куестът би се върнал на етап 2.
  9. Пазачът умира (EnemyKilled warden). Куестът е активен и id е "warden": complete(). Топчето е на „изпълнен“. Боят само каза „врагът умря“; кой слуша, не му е работа.

Кодът до сцената е шината, основата на куеста и подкласът MinersQuest от програмата. На всяка стъпка светят редовете, които работят в този момент.

Сцената показва само куеста „Изчезналите миньори“. Опитът и диалогът са в програмите по-горе и по-долу, не в сцената.

Идеята

Диалогът е дърво с условия

Разговорът е дърво: възел (node) има реплика и няколко отговора (option), а всеки отговор води към следващ възел (или към end, който затваря). Ако това е всичко, диалогът е еднакъв за всеки играч. Но Вела не трябва да предлага куеста за среброто, ако той е взет, и не бива да взема руда, която героят няма. Затова отговорът има условие и действия:

  • условие (condition): flag:име, item:предмет:брой, quest:куест:състояние, с ! отпред за отрицание. Няколко условия са с запетая и важат всички (логическо „и“). - значи „винаги“.
  • действия (actions): flag, start, complete, reward, give, take. Изпълняват се по ред, когато отговорът е избран.

Условията и действията са кратък текст във формат вид:аргумент:аргумент, защото диалозите са данни: могат да живеят във файл, а не в кода. visibleOptions показва само отговорите с вярно условие, а choose изпълнява действията на избрания.

record Option(string Text, string Next, string Condition, string Actions);
record Node(string Text, List<Option> Options);
class Game
{
static readonly Dictionary<string, string> Names = new()
{
["ore"] = "руда",
["silver_sword"] = "сребърен меч",
["potion_heal"] = "лековита отвара",
};
public readonly HashSet<string> Flags = new();
public readonly Dictionary<string, int> Items = new();
public readonly Dictionary<string, Quest> Quests = new();
public Game(params Quest[] list)
{
foreach (var quest in list) Quests[quest.Id] = quest;
}
public Quest GetQuest(string id)
{
return Quests[id];
}
public int Count(string id)
{
return Items.GetValueOrDefault(id, 0);
}
string Label(string id, int n)
{
return Names[id] + " x" + n;
}
public void Give(string id, int n)
{
Items[id] = Count(id) + n;
Console.WriteLine("Получаваш: " + Label(id, n) + ".");
}
public void Take(string id, int n)
{
Items[id] = Count(id) - n;
Console.WriteLine("Даваш: " + Label(id, n) + ".");
}
}
string[] SplitList(string text)
{
return text == "-" ? [] : text.Split(',');
}
bool CheckCondition(Game game, string condition)
{
foreach (var part in SplitList(condition))
{
bool negate = part.StartsWith('!');
var p = (negate ? part.Substring(1) : part).Split(':');
bool ok;
if (p[0] == "flag") ok = game.Flags.Contains(p[1]);
else if (p[0] == "item") ok = game.Count(p[1]) >= int.Parse(p[2]);
else if (p[0] == "quest") ok = game.GetQuest(p[1]).State == p[2];
else throw new ArgumentException("unknown condition: " + part);
if (negate) ok = !ok;
if (!ok) return false;
}
return true;
}
void RunActions(Game game, string actions)
{
foreach (var part in SplitList(actions))
{
var p = part.Split(':');
if (p[0] == "flag")
{
game.Flags.Add(p[1]);
}
else if (p[0] == "start")
{
game.GetQuest(p[1]).Start();
}
else if (p[0] == "complete")
{
game.GetQuest(p[1]).Complete();
}
else if (p[0] == "reward")
{
var quest = game.GetQuest(p[1]);
quest.State = "rewarded";
Console.WriteLine("Награда за „" + quest.Title + "“.");
}
else if (p[0] == "give")
{
game.Give(p[1], int.Parse(p[2]));
}
else if (p[0] == "take")
{
game.Take(p[1], int.Parse(p[2]));
}
else
{
throw new ArgumentException("unknown action: " + part);
}
}
}
List<Option> VisibleOptions(Game game, Node node)
{
var result = new List<Option>();
foreach (var option in node.Options)
{
if (option.Condition == "-" || CheckCondition(game, option.Condition)) result.Add(option);
}
return result;
}
void Show(string speaker, Game game, Node node)
{
Console.WriteLine(speaker + ": " + node.Text);
var options = VisibleOptions(game, node);
for (int i = 0; i < options.Count; i++) Console.WriteLine(" " + (i + 1) + ") " + options[i].Text);
}
string Choose(Game game, Node node, int number)
{
var option = VisibleOptions(game, node)[number - 1];
Console.WriteLine("> " + number);
RunActions(game, option.Actions);
return option.Next;
}

Данните са двата диалога от играта: ковачката Вела (предмети и куест) и миньорът Добри (еднократен подарък с флаг).

var smith = new Dictionary<string, Node>
{
["start"] = new Node("Огнището ми е студено без сребро. Донесеш ли ми три парчета руда, ще ти изкова меч.", [
new Option("Ще потърся руда.", "deal", "quest:silver:inactive", "start:silver"),
new Option("Ето рудата.", "done", "quest:silver:active,item:ore:3", "take:ore:3,complete:silver,reward:silver,give:silver_sword:1"),
new Option("Довиждане.", "end", "-", "-")]),
["deal"] = new Node("Рудата е на второто ниво. Големите от руда я носят в себе си.", [
new Option("Добре.", "end", "-", "-")]),
["done"] = new Node("Сребърен меч. Острието му не се затъпява.", [
new Option("Благодаря.", "end", "-", "-")]),
};
var miner = new Dictionary<string, Node>
{
["start"] = new Node("Жив човек! Другите избягаха нагоре... Аз останах затрупан тук.", [
new Option("Какво има долу?", "warden", "quest:miners:active,!flag:miner_told", "flag:miner_told,give:potion_heal:1"),
new Option("Стой тук, ще се върна.", "end", "-", "-")]),
["warden"] = new Node("Пазачът на дълбините. Каменен страж, събуден от кирките ни. Когато се пропука, вика мъртвите на помощ. Вземи отварата ми.", [
new Option("Ще го спра.", "end", "-", "-")]),
};
var game = new Game(new Quest("silver", "Сребро за ковачката", ["Събери три парчета сребърна руда.", "Занеси рудата на ковачката Вела."], "Ковачката има своето сребро."), new Quest("miners", "Изчезналите миньори", ["Слез в мината и потърси миньорите.", "Някой е жив на второто ниво. Намери го.", "Победи Пазача на дълбините на третото ниво."], "Пазачът е победен. Сивия брод е спасен."));
var start = smith["start"];
Show("Вела", game, start);
string next = Choose(game, start, 1);
Show("Вела", game, smith[next]);
game.Give("ore", 3);
Show("Вела", game, start);
next = Choose(game, start, 1);
Show("Вела", game, smith[next]);
game.GetQuest("miners").Start();
var minerStart = miner["start"];
Show("Добри", game, minerStart);
next = Choose(game, minerStart, 1);
Show("Добри", game, miner[next]);
Show("Добри", game, minerStart);
Изход
Вела: Огнището ми е студено без сребро. Донесеш ли ми три парчета руда, ще ти изкова меч.
1) Ще потърся руда.
2) Довиждане.
> 1
Нов куест: „Сребро за ковачката“.
Вела: Рудата е на второто ниво. Големите от руда я носят в себе си.
1) Добре.
Получаваш: руда x3.
Вела: Огнището ми е студено без сребро. Донесеш ли ми три парчета руда, ще ти изкова меч.
1) Ето рудата.
2) Довиждане.
> 1
Даваш: руда x3.
Куестът „Сребро за ковачката“ е изпълнен!
Награда за „Сребро за ковачката“.
Получаваш: сребърен меч x1.
Вела: Сребърен меч. Острието му не се затъпява.
1) Благодаря.
Нов куест: „Изчезналите миньори“.
Добри: Жив човек! Другите избягаха нагоре... Аз останах затрупан тук.
1) Какво има долу?
2) Стой тук, ще се върна.
> 1
Получаваш: лековита отвара x1.
Добри: Пазачът на дълбините. Каменен страж, събуден от кирките ни. Когато се пропука, вика мъртвите на помощ. Вземи отварата ми.
1) Ще го спра.
Добри: Жив човек! Другите избягаха нагоре... Аз останах затрупан тук.
1) Стой тук, ще се върна.

Следи как се менят отговорите на Вела за същия възел start: при първото посещение са два (deal и „Довиждане“), защото done иска куест и руда. След като героят донесе три руди, тя вече не предлага deal (куестът е активен), а предлага done. Действията на done взимат рудата, затварят куеста, дават наградата и сребърния меч. При Добри отговорът „Какво има долу?“ изчезва, щом флагът miner_told е сложен: подаръкът е еднократен.

Идеята

В играта: опит, умения и шина

Ето истинските откъси от канона. Първо, нивата и дървото (xp-levelup). Освен нашето xpForLevel и pendingLevels тук има learnableSkills (умения само на класа на героя, по списъка на героя), levelUpChoices (четири показателя и достъпните умения) и applyLevelChoice:

// Кривата на нивата е таблица: XP_TABLE[n] е опитът, с който се стига ниво n.
class LevelChoice
{
public string Kind = "";
public string Stat = "";
public int Amount;
}
static partial class Arcanum
{
public static int XpForLevel(int level)
{
if (level < XP_TABLE.Count) return XP_TABLE[level];
return XP_TABLE[XP_TABLE.Count - 1] + (level - XP_TABLE.Count + 1) * 150;
}
public static int PendingLevels(Hero hero)
{
int levels = 0;
while (hero.Xp >= XpForLevel(hero.Level + levels + 1)) levels++;
return levels;
}
// Новото ниво само по себе си: повече живот и мана, пълно здраве.
public static void RaiseLevel(Hero hero)
{
HeroClass heroClass = hero.HeroClass();
hero.Level += 1;
hero.Base["maxHp"] = hero.Base["maxHp"] + heroClass.HpPerLevel;
hero.Base["maxMana"] = hero.Base["maxMana"] + heroClass.ManaPerLevel;
hero.Hp = hero.MaxHp();
hero.Mana = hero.MaxMana();
}
// Дървото на уменията: два клона по три стъпки. Стъпка се учи, ако предишната в клона е научена.
public static List<string> LearnableSkills(Hero hero)
{
List<string> result = new List<string>();
foreach (Skill skill in SKILLS.Values())
{
if (skill.ClassId != hero.ClassId || hero.Skills.IndexOf(skill.Id) >= 0) continue;
bool ready = skill.Tier == 1;
foreach (string known in hero.Skills)
{
Skill other = SKILLS.Get(known);
if (other.Branch == skill.Branch && other.Tier == skill.Tier - 1) ready = true;
}
if (ready) result.Add(skill.Id);
}
return result;
}
// Изборите при ново ниво: показател или ново умение.
public static List<LevelChoice> LevelUpChoices(Hero hero)
{
List<LevelChoice> choices = new List<LevelChoice>
{
new LevelChoice { Kind = "stat", Stat = "maxHp", Amount = 8 },
new LevelChoice { Kind = "stat", Stat = "strength", Amount = 1 },
new LevelChoice { Kind = "stat", Stat = "agility", Amount = 1 },
new LevelChoice { Kind = "stat", Stat = "defense", Amount = 1 },
};
foreach (string skillId in LearnableSkills(hero)) choices.Add(new LevelChoice { Kind = "skill", Stat = skillId, Amount = 0 });
return choices;
}
public static void ApplyLevelChoice(Hero hero, LevelChoice choice)
{
if (choice.Kind == "skill")
{
hero.Skills.Add(choice.Stat);
}
else
{
hero.Base[choice.Stat] = hero.Base[choice.Stat] + choice.Amount;
if (choice.Stat == "maxHp") hero.Hp = hero.MaxHp();
}
}
}

Разликите от нашата програма са малко:

  • raiseLevel в играта добавя и мана и връща пълно здраве; ние пипаме само максималния живот.
  • Нивото не се дава в gainXp. enemyKilled (освен златото и трупа) вдига hero.xp и пуска събитие, а queueLevelUps слага екран LevelUpScreen в стека (урокът за екраните): играчът избира показател или умение и едва тогава raiseLevel и applyLevelChoice променят героя. Екранът е един за ниво, а не едно диалогово за всички.
  • Показателите се менят в base (урокът за героите), а не в самия герой.

А шината е същата, като нашата:

// Шината от събития: който публикува, не знае кой слуша. Куестовете се абонират и сами следят
// напредъка си, а бойната система само казва „врагът умря“.
class GameEvent
{
public string Type = "";
public int Depth;
public int X;
public int Y;
public char Decor = ' ';
public string Marker = "";
public string ItemId = "";
public int Count;
public string KindId = "";
public string DialogId = "";
public string NodeId = "";
}
class EventBus
{
public Dictionary<string, List<Action<GameEvent>>> Handlers = new Dictionary<string, List<Action<GameEvent>>>();
public void Subscribe(string type, Action<GameEvent> handler)
{
if (!Handlers.ContainsKey(type)) Handlers[type] = new List<Action<GameEvent>>();
Handlers[type].Add(handler);
}
public void Publish(GameEvent gameEvent)
{
if (!Handlers.ContainsKey(gameEvent.Type)) return;
foreach (Action<GameEvent> handler in new List<Action<GameEvent>>(Handlers[gameEvent.Type])) handler(gameEvent);
}
}

Събитията в играта са свободни обекти ({ type: 'EnemyKilled', kindId: … }), с различни полета за всеки тип: kindId, itemId, dialogId с nodeId, depth, decor, marker. В Java, C# и Python всяка форма е отделен запис. Ние вързахме всичко в type, id и depth, за да се събере един тип на четирите езика.

Идеята

В играта: куестове и диалози

Ето и истинските куестове. Основата Quest ползва ключове към текстовете (t('quest.' + id + '.title')), за да се превежда играта; MissingMinersQuest е нашият, а другите два показват какво още може един автомат:

// Куестът е малък краен автомат: inactive → active (етапи) → done → rewarded. Сам се абонира за
// събитията, които го движат.
abstract class Quest
{
public string Id;
public string State;
public int Stage;
public Quest(string id)
{
Id = id;
State = "inactive";
Stage = 0;
}
public string Title()
{
return T("quest." + Id + ".title");
}
public string Goal()
{
if (State == "done") return T("quest." + Id + ".done");
if (State == "rewarded") return T("quest.rewarded");
return T("quest." + Id + ".stage" + Stage);
}
public virtual void Start(Game game)
{
State = "active";
Stage = 1;
game.Say(T("quest.started", ("title", Title())));
}
public void Advance(Game game, int stage)
{
Stage = stage;
game.Say(T("quest.updated", ("title", Title()), ("goal", Goal())));
}
public void Complete(Game game)
{
State = "done";
game.Say(T("quest.completed", ("title", Title())));
}
public virtual void Subscribe(EventBus bus, Game game) { }
}
class MissingMinersQuest : Quest
{
public MissingMinersQuest() : base("miners") { }
public override void Subscribe(EventBus bus, Game game)
{
bus.Subscribe("TileEntered", (gameEvent) =>
{
if (State == "active" && Stage == 1 && gameEvent.Depth == 2) Advance(game, 2);
});
bus.Subscribe("DialogChoice", (gameEvent) =>
{
if (State == "active" && Stage < 3 && gameEvent.DialogId == "miner" && gameEvent.NodeId == "warden") Advance(game, 3);
});
bus.Subscribe("EnemyKilled", (gameEvent) =>
{
if (State == "active" && gameEvent.KindId == "warden") Complete(game);
});
}
}
class BloodTrailQuest : Quest
{
public BloodTrailQuest() : base("blood") { }
public override void Start(Game game)
{
base.Start(game);
if (game.HasFlag("leader_dead")) Complete(game);
}
public override void Subscribe(EventBus bus, Game game)
{
bus.Subscribe("TileEntered", (gameEvent) =>
{
if (State != "active" || gameEvent.Depth != 1) return;
if (Stage == 1 && gameEvent.Decor == ',') Advance(game, 2);
if (Stage < 3 && gameEvent.Marker == "lair") Advance(game, 3);
});
bus.Subscribe("EnemyKilled", (gameEvent) =>
{
if (gameEvent.KindId != "wolf_leader") return;
game.SetFlag("leader_dead");
if (State == "active") Complete(game);
});
}
}
class SilverQuest : Quest
{
public SilverQuest() : base("silver") { }
public override void Start(Game game)
{
base.Start(game);
Check(game);
}
public void Check(Game game)
{
if (State == "active" && Stage == 1 && game.Hero.Inventory.Count("ore") >= 3) Advance(game, 2);
}
public override void Subscribe(EventBus bus, Game game)
{
bus.Subscribe("ItemPicked", (gameEvent) =>
{
if (gameEvent.ItemId == "ore") Check(game);
});
}
}
static partial class Arcanum
{
public static List<Quest> CreateQuests()
{
return new List<Quest> { new MissingMinersQuest(), new BloodTrailQuest(), new SilverQuest() };
}
}
  • Куест, който започва след условието. BloodTrailQuest.start и SilverQuest.start проверяват веднага дали условието вече не е вярно: вълкът водач може да е мъртъв, а рудата в раницата, преди да е поет куестът. Така куестът не остава на етап 1 завинаги. Нашият MinersQuest не го прави, защото Пазачът не умира преди разговора със старейшината.
  • Етапи с повече от един източник. BloodTrailQuest слуша TileEntered по два начина: за следа от кръв (decor) и за леговище (marker). Не е нужно събитията да са съгласувани: слушателят пита за състоянието си и отговаря.
  • Достигане на победата. Пазачът не е само EnemyKilled: играта слага victory в enemyKilled, а куестът върви от същото събитие. Куестът „Изчезналите миньори“ няма награда: победата показва екрана за победа.

Диалогът в канона е същото, което построихме, и още: действията включват и gold и xp (наградата на ловеца), а условията и gold: (достатъчно злато):

// Диалогът е дърво с условия: изборите, чиито условия не са изпълнени, не се показват.
static partial class Arcanum
{
public static bool CheckCondition(Game game, string condition)
{
foreach (string part in SplitList(condition))
{
bool negate = part[0] == '!';
string[] pieces = (negate ? part.Substring(1) : part).Split(':');
bool ok = false;
if (pieces[0] == "flag") ok = game.HasFlag(pieces[1]);
else if (pieces[0] == "item") ok = game.Hero.Inventory.Count(pieces[1]) >= ToInt(pieces[2]);
else if (pieces[0] == "gold") ok = game.Hero.Gold >= ToInt(pieces[1]);
else if (pieces[0] == "quest") ok = game.Quest(pieces[1]).State == pieces[2];
else throw new Exception("unknown condition: " + part);
if (negate) ok = !ok;
if (!ok) return false;
}
return true;
}
public static void RunActions(Game game, string actions)
{
foreach (string part in SplitList(actions))
{
string[] pieces = part.Split(':');
if (pieces[0] == "flag")
{
game.SetFlag(pieces[1]);
}
else if (pieces[0] == "start")
{
game.Quest(pieces[1]).Start(game);
}
else if (pieces[0] == "complete")
{
game.Quest(pieces[1]).Complete(game);
}
else if (pieces[0] == "reward")
{
game.Quest(pieces[1]).State = "rewarded";
game.Say(T("quest.rewardedSay", ("title", game.Quest(pieces[1]).Title())));
}
else if (pieces[0] == "give")
{
game.GiveItem(pieces[1], ToInt(pieces[2]));
}
else if (pieces[0] == "take")
{
game.Hero.Inventory.Remove(pieces[1], ToInt(pieces[2]));
game.Say(T("item.given", ("item", ItemLabel(pieces[1], ToInt(pieces[2])))));
}
else if (pieces[0] == "gold")
{
game.Hero.Gold += ToInt(pieces[1]);
game.Say(T("item.gold", ("amount", pieces[1])));
}
else if (pieces[0] == "xp")
{
game.GainXp(ToInt(pieces[1]));
}
else
{
throw new Exception("unknown action: " + part);
}
}
}
public static List<DialogOption> VisibleOptions(Game game, DialogNode node)
{
List<DialogOption> result = new List<DialogOption>();
foreach (DialogOption option in node.Options)
{
if (option.Condition == "-" || CheckCondition(game, option.Condition)) result.Add(option);
}
return result;
}
}

DialogScreen (екранът в стека) вика visibleOptions, а когато играчът избере отговор, изпълнява runActions и публикува DialogChoice със следващия възел. Така диалогът и куестът не знаят един за друг: разговорът с миньора мести куеста само защото миньорът е публикувал, а куестът е слушал.

Пробвай

Предвиди изхода

Три разчета на pendingLevels с таблицата от урока. Какво печата програмата?

// XP_TABLE[n] е общият опит, с който се стига ниво n.
int[] xpTable = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720];
int XpForLevel(int level)
{
if (level < xpTable.Length) return xpTable[level];
// След края на таблицата кривата продължава с 150 на ниво.
return xpTable[xpTable.Length - 1] + (level - xpTable.Length + 1) * 150;
}
// Колко нива чакат: опитът стига ли до следващите прагове.
int PendingLevels(int xp, int level)
{
int levels = 0;
while (xp >= XpForLevel(level + levels + 1)) levels++;
return levels;
}
Console.WriteLine(PendingLevels(130, 1));
Console.WriteLine(PendingLevels(104, 3));
Console.WriteLine(PendingLevels(55, 1));
Отговор

Пробвай

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

Колега махна една проверка в MinersQuest и сега куестът се връща назад. Героят вече е говорил с миньора (етап 3) и пак слиза на второто ниво:

var bus = new EventBus();
var quest = new MinersQuest();
quest.Subscribe(bus);
Fire(bus, new GameEvent("EnemyKilled", "rat"));
Fire(bus, new GameEvent("TileEntered", Depth: 2));
Console.WriteLine("> старейшината дава куеста");
quest.Start();
Fire(bus, new GameEvent("TileEntered", Depth: 1));
Fire(bus, new GameEvent("TileEntered", Depth: 2));
Fire(bus, new GameEvent("DialogChoice", "miner:warden"));
Fire(bus, new GameEvent("TileEntered", Depth: 2));
Fire(bus, new GameEvent("EnemyKilled", "warden"));
Изход
> EnemyKilled rat
> TileEntered 2
> старейшината дава куеста
Нов куест: „Изчезналите миньори“.
> TileEntered 1
> TileEntered 2
„Изчезналите миньори“: Някой е жив на второто ниво. Намери го.
> DialogChoice miner:warden
„Изчезналите миньори“: Победи Пазача на дълбините на третото ниво.
> TileEntered 2
„Изчезналите миньори“: Някой е жив на второто ниво. Намери го.
> EnemyKilled warden
Куестът „Изчезналите миньори“ е изпълнен!

В края на изхода, след третото TileEntered 2 (героят вече е говорил с миньора), целта пак е „Някой е жив на второто ниво“, макар че героят вече знае за Пазача. Кой ред причинява това?

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

По-точно

По-точно: реда на слушателите, изключения и детерминизъм

Синхронно. publish връща, когато са свършили всички слушатели. Всичко е в една нишка, затова слушател, който публикува ново събитие, го пуска вътре в първото (вложено публикуване). Ако куест A слуша EnemyKilled и публикува QuestDone, а куест B слуша QuestDone, B се обажда преди края на първото publish. Ограничи дълбочината: безкраен цикъл от събития е най-трудният бъг на шините.

Реда на слушателите. Подредбата на абонирането е нарочно предвидима (списък, не множество): играта трябва да дава еднакъв резултат при едно и също семе на четирите езика. Но ако слушателите зависят от реда, схемата е сбъркана: два слушателя, които се нуждаят един от друг, трябва да са един.

Изключение в слушател. Тук то прекъсва publish и следващите слушатели не се викат. В игра за един играч това е приемливо (по-добре да се види грешката), в сървър се обгражда с try.

Отписване. Нашата шина няма unsubscribe. Куест, който е done, остава абониран и всеки път пита state == "active"; това е евтино. Където слушателите са много или живеят кратко (елементи на екрана), без отписване остават „висящи“ слушатели и памет, която не се връща.

Автоматът расте. Пет състояния с по два прехода са десет реда if. Когато куестът има разклонения (избери страна, провали се по време, провери три цели в произволен ред), състоянието вече не е една стойност. Тогава всяка цел е свой малък автомат, а куестът е „изпълнен“, когато всички са. Канонът нарочно се спира преди това.

Диалог като данни. Нашият формат е низ вид:арг:арг. Предимство: диалогът може да е във файл и да се променя без нов код. Цена: грешка в името (quets:silver:active) се хваща чак при изпълнение (unknown condition), не при компилиране. Затова checkCondition и runActions хвърлят за непознат вид вместо да пропускат тихо.

Упражнение

Три вълка

Реши задачата в редактора си. Тръгни от програмата за куеста.

Куест „Три вълка“. Напиши WolfQuest (подклас на Quest) с една цел „Убий три вълка“: куестът брои убитите вълци и минава на done при третия. Слуша само EnemyKilled; брои само вълци (id е "wolf") и само докато е active. При всеки убит вълк, който не е последният, пише целта с брояча, например „Убий три вълка (1/3)“ (презапиши goal()), като вика advance(1). При третия вика complete(). Събития преди start() и след done не броят.

Основата Quest, EventBus и fire са същите като в програмата за куеста. Ето сценария:

var bus = new EventBus();
var quest = new WolfQuest();
quest.Subscribe(bus);
Fire(bus, new GameEvent("EnemyKilled", "wolf"));
Console.WriteLine("> start");
quest.Start();
Fire(bus, new GameEvent("EnemyKilled", "rat"));
Fire(bus, new GameEvent("EnemyKilled", "wolf"));
Fire(bus, new GameEvent("EnemyKilled", "wolf"));
Fire(bus, new GameEvent("EnemyKilled", "wolf"));
Fire(bus, new GameEvent("EnemyKilled", "wolf"));

Изходът е:

Изход
> EnemyKilled wolf
> start
Нов куест: „Три вълка“.
> EnemyKilled rat
> EnemyKilled wolf
„Три вълка“: Убий три вълка (1/3)
> EnemyKilled wolf
„Три вълка“: Убий три вълка (2/3)
> EnemyKilled wolf
Куестът „Три вълка“ е изпълнен!
> EnemyKilled wolf

Още задачи

  • Награда. Добави на Quest метод reward(), който от done минава на rewarded и пише наградата. После направи от диалога на ловеца отговор с условие quest:wolves:done и действие reward:wolves.
  • Два куеста наведнъж. Абонирай и MinersQuest, и WolfQuest към една и съща шина и пусти сценарий, в който едно EnemyKilled движи и двата. Защо едно publish стига?
  • Отписване. Добави unsubscribe(type, handler) в шината. Какво трябва да направиш в publish, за да може слушател да се отпише по време на публикуването?
  • Дървото на уменията. Добави трети клас с два клона, в които втората стъпка иска две научени стъпки (по една от всеки клон). Какво променя това в learnable?

Тест

Провери се

1. Героят е на ниво 3 и има 92 опит. Убива Пазача (+150) и опитът става 242. Прагът за ниво 4 е 105, за ниво 5 е 170, за ниво 6 е 250. Колко нива чакат и как ги получава играчът?
2. Героят на варварин е научил „Ярост“ (rage, клон 1, стъпка 1) и „Боен вик“ (war_cry, клон 2, стъпка 1). Кои умения може да научи следващото ниво?
3. Защо publish в шината обхожда копие на списъка със слушатели (slice, list(...), new ArrayList<>(...)), а не самия списък?
4. Куестът е на етап 3. Идва TileEntered с depth 2 (героят пак слиза на второто ниво). Какво прави слушателят, който вдига етапа до 2, и защо?
5. Отговорът в диалога има условие quest:silver:active,item:ore:3. Героят е взел куеста, но има само 2 руди. Какво вижда играчът?

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