Към съдържанието
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, 0));
}

Идеята

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

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

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

class Hero {
int level = 1;
int xp = 0;
int maxHp = 48;
}
record Kill(String name, int xp) {}
// XP_TABLE[n] е общият опит, с който се стига ниво n.
static final int[] XP_TABLE = {0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720};
static int xpForLevel(int level) {
if (level < XP_TABLE.length) return XP_TABLE[level];
// След края на таблицата кривата продължава с 150 на ниво.
return XP_TABLE[XP_TABLE.length - 1] + (level - XP_TABLE.length + 1) * 150;
}
// Колко нива чакат: опитът стига ли до следващите прагове.
static int pendingLevels(int xp, int level) {
int levels = 0;
while (xp >= xpForLevel(level + levels + 1)) levels++;
return levels;
}
static void raiseLevel(Hero hero) {
hero.level += 1;
hero.maxHp += 9;
}
static final List<Kill> KILLS = List.of(new Kill("плъх", 5), new Kill("гоблин", 9), new Kill("шаман", 12), new Kill("вълк", 14), new Kill("култист", 20), new Kill("голем", 32), new Kill("Пазач", 150));
Hero hero = new Hero();
for (Kill kill : KILLS) {
hero.xp += kill.xp();
System.out.println(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);
System.out.println(" ниво " + hero.level + ": живот " + before + " -> " + hero.maxHp);
}
}
for (int level = 10; level <= 12; level++) {
System.out.println("ниво " + 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.
static final int[] XP_TABLE = {0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720};

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

// Колко нива чакат: опитът стига ли до следващите прагове.
static 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) {}
static final List<Skill> SKILLS = List.of(
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));
static final String[] ORDER = {"rage", "war_cry", "whirlwind", "iron_skin", "bloodlust", "second_wind"};
static List<String> learnable(List<Skill> skills, List<String> known) {
List<String> result = new ArrayList<>();
for (Skill skill : skills) {
if (known.contains(skill.id())) continue;
boolean ready = skill.tier() == 1;
for (Skill other : 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;
}
static String show(List<String> list) {
return list.isEmpty() ? "-" : String.join(", ", list);
}

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

List<String> known = new ArrayList<>();
System.out.println("начало -> " + show(learnable(SKILLS, known)));
for (String id : ORDER) {
known.add(id);
System.out.println(id + " -> " + show(learnable(SKILLS, 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) {
String label() {
return id.isEmpty() ? String.valueOf(depth) : id;
}
}
class EventBus {
private final Map<String, List<Consumer<GameEvent>>> handlers = new HashMap<>();
void subscribe(String type, Consumer<GameEvent> handler) {
handlers.computeIfAbsent(type, k -> new ArrayList<>()).add(handler);
}
void publish(GameEvent event) {
if (!handlers.containsKey(event.type())) return;
// Копие на списъка: слушател може да се абонира или да се отпише по време на публикуването.
for (Consumer<GameEvent> handler : new ArrayList<>(handlers.get(event.type()))) handler.accept(event);
}
}

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

EventBus bus = new EventBus();
Stats stats = new Stats();
bus.subscribe("EnemyKilled", e -> log(e));
bus.subscribe("TileEntered", e -> log(e));
bus.subscribe("EnemyKilled", e -> {
stats.kills++;
System.out.println("убити: " + stats.kills);
});
fire(bus, new GameEvent("EnemyKilled", "rat", 0));
fire(bus, new GameEvent("TileEntered", "", 2));
fire(bus, new GameEvent("EnemyKilled", "wolf", 0));
fire(bus, new GameEvent("ItemPicked", "ore", 0));
Изход
> 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 {
final String id;
final String title;
final String[] goals;
final String doneGoal;
String state = "inactive";
int stage = 0;
Quest(String id, String title, String[] goals, String doneGoal) {
this.id = id;
this.title = title;
this.goals = goals;
this.doneGoal = doneGoal;
}
String goal() {
if (state.equals("done")) return doneGoal;
return goals[stage - 1];
}
void start() {
state = "active";
stage = 1;
System.out.println("Нов куест: „" + title + "“.");
}
void advance(int newStage) {
stage = newStage;
System.out.println("„" + title + "“: " + goal());
}
void complete() {
state = "done";
System.out.println("Куестът „" + title + "“ е изпълнен!");
}
}
class MinersQuest extends Quest {
MinersQuest() {
super("miners", "Изчезналите миньори", new String[] {"Слез в мината и потърси миньорите.", "Някой е жив на второто ниво. Намери го.", "Победи Пазача на дълбините на третото ниво."}, "Пазачът е победен. Сивия брод е спасен.");
}
void subscribe(EventBus bus) {
bus.subscribe("TileEntered", e -> {
if (state.equals("active") && stage == 1 && e.depth() == 2) advance(2);
});
bus.subscribe("DialogChoice", e -> {
if (state.equals("active") && stage < 3 && e.id().equals("miner:warden")) advance(3);
});
bus.subscribe("EnemyKilled", e -> {
if (state.equals("active") && e.id().equals("warden")) complete();
});
}
}

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

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

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

Виж го

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

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

class EventBus {
private final Map<String, List<Consumer<GameEvent>>> handlers = new HashMap<>();
void subscribe(String type, Consumer<GameEvent> handler) {
handlers.computeIfAbsent(type, k -> new ArrayList<>()).add(handler);
}
void publish(GameEvent event) {
if (!handlers.containsKey(event.type())) return;
// Копие на списъка: слушател може да се абонира или да се отпише по време на публикуването.
for (Consumer<GameEvent> handler : new ArrayList<>(handlers.get(event.type()))) handler.accept(event);
}
}
class Quest {
final String id;
final String title;
final String[] goals;
final String doneGoal;
String state = "inactive";
int stage = 0;
Quest(String id, String title, String[] goals, String doneGoal) {
this.id = id;
this.title = title;
this.goals = goals;
this.doneGoal = doneGoal;
}
String goal() {
if (state.equals("done")) return doneGoal;
return goals[stage - 1];
}
void start() {
state = "active";
stage = 1;
System.out.println("Нов куест: „" + title + "“.");
}
void advance(int newStage) {
stage = newStage;
System.out.println("„" + title + "“: " + goal());
}
void complete() {
state = "done";
System.out.println("Куестът „" + title + "“ е изпълнен!");
}
}
class MinersQuest extends Quest {
MinersQuest() {
super("miners", "Изчезналите миньори", new String[] {"Слез в мината и потърси миньорите.", "Някой е жив на второто ниво. Намери го.", "Победи Пазача на дълбините на третото ниво."}, "Пазачът е победен. Сивия брод е спасен.");
}
void subscribe(EventBus bus) {
bus.subscribe("TileEntered", e -> {
if (state.equals("active") && stage == 1 && e.depth() == 2) advance(2);
});
bus.subscribe("DialogChoice", e -> {
if (state.equals("active") && stage < 3 && e.id().equals("miner:warden")) advance(3);
});
bus.subscribe("EnemyKilled", e -> {
if (state.equals("active") && e.id().equals("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 final Map<String, String> NAMES = Map.of("ore", "руда", "silver_sword", "сребърен меч", "potion_heal", "лековита отвара");
final Set<String> flags = new HashSet<>();
final Map<String, Integer> items = new HashMap<>();
final Map<String, Quest> quests = new HashMap<>();
Game(Quest... list) {
for (Quest quest : list) quests.put(quest.id, quest);
}
Quest quest(String id) {
return quests.get(id);
}
int count(String id) {
return items.getOrDefault(id, 0);
}
String label(String id, int n) {
return NAMES.get(id) + " x" + n;
}
void give(String id, int n) {
items.merge(id, n, Integer::sum);
System.out.println("Получаваш: " + label(id, n) + ".");
}
void take(String id, int n) {
items.merge(id, -n, Integer::sum);
System.out.println("Даваш: " + label(id, n) + ".");
}
}
static String[] splitList(String text) {
return text.equals("-") ? new String[0] : text.split(",");
}
static boolean checkCondition(Game game, String condition) {
for (String part : splitList(condition)) {
boolean negate = part.startsWith("!");
String[] p = (negate ? part.substring(1) : part).split(":");
boolean ok;
if (p[0].equals("flag")) ok = game.flags.contains(p[1]);
else if (p[0].equals("item")) ok = game.count(p[1]) >= Integer.parseInt(p[2]);
else if (p[0].equals("quest")) ok = game.quest(p[1]).state.equals(p[2]);
else throw new IllegalArgumentException("unknown condition: " + part);
if (negate) ok = !ok;
if (!ok) return false;
}
return true;
}
static void runActions(Game game, String actions) {
for (String part : splitList(actions)) {
String[] p = part.split(":");
if (p[0].equals("flag")) {
game.flags.add(p[1]);
} else if (p[0].equals("start")) {
game.quest(p[1]).start();
} else if (p[0].equals("complete")) {
game.quest(p[1]).complete();
} else if (p[0].equals("reward")) {
Quest quest = game.quest(p[1]);
quest.state = "rewarded";
System.out.println("Награда за „" + quest.title + "“.");
} else if (p[0].equals("give")) {
game.give(p[1], Integer.parseInt(p[2]));
} else if (p[0].equals("take")) {
game.take(p[1], Integer.parseInt(p[2]));
} else {
throw new IllegalArgumentException("unknown action: " + part);
}
}
}
static List<Option> visibleOptions(Game game, Node node) {
List<Option> result = new ArrayList<>();
for (Option option : node.options()) {
if (option.condition().equals("-") || checkCondition(game, option.condition())) result.add(option);
}
return result;
}
static void show(String speaker, Game game, Node node) {
System.out.println(speaker + ": " + node.text());
List<Option> options = visibleOptions(game, node);
for (int i = 0; i < options.size(); i++) System.out.println(" " + (i + 1) + ") " + options.get(i).text());
}
static String choose(Game game, Node node, int number) {
Option option = visibleOptions(game, node).get(number - 1);
System.out.println("> " + number);
runActions(game, option.actions());
return option.next();
}

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

static final Map<String, Node> SMITH = Map.of(
"start", new Node("Огнището ми е студено без сребро. Донесеш ли ми три парчета руда, ще ти изкова меч.", List.of(
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("Рудата е на второто ниво. Големите от руда я носят в себе си.", List.of(
new Option("Добре.", "end", "-", "-"))),
"done", new Node("Сребърен меч. Острието му не се затъпява.", List.of(
new Option("Благодаря.", "end", "-", "-"))));
static final Map<String, Node> MINER = Map.of(
"start", new Node("Жив човек! Другите избягаха нагоре... Аз останах затрупан тук.", List.of(
new Option("Какво има долу?", "warden", "quest:miners:active,!flag:miner_told", "flag:miner_told,give:potion_heal:1"),
new Option("Стой тук, ще се върна.", "end", "-", "-"))),
"warden", new Node("Пазачът на дълбините. Каменен страж, събуден от кирките ни. Когато се пропука, вика мъртвите на помощ. Вземи отварата ми.", List.of(
new Option("Ще го спра.", "end", "-", "-"))));
Game game = new Game(new Quest("silver", "Сребро за ковачката", new String[] {"Събери три парчета сребърна руда.", "Занеси рудата на ковачката Вела."}, "Ковачката има своето сребро."), new Quest("miners", "Изчезналите миньори", new String[] {"Слез в мината и потърси миньорите.", "Някой е жив на второто ниво. Намери го.", "Победи Пазача на дълбините на третото ниво."}, "Пазачът е победен. Сивия брод е спасен."));
Node start = SMITH.get("start");
show("Вела", game, start);
String next = choose(game, start, 1);
show("Вела", game, SMITH.get(next));
game.give("ore", 3);
show("Вела", game, start);
next = choose(game, start, 1);
show("Вела", game, SMITH.get(next));
game.quest("miners").start();
Node minerStart = MINER.get("start");
show("Добри", game, minerStart);
next = choose(game, minerStart, 1);
show("Добри", game, MINER.get(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.
static int xpForLevel(int level) {
if (level < XP_TABLE.size()) return XP_TABLE.get(level);
return XP_TABLE.get(XP_TABLE.size() - 1) + (level - XP_TABLE.size() + 1) * 150;
}
static int pendingLevels(Hero hero) {
int levels = 0;
while (hero.xp >= xpForLevel(hero.level + levels + 1)) levels++;
return levels;
}
// Новото ниво само по себе си: повече живот и мана, пълно здраве.
static void raiseLevel(Hero hero) {
HeroClass heroClass = hero.heroClass();
hero.level += 1;
hero.base.put("maxHp", hero.base.get("maxHp") + heroClass.hpPerLevel);
hero.base.put("maxMana", hero.base.get("maxMana") + heroClass.manaPerLevel);
hero.hp = hero.maxHp();
hero.mana = hero.maxMana();
}
// Дървото на уменията: два клона по три стъпки. Стъпка се учи, ако предишната в клона е научена.
static List<String> learnableSkills(Hero hero) {
List<String> result = new ArrayList<>();
for (Skill skill : SKILLS.values()) {
if (!skill.classId.equals(hero.classId) || hero.skills.indexOf(skill.id) >= 0) continue;
boolean ready = skill.tier == 1;
for (String known : 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;
}
// Изборът при ново ниво: показател (stat и amount) или умение (stat е id на умението).
static class LevelChoice {
String kind;
String stat;
int amount;
LevelChoice(String kind, String stat, int amount) {
this.kind = kind;
this.stat = stat;
this.amount = amount;
}
}
// Изборите при ново ниво: показател или ново умение.
static List<LevelChoice> levelUpChoices(Hero hero) {
List<LevelChoice> choices = new ArrayList<>(List.of(
new LevelChoice("stat", "maxHp", 8),
new LevelChoice("stat", "strength", 1),
new LevelChoice("stat", "agility", 1),
new LevelChoice("stat", "defense", 1)
));
for (String skillId : learnableSkills(hero)) choices.add(new LevelChoice("skill", skillId, 0));
return choices;
}
static void applyLevelChoice(Hero hero, LevelChoice choice) {
if (choice.kind.equals("skill")) {
hero.skills.add(choice.stat);
} else {
hero.base.put(choice.stat, hero.base.get(choice.stat) + choice.amount);
if (choice.stat.equals("maxHp")) hero.hp = hero.maxHp();
}
}

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

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

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

// Шината от събития: който публикува, не знае кой слуша. Куестовете се абонират и сами следят
// напредъка си, а бойната система само казва „врагът умря“.
static class GameEvent {
String type;
int depth = 0;
int x = 0;
int y = 0;
char decor = ' ';
String marker = "";
String itemId = "";
int count = 0;
String kindId = "";
String dialogId = "";
String nodeId = "";
GameEvent(String type) {
this.type = type;
}
static GameEvent tileEntered(int depth, int x, int y, char decor, String marker) {
GameEvent event = new GameEvent("TileEntered");
event.depth = depth;
event.x = x;
event.y = y;
event.decor = decor;
event.marker = marker;
return event;
}
static GameEvent itemPicked(String itemId, int count) {
GameEvent event = new GameEvent("ItemPicked");
event.itemId = itemId;
event.count = count;
return event;
}
static GameEvent enemyKilled(String kindId) {
GameEvent event = new GameEvent("EnemyKilled");
event.kindId = kindId;
return event;
}
static GameEvent dialogChoice(String dialogId, String nodeId) {
GameEvent event = new GameEvent("DialogChoice");
event.dialogId = dialogId;
event.nodeId = nodeId;
return event;
}
}
static class EventBus {
Map<String, List<Consumer<GameEvent>>> handlers = new LinkedHashMap<>();
void subscribe(String type, Consumer<GameEvent> handler) {
if (!this.handlers.containsKey(type)) this.handlers.put(type, new ArrayList<>());
this.handlers.get(type).add(handler);
}
void publish(GameEvent event) {
if (!this.handlers.containsKey(event.type)) return;
for (Consumer<GameEvent> handler : new ArrayList<>(this.handlers.get(event.type))) handler.accept(event);
}
}

Събитията в играта са свободни обекти ({ 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. Сам се абонира за
// събитията, които го движат.
static class Quest {
String id;
String state;
int stage;
Quest(String id) {
this.id = id;
this.state = "inactive";
this.stage = 0;
}
String title() {
return t("quest." + this.id + ".title");
}
String goal() {
if (this.state.equals("done")) return t("quest." + this.id + ".done");
if (this.state.equals("rewarded")) return t("quest.rewarded");
return t("quest." + this.id + ".stage" + this.stage);
}
void start(Game game) {
this.state = "active";
this.stage = 1;
game.say(t("quest.started", "title", this.title()));
}
void advance(Game game, int stage) {
this.stage = stage;
game.say(t("quest.updated", "title", this.title(), "goal", this.goal()));
}
void complete(Game game) {
this.state = "done";
game.say(t("quest.completed", "title", this.title()));
}
void subscribe(EventBus bus, Game game) {}
}
static class MissingMinersQuest extends Quest {
MissingMinersQuest() {
super("miners");
}
@Override
void subscribe(EventBus bus, Game game) {
bus.subscribe("TileEntered", (event) -> {
if (this.state.equals("active") && this.stage == 1 && event.depth == 2) this.advance(game, 2);
});
bus.subscribe("DialogChoice", (event) -> {
if (this.state.equals("active") && this.stage < 3 && event.dialogId.equals("miner") && event.nodeId.equals("warden")) this.advance(game, 3);
});
bus.subscribe("EnemyKilled", (event) -> {
if (this.state.equals("active") && event.kindId.equals("warden")) this.complete(game);
});
}
}
static class BloodTrailQuest extends Quest {
BloodTrailQuest() {
super("blood");
}
@Override
void start(Game game) {
super.start(game);
if (game.hasFlag("leader_dead")) this.complete(game);
}
@Override
void subscribe(EventBus bus, Game game) {
bus.subscribe("TileEntered", (event) -> {
if (!this.state.equals("active") || event.depth != 1) return;
if (this.stage == 1 && event.decor == ',') this.advance(game, 2);
if (this.stage < 3 && event.marker.equals("lair")) this.advance(game, 3);
});
bus.subscribe("EnemyKilled", (event) -> {
if (!event.kindId.equals("wolf_leader")) return;
game.setFlag("leader_dead");
if (this.state.equals("active")) this.complete(game);
});
}
}
static class SilverQuest extends Quest {
SilverQuest() {
super("silver");
}
@Override
void start(Game game) {
super.start(game);
this.check(game);
}
void check(Game game) {
if (this.state.equals("active") && this.stage == 1 && game.hero.inventory.count("ore") >= 3) this.advance(game, 2);
}
@Override
void subscribe(EventBus bus, Game game) {
bus.subscribe("ItemPicked", (event) -> {
if (event.itemId.equals("ore")) this.check(game);
});
}
}
static List<Quest> createQuests() {
return new ArrayList<>(List.of(new MissingMinersQuest(), new BloodTrailQuest(), new SilverQuest()));
}
  • Куест, който започва след условието. BloodTrailQuest.start и SilverQuest.start проверяват веднага дали условието вече не е вярно: вълкът водач може да е мъртъв, а рудата в раницата, преди да е поет куестът. Така куестът не остава на етап 1 завинаги. Нашият MinersQuest не го прави, защото Пазачът не умира преди разговора със старейшината.
  • Етапи с повече от един източник. BloodTrailQuest слуша TileEntered по два начина: за следа от кръв (decor) и за леговище (marker). Не е нужно събитията да са съгласувани: слушателят пита за състоянието си и отговаря.
  • Достигане на победата. Пазачът не е само EnemyKilled: играта слага victory в enemyKilled, а куестът върви от същото събитие. Куестът „Изчезналите миньори“ няма награда: победата показва екрана за победа.

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

// Диалогът е дърво с условия: изборите, чиито условия не са изпълнени, не се показват.
static boolean checkCondition(Game game, String condition) {
for (String part : splitList(condition)) {
boolean negate = part.charAt(0) == '!';
List<String> pieces = split(negate ? part.substring(1) : part, ":");
boolean ok = false;
if (pieces.get(0).equals("flag")) ok = game.hasFlag(pieces.get(1));
else if (pieces.get(0).equals("item")) ok = game.hero.inventory.count(pieces.get(1)) >= toInt(pieces.get(2));
else if (pieces.get(0).equals("gold")) ok = game.hero.gold >= toInt(pieces.get(1));
else if (pieces.get(0).equals("quest")) ok = game.quest(pieces.get(1)).state.equals(pieces.get(2));
else throw new RuntimeException("unknown condition: " + part);
if (negate) ok = !ok;
if (!ok) return false;
}
return true;
}
static void runActions(Game game, String actions) {
for (String part : splitList(actions)) {
List<String> pieces = split(part, ":");
if (pieces.get(0).equals("flag")) {
game.setFlag(pieces.get(1));
} else if (pieces.get(0).equals("start")) {
game.quest(pieces.get(1)).start(game);
} else if (pieces.get(0).equals("complete")) {
game.quest(pieces.get(1)).complete(game);
} else if (pieces.get(0).equals("reward")) {
game.quest(pieces.get(1)).state = "rewarded";
game.say(t("quest.rewardedSay", "title", game.quest(pieces.get(1)).title()));
} else if (pieces.get(0).equals("give")) {
game.giveItem(pieces.get(1), toInt(pieces.get(2)));
} else if (pieces.get(0).equals("take")) {
game.hero.inventory.remove(pieces.get(1), toInt(pieces.get(2)));
game.say(t("item.given", "item", itemLabel(pieces.get(1), toInt(pieces.get(2)))));
} else if (pieces.get(0).equals("gold")) {
game.hero.gold += toInt(pieces.get(1));
game.say(t("item.gold", "amount", pieces.get(1)));
} else if (pieces.get(0).equals("xp")) {
game.gainXp(toInt(pieces.get(1)));
} else {
throw new RuntimeException("unknown action: " + part);
}
}
}
static List<DialogOption> visibleOptions(Game game, DialogNode node) {
List<DialogOption> result = new ArrayList<>();
for (DialogOption option : node.options) {
if (option.condition.equals("-") || checkCondition(game, option.condition)) result.add(option);
}
return result;
}

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

Пробвай

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

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

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

Пробвай

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

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

EventBus bus = new EventBus();
MinersQuest quest = new MinersQuest();
quest.subscribe(bus);
fire(bus, new GameEvent("EnemyKilled", "rat", 0));
fire(bus, new GameEvent("TileEntered", "", 2));
System.out.println("> старейшината дава куеста");
quest.start();
fire(bus, new GameEvent("TileEntered", "", 1));
fire(bus, new GameEvent("TileEntered", "", 2));
fire(bus, new GameEvent("DialogChoice", "miner:warden", 0));
fire(bus, new GameEvent("TileEntered", "", 2));
fire(bus, new GameEvent("EnemyKilled", "warden", 0));
Изход
> 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 са същите като в програмата за куеста. Ето сценария:

EventBus bus = new EventBus();
WolfQuest quest = new WolfQuest();
quest.subscribe(bus);
fire(bus, new GameEvent("EnemyKilled", "wolf", 0));
System.out.println("> start");
quest.start();
fire(bus, new GameEvent("EnemyKilled", "rat", 0));
fire(bus, new GameEvent("EnemyKilled", "wolf", 0));
fire(bus, new GameEvent("EnemyKilled", "wolf", 0));
fire(bus, new GameEvent("EnemyKilled", "wolf", 0));
fire(bus, new GameEvent("EnemyKilled", "wolf", 0));

Изходът е:

Изход
> 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 за достъпно обучение по програмиране ·