Нива, диалози и куестове
Накратко: Опитът се смята по таблица, а новите нива и умения се дават едно по едно; боят и картата само публикуват събития в шина, а куестовете се абонират и са крайни автомати; диалогът е дърво, в което условията крият ненужните отговори.
Проблем
Вълкът, който не знае, че има куест
Досега героят убиваше врагове и това беше всичко. Сега играта иска да се случи още нещо: убийството на вълка водач да затвори куест, убийството на Пазача да даде победата, влизането на второ ниво да мести друг куест напред, а опитът да вдигне нивото. Най-простият начин е да го запишеш там, където врагът умира:
def on_enemy_killed(enemy): hero.xp += enemy.xp # Боят знае за всеки, който се интересува от смъртта на враг. quests.enemy_killed(enemy) achievements.enemy_killed(enemy) # Нов слушател: още един ред тук и още една промяна в боя. stats.kills += 1Тук боят знае за куестовете, постиженията и статистиката. Нов куест е нов ред в боя, а боят вече има много причини да се променя. Още по-лошо: всеки нов слушател се добавя в чужд код, и всяка забравена връзка е куест, който никога не приключва.
Решението е да се обърне посоката на знанието. Боят казва на едно място „врагът умря“ и не се интересува кой слуша. Който иска да знае, се записва (абонира). Това е шина от събития. Урокът строи пет неща, по реда, в който играта ги ползва: опит и нива (крива като таблица), дърво от умения, шина от събития, куест като краен автомат и диалог като дърво с условия.
Така изглежда същото място, когато има шина. Боят публикува един ред и толкова:
def on_enemy_killed(enemy): hero.xp += enemy.xp bus.publish(GameEvent("EnemyKilled", enemy.kind))Идеята
Опит и нива: кривата е таблица
Колко опит иска всяко ниво? Формула (например 100 × ниво²) е гладка, но за настройка на играта е неудобна: искаш да кажеш „ниво 3 е след около шест врага“, не да пресмяташ. Затова кривата е таблица: XP_TABLE[n] е общият опит, с който се стига ниво n. Над края на таблицата кривата продължава с още 150 на ниво, за да не гърми играта на ниво 11.
Героят има xp и level. На всяко убийство xp расте, а pendingLevels казва колко нива чакат: колко от следващите прагове опитът вече е стигнал. Новото ниво е отделна стъпка (raiseLevel): повече живот. Програмата убива седем врага от играта, всеки с опита си (плъх 5, гоблин 9, ... Пазачът 150), и пише всяко ново ниво:
class Hero: def __init__(self): self.level = 1 self.xp = 0 self.max_hp = 48
# XP_TABLE[n] е общият опит, с който се стига ниво n.XP_TABLE = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720]
def xp_for_level(level): if level < len(XP_TABLE): return XP_TABLE[level] # След края на таблицата кривата продължава с 150 на ниво. return XP_TABLE[-1] + (level - len(XP_TABLE) + 1) * 150
# Колко нива чакат: опитът стига ли до следващите прагове.def pending_levels(xp, level): levels = 0 while xp >= xp_for_level(level + levels + 1): levels += 1 return levels
def raise_level(hero): hero.level += 1 hero.max_hp += 9KILLS = [("плъх", 5), ("гоблин", 9), ("шаман", 12), ("вълк", 14), ("култист", 20), ("голем", 32), ("Пазач", 150)]
hero = Hero()for name, xp in KILLS: hero.xp += xp print(name + " +" + str(xp) + ": опит " + str(hero.xp)) raise_count = pending_levels(hero.xp, hero.level) for _ in range(raise_count): before = hero.max_hp raise_level(hero) print(" ниво " + str(hero.level) + ": живот " + str(before) + " -> " + str(hero.max_hp))
for level in range(10, 13): print("ниво " + str(level) + ": опит " + str(xp_for_level(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.
XP_TABLE = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720]
def xp_for_level(level):
if level < len(XP_TABLE):
return XP_TABLE[level]
# След края на таблицата кривата продължава с 150 на ниво.
return XP_TABLE[-1] + (level - len(XP_TABLE) + 1) * 150
# Колко нива чакат: опитът стига ли до следващите прагове.
def pending_levels(xp, level):
levels = 0
while xp xp_for_level(level + levels + 1):
levels += 1
return levelsОпитът стига прага, когато е равен на него или по-голям: >=. С > героят с точно 20 опит не би получил ниво 2, а прагът в таблицата значи „с толкова опит нивото се стига“. Равенството се брои и в задачата „Предвиди изхода“ по-долу.
По-точно, за любопитните
Защо таблица, а не формула? Таблицата е данни: играч с по-бавен темп, ново ниво или друг режим са друга таблица, не друг код. Канонът я държи като число, разделено с | (0|0|20|55|105|170|250|345|455|580|720), и я чете при старт заедно с предметите и враговете. Първите две нули са за нива 0 и 1: героят започва на ниво 1.
Кривата се настройва със симулацията от последния урок: пускат се хиляди игри и се гледа на кое ниво героят стига до какъв враг.
Идеята
Дървото на уменията: две пътеки по три стъпки
На всяко ново ниво играчът избира: показател (+8 живот, +1 сила, +1 ловкост, +1 защита) или ново умение. Уменията на един клас са дърво с два клона по три стъпки. Правилото е кратко: умение е достъпно, ако вече не е научено и е или стъпка 1, или в същия клон е научена стъпка с едно по-ниска.
@dataclass(frozen=True)class Skill: id: str branch: int tier: int
SKILLS = [ Skill("rage", 1, 1), Skill("whirlwind", 1, 2), Skill("bloodlust", 1, 3), Skill("war_cry", 2, 1), Skill("iron_skin", 2, 2), Skill("second_wind", 2, 3),]ORDER = ["rage", "war_cry", "whirlwind", "iron_skin", "bloodlust", "second_wind"]
def learnable(skills, known): result = [] for skill in skills: if skill.id in known: continue ready = skill.tier == 1 for other in skills: if other.id in known and other.branch == skill.branch and other.tier == skill.tier - 1: ready = True if ready: result.append(skill.id) return result
def show(items): return "-" if len(items) == 0 else ", ".join(items)Програмата учи уменията на варварина в зададен ред и след всяко пише какво е достъпно. Празният списък е -.
known = []print("начало -> " + show(learnable(SKILLS, known)))for skill_id in ORDER: known.append(skill_id) print(skill_id + " -> " + show(learnable(SKILLS, known)))начало -> rage, war_cryrage -> whirlwind, war_crywar_cry -> whirlwind, iron_skinwhirlwind -> bloodlust, iron_skiniron_skin -> bloodlust, second_windbloodlust -> second_windsecond_wind -> -В началото достъпни са само двете начала: rage и war_cry. Научаването на rage отваря whirlwind, но bloodlust още чака (иска whirlwind), а клонът на war_cry не се променя. Накрая second_wind -> -: дървото е изчерпано и в избора за ново ниво остават само показателите.
Идеята
Шина от събития: кой публикува, не знае кой слуша
Шината е най-простото нещо: речник от тип на събитие към списък от функции. subscribe(type, handler) слага функция в списъка за този тип. publish(event) вика всички функции от списъка за типа на събитието. Нищо друго.
Събитието е малък запис: тип и данни. Тук то е type, id (врагът, предметът или диалогът) и depth (нивото). Такива са EnemyKilled (врагът умря), TileEntered (героят е влязъл на ниво), DialogChoice (избран е отговор) и ItemPicked (взет е предмет).
@dataclass(frozen=True)class GameEvent: type: str id: str = "" depth: int = 0
def label(self): return str(self.depth) if self.id == "" else self.id
class EventBus: def __init__(self): self.handlers = {}
def subscribe(self, type, handler): self.handlers.setdefault(type, []).append(handler)
def publish(self, event): if event.type not in self.handlers: return # Копие на списъка: слушател може да се абонира или да се отпише по време на публикуването. for handler in list(self.handlers[event.type]): handler(event)Първата програма слага двама слушатели на EnemyKilled (запис и броячът на убитите) и един на TileEntered. На ItemPicked няма никой и шината просто не прави нищо.
bus = EventBus()stats = Stats()
def count_kill(event): stats.kills += 1 print("убити: " + str(stats.kills))
bus.subscribe("EnemyKilled", log)bus.subscribe("TileEntered", log)bus.subscribe("EnemyKilled", count_kill)
fire(bus, GameEvent("EnemyKilled", "rat"))fire(bus, GameEvent("TileEntered", depth=2))fire(bus, GameEvent("EnemyKilled", "wolf"))fire(bus, 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: def __init__(self, id, title, goals, done_goal): self.id = id self.title = title self.goals = goals self.done_goal = done_goal self.state = "inactive" self.stage = 0
def goal(self): if self.state == "done": return self.done_goal return self.goals[self.stage - 1]
def start(self): self.state = "active" self.stage = 1 print("Нов куест: „" + self.title + "“.")
def advance(self, new_stage): self.stage = new_stage print("„" + self.title + "“: " + self.goal())
def complete(self): self.state = "done" print("Куестът „" + self.title + "“ е изпълнен!")
class MinersQuest(Quest): def __init__(self): super().__init__("miners", "Изчезналите миньори", ["Слез в мината и потърси миньорите.", "Някой е жив на второто ниво. Намери го.", "Победи Пазача на дълбините на третото ниво."], "Пазачът е победен. Сивия брод е спасен.")
def subscribe(self, bus): def on_tile(event): if self.state == "active" and self.stage == 1 and event.depth == 2: self.advance(2)
def on_dialog(event): if self.state == "active" and self.stage < 3 and event.id == "miner:warden": self.advance(3)
def on_kill(event): if self.state == "active" and event.id == "warden": self.complete()
bus.subscribe("TileEntered", on_tile) bus.subscribe("DialogChoice", on_dialog) bus.subscribe("EnemyKilled", on_kill)Всеки слушател започва с state == "active": шината не знае в какво състояние е куестът, затова въпросът е негов. Второто условие (stage == 1, stage < 3, id == ...) казва кое точно събитие движи точно този преход. Основата е нарочно без subscribe; в канона я има празна, за да може играта да обходи всички куестове и да викне subscribe на всеки.
Сценарият има осем стъпки, четири от които куестът игнорира. Старейшината дава куеста на третата, а стъпките са точно тези, които показва сцената по-долу.
bus = EventBus()quest = MinersQuest()quest.subscribe(bus)
fire(bus, GameEvent("EnemyKilled", "rat"))fire(bus, GameEvent("TileEntered", depth=2))print("> старейшината дава куеста")quest.start()fire(bus, GameEvent("TileEntered", depth=1))fire(bus, GameEvent("TileEntered", depth=2))fire(bus, GameEvent("DialogChoice", "miner:warden"))fire(bus, GameEvent("TileEntered", depth=2))fire(bus, GameEvent("EnemyKilled", "warden"))> EnemyKilled rat> TileEntered 2> старейшината дава куестаНов куест: „Изчезналите миньори“.> TileEntered 1> TileEntered 2„Изчезналите миньори“: Някой е жив на второто ниво. Намери го.> DialogChoice miner:warden„Изчезналите миньори“: Победи Пазача на дълбините на третото ниво.> TileEntered 2> EnemyKilled wardenКуестът „Изчезналите миньори“ е изпълнен!Виж го
Събитията вървят към куеста
Горе е пътят на куеста с топче, което стои на състоянието му. Всяко събитие е плочка: вдига се до шината, отива до куеста и куестът или се мести напред, или трепва и казва „игнорира“. Вижда се кои четири от осемте събития не движат нищо и защо: куестът още не е започнал, нивото е друго, а при едното етапът вече е изминат.
class EventBus: def __init__(self): self.handlers = {}
def subscribe(self, type, handler): self.handlers.setdefault(type, []).append(handler)
def publish(self, event): if event.type not in self.handlers: return # Копие на списъка: слушател може да се абонира или да се отпише по време на публикуването. for handler in list(self.handlers[event.type]): handler(event)
class Quest: def __init__(self, id, title, goals, done_goal): self.id = id self.title = title self.goals = goals self.done_goal = done_goal self.state = "inactive" self.stage = 0
def goal(self): if self.state == "done": return self.done_goal return self.goals[self.stage - 1]
def start(self): self.state = "active" self.stage = 1 print("Нов куест: „" + self.title + "“.")
def advance(self, new_stage): self.stage = new_stage print("„" + self.title + "“: " + self.goal())
def complete(self): self.state = "done" print("Куестът „" + self.title + "“ е изпълнен!")
class MinersQuest(Quest): def __init__(self): super().__init__("miners", "Изчезналите миньори", ["Слез в мината и потърси миньорите.", "Някой е жив на второто ниво. Намери го.", "Победи Пазача на дълбините на третото ниво."], "Пазачът е победен. Сивия брод е спасен.")
def subscribe(self, bus): def on_tile(event): if self.state == "active" and self.stage == 1 and event.depth == 2: self.advance(2)
def on_dialog(event): if self.state == "active" and self.stage < 3 and event.id == "miner:warden": self.advance(3)
def on_kill(event): if self.state == "active" and event.id == "warden": self.complete()
bus.subscribe("TileEntered", on_tile) bus.subscribe("DialogChoice", on_dialog) bus.subscribe("EnemyKilled", on_kill)Стъпка по стъпка, с думи
- Играта публикува събития в шината, а шината вика всеки, който се е абонирал за този тип. Горе е куестът „Изчезналите миньори“: още не е започнал. Топчето стои на „неактивен“.
- Умира плъх (EnemyKilled rat). Куестът е абониран за EnemyKilled, шината го вика, но слушателят пита: активен ли е куестът и врагът Пазач ли е? Не е: нищо не се случва. Боят не знае, че някой слуша.
- Героят слиза на второто ниво (TileEntered 2). Условието е правилно, но куестът още е неактивен (state не е "active"), затова пак нищо. Събитията идват, когато им е времето на играта, не на куеста.
- Старейшината дава куеста: start() го прави active, етап 1. Топчето минава на „етап 1“. Това не е събитие от шината, а действие от диалога.
- Героят влиза на ниво 1 (TileEntered 1). Куестът вече е активен и на етап 1, но иска ниво 2: depth е 1, условието не минава. Ново събитие за същия тип не значи напредък.
- Второ ниво (TileEntered 2): активен, етап 1 и depth 2. Слушателят вика advance(2): топчето минава на „етап 2“ и играта пише „„Изчезналите миньори“: Някой е жив на второто ниво. Намери го.“.
- Героят говори с миньора и избира „Какво има долу?“: диалогът публикува DialogChoice miner:warden. Куестът е на етап 2 (под 3) и идентификаторът съвпада: advance(3). Топчето е на „етап 3“.
- Героят пак слиза на ниво 2 (TileEntered 2). Условието depth == 2 е вярно, но етапът вече е 3, не 1: проверката stage == 1 спира събитието. Без нея куестът би се върнал на етап 2.
- Пазачът умира (EnemyKilled warden). Куестът е активен и id е "warden": complete(). Топчето е на „изпълнен“. Боят само каза „врагът умря“; кой слуша, не му е работа.
Кодът до сцената е шината, основата на куеста и подкласът MinersQuest от програмата. На всяка стъпка светят редовете, които работят в този момент.
Сцената показва само куеста „Изчезналите миньори“. Опитът и диалогът са в програмите по-горе и по-долу, не в сцената.
Идеята
Диалогът е дърво с условия
Разговорът е дърво: възел (node) има реплика и няколко отговора (option), а всеки отговор води към следващ възел (или към end, който затваря). Ако това е всичко, диалогът е еднакъв за всеки играч. Но Вела не трябва да предлага куеста за среброто, ако той е взет, и не бива да взема руда, която героят няма. Затова отговорът има условие и действия:
- условие (
condition):flag:име,item:предмет:брой,quest:куест:състояние, с!отпред за отрицание. Няколко условия са с запетая и важат всички (логическо „и“).-значи „винаги“. - действия (
actions):flag,start,complete,reward,give,take. Изпълняват се по ред, когато отговорът е избран.
Условията и действията са кратък текст във формат вид:аргумент:аргумент, защото диалозите са данни: могат да живеят във файл, а не в кода. visibleOptions показва само отговорите с вярно условие, а choose изпълнява действията на избрания.
@dataclass(frozen=True)class Option: text: str next: str condition: str actions: str
@dataclass(frozen=True)class Node: text: str options: list
class Game: NAMES = {"ore": "руда", "silver_sword": "сребърен меч", "potion_heal": "лековита отвара"}
def __init__(self, *quests): self.flags = set() self.items = {} self.quests = {quest.id: quest for quest in quests}
def quest(self, id): return self.quests[id]
def count(self, id): return self.items.get(id, 0)
def label(self, id, n): return Game.NAMES[id] + " x" + str(n)
def give(self, id, n): self.items[id] = self.count(id) + n print("Получаваш: " + self.label(id, n) + ".")
def take(self, id, n): self.items[id] = self.count(id) - n print("Даваш: " + self.label(id, n) + ".")def split_list(text): return [] if text == "-" else text.split(",")
def check_condition(game, condition): for part in split_list(condition): negate = part.startswith("!") p = (part[1:] if negate else part).split(":") if p[0] == "flag": ok = p[1] in game.flags elif p[0] == "item": ok = game.count(p[1]) >= int(p[2]) elif p[0] == "quest": ok = game.quest(p[1]).state == p[2] else: raise ValueError("unknown condition: " + part) if negate: ok = not ok if not ok: return False return True
def run_actions(game, actions): for part in split_list(actions): p = part.split(":") if p[0] == "flag": game.flags.add(p[1]) elif p[0] == "start": game.quest(p[1]).start() elif p[0] == "complete": game.quest(p[1]).complete() elif p[0] == "reward": quest = game.quest(p[1]) quest.state = "rewarded" print("Награда за „" + quest.title + "“.") elif p[0] == "give": game.give(p[1], int(p[2])) elif p[0] == "take": game.take(p[1], int(p[2])) else: raise ValueError("unknown action: " + part)
def visible_options(game, node): return [o for o in node.options if o.condition == "-" or check_condition(game, o.condition)]
def show(speaker, game, node): print(speaker + ": " + node.text) for i, option in enumerate(visible_options(game, node)): print(" " + str(i + 1) + ") " + option.text)
def choose(game, node, number): option = visible_options(game, node)[number - 1] print("> " + str(number)) run_actions(game, option.actions) return option.nextДанните са двата диалога от играта: ковачката Вела (предмети и куест) и миньорът Добри (еднократен подарък с флаг).
SMITH = { "start": Node("Огнището ми е студено без сребро. Донесеш ли ми три парчета руда, ще ти изкова меч.", [ Option("Ще потърся руда.", "deal", "quest:silver:inactive", "start:silver"), Option("Ето рудата.", "done", "quest:silver:active,item:ore:3", "take:ore:3,complete:silver,reward:silver,give:silver_sword:1"), Option("Довиждане.", "end", "-", "-"), ]), "deal": Node("Рудата е на второто ниво. Големите от руда я носят в себе си.", [ Option("Добре.", "end", "-", "-"), ]), "done": Node("Сребърен меч. Острието му не се затъпява.", [ Option("Благодаря.", "end", "-", "-"), ]),}
MINER = { "start": Node("Жив човек! Другите избягаха нагоре... Аз останах затрупан тук.", [ Option("Какво има долу?", "warden", "quest:miners:active,!flag:miner_told", "flag:miner_told,give:potion_heal:1"), Option("Стой тук, ще се върна.", "end", "-", "-"), ]), "warden": Node("Пазачът на дълбините. Каменен страж, събуден от кирките ни. Когато се пропука, вика мъртвите на помощ. Вземи отварата ми.", [ Option("Ще го спра.", "end", "-", "-"), ]),}game = Game(Quest("silver", "Сребро за ковачката", ["Събери три парчета сребърна руда.", "Занеси рудата на ковачката Вела."], "Ковачката има своето сребро."), Quest("miners", "Изчезналите миньори", ["Слез в мината и потърси миньорите.", "Някой е жив на второто ниво. Намери го.", "Победи Пазача на дълбините на третото ниво."], "Пазачът е победен. Сивия брод е спасен."))start = SMITH["start"]show("Вела", game, start)next_id = choose(game, start, 1)show("Вела", game, SMITH[next_id])game.give("ore", 3)show("Вела", game, start)next_id = choose(game, start, 1)show("Вела", game, SMITH[next_id])game.quest("miners").start()miner_start = MINER["start"]show("Добри", game, miner_start)next_id = choose(game, miner_start, 1)show("Добри", game, MINER[next_id])show("Добри", game, miner_start)Вела: Огнището ми е студено без сребро. Донесеш ли ми три парчета руда, ще ти изкова меч. 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.def xp_for_level(level): if level < len(XP_TABLE): return XP_TABLE[level] return XP_TABLE[-1] + (level - len(XP_TABLE) + 1) * 150
def pending_levels(hero): levels = 0 while hero.xp >= xp_for_level(hero.level + levels + 1): levels += 1 return levels
# Новото ниво само по себе си: повече живот и мана, пълно здраве.def raise_level(hero): hero_class = hero.hero_class() hero.level += 1 hero.base['maxHp'] = hero.base['maxHp'] + hero_class.hp_per_level hero.base['maxMana'] = hero.base['maxMana'] + hero_class.mana_per_level hero.hp = hero.max_hp() hero.mana = hero.max_mana()
# Дървото на уменията: два клона по три стъпки. Стъпка се учи, ако предишната в клона е научена.def learnable_skills(hero): result = [] for skill in SKILLS.values(): if skill.class_id != hero.class_id or skill.id in hero.skills: continue ready = skill.tier == 1 for known in hero.skills: other = SKILLS[known] if other.branch == skill.branch and other.tier == skill.tier - 1: ready = True if ready: result.append(skill.id) return result
# Изборите при ново ниво: показател или ново умение.def level_up_choices(hero): choices = [ SimpleNamespace(kind='stat', stat='maxHp', amount=8), SimpleNamespace(kind='stat', stat='strength', amount=1), SimpleNamespace(kind='stat', stat='agility', amount=1), SimpleNamespace(kind='stat', stat='defense', amount=1), ] for skill_id in learnable_skills(hero): choices.append(SimpleNamespace(kind='skill', stat=skill_id, amount=0)) return choices
def apply_level_choice(hero, choice): if choice.kind == 'skill': hero.skills.append(choice.stat) else: hero.base[choice.stat] = hero.base[choice.stat] + choice.amount if choice.stat == 'maxHp': hero.hp = hero.max_hp()Разликите от нашата програма са малко:
raiseLevelв играта добавя и мана и връща пълно здраве; ние пипаме само максималния живот.- Нивото не се дава в
gainXp.enemyKilled(освен златото и трупа) вдигаhero.xpи пуска събитие, аqueueLevelUpsслага екранLevelUpScreenв стека (урокът за екраните): играчът избира показател или умение и едва тогаваraiseLevelиapplyLevelChoiceпроменят героя. Екранът е един за ниво, а не едно диалогово за всички. - Показателите се менят в
base(урокът за героите), а не в самия герой.
А шината е същата, като нашата:
# Шината от събития: който публикува, не знае кой слуша. Куестовете се абонират и сами следят# напредъка си, а бойната система само казва „врагът умря“.class EventBus: def __init__(self): self.handlers = {}
def subscribe(self, event_type, handler): if event_type not in self.handlers: self.handlers[event_type] = [] self.handlers[event_type].append(handler)
def publish(self, event): if event.type not in self.handlers: return for handler in list(self.handlers[event.type]): handler(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. Сам се абонира за# събитията, които го движат.class Quest(ABC): def __init__(self, quest_id): self.id = quest_id self.state = 'inactive' self.stage = 0
def title(self): return t('quest.' + self.id + '.title')
def goal(self): if self.state == 'done': return t('quest.' + self.id + '.done') if self.state == 'rewarded': return t('quest.rewarded') return t('quest.' + self.id + '.stage' + str(self.stage))
def start(self, game): self.state = 'active' self.stage = 1 game.say(t('quest.started', {'title': self.title()}))
def advance(self, game, stage): self.stage = stage game.say(t('quest.updated', {'title': self.title(), 'goal': self.goal()}))
def complete(self, game): self.state = 'done' game.say(t('quest.completed', {'title': self.title()}))
@abstractmethod def subscribe(self, bus, game): pass
class MissingMinersQuest(Quest): def __init__(self): super().__init__('miners')
def subscribe(self, bus, game): def on_tile_entered(event): if self.state == 'active' and self.stage == 1 and event.depth == 2: self.advance(game, 2)
def on_dialog_choice(event): if self.state == 'active' and self.stage < 3 and event.dialog_id == 'miner' and event.node_id == 'warden': self.advance(game, 3)
def on_enemy_killed(event): if self.state == 'active' and event.kind_id == 'warden': self.complete(game)
bus.subscribe('TileEntered', on_tile_entered) bus.subscribe('DialogChoice', on_dialog_choice) bus.subscribe('EnemyKilled', on_enemy_killed)
class BloodTrailQuest(Quest): def __init__(self): super().__init__('blood')
def start(self, game): super().start(game) if game.has_flag('leader_dead'): self.complete(game)
def subscribe(self, bus, game): def on_tile_entered(event): if self.state != 'active' or event.depth != 1: return if self.stage == 1 and event.decor == ',': self.advance(game, 2) if self.stage < 3 and event.marker == 'lair': self.advance(game, 3)
def on_enemy_killed(event): if event.kind_id != 'wolf_leader': return game.set_flag('leader_dead') if self.state == 'active': self.complete(game)
bus.subscribe('TileEntered', on_tile_entered) bus.subscribe('EnemyKilled', on_enemy_killed)
class SilverQuest(Quest): def __init__(self): super().__init__('silver')
def start(self, game): super().start(game) self.check(game)
def check(self, game): if self.state == 'active' and self.stage == 1 and game.hero.inventory.count('ore') >= 3: self.advance(game, 2)
def subscribe(self, bus, game): def on_item_picked(event): if event.item_id == 'ore': self.check(game)
bus.subscribe('ItemPicked', on_item_picked)
def create_quests(): return [MissingMinersQuest(), BloodTrailQuest(), SilverQuest()]- Куест, който започва след условието.
BloodTrailQuest.startиSilverQuest.startпроверяват веднага дали условието вече не е вярно: вълкът водач може да е мъртъв, а рудата в раницата, преди да е поет куестът. Така куестът не остава на етап 1 завинаги. НашиятMinersQuestне го прави, защото Пазачът не умира преди разговора със старейшината. - Етапи с повече от един източник.
BloodTrailQuestслушаTileEnteredпо два начина: за следа от кръв (decor) и за леговище (marker). Не е нужно събитията да са съгласувани: слушателят пита за състоянието си и отговаря. - Достигане на победата. Пазачът не е само
EnemyKilled: играта слагаvictoryвenemyKilled, а куестът върви от същото събитие. Куестът „Изчезналите миньори“ няма награда: победата показва екрана за победа.
Диалогът в канона е същото, което построихме, и още: действията включват и gold и xp (наградата на ловеца), а условията и gold: (достатъчно злато):
# Диалогът е дърво с условия: изборите, чиито условия не са изпълнени, не се показват.def check_condition(game, condition): for part in split_list(condition): negate = part[:1] == '!' pieces = (part[1:] if negate else part).split(':') ok = False if pieces[0] == 'flag': ok = game.has_flag(pieces[1]) elif pieces[0] == 'item': ok = game.hero.inventory.count(pieces[1]) >= to_int(pieces[2]) elif pieces[0] == 'gold': ok = game.hero.gold >= to_int(pieces[1]) elif pieces[0] == 'quest': ok = game.quest(pieces[1]).state == pieces[2] else: raise RuntimeError('unknown condition: ' + part) if negate: ok = not ok if not ok: return False return True
def run_actions(game, actions): for part in split_list(actions): pieces = part.split(':') if pieces[0] == 'flag': game.set_flag(pieces[1]) elif pieces[0] == 'start': game.quest(pieces[1]).start(game) elif pieces[0] == 'complete': game.quest(pieces[1]).complete(game) elif pieces[0] == 'reward': game.quest(pieces[1]).state = 'rewarded' game.say(t('quest.rewardedSay', {'title': game.quest(pieces[1]).title()})) elif pieces[0] == 'give': game.give_item(pieces[1], to_int(pieces[2])) elif pieces[0] == 'take': game.hero.inventory.remove(pieces[1], to_int(pieces[2])) game.say(t('item.given', {'item': item_label(pieces[1], to_int(pieces[2]))})) elif pieces[0] == 'gold': game.hero.gold += to_int(pieces[1]) game.say(t('item.gold', {'amount': pieces[1]})) elif pieces[0] == 'xp': game.gain_xp(to_int(pieces[1])) else: raise RuntimeError('unknown action: ' + part)
def visible_options(game, node): result = [] for option in node.options: if option.condition == '-' or check_condition(game, option.condition): result.append(option) return resultDialogScreen (екранът в стека) вика visibleOptions, а когато играчът избере отговор, изпълнява runActions и публикува DialogChoice със следващия възел. Така диалогът и куестът не знаят един за друг: разговорът с миньора мести куеста само защото миньорът е публикувал, а куестът е слушал.
Пробвай
Предвиди изхода
Три разчета на pendingLevels с таблицата от урока. Какво печата програмата?
# XP_TABLE[n] е общият опит, с който се стига ниво n.XP_TABLE = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720]
def xp_for_level(level): if level < len(XP_TABLE): return XP_TABLE[level] # След края на таблицата кривата продължава с 150 на ниво. return XP_TABLE[-1] + (level - len(XP_TABLE) + 1) * 150
# Колко нива чакат: опитът стига ли до следващите прагове.def pending_levels(xp, level): levels = 0 while xp >= xp_for_level(level + levels + 1): levels += 1 return levels
print(pending_levels(130, 1))print(pending_levels(104, 3))print(pending_levels(55, 1))При 130 опит и ниво 1 праговете 20, 55 и 105 са стигнати (130 ≥ 105), а 170 не е: три нива. При 104 опит и ниво 3 следващият праг е 105, а 104 не го стига: нула. При 55 опит и ниво 1 праговете са 20 и 55 (55 ≥ 55, равенството се брои), а 105 не: две. Вторият отговор е с > вместо >=; третият брои от xpForLevel(level), т.е. и нивото, на което героят вече е.
Пробвай
Намери грешката
Колега махна една проверка в MinersQuest и сега куестът се връща назад. Героят вече е говорил с миньора (етап 3) и пак слиза на второто ниво:
bus = EventBus()quest = MinersQuest()quest.subscribe(bus)
fire(bus, GameEvent("EnemyKilled", "rat"))fire(bus, GameEvent("TileEntered", depth=2))print("> старейшината дава куеста")quest.start()fire(bus, GameEvent("TileEntered", depth=1))fire(bus, GameEvent("TileEntered", depth=2))fire(bus, GameEvent("DialogChoice", "miner:warden"))fire(bus, GameEvent("TileEntered", depth=2))fire(bus, GameEvent("EnemyKilled", "warden"))> EnemyKilled rat> TileEntered 2> старейшината дава куестаНов куест: „Изчезналите миньори“.> TileEntered 1> TileEntered 2„Изчезналите миньори“: Някой е жив на второто ниво. Намери го.> DialogChoice miner:warden„Изчезналите миньори“: Победи Пазача на дълбините на третото ниво.> TileEntered 2„Изчезналите миньори“: Някой е жив на второто ниво. Намери го.> EnemyKilled wardenКуестът „Изчезналите миньори“ е изпълнен!В края на изхода, след третото TileEntered 2 (героят вече е говорил с миньора), целта пак е „Някой е жив на второто ниво“, макар че героят вече знае за Пазача. Кой ред причинява това?
Избери реда с грешката.
Условието на TileEntered не пита на кой етап е куестът: state == "active" и depth == 2 са вярно и на етап 3, затова слизането пак вика advance(2). Правилно: stage == 1 && depth == 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 са същите като в програмата за куеста. Ето сценария:
bus = EventBus()quest = WolfQuest()quest.subscribe(bus)
fire(bus, GameEvent("EnemyKilled", "wolf"))print("> start")quest.start()fire(bus, GameEvent("EnemyKilled", "rat"))fire(bus, GameEvent("EnemyKilled", "wolf"))fire(bus, GameEvent("EnemyKilled", "wolf"))fire(bus, GameEvent("EnemyKilled", "wolf"))fire(bus, 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?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Списъци: append, remove и sort · dict и set · Ламбди · dataclass, match и шаблони
- Задачи по темата: Хрониките на Арканум