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

Умения и действия като команди

Накратко: Командата е обект, който знае как да се изпълни и как да се запише като данни. Боят изпълнява команди през един метод; кой я избира (играч, изкуствен интелект или запис) е друг въпрос. Затова записаните команди и същото семе повтарят боя, а без екран боят върви хиляди пъти.

Проблем

Четирима избират, един изпълнява

В боя на играта има четирима „избиратели“. Играчът натиска 1, 2, 3 или 4. Гоблинът просто удря. Героят на автопилот, който върти симулацията, преценява какво му е най-изгодно. А повторението на боя трябва да направи същото, което е станало, без никой да избира нищо. Изборите са четири вида, а боят, който ги изпълнява, е един.

Ако изборът е верига if (избор == 1) … else if (избор == 2) … и тя стои на четири места, всяко ново действие са четири промени и четири шанса да забравиш една. Добавиш ли „пий отвара“, повторението тихо няма да я познава и ще показва друг бой.

Ето боя в играта. След 2 се отваря списък с уменията, а тези, които още се презареждат, са означени (Ярост: след 4 хода):

=== Бой ===
Ива, варварин ур.6
Ж [##########--] 86/103 М [########] 9/9
яр+- Умения -----------------------------------------+
1) П| 1) Ярост (0 мана) (след 4) |
Бой| 2) Вихър (3 мана) |
Ива| 3) Боен вик (2 мана) |
Ива| 4) Желязна кожа (3 мана) |
Паз| |
Паз| цифра: избор, x: назад |
1 уд+--------------------------------------------------+

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

Идеята

Командата е обект

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

from dataclasses import dataclass
# Записът на едно действие: кой (номер), какво, срещу кого (номер или -1) и кое умение или предмет.
@dataclass(frozen=True)
class ActionRecord:
actor: int
action: str
target: int
id: str
class AttackAction:
def __init__(self, actor, target):
self.actor = actor
self.target = target
def to_record(self, battle):
return ActionRecord(battle.index_of(self.actor), "attack", battle.index_of(self.target), "")
def execute(self, battle):
battle.hit(self.actor, self.target, self.actor.power + battle.rng.roll(3))
class ItemAction:
def __init__(self, actor, item_id):
self.actor = actor
self.item_id = item_id
def to_record(self, battle):
return ActionRecord(battle.index_of(self.actor), "item", -1, self.item_id)
def execute(self, battle):
self.actor.potions -= 1
healed = min(10, self.actor.max_hp - self.actor.hp)
self.actor.hp += healed
battle.say(self.actor.name + ": отвара, +" + str(healed) + " живот")
class FleeAction:
def __init__(self, actor):
self.actor = actor
def to_record(self, battle):
return ActionRecord(battle.index_of(self.actor), "flee", -1, "")
def execute(self, battle):
if battle.rng.roll(100) < 50:
battle.say(self.actor.name + " бяга.")
battle.over = True
battle.outcome = "fled"
else:
battle.say(self.actor.name + " не успява да избяга.")

Всяка команда пази кой действа (actor) и срещу кого (target) и има два метода:

  • execute(battle): изпълнява я. Командата не смята живота на другите сама: вика battle.hit, а той сваля живота и пише реда в дневника;
  • toRecord(battle): описва я като данни: номер на участника, дума за вида, номер на целта и умението или предмета. В записа няма живот, щета и случайност.

Затова боят може да държи списък от команди от различни видове и да вика на всички едно и също: това е полиморфизмът от наследяването, само че тук командите са обекти с общ интерфейс. Програмата слага в списък удар на героя, удар на гоблина, умение и отвара и ги дава на perform една по една. Редовете на боя излизат веднага (live), а накрая се печата записът.

battle = new_battle(7)
battle.live = True
hero = battle.hero()
goblin = battle.fighters[1]
queue = [
AttackAction(hero, goblin),
AttackAction(goblin, hero),
SkillAction(hero, skill_of(battle, "smash"), goblin),
ItemAction(hero, "potion"),
]
for action in queue:
battle.perform(action)
print("Запис:")
for r in battle.record:
print(describe(r))
Изход
Воин → Гоблин: 8 щета
Гоблин → Воин: 8 щета
Воин: Мощен удар
Воин → Гоблин: 12 щета
Гоблин пада.
Воин: отвара, +8 живот
Запис:
0 attack 1
1 attack 0
0 skill 1 smash
0 item -1 potion

Записът е това, което остава от боя: четири реда по няколко думи. Нужен е за повторението, което идва по-нататък.

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

# Действията са команди: обект с execute(battle). Играчът и изкуственият интелект избират
# команда, боят я изпълнява. Затова симулацията върти същия бой без екран.
class AttackAction:
def __init__(self, actor, target):
self.actor = actor
self.target = target
def to_record(self, battle):
return SimpleNamespace(actor=battle.index_of(self.actor), action='attack', target=battle.index_of(self.target), id='')
def execute(self, battle):
strike(battle, self.actor, self.target, self.actor.attack_profile(), 100)
class FleeAction:
def __init__(self, actor):
self.actor = actor
def to_record(self, battle):
return SimpleNamespace(actor=battle.index_of(self.actor), action='flee', target=-1, id='')
def execute(self, battle):
if not battle.can_flee:
battle.say(t('combat.noFlee'))
return
fastest = 0
for enemy in battle.alive_enemies():
fastest = max(fastest, enemy.stat('agility'))
chance = clamp(50 + (self.actor.stat('agility') - fastest) * 5, 15, 90)
if battle.game.rng.chance(chance):
battle.say(t('combat.fled', {'name': self.actor.name}))
battle.over = True
battle.outcome = 'fled'
else:
battle.say(t('combat.fleeFail', {'name': self.actor.name}))

Бягството има шанс (по ловкостта на най-бързия враг), но не пише щета. Враговете също използват команди: умения като плюенето на шамана или лечението му са класът SpecialAction, един на вид умение.

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

Това е шаблонът Command (от книгата на „четиримата“, Gang of Four): заявката става обект. От него следват неща, които с if са трудни: запис (запази командите), повторение (изиграй ги пак), отмяна (всяка команда знае и обратното си действие и командите се връщат назад по стек) и опашка (командата чака реда си). Тук се ползват първите две. Отмяна в бой със зарове, кървене и смърт е трудна, защото обратното на „щета 7“ не е просто „живот + 7“, затова играта пази запис, а не стек за отмяна. В текстов редактор или рисуване шаблонът служи точно за отмяна.

Идеята

Боят изпълнява

Боят е клас с участници, дневник и запис. Важното в него е едно място, през което минава всяка команда:

class Battle:
def __init__(self, seed, fighters, skills):
self.rng = Rng(seed)
self.fighters = fighters
self.skills = skills
self.log = []
self.record = []
self.over = False
self.live = False
self.outcome = "none"
self.rounds = 0
def hero(self):
return self.fighters[0]
def say(self, text):
self.log.append(text)
if self.live:
print(text)
def index_of(self, fighter):
return self.fighters.index(fighter)
def alive_enemies(self):
return [f for f in self.fighters[1:] if f.alive()]
def hit(self, actor, target, amount):
target.hp = max(0, target.hp - amount)
self.say(actor.name + " → " + target.name + ": " + str(amount) + " щета")
if not target.alive():
self.say(target.name + " пада.")
# Единственото място, през което минава всяка команда: първо се записва, после се изпълнява.
def perform(self, action):
self.record.append(action.to_record(self))
action.execute(self)
self.check_end()
def check_end(self):
if self.over:
return
if not self.hero().alive():
self.over = True
self.outcome = "lose"
self.say("Поражение.")
elif not self.alive_enemies():
self.over = True
self.outcome = "win"
self.say("Победа!")

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

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

В играта perform е същият, само че след изпълнението обработва смъртите (плячка, опит, събития за куестовете):

def perform(self, action):
self.record.append(action.to_record(self))
action.execute(self)
self.handle_deaths()

Идеята

Уменията са данни

SkillAction е един клас, а уменията са много: Вихърът удря всички, Мощният удар удря един враг със 170% от силата. Разликата е в таблица, не в код. Всеки ред от текста е едно умение: идентификатор, мана, презареждане, сила в проценти, цел и име за показване:

from dataclasses import dataclass
SKILLS = """
cleave|3|3|80|all|Вихър
smash|4|4|170|one|Мощен удар
""".strip()
@dataclass(frozen=True)
class Skill:
id: str
mana: int
cooldown: int
percent: int
target: str
name: str
def load_skills(text):
skills = []
for line in text.split("\n"):
p = line.split("|")
skills.append(Skill(p[0], int(p[1]), int(p[2]), int(p[3]), p[4], p[5]))
return skills

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

В играта таблицата е по-широка: освен мана и презареждане има клас и клон в дървото на уменията, вид (удар с оръжие, заклинание, лечение, ефект върху себе си, ефект върху враговете), цел one, all или self, брой удари, щета на заклинанието, и ефект с ходове и сила. Ето първите ѝ редове:

# Уменията: клас, клон (1 или 2), стъпка в клона (1–3), вид (weapon: с оръжието, spell: заклинание,
# heal: лекуване, buff: ефект върху себе си, debuff: ефект върху враговете), цел (one, all, self),
# мана, презареждане в ходове, удари, сила в проценти от удара с оръжие, щета на заклинанието
# (мин–макс), вид щета („-“ е видът на оръжието), ефект, ходове на ефекта, сила на ефекта.
SKILLS_DATA = """
# id|клас|клон|стъпка|вид|цел|мана|презареждане|удари|сила|мин|макс|щета|ефект|ходове|сила на ефекта|bg|en|de|es
rage|barbarian|1|1|buff|self|0|5|0|0|0|0|-|rage|3|6|Ярост|Rage|Wut|Ira
whirlwind|barbarian|1|2|weapon|all|3|3|1|80|0|0|-|-|0|0|Вихър|Whirlwind|Wirbelwind|Torbellino
bloodlust|barbarian|1|3|weapon|one|4|4|1|170|0|0|slash|bleed|3|2|Кървава жажда|Bloodlust|Blutrausch|Sed de sangre

Един SkillAction в играта чете ред от тази таблица и според вида прави удар с оръжие, заклинание, лечение или слага ефект:

class SkillAction:
def __init__(self, actor, skill, target):
self.actor = actor
self.skill = skill
self.target = target
def to_record(self, battle):
return SimpleNamespace(actor=battle.index_of(self.actor), action='skill', target=battle.index_of(self.target), id=self.skill.id)
def execute(self, battle):
hero = self.actor
skill = self.skill
hero.mana -= skill.mana
hero.cooldowns[skill.id] = skill.cooldown
battle.say(t('combat.uses', {'name': hero.name, 'skill': nm(skill)}))
targets = [self.target]
if skill.target == 'all':
targets = battle.alive_enemies()
if skill.target == 'self':
targets = [hero]
for target in targets:
if skill.kind == 'weapon':
profile = hero.attack_profile()
if skill.type != '-':
profile.type = skill.type
for hit in range(skill.hits):
if not target.is_alive():
break
landed = strike(battle, hero, target, profile, skill.power)
if landed and skill.effect != '-':
apply_effect(target, make_effect(skill.effect, skill.effect_turns, skill.effect_power), battle)
elif skill.kind == 'spell':
spell_hit(battle, hero, target, skill.min, skill.max, skill.type)
if skill.effect != '-':
apply_effect(target, make_effect(skill.effect, skill.effect_turns, skill.effect_power), battle)
elif skill.kind == 'heal':
healed = hero.heal(skill.min + hero.level * 2)
battle.say(t('combat.healed', {'name': hero.name, 'amount': healed}))
if skill.effect != '-':
apply_effect(hero, make_effect(skill.effect, skill.effect_turns, skill.effect_power), battle)
else:
apply_effect(target, make_effect(skill.effect, skill.effect_turns, skill.effect_power), battle)

Идеята

Мана и презареждане

Умение без цена е най-добрият избор всеки път. Затова има два ограничителя: мана (запас, който се харчи) и презареждане (колко хода умението не е на разположение след употреба). В кода те са на три места и всяко прави едно нещо:

  • SkillAction.execute плаща: сваля маната и слага презареждането;
  • Fighter.tick брои: в началото на хода на участника всяко презареждане намалява с едно;
  • problem пита: връща причината, поради която умението не е налично, или празен низ.
# Защо умението не е налично сега: празен низ, ако е налично.
def problem(battle, hero, skill):
if hero.cooldown_of(skill.id) > 0:
return "презарежда се: " + str(hero.cooldown_of(skill.id))
if hero.mana < skill.mana:
return "малко мана"
return ""
def usable_skills(battle, hero):
return [skill for skill in battle.skills if problem(battle, hero, skill) == ""]
class Fighter:
def __init__(self, name, hp, power, mana, potions):
self.name = name
self.hp = hp
self.max_hp = hp
self.power = power
self.mana = mana
self.max_mana = mana
self.potions = potions
self.cooldowns = {}
def alive(self):
return self.hp > 0
def cooldown_of(self, skill_id):
return self.cooldowns.get(skill_id, 0)
# Минава ход: презарежданията намаляват с едно.
def tick(self):
for skill_id in self.cooldowns:
self.cooldowns[skill_id] = max(0, self.cooldowns[skill_id] - 1)

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

battle = new_battle(7)
hero = battle.hero()
for turn in range(1, 7):
hero.tick()
print(f"Ход {turn}, мана {hero.mana}")
chosen = None
for skill in battle.skills:
why = problem(battle, hero, skill)
print(" " + skill.name + ": " + ("готово" if why == "" else why))
if why == "" and chosen is None:
chosen = skill
if chosen is None:
print(" нищо не може")
continue
print(" -> използва " + chosen.name)
battle.perform(SkillAction(hero, chosen, battle.alive_enemies()[0]))
Изход
Ход 1, мана 8
Вихър: готово
Мощен удар: готово
-> използва Вихър
Ход 2, мана 5
Вихър: презарежда се: 2
Мощен удар: готово
-> използва Мощен удар
Ход 3, мана 1
Вихър: презарежда се: 1
Мощен удар: презарежда се: 3
нищо не може
Ход 4, мана 1
Вихър: малко мана
Мощен удар: презарежда се: 2
нищо не може
Ход 5, мана 1
Вихър: малко мана
Мощен удар: презарежда се: 1
нищо не може
Ход 6, мана 1
Вихър: малко мана
Мощен удар: малко мана
нищо не може

Виж хода 2: Вихърът още се презарежда (2 хода), но Мощният удар е готов, защото маната стига. На хода 4 Вихърът вече е презареден, но маната (1) не стига. На хода 6 е обратното на хода 3: и двете са презаредени, а маната пак не стига за нито едно. Затова problem проверява презареждането първо: при Мощния удар причината се сменя от „презарежда се“ на „малко мана“ едва когато презареждането свърши.

self.actor.cooldowns[self.skill.id] = 

В играта списъкът с налични умения е същата проверка, само че върху героя на играта:

def usable_skills(hero):
result = []
for skill_id in hero.skills:
skill = SKILLS[skill_id]
if hero.mana >= skill.mana and hero.cooldown_of(skill.id) == 0:
result.append(skill)
return result
По-точно, за любопитните

В играта маната се връща с една на всяка крачка по картата, не на ход в боя, а също с отвара и при ново ниво. Затова в боя тя е запас, който трябва да стигне: ако я похарчиш за Вихъра на първия ход, нямаш я, когато шефът я поиска. Презареждането се брои на ходовете на самия участник (tick е в началото на неговия ход), не на рундовете. Затова по-бърз участник, който действа по-често, презарежда по-често.

Идеята

Целите

Умението има цел: one (един враг), all (всички живи) или self (самият герой). SkillAction събира целите в списък и прилага ефекта към всяка: при all това са всички живи врагове, иначе е една цел. Затова Вихърът удря и гоблина, и плъха с една команда, а Мощният удар само един.

Но кой избира целта? Играчът пише номер: врагът е 1 или 2 сред живите, а без номер е първият. Програмата превръща реда на играча в команда в parse:

# Целта е номер от 1 сред живите врагове; без номер е първият.
def aim(battle, parts, index):
alive = battle.alive_enemies()
if len(parts) <= index:
return alive[0]
text = parts[index]
if len(text) != 1 or not text.isdigit():
return None
number = int(text)
return alive[number - 1] if 1 <= number <= len(alive) else None
# Невалидната команда не струва ход: казва защо и пита пак.
def refuse(battle, line, reason):
print("? " + line.strip() + ": " + reason)
return None
def parse(battle, hero, line):
parts = line.split()
word = parts[0] if parts else ""
if word == "attack":
target = aim(battle, parts, 1)
return refuse(battle, line, "няма такава цел") if target is None else AttackAction(hero, target)
if word == "skill":
skill = skill_of(battle, parts[1]) if len(parts) > 1 else None
if skill is None:
return refuse(battle, line, "непознато умение")
why = problem(battle, hero, skill)
if why != "":
return refuse(battle, line, why)
target = aim(battle, parts, 2)
return refuse(battle, line, "няма такава цел") if target is None else SkillAction(hero, skill, target)
if word == "item":
return ItemAction(hero, "potion") if hero.potions > 0 else refuse(battle, line, "няма отвари")
if word == "flee":
return FleeAction(hero)
return refuse(battle, line, "непозната команда")

Забележи, че грешната команда не струва ход. parse казва причината (непознато умение, няма такава цел, умението се презарежда) и връща нищо, а Player чете следващия ред. Команда, която не е направена, не се записва и не се изпълнява, затова боят не научава за нея. Правилото е същото като при блъскането в стена на картата.

Програмата чете командите от стандартния вход, по една на ред. Част от тях са грешни нарочно:

Вход
skill fireball
attack 5
skill cleave
skill cleave
dance
skill smash 2
item
attack 1
attack
battle = new_battle(7)
battle.live = True
lines = [line for line in sys.stdin.read().split("\n") if line.strip()]
run(battle, Player(lines))
Изход
== Рунда 1 ==
? skill fireball: непознато умение
? attack 5: няма такава цел
Воин: Вихър
Воин → Гоблин: 6 щета
Воин → Плъх: 6 щета
Гоблин → Воин: 8 щета
Плъх → Воин: 4 щета
== Рунда 2 ==
? skill cleave: презарежда се: 2
? dance: непозната команда
Воин: Мощен удар
Воин → Плъх: 12 щета
Плъх пада.
Гоблин → Воин: 7 щета
== Рунда 3 ==
Воин: отвара, +10 живот
Гоблин → Воин: 7 щета
== Рунда 4 ==
Воин → Гоблин: 6 щета
Гоблин → Воин: 6 щета
== Рунда 5 ==
Воин → Гоблин: 7 щета
Гоблин пада.
Победа!

Редовете с ? са отказите: няма умение „fireball“, няма пети враг, Вихърът още се презарежда, „dance“ не е команда. Нито един не е минал през perform, затова не е нито в дневника, нито в записа.

В играта изборът на цел е отделен екран над боя, прозрачен, който не удря сам: връща номера на целта с pop(result), а боят решава какво да прави с него (урокът за екраните, които връщат отговор). Така изглежда:

=== Бой ===
Ива, варварин ур.1
Ж [############] 48/48 М [########] 4/4
1) Гоблин копиеносец [########] 16/16
2) Гоблин шаман [########] 13/13
Бой! Срещу теб: гоблин копиеносец, гоблин шаман.
1 удар 2 умение 3 предмет 4 бягство
+- Цел ----------------------------+
| 1) гоблин копиеносец 16/16 |
| 2) гоблин шаман 13/13 |
| |
| цифра: цел, x: назад |
+----------------------------------+
# Изборът на цел е прозрачен екран над боя. Не удря сам: връща номера на целта с pop(result),
# а боят решава какво да прави с нея.
class TargetScreen(Screen):
def __init__(self, game, battle):
super().__init__(game)
self.battle = battle
self.note = ''
def is_transparent(self):
return True
def draw(self, buffer):
enemies = self.battle.alive_enemies()
buffer.box(24, 9, 36, len(enemies) + 4, t('target.title'))
for i in range(len(enemies)):
buffer.text(26, 10 + i, str(i + 1) + ') ' + pad(enemies[i].name, 22) + ' ' + str(enemies[i].hp) + '/' + str(enemies[i].max_hp()), enemies[i].kind.color)
buffer.text(26, 11 + len(enemies), self.note if len(self.note) > 0 else t('target.help'), 'gray')
def handle(self, command):
if command == 'x':
self.game.stack.pop(None)
return
choice = parse_int_strict(command)
if choice is None or choice < 1 or choice > len(self.battle.alive_enemies()):
self.note = t('ui.unknown', {'command': command})
return
self.game.stack.pop(choice - 1)

Екранът на боя пази какво е започнато (pending), а onResult довършва командата, когато отговорът дойде: проверява маната и презареждането, при нужда отваря втори екран за целта и накрая вика heroAct с готовата команда:

def on_result(self, result):
battle = self.battle
hero = battle.hero
pending = self.pending
self.pending = None
if result is None or pending is None:
return
if pending.kind == 'attack':
battle.hero_act(AttackAction(hero, battle.alive_enemies()[result]))
elif pending.kind == 'skill':
skill = SKILLS[hero.skills[result]]
if hero.mana < skill.mana:
self.note = t('battle.noMana', {'skill': nm(skill)})
elif hero.cooldown_of(skill.id) > 0:
self.note = t('battle.cooldown', {'skill': nm(skill), 'turns': hero.cooldown_of(skill.id)})
elif skill.target == 'one' and len(battle.alive_enemies()) > 1:
self.pending = SimpleNamespace(kind='skillTarget', skill=skill)
self.game.stack.push(TargetScreen(self.game, battle))
else:
battle.hero_act(SkillAction(hero, skill, battle.alive_enemies()[0]))
elif pending.kind == 'skillTarget':
battle.hero_act(SkillAction(hero, pending.skill, battle.alive_enemies()[result]))
else:
battle.hero_act(ItemAction(hero, self.potion_ids()[result]))

Идеята

Кой избира: chooser

Командите вече ги има. Кой ги избира? Всеки избирател е обект със същия метод choose(battle, actor), който връща команда или нищо (край на входа):

class Auto:
def choose(self, battle, actor):
return choose_hero(battle) if actor is battle.hero() else choose_enemy(battle, actor)
class Player:
def __init__(self, lines):
self.lines = lines
self.next = 0
def choose(self, battle, actor):
if actor is not battle.hero():
return choose_enemy(battle, actor)
while self.next < len(self.lines):
action = parse(battle, actor, self.lines[self.next])
self.next += 1
if action is not None:
return action
return None
class Replay:
def __init__(self, record):
self.record = record
self.next = 0
def choose(self, battle, actor):
if self.next >= len(self.record):
return None
action = action_from_record(battle, self.record[self.next])
self.next += 1
return action
def weakest(battle):
best = None
for enemy in battle.alive_enemies():
if best is None or enemy.hp < best.hp:
best = enemy
return best
def choose_enemy(battle, enemy):
return AttackAction(enemy, battle.hero())
def choose_hero(battle):
hero = battle.hero()
target = weakest(battle)
if hero.hp * 100 // hero.max_hp < 35 and hero.potions > 0:
return ItemAction(hero, "potion")
usable = usable_skills(battle, hero)
for skill in usable:
if skill.target == "all" and len(battle.alive_enemies()) >= 2:
return SkillAction(hero, skill, target)
for skill in usable:
if skill.target == "one":
return SkillAction(hero, skill, target)
return AttackAction(hero, target)
  • Auto избира за всички: враговете удрят героя, а героят преценява по приоритет: отвара при малко живот, умение за много цели, силен удар, обикновен удар. Така върви симулацията. Това е най-простата „оценка на полезността“; по-умните решения на враговете са темата на следващия урок (Врагове, които мислят);
  • Player дава командите на героя от входа, а враговете остават на изкуствения интелект;
  • Replay ще видим след малко.

Цикълът на боя run не знае кой е кой. Той върти рундове, на всеки участник казва „на ред си“ и взима команда от chooser-а. Затова кой избира и какво се изпълнява са две независими неща: играчът, автопилотът и записът се сменят, без да се пипа боят.

# Един бой: рундове, а във всеки ход на всички по ред. Командата идва от chooser-а.
def run(battle, chooser):
while not battle.over and battle.rounds < 12:
battle.rounds += 1
battle.say("== Рунда " + str(battle.rounds) + " ==")
for actor in battle.fighters:
if battle.over:
break
if not actor.alive():
continue
actor.tick()
action = chooser.choose(battle, actor)
if action is None:
battle.over = True
break
battle.perform(action)
def action_from_record(battle, r):
actor = battle.fighters[r.actor]
target = battle.fighters[r.target] if r.target >= 0 else None
if r.action == "attack":
return AttackAction(actor, target)
if r.action == "skill":
return SkillAction(actor, skill_of(battle, r.id), target)
if r.action == "item":
return ItemAction(actor, r.id)
return FleeAction(actor)
def describe(r):
return f"{r.actor} {r.action} {r.target}" + (f" {r.id}" if r.id else "")
# Повторението: ново начало от същото семе, а записът вместо избор.
def replay_battle(seed, record):
battle = new_battle(seed)
run(battle, Replay(record))
return battle
def same_lines(first, second):
if len(first) != len(second):
return False
for i in range(len(first)):
if first[i] != second[i]:
return False
return True

Виж го

Запис и повторение

Командите вече минават през perform, а там се записват. Остава да ги пуснем пак. Повторението е нов бой от същото начало (същото семе, същите участници), в който chooser-ът е Replay: той дава записаните команди една по една. На сцената горе е боят на живо, по средата е лентата със записа, долу е повторението.

# Единственото място, през което минава всяка команда: първо се записва, после се изпълнява.
def perform(self, action):
self.record.append(action.to_record(self))
action.execute(self)
self.check_end()
# Един бой: рундове, а във всеки ход на всички по ред. Командата идва от chooser-а.
def run(battle, chooser):
while not battle.over and battle.rounds < 12:
battle.rounds += 1
battle.say("== Рунда " + str(battle.rounds) + " ==")
for actor in battle.fighters:
if battle.over:
break
if not actor.alive():
continue
actor.tick()
action = chooser.choose(battle, actor)
if action is None:
battle.over = True
break
battle.perform(action)
# Повторението: ново начало от същото семе, а записът вместо избор.
def replay_battle(seed, record):
battle = new_battle(seed)
run(battle, Replay(record))
return battle
def same_lines(first, second):
if len(first) != len(second):
return False
for i in range(len(first)):
if first[i] != second[i]:
return False
return True
Стъпка по стъпка, с думи
  1. Боят върти рундове. На ред е един участник и chooser дава команда: изкуственият интелект (или играчът) за героя, удар за враговете. Боят не пита каква е командата: само я взема.
  2. perform записва командата и я изпълнява. Първата е умението от таблицата (0 skill 2): маната пада до 5; животът на гоблина е 18 → 12, на плъха 10 → 4. Жетонът на лентата е записът ѝ.
  3. Всяка команда, на героя и на враговете, минава през същия perform и пуска жетон на лентата. Записът е само данни: кой (номер), какво, срещу кого. В него няма живот, щета и случайност.
  4. Боят свърши: 10 команди в записа (лентата показва първите 8). Повторението започва наново от същото семе и със същите участници, пълни с живот. Само изборът е различен: командите идват от записа, не от играч и изкуствен интелект.
  5. Replay.choose дава следващия жетон като команда, а run и perform са същите като на живо. Генераторът е със същото семе, затова всеки удар излиза същият и числата в долния бой повтарят горния ход по ход.
  6. sameLines сравнява двата дневника ред по ред: 21 реда и нито една разлика. Затова повторението може да се провери, а не само да се гледа.
  7. Семето и записът са целият бой. Същият запис, пуснат без екран хиляди пъти, е и симулацията за баланса.

Програмата играе бой с автопилот, печата записа и го пуска два пъти: със същото семе и с друго.

battle = new_battle(7)
run(battle, Auto())
print("Запис:")
for r in battle.record:
print(describe(r))
again = replay_battle(7, battle.record)
print("Същият бой от записа: " + ("да" if same_lines(battle.log, again.log) else "не"))
other = replay_battle(8, battle.record)
print("Същият бой от друго семе: " + ("да" if same_lines(battle.log, other.log) else "не"))
Изход
Запис:
0 skill 2 cleave
1 attack 0
2 attack 0
0 skill 2 smash
1 attack 0
0 item -1 potion
1 attack 0
0 attack 1
1 attack 0
0 attack 1
Същият бой от записа: да
Същият бой от друго семе: не

Първото повторение съвпада с боя ред по ред, второто не. Записът пази изборите (кой, какво, срещу кого), не заровете. Щетите и уцелванията идват от генератора със семе, затова същият запис върху друго семе дава същите избори върху други числа. Това е и причината записът да е малък: 10 команди, не 21 реда дневник.

В играта повторението е екран след победа (клавишът r). Той показва същия дневник по страници и отговор дали съвпада с боя:

=== Повторение на боя (1/1) ===
Бой! Срещу теб: гигантски плъх.
Гигантски плъх драска Ива (1).
Ива пропуска гигантски плъх.
Гигантски плъх драска Ива (1).
Ива смазва гигантски плъх (12).
Гигантски плъх пада и не става.
Злато: +1.
Гигантски плъх оставя: противоотрова.
Повторението съвпада с боя: да
(Enter: обратно)
def action_from_record(battle, record):
actor = battle.creature_at(record.actor)
target = battle.creature_at(record.target) if record.target >= 0 else None
if record.action == 'attack':
return AttackAction(actor, target)
if record.action == 'skill':
return SkillAction(actor, SKILLS[record.id], target)
if record.action == 'item':
return ItemAction(actor, record.id)
if record.action == 'flee':
return FleeAction(actor)
return SpecialAction(actor, record.id, target)
# Връща дневника на повторения бой.
def replay_battle(snapshot, record):
world = ReplayWorld(snapshot)
enemies = []
for enemy in snapshot.enemies:
enemies.append(enemy.copy())
battle = Battle(world, enemies, snapshot.can_flee)
battle.script = record
battle.start()
battle.advance()
return battle.log
def same_lines(first, second):
if len(first) != len(second):
return False
for i in range(len(first)):
if first[i] != second[i]:
return False
return True
По-точно, за любопитните

В играта повторението не започва от семето на света, а от снимка на началото на боя: състоянието на генератора и копия на героя и враговете (с уменията, маната и предметите на героя в този миг). Героят има инвентар и ниво, които не се строят от семе, затова снимката ги носи. Повторението е Battle с script вместо играч и изкуствен интелект: advance взема командите от записа. Светът за повторението (ReplayWorld) не се променя: няма трупове, злато и куестове, остава само дневникът.

Записът зависи от версията на правилата: ако промениш щетата на Вихъра, старите записи вече не съвпадат. Затова записите са добри за проверка и за търсене на грешки в същата версия, не за вечно хранилище.

Идеята

Хиляда боя без екран

Пуснеш ли run с Auto за двете страни, не ти трябват екран, играч и запис. Програмата играе боеве със семена от 1 до 1000 и брои победите:

print("Симулация без екран")
wins = 0
losses = 0
commands = 0
total = 1000
for seed in range(1, total + 1):
battle = new_battle(seed)
run(battle, Auto())
if battle.outcome == "win":
wins += 1
else:
losses += 1
commands += len(battle.record)
average = commands * 10 // total
print(f"Боеве: {total}, победи: {wins}, поражения: {losses}, средно команди: {average // 10}.{average % 10}")
Изход
Симулация без екран
Боеве: 1000, победи: 875, поражения: 125, средно команди: 9.9

Резултатът е 875 победи и 125 поражения. Така се мери балансът: ако героят печели всеки бой, враговете са слаби; ако го губи, са твърде силни; числата се бутат, докато изходът стане такъв, какъвто искаш. Всичко е детерминистично: същият код дава същите числа всеки път и на всеки език.

В играта това е --simulate=1000: боеве за всеки клас срещу всеки враг, както ще ги видиш в последния урок. Основата е тази: героят на автопилот (autoHero) избира команди със същата оценка, а боят върви със същия advance, без екран:

def simulate_fight(game, hero_class, kind):
game.hero = make_sim_hero(game, hero_class, SIM_LEVELS[kind.depth])
battle = Battle(game, [Enemy(game.new_id(), kind)], False)
battle.auto_hero = True
battle.advance()
return SimpleNamespace(win=battle.outcome == 'win', turns=battle.hero_turns)

Симулацията е възможна, защото боят пише в дневник, не на екрана (в урока live само го копира там), а изборът на команда е сменяем.

Пробвай

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

Героят ползва на всеки ход последното налично умение (Мощния удар, ако може, иначе Вихъра). Вихърът струва 3 мана и се презарежда 3 хода, Мощният удар струва 4 и се презарежда 4, а героят започва с 8 мана. Какво печата програмата?

battle = new_battle(7)
hero = battle.hero()
for turn in range(1, 6):
hero.tick()
used = "-"
for skill in usable_skills(battle, hero):
used = skill.id
if used != "-":
battle.perform(SkillAction(hero, skill_of(battle, used), battle.fighters[1]))
print(turn, used)
Отговор

Пробвай

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

Колега промени SkillAction и сега повторението не съвпада с боя. Записът изглежда странно:

class SkillAction:
def __init__(self, actor, skill, target):
self.actor = actor
self.skill = skill
self.target = target
def to_record(self, battle):
return ActionRecord(battle.index_of(self.actor), "attack", battle.index_of(self.target), self.skill.id)
def execute(self, battle):
self.actor.mana -= self.skill.mana
self.actor.cooldowns[self.skill.id] = self.skill.cooldown
battle.say(self.actor.name + ": " + self.skill.name)
targets = battle.alive_enemies() if self.skill.target == "all" else [self.target]
for victim in targets:
battle.hit(self.actor, victim, self.actor.power * self.skill.percent // 100 + battle.rng.roll(3))
battle = new_battle(7)
run(battle, Auto())
print("Запис:")
for r in battle.record:
print(describe(r))
again = replay_battle(7, battle.record)
print("Същият бой от записа: " + ("да" if same_lines(battle.log, again.log) else "не"))
Изход
Запис:
0 attack 2 cleave
1 attack 0
2 attack 0
0 attack 2 smash
1 attack 0
0 item -1 potion
1 attack 0
0 attack 1
1 attack 0
0 attack 1
Същият бой от записа: не

В записа Вихърът е attack. Кой ред е виновен?

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

По-точно

Какво не може командата

Командата като обект е удобна, но има граници, които си струва да знаеш.

Класът сам не пази от незаконна команда. SkillAction не проверява маната, защото я проверяват parse (за играча) и usableSkills (за изкуствения интелект), преди да го създадат. Ако някъде заобиколиш тези проверки, маната става отрицателна. Другият избор е проверката да е в самата команда, която връща „не мога“. Играта избира първия: незаконна команда не се създава.

Записът зависи от правилата. Той пази изборите, не последствията. Ако промениш формулата на щетата или добавиш още едно хвърляне на зарове, старите записи дават друг бой. Редът на извикванията към генератора е част от договора.

Случайността трябва да е в семето. Ако част от боя ползва Random на езика или часа, повторението се разминава. Затова играта има собствен генератор, а щетата и уцелванията се хвърлят само на няколко места.

Командата не е събитие. Събитието казва „нещо стана“ (враг умря), командата казва „направи“. Шината от събития е друг урок (куестовете) и двете често работят заедно: командата променя боя, а боят пуска събитие.

Класът на вид не расте безкрайно. За четири вида команди е добре, за осемнадесет умения е много. Затова уменията са данни, а SkillAction е един клас за всичките.

Упражнение

Почивка

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

Почивка. Добави четвърти вид команда за героя: rest връща 2 мана (не повече от максимума) и струва хода. Играчът я пише като rest. Трябва да се пипнат три места: самият клас RestAction (с toRecord, който връща rest), parse (думата на играча) и actionFromRecord (записът). В края програмата изиграва повторението и пише дали съвпада. Входът (по един на ред) е `skill smash 2`, `dance`, `rest`, `item`, `skill smash 1`, `attack 1`, `skill smash 1`, а изходът е:

Вход
skill smash 2
dance
rest
item
skill smash 1
attack 1
skill smash 1
Изход
== Рунда 1 ==
Воин: Мощен удар
Воин → Плъх: 12 щета
Плъх пада.
Гоблин → Воин: 8 щета
== Рунда 2 ==
? dance: непозната команда
Воин: почивка +2 мана
Гоблин → Воин: 8 щета
== Рунда 3 ==
Воин: отвара, +10 живот
Гоблин → Воин: 6 щета
== Рунда 4 ==
? skill smash 1: презарежда се: 1
Воин → Гоблин: 8 щета
Гоблин → Воин: 7 щета
== Рунда 5 ==
Воин: Мощен удар
Воин → Гоблин: 11 щета
Гоблин пада.
Победа!
Същият бой от записа: да

Още задачи

  • Лечебно умение. Добави в таблицата умение, което лекува героя (цел self). Колко реда от кода трябва да се променят и защо?
  • Бягство. FleeAction хвърля зарове (50%). Подай flee във входа на играча и виж повторението: съвпада ли? Защо хвърлянето не е в записа?
  • Отмяна. Опитай да добавиш метод undo() на AttackAction и ItemAction. Коя от командите не може да се върне без снимка на състоянието?
  • Баланс. Увеличи силата на гоблина с 1 и пусни симулацията. Колко се променят победите и защо числата са от хиляда боя, а не от един?

Тест

Провери се

1. Добавяш към играта команда „почивка“. Освен самия клас, кои още две места трябва да научиш за нея, за да работят и играчът, и повторението?
2. Записът пази номера на участника (0, 1, 2), а не самия обект. Защо?
3. Героят има 8 мана и ползва Вихър (3 мана, презареждане 3). На следващия си ход играчът пише същото умение. Какво става?
4. Повторението с друго семе („Същият бой от друго семе: не“) ползва същия запис. Защо излиза друг бой?
5. Какво прави възможна симулацията без екран?

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