Умения и действия като команди
Накратко: Командата е обект, който знае как да се изпълни и как да се запише като данни. Боят изпълнява команди през един метод; кой я избира (играч, изкуствен интелект или запис) е друг въпрос. Затова записаните команди и същото семе повтарят боя, а без екран боят върви хиляди пъти.
Проблем
Четирима избират, един изпълнява
В боя на играта има четирима „избиратели“. Играчът натиска 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 уд+--------------------------------------------------+Идеята е да превърнем действието в обект: команда. Всяка команда знае как да се изпълни и как да се запише като данни. Боят не пита каква е, само я изпълнява. Кой я е избрал (играч, враг, изкуствен интелект или запис) е друг въпрос и боят не се занимава с него. Урокът строи това на малка арена и после показва същото място в играта: командите, уменията от таблица, маната и презарежданията, целите, повторението на боя и симулацията без екран.
Идеята
Командата е обект
Арената на урока е малка: воин срещу гоблин и плъх. Сметките са с цели числа, а „заровете“ са генератор със семе (както в уводния урок), затова боят е един и същ на всеки език. Командите са четири вида и от гледна точка на боя правят едно и също:
// Записът на едно действие: кой (номер), какво, срещу кого (номер или -1) и кое умение или предмет.record ActionRecord(int Actor, string Action, int Target, string Id);
// Командата е обект: знае как да се запише и как да се изпълни.interface IBattleAction{ ActionRecord ToRecord(Battle battle);
void Execute(Battle battle);}
class AttackAction : IBattleAction{ public readonly Fighter Actor; public readonly Fighter Target;
public AttackAction(Fighter actor, Fighter target) { Actor = actor; Target = target; }
public ActionRecord ToRecord(Battle battle) { return new ActionRecord(battle.IndexOf(Actor), "attack", battle.IndexOf(Target), ""); }
public void Execute(Battle battle) { battle.Hit(Actor, Target, Actor.Power + battle.Rng.Roll(3)); }}
class ItemAction : IBattleAction{ public readonly Fighter Actor; public readonly string ItemId;
public ItemAction(Fighter actor, string itemId) { Actor = actor; ItemId = itemId; }
public ActionRecord ToRecord(Battle battle) { return new ActionRecord(battle.IndexOf(Actor), "item", -1, ItemId); }
public void Execute(Battle battle) { Actor.Potions -= 1; int healed = Math.Min(10, Actor.MaxHp - Actor.Hp); Actor.Hp += healed; battle.Say(Actor.Name + ": отвара, +" + healed + " живот"); }}
class FleeAction : IBattleAction{ public readonly Fighter Actor;
public FleeAction(Fighter actor) { Actor = actor; }
public ActionRecord ToRecord(Battle battle) { return new ActionRecord(battle.IndexOf(Actor), "flee", -1, ""); }
public void Execute(Battle battle) { if (battle.Rng.Roll(100) < 50) { battle.Say(Actor.Name + " бяга."); battle.Over = true; battle.Outcome = "fled"; } else { battle.Say(Actor.Name + " не успява да избяга."); } }}Всяка команда пази кой действа (actor) и срещу кого (target) и има два метода:
execute(battle): изпълнява я. Командата не смята живота на другите сама: викаbattle.hit, а той сваля живота и пише реда в дневника;toRecord(battle): описва я като данни: номер на участника, дума за вида, номер на целта и умението или предмета. В записа няма живот, щета и случайност.
Затова боят може да държи списък от команди от различни видове и да вика на всички едно и също: това е полиморфизмът от наследяването, само че тук командите са обекти с общ интерфейс. Програмата слага в списък удар на героя, удар на гоблина, умение и отвара и ги дава на perform една по една. Редовете на боя излизат веднага (live), а накрая се печата записът.
Battle battle = NewBattle(7);battle.Live = true;Fighter hero = battle.Hero();Fighter goblin = battle.Fighters[1];var queue = new List<IBattleAction>{ new AttackAction(hero, goblin), new AttackAction(goblin, hero), new SkillAction(hero, SkillOf(battle, "smash")!, goblin), new ItemAction(hero, "potion"),};foreach (IBattleAction action in queue){ battle.Perform(action);}Console.WriteLine("Запис:");foreach (ActionRecord r in battle.Record){ Console.WriteLine(Describe(r));}Воин → Гоблин: 8 щетаГоблин → Воин: 8 щетаВоин: Мощен ударВоин → Гоблин: 12 щетаГоблин пада.Воин: отвара, +8 животЗапис:0 attack 11 attack 00 skill 1 smash0 item -1 potionЗаписът е това, което остава от боя: четири реда по няколко думи. Нужен е за повторението, което идва по-нататък.
В играта класовете са същите по форма. Така изглеждат ударът и бягството:
class AttackAction : IBattleAction{ public Creature Actor; public Creature? Target;
public AttackAction(Creature actor, Creature? target) { Actor = actor; Target = target; }
public ActionRecord ToRecord(Battle battle) { return new ActionRecord { Actor = battle.IndexOf(Actor), Action = "attack", Target = battle.IndexOf(Target), Id = "" }; }
public void Execute(Battle battle) { Strike(battle, Actor, Target!, Actor.AttackProfile(), 100); }}class FleeAction : IBattleAction{ public Creature Actor;
public FleeAction(Creature actor) { Actor = actor; }
public ActionRecord ToRecord(Battle battle) { return new ActionRecord { Actor = battle.IndexOf(Actor), Action = "flee", Target = -1, Id = "" }; }
public void Execute(Battle battle) { if (!battle.CanFlee) { battle.Say(T("combat.noFlee")); return; } int fastest = 0; foreach (Enemy enemy in battle.AliveEnemies()) fastest = Math.Max(fastest, enemy.Stat("agility")); int chance = Clamp(50 + (Actor.Stat("agility") - fastest) * 5, 15, 90); if (battle.Game.Rng.Chance(chance)) { battle.Say(T("combat.fled", ("name", Actor.Name))); battle.Over = true; battle.Outcome = "fled"; } else { battle.Say(T("combat.fleeFail", ("name", Actor.Name))); } }}Бягството има шанс (по ловкостта на най-бързия враг), но не пише щета. Враговете също използват команди: умения като плюенето на шамана или лечението му са класът SpecialAction, един на вид умение.
По-точно, за любопитните
Това е шаблонът Command (от книгата на „четиримата“, Gang of Four): заявката става обект. От него следват неща, които с if са трудни: запис (запази командите), повторение (изиграй ги пак), отмяна (всяка команда знае и обратното си действие и командите се връщат назад по стек) и опашка (командата чака реда си). Тук се ползват първите две. Отмяна в бой със зарове, кървене и смърт е трудна, защото обратното на „щета 7“ не е просто „живот + 7“, затова играта пази запис, а не стек за отмяна. В текстов редактор или рисуване шаблонът служи точно за отмяна.
Идеята
Боят изпълнява
Боят е клас с участници, дневник и запис. Важното в него е едно място, през което минава всяка команда:
class Battle{ public readonly Rng Rng; public readonly List<Fighter> Fighters; public readonly List<Skill> Skills; public readonly List<string> Log = new(); public readonly List<ActionRecord> Record = new(); public bool Over = false; public bool Live = false; public string Outcome = "none"; public int Rounds = 0;
public Battle(int seed, List<Fighter> fighters, List<Skill> skills) { Rng = new Rng(seed); Fighters = fighters; Skills = skills; }
public Fighter Hero() => Fighters[0];
public void Say(string text) { Log.Add(text); if (Live) Console.WriteLine(text); }
public int IndexOf(Fighter fighter) => Fighters.IndexOf(fighter);
public List<Fighter> AliveEnemies() { var result = new List<Fighter>(); for (int i = 1; i < Fighters.Count; i++) { if (Fighters[i].Alive()) result.Add(Fighters[i]); } return result; }
public void Hit(Fighter actor, Fighter target, int amount) { target.Hp = Math.Max(0, target.Hp - amount); Say(actor.Name + " → " + target.Name + ": " + amount + " щета"); if (!target.Alive()) Say(target.Name + " пада."); }
// Единственото място, през което минава всяка команда: първо се записва, после се изпълнява. public void Perform(IBattleAction action) { Record.Add(action.ToRecord(this)); action.Execute(this); CheckEnd(); }
public void CheckEnd() { if (Over) return; if (!Hero().Alive()) { Over = true; Outcome = "lose"; Say("Поражение."); } else if (AliveEnemies().Count == 0) { Over = true; Outcome = "win"; Say("Победа!"); } }}perform прави три неща в този ред: записва командата, изпълнява я и проверява дали боят е свършил. Заобиколен път няма: нито героят, нито гоблинът, нито повторението изпълняват нещо по друг начин. Затова записът е пълен: ако нещо е променило боя, то е минало оттук.
hit е второто място, което си заслужава да е само едно: само то сваля живота и пише реда „кой → кого: щета“. Така дневникът е достатъчно точен, за да се сравнява ред по ред. В играта след всеки удар се слагат и последствията от предишния урок: кървене, зашеметяване, горене, отрова. Те са на едно място, затова са еднакви за играч, враг, умение и повторение, а не по едно във всяка команда.
В играта perform е същият, само че след изпълнението обработва смъртите (плячка, опит, събития за куестовете):
public void Perform(IBattleAction action){ Record.Add(action.ToRecord(this)); action.Execute(this); HandleDeaths();}Идеята
Уменията са данни
SkillAction е един клас, а уменията са много: Вихърът удря всички, Мощният удар удря един враг със 170% от силата. Разликата е в таблица, не в код. Всеки ред от текста е едно умение: идентификатор, мана, презареждане, сила в проценти, цел и име за показване:
const string SKILLS = """ cleave|3|3|80|all|Вихър smash|4|4|170|one|Мощен удар """;
record Skill(string Id, int Mana, int Cooldown, int Percent, string Target, string Name);
static List<Skill> LoadSkills(string text){ var skills = new List<Skill>(); foreach (string line in text.Split('\n')) { string[] p = line.Split('|'); skills.Add(new Skill(p[0], int.Parse(p[1]), int.Parse(p[2]), int.Parse(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),// мана, презареждане в ходове, удари, сила в проценти от удара с оръжие, щета на заклинанието// (мин–макс), вид щета („-“ е видът на оръжието), ефект, ходове на ефекта, сила на ефекта.static partial class Arcanum{ public static readonly string 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 : IBattleAction{ public Creature Actor; public Skill Skill; public Creature? Target;
public SkillAction(Creature actor, Skill skill, Creature? target) { Actor = actor; Skill = skill; Target = target; }
public ActionRecord ToRecord(Battle battle) { return new ActionRecord { Actor = battle.IndexOf(Actor), Action = "skill", Target = battle.IndexOf(Target), Id = Skill.Id }; }
public void Execute(Battle battle) { Hero hero = (Hero)Actor; Skill skill = Skill; hero.Mana -= skill.Mana; hero.Cooldowns.Set(skill.Id, skill.Cooldown); battle.Say(T("combat.uses", ("name", hero.Name), ("skill", Nm(skill)))); List<Creature> targets = new List<Creature> { Target! }; if (skill.Target == "all") targets = new List<Creature>(battle.AliveEnemies()); if (skill.Target == "self") targets = new List<Creature> { hero }; foreach (Creature target in targets) { if (skill.Kind == "weapon") { DamageProfile profile = hero.AttackProfile(); if (skill.Type != "-") profile.Type = skill.Type; for (int hit = 0; hit < skill.Hits; hit++) { if (!target.IsAlive()) break; bool landed = Strike(battle, hero, target, profile, skill.Power); if (landed && skill.Effect != "-") ApplyEffect(target, MakeEffect(skill.Effect, skill.EffectTurns, skill.EffectPower), battle); } } else if (skill.Kind == "spell") { SpellHit(battle, hero, target, skill.Min, skill.Max, skill.Type); if (skill.Effect != "-") ApplyEffect(target, MakeEffect(skill.Effect, skill.EffectTurns, skill.EffectPower), battle); } else if (skill.Kind == "heal") { int healed = hero.Heal(skill.Min + hero.Level * 2); battle.Say(T("combat.healed", ("name", hero.Name), ("amount", healed))); if (skill.Effect != "-") ApplyEffect(hero, MakeEffect(skill.Effect, skill.EffectTurns, skill.EffectPower), battle); } else { ApplyEffect(target, MakeEffect(skill.Effect, skill.EffectTurns, skill.EffectPower), battle); } } }}Идеята
Мана и презареждане
Умение без цена е най-добрият избор всеки път. Затова има два ограничителя: мана (запас, който се харчи) и презареждане (колко хода умението не е на разположение след употреба). В кода те са на три места и всяко прави едно нещо:
SkillAction.executeплаща: сваля маната и слага презареждането;Fighter.tickброи: в началото на хода на участника всяко презареждане намалява с едно;problemпита: връща причината, поради която умението не е налично, или празен низ.
// Защо умението не е налично сега: празен низ, ако е налично.static string Problem(Battle battle, Fighter hero, Skill skill){ if (hero.CooldownOf(skill.Id) > 0) return "презарежда се: " + hero.CooldownOf(skill.Id); if (hero.Mana < skill.Mana) return "малко мана"; return "";}
static List<Skill> UsableSkills(Battle battle, Fighter hero){ var result = new List<Skill>(); foreach (Skill skill in battle.Skills) { if (Problem(battle, hero, skill) == "") result.Add(skill); } return result;}class Fighter{ public readonly string Name; public int Hp; public readonly int MaxHp; public readonly int Power; public int Mana; public readonly int MaxMana; public int Potions; public readonly Dictionary<string, int> Cooldowns = new();
public Fighter(string name, int hp, int power, int mana, int potions) { Name = name; Hp = hp; MaxHp = hp; Power = power; Mana = mana; MaxMana = mana; Potions = potions; }
public bool Alive() => Hp > 0;
public int CooldownOf(string skillId) => Cooldowns.GetValueOrDefault(skillId, 0);
// Минава ход: презарежданията намаляват с едно. public void Tick() { foreach (string skillId in new List<string>(Cooldowns.Keys)) { Cooldowns[skillId] = Math.Max(0, Cooldowns[skillId] - 1); } }}Програмата дава на героя шест хода и за всяко умение показва дали е готово, а ако не е, защо. На всеки ход героят ползва първото налично:
Battle battle = NewBattle(7);Fighter hero = battle.Hero();for (int turn = 1; turn <= 6; turn++){ hero.Tick(); Console.WriteLine("Ход " + turn + ", мана " + hero.Mana); Skill? chosen = null; foreach (Skill skill in battle.Skills) { string why = Problem(battle, hero, skill); Console.WriteLine(" " + skill.Name + ": " + (why == "" ? "готово" : why)); if (why == "" && chosen == null) chosen = skill; } if (chosen == null) { Console.WriteLine(" нищо не може"); continue; } Console.WriteLine(" -> използва " + chosen.Name); battle.Perform(new SkillAction(hero, chosen, battle.AliveEnemies()[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 проверява презареждането първо: при Мощния удар причината се сменя от „презарежда се“ на „малко мана“ едва когато презареждането свърши.
Actor.Cooldowns[Skill.Id] = ;Когато умението се използва, SkillAction слага в презарежданията на участника числото от таблицата: skill.cooldown. На следващите ходове tick го намалява до 0, а problem казва „готово“ едва тогава.
В играта списъкът с налични умения е същата проверка, само че върху героя на играта:
public static List<Skill> UsableSkills(Hero hero){ List<Skill> result = new List<Skill>(); foreach (string skillId in hero.Skills) { Skill skill = SKILLS.Get(skillId); if (hero.Mana >= skill.Mana && hero.CooldownOf(skill.Id) == 0) result.Add(skill); } return result;}По-точно, за любопитните
В играта маната се връща с една на всяка крачка по картата, не на ход в боя, а също с отвара и при ново ниво. Затова в боя тя е запас, който трябва да стигне: ако я похарчиш за Вихъра на първия ход, нямаш я, когато шефът я поиска. Презареждането се брои на ходовете на самия участник (tick е в началото на неговия ход), не на рундовете. Затова по-бърз участник, който действа по-често, презарежда по-често.
Идеята
Целите
Умението има цел: one (един враг), all (всички живи) или self (самият герой). SkillAction събира целите в списък и прилага ефекта към всяка: при all това са всички живи врагове, иначе е една цел. Затова Вихърът удря и гоблина, и плъха с една команда, а Мощният удар само един.
Но кой избира целта? Играчът пише номер: врагът е 1 или 2 сред живите, а без номер е първият. Програмата превръща реда на играча в команда в parse:
// Целта е номер от 1 сред живите врагове; без номер е първият.static Fighter? Aim(Battle battle, string[] parts, int index){ List<Fighter> alive = battle.AliveEnemies(); if (parts.Length <= index) return alive[0]; string text = parts[index]; if (text.Length != 1 || !char.IsDigit(text[0])) return null; int number = text[0] - '0'; return number >= 1 && number <= alive.Count ? alive[number - 1] : null;}
// Невалидната команда не струва ход: казва защо и пита пак.static IBattleAction? Refuse(Battle battle, string line, string reason){ Console.WriteLine("? " + line.Trim() + ": " + reason); return null;}
static IBattleAction? Parse(Battle battle, Fighter hero, string line){ string[] parts = line.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); string word = parts.Length > 0 ? parts[0] : ""; if (word == "attack") { Fighter? target = Aim(battle, parts, 1); return target == null ? Refuse(battle, line, "няма такава цел") : new AttackAction(hero, target); } if (word == "skill") { Skill? skill = parts.Length > 1 ? SkillOf(battle, parts[1]) : null; if (skill == null) return Refuse(battle, line, "непознато умение"); string why = Problem(battle, hero, skill); if (why != "") return Refuse(battle, line, why); Fighter? target = Aim(battle, parts, 2); return target == null ? Refuse(battle, line, "няма такава цел") : new SkillAction(hero, skill, target); } if (word == "item") { return hero.Potions > 0 ? new ItemAction(hero, "potion") : Refuse(battle, line, "няма отвари"); } if (word == "flee") return new FleeAction(hero); return Refuse(battle, line, "непозната команда");}Забележи, че грешната команда не струва ход. parse казва причината (непознато умение, няма такава цел, умението се презарежда) и връща нищо, а Player чете следващия ред. Команда, която не е направена, не се записва и не се изпълнява, затова боят не научава за нея. Правилото е същото като при блъскането в стена на картата.
Програмата чете командите от стандартния вход, по една на ред. Част от тях са грешни нарочно:
skill fireballattack 5skill cleaveskill cleavedanceskill smash 2itemattack 1attackBattle battle = NewBattle(7);battle.Live = true;var lines = new List<string>();string? line;while ((line = Console.ReadLine()) != null){ if (line.Trim() != "") lines.Add(line);}Run(battle, new 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/41) Гоблин копиеносец [########] 16/162) Гоблин шаман [########] 13/13 Бой! Срещу теб: гоблин копиеносец, гоблин шаман.1 удар 2 умение 3 предмет 4 бягство
+- Цел ----------------------------+ | 1) гоблин копиеносец 16/16 | | 2) гоблин шаман 13/13 | | | | цифра: цел, x: назад | +----------------------------------+// Изборът на цел е прозрачен екран над боя. Не удря сам: връща номера на целта с Pop(result),// а боят решава какво да прави с нея.class TargetScreen : Screen{ public Battle Battle; public string Note;
public TargetScreen(Game game, Battle battle) : base(game) { Battle = battle; Note = ""; }
public override bool IsTransparent() { return true; }
public override void Draw(CellBuffer buffer) { List<Enemy> enemies = Battle.AliveEnemies(); buffer.Box(24, 9, 36, enemies.Count + 4, T("target.title")); for (int i = 0; i < enemies.Count; i++) { buffer.Text(26, 10 + i, (i + 1) + ") " + Pad(enemies[i].Name, 22) + " " + enemies[i].Hp + "/" + enemies[i].MaxHp(), enemies[i].Kind.Color); } buffer.Text(26, 11 + enemies.Count, Note.Length > 0 ? Note : T("target.help"), "gray"); }
public override void Handle(string command) { if (command == "x") { Game.Stack.Pop(null); return; } long? choice = ParseIntStrict(command); if (choice == null || choice < 1 || choice > Battle.AliveEnemies().Count) { Note = T("ui.unknown", ("command", command)); return; } Game.Stack.Pop((int)choice.Value - 1); }}Екранът на боя пази какво е започнато (pending), а onResult довършва командата, когато отговорът дойде: проверява маната и презареждането, при нужда отваря втори екран за целта и накрая вика heroAct с готовата команда:
public override void OnResult(object? result){ Battle battle = Battle; Hero hero = battle.Hero; PendingChoice? pending = Pending; Pending = null; if (result == null || pending == null) return; int index = (int)result; if (pending.Kind == "attack") { battle.HeroAct(new AttackAction(hero, battle.AliveEnemies()[index])); } else if (pending.Kind == "skill") { Skill skill = SKILLS.Get(hero.Skills[index]); if (hero.Mana < skill.Mana) { Note = T("battle.noMana", ("skill", Nm(skill))); } else if (hero.CooldownOf(skill.Id) > 0) { Note = T("battle.cooldown", ("skill", Nm(skill)), ("turns", hero.CooldownOf(skill.Id))); } else if (skill.Target == "one" && battle.AliveEnemies().Count > 1) { Pending = new PendingChoice { Kind = "skillTarget", Skill = skill }; Game.Stack.Push(new TargetScreen(Game, battle)); } else { battle.HeroAct(new SkillAction(hero, skill, battle.AliveEnemies()[0])); } } else if (pending.Kind == "skillTarget") { battle.HeroAct(new SkillAction(hero, pending.Skill!, battle.AliveEnemies()[index])); } else { battle.HeroAct(new ItemAction(hero, PotionIds()[index])); }}Идеята
Кой избира: chooser
Командите вече ги има. Кой ги избира? Всеки избирател е обект със същия метод choose(battle, actor), който връща команда или нищо (край на входа):
// Кой избира команда: изкуственият интелект, играчът или записът.interface IChooser{ IBattleAction? Choose(Battle battle, Fighter actor);}
class Auto : IChooser{ public IBattleAction? Choose(Battle battle, Fighter actor) { return actor == battle.Hero() ? ChooseHero(battle) : ChooseEnemy(battle, actor); }}
class Player : IChooser{ readonly List<string> lines; int next = 0;
public Player(List<string> lines) { this.lines = lines; }
public IBattleAction? Choose(Battle battle, Fighter actor) { if (actor != battle.Hero()) return ChooseEnemy(battle, actor); while (next < lines.Count) { IBattleAction? action = Parse(battle, actor, lines[next++]); if (action != null) return action; } return null; }}
class Replay : IChooser{ readonly List<ActionRecord> record; int next = 0;
public Replay(List<ActionRecord> record) { this.record = record; }
public IBattleAction? Choose(Battle battle, Fighter actor) { if (next >= record.Count) return null; return ActionFromRecord(battle, record[next++]); }}
static Fighter Weakest(Battle battle){ Fighter? best = null; foreach (Fighter enemy in battle.AliveEnemies()) { if (best == null || enemy.Hp < best.Hp) best = enemy; } return best!;}
static IBattleAction ChooseEnemy(Battle battle, Fighter enemy){ return new AttackAction(enemy, battle.Hero());}
static IBattleAction ChooseHero(Battle battle){ Fighter hero = battle.Hero(); Fighter target = Weakest(battle); if (hero.Hp * 100 / hero.MaxHp < 35 && hero.Potions > 0) return new ItemAction(hero, "potion"); List<Skill> usable = UsableSkills(battle, hero); foreach (Skill skill in usable) { if (skill.Target == "all" && battle.AliveEnemies().Count >= 2) return new SkillAction(hero, skill, target); } foreach (Skill skill in usable) { if (skill.Target == "one") return new SkillAction(hero, skill, target); } return new AttackAction(hero, target);}Autoизбира за всички: враговете удрят героя, а героят преценява по приоритет: отвара при малко живот, умение за много цели, силен удар, обикновен удар. Така върви симулацията. Това е най-простата „оценка на полезността“; по-умните решения на враговете са темата на следващия урок (Врагове, които мислят);Playerдава командите на героя от входа, а враговете остават на изкуствения интелект;Replayще видим след малко.
Цикълът на боя run не знае кой е кой. Той върти рундове, на всеки участник казва „на ред си“ и взима команда от chooser-а. Затова кой избира и какво се изпълнява са две независими неща: играчът, автопилотът и записът се сменят, без да се пипа боят.
// Един бой: рундове, а във всеки ход на всички по ред. Командата идва от chooser-а.static void Run(Battle battle, IChooser chooser){ while (!battle.Over && battle.Rounds < 12) { battle.Rounds++; battle.Say("== Рунда " + battle.Rounds + " =="); foreach (Fighter actor in battle.Fighters) { if (battle.Over) break; if (!actor.Alive()) continue; actor.Tick(); IBattleAction? action = chooser.Choose(battle, actor); if (action == null) { battle.Over = true; break; } battle.Perform(action); } }}
static IBattleAction ActionFromRecord(Battle battle, ActionRecord r){ Fighter actor = battle.Fighters[r.Actor]; Fighter target = r.Target >= 0 ? battle.Fighters[r.Target] : null!; if (r.Action == "attack") return new AttackAction(actor, target); if (r.Action == "skill") return new SkillAction(actor, SkillOf(battle, r.Id)!, target); if (r.Action == "item") return new ItemAction(actor, r.Id); return new FleeAction(actor);}
static string Describe(ActionRecord r){ return r.Actor + " " + r.Action + " " + r.Target + (r.Id == "" ? "" : " " + r.Id);}
// Повторението: ново начало от същото семе, а записът вместо избор.static Battle ReplayBattle(int seed, List<ActionRecord> record){ Battle battle = NewBattle(seed); Run(battle, new Replay(record)); return battle;}
static bool SameLines(List<string> first, List<string> second){ if (first.Count != second.Count) return false; for (int i = 0; i < first.Count; i++) { if (first[i] != second[i]) return false; } return true;}Виж го
Запис и повторение
Командите вече минават през perform, а там се записват. Остава да ги пуснем пак. Повторението е нов бой от същото начало (същото семе, същите участници), в който chooser-ът е Replay: той дава записаните команди една по една. На сцената горе е боят на живо, по средата е лентата със записа, долу е повторението.
// Единственото място, през което минава всяка команда: първо се записва, после се изпълнява.public void Perform(IBattleAction action){ Record.Add(action.ToRecord(this)); action.Execute(this); CheckEnd();}
// Един бой: рундове, а във всеки ход на всички по ред. Командата идва от chooser-а.static void Run(Battle battle, IChooser chooser){ while (!battle.Over && battle.Rounds < 12) { battle.Rounds++; battle.Say("== Рунда " + battle.Rounds + " =="); foreach (Fighter actor in battle.Fighters) { if (battle.Over) break; if (!actor.Alive()) continue; actor.Tick(); IBattleAction? action = chooser.Choose(battle, actor); if (action == null) { battle.Over = true; break; } battle.Perform(action); } }}
// Повторението: ново начало от същото семе, а записът вместо избор.static Battle ReplayBattle(int seed, List<ActionRecord> record){ Battle battle = NewBattle(seed); Run(battle, new Replay(record)); return battle;}
static bool SameLines(List<string> first, List<string> second){ if (first.Count != second.Count) return false; for (int i = 0; i < first.Count; i++) { if (first[i] != second[i]) return false; } return true;}Стъпка по стъпка, с думи
- Боят върти рундове. На ред е един участник и chooser дава команда: изкуственият интелект (или играчът) за героя, удар за враговете. Боят не пита каква е командата: само я взема.
- perform записва командата и я изпълнява. Първата е умението от таблицата (0 skill 2): маната пада до 5; животът на гоблина е 18 → 12, на плъха 10 → 4. Жетонът на лентата е записът ѝ.
- Всяка команда, на героя и на враговете, минава през същия perform и пуска жетон на лентата. Записът е само данни: кой (номер), какво, срещу кого. В него няма живот, щета и случайност.
- Боят свърши: 10 команди в записа (лентата показва първите 8). Повторението започва наново от същото семе и със същите участници, пълни с живот. Само изборът е различен: командите идват от записа, не от играч и изкуствен интелект.
- Replay.choose дава следващия жетон като команда, а run и perform са същите като на живо. Генераторът е със същото семе, затова всеки удар излиза същият и числата в долния бой повтарят горния ход по ход.
- sameLines сравнява двата дневника ред по ред: 21 реда и нито една разлика. Затова повторението може да се провери, а не само да се гледа.
- Семето и записът са целият бой. Същият запис, пуснат без екран хиляди пъти, е и симулацията за баланса.
Програмата играе бой с автопилот, печата записа и го пуска два пъти: със същото семе и с друго.
Battle battle = NewBattle(7);Run(battle, new Auto());Console.WriteLine("Запис:");foreach (ActionRecord r in battle.Record){ Console.WriteLine(Describe(r));}Battle again = ReplayBattle(7, battle.Record);Console.WriteLine("Същият бой от записа: " + (SameLines(battle.Log, again.Log) ? "да" : "не"));Battle other = ReplayBattle(8, battle.Record);Console.WriteLine("Същият бой от друго семе: " + (SameLines(battle.Log, other.Log) ? "да" : "не"));Запис:0 skill 2 cleave1 attack 02 attack 00 skill 2 smash1 attack 00 item -1 potion1 attack 00 attack 11 attack 00 attack 1Същият бой от записа: даСъщият бой от друго семе: неПървото повторение съвпада с боя ред по ред, второто не. Записът пази изборите (кой, какво, срещу кого), не заровете. Щетите и уцелванията идват от генератора със семе, затова същият запис върху друго семе дава същите избори върху други числа. Това е и причината записът да е малък: 10 команди, не 21 реда дневник.
В играта повторението е екран след победа (клавишът r). Той показва същия дневник по страници и отговор дали съвпада с боя:
=== Повторение на боя (1/1) === Бой! Срещу теб: гигантски плъх. Гигантски плъх драска Ива (1). Ива пропуска гигантски плъх. Гигантски плъх драска Ива (1). Ива смазва гигантски плъх (12). Гигантски плъх пада и не става. Злато: +1. Гигантски плъх оставя: противоотрова.Повторението съвпада с боя: да(Enter: обратно)public static IBattleAction ActionFromRecord(Battle battle, ActionRecord record){ Creature actor = battle.CreatureAt(record.Actor); Creature? target = record.Target >= 0 ? battle.CreatureAt(record.Target) : null; if (record.Action == "attack") return new AttackAction(actor, target); if (record.Action == "skill") return new SkillAction(actor, SKILLS.Get(record.Id), target); if (record.Action == "item") return new ItemAction(actor, record.Id); if (record.Action == "flee") return new FleeAction(actor); return new SpecialAction(actor, record.Id, target);}
// Връща дневника на повторения бой.public static List<string> ReplayBattle(BattleSnapshot snapshot, List<ActionRecord> record){ ReplayWorld world = new ReplayWorld(snapshot); List<Enemy> enemies = new List<Enemy>(); foreach (Enemy enemy in snapshot.Enemies) enemies.Add(enemy.Copy()); Battle battle = new Battle(world, enemies, snapshot.CanFlee); battle.Script = record; battle.Start(); battle.Advance(); return battle.Log;}
public static bool SameLines(List<string> first, List<string> second){ if (first.Count != second.Count) return false; for (int i = 0; i < first.Count; i++) { if (first[i] != second[i]) return false; } return true;}По-точно, за любопитните
В играта повторението не започва от семето на света, а от снимка на началото на боя: състоянието на генератора и копия на героя и враговете (с уменията, маната и предметите на героя в този миг). Героят има инвентар и ниво, които не се строят от семе, затова снимката ги носи. Повторението е Battle с script вместо играч и изкуствен интелект: advance взема командите от записа. Светът за повторението (ReplayWorld) не се променя: няма трупове, злато и куестове, остава само дневникът.
Записът зависи от версията на правилата: ако промениш щетата на Вихъра, старите записи вече не съвпадат. Затова записите са добри за проверка и за търсене на грешки в същата версия, не за вечно хранилище.
Идеята
Хиляда боя без екран
Пуснеш ли run с Auto за двете страни, не ти трябват екран, играч и запис. Програмата играе боеве със семена от 1 до 1000 и брои победите:
Console.WriteLine("Симулация без екран");int wins = 0;int losses = 0;int commands = 0;int total = 1000;for (int seed = 1; seed <= total; seed++){ Battle battle = NewBattle(seed); Run(battle, new Auto()); if (battle.Outcome == "win") wins++; else losses++; commands += battle.Record.Count;}int average = commands * 10 / total;Console.WriteLine("Боеве: " + total + ", победи: " + wins + ", поражения: " + losses + ", средно команди: " + average / 10 + "." + average % 10);Симулация без екранБоеве: 1000, победи: 875, поражения: 125, средно команди: 9.9Резултатът е 875 победи и 125 поражения. Така се мери балансът: ако героят печели всеки бой, враговете са слаби; ако го губи, са твърде силни; числата се бутат, докато изходът стане такъв, какъвто искаш. Всичко е детерминистично: същият код дава същите числа всеки път и на всеки език.
В играта това е --simulate=1000: боеве за всеки клас срещу всеки враг, както ще ги видиш в последния урок. Основата е тази: героят на автопилот (autoHero) избира команди със същата оценка, а боят върви със същия advance, без екран:
public static SimResult SimulateFight(Game game, HeroClass heroClass, EnemyKind kind){ game.Hero = MakeSimHero(game, heroClass, SIM_LEVELS[kind.Depth]); Battle battle = new Battle(game, new List<Enemy> { new Enemy(game.NewId(), kind) }, false); battle.AutoHero = true; battle.Advance(); return new SimResult { Win = battle.Outcome == "win", Turns = battle.HeroTurns };}Симулацията е възможна, защото боят пише в дневник, не на екрана (в урока live само го копира там), а изборът на команда е сменяем.
Пробвай
Предвиди изхода
Героят ползва на всеки ход последното налично умение (Мощния удар, ако може, иначе Вихъра). Вихърът струва 3 мана и се презарежда 3 хода, Мощният удар струва 4 и се презарежда 4, а героят започва с 8 мана. Какво печата програмата?
Battle battle = NewBattle(7);Fighter hero = battle.Hero();for (int turn = 1; turn <= 5; turn++){ hero.Tick(); string used = "-"; foreach (Skill skill in UsableSkills(battle, hero)) used = skill.Id; if (used != "-") { battle.Perform(new SkillAction(hero, SkillOf(battle, used)!, battle.Fighters[1])); } Console.WriteLine(turn + " " + used);}Ход 1: и двете са налични, взема се последното, Мощният удар (мана 8 → 4, презареждане 4). Ход 2: Мощният удар се презарежда (3), остава Вихърът и 4 мана му стигат (мана 1). От хода 3 маната е 1 и не стига за нищо, дори на хода 5, когато Мощният удар вече е презареден. Другите изходи са на програма без презареждане (Мощният удар би се повторил на хода 2) и на програма, която не харчи мана (на хода 5 Мощният удар пак щеше да е наличен).
Пробвай
Намери грешката
Колега промени SkillAction и сега повторението не съвпада с боя. Записът изглежда странно:
class SkillAction : IBattleAction{ public readonly Fighter Actor; public readonly Skill Skill; public readonly Fighter Target;
public SkillAction(Fighter actor, Skill skill, Fighter target) { Actor = actor; Skill = skill; Target = target; }
public ActionRecord ToRecord(Battle battle) { return new ActionRecord(battle.IndexOf(Actor), "attack", battle.IndexOf(Target), Skill.Id); }
public void Execute(Battle battle) { Actor.Mana -= Skill.Mana; Actor.Cooldowns[Skill.Id] = Skill.Cooldown; battle.Say(Actor.Name + ": " + Skill.Name); List<Fighter> targets = Skill.Target == "all" ? battle.AliveEnemies() : new List<Fighter> { Target }; foreach (Fighter victim in targets) { battle.Hit(Actor, victim, Actor.Power * Skill.Percent / 100 + battle.Rng.Roll(3)); } }}Battle battle = NewBattle(7);Run(battle, new Auto());Console.WriteLine("Запис:");foreach (ActionRecord r in battle.Record){ Console.WriteLine(Describe(r));}Battle again = ReplayBattle(7, battle.Record);Console.WriteLine("Същият бой от записа: " + (SameLines(battle.Log, again.Log) ? "да" : "не"));Запис:0 attack 2 cleave1 attack 02 attack 00 attack 2 smash1 attack 00 item -1 potion1 attack 00 attack 11 attack 00 attack 1Същият бой от записа: неВ записа Вихърът е attack. Кой ред е виновен?
Избери реда с грешката.
toRecord записва "attack" вместо "skill". На живо умението се изпълнява като умение: маната е платена, ударът е по всички. Но в записа пише „удар“ по една цел, и повторението прави точно това, което чете: удря, без да плати мана и без презареждане. Дневниците се разминават още на първата команда. Признакът е в самия запис: редове като 0 attack 2 cleave, където видът и умението си противоречат.
По-точно
Какво не може командата
Командата като обект е удобна, но има граници, които си струва да знаеш.
Класът сам не пази от незаконна команда. 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 2dancerestitemskill smash 1attack 1skill 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 и пусни симулацията. Колко се променят победите и защо числата са от хиляда боя, а не от един?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Списъци · Dictionary и HashSet · Ламбди и делегати · record, switch и шаблони
- Задачи по темата: Хрониките на Арканум