Умения и действия като команди
Накратко: Командата е обект, който знае как да се изпълни и как да се запише като данни. Боят изпълнява команди през един метод; кой я избира (играч, изкуствен интелект или запис) е друг въпрос. Затова записаните команди и същото семе повтарят боя, а без екран боят върви хиляди пъти.
Проблем
Четирима избират, един изпълнява
В боя на играта има четирима „избиратели“. Играчът натиска 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 уд+--------------------------------------------------+Идеята е да превърнем действието в обект: команда. Всяка команда знае как да се изпълни и как да се запише като данни. Боят не пита каква е, само я изпълнява. Кой я е избрал (играч, враг, изкуствен интелект или запис) е друг въпрос и боят не се занимава с него. Урокът строи това на малка арена и после показва същото място в играта: командите, уменията от таблица, маната и презарежданията, целите, повторението на боя и симулацията без екран.
Идеята
Командата е обект
Арената на урока е малка: воин срещу гоблин и плъх. Сметките са с цели числа, а „заровете“ са генератор със семе (както в уводния урок), затова боят е един и същ на всеки език. Командите са четири вида и от гледна точка на боя правят едно и също:
class AttackAction { constructor(actor, target) { this.actor = actor; this.target = target; }
toRecord(battle) { return { actor: battle.indexOf(this.actor), action: "attack", target: battle.indexOf(this.target), id: "" }; }
execute(battle) { battle.hit(this.actor, this.target, this.actor.power + battle.rng.roll(3)); }}
class ItemAction { constructor(actor, itemId) { this.actor = actor; this.itemId = itemId; }
toRecord(battle) { return { actor: battle.indexOf(this.actor), action: "item", target: -1, id: this.itemId }; }
execute(battle) { this.actor.potions -= 1; const healed = Math.min(10, this.actor.maxHp - this.actor.hp); this.actor.hp += healed; battle.say(`${this.actor.name}: отвара, +${healed} живот`); }}
class FleeAction { constructor(actor) { this.actor = actor; }
toRecord(battle) { return { actor: battle.indexOf(this.actor), action: "flee", target: -1, id: "" }; }
execute(battle) { if (battle.rng.roll(100) < 50) { battle.say(`${this.actor.name} бяга.`); battle.over = true; battle.outcome = "fled"; } else { battle.say(`${this.actor.name} не успява да избяга.`); } }}Всяка команда пази кой действа (actor) и срещу кого (target) и има два метода:
execute(battle): изпълнява я. Командата не смята живота на другите сама: викаbattle.hit, а той сваля живота и пише реда в дневника;toRecord(battle): описва я като данни: номер на участника, дума за вида, номер на целта и умението или предмета. В записа няма живот, щета и случайност.
Затова боят може да държи списък от команди от различни видове и да вика на всички едно и също: това е полиморфизмът от наследяването, само че тук командите са обекти с общ интерфейс. Програмата слага в списък удар на героя, удар на гоблина, умение и отвара и ги дава на perform една по една. Редовете на боя излизат веднага (live), а накрая се печата записът.
const battle = newBattle(7);battle.live = true;const hero = battle.hero();const goblin = battle.fighters[1];const queue = [ new AttackAction(hero, goblin), new AttackAction(goblin, hero), new SkillAction(hero, skillOf(battle, "smash"), goblin), new ItemAction(hero, "potion"),];for (const action of queue) { battle.perform(action);}console.log("Запис:");for (const r of battle.record) { console.log(describe(r));}Воин → Гоблин: 8 щетаГоблин → Воин: 8 щетаВоин: Мощен ударВоин → Гоблин: 12 щетаГоблин пада.Воин: отвара, +8 животЗапис:0 attack 11 attack 00 skill 1 smash0 item -1 potionЗаписът е това, което остава от боя: четири реда по няколко думи. Нужен е за повторението, което идва по-нататък.
В играта класовете са същите по форма. Така изглеждат ударът и бягството:
// Действията са команди: обект с execute(battle). Играчът и изкуственият интелект избират// команда, боят я изпълнява. Затова симулацията върти същия бой без екран.class AttackAction { constructor(actor, target) { this.actor = actor; this.target = target; }
toRecord(battle) { return { actor: battle.indexOf(this.actor), action: 'attack', target: battle.indexOf(this.target), id: '' }; }
execute(battle) { strike(battle, this.actor, this.target, this.actor.attackProfile(), 100); }}class FleeAction { constructor(actor) { this.actor = actor; }
toRecord(battle) { return { actor: battle.indexOf(this.actor), action: 'flee', target: -1, id: '' }; }
execute(battle) { if (!battle.canFlee) { battle.say(t('combat.noFlee')); return; } let fastest = 0; for (const enemy of battle.aliveEnemies()) fastest = Math.max(fastest, enemy.stat('agility')); const chance = clamp(50 + (this.actor.stat('agility') - fastest) * 5, 15, 90); if (battle.game.rng.chance(chance)) { battle.say(t('combat.fled', { name: this.actor.name })); battle.over = true; battle.outcome = 'fled'; } else { battle.say(t('combat.fleeFail', { name: this.actor.name })); } }}Бягството има шанс (по ловкостта на най-бързия враг), но не пише щета. Враговете също използват команди: умения като плюенето на шамана или лечението му са класът SpecialAction, един на вид умение.
По-точно, за любопитните
Това е шаблонът Command (от книгата на „четиримата“, Gang of Four): заявката става обект. От него следват неща, които с if са трудни: запис (запази командите), повторение (изиграй ги пак), отмяна (всяка команда знае и обратното си действие и командите се връщат назад по стек) и опашка (командата чака реда си). Тук се ползват първите две. Отмяна в бой със зарове, кървене и смърт е трудна, защото обратното на „щета 7“ не е просто „живот + 7“, затова играта пази запис, а не стек за отмяна. В текстов редактор или рисуване шаблонът служи точно за отмяна.
Идеята
Боят изпълнява
Боят е клас с участници, дневник и запис. Важното в него е едно място, през което минава всяка команда:
class Battle { constructor(seed, fighters, skills) { this.rng = new Rng(seed); this.fighters = fighters; this.skills = skills; this.log = []; this.record = []; this.over = false; this.live = false; this.outcome = "none"; this.rounds = 0; }
hero() { return this.fighters[0]; }
say(text) { this.log.push(text); if (this.live) console.log(text); }
indexOf(fighter) { return this.fighters.indexOf(fighter); }
aliveEnemies() { return this.fighters.slice(1).filter((f) => f.alive()); }
hit(actor, target, amount) { target.hp = Math.max(0, target.hp - amount); this.say(`${actor.name} → ${target.name}: ${amount} щета`); if (!target.alive()) this.say(`${target.name} пада.`); }
// Единственото място, през което минава всяка команда: първо се записва, после се изпълнява. perform(action) { this.record.push(action.toRecord(this)); action.execute(this); this.checkEnd(); }
checkEnd() { if (this.over) return; if (!this.hero().alive()) { this.over = true; this.outcome = "lose"; this.say("Поражение."); } else if (this.aliveEnemies().length === 0) { this.over = true; this.outcome = "win"; this.say("Победа!"); } }}perform прави три неща в този ред: записва командата, изпълнява я и проверява дали боят е свършил. Заобиколен път няма: нито героят, нито гоблинът, нито повторението изпълняват нещо по друг начин. Затова записът е пълен: ако нещо е променило боя, то е минало оттук.
hit е второто място, което си заслужава да е само едно: само то сваля живота и пише реда „кой → кого: щета“. Така дневникът е достатъчно точен, за да се сравнява ред по ред. В играта след всеки удар се слагат и последствията от предишния урок: кървене, зашеметяване, горене, отрова. Те са на едно място, затова са еднакви за играч, враг, умение и повторение, а не по едно във всяка команда.
В играта perform е същият, само че след изпълнението обработва смъртите (плячка, опит, събития за куестовете):
perform(action) { this.record.push(action.toRecord(this)); action.execute(this); this.handleDeaths();}Идеята
Уменията са данни
SkillAction е един клас, а уменията са много: Вихърът удря всички, Мощният удар удря един враг със 170% от силата. Разликата е в таблица, не в код. Всеки ред от текста е едно умение: идентификатор, мана, презареждане, сила в проценти, цел и име за показване:
const SKILLS = `cleave|3|3|80|all|Вихърsmash|4|4|170|one|Мощен удар`.trim();
function loadSkills(text) { return text.split("\n").map((line) => { const p = line.split("|"); return { id: p[0], mana: Number(p[1]), cooldown: Number(p[2]), percent: Number(p[3]), target: p[4], name: p[5] }; });}Програмата чете текста веднъж при старт (loadSkills) и пази записи Skill. Ново умение от вече познат вид е нов ред, не нов клас. Това е същата идея като таблицата с плочките от картата: данните са отделени от кода.
В играта таблицата е по-широка: освен мана и презареждане има клас и клон в дървото на уменията, вид (удар с оръжие, заклинание, лечение, ефект върху себе си, ефект върху враговете), цел one, all или self, брой удари, щета на заклинанието, и ефект с ходове и сила. Ето първите ѝ редове:
// Уменията: клас, клон (1 или 2), стъпка в клона (1–3), вид (weapon: с оръжието, spell: заклинание,// heal: лекуване, buff: ефект върху себе си, debuff: ефект върху враговете), цел (one, all, self),// мана, презареждане в ходове, удари, сила в проценти от удара с оръжие, щета на заклинанието// (мин–макс), вид щета („-“ е видът на оръжието), ефект, ходове на ефекта, сила на ефекта.const SKILLS_DATA = `# id|клас|клон|стъпка|вид|цел|мана|презареждане|удари|сила|мин|макс|щета|ефект|ходове|сила на ефекта|bg|en|de|esrage|barbarian|1|1|buff|self|0|5|0|0|0|0|-|rage|3|6|Ярост|Rage|Wut|Irawhirlwind|barbarian|1|2|weapon|all|3|3|1|80|0|0|-|-|0|0|Вихър|Whirlwind|Wirbelwind|Torbellinobloodlust|barbarian|1|3|weapon|one|4|4|1|170|0|0|slash|bleed|3|2|Кървава жажда|Bloodlust|Blutrausch|Sed de sangreЕдин SkillAction в играта чете ред от тази таблица и според вида прави удар с оръжие, заклинание, лечение или слага ефект:
class SkillAction { constructor(actor, skill, target) { this.actor = actor; this.skill = skill; this.target = target; }
toRecord(battle) { return { actor: battle.indexOf(this.actor), action: 'skill', target: battle.indexOf(this.target), id: this.skill.id }; }
execute(battle) { const hero = this.actor; const skill = this.skill; hero.mana -= skill.mana; hero.cooldowns.set(skill.id, skill.cooldown); battle.say(t('combat.uses', { name: hero.name, skill: nm(skill) })); let targets = [this.target]; if (skill.target === 'all') targets = battle.aliveEnemies(); if (skill.target === 'self') targets = [hero]; for (const target of targets) { if (skill.kind === 'weapon') { const profile = hero.attackProfile(); if (skill.type !== '-') profile.type = skill.type; for (let hit = 0; hit < skill.hits; hit++) { if (!target.isAlive()) break; const 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') { const 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пита: връща причината, поради която умението не е налично, или празен низ.
// Защо умението не е налично сега: празен низ, ако е налично.function problem(battle, hero, skill) { if (hero.cooldownOf(skill.id) > 0) return `презарежда се: ${hero.cooldownOf(skill.id)}`; if (hero.mana < skill.mana) return "малко мана"; return "";}
function usableSkills(battle, hero) { return battle.skills.filter((skill) => problem(battle, hero, skill) === "");}class Fighter { constructor(name, hp, power, mana, potions) { this.name = name; this.hp = hp; this.maxHp = hp; this.power = power; this.mana = mana; this.maxMana = mana; this.potions = potions; this.cooldowns = new Map(); }
alive() { return this.hp > 0; }
cooldownOf(skillId) { return this.cooldowns.get(skillId) ?? 0; }
// Минава ход: презарежданията намаляват с едно. tick() { for (const [skillId, left] of this.cooldowns) { this.cooldowns.set(skillId, Math.max(0, left - 1)); } }}Програмата дава на героя шест хода и за всяко умение показва дали е готово, а ако не е, защо. На всеки ход героят ползва първото налично:
const battle = newBattle(7);const hero = battle.hero();for (let turn = 1; turn <= 6; turn++) { hero.tick(); console.log(`Ход ${turn}, мана ${hero.mana}`); let chosen = null; for (const skill of battle.skills) { const why = problem(battle, hero, skill); console.log(" " + skill.name + ": " + (why === "" ? "готово" : why)); if (why === "" && chosen === null) chosen = skill; } if (chosen === null) { console.log(" нищо не може"); continue; } console.log(" -> използва " + 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 проверява презареждането първо: при Мощния удар причината се сменя от „презарежда се“ на „малко мана“ едва когато презареждането свърши.
this.actor.cooldowns.set(this.skill.id, );Когато умението се използва, SkillAction слага в презарежданията на участника числото от таблицата: skill.cooldown. На следващите ходове tick го намалява до 0, а problem казва „готово“ едва тогава.
В играта списъкът с налични умения е същата проверка, само че върху героя на играта:
function usableSkills(hero) { const result = []; for (const skillId of hero.skills) { const skill = SKILLS.get(skillId); if (hero.mana >= skill.mana && hero.cooldownOf(skill.id) === 0) result.push(skill); } return result;}По-точно, за любопитните
В играта маната се връща с една на всяка крачка по картата, не на ход в боя, а също с отвара и при ново ниво. Затова в боя тя е запас, който трябва да стигне: ако я похарчиш за Вихъра на първия ход, нямаш я, когато шефът я поиска. Презареждането се брои на ходовете на самия участник (tick е в началото на неговия ход), не на рундовете. Затова по-бърз участник, който действа по-често, презарежда по-често.
Идеята
Целите
Умението има цел: one (един враг), all (всички живи) или self (самият герой). SkillAction събира целите в списък и прилага ефекта към всяка: при all това са всички живи врагове, иначе е една цел. Затова Вихърът удря и гоблина, и плъха с една команда, а Мощният удар само един.
Но кой избира целта? Играчът пише номер: врагът е 1 или 2 сред живите, а без номер е първият. Програмата превръща реда на играча в команда в parse:
// Целта е номер от 1 сред живите врагове; без номер е първият.function aim(battle, parts, index) { const alive = battle.aliveEnemies(); if (parts.length <= index) return alive[0]; const text = parts[index]; if (!/^\d$/.test(text)) return null; const number = Number(text); return number >= 1 && number <= alive.length ? alive[number - 1] : null;}
// Невалидната команда не струва ход: казва защо и пита пак.function refuse(battle, line, reason) { console.log(`? ${line.trim()}: ${reason}`); return null;}
function parse(battle, hero, line) { const parts = line.trim().split(/\s+/); const word = parts[0]; if (word === "attack") { const target = aim(battle, parts, 1); return target === null ? refuse(battle, line, "няма такава цел") : new AttackAction(hero, target); } if (word === "skill") { const skill = parts.length > 1 ? skillOf(battle, parts[1]) : null; if (skill === null) return refuse(battle, line, "непознато умение"); const why = problem(battle, hero, skill); if (why !== "") return refuse(battle, line, why); const 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 1attackconst battle = newBattle(7);battle.live = true;const lines = readFileSync(0, "utf8").split("\n").filter((line) => line.trim() !== "");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 extends Screen { constructor(game, battle) { super(game); this.battle = battle; this.note = ''; }
isTransparent() { return true; }
draw(buffer) { const enemies = this.battle.aliveEnemies(); buffer.box(24, 9, 36, enemies.length + 4, t('target.title')); for (let i = 0; i < enemies.length; 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.length, this.note.length > 0 ? this.note : t('target.help'), 'gray'); }
handle(command) { if (command === 'x') { this.game.stack.pop(null); return; } const choice = parseIntStrict(command); if (choice === null || choice < 1 || choice > this.battle.aliveEnemies().length) { this.note = t('ui.unknown', { command: command }); return; } this.game.stack.pop(choice - 1); }}Екранът на боя пази какво е започнато (pending), а onResult довършва командата, когато отговорът дойде: проверява маната и презареждането, при нужда отваря втори екран за целта и накрая вика heroAct с готовата команда:
onResult(result) { const battle = this.battle; const hero = battle.hero; const pending = this.pending; this.pending = null; if (result === null || pending === null) return; if (pending.kind === 'attack') { battle.heroAct(new AttackAction(hero, battle.aliveEnemies()[result])); } else if (pending.kind === 'skill') { const skill = SKILLS.get(hero.skills[result]); if (hero.mana < skill.mana) { this.note = t('battle.noMana', { skill: nm(skill) }); } else if (hero.cooldownOf(skill.id) > 0) { this.note = t('battle.cooldown', { skill: nm(skill), turns: hero.cooldownOf(skill.id) }); } else if (skill.target === 'one' && battle.aliveEnemies().length > 1) { this.pending = { kind: 'skillTarget', skill: skill }; this.game.stack.push(new TargetScreen(this.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()[result])); } else { battle.heroAct(new ItemAction(hero, this.potionIds()[result])); }}Идеята
Кой избира: chooser
Командите вече ги има. Кой ги избира? Всеки избирател е обект със същия метод choose(battle, actor), който връща команда или нищо (край на входа):
class Auto { choose(battle, actor) { return actor === battle.hero() ? chooseHero(battle) : chooseEnemy(battle, actor); }}
class Player { constructor(lines) { this.lines = lines; this.next = 0; }
choose(battle, actor) { if (actor !== battle.hero()) return chooseEnemy(battle, actor); while (this.next < this.lines.length) { const action = parse(battle, actor, this.lines[this.next++]); if (action !== null) return action; } return null; }}
class Replay { constructor(record) { this.record = record; this.next = 0; }
choose(battle, actor) { if (this.next >= this.record.length) return null; return actionFromRecord(battle, this.record[this.next++]); }}
function weakest(battle) { let best = null; for (const enemy of battle.aliveEnemies()) { if (best === null || enemy.hp < best.hp) best = enemy; } return best;}
function chooseEnemy(battle, enemy) { return new AttackAction(enemy, battle.hero());}
function chooseHero(battle) { const hero = battle.hero(); const target = weakest(battle); if (Math.floor((hero.hp * 100) / hero.maxHp) < 35 && hero.potions > 0) return new ItemAction(hero, "potion"); const usable = usableSkills(battle, hero); for (const skill of usable) { if (skill.target === "all" && battle.aliveEnemies().length >= 2) return new SkillAction(hero, skill, target); } for (const skill of usable) { if (skill.target === "one") return new SkillAction(hero, skill, target); } return new AttackAction(hero, target);}Autoизбира за всички: враговете удрят героя, а героят преценява по приоритет: отвара при малко живот, умение за много цели, силен удар, обикновен удар. Така върви симулацията. Това е най-простата „оценка на полезността“; по-умните решения на враговете са темата на следващия урок (Врагове, които мислят);Playerдава командите на героя от входа, а враговете остават на изкуствения интелект;Replayще видим след малко.
Цикълът на боя run не знае кой е кой. Той върти рундове, на всеки участник казва „на ред си“ и взима команда от chooser-а. Затова кой избира и какво се изпълнява са две независими неща: играчът, автопилотът и записът се сменят, без да се пипа боят.
// Един бой: рундове, а във всеки ход на всички по ред. Командата идва от chooser-а.function run(battle, chooser) { while (!battle.over && battle.rounds < 12) { battle.rounds++; battle.say(`== Рунда ${battle.rounds} ==`); for (const actor of battle.fighters) { if (battle.over) break; if (!actor.alive()) continue; actor.tick(); const action = chooser.choose(battle, actor); if (action === null) { battle.over = true; break; } battle.perform(action); } }}
function actionFromRecord(battle, r) { const actor = battle.fighters[r.actor]; const 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);}
function describe(r) { return `${r.actor} ${r.action} ${r.target}` + (r.id ? ` ${r.id}` : "");}
// Повторението: ново начало от същото семе, а записът вместо избор.function replayBattle(seed, record) { const battle = newBattle(seed); run(battle, new Replay(record)); return battle;}
function sameLines(first, second) { if (first.length !== second.length) return false; for (let i = 0; i < first.length; i++) { if (first[i] !== second[i]) return false; } return true;}Виж го
Запис и повторение
Командите вече минават през perform, а там се записват. Остава да ги пуснем пак. Повторението е нов бой от същото начало (същото семе, същите участници), в който chooser-ът е Replay: той дава записаните команди една по една. На сцената горе е боят на живо, по средата е лентата със записа, долу е повторението.
// Единственото място, през което минава всяка команда: първо се записва, после се изпълнява.perform(action) { this.record.push(action.toRecord(this)); action.execute(this); this.checkEnd();}
// Един бой: рундове, а във всеки ход на всички по ред. Командата идва от chooser-а.function run(battle, chooser) { while (!battle.over && battle.rounds < 12) { battle.rounds++; battle.say(`== Рунда ${battle.rounds} ==`); for (const actor of battle.fighters) { if (battle.over) break; if (!actor.alive()) continue; actor.tick(); const action = chooser.choose(battle, actor); if (action === null) { battle.over = true; break; } battle.perform(action); } }}
// Повторението: ново начало от същото семе, а записът вместо избор.function replayBattle(seed, record) { const battle = newBattle(seed); run(battle, new Replay(record)); return battle;}
function sameLines(first, second) { if (first.length !== second.length) return false; for (let i = 0; i < first.length; 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 реда и нито една разлика. Затова повторението може да се провери, а не само да се гледа.
- Семето и записът са целият бой. Същият запис, пуснат без екран хиляди пъти, е и симулацията за баланса.
Програмата играе бой с автопилот, печата записа и го пуска два пъти: със същото семе и с друго.
const battle = newBattle(7);run(battle, new Auto());console.log("Запис:");for (const r of battle.record) { console.log(describe(r));}const again = replayBattle(7, battle.record);console.log("Същият бой от записа: " + (sameLines(battle.log, again.log) ? "да" : "не"));const other = replayBattle(8, battle.record);console.log("Същият бой от друго семе: " + (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: обратно)function actionFromRecord(battle, record) { const actor = battle.creatureAt(record.actor); const 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);}
// Връща дневника на повторения бой.function replayBattle(snapshot, record) { const world = new ReplayWorld(snapshot); const enemies = []; for (const enemy of snapshot.enemies) enemies.push(enemy.copy()); const battle = new Battle(world, enemies, snapshot.canFlee); battle.script = record; battle.start(); battle.advance(); return battle.log;}
function sameLines(first, second) { if (first.length !== second.length) return false; for (let i = 0; i < first.length; i++) { if (first[i] !== second[i]) return false; } return true;}По-точно, за любопитните
В играта повторението не започва от семето на света, а от снимка на началото на боя: състоянието на генератора и копия на героя и враговете (с уменията, маната и предметите на героя в този миг). Героят има инвентар и ниво, които не се строят от семе, затова снимката ги носи. Повторението е Battle с script вместо играч и изкуствен интелект: advance взема командите от записа. Светът за повторението (ReplayWorld) не се променя: няма трупове, злато и куестове, остава само дневникът.
Записът зависи от версията на правилата: ако промениш щетата на Вихъра, старите записи вече не съвпадат. Затова записите са добри за проверка и за търсене на грешки в същата версия, не за вечно хранилище.
Идеята
Хиляда боя без екран
Пуснеш ли run с Auto за двете страни, не ти трябват екран, играч и запис. Програмата играе боеве със семена от 1 до 1000 и брои победите:
console.log("Симулация без екран");let wins = 0;let losses = 0;let commands = 0;const total = 1000;for (let seed = 1; seed <= total; seed++) { const battle = newBattle(seed); run(battle, new Auto()); if (battle.outcome === "win") wins++; else losses++; commands += battle.record.length;}const average = Math.floor((commands * 10) / total);console.log(`Боеве: ${total}, победи: ${wins}, поражения: ${losses}, средно команди: ${Math.floor(average / 10)}.${average % 10}`);Симулация без екранБоеве: 1000, победи: 875, поражения: 125, средно команди: 9.9Резултатът е 875 победи и 125 поражения. Така се мери балансът: ако героят печели всеки бой, враговете са слаби; ако го губи, са твърде силни; числата се бутат, докато изходът стане такъв, какъвто искаш. Всичко е детерминистично: същият код дава същите числа всеки път и на всеки език.
В играта това е --simulate=1000: боеве за всеки клас срещу всеки враг, както ще ги видиш в последния урок. Основата е тази: героят на автопилот (autoHero) избира команди със същата оценка, а боят върви със същия advance, без екран:
function simulateFight(game, heroClass, kind) { game.hero = makeSimHero(game, heroClass, SIM_LEVELS[kind.depth]); const battle = new Battle(game, [new Enemy(game.newId(), kind)], false); battle.autoHero = true; battle.advance(); return { win: battle.outcome === 'win', turns: battle.heroTurns };}Симулацията е възможна, защото боят пише в дневник, не на екрана (в урока live само го копира там), а изборът на команда е сменяем.
Пробвай
Предвиди изхода
Героят ползва на всеки ход последното налично умение (Мощния удар, ако може, иначе Вихъра). Вихърът струва 3 мана и се презарежда 3 хода, Мощният удар струва 4 и се презарежда 4, а героят започва с 8 мана. Какво печата програмата?
const battle = newBattle(7);const hero = battle.hero();for (let turn = 1; turn <= 5; turn++) { hero.tick(); let used = "-"; for (const skill of usableSkills(battle, hero)) used = skill.id; if (used !== "-") { battle.perform(new SkillAction(hero, skillOf(battle, used), battle.fighters[1])); } console.log(turn, used);}Ход 1: и двете са налични, взема се последното, Мощният удар (мана 8 → 4, презареждане 4). Ход 2: Мощният удар се презарежда (3), остава Вихърът и 4 мана му стигат (мана 1). От хода 3 маната е 1 и не стига за нищо, дори на хода 5, когато Мощният удар вече е презареден. Другите изходи са на програма без презареждане (Мощният удар би се повторил на хода 2) и на програма, която не харчи мана (на хода 5 Мощният удар пак щеше да е наличен).
Пробвай
Намери грешката
Колега промени SkillAction и сега повторението не съвпада с боя. Записът изглежда странно:
class SkillAction { constructor(actor, skill, target) { this.actor = actor; this.skill = skill; this.target = target; }
toRecord(battle) { return { actor: battle.indexOf(this.actor), action: "attack", target: battle.indexOf(this.target), id: this.skill.id }; }
execute(battle) { this.actor.mana -= this.skill.mana; this.actor.cooldowns.set(this.skill.id, this.skill.cooldown); battle.say(`${this.actor.name}: ${this.skill.name}`); const targets = this.skill.target === "all" ? battle.aliveEnemies() : [this.target]; for (const victim of targets) { battle.hit(this.actor, victim, Math.floor((this.actor.power * this.skill.percent) / 100) + battle.rng.roll(3)); } }}const battle = newBattle(7);run(battle, new Auto());console.log("Запис:");for (const r of battle.record) { console.log(describe(r));}const again = replayBattle(7, battle.record);console.log("Същият бой от записа: " + (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 и пусни симулацията. Колко се променят победите и защо числата са от хиляда боя, а не от един?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Масиви: push, splice и sort · Map и Set · Стрелкови функции · Данни, switch и разглобяване
- Задачи по темата: Хрониките на Арканум