Перейти к основному содержимому

Внутреннее устройство сражений: подробный разбор

Это руководство предназначено для пользователей редакций Source и Studio, имеющих доступ ко всему исходному коду MCE на C#.

Боевые модули

Пятнадцать модулей, один оркестратор. BattleManager не содержит собственной боевой логики — он задаёт вопросы, а конечный автомат решает, что будет спрошено дальше.

Этот документ подробно рассматривает архитектуру BattleManager, его 15 модулей, BattleStateMachine, формулы, которые использует сражение, и способы добавления новых боевых возможностей. О публичных членах см. API BattleManager.

Архитектура BattleManager​

BattleManager следует модульному паттерну композиции. Вместо монолитного класса на тысячи строк боевая логика разделена между специализированными модулями, которые живут на одном GameObject и общаются через центр — BattleManager. Менеджер объявляет их через [RequireComponent], поэтому добавление BattleManager на GameObject добавляет весь набор:

[RequireComponent(typeof(BattleStateMachine))]
[RequireComponent(typeof(BattleManagerBattlersModule))]
[RequireComponent(typeof(BattleManagerBattlerSwitchModule))]
[RequireComponent(typeof(BattleManagerBattlerStatsModule))]
[RequireComponent(typeof(BattleManagerHealthModule))]
[RequireComponent(typeof(BattleManagerMovesModule))]
[RequireComponent(typeof(BattleManagerItemsModule))]
[RequireComponent(typeof(BattleManagerCaptureModule))]
[RequireComponent(typeof(BattleManagerRostersModule))]
[RequireComponent(typeof(BattleManagerAIModule))]
[RequireComponent(typeof(BattleManagerScenariosModule))]
[RequireComponent(typeof(BattleManagerStatusesModule))]
[RequireComponent(typeof(BattleManagerCharactersModule))]
[RequireComponent(typeof(BattleManagerAnimationModule))]
[RequireComponent(typeof(BattleManagerAudioModule))]
[RequireComponent(typeof(BattleManagerMegaModule))]
public class BattleManager : OpenMonBehaviour<BattleManager>, IBattleManager, IPlayerDataReceiver
{
// Module accessors, resolved from sibling components:
internal BattleManagerBattlersModule Battlers => GetCachedComponent<BattleManagerBattlersModule>();
internal BattleManagerHealthModule BattlerHealth => GetCachedComponent<BattleManagerHealthModule>();
internal BattleManagerMovesModule Moves => GetCachedComponent<BattleManagerMovesModule>();
// ...BattleManagerBattlerSwitch, BattlerStats, Items, Capture, Rosters, AI,
// Scenario, Statuses, Characters, Animation, Audio and Megas.
}

Базовый класс BattleManagerModule​

Все модули наследуют от обобщённого BattleManagerModule<T>:

public abstract class BattleManagerModule<T> : OpenMonBehaviour<T>
where T : BattleManagerModule<T>
{
protected BattleManager BattleManager => GetCachedComponent<BattleManager>();

// The same accessors the manager has, so modules can call into each other:
protected BattleManagerBattlersModule Battlers => BattleManager.Battlers;
protected BattleManagerHealthModule BattlerHealth => BattleManager.BattlerHealth;
protected BattleManagerMovesModule Moves => BattleManager.Moves;
// ...
}

Поэтому модулю не нужны ни код регистрации, ни вызов Initialize: это MonoBehaviour на GameObject сражения, и он обращается к остальным через унаследованные аксессоры. GetCachedComponent<T>() (из OpenMonBehaviour) находит любой соседний компонент.

Подробный разбор модулей​

BattleManagerBattlersModule​

Файл: BattleManagerBattlersModule.cs

Сопоставляет бойцов с их боевыми индексами и спрайтами и обратно, обрабатывает смену форм и бегство. Battler (в Battler.cs) — это MonsterInstance с состоянием в сражении сверху:

public class Battler : MonsterInstance
{
public SerializableDictionary<Stat, short> StatStage;
public SerializableDictionary<BattleStat, short> BattleStatStage; // accuracy, evasion...
public byte CriticalStage;
public SubstituteData Substitute;
public Dictionary<VolatileStatus, int> VolatileStatuses;
public List<Battler> BattlersFought;
public List<Move> MovesUsedSinceEnteringBattle;
public bool EnteredBattleThisTurn;
// ...hooks such as OnCalculateStat, OnStatChange, CanSwitch, GetTypes, GetAbility
}

Ключевые методы: GetBattlerFromBattleIndex(type, index), GetTypeAndIndexOfBattler(battler), GetBattlersFighting(type), IsBattlerFighting, ChangeForm(...), RunAway(...).

BattleManagerMovesModule​

Файл: BattleManagerMovesModule.cs

Выполняет приём: PerformMove(type, index, targets, moveIndex) (слот 0-3, 4 для Struggle) запускает весь конвейер эффектов, а ForcePerformMove используется приёмами, вызывающими другие приёмы. Урон здесь не вычисляется, а вычисляется самим приёмом (DamageMove.CalculateMoveDamage) через чистые вспомогательные методы DamageMath.

Конвейер урона (DamageMath, Gen V+):

// Base: (2 * level / 5 + 2) * power * attack/defense * externalPowerMultiplier / 50 + 2
float baseMultiplier = DamageMath.ComputeBaseMultiplier(level, power, attackDefense, externalPowerMultiplier);

// Every factor is computed by the move and multiplied in one place:
float damage = DamageMath.ComputeDamage(baseMultiplier,
targets, // 0.75 (SpreadMultiplier) when hitting several targets
weather, // from the active Weather
terrain, // from the active Terrain
critical, // 1.5 (CriticalMultiplier) on a critical hit
random, // between 0.85 (MinRandom) and 1
stab, // 1.5 (StabMultiplier) x ability changes
typeEffectiveness, // product over the defender's types
status, // e.g. Burn returns 0.5 for physical moves
additional); // abilities, held items, side statuses, allies

int final = DamageMath.RoundDamage(damage); // rounded, never below 1

Множитель additional накапливается через хуки: OnCalculateMoveDamageWhenUsing применяющего, хуки союзников, OnCalculateMoveDamageWhenTargeted цели и хуки SideStatus стороны цели; каждый получает текущее произведение и возвращает новое.

BattleManagerHealthModule​

Файл: BattleManagerHealthModule.cs

Все изменения HP проходят через этот модуль:

  • ChangeLife(battler, user, amount, ...) -- канонический способ нанести урон бойцу или вылечить его: запускает анимацию и звук удара, срабатывают способности, возвращается (appliedAmount, fainted).
  • CheckFaintedBattlers() -- обнаруживает бойцов, чьи HP упали до нуля.
  • ProcessFaintedBattlers() -- проигрывает анимации потери сознания и разрешает их (XP, EV, замены).

BattleManagerStatusesModule​

Файл: BattleManagerStatusesModule.cs

Управляет четырьмя семействами статусов, каждое из которых — ScriptableObject, который можно расширять:

СемействоКлассЖивёт на
Статусное состояниеStatus (Burn, Poison, BadlyPoison, Paralysis, Freeze, Sleep)по одному на бойца
ИзменчивыйVolatileStatusбоец
Статус стороныSideStatusодна сторона поля
ГлобальныйGlobalStatusвсё поле

Погода и местность — часть сценария сражения (BattleManagerScenariosModule), а не этого модуля. Status — набор виртуальных хуков. Например, Burn переопределяет OnStatusAddedInBattle, OnStatusTickInBattle (урон в конце хода), OnCalculateMoveDamage (0,5 для физических приёмов) и PlayAnimation; остальные доступные — CanAddStatus, OnStatusRemovedInBattle, OnAboutToPerformMove, OnCalculateStat, AboutToBeHitByMove и AfterHitByMove.

API модуля — AddStatus(...) и RemoveStatus(...) (по одной перегрузке на семейство), HasStatus(...) и TriggerStatusesCountdown(), который конечный автомат вызывает раз за ход.

BattleManagerCaptureModule​

Файл: BattleManagerCaptureModule.cs, формулы в BattleUtils.cs

TryCaptureMonster(index, ball) выполняет поимку дикого противника с броском мяча и анимациями покачивания. Вероятность берётся из BattleUtils.CalculateShakeProbability:

// The modified catch rate:
float catchRate = ((3 * maxHP - 2 * currentHP) * rate * ballMultiplier + ballAddition)
/ (3 * maxHP)
* statusBonus // Status.CatchMultiplier
* Mathf.Max((30 - level) * 0.1f, 1); // low-level bonus

// One shake succeeds with this probability (x the game's catch-difficulty multiplier):
float shake = Mathf.Floor(65536 / Mathf.Pow(255 / catchRate, 0.1875f)) / 65535f;

Мяч с флагом NeverFails всегда покачивается. Онлайн число успешных покачиваний определяет сервер (см. Авторитет сражений).

BattleUtils.RunChance(allySpeed, enemySpeed, attempts) — соответствующая формула бегства из дикого сражения.

BattleStateMachine​

Файл: BattleStateMachine.cs

Конечный автомат — это простой цикл-корутина (BattleLoop), который запускает BattleManager через StartBattleLoop(). Это не перечисление состояний: каждый проход цикла while (!BattleManager.IsBattleOver) — это один ход.

while (!BattleManager.IsBattleOver)
{
Battlers.UpdateBattlersFought();
yield return RequestActions(result => actions = result); // player menu + AIs

// Server-authoritative battle: the server resolves the turn and the loop shows the result.
// Otherwise:
yield return DetermineActionOrder(actions, ...); // priority, then speed
yield return PerformActions();

yield return AfterTurnPreStatusCallbacks(BattlerType.Ally);
yield return AfterTurnPreStatusCallbacks(BattlerType.Enemy);
yield return Statuses.TriggerStatusesCountdown();
yield return AfterTurnPostStatusCallbacks(BattlerType.Ally);
yield return AfterTurnPostStatusCallbacks(BattlerType.Enemy);

yield return ResetBattlersTurnFlags(BattlerType.Ally);
yield return ResetBattlersTurnFlags(BattlerType.Enemy);

yield return BattlerHealth.CheckFaintedBattlers();
yield return BattlerHealth.ProcessFaintedBattlers();
// ...end of the turn
}

yield return Animation.BattleEndAnimation();
yield return Characters.GivePlayerPriceMoney();
yield return Characters.TakePlayerDefeatMoney();
yield return BattleEndCallbacks();

(Листинг сокращён; полный цикл, включая серверно-авторитетную ветвь, смотрите в файле.)

Добавление новых боевых модулей​

Чтобы добавить модуль в боевую систему:

Шаг 1: Создайте класс модуля​

using System.Collections;
using OpenMon.MCE.Battle;
using UnityEngine;

public class BattleTurnLogModule : BattleManagerModule<BattleTurnLogModule>
{
private int turnsLogged;

public IEnumerator LogLeads()
{
Battler ally = Battlers.GetBattlerFromBattleIndex(BattlerType.Ally, 0);
Battler enemy = Battlers.GetBattlerFromBattleIndex(BattlerType.Enemy, 0);

turnsLogged++;
Debug.Log($"Turn {turnsLogged}: {ally.Species.name} vs {enemy.Species.name}");
yield break;
}
}

Шаг 2: Поместите его рядом с BattleManager​

Добавьте компонент на GameObject BattleManager в сцене сражения. (Если хотите, чтобы он шёл с каждым BattleManager, добавьте также [RequireComponent(typeof(BattleTurnLogModule))] в менеджер и типизированный аксессор рядом с остальными.)

Шаг 3: Вызовите его​

Из конечного автомата, из другого модуля или из хука:

yield return BattleManager.GetCachedComponent<BattleTurnLogModule>().LogLeads();

Подключение к сражению без событий​

Сражение не публикует события C# между модулями. Точки расширения:

Вы хотите...Расширьте
Реагировать на начало и конец сражения из игрыIBattleSystem (OnBattleStarted, OnBattleEnded) или BattleManager.SubscribeToBattleInitialized / SubscribeToBattleFinished
Изменить действия тренераScriptableObject BattleAI (ИИ сражений)
Изменить урон, характеристики или эффекты ходаScriptableObject Status, Weather, Terrain, Ability, Item или Move: все они предоставляют хуки вида OnCalculate..., OnStatusTick..., AfterHitByMove, которые вызывает цикл
Добавить в ход совершенно новую фазуНовый модуль, вызываемый из BattleStateMachine

Тестирование боевой логики​

Чистые части сражения — это простые статические функции, поэтому их можно тестировать без сцены. Набор тестов режима редактора в репозитории (MCE.Editor.Tests, в Assets/OpenMon/Core/Tests/Editor/) делает это в файлах вроде DamageFormulaTests.cs:

[Test]
public void ComputeDamage_MultipliesEveryFactor()
{
// Base 100, hitting one target, no weather or terrain, no crit, max roll,
// STAB (x1.5) and super effective (x2), no status, nothing extra.
float damage = DamageMath.ComputeDamage(100f, 1f, 1f, 1f, 1f, 1f,
DamageMath.StabMultiplier, 2f, 1f, 1f);

Assert.AreEqual(300f, damage, 0.001f);
}

[Test]
public void RoundDamage_NeverBelowOne() => Assert.AreEqual(1, DamageMath.RoundDamage(0.2f));

Рекомендации​

  1. Делайте модули узконаправленными. Каждый модуль должен отвечать за одну обязанность.
  2. Обращайтесь к соседям через аксессоры базового класса, а не через поиск по сцене.
  3. Помещайте правила в ScriptableObject (статусы, способности, предметы, приёмы), а не в цикл: их можно тестировать и переиспользовать.
  4. Тестируйте математику урона и поимки с известными входными данными и ожидаемыми результатами через чистые вспомогательные методы.
  5. Не изменяйте состояние Battler напрямую -- обращайтесь к модулю, который отвечает за эту область (ChangeLife для HP, модуль статусов для статусов).
  6. Используйте конечный автомат для упорядочивания ходов. Не добавляйте разовые корутины.