跳到主要内容

战斗内部机制深入解析

本指南面向可以访问完整 MCE C# 源码的 Source 和 Studio 版用户。

战斗模块

十五个模块,一个协调者。BattleManager 自己不包含战斗逻辑 —— 它负责询问, 而状态机决定接下来询问什么。

本文档详细介绍 BattleManager 的架构、它的 15 个模块、BattleStateMachine、战斗所用的公式,以及如何添加新的战斗功能。公共成员请参见 BattleManager API。

BattleManager 架构​

BattleManager 遵循模块化组合模式。它不是一个有数千行的庞大类,而是将战斗逻辑拆分到专注的模块中,这些模块位于同一个 GameObject 上,并通过 BattleManager 这个枢纽通信。管理器用 [RequireComponent] 声明它们,因此给 GameObject 添加 BattleManager 就会添加整套模块:

[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 调用:它是战斗 GameObject 上的一个 MonoBehaviour,通过继承的访问器访问其他模块。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,第五世代及以后):

// 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() -- 播放濒死动画并处理它们(经验值、努力值、替换)。

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 旁边​

把组件添加到战斗场景中 BattleManager 的 GameObject 上。(如果你希望它随每个 BattleManager 一起出现,还要在管理器上添加 [RequireComponent(typeof(BattleTurnLogModule))],并在其他访问器旁边添加一个带类型的访问器。)

步骤 3:调用它​

从状态机、另一个模块或某个钩子中调用:

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

不使用事件接入战斗​

战斗不会在模块之间发布 C# 事件。扩展点有:

你想要……扩展
在游戏中对战斗的开始和结束做出反应IBattleSystem(OnBattleStarted、OnBattleEnded)或 BattleManager.SubscribeToBattleInitialized / SubscribeToBattleFinished
改变训练师的行为一个 BattleAI ScriptableObject(战斗 AI)
改变伤害、能力值或回合效果一个 Status、Weather、Terrain、Ability、Item 或 Move ScriptableObject:它们都公开了循环所调用的 OnCalculate...、OnStatusTick...、AfterHitByMove 风格的钩子
为回合添加一个全新的阶段一个由 BattleStateMachine 调用的新模块

测试战斗逻辑​

战斗的纯逻辑部分是简单的静态函数,因此无需场景就能测试。仓库的编辑模式测试套件(位于 Assets/OpenMon/Core/Tests/Editor/ 的 MCE.Editor.Tests)就在 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 的状态 —— 通过负责该事务的模块(HP 用 ChangeLife,状态用状态模块)。
  6. 使用状态机来安排回合顺序。不要添加临时的协程。