Análisis a fondo de los internos de la batalla
Esta guía es para los usuarios de las ediciones Source y Studio que tienen acceso al código fuente C# completo de MCE.
Quince módulos, un orquestador. BattleManager no contiene lógica de combate propia: pregunta,
y la máquina de estados decide qué se pregunta a continuación.
Este documento ofrece una mirada detallada a la arquitectura de BattleManager, sus 15 módulos, el BattleStateMachine, las fórmulas que usa la batalla y cómo agregar nuevas funciones de batalla. Para los miembros públicos consulta la API de BattleManager.
Arquitectura de BattleManager
BattleManager sigue un patrón de composición modular. En lugar de ser una clase monolítica de miles de líneas, la lógica de batalla se reparte en módulos específicos que viven en el mismo GameObject y se comunican a través del centro que es BattleManager. El gestor los declara con [RequireComponent], así que agregar un BattleManager a un GameObject agrega todo el conjunto:
[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.
}
Clase base BattleManagerModule
Todos los módulos heredan de la clase genérica 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;
// ...
}
Por tanto, un módulo no necesita código de registro ni una llamada a Initialize: es un MonoBehaviour en el GameObject de la batalla y llega a los demás mediante los accesores heredados. GetCachedComponent<T>() (de OpenMonBehaviour) encuentra cualquier componente hermano.
Análisis a fondo de los módulos
BattleManagerBattlersModule
Archivo: BattleManagerBattlersModule.cs
Resuelve los combatientes hacia y desde sus índices de batalla y sprites, y gestiona los cambios de forma y la huida. Un Battler (en Battler.cs) es un MonsterInstance con el estado en batalla encima:
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
}
Métodos clave: GetBattlerFromBattleIndex(type, index), GetTypeAndIndexOfBattler(battler), GetBattlersFighting(type), IsBattlerFighting, ChangeForm(...), RunAway(...).
BattleManagerMovesModule
Archivo: BattleManagerMovesModule.cs
Ejecuta un movimiento: PerformMove(type, index, targets, moveIndex) (ranura 0-3, 4 para Struggle) ejecuta todo el pipeline de efectos, y ForcePerformMove lo usan los movimientos que llaman a otros movimientos. El daño no se calcula aquí sino en el propio movimiento (DamageMove.CalculateMoveDamage), mediante los ayudantes puros de DamageMath.
El pipeline de daño (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
El factor additional se acumula mediante ganchos: el OnCalculateMoveDamageWhenUsing del usuario, los ganchos de los aliados, el OnCalculateMoveDamageWhenTargeted del objetivo y los ganchos de SideStatus del bando objetivo, cada uno de los cuales recibe el producto acumulado y devuelve el nuevo.
BattleManagerHealthModule
Archivo: BattleManagerHealthModule.cs
Todos los cambios de HP pasan por este módulo:
ChangeLife(battler, user, amount, ...)-- la forma canónica de dañar o curar a un combatiente: ejecuta la animación y el audio del golpe, dispara habilidades e informa(appliedAmount, fainted).CheckFaintedBattlers()-- detecta los combatientes cuyo HP llegó a cero.ProcessFaintedBattlers()-- reproduce las animaciones de debilitamiento y las resuelve (XP, EV, reemplazos).
BattleManagerStatusesModule
Archivo: BattleManagerStatusesModule.cs
Gestiona cuatro familias de estados, cada una un ScriptableObject que puedes extender:
| Familia | Clase | Vive en |
|---|---|---|
| Estado alterado | Status (Burn, Poison, BadlyPoison, Paralysis, Freeze, Sleep) | uno por combatiente |
| Volátil | VolatileStatus | un combatiente |
| De bando | SideStatus | un lado del campo |
| Global | GlobalStatus | todo el campo |
El clima y el terreno forman parte del escenario de la batalla (BattleManagerScenariosModule) y no de este módulo. Un Status es un conjunto de ganchos virtuales. Burn, por ejemplo, sobrescribe OnStatusAddedInBattle, OnStatusTickInBattle (el daño al final del turno), OnCalculateMoveDamage (0,5 para movimientos físicos) y PlayAnimation; los demás disponibles son CanAddStatus, OnStatusRemovedInBattle, OnAboutToPerformMove, OnCalculateStat, AboutToBeHitByMove y AfterHitByMove.
La API del módulo es AddStatus(...) y RemoveStatus(...) (una sobrecarga por familia), HasStatus(...) y TriggerStatusesCountdown(), que la máquina de estados llama una vez por turno.
BattleManagerCaptureModule
Archivo: BattleManagerCaptureModule.cs, fórmulas en BattleUtils.cs
TryCaptureMonster(index, ball) ejecuta una captura contra un enemigo salvaje, con el lanzamiento de la bola y las animaciones de sacudida. La probabilidad proviene de 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;
Una bola marcada como NeverFails siempre se sacude. En línea, el número de sacudidas exitosas lo decide el servidor (consulta Autoridad de batalla).
BattleUtils.RunChance(allySpeed, enemySpeed, attempts) es la fórmula correspondiente para huir de una batalla salvaje.
BattleStateMachine
Archivo: BattleStateMachine.cs
La máquina de estados es un simple bucle de corrutina (BattleLoop), iniciado por BattleManager con StartBattleLoop(). No es un enum de estados: cada pasada del bucle while (!BattleManager.IsBattleOver) es un turno.
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();
(El listado está abreviado; lee el archivo para ver el bucle completo, incluida la rama autoritativa en el servidor.)
Agregar nuevos módulos de batalla
Para agregar un módulo al sistema de batalla:
Paso 1: Crear la clase del módulo
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;
}
}
Paso 2: Ponerlo junto al BattleManager
Agrega el componente al GameObject del BattleManager en la escena de batalla. (Si quieres que venga con cada BattleManager, agrega también [RequireComponent(typeof(BattleTurnLogModule))] al gestor y un accesor tipado junto a los demás.)
Paso 3: Llamarlo
Desde la máquina de estados, desde otro módulo o desde un gancho:
yield return BattleManager.GetCachedComponent<BattleTurnLogModule>().LogLeads();
Engancharse a una batalla sin eventos
La batalla no publica eventos de C# entre módulos. Los puntos de extensión son:
| Si quieres... | Extiende |
|---|---|
| Reaccionar al inicio y al fin de una batalla desde el juego | IBattleSystem (OnBattleStarted, OnBattleEnded) o BattleManager.SubscribeToBattleInitialized / SubscribeToBattleFinished |
| Cambiar lo que hace un entrenador | Un ScriptableObject BattleAI (IA de batalla) |
| Cambiar el daño, las estadísticas o los efectos de turno | Un ScriptableObject Status, Weather, Terrain, Ability, Item o Move: todos exponen ganchos del estilo OnCalculate..., OnStatusTick..., AfterHitByMove que llama el bucle |
| Agregar una fase totalmente nueva al turno | Un módulo nuevo llamado desde BattleStateMachine |
Probar la lógica de batalla
Las partes puras de la batalla son funciones estáticas simples, así que se pueden probar sin escena. La suite en modo edición del repositorio (MCE.Editor.Tests, en Assets/OpenMon/Core/Tests/Editor/) lo hace en archivos como 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));
Buenas prácticas
- Mantén los módulos enfocados. Cada módulo debe encargarse de una sola responsabilidad.
- Llega a los hermanos mediante los accesores de la clase base, no mediante búsquedas en la escena.
- Pon las reglas en ScriptableObjects (estados, habilidades, objetos, movimientos) en lugar de en el bucle: son comprobables y reutilizables.
- Prueba las matemáticas de daño y captura con entradas conocidas y salidas esperadas mediante los ayudantes puros.
- No modifiques directamente el estado de
Battler: pasa por el módulo que se encarga de esa cuestión (ChangeLifepara el HP, el módulo de estados para los estados). - Usa la máquina de estados para ordenar los turnos. No agregues corrutinas ad hoc.