Saltar al contenido principal

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.

Los módulos de batalla

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:

FamiliaClaseVive en
Estado alteradoStatus (Burn, Poison, BadlyPoison, Paralysis, Freeze, Sleep)uno por combatiente
VolátilVolatileStatusun combatiente
De bandoSideStatusun lado del campo
GlobalGlobalStatustodo 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 juegoIBattleSystem (OnBattleStarted, OnBattleEnded) o BattleManager.SubscribeToBattleInitialized / SubscribeToBattleFinished
Cambiar lo que hace un entrenadorUn ScriptableObject BattleAI (IA de batalla)
Cambiar el daño, las estadísticas o los efectos de turnoUn 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 turnoUn 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​

  1. Mantén los módulos enfocados. Cada módulo debe encargarse de una sola responsabilidad.
  2. Llega a los hermanos mediante los accesores de la clase base, no mediante búsquedas en la escena.
  3. Pon las reglas en ScriptableObjects (estados, habilidades, objetos, movimientos) en lugar de en el bucle: son comprobables y reutilizables.
  4. Prueba las matemáticas de daño y captura con entradas conocidas y salidas esperadas mediante los ayudantes puros.
  5. No modifiques directamente el estado de Battler: pasa por el módulo que se encarga de esa cuestión (ChangeLife para el HP, el módulo de estados para los estados).
  6. Usa la máquina de estados para ordenar los turnos. No agregues corrutinas ad hoc.