Seguimos con los patrones de diseño de comportamiento, hoy nos toca el patrón State. El objetivo de este patrón es permitirnos de forma sencilla que un objeto cambie su comportamiento en función del estado en el que se encuentra.
Por ejemplo, imaginemos un objeto para gestionar la conexión con el servidor. Esta conexión puede estar abierta (conectada), cerrada (desconectada) o conectando. Según el estado en el que se encuentre la conexión su comportamiento será diferente.
Si está cerrada y le damos la orden para conectarse pasará a conectando, una vez establecida la conexión pasará a abierta. Si la conexión está abierta y le decimos que se conecte simplemente puede ignorar esta orden.
Mientras la conexión esté abierta podremos enviar paquetes, pero si intentamos enviar paquetes cuando la conexión está cerrada no mostrará un error.
Como veis, el comportamiento cambia en función del estado. Vamos a verlo en más profundidad y veremos un ejemplo de como aplicarlo a videojuegos.
State pattern
Lo primero el diagrama:
Por un lado tenemos el Context
, que sería la clase de conexión en el ejemplo anterior, y este Contexto habla con los estados. El Contexto simplemente hace de puerto de entrada para los clientes, de forma que no necesiten conocer a los estados, y delega las instrucciones al estado actual.
Este estado actual recibirá la orden por parte del Contexto, hará su lógica e informará al Context para que sepa que ha terminado y si debe cambiar a otro estado.
El patrón State tiene muchas formas distintas de implementarse, sobretodo en cuanto a la gestión de estados se refiere. Podemos hacer que el Context tenga las condiciones de cambio de estado, pero esto implica que cada vez que añadamos un estado tendremos que añadir nuevas condiciones.
Otra opción es que cada estado defina a que otros estados puede ir, la ventaja de esto es que es más fácil añadir nuevos estados, pero también tenemos esta lógica mucho más distribuida.
Otra podría ser tener una tabla de configuración con las posibles transiciones. El contexto recibirá esta configuración y cuando un estado termine solo tendrá que mirar en la tabla cual es el siguiente estado a ejecutar.
Podemos hacer que un estado solo pueda cambiar a otro estado, o podemos hacer que desde un estado podamos ir a varios teniendo en cuenta el resultado del estado anterior. Obviamente cuanto más flexible sea el sistema más compleja será la configuración.
Implementando el patrón State
Vamos a pasar ahora a la implementación, en esta ocasión vamos a hacer una maquina de estados simple para gestionar el comportamiento de un enemigo cualquiera.
Este enemigo tendrá 4 estados posibles: buscando oponente, moviéndose hacia un objetivo, atacando a un objetivo y esperando.
- Esperando:
- Estará en idle y pasados X segundos se moverá al estado <<buscando oponente>>.
- Buscando oponente:
- Buscará objetivos cercanos, si lo encuentra cambiará a <<moviéndose hacia un objetivo>>, si no encuentra objetivo volverá a <<esperando>>.
- Moviéndose hacia un objetivo:
- Se moverá hacia un objetivo y cuando esté cerca cambiará a <<atacando a un objetivo>>
- Atacando a un objetivo:
- Le restará vida al objetivo y volverá al estado de <<esperando>>
Vamos a verlo en un diagrama de estados:
Esta sería la maquina de estados que tendría nuestro enemigo, bastante simple pero nos vale para explicar el patrón. Antes de pasar a código vamos a ver un diagrama de clases de la implementación que vamos a hacer.
Diagrama
Por un lado lo primero es definir una interfaz para nuestros estados, en este caso nos interesa que devuelva un Task porque algunos estados llevarán más de un frame ejecutarse y lo haremos con funciones asíncronas.
Además esta Task está templatizada con StateResult
que simplemente contendrá la Id del siguiente estado y podría contener algunos datos extra para ser utilizados en el siguiente estado.
Por debajo de esta interfaz tenemos nuestros 4 estados concretos.
Por otro lado hemos definido algunas clases auxiliares que nos ayudarán a realizar el cometido de nuestros estados. ITargetFinder
simplemente se encargará de hacer un FindObjectsOfType
y buscar a los posibles objetivos.
EnemyStatesConfiguration
contendrá la instancia de los 4 estados, ya que los vamos a reciclar, y también contendrá la ID de las transiciones.
Y para terminar Enemy
se encargará de ejecutar el siguiente estado cuando el actual termine.
Vamos a pasar ahora a ver el código, pero antes de esto me gustaría aclarar que me he tomado algunas licencias para simplificar la lógica y poder centrarnos en el patrón State, según vayan apareciendo comentaré alternativas.
Veamos el patrón State en Unity
Estados
public class StateResult
{
public readonly int NextStateId;
public readonly object ResultData;
public StateResult(int nextStateId, object resultData = null)
{
NextStateId = nextStateId;
ResultData = resultData;
}
}
public interface IEnemyState
{
Task<StateResult> DoAction(object data);
}
Lo he llamado IEnemyState pero perfectamente podría ser la interfaz de un estado genérico ya que ahora mismo no tiene nada en particular, pero en un futuro podríamos necesitar añadir otros métodos concretos de estos estados.
Aquí me he tomado la licencia de que el parámetro de entrada sea un object y hacer un casting dentro del State, pero en lugar de esto podríamos registrar los datos de salida de un estado en un diccionario y que fuera el estado concreto el que consultara la información que necesite. Esta técnica se utiliza para IA.
public class IdleState : IEnemyState
{
private readonly float _secondsToWait;
public IdleState(float secondsToWait)
{
_secondsToWait = secondsToWait;
}
public async Task<StateResult> DoAction(object data)
{
await Task.Delay(TimeSpan.FromSeconds(_secondsToWait));
return new StateResult(EnemyStatesConfiguration.FindTargetState);
}
}
Muy básico, simplemente le pasamos un tiempo en el constructor y esperamos ese tiempo cuando se ejecuta el estado. El siguiente estado será el de buscar un objetivo.
public class FindTargetState : IEnemyState
{
private readonly ITargetFinder _targetFinder;
private readonly Enemy _enemy;
private readonly float _sqrMaxDistance;
public FindTargetState(Enemy enemy, float visionRange, ITargetFinder targetFinder)
{
_enemy = enemy;
_sqrMaxDistance = visionRange * visionRange;
_targetFinder = targetFinder;
}
public Task<StateResult> DoAction(object data)
{
var targets = _targetFinder.FindTargets();
foreach (var target in targets)
{
if (target == _enemy)
{
continue;
}
var sqrDistanceToTheTarget = (target.CurrentPosition - _enemy.CurrentPosition).sqrMagnitude;
if (sqrDistanceToTheTarget > _sqrMaxDistance)
{
continue;
}
return Task.FromResult(new StateResult(EnemyStatesConfiguration.MovingToTargetState, target));
}
return Task.FromResult(new StateResult(EnemyStatesConfiguration.IdleState));
}
}
Recibimos el enemigo sobre el que ejecutamos el estado, la distancia máxima a la que puede estar el objetivo para considerarlo y el ITargetFinder
que nos permitirá obtener todos los posibles objetivos.
El método DoAction es muy simple, preguntará por los posibles objetivos y si encuentra uno que esté dentro de la distancia máxima pasaremos al estado para desplazarnos. Si por otro lado no encontramos un objetivo valido volveremos a Idle.
Como veis aquí, si encontramos un objetivo lo devolveremos como resultado del estado ya que queremos que se utilice en el siguiente estado de desplazarnos.
public class MoveToTargetState : IEnemyState
{
private readonly Enemy _enemy;
private readonly float _sqrMinDistanceToAttack;
private readonly float _movementSpeed;
public MoveToTargetState(Enemy enemy, float minDistanceToAttack, float movementSpeed)
{
_enemy = enemy;
_movementSpeed = movementSpeed;
_sqrMinDistanceToAttack = minDistanceToAttack * minDistanceToAttack;
}
public async Task<StateResult> DoAction(object data)
{
var target = data as Enemy;
Assert.IsNotNull(target);
var distanceToTheTarget = (target.CurrentPosition - _enemy.CurrentPosition);
do
{
_enemy.transform.Translate(distanceToTheTarget.normalized * _movementSpeed * Time.deltaTime);
await Task.Yield();
distanceToTheTarget = (target.CurrentPosition - _enemy.CurrentPosition);
} while (distanceToTheTarget.sqrMagnitude > _sqrMinDistanceToAttack);
return new StateResult(EnemyStatesConfiguration.AttackingTargetState, target);
}
}
En este estado nos vamos desplazando a una velocidad constante hasta el objetivo y cuando estamos cerca cambiamos al estado de atacar y devolvemos el objetivo como resultado.
public class AttackToTargetState : IEnemyState
{
private readonly float _damageToApply;
public AttackToTargetState(float damageToApply)
{
_damageToApply = damageToApply;
}
public Task<StateResult> DoAction(object data)
{
var target = data as Enemy;
Assert.IsNotNull(target);
target.DoDamage(_damageToApply);
return Task.FromResult(new StateResult(EnemyStatesConfiguration.IdleState));
}
}
En el estado de atacar simplemente hacemos daño al objetivo y volvemos al estado de idle.
Configuración y consumidores
Vamos ahora a por la configuración, en este caso he optado por un diccionario para almacenar los estados y unos métodos para guardar y obtener los estados.
public class EnemyStatesConfiguration
{
private int InitialState;
public const int IdleState = 0;
public const int FindTargetState = 1;
public const int MovingToTargetState = 2;
public const int AttackingTargetState = 3;
private readonly Dictionary<int, IEnemyState> _states;
public EnemyStatesConfiguration()
{
_states = new Dictionary<int, IEnemyState>();
}
public void AddInitialState(int id, IEnemyState state)
{
_states.Add(id, state);
InitialState = id;
}
public void AddState(int id, IEnemyState state)
{
_states.Add(id, state);
}
public IEnemyState GetState(int stateId)
{
Assert.IsTrue(_states.ContainsKey(stateId), $"State with id {stateId} do not exit");
return _states[stateId];
}
public IEnemyState GetInitialState()
{
return GetState(InitialState);
}
}
En la aproximación que estamos utilizando son los propios estados los que conocen los estados a los que pueden cambiar y bajo que condiciones, pero otra opción sería dotar a esta clase de más lógica y añadir las transiciones en la configuración. Esta segunda aproximación seguramente aporte más flexibilidad pero la solución es más compleja.
Y por ultimo tenemos el enemigo que es la clase que hace de Context
para los estados.
public class Enemy : MonoBehaviour, ITarget
{
public Vector3 CurrentPosition => transform.position;
private EnemyStatesConfiguration _enemyStatesConfiguration;
private TargetFinder _targetsFinder;
private void Awake()
{
_enemyStatesConfiguration = new EnemyStatesConfiguration();
_enemyStatesConfiguration.AddInitialState(EnemyStatesConfiguration.IdleState,
new IdleState(2.0f)
);
_enemyStatesConfiguration.AddState(EnemyStatesConfiguration.FindTargetState,
new FindTargetState(this, 20, TargetFinder.Instance));
_enemyStatesConfiguration.AddState(EnemyStatesConfiguration.MovingToTargetState,
new MoveToTargetState(this, 2, 2));
_enemyStatesConfiguration.AddState(EnemyStatesConfiguration.AttackingTargetState,
new AttackToTargetState(2));
}
private void Start()
{
StartState(_enemyStatesConfiguration.GetInitialState());
}
private async void StartState(IEnemyState state, object data = null)
{
while (true)
{
var resultData = await state.DoAction(data);
var nextState = _enemyStatesConfiguration.GetState(resultData.NextStateId);
state = nextState;
data = resultData.ResultData;
}
}
public void DoDamage(float damageToApply)
{
Debug.Log("Receiving damage");
}
}
En esta clase estamos configurando los estados en el Awake
y arrancamos el primer estado en el Start
. Después simplemente entramos en un bucle infinito donde esperaremos a que acabe el estado actual y ejecutaremos el siguiente según el resultado.
Aquí hay alguna cosa que me he tomado la licencia de ignorar para centrarme en el patrón, cómo podría ser que ocurriría con el estado actual si el enemigo muere o queda estuneado. En ese caso seguramente tendríamos un estado de muerte y necesitaríamos algún mecanismo de detener o pausar la lógica del estado actual.
Conclusión
En este post hemos visto como implementar una versión del patrón State, y es que este patrón tiene muchas formas de ser implementado. Podemos hacer que la transición de los estados la gestionen los propios estados, la podemos gestionar desde el contexto, o incluso hacerlo todo desde la configuración.
Este patrón es muy utilizados para temas de IA y nos ha permitido modelar los distintos comportamientos del enemigo sin tener que modificar para nada esta clase.
Una evolución de este patrón podrían ser los Behaviour Tree, que no son otra cosa que una maquina de estados con condicionales, loops y algo más de azúcar.
Si quieres descargar el código de ejemplo que hemos utilizado aquí y probarlo tu mismo aquí te dejo el enlace.
Fuentes
- El libro de GANG OF FOUR, patrones de diseño (Erich Gamma)
- Game Programming Patterns (Robert Nystrom – Electronic Arts)
- Agile Principles, Patterns, and Practices in C# (Robert C. Martin)