Seguimos con la serie de patrones, hoy nos toca el patrón Command o Comando. Este es el primer patrón de comportamiento que vemos en la serie.
En resumidas cuentas, el patrón Command es simplemente una orden, cierta lógica que queremos hacer, encapsulada en una clase y con un método Execute.
Esto nos permitirá reciclar este comportamiento tantas veces como queramos, también nos permitirá concatenar Comandos (lo que se conoce como una cola de comandos), y lo mejor de todo, nos permitirá que nuestras clases puedan ejecutar acciones sin conocer los detalles de estas. ¡Es un win en toda regla!
Diagrama del patrón Command
Como siempre vamos a ver primero el diagrama, aunque es bastante sencillo.
Tenemos una interfaz de Comando, todos los comandos concretos que necesitemos, el que va a recibir la acción y un cliente que ejecutará el comando. El comando simplemente llamará a los métodos de Receiver
para ejecutar sus acciones.
Igual algunos estaréis pensando, ¿y si necesito añadir parámetros a ese Execute?
Para esto tenemos varias opciones, la primera y más común es inyectar esos parámetros en el constructor, siempre que conozcamos este valor cuando vamos a crear el comando.
En el caso de que fueran parámetros que solo sabemos en el momento de ejecutarlo, en el constructor podríamos pasarle un Action, o algún objeto con el que obtener esta información que necesita.
Otra opción sería añadirlos en el Execute pero puede que no todos los comandos necesiten la misma información y tengamos que añadir variables templatizadas para poder generalizarlo.
Implementación de un Command pattern
Bien, para este ejemplo vamos a hacer una cola de comandos asíncrona, porque si sabemos hacerla asíncrona sabemos hacerla síncrona. Para esto utilizaremos funciones async que ya hicimos un vídeo de YouTube explicando las ventajas de estas sobre las corrutinas, te lo dejo aquí abajo.
Nuestra cola de comandos lo que hará será recibir ICommand
, los irá poniendo en cola y cuando termine un Command empezará a ejecutar el siguiente.
Sobre esto se pueden hacer muchas variantes, porque tal vez queremos ejecutar comandos en paralelo, o que se ejecute un comando u otro según los resultados del comando anterior. Muchas cosas en las que no voy a entrar porque si no esto escala exponencial y son cosas que podéis deducir vosotros mismos una vez tengáis la base.
Vamos a ver el diagrama de lo que estamos hablando.
La interfaz de ICommand
es muy sencilla, solo tiene el método Execute
:
public interface ICommand
{
Task Execute();
}
La cola de comandos tampoco tiene mucho misterio y se puede implementar de muchas formas distintas, en nuestro caso lo que vamos a hacer es que cuando añadamos un comando nuevo, comprobemos si hay comandos en ejecución y si no ejecutaremos este comando.
public class CommandQueue
{
public static CommandQueue Instance => _instance ?? (_instance = new CommandQueue());
private readonly Queue<ICommand> _commandsToExecute;
private bool _runningCommand;
private static CommandQueue _instance;
private CommandQueue()
{
_commandsToExecute = new Queue<ICommand>();
_runningCommand = false;
}
public void AddCommand(ICommand commandToEnqueue)
{
_commandsToExecute.Enqueue(commandToEnqueue);
RunNextCommand().WrapErrors();
}
private async Task RunNextCommand()
{
if (_runningCommand)
{
return;
}
while (_commandsToExecute.Count > 0)
{
_runningCommand = true;
var commandToExecute = _commandsToExecute.Dequeue();
await commandToExecute.Execute();
}
_runningCommand = false;
}
}
Como estamos utilizando Unity, la función WrapErrors
simplemente nos proporciona un contexto para capturar las posibles excepciones de una función async, en el vídeo sobre Corrutinas y Async podréis encontrar toda la información.
Vamos a ver ahora los dos comandos concretos:
public class CanvasFadeCommand : ICommand
{
private readonly CanvasGroup _canvasGroup;
private readonly float _newAlpha;
private readonly float _duration;
public CanvasFadeCommand(CanvasGroup canvasGroup, float newAlpha, float duration)
{
_canvasGroup = canvasGroup;
_newAlpha = newAlpha;
_duration = duration;
}
public async Task Execute()
{
var initialAlpha = _canvasGroup.alpha;
var alphaDifference = _newAlpha - initialAlpha;
var alphaIncrement = alphaDifference / _duration;
while (Math.Abs(_canvasGroup.alpha - _newAlpha) > 0.01f)
{
_canvasGroup.alpha += alphaIncrement * Time.deltaTime;
await Task.Yield();
}
}
}
Este script simplemente modificará el alfa de un CanvasGroup de una forma muy artesanal. Para estos casos es mejor utilizar Tweeners pero lo que me interesa es ilustrar que podemos utilizar todo tipo de comandos.
public class LoadSceneCommand : ICommand
{
private readonly string _sceneName;
public LoadSceneCommand(string sceneName)
{
_sceneName = sceneName;
}
public async Task Execute()
{
var asyncOperation = SceneManager.LoadSceneAsync(_sceneName);
while (!asyncOperation.isDone)
{
await Task.Yield();
}
}
}
Con los comandos ya creados solo nos falta consumirlos. Tendremos un menú con un botón que al pulsarlo queremos que haga fade out de su canvas y fade in del canvas del otro menú, esto lo podríamos implementar de esta forma:
public class Menu1 : MonoBehaviour
{
[SerializeField] private Button _showMenu2Button;
[SerializeField] private CanvasGroup _canvasGroup1;
[SerializeField] private CanvasGroup _canvasGroup2;
private void Awake()
{
_showMenu2Button.onClick.AddListener(ShowNextMenu);
}
private void ShowNextMenu()
{
CommandQueue.Instance.AddCommand(
new CanvasFadeCommand(_canvasGroup1, 0, 0.5f)
);
CommandQueue.Instance.AddCommand(
new CanvasFadeCommand(_canvasGroup2, 1, 0.5f)
);
}
}
Aquí estamos consumiendo los comandos y encolandolos cuando se pulsa el botón, pero podemos exprimir un poco más el patrón Command y hacer que el propio menú no conozca los detalles de los comandos que ejecuta.
Esto nos aporta varias cosas:
- Podemos tener una configuración donde decidimos qué va a hacer cada botón y lo podemos ver de un vistazo.
- Nos permite tener distintas configuraciones, para un menú igual no tiene mucho sentido pero pensar a lo grande.
- Imaginaros un boss cualquiera y distintos modos de dificultad, podemos hacer que en fácil se configuren unos comandos y en difícil otros comandos más complejos. El Jefe no cambiará porque simplemente conoce la interfaz de los comandos que ejecuta y podremos extender su comportamiento respetando OCP.
- También puedes hacer que para Android se ejecuten unas acciones y para iOS otras distintas.
En Zenject por ejemplo, que es un gestor de dependencias, tienes un script de instalación donde das de alta todas tus acciones, das de alta las clases a las que se podrá acceder desde el contenedor y muchas otras cosas, y todo esto lo haces desde la configuración.
Lo que vamos a hacer es esto mismo, tendremos un archivo de configuración donde le indicaremos al menú que comandos son los que tiene que ejecutar.
Mejorando la implementación
Por un lado en el Installer
configuramos los menús, en este caso el Menu1
queremos que haga fade del Menu1
y muestre el Menu2
. Y el Menu2
queremos que haga fade de si mismo y cargue una escena.
public class Installer : MonoBehaviour
{
[SerializeField] private Menu1 _menu1;
[SerializeField] private Menu2 _menu2;
[SerializeField] private CanvasGroup _canvasGroup1;
[SerializeField] private CanvasGroup _canvasGroup2;
private void Awake()
{
var menu1Commands = new List<ICommand>
{
new CanvasFadeCommand(_canvasGroup1, 0, 0.5f),
new CanvasFadeCommand(_canvasGroup2, 1, 0.5f)
};
_menu1.Configure(menu1Commands);
var menu2Commands = new List<ICommand>
{
new CanvasFadeCommand(_canvasGroup2, 0, 0.5f),
new LoadSceneCommand("Game")
};
_menu2.Configure(menu2Commands);
}
}
public class Menu1 : MonoBehaviour
{
[SerializeField] private Button _showMenu2Button;
private List<ICommand> _showNextMenuCommands;
private void Awake()
{
_showMenu2Button.onClick.AddListener(ShowNextMenu);
}
private void ShowNextMenu()
{
foreach (var command in _showNextMenuCommands)
{
CommandQueue.Instance.AddCommand(command);
}
}
public void Configure(List<ICommand> showNextMenuCommands)
{
_showNextMenuCommands = showNextMenuCommands;
}
}
Ahora el menú solo se tendrá que preocupar de ejecutar los comandos que tenga configurados y no le importará lo que hagan. Si queremos ampliar el comportamiento solo tenemos que añadir más comandos a la lista y el menú no se verá afectado.
public class Menu2 : MonoBehaviour
{
[SerializeField] private Button _loadNextSceneButton;
private List<ICommand> _loadNextSceneCommands;
private void Awake()
{
_loadNextSceneButton.onClick.AddListener(LoadNextScene);
}
private void LoadNextScene()
{
foreach (var command in _loadNextSceneCommands)
{
CommandQueue.Instance.AddCommand(command);
}
}
public void Configure(List<ICommand> loadNextSceneCommands)
{
_loadNextSceneCommands = loadNextSceneCommands;
}
}
Conclusiones
Aplicando el patrón Command hemos conseguido encapsular y reciclar una lógica que sabemos qué repetiremos a lo largo de nuestro juego.
También hemos abstraído los detalles de esta lógica a los consumidores de forma que no conocen ni lo que van a ejecutar, con lo que podremos extenderlos sin tener que modificar a los consumidores, y respetando así el principio Open-Close de SOLID.
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)