Autómata finito en C# para Unity3D

Este artículo es una traducción del que escribimos originalmente en Inglés.

Los Autómatas Finitos o  Máquinas de Estado Finito (FSMs en sus siglas en Inglés) son bastante útiles en contextos muy diversos, como probablemente ya sabrás si estás leyendo ésto. Desde el menú al comportamiento complejo de las entidades de un juego, una buena FSM puede mejorar la legibilidad de tu código y simplificar el diseño. O convertirlo en un infierno de código espaguetti, dependiendo de cómo se implemente…

Hace un tiempo dí con Programming Game AI by Example, de Mat Buckland, donde aparece no solo una magnífica explicación de las FSMs, sino también cómo implementarlas en C++. También contiene una excelente introducción a los steering behaviours (cosa que nunca he tenido muy clara cómo traducir), a la búsqueda de caminos y a un par de cosas más. Si te interesa la Inteligencia Artificial desde luego es un libroque vale la pena leer.

Decidí portar su FSM a C# y compartirlo, esperando que alguien encuentre una buena manera de mejorarlo. Esta versión no incluye un sistema de paso de mensajes, aunque podría añadírsele en el futuro. Y después de algunas críticas en Reddit sobre la herencia y las clases, probé a implementarlas usando corrutinas – ya traduciré ese artículo más adelante.

Para empezar, definiremos  un Estado Finito (Finite State) usando precisamente la razón por la que pasé del JavaScript (UnityScript, ActionScript o como quieras llamarlo) al C#: Genéricos (Generics). De esta forma un Estado Finito será una clase genérica y abstracta con solo tres métodos:

File: FSMState.cs

abstract public class FSMState     {
  abstract public void Enter (T entity);
  abstract public void Execute (T entity);
  abstract public void Exit(T entity);
}

Supongo que también podría ser una interfaz, tal vez lo cambie en una versión futura. T apuntará al dueño del Autómata Finito, como veremos más adelante.

Luego definimos al Autómata Finito, que también será una clase genérica:

File: FiniteStateMachine.cs

public class FiniteStateMachine   {
  private T Owner;
  private FSMState CurrentState;
  private FSMState PreviousState;
  private FSMState GlobalState;

  public void Awake() {
    CurrentState = null;
    PreviousState = null;
    GlobalState = null;
  }

  public void Configure(T owner, FSMState InitialState) {
    Owner = owner;
    ChangeState(InitialState);
  }

  public void  Update() {
    if (GlobalState != null)  GlobalState.Execute(Owner);
    if (CurrentState != null) CurrentState.Execute(Owner);
  }

  public void  ChangeState(FSMState NewState) {
    PreviousState = CurrentState;
    if (CurrentState != null)
      CurrentState.Exit(Owner);
      CurrentState = NewState;
      if (CurrentState != null)
        CurrentState.Enter(Owner);
  }

  public void  RevertToPreviousState() {
    if (PreviousState != null)
      ChangeState(PreviousState);
  }
};

Básicamente permite configurarlo (estableciendo un estado inicial y a su dueño), actualizar el estado global y el actual (las entidades del juego podrán tener ambos), cambiar el estado actual y volver al estado anterior cuando sea necesario.

Para poder usarlo es necesario definir estados específicos. Como para ello necesitamos un contexto, usaremos el proyecto de Mat, West World, simplificándolo a dos estados. En este West World vive un minero cuyo único deseo consiste en acumular oro. En nuestra versión simplificada, trabaja en la mina hasta que encuentra dos pepitas. Entonces irá al banco a depositar el oro en la caja fuerte, y luego volverá a la mina. Pobre tipo. En la versión original también le iba entrando sed después de cada acción, y se cansaba, así que acababa visitando el pub local y se iba a dormir cuando lo necesitaba. Pero la iteración banco-mina será suficiente para mostrar cómo funciona esta máquina de estados.

Vamos a definir primero al Minero (Miner), que contiene su propio Autómata Finito y una serie de métodos que son bastante sencillos de comprender, así como una enumeración de posibles localizaciones:

File: Miner.cs

using UnityEngine;

public enum Locations { goldmine, bar, bank, home };

public class Miner : MonoBehaviour {

  private FiniteStateMachine FSM;

  public Locations  Location = Locations.goldmine;
  public int           GoldCarried = 0;
  public int           MoneyInBank  = 0;
  public int           Thirst = 0;
  public int           Fatigue = 0;

  public void Awake() {
    Debug.Log("Miner awakes...");
    FSM = new FiniteStateMachine();
    FSM.Configure(this, EnterMineAndDigForNuggets.Instance);
  }

  public void ChangeState(FSMState e) {
    FSM.ChangeState(e);
  }

  public void Update() {
    Thirst++;
    FSM.Update();
  }

  public void ChangeLocation(Locations l) {
    Location = l;
  }

  public void AddToGoldCarried(int amount) {
    GoldCarried += amount;
  }

  public void AddToMoneyInBank(int amount ) {
    MoneyInBank += amount;
    GoldCarried = 0;
  }

  public bool RichEnough() {
    return false;
  }

  public bool PocketsFull() {
    bool full = GoldCarried ==  2 ? true : false;
    return full;
  }

  public bool Thirsty() {
    bool thirsty = Thirst == 10 ? true : false;
    return thirsty;
  }

  public void IncreaseFatigue() {
    Fatigue++;
  }
}

Cuando un minero entra en Awake (puede ser asignado a un GameObject puesto que hereda de MonoBehaviour), crea su propia FSM con él mismo como dueño y una instancia del estado EnterMineAndDigForNuggets (ve a la mina a buscar pepitas)

Buckland define cada estado con un patrón Singleton, pero podríamos crear un nuevo estado cada vez o mantener instancias de cada estado en la clase Miner. Seguiremos el método de Buckland, pero debería ser fácil cambiar a un sistema basado en instancias.

Vamos a definir los dos estados que necesita el minero:

File: EnterMineAndDigForNugget.cs

using UnityEngine;

public sealed class EnterMineAndDigForNuggets :  FSMState {

  static readonly EnterMineAndDigForNuggets instance =
    new EnterMineAndDigForNuggets();
  public static EnterMineAndDigForNuggets Instance {
    get {
      return instance;
    }
  }
  static EnterMineAndDigForNuggets() { }
  private EnterMineAndDigForNuggets() { }

  public override void Enter (Miner m) {
    if (m.Location != Locations.goldmine) {
      Debug.Log("Entering the mine...");
      m.ChangeLocation(Locations.goldmine);
    }
  }

  public override void Execute (Miner m) {
    m.AddToGoldCarried(1);
    Debug.Log("Picking ap nugget and that's..." +
      m.GoldCarried);
    m.IncreaseFatigue();
    if (m.PocketsFull())
      m.ChangeState(VisitBankAndDepositGold.Instance);
  }

  public override void Exit(Miner m) {
    Debug.Log("Leaving the mine with my pockets full...");
  }
}

y

File: VisitBankAndDepositGold.cs

using UnityEngine;

public sealed class VisitBankAndDepositGold :  FSMState {

  static readonly VisitBankAndDepositGold instance = new
    VisitBankAndDepositGold();
  public static VisitBankAndDepositGold Instance {
    get {
      return instance;
    }
  }
  static VisitBankAndDepositGold() { }
  private VisitBankAndDepositGold() { }

  public override void Enter (Miner m) {
    if (m.Location != Locations.bank) {
      Debug.Log("Entering the bank...");
      m.ChangeLocation(Locations.bank);
    }
  }

  public override void Execute (Miner m) {
    Debug.Log("Feeding The System with MY gold... " +
      m.MoneyInBank);
    m.AddToMoneyInBank(m.GoldCarried);
    m.ChangeState(EnterMineAndDigForNuggets.Instance);
  }

  public override void Exit(Miner m) {
    Debug.Log("Leaving the bank...");
  }
}

que no deberían ser muy difíciles de seguir. El primer bloque en ambas clases es usado para crear el patrón Singleton de forma que sea seguro de usar con threads (al menos según la solución encontrada en StackOverflow…). Luego, cada estado sustituye tres métodos de la clase base: Enter, que se llama cuando comienza el estado; Execute, que se llama en cada iteración (luego explico un poco más); y Exit, que se llama al finalizar el estado. Como saben quién es su dueño (el minero), pueden hacer uso de sus métodos públicos para alterar sus propiedades.

Así que cuando el minero Baja A la Mina a Buscar Pepitas (EnterMineAndDigForNuggets), primero cambia su localización en el método Enter. Luego coge una pepita y se cansa un poco. Si tiene dos pepitas cambia su estado a VisitBankAndDepositGold. Cuando este estado se ejecuta, el minero guarda todo el oro que acarreaba en su cuenta bancaria y vuelve a su estado EnterMineAndDigForNuggets. Todos los médos usan un bonito Debug.Log para mostrar por la consola de Unity qué demonios está ocurriendo.

Muy posiblemente todo esto pueda ser mejorado usando corrutinas, y hay situaciones que pueden requerir toquetear alguna cosa. Por ejemplo, podríamos necesitar una pausa entre los métodos Enter, Execute y Exit. Y aunque la queja habitual de este sistema es que requiere un montón de clases (una para cada estado), es bastante elegante. ¡Deja las cosas sencillas y fáciles de depurar!

Aquí tienen un UnityPackage que pueden importar directamente a un proyecto de Unity3D. Basta con asignar Miner.cs a un GameObject vacío, ejecutar el proyecto ¡y le verán yendo a trabajar y ahorrando oro para sus nietos a través de mensajes de consola increíblemente aburridos! Si seleccionan el GameObject verán en el Inspector cómo se alteran sus valores de ahorros, cansancio y sed.

One thought on “Autómata finito en C# para Unity3D

  1. hola sobre el tutorial de máquinas de estado me encantó pero el enlace al paquete no esta funcional, seria posible que me lo pasaras por mail?. Un saludo.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

About

This is PlayMedusa's game devlog: sketches, artwork, news, tutorials... Know more about us and our work in our MAIN SITE!

We create games mostly because we enjoy doing so. We hope you also enjoy playing them! Check them out at itchio Some can be played on the browser!

Este sitio web utiliza cookies para que usted tenga la mejor experiencia de usuario. Si continúa navegando está dando su consentimiento para la aceptación de las mencionadas cookies y la aceptación de nuestra política de cookies, pinche el enlace para mayor información.plugin cookies

ACEPTAR
Aviso de cookies