Uso de Eventos y Delegados de C# en Unity

Hoy por primera vez me voy a atrever con un artículo técnico. En concreto escribiré sobre una de las características más interesantes del lenguaje de programación C# aplicada a la programación con Unity: Eventos y Delegados.

Delegados

Técnicamente un delegado no es más que un objeto que sabe cómo llamar a un método. Es el equivalente a un puntero a función de C o C++. Como el puntero, debe cumplir con la firma del método: parámetros y tipo del valor de retorno.

Para declarar un delegado se utiliza la palabra clave delegate (sorpresa) de la siguiente forma:

delegate Vector2 UserInput ();

¿Qué significa esto? Significa que hemos declarado un delegado llamado UserInput que será compatible con cualquier método que devuelva un Vector2 y no tenga parámetros.

Un ejemplo de uso de delegados es este:

delegate Vector2 UserInputDelegate();
private UserInputDelegate UserInput;

void Start(){
    if(SystemInfo.deviceType == DeviceType.Handheld){
        UserInput = TouchInput;
    } else  if(SystemInfo.deviceType == DeviceType.Desktop){
        UserInput = MouseInput;
    } else {
        Debug.Log("Unsupported device input!"); // Por ejemplo.
    }
}

private Vector2 MouseInput(){
    // Devuelve la posición del cursor en pantalla
}

private Vector2 TouchInput(){
    // Devuelve la posición del dedo en pantalla
}

void Update(){
    // Obtiene la entrada del usuario, independientemente del dispositivo
    Vector2 input = UserInput();
}

 

¿Qué ocurre aquí? En el método Start, identificamos en qué tipo de dispositivo estamos funcionando y en función de ello asignamos el método que debe llamar nuestro delegado. Así en el método Update no tenemos que preocuparnos por el tipo de plataforma. Por supuesto este ejemplo solo es una gran simplificación de cómo gestionar la entrada desde distintos tipos de dispositivos…

Los delegados además pueden “encadenarse” en lo que se llaman delegados multicast. Esto permite invocar una serie no limitada de métodos desde una sola llamada al delegado:

delegate void UpdateDelegate (float delta);
private UpdateDelegate UpdateMethods;

private void UpdateInput(float dt){ // Cosas del input }
private void UpdatePhysics(float dt){ // Cosas de física }
private void UpdateEffects(float dt){ // Cosas de los efectos mismo }

void Start(){
    UpdateMethods += UpdateInput;
    UpdateMethods += UpdatePhysics;
    UpdateMethods += UpdateEffects;
}

void Update(){
    // Invoca a UpdateInput(), UpdatePhysics() y UpdateEffects(), en ese orden.
    UpdateMethods();
}

¿A que mola? Con una sola llamada hemos invocado 3 métodos con la misma firma en el orden en que se han añadido. Hay que tener en cuenta que si los métodos devolvieran algo solo se obtendría el último valor devuelto, perdiéndose los demás.

Si en algún momento quisiéramos eliminar un método del delegado broadcast, sería tan simple como:

UpdateMethods -= UpdateInput;

Existen varios aspectos avanzados de los delegados que no voy a cubrir aquí como los delegados con tipos genéricos y los delegados de tipo Func y Action.

Eventos

Al usar delegados uno se pregunta… ¿Sería posible suscribir un método del objeto A a un delegado del objeto B? ¿Podría llamar a un método en todos los objetos de mi juego cuando ocurra un evento determinado?

La respuesta a estas preguntas se denomina event, y no es más que la formalización de un patrón emisor-suscriptores.  ¿Para qué sirve esto?

Imaginamos un juego de naves estilo Space Invaders que incluye un super arma que daña a todos los enemigos en pantalla cuando se lanza. El código que podríamos escribir es similar al siguiente:

void Update(){
    if(Input.GetButtonDown("Fire2")){
        // Daña a todos los enemigos en pantalla
        DamageEveryone();
    }
}

void DamageEveryone(){
    foreach(GameObject o: GameObject.FindGameObjectsWithTag("Enemy")){
        o.SendMessage("Damaged", 10)
        // O también:
        o.GetComponent<EnemyController>().Damage(10);
    }
}

Hay algunos problemas con esta aproximación:

  1. Iteramos sobre todos los objetos siempre, con el gasto de memoria y tiempo (solucionable con una caché de objetos, pero eso para otro día).
  2. Envía mensajes a cada objeto, que es una operación relativamente lenta.
  3. Exige que que los enemigos tengan el componente EnemyController.
  4. Si el enemigo está fuera de pantalla, también se ve afectado.

Una solución sencilla y elegante pasa por el uso de eventos. En este caso concreto, crearíamos el evento “MegaBombReleased” en el controlador del juego y cada objeto que quisiera ser notificado del evento, se suscribiría a él con el delegado correspondiente:

public delegate void MegaBombDelegate (float damage);

public class GameController: MonoBehavior{
    public event MegaBombDelegate MegaBombReleased;

    void Update(){
        if(Input.GetButtonDown("Fire2")){
            // Invoca al delegado de todo objeto suscrito al evento.
            MegaBombReleased();
        }
    }

// Enemigo fuerte
public class PowerfulEnemy{
    void Start(){
        GameController.Instance.MegaBombReleased += GotDamaged;
    }

    // Ojo, la firma debe ser compatible con el delegado!
    void GotDamaged (float damage){
        shields -= damage // Por ejemplo
    }
}

// Enemigo débil
public class WeakEnemy{
    void Start(){
        GameController.Instance.MegaBombReleased += GotDamaged;
    }
    void GotDamaged (float damage){
        DestroyAndDieByDeath(); // Nos destruyen directamente
    }
}

(Por supuesto este código está muy simplificado y no contempla por ejemplo el uso de clases abstractas en las clases de los enemigos)

En este ejemplo simple, cuando los diferentes enemigos se crean se suscriben al evento MegaBombReleased, de forma que cada vez que el jugador pulse el botón “Fire2” se llamara a ese método. Esto ocurre en todos los objetos suscritos al evento por lo que solucionamos los problemas 1, 2 y 3 de la lista anterior.

¿Pero qué hay del problema 4? Cómo podríamos hacer que este código se ejecutara únicamente cuando el enemigo está en pantalla? Para ello podemos echar mano de los métodos MonoBehavior.OnBecameVisible y MonoBehavior.OnBecameInvisible:

class Enemy: MonoBehavior{
    void OnBecameVisible(){
        GameController.Instance.MegaBombReleased += GotDamaged
    }

    void OnBecameInvisible(){
        GameController.Instance.MegaBombReleased -= GotDamaged
    }
}

De esta forma la mega bomba solo afectará al enemigo cuando sea visible en pantalla.

Por cierto que una buena práctica es desuscribirse de cualquier evento previamente suscrito una vez que el objeto ha sido destruido usando MonoBehavior.OnDestroy:

    void OnDestroy(){
          GameController.Instance.MegaBombReleased -= GotDamaged
    }

Si en el momento de la destrucción del objecto no estaba suscrito al evento, esta operación no causará ningún error.

Y hasta aquí llego por hoy. Espero haber sido capaz de transmitir el interés que tienen eventos y delegados para la programación con Unity. Si tenéis cualquier duda o inquietud, no dudéis en transmitírmela mediante los comentarios.

9 thoughts on “Uso de Eventos y Delegados de C# en Unity

  1. Zalo

    Muy interesante el artículo, David. Eventos y delegados es algo de C# que me encanta y echo de menos en C++ (aunque se puede implementar).

    Un tema interesante que se ha quedado fuera es el de los System.Action ya que sorprendentemente se pueden suscribir a un evento dando mucho juego para algunas cosas. C# es un magnífico lenguage de programación

    Reply
    1. David Erosa Post author

      La verdad es que es este tipo de características las que me han hecho abrazar C# a mi también. Los System.Action los he referenciado como “cosa avanzada” para no meter demasiado, pero es cierto que son algo muy interesante. ¿Quizá para un post de invitado? 😉

      Reply
  2. Miguel

    Gracias por el post. Me va a ser muy útil para nuestros juegos en Unity ( de momento somos dos programadores ).

    Espero que podais seguir profundizando con post igualmente interesantes.

    Saludos desde Madrid !!!

    Reply
  3. Mariano Rajoy

    Lamento ser yo el que lo diga, pero se nota que es el primer articulo tecnico que escribes porque no me he enterado de nada, a pesar de releerlo varias veces.
    Muy confuso y muy complicado, a pesar de tener una redacion y maquetado bastante agradable y elegante.
    No he leido otros articulos tecnicos tuyos, pero supongo que habras ido mejorando con el tiempo.
    Animo y a seguir mejorando.

    Reply
  4. Jose Moyano

    Me ha gustado bastante el artículo, seguiré atento a más.
    Una única duda (que puede que este equivocado yo), en el MegaBombReleased(); del Update ¿no se debe pasar un parámetro float en el parentesis?

    Gracias!

    Reply

Leave a Reply to Mariano Rajoy Cancel reply

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