Ya había mostrado en otro post cómo es el tema de las Action, Func, Delegates y Events. En este post vamos a volver a usarlos para hacer algo así como un gestor de eventos. Un objeto al cuál le podemos decir que nos avise cuando pasen determinadas cosas en nuestro juego. También lo podemos usar para comunicar objetos entre sí por diferentes motivos.

CONCEPTO GENERAL

Muchas veces tenemos la necesidad de saber cuándo pasan determinadas cosas en nuestro juego, como que el usuario ganó o perdió, pasó de nivel, agarró determinadas cosas, etc. También surge a veces la necesidad de solamente comunicar objetos entre sí para evitar determinadas Race Conditions, o sea, que un suceso que depende de otro no ocurra hasta que este último ocurra. Un ejemplo podría ser al cargar una imagen. La secuencia debería ser:

  • Comenzar la carga.
  • Esperar a que la carga termine.
  • Mostrar la imagen.

Si los sucesos no ocurren en ese orden podríamos tener un error, por ejemplo, al intentar rotar la imagen que TODAVÍA no fue cargada. En otras palabras, este sistema de eventos serviría para evitar que algo en el juego suceda antes que otra cosa.

FUNCIONAMIENTO

El funcionamiento es como si fuera una persona que escucha determinados eventos, y los reparte a otras personas que estén interesadas en ellos. Cada persona o entidad se suscribe a este sistema pidiendo ser avisado cuando suceda DETERMINADA cosa en el juego. El sistema se debe encargar de avisar a cada uno, una vez que el evento ocurra. Esto no significa que este sistema está todo el tiempo escuchando todos los sucesos del juego. De hecho, no escucha ninguno. Cuando el suceso ocurre, la entidad responsable de que el suceso ocurra debería avisarle al sistema que algo pasó, algo que otras entidades podrían necesitar, y entonces este sistema avisa a todas las entidades subscriptas sobre dicho EVENTO. Es como cuando ustedes se suscriben al canal de alguien. Cuando ese alguien sube un video o un post nuevo, esa misma acción de subirlo le avisa al sistema que esto ocurrió, para que ese sistema notifique a todos los usuarios subscriptos sobre este suceso. La idea acá es replicar este sistema. Por supuesto no es algo que yo haya inventado. Es un patrón de diseño que ya existe hace mucho tiempo y es muy usado en el desarrollo de software.  

ESTRUCTURA

Vamos a empezar a programarlo. Voy a hacerle un funcionamiento básico y ustedes extiéndanlo como mejor les parezca. Creemos entonces nuestro script, sólo con las funciones y variables básicas, y luego vamos agregando conforme vayamos necesitando.

Copy to Clipboard

En base a este esquema, la idea sería que los “suscriptores” puedan escuchar los sucesos o dejar de hacerlo con los métodos AddListener() y RemoveListener(), y que el juego o aplicación pueda “avisar” a los suscriptores que algo pasó por medio del método Dispatch(). Los dos primeros métodos reciben un parámetro, imaginan para qué es? La idea es que ese Action representaría el método que se va a ejecutar cuando un suceso ocurra. Pero así como está, nuestro EventSystem no puede notificar nada en particular, ya que sólo estoy recibiendo el parámetro para avisar, pero no un parámetro que especifique qué acción escuchar. Y esto es importante, ya que no queremos avisar a los suscriptores de todas las cosas que pasan en el juego, sino de las cosas a las que ellos se hayan suscrito. Imaginen que si a ustedes les interesa nada más recibir novedades de un blog cuando se suban cosas nuevas sobre Unity y sobre Videojuegos y ese blog les manda notificaciones de cada cosa que pasa, como un usuario nuevo, un post nuevo de lo que sea, etc. Ustedes van a querer sólo recibir una noticia o aviso cuando pasen las cosas que les interesan o les son relevantes a ustedes. Así que vamos a hacerle una modificación a ese script, basado en esto que acabamos de decir:

Copy to Clipboard

Con esto ya podemos registrar diferentes “escuchadores” para los diferentes tipos de eventos. Estos escuchadores básicamente serán métodos a ejecutar al momento de que ocurra algo específico. Para esto debemos guardar estos escuchadores, para que cuando el evento ocurra y lo disparemos, podamos notificar a cada escuchador. O sea, el método Dispatch deberá llamar a estos escuchadores que guardemos con el método AddEventListener. La pregunta mágica sería: Dónde guardamos esta variable que nos van a pasar por parámetro? Pues bien… A simple vista podríamos hacer una lista o array de Action, pero no podríamos distinguirlo según el tipo del parámetro Action. Para resolver este problema, vamos a usar una variable un poco más compleja.

El tipo de dato que vamos a usar es un Dictionary. Pero como sabemos, este tipo de dato nos pide un tipo de dato para la clave y otro para el valor. Vamos a declararlo de la siguiente manera:

Copy to Clipboard

 

Vamos a usar Type para la clave y HashSet para el valor. De esta manera, podemos guardar una lista de “escuchadores” para todos los eventos de un determinado tipo. Vamos a usar HashSet porque nos permite guardar una lista de elementos que no se pueden repetir. Es decir, que si agregamos dos veces el mismo elemento, este no se repetirá, ya que es guardado en el mismo slot.

Para agregar elementos, lo haremos de la siguiente manera:

Copy to Clipboard

 

Básicamente lo primero que hacemos es obtener el tipo del valor T para usarlo como clave. Luego, consultamos si ya fue agregada alguna lista con acciones. Si fue agregada, entonces agregamos el callback a ESA lista, y sino, generamos una nueva lista, y metemos dentro nuestro listener. Luego, agregamos esa lista, con la key (que sería nuestro tipo de dato obtenido), al diccionario que almacena a todos nuestros escuchadores, separados por tipo.

Algo adicional que podemos hacer para filtrar un poco más, es crear una interfaz para que todos los eventos que vamos a disparar la utilicen. De esta manera, podríamos hacer que sólo se puedan agregar eventos que implementen nuestra interfaz, y también agregar todas las firmas necesarias para cada uno de nuestros eventos. Vamos a crear la interfaz.

Copy to Clipboard

Ahora podemos trabajar directamente con nuestra interfaz. Para agregar el filtro, vamos al método AddListener y agregamos lo siguiente.

Copy to Clipboard

Ahora sólo podremos agregar escuchadores para todos los tipos de dato ICustomEvent. Si querémos registrar una clase en particular, esta deberá implementar nuestra interfaz.

Vamos ahora a completar el método para remover un listener.

Copy to Clipboard

Con esto ya tenemos una forma de quitar los listeners que vamos registrando.

NOTA:

La documentación de C# dice que si el elemento especificado no se encuentra, no se lanza ninguna exception. Así que no hace falta preocuparnos por eso. :D.

Lo siguiente sería programar el cuerpo del método Dispatch. Este debería encargarse de disparar el evento, y notificar a todos los que estén escuchando dicho tipo de evento. Para hacerlo, deberíamos ejecutar todos los métodos que estén almacenados dentro de la clave TIPO DE EVENTO. Nos quedaría de la siguiente manera.

Copy to Clipboard

Es similar al método AddListener, ya que buscamos la key primero. Luego recorremos el HashSet de elementos, con todos los listeners dentro, y los invocamos uno por uno. Si no entra al if, quiere decir que no había nadie escuchando el tipo de evento. Si no entra al foreach, quiere decir que no quedan escuchadores para el tipo de evento. Este segundo caso no debería darse nunca, y deberíamos encargarnos de eso. Para ello, lo que tenemos que hacer es remover la key, una vez que el tipo de eventos no tenga más escuchadores. Vamos a hacer ese ajuste en el método RemoveListener.

Copy to Clipboard

Y con esto ya tenemos un disparador de eventos básico. Prueben agregar un listener, y disparar un evento y vean qué pasa. Dejo la prueba completa que hice en un proyecto de consola.

Copy to Clipboard

Por supuesto hay muchas más modificaciones que le podemos hacer a este disparador de eventos. Por ejemplo, una de ellas es la posibilidad de transportar el evento con determinados datos entre las distintas notificaciones a cada uno de los listeners. Para esto vamos a sobrecargar los métodos para que además de recibir Action reciban Action<T>, donde T sería ICustomEvent. Agregaríamos entonces los métodos de la siguiente manera:

Copy to Clipboard

Los primeros dos métodos son bastante explicativos en sí mismos. Pero… Por qué el método Dispatch ahora recibe un initializer? Esto es porque si vamos a mandar como parámetro a los listeners el evento, como si fuera un “paquete” que le entregamos con determinada información, necesitaremos crearlo. Entonces, podemos o bien dejar la inicialización al que va a disparar el evento, o bien podemos dejar que la inicialización la haga el disparador de eventos. Pero vamos primero a completar los métodos antes de entrar en este detalle.

Para eso, primero vamos a crear una variable donde guardar los listeners que vamos a registrar. Estos listeners van a ser actions que reciban un parámetro (que va a ser el evento a escuchar con determinada data), así que necesitamos que sea diferente a la anterior.

Copy to Clipboard

 

Como verán la declaración es casi idéntica, sólo que ahora ese Action es diferente: Action. Acá vamos a guardar todos los listeners que reciban como parámetro un evento específico. La declaración de los cuerpos de los métodos AddListener y RemoveListener (me refiero a sus sobrecargas), van a ser ligeramente idénticas, sólo que ahora apuntan a guardar todo en otra variable:

Copy to Clipboard

 

Como verán no hay demasiada diferencia, salvo por la línea 4. Qué es esto? Esto se llama Función Local. Es como cualquier otro método, pero se define dentro de otra función. Lo hacemos porque el espacio donde vamos a guardar el listener es de tipo Action<ICustomEvent> y nuestro listener es de tipo Action<T>. Si bien T vendría a ser de tipo ICustomEvent, o sea, que hereda y/o implementa ICustomEvent, Action<T> no hereda ni implementa Action<ICustomEvent>. O sea, si hiciéramos que X = Action<T> e Y = Action<ICustomEvent> ninguno de los dos hereda de ninguno, ni tienen relación alguna entre sí. Por lo tanto, no podemos guardar una variable de tipo Action<T> dentro de una variable de tipo Action<ICustomEvent>, por más que T herede de ICustomEvent. Pueden hacer la prueba si no me creen (es más, les diría que nunca me crean y siempre prueben todo por sus medios :D). Hagan las variables e intenten guardar una dentro de la otra, y van a ver el error de compilación que les tira C#. Entonces, lo que hacemos para solucionar ese “problemilla” es crear una función local en el medio, que ejecute correctamente el método como debe ser, y que se encargue de tratar a ese T como el ICustomAction que es.

El método Dispatch sí va a tener algunas diferencias más:

Copy to Clipboard

 

La primer cosa a notar es que primero llamamos al Dispatch anterior. Esto es porque un evento está ocurriendo y querémos notificar A TODOS los que lo estén escuchando, más allá de si estos quieren o no los datos del evento. O sea, el suceso ocurre y hay que notificarlo.

Por otro lado… En la línea 4 podemos ver que creamos una variable llamada eventData de tipo T y le asignamos lo que devuelva nuestro initializer. La idea de esto es que al hacer un dispatch, la clase encargada de disparar el evento también se encargue de completarlo con los datos que hagan falta. O sea, lo que vamos a querer hacer acá es lo siguiente:

Algo Ocurre => Creo el reporte con todos los datos necesarios => Lo envío.

Algo Ocurre sería el bloque de código donde vamos a disparar nuestro evento. Creo el reporte con todos los datos necesarios sería la parte donde entra nuestro initializer: Creamos una instancia del evento y lo cargamos con los datos que correspondan. Lo envío es la parte en la que llamamos a Dispatch.

Primero vamos a crear el evento, con datos adentro para transportar:

Copy to Clipboard

 

Vamos a ver cómo quedaría ahora el código:

Copy to Clipboard

 

Podemos directamente no crear la variable initializer y pasar directamente el Func para inicializar dentro de los parámetros. Eso ya queda a la elección de ustedes.

Y con esto tienen su propio EventSystem. La idea principal de pasar el evento por parámetros es, por ejemplo, si quisieran pasar el momento en el que alguien hace click sobre un objeto, probablemente les interese saber en qué objeto hicieron click. Pero si no les interesa, si sólo quieren saber, siguiendo el ejemplo, cuándo se hizo click, entonces no necesitan pasar el evento por parámetro.

Por otra lado, otra cosa que les recomendaría es que tengan un EventSystem global, o sea, para todos los eventos del juego en general, y otro que sea más específico. Por ejemplo, si tienen muchos objetos en pantalla, y cada objeto quiere saber cuándo fue clickeado para hacer “algo”, tener un EventSystem general va a desencadenar en que CADA VEZ que le hacen click a UNO de los objetos, TODOS se estarían enterando (ya que el evento se dispararía). Ahí lo que podemos hacer es mandar el evento con el parámetro de a qué objeto clickeamos, pero esto sólo sirve si hay UNA SOLA ENTIDAD queríendo saber a qué objeto clickeamos. Pero qué pasa si TODOS los cuadrados esperan ser clickeados para hacer algo? Para este caso en particular sería que cada objeto pueda disparar sus propios eventos. O sea, que cada objeto tenga su propio EventSystem, y nosotros nos suscribimos al EventSystem que nos interese. En este caso, cada objeto se podría suscribir a su propio EventSystem. Claro que esto depende de cómo piensen el sistema en sí mismo. Para algunos es mejor que haya un objeto escuchando y que ese objeto tome las decisiones. Pero todo depende de cómo lo piensen.

Les dejo, por último, el código final:

Copy to Clipboard