(Global) Event Bus en Godot

(Global) Event Bus en Godot


Patrones de diseño
programacion godot

Quienes programamos videojuegos sabemos que una de las cosas más intrincadas del arte es la comunicación entre objetos y/o sistemas, así también que el santo grial es alcanzar el famoso “código desacoplado”. En esta lucha uno de nuestros aliados principales es el patron de diseño observer, un patrón de comportamiento que nos permite enviar mensajes desde un objeto y que solo aquellos que estén suscriptos a recibir ese mensaje lo hagan y se comporten como quieran ante este “evento”. Si hay 0 objetos suscriptores en escena no pasa nada (el mensaje se envía, nadie lo recibe y sin problemas) y si hay suscriptores, pero no está el emisor tampoco (el mensaje nunca se emite, todo sigue igual). Se evitan muchos problemas de dependencias/acoplamiento.

Sin embargo, este patrón, así como así, tiene un inconveniente: necesitamos “conectar” de forma explícita a emisor con receptor/es. Esto puede no ser inconveniente hasta que nos encontramos con el escenario de ¿y qué hago si necesito conectar a 2 nodos que están “lejos” entre sí en la jerarquía de la escena (acá estoy hablando específicamente de Godot y con su jerga, en cada engine con su lenguaje tendrá sus cuestiones).

Una buena solución para este problema es ampliar este patrón de forma tal que todo pase por un intermediario, un “bus”. Es decir, los eventos estarán definidos en este bus, que será un Autoload/Singleton, de forma tal que cualquier objeto pueda acceder a la clase y decirle que emita su evento, así como cualquier otro puede acceder a la clase y suscribirse a un evento. Algo así como Clase EventBus —> Donde todos los eventos están definidos Clase A —> accede a EventBus para emitir un evento X Clase B —> accede a EventBus para suscribirse a un evento X

A y B no se conocen, EventBus no conoce ni a A ni a B, pero ambos pueden emitir y suscribirse a señales de forma tal de estar “comunicados” entre sí.

Veamos en la práctica:

#event_bus.gd

signal coin_consumed(value: int)
#class_a.gd

func foo() -> void:
    EventBus.emit.coin_consumed(100)
#class_b.gd

func _ready() -> void:
    EventBus.connect.coin_consumed(_on_coin_consumed)

func _on_coin_consumed(value: int) -> void:
    print("coin consumed")
    print(value)
    print("#############")

Tan sencillo que parece difícil de creer, cuando se lo implementa, todo el poder que da. Ahora bien… ¿Es perfecto? Claro que no, como todas las cosas, hay un trade-off. El primer problema más claro es que muy rápido se pierde el registro mental de “que está conectado a que”, esto puede llevar a situaciones donde incluso introducimos bugs porque emitimos la señal conectada al bus antes de que exista el objeto con la clase que se suscribe a la misma. Otro problema, inherente al sistema de señales de Godot, es que estamos bastante “desprotegidos” en cuanto al pasaje de argumentos de señales. En el ejemplo que les di, pueden probar emitiendo la señal sin un valor, o conectando al método objetivo sin declararlo con el parámetro correspondiente. Por último (y tal vez el peor) es que es un patrón muy bueno para convertirlo en un “martillo de oro” y usarlo en situaciones que carecen totalmente de sentido (como, por ejemplo, para enviar una señal desde un nodo B a un nodo A donde B es hijo jerárquico de dicho A, me ha pasado).

La única parcialmente controlable es la 2da, acá el código ligeramente modificado para al menos poder asegurarnos de que no fallamos en los argumentos que enviamos (en la declaración del método objetivo seguimos igual)

#event_bus.gd

signal coin_consumed(value: int)

func emit_coin_consumed(value: int) -> void:
    coin_consumed.emit(value)
#class_a.gd

func foo() -> void:
    EventBus.emit_coin_consumed(100)
#class_b.gd

func _ready() -> void:
    EventBus.connect.coin_consumed(_on_coin_consumed)

func _on_coin_consumed(value: int) -> void:
    print("coin consumed")
    print(value)
    print("#############")

Como pueden ver, lo que hacemos es crear dentro del bus un evento que es el que realmente se encarga de emitir la señal. A este evento hay que pasarle el argumento correspondiente, por lo cual achicamos un poco el margen de error.

Este mismo patrón es fácilmente replicable en motores como Unity y Unreal (bueno, este último no tanto, hay que surfear la sintaxis de la API Unreal C++, pero se puede). Conceptualmente, se hace exactamente lo mismo, un script intermediario Singleton que contiene las señales y, por otro lado, scripts que acceden para emitir y otros que acceden para suscribirse, y en estos últimos hay que declarar/implementar y conectar a los métodos objetivos.

Como siempre y con todos los patrones de diseños: ninguno es perfecto, todos deben implementarse respondiendo a una necesidad (y no forzar el patrón porque sí) y en todo hay, como mínimo, un trade-off.