(Global) Event Bus in Godot
Chi di noi programma videogiochi sa che una delle cose più intricate nell’arte è la comunicazione tra oggetti e/o sistemi, e che il santo graal è raggiungere il famoso “codice disaccoppiato”. In questa lotta, uno dei nostri principali alleati è il pattern observer, un pattern comportamentale che ci permette di inviare messaggi da un oggetto e solo chi è iscritto per ricevere quel messaggio lo fa e si comporta come desidera in risposta a questo “evento”. Se ci sono 0 oggetti iscritti nella scena, non succede nulla (il messaggio viene inviato, nessuno lo riceve, e nessun problema), e se ci sono iscritti, ma il mittente non c’è nemmeno (il messaggio non viene mai inviato, tutto rimane uguale). Molti problemi di dipendenza/accoppiamento vengono evitati.
Tuttavia, questo pattern, così com’è, ha un inconveniente: dobbiamo “connettere” esplicitamente il mittente con il ricevitore/i. Questo potrebbe non essere un problema finché non ci troviamo nello scenario di cosa faccio se devo connettere 2 nodi che sono “lontani” l’uno dall’altro nella gerarchia della scena (qui parlo specificamente di Godot e del suo gergo, in ogni motore con il suo linguaggio avrà i suoi problemi).
Una buona soluzione a questo problema è estendere questo pattern in modo che tutto passi attraverso un intermediario, un “bus”. Cioè, gli eventi saranno definiti in questo bus, che sarà un Autoload/Singleton, in modo che qualsiasi oggetto possa accedere alla classe e dirle di emettere il suo evento, proprio come qualsiasi altro oggetto può accedere alla classe e iscriversi a un evento. Qualcosa come Classe EventBus —> Dove sono definiti tutti gli eventi Classe A —> accede a EventBus per emettere un evento X Classe B —> accede a EventBus per iscriversi a un evento X
A e B non si conoscono, EventBus non conosce A o B, ma entrambi possono emettere e iscriversi ai segnali in modo tale da essere “comunicati” tra loro.
Vediamo in pratica:
#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("##############")
Così semplice che sembra difficile da credere, quando implementato, tutto il potere che dà.
Ora allora… È perfetto? Certo che no, come tutte le cose, c’è un compromesso.
Il primo e più ovvio problema è che perdiamo rapidamente la traccia mentale di “cosa è connesso a cosa”, questo può portare a situazioni in cui introduciamo persino bug perché emettiamo il segnale connesso al bus prima che esista l’oggetto con la classe che si iscrive ad esso. Un altro problema, intrinseco al sistema di segnali di Godot, è che siamo abbastanza “scoperti” riguardo al passaggio degli argomenti del segnale. Nell’esempio che ti ho dato, puoi provare a emettere il segnale senza un valore, o connetterlo al metodo target senza dichiararlo con il parametro corrispondente.
Infine (e forse il peggiore) è che è un ottimo pattern per trasformarsi in un “martello d’oro” e usare in situazioni che sono completamente prive di senso (come, ad esempio, per inviare un segnale da un nodo B a un nodo A dove B è un figlio gerarchico di detto A, mi è successo).
L’unico parzialmente controllabile è il 2°, qui il codice è leggermente modificato per poter almeno assicurarci di non fallire negli argomenti che inviamo (nella dichiarazione del metodo target rimaniamo uguali)
#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("#############")
Come puoi vedere, quello che facciamo è creare un evento all’interno del bus che è quello che emette effettivamente il segnale. Dobbiamo passare l’argomento corrispondente a questo evento, il che riduce un po’ il margine di errore.
Questo stesso pattern è facilmente replicabile in motori come Unity e Unreal (beh, non tanto quest’ultimo, devi navigare nella sintassi dell’API C++ di Unreal, ma puoi). Concettualmente, fai esattamente la stessa cosa, uno script Singleton intermediario che contiene i segnali e, d’altra parte, script che accedono per emettere e altri che accedono per iscriversi, e in quest’ultimo devi dichiarare/implementare e connetterti ai metodi target.
Come sempre e con tutti i design pattern: nessuno è perfetto, tutti devono essere implementati rispondendo a una necessità (e non forzare il pattern solo perché) e in tutto c’è, almeno, un compromesso.