Decoupling Using Signals
For the game jam version, we’ve ended up using global methods to control the behaviour of components that aren’t directly linked. Things like U.I., Audio, and other components each had a global controller, and when you wanted to change the U.I. or play audio, you called a method on that controller.
While this is fine for a small game, that pattern forced each object to not only remember to call a controller but also to know how the internals of that controller worked. Is it safe to call that method multiple times? Which parameters does this method need? What happens if I pass more than one parameter? what if I need to send some extra data? what if I don’t have any extra data to send?
Each time a developer needed to add a new object or a new behaviour to the game, they would have to read the implementation for each controller and answer all those questions again.
A few examples:
1
2
3
4
5
6
# Reading a note
Globals.set_active_note(note_pages)
Globals.hud_controller.set_hud(HudController.HudState.READING_NOTE)
# Entering a focus node (zoom in into a panel)
Globals.hud_controller.set_hud(HudController.HudState.ZOOMED_IN)
Introducing Signal Bus pattern to the HUD
One of the methods commonly used in Godot to communicate with objects that sit higher on the node tree is to use signals. The standard convention in the forums is usually “call down, signal up”. In our case, adding signals to control the U.I. instead of directly calling the method simplified the code by centralising all the U.I. behaviour in a single place. The objects in the game don’t even need to know that a U.I. exists, all they need to know is how to emit a signal when they are interacted with.
[!info] Check the link [[Signals and SignalBus in Godot]] to understand how the logic was implemented.
A signal bus is a single place that contains all the signals emitted. Because it is globally scoped, every object in the game has access to it automatically.
Now, the only place that the decides which HUD is set is the hud_controller. We can now easily inspect which signals will trigger a change in the U.I.:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func _ready() -> void:
# Connect signals
SignalBus.note_opened.connect(_on_note_inspect)
SignalBus.focus_node_entered.connect(_on_zoom_in)
SignalBus.position_node_entered.connect(_on_entedered_position_node)
SignalBus.binder_opened.connect(_on_open_binder)
...
func revert_hud_state():
set_hud(previous_hud_state)
func _on_note_inspect(note_data) -> void:
Globals.set_active_note(note_data)
self.set_hud(HudController.HudState.READING_NOTE)
func _on_zoom_in() -> void:
self.set_hud(HudController.HudState.ZOOMED_IN)
func _on_entedered_position_node() -> void:
self.set_hud(HudController.HudState.EXPLORE)
func _on_open_binder() -> void:
Globals.open_binder()
self.set_hud(HudController.HudState.READING_NOTE)
And instead of directly calling the controller, each object will now emit a signal. Using the same examples as before:
1
2
3
4
5
# Reading a note
SignalBus.note_opened.emit(note_pages)
# Entering a focus node (zoom in into a panel)
SignalBus.focus_node_entered.emit()