Registering signals
Signals are a Godot mechanism to implement the Observer pattern. You can emit events, which are received by everyone who is subscribed ("connected") to the signal, decoupling sender and receiver. If you haven't worked with Godot signals before, you should definitely read the GDScript tutorial.
Table of contents
- The problem with GDScript signals
- Rust signals
- Connecting signals
- Emitting signals
- Accessing signals outside the class
- Advanced signal setups
- Conclusion
The problem with GDScript signals
You can define GDScript signals as follows, with optional parameter names and types:
signal damage_taken
signal damage_taken(amount)
signal damage_taken(amount: int)
However, the difference between the above declarations is purely informational (e.g. appears in class docs). Let's look at an example:
signal damage_taken(amount: int)
func log_damage():
print("damaged!")
func _ready():
damage_taken.connect(log_damage)
Note how log_damage()
has no parameters, yet you can connect it without warning, neither at parse time nor at runtime.
This problem isn't limited to connect()
; let's pass an argument of wrong type to emit()
:
signal damage_taken(amount: int)
func log_damage(amount): # now with parameter
print("damaged: ", amount)
func _ready():
damage_taken.connect(log_damage)
damage_taken.emit(true) # no int, no worries -> prints "damaged: true"
Again, GDScript happily passes through bool
, despite the signal declaring int
.
In GDScript, a signal
parameter list is not type-checked.
Mismatching connect()
or emit()
calls may or may not be caught at runtime, based on the handler function's own typing.
They are never caught at parse time.
While this seems like a minor issue in examples like the above, this becomes hard to track in bigger projects with many similar signals, especially once you start refactoring. A signal is designed to act as an API between the sender and receiver -- but there is no way to verify this interface contract, apart from a high level of manual discipline and testing.
Rust signals
godot-rust provides a type-safe and straightforward API to connect and emit signals, even though they are untyped in GDScript. You can rely on signatures and don't need to fear refactorings, as Rust will catch any mismatches at compile time.
In Rust, signals can be defined with the #[signal]
attribute inside a #[godot_api]
block.
Let's take again our class from earlier and declare a damage_taken
signal:
Signal syntax is close to #[func]
, but it needs a semicolon instead of a function body. Receivers (&self
, &mut self
) and return types
are not supported.
Generated code
As soon as you register at least one signal, godot-rust will implement the WithSignals
trait for your class.
This provides the signals()
method, which can now be accessed inside class methods.
signals()
returns a signal collection, i.e. a struct which exposes all signals as named methods:
The damage_taken()
method returns a custom-generated signal type (referred to as $Signal
in the snippet), whose API is tailored to the
signature of fn damage_taken(amount: i32)
. Each #[signal]
attribute generates a distinct signal type.
The signal type is implementation-defined. Besides the #[signal]
-specific custom API, it also implements Deref/DerefMut
with target
TypedSignal
, meaning you can additionally use all those methods on each signal type.
Connecting signals
godot-rust offers many ways to connect signals, depending on where the handler function is located.
Signal + handler on same object self
Connecting signals to methods of the same class is quite common. This is possible with the connect_self()
method, which simply takes the
method pointer as an argument:
Note how on_damage_taken
has no #[func]
attribute, and its surrounding impl block no #[godot_api]
proc-macro. Signal receivers are
regular Rust functions! You can completely hide them from Godot, and only make them accessible via signals.
Since connect_self()
's parameter here is essentially impl FnMut(&mut Self, i32)
, you can also pass a closure:
Handler on different object
If the handler function should run on an object other than self
, you can use connect_obj()
, which takes a &Gd<T>
as first argument:
Handler without object (associated/static function)
If the handler function does not need access to self
, simply use connect()
:
Emitting signals
We already saw that #[signal]
attributes generate a signal type with several methods: connect()
, connect_self()
and connect_obj()
.
This same signal type also provides an emit()
method, which you can use to trigger the signal:
Like connect*()
methods, emit()
is fully type-safe. You can only pass a single i32
. If you update your signal definition, e.g. to take a
bool
or enum
value for the type of damage, the compiler will catch all connect*
and emit
calls. You'll sleep well after refactorings.
The nice thing about emit()
is that it also comes with parameter names, as provided in the #[signal]
attribute. This lets IDEs provide
more context, e.g. show parameter inlay hints in emit()
calls.
In addition to the specific emit()
method, the TypedSignal
(deref target of the custom signal type) also provides a generic method
emit_tuple()
, which takes a tuple of all arguments, by value. This is rarely needed, but can be useful in situations where you want to pass
multiple arguments as a "bundle". Just for completeness, the above call is equivalent to:
Accessing signals outside the class
As your game grows in interactions, you may want to configure or emit signals not just within impl Monster
blocks, but also from other parts
of your codebase. The trait method WithSignals::signals()
allows direct access from &mut self
, but outside you often
only have a Gd<Monster>
. You could technically bind_mut()
that object, but there's a better way without borrow-checking.
For this reason, Gd
itself also provides a signals()
method, returning the exact same signal collection API:
Signal visibility
Like all items in Rust, signals are private by default, i.e. only visible in their module and submodules.
You can make them public by adding pub
to the #[signal]
attribute:
Of course, pub(crate)
, pub(super)
or pub(in path)
are also possible for more fine-grained control.
#[signal]
visibility must not exceed class visibility.
If you get errors such as "can't leak private type", then you violated this rule.
So, if your class is declared as struct Monster
(private), then you cannot declare signals as pub
or pub(crate)
. This is due to a technical
limitation resulting from signals being separate types, which refer to the class type in their APIs. Making them "more public" than the class
would thus circumvent Rust's privacy rules.
Semantically, it makes sense though: the only situation where you'd need outside access is through Gd<SomeClass>::signals()
, and this implies
that SomeClass
is visible at that point. But unlike other Rust items such as fn
, wider visibility isn't automatically limited to "at most
struct visibility", but causes a compile error.
Note that you cannot separate the visibility of connect and emit APIs. If you want to make sure that outsiders can only emit, keep the signal private and provide a public wrapper function in your class that forwards the call to the signal.
Connecting from outside
Let's say you have a sound system which should play a sound effect whenever a monster takes damage. You can connect to the signal from there:
Emitting from outside
Like connecting, emitting can also happen through Gd::signals()
. The rest remains the same.
Advanced signal setups
The TypedSignal::connect*()
methods are designed to be straightforward, while covering common use cases. If you need more advanced setups,
a high degree of customization is provided by TypedSignal::connect_builder()
.
The returned ConnectBuilder
provides several dimensions of configurability:
- Receiver:
function(args)
,method(&self, args)
,method(&mut self, args)
- Provided object: none,
&mut self
orGd<T>
- Connection flags:
DEFERRED
,ONESHOT
,PERSIST
- Single-threaded (default) or thread-crossing (sync)
To finish it, done()
is invoked. Some example setups:
The builder methods need to be called in the correct order ("stages"). See API docs for more information.
Untyped signals
Godot's low-level APIs for dealing with untyped signals are still available:
Object::connect()
,Object::connect_ex()
Object::emit_signal()
Signal::connect()
Signal::emit()
They can be used as a fallback for areas that the new typed signal API doesn't cover yet (e.g. Godot's built-in signals), or in situations where you only have some information available at runtime.
Certain typed-signal features are still planned and will make working with signals even more streamlined. Other features are likely not going
to be ported to godot-rust, e.g. a Callable::bind()
equivalent for typed Rust methods. Just use closures instead.
Conclusion
In this chapter, we saw how godot-rust's type-safe signals provide an intuitive and resilient way to deal with Godot's observer pattern and avoid certain pitfalls of GDScript. Rust function references or closures can be directly connected to signals, and emitting is achieved with regular function calls.