Guide to signals

From BeeStation Wiki
Revision as of 06:33, 19 December 2023 by Itsmeowdev (talk | contribs) (Created page with "== What is a signal? == A signal is a standard interface for calling procs on components and other objects. It prevents holding hard references and allows the procs to be called on multiple objects from one "event". It is an event-subscriber system, where one thing "shouts", or emits an event, and multiple things "listen". == Why signals? == In code, signals can improve quality and stability, because you do not have to collect a list of things and then call a proc on...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search

What is a signal?

A signal is a standard interface for calling procs on components and other objects. It prevents holding hard references and allows the procs to be called on multiple objects from one "event". It is an event-subscriber system, where one thing "shouts", or emits an event, and multiple things "listen".

Why signals?

In code, signals can improve quality and stability, because you do not have to collect a list of things and then call a proc on them if you want to inform them of a certain event happening. For example, previously, you may have needed to hold a reference to something or maintain a list of things that want to hear about an event occuring. Signals are built into every datum and object, and perform this duty automatically. Signals can also replace the need to check values on a component or other object, as you can simply emit a signal requesting it, and have the other object listen for it and respond.

For example, many things in the game care if a player dies. Because of that, we have a signal called COMSIG_MOB_DEATH, that is emitted by mobs when they die. It provides data with it as well (what emitted it, current health, etc.) that may be useful to the "listener". Signals can also be returned with a value to the emitter, allowing further interactions between objects.

How do I use signals?

Signals are made of several parts.

An emitter, which calls SEND_SIGNAL(). This emits the signal to anything that is currently listening to the emitter.

A listener, which calls RegisterSignal with the emitter as an argument, and provides a signal type and a signal callback.

A signal type, which is the type of signal being emitted or listened to. This is what COMSIG_MOB_DEATH is - it's a unique string that denotes the type of signal being sent, and when it is sent or how it is to be used.

A signal callback, which is a proc that receives data from the emitter's signal for a specific signal type.

Adding or selecting a type

All signals have a type, which is the unique string describing what the signal is used for or when it is emitted. All types are stored in code/__DEFINES/dcs/signals/, and sorted into categories based on the type of the emitter. For example, any signal sent only by /mob/ types should be in signals_mob/signals_mob.dm.

This is what a signal type definition looks like. The string in the signal should be unique to the signal type - and is usually the lowercase of everything after COMSIG_. It is also standard to document when a signal is sent within the comment, either by stating it or providing a proc path for the case where SEND_SIGNAL is used.

#define COMSIG_MOB_DEATH "mob_death" //! from base of mob/death(): (gibbed)

The parent path for the signal should be the highest path for which the signal is relevant. For example, there is both COMSIG_MOB_DEATH and COMSIG_LIVING_DEATH, because some places only care when a living mob dies. When creating a signal, determine which context is most useful or applicable, and apply it to the highest object possible. COMSIG_LIVING_DEATH provides additional arguments (or context) when the signal is emitted, making it necessary should anything require this information.

Listening for a signal

This is one of the most common patterns in the entire codebase, so it is absolutely vital to understand. When you want to receive a signal from an atom or datum, you call RegisterSignal with that atom or datum as the first argument, a signal type, and then a signal listener.

This is often done during Initialize(), however it can take place at any time so long as the emitter exists. Do note that RegisterSignal is a type on /datum, so it is implicitly calling src.RegisterSignal(). This means you can technically force another datum to register a signal listener via other_thing.RegisterSignal(emitter), however this is not recommended.

RegisterSignal(emitter_here, COMSIG_MOB_DEATH, PROC_REF(on_mob_death))

As you can see, the signal registration requires a signal listener. These are procs with a special format. In a signal's comment, or when it is emitted, there is usually a list of arguments, or simply no arguments. These arguments are provided to the signal listener as additional data. When defining a listener, you can choose to include these arguments if you need them.

/datum/example/proc/on_mob_death(mob/parent, gibbed)
    SIGNAL_HANDLER

As you can see, the first argument provided to a signal listener is ALWAYS the emitter, so you can assume for COMSIG_MOB_DEATH that it is of the type /mob. The remaining arguments are defined by the emitter itself when the signal is sent. You do not need to include any arguments if you do not want them.

You can also see that signal listeners always start with SIGNAL_HANDLER. This is a special DEFINE that disables sleeping within the proc, as sleeping in a signal listener can break the master controller's timing. It is important to include this in all signal listeners, since it is not checked automatically otherwise.

Stopping listening to a signal

Registering a signal on an emitter stores a reference to that emitter. This is problematic if the emitter were to be deleted or dissociated from the listener, as there is not a reference the other way around. It is generally the responsibility of the listener to control its own references to the emitter. For example, if we have an object that was listening for another object's death, but only once, we could unregister the signal during on_mob_death so as to prevent holding a reference.

Unregistering a signal is simple:

UnregisterSignal(emitter_here, COMSIG_MOB_DEATH)

This will remove src's listener for COMSIG_MOB_DEATH.

It is also important to know that all atoms and datums will automatically unregister all their signals when they are destroyed, so there is no need to do so yourself during Destroy(), however some things like components or species need to remove signals when they are 'dissociated', such as on_species_loss, or when a component is detached, as they are not always immediately destroyed.

Emitting or sending a signal

To send a signal, and retrieve results from listeners, you simply use the SEND_SIGNAL define.

SEND_SIGNAL(src, COMSIG_MOB_DEATH)

The first argument is the emitter, so generally it is src, however this is not a rule. The second argument is always the type. You may provide additional arguments that will be sent as extra arguments to all listeners, as discussed earlier.

Another key feature of SEND_SIGNAL is that it can return a result. If a signal listener has a return value, it will be given as the result of SEND_SIGNAL. This means you can retrieve and act on data from listeners.

For example, this is used by emag signals:

/atom/proc/use_emag(mob/user, obj/item/card/emag/hacker)
	if(!SEND_SIGNAL(src, COMSIG_ATOM_SHOULD_EMAG, user))
		SEND_SIGNAL(src, COMSIG_ATOM_ON_EMAG, user, hacker)

If the result of COMSIG_ATOM_SHOULD_EMAG is FALSE, then COMSIG_ATOM_ON_EMAG is sent as well. Do note that this is intentionally inverted in case of an error, since procs will return null when they error, so this should be taken into consideration when sending signals to unknown listeners.