This is by far the biggest thing I always find myself going in circles over and can never seem to find an approach I'm happy with - setting up regular UnityEvent listeners from code vs. setting up "persistent event listeners" (Unity's term for event listeners we add to a UnityEvent in the Unity Inspector).
Let's say, as a very simple example, we've got a platformer where the player can pick up coins. We might have a player class that looks like this:
public class Player : MonoBehaviour {
[SerializeField] private UnityEvent collectedCoinEvent;
public UnityEvent CollectedCoinEvent => collectedCoinEvent;
private void OnCollisionEnter(Collision collision) {
var coin = collision.transform.GetComponent<Coin>();
if (coin != null) {
coin.ReturnToPool();
collectedCoinEvent.Invoke();
}
}
}
Say we then add a CoinTracker
which tracks the coins collected. Here are two ways we could write the class:
- For use with a persistent listener from the inspector:
public class CoinTracker : MonoBehaviour {
[SerializeField] private TMP_Text coinsText;
private int coins = 0;
//this function is called by a persistent listener on the Player's
//collectedCoinEvent in the Inspector
public void AddCoin() {
coins++;
coinsText.text = coins.ToString();
}
}
This solution reduces coupling and makes it easier for someone who is not a programmer to set up the event logic. However, it makes navigating and understanding the code more difficult. Our IDE will show that there are 0 references to AddCoin()
and 0 references to the CollectedCoinEvent
property. Figuring out what happens when collectedCoinEvent
is invoked is not too difficult (check the Inspector) but it's much more difficult to figure out what persistent event listeners might be calling the AddCoin()
function.
- Add the event listener from code:
public class CoinTracker : MonoBehaviour {
[SerializeField] private TMP_Text coinsText;
[SerializeField] private Player player;
private int coins = 0;
private void Awake() {
Assert.IsNotNull(player);
player.CollectedCoinEvent.AddListener(AddCoin);
}
private void AddCoin() {
coins++;
coinsText.text = coins.ToString();
}
}
With this approach, since we add the event listener in code, we can easily follow the event flow in our IDE using features such as "find references". However, we've created coupling - the CoinTracker needs a reference to the Player instance. Additionally, team members who only work with the Editor and not with code wouldn't be able to add or remove event logic.
- A third approach uses an intermediary class for events, as an alternative to approach #2. This requires changes to the
Player
as well.
public class GameEventService : MonoBehaviour {
private UnityEvent collectedCoinEvent = new UnityEvent();
public UnityEvent CollectedCoinEvent => collectedCoinEvent ;
}
public class Player : MonoBehaviour {
[SerializeField] private GameEventService gameEventService;
private void OnCollisionEnter(Collision collision) {
var coin = collision.transform.GetComponent<Coin>();
if (coin != null) {
coin.ReturnToPool();
gameEventService.CollectedCoinEvent.Invoke();
}
}
}
public class CoinTracker : MonoBehaviour {
[SerializeField] private TMP_Text coinsText;
[SerializeField] private GameEventService gameEventService;
private int coins = 0;
private void Awake() {
Assert.IsNotNull(player);
gameEventService.CollectedCoinEvent.AddListener(AddCoin);
}
private void AddCoin() {
coins++;
coinsText.text = coins.ToString();
}
}
With this approach, we've eliminated coupling directly between CoinTracker
and Player
, but both classes are now coupled to GameEventService
. While I've used UnityEvents for the example, we could do the same thing with C# events. As with #2, team members who only work with the Editor and not with code wouldn't be able to add or remove event logic.
Is there a right answer here? A rule for when to use a particular approach? A better alternative?