Eventos na Unity: UnityEvent, Action e event em C#
Eventos na Unity resolvem um problema simples: quando o jogador morre, a HUD, o áudio e a tela de game over precisam saber, sem que o script do jogador conheça cada um deles. Você tem três ferramentas para isso: UnityEvent (ligado pelo Inspector), System.Action (um delegate do C#) e a palavra-chave event em cima de um Action. Este post mostra o mesmo exemplo nas três formas, diz quando cada uma é a melhor escolha e mostra o vazamento que acontece quando você esquece de remover um listener.
O código é do Unity 6.3 LTS e compila colando os arquivos num projeto novo.
Por que usar eventos na Unity
Sem eventos, o script do jogador costuma ficar assim: ao zerar a vida, ele chama hud.ShowGameOver(), audio.PlayDeath(), camera.Shake(), score.SaveRecord(). Ele precisa de uma referência para cada sistema, e cada sistema novo exige mexer no jogador. Num jogo pequeno isso funciona por uma semana. Depois, qualquer mudança na HUD quebra a compilação do jogador.
Com eventos, o jogador só anuncia: "minha vida mudou" e "morri". Quem se importa se inscreve. O jogador não sabe quantos ouvintes existem, e você adiciona um sistema de conquistas amanhã sem tocar no PlayerHealth. É o padrão Observer, que aparece com mais contexto no post de padrões de projeto para jogos. Se você vem do Godot, é a mesma ideia dos signals do GDScript.
UnityEvent: ligar pelo Inspector
UnityEvent fica no namespace UnityEngine.Events e é serializável. Isso quer dizer que ele aparece no Inspector como uma lista onde você arrasta um objeto e escolhe um método para chamar. Versão 1 do PlayerHealth.cs:
using UnityEngine;
using UnityEngine.Events;
public class PlayerHealth : MonoBehaviour
{
[SerializeField, Min(1)] private int maxHealth = 100;
[SerializeField] private UnityEvent<int, int> onHealthChanged = new UnityEvent<int, int>();
[SerializeField] private UnityEvent onDied = new UnityEvent();
private int currentHealth;
private bool isDead;
private void Start()
{
currentHealth = maxHealth;
onHealthChanged.Invoke(currentHealth, maxHealth);
}
public void TakeDamage(int amount)
{
if (isDead)
{
return;
}
currentHealth = Mathf.Max(0, currentHealth - amount);
onHealthChanged.Invoke(currentHealth, maxHealth);
if (currentHealth == 0)
{
isDead = true;
onDied.Invoke();
}
}
}
No Inspector do Player aparecem dois blocos, On Health Changed (Int32, Int32) e On Died (). Clique no +, arraste o objeto da HUD para o campo de objeto e escolha o método no dropdown. Para o evento com parâmetros, escolha o método na seção dinâmica do dropdown (a que lista os métodos que aceitam int, int), assim o valor do Invoke chega ao método.
A versão genérica UnityEvent<T> (até quatro parâmetros) é declarada direto como campo, sem precisar criar subclasse, e serializa normalmente.
O ponto forte do UnityEvent é que um designer liga "ao morrer, toque este som e desative este objeto" sem escrever código. O ponto fraco é que a ligação fica escondida no Inspector: quem lê o código não vê quem escuta o evento.
System.Action e a palavra event
Action é um delegate do namespace System: uma variável que guarda uma lista de métodos. Action sem parâmetro, Action<int> com um, Action<int, int> com dois. Versão 2, com um campo público comum:
using System;
using UnityEngine;
public class PlayerHealth : MonoBehaviour
{
[SerializeField, Min(1)] private int maxHealth = 100;
public Action<int, int> HealthChanged;
public Action Died;
private int currentHealth;
private void Start()
{
currentHealth = maxHealth;
HealthChanged?.Invoke(currentHealth, maxHealth);
}
public void TakeDamage(int amount)
{
currentHealth = Mathf.Max(0, currentHealth - amount);
HealthChanged?.Invoke(currentHealth, maxHealth);
if (currentHealth == 0)
{
Died?.Invoke();
}
}
}
Funciona, mas tem dois furos. Qualquer outra classe pode fazer player.Died = null; e apagar todos os ouvintes com um = no lugar de +=. E qualquer classe pode chamar player.Died.Invoke() e "matar" o jogador de fora. Por isso a versão 3 coloca event na frente:
using System;
using UnityEngine;
public class PlayerHealth : MonoBehaviour
{
[SerializeField, Min(1)] private int maxHealth = 100;
public event Action<int, int> HealthChanged;
public event Action Died;
private int currentHealth;
private bool isDead;
public int CurrentHealth => currentHealth;
public int MaxHealth => maxHealth;
private void Awake()
{
currentHealth = maxHealth;
}
public void TakeDamage(int amount)
{
if (isDead)
{
return;
}
currentHealth = Mathf.Max(0, currentHealth - amount);
HealthChanged?.Invoke(currentHealth, maxHealth);
if (currentHealth == 0)
{
isDead = true;
Died?.Invoke();
}
}
}
Com event, de fora da classe só é permitido += e -=. Atribuir com = ou chamar Invoke fora da classe dá erro de compilação. É a mesma variável, com a porta trancada. Para eventos em código, sempre que puder, use event.
O ?.Invoke é o jeito curto de "dispare se alguém estiver ouvindo". Delegate sem nenhum ouvinte é null, e chamar Died() direto nesse caso lança NullReferenceException.
Mesmo exemplo nas três formas
Os ouvintes da versão com event ficam assim. Arquivo HealthHUD.cs:
using TMPro;
using UnityEngine;
public class HealthHUD : MonoBehaviour
{
[SerializeField] private PlayerHealth player;
[SerializeField] private TMP_Text healthLabel;
[SerializeField] private GameObject gameOverPanel;
private void OnEnable()
{
player.HealthChanged += HandleHealthChanged;
player.Died += HandleDied;
}
private void Start()
{
HandleHealthChanged(player.CurrentHealth, player.MaxHealth);
}
private void OnDisable()
{
player.HealthChanged -= HandleHealthChanged;
player.Died -= HandleDied;
}
private void HandleHealthChanged(int current, int max)
{
healthLabel.text = $"{current}/{max}";
}
private void HandleDied()
{
gameOverPanel.SetActive(true);
}
}
E o áudio, PlayerDeathSound.cs:
using UnityEngine;
public class PlayerDeathSound : MonoBehaviour
{
[SerializeField] private PlayerHealth player;
[SerializeField] private AudioSource audioSource;
[SerializeField] private AudioClip deathClip;
private void OnEnable()
{
player.Died += PlayDeath;
}
private void OnDisable()
{
player.Died -= PlayDeath;
}
private void PlayDeath()
{
audioSource.PlayOneShot(deathClip);
}
}
Comparando as três formas com o mesmo cenário:
| UnityEvent | Action público | event Action | |
|---|---|---|---|
| Liga pelo Inspector | Sim | Não | Não |
| Quem escuta fica visível no código | Não | Sim | Sim |
| Outra classe pode apagar ouvintes | Só os de código (RemoveAllListeners) | Sim, com = | Não |
| Outra classe pode disparar | Sim, se o campo for acessível | Sim | Não |
Na versão UnityEvent, os ouvintes podem ser ligados no Inspector ou por código com onDied.AddListener(PlayDeath) e onDied.RemoveListener(PlayDeath), seguindo a mesma regra de OnEnable e OnDisable.
Eventos são uma peça; um jogo de verdade tem dezenas delas conversando ao mesmo tempo: vida, pontuação, inimigos, salvamento, menus. Saber escolher a ferramenta em cada ponto, sem acabar com tudo acoplado num script gigante, é o tipo de decisão que se aprende fazendo um projeto inteiro com alguém revisando as escolhas.
Inscrever e desinscrever em OnEnable e OnDisable
A regra de ouro: quem se inscreve no OnEnable se desinscreve no OnDisable. Isso cobre três situações de uma vez:
- Objeto desativado. A HUD escondida para de reagir enquanto está inativa e volta a ouvir quando é reativada.
- Objeto destruído. O
OnDisableroda antes da destruição, então a inscrição sai junto. - Troca de cena. Objetos da cena antiga são destruídos e se desinscrevem sozinhos.
Por que não se inscrever no Start e se desinscrever no OnDestroy? Funciona para objetos que nunca são desativados, mas falha no caso da HUD escondida: ela continua recebendo eventos e tentando mexer em UI inativa. O par OnEnable/OnDisable é simétrico e mais previsível.
Dois detalhes. No OnEnable, o objeto de onde vem o evento precisa já existir: se player pode ser nulo naquele momento, verifique antes de fazer +=. E a leitura inicial da vida fica no Start do HealthHUD, não no OnEnable, porque a Unity não garante que o Awake do jogador rodou antes do OnEnable da HUD; no Start, todos os Awake da cena já rodaram.
UnityEvent vs event: performance e organização
Chamar um UnityEvent custa mais do que chamar um delegate C#, porque ele resolve os ouvintes persistentes do Inspector e passa por uma camada a mais. Para eventos raros (morte, fim de fase, abrir menu), essa diferença não aparece no Profiler. Para algo disparado centenas de vezes por frame (cada bala, cada partícula), prefira event Action ou uma chamada direta.
A diferença que mais pesa no dia a dia é de organização:
- UnityEvent brilha na borda entre código e cena: botões, gatilhos de porta, sequências de tutorial, coisas que um designer configura.
- event Action brilha entre sistemas de código: vida avisando HUD, inventário avisando save,
GameManageravisando mudança de estado. Quem lê oHealthHUDvê na hora de onde vem cada chamada. UmGameManagerque expõe eventos comoGameStartedeGameOveré o próximo passo natural, assunto do post sobre Singleton e GameManager na Unity.
Um evento static (public static event Action PlayerDied;) evita a referência ao jogador, mas guarda ouvintes por toda a sessão. Se você desativar o domain reload nas opções de Enter Play Mode, ouvintes de uma sessão de teste podem sobrar na próxima. Desinscrever no OnDisable continua obrigatório.
E quando não usar evento nenhum? Quando a relação é de um para um e permanente. Uma arma que sempre pertence ao mesmo jogador pode chamar player.AddRecoil() direto: criar um evento para um único ouvinte que nunca muda só espalha a lógica por mais arquivos. Evento compensa quando há vários ouvintes, quando os ouvintes mudam ao longo do jogo ou quando quem dispara não deveria saber quem escuta. Na dúvida, comece com a chamada direta e troque por event no dia em que aparecer o segundo interessado.
Erros comuns: listener chamado em objeto destruído e evento nulo
MissingReferenceException quando o evento dispara. Um objeto se inscreveu e foi destruído sem se desinscrever. O delegate continua apontando para ele, e o método tenta acessar um componente que já não existe. Correção: par += no OnEnable e -= no OnDisable.
NullReferenceException em Died(). Ninguém se inscreveu ainda e o delegate é nulo. Use Died?.Invoke().
Inscrever com lambda e não conseguir remover. player.Died += () => PlayDeath(); cria um delegate novo; o -= com outra lambda igual não remove nada. Inscreva métodos com nome, ou guarde a lambda numa variável.
Ouvinte duplicado. Se o OnEnable roda duas vezes sem OnDisable no meio (por exemplo, você chama a inscrição também no Start), o método é chamado duas vezes por evento. Inscreva em um lugar só.
Ligação do Inspector perdida. Renomeou o método que estava ligado num UnityEvent? O Inspector mostra o campo como faltando e o evento para de chamar. Depois de renomear métodos públicos, confira os UnityEvents da cena.
Próximo passo
Pegue um script do seu projeto que chama três ou mais sistemas diretamente e troque por um event Action, com cada sistema se inscrevendo no próprio OnEnable. Depois desative a HUD durante o jogo e confirme que nada quebra. Quando isso estiver natural, leia sobre o Singleton e o GameManager na Unity para centralizar os eventos de estado do jogo.
Perguntas frequentes
Qual a diferença entre UnityEvent e event em C#?
UnityEvent é serializável: aparece no Inspector e você liga a resposta arrastando objetos, sem código. event é um recurso da linguagem C#: só se inscreve por código, é mais leve para chamar e impede que outra classe dispare ou apague o evento. Use UnityEvent para ligações feitas por designers na cena e event para comunicação entre sistemas no código.
Como usar AddListener no UnityEvent?
Chame meuEvento.AddListener(Metodo) passando um método com os mesmos parâmetros do evento, normalmente no OnEnable, e remova com RemoveListener(Metodo) no OnDisable. Listeners adicionados por código não aparecem no Inspector. E o RemoveAllListeners só remove os listeners de código: os ligados no Inspector continuam.
UnityEvent com parâmetro funciona no Inspector?
Funciona. Declare o campo como UnityEvent<int> (ou com até quatro tipos) e marque com [SerializeField]. No Inspector, escolha a opção dinâmica do método para receber o valor que o código passa no Invoke.
Por que recebo MissingReferenceException quando o evento dispara?
Algum objeto se inscreveu no evento e foi destruído sem se desinscrever. O delegate ainda aponta para ele e, ao acessar o componente destruído, a Unity lança a exceção. Desinscreva no OnDisable sempre que se inscrever no OnEnable.



