Coletar Moedas na Unity: Trigger, Contador e Som
Coletar moedas na Unity (o unity collect coins do primeiro jogo de plataforma) parece um OnTriggerEnter2D e um score++, mas o jeito como você liga moeda, contador, texto e som define se o sistema aguenta 300 moedas na fase ou vira um festival de FindObjectOfType. Aqui você monta a moeda completa em C# do Unity 6.3 LTS: trigger 2D, contador global por evento, pontuação em TextMeshPro, som que não é cortado quando a moeda some, animação de girar e recorde salvo com PlayerPrefs.
Coletar moedas na Unity: trigger, contador e feedback
O sistema tem três peças, e cada uma conhece o mínimo possível:
- Coin: detecta o jogador, toca o som, avisa "fui coletada, valho 1" e some. Não sabe que existe placar.
- ScoreManager: escuta as moedas, soma, guarda o recorde e avisa quando a pontuação muda. Não sabe que existe texto na tela.
- ScoreUI: escuta o ScoreManager e atualiza o texto. Não sabe que existem moedas.
A ligação entre eles é por evento. O erro clássico que isso evita: cada moeda fazendo FindObjectOfType<ScoreManager>() no Start. Com 300 moedas, são 300 buscas na cena inteira ao carregar a fase. E no Unity 6 esse método nem é mais o caminho: FindObjectOfType está obsoleto, substituído por FindFirstObjectByType e FindAnyObjectByType. Com evento, você não precisa de nenhum dos três.
Collider como trigger e tag do jogador
Monte a moeda:
- Crie um GameObject
Coincom SpriteRenderer (16x16 ou 32x32 pixels, conforme a arte do jogo). - Adicione CircleCollider2D e marque Is Trigger.
- No jogador, confira que ele tem Rigidbody2D e um collider, e que a Tag dele é
Player(a tagPlayerjá vem na lista padrão).
Trigger 2D só dispara se pelo menos um dos dois objetos tiver Rigidbody2D. O jogador já tem, então a moeda não precisa. Colocar Rigidbody2D em cada moeda é desperdício e ainda faz a moeda cair se a gravidade estiver ligada.
Contador com evento estático ou ScoreManager
Crie Coin.cs:
using System;
using UnityEngine;
[RequireComponent(typeof(Collider2D))]
public class Coin : MonoBehaviour
{
public static event Action<int> Collected;
[SerializeField] private int value = 1;
[SerializeField] private AudioClip pickupSound;
[SerializeField] private float soundVolume = 0.8f;
[SerializeField] private float spinSpeed = 4f;
[SerializeField] private float bobHeight = 0.1f;
private bool collected;
private Vector3 startPosition;
private Vector3 startScale;
private float phase;
private void Awake()
{
startPosition = transform.position;
startScale = transform.localScale;
phase = UnityEngine.Random.Range(0f, Mathf.PI * 2f);
}
private void Update()
{
float t = Time.time * spinSpeed + phase;
transform.localScale = new Vector3(startScale.x * Mathf.Cos(t), startScale.y, startScale.z);
transform.position = startPosition + Vector3.up * (Mathf.Sin(t * 0.5f) * bobHeight);
}
private void OnTriggerEnter2D(Collider2D other)
{
if (collected || !other.CompareTag("Player"))
{
return;
}
collected = true;
if (pickupSound != null)
{
AudioSource.PlayClipAtPoint(pickupSound, Camera.main.transform.position, soundVolume);
}
Collected?.Invoke(value);
gameObject.SetActive(false);
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStaticEvent()
{
Collected = null;
}
}
Linha a linha do que importa:
public static event Action<int> Collectedé um canal único para todas as moedas. Quem quer saber de moeda se inscreve uma vez, e não precisa de referência a nenhuma moeda específica.collectedimpede a contagem dupla quando o jogador tem dois colliders (corpo e pés) entrando no mesmo frame.CompareTag("Player")em vez deother.tag == "Player": oCompareTagnão aloca string e acusa erro se a tag não existir, o que pega erro de digitação.UnityEngine.Randomescrito por extenso porque ousing Systemtraz umSystem.Randome o nome curto fica ambíguo.ResetStaticEventlimpa o evento ao entrar em Play Mode. Se você desligar o recarregamento de domínio nas opções de Enter Play Mode (para o Play ficar mais rápido), campos estáticos sobrevivem entre uma execução e outra, e o evento acumularia ouvintes de sessões passadas.
A animação de girar é o truque clássico de moeda 2D: a escala X segue um cosseno, então a moeda "achata" e "vira" como se rodasse no próprio eixo, e um seno leve faz ela flutuar. A fase aleatória evita que todas as moedas da fase girem sincronizadas, o que parece robótico. Se o seu artista desenhou os frames da moeda girando, use um Animator com esses sprites no lugar.
Agora ScoreManager.cs:
using System;
using UnityEngine;
public class ScoreManager : MonoBehaviour
{
private const string HighScoreKey = "HighScore";
public event Action<int> ScoreChanged;
public event Action<int> HighScoreChanged;
public int Score { get; private set; }
public int HighScore { get; private set; }
private void Awake()
{
HighScore = PlayerPrefs.GetInt(HighScoreKey, 0);
}
private void OnEnable()
{
Coin.Collected += AddPoints;
}
private void OnDisable()
{
Coin.Collected -= AddPoints;
}
private void Start()
{
ScoreChanged?.Invoke(Score);
HighScoreChanged?.Invoke(HighScore);
}
public void AddPoints(int amount)
{
Score += amount;
ScoreChanged?.Invoke(Score);
if (Score > HighScore)
{
HighScore = Score;
HighScoreChanged?.Invoke(HighScore);
}
}
public void SaveHighScore()
{
PlayerPrefs.SetInt(HighScoreKey, HighScore);
PlayerPrefs.Save();
}
}
O ScoreManager se inscreve no evento em OnEnable e sai em OnDisable. Isso é obrigatório com evento estático: se o ScoreManager for destruído ao trocar de cena e continuar inscrito, a próxima moeda chama um método de objeto destruído.
Mostrar a pontuação com TextMeshPro
No Unity 6, o TextMeshPro faz parte do pacote de UI (com.unity.ugui), então não há pacote separado para instalar. Crie o texto em GameObject > UI > Text - TextMeshPro. Na primeira vez, a Unity pede para importar o TMP Essentials: aceite, porque é ali que vem a fonte padrão.
Posicione o texto no canto superior esquerdo do Canvas (âncora no canto, para não fugir em outras resoluções) e crie ScoreUI.cs:
using TMPro;
using UnityEngine;
public class ScoreUI : MonoBehaviour
{
[SerializeField] private ScoreManager scoreManager;
[SerializeField] private TMP_Text scoreText;
[SerializeField] private TMP_Text highScoreText;
private void OnEnable()
{
scoreManager.ScoreChanged += UpdateScore;
scoreManager.HighScoreChanged += UpdateHighScore;
}
private void OnDisable()
{
scoreManager.ScoreChanged -= UpdateScore;
scoreManager.HighScoreChanged -= UpdateHighScore;
}
private void UpdateScore(int score)
{
scoreText.SetText("Moedas: {0}", score);
}
private void UpdateHighScore(int highScore)
{
highScoreText.SetText("Recorde: {0}", highScore);
}
}
TMP_Text é a classe base que serve tanto para texto de UI quanto para texto no mundo. O SetText com {0} formata o número sem criar string nova a cada chamada, o que importa quando o contador muda muitas vezes por segundo. O texto só é atualizado quando a pontuação muda, nunca no Update.
Até aqui são três scripts pequenos e desacoplados. O jogo de verdade pede mais conexões: a moeda que conta para desbloquear a porta do fim da fase, o recorde que aparece na tela de game over, o placar que precisa sobreviver à troca de cena, o checkpoint que decide se as moedas pegas antes de morrer contam. Organizar essas dependências sem virar espaguete é o que um curso de Unity com projeto completo treina, porque você vê o sistema crescendo dentro de um jogo inteiro.
Som da moeda sem cortar ao destruir
O bug: você coloca um AudioSource na moeda, chama Play() e logo em seguida Destroy(gameObject). O som não toca, porque o AudioSource morreu junto.
AudioSource.PlayClipAtPoint(clip, posição, volume) resolve: ele cria um AudioSource temporário na posição que você passar e o descarta sozinho quando o clipe termina. A moeda pode sumir na hora.
Detalhe de jogo 2D: esse AudioSource temporário toca na posição do mundo que você indicou. Se você passar a posição da moeda, ela fica a 10 unidades da câmera (que fica em z = -10 no template 2D) e o volume pode cair com a distância. Por isso o código passa Camera.main.transform.position: o som toca "no ouvido" do jogador, com volume cheio.
Alternativa sem objeto temporário: um AudioSource fixo na cena (no ScoreManager, por exemplo) chamando PlayOneShot(clip). O PlayOneShot permite vários sons sobrepostos no mesmo AudioSource, o que é bom quando o jogador atravessa uma fileira de moedas.
Para o som não ficar irritante na décima moeda seguida, varie o pitch de 0,95 a 1,1 a cada coleta, ou suba o pitch em sequência quando as moedas são pegas em menos de meio segundo. Esse tipo de detalhe é o assunto de game feel e juice.
Recorde salvo com PlayerPrefs
PlayerPrefs guarda pares chave e valor no dispositivo (no Windows, no registro, e no WebGL, no armazenamento do navegador). Para recorde, é o suficiente:
- Ler:
PlayerPrefs.GetInt("HighScore", 0), com 0 como padrão quando ainda não há nada salvo. - Gravar:
PlayerPrefs.SetInt("HighScore", valor). - Persistir:
PlayerPrefs.Save(). A Unity também salva ao fechar o jogo normalmente, mas se o jogo travar ou o navegador fechar a aba, o que não foi salvo some.
Chame SaveHighScore() no game over ou no fim da fase, não a cada moeda: gravar em disco 300 vezes por fase é custo sem motivo. E não use PlayerPrefs para inventário, progresso de várias fases ou nada que o jogador não possa perder. Para isso, salve em arquivo (JSON em Application.persistentDataPath).
Erros comuns: moeda contando duas vezes, OnTriggerEnter2D não dispara
A moeda conta duas vezes. Dois colliders do jogador entram no mesmo frame. A flag collected resolve. Se o jogador tiver um collider de ataque também com tag Player, a espada coleta moeda: dê outra tag para a hitbox.
OnTriggerEnter2D não dispara. As causas mais comuns: nenhum dos dois tem Rigidbody2D, o collider da moeda não está como Is Trigger, você usou OnTriggerEnter (3D) em vez de OnTriggerEnter2D, ou o parâmetro do método está com o tipo errado (Collider em vez de Collider2D). Também confira a matriz de colisão em Edit > Project Settings > Physics 2D: se as layers da moeda e do jogador não colidem, o trigger também não dispara. O post de OnTriggerEnter que não funciona na Unity tem o checklist completo.
O texto não atualiza. O ScoreUI não tem a referência do ScoreManager no Inspector, ou o texto arrastado é um Text legado e não um TextMeshPro.
O recorde zera ao fechar. Faltou chamar SaveHighScore() antes de sair, ou o jogo fechou por erro antes do Save().
Próximo passo
Com moedas funcionando, o mesmo padrão de evento serve para qualquer coletável: vida, munição, chave, power-up. Crie uma classe base Pickup com o trigger e a flag, e herde para cada tipo. Um bom exercício é colocar 100 moedas na fase, contar quantas o jogador pegou e mostrar "87/100" na tela de fim de fase: você vai precisar contar as moedas no início da cena e passar esse total para a UI, o que treina exatamente a comunicação entre sistemas que este post montou. Para comparar com a mesma ideia em outra engine, o post de power-up coletável no Godot usa sinais no lugar de eventos C#, e a arquitetura é a mesma: o coletável avisa, quem se importa escuta.
Perguntas frequentes
Como fazer um sistema de pontuação na Unity?
Crie um ScoreManager que guarda a pontuação e dispara um evento quando ela muda. Os coletáveis avisam por evento que foram pegos, e a UI escuta o ScoreManager para atualizar o texto. Assim nenhum script precisa procurar o outro na cena.
Por que o som da moeda não toca quando ela é destruída?
Porque o AudioSource está na própria moeda e é destruído junto com ela antes do som terminar. Use AudioSource.PlayClipAtPoint, que cria um AudioSource temporário que se apaga sozinho, ou toque o som num AudioSource que fica na cena, como o do ScoreManager.
Como salvar o recorde na Unity?
Use PlayerPrefs.GetInt para ler o recorde salvo (com 0 como padrão), compare com a pontuação da partida e, se for maior, grave com PlayerPrefs.SetInt e chame PlayerPrefs.Save. Para dados maiores que alguns números, prefira salvar em arquivo JSON.
Por que a moeda conta duas vezes na Unity?
O personagem tem mais de um collider e os dois entram no trigger no mesmo frame, ou a moeda é desativada no fim do frame e o evento dispara de novo. Uma flag de coletada dentro da moeda resolve: ela só conta na primeira vez.



