Voltar para o Blog
Quest Log

Coletar Moedas na Unity: Trigger, Contador e Som

Personagem 2D pulando para pegar uma fileira de moedas douradas girando, com o contador de pontos no canto da tela

Coletar moedas na Unity com trigger 2D, contador por evento, pontuação em TextMeshPro, som que não corta e recorde salvo com PlayerPrefs, em C# do Unity 6.

Ferramenta:UnityC#

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:

  1. Crie um GameObject Coin com SpriteRenderer (16x16 ou 32x32 pixels, conforme a arte do jogo).
  2. Adicione CircleCollider2D e marque Is Trigger.
  3. No jogador, confira que ele tem Rigidbody2D e um collider, e que a Tag dele é Player (a tag Player já 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.
  • collected impede a contagem dupla quando o jogador tem dois colliders (corpo e pés) entrando no mesmo frame.
  • CompareTag("Player") em vez de other.tag == "Player": o CompareTag não aloca string e acusa erro se a tag não existir, o que pega erro de digitação.
  • UnityEngine.Random escrito por extenso porque o using System traz um System.Random e o nome curto fica ambíguo.
  • ResetStaticEvent limpa 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.

Curso de Unity
Quer fazer seu primeiro jogo na Unity?

No curso de Unity 6 da CursoGame.Dev você vai do zero ao primeiro jogo, com um desafio prático em cada level e suporte individual no WhatsApp quando travar.

Conhecer o curso de Unity
Suporte no WhatsAppAcesso vitalícioGarantia de 7 dias

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.