Voltar para o Blog
Quest Log

Match-3 na Unity: Grade, Troca e Combinação

Tabuleiro de match-3 com gemas coloridas em grade, uma linha de três gemas brilhando no momento da combinação

Unity match 3 jogável: grade 8x8 em array 2D, tabuleiro sem combinação inicial, troca animada, detecção de linhas, queda, reposição e cascata em C# do Unity 6.

Ferramenta:UnityC#

Match-3 na Unity: Grade, Troca e Combinação

Um unity match 3 jogável se apoia num princípio: o tabuleiro de verdade é um array 2D de números, e as gemas na tela só refletem esse array. Com isso separado, você implementa uma grade 8x8, gera o tabuleiro inicial sem nenhuma combinação pronta, troca peças com animação, detecta linhas de 3 ou mais, remove, faz as peças caírem, repõe por cima e repete em cascata até o tabuleiro estabilizar. Este guia faz tudo isso em C# para o Unity 6.3 LTS, usando Awaitable para sequenciar as animações.

O erro que derruba a maioria dos primeiros match-3, aceitar input durante a animação, é resolvido com uma linha, e você vai ver onde ela entra.

Como funciona um match-3 por dentro

Por baixo das gemas brilhantes, um match-3 é um laço:

  1. O jogador troca duas peças vizinhas.
  2. O jogo procura combinações (3 ou mais iguais em linha ou coluna).
  3. Se não achou, desfaz a troca.
  4. Se achou, remove as peças combinadas e soma pontos.
  5. As peças acima caem para ocupar os buracos.
  6. Peças novas entram pelo topo.
  7. Volta ao passo 2: a queda pode ter criado combinações novas (a cascata).
  8. Quando não há mais combinação, devolve o controle ao jogador.

Os passos 2 a 7 são lógica pura sobre um array. A animação acontece depois de cada passo, mas não decide nada. Esse jeito de separar regra e apresentação é o que a lógica de programação para jogos chama de pensar no estado antes de pensar na tela.

Grade em array 2D

O tabuleiro lógico é um int[,] onde cada número é um tipo de peça (0 a 5 para seis cores) e -1 é célula vazia. Ao lado dele, um Gem[,] guarda a referência para o GameObject de cada célula.

Arquivo Gem.cs (no prefab da gema, com um SpriteRenderer e um BoxCollider2D do tamanho da célula):

using UnityEngine;

[RequireComponent(typeof(SpriteRenderer))]
public class Gem : MonoBehaviour
{
    [SerializeField] private Sprite[] sprites;

    private SpriteRenderer spriteRenderer;

    public int Type { get; private set; }
    public Vector2Int Cell { get; set; }

    private void Awake()
    {
        spriteRenderer = GetComponent<SpriteRenderer>();
    }

    public void SetType(int type)
    {
        Type = type;
        spriteRenderer.sprite = sprites[type];
    }
}

A posição na tela é calculada a partir da célula: origem + new Vector3(x, y) * tamanhoDaCélula. Use uma câmera Orthographic com Size 5 e um tabuleiro de 8 células de 1 unidade, centralizado com origem em (-3,5, -3,5). Ele cabe na tela de um celular em pé com sobra para a interface.

Gerar tabuleiro sem combinação inicial

Se o tabuleiro já nasce com três iguais, o jogo começa sozinho com uma cascata que o jogador não causou. O jeito ingênuo é gerar aleatório e sortear de novo até não haver combinação, o que pode levar muitas tentativas. O jeito certo é impedir na hora de preencher.

O preenchimento vai coluna por coluna da esquerda para a direita e, em cada coluna, de baixo para cima. Quando você chega na célula (x, y), as células à direita e acima ainda estão vazias. Então só duas situações formam combinação: as duas peças à esquerda são iguais ao tipo sorteado, ou as duas abaixo são. Basta excluir esses tipos do sorteio. O método abaixo faz isso; ele entra no arquivo BoardMatches.cs, mostrado completo mais adiante, então não cole duas vezes.

private int PickTypeWithoutMatch(int x, int y)
{
    List<int> options = new List<int>();
    for (int t = 0; t < gemTypes; t++)
    {
        bool leftMatch = x >= 2 && board[x - 1, y] == t && board[x - 2, y] == t;
        bool downMatch = y >= 2 && board[x, y - 1] == t && board[x, y - 2] == t;
        if (!leftMatch && !downMatch) options.Add(t);
    }
    return options[Random.Range(0, options.Count)];
}

Com 6 tipos, no máximo 2 são excluídos, então sempre sobram ao menos 4 opções. Com 3 tipos, poderia sobrar 1 só, o que deixa o tabuleiro previsível: use pelo menos 5 tipos num 8x8.

Match-3 na Unity: trocar peças

O jogador arrasta de uma gema para a vizinha. O Input System trata mouse e toque pelo mesmo Pointer.current, então o mesmo código serve para PC e celular.

Arquivo Board.cs, com toda a lógica (os métodos de busca, queda e animação aparecem nas próximas seções):

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

public partial class Board : MonoBehaviour
{
    [SerializeField] private int width = 8;
    [SerializeField] private int height = 8;
    [SerializeField] private int gemTypes = 6;
    [SerializeField] private Gem gemPrefab;
    [SerializeField] private Vector2 origin = new Vector2(-3.5f, -3.5f);
    [SerializeField] private float swapDuration = 0.15f;
    [SerializeField] private float fallDuration = 0.2f;

    private int[,] board;
    private Gem[,] gems;
    private bool busy;
    private Vector2Int? dragStart;
    private Camera cam;
    private int score;

    private void Start()
    {
        cam = Camera.main;
        board = new int[width, height];
        gems = new Gem[width, height];
        for (int x = 0; x < width; x++)
        {
            for (int y = 0; y < height; y++)
            {
                board[x, y] = -1;
            }
        }

        for (int x = 0; x < width; x++)
        {
            for (int y = 0; y < height; y++)
            {
                board[x, y] = PickTypeWithoutMatch(x, y);
                gems[x, y] = CreateGem(x, y, board[x, y], CellToWorld(x, y));
            }
        }
    }

    private Vector3 CellToWorld(int x, int y)
    {
        return new Vector3(origin.x + x, origin.y + y, 0f);
    }

    private Gem CreateGem(int x, int y, int type, Vector3 position)
    {
        Gem gem = Instantiate(gemPrefab, position, Quaternion.identity, transform);
        gem.SetType(type);
        gem.Cell = new Vector2Int(x, y);
        return gem;
    }

    private void Update()
    {
        if (busy) return;

        Pointer pointer = Pointer.current;
        if (pointer == null) return;

        Vector2 screen = pointer.position.ReadValue();
        Vector3 world = cam.ScreenToWorldPoint(new Vector3(screen.x, screen.y, -cam.transform.position.z));
        Vector2Int cell = new Vector2Int(Mathf.RoundToInt(world.x - origin.x), Mathf.RoundToInt(world.y - origin.y));

        if (pointer.press.wasPressedThisFrame && IsInside(cell))
        {
            dragStart = cell;
        }

        if (dragStart.HasValue && pointer.press.isPressed && cell != dragStart.Value)
        {
            Vector2Int delta = cell - dragStart.Value;
            Vector2Int dir = Mathf.Abs(delta.x) > Mathf.Abs(delta.y)
                ? new Vector2Int((int)Mathf.Sign(delta.x), 0)
                : new Vector2Int(0, (int)Mathf.Sign(delta.y));
            Vector2Int target = dragStart.Value + dir;
            Vector2Int from = dragStart.Value;
            dragStart = null;

            if (IsInside(target))
            {
                _ = SwapAndResolve(from, target);
            }
        }

        if (pointer.press.wasReleasedThisFrame)
        {
            dragStart = null;
        }
    }

    private bool IsInside(Vector2Int c)
    {
        return c.x >= 0 && c.y >= 0 && c.x < width && c.y < height;
    }

    private void SwapData(Vector2Int a, Vector2Int b)
    {
        (board[a.x, a.y], board[b.x, b.y]) = (board[b.x, b.y], board[a.x, a.y]);
        (gems[a.x, a.y], gems[b.x, b.y]) = (gems[b.x, b.y], gems[a.x, a.y]);
        gems[a.x, a.y].Cell = a;
        gems[b.x, b.y].Cell = b;
    }
}

O arraste converte a posição do ponteiro para célula, e quando o dedo sai da célula inicial, a direção dominante (horizontal ou vertical) define a vizinha. Isso aceita arrastes tortos e nunca troca na diagonal. A classe é partial para dividir o código em arquivos menores; os outros pedaços ficam em arquivos com outro nome, todos declarando public partial class Board. Só o arquivo Board.cs precisa ter o nome da classe.

Detectar combinações

A busca percorre cada linha e cada coluna contando sequências do mesmo tipo. Quando uma sequência termina com 3 ou mais, marca todas as células num bool[,]. Marcar em vez de remover na hora permite pegar combinações em L e em T, em que uma célula pertence a uma linha e a uma coluna ao mesmo tempo.

Arquivo BoardMatches.cs:

using System.Collections.Generic;
using UnityEngine;

public partial class Board
{
    private bool[,] FindMatches()
    {
        bool[,] matched = new bool[width, height];

        for (int y = 0; y < height; y++)
        {
            int run = 1;
            for (int x = 1; x <= width; x++)
            {
                bool same = x < width && board[x, y] != -1 && board[x, y] == board[x - 1, y];
                if (same) { run++; continue; }
                if (run >= 3)
                {
                    for (int k = x - run; k < x; k++) matched[k, y] = true;
                }
                run = 1;
            }
        }

        for (int x = 0; x < width; x++)
        {
            int run = 1;
            for (int y = 1; y <= height; y++)
            {
                bool same = y < height && board[x, y] != -1 && board[x, y] == board[x, y - 1];
                if (same) { run++; continue; }
                if (run >= 3)
                {
                    for (int k = y - run; k < y; k++) matched[x, k] = true;
                }
                run = 1;
            }
        }

        return matched;
    }

    private static int CountTrue(bool[,] grid)
    {
        int count = 0;
        foreach (bool b in grid) if (b) count++;
        return count;
    }

    private int PickTypeWithoutMatch(int x, int y)
    {
        List<int> options = new List<int>();
        for (int t = 0; t < gemTypes; t++)
        {
            bool leftMatch = x >= 2 && board[x - 1, y] == t && board[x - 2, y] == t;
            bool downMatch = y >= 2 && board[x, y - 1] == t && board[x, y - 2] == t;
            if (!leftMatch && !downMatch) options.Add(t);
        }
        return options[Random.Range(0, options.Count)];
    }
}

O laço vai até x <= width (uma posição além do fim) de propósito: é o jeito de fechar a última sequência da linha sem repetir o código de checagem depois do laço.

Com troca e detecção funcionando, você tem a mecânica que define o gênero. O que separa isso de um match-3 publicado é muito trabalho em volta: fases com objetivo, peças especiais, tela de vitória, efeitos, som, progressão e build otimizada para celular. Cada camada mexe no fluxo de cascata, e é aqui que quem faz sozinho costuma travar.

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

Remover, cair e repor

Arquivo BoardResolve.cs:

using System.Collections.Generic;
using UnityEngine;

public partial class Board
{
    private async Awaitable SwapAndResolve(Vector2Int a, Vector2Int b)
    {
        busy = true;

        SwapData(a, b);
        await AnimateToCells(swapDuration);

        bool[,] matched = FindMatches();
        if (CountTrue(matched) == 0)
        {
            SwapData(a, b);
            await AnimateToCells(swapDuration);
            busy = false;
            return;
        }

        int combo = 1;
        while (CountTrue(matched) > 0)
        {
            score += CountTrue(matched) * 10 * combo;
            RemoveMatched(matched);
            await Awaitable.WaitForSecondsAsync(0.1f, destroyCancellationToken);

            CollapseAndRefill();
            await AnimateToCells(fallDuration);

            matched = FindMatches();
            combo++;
        }

        busy = false;
    }

    private void RemoveMatched(bool[,] matched)
    {
        for (int x = 0; x < width; x++)
        {
            for (int y = 0; y < height; y++)
            {
                if (!matched[x, y]) continue;
                Destroy(gems[x, y].gameObject);
                gems[x, y] = null;
                board[x, y] = -1;
            }
        }
    }

    private void CollapseAndRefill()
    {
        for (int x = 0; x < width; x++)
        {
            int writeY = 0;
            for (int y = 0; y < height; y++)
            {
                if (board[x, y] == -1) continue;
                if (y != writeY)
                {
                    board[x, writeY] = board[x, y];
                    gems[x, writeY] = gems[x, y];
                    gems[x, writeY].Cell = new Vector2Int(x, writeY);
                    board[x, y] = -1;
                    gems[x, y] = null;
                }
                writeY++;
            }

            int spawnOffset = 0;
            for (int y = writeY; y < height; y++)
            {
                int type = Random.Range(0, gemTypes);
                board[x, y] = type;
                Vector3 spawnPos = CellToWorld(x, height + spawnOffset);
                gems[x, y] = CreateGem(x, y, type, spawnPos);
                spawnOffset++;
            }
        }
    }

    private async Awaitable AnimateToCells(float duration)
    {
        List<Gem> moving = new List<Gem>();
        List<Vector3> starts = new List<Vector3>();
        foreach (Gem gem in gems)
        {
            if (gem == null) continue;
            Vector3 target = CellToWorld(gem.Cell.x, gem.Cell.y);
            if ((gem.transform.position - target).sqrMagnitude < 0.0001f) continue;
            moving.Add(gem);
            starts.Add(gem.transform.position);
        }

        float t = 0f;
        while (t < 1f)
        {
            t = Mathf.Min(1f, t + Time.deltaTime / duration);
            float eased = 1f - (1f - t) * (1f - t);
            for (int i = 0; i < moving.Count; i++)
            {
                Vector3 target = CellToWorld(moving[i].Cell.x, moving[i].Cell.y);
                moving[i].transform.position = Vector3.LerpUnclamped(starts[i], target, eased);
            }
            await Awaitable.NextFrameAsync(destroyCancellationToken);
        }
    }
}

A queda usa a técnica do "ponteiro de escrita": para cada coluna, percorre de baixo para cima e copia cada peça existente para a posição writeY, que só avança quando encontra peça. Os buracos sobem naturalmente para o topo. Depois, as células de writeY até o topo recebem peças novas, criadas acima do tabuleiro (height + spawnOffset) para que caiam para dentro da tela em vez de surgirem do nada.

O AnimateToCells move de uma vez todas as gemas que estão fora do lugar, com uma curva de desaceleração. Como a lógica já atualizou Cell de cada gema, a animação só precisa ir de onde a gema está até onde ela deveria estar.

Sobre Awaitable: é a classe do Unity 6 para escrever sequências com async e await. Awaitable.NextFrameAsync espera um frame e WaitForSecondsAsync espera um tempo, com o mesmo papel do yield return null e do WaitForSeconds em coroutines. O destroyCancellationToken cancela a espera se o tabuleiro for destruído, por exemplo ao trocar de cena. A documentação avisa que instâncias de Awaitable são reaproveitadas pela Unity e não devem ser aguardadas duas vezes no mesmo método. Se você prefere coroutines, o fluxo é idêntico com IEnumerator e yield return StartCoroutine(...), como mostra o guia de coroutines e WaitForSeconds.

Cascata e pontuação

A cascata é o while (CountTrue(matched) > 0) no SwapAndResolve. Depois de cada queda, a busca roda de novo; se as peças novas ou as que caíram formaram combinação, o laço remove, derruba e repõe outra vez. A variável combo cresce a cada volta e multiplica os pontos: 10 por peça na primeira combinação, 20 na segunda cascata, 30 na terceira. É essa multiplicação que faz a cascata parecer um prêmio.

Cuidado ao balancear: a reposição usa Random.Range puro, sem a proteção do tabuleiro inicial. Isso é intencional, porque as cascatas espontâneas são parte da graça. Se elas acontecerem demais, reduza para 5 tipos de gema em vez de 6 e veja o efeito contrário: menos tipos, mais cascatas.

Tabuleiro sem jogada possível

Depois da cascata, pode acontecer de nenhuma troca formar combinação. O jogador fica olhando para o tabuleiro sem saber que não há saída. A checagem é força bruta e barata num 8x8: para cada célula, simule a troca com a vizinha da direita e com a de cima no array (SwapData sem animar), rode FindMatches, desfaça a troca e pare no primeiro resultado positivo. São no máximo 112 trocas testadas, nada que pese num frame.

Se nenhuma troca funciona, embaralhe: recolha os tipos, sorteie de novo com a mesma regra do tabuleiro inicial e anime as gemas trocando de lugar. Esse mesmo teste serve para a dica que muitos jogos mostram depois de alguns segundos parados: a primeira troca válida encontrada é a que pisca na tela.

Erros comuns: input durante animação

  • Clicar durante a animação quebra o tabuleiro. É o erro número um. A flag busy vira true no começo do SwapAndResolve e só volta a false depois da última cascata. O Update retorna logo na primeira linha enquanto ela está ligada.
  • Troca sem combinação fica feita. Faltou desfazer: trocar de novo os dados e animar de volta.
  • Combinação em L conta peças duas vezes. Remover na hora da busca em vez de marcar num bool[,].
  • Peças surgindo no lugar em vez de cair. As novas foram criadas na célula final. Crie acima do tabuleiro e deixe a animação levar.
  • Erro de referência nula depois de trocar de cena. A sequência async continuou rodando sem o tabuleiro. Passe destroyCancellationToken para as esperas.
  • Arraste em diagonal trocando peças erradas. Use a direção dominante do arraste, não a célula final.

Próximo passo

Acrescente a peça especial de 4 em linha: quando uma sequência tem exatamente 4, em vez de remover a peça do meio, transforme-a numa gema que limpa a linha inteira quando combinada. Isso exige guardar o tamanho de cada sequência no FindMatches, um bom exercício de estender a busca sem quebrá-la. Para comparar a mesma mecânica em outra engine, veja o match-3 na Godot.

Perguntas frequentes

Como fazer um jogo tipo Candy Crush na Unity?

Guarde o tabuleiro num array 2D de tipos de peça, separado dos GameObjects visuais. A cada troca, procure sequências de 3 ou mais iguais nas linhas e colunas, remova, faça as peças de cima caírem, preencha o topo com peças novas e repita a busca até não haver mais combinação. A troca que não forma combinação é desfeita.

Como evitar combinações prontas no começo do match-3?

Ao preencher cada célula, sorteie entre os tipos que não completam três iguais com as duas peças à esquerda nem com as duas abaixo. Como o preenchimento vai da esquerda para a direita e de baixo para cima, só essas duas direções importam. O tabuleiro nasce limpo sem precisar sortear de novo.

Coroutine ou async para animar o match-3 na Unity 6?

Os dois funcionam. Coroutines são o caminho clássico com yield return. No Unity 6, a classe Awaitable permite escrever a mesma sequência com async e await, o que deixa o fluxo de troca, remoção, queda e cascata mais legível. Só não aguarde a mesma instância de Awaitable duas vezes, porque elas são reaproveitadas pela Unity.

Por que meu match-3 quebra quando clico rápido?

Porque o jogador consegue iniciar uma troca enquanto a anterior ainda está animando, e as duas mexem no mesmo array. Use uma flag de ocupado que bloqueia o input do começo da troca até o fim da última cascata.