Voltar para o Blog
Quest Log

Movimento em Grade na Unity: Estilo Pokémon e Puzzle

Personagem em pixel art visto de cima andando sobre um piso quadriculado, empurrando uma caixa de madeira em direção a um alvo marcado no chão

Unity grid movement em C#: passo de célula em célula com coroutine, input travado, parede checada antes de andar, alinhamento ao Tilemap e caixa de Sokoban.

Ferramenta:UnityC#

Movimento em Grade na Unity: Estilo Pokémon e Puzzle

Unity grid movement é o movimento em que o personagem anda de uma célula para a vizinha, sempre parando no centro de um tile, como em Pokémon, Zelda de Game Boy ou qualquer puzzle de empurrar caixas. A lógica tem quatro peças: converter posição em célula com Grid.WorldToCell, checar se a célula de destino está livre antes de andar, mover com uma coroutine de passo e bloquear o input enquanto o passo acontece. Este tutorial monta as quatro em C# para o Unity 6.3 LTS e fecha com uma variação de Sokoban, com caixa empurrável.

A diferença para o movimento livre é que aqui a física não decide nada. O script sabe exatamente onde cada coisa está e só deixa o personagem ir para onde é permitido. Isso deixa o comportamento previsível, que é o que jogo de puzzle precisa.

Unity grid movement: como funciona

A cena usa o sistema de Tilemap da Unity:

Grid  (componente Grid, Cell Size 1 x 1)
├── Ground  (Tilemap, só visual)
├── Walls   (Tilemap, paredes)
Player
Box (várias)

O componente Grid define o tamanho das células. Com Cell Size (1, 1, 0) e sprites importados com Pixels Per Unit igual ao tamanho do tile (16 para tiles de 16x16), cada tile ocupa exatamente uma célula. Os Tilemaps Ground e Walls são filhos do mesmo Grid, então uma coordenada de célula significa o mesmo lugar nos dois. Se o Tilemap em si é novidade, o tutorial de Tilemap na Unity cobre paleta, pintura e camadas.

O ciclo de um passo:

  1. Se está no meio de um passo, ignora o input.
  2. Lê o input e reduz para uma das 4 direções.
  3. Calcula a célula vizinha nessa direção.
  4. Se a célula é parede ou tem algo sólido, não anda (mas vira para lá).
  5. Se está livre, desliza do centro da célula atual até o centro da vizinha em um tempo fixo.

As três funções do Grid que fazem o trabalho:

  • WorldToCell(Vector3): devolve o Vector3Int da célula que contém aquela posição do mundo.
  • CellToWorld(Vector3Int): devolve a posição do canto de origem da célula (o canto inferior esquerdo, num grid retangular padrão).
  • GetCellCenterWorld(Vector3Int): devolve o centro da célula.

Confundir as duas últimas é o erro mais comum: usar CellToWorld para posicionar o personagem faz tudo ficar deslocado meio tile.

Mover de uma célula para outra

Crie GridMover.cs no Player:

using System.Collections;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.Tilemaps;

public class GridMover : MonoBehaviour
{
    [Header("Grid")]
    [SerializeField] private Grid grid;
    [SerializeField] private Tilemap wallTilemap;

    [Header("Colisão")]
    [SerializeField] private LayerMask blockingMask;
    [SerializeField] private LayerMask pushableMask;

    [Header("Passo")]
    [SerializeField] private float stepDuration = 0.18f;

    private InputAction moveAction;
    private Vector3Int currentCell;
    private bool isMoving;

    public Vector2Int Facing { get; private set; } = Vector2Int.down;
    public bool IsMoving => isMoving;

    private void Awake()
    {
        moveAction = InputSystem.actions.FindAction("Move");
        currentCell = grid.WorldToCell(transform.position);
        transform.position = grid.GetCellCenterWorld(currentCell);
    }

    private void Update()
    {
        if (isMoving)
        {
            return;
        }

        Vector2Int direction = ToCardinal(moveAction.ReadValue<Vector2>());
        if (direction == Vector2Int.zero)
        {
            return;
        }

        Facing = direction;
        TryMove(direction);
    }

    private Vector2Int ToCardinal(Vector2 input)
    {
        if (input.sqrMagnitude < 0.25f)
        {
            return Vector2Int.zero;
        }

        if (Mathf.Abs(input.x) > Mathf.Abs(input.y))
        {
            return new Vector2Int(input.x > 0f ? 1 : -1, 0);
        }

        return new Vector2Int(0, input.y > 0f ? 1 : -1);
    }

    private void TryMove(Vector2Int direction)
    {
        Vector3Int targetCell = currentCell + new Vector3Int(direction.x, direction.y, 0);

        if (IsWall(targetCell))
        {
            return;
        }

        Vector3 targetWorld = grid.GetCellCenterWorld(targetCell);

        Collider2D pushable = Physics2D.OverlapPoint(targetWorld, pushableMask);
        if (pushable != null)
        {
            if (!pushable.TryGetComponent(out PushableBox box) || !box.TryPush(direction))
            {
                return;
            }
        }
        else if (Physics2D.OverlapPoint(targetWorld, blockingMask) != null)
        {
            return;
        }

        StartCoroutine(StepRoutine(targetCell));
    }

    private bool IsWall(Vector3Int cell)
    {
        return wallTilemap != null && wallTilemap.HasTile(cell);
    }

    private IEnumerator StepRoutine(Vector3Int targetCell)
    {
        isMoving = true;

        Vector3 start = transform.position;
        Vector3 end = grid.GetCellCenterWorld(targetCell);
        float elapsed = 0f;

        while (elapsed < stepDuration)
        {
            elapsed += Time.deltaTime;
            float t = Mathf.Clamp01(elapsed / stepDuration);
            transform.position = Vector3.Lerp(start, end, t);
            yield return null;
        }

        transform.position = end;
        currentCell = targetCell;
        isMoving = false;
    }
}

No Inspector, arraste o Grid e o Tilemap Walls. Os LayerMasks entram na seção de paredes e caixas.

A coroutine StepRoutine interpola a posição de start a end em stepDuration segundos e, no fim, crava a posição exata do centro. Esse "cravar" é importante: somar Time.deltaTime nunca dá exatamente stepDuration, e sem a linha transform.position = end o personagem acumularia um erro minúsculo a cada passo até sair do alinhamento.

Com 0.18 segundo por passo, o personagem anda cerca de 5,5 tiles por segundo, um ritmo próximo ao de RPGs de 16 bits. Para correr, basta reduzir o stepDuration enquanto um botão estiver apertado.

Bloquear input durante o passo

O if (isMoving) return; no topo do Update é o que transforma movimento livre em movimento em grade. Durante os 0,18 segundo do passo, nada do input é lido. Quando o passo termina, isMoving volta a falso e, se o jogador ainda está segurando a direção, o próximo Update já começa outro passo. O resultado é o andar contínuo de Pokémon: segurou, anda tile por tile sem parar; soltou, para no próximo centro.

Por que não usar WasPressedThisFrame em vez de ler o valor contínuo? Porque aí o jogador teria que apertar uma vez por tile. Em puzzle por turnos isso pode ser o desejado; em RPG, cansa em dois minutos.

A conversão ToCardinal descarta diagonais escolhendo o eixo dominante e ignora inclinações pequenas do analógico (sqrMagnitude < 0.25, ou seja, menos da metade do curso). Sem esse limiar, um analógico com folga faz o personagem andar sozinho. Se a matemática de comparar eixos e comprimentos ainda não está firme, o post de vetores para jogos explica com desenhos.

Checar parede antes de andar

O script usa duas checagens, cada uma para um tipo de obstáculo:

Cenário fixo: Tilemap.HasTile. Paredes pintadas no Tilemap Walls são consultadas direto pelos dados do tile: wallTilemap.HasTile(targetCell) devolve verdadeiro se existe um tile naquela célula. Não depende de collider, de física nem de layer. É rápido e exato. O único requisito é que Walls seja filho do mesmo Grid que o script usa para calcular células.

Objetos que se movem: Physics2D.OverlapPoint. Caixas, NPCs, portas fechadas não estão no Tilemap. Para eles, o script pergunta à física se existe algum collider exatamente no centro da célula de destino, filtrando por LayerMask:

  • pushableMask: a layer Pushable, das caixas.
  • blockingMask: a layer Blocking, de NPCs e objetos sólidos que não se empurram.

Cada objeto desses precisa de um Collider2D (um BoxCollider2D de 0,9 x 0,9 já basta). Se ele se move, adicione também um Rigidbody2D com Body Type Kinematic, que é a forma correta de mover um collider sem custo extra.

Atenção se você colocar Tilemap Collider 2D com Composite Collider 2D nas paredes e quiser usar OverlapPoint nelas: no Composite Collider 2D, o Geometry Type Outlines gera só o contorno, e um ponto no meio do tile não encosta em nada. Ou use Polygons, ou faça como o script e consulte o Tilemap.

Movimento em grade é a base de RPG, puzzle e roguelike por turnos, e cada um desses gêneros pede sistemas em volta: inimigos que andam no mesmo grid, turnos, desfazer jogada, diálogo, transição entre mapas. Fazer isso tudo conversar sem que um sistema quebre o outro é onde muita gente para. É esse tipo de jogo completo, montado peça por peça, que o curso de Unity trabalha.

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

Alinhar ao Tilemap

O Awake faz um alinhamento inicial: converte a posição do personagem para célula e o coloca no centro dela. Isso significa que você pode soltar o Player em qualquer lugar da cena no editor, e no Play ele se encaixa na célula mais próxima.

Para posicionar no editor já alinhado, segure Ctrl (Cmd no Mac) enquanto arrasta o objeto: a Unity aplica o snap de incremento definido em Edit > Grid and Snap Settings. Com o snap em 1 e o pivô do sprite no centro, ainda fica meio tile fora; ajuste a posição para valores terminados em .5 (como 3,5 e 2,5), que são os centros das células num grid de origem zero.

Se o sprite do personagem tem o pivô nos pés (comum para personagens altos), o centro da célula vai ficar nos pés, o que costuma ser o desejado. Só lembre de manter o mesmo pivô em todos os objetos do grid.

Empurrar caixa estilo Sokoban

A regra de Sokoban: o jogador empurra uma caixa se a célula atrás dela estiver livre; caixa não empurra caixa. Crie PushableBox.cs na caixa (com BoxCollider2D, Rigidbody2D Kinematic e layer Pushable):

using System.Collections;
using UnityEngine;
using UnityEngine.Tilemaps;

public class PushableBox : MonoBehaviour
{
    [SerializeField] private Grid grid;
    [SerializeField] private Tilemap wallTilemap;
    [SerializeField] private LayerMask occupiedMask;
    [SerializeField] private float stepDuration = 0.18f;

    private bool isMoving;

    public bool TryPush(Vector2Int direction)
    {
        if (isMoving)
        {
            return false;
        }

        Vector3Int currentCell = grid.WorldToCell(transform.position);
        Vector3Int targetCell = currentCell + new Vector3Int(direction.x, direction.y, 0);

        if (wallTilemap != null && wallTilemap.HasTile(targetCell))
        {
            return false;
        }

        Vector3 targetWorld = grid.GetCellCenterWorld(targetCell);
        if (Physics2D.OverlapPoint(targetWorld, occupiedMask) != null)
        {
            return false;
        }

        StartCoroutine(SlideRoutine(transform.position, targetWorld));
        return true;
    }

    private IEnumerator SlideRoutine(Vector3 start, Vector3 end)
    {
        isMoving = true;
        float elapsed = 0f;

        while (elapsed < stepDuration)
        {
            elapsed += Time.deltaTime;
            transform.position = Vector3.Lerp(start, end, Mathf.Clamp01(elapsed / stepDuration));
            yield return null;
        }

        transform.position = end;
        isMoving = false;
    }
}

O occupiedMask da caixa inclui Pushable e Blocking: assim ela não entra numa célula com outra caixa nem com um NPC. Como a caixa e o jogador usam o mesmo stepDuration, os dois deslizam juntos e o empurrão parece físico.

Para o objetivo do puzzle, marque as células-alvo num Tilemap Goals e, depois de cada empurrão, confira se todas as caixas estão sobre um tile de Goals com HasTile(grid.WorldToCell(box.position)).

Virar sem andar e animar o passo

Em Pokémon, um toque rápido na direção só vira o personagem; segurar faz andar. Dá para reproduzir com um pequeno atraso: quando a direção pedida é diferente de Facing, o script só atualiza Facing e guarda o instante; o passo só começa se o jogador continuar segurando por mais uns 0,08 segundo. Isso deixa o jogador olhar para um NPC ou uma placa sem dar um passo para dentro dela.

Para a animação, o GridMover já expõe Facing e IsMoving. No Animator, use dois Blend Trees 2D (parado e andando) com parâmetros FaceX e FaceY alimentados por Facing, e um Bool Moving vindo de IsMoving. Como o passo tem duração fixa, ajuste a velocidade do clip de andar para que um ciclo de pernas caiba em dois passos; assim o pé de apoio alterna a cada tile, como nos RPGs clássicos.

Erros comuns

Tudo fica meio tile deslocado. Uso de CellToWorld em vez de GetCellCenterWorld, ou pivô do sprite diferente entre objetos.

O personagem atravessa a parede. O Tilemap Walls não está no mesmo Grid do script, ou o campo Wall Tilemap está vazio.

OverlapPoint não detecta a caixa. A caixa está numa layer que não está no LayerMask, ou não tem Collider2D.

O personagem anda sozinho. Analógico com folga. Aumente o limiar do ToCardinal.

Sai do alinhamento depois de muitos passos. Falta a linha que crava transform.position = end no fim da coroutine.

Dá para andar na diagonal. O input não está passando por ToCardinal.

Próximo passo

Com o grid funcionando, construa uma fase de Sokoban de 8x8 com três caixas e três alvos e teste se dá para resolver. Depois adicione um botão de desfazer: guardar a lista de posições antes de cada passo é um ótimo exercício de estrutura de dados.

Para ver a mesma mecânica em outra engine, o post de movimento em grade no Godot resolve com TileMapLayer e Tween, e comparar as duas versões ajuda a separar o que é conceito do que é API.

Perguntas frequentes

Como fazer movimento em grade estilo Pokémon na Unity?

Converta a posição do personagem para célula com Grid.WorldToCell, calcule a célula vizinha na direção do input, confira se ela está livre e mova até o centro dela com uma coroutine. Enquanto o passo acontece, ignore novos inputs.

Qual a diferença entre CellToWorld e GetCellCenterWorld?

CellToWorld devolve o canto de origem da célula, que num grid padrão é o canto inferior esquerdo. GetCellCenterWorld devolve o centro. Para posicionar personagens e objetos, use o centro, senão tudo fica deslocado meio tile.

Como checar se tem parede antes de andar no grid?

Para paredes pintadas num Tilemap, use wallTilemap.HasTile(celula). Para objetos que se movem, como caixas e NPCs, use Physics2D.OverlapPoint no centro da célula com um LayerMask. Os dois juntos cobrem cenário fixo e objetos dinâmicos.

Movimento em grade precisa de Rigidbody2D?

O personagem não precisa: como o script confere a célula antes de andar, a física não precisa resolver colisões. Objetos que serão detectados por OverlapPoint precisam de Collider2D, e os que se movem ficam melhores com um Rigidbody2D Kinematic.