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:
- Se está no meio de um passo, ignora o input.
- Lê o input e reduz para uma das 4 direções.
- Calcula a célula vizinha nessa direção.
- Se a célula é parede ou tem algo sólido, não anda (mas vira para lá).
- 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 oVector3Intda 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 layerPushable, das caixas.blockingMask: a layerBlocking, 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.
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.



