Voltar para o Blog
Quest Log

A* no Godot com AStarGrid2D: Pathfinding para Jogos em Grid

Tabuleiro em grade visto de cima com um caminho iluminado desviando de obstáculos

Aprenda pathfinding com A* no Godot usando AStarGrid2D: setup, integração com TileMap, custo de terreno e movimento em grade com código pronto.

A* no Godot com AStarGrid2D: Pathfinding para Jogos em Grid

Se o seu jogo se move em células (tático por turnos, roguelike, tower defense, qualquer coisa com tabuleiro), implementar A* no Godot não exige escrever o algoritmo na mão. O Godot 4 traz a classe AStarGrid2D pronta: você diz o tamanho da grade, marca o que é parede, e ela devolve o caminho célula por célula. Sem heap, sem lista aberta, sem reinventar nada.

Esse tutorial monta o fluxo completo que eu uso: criar a grade, conectar com um TileMapLayer, dar custo diferente pra cada terreno (lama custa mais que estrada), escolher como tratar diagonais e mover um personagem pelo caminho. Todo código é GDScript do Godot 4.x e roda como está.

Quando usar AStarGrid2D (e quando não)

O Godot tem três jeitos de fazer pathfinding, e escolher o errado gera retrabalho:

AStarGrid2D: pra movimento em grade. O mapa é uma matriz de células, o personagem anda de célula em célula. É o caso de jogo tático, puzzle, roguelike, tower defense. É a opção mais simples e mais rápida pra esse formato, porque a vizinhança entre células já é implícita: você não cadastra conexões, só marca o que é sólido.

AStar2D/AStar3D: pra grafos arbitrários. Você cadastra cada ponto e cada conexão na mão. Útil quando o mapa não é uma grade (waypoints espalhados, rotas de patrulha, mapa de mundo com estradas). Se o seu mapa é grade, usar AStar2D é trabalho extra pra chegar no mesmo lugar.

NavigationAgent + NavigationRegion: pra movimento livre e contínuo, sem células. Inimigo que persegue o player em qualquer direção, NPC desviando de obstáculo em tempo real. É outro problema, com outro custo.

Regra prática: o personagem ocupa uma célula e se move pra célula vizinha? AStarGrid2D. Move livre pelo espaço? Navegação. O resto é exceção.

Setup do A* no Godot: a grade mínima

O ciclo de vida da AStarGrid2D tem três passos: configurar, chamar update(), usar. O update() é obrigatório depois de mexer em region, cell_size ou offset; sem ele a grade não existe internamente e qualquer consulta falha.

var astar := AStarGrid2D.new()

func _ready():
    # A região da grade em células: começa em (0,0), 16 de largura, 9 de altura.
    astar.region = Rect2i(0, 0, 16, 9)
    # Tamanho de cada célula em pixels (usado pra converter célula -> posição).
    astar.cell_size = Vector2(64, 64)
    astar.update()

    # Marca uma parede.
    astar.set_point_solid(Vector2i(5, 3))

    # Caminho em coordenadas de célula:
    print(astar.get_id_path(Vector2i(0, 0), Vector2i(10, 5)))
    # Caminho em posições no mundo (multiplica pela cell_size):
    print(astar.get_point_path(Vector2i(0, 0), Vector2i(10, 5)))

Os dois métodos de consulta resolvem o mesmo caminho, mudando só a unidade da resposta. get_id_path() devolve um array de Vector2i com coordenadas de célula, que é o que você quer em jogo por turnos. get_point_path() devolve um PackedVector2Array com posições em pixels, prático quando o personagem vai deslizar pelo caminho.

Duas armadilhas que pegam todo mundo na primeira vez:

  • Consultar célula fora da região quebra com erro. Antes de pedir caminho pra onde o jogador clicou, valide com astar.is_in_boundsv(celula).
  • Origem ou destino sólido devolve caminho vazio. Clicou na parede? get_id_path() retorna array vazio. Trate esse caso em vez de assumir que sempre vem caminho.

Conectando a grade com um TileMapLayer

Mapa de verdade não é hardcoded: ele vem do TileMap. A ideia é gerar a grade a partir do que já está pintado no editor, usando uma camada de dados customizados do TileSet pra dizer o que é andável.

No TileSet, crie uma Custom Data Layer chamada andavel do tipo bool. Marque true nos tiles de chão e deixe false em parede, água, pedra. Com isso o level designer controla o pathfinding pintando tiles, sem tocar em código.

O script lê tudo no _ready():

@export var tile_map: TileMapLayer

var astar := AStarGrid2D.new()

func _ready():
    # A região e o tamanho de célula vêm do próprio TileMap.
    astar.region = tile_map.get_used_rect()
    astar.cell_size = Vector2(tile_map.tile_set.tile_size)
    astar.update()

    for celula in tile_map.get_used_cells():
        var dados := tile_map.get_cell_tile_data(celula)
        if dados == null or not dados.get_custom_data("andavel"):
            astar.set_point_solid(celula)

Repare que get_used_rect() pode não começar em (0,0). Se o mapa foi pintado a partir da célula (-8, -4), a região acompanha, e as consultas usam as mesmas coordenadas de célula do TileMap. É por isso que vale sempre converter posição de mundo pra célula com local_to_map() em vez de dividir por tamanho de tile na mão.

Pra fechar quadrados grandes de parede de uma vez (a borda do mapa, por exemplo), existe o atalho fill_solid_region():

astar.fill_solid_region(Rect2i(0, 0, 16, 1))  # linha inteira do topo

E quando o mapa muda em runtime (porta que abre, ponte que cai), basta atualizar o ponto:

astar.set_point_solid(celula_da_porta, false)  # abriu, virou andável

Não precisa chamar update() de novo pra isso. O update() só é necessário quando região, tamanho de célula ou offset mudam.

Próximo nível
Quer aprender isso na prática?

No CursoGame.Dev você sai dos tutoriais soltos e constrói jogos publicáveis, com trilha progressiva, quests práticas e feedback real.

Conhecer a plataforma
+500 alunos4.9/5Garantia 7 dias

Custo de terreno: lama, estrada e weight scale

Aqui está o recurso que separa pathfinding básico de pathfinding de jogo tático. Nem toda célula andável custa igual: atravessar lama é mais lento que andar na estrada, e o caminho "mais curto" em células nem sempre é o melhor. O A* resolve isso com peso por célula.

# Custo padrão de cada célula é 1.0.
# Lama: custa o triplo. O A* só passa por ela se compensar muito.
astar.set_point_weight_scale(Vector2i(4, 3), 3.0)

Com peso 3.0, entrar naquela célula equivale a andar três células normais. O algoritmo continua achando o caminho de menor custo total, então ele contorna a lama quando o desvio é barato e atravessa quando o desvio sai mais caro. Você não programa essa decisão: ela emerge do custo.

O jeito limpo de alimentar isso é a mesma técnica do andavel: uma Custom Data Layer custo do tipo float no TileSet, com 1.0 no chão comum, 3.0 na lama, 0.5 na estrada. O loop de setup fica assim:

for celula in tile_map.get_used_cells():
    var dados := tile_map.get_cell_tile_data(celula)
    if dados == null or not dados.get_custom_data("andavel"):
        astar.set_point_solid(celula)
    else:
        astar.set_point_weight_scale(celula, dados.get_custom_data("custo"))

Um detalhe de design que aprendi testando: diferenças pequenas de peso (1.0 contra 1.2) quase não mudam os caminhos e o jogador não percebe. Pra terreno "sentir" diferente, use contrastes francos, tipo 1.0 contra 3.0. E estrada com custo abaixo de 1.0 é um truque ótimo pra fazer unidades preferirem rotas naturais sem nenhum script de comportamento.

Diagonais e heurísticas

Por padrão a AStarGrid2D permite movimento diagonal, e isso nem sempre é o que o jogo pede. A propriedade diagonal_mode tem quatro opções:

# Jogo tático clássico, só 4 direções:
astar.diagonal_mode = AStarGrid2D.DIAGONAL_MODE_NEVER

# Diagonal liberada sempre (padrão):
astar.diagonal_mode = AStarGrid2D.DIAGONAL_MODE_ALWAYS

# Diagonal só se não cortar quina de parede:
astar.diagonal_mode = AStarGrid2D.DIAGONAL_MODE_ONLY_IF_NO_OBSTACLES

O modo ONLY_IF_NO_OBSTACLES merece destaque: ele impede aquele visual estranho de personagem atravessando a quina de uma parede na diagonal. Em quase todo jogo com diagonal eu uso esse em vez do ALWAYS. Existe ainda o DIAGONAL_MODE_AT_LEAST_ONE_WALKABLE, um meio-termo que exige só um dos lados livre.

A heurística (a estimativa de distância que guia a busca) também é configurável via default_compute_heuristic e default_estimate_heuristic. A regra é casar com o movimento: HEURISTIC_MANHATTAN pra grade sem diagonal, HEURISTIC_OCTILE pra grade com diagonal (é o padrão e funciona bem), HEURISTIC_EUCLIDEAN quando o resultado vai virar movimento contínuo. Se você não tem motivo pra mexer, o padrão serve.

Última propriedade dessa família: jumping_enabled = true ativa uma otimização (Jump Point Search) que acelera bastante a busca em mapas grandes e abertos. O porém é direto: ela ignora os pesos de célula. Se o seu jogo usa custo de terreno, deixe desligada. É um ou outro.

Movendo o personagem pelo caminho

Caminho calculado é só um array. O movimento de tabuleiro clássico (anda uma célula, para, anda a próxima) fica limpo com tween:

extends Node2D

@export var tile_map: TileMapLayer

var astar := AStarGrid2D.new()
var caminho: Array[Vector2i] = []
var movendo := false

func _unhandled_input(event):
    if event is InputEventMouseButton and event.pressed \
            and event.button_index == MOUSE_BUTTON_LEFT:
        var clique := tile_map.to_local(get_global_mouse_position())
        var destino := tile_map.local_to_map(clique)
        var origem := tile_map.local_to_map(tile_map.to_local(global_position))

        if not astar.is_in_boundsv(destino) or astar.is_point_solid(destino):
            return

        caminho = astar.get_id_path(origem, destino)
        if not caminho.is_empty():
            caminho.pop_front()  # a primeira célula é onde já estamos

func _process(_delta):
    if movendo or caminho.is_empty():
        return
    movendo = true
    var proxima: Vector2i = caminho.pop_front()
    # map_to_local devolve o centro da célula, em coordenadas do TileMap.
    var alvo := tile_map.to_global(tile_map.map_to_local(proxima))
    var tween := create_tween()
    tween.tween_property(self, "global_position", alvo, 0.15)
    tween.finished.connect(func(): movendo = false)

Dois pontos valem atenção. Primeiro, o caminho inclui a célula de origem, por isso o pop_front() logo depois de calcular. Segundo, map_to_local() devolve o centro do tile, então o personagem para alinhado sem nenhuma conta extra de offset.

Pra movimento deslizante em vez de passo a passo, troque a fila de células por get_point_path() e siga os pontos com velocidade constante num CharacterBody2D. A estrutura é a mesma, muda só a interpolação.

Performance e recálculo

A boa notícia: pra mapa de jogo típico (algumas dezenas por algumas dezenas de células), o custo de um get_id_path() é desprezível e você pode recalcular sempre que o destino muda sem pensar duas vezes.

O que evitar é recalcular sem motivo. Recalcular caminho de vinte inimigos todo frame é desperdício clássico: o caminho só muda quando o mapa muda ou o alvo muda de célula. Guarde a célula do alvo no último cálculo e só refaça quando ela for outra. Em jogo por turnos, então, o recálculo acontece uma vez por ação e performance simplesmente não é assunto.

Pra mapa gigante e aberto sem custo de terreno, jumping_enabled resolve. Pra mapa gigante com custo de terreno, o caminho costuma ser reduzir a frequência de recálculo e espalhar os pedidos entre frames, não otimizar o A* em si.

Fechando

O fluxo inteiro cabe numa frase: região e célula vêm do TileMap, andavel e custo vêm de Custom Data do TileSet, set_point_solid() e set_point_weight_scale() montam a grade, get_id_path() devolve o caminho e um tween anda por ele. Nenhuma etapa exige entender a matemática do A*, mas todas ficam melhores quando você entende o que peso e heurística significam.

Se quiser fixar, monte um tabuleiro pequeno com três terrenos (chão, lama, estrada) e clique pra mover. Depois mude os pesos e veja o caminho se redesenhar sozinho. Ver o algoritmo trocar de rota porque a lama ficou cara é o momento em que isso deixa de ser API decorada e vira ferramenta sua.